아키텍처 규칙은 대부분 문서에만 있다. “슬라이스 사이에는 순환 의존을 두지 않는다”고 적어 놓고, 코드 리뷰에서 걸러 내기를 기대한다. 그런데 리뷰어가 놓치면 그대로 들어가고, 한 번 들어간 순환 의존은 시간이 지날수록 걷어 내기 어려워진다
결론부터 말하면 이런 규칙은 테스트 코드로 강제할 수 있다. ArchUnit이 슬라이스 단위 순환 의존 검증을 기본으로 제공하고, 기본 제공에 없는 규칙은 확장해서 만들 수 있다. 이 글은 버티컬 슬라이스라는 구분 단위를 정의하고, 그 사이의 의존 규칙 두 가지를 테스트로 옮긴 기록이다
이 글은 토비님의 스프링 백엔드 강의에서 다루는 온라인 학습 서비스 예제 프로젝트
Splearn을 따라 구현하면서, 강의 내용과 직접 작성한 코드를 함께 정리한 것이다. 개념과 설계 판단의 근거는 강의를 따르되, 코드와 해석은 직접 구현한 저장소 기준이다
이 글에서 자주 나오는 용어
- 레이어(Layer): 기술적 역할로 시스템을 가로로 나눈 단위. 어댑터, 애플리케이션 서비스, 도메인이 여기 해당한다
- 버티컬 슬라이스(Vertical Slice): 기능을 중심으로 시스템을 세로로 잘라 낸 단위. 레이어와 직교한다
- 애그리거트(Aggregate): 함께 변경되어야 하는 엔티티들의 묶음. 불변식을 지켜야 하고 한 번에 로딩되어 한 번에 변경되는 단위다
- 순환 의존성(Circular Dependency): 모듈 의존 관계를 따라가다 보면 출발점으로 돌아오는 구조
- DIP(의존 관계 역전 원칙): 인터페이스의 소유권을 사용하는 쪽에 두어 코드 의존 방향을 뒤집는 설계 원칙
- ArchUnit: 패키지 의존 방향 같은 아키텍처 규칙을 테스트 코드로 검증하는 자바 라이브러리
레이어는 가로로, 슬라이스는 세로로 자른다
레이어는 시스템을 구분하는 방법 중 하나일 뿐이다. 같은 시스템을 다른 방향으로도 나눌 수 있다
회원 │ 강사 │ 강의
┌─────────────┼────────────┼────────────┐
Adapter │ │ │ │
├─────────────┼────────────┼────────────┤
Application│ │ │ │
Service │ │ │ │
├─────────────┼────────────┼────────────┤
Domain │ │ │ │
└─────────────┴────────────┴────────────┘
↑ 가로선은 레이어 ↑ 세로선은 슬라이스
헥사고날 아키텍처를 육각형이나 원으로 그리지 않고 계층형처럼 눕혀 그리면 이 구분이 잘 드러난다. 가로로 자르면 레이어가 되고, 세로로 자르면 슬라이스가 된다. 슬라이스는 계층과 상관없이 “회원과 관련된 것 전체”를 하나로 묶는다
슬라이스를 나누는 핵심은 기능 단위로 변경이 일어나는 경계를 정하는 것이다. 개념적으로 이름을 붙여 자를 수도 있지만, 슬라이스가 중요한 이유는 변경과 직결되기 때문이다. 어떤 요구사항이 바뀌면 함께 수정되는 코드가 무엇인지를 생각하고 그 코드들을 묶어 둔다. 이건 소프트웨어의 기본 원칙인 응집도를 설명하는 것과 같은 말이다. 변경이 함께 일어나는 것들을 한 모듈에 두는 게 응집도가 높은 코드다
슬라이스가 잘 잡히면 세 가지가 따라온다. 하나의 기능을 독립적으로 이해하고 테스트할 수 있다. 다른 슬라이스의 내부 구현을 직접 참조하지 않는다. 협업이 필요하면 인터페이스를 통해서만 한다. 헥사고날에서 포트라고 부르는 것이 여기서도 그대로 쓰인다
슬라이스 기준은 애그리거트와 애플리케이션 서비스다
헥사고날 아키텍처와 도메인 모델 패턴을 적용한 프로젝트라면 슬라이스 기준을 정하기가 쉽다
도메인 계층에서는 애그리거트가 좋은 기준이다. 애그리거트는 애초에 불변식을 지켜야 하고 한 번에 로딩되어 한 번에 변경되는 단위로 정의된다. 변경 경계라는 슬라이스의 조건과 정확히 겹친다. 애플리케이션 서비스도 각각 하나의 슬라이스로 볼 수 있다. 그래서 이 프로젝트의 개발 가이드에는 이렇게 적혀 있다
- domain 패키지 아래 첫 번째 패키지는 각각 하나의 애그리거트이다. - application 패키지 아래 첫 번째 패키지는 같은 이름의 애그리거트에 대한 애플리케이션 서비스 패키지이다 - 위에서 구분한 패키지는 슬라이스(slice)라고 부른다 - 하나의 도메인과 애플리케이션 서비스 슬라이스를 묶어 애플리케이션 컴포넌트라고 한다
슬라이스를 무엇으로 자를지를 코드가 아니라 문서에 명시해 둔 것이다. 뒤에서 ArchUnit 규칙을 쓸 때 이 문장이 그대로 패턴이 된다
현재 패키지 구조는 이렇다
kimspring.splearn
├── domain
│ ├── member // 회원 애그리거트 → 슬라이스
│ ├── instructor // 강사 애그리거트 → 슬라이스
│ └── shared // 공유 값 객체 → 슬라이스로 취급
└── application
├── member // 회원 애플리케이션 서비스 → 슬라이스
└── instructor // 강사 애플리케이션 서비스 → 슬라이스
shared는 애그리거트가 아니다. Email처럼 여러 애그리거트가 공통으로 쓰는 값 객체를 담는 곳이다. 그런데 의존 관계를 따질 때는 하나의 슬라이스로 취급해도 문제가 없다. shared는 의존을 받기만 하고 다른 슬라이스를 의존하지 않기 때문이다. 의존을 받기만 하는 대상이 하나 있으면 단방향 원칙을 오히려 잘 지킬 수 있다
순환 의존성이 만드는 문제는 네 가지다
순환 의존성은 A가 B를, B가 C를, C가 다시 A를 의존하는 구조다. 더 단순한 형태는 A와 B가 서로를 의존하는 양방향 의존이다. 모듈이 아무리 많아도 의존 관계를 따라가다 출발점으로 돌아오면 순환 의존성이 있는 것이다
A ──→ B A ──→ B ↑ │ ↑ │ └── C ┘ └─────┘ 3단계 순환 양방향 (2단계 순환)
여기서 생기는 문제는 네 가지다
모듈을 독립적으로 이해할 수 없다. B를 변경하면 C가 영향을 받고, C는 A에, A는 다시 B에 영향을 준다. 내가 한 변경이 돌고 돌아 나에게 돌아온다. 이 변경이 어디까지 영향을 주는지 파악하려면 고리에 걸린 모든 모듈을 따라가야 한다
독립적으로 테스트하거나 교체할 수 없다. 모듈에 데이터를 넣고 기능을 호출했는데 그 실행이 순환을 타고 자기 자신에게 돌아온다면, 분리된 상태로 시뮬레이션하는 게 불가능해질 수 있다. 애플리케이션 전체를 띄우지 않으면 정확히 테스트할 수 없는 상태가 된다
책임과 소유권이 불명확해진다. 모듈이 커지면 팀이 나뉜다. 다른 팀이 변경한 게 복잡한 의존 관계를 타고 우리 팀 모듈에 영향을 준다. 팀 간 경계가 흐려지면 갈등이 생긴다
변경이 무서워진다. 레거시 시스템을 손대기 어려운 이유가 대부분 여기에 있다. 어디 한 군데 건드리면 예상하지 못한 곳에서 문제가 터지므로 아무도 고치려 하지 않는다
반대로 단방향 의존성을 지키면 변경 영향의 방향을 예측할 수 있고, 모듈을 독립적으로 개발·테스트할 수 있으며, 하위 모듈을 재사용하거나 교체하기 쉬워진다. 그리고 장기적으로 시스템을 분리하고 확장하기가 쉬워진다. Gradle 멀티모듈로 쪼개거나, 더 나아가 마이크로서비스로 분리할 때 단방향 의존성이 있으면 경계가 명확하다
이걸 가장 잘 보여주는 사례가 스프링 프레임워크 자체다. 스프링 1.0에는 자바 패키지 단위의 순환 의존성이 있었다. 1.1을 개발하던 무렵 패키지 레벨의 순환 의존성이 성장에 방해가 된다고 판단하고 전부 제거해 단방향 의존만 남겼다. 그 덕분에 2점대로 올라가면서 모듈을 세밀하게 쪼갤 수 있었다. 그전에는 코어와 MVC 정도로만 나뉘어 있었는데, 기능이 늘면서 파일이 너무 커지자 상세 모듈로 분리해 필요한 것만 가져다 쓸 수 있게 만들었다. ORM을 안 쓰면 관련 모듈을 빼는 식이다. 이 분리는 코드 레벨 변경 없이 상위 패키지 단위의 패키징만 바꿔서 가능했다
beFreeOfCycles 한 줄이면 순환 검증이 끝난다
기존 아키텍처 테스트에는 레이어 사이 의존 방향을 검증하는 규칙이 이미 있었다
@AnalyzeClasses(packages = "kimspring.splearn", importOptions = ImportOption.DoNotIncludeTests.class)
public class HexagonalArchitectureTest {
@ArchTest
void hexagonalArchitecture(JavaClasses classes) {
Architectures.layeredArchitecture()
.consideringAllDependencies()
.layer("domain").definedBy("kimspring.splearn.domain..")
.layer("application").definedBy("kimspring.splearn.application..")
.layer("adapter").definedBy("kimspring.splearn.adapter..")
.whereLayer("domain").mayOnlyBeAccessedByLayers("application", "adapter")
.whereLayer("application").mayOnlyBeAccessedByLayers("adapter")
.whereLayer("adapter").mayNotBeAccessedByAnyLayer()
.check(classes);
}
}
레이어는 패키지로 정의하고, 각 레이어를 누가 접근할 수 있는지를 선언한다. 도메인은 애플리케이션과 어댑터가 접근할 수 있고, 애플리케이션은 어댑터만 접근할 수 있고, 어댑터는 아무도 접근할 수 없다. 여기에 슬라이스 규칙을 추가한다
@ArchTest
void aggregateFreeOfCycles(JavaClasses classes) {
SlicesRuleDefinition.slices()
.matching("kimspring.splearn.domain.(*)..")
.should().beFreeOfCycles()
.check(classes);
}
핵심은 matching()의 패턴이다. domain. 뒤의 (*) 자리에 들어가는 이름이 하나의 슬라이스가 되고, 그 뒤의 ..은 하위 패키지 전부를 포함한다는 뜻이다. 즉 domain.member와 그 아래 모든 클래스가 member 슬라이스가 되고, 같은 방식으로 instructor와 shared가 잡힌다. 슬라이스가 늘어나도 이 규칙은 그대로 둬도 된다
beFreeOfCycles()는 그 슬라이스들 사이에 순환이 없어야 한다는 규칙이다. 메서드 이름 그대로 읽힌다. @ArchTest를 붙이고 파라미터로 JavaClasses를 받으면 ArchUnit이 클래스를 탐색해 넘겨 준다
애플리케이션 서비스 계층도 같은 방식이다. 패키지 경로만 바꾼다
@ArchTest
void applicationServiceFreeOfCycles(JavaClasses classes) {
SlicesRuleDefinition.slices()
.matching("kimspring.splearn.application.(*)..")
.should().beFreeOfCycles()
.check(classes);
}
이제 세 가지 검증이 함께 돈다
hexagonalArchitecture → 레이어 사이 단방향 aggregateFreeOfCycles → 도메인 슬라이스 사이 순환 없음 applicationServiceFreeOfCycles → 애플리케이션 슬라이스 사이 순환 없음
통과하는 테스트는 아직 검증된 게 아니다
이 테스트를 실행하면 통과한다. 현재 의존 방향이 이렇기 때문이다
instructor ──→ member ──→ shared
Instructor가 Member를 필드로 갖고 있으므로 강사가 회원을 의존한다. 반대 방향은 없다. shared는 현재 member에서만 의존받고 있고, 강사가 별도 이메일을 갖게 되면 instructor도 shared를 의존하게 된다. 어느 쪽이든 shared는 의존을 받기만 하므로 순환은 생기지 않는다
그런데 통과했다는 사실만으로는 이 테스트가 문제를 잡아낼 수 있는지 알 수 없다. 규칙이 잘못 쓰여서 아무것도 검사하지 않고 있어도 통과한다. 그래서 일부러 위반 케이스를 만들어 실패를 확인해야 한다
Member에 Instructor 타입 필드를 하나 추가하면 된다. 이미 Instructor가 Member를 의존하고 있으므로 양방향이 되고, 순환 의존이 성립한다. 실행하면 테스트가 실패하면서 어느 슬라이스가 어느 슬라이스를 의존하는지, 구체적으로 어떤 필드 때문인지까지 알려 준다. 필드가 아니라 메서드 파라미터로 쓰거나 메서드 안에서 지역 변수로 써도 똑같이 잡힌다
이 확인 절차를 거치기 전까지 검증 테스트는 검증되지 않은 상태다
애그리거트 간 호출 제한은 직접 만들어야 한다
두 번째 규칙은 기본 제공에 없다. 이 프로젝트의 개발 가이드에는 이렇게 적혀 있다
애그리거트는 다른 애그리거트 루트를 직접 참조할 수 있다. 조회 메서드(getter 등)와 레코드 메서드 사용 가능. 단, 다른 애그리거트를 변경하는 메서드는 사용할 수 없다.
직접 참조 자체는 허용한다. Instructor가 Member를 필드로 갖는 건 문제가 없다. 문제는 그 참조를 타고 다니면서 변경 메서드를 호출하는 것이다. member.activate()나 member.deactivate()를 강사 쪽 코드에서 마음대로 호출하면 애그리거트의 경계가 무너진다. 변경이 필요하면 애플리케이션 서비스를 거쳐 해당 애그리거트를 관리하는 컴포넌트에 요청해야 한다.
ArchUnit에 이 규칙을 그대로 담은 메서드는 없다. 대신 확장 지점이 있다. ArchCondition 추상 클래스를 상속해 조건을 직접 구현하면 된다.
@ArchTest
void aggregateDependencies(JavaClasses classes) {
SlicesRuleDefinition.slices()
.matching("kimspring.splearn.domain.(*)..")
.should(onlyCallGettersOrRecordMethodsOfOtherSlices())
.check(classes);
}
슬라이스를 잡는 방식은 앞과 같고, should()에 직접 만든 조건을 넘긴다. 조건 구현은 이렇다
private <SLICE extends Set<JavaClass>> ArchCondition<SLICE> onlyCallGettersOrRecordMethodsOfOtherSlices() {
return new ArchCondition<>("다른 슬라이스의 getter 또는 레코드, Enum 메소드만 호출할 수 있다") {
private final Set<JavaClass> classesInAnySlice = new HashSet<>();
@Override
public void init(Collection<SLICE> allSlice) {
allSlice.forEach(classesInAnySlice::addAll);
}
@Override
public void check(SLICE slice, ConditionEvents events) {
for (JavaClass javaClass : slice) {
for (JavaMethodCall call : javaClass.getMethodCallsFromSelf()) {
JavaClass targetOwner = call.getTargetOwner();
if (slice.contains(targetOwner)) continue;
if (!classesInAnySlice.contains(targetOwner)) continue;
if (targetOwner.isRecord()) continue;
if (targetOwner.isEnum()) continue;
String methodName = call.getTarget().getName();
if (methodName.startsWith("get") || methodName.startsWith("is")
|| methodName.startsWith("ensure")) continue;
events.add(SimpleConditionEvent.violated(call, call.getDescription()));
}
}
}
};
}
복잡해 보이지만 개념을 그대로 옮긴 코드다. 생성자에 넘긴 문자열은 규칙 위반 시 나오는 설명이다. 보통 영어로 쓰는데, should() 뒤에 붙어 문장처럼 읽히기 때문이다. 여기서는 이해하기 편하도록 한글로 적었다
init()은 검사 시작 전에 한 번 실행된다. 전체 슬라이스에 포함된 모든 클래스를 Set 하나에 모아 둔다. 이걸 왜 만드는지는 check()를 보면 드러난다
check()는 슬라이스 하나씩 호출된다. 슬라이스가 세 개면 세 번 실행된다. 안에서 이중 루프를 돌면서 슬라이스 안의 모든 클래스를 꺼내고, 각 클래스가 호출하는 메서드를 전부 가져온다. 그리고 네 단계로 걸러 낸다
① 같은 슬라이스의 메서드인가 → 통과 (얼마든지 호출 가능) ② 슬라이스에 속하지 않는 클래스인가 → 통과 (자바 표준, 스프링 등) ③ 레코드 또는 이넘의 메서드인가 → 통과 (상태를 변경하지 않는다) ④ get / is / ensure 로 시작하는가 → 통과 (조회 및 상태 확인) 그 외 → 위반
첫 번째 필터는 자기 슬라이스 내부 호출을 걸러 낸다. Member가 MemberDetail의 메서드를 호출하는 건 애그리거트 안의 일이므로 문제가 없다. 두 번째 필터가 init()에서 모아 둔 집합을 쓰는 곳이다. 우리가 잡으려는 건 다른 슬라이스의 메서드 호출인데, 자바 라이브러리나 스프링의 메서드 호출은 상관없다. 슬라이스에 속하지 않은 클래스면 검사 대상에서 뺀다
여기까지 내려온 호출은 전부 “다른 슬라이스 클래스의 메서드 호출”이다. 세 번째와 네 번째 필터가 허용 조건이다. 레코드는 내부 속성을 변경하지 않으므로 어떤 메서드든 안전하다. 레코드는 getter 규약을 따르지 않고 임의의 이름을 쓰기 때문에 이름으로 거를 수 없어서 타입으로 판단한다. 이넘도 같은 이유다. 마지막으로 일반 클래스라면 메서드 이름이 조회를 뜻하는 접두사로 시작하는지를 본다. 전부 통과하지 못하면 SimpleConditionEvent.violated()로 위반 이벤트를 추가하고, 그 순간 테스트가 실패한다
위반 케이스를 넣다가 이넘 예외를 발견했다
이 조건도 실패를 확인해야 한다. 강사 슬라이스에서 회원 슬라이스의 메서드를 호출하는 코드를 넣어 봤다
member.getName()은 통과한다. get으로 시작하므로 허용 조건에 걸린다. 그런데 member.getStatus().name()을 넣으니 위반으로 잡혔다. getStatus()까지는 괜찮지만 그 결과인 MemberStatus 이넘의 name()이 걸린 것이다. 이넘은 그 자체로 속성이 변경되는 메서드가 없으므로 허용해야 한다. 이 예외가 빠져 있었고, 위반 사례를 넣어 보다가 드러났다
// 이넘 예외를 추가하기 전 if (targetOwner.isRecord()) continue; // 추가한 뒤 if (targetOwner.isRecord()) continue; if (targetOwner.isEnum()) continue;
준비 단계에서 통과만 확인했다면 이 구멍은 실제 코드에서 이넘을 쓰는 순간 잘못된 실패로 나타났을 것이다. 위반 케이스를 넣어 보는 절차가 규칙 자체의 결함을 찾아 준 셈이다
반면 member.activate()를 넣으면 그대로 위반으로 잡힌다. 허용 조건 어디에도 걸리지 않기 때문이다. 의도한 동작이다
이 프로젝트의 실제 코드에는 강의에 없던 허용 조건이 하나 더 있다. ensure로 시작하는 메서드다. Instructor.ensureActive()처럼 상태를 확인하고 조건이 아니면 예외를 던지는 메서드를 위한 것이다. 값을 반환하지 않으니 get이나 is에 걸리지 않지만 상태를 변경하지도 않는다. 다만 이 세 접두사 중 ensure가 가장 위험하다. get과 is는 자바 관례상 조회를 뜻하는 게 널리 합의되어 있지만, ensure는 그렇지 않다. ensureXxx()라는 이름을 붙이고 안에서 상태를 바꾸는 코드를 짜도 이 규칙은 잡아내지 못한다. 접두사 기반 검사가 갖는 근본적인 한계이고, 팀 안에서 네이밍 규칙을 합의해야 성립하는 조건이다
순환이 필요해 보일 때는 DIP를 쓴다
애그리거트와 컴포넌트가 늘어나면 규칙을 위반해야 할 것 같은 상황이 나온다. 회원 쪽에서 어떤 기능을 하려면 강사 쪽의 무언가가 필요해 보이는 식이다. 이때 쓸 수 있는 방법이 DIP다
이 프로젝트는 이미 같은 문제를 한 번 풀었다. 애플리케이션 서비스는 리포지토리 구현을 사용해야 한다. 실행 흐름은 안에서 밖으로 나간다. 그런데 헥사고날의 의존 방향은 밖에서 안으로만이다. 해결은 인터페이스의 소유권을 옮기는 것이었다. 리포지토리 인터페이스를 어댑터가 아니라 애플리케이션 내부에 두고, 실제 구현만 어댑터에 뒀다
[실행 흐름] ApplicationService ──호출──→ RepositoryImpl (adapter)
[코드 의존] ApplicationService ──→ MemberRepository (application.required)
↑
구현 (adapter)
여기서 말하는 의존 관계는 코드의 의존 관계다. 내가 코드를 변경하면 함께 영향을 받아 변경되는가를 기준으로 판단한다. 실행 흐름과는 다르다. 인터페이스를 안쪽으로 가져오면 실행 흐름은 밖으로 나가지만 코드 의존은 밖에서 안으로만 향한다
같은 원리를 슬라이스 사이에도 쓸 수 있다. SOLID의 D에 해당하는 원칙이고, 의존 관계를 다룰 때 가장 실용적으로 쓰이는 부분이다
규칙 문서는 AI에게도 하네스가 된다
이 규칙들은 코드에만 있는 게 아니라 개발 가이드 문서에도 정리해 뒀다. 팀이 공유하는 원칙이나 표준은 문서로 남기는 편이 좋다. 코드 리뷰에서 문서를 근거로 지적할 수 있고, 새 팀원이 들어오거나 규칙을 다른 팀으로 확장할 때 참고할 수 있기 때문이다
여기에 최근 하나가 더 붙었다. AI에게 코딩을 시킬 때 이 문서가 하네스로 동작한다. 작업을 요청하면서 “이 가이드 문서의 원칙을 지켜서 개발하고, 작성 후에는 이 문서를 기준으로 리뷰를 한 번 더 진행하라”고 하면 결과가 달라진다. 그런 지시 없이 “스프링으로 이걸 개발해 줘”라고만 하면 흔한 안티패턴이 섞인 코드가 나올 가능성이 있고, 이번에는 괜찮았어도 다음에 다시 시키면 스타일이 바뀐다. AI가 갖는 확률적 특성 때문이다. 하네스라고 하면 스킬이나 MCP 같은 것만 떠올리기 쉬운데, 잘 정리된 가이드 문서가 그 역할을 한다
그리고 문서와 ArchUnit 테스트는 역할이 다르다. 문서는 왜 그런 규칙인지를 설명하고, 테스트는 그 규칙이 깨졌는지를 알려 준다. 문서만 있으면 지켜지지 않고, 테스트만 있으면 왜 그런 규칙인지 알 수 없다. 둘 다 있어야 한다
이 방식이 그대로 통하지 않는 조건
강의에서 다루지 않았거나, 실제로 적용하면서 남은 지점이 몇 가지 있다
슬라이스 정의가 패키지 경로에 묶여 있다. matching() 패턴은 domain 바로 아래 패키지를 슬라이스로 잡는다. 애그리거트 두 개를 묶어 더 큰 슬라이스로 다루고 싶거나 패키지 구조를 바꾸면 패턴도 같이 고쳐야 한다. 슬라이스가 패키지와 일대일로 대응한다는 규칙 자체가 이 검증의 전제다
접두사 기반 허용 조건은 우회할 수 있다. get으로 시작하는 메서드 안에서 상태를 바꿔도 이 규칙은 잡지 못한다. 명명 관례를 지킨다는 신뢰 위에서만 동작한다
어댑터 계층에 대한 같은 규칙은 아직 만들지 않았다. 웹 API가 엔티티를 그대로 반환하면 컨트롤러 쪽에서 도메인의 변경 메서드를 호출하는 코드가 들어갈 수 있다. 트랜잭션이 이미 종료된 뒤라 DB에 반영되지는 않지만, 그런 코드가 남아 있는 것 자체가 문제다. 이게 무서워서 엔티티를 못 넘기고 DTO로 전부 바꾸는 건 불필요한 오버헤드다. 같은 방식의 ArchUnit 규칙으로 막으면 된다. 슬라이스 기준으로 쓸 수도 있고, @Controller나 @RestController가 붙은 클래스에서는 엔티티의 get, is, 레코드, 이넘 메서드만 호출할 수 있다는 규칙을 주는 방법도 있다. 앞선 글에서 아무 기능이 없는 @Adapter 애노테이션을 만들어 둔 이유가 여기에 있다
검증 대상은 코드 구조뿐이다. ArchUnit은 “이 슬라이스가 저 슬라이스를 참조하는가”를 판단하지, 그 참조가 도메인 관점에서 타당한지는 판단하지 않는다. 순환이 없다는 것과 설계가 옳다는 것은 다른 얘기다
정리하면 아키텍처 규칙은 문서에 적는 순간이 아니라 테스트로 옮기는 순간부터 강제된다. 그리고 그 테스트는 통과를 확인한 순간이 아니라 일부러 위반해서 실패를 확인한 순간부터 신뢰할 수 있다
출처 – 토비의 클린 스프링 – 도메인 모델 패턴과 헥사고날 아키텍처 Part 2