들어가며
헥사고날 아키텍처(Hexagonal Architecture, 일명 Ports and Adapters)의 핵심은 단순합니다.
의존성은 항상 안쪽(도메인)으로만 향한다.
- 도메인은 어떤 외부 레이어도 알면 안 된다.
- 애플리케이션은 어댑터의 구현체가 아닌 포트(인터페이스)만 알아야 한다.
- 어댑터끼리는 서로 직접 의존하지 않는다.
TechMoa 프로젝트에서는 이 규칙을 Gradle 멀티모듈 구조로 먼저 막고 있습니다.
domain 모듈은 어떤 모듈도 implementation하지 않고, application은 domain만, presentation은 domain과 application만 의존하는 식입니다.
그렇다면 Gradle만으로 충분하지 않나요?
- 누군가
infrastructure:jpa에implementation(project(":presentation"))를 추가해버리면? → 컴파일은 성공합니다. - 새 모듈을 추가하면서 의존성 방향을 잘못 설정하면? → 리뷰에서 놓치면 끝입니다.
- 모듈 내부에서 패키지 단위 규칙까지 강제하고 싶다면? → Gradle은 거기까진 모릅니다.
그래서 도입한 게 ArchUnit 입니다.
ArchUnit은 JVM 클래스파일을 읽어 의존 관계를 추출하고, 그것을 테스트 코드로 검증해주는 라이브러리입니다. 따라서 런타임 시점에 아키텍처 규칙 체크를 강제할 수 있습니다.
이번 글에서는 TechMoa의 헥사고날 레이어 의존성 규칙 4가지를 ArchUnit으로 강제하는 예시를 정리합니다.
본문
사전 준비: 의존성 추가
테스트는 모든 모듈을 implementation으로 묶고 있는 boot 모듈에 작성합니다.
이렇게 하면 ArchUnit이 모든 서브모듈의 클래스를 한 번에 훑을 수 있습니다.
// boot/build.gradle.kts
dependencies {
// ... 기존 의존성 testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0")}
헥사고날 의존성 규칙 4가지
@AnalyzeClasses(
packages = ["site.techmoa"],
importOptions = [ImportOption.DoNotIncludeTests::class],
)
class HexagonalArchitectureTest {
@ArchTest
val `domain은 외부 레이어를 의존하지 않는다` =
noClasses()
.that().resideInAPackage("site.techmoa.domain..")
.should().dependOnClassesThat()
.resideInAnyPackage(
"site.techmoa.application..",
"site.techmoa.presentation..",
"site.techmoa.infrastructure..",
"site.techmoa.worker..",
)
.because("도메인은 헥사고날 아키텍처의 가장 안쪽 레이어로, 외부 어댑터나 애플리케이션 서비스를 알아서는 안 된다.")
@ArchTest
val `application은 어댑터 레이어를 의존하지 않는다` =
noClasses()
.that().resideInAPackage("site.techmoa.application..")
.should().dependOnClassesThat()
.resideInAnyPackage(
"site.techmoa.presentation..",
"site.techmoa.infrastructure..",
"site.techmoa.worker..",
)
.because("애플리케이션 레이어는 포트(인터페이스)만 알아야 하며, 어댑터 구현을 직접 의존하면 안 된다.")
@ArchTest
val `presentation 어댑터는 infrastructure 어댑터를 의존하지 않는다` =
noClasses()
.that().resideInAPackage("site.techmoa.presentation..")
.should().dependOnClassesThat()
.resideInAPackage("site.techmoa.infrastructure..")
.because("어댑터 간 직접 의존은 헥사고날의 격리 원칙을 깨뜨린다. 어댑터 간 협력은 application 레이어를 통해 이루어져야 한다.")
@ArchTest
val `infrastructure 어댑터는 presentation 어댑터를 의존하지 않는다` =
noClasses()
.that().resideInAPackage("site.techmoa.infrastructure..")
.should().dependOnClassesThat()
.resideInAPackage("site.techmoa.presentation..")
.because("어댑터 간 직접 의존은 헥사고날의 격리 원칙을 깨뜨린다. 어댑터 간 협력은 application 레이어를 통해 이루어져야 한다.")
}
문법 설명
@AnalyzeClasses
@AnalyzeClasses(
packages = ["site.techmoa"], importOptions = [ImportOption.DoNotIncludeTests::class],)
packages: 분석 대상 루트 패키지. 여기 지정한 패키지 이하의 모든 클래스를 메모리에 올립니다.importOptions: 분석 대상에서 제외할 영역.DoNotIncludeTests는 테스트 클래스를 제외합니다. 그렇지 않으면 테스트끼리의 의존성도 규칙에 걸려 거짓 양성이 발생할 수 있습니다.
@ArchTest val
@ArchTest 애너테이션이 붙은 val 필드는 ArchUnit JUnit 러너가 자동으로 검출하여 테스트로 실행합니다.
Kotlin의 백틱 식별자 덕분에 규칙 이름을 한글 문장으로 그대로 쓸 수 있어 테스트 리포트의 가독성이 좋아집니다.
noClasses().that(...).should(...) DSL 읽는 법
noClasses()
.that().resideInAPackage("site.techmoa.domain..") .should().dependOnClassesThat() .resideInAnyPackage("site.techmoa.application..", ...)
영어 문장으로 그대로 읽으면 의미가 분명해집니다.
No classes that reside in
site.techmoa.domain..
should depend on classes that reside in any package
[application.., presentation.., infrastructure.., worker..]
= “site.techmoa.domain.. 에 속한 클래스 중 → 저 4개 패키지를 의존하는 클래스는 → 단 하나도 없어야 한다.”
noClasses()는 금지문이고, 함께 자주 쓰는 classes().should(...)는 의무문입니다.
| DSL | 의미 |
|---|---|
noClasses().that(X).should(Y) | X인 클래스는 Y하면 안 된다 |
classes().that(X).should(Y) | X인 클래스는 반드시 Y해야 한다 |
resideInAPackage 와일드카드
..(양쪽 끝): 임의의 하위 패키지 포함.site.techmoa.domain..=site.techmoa.domain,site.techmoa.domain.model,site.techmoa.domain.model.user.*전부*: 한 단계만 매치- 정규식이 아닌 ArchUnit 전용 와일드카드 표기입니다.
.because("...")
테스트 실패 시 출력 메시지에 함께 노출되는 사유입니다. 단순한 주석을 넘어 “왜 이 규칙이 존재하는가”를 코드에 남겨두는 효과가 있습니다.
나중에 이 테스트가 실패했을 때, 규칙의 의도를 빠르게 파악할 수 있는 단서가 됩니다.
정리
- 헥사고날 아키텍처의 의존성 규칙은 Gradle 모듈 의존성만으로 완벽히 강제하기 어렵습니다.
- ArchUnit은 클래스파일 단위 의존성을 분석해 아키텍처 규칙을 테스트로 고정해줍니다.
noClasses().that(...).should(...)DSL은 영어 문장처럼 읽혀, 규칙의 의도가 코드에 그대로 드러납니다.- Kotlin 백틱 식별자 +
@ArchTest val조합으로 한글 규칙 이름을 자연스럽게 쓸 수 있습니다. .because("...")절은 단순한 주석이 아니라 규칙의 의도를 남기는 장치입니다. 적극적으로 활용하세요.- 패키지 와일드카드(
..pkg..)는 의도보다 넓게 매치될 수 있습니다. 멀티모듈에서는 절대 경로를 명시하세요.
이번 글에서는 의존성 방향에 대한 4개 규칙만 다뤘지만, ArchUnit은 그 외에도 다양한 규칙을 지원합니다.
- 도메인 순수성:
domain패키지가org.springframework..,jakarta.persistence..를 의존하지 않도록 - 네이밍 규칙:
Controller접미사를 가진 클래스는presentation.controller패키지에만 위치 - 포트-어댑터 매칭: 모든
application.port..인터페이스는infrastructure..의 구현체로 연결되어야 함 - 순환 의존성 금지:
slices().matching(...).should().beFreeOfCycles()
댓글