ArchUnit
모든 프로젝트에는 아키텍처가 존재하고 개발자는 이를 준수해야 한다. 별도의 문서를 통해 가이드할 수도 있지만, 코드 레벨에서 아키텍처를 파악하고 또 준수하고 있는지 테스트까지 해볼 수 있는 도구가 바로 ArchUnit이다. 아래 의존성 하나로 JUnit5와 통합하여 테스트를 진행할 수 있다.
dependencies {
testImplementation 'com.tngtech.archunit:archunit-junit5:0.23.1'
}
아키텍처 검증
아래 구조와 같이 전형적인 Hexagonal Architecture를 준수하는 프로젝트라고 가정 해보자.
.
└── com
└── example
└── archunitsample
├── ArchunitSampleApplication.java
└── user
├── adapter
│ ├── in
│ │ └── rest
│ │ ├── UserController.java
│ │ └── dto
│ │ └── CreateUserDto.java
│ └── out
│ └── persistence
│ ├── SaveUserAdapter.java
│ ├── entity
│ │ └── UserEntity.java
│ └── repository
│ └── UserRepository.java
├── application
│ ├── port
│ │ ├── in
│ │ │ └── CreateUserUseCase.java
│ │ └── out
│ │ └── SaveUserPort.java
│ └── service
│ └── CreateUserService.java
└── domain
└── User.java
먼저 헥사고날 아키텍처의 가장 큰 특징은 모든 의존성이 안쪽으로 향한다는 것이다. 다시 말하면 코어 기능을 담당하는 패키지(application, domain)에서는 아키텍처 상 외부 패키지(adapter)에 대한 참조가 존재해서는 안된다는 것이다.
class ArchitectureTest {
@Test
@DisplayName("코어 의존성 테스트")
void domainDependencyTest() {
JavaClasses classes = new ClassFileImporter().importPackages("com.example.archunitsample");
ArchRule coreDependencyRule = noClasses()
.that().resideInAnyPackage("..application..", "..domain..")
.should().accessClassesThat().resideInAPackage("..adapter..");
coreDependencyRule.check(classes);
}
}
이를 ArchUnit API를 직접 low level로 호출하는 것이 아니라 JUnit5와 통합하면 아래와 같이 코드를 간소화할 수 있다.
@AnalyzeClasses(packagesOf = ArchunitSampleApplication.class)
class ArchitectureTest {
// 1. application, domain에서는 외부 패키지 호출이 불가하다.
@ArchTest
public static final ArchRule domainDependencyRule = noClasses()
.that().resideInAnyPackage("..application..", "..domain..")
.should().accessClassesThat().resideInAPackage("..adapter..");
}
그리고 흔히 실수하는 내용으로, persitence 계층의 JPA Entity를 API Response에 사용하는 등 다른 계층에서 사용하는 경우가 있는데 이 케이스도 persistence 계층을 고립시킴으로써 해결할 수 있다.
@AnalyzeClasses(packagesOf = ArchunitSampleApplication.class)
class ArchitectureTest {
// 1. application, domain에서는 외부 패키지 호출이 불가하다.
@ArchTest
public static final ArchRule domainDependencyRule = noClasses()
.that().resideInAnyPackage("..application..", "..domain..")
.should().accessClassesThat().resideInAPackage("..adapter..");
// 2. persistence.entity 패키지는 persistence 패키지 내에서만 사용된다.
@ArchTest
public static final ArchRule persistenceEntityIsolationRule = classes()
.that().resideInAnyPackage("..out.persistence.entity..")
.should().onlyBeAccessed().byAnyPackage("..out.persistence..");
}
마지막으로 port 패키지에 대한 의존성 검증을 추가한다.
@AnalyzeClasses(packagesOf = ArchunitSampleApplication.class)
class ArchitectureTest {
// 1. application, domain에서는 외부 패키지 호출이 불가하다.
@ArchTest
public static final ArchRule domainDependencyRule = noClasses()
.that().resideInAnyPackage("..application..", "..domain..")
.should().accessClassesThat().resideInAPackage("..adapter..");
// 2. persistence.entity 패키지는 persistence 패키지 내에서만 사용된다.
@ArchTest
public static final ArchRule persistenceEntityIsolationRule = classes()
.that().resideInAnyPackage("..out.persistence.entity..")
.should().onlyBeAccessed().byAnyPackage("..out.persistence..");
// 3. port.in 패키지의 클래스는 adapter.in 패키지에서 의존한다.
@ArchTest
public static final ArchRule inputPortDependencyRule = classes()
.that().resideInAPackage("..application.port.in..")
.should().onlyAccessClassesThat().resideInAPackage("..adapter.in..");
// 4. port.out 패키지의 클래스는 application.service 패키지에서 의존한다.
@ArchTest
public static final ArchRule outputPortDependencyRule = classes()
.that().resideInAnyPackage("..application.port.out..")
.should().onlyAccessClassesThat().resideInAnyPackage("..application.service..");
}
더 많은 기능
// 어노테이션 검사
// EntityManager를 상속하는 클래스들은 Transactional 어노테이션을 포함해야 한다.
classes().that().areAssignableTo(EntityManager.class)
.should().onlyHaveDependentClassesThat().areAnnotatedWith(Transactional.class)
// 의존성 사이클 체크
// com.myapp 패키지 내부에서 의존성 사이클이 생성되는지 검증한다.
slices().matching("com.myapp.(*)..").should().beFreeOfCycles()
// Layerd Architecture 검증
layeredArchitecture()
.layer("Controller").definedBy("..controller..")
.layer("Service").definedBy("..service..")
.layer("Persistence").definedBy("..persistence..")
.whereLayer("Controller").mayNotBeAccessedByAnyLayer()
.whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
.whereLayer("Persistence").mayOnlyBeAccessedByLayers("Service")
// Onion Architecture 검증
onionArchitecture()
.domainModels("com.myapp.domain.model..")
.domainServices("com.myapp.domain.service..")
.applicationServices("com.myapp.application..")
.adapter("cli", "com.myapp.adapter.cli..")
.adapter("persistence", "com.myapp.adapter.persistence..")
.adapter("rest", "com.myapp.adapter.rest..");
위 코드처럼 어노테이션에 대한 검증, 의존 관계에서 사이클이 나타나는지에 대한 검증, 특정한 아키텍처 조건을 만족하는가와 같은 기능들 뿐만아니라 다양한 커스텀 컨벤션을 만들어갈 수 있다.
살펴본 바로는 프로젝트 아키텍처에 필요한 대부분의 제약 조건을 구현할 수 있는 것으로 보인다. 기능이 풍부하다고해서 너무 strict하게 구현했을 때는 또 다른 문제가 생길 수 있겠지만, 잘 사용한다면 여러 개발자들 사이에서 일관된 아키텍처를 유지하면서 생산성 또한 향상시키는데 도움이될 듯 하다.
참고