ArchUnit이란?
ArchUnit은 Java/Kotlin 코드의 아키텍처 규칙을 단위 테스트로 검증하는 라이브러리입니다. 패키지 의존성, 레이어 분리, 네이밍 컨벤션, 어노테이션 사용 규칙 등을 JUnit 테스트로 작성하여 CI/CD에서 자동 검증할 수 있습니다. 코드 리뷰에서 놓치기 쉬운 아키텍처 위반을 컴파일 타임에 잡아줍니다.
의존성 설정
// build.gradle.kts
dependencies {
testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0")
}
패키지 의존성 규칙
레이어드 아키텍처에서 가장 흔한 위반은 하위 레이어가 상위 레이어를 참조하는 것입니다. ArchUnit으로 이를 강제합니다.
@AnalyzeClasses(packages = "com.example.app")
class LayerDependencyTest {
@ArchTest
static final ArchRule controller_should_not_access_repository =
noClasses()
.that().resideInAPackage("..controller..")
.should().accessClassesThat()
.resideInAPackage("..repository..")
.because("컨트롤러는 서비스를 통해서만 데이터에 접근해야 합니다");
@ArchTest
static final ArchRule service_should_not_depend_on_controller =
noClasses()
.that().resideInAPackage("..service..")
.should().dependOnClassesThat()
.resideInAPackage("..controller..")
.because("서비스 레이어는 컨트롤러를 알면 안 됩니다");
@ArchTest
static final ArchRule domain_should_not_depend_on_infrastructure =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("..infrastructure..", "..adapter..")
.because("도메인은 인프라에 의존하면 안 됩니다");
}
레이어드 아키텍처 선언적 검증
ArchUnit은 layeredArchitecture() API로 전체 레이어 규칙을 한 번에 정의할 수 있습니다.
@AnalyzeClasses(packages = "com.example.app")
class ArchitectureLayerTest {
@ArchTest
static final ArchRule layered_architecture = layeredArchitecture()
.consideringAllDependencies()
.layer("Controller").definedBy("..controller..")
.layer("Service").definedBy("..service..")
.layer("Repository").definedBy("..repository..")
.layer("Domain").definedBy("..domain..")
.layer("Config").definedBy("..config..")
.whereLayer("Controller").mayNotBeAccessedByAnyLayer()
.whereLayer("Service").mayOnlyBeAccessedByLayers("Controller", "Config")
.whereLayer("Repository").mayOnlyBeAccessedByLayers("Service")
.whereLayer("Domain").mayOnlyBeAccessedByLayers(
"Service", "Repository", "Controller", "Config");
}
헥사고날 아키텍처 검증
헥사고날 아키텍처에서 가장 중요한 규칙 — 도메인이 어댑터를 모르는 것 — 을 테스트로 강제합니다.
@AnalyzeClasses(packages = "com.example.order")
class HexagonalArchitectureTest {
@ArchTest
static final ArchRule hexagonal = onionArchitecture()
.domainModels("..domain.model..")
.domainServices("..domain.service..")
.applicationServices("..application..")
.adapter("rest", "..adapter.in.rest..")
.adapter("persistence", "..adapter.out.persistence..")
.adapter("messaging", "..adapter.out.messaging..");
@ArchTest
static final ArchRule domain_must_not_use_spring =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAPackage("org.springframework..")
.because("도메인 모델은 Spring 프레임워크에 의존하면 안 됩니다");
@ArchTest
static final ArchRule domain_must_not_use_jpa =
noClasses()
.that().resideInAPackage("..domain.model..")
.should().dependOnClassesThat()
.resideInAnyPackage("jakarta.persistence..", "javax.persistence..")
.because("도메인 모델에 JPA 어노테이션이 있으면 안 됩니다");
}
네이밍 컨벤션 검증
팀의 네이밍 규칙을 테스트로 강제하면, 코드 리뷰에서 컨벤션 관련 피드백이 줄어듭니다.
@AnalyzeClasses(packages = "com.example.app")
class NamingConventionTest {
@ArchTest
static final ArchRule controllers_should_be_suffixed =
classes()
.that().resideInAPackage("..controller..")
.and().areAnnotatedWith(RestController.class)
.should().haveSimpleNameEndingWith("Controller");
@ArchTest
static final ArchRule services_should_be_suffixed =
classes()
.that().resideInAPackage("..service..")
.and().areAnnotatedWith(Service.class)
.should().haveSimpleNameEndingWith("Service");
@ArchTest
static final ArchRule repositories_should_be_suffixed =
classes()
.that().resideInAPackage("..repository..")
.should().haveSimpleNameEndingWith("Repository");
@ArchTest
static final ArchRule dtos_should_be_records =
classes()
.that().resideInAPackage("..dto..")
.and().haveSimpleNameEndingWith("Request")
.should().beRecords()
.because("Request DTO는 record로 작성해야 합니다");
@ArchTest
static final ArchRule exceptions_should_extend_runtime =
classes()
.that().haveSimpleNameEndingWith("Exception")
.should().beAssignableTo(RuntimeException.class)
.because("커스텀 예외는 RuntimeException을 상속해야 합니다");
}
어노테이션 사용 규칙
Spring 어노테이션의 올바른 사용을 강제합니다.
@AnalyzeClasses(packages = "com.example.app")
class AnnotationRuleTest {
@ArchTest
static final ArchRule controllers_should_be_annotated =
classes()
.that().resideInAPackage("..controller..")
.and().haveSimpleNameEndingWith("Controller")
.should().beAnnotatedWith(RestController.class)
.orShould().beAnnotatedWith(Controller.class);
@ArchTest
static final ArchRule no_field_injection =
noFields()
.should().beAnnotatedWith(Autowired.class)
.because("필드 주입 대신 생성자 주입을 사용해야 합니다");
@ArchTest
static final ArchRule transactional_on_service_only =
noClasses()
.that().resideInAPackage("..controller..")
.should().beAnnotatedWith(Transactional.class)
.because("@Transactional은 서비스 레이어에만 사용해야 합니다");
@ArchTest
static final ArchRule entities_should_have_no_arg_constructor =
classes()
.that().areAnnotatedWith(Entity.class)
.should().haveOnlyFinalFields()
.orShould().bePackagePrivate()
.because("JPA 엔티티는 기본 생성자가 필요합니다");
}
순환 의존성 탐지
패키지 간 순환 참조는 코드 복잡도를 급격히 높입니다. ArchUnit으로 원천 차단합니다.
@AnalyzeClasses(packages = "com.example.app")
class CycleDependencyTest {
@ArchTest
static final ArchRule no_package_cycles =
slices().matching("com.example.app.(*)..")
.should().beFreeOfCycles()
.because("패키지 간 순환 의존성은 허용하지 않습니다");
@ArchTest
static final ArchRule no_module_cycles =
slices().matching("com.example.app.(*)..")
.should().notDependOnEachOther()
.because("모듈 간 양방향 의존성은 허용하지 않습니다");
}
커스텀 룰 작성
기본 API로 표현하기 어려운 규칙은 ArchCondition을 직접 구현합니다.
public class CustomArchConditions {
// 퍼블릭 메서드에 반드시 로깅이 있어야 한다
public static ArchCondition<JavaMethod> haveLogging() {
return new ArchCondition<>("have logging") {
@Override
public void check(JavaMethod method, ConditionEvents events) {
boolean hasLogging = method.getMethodCallsFromSelf().stream()
.anyMatch(call -> call.getTargetOwner().getName()
.contains("Logger"));
if (!hasLogging) {
events.add(SimpleConditionEvent.violated(method,
method.getFullName() + " 에 로깅이 없습니다"));
}
}
};
}
// 서비스는 3개 이상의 의존성을 가지면 안 된다
public static ArchCondition<JavaClass> haveAtMostNDependencies(int max) {
return new ArchCondition<>("have at most " + max + " constructor parameters") {
@Override
public void check(JavaClass clazz, ConditionEvents events) {
clazz.getConstructors().forEach(constructor -> {
int paramCount = constructor.getRawParameterTypes().size();
if (paramCount > max) {
events.add(SimpleConditionEvent.violated(clazz,
clazz.getName() + " 의 생성자 파라미터가 " + paramCount +
"개입니다 (최대 " + max + "개)"));
}
});
}
};
}
}
// 사용
@ArchTest
static final ArchRule services_should_not_have_too_many_dependencies =
classes()
.that().areAnnotatedWith(Service.class)
.should(haveAtMostNDependencies(5))
.because("서비스 의존성이 5개를 초과하면 책임이 과도합니다");
Freezing: 점진적 적용
레거시 프로젝트에 ArchUnit을 도입할 때, 기존 위반은 동결(freeze)하고 새로운 위반만 감지할 수 있습니다.
@AnalyzeClasses(packages = "com.example.app")
class FreezingRuleTest {
@ArchTest
static final ArchRule no_cycles =
FreezingArchRule.freeze(
slices().matching("com.example.app.(*)..")
.should().beFreeOfCycles()
);
// 첫 실행: 기존 위반을 stored.rules 파일에 기록
// 이후 실행: 새로운 위반만 실패 처리
}
src/test/resources/archunit_store/ 디렉토리에 동결된 위반 목록이 저장됩니다. 기존 위반을 수정하면 자동으로 동결 목록에서 제거됩니다.
CI/CD 통합
ArchUnit 테스트는 일반 JUnit 테스트이므로 CI 파이프라인에 자연스럽게 통합됩니다.
# GitHub Actions 예시
- name: Architecture Tests
run: ./gradlew test --tests '*ArchitectureTest*' --tests '*ArchTest*'
# 또는 별도 Gradle 태스크
tasks.register<Test>("archTest") {
useJUnitPlatform {
includeTags("architecture")
}
description = "Run architecture tests"
group = "verification"
}
실전 팁
- 테스트 분리: ArchUnit 테스트는 별도 패키지(
architecture)에 모아두면 관리가 편합니다 - ImportOption:
@AnalyzeClasses(importOptions = DoNotIncludeTests.class)로 테스트 코드를 분석 대상에서 제외합니다 - 점진적 도입: FreezingArchRule로 기존 위반을 동결하고, 새 코드부터 규칙을 적용합니다. Testcontainers 통합 테스트와 함께 테스트 스위트를 구성하면 효과적입니다
- 팀 온보딩: ArchUnit 테스트 자체가 아키텍처 문서 역할을 합니다. 새 팀원이
because()메시지를 읽으면 아키텍처 규칙을 파악할 수 있습니다 - 성능: 클래스 분석은 캐시되므로, 같은
@AnalyzeClasses를 공유하는 테스트가 빠릅니다
마무리
ArchUnit은 “아키텍처 규칙을 코드로 표현한다”는 간단한 아이디어로, 코드 리뷰에서 반복되는 아키텍처 피드백을 자동화합니다. 레이어 분리, 순환 의존성, 네이밍 컨벤션, 모던 Java 패턴 적용 여부까지 — CI에서 자동 검증하면 아키텍처 부채가 축적되는 것을 원천 차단할 수 있습니다.