Spring ArchUnit 아키텍처 테스트

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에서 자동 검증하면 아키텍처 부채가 축적되는 것을 원천 차단할 수 있습니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux