본문 바로가기
개발진행목록/스터디카페 통합 조회 플랫폼

[studyhub] ArchUnit으로 모듈 경계 검증하기

by o3oppp 2026. 9. 14.

ArchUnit

testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'

아키텍처 규칙을 테스트 코드로 검증하는 라이브러리로, "reservation은 cafe를 참조하면 안 된다"같은 약속을 JUnit 테스트로 작성해두면, 규칙을 어긴 코드가 생겼을 때 테스트가 실패하면서 실패한 위치를 알려준다.

일반 테스트와 다른 점

  • 단위 테스트나 통합 테스트는 코드가 무엇을 하는지를 검증한다.
  • 아키텍처 테스트는 코드가 어떻게 생겼는지를 검증한다. (어떤 패키지가 어떤 패키지를 참조하는지, 클래스 이름이 규칙에 맞는지 등)
  • 기능이 정상 동작해도 아키텍처 테스트는 실패할 수 있다.

어떻게 검사하는지

  • 컴파일된 .class 파일을 읽어 클래스 간 의존 관계를 분석한다. 애플리케이션을 실행하지 않고 바이트코드만 보기 때문에 DB나 스프링 컨텍스트가 필요 없다.
  • 그래서 SpringBootTest를 붙일 필요가 없다. 붙여두면 테스트를 실행할 때마다 애플리케이션이 실행되면서 DB 연결까지 시도하므로 느려지기만 한다.

기본 문법

@AnalyzeClasses(packages = "com.studyhub")
public class ModuleDependencyTest {

    @ArchTest
    static final ArchRule reservation은_cafe와_member를_참조하지_않는다 =
        noClasses()
            .that().resideInAPackage("..reservation..")
            .should().dependOnClassesThat(
                resideInAPackage("..cafe..")
                    .or(resideInAPackage("..member..")));
  • @AnalyzeClasses(packages = ... : 분석할 최상위 패키지를 지정한다. 지정한 범위의 클래스들만 검사 대상이 된다.
  • @ArchTest : 메스더가 아니라 필드에 붙이고 규칙 자체가 상태를 갖지 않는 값이기 때문에 static final이어야 한다.
  • noClasses() : "~한 클래스는 하나도 없어야 한다" 는 뜻이다.
  • classes() : "~한 클래스는 모두 ~해야 한다" 는 뜻이다.
  • .that() : 검사 대상을 좁힌다.
  • .should() : 대상이 마족해야 할(또는 만족하면 안 되는) 조건을 쓴다.
  • ..패키지명.. : 앞뒤의 ..은 "그 아래 모든 하위 패키지"를 뜻한다. ..reservation..은 reservation.domain, reservation.service등을 모두 포함한다.
  • 해석하면 "어떤 클래스도, reservation 패키지에 있으면서, cafe나 member 패키지의 클래스에 의존하면 안 된다"는 뜻이다.

전면 금지 : 메서드를 이어 붙이는 형태

noClasses()
    .that().resideInAPackage("..reservation..")
    .should().dependOnClassesThat().resideInAPackage("..cafe..");
  • dependOnClassesThat()을 호출한 뒤 점을 찍어 .resideInAPackage(...)을 붙인다.
  • reservation은 cafe를 어떤 형태로도 참조하지 않으므로 이 형태로 충분하다.

일부 허용 : 조건을 인자로 넘기는 형태

noClasses()
    .that().resideInAPackage("..cafe..")
    .should().dependOnClassesThat(
        resideInAPackage("..reservation..")
            .and(not(resideInAPackage("..reservation.port..")))
    );
  • cafe는 reservation.port를 구현해야 하므로 reservation 전체를 막아서는 안 된다.
  • 조건을 조합하려면 dependOnClassesThat()의 괄호 안에 조건을 넣어야 한다.
  • 점을 찍는 형태로는 and, or, not을 쓸 수 없다.
  • 해석하면 "cafe에 있는 클래스는 reservation 패키지이면서 reservation.port는 아닌 클래스에 의존하면 안 된다"는 뜻이다.

and, or

resideInAPackage("..cafe..")
    .and(resideInAPackage("..member.."))
  • and는 두 조건을 동시에 만족해야 한다는 뜻으로, 위의 예시는 "한 클래스가 cafe 패키지이면서 동시에 member 패키지"라는 뜻으로 항상 거짓이다.
  • 서로 다른 패키지를 나열할 때는 or, 한 패키지 안에서 범위를 좁힐 때는 and를 사용한다.

테스트 코드 작성

@AnalyzeClasses(packages = "com.studyhub")
public class ModuleDependencyTest {

    @ArchTest
    static final ArchRule reservation은_cafe와_member를_참조하지_않는다 =
        noClasses()
            .that().resideInAPackage("..reservation..")
            .should().dependOnClassesThat(
                resideInAPackage("..cafe..")
                    .or(resideInAPackage("..member..")));

    @ArchTest
    static final ArchRule cafe는_reservation의_port만_참조한다 =
        noClasses()
            .that().resideInAPackage("..cafe..")
            .should().dependOnClassesThat(
                resideInAPackage("..reservation..")
                    .and(not(resideInAPackage("..reservation.port..")))
            );

    @ArchTest
    static final ArchRule member는_reservation의_port만_참조한다 =
        noClasses()
            .that().resideInAPackage("..member..")
            .should().dependOnClassesThat(
                resideInAPackage("..reservation..")
                    .and(not(resideInAPackage("..reservation.port.."))));

    @ArchTest
    static final ArchRule member는_cafe의_port만_참조한다 =
        noClasses()
            .that().resideInAPackage("..member..")
            .should().dependOnClassesThat(
                resideInAPackage("..cafe..")
                    .and(not(resideInAPackage("..cafe.port.."))));

    @ArchTest
    static final ArchRule common은_어느_도메인모듈도_참조하지_않는다 =
        noClasses()
            .that().resideInAPackage("..common..")
            .should().dependOnClassesThat(
                resideInAPackage("..cafe..")
                    .or(resideInAPackage("..reservation.."))
                    .or(resideInAPackage("..member..")));
}

 

실패하는 경우

  • was violated (5 times) 뒤에 위반 지점이 하나식 나열된다.
  • 클래스명과 줄 번호가 함께 나오므로 어디를 고쳐야 하는지 알 수 있다.
  • 규칙 이름도 그래도 출력되므로 여러 규칙 중 어느 것이 깨졌는지 구분된다.

테스트를 만들어두는 것만으로는 부족한 이유

  • 테스트 코드를 작성해 두었지만 실행하지 않으면 아무것도 검증되지 않는다.
  • 빌드 시 테스트 코드 실행을 강제화 할 필요가 있다.
  • Gradle의 build 테스트는 test를 포함하므로 ./gradlew build를 실행하면 아키텍처 테스트도 함께 돌고 하나라도 실패하면 빌드가 실패한다.
  • 여기에 CI를 연결하면 push나 PR마다 자동으로 검증되어 규칙을 어긴 코드는 머지되기 전에 걸린다.