Search

ArchUnit과 함께 아키텍처 검사하기

ArchUnit 이란?

ArchUnit은 Java 코드의 아키텍처를 검사할 수 있는 라이브러리입니다.
Java 바이트코드를 분석하고 모든 클래스의 구조를 해석함으로써 검사를 수행합니다.
ArchUnit을 통해 패키지나 클래스, 레이어 간의 의존성을 검사하고 순환 의존 여부 등을 확인할 수 있습니다.
ArchUnit은 JUnit과 함께 사용할 수도 있으며, 이 경우 간편하게 테스트를 작성할 수 있습니다.
@AnalyzeClasses(packages = "com.myapp") public class ArchitectureTest { @ArchTest static final ArchRule controllers_should_not_depend_on_repositories = ... }
Java
복사
이때 @AnalyzeClasses는 지정한 패키지로부터 클래스들을 읽어오고, 이 정보를 테스트 클래스 전체에서 사용할 수 있게 해줍니다.
또한 테스트 메서드를 작성하지 않아도 @ArchTest 을 붙은 필드를 Reflection으로 읽어서 테스트로 간주하고 실행시킵니다.

ArchUnit 동작 원리

ArchUnit 의 검사는 크게 3단계로 동작합니다.

1. Class Importing

이 단계에서는 ClassVisitor 와 MethodVisitor의 구현체인 JavaClassProcessor, MethodProcessor 를 통해 Reflection이 아닌 ASM(어셈블리)으로 바이트코드 수준에서 클래스를 읽습니다.
이후 ArchUnit의 JavaClass, JavaMethod, JavaField 등의 도메인 모델로 변환합니다.
이때 주요 클래스는 ClassFileImporter로 아래와 같이 사용할 수 있습니다.
JavaClasses javaClasses = new ClassFileImporter() .withImportOptions(List.of(new ImportOption.DoNotIncludeTests(), new ImportOption.DoNotIncludeJars())) .importPackages("com.package.root");
Java
복사
이렇게 만들어진 JavaClasses는 규칙을 정의하고 검사를 할 때 반복적으로 사용되기 때문에 특정 패키지에 대해서만 스코프를 좁혀 테스트를 하는 경우가 아니라면 재사용을 고려하여 작성하는 것이 좋습니다.
기본적으로 제공되는 ImportOption들은 아래와 같습니다.
DO_NOT_INCLUDE_TESTS
아래의 test classes를 포함시키지 않습니다.
maven test classes(.*/target/test-classes/.*)
gradle test (.*/build/classes/([^/]+/)?test/.*)
intellij test output(.*/out/test/.*)
ONLY_INCLUDE_TESTS
아래의 test classes만 포함시킵니다.
maven test classes(.*/target/test-classes/.*)
gradle test (.*/build/classes/([^/]+/)?test/.*)
intellij test output(.*/out/test/.*)
DO_NOT_INCLUDE_TEST_FIXTURES
아래의 gradle test fixtures 경로를 포함시키지 않습니다.
test fixtures file path(.*/build/classes/.*/testFixtures/.*)
test fixtures far path(.*/build/libs/.*-test-fixtures.jar!.*)
DO_NOT_INCLUDE_JARS
ClassFileImporter 를 통해 JAR로 import된 경우 해당 JAR 경로들을 제외합니다.
DO_NOT_INCLUDE_ARCHIVES
JAR + JRT 전체 경로를 제외합니다.
DO_NOT_INCLUDE_PACKAGE_INFOS
package-info.java가 컴파일된 package-info.class 를 제외합니다.
이렇게 제공되는 ImportOption은 interface 로써 기본적으론 Predefined가 구현체로 제공되지만 직접 구현체를 작성하여 사용할 수 있습니다.

2. Rule Definition

이 단계에서는 ArchRule DSL을 이용해 Readable하게 규칙을 정의합니다.
이러한 규칙은 1번 단계에서 정의한 JavaClasses라는 클래스 그래프를 대상으로 ArchRule(ArchRuleDeifnition) 객체를 만드는 것입니다.
이 객체엔 어떤 요소들을 대상으로 검사할지(Predicates + Transformer) 그리고 어떤 제약을 적용할지(Condition)에 대한 내용이 들어있습니다.
이때 사용하는 주요 클래스들은 ArchRuleDefinition, Predicates, ArchCondition, Transformers 입니다.
ArchRuleDefinition
먼저 ArchRuleDefinition에는 아래와 같은 대표적인 메서드들이 있습니다.
classes() / noClasses() → JavaClass 모델 대상
methods() / noMethods() → JavaMethod 모델 대상
fields() / noFields() → JavaField 모델 대상
layeredArchitecture(), slices() → 슬라이스, 레이어 대상
(여기서 no가 붙은 메서드는 해당 모델을 대상으로 위반이 있으면 안되는 룰을 생성합니다.)
각 메서드들은 반환 값으로 GivenClass, GivenMethods, GivenFields 같은 빌더를 반환합니다.
이 빌더엔 that(), should() 메서드가 있어서 메서드 체이닝 방식으로 Readable한 코드 작성이 가능하게 해줍니다.
예시로 아래와 같은 Rule을 생성할 수 있습니다.
@ArchTest static final ArchRule controllers_should_not_access_repositories = noClasses() .that().resideInAPackage("..controller..") .should().accessClassesThat().resideInAPackage("..repository..");
Java
복사
Predicates
ArchUnit의 Predicate는 설명을 포함한 DescribedPredicate를 주로 사용합니다.
이 구현체는 Java의 기본 함수형 인터페이스인 Predicate를 구현합니다.
내부적으로 and(), or(), not(), negate() 와 같은 메서드를 갖고 있어서 여러 조건들을 결합하여 사용할 수 있습니다.
또한 test() 메서드를 통해 ArchRule을 검사할 때 true/false를 판단하여 검사하게 됩니다.
그렇기 때문에 사용하고자 하는 조건으로 직접 정의하여 사용할 수도 있습니다.
DescribedPredicate<JavaClass> areInControllerPackage = new DescribedPredicate<>("reside in controller package") { @Override public boolean test(JavaClass input) { return input.getPackageName().contains(".controller."); } };
Java
복사
이렇게 정의한 조건은 아래처럼 ArchRule에 사용할 수 있습니다.
ArchRule rule = classes() .that(areInControllerPackage) .should().beAnnotatedWith(MyController.class);
Java
복사
Predicate는 단순히 조건을 표현하는 표현식이기 때문에 that(), should() 각각 사용되는 방식이 다릅니다.
that() → 어떤 대상을 검사해야하는지
should() → 검사 대상이 어떤 조건을 만족해야하는지
ArchCondition
ArchRuleDefinition은 GivenXXX과 같은 빌더를 반환한다고 하였는데, 이 빌더 내부엔 ArchCondition을 갖고 있습니다.
이 ArchCondition은 Predicate 또는 should()의 내부 메서드를 통해 aggregate되게 됩니다.
완성된 ArchCondition은 마지막 단계에서 check()를 통해 검사됩니다.
ArchRuleDefinition.classes().should().haveSimpleNameStartingWith() 의 내부 코드를 살펴보겠습니다.
// ArchRuleDeifnition.class @PublicAPI(usage = ACCESS) public GivenClasses classes() { return new GivenClassesInternal(priority, Transformers.classes()); }
Java
복사
// GivenClassesInternal.class @Override public ClassesShould should() { return new ClassesShouldInternal(finishedClassesTransformer(), priority, prepareCondition); }
Java
복사
// ClassesShouldInternal.class @Override public ClassesShouldConjunction haveSimpleNameStartingWith(String prefix) { return addCondition(ArchConditions.haveSimpleNameStartingWith(prefix)); }
Java
복사
Transformers
Transformers는 내부적으로 ArchUnit의 도메인 모델 간의 변환에 사용됩니다.
예시로 ArchRuleDeifinition의 methods()를 살펴보겠습니다.
// ArchRuleDefinition.class @PublicAPI(usage = ACCESS) public GivenMethods methods() { return new GivenMethodsInternal(priority, Transformers.methods()); }
Java
복사
// Transformers.class static ClassesTransformer<JavaMethod> methods() { return new AbstractClassesTransformer<JavaMethod>("methods") { @Override public Iterable<JavaMethod> doTransform(JavaClasses collection) { ImmutableSet.Builder<JavaMethod> result = ImmutableSet.builder(); for (JavaClass javaClass : collection) { result.addAll(javaClass.getMethods()); } return result.build(); } }; }
Java
복사

3. Evaluation

앞선 단계에서 만들었던 ArchRuleDefinition은 SimpleArchRule 같은 ArchRule 구현체로 만들어지게 됩니다.
해당 구현체는 description, transformer, condition 을 필드로 가지며 check()를 통해 최종 검사를 진행합니다.
check() 메서드의 동작은 대략 아래처럼 진행됩니다.
@Override public void check(JavaClasses classes) { // 1. Transformer로 대상 추출 Set<T> items = transformer.transform(classes); // 2. ConditionEvents 준비 ConditionEvents events = new ConditionEvents(); // 3. 각 대상에 대해 Condition 실행 for (T item : items) { condition.check(item, events); } // 4. 결과 확인 + 예외 던지기 if (events.containViolation()) { throw new AssertionError(failureReportFor(events)); } }
Java
복사
여기서 ConditionEvents는 조건 검사에 대한 이벤트 저장소로 성공/실패 여부, 메세지, 관련 클래스 정보를 가집니다.
이렇게 만들어진 ConditionEvents는 JUnit과 연결되어서 위반 사항들에 대해 FailureReport 객체를 만든 후 toString()을 통해 JUnit의 AssertionError 메세지로 사용됩니다.
JUnit에서는 이러한 AssertionError가 존재하는 경우 테스트를 실패로 표시하고 메세지를 표시하게 됩니다.

ArchUnit 사용 사례

우선 ArchUnit을 사용하기 위한 라이브러리는 다음과 같습니다.
// JUnit을 사용하지 않는 라이브러리 testImplementation "com.tngtech.archunit:archunit:${version}" // JUnit을 사용하는 라이브러리 testImplementation "com.tngtech.archunit:archunit-junit5:${version}"
Java
복사

패키지 종속성 검사

@ArchTest static final ArchRule controllers_should_not_depend_on_repositories = noClasses() .that().resideInAPackage("..controller..") .should().dependOnClassesThat().resideInAPackage("..repository..");
Java
복사
..controller.. 패키지에 있는 클래스들은 ..repository.. 패키지 클래스에 의존하면 안 된다.

클래스 종속성 검사

@ArchTest static final ArchRule services_should_depend_on_interfaces_not_impls = noClasses() .that().resideInAPackage("..service..") .should().dependOnClassesThat().haveSimpleNameEndingWith("Impl");
Java
복사
..service.. 패키지에 있는 클래스들은 이름이 “Impl”로 끝나는 클래스에 의존하면 안 된다.

클래스 및 패키지 포함 검사

@ArchTest static final ArchRule controllers_should_be_in_controller_package = classes() .that().haveSimpleNameEndingWith("Controller") .should().resideInAPackage("..controller..");
Java
복사
이름이 “Controller”로 끝나는 클래스들은 ..controller.. 패키지 안에 존재해야 한다.

상속 검사

@ArchTest static final ArchRule service_impls_should_implement_service_interface = classes() .that().haveSimpleNameEndingWith("ServiceImpl") .should().implement(com.myapp.service.GenericService.class);
Java
복사
이름이 “ServiceImpl”로 끝나는 클래스들은 *Service 인터페이스를 구현해야한다.

어노테이션 검사

@ArchTest static final ArchRule controller_classes_should_be_annotated = classes() .that().resideInAPackage("..controller..") .should().beAnnotatedWith(org.springframework.web.bind.annotation.RestController.class) .orShould().beAnnotatedWith(org.springframework.stereotype.Controller.class);
Java
복사
..controller.. 패키지의 클래스들은 @RestController 또는 @Controller 어노테이션이 붙어야한다.

레이어 검사

@ArchTest static final ArchRule layered_architecture_rule = layeredArchitecture() .consideringAllDependencies() // 레이어 정의 .layer("Controller").definedBy("com.myapp.controller..") .layer("Service").definedBy("com.myapp.service..") .layer("Repository").definedBy("com.myapp.repository..") // 의존 규칙 .whereLayer("Controller").mayNotBeAccessedByAnyLayer() .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller") .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service");
Java
복사
Controller-Service-Repository 의 3-tier 레이어드 아키텍처에서 순방향 의존을 지켜야한다.

사이클 검사

@ArchTest static final ArchRule no_cycles_between_feature_packages = slices() .matching("com.myapp.feature.(*)..") // (*)가 슬라이스 이름 .should().beFreeOfCycles();
Java
복사
feature 패키지의 하위 패키지들 간의 순환 의존이 있으면 안된다.

트러블 슈팅

1. 일부 클래스 미인식 이슈

private static final JavaClasses CLASSES = new ClassFileImporter() .withImportOption(new ImportOption.DoNotIncludeTests()) .importPackages("com.package.root");
Java
복사
위 코드처럼 importPackages() 를 사용해서 루트를 지정할 경우
해당 테스트를 실행시키는 주체가 되는 모듈의 working directory를 기준으로 classpath를 찾게 됩니다.
이 과정에서 classpath가 꼬여버려서 일부 class가 로딩되지 않았고, 테스트가 정상적으로 이루어지지 않았습니다.
private static final JavaClasses CLASSES = new ClassFileImporter() .withImportOption(new ImportOption.DoNotIncludeTests()) .importPath(Paths.get("build/classes/java/main").toAbsolutePath());
Java
복사
build 된 class 파일을 기준으로 절대경로를 지정해주었더니 정상적으로 모든 클래스가 로딩되는걸 확인하였습니다.

2. test sourceSet에서의 classpath 이슈

아키텍처와 관련된 테스트는 공용 테스트라고 생각하여서 core 모듈에 위치시켰습니다.
이후 테스트를 실행하니 테스트 자체를 인식하지 못하는 이슈가 발생하였습니다.
원인은 core 모듈을 testImplementation으로 의존하더라도 테스트 환경에선 모듈 간의 classpath가 공유되지 않는 문제 때문이었습니다.
이 문제를 해결하기 위해 core 모듈의 테스트를 testFixtures 패키지로 이동시켜주었고, api 모듈은 core 모듈을 testFixtures로 의존하도록 변경하였습니다.
이렇게 classpath를 공유하도록 해주니 정상적으로 api 모듈의 테스트를 실행했을 때도 인식되는걸 확인하였습니다.

3. 멀티 모듈 환경에서 classpath 이슈

1, 2번 이슈를 해결하고 난 뒤 Repository 클래스들만 로딩하지 못하는걸 확인하였습니다.
원인은 ClassFileImporter에게 지정해준 경로는 build/classes/java/main 이기 때문이었습니다.
멀티 모듈 프로젝트는 빌드 시에 jar로 만들어지는 과정에서 여러 모듈들의 classpath가 통합되는 방식인데, 테스트는 빌드 결과물인 jar를 통해 실행되는 것이 아니었기 때문입니다.
private static final JavaClasses CLASSES = new ClassFileImporter() .withImportOption(new ImportOption.DoNotIncludeTests()) .importPaths( Paths.get("../api/build/classes/java/main"), Paths.get("../database/build/classes/java/main"), Paths.get("../core/build/classes/java/main") );
JavaScript
복사
위 코드처럼 ClassFileImporter 에게 인식시켜주려하는 모듈의 build/classes/java/main 을 전부 경로로 잡아주면 정상적으로 인식되는 것을 확인할 수 있었습니다.
하지만 모듈 의존성에 대한 관리포인트가 build.gradle 말고도 더 늘어난다는 번거로움이 생깁니다.
다시 생각해보면 이를 통해 JVM이 인식하는 main sourceSet의 classpath만을 ArchUnit이 인식할 수 있다는걸 알아냈습니다.
그래서 다르게 접근하여 테스트를 실행하면 의존하고 있는 모듈들의 main sourceSet의 classpath를 추가해준다면 앞선 모든 이슈들을 해결할 수 있을거라고 판단되었습니다.
test { useJUnitPlatform() // 현재 모듈 main output은 항상 추가 classpath += sourceSets.main.output // 의존 모듈 자동 추가 project.configurations.implementation.dependencies .withType(ProjectDependency) .each { dependency -> classpath += dependency.dependencyProject.sourceSets.main.output } }
Groovy
복사
먼저 build.gradle 에 Gradle에 의해 의존하고 있는 모듈들의 classpath가 합쳐질 수 있도록 하였습니다.
private static final JavaClasses CLASSES = new ClassFileImporter() .withImportOption(new ImportOption.DoNotIncludeTests()) .importPackages("com.package.root");
Java
복사
classpath가 합쳐졌으니 더 이상 build/classes/java/main 경로를 지정해줄 필요 없이 루트 패키지만 지정해주면 모든 클래스를 인식할 수 있게 됩니다.

4. 테스트 실행 시 OOM 이슈

테스트 케이스를 작성 후 gradlew로 통합 테스트를 진행했더니 일부 테스트 클래스가 ignored 되었습니다.
디버그 모드로 테스트를 실행시켜 로그를 확인해보니 OOM이 발생한걸 확인하였습니다.
원인은 ClassFileImporter를 통해 클래스를 로딩하는 과정을 여러 테스트 클래스에서 매번 새로 하고 있어서 static으로 너무 많은 데이터가 올라가 메모리가 부족해 진 것이었습니다.
이 문제를 해결하기 위해 JVM 안에서 한번만 클래스 로딩이 발생하도록 컴포넌트를 만들었습니다.
public final class ClassLoader { private static final JavaClasses CLASSES = new ClassFileImporter() .withImportOption(new ImportOption.DoNotIncludeTests()) .importPackages("com.package.root"); public static JavaClasses load() { return CLASSES; } }
Java
복사
그리고 테스트 클래스들은 컴포넌트를 통해 사전에 로딩된 클래스들을 가져와서 사용할 수 있게 하였습니다.
private static JavaClasses CLASSES = ClassLoader.load();
Java
복사

실전 적용

클래스 네이밍 규칙 테스트
어노테이션 규칙 테스트
아키텍처 레이어 규칙 테스트