튼튼발자 개발 성장기🏋️

[ArchUnit] 아키텍처 검증 본문

Framework/spring

[ArchUnit] 아키텍처 검증

시뻘건 튼튼발자 2025. 10. 30. 10:02
반응형

조직이 빠르게 변화하고 오랜시간동안 구성원이 계속해서 바뀌며 빌딩해야하는 프로젝트에서는 초기 프로젝트 설계 의도를 잃어버리기 쉽습니다. 실제로 현업에서 DDD 설계 프로젝트가 변형되어 나중에는 여러 도메인이 강결합을 가지는 어처구니 없는 구조가 되어가는 것을 본 적이 있습니다. 이를 방지하려면 아키텍처를 설계하고 설계된 아키텍처가 변형되지 않도록 꽉! 잡아주는 틀이 있으면 좋을것 같다는 생각이 들었습니다. 이처럼 헥사고날이든 삼계층이든 어떠한 아키텍처 구조를 가진 프로젝트가 빌드 될때마다 정해진 규율 안에서 아키텍처의 변동이 있으면 빌드가 되지 않도록 하고, 의도치 않게, 혹은 신규 입사자의 실수로 아키텍처에 변형이 왔을 때 빠르게 캐치하여 수정할 수 있으면서 프로젝트의 신뢰도를 높이고 코드리뷰는 비즈니스 로직에 포커스를 맞출 수 있는 강점이 생깁니다. 그러기 위해서 ArchUnit이라고 하는 오픈소스 라이브러리(1.4.1 버전 기준)를 소개하고자합니다.

목차


ArchUnit이란?

ArchUnit은 Java/Kotlin 아키텍처를 테스트하기 위한 오픈소스 라이브러리입니다. 바이트코드를 분석하여 아키텍처 규칙을 검증하고, 이를 단위 테스트처럼 실행할 수 있게 합니다.

핵심 특징

  • 코드로 표현하는 아키텍처 규칙: 문서화된 아키텍처 원칙을 실행 가능한 테스트로 변환
  • CI/CD 통합: 빌드 프로세스에 통합하여 자동으로 아키텍처 검증
  • 리팩토링 안전성: 코드 변경 시 아키텍처 원칙 준수 여부 자동 검증
  • 명확한 DSL: 읽기 쉬운 Fluent API로 규칙 정의
  • JUnit 통합: JUnit 4/5와 통합
  • Kotlin 지원: Kotlin DSL과 호환

왜 ArchUnit을 사용해야 하는가?

1. 아키텍처 부채 방지

앞서 이야기한 바와 같이 시간이 지남에 따라 아키텍처 원칙이 깨지는 것을 방지합니다.

문제 상황:

// Service 레이어가 Controller를 직접 참조하면 안 되는데...
@Service
class UserService(
    private val controller: UserController // 레이어 위반!
) {
    fun processUser() {
        controller.handleRequest()
    }
}

ArchUnit으로 방지:

@ArchTest
val layerRule = noClasses()
    .that().resideInAPackage("..service..")
    .should().dependOnClassesThat().resideInAPackage("..controller..")

2. 코드 리뷰 부담 감소

  • 사람이 매번 확인하기 어려운 구조적 규칙을 자동으로 검증합니다.
  • 코드리뷰에서는 비즈니스 로직에 집중할 수 있도록합니다.

3. 신규 입사자 온보딩

  • 코드로 표현된 아키텍처 규칙은 두 말하면 잔소리입니다.

4. 리팩토링 안전성

  • 리팩토링 시 의도하지 않은 의존성 추가를 즉시 감지합니다.

5. 일관성 유지

  • 팀 전체가 동일한 아키텍처 원칙을 따르도록 강제합니다.

ArchUnit 내부 동작 방식

1. 전체 아키텍처

┌───────────────────────────────────────────────────────┐
│                     ArchUnit Core                     │
├───────────────────────────────────────────────────────┤
│                                                       │
│  ┌──────────────┐      ┌──────────────┐               │
│  │ ClassFile    │─────>│   Domain     │               │
│  │  Importer    │      │   Model      │               │
│  └──────────────┘      └──────────────┘               │
│         │                     │                       │
│         │                     │                       │
│         v                     v                       │
│  ┌──────────────┐      ┌──────────────┐               │
│  │   Bytecode   │      │  JavaClasses │               │
│  │   Analysis   │      │  JavaClass   │               │
│  └──────────────┘      │  JavaMethod  │               │
│                        │  JavaField   │               │
│                        └──────────────┘               │
│                              │                        │
│                              v                        │
│                        ┌──────────────┐               │
│                        │ Dependency   │               │
│                        │    Graph     │               │
│                        └──────────────┘               │
│                              │                        │
│                              v                        │
│                        ┌──────────────┐               │
│                        │    Rule      │               │
│                        │  Evaluation  │               │
│                        └──────────────┘               │
└───────────────────────────────────────────────────────┘

2. ClassFileImporter: 바이트코드 분석

2.1 Import 프로세스

// 1단계: 클래스 파일 위치 결정
val importer = ClassFileImporter()
val classes = importer.importPackages("com.myapp")

// 내부 동작:
// 1. 클래스패스 스캔
// 2. .class 파일 찾기
// 3. 바이트코드 읽기
// 4. ASM 라이브러리로 파싱
// 5. JavaClasses 도메인 모델로 변환

2.2 바이트코드 분석 메커니즘

ArchUnit은 ASM(Java bytecode manipulation framework)을 사용하여 바이트코드를 분석합니다:

.class 파일
    ↓
ClassFileImporter
    ↓
ASM ClassReader
    ↓
ClassVisitor 패턴
    ↓
JavaClass 생성
    ├─ 클래스 메타데이터
    ├─ 필드 정보
    ├─ 메서드 정보
    ├─ 어노테이션
    └─ 의존성 정보

실제 분석 과정:

// ArchUnit 내부 처리 (simplified)
class ClassFileProcessor {
    fun process(classFile: ByteArray): JavaClass {
        val classReader = ClassReader(classFile)
        val visitor = ArchUnitClassVisitor()

        // ASM을 통한 바이트코드 방문
        classReader.accept(visitor, 0)

        return visitor.build()
    }
}

// 각 바이트코드 명령어를 방문하며 정보 수집
class ArchUnitClassVisitor : ClassVisitor {
    override fun visitMethod(
        access: Int,
        name: String,
        descriptor: String,
        signature: String?,
        exceptions: Array<String>?
    ): MethodVisitor {
        // 메서드 정보 수집
        // - 접근 제어자
        // - 메서드 시그니처
        // - 예외 정보
        return MethodDependencyCollector()
    }

    override fun visitField(...) {
        // 필드 정보 수집
    }
}

3. Domain Model: JavaClasses

3.1 도메인 모델 구조

JavaClasses (컬렉션)
    │
    ├─ JavaClass (클래스)
    │   ├─ name: String
    │   ├─ packageName: String
    │   ├─ modifiers: Set<JavaModifier>
    │   ├─ annotations: Set<JavaAnnotation>
    │   │
    │   ├─ JavaField (필드)
    │   │   ├─ name: String
    │   │   ├─ type: JavaClass
    │   │   └─ annotations: Set<JavaAnnotation>
    │   │
    │   ├─ JavaMethod (메서드)
    │   │   ├─ name: String
    │   │   ├─ returnType: JavaClass
    │   │   ├─ parameters: List<JavaParameter>
    │   │   └─ methodCalls: Set<JavaMethodCall>
    │   │
    │   └─ JavaConstructor (생성자)
    │       ├─ parameters: List<JavaParameter>
    │       └─ constructorCalls: Set<JavaConstructorCall>
    │
    └─ Dependencies
        ├─ JavaFieldAccess
        ├─ JavaMethodCall
        ├─ JavaConstructorCall
        └─ JavaAnnotation references

3.2 의존성 표현

// JavaClass 예제
class JavaClass {
    val name: String
    val packageName: String

    // 이 클래스가 직접 접근하는 클래스들
    fun getDirectDependenciesFromSelf(): Set<Dependency> {
        return fields.flatMap { it.type } +
               methods.flatMap { it.returnType + it.parameterTypes } +
               annotations.map { it.rawType }
    }

    // 이 클래스에 접근하는 클래스들
    fun getDirectDependenciesToSelf(): Set<Dependency> {
        // 역방향 의존성
    }
}

4. 의존성 그래프 생성

4.1 그래프 구축 과정

// 1단계: 모든 클래스 Import
val classes = ClassFileImporter().importPackages("com.myapp")

// 2단계: 의존성 그래프 구축 (내부 동작)
class DependencyGraphBuilder {
    fun build(classes: JavaClasses): DependencyGraph {
        val graph = DependencyGraph()

        for (clazz in classes) {
            // 필드 의존성
            for (field in clazz.allFields) {
                graph.addEdge(clazz, field.rawType)
            }

            // 메서드 호출 의존성
            for (method in clazz.methods) {
                for (call in method.methodCallsFromSelf) {
                    graph.addEdge(clazz, call.targetOwner)
                }
            }

            // 상속 의존성
            clazz.superclass.ifPresent { superClass ->
                graph.addEdge(clazz, superClass)
            }

            // 인터페이스 의존성
            for (iface in clazz.interfaces) {
                graph.addEdge(clazz, iface)
            }
        }

        return graph
    }
}

4.2 의존성 타입

ArchUnit은 다음과 같은 의존성을 감지합니다:

sealed class Dependency {
    // 1. 필드 타입 의존성
    class FieldTypeDependency(
        val origin: JavaField,
        val target: JavaClass
    )

    // 2. 메서드 파라미터 의존성
    class ParameterDependency(
        val origin: JavaMethod,
        val target: JavaClass
    )

    // 3. 리턴 타입 의존성
    class ReturnTypeDependency(
        val origin: JavaMethod,
        val target: JavaClass
    )

    // 4. 메서드 호출 의존성
    class MethodCallDependency(
        val origin: JavaMethod,
        val target: JavaMethod,
        val lineNumber: Int
    )

    // 5. 생성자 호출 의존성
    class ConstructorCallDependency(
        val origin: JavaCodeUnit,
        val target: JavaConstructor
    )

    // 6. 상속 의존성
    class InheritanceDependency(
        val origin: JavaClass,
        val target: JavaClass
    )

    // 7. 어노테이션 의존성
    class AnnotationDependency(
        val origin: JavaClass,
        val target: JavaAnnotation
    )
}

5. Rule Evaluation: 규칙 평가 엔진

5.1 규칙 평가 프로세스

// 규칙 정의
val rule = classes()
    .that().resideInAPackage("..service..")
    .should().onlyBeAccessed().byAnyPackage("..controller..")

// 내부 평가 프로세스
class RuleEvaluator {
    fun evaluate(rule: ArchRule, classes: JavaClasses): EvaluationResult {
        // 1단계: Predicate 필터링
        val matchingClasses = classes.filter { clazz ->
            rule.predicate.test(clazz)
        }
        // 결과: service 패키지의 모든 클래스

        // 2단계: Condition 검사
        val violations = mutableListOf<Violation>()

        for (clazz in matchingClasses) {
            // 이 클래스에 접근하는 모든 클래스 확인
            val accessors = clazz.getDirectDependenciesToSelf()

            for (accessor in accessors) {
                if (!accessor.resideInAnyPackage("..controller..")) {
                    violations.add(
                        Violation(
                            message = "Class ${accessor.name} accesses ${clazz.name}",
                            lineNumber = accessor.lineNumber
                        )
                    )
                }
            }
        }

        return EvaluationResult(violations)
    }
}

5.2 규칙 구조

// ArchRule 인터페이스
interface ArchRule {
    fun check(classes: JavaClasses)
    fun evaluate(classes: JavaClasses): EvaluationResult
    fun because(reason: String): ArchRule
    fun allowEmptyShould(allow: Boolean): ArchRule
}

// 규칙은 Predicate + Condition으로 구성
class ClassesShouldRule(
    private val predicate: DescribedPredicate<JavaClass>,
    private val condition: ArchCondition<JavaClass>
) : ArchRule {

    override fun evaluate(classes: JavaClasses): EvaluationResult {
        val relevantClasses = classes.filter { predicate.test(it) }

        val events = ConditionEvents()
        for (clazz in relevantClasses) {
            condition.check(clazz, events)
        }

        return EvaluationResult(events)
    }
}

6. 순환 의존성 검사 알고리즘

ArchUnit은 Johnson's Algorithm을 사용하여 방향 그래프에서 모든 기본 순환(elementary cycles)을 찾습니다.

6.1 ArchUnit의 실제 구현 (ArchUnit 공식 github 참고)

// 1단계: CycleDetector - 진입점
object CycleDetector {
    fun <NODE, EDGE : Edge<NODE>> detectCycles(
        nodes: Collection<NODE>,
        edges: Collection<EDGE>
    ): Cycles<EDGE> {
        val graph = Graph<NODE, EDGE>()
        graph.addNodes(nodes)
        graph.addEdges(edges)
        return graph.findCycles()
    }
}

// 2단계: Graph - Johnson's Algorithm 적용
class Graph<NODE, EDGE : Edge<NODE>> {
    fun findCycles(): Cycles<EDGE> {
        // Johnson's Cycle Finder 사용
        val johnsonCycleFinder = JohnsonCycleFinder(createPrimitiveGraph())
        val rawCycles = johnsonCycleFinder.findCycles()

        return CyclesInternal(
            mapToCycles(rawCycles),
            rawCycles.maxNumberOfCyclesReached()
        )
    }

    private fun createPrimitiveGraph(): PrimitiveGraph {
        // 그래프를 정수 기반 표현으로 변환 (성능 최적화)
        return PrimitiveGraph(nodeToInt, edges)
    }
}

// 3단계: JohnsonCycleFinder - 핵심 알고리즘
class JohnsonCycleFinder(private val graph: PrimitiveGraph) {
    private val stack = mutableListOf<Int>()
    private val blocked = mutableSetOf<Int>()
    private val blockedDependencies = mutableMapOf<Int, MutableSet<Int>>()
    private val cycles = mutableListOf<List<Int>>()

    fun findCycles(): Result {
        var startNode = 0

        while (startNode < graph.size()) {
            // 1. 현재 노드부터 시작하는 SCC 찾기
            val scc = findStronglyConnectedComponent(startNode)

            if (scc.isNotEmpty()) {
                // 2. SCC 내에서 순환 찾기
                val subgraph = graph.subgraph(scc)
                val lowestNode = scc.min()

                blocked.clear()
                blockedDependencies.clear()

                // 3. DFS로 lowestNode로 돌아오는 경로 찾기
                findCyclesInSCC(lowestNode, lowestNode, subgraph)
            }

            startNode++

            // 최대 순환 개수 제한 체크
            if (cycles.size >= maxCycles) break
        }

        return Result(cycles, cycles.size >= maxCycles)
    }

    private fun findCyclesInSCC(
        startNode: Int,
        currentNode: Int,
        subgraph: PrimitiveGraph
    ): Boolean {
        var foundCycle = false
        stack.add(currentNode)
        blocked.add(currentNode)

        // 현재 노드의 모든 후속 노드 탐색
        for (successor in subgraph.getSuccessors(currentNode)) {
            when {
                // 순환 발견!
                successor == startNode -> {
                    cycles.add(stack.toList())
                    foundCycle = true
                }
                // 아직 방문하지 않은 노드
                !blocked.contains(successor) -> {
                    if (findCyclesInSCC(startNode, successor, subgraph)) {
                        foundCycle = true
                    }
                }
            }
        }

        // Backtrack
        if (foundCycle) {
            // 순환을 찾았으면 blocked 해제
            unblock(currentNode)
        } else {
            // 순환을 못 찾았으면 의존성 기록
            for (successor in subgraph.getSuccessors(currentNode)) {
                blockedDependencies
                    .getOrPut(successor) { mutableSetOf() }
                    .add(currentNode)
            }
        }

        stack.removeLast()
        return foundCycle
    }

    private fun unblock(node: Int) {
        blocked.remove(node)

        // 이 노드에 의존적으로 블록된 노드들도 해제
        blockedDependencies[node]?.let { dependents ->
            for (dependent in dependents.toList()) {
                if (blocked.contains(dependent)) {
                    unblock(dependent)
                }
            }
            dependents.clear()
        }
    }

    private fun findStronglyConnectedComponent(startNode: Int): Set<Int> {
        // Tarjan's Algorithm으로 SCC 찾기
        // (Johnson's Algorithm의 전처리 단계)
        // ... SCC 로직 ...
    }
}

6.2 Johnson's Algorithm 동작 원리

단계별 예제:

초기 그래프:
    A ──> B ──> C
    ↑     ↓     ↓
    └──── D ←── E

1단계: SCC 찾기
   - {A, B, D} 는 하나의 SCC (순환 가능)
   - {C}, {E} 는 단독 노드

2단계: SCC {A, B, D}에서 순환 찾기
   시작: A (최저 노드)

   DFS 탐색:
   A -> B (stack: [A, B], blocked: {A, B})
   B -> D (stack: [A, B, D], blocked: {A, B, D})
   D -> A (순환 발견! A로 돌아옴)

   발견된 순환: [A, B, D, A]

3단계: Unblock 처리
   - A, B, D를 blocked에서 제거
   - 다른 경로 탐색 가능하게 함

4단계: 다음 SCC로 이동
   - {C}, {E}는 순환 없음

최종 결과:
   순환 1개 발견: A -> B -> D -> A

7. 캐싱 시스템

7.1 JavaClasses 캐싱

// JUnit 통합 시 자동 캐싱
@AnalyzeClasses(packages = ["com.myapp"])
class ArchitectureTest {
    // 첫 번째 테스트: 클래스 import
    @ArchTest
    val rule1 = classes().should()...

    // 두 번째 테스트: 캐시된 클래스 재사용!
    @ArchTest
    val rule2 = classes().should()...
}

// 내부 캐싱 메커니즘
object ClassCache {
    private val cache = ConcurrentHashMap<CacheKey, SoftReference<JavaClasses>>()

    data class CacheKey(
        val locations: Set<Location>,
        val importOptions: Set<ImportOption>
    )

    fun get(key: CacheKey): JavaClasses? {
        return cache[key]?.get()?.also {
            println("Cache hit: ${it.size} classes")
        }
    }

    fun put(key: CacheKey, classes: JavaClasses) {
        // SoftReference: 메모리 부족 시 GC 가능
        cache[key] = SoftReference(classes)
    }
}

7.2 캐시 무효화

// 캐시는 다음 경우 무효화됨:
// 1. .class 파일 변경 감지
// 2. import 옵션 변경
// 3. 명시적 캐시 클리어

// 명시적 캐시 비활성화
@AnalyzeClasses(
    packages = ["com.myapp"],
    cacheMode = CacheMode.PER_CLASS // 기본값
    // cacheMode = CacheMode.FOREVER  // 영구 캐시
)

8. 성능 최적화 메커니즘

8.1 Import 최적화

// 1. 병렬 처리
class ParallelClassFileImporter {
    fun importPackages(packages: List<String>): JavaClasses {
        return packages
            .parallelStream()
            .flatMap { pkg ->
                findClassFiles(pkg).stream()
            }
            .map { classFile ->
                parseClassFile(classFile) // 병렬 파싱
            }
            .collect(Collectors.toSet())
            .let { JavaClasses(it) }
    }
}

// 2. Lazy Loading
class LazyJavaClass(
    val name: String,
    private val bytecode: ByteArray
) {
    private val methods: List<JavaMethod> by lazy {
        // 실제 사용 시점에 파싱
        parseMethodsFromBytecode(bytecode)
    }
}

8.2 의존성 해결 최적화

// archunit.properties
// 의존성 해결 반복 횟수 제한
import.dependencyResolutionProcess.maxIterationsForMemberTypes=1
import.dependencyResolutionProcess.maxIterationsForAccessesToTypes=1

// 내부 동작
class DependencyResolver {
    fun resolve(
        classes: JavaClasses,
        maxIterations: Int
    ): JavaClasses {
        var current = classes
        var iteration = 0

        while (hasUnresolvedDependencies(current) &&
               iteration < maxIterations) {
            current = resolveNextLevel(current)
            iteration++
        }

        return current
    }
}

9. 메모리 관리

9.1 메모리 사용 패턴

Import 시 메모리 사용:
┌─────────────────────────────────┐
│ ClassFile                       │  ~1KB per class
├─────────────────────────────────┤
│ JavaClass                       │  ~2-5KB per class
├─────────────────────────────────┤
│ Dependency Graph                │  ~1KB per dependency
├─────────────────────────────────┤
│ Cache                           │  GC 가능
└─────────────────────────────────┘

예: 1,000개 클래스 → 약 4-8MB 메모리 사용

9.2 대규모 프로젝트 최적화

// 1. 패키지 단위 분할 import
val module1 = ClassFileImporter()
    .importPackages("com.myapp.module1")

val module2 = ClassFileImporter()
    .importPackages("com.myapp.module2")

// 2. ImportOption으로 범위 제한
val classes = ClassFileImporter()
    .withImportOption(DoNotIncludeTests())
    .withImportOption(DoNotIncludeJars())
    .withImportOption(DoNotIncludeArchives())
    .importPackages("com.myapp")

// 3. 특정 클래스만 import
val classes = ClassFileImporter()
    .importClasses(
        UserService::class.java,
        OrderService::class.java
    )

설치 및 설정

Gradle

dependencies {
    testImplementation("com.tngtech.archunit:archunit-junit5:1.4.1")
}

검증 가능한 아키텍처 규칙

1. 패키지 의존성 규칙

1.1 특정 패키지 간 의존성 금지

@ArchTest
val servicesShouldNotDependOnControllers = noClasses()
    .that().resideInAPackage("..service..")
    .should().dependOnClassesThat().resideInAPackage("..controller..")

1.2 특정 패키지만 접근 허용

@ArchTest
val servicesShouldOnlyBeAccessedByControllers = classes()
    .that().resideInAPackage("..service..")
    .should().onlyBeAccessed().byAnyPackage("..controller..", "..service..")

1.3 의존성 방향 제한

@ArchTest
val domainShouldOnlyBeDependedOnByService = classes()
    .that().resideInAPackage("..domain..")
    .should().onlyHaveDependentClassesThat()
    .resideInAnyPackage("..service..", "..domain..")

2. 레이어 아키텍처 검증

2.1 계층형 아키텍처

@ArchTest
val layerDependenciesAreRespected = layeredArchitecture()
    .consideringAllDependencies()
    .layer("Controller").definedBy("..controller..")
    .layer("Service").definedBy("..service..")
    .layer("Repository").definedBy("..repository..")
    .layer("Domain").definedBy("..domain..")

    .whereLayer("Controller").mayNotBeAccessedByAnyLayer()
    .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
    .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service")
    .whereLayer("Domain").mayOnlyBeAccessedByLayers("Service", "Repository")

2.2 Onion Architecture

@ArchTest
val onionArchitectureIsRespected = 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..")

3. 순환 의존성 검사

3.1 패키지 간 순환 의존성 방지

@ArchTest
val noCyclesBetweenPackages = slices()
    .matching("com.myapp.(*)..").should().beFreeOfCycles()

3.2 하위 패키지 포함 순환 검사

@ArchTest
val noCyclesIncludingSubpackages = slices()
    .matching("com.myapp.(**)").should().beFreeOfCycles()

3.3 서비스 간 의존성 금지

@ArchTest
val servicesShouldNotDependOnEachOther = slices()
    .matching("..myapp.(**).service..")
    .should().notDependOnEachOther()

4. 클래스 명명 규칙

4.1 서비스 클래스 네이밍

@ArchTest
val servicesShouldBeSuffixed = classes()
    .that().resideInAPackage("..service..")
    .and().areNotInterfaces()
    .should().haveSimpleNameEndingWith("Service")

4.2 Repository 네이밍

@ArchTest
val repositoriesShouldBeSuffixed = classes()
    .that().resideInAPackage("..repository..")
    .and().areInterfaces()
    .should().haveSimpleNameEndingWith("Repository")

4.3 Controller 네이밍

@ArchTest
val controllersShouldBeSuffixed = classes()
    .that().resideInAPackage("..controller..")
    .should().haveSimpleNameEndingWith("Controller")

5. 상속 및 인터페이스 규칙

@ArchTest
val entityManagerOnlyInPersistence = classes()
    .that().areAssignableTo(EntityManager::class.java)
    .should().onlyHaveDependentClassesThat()
    .resideInAnyPackage("..persistence..")

6. 어노테이션 기반 규칙

@ArchTest
val servicesShouldBeAnnotated = classes()
    .that().resideInAPackage("..service..")
    .and().areNotInterfaces()
    .should().beAnnotatedWith(Service::class.java)

@ArchTest
val repositoriesShouldBeAnnotated = classes()
    .that().resideInAPackage("..repository..")
    .and().areInterfaces()
    .should().beAnnotatedWith(Repository::class.java)

7. 접근 제어 규칙

@ArchTest
val fieldsShouldBePrivate = fields()
    .that().areDeclaredInClassesThat().resideInAPackage("..domain..")
    .and().areNotStatic()
    .and().areNotFinal()
    .should().bePrivate()

@ArchTest
val utilityClassesShouldHavePrivateConstructor = classes()
    .that().haveSimpleNameEndingWith("Utils")
    .or().haveSimpleNameEndingWith("Helper")
    .should().haveOnlyPrivateConstructors()

실전 예제

Spring Boot 프로젝트 전체 아키텍처 테스트

import com.tngtech.archunit.core.importer.ImportOption
import com.tngtech.archunit.junit.AnalyzeClasses
import com.tngtech.archunit.junit.ArchTest
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*
import com.tngtech.archunit.library.Architectures.layeredArchitecture
import com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices
import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service
import org.springframework.web.bind.annotation.RestController
import org.springframework.security.access.prepost.PreAuthorize
import javax.annotation.security.PermitAll

@AnalyzeClasses(
    packages = ["com.example"],
    importOptions = [ImportOption.DoNotIncludeTests::class]
)
class ArchitectureTest {

    // ========== 1. 레이어 아키텍처 ==========
    @ArchTest
    val layerDependenciesAreRespected = layeredArchitecture()
        .consideringAllDependencies()
        .layer("Controller").definedBy("..controller..")
        .layer("Service").definedBy("..service..")
        .layer("Repository").definedBy("..repository..")
        .layer("Domain").definedBy("..domain..")

        .whereLayer("Controller").mayNotBeAccessedByAnyLayer()
        .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
        .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service")
        .whereLayer("Domain").mayOnlyBeAccessedByLayers("Service", "Repository")

    // ========== 2. 순환 의존성 방지 ==========
    @ArchTest
    val noCyclesBetweenPackages = slices()
        .matching("com.example.(*)..")
        .should().beFreeOfCycles()

    @ArchTest
    val servicesShouldNotDependOnEachOther = slices()
        .matching("..example.(**).service..")
        .should().notDependOnEachOther()

    // ========== 3. 네이밍 컨벤션 ==========
    @ArchTest
    val servicesShouldBeSuffixed = classes()
        .that().resideInAPackage("..service..")
        .and().areNotInterfaces()
        .should().haveSimpleNameEndingWith("Service")
        .orShould().haveSimpleNameEndingWith("ServiceImpl")

    @ArchTest
    val repositoriesShouldBeSuffixed = classes()
        .that().resideInAPackage("..repository..")
        .and().areInterfaces()
        .should().haveSimpleNameEndingWith("Repository")

    @ArchTest
    val controllersShouldBeSuffixed = classes()
        .that().resideInAPackage("..controller..")
        .should().haveSimpleNameEndingWith("Controller")

    // ========== 4. 어노테이션 규칙 ==========
    @ArchTest
    val servicesShouldBeAnnotated = classes()
        .that().resideInAPackage("..service..")
        .and().areNotInterfaces()
        .should().beAnnotatedWith(Service::class.java)

    @ArchTest
    val repositoriesShouldBeAnnotated = classes()
        .that().resideInAPackage("..repository..")
        .and().areInterfaces()
        .should().beAnnotatedWith(Repository::class.java)

    @ArchTest
    val controllersShouldBeAnnotated = classes()
        .that().resideInAPackage("..controller..")
        .should().beAnnotatedWith(RestController::class.java)

    // ========== 5. JPA 사용 제한 ==========
    @ArchTest
    val jpaShouldOnlyBeUsedInPersistence = noClasses()
        .that().resideOutsideOfPackages("..repository..", "..domain..")
        .should().dependOnClassesThat()
        .resideInAnyPackage("javax.persistence..", "jakarta.persistence..")

    // ========== 6. 필드 접근 제한 ==========
    @ArchTest
    val domainFieldsShouldBePrivate = fields()
        .that().areDeclaredInClassesThat().resideInAPackage("..domain..")
        .and().areNotStatic()
        .and().areNotFinal()
        .should().bePrivate()

    // ========== 7. 보안 규칙 ==========
    @ArchTest
    val controllerMethodsShouldHaveSecurity = methods()
        .that().arePublic()
        .and().areDeclaredInClassesThat().areAnnotatedWith(RestController::class.java)
        .should().beAnnotatedWith(PreAuthorize::class.java)
        .orShould().beAnnotatedWith(PermitAll::class.java)

    // ========== 8. 의존성 방향 (DDD) ==========
    @ArchTest
    val domainShouldNotDependOnInfrastructure = noClasses()
        .that().resideInAPackage("..domain..")
        .should().dependOnClassesThat()
        .resideInAnyPackage("..controller..", "..repository..", "..config..")

    // ========== 9. Service는 Controller를 의존하면 안됨 ==========
    @ArchTest
    val serviceShouldNotDependOnController = noClasses()
        .that().resideInAPackage("..service..")
        .should().dependOnClassesThat().resideInAPackage("..controller..")

    // ========== 10. Util 클래스 규칙 ==========
    @ArchTest
    val utilityClassesShouldHavePrivateConstructor = classes()
        .that().haveSimpleNameEndingWith("Utils")
        .or().haveSimpleNameEndingWith("Helper")
        .should().haveOnlyPrivateConstructors()
}

커스텀 규칙 예제

// 1. 커스텀 Predicate
val havePayloadField = object : DescribedPredicate<JavaClass>("have a field annotated with @Payload") {
    override fun test(input: JavaClass): Boolean {
        return input.allFields.any { it.isAnnotatedWith(Payload::class.java) }
    }
}

@ArchTest
val payloadClassesShouldBeSecured = classes()
    .that(havePayloadField)
    .should().beAnnotatedWith(Secured::class.java)

// 2. 커스텀 Condition
val onlyBeAccessedBySecuredMethods = object : ArchCondition<JavaClass>("only be accessed by secured methods") {
    override fun check(item: JavaClass, events: ConditionEvents) {
        for (access in item.accessesToSelf) {
            if (access.origin is JavaMethod) {
                val method = access.origin as JavaMethod
                if (!method.isAnnotatedWith(Secured::class.java)) {
                    val message = "Method ${method.fullName} accesses ${item.name} but is not @Secured"
                    events.add(SimpleConditionEvent.violated(access, message))
                }
            }
        }
    }
}

@ArchTest
val sensitiveClassesAccess = classes()
    .that().resideInAPackage("..sensitive..")
    .should(onlyBeAccessedBySecuredMethods)

// 3. 복합 규칙
@ArchTest
val complexRule = classes()
    .that().resideInAPackage("..service..")
    .and().haveSimpleNameEndingWith("Service")
    .and().areNotInterfaces()
    .should().beAnnotatedWith(Service::class.java)
    .andShould().onlyBeAccessed().byAnyPackage("..controller..", "..service..")
    .because("Services should be annotated and only accessed by controllers")

테스트 실행 결과 예제

[실패 예시]
Architecture Violation [Priority: MEDIUM] - Rule 'classes that reside in a package '..service..'
should only be accessed by any package ['..controller..', '..service..']' was violated (3 times):

  Class <com.myapp.repository.UserRepository> accesses class <com.myapp.service.UserService> in (UserRepository.kt:15)
  Class <com.myapp.util.ServiceHelper> calls method <com.myapp.service.OrderService.processOrder()> in (ServiceHelper.kt:23)
  Field <com.myapp.domain.Order.userService> has type <com.myapp.service.UserService> in (Order.kt:12)

[성공 예시]
✓ Layer dependencies are respected
✓ No cycles between packages
✓ Services should be suffixed with 'Service'
✓ All architecture tests passed!

설정 및 최적화

archunit.properties 설정

프로젝트의 src/test/resources/archunit.properties:

# 순환 의존성 감지 최대 개수
cycles.maxNumberToDetect=100

# 순환 의존성 엣지당 출력할 최대 의존성 개수
cycles.maxNumberOfDependenciesPerEdge=20

# 클래스패스에서 누락된 의존성 자동 해결
resolveMissingDependenciesFromClassPath=false

# 의존성 해결 반복 횟수 제한
import.dependencyResolutionProcess.maxIterationsForMemberTypes=1
import.dependencyResolutionProcess.maxIterationsForAccessesToTypes=1
import.dependencyResolutionProcess.maxIterationsForSupertypes=-1
import.dependencyResolutionProcess.maxIterationsForEnclosingTypes=-1

# 클래스 캐시 활성화
enableMd5InClassSourcesUrl=false

위반 무시 설정

archunit_ignore_patterns.txt (src/test/resources):

# 레거시 코드 임시 예외
.*legacy\..*

# 특정 서비스 예외
.*OldUserService.*

# 마이그레이션 중인 패키지
.*migration\.v1\..*

# 생성된 코드 제외
.*\.generated\..*
.*\.querydsl\..*

성능 최적화 팁

1. Import 범위 최소화

// 나쁜 예: 전체 클래스패스 import
val classes = ClassFileImporter().importClasspath()

// 좋은 예: 필요한 패키지만 import
val classes = ClassFileImporter()
    .withImportOption(DoNotIncludeTests())
    .withImportOption(DoNotIncludeJars())
    .importPackages("com.myapp")

2. 캐시 활용

@AnalyzeClasses(
    packages = ["com.myapp"],
    cacheMode = CacheMode.PER_CLASS  // 클래스별 캐싱
)

3. 병렬 테스트 실행

// build.gradle.kts
tasks.test {
    useJUnitPlatform()
    maxParallelForks = Runtime.getRuntime().availableProcessors()
}

4. 커스텀 Import Option

class ExcludeGeneratedCode : ImportOption {
    override fun includes(location: Location): Boolean {
        return !location.contains("/generated/") &&
               !location.contains("/build/")
    }
}

@AnalyzeClasses(
    packages = ["com.myapp"],
    importOptions = [
        DoNotIncludeTests::class,
        ExcludeGeneratedCode::class
    ]
)

참고 자료


반응형