테스트코드(Test Code)

ArchUnit Test로 컨벤션 강제하기

Junuuu 2025. 1. 18. 13:55
반응형

개요

ArchUnit에 대해서 알아보고 ArchUnit을 활용하여 어떻게 컨벤션을 강제할 수 있는지 글을 작성해보려고 합니다.

 

 

ArchUnit이란?

ArchUnit은 패키지와 클래스, 레이어와 슬라이스 간의 종속성을 검사하고 순환 종속성 등을 확인할 수 있습니다. 주어진 Java 바이트코드를 분석하고 모든 클래스를 Java 코드 구조로 가져와서 이를 수행합니다.

 

실습 - gradle 세팅

dependencies {
    // 글 작성 기준 공식문서 가이드 버전
    testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0") 
}

 

실습 - ArchUnit으로 의존성 강제하기

class ArchitectureTest {

    private val importedClasses: JavaClasses = ClassFileImporter()
        .importPackages("com.example.study") // 자신의 루트 패키지로 변경

    @Test
    fun `controllers should not depend on repositories`() {
        val rule = ArchRuleDefinition.noClasses() // noClasses로 금지조건 명시
            .that().resideInAPackage("..controller..") // controller에는
            .should().dependOnClassesThat()
            .resideInAPackage("..repository..") // repository 패키지가 없어야 함
            .because("Controllers should not depend directly on repositories")

        rule.check(importedClasses)
    }
}

위 테스트 코드를 통하여 controller 패키지에는 repository 패키지 의존성을 가질 수 없도록 설정할 수 있습니다.

 

아래와 같이 controller가 service를 의존하는 경우 테스트가 정상적으로 통과됩니다.

package com.example.study.controller

import com.example.study.service.TestService
import org.springframework.stereotype.Component

@Component
class TestController(
    private val testService: TestService,
) {
}

 

하지만 아래와 같이 controller가 repository를 의존하는 경우 테스트가 실패하게 됩니다.

package com.example.study.controller

import com.example.study.repository.TestRepository
import org.springframework.stereotype.Component

@Component
class TestController(
    private val testRepository: TestRepository,
) {
}

 

아래그림과 같이 패키지 구조를 가지게 되면 테스트가 실패하는 모습을 확인할 수 있습니다.

 

위 사례에서는 ArchUnit을 활용하여 간단하게 의존성을 강제해 보았습니다.

지금부터는 컨벤션에 대해 알아보고 컨벤션을 강제하는 방법을 시도해 보겠습니다.

 

컨벤션이란?

소프트웨어 컨벤션(software convention)이란, 소프트웨어 개발 과정에서 개발자들이 따라야 할 규칙이나 표준을 의미합니다. 이러한 컨벤션은 코드를 작성하거나 구조화할 때 일관성을 유지하고, 코드의 가독성과 유지보수성을 높이며, 팀원 간의 협업을 원활하게 하기 위해 사용됩니다.

 

주요 소프트웨어 컨벤션으로는 코딩 스타일, 코드 구조와 패턴, 명명 규칙, 버전 관리와 커밋 메시지 작성 규칙 등이 있습니다.

 

만약 Client 모듈에는 Enum을 활용하면 역직렬화 문제가 발생할 수 있으니 String을 활용하여 대응하자라는 컨벤션이 정해지면 어떨까요?

 

5명이 개발자가 합의하에 이런 컨벤션을 정하고 잘 지키지만 시간이 지나면서 새로운 인원이 합류하고 규칙이 잊혀 갈 수 있습니다.

 

이런 상황에서 지속적으로 컨벤션을 지키려면 ArchUnit을 활용하여 컨벤션을 강제해 볼 수 있습니다.

 

실습 - ArchUnit으로 컨벤션 강제해 보기

어떻게 ArchUnit으로 컨벤션을 강제해 볼 수 있을까요?

 

ArchUnit에 대해 더 알아보기 위해서 공식문서에서 소개하는 ArchUnit의 Idea와 Concept을 먼저 알아보겠습니다.

 

ArchUnit은 Core, Lang, Library 계층으로 나뉩니다.

 

Core 계층이 가장 중요하며 바이트코드를 Java Object로 변환합니다.

 

Lang 계층은 아키텍처 규칙을 간결하게 지정하는 규칙 구문이 포함되어 있습니다.

 

Library 계층에는 여러 계층으로 이루어진 계층형 아키텍처와 같이 더 복잡한 사전 정의된 규칙이 포함되어 있습니다.

 

컨벤션을 강제하기 위해서는 바이트코드를 기반으로 해당 Object가 Enum을 가지는지 판별해야 하므로 Core 계층을 자세하게 알아보겠습니다.

 

Core 계층은 Java Reflection API 유사한 역할을 수행하며 JavaMethod, JavaField 클래스를 제공하여 getName(), getMethods(), getRawType() or getRawParameterTypes()을 호출할 수 있도록 도와줍니다.

 

ClassFileImporter를 활용하여 지정된 경로의 컴파일된 Java 클래스들을 가져올 수 있습니다.

 

https://www.archunit.org/userguide/html/000_Index.html#_domain

 

Core 계층은 위와 같은 의존도를 가지고 있으며 이제 단계적으로 구현해 보겠습니다.

 

feign interface대상 클래스들 가져오기

class FeignClientEnumTest {

    // 테스트할 클래스를 임포트
    val importedClasses: JavaClasses = ClassFileImporter()
        .withImportOption(ImportOption.DoNotIncludeTests())
        .importPackages(FeignTestApplication::class.java.packageName)

    @Test
    @DisplayName("step1 - feign interface 대상 가져오기")
    fun step1() {
        val rule = ArchRuleDefinition
            .methods()
            .that()
            .areDeclaredInClassesThat()
            .areAnnotatedWith(FeignClient::class.java)
            .should(
                object : ArchCondition<JavaMethod>("feign method 반환값 가져오기") {
                    override fun check(method: JavaMethod, event: ConditionEvents) {
                        val fullName: String = method.fullName
                        val returnType: JavaType = method.returnType
                        println(fullName)
                        println(returnType)
                    }
                }
            )
        rule.check(importedClasses)
    }
}

현재의 관심사는 @FeignClient 어노테이션이 붙어있는 모든 클래스의 메서드들의 반환타입을 검사하여 반환타입에 Enum이 포함되는지 검사하는 것입니다.

 

따라서 모든 메서드에 대해서 검사를 수행하도록 하며 클래스 부분에 @FeignClient 어노테이션이 붙어있는 대상 클래스들을 import 하여 fullName와 returnType을 출력해 보았습니다.

 

디버깅 포인트를 찍고 테스트를 수행해 보면 아래와 같이 메서드들을 잘 가져오는 모습을 확인할 수 있습니다.

getProfiles라는 이름의 메서드이며, List<Product>를 반환하고 있습니다.

 

getProduct라는 이름의 메서드이며, Product enum 타입을 반환하고 있습니다.

 

메서드의 반환타입을 가져올 수 있으니 이제 Enum인지 어떻게 확인할 수 있을까요?

 

메서드의 반환타입이 Enum 타입인지 확인하기

    @Test
    @DisplayName("step2 - method 반환값 enum 인지 검사하기")
    fun step2() {
        val rule = ArchRuleDefinition
            .methods()
            .that()
            .areDeclaredInClassesThat()
            .areAnnotatedWith(FeignClient::class.java)
            .should(
                object : ArchCondition<JavaMethod>("feign method 반환값 가져오기") {
                    override fun check(method: JavaMethod, events: ConditionEvents) {
                        val returnType: JavaType = method.returnType
                        val returnTypeClass: JavaClass = returnType.toErasure()

                        if(returnTypeClass.isEnum){
                            events.add(
                                SimpleConditionEvent.violated(
                                    method,
                                    "Feign 메서드 ${method.fullName}에서 enum을 직접 리턴시키고 있습니다."
                                )
                            )
                            return
                        }
                    }
                }
            )
        rule.check(importedClasses)
    }

 

JavaType의 메서드인 toErasure() 메서드를 호출하면 JavaType 타입이 JavaClass로 타입이 변환됩니다.

JavaClass의 경우 isEnum 메서드를 가지고 있어서 해당 값이 Enum인지 판별할 수 있습니다.

 

Product라는 Enum을 반환하는 경우에는 조건문에 들어가 규칙 위반 이벤트를 전파합니다.

enum 타입이 검사됨

 

하지만 List<Product> 인 경우에는 제네릭에 의해 java.util.List 클래스로 취급되어 Enum으로 판별되지 않습니다.

enum 타입에 걸리지 않음

 

제네릭의 내부가 Enum을 활용하는 경우도 검사하기

returnType이 JavaParameterizedType인 경우에는 getActualTypeArguments 메서드를 호출하면 List<JavaType>을 반환하여 List<T> 인 경우, Map<K, V> 인 경우의 구체타입을 JavaType으로 반환합니다.

 

만약 Map인 경우에는 구체타입을 반환할 때 List의 Size가 2개가 되며 반환되는 List가 JavaClass 타입이여서 실제 타입이 Enum인지 확인할 수 있습니다.

Map의 경우 List<JavaType>이 2개 반환된다.

 

이제 제네릭에 활용되는 Enum까지 검사했으니 끝일까요?

 

흔하게 List<Enum>, Enum 등을 반환할 수 있지만 실제로 반환값을 활용할 때는 DTO 등을 사용자가 정의하고 그 내부에 필드로 활용하는 경우가 많습니다.

 

즉, Nested Class를 고려해야 합니다.

사용자가 정의한 Class 내부에 여러 개의 필드가 존재할 수 있으며, 해당 필드는 또 Class 일 수 있습니다.

이 과정을 탐색하기 위해서는 재귀함수를 활용해 볼 수 있습니다.

 

Nested Class 검사하기

data class NestedClass(
    val productResponse: ProductResponse,
    val someValue: String,
    val someInt: Int,
)

data class NestedClassV2(
    val productResponse: Map<String, Product>,
    val someValue: String,
    val someInt: Int,
)

data class ProductResponse(
    val product: Product,
)

enum class Product{
    PRODUCT_A,
    PRODUCT_B
}

위와 같은 NestedClass와 NestedClassV2를 반환값으로 정의하면 어떨까요?

내부 필드에 Map안에 Enum이 선언되어 있으며, 내부 필드(클래스)의 내부에 Enum이 선언되어 있습니다.

이런 경우도 step3 버전으로 감지할 수 있을까요?

 

규칙 위반에는 걸리지 않지만 디버깅을 해보면 실마리를 찾을 수 있습니다.

members 필드에 fields 사이즈가 3이며 해당 내부에 JavaField로 활용한 내부 프로퍼티들을 볼 수 있습니다.

 

JavaField의 경우에도 getRawType 메서드를 호출하면 JavaClass로 변환되거나 getType 메서드를 호출하면 JavaType으로 변환할 수 있습니다.

 

즉, JavaField의 경우에도 JavaClass로 변환하면 Enum인지 확인할 수 있습니다.

 

최종적으로 재귀적으로 Nested Class를 탐색하는 코드를 만들어보고 여기에는 @AllowedClientUsingEnumReturnType 어노테이션이 붙어있는 경우는 예외적으로 허용해 주는 기능도 같이 넣었습니다.

    @Test
    @DisplayName("step4 - Nested Class 검사하기")
    fun step4() {
        val rule = ArchRuleDefinition
            .methods()
            .that()
            .areDeclaredInClassesThat()
            .areAnnotatedWith(FeignClient::class.java)
            .and()
            .areNotAnnotatedWith(AllowedClientUsingEnumReturnType::class.java)
            .should(
                object : ArchCondition<JavaMethod>("feign method 반환값 가져오기") {
                    override fun check(method: JavaMethod, events: ConditionEvents) {
                        val returnType: JavaType = method.returnType
                        val returnTypeClass: JavaClass = returnType.toErasure()

                        if(isUsingEnum(method = method, returnType = returnType, returnTypeClass = returnTypeClass, events = events)){
                            return
                        }

                        checkFieldsRecursively(returnType, events){ dtoClass: JavaClass, field: JavaField, events: ConditionEvents  ->

                            if(isUsingEnum(method = method, returnType = field.type, returnTypeClass = field.rawType, events = events)){
                                return@checkFieldsRecursively true
                            }
                            return@checkFieldsRecursively false
                        }
                    }
                }
            )
        rule.check(importedClasses)
    }

    private fun isUsingEnum(method: JavaMethod, returnType: JavaType, returnTypeClass: JavaClass, events: ConditionEvents) : Boolean{
        if(returnType is JavaParameterizedType){
            // actualTypeArguments를 활용하면 List<T> 등의 구체타입을 가져올 수 있다.
            val actualTypeArguments: List<JavaType> = returnType.actualTypeArguments
            // Map<K,V> 인 경우에는 List의 size가 2개라서 순회해야 한다.
            actualTypeArguments.forEach { javaType: JavaType ->
                if(javaType.toErasure().isEnum){
                    events.add(
                        SimpleConditionEvent.violated(
                            method,
                            "Feign 메서드 ${method.fullName}에서 enum을 직접 리턴시키고 있습니다."
                        )
                    )
                    return true
                }
            }
        }

        if(returnTypeClass.isEnum){
            events.add(
                SimpleConditionEvent.violated(
                    method,
                    "Feign 메서드 ${method.fullName}에서 enum을 직접 리턴시키고 있습니다."
                )
            )
            return true
        }

        return false
    }

    private fun checkFieldsRecursively(
        javaType: JavaType,
        events: ConditionEvents,
        visitedTypes: MutableSet<JavaType> = mutableSetOf(),
        block: (JavaClass, JavaField, ConditionEvents) -> Boolean,
    ){
        if(!visitedTypes.add(javaType)){
            return
        }

        val dtoClass: JavaClass = javaType.toErasure()

        dtoClass.fields.forEach { filed: JavaField ->
            val fieldJavaClass: JavaClass = filed.rawType
            if(block(fieldJavaClass, filed, events)) return

            if(isMyClass(fieldJavaClass)){
                checkFieldsRecursively(fieldJavaClass, events, visitedTypes, block)
            }
        }
    }

 

isUsingEnum 메서드를 활용하여 JavaClass가 Enum인지, 내부 제네릭이 Enum인지 검사하는 메서드를 추출합니다.

 

이후 재귀적으로 호출되는 checkFieldsRecursively 메서드를 구성하고 Nested Class의 내부 필드들도 checkFieldsRecursively를 호출합니다.

 

block의 경우 isUsingEnum를 활용하여 enum인지 체크하고 visitedTypes을 활용하여 DTO들이 순환참조 하는 경우 무한적으로 실행되는 경우를 막았습니다.

 

최종적으로 Nested Class, Generic 등에서도 Enum이 활용되는 경우를 막을 수 있게 되었으며 AllowedClientUsingEnumReturnType를 활용하는 경우에는 예외적으로 enum이 반환될 수 있도록 유연하게 구성하였습니다.

 

예를 들어 자기 자신이 구성해둔 api를 호출하는 경우에는 반환값에 Enum을 활용하는것이 합리적일 수 있습니다.

 

엣지케이스 잡기 - ResponseEntity<T>도 검증하기

Feign Client의 응답값으로는 ResponseEntity<T> 형식도 반환값으로 활용하곤 합니다.

 

이때 ResponseEntity도 제네릭에 속하지만 실제 ResponseEntity<NestedClass> 를 검사할 때는 NestedClass가 내부에 enum을 활용하지만 테스트가 실패하지 않았습니다.

동일하게 List<NestedClass>의 경우도 테스트가 실패하지 않았습니다.

 

즉, List<Enum>의 경우에는 검증이 잘 되었지만 List<NestedClass>의 경우 enum 검증이 잘 되지 않았던 것입니다.

 

위 케이스도 잡기 위해서는 구조를 조금 바꾸어 구체 클래스들을 모두 뽑아서 검증해야 하므로 getActualTypes 메서드를 새롭게 만듭니다.

    /**
     * @param javaType 실제 메서드의 반환타입
     * @return Map<K,V> 인 경우에는 List의 size가 2로 반환된다.
     */
    private fun getActualTypes(javaType: JavaType): List<JavaType> {
        return when(javaType){
            is JavaClass -> listOf(javaType)
            // actualTypeArguments를 활용하면 List<T> 등의 구체타입을 가져올 수 있다.
            // flatMap 활용하는 이유는 Map<String, Map<String, Enum>> 등의 중첩 제네릭이 활용될때 재귀적으로 호출하면서 모든 구체클래스를 들고오기 위함이다.
            is JavaParameterizedType -> javaType.actualTypeArguments.flatMap { getActualTypes(it) }
            else -> throw UnsupportedOperationException("지원하지 않는 타입입니다.")
        }
    }

 

위 메서드는 반환타입에 활용된 모든 구체타입을 뽑아주는 역할을 수행합니다.

 

이후 isUsingEnum 메서드도 구체타입을 넘겨서 검사하도록 변경합니다.

    private fun isUsingEnum(method: JavaMethod, actualTypeArguments: List<JavaType>, events: ConditionEvents) : Boolean{
        actualTypeArguments.forEach { javaType: JavaType ->
            if(javaType.toErasure().isEnum){
                events.add(
                    SimpleConditionEvent.violated(
                        method,
                        "Feign 메서드 ${method.fullName}에서 enum을 직접 리턴시키고 있습니다."
                    )
                )
                return true
            }
        }
        return false
    }

 

재귀적으로 호출되는 checkFieldsRecursively 메서드에서도 내부 필드들의 구체타입을 뽑도록 변경해야 합니다.

    private fun checkFieldsRecursively(
        actualTypeArguments: List<JavaType>,
        events: ConditionEvents,
        visitedTypes: MutableSet<JavaType> = mutableSetOf(),
        block: (actualTypes: List<JavaType> , ConditionEvents) -> Boolean,
    ){
        actualTypeArguments.forEach {javaType: JavaType ->
            if(!visitedTypes.add(javaType)){
                return
            }

            val dtoClass: JavaClass = javaType.toErasure()

            dtoClass.fields.forEach { filed: JavaField ->
                val actualTypes: List<JavaType> = getActualTypes(filed.type)
                if(block(actualTypes, events)) return

                val fieldJavaClass: JavaClass = filed.rawType
                if(isMyClass(fieldJavaClass)){
                    checkFieldsRecursively(actualTypes, events, visitedTypes, block)
                }
            }
        }
    }

 

최종적으로 모든 메서드들을 조합한 코드입니다.

    @Test
    @DisplayName("step4 - Nested Class 검사하기")
    fun step4() {
        val rule = ArchRuleDefinition
            .methods()
            .that()
            .areDeclaredInClassesThat()
            .areAnnotatedWith(FeignClient::class.java)
            .and()
            .areNotAnnotatedWith(AllowedClientUsingEnumReturnType::class.java)
            .should(
                object : ArchCondition<JavaMethod>("feign method 반환값 가져오기") {
                    override fun check(method: JavaMethod, events: ConditionEvents) {
                        val returnType: JavaType = method.returnType
                        val actualTypes = getActualTypes(returnType)

                        if(isUsingEnum(method = method, actualTypeArguments = actualTypes, events = events)){
                            return
                        }

                        checkFieldsRecursively(actualTypes, events){ fieldsActualTypes: List<JavaType>, events: ConditionEvents  ->

                            if(isUsingEnum(method = method, actualTypeArguments = fieldsActualTypes, events = events)){
                                return@checkFieldsRecursively true
                            }
                            return@checkFieldsRecursively false
                        }
                    }
                }
            )
        rule.check(importedClasses)
    }

 

기존에는 제네릭을 그대로 넘겼기 때문에 검증할 수 없었습니다.

바뀐 버전에서는 구체타입을 먼저 추출하도록 변환되어서 actualTypes를 넘겨주면 주기 때문에 내부의 타입을 가지고 값을 검증할 수 있습니다.

 

마무리

사실 ArchUnit을 활용하여 테스트로 여러 가지 규칙을 막았다고 하더라도 누군가 Any로 반환값을 받고 Any를 Enum으로 형변환하는 경우에는 여전히 문제가 발생할 수 있습니다.

 

혹은 예상하지 못한 구멍이 있을 수도 있습니다.

 

혹은 String으로 받고 Enum으로 형변환하는 경우에도 역직렬화 문제는 여전히 발생할 수 있습니다.

 

하지만 해당 테스트는 왜 반환값으로 Enum을 활용할 수 없는지 한번 생각해 보는 기회를 제공하며 Enum을 활용하는 경우 역직렬화 문제가 발생할 수 있음을 새로운 개발자에게 인지하도록 하게 되는 좋은 장치가 될 수 있을 거라 기대합니다.

 

 

참고자료

https://www.archunit.org/

https://d2.naver.com/helloworld/9222129