본문 바로가기

Study

[내일배움캠프 TIL] 30일차 - Arch unit

728x90
반응형

프로젝트에 Arch unit을 적용하기 위해 

공부를 하고 정리해보았습니다.

 

1. ArchUnit 개념 🎯

목적: 기능 테스트가 아닌 '오타, 네이밍 컨벤션, 3계층 설계 규칙'등 을 자동 검증하는 툴.

CI 입구 컷: 규칙 위반 시 빌드가 실패하여 잘못된 코드 유입을 막음.
-> 기본적으로 Juit 테스트 하듯이 하는데, CI에서 테스트 돌릴 때 에러가 있으면 걸릴 수 있는 구조입니다.

📦 의존성 설정 (Root build.gradle)

dependencies {
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
}

2. 실전 예시 코드 💻

① [common 모듈] src/main/java에 작성 (공통 뼈대)

package com.sparta.common.architecture;

import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;

public abstract class BaseArchitectureTest {

    // 규칙 1: controller 패키지 내 클래스는 항상 'Controller'로 끝날 것
    @ArchTest
    public static final ArchRule 컨트롤러_네이밍_규칙 = classes()
            .that().resideInAPackage("..controller..")
            .should().haveSimpleNameEndingWith("Controller");

	  // 규칙: 노션 가이드에 맞춘 4계층 의존성 검증
		@ArchTest
		public static final ArchRule 네계층_클린_아키텍처_규칙 = layeredArchitecture()
		        .consideringAllDependencies()
		        // 1. 노션에 적힌 4대 계층(레이어)의 주소를 정의합니다.
		        .layer("Presentation").definedBy("..presentation..")
		        .layer("Application").definedBy("..application..")
		        .layer("Domain").definedBy("..domain..")
		        .layer("Infrastructure").definedBy("..infrastructure..")
		        
		        // 2. 출입 통제 규칙 (상위 ➔ 하위 방향만 허용)
		        // Presentation(표현)은 대문이므로 그 어떤 레이어도 접근할 수 없음
		        .whereLayer("Presentation").mayNotBeAccessedByAnyLayer()
		        
		        // Application(응용)은 오직 Presentation에서만 접근 가능
		        .whereLayer("Application").mayOnlyBeAccessedByLayers("Presentation")
		        
		        // Domain(도메인)은 상위 계층인 Application에서만 접근 가능 
		        .whereLayer("Domain").mayOnlyBeAccessedByLayers("Application")
		        
		          // 인프라는 실행 지휘관인 Application과 껍데기를 쥐고 있는 Domain 둘 다 접근 허용
            .whereLayer("Infrastructure").mayOnlyBeAccessedByLayers("Application", "Domain");
}

3. 도메인 네이밍 검증

어떨 때 유용한가요?

  • 도메인별 파일명 통일: user 패키지 안에 실수로 MemberController를 만들거나, 오타로 UsreService라고 적어 컨벤션이 깨지는 것을 빌드 시점에 원천 차단합니다.
  • 오타 및 휴먼 에러 방지: 컴퓨터가 "특정 패키지 하위 클래스들은 무조건 지정한 도메인 이름(Prefix)으로 시작해야 한다"를 강제하므로, 100% 통일된 깔끔한 파일명을 유지할 수 있습니다.

🛠️ 부모 클래스 구현 (BaseArchitectureTest)

이 메서드는 규칙을 직접 실행하지 않고, 자식들이 넘겨주는 도메인 이름(Prefix)에 맞춰 검증 규칙을 동적으로 만들어 배달하는 역할을 합니다.

package com.sparta.common.architecture;

import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.lang.CompositeArchRule;
import java.util.List;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;

public abstract class BaseArchitectureTest {

    /**
     * 💡 도메인 클래스 네이밍 종합 검증기
     * 주입받은 prefix(예: "User")를 기반으로, 해당 패키지 내 핵심 클래스들의 네이밍을 한 번에 확인합니다.
     */
    public static ArchRule 도메인_클래스_네이밍_종합_검증(String prefix) {
        String lowerPrefix = prefix.toLowerCase(); // 예: "User" -> "user"

        // List.of(...)로 각각의 계층별 규칙들을 하나로 묶어서 리턴합니다.
        return CompositeArchRule.of(List.of(
                // 1. Controller 검증 (예: ..user.presentation.. 하위 Controller는 'User'로 시작)
                classes().that().resideInAPackage(".." + lowerPrefix + ".presentation..")
                        .and().haveSimpleNameEndingWith("Controller")
                        .should().haveSimpleNameStartingWith(prefix),

                // 2. Service 검증 (예: ..user.application.. 하위 Service는 'User'로 시작)
                classes().that().resideInAPackage(".." + lowerPrefix + ".application..")
                        .and().haveSimpleNameEndingWith("Service")
                        .should().haveSimpleNameStartingWith(prefix),

                // 3. Repository 인터페이스 검증 (예: ..user.domain.. 하위 Repository는 'User'로 시작)
                classes().that().resideInAPackage(".." + lowerPrefix + ".domain..")
                        .and().haveSimpleNameEndingWith("Repository")
                        .should().haveSimpleNameStartingWith(prefix),

                // 4. Repository 구현체 검증 (예: ..user.infrastructure.. 하위 Impl은 'User'로 시작)
                classes().that().resideInAPackage(".." + lowerPrefix + ".infrastructure..")
                        .and().haveSimpleNameEndingWith("RepositoryImpl")
                        .should().haveSimpleNameStartingWith(prefix)
        ))
        .allowEmptyShould(true) // 👈 초기 개발 단계에서 클래스가 0개여도 빌드가 터지지 않게 방어!
        .as(prefix + " 도메인 패키지 내 핵심 클래스들은 반드시 '" + prefix + "' 접두사로 시작해야 합니다.");
    }
}

🛠️ 자식 클래스 적용 예시 (UserArchitectureTest)

각 마이크로서비스 모듈에서는 아래처럼

1) 패키지 경로를 지정하고,

2) 부모의 종합 검증기에 도메인 이름만 주입

package com.sparta.userservice;

import com.sparta.common.architecture.BaseArchitectureTest;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.junit.ImportOption;
import com.tngtech.archunit.lang.ArchRule;

// 1. 스캔 범위 설정: 우리 서비스 패키지만 지정하고, 외부 JAR는 스캔에서 제외 (성능 최적화)
@AnalyzeClasses(
    packages = "com.sparta.userservice",
    importOptions = {
        ImportOption.DoNotIncludeJars.class,
        ImportOption.DoNotIncludeTests.class
    }
)
public class UserArchitectureTest extends BaseArchitectureTest {

    // 2. 실행 스위치 켜기: @ArchTest를 붙이고 부모의 종합 검증기에 "User" 주입
    @ArchTest
    public static final ArchRule 유저_도메인_네이밍_종합_규칙 = 도메인_클래스_네이밍_종합_검증("User");
}

4. 실무형 ArchUnit 테스트 케이스 🎯

실제 프로젝트 운영 시 반드시 필요한 핵심 규칙 3가지입니다. .as()를 통해 실패 시 가이드를 명시합니다.

// 1. 🔄 의존성 규칙: common -> 그 어떤 서비스 모듈도 참조 금지 (순환 참조 방지)
@ArchTest
static final ArchRule common_module_dependency_rule = 
    noClasses().that().resideInAPackage("..common..")
    .should().dependOnClassesThat().resideInAPackage("..service..") // 💡 특정 서비스가 아닌 모든 *service 모듈 차단
    .as("공통(common) 모듈은 상위 서비스 모듈들을 참조할 수 없습니다.");

// 2. 패키지 구조 규칙: @Service는 반드시 application 내부 또는 하위에 위치
@ArchTest
static final ArchRule service_package_rule = 
    classes().that().areAnnotatedWith(org.springframework.stereotype.Service.class)
    .should().resideInAPackage("..application..")
    .as("@Service 어노테이션이 붙은 클래스는 반드시 application 패키지 내부에 위치해야 합니다.");

// 3. 인터페이스 구현 규칙: Repository 인터페이스는 반드시 'Domain'에 위치 (DIP 준수)
@ArchTest
static final ArchRule repository_interface_location_rule = 
    classes().that().areInterfaces()
    .and().haveSimpleNameEndingWith("Repository")
    .should().resideInAPackage("..domain..")
    .as("Repository 인터페이스(껍데기)는 순수 비즈니스 레이어인 ..domain.. 패키지에 위치해야 합니다.");

// 4. 추가 규칙: Repository 실제 구현체(Impl)는 반드시 'Infrastructure'에 위치
@ArchTest
static final ArchRule repository_implementation_location_rule = 
    classes().that().haveSimpleNameEndingWith("RepositoryImpl")
    .should().resideInAPackage("..infrastructure..")
    .as("Repository의 실제 JPA/QueryDSL 구현체(Impl)는 ..infrastructure.. 패키지에 위치해야 합니다.");

💡 [중요] ArchUnit 테스트 빈 클래스 예외 처리 규칙

초기 개발 단계에서 아직 만들지 않은 클래스(예: ~RepositoryImpl 등)가 있으면 ArchUnit이 "검사할 대상이 없다"며 빌드를 터뜨립니다.

이를 방지하기 위해 모든 규칙의 .as() 메서드 바로 직전에 .allowEmptyShould(true) 설정을 필수로 장착합니다.

[구현 예시]

@ArchTest
static final ArchRule repository_implementation_location_rule = 
    classes().that().haveSimpleNameEndingWith("RepositoryImpl")
    .should().resideInAPackage("..infrastructure..")
    .allowEmptyShould(true) // 👈 개발 단계 필수 방어막!
    .as("Repository의 실제 구현체는 ..infrastructure.. 패키지에 위치해야 합니다.");

5. 폴더 구조 시각화 📂

root-project (루트 프로젝트)
├── common (공통 모듈)
│   └── src/main/java/com/sparta/common/
│       ├── architecture/ (공통 아키텍처 검증 뼈대 및 BaseTest)
│       └── dto/ (공통 응답 객체)
│
├── user-service (유저/인증 관련 마이크로서비스 모듈)
│   ├── src/main/java/com/sparta/userservice/
│   │   ├── auth/ (인증 관련 도메인 디렉토리)
│   │   │   ├── presentation/ (Controller, Web DTO 계층)
│   │   │   ├── application/ (Service, Command DTO 계층)
│   │   │   ├── domain/ (⭐️ 방식 B: @Entity가 포함된 순수 비즈니스 및 인터페이스 계층)
│   │   │   └── infrastructure/ (JPA Repository, 외부 연동 기술 계층)
│   │   │
│   │   └── user/ (유저 관련 도메인 디렉토리)
│   │       ├── presentation/ (Controller, Web DTO 계층)
│   │       ├── application/ (Service, Command DTO 계층)
│   │       ├── domain/ (⭐️ 방식 B: @Entity가 포함된 순수 비즈니스 및 인터페이스 계층)
│   │       └── infrastructure/ (JPA Repository, 외부 연동 기술 계층)
│   │
│   └── src/test/java/com/sparta/userservice/
│       └── UserArchitectureTest.java (4계층 구조 자동 검증 테스트)
│
└── api-gateway (API 게이트웨이 모듈)

6. 유지보수를 위한 팁 💡

 💡 성능 최적화: 프로젝트가 커질수록 ArchConfiguration.get().setExtension(...) 설정을 통해 스캔 범위를 조정해야 빌드 시간이 늘어나지 않습니다. 특정 패키지만 스캔하도록 제한하는 것이 '시니어의 센스'입니다.

package com.sparta.companyservice.architecture;

import com.sparta.common.architecture.BaseArchitectureTest;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ImportOption;

// 🎯 importOptions 설정을 추가하여 외부 라이브러리와 테스트 폴더는 스캔에서 원천 차단합니다!
@AnalyzeClasses(
    packages = "com.sparta.companyservice",
    importOptions = {
        ImportOption.DoNotIncludeJars.class,  // 👈 꿀팁: 외부 라이브러리 스캔 제외 (성능 최적화 핵심)
        ImportOption.DoNotIncludeTests.class  // 👈 테스트 코드끼리 검사하는 것 제외
    }
)
public class CompanyArchitectureTest extends BaseArchitectureTest {
    // 공통 규칙(컨트롤러 네이밍 등)을 그대로 이어받으므로 여기는 텅 비워둡니다.
}
반응형