아키텍처 적합도 함수로 프로젝트 구조 및 코드의 역할과 책임의 강제성 부여하기

아키텍처 적합도 함수의 개념과 Spring Boot를 사용하여 프로젝트 구조 및 코드의 역할과 책임의 강제성을 부여하는 방법을 알아봅니다.

#Spring Boot#Architecture Fitness Function#Archunit
2026년 03월 01일
21

STEP 1. Java의 언어적 특성에 따른 아키텍처 구축의 한계

Java의 언어적 특성에 따른 아키텍처 구축의 한계를 가상의 비기능적 요구 사항을 정의하고 구체적으로 설명하겠습니다.

비기능적 요구사항
  1. Clean Architecture, Hexagonal Architecture, DDD 아키텍처를 준수합니다.
  2. 완벽한 도메인 캡슐화를 통해 구현합니다.
  3. 순수 언어적 강제를 Java 문법을 통해 구현합니다.
  4. 순수 domain 모델과 persistence 모델은 분리합니다.
  5. 게층간 변환 도구는 Mapstruct를 사용하고, 객체 변환 매커니즘은 Builder를 사용합니다.
  6. common 모듈과 shared-kernel 모듈을 사용합니다.
    • common 모듈: 공통 모듈로 모든 모듈에서 공통으로 사용되는 모듈입니다.
    • shared-kernel 모듈: 공통 도메인 모듈로 모든 도메인에서 공통으로 사용되는 모듈입니다.
    • common 모듈은 shared-kernel 모듈을 의존할 수 있지만, shared-kernel 모듈은 common 모듈을 의존할 수 없습니다.
  7. 내부 Bounded Context는 외부의 Bounded Context를 의존하지 않습니다.

비기능적 요구사항 구체화
  1. 아키텍처 관련 규칙

  2. 완벽한 도메인 캡슐화

    (1) 도메인의 생성자 Builder는 persistence, service 계층에서 호출하는 것을 강제합니다.
    (2) 도메인 생성자 Builder는 도메인 객체의 복원과 내부 팩토리 메소드에서만 사용할 수 있습니다.
    => 객체의 복원에 사용되는 생성자 Builder이기 때문에 도메인의 모든 필드를 가지는 생성자를 기본적으로 생성합니다.
    (3) 도메인 생성자는 private 접근 제어자로 강제합니다.
    => 외부에서 new를 사용하여 도메인 객체를 생성하는 것을 막습니다.

    1. 순수 언어적 강제
      (1) 접근제어자를 통해 코드의 접근 권한을 제한합니다.

구현 방향성
  1. 아키텍처: Modular Monolith 기반의 아키텍처 구조 적용
    (1) 계층구조
    • Lev 0: Bounded Context (Vertical Slicing)
    • Lev 1: Horizontal Slicing (adapter, application, domain)
      (2) 특징
    • Bounded Context: 연관도가 높은 비즈니스 기능 집합이며 한 개 이상의 Aggregate Root를 가질 수 있습니다.
    • Horizontal Slicing: 계층구조를 따라 코드를 분리합니다.
  2. 내부 Bounded Context는 외부의 Bounded Context를 의존하지 않습니다.

🤔 애플리케이션 코드를 기준으로 비기능적 요구사항을 준수하는 기능 구현이 가능한가?

결론부터 말씀드리면 애플리케이션 코드만으로는 비기능적 요구사항을 충족시키는 것은 어렵습니다.
adpater구현체에서는 persistence 모델을 domain 모델로 변환하기 위해 Mapstruct를 사용하게 될 것입니다.
Mapstruct를 Builder 기반으로 객체 변환을 약속하고 있기 때문에 domain의 생성자 Builder는 adapter구현체에서 호출이 가능해야하는 상태여야합니다. 즉, 지금의 구조에서는 domain의 생성자 빌더는 PUBLIC ACCESSIBLE 상태여야 합니다.
하지만 PUBLIC ACCESSIBLE 상태를 가지게 되는 순간 모든 코드에서 생성자 빌더를 호출할 수 있게 되고, 이는 도메인 캡슐화를 위배하게 됩니다.

🤔 해결방법 생각하기

1. Java의 Reflection을 사용하여 해결합니다.
: Java의 언어적 한계를 뛰어넘기 위해 Reflection을 사용하여 해결할 수 있을 것으로 생각합니다.
하지만 Reflection을 사용하는 것은 코드의 복잡도를 높이고 가독성을 떨어뜨리는 요소가 될 수 있으며 성능 저하가 발생할 수 있습니다.
따라서 지금 해결하고자 하는 문제에 Reflection을 사용하는 것은 배제해야합니다.

2. 실용적인 코드로 타협
: 이 방법은 Java의 언어적 한계를 뛰어넘는 방법이 아니라 실용적인 코드로 타협하는 방법입니다.
domain 모델의 생성자 Builder에 PUBLIC ACCESSIBLE을 허용하고, 내부적 팀 규칙을 엄격하게 정립하여 도메인 캡슐화 등의 문제를 해결하고 코드의 오염이 되지 않도록 규정합니다.
하지만 이 방법은 코드의 불안정성을 높이고 엄격한 리뷰가 진행되지 않는다면 Builder의 양면성에 의거하여 애플리케이션 서비스 계층에서 비즈니스 불변성 검증(Factory Method)을 우회하여 객체를 무분별하게 생성(Creation)할 수 있는 치명적인 구멍이 생깁니다
따라서 지금 해결하고자 하는 문제에 실용적인 코드로 타협하는 것은 차선책으로 남겨두어야합니다.

3. 아키텍처 적합도 함수 도입

아키텍처

  1. 거시적 설계(Macro Architecture)[#macro-architecture]
    : 아키텍처의 구조를 정의하는 규칙을 정의합니다.
    • 계층간 의존성 방향
  2. 미시적 설계(Micro Architecture)[#micro-architecture]
    : 매크로 아키텍처가 의도한 바를 실제 코드에서 어떻게 구현하고 지켜낼 것인지에 대한 결정입니다.
    • ex: 도메인 객체의 생성은 팩토리 메서드로만 제한한다.
    • ex: Builder는 복원 및 내부 펙토리 메소드를 만들때만 사용한다.

🤔 또 다른 방식, Spring Modulith

: 아키텍처 적합도 평가를 위해 Spring Modulith를 사용하는 방법도 있다고 합니다.
아래는 앞으로 사용할 ArthUnit framework와 Spring Modulith를 비교한 사진입니다.

아키텍처 적합도 함수는 아키텍처의 객관적인 무결성 평가를 제공합니다.
가령 domain의 생성자 Builder를 adapter구현체와 domain 내부에서만 사용할 수 있도록 강제하여 도메인 캡슐화를 유지할 수 있습니다.
따라서 지금 해결하고자 하는 문제 해결에 적합한 방식으로 판단하고 최선책으로 도입할 수 있습니다.

STEP 2. 아키텍처 적합도 함수(Architecture Fitness Function)

앞서 언급했지만 아키텍처 적합도 함수는 아키텍처의 특정 품질 속성(결합도, 계층 의존성 등)이 시간이 지나도 유지되는지를 자동으로 측정하는 메커니즘으로 아키텍처의 객관적인 무결성 평가를 제공합니다.

STEP 2-1. 아키텍처 적합도 함수의 검증 방식

애플리케이션이 빌드 되기 이전에 테스트 코드를 강제하고, 테스트 코드에 아키텍처 적합도 함수를 적용하여 아키텍처의 무결성을 검증합니다.
이 방식은 애플리케이션이 빌드 되기 이전에 아키텍처의 무결성을 검증할 수 있기 때문에 객관적인 아키텍처 무결성 평가를 제공할 수 있습니다.

🤔 테스트 코드는 애플리케이션 코드 기반으로 돌아가야하지 않나?

: 전통적인 테스트는 애플리케이션 동작 검증을 기반으로 두었습니다. (유닛 테스트, 슬라이스 테스트)
하지만 진화적 아키텍처에서는 전통적인 테스트 의미를 넘어 사용자 정의 컴파일러(Architecture Fitness Function)로 취급하는 패러다임의 전환이 이루어졌습니다.
즉, 애플리케이션이 테스트에 의존하는 것이 아니라, 언어 문법의 한계(public)를 보완하기 위해 컴파일/빌드 프로세스를 확장한 개념의 접근 방법입니다.

저는 아키텍처 적합도 검사를 위해 ArchUnit Framework를 사용하였습니다.

STEP 3. ArchUnit framework

ArchUnit framework는 Java 또는 Kotlin을 바이트코드 수준의 정적 분석 및 구조 테스트를 목적으로합니다.
ArchUnit은 오직 테스트 코드에서만 동작합니다. 따라서 ArchUnit을 도입하여 전체 프로젝트의 아키텍처 적합도 평가를 하게 될 경우 아키텍처 규칙이 깨지면 빌드 자체를 실패(Build Fail)시켜 배포를 막는 CI/CD 파이프라인 연동은 필수적으로 적용해야합니다.

ArchUnit을 도입하여 비기능 요구사항 구체화를 만족시키는 아키텍처 적합도 함수 코드의 구현을 해보겠습니다.

STEP 3-1. Micro Architecture 적합도 함수

[분석하기]

  1. Micro Architecture 적합도 함수 범위
    (1) domain 모델의 생성자 Builder는 adapter구현체와 domain 내부에서만 사용할 수 있도록 강제합니다.
    (2) 도메인 생성자 Builder는 복원 및 내부 펙토리 메소드를 만들때만 사용할 수 있습니다.
    (3) 도메인 생성자는 private 접근 제어자로 강제합니다. => 순수 Java 문법의 제어.

[구현하기]

  1. 명확한 클래스명: DomainRuleArchTest.java
    [포함 범위]: Micro Architecture 적합도 함수 범위의 (1), (2)
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
 
import static com.tngtech.archunit.core.domain.JavaCall.Predicates.target;
import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage;
import static com.tngtech.archunit.core.domain.properties.HasName.Predicates.name;
import static com.tngtech.archunit.core.domain.properties.HasOwner.Predicates.With.owner;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
 
    /**
     *     1. 도메인 빌더 접근제어
     *         - domain계층과 Driving adapter 하위 디렉토리인 mapper를 제외한 다른 패키지에서는
     *         도메인 패키지에 속한 클래스의 builder() 메서드를 호출해서는 안 되는 규칙을 가집니다.
     * 
     *     2. 거짓 양성 방어: allowEmptyShould(false)
     *         - builder 메소드가 프로젝트에서 완전히 제거되어 테스트가 무조건 성공하는 현상을 방지합니다.
     */
@AnalyzeClasses(
        // ArchUnit이 바이트코드를 분석할 루트 패키지를 지정합니다. 이 패키지 하위에 있는 모든 클래스가 분석 대상이 됩니다.
        packages = "io.hirecore.hirecorememberserver",
        // 애플리케이션의 프로덕션 코드(Main)만 검증 대상으로 삼습니다. 테스트 코드(Test)는 검증에서 제외하여 테스트 작성의 자유도를 보장하고 검증 속도를 높입니다.
        importOptions = ImportOption.DoNotIncludeTests.class
)
class DomainRuleArchTest {
 
    // 2. 아키텍처 규칙 정의: 이 필드가 검증해야 할 하나의 '아키텍처 규칙(Rule)'임을 나타냅니다.
    @ArchTest
    static final ArchRule builder_ShouldOnlyBeCalledByDomainOrPersistence =
            // [검증 시작] "어떤 클래스도 ~해서는 안 된다"라는 부정형 검증의 시작점입니다.
            noClasses()
                    // [필터링] 도메인 계층과 영속성 어댑터 계층 '외부'에 위치한 클래스들만 타겟팅합니다.
                    .that().resideOutsideOfPackages(
                            "io.hirecore.hirecorememberserver.modules.*.domain..",
                            "io.hirecore.hirecorememberserver.modules.*.adapter.out.persistence.*.mapper.."
                    )
                    // [행위 검증] 위에서 타겟팅된 외부 클래스들은 아래 조건을 만족하는 메서드를 호출해서는 안 됩니다.
                    .should().callMethodWhere(
                            // 호출 대상 메서드의 이름이 "builder" 이고,
                            target(name("builder"))
                                    // 그 메서드를 소유한(owner) 클래스가 "io.hirecore.hirecorememberserver.modules.*.domain.." 패키지 내부에 위치할 경우.
                                    // 즉, "도메인 객체의 builder() 메서드 호출을 금지한다"는 의미입니다.
                                    .and(target(owner(resideInAPackage("io.hirecore.hirecorememberserver.modules.*.domain.."))))
                    )
                    // [안전 장치] 향후 리팩토링으로 'builder' 메서드가 제거되거나 패키지 구조가 바뀌어 검사할 대상이 아예 없어졌을 때, 
                    // 테스트가 무조건 통과(Pass)해버리는 긍정 오류(False Positive)를 방지하기 위해 강제로 테스트를 실패시킵니다.
                    .allowEmptyShould(false)
                    // [실패 사유] 테스트가 실패했을 때 동료 개발자가 의도를 바로 파악할 수 있도록 출력할 메시지입니다.
                    .because(
                            "도메인 객체의 캡슐화를 훼손하지 않도록, Service 등 외부 계층에서는 빌더 대신 비즈니스 의미가 부여된 정적 팩토리 메서드를 사용해야 합니다."
                    );
}

코드설명

builder_ShouldOnlyBeCalledByDomainOrPersistence는 domain계층과 Driving adapter 하위 디렉토리인 mapper를 제외한 다른 패키지에서,
도메인 패키지에 속한 클래스의 builder() 메서드를 호출해서는 안 되는 Micro Architecture 적합도 함수를 작성하여 요구사항 1번2번을 만족시킵니다.

자세한 코드의 설명은 주석으로 대체합니다.

STEP 3-2. Macro Architecture 적합도 함수

[분석하기]

  1. Macro Architecture 적합도 함수 범위
    (1) 계층간 의존성 방향 => Clean Architecture, Hexagonal Architecture 준수
    (2) Bounded Context 간 의존성 방향 => DDD원칙을 준수하여 내부 Bounded Context는 외부의 Bounded Context를 의존하지 않습니다.
    (3) common 모듈은 shared-kernel 모듈을 의존할 수 있지만, shared-kernel 모듈은 common 모듈을 의존할 수 없음

[구현하기]

  1. Macro Architecture 적합도 함수 범위의 (1)번: HexagonalArchitectureRuleTest.java
  2. Macro Architecture 적합도 함수 범위의 (2) + (3)번: ModuleBoundaryArchTest.java

1. Macro Architecture 적합도 함수 범위의 (1)번: HexagonalArchitectureRuleTest 해결하기

@AnalyzeClasses(
        packages = "io.hirecore.hirecorememberserver",
        importOptions = ImportOption.DoNotIncludeTests.class
)
class HexagonalArchitectureArchTest {
 
    @ArchTest
    static final ArchRule layer_dependencies_are_respected =
            layeredArchitecture()
                    .consideringAllDependencies()
                    // 1. 계층 정의
                    .layer("Adapter").definedBy("io.hirecore.hirecorememberserver.modules.*.adapter..")
                    .layer("Application").definedBy("io.hirecore.hirecorememberserver.modules.*.application..")
                    .layer("Domain").definedBy("io.hirecore.hirecorememberserver.modules.*.domain..")
 
                    // 2. 의존성 규칙 정의 (접근 허용 주체만 명시)
 
                    // [Domain]은 가장 안쪽에 있으므로 Application과 Adapter만 접근 가능합니다.
                    // (common 패키지 등 외부 계층이 Domain을 직접 호출하는 것을 막습니다)
                    .whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Adapter")
 
                    // [Application]은 오직 Adapter에서만 진입할 수 있습니다.
                    // (이 규칙 덕분에 Domain 계층이 Application을 역방향으로 참조하는 것이 자동 차단됩니다)
                    .whereLayer("Application").mayOnlyBeAccessedByLayers("Adapter")
 
                    // [Adapter]는 최외곽이므로 그 어떤 내부 계층도 Adapter에 접근할 수 없습니다.
                    // (이 규칙 덕분에 Application이나 Domain이 Adapter를 참조하는 DIP 위반이 완벽히 차단됩니다)
                    .whereLayer("Adapter").mayNotBeAccessedByAnyLayer()
 
                    .because("헥사고날 아키텍처의 의존성 역전 원칙(DIP)에 따라 내부 계층은 외부 계층을 알 수 없어야 합니다.");
}

코드설명

mayOnlyAccessLayers() 메서드는 지나친 엄격한 검증 기준을 가집니다.
.whereLayer("Adapter").mayOnlyAccessLayers("Application", "Domain")는 Adapter 계층이 Application 계층과 Domain 계층만 접근할 수 있도록 강제하기 때문에 Java의 기본 클래스, common 모듈, shared-kernel 모듈을 제외한 모든 클래스가 Adapter 계층에서 접근할 수 없도록 강제합니다.
이 문제는 빌드 실패로 이어지게 되고, 이를 해결하기 위해서는 계층이 누굴 만날지(Access)를 통제하는 대신, 누가 나를 만날 수 있는지(AccessedBy)만 엄격하게 통제하여 작성하는 것이 핵심입니다.

위와 같은 코드를 작성하여 Macro Architecture 적합도 함수 범위의 1번, 2번을 만족시킵니다.

2. Macro Architecture 적합도 함수 범위의 (2)번 + (3)번: ModuleBoundaryArchTest 해결하기

@AnalyzeClasses(
        packages = "io.hirecore.hirecorememberserver",
        importOptions = ImportOption.DoNotIncludeTests.class
)
class ModuleBoundaryArchTest {
    @ArchTest
    static final ArchRule root_common_should_not_depend_on_modules =
            noClasses()
                    .that().resideInAPackage("io.hirecore.hirecorememberserver.common..")
                    .should().dependOnClassesThat().resideInAPackage("io.hirecore.hirecorememberserver.modules..")
                    .allowEmptyShould(false)
                    .because("Common 패키지는 전역적인 공통 로직이므로 특정 비즈니스 모듈에 의존해서는 안 됩니다.");
 
    //todo: DDDArchTest로 분리하는 것이 좋을 것 같긴합니다.
    @ArchTest
    static final ArchRule modules_should_be_independent =
            SlicesRuleDefinition.slices()
                    // 동적 매칭: modules 바로 아래 디렉토리(account, profile 등)를 각각의 슬라이스로 인식
                    .matching("io.hirecore.hirecorememberserver.modules.(*)..")
                    //  .should().beFreeOfCycles()   // 순환 참조만 금지 (단방향 참조 허용)
                    .should().notDependOnEachOther() // 순환·단방향 참조 모두 금지 (완전한 모듈 격리)
                    .because("비즈니스 모듈 간의 결합도를 낮추고 독립성을 보장하기 위해 슬라이스 간 순환/직접 참조를 금지합니다.");
}

코드설명

root_common_should_not_depend_on_modules는 common 모듈은 modules 모듈을 의존할 수 없도록 강제합니다.
modules_should_be_independent는 modules 모듈 간의 순환/직접 참조를 금지하여 모듈 간의 결합도를 낮추고 독립성을 보장합니다.

위와 같은 코드를 작성하여 Macro Architecture 적합도 함수 범위의 2번, 3번을 만족시킵니다.