--- title: Clean Architecture를 패키지 구조로 강제하기 source_type: blog status: draft confidence: unknown tags: [blog, ca-tmpl, architecture, archunit, clean-architecture, package-structure] related_projects: [ca-tmpl] last_reviewed: canonical_sources: [] audience: backend-engineer target_publish: status_label: draft --- # Clean Architecture를 패키지 구조로 강제하기 > 그림으로 그린 계층 구조가 6개월 뒤에도 그대로 지켜지려면, 문서가 아니라 빌드가 그걸 지켜줘야 합니다. ## TL;DR - Clean Architecture는 그림으로 보면 단순하지만, 시간이 지나면 controller가 repository를 직접 부르고 application이 web DTO를 받는 식으로 흐트러지기 쉽습니다. - ca-tmpl은 이 경계를 **두 겹**으로 강제합니다: Gradle 모듈 의존성(1차 경계) + ArchUnit import 규칙(2차 경계). - `shared-contract`는 "아무 공통 코드나 넣는 곳"이 아니라 **운영 계약만 허용하는 제한된 통로**로 정의했습니다. - 검증 범위는 로컬/개발까지입니다. 운영에서 이 구조가 유지보수 비용을 얼마나 줄였는지는 아직 측정하지 않았습니다. --- ## 1. 그림은 쉽지만, 코드는 시간이 지나면 배신한다 Clean Architecture를 그림으로 그리면 단순합니다. domain은 가장 안쪽에 있고, application은 use case를 담고, adapter는 바깥쪽 기술(웹, DB, 외부 API)을 맡습니다. 문제는 그림이 아니라 **시간이 지난 뒤의 코드**입니다. - controller가 편의상 repository를 직접 부르기 시작하고 - application이 web DTO를 파라미터로 받기 시작하고 - "일단 공통이니까"라며 shared 패키지가 온갖 것의 dumping ground가 되기 시작하면 구조는 다이어그램에만 남고 실제 코드는 이름만 Clean Architecture인 상태가 됩니다. ca-tmpl은 이 문제를 패키지 네이밍 컨벤션만으로 풀지 않았습니다. **Gradle 멀티모듈을 1차 경계**로 두고, **ArchUnit을 2차 경계**로 뒀습니다. Build graph에서는 어떤 모듈이 어떤 모듈을 의존할 수 있는지 검사하고, source import graph에서는 domain/application/adapter/shared-contract가 금지된 타입을 가져오는지 검사합니다. 즉 "Clean Architecture로 짰다"가 목표가 아니라, **깨지는 순간 빌드가 알려주는 skeleton**을 만드는 게 목표였습니다. --- ## 2. 모듈 구조 먼저 보기 현재 ca-tmpl의 production root는 `dev.caskeleton`이고, 다음 모듈로 나뉘어 있습니다. ```groovy include 'app-bootstrap' include 'domain-core' include 'application-core' include 'adapter-web' include 'adapter-persistence-rdbms' include 'adapter-persistence-postgresql' include 'adapter-outbound' include 'adapter-identifier' include 'shared-contract' include 'sample-portfolio' ``` `app-bootstrap`은 composition root라서 Spring Boot의 component scan 대상을 명시적으로 나열합니다. ```java @SpringBootApplication( scanBasePackages = { "dev.caskeleton.bootstrap", "dev.caskeleton.adapter", "dev.caskeleton.application", "dev.caskeleton.domain", "dev.caskeleton.shared" }) public class CaSkeletonApplication { public static void main(String[] args) { SpringApplication.run(CaSkeletonApplication.class, args); } } ``` 참고로 이 10개 모듈 구성은 처음부터 이랬던 건 아니에요. 초기 설계는 8개 모듈이었고, 이후 `adapter-identifier`와 persistence 세분화가 추가됐습니다. 그래서 이 글에서는 "지금 HEAD 기준 모듈 수"와 "그 결정이 언제 검증됐는지"를 섞어 말하지 않으려고 합니다. --- ## 3. 1차 경계: Gradle이 프로젝트 의존성을 막는다 핵심은 `verifyCleanArchitectureDependencies`라는 커스텀 Gradle task입니다. 각 모듈이 의존할 수 있는 모듈을 whitelist로 들고 있다가, 허용되지 않은 `project()` 의존성이 들어오면 빌드를 실패시킵니다. ```groovy tasks.register('verifyCleanArchitectureDependencies') { doLast { Map> allowedProjectDependencies = [ 'domain-core' : ['shared-contract'] as Set, 'application-core' : ['domain-core', 'shared-contract'] as Set, 'adapter-web' : ['application-core', 'domain-core', 'shared-contract'] as Set, 'adapter-outbound' : ['application-core', 'domain-core', 'shared-contract'] as Set, 'shared-contract' : [] as Set ] // 허용되지 않은 ProjectDependency가 있으면 GradleException을 던진다. } } ``` 예를 들어 web adapter가 persistence adapter나 outbound adapter를 직접 의존하면 안 됩니다. `app-bootstrap`은 composition root라서 여러 모듈을 조립할 수 있지만, **production 코드가 `sample-portfolio`에 의존하는 것은 금지**됩니다. 샘플 코드는 학습과 fixture 역할을 하는 소비자 모듈이지, production core가 기대는 기반 모듈이 아니기 때문입니다. ```java @ArchTest static final ArchRule PRODUCTION_CODE_DOES_NOT_DEPEND_ON_SAMPLE_PORTFOLIO = noClasses() .that() .resideOutsideOfPackage("..sample.portfolio..") .should() .dependOnClassesThat() .resideInAPackage("..sample.portfolio.."); ``` --- ## 4. 2차 경계: ArchUnit이 import 방향을 막는다 모듈 단위 경계만으로는 부족합니다. 같은 모듈 안에서도 패키지 간 import 방향이 흐트러질 수 있거든요. 여기서부터는 ArchUnit이 맡습니다. **domain은 순수해야 합니다.** ```java @ArchTest static final ArchRule DOMAIN_IS_PURE = noClasses() .that() .resideInAPackage("..domain..") .should() .dependOnClassesThat() .resideInAnyPackage( "org.springframework..", "jakarta.persistence..", "org.hibernate..", "lombok..", "..application..", "..adapter..", "..bootstrap..") .allowEmptyShould(true); ``` domain 패키지는 Spring, JPA, Hibernate, Lombok은 물론이고 application, adapter, bootstrap에도 의존할 수 없습니다. **application도 마찬가지로 갇혀 있습니다.** adapter, bootstrap, persistence, Spring Web, Hibernate에 의존하지 못하고, Spring의 `@Transactional`도 직접 쓸 수 없습니다. 이건 트랜잭션을 다루지 않는다는 뜻이 아니라, 그 책임을 `TransactionPort` 같은 application port로 명시적으로 드러내겠다는 결정입니다. **adapter끼리도 서로 직접 알면 안 됩니다.** - web adapter가 persistence/outbound adapter를 직접 알면, controller가 DB나 외부 client의 세부 구현을 우회할 길이 생깁니다. - persistence adapter가 web adapter를 알면, 저장소 계층이 transport 모양을 알게 됩니다. - outbound adapter가 persistence adapter를 직접 알면, 외부 호출과 저장소 구현이 서로 엮입니다. ca-tmpl은 adapter 간의 연결이 반드시 application/domain/shared-contract를 거쳐서만 흐르도록 강제합니다. --- ## 5. `shared`는 편의 패키지가 아니다 이름이 "shared"라고 해서 아무 공통 코드나 넣을 수 있는 곳이 아닙니다. ca-tmpl에서 `shared-contract`는 response, request, error, headers, logging, tracing, metrics, registry, annotation 같은 **operational contract 패키지만** 허용합니다. 비즈니스 개념(business concept)이 shared로 들어오기 시작하면, 서로 다른 도메인들이 같은 이름의 공통 모델에 묶여버리기 쉽습니다. 그래서 shared는 편의 패키지가 아니라 **운영 계약이 흐르는 제한된 통로**로 정의했습니다. --- ## 6. 규칙이 진짜로 동작하는지는 어떻게 아는가 — violations-as-data ArchUnit rule의 함정 중 하나는, 매칭 대상이 비어 있으면 아무것도 검사하지 않으면서 그냥 green이 될 수 있다는 점입니다. rule 이름은 그럴듯한데 실제로는 아무 위반도 못 잡는 상태죠. ca-tmpl은 이걸 막기 위해 **의도적으로 잘못된 fixture 클래스**를 test tree에 만들어두고, 각 rule이 그 위반을 실제로 잡아내는지 확인합니다. ```java class ArchitectureViolationFixtureTest { private static final JavaClasses VIOLATION_CLASSES = new ClassFileImporter().importPackages("dev.caskeleton.bootstrap.architecture.violations"); // intentional fixture classes를 읽어 각 ArchUnit rule이 실제 위반을 잡는지 확인한다. } ``` 이건 architecture rule 자체를 테스트하는 장치입니다. "규칙을 만들었다"와 "그 규칙이 실제로 동작한다"는 다른 문장이니까요. --- ## 7. 이 구조가 못 잡는 것들 이 구조가 만능은 아닙니다. ArchUnit은 bytecode에 남는 import, call, annotation은 잘 잡아내지만, `ApplicationContext.getBean(String)`이나 `Class.forName(String)` 같은 **문자열 기반 reflection 우회**는 정적으로 잡기 어렵습니다. 마음만 먹으면 규칙을 우회할 방법은 여전히 존재한다는 뜻입니다. 또한 운영 배포나 장기 유지보수 효과에 대한 측정은 아직 없습니다. 이 글에서 말할 수 있는 범위는 **ca-tmpl 저장소에 실제로 구현되어 있고, 로컬/dev 검증으로 확인된 모듈/패키지 경계까지**입니다. --- ## 마무리 ca-tmpl의 Clean Architecture 패키지 레이아웃에서 핵심은 "domain, application, adapter로 나눴다"는 사실 자체가 아닙니다. 핵심은 **그 나눔을 Gradle dependency matrix와 ArchUnit fitness function으로 계속 확인한다는 점**입니다. 좋은 skeleton은 한 번 예쁘게 그려둔 다이어그램이 아니라, 새 도메인을 추가하려는 사람이 실수로 경계를 깨뜨렸을 때 **어디서 무엇이 잘못됐는지 빌드가 바로 알려주는 구조**여야 합니다.