9.8 KiB
title, source_type, status, confidence, tags, related_projects, last_reviewed, canonical_sources, audience, target_publish, status_label
| title | source_type | status | confidence | tags | related_projects | last_reviewed | canonical_sources | audience | target_publish | status_label | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Clean Architecture를 패키지 구조로 강제하기 | blog | draft | unknown |
|
|
backend-engineer | 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이고, 다음 모듈로 나뉘어 있습니다.
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 대상을 명시적으로 나열합니다.
@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() 의존성이 들어오면 빌드를 실패시킵니다.
tasks.register('verifyCleanArchitectureDependencies') {
doLast {
Map<String, Set<String>> 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가 기대는 기반 모듈이 아니기 때문입니다.
@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은 순수해야 합니다.
@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이 그 위반을 실제로 잡아내는지 확인합니다.
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은 한 번 예쁘게 그려둔 다이어그램이 아니라, 새 도메인을 추가하려는 사람이 실수로 경계를 깨뜨렸을 때 어디서 무엇이 잘못됐는지 빌드가 바로 알려주는 구조여야 합니다.