Files
llm-wiki/wiki/publish-blog/clean-architecture-package-layout-blog.md
T

198 lines
9.8 KiB
Markdown

---
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<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가 기대는 기반 모듈이 아니기 때문입니다.
```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은 한 번 예쁘게 그려둔 다이어그램이 아니라, 새 도메인을 추가하려는 사람이 실수로 경계를 깨뜨렸을 때 **어디서 무엇이 잘못됐는지 빌드가 바로 알려주는 구조**여야 합니다.