Files
document-haness/docs/ca-tmpl/final/document.md
T

1763 lines
152 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 실행 가능한 클린 아키텍처 — 선언이 아니라 빌드가 지키는 경계
`ca-tmpl`을 만들 때 가장 먼저 정한 기준은 “경계를 어긴 코드가 실제로 빌드를 통과하지 못해야
한다”는 것이었습니다. 클린 아키텍처라는 이름을 붙여도 컨트롤러가 리포지토리를 직접 참조하는 코드는
얼마든지 만들 수 있기 때문입니다. 그래서 책임을 패키지로 나누는 데서 멈추지 않고 19개 모듈의 소스
의존 방향을 코어 쪽으로 고정했습니다. 금지된 의존은 컴파일·Gradle 검증·아키텍처 테스트 중 알맞은
지점에서 실패하도록 만들었습니다. 이 글에서는 제가 왜 이런 구조를 택했고, 각 장치가 어떤 위반을
막도록 구현했는지 설명합니다.
## 패키지 이름만으로는 경계를 강제할 수 없다
제가 `ca-tmpl`에서 해결하려 한 문제가 이것입니다.
`controller`·`service`·`repository`를 잘 나눠도 컨트롤러가 JPA 리포지토리를 직접 참조할 수
있습니다. 클래스패스에 타입이 있으면 코드는 그대로 컴파일되고, 리뷰에서 놓치면 병합도 막지
못합니다. 그래서 사람이 기억하던 규칙을 `javac`, Gradle 검증, 아키텍처 테스트가 실행하는 실패
조건으로 옮겼습니다.
읽고 나면 다음을 할 수 있습니다.
- 런타임 호출 방향과 소스 의존 방향을 구분하고 DIP가 정확히 무엇을 역전하는지 설명할 수 있습니다.
- 멀티모듈 클래스패스 격리, Gradle 의존 화이트리스트, ArchUnit(자바 코드 구조 규칙을 테스트로
검사하는 라이브러리) 규칙이 각각 어떤 위반을 잡고 어떤 위반을 놓치는지 판별할 수 있습니다.
- 자신의 팀 상황에서 이 강제 장치들이 이익인지 순비용인지 판단할 수 있습니다.
설명에서는 `ca-tmpl`의 멀티모듈 구조, 의존 정책, 아키텍처 테스트를 중심으로 합니다.
운영 트래픽이나 장애 상황에서의 실측 효과는 다루지 않습니다.
설명은 컴파일되는 경계 위반에서 시작합니다. 이어서 런타임 호출과 소스 의존, 포트와 링을 구분하고
`ca-tmpl`의 모듈 구조와 요청 흐름을 보여 줍니다. 마지막에는 빌드가 실제로 막는 위반과 여전히 사람이
확인해야 하는 영역을 나눕니다.
## 경계는 왜 보이지 않게 되는가
### 경계가 무너지는 순간 — 컴파일되는 위반
제가 가장 먼저 막으려 한 것은 아래처럼 경계를 건너뛰어도 컴파일되는 코드였습니다.
```java
@RestController
class WorkLogController {
private final JpaWorkLogRepository repository;
}
```
겉으로는 필드 하나를 바로 주입한 코드지만 실제 요청은 유스케이스와 도메인을 모두 건너뜁니다. 이런
우회를 빌드에서 막으려면 다음 네 가지 질문에 답해야 했습니다.
- **컴파일러가 허용하는가?** 타입만 맞으면 허용합니다.
- **기존 테스트가 잡는가?** 경계 규칙이 없으면 놓칠 수 있습니다.
- **리뷰에서 놓치면 어떻게 되는가?** 그대로 머지됩니다.
- **반년 뒤 이 의존은 누가 기억하는가?** 아무도 기억하지 못합니다.
리뷰에서 놓친 의존이 하나씩 쌓이면 그림으로 그려 둔 아키텍처와 실제 코드가 서서히 달라집니다. 이런
변화를 아키텍처 침식(erosion)이라고 합니다. 최상위 폴더를 `controller`·`service`·`repository`로만
나눈 구조에서는 주문 기능 하나를 고칠 때 세 폴더를 함께 열게 됩니다. 이때 폴더 구조는 시스템이 무슨
일을 하는지보다 어떤 프레임워크 책임으로 나뉘었는지를 먼저 보여 줍니다.
![표현·비즈니스·데이터액세스·DB 네 층이 위에서 아래로 depends-on 화살표로 연결되고, 비즈니스 층에서 도메인이 기술에 묶인다는 경고로 이어지는 다이어그램.](../assets/architecture-layered-2026-07-04.svg)
질문이 하나 남습니다. **그렇다면 이 경계는 무엇이 지킬까요?**
### 진짜 문제는 Layered가 아니라 보이지 않는 경계
저는 이 문제를 Layered 구조의 탓으로 보지 않았습니다. 같은 클래스를 Clean이라는 패키지 아래로
옮겨도 컴파일 결과는 달라지지 않기 때문입니다. 위 예시의 컨트롤러→리포지토리 직접 의존은
타입이 같은 클래스패스에 있는 한 컴파일과 테스트를 모두 통과합니다. 패키지 이름에는 사람이 정한
규칙이 담겨 있지만 컴파일러는 그 이름을 경계로 해석하지 않습니다. 그래서 문제를 Layered라는 이름이
아니라 경계가 컴파일러와 빌드 시스템에 **보이지 않는 상태**로 정의했습니다.
경계를 어디에 표현하느냐에 따라, 서로 다른 위반을 잡을 수 있는 강제 수단이 이렇게 갈립니다.
![단일모듈 Layered·단일모듈 Clean·멀티모듈 Clean·정책과 테스트가 있는 Clean에서 서로 다른 경계 위반 예시가 어떤 검출 수단을 가질 수 있는지 비교한 네 카드.](../assets/boundary-enforcement-ladder.svg)
| 구조 | 의존 규칙 존재 | 빌드 강제 | 위반 코드 |
| ----------------- | :------------: | :------------------------: | --------------------------------- |
| 단일 모듈 Layered | 약함 | 없음 | 컴파일 성공 |
| 단일 모듈 Clean | 있음 | 약함(테스트뿐) | 컴파일 성공 |
| 멀티모듈 Clean | 있음 | 클래스패스 | 금지 타입 **컴파일 실패** |
| 실행 가능한 Clean | 있음 | 클래스패스 + 정책 + 테스트 | 금지 모듈 의존 **빌드 실패** |
표의 아래쪽으로 갈수록 위반을 발견하는 곳이 리뷰에서 컴파일·빌드로 옮겨갑니다. 맨 아래의 **실행
가능한(executable) 아키텍처**에서는 경계를 어긴 코드가 컴파일이나 빌드를 통과하지 못합니다.
### 문제를 설계 요구사항으로 변환
컨트롤러가 저장소를 우회하도록 두면 DB 스키마 변경이 서비스와 API 응답까지 번집니다. 서비스가
구체 저장소에 묶이면 DB 없이 테스트하기 어렵고, 오류 코드 같은 운영 용어도 도메인 코드로 들어올
수 있습니다. 그래서 저는 “경계를 지켜야 한다”는 말로 끝내지 않고, 빌드가 확인할 수 있는 요구사항으로
하나씩 바꿨습니다. 제가 해결하려 한 문제와 이를 막을 요구사항을 짝지으면 다음과 같습니다.
| 문제 | 설계 요구사항 |
| ------------------------------ | --------------------------------- |
| DB 변경이 서비스·API까지 전파 | 영속성 모델과 도메인 모델 분리 |
| 정책이 Spring 타입에 결합 | 코어의 프레임워크 클래스패스 제한 |
| Controller가 Repository 우회 | 입력 포트를 통한 유스케이스 진입 |
| 테스트가 DB를 요구 | 애플리케이션 소유 출력 포트 |
| 패키지 경계가 침식 | 컴파일·빌드·테스트 수준 강제 |
| 운영 계약이 도메인에 침투 | 도메인 언어와 운영 언어 분리 |
여섯 요구는 이 글 전체의 뼈대입니다. 결론에서 각 요구에 `ca-tmpl`의 구체적인 설계를 짝지어 놓습니다.
### 이 구조가 이익이 되는 조건
저는 `ca-tmpl`을 여러 프로젝트에서 반복해 사용할 스켈레톤으로 만들었습니다. 이런 환경에서는 최초
설계자가 모든 변경을 계속 리뷰할 수 없지만 빌드 규칙과 테스트는 담당자가 바뀌어도 같은 검사를
실행합니다. 반대로 수명이 짧고 변경하는 사람이 적은 서비스라면 19개 모듈과 여러 정책 파일을 유지하는
비용이 더 클 수 있습니다.
`ca-tmpl`에 여러 겹의 강제 장치를 둔 이유는 경계 규칙을 개인의 기억이 아니라 팀이 반복 실행할 수
있는 검사로 옮기기 위해서입니다. 모듈 클래스패스는 금지된 타입을 보이지 않게 하고, Gradle 정책은
금지된 모듈 의존을 거부하며, ArchUnit은 같은 모듈 안의 패키지 규칙까지 검사합니다.
`settings.gradle`에는 인바운드 어댑터 4개와 아웃바운드 어댑터 10개가 포함돼 있습니다. 이들을 모듈로
분리한 이유는 어댑터마다 허용할 기술 의존, 활성화 조건, 테스트 전략이 다르기 때문입니다.
여러 어댑터를 지원하도록 만들었지만 전부 항상 실행되게 하지는 않았습니다. 조립 모듈 `app-bootstrap`은 어댑터 11개를
main 프로젝트 의존에 넣고 나머지 3개는 클래스패스 밖의 참조 어댑터로 남깁니다. 게다가 main 의존에 포함된
모듈조차 런타임 프로퍼티가 꺼져 있으면 구체 백엔드 빈이 뜨지 않습니다. 예를 들어 Redis 캐시 설정은
`matchIfMissing = false`라 플래그가 없으면 기본적으로 꺼져 있습니다.
참조 코드는 프로덕션과 격리됩니다. 예제 모듈 `sample-portfolio`는 main 구현체가 아니라 별도
`sampleFixture` 설정으로만 클래스패스에 붙습니다. `SampleRemovalSmokeContractTest`는 지정된 프로덕션
모듈이 샘플을 일반 프로덕션 configuration으로 참조하지 않는지 검사하고, `sampleOffTest`
샘플을 뺀 핵심 테스트 경로를 실행합니다. 이 예제 모듈은 프로덕션 그래프를 건드리지 않고 제거할 수
있어야 하며, 위반하면 `check`가 실패합니다.
도메인 순수성과 운영 계약도 같은 모듈에 넣지 않았습니다. ArchUnit 규칙
`DOMAIN_IS_PURE`는 도메인 의존을 검사하고, 로깅·에러 코드·응답 포맷은 `shared-contract`에 두었습니다.
이렇게 두 종류의 코드를 다른 모듈에서 관리하지만, 이 분리만으로 운영 용어가 도메인에 들어오는 모든
경우를 자동 차단하는 것은 아닙니다. 실제 강제 범위는 뒤에서 다시 설명합니다.
## DIP가 뒤집는 것은 호출이 아니라 소스 의존이다
### 실행 흐름과 소스 의존은 왜 반대가 되는가
`ca-tmpl`의 의존 방향을 설계할 때는 런타임 호출, 데이터 흐름, 소스 의존을 먼저 구분했습니다.
런타임에는 바깥의 컨트롤러가 안쪽을 호출하지만, 클린 아키텍처의 의존 화살표도 바깥에서 안쪽을
가리킵니다. 세 방향을 한 화살표로 읽으면 실행 순서와 의존 방향이 뒤섞입니다. 따로 놓으면 차이가
분명해집니다.
- **런타임 호출 방향**: `Controller → Use Case → Port 구현 → DB`. 요청이 오면 호출은 바깥에서
안으로 들어갔다가, 가장자리에서 다시 바깥의 어댑터로 나가 DB를 조회하거나 갱신합니다.
- **데이터 흐름**: 요청은 안쪽으로 들어가고 결과는 다시 바깥쪽으로 나옵니다. 방향이라기보다 왕복입니다.
- **소스 코드 의존 방향**: `Adapter → Application/Domain`. 컴파일 시점에 어느 모듈이 어느 모듈을
`import`하고 클래스패스에 두는지를 말합니다. 여기서만은 화살표가 항상 안쪽을 향합니다.
DIP(의존성 역전 원칙, Dependency Inversion Principle)는 셋 중 딱 하나만 바꿉니다.
**DIP가 역전하는 대상은 런타임 호출이 아니라 소스 코드 의존 관계입니다.**
런타임에 유스케이스가 포트 구현을 호출한다는 사실은 그대로 둡니다. 뒤집을 수도 없고 뒤집을 필요도
없습니다. DIP가 뒤집는 건 "그 호출을 성립시키려면 누가 누구의 타입을 알아야 하는가"입니다.
소스 의존이 호출 방향과 달라야 하는 이유도 여기에 있습니다. 자연스럽게 짜면 호출하는 쪽이 호출당하는 쪽의 타입을 압니다. 유스케이스가 DB
리포지토리를 직접 알면 소스 의존이 호출 방향을 그대로 따라 안에서 바깥으로 흘러 코어가 DB를 알게
됩니다.
DIP는 이 사이에 코어가 소유한 인터페이스를 끼웁니다. 유스케이스는 인터페이스만 알고 그 인터페이스를
바깥의 어댑터가 구현합니다. 그러면 호출은 여전히 안에서 바깥으로 나가지만 타입을 아는 방향(소스
의존)은 어댑터가 코어를 아는 쪽으로 뒤집힙니다. 호출은 그대로, 소스 의존만 역전됩니다.
`ca-tmpl`에서는 코어 모듈 `application-core``TransactionPort`를 선언하고 JPA 어댑터의
`SpringTransactionPort`가 이를 구현합니다. 구현체가 코어 인터페이스를 import하므로 어댑터 소스가
코어를 향합니다. 모듈 의존도 같은 방향입니다.
```groovy
implementation project(':application-core')
```
어댑터 빌드 파일은 한 줄로 코어에 의존을 걸지만 코어의 의존에는 이 어댑터를 가리키는 project 의존이 없습니다. `application-core`의 프로젝트 의존은 `domain-core``shared-contract`뿐입니다.
![상단은 FeedController에서 GetFeedUseCase와 SpringTransactionPort로 이어지는 런타임 호출, 하단은 GetFeedUseCase가 QueryUseCase와 TransactionPort 계약을 사용하고 SpringTransactionPort가 TransactionPort를 구현하는 소스 의존을 분리한 두 패널.](../assets/runtime-call-source-dependency.svg)
`GetFeedUseCase`는 런타임에 `SpringTransactionPort`를 호출하지만, 소스에서는
`SpringTransactionPort`가 코어의 `TransactionPort`에 의존합니다. 이 역전이 있어야
`application-core`가 DB·영속 구현과 전송 프레임워크 타입을 모른 채 남습니다.
다만 `application-core` 자체가 framework-free라는 뜻은 아닙니다. 이 모듈은 SLF4J를
사용합니다. 코어 모듈 중 main compileClasspath에 외부 의존이 전혀 없는 곳은 `domain-core`와 운영
계약 모듈 `shared-contract`입니다.
### Hexagonal — 포트는 무엇을 나누는가
포트는 웹과 DB 같은 기술 종류가 아니라 대화 방향을 기준으로 나눴습니다. HTTP 요청·스케줄러·메시지
소비처럼 바깥에서 유스케이스를 시작하는 경계는 인바운드로, DB 조회·알림 발송·파일 저장처럼
유스케이스가 바깥 기능을 요청하는 경계는 아웃바운드로 나눴습니다.
Hexagonal의 육각형은 이 두 대화 방향 사이에 도메인과 유스케이스를 남깁니다.
원리 수준의 흐름은 `inbound Adapter → Input Port → Application Service → Output Port → outbound Adapter`입니다.
다만 `ca-tmpl`의 피드 조회는 이 다섯 자리를 모두 별도 타입으로 분리하지 않았습니다.
요청은 `FeedController`(inbound Adapter)에서 시작해 `getFeed.handle(new GetFeedQuery(page, size))`를 부릅니다.
컨트롤러가 주입받는 `getFeed`의 선언 타입은 구체 클래스 `GetFeedUseCase`입니다. 유스케이스별 전용
Input Port 인터페이스는 없고 대신 이 구체 서비스가 코어의 일반 계약
`QueryUseCase<GetFeedQuery, List<FeedSummary>>`를 구현합니다. 이런 타입 계약을 포트로 삼으면 하나의
선언이 포트의 모양과 기계적 강제를 함께 제공하면서도 유스케이스의 책임은 유지할 수 있습니다.
![왼쪽 FeedController가 구체 GetFeedUseCase를 호출하고, application-core 안의 GetFeedUseCase가 FeedQueryPort를 호출하며, 오른쪽 FeedQueryAdapter가 그 코어 계약을 구현하는 실제 피드 조회 구조.](../assets/hexagonal-ports.svg)
`FeedQueryPort`는 어댑터 모듈이 아니라 `application-core`와 같은 패키지에 선언돼 있습니다.
코어가 출력 인터페이스를 소유하므로 `GetFeedUseCase`는 "조회 결과를 어떻게 가져올지"가 아니라 "무엇을 받고 싶은지"만 압니다.
저장 기술을 바꾸면 직접 의존의 변경 반경은 `FeedQueryPort` 바깥의 어댑터와 매핑 경계로 제한됩니다.
다만 쿼리 의미나 반환 모델까지 달라지면 코어 계약도 바뀔 수 있으므로 DB를 바꿔도 코어가 항상
그대로 유지되는 것은 아닙니다.
Input Port와 Output Port를 구분하는 기준은 소유권이 아니라 방향입니다.
- **Driving Port(Input Port)** — 바깥이 안쪽에 의도를 전달하는 창구. 코어가 받아들이는 요청의 모양(`Command`/`Query`)을 코어가 강제하고 어댑터는 그 모양을 벗어난 요청을 만들 수 없습니다.
- **Driven Port(Output Port)** — 안쪽이 바깥에 능력을 요구하는 창구. 코어는 "이런 능력이
필요하다"까지만 선언하고 그 능력을 무엇으로 채우는지는 모릅니다.
Input Port의 `Command``Query`는 어댑터가 코어에 전달할 요청의 모양을 제한합니다. 반대로 Output
Port는 아웃바운드 어댑터가 코어에 제공해야 할 기능을 정합니다. 둘을 하나의 인터페이스에 합치면 받는
요청과 외부에 요구하는 기능이 섞여 한쪽의 변경이 다른 쪽까지 번집니다. `ca-tmpl`은 읽기와 쓰기의
의도도 타입에 남기기 위해 `UseCase<I, O>``CommandUseCase``QueryUseCase`로 나눴고,
`GetFeedUseCase`는 후자를 구현합니다. `FeedController`는 구체 `GetFeedUseCase`를 주입받고
`FeedQueryAdapter`는 명시적인 `FeedQueryPort`를 구현합니다. 두 소스 의존 모두 코어를 향하지만
인터페이스를 실제 주입 경계로 사용하는 정도는 다릅니다.
저는 실제로 바뀔 수 있는 기술이 있거나 테스트에서 구현을 대체해야 하는 경계에만 포트를 두었습니다.
외부 기술이 끼지 않는 코어 내부의 계산·조립 클래스까지 인터페이스 하나와 구현체 하나로 나누면
동작은 그대로인데 읽는 사람만 두 파일을 오가게 됩니다.
이렇게 경계마다 타입을 나누면서 매핑 비용도 생겼습니다. 피드 항목 하나는 JPA 엔티티에서
`FeedSummary`로, 다시 웹 응답 `FeedResponse`로 옮겨집니다. 중첩 값 객체도 같은 과정을 거칩니다.
`FeedSummary.HighlightSummary``FeedResponse.HighlightPart``color`, `text`, `createdAt`
필드가 같지만 별개 타입으로 선언되고 각각 매핑됩니다. DB 필드 하나를 응답에 추가하려면 세 클래스와
두 매핑 함수를 함께 고쳐야 합니다. AI가 반복 코드를 만들어 주더라도 변경 지점과 검토할 코드는
줄어들지 않습니다. 코어가 JPA와 HTTP를 모르는 대신 두 번의 매핑을 계속 관리해야 합니다.
### Clean ↔ Spring — 네 개의 링을 모듈에 앉히기
클린 아키텍처를 설명할 때는 보통 네 개의 링을 그립니다. 저는 링의 이름을 그대로 모듈에 붙이는 대신,
코드가 바뀌는 이유를 기준으로 `ca-tmpl`의 모듈에 대응시켰습니다. 같은 이유로 바뀌는 코드는 한 경계에
두고 다른 이유로 바뀌는 코드는 의존 방향을 나눴습니다.
| 링 | `ca-tmpl` 모듈 | 대표 책임 | 이곳에 두는 이유 |
| -------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
| 엔터프라이즈 업무 규칙(Enterprise Business Rules) | `domain-core` | Aggregate·Value Object — 예: `FeedItem` | 핵심 불변식은 HTTP·DB·Spring 교체와 무관하게 유지돼야 합니다. 그래서 JPA와 Spring 타입을 클래스패스에서 제외합니다. |
| 애플리케이션 업무 규칙(Application Business Rules) | `application-core` | Use Case·Input/Output Port — 예: `GetFeedUseCase`, `FeedQueryPort` | 유스케이스는 업무 흐름과 필요한 외부 능력을 정의하되, 그 능력을 어떤 기술로 구현하는지는 몰라야 합니다. |
| 인터페이스 어댑터(Interface Adapters) | `adapter:inbound:*`, `adapter:outbound:*` | Controller·영속성 어댑터 — 예: `FeedController`, `FeedQueryAdapter` | HTTP·JPA 같은 외부 모델을 코어 계약으로 변환하는 책임을 모아 기술 변경의 직접 파급을 경계 밖에 가둡니다. |
| 프레임워크와 드라이버(Frameworks & Drivers) | 어댑터의 구체 기술 의존,`app-bootstrap` | Spring MVC·Spring Data JPA·PostgreSQL·Boot/Flyway·관측·보안 배선 | 구체 프레임워크 선택과 실행 시점 조립은 배포 환경에 따라 바뀌므로 가장 바깥에서 결정합니다. |
`domain-core`를 별도 모듈로 둔 이유는 도메인 규칙을 프레임워크 변경에서 보호하기 위해서입니다. 이 모듈에는
Spring Web, JPA, Spring TX가 없으므로 도메인 코드가 해당 타입을 참조하면 컴파일 단계에서 실패합니다.
`application-core`는 유스케이스와 포트를 소유합니다. 이렇게 해야 `GetFeedUseCase`가 "피드를 조회한다"는 업무 흐름만 알고, 조회를 JPA로 할지 다른 저장소로 할지는 아웃바운드 어댑터가 결정할 수 있습니다.
인바운드와 아웃바운드를 별도 어댑터 모듈로 둔 이유는 변환 방향과 기술 의존이 다르기 때문입니다.
`adapter:inbound:web`은 HTTP 요청을 애플리케이션 입력으로 바꾸기 위해 `web` 의존성을 사용합니다.
`adapter:outbound:jpa`는 애플리케이션의 출력 포트를 DB 접근으로 바꾸기 위해 `jpa`와 PostgreSQL
드라이버를 사용합니다. 두 기술 의존은 코어 모듈로 전파되지 않습니다.
`app-bootstrap`을 별도 조립 모듈로 둔 이유는 어떤 구현을 실제로 사용할지 한곳에서 고르기 위해서입니다.
이 모듈이 Boot·Validation·Flyway·Micrometer/OTel·Actuator·Security 의존과 어댑터 배선을 맡으므로
코어는 기동 방식과 운영 기술을 알 필요가 없습니다. 메시징 모듈처럼 구체 브로커
클라이언트가 아직 없는 어댑터는 확장 계약만 제공합니다. 모듈이 존재한다는 사실과 실제 연동이 완성됐다는 사실을 구분해야 합니다.
이 매핑은 유일한 정답이 아닙니다. `ca-tmpl`은 금지된 타입을 코어 클래스패스에서 제거해 경계 위반을
컴파일 단계에서 막기 위해 Gradle 모듈을 사용합니다. 그 대가로 모듈별 빌드 선언과 의존 정책을 계속
관리해야 합니다. 경계를 패키지 규칙만으로도 충분히 지킬 수 있는 작은 서비스라면 이 비용이 이익보다 클
수 있습니다.
**Dependency Rule.** 프로젝트가 소유한 모듈 사이의 소스 의존은 안쪽을 향합니다. 어댑터 모듈이
애플리케이션·도메인 계약을 참조하고 Gradle 화이트리스트는 반대 방향의 프로젝트 의존을 허용하지
않습니다. Spring MVC·JPA·PostgreSQL 같은 외부 라이브러리 간선은 어댑터 모듈에서 프레임워크 쪽으로
향합니다. Spring MVC·JPA·PostgreSQL 의존은 각 어댑터 안에서 끝나며 코어 모듈의 클래스패스로
들어가지 않습니다.
**Boundary Data.** 같은 피드 데이터도 경계를 넘을 때마다 사용하는 타입이 달라집니다. JPA 엔티티는
`FeedSummary`로 바뀌고 웹 경계에서는 다시 `FeedResponse`가 됩니다. 다만 모든 필드 타입을 복제하지는
않습니다. `FeedItemJpaEntity`는 도메인의 `Visibility` enum을 직접 import해 재사용합니다. 의미가 같은
단순 enum까지 따로 만들지는 않았습니다.
**Entity라는 이름이 두 번 쓰입니다.** Uncle Bob의 Entity(Enterprise Business Rules)와 JPA Entity는 이름이 같을 뿐 전혀 다른 개념입니다. `ca-tmpl`에서 이 둘은 실제로 서로 다른 모듈의 서로 다른 타입입니다.
- Uncle Bob의 Entity는 `domain-core``FeedItem`입니다.
`@AggregateRoot`가 붙은 타입에 public setter를 추가하면 별도의 아키텍처 테스트가 실패합니다.
임포트는 자체 stereotype 애노테이션과 JDK 타입뿐입니다.
- JPA `@Entity``adapter/outbound/jpa``FeedItemJpaEntity`입니다.
JPA 의존성을 임포트하고 `@Entity` `@Table(name = "feed_items")`가 붙습니다.
영속성 프레임워크가 리플렉션으로 다루기 위한 계약입니다.
두 타입은 서로를 직접 알지 못합니다. 어댑터가 소유한 `FeedItemPersistenceMapper.toDomain()`은 JPA
엔티티에서 도메인으로 가는 한 방향 재구성을 제공하지만 현재 피드 조회 경로는 이를 호출하지 않고 조회
결과에서 `FeedSummary`를 직접 만듭니다.
### Layered·Hexagonal·Clean은 경쟁하지 않는다
저는 Layered·Hexagonal·Clean을 서로 경쟁하는 구조로 보지 않았습니다. Layered는 표현·서비스·영속성
같은 기술 책임을 어디에 모을지 설명하고, Hexagonal은 외부와 대화하는 자리를 Driving/Driven 포트로
나눕니다. Clean은 정책 수준에 따라 소스 의존이 어느 쪽을 향해야 하는지 정합니다. 세 설명이 만나는
곳은 DIP였습니다. 바깥 기술이 코어가
소유한 계약에 의존하면 도메인은 구체 프레임워크를 몰라도 됩니다.
![세 패널(Layered 층, Hexagonal 포트 경계, Clean 동심원)이 나란히 놓이고, 셋 다 안쪽으로 향하는 화살표와 의존은 안쪽으로만이라는 공통 규칙으로 묶인다.](../assets/architecture-three-lenses.svg)
Layered에서도 서비스 계층이 포트를 소유하고 영속성 구현이 그 포트에 의존하게 만들 수 있습니다. 따라서
층의 개수만으로 의존 역전 여부가 정해지지는 않습니다. Hexagonal로는 경계를 어디에 그을지 정하고,
Clean의 의존 규칙으로는 그 경계를 넘는 소스 의존의 방향을 정합니다. 이 두 규칙을 함께 적용해도 DB
교체 비용은 사라지지 않습니다. 식별자·쿼리·락·격리 수준이 달라지면 코어 계약도 바뀔 수 있습니다.
`ca-tmpl`은 그 변경이 곧바로 컨트롤러와 다른 어댑터까지 번지지 않도록 모듈과 매핑 경계에서
멈추게 합니다.
### 판단 기준 — 모듈 하나를 추가하는 다섯 질문
이 원칙을 실제 Gradle 모듈로 옮기면서 “얼마나 나눠야 하는가”를 정해야 했습니다. 경계를 많이 만들수록
금지할 의존은 분명해지지만, 모듈마다 빌드 선언과 테스트, 매핑을 함께 관리해야 합니다. 그래서 저는 모듈 수를
기준으로 삼지 않고 아래 질문으로 새 경계가 필요한지 판단했습니다.
> **독립적으로 제한해야 하는 클래스패스, 또는 독립적으로 선택해야 하는 런타임 능력이 존재하는가?**
구체적으로 다섯 질문으로 판단합니다.
1. **금지할 의존성이 다른가?** — 예: `objectstorage`는 도메인을 몰라야 합니다(`domain-core` 접근 금지).
2. **선택적으로 켜고 끌 수 있는가?** — 예: `grpc`는 opt-in 참조 어댑터입니다.
3. **별도 테스트 전략이 필요한가?** — 예: `persistence-jpa`는 Testcontainers 통합 테스트를 씁니다.
4. **변경 주기가 다른가?**
5. **독립 배포가 아니라도 독립 컴파일이 가치 있는가?**
다섯 질문에 모두 "아니오"라면 저는 모듈을 더 나누지 않습니다. 독립적인 테스트, 변경 범위의 제한,
기능의 선택적 조합이라는 기준은 여러 기업 기술 블로그에서 반복해서 확인한 모듈 분리 목적에서
가져왔습니다. `ca-tmpl`은 여러 프로젝트가 가져다 쓸 스켈레톤으로 만들었기 때문에, 사용하지
않는 기능 모듈을 런타임 의존에서 빼면 관련 자동 구성과 애플리케이션 컨텍스트도 등록되지 않아야
합니다.
## 19개 모듈은 잘게 나누는 것이 목적이 아니었다
### 전체 구조 — 19개 leaf 모듈
`ca-tmpl`은 **19개의 leaf 모듈**로 만들었습니다. 모든 기능을 잘게 나누는 것이 목적은 아니었습니다.
코어의 클래스패스를 제한할 모듈과 프로젝트마다 골라 쓸 어댑터를 따로 떼어 낸 결과입니다. 여러
프로젝트에서 오래 복제해 쓰는 스켈레톤이므로 거버넌스·품질·경계를 같은 빌드에서 검사하도록
구성했습니다. 스택은 Spring Boot 4.0.0, Gradle 9.0.0, Java 21입니다. 이 버전들은 특정 아키텍처 기능 때문에
고른 것이 아니라 메이저 버전 전환 시점에 검증하고 고정한 빌드 기준점입니다. 계속 유지해야 할
아키텍처 이유는 없으므로 호환성 테스트를 통과하는 범위에서 최신 유지보수 버전으로 갱신해야 합니다.
![좌우 인바운드·아웃바운드 어댑터가 application-core를 향하고, application-core가 domain-core와 shared-contract에 각각 의존하며, app-bootstrap이 application-core를 조립하는 전체 구조. domain-core와 shared-contract 사이에는 의존 화살표가 없다.](../assets/big-picture.svg)
- **내부 모듈 3개** — `domain-core`(순수 도메인), `application-core`(유스케이스와 포트),
`shared-contract`(운영 계약)
- **조립 루트 1개** — `app-bootstrap`
- **인바운드 4개** — `web`·`grpc`·`graphql`·`websocket`
- **아웃바운드 10개** — `persistence-jpa`·`support`·`messaging`·`cache-redis`·`notification`·
`objectstorage`·`fileserver`·`persistence-mongo`·`httpclient`·`identifier`
- **참조 슬라이스 1개** — `sample-portfolio`
모듈 수를 19개로 늘리는 것 자체가 목적은 아니었습니다. 코어에서는 기술 의존을 막고, 어댑터마다 활성화
조건과 테스트 전략을 따로 관리하며, 조립 루트에서 실제 실행 구성을 고르게 했습니다. 프로젝트마다 필요한
기술이 다르기 때문에 19개를 모두 활성화하지 않고 선택해서 쓰는 구조로 만들었습니다. PostgreSQL,
MongoDB, Redis를 초기 후보로 고를 때는 Stack Overflow Developer Survey의 사용량도 참고했습니다.
Kafka를 포함해 여기에 들어 있는 기술이 모든 프로젝트에 필요하다는 뜻은 아닙니다.
19개 모듈을 설명할 때는 먼저 아웃바운드와 인바운드로 나눴습니다. 아웃바운드에는 다음 책임을
두었습니다.
- `persistence-jpa`에는 PostgreSQL 드라이버
- `objectstorage`에는 opt-in S3/MinIO 백엔드
- `fileserver`에는 순수 JDK 파일시스템 구현
- `cache-redis`·`messaging`·`notification`에는 각각 `RedisClient`·`KafkaSender`·`SlackClient`
구현을 프로젝트가 채워 넣을 수 있는 확장점이 있습니다.
- `httpclient`는 외부 HTTP API 호출과 timeout-retry 같은 통신 정책을 담당합니다.
애플리케이션 코어는 PostgreSQL, Redis, Kafka, S3를 직접 알지 않습니다. 필요한 기능만 output port로
선언하고 각 아웃바운드 어댑터가 이를 실제 기술로 구현합니다.
![persistence-jpa는 PostgreSQL 드라이버·dialect, persistence-mongo는 opt-in MongoDB 스캐폴드, objectstorage는 선택형 S3/MinIO 백엔드, fileserver는 파일시스템 구현의 네 실선 경로이고, notification·cache-redis·messaging은 각각 SlackClient·RedisClient·KafkaSender 확장 seam인 시스템 경계도.](../assets/context-system-boundary.svg)
반대쪽에는 외부 요청을 애플리케이션 입력으로 변환하는 인바운드 경계가 있습니다.
- `web`은 HTTP 요청, JSON DTO, Bean Validation, 인증 인가와 HTTP 오류 응답을 담당합니다.
- `grpc`는 protobuf 기반 요청과 gRPC 서버 lifecycle을 담당합니다.
- `graphql`은 GraphQL schema와 query-mutation 진입점을 담당합니다.
- `websocket`은 WebSocket.STOMP 연결과 실시간 메시지 진입점을 담당합니다.
각 인바운드 어댑터는 자신이 사용하는 전송 기술 타입을 모듈 안에서 처리합니다. HTTP request DTO,
protobuf message, GraphQL resolver, WebSocket message를 그대로 application-core에 넘기지 않습니다.
어댑터가 application command나 query로 바꾼 뒤 유스케이스를 호출합니다.
```
HTTP DTO ─────────┐
Protobuf message ─┤
GraphQL request ──┼─> Command / Query ─> Application use case
WebSocket message ┘
```
인바운드와 아웃바운드는 테스트 전략도 다릅니다.
- 인바운드 어댑터: 역직렬화, 요청 검증, 인증 인가, transport 계약, 오류 응답
- 아웃바운드 어댑터: 데이터 매핑, 외부 시스템 연동, timeout-retry, 기술 예외 변환
<!-- techviz:begin id=inbound-transport-boundary context-sha256=04fbab095d33d301746c34f7cca305730919bad3c341bcf63b8ad3ee3b396d31 -->
<!-- techviz:generate id=inbound-transport-boundary -->
![네 인바운드 어댑터의 HTTP DTO, protobuf message, GraphQL request, WebSocket message가 Command 또는 Query로 수렴해 application use case를 호출하는 흐름도.](../assets/inbound-transport-boundary/inbound-transport-boundary.svg)
왼쪽에서 오른쪽으로 읽습니다. `web`은 HTTP DTO, `grpc`는 protobuf message, `graphql`은 GraphQL
request, `websocket`은 WebSocket message를 각 어댑터 경계에서 처리합니다. 네 어댑터는 전송 기술
타입을 `application-core`로 넘기지 않고 Command 또는 Query로 변환합니다. 변환된 입력만 유스케이스를
호출합니다.
`app-bootstrap`은 프로젝트가 실제 사용할 인바운드와 아웃바운드 어댑터를 골라 애플리케이션 포트와
연결합니다. 쓰지 않는 선택형 어댑터를 런타임 의존에서 빼면 그 모듈의 빈과 설정도 애플리케이션
컨텍스트에 등록되지 않습니다.
이 실행 구성을 바탕으로 코드의 의존성이 어떤 방향으로 흐르도록 만들었는지 설명하겠습니다.
이 프로젝트의 모듈 간 의존은 인바운드와 아웃바운드 모두 바깥에서 안쪽으로 향합니다.
`verifyCleanArchitectureDependencies`는 모듈 사이의 프로젝트 의존을 검사하고, ArchUnit의
`DOMAIN_IS_PURE`는 모듈 내부 코드가 금지된 프레임워크 타입을 참조하는지 검사합니다.
<!-- techviz:begin id=bootstrap-dependency-guards context-sha256=04fbab095d33d301746c34f7cca305730919bad3c341bcf63b8ad3ee3b396d31 -->
<!-- techviz:generate id=bootstrap-dependency-guards -->
![가운데 application-core와 양쪽 port·adapter, 아래 app-bootstrap, Gradle 모듈 의존 게이트와 ArchUnit 내부 순수성 게이트의 연결을 함께 보여 주는 ports-and-adapters 구조도.](../assets/bootstrap-dependency-guards/bootstrap-dependency-guards.svg)
가운데 `application-core`를 기준으로 왼쪽에는 인바운드 어댑터와 Input Port가, 오른쪽에는 Output
Port와 아웃바운드 어댑터가 있습니다. 어댑터의 모듈 의존은 포트와 코어 쪽을 향합니다. 아래의
`app-bootstrap`은 실제 사용할 양쪽 어댑터를 골라 애플리케이션 포트에 연결합니다. 두 검증 게이트는
역할이 나뉩니다. `verifyCleanArchitectureDependencies`는 모듈 사이의 프로젝트 의존을 검사하고,
ArchUnit 규칙은 모듈 내부 코드가 금지된 프레임워크 타입을 참조하는지 검사합니다.
![프로젝트가 소유한 어댑터와 composition root에서 애플리케이션·도메인으로 향하는 모듈 의존, MVC·JPA·DB 의존을 어댑터가 소유하는 표면, Boot·Flyway·관측·보안 배선을 app-bootstrap이 소유하는 별도 표면을 분리한 논리 구조 그림.](../assets/logical-four-rings.svg)
*프로젝트 모듈 간 의존은 adapter→application→domain으로 안쪽을 향합니다. MVC·JPA·DB 구체 의존은 해당 어댑터가 소유하고 Boot·Flyway·관측·보안 조립은 app-bootstrap이 별도로 소유합니다.*
아래 그림은 `allowedProjectDependencies` 중 코어 접근권과 `support` 공유의 비대칭을 보여 주는 다섯 부분만 표현합니다.
![화이트리스트의 다섯 행을 각각 의존 출발점과 의존 가능 대상으로 연결해 domain-core·shared-contract·support 접근 비대칭을 보여 주는 정책 그림.](../assets/module-graph-measured.svg)
### 경계마다 다른 모델 — 다섯 종류
피드 항목의 같은 필드가 여러 타입에 반복되는 것은 경계마다 모델을 분리했기 때문입니다.
`FeedController`에서 JPA 조회까지는 각 타입이 맡은 경계를 넘기지 않도록 만들었습니다. 실제 피드 조회에는
`GetFeedQuery`·`FeedSummary`·`FeedItemJpaEntity`·`FeedResponse` 네 종류가 참여합니다. `FeedItem`
이 조회 경로를 지나지 않고 도메인 모델과 JPA 엔티티를 비교할 때만 등장합니다.
| # | 모델 종류 | 대표 타입 | 소속 모듈 | 경계를 넘나드는 이유 |
| -- | -------------------------- | -------------------------------------------------- | ------------------------------------ | ------------------------------------------------------ |
| ① | 인바운드 DTO | `FeedResponse`(record, 중첩 `HighlightPart`) | `adapter:inbound:web` | HTTP 응답 바디 모양 — 아는 건 컨트롤러·매퍼뿐 |
| ② | 애플리케이션 Command/Query | `GetFeedQuery`(record, `implements Query`) | `application-core` | 코어가 강제하는 Input Port 요청 모양 |
| ③ | 애플리케이션 프로젝션 | `FeedSummary`(record, 중첩 `HighlightSummary`) | `application-core` | `FeedQueryPort.loadFeed()`가 돌려주는 읽기 전용 투영 |
| ④ | 도메인 애그리게이트 | `FeedItem`(`@AggregateRoot`) | `domain-core` | 정책·불변식이 사는 자리 — 프레임워크 임포트 0 |
| ⑤ | 아웃바운드 영속 엔티티 | `FeedItemJpaEntity`(`@Entity`) | `adapter:outbound:persistence-jpa` | `jakarta.persistence` 리플렉션 계약 |
다섯 모델은 한 객체의 생애주기 단계가 아닙니다. 조회 경로에서는 `Query` 마커만 사용합니다.
`Command` 마커는 `sample-portfolio``CreateWorkLogCommand`에 실제로 적용돼 있습니다.
재매핑은 두 번 일어납니다.
1. persistence에서 application으로 넘어갈 때입니다.
`FeedQueryAdapter.loadFeed()`는 JPA 조회 결과를 `FeedSummary`로 직접 조립합니다.
`FeedItemJpaEntity → FeedItem → FeedSummary`처럼 애그리게이트를 재구성하지 않고 조회 결과에서 곧장 애플리케이션 프로젝션으로 건너갑니다.
같은 DB 안에서 읽기 경로만 논리적으로 나누는 이 우회가 뒤에서 다룰 CQRS-lite 결정의 구체적인 모습입니다.
CQRS는 명령(Command)과 조회(Query)의 코드·모델을 나누는 패턴이고 lite는 저장소 분리 없이 코드 경로와 모델만 나눈 수준을 뜻합니다.
2. application에서 web으로 나갈 때입니다.
`FeedWebMapper.toResponse()``FeedSummary``FeedResponse`로 다시 조립합니다.
두 매핑을 모두 어댑터가 소유하므로 `GetFeedUseCase``FeedQueryPort`는 웹 응답이나 JPA 엔티티의
모양을 모릅니다. `GetFeedUseCase``FeedResponse`를 직접 만들었다면 HTTP 응답 변경이 코어 변경으로 번졌을 것입니다. 반대로 도메인 재구성이 필요한 경로에서는 어댑터의 `FeedItemPersistenceMapper`가 코어 모델 변경을 따라 바뀌는 것이 의도한 결합입니다.
### 패키지 축과 모듈 축 — 왜 둘 다 쓰는가
패키지와 모듈은 같은 경계를 두 번 표현하려고 함께 쓴 것이 아닙니다. 저는 패키지로 **무엇이 같이
사는가**를 정하고, 모듈로 **무엇이 무엇을 알 수 있는가**를 실제 컴파일 범위에서 제한했습니다.
기능·기술 패키지가 섞인 hybrid 배치와 19개 모듈은 서로 다른 역할을 맡습니다.
패키지 축으로 응집은 얻지만 의존 위반은 여전히 컴파일러가 막지 못합니다. 도메인의 `feed`는 기능 응집을 보이지만 샘플 어댑터의 `controller`·`dto`·`mapper`와 애플리케이션의 `command`·`query`·`port`는 기술 책임으로 묶입니다.
외부 사례에서 패키지를 나눈 이유도 달랐습니다. Sahibinden은 기능 패키지의 응집·캡슐화·모듈성을,
arawn은 외형 복제보다 높은 응집과 느슨한 결합을 강조했습니다. 우아한형제들 사례에서는 레이어 단위로
멀티모듈을 기계적으로 나누면 output port가 많이 생길 수 있었습니다.
그런데 패키지 캡슐화가 지켜주는 범위는 좁습니다. `package-private`는 같은 패키지 안에서 어떤 클래스를
서로 볼 수 있는가를 컴파일러가 강제하지만 이 패키지가 어떤 외부 라이브러리에 의존해도 되는가라는
규칙은 강제하지 않습니다. 자바 문법에는 "이 패키지는 저 패키지를 import하면 안 된다"가 없습니다.
남는 방어선은 패키지 규칙 기반 ArchUnit 하나뿐입니다.
모듈 축은 컴파일과 빌드가 강제합니다. `domain-core`가 별도의 프레임워크 의존성을 선언하지 않으면 그런 타입은
이 모듈의 클래스패스에 없으므로, 참조하면 `javac`가 컴파일을 중단합니다. 모듈 그래프가 못 보는 패키지 내부는 ArchUnit이 이어서 검증합니다.
패키지 축만 있으면 관련 책임은 가까이 놓이지만 그 경계가 무너져도 컴파일러는 이를 잡지 못합니다. 모듈 축만 있으면 위반은 확실히 막히지만 같은 기능의 코드가 모듈 내부에서 서로 다른 책임들과 뒤섞이는 것까지는 막지 못합니다. 패키지가 그 응집을 담당합니다. 패키지는 경계를 사람이 읽기 쉽게 만들고 모듈은 그 경계를 빌드가 어기지 못하게 만듭니다.
### 내부 정책 모듈 — domain-core·application-core·shared-contract
세 코어 모듈을 모두 같은 수준의 순수성으로 정의하지는 않았습니다. `domain-core`
`shared-contract`의 main 외부 의존은 비워 두었지만 `application-core``domain-core`
`shared-contract`를 알고 SLF4J도 사용합니다. “세 모듈이 모두 완전히 순수하다”고 약속하는 대신,
각 모듈이 알아도 되는 타입을 클래스패스와 ArchUnit 규칙으로 제한했습니다.
| 모듈 | 맡은 결정 | 허용한 지식 | 대표 실행 경로 | 경계를 고정하는 규칙 |
| -------------------- | ----------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------- |
| `domain-core` | 애그리게이트·값·이벤트·식별자와 불변식 | main compile은 JDK와 자체 타입뿐 | `FeedItem`이 자체 스테레오타입·JDK 타입만 사용 | `DOMAIN_IS_PURE`, `DOMAIN_HAS_NO_LOGGER` |
| `application-core` | 유스케이스 순서와 바깥 능력의 포트 | domain-core, shared-contract | `GetFeedUseCase``tx.inRead(() -> feedQuery.loadFeed(...))` 호출 | `APPLICATION_DOES_NOT_DEPEND_ON_ADAPTERS_OR_TRANSPORT` 외 |
| `shared-contract` | 응답·오류·추적·메트릭 같은 운영 계약 | main 외부 의존 0, 허용한 운영 패키지 prefix | `Envelope(success, data, error, meta)``ApiErrorCode` | `SHARED_CONTRACT_CONTAINS_ONLY_OPERATIONAL_CONTRACT_PACKAGES` |
`domain-core`의 빈 의존 블록은 Spring·JPA·Servlet·Hibernate 같은 외부 프레임워크 타입이 들어올 직접 의존 통로를 없앱니다. JDK 자체의 파일·네트워크·SQL API까지 자동으로 금지한다는 뜻은 아닙니다.
`DOMAIN_IS_PURE`는 금지 패키지 의존을 막고 `DOMAIN_HAS_NO_LOGGER`는 로깅 프레임워크까지 차단합니다.
두 규칙은 도메인이 직접 로그를 남기지 못하게 합니다. 예외를 어떤 종류로 나누고 각 예외에 구체적인
실패 사유를 담을지는 별도의 규칙으로 확인해야 합니다.
`application-core`의 main 의존은 `domain-core``shared-contract`뿐입니다. `spring-web`·JPA·
`spring-tx`는 main compileClasspath에 없습니다. 유스케이스는 `TransactionPort`·`OutboxStorePort`·
`FeedQueryPort` 같은 인터페이스로 필요한 기능을 요청하고 어댑터가 구현합니다. `@Transactional`
컴파일 의존과 ArchUnit 규칙 양쪽에서 막기 때문에 트랜잭션 의도도 `TransactionPort`로 표현합니다.
`shared-contract`에는 도메인 규칙이 아니라 응답·오류·로깅·추적 같은 운영 계약을 모아 두었습니다.
`response`·`error`·`logging`·`tracing` 등 고정된 패키지 밖에 새 공유 타입을 두면
`SHARED_CONTRACT_CONTAINS_ONLY_OPERATIONAL_CONTRACT_PACKAGES`에서 거부합니다. 다만 이 규칙은 package prefix만 검사하기 때문에, 허용 패키지 안에 놓인 타입의 의미가 실제로 운영 계약인지까지 판별하지는 않습니다.
### 인바운드 어댑터 — web·grpc·graphql·websocket
REST·gRPC·GraphQL·WebSocket 네 인바운드 모듈은 같은 깊이로 구현하지 않았습니다. 기본 실행에는
`web`만 넣고, gRPC·GraphQL·WebSocket은 필요한 프로젝트가 따로 연결하는 opt-in 참조 구현으로
두었습니다. 구현 범위는 달라도 전송 기술을 코어 밖에 두고 아웃바운드 구현을 직접 고르지
않는 규칙은 같게 적용했습니다.
| 모듈 | 현재 제공하는 기능 | 결정적인 차이 |
| ----------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------- |
| `adapter:inbound:web` | `/feed` REST·health·공유 웹 인프라 | `FeedController``GetFeedUseCase`를 호출하고 `FeedWebMapper`로 응답 DTO를 만듭니다 |
| `adapter:inbound:grpc` | health·reflection | 피처 proto 없이 Netty 서버를 직접 수명주기 관리합니다 |
| `adapter:inbound:graphql` | 최소 헬스 스키마 | 피처 스키마·리졸버 추가는 소비 프로젝트의 확장 작업입니다 |
| `adapter:inbound:websocket` | STOMP-over-SockJS 실시간 채널 | 인프로세스 도메인 이벤트를 토픽으로 보내는 best-effort 경로이며 내구성 있는 outbox가 아닙니다 |
기본 실행에 연결한 Feed 경로는 이 규칙을 가장 짧게 보여 줍니다. `FeedController``GetFeedQuery`를 만들어
`getFeed.handle(...)`에 넘긴 뒤 `FeedSummary`를 웹 DTO로 매핑하며 JPA 리포지토리나 엔티티를 호출하지 않습니다. `WEB_ADAPTER_DOES_NOT_DEPEND_ON_PERSISTENCE_OR_OUTBOUND_ADAPTERS`가 이 우회를 금지하고 `CONTROLLERS_DO_NOT_RETURN_DOMAIN_OR_ENTITY_TYPES`는 공개 메서드의 raw 반환 타입이 지정된 도메인·영속 entity·repository 패키지 타입이 되는 것을 막습니다(제네릭 내부 타입까지 검사하지는 않습니다).
세 opt-in 모듈이 있다고 업무 API까지 완성된 것은 아닙니다. gRPC는 health·reflection만, GraphQL은 최소
헬스 스키마만 제공하고 WebSocket 경로의 도메인은 STOMP를 알지 못합니다. 실제 전송 기능을 추가하려면
각 모듈에 피처 계약을 더해야 합니다. 이렇게 얇은 opt-in 모듈로 남겨 두면 기본 애플리케이션에 불필요한
전송 의존을 넣지 않고도 확장 지점을 시험할 수 있습니다. 마지막 안전망은 전송 종류와 무관합니다.
`INBOUND_ADAPTERS_DO_NOT_DEPEND_ON_OUTBOUND_ADAPTERS`가 인바운드에서 아웃바운드로 향하는 모든 직접 의존을 거부합니다.
### 아웃바운드 어댑터 — 유형별
아웃바운드 모듈 열 개도 같은 깊이로 구현하지 않았습니다. `persistence-jpa`처럼 실제 구현을 넣은
모듈이 있는 반면, `cache-redis`·`messaging`·`notification`에는
`RedisClient`·`KafkaSender`·`SlackClient` 인터페이스만 두었습니다. 프로젝트마다 외부 시스템 선택이
달라질 수 있어 “바로 실행할 기준 구현”과 “소비 프로젝트가 채울 확장점”을 구분했습니다. 구현 깊이는
달라도 인바운드나 다른 아웃바운드 구현을 직접 선택하지 않는 규칙은 같게 적용했습니다.
| 묶음 | 모듈 | 구현된 능력 | 도입 시 확인할 예외 |
| --------------- | ------------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 영속성 | `persistence-jpa`, `persistence-mongo` | RDBMS 구현과 opt-in NoSQL 배선 | 멱등성·outbox·분산 락은 JPA에 구현돼 있습니다. Mongo는 드라이버·리포지토리 스캔 배선만 있고 `document``repository`는 템플릿을 가져다 쓰는 프로젝트에서 추가합니다. |
| SDK 없는 확장점 | `cache-redis`, `messaging`, `notification` | 실제 Redis·Kafka·Slack 클라이언트를 연결할 인터페이스 | 모듈이 존재해도 벤더 연결이 완성된 것은 아닙니다 |
| 외부 시스템 | `objectstorage`, `httpclient`, `fileserver` | AWS SDK S3/MinIO, 회복성 HTTP, 순수 JDK 파일 출력 | 클라이언트 유무와 빈 활성화 조건이 서로 다릅니다 |
| 기반 구현 | `identifier`, `support` | UUIDv7 식별자와 공통 상관관계 로깅 | `support`만 형제들이 공유할 수 있습니다 |
영속성의 기준 구현은 `persistence-jpa`입니다. Feed 엔티티·리포지토리·매퍼와, 나중에 확인할
트랜잭션·락·멱등성·outbox 구현이 이 모듈에 놓입니다. PostgreSQL 드라이버는 `runtimeOnly`
compileClasspath에 들어오지 않습니다. `.postgresql` 밖의 패키지는
`PERSISTENCE_RDBMS_STAYS_VENDOR_NEUTRAL` 규칙 때문에 벤더 타입을 참조하지 못합니다.
`cache-redis`·`messaging`·`notification` 세 모듈의 빌드에는 각각 Redis SDK, Kafka SDK, Slack SDK
선언이 없습니다. `ca-tmpl`을 가져다 쓰는 프로젝트에서 `RedisClient`, `KafkaSender`, `SlackClient`의 실제 구현을 연결해야
합니다. 인터페이스가 클래스패스에 보인다고 외부 시스템 연동까지 끝난 것은 아닙니다.
의존 권한도 균일하지 않습니다. `objectstorage`·`fileserver`·`persistence-mongo``application-core`·`shared-contract`만 볼 수 있어 도메인 어휘를 모르고 나머지는 맡은 구현에 따라
`domain-core` 접근이 허용됩니다. `support`는 화이트리스트에 명시적으로 등록된 공유 기반입니다.
`messaging`·`cache-redis`·`notification`·`httpclient` 네 모듈은 `support`를 볼 수 있지만
`support`가 형제 구현을 역으로 선택할 수는 없습니다.
`OUTBOUND_ADAPTERS_DO_NOT_DEPEND_ON_INBOUND_ADAPTERS`가 전송 계층으로 향하는 의존을
차단하고 `OUTBOUND_ADAPTERS_ARE_PEERS_SHARING_ONLY_SUPPORT`는 같은 아웃바운드의 직접 의존을 금지합니다.
이렇게 제한하는 이유는 아웃바운드 구현 하나가 다른 구현을 선택하기 시작하면 교체 단위가 서로
묶이기 때문입니다. 공통 로깅과 상관관계 기능만 `support`로 공유하고 구체 어댑터 간 의존은 금지해 각
구현을 독립적으로 바꿀 수 있게 합니다. 대신 `support`가 잡다한 공용 모듈로 커지지 않도록 화이트리스트와 ArchUnit 규칙을 함께 유지해야 합니다.
### 조립 — app-bootstrap composition root
모듈 등록과 기능 실행은 한 단계로 묶지 않았습니다. `settings.gradle`은 모듈의 존재만 등록하고,
`app-bootstrap`이 런타임 클래스패스에 올릴 모듈을 고릅니다. 그다음 Spring의 조건부 설정이 실제
빈을 활성화합니다. Composition root에서 이 세 선택을 한곳에 모았습니다.
`objectstorage`·`fileserver`·`persistence-mongo`도 프로젝트 의존성으로 명시합니다. 따라서 이 세 모듈은
main 클래스패스에 들어오지만, 실제 빈의 활성화 여부는 런타임 프로퍼티로 결정됩니다. 셋 다
`ca-skeleton.<모듈>.enabled` 기본값이 꺼짐입니다. 반면 `grpc`·`graphql`·`websocket`은 저장소에
포함되어 있지만 `app-bootstrap`의 프로젝트 의존성에는 없습니다. 이는 클래스패스에 등록하고 나면
`@ConditionalOnProperty`로 켜고 끌 수단이 남지 않기 때문입니다.
세 모듈이 그런 상태인 근거는 각각 다릅니다. `GrpcServerConfig`의 게이트는 `matchIfMissing = true`
플래그가 없으면 기본이 켜짐이고, `HealthGraphqlController``WebSocketConfig`에는 조건 애노테이션이
아예 없습니다. `CaSkeletonApplication``dev.caskeleton.adapter`를 통째로 컴포넌트 스캔하므로, 클래스패스에
올리는 순간 각각 별도 TCP 포트·`/graphql` 마운트·`/ws` STOMP 엔드포인트가 부팅마다 생깁니다. 이 셋에게는
의존성 선언을 하지 않는 것이 유일한 opt-in 수단입니다.
그런데 클래스패스에 모듈이 있다고 모든 빈이 바로 실행되는 것은 아닙니다.
`persistence-mongo`가 그 사례입니다. 모듈 자신의 `@ConditionalOnProperty`는 자기 빈만 통제할 뿐, 스타터가
클래스패스에 올라오면 발동하는 Spring Boot 자체의 Mongo 자동설정까지 막지는 못합니다. 그래서 조립
루트가 나머지 절반을 맡습니다. `app-bootstrap``application.yml``spring.autoconfigure.exclude`
Mongo 자동설정 세 개를 꺼 클래스패스를 무력화하고, 모듈이 켜질 때 `MongoPersistenceConfig`
`@ImportAutoConfiguration`으로 같은 셋을 되살립니다. 명시적 import는 `spring.autoconfigure.exclude`
영향을 받지 않으므로 모듈을 켰을 때 자동 설정을 다시 활성화할 수 있습니다. 따라서 이 opt-in 동작은
조립 루트와 모듈이 함께 구현하는 계약입니다.
아래 그림은 `app-bootstrap`의 main 프로젝트 의존에 포함된 어댑터 열한 개와 현재 main 의존 목록에
없는 참조 어댑터 세 개를 비교합니다. 실행 시 활성 빈 전체를 측정한 그림은 아닙니다.
<!-- techviz:begin id=production-vs-optin context-sha256=81fb5cb8cd16eaae6916a0d0f2b3cddfabc39e58a87559466b52f922ca95a95b -->
<!-- techviz:generate id=production-vs-optin -->
![왼쪽의 app-bootstrap main 의존 포함 어댑터 11개와 오른쪽의 main 의존 목록 밖 grpc·graphql·websocket 세 개를 비교한 그림. 클래스패스 구성 비교이며 활성 빈 수를 뜻하지 않는다.](../assets/production-vs-optin.svg)
<details>
<summary>Diagram description</summary>
왼쪽 비교 항목은 app-bootstrap의 main 프로젝트 의존에 포함되어 main 클래스패스에 들어오는 어댑터 11개를 나타냅니다. 클래스패스 포함과 실제 빈 활성화는 별개이며 런타임 조건이 활성화를 추가로 결정합니다. 오른쪽 비교 항목은 현재 main 의존 목록에 없는 grpc, graphql, websocket 세 참조 어댑터를 나타냅니다. 이 셋은 클래스패스에 등록되면 기본 활성화되므로 의존성 선언을 하지 않는 것이 opt-in 수단입니다. 두 수치는 main 의존 선언을 비교한 것이며 실행 시 활성 빈 전체를 측정한 값이 아닙니다.
</details>
[Editable source](../assets/production-vs-optin.drawio) · [Grounded VizSpec](.techviz/production-vs-optin/spec.json)
<!-- techviz:end id=production-vs-optin -->
```java
// ca-tmpl · adapter/outbound/cache-redis/.../RedisCacheAdapterConfig.java
@Bean
@ConditionalOnProperty(
name = "app.cache.redis.enabled",
havingValue = "true",
matchIfMissing = false)
public CacheBackend redisCacheBackend(RedisClient redisClient) {
return new RedisCacheStore(redisClient);
}
```
`@ConditionalOnProperty`가 외부 백엔드 빈 활성화를 한 번 더 결정합니다.
`app-bootstrap`이 의존하는 모듈도 모두 실행되지는 않습니다. Redis나 Kafka 같은 외부 백엔드는 런타임
프로퍼티가 켜졌을 때만 빈으로 등록됩니다. 프로퍼티는 이미 클래스패스에 들어온 모듈의 기능만 선택하며,
의존성에 없는 모듈을 자동으로 불러오지는 않습니다. `sample-portfolio`도 테스트용 의존으로만 연결되어
기본 애플리케이션의 main 클래스패스에는 들어가지 않습니다. 샘플을 제거한 뒤에도 핵심 테스트가
동작하는지는 별도의 빌드 테스트로 확인합니다.
```groovy
// ca-tmpl · app-bootstrap/build.gradle:16-21
configurations {
sampleFixture {
canBeConsumed = false
canBeResolved = false
}
}
```
`testCompileClasspath`·`testRuntimeClasspath``sampleFixture`를 확장하므로 테스트에서는
`sample-portfolio``WorkLog` 같은 샘플 전용 타입이 함께 보입니다. 반대로 main의
`compileClasspath`·`runtimeClasspath`는 이 configuration을 확장하지 않습니다. 격리
주장은 테스트 클래스패스 전체가 아니라 main 프로덕션 컴파일·런타임 그래프에 한정됩니다.
`sampleOffTest`는 샘플 없는 핵심 테스트 경로를 별도로 정의하고 `SampleRemovalSmokeContractTest`
지정된 열다섯 모듈이 샘플을 일반 프로덕션 configuration으로 참조하지 않는지 검사합니다. main
클래스패스 밖의 세 어댑터까지 자동 탐색하지는 않습니다.
*격리는 main 프로덕션 그래프에 한정됩니다. 테스트 클래스패스에는 샘플과 그 전이 의존이 함께 보입니다.*
**메인 엔트리와 두 번째 composition root.** 이렇게 배선된 그래프가 부팅하는 지점은 클래스 하나입니다.
`CaSkeletonApplication``bootstrap`·`adapter`·`application`·`domain`·`shared` 다섯 패키지를 컴포넌트
스캔과 `@ConfigurationProperties` 스캔 양쪽에 명시적으로 올립니다.
```java
// ca-tmpl · app-bootstrap/.../CaSkeletonApplication.java:7-22
@SpringBootApplication(
scanBasePackages = {
"dev.caskeleton.bootstrap",
"dev.caskeleton.adapter",
"dev.caskeleton.application",
"dev.caskeleton.domain",
"dev.caskeleton.shared"
})
@ConfigurationPropertiesScan(
basePackages = {
"dev.caskeleton.bootstrap",
"dev.caskeleton.adapter",
"dev.caskeleton.application",
"dev.caskeleton.domain",
"dev.caskeleton.shared"
})
```
`sample-portfolio`는 여섯 번째 최상위 패키지 `dev.caskeleton.sample` 아래에 있으므로 이 스캔에서도
제외됩니다. 클래스패스 격리와 패키지 스캔 격리가 같은 방향을 가리킵니다. 그렇다고 샘플이 부팅
불가능한 코드 조각은 아닙니다. `sample-portfolio`는 Spring Boot 플러그인을 직접 적용한 두 번째 독립
composition root입니다. 자체 `SamplePortfolioApplication`이 프로덕션 모듈과 샘플 패키지를 함께 스캔하되
샘플 전용 영속 구성으로 대체할 설정과 공개 데모에서 제외할 보안 구성은 필터로 제외합니다.
### 클래스패스가 경계를 강제하는 원리
`domain-core`에서는 Spring·JPA 타입을 금지 목록에만 올리지 않고 main compileClasspath에서도
제외했습니다. 패키지가 코드를 어디에 둘지 보여 준다면, 모듈은 그 코드가 볼 수 있는 타입 자체를
줄입니다. 저는 이 차이를 이용해 의존 그래프를 문서상의 약속이 아니라 컴파일러가 검사하는 규칙으로
만들었습니다.
한 가지 모델을 짚고 가겠습니다. 자바는 컴파일 시 각 모듈의 클래스패스(그 모듈이 볼 수 있는 타입의 집합)를
기준으로 타입을 해석합니다. Gradle 멀티모듈은 이 클래스패스를 모듈마다 분리하므로 직접 또는 전이
main 의존으로 도달하지 않는 라이브러리 타입은 그 모듈의 main 컴파일에서 보이지 않습니다. 뒤에서
확인할 경계 검사는 모두 이 클래스패스 분리를 전제로 합니다.
멀티모듈이면 각 모듈의 `build.gradle`이 자기가 필요한 것만 선언합니다. `domain-core` leaf는 main
의존을 선언하지 않고 `shared-contract`도 main compile 의존이 비어 있습니다. 두 모듈의 lockfile은
`compileClasspath`·`runtimeClasspath`를 빈 configuration으로 기록합니다. 애플리케이션 코어
lockfile에는 Spring Boot·DI 관련 main 의존이 있지만 Spring Web·WebMVC는 테스트 configuration에만
나타나고 Spring TX·JPA 항목은 없습니다. 그 결과 각 코어 모듈에서 정책상 금지한 타입이 그 모듈의
main compileClasspath에 없습니다. 도메인 클래스에 `import org.springframework...`를 쓰면 테스트 단계까지
갈 것도 없이 해당 모듈의 소스를 컴파일하는 모든 빌드에서 `javac`가 실패합니다.
여기서 configuration의 범위도 분명히 해야 합니다. Gradle의 Java Library 플러그인 맥락에서 `api`는 공개 계약 타입을
소비자에게 전이 노출하고 `implementation`은 구현 의존을 내부로 좁힙니다. 현재 `ca-tmpl`의 leaf
subproject는 `java` 플러그인을 적용하고 `java-library`는 적용하지 않습니다. 빌드 파일은
`implementation` 의존을 사용하며 `api` 선언은 없습니다. 따라서 이 구성을 Java Library 플러그인의
`api`/`implementation` 캡슐화 선택으로 해석해서는 안 됩니다.
```groovy
// ca-tmpl · adapter/inbound/web/build.gradle
implementation project(':domain-core')
implementation project(':application-core')
implementation project(':shared-contract')
```
실행 시점 드라이버에는 `runtimeOnly`, 컴파일 보조 도구에는 `compileOnly` 같은 별도 configuration도
씁니다. 경계 강제에 중요한 사실은 각 모듈이 컴파일에 필요한 의존을 직접 드러낸다는 점입니다. Java
Library 플러그인을 도입해 공개 API를 설계한다면 `api`의 소비자 편의와 넓어진 전이 가시성을 함께
평가해야 합니다.
그렇다면 하나의 모듈 안에서 패키지만으로 클린 아키텍처를 표현하면 어떻게 될까요? 차이는 여기서 드러납니다.
![왼쪽 멀티모듈(모듈별 분리 클래스패스, javac가 금지 타입 차단)과 오른쪽 단일모듈(공유 클래스패스, ArchUnit만 남음)을 대비하는 두 패널.](../assets/module-vs-single.svg)
*멀티모듈의 실익은 규칙 수가 아니라 실패 시점입니다. 금지 타입이 클래스패스에서 사라져 javac가 먼저
멈추는 반면, 단일모듈은 같은 위반을 ArchUnit 실행까지 미룹니다.*
단일 모듈이면 모든 클래스가 하나의 컴파일 클래스패스를 공유합니다. 어댑터 코드에는 Spring과 JPA가
필요하니 그 의존이 모듈에 들어옵니다. 그러면 도메인 패키지에서도 그 타입들이 그대로 보입니다. `domain`
패키지 안에서 `import org.springframework...`를 써도 컴파일이 멀쩡히 통과합니다. 남는 방어선은 패키지
규칙 기반의 ArchUnit 하나뿐입니다. 이건 컴파일 이후에 도는 테스트라서 끄거나 glob을 잘못 쓰거나 깜빡
잊으면 조용히 통과합니다.
이 경계를 검증하려고 위반 픽스처의 클래스패스도 분리했습니다. 프로덕션 `application-core`에는
`spring-tx`가 없어 `@Transactional` 타입을 해석할 수 없습니다. 그래서 ArchUnit 규칙을 시험하는
픽스처는 `app-bootstrap`의 테스트 소스셋에 두고, 그 소스셋에만 `spring-tx`
`testCompileOnly`로 추가했습니다.
```groovy
// ca-tmpl · app-bootstrap/build.gradle — 위반 픽스처를 "컴파일"하기 위해서만 되넣는다
testCompileOnly 'org.springframework:spring-tx'
```
이 한 줄이 프로덕션과 규칙 테스트의 클래스패스를 갈라 놓습니다. 프로덕션 코어에서는 금지 타입이
해석되지 않고 위반 픽스처를 평가하는 테스트 소스에서만 그 타입이 보입니다. 단일 모듈의 공유
클래스패스라면 이런 분리가 성립하지 않습니다.
### 세 겹 게이트 — 클래스패스·화이트리스트·ArchUnit
클래스패스 격리 하나로 모든 위반을 막을 수는 없습니다. 클래스패스는 코어에 없는 외부 타입은 막아도,
허용된 타입 사이의 잘못된 모듈 의존이나 같은 모듈 안의 패키지 위반까지 모두 알 수 없습니다. 그래서
`ca-tmpl`에는 컴파일 클래스패스, Gradle
화이트리스트, ArchUnit을 함께 둡니다. 세 검사는 맡은 위반이 다르며 고정된 실행 순서나 속도 순위를
뜻하지 않습니다.
![컴파일 클래스패스 격리, Gradle 의존 화이트리스트, ArchUnit 규칙을 실행 순서가 없는 세 독립 강제 범위로 놓고, 앞의 두 범위가 모듈 분리에 기대는 점을 묶어 표시한 그림.](../assets/three-gate-flow.svg)
*세 게이트는 각기 다른 위반 유형을 맡습니다. 실행 순서나 속도 순위는 없습니다. 모듈을 합치면 클래스패스
격리와 프로젝트 의존 정책의 범위가 사라지고 ArchUnit의 별도 범위만 남습니다.*
| 겹 | 무엇을 막나 | 언제 | 단일모듈이면 |
| --------------------------- | ----------------------------- | -------------------------------------- | :-----------: |
| ① 컴파일 클래스패스 격리 | 코어의 금지된 서드파티 import | 해당 모듈을 컴파일하는 빌드의 `javac` | 사라짐 |
| ② Gradle 모듈 화이트리스트 | 금지된 모듈→모듈 의존 | 빌드 검증(check) | 사라짐 |
| ③ ArchUnit 패키지 규칙 | 패키지·타입 수준 위반 | 테스트 | 유일하게 남음 |
**Gradle 모듈 화이트리스트(②).** 루트 `build.gradle`에 각 모듈이 의존해도 되는 모듈을 명시한 지도가
있습니다.
```groovy
// ca-tmpl · build.gradle — 정책 발췌 (전체 맵의 일부)
Map<String, Set<String>> allowedProjectDependencies = [
'domain-core' : ['shared-contract'],
'application-core' : ['domain-core', 'shared-contract'],
'adapter:outbound:objectstorage' : ['application-core', 'shared-contract'], // domain-core 없음
//...
'shared-contract' : [], // 허용 project 의존 0
]
```
`verifyCleanArchitectureDependencies` 태스크는 각 모듈의 `api`·`implementation`·`compileOnly`·
`runtimeOnly` 네 production configuration에 직접 선언된 `project(...)` 의존만 읽어 이
화이트리스트와 대조합니다. 벗어난 의존이 하나라도 있으면 `GradleException`이 발생해 검증이 실패합니다.
테스트·사용자 정의 configuration과 해석된 전이 의존 그래프는 검사 범위가 아닙니다. 이 태스크는
모든 모듈의 `check`에 걸려 있고 양방향 완전성을 검사합니다. 정책에만 있고 존재하지 않는 모듈이
있어도, 반대로 새 모듈을 추가하고 정책에 등록하지 않아도 `check`가 실패합니다. 규칙을 모르는 새 코드가
정책 밖에서 들어오는 것을 막습니다.
**ArchUnit 바이트코드 규칙(③).** 모듈 그래프가 못 보는 패키지 내부까지 잡습니다.
```java
// ca-tmpl — 도메인이 Spring/JPA/Lombok/다른 계층을 의존하면 테스트 실패
static final ArchRule DOMAIN_IS_PURE =
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework..", "jakarta.persistence..", "lombok..", "..adapter..");
// 대표 4개 발췌 — 실제 규칙은 13개 금지 패키지
```
> **세는 기준.** "규칙 수"는 `@ArchTest`가 붙은 `ArchRule`을 센 것입니다. `CleanArchitectureTest`
> 57개, `DisabledAdapterArchitectureTest` 2개, `NamingConventionTest` 2개,
> `ScheduledJobOverlapPolicyTest` 1개, `TaskExecutorDecoratorPolicyTest` 1개 — 총 **63개**가 5개
> 클래스에 분산돼 있습니다. 별도로 manual importer로 직접 평가하는 4개 규칙을 합치면 `static final ArchRule`은 8개 클래스의 67개입니다.
규칙의 폭이 넓습니다. 도메인 순수성뿐 아니라 읽기 전용 유스케이스가 리포지토리 쓰기 메서드를 부르지
못하게, 컨트롤러와 아웃바운드 어댑터의 raw 반환 타입이 지정된 패키지 타입이 되지 못하게까지 검사합니다.
도메인 순수성은 `DOMAIN_IS_PURE` 하나로 끝나지 않습니다. 도메인 로거 금지,
`@ValueObject`의 public 무인자 생성자 금지, `@AggregateRoot`의 public setter 금지라는 세 모델링
가드레일이 함께 받칩니다.
세 겹에 비공허성 검증(뒤의 test-the-test 절)과 사람·운영 판단을 더하면 서로 겹치지만 대체할 수 없는
다섯 강제 범위가 됩니다.
![javac 클래스패스, Gradle 프로젝트 의존 정책, ArchUnit 구조 규칙, test-the-test 비공허성 검증, 리뷰·런타임 검증을 순서나 속도 비교 없이 겹쳐 놓은 다섯 강제 범위.](../assets/enforcement-ladder.svg)
*다섯 범위는 서로 대체하거나 항상 같은 순서로 실행되는 단계가 아닙니다. 각 범위가 잡는 위반 종류와
놓치는 영역이 달라 함께 경계를 보완합니다.*
## Feed 조회 한 건, 그리고 구현했지만 연결되지 않은 계약들
Feed 조회는 제가 모듈 경계를 설명하기 위해 만든 가장 짧은 기준 경로입니다. 유스케이스, 트랜잭션
포트, 영속 어댑터를 모두 지나지만 흐름은 네 단계로 끝납니다. 이 경로를 먼저 설명한 뒤 쓰기 경로에서
사용하거나 계약만 준비해 둔 검증, 예외·오류 응답, 로깅·추적, 트랜잭션·일관성, 멱등성, outbox를
각각 나누어 설명하겠습니다.
### Feed 조회 경로 — 컨트롤러에서 응답까지
`GET /feed?page=0&size=20` 요청은 컨트롤러에서 응답을 만들 때까지 다음 네 단계를 지나도록
구현했습니다. 포트와 어댑터 타입은 여러 개지만 실제 읽기 호출은 이 순서로 끝납니다.
1. `FeedController`가 쿼리 파라미터로 `GetFeedQuery(page, size)`를 만들어 `getFeed.handle(...)`
호출합니다. 컨트롤러가 주입받은 선언 타입은 구체 클래스 `GetFeedUseCase`입니다.
2. `GetFeedUseCase.handle()``@UseCaseCapability(transactionMode = READ_ONLY, repositoryAccess = READ_REPOSITORY)`를 선언하고 `tx.inRead(() -> feedQuery.loadFeed(...))`를 호출합니다. 읽기
트랜잭션 경계 안에서 출력 포트를 부릅니다.
3. DI가 연결한 `FeedQueryAdapter``FeedQueryPort` 계약의 실체로 실행됩니다. JPA 조회 결과에서 코어의
읽기 전용 투영 `FeedSummary`를 직접 조립합니다. 도메인 애그리게이트 재구성은 건너뜁니다.
4. 결과 `List<FeedSummary>`가 유스케이스와 트랜잭션 경계를 되돌아 나오고 `FeedWebMapper.toResponse`
이를 웹 응답 `FeedResponse`로 다시 조립해 컨트롤러가 반환합니다.
![FeedController·GetFeedUseCase·TransactionPort 호출 경계·FeedQueryAdapter 네 런타임 참여자 사이에서 handle 호출, inRead 진입, 콜백 실행, FeedQueryAdapter 디스패치와 반환, inRead·handle 반환을 1~8 순서로 분리하고, FeedQueryPort는 별도 컴파일 시점 계약 배지로 둔 시퀀스.](../assets/runtime-seq-feed.svg)
*실제 실행은 GetFeedUseCase가 TransactionPort.inRead에 들어간 뒤 콜백에서 FeedQueryAdapter를
호출하고 결과를 되돌리는 순서입니다. FeedQueryPort는 런타임 lifeline이 아니라 컴파일 시점 타입 계약입니다.*
이렇게 구현했기 때문에 HTTP 응답 바디는 `FeedResponse`가 정하고 코어는 그 모양을 모릅니다. 피드
항목은 `FeedItemJpaEntity → FeedSummary → FeedResponse` 세 모델을 거치고, 두 매핑은 모두
어댑터가 맡습니다. 소스 의존도 컨트롤러와 어댑터에서 코어 쪽으로만 향합니다. Feed 조회 한 건에 포트
소유권과 모델 분리, 클래스패스 격리를 함께 담았습니다.
### 검증 — 3계층과 Feed 경로의 공백
현재 Feed 경로에는 `page``size`의 웹 경계 검증을 연결하지 않았습니다. `FeedController`에는
Bean Validation도 범위 거부도 없습니다. 두 파라미터는
`@RequestParam(required = false, defaultValue = "0")`로만
선언돼 있고 파일 전체에 `jakarta.validation` import도 `@Valid`도 범위 제약도 없습니다. 다만 영속성
어댑터 `FeedQueryAdapter.loadFeed()``Math.max(0, page)`로 음수 페이지를 0으로, `size <= 0? 20 : size`로 0 이하 크기를 20으로 정규화합니다. 이는 잘못된 값을 4xx로 거부하는 입력 검증이 아니라 저장소
호출 직전의 폴백입니다. 비정상적으로 큰 `size`에는 상한이 없습니다.
검증의 기준 구현은 `sample-portfolio``Poster`·`WorkLog` 경로에 넣었습니다. 여기서는
"형식이 맞는가", "여러 필드가 서로 앞뒤가 맞는가", "이 상태 전이가 도메인 규칙을 지키는가"를
서로 다른 곳에서 검사합니다. 세 질문을 한 계층에 몰아넣으면 도메인이 `jakarta.validation`
애노테이션을 알게 되거나, 애플리케이션 계층이 웹 프레임워크의 예외 처리를 흉내 내야 하기 때문입니다.
- **웹 경계 — 문법.** `CreatePosterRequest`는 record에 `@NotBlank`·`@Size`를 붙여 "필드가 있는가,
길이가 맞는가"만 검사합니다. `CreateWorkLogRequest`는 한 걸음 더 나가
`@GroupSequence({Syntax.class, Invariant.class, CreateWorkLogRequest.class})`로 검증 순서를
강제합니다. `Syntax` 그룹이 통과해야 `Invariant` 그룹의 `@AssertTrue isPeriodOrdered()`(종료일이
시작일보다 앞서지 않는가)가 실행됩니다. 두 DTO는 컨트롤러에서 `@Valid @RequestBody`로 소비됩니다.
- **애플리케이션 — 같은 검증을 반복하지 않습니다.** 유스케이스는 커맨드를 받으며 같은 Bean Validation을 반복하지
않습니다. 이 경계는 `VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY` ArchUnit 규칙이
`..domain..`·`..application..` 패키지의 `jakarta.validation..` 의존을 거부해 고정한 것입니다.
- **도메인 — 불변식.** `Poster`의 제목은 반드시 `requireValidTitle`을 거치고 `publish()`는 이미지가
없으면 `PosterInvariantException(IMAGE_REQUIRED)`를 던집니다. 웹이 이미 걸러낸 것과 무관하게 도메인이
다시 지킵니다.
도메인 게이트에도 한계를 함께 기록합니다. `rehydrate(...)`는 저장된 `imageKey`·`status`를 그대로
생성자에 넘기며 생성자 검사 너머의 불변식을 재도출하지 않습니다. `PUBLISHED`와 빈 이미지의 조합까지
재검증하는 완전한 복원 게이트는 아닙니다. Feed 경로에서는 검증이 더 적었습니다. `FeedItem`의 유일한 생성
경로는 `Objects.requireNonNull`만 쓰므로, 클라이언트 입력과 서버 버그를 구분하는 `Reason` 같은
장치가 없고 null이 들어오면 그냥 `NullPointerException`이 납니다.
계층 규율을 고정하는 규칙과 대표 테스트는 다음과 같습니다.
| 검사 | 고정하는 경계 |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY` | 도메인·애플리케이션 패키지의 `jakarta.validation..` 의존 거부 |
| `VALID_CASCADE_DEPTH_AT_MOST_THREE` | `@Valid` 캐스케이드의 직접 raw 필드 사슬을 3단계로 제한하는 근사 가드 — 컨테이너 제네릭 원소와 수렴 그래프의 최장 경로는 정확히 추적하지 못할 수 있습니다 |
| `PosterControllerWireTest` | 빈 제목은 유스케이스 전에 400`VALIDATION_FAILED`, 이미지 없는 발행은 400 `POSTER_IMAGE_REQUIRED` |
| `PosterTest` | null/blank 제목이 NPE가 아닌 `PosterInvariantException(TITLE_BLANK)`로 실패 |
### 예외·오류 응답 — 두 단계 처리 사슬, 하나의 Envelope
모든 예외를 하나의 핸들러에서 `Envelope`로 바꾸지는 않았습니다. `Poster.publish()`에서 발생한
예외는 분류하는 곳과 공개 메시지를 고르는 곳을 나눴습니다. `sample-portfolio`의 독립 실행점에서는
두 단계 `@RestControllerAdvice` 체인이 이 일을 맡습니다. 기본 `CaSkeletonApplication`에는 샘플
모듈이 없으므로 같은 도메인 핸들러 체인도 생기지 않습니다.
샘플 소유 `DomainExceptionHandler``@Order(Ordered.HIGHEST_PRECEDENCE)`로 먼저 실행되어
WorkLog·Poster의 도메인 예외 다섯 종류를 포트폴리오 오류 코드로 바꿉니다. 뒤의
`GlobalExceptionHandler`는 운영·전송·보안·인프라 실패를 맡습니다. 17개 `@ExceptionHandler`와 7개 전송
오류 재정의, 도합 24개 메서드가 있으며 분류되지 않은 예외는 마지막 `Exception.class` 핸들러에서 500
`INTERNAL_ERROR`가 됩니다. 도메인 핸들러는 SQLState나 업스트림 장애를 모르고 전역 핸들러는
`PosterInvariantException.Reason`이나 `PortfolioErrorCode`를 import하지 않습니다.
오류 응답의 모양은 `Envelope<T>(success, data, error, meta)`입니다. 성공·실패 팩토리는 전달받은 값을
관례상 `data` 또는 `error` 한쪽에 놓습니다. 그러나 팩토리는 인자를 null 검사하지 않고 record 생성자도
이를 강제하지 않습니다. exactly-one/non-null은 타입 불변식이 아니라 호출자 사용 규율입니다.
`Envelope`가 전체 HTTP 성공 응답의 유일한 형식도 아닙니다. `FeedController.feed()`
`List<FeedResponse>`를 직접 반환합니다. 실패 본문
`ApiError(code, category, message, retryable, details)`에서 `code`는 클라이언트의 안정된 분기 키이고
`retryable`은 같은 호출을 다시 시도할 가치가 있는지를 별도로 나타냅니다.
운영 분류와 도메인 분류도 서로 독립적입니다. `OperationalError`는 코드 54개를 13개 그룹으로 나누고
`PortfolioErrorCode`는 샘플 전용 6개 값을 정의합니다. 둘 다 `ApiErrorCode`를 구현하므로 응답 팩토리는
같은 계약을 쓰지만 한 enum의 변경이 다른 enum의 변경을 요구하지 않습니다.
| 실패 경로 | 최초 분류 | HTTP 투영 | 공개하지 않는 것 |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ---------------------------------- |
| 이미지 없는 `Poster.publish()` | `DomainExceptionHandler``IMAGE_REQUIRED``POSTER_IMAGE_REQUIRED`로 변환 | 400`Envelope` | 내부 상태 전이 구현 |
| SQLState`23505` 매핑 계약 | `StandardSqlStateErrorMapping``DB_UNIQUE_VIOLATION` 선택, 전역 핸들러 테스트는 `CONFLICT` 안전 메시지 투영 | 두 구성요소의 독립 계약 | SQLState·제약명·원본 진단 메시지 |
| `DependencyFailureException` | 전역 핸들러가 코드별 안전 메시지 선택 | 코드에 따라 `Retry-After` 추가 | 의존성 이름과 원본 진단 메시지 |
| 멱등성 충돌 | 전용 타입별 핸들러 — 현재 호출 엔드포인트 0 | 실행 중 409, 지문 불일치 422, 범위 누락 400 | 저장 레코드 내부 상태 |
특히 SQLState 행은 실제 요청 사슬을 뜻하지 않습니다. portable 매핑과 전역 핸들러는 각각 테스트되지만,
`PersistenceExceptionTranslator.translate(...)`를 호출하는 프로덕션 소비자는 없습니다. 따라서
`23505 → DB_UNIQUE_VIOLATION → HTTP 409` 전체가 이미 배선됐다고 읽으면 안 됩니다.
`Retry-After``retryable=true`와 동의어가 아닙니다. `RetryAfterAdvisor`에 등록된 8개 코드에만 값이
있고 나머지는 헤더를 만들지 않습니다. 영속성·의존성 핸들러도 원본 메시지를 잘라 쓰지 않습니다.
내부 진단 정보가 응답으로 새는 것을 막기 위해 `ClientSafeErrorMessages`가 코드와 카테고리에 따른
고정 문자열을 선택합니다. 원본 예외 메시지를 그대로 쓰는 방식 대신 안전한 공개 메시지를 유지하므로,
새 오류 코드를 추가할 때마다 메시지 매핑도 함께 관리해야 합니다.
```java
// GlobalExceptionHandler.java:224-236 (persistence — 카테고리 기반 고정 메시지, 발췌)
@ExceptionHandler(PersistenceFailureException.class)
public ResponseEntity<Envelope<Void>> handlePersistenceFailure(PersistenceFailureException ex) {
ApiErrorCode code = ex.errorCode();
log.error(
"persistence failure classified as {} (category={}, retryable={})",
code.code(), code.category(), code.retryable(), ex);
spanErrorRecorder.recordException(ex, code.code());
return ErrorResponseFactory.envelope(
code, ClientSafeErrorMessages.forPersistence(code.category()), null);
}
```
분류 코드가 있다고 웹 배선까지 생기지는 않습니다. 멱등성 예외 셋은 전용 핸들러가 있지만
`LockAcquisitionTimeoutException.errorCode()`는 409·retryable 분류를 반환하면서도
`ApiErrorCarrier`를 구현하지 않고 웹 전용 핸들러도 없습니다. 프로덕션 애플리케이션/유스케이스 범위의
`tryAcquire` 호출자가 0개라 현재 요청이 이 예외를 내는 경로는 없습니다. 향후 호출자만 추가하고 예외를
그대로 올리면 catch-all이 500으로 처리합니다. 현재 장애는 아니지만 락을 HTTP 경로에 도입할 때 함께
닫아야 할 배선 공백입니다.
![LockAcquisitionTimeoutException의 409 분류 계약과, 프로덕션 호출자 0·전용 웹 핸들러 0 때문에 현재 웹 409 경로가 연결되지 않은 상태를 분리한 라우팅도.](../assets/lock-timeout-routing-gap.svg)
*409 분류 계약의 존재와 실제 웹 응답 배선은 별개입니다. 분류만으로 409 응답이 보장되지 않습니다.*
테스트가 증명하는 범위도 나뉩니다. `GlobalExceptionHandlerTest`는 SQL 제약명·의존성 진단 메시지가
응답에 없음을, `OperationalErrorTest`는 enum 전체의 카테고리 배정과 결정적 client 오류의
`retryable=false`를 각각 증명하며 24개 핸들러 전체의 균일한 커버리지나 실제 HTTP caller 배선은
증명하지 않습니다. 새 오류 타입을 도입할 때는 코드 등록, 안전 메시지, 핸들러 배선, 비노출
테스트를 각각 확인해야 합니다. enum에 값 하나를 추가하는 것만으로 wire 계약이 완성되지 않습니다.
### 로깅·추적 — 상관관계 ID의 MDC 전파와 가명화
상관관계 ID와 사용자 가명값은 모두 MDC에 넣지만 같은 책임으로 묶지는 않았습니다. 상관관계 ID는
흩어진 로그를 한 요청으로 묶고, 가명화는 그 로그에 원본 사용자
식별자가 남지 않게 합니다. 둘은 같은 MDC(Mapped Diagnostic Context, 스레드별 로그 문맥 저장소)를
사용하지만 `RequestLoggingFilter`가 요청 수명을 관리하고 `UserPrincipalPseudonymizerPort`가 사용자
식별자를 바꿉니다. 아웃바운드 어댑터는 `OutboundCorrelation`으로 이미 만들어진 값을 읽습니다. 도메인은
`DOMAIN_HAS_NO_LOGGER` 때문에 이 계약 전체를 모릅니다.
필터·응답 메타데이터·아웃바운드 로깅의 키 이름이 달라져 추적이 끊기는 것을 막기 위해 키 이름은
`docs/registries/mdc-keys.yaml`에서 한 번만 관리합니다. 이 레지스트리는 19개 키를 등록하며
`request_id`·`trace_id`·`span_id`·`correlation_id`·`tenant_id`·`user_principal`을 core
SSOT(single source of truth, 단일 기준)로 표시합니다. 응답에는 `ResponseMetaFactory`
`request_id`·`trace_id`·`correlation_id`만 camelCase로
옮깁니다(응답 3키 투영). 레지스트리에서 `user_principal`의 헤더 매핑은 `null`입니다.
요청 스레드의 수명주기는 다음 순서로 한 번만 정의됩니다.
1. `RequestLoggingFilter``X-Request-Id`·`X-Correlation-Id`에서 CR/LF를 포함한 U+0000U+001F
제어문자를 제거하고 200자로 제한합니다. 값이 없으면 새로 만듭니다. 유효한 W3C `traceparent`
채택하고 아니면 새 root trace와 span을 만듭니다.
2. 네 키를 MDC에 넣고 필터 체인을 실행합니다. 같은 스레드의 아웃바운드 로깅은
`OutboundCorrelation.current()`로 값을 읽으며 컨텍스트가 없으면 `UNKNOWN`을 씁니다.
3. 체인이 정상 반환하거나 예외를 던지면 `finally`에서 인증 사용자의 원본 ID를 가명화 포트에 넘깁니다.
`HmacUserPrincipalPseudonymizer`는 HMAC-SHA-256으로 64자리 소문자 hex를 만들고 필터는 그 결과만
`user_principal`에 넣어 `http_request`를 기록합니다.
4. 가명 처리와 로그 기록이 끝나면 5키를 제거합니다.
다만 이 구현이 모든 실패에서 MDC 제거를 보장하지는 않습니다. `chain.doFilter`의 정상 반환과 예외는
모두 같은 정리 경로를 지나지만, 가명 처리나 `log.info` 자체가 제거 전에 런타임 예외를 던지면 중첩 `finally`가 없어
제거 호출을 건너뜁니다.
가명화 포트에도 예외 없음(no-throw) 계약은 없습니다. 현재 구현이 보장하는 것은 체인 성공·실패 뒤 정리
**시도**와, 정리 본문이 끝났을 때의 5키 제거까지입니다.
![요청 헤더 살균과 MDC 주입부터 finally의 가명 처리·http_request 로그·조건부 5키 제거까지를 시간순으로 놓은 그림. 제거 전 실패 창과 applicationTaskExecutor의 AsyncContextTaskDecorator 전파를 구분한다.](../assets/mdc-request-lifecycle.svg)
*도식은 필터 체인의 성공·예외 뒤 같은 정리 경로가 시작되는 것과, 정리 본문 자체의 실패까지 5키 제거가
보장되지는 않는 것을 구분합니다.*
비동기 경계에는 별도 조건이 붙습니다. 원시 스레드 전환은 thread-local MDC를 자동 복사하지 않습니다.
구성된 `applicationTaskExecutor``AsyncContextTaskDecorator`를 설치해 제출 시점의 호출자 MDC를
스냅샷으로 잡고 worker에서 작업하는 동안만 설정한 뒤 이전 컨텍스트를 복원합니다. 이 executor를 우회한
스레드에서는 같은 전파를 기대할 수 없고 그때 `OutboundCorrelation.current()``UNKNOWN`으로
떨어집니다.
전파와 실패 정책도 구분해야 합니다. 일반 `OutboundMessagePublisher`는 브로커 실패를 WARN으로 기록하고
삼키는 fail-open 경로입니다. `OutboxMessagePublishAdapter`는 같은 로거를 쓰되 예외를 다시 던지는
fail-closed 경로입니다. 로거가 정책을 정하는 게 아니라 호출자가 실패 이후의 제어 흐름을 정합니다.
공통 로거 구현에는 별도의 노출 위험이 있습니다.
```java
// FailOpenDependencyLogger.java:37-48
public void logFailure(
String dependencyName, String dependencyType, String operation, Throwable cause) {
log.warn(
"dependency_name=\"{}\" dependency_type=\"{}\" operation=\"{}\" "
+ "outcome=\"FAILURE\" correlation_id=\"{}\" error=\"{}: {}\"",
dependencyName, dependencyType, operation,
OutboundCorrelation.current(),
cause.getClass().getSimpleName(),
cause.getMessage());
}
```
시그니처에 payload는 없지만 `cause.getMessage()`는 살균하지 않습니다. 예외 메시지에 원본 요청 데이터가
들어가면 로그로 노출될 수 있습니다. "payload 파라미터를 받지 않는다"와 "민감 정보가 절대 기록되지
않는다"는 서로 다른 보장입니다. 테스트도 그 차이를 드러냅니다. `FailOpenDependencyLoggerTest`
고정한 두 payload·PII 표식은 logger 입력으로 전달되지 않고 assertion literal로만 등장하므로 이
검증만으로 임의 예외 메시지의 살균을 증명하지 못합니다.
### 트랜잭션·일관성 — TransactionPort 3모드와 분산 락의 끊어진 사슬
`TransactionPort``DistributedLockPort`는 같은 실행 흐름에 연결하지 않았습니다.
`TransactionPort`는 애플리케이션 계층이 Spring의 `@Transactional` 없이 트랜잭션 의도를
선언하게 합니다. `DistributedLockPort`는 여러 애플리케이션 인스턴스가 같은 키를 다툴 때 사용할 상호 배제
계약입니다. 현재 프로덕션 애플리케이션과 유스케이스에는 `tryAcquire(...)` 호출자가 없습니다. 두 포트는
각각 계약·어댑터·테스트만 제공하며 하나의 프로덕션 흐름을 만들지는 않습니다.
`TransactionPort``inWrite`/`inRead`/`inNew` 세 메서드를 노출하고 `Supplier`/`Runnable` 콜백을
받습니다. 런타임 예외가 나면 롤백한 뒤 그대로 전파합니다. `inWrite`는 REQUIRED 쓰기, `inRead`는 REQUIRED
읽기 전용, `inNew`는 REQUIRES_NEW이며 세 모드 모두 `READ_COMMITTED`를 명시합니다.
```java
// SpringTransactionPort.java:60-71 — 세 모드 모두 격리 수준을 명시적으로 고정
private static TransactionTemplate template(
PlatformTransactionManager transactionManager,
TransactionMode mode,
int propagation,
boolean readOnly) {
TransactionTemplate template = new TransactionTemplate(transactionManager);
template.setName("application-" + mode.name().toLowerCase());
template.setPropagationBehavior(propagation);
template.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
template.setReadOnly(readOnly);
return template;
}
```
`TransactionTemplate`은 생성자에서 한 번만 만들어집니다. 요청마다 가변 템플릿을 재구성하지 않아 설정
경합을 피하고 벤더 기본 격리 수준 대신 `READ_COMMITTED`를 고정합니다. 애플리케이션의
`@Transactional` 사용은 컴파일 의존 부재와
`APPLICATION_DOES_NOT_USE_SPRING_TRANSACTIONAL_ANNOTATION` 규칙 양쪽에서 막힙니다.
`USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY` 규칙은 직접 호출에 한해 capability 선언과
`inRead`/`inWrite`/`inNew`의 대응을 검사하며 헬퍼 뒤에 숨은 호출까지 추론하지는 못합니다.
분산 락 쪽 배선은 실행 모드에 따라 달라집니다. 기본값 `multi-instance-enabled=false`에서는 인프로세스
`LockRegistryDistributedLockAdapter`가 데코레이터 없는 `@Primary` 구현입니다. 멀티인스턴스 모드를 켰을
때만 JDBC 기반 구현을 `MeteredDistributedLockPort`가 감싸 `@Primary`가 됩니다. 타임아웃 카운터와 리스
만료 처리는 멀티인스턴스 모드에서만 동작하며, 모든 실행 모드에 공통으로 적용되지는 않습니다. 이 락은
인터페이스 javadoc이 명시하듯 정합성 락이 아니라 효율성 락이며 데이터 정합성은 여전히 DB 제약이
지켜야 합니다.
```java
// LockRegistryDistributedLockAdapter.java:31-55 (발췌)
if (leaseTtl.compareTo(configuredTtl) > 0) {
throw new IllegalArgumentException(
"leaseTtl (" + leaseTtl + ") exceeds the registry's configuredTtl ("
+ configuredTtl + ")....");
}
Lock l = registry.obtain(key);
boolean acquired;
try {
acquired = l instanceof DistributedLock distributedLock
? distributedLock.tryLock(waitTime, leaseTtl)
: l.tryLock(waitTime.toMillis(), TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LockAcquisitionTimeoutException(key, waitTime);
}
if (!acquired) {
throw new LockAcquisitionTimeoutException(key, waitTime);
}
return l::unlock;
```
**권장 통합 순서.** `DistributedLock.close()`의 javadoc과 README는 보호할 트랜잭션이 커밋된 뒤 락을
해제하라고 요구합니다. 코드 모양으로 옮기면 락 획득이 먼저, `tx.inWrite(...)`의 반환과 커밋이 그다음,
`close()`가 마지막입니다.
```java
// Javadoc/README가 요구하는 통합 패턴. 현재 프로덕션 애플리케이션/유스케이스 호출자는 없다.
try (DistributedLock lock = locks.tryAcquire(key, waitTime, leaseTtl)) {
return tx.inWrite(action); // 반환 시점에는 commit 또는 rollback이 끝난다.
} // 그 뒤 close()가 락을 해제한다.
```
이 순서는 권장 패턴이며 현재 프로덕션 배선이 아닙니다. 계약 테스트도 try/finally 해제를 보여줄 뿐 실제
DB 커밋과 락 해제를 하나의 통합 테스트로 연결하지 않습니다. `tx.inWrite` 안에서 락을 획득한다고
서술하면 위 권장 순서와 반대가 되므로 그렇게 해석하면 안 됩니다.
![왼쪽은 TransactionPort의 세 모드와 Spring 구현, 오른쪽은 프로덕션 호출자가 0인 DistributedLockPort의 단일·다중 인스턴스 배선을 보여 주는 그림. 아래에는 현재 실행 흐름이 아닌 락 획득·커밋·해제 통합 계약과 DB 정합성 방어선을 분리해 놓았다.](../assets/transaction-lock-independent-contracts.svg)
*두 포트를 한 실행 사슬로 읽으면 프로덕션 호출자 0건과 다중 인스턴스에서만 생기는 계측 배선을 숨기게
됩니다. 향후 결합하더라도 커밋 뒤 해제 순서와 DB 제약의 최종 정합성 책임은 남습니다.*
테스트 경계도 나뉩니다. `SpringTransactionPortTest`의 5개 테스트는 세 모드의
propagation·isolation·readOnly와 런타임 예외 rollback을 단언합니다.
`DistributedLockPortContractTest`의 5개 테스트는 작업 뒤 해제와 `close()` 후 재획득을 검사합니다. 두
스위트는 포트 각각의 부분 계약을 뒷받침하지만 프로덕션 호출자, 트랜잭션과 락의 통합 순서, HTTP 409
매핑까지 증명하지 않습니다.
### 멱등성·동시성 — IdempotencyExecutor의 네 분기, 하나의 200ms 창
`IdempotencyExecutor`는 구현했지만 HTTP 엔드포인트에는 아직 연결하지 않았습니다.
`WorkLogController``Idempotency-Key` 헤더를 받지만 바인딩만 하고 실행기를 부르지는 않습니다.
따라서 이 절에서는 현재 배선 상태와 실행기 내부 동작을 나누어 설명합니다. 실행기는 같은 요청의 재시도,
같은 키를 다른 본문에 재사용한 경우, 거의 동시에 도착한 두 요청을 구분합니다. 동시 삽입에는 분산 락
대신 `uq_idempotency_scope` 유니크 제약을 사용하고 늦게 온 요청은 제한된 시간만 기다립니다.
책임은 세 경계로 나뉩니다.
| 경계 | 소유하는 결정 | 소유하지 않는 것 |
| ----------------------------------- | --------------------------------------------------------------------------------- | ------------------------ |
| `IdempotencyKeySupport` | 헤더·principal로 scope 구성, 요청 직렬화, fingerprint와 JSON 코덱 | 경쟁·대기 정책 |
| `IdempotencyExecutor` | `find`·`tryBegin`·replay·대기·완료 순서, 200ms 대기와 20ms 폴링, TTL 상한 | HTTP·JSON·DB 제약 구현 |
| `IdempotencyStorePort`/JPA 어댑터 | `tryBegin`·`find`·`complete`·`discard`의 원자 연산 | 재시도 횟수와 대기 시간 |
`scope``(tenant, principal, idempotencyKey, useCaseName)`이며 단일 테넌트 요청을 표현하기 위해
`tenant``null`을 허용합니다. PostgreSQL UNIQUE 제약은 nullable column의 중복을 허용할 수 있기
때문에 영속 매퍼는 `null`을 빈 문자열로 바꿔 동일 scope가 여러 번 저장되지 않게 합니다. 이 방식은 빈
문자열을 영속성 센티널로 예약한다는 비용이 있습니다. `fingerprint`는 SHA-256 64자리 소문자 16진수지만 전송된 원시
바이트의 해시는 아닙니다. 웹 경계가 역직렬화된 객체를 다시 직렬화한 바이트를 해시합니다. TTL은 기본값과
재정의 값 모두 72시간을 넘을 수 없습니다.
실행 결과는 네 가지로 나뉩니다.
| 종료점 | 조건 | 동작 실행 |
| --------------------------------- | ---------------------------------------------------------- | ----------------------: |
| 신규(new) | 살아 있는 레코드가 없고 `tryBegin`이 실행권 선점에 성공 | 1회 |
| 저장 응답 재사용(replay-hit) | 같은 `fingerprint``COMPLETED` 레코드 발견 | 0회, 저장 응답 역직렬화 |
| 실행 중(in-flight) | 같은 `fingerprint`가 진행 중이며 200ms 안에 완료되지 않음 | 0회, 409 |
| 지문 불일치(fingerprint-mismatch) | 같은 `scope`에 다른 `fingerprint` 존재 | 0회, 즉시 422 |
실행권 경쟁에서 진 경우와 기존 `IN_FLIGHT`를 읽은 경우는 같은 마감시각과 20ms 폴링을 씁니다. 기다리는
동안 승자가 완료하면 저장 응답 재사용으로 바뀌고 마감시각을 넘기면 409가 됩니다.
![execute 진입에서 find·tryBegin·fingerprint·status·두 대기 진입점과 하나의 200ms 마감시각을 거쳐 네 정상 결정으로 가고, new 실행 뒤 action·codec·complete 실패 시 discard 성공과 discard 자체 실패를 별도 경로로 나눈 흐름도.](../assets/idempotency-four-branches.svg)
*두 대기 진입점은 같은 200ms 마감시각으로 합쳐지고 본문 불일치는 즉시 종료됩니다. 실행권을 선점한
뒤 실패하면 discard 성공 여부에 따라 원래 예외를 다시 던질지, IN_FLIGHT를 지울지가 달라집니다.*
실행권 선점 이후의 실패 경계가 정확히 한 번(exactly-once) 실행 여부를 결정합니다. `runAndComplete()`
동작을 실행한 뒤 응답을 직렬화해 완료 상태로 저장합니다. 동작·코덱·`complete`가 런타임 예외를 던지면
catch 블록은 `store.discard(scope)`를 호출한 다음 원래 예외를 다시 던지려 합니다. **`discard`가 성공할
때만** 실행권이 지워지고 원래 예외가 그대로 전파됩니다.
`discard`에 예외 없음 계약이 없으므로 그 실패
창에서는 원래 예외가 `discard` 예외로 가려지고 `IN_FLIGHT`가 TTL까지 남을 수 있습니다. 반대로 동작의
외부 부작용은 성공했는데 코덱이나 `complete`가 실패하고 `discard`는 성공하면 재시도가 동작을 다시
실행할 수 있습니다. 어느 쪽이든 이 구현은 정확히 한 번 실행을 보장하지 않습니다.
동시에 들어온 요청 중 하나에만 실행권을 주는 핵심은 다음 짧은 어댑터 코드에 있습니다.
`saveAndFlush`가 제약 검사를 즉시 일으키고
동일 scope 유니크 충돌이면 `false`를 돌려 실행기의 대기 경로로 보냅니다.
```java
// IdempotencyStoreAdapter.java:74-77 (발췌) — DB 유니크 제약이 실제 경쟁 심판
try {
repository.saveAndFlush(claim); // flush forces the unique-constraint check now
return true;
} catch (DataIntegrityViolationException raceLost) {
// Another caller inserted between the lookup and the flush — they own it.
return false;
}
```
다만 catch는 constraint 이름이나 SQLState를 확인하지 않고 모든 `DataIntegrityViolationException`
경쟁 패배로 분류합니다. 다른 무결성 위반도 `false`로 오인돼 대기 뒤 409로 끝날 수 있습니다.
안전하게 운영하려면 목표 유니크 제약 위반만 경쟁 패배로 분류하고 나머지는 원래 오류로 전파해야 합니다.
제약명이나 SQLState를 직접 확인하면 벤더 결합이 늘 수 있으므로, 그 판별은 영속성 어댑터 안에
가두는 것이 경계와 오류 정확성 사이의 현실적인 절충입니다.
계약과 구현의 불일치도 하나 있습니다. `IdempotencyScope` javadoc은 principal을 "pseudonymized"라고
설명하지만 `IdempotencyKeySupport.currentPrincipal()``AuthenticatedPrincipal.idpUserId()`를 그대로
반환하고 가명화 포트를 호출하지 않습니다. 이 helper를 실제 엔드포인트에 배선하면 raw IdP 사용자 ID가
영속 키로 흘러갈 수 있습니다. 로깅 절의 가명화 보장은 MDC·응답 메타 경로에 한정되며 이 저장 경계까지
덮지 않습니다. 이 helper를 실제 엔드포인트에 배선한다면 principal에 가명화 포트를 먼저 적용해야 raw
사용자 ID가 영속 키로 저장되는 위험을 막을 수 있습니다.
HTTP 예외 매핑은 준비됐지만 실제 호출은 비어 있습니다. 409/422/400 전용 핸들러가 모두 존재하지만 어떤
실제 엔드포인트도 이 메커니즘을 호출하지 않습니다. `WorkLogController`의 두 POST는
`Idempotency-Key` 헤더를 바인딩만 하고 사용하지 않습니다. 이 메커니즘을 실제 쓰기 경로에 적용하려면
쓰기 유스케이스 호출을 실행기의 동작으로 감싸는 배선이 추가로 필요합니다.
`IdempotencyExecutorTest`의 9개 테스트 중 7개가 네 분기와 실패·만료 하위 경로를 덮습니다. 나머지 둘은
72시간 TTL 상한을 생성 시점과 호출 시점에서 검사합니다. 대기 테스트는 실제로 200ms를 재우지 않고
테스트 `Sleeper`가 가변 `Clock`을 앞당깁니다. 이 스위트는 실행기의 결정성을 증명하지만 엔드포인트
배선이나 동작의 외부 부작용까지 정확히 한 번 실행으로 만들지는 않습니다.
### Outbox·메시징 — 두 실패 경로와 SKIP LOCKED 기반 선점
멱등성 실행기는 엔드포인트에 연결하지 않았지만 outbox는 샘플 쓰기 경로에 실제로 연결했습니다.
`CreateWorkLogUseCase`에서는 DB 저장과 브로커 발행을 하나의 트랜잭션으로 묶지 않았습니다. 브로커
호출은 비즈니스 트랜잭션 밖에서 일어납니다. 아웃박스(outbox)는 먼저 발행할 이벤트 행을 도메인 변경과 같은 DB 트랜잭션에 저장합니다.
현재 샘플에서는 도메인 저장과 `OutboxAppendPort.append(...)`가 하나의 `tx.inWrite` 콜백 안에
있습니다. `OutboxAppendPort`는 자체 트랜잭션을 열지 않으므로 WorkLog 변경과 `PENDING` 행은 함께
커밋되거나 함께 롤백됩니다.
커밋 이후의 전달은 별도 계약입니다. `OutboxRelayScheduler`의 폴링 간격은 설정이 없으면 기본 5초입니다.
스케줄러가 호출하는 `PublishPendingOutboxEventsUseCase`는 짧은 쓰기 트랜잭션에서 배치를 선점하고
트랜잭션 밖에서 발행한 뒤 각 행의 결과를 다시 짧은 쓰기 트랜잭션으로 기록합니다.
| 시점 | 트랜잭션 경계 | 일어나는 일 |
| ---- | --------------------------------------------------------- | ------------------------------------------------------ |
| T0 | 비즈니스 `tx.inWrite` | 도메인 저장 + append,`PENDING` 커밋 |
| T1 | 짧은 릴레이 쓰기 트랜잭션 | 선점 가능한 행을 가져와 `IN_FLIGHT`로 전환 |
| T2 | 브로커 호출은 트랜잭션 밖, 상태 기록은 별도 쓰기 트랜잭션 | 발행 후 `PUBLISHED`, 실패 시 `FAILED` 또는 `DEAD` |
![위쪽의 tx.inWrite append에서 PENDING 쓰기로 가는 경로와 아래쪽의 기본 fixedDelay=PT5S 스케줄러가 claimBatch·재정렬·트랜잭션 밖 publish·성공·실패 처리로 이어지는 경로를 점선 폴링 간선으로 이은 흐름도.](../assets/outbox-two-paths.svg)
*기본 fixedDelay=PT5S(설정이 없을 때의 5초)는 폴링 주기를 뜻할 뿐 다음 선점의 최소 시간 경계를 보장하지 않습니다. append는
쓰기 트랜잭션 안이고 publish는 커밋 뒤 트랜잭션 밖입니다.*
`OutboxBackoffPolicy`는 30초 기반 지수 백오프와 최대 3회를 계산합니다. 지터를 0으로 둔 백오프 테스트의
1·2·3회차는 30/60/120초입니다. 상태 전이에서 `PUBLISHED``DEAD`는 종착 상태입니다. 보존기간이 지난
`PUBLISHED` 행은 별도 정리 경로에서 삭제될 수 있고 `DEAD`에는 자동 후속 전이가 없습니다. `PENDING`
`FAILED``next_attempt_at` 조건으로, 고아 `IN_FLIGHT`는 가시성 제한 시간 조건으로 다시 선점됩니다.
재시도는 같은 호출 스택에서 반복하지 않고 상태와 다음 시각을 저장한 뒤 다음 폴에 맡깁니다.
![PENDING에서 IN_FLIGHT로 간 뒤 PUBLISHED·FAILED·DEAD로 갈라지고, FAILED는 next_attempt_at 경과 후 재선점되며 IN_FLIGHT 가시성 제한 시간 만료도 자기 순환하는 상태기계. PUBLISHED는 보존기간 뒤 삭제될 수 있고 DEAD는 후속 전이가 없는 종착 상태다.](../assets/outbox-state-machine.svg)
*FAILED의 재선점과 IN_FLIGHT 가시성 회수는 서로 다른 순환 경로입니다. DEAD에는 자동 후속 전이가 없으며
수동 개입 전까지 그대로 남습니다.*
발행 뒤에는 두 실패 경로가 갈립니다.
| 실패 지점 | 상태 | 현재 폴링 회차의 제어 흐름 | 결과 위험 |
| ----------------------- | --------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------- |
| `publishPort.publish` | `markFailed` 또는 `markDead`를 별도 트랜잭션으로 기록 | 기록이 성공한 경우에만 다음 이벤트로 계속 | 최대 시도 뒤 성공 전달 0건 가능 |
| `store.markPublished` | `IN_FLIGHT`로 남음 | 저장 예외가 전파되어 현재 배치를 중단할 수 있음 | 가시성 제한 시간 뒤 재선점되어 중복 발행 가능 |
발행 실패 자체는 `handlePublishFailure`가 잡아 시도 횟수에 따라 `OUTBOX_DEAD_LETTER` 또는
`OUTBOX_PUBLISH_FAILED``ERROR`로 기록하며 상태 전이를 수행합니다. `markFailed`·`markDead` 저장이
실패하면 그 예외가 전파되므로 "브로커 실패를 삼키고 항상 다음 행으로 간다"고 설명하면 틀립니다.
발행은 성공했지만 `markPublished`가 실패한 경우에는 catch가 적용되지 않습니다. 행은 `IN_FLIGHT`
남고 이미 브로커가 받은 이벤트를 다시 보낼 수 있는 창이 여기서 생깁니다.
클레임의 경쟁·순서 정책은 다음 SQL 한 문장에 있습니다.
```sql
-- PostgreSqlOutboxClaimRepository.java:16-29 — CLAIM_SQL (FIFO 게이트 + FOR UPDATE SKIP LOCKED)
SELECT * FROM outbox_event o
WHERE o.next_attempt_at <= :now
AND o.status IN ('PENDING', 'FAILED', 'IN_FLIGHT')
AND NOT EXISTS (
SELECT 1 FROM outbox_event p
WHERE p.aggregate_id = o.aggregate_id
AND p.occurred_at < o.occurred_at
AND p.status <> 'PUBLISHED'
)
ORDER BY o.occurred_at ASC
LIMIT :limit
FOR UPDATE SKIP LOCKED
```
`NOT EXISTS`는 같은 애그리게이트에서 엄격히 더 이른 `occurred_at`의 미발행 행을 게이트로 삼습니다.
더 이른 선두 행이 `DEAD`면 수동 개입 전까지 후속 행이 막힙니다. 같은 타임스탬프의 두 행에는 동률
정렬 키(tie-breaker)가 없어 결정적 FIFO를 보장하지 않습니다. `FOR UPDATE SKIP LOCKED`는 여러
릴레이가 같은 행을 동시에 클레임하지 못하게 하므로 별도 리더 락을 쓰지 않습니다. 이 동시 선점 방지 동작은
다중 인스턴스 테스트로 검증됩니다(두 Spring 컨텍스트가 1000건을 발행할 때 중복 선점 0건). 이는 선점
중복 방지의 증거이지 `markPublished` 실패 이후의 재발행까지 없다는 증거는 아닙니다.
테스트는 상태, DB 게이트, 다중 인스턴스 경쟁을 서로 다른 층에서 맡습니다.
| 테스트 파일 | 층위 | 개수 | 무엇을 검사하나 |
| --------------------------------------------- | ------------------ | --------- | --------------------------------------------- |
| `PublishPendingOutboxEventsUseCaseTest` | 단위 | 9 | 두 실패 경로와 PUBLISHED/FAILED/DEAD 결과 |
| `OutboxBackoffPolicyTest` | 단위 | 9 | 최대 3회, 30/60/120초와 지터 범위 |
| `OutboxRowLifecycleContractTest` | 실 PostgreSQL | 9 | 선두 레코드 게이트, PUBLISHED 해제, 고아 회수 |
| `OutboxPublisherLeaderElectionContractTest` | 두 Spring 컨텍스트 | 1 | SKIP LOCKED의 중복 선점 방지 |
| `EventPayloadPiiContractTest` | ArchUnit | red/green | 이벤트 필드명의 민감어 패턴만 검사 |
보장 범위는 여기까지입니다. 도메인 변경과 append는 한 트랜잭션이고 동시 릴레이의 선점 중복은 DB가
막습니다. 하지만 제한된 재시도가 모두 실패하면 브로커 전달은 0건일 수 있고 상태 기록이 실패하면 중복
발행할 수 있습니다. 소비자 중복 제거에는 같은 `idempotencyKey`를 건너뛰는 인메모리 fake 계약이 있지만 그
테스트는 영속 저장소·TTL·분산 일관성을 명시적으로 범위 밖에 둡니다. `DEAD` 행에는 수동 처분 runbook이
있어 원인 해소 뒤 `PENDING`으로 되돌리거나 승인 후 `PUBLISHED`로 표시합니다. 운영자가 수행하는
절차이지 자동화된 복구 전이가 아닙니다. 프로덕션 소비자의 영속 중복 제거와 수동 처분의 운영 준비도를
별도로 확인하기 전에는 자동 전달 완료나 결정적 전체 순서를 약속할 수 없습니다.
이 횡단 계약들은 구현과 테스트의 존재만으로 완성됐다고 보지 않았습니다. 실제 요청 경로에 연결하지
않은 계약도 있기 때문입니다. 현재 배선 상태를 함께 놓으면 다음과 같습니다. 트랜잭션 절에서 함께
다룬 `TransactionPort``DistributedLockPort`는 배선 상태가 서로 달라 행을 나눴습니다.
| 횡단 계약 | 구현·테스트 | 현재 배선 상태 |
| --------------- | ------------------------------------- | --------------------------------------------------------------------- |
| 검증 | 샘플 3계층 검증 있음 | Feed 웹 경계 검증 공백, 어댑터 폴백만 존재 |
| 예외·오류 응답 | 두 단계 핸들러 체인·안전 메시지 있음 | 도메인 핸들러 체인은 샘플 실행점에만, 락 타임아웃 전용 웹 핸들러 없음 |
| 로깅·추적 | 필터·가명화·전파 데코레이터 있음 | 요청 경로 배선됨, executor 우회 시 UNKNOWN 폴백 |
| 트랜잭션 | 3모드 포트·구현·테스트 있음 | Feed 읽기 경로 배선됨 |
| 분산 락 | 계약·어댑터·테스트 있음 | 프로덕션 애플리케이션/유스케이스 호출자 0 |
| 멱등성 | 실행기·저장 어댑터·핸들러 있음 | 엔드포인트 배선 0, 헤더 바인딩만 존재 |
| Outbox | append·릴레이·상태기계·테스트 있음 | 샘플 쓰기 경로 배선됨, 소비자 영속 중복 제거는 범위 밖 |
## 초록색 테스트가 규칙이 살아 있다는 뜻은 아니다
`ca-tmpl`의 경계는 설명만으로 끝내지 않고 테스트와 빌드로 검증하도록 만들었습니다. 테스트가 어디까지
실제 코드를 실행하는지, ArchUnit 규칙이 빈 검사로 통과하지 않는지(test-the-test), 위반 코드를 넣으면
예상한 게이트에서 멈추는지(break-it)를 각각 나눴습니다. 빌드 밖의 아티팩트와 런타임 계약도 저장소
정책으로 관리합니다.
### 테스트 경계 네 층 — 무엇을 실행하고 무엇을 대체하는가
테스트 이름만으로 범위를 정하지 않았습니다. `unit`, `contract`, `integration`이라는 이름보다 실제로
실행하는 코드와 바꿔 끼운 협력자를 기준으로 삼았습니다. 현재 테스트 트리는 실행 비용과 대체 범위에
따라 다음 네 층으로 나뉩니다.
| 층 | 무엇을 검증 | 진짜(real) | 가짜(substituted) | 도구 |
| ----------- | ----------------------------------- | ----------------------------- | ------------------------------------ | ------------------------------------ |
| 도메인 단위 | aggregate 규칙·상태기계 | 도메인 POJO 전부 | 없음 | JUnit + AssertJ |
| 유스케이스 | 유스케이스 로직 | 유스케이스 + 도메인 | 아웃바운드 포트 = 손으로 만든 페이크 | JUnit (Mockito 0) |
| 어댑터 | 컨트롤러·매핑·DB 왕복 | 어댑터 본체 | 외부 협력자 또는 진짜 인프라 | @WebMvcTest / Testcontainers |
| 통합 | outbox relay·분산 락 provider 계약 | Postgres·Flyway·어댑터 배선 | publisher stub·고정 Clock | Testcontainers + 최소 Spring context |
안쪽 경계의 효과는 수치로도 확인됩니다. `sample-portfolio`의 도메인·애플리케이션 테스트 소스셋에는 단위
테스트 클래스 15개(테스트 메서드 71개)가 있고 그중 어느 하나도 `org.springframework`·Mockito·
Testcontainers를 import하지 않습니다.
유스케이스 층에서 레이어드 구조와의 차이가 가장 분명하게 드러납니다. 유스케이스가 도메인 소유 `WorkLogRepository`
인터페이스에 의존하므로 테스트는 이를 `ArrayList` 기반 인메모리 페이크로 바꾸고 유스케이스를 그냥
`new` 해서 돌립니다.
```java
// 스프링 컨텍스트도 Mockito도 없다. 진짜로 동작하는 페이크를 손으로 만든다.
static class FakeRepo implements WorkLogRepository {
final List<WorkLog> store = new ArrayList<>();
public WorkLog save(WorkLog w) { store.removeIf(x -> x.id().equals(w.id())); store.add(w); return w; }
public Optional<WorkLog> findById(WorkLogId id) {... } // 진짜 조회·페이징
}
static final TransactionPort TX = new TransactionPort() {
public <T> T inWrite(Supplier<T> a) { return a.get(); } // 그냥 실행
public <T> T inRead (Supplier<T> a) { return a.get(); }
public <T> T inNew (Supplier<T> a) { return a.get(); }
};
WorkLog created = new CreateWorkLogUseCase(repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, UTC_CLOCK, TX).handle(cmd);
```
포트가 **도메인이 소유한 인터페이스**라 이런 테스트가 가능합니다. 페이크는 목(mock)이 아니라 `store`에 진짜로
넣고 빼는 작은 구현이고 이 층 전체에서 Mockito는 한 번도 안 씁니다. 레이어드의 전형적인 단일
모듈 Spring 구현이었다면 서비스가 Spring Data 타입과 트랜잭션 프록시에 결합되기 쉬워 테스트하려면
컨텍스트를 띄우거나 프레임워크 타입을 목킹해야 합니다. 차이가 드러나는 지점은 협력자의 타입입니다. 이
대조는 결합도 차이를 설명하기 위한 것이며 저장소의 대칭 측정 결과가 아닙니다.
![왼쪽은 프레임워크 협력자를 직접 대체하는 레이어드 테스트의 설명용 예시, 오른쪽은 익명 TransactionPort 테스트 더블을 사용하는 포트 유스케이스 테스트를 대비한 두 패널.](../assets/test-contrast.svg)
*오른쪽은 코어 소유 포트를 익명 테스트 더블로 대체하는 실제 패턴이고 왼쪽은 결합도 차이를 설명하기
위한 대조입니다. 두 패널을 저장소의 대칭 측정 결과로 읽지 않습니다.*
도메인 층에는 주목할 테스트가 하나 더 있습니다. `WorkLogTest`가 상태기계(OPEN→IN_PROGRESS→CLOSED)를
확인하는 데 더해 `WorkLogInvariantTest`는 도메인 예외가 운영용 `ApiErrorCode` 계약을 구현하지
않는다는 것까지 단언합니다. 운영 코드 분리가 테스트로 고정돼 있습니다. 어댑터·통합 층의 대표
테스트는 `PosterControllerWireTest`(@WebMvcTest 슬라이스, 유스케이스만 목킹)와
`FeedPersistenceIT`(@DataJpaTest + Testcontainers `postgres:16-alpine`, Docker 없으면 스킵)입니다. 아웃박스
통합 테스트는 전체 앱을 부팅하지 않고 Flyway를 적용한 최소 `AnnotationConfigApplicationContext`
씁니다. 이 예시가 전체 테스트 트리를 빠짐없이 열거하는 것은 아닙니다.
taxonomy 자체도 일부는 규칙으로 강제됩니다. `TestTaxonomyArchitectureTest`는 contract·architecture
패키지의 Testcontainers 의존을 금지하고 한 클래스의 `@WebMvcTest`·`@DataJpaTest` 혼용을 거부합니다.
프로덕션 코드의 `..fixtures..` 의존은 별도 규칙으로 확인합니다. 실 서비스가 필요한 테스트를 integration
패키지로 보내는 규율은 이 제한된 게이트와 디렉터리 관례가 함께 만듭니다.
![sample-portfolio의 domain/application 테스트 예시와 TestTaxonomyArchitectureTest가 강제하는 Testcontainers 금지·슬라이스 혼용 금지·fixture 누출 금지 규칙을 나란히 구분한 그림.](../assets/test-taxonomy-layers.svg)
*테스트 파일이 현재 어디에 놓였는지와 아키텍처 규칙이 실제로 강제하는 범위는 구분해야 합니다.*
### 규칙을 테스트하는 테스트 — test-the-test
ArchUnit 테스트가 모두 초록색이어도 규칙이 살아 있다는 뜻은 아닙니다. glob 하나를 잘못 쓰면 검사
대상이 0개가 되고 아무것도 검사하지 않은 채 통과할 수 있기 때문입니다(vacuous pass). 그래서 저는
`architecture/violations/`에 규칙을 일부러 어기는 픽스처를 넣었습니다. 이곳에는
`package-info.java`를 제외한 Java 소스 48개가 있고, 규칙을 일부러 어기는 타입과 평가를 돕는 지원
타입이 함께 들어 있습니다. `ca-tmpl`은 이 코퍼스를 검사했을 때 실제 위반이 나오는지를 별도 테스트로
확인합니다.
> **세는 기준.** 해당 트리는 `.java` 54개이며 그중 `package-info.java` 6개를 빼면 48개입니다.
> `FixtureRepository` 같은 지원 타입도 포함되므로 48을 규칙 수나 독립 위반 수로 해석하지 않습니다.
```java
// ca-tmpl · 위반 픽스처: 읽기 전용(READ_REPOSITORY)으로 선언해 놓고 repository.save()를 부른다
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class ReadOnlyRepositoryWriteUseCase
implements CommandUseCase<ReadOnlyRepositoryWriteUseCase.DummyCommand, Void> {
public Void handle(DummyCommand input) {
repository.save(new Object()); // 위반: 읽기 전용이 쓰기 메서드를 호출
return null;
}
}
// ca-tmpl · ArchitectureViolationFixtureTest — "이 픽스처를 규칙에 통과시키면 정말 위반으로 걸리는가"
private static final JavaClasses READ_ONLY_REPOSITORY_WRITE_FIXTURE_ONLY =
new ClassFileImporter().importClasses(
ReadOnlyRepositoryWriteUseCase.class, FixtureRepository.class);
@Test
void readOnlyUseCasesDoNotCallRepositoryWriteMethodsCatchesReadToWriteUpgrade() {
EvaluationResult result =
CleanArchitectureTest.READ_ONLY_USE_CASES_DO_NOT_CALL_REPOSITORY_WRITE_METHODS
.evaluate(READ_ONLY_REPOSITORY_WRITE_FIXTURE_ONLY);
assertThat(result.hasViolation()).isTrue(); // 규칙이 살아 있다는 증거
}
```
위반 픽스처들은 프로덕션 스캔에서 격리됩니다. `ProductionClassImportOption`이 규칙이 도는 대상에서
위반 코드를 빼기 때문에, 픽스처가 진짜 빌드를 깨뜨리지 않으면서 "규칙이 살아 있다"만 증명합니다. 이
클래스는 스톡 `ImportOption.DoNotIncludeTests`를 감싸 `/sampleOffTest/`까지 함께 제외합니다.
위반 코드를 프로덕션 소스에 두면 전체 빌드가 항상 실패하므로 별도 테스트 소스셋이 필요합니다. 그 대신
사용자 정의 import option을 유지하고 새 테스트 소스셋이 생길 때 제외 범위를 함께 갱신해야 합니다.
이 픽스처 테스트가 다루는 규칙은 실제 위반을 잡는다는 점까지 확인됐습니다. 그러나 63개 `@ArchTest`
전부에 각각 대응하는 비공허성 테스트가 있다는 뜻은 아닙니다.
커버되지 않은 규칙은 여전히 import 범위와 대상 수를 별도로 확인해야 합니다.
### 깨뜨리면 어디서 멈추나 — break-it
위반을 어디에 넣느냐에 따라 먼저 실패하는 검사도 달라집니다. 저는 같은 종류의 위반을 프로덕션 코어,
어댑터 빌드 파일, 테스트 픽스처에 각각 둘 수 있도록 break-it 사례를 나눴습니다. 위치별로 멈추는 곳은
다음과 같습니다.
**① web이 아웃바운드(persistence)를 직접 의존 — 게이트 ②가 잡습니다.**
```text
위반 implementation project(':adapter:outbound:persistence-jpa') ← adapter/inbound/web/build.gradle
게이트 ② Gradle 모듈 화이트리스트 — web은 아웃바운드·형제를 의존할 수 없다
(그 의존으로 web 코드가 outbound 타입까지 참조하면 ③ ArchUnit 규칙도 별도로 위반으로 판정)
판정 verifyCleanArchitectureDependencies가 화이트리스트 밖 의존을 발견 → GradleException으로 check 실패
```
**② 도메인에 `@Component`(Spring 의존) — 게이트 ①이 먼저 잡습니다.**
```text
위반 @Component class FeedItem { … } ← domain-core (순수 POJO여야 함)
게이트 ① 컴파일 격리 — org.springframework 타입이 domain-core 클래스패스에 아예 없다
(Spring이 닿는 자리에 넣었다면 ③ ArchUnit DOMAIN_IS_PURE)
판정 코어 소스면 javac 실패(타입 부재), 픽스처 위치면 ArchUnit 테스트 실패
```
**③ 애플리케이션에 `@Transactional` — 게이트 ①이 먼저 잡습니다.**
```text
위반 @Transactional public Foo handle(...) { … } ← application-core
게이트 ① 컴파일 격리 — spring-tx가 application-core main compileClasspath에 없다
(testCompileOnly로 spring-tx가 복원된 자리면 ③ ArchUnit 규칙)
판정 코어 소스면 javac 실패(spring-tx 부재), 복원된 자리면 ArchUnit 테스트 실패
```
**`Class.forName(문자열)` 리플렉션 우회 — 아무 게이트도 못 잡습니다.**
```text
위반 Class.forName("org.springframework.context.ApplicationContext")
게이트 없음 — 문자열 키는 바이트코드에 타입 의존을 남기지 않는다
판정 이를 검출하는 정적 규칙 없음 → 정적 게이트에서 차단되지 않음
```
![큰 원(실제 경계 위반 전체) 안에 작은 원(정적 분석이 보는 영역)이 포함되고, 잡는 항목과 못 잡는 항목이 각각 나열된 벤 다이어그램.](../assets/static-analysis-venn.svg)
*정적 분석이 잡는 것은 실제 경계 위반 전체의 부분집합입니다. 작은 원 밖의 리플렉션·문자열 조회는 코드
리뷰와 런타임 검증이 맡아야 할 사각지대입니다.*
②·③에서 보이듯, 어느 겹이 잡는지는 위반의 주입 위치가 정합니다. 도메인·애플리케이션 모듈엔 금지
타입 자체가 클래스패스에 없어서 프로덕션 소스에 넣으면 컴파일 격리(게이트 ①)가 ArchUnit(게이트 ③)
보다 먼저 실패합니다. `javac`에서 막히므로 ArchUnit은 실행조차 안 됩니다.
> **직접 확인하는 방법.** 깨끗한 작업 트리나 일회용 브랜치에서 각 위반을 해당 위치에 한 줄씩 넣고
> `cd src && ./gradlew check`를 실행합니다. 예상한 게이트에서 실패하는지 확인한 뒤 변경을 되돌립니다.
> 실행 환경은 Gradle 9.0.0, Java 21, Spring Boot 4.0.0입니다.
### 빌드 너머의 강제 — CI·공급망·컨테이너
세 겹 게이트의 범위는 빌드 안의 소스 경계까지입니다. 코드가 이미지가 된 뒤에는 다른 검사가
필요합니다. 그래서 `.github/workflows/`, 공급망 정책 파일, `docker-compose.yml`에 아티팩트의
출처와 컨테이너 런타임 조건을 별도 저장소 계약으로 넣었습니다.
**CI와 공급망 계약.** `.github/workflows/`에는 `ci-quality-gates`, `dependency-vulnerability`,
`build-release-supply-chain`, `supply-chain-retention-audit`, `link-check` 다섯 워크플로가 있고 CI는
`./gradlew check`를 실행합니다. 릴리스 경로는 다음 검사를 조합합니다.
- **재현 가능 빌드:** `verify-reproducible-build.sh`로 같은 입력의 산출물이 재현되는지 검사합니다.
- **SBOM과 키리스 서명:** SPDX SBOM(소프트웨어 구성 명세서)을 만들고 Cosign으로 이미지 서명과 SBOM
attestation(산출물에 대한 서명된 증명)을 남깁니다.
- **SLSA 프로버넌스(빌드 출처 증명):** `generator_container_slsa3@v2.1.0`을 호출해 출처를 만들고 별도 verify job에서
서명자와 소스·태그·빌더 정보를 재검사합니다.
`supply-chain-policy.json`은 이미지 식별을 immutable digest로, 서명을 Cosign keyless(장기 서명 키 없이 워크플로 신원으로 서명)로, 프로버넌스를
SLSA v1로 고정하고 롤백 보존 기준 `minimumReleaseCount: 10`·`minimumAgeDays: 90`을 명시합니다. 이 값은
기계 판독 가능한 정책 파일에 들어 있습니다. 다만 CI가 이 두 키를 읽어 실제 보존 상태를 판정하는 호출
경로는 없습니다. 의존성 갱신은 `renovate.json`이 보안
업데이트만 열고 patch·pin·digest에만 `automerge: true`를 설정하며 실제 병합은 저장소의 상태 검사와
브랜치 보호 설정에도 좌우됩니다. `.trivyignore.yaml`의 억제 항목은 사유와 만료일을 가져야 하며
`verifyTrivyignore`가 빌드에서 검사합니다.
**컨테이너 런타임 계약.** 기본 `docker-compose.yml`에는 다음 운영 조건이 명시돼 있습니다.
- `read_only: true` 루트 파일시스템과 `/tmp`·`/var/tmp/heap` tmpfs
- `mem_limit: 512m``-XX:MaxRAMPercentage=75`가 계산할 메모리 상한
- `stop_grace_period: 40s` — 앱 드레인 30초, preStop 5초, 안전 여유 5초의 합
- 관리 포트 9001의 actuator readiness probe 헬스체크
이 장치가 코드의 정확성을 증명하는 것은 아닙니다. 서명·프로버넌스의 신뢰는 CI 실행 환경과 OIDC
발급자(워크플로 신원 토큰 발급자)까지 이어지며 그 신뢰 뿌리가 침해되면 정상 절차처럼 보이는 잘못된
산출물이 만들어질 수 있습니다. 키리스 서명은 키 관리 부담을 발급자와 워크플로 신원에 대한 의존으로 옮길 뿐 없애지 않습니다. 빌드
안에서는 소스 의존을, 릴리스 파이프라인에서는 산출물의 출처를, compose에서는 프로세스의 런타임 제약을
각각 별도 계약으로 관리합니다. 세 계약은 서로 보완하지만 어느 하나도 나머지 둘을 대신하지 않습니다.
## 다섯 결정의 반대편과 강제의 한계
`ca-tmpl`을 만들면서 같은 목표를 더 적은 비용으로 달성할 대안도 함께 비교했습니다. 장기 재사용
템플릿에서는 유용한 장치도 작은 서비스에서는 유지비만 늘릴 수 있기 때문입니다. 외부 사례는 참고하되
제가 택한 다섯 결정과 반대 선택이 더 나은 조건을 따로 설명합니다.
### 참고한 외부 사례 — 무엇을 어디까지 쓰는가
외부 사례는 `ca-tmpl`의 선택 이유를 대신하는 근거로 사용하지 않았습니다. 같은 기술을 썼다고 해서
같은 제약과 이유로 선택한 것은 아니기 때문입니다. 외부 글은 선택 비용을 비교하는 대조군으로만
사용하고, `ca-tmpl`의 선택 이유는 제가 구현한 코드·Gradle 선언·테스트에서 설명할 수 있는 범위로
남겼습니다.
| 사례 | 비교할 특성 | `ca-tmpl` 판단에 쓰는 범위 |
| --------------------- | --------------------------------------------------------- | -------------------------------------------------------------- |
| 우아한형제들 | 레이어 단위 멀티모듈에서 output port가 늘어나는 비용 | 기능 우선 패키지를 택할 때의 반대 사례 |
| 카카오뱅크 | 멀티모듈·헥사고날·Spring Modulith의 결합 | 현재 미채택 상태를 확인하고 별도 평가 대상으로 분리 |
| Netflix Tudum | Kafka 기반 CQRS에서 Raw Hollow 기반 CQRS로 구현 교체 | CQRS 하부 구현도 운영 조건에 따라 바뀔 수 있다는 사례로만 참고 |
| Sahibinden | package-by-feature의 응집·캡슐화·모듈성 | 기능 우선 패키지의 장점 비교 |
| arawn | 외형 복제보다 높은 응집과 느슨한 결합을 우선 | 패키지 선택의 판단 원칙으로 참고 |
| Allegro | 안쪽을 향하는 계층 의존과 추가 빌드·학습 비용 | 구조를 복제하지 않고 비용 대조에 사용 |
| Buckpal·reflectoring | 작은 헥사고날 웹 앱의 Input/Output Port 구성 | 포트 배치의 외부 대조로 참고 |
| Arho Huttunen | 도메인/JPA 모델 분리와 매핑 비용, 코어 밖 트랜잭션 선택지 | 모델 분리와 트랜잭션 경계의 비용 대조로 참고 |
Tudum 사례는 CQRS를 버린 사례가 아닙니다. Kafka에서 Raw Hollow로 구현 메커니즘을 바꿨으므로
`ca-tmpl`의 CQRS-lite 선택을 직접 입증하는 자료로 쓰지 않습니다.
### 다섯 설계 결정과 그 반대편
제가 `ca-tmpl`에서 택한 선택과 더 단순한 대안을 나란히 놓으면 다음과 같습니다. `ca-tmpl`은 hybrid
패키지, 포트 기반 트랜잭션, 멀티모듈과 ArchUnit, CQRS-lite, 순수 POJO 도메인을 택했습니다. 아래 표에는
선택의 장점만 적지 않고 반대편이 더 나은 조건도 함께 남겼습니다.
| 결정 | 선택 | 선택으로 얻는 것 | 반대편이 나은 조건 |
| ---------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| ① 패키지 배치 | 계층 소유 코어·어댑터와 수직 샘플을 함께 쓰는 hybrid | 프로덕션 경계는 계층별로 통제하고, 샘플은 한 기능의 종단 구성을 보여 줍니다. | 한 가지 축만으로 충분한 작은 서비스 |
| ② 트랜잭션 경계 | `@Transactional` 대신 코어 소유 포트 | Spring TX를 애플리케이션 클래스패스에서 빼고 트랜잭션 의도를 테스트 가능한 계약으로 만듭니다. | 단일 DB를 쓰며 간접 호출 비용이 더 큰 작은 팀 |
| ③ 모듈화 | 멀티모듈 + ArchUnit | 금지된 타입은 컴파일에서, 허용 범위 안의 패키지 위반은 테스트에서 잡습니다. | 수명이 짧아 모듈·정책 유지비를 회수하기 어려운 서비스 |
| ④ CQRS | full이 아닌 lite | 읽기·쓰기 코드와 모델은 분리하되 별도 저장소의 복제·복구 비용은 도입하지 않습니다. | 읽기·쓰기 부하가 명확히 비대칭이고 동기화 비용을 감당할 수 있는 시스템 |
| ⑤ 도메인 순수성 | Lombok·JPA 없는 순수 POJO | 도메인 규칙을 ORM 생명주기와 프레임워크 타입에서 분리합니다. | 매핑 비용이 격리 효과보다 큰 단순 CRUD 서비스 |
**① 패키지 배치.** `ca-tmpl`의 실제 패키지는 한쪽으로만 정렬되지 않습니다. 프로덕션 코어·어댑터
모듈은 계층이 소유하지만 `sample-portfolio`는 수직 참조 슬라이스이고 도메인은 `feed`처럼 기능
중심이며 애플리케이션은 기술 패키지와 기능 패키지를 함께 둡니다. 이 배치는 프로덕션의 허용 의존을
계층별로 통제하면서도 샘플에서는 기능 하나의 종단 구성을 한곳에 보여 줍니다. 기능이 2~3개로 고정된
작은 서비스라면 두 축을 병행하는 것 자체가 오버엔지니어링입니다. 그 조건에서는 한 가지 패키지 축이 더
짧습니다.
![패키지 배치에서 layer-first와 feature-first 사이에 ca-tmpl의 계층 소유 코어·어댑터, 수직 샘플, 기능·기술 혼합 배치를 놓은 네 노드 그림.](../assets/decision-spectrum-1.svg)
*이 그림은 `ca-tmpl`의 hybrid 배치를 보여 주며 모든 프로젝트에 적용할 정답을 뜻하지 않습니다.*
**② 트랜잭션 경계.** 서비스에 `@Transactional`을 직접 붙이면 애플리케이션 모듈에 `spring-tx`
애노테이션 결합이 추가됩니다. 바깥 데코레이터는 그 결합을 유스케이스에서 치우는 대신 위임 메서드를
반복합니다. `ca-tmpl`은 세 번째 위치(유스케이스가 코어 소유 포트로 트랜잭션 의도를 선언하는 방식)를
택했습니다. 이 선택 덕분에 Spring 프록시 없이 유스케이스를 단위 테스트할 수 있고 허용할
정책을 계약으로 제한할 수 있습니다. 대신 콜백이 읽기 흐름을 끊고 `timeout`·`isolation`을 추가할 때는
포트 자체를 확장해야 합니다. 단일 DB를 쓰는 작은 팀이라면 직접 `@Transactional`이 이 간접 비용보다
나을 수 있습니다.
*데코레이터는 반복되는 위임 코드와 배선 비용을, 포트는 콜백 가독성과 확장 비용을 냅니다. 어느 쪽도
application-core 전체를 framework-free로 만들지 않습니다.*
**③ 모듈화.** 컴파일 클래스패스는 모듈마다 갈리므로 멀티모듈이어야 "금지된 타입이 이 모듈에는
없다"가 성립합니다. 단일 모듈이나 느슨한 멀티모듈은 패키지 규칙과 리뷰가 주 방어선으로 남습니다. Spring
Modulith까지 더하면 논리적 package-module 경계를 테스트로 검사할 수 있습니다. 후자의 실제 대조군이
카카오뱅크 사례입니다. 현재 Gradle 스크립트에는 Spring Modulith 의존 선언이 없으므로 Modulith 기반
강제가 있다고 말할 수 없습니다. 오래 유지하지 않을 1회성 서비스라면 모듈·정책 유지
비용을 회수하기 어려워 약한 강제로도 충분합니다.
![현재 Gradle 빌드에 Spring Modulith 의존이 없으며, 도입 여부는 별도 결정으로 남아 있음을 보여 주는 그림.](../assets/decision-spectrum-3.svg)
*Spring Modulith를 도입할지는 현재 경계 게이트로 부족한 부분과 추가 유지비를 비교해 별도로 결정해야
합니다.*
**④ CQRS.** CQRS 자체가 물리적 저장소 분리를 필수로 요구하지는 않습니다. 이 글에서 full 쪽 대안으로
비교하는 것은 읽기·쓰기 저장소까지 분리해 복제·동기화를 운영하는 구성입니다. `WorkLogSummary`
javadoc은 현재 조회 우회 모델을 CQRS-lite라고 부르고 명령과 조회의 코드·모델을 논리적으로 나눕니다.
이 구성을 선택할 실용적 이유는 읽기 모델을 도메인 재구성에서 분리하면서도 별도 저장소의
복제·동기화·복구 비용은 도입하지 않는 데 있습니다. 읽기·쓰기 부하와 지연 요구가 실제로 갈리고 그 운영
비용을 감당할 수 있을 때 full CQRS를 별도로 평가할 수 있습니다.
**⑤ 도메인 순수성.** 도메인 애그리게이트에 `@Entity`를 붙이면 코어가 Hibernate를 알게 됩니다.
`ca-tmpl`은 순수 POJO와 별도 JPA 타입을 두고 어댑터가 매핑하는 쪽을 택했고 `DOMAIN_IS_PURE`가 이
선택을 지킵니다. 이 구조가 유효한 이유는 업무 불변식을 ORM의 애노테이션·생명주기·지연 로딩에서
분리하기 때문입니다. 반면 같은 항목은 JPA 엔티티, Application 타입, Interface Adapter 타입 세 벌로
갈리고 매핑은 어댑터가 부담합니다. "도메인=엔티티" 결합을 받아들이고 세 모델을 유지할 여력이 없는
팀이라면 직접 매핑을 줄이는 편이 더 실용적일 수 있습니다.
### 도메인 예외 — 운영 코드와 Reason의 분리
도메인 예외에는 운영 오류 코드를 직접 넣지 않고 안전한 명사 enum `Reason`만 남겼습니다. 다만 이
구조만으로 운영 코드의 역류를 완전히 막지는 못합니다. 실제로 구현한 패턴도 `sample-portfolio`
WorkLog·Poster 예외에 한정됩니다. 두 샘플 도메인은 `Reason`을 소유하고, 샘플 웹 계층의
`DomainExceptionHandler`가 이를 `ApiErrorCode`로 바꿉니다. 변환 코드는 늘지만 도메인 예외가 HTTP
상태와 운영 에러 코드를 직접 알지 않아도 됩니다.
```java
// ca-tmpl · sample-portfolio/domain/worklog/WorkLogInvariantException.java
enum Reason { TITLE_BLANK, INVALID_STATUS_TRANSITION, CLOSED_WORKLOG_MUTATION }
```
이 방식은 도메인이 운영 코드를 모르게 하는 대신 사유와 API 오류 코드의 매핑 누락 가능성을 만듭니다. 실제 구현도
완전히 균일하지 않습니다. `WorkLog` 핸들러는 여러 사유를 `WORKLOG_CONFLICT` 하나로 접고 `Poster`
핸들러만 사유별 `switch`를 씁니다. 구조적 한계도 있습니다. Gradle 정책은
`domain-core → shared-contract`를 허용하고 `DOMAIN_IS_PURE`의 금지 목록에도 `..shared..`는 없습니다.
이 규칙 하나로 운영 코드의 역류를 차단한다고 말할 수 없고 매핑의 완전성도 자동 보장되지 않습니다.
### 같은 의존 규칙, 다른 강제 수준
저는 검사를 많이 붙이는 것 자체를 목표로 삼지 않았습니다. 장치를 줄이면 초기 구성과 변경은
가벼워지지만 위반 발견이 리뷰나 런타임까지 늦어집니다. 반대로
클래스패스·빌드 정책·규칙 테스트를 늘리면 위반은 일찍 멈추지만 모듈 선언, 페이크, 매핑, 정책
파일을 계속 관리해야 했습니다. 의존을 코어 쪽으로 향하게 하는 원리는 같아도 어디까지 자동으로 막을지는
프로젝트마다 달라집니다.
`ca-tmpl`은 main 프로젝트 의존 그래프, `implementation` 의존, 세 겹 게이트, `sampleOffTest`,
`shared-contract`, 레지스트리와 런북을 함께 유지하는 쪽을 택했습니다. 재사용 기간이 짧고 변경 주체가
적다면 같은 장치가 순비용이 될 수 있습니다. 따라서 "클린인가"라는 이름보다 위반을 얼마나 일찍
잡아야 하는지, 그 대신 모듈·매핑·정책 파일을 계속 관리할 수 있는지를 먼저 판단해야 합니다.
### 못 잡는 것 — 강제 범위의 한계
게이트를 설계하면서 정적 검사가 보지 못하는 영역도 함께 적었습니다. 빌드가 실패하지 않는다고 해서
경계와 운영이 모두 안전한 것은 아니기 때문입니다.
- 런타임 우회는 못 잡습니다. `Class.forName(문자열)`이나 `getBean(문자열)` 같은 문자열 키 조회는
바이트코드에 타입 의존이 남지 않아 정적 분석이 통과시킵니다. 이 영역은 코드 리뷰·런타임 검증으로
보완할 수밖에 없습니다.
- 잘못된 도메인 모델은 깨끗하게 분리해도 여전히 잘못된 모델입니다. 경계가 깔끔하다고 모델까지
옳아지지는 않습니다. 아키텍처는 나쁜 설계를 좋은 설계로 바꿔 주지 않습니다.
- 너무 많은 포트는 의미 없는 위임·매핑 코드를 만듭니다. 교체 가능성이 실제로 필요 없는 곳에 포트를
두면 남는 건 보일러플레이트뿐입니다.
- 모듈 경계를 잘못 그으면 되돌리는 비용이 큽니다. 19개 Gradle 모듈은 구조로 일찍 확정됩니다. 잘못
나눈 경계를 재분할·병합하려면 `build.gradle` 수정, 화이트리스트 갱신, ArchUnit 규칙 수정, 참조하는
쪽의 의존 선언까지 연쇄로 바뀝니다. 경계를 일찍 강제할수록 그 경계 자체를 바꿀 때 수정할 곳도
늘어납니다. 이는 정량 측정값이 아니라 변경 범위에 대한 설명입니다.
- 클린 아키텍처가 운영 준비성을 주진 않습니다. 경계가 깨끗해도 실패 분류·로깅·추적이 없으면 운영은
비어 있습니다. 그래서 `ca-tmpl`은 아키텍처 위에 별도의 운영 계약을 둡니다. 여기에는
`shared-contract``Envelope`·`ApiErrorCode`, 레지스트리 YAML 7개, 런북 45개(실패 모드별 44개 +
템플릿 1개)가 포함됩니다.
이 문서·정책 자산은 그만큼의 유지비를 요구합니다.
- 공급망 계약도 신뢰 뿌리까지만 강합니다. 서명·프로버넌스는 누가 무엇을 어떤 절차로 빌드했는지를
검증하지만 CI 자격증명이 침해되면 attestation도 정상 절차처럼 위조될 수 있습니다. 출처와 무결성은
코드의 정확성과 다른 보장입니다.
## 19개 모듈부터 그대로 복제하지 않는다
### 사전 점검 — 어떤 상황에 어떤 구조가 맞는가
다른 프로젝트에서 `ca-tmpl`을 사용할 때는 19개 모듈부터 그대로 복제하도록 권하지 않습니다. 강한
게이트는 위반을 일찍 발견하지만 모듈·포트·테스트 정책을 계속 관리해야 합니다. 그래서 모듈 수보다
경계를 어겼을 때 생기는 비용과 프로젝트를 얼마나 오래 재사용할지를 먼저 비교해야 합니다.
| 상황 | 적합한 방향 |
| --------------------------------------------------- | -------------------------------------------------- |
| 짧은 시간 안에 개념을 실행해 보는 학습용 예제 | 단일 모듈 또는 작은 멀티모듈 |
| 여러 어댑터를 바꿔 끼우며 실험하는 랩 | 선택 구성을 명시한 실행형 참조 구현 |
| 수명이 짧고 변경 주체가 적은 서비스 | 필요한 경계만 남긴 모듈 축소형 |
| 여러 프로젝트가 복제할 조직 템플릿 | `ca-tmpl`처럼 자동 게이트를 포함한 스켈레톤 |
| 경계 침식의 조기 차단이 핵심인 서비스 | 컴파일·Gradle·ArchUnit을 함께 쓰는 구성 |
| 공급망·운영 계약까지 저장소에서 관리해야 하는 환경 | 품질·릴리스 정책을 코드와 함께 버전 관리하는 구성 |
현재 `ca-tmpl` 구성은 뒤쪽 세 상황에 더 잘 맞습니다. 작은 팀이나 짧은 수명 서비스에서는 같은 장치가
순비용이 될 수 있고 반대로 여러 팀이 반복해서 복제하는 템플릿이라면 위반을 리뷰에만 맡기는 비용이 더
커질 수 있습니다.
### 점진적 적용 — WHY에서 HOW로
`ca-tmpl`의 README에는 이 판단을 실제 빌드 실패까지 확인할 수 있는 짧은 진입 경로를 두었습니다.
먼저 퀵스타트로 현재 구조를 띄우고, 그다음 샘플을 참고해 도메인 하나를 추가하는 두 걸음입니다.
1. **띄워 봅니다.** README 퀵스타트는 소스 컴파일 검사 → 로컬 PostgreSQL 기동 → 애플리케이션 이미지
빌드·기동과 Flyway 완료 확인 → sample 격리/build 검증 → `/api/healthcheck` 스모크의 다섯 단계를
다음 한 명령에 묶습니다.
```bash
cd src && ./gradlew bootstrap
```
이미지 빌드·기동 단계가 있으므로 컨테이너 런타임(Docker)이 준비돼 있어야 합니다. 저장소의 DB 왕복
테스트도 Docker가 없으면 스킵됩니다. 각 단계의 예상 출력과 기동한 컨테이너를 내리는 절차는
README를 기준으로 확인합니다.
2. **도메인을 하나 더합니다.** `sample-portfolio`를 참조 슬라이스 삼아 새 도메인을 안쪽에서 바깥으로
쌓아 봅니다. `domain-core`(순수 POJO) → `application-core`(유스케이스·포트) →
어댑터(`web`·`persistence-jpa`) 순서입니다. break-it 절의 사례처럼 금지된 의존을 추가하면 위치에 따라
`javac`, Gradle 의존 검사, ArchUnit 중 해당 게이트가 실패해야 합니다.
기존 프로젝트에는 강제 범위를 단계적으로 넓힙니다. 먼저 ArchUnit 패키지 규칙을 추가하고 위반
픽스처로 규칙이 실제 실패하는지 확인합니다. 경계가 안정되면 코어를 별도 모듈로 분리해 클래스패스
격리를 얻습니다. 모듈이 늘면 의존 화이트리스트를 추가하고 `check`에 연결합니다. 각 단계는 앞 단계의
규칙을 대체하지 않고 서로 다른 위반 유형을 맡습니다.
### 중단·롤백 기준 — 언제 멈추거나 되돌리는가
포트와 모듈을 계속 추가하는 쪽으로만 기준을 두면 실제 교체 요구가 사라져도 구조가 남습니다. 그래서
도입 순서와 함께 중단·롤백 기준도 정했습니다. 다음 신호가 보이면 강제 범위를 줄이거나 이미 나눈
경계를 합치는 쪽을 검토합니다.
- 실제로 교체할 기술도, 테스트에서 대체할 필요도 없다면 그 포트는 걷어냅니다. 이런 포트는 경계를
만들지 않고 위임 코드만 늘립니다.
- 모듈 추가의 다섯 질문에 모두 "아니오"라면 모듈로 나누지 않습니다. 이미 나눈 모듈이 이 기준에 걸리면
병합을 검토하되, 화이트리스트·ArchUnit·의존 선언의 연쇄 수정 비용을 함께 계산합니다.
- 팀이 세 벌 모델(도메인·영속·응답)의 매핑을 유지할 여력이 없다면 도메인 순수성 수준을 낮추는 것이
구조를 방치하는 것보다 낫습니다. 단, 그 완화가 어떤 검출 능력을 포기하는지 이 글의 게이트 표로
확인합니다.
- 강제 장치를 끄는 변경(규칙 삭제, 화이트리스트 완화)은 일반 코드 변경과 같은 리뷰를 거치지 않게 되기
쉬우므로, 정책 파일 변경에 별도 승인 경로를 두는 것을 검토합니다. `ca-tmpl`은 CODEOWNERS로 보안
소유자를 지정하되, 실제 강제는 브랜치 보호 설정에 달려 있음을 함께 기록합니다.
## 결론 — 규칙의 개수가 아니라 실패하는 지점
제가 `ca-tmpl`을 만들면서 가장 중요하게 본 것은 “클린 아키텍처로 짰다”는 이름이 아니라 위반이
실제로 멈추는 지점이었습니다. 그래서 경계를 컴파일러와 빌드 시스템이 볼 수 있는 형태로 옮겼고,
금지된 의존이 자동으로 실패하도록 만들었습니다.
`ca-tmpl`은 세 겹의 게이트를 사용합니다. 모듈 분리는 금지된 타입을 코어 클래스패스에서 없앱니다.
`verifyCleanArchitectureDependencies`는 네 production configuration에 직접 선언된 프로젝트 의존의
상한을 검사합니다. ArchUnit은 허용된 클래스패스 안의 패키지·애노테이션 규칙까지 확인합니다.
강제의 범위도 분명합니다. 문자열 기반 리플렉션, 도메인 모델 자체의 품질, 운영 트래픽에서 나타나는
효과는 이 게이트만으로 판단할 수 없고 포트와 모듈을 늘리는 비용 역시 사라지지 않습니다.
앞에서 정한 여섯 요구와 제가 구현한 장치를 모으면 다음과 같습니다.
| 문제 | 설계 요구 | `ca-tmpl`의 구현 |
| ------------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| DB 변경이 서비스·API까지 전파 | 영속성 모델과 도메인 모델 분리 | 도메인 애그리게이트와 영속 엔티티를 구분하고 어댑터가 재매핑을 소유 |
| 정책이 Spring 타입에 결합 | 코어의 프레임워크 클래스패스 제한 | `domain-core` main compileClasspath는 외부 의존 없이 두고 `application-core`에서 Web·JPA·Spring TX 제외 |
| Controller가 Repository를 우회 | 유스케이스를 통한 진입 | `FeedController`가 유스케이스를 주입하고 서비스는 코어의 일반 계약을 구현 |
| 테스트가 DB를 요구 | 애플리케이션 소유 출력 포트 | 코어가 출력 포트를 정의하고 어댑터가 구현해 안쪽 테스트를 인프라에서 분리 |
| 패키지 경계가 침식 | 컴파일·빌드·테스트 수준 강제 | 클래스패스, Gradle 의존 허용 목록, ArchUnit 규칙을 함께 적용 |
| 운영 계약이 도메인에 침투 | 도메인 언어와 운영 언어 분리 | 샘플 도메인은 `Reason`을 소유하고 샘플 web 어댑터가 `ApiErrorCode`로 변환 |
이 장치들이 실제 팀의 변경 속도와 장애 비용에 어떤 영향을 주는지는 도입 환경에서 따로 측정해야 합니다.
중요한 경계 위반을 재현 가능한 검사로 옮기고, 그 검사가 놓치는 영역도 함께 기록해야 합니다. 자신의
저장소에서도 자주 발생하는 경계 위반 하나를 골라 리뷰·테스트·컴파일 중 어디에서 멈추는지 확인해
볼 수 있습니다. 아직 사람의 기억에만 기대고 있다면 그 위반부터 자동 검사로 옮기면 됩니다.