미추적 파일과 미커밋 수정을 전부 담아 pre-harness-removal 태그의 복구 범위를 확보한다. .agents/skills/writing-natural-korean 9개와 korean-technical-blog-skills-bundle-v1 61개가 여기 포함된다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1760 lines
153 KiB
Markdown
Executable File
1760 lines
153 KiB
Markdown
Executable File
# 실행 가능한 클린 아키텍처 — 선언이 아니라 빌드가 지키는 경계
|
||
|
||
`ca-tmpl`을 만들 때 가장 먼저 정한 기준은 “경계를 어긴 코드가 실제로 빌드를 통과하지 못해야
|
||
한다”는 것이었습니다. 클린 아키텍처라는 이름을 붙여도 컨트롤러가 리포지토리를 직접 참조하는 코드는
|
||
얼마든지 만들 수 있기 때문입니다. 그래서 책임을 패키지로 나누는 데서 멈추지 않고 19개 모듈의 소스
|
||
의존 방향을 코어 쪽으로 고정했습니다. 금지된 의존은 컴파일·Gradle 검증·아키텍처 테스트 중 알맞은
|
||
지점에서 실패하도록 만들었습니다. 이 글에서는 제가 왜 이런 구조를 택했고, 각 장치가 어떤 위반을
|
||
막도록 구현했는지 설명합니다.
|
||
|
||
## 그래서 무엇을 해결하는가
|
||
|
||
제가 `ca-tmpl`에서 해결하려 한 문제는 패키지 이름만으로는 경계를 강제할 수 없다는 점이었습니다.
|
||
`controller`·`service`·`repository`를 잘 나눠도 컨트롤러가 JPA 리포지토리를 직접 참조할 수
|
||
있습니다. 클래스패스에 타입이 있으면 코드는 그대로 컴파일되고, 리뷰에서 놓치면 병합도 막지
|
||
못합니다. 그래서 사람이 기억하던 규칙을 `javac`, Gradle 검증, 아키텍처 테스트가 실행하는 실패
|
||
조건으로 옮겼습니다.
|
||
|
||
읽고 나면 다음을 할 수 있습니다.
|
||
|
||
- 런타임 호출 방향과 소스 의존 방향을 구분하고 DIP가 정확히 무엇을 역전하는지 설명할 수 있습니다.
|
||
- 멀티모듈 클래스패스 격리, Gradle 의존 화이트리스트, ArchUnit(자바 코드 구조 규칙을 테스트로
|
||
검사하는 라이브러리) 규칙이 각각 어떤 위반을 잡고 어떤 위반을 놓치는지 판별할 수 있습니다.
|
||
- 자신의 팀 상황에서 이 강제 장치들이 이익인지 순비용인지 판단할 수 있습니다.
|
||
|
||
설명에서는 `ca-tmpl`의 멀티모듈 구조, 의존 정책, 아키텍처 테스트를 중심으로 합니다.
|
||
운영 트래픽이나 장애 상황에서의 실측 효과는 다루지 않습니다.
|
||
|
||
설명은 컴파일되는 경계 위반에서 시작합니다. 이어서 런타임 호출과 소스 의존, 포트와 링을 구분하고
|
||
`ca-tmpl`의 모듈 구조와 요청 흐름을 보여 줍니다. 마지막에는 빌드가 실제로 막는 위반과 여전히 사람이
|
||
확인해야 하는 영역을 나눕니다.
|
||
|
||
- 문제가 생기는 맥락과 제약 — 경계는 왜 보이지 않게 되는가
|
||
- 핵심 판단 기준과 멘털 모델 — 세 가지 방향, 포트, 링, 모듈 판단 기준
|
||
- 해결 방식이 동작하는 과정 — 19개 모듈, 모델 분리, 세 겹 게이트
|
||
- 구현으로 설명하는 요청 경로 — Feed 조회와 여섯 횡단 계약
|
||
- 어떻게 검증할 것인가 — 테스트 4층, test-the-test, break-it, 공급망
|
||
- 대안, 트레이드오프, 실패 조건 — 다섯 결정의 반대편과 강제의 한계
|
||
- 실무 적용 체크리스트 — 상황 판별, 점진 적용, 중단·롤백 기준
|
||
|
||
## 문제가 생기는 맥락과 제약
|
||
|
||
### 경계가 무너지는 순간 — 컴파일되는 위반
|
||
|
||
제가 가장 먼저 막으려 한 것은 아래처럼 경계를 건너뛰어도 컴파일되는 코드였습니다.
|
||
|
||
```java
|
||
@RestController
|
||
class WorkLogController {
|
||
private final JpaWorkLogRepository repository;
|
||
}
|
||
```
|
||
|
||
겉으로는 필드 하나를 바로 주입한 코드지만 실제 요청은 유스케이스와 도메인을 모두 건너뜁니다. 이런
|
||
우회를 빌드에서 막으려면 다음 네 가지 질문에 답해야 했습니다.
|
||
|
||
- **컴파일러가 허용하는가?** 타입만 맞으면 허용합니다.
|
||
- **기존 테스트가 잡는가?** 경계 규칙이 없으면 놓칠 수 있습니다.
|
||
- **리뷰에서 놓치면 어떻게 되는가?** 그대로 머지됩니다.
|
||
- **반년 뒤 이 의존은 누가 기억하는가?** 아무도 기억하지 못합니다.
|
||
|
||
리뷰에서 놓친 의존이 하나씩 쌓이면 그림으로 그려 둔 아키텍처와 실제 코드가 서서히 달라집니다. 이런
|
||
변화를 아키텍처 침식(erosion)이라고 합니다. 최상위 폴더를 `controller`·`service`·`repository`로만
|
||
나눈 구조에서는 주문 기능 하나를 고칠 때 세 폴더를 함께 열게 됩니다. 이때 폴더 구조는 시스템이 무슨
|
||
일을 하는지보다 어떤 프레임워크 책임으로 나뉘었는지를 먼저 보여 줍니다.
|
||
|
||

|
||
|
||
질문이 하나 남습니다. **그렇다면 이 경계는 무엇이 지킬까요?**
|
||
|
||
### 진짜 문제는 Layered가 아니라 보이지 않는 경계
|
||
|
||
저는 이 문제를 Layered 구조의 탓으로 보지 않았습니다. 같은 클래스를 Clean이라는 패키지 아래로
|
||
옮겨도 컴파일 결과는 달라지지 않기 때문입니다. 위 예시의 컨트롤러→리포지토리 직접 의존은
|
||
타입이 같은 클래스패스에 있는 한 컴파일과 테스트를 모두 통과합니다. 패키지 이름에는 사람이 정한
|
||
규칙이 담겨 있지만 컴파일러는 그 이름을 경계로 해석하지 않습니다. 그래서 문제를 Layered라는 이름이
|
||
아니라 경계가 컴파일러와 빌드 시스템에 **보이지 않는 상태**로 정의했습니다.
|
||
|
||
경계를 어디에 표현하느냐에 따라, 서로 다른 위반을 잡을 수 있는 강제 수단이 이렇게 갈립니다.
|
||
|
||

|
||
|
||
| 구조 | 의존 규칙 존재 | 빌드 강제 | 위반 코드 |
|
||
| ----------------- | :------------: | :------------------------: | --------------------------------- |
|
||
| 단일 모듈 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`에 두었습니다.
|
||
이렇게 두 종류의 코드를 다른 모듈에서 관리하지만, 이 분리만으로 운영 용어가 도메인에 들어오는 모든
|
||
경우를 자동 차단하는 것은 아닙니다. 실제 강제 범위는 뒤에서 다시 설명합니다.
|
||
|
||
## 핵심 판단 기준과 멘털 모델
|
||
|
||
### 실행 흐름과 소스 의존은 왜 반대가 되는가
|
||
|
||
`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`뿐입니다.
|
||
|
||

|
||
|
||
`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>>`를 구현합니다. 이런 타입 계약을 포트로 삼으면 하나의
|
||
선언이 포트의 모양과 기계적 강제를 함께 제공하면서도 유스케이스의 책임은 유지할 수 있습니다.
|
||
|
||

|
||
|
||
`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의 의존 규칙으로는 그 경계를 넘는 소스 의존의 방향을 정합니다. 이 두 규칙을 함께 적용해도 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입니다. 이 버전들은 특정 아키텍처 기능 때문에
|
||
고른 것이 아니라 메이저 버전 전환 시점에 검증하고 고정한 빌드 기준점입니다. 계속 유지해야 할
|
||
아키텍처 이유는 없으므로 호환성 테스트를 통과하는 범위에서 최신 유지보수 버전으로 갱신해야 합니다.
|
||
|
||

|
||
|
||
- **내부 모듈 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로
|
||
선언하고 각 outbound adapter가 이를 실제 기술로 구현합니다.
|
||
|
||

|
||
|
||
반대쪽에는 외부 요청을 애플리케이션 입력으로 변환하는 inbound 경계가 있습니다.
|
||
|
||
- `web`은 HTTP 요청, JSON DTO, Bean Validation, 인증 인가와 HTTP 오류 응답을 담당합니다.
|
||
- `grpc`는 protobuf 기반 요청과 gRPC 서버 lifecycle을 담당합니다.
|
||
- `graphql`은 GraphQL schema와 query-mutation 진입점을 담당합니다.
|
||
- `websocket`은 WebSocket.STOMP 연결과 실시간 메시지 진입점을 담당합니다.
|
||
|
||
각 inbound adapter는 자신이 사용하는 전송 기술 타입을 모듈 안에서 처리합니다. 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 ┘
|
||
```
|
||
|
||
Inbound와 outbound는 테스트 전략도 다릅니다.
|
||
|
||
- Inbound adapter : 역직렬화, 요청 검증, 인증 인가, transport 계약, 오류 응답
|
||
- Outbound adapter : 데이터 매핑, 외부 시스템 연동, timeout-retry, 기술 예외 변환
|
||
|
||
<!-- techviz:begin id=inbound-transport-boundary context-sha256=04fbab095d33d301746c34f7cca305730919bad3c341bcf63b8ad3ee3b396d31 -->
|
||
|
||
<!-- techviz:generate id=inbound-transport-boundary -->
|
||
|
||

|
||
|
||
왼쪽에서 오른쪽으로 읽습니다. web은 HTTP DTO, grpc는 protobuf message, graphql은 GraphQL request, websocket은 WebSocket message를 각 어댑터 경계에서 처리합니다. 네 어댑터는 전송 기술 타입을 application-core로 넘기지 않고 Command 또는 Query로 변환합니다. 변환된 입력만 Application use case를 호출합니다.
|
||
|
||
app-bootstrap은 프로젝트가 실제 사용할 inbound와 outbound adapter를 선택해서 application port와 연결합니다. 사용하지 않는 선택형 어댑터를 런타임 의존성에서 제외하면 해당 모듈의 빈과 설정도 애플리케이션 컨텍스트에 등록되지 않습니다.
|
||
이 실행 구성을 바탕으로 코드의 의존성이 어떤 방향으로 흐르도록 만들었는지 설명하겠습니다.
|
||
이 프로젝트의 모듈 간 의존은 inbound와 outbound 모두 바깥에서 안쪽으로 향합니다. verifyCleanArchitectureDependencies는 모듈 간 프로젝트의 의존성을 검사하고, ArchUnit의 DOMAIN_IS_TRUE는 모듈 내부 코드가 금지된 프레임워크 타입을 참조하는지 검사합니다.
|
||
|
||
<!-- techviz:begin id=bootstrap-dependency-guards context-sha256=04fbab095d33d301746c34f7cca305730919bad3c341bcf63b8ad3ee3b396d31 -->
|
||
|
||
<!-- techviz:generate id=bootstrap-dependency-guards -->
|
||
|
||

|
||
|
||
가운데 Application Core를 기준으로 왼쪽에는 Inbound adapters와 Input port, 오른쪽에는 Output port와 Outbound adapters가 있습니다. 어댑터의 모듈 의존은 포트와 코어 쪽을 향합니다. 아래의 app-bootstrap은 실제 사용할 양쪽 어댑터를 선택하고 application port에 연결합니다. 별도의 두 검증 게이트 중 verifyCleanArchitectureDependencies는 모듈 간 프로젝트 의존을 검사하고 ArchUnit 규칙은 모듈 내부 코드의 금지된 프레임워크 타입 참조를 검사합니다.
|
||
|
||

|
||
|
||
*프로젝트 모듈 간 의존은 adapter→application→domain으로 안쪽을 향합니다. MVC·JPA·DB 구체 의존은 해당 어댑터가 소유하고 Boot·Flyway·관측·보안 조립은 app-bootstrap이 별도로 소유합니다.*
|
||
|
||
아래 그림은 `allowedProjectDependencies` 중 코어 접근권과 `support`공유의 비대칭을 보여 주는 다섯 부분만 표현합니다.
|
||
|
||

|
||
|
||
### 경계마다 다른 모델 — 다섯 종류
|
||
|
||
피드 항목의 같은 필드가 여러 타입에 반복되는 것은 경계마다 모델을 분리했기 때문입니다.
|
||
`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 -->
|
||
|
||

|
||
|
||
<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 실행까지 미룹니다.*
|
||
|
||
단일 모듈이면 모든 클래스가 하나의 컴파일 클래스패스를 공유합니다. 어댑터 코드에는 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을 함께 둡니다. 세 검사는 맡은 위반이 다르며 고정된 실행 순서나 속도 순위를
|
||
뜻하지 않습니다.
|
||
|
||

|
||
*세 게이트는 각기 다른 위반 유형을 맡습니다. 실행 순서나 속도 순위는 없습니다. 모듈을 합치면 클래스패스
|
||
격리와 프로젝트 의존 정책의 범위가 사라지고 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 절)과 사람·운영 판단을 더하면 서로 겹치지만 대체할 수 없는
|
||
다섯 강제 범위가 됩니다.
|
||
|
||

|
||
*다섯 범위는 서로 대체하거나 항상 같은 순서로 실행되는 단계가 아닙니다. 각 범위가 잡는 위반 종류와
|
||
놓치는 영역이 달라 함께 경계를 보완합니다.*
|
||
|
||
## 구현으로 설명하는 요청 경로
|
||
|
||
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`로 다시 조립해 컨트롤러가 반환합니다.
|
||
|
||

|
||
*실제 실행은 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은 타입 불변식이 아니라 호출자 사용 규율입니다. 이것이 전체 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 경로에 도입할 때 함께
|
||
닫아야 할 배선 공백입니다.
|
||
|
||

|
||
*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+0000–U+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키 제거까지입니다.
|
||
|
||

|
||
*도식은 필터 체인의 성공·예외 뒤 같은 정리 경로가 시작되는 것과, 정리 본문 자체의 실패까지 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` 안에서 락을 획득한다고
|
||
서술하면 위 권장 순서와 반대가 되므로 그렇게 해석하면 안 됩니다.
|
||
|
||

|
||
*두 포트를 한 실행 사슬로 읽으면 프로덕션 호출자 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가 됩니다.
|
||
|
||

|
||
*두 대기 진입점은 같은 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` |
|
||
|
||

|
||
*기본 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`는 가시성 제한 시간 조건으로 다시 선점됩니다.
|
||
재시도는 같은 호출 스택에서 반복하지 않고 상태와 다음 시각을 저장한 뒤 다음 폴에 맡깁니다.
|
||
|
||

|
||
*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`로 표시합니다. 운영자가 수행하는
|
||
절차이지 자동화된 복구 전이가 아닙니다. 프로덕션 소비자의 영속 중복 제거와 수동 처분의 운영 준비도를
|
||
별도로 확인하기 전에는 자동 전달 완료나 결정적 전체 순서를 약속할 수 없습니다.
|
||
|
||
여섯 계약은 구현과 테스트의 존재만으로 완성됐다고 보지 않았습니다. 실제 요청 경로에 연결하지 않은
|
||
계약도 있기 때문입니다. 현재 배선 상태를 함께 놓으면 다음과 같습니다.
|
||
|
||
| 횡단 계약 | 구현·테스트 | 현재 배선 상태 |
|
||
| --------------- | ------------------------------------- | --------------------------------------------------------------------- |
|
||
| 검증 | 샘플 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 타입과 트랜잭션 프록시에 결합되기 쉬워 테스트하려면
|
||
컨텍스트를 띄우거나 프레임워크 타입을 목킹해야 합니다. 차이가 드러나는 지점은 협력자의 타입입니다. 이
|
||
대조는 결합도 차이를 설명하기 위한 것이며 저장소의 대칭 측정 결과가 아닙니다.
|
||
|
||

|
||
*오른쪽은 코어 소유 포트를 익명 테스트 더블로 대체하는 실제 패턴이고 왼쪽은 결합도 차이를 설명하기
|
||
위한 대조입니다. 두 패널을 저장소의 대칭 측정 결과로 읽지 않습니다.*
|
||
|
||
도메인 층에는 주목할 테스트가 하나 더 있습니다. `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
|
||
패키지로 보내는 규율은 이 제한된 게이트와 디렉터리 관례가 함께 만듭니다.
|
||
|
||

|
||
*테스트 파일이 현재 어디에 놓였는지와 아키텍처 규칙이 실제로 강제하는 범위는 구분해야 합니다.*
|
||
|
||
### 규칙을 테스트하는 테스트 — 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")
|
||
↓
|
||
게이트 없음 — 문자열 키는 바이트코드에 타입 의존을 남기지 않는다
|
||
↓
|
||
판정 이를 검출하는 정적 규칙 없음 → 정적 게이트에서 차단되지 않음
|
||
```
|
||
|
||

|
||
*정적 분석이 잡는 것은 실제 경계 위반 전체의 부분집합입니다. 작은 원 밖의 리플렉션·문자열 조회는 코드
|
||
리뷰와 런타임 검증이 맡아야 할 사각지대입니다.*
|
||
|
||
②·③에서 보이듯, 어느 겹이 잡는지는 위반의 주입 위치가 정합니다. 도메인·애플리케이션 모듈엔 금지
|
||
타입 자체가 클래스패스에 없어서 프로덕션 소스에 넣으면 컴파일 격리(게이트 ①)가 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`의 선택 이유는 제가 구현한 코드, Gradle 선언, 테스트 규칙으로
|
||
설명했습니다.
|
||
|
||
### 다섯 설계 결정과 그 반대편
|
||
|
||
제가 `ca-tmpl`에서 택한 선택과 더 단순한 대안을 나란히 놓으면 다음과 같습니다. `ca-tmpl`은 hybrid
|
||
패키지, 포트 기반 트랜잭션, 멀티모듈과 ArchUnit, CQRS-lite, 순수 POJO 도메인을 택했습니다. 장기 재사용
|
||
템플릿에서는 경계를 반복 검사할 수 있지만, 1회성 서비스에서는 같은 장치가 유지비만 늘릴 수 있었습니다.
|
||
그래서 아래 표에는 선택의 장점만 적지 않고 반대편이 더 나은 조건도 함께 남겼습니다.
|
||
|
||
| 결정 | 선택 | 선택으로 얻는 것 | 반대편이 나은 조건 |
|
||
| ---------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||
| ① 패키지 배치 | 계층 소유 코어·어댑터와 수직 샘플을 함께 쓰는 hybrid | 프로덕션 경계는 계층별로 통제하고, 샘플은 한 기능의 종단 구성을 보여 줍니다. | 한 가지 축만으로 충분한 작은 서비스 |
|
||
| ② 트랜잭션 경계 | `@Transactional` 대신 코어 소유 포트 | Spring TX를 애플리케이션 클래스패스에서 빼고 트랜잭션 의도를 테스트 가능한 계약으로 만듭니다. | 단일 DB를 쓰며 간접 호출 비용이 더 큰 작은 팀 |
|
||
| ③ 모듈화 | 멀티모듈 + ArchUnit | 금지된 타입은 컴파일에서, 허용 범위 안의 패키지 위반은 테스트에서 잡습니다. | 수명이 짧아 모듈·정책 유지비를 회수하기 어려운 서비스 |
|
||
| ④ CQRS | full이 아닌 lite | 읽기·쓰기 코드와 모델은 분리하되 별도 저장소의 복제·복구 비용은 도입하지 않습니다. | 읽기·쓰기 부하가 명확히 비대칭이고 동기화 비용을 감당할 수 있는 시스템 |
|
||
| ⑤ 도메인 순수성 | Lombok·JPA 없는 순수 POJO | 도메인 규칙을 ORM 생명주기와 프레임워크 타입에서 분리합니다. | 매핑 비용이 격리 효과보다 큰 단순 CRUD 서비스 |
|
||
|
||
**① 패키지 배치.** `ca-tmpl`의 실제 패키지는 한쪽으로만 정렬되지 않습니다. 프로덕션 코어·어댑터
|
||
모듈은 계층이 소유하지만 `sample-portfolio`는 수직 참조 슬라이스이고 도메인은 `feed`처럼 기능
|
||
중심이며 애플리케이션은 기술 패키지와 기능 패키지를 함께 둡니다. 이 배치는 프로덕션의 허용 의존을
|
||
계층별로 통제하면서도 샘플에서는 기능 하나의 종단 구성을 한곳에 보여 줍니다. 기능이 2~3개로 고정된
|
||
작은 서비스라면 두 축을 병행하는 것 자체가 오버엔지니어링입니다. 그 조건에서는 한 가지 패키지 축이 더
|
||
짧습니다.
|
||
|
||

|
||
*이 그림은 `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회성 서비스라면 모듈·정책 유지
|
||
비용을 회수하기 어려워 약한 강제로도 충분합니다.
|
||
|
||

|
||
*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도 정상 절차처럼 위조될 수 있습니다. 출처와 무결성은
|
||
코드의 정확성과 다른 보장입니다.
|
||
|
||
## 실무 적용 체크리스트
|
||
|
||
### 사전 점검 — 어떤 상황에 어떤 구조가 맞는가
|
||
|
||
다른 프로젝트에서 `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`(유스케이스·포트) →
|
||
adapter(`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`로 변환 |
|
||
|
||
이 장치들이 실제 팀의 변경 속도와 장애 비용에 어떤 영향을 주는지는 도입 환경에서 따로 측정해야 합니다.
|
||
|
||
제가 `ca-tmpl`을 만들며 내린 결론은 규칙의 개수보다 실패하는 지점이 중요하다는 것입니다. 중요한
|
||
경계 위반을 재현 가능한 검사로 옮기고, 그 검사가 놓치는 영역도 함께 기록해야 합니다. 자신의
|
||
저장소에서도 자주 발생하는 경계 위반 하나를 골라 리뷰·테스트·컴파일 중 어디에서 멈추는지 확인해
|
||
볼 수 있습니다. 아직 사람의 기억에만 기대고 있다면 그 위반부터 자동 검사로 옮기면 됩니다.
|