chore: snapshot working tree before harness removal

미추적 파일과 미커밋 수정을 전부 담아 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>
This commit is contained in:
DongHyeonka
2026-08-07 14:19:24 +09:00
co-authored by Claude Opus 5
parent b101b6e717
commit 1099834617
85 changed files with 8547 additions and 114 deletions
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="330" viewBox="0 0 1400 330" role="img" aria-labelledby="title desc">
<title id="title">Runtime call</title>
<desc id="desc">FeedController calls GetFeedUseCase, which dispatches to SpringTransactionPort at runtime.</desc>
<metadata>{&quot;source&quot;:&quot;runtime-call-source-dependency.svg&quot;,&quot;panel&quot;:&quot;upper&quot;,&quot;canvas_policy&quot;:&quot;diagram-only&quot;}</metadata>
<defs>
<marker id="arrow-ink" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto" markerUnits="strokeWidth"><path d="M 0 0 L 10 4 L 0 8 z" fill="#25282D"/></marker>
</defs>
<rect width="1400" height="330" fill="#FFFFFF"/>
<rect x="35" y="45" width="1330" height="245" fill="#FBFCFE" stroke="#C5CBD3" stroke-width="1.3" rx="10"/>
<rect x="44" y="34" width="233.9" height="20" fill="#FFFFFF" rx="2"/>
<text x="51" y="50" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="11" font-weight="650" fill="#667085" text-anchor="start">실행 시점 관계 · 실선 = 호출·디스패치</text>
<rect x="100" y="125" width="245" height="90" fill="#FFFFFF" stroke="#59616B" stroke-width="1.5" rx="8"/>
<text x="222.5" y="164" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="16" font-weight="700" fill="#25282D" text-anchor="middle">FeedController</text>
<text x="222.5" y="189" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="12" font-weight="450" fill="#667085" text-anchor="middle">driving adapter</text>
<rect x="555" y="105" width="285" height="130" fill="#EAF3FF" stroke="#1677FF" stroke-width="1.5" rx="8"/>
<text x="697.5" y="164" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="16" font-weight="700" fill="#25282D" text-anchor="middle">GetFeedUseCase</text>
<text x="697.5" y="189" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="12" font-weight="450" fill="#667085" text-anchor="middle">concrete service</text>
<rect x="1060" y="125" width="250" height="90" fill="#FFFFFF" stroke="#59616B" stroke-width="1.5" rx="8"/>
<text x="1185" y="164" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="16" font-weight="700" fill="#25282D" text-anchor="middle">SpringTransactionPort</text>
<text x="1185" y="189" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="12" font-weight="450" fill="#667085" text-anchor="middle">runtime implementation</text>
<path d="M 345 170 L 555 170" fill="none" stroke="#25282D" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow-ink)"/>
<rect x="405.4" y="145" width="89.2" height="20" fill="#FFFFFF" rx="2"/>
<text x="450" y="161" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="11" font-weight="650" fill="#25282D" text-anchor="middle">Runtime call</text>
<path d="M 840 170 L 1060 170" fill="none" stroke="#25282D" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow-ink)"/>
<rect x="892.8" y="145" width="114.3" height="20" fill="#FFFFFF" rx="2"/>
<text x="950" y="161" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="11" font-weight="650" fill="#25282D" text-anchor="middle">Runtime dispatch</text>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="340" viewBox="0 0 1400 340" role="img" aria-labelledby="title desc">
<title id="title">Source dependency and contract ownership</title>
<desc id="desc">GetFeedUseCase depends on application-core contracts, while SpringTransactionPort implements TransactionPort.</desc>
<metadata>{&quot;source&quot;:&quot;runtime-call-source-dependency.svg&quot;,&quot;panel&quot;:&quot;lower&quot;,&quot;canvas_policy&quot;:&quot;diagram-only&quot;}</metadata>
<defs>
<marker id="arrow-muted" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto" markerUnits="strokeWidth"><path d="M 0 0 L 10 4 L 0 8 z" fill="#667085"/></marker>
<marker id="arrow-purple" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto" markerUnits="strokeWidth"><path d="M 0 0 L 10 4 L 0 8 z" fill="#7556D8"/></marker>
</defs>
<rect width="1400" height="340" fill="#FFFFFF"/>
<rect x="35" y="45" width="1330" height="255" fill="#FBFCFE" stroke="#C5CBD3" stroke-width="1.3" rx="10"/>
<rect x="44" y="34" width="284.2" height="20" fill="#FFFFFF" rx="2"/>
<text x="51" y="50" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="11" font-weight="650" fill="#667085" text-anchor="start">계약 소유·소스 의존 · 점선 = 타입·계약을 향함</text>
<rect x="95" y="135" width="260" height="85" fill="#F1EDFF" stroke="#7556D8" stroke-width="1.5" rx="8"/>
<text x="225" y="156" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="11" font-weight="600" fill="#667085" text-anchor="middle" letter-spacing="0.8">&lt;&lt;interface&gt;&gt;</text>
<text x="225" y="177.5" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="16" font-weight="700" fill="#25282D" text-anchor="middle">QueryUseCase&lt;Q,R&gt;</text>
<text x="225" y="202.5" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="12" font-weight="450" fill="#667085" text-anchor="middle">application-core contract</text>
<rect x="565" y="115" width="270" height="125" fill="#EAF3FF" stroke="#1677FF" stroke-width="1.5" rx="8"/>
<text x="700" y="171.5" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="16" font-weight="700" fill="#25282D" text-anchor="middle">GetFeedUseCase</text>
<text x="700" y="196.5" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="12" font-weight="450" fill="#667085" text-anchor="middle">implements · calls</text>
<rect x="1060" y="135" width="240" height="85" fill="#F1EDFF" stroke="#7556D8" stroke-width="1.5" rx="8"/>
<text x="1180" y="156" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="11" font-weight="600" fill="#667085" text-anchor="middle" letter-spacing="0.8">&lt;&lt;interface&gt;&gt;</text>
<text x="1180" y="177.5" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="16" font-weight="700" fill="#25282D" text-anchor="middle">TransactionPort</text>
<text x="1180" y="202.5" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="12" font-weight="450" fill="#667085" text-anchor="middle">application-core contract</text>
<path d="M 565 160 L 355 160" fill="none" stroke="#7556D8" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="7 5" marker-end="url(#arrow-purple)"/>
<rect x="421.6" y="135" width="76.7" height="20" fill="#FFFFFF" rx="2"/>
<text x="460" y="151" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="11" font-weight="650" fill="#7556D8" text-anchor="middle">Implements</text>
<path d="M 835 160 L 1060 160" fill="none" stroke="#7556D8" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="7 5" marker-end="url(#arrow-purple)"/>
<rect x="902.9" y="135" width="89.2" height="20" fill="#FFFFFF" rx="2"/>
<text x="947.5" y="151" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="11" font-weight="650" fill="#7556D8" text-anchor="middle">Runtime call</text>
<rect x="1080" y="65" width="200" height="50" fill="#FFFFFF" stroke="#59616B" stroke-width="1.5" rx="8"/>
<text x="1180" y="95" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="12" font-weight="700" fill="#25282D" text-anchor="middle">SpringTransactionPort</text>
<path d="M 1180 115 L 1180 135" fill="none" stroke="#667085" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="7 5" marker-end="url(#arrow-muted)"/>
<rect x="1221.7" y="117" width="76.7" height="20" fill="#FFFFFF" rx="2"/>
<text x="1260" y="133" font-family="'Noto Sans CJK KR', 'Apple SD Gothic Neo', sans-serif" font-size="11" font-weight="650" fill="#667085" text-anchor="middle">Implements</text>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -236,12 +236,12 @@ Port는 아웃바운드 어댑터가 코어에 제공해야 할 기능을 정합
코드가 바뀌는 이유를 기준으로 `ca-tmpl`의 모듈에 대응시켰습니다. 같은 이유로 바뀌는 코드는 한 경계에
두고 다른 이유로 바뀌는 코드는 의존 방향을 나눴습니다.
| 링 | `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·관측·보안 배선 | 구체 프레임워크 선택과 실행 시점 조립은 배포 환경에 따라 바뀌므로 가장 바깥에서 결정합니다. |
| 애플리케이션 업무 규칙(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가 없으므로 도메인 코드가 해당 타입을 참조하면 컴파일 단계에서 실패합니다.
@@ -391,7 +391,9 @@ Inbound와 outbound는 테스트 전략도 다릅니다.
- Outbound adapter : 데이터 매핑, 외부 시스템 연동, timeout-retry, 기술 예외 변환
<!-- techviz:begin id=inbound-transport-boundary context-sha256=04fbab095d33d301746c34f7cca305730919bad3c341bcf63b8ad3ee3b396d31 -->
<!-- techviz:generate id=inbound-transport-boundary -->
![네 inbound adapter의 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로 변환합니다. 변환된 입력만 Application use case를 호출합니다.
@@ -401,7 +403,9 @@ app-bootstrap은 프로젝트가 실제 사용할 inbound와 outbound adapter를
이 프로젝트의 모듈 간 의존은 inbound와 outbound 모두 바깥에서 안쪽으로 향합니다. verifyCleanArchitectureDependencies는 모듈 간 프로젝트의 의존성을 검사하고, ArchUnit의 DOMAIN_IS_TRUE는 모듈 내부 코드가 금지된 프레임워크 타입을 참조하는지 검사합니다.
<!-- 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를 기준으로 왼쪽에는 Inbound adapters와 Input port, 오른쪽에는 Output port와 Outbound adapters가 있습니다. 어댑터의 모듈 의존은 포트와 코어 쪽을 향합니다. 아래의 app-bootstrap은 실제 사용할 양쪽 어댑터를 선택하고 application port에 연결합니다. 별도의 두 검증 게이트 중 verifyCleanArchitectureDependencies는 모듈 간 프로젝트 의존을 검사하고 ArchUnit 규칙은 모듈 내부 코드의 금지된 프레임워크 타입 참조를 검사합니다.
@@ -433,15 +437,16 @@ app-bootstrap은 프로젝트가 실제 사용할 inbound와 outbound adapter를
`Command` 마커는 `sample-portfolio``CreateWorkLogCommand`에 실제로 적용돼 있습니다.
재매핑은 두 번 일어납니다.
1. persistence에서 application으로 넘어갈 때입니다.
`FeedQueryAdapter.loadFeed()`는 JPA 조회 결과를 `FeedSummary`로 직접 조립합니다.
`FeedItemJpaEntity → FeedItem → FeedSummary`처럼 애그리게이트를 재구성하지 않고 조회 결과에서 곧장 애플리케이션 프로젝션으로 건너갑니다.
`FeedQueryAdapter.loadFeed()`는 JPA 조회 결과를 `FeedSummary`로 직접 조립합니다.
`FeedItemJpaEntity → FeedItem → FeedSummary`처럼 애그리게이트를 재구성하지 않고 조회 결과에서 곧장 애플리케이션 프로젝션으로 건너갑니다.
같은 DB 안에서 읽기 경로만 논리적으로 나누는 이 우회가 뒤에서 다룰 CQRS-lite 결정의 구체적인 모습입니다.
CQRS는 명령(Command)과 조회(Query)의 코드·모델을 나누는 패턴이고 lite는 저장소 분리 없이 코드 경로와 모델만 나눈 수준을 뜻합니다.
2. application에서 web으로 나갈 때입니다.
`FeedWebMapper.toResponse()``FeedSummary``FeedResponse`로 다시 조립합니다.
`FeedWebMapper.toResponse()``FeedSummary``FeedResponse`로 다시 조립합니다.
두 매핑을 모두 어댑터가 소유하므로 `GetFeedUseCase``FeedQueryPort`는 웹 응답이나 JPA 엔티티의
모양을 모릅니다. `GetFeedUseCase``FeedResponse`를 직접 만들었다면 HTTP 응답 변경이 코어 변경으로 번졌을 것입니다. 반대로 도메인 재구성이 필요한 경로에서는 어댑터의 `FeedItemPersistenceMapper`가 코어 모델 변경을 따라 바뀌는 것이 의도한 결합입니다.
@@ -474,11 +479,11 @@ arawn은 외형 복제보다 높은 응집과 느슨한 결합을 강조했습
`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` | 애그리게이트·값·이벤트·식별자와 불변식 | 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`는 로깅 프레임워크까지 차단합니다.
@@ -501,8 +506,8 @@ REST·gRPC·GraphQL·WebSocket 네 인바운드 모듈은 같은 깊이로 구
두었습니다. 구현 범위는 달라도 전송 기술을 코어 밖에 두고 아웃바운드 구현을 직접 고르지
않는 규칙은 같게 적용했습니다.
| 모듈 | 현재 제공하는 기능 | 결정적인 차이 |
| ----------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------- |
| 모듈 | 현재 제공하는 기능 | 결정적인 차이 |
| ----------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------- |
| `adapter:inbound:web` | `/feed` REST·health·공유 웹 인프라 | `FeedController``GetFeedUseCase`를 호출하고 `FeedWebMapper`로 응답 DTO를 만듭니다 |
| `adapter:inbound:grpc` | health·reflection | 피처 proto 없이 Netty 서버를 직접 수명주기 관리합니다 |
| `adapter:inbound:graphql` | 최소 헬스 스키마 | 피처 스키마·리졸버 추가는 소비 프로젝트의 확장 작업입니다 |
@@ -525,12 +530,12 @@ REST·gRPC·GraphQL·WebSocket 네 인바운드 모듈은 같은 깊이로 구
달라질 수 있어 “바로 실행할 기준 구현”과 “소비 프로젝트가 채울 확장점”을 구분했습니다. 구현 깊이는
달라도 인바운드나 다른 아웃바운드 구현을 직접 선택하지 않는 규칙은 같게 적용했습니다.
| 묶음 | 모듈 | 구현된 능력 | 도입 시 확인할 예외 |
| --------------- | ------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| 영속성 | `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`, `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`
@@ -581,8 +586,11 @@ Mongo 자동설정 세 개를 꺼 클래스패스를 무력화하고, 모듈이
아래 그림은 `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>
@@ -593,6 +601,7 @@ Mongo 자동설정 세 개를 꺼 클래스패스를 무력화하고, 모듈이
</details>
[Editable source](../assets/production-vs-optin.drawio) · [Grounded VizSpec](.techviz/production-vs-optin/spec.json)
<!-- techviz:end id=production-vs-optin -->
```java
@@ -606,6 +615,7 @@ public CacheBackend redisCacheBackend(RedisClient redisClient) {
return new RedisCacheStore(redisClient);
}
```
`@ConditionalOnProperty`가 외부 백엔드 빈 활성화를 한 번 더 결정합니다.
`app-bootstrap`이 의존하는 모듈도 모두 실행되지는 않습니다. Redis나 Kafka 같은 외부 백엔드는 런타임
@@ -861,12 +871,12 @@ Bean Validation도 범위 거부도 없습니다. 두 파라미터는
계층 규율을 고정하는 규칙과 대표 테스트는 다음과 같습니다.
| 검사 | 고정하는 경계 |
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY` | 도메인·애플리케이션 패키지의`jakarta.validation..` 의존 거부 |
| 검사 | 고정하는 경계 |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `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)`로 실패 |
| `PosterControllerWireTest` | 빈 제목은 유스케이스 전에 400`VALIDATION_FAILED`, 이미지 없는 발행은 400 `POSTER_IMAGE_REQUIRED` |
| `PosterTest` | null/blank 제목이 NPE가 아닌`PosterInvariantException(TITLE_BLANK)`로 실패 |
### 예외·오류 응답 — 두 단계 처리 사슬, 하나의 Envelope
@@ -1543,8 +1553,8 @@ Tudum 사례는 CQRS를 버린 사례가 아닙니다. Kafka에서 Raw Hollow로
템플릿에서는 경계를 반복 검사할 수 있지만, 1회성 서비스에서는 같은 장치가 유지비만 늘릴 수 있었습니다.
그래서 아래 표에는 선택의 장점만 적지 않고 반대편이 더 나은 조건도 함께 남겼습니다.
| 결정 | 선택 | 선택으로 얻는 것 | 반대편이 나은 조건 |
| ---------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| 결정 | 선택 | 선택으로 얻는 것 | 반대편이 나은 조건 |
| ---------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| ① 패키지 배치 | 계층 소유 코어·어댑터와 수직 샘플을 함께 쓰는 hybrid | 프로덕션 경계는 계층별로 통제하고, 샘플은 한 기능의 종단 구성을 보여 줍니다. | 한 가지 축만으로 충분한 작은 서비스 |
| ② 트랜잭션 경계 | `@Transactional` 대신 코어 소유 포트 | Spring TX를 애플리케이션 클래스패스에서 빼고 트랜잭션 의도를 테스트 가능한 계약으로 만듭니다. | 단일 DB를 쓰며 간접 호출 비용이 더 큰 작은 팀 |
| ③ 모듈화 | 멀티모듈 + ArchUnit | 금지된 타입은 컴파일에서, 허용 범위 안의 패키지 위반은 테스트에서 잡습니다. | 수명이 짧아 모듈·정책 유지비를 회수하기 어려운 서비스 |