init: llm-wiki-haness 하네스 설계

This commit is contained in:
DongHyeonka
2026-07-24 14:21:35 +09:00
parent 42bf3db4fd
commit 6c53ded9cb
2436 changed files with 194486 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
# Reserved for the transactional vault migration.
@@ -0,0 +1,81 @@
---
title: blog-topic / api-deprecation-sunset-header-migration-window
source_type: blog-topic
status: raw
related_branches: [feature-api-compatibility-deprecation-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, api-design, ietf, api-contract, version-scheme]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: api-deprecation-sunset-header-migration-window
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-api-compatibility-deprecation-contract]] — API deprecation과 compatibility contract 설계에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-api-compatibility-deprecation-contract]]
## 글감 / Topic seed
- 한 문장 요지: API versioning과 deprecation을 분리하고 `Sunset`/`Deprecation` header, migration window, compatibility fixture를 어떻게 연결할지 정리한다.
- 예상 제목 후보:
- API deprecation은 versioning과 어떻게 다를까
- Sunset header를 보낸다고 deprecation 운영이 끝나는 것은 아니다
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- branch에 D5-D8로 `Sunset`/`Deprecation` header pairing, migration window, compatibility fixture가 정리되어 있다 — 근거 후보: [[raw/branch-notes/feature-api-compatibility-deprecation-contract]] D5-D8, section+line `:143-170`.
- 경험 후보:
- 90/30 migration window와 OpenAPI `deprecated` 근거는 확인 필요 경계로 남아 있다.
- 의견/해석 후보:
- deprecation은 "버전을 하나 더 만드는 일"이 아니라 client에게 시간, 신호, migration path를 제공하는 운영 계약이다.
## Outline seed
1. versioning과 deprecation을 분리하기 — 새 버전 제공과 기존 surface 종료 예고는 다른 문제다.
2. HTTP header는 사람용 공지가 아니라 machine-readable signal이다 — `Sunset`, `Deprecation`, `Link` 관계를 나눈다.
3. migration window는 project policy다 — 외부 표준처럼 말하지 않고 ca-tmpl 결정으로 표시한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/api-evolution-and-schema.md` 후보:
- ca-tmpl API deprecation project decision.
- `wiki/concepts/api-evolution-and-schema.md` 후보:
- API compatibility, deprecation, sunset header 일반 개념.
- 필요한 추가 검증:
- OpenAPI `deprecated` 근거, migration window의 project-local status, header pairing source claim.
## Sources / 근거 후보
- [[raw/branch-notes/feature-api-compatibility-deprecation-contract]] — D5-D8 deprecation decision 근거.
## 미해결 / Unknown
- 아직 확인해야 할 사실: 90/30 window와 OpenAPI deprecated marker가 source-backed인지 project convention인지.
- 과장하면 안 되는 부분: 실제 운영 deprecation 경험처럼 쓰면 안 된다. ca-tmpl은 운영 배포 검증이 없다.
- 블로그로 쓰기 전에 필요한 canonical 정제: `UNSUPPORTED_DECISION`과 source-backed decision 분리.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/api-evolution-and-schema.md` 의 compatibility/deprecation documented-only 섹션에 반영했다. 일반 개념은 기존 `wiki/concepts/api-evolution-and-schema.md``Sunset` / `Deprecation` 역할 분리와 OpenAPI marker 근거를 이미 포함한다.
- 다음 단계: source canonical이 `verified` 상태이므로 이후 `blogify` 대상으로 삼을 수 있다. 단 블로그 본문에서는 90/30 window를 project-local policy로 표기하고 운영 경험처럼 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-api-compatibility-deprecation-contract]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/api-deprecation-sunset-header-migration-window-YYYY-MM-DD.md` 후보
@@ -0,0 +1,87 @@
---
title: blog-topic / archunit-generic-return-type-purity-query-port-2026-06-05
source_type: blog-topic
status: raw
related_branches: [feature-application-query-bypass-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, archunit, fitness-function, cqrs, read-model, generics, clean-architecture]
created: 2026-06-05
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: archunit-generic-return-type-purity-query-port-2026-06-05
> Layer: `raw/blog-topics/` — 채용공고가 아닌 작업·학습·트러블슈팅에서 나온 블로그 글감 원석. canonical 정제 전 raw 후보이며, `wiki/blog/` 직접 생성 근거가 아니다.
## Parent / 부모
- [[raw/branch-notes/feature-application-query-bypass-contract]] — D1 purity guardrail 구현(`query_ports_do_not_leak_domain_jpa_or_web_types`)에서 추출. 본 글감은 그 ArchUnit rule 의 *generic type argument 검사* 기법 단독 추출.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- CQRS-lite projection read 의 핵심 가드레일("read port 가 도메인/JPA/web 타입을 누출하지 않는다")을 ArchUnit 으로 *기계 강제*하려 했는데, `List<DomainType>` 같은 **generic type argument 누출**이 통상적인 raw-return-type 검사(`notHaveRawReturnType`)로는 안 잡힌다는 점.
## 글감 코어 / Core idea
- **문제**: `methods().should().notHaveRawReturnType(...)` 는 메서드 반환의 *raw type* 만 본다. `List<WorkLog>` 의 raw type 은 `java.util.List` 라 통과 → 도메인 aggregate 가 generic 인자로 조용히 누출.
- **해결**: custom `ArchCondition<JavaMethod>` 에서 `method.getReturnType().getAllInvolvedRawTypes()` 사용. 이 API 는 반환 타입 + **모든 generic 인자를 재귀적으로** erasure 로 평탄화한다(예: `Map<? extends Serializable, List<Integer>>``[Map, Serializable, List, Integer]`). 평탄화된 각 `JavaClass` 를 금지 패키지 술어(`resideInAnyPackage(..domain.., ..adapter.., jakarta.persistence.., org.springframework.web.., ...)`)로 검사.
- **검증 (violations-as-data + over-block)**: ① raw-leak fixture(도메인 타입 직접 반환), ② **generic-only leak fixture**(`List<FakeDomainEntity>` — raw 검사라면 vacuous pass 할 케이스로 generic 검사 자체를 증명), ③ over-block guard(`List<String>` 반환 clean port 는 미플래그). 셋을 격리 corpus 로 각각 평가.
- **타겟팅**: naming convention 으로 read port 식별 — `..application..` 패키지 + simple name `*QueryPort`. through-aggregate read(repository port → 도메인 aggregate)는 의도적으로 rule scope 밖(코어가 강제하는 건 purity 가드레일뿐, projection 사용 자체는 프로젝트 선택).
## 글감 / Topic seed
- 한 문장 요지: query port return type purity는 raw return type만 보면 generic 인자 leak을 놓치므로 ArchUnit signature traversal로 보강해야 한다.
- 예상 제목 후보:
- Java generics 때문에 새는 query port purity를 ArchUnit으로 잡기
- `List<DomainType>` leak을 정적 분석으로 막는 방법
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- `notHaveRawReturnType``List<DomainType>``DomainType` 인자를 보지 못한다.
- `getAllInvolvedRawTypes()`는 return type과 generic 인자를 평탄화해 검사할 수 있다.
- 의견/해석 후보:
- read projection port의 순수성은 raw type뿐 아니라 signature 전체를 봐야 유지된다.
## Outline seed
1. raw return type 검사만으로는 generic-only leak이 통과하는 이유를 설명한다.
2. `getAllInvolvedRawTypes()`로 signature 전체를 평탄화하는 방식을 정리한다.
3. violations-as-data fixture와 over-block guard로 rule의 non-vacuity를 확인한다.
## 왜 의미 있나 / Why it matters
- "아키텍처 규칙을 코드리뷰 신뢰가 아니라 fitness function 으로 기계 강제" 라는 스켈레톤 가치의 구체 사례. 특히 Java generics 의 type erasure 가 정적 분석의 사각지대를 만드는 지점을 ArchUnit 의 signature traversal API 로 메우는 패턴.
- 한계: bytecode 의 generic signature 에 의존 → reflection/`Object` 다운캐스트로 우회하는 누출은 못 잡음(정적 분석 공통 한계). 글에서 이 경계를 솔직히 명시할 것.
## 관련 / Related
- [[raw/blog-topics/archunit-violations-as-data-pattern-2026-05-28]] — fixture 로 rule 을 역검증하는 상위 패턴.
- [[raw/branch-notes/feature-application-port-usecase-contract]] — `QueryUseCase` / capability fitness function(본 rule 이 보완하는 선행 계약).
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/clean-architecture-package-layout.md` 후보:
- application query port generic return type purity guardrail 글감.
- 필요한 추가 검증:
- 현재 ca-tmpl 코드의 `query_ports_do_not_leak_domain_jpa_or_web_types` rule과 fixture 존재 여부.
## Sources / 근거 후보
- [[raw/branch-notes/feature-application-query-bypass-contract]]
- [[raw/blog-topics/archunit-violations-as-data-pattern-2026-05-28]]
## 미해결 / Unknown
- 아직 확인해야 할 사실: generic signature traversal rule의 현재 구현 위치와 테스트명.
- 과장하면 안 되는 부분: 정적 분석이 reflection/Object downcast 누출까지 잡는다고 쓰지 않는다.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/clean-architecture-package-layout.md` 에 query port generic return type purity guardrail 글감으로 반영한다.
- 다음 단계: source canonical은 일부 verified 영역을 포함하지만, 이 specific rule은 blogify 전 code/branch evidence 재확인이 필요하다.
@@ -0,0 +1,101 @@
---
title: blog-topic / archunit-jackson-default-typing-cve-2019-14379-block-2026-05-29
source_type: blog-topic
status: raw
related_branches: [feature-boundary-validation-mapping-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, archunit, jackson, security, cve, fitness-function, polymorphic-deserialization]
created: 2026-05-29
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: archunit-jackson-default-typing-cve-2019-14379-block-2026-05-29
> Layer: `raw/blog-topics/` — 채용공고가 아닌 작업·학습·트러블슈팅에서 나온 블로그 글감 원석. canonical 정제 전 raw 후보이며, `wiki/blog/` 직접 생성 근거가 아니다.
## Parent / 부모
- [[raw/branch-notes/feature-boundary-validation-mapping-contract]] — B5 결정 + Claims to Verify 의 두 행 `actually-implemented` 승급. 본 글감은 그 enforcement 패스의 보안 측면 단독 추출.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-05-29
- 트리거 연결 노트: [[raw/branch-notes/feature-boundary-validation-mapping-contract]] — 1차 enforcement 패스에서 `no_jackson_laissez_faire_subtype_validator` + `no_jackson_enable_default_typing_call` 두 ArchUnit 규칙 + `DefaultTypingFixture` violations-as-data 테스트로 CVE-2019-14379 의 코드 진입점을 정적으로 차단한 작업.
## 글감 / Topic seed
- 한 문장 요지: Jackson polymorphic deserialization 의 RCE 게이트 (`ObjectMapper.enableDefaultTyping()` / `activateDefaultTyping(LaissezFaireSubTypeValidator)`) 는 *런타임 dependency check 가 아니라 컴파일/테스트 단계의 fitness function* 으로 막아야 안전하다 — 한번 머지된 뒤에는 production 트래픽으로 RCE 가 터지는 게 검출 시점이므로 너무 늦다.
- 떠오른 계기: feature-boundary-validation-mapping-contract B5 결정의 enforcement 단계. CVE 자체의 발견 (2019) 과 Jackson 2.10 의 `@Deprecated` 조치 (2019-09) 가 있었음에도, *우리 코드가 호출하지 않는다* 는 사실은 매 PR 마다 사람이 보장해야 하는 규약이었다. 이걸 ArchUnit fitness function 으로 commit 으로 박은 사례.
- 예상 제목 후보:
- CVE-2019-14379 의 호출 경로를 ArchUnit 으로 정적 봉쇄하기
- "Jackson 을 안전하게 쓴다" 를 컨벤션이 아닌 fitness function 으로 박는 방법
- `enableDefaultTyping()` 한 줄이 RCE 가 되는 이유와 정적 차단
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- CVE-2019-14379 의 RCE 경로는 `ObjectMapper.enableDefaultTyping()` 활성화 + `ehcache` 같은 gadget class 가 classpath 에 있는 조건 — 근거: NVD CVE-2019-14379 (CVSS 9.8), Jackson 보안 가이드 `enableDefaultTyping``@Deprecated since 2.10` 표기.
- Jackson 2.10 의 공식 대체 API 는 `activateDefaultTyping(PolymorphicTypeValidator)` 이며, allowlist 구현체 `BasicPolymorphicTypeValidator` 또는 `@JsonTypeInfo(use = NAME) + @JsonSubTypes` 명시가 정식 — 근거: `raw/official-docs/schema-jackson-polymorphic-deserialization.md#JACK-POLY-C1..C5`.
- `LaissezFaireSubTypeValidator`*모든 subtype 허용* 의 명시적 anti-allowlist — 클래스 이름 그 자체가 "보안 검증 없음" 의 표지 — 근거: `JACK-POLY-C2`.
- ca-tmpl 의 적용: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java` 에 두 규칙 추가 — `no_jackson_laissez_faire_subtype_validator` (클래스 참조 차단), `no_jackson_enable_default_typing_call` (메서드 호출 차단). 위반 fixture 는 `violations/boundary/DefaultTypingFixture.java`.
- 규칙 두 개를 분리한 이유: `LaissezFaireSubTypeValidator` *없이* `enableDefaultTyping()` 만 호출해도 (`ObjectMapper.DefaultTyping.NON_FINAL` 등 deprecated overload) 위험. 호출 차단과 import 차단이 *독립적인 진입점* 이므로 둘 다 닫아야 한다.
- 경험 후보:
- ArchUnit DSL 의 `callMethodWhere(target(name(...)))` 패턴은 호출자 의도와 무관하게 *메서드 이름* 으로 catch. `enableDefaultTyping` 이라는 메서드 이름이 ObjectMapper 외부에 존재할 가능성이 거의 0 이므로 owner 필터를 생략해도 false positive 없음 — 단순 규칙이 충분.
- violations-as-data fixture 는 `DefaultTypingFixture.unsafe()` 한 메서드로 두 규칙을 동시에 catch (호출 + 참조). 한 fixture 가 *서로 다른 규칙 두 개를 동시에 검증* 하는 케이스 — 규칙별 1:1 fixture 가 아니어도 됨.
- `app-bootstrap` 의 test classpath 에 Jackson 이 없어서 컴파일 실패 → `testImplementation 'org.springframework.boot:spring-boot-starter-json'` 추가. *fitness function 자체에 의존성을 끌어들이는 cost* 가 있음을 의식해야 한다.
- 의견 / 해석 후보:
- 라이브러리 *버전* 차단 (jackson-databind ≥ 2.10) 은 supply-chain branch 의 책임이지만, *코드 사용 차단* (deprecated API 호출 금지) 은 boundary contract 의 책임. 두 가지를 같은 PR 에서 묶으면 책임 경계가 흐려진다.
- "CVE 가 알려진 후에는 사람이 review 로 막으면 된다" 는 흔한 반론은 *시간 경과에 따른 attention decay* 를 무시한다. 5년 뒤 합류한 신입이 PR review 할 때 `enableDefaultTyping` 이 안전한지 즉시 판단하기 어렵다 — fitness function 은 *지식 보존 비용* 의 외부화.
- Jackson 2.15 의 sealed type 자동 인식 같은 *조용한 행동 변화* 가 들어와도, 본 규칙은 호출 자체를 차단하므로 회귀 없음 — Jackson 의 mitigation 진화를 기다리는 대신 *진입점 자체를 봉쇄* 하는 전략의 정당화.
## Outline seed
1. CVE-2019-14379 의 한 줄 — `enableDefaultTyping()` + ehcache gadget 으로 RCE (CVSS 9.8).
2. Jackson 의 공식 대응 — 2.10 `@Deprecated` + `PolymorphicTypeValidator` + `BasicPolymorphicTypeValidator` allowlist.
3. 하지만 *우리 코드가 호출하지 않는다* 는 사실의 유지비용 — review fatigue, 시간 경과, 신입 합류.
4. fitness function 으로의 외부화 — ArchUnit 규칙 두 개 (`callMethodWhere` + `dependOnClassesThat`) 의 의도와 분리 이유.
5. violations-as-data 로 *규칙이 실제로 catch 하는지* 박기 — `DefaultTypingFixture` 한 메서드가 두 규칙을 동시에 검증.
6. fitness function 의 비용 — `spring-boot-starter-json` 을 test classpath 에 끌어들임.
7. 보안 책임의 경계 — *코드 사용 차단* (boundary contract) vs *버전 차단* (supply-chain) 의 분리.
8. 정리 — CVE 차단은 *지식 보존 비용* 의 코드화. fitness function 이 review 의 검토 부담을 commit 으로 이전한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/archunit-jackson-cve-block.md` 후보:
- 실제 두 규칙 + `DefaultTypingFixture` 의 코드 발췌.
- `testImplementation 'spring-boot-starter-json'` 추가의 비용.
- violations-as-data 1 fixture × 2 negative test 패턴.
- `wiki/concepts/fitness-function-for-known-cve.md` 후보:
- "*우리 코드가 호출하지 않는다*" 를 review 로 유지하는 비용 분석.
- 코드 사용 차단 vs 버전 차단의 책임 경계.
- 라이브러리 mitigation 진화와 *진입점 차단* 전략의 trade-off.
- 필요한 추가 검증:
- `BasicPolymorphicTypeValidator` 사용을 *권장* 하는 메시지를 ArchUnit 위반 메시지에 포함할 가치가 있는지 — false-positive 시 개발자가 즉시 대안을 알 수 있게.
- sealed `Command` 타입 도입 시 본 규칙이 `@JsonTypeInfo` 명시 패턴과 충돌하지 않는지 (충돌 없음 가설 — 검증은 future use case 에서).
## Sources / 근거 후보
- [[raw/branch-notes/feature-boundary-validation-mapping-contract]] — B5 결정 + 1차 enforcement 패스 결과.
- [[raw/official-docs/schema-jackson-polymorphic-deserialization]] — `JACK-POLY-C1..C5` (CVE, deprecated API, allowlist 표준).
- [[raw/blog-topics/archunit-violations-as-data-pattern-2026-05-28]] — violations-as-data 메타 패턴. 본 글감은 그 패턴의 *보안* 특화 사례.
- [[raw/branch-notes/feature-architecture-enforcement-rules]] — 같은 fitness-function 자체 검증 라인.
## 미해결 / Unknown
- 아직 확인해야 할 사실: violation message에 `BasicPolymorphicTypeValidator` 대안을 포함할지 여부.
- 과장하면 안 되는 부분: ArchUnit rule은 ca-tmpl 코드의 특정 호출/참조를 차단하는 것이며, Jackson RCE 일반 위험을 모두 제거한다고 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-boundary-validation-mapping-contract]]
- 관련 raw topic: [[raw/blog-topics/archunit-violations-as-data-pattern-2026-05-28]]
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/boundary-validation-mapping.md` 에 Jackson default typing CVE static block 글감으로 반영했다.
- 다음 단계: source canonical이 `verified` 상태이므로 이후 `blogify` 대상으로 삼을 수 있다. 단 ArchUnit rule이 탐지 가능한 호출/참조 범위로 제한된다는 점을 유지한다.
@@ -0,0 +1,97 @@
---
title: testCompileOnly 타입을 ArchUnit fixture 에서 안전하게 참조하기 — annotation-only 패턴
source_type: blog-topic
status: raw
related_branches: [feature-streaming-response-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, archunit, gradle, testcompileonly, fixture]
created: 2026-06-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# testCompileOnly 타입을 ArchUnit fixture 에서 안전하게 참조하기
## Parent
- [[raw/branch-notes/feature-streaming-response-contract]]
## 글감 요약
ArchUnit violations-as-data 패턴에서 금지 타입을 `testCompileOnly` 로만 선언할 때 발생하는 `NoClassDefFoundError` 와, **annotation-only 참조** 로 해결하는 패턴.
### 핵심 발견
- `testCompileOnly` jar 는 compile-time 에만 존재 → JUnit 이 class 로드 시 superclass resolve 불가 → `NoClassDefFoundError`.
- ArchUnit 의 `ClassFileImporter` 는 바이트코드 직접 파싱 → class loading 불필요. 문제는 JUnit 스캐닝.
- **annotation 참조** 는 JVM 이 class load 시 즉시 resolve 하지 않으므로 안전.
- `@EnableWebSocket` (spring-websocket) 을 annotation 으로만 달면: (1) ArchUnit 이 `org.springframework.web.socket..` 의존 탐지 성공, (2) runtime classpath 에 jar 없어도 class 로드 성공.
### 독자
Spring Boot + Gradle 멀티모듈 + ArchUnit 조합에서 architecture enforcement 를 구현하는 백엔드 개발자.
### 구성 아이디어
1. 문제: violations-as-data fixture 와 `testCompileOnly` 충돌
2. 원인 분석: JVM class loading vs ArchUnit bytecode parsing
3. 해결: annotation-only 참조 패턴
4. 추가 발견: `jakarta.websocket-api` server-only jar 이슈
5. 패턴 정리표 (annotation / method return type / extends 별 `testCompileOnly` 안전성)
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-06-02
- 트리거 연결 노트: [[raw/branch-notes/feature-streaming-response-contract]]
## 글감 / Topic seed
- 한 문장 요지: `testCompileOnly` 금지 타입을 ArchUnit fixture에서 검출하려면 class loading을 유발하지 않는 annotation-only 참조가 안전하다.
- 예상 제목 후보:
- ArchUnit fixture에서 testCompileOnly 타입을 안전하게 참조하기
- JVM class loading과 ArchUnit bytecode parsing의 차이
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- `testCompileOnly` jar는 JUnit class loading 시점에는 없을 수 있다.
- ArchUnit importer는 bytecode를 직접 읽으므로 annotation 참조만으로 dependency detection이 가능하다.
- 의견/해석 후보:
- violations-as-data fixture는 runtime classpath 안정성까지 고려해야 한다.
## Outline seed
1. `testCompileOnly` fixture가 `NoClassDefFoundError`를 만드는 경로를 설명한다.
2. annotation-only 참조가 왜 class loading을 덜 유발하는지 정리한다.
3. streaming/WebSocket ban rule fixture에 적용할 때의 한계를 적는다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/streaming-response-support.md` 후보:
- streaming/WebSocket ban ArchUnit fixture 안정화 글감.
- `wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard.md` 후보:
- ArchUnit fixture/testing pattern 글감.
- 필요한 추가 검증:
- 현재 fixture가 annotation-only 패턴으로 유지되는지.
## Sources / 근거 후보
- [[raw/branch-notes/feature-streaming-response-contract]]
## 미해결 / Unknown
- 아직 확인해야 할 사실: 현재 test runtime classpath와 fixture 참조 방식.
- 과장하면 안 되는 부분: annotation-only가 모든 `testCompileOnly` 참조를 안전하게 만든다고 일반화하지 않는다.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/streaming-response-support.md``wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard.md` 에 ArchUnit fixture 안정화 글감으로 반영한다.
- 다음 단계: streaming canonical은 verified지만, fixture classpath 세부는 blogify 전 재확인한다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-streaming-response-contract]]
@@ -0,0 +1,111 @@
---
title: blog-topic / archunit-violations-as-data-pattern-2026-05-28
source_type: blog-topic
status: raw
related_branches: [feature-architecture-enforcement-rules, feature-application-port-usecase-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, archunit, testing, fitness-function, spring-modulith, negative-test]
created: 2026-05-28
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: archunit-violations-as-data-pattern-2026-05-28
> Layer: `raw/blog-topics/` — 채용공고가 아닌 작업·학습·트러블슈팅에서 나온 블로그 글감 원석. canonical 정제 전 raw 후보이며, `wiki/blog/` 직접 생성 근거가 아니다.
## Parent / 부모
- [[raw/branch-notes/feature-architecture-enforcement-rules]] — `violations-as-data` pattern 을 Claims to Verify 의 `planned` 에서 `actually-implemented` 로 승급시킨 round 2 작업.
- [[raw/branch-notes/feature-application-port-usecase-contract]] — D14 (KEYED idempotency freeze) 의 ArchUnit custom condition 도 같은 fixture 로 보증.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-05-28
- 트리거 연결 노트: [[raw/branch-notes/feature-architecture-enforcement-rules]] — Claims to Verify 의 마지막 행 ("ArchUnit rule 이 위반을 실제로 catch 한다는 commit 된 증명") 을 Spring Modulith `example/ninvalid` 패턴으로 구현한 작업.
## 글감 / Topic seed
- 한 문장 요지: ArchUnit rule 은 _없는 위반_ 에 대해 vacuously pass 한다 — production 코드에 위반이 우연히 없을 때도, 분석 scope 자체가 비어있을 때도 동일하게 SUCCESS. **위반 fixture + negative test**_rule 이 실제로 catch 하는지_ 를 commit 으로 박아두지 않으면 silent regression 이 누적된다.
- 떠오른 계기: round 1 작업에서 발견한 [[raw/errors/archunit-test-scope-sample-ticket-inclusion-2026-05-28]] (ArchUnit scope 가 production classpath 만 보고 vacuous pass 한 사례) + Spring Modulith 의 `example/ninvalid` 패턴 발견.
- 예상 제목 후보:
- ArchUnit rule 을 _믿을 수 있게_ 만드는 violations-as-data 패턴
- Spring Modulith 의 `example/ninvalid` 를 ca-skeleton 에 차용한 6개 negative test
- "rule 이 작동하는지" 를 commit 으로 박아두기 — fitness function 의 self-verification
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- ArchUnit 의 vacuous pass 함정은 두 갈래로 발생 — (a) production code 에 위반이 우연히 없음, (b) `@AnalyzeClasses` 의 import scope 가 비어 있음 (classpath 누락) — 근거: [[raw/errors/archunit-test-scope-sample-ticket-inclusion-2026-05-28]] §직접 원인.
- Spring Modulith 공식 incubator 가 _자기 rule 들을 검증_ 하기 위해 `example/ninvalid` fixture package 와 `modules.detectViolations().getMessages()` assertion 을 사용 — 근거: `raw/company-tech-blogs/spring-modulith-archunit-generated-exemption-and-violations-as-data.md#SPRING-MOD-AU-C2` (`SPRING-MOD-AU-C2`).
- ca-tmpl 의 적용: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/` 에 6 fixture (domain 1 + application 5) + `ArchitectureViolationFixtureTest` 에 6 negative test — 근거: `feature-architecture-enforcement-rules.md` 구현 결과 round 2 + Claims to Verify 마지막 행 → `actually-implemented`.
- fixture 는 `src/test/...` 위치이므로 main `@AnalyzeClasses(importOptions = DoNotIncludeTests.class)`_자동으로_ 제외됨 — main suite 가 fixture 때문에 실패하지 않음 — 근거: `feature-architecture-enforcement-rules.md` 진행 중 메모 round 2.
- negative test 는 `new ClassFileImporter().importPackages("...violations")` 로 fixture __ 로드한 뒤 rule 의 `EvaluationResult.hasViolation() == true` 를 단순 assert — 근거: `src/app-bootstrap/src/test/.../ArchitectureViolationFixtureTest.java`.
- 경험 후보:
- KEYED idempotency rule (D14) 은 ArchUnit DSL 로 표현 불가능 → custom `ArchCondition<JavaClass>` 작성. `JavaAnnotation.get("idempotency")``JavaEnumConstant` 를 반환하므로 bytecode 만으로 enum value 검사 가능 (reflection 없음) — 근거: `CleanArchitectureTest#notDeclareKeyedIdempotency` 메서드 + branch-note D14.
- `TransactionalAnnotatedFixture``@Transactional` 을 import 하려면 `app-bootstrap/build.gradle``testCompileOnly 'org.springframework:spring-tx'` 가 필요. production scope 에 영향 없음 (test 만) — 근거: `feature-architecture-enforcement-rules.md` round 2 메모.
- fixture 클래스를 `package-private` 으로 유지해 _외부 사용 불가_ 명시 + ArchUnit fixture import 만 동작 — 의도 노이즈 차단.
- 의견 / 해석 후보:
- ArchUnit rule 은 _코드_ 다. 코드는 테스트 없이 믿으면 안 된다 — fitness function 도 동일.
- vacuous pass 는 _"rule 이 안 잡혔다"_ 가 아니라 _"rule 이 무엇을 잡는지 아무도 검증 안 했다"_ 의 신호. 본 패턴은 후자를 commit 으로 박는 게 목적.
- **간단한 rule (`noClasses().that(pkg).should().dependOn(pkg2)`) 은 vacuous pass 위험이 _제일 큼_** — 술어가 단순할수록 production 매칭이 우연히 0개가 되기 쉽다. _복잡한 custom condition (D14) 은 명시적으로 짠 거니까 더 안전_ 이라는 직관과 반대.
- Spring Modulith 가 _자기 자신_ 을 검증하는 데 쓰는 패턴이라는 점이 글의 강한 thesis — "rule 의 production-readiness 의 골든 스탠다드".
## Outline seed
1. 동기 — ArchUnit 의 vacuous pass 함정 두 갈래 → **rule 이 잡는다고 _믿는_ 것과 _증명_ 하는 것의 차이.**
2. Spring Modulith 의 self-verification 패턴 (`example/ninvalid` + `detectViolations().getMessages()`) → **OSS 공식 incubator 가 자기 rule 을 같은 방식으로 검증한다는 신뢰 신호.**
3. ca-tmpl 의 차용 — `violations/` package + `ArchitectureViolationFixtureTest`**6 fixture × 6 negative test 의 1:1 매칭.**
4. fixture 의 위치 결정 — `src/test/...` 안에 두면 main `DoNotIncludeTests` 가 자동 제외 → **main suite 와 negative test 가 _서로를 깨뜨리지 않는_ 격리.**
5. custom ArchCondition (D14) — `JavaAnnotation.get(...)` 로 enum value 검사 → **reflection 없이 bytecode 만으로 annotation parameter catch 가능.**
6. fixture 의 deps — `testCompileOnly 'org.springframework:spring-tx'` 의 비대칭 의존 → **`@Transactional`_import_ 만 하고 production scope 에는 안 들어감.**
7. 한계 — string-key bean lookup / `Class.forName(String)` 의 bypass 는 여전히 catch 불가 (D12) → **fitness function 의 정직한 한계.**
8. 정리 — rule 은 코드다. 코드는 negative test 없이 믿지 말자.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/archunit-violations-as-data.md` 후보:
- 실제 6 fixture + 6 negative test 의 코드 발췌.
- `testCompileOnly 'org.springframework:spring-tx'` 비대칭 의존 패턴.
- `JavaAnnotation.get("idempotency")` custom condition 코드.
- `wiki/concepts/archunit-violations-as-data.md` 후보:
- "vacuous pass 함정 두 갈래" 의 project-agnostic 정리.
- Spring Modulith `example/ninvalid` 패턴의 일반화.
- test-scope fixture + main DoNotIncludeTests 의 격리 패턴.
- 필요한 추가 검증:
- 본 패턴이 _큰 codebase_ (rule 수십 개) 에 적용했을 때 negative test 가 production rule 의 작은 변경에 같이 깨지는지 (regression sensitivity 측정).
- `feature-archunit-negative-fixture-baseline` 같은 후속 branch 를 분리할 가치가 있는지.
## Sources / 근거 후보
- [[raw/branch-notes/feature-architecture-enforcement-rules]] — Claims to Verify 마지막 행 `actually-implemented` 승급 + 진행 중 메모 round 2 + Closure 갱신.
- [[raw/branch-notes/feature-application-port-usecase-contract]] — D14 의 custom ArchCondition 으로 KEYED enum value catch.
- [[raw/errors/archunit-test-scope-sample-ticket-inclusion-2026-05-28]] — vacuous pass 의 두 번째 갈래 (classpath scope) 의 실 사례.
- [[raw/company-tech-blogs/spring-modulith-archunit-generated-exemption-and-violations-as-data]] — `example/ninvalid` 패턴 (`SPRING-MOD-AU-C2`) + `annotatedWith(Generated.class)` 예시 (`SPRING-MOD-AU-C1`).
- [[raw/official-docs/archunit-user-guide]] — `JavaAnnotation` / `JavaEnumConstant` / `EvaluationResult.hasViolation()` 의 공식 API 근거 (`ARCHUNIT-UG-C5`).
- [[raw/interviews/archunit-static-analysis-limits]] — 같은 작업에서 파생된 면접 질문.
## 미해결 / Unknown
- 아직 확인해야 할 사실: negative test 가 _rule wording 변경_ 에 얼마나 민감하게 깨지는지 — false-positive regression 비용 vs 진짜 regression catch 비용의 균형.
- 아직 확인해야 할 사실: fixture 클래스의 __ 가 늘어날 때 (예: 50 rule × 50 fixture) 관리 비용. Spring Modulith 의 실 fixture 디렉터리 크기와 비교 필요.
- 과장하면 안 되는 부분: 본 패턴은 _ArchUnit static analysis 의 한계 (D12 string bypass)_ 를 보완하지 _않는다_. negative test 도 정적이라 reflection bypass 는 잡지 못함.
- 과장하면 안 되는 부분: ca-tmpl 의 6 fixture / 6 test 는 _proof-of-concept_ 규모. 실 사업 도메인의 30+ rule 적용 시 관리 비용 측정 미수행.
- 블로그로 쓰기 전에 필요한 canonical 정제: `wiki/projects/ca-tmpl/archunit-violations-as-data.md` + `wiki/concepts/archunit-violations-as-data.md` 정제. 1~2개 추가 branch 적용 사례 누적.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/clean-architecture-package-layout.md``wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard.md` 에 violations-as-data / negative fixture 글감으로 반영한다.
- 다음 단계: blogify 전 적용 branch 수와 fixture 관리 비용을 확인한다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-architecture-enforcement-rules]] (본 패턴의 직접 적용), [[raw/branch-notes/feature-application-port-usecase-contract]] (D14 custom condition 의 negative test).
- 관련 errors: [[raw/errors/archunit-test-scope-sample-ticket-inclusion-2026-05-28]] (vacuous pass 의 다른 갈래).
- 관련 interview prep: [[raw/interviews/archunit-static-analysis-limits]] (static analysis 한계 + violations-as-data 보완), [[raw/interviews/clean-architecture-boundary-enforcement]] (선행 — boundary 자동 검증의 자매 토픽).
- 관련 blog topics: [[raw/blog-topics/clean-architecture-boundary-enforcement-2026-05-28]] (boundary 자동 검증의 자매 글감).
- derived blog: 생성 전. 생성 시 `wiki/blog/archunit-violations-as-data-pattern-YYYY-MM-DD.md` 후보.
@@ -0,0 +1,81 @@
---
title: blog-topic / binary-readiness-scorecard-clean-architecture-skeleton
source_type: blog-topic
status: raw
related_branches: [feature-implementation-readiness-scorecard]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, architecture, testing, ci-cd, clean-architecture, api-contract]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: binary-readiness-scorecard-clean-architecture-skeleton
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-implementation-readiness-scorecard]] — 15-area binary readiness scorecard와 dry-run evidence에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-implementation-readiness-scorecard]]
## 글감 / Topic seed
- 한 문장 요지: "좋아 보이는 skeleton"과 "도입 가능한 skeleton"을 15개 영역의 binary gate로 분리한 이유를 정리한다.
- 예상 제목 후보:
- Clean Architecture skeleton의 준비 상태를 점수화해 본 이유
- 도입 가능한 skeleton인지 판단하는 binary scorecard
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- branch에 15-area binary readiness scorecard, dry-run evidence, local Gradle/shell gate 통과 기록이 있다 — 근거 후보: [[raw/branch-notes/feature-implementation-readiness-scorecard]] section+line `:25-28`, `:137-160`, `:179-187`, `:298-304`.
- 경험 후보:
- hosted CI/provenance는 미확인으로 남아 있어 local evidence와 hosted evidence를 분리해야 한다.
- 의견/해석 후보:
- readiness는 감상적 완성도가 아니라 "새 프로젝트가 복제했을 때 어떤 계약이 실행되는가"로 봐야 한다.
## Outline seed
1. skeleton은 README보다 gate가 중요하다 — 도입자는 설명보다 실패 조건을 믿는다.
2. binary scorecard의 장점과 손실 — 애매한 점수보다 통과/미통과가 action을 만든다.
3. local evidence와 hosted evidence를 분리하기 — 내 컴퓨터에서 통과한 것과 CI/provenance는 다른 주장이다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/skeleton-readiness-scorecard.md` 후보:
- ca-tmpl readiness scorecard 적용 사실.
- `wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard.md` 후보:
- governance/verification/test/scorecard 통합 문서로 병합 가능.
- 필요한 추가 검증:
- 15개 area 목록, pass/fail 산식, hosted CI/provenance 미확인 경계.
## Sources / 근거 후보
- [[raw/branch-notes/feature-implementation-readiness-scorecard]] — readiness scorecard와 dry-run evidence 근거.
## 미해결 / Unknown
- 아직 확인해야 할 사실: hosted CI, provenance, external adoption 여부.
- 과장하면 안 되는 부분: local readiness를 production readiness나 외부 채택 가능성으로 확대하지 않는다.
- 블로그로 쓰기 전에 필요한 canonical 정제: scorecard 결과와 evidence grade 분리.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard.md` 에 binary readiness scorecard 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 review/verify가 필요하다. local readiness를 production readiness로 확대하지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-implementation-readiness-scorecard]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/binary-readiness-scorecard-clean-architecture-skeleton-YYYY-MM-DD.md` 후보
@@ -0,0 +1,82 @@
---
title: blog-topic / boundary-validation-mapper-responsibility-map
source_type: blog-topic
status: raw
related_branches: [feature-boundary-validation-mapping-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, validation, mapper, bean-validation, partial-update, anti-corruption-layer]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: boundary-validation-mapper-responsibility-map
> Layer: `raw/blog-topics/` — 채용공고가 아닌 작업·학습·트러블슈팅에서 나온 블로그 글감 원석. 다듬어진 블로그 초안은 canonical (`wiki/concepts/` 또는 `wiki/projects/`) 정제 후 `/blogify` 또는 수동 작성으로 `wiki/blog/`에 별도 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-boundary-validation-mapping-contract]] — 입력 경계 검증과 DTO-domain mapper 책임을 B1-B8로 분리한 verified branch에서 나온 상위 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-boundary-validation-mapping-contract]]
## 글감 / Topic seed
- 한 문장 요지: 입력 syntax, application policy, domain invariant, persistence integrity, mapper normalization 책임을 한 계층에 몰아넣지 않고 경계별로 나눈 이유를 정리한다.
- 예상 제목 후보:
- Clean Architecture에서 validation과 mapper 책임을 나누는 법
- DTO mapper를 단순 변환기가 아니라 경계 정책으로 본 이유
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- B1-B8 결정 묶음이 branch의 decision 영역과 extraction 영역에 존재한다 — 근거 후보: [[raw/branch-notes/feature-boundary-validation-mapping-contract]] section+line `:87-101`, `:385-407`.
- 경험 후보:
- 기존 Jackson default typing CVE 글감은 B5에 가까운 좁은 보안 사례라, B1-B8 전체 책임 지도 글감은 별도로 필요하다 — 근거 후보: lane-01 inventory, 기존 [[raw/blog-topics/archunit-jackson-default-typing-cve-2019-14379-block-2026-05-29]].
- 의견/해석 후보:
- mapper를 "보일러플레이트 제거 도구"로만 보면 normalization, masking, public-field boundary 같은 정책 책임이 흐려진다.
## Outline seed
1. validation이라는 단어가 너무 넓다 — syntax, policy, invariant, persistence integrity는 실패 위치와 책임자가 다르다.
2. mapper는 단순 변환기만은 아니다 — 외부 DTO와 내부 domain 사이에서 normalization과 public-field 정책을 고정한다.
3. ArchUnit rule은 boundary drift를 데이터로 만든다 — 계층 의도를 빌드 실패 조건으로 바꾸는 것이 핵심이다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/boundary-validation-mapping.md` 후보:
- ca-tmpl에서 B1-B8로 검증/매핑 책임을 분리한 프로젝트 결정과 검증 등급.
- `wiki/concepts/boundary-validation-and-dto-mapping.md` 후보:
- Bean Validation, partial update, anti-corruption mapper 책임 분리 일반 개념.
- 필요한 추가 검증:
- B1-B8 항목의 실제 구현/테스트 상태와 `locally-verified` 범위 확인.
## Sources / 근거 후보
- [[raw/branch-notes/feature-boundary-validation-mapping-contract]] — B1-B8 결정과 extraction 후보의 근거.
- [[raw/blog-topics/archunit-jackson-default-typing-cve-2019-14379-block-2026-05-29]] — 좁은 하위 topic과의 중복 경계.
## 미해결 / Unknown
- 아직 확인해야 할 사실: B1-B8 각각의 구현 파일/테스트 anchor.
- 과장하면 안 되는 부분: 모든 validation 책임을 이 구조 하나로 해결한다고 쓰면 안 된다. ca-tmpl의 경계 분리 결정과 검증된 범위로 제한한다.
- 블로그로 쓰기 전에 필요한 canonical 정제: project 문서의 verified 항목과 concept 문서의 일반 개념을 분리.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/boundary-validation-mapping.md` 에 boundary/mapper 책임 분리 글감으로 반영했다.
- 다음 단계: source canonical이 `verified` 상태이므로 이후 `blogify` 대상으로 삼을 수 있다. 단 ca-tmpl 내부 taxonomy와 일반 표준을 혼동하지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-boundary-validation-mapping-contract]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/boundary-validation-mapper-responsibility-map-YYYY-MM-DD.md` 후보
@@ -0,0 +1,81 @@
---
title: blog-topic / cache-backend-router-fail-open-decorator
source_type: blog-topic
status: raw
related_branches: [feature-cachestore-multi-backend-router]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, caching, spring-boot, fail-open-fail-closed, circuit-breaker]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: cache-backend-router-fail-open-decorator
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-cachestore-multi-backend-router]] — cache backend routing과 fail-open decorator 구현 경험에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-cachestore-multi-backend-router]]
## 글감 / Topic seed
- 한 문장 요지: cache 장애를 backend 내부 `try/catch`로 흩뿌리지 않고 router/decorator 조립 계약으로 중앙화한 이유를 정리한다.
- 예상 제목 후보:
- Cache fail-open을 decorator로 분리한 이유
- Cache backend router로 OCP와 장애 정책을 같이 지키기
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- `FailOpenCacheStore`, `CacheStoreRouter`, `CacheBackend` 기여 모델이 implemented/local evidence로 정리됐다 — 근거 후보: [[raw/branch-notes/feature-cachestore-multi-backend-router]] D1-D3, section+line `:78-83`, 구현 결과 `:104-112`, closure `:121-125`.
- 경험 후보:
- backend별 장애 처리 분기가 늘어날수록 정책이 흩어지므로, fail-open/fail-closed를 조립 계층에서 명시하는 편이 낫다.
- 의견/해석 후보:
- cache는 correctness owner가 아니라 availability optimization일 수 있으므로, 실패 정책을 use case 밖에서 드러내야 한다.
## Outline seed
1. cache backend가 늘어나면 장애 정책도 늘어난다 — Redis/Caffeine/noop을 같은 interface로 묶는 것만으로는 부족하다.
2. fail-open은 내부 catch가 아니라 contract다 — 어떤 exception을 삼키고 무엇을 관측할지 중앙에서 정한다.
3. router/decorator 구조가 OCP를 지키는 지점 — backend 추가와 정책 변경을 분리한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound.md` 후보:
- ca-tmpl cache backend router와 fail-open decorator 구현 사실.
- `wiki/concepts/fail-open-fail-closed.md` 후보:
- cache에서 fail-open을 선택할 수 있는 조건과 경계.
- 필요한 추가 검증:
- 실제 class/test anchor와 exception classification 범위.
## Sources / 근거 후보
- [[raw/branch-notes/feature-cachestore-multi-backend-router]] — D1-D3와 구현/검증 결과.
## 미해결 / Unknown
- 아직 확인해야 할 사실: fail-open 적용 대상 exception, metric/log 기록 방식.
- 과장하면 안 되는 부분: 모든 cache 실패를 삼켜도 된다는 뜻이 아니다. ca-tmpl에서 정한 cache role과 검증된 backend 범위로 제한한다.
- 블로그로 쓰기 전에 필요한 canonical 정제: project 문서의 data-layer cache section 보강.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound.md` 에 cache router/decorator 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 review/verify가 필요하다. 모든 cache 실패를 삼켜도 된다고 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-cachestore-multi-backend-router]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/cache-backend-router-fail-open-decorator-YYYY-MM-DD.md` 후보
@@ -0,0 +1,81 @@
---
title: blog-topic / cache-consistency-after-commit-stampede-contract
source_type: blog-topic
status: raw
related_branches: [feature-cache-consistency-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, caching, persistence, spring-boot, transaction-synchronization]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: cache-consistency-after-commit-stampede-contract
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-cache-consistency-contract]] — cache invalidation, stampede, negative cache, consistency window 결정에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-cache-consistency-contract]]
## 글감 / Topic seed
- 한 문장 요지: cache invalidation은 after-commit으로, stampede guard는 single/multi-instance별로, negative TTL과 consistency window는 별도 계약으로 나눠야 한다는 주제.
- 예상 제목 후보:
- cache invalidation을 transaction commit 뒤로 미루는 이유
- cache consistency와 stampede guard를 한데 묶으면 안 되는 이유
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- branch에 D2-D8로 after-commit invalidation, stampede, negative cache, consistency window가 정리되어 있다 — 근거 후보: [[raw/branch-notes/feature-cache-consistency-contract]] D2-D8, section+line `:87-107`, tests `:137-143`.
- 경험 후보:
- D5-D9에는 unsupported/planned 경계가 있어 raw topic 단계에서 보강 필요로 둬야 한다.
- 의견/해석 후보:
- cache consistency는 하나의 기법이 아니라 invalidation timing, concurrency guard, stale window의 조합이다.
## Outline seed
1. commit 전 invalidation의 함정 — DB transaction과 cache state가 엇갈릴 수 있다.
2. stampede guard는 deployment model을 탄다 — single-instance lock과 multi-instance lock은 다른 문제다.
3. negative cache와 consistency window는 숫자 정책이다 — source-backed fact와 project convention을 분리한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound.md` 후보:
- ca-tmpl cache consistency project decision.
- `wiki/concepts/data-layer-persistence-cache-outbound.md` 후보:
- cache-aside, after-commit invalidation, stampede guard 일반 개념.
- 필요한 추가 검증:
- D5-D9의 source support, planned test 구현 여부.
## Sources / 근거 후보
- [[raw/branch-notes/feature-cache-consistency-contract]] — cache consistency decisions.
## 미해결 / Unknown
- 아직 확인해야 할 사실: consistency window 수치와 stampede guard 구현 상태.
- 과장하면 안 되는 부분: planned test와 unsupported decision을 implemented처럼 쓰지 않는다.
- 블로그로 쓰기 전에 필요한 canonical 정제: source-backed claim과 project-local policy 분리.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound.md` 에 after-commit invalidation/stampede 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 review/verify가 필요하다. planned test와 unsupported decision을 implemented처럼 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-cache-consistency-contract]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/cache-consistency-after-commit-stampede-contract-YYYY-MM-DD.md` 후보
@@ -0,0 +1,88 @@
---
title: blog-topic / ci-gate-wiring-vs-policy-ownership
source_type: blog-topic
status: raw
related_branches: [feature-ci-quality-gates-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, ci, github-actions, gradle]
created: 2026-06-20
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: ci-gate-wiring-vs-policy-ownership
> Layer: `raw/blog-topics/` — 작업·트러블슈팅에서 나온 블로그 글감 원석.
> `status_label`: `captured`
## Parent / 부모
- [[raw/branch-notes/feature-ci-quality-gates-contract]] — "어떤 게이트가 도느냐(wiring)" 와 "그 게이트의 도구·임계값(policy)" 을 다른 branch 가 소유하도록 분리한 경험에서 도출.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-06-20
- 트리거 연결 노트: [[raw/branch-notes/feature-ci-quality-gates-contract]]
## 글감 / Topic seed
- 한 줄 요약: CI 품질 게이트를 "배선(wiring)" 과 "정책(policy)" 으로 쪼개면, 20개 게이트를 한 워크플로에 욱여넣지 않고도 소유권이 깨끗해지고 게이트가 silent-pass 하지 않는다.
- 풀어야 할 질문들:
- **소유권 분리.** vulnerability 스캔의 *release-blocking 여부*(wiring)와 *scanner 선택·severity 임계값*(policy)을 왜 다른 owner 가 갖나? → 한 branch 가 둘 다 가지면 정책 변경이 매번 게이트 그래프를 건드려 결합도가 폭증. ca-tmpl 은 `.github/ci-gate-matrix.yml`(20행 SSOT) + `verify-gate-matrix.sh` cross-check 로 "표 ↔ 실제 task/test/job" 정합을 매 PR 강제.
- **fan-in 이 차단을 보장하려면.** `needs + if: success()` aggregator 는 상위 실패 시 *skipped* — 차단 안 됨. `always()` + `needs.*.result` 스캔이라야 "1건 실패 → 릴리스 block". (evidence-first: 공식 문서가 보장하는 건 매핑 가능성뿐, 실제 차단은 의도적 실패 잡으로 검증해야.)
- **위임 게이트의 정직한 표현.** owner branch 가 아직 없는 게이트(SBOM/Cosign/SLSA/gitleaks)는 가짜 통과 잡으로 채우지 말고 `delegated-pending` 으로 *명시적으로 미구현* 이라 표시 → cross-check 가 개수까지 보고.
- 독자가 얻어갈 것: "게이트를 늘리는 것" 보다 "게이트가 실제로 막는지 + 누가 그 정책을 소유하는지" 가 본질이라는 관점.
## 확장 메모 / Expansion notes
- 곁가지: flaky quarantine 의 14일 sunset 을 Gradle 거버넌스 태스크(`verifyQuarantineSunset`)로 강제 — `@Tag("quarantine")` 격리 + repo-루트 레지스트리 + drift/sunset 이중 검사. quarantine 이 *영구 주차장* 이 되는 걸 빌드가 막는다. (별도 글감 가능.)
- 대비 사례: `.trivyignore` suppression 거버넌스([[raw/blog-topics/trivy-suppression-governance-static-gate-2026-06-20]])와 같은 패턴 — "정책 파일 + CI 필드검증 + CODEOWNERS merge 승인" 삼중 통제.
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- CI gate matrix는 gate wiring과 policy owner를 분리해 기록한다.
- GitHub Actions fan-in은 `always()``needs.*.result` 확인을 써야 upstream failure가 skipped로 묻히지 않는다.
- 의견/해석 후보:
- CI gate의 본질은 gate 수가 아니라 실제 차단 여부와 policy ownership의 분리다.
## Outline seed
1. gate wiring과 policy ownership을 분리하는 이유를 설명한다.
2. `needs + if: success()` fan-in의 skipped 함정을 다룬다.
3. delegated-pending gate를 가짜 green으로 만들지 않는 표현 방식을 정리한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/devops-ci-supply-chain-dx.md` 후보:
- CI gate wiring vs policy ownership 글감.
- 필요한 추가 검증:
- 실제 workflow fan-in 실패 검증과 matrix cross-check 구현 여부.
## Sources / 근거 후보
- [[raw/branch-notes/feature-ci-quality-gates-contract]]
- [[raw/errors/ci-fan-in-skipped-not-failed-and-gitignored-config-2026-06-20]]
- [[raw/interviews/ci-release-gate-fan-in-blocking-2026-06-20]]
- [[raw/blog-topics/trivy-suppression-governance-static-gate-2026-06-20]]
## 미해결 / Unknown
- 아직 확인해야 할 사실: hosted CI에서 fan-in 차단 검증이 수행됐는지.
- 과장하면 안 되는 부분: delegated-pending gate를 구현 완료 gate처럼 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-ci-quality-gates-contract]]
- 관련 error: [[raw/errors/ci-fan-in-skipped-not-failed-and-gitignored-config-2026-06-20]]
- 관련 interview: [[raw/interviews/ci-release-gate-fan-in-blocking-2026-06-20]]
- 유사 거버넌스 글감: [[raw/blog-topics/trivy-suppression-governance-static-gate-2026-06-20]]
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/devops-ci-supply-chain-dx.md` 에 CI gate wiring과 policy ownership 분리 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 GitHub Actions fan-in 차단 검증과 delegated-pending 범위를 분리한다.
@@ -0,0 +1,121 @@
---
title: blog-topic / clean-architecture-boundary-enforcement-2026-05-28
source_type: blog-topic
status: raw
related_branches: [feature-architecture-enforcement-rules]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, architecture, testing, archunit, clean-architecture, gradle]
created: 2026-05-28
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: clean-architecture-boundary-enforcement-2026-05-28
> Layer: `raw/blog-topics/` — 채용공고가 아닌 작업·학습·트러블슈팅에서 나온 블로그 글감 원석. canonical 정제 전 raw 후보이며, `wiki/blog/` 직접 생성 근거가 아니다.
## Parent / 부모
- [[raw/branch-notes/feature-architecture-enforcement-rules]] — Clean Architecture 경계를 Gradle / ArchUnit fitness function 으로 실 강제한 구현·검증 경험 (Decisions D1~D10 + Claims to Verify 표).
- [[raw/project-notes/ca-skeleton-operational-contract]] — ca-tmpl skeleton 의 module boundary / operational contract SSOT.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-05-28
- 트리거 연결 노트: [[raw/branch-notes/feature-architecture-enforcement-rules]] — `Decision D1` (CA 경계는 architecture test 로 강제) 을 실제 코드와 테스트로 붙인 작업.
## 글감 / Topic seed
- 한 문장 요지: Clean Architecture 는 문서로만 선언하면 시간이 지나면 무너지므로, **Gradle module dependency rule (1차) + ArchUnit bytecode fitness function (2차)** 으로 _역할을 나눠_ 자동 검증해야 한다. 한쪽만으로는 항상 false-pass 가 남는다.
- 떠오른 계기: `feature-architecture-enforcement-rules` 작업에서 ca-tmpl `src/build.gradle``verifyCleanArchitectureDependencies``src/app-bootstrap/src/test/.../CleanArchitectureTest.java` 를 함께 보강하고, 임시 위반 코드로 red/green 검증까지 마침.
- 예상 제목 후보:
- Clean Architecture 경계를 _문서가 아니라 테스트_ 로 지키기 — Gradle + ArchUnit 의 분업
- Gradle 이 잡는 것 vs ArchUnit 이 잡는 것 — module graph 와 bytecode rule 의 역할 분리
- 빈 anchor module 도 ArchUnit 으로 검증할 수 있을까? — `allowEmptyShould(true)` 의 정직한 사용
## 핵심 주장 후보 / Claim candidates
> 아직 canonical 이 아니다. 사실/경험/의견 후보를 분리한다. 각 항목은 branch-note Decision ID 또는 외부 source claim ID 로 근거를 인용한다.
- 사실 후보:
- ca-tmpl 의 boundary 강제는 **Gradle 의 project-dependency 매트릭스 + ArchUnit fitness function** 두 층으로 구성된다. Gradle 은 _build graph 수준_, ArchUnit 은 _bytecode/import 수준_ 을 막는다. 둘은 잡는 위반의 종류가 다르다 — 근거: `feature-architecture-enforcement-rules.md` D1 (CA 경계 = architecture test 강제), D2 (package rule = Gradle multi-module boundary). 외부 근거: `raw/official-docs/governance-archunit-official.md#AU-OFF-C1`, `raw/official-docs/archunit-conditional-on-property-3-layer-pattern.md#AUCP-C5`.
- `domain-core``org.springframework..`, `jakarta.persistence..`, `..adapter..`, `..application..`, `..bootstrap..` import 모두 금지 — 근거: `feature-architecture-enforcement-rules.md` D3 (domain-core forbidden import). 외부 근거: `raw/company-tech-blogs/woowahan-hexagonal-multimodule.md#WW-HEX-C1` (Domain / Application / Framework / Bootstrap 4-module 격리 사례).
- `application-core``org.springframework.transaction.annotation.Transactional`_직접 import_ 하면 ArchUnit rule 이 실패. Spring 공식은 `@Transactional` 직접 부착을 권고 (다수파) 이므로 ca-tmpl 은 의도적 소수파 결정 — 근거: `feature-architecture-enforcement-rules.md` D8 (application `@Transactional` 직접 import 금지) + `feature-application-port-usecase-contract.md` D3 (TransactionPort abstraction). 외부 근거: `raw/official-docs/at-transactional-spring-official.md#AT-TX-C1` (Spring 공식 권고).
- `production_code_does_not_depend_on_sample_ticket` ArchUnit rule + Gradle `verifyCleanArchitectureDependencies` 가 동시에 sample-ticket 역수입을 차단 — 근거: `feature-architecture-enforcement-rules.md` D7 (sample-ticket production 역수입 금지) + Claims to Verify "production module 이 `sample-ticket` 에 의존하면 실패한다" → `locally-verified`.
- 경험 후보:
- 임시 위반 코드 (`shared.ticket` package, controller 의 domain return, application 의 `@Transactional`, `app-bootstrap -> sample-ticket` Gradle dep) 를 각각 추가해 신규 rule 이 실패함을 red/green 으로 확인 → 위반 제거 후 `cd src && ./gradlew :app-bootstrap:test verifyCleanArchitectureDependencies``cd src && ./gradlew test` 모두 통과 — 근거: `feature-architecture-enforcement-rules.md` §진행 중 메모 2026-05-28 항목 + Closure §`locally-verified`.
- 빈 skeleton anchor module 이 ArchUnit empty-should failure 를 일으켜 `allowEmptyShould(true)`_빈 상태가 의도된 rule 에만_ 선별 적용 — 근거: `feature-architecture-enforcement-rules.md` §마주친 문제 + 파생 에러 [[raw/errors/archunit-empty-should-anchor-2026-05-27]].
- Codex sandbox 의 read-only `~/.gradle` 권한 때문에 wrapper 가 lock 파일을 못 만들어 실행이 실패 → 사용자 승인 escalation 으로 재실행 — 근거: 파생 에러 [[raw/errors/gradle-wrapper-readonly-cache-2026-05-28]].
- 의견 / 해석 후보:
- Gradle 의 project dependency 매트릭스만으로는 _세부 import_ (e.g., controller 가 JPA entity 를 return type 으로 노출) 를 못 잡는다. ArchUnit 이 이걸 보완. 반대로 ArchUnit 만으로는 _module 간 build-graph 사이클_ 을 깔끔하게 못 잡는다. **둘을 분업하는 게 작은 skeleton 에서는 Spring Modulith 도입보다 가볍다** — 근거: `feature-architecture-enforcement-rules.md` §결정 사항 2026-05-27 "Spring Modulith verifier 도입은 out of scope §범위" + D5 Open Risk ("Spring Modulith 없이 public API 강제는 약함").
- **ArchUnit 은 reflection / runtime lookup 우회를 잡지 못한다** (`ApplicationContext#getBean` 류). 이건 ArchUnit 의 한계로 솔직히 인정해야 하며, Sonar custom rule 또는 review checklist 로 보완 — 근거: `feature-architecture-enforcement-rules.md` Claims to Verify "runtime lookup 우회는 ArchUnit 으로 잡히지 않는다" → `planned`.
- **다수파 (`@Transactional` 직접 부착) 도 합리적이다**. ca-tmpl 의 boundary 강제는 _template repository 라서_ 의도적으로 엄격한 소수파. 단일 DB / 단일 transaction manager 상황의 작은 팀은 다수파가 boilerplate 비용 측면에서 낫다 — 근거: `feature-architecture-enforcement-rules.md` D8 Open Risk + `feature-application-port-usecase-contract.md` 외부 근거 §대안 비교.
## Outline seed
> 각 섹션 옆에 `→ 핵심 메시지` 를 함께 명시한다.
1. 동기 — Clean Architecture 를 "했다" 고 말하지만 controller 가 repository 를 import 해도 빌드가 도는 흔한 상황 → **문서만으로는 boundary drift 가 누적된다.**
2. 두 층의 분업 — Gradle project-dependency matrix vs ArchUnit bytecode rule → **각자 잡는 위반 종류가 다르고 한쪽만으로는 false-pass 가 남는다.**
3. 실제 구현 스케치 — `verifyCleanArchitectureDependencies` 의 allowed map + `CleanArchitectureTest` 의 12개 rule → **rule 은 _도메인 추가_ 보다 _도메인 누락_ 으로 더 자주 깨진다 (예: 새 module 추가 시 양쪽 다 업데이트 필요).**
4. red/green 으로 rule 을 _믿을 수 있게_ 만들기 — 임시 위반 코드 4종 추가 → 실패 확인 → 제거 → 통과 → **rule 이 _진짜로 잡는지_ 를 매번 검증하지 않으면 silent regression 이 생긴다.**
5. 빈 anchor 문제 — skeleton template 에서 production 코드가 비어 있는 상태도 valid → **`allowEmptyShould(true)`_빈 상태가 의도된 rule_ 에만 선별 적용. 모든 rule 에 일괄 적용하면 안 됨.**
6. ArchUnit 의 한계와 보완 — runtime reflection / MapStruct generated path / Spring Modulith → **솔직한 한계 인정 + 보완 도구 (Sonar / review checklist / Modulith) 의 분업.**
7. template repository 라서 가능한 엄격함 — production project 와의 trade-off → **boundary 비용을 _learning cost_ 로 흡수할 수 있는 환경에서 강제하라.**
## Canonical 전환 후보 / Canonical extraction candidates
> `wiki/blog/` 로 바로 가지 않는다. 먼저 어떤 canonical 문서로 정제할지 기록한다.
- `wiki/projects/ca-tmpl/architecture-enforcement-rules.md` 후보:
- 실제 적용된 Gradle dependency matrix (모듈별 allowed list).
- 실제 작성된 ArchUnit rule 12종 (이름 + 잡는 위반).
- red/green 검증 절차 (`feature-architecture-enforcement-rules.md` Closure 의 `locally-verified` 5개 항목).
- `wiki/concepts/architecture-enforcement-testing.md` 후보:
- Gradle build-graph rule 과 ArchUnit bytecode rule 의 _역할 분리_ 패턴 (project-agnostic).
- `allowEmptyShould` 와 skeleton template 의 빈 anchor 처리 패턴.
- "ArchUnit 의 한계: runtime reflection / generated code" 일반 원칙.
- 필요한 추가 검증:
- runtime lookup / reflection 우회가 현재 rule 을 실제로 false-pass 하는지 PoC 실험 (`feature-architecture-enforcement-rules.md` Claims to Verify 의 `planned` 항목).
- MapStruct generated mapper exemption 경로 확인 (동일 표의 `needs-confirmation` 항목).
- Spring Modulith 를 도입했을 때 ArchUnit rule 과의 중복/대체 관계.
## Sources / 근거 후보
- [[raw/branch-notes/feature-architecture-enforcement-rules]] — 구현 결정 (D1~D10), 검증 결과, Claims to Verify 의 status grading, Closure 의 `locally-verified` / `documented-only` 분리.
- [[raw/branch-notes/feature-skeleton-package-blueprint-contract]] — module boundary 의 자매 결정 (D1~D8). 본 글의 Gradle dependency matrix 항목은 두 branch 결정의 교집합.
- [[raw/branch-notes/feature-application-port-usecase-contract]] — application 의 `@Transactional` 직접 import 금지 결정 (D3) 와 TransactionPort 추상화. 본 글의 다수파 vs 소수파 trade-off 단락 근거.
- [[raw/errors/archunit-empty-should-anchor-2026-05-27]] — 빈 anchor 와 `allowEmptyShould(true)` 의 선별 적용 사례.
- [[raw/errors/gradle-wrapper-readonly-cache-2026-05-28]] — sandbox 환경에서 build tool 실행 검증의 함정.
- [[raw/errors/archunit-test-scope-sample-ticket-inclusion-2026-05-28]] — `production_code_does_not_depend_on_sample_ticket` rule 의 test-scope inclusion 미묘함.
- [[raw/official-docs/archunit-user-guide]] — ArchUnit rule DSL / JUnit 통합 (`ARCHUNIT-UG-C5`, `ARCHUNIT-UG-C6`).
- [[raw/official-docs/governance-archunit-official]] — architecture test 로 governance 강제하는 일반 근거 (`AU-OFF-C1`, `AU-OFF-C2`).
- [[raw/official-docs/archunit-conditional-on-property-3-layer-pattern]] — predicate/condition 기반 fitness function 의 bytecode 모델 (`AUCP-C1` ~ `AUCP-C5`).
- [[raw/company-tech-blogs/woowahan-hexagonal-multimodule]] — Domain / Application / Framework / Bootstrap 4-module 분리 사례 (`WW-HEX-C1` ~ `WW-HEX-C5`).
- [[raw/company-tech-blogs/modulith-kakaobank-techblog-2025]] — Gradle multi-module + Hexagonal + Spring Modulith 사례 (`KAKAOBANK-MOD-C2`, `KAKAOBANK-MOD-C4`).
- [[raw/interviews/clean-architecture-boundary-enforcement]] — 같은 경험에서 파생된 예상 면접 질문.
## 미해결 / Unknown
- 아직 확인해야 할 사실: runtime lookup / reflection 우회가 현재 ArchUnit rule 을 실제로 false-pass 하는지 실험 미수행 (`feature-architecture-enforcement-rules.md` Claims to Verify `planned`).
- 아직 확인해야 할 사실: MapStruct generated mapper exemption 의 build path 가 실제 빌드 도구 설정에 따라 어떻게 달라지는지 (`feature-architecture-enforcement-rules.md` D9 `UNSUPPORTED_DECISION`).
- 과장하면 안 되는 부분: 본 글의 모든 검증은 **`locally-verified`** 다. ca-tmpl 은 template repository 이고 prod 운영 검증은 없다. 글에 "운영에서 검증된" 같은 표현 금지.
- 과장하면 안 되는 부분: "Gradle + ArchUnit 분업이 Spring Modulith 보다 우월하다" 가 아니라 "_작은 skeleton 에서는_ 가볍다" 까지만 주장 가능. Modulith verifier 를 도입한 사례 (kakaobank) 도 동등한 합리성을 가짐.
- 블로그로 쓰기 전에 필요한 canonical 정제: `wiki/projects/ca-tmpl/architecture-enforcement-rules.md` 를 최신 코드 상태 (모듈 매트릭스, ArchUnit rule 12종 이름) 로 맞춘 뒤 verified 항목만 blog 초안으로 이동.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/clean-architecture-package-layout.md` 에 Gradle + ArchUnit boundary enforcement 글감으로 반영한다.
- 다음 단계: runtime lookup PoC / MapStruct exemption 같은 planned 항목은 blogify 전 과장 금지로 유지한다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-architecture-enforcement-rules]], [[raw/branch-notes/feature-skeleton-package-blueprint-contract]], [[raw/branch-notes/feature-application-port-usecase-contract]] (자매 결정).
- 관련 errors: [[raw/errors/archunit-empty-should-anchor-2026-05-27]], [[raw/errors/gradle-wrapper-readonly-cache-2026-05-28]], [[raw/errors/archunit-test-scope-sample-ticket-inclusion-2026-05-28]].
- 관련 interview prep: [[raw/interviews/clean-architecture-boundary-enforcement]], [[raw/interviews/clean-architecture-module-blueprint]] (자매 질문), [[raw/interviews/transaction-port-vs-spring-transactional]] (`@Transactional` 다수파 vs 소수파 trade-off 단락의 자매).
- 관련 blog topics: [[raw/blog-topics/clean-architecture-module-blueprint-2026-05-28]] (자매 글감), [[raw/blog-topics/transaction-port-abstraction-over-spring-transactional-2026-05-28]] (다수파/소수파 trade-off 글감), [[raw/blog-topics/post-implementation-knowledge-capture-workflow-2026-05-28]] (캡처 워크플로우 자매).
- derived blog: 생성 전. 생성 시 `wiki/blog/clean-architecture-boundary-enforcement-YYYY-MM-DD.md` 후보.
@@ -0,0 +1,127 @@
---
title: blog-topic / clean-architecture-module-blueprint-2026-05-28
source_type: blog-topic
status: raw
related_branches: [feature-skeleton-package-blueprint-contract]
related_projects: [ca-skeleton]
tags: [blog-topic, ca-skeleton, architecture, gradle, clean-architecture, hexagonal, multi-module]
created: 2026-05-28
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: clean-architecture-module-blueprint-2026-05-28
> Layer: `raw/blog-topics/` — 채용공고가 아닌 작업·학습·트러블슈팅에서 나온 블로그 글감 원석. canonical 정제 전 raw 후보이며, `wiki/blog/` 직접 생성 근거가 아니다.
## Parent / 부모
- [[raw/branch-notes/feature-skeleton-package-blueprint-contract]] — Clean Architecture skeleton 의 package/module blueprint 를 single-module feature-first 에서 Gradle multi-module Hexagonal boundary 로 _수정_ 한 결정 (Decisions D1~D8 + Default Module Blueprint tree).
- [[raw/project-notes/ca-skeleton-operational-contract]] — ca-tmpl skeleton 의 운영 계약 SSOT (§20 Skeleton Blueprint Contract 영역).
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-05-27
- 트리거 연결 노트: [[raw/branch-notes/feature-skeleton-package-blueprint-contract]] — 초기 single-module feature-first 결정 (`결정 사항 2026-05-22`) 을 `결정 사항 2026-05-27` 에서 Gradle multi-module Hexagonal 로 _명시적으로 수정_ 한 점이 글감의 핵심 사건.
## 글감 / Topic seed
- 한 문장 요지: Clean Architecture skeleton 은 패키지 이름을 예쁘게 나누는 것만으로는 부족하다. **Gradle module boundary 가 1차 강제선, package 내부 책임이 2차 분류** 가 되어야 새 도메인 기능이 들어와도 경계가 무너지지 않는다.
- 떠오른 계기: ca-tmpl 의 초기 결정이 single-module feature-first 였다가 우아한형제들 / 카카오뱅크 사례 검토 후 multi-module Hexagonal 로 _명시적으로 수정_ 된 과정 — 의사결정의 _뒤집힘_ 자체가 글감.
- 예상 제목 후보:
- Clean Architecture 템플릿에서 package 이름보다 먼저 정해야 할 것 — module boundary
- 우리는 왜 single-module feature-first 에서 multi-module Hexagonal 로 _바꿨나_
- reference code 를 production 에서 빼고 `sample-ticket` 으로 격리한 이유
## 핵심 주장 후보 / Claim candidates
> 각 항목은 branch-note Decision ID 또는 외부 source claim ID 로 근거를 인용한다.
- 사실 후보:
- ca-tmpl 의 기본 module 은 `app-bootstrap`, `domain-core`, `application-core`, `adapter-web`, `adapter-persistence`, `adapter-outbound`, `shared-contract`, `sample-ticket` 8개. module boundary 가 1차 강제선이고 module 내부 package 는 2차 책임 분류 — 근거: `feature-skeleton-package-blueprint-contract.md` D1 (Phase C2 기본 구조 = Gradle multi-module + Clean Architecture / Hexagonal), `결정 사항 2026-05-27` ("module boundary 가 1차 강제선이고, module 내부 package 는 2차 책임 분류"). 외부 근거: `raw/company-tech-blogs/woowahan-hexagonal-multimodule.md#WW-HEX-C1`, `raw/company-tech-blogs/modulith-kakaobank-techblog-2025.md#KAKAOBANK-MOD-C4`.
- `domain-core` 는 framework-neutral POJO 로 유지. Spring annotation, JPA annotation, HTTP DTO 를 모두 모름 — 근거: `feature-skeleton-package-blueprint-contract.md` D2 (domain-core = framework-neutral). 외부 근거: `raw/company-tech-blogs/woowahan-hexagonal-multimodule.md#WW-HEX-C1`, `raw/official-docs/arch-clean-architecture-uncle-bob.md` (Dependency Rule).
- `application-core``domain-core``shared-contract` 에만 의존. adapter 구현체 / Spring Web / JPA / Redis / Kafka / outbound HTTP client 모두 adapter 밖으로 들어오면 안 됨 — 근거: `feature-skeleton-package-blueprint-contract.md` D3 (application-core = domain + shared only). 외부 근거: `raw/company-tech-blogs/woowahan-hexagonal-multimodule.md#WW-HEX-C2`, `raw/company-tech-blogs/modulith-kakaobank-techblog-2025.md#KAKAOBANK-MOD-C4`.
- `shared-contract` 는 response envelope / error code / header / MDC / metric / registry / annotation 같은 _skeleton-wide operational contract_ 만 허용. business / domain concept 는 금지 — 근거: `feature-skeleton-package-blueprint-contract.md` D6 (shared-contract = operational contract only). 외부 근거: `raw/official-docs/feature-first-uncle-bob-screaming-architecture-2011.md#SCREAM-C1`.
- `sample-ticket` 은 fixture/sample consumer 이며 production module 이 import 하거나 dependency 로 선언하면 실패 — 근거: `feature-skeleton-package-blueprint-contract.md` D7 (sample-ticket production 역수입 금지) + `feature-architecture-enforcement-rules.md` D7 (자매 ArchUnit rule). 외부 근거: ca-tmpl 자체 결정 (project-decision).
- 초기 결정 (single-module feature-first) 은 2026-05-22, 수정 결정 (Gradle multi-module + Clean Architecture / Hexagonal) 은 2026-05-27 — 근거: `feature-skeleton-package-blueprint-contract.md` §결정 사항 ("2026-05-22: 초기 문서의 기본 구조는 single-module feature-first package layout이었다", "2026-05-27: Phase C2 기본 구조는 ... 로 수정한다").
- 경험 후보:
- 기존 reference code (blog domain) 를 production module 에서 `sample-ticket/src/main/java/dev/caskeleton/sample/ticket/...` 로 격리. production module 은 `package-info.java` + skeleton anchor 중심으로 정리 — 근거: `feature-skeleton-package-blueprint-contract.md` Closure §`actually-implemented` "기존 reference code는 production module에서 `sample-ticket` 내부 `dev.caskeleton.sample.ticket.*` package로 격리됨".
- production package root 를 `dev.caskeleton` 으로 rename + `BlogApplication``CaSkeletonApplication` + `blog.*` 설정 prefix → `ca-skeleton.*` 전환 — 근거: 동일 Closure 항목.
- 빈 skeleton anchor module 이 ArchUnit empty should failure 를 일으켜 `allowEmptyShould(true)`_빈 상태가 의도된 rule 에만_ 선별 적용 — 근거: 파생 에러 [[raw/errors/archunit-empty-should-anchor-2026-05-27]] §해결 ("빈 상태가 skeleton contract상 유효한 rule에만 `allowEmptyShould(true)`").
- `sample-ticket` 격리 후 sample 내부 `GlobalExceptionHandler``InvalidBearerTokenException` 을 import 하지만 sample build.gradle 에 `spring-boot-starter-oauth2-resource-server` 가 없어서 compile 실패 → starter 명시 추가 — 근거: 파생 에러 [[raw/errors/sample-ticket-oauth2-resource-server-dependency-2026-05-27]] §해결.
- 의견 / 해석 후보:
- 템플릿 프로젝트의 완성도 기준은 "예시 도메인이 잘 돈다" 가 아니라 **"예시를 통째로 들어내도 경계가 남는다"** 이다. ca-tmpl 의 `sample-ticket` 격리는 이 기준의 직접 검증.
- `common` / `shared` 모듈은 _편의_ 보다 _오염 방지 규칙_ 을 먼저 가져야 한다. ca-tmpl 의 `shared-contract` 는 8개 sub-package allowlist (`response/error/headers/logging/tracing/metrics/registry/annotation`) 로 명시 제한.
- **single-module feature-first 도 작은 프로젝트엔 합리적**이다. ca-tmpl 이 multi-module 을 택한 건 _template repository 라서_ 새 프로젝트가 시작될 때 경계가 흐트러지지 않도록 학습 비용을 미리 흡수한다는 결정 — 근거: `feature-skeleton-package-blueprint-contract.md` D8 Open Risk ("작은 프로젝트에서는 single-module이 비용이 낮을 수 있음. ca-tmpl은 skeleton template이므로 boundary 학습/검증 비용을 감수").
- **company-tech-blog 사례 (우아한형제들 / 카카오뱅크) 는 best practice 가 아니라 case study** 다. 본 글에서 이 두 사례를 인용하되 "공식 표준" 으로 승격하지 않는 정직함이 중요 — 근거: `feature-skeleton-package-blueprint-contract.md` Decision Evidence Map 의 Evidence Strength 컬럼 (`company-case-study`).
## Outline seed
> 각 섹션 옆에 `→ 핵심 메시지` 를 함께 명시한다.
1. 의사결정의 _뒤집힘_ — 2026-05-22 의 single-module feature-first 결정을 2026-05-27 에 명시적으로 수정한 과정 → **template 의 첫 결정도 case study 검토 뒤 뒤집을 수 있다는 정직함.**
2. module boundary 가 _1차_ 강제선인 이유 — package convention 만으로는 import 가 자유롭다 → **Gradle dependency graph 가 컴파일 단계에서 위반을 막는다.**
3. 8개 module 의 책임 — `domain-core`, `application-core`, `adapter-{web,persistence,outbound}`, `shared-contract`, `sample-ticket`, `app-bootstrap`**dependency direction 표 + 각 모듈의 forbidden import 매트릭스.**
4. `shared-contract` 를 좁게 잡는 이유 — 8개 sub-package allowlist (`response/error/headers/...`) → **business common dumping ground 방지가 _편의_ 보다 우선.**
5. `sample-ticket` 격리 — production module 의 ArchUnit rule + Gradle dependency rule + 별도 `*Application`_없음_**"예시를 들어내도 경계가 남는다" 가 template repository 의 완성도 기준.**
6. 구현 중 드러난 작은 실패들 — 빈 anchor 의 `allowEmptyShould` 선별 적용 + sample-ticket compile classpath 누락 → **template repository 의 "비어 있음" 은 의도된 상태일 수 있다.**
7. 다른 선택지의 정직한 비교 — single-module / layer-first / pure hexagonal / Spring Modulith → **ca-tmpl 의 선택이 _유일한 정답_ 이 아니라 _이 맥락에서의 최적_ 임을 명시.**
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/clean-architecture-package-layout.md` 후보:
- 실제 적용된 8 module + 각 모듈의 sub-package 책임 트리 (`feature-skeleton-package-blueprint-contract.md` §Default Module Blueprint).
- dependency direction 매트릭스 (`Module Dependency Rule` 표).
- `dev.caskeleton` 으로의 package rename + `CaSkeletonApplication` / `BootstrapSettings` / `ca-skeleton.*` 설정 prefix 전환.
- local verification 결과 4종 (`./gradlew test`, `verifyCleanArchitectureDependencies`, `:app-bootstrap:test --tests '*CleanArchitectureTest'`, `:adapter-web:test --tests '*SettingsTest'`).
- `wiki/concepts/clean-architecture-package-layout.md` 후보:
- multi-module Hexagonal-inspired skeleton layout 의 일반화 원칙 (project-agnostic).
- "module boundary 1차, package convention 2차" 분업 원칙.
- `shared` 모듈을 좁게 잡는 _operational contract only_ rule.
- "예시를 들어내도 경계가 남는다" 의 template completeness 기준.
- 필요한 추가 검증:
- canonical 문서가 최신 코드 상태 (`dev.caskeleton`, `sample-ticket` 격리, Spring Boot 3.5.14, 새로 추가된 `feature-application-port-usecase-contract``application-core` 패키지 구조) 까지 반영하는지.
- Spring Modulith named interface 를 후속 도입했을 때 Gradle multi-module + ArchUnit 구성과의 중복/대체 관계.
## Sources / 근거 후보
- [[raw/branch-notes/feature-skeleton-package-blueprint-contract]] — 결정 D1~D8, Default Module Blueprint tree, Module Dependency Rule 표, Closure §`actually-implemented` / `locally-verified`.
- [[raw/branch-notes/feature-architecture-enforcement-rules]] — 자매 결정 D1~D10. 본 글의 ArchUnit 단락 근거.
- [[raw/branch-notes/feature-application-port-usecase-contract]] — `application-core` package 구조의 후속 결정 (D1: inbound `*UseCase` / outbound `*Port` naming). canonical 정제 시 통합 필요.
- [[raw/errors/archunit-empty-should-anchor-2026-05-27]] — 빈 anchor 와 `allowEmptyShould(true)` 선별 적용.
- [[raw/errors/sample-ticket-oauth2-resource-server-dependency-2026-05-27]] — sample 격리 후 compile dependency 누락.
- [[raw/interviews/clean-architecture-module-blueprint]] — 같은 작업에서 파생된 예상 면접 질문.
- [[raw/interviews/shared-contract-and-sample-isolation]] — shared/sample 책임 경계 예상 질문.
- [[raw/company-tech-blogs/woowahan-hexagonal-multimodule]] — Domain / Application / Framework / Bootstrap 4-module 격리 사례 (`WW-HEX-C1`, `WW-HEX-C2`).
- [[raw/company-tech-blogs/modulith-kakaobank-techblog-2025]] — Gradle multi-module + Hexagonal + Spring Modulith 사례 (`KAKAOBANK-MOD-C2`, `KAKAOBANK-MOD-C4`).
- [[raw/official-docs/hexagonal-cockburn-wikipedia-summary]] — Ports & Adapters 원형 (`HEX-WIKI-C5`).
- [[raw/official-docs/arch-clean-architecture-uncle-bob]] — Dependency Rule 의 클래식 근거 (`engineering-blog`, official standard 아님).
- [[raw/official-docs/feature-first-uncle-bob-screaming-architecture-2011]] — feature/use-case 가 framework 위에 드러나야 한다는 사상 (`SCREAM-C1`).
- [[raw/official-docs/hexagonal-thombergs-buckpal-github]] — feature/package 내부 port-adapter 책임 분리 참고.
- [[raw/official-docs/layer-first-baeldung-clean-architecture-spring-boot]] — _대안 1: layer-first_ 의 입문형 사례.
- [[raw/official-docs/onion-palermo-original-2008]] — _대안 4: onion_ 의 원형.
## 미해결 / Unknown
- 아직 확인해야 할 사실: ca-tmpl 의 8 module 구조가 _실제 사업 도메인_ 이 들어왔을 때 module 분할 또는 새 adapter (e.g., `adapter-messaging`) 추가가 자연스럽게 가능한지. 현재는 reference (sample-ticket) 만 검증.
- 아직 확인해야 할 사실: Spring Modulith 의 named interface 검증을 추가하면 ArchUnit rule 중 어느 것이 _중복_ 이고 어느 것이 _보완_ 인지.
- 과장하면 안 되는 부분: 본 글의 모든 검증은 **`locally-verified`** 이며 prod 운영 검증 없음. "운영에서 검증됐다" 라는 표현 금지.
- 과장하면 안 되는 부분: 우아한형제들 / 카카오뱅크 사례는 _case study_ 다. "대기업에서 표준" 또는 "industry standard" 같은 표현으로 격상시키지 말 것 — `feature-skeleton-package-blueprint-contract.md` Decision Evidence Map Open Risk 컬럼이 이 한계를 명시.
- 블로그로 쓰기 전에 필요한 canonical 정제: `wiki/projects/ca-tmpl/clean-architecture-package-layout.md`_최신 코드 상태_ (특히 `feature-application-port-usecase-contract` 작업으로 추가된 `application-core` 패키지 구조) 까지 반영한 뒤 verified 항목만 글로 이동.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/clean-architecture-package-layout.md` 에 module/package blueprint 글감으로 반영했다.
- 다음 단계: source canonical이 `verified` 상태이므로 이후 `blogify` 대상으로 삼을 수 있다. 단 운영 검증이나 전체 Phase C2 완료처럼 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-skeleton-package-blueprint-contract]], [[raw/branch-notes/feature-architecture-enforcement-rules]] (자매 — boundary 강제), [[raw/branch-notes/feature-application-port-usecase-contract]] (후속 — application 내부 패키지 구조).
- 관련 errors: [[raw/errors/archunit-empty-should-anchor-2026-05-27]], [[raw/errors/sample-ticket-oauth2-resource-server-dependency-2026-05-27]].
- 관련 interview prep: [[raw/interviews/clean-architecture-module-blueprint]], [[raw/interviews/shared-contract-and-sample-isolation]], [[raw/interviews/clean-architecture-boundary-enforcement]].
- 관련 blog topics: [[raw/blog-topics/clean-architecture-boundary-enforcement-2026-05-28]] (자매 글감 — boundary 강제), [[raw/blog-topics/transaction-port-abstraction-over-spring-transactional-2026-05-28]] (자매 글감 — application 의 framework 격리).
- derived blog: 생성 전. 생성 시 `wiki/blog/clean-architecture-module-blueprint-YYYY-MM-DD.md` 후보.
@@ -0,0 +1,86 @@
---
title: blog-topic / clean-architecture-reference-project-adoption
source_type: blog-topic
status: raw
related_branches: [feature-sample-removal-adoption-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, architecture, testing, clean-architecture, ddd, api-contract]
created: 2026-06-17
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: clean-architecture-reference-project-adoption
> Layer: `raw/blog-topics/` — 채용공고가 아닌 작업·학습·트러블슈팅에서 나온 블로그 글감 원석. canonical 정제 전 raw 후보이며, 바로 `wiki/blog/` 로 승격하지 않는다.
## Parent / 부모
- [[raw/branch-notes/feature-sample-removal-adoption-contract]] — 레퍼런스 프로젝트 비교와 sample/adoption 계약 정리 과정에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-06-17
- 트리거 연결 노트: [[raw/branch-notes/feature-sample-removal-adoption-contract]]
## 글감 / Topic seed
- 한 문장 요지: Clean Architecture 스켈레톤을 고도화할 때 레퍼런스 프로젝트를 그대로 베끼지 않고, 계약 검증·event reliability·bounded context·도메인 모델링·운영 도구로 분해해 흡수하는 방법.
- 예상 제목 후보:
- Clean Architecture 템플릿을 레퍼런스 프로젝트로 고도화하는 법
- 여러 DDD/Hexagonal 프로젝트에서 스켈레톤에 흡수할 것과 버릴 것
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- ca-tmpl 은 현재 module dependency gate, outbox relay, OpenAPI snapshot, env/one-type verification, runbook/registry 기반 운영 계약을 이미 갖고 있다. — 근거 후보: [[raw/branch-notes/feature-sample-removal-adoption-contract]], `docs/superpowers/plans/2026-06-17-reference-project-adoption.md`
- 조사 대상 레퍼런스들은 contract verification, acceptance-test, saga/outbox/inbox, modular monolith, domain modeling, observability, generator 측면에서 서로 다른 강점을 갖는다. — 근거 후보: [[raw/branch-notes/feature-sample-removal-adoption-contract]]
- 경험 후보:
- 로컬 README/구조/대표 구현을 evidence matrix 로 나누고, 그대로 흡수 금지 항목을 별도 표로 분리했다. — 근거 후보: [[raw/branch-notes/feature-sample-removal-adoption-contract]]
- 의견/해석 후보:
- 스켈레톤 프로젝트는 기능을 많이 담는 것보다, 새 프로젝트가 안전하게 확장할 수 있는 검증 가능한 seam 을 제공하는 편이 실무에 더 가깝다.
## Outline seed
1. 레퍼런스 프로젝트를 그대로 복제하면 생기는 문제 — stack drift, layer rule 충돌, sample 과 production 의 혼동을 설명한다.
2. 흡수 후보를 기능 축으로 재분류하기 — contract, event reliability, bounded context, domain modeling, ops/tooling 으로 나눈다.
3. ca-tmpl 에 먼저 적용할 P0 — contract verification, acceptance-test module, consumer inbox/dedupe 가 왜 가장 효과적인지 정리한다.
4. 보류해야 할 것들 — Spring Modulith, WebFlux, chaos, generator 는 optional spike 로 두는 이유를 적는다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/reference-project-adoption.md` 후보:
- ca-tmpl 에 실제로 적용한 레퍼런스 흡수 전략과 검증 결과.
- `wiki/concepts/clean-architecture-template-evolution.md` 후보:
- Clean Architecture 템플릿을 진화시킬 때 레퍼런스를 평가하는 일반 기준.
- 필요한 추가 검증:
- Phase 1~2 구현 후 실제 테스트/빌드 결과.
- Spring Cloud Contract, Springwolf, Spring Modulith 의 ca-tmpl 현재 스택 호환성.
## Sources / 근거 후보
- [[raw/branch-notes/feature-sample-removal-adoption-contract]] — 레퍼런스 흡수와 sample/adoption 계약 정리 방향.
- `docs/superpowers/plans/2026-06-17-reference-project-adoption.md` — 레퍼런스 프로젝트 evidence matrix 와 적용 plan.
## 미해결 / Unknown
- 아직 확인해야 할 사실: 각 P0/P1 항목은 구현 전 focused audit 과 dependency compatibility 확인이 필요하다.
- 과장하면 안 되는 부분: 현재 상태는 `documented-only` 계획이며, contract/inbox/acceptance-test 구현이 완료된 것이 아니다.
- 블로그로 쓰기 전에 필요한 canonical 정제: 실제 Phase 1 또는 Phase 2 구현 결과와 검증 로그를 `wiki/projects/ca-tmpl/...` 로 승격해야 한다.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/sample-fixture-and-adoption.md` 에 reference project adoption / sample-adoption 전략 글감으로 반영한다.
- 다음 단계: 정확성 감사에서 발견된 결함을 반영한 계획만 blogify 근거로 사용한다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-sample-removal-adoption-contract]]
- 관련 error: 없음
- 관련 interview prep: 없음
- derived blog: 생성 전. 생성 시 `wiki/blog/clean-architecture-reference-project-adoption-2026-06-17.md` 후보
- 정확성 감사 (2026-06-19): 이 글감의 근거가 된 계획 문서의 README 라인 인용/장점 요약을 19개 원본 프로젝트와 대조한 결과 — 13개 정확, 6개 결함(library "domain/integration event 구분"은 미구현 placeholder인 phantom 장점; dddsample-core/food-ordering는 장점 실재하나 인용 라인 오류; 라인-정밀도 결함 묶음). 산출물: `ca-tmpl/docs/superpowers/specs/2026-06-19-reference-project-adoption-accuracy-report.md`. **블로그로 승격 시 위 결함이 수정된 계획을 근거로 삼을 것** — 미수정 인용을 그대로 인용하면 글의 사실성이 깨진다.
@@ -0,0 +1,91 @@
---
title: blog-topic / contract-registry-schema-owner-vs-row-owner-gate-2026-06-20
source_type: blog-topic
status: raw
related_branches: [feature-contract-registry-governance]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, registry, governance, contract, yaml, test, ownership]
created: 2026-06-20
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: contract-registry-schema-owner-vs-row-owner-gate-2026-06-20
> Layer: `raw/blog-topics/` — 채용공고가 아닌 작업·학습에서 나온 블로그 글감 원석. canonical 정제 전 raw 후보이며, `wiki/blog/` 직접 생성 근거가 아니다.
## Parent / 부모
- [[raw/branch-notes/feature-contract-registry-governance]] — 본 branch 의 schema-owner 게이트(`ContractRegistrySchemaGovernanceTest`) 구현(Phase C2, 2026-06-20)에서 추출.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 7개 contract registry(error/env/secrets/header/mdc/metric/capability)의 **schema** 를 한 branch 가 소유하되 **row 값** 은 8개 sibling branch 에 위임하는 구조에서, "schema 가 실제로 강제되는가" 를 기계로 증명하려 했다. 기존 테스트는 전부 *단일 registry 의 값/enum drift* 만 봤고, **registry 들이 공통 schema 를 따르는지** 를 보는 테스트는 없었다.
## 글감 코어 / Core idea
- **문제 분리(separation of ownership)**: registry governance 에는 두 종류의 소유권이 있다 — (a) *schema owner*: 어떤 column 이 있어야 하는가(구조·저장 형식·변경 절차), (b) *row owner*: 어떤 code/key/name 값이 존재하는가. 이 둘을 한 테스트로 섞으면 위임이 깨진다. ca-skeleton 은 7 registry 의 schema 를 단일 branch 가, 값은 8 sibling 이 소유.
- **schema 게이트의 단언 집합**: ① N family 존재(파일 부재 = 누락 = FAIL, silent skip 아님) ② 각 파일의 `# Schema owner:` 헤더 ③ 모든 row 의 identity + 위임 포인터(`owner_branch`) ④ full row 의 universal contract column(`compatibility_impact` legal enum + `required_test` = "모든 registry 항목은 최소 1개 contract test 와 연결") ⑤ **문서화된 면제**(reference row)의 명시적 검증.
- **면제를 검증 가능하게**: 한 registry(secrets)는 다른 registry(env-keys)로 값을 위임하는 *reference row* 를 둔다. 이들은 contract column 을 생략한다. 게이트가 이를 그냥 skip 하면 "면제" 와 "누락" 을 구분 못한다 → reference row 는 `reference:` target 보유를 별도 단언. (이 함정의 디버그 기록: [[raw/errors/contract-registry-reference-row-universal-column-false-fail-2026-06-20]])
- **gitignored seed 위의 테스트 이중 모드**: registry SSOT 가 `/docs`(gitignore)에 있어 CI/fresh checkout 엔 부재. 부재 → `Assumptions.assumeTrue` 로 SKIP(거짓 green 아님), 존재 → 위반 hard FAIL. "데이터가 없으면 통과" 가 아니라 "데이터가 없으면 검사 안 함, 있으면 엄격" 이 정직한 drift gate 의 기본형.
- **검증(게이트의 실효성 증명)**: seed 로 6 tests green 만으로는 vacuous 일 수 있다 → 음성 변이(illegal `compatibility_impact` 주입 → 해당 단언 FAIL → 원복) 로 게이트가 실제로 막는지 증명. "green 한 번" 이 아니라 "틀린 데이터에 red" 까지 봐야 신뢰.
## 왜 의미 있나 / Why it matters
- "문서로만 있는 거버넌스 규칙은 쉽게 깨진다" 를 fitness function 으로 메우는 스켈레톤 가치의 구체 사례 — 단, 이번엔 *코드 구조* 가 아니라 *데이터 계약(registry yaml)* 자체가 대상.
- 멀티-owner registry 에서 "schema vs row" 소유권 분리는 monorepo/플랫폼 팀에서 흔한 구조(공통 schema 팀 + 도메인 팀). 그 경계를 테스트로 박제하는 패턴은 이식성이 높다.
- 한계(글에서 솔직히 명시할 것): 이 게이트는 *artifact 가 schema 를 따르는가* 만 본다. "registry 에 없는 token 이 코드에 등장하는가" 의 정적 탐지(ArchUnit custom rule)는 별개 PoC 로 미구현(`planned`). 즉 schema 정합 ≠ token 사용 강제.
## 글감 / Topic seed
- 한 문장 요지: contract registry governance에서는 schema owner와 row owner를 분리하고, schema gate가 면제 row까지 명시적으로 검증해야 drift를 줄일 수 있다.
- 예상 제목 후보:
- Registry schema owner와 row owner를 나눈 이유
- YAML registry governance를 테스트로 고정하기
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- registry schema와 row 값은 서로 다른 owner가 가질 수 있다.
- reference row는 full row와 다른 검증 경로가 필요하다.
- 의견/해석 후보:
- schema 정합과 token 사용 강제는 다른 gate이며 같은 테스트로 섞으면 소유권이 흐려진다.
## Outline seed
1. schema owner와 row owner의 책임을 분리한다.
2. universal contract column과 reference row 면제 검증을 설명한다.
3. schema gate의 한계와 runtime token 강제의 별도 owner를 구분한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard.md` 후보:
- registry schema owner vs row owner gate 글감.
- 필요한 추가 검증:
- 실제 `ContractRegistrySchemaGovernanceTest`와 negative mutation 검증 여부.
## Sources / 근거 후보
- [[raw/branch-notes/feature-contract-registry-governance]]
- [[raw/branch-notes/feature-contract-verification-test-suite]]
- [[raw/errors/contract-registry-reference-row-universal-column-false-fail-2026-06-20]]
## 미해결 / Unknown
- 아직 확인해야 할 사실: schema gate 구현과 negative mutation 검증이 현재 코드에 남아 있는지.
- 과장하면 안 되는 부분: schema 정합을 token 사용 강제나 runtime verification으로 확대하지 않는다.
## 관련 / Related
- [[raw/branch-notes/feature-contract-registry-governance]]
- [[raw/branch-notes/feature-contract-verification-test-suite]] — runtime token 강제(11 release-blocking gates) 를 소유하는 sibling. 본 글감의 "schema 정합 ≠ token 사용 강제" 경계의 반대편.
- [[raw/errors/contract-registry-reference-row-universal-column-false-fail-2026-06-20]]
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard.md` 에 contract registry schema owner vs row owner gate 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 registry schema gate 구현·negative mutation 검증 여부를 재확인한다.
@@ -0,0 +1,81 @@
---
title: blog-topic / contract-verification-suite-release-gates
source_type: blog-topic
status: raw
related_branches: [feature-contract-verification-test-suite]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, testing, ci-cd, junit5, api-contract, static-analysis]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: contract-verification-suite-release-gates
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-contract-verification-test-suite]] — release-blocking contract suite 구현과 검증에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-contract-verification-test-suite]]
## 글감 / Topic seed
- 한 문장 요지: skeleton의 운영 계약을 문서로만 두지 않고 release-blocking test suite로 묶은 이유와 설계 단위를 정리한다.
- 예상 제목 후보:
- 운영 계약을 release gate로 바꾸는 방법
- Clean Architecture skeleton에서 contract verification suite를 둔 이유
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- 11개 release-blocking gate, ApprovalTests/OpenAPI snapshot, optional adapter skip proof, masking regex 함정이 branch에 정리되어 있다 — 근거 후보: [[raw/branch-notes/feature-contract-verification-test-suite]] D6/D7 line `:134-136`, 구현 `:104-107`, blog seeds `:349-354`.
- 경험 후보:
- registry drift, ArchUnit isolation, OpenAPI committed snapshot은 각각 다른 실패 모드를 잡기 때문에 하나의 "품질 테스트"로 뭉개기 어렵다.
- 의견/해석 후보:
- skeleton project의 핵심은 기능 수가 아니라 복제 후 깨지면 안 되는 계약을 실행 가능하게 만드는 데 있다.
## Outline seed
1. contract verification은 단일 테스트가 아니다 — registry, API snapshot, architecture rule, adapter skip proof가 서로 다른 drift를 본다.
2. release-blocking과 advisory check를 구분해야 한다 — 모든 검사를 같은 severity로 두면 운영이 어려워진다.
3. snapshot nondeterminism도 계약 설계의 일부다 — 재현 가능한 출력이 있어야 gate가 신뢰된다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard.md` 후보:
- ca-tmpl contract verification suite 구현 사실.
- `wiki/concepts/skeleton-governance-registry-verification-test-scorecard.md` 후보:
- registry, verification test, scorecard 일반 개념.
- 필요한 추가 검증:
- 11개 gate의 목록, release-blocking 여부, 실제 CI/Gradle 연결 상태.
## Sources / 근거 후보
- [[raw/branch-notes/feature-contract-verification-test-suite]] — release-blocking suite와 blog seed 근거.
## 미해결 / Unknown
- 아직 확인해야 할 사실: hosted CI에서 gate가 실제 release를 차단한 사례가 있는지.
- 과장하면 안 되는 부분: local verification과 hosted CI/prod evidence를 섞으면 안 된다.
- 블로그로 쓰기 전에 필요한 canonical 정제: project 문서의 gate matrix와 evidence grade 보강.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard.md` 에 verification suite/release gate 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 review/verify가 필요하다. local verification과 hosted CI/prod evidence를 섞지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-contract-verification-test-suite]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/contract-verification-suite-release-gates-YYYY-MM-DD.md` 후보
@@ -0,0 +1,91 @@
---
title: blog-topic / digest-first-java-release-pipeline
source_type: blog-topic
status: raw
related_branches: [feature-build-release-supply-chain-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, ci-cd, gradle, docker, supply-chain, reproducible-builds]
created: 2026-06-21
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: digest-first-java-release-pipeline
> Layer: `raw/blog-topics/` — 실제 Java 21/Gradle 멀티모듈 release pipeline 구현에서 나온 글감 원석.
## Parent / 부모
- [[raw/branch-notes/feature-build-release-supply-chain-contract]] — digest-first build/sign/verify/promotion과 rollback audit를 구현한 작업.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-06-21
- 트리거 연결 노트: [[raw/branch-notes/feature-build-release-supply-chain-contract]].
## 글감 / Topic seed
- 한 문장 요지: 재현 가능한 JAR부터 digest-bound SBOM·Cosign·SLSA 검증과 rollback manifest까지 하나의 release-blocking DAG로 묶어야 mutable tag가 공급망 SSOT가 되는 일을 막을 수 있다.
- 예상 제목 후보:
- Java 릴리스를 태그가 아니라 Digest로 승격하는 공급망 파이프라인
- Gradle Lock부터 SLSA까지: 재빌드 없는 컨테이너 릴리스 설계
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- Gradle archive timestamp/order/mode와 JDK pin을 고정한 두 clean build에서 같은 SHA-256을 얻었다. 근거: [[raw/branch-notes/feature-build-release-supply-chain-contract]] D10, §구현 결과.
- Cosign signer identity/issuer와 SLSA exact builder ID 검증을 통과한 digest만 version tag로 promotion하도록 DAG를 배선했다. 근거: 같은 branch D4, D7, D12, D13.
- 경험 후보:
- `dependencies` report가 lock 누락을 출력하고도 exit 0인 fail-open을 발견해 실제 resolution task와 negative lock-drift test로 교체했다. 근거: 같은 branch §마주친 문제.
- rollback audit가 release asset 이름뿐 아니라 manifest의 source revision/image digest와 GHCR digest 일치까지 검증하도록 보강했다. 근거: 같은 branch D11, §구현 결과.
- 의견/해석 후보:
- 공급망 파이프라인의 핵심은 도구 수가 아니라, immutable identity가 모든 gate와 rollback 경로를 관통하도록 만드는 것이다.
## Outline seed
1. Tag-only release의 빈틈 — mutable tag와 재빌드가 검증 대상/배포 대상의 동일성을 깨뜨린다.
2. Build contract — strict dependency locks, SemVer+sha, reproducible archives, pinned JDK/base digest로 입력을 닫는다.
3. Evidence DAG — High/Critical scan, SPDX SBOM, Cosign identity, SLSA exact builder를 promotion 전에 fan-in한다.
4. Promotion과 rollback — 검증된 digest를 재빌드 없이 tag하고 manifest/SBOM/GHCR digest retention을 audit한다.
5. 검증의 정직한 경계 — local fixture와 live GitHub OIDC/Rekor/GHCR 증거를 분리한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/build-release-supply-chain.md` 후보:
- ca-tmpl에 실제 적용된 release DAG, lock task, manifest/retention audit.
- `wiki/concepts/digest-first-release-promotion.md` 후보:
- immutable digest 중심 verification/promotion/rollback 일반 패턴.
- 필요한 추가 검증:
- GitHub release candidate tag로 OIDC/Rekor/GHCR live pipeline 실행.
- generated SBOM과 Gradle resolved dependency 비교.
## Sources / 근거 후보
- [[raw/branch-notes/feature-build-release-supply-chain-contract]] — 구현 결정 D1~D13과 local evidence.
- [[raw/errors/sandbox-build-verification-boundaries-2026-06-21]] — verification 경계와 안전한 대체.
- [[raw/interviews/digest-first-supply-chain-release-gates]] — 설계 질문과 답변 경계.
- [[raw/official-docs/supply-chain-cosign-keyless-sigstore]] — Cosign keyless 근거.
- [[raw/official-docs/supply-chain-slsa-provenance-framework]] — SLSA provenance 근거.
- [[raw/official-docs/gradle-reproducible-archives-working-with-files]] — reproducible archive 근거.
## 미해결 / Unknown
- 아직 확인해야 할 사실: GitHub-hosted live provenance payload, Rekor entry, GHCR referrer retention.
- 과장하면 안 되는 부분: local workflow/static/fixture 검증을 production release 성공으로 표현하지 않는다.
- 블로그로 쓰기 전에 필요한 canonical 정제: branch 결과를 project/concept canonical로 승격하고 live CI evidence를 별도 등급으로 병합한다.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/devops-ci-supply-chain-dx.md` 에 digest-first release pipeline / supply-chain DAG 글감으로 반영한다.
- 다음 단계: live release evidence 확보 전까지 production release 성공으로 표현하지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-build-release-supply-chain-contract]].
- 관련 error: [[raw/errors/sandbox-build-verification-boundaries-2026-06-21]].
- 관련 interview prep: [[raw/interviews/digest-first-supply-chain-release-gates]].
- derived blog: 생성 전.
@@ -0,0 +1,81 @@
---
title: blog-topic / distributed-lock-transaction-commit-boundary
source_type: blog-topic
status: raw
related_branches: [feature-distributed-lock-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, persistence, postgresql, distributed-lock, lock-lease, transaction-isolation]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: distributed-lock-transaction-commit-boundary
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-distributed-lock-contract]] — distributed lock provider와 transaction commit boundary 검증에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-distributed-lock-contract]]
## 글감 / Topic seed
- 한 문장 요지: 분산 락에서 `lock.close()`와 DB commit 순서가 잘못 맞물리면 lost update 경계가 생기는 이유를 정리한다.
- 예상 제목 후보:
- 분산 락은 언제 풀어야 안전할까
- lock close와 transaction commit 사이의 위험한 틈
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- branch에 `distributedLockProvider` owner, lock port/JdbcLockRegistry, lease/CME handling, 로컬 검증 항목이 정리되어 있다 — 근거 후보: [[raw/branch-notes/feature-distributed-lock-contract]] section+line `:36`, `:48`, `:341-345`, `:370-384`.
- 경험 후보:
- branch 자체에 파생 글감 seed가 여러 개 있으며, lock release와 commit boundary는 기존 raw topic에 exact duplicate가 없다.
- 의견/해석 후보:
- 분산 락의 correctness는 "락을 잡았다"보다 "락을 언제까지 잡고 있었는가"에 더 민감하다.
## Outline seed
1. 락 획득보다 해제가 더 무섭다 — commit 전 unlock은 다른 writer에게 잘못된 신호를 줄 수 있다.
2. transaction boundary와 lock lifecycle을 같은 그림에 놓기 — DB commit, exception, lease 만료를 함께 본다.
3. local verification으로 증명할 수 있는 것과 없는 것 — single-node/JDBC lock registry 검증과 multi-node 운영 리스크를 분리한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/distributed-lock-provider-contract.md` 후보:
- ca-tmpl distributed lock provider 구현/검증 사실.
- `wiki/concepts/distributed-lock.md` 후보:
- lock lease, unlock timing, transaction interaction 일반 개념.
- 필요한 추가 검증:
- lock close/commit ordering 테스트와 lease 만료/exception path 검증.
## Sources / 근거 후보
- [[raw/branch-notes/feature-distributed-lock-contract]] — lock provider와 verification 근거.
## 미해결 / Unknown
- 아직 확인해야 할 사실: multi-instance 환경에서 검증된 범위.
- 과장하면 안 되는 부분: local/JDBC lock 검증을 운영 분산 환경 보장으로 표현하지 않는다.
- 블로그로 쓰기 전에 필요한 canonical 정제: distributed lock project 문서 생성 또는 기존 data-layer 문서에 통합.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound.md` 에 distributed lock transaction commit boundary 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 review/verify가 필요하다. local/JDBC lock 검증을 운영 분산 환경 보장으로 표현하지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-distributed-lock-contract]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/distributed-lock-transaction-commit-boundary-YYYY-MM-DD.md` 후보
@@ -0,0 +1,101 @@
---
title: blog-topic / domain-modeling-guardrails-as-archunit-fitness-functions-2026-06-05
source_type: blog-topic
status: raw
related_branches: [feature-domain-modeling-guardrails]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, archunit, fitness-function, ddd, value-object, aggregate, domain-event, jqwik, clean-architecture]
created: 2026-06-05
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: domain-modeling-guardrails-as-archunit-fitness-functions-2026-06-05
> Layer: `raw/blog-topics/` — 작업·트러블슈팅에서 나온 글감 원석. canonical 정제 전 raw.
## Parent
- [[raw/branch-notes/feature-domain-modeling-guardrails]]
## 한 줄 글감
"DDD 전술 패턴을 README 권고가 아니라 빌드 깨짐으로 강제하기 — stereotype 애너테이션 + ArchUnit fitness function."
## 본문 뼈대 (초안)
1. **문제**: rich domain model / 값 객체 불변식 / transport-free 도메인 이벤트는 보통 "문서 권고"로 남고 시간이 지나면 침식된다(anemic 회귀, public setter, 도메인에 Kafka 타입 누출).
2. **접근**: 의미를 드러내는 마커 애너테이션을 도메인 코어에 둔다 — `@ValueObject`/`@AggregateRoot`/`@DomainEvent` (java.lang.annotation 만 의존, 프레임워크 0).
3. **강제**: ArchUnit 규칙이 마커를 키로 평가
- `value_objects_have_no_public_no_arg_constructor` — 빈 생성자 = 불변식 우회 백도어 차단. record(컴포넌트 보유)는 자동 충족.
- `aggregate_root_setters_are_not_public``set*` 비공개 강제(Vernon Option A: ORM 외부 매핑 가시성).
- `domain_events_are_records` + `domain_events_are_transport_free` — immutable record + Kafka/HTTP/JAX-RS 패키지 의존 금지.
- `domain_has_no_logger` — 도메인은 로그 대신 안전한 명사형 reason enum 예외로 위반을 표현.
4. **owner 경계 교훈**: "도메인 순수성" 규칙(다른 branch 소유)에 logger 금지를 끼워넣지 않고 별도 규칙으로 분리한 이유 — 규칙 소유권/위반 메시지 명확성.
5. **정직성 교훈**: logger 금지는 공식 표준이 아니라 프로젝트 자체 규약. 사실 등급을 격상하지 않는다.
6. **비공허(non-vacuous) 증명**: 규칙마다 의도적 위반 fixture + 격리 코퍼스로 "실제로 잡는다"를 테스트(violations-as-data). transport glob 은 broker별 격리 증명.
7. **함정**: `testCompileOnly` 타입을 record component 로 쓰면 JUnit *discovery* 가 죽는다 → method body `.class` 참조로 회피([[raw/errors/archunit-testcompileonly-class-loading-2026-06-02]]).
8. **불변식 검증의 깊이**: 예시 테스트 대신 jqwik property-based test 로 값 객체 입력 공간 전체를 무작위 검증.
9. **이벤트 경계 PoC**: `WorkLogReserved`(domain) → `WorkLogReservedIntegrationEvent`(application mapper) — 도메인은 wire 를 모른다.
## Cross-links
- [[raw/blog-topics/archunit-testcompileonly-fixture-annotation-pattern-2026-06-02]]
- [[raw/blog-topics/archunit-violations-as-data-pattern-2026-05-28]]
- [[raw/interviews/domain-modeling-guardrails-archunit-2026-06-05]]
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-06-05
- 트리거 연결 노트: [[raw/branch-notes/feature-domain-modeling-guardrails]]
## 글감 / Topic seed
- 한 문장 요지: DDD tactical pattern을 README 권고가 아니라 marker annotation + ArchUnit fitness function으로 빌드 단계에서 강제한다.
- 예상 제목 후보:
- DDD guardrail을 ArchUnit fitness function으로 만들기
- Value Object와 Domain Event 규칙을 빌드에서 검증하기
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- `@ValueObject`, `@AggregateRoot`, `@DomainEvent` 같은 marker를 ArchUnit rule의 평가 key로 쓴다.
- 의견/해석 후보:
- domain modeling 규칙은 공식 표준이 아니라 project-local guardrail이므로 사실 등급을 조심해야 한다.
## Outline seed
1. tactical DDD rule이 문서 권고로만 남을 때 침식되는 경로를 설명한다.
2. framework-free marker annotation과 ArchUnit rule의 역할을 나눈다.
3. violations-as-data와 jqwik property test로 guardrail이 실제로 bite하는지 확인한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/clean-architecture-package-layout.md` 후보:
- domain modeling guardrail / ArchUnit fitness function 글감.
- 필요한 추가 검증:
- 현재 marker annotation, ArchUnit rule, jqwik test 존재 여부.
## Sources / 근거 후보
- [[raw/branch-notes/feature-domain-modeling-guardrails]]
- [[raw/blog-topics/archunit-violations-as-data-pattern-2026-05-28]]
- [[raw/errors/archunit-testcompileonly-class-loading-2026-06-02]]
## 미해결 / Unknown
- 아직 확인해야 할 사실: domain guardrail 구현과 property-based test의 현재 상태.
- 과장하면 안 되는 부분: logger ban이나 marker taxonomy를 DDD 공식 표준처럼 쓰지 않는다.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/clean-architecture-package-layout.md` 에 domain modeling guardrail 글감으로 반영한다.
- 다음 단계: blogify 전 project-local rule과 구현 증거를 분리한다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-domain-modeling-guardrails]]
@@ -0,0 +1,101 @@
---
title: blog-topic / env-example-drift-gate-gradle-2026-06-06
source_type: blog-topic
status: raw
related_branches: [feature-env-driven-runtime-configuration]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, gradle, configuration, 12-factor, fail-fast, developer-experience]
created: 2026-06-06
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: env-example-drift-gate-gradle-2026-06-06
> Layer: `raw/blog-topics/` — 작업에서 나온 블로그 글감 원석. canonical 정제 전 raw 후보.
## Parent / 부모
- [[raw/branch-notes/feature-env-driven-runtime-configuration]]
## 글감 한 줄
"`.env.example` 을 추가하려다 깨달은 것 — 우리 `.env` 는 이미 tracked 였다. drift 게이트의 정답 소스는 템플릿이 아니라 코드가 실제로 요구하는 surface 다."
## 핵심 논지
- 흔한 패턴: secret 때문에 `.env` 를 gitignore 하고 redacted `.env.example` 을 commit. 하지만 `.env.example` 은 "작성 시점 스냅샷"이라 새 env 가 생겨도 갱신 안 돼 drift → 신규 합류자가 복사해 띄우면 누락 env startup 실패.
- **반전(이 프로젝트의 실제 결정)**: ca-tmpl 은 `src/.env` 자체를 git-tracked 로 둔다(로컬 dev 기본값 포함, secret 은 로컬 sentinel). 이 경우 `.env.example` 은 **password 만 가린 중복 사본**이라 가치가 거의 없다. 처음엔 D7 문언대로 `.env.example` + `verifyEnvExample` 을 만들었다가, 리뷰에서 "`.env` 가 이미 tracked 인데 example 이 왜 필요?"라는 지적으로 제거 → task 를 `verifyEnvKeys` 로 rename.
- 결론적 게이트(custom Gradle task `verifyEnvKeys`)는 **template 파일이 아니라 코드 surface 를 기준**으로 두 방향만 강제:
1. `application.yml``${VAR}`(inline default 없는 것=required) 가 전부 `src/.env` 에 존재(누락 0).
2. `src/.env` 의 모든 키가 `application.yml` 어딘가 `${...}` 로 실제 소비됨(orphan/stale 0).
- `inputs.files(...)` 선언으로 Gradle up-to-date 캐싱과 호환, `check``dependsOn` 연결해 CI 필수 게이트화.
- 교훈: "`.env.example` drift 막기"는 수단이지 목적이 아니다. 진짜 목적은 "코드가 요구하는 env 와 운영자가 가진 env 가 일치하는가". `.env` 가 tracked 라면 example 은 군더더기이고, 게이트는 application.yml ↔ `.env` 를 직접 보는 게 맞다.
## 왜 registry 기준이 아니라 application.yml surface 기준인가 (설계 결정)
- contract registry(`env-keys.yaml`)는 **여러 미구현 branch 의 키까지** 포함 → registry 와 `.env` 를 1:1 강제하면 코드에 없는 phantom 키 수십 개를 넣어야 함(운영자가 무시할 값).
- "운영자가 `.env` 만으로 앱을 띄울 수 있는가"가 진짜 목적 → 검증 기준은 **실제 config surface(application.yml placeholder)** 가 맞다. registry 는 governance SSOT 로 별도 유지.
- 교훈: drift gate 의 "정답 소스"는 빌드 가능한 표면이어야지, 미래 계약을 담은 레지스트리가 아니다.
## 곁가지 주제
- 12-factor §III config 와 `APP_` prefix 전면 통일(외부 의존 env 와 시각 분리)의 트레이드오프.
- boolean `true/false`-only, Duration `30s`-only 같은 "기계적으로는 동등하나 팀 규약으로 1택" 결정을 어떻게 문서화/강제하나.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-06-06
- 트리거 연결 노트: [[raw/branch-notes/feature-env-driven-runtime-configuration]]
## 글감 / Topic seed
- 한 문장 요지: `.env.example` drift를 막는 목적은 example 파일 유지가 아니라 실제 config surface와 실행 env key의 정합성을 검증하는 것이다.
- 예상 제목 후보:
- `.env.example`이 아니라 실제 config surface를 검증하기
- Gradle task로 env key drift를 막는 방법
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- ca-tmpl은 env-driven runtime configuration branch에서 env key drift gate를 다뤘다.
- tracked `.env``application.yml` placeholder의 양방향 정합을 보는 방향이 글감의 핵심이다.
- 의견/해석 후보:
- drift gate의 정답 소스는 미래 registry가 아니라 현재 빌드 가능한 runtime surface여야 한다.
## Outline seed
1. `.env.example`은 snapshot이라 drift가 생기기 쉽다.
2. tracked `.env` 정책에서는 example 사본보다 key surface 검증이 더 중요하다.
3. Gradle `verifyEnvKeys`가 application config와 env key를 양방향으로 확인한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/config-and-adapter-templates.md` 후보:
- env key drift gate와 `.env` tracked policy.
- 필요한 추가 검증:
- 실제 `verifyEnvKeys` 구현 여부와 현재 ca-tmpl의 `.env` 추적 정책.
## Sources / 근거 후보
- [[raw/branch-notes/feature-env-driven-runtime-configuration]]
- [[raw/interviews/startup-fail-fast-config-validation-2026-06-06]]
## 미해결 / Unknown
- 아직 확인해야 할 사실: `verifyEnvKeys`가 현재 ca-tmpl 코드에 존재하는지.
- 과장하면 안 되는 부분: draft canonical 확인 전까지 구현 완료처럼 쓰지 않는다.
## 관련 / Related
- [[raw/branch-notes/feature-env-driven-runtime-configuration]]
- [[raw/interviews/startup-fail-fast-config-validation-2026-06-06]]
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/config-and-adapter-templates.md` 에 env key drift gate 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 branch-note/code evidence와 현재 `.env` tracked 정책을 재확인한다.
@@ -0,0 +1,86 @@
---
title: blog-topic / executable-clean-architecture-onboarding-2026-06-25
source_type: blog-topic
status: raw
related_branches: [feature-domain-feature-onboarding-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, architecture, testing, archunit, clean-architecture, multi-module]
created: 2026-06-25
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: executable-clean-architecture-onboarding-2026-06-25
> Layer: `raw/blog-topics/` — multi-module Clean Architecture onboarding checklist 를 실행 가능한 테스트로 만든 경험 글감.
## Parent / 부모
- [[raw/branch-notes/feature-domain-feature-onboarding-contract]] — 문서 중심 onboarding contract를 ArchUnit/JUnit dry-run으로 구현한 작업에서 파생.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-06-25
- 트리거 연결 노트: [[raw/branch-notes/feature-domain-feature-onboarding-contract]]
## 글감 / Topic seed
- 한 문장 요지: Clean Architecture 체크리스트는 README에만 있으면 약하고, test-only dry-run slice와 negative fixture로 만들면 새 도메인 추가 경계가 CI에서 반복 검증된다.
- 예상 제목 후보:
- Clean Architecture 온보딩 체크리스트를 테스트로 바꾸기
- 새 도메인 추가가 아키텍처를 깨지 않는다는 걸 어떻게 증명할까
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- ca-tmpl은 read-only/write onboarding slice를 분리해 정의한다 — 근거 후보: [[raw/branch-notes/feature-domain-feature-onboarding-contract]] D3, D4.
- 이번 구현은 `DomainFeatureOnboardingContractTest``CleanArchitectureTest` rule로 해당 계약을 검증한다 — 근거 후보: [[raw/branch-notes/feature-domain-feature-onboarding-contract]] §구현 결과.
- 경험 후보:
- `./gradlew verifyCleanArchitectureDependencies`, ArchUnit focused test, focused onboarding suite, 전체 `./gradlew test`까지 통과시켰다 — 근거 후보: [[raw/branch-notes/feature-domain-feature-onboarding-contract]] §Verification commands.
- 의견/해석 후보:
- “문서로 합의한 아키텍처”와 “실제로 실패하는 guardrail” 사이에는 큰 차이가 있다. 단, 모든 것을 정적 분석으로 잡을 수는 없으므로 Open Risk를 문서화해야 한다.
## Outline seed
1. 문제: 새 도메인 추가는 controller-only shortcut으로 무너지기 쉽다 — 체크리스트만으로는 반복 검증이 어렵다.
2. 접근: read-only/write 최소 slice를 test-only Ticket fixture로 만든다 — 실제 production domain을 추가하지 않고도 계약을 검증한다.
3. 실패도 데이터로 만든다 — missing transaction boundary와 shared-contract domain pollution을 negative fixture로 잡는다.
4. 한계: ArchUnit direct-call 분석은 helper 뒤를 못 본다 — guardrail과 review의 경계를 같이 적어야 한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/domain-feature-onboarding-guardrails.md` 후보:
- ca-tmpl에서 새 도메인 기능을 추가할 때 통과해야 하는 read/write dry-run guardrail.
- `wiki/concepts/executable-architecture-guardrails.md` 후보:
- 아키텍처 문서 계약을 JUnit/ArchUnit positive/negative fixture로 전환하는 일반 패턴.
- 필요한 추가 검증:
- 실제 downstream 새 도메인 branch에서 false positive/negative 관찰.
- `sampleOffTest`까지 포함한 check matrix에서 시간 비용 측정.
## Sources / 근거 후보
- [[raw/branch-notes/feature-domain-feature-onboarding-contract]] — 구현 결정과 local verification evidence.
- [[raw/errors/gradle-wrapper-sandbox-lock-2026-06-25]] — sandboxed Gradle 검증 문제.
- [[raw/interviews/clean-architecture-domain-onboarding-guardrails]] — 예상 면접 질문 원석.
## 미해결 / Unknown
- 아직 확인해야 할 사실: downstream fork에서 fixture 없이 실제 production domain slice를 추가했을 때 rule coverage가 충분한지.
- 과장하면 안 되는 부분: 이 작업은 local verification이며 운영 검증이나 보편 표준 증명은 아니다.
- 블로그로 쓰기 전에 필요한 canonical 정제: `wiki/projects/ca-tmpl/`에 project fact로 승격 후 blog derive.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/clean-architecture-package-layout.md` 에 executable onboarding guardrails 글감으로 반영했다.
- 다음 단계: source canonical이 `verified` 상태이므로 이후 `blogify` 대상으로 삼을 수 있다. 단 운영 검증이나 보편 표준 증명처럼 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-domain-feature-onboarding-contract]]
- 관련 error: [[raw/errors/gradle-wrapper-sandbox-lock-2026-06-25]]
- 관련 interview prep: [[raw/interviews/clean-architecture-domain-onboarding-guardrails]]
- derived blog: 생성 전
@@ -0,0 +1,87 @@
---
title: blog-topic / five-stage local bootstrap contract
source_type: blog-topic
status: raw
related_branches: [feature-developer-experience-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, ci-cd, gradle, docker]
created: 2026-06-24
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: five-stage-local-bootstrap-contract
## Parent / 부모
- [[raw/branch-notes/feature-developer-experience-contract]] — single-command bootstrap 구현·검증에서 파생.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-06-24
- 트리거 연결 노트: [[raw/branch-notes/feature-developer-experience-contract]]
## 글감 / Topic seed
- 한 문장 요지: 단일 bootstrap 명령의 가치는 명령 수가 아니라 compile, dependency, migration, contract, HTTP smoke 실패를 서로 다른 증거로 분리하는 데 있다.
- 예상 제목 후보:
- Spring Boot 템플릿의 첫 실행을 5단계 Gradle 계약으로 만든 이유
- docker compose up만으로는 잡지 못한 local bootstrap 실패 두 가지
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- `./gradlew bootstrap`이 다섯 task를 순서대로 실행한다 — 근거: [[raw/branch-notes/feature-developer-experience-contract]] D3, §2026-06-24 구현 결과.
- README 명령 drift가 `check`에서 검증된다 — 근거: 같은 branch D4.
- 경험 후보:
- host 5432 publish 제거와 slim JRE RNG bean 수정 뒤 실제 container health + HTTP smoke가 통과했다 — 근거: branch §마주친 문제, 두 error note.
- 의견/해석 후보:
- fresh-clone DX는 문서 친절도보다 실행 가능한 실패 계약으로 평가하는 편이 더 재현 가능하다.
## Outline seed
1. 왜 단일 명령인가 — 사용자가 기억할 entrypoint를 하나로 줄이되 내부 실패는 숨기지 않는다.
2. 다섯 단계의 경계 — compile, dependency, migration/start, delegated sample contract, HTTP smoke가 잡는 결함이 서로 다르다.
3. 실제로 잡힌 두 실패 — host port exposure와 slim JRE provider parity가 unit test만으로 남는 이유.
4. 문서도 빌드 입력이다 — README command drift와 link-rot를 CI 계약으로 만드는 방법.
5. 검증 등급의 경계 — local green과 CI/OS/prod 검증을 구분한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/developer-experience-bootstrap.md` 후보:
- 5단계 task graph, failure contract, local verification 결과.
- `wiki/concepts/runtime-image-parity.md` 후보:
- full JDK test와 slim JRE provider/module parity gap.
- 필요한 추가 검증:
- Linux clean clone, Apple Silicon, WSL2 실행 시간/성공률.
- GitHub Actions link-check 실제 실행과 false-positive 수렴.
## Sources / 근거 후보
- [[raw/branch-notes/feature-developer-experience-contract]] D3, D4, D8, D10.
- [[raw/errors/bootstrap-postgres-port-collision-2026-06-24]].
- [[raw/errors/slim-jre-random-generator-missing-2026-06-24]].
- [[raw/interviews/single-command-local-bootstrap]].
## 미해결 / Unknown
- 아직 확인해야 할 사실: 지원 OS별 first-run 시간과 GitHub link-check 결과.
- 과장하면 안 되는 부분: Linux local 검증을 모든 OS/CI/prod에서의 보장으로 표현하지 않는다.
- 블로그로 쓰기 전에 필요한 canonical 정제: 위 project/concept 후보에 code/test evidence를 추출한다.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/devops-ci-supply-chain-dx.md` 에 five-stage local bootstrap 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 review/verify가 필요하다. Linux local 검증을 모든 OS/CI/prod 보장으로 표현하지 않는다.
## Related / 관련
- [[raw/branch-notes/feature-developer-experience-contract]].
- [[raw/errors/bootstrap-postgres-port-collision-2026-06-24]].
- [[raw/errors/slim-jre-random-generator-missing-2026-06-24]].
- [[raw/interviews/single-command-local-bootstrap]].
- derived blog: 생성 전. canonical 정제 후 `wiki/blog/<slug>-YYYY-MM-DD.md` 후보.
@@ -0,0 +1,94 @@
---
title: blog-topic / Spring Security 없이 application layer 에 method-level 인가 걸기
source_type: blog-topic
status: raw
related_branches: [feature-authentication-authorization-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, security, authorization, clean-architecture, spring-security, method-security]
created: 2026-06-08
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: framework-free method authorization in Clean Architecture
> Layer: `raw/blog-topics/` — 구현에서 나온 블로그 글감 seed. canonical 추출은 `/ingest` 시 별도.
## Parent / 부모
- [[raw/branch-notes/feature-authentication-authorization-contract]]
## 글감 한 줄
"`@PreAuthorize` 를 쓰면 application layer 가 Spring Security 에 결합된다. annotation 은 core 에, 집행은 adapter 에 두면 layer 순수성을 지키면서 method-level 인가를 걸 수 있다."
## 핵심 논지 / Outline
1. **문제**: Clean Architecture 에서 application/domain 은 framework-free 여야 하는데, Spring method security(`@PreAuthorize`/`@Secured`)는 bean 을 `org.springframework.security` 에 결합시킨다.
2. **분리**: *결정(decision)**메커니즘(mechanism)* 분리.
- core: `@RequiresPermission`(plain annotation) + `AuthorizationPort`(plain interface) + `Permission`(value object) + `AuthorizationPrincipal`(raw roles).
- adapter: `AuthorizationManager<MethodInvocation>` 가 annotation 을 읽고 principal 을 매핑해 port 에 위임. `@EnableMethodSecurity(prePostEnabled=false)` + custom `Advisor`(ROLE_INFRASTRUCTURE) 로 wiring.
3. **거부 → 403 의 2-hop**: core 가 자체 예외 throw → adapter 가 Spring `AccessDeniedException` 으로 변환 → 에러 envelope.
4. **permission-centric RBAC**: role=permission 묶음, registry 로 raw role→effective permission 해소(case-insensitive, fail-closed, no wildcard=least-privilege).
5. **함정**: CGLIB vs JDK proxy(concrete 주입 시 `proxyTargetClass=true` 필수), AOP self-invocation bypass, unauthenticated(`AuthenticationException`) vs unauthorized(`AccessDeniedException`) 구분. → [[raw/errors/method-security-cglib-vs-jdk-proxy-usecase-injection-2026-06-08]]
6. **마이그레이션 path**: port interface 덕분에 RBAC→ABAC 전환이 구현체 교체로 끝남.
## 차별점
대부분의 Spring 튜토리얼은 `@PreAuthorize` 를 service 에 바로 붙인다. 이 글은 "왜 그게 hexagonal/clean 구조에서 부채인가 + 어떻게 분리하나"를 코드(TransactionPort 선례와 동일 패턴)로 보여준다.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-06-08
- 트리거 연결 노트: [[raw/branch-notes/feature-authentication-authorization-contract]]
## 글감 / Topic seed
- 한 문장 요지: application layer에 Spring Security annotation을 직접 붙이지 않고 plain annotation + port + adapter method-security로 method-level authorization을 구현한다.
- 예상 제목 후보:
- Clean Architecture에서 framework-free method authorization 만들기
- `@PreAuthorize` 없이 application layer 인가 걸기
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- core annotation과 authorization port는 Spring Security type을 직접 의존하지 않는다.
- adapter가 Spring `AuthorizationManager`와 advisor wiring을 소유한다.
- 의견/해석 후보:
- authorization decision과 framework mechanism을 분리하면 RBAC→ABAC migration path가 단순해진다.
## Outline seed
1. `@PreAuthorize`가 application layer purity를 깨는 경로를 설명한다.
2. core annotation/port와 adapter enforcement를 분리한다.
3. 403 envelope, CGLIB/JDK proxy, self-invocation bypass 한계를 적는다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets.md` 후보:
- framework-free method authorization 글감.
- 필요한 추가 검증:
- 현재 `@RequiresPermission`, `AuthorizationPort`, method security adapter 구현 여부.
## Sources / 근거 후보
- [[raw/branch-notes/feature-authentication-authorization-contract]]
- [[raw/errors/method-security-cglib-vs-jdk-proxy-usecase-injection-2026-06-08]]
## 미해결 / Unknown
- 아직 확인해야 할 사실: method security custom advisor의 현재 구현·검증 여부.
- 과장하면 안 되는 부분: Spring Security 자체를 부정하지 않고 ca-tmpl layer boundary 선택으로 제한한다.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets.md` 에 framework-free method authorization 글감으로 반영한다.
- 다음 단계: blogify 전 구현 증거와 proxy/self-invocation 한계를 확인한다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-authentication-authorization-contract]]
@@ -0,0 +1,82 @@
---
title: blog-topic / gitea-act-dependency-security-gate-portability
source_type: blog-topic
status: raw
related_branches: [feature-dependency-vulnerability-management-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, ci-cd, docker, supply-chain, static-analysis]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: gitea-act-dependency-security-gate-portability
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-dependency-vulnerability-management-contract]] — dependency security gate를 GitHub Actions 전용 action에서 Gitea/act runner 환경으로 옮기는 과정에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-dependency-vulnerability-management-contract]]
## 글감 / Topic seed
- 한 문장 요지: GitHub Actions 전용 dependency-review/trivy-action을 Gitea와 act_runner 환경에서 플랫폼 독립 CLI gate로 조정한 이유를 정리한다.
- 예상 제목 후보:
- dependency security gate를 GitHub Actions 밖으로 옮기기
- Gitea와 act_runner에서 supply chain gate를 유지하는 법
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- branch에 Gitea/act adaptation 기록과 suppression governance 기존 topic이 정리되어 있다 — 근거 후보: [[raw/branch-notes/feature-dependency-vulnerability-management-contract]] Gitea/act notes `:138-149`, existing suppression topic linkage.
- 경험 후보:
- 기존 Trivy suppression topic은 suppression governance이고, 이 글감은 CI platform portability 실패와 CLI 전환이 초점이다.
- 의견/해석 후보:
- supply chain gate는 특정 CI product action에 묶이면 재사용성이 떨어질 수 있다.
## Outline seed
1. GitHub Actions action은 편하지만 platform coupling이 생긴다 — Gitea/act runner에서 깨지는 지점을 본다.
2. CLI gate로 옮기기 — 입력/출력/exit code를 명시하면 CI provider를 바꿔도 계약을 유지할 수 있다.
3. suppression governance와 portability를 분리하기 — 보안 정책과 runner wiring은 다른 소유권이다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/devops-ci-supply-chain-dx.md` 후보:
- ca-tmpl dependency vulnerability gate portability decision.
- `wiki/concepts/devops-ci-supply-chain-dx.md` 후보:
- CI provider portability와 supply chain gate 일반 개념.
- 필요한 추가 검증:
- 실제 Gitea/act failure log, CLI invocation, exit code behavior.
## Sources / 근거 후보
- [[raw/branch-notes/feature-dependency-vulnerability-management-contract]] — Gitea/act adaptation 근거.
- [[raw/blog-topics/trivy-suppression-governance-static-gate-2026-06-20]] — suppression governance 인접 topic.
## 미해결 / Unknown
- 아직 확인해야 할 사실: Gitea/act runner에서 어떤 action이 왜 깨졌는지의 재현 로그.
- 과장하면 안 되는 부분: 모든 CI에서 동작한다고 쓰지 않는다. portability를 높인 설계로 제한한다.
- 블로그로 쓰기 전에 필요한 canonical 정제: devops project 문서의 CI gate evidence 보강.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/devops-ci-supply-chain-dx.md` 에 Gitea/act dependency security gate portability 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 review/verify가 필요하다. 모든 CI에서 동작한다고 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-dependency-vulnerability-management-contract]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/gitea-act-dependency-security-gate-portability-YYYY-MM-DD.md` 후보
@@ -0,0 +1,92 @@
---
title: blog-topic / gradle9-java21-static-analysis-baseline-2026-06-20
source_type: blog-topic
status: raw
related_branches: [feature-static-analysis-quality-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, static-analysis, gradle, spotless, checkstyle, spotbugs, errorprone, java21]
created: 2026-06-20
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: gradle9-java21-static-analysis-baseline-2026-06-20
> Layer: `raw/blog-topics/` — 채용공고가 아닌 작업·학습에서 나온 블로그 글감 원석. canonical 정제 전 raw 후보이며, `wiki/blog/` 직접 생성 근거가 아니다.
## Parent / 부모
- [[raw/branch-notes/feature-static-analysis-quality-contract]] — Spotless + Checkstyle + SpotBugs + FindSecBugs + ErrorProne 5종을 Gradle 9.0.0 / Java 21 멀티모듈에 도입한 작업에서 추출.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 정적 분석 도구가 전무한 greenfield 스켈레톤(10 모듈)에 "비중복·로컬·infra-free" 원칙으로 5종 도구를 한 번에 도입. 도구 선택은 문서에서 끝났지만, 실제 wiring 에서 (1) formatter↔linter 책임 중복, (2) 기존 코드 대량 위반, (3) BOM↔도구 classpath 충돌이 줄줄이 나왔다.
## 글감 코어 / Core idea
- **formatter 와 linter 의 책임 분리(중복 제거)**: google-java-format(Spotless) 가 *포맷·import order* 를 소유하면, Checkstyle 은 그 모듈(`Indentation`/`LineLength`/`WhitespaceAround`/`CustomImportOrder`)을 **반드시 빼야** 한다. 안 그러면 formatter 가 고친 걸 linter 가 reject → CI 무한 reformat 루프(checkstyle #6527). Checkstyle 은 formatter 가 못 하는 것(naming·Javadoc·logical)만 남긴다. "두 도구가 같은 규칙을 강제하지 않게 하는 게 도입의 핵심" 이라는 한 줄.
- **기존 코드에 blocking 게이트를 씌우는 3가지 전략**: ① 전부 컴플라이언스(reformat + Javadoc 327개 작성 — 비현실적·부정확 위험), ② 포맷은 전체 적용 + Javadoc 은 warning-tier 로 시작(추후 승급), ③ ratchet(변경 파일만). 이 스켈레톤은 ②를 택함 — `spotlessApply` 로 654 파일 일괄 포맷(포매터 도입의 표준 절차)하되, Checkstyle Javadoc 규칙은 `severity=warning` + `maxWarnings=∞` 로 reported-but-non-blocking. naming/logical 은 error-tier 유지.
- **idiom false-positive 는 rename 이 아니라 calibrate**: ConstantName 이 SLF4J `private static final Logger log` 를 25건 잡는다 — 하지만 Logger 는 mutable observable state 라 Google §5.2.4 상 *상수가 아님*`log`/`logger` 를 패턴에 허용. InterfaceTypeParameterName 이 F-bounded self-type `ResourceId<SELF ...>``SELF` 를 잡는다 → 타입 파라미터 패턴을 `^[A-Z][A-Z0-9]*$` 로 완화. "규칙이 관용구를 잡으면 코드를 망치지 말고 규칙을 보정한다."
- **BOM 이 도구 classpath 를 오염시킨다**: `io.spring.dependency-management` 는 BOM managed version 을 **모든 configuration**(런타임뿐 아니라 `spotbugs` 도구 설정)에 적용. SpotBugs 4.10.2 가 요구하는 commons-lang3 3.20.0 이 Boot BOM 의 3.17.0 으로 강등 → `NoClassDefFoundError: org.apache.commons.lang3.Strings` 로 분석 worker crash. `resolutionStrategy.force` 는 안 먹히고 `ext['commons-lang3.version']='3.20.0'` 로 managed property 를 override 해야 함. (디버그 전말: [[raw/errors/spotbugs-commons-lang3-bom-downgrade-noclassdef-2026-06-20]])
- **SpotBugs 노이즈는 reportLevel 로 끊는다**: 기본(medium)에서 78건 중 38건이 EI_EXPOSE_REP/REP2 — 생성자가 주입받은 EntityManager/repository/Clock/ObjectMapper 를 "방어적 복사 안 했다" 고 잡는 노이즈(DI 협력자는 복사하면 안 됨). `reportLevel='high'` 로 high-confidence 만 blocking → 노이즈 제거. 남는 high 보안 finding(SPRING_CSRF_PROTECTION_DISABLED)은 stateless JWT API 에서 의도된 설정이라 exclude.xml 로 근거와 함께 suppress.
- **게이트 실효성 증명**: `./gradlew check` 가 green 한 번으로 끝내지 말고, 의도적 위반(나쁜 포맷 + `Bad_Method_Name`)을 주입해 spotlessCheck/checkstyleMain 이 실제로 BUILD FAILED 하는지(gate bites) 확인 후 원복.
## 글감 / Topic seed
- 한 문장 요지: Gradle 9 / Java 21 멀티모듈에 정적 분석 baseline을 넣을 때 핵심은 plugin 나열이 아니라 formatter-linter 책임 분리, 기존 코드 마이그레이션, 도구 classpath 충돌 처리다.
- 예상 제목 후보:
- Gradle 9와 Java 21에서 static analysis baseline을 잡는 법
- Spotless, Checkstyle, SpotBugs, ErrorProne을 한 번에 넣으며 배운 것
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- Spotless가 format/import order를 소유하면 Checkstyle의 중복 formatting rule은 제거해야 한다.
- Spring dependency management BOM은 SpotBugs tool configuration의 transitive dependency에도 영향을 줄 수 있다.
- Javadoc rule은 warning-tier로 시작하고 naming/logical rule은 blocking으로 둘 수 있다.
- 의견/해석 후보:
- static analysis baseline은 도구 도입보다 기존 코드와 CI가 감당할 수 있는 승급 경로 설계가 더 중요하다.
## Outline seed
1. formatter와 linter가 같은 규칙을 강제할 때 생기는 reformat loop를 설명한다.
2. 기존 코드 위반을 한 번에 blocking하지 않고 warning-tier/ratchet/전면 수정 중 선택하는 기준을 정리한다.
3. Spring BOM이 SpotBugs classpath를 오염시킨 사례와 해결 방향을 적는다.
4. 의도적 위반 주입으로 gate가 실제로 실패하는지 확인하는 절차를 남긴다.
## 왜 의미 있나 / Why it matters
- "정적 분석 도구 도입" 은 plugin 한 줄이 아니라, **책임 중복 제거 + 기존 코드 마이그레이션 전략 + 도구/BOM classpath 충돌** 의 묶음이다. 실무에서 그대로 부딪히는 함정들이라 이식성이 높다.
- Gradle 9 + Java 21(record/sealed bytecode) 환경에서 5종 도구의 버전 호환을 실측으로 확정한 사례. 문서가 "Gradle 7+/JRE 17+" 만 명시할 때 실제로 도는지는 별개라는 점.
- 한계(글에서 명시): Javadoc 은 아직 warning-tier(blocking 미승급), SonarQube 는 외부 서비스라 기본 배제(opt-in 문서만). 즉 "완성된 게이트" 가 아니라 "정직하게 단계적으로 조이는 baseline".
## 관련 / Related
- [[raw/errors/spotbugs-commons-lang3-bom-downgrade-noclassdef-2026-06-20]]
- [[raw/branch-notes/feature-static-analysis-quality-contract]]
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/devops-ci-supply-chain-dx.md` 후보:
- Gradle 9 / Java 21 static analysis baseline 글감.
- 필요한 추가 검증:
- 현재 Spotless/Checkstyle/SpotBugs/ErrorProne wiring과 warning-tier 상태.
## Sources / 근거 후보
- [[raw/branch-notes/feature-static-analysis-quality-contract]]
- [[raw/errors/spotbugs-commons-lang3-bom-downgrade-noclassdef-2026-06-20]]
## 미해결 / Unknown
- 아직 확인해야 할 사실: Javadoc warning-tier가 blocking으로 승급됐는지.
- 과장하면 안 되는 부분: static analysis baseline을 운영 품질 보장처럼 쓰지 않는다.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/devops-ci-supply-chain-dx.md` 에 Gradle 9 / Java 21 static analysis baseline 글감으로 반영한다.
- 다음 단계: blogify 전 실제 current tool versions와 gate-bites evidence를 확인한다.
@@ -0,0 +1,101 @@
---
title: "HikariCP knob 간 제약을 Spring Boot 시작 guard 로 강제하는 패턴"
source_type: blog-topic
status: raw
related_branches: [feature-database-connection-pool-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, hikaricp, spring-boot, startup-validation, connection-pool, clean-architecture]
created: 2026-06-09
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# HikariCP inter-knob constraints as a Spring Boot startup guard
## Parent
- [[raw/branch-notes/feature-database-connection-pool-contract]]
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-06-09
- 트리거 연결 노트: [[raw/branch-notes/feature-database-connection-pool-contract]]
## 글감 / Topic seed
- 한 문장 요지: HikariCP knob 간 제약을 runtime 경고에 맡기지 않고 Spring Boot startup guard로 수집해 fail-fast시키는 패턴이다.
- 예상 제목 후보:
- HikariCP 설정 오류를 startup에서 잡기
- Connection pool knob 제약을 Spring Boot guard로 고정하기
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- `validationTimeout < connectionTimeout`, `keepaliveTime < maxLifetime` 같은 inter-knob 제약이 있다.
- `SmartInitializingSingleton``ApplicationContextRunner`로 startup guard를 검증할 수 있다.
- 의견/해석 후보:
- pool 설정 오류는 traffic을 받기 전 startup phase에서 실패시키는 편이 운영적으로 더 명확하다.
## Outline seed
1. HikariCP knob 간 제약과 runtime 경고/reset의 한계를 정리한다.
2. String 기반 defensive parse로 Duration drift를 안전하게 처리한다.
3. `ApplicationContextRunner`로 guard failure를 작은 테스트로 고정한다.
## 핵심 아이디어
HikariCP 에는 knob 간 순서 제약이 있다:
- `validationTimeout < connectionTimeout`
- `keepaliveTime < maxLifetime`
- `connectionTimeout >= 250 ms`
- `leakDetectionThreshold >= 2000 ms` (0 = disabled 허용)
이 제약들은 HikariCP 내부에서 경고 또는 reset 으로만 처리되고, 설정 오류가 runtime 에서만 드러나는 경우가 많다. `SmartInitializingSingleton` + `Environment.getProperty(key)` (String, not typed) 패턴으로 context refresh 완료 직전에 모든 위반을 한꺼번에 수집해 `IllegalStateException` 으로 boot fail 시키면, 잘못된 pool 설정이 prod 에 배포되는 것을 막을 수 있다.
## 흥미로운 구현 포인트
### Defensive parseMillis (CONNECTION_TIMEOUT_FORMAT_DRIFT)
`environment.getProperty("spring.datasource.hikari.connection-timeout", Long.class)` 는 env-keys.yaml default 가 `"5s"` (Duration string) 일 때 `ConversionFailedException` 을 던진다. 대신 `getProperty(key)` 로 String 을 받아 `Long.parseLong(raw.trim())` + `NumberFormatException catch → return null` 패턴으로 방어 파싱하면:
1. 숫자 ms 값은 정상 검증
2. Duration string 은 null (absent 취급) — 크래시 없이 skip
3. 명세에서 두 포맷이 공존하는 drift 환경에서 안전
### ApplicationContextRunner 기반 단위 테스트
`@SpringBootTest` 없이 `ApplicationContextRunner.withUserConfiguration(ValidatorConfig.class)` 만으로 `SmartInitializingSingleton``afterSingletonsInstantiated()` 가 호출된다. `context.hasFailed()` + `context.getStartupFailure().hasStackTraceContaining(...)` 으로 각 위반 케이스를 격리 검증.
## 글감 방향
- Spring Boot startup contract 패턴 시리즈 (`SmartInitializingSingleton` vs `ApplicationListener<ContextRefreshedEvent>` vs `@PostConstruct`)
- HikariCP 운영에서 놓치기 쉬운 knob 간 제약 총정리
- "설정 오류를 runtime 이 아닌 startup 에서 잡는다" 원칙의 구현 패턴들
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound.md` 후보:
- HikariCP inter-knob constraint startup guard를 data-layer/pool configuration 글감으로 연결.
- 필요한 추가 검증:
- 실제 validator class, `ApplicationContextRunner` 테스트, env duration drift 처리 범위.
## Sources / 근거 후보
- [[raw/branch-notes/feature-database-connection-pool-contract]]
## 미해결 / Unknown
- 아직 확인해야 할 사실: 현재 ca-tmpl 코드에 startup guard와 관련 테스트가 존재하는지.
- 과장하면 안 되는 부분: HikariCP 자체가 모든 오류를 방치한다고 쓰지 않고, ca-tmpl에서 선택한 fail-fast 보강으로 제한한다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-database-connection-pool-contract]]
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound.md` 에 HikariCP startup guard 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 pool guard 구현·검증 여부를 branch-note/code 기준으로 확인한다.
@@ -0,0 +1,101 @@
---
title: blog-topic / idempotency-executor-application-layer-clean-architecture-2026-06-09
source_type: blog-topic
status: raw
related_branches: [feature-rate-limit-idempotency-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, idempotency, clean-architecture, rate-limit]
created: 2026-06-09
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: 멱등성을 application layer 실행기로 — Clean Architecture에서 rate-limit/idempotency 운영 계약
> Layer: `raw/blog-topics/` — feature-rate-limit-idempotency-contract 구현에서 나온 글감.
## Parent / 부모
- [[raw/branch-notes/feature-rate-limit-idempotency-contract]]
## 글감 / Topic
운영 멱등성과 rate-limit을 "프레임워크 미들웨어"가 아니라 **계층 소유권**으로 배치한 사례.
## 글감 / Topic seed
- 한 문장 요지: idempotency는 application-layer executor가, rate-limit은 presentation interceptor가 소유하도록 나눠 Clean Architecture 경계를 명시한다.
- 예상 제목 후보:
- 멱등성을 application layer 실행기로 두기
- Clean Architecture에서 idempotency와 rate-limit 소유권 나누기
### 다룰 포인트
1. **owner_layer 분리**: idempotency 코드(409/422)는 `owner_layer: application``IdempotencyExecutor`
포트 + `IdempotencyStore` 포트 + DB 어댑터로 application/infra에 둠. rate-limit(429)은
`owner_layer: presentation` → adapter-web `HandlerInterceptor`. 같은 "운영 횡단 관심사"라도 코드/응답
소유 계층이 다르다.
2. **filter vs interceptor**: rate-limit key가 `IP + uri_template(normalized)`를 요구 →
servlet filter는 handler mapping 이전이라 route template(`/v1/worklogs/{id}`)을 모름.
`HandlerInterceptor`로 옮겨 `BEST_MATCHING_PATTERN_ATTRIBUTE`를 사용.
3. **명시적 실행기 vs AOP**: KEYED use case를 BeanPostProcessor/AOP로 감싸는 대신 `executor.execute(ctx, action, codec)`
명시 호출. 호출부가 약간 장황하지만 ArchUnit/테스트 단순성과 스켈레톤 투명성을 얻음.
4. **insert-or-read + unique 제약을 동시성 중재자로**: 200ms in-flight wait는 IETF 즉시-409 SHOULD의
"운영 친화적 변형"(표준 그대로 아님). fingerprint(SHA-256) mismatch는 IETF 422 권고 정합.
5. **port-codec 분리로 application의 wire-format 중립성**: 실행기는 `IdempotentResponseCodec<R>`(web JSON 소유)로
직렬화만 위임 → application은 transport/storage 중립.
6. **UNSUPPORTED_IMPL_DECISION 정직성**: 200ms·SHA-256·8KB·fixed-window·canonicalization 미적용은
외부 표준이 강제하지 않음을 코드 주석에 명시 — "표준 따름" 과장 금지.
## 관련
- [[wiki/concepts/idempotency-key-design]]
- [[raw/errors/idempotency-expired-row-reclaim-409-loop-2026-06-09]]
- [[raw/errors/scheduled-reaper-wrong-config-prefix-2026-06-09]]
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/idempotency-key-design.md` 후보:
- idempotency executor의 application-layer ownership와 rate-limit presentation ownership 분리.
- 필요한 추가 검증:
- `IdempotencyExecutor`, `IdempotencyStore`, response codec, interceptor 구현 여부.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-06-09
- 트리거 연결 노트: [[raw/branch-notes/feature-rate-limit-idempotency-contract]]
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- idempotency와 rate-limit은 같은 운영 횡단 관심사처럼 보여도 owner layer가 다르다.
- idempotency executor는 application/use case 실행 경계에 놓고, rate-limit은 route template을 아는 web interceptor가 맡는 구조다.
- 의견/해석 후보:
- AOP보다 명시 실행기가 skeleton 투명성과 테스트 용이성을 준다.
## Outline seed
1. owner layer를 application과 presentation으로 나눈다.
2. filter와 interceptor가 route template을 볼 수 있는 시점 차이를 설명한다.
3. `IdempotencyExecutor`와 response codec 분리로 wire format 중립성을 유지한다.
## Sources / 근거 후보
- [[raw/branch-notes/feature-rate-limit-idempotency-contract]]
- [[wiki/concepts/idempotency-key-design]]
- [[raw/errors/idempotency-expired-row-reclaim-409-loop-2026-06-09]]
- [[raw/errors/scheduled-reaper-wrong-config-prefix-2026-06-09]]
## 미해결 / Unknown
- 아직 확인해야 할 사실: 현재 ca-tmpl 코드의 executor/storage/interceptor 구현 여부.
- 과장하면 안 되는 부분: IETF draft 준수와 ca-tmpl의 200ms wait 변형을 섞지 않는다.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/idempotency-key-design.md` 에 application-layer idempotency executor 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 실제 구현 등급을 재확인한다.
@@ -0,0 +1,81 @@
---
title: blog-topic / identifier-governance-rule-scoping-by-id-kind-2026-06-01
source_type: blog-topic
status: raw
related_branches: [feature-resource-identifier-contract, feature-boundary-validation-mapping-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, archunit, identifier, architecture, ddd, clean-architecture]
created: 2026-06-01
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: identifier-governance-rule-scoping-by-id-kind-2026-06-01
> Layer: `raw/blog-topics/` — 채용공고가 아닌 작업·학습·트러블슈팅에서 나온 블로그 글감 원석.
## Parent / 부모
- [[raw/branch-notes/feature-resource-identifier-contract]] — D5(도메인 port + application orchestration) + D17(4 ArchUnit rule) 결정.
- [[raw/errors/archunit-no-uuid-random-trace-id-false-positive-2026-06-01]] — resource-id rule이 trace-id 생성을 잘못 잡은 사건.
## 트리거 / Trigger
- 트리거 유형: `branch-work` / `error`
- 트리거 날짜: 2026-06-01
- 트리거 연결 노트: [[raw/errors/archunit-no-uuid-random-trace-id-false-positive-2026-06-01]]
## 글감 / Topic seed
- 한 문장 요지: 한 시스템에는 ID가 여러 종류(resource / trace / session / idempotency-key / api-key)가 공존하고, 각각 **생성 주체·형식·수명·책임 branch가 다르다**. "모든 `UUID.randomUUID()`를 금지"하는 ArchUnit rule은 합법적인 trace-id 생성을 잡는 false positive를 낳는다 — 거버넌스 규칙은 *ID 종류별로* scope해야 한다.
- 떠오른 계기: resource-id 전용 `no_uuid_random_in_controller``RequestLoggingFilter`의 correlation-id 생성을 잡음.
- 예상 제목 후보:
- "ID 종류별 거버넌스": 한 규칙으로 모든 식별자를 다스리려다 생긴 false positive
- Clean Architecture에서 도메인 식별자를 인프라 결합 없이 생성하기 (port + application orchestration)
- ArchUnit fitness function의 scope 설계: 결정 텍스트 vs reference 코드
## 핵심 주장 후보 / Claim candidates
- DDD factory pattern은 "entity가 자기 ID를 minting"하라고 요구하지 않는다 — factory는 도메인 *service/port*이고, 생성 *호출 시점*은 use case orchestration이다. 도메인 순수성(인프라 라이브러리 미결합)과 server-assigned id를 동시에 만족.
- 식별자 거버넌스 ArchUnit rule은 대상 ID의 *종류*를 명시해야 한다: resource id는 controller/use case에서 직접 생성 금지(factory 강제), trace id는 filter에서 생성 정상(distributed-tracing 책임), idempotency-key는 client 생성(rate-limit 책임).
- rule selector는 spec의 "결정 텍스트(좁은 의도)"와 "reference 코드(넓은 예시)"가 어긋날 때 결정 텍스트를 따른다.
- 사람이 만든 sealed 계층 enumeration이 모듈 경계로 불가능할 때, ArchUnit rule(`no_long_id_pk`)이 compile-time `sealed permits`의 빌드타임 대체가 된다.
## Outline seed
1. ID는 하나의 범주가 아니라 resource/trace/session/idempotency/api key처럼 책임이 나뉜다.
2. 너무 넓은 ArchUnit rule은 합법적인 trace-id 생성까지 잡는 false positive를 만든다.
3. governance rule은 결정 텍스트의 좁은 의도와 ID kind별 owner를 기준으로 scope한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/resource-identifier-format.md` 후보:
- ca-tmpl identifier governance rule scoping 결정과 false-positive boundary.
- `wiki/concepts/resource-identifier-format.md` 후보:
- ID kind별 governance 일반 개념.
## Sources / 근거 후보
- [[raw/branch-notes/feature-resource-identifier-contract]] — resource identifier governance 결정.
- [[raw/errors/archunit-no-uuid-random-trace-id-false-positive-2026-06-01]] — trace-id false-positive 사건.
## 미해결 / Unknown
- 아직 확인해야 할 사실: resource-id rule과 tracing/correlation-id rule의 owner 경계를 concept 문서에 어느 수준까지 일반화할지.
- 과장하면 안 되는 부분: 모든 `UUID.randomUUID()` 호출을 금지하는 것이 정답이라고 쓰지 않는다.
- 블로그로 쓰기 전에 필요한 canonical 정제: 이미 `wiki/projects/ca-tmpl/resource-identifier-format.md`에 연결됨. ID kind별 일반 개념은 blogify 전 확인한다.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/resource-identifier-format.md` 에 identifier kind별 governance scoping 글감으로 반영했다.
- 다음 단계: source canonical이 `verified` 상태이므로 이후 `blogify` 대상으로 삼을 수 있다. 단 모든 `UUID.randomUUID()` 금지가 정답이라고 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-resource-identifier-contract]]
- 관련 error: [[raw/errors/archunit-no-uuid-random-trace-id-false-positive-2026-06-01]]
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/identifier-governance-rule-scoping-by-id-kind-YYYY-MM-DD.md` 후보
@@ -0,0 +1,82 @@
---
title: blog-topic / java21-context-propagation-strategy-virtual-threads
source_type: blog-topic
status: raw
related_branches: [feature-runtime-context-propagation-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, runtime, java-21, loom, virtual-threads, thread-local]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: java21-context-propagation-strategy-virtual-threads
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-runtime-context-propagation-contract]] — ThreadLocal, Micrometer Context Propagation, Java 21 ScopedValue 선택 기준에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-runtime-context-propagation-contract]]
## 글감 / Topic seed
- 한 문장 요지: Java 21 환경에서 request/security/tenant context를 ThreadLocal, Micrometer Context Propagation, ScopedValue 중 어디까지 다룰지 선택 기준을 정리한다.
- 예상 제목 후보:
- Java 21에서 context propagation을 다시 봐야 하는 이유
- ThreadLocal에서 ScopedValue로 바로 갈 수 없는 이유
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- branch가 Java 21 ScopedValue, Micrometer Context Propagation, ThreadLocal 선택 기준 topic을 명시한다 — 근거 후보: [[raw/branch-notes/feature-runtime-context-propagation-contract]] section+line `:268-271`.
- 경험 후보:
- 기존 async TaskDecorator topic은 executor/MDC 쪽에 가깝고, 이 글감은 runtime context propagation strategy 자체를 다룬다.
- 의견/해석 후보:
- context propagation은 API 선택 문제가 아니라 thread model, observability, security context boundary를 같이 보는 문제다.
## Outline seed
1. ThreadLocal은 익숙하지만 thread model에 묶인다 — executor, virtual thread, async boundary에서 다시 검토해야 한다.
2. Micrometer Context Propagation은 관측성 중심의 장점이 있다 — trace/log context와 application context를 섞지 않아야 한다.
3. ScopedValue는 매력적이지만 adoption boundary가 있다 — Java version, framework support, migration cost를 본다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/runtime-context-propagation.md` 후보:
- ca-tmpl runtime context propagation project decision.
- `wiki/concepts/context-propagation-java-virtual-threads.md` 후보:
- Java 21 context propagation 일반 개념.
- 필요한 추가 검증:
- ca-tmpl의 실제 ThreadLocal implementation과 virtual thread 지원 여부.
## Sources / 근거 후보
- [[raw/branch-notes/feature-runtime-context-propagation-contract]] — context propagation topic seed 근거.
- [[raw/blog-topics/spring-async-taskdecorator-bounded-executor-saturation-shutdown-2026-06-13]] — 인접한 async context topic.
## 미해결 / Unknown
- 아직 확인해야 할 사실: Java 21 ScopedValue를 실제 production path에 적용했는지 여부.
- 과장하면 안 되는 부분: ScopedValue 채택 경험처럼 쓰면 안 된다. 선택 기준/후보로 분리한다.
- 블로그로 쓰기 전에 필요한 canonical 정제: runtime context propagation project 문서 생성 또는 runtime 문서 통합.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/runtime-container-health-migration.md` 에 Java 21 context propagation 선택 기준 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 review/verify가 필요하다. ScopedValue 채택 경험처럼 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-runtime-context-propagation-contract]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/java21-context-propagation-strategy-virtual-threads-YYYY-MM-DD.md` 후보
@@ -0,0 +1,81 @@
---
title: blog-topic / jdk-httpclient-dns-connectexception-classification
source_type: blog-topic
status: raw
related_branches: [feature-outbound-http-client-baseline]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, integration, networking, spring-boot, retry-policy, api-contract]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: jdk-httpclient-dns-connectexception-classification
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-outbound-http-client-baseline]] — outbound HTTP failure taxonomy에서 DNS 실패 분류 edge case로 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-outbound-http-client-baseline]]
## 글감 / Topic seed
- 한 문장 요지: JDK HttpClient에서 DNS 실패를 connection failure로 분류할 때 exception wrapping과 retry category를 어떻게 다뤘는지 정리한다.
- 예상 제목 후보:
- JDK HttpClient DNS 실패는 어떤 outbound failure일까
- DNS failure를 retryable connection error로 분류할 때 조심할 점
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- branch가 DNS 실패 분류 topic을 명시한다 — 근거 후보: [[raw/branch-notes/feature-outbound-http-client-baseline]] line `:373`.
- 경험 후보:
- DNS failure classification은 outbound failure taxonomy의 실제 edge case로 남아 있다.
- 의견/해석 후보:
- retry policy는 HTTP status만 보아서는 부족하고, connect/DNS/TLS/read timeout 같은 transport failure를 별도로 분류해야 한다.
## Outline seed
1. HTTP client failure는 HTTP status만이 아니다 — DNS, connect, TLS, read timeout을 transport layer로 분리한다.
2. JDK HttpClient exception wrapping 읽기 — root cause와 exposed exception이 다를 수 있다.
3. retry category로 연결하기 — connection failure와 remote 5xx를 같은 방식으로 다루지 않는다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/outbound-http-client-baseline.md` 후보:
- ca-tmpl outbound HTTP failure classification 구현 사실.
- `wiki/concepts/outbound-http-failure-classification.md` 후보:
- outbound failure taxonomy 일반 개념.
- 필요한 추가 검증:
- JDK HttpClient DNS 실패 재현 테스트와 exception class chain.
## Sources / 근거 후보
- [[raw/branch-notes/feature-outbound-http-client-baseline]] — DNS classification topic seed 근거.
## 미해결 / Unknown
- 아직 확인해야 할 사실: DNS failure가 실제 코드에서 어떤 exception path로 들어오는지.
- 과장하면 안 되는 부분: 운영 장애 사례처럼 쓰지 않는다. local/test evidence 중심 글감으로 제한한다.
- 블로그로 쓰기 전에 필요한 canonical 정제: outbound HTTP project 문서의 failure taxonomy 갱신.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound.md` 에 JDK HttpClient DNS/ConnectException classification 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 review/verify가 필요하다. 운영 장애 사례처럼 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-outbound-http-client-baseline]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/jdk-httpclient-dns-connectexception-classification-YYYY-MM-DD.md` 후보
@@ -0,0 +1,81 @@
---
title: blog-topic / jvm-oom-vs-container-oomkill-exit-137
source_type: blog-topic
status: raw
related_branches: [feature-container-runtime-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, runtime, docker, kubernetes, graceful-shutdown]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: jvm-oom-vs-container-oomkill-exit-137
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-container-runtime-contract]] — container runtime 계약과 OOM 137 구분 검증에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-container-runtime-contract]]
## 글감 / Topic seed
- 한 문장 요지: JVM OOM과 container OOMKill이 모두 exit 137처럼 보일 때 heap dump/native stderr/runtime signal로 구분하는 방법을 정리한다.
- 예상 제목 후보:
- exit 137만 보고 JVM OOM과 OOMKill을 구분할 수 있을까
- container runtime에서 Java OOM을 관측 가능하게 만들기
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- branch가 JVM OOM과 kubelet/container OOMKill 구분을 blog seed로 남겼다 — 근거 후보: [[raw/branch-notes/feature-container-runtime-contract]] D5 section+line `:210-219`, blog seed `:331-335`, verification `:286`.
- 경험 후보:
- Docker runtime 검증 기록이 있어 운영 면접/블로그 소재로 확장 가능하다.
- 의견/해석 후보:
- exit code는 진단의 출발점일 뿐 원인 판정 근거가 아니다. JVM 내부 OOM과 외부 kill signal을 분리할 관측 자료가 필요하다.
## Outline seed
1. exit 137은 증상이지 원인이 아니다 — JVM process가 죽은 이유를 runtime 계층별로 나눠야 한다.
2. JVM OOM에는 JVM이 남길 수 있는 흔적이 있다 — heap dump, error log, stderr가 핵심 단서가 된다.
3. container OOMKill은 외부에서 죽인다 — runtime event와 memory limit 관측이 필요하다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/runtime-container-health-migration.md` 후보:
- ca-tmpl container runtime OOM 관측 계약.
- `wiki/concepts/runtime-container-health-migration.md` 후보:
- JVM OOM과 container OOMKill 분리 일반 개념.
- 필요한 추가 검증:
- 실제 Docker/Kubernetes 재현 절차와 로그/exit code evidence.
## Sources / 근거 후보
- [[raw/branch-notes/feature-container-runtime-contract]] — D5, blog seed, verification 근거.
## 미해결 / Unknown
- 아직 확인해야 할 사실: Kubernetes 환경에서의 event/log evidence와 ca-tmpl 검증 범위.
- 과장하면 안 되는 부분: 운영 Kubernetes에서 검증된 장애 대응 경험처럼 쓰면 안 된다. local/Docker 검증과 운영 가정을 분리한다.
- 블로그로 쓰기 전에 필요한 canonical 정제: runtime project 문서의 evidence grade 확인.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/runtime-container-health-migration.md` 에 JVM OOM vs container OOMKill 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 review/verify가 필요하다. 운영 Kubernetes 장애 대응 경험처럼 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-container-runtime-contract]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/jvm-oom-vs-container-oomkill-exit-137-YYYY-MM-DD.md` 후보
@@ -0,0 +1,76 @@
---
title: blog-topic / logback-layer1-secret-masking-json-vs-pattern-2026-06-14
source_type: blog-topic
status: raw
related_branches: [feature-log-management-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, logback, logstash-encoder, masking, security, observability, redaction]
created: 2026-06-14
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: logback-layer1-secret-masking-json-vs-pattern-2026-06-14
> Layer: `raw/blog-topics/` — 글감 원석. canonical `wiki/blog` 정제 전.
## Parent / 부모
- [[raw/branch-notes/feature-log-management-contract]] — Redaction Layer 1(DRIFT-2) 구현에서 파생.
## 트리거 / Trigger
"ERROR/WARN 로그에 token/password/Authorization 헤더를 `****` 로 가린다"는 계약을 `%replace(%msg){...}` 한 줄로 끝내려다, 운영 포맷이 `LogstashEncoder`(JSON) 임을 깨달음. `%replace` 는 PatternLayout converter 인데 JSON encoder 는 PatternLayout 을 **우회**한다 → JSON 로그에는 마스킹이 안 걸린다. 정작 가려야 할 production(JSON) 경로가 무방비.
## 글감 / Topic seed
"Logback 에서 secret 마스킹을 제대로 하려면 — `%replace` 가 JSON 을 못 가리는 이유와 단일 정규식 SSOT 설계":
1. **두 갈래 인코딩 경로**: 사람이 읽는 `PatternLayout`(local/dev) vs 구조화 `LogstashEncoder`(staging/prod). `%replace` 는 전자에만 적용.
2. **JSON 경로의 올바른 도구**: `net.logstash.logback.mask.MaskingJsonGeneratorDecorator` + `ValueMasker` — JSON 생성 시점에 모든 string value(message/MDC/stack trace)를 정규식으로 치환. `<jsonGeneratorDecorator>` 로 encoder 에 장착.
3. **pattern 경로**: `MessageConverter` 를 상속한 custom converter(`%maskedMsg`)로 같은 정규식 적용.
4. **단일 SSOT**: 두 경로가 **같은** `LogMaskingPatterns`(컴파일된 `List<Pattern>` + capture-group replacement)를 공유 → profile/포맷 전환이 마스킹 대상을 바꾸지 못함(D10 "가독성 전환이 secret 노출로 이어지지 않게").
5. **정규식 설계**: keyword(group1)+separator(group2)+value(group3) 로 캡처 후 `$1$2****` 치환 → 키는 남기고 값만 가림. 부정 문자클래스(`[^\s"',&}]+`)로 catastrophic backtracking 회피. Authorization/Bearer 별도 룰.
6. **한계 명시**: 정규식 기반은 obfuscated 인코딩(Base64URL blob, prefix 없는 토큰)을 놓칠 수 있음 → 1차 방어는 여전히 "logger 가 payload/body 인자를 안 받음(by construction)". 마스킹은 defence-in-depth.
## 핵심 주장 후보 / Claim candidates
- `%replace` 만으로 "로그 마스킹 했다"는 JSON 운영에서 거짓 안심이다 — encoder 가 PatternLayout 을 우회하면 무력.
- 마스킹 정규식은 **인코딩 경로마다 중복하지 말고** 단일 Java SSOT 로 두고 decorator/converter 두 어댑터가 참조해야 한다 — 그래야 profile 전환이 보안 동작을 못 바꾼다.
- value 캡처 그룹 + `$n` 역참조 치환으로 "키는 보존, 값만 마스킹" → 진단 가능성과 보안의 균형.
## 관련 / Related
- [[raw/branch-notes/feature-log-management-contract]]
- 동반 면접 노트(드롭 메트릭 결정론 테스트 + 마스킹 Q): [[raw/interviews/deterministic-logback-asyncappender-drop-metric-test-2026-06-14]]
## Outline seed
1. PatternLayout `%replace`가 JSON encoder 경로를 우회하는 문제를 설명한다.
2. `MaskingJsonGeneratorDecorator`와 custom `%maskedMsg`가 같은 SSOT regex를 공유하게 한다.
3. regex masking의 한계와 logger signature의 by-construction 방어를 분리한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/observability-log-metric-trace-runbook.md` 후보:
- Logback JSON vs pattern masking Layer 1 글감.
- 필요한 추가 검증:
- 현재 `LogMaskingPatterns`, JSON decorator, pattern converter 구현 여부.
## Sources / 근거 후보
- [[raw/branch-notes/feature-log-management-contract]]
- [[raw/interviews/deterministic-logback-asyncappender-drop-metric-test-2026-06-14]]
## 미해결 / Unknown
- 아직 확인해야 할 사실: 현재 ca-tmpl 코드에 JSON masking decorator와 pattern converter가 존재하는지.
- 과장하면 안 되는 부분: regex masking이 모든 secret 형태를 잡는다고 쓰지 않는다.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/observability-log-metric-trace-runbook.md` 에 Logback JSON/pattern masking 글감으로 반영한다.
- 다음 단계: blogify 전 masking 구현 등급과 payload logging 금지 rule을 분리한다.
@@ -0,0 +1,81 @@
---
title: blog-topic / manifest-driven-agent-harness-policy-engine
source_type: blog-topic
status: raw
related_branches: [chore-harness-policy-engine-alignment]
related_projects: [ca-skeleton]
tags: [blog-topic, ca-skeleton, architecture, build-tooling, code-generation]
created: 2026-07-20
status_label: captured
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: manifest-driven-agent-harness-policy-engine
## 부모
- [[raw/branch-notes/chore-harness-policy-engine-alignment]] — 실제 harness drift 감사와 구현에서 나온 글감.
## 트리거
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-20
- 트리거 연결 노트: [[raw/branch-notes/chore-harness-policy-engine-alignment]]
## 글감
- 한 문장 요지: 중복 prompt 모음을 module registry, strict evidence, deterministic renderer, risk profile을 가진 실행 가능한 policy engine으로 바꾼 과정.
- 예상 제목 후보:
- Clean Architecture Agent Harness를 Manifest-Driven Policy Engine으로 바꾸기
- Prompt 동기화가 아니라 Mutation Test로 지키는 멀티 플랫폼 개발 하네스
## 핵심 주장 후보
- 사실 후보: flat path gate는 실제 nested adapter production 경로를 놓쳤다 — 근거: branch §마주친 문제.
- 사실 후보: 19개 leaf registry를 Gradle/import/agent consumer가 함께 사용한다 — 근거: branch D1.
- 경험 후보: 세 차례 architecture review와 spec review에서 revision surface, risk, verdict evidence의 우회를 mutation test로 닫았다 — 근거: branch §검증 결과.
- 의견/해석 후보: agent prompt를 문서가 아니라 생성·검증 가능한 artifact로 다뤄야 장기 drift를 줄일 수 있다.
## Outline seed
1. 감사에서 드러난 topology drift — legacy flat path가 왜 green test 뒤에 숨었는지.
2. registry와 immutable task packet — module owner, profile, rule hash를 한 번 resolve하는 방식.
3. platform renderer와 thin adapter — 공통 의미와 제품별 hook 문법을 분리하는 방식.
4. fail-closed evidence chain — counts, command rows, revision, upstream artifact를 검증한 이유.
5. risk-based ceremony — N!·전수 matrix 대신 low/medium/high와 evidence profile을 쓴 이유.
6. 검증의 경계 — static parity는 external authenticated E2E가 아니며 baseline failure도 별도로 남겨야 한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/manifest-driven-agent-harness.md` 후보: ca-tmpl 실제 구조·테스트·review 결과.
- `wiki/concepts/agent-harness-policy-engine.md` 후보: registry, renderer, evidence identity의 일반 패턴.
- 필요한 추가 검증: 실제 세 플랫폼 golden run, production full check green, CI에서 physical surface 설치·parity 재현.
## 근거 후보
- [[raw/branch-notes/chore-harness-policy-engine-alignment]] — D1-D5와 local verification.
- [[raw/errors/ca-tmpl-preexisting-check-baseline-failures-2026-07-20]] — baseline-aware verification 한계.
- [[raw/interviews/manifest-driven-multi-platform-agent-harness]] — 예상 설계 질문.
- [[raw/official-docs/google-antigravity-hooks]] — platform hook contract.
## 미해결
- 아직 확인해야 할 사실: authenticated products에서 같은 seeded task의 verdict/evidence parity.
- 과장하면 안 되는 부분: repository-local static parity와 mutation test만 `locally-verified`다.
- 블로그 전에 필요한 canonical 정제: code path/command evidence를 `wiki/projects` 문서로 승격하고 external golden 결과를 추가한다.
## 처리 결정
- 액션: `keep-as-topic`
- 이유: 구현과 local evidence는 충분하지만 external golden과 production full check가 남아 있다.
- 다음 단계: 후속 검증 뒤 project/concept canonical로 정제한다.
## 관련
- [[raw/branch-notes/chore-harness-policy-engine-alignment]]
- [[raw/errors/ca-tmpl-preexisting-check-baseline-failures-2026-07-20]]
- [[raw/interviews/manifest-driven-multi-platform-agent-harness]]
- derived blog: 생성 전.
@@ -0,0 +1,81 @@
---
title: blog-topic / micrometer-meterfilter-resilience4j-functioncounter
source_type: blog-topic
status: raw
related_branches: [feature-outbound-http-client-baseline]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, observability, micrometer, circuit-breaker, metric-naming, high-cardinality]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: micrometer-meterfilter-resilience4j-functioncounter
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-outbound-http-client-baseline]] — Micrometer MeterFilter와 Resilience4j FunctionCounter 충돌 회피 쟁점에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-outbound-http-client-baseline]]
## 글감 / Topic seed
- 한 문장 요지: outbound HTTP observability에서 Micrometer `MeterFilter`와 Resilience4j `FunctionCounter` registration/tag policy가 충돌할 수 있는 지점을 정리한다.
- 예상 제목 후보:
- MeterFilter가 Resilience4j metric을 만날 때 생기는 문제
- outbound HTTP metric tag policy를 어디서 강제할까
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- branch가 Micrometer MeterFilter와 Resilience4j FunctionCounter 충돌 회피 topic을 명시한다 — 근거 후보: [[raw/branch-notes/feature-outbound-http-client-baseline]] line `:374`.
- 경험 후보:
- metrics tag/registration 문제를 운영 관측성 글감으로 분리할 수 있다.
- 의견/해석 후보:
- metric naming/cardinality policy는 application metric만이 아니라 library-generated metric에도 영향을 준다.
## Outline seed
1. library metric도 내 관측성 계약 안에 들어온다 — Resilience4j가 생성한 meter를 어떻게 다룰지 정해야 한다.
2. MeterFilter는 강력하지만 순서와 scope가 중요하다 — registration 시점의 tag policy를 확인한다.
3. cardinality guard와 circuit breaker metric의 균형 — 필요한 label과 금지 label을 나눈다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/outbound-http-client-baseline.md` 후보:
- outbound HTTP metrics integration decision.
- `wiki/concepts/micrometer-meterfilter-ordering.md` 후보:
- Micrometer MeterFilter와 library meter registration 일반 개념.
- 필요한 추가 검증:
- 실제 meter 이름, tag set, filter ordering test.
## Sources / 근거 후보
- [[raw/branch-notes/feature-outbound-http-client-baseline]] — Micrometer/Resilience4j topic seed 근거.
## 미해결 / Unknown
- 아직 확인해야 할 사실: FunctionCounter registration과 MeterFilter 적용 순서.
- 과장하면 안 되는 부분: Micrometer/Resilience4j 자체의 일반 결함처럼 쓰지 않는다. ca-tmpl metric contract와 integration edge로 제한한다.
- 블로그로 쓰기 전에 필요한 canonical 정제: official docs/raw source 보강 필요.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound.md` 에 Micrometer/Resilience4j metric registration edge 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 review/verify가 필요하다. Micrometer/Resilience4j 자체의 일반 결함처럼 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-outbound-http-client-baseline]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/micrometer-meterfilter-resilience4j-functioncounter-YYYY-MM-DD.md` 후보
@@ -0,0 +1,105 @@
---
title: blog-topic / checkoutable N+1 API replay
source_type: blog-topic
status: raw
related_branches: [experiment-nplus1-feed-api-replay]
related_projects: [nplus1-presentation-prep, ca-skeleton]
tags: [blog-topic, nplus1-presentation-prep, persistence, api-design, postgresql, docker, hands-on-lab]
created: 2026-07-15
status_label: expanded
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: checkoutable N+1 API replay
> Layer: `raw/blog-topics/` — 테스트 코드에서만 보이던 N+1 관찰값을 checkout 가능한 Git stage, 로컬 HTTP API, PostgreSQL row 확인으로 바꾼 작업의 글감이다. 이 문서는 블로그 초안이나 canonical 문서가 아니다.
## Parent / 부모
- [[raw/branch-notes/experiment-nplus1-feed-api-replay]] — D1의 11개 checkout checkpoint, D3의 Crown/L12 분리, D4의 실제 Docker PostgreSQL HTTP+DB smoke에서 나온 글감이다.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-15
- 트리거 연결 노트: [[raw/branch-notes/experiment-nplus1-feed-api-replay]]
## 글감 / Topic seed
- 한 문장 요지: N+1 학습을 테스트 결과 읽기로 끝내지 않고, 각 Git tag를 checkout해 fixture reset → HTTP 응답 → PostgreSQL row를 직접 보는 11단계 실습으로 바꾼 과정을 기록한다.
- 예상 제목 후보:
- 테스트만으로는 보이지 않는 N+1: 11개 checkout point로 만든 API·DB 실습
- L1의 N+1부터 Crown까지: Git tag, curl, psql로 따라가는 JPA 조회 실험
- 쿼리 수 최적화와 read model을 같은 해법으로 말하지 않기
## 핵심 주장 후보 / Claim candidates
> 아직 canonical이 아니다. 각 사실 후보의 검증 범위는 아래 근거에 적힌 로컬 환경까지다.
- 사실 후보:
- 학습 경로는 `L1 → L2 → L3 → L4 → L5 → L6 → L14 → L15 → L16 → Crown → L12` 순서의 11개 checkout 가능한 tag로 고정되었다. — 근거: [[raw/branch-notes/experiment-nplus1-feed-api-replay]] D1, §구현 가이드 / 고정 replay checkpoint
- final L12 tag의 로컬 Docker Compose smoke에서 reset 100건, Crown feed의 prepared statement 1·entity load 0, L12 read model의 부모당 Top-3, marker row count 100이 관찰되었다. — 근거: [[raw/branch-notes/experiment-nplus1-feed-api-replay]] D3, D4, §검증 기록 / Docker HTTP + PostgreSQL smoke
- 별도 L1 historical smoke에서 lazy highlight 전략과 `collectionFetches=10`이 관찰되어, 마지막 상태만 보는 방식과 다른 출발점의 문제를 HTTP 응답으로 확인할 수 있었다. — 근거: [[raw/branch-notes/experiment-nplus1-feed-api-replay]] D1, §검증 기록 / Historical L1 smoke
- 경험 후보:
- 학습자는 stage를 checkout한 뒤 lab fixture를 reset하고 API 응답을 본 다음 `psql` marker row를 확인하는 같은 루프를 반복할 수 있다. — 근거: [[raw/branch-notes/experiment-nplus1-feed-api-replay]] D2, D4, §목표 / WHY
- Crown의 one-query endpoint와 L12의 same-store CQRS-lite two-query read port를 별도 경로로 두면, "쿼리 수 최소화"와 "application read-model 분리"를 한 결과로 오해하지 않게 된다. — 근거: [[raw/branch-notes/experiment-nplus1-feed-api-replay]] D3, §Crown과 L12의 의도적 차이
- 의견/해석 후보:
- N+1 실습의 핵심 산출물은 최종 쿼리 하나가 아니라, 각 선택이 response·Hibernate 관찰값·DB 데이터에 어떻게 나타나는지 비교할 수 있는 반복 가능한 관찰 루프다.
## Outline seed
1. 왜 마지막 Crown 코드만으로는 학습이 어려웠는가 — 최종 상태는 출발점의 lazy collection N+1과 중간 선택지를 숨긴다는 점을 보여준다.
2. 11개 tag를 실습 단위로 고정한 방법 — Git checkout을 문서 목차가 아니라 실행 가능한 실험의 시작점으로 사용한다.
3. fixture reset, HTTP, psql의 관찰 루프 — 테스트 assertion 밖에서 response shape와 marker-owned row를 함께 확인하는 이유를 설명한다.
4. L1에서 무엇을 보고 Crown에서 무엇이 달라지는가 — collection fetch 수와 one-query/zero-entity-load 관찰값을 같은 질문으로 비교한다.
5. Crown과 L12를 분리해서 읽어야 하는 이유 — one native query 최적화와 same-store CQRS-lite projection은 해결하려는 문제가 다르다는 점을 정리한다.
6. 재현 결과를 과장하지 않는 법 — 로컬 Docker 검증은 production latency, deployment profile, 다른 machine의 모든 tag 재현을 증명하지 않는다고 명시한다.
## Canonical 전환 후보 / Canonical extraction candidates
> `wiki/blog/`로 바로 가지 않는다. 먼저 아래 후보를 canonical로 정제한다.
- `wiki/projects/nplus1-presentation-prep/nplus1-feed-api-replay.md` 후보:
- 11-stage replay catalog, lab-only API boundary, local Docker/PostgreSQL smoke의 구현 사실과 evidence grade를 분리해 기록한다.
- `wiki/concepts/n-plus-one-query-observability.md` 후보:
- lazy loading, fetch join paging, batch fetch, projection, Top-N, keyset의 관찰 지표를 일반 개념으로 정리한다.
- 필요한 추가 검증:
- 깨끗한 clone/worktree에서 11개 tag의 compose/API smoke를 반복한다.
- deployment manifest/env registry에서 `lab` profile이 운영 환경에 활성화되지 않는지 확인한다.
- base architecture failure를 분리 수정한 뒤 full `./gradlew check` 결과를 기록한다.
## Sources / 근거 후보
> 글감 단계의 후보 링크다. 최종 블로그의 사실 근거는 canonical 문서에서 다시 검증한다.
- [[raw/branch-notes/experiment-nplus1-feed-api-replay]] — 구현·Docker smoke·검증 한계의 직접 근거.
- [[raw/official-docs/test-taxonomy-testcontainers-official]] — D4가 실제 PostgreSQL integration evidence를 택한 외부 근거.
- [[raw/official-docs/cqrs-pattern-azure-architecture-center]] — D3의 same-store CQRS-lite와 별도 read store CQRS 구분 근거.
- [[raw/official-docs/spring-data-jpa-projections-spring-official]] — D3의 application 반환용 projection/read shape 분리 근거.
## 미해결 / Unknown
- 아직 확인해야 할 사실:
- 다른 깨끗한 machine/worktree에서도 모든 11개 tag가 동일한 Compose/API guide로 재현되는지는 확인되지 않았다.
- 운영 환경에서 `lab` profile이 활성화되지 않는다는 deployment-level 증거는 아직 없다.
- 과장하면 안 되는 부분:
- 기록된 HTTP·`psql` 결과는 local Docker Compose PostgreSQL smoke이며 production 성능, latency SLA, 운영 권한 경계를 증명하지 않는다.
- L12는 Crown과 동등한 visibility/keyset 해법이 아니라 same-store CQRS-lite의 two-query projection이다.
- 블로그로 쓰기 전에 필요한 canonical 정제:
- stage/tag와 guide의 매핑을 history rewrite 이후에도 다시 확인한다.
- 사실 후보별 evidence grade와 관찰 명령을 canonical project 문서에 고정한다.
## Decision / 처리 결정
- 액션: `keep-as-topic`
- 이유: checkout replay와 로컬 smoke는 evidence가 있지만, 아직 canonical project/concept 문서와 다른 machine 재현 근거가 없다.
- 다음 단계: `wiki/projects/nplus1-presentation-prep/nplus1-feed-api-replay.md` 후보를 evidence grade와 함께 정제한 뒤에만 `/blogify`를 검토한다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/experiment-nplus1-feed-api-replay]]
- 관련 error: 생성 전. replay worktree root discovery 및 base architecture failure는 Parent의 Cluster에서 별도 raw error로 추적한다.
- 관련 interview prep: 생성 전. Crown one-query와 CQRS-lite read model의 구분은 Parent의 Cluster에서 별도 raw interview note로 추적한다.
- derived blog: 생성 전. 생성 시 `wiki/blog/nplus1-lab-checkoutable-api-replay-2026-07-15.md` 후보
@@ -0,0 +1,90 @@
---
title: blog-topic / operational-error-envelope-meta-category-migration-2026-06-01
source_type: blog-topic
status: raw
related_branches: [feature-operational-error-observability-foundation]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, error-handling, observability, api-design, mdc, logging, testing, spring-boot]
created: 2026-06-01
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: operational-error-envelope-meta-category-migration-2026-06-01
> Layer: `raw/blog-topics/` — 채용공고가 아닌 작업·학습·트러블슈팅에서 나온 블로그 글감 원석. canonical 정제 전 raw 후보이며, `wiki/blog/` 직접 생성 근거가 아니다.
## Parent / 부모
- [[raw/branch-notes/feature-operational-error-observability-foundation]] — 운영 에러 분류 enum + 응답 envelope `meta`/`category` + snake_case MDC + 헤더 sanitization 구현·검증 경험.
- [[raw/project-notes/ca-skeleton-operational-contract]] — ca-tmpl 운영 계약 SSOT.
## 트리거 / Trigger
이미 동작하는 응답 envelope(`{success,data,error,traceId}`)을, 관측성 계약이 요구하는 richer shape(`{success,data,error.{code,category,...},meta.{requestId,traceId,correlationId}}`)으로 *기존 계약을 깨지 않고* 끌어올리는 마이그레이션을 직접 했다. 그 과정에서 (a) 인터페이스 추상 메서드 추가의 blast radius, (b) 동일 식별자의 계층별 case 매핑, (c) inbound 헤더 log injection 방어, (d) Spring Boot 슬라이스 테스트의 컨텍스트 오염 트러블슈팅까지 한 묶음으로 나왔다.
## 글감 후보 / Candidate angles
1. **"운영 에러 분류를 SSOT enum 으로 고정하기"** — 13개 임시 목록 → 10-category enum 으로 수렴. category(식별)와 retryable(런타임 신호)을 분리한 이유(per-code retryable, `INTERNAL_ERROR` 처럼 category default 와 다른 코드 허용).
2. **"이미 직렬화되는 응답 계약에 필드를 안전하게 추가하는 법"** — record 컴포넌트 추가가 모든 호출부를 깨뜨리는 게 *오히려 안전장치*. 컴파일러가 숨은 consumer(`PortfolioErrorCode`, `BulkEnvelopeTest`)를 전부 노출 → 한 패스 마이그레이션. ProblemDetail 거부 결정(D5/D6)을 불변으로 둔 additive 확장.
3. **"같은 ID 가 로그·JSON·헤더에서 다르게 쓰이는 건 버그가 아니다"** — `request_id`(snake) ↔ `meta.requestId`(camel) ↔ `X-Request-Id`(kebab)/`traceparent`(W3C lowercase). registry 로 3열 1:1 매핑을 고정하고 변환 지점을 한 군데(`ResponseMetaFactory`)로 모으는 패턴.
4. **"구조화 로깅에서의 log injection(CWE-117) 방어"** — 평문 로깅과 달리 JSON 로깅에서의 진짜 위협은 CR/LF 줄 위조. reject/encode 가 아니라 strip + length cap 을 고른 trade-off.
5. **(트러블슈팅) "@WebMvcTest 의 nested @SpringBootConfiguration 이 같은 패키지 다른 테스트를 조용히 깨뜨린 사건"** — git stash / 파일 mv 비파괴 격리로 원인 좁히기 → adapter 모듈 standalone MockMvc 로 재설계. (→ [[raw/errors/webmvctest-nested-springbootconfiguration-context-pollution-2026-06-01]])
## 핵심 메시지 / Thesis (raw)
운영 에러/관측성 "기반 계약"은 화려한 기능이 아니라 *모든 어댑터가 같은 실패 언어를 쓰게 만드는 어휘 고정*이다. 그 어휘를 (1) enum SSOT, (2) registry 매핑, (3) 단일 변환 지점, (4) 컴파일러로 강제되는 additive 확장으로 박아두면, 이후 모든 기능 branch 가 그 위에서 일관되게 쌓인다.
## 글감 / Topic seed
- 한 문장 요지: 기존 envelope을 깨지 않고 `error.category``meta`를 추가해 운영 에러 어휘와 관측성 식별자를 한 응답 계약으로 묶는다.
- 예상 제목 후보:
- API error envelope에 meta와 category를 추가한 이유
- 운영 에러 어휘를 enum과 response meta로 고정하기
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- operational error foundation branch가 category enum, response meta, MDC key, header sanitization을 다룬다.
- additive record component 추가는 호출부 compile error로 migration blast radius를 드러낸다.
- 의견/해석 후보:
- 운영 에러 기반 계약은 모든 adapter가 같은 실패 언어를 쓰게 만드는 어휘 고정 작업이다.
## Outline seed
1. 기존 envelope에 `meta``category`를 추가해야 했던 이유를 설명한다.
2. enum SSOT, registry mapping, response meta factory의 역할을 나눈다.
3. header sanitization과 log injection 방어를 관측성 계약의 일부로 다룬다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/api-error-envelope-design.md` 후보:
- verified envelope meta/category migration 글감.
- 필요한 추가 검증:
- blogify 전 운영 검증이 아니라 local verification 범위임을 유지한다.
## Sources / 근거 후보
- [[raw/branch-notes/feature-operational-error-observability-foundation]]
- [[raw/project-notes/ca-skeleton-operational-contract]]
- [[raw/errors/webmvctest-nested-springbootconfiguration-context-pollution-2026-06-01]]
- [[raw/interviews/operational-error-envelope-and-observability-foundation]]
## 미해결 / Unknown
- 아직 확인해야 할 사실: 운영 배포/측정 근거는 없다.
- 과장하면 안 되는 부분: `verified` canonical이더라도 prod verification으로 확대하지 않는다.
## 관련
- [[raw/branch-notes/feature-operational-error-observability-foundation]]
- [[raw/errors/webmvctest-nested-springbootconfiguration-context-pollution-2026-06-01]]
- [[raw/interviews/operational-error-envelope-and-observability-foundation]]
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/api-error-envelope-design.md` 에 meta/category migration과 operational error vocabulary 글감으로 반영했다.
- 다음 단계: source canonical이 `verified` 상태이므로 이후 `blogify` 대상으로 삼을 수 있다. 단 운영 검증으로 확대하지 않는다.
@@ -0,0 +1,81 @@
---
title: blog-topic / persistence-audit-metadata-clean-architecture
source_type: blog-topic
status: raw
related_branches: [feature-persistence-auditing-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, persistence, spring-data, hibernate, auditing, clean-architecture]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: persistence-audit-metadata-clean-architecture
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-persistence-auditing-contract]] — audit metadata를 domain 밖 persistence adapter에서 채운 결정에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-persistence-auditing-contract]]
## 글감 / Topic seed
- 한 문장 요지: audit column을 domain model에 섞지 않고 persistence adapter에서 채우는 선택과 Spring Data JPA Auditing을 바로 쓰지 않은 경계를 정리한다.
- 예상 제목 후보:
- Clean Architecture에서 audit metadata를 어디에 둘까
- createdAt은 domain인가 persistence detail인가
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- branch가 audit metadata를 도메인 밖으로 분리하는 persistence contract와 Manual explicit-set vs JPA Auditing topic 후보를 명시한다 — 근거 후보: [[raw/branch-notes/feature-persistence-auditing-contract]] line `:300`.
- 경험 후보:
- 구현된 manual explicit-set 경계를 중심으로 써야 하며, JPA Auditing 채택을 구현 사실처럼 쓰면 안 된다.
- 의견/해석 후보:
- audit metadata는 도메인 정책일 수도 있고 persistence concern일 수도 있으므로, skeleton에서는 기본 경계를 좁게 잡는 편이 낫다.
## Outline seed
1. audit field가 항상 domain language는 아니다 — created/updated metadata의 소유자를 정해야 한다.
2. persistence adapter에서 채우는 방식 — domain purity를 지키지만 mapping 책임이 늘어난다.
3. Spring Data JPA Auditing과의 trade-off — 편의성, framework coupling, explicitness를 비교한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/persistence-auditing-contract.md` 후보:
- ca-tmpl persistence auditing 구현 사실.
- `wiki/concepts/audit-metadata-clean-architecture.md` 후보:
- audit metadata 소유권 일반 개념.
- 필요한 추가 검증:
- entity/mapper/test anchor와 Spring Data JPA Auditing 미채택 사유.
## Sources / 근거 후보
- [[raw/branch-notes/feature-persistence-auditing-contract]] — audit metadata branch와 topic seed 근거.
## 미해결 / Unknown
- 아직 확인해야 할 사실: auditor identity, clock injection, update timestamp 처리 방식.
- 과장하면 안 되는 부분: JPA Auditing이 나쁘다고 쓰지 않는다. ca-tmpl skeleton의 boundary choice로 제한한다.
- 블로그로 쓰기 전에 필요한 canonical 정제: project implementation evidence와 concept trade-off 분리.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound.md` 에 persistence audit metadata boundary 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 review/verify가 필요하다. JPA Auditing이 나쁘다고 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-persistence-auditing-contract]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/persistence-audit-metadata-clean-architecture-YYYY-MM-DD.md` 후보
@@ -0,0 +1,113 @@
---
title: blog-topic / post-implementation-knowledge-capture-workflow-2026-05-28
source_type: blog-topic
status: raw
related_branches: [feature-architecture-enforcement-rules]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, workflow, documentation, agent-workflow, llm-wiki]
created: 2026-05-28
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: post-implementation-knowledge-capture-workflow-2026-05-28
> Layer: `raw/blog-topics/` — 채용공고가 아닌 작업·학습·트러블슈팅에서 나온 블로그 글감 원석. canonical 정제 전 raw 후보이며, `wiki/blog/` 직접 생성 근거가 아니다.
## Parent / 부모
- [[raw/branch-notes/feature-architecture-enforcement-rules]] — ca-tmpl 작업 종료 조건에 LLM Wiki capture (branch-note + derived raw notes) 를 _명시적으로_ 추가한 결정 (`결정 사항 2026-05-28: non-trivial 구현 종료 조건에 LLM Wiki capture를 포함한다`).
- [[raw/project-notes/ca-skeleton-operational-contract]] — ca-tmpl skeleton 의 workflow 운영 계약 맥락.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-05-28
- 트리거 연결 노트: [[raw/branch-notes/feature-architecture-enforcement-rules]] — 본 결정이 branch-note 의 `Decision (2026-05-28: 종료 조건에 LLM Wiki capture)` 한 줄에 압축됨. 같은 날 다른 모든 코드 결정 (Gradle / ArchUnit rule) 과 _대등한 격_ 으로 기록한 점이 핵심.
## 글감 / Topic seed
- 한 문장 요지: "구현 완료" 의 정의에 _지식 캡처 (branch-note + 파생 raw notes) 까지_ 포함해야 코드만 남고 의사결정 / 트러블슈팅 / 글감이 사라지는 걸 막을 수 있다.
- 떠오른 계기: ca-tmpl 작업에서 "branch 마치고 나면 다음 세션에서 이 결정의 _이유__대안_ 을 다시 답할 수 없는" 반복 문제. 사용자가 매번 채팅으로 "branch-note 도 써줘" 를 요청하던 비용을 줄이기 위해 _agent prompt + repo-local rule_ 두 층에 capture rule 을 추가.
- 예상 제목 후보:
- "구현 완료" 의 정의에 지식 캡처를 포함하기 — repo-local workflow 설계
- LLM 에게 "코드 끝나면 branch-note 도 써" 라고 매번 요청하지 않으려면
- branch-note · errors · interviews · blog-topics 의 _네 갈래_ 캡처 워크플로우
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- ca-tmpl repo 의 종료 조건 워크플로우는 `AGENTS.md` + 루트 `CLAUDE.md` + `.agents/plugins/ca-superpowers/rules/llm-wiki-capture.md` + `.claude/skills/ca-superpowers-workflow/SKILL.md`_네 곳_ 에 capture rule 이 흩어져 있고, 각 위치는 트리거가 다르다 (대화 시작, 모듈별 작업, 비-자명한 구현 종료, skill 호출) — 근거: `feature-architecture-enforcement-rules.md` §진행 중 메모 2026-05-28 "워크플로우 반영: ca-tmpl repo 내부 `AGENTS.md`, `CLAUDE.md`, `.agents/plugins/ca-superpowers/`, `.claude/`, `.codex/` 지침에 ... 캡처 규칙을 추가".
- 캡처 단위는 4종 — `raw/branch-notes/`, `raw/errors/`, `raw/interviews/`, `raw/blog-topics/` — 그리고 _canonical_ (`wiki/...`) 은 _명시 요청 없으면 생성 금지_ — 근거: `.agents/plugins/ca-superpowers/rules/llm-wiki-capture.md` §"canonical 추출 요청이 없는 한 wiki/blog/wiki/interview/wiki/portfolio/wiki/concepts/wiki/projects를 바로 만들지 않는다".
- 모든 derived note 는 `## Parent` 로 branch-note 에 upward link, branch-note 는 `## Cluster` 로 derived note 에 downward link — _양방향 nav_ 가 의무 — 근거: 동일 rule 4번 ("파생 문서는 `## Parent`에서 branch-note로 upward link하고, branch-note의 `## Cluster / 묶음`에는 파생 문서 wikilink를 되돌려 적는다").
- 종료 응답에는 반드시 `Wiki capture` 라인 — 갱신된 노트 / 의도적으로 생성 안 한 derived note (없음 명시) / 차단 (BLOCKED) 중 하나를 보고 — 근거: 동일 rule §Final Response Requirement.
- 경험 후보:
- 본 결정을 _다른 코드 결정과 대등한 격_ 으로 branch-note 의 §결정 사항 마지막 한 줄로 추가 (`2026-05-28: non-trivial 구현 종료 조건에 LLM Wiki capture를 포함한다`) — 근거: `feature-architecture-enforcement-rules.md` §결정 사항 마지막 항목.
- 본 결정의 _대안 비교_ 까지 명시: (a) 사용자 수동 요청 (누락 위험), (b) Wiki vault 내부 규칙만 (ca-tmpl 작업자가 인식 못함), (c) ca-tmpl repo-local rule (채택) — 근거: 동일 §결정 사항 마지막 항목 `검토한 대안:`.
- 본 글의 자매 branch (`feature-application-port-usecase-contract`) 가 _이 워크플로우를 실제로 적용한 첫 사례_ — branch-note 갱신 + 3개 derived note (error, interview, blog-topic) 생성을 마지막 응답에 `Wiki capture` 로 보고 — 근거: [[raw/branch-notes/feature-application-port-usecase-contract]] §완료 후 정리 + §Cluster.
- 워크플로우 패치 도중 도구 자동 승인 검토가 차단되어 사용자 명시 승인 후 재개한 사례 — 근거: 파생 에러 [[raw/errors/apply-patch-auto-approval-rejected-2026-05-28]].
- 의견 / 해석 후보:
- 문서화는 _후행 작업_ 이 아니라 _완료 조건의 일부_ 가 되어야 누락이 줄어든다. 단, "기록을 강제하는 자동 장치 (CI / git hook)" 까지는 아직 가지 않았다 — 현재는 _agent workflow rule_ 수준이며 honesty 차원에서 명시 — 근거: `feature-architecture-enforcement-rules.md` 진행 중 메모 2026-05-28 "등급: `documented-only` (repo-local workflow docs; 자동 강제 장치 아님)".
- 캡처를 _네 갈래_ (branch / error / interview / blog-topic) 로 _구조화_ 하면 코드 끝난 뒤 _즉시_ 처분 가능 — "branch-note 만 적으면 errors 가 묻히고, errors 만 적으면 interview 가 묻힘". 4갈래 분리가 _분실 방지_ 의 핵심.
- **derived note 가 _없을 때_ "없음" 을 명시하는 것** 이 의외로 중요하다 (`Errors: 없음`, `Interview prep: 없음`). 빈 cluster section 은 "정말 없는지 검토했음" 의 증거이고, _없으면 그냥 비워두는 것_ 보다 사후 검증 가능.
- 모든 raw note 가 _line-cited evidence_ 를 갖는 것이 (전체 글의 모든 사실 후보를 `D3` / `AT-TX-C5` 같은 ID 로 인용) **canonical wiki 로 승급할 때 _재검증 가능_** 하게 만드는 가장 큰 차이.
## Outline seed
> 각 섹션 옆에 `→ 핵심 메시지` 를 함께 명시한다.
1. 문제 — 코드는 끝났는데 _왜 이렇게 했는지__고려한 대안_ 이 채팅 로그에만 남아 다음 세션에서 휘발 → **"기록을 매번 요청한다" 는 비용을 줄이는 게 글의 출발점.**
2. 캡처를 _완료 조건_ 으로 옮기기 — repo-local `AGENTS.md` + `CLAUDE.md` + `llm-wiki-capture.md` + skill 의 _네 위치_**트리거를 코드 작업 가까이에 둬야 워크플로우가 실행된다.**
3. 네 갈래 raw 구조 — branch-notes / errors / interviews / blog-topics → **분리하지 않으면 한 갈래가 다른 갈래를 묻는다.**
4. _없을 때 없음을 명시_ — empty cluster section 의 honesty → **"검토 안 함" 과 "검토 후 없음" 을 구분.**
5. 라인 인용으로 _승급 가능_ 하게 — `D3`, `AT-TX-C5` 인용 패턴 → **raw 가 canonical 로 갈 때 _재검증 가능_ 한 것은 line-cited evidence 뿐.**
6. 한계 — 자동 강제 (CI / git hook) 아님, _agent workflow rule_ 수준 → **honesty 차원에서 documented-only 등급을 글에 명시.**
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-skeleton/knowledge-capture-workflow.md` 후보:
- ca-tmpl 의 실제 4 위치 capture rule (AGENTS / CLAUDE / llm-wiki-capture / skill) + 종료 응답의 `Wiki capture` 라인 형식.
- `feature-application-port-usecase-contract` 의 적용 사례 (branch-note + 3 derived notes).
- "양방향 nav" 강제 (`## Parent``## Cluster`) 의 검증 방법.
- `wiki/concepts/post-implementation-knowledge-capture.md` 후보:
- "구현 완료 조건에 지식 캡처 포함" 의 일반 원칙 (project-agnostic).
- 캡처 단위를 4갈래 (branch / errors / interviews / blog-topics) 로 분리하는 _why_.
- canonical 승급의 게이트 (line-cited evidence + status grading).
- 필요한 추가 검증:
- 이후 다른 branch 작업에서 실제로 derived note 가 _자동으로_ 기록되는지 반복 관찰 (현재 1 사례 = `feature-application-port-usecase-contract`).
- 자동 강제 장치 (git hook / CI step) 를 추가했을 때의 비용 / 효과.
## Sources / 근거 후보
- [[raw/branch-notes/feature-architecture-enforcement-rules]] — workflow rule 반영 결정 (§결정 사항 2026-05-28 마지막 항목) + §진행 중 메모 2026-05-28 워크플로우 반영 내역.
- [[raw/branch-notes/feature-application-port-usecase-contract]] — 본 워크플로우의 첫 적용 사례 (Wiki capture 결과: branch-note 갱신 + 3 derived notes).
- [[raw/errors/apply-patch-auto-approval-rejected-2026-05-28]] — workflow 문서 패치 중 도구 차단 사례.
- [[raw/interviews/post-implementation-knowledge-capture]] — 같은 작업에서 파생된 예상 면접 질문.
- repo file: `ca-tmpl/.agents/plugins/ca-superpowers/rules/llm-wiki-capture.md` — Required Capture Sequence + When No Derived Note Is Needed + Final Response Requirement (4 sections).
- repo file: `ca-tmpl/AGENTS.md` §LLM Wiki 캡처 워크플로우.
- repo file: `ca-tmpl/CLAUDE.md` §LLM Wiki capture.
- repo file: `ca-tmpl/.claude/skills/ca-superpowers-workflow/SKILL.md` §LLM Wiki Capture Before Completion.
## 미해결 / Unknown
- 아직 확인해야 할 사실: 문서 규칙 __ 으로 _장기적으로_ agent session 누락이 줄어드는지 (현재 1 사례 검증).
- 아직 확인해야 할 사실: 자동 강제 장치 (git hook / CI step / agent runtime check) 가 _필요한지_, 아니면 documented rule 로 충분한지.
- 과장하면 안 되는 부분: 본 워크플로우는 **`documented-only`** 다. CI / git hook 으로 자동 강제하지 않음. "자동으로 캡처된다" 같은 표현 금지.
- 과장하면 안 되는 부분: agent runtime 이 본 rule 파일들을 _실제로_ 로드하는지는 plugin/skill 구현 의존이며 ca-tmpl repo 외부 의존성 — 글에서 "어떤 runtime 에서도 동작" 같은 일반화 금지.
- 블로그로 쓰기 전에 필요한 canonical 정제: 2~3개 추가 branch 사례를 거쳐 워크플로우 안정성 확인 → `wiki/projects/ca-skeleton/knowledge-capture-workflow.md` 정제 → blog 초안.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: 신규 `wiki/projects/ca-tmpl/knowledge-capture-workflow.md` 에 post-implementation knowledge capture workflow 글감으로 반영한다.
- 다음 단계: blogify 전 자동 강제 장치가 아니라 documented workflow rule임을 유지한다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-architecture-enforcement-rules]] (결정), [[raw/branch-notes/feature-application-port-usecase-contract]] (첫 적용 사례).
- 관련 errors: [[raw/errors/apply-patch-auto-approval-rejected-2026-05-28]] (workflow 문서 패치 중 도구 차단).
- 관련 interview prep: [[raw/interviews/post-implementation-knowledge-capture]].
- 관련 blog topics: [[raw/blog-topics/clean-architecture-boundary-enforcement-2026-05-28]] (같은 branch 의 자매 글감), [[raw/blog-topics/transaction-port-abstraction-over-spring-transactional-2026-05-28]] (첫 적용 사례에서 나온 글감 — 본 워크플로우의 _효과 증거_).
- derived blog: 생성 전. 후보 `wiki/blog/post-implementation-knowledge-capture-workflow-YYYY-MM-DD.md`.
@@ -0,0 +1,82 @@
---
title: blog-topic / repository-capability-archunit-fitness-function
source_type: blog-topic
status: raw
related_branches: [feature-repository-access-permission-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, persistence, testing, archunit, static-analysis, clean-architecture]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: repository-capability-archunit-fitness-function
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-repository-access-permission-contract]] — repository 접근 capability를 annotation, registry, ArchUnit rule로 계약화한 branch에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-repository-access-permission-contract]]
## 글감 / Topic seed
- 한 문장 요지: repository 접근 권한을 사람의 리뷰 기억에 맡기지 않고 annotation + registry + ArchUnit fitness function으로 강제한 이유를 정리한다.
- 예상 제목 후보:
- Repository 접근 권한을 ArchUnit rule로 고정하기
- Clean Architecture에서 persistence capability를 계약으로 다루기
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- branch가 repository capability registry drift와 3층 검증을 blog topic 후보로 명시한다 — 근거 후보: [[raw/branch-notes/feature-repository-access-permission-contract]] section+line `:311-314`.
- 경험 후보:
- 기존 boundary enforcement topic은 계층 의존성 중심이고, 이 글감은 repository capability annotation/registry coherence라는 좁은 실패 모드를 다룬다.
- 의견/해석 후보:
- repository 접근 제한은 "어느 package에서 접근했는가"보다 "어떤 capability로 접근을 허용했는가"까지 내려가야 drift를 줄일 수 있다.
## Outline seed
1. package boundary만으로는 repository intent를 알 수 없다 — 접근 권한의 의미를 capability로 드러낸다.
2. annotation, registry, ArchUnit의 역할 분리 — 선언, 목록, 검증이 서로를 보완한다.
3. fitness function의 한계 — helper/mapper indirection까지 자동 검출한다고 과장하지 않는다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/repository-access-capability-contract.md` 후보:
- ca-tmpl repository access permission contract 구현 사실.
- `wiki/concepts/archunit-fitness-function.md` 후보:
- ArchUnit rule을 architecture fitness function으로 사용하는 일반 개념.
- 필요한 추가 검증:
- annotation 이름, registry schema, ArchUnit rule/test anchor.
## Sources / 근거 후보
- [[raw/branch-notes/feature-repository-access-permission-contract]] — blog seed와 capability contract 근거.
- [[raw/blog-topics/clean-architecture-boundary-enforcement-2026-05-28]] — 기존 boundary topic과의 경계.
## 미해결 / Unknown
- 아직 확인해야 할 사실: helper/mapper 우회 경로 검출 가능 범위.
- 과장하면 안 되는 부분: 모든 repository misuse를 자동 검출한다고 쓰면 안 된다. 정적 분석 rule이 볼 수 있는 구조로 제한한다.
- 블로그로 쓰기 전에 필요한 canonical 정제: project 문서의 implemented/local evidence 정리.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound.md` 에 repository capability ArchUnit fitness function 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 review/verify가 필요하다. 모든 repository misuse를 자동 검출한다고 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-repository-access-permission-contract]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/repository-capability-archunit-fitness-function-YYYY-MM-DD.md` 후보
@@ -0,0 +1,81 @@
---
title: blog-topic / runbook-coverage-junit-contract-test
source_type: blog-topic
status: raw
related_branches: [feature-operational-runbook-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, observability, testing, junit5, api-contract]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: runbook-coverage-junit-contract-test
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-operational-runbook-contract]] — 운영 runbook coverage gate를 테스트로 구현한 경험에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-operational-runbook-contract]]
## 글감 / Topic seed
- 한 문장 요지: 운영 runbook 링크가 문서에만 존재하는지, 실제 error registry와 연결되어 release를 막을 수 있는지 JUnit contract test로 확인한 이유를 정리한다.
- 예상 제목 후보:
- Runbook coverage를 JUnit 테스트로 막아본 이유
- 운영 문서도 release gate가 될 수 있을까
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- branch에 runbook coverage gate와 link-check smoke가 구현/검증된 것으로 정리되어 있고 blog topic 후보가 Cluster에 명시되어 있다 — 근거 후보: [[raw/branch-notes/feature-operational-runbook-contract]] line `:301`.
- 경험 후보:
- Gradle task가 아니라 test suite에 넣는 방식은 release-blocking semantics를 명확히 하는 장점이 있다.
- 의견/해석 후보:
- runbook은 "있으면 좋은 문서"가 아니라 retryable failure와 연결될 때 운영 계약이 된다.
## Outline seed
1. error code와 runbook을 따로 관리하면 drift가 생긴다 — registry row와 문서 링크를 같은 gate로 본다.
2. JUnit으로 문서 coverage를 검사하는 이유 — application test lifecycle에 운영 artifact를 포함한다.
3. link-check와 semantic coverage는 다르다 — URL이 살아 있어도 runbook이 충분하다는 뜻은 아니다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/operational-runbook-coverage-gate.md` 후보:
- ca-tmpl runbook coverage gate 구현 사실.
- `wiki/concepts/runbook-coverage-gate.md` 후보:
- error registry와 runbook coverage를 연결하는 일반 패턴.
- 필요한 추가 검증:
- 테스트명, registry schema, retryable=true row 처리 방식.
## Sources / 근거 후보
- [[raw/branch-notes/feature-operational-runbook-contract]] — runbook coverage gate topic seed와 구현/검증 근거.
## 미해결 / Unknown
- 아직 확인해야 할 사실: link-check smoke와 coverage gate의 정확한 차이.
- 과장하면 안 되는 부분: runbook 내용 품질까지 자동 보장한다고 쓰면 안 된다. coverage와 link existence 검증으로 제한한다.
- 블로그로 쓰기 전에 필요한 canonical 정제: runbook coverage gate project 문서 생성 또는 observability 문서 통합.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/observability-log-metric-trace-runbook.md` 에 runbook coverage JUnit contract test 글감으로 반영했다.
- 다음 단계: source canonical이 `verified` 상태이므로 이후 `blogify` 대상으로 삼을 수 있다. 단 runbook 내용 품질까지 자동 보장한다고 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-operational-runbook-contract]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/runbook-coverage-junit-contract-test-YYYY-MM-DD.md` 후보
@@ -0,0 +1,84 @@
---
title: blog-topic / sample-domain-contract-fixture-clean-architecture
source_type: blog-topic
status: raw
related_branches: [feature-sample-domain-contract-fixture]
related_projects: [ca-skeleton]
tags: [blog-topic, ca-skeleton, architecture, testing, clean-architecture, api-contract]
created: 2026-06-10
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: sample-domain-contract-fixture-clean-architecture
## Parent / 부모
- [[raw/branch-notes/feature-sample-domain-contract-fixture]] — sample-portfolio fixture를 문서 계약대로 구현하면서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-06-10
- 트리거 연결 노트: [[raw/branch-notes/feature-sample-domain-contract-fixture]]
## 글감 / Topic seed
- 한 문장 요지: Clean Architecture 템플릿의 샘플 도메인은 데모 기능이 아니라 validation, mapper, transaction, response, conflict 계약을 실제 흐름으로 검증하는 fixture가 될 수 있다.
- 예상 제목 후보:
- Clean Architecture 템플릿에 sample domain fixture를 남기는 이유
- 샘플 기능이 아니라 계약 검증 도구로서의 WorkLog 도메인
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- ca-tmpl은 `sample-portfolio`를 production module이 의존하지 않는 fixture/reference consumer로 둔다 — 근거 후보: [[raw/branch-notes/feature-sample-domain-contract-fixture]] D2/D5.
- 2026-06-10 구현은 `WorkLogStatus` 상태 머신과 `WorkLogOwner` minimum model을 domain→application→persistence→web에 연결했다 — 근거 후보: [[raw/branch-notes/feature-sample-domain-contract-fixture]] §진행 중 메모.
- 경험 후보:
- focused RED에서 missing enum/value object/accessor/command status patch 컴파일 실패를 확인하고, GREEN 후 `:sample-portfolio:test`, architecture guard, full `test`, `check`를 통과시켰다 — 근거 후보: [[raw/branch-notes/feature-sample-domain-contract-fixture]] §진행 중 메모.
- 의견/해석 후보:
- 템플릿의 sample은 "보여주기용 CRUD"보다 "경계 계약을 깨뜨리면 테스트가 실패하는 살아있는 fixture"일 때 유지 비용을 정당화하기 쉽다.
## Outline seed
1. 문제: sample을 지우면 contract 흐름 검증이 빈다 — validation/mapper/error/transaction이 unit test 조각으로만 남는 위험.
2. 설계: sample-portfolio를 production과 분리된 fixture consumer로 둔다 — 모듈 경계와 ArchUnit guard가 핵심.
3. 구현: WorkLog minimum model을 계층별로 흘린다 — status state machine, owner, optimistic version, response/persistence round-trip.
4. 검증: RED-GREEN과 architecture/full Gradle check — contract fixture는 테스트 증거로 말한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/sample-domain-contract-fixture.md` 후보:
- sample-portfolio fixture의 실제 구현 파일과 검증 명령.
- `wiki/concepts/sample-domain-contract-fixture.md` 후보:
- sample domain을 contract fixture로 설계하는 일반 패턴.
- 필요한 추가 검증:
- canonical `sample-fixture-and-adoption` 명명 drift 정리.
- sample-off/profile isolation owner branch 결과 확인.
## Sources / 근거 후보
- [[raw/branch-notes/feature-sample-domain-contract-fixture]] — 구현 결정, scenario matrix, 2026-06-10 검증 기록.
- [[raw/errors/gradle-wrapper-lock-read-only-sandbox-2026-06-10]] — 검증 중 sandbox tooling 이슈.
- [[raw/interviews/sample-domain-contract-fixture-clean-architecture]] — 같은 작업에서 나온 면접 질문 원석.
## 미해결 / Unknown
- 아직 확인해야 할 사실: sample-off CI/profile isolation 구현 branch의 최종 상태.
- 과장하면 안 되는 부분: 이번 글감은 locally-verified 구현 원석이며 canonical 정제 전이다.
- 블로그로 쓰기 전에 필요한 canonical 정제: branch-note의 implemented claims를 `wiki/projects/ca-tmpl/` 문서로 승격.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/sample-fixture-and-adoption.md` 에 sample domain contract fixture 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 review/verify가 필요하다. sample domain을 production feature처럼 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-sample-domain-contract-fixture]]
- 관련 error: [[raw/errors/gradle-wrapper-lock-read-only-sandbox-2026-06-10]]
- 관련 interview prep: [[raw/interviews/sample-domain-contract-fixture-clean-architecture]]
- derived blog: 생성 전.
@@ -0,0 +1,95 @@
---
title: Sample fixture dual-mode build matrix
source_type: blog-topic
status: raw
related_branches: [feature-sample-removal-adoption-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, clean-architecture, gradle, template-repository, testing]
created: 2026-06-25
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# Sample fixture dual-mode build matrix
## Parent
- [[raw/branch-notes/feature-sample-removal-adoption-contract]]
## Angle
템플릿 저장소에서 예제 도메인을 완전히 삭제하지 않고도, production/core 계약이 예제 코드에 의존하지 않음을 증명하는 방법.
## Outline
1. Sample module을 삭제하지 않는 이유: fixture, reference, contract coverage.
2. Runtime toggle이 부적절했던 이유: production app에는 애초에 sample runtime wiring이 없다.
3. Dual-mode를 build/test matrix로 재정의: sample-on과 sample-off.
4. Gradle 구현: declarable fixture configuration, custom source set, dedicated test task.
5. 테스트 정리: core test의 sample import 제거, sample-owned contract는 sample module로 이동.
6. CI release gate: hosted workflow wiring과 local gate matrix verification.
7. 함정: dependency locks, ArchUnit import option, empty corpus, static-analysis policy.
## Outline seed
1. Sample module을 삭제하지 않는 이유를 fixture, reference, contract coverage 관점으로 설명한다.
2. runtime toggle이 아니라 sample-on/sample-off build matrix가 필요한 이유를 정리한다.
3. Gradle fixture configuration, custom source set, dedicated test task의 역할을 나눈다.
4. CI release gate와 local verification에서 sample-off가 실제로 막아야 하는 실패를 기록한다.
## Evidence to cite later
- `src/app-bootstrap/build.gradle` `sampleFixture` / `sampleOffTest`.
- `SampleRemovalSmokeContractTest`.
- `ProductionClassImportOption`.
- `.github/workflows/ci-quality-gates.yml` `sample-off` job.
- [[raw/errors/gradle-custom-source-set-isolation-failures-2026-06-25]].
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-06-25
- 트리거 연결 노트: [[raw/branch-notes/feature-sample-removal-adoption-contract]]
## 글감 / Topic seed
- 한 문장 요지: sample domain을 runtime toggle이 아니라 sample-on/sample-off build matrix로 격리해 production/core 계약이 sample에 의존하지 않음을 검증한다.
- 예상 제목 후보:
- Sample-off build matrix로 템플릿 의존성 검증하기
- 예제 도메인을 지우지 않고 production 경계를 증명하기
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- sample fixture source set과 sample-off test task가 sample 의존성 격리를 검증한다.
- 의견/해석 후보:
- template repository에서 sample은 제거 대상이 아니라 contract fixture일 수 있다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/sample-fixture-and-adoption.md` 후보:
- sample fixture dual-mode build matrix 글감.
- 필요한 추가 검증:
- 현재 `sampleFixture`, `sampleOffTest`, CI sample-off job 존재 여부.
## Sources / 근거 후보
- [[raw/branch-notes/feature-sample-removal-adoption-contract]]
- [[raw/errors/gradle-custom-source-set-isolation-failures-2026-06-25]]
## 미해결 / Unknown
- 아직 확인해야 할 사실: hosted CI sample-off job 실제 차단 검증 여부.
- 과장하면 안 되는 부분: runtime toggle로 검증한다고 쓰지 않고 build/test matrix로 제한한다.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/sample-fixture-and-adoption.md` 에 sample fixture dual-mode build matrix 글감으로 반영한다.
- 다음 단계: blogify 전 local vs hosted CI evidence를 분리한다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-sample-removal-adoption-contract]]
@@ -0,0 +1,82 @@
---
title: blog-topic / secret-source-port-restart-only-rotation
source_type: blog-topic
status: raw
related_branches: [feature-secrets-config-source-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, security, spring-boot, externalized-config, api-contract]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: secret-source-port-restart-only-rotation
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-secrets-config-source-contract]] — `SecretSource` port와 restart-only reload guard 구현 경험에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-secrets-config-source-contract]]
## 글감 / Topic seed
- 한 문장 요지: secret source를 설정 문서의 문자열 규칙으로만 두지 않고 `SecretSource` port, restart-only rotation, `@RefreshScope` 금지로 닫은 이유를 정리한다.
- 예상 제목 후보:
- SecretSource port로 secret loading 경계를 고정하기
- 왜 ca-tmpl은 secret reload를 restart-only로 제한했나
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- branch에 `SecretSource` port, restart-only reload, `@RefreshScope` 금지, locally-verified 구현 기록이 있다 — 근거 후보: [[raw/branch-notes/feature-secrets-config-source-contract]] D5 line `:131`, implementation `:149-159`, `:207-223`, closure `:348-352`.
- 경험 후보:
- `.env` drift gate와 달리 이 글감은 secret source abstraction과 reload lifecycle을 다룬다.
- 의견/해석 후보:
- secret rotation을 runtime reload로 풀면 lifecycle과 connection/cache state 문제가 따라오기 때문에 skeleton 기본값은 좁게 잡는 편이 안전하다.
## Outline seed
1. secret은 config key와 다르다 — source, masking, reload lifecycle을 함께 봐야 한다.
2. `SecretSource` port가 주는 이점 — adapter 교체 가능성과 application boundary를 동시에 얻는다.
3. restart-only rotation의 trade-off — 단순하고 검증 가능하지만 runtime rotation 요구는 별도 설계가 필요하다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets.md` 후보:
- ca-tmpl secret config source 구현 사실.
- `wiki/concepts/security-baseline-jwt-actuator-secrets.md` 후보:
- externalized secret source와 reload lifecycle 일반 개념.
- 필요한 추가 검증:
- `SecretSource` 구현체, test anchor, `@RefreshScope` 금지 rule 여부.
## Sources / 근거 후보
- [[raw/branch-notes/feature-secrets-config-source-contract]] — secret source port와 restart-only reload 근거.
- [[raw/blog-topics/env-example-drift-gate-gradle-2026-06-06]] — 인접하지만 다른 `.env` drift topic.
## 미해결 / Unknown
- 아직 확인해야 할 사실: runtime rotation 미지원 범위와 future extension point.
- 과장하면 안 되는 부분: Vault/KMS dynamic secret 운영을 구현했다고 쓰면 안 된다. restart-only contract로 제한한다.
- 블로그로 쓰기 전에 필요한 canonical 정제: secret config project 문서 verified 범위 확인.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets.md` 에 SecretSource/restart-only rotation 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 review/verify가 필요하다. Vault/KMS dynamic secret 운영을 구현했다고 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-secrets-config-source-contract]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/secret-source-port-restart-only-rotation-YYYY-MM-DD.md` 후보
@@ -0,0 +1,90 @@
---
title: blog-topic / skip-locked-outbox-per-aggregate-fifo-gate-2026-06-11
source_type: blog-topic
status: raw
related_branches: [feature-domain-event-outbox-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, outbox, skip-locked, fifo, postgresql, testcontainers, clean-architecture]
created: 2026-06-11
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: skip-locked-outbox-per-aggregate-fifo-gate-2026-06-11
> Layer: `raw/blog-topics/` — 채용공고가 아닌 작업·학습·트러블슈팅에서 나온 블로그 글감 원석. canonical 정제 전 raw 후보이며, `wiki/blog/` 직접 생성 근거가 아니다.
## Parent / 부모
- [[raw/branch-notes/feature-domain-event-outbox-contract]] — outbox relay 구현 중 SKIP LOCKED 와 per-aggregate FIFO 의 충돌을 claim query 의 `NOT EXISTS` 게이트로 해소한 경험 단독 추출.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- `FOR UPDATE SKIP LOCKED` 폴링 outbox 는 멀티 인스턴스 claim 경합을 우아하게 풀지만, PostgreSQL/MySQL 공식 문서가 명시하듯 **순서를 깬다(inconsistent view)**. "per-aggregate FIFO 보장" 계약과 정면 충돌 — 1차 구현이 실제로 게이트를 빠뜨려 리뷰에서 잡혔고(head FAILED 인데 tail 이 먼저 발행되는 경로), 수정 과정 자체가 글감.
## 글감 코어 / Core idea
- **문제**: SKIP LOCKED 는 "락 못 잡으면 건너뛴다" — 같은 aggregate 의 이벤트 e1, e2 가 서로 다른 publisher 에 분산 claim 되거나, e1 이 FAILED(backoff 대기) 인 동안 e2 가 먼저 나가면 consumer 가 순서 역전을 본다.
- **해결**: claim query 에 상관 서브쿼리 게이트 —
`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')`.
배치에는 aggregate 당 head 1건만 들어오고, head 가 비-PUBLISHED(FAILED/IN_FLIGHT/**DEAD 포함**)인 동안 후행은 구조적으로 claim 불가. READ_COMMITTED 스냅숏을 읽는 게이트라 보수적(차단 우위)으로 동작.
- **트레이드오프 (strict FIFO)**: DEAD 가 후행을 영구 차단 → poison event 1건이 aggregate 스트림을 멈춘다. 자동 우회 대신 runbook 수동 처분(재발행 `PENDING` 리셋 vs skip `PUBLISHED` 마킹 — 이벤트 갭 승인 필요)으로 설계. backlog 증가는 `outbox.pending.size` P2 alert 가 감지.
- **검증**: Testcontainers PG 계약 테스트 3종 — ① 2개 Spring context × 1000 rows 동시 claim, 합계 1000·중복 0 (SKIP LOCKED 단일 claim), ② head FAILED/DEAD 시 tail 차단·head PUBLISHED 후 해제 (FIFO 게이트), ③ `next_attempt_at` 을 visibility timeout 으로 재사용한 IN_FLIGHT orphan 재claim.
- **부가 발견**: 공유 HikariDataSource 를 두 context 에 등록하면 첫 close 가 풀을 닫는다(`setDestroyMethodName("")` 필요) — [[raw/errors/testcontainers-two-context-shared-datasource-close-2026-06-11]].
## 왜 의미 있나 / Why it matters
- 국내외 outbox 글 대부분이 "SKIP LOCKED 로 폴링하면 된다"에서 멈춘다. **ordering 계약과의 충돌**과 그 해소(쿼리 레벨 게이트 + strict FIFO 의 운영 비용 명문화 + 계약 테스트로 고정)까지 다루는 글은 드물다.
- fail-open publisher(use case 직발행)와 fail-closed publisher(outbox relay)가 한 코드베이스에 공존해야 하는 이유도 곁들일 수 있는 실전 소재.
## 글감 / Topic seed
- 한 문장 요지: `FOR UPDATE SKIP LOCKED`는 claim 경합을 줄이지만 per-aggregate FIFO 보장과 충돌할 수 있어 head gate가 필요하다.
- 예상 제목 후보:
- SKIP LOCKED outbox에서 순서를 지키는 방법
- per-aggregate FIFO를 깨지 않는 outbox claim query
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- `SKIP LOCKED`는 잠긴 row를 skip하므로 동일 aggregate의 tail이 먼저 claim될 수 있다.
- `NOT EXISTS` head gate는 앞선 미발행 row가 있을 때 tail claim을 막는 방식이다.
- 의견/해석 후보:
- strict FIFO는 poison event가 aggregate stream을 멈추는 운영 비용을 동반한다.
## Outline seed
1. SKIP LOCKED가 해결하는 문제와 새로 만드는 ordering 문제를 분리한다.
2. head gate query로 per-aggregate FIFO를 보강한다.
3. DEAD row가 tail을 막는 strict FIFO의 운영 비용과 runbook 필요성을 설명한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/transactional-outbox-pattern.md` 후보:
- SKIP LOCKED vs per-aggregate FIFO gate 글감.
- 필요한 추가 검증:
- branch-note/code 기준 실제 Testcontainers 검증 여부.
## Sources / 근거 후보
- [[raw/branch-notes/feature-domain-event-outbox-contract]]
- [[raw/errors/testcontainers-two-context-shared-datasource-close-2026-06-11]]
## 미해결 / Unknown
- 아직 확인해야 할 사실: 현재 canonical의 구현 없음 기록과 raw seed의 검증 주장 간 차이.
- 과장하면 안 되는 부분: SKIP LOCKED가 ordering을 자동 보장한다고 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-domain-event-outbox-contract]]
- 관련 error: [[raw/errors/testcontainers-two-context-shared-datasource-close-2026-06-11]]
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/transactional-outbox-pattern.md` 에 SKIP LOCKED와 per-aggregate FIFO gate 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 outbox 구현·Testcontainers 검증 여부를 branch-note/code 기준으로 재확인한다.
@@ -0,0 +1,82 @@
---
title: blog-topic / spring-actuator-health-probe-group-split
source_type: blog-topic
status: raw
related_branches: [feature-runtime-health-lifecycle-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, runtime, spring-boot, kubernetes, graceful-shutdown, sigterm]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: spring-actuator-health-probe-group-split
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] — liveness/readiness/startup probe group split 구현/검증 후보에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-runtime-health-lifecycle-contract]]
## 글감 / Topic seed
- 한 문장 요지: Spring Actuator health group을 liveness/readiness/startup으로 나누고, startup guard와 shutdown lifecycle을 같은 운영 계약으로 본 이유를 정리한다.
- 예상 제목 후보:
- Spring Actuator health group을 세 개로 나눈 이유
- readiness와 startup을 같은 endpoint로 보면 생기는 문제
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- branch에 runtime health lifecycle probe group과 startup guard 구현/검증 기록이 있다 — 근거 후보: [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] section+line `:359-385`.
- 경험 후보:
- blog seed가 직접 명시되지는 않았지만 구현 기록과 test edge가 충분하다는 lane-08 판정이 있다.
- 의견/해석 후보:
- liveness/readiness/startup은 모두 health endpoint지만 실패 시 orchestration action이 다르므로 분리해야 한다.
## Outline seed
1. liveness/readiness/startup은 같은 "건강"이 아니다 — restart, traffic removal, startup delay라는 action이 다르다.
2. Spring Actuator group split이 주는 구조 — dependency readiness와 process liveness를 나눈다.
3. auth/exposure blocker를 분리하기 — probe가 있어도 network/auth 설정이 막으면 운영 계약이 완성되지 않는다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/runtime-health-lifecycle-contract.md` 후보:
- ca-tmpl runtime health lifecycle implementation.
- `wiki/concepts/spring-actuator-health-probes.md` 후보:
- Spring Actuator health groups와 Kubernetes probes 일반 개념.
- 필요한 추가 검증:
- actuator exposure/auth, probe endpoint path, Kubernetes manifest 연결 여부.
## Sources / 근거 후보
- [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] — probe group split과 startup guard 근거.
- [[raw/blog-topics/spring-boot-startup-exit-code-propagation-2026-06-10]] — 인접한 startup failure topic.
## 미해결 / Unknown
- 아직 확인해야 할 사실: actuator exposure/auth blocker 해소 여부.
- 과장하면 안 되는 부분: Kubernetes end-to-end readiness 보장을 단정하지 않는다. 구현 기록과 남은 blocker를 분리한다.
- 블로그로 쓰기 전에 필요한 canonical 정제: runtime health project 문서의 current state 갱신.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/runtime-container-health-migration.md` 에 Actuator health probe group split 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 review/verify가 필요하다. Kubernetes end-to-end readiness 보장을 단정하지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-runtime-health-lifecycle-contract]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/spring-actuator-health-probe-group-split-YYYY-MM-DD.md` 후보
@@ -0,0 +1,101 @@
---
title: blog-topic / spring-async-taskdecorator-bounded-executor-saturation-shutdown-2026-06-13
source_type: blog-topic
status: raw
related_branches: [feature-background-job-async-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, async, threadpooltaskexecutor, taskdecorator, mdc, graceful-shutdown, micrometer, clean-architecture]
created: 2026-06-13
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: spring-async-taskdecorator-bounded-executor-saturation-shutdown-2026-06-13
> Layer: `raw/blog-topics/` — 채용공고가 아닌 작업·학습·트러블슈팅에서 나온 블로그 글감 원석. canonical 정제 전 raw 후보이며, `wiki/blog/` 직접 생성 근거가 아니다.
## Parent / 부모
- [[raw/branch-notes/feature-background-job-async-contract]] — D5/D6/D7/D8 (executor + context propagation + saturation + graceful shutdown) 실 구현에서 추출.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- "요청 스레드 밖의 실패는 GlobalExceptionHandler 가 못 잡는다" 는 문제의식으로 배경 작업(@Async/scheduler) 운영 계약을 코드로 구현하면서, Spring Boot 의 기본 executor 가 운영에 부적합한 기본값(unbounded queue)을 갖는다는 점과 컨텍스트 전파/우아한 종료의 세부가 한데 모였다.
## 글감 코어 / Core idea
- **기본 executor 를 그대로 쓰면 안 되는 이유**: Spring Boot 가 자동 구성하는 `applicationTaskExecutor` 의 queue capacity 기본값은 `Integer.MAX_VALUE`(사실상 unbounded). JDK `ThreadPoolExecutor` 는 큐가 *가득 찰 때만* core→max 로 성장하므로(3단계 성장), unbounded 큐에서는 `maxPoolSize` 가 영원히 무효 — OOM 직전까지 큐만 쌓인다. 따라서 bounded queue 를 강제하고 `@ConditionalOnMissingBean(Executor.class)` 로 자동 구성을 back-off 시킨 뒤 직접 빈을 등록한다. `Integer.MAX_VALUE` 큐 용량은 "이름만 bounded 인 unbounded" 라 설정 검증에서 거부.
- **TaskDecorator 1개로 컨텍스트 전파**: caller→worker 로 (1) MDC 맵 전체(`MDC.getCopyOfContextMap()` — request_id/trace_id/correlation_id/tenant_id + tracing bridge 가 채운 span_id 까지 한 번에), (2) 도메인 컨텍스트(별도 propagator seam 의 `wrap(Runnable)`)를 복사. **캡처 시점이 핵심**: `decorate()` 호출 시점(=submit time)에 스냅숏을 떠야 하며 run time 이 아니다. 그리고 **대칭 복원**: 작업 후 worker 의 이전 MDC 로 되돌려, 풀 재사용 스레드가 한 작업의 MDC 를 다음 작업으로 흘리지 않게 한다.
- **SecurityContext 는 기본 전파하지 않는다**: `MODE_INHERITABLETHREADLOCAL` 은 풀 스레드 재사용 시 stale principal 위험. principal 이 필요한 use case 만 `DelegatingSecurityContextTaskExecutor` 로 명시적 opt-in. (registry 상 user_principal 은 `propagation: [none]`.)
- **Saturation 을 침묵시키지 않는다**: AbortPolicy 를 감싸 거부 시 (1) 구조화 ERROR 로그(error.code=JOB_EXECUTOR_REJECTED) + (2) `executor.rejected.total{executor_name, policy}` 카운터 증가 후 (3) `RejectedExecutionException` 재던짐(AbortPolicy 시맨틱 보존). `executor.saturation` 게이지로 큐 점유율 관측.
- **Graceful shutdown 예산 계층**: `setWaitForTasksToCompleteOnShutdown(true)` + `setAwaitTerminationSeconds(19)`. 19 = 컨테이너 app shutdown 예산 20s 1s 정리 마진. 계층 부등식: executor await(≤19s) < app shutdown(20s) ≤ `timeout-per-shutdown-phase` < k8s `terminationGracePeriodSeconds`(기본 30s, 초과 시 SIGKILL).
- **async 예외의 두 경로**: `submit()` 은 throwable 을 `Future` 에 가둬 `get()` 으로 표면화(삼켜지지 않음); `execute()` 는 worker 의 uncaught handler 로 간다 — 그래서 모든 작업을 감싸는 decorator 는 예외를 **재던져야** 하고 MDC 복원 `finally` 에서 삼키면 안 된다.
## 글감 / Topic seed
- 한 문장 요지: 운영 가능한 Spring async executor는 bounded queue, context propagation, rejection metric, graceful shutdown budget을 하나의 계약으로 묶어야 한다.
- 예상 제목 후보:
- `@Async`를 운영 계약으로 만들기
- Spring `ThreadPoolTaskExecutor`에서 MDC, saturation, shutdown을 다루는 법
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- unbounded queue에서는 `ThreadPoolExecutor`의 maxPoolSize가 사실상 성장 조건을 만나기 어렵다.
- `TaskDecorator`는 submit 시점의 MDC/context snapshot을 worker 실행으로 넘길 수 있다.
- executor await time은 application/container shutdown budget보다 작아야 한다.
- 의견/해석 후보:
- background job 안정성은 비동기 실행 자체보다 실패, 포화, 종료를 관측 가능한 계약으로 만드는 데 달려 있다.
## Outline seed
1. Spring Boot 기본 executor queue 설정과 maxPoolSize 함정을 설명한다.
2. TaskDecorator로 MDC와 domain context를 복사하고 복원하는 흐름을 정리한다.
3. SecurityContext는 기본 전파하지 않고 opt-in으로 다루는 이유를 적는다.
4. rejection logging/metric과 graceful shutdown budget 계층을 하나의 운영 계약으로 묶는다.
## 왜 흥미로운가 / Why it matters
- "그냥 @Async 붙이면 된다" 와 운영 가능한 background 실행의 간극(기본값 함정 · 컨텍스트 전파 · saturation 가시성 · 종료 예산)을 구체 코드로 보여주는 좋은 사례. Clean Architecture 관점에서 executor 배선은 composition root(app-bootstrap) 가 소유하고, 도메인 컨텍스트 전파는 별도 seam 인터페이스로 분리한 점도 곁들일 수 있다.
## 확장 메모 / Notes
- 본문 작성 시 정량 근거(부하테스트로 core=10/max=50/queue=200 검증)는 아직 `planned` 임을 명시 — 수치는 trade-off 기본값이지 측정값이 아니다.
- Observation **scope** 전파(worker 에서 만든 child span 의 부모 연결)는 `io.micrometer:context-propagation` + `ContextPropagatingTaskDecorator` 가 필요한 별도 업그레이드 — 본 구현은 MDC 문자열 복사(로그 연속성)까지만.
## 관련 / Related
- [[raw/branch-notes/feature-background-job-async-contract]]
- [[raw/official-docs/jdk21-threadpoolexecutor-javadoc]] · [[raw/official-docs/spring-framework-threadpooltaskexecutor-javadoc]] · [[raw/official-docs/spring-executor-configuration-support-javadoc]] · [[raw/official-docs/kubernetes-pod-lifecycle-termination]]
- [[raw/interviews/async-executor-saturation-context-propagation-2026-06-13]]
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/observability-log-metric-trace-runbook.md` 후보:
- async executor MDC/context propagation, saturation metric, graceful shutdown 글감.
- `wiki/projects/ca-tmpl/runtime-container-health-migration.md` 후보:
- shutdown budget와 executor await hierarchy 글감.
- 필요한 추가 검증:
- current executor bean, TaskDecorator, rejection metric, shutdown budget test.
## Sources / 근거 후보
- [[raw/branch-notes/feature-background-job-async-contract]]
- [[raw/official-docs/jdk21-threadpoolexecutor-javadoc]]
- [[raw/official-docs/spring-framework-threadpooltaskexecutor-javadoc]]
- [[raw/official-docs/spring-executor-configuration-support-javadoc]]
- [[raw/official-docs/kubernetes-pod-lifecycle-termination]]
## 미해결 / Unknown
- 아직 확인해야 할 사실: 부하테스트나 executor sizing 실측 여부.
- 과장하면 안 되는 부분: core/max/queue 숫자를 측정 기반 튜닝값처럼 쓰지 않는다.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/observability-log-metric-trace-runbook.md``wiki/projects/ca-tmpl/runtime-container-health-migration.md` 에 async executor 운영 계약 글감으로 반영한다.
- 다음 단계: blogify 전 MDC-only propagation과 Observation scope propagation을 분리한다.
@@ -0,0 +1,127 @@
---
title: Spring Boot 3 @ConfigurationProperties record 에 보조 생성자를 추가하면 바인딩이 깨지는 이유
source_type: blog-topic
status: raw
created: 2026-06-12
related_branches: [feature-domain-event-outbox-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, spring-boot, configuration-properties, record, constructor-binding, java21]
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# Spring Boot 3 `@ConfigurationProperties` record 에 보조 생성자를 추가하면 바인딩이 깨지는 이유
## Parent
[[raw/branch-notes/feature-domain-event-outbox-contract]]
---
## 글감 씨앗
`OutboundHttpSettings` record 에 기존 호출부 호환을 위한 보조 6-arg 생성자를 추가했을 때, `ApplicationContextRunner` 로 바인딩을 테스트하자 `No default constructor found` 로 실패한 경험. 해결책은 `@ConstructorBinding` 을 canonical compact constructor 에 추가하는 것이었다.
---
## 블로그 글 아이디어
### 제목 후보
- "Spring Boot 3 `@ConfigurationProperties` 레코드에 보조 생성자를 추가하면 생기는 일"
- "왜 Java record 에 생성자를 하나 더 추가했더니 Spring Boot 설정 바인딩이 깨졌나"
### 핵심 메시지
Spring Boot 3.x 는 record 에 생성자가 **딱 하나**일 때만 자동으로 constructor binding 경로를 선택한다. 생성자가 둘 이상이면 일반 JavaBean 경로(no-arg constructor 탐색)로 fallback 하기 때문에, 보조 생성자를 추가하는 순간 기존에 잘 돌던 바인딩이 깨진다.
### 커버할 내용
1. Spring Boot `@ConfigurationProperties` 에서 record 바인딩이 동작하는 원리 (single-constructor auto-detect)
2. 보조 생성자 추가 시 발생하는 예외 메시지와 스택 트레이스 분석
3. 해결책: `@ConstructorBinding` (from `org.springframework.boot.context.properties.bind`) 을 canonical compact constructor 에 명시
4. Spring Boot 2.x vs 3.x import 경로 차이 (`@ConstructorBinding` deprecated 위치 변경)
5. 실전 패턴: 기존 호출부 호환을 유지하면서 record 필드를 확장하는 방법 (보조 생성자 + `@ConstructorBinding`)
### 코드 예시
```java
@ConfigurationProperties(prefix = "app.outbound.http")
public record MySettings(
Duration connectTimeout,
Retry retry) {
@ConstructorBinding // 다중 생성자 record 필수!
public MySettings { /* validation */ }
/** 보조 생성자: 기존 호출부 호환 */
public MySettings(Duration connectTimeout) {
this(connectTimeout, null);
}
}
```
### 독자 대상
Java 21 + Spring Boot 3.x 를 사용하며 `@ConfigurationProperties` 를 record 로 작성하는 개발자.
---
## Claims To Verify
- Spring Boot 3.4 릴리즈 노트에 이 동작의 공식 문서 여부 확인 필요.
- `@ConstructorBinding` import 경로 변경 이력 (2.x → 3.x) 공식 마이그레이션 가이드 인용 필요.
## 트리거 / Trigger
- 트리거 유형: `troubleshooting`
- 트리거 날짜: 2026-06-12
- 트리거 연결 노트: [[raw/branch-notes/feature-domain-event-outbox-contract]]
## 글감 / Topic seed
- 한 문장 요지: Spring Boot 3 record `@ConfigurationProperties`에 보조 생성자를 추가하면 constructor binding auto-detect가 깨질 수 있어 canonical constructor에 `@ConstructorBinding`을 명시해야 한다.
- 예상 제목 후보:
- Spring Boot 3 record configuration binding이 깨지는 이유
- 보조 생성자와 `@ConstructorBinding`의 함정
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- multi-constructor record는 single-constructor auto-detect 경로를 벗어날 수 있다.
- 의견/해석 후보:
- backward-compatible constructor를 추가할 때 binding entrypoint를 명시하는 테스트가 필요하다.
## Outline seed
1. record binding auto-detect와 multi-constructor fallback을 설명한다.
2. `ApplicationContextRunner` failure로 원인을 좁힌다.
3. `@ConstructorBinding` import 경로와 canonical constructor 명시 패턴을 정리한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/config-and-adapter-templates.md` 후보:
- configuration properties record binding troubleshooting 글감.
- 필요한 추가 검증:
- 공식 문서/마이그레이션 가이드 source 보강.
## Sources / 근거 후보
- [[raw/branch-notes/feature-domain-event-outbox-contract]]
## 미해결 / Unknown
- 아직 확인해야 할 사실: Spring Boot 3.x 공식 문서의 정확한 constructor binding 문구.
- 과장하면 안 되는 부분: 모든 record multi-constructor가 동일하게 실패한다고 단정하지 않는다.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/config-and-adapter-templates.md` 에 Spring Boot 3 record configuration binding 글감으로 반영한다.
- 다음 단계: blogify 전 official doc 근거를 보강한다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-domain-event-outbox-contract]]
@@ -0,0 +1,81 @@
---
title: blog-topic / spring-boot-serialization-contract-pins
source_type: blog-topic
status: raw
related_branches: [feature-schema-serialization-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, api-design, spring-boot, json, api-contract, static-analysis]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: spring-boot-serialization-contract-pins
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-schema-serialization-contract]] — Jackson/BigDecimal/datetime serialization pin과 ArchUnit guard 구현에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-schema-serialization-contract]]
## 글감 / Topic seed
- 한 문장 요지: Spring Boot serialization을 framework default에 맡기지 않고 명시 pin, effective bean test, ArchUnit rule로 고정한 이유를 정리한다.
- 예상 제목 후보:
- Spring Boot serialization contract를 default 대신 pin으로 관리하기
- BigDecimal과 datetime serialization을 테스트 가능한 계약으로 만들기
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- branch가 별도 blog candidate를 직접 남겼고 serialization pin 구현 결과를 기록했다 — 근거 후보: [[raw/branch-notes/feature-schema-serialization-contract]] line `:302`, D1-D4 `:130-135`, implementation `:244-250`.
- 경험 후보:
- BigDecimal double constructor 차단과 effective ObjectMapper test는 "설정값이 있다"가 아니라 "실제로 적용된다"를 확인하기 위한 장치다.
- 의견/해석 후보:
- serialization policy는 API compatibility의 일부라서 default drift를 방치하면 client contract가 흔들릴 수 있다.
## Outline seed
1. serialization default는 API contract가 아니다 — framework upgrade와 설정 drift를 고려해야 한다.
2. pin + effective bean test 조합 — yml 값과 실제 ObjectMapper 동작을 같이 확인한다.
3. ArchUnit으로 금지 API를 막기 — `new BigDecimal(double)` 같은 실수를 compile/test 단계에서 잡는다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/api-evolution-and-schema.md` 후보:
- ca-tmpl schema/serialization contract 구현 사실.
- `wiki/concepts/api-evolution-and-schema.md` 후보:
- serialization compatibility와 numeric precision 일반 개념.
- 필요한 추가 검증:
- ObjectMapper test, ArchUnit rule, BigDecimal/datetime sample output.
## Sources / 근거 후보
- [[raw/branch-notes/feature-schema-serialization-contract]] — serialization pin과 implementation 근거.
## 미해결 / Unknown
- 아직 확인해야 할 사실: per-API money field 직렬화 예제가 실제로 존재하는지.
- 과장하면 안 되는 부분: 모든 API serialization 문제가 해결됐다고 쓰지 않는다. branch에서 구현/검증한 pin과 guard 범위로 제한한다.
- 블로그로 쓰기 전에 필요한 canonical 정제: project 문서의 verified 항목과 needs-confirmation 항목 분리.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/api-evolution-and-schema.md` 의 schema/serialization 섹션에 output serialization pin, effective `ObjectMapper` test, BigDecimal constructor guard 범위로 반영했다.
- 다음 단계: source canonical이 `verified` 상태이므로 이후 `blogify` 대상으로 삼을 수 있다. 단 입력측 deser switch와 per-API money 직렬화 예제는 별도 owner/미구현 범위로 표시한다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-schema-serialization-contract]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/spring-boot-serialization-contract-pins-YYYY-MM-DD.md` 후보
@@ -0,0 +1,115 @@
---
title: blog-topic / spring-boot-startup-exit-code-propagation-2026-06-10
source_type: blog-topic
status: raw
related_branches: [feature-migration-startup-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, spring-boot, exit-code, startup, kubernetes, flyway, sysexits]
created: 2026-06-10
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: spring-boot-startup-exit-code-propagation-2026-06-10
> Layer: `raw/blog-topics/` — 채용공고가 아닌 작업·학습·트러블슈팅에서 나온 블로그 글감 원석. canonical 정제 전 raw 후보이며, `wiki/blog/` 직접 생성 근거가 아니다.
## Parent / 부모
- [[raw/branch-notes/feature-migration-startup-contract]]
## 글감 한 줄
서버가 뜨기 전에 죽는 실패(env 누락 / migration 실패 / profile mismatch / required adapter disabled)에서 **원인별 JVM exit code** 를 안전하게 전파하는 Spring Boot 메커니즘과, 흔히 처방되는 `System.exit(SpringApplication.exit(run(...)))` 패턴이 장기 실행 서버에서는 오히려 버그인 이유.
## 핵심 포인트 (draft 후보)
1. **두 가지 메커니즘과 동작 시점**
- `ExitCodeExceptionMapper` (bean) — context 가 active 일 때만 동작. context refresh 실패(env/profile/adapter 검증이 `SmartInitializingSingleton` 에서 throw)는 `context.isActive()==false` 라 mapper 가 호출되지 않음.
- `ExitCodeGenerator` (예외가 직접 구현) — `SpringApplication.run()` 이 실패를 re-throw 하면, 부팅 스레드에 설치된 `SpringBootExceptionHandler`(uncaught exception handler)가 실패 예외 체인에서 `getExitCode()` 를 읽어 `System.exit(code)` 호출. **main() 을 건드리지 않아도** custom exit code 가 전파된다.
2. **`System.exit(SpringApplication.exit(run(...)))` 의 함정**
- 많은 글이 "custom exit code 를 쓰려면 main 을 이렇게 감싸라"고 처방한다.
- 그러나 `SpringApplication.exit(context, ...)` 의 구현은 `finally { close(context); }` — context 를 닫고, 정상 부팅이면 `ExitCodeGenerator` bean 이 없으니 **0 을 반환**한다.
- 결과: web 서버처럼 계속 떠 있어야 하는 프로세스를 **부팅 직후 종료**시킨다. 이 패턴은 batch/CLI(러너 완료 후 종료)용이지 long-running server 용이 아니다.
- 교훈: "startup 실패 exit code" 와 "정상 종료 exit code" 는 다른 문제다. 전자는 예외 + `ExitCodeGenerator` 로 충분.
3. **exit code 숫자 선택 — sysexits(3) 정합/불일치**
- `78 EX_CONFIG`(env 누락/malformed), `70 EX_SOFTWARE`(migration 실패) 는 BSD sysexits 의미와 정합.
- `71 EX_OSERR`("cannot fork/pipe"), `72 EX_OSFILE`("system file missing") 는 profile mismatch / adapter disabled 와 의미가 어긋남 → 외부 표준으로 방어 불가, **조직 internal convention** 으로만 성립. 글에서 "POSIX 표준" 이라 과장하지 말 것.
- k8s 는 0255 exit code 를 `lastState.terminated.exitCode` 에 보존하지만 숫자별 자동 분기는 없음 → 실질 discriminator 는 structured log(`startup.phase`/`error.code`).
4. **migration 을 readiness 이전에 — `FlywayMigrationStrategy` vs `ApplicationRunner`**
- `FlywayMigrationStrategy` 는 context refresh 단계(Flyway bean 초기화)에 실행 → readiness(=ApplicationReadyEvent 이후 UP) **이전**에 완료/실패. 반쯤 migrate 된 schema 가 트래픽을 받지 못한다.
- 같은 일을 `ApplicationRunner` 로 하면 ready 이후 실행되어 순서 보장이 깨진다.
## 왜 글로 쓸 만한가
- "startup exit code" 검색 시 나오는 다수 처방이 long-running 서버에 부적합하다는 점은 실제로 코드를 까봐야 드러난다 (`SpringApplication.exit``finally close`).
- sysexits 를 빌려 쓰되 71/72 처럼 의미가 안 맞는 코드를 "표준" 이라 부르지 않는 정직한 컨벤션 설계 사례.
## 검증 상태
- `locally-verified`: 예외별 `getExitCode()` = 78/70/71/72 단위 테스트, structured log 필드 단위 테스트, refresh-time 전략 구조 테스트 (app-bootstrap, 전체 `check` green).
- `planned`: 실제 k8s pod `lastState.terminated.exitCode` e2e 단언, testcontainers 기반 migration 실패 로그 단언.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-06-10
- 트리거 연결 노트: [[raw/branch-notes/feature-migration-startup-contract]]
## 글감 / Topic seed
- 한 문장 요지: startup failure exit code는 long-running server를 `SpringApplication.exit(run(...))`로 감싸는 문제가 아니라 실패 예외와 Boot exit-code propagation 경로를 이해하는 문제다.
- 예상 제목 후보:
- Spring Boot startup 실패 exit code를 안전하게 전파하기
- `SpringApplication.exit(run(...))`가 서버에서 위험한 이유
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- `ExitCodeExceptionMapper``ExitCodeGenerator`는 동작 시점이 다르다.
- sysexits 숫자는 POSIX 표준이 아니라 BSD 관례/조직 convention으로 다뤄야 한다.
- 의견/해석 후보:
- startup failure exit code와 정상 종료 exit code는 다른 문제다.
## Outline seed
1. startup failure의 exit code 전파 경로를 구분한다.
2. `SpringApplication.exit(run(...))` 패턴이 long-running server를 닫는 함정을 설명한다.
3. sysexits 관례와 ca-tmpl 내부 convention의 경계를 분리한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/runtime-container-health-migration.md` 후보:
- startup failure exit code propagation 글감.
- 필요한 추가 검증:
- 현재 ca-tmpl 코드의 exit code exception/test 존재 여부.
## Sources / 근거 후보
- [[raw/branch-notes/feature-migration-startup-contract]]
- [[raw/official-docs/spring-boot-exit-code-generator-startup-failure]]
- [[raw/official-docs/sysexits-bsd-exit-code-convention]]
- [[raw/official-docs/kubernetes-exit-code-observability-termination]]
## 미해결 / Unknown
- 아직 확인해야 할 사실: k8s pod termination exit code e2e 검증 여부.
- 과장하면 안 되는 부분: sysexits를 POSIX 표준이라고 쓰지 않는다.
## Related
- [[raw/official-docs/spring-boot-exit-code-generator-startup-failure]]
- [[raw/official-docs/sysexits-bsd-exit-code-convention]]
- [[raw/official-docs/kubernetes-exit-code-observability-termination]]
- [[raw/blog-topics/hikaricp-inter-knob-constraints-startup-guard-2026-06-09]] — 같은 `SmartInitializingSingleton` fail-fast startup-guard 패턴.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/runtime-container-health-migration.md` 에 startup failure exit code propagation 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 sysexits 관례와 ca-tmpl 내부 convention 경계를 분리해 review한다.
@@ -0,0 +1,95 @@
---
title: blog-topic / spring-conditional-on-property-optional-adapter-template-2026-06-09
source_type: blog-topic
status: raw
related_branches: [feature-integration-adapter-templates]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, spring, clean-architecture, adapter, template]
created: 2026-06-09
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: heavy SDK 없이 "선택형 어댑터 템플릿" 을 경량 skeleton 에 싣기
> Layer: `raw/blog-topics/` — 구현·설계·트러블슈팅 기반 글감(채용공고 아님).
## Parent / 부모
- [[raw/branch-notes/feature-integration-adapter-templates]]
## 글감 한 줄
Clean Architecture 템플릿에 Kafka/Redis/Slack/Email 같은 선택형 어댑터를 "기본 비활성 + 같은 방식으로 실패/관측" 하도록 싣되, 실 SDK 는 안 넣고 `@ConditionalOnProperty` + integration seam + disabled sentinel + ArchUnit 3계층으로 계약만 보장하는 패턴.
## 다룰 내용
1. 문제: 선택형 어댑터를 전부 기본 dependency 로 넣으면 skeleton 이 무거워지고, 빼면 "붙일 때 제각각" 실패한다.
2. 결정: optional module/template 기본 + disabled-default. 대안 비교(Java SPI=on/off 표현 불가·DI 미통합, `@Profile`=boolean 시맨틱 부재, Feature flag(FF4J/Togglz)=runtime branching 이라 startup on/off 와 시맨틱 다름)를 왜 제쳤는지.
3. 3계층 검출:
- Layer 1 `@ConditionalOnProperty(matchIfMissing=false)` 로 bean-gating + 누락=disabled 명시.
- Layer 2 ArchUnit 로 (a) application→optional adapter import 격리, (b) optional adapter `@Bean``@ConditionalOnProperty` gating 강제. 정적 검사의 한계도 함께.
- Layer 3 disabled sentinel 이 `AdapterDisabledException` 으로 fail-fast.
4. integration seam 패턴: `KafkaSender`/`RedisClient`/... interface 만 제공 → fork 프로젝트가 SDK + 구현 주입. 템플릿은 계약 owner, 소비자는 연동 owner.
5. fail-open vs fail-closed: 알림/캐시는 부수효과라 fail-open(5xx 미승격), Redis unavailable=cache-miss, 로거 시그니처에서 payload 인자를 없애 PII 누출을 구조적으로 차단.
6. 운영 계약 정합: error-codes/env-keys registry 에 row 추가, startup `REQUIRED_ADAPTER_DISABLED` 와 runtime `ADAPTER_DISABLED` 를 lifecycle 로 분리.
7. 함정: B7 같은 "outbound 패키지 public method return-type" ArchUnit rule 이 `@Configuration` `@Bean` factory 를 과탐 → rule scoping(약화 아님, 정밀화). [[raw/errors/archunit-b7-configuration-bean-factory-return-type-2026-06-09]].
## 왜 쓸 만한가
- "라이브러리를 안 넣고도 계약을 강제" 하는 구체 사례 — 면접/포트폴리오에서 설계 판단(경량성 vs 계약 강제) 을 보여줄 수 있다.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-06-09
- 트리거 연결 노트: [[raw/branch-notes/feature-integration-adapter-templates]]
## 글감 / Topic seed
- 한 문장 요지: optional adapter를 기본 비활성 seam으로 싣고, 실제 SDK는 fork/consumer가 붙이게 하되 실패 언어와 gating은 skeleton이 제공한다.
- 예상 제목 후보:
- 선택형 어댑터 템플릿을 가볍게 싣는 방법
- `@ConditionalOnProperty`로 optional adapter 계약 만들기
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- raw branch는 optional adapter template과 disabled sentinel, ArchUnit scope를 다룬다.
- 의견/해석 후보:
- 선택형 어댑터의 핵심은 SDK 포함 여부가 아니라 disabled 상태의 실패 방식과 관측 가능성이다.
## Outline seed
1. 선택형 adapter를 모두 dependency로 넣으면 skeleton이 무거워진다.
2. `@ConditionalOnProperty`, integration seam, disabled sentinel의 역할을 나눈다.
3. ArchUnit rule은 정적 검출 범위와 한계를 함께 적어야 한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/config-and-adapter-templates.md` 후보:
- optional adapter template과 ConditionalOnProperty 기반 gating.
- 필요한 추가 검증:
- 실제 adapter template module, ArchUnit rule, disabled sentinel 구현 여부.
## Sources / 근거 후보
- [[raw/branch-notes/feature-integration-adapter-templates]]
- [[raw/errors/archunit-b7-configuration-bean-factory-return-type-2026-06-09]]
## 미해결 / Unknown
- 아직 확인해야 할 사실: 현재 ca-tmpl 코드에 optional adapter template이 구현됐는지.
- 과장하면 안 되는 부분: heavy SDK 없이 계약을 설계한 것과 실제 adapter 구현을 분리한다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-integration-adapter-templates]]
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/config-and-adapter-templates.md` 에 optional adapter template / `@ConditionalOnProperty` 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 실제 adapter template 구현·ArchUnit rule·disabled sentinel 존재 여부를 재검증한다.
@@ -0,0 +1,82 @@
---
title: blog-topic / spring-responseentityexceptionhandler-transport-failure-envelope
source_type: blog-topic
status: raw
related_branches: [feature-api-contract-baseline]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, api-design, error-handling, spring-mvc, api-contract]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: spring-responseentityexceptionhandler-transport-failure-envelope
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-api-contract-baseline]] — HTTP API baseline에서 transport failure를 error envelope로 분류한 구현/검증 경험에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-api-contract-baseline]]
## 글감 / Topic seed
- 한 문장 요지: Spring MVC의 `ResponseEntityExceptionHandler` 기본 흐름을 깨지 않으면서 405/406/413/415 같은 transport failure를 공통 envelope로 정리한 경험을 쓴다.
- 예상 제목 후보:
- Spring transport failure도 같은 error envelope로 다루기
- 405와 415를 domain error처럼 보이게 만들지 않기
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- branch가 transport failure envelope 글감 후보를 직접 남겼다 — 근거 후보: [[raw/branch-notes/feature-api-contract-baseline]] section+line `:479-481`.
- 405/406/413/415 handler와 테스트가 verified item으로 언급된다 — 근거 후보: [[raw/branch-notes/feature-api-contract-baseline]] section+line `:145-156`.
- 경험 후보:
- transport failure를 공통 envelope에 넣되, domain validation/error와 같은 의미로 섞지 않는 설계가 필요했다.
- 의견/해석 후보:
- API error envelope는 "모든 실패를 같은 원인으로 보이게 하는 것"이 아니라, 원인 category를 잃지 않고 client가 일관된 shape을 받게 하는 장치다.
## Outline seed
1. transport failure와 domain failure는 다르다 — 같은 JSON shape이어도 원인 category와 복구 방식은 다르다.
2. Spring MVC 기본 exception handler를 대체할 때 생기는 위험 — framework가 이미 분류한 실패를 덮어쓰지 않아야 한다.
3. envelope의 목적은 균질화가 아니라 관측 가능한 분류다 — status, category, retryability를 잃지 않는다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/api-evolution-and-schema.md` 후보:
- ca-tmpl HTTP API baseline의 transport failure envelope 구현 사실.
- `wiki/concepts/api-error-envelope-design.md` 후보:
- transport/framework-layer failure와 application/domain failure를 envelope에서 구분하는 일반 설계.
- 필요한 추가 검증:
- handler 클래스/테스트 이름, category 값, 실제 응답 shape 확인.
## Sources / 근거 후보
- [[raw/branch-notes/feature-api-contract-baseline]] — transport failure envelope 후보와 verified item 근거.
## 미해결 / Unknown
- 아직 확인해야 할 사실: 실제 handler method별 status/category mapping.
- 과장하면 안 되는 부분: Spring의 모든 예외를 ca-tmpl envelope가 포괄한다고 단정하지 않는다. branch에서 검증된 transport failure 범위로 제한한다.
- 블로그로 쓰기 전에 필요한 canonical 정제: `wiki/projects/ca-tmpl/api-evolution-and-schema.md`의 verified 범위 재확인.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/api-error-envelope-design.md` 에 verified transport failure row와 Spring MVC override 경계를 반영했다. HTTP API baseline 쪽 구현 anchor는 `wiki/projects/ca-tmpl/api-evolution-and-schema.md` 의 D8/D9/D12 transport errors와 연결된다.
- 다음 단계: source canonical이 `verified` 상태이므로 이후 `blogify` 대상으로 삼을 수 있다. 단 Spring MVC의 모든 예외를 포괄한다고 쓰지 않고 검증된 413/406/415/405/412 범위로 제한한다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-api-contract-baseline]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/spring-responseentityexceptionhandler-transport-failure-envelope-YYYY-MM-DD.md` 후보
@@ -0,0 +1,93 @@
---
title: blog-topic / spring-security-filter-layer-error-envelope
source_type: blog-topic
status: raw
related_branches: [feature-security-operational-baseline]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, security, jwt, spring-security, error-handling]
created: 2026-06-08
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: Spring Security 인증 실패는 왜 @RestControllerAdvice 로 안 잡히나
> Layer: `raw/blog-topics/` — 글감 seed. канонical 글은 `/blogify` 후 `wiki/blog/`.
## Parent / 부모
- [[raw/branch-notes/feature-security-operational-baseline]] — 구현 근거 branch note.
## 글감 핵심 / Hook
대부분 `@RestControllerAdvice` + `@ExceptionHandler(AuthenticationException.class)` 로 인증 에러를 공통 처리하려다 "왜 안 잡히지?" 를 겪는다. 답: resource server 의 bearer 토큰 검증 실패는 **filter 단계**(`BearerTokenAuthenticationFilter` / `ExceptionTranslationFilter`)에서 `AuthenticationEntryPoint` 로 흘러 DispatcherServlet 의 advice 에 도달하지 않는다.
## 다룰 내용 / Outline
1. **실패 흐름 해부**: 토큰 없음 → `InsufficientAuthenticationException`(authorization filter) / 토큰 무효 → `OAuth2AuthenticationException`(bearer filter). 둘 다 EntryPoint 로. 403 은 `AccessDeniedHandler`.
2. **공통 에러 Envelope 통일**: custom `AuthenticationEntryPoint`/`AccessDeniedHandler` 가 나머지 API 와 동일한 응답 봉투(`success/error/meta`)를 직접 직렬화. 컨트롤러 에러와 보안 에러의 shape 일치.
3. **fine-grained 분류 + 과노출 방지**: `JwtValidationException`(exp/iss/aud) vs `BadJwtException`(signature/malformed/kid) 를 inspect 해 12 code 로 분기하되, 클라이언트엔 generic message + `WWW-Authenticate`/`Retry-After` 만. token/issuer/audience 는 로그·응답에 미노출.
4. **clock skew 명시 패턴**: `SupplierJwtDecoder` 로 JWKS discovery 를 lazy 유지하면서 `JwtTimestampValidator(Duration.ofSeconds(60))` 를 명시 → 프레임워크 default 변경에 의한 silent drift 차단. startup 시 IdP 불필요라는 부수 이점.
5. **heuristic 의 한계**: 메시지 문자열 매칭의 fragility, unmapped → generic 401 fallback(절대 500 금지).
## Outline seed
1. bearer token 실패가 DispatcherServlet 이전 filter layer에서 처리되는 흐름을 설명한다.
2. `AuthenticationEntryPoint``AccessDeniedHandler`가 API error envelope을 직접 직렬화하는 방식을 정리한다.
3. token/issuer/audience를 응답에 노출하지 않으면서 code를 분류하는 경계를 적는다.
4. clock skew 명시와 heuristic fallback의 한계를 함께 다룬다.
## 차별점 / Why worth writing
"Spring Security 에러를 advice 로 못 잡는다" 는 흔한 함정 + 공통 Envelope 통일 + 운영 분류 + 표준(RFC 9110) 정합을 한 흐름으로 묶은 실전 예시. skeleton 코드 anchor 존재.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-06-08
- 트리거 연결 노트: [[raw/branch-notes/feature-security-operational-baseline]]
## 글감 / Topic seed
- 한 문장 요지: Spring Security 인증/인가 실패는 DispatcherServlet 이전 filter layer에서 처리되므로 `@RestControllerAdvice`가 아니라 entry point/denied handler가 envelope을 직접 직렬화해야 한다.
- 예상 제목 후보:
- Spring Security 인증 실패는 왜 ControllerAdvice로 안 잡힐까
- Filter layer 보안 오류를 API envelope으로 통일하기
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- bearer token validation failure는 `AuthenticationEntryPoint`로 흐르고 controller advice에 도달하지 않는다.
- 403은 `AccessDeniedHandler` 경로다.
- 의견/해석 후보:
- 보안 오류 shape을 API envelope과 맞추되 token/issuer/audience 과노출은 피해야 한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets.md` 후보:
- Spring Security filter-layer error envelope 글감.
- `wiki/projects/ca-tmpl/api-error-envelope-design.md` 후보:
- envelope shape 통일의 security adapter 경계.
- 필요한 추가 검증:
- custom entry point/denied handler 구현과 error code 분류 테스트.
## Sources / 근거 후보
- [[raw/branch-notes/feature-security-operational-baseline]]
## 미해결 / Unknown
- 아직 확인해야 할 사실: 현재 custom `AuthenticationEntryPoint` / `AccessDeniedHandler` 구현 여부.
- 과장하면 안 되는 부분: 모든 Spring Security exception을 fine-grained하게 안정 분류한다고 쓰지 않는다.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets.md``wiki/projects/ca-tmpl/api-error-envelope-design.md` 에 filter-layer security envelope 글감으로 반영한다.
- 다음 단계: blogify 전 heuristic message matching 한계를 유지한다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-security-operational-baseline]]
@@ -0,0 +1,82 @@
---
title: blog-topic / streaming-response-not-supported-archunit-ban
source_type: blog-topic
status: raw
related_branches: [feature-streaming-response-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, api-design, testing, archunit, static-analysis, api-contract]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: streaming-response-not-supported-archunit-ban
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-streaming-response-contract]] — server-push streaming 미지원 결정을 ArchUnit import-ban으로 강제한 branch에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-streaming-response-contract]]
## 글감 / Topic seed
- 한 문장 요지: SSE/WebSocket을 지금 지원하지 않는다는 결정을 문서 선언에 그치지 않고 ArchUnit import-ban으로 고정한 이유를 정리한다.
- 예상 제목 후보:
- 지원하지 않는 기능도 architecture contract가 될 수 있다
- SSE/WebSocket 미지원 결정을 ArchUnit으로 강제하기
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- branch에 streaming 미지원 D1과 ArchUnit import-ban D3가 정리되어 있다 — 근거 후보: [[raw/branch-notes/feature-streaming-response-contract]] D1/D3 `:125-138`, DEM `:144-146`, ingest note `:253`.
- 경험 후보:
- 기존 `archunit-testcompileonly-fixture` topic은 fixture/classloading 함정에 가까워, 미지원 전략 자체는 별도 글감으로 분리할 수 있다.
- 의견/해석 후보:
- skeleton에서 미지원은 빈칸이 아니라 adoption boundary다. 지원하지 않는 surface도 의도적으로 차단해야 한다.
## Outline seed
1. "아직 안 씀"과 "지원하지 않음"은 다르다 — 후자는 contract로 표현할 수 있다.
2. import-ban rule의 장점 — 특정 framework API가 product surface로 새어 나오는 것을 막는다.
3. 예외와 경계 — download streaming 등 차단 제외 범위를 명확히 해야 한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/streaming-response-support.md` 후보:
- ca-tmpl streaming response 미지원 project decision과 ArchUnit rule.
- `wiki/concepts/streaming-response-patterns.md` 후보:
- SSE/WebSocket/long-polling/chunked response 선택 기준.
- 필요한 추가 검증:
- banned API 목록과 fixture/vacuous-pass 방지 테스트.
## Sources / 근거 후보
- [[raw/branch-notes/feature-streaming-response-contract]] — 미지원 결정과 ArchUnit guard 근거.
- [[raw/blog-topics/archunit-testcompileonly-fixture-annotation-pattern-2026-06-02]] — 인접한 fixture 함정 topic.
## 미해결 / Unknown
- 아직 확인해야 할 사실: `StreamingResponseBody` 다운로드 예외처럼 허용 surface가 있는지.
- 과장하면 안 되는 부분: streaming 기술 자체가 나쁘다고 쓰지 않는다. ca-tmpl skeleton scope에서 미지원으로 둔 결정이다.
- 블로그로 쓰기 전에 필요한 canonical 정제: project decision과 general streaming concept 분리.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/streaming-response-support.md` 에 streaming 미지원 + ArchUnit ban 글감으로 반영했다.
- 다음 단계: source canonical이 `verified` 상태이므로 이후 `blogify` 대상으로 삼을 수 있다. 단 streaming 기술 자체가 나쁘다는 결론으로 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-streaming-response-contract]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/streaming-response-not-supported-archunit-ban-YYYY-MM-DD.md` 후보
@@ -0,0 +1,93 @@
---
title: blog-topic / test-taxonomy-archunit-enforcement-2026-06-19
source_type: blog-topic
status: raw
related_branches: [feature-test-taxonomy-fixture-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, archunit, test-taxonomy, testcontainers, spring-test-slice, fixture]
created: 2026-06-19
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: test-taxonomy-archunit-enforcement-2026-06-19
> Layer: `raw/blog-topics/` — 채용공고가 아닌 작업·학습·트러블슈팅에서 나온 블로그 글감 원석. canonical 정제 전 raw 후보이며, `wiki/blog/` 직접 생성 근거가 아니다.
## Parent / 부모
- [[raw/branch-notes/feature-test-taxonomy-fixture-contract]] — 6-level test taxonomy 계약을 _문서_ 에서 _빌드가 강제하는 규칙_ 으로 옮긴 구현(2026-06-19).
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-06-19
- 트리거 연결 노트: [[raw/branch-notes/feature-test-taxonomy-fixture-contract]] — §테스트 계약 #4 와 D6/D7 drift 를 ArchUnit 규칙으로 닫은 작업.
## 글감 / Topic seed
테스트 분류(unit/contract/architecture/slice/integration/smoke)를 README 에 적어두는 것과, _잘못된 레벨에 놓인 테스트를 빌드가 거부_ 하게 만드는 것은 다르다. 후자를 ArchUnit 으로 구현한 사례.
핵심 3개 규칙:
1. **레벨 경계 = 의존 경계로 강제**: "contract·architecture 레벨 테스트는 Testcontainers 에 의존하면 실패." Testcontainers 를 쓰던 `bootstrap/contract/` 테스트 5개는 사실 integration 테스트가 contract 디렉터리에 mis-file 된 것 → `bootstrap/integration/` 으로 재분류한 뒤, `..contract..`/`..architecture..` 패키지가 `org.testcontainers..` 에 의존하면 fail 하는 규칙을 추가. 이렇게 하면 "5분 fast-feedback 게이트(unit+contract+architecture)" 가 컨테이너 기동 비용에 오염되는 것을 빌드가 막는다.
2. **Spring slice annotation 혼용 금지**: Spring 공식이 `@WebMvcTest` + `@DataJpaTest` 혼용을 "not supported" 로 명시(SB-SLICE-C2) → 한 클래스에 두 slice annotation 이 붙으면 fail.
3. **fixture 가 production classpath 로 새지 않게**: `..fixtures..` 패키지에 대한 production 코드 의존을 차단.
## 왜 흥미로운가 / Why it's worth writing
- "테스트 분류는 컨벤션" 이라는 통념을 깨는 구체적 메커니즘. `@Tag` 보다 강하게, _import graph_ 자체로 레벨을 강제한다.
- ArchUnit 함정 2개를 실제로 다룬다:
- `@AnalyzeClasses(DoNotIncludeTests)` 는 test 클래스를 못 본다 → 규칙 대상이 test 자체일 땐 manual `ClassFileImporter` 가 필요. ([[raw/interviews/archunit-manual-importer-vs-analyzeclasses]])
- `allowEmptyShould(true)` + positive control: 규칙이 진짜로 발화하는지 증명하지 않으면 vacuous pass. 본 작업은 Testcontainers 를 실제로 쓰는 integration 패키지에 규칙을 평가해 `hasViolation()==true` 로 non-vacuity 를 못박았다. ([[raw/blog-topics/archunit-violations-as-data-pattern-2026-05-28]])
## 곁가지 / Tangents
- DIR_LEVEL drift("integration test 가 contract 폴더에 있다")처럼, 디렉터리 이름과 테스트 _레벨_ 이 어긋나면 fast-feedback 게이트 설계가 조용히 무너진다는 운영 교훈.
- 이 글감은 cross-branch [[raw/branch-notes/feature-ci-quality-gates-contract]](5분 budget 게이트의 CI 구현 owner)와 묶어 "테스트 피라미드를 CI 가 강제하는 법" 으로 확장 가능.
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- test taxonomy는 package/import graph로도 강제할 수 있다.
- contract/architecture level에서 Testcontainers dependency를 금지하면 fast-feedback gate 오염을 줄일 수 있다.
- 의견/해석 후보:
- test level은 이름표가 아니라 실행 비용과 dependency boundary의 계약이다.
## Outline seed
1. README taxonomy와 build-enforced taxonomy의 차이를 설명한다.
2. Testcontainers dependency, slice annotation, fixtures leak rule을 나눠 설명한다.
3. manual `ClassFileImporter`와 non-vacuity positive control의 필요성을 정리한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard.md` 후보:
- test taxonomy ArchUnit enforcement 글감.
- 필요한 추가 검증:
- 현재 test taxonomy rule과 relocated integration test evidence.
## Sources / 근거 후보
- [[raw/branch-notes/feature-test-taxonomy-fixture-contract]]
- [[raw/interviews/archunit-manual-importer-vs-analyzeclasses]]
- [[raw/blog-topics/archunit-violations-as-data-pattern-2026-05-28]]
## 미해결 / Unknown
- 아직 확인해야 할 사실: CI 5min budget과 ArchUnit rule이 실제로 연결되어 release-blocking인지.
- 과장하면 안 되는 부분: 테스트 품질 전체를 보장한다고 쓰지 않고 level misplacement 방지로 제한한다.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard.md` 에 test taxonomy ArchUnit enforcement 글감으로 반영한다.
- 다음 단계: blogify 전 hosted CI와 local architecture test evidence를 분리한다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-test-taxonomy-fixture-contract]]
@@ -0,0 +1,82 @@
---
title: blog-topic / transaction-isolation-vendor-default-pin
source_type: blog-topic
status: raw
related_branches: [feature-transaction-concurrency-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, persistence, postgresql, transaction-isolation, mvcc, gap-lock]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: transaction-isolation-vendor-default-pin
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-transaction-concurrency-contract]] — DB vendor default에 맡기지 않고 isolation을 명시 pin하는 결정에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-transaction-concurrency-contract]]
## 글감 / Topic seed
- 한 문장 요지: Postgres/MySQL 등 DB vendor default isolation 차이를 skeleton contract에서 명시 pin/test로 다루는 이유를 정리한다.
- 예상 제목 후보:
- DB isolation을 default에 맡기지 않은 이유
- Transaction isolation은 왜 skeleton contract가 되어야 할까
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- branch의 D3가 vendor default 차이와 pin/test 계약을 다룬다 — 근거 후보: [[raw/branch-notes/feature-transaction-concurrency-contract]] line `:116`, `:164-179`, `:287-292`.
- 경험 후보:
- 기존 TransactionPort topic은 abstraction 중심이고, 이 글감은 isolation pin/test 계약을 별도로 다룬다.
- 의견/해석 후보:
- transaction abstraction이 있어도 isolation default를 숨기면 concurrency behavior가 환경별로 달라질 수 있다.
## Outline seed
1. `@Transactional` 추상화와 isolation은 다른 문제다 — method boundary와 DB behavior를 분리한다.
2. vendor default가 다른 이유를 글감으로 삼기 — Postgres/MySQL 차이를 project contract로 pin한다.
3. pin만으로 충분하지 않다 — startup/test에서 실제 isolation을 확인해야 한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/transaction-boundary-abstraction.md` 후보:
- ca-tmpl transaction/concurrency isolation pin decision.
- `wiki/concepts/transaction-isolation.md` 후보:
- isolation level, MVCC, gap lock 일반 개념.
- 필요한 추가 검증:
- 실제 DB별 isolation check test와 configured value.
## Sources / 근거 후보
- [[raw/branch-notes/feature-transaction-concurrency-contract]] — D3 isolation pin 근거.
- [[raw/blog-topics/transaction-port-abstraction-over-spring-transactional-2026-05-28]] — 인접한 TransactionPort topic.
## 미해결 / Unknown
- 아직 확인해야 할 사실: MySQL/Postgres 양쪽에서 실제 테스트했는지, 아니면 policy만 있는지.
- 과장하면 안 되는 부분: 모든 concurrency anomaly를 isolation pin으로 해결한다고 쓰지 않는다.
- 블로그로 쓰기 전에 필요한 canonical 정제: project implementation evidence와 general DB concept 분리.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/transaction-boundary-abstraction.md` 에 transaction isolation vendor default pin 글감으로 반영했다.
- 다음 단계: source canonical이 `verified` 상태이므로 이후 `blogify` 대상으로 삼을 수 있다. 단 모든 concurrency anomaly를 isolation pin으로 해결한다고 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-transaction-concurrency-contract]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/transaction-isolation-vendor-default-pin-YYYY-MM-DD.md` 후보
@@ -0,0 +1,127 @@
---
title: blog-topic / transaction-port-abstraction-over-spring-transactional-2026-05-28
source_type: blog-topic
status: raw
related_branches: [feature-application-port-usecase-contract]
related_projects: [ca-skeleton]
tags: [blog-topic, ca-skeleton, transaction, hexagonal, spring, transactional, transaction-port, archunit]
created: 2026-05-28
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: transaction-port-abstraction-over-spring-transactional-2026-05-28
> Layer: `raw/blog-topics/` — 채용공고가 아닌 작업·학습·트러블슈팅에서 나온 블로그 글감 원석. canonical 정제 전 raw 후보이며, `wiki/blog/` 직접 생성 근거가 아니다.
## Parent / 부모
- [[raw/branch-notes/feature-application-port-usecase-contract]] — `TransactionPort` 도입 + ArchUnit fitness function + `sample-ticket` 마이그레이션의 실 구현 (D3, Decisions 2026-05-28).
- [[raw/project-notes/ca-skeleton-operational-contract]] — ca-tmpl skeleton 의 운영 계약 맥락.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-05-28
- 트리거 연결 노트: [[raw/branch-notes/feature-application-port-usecase-contract]] — branch-note 의 D3 ("transaction boundary 는 application use case 책임이지만 Spring `@Transactional` 직접 import 는 금지하고 `TransactionPort` 또는 `TransactionalUseCaseRunner` abstraction 을 기본값으로") 를 실제 코드로 옮긴 작업이 글감의 핵심.
## 글감 / Topic seed
- 한 문장 요지: application 계층이 `@Transactional`_직접 import_ 하지 않도록 `TransactionPort` 같은 추상화를 두는 선택은 Hexagonal 의 소수파 패턴이지만, **ArchUnit fitness function 과 결합하면** framework leakage drift 를 _실제로_ 막을 수 있다 — 추상은 그 자체가 아니라 _enforce 되는_ 추상이 의미를 갖는다.
- 떠오른 계기: `feature-application-port-usecase-contract` 작업에서 `TransactionPort` 를 정의하고 ArchUnit 의 `application_does_not_use_spring_transactional_annotation` 으로 `@Transactional` 직접 import 를 차단, 동시에 `sample-ticket` 의 기존 `@Transactional` 사용을 모두 `tx.inWrite` / `tx.inRead` 로 마이그레이션한 경험.
- 예상 제목 후보:
- application 계층에서 `@Transactional` 을 떼어내기 — TransactionPort 와 ArchUnit fitness function
- Hexagonal 다수파 vs 소수파 — 트랜잭션 경계 추상화의 비용과 이득
- `@Transactional` 직접 부착 vs TransactionPort — ca-skeleton 사례
## 핵심 주장 후보 / Claim candidates
> 각 항목은 branch-note Decision ID 또는 외부 source claim ID 로 근거를 인용한다.
- 사실 후보:
- ca-tmpl 의 `application-core` 모듈은 Gradle 의존성에서 `spring-tx`_제거_ 해서 `@Transactional` 어노테이션이 컴파일 classpath 에서 _reach 불가능_ — 근거: `feature-application-port-usecase-contract.md` Decisions 2026-05-28 ("`application-core` Gradle 의 `spring-tx` 의존성 제거. `@Transactional` 어노테이션이 모듈 컴파일 classpath 자체에 없도록 belt+suspenders") + 구현 결과 §application-core build.gradle.
- `TransactionPort``inWrite` / `inRead` / `inNew` 3개 메서드만 노출하고 `NESTED` / `NEVER` propagation 은 _의도적_ 미노출 — 근거: 동일 branch-note Decisions 2026-05-28 ("`TransactionPort` API 는 `inWrite` / `inRead` / `inNew` 3 메서드. `NESTED``NEVER` 는 API 에서 노출 안 함") + §TransactionPort Contract 표.
- `SpringTransactionPort` 는 mode 별로 미리 빌드된 `TransactionTemplate` 인스턴스 3개를 보관해서 호출 시점의 mutation 없이 _동시성 안전_ — 근거: 동일 Decisions ("모드별 `TransactionTemplate` 인스턴스 3개를 미리 빌드해서 보관. `setReadOnly` / `setPropagationBehavior` 의 호출당 mutation 으로 인한 동시성 위험 차단").
- ArchUnit `noClasses().that().resideInAPackage("..application..").should().dependOnClassesThat().haveFullyQualifiedName("org.springframework.transaction.annotation.Transactional")` 으로 application 의 `@Transactional` import 를 자동 차단 — 근거: 동일 branch-note Claims to Verify 표의 "application package의 ArchUnit rule이 `org.springframework.transaction.annotation.Transactional` import를 실제로 catch" 행 → `actually-implemented` (2026-05-28).
- `Isolation` enum 은 `READ_COMMITTED` 만 노출하고 `REPEATABLE_READ` / `SERIALIZABLE``feature-transaction-concurrency-contract` 로 위임 — 근거: 동일 Decisions ("`Isolation` enum 은 `READ_COMMITTED` 만 노출").
- Spring `@Transactional` AOP proxy 의 _self-invocation_ 함정: 같은 클래스 내 `this.otherMethod()` 호출 시 proxy 우회 — 근거: `raw/official-docs/at-transactional-spring-official.md#AT-TX-C5`.
- `readOnly = true` 는 REQUIRED / REQUIRES_NEW propagation 에 한정 적용 — 근거: `raw/official-docs/spring-tx-management-reference.md#SPRING-TX-MGR-C6`. ca-tmpl 의 `TransactionPort.inRead` 도 이 제약을 따라 REQUIRED + readOnly 로 구현.
- 경험 후보:
- 기존 `sample-ticket` 의 aggregate Service (`UserService` / `PostService`) 의 `@Transactional(readOnly=true)` class-level + `@Transactional` method-level 패턴을 `tx.inRead(() -> ...)` / `tx.inWrite(() -> ...)`_일괄 마이그레이션_. 동작 동등하지만 application 의 Spring 의존성 surface 가 감소 — 근거: `feature-application-port-usecase-contract.md` 구현 결과 §sample-ticket.
- ArchUnit rule 을 추가했지만 `app-bootstrap` 의 test classpath 가 `sample-ticket` 을 포함하지 않아서 _vacuously_ 통과한 사례. `testImplementation project(':sample-ticket')` 으로 test-only 의존을 추가해 해결 — 근거: 파생 에러 [[raw/errors/archunit-test-scope-sample-ticket-inclusion-2026-05-28]].
- `Idempotency` enum 값을 `IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT` 세 종으로 결정. `KEYED` 는 idempotency key 기반 dedup 필요 표시이고 후속 `feature-rate-limit-idempotency-contract` 가 이어받음 — 근거: `feature-application-port-usecase-contract.md` Decisions 2026-05-28.
- 의견 / 해석 후보:
- `TransactionPort` 추상의 _진짜 이득_ 은 testability 가 아니다 (`@Transactional` 메서드도 `@SpringBootTest` 로 잘 테스트됨). **Spring 의존을 단일 진입점 (`SpringTransactionPort`) 으로 좁힌다** — Spring 업그레이드 / multi-tenant / multi-DB 시 transaction 정책 변경 지점이 한 클래스로 집중됨.
- **boilerplate 증가는 사실** (메서드마다 `tx.inWrite(() -> { ... })` 한 단). 단일 DB / 단일 transactionManager 환경의 작은 팀은 다수파 (`@Transactional` 직접 부착, hexagonal-reflectoring) 가 reasonable.
- **추상은 _enforce 되는_ 추상이 의미를 갖는다**. ArchUnit fitness function 없이 `TransactionPort` 만 두면 _컨벤션_ 에 그치지만, fitness function 이 `@Transactional` import 를 _실패_ 시키면 추상이 _drift 방지 메커니즘_ 으로 작동.
- **`testImplementation project(':sample-ticket')` 으로 sample 을 test classpath 에만 두는 비대칭 의존**은 production drift 차단 (`production_code_does_not_depend_on_sample_ticket`) 과 ArchUnit scope 확장을 _양립_ 시키는 흥미로운 패턴 — 일반화 가능.
## Outline seed
> 각 섹션 옆에 `→ 핵심 메시지` 를 함께 명시한다.
1. 동기 — hexagonal / Clean Architecture 를 "했다" 면서 `@Transactional` 은 application 에 그대로 두는 일관성 누락 → **추상의 _enforceable_ 형태가 없으면 컨벤션이 무너진다.**
2. 다수파 입장의 합리성 — `@Transactional` 직접 부착 ([[raw/company-tech-blogs/hexagonal-reflectoring-transactional-placement]]) → **소수파 결정의 _대가_ 를 인정하고 시작.**
3. ca-tmpl 의 선택 — `TransactionPort` (UNIL / Vassilis Soum 류) + ArchUnit fitness function _결합_**두 요소가 함께 있을 때만 의미.**
4. 구현 스케치 — `TransactionPort` 의 3 메서드 한정 API → **`NESTED` / `NEVER` 미노출이 컨벤션이 아니라 API 표현.**
5. infrastructure 구현 — `SpringTransactionPort` 의 mode 별 pre-built `TransactionTemplate`**`setReadOnly` / `setPropagationBehavior`_호출당 mutation_ 회피.**
6. belt+suspenders — `application-core` Gradle 에서 `spring-tx` 제거 → **컴파일 classpath 에서 reach 불가능 + ArchUnit 양쪽으로 막음.**
7. ArchUnit scope 의 함정 — `app-bootstrap` test classpath 가 `sample-ticket` 을 안 보던 문제 → **`testImplementation project(':sample-ticket')` 의 비대칭 의존.**
8. 마이그레이션 결과 — `sample-ticket``@Transactional` 0개. `tx.inWrite` / `tx.inRead` 로 일괄 전환 → **동작 동등 + Spring 의존 surface 감소.**
9. 한계 / 미해결 — `REQUIRES_NEW` 실 outbox 통합 검증 미수행, `noRollbackFor` / `timeout` 미지원, `externalOutboundAllowed` dependency-aware rule 미작성 → **추상이 _모든_ Spring 표현력을 capture 하는 게 아니라는 정직함.**
10. 정리 — 추상은 그 자체로 가치 있는 게 아니라, _enforce 되는_ 추상이 의미를 갖는다 → **fitness function 결합이 본 글의 진짜 thesis.**
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/transaction-port-contract.md` 후보:
- `TransactionPort` 의 3 메서드 + `Isolation.READ_COMMITTED` 만 노출 + `NESTED` / `NEVER` 차단.
- `SpringTransactionPort` 의 mode 별 pre-built template 패턴.
- `application-core``spring-tx` 제거 + ArchUnit fitness function 의 belt+suspenders.
- sample-ticket 마이그레이션 결과 (Before / After 코드).
- `wiki/concepts/transaction-boundary-abstraction.md` 후보:
- 다수파 (`@Transactional` 직접 부착) vs 소수파 (`TransactionPort`) 의 trade-off 표.
- `TransactionTemplate` vs `@Transactional` AOP proxy 의 self-invocation 차이.
- "추상은 enforce 되는 추상이 의미를 갖는다" 의 일반 원칙.
- 필요한 추가 검증:
- `REQUIRES_NEW` 의 실 outbox 동작 통합 테스트 (Testcontainers, `feature-domain-event-outbox-contract` 로 위임).
- `readOnly = true` 의 Hibernate flush-mode 측정 PoC (`feature-application-port-usecase-contract.md` Claims to Verify 의 `planned`).
- `*Port` outbound naming rule + `externalOutboundAllowed` dependency-aware rule (outbound port marker 정의 필요).
## Sources / 근거 후보
- [[raw/branch-notes/feature-application-port-usecase-contract]] — 결정 D1~D10 + 2026-05-28 구현 결과 + Claims to Verify status.
- [[raw/branch-notes/feature-architecture-enforcement-rules]] — 자매 결정 (D8: application `@Transactional` 직접 import 금지) 의 _자동 검증_ 자매.
- [[raw/errors/archunit-test-scope-sample-ticket-inclusion-2026-05-28]] — ArchUnit scope 의 test-classpath 함정.
- [[raw/interviews/transaction-port-vs-spring-transactional]] — 같은 주제의 면접 질문 노트.
- [[raw/official-docs/at-transactional-spring-official]] — `@Transactional` 공식 + self-invocation 함정 (`AT-TX-C1`, `AT-TX-C5`).
- [[raw/official-docs/spring-tx-management-reference]] — Spring transaction abstraction (`SPRING-TX-MGR-C3`, `SPRING-TX-MGR-C6`).
- [[raw/official-docs/transaction-template-spring-official]] — `TransactionTemplate` programmatic 공식 (`TX-TMPL-C1` ~ `TX-TMPL-C4`).
- [[raw/company-tech-blogs/transaction-port-clean-ddd-spring-medium]] — UNIL TransactionPort 진화 사례 (`UNIL-TX-C1`, `UNIL-TX-C2`).
- [[raw/company-tech-blogs/transaction-port-vassilis-soum-github-readme]] — Vassilis Soum 의 TransactionPort 참고 구현 (`VSOUM-TX-C1` ~ `VSOUM-TX-C4`).
- [[raw/company-tech-blogs/hexagonal-reflectoring-transactional-placement]] — `@Transactional` 직접 부착 다수파 (`HEX-REFL-C1`, `HEX-REFL-C5`).
- [[raw/blog-topics/clean-architecture-boundary-enforcement-2026-05-28]] — ArchUnit fitness function 의 자매 글감.
## 미해결 / Unknown
- 아직 확인해야 할 사실: `TransactionPort``noRollbackFor` / `timeout` / `transactionManager` (multi-DB) 등 Spring 표현력 전체를 capture 가능한지 — 현재는 _하지 않음_ (의도적 한정).
- 아직 확인해야 할 사실: `REQUIRES_NEW` 가 실제 outbox / audit row 시나리오에서 _독립 commit_ 되는지 통합 테스트 미수행.
- 아직 확인해야 할 사실: `TransactionTemplate` 기반 구현이 self-invocation 함정에서 _완전히_ 자유로운지 (port 메서드가 다른 port 메서드를 호출하는 경우 PoC 필요).
- 과장하면 안 되는 부분: 본 글의 모든 검증은 **`locally-verified`** 이고 prod 운영 없음.
- 과장하면 안 되는 부분: `TransactionPort` 가 다수파 (`@Transactional` 직접) 보다 _우월_ 하다는 식 금지. _이 맥락 (template repository, 격리 우선)_ 의 선택까지만.
- 블로그로 쓰기 전에 필요한 canonical 정제: `wiki/projects/ca-tmpl/transaction-port-contract.md` 정제 + outbox 통합 검증 1 사례 추가 후 `expanded`.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/transaction-boundary-abstraction.md` 에 TransactionPort abstraction 글감으로 반영했다.
- 다음 단계: source canonical이 `verified` 상태이므로 이후 `blogify` 대상으로 삼을 수 있다. 단 REQUIRES_NEW/outbox 실 DB 통합 검증 부재와 소수파 선택이라는 경계를 유지한다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-application-port-usecase-contract]] (본 결정의 SSOT), [[raw/branch-notes/feature-architecture-enforcement-rules]] (자매 — `@Transactional` 금지 rule), [[raw/branch-notes/feature-domain-event-outbox-contract]] (후속 — `REQUIRES_NEW` 의 실 사용처).
- 관련 errors: [[raw/errors/archunit-test-scope-sample-ticket-inclusion-2026-05-28]].
- 관련 interview prep: [[raw/interviews/transaction-port-vs-spring-transactional]] (같은 주제의 자매), [[raw/interviews/clean-architecture-boundary-enforcement]] (ArchUnit 자매).
- 관련 blog topics: [[raw/blog-topics/clean-architecture-boundary-enforcement-2026-05-28]] (자매 글감 — boundary 강제), [[raw/blog-topics/clean-architecture-module-blueprint-2026-05-28]] (자매 글감 — module 분리), [[raw/blog-topics/post-implementation-knowledge-capture-workflow-2026-05-28]] (캡처 워크플로우 자매).
- derived blog: 생성 전. 생성 시 `wiki/blog/transaction-port-abstraction-over-spring-transactional-YYYY-MM-DD.md` 후보.
@@ -0,0 +1,87 @@
---
title: blog-topic / trivy-suppression-governance-static-gate
source_type: blog-topic
status: raw
related_branches: [feature-dependency-vulnerability-management-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, security, supply-chain, ci]
created: 2026-06-20
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: trivy-suppression-governance-static-gate
> Layer: `raw/blog-topics/` — 작업·트러블슈팅에서 나온 블로그 글감 원석.
> `status_label`: `captured`
## Parent / 부모
- [[raw/branch-notes/feature-dependency-vulnerability-management-contract]] — D5 를 구현하며 "suppression 거버넌스를 빌드 게이트로 강제"한 경험에서 도출.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-06-20
- 트리거 연결 노트: [[raw/branch-notes/feature-dependency-vulnerability-management-contract]]
## 글감 / Topic seed
- 한 문장 요지: 취약점 스캐너의 suppression 파일(`.trivyignore.yaml`)은 그대로 두면 *만료일·사유 없는 영구 silent bypass* 가 되기 쉬운데, 이걸 100줄짜리 Gradle 정적 게이트 하나로 빌드 차원에서 강제할 수 있다.
- 예상 제목 후보:
- "`.trivyignore` 가 백도어가 되지 않게: Gradle 정적 게이트로 suppression 거버넌스 강제하기"
- "보안 게이트의 게이트: suppression 에 만료일과 사유를 코드로 강제한 이야기"
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- Trivy 는 `expired_at` 이 없으면 suppression 을 **영구 유효**로 취급한다 — 근거 후보: [[raw/official-docs/trivy-filtering-suppression-policy]] C4.
- "정책(policy)"과 "강제(enforcement)"는 분리된다: 정책 문서(`dependency-vulnerability-policy.md`)는 사람이 읽고, 강제는 `verifyTrivyignore` gate + CODEOWNERS 가 한다 — 근거 후보: [[raw/branch-notes/feature-dependency-vulnerability-management-contract]] D5 §3.
- 경험 후보:
- line-based parser(YAML 라이브러리 없이, repo 의 `verifyEnvKeys` 스타일 답습)로 게이트를 만들고 6-케이스로 pass·fail 검증 — 근거 후보: 동 branch §진행 중 메모 2026-06-20.
- `subprojects { check { dependsOn } }` 배선으로 `./gradlew check` 에 자동 편입 — 기존 4번째 verify 게이트로 합류.
- 의견/해석 후보:
- 보안 게이트를 도입할 때 *우회 경로*(suppression/ignore)를 같이 설계하지 않으면, 게이트는 도입 첫날부터 무력화될 수 있다. suppression governance 는 스캐너 도입의 후순위가 아니라 동시 작업이어야 한다.
## Outline seed
1. 문제: suppression 파일은 보안 게이트의 합법적 우회구 — 그러나 만료일·사유 없이 추가되면 영구 백도어 → 핵심 메시지: 우회구에도 통제가 필요하다.
2. Trivy `.trivyignore.yaml` 포맷과 `expired_at` 의 함정(누락=영구) → 핵심 메시지: 기본값이 "안전"의 반대.
3. 이중 통제 설계: CI field-gate(`verifyTrivyignore`) + merge-gate(CODEOWNERS)가 왜 둘 다 필요한가 → 핵심 메시지: "누가 바꾸나"와 "무엇이 갖춰졌나"는 다른 축.
4. 100줄 Gradle 게이트 구현 — line-based parser, 90일 창, 만료/창초과 검사, 6-케이스 검증 → 핵심 메시지: 가벼운 정적 게이트로 충분하다.
5. 한계와 정직한 등급: `locally-verified` vs CI 실증(`needs-confirmation`) → 핵심 메시지: 게이트가 도는 것과 운영에서 막는 것은 다르다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/dependency-vulnerability-suppression-gate.md` 후보:
- `verifyTrivyignore` 게이트 설계 + 이중 통제 + UNSUPPORTED_IMPL_DECISION(90일) 의 ca-tmpl 적용 사실.
- `wiki/concepts/security-gate-suppression-governance.md` 후보:
- "보안 게이트의 우회 경로도 거버넌스 대상" 이라는 일반 개념(스캐너 무관).
- 필요한 추가 검증:
- CI 러너에서 워크플로 + CODEOWNERS 실제 차단 실증, lockfile 커밋 후 Trivy fs 가 Gradle deps 를 실제로 스캔하는지.
## Sources / 근거 후보
- [[raw/branch-notes/feature-dependency-vulnerability-management-contract]] — D5, §구현가이드 §3, §진행 중 메모.
- [[raw/official-docs/trivy-filtering-suppression-policy]] — `expired_at`/`statement` 시맨틱.
- [[raw/interviews/trivy-suppression-dual-control-governance-2026-06-20]] — 같은 주제의 면접 질문.
## 미해결 / Unknown
- 아직 확인해야 할 사실: 만료된 suppression 이 release 직전 빌드를 깨뜨릴 때의 운영 흐름.
- 과장하면 안 되는 부분: 게이트는 `locally-verified` 다. "운영에서 취약점 우회를 막았다"는 아직 `needs-confirmation`.
- 블로그로 쓰기 전에 필요한 canonical 정제: ca-tmpl 적용 사실 → `wiki/projects/`, 일반 개념 → `wiki/concepts/` 분리.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/devops-ci-supply-chain-dx.md` 에 Trivy suppression governance static gate 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 review/verify가 필요하다. 운영에서 취약점 우회를 막았다고 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-dependency-vulnerability-management-contract]]
- 관련 interview prep: [[raw/interviews/trivy-suppression-dual-control-governance-2026-06-20]]
- derived blog: 생성 전. 생성 시 `wiki/blog/<slug>-2026-06-20.md` 후보
@@ -0,0 +1,81 @@
---
title: blog-topic / ulid-crockford-base32-excluded-letters-2026-06-01
source_type: blog-topic
status: raw
related_branches: [feature-resource-identifier-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, ulid, crockford-base32, identifier, validation]
created: 2026-06-01
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: ulid-crockford-base32-excluded-letters-2026-06-01
> Layer: `raw/blog-topics/` — 채용공고가 아닌 작업·학습·트러블슈팅에서 나온 블로그 글감 원석.
## Parent / 부모
- [[raw/branch-notes/feature-resource-identifier-contract]] — ULID 채택 + Crockford base32 charset(D2) 결정.
- [[raw/errors/ulid-fixture-crockford-u-self-inconsistency-2026-06-01]] — "ULID처럼 보이는" placeholder가 실제로는 invalid였던 사건.
## 트리거 / Trigger
- 트리거 유형: `branch-work` / `error`
- 트리거 날짜: 2026-06-01
- 트리거 연결 노트: [[raw/errors/ulid-fixture-crockford-u-self-inconsistency-2026-06-01]]
## 글감 / Topic seed
- 한 문장 요지: ULID는 Crockford base32를 쓰고, Crockford는 사람이 헷갈리는 **I, L, O, U를 의도적으로 제외**한다. 그래서 "대충 26자 영숫자"로 만든 예시 ULID는 빌드에서 터진다 — charset 결정과 예시 값은 *같은 파서로* 교차검증해야 한다.
- 떠오른 계기: spec의 D19 fixture `01HRGC7K2N4F6P8Q0R2S4T6U8V`가 23번째 'U' 때문에 자기 자신의 regex/charset을 위반.
- 예상 제목 후보:
- ULID 식별자에 왜 I/L/O/U가 없을까 (Crockford base32의 사람-친화 설계)
- "유효해 보이는" 식별자가 빌드를 깨뜨릴 때: 문서 예시 값을 단위테스트하라
- UUID dashed vs ULID Crockford: URL/로그/DB에서의 실전 차이
## 핵심 주장 후보 / Claim candidates
- Crockford base32 alphabet = `0123456789ABCDEFGHJKMNPQRSTVWXYZ` (I/L/O/U 제외, 32자) — case-insensitive 디코딩 시 `I/L→1`, `O→0` 정규화.
- ULID = 48bit ms timestamp + 80bit random, 26자, lexicographic 정렬 = 시간 정렬. URL-safe(RFC 3986 unreserved 진부분집합)라 percent-encoding 불필요.
- 문서에 박는 예시 식별자는 라이브러리 파서(`ulid-creator``Ulid.from`)로 1회 검증한 값만 써라 — 구현이 곧 spec의 단위테스트다.
- 외부 검증 가능한 값(ULID spec 공식 예제 `01ARZ3NDEKTSV4RRFFQ69G5FAV`)을 fixture로 쓰면 면접/포트폴리오에서 "왜 이 값?"에 정당성이 생긴다.
## Outline seed
1. "유효해 보이는 ID"와 실제 parser가 통과하는 ID는 다르다.
2. Crockford base32 alphabet과 ULID charset 경계를 설명한다.
3. 문서 예시 값도 production parser로 검증하는 contract fixture로 다룬다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/resource-identifier-format.md` 후보:
- ca-tmpl ULID resource identifier 결정과 fixture/example 검증 경계.
- `wiki/concepts/resource-identifier-format.md` 후보:
- ULID/Crockford base32 alphabet 일반 개념.
## Sources / 근거 후보
- [[raw/branch-notes/feature-resource-identifier-contract]] — ULID resource identifier 결정.
- [[raw/errors/ulid-fixture-crockford-u-self-inconsistency-2026-06-01]] — invalid fixture 사건.
## 미해결 / Unknown
- 아직 확인해야 할 사실: ULID official spec claim과 `ulid-creator` parser 동작을 concept 문서에서 어느 수준까지 분리할지.
- 과장하면 안 되는 부분: ULID가 UUID보다 항상 낫다고 쓰지 않는다.
- 블로그로 쓰기 전에 필요한 canonical 정제: 이미 `wiki/projects/ca-tmpl/resource-identifier-format.md`에 연결됨. concept 쪽 claim-backed 표현은 blogify 전 확인한다.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/resource-identifier-format.md` 에 ULID/Crockford base32 예시 검증 글감으로 반영했다.
- 다음 단계: source canonical이 `verified` 상태이므로 이후 `blogify` 대상으로 삼을 수 있다. 단 ULID가 UUID보다 항상 우월하다고 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-resource-identifier-contract]]
- 관련 error: [[raw/errors/ulid-fixture-crockford-u-self-inconsistency-2026-06-01]]
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/ulid-crockford-base32-excluded-letters-YYYY-MM-DD.md` 후보
@@ -0,0 +1,82 @@
---
title: blog-topic / w3c-traceparent-fork-activated-seam
source_type: blog-topic
status: raw
related_branches: [feature-distributed-tracing-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, observability, opentelemetry, trace-status, span-event]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: w3c-traceparent-fork-activated-seam
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-distributed-tracing-contract]] — OTel SDK 미배선 상태에서 W3C traceparent contract를 먼저 둔 결정에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-distributed-tracing-contract]]
## 글감 / Topic seed
- 한 문장 요지: OTel SDK를 붙이기 전에 W3C traceparent 계약을 먼저 두면 어떤 seam과 landmine이 생기는지 정리한다.
- 예상 제목 후보:
- OTel 없이 traceparent 계약부터 두면 생기는 일
- distributed tracing을 나중에 붙이기 위한 seam 설계
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- branch가 W3C traceparent 계약 타입, baggage allowlist, disabled fallback, fork-activated tracing seam, OTel SDK 미배선 경계를 정리한다 — 근거 후보: [[raw/branch-notes/feature-distributed-tracing-contract]] section+line `:106-112`, `:254-262`, `:319-322`.
- 경험 후보:
- 기존 async MDC 글감과 달리, 이 글감은 OTel SDK composition 전 sampled flag/meta traceId 불일치 같은 seam failure를 다룬다.
- 의견/해석 후보:
- tracing은 라이브러리를 붙이는 일이 아니라 trace context contract를 먼저 정하는 일일 수 있다.
## Outline seed
1. traceparent contract를 먼저 두는 이유 — propagation surface와 domain/application dependency를 분리한다.
2. disabled fallback의 의미 — tracing이 꺼져도 contract가 사라지지 않게 한다.
3. fork-activated seam의 landmine — OTel SDK가 들어올 때 sampled flag, baggage, meta traceId 정합을 다시 봐야 한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/observability-log-metric-trace-runbook.md` 후보:
- ca-tmpl tracing contract decision.
- `wiki/concepts/distributed-tracing-context-propagation.md` 후보:
- W3C trace context와 baggage propagation 일반 개념.
- 필요한 추가 검증:
- W3C Trace Context official claim, OTel SDK integration 전후 behavior.
## Sources / 근거 후보
- [[raw/branch-notes/feature-distributed-tracing-contract]] — tracing seam decision 근거.
- [[raw/blog-topics/spring-async-taskdecorator-bounded-executor-saturation-shutdown-2026-06-13]] — 인접한 async/MDC context topic.
## 미해결 / Unknown
- 아직 확인해야 할 사실: OTel SDK가 실제로 배선된 이후의 behavior.
- 과장하면 안 되는 부분: end-to-end distributed tracing 구현 완료처럼 쓰지 않는다. seam과 contract 중심으로 제한한다.
- 블로그로 쓰기 전에 필요한 canonical 정제: W3C/OTel raw source claim 연결.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/observability-log-metric-trace-runbook.md` 에 W3C traceparent seam 글감으로 반영했다.
- 다음 단계: source canonical이 `verified` 상태이므로 이후 `blogify` 대상으로 삼을 수 있다. 단 end-to-end distributed tracing 구현 완료처럼 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-distributed-tracing-contract]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/w3c-traceparent-fork-activated-seam-YYYY-MM-DD.md` 후보
@@ -0,0 +1,82 @@
---
title: blog-topic / webhook-full-jitter-dlq-observability
source_type: blog-topic
status: raw
related_branches: [feature-webhook-outbound-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, integration, observability, retry-policy, exponential-backoff, dead-letter-queue]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: webhook-full-jitter-dlq-observability
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-webhook-outbound-contract]] — webhook retry, DLQ, metric registry seed에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-webhook-outbound-contract]]
## 글감 / Topic seed
- 한 문장 요지: webhook retry를 Full Jitter, DLQ, metric contract로 묶을 때 retry storm과 관측 가능성을 어떻게 다룰지 정리한다.
- 예상 제목 후보:
- Webhook retry에 Full Jitter가 필요한 이유
- DLQ와 metric 없이 webhook retry를 켜면 생기는 문제
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- branch D3가 Full Jitter retry를 다루고 metric registry seed가 있다 — 근거 후보: [[raw/branch-notes/feature-webhook-outbound-contract]] line `:109`, `:158-170`, `:344-381`.
- 경험 후보:
- 검증 claim이 planned 중심이라 raw topic으로 캡처하되 canonical화 전 구현/테스트 상태를 분리해야 한다.
- 의견/해석 후보:
- retry policy는 backoff 계산식만이 아니라 DLQ, dedupe, metric cardinality와 함께 설계해야 한다.
## Outline seed
1. retry는 장애를 줄일 수도 키울 수도 있다 — synchronized retry와 retry storm을 피해야 한다.
2. Full Jitter의 역할 — delay 계산식과 upper bound를 project policy로 둔다.
3. DLQ와 metric contract — 실패를 재시도한 뒤 어디에 남기고 어떻게 관측할지 정한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound.md` 후보:
- ca-tmpl webhook retry/DLQ/observability project decision.
- `wiki/concepts/retry-policy.md` 후보:
- exponential backoff, jitter, DLQ 일반 개념.
- 필요한 추가 검증:
- Full Jitter formula source claim, DLQ implementation, metric names/cardinality.
## Sources / 근거 후보
- [[raw/branch-notes/feature-webhook-outbound-contract]] — D3 retry and metric seed 근거.
- [[raw/official-docs/aws-builders-retry-jitter]] — retry jitter 근거 후보.
## 미해결 / Unknown
- 아직 확인해야 할 사실: webhook retry implementation과 DLQ persistence 상태.
- 과장하면 안 되는 부분: planned metric/retry items를 구현 완료로 쓰지 않는다.
- 블로그로 쓰기 전에 필요한 canonical 정제: AWS jitter claim과 ca-tmpl project-local retry policy 분리.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound.md` 에 webhook retry/DLQ/observability 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 review/verify가 필요하다. planned metric/retry items를 구현 완료로 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-webhook-outbound-contract]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/webhook-full-jitter-dlq-observability-YYYY-MM-DD.md` 후보
@@ -0,0 +1,84 @@
---
title: blog-topic / webhook-signature-replay-contract
source_type: blog-topic
status: raw
related_branches: [feature-webhook-outbound-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, integration, security, api-contract, retry-policy, event-schema]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: webhook-signature-replay-contract
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-webhook-outbound-contract]] — HMAC signature와 replay protection contract 결정에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-webhook-outbound-contract]]
## 글감 / Topic seed
- 한 문장 요지: webhook signature에서 raw bytes, timestamp, message id를 계약으로 고정하고 replay window를 다루는 방법을 정리한다.
- 예상 제목 후보:
- Webhook HMAC signature에서 무엇을 서명해야 할까
- replay protection은 signature와 별개로 설계해야 한다
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- branch D1/D2가 signature/replay contract를 다룬다 — 근거 후보: [[raw/branch-notes/feature-webhook-outbound-contract]] line `:98-109`, `:147-157`.
- 경험 후보:
- header명과 Hex 인코딩은 project-local convention으로 두고, raw bytes/timestamp/message id 같은 핵심은 source-backed 결정과 분리해야 한다.
- 의견/해석 후보:
- HMAC signature는 payload integrity만 다루므로, replay 방지는 timestamp/window/message id 저장 정책과 함께 설계해야 한다.
## Outline seed
1. canonical string을 정하지 않으면 signature가 흔들린다 — raw bytes, timestamp, id를 어떤 순서로 묶을지 정한다.
2. signature와 replay는 다른 문제다 — 같은 요청을 다시 보내는 공격은 별도 state/window가 필요하다.
3. provider convention과 project convention 분리 — Stripe/Svix/GitHub 사례를 그대로 표준처럼 쓰지 않는다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound.md` 후보:
- ca-tmpl outbound webhook signature/replay contract.
- `wiki/concepts/webhook-signature.md` 후보:
- webhook HMAC signature와 replay protection 일반 개념.
- 필요한 추가 검증:
- raw body access, timestamp tolerance, message id dedupe storage.
## Sources / 근거 후보
- [[raw/branch-notes/feature-webhook-outbound-contract]] — D1/D2 signature/replay 근거.
- [[raw/official-docs/github-webhook-signature]] — provider signature 근거 후보.
- [[raw/official-docs/stripe-webhook-signature]] — provider signature 근거 후보.
- [[raw/official-docs/svix-webhook-best-practices]] — webhook best practice 근거 후보.
## 미해결 / Unknown
- 아직 확인해야 할 사실: ca-tmpl의 exact header names, encoding, replay store 구현 여부.
- 과장하면 안 되는 부분: provider 문서를 universal standard처럼 쓰지 않는다. 사례와 project convention을 분리한다.
- 블로그로 쓰기 전에 필요한 canonical 정제: signature concept 문서와 project decision 분리.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound.md` 에 webhook signature/replay 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 review/verify가 필요하다. provider 문서를 universal standard처럼 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-webhook-outbound-contract]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/webhook-signature-replay-contract-YYYY-MM-DD.md` 후보
@@ -0,0 +1,82 @@
---
title: blog-topic / webhook-ssrf-egress-proxy-redirect-block
source_type: blog-topic
status: raw
related_branches: [feature-webhook-outbound-contract]
related_projects: [ca-tmpl]
tags: [blog-topic, ca-tmpl, integration, security, networking, retry-policy, api-contract]
created: 2026-07-02
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: webhook-ssrf-egress-proxy-redirect-block
> Layer: `raw/blog-topics/` — 작업 중 나온 블로그 글감 원석. 최종 블로그는 canonical 정제 후 `wiki/blog/`에서 작성한다.
## Parent / 부모
- [[raw/branch-notes/feature-webhook-outbound-contract]] — webhook endpoint 등록의 SSRF/redirect defense 결정에서 나온 글감.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-07-02
- 트리거 연결 노트: [[raw/branch-notes/feature-webhook-outbound-contract]]
## 글감 / Topic seed
- 한 문장 요지: webhook endpoint 등록을 단순 URL 저장으로 보지 않고 egress proxy, redirect block, private range 차단 계약으로 다룬 이유를 정리한다.
- 예상 제목 후보:
- Webhook endpoint 등록은 SSRF 입력이다
- outbound webhook에서 redirect를 막아야 하는 이유
## 핵심 주장 후보 / Claim candidates
- 사실 후보:
- branch D4가 SSRF/redirect defense를 다루고, 구현 사양/failure path/audit finding이 연결되어 있다 — 근거 후보: [[raw/branch-notes/feature-webhook-outbound-contract]] line `:107-110`, `:116-125`, `:175-177`, `:207-210`.
- 경험 후보:
- 기존 webhook topic이 없어 SSRF defense를 독립 글감으로 캡처할 가치가 있다.
- 의견/해석 후보:
- webhook URL은 outbound 설정이 아니라 외부 사용자가 제공하는 네트워크 입력으로 다뤄야 한다.
## Outline seed
1. webhook URL은 신뢰할 수 없는 입력이다 — private IP, metadata endpoint, redirect chain을 생각해야 한다.
2. validation만으로 부족한 이유 — DNS rebinding과 redirect 때문에 egress layer 통제가 필요하다.
3. project-local convention과 source-backed defense 분리 — header/path/status 정책은 ca-tmpl 결정으로 표시한다.
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound.md` 후보:
- ca-tmpl outbound webhook SSRF defense decision.
- `wiki/concepts/ssrf-defense.md` 후보:
- SSRF defense와 outbound allow/deny policy 일반 개념.
- 필요한 추가 검증:
- egress proxy 구현 여부, redirect block test, private range deny test.
## Sources / 근거 후보
- [[raw/branch-notes/feature-webhook-outbound-contract]] — D4 SSRF/redirect defense 근거.
- [[raw/official-docs/owasp-ssrf-prevention]] — 공식 근거 후보가 이미 raw에 존재함.
## 미해결 / Unknown
- 아직 확인해야 할 사실: 현재 ca-tmpl 코드에서 구현/검증된 범위와 planned 범위.
- 과장하면 안 되는 부분: TODO/Claims To Verify가 planned 중심이므로 구현 완료처럼 쓰면 안 된다.
- 블로그로 쓰기 전에 필요한 canonical 정제: webhook project facts와 SSRF concept facts 분리.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound.md` 에 webhook SSRF/egress 글감으로 반영했다.
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 review/verify가 필요하다. planned 중심 항목을 구현 완료처럼 쓰지 않는다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-webhook-outbound-contract]]
- 관련 error:
- 관련 interview prep:
- derived blog: 생성 전. 생성 시 `wiki/blog/webhook-ssrf-egress-proxy-redirect-block-YYYY-MM-DD.md` 후보
@@ -0,0 +1,239 @@
---
title: API Error Envelope을 프로젝트 계약으로 고정하기
source_type: blog
status: verified
confidence: high
tags: [blog, ca-tmpl, api-design, error-handling]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
canonical_sources:
- wiki/projects/ca-tmpl/api-error-envelope-design
- wiki/concepts/api-error-envelope-design
audience: backend-engineer
target_publish:
status_label: ready
---
# API Error Envelope을 프로젝트 계약으로 고정하기
## Parent / 부모 (필수)
- Canonical source: [[wiki/projects/ca-tmpl/api-error-envelope-design]]
- Supporting concept: [[wiki/concepts/api-error-envelope-design]]
## 타깃 독자 / Target reader
- Spring Boot 기반 REST API에서 error response contract를 정해야 하는 백엔드 엔지니어.
- 이미 HTTP status, Spring MVC exception handling, validation error mapping의 기본은 알고 있다고 가정한다.
- 이 글에서 처음 보게 될 포인트: `ProblemDetail`을 거부하는 이유가 "표준이 싫어서"가 아니라, 프로젝트가 요구한 success/error 대칭 envelope과 운영 메타데이터 계약 때문이라는 점.
## 도입 / Hook
API 실패 응답은 보통 나중에 정리하려고 미루기 쉽다. 그런데 validation, security, transport failure, business rule violation이 각자 다른 JSON shape을 반환하기 시작하면 client는 실패 원인을 안정적으로 분기할 수 없고, 운영자는 응답과 로그/트레이스를 한 번에 이어 보기 어렵다.
ca-tmpl에서는 Spring 6+의 `ProblemDetail`을 그대로 쓰지 않고, `{ success, data, error, meta }` 형태의 custom envelope을 프로젝트 계약으로 고정했다. 이 글은 그 결정이 어떤 요구에서 나왔고, 어디까지 코드로 구현되고 로컬 검증됐으며, 아직 구현됐다고 말하면 안 되는 부분이 무엇인지 정리한다.
## 본문 outline / Body outline
1. 실패 응답 shape이 흩어질 때 생기는 문제
- validation, security, transport failure가 서로 다른 응답 구조를 만들면 client 분기와 테스트가 어려워진다.
- ca-tmpl의 목표는 모든 실패를 같은 원인으로 섞는 것이 아니라, 같은 envelope 안에서 status/code/category 의미를 보존하는 것이다.
2.`ProblemDetail`을 그대로 쓰지 않았나
- `ProblemDetail`은 실패 전용 평면 shape이다.
- ca-tmpl은 성공과 실패를 같은 top-level envelope으로 감싸고, `error.code`, `error.category`, `error.retryable`, `error.details`, `meta`를 1급 계약으로 두고 싶었다.
- 따라서 표준 위에 다시 custom 확장층을 얹기보다 프로젝트 전용 envelope을 명시적으로 선택했다.
3. ca-tmpl envelope의 핵심 필드
- `success`: client의 1차 분기 기준.
- `data`: 성공 응답 payload.
- `error.code`: machine-readable error identifier.
- `error.category`: 운영 분류.
- `error.retryable`: client retry 판단의 최소 힌트.
- `error.details`: validation field error 같은 항목별 오류.
- `meta`: `requestId`, `traceId`, `correlationId`로 응답과 로그/트레이스를 연결하는 영역.
4. 구현으로 고정한 계약
- `Envelope`, `ApiError`, `ResponseMeta`, `Category`, `OperationalError`, `ApiErrorCode`.
- `GlobalExceptionHandler`, `ErrorResponseFactory`, `EnvelopeBodyAdvice`.
- `ProblemDetail` import 금지 ArchUnit rule.
- `spring.mvc.problemdetails.enabled: false` pin과 config regression test.
5. transport failure까지 같은 shape으로 태우기
- 413, 406, 415, 405(+`Allow`), 412는 envelope shape으로 반환되도록 테스트됐다.
- Spring MVC `ResponseEntityExceptionHandler`가 이미 다루는 umbrella exception은 중복 `@ExceptionHandler`가 아니라 protected override로 다룬다.
- 이 범위는 검증된 transport row에 한정한다.
6. 아직 말하면 안 되는 부분
- 운영 배포와 prod metric 검증은 없다.
- `Retry-After` header 발행은 planned/stub이다.
- 5xx span ERROR 기록도 planned/stub이다.
- business rule violation의 세부 category/details mapping은 별도 owner branch 책임이다.
## 본문 / Body
API error response는 처음에는 작아 보입니다. 실패하면 적당한 HTTP status와 message만 내려주면 될 것처럼 보입니다. 그런데 프로젝트가 커지면 이야기가 달라집니다. validation 실패는 field 목록을 내려주고, 인증 실패는 Spring Security가 다른 shape을 만들고, 잘못된 `Content-Type`이나 큰 request body는 Spring MVC transport layer에서 또 다른 응답을 만들 수 있습니다.
이 상태가 오래가면 client 입장에서는 "실패했다"는 사실보다 "이번 실패는 어떤 모양으로 오지?"를 먼저 걱정해야 합니다. 운영하는 사람 입장에서도 비슷합니다. 응답에 trace id가 있는지, 재시도해도 되는 오류인지, validation 문제인지 인증 문제인지가 매번 다른 위치에 있으면 로그와 응답을 이어서 보기 어렵습니다.
ca-tmpl에서 API Error Envelope을 먼저 계약으로 잡은 이유는 여기에 있습니다. 목표는 모든 실패를 똑같은 원인으로 뭉개는 것이 아니었습니다. HTTP status와 error code의 의미는 유지하되, client가 읽는 바깥 구조를 하나로 맞추는 것이었습니다.
쉽게 말하면 실패 응답에도 "봉투"를 하나 씌운 셈입니다. 봉투 바깥에는 `success`, `data`, `error`, `meta`가 있고, 실패일 때는 `success=false`, `data=null`, `error`에 실제 오류 정보가 들어갑니다. `meta`에는 요청과 로그, trace를 이어 볼 수 있는 id들이 들어갑니다.
```json
{
"success": false,
"data": null,
"error": {
"code": "VALIDATION_FAILED",
"category": "VALIDATION",
"message": "Request body failed validation",
"retryable": false,
"details": []
},
"meta": {
"requestId": "...",
"traceId": "...",
"correlationId": "..."
}
}
```
여기서 중요한 점은 이 구조가 단순히 보기 좋은 JSON이 아니라는 것입니다. `error.code`는 client가 분기할 수 있는 machine-readable identifier입니다. `message`는 사람이 읽는 문장이므로 client 로직이 여기에 의존하면 안 됩니다. `error.category`는 운영 분류입니다. validation 문제인지, auth 문제인지, dependency 문제인지 같은 큰 묶음을 나타냅니다. `retryable`은 client가 재시도를 검토할 수 있게 해 주는 최소 힌트입니다. `details`는 validation field error처럼 항목별 정보가 필요한 경우에만 채웁니다.
그럼 Spring 6+에서 제공하는 `ProblemDetail`을 쓰면 되지 않을까요? 이 질문이 자연스럽습니다. `ProblemDetail`은 RFC 7807 계열의 실패 응답 모델이고, Spring에서도 기본 지원합니다. 하지만 ca-tmpl의 요구와는 결이 달랐습니다.
`ProblemDetail`은 실패 전용 평면 shape입니다. 반면 ca-tmpl은 성공과 실패를 모두 같은 top-level envelope으로 감싸고 싶었습니다. 성공 응답도 `success=true`, 실패 응답도 `success=false`로 읽히게 만들고 싶었던 것입니다. 또한 ca-tmpl은 `code`, `category`, `retryable`, `meta`를 프로젝트 계약의 1급 필드로 두고 싶었습니다. `ProblemDetail` 위에 확장 필드를 계속 얹으면 결국 표준을 쓰는 척하면서 실제로는 custom envelope을 하나 더 만든 셈이 됩니다.
그래서 ca-tmpl의 선택은 "ProblemDetail이 나쁜 설계라서 버린다"가 아니었습니다. 실패 전용 표준 모델보다, 이 skeleton이 원하는 success/error 대칭 구조와 운영 메타데이터가 더 중요했기 때문에 custom envelope을 명시적으로 선택한 것입니다.
이 결정은 문서에만 남아 있지 않습니다. `shared-contract` 모듈에는 `Envelope`, `ApiError`, `ResponseMeta`가 있고, web adapter에는 예외를 envelope으로 바꾸는 `GlobalExceptionHandler``ErrorResponseFactory`가 있습니다. 즉 "우리 프로젝트는 이런 실패 응답을 쓴다"가 README 문장에 머문 것이 아니라, 컴파일되는 타입과 테스트 가능한 경로로 내려왔습니다.
`Envelope`는 성공과 실패가 같은 바깥 구조를 공유한다는 결정을 담습니다. 성공이면 `data`가 있고 `error`가 없습니다. 실패이면 `error`가 있고 `data`가 없습니다. `ApiError`는 실패 안쪽의 구조를 고정합니다. 특히 `category``retryable`을 field로 올려 둔 점이 중요합니다. 이 둘을 message 안에 섞어 두면 client와 운영 도구가 안정적으로 읽기 어렵습니다.
`ErrorResponseFactory`는 이 결정을 Spring MVC 응답으로 바꾸는 관문입니다. `ApiErrorCode`가 가진 HTTP status, code, category, retryable 값을 읽고 `Envelope.failure(...)`를 만들어 냅니다. 이 관문이 있으면 handler마다 JSON을 직접 조립하지 않아도 됩니다. 실패 응답을 만드는 길을 하나로 좁혀 두는 효과가 있습니다.
또 하나 중요한 장치는 `ProblemDetail`을 다시 들여오지 못하게 막는 것입니다. ca-tmpl은 `spring.mvc.problemdetails.enabled=false`를 application.yml에 명시하고, 그 값이 유지되는지 테스트합니다. 여기에 더해 ArchUnit rule로 production code가 `org.springframework.http.ProblemDetail`에 의존하지 못하게 막습니다. 이것은 "개발자가 조심하자" 수준의 약속이 아니라, build가 깨지는 계약입니다.
transport failure를 같은 envelope에 태운 것도 이 글의 핵심입니다. 예를 들어 request body가 너무 크면 413, 지원하지 않는 `Content-Type`이면 415, 지원하지 않는 HTTP method면 405가 됩니다. 이때 status의 의미는 그대로 보존해야 합니다. ca-tmpl은 이런 실패들을 모두 `VALIDATION_FAILED` 같은 하나의 오류로 뭉개지 않고, `PAYLOAD_TOO_LARGE`, `UNSUPPORTED_MEDIA_TYPE`, `METHOD_NOT_ALLOWED`처럼 구분된 code/status로 envelope에 담습니다.
특히 Spring MVC의 `ResponseEntityExceptionHandler`가 이미 처리하는 계열은 주의가 필요합니다. 같은 예외를 `@ExceptionHandler`로 다시 등록하면 framework가 가진 처리 흐름과 충돌할 수 있습니다. ca-tmpl은 이런 경우 protected override를 사용해서 Spring MVC의 흐름 위에서 body만 envelope shape으로 바꿉니다. 예를 들어 405에서는 `Allow` header도 함께 보존합니다. 실패 응답의 바깥 shape은 통일하지만, HTTP가 가진 의미까지 지워 버리지는 않는다는 뜻입니다.
다만 이 글에서 말할 수 있는 범위는 분명히 제한해야 합니다. 현재 근거는 코드 구현과 로컬 검증입니다. canonical 문서 기준으로 `./gradlew check`가 통과했고, 413/406/415/405(+`Allow`)/412 같은 transport failure row가 테스트됐다고 말할 수 있습니다. 하지만 운영 배포에서 검증했다거나, 실제 production metric으로 개선을 확인했다고 말할 수는 없습니다.
아직 planned/stub으로 남은 것도 있습니다. `Retry-After` header 발행은 rate-limit owner branch의 책임으로 남아 있습니다. 5xx span ERROR 기록도 tracer-neutral seam은 있지만, 이 글에서 운영 추적이 완성됐다고 말하면 안 됩니다. business rule violation을 어떤 `category``details`로 세분화할지도 foundation이 아니라 별도 owner branch의 범위입니다.
정리하면 ca-tmpl의 API Error Envelope은 "표준을 몰라서 만든 custom JSON"이 아닙니다. `ProblemDetail`, JSON:API errors, Google `rpc.Status` 같은 선택지를 비교한 뒤, 이 skeleton이 더 중요하게 본 요구를 코드 계약으로 고정한 결과입니다. 그 요구는 성공/실패 응답의 대칭성, client가 읽을 수 있는 안정적인 error code, 운영자가 볼 수 있는 category와 meta, 그리고 exception leak을 막는 일관된 실패 응답 경로였습니다.
좋은 error response 설계는 예쁜 JSON을 만드는 일이 아니라, 실패를 다루는 책임을 어디에 둘지 정하는 일에 가깝습니다. ca-tmpl의 선택은 그 책임을 프로젝트 초기에 명시하고, 테스트와 ArchUnit rule로 회귀하지 않게 붙잡아 둔 사례입니다.
## 코드 예제 / Code samples (있다면)
```java
// 출처: [[wiki/projects/ca-tmpl/api-error-envelope-design]]
// 실제 파일: shared-contract/src/main/java/dev/caskeleton/shared/response/Envelope.java
public record Envelope<T>(boolean success, T data, ApiError error, ResponseMeta meta) {
public static <T> Envelope<T> ok(T data, ResponseMeta meta) {
return new Envelope<>(true, data, null, meta);
}
public static <T> Envelope<T> failure(ApiError error, ResponseMeta meta) {
return new Envelope<>(false, null, error, meta);
}
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/api-error-envelope-design]]
// 실제 파일: shared-contract/src/main/java/dev/caskeleton/shared/response/ApiError.java
public record ApiError(
String code, String category, String message, boolean retryable, Object details) {
public static ApiError of(String code, String category, String message, boolean retryable) {
return new ApiError(code, category, message, retryable, null);
}
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/api-error-envelope-design]]
// 실제 파일: adapter-web/src/main/java/dev/caskeleton/adapter/web/error/ErrorResponseFactory.java
public static Envelope<Void> body(ApiErrorCode code, String message, Object details) {
ApiError err =
details == null
? ApiError.of(code.code(), code.category().name(), message, code.retryable())
: ApiError.withDetails(
code.code(), code.category().name(), message, code.retryable(), details);
return Envelope.failure(err, ResponseMetaFactory.fromMdc());
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/api-error-envelope-design]]
// 실제 파일: app-bootstrap/src/test/java/.../CleanArchitectureTest.java
@ArchTest
static final ArchRule NO_PROBLEM_DETAIL_USAGE =
noClasses()
.that()
.resideInAPackage("dev.caskeleton..")
.should()
.dependOnClassesThat()
.haveFullyQualifiedName("org.springframework.http.ProblemDetail");
```
```yaml
# 출처: [[wiki/projects/ca-tmpl/api-error-envelope-design]]
# 실제 파일: app-bootstrap/src/main/resources/application.yml
spring:
mvc:
problemdetails:
enabled: false
```
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
- [[wiki/projects/ca-tmpl/api-error-envelope-design]] - 이 글의 1차 canonical. `verified`, `actually-implemented`, `locally-verified` 범위와 과장 금지 항목을 따른다.
- [[wiki/concepts/api-error-envelope-design]] - `ProblemDetail`, Google `rpc.Status`, JSON:API errors, GraphQL errors, custom envelope trade-off를 정리한 개념 canonical.
- [[raw/project-notes/ca-skeleton-operational-contract]] - Structured API Response Contract, Exception Ownership Contract, Operational Error Category, Topic 4 envelope 대안 검토.
- [[raw/branch-notes/feature-operational-error-observability-foundation]] - envelope schema SSOT와 exception leak 금지 catalog.
- [[raw/branch-notes/feature-api-contract-baseline]] - 413/406/415/405(+`Allow`)/412 transport failure envelope mapping 검증.
- [[raw/blog-topics/spring-responseentityexceptionhandler-transport-failure-envelope-2026-07-02]] - Spring MVC transport failure envelope 글감.
- [[raw/blog-topics/operational-error-envelope-meta-category-migration-2026-06-01]] - `error.category``meta` migration 글감.
- [[raw/blog-topics/spring-security-filter-layer-error-envelope-2026-06-08]] - Spring Security filter-layer envelope 후속 글감.
## 사실 vs 의견 / Fact vs opinion 구분
- 사실: ca-tmpl에는 `Envelope`, `ApiError`, `ResponseMeta`, `Category`, `OperationalError`, `GlobalExceptionHandler`, `ErrorResponseFactory`, `EnvelopeBodyAdvice`가 코드로 존재한다.
- 사실: `ProblemDetail`은 ArchUnit rule과 `spring.mvc.problemdetails.enabled: false` 설정으로 금지/비활성화되어 있다.
- 사실: `./gradlew check`가 2026-06-01에 통과했고, 413/406/415/405(+`Allow`)/412 transport failure mapping이 테스트로 검증됐다.
- 사실: 운영 배포와 prod 검증은 없다.
- 의견: ca-tmpl의 요구 조합에서는 `ProblemDetail` 위에 확장을 쌓는 것보다 custom envelope을 명시적으로 고정하는 편이 더 설명 가능하다.
- 의견: `retryable``category`를 1급 필드로 두면 client/retry/observability 설계가 단순해진다. 다만 `RetryInfo.retry_delay` 같은 더 구체적인 표준 정보를 잃는 trade-off가 있다.
## 답할 수 있는 범위 / Answer boundary
- 자신 있게 답할 수 있음: ca-tmpl이 왜 `ProblemDetail`을 채택하지 않았는지.
- 자신 있게 답할 수 있음: envelope field shape과 각 필드의 책임.
- 자신 있게 답할 수 있음: 어떤 클래스와 테스트로 계약을 고정했는지.
- 자신 있게 답할 수 있음: 어떤 transport failure row가 envelope으로 검증됐는지.
- 제한해서 답해야 함: 운영에서의 동작. 현재는 local/dev verification까지만 말한다.
- 제한해서 답해야 함: `Retry-After`, 5xx span ERROR, business rule details mapping. 현재 문서 기준으로는 planned/stub 또는 별도 owner branch 범위다.
## 게시 체크리스트 / Publish checklist
- [x] 원천 canonical이 `reviewed | verified | published-ready` 상태인지 확인
- [x] derived 문서가 raw를 1차 근거처럼 사용하지 않는지 확인
- [x] 코드 발췌가 실제 ca-tmpl 코드와 일치하는지 확인
- [x] `actually-implemented`, `locally-verified`, `prod-verified` 범위를 분리했는지 확인
- [x] 금지 마케팅 표현을 쓰지 않았는지 확인
- [x] `canonical_sources`를 실제 인용 canonical로 채웠는지 확인
- [x] 본문 작성 후 `status_label``ready`로 갱신했는지 확인
## Related / 관련
- 관련 project 문서: [[wiki/projects/ca-tmpl/api-error-envelope-design]]
- 관련 concept 문서: [[wiki/concepts/api-error-envelope-design]]
- 후속 글 후보: [[raw/blog-topics/spring-responseentityexceptionhandler-transport-failure-envelope-2026-07-02]]
- 후속 글 후보: [[raw/blog-topics/spring-security-filter-layer-error-envelope-2026-06-08]]
- 후속 글 후보: [[raw/blog-topics/operational-error-envelope-meta-category-migration-2026-06-01]]
@@ -0,0 +1,257 @@
---
title: API Evolution은 버전 번호가 아니라 계약의 문제다
source_type: blog
status: verified
confidence: high
tags: [blog, ca-tmpl, api-design, versioning, schema]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
canonical_sources:
- wiki/projects/ca-tmpl/api-evolution-and-schema
- wiki/concepts/api-evolution-and-schema
audience: backend-engineer
target_publish:
status_label: ready
---
# API Evolution은 버전 번호가 아니라 계약의 문제다
> Layer: `wiki/blog/` — **외부 공개용 블로그 글 초안·완성본**. canonical (`wiki/concepts/` + `wiki/projects/`) 에서 파생된 산출물.
> 상태: draft → reviewed → verified → **published-ready** (외부 게시 가능)
> `status_label`: `outline` | `drafting` | `review` | `ready` | `published` | `retired`
> `audience`: `backend-engineer` | `senior-engineer` | `tech-lead` | `general` — 깊이·전문용어 사용량이 달라짐.
## Parent / 부모 (필수)
> wiki/blog/ 는 derived layer. **반드시 canonical wiki/concepts/ 또는 wiki/projects/ 에서 파생.** raw 또는 branch에서 직접 파생 금지.
- 핵심 canonical:
- [[wiki/projects/ca-tmpl/api-evolution-and-schema]] — ca-tmpl API contract baseline, compatibility/deprecation, schema/serialization 결정과 검증 범위.
- [[wiki/concepts/api-evolution-and-schema]] — API versioning, deprecation, schema compatibility, serialization policy 일반 개념.
- 영감 출처:
- [[raw/blog-topics/api-deprecation-sunset-header-migration-window-2026-07-02]] — Sunset/Deprecation header와 migration window 글감.
- [[raw/blog-topics/spring-boot-serialization-contract-pins-2026-07-02]] — Jackson serialization pin과 BigDecimal constructor guard 글감.
## 타깃 독자 / Target reader
> 이 글을 누가 읽을 것인지. 톤·전문용어·깊이가 결정됨.
- 독자 profile: Spring Boot 기반 REST API를 만들면서 versioning, pagination, deprecation, serialization contract를 어디까지 정해야 하는지 고민하는 백엔드 엔지니어.
- 독자가 이미 알고 있을 것이라 가정하는 것: HTTP status, REST endpoint, OpenAPI, Jackson, Spring MVC의 기본 역할.
- 독자가 처음 듣는다고 가정하는 것: API evolution을 단순히 `/v1` prefix가 아니라 migration window, compatibility catalog, conditional request, serialization pin까지 포함하는 계약으로 보는 관점.
## 도입 / Hook
> 왜 이 글을 쓰는가. 독자에게 이 글의 가치를 1~2문장으로.
- 문제 / 궁금증: API versioning을 `/v1`만 붙이면 끝난다고 생각하기 쉽지만, 실제로는 pagination cap, ETag, cache header, deprecation signal, serialization default drift까지 모두 contract surface가 된다.
- 이 글이 답하는 것: ca-tmpl이 API evolution을 어떤 하위 계약으로 쪼갰고, 그중 무엇은 코드로 구현·로컬 검증됐으며, 무엇은 아직 documented-only인지 구분한다.
- 이 글이 답하지 않는 것 (스코프): 실제 외부 client migration 운영 경험, production cutover, 410 응답 전환 실측, Avro Schema Registry 운영 경험.
## 본문 outline / Body outline
> 글의 흐름. 초안 단계에서는 outline 만, drafting 단계부터 본문.
1. API evolution은 `/v1` prefix 하나가 아니다 — versioning, pagination, cache, conditional request, OpenAPI, batch/LRO, serialization policy까지 surface로 본다.
2. ca-tmpl에서 실제 구현된 contract baseline — `/v1`, pagination/sort, ETag/If-Match/304/412, `no-store`/`Vary`, OpenAPI producer, LRO, batch endpoint.
3. compatibility/deprecation은 아직 문서 계약이다 — 90d/30d window, 7행 breaking change catalog, `Sunset` + `Deprecation`, OpenAPI `deprecated: true`는 구현됐다고 말하지 않는다.
4. serialization은 출력측만 로컬 검증됐다 — datetime/BigDecimal pin, effective `ObjectMapper` test, `new BigDecimal(double/float)` ArchUnit ban.
5. 표준과 project-local trade-off를 분리하기 — RFC 8594, RFC 9110, RFC 3339, OpenAPI 같은 source-backed 사실과 90d/30d·size cap 100·weak ETag 같은 project decision을 구분한다.
6. 블로그에서 과장하면 안 되는 경계 — 운영 deprecation 경험, strong ETag, idempotency replay, OpenAPI drift release gate, money string serialization은 아직 말하면 안 된다.
## 본문 / Body
API evolution을 처음 생각할 때 가장 먼저 떠오르는 것은 보통 version number입니다. `/v1`을 붙일지, header로 받을지, 날짜 기반으로 갈지 같은 질문입니다. 그런데 실제로 API가 오래 살아남으려면 version number만으로는 부족합니다.
API는 한 번 배포되면 client와 약속이 됩니다. 응답 field를 없애는 일, enum 값을 줄이는 일, pagination limit을 바꾸는 일, datetime을 숫자로 보내던 것을 문자열로 바꾸는 일도 모두 client에게는 변화입니다. 그래서 API evolution은 "버전을 어떻게 붙일까"보다 넓은 문제입니다. 더 정확히는 API surface 전체가 어떻게 변할 수 있고, 그 변화가 client를 언제 깨뜨리는지 정하는 계약입니다.
ca-tmpl의 API Evolution & Schema 문서는 이 문제를 세 갈래로 나눕니다. 첫 번째는 실제 HTTP API의 기본 계약입니다. `/v1` prefix, pagination/sort, ETag와 conditional request, cache header, OpenAPI producer, long-running operation, batch endpoint 같은 것들입니다. 두 번째는 compatibility와 deprecation입니다. 어떤 변경을 breaking으로 볼지, deprecated API를 얼마나 오래 살릴지, `Sunset``Deprecation` header를 어떻게 보낼지에 대한 정책입니다. 세 번째는 schema와 serialization입니다. 날짜와 decimal이 어떤 JSON 모양으로 나가야 하는지, Jackson default가 바뀌어도 계약이 흔들리지 않게 어떻게 고정할지에 대한 문제입니다.
중요한 점은 이 세 갈래의 검증 수준이 서로 다르다는 것입니다. ca-tmpl에서 API contract baseline은 상당 부분 코드로 구현되고 로컬 테스트로 검증됐습니다. 반면 compatibility/deprecation 정책은 아직 문서 계약입니다. serialization은 출력측 일부가 구현·검증됐지만, 모든 schema evolution 도구가 구현된 것은 아닙니다. 이 구분을 흐리면 블로그 글은 읽기 좋아져도 사실 경계가 무너집니다.
먼저 구현된 API contract baseline부터 보겠습니다. ca-tmpl은 public endpoint에 `/v1` prefix를 사용합니다. 이것은 단지 URL을 예쁘게 만드는 선택이 아니라, API major version을 route surface에 드러내는 결정입니다. `PresentationSettings``ca-skeleton.presentation.api-base-path`를 읽고, 값이 빠졌거나 `/`로 시작하지 않을 때 보정합니다. canonical 문서 기준으로 운영 default는 `/v1`이고, `VersioningPrefixTest``/v1/probe`는 열리고 `/probe`는 열리지 않는다는 것을 검증합니다.
pagination도 계약입니다. client가 `size=100000`을 던질 수 있게 두면 서버 resource를 쉽게 압박할 수 있습니다. ca-tmpl의 `PageParams`는 기본 size를 20으로 두고, 1 이상 100 이하만 허용합니다. `page`는 0-indexed이며, deep offset은 `page > 10000`일 때 표시합니다. 여기서 숫자 100과 10000은 표준이 정한 값이 아닙니다. DoS 방어와 cursor pagination 유도라는 project-local trade-off입니다. 따라서 글에서는 "표준이라서 100"이라고 말하면 안 되고, "ca-tmpl이 skeleton 기본값으로 선택한 제한"이라고 말해야 합니다.
conditional request도 흥미로운 부분입니다. conditional request는 client가 "내가 알고 있는 이전 상태와 같을 때만 처리해 달라" 또는 "내가 가진 버전과 같으면 body를 다시 보내지 않아도 된다"고 말하는 HTTP 메커니즘입니다. ca-tmpl은 entity의 optimistic lock version에서 `W/"<version>"` 형태의 ETag를 만들고, read에서는 `If-None-Match`로 304를, write에서는 `If-Match` mismatch로 412를 냅니다. 이 흐름은 `ETags``PreconditionFailedException`, controller wire test로 검증됩니다.
다만 여기에도 경계가 있습니다. ca-tmpl의 ETag 비교는 RFC 9110의 strict한 strong comparison 구현이 아닙니다. `ETags.matches``W/` marker와 따옴표를 벗겨 opaque value를 비교하는 lenient 구현입니다. skeleton에서 이해하기 쉬운 optimistic lock bridge를 택한 것이지, production-grade strong ETag semantics를 모두 구현했다고 말하면 안 됩니다.
cache policy는 더 보수적입니다. 인증된 API에서 cache default를 열어 두면 proxy나 browser cache가 민감한 응답을 붙잡을 수 있습니다. ca-tmpl의 `CacheControlFilter`는 모든 응답에 `Cache-Control: no-store``Vary: Accept, Accept-Encoding, Authorization`을 먼저 박습니다. cacheable endpoint가 필요하면 명시적으로 opt-in해야 합니다. 기본값을 닫고 예외를 열게 만든 셈입니다.
OpenAPI producer, long-running operation, batch endpoint도 baseline에 들어갑니다. OpenAPI는 `/v3/api-docs`가 열리는지 확인하는 producer 수준까지 구현됐습니다. long-running operation은 `POST /worklogs:export`가 202 Accepted와 `Location` header, polling URL을 돌려주는 sample fixture로 구현됐습니다. batch endpoint는 `POST /worklogs:batchCreate`에서 단일 transaction atomic 처리와 size cap을 검증합니다. 여기까지는 "코드로 구현했고 로컬 검증했다"고 말할 수 있는 범위입니다.
반대로 compatibility/deprecation은 조심해야 합니다. ca-tmpl은 90일 public, 30일 internal migration window를 문서 계약으로 정했습니다. 응답 field 제거, 응답 field 의미 변화, required request field 추가, enum value 제거, enum value 의미 변화, narrow enum, 기본값 변경을 breaking change catalog로 분류했습니다. 또한 `Sunset` header와 `Deprecation` header를 함께 보내기로 결정했습니다.
하지만 이것들은 아직 response interceptor나 release gate로 구현된 것이 아닙니다. 실제 API를 deprecated 상태로 운영해 본 것도 아니고, 외부 client가 90일 안에 migration을 끝냈는지 검증한 경험도 없습니다. 따라서 이 부분은 "설계했다", "문서 계약으로 잡았다", "표준과 사례를 비교해 이런 정책을 택했다"까지만 말해야 합니다. "운영에서 검증했다"는 표현은 쓰면 안 됩니다.
`Sunset``Deprecation`의 차이는 글에서 꼭 풀어야 합니다. `Sunset`은 언제 사라질지를 알려주는 날짜 신호입니다. `Deprecation`은 지금 이미 deprecated 상태인지를 알려주는 신호입니다. 하나만 보내면 정보가 반쪽이 됩니다. ca-tmpl은 그래서 둘을 함께 보내기로 결정했습니다. 여기에 `Link rel="deprecation"`이나 `Link rel="sunset"`을 붙여 사람이 읽을 migration guide로 연결하는 방향도 문서에 잡혀 있습니다. 다시 말하지만, 현재는 결정과 설계이지 구현은 아닙니다.
schema/serialization 축은 조금 다릅니다. 여기서는 출력측 일부가 실제로 구현됐습니다. ca-tmpl은 Jackson 설정에서 `WRITE_DATES_AS_TIMESTAMPS=false`를 명시해 `OffsetDateTime``LocalDate`가 숫자나 배열이 아니라 ISO-8601 문자열로 나가도록 고정합니다. `WRITE_BIGDECIMAL_AS_PLAIN=true`도 명시해 큰 `BigDecimal`이 scientific notation으로 나가지 않게 합니다.
흥미로운 점은 이 설정들이 현재 Spring Boot 기본값과 크게 어긋나지 않는다는 것입니다. 그런데도 ca-tmpl은 명시적으로 pin을 둡니다. 이유는 default에 기대면 future default drift를 잡기 어렵기 때문입니다. 그래서 `JacksonSerializationPolicyTest`는 설정 binding만 보는 것이 아니라 실제 wired `ObjectMapper``OffsetDateTime`, `LocalDate`, `BigDecimal`을 직렬화해 봅니다. `JavaTimeModule`이 빠져서 날짜가 배열로 나가는 회귀도 이 테스트가 잡을 수 있습니다.
`BigDecimal`은 정적 차단까지 들어갑니다. Java에서 `new BigDecimal(0.1)`은 사람이 기대하는 0.1이 아니라 binary floating-point 오차를 품은 값을 만들 수 있습니다. ca-tmpl은 production code에서 `new BigDecimal(double)``new BigDecimal(float)` 생성자를 호출하지 못하도록 ArchUnit rule을 둡니다. 이건 "조심하자"가 아니라 build에서 깨지는 계약입니다.
하지만 serialization도 모든 것이 끝난 것은 아닙니다. 입력측 strict deserialization, null/empty/missing 3-state 처리, per-API money string-vs-number 선택, OpenAPI drift release gate, 제거 field 재사용 방지 도구, Avro compatibility 자동검사는 각각 다른 owner나 planned 범위에 있습니다. 특히 sample domain에 money field가 없기 때문에 `@JsonSerialize(ToStringSerializer)` 같은 money string serialization 코드 시연은 없습니다.
이 글의 핵심은 API evolution을 넓게 보되, 구현 등급을 섞지 않는 데 있습니다. `/v1`, pagination, ETag, cache header, OpenAPI producer, LRO, batch endpoint는 로컬 검증된 구현으로 말할 수 있습니다. deprecation policy는 문서 계약으로 말해야 합니다. serialization output pin과 BigDecimal guard는 로컬 검증으로 말할 수 있습니다. strong ETag, idempotency replay, OpenAPI release gate, production deprecation 운영은 아직 말하면 안 됩니다.
좋은 skeleton은 단지 "예제 endpoint가 동작한다"에서 끝나지 않습니다. 나중에 API가 변할 때 어떤 변화가 안전하고, 어떤 변화가 client를 깨뜨리며, 어떤 값은 default에 기대지 않고 명시적으로 고정해야 하는지까지 알려 줘야 합니다. ca-tmpl의 API Evolution & Schema 결정은 그 방향을 잡은 문서입니다. 일부는 이미 코드와 테스트로 내려왔고, 일부는 앞으로 구현해야 할 계약으로 남아 있습니다. 이 둘을 구분해서 설명할 수 있을 때, 비로소 이 주제를 내 프로젝트 경험으로 말할 수 있습니다.
## 코드 예제 / Code samples (있다면)
> 가능한 실제 프로젝트 코드 인용. 가짜 예제 금지. 추출 시 출처 PR·커밋 명시.
```java
// 출처: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
// 실제 파일: adapter-web/src/main/java/dev/caskeleton/adapter/web/settings/PresentationSettings.java
@ConfigurationProperties(prefix = "ca-skeleton.presentation")
public record PresentationSettings(String apiBasePath) {
public PresentationSettings {
if (apiBasePath == null) {
apiBasePath = "";
} else if (!apiBasePath.isEmpty() && !apiBasePath.startsWith("/")) {
apiBasePath = "/" + apiBasePath;
}
}
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
// 실제 파일: adapter-web/src/main/java/dev/caskeleton/adapter/web/pagination/PageParams.java
public record PageParams(int page, int size) {
public static final int DEFAULT_SIZE = 20;
public static final int MIN_SIZE = 1;
public static final int MAX_SIZE = 100;
public static final int DEEP_OFFSET_THRESHOLD = 10000;
public boolean isDeepOffset() {
return page > DEEP_OFFSET_THRESHOLD;
}
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
// 실제 파일: adapter-web/src/main/java/dev/caskeleton/adapter/web/conditional/ETags.java
public static String weakFromVersion(long version) {
return "W/\"" + version + "\"";
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
// 실제 파일: adapter-web/src/main/java/dev/caskeleton/adapter/web/filter/CacheControlFilter.java
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
response.setHeader(ApiHeaders.CACHE_CONTROL, "no-store");
response.setHeader(ApiHeaders.VARY, "Accept, Accept-Encoding, Authorization");
chain.doFilter(request, response);
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
// 실제 파일: sample-portfolio/src/main/java/.../OperationsController.java
@PostMapping("/worklogs:export")
public ResponseEntity<Operation<WorkLogExportResult>> export() {
Operation<WorkLogExportResult> accepted =
operations.startExport(presentationSettings.apiBasePath(), 0L);
return ResponseEntity.accepted().location(URI.create(accepted.statusUrl())).body(accepted);
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
// 실제 파일: app-bootstrap/src/test/java/.../JacksonSerializationPolicyTest.java
String dateTimeJson = mapper.writeValueAsString(utc);
assertThat(dateTimeJson).isEqualTo("\"1985-04-12T23:20:50.52Z\"");
String scaledJson = mapper.writeValueAsString(new BigDecimal("1.10"));
assertThat(scaledJson).isEqualTo("1.10");
```
```java
// 출처: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
// 실제 파일: app-bootstrap/src/test/java/.../CleanArchitectureTest.java
@ArchTest
static final ArchRule NO_BIGDECIMAL_DOUBLE_CONSTRUCTOR =
noClasses()
.that()
.resideInAPackage("dev.caskeleton..")
.should()
.callConstructor(BigDecimal.class, double.class)
.orShould()
.callConstructor(BigDecimal.class, float.class);
```
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
> 모든 사실 주장은 canonical 또는 raw 인용으로 뒷받침. 자기 추론은 명시적으로 "내 해석" 으로 분리.
- [[wiki/projects/ca-tmpl/api-evolution-and-schema]] — 이 글의 1차 canonical. API contract baseline과 schema/serialization 출력측은 구현·로컬 검증 범위, compatibility/deprecation은 documented-only 범위로 구분한다.
- [[wiki/concepts/api-evolution-and-schema]] — API versioning/deprecation/schema compatibility의 일반 개념과 표준·사례 비교.
- [[raw/branch-notes/feature-api-contract-baseline]] — `/v1`, pagination/sort, conditional request, cache header, OpenAPI producer, LRO, batch endpoint 구현·검증 근거.
- [[raw/branch-notes/feature-api-compatibility-deprecation-contract]] — 90d/30d migration window, breaking change catalog, Sunset+Deprecation decision. 현재 documented-only.
- [[raw/branch-notes/feature-schema-serialization-contract]] — Jackson serialization output pin, BigDecimal constructor guard, serialization policy test.
- [[raw/blog-topics/api-deprecation-sunset-header-migration-window-2026-07-02]] — deprecation/migration window 블로그 글감 raw seed.
- [[raw/blog-topics/spring-boot-serialization-contract-pins-2026-07-02]] — Spring Boot serialization contract pin 블로그 글감 raw seed.
- [[raw/official-docs/sunset-deprecation-headers-paired-usage]] — `Sunset` + `Deprecation` header paired usage 근거.
- [[raw/official-docs/rfc9110-http-semantics]] — conditional request, 304/412, transport status 의미.
- [[raw/official-docs/rfc3339-datetime-utc]] — datetime serialization 표현 근거.
## 사실 vs 의견 / Fact vs opinion 구분
> 독자가 자신 있게 인용할 수 있도록.
- **사실 (검증됨)**:
- ca-tmpl의 API contract baseline 일부는 코드로 구현되어 있고 `./gradlew check`/테스트로 로컬 검증됐다. 근거: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
- `/v1` prefix, pagination/sort, ETag/If-Match/304/412, cache header, OpenAPI producer, LRO, batch endpoint는 구현 범위에 포함된다. 근거: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
- Jackson serialization 출력측 pin과 `new BigDecimal(double/float)` 정적 차단은 로컬 검증됐다. 근거: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
- **내 해석·의견 (검증 안 된 추론)**:
- API evolution을 versioning 하나가 아니라 "API surface 전체의 변화 관리"로 보면 skeleton 단계에서 정해야 할 계약이 더 선명해진다.
- default 값을 그대로 믿는 것보다 명시 pin과 effective-bean test를 두는 편이 skeleton template에는 설명 가능하다.
- **알지 못하는 것**:
- 실제 external client migration이 90d/30d window로 충분했는지 알 수 없다. 운영 배포가 없다.
- compatibility/deprecation header를 실제 response interceptor로 구현하고 cutover까지 운영해 본 경험은 없다.
- OpenAPI drift release gate, Avro compatibility, money string-vs-number per-API serialization은 아직 구현·검증 범위가 아니다.
## 답할 수 있는 범위 / Answer boundary
> 이 글을 읽은 사람에게 후속 질문을 받았을 때 자신 있게 답할 수 있는 범위.
- 자신 있게 답할 수 있는 후속 질문:
- ca-tmpl에서 API contract baseline을 어떤 항목으로 나눴는가?
- `/v1` path prefix와 ETag/If-Match/304/412를 어떤 테스트로 검증했는가?
- `Sunset``Deprecation` header는 어떤 차이가 있고 왜 함께 보내기로 했는가?
- Jackson serialization pin과 BigDecimal constructor guard는 왜 두었는가?
- "그건 다음 글에서 다루겠다" 라고 해야 하는 부분:
- 실제 deprecation 운영과 client migration coordination.
- release-blocking OpenAPI drift gate 구현.
- idempotency replay semantics.
- strong ETag 전환.
- per-API money string serialization code sample.
## 게시 체크리스트 / Publish checklist
`ready``published` 로 올리기 전 확인.
- [x] 모든 사실 주장에 canonical 링크 있음
- [x] 사실 vs 의견 분리 명시됨
- [x] 금지 마케팅 표현 없음
- [x] 코드 예제 출처 명시
- [x] 타깃 독자 가정과 톤 일치
- [x] `/lint` 통과
- [ ] 게시 URL 기록 (게시 후):
## Related / 관련
- 후속 글 후보: [[wiki/blog/ca-tmpl-api-error-envelope-design-2026-07-02]]
- 관련 포트폴리오 항목: `[[wiki/portfolio/<...>]]`
- 영감을 받은 raw 자료: [[raw/blog-topics/api-deprecation-sunset-header-migration-window-2026-07-02]], [[raw/blog-topics/spring-boot-serialization-contract-pins-2026-07-02]]
@@ -0,0 +1,255 @@
---
title: 입력 경계에서 검증과 매핑 책임을 분리하기
source_type: blog
status: verified
confidence: high
tags: [blog, ca-tmpl, validation, mapper, archunit]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
canonical_sources:
- wiki/projects/ca-tmpl/boundary-validation-mapping
audience: backend-engineer
target_publish:
status_label: ready
---
# 입력 경계에서 검증과 매핑 책임을 분리하기
> Layer: `wiki/blog/` — **외부 공개용 블로그 글 초안·완성본**. canonical (`wiki/concepts/` + `wiki/projects/`) 에서 파생된 산출물.
> 상태: draft → reviewed → verified → **published-ready** (외부 게시 가능)
> `status_label`: `outline` | `drafting` | `review` | `ready` | `published` | `retired`
> `audience`: `backend-engineer` | `senior-engineer` | `tech-lead` | `general` — 깊이·전문용어 사용량이 달라짐.
## Parent / 부모 (필수)
> wiki/blog/ 는 derived layer. **반드시 canonical wiki/concepts/ 또는 wiki/projects/ 에서 파생.** raw 또는 branch에서 직접 파생 금지.
- 핵심 canonical:
- [[wiki/projects/ca-tmpl/boundary-validation-mapping]] — ca-tmpl 입력 경계 검증, DTO↔도메인 매핑, ArchUnit 정적 강제 구현·검증 범위.
- 관련 개념 문서:
- [[wiki/concepts/boundary-validation-and-dto-mapping]] — boundary validation과 DTO/domain mapping 일반 개념. 현재 concept 문서는 `draft`이므로 이 글의 구현 사실 근거는 verified project canonical에 둔다.
- 영감 출처:
- [[raw/blog-topics/boundary-validation-mapper-responsibility-map-2026-07-02]] — validation/mapper 책임 분리 글감.
- [[raw/blog-topics/archunit-jackson-default-typing-cve-2019-14379-block-2026-05-29]] — Jackson default typing 위험 API 정적 차단 글감.
## 타깃 독자 / Target reader
> 이 글을 누가 읽을 것인지. 톤·전문용어·깊이가 결정됨.
- 독자 profile: Spring Boot API에서 request DTO, validation, mapper, domain model 경계를 어디에 둘지 고민하는 백엔드 엔지니어.
- 독자가 이미 알고 있을 것이라 가정하는 것: Bean Validation, DTO, controller/service 계층, Jackson, 기본적인 Clean Architecture 용어.
- 독자가 처음 듣는다고 가정하는 것: PATCH 3-state, mapper 실패와 validation 실패의 분리, ArchUnit으로 boundary rule을 build-time contract로 만드는 방식.
## 도입 / Hook
> 왜 이 글을 쓰는가. 독자에게 이 글의 가치를 1~2문장으로.
- 문제 / 궁금증: 입력 검증과 DTO↔domain mapping을 한 계층에 몰아두면 request DTO가 application layer까지 새거나, domain/entity가 response로 silent 직렬화되거나, PATCH가 기존 값을 조용히 덮어쓰는 회귀가 생긴다.
- 이 글이 답하는 것: ca-tmpl이 입력 syntax, mapper, response shaping, outbound ACL, polymorphic deserialization, envelope error mapping을 어떻게 나누고 어떤 것은 ArchUnit으로 강제했는지 정리한다.
- 이 글이 답하지 않는 것 (스코프): 운영 트래픽 검증, 실 DB/Testcontainers 통합 검증, 실 외부 HTTP/WireMock 통합, OpenAPI `oneOf` response shape 명세.
## 본문 outline / Body outline
> 글의 흐름. 초안 단계에서는 outline 만, drafting 단계부터 본문.
1. 경계가 흐려질 때 생기는 문제 — request DTO leak, domain/entity response leak, PATCH silent overwrite, raw external response leak.
2. validation 실패와 mapping 실패를 분리하기 — Spring이 다루는 request parsing/validation은 `VALIDATION_FAILED`, mapper 내부 의미 실패는 `MappingException``MAPPING_FAILED`.
3. PATCH 3-state를 `JsonNullable<T>`에서 `Patch<T>`로 옮기기 — web adapter의 Jackson-aware 타입을 application-core 밖으로 가두고 absent/null/value를 보존한다.
4. polymorphic deserialization을 allowlist로 제한하기 — Jackson default typing 위험 API를 ArchUnit rule로 막고, `@JsonTypeInfo` + subtype allowlist만 허용한다.
5. DTO/domain/persistence 경계를 ArchUnit fitness function으로 고정하기 — controller 반환 타입, application method parameter, ProblemDetail import, merge-patch media type, `@Valid` cascade depth, outbound ACL return type을 build-time으로 검증한다.
6. 검증된 범위와 아직 아닌 범위 — WorkLog wire/unit test, ArchitectureViolationFixtureTest, virtual-thread MDC e2e는 검증됐지만 운영, 실 DB 통합, 실 외부 HTTP 통합, OpenAPI shape 명세는 아니다.
## 본문 / Body
입력 경계는 처음에는 controller의 `@Valid` 정도로 끝나는 문제처럼 보입니다. request body를 DTO로 받고, Bean Validation으로 검사하고, service에 넘기면 충분해 보입니다. 그런데 프로젝트가 커질수록 이 경계는 생각보다 쉽게 흐려집니다.
예를 들어 request DTO가 application layer까지 들어가면 application core가 web framework의 모양을 알게 됩니다. 반대로 domain entity나 JPA entity가 controller response로 바로 나가면, 내부 모델이 외부 API contract가 되어 버립니다. PATCH에서는 더 미묘한 문제가 생깁니다. field가 아예 빠진 것인지, 명시적으로 `null`을 보낸 것인지, 새 값을 보낸 것인지 구분하지 못하면 기존 값을 조용히 덮어쓰는 버그가 생깁니다.
ca-tmpl의 boundary validation/mapping 계약은 이 문제를 “입력 검증을 어디서 하느냐” 하나로 보지 않습니다. request parsing, syntax validation, mapper, domain/application command, response shaping, outbound ACL을 서로 다른 책임으로 나눕니다. 그리고 중요한 경계는 ArchUnit rule과 wire-level test로 고정합니다. 컨벤션 문서에만 적어두는 것이 아니라, 누군가 실수로 깨면 build가 실패하게 만드는 방식입니다.
먼저 validation 실패와 mapping 실패를 분리합니다. Spring MVC가 request body를 읽지 못하거나 Bean Validation을 통과하지 못한 경우는 `VALIDATION_FAILED`입니다. 예를 들어 `MethodArgumentNotValidException`, `HttpMessageNotReadableException`, `ConstraintViolationException` 계열입니다. 반면 payload는 구조적으로 들어왔지만 mapper가 의미상 domain command로 바꿀 수 없는 경우는 `MappingException`입니다. ca-tmpl은 이것을 `MAPPING_FAILED`로 분류합니다.
이 분리가 중요한 이유는 실패의 원인이 다르기 때문입니다. validation failure는 보통 client가 입력 형식을 잘못 보냈다는 뜻입니다. mapping failure는 한 단계 더 안쪽입니다. 예를 들어 link URI가 형식은 문자열이지만 project가 받아들일 수 없는 형태라면, mapper가 그것을 domain command로 바꾸지 못합니다. 둘을 모두 “bad request”로만 뭉개면 운영 분류와 client 디버깅이 어려워집니다.
PATCH 3-state도 이 글의 핵심입니다. 일반 update에서는 `null`을 “값을 지운다”로 볼 수 있지만, PATCH에서는 field가 빠진 상태와 field가 `null`인 상태가 다릅니다. 빠졌다는 것은 변경하지 말라는 뜻이고, 명시적 `null`은 비우라는 뜻일 수 있습니다. ca-tmpl은 web adapter에서 `JsonNullable<T>`를 받고, application으로 넘기기 전에 Jackson-free 타입인 `Patch<T>`로 변환합니다.
이렇게 하면 application-core는 Jackson을 모릅니다. application은 `Patch.absent()`, `Patch.ofNull()`, `Patch.of(value)`만 보고 의도를 판단합니다. web adapter는 wire 표현을 domain/application이 이해할 수 있는 작은 값 객체로 번역하는 역할만 맡습니다. 이것이 DTO와 command 사이의 mapper 책임입니다.
polymorphic deserialization도 경계 문제입니다. Jackson default typing은 임의 subtype을 열어 둘 수 있고, 과거 CVE-2019-14379 같은 gadget chain 위험과 연결됩니다. ca-tmpl은 `ObjectMapper.enableDefaultTyping()` 호출과 `LaissezFaireSubTypeValidator` 의존을 ArchUnit으로 막습니다. 대신 `@JsonTypeInfo(use = NAME)`과 명시적인 `@JsonSubTypes` allowlist를 사용합니다. 즉 “다형성을 쓰지 않는다”가 아니라, 허용된 이름과 타입만 받게 하는 것입니다.
DTO/domain/persistence 경계는 정적 rule로 고정합니다. controller public method가 domain entity, JPA entity, repository type을 반환하지 못하게 막습니다. application public method가 web DTO를 parameter로 받지 못하게 막습니다. RFC 7807 `ProblemDetail` import도 막고, `application/merge-patch+json` media type 문자열도 막습니다. outbound adapter public method가 raw external response type을 밖으로 흘리지 못하게 하는 ACL rule도 있습니다.
여기서 ArchUnit은 “아키텍처 다이어그램 검사기”가 아닙니다. 사람이 리뷰에서 놓치기 쉬운 carrier를 build-time에 잡는 fitness function에 가깝습니다. 특히 ca-tmpl은 violations-as-data 방식을 씁니다. 의도적으로 잘못된 fixture를 만들어 rule이 실제 위반을 잡는지 테스트합니다. 이것은 rule이 아무 것도 검사하지 않는데 green이 되는 vacuous pass를 줄이는 장치입니다.
물론 이 계약이 모든 것을 해결한 것은 아닙니다. 현재 검증 범위는 local/dev입니다. `WorkLogControllerWireTest`, unit/contract test, virtual-thread MDC test, `CleanArchitectureTest`와 violation fixture가 있습니다. 하지만 운영 배포는 없고, 실 DB 통합 테스트도 없습니다. outbound ACL도 실 `WebClient`/`RestClient`와 WireMock 왕복으로 검증된 것은 아닙니다. OpenAPI `oneOf` response shape 명세도 아직 planned입니다.
정리하면 ca-tmpl의 boundary validation/mapping 결정은 “controller에 `@Valid` 붙였다”보다 훨씬 넓은 계약입니다. request DTO가 어디까지 들어갈 수 있는지, mapper 실패는 어떤 error code가 되는지, PATCH의 세 상태를 어떻게 보존하는지, 외부 응답 raw type이 domain으로 어떻게 정리되는지, 그리고 그 규칙을 어떤 ArchUnit rule로 고정하는지를 함께 다룹니다. 좋은 경계 설계는 한 번의 아름다운 mapper가 아니라, 다음 사람이 무심코 깨뜨려도 build가 알려주는 구조에 가깝습니다.
## 코드 예제 / Code samples (있다면)
> 가능한 실제 프로젝트 코드 인용. 가짜 예제 금지. 추출 시 출처 PR·커밋 명시.
```java
// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
// 실제 파일: shared-contract/src/main/java/dev/caskeleton/shared/request/Patch.java
public final class Patch<T> {
public static <T> Patch<T> absent() { ... }
public static <T> Patch<T> ofNull() { ... }
public static <T> Patch<T> of(T value) { ... }
public boolean isAbsent() { return !present; }
public boolean isExplicitNull() { return present && value == null; }
public boolean hasValue() { return present && value != null; }
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
// 실제 파일: sample-portfolio/.../CreateWorkLogRequest.java
@GroupSequence({Syntax.class, Invariant.class, CreateWorkLogRequest.class})
public record CreateWorkLogRequest(
@NotBlank(groups = Syntax.class) @Size(max = 200, groups = Syntax.class) String title,
@NotNull(groups = Syntax.class) WorkCategory category,
@NotNull(groups = Syntax.class) LocalDate periodStart,
LocalDate periodEnd) {
@AssertTrue(message = "periodEnd must not be before periodStart", groups = Invariant.class)
public boolean isPeriodOrdered() { ... }
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
// 실제 파일: sample-portfolio/.../UpdateWorkLogRequest.java
private static <T> Patch<T> toPatch(JsonNullable<T> field) {
if (field == null || !field.isPresent()) {
return Patch.absent();
}
return field.get() == null ? Patch.ofNull() : Patch.of(field.get());
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
// 실제 파일: sample-portfolio/.../SamplePolymorphicRequest.java
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind")
@JsonSubTypes({
@JsonSubTypes.Type(value = SamplePolymorphicRequest.Text.class, name = "text"),
@JsonSubTypes.Type(value = SamplePolymorphicRequest.Image.class, name = "image")
})
public sealed interface SamplePolymorphicRequest
permits SamplePolymorphicRequest.Text, SamplePolymorphicRequest.Image {}
```
```java
// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
// 실제 파일: shared-contract/src/main/java/dev/caskeleton/shared/error/MappingException.java
public class MappingException extends RuntimeException {
public MappingException(String message) {
super(message);
}
public MappingException(String message, Throwable cause) {
super(message, cause);
}
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
// 실제 파일: adapter-web/src/main/java/dev/caskeleton/adapter/web/error/GlobalExceptionHandler.java
@ExceptionHandler(MappingException.class)
public ResponseEntity<Envelope<Void>> handleMapping(MappingException ex) {
return ErrorResponseFactory.envelope(OperationalError.MAPPING_FAILED, ex.getMessage(), null);
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
// 실제 파일: sample-portfolio/.../RepoStatsAclMapper.java
static RepoStats toDomain(RawRepoStatsResponse raw) {
if (raw == null || raw.fullName() == null || raw.fullName().isBlank()) {
throw new MappingException("repo provider: missing 'fullName'");
}
return new RepoStats(
raw.fullName().toLowerCase(), Math.max(0, raw.stargazers()), raw.pushedAt());
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
// 실제 파일: app-bootstrap/src/test/java/.../CleanArchitectureTest.java
@ArchTest
static final ArchRule APPLICATION_METHODS_DO_NOT_ACCEPT_WEB_DTOS =
methods()
.that()
.areDeclaredInClassesThat()
.resideInAPackage("..application..")
.and()
.arePublic()
.should()
.notHaveRawParameterTypes(...);
```
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
> 모든 사실 주장은 canonical 또는 raw 인용으로 뒷받침. 자기 추론은 명시적으로 "내 해석" 으로 분리.
- [[wiki/projects/ca-tmpl/boundary-validation-mapping]] — 이 글의 1차 canonical. `verified`, `actually-implemented`, `locally-verified` 범위와 planned 항목을 따른다.
- [[wiki/concepts/boundary-validation-and-dto-mapping]] — boundary validation, DTO/domain mapping, mapper responsibility의 관련 개념 문서. 현재 `draft`이므로 project 구현 사실의 출처로 쓰지 않는다.
- [[raw/branch-notes/feature-boundary-validation-mapping-contract]] — feature branch 결정, Decision Evidence Map, 구현 결과.
- [[raw/blog-topics/boundary-validation-mapper-responsibility-map-2026-07-02]] — validation/mapper responsibility map 블로그 글감 raw seed.
- [[raw/blog-topics/archunit-jackson-default-typing-cve-2019-14379-block-2026-05-29]] — Jackson default typing 위험 API static block 블로그 글감 raw seed.
- [[raw/official-docs/validation-jakarta-bean-validation-3.0-spec]] — `@GroupSequence`, `@Valid` cascade 근거.
- [[raw/official-docs/spring-mvc-rest-exception-handling]] — Spring MVC validation/deserialization exception 처리 근거.
- [[raw/official-docs/patch-json-merge-rfc7396]] — JSON Merge Patch null semantics와 미채택 근거.
- [[raw/official-docs/schema-jackson-polymorphic-deserialization]] — Jackson polymorphic deserialization 위험과 allowlist API 근거.
## 사실 vs 의견 / Fact vs opinion 구분
> 독자가 자신 있게 인용할 수 있도록.
- **사실 (검증됨)**:
- `Patch<T>`, `MappingException`, `GlobalExceptionHandler`, `EnvelopeBodyAdvice`, WorkLog request/mapper, outbound ACL mapper, boundary ArchUnit rules는 ca-tmpl 코드에 존재한다. 근거: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
- `WorkLogControllerWireTest`, unit/contract tests, virtual-thread MDC tests, `CleanArchitectureTest` + violation fixtures가 로컬 검증 범위에 포함된다. 근거: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
- 운영 배포와 prod 검증은 없다. 근거: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
- **내 해석·의견 (검증 안 된 추론)**:
- validation/mapping 책임을 하나의 mapper나 controller에 몰지 않고 boundary rule로 쪼개면, skeleton 사용자가 깨뜨리기 쉬운 회귀를 더 빨리 발견할 수 있다.
- violations-as-data 방식은 ArchUnit rule이 실제 위반을 잡는지 확인하는 데 좋은 학습 소재다.
- **알지 못하는 것**:
- 실 DB 통합에서 PATCH/mapper 정책이 어떻게 동작하는지는 아직 검증되지 않았다.
- 실 외부 HTTP + WireMock 기반 outbound ACL 검증은 아직 없다.
- OpenAPI `oneOf` response shape 명세는 아직 planned다.
## 답할 수 있는 범위 / Answer boundary
> 이 글을 읽은 사람에게 후속 질문을 받았을 때 자신 있게 답할 수 있는 범위.
- 자신 있게 답할 수 있는 후속 질문:
- validation 실패와 mapping 실패를 왜 다른 error category로 나눴는가?
- PATCH absent/null/value를 왜 구분해야 하는가?
- Jackson default typing 위험 API를 어떤 ArchUnit rule로 막았는가?
- controller/application/outbound boundary를 어떤 정적 rule로 강제했는가?
- violations-as-data fixture가 왜 필요한가?
- "그건 다음 글에서 다루겠다" 라고 해야 하는 부분:
- 실 DB/Testcontainers 통합 검증.
- 실 외부 HTTP/WireMock 기반 ACL 검증.
- OpenAPI response shape `oneOf` 명세.
- 운영 트래픽에서 이 계약이 인시던트를 줄였는지에 대한 측정.
## 게시 체크리스트 / Publish checklist
`ready``published` 로 올리기 전 확인.
- [x] 모든 사실 주장에 canonical 링크 있음
- [x] 사실 vs 의견 분리 명시됨
- [x] 금지 마케팅 표현 없음
- [x] 코드 예제 출처 명시
- [x] 타깃 독자 가정과 톤 일치
- [x] `/lint` 통과
- [ ] 게시 URL 기록 (게시 후):
## Related / 관련
- 후속 글 후보: [[wiki/blog/ca-tmpl-api-error-envelope-design-2026-07-02]]
- 영감을 받은 raw 자료: [[raw/blog-topics/boundary-validation-mapper-responsibility-map-2026-07-02]], [[raw/blog-topics/archunit-jackson-default-typing-cve-2019-14379-block-2026-05-29]]
@@ -0,0 +1,199 @@
---
title: Clean Architecture를 패키지 구조로 강제하기
source_type: blog
status: verified
confidence: high
tags: [blog, ca-tmpl, clean-architecture, package-layout]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
canonical_sources:
- wiki/projects/ca-tmpl/clean-architecture-package-layout
audience: backend-engineer
target_publish:
status_label: ready
---
# Clean Architecture를 패키지 구조로 강제하기
## Parent / 부모 (필수)
- 핵심 canonical: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]] — ca-tmpl module/package blueprint, Gradle dependency matrix, ArchUnit boundary rule, negative fixture 검증 범위.
- 관련 개념 문서: [[wiki/concepts/clean-architecture-package-layout]] — Clean Architecture package layout 일반 비교. 현재 concept 문서는 `draft`이므로 이 글의 구현 사실 근거는 verified project canonical에 둔다.
## 타깃 독자 / Target reader
- 독자 profile: Clean Architecture를 Java/Spring 멀티모듈 skeleton에 적용하려는 백엔드 엔지니어.
- 이미 안다고 가정하는 것: controller, application service, domain, adapter 계층.
- 처음 듣는다고 가정하는 것: package layout 자체를 ArchUnit fitness function으로 고정하는 방식.
## 도입 / Hook
- 문제 / 궁금증: Clean Architecture는 그림으로는 쉽지만, package가 흐트러지면 금방 관례가 된다.
- 이 글이 답하는 것: ca-tmpl이 package/module layout과 dependency rule을 어떻게 구현·검증했는지.
- 이 글이 답하지 않는 것: 모든 도메인에 맞는 universal package 구조.
## 본문 outline / Body outline
1. 계층 그림만으로는 부족하다 — import 방향이 깨지면 architecture도 깨진다.
2. ca-tmpl의 module/package layout — domain, application, adapters, shared contract의 책임.
3. ArchUnit rule로 강제하기 — 금지 import와 boundary violation을 build에서 잡는다.
4. sample-portfolio 격리 — 예제 코드는 template core와 분리한다.
5. 말할 수 있는 범위 — local verification과 planned/open risk를 구분한다.
## 본문 / Body
Clean Architecture는 그림으로 보면 단순합니다. domain은 안쪽에 있고, application은 use case를 담고, adapter는 바깥쪽 기술을 맡습니다. 문제는 그림이 아니라 시간이 지난 뒤의 코드입니다. controller가 repository를 직접 부르거나, application이 web DTO를 parameter로 받거나, shared package가 business common dumping ground가 되기 시작하면 구조는 이름만 남습니다.
ca-tmpl은 이 문제를 package naming convention만으로 해결하지 않았습니다. Gradle multi-module을 1차 경계로 두고, ArchUnit을 2차 경계로 둡니다. build graph에서는 어떤 module이 어떤 module을 의존할 수 있는지 검사하고, source import graph에서는 domain/application/adapter/shared-contract가 금지된 타입을 가져오는지 검사합니다. 즉 “Clean Architecture로 짰다”가 아니라, 깨졌을 때 build가 알려주는 skeleton을 만들려는 결정입니다.
현재 ca-tmpl의 production root는 `dev.caskeleton`입니다. bootstrap은 `dev.caskeleton.bootstrap`에 있고, domain/application/adapter/shared package를 component scan 대상으로 명시합니다. module은 `domain-core`, `application-core`, `adapter-web`, `adapter-persistence-rdbms`, `adapter-persistence-postgresql`, `adapter-outbound`, `adapter-identifier`, `shared-contract`, `app-bootstrap`, `sample-portfolio`로 나뉘어 있습니다. project canonical의 최초 slice는 8개 module blueprint였고, 이후 다른 slice에서 identifier와 persistence 세분화가 추가됐습니다. 그래서 이 글에서는 “현재 HEAD의 module 수”와 “그 slice가 검증한 결정”을 섞어 말하지 않습니다.
Gradle 쪽 핵심은 `verifyCleanArchitectureDependencies`입니다. 이 task는 module별 허용 dependency를 whitelist로 들고 있다가, 허용되지 않은 `project()` dependency가 들어오면 실패합니다. 예를 들어 web adapter가 persistence adapter나 outbound adapter를 직접 의존하면 안 됩니다. app-bootstrap은 composition root라 여러 module을 조립할 수 있지만, production code가 `sample-portfolio`에 의존하는 것은 금지됩니다. sample은 학습과 fixture 역할을 하는 소비자 module이지 production core가 기대는 기반 module이 아니기 때문입니다.
ArchUnit 쪽 핵심은 import 방향입니다. `domain_is_pure` rule은 domain package가 Spring, JPA, Hibernate, Lombok, application, adapter, bootstrap에 의존하지 못하게 합니다. application package도 adapter, bootstrap, persistence, Spring Web, Hibernate에 의존하지 못합니다. application에서 Spring `@Transactional`을 직접 쓰지 못하게 막는 rule도 여기에 놓여 있습니다. transaction 자체를 다루지 않는다는 뜻이 아니라, 그 책임을 `TransactionPort` 같은 application port로 드러내겠다는 뜻입니다.
adapter 간 직접 의존도 막습니다. web adapter가 persistence/outbound adapter를 직접 알면, controller가 DB나 외부 client 세부 구현을 우회할 수 있습니다. persistence adapter가 web adapter를 알면, 저장소 계층이 transport 모양을 알게 됩니다. outbound adapter가 persistence adapter를 직접 알면, 외부 호출과 저장소 구현이 서로 엮입니다. ca-tmpl은 이런 adapter 간 연결을 application/domain/shared contract를 통해서만 흐르게 하려 합니다.
`shared-contract`도 별도 경계가 있습니다. 이름이 shared라고 해서 아무 공통 코드를 넣는 곳이 아닙니다. ca-tmpl에서는 response, request, error, headers, logging, tracing, metrics, registry, annotation 같은 operational contract package만 허용합니다. business concept가 shared로 들어오면 여러 domain이 같은 이름의 공통 모델에 묶이기 쉽습니다. 그래서 shared는 편의 package가 아니라 운영 계약의 제한된 통로로 둡니다.
또 하나 중요한 장치는 violations-as-data입니다. ArchUnit rule은 매칭 대상이 비어 있으면 의미 없이 green이 될 수 있습니다. ca-tmpl은 의도적으로 잘못된 fixture class를 test tree에 두고, rule이 그 위반을 실제로 잡는지 확인합니다. 이렇게 하면 rule 이름만 있고 아무 것도 검사하지 않는 상태를 줄일 수 있습니다. 이것은 architecture test 자체를 테스트하는 장치입니다.
다만 이 구조도 만능은 아닙니다. ArchUnit은 bytecode에 남는 import, call, annotation을 잘 잡지만, string-key `ApplicationContext.getBean(String)`, `Class.forName(String)` 같은 reflection-style 우회는 정적으로 잡기 어렵습니다. 또한 운영 배포나 장기 유지보수 효과 측정은 없습니다. 이 글에서 말할 수 있는 범위는 ca-tmpl repository에서 구현됐고, 로컬/dev 검증으로 확인된 module/package boundary까지입니다.
정리하면 ca-tmpl의 Clean Architecture package layout은 “도메인, 애플리케이션, 어댑터로 나눴다”가 핵심이 아닙니다. 핵심은 그 나눔을 Gradle dependency matrix와 ArchUnit fitness function으로 계속 확인한다는 점입니다. skeleton은 한 번 예쁘게 만든 구조보다, 새 도메인을 추가하는 사람이 실수했을 때 어디서 잘못됐는지 알려주는 구조여야 합니다.
## 코드 예제 / Code samples (있다면)
```groovy
// 출처: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]]
// 실제 파일: settings.gradle, ca-tmpl @f6fbd4e196b4
include 'app-bootstrap'
include 'domain-core'
include 'application-core'
include 'adapter-web'
include 'adapter-persistence-rdbms'
include 'adapter-persistence-postgresql'
include 'adapter-outbound'
include 'adapter-identifier'
include 'shared-contract'
include 'sample-portfolio'
```
```java
// 출처: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]]
// 실제 파일: app-bootstrap/.../CaSkeletonApplication.java, ca-tmpl @f6fbd4e196b4
@SpringBootApplication(
scanBasePackages = {
"dev.caskeleton.bootstrap",
"dev.caskeleton.adapter",
"dev.caskeleton.application",
"dev.caskeleton.domain",
"dev.caskeleton.shared"
})
public class CaSkeletonApplication {
public static void main(String[] args) {
SpringApplication.run(CaSkeletonApplication.class, args);
}
}
```
```groovy
// 출처: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]]
// 실제 파일: build.gradle, ca-tmpl @f6fbd4e196b4
tasks.register('verifyCleanArchitectureDependencies') {
doLast {
Map<String, Set<String>> allowedProjectDependencies = [
'domain-core' : ['shared-contract'] as Set,
'application-core' : ['domain-core', 'shared-contract'] as Set,
'adapter-web' : ['application-core', 'domain-core', 'shared-contract'] as Set,
'adapter-outbound' : ['application-core', 'domain-core', 'shared-contract'] as Set,
'shared-contract' : [] as Set
]
// 허용되지 않은 ProjectDependency가 있으면 GradleException을 던진다.
}
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]]
// 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4
@ArchTest
static final ArchRule DOMAIN_IS_PURE =
noClasses()
.that()
.resideInAPackage("..domain..")
.should()
.dependOnClassesThat()
.resideInAnyPackage(
"org.springframework..",
"jakarta.persistence..",
"org.hibernate..",
"lombok..",
"..application..",
"..adapter..",
"..bootstrap..")
.allowEmptyShould(true);
```
```java
// 출처: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]]
// 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4
@ArchTest
static final ArchRule PRODUCTION_CODE_DOES_NOT_DEPEND_ON_SAMPLE_PORTFOLIO =
noClasses()
.that()
.resideOutsideOfPackage("..sample.portfolio..")
.should()
.dependOnClassesThat()
.resideInAPackage("..sample.portfolio..");
```
```java
// 출처: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]]
// 실제 파일: app-bootstrap/.../ArchitectureViolationFixtureTest.java, ca-tmpl @f6fbd4e196b4
class ArchitectureViolationFixtureTest {
private static final JavaClasses VIOLATION_CLASSES =
new ClassFileImporter().importPackages("dev.caskeleton.bootstrap.architecture.violations");
// intentional fixture classes를 읽어 각 ArchUnit rule이 실제 위반을 잡는지 확인한다.
}
```
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
- [[wiki/projects/ca-tmpl/clean-architecture-package-layout]] — 이 글의 1차 canonical. module/package blueprint, Gradle dependency guard, ArchUnit enforcement, negative fixture, 검증 범위와 과장 금지 항목을 따른다.
- [[wiki/concepts/clean-architecture-package-layout]] — 관련 개념 문서. 현재 `draft`이므로 구현 사실의 출처로 쓰지 않는다.
## 사실 vs 의견 / Fact vs opinion 구분
- 사실: ca-tmpl에는 Gradle module dependency matrix, `CleanArchitectureTest`, `ArchitectureViolationFixtureTest`, production → sample dependency ban, `shared-contract` package scope rule이 존재한다. 근거: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]]
- 사실: 검증 범위는 local/dev이며, 운영 배포나 운영 metric 검증은 없다. 근거: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]]
- 의견: package layout은 문서보다 build-time guardrail과 함께 있을 때 skeleton 학습 효과가 커진다.
- 알지 못하는 것: 운영 조직에서 이 구조가 장기 유지보수 비용을 얼마나 줄였는지는 측정하지 않았다.
## 답할 수 있는 범위 / Answer boundary
- 자신 있게 답할 수 있는 후속 질문:
- 왜 Gradle multi-module을 1차 boundary로 두었는가?
- Gradle dependency matrix와 ArchUnit rule은 각각 무엇을 막는가?
- `sample-portfolio`를 production code가 의존하지 못하게 한 이유는 무엇인가?
- violations-as-data fixture가 왜 필요한가?
- 다음 글로 넘길 부분:
- Spring Modulith 도입 여부.
- 대규모 도메인에서 feature module을 더 쪼개는 전략.
- runtime lookup/reflection 우회를 자동으로 잡는 방법.
## 게시 체크리스트 / Publish checklist
- [x] 모든 사실 주장에 canonical 링크 있음
- [x] 사실 vs 의견 분리 명시됨
- [x] 금지 마케팅 표현 없음
- [x] 코드 예제 출처 명시
- [x] 타깃 독자 가정과 톤 일치
- [x] `/lint` 통과
- [ ] 게시 URL 기록 (게시 후):
## Related / 관련
- 후속 글 후보: [[wiki/blog/ca-tmpl-skeleton-governance-registry-verification-test-scorecard-2026-07-02]]
- 관련 개념 문서: [[wiki/concepts/clean-architecture-package-layout]]
@@ -0,0 +1,154 @@
---
title: Optional Adapter를 설정 계약으로 다루기
source_type: blog
status: verified
confidence: high
tags: [blog, ca-tmpl, config, adapter, conditional-on-property]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-03
canonical_sources:
- wiki/projects/ca-tmpl/config-and-adapter-templates
audience: backend-engineer
target_publish:
status_label: ready
---
# Optional Adapter를 설정 계약으로 다루기
## Parent / 부모 (필수)
- 핵심 canonical: [[wiki/projects/ca-tmpl/config-and-adapter-templates]]
- 관련 개념 문서: [[wiki/concepts/config-and-adapter-templates]] - 일반 config/adapter template 비교. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
## 타깃 독자 / Target reader
- 독자 profile: Spring Boot optional adapter와 env-driven 설정을 skeleton에 넣으려는 백엔드 엔지니어.
- 이미 안다고 가정하는 것: `@ConfigurationProperties`, `@ConditionalOnProperty`, env var.
- 처음 듣는다고 가정하는 것: adapter 추가를 설정값 하나가 아니라 registry, bean gating, static rule, startup fail-fast가 맞물린 계약으로 보는 방식.
## 도입 / Hook
Optional adapter는 처음에는 편해 보입니다. Redis가 있으면 cache adapter를 켜고, Kafka가 있으면 message broker adapter를 켜고, Slack이나 email provider는 필요할 때만 붙이면 됩니다. 문제는 “꺼져 있어도 안전한가”입니다. env key가 `.env`에는 있는데 `application.yml`에서 안 쓰이거나, optional adapter bean이 조건 없이 등록되거나, disabled 상태인데 application layer가 adapter package를 직접 import하면 설정은 계약이 아니라 분위기가 됩니다.
ca-tmpl은 이 문제를 `@ConditionalOnProperty` 하나로 끝내지 않았습니다. `APP_` env registry와 `.env` drift gate, `@ConfigurationProperties` settings, optional adapter package isolation, `@ConditionalOnProperty` annotation rule, startup failure exception을 나눠 두었습니다. 이 글은 optional adapter를 “있으면 쓰고 없으면 말고”가 아니라 “켜지는 조건과 실패 방식이 검증되는 계약”으로 만든 이유를 정리합니다.
## 본문 outline / Body outline
1. optional adapter의 흔한 실패 - env drift, ungated bean, hidden direct import.
2. 설정은 runtime contract다 - `.env`, `application.yml`, env registry를 같이 검증한다.
3. `@ConditionalOnProperty`의 역할과 한계 - bean 등록 조건은 보지만 runtime activation 전체를 증명하지는 않는다.
4. static isolation과 startup fail-fast - disabled adapter가 조용히 섞이지 않게 한다.
5. 구현된 것과 provider-specific template의 남은 범위를 분리한다.
## 본문 / Body
설정값은 코드 밖에 있지만, 실제로는 코드의 실행 경로를 바꿉니다. `APP_CACHE_REDIS_ENABLED=true`가 들어오면 Redis cache backend가 생기고, `app.messaging.broker=kafka`가 들어오면 Kafka broker bean이 등록됩니다. 이런 설정을 문서로만 관리하면 drift가 생깁니다. `.env`에만 남은 key, `application.yml`에만 있는 placeholder, registry에 등록되지 않은 `APP_` key가 조금씩 쌓입니다.
ca-tmpl은 이 drift를 Gradle task로 막습니다. `verifyEnvKeys``src/.env`, `application.yml`, `docs/registries/env-keys.yaml`을 함께 읽습니다. `application.yml`의 required placeholder가 `.env`에 없으면 실패하고, `.env` key가 어떤 placeholder에도 쓰이지 않으면 실패합니다. 또 `APP_` key는 env registry에 등록되어 있어야 합니다. 즉 설정 문서와 실제 boot 설정이 따로 움직이지 않게 빌드 단계에서 묶습니다.
adapter activation은 Layer 1에서 Spring bean 조건으로 표현합니다. Redis cache backend는 `app.cache.redis.enabled=true`일 때만 `CacheBackend` bean을 제공합니다. Kafka broker도 `app.messaging.broker=kafka`일 때만 `MessageBroker` bean을 등록합니다. 이 방식의 장점은 adapter 구현이 중앙 router나 use case를 직접 수정하지 않아도 “내가 활성화되는 조건”을 자기 config에 선언할 수 있다는 점입니다.
하지만 `@ConditionalOnProperty`만으로는 충분하지 않습니다. ArchUnit은 런타임 property evaluation을 실행하지 않습니다. 대신 ca-tmpl은 정적 분석으로 두 가지를 봅니다. application layer가 optional adapter package를 import하지 않는지, optional adapter package 안의 `@Bean` method가 `@ConditionalOnProperty`를 갖고 있는지입니다. 이건 “현재 어떤 profile에서 bean이 켜졌는가”를 증명하는 것이 아니라, disabled-default를 우회할 수 있는 코드 구조를 막는 쪽입니다.
Layer 3는 startup fail-fast입니다. required capability adapter가 꺼져 있거나 coordination bean이 없으면 `RequiredAdapterDisabledException` 계열 startup failure로 드러납니다. cache router나 messaging config처럼 중앙 binding 지점에서도 disabled backend binding이 조용한 no-op으로 흘러가지 않도록 설계합니다. skeleton에서 중요한 것은 “꺼져 있으면 아무 일도 하지 않는다”가 아니라, “꺼져 있는데 필요한 경로라면 빨리 실패한다”입니다.
이 결정을 그림으로 보면 세 층입니다.
| 층 | 잡는 문제 | ca-tmpl 구현 범위 |
|---|---|---|
| Env registry gate | `.env` / `application.yml` / registry drift | `verifyEnvKeys` |
| Bean gating | optional adapter bean이 조건 없이 등록되는 문제 | `@ConditionalOnProperty`, `DisabledAdapterArchitectureTest` |
| Startup/runtime fail-fast | required adapter가 disabled인데 조용히 진행되는 문제 | startup failure exception, router/config guard |
이 글에서 조심해야 할 경계도 있습니다. ca-tmpl에는 env registry/gate와 optional adapter guard가 구현되어 있고 `./gradlew check`로 로컬 검증됐습니다. 반면 모든 provider-specific adapter template가 완성됐다고 말하면 안 됩니다. Kafka, Redis, Slack, Google Email 같은 표면이 일부 존재하더라도, “모든 외부 provider 전환을 검증했다”는 주장은 project canonical 범위를 넘습니다. 이 글의 결론은 “optional adapter를 완성했다”가 아니라 “optional adapter가 켜지고 꺼지는 실패 모드를 설정 계약으로 드러내기 시작했다”입니다.
## 코드 예제 / Code samples (있다면)
```groovy
// 출처: [[wiki/projects/ca-tmpl/config-and-adapter-templates]]
// 실제 파일: src/build.gradle, ca-tmpl @f6fbd4e196b4
tasks.register('verifyEnvKeys') {
description = 'Verifies src/.env covers application.yml placeholders and every APP_ key is registered.'
File envFile = file("${rootProject.projectDir}/.env")
File appYml = file("${rootProject.projectDir}/app-bootstrap/src/main/resources/application.yml")
File registryFile = file("${rootProject.projectDir}/../docs/registries/env-keys.yaml")
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/config-and-adapter-templates]]
// 실제 파일: adapter-outbound/.../RedisCacheAdapterConfig.java, ca-tmpl @f6fbd4e196b4
@Bean
@ConditionalOnProperty(
name = "app.cache.redis.enabled",
havingValue = "true",
matchIfMissing = false)
public CacheBackend redisCacheBackend(RedisClient redisClient) {
return new RedisCacheStore(redisClient);
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/config-and-adapter-templates]]
// 실제 파일: adapter-outbound/.../KafkaAdapterConfig.java, ca-tmpl @f6fbd4e196b4
@Bean
@ConditionalOnProperty(name = "app.messaging.broker", havingValue = "kafka")
public MessageBroker kafkaMessageBroker(KafkaSender sender, KafkaAdapterSettings settings) {
if (settings.brokers().isEmpty()) {
throw new IllegalStateException("app.messaging.broker=kafka requires a non-empty broker list");
}
return new KafkaMessageBroker(sender);
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/config-and-adapter-templates]]
// 실제 파일: app-bootstrap/.../DisabledAdapterArchitectureTest.java, ca-tmpl @f6fbd4e196b4
static final ArchRule APPLICATION_DOES_NOT_DEPEND_ON_OPTIONAL_ADAPTERS =
noClasses()
.that()
.resideInAPackage("..application..")
.should()
.dependOnClassesThat()
.resideInAnyPackage(OPTIONAL_ADAPTER_PACKAGES);
```
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
- [[wiki/projects/ca-tmpl/config-and-adapter-templates]] - 이 글의 1차 canonical. env registry/gate, `@ConfigurationProperties`, optional adapter `@ConditionalOnProperty`, ArchUnit static guard, startup fail-fast, local verification, provider별 미완성 범위를 따른다.
- [[wiki/concepts/config-and-adapter-templates]] - 관련 개념 문서. Spring Cloud Config, ConfigMap reload, Consul, Parameter Store, feature flag SaaS 같은 대안 비교 배경으로만 둔다.
## 사실 vs 의견 / Fact vs opinion 구분
- 사실: ca-tmpl에는 `verifyEnvKeys`, `docs/registries/env-keys.yaml`, 여러 `@ConfigurationProperties` settings, optional adapter `@ConditionalOnProperty` config, `DisabledAdapterArchitectureTest`, startup failure exception이 존재한다. 근거: [[wiki/projects/ca-tmpl/config-and-adapter-templates]]
- 사실: `./gradlew check`가 2026-07-02 기준 통과했고, `verifyEnvKeys`와 optional adapter 관련 검증이 local/dev 범위에 포함된다. 근거: [[wiki/projects/ca-tmpl/config-and-adapter-templates]]
- 사실: 모든 provider-specific adapter template와 모든 disabled runtime path가 완성됐다고 말하지 않는다. 근거: [[wiki/projects/ca-tmpl/config-and-adapter-templates]]
- 의견: optional adapter는 silent noop보다 fail-fast 계약으로 두는 편이 skeleton 학습과 장애 분석에 더 유리하다.
- 알지 못하는 것: 실제 환경에서 adapter on/off를 전환한 운영 경험, provider SDK별 production tuning.
## 답할 수 있는 범위 / Answer boundary
- 자신 있게 답할 수 있는 후속 질문:
- env key drift를 왜 build gate로 잡는가?
- `@ConditionalOnProperty`는 어떤 문제를 해결하고 어떤 문제를 해결하지 못하는가?
- optional adapter static isolation과 startup fail-fast가 왜 둘 다 필요한가?
- 다음 글로 넘길 부분:
- 특정 provider SDK별 timeout/retry/auth 설정.
- runtime reload나 feature flag SaaS가 필요한 제품 단계.
- 실제 환경에서 adapter toggle을 운영한 경험.
## 게시 체크리스트 / Publish checklist
- [x] 모든 사실 주장에 canonical 링크 있음
- [x] 사실 vs 의견 분리 명시됨
- [x] 금지 마케팅 표현 없음
- [x] 코드 예제 출처 명시
- [x] 타깃 독자 가정과 톤 일치
- [x] `/lint` 통과
- [ ] 게시 URL 기록 (게시 후):
## Related / 관련
- 후속 글 후보: [[wiki/blog/ca-tmpl-runtime-container-health-migration-2026-07-02]]
- 후속 글 후보: [[wiki/blog/ca-tmpl-data-layer-persistence-cache-outbound-2026-07-02]]
@@ -0,0 +1,184 @@
---
title: Persistence와 Cache, Outbound 경계를 한 문서에서 분리하기
source_type: blog
status: verified
confidence: high
tags: [blog, ca-tmpl, persistence, cache, outbound]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
canonical_sources:
- wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound
audience: backend-engineer
target_publish:
status_label: ready
---
# Persistence와 Cache, Outbound 경계를 한 문서에서 분리하기
## Parent / 부모 (필수)
- 핵심 canonical: [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]]
- 관련 개념 문서: [[wiki/concepts/data-layer-persistence-cache-outbound]] — data layer 일반 개념. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
## 타깃 독자 / Target reader
- 독자 profile: data layer baseline을 skeleton 수준에서 정하려는 백엔드 엔지니어.
- 이미 안다고 가정하는 것: JPA, cache-aside, outbound HTTP, connection pool.
- 처음 듣는다고 가정하는 것: persistence/cache/outbound를 한데 묶되 증거 등급을 분리하는 방식.
## 도입 / Hook
- 문제 / 궁금증: data layer는 persistence, cache, outbound가 섞여 보여도 실패 모드와 검증 범위가 다르다.
- 이 글이 답하는 것: ca-tmpl에서 구현된 항목과 문서/계획만 있는 항목을 구분한다.
- 이 글이 답하지 않는 것: 실제 운영 DB latency와 cache hit ratio 개선 측정.
## 본문 outline / Body outline
1. data layer baseline을 쪼개서 보기 — persistence, cache, outbound.
2. 실제 구현된 범위 — idempotency/outbox adapter, migration, OSIV/Hikari guard, lower-layer cache SPI.
3. cache와 outbound의 실패 계약 — fail-open/fail-closed를 구분한다.
4. Hikari/startup guard와 slow query 논의 — 구현/계획/needs-confirmation을 분리한다.
5. 운영 검증 없음 — metric과 incident 경험처럼 말하지 않는다.
## 본문 / Body
data layer라고 부르면 하나로 보이지만, 실제로는 서로 다른 실패 모드가 섞여 있습니다. persistence는 DB transaction과 constraint, connection pool 문제가 중심입니다. cache는 빠른 조회와 stale data, backend 장애 시 degrade 정책이 중심입니다. outbound HTTP는 외부 dependency의 timeout, retry, circuit breaker, shutdown 처리 문제가 중심입니다. ca-tmpl의 data layer 문서는 이 셋을 한 문서에 두되, 구현된 범위와 계획만 있는 범위를 분리합니다.
이 분리가 중요한 이유는 과장하기 쉽기 때문입니다. “data layer baseline을 구현했다”고 말하면 persistence classifier, cache consistency, outbound resilience가 모두 같은 수준으로 끝난 것처럼 들립니다. 하지만 ca-tmpl 기준으로는 구현된 slice가 서로 다릅니다. idempotency/outbox RDBMS adapter, PostgreSQL migration, OSIV/Hikari startup guard, lower-layer cache SPI/router/fail-open, outbound HTTP client는 구현·로컬 검증됐습니다. 반면 SQLState classifier 전체, read replica lag metric, cache-aside + Redisson distributed mutex + after-commit invalidation 전체 contract, 운영 tuning은 아직 planned 또는 부분 구현입니다.
persistence 쪽에서 구현된 대표 guard는 OSIV off입니다. OSIV(Open Session In View)는 web response 렌더링 시점까지 Hibernate session을 열어두는 방식입니다. 편리하지만 presentation layer에서 lazy association을 만지는 순간 DB query가 나갈 수 있습니다. ca-tmpl은 `spring.jpa.open-in-view=true`가 명시되면 startup에서 실패시키는 validator를 둡니다. 즉 layer boundary를 runtime configuration에서도 깨지 않게 합니다.
HikariCP 설정도 startup guard로 다룹니다. connection-timeout은 최소 250ms 이상이어야 하고, validation-timeout은 connection-timeout보다 작아야 하며, keepalive-time은 max-lifetime보다 작아야 합니다. leak-detection-threshold도 켤 거면 2000ms 이상이어야 합니다. 이것은 pool sizing을 운영에서 측정했다는 뜻이 아닙니다. 잘못 조합된 knob를 애플리케이션 시작 시점에 빨리 실패시키는 guard입니다.
cache 쪽은 fail-open 경계를 구현했습니다. ca-tmpl의 `CacheStoreRouter`는 logical cache name을 backend id로 라우팅합니다. binding이 없는 logical cache를 호출하면 조용히 no-op 하지 않고 `AdapterDisabledException`을 던집니다. 반대로 backend가 구성된 뒤 실제 cache backend 호출이 실패하면 `FailOpenCacheStore`가 get은 miss로, put은 관찰된 실패로 낮춥니다. cache는 성능 보조 장치이므로 backend 장애가 곧 5xx가 되지 않게 하는 쪽입니다.
이 차이가 outbox와 다릅니다. outbox publish는 fail-open이면 안 됩니다. 메시지 발행 실패를 조용히 삼키면 downstream이 영원히 변경 사실을 모를 수 있습니다. 그래서 outbox는 `FAILED`/`DEAD` state machine과 error log를 갖습니다. cache는 장애 시 miss로 degrade할 수 있지만, outbox는 실패를 상태로 남기고 다시 처리해야 합니다. 같은 “adapter failure”라도 업무 의미가 다릅니다.
outbound HTTP는 또 다른 경계입니다. ca-tmpl의 `OutboundHttpClient`는 dependency name별 baseline client를 만들고, shutdown 중이면 네트워크를 맺기 전에 fail-fast합니다. buffered 호출은 retry/circuit breaker decorator를 거치고, streaming 호출은 retry하지 않습니다. 이미 일부 bytes를 소비한 stream은 안전하게 재시도하기 어렵기 때문입니다. retry policy도 GET/HEAD/PUT/DELETE 같은 idempotent method만 재시도 대상으로 둡니다. POST/PATCH는 기본적으로 제외됩니다.
outbound HTTP에서 중요한 것은 timeout 3축입니다. connect timeout, read timeout, global call timeout을 분리해서 생각합니다. connect timeout은 TCP 연결 단계, read timeout은 socket read 단계, global call timeout은 retry를 포함한 전체 예산입니다. ca-tmpl의 현재 구현은 이 값을 기본값으로 박아두기보다 필수 설정으로 요구하고, 누락 또는 잘못된 raw RestClient 등록을 startup에서 막는 방향입니다.
정리하면 ca-tmpl의 data layer baseline은 “DB, cache, HTTP를 다 구현했다”는 단순한 문장이 아닙니다. 구현된 것은 구현됐다고 말하고, planned인 것은 planned라고 남기는 문서입니다. 이 글에서 가장 중요한 학습 포인트도 여기에 있습니다. data layer의 경계는 기술 이름으로 나뉘는 것이 아니라, 실패했을 때 무엇을 보존해야 하는지로 나뉩니다. DB transaction은 정합성을 보존해야 하고, cache는 miss로 degrade할 수 있으며, outbound HTTP는 retry/timeout 예산 안에서 실패를 분류해야 합니다.
## 코드 예제 / Code samples (있다면)
```java
// 출처: [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]]
// 실제 파일: app-bootstrap/.../OpenInViewSafetyValidator.java, ca-tmpl @f6fbd4e196b4
public void afterSingletonsInstantiated() {
Boolean openInView = environment.getProperty("spring.jpa.open-in-view", Boolean.class);
if (Boolean.TRUE.equals(openInView)) {
throw new IllegalStateException("APP_DATASOURCE_OPEN_IN_VIEW must be false");
}
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]]
// 실제 파일: app-bootstrap/.../HikariPoolConstraintValidator.java
if (validationTimeout != null
&& connectionTimeout != null
&& validationTimeout >= connectionTimeout) {
violations.add("validation-timeout must be < connection-timeout");
}
if (keepaliveTime != null && maxLifetime != null && keepaliveTime >= maxLifetime) {
violations.add("keepalive-time must be < max-lifetime");
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]]
// 실제 파일: adapter-outbound/.../CacheStoreRouter.java
public Optional<String> get(String logicalName, String key) {
return resolve(logicalName).get(key);
}
private CacheStore resolve(String logicalName) {
String backendId = bindings.get(logicalName);
if (backendId == null) {
throw new AdapterDisabledException("cache", "no cache backend bound");
}
return backends.get(backendId);
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]]
// 실제 파일: adapter-outbound/.../FailOpenCacheStore.java
public Optional<String> get(String key) {
try {
return delegate.get(key);
} catch (Exception ex) {
dependencyLogger.logFailure(delegate.backendId(), "cache", "get", ex);
return Optional.empty();
}
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]]
// 실제 파일: adapter-outbound/.../OutboundHttpClient.java
if (shutdownGuard.isShuttingDown()) {
throw observer.rejectShutdown("shutdown in progress — outbound call rejected fail-fast");
}
retryPolicy.beginCall(method, deadline);
try {
Supplier<T> decorated = countingSupplier;
if (retry.isPresent()) decorated = Retry.decorateSupplier(retry.get(), decorated);
if (cb.isPresent()) decorated = CircuitBreaker.decorateSupplier(cb.get(), decorated);
return decorated.get();
} finally {
retryPolicy.endCall();
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]]
// 실제 파일: adapter-outbound/.../OutboundRetryPolicy.java
private static final Set<HttpMethod> IDEMPOTENT_METHODS =
Set.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.PUT, HttpMethod.DELETE);
if (!IDEMPOTENT_METHODS.contains(ctx.method())) {
return false;
}
```
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
- [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]] — 이 글의 1차 canonical. persistence/cache/outbound 각각의 구현·부분 구현·planned 경계를 따른다.
- [[wiki/concepts/data-layer-persistence-cache-outbound]] — 관련 개념 문서. 구현 사실 출처로 쓰지 않는다.
## 사실 vs 의견 / Fact vs opinion 구분
- 사실: ca-tmpl에는 idempotency/outbox RDBMS adapter와 migration, OSIV-off startup guard, Hikari inter-knob startup guard, lower-layer cache SPI/router/fail-open, outbound HTTP baseline이 존재한다. 근거: [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]]
- 사실: SQLState classifier 전체, read replica lag metric/alert, cache-aside + Redisson distributed mutex + after-commit invalidation 전체 contract, 운영 tuning은 구현 완료로 말하지 않는다. 근거: [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]]
- 사실: 운영 배포, pool wait p99, cache hit ratio, circuit breaker 운영 경험은 없다. 근거: [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]]
- 의견: persistence/cache/outbound를 함께 다루더라도 실패 계약은 분리해서 설명해야 한다.
- 알지 못하는 것: production pool wait, slow query, cache hit rate, outbound dependency 장애율.
## 답할 수 있는 범위 / Answer boundary
- 자신 있게 답할 수 있는 후속 질문:
- fail-open cache와 fail-closed outbox의 차이는 무엇인가?
- OSIV off startup guard가 layer boundary와 어떤 관련이 있는가?
- Hikari knob guard는 운영 tuning과 어떻게 다른가?
- outbound HTTP retry에서 POST/PATCH를 제외한 이유는 무엇인가?
- 다음 글로 넘길 부분:
- 실제 DB/cache 운영 metric.
- SQLState classifier 전체 구현과 운영 alert.
- cache-aside after-commit invalidation의 full contract.
## 게시 체크리스트 / Publish checklist
- [x] 모든 사실 주장에 canonical 링크 있음
- [x] 사실 vs 의견 분리 명시됨
- [x] 금지 마케팅 표현 없음
- [x] 코드 예제 출처 명시
- [x] 타깃 독자 가정과 톤 일치
- [x] `/lint` 통과
- [ ] 게시 URL 기록 (게시 후):
## Related / 관련
- 후속 글 후보: [[wiki/blog/ca-tmpl-transactional-outbox-pattern-2026-07-02]]
@@ -0,0 +1,143 @@
---
title: CI와 Supply Chain을 Skeleton 계약으로 묶기
source_type: blog
status: verified
confidence: high
tags: [blog, ca-tmpl, devops, ci-cd, supply-chain]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-03
canonical_sources:
- wiki/projects/ca-tmpl/devops-ci-supply-chain-dx
audience: backend-engineer
target_publish:
status_label: ready
---
# CI와 Supply Chain을 Skeleton 계약으로 묶기
## Parent / 부모 (필수)
- 핵심 canonical: [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]]
- 관련 개념 문서: [[wiki/concepts/devops-ci-supply-chain-dx]] - 일반 CI/supply-chain/DX 개념. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
## 타깃 독자 / Target reader
- 독자 profile: template repo의 CI, static analysis, release/supply-chain baseline을 설계하려는 엔지니어.
- 이미 안다고 가정하는 것: Gradle, GitHub Actions, dependency lock, SBOM, static analysis.
- 처음 듣는다고 가정하는 것: CI를 도구 목록이 아니라 gate ownership, drift check, release evidence의 계약으로 보는 방식.
## 도입 / Hook
CI에 도구를 많이 붙이는 것은 어렵지 않습니다. formatter, linter, unit test, integration test, vulnerability scanner, SBOM, signing, provenance를 순서대로 추가하면 화면은 그럴듯해집니다. 그런데 어떤 gate가 release를 막는지, 실패하면 어느 branch contract가 책임지는지, 문서의 gate matrix가 실제 workflow와 어긋나면 누가 잡는지 정하지 않으면 CI는 금방 장식이 됩니다.
ca-tmpl은 DevOps baseline을 “workflow 파일 몇 개”가 아니라 skeleton contract로 보려 했습니다. gate ownership matrix를 repo 안에 두고, Gradle custom task와 workflow job이 실제로 존재하는지 검사하며, dependency lock과 reproducible archive 설정, Cosign/SLSA 관련 workflow와 검증 스크립트를 둡니다. 단, hosted GitHub Actions에서 release를 실제 발행하고 Rekor/GHCR evidence까지 확인한 것은 아닙니다. 이 글은 구현된 local/repo-level gate와 live release 검증의 경계를 분리합니다.
## 본문 outline / Body outline
1. CI gate는 tool list가 아니라 release contract다.
2. gate matrix와 owner branch - 무엇이 실패하면 누가 고쳐야 하는가.
3. Gradle baseline - static analysis, dependency locking, reproducible archive.
4. supply-chain workflow - SBOM, Cosign, SLSA, Trivy를 증거 체인으로 묶는다.
5. local/dev portability와 hosted release 검증의 차이를 분리한다.
## 본문 / Body
CI를 설계할 때 흔한 실수는 “무엇을 실행할지”만 정하는 것입니다. 실제로 더 중요한 질문은 “이 검사가 실패하면 release가 막히는가”, “누가 policy를 소유하는가”, “문서에 적힌 gate가 실제 workflow에 남아 있는가”입니다. ca-tmpl의 `.github/ci-gate-matrix.yml`은 이 질문에 답하기 위한 파일입니다. 각 gate에는 id, release blocking 여부, owner branch, mechanism, ref, workflow가 붙습니다.
이 matrix는 문서가 아니라 검사 대상입니다. `verify-gate-matrix.sh`는 matrix row를 읽고, mechanism별로 실제 존재 여부를 확인합니다. `gradle-custom-task`라면 `tasks.register('<ref>')`가 있어야 하고, `contract-test`라면 test class 파일이 있어야 하며, `workflow-job`이라면 workflow 안에 job id가 있어야 합니다. 이렇게 하면 “문서에는 gate가 있는데 CI에서는 빠진 상태”를 줄일 수 있습니다.
Gradle baseline도 여러 층으로 나뉩니다. `src/build.gradle`은 Spotless, Checkstyle, SpotBugs, ErrorProne을 subproject에 적용하고, dependency locking을 strict mode로 켭니다. archive task는 timestamp, file order, permission을 고정해 build artifact가 host 환경에 덜 흔들리도록 합니다. 이것은 production artifact reproducibility를 완전히 증명한다는 뜻이 아니라, skeleton에서 entropy source를 줄이는 baseline입니다.
Supply chain 쪽은 release evidence를 digest 중심으로 묶으려는 방향입니다. `.github/workflows/build-release-supply-chain.yml`에는 SBOM generation, Trivy image scan, Cosign sign/attest, SLSA provenance, verification, promotion step이 있습니다. `.github/scripts/verify-supply-chain-contract.sh`는 workflow와 policy file에 필요한 문자열과 job wiring이 남아 있는지 확인합니다. 예를 들어 `cosign sign --yes`, `cosign attest --yes`, `--certificate-identity`, `--certificate-oidc-issuer`, SLSA v1 predicate, exact builder identity 같은 조건을 검사합니다.
여기서 중요한 경계가 있습니다. ca-tmpl에 workflow와 검증 스크립트가 존재한다는 사실과, 실제 release artifact가 public registry에 올라갔고 Rekor/GHCR evidence로 검증됐다는 사실은 다릅니다. project canonical은 후자를 확인하지 않았다고 명시합니다. 따라서 이 글은 “supply-chain release를 운영했다”가 아니라 “supply-chain release contract를 repo-level workflow와 script로 고정했다”까지만 말합니다.
DX도 같은 관점입니다. `./gradlew bootstrap`은 compile, Docker preflight, dependency/migration/startup, sample contract, HTTP smoke를 하나의 진입점으로 묶습니다. 이 명령이 모든 OS와 CI 환경을 보장한다는 뜻은 아닙니다. 대신 새 프로젝트를 받은 사람이 “무엇부터 실행해야 하는가”를 덜 고민하게 만들고, 실패 지점을 단계로 나누려는 목적입니다.
결국 ca-tmpl의 DevOps baseline은 “이 도구를 썼다”보다 “어떤 증거가 release를 통과시키는가”에 가깝습니다. gate matrix가 workflow와 drift 나지 않아야 하고, dependency lock이 조용히 풀리면 안 되며, vulnerability suppression은 사유와 만료일 없이 남으면 안 됩니다. 이 정도가 local/repo-level에서 검증된 범위입니다. 실제 hosted CI run, release publication, live Cosign/Rekor 검증은 별도의 근거가 생긴 뒤에만 말할 수 있습니다.
## 코드 예제 / Code samples (있다면)
```yaml
# 출처: [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]]
# 실제 파일: .github/ci-gate-matrix.yml, ca-tmpl @f6fbd4e196b4
gates:
- id: architecture-test
release_blocking: true
owner_branch: feature-architecture-enforcement-rules
mechanism: contract-test
ref: app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java
runs_in: ci-quality-gates
```
```bash
# 출처: [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]]
# 실제 파일: .github/scripts/verify-gate-matrix.sh, ca-tmpl @f6fbd4e196b4
# Cross-checks every row of .github/ci-gate-matrix.yml against reality:
# gradle-custom-task -> a tasks.register('<ref>') exists
# contract-test -> the <ref> test-class file exists under src/
# workflow-job -> the <ref> job id exists in .github/workflows/<runs_in>.yml
```
```groovy
// 출처: [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]]
// 실제 파일: src/build.gradle, ca-tmpl @f6fbd4e196b4
dependencyLocking {
lockAllConfigurations()
lockMode = LockMode.STRICT
}
tasks.withType(AbstractArchiveTask).configureEach {
preserveFileTimestamps = false
reproducibleFileOrder = true
}
```
```bash
# 출처: [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]]
# 실제 파일: .github/scripts/verify-supply-chain-contract.sh, ca-tmpl @f6fbd4e196b4
require_fixed "${workflow}" 'cosign sign --yes' 'D6 keyless image signature'
require_fixed "${workflow}" 'cosign attest --yes' 'D4 digest-bound SBOM attestation'
require_fixed "${workflow}" '--certificate-identity' 'D12 signer identity verification'
require_fixed "${workflow}" '--certificate-oidc-issuer' 'D12 OIDC issuer verification'
require_fixed "${workflow}" 'generator_container_slsa3.yml@v2.1.0' 'D7 isolated SLSA generator'
```
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
- [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]] - 이 글의 1차 canonical. CI gate matrix, Gradle gates, supply-chain workflow/script, local verification, live release 미검증 경계를 따른다.
- [[wiki/concepts/devops-ci-supply-chain-dx]] - 관련 개념 문서. CI provider, signing, provenance, dependency lock, dev environment 대안 비교 배경으로만 둔다.
## 사실 vs 의견 / Fact vs opinion 구분
- 사실: ca-tmpl에는 CI workflow, `.github/ci-gate-matrix.yml`, gate matrix 검증 스크립트, supply-chain policy/script, Gradle dependency lock/reproducible archive 설정, 여러 custom verification task가 존재한다. 근거: [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]]
- 사실: `./gradlew check`가 2026-07-02 기준 통과했고, local gate 검증이 project canonical에 기록되어 있다. 근거: [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]]
- 사실: hosted GitHub Actions run, 실제 release publication, live Rekor/GHCR/Cosign 검증은 확인하지 않았다. 근거: [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]]
- 의견: skeleton에서는 CI tool list보다 gate ownership matrix가 더 오래 남는 설계 자산이다.
- 알지 못하는 것: public artifact 소비자, 실제 release incident, live registry rollback 경험.
## 답할 수 있는 범위 / Answer boundary
- 자신 있게 답할 수 있는 후속 질문:
- gate matrix가 왜 필요한가?
- Gradle dependency locking과 reproducible archive 설정이 어떤 drift를 줄이는가?
- Cosign/SLSA/Trivy workflow와 검증 스크립트가 repo-level에서 무엇을 고정하는가?
- 다음 글로 넘길 부분:
- 실제 release signing 운영.
- public artifact distribution과 rollback manifest 운영.
- SLSA level 달성 여부와 live provenance 검증.
## 게시 체크리스트 / Publish checklist
- [x] 모든 사실 주장에 canonical 링크 있음
- [x] 사실 vs 의견 분리 명시됨
- [x] 금지 마케팅 표현 없음
- [x] 코드 예제 출처 명시
- [x] 타깃 독자 가정과 톤 일치
- [x] `/lint` 통과
- [ ] 게시 URL 기록 (게시 후):
## Related / 관련
- 후속 글 후보: [[wiki/blog/ca-tmpl-skeleton-governance-registry-verification-test-scorecard-2026-07-02]]
- 후속 글 후보: [[wiki/blog/ca-tmpl-sample-fixture-and-adoption-2026-07-02]]
@@ -0,0 +1,160 @@
---
title: Idempotency Key를 API 표면이 아니라 실행 계약으로 보기
source_type: blog
status: verified
confidence: high
tags: [blog, ca-tmpl, idempotency, api-design]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
canonical_sources:
- wiki/projects/ca-tmpl/idempotency-key-design
audience: backend-engineer
target_publish:
status_label: ready
---
# Idempotency Key를 API 표면이 아니라 실행 계약으로 보기
## Parent / 부모 (필수)
- 핵심 canonical: [[wiki/projects/ca-tmpl/idempotency-key-design]]
- 관련 개념 문서: [[wiki/concepts/idempotency-key-design]] — 일반 idempotency key 개념. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
## 타깃 독자 / Target reader
- 독자 profile: POST 중복 요청과 retry를 안전하게 처리하려는 백엔드 엔지니어.
- 이미 안다고 가정하는 것: HTTP retry, unique key, transaction.
- 처음 듣는다고 가정하는 것: scope, body digest, replay/in-flight/mismatch 분류를 API 계약으로 고정하는 방식.
## 도입 / Hook
- 문제 / 궁금증: `Idempotency-Key` header만 받는다고 idempotency가 구현되는 것은 아니다.
- 이 글이 답하는 것: ca-tmpl이 key scope, request digest, status classification, persistence/executor 경계를 어떻게 나눴는지.
- 이 글이 답하지 않는 것: production retry traffic과 duplicate suppression metric.
## 본문 outline / Body outline
1. header surface와 실제 executor의 차이.
2. triple scope와 request digest — 같은 key가 무엇을 의미하는지 고정한다.
3. in-flight/replay/mismatch 분류 — client가 무엇을 해야 하는지 알려준다.
4. transaction boundary와 persistence adapter — local verification 범위.
5. 아직 운영 검증은 없다.
## 본문 / Body
`Idempotency-Key` header를 받는 것만으로 idempotency가 구현되지는 않습니다. header는 단지 client가 “이 요청은 같은 의도로 다시 보낼 수 있다”고 알려주는 표면입니다. 서버가 실제로 해야 할 일은 더 많습니다. 같은 요청인지 판단해야 하고, 이미 처리 중인지 구분해야 하며, 완료된 결과를 replay할 수 있어야 하고, 같은 key로 다른 body가 들어오면 client bug로 돌려줘야 합니다.
ca-tmpl은 이 문제를 web filter 하나로 처리하지 않았습니다. 핵심 실행 계약은 application layer의 `IdempotencyExecutor`에 둡니다. web adapter는 header, principal, use case name, request fingerprint를 모아 context를 만들고, executor는 store port를 통해 claim/replay/mismatch/in-flight를 판정합니다. persistence adapter는 DB table과 unique constraint로 scope 충돌을 실제로 막습니다.
scope는 `(authenticatedPrincipal, idempotencyKey, useCaseName)` triple입니다. tenant isolation이 활성화되면 tenant가 앞에 붙어 4-tuple이 됩니다. 여기서 `useCaseName`을 넣는 이유가 중요합니다. 같은 principal이 같은 idempotency key를 두 다른 use case에 보냈을 때 충돌하면 안 됩니다. URL path를 scope에 넣지 않는 것도 의도입니다. path version이 바뀌어도 같은 application use case의 실행 의미가 유지될 수 있기 때문입니다.
request fingerprint는 같은 key가 같은 body를 뜻하는지 확인하는 장치입니다. ca-tmpl의 executor는 live record를 찾으면 fingerprint를 먼저 비교합니다. 같으면 상태에 따라 replay 또는 in-flight 처리로 갑니다. 다르면 `IdempotencyRequestMismatchException`을 던지고, web boundary에서 `422`로 매핑합니다. 같은 key를 재사용했지만 body가 다르다는 것은 보통 client가 idempotency key를 잘못 관리한다는 신호입니다.
동시 도착은 `409`로 분리합니다. executor는 record가 `IN_FLIGHT`이면 바로 실패시키지 않고 최대 200ms 동안 짧게 기다립니다. 그 안에 선행 요청이 완료되면 저장된 response를 replay할 수 있습니다. 그래도 완료되지 않으면 `IdempotencyInFlightException`이 나고 `409`로 응답합니다. 이 200ms는 부하 테스트로 튜닝된 수치가 아니라 ca-tmpl 기본 정책값입니다.
완료된 요청은 저장된 response를 replay합니다. action이 성공하면 codec이 response를 직렬화해 store에 저장하고, 같은 scope의 후속 요청은 action을 다시 실행하지 않고 그 payload를 역직렬화합니다. action이 예외를 던지면 executor는 record를 discard합니다. 실패한 실행을 영구적으로 replay하지 않기 위해서입니다. 즉 idempotency는 성공 응답 replay와 실행 중 충돌 제어를 다루며, 모든 실패를 캐시하는 장치가 아닙니다.
저장소는 DB table입니다. Redis나 in-memory cache만으로 두지 않은 이유는 skeleton에서 transaction boundary와 운영 복구 가능성을 우선했기 때문입니다. PostgreSQL migration에는 `tenant`, `principal`, `idempotency_key`, `use_case_name` unique constraint가 있습니다. TTL은 기본 24h이고, executor는 per-use-case override가 있더라도 72h cap을 넘지 못하게 합니다.
응답 코드도 의도적으로 나뉩니다. in-flight 충돌은 `409 Conflict`, 같은 key와 다른 body fingerprint는 `422 Unprocessable Entity`입니다. 두 상황은 client가 해야 할 일이 다릅니다. `409`은 조금 뒤 다시 시도할 수 있지만, `422`는 key/body 조합을 고쳐야 합니다. ca-tmpl은 이 차이를 error envelope mapping까지 이어갑니다.
검증 범위는 local/dev입니다. `IdempotencyExecutor`, web helper/codec, RDBMS store, PostgreSQL unique scope contract가 존재하고 `./gradlew check`로 검증됐습니다. 하지만 운영 duplicate suppression metric, 실제 retry traffic 비율, 200ms wait의 부하 기반 튜닝은 없습니다. 따라서 이 글에서 말할 수 있는 것은 구현과 로컬 검증이지 운영 효과 측정이 아닙니다.
## 코드 예제 / Code samples (있다면)
```java
// 출처: [[wiki/projects/ca-tmpl/idempotency-key-design]]
// 실제 파일: application-core/.../IdempotencyScope.java, ca-tmpl @f6fbd4e196b4
public record IdempotencyScope(
String tenant, String principal, String idempotencyKey, String useCaseName) {
public static IdempotencyScope of(String principal, String idempotencyKey, String useCaseName) {
return of(null, principal, idempotencyKey, useCaseName);
}
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/idempotency-key-design]]
// 실제 파일: application-core/.../IdempotencyExecutor.java, ca-tmpl @f6fbd4e196b4
public final class IdempotencyExecutor {
public static final Duration IN_FLIGHT_WAIT = Duration.ofMillis(200);
public static final Duration MAX_TTL = Duration.ofHours(72);
public <R> R execute(
IdempotencyContext context, Supplier<R> action, IdempotentResponseCodec<R> codec) {
// claim -> fingerprint mismatch -> replay -> in-flight wait -> 409
}
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/idempotency-key-design]]
// 실제 파일: application-core/.../IdempotencyExecutor.java
if (!record.fingerprint().equals(fingerprint)) {
throw new IdempotencyRequestMismatchException(scope);
}
if (record.status() == IdempotencyStatus.COMPLETED) {
return codec.deserialize(record.response().payload());
}
if (!now.isBefore(deadline)) {
throw new IdempotencyInFlightException(scope);
}
```
```sql
-- 출처: [[wiki/projects/ca-tmpl/idempotency-key-design]]
-- 실제 파일: adapter-persistence-postgresql/.../V1__idempotency_record.sql
CREATE TABLE idempotency_record (
id uuid NOT NULL,
tenant varchar(128) NOT NULL DEFAULT '',
principal varchar(256) NOT NULL,
idempotency_key varchar(256) NOT NULL,
use_case_name varchar(256) NOT NULL,
request_hash char(64) NOT NULL,
status varchar(16) NOT NULL,
response_payload text NULL,
expires_at timestamptz NOT NULL,
CONSTRAINT uq_idempotency_scope
UNIQUE (tenant, principal, idempotency_key, use_case_name)
);
```
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
- [[wiki/projects/ca-tmpl/idempotency-key-design]] — 이 글의 1차 canonical. triple scope, 24h TTL, 200ms wait, 409/422 mapping, 구현/로컬 검증 범위와 과장 금지 경계를 따른다.
- [[wiki/concepts/idempotency-key-design]] — 관련 개념 문서. 구현 사실 출처로 쓰지 않는다.
## 사실 vs 의견 / Fact vs opinion 구분
- 사실: ca-tmpl에는 `IdempotencyExecutor`, `IdempotencyStorePort`, `IdempotencyScope`, `RequestFingerprint`, web helper/codec, RDBMS store, PostgreSQL unique scope migration이 존재한다. 근거: [[wiki/projects/ca-tmpl/idempotency-key-design]]
- 사실: `./gradlew check`, executor/store/web mapping/unique scope contract test가 로컬 검증 범위에 포함된다. 근거: [[wiki/projects/ca-tmpl/idempotency-key-design]]
- 사실: 운영 배포, duplicate suppression metric, 200ms wait 부하 튜닝은 없다. 근거: [[wiki/projects/ca-tmpl/idempotency-key-design]]
- 의견: idempotency는 API header보다 application execution contract로 설명할 때 설계가 더 잘 보인다.
- 알지 못하는 것: 운영 retry traffic에서 replay/in-flight/mismatch 비율이 어떻게 나오는지.
## 답할 수 있는 범위 / Answer boundary
- 자신 있게 답할 수 있는 후속 질문:
- replay, in-flight, mismatch를 왜 나눴는가?
- triple scope에 `useCaseName`을 넣은 이유는 무엇인가?
- 같은 key + 다른 body를 왜 `422`로 보는가?
- DB table unique constraint가 idempotency executor와 어떻게 맞물리는가?
- 다음 글로 넘길 부분:
- 운영 duplicate suppression metric.
- multi-node production race 부하 테스트.
- long-term retention policy와 비용 모델.
## 게시 체크리스트 / Publish checklist
- [x] 모든 사실 주장에 canonical 링크 있음
- [x] 사실 vs 의견 분리 명시됨
- [x] 금지 마케팅 표현 없음
- [x] 코드 예제 출처 명시
- [x] 타깃 독자 가정과 톤 일치
- [x] `/lint` 통과
- [ ] 게시 URL 기록 (게시 후):
## Related / 관련
- 후속 글 후보: [[wiki/blog/ca-tmpl-api-evolution-and-schema-2026-07-02]]
@@ -0,0 +1,121 @@
---
title: 구현 후 지식을 다시 Wiki로 회수하기
source_type: blog
status: verified
confidence: medium
tags: [blog, ca-tmpl, workflow, documentation]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-03
canonical_sources:
- wiki/projects/ca-tmpl/knowledge-capture-workflow
audience: backend-engineer
target_publish:
status_label: ready
---
# 구현 후 지식을 다시 Wiki로 회수하기
## Parent / 부모 (필수)
- 핵심 canonical: [[wiki/projects/ca-tmpl/knowledge-capture-workflow]]
- 관련 개념 문서: [[wiki/concepts/clean-architecture-package-layout]] - ca-tmpl 문서 구조와 project/concept 분리의 배경으로만 참고한다.
## 타깃 독자 / Target reader
- 독자 profile: 구현 과정에서 생긴 결정을 branch note, wiki, blog로 회수하고 싶은 개발자.
- 이미 안다고 가정하는 것: branch note, project note, blog draft, wiki 문서화.
- 처음 듣는다고 가정하는 것: raw 증거, canonical 문서, derived 산출물을 분리해서 학습 루프를 만드는 방식.
## 도입 / Hook
구현이 끝난 뒤 가장 빨리 사라지는 것은 코드가 아닙니다. 코드는 repository에 남습니다. 사라지는 것은 “왜 이 선택을 했는가”, “어떤 대안을 버렸는가”, “어디까지 검증했고 어디부터는 추측인가” 같은 맥락입니다. 이 맥락은 채팅 로그, branch note, 테스트 실패, 작은 TODO 사이에 흩어지기 쉽습니다.
ca-tmpl의 knowledge capture workflow는 이 문제를 줄이기 위한 문서화 규칙입니다. raw 자료를 증거로 보관하고, `wiki/projects``wiki/concepts`를 canonical로 정리한 뒤, blog/interview/portfolio 같은 derived 산출물을 canonical에서만 만듭니다. 이 글은 애플리케이션 기능이 아니라 작업 종료 조건으로서의 지식 회수 구조를 설명합니다.
## 본문 outline / Body outline
1. 구현 후 사라지는 것은 코드가 아니라 결정 맥락이다.
2. raw, canonical, derived를 섞지 않는다.
3. branch-note와 blog-topic은 증거이고 project 문서는 설명 가능한 결정이다.
4. blogify는 raw가 아니라 verified canonical에서 시작한다.
5. 이 workflow는 자동화가 아니라 documentation rule이다.
## 본문 / Body
개발자가 나중에 다시 공부하기 어려운 이유는 “기록이 없어서”만은 아닙니다. 기록은 많습니다. branch note도 있고, daily note도 있고, 테스트 로그도 있고, raw blog-topic도 있습니다. 문제는 그 기록들이 서로 다른 신뢰도를 갖는다는 점입니다. 구현 중 적은 메모와 코드 대조를 마친 project canonical, 그리고 외부에 내보낼 블로그 초안은 같은 레이어가 아닙니다.
ca-tmpl workflow의 첫 원칙은 raw를 증거로 두는 것입니다. branch-note, daily-note, error note, blog-topic은 생각의 흔적과 구현 증거를 보관합니다. 여기에는 미확정 판단, 실패한 시도, 나중에 다듬을 글감이 들어갈 수 있습니다. raw는 귀중하지만 그대로 블로그가 되지는 않습니다.
두 번째 레이어는 canonical입니다. `wiki/projects/`는 내 프로젝트에서 실제로 구현됐거나 로컬 검증된 결정을 정리합니다. `wiki/concepts/`는 특정 프로젝트를 떠난 일반 개념과 trade-off를 정리합니다. ca-tmpl의 20개 project 문서가 중요한 이유도 여기에 있습니다. 여러 branch-note에 흩어진 결정을 주제별로 합치고, 구현 범위와 미검증 범위를 분리해서 “내가 설명할 수 있는 지식”으로 바꾸기 때문입니다.
세 번째 레이어가 derived 산출물입니다. blog, interview, portfolio는 canonical에서 파생됩니다. raw branch-note에서 바로 blog를 만들지 않는 이유는 간단합니다. raw에는 사실, 추측, 계획, 감정, 작업 중간 판단이 섞입니다. canonical을 거치면 “실제로 코드가 있는가”, “로컬에서 검증됐는가”, “prod 검증은 없는가”, “planned를 구현처럼 말하고 있지 않은가”를 먼저 정리할 수 있습니다.
이번 ca-tmpl 블로그 작업도 같은 흐름입니다. raw/blog-topics 59개는 글감 원석이었고, ingest를 통해 project canonical에 반영됐습니다. 그 뒤 `blogify``wiki/projects/ca-tmpl/*.md`에서 시작했습니다. 그래서 블로그 20개는 branch-note 50여 개를 1:1로 그대로 옮긴 것이 아니라, 프로젝트 결정 주제 20개로 녹인 뒤 다시 읽을 수 있는 글로 풀어내는 구조입니다.
이 workflow의 장점은 학습 경로가 보인다는 점입니다. 어떤 글을 쓰다가 근거가 약하면 raw로 돌아가는 것이 아니라 canonical을 먼저 고칩니다. canonical이 draft라면 verified로 올릴 근거를 대조합니다. blog에 쓸 수 없는 planned 항목은 planned라고 표시합니다. 이렇게 하면 글쓰기 자체가 복습이 됩니다. 단순히 문장을 만드는 것이 아니라, 내가 어디까지 알고 어디부터 모르는지 나누는 과정이기 때문입니다.
다만 이 글은 높은 자동화를 주장하지 않습니다. canonical에 따르면 이 workflow는 runtime 기능이 아니고, git hook이나 CI로 강제되는 구조도 아닙니다. 일부 branch에서 branch-note 갱신과 derived raw note 생성이 실제로 수행됐고, raw/blog-topics 59개 ingest batch가 적용 사례로 남아 있을 뿐입니다. 따라서 confidence도 `medium`으로 둡니다. 문서화 규칙으로는 검증됐지만, 자동 강제 장치가 있는 것은 아닙니다.
결론적으로 knowledge capture는 ca-tmpl의 코드 기능이 아니라 학습과 설명을 위한 작업 방식입니다. 구현이 끝난 뒤 branch-note를 닫고, raw 글감을 canonical에 반영하고, verified project 문서에서 blog를 파생합니다. 이 구조를 따르면 “왜 그렇게 결정했는지”를 나중에 다시 따라갈 수 있습니다. 그게 이 블로그 묶음의 진짜 목적입니다.
## 코드 예제 / Code samples (있다면)
이 글은 runtime code를 설명하는 글이 아니므로 애플리케이션 코드 예제는 두지 않는다. 대신 실제 workflow는 아래 흐름으로 읽는다.
```text
# 출처: [[wiki/projects/ca-tmpl/knowledge-capture-workflow]]
raw/branch-notes + raw/blog-topics
-> wiki/projects 또는 wiki/concepts canonical
-> wiki/blog, wiki/interview, wiki/portfolio derived output
```
```text
# 출처: [[wiki/projects/ca-tmpl/knowledge-capture-workflow]]
blogify 입력으로 적합한 것:
wiki/projects/ca-tmpl/<verified-project-canonical>.md
wiki/concepts/<reviewed-or-verified-concept>.md
blogify 입력으로 피해야 하는 것:
raw/branch-notes/<branch-note>.md
raw/blog-topics/<topic-seed>.md
```
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
- [[wiki/projects/ca-tmpl/knowledge-capture-workflow]] - 이 글의 1차 canonical. runtime 구현 없음, documentation workflow, partial local 사례, 자동 강제 부재, confidence medium 경계를 따른다.
- [[wiki/concepts/clean-architecture-package-layout]] - 관련 개념 문서. ca-tmpl 문서 구조와 project/concept 분리 배경으로만 둔다.
## 사실 vs 의견 / Fact vs opinion 구분
- 사실: 이 문서가 다루는 것은 runtime 기능이나 애플리케이션 코드가 아니라 workflow rule과 문서화 결정이다. 근거: [[wiki/projects/ca-tmpl/knowledge-capture-workflow]]
- 사실: 일부 branch에서 branch-note 갱신과 derived raw note 생성이 수행됐고, raw/blog-topics 59개 ingest batch가 raw에서 canonical로 승격된 사례로 기록되어 있다. 근거: [[wiki/projects/ca-tmpl/knowledge-capture-workflow]]
- 사실: git hook/CI enforcement는 없고 agent workflow rule에 의존한다. 근거: [[wiki/projects/ca-tmpl/knowledge-capture-workflow]]
- 의견: 블로그 작성은 문장 생산보다 canonical을 다시 검증하는 학습 루프로 볼 때 더 효과적이다.
- 알지 못하는 것: 장기적으로 회고 품질, 면접 성과, 외부 글 반응이 얼마나 좋아지는지.
## 답할 수 있는 범위 / Answer boundary
- 자신 있게 답할 수 있는 후속 질문:
- 왜 raw에서 바로 blog를 만들지 않는가?
- branch-note와 project canonical의 역할은 어떻게 다른가?
- ca-tmpl 20개 project blog가 branch-note 묶음을 어떻게 학습 가능한 구조로 바꾸는가?
- 다음 글로 넘길 부분:
- git hook/CI 기반 documentation gate.
- 자동 품질 검사 확장.
- 블로그 게시 후 독자 반응이나 회고 효과 측정.
## 게시 체크리스트 / Publish checklist
- [x] 모든 사실 주장에 canonical 링크 있음
- [x] 사실 vs 의견 분리 명시됨
- [x] 금지 마케팅 표현 없음
- [x] 코드 예제 출처 명시
- [x] 타깃 독자 가정과 톤 일치
- [x] `/lint` 통과
- [ ] 게시 URL 기록 (게시 후):
## Related / 관련
- 후속 글 후보: [[wiki/blog/ca-tmpl-skeleton-governance-registry-verification-test-scorecard-2026-07-02]]
- 후속 글 후보: [[wiki/blog/ca-tmpl-api-error-envelope-design-2026-07-02]]
- 후속 글 후보: [[wiki/blog/ca-tmpl-api-evolution-and-schema-2026-07-02]]
@@ -0,0 +1,146 @@
---
title: Multi-tenancy를 기본값이 아니라 Opt-in 계약으로 두기
source_type: blog
status: verified
confidence: high
tags: [blog, ca-tmpl, multi-tenancy, saas]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-03
canonical_sources:
- wiki/projects/ca-tmpl/multi-tenancy-isolation-patterns
audience: backend-engineer
target_publish:
status_label: ready
---
# Multi-tenancy를 기본값이 아니라 Opt-in 계약으로 두기
## Parent / 부모 (필수)
- 핵심 canonical: [[wiki/projects/ca-tmpl/multi-tenancy-isolation-patterns]]
- 관련 개념 문서: [[wiki/concepts/multi-tenancy-isolation-patterns]] - Pool/Silo/Bridge와 Hibernate multi-tenancy 전략의 일반 배경. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
## 타깃 독자 / Target reader
- 독자 profile: SaaS skeleton에서 tenant isolation을 어디까지 기본 제공할지 고민하는 백엔드 엔지니어.
- 이미 안다고 가정하는 것: `tenant_id`, shared DB, schema-per-tenant.
- 처음 듣는다고 가정하는 것: multi-tenancy를 default feature가 아니라 opt-in guardrail과 migration trigger로 다루는 방식.
## 도입 / Hook
Multi-tenancy는 SaaS에서 중요하지만, skeleton에 처음부터 강하게 박아 넣기 어렵습니다. 모든 query에 tenant predicate를 강제하고, tenant resolver filter를 만들고, admin tenant switching을 열고, schema-per-tenant까지 고려하면 single-tenant 서비스에도 비용이 따라옵니다. 반대로 아무 계약도 없으면 나중에 tenant를 얹을 때 권한, idempotency, logging, repository 경계가 한꺼번에 흔들립니다.
ca-tmpl은 이 사이에서 opt-in 방향을 택했습니다. 기본은 `APP_TENANT_ENABLED=false`이고, shared DB + `tenant_id` 방향을 문서화하되 현재 구현은 registry, capability, idempotency scope, runbook stub 같은 foundation에 머뭅니다. repository-level tenant filter와 cross-tenant E2E isolation은 아직 planned입니다. 이 글은 multi-tenancy를 “완성했다”고 말하지 않고, 어디까지 foundation을 깔았는지 정리합니다.
## 본문 outline / Body outline
1. multi-tenancy를 skeleton 기본값으로 강제하지 않는 이유.
2. shared DB + `tenant_id`와 schema/db-per-tenant의 trade-off.
3. 현재 구현된 registry/capability/idempotency foundation.
4. 아직 없는 tenant resolver와 repository filter.
5. migration trigger와 운영 검증 없음.
## 본문 / Body
Multi-tenancy의 첫 갈림길은 격리 수준입니다. 모든 tenant를 같은 DB와 table에 두고 `tenant_id` column으로 나누는 방식은 운영이 단순합니다. 반면 schema-per-tenant나 db-per-tenant는 isolation은 강하지만 migration, backup, connection pool, monitoring 비용이 빠르게 늘어납니다. ca-tmpl은 B2B 초기 단계, tenant 수 수십에서 수백 정도의 가정을 두고 shared DB + `tenant_id`를 baseline 후보로 잡았습니다.
하지만 이 선택은 “항상 shared DB가 낫다”는 뜻이 아닙니다. 규제 산업, data residency 요구, enterprise tier처럼 격리를 상품 가치로 팔아야 하는 경우에는 schema나 DB를 나누는 쪽이 맞을 수 있습니다. 그래서 ca-tmpl canonical은 migration trigger도 함께 기록합니다. 규제 요구, tenant 수와 row 수 증가, enterprise tier 등장 같은 조건이 생기면 Pool 모델에서 더 강한 isolation으로 넘어갈 수 있다는 판단입니다.
현재 코드로 구현된 것은 storage isolation 전체가 아니라 foundation입니다. env registry에는 `APP_TENANT_ENABLED`가 있고, header registry에는 `X-Tenant-Id`가 있습니다. capability registry에는 `CROSS_TENANT_ADMIN`이 존재합니다. error-code registry에는 tenant 미지원 상태에서 tenant header가 들어왔을 때의 `TENANT_NOT_SUPPORTED`가 정의되어 있고, cross-tenant mismatch runbook stub도 있습니다.
application layer에도 일부 표현이 있습니다. `UseCaseCapability`에는 `crossTenantAdmin` flag가 있습니다. 이것은 tenant 경계를 넘는 admin use case가 명시적으로 선언해야 하는 capability입니다. idempotency 쪽에는 `IdempotencyScope`가 single-tenant triple뿐 아니라 tenant를 앞에 둔 4-tuple을 표현할 수 있습니다. tenant가 활성화되면 idempotency key 충돌도 tenant boundary 안에서 해석되어야 하기 때문입니다.
다만 중요한 enforcement가 아직 없습니다. request에서 tenant를 해석하는 tenant resolver filter는 구현됐다고 말할 수 없습니다. repository 진입점에서 `tenant_id` predicate를 강제하는 rule도 아직 planned입니다. `CROSS_TENANT_ADMIN`을 가진 admin만 `X-Tenant-Id` header로 tenant switching을 할 수 있다는 정책은 문서/registry 수준에 가깝고, E2E isolation으로 검증된 상태는 아닙니다.
이 경계가 이 글의 핵심입니다. ca-tmpl은 multi-tenancy를 처음부터 모든 서비스에 강제하지 않습니다. 대신 나중에 tenant를 열 때 필요한 vocabulary와 일부 cross-cutting surface를 미리 잡아 둡니다. header, env key, capability, idempotency scope, runbook link가 그 foundation입니다. 반면 실제 data isolation은 repository filter와 E2E test가 들어와야 닫힙니다.
따라서 면접이나 블로그에서 말할 때도 “multi-tenancy를 구현했다”보다 “multi-tenancy를 opt-in으로 열 수 있게 foundation을 만들었고, storage isolation enforcement는 planned로 남겼다”가 정확합니다. 이 차이를 숨기지 않는 것이 오히려 설계 이해를 더 잘 보여줍니다.
## 코드 예제 / Code samples (있다면)
```yaml
# 출처: [[wiki/projects/ca-tmpl/multi-tenancy-isolation-patterns]]
# 실제 파일: docs/registries/env-keys.yaml, ca-tmpl @f6fbd4e196b4
- name: APP_TENANT_ENABLED
type: boolean
default: false
allowed_values: [true, false]
reload_policy: restart-only
owner_branch: feature-tenant-context-policy
```
```yaml
# 출처: [[wiki/projects/ca-tmpl/multi-tenancy-isolation-patterns]]
# 실제 파일: docs/registries/capabilities.yaml, ca-tmpl @f6fbd4e196b4
- name: CROSS_TENANT_ADMIN
scope: use_case_method
enforcement: archunit
annotation: "@UseCaseCapability(crossTenantAdmin = true)"
```
```java
// 출처: [[wiki/projects/ca-tmpl/multi-tenancy-isolation-patterns]]
// 실제 파일: application-core/.../UseCaseCapability.java, ca-tmpl @f6fbd4e196b4
public @interface UseCaseCapability {
TransactionMode transactionMode();
Idempotency idempotency();
RepositoryAccess repositoryAccess();
boolean crossTenantAdmin() default false;
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/multi-tenancy-isolation-patterns]]
// 실제 파일: application-core/.../IdempotencyScope.java, ca-tmpl @f6fbd4e196b4
public record IdempotencyScope(
String tenant, String principal, String idempotencyKey, String useCaseName) {
public static IdempotencyScope of(
String tenant, String principal, String idempotencyKey, String useCaseName) {
requirePresent("principal", principal);
requirePresent("idempotencyKey", idempotencyKey);
requirePresent("useCaseName", useCaseName);
String normalizedTenant = (tenant == null || tenant.isBlank()) ? null : tenant;
return new IdempotencyScope(normalizedTenant, principal, idempotencyKey, useCaseName);
}
}
```
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
- [[wiki/projects/ca-tmpl/multi-tenancy-isolation-patterns]] - 이 글의 1차 canonical. opt-in policy, shared DB + `tenant_id`, registry/capability/idempotency foundation, planned repository filter, 운영 미검증 경계를 따른다.
- [[wiki/concepts/multi-tenancy-isolation-patterns]] - 관련 개념 문서. Pool/Silo/Bridge와 Hibernate strategy의 일반 비교 배경으로만 둔다.
## 사실 vs 의견 / Fact vs opinion 구분
- 사실: ca-tmpl에는 `APP_TENANT_ENABLED`, `X-Tenant-Id`, `CROSS_TENANT_ADMIN`, tenant 관련 error code/runbook stub, `UseCaseCapability.crossTenantAdmin`, tenant-aware `IdempotencyScope`가 존재한다. 근거: [[wiki/projects/ca-tmpl/multi-tenancy-isolation-patterns]]
- 사실: repository-level tenant predicate 강제, tenant resolver filter, cross-tenant E2E isolation은 구현/검증됐다고 말하지 않는다. 근거: [[wiki/projects/ca-tmpl/multi-tenancy-isolation-patterns]]
- 사실: 운영 배포, tenant isolation audit, penetration test 결과는 없다. 근거: [[wiki/projects/ca-tmpl/multi-tenancy-isolation-patterns]]
- 의견: ca-tmpl 같은 skeleton에서는 multi-tenancy를 default feature가 아니라 opt-in foundation으로 두는 편이 적용 범위를 넓힌다.
- 알지 못하는 것: 실제 tenant 수, row 수, noisy neighbor metric, schema/db-per-tenant migration 경험.
## 답할 수 있는 범위 / Answer boundary
- 자신 있게 답할 수 있는 후속 질문:
- 왜 schema-per-tenant를 skeleton 기본값으로 두지 않았는가?
- `X-Tenant-Id` header를 왜 admin only로 제한해야 하는가?
- idempotency scope에 tenant dimension이 왜 필요한가?
- 다음 글로 넘길 부분:
- tenant resolver filter 구현.
- repository-level `tenant_id` predicate 강제.
- cross-tenant E2E isolation과 audit evidence.
## 게시 체크리스트 / Publish checklist
- [x] 모든 사실 주장에 canonical 링크 있음
- [x] 사실 vs 의견 분리 명시됨
- [x] 금지 마케팅 표현 없음
- [x] 코드 예제 출처 명시
- [x] 타깃 독자 가정과 톤 일치
- [x] `/lint` 통과
- [ ] 게시 URL 기록 (게시 후):
## Related / 관련
- 후속 글 후보: [[wiki/blog/ca-tmpl-security-baseline-jwt-actuator-secrets-2026-07-02]]
- 후속 글 후보: [[wiki/blog/ca-tmpl-idempotency-key-design-2026-07-02]]
@@ -0,0 +1,165 @@
---
title: Observability를 로그 한 줄이 아니라 운영 계약으로 보기
source_type: blog
status: verified
confidence: high
tags: [blog, ca-tmpl, observability, logging, metrics, tracing]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-03
canonical_sources:
- wiki/projects/ca-tmpl/observability-log-metric-trace-runbook
audience: backend-engineer
target_publish:
status_label: ready
---
# Observability를 로그 한 줄이 아니라 운영 계약으로 보기
## Parent / 부모 (필수)
- 핵심 canonical: [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]]
- 관련 개념 문서: [[wiki/concepts/observability-log-metric-trace-runbook]] - 일반 observability 개념. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
## 타깃 독자 / Target reader
- 독자 profile: skeleton에 log/metric/trace/runbook baseline을 넣고 싶은 백엔드 엔지니어.
- 이미 안다고 가정하는 것: MDC, Micrometer, trace id, runbook.
- 처음 듣는다고 가정하는 것: 관측 가능성을 응답 `meta`, 로그 MDC, trace context, runbook의 연결 계약으로 보는 관점.
## 도입 / Hook
로그가 많다고 장애 대응이 쉬워지는 것은 아닙니다. 요청 id가 응답에는 있는데 로그에는 없거나, 로그에는 trace id가 있는데 client가 받은 error envelope에는 없거나, metric alert는 울렸는데 어떤 runbook을 봐야 하는지 연결되지 않으면 관측 데이터는 흩어진 조각이 됩니다.
ca-tmpl은 observability를 “로그 한 줄 찍기”가 아니라 연결 가능한 계약으로 다루려 했습니다. request id, trace id, correlation id를 MDC에 올리고, 응답 envelope의 `meta`에도 투영하며, inbound header는 sanitize하고, user principal은 pseudonymize해서 로그에 싣는 식입니다. 다만 project canonical 기준으로 전체 log/metric/trace/runbook 체계가 모두 구현된 것은 아닙니다. 이 글은 구현된 foundation slice와 아직 planned/documented-only인 부분을 나눠서 정리합니다.
## 본문 outline / Body outline
1. observability는 출력 포맷이 아니라 join 가능성이다.
2. requestId/traceId/correlationId와 envelope meta.
3. MDC key, header sanitizer, request logging filter.
4. logback JSON/MDC include와 masking/sampling의 구현 범위.
5. metric/trace/runbook 중 구현된 범위와 planned 범위.
6. 운영 장애 대응 효과와 alert tuning 검증은 없음.
## 본문 / Body
장애 상황에서 가장 먼저 필요한 것은 “이 응답이 어떤 로그와 이어지는가”입니다. client가 받은 실패 응답에 `requestId``traceId`가 있어도, 서버 로그에 같은 key가 없으면 검색이 끊깁니다. 반대로 로그에만 trace id가 있고 응답에는 없으면 client 문의에서 출발해 서버 이벤트로 들어가기 어렵습니다. ca-tmpl의 observability foundation은 이 연결을 기본 계약으로 둡니다.
구현의 중심에는 MDC key가 있습니다. `MdcKeys``request_id`, `trace_id`, `span_id`, `correlation_id`, `user_principal`을 snake_case로 정의합니다. `RequestLoggingFilter`는 inbound `X-Request-Id`, `X-Correlation-Id`, `traceparent`를 읽고, 없거나 유효하지 않으면 서버에서 생성합니다. 값은 MDC에 들어가고 response header에도 다시 설정됩니다. 그래서 request 처리 중 남는 로그와 client가 받은 header가 같은 id로 이어질 수 있습니다.
`ResponseMetaFactory`는 이 MDC 값을 API envelope의 `meta`로 투영합니다. 로그에서는 snake_case key를 쓰지만, JSON wire format은 `requestId`, `traceId`, `correlationId` camelCase record입니다. 이 작은 변환이 중요합니다. 로그의 key naming과 API contract naming을 억지로 같게 만들지 않고, 각 영역의 규칙을 유지한 채 mapping 지점을 명확히 둔 것입니다.
header는 그대로 믿지 않습니다. `HeaderSanitizer`는 inbound header value에서 `\r`, `\n`, ASCII control char를 제거하고 길이를 제한합니다. request id나 correlation id는 로그/MDC에 들어가므로 log injection을 피해야 합니다. `RequestLoggingFilter`는 user principal도 raw id를 MDC에 넣지 않고 `UserPrincipalPseudonymizerPort`를 거쳐 pseudonymized value만 싣습니다. 즉 “관측 가능하게 남긴다”와 “민감 정보를 그대로 남긴다”를 구분합니다.
logback 설정도 이 계약을 받쳐줍니다. local/dev는 사람이 읽기 쉬운 pattern layout을 쓰고, 그 외 profile은 structured JSON encoder에 MDC key를 포함합니다. 설정에는 `trace_id`, `span_id`, `request_id`, `correlation_id`, `user_principal` include가 명시되어 있습니다. 또한 masking converter/decorator와 sampling turbo filter, async appender 설정도 존재합니다. 다만 이 글에서 말할 수 있는 것은 코드와 local verification 범위입니다. production log pipeline에서의 실제 누락률이나 비용 절감 효과는 검증된 주장이 아닙니다.
traceparent 처리도 선을 분명히 해야 합니다. 현재 `RequestLoggingFilter`는 inbound W3C `traceparent`가 유효하면 채택하고, 없으면 fresh ROOT traceparent를 생성합니다. 이것은 trace id를 응답/log에 연결하기 위한 foundation입니다. 하지만 실제 distributed tracer가 붙어 span tree를 export하고, 5xx span을 ERROR로 기록하고, trace backend에서 검색된다는 주장까지는 별도 구현/운영 검증이 필요합니다.
metric과 runbook도 마찬가지입니다. project canonical에는 log/metric/trace/runbook을 하나의 운영 계약으로 보는 방향이 있지만, 모든 항목이 같은 증거 등급은 아닙니다. 구현된 foundation은 MDC/header/meta/logback 중심입니다. Prometheus alert, alert tuning, runbook link-check와 실제 incident response 효과는 documented-only 또는 planned 범위로 남아 있습니다. 따라서 이 글의 결론은 “운영 관측성이 완성됐다”가 아니라 “ca-tmpl은 응답 meta와 로그 context를 연결하는 관측성 foundation을 구현했다”입니다.
## 코드 예제 / Code samples (있다면)
```java
// 출처: [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]]
// 실제 파일: adapter-web/.../MdcKeys.java, ca-tmpl @f6fbd4e196b4
public final class MdcKeys {
public static final String REQUEST_ID = "request_id";
public static final String TRACE_ID = "trace_id";
public static final String SPAN_ID = "span_id";
public static final String CORRELATION_ID = "correlation_id";
public static final String USER_PRINCIPAL = "user_principal";
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]]
// 실제 파일: adapter-web/.../RequestLoggingFilter.java, ca-tmpl @f6fbd4e196b4
String requestId = resolveOrGenerate(req.getHeader("X-Request-Id"));
String correlationId = resolveOrGenerate(req.getHeader("X-Correlation-Id"));
res.setHeader("X-Request-Id", requestId);
res.setHeader("X-Correlation-Id", correlationId);
MDC.put(MdcKeys.REQUEST_ID, requestId);
MDC.put(MdcKeys.CORRELATION_ID, correlationId);
TraceParent traceParent = resolveOrGenerateTraceParent(req.getHeader("traceparent"));
MDC.put(MdcKeys.TRACE_ID, traceParent.traceId());
MDC.put(MdcKeys.SPAN_ID, traceParent.spanId());
res.setHeader("traceparent", traceParent.toHeader());
```
```java
// 출처: [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]]
// 실제 파일: adapter-web/.../HeaderSanitizer.java, ca-tmpl @f6fbd4e196b4
public static String sanitize(String raw, int maxLength) {
if (raw == null) {
return null;
}
StringBuilder sb = new StringBuilder(Math.min(raw.length(), maxLength));
for (int i = 0; i < raw.length() && sb.length() < maxLength; i++) {
char c = raw.charAt(i);
if (c >= 0x20) {
sb.append(c);
}
}
return sb.toString();
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]]
// 실제 파일: adapter-web/.../ResponseMetaFactory.java, ca-tmpl @f6fbd4e196b4
public static ResponseMeta fromMdc() {
return new ResponseMeta(
MDC.get(MdcKeys.REQUEST_ID), MDC.get(MdcKeys.TRACE_ID), MDC.get(MdcKeys.CORRELATION_ID));
}
```
```xml
<!-- 출처: [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]] -->
<!-- 실제 파일: app-bootstrap/src/main/resources/logback-spring.xml, ca-tmpl @f6fbd4e196b4 -->
<includeMdcKeyName>trace_id</includeMdcKeyName>
<includeMdcKeyName>span_id</includeMdcKeyName>
<includeMdcKeyName>request_id</includeMdcKeyName>
<includeMdcKeyName>correlation_id</includeMdcKeyName>
<includeMdcKeyName>user_principal</includeMdcKeyName>
```
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
- [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]] - 이 글의 1차 canonical. MDC/header/meta/logback foundation, local verification, planned/documented-only 항목 경계를 따른다.
- [[wiki/concepts/observability-log-metric-trace-runbook]] - 관련 개념 문서. 로그, metric, trace, runbook의 일반 개념 배경으로만 둔다.
## 사실 vs 의견 / Fact vs opinion 구분
- 사실: ca-tmpl에는 `MdcKeys`, `HeaderSanitizer`, `RequestLoggingFilter`, `ResponseMetaFactory`, `ResponseMeta`, `logback-spring.xml` MDC include 설정이 존재한다. 근거: [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]]
- 사실: inbound request/correlation id sanitize, traceparent 채택/생성, response header 설정, envelope meta projection은 구현된 foundation 범위다. 근거: [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]]
- 사실: production alert tuning, 실제 incident response 효과, trace backend export 검증, runbook 운영 검증은 project canonical 기준으로 구현/운영 검증 범위가 아니다. 근거: [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]]
- 의견: observability를 “데이터 출력”보다 “서로 join 가능한 운영 계약”으로 설명하면 skeleton 설계 의도가 더 잘 드러난다.
- 알지 못하는 것: 실제 alert fatigue, log volume/cost 변화, production trace 검색 성공률.
## 답할 수 있는 범위 / Answer boundary
- 자신 있게 답할 수 있는 후속 질문:
- response meta와 log context를 왜 연결하는가?
- snake_case MDC와 camelCase JSON meta를 왜 분리하는가?
- inbound header sanitize와 principal pseudonymization은 어떤 위험을 줄이는가?
- 다음 글로 넘길 부분:
- production alert threshold.
- OpenTelemetry exporter와 trace backend 운영.
- runbook link-check와 incident review 결과.
## 게시 체크리스트 / Publish checklist
- [x] 모든 사실 주장에 canonical 링크 있음
- [x] 사실 vs 의견 분리 명시됨
- [x] 금지 마케팅 표현 없음
- [x] 코드 예제 출처 명시
- [x] 타깃 독자 가정과 톤 일치
- [x] `/lint` 통과
- [ ] 게시 URL 기록 (게시 후):
## Related / 관련
- 후속 글 후보: [[wiki/blog/ca-tmpl-api-error-envelope-design-2026-07-02]]
- 후속 글 후보: [[wiki/blog/ca-tmpl-security-baseline-jwt-actuator-secrets-2026-07-02]]
@@ -0,0 +1,152 @@
---
title: Privacy와 File Handling을 Domain Modeling과 함께 보기
source_type: blog
status: verified
confidence: high
tags: [blog, ca-tmpl, privacy, file-upload, domain-modeling]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-03
canonical_sources:
- wiki/projects/ca-tmpl/privacy-file-domain-modeling
audience: backend-engineer
target_publish:
status_label: ready
---
# Privacy와 File Handling을 Domain Modeling과 함께 보기
## Parent / 부모 (필수)
- 핵심 canonical: [[wiki/projects/ca-tmpl/privacy-file-domain-modeling]]
- 관련 개념 문서: [[wiki/concepts/privacy-file-domain-modeling]] - privacy, file upload, DDD guardrail의 일반 배경. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
## 타깃 독자 / Target reader
- 독자 profile: skeleton에서 privacy, file upload, domain modeling guardrail을 함께 고민하는 백엔드 엔지니어.
- 이미 안다고 가정하는 것: PII, file upload, DDD aggregate.
- 처음 듣는다고 가정하는 것: privacy/file/domain을 각각 따로 구현하지 않고 “어떤 값이 어디에 남으면 안 되는가”라는 모델링 경계로 연결하는 관점.
## 도입 / Hook
Privacy는 보안팀 문서처럼 보이고, file upload는 adapter 이슈처럼 보이며, domain modeling은 DDD 문법처럼 보입니다. 그런데 실제 프로젝트에서는 세 가지가 자주 엮입니다. 사용자의 원본 식별자가 로그에 남으면 안 되고, upload payload는 app/proxy/gateway 어디에서 막을지 정해야 하며, domain model은 ORM이나 logger에 오염되지 않아야 합니다.
ca-tmpl은 이 세 축을 한 문서에 묶었지만, 구현 범위는 균등하지 않습니다. HMAC 기반 principal pseudonymization, MDC logging path, domain purity/aggregate/value object guardrail은 코드와 테스트가 있습니다. 반면 file upload handler, DSR workflow, backup cryptographic erasure, ICAP integration은 아직 문서/계획 범위입니다. 이 글은 이 차이를 숨기지 않고, privacy와 modeling을 함께 보는 이유를 정리합니다.
## 본문 outline / Body outline
1. privacy/file/domain modeling을 같은 문서에서 보는 이유.
2. 구현된 privacy foundation - principal pseudonymization과 MDC logging.
3. 구현된 domain guardrail - pure domain, logger ban, value object, aggregate setter rule.
4. file handling과 DSR은 아직 planned 범위다.
5. compliance/운영 검증은 없다.
## 본문 / Body
Privacy의 첫 번째 실수는 “민감 정보를 나중에 마스킹하면 된다”고 생각하는 것입니다. 하지만 로그에 raw principal이 들어가면, 이후 retention이나 DSR을 논의하기 전에 이미 추적 가능한 식별자가 퍼져 있습니다. ca-tmpl은 request logging path에서 raw principal을 바로 MDC에 넣지 않고, `UserPrincipalPseudonymizerPort`를 거친 pseudonymized value만 `user_principal` key에 넣습니다.
구현체는 `HmacUserPrincipalPseudonymizer`입니다. 입력 principal을 HMAC-SHA-256으로 64자 lowercase hex token으로 바꿉니다. `PseudonymizationConfig`는 이 구현체를 `UserPrincipalPseudonymizerPort` bean으로 제공합니다. `PrivacySettings``ca-skeleton.privacy.*` 설정에서 salt를 읽고, local/test에서 blank salt면 dev sentinel을 사용합니다. 이 흐름은 “raw subject를 로그에 그대로 쓰지 않는다”는 최소 foundation입니다.
다만 이것을 anonymization이라고 부르면 안 됩니다. HMAC token은 같은 salt에서 같은 입력을 안정적으로 같은 token으로 만들기 때문에, 추적 가능성을 줄이는 pseudonymization에 가깝습니다. input space가 작으면 brute-force 위험도 남습니다. 또한 backup 안에 이미 남은 값을 단건 삭제하는 GDPR Art.17 문제까지 해결하지 않습니다. project canonical도 per-principal envelope key 구조는 미결정이라고 명시합니다.
Domain modeling guardrail은 privacy와 다른 문제처럼 보이지만, 같은 방향을 봅니다. domain layer가 logger를 직접 잡으면 domain invariant violation이 곧바로 log payload가 될 수 있습니다. domain class가 JPA annotation이나 framework type에 묶이면 persistence detail이 domain boundary로 들어옵니다. ca-tmpl의 `CleanArchitectureTest`는 domain package가 Spring/JPA/Hibernate/Lombok/application/adapter/bootstrap에 의존하지 못하게 하고, domain logger dependency도 별도 rule로 금지합니다.
Value Object와 Aggregate Root rule도 있습니다. `@ValueObject`는 public no-arg constructor를 금지합니다. 값 객체가 빈 생성자로 만들어지고 나중에 setter로 채워지면 invariant를 우회할 수 있기 때문입니다. `@AggregateRoot`에는 public `set*` mutator를 금지합니다. aggregate state는 의도가 드러나는 method를 통해 바뀌어야 하고, 그 method가 invariant를 확인해야 합니다.
File handling 쪽은 아직 구현됐다고 말하면 안 됩니다. app 10MB, proxy 12MB, gateway 20MB 같은 size limit, content-type allowlist, temp orphan cleanup, ICAP antivirus gateway는 project canonical에 문서화되어 있지만 file upload handler나 scan integration으로 닫힌 상태가 아닙니다. DSR SLA, backup cryptographic erasure도 마찬가지입니다. 설계 방향은 있지만, 실제 요청 처리 workflow나 법무/compliance review가 있는 것은 아닙니다.
그래서 이 글의 결론은 조심스럽습니다. ca-tmpl은 privacy/file/domain 전체를 완성한 것이 아닙니다. 구현된 것은 pseudonymized principal logging foundation과 domain modeling guardrail 일부입니다. file upload, DSR, backup erasure는 후속 구현이 필요합니다. 하지만 세 축을 함께 보는 관점은 유효합니다. 어떤 값이 어디에 남는지, 어떤 계층이 어떤 타입을 알 수 있는지, 어떤 construction path가 invariant를 우회하는지 모두 결국 boundary 문제이기 때문입니다.
## 코드 예제 / Code samples (있다면)
```java
// 출처: [[wiki/projects/ca-tmpl/privacy-file-domain-modeling]]
// 실제 파일: adapter-identifier/.../HmacUserPrincipalPseudonymizer.java, ca-tmpl @f6fbd4e196b4
public String pseudonymize(String rawPrincipal) {
if (rawPrincipal == null || rawPrincipal.isBlank()) {
return null;
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(key);
byte[] digest = mac.doFinal(rawPrincipal.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/privacy-file-domain-modeling]]
// 실제 파일: adapter-web/.../RequestLoggingFilter.java, ca-tmpl @f6fbd4e196b4
private void putUserPrincipalIfAvailable() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof AuthenticatedPrincipal user) {
String pseudo = pseudonymizer.pseudonymize(user.idpUserId());
if (pseudo != null) {
MDC.put(MdcKeys.USER_PRINCIPAL, pseudo);
}
}
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/privacy-file-domain-modeling]]
// 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4
static final ArchRule DOMAIN_IS_PURE =
noClasses()
.that()
.resideInAPackage("..domain..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("org.springframework..", "jakarta.persistence..", "org.hibernate..");
```
```java
// 출처: [[wiki/projects/ca-tmpl/privacy-file-domain-modeling]]
// 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4
static final ArchRule AGGREGATE_ROOT_SETTERS_ARE_NOT_PUBLIC =
methods()
.that()
.haveNameMatching("set.*")
.and()
.areDeclaredInClassesThat()
.areAnnotatedWith(AggregateRoot.class)
.should()
.notBePublic();
```
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
- [[wiki/projects/ca-tmpl/privacy-file-domain-modeling]] - 이 글의 1차 canonical. pseudonymization/logging path, domain guardrail, file/DSR/backup planned 범위, 운영/법무 미검증 경계를 따른다.
- [[wiki/concepts/privacy-file-domain-modeling]] - 관련 개념 문서. GDPR, cryptographic erase, file upload, DDD guardrail의 일반 배경으로만 둔다.
## 사실 vs 의견 / Fact vs opinion 구분
- 사실: ca-tmpl에는 `HmacUserPrincipalPseudonymizer`, `PseudonymizationConfig`, `PrivacySettings`, `RequestLoggingFilter`의 pseudonymized principal logging path가 존재한다. 근거: [[wiki/projects/ca-tmpl/privacy-file-domain-modeling]]
- 사실: domain purity, domain logger ban, value object no public no-arg constructor, aggregate setter visibility rule이 `CleanArchitectureTest` 계열에 존재한다. 근거: [[wiki/projects/ca-tmpl/privacy-file-domain-modeling]]
- 사실: file upload handler, DSR workflow, backup cryptographic erasure, ICAP integration은 구현/로컬 검증 범위가 아니다. 근거: [[wiki/projects/ca-tmpl/privacy-file-domain-modeling]]
- 의견: privacy와 domain modeling은 서로 다른 주제처럼 보여도 “값이 어디에 남고 어떤 경계가 우회되는가”라는 같은 질문으로 연결된다.
- 알지 못하는 것: 실제 GDPR DSR 처리, legal review, antivirus gateway 운영, backup erasure 검증.
## 답할 수 있는 범위 / Answer boundary
- 자신 있게 답할 수 있는 후속 질문:
- HMAC pseudonymization이 anonymization이 아닌 이유.
- raw principal을 MDC에 직접 쓰지 않는 이유.
- domain layer logger ban과 aggregate/value object guardrail이 어떤 우회를 막는가.
- 다음 글로 넘길 부분:
- file upload handler와 antivirus integration.
- DSR workflow와 backup cryptographic erasure.
- compliance review와 production privacy workflow.
## 게시 체크리스트 / Publish checklist
- [x] 모든 사실 주장에 canonical 링크 있음
- [x] 사실 vs 의견 분리 명시됨
- [x] 금지 마케팅 표현 없음
- [x] 코드 예제 출처 명시
- [x] 타깃 독자 가정과 톤 일치
- [x] `/lint` 통과
- [ ] 게시 URL 기록 (게시 후):
## Related / 관련
- 후속 글 후보: [[wiki/blog/ca-tmpl-clean-architecture-package-layout-2026-07-02]]
- 후속 글 후보: [[wiki/blog/ca-tmpl-observability-log-metric-trace-runbook-2026-07-02]]
@@ -0,0 +1,160 @@
---
title: Resource Identifier를 UUID 대신 ULID로 고정한 이유
source_type: blog
status: verified
confidence: high
tags: [blog, ca-tmpl, resource-identifier, ulid]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-03
canonical_sources:
- wiki/projects/ca-tmpl/resource-identifier-format
audience: backend-engineer
target_publish:
status_label: ready
---
# Resource Identifier를 UUID 대신 ULID로 고정한 이유
## Parent / 부모 (필수)
- 핵심 canonical: [[wiki/projects/ca-tmpl/resource-identifier-format]]
- 관련 개념 문서: [[wiki/concepts/resource-identifier-format]] - 일반 identifier format 비교. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
## 타깃 독자 / Target reader
- 독자 profile: API resource id 규칙을 skeleton 수준에서 정하려는 백엔드 엔지니어.
- 이미 안다고 가정하는 것: UUID, database primary key, API id.
- 처음 듣는다고 가정하는 것: ULID의 표기 규칙, 생성 위치, id-kind governance를 architecture rule로 고정하는 방식.
## 도입 / Hook
API의 id는 처음에는 단순한 문자열처럼 보입니다. 하지만 id가 어디에서 생성되는지, 어떤 형태로 외부에 노출되는지, DB에는 어떤 타입으로 저장되는지, 어떤 계층이 id를 만들 수 있는지까지 정하지 않으면 나중에 작은 균열이 생깁니다. controller에서 `UUID.randomUUID()`를 부르고, 다른 use case에서는 DB sequence를 쓰고, 또 다른 API는 문자열 id를 그대로 반환하는 식입니다.
ca-tmpl은 이 문제를 “UUID냐 ULID냐”의 취향 싸움으로만 보지 않았습니다. 외부 API에는 26자 대문자 Crockford base32 ULID를 노출하고, domain은 id를 value object로 다루며, 생성은 adapter port 뒤로 숨기고, persistence는 PostgreSQL `uuid` column으로 저장하는 계약으로 묶었습니다. 이 글은 그 결정이 왜 필요했는지, 어떤 코드로 고정됐는지, 그리고 아직 검증했다고 말하면 안 되는 부분이 무엇인지 정리합니다.
## 본문 outline / Body outline
1. identifier를 나중에 정하면 생기는 문제.
2. ULID를 API 표기 규칙으로 선택한 이유와 trade-off.
3. domain value object, generation port, adapter module의 역할 분리.
4. persistence와 wire format의 분리.
5. ArchUnit rule로 id governance를 고정한 범위.
6. 운영 규모에서의 index/locality 검증은 없음.
## 본문 / Body
Resource id 설계에서 먼저 정해야 하는 것은 “id 값이 무엇인가”보다 “누가 id를 만들 수 있는가”입니다. controller가 직접 `UUID.randomUUID()`를 호출하면 use case마다 생성 방식이 갈라질 수 있습니다. application service가 라이브러리에 직접 의존하면 domain model은 순수해 보여도 use case가 infrastructure detail을 알고 있게 됩니다. DB가 id 생성을 전담하면 API에 노출되는 id format과 persistence type이 묶입니다.
ca-tmpl은 이 지점을 domain port로 끊었습니다. domain-core에는 `ResourceId` marker와 `IdFactory<T>` port가 있고, sample domain에는 `WorkLogId``WorkLogIdFactory`가 있습니다. `WorkLogId`는 26자 대문자 Crockford base32 ULID 문자열만 받는 value object입니다. domain은 ULID library를 직접 알지 않습니다. 실제 생성은 `adapter-identifier` module의 `UlidWorkLogIdFactory`가 맡습니다. 이렇게 하면 domain은 “id shape”만 알고, “id를 어떻게 mint하는가”는 adapter가 책임집니다.
ULID를 고른 이유는 API 표기와 정렬성의 균형입니다. ULID는 26자 문자열이라 URL path에 넣기 쉽고, 시간 성분이 앞에 있어 생성 시점 기준 정렬 가능성이 있습니다. ca-tmpl에서는 이를 외부 wire format으로 삼았습니다. 다만 이 말이 곧 “모든 DB에서 insert 성능이 검증됐다”는 뜻은 아닙니다. project canonical은 PostgreSQL index locality benchmark가 없다고 명시합니다. 이 글도 그 선을 넘지 않습니다.
재미있는 부분은 DB 저장 방식입니다. API와 domain에서는 ULID 문자열을 쓰지만, JPA entity는 `UUID` field를 PostgreSQL native `uuid` column에 저장합니다. `UlidCodec`이 ULID 문자열과 UUID 사이 변환을 맡고, persistence mapper가 domain `WorkLogId`와 entity `UUID` 사이를 변환합니다. 즉 외부 계약은 “대문자 ULID 문자열”이고, DB 저장 계약은 “native uuid type”입니다. 두 계약을 같은 문자열 column으로 합쳐버리지 않은 셈입니다.
wire format도 별도로 고정했습니다. Java record인 `WorkLogId`를 그대로 Jackson이 직렬화하면 `{ "value": "..." }` 형태가 될 수 있습니다. ca-tmpl은 `WorkLogIdSerializer`를 두어 응답에서는 bare ULID string이 나가도록 했습니다. 이 결정 덕분에 API 소비자는 id field를 객체가 아니라 문자열로 다룹니다. 내부 value object와 외부 JSON shape를 분리한 것입니다.
id governance는 ArchUnit rule로도 고정되어 있습니다. domain entity의 `id` field는 `ResourceId`여야 하고, controller/application layer는 resource id 생성을 위해 `UUID.randomUUID()``UlidCreator`에 직접 닿지 않아야 합니다. `Math.random()`도 id seed로 쓰지 못하게 막습니다. JPA `@Column`으로 매핑된 id field가 기본 `varchar(255)`로 떨어지는 것도 금지합니다. 또 `adapter-identifier`는 sibling adapter나 bootstrap에 의존하지 못합니다.
여기까지가 ca-tmpl이 실제로 구현하고 로컬 검증한 범위입니다. `adapter-identifier` module, `WorkLogId`, `UlidCodec`, persistence mapper, JSON serializer, architecture rule과 테스트가 존재합니다. 반면 CUID2 override, multi-tenancy까지 포함한 id scoping, log scrubber, PostgreSQL index benchmark는 구현됐다고 말하면 안 됩니다. 이 글의 결론은 “ULID가 어디서나 이긴다”가 아니라, “ca-tmpl은 id format을 API/Domain/Persistence/Architecture rule까지 이어지는 계약으로 만들었다”입니다.
## 코드 예제 / Code samples (있다면)
```java
// 출처: [[wiki/projects/ca-tmpl/resource-identifier-format]]
// 실제 파일: domain-core/.../ResourceId.java, ca-tmpl @f6fbd4e196b4
public interface ResourceId<SELF extends ResourceId<SELF>> {
/** The canonical 26-character uppercase Crockford base32 ULID string. */
String value();
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/resource-identifier-format]]
// 실제 파일: sample-portfolio/.../WorkLogId.java, ca-tmpl @f6fbd4e196b4
public record WorkLogId(String value) implements ResourceId<WorkLogId> {
private static final Pattern PATTERN = Pattern.compile("^[0-9A-HJKMNP-TV-Z]{26}$");
public WorkLogId {
if (value == null || !PATTERN.matcher(value).matches()) {
throw new IllegalArgumentException("Invalid WorkLogId format: " + value);
}
}
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/resource-identifier-format]]
// 실제 파일: adapter-identifier/.../UlidCodec.java, ca-tmpl @f6fbd4e196b4
public static String normalize(String input) {
if (input == null) {
return null;
}
return Ulid.from(input.toUpperCase(Locale.ROOT)).toString();
}
public static UUID toUuid(String ulidString) {
return Ulid.from(ulidString).toUuid();
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/resource-identifier-format]]
// 실제 파일: sample-portfolio/.../WorkLogEntity.java, ca-tmpl @f6fbd4e196b4
@Id
@Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false)
@JdbcTypeCode(SqlTypes.UUID)
private UUID id;
```
```java
// 출처: [[wiki/projects/ca-tmpl/resource-identifier-format]]
// 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4
static final ArchRule NO_UUID_RANDOM_IN_CONTROLLER =
noClasses()
.that()
.resideInAnyPackage("..adapter.web..controller..", "..application..")
.should()
.callMethod(UUID.class, "randomUUID")
.orShould()
.dependOnClassesThat()
.haveFullyQualifiedName("com.github.f4b6a3.ulid.UlidCreator");
```
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
- [[wiki/projects/ca-tmpl/resource-identifier-format]] - 이 글의 1차 canonical. ULID wire format, `ResourceId`, `IdFactory`, `adapter-identifier`, persistence UUID column, ArchUnit rule, local verification, 미구현 항목 경계를 따른다.
- [[wiki/concepts/resource-identifier-format]] - 관련 개념 문서. UUIDv7/ULID/Snowflake/NanoID/CUID2 등 일반 비교를 위한 배경으로만 둔다.
## 사실 vs 의견 / Fact vs opinion 구분
- 사실: ca-tmpl에는 `ResourceId`, `IdFactory`, `WorkLogId`, `WorkLogIdFactory`, `UlidWorkLogIdFactory`, `UlidCodec`, `WorkLogEntity`, `WorkLogPersistenceMapper`, `WorkLogIdSerializer`가 존재한다. 근거: [[wiki/projects/ca-tmpl/resource-identifier-format]]
- 사실: `adapter-identifier` module과 identifier 관련 ArchUnit rule이 존재하고, project canonical은 이를 local verification 범위로 기록한다. 근거: [[wiki/projects/ca-tmpl/resource-identifier-format]]
- 사실: PostgreSQL uuid index locality benchmark, CUID2 override, multi-tenancy id scoping, `UlidLogScrubber`는 구현/운영 검증으로 말하지 않는다. 근거: [[wiki/projects/ca-tmpl/resource-identifier-format]]
- 의견: ca-tmpl 같은 skeleton에서는 id를 단순 primitive로 두는 것보다 value object와 generation port로 고정하는 편이 이후 boundary rule을 설명하기 쉽다.
- 알지 못하는 것: production insert/index metric, tenant별 id collision/lookup 운영 결과.
## 답할 수 있는 범위 / Answer boundary
- 자신 있게 답할 수 있는 후속 질문:
- ca-tmpl에서 resource id 생성이 왜 adapter port 뒤에 있는가?
- API에는 ULID string을 노출하면서 DB에는 왜 native `uuid` column을 쓰는가?
- 어떤 ArchUnit rule이 id 생성 위치와 id column mapping을 막는가?
- 다음 글로 넘길 부분:
- sharding/partitioning 환경의 id 전략.
- PostgreSQL index locality benchmark.
- multi-tenant id scoping과 tenant-aware repository rule.
## 게시 체크리스트 / Publish checklist
- [x] 모든 사실 주장에 canonical 링크 있음
- [x] 사실 vs 의견 분리 명시됨
- [x] 금지 마케팅 표현 없음
- [x] 코드 예제 출처 명시
- [x] 타깃 독자 가정과 톤 일치
- [x] `/lint` 통과
- [ ] 게시 URL 기록 (게시 후):
## Related / 관련
- 후속 글 후보: [[wiki/blog/ca-tmpl-api-evolution-and-schema-2026-07-02]]
- 후속 글 후보: [[wiki/blog/ca-tmpl-multi-tenancy-isolation-patterns-2026-07-02]]
@@ -0,0 +1,166 @@
---
title: Runtime 설정 오류를 Startup에서 실패시키기
source_type: blog
status: verified
confidence: high
tags: [blog, ca-tmpl, runtime, container, health, migration]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-03
canonical_sources:
- wiki/projects/ca-tmpl/runtime-container-health-migration
audience: backend-engineer
target_publish:
status_label: ready
---
# Runtime 설정 오류를 Startup에서 실패시키기
## Parent / 부모 (필수)
- 핵심 canonical: [[wiki/projects/ca-tmpl/runtime-container-health-migration]]
- 관련 개념 문서: [[wiki/concepts/runtime-container-health-migration]] - 일반 runtime/container/health/migration 개념. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
## 타깃 독자 / Target reader
- 독자 profile: container readiness, health check, migration, runtime config guard를 skeleton에 넣고 싶은 엔지니어.
- 이미 안다고 가정하는 것: Docker/Kubernetes probe, Flyway, env config, Spring Boot Actuator.
- 처음 듣는다고 가정하는 것: runtime safety를 startup validator, health group, migration strategy, exit code로 묶는 방식.
## 도입 / Hook
운영 설정 오류는 배포 뒤에 늦게 발견될수록 비쌉니다. pool size가 음수로 들어가거나, `open-in-view`가 켜지거나, prod profile에서 Flyway safety option이 풀린 상태로 애플리케이션이 올라오면, 문제는 요청을 받기 시작한 뒤에 드러날 수 있습니다.
ca-tmpl은 이런 오류를 startup 단계에서 실패시키는 방향으로 runtime contract를 잡았습니다. env-driven configuration을 쓰되 잘못된 값은 lenient default로 숨기지 않고, health group은 liveness/readiness/startup 역할을 나누며, Flyway migration 실패는 startup failure와 exit code로 드러냅니다. 이 글은 ca-tmpl에 실제로 구현된 runtime/container/health/migration baseline과 아직 운영 검증으로 말하면 안 되는 범위를 정리합니다.
## 본문 outline / Body outline
1. startup fail-fast의 가치.
2. runtime numeric bounds와 OSIV/Hikari guard.
3. health probe group split과 readiness gate.
4. Flyway migration startup contract와 exit code.
5. container image/runtime baseline.
6. Kubernetes cluster rollout 검증은 없음.
## 본문 / Body
runtime 설정은 코드보다 덜 중요해 보이지만, 실제로는 애플리케이션의 동작 경계를 바꿉니다. DB pool max size, Tomcat thread count, shutdown timeout, Flyway option, Actuator exposure는 모두 장애 양상을 바꿀 수 있습니다. 그래서 ca-tmpl은 “값이 이상하면 프레임워크 기본값으로 알아서 흘러가게 둔다”보다 “startup에서 실패한다”는 쪽을 택했습니다.
`RuntimeNumericBoundsValidator`는 대표적인 예입니다. `spring.datasource.hikari.maximum-pool-size`, `server.tomcat.threads.max`, `server.tomcat.max-connections` 같은 값은 1 이상이어야 하고, minimum idle이나 accept count처럼 0을 허용하는 값은 0 이상이어야 합니다. key가 없으면 framework default에 맡기지만, key가 있는데 범위를 벗어나면 `IllegalStateException`으로 startup을 막습니다. env-driven 설정을 쓰면서도 잘못된 env 값을 조용히 묻지 않는 장치입니다.
OSIV와 Hikari 설정도 별도 guard로 다룹니다. `OpenInViewSafetyValidator``spring.jpa.open-in-view=true`를 거부합니다. Hikari validator는 `connection-timeout`, `validation-timeout`, `keepalive-time`, `max-lifetime`, `leak-detection-threshold`의 상호 관계를 검사합니다. 예를 들어 validation timeout이 connection timeout보다 길면 pool 동작을 예측하기 어려워집니다. ca-tmpl은 이런 값을 요청 처리 뒤의 증상으로 발견하기보다 startup에서 configuration error로 드러내려 합니다.
health check는 endpoint 하나로 뭉개지 않습니다. `application.yml`에는 Actuator health group이 `liveness`, `readiness`, `startup`으로 나뉘어 있습니다. liveness는 JVM이 계속 살아갈 수 있는지를 보며 dependency health를 포함하지 않습니다. DB가 잠깐 내려갔다고 pod를 재시작하는 것은 보통 원하는 동작이 아니기 때문입니다. readiness는 traffic을 받아도 되는지를 판단하므로 `readinessState,db`를 포함합니다. startup은 context initialization과 migration 완료 후 준비 상태를 드러내는 gate로 둡니다.
Flyway도 startup contract의 일부입니다. `MigrationStartupRunner`는 context refresh 중 Flyway migration을 수행하고, 실패하면 `MigrationFailedException` 계열로 바꿔 exit code 70에 연결합니다. `StartupErrorCode`에는 startup validation, migration failure, profile mismatch, required adapter disabled가 각각 다른 exit code와 phase로 정의되어 있습니다. 실패 원인을 process exit status와 structured startup log에서 분리해 보려는 설계입니다.
prod profile의 Flyway safety guard도 들어 있습니다. `FlywayProdSafetyValidator`는 prod에서 `baseline-on-migrate=true`, `out-of-order=true`, `clean-disabled=false` 같은 위험한 override를 막습니다. 여기서 중요한 것은 “Flyway를 쓰면 안전하다”가 아닙니다. migration 도구를 쓰더라도 prod에서 안전망을 푸는 설정이 들어오면 애플리케이션이 올라오지 않게 만드는 것입니다.
container/runtime baseline도 project canonical에 포함되어 있습니다. `src/Dockerfile`, graceful shutdown 설정, Actuator health group, startup validator, migration strategy가 묶여 있습니다. 그러나 실제 Kubernetes manifest, rolling update, probe tuning, cluster에서의 rollout incident 검증은 없습니다. 따라서 이 글은 “Kubernetes 운영에서 검증된 lifecycle 설계”가 아니라 “ca-tmpl이 startup fail-fast와 health/migration baseline을 코드와 설정으로 고정하고 로컬 검증했다”까지 말합니다.
## 코드 예제 / Code samples (있다면)
```java
// 출처: [[wiki/projects/ca-tmpl/runtime-container-health-migration]]
// 실제 파일: app-bootstrap/.../RuntimeNumericBoundsValidator.java, ca-tmpl @f6fbd4e196b4
static final List<Bound> BOUNDS =
List.of(
new Bound("spring.datasource.hikari.maximum-pool-size", "APP_DATASOURCE_POOL_MAX_SIZE", 1),
new Bound("server.tomcat.threads.max", "APP_SERVER_TOMCAT_MAX_THREADS", 1),
new Bound("server.tomcat.max-connections", "APP_SERVER_TOMCAT_MAX_CONNECTIONS", 1),
new Bound("spring.datasource.hikari.minimum-idle", "APP_DATASOURCE_POOL_MIN_IDLE", 0),
new Bound("server.tomcat.accept-count", "APP_SERVER_TOMCAT_ACCEPT_COUNT", 0));
```
```java
// 출처: [[wiki/projects/ca-tmpl/runtime-container-health-migration]]
// 실제 파일: app-bootstrap/.../StartupErrorCode.java, ca-tmpl @f6fbd4e196b4
public enum StartupErrorCode {
STARTUP_VALIDATION_FAILED(78, StartupPhase.ENV_VALIDATION),
MIGRATION_FAILED(70, StartupPhase.MIGRATION),
PROFILE_MISMATCH(71, StartupPhase.PROFILE_CHECK),
REQUIRED_ADAPTER_DISABLED(72, StartupPhase.ADAPTER_ENABLEMENT);
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/runtime-container-health-migration]]
// 실제 파일: app-bootstrap/.../FlywayProdSafetyValidator.java, ca-tmpl @f6fbd4e196b4
if (isTrue(BASELINE_ON_MIGRATE_KEY)) {
violations.add(BASELINE_ON_MIGRATE_KEY + "=true (removes the missing-migration safety net)");
}
if (isTrue(OUT_OF_ORDER_KEY)) {
violations.add(OUT_OF_ORDER_KEY + "=true (breaks migration ordering consistency)");
}
if (isFalse(CLEAN_DISABLED_KEY)) {
violations.add(CLEAN_DISABLED_KEY + "=false (re-arms destructive Flyway clean)");
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/runtime-container-health-migration]]
// 실제 파일: app-bootstrap/.../MigrationStartupRunner.java, ca-tmpl @f6fbd4e196b4
try {
MigrateResult result = flyway.migrate();
int executed = (result != null) ? result.migrationsExecuted : 0;
log.info("startup phase {}: migration complete, {} migration(s) applied",
kv("startup.phase", StartupPhase.MIGRATION.wireName()), executed);
} catch (FlywayException e) {
throw StartupFailures.migrationFailed("Flyway forward-only migration failed during startup", e);
}
```
```yaml
# 출처: [[wiki/projects/ca-tmpl/runtime-container-health-migration]]
# 실제 파일: app-bootstrap/src/main/resources/application.yml, ca-tmpl @f6fbd4e196b4
management:
endpoint:
health:
probes:
enabled: true
group:
liveness:
include: livenessState
readiness:
include: readinessState,db
startup:
include: readinessState
```
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
- [[wiki/projects/ca-tmpl/runtime-container-health-migration]] - 이 글의 1차 canonical. startup validators, Actuator health group, Flyway startup migration/safety guard, exit code mapping, local verification, 운영 미검증 경계를 따른다.
- [[wiki/concepts/runtime-container-health-migration]] - 관련 개념 문서. container lifecycle, health probe, migration strategy 일반 배경으로만 둔다.
## 사실 vs 의견 / Fact vs opinion 구분
- 사실: ca-tmpl에는 runtime numeric bounds validator, OSIV/Hikari safety validator, Actuator health group 설정, Flyway startup migration strategy, prod safety validator, startup exit code mapping이 존재한다. 근거: [[wiki/projects/ca-tmpl/runtime-container-health-migration]]
- 사실: startup validation과 migration failure는 local/dev verification 범위로 기록되어 있다. 근거: [[wiki/projects/ca-tmpl/runtime-container-health-migration]]
- 사실: Kubernetes manifest, rolling update, production probe tuning, cluster-level incident 검증은 없다. 근거: [[wiki/projects/ca-tmpl/runtime-container-health-migration]]
- 의견: skeleton에서는 잘못된 runtime env를 lenient default로 흘리는 것보다 startup에서 실패시키는 쪽이 학습과 운영 설명에 유리하다.
- 알지 못하는 것: 실제 orchestrator rollout behavior, migration lock contention, production shutdown latency.
## 답할 수 있는 범위 / Answer boundary
- 자신 있게 답할 수 있는 후속 질문:
- ca-tmpl은 어떤 runtime config 오류를 startup에서 막는가?
- liveness와 readiness health group을 왜 나누는가?
- Flyway migration 실패가 어떻게 startup failure와 exit code로 연결되는가?
- 다음 글로 넘길 부분:
- Kubernetes production probe tuning.
- rolling update와 graceful shutdown 실측.
- DB migration 운영 runbook과 장애 복구 사례.
## 게시 체크리스트 / Publish checklist
- [x] 모든 사실 주장에 canonical 링크 있음
- [x] 사실 vs 의견 분리 명시됨
- [x] 금지 마케팅 표현 없음
- [x] 코드 예제 출처 명시
- [x] 타깃 독자 가정과 톤 일치
- [x] `/lint` 통과
- [ ] 게시 URL 기록 (게시 후):
## Related / 관련
- 후속 글 후보: [[wiki/blog/ca-tmpl-config-and-adapter-templates-2026-07-02]]
- 후속 글 후보: [[wiki/blog/ca-tmpl-devops-ci-supply-chain-dx-2026-07-02]]
@@ -0,0 +1,158 @@
---
title: Sample Fixture를 버리는 예제가 아니라 Adoption 계약으로 만들기
source_type: blog
status: verified
confidence: high
tags: [blog, ca-tmpl, sample-fixture, adoption]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-03
canonical_sources:
- wiki/projects/ca-tmpl/sample-fixture-and-adoption
audience: backend-engineer
target_publish:
status_label: ready
---
# Sample Fixture를 버리는 예제가 아니라 Adoption 계약으로 만들기
## Parent / 부모 (필수)
- 핵심 canonical: [[wiki/projects/ca-tmpl/sample-fixture-and-adoption]]
- 관련 개념 문서: [[wiki/concepts/sample-fixture-and-adoption]] - sample fixture/adoption의 일반 배경. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
## 타깃 독자 / Target reader
- 독자 profile: template project의 sample domain을 어떻게 유지/제거할지 고민하는 개발자.
- 이미 안다고 가정하는 것: sample app, fixture, template adoption.
- 처음 듣는다고 가정하는 것: sample을 데모가 아니라 architecture rule과 operational contract를 검증하는 corpus로 사용하는 방식.
## 도입 / Hook
Template repository의 sample code는 애매합니다. 남겨두면 실제 서비스 코드처럼 오해받고, 지우면 skeleton이 정말 동작하는지 보여줄 corpus가 사라집니다. 특히 Clean Architecture skeleton에서는 sample이 단순 CRUD 데모를 넘어 envelope, authorization, transaction, idempotency, outbox, OpenAPI snapshot 같은 계약을 실제 흐름으로 건드리는 역할을 합니다.
ca-tmpl은 sample을 “나중에 지울 예제”로만 보지 않았습니다. `sample-portfolio` module을 fixture로 유지하고, 동시에 `sampleOffTest`로 sample이 빠진 classpath에서도 core test suite가 컴파일/실행되는지 확인합니다. sample-on과 sample-off를 둘 다 검증하는 구조입니다. 이 글은 sample fixture를 adoption 계약으로 다루는 이유와, 아직 실제 외부 프로젝트 adoption 경험으로 말하면 안 되는 부분을 정리합니다.
## 본문 outline / Body outline
1. sample domain의 목적 - 데모가 아니라 contract proof.
2. sample-on과 sample-off를 둘 다 검증하는 이유.
3. `sampleFixture` configuration과 `sampleOffTest` source set.
4. `SampleRemovalSmokeContractTest`가 막는 회귀.
5. 외부 adoption 사례는 없음.
## 본문 / Body
좋은 skeleton에는 작동하는 예제가 필요합니다. 문서만 보고 architecture rule을 이해하기는 어렵습니다. ca-tmpl의 `sample-portfolio`는 WorkLog 도메인을 통해 use case, controller, persistence adapter, id generation, validation, idempotency, outbox, OpenAPI snapshot 같은 표면을 실제로 건드립니다. 그래서 sample은 “보여주기 화면”이 아니라 contract를 깨뜨렸을 때 테스트가 반응하는 corpus입니다.
하지만 sample이 production runtime에 섞이면 다른 문제가 생깁니다. downstream project가 template을 가져간 뒤에도 sample package가 core module의 production dependency에 남아 있으면, sample을 지우는 순간 build가 깨질 수 있습니다. 더 나쁘게는 production app이 sample route나 sample bean을 몰래 품은 채 출발할 수 있습니다. 그래서 ca-tmpl은 sample 제거를 runtime toggle이 아니라 build/test classpath 문제로 다룹니다.
핵심은 `sampleFixture` configuration과 `sampleOffTest`입니다. ordinary test는 sample fixture를 볼 수 있습니다. sample-on axis에서 sample이 contract corpus로 작동해야 하기 때문입니다. 반면 `sampleOffTest`는 같은 app-bootstrap core test source를 sample-portfolio 없이 컴파일하고 실행합니다. 즉 sample이 빠져도 core skeleton이 sample type에 의존하지 않는지 확인합니다.
`SampleRemovalSmokeContractTest`는 이 경계를 여러 방식으로 확인합니다. production module의 build.gradle에서 `sample-portfolio`가 test 또는 sampleFixture scope 밖으로 들어오지 않는지 봅니다. app-bootstrap core test가 `dev.caskeleton.sample.portfolio.*`를 import하지 않는지도 확인합니다. `sampleOffTest` source set과 task가 선언되어 있는지, CI workflow에 `sample-off` job과 `./gradlew :app-bootstrap:sampleOffTest`가 있는지도 검사합니다.
GitHub Actions에도 sample-off axis가 있습니다. ordinary quality-gates job은 sample-on axis이고, `sample-off` job은 sample-portfolio가 compile/runtime classpath에 없는 상태에서 `:app-bootstrap:sampleOffTest`와 architecture dependency matrix를 돌립니다. 이것은 실제 외부 프로젝트 adoption을 검증했다는 뜻은 아닙니다. 하지만 template 내부에서는 “sample을 지워도 core가 sample에 기대지 않는다”는 방향을 테스트로 표현합니다.
이 방식은 sample을 무조건 오래 남기자는 뜻도 아닙니다. downstream project에서는 sample을 지울 수 있습니다. 다만 지우기 전에 sample-off build가 green이어야 합니다. sample을 먼저 지워서 어떤 계약이 깨졌는지 모르게 만드는 것보다, sample-on으로 reference behavior를 보고 sample-off로 제거 가능성을 확인하는 편이 안전합니다.
주의할 점도 있습니다. canonical에는 예전 `sample-ticket` 12 scenario matrix 같은 계획성 문장과 현재 `sample-portfolio` 구현이 함께 남아 있습니다. 이 글에서 구현 사실로 말할 수 있는 것은 `sample-portfolio`, `sampleFixture`, `sampleOffTest`, `SampleRemovalSmokeContractTest`, CI sample-off job, 로컬 `./gradlew check` 범위입니다. 외부 프로젝트가 ca-tmpl을 adoption했고 도입 시간이 줄었다는 식의 주장은 아직 없습니다.
## 코드 예제 / Code samples (있다면)
```groovy
// 출처: [[wiki/projects/ca-tmpl/sample-fixture-and-adoption]]
// 실제 파일: app-bootstrap/build.gradle, ca-tmpl @f6fbd4e196b4
configurations {
sampleFixture {
canBeConsumed = false
canBeResolved = false
}
}
sourceSets {
sampleOffTest {
java.srcDirs = sourceSets.test.java.srcDirs
resources.srcDirs = sourceSets.test.resources.srcDirs
compileClasspath += sourceSets.main.output
runtimeClasspath += sourceSets.main.output
}
}
```
```groovy
// 출처: [[wiki/projects/ca-tmpl/sample-fixture-and-adoption]]
// 실제 파일: app-bootstrap/build.gradle, ca-tmpl @f6fbd4e196b4
dependencies {
sampleFixture project(':sample-portfolio')
}
tasks.register('sampleOffTest', Test) {
description = 'Compiles and runs the core test suite without sample-portfolio on the classpath.'
testClassesDirs = sourceSets.sampleOffTest.output.classesDirs
classpath = sourceSets.sampleOffTest.runtimeClasspath
systemProperty 'ca.sample.mode', 'off'
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/sample-fixture-and-adoption]]
// 실제 파일: app-bootstrap/.../SampleRemovalSmokeContractTest.java, ca-tmpl @f6fbd4e196b4
void sampleClassIsAbsentFromTheSampleOffTestClasspath() {
Assumptions.assumeTrue(
"off".equals(System.getProperty("ca.sample.mode")),
"sample classpath absence is verified only by sampleOffTest");
assertThat(isClassPresent("dev.caskeleton.sample.portfolio.SamplePortfolioApplication"))
.as("sampleOffTest must not contain the sample-portfolio jar")
.isFalse();
}
```
```yaml
# 출처: [[wiki/projects/ca-tmpl/sample-fixture-and-adoption]]
# 실제 파일: .github/workflows/ci-quality-gates.yml, ca-tmpl @f6fbd4e196b4
sample-off:
runs-on: ubuntu-latest
steps:
- name: sampleOffTest + clean architecture dependency matrix
working-directory: src
run: ./gradlew :app-bootstrap:sampleOffTest verifyCleanArchitectureDependencies --no-daemon --stacktrace
```
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
- [[wiki/projects/ca-tmpl/sample-fixture-and-adoption]] - 이 글의 1차 canonical. `sample-portfolio`, sample fixture/adoption decision, `sampleFixture`, `sampleOffTest`, `SampleRemovalSmokeContractTest`, CI sample-off job, 외부 adoption 미검증 경계를 따른다.
- [[wiki/concepts/sample-fixture-and-adoption]] - 관련 개념 문서. template sample과 adoption strategy의 일반 배경으로만 둔다.
## 사실 vs 의견 / Fact vs opinion 구분
- 사실: ca-tmpl에는 `sample-portfolio` module, `sampleFixture` configuration, `sampleOffTest` task, `SampleRemovalSmokeContractTest`, CI `sample-off` job이 존재한다. 근거: [[wiki/projects/ca-tmpl/sample-fixture-and-adoption]]
- 사실: `./gradlew check`가 2026-07-02 기준 통과했고, sample-off 관련 task가 check graph에 포함되어 실행된 것으로 기록되어 있다. 근거: [[wiki/projects/ca-tmpl/sample-fixture-and-adoption]]
- 사실: 외부 프로젝트 adoption 사례, hosted release 차단 사례, adoption 시간 측정값은 없다. 근거: [[wiki/projects/ca-tmpl/sample-fixture-and-adoption]]
- 의견: sample은 빨리 지울 데모보다 architecture contract를 증명하는 corpus로 남기는 편이 skeleton 학습에 유리하다.
- 알지 못하는 것: 실제 template consumer의 migration friction, sample 제거에 걸린 시간, 조직별 adoption pattern.
## 답할 수 있는 범위 / Answer boundary
- 자신 있게 답할 수 있는 후속 질문:
- sample domain이 어떤 contract를 검증하는 corpus인가?
- sample-on과 sample-off를 둘 다 검증하는 이유는 무엇인가?
- `sampleOffTest`가 runtime toggle이 아니라 classpath contract인 이유는 무엇인가?
- 다음 글로 넘길 부분:
- 실제 외부 프로젝트 adoption report.
- sample 제거 자동화 script.
- Backstage나 Cookiecutter 같은 generator형 adoption과의 비교.
## 게시 체크리스트 / Publish checklist
- [x] 모든 사실 주장에 canonical 링크 있음
- [x] 사실 vs 의견 분리 명시됨
- [x] 금지 마케팅 표현 없음
- [x] 코드 예제 출처 명시
- [x] 타깃 독자 가정과 톤 일치
- [x] `/lint` 통과
- [ ] 게시 URL 기록 (게시 후):
## Related / 관련
- 후속 글 후보: [[wiki/blog/ca-tmpl-clean-architecture-package-layout-2026-07-02]]
- 후속 글 후보: [[wiki/blog/ca-tmpl-devops-ci-supply-chain-dx-2026-07-02]]
@@ -0,0 +1,164 @@
---
title: Security Baseline을 JWT, Actuator, Secrets로 나누기
source_type: blog
status: verified
confidence: high
tags: [blog, ca-tmpl, security, jwt, actuator, secrets]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-03
canonical_sources:
- wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets
audience: backend-engineer
target_publish:
status_label: ready
---
# Security Baseline을 JWT, Actuator, Secrets로 나누기
## Parent / 부모 (필수)
- 핵심 canonical: [[wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets]]
- 관련 개념 문서: [[wiki/concepts/security-baseline-jwt-actuator-secrets]] - JWT Resource Server, actuator 노출, secret source/rotation의 일반 배경. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
## 타깃 독자 / Target reader
- 독자 profile: Spring Boot skeleton에 보안 baseline을 넣으려는 백엔드 엔지니어.
- 이미 안다고 가정하는 것: JWT, OAuth2 Resource Server, Spring Security filter chain, actuator, 환경 변수 기반 secret 주입.
- 처음 듣는다고 가정하는 것: security baseline을 인증 설정 하나가 아니라 데이터면 인증/인가, 제어면 actuator, secret lifecycle의 세 계약으로 나누는 방식.
## 도입 / Hook
Spring Boot 프로젝트에 보안을 붙인다고 하면 보통 `SecurityFilterChain`부터 떠올립니다. JWT를 검증하고, public path를 열고, 나머지는 인증을 요구하면 일단 그림은 그려집니다. 그런데 운영 관점에서 보면 그 정도로는 baseline이라고 부르기 어렵습니다. 인증 실패가 어떤 JSON shape으로 내려가는지, actuator endpoint가 앱 트래픽과 같은 경계에 놓이는지, secret rotation을 runtime reload로 볼지 restart-only로 볼지까지 같이 정해야 합니다.
ca-tmpl은 이 문제를 세 표면으로 나눴습니다. 데이터면은 JWT Resource Server와 AuthN/AuthZ 실패 envelope으로, 제어면은 management actuator chain으로, secret은 `SecretSource`와 restart-only guard로 다룹니다. 이 글은 ca-tmpl에 실제로 구현되고 로컬 검증된 범위와, 아직 IdP/secret manager 운영 경험처럼 말하면 안 되는 범위를 분리합니다.
## 본문 outline / Body outline
1. security baseline은 인증 설정 하나가 아니다.
2. 데이터면: JWT Resource Server와 filter-layer error envelope.
3. 제어면: actuator를 별도 security chain으로 본다.
4. secret: source abstraction과 restart-only rotation guard.
5. 구현된 baseline과 운영 미검증 범위를 분리한다.
## 본문 / Body
보안 baseline을 좁게 잡으면 “JWT를 검증한다”가 전부가 됩니다. 하지만 skeleton/template에서는 다음 프로젝트가 무엇을 가져가야 하는지까지 보여줘야 합니다. ca-tmpl의 기준은 세 가지였습니다. 첫째, 사용자 요청이 들어오는 데이터면 인증/인가를 stateless JWT Resource Server로 고정합니다. 둘째, actuator 같은 제어면은 일반 API와 다른 노출 정책을 갖게 합니다. 셋째, secret은 문자열 설정값이 아니라 source와 reload 정책이 있는 runtime 계약으로 봅니다.
데이터면의 핵심은 `adapter-web``SecurityConfig``JwtDecoderConfig`입니다. `SecurityConfig``exceptionHandling``oauth2ResourceServer` 양쪽에 같은 entry point와 access denied handler를 연결합니다. Spring Security filter layer에서 발생한 401/403은 `@ControllerAdvice`까지 내려오지 않는 경우가 많습니다. 그래서 filter layer 자체가 ca-tmpl의 API error envelope을 쓰도록 entry point/denied handler를 맞춘 것입니다.
JWT decoder도 framework 기본값에만 맡기지 않습니다. `JwtDecoderConfig``SupplierJwtDecoder`를 사용해 JWKS discovery를 기동 시점이 아니라 첫 decode 시점으로 미룹니다. validator chain에는 60초 clock skew, issuer validation, 선택적 audience validation이 명시됩니다. 여기서 구현된 것은 “JWT 검증 baseline”입니다. 외부 IdP 운영, JWKS rotation latency, unknown `kid` 상황의 실측값은 아직 없습니다.
제어면은 actuator입니다. ca-tmpl의 `ManagementSecurityConfig`는 actuator endpoint용 `SecurityFilterChain``@Order(0)`으로 별도 구성합니다. `health`, `info`, `prometheus`는 allowlist로 열고, `POST/DELETE /actuator/loggers/**`는 deny합니다. 이 결정의 요지는 “actuator도 Spring Security가 보호한다”가 아니라, application API와 다른 security matcher, 다른 노출 정책, 다른 ingress/network boundary를 가져야 한다는 점입니다.
secret 쪽에서는 `SecretSource` abstraction과 restart-only 원칙이 중요합니다. ca-tmpl은 local `.env`와 prod secret source를 같은 소비자 코드가 보게 하되, runtime reload를 기본 경로로 만들지 않습니다. `SecretReloadContractTest`는 context refresh 이후 property source를 바꿔도 이미 바인딩된 configuration property 값이 바뀌지 않는다는 것을 확인합니다. 또한 Spring Cloud refresh scope machinery가 runtime classpath에 없다는 점도 검증합니다. 이것은 secret manager 연동을 구현했다는 뜻이 아니라, ca-tmpl의 secret 소비 계약이 restart-only라는 뜻입니다.
이 세 영역을 묶으면 security baseline의 의미가 달라집니다. JWT는 데이터면 인증을 담당하고, actuator는 제어면 노출을 담당하며, secret source는 runtime config lifecycle을 담당합니다. 세 영역은 모두 Spring Boot 설정처럼 보이지만 실패 형태, 네트워크 경계, lifecycle 위험이 다릅니다. ca-tmpl은 그 차이를 문서에만 남기지 않고 `SecurityErrorClassifierTest`, `JwtDecoderConfigTest`, `ActuatorSecurityHttpTest`, `SecretReloadContractTest` 같은 테스트로 일부 고정했습니다.
주의할 점도 분명합니다. 이 글에서 “구현됐다”고 말할 수 있는 것은 ca-tmpl repo 안의 filter chain, lazy decoder, actuator policy, secret source/reload guard, 로컬 `./gradlew check` 통과 범위입니다. 실제 Keycloak이나 외부 IdP를 붙여 token lifecycle을 검증한 것이 아니고, Vault/AWS Secrets Manager/GCP Secret Manager 통합도 없습니다. actuator endpoint에 대한 침투 테스트나 production metric도 없습니다. security baseline을 설명할 때는 이 경계를 같이 말해야 합니다.
## 코드 예제 / Code samples (있다면)
```java
// 출처: [[wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets]]
// 실제 파일: adapter-web/.../auth/SecurityConfig.java, ca-tmpl @f6fbd4e196b4
.exceptionHandling(
ex ->
ex.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler))
.oauth2ResourceServer(
oauth ->
oauth
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter)));
```
```java
// 출처: [[wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets]]
// 실제 파일: adapter-web/.../auth/JwtDecoderConfig.java, ca-tmpl @f6fbd4e196b4
return new SupplierJwtDecoder(
() -> {
NimbusJwtDecoder decoder =
NimbusJwtDecoder.withIssuerLocation(settings.issuerUri()).build();
decoder.setJwtValidator(jwtValidator(settings.issuerUri(), settings.audience()));
return decoder;
});
validators.add(new JwtTimestampValidator(Duration.ofSeconds(60)));
validators.add(new JwtIssuerValidator(issuerUri));
```
```java
// 출처: [[wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets]]
// 실제 파일: app-bootstrap/.../management/security/ManagementSecurityConfig.java, ca-tmpl @f6fbd4e196b4
http.securityMatcher(EndpointRequest.toAnyEndpoint())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(
auth ->
auth
.requestMatchers(EndpointRequest.to("health", "info", "prometheus"))
.permitAll()
.requestMatchers(HttpMethod.POST, "/actuator/loggers/**")
.denyAll()
.requestMatchers(HttpMethod.DELETE, "/actuator/loggers/**")
.denyAll()
.anyRequest()
.authenticated());
```
```java
// 출처: [[wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets]]
// 실제 파일: app-bootstrap/.../contract/SecretReloadContractTest.java, ca-tmpl @f6fbd4e196b4
sources.addFirst(
new MapPropertySource(
"rotated-secret-source",
Map.of("secret-reload-probe.value", "rotated-secret")));
SecretHolder afterRotation = context.getBean(SecretHolder.class);
assertThat(afterRotation.value()).isEqualTo("initial-secret");
assertThatThrownBy(
() -> Class.forName("org.springframework.cloud.context.scope.refresh.RefreshScope"))
.isInstanceOf(ClassNotFoundException.class);
```
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
- [[wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets]] - 이 글의 1차 canonical. JWT Resource Server, filter-layer envelope, actuator security chain, secret source/reload guard, local verification, IdP/secret manager/prod 미검증 경계를 따른다.
- [[wiki/concepts/security-baseline-jwt-actuator-secrets]] - 관련 개념 문서. JWT, actuator, secret rotation의 일반 배경으로만 둔다.
## 사실 vs 의견 / Fact vs opinion 구분
- 사실: ca-tmpl에는 `SecurityConfig`, `JwtDecoderConfig`, `SecurityErrorClassifier`, envelope entry point/denied handler, `ManagementSecurityConfig`, `SecretSource*`, `SecretReloadContractTest`가 존재한다. 근거: [[wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets]]
- 사실: `./gradlew check`가 2026-07-02 기준 통과했고, security/error path, actuator policy, secret source/reload guard 관련 테스트가 canonical에 기록되어 있다. 근거: [[wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets]]
- 사실: 외부 IdP 운영, JWKS rotation latency, secret manager integration, real secret rotation automation, pentest, prod metric 검증은 없다. 근거: [[wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets]]
- 의견: skeleton의 security baseline은 JWT 검증 코드보다 실패 계약, control plane 노출, secret lifecycle을 함께 묶을 때 설명력이 높아진다.
- 알지 못하는 것: 실제 IdP 장애 상황, secret manager rotation window, actuator 노출 사고 대응 경험.
## 답할 수 있는 범위 / Answer boundary
- 자신 있게 답할 수 있는 후속 질문:
- filter-layer 401/403을 error envelope으로 맞춘 이유는 무엇인가?
- `SupplierJwtDecoder`와 60초 clock skew를 명시한 이유는 무엇인가?
- actuator를 일반 API security chain과 분리해서 보는 이유는 무엇인가?
- secret runtime reload를 기본 경로로 두지 않은 이유는 무엇인가?
- 다음 글로 넘길 부분:
- real IdP integration과 token lifecycle.
- Vault/Secrets Manager/KMS 통합.
- actuator endpoint penetration test나 prod metric 기반 검증.
## 게시 체크리스트 / Publish checklist
- [x] 모든 사실 주장에 canonical 링크 있음
- [x] 사실 vs 의견 분리 명시됨
- [x] 금지 마케팅 표현 없음
- [x] 코드 예제 출처 명시
- [x] 타깃 독자 가정과 톤 일치
- [x] `/lint` 통과
- [ ] 게시 URL 기록 (게시 후):
## Related / 관련
- 후속 글 후보: [[wiki/blog/ca-tmpl-api-error-envelope-design-2026-07-02]]
- 후속 글 후보: [[wiki/blog/ca-tmpl-runtime-container-health-migration-2026-07-02]]
- 후속 글 후보: [[wiki/blog/ca-tmpl-config-and-adapter-templates-2026-07-02]]
@@ -0,0 +1,148 @@
---
title: Skeleton Governance를 Registry와 Verification으로 닫기
source_type: blog
status: verified
confidence: high
tags: [blog, ca-tmpl, governance, archunit, testing]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-03
canonical_sources:
- wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard
audience: backend-engineer
target_publish:
status_label: ready
---
# Skeleton Governance를 Registry와 Verification으로 닫기
## Parent / 부모 (필수)
- 핵심 canonical: [[wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard]]
- 관련 개념 문서: [[wiki/concepts/skeleton-governance-registry-verification-test-scorecard]] - registry, verification, test taxonomy, scorecard의 일반 배경. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
## 타깃 독자 / Target reader
- 독자 profile: template skeleton의 품질 기준을 registry, test, gate로 유지하려는 백엔드/플랫폼 엔지니어.
- 이미 안다고 가정하는 것: Gradle test, ArchUnit, YAML registry, CI gate.
- 처음 듣는다고 가정하는 것: governance를 규칙 문서가 아니라 owner, registry row, verification mechanism, test taxonomy로 연결하는 방식.
## 도입 / Hook
Skeleton 프로젝트에서 좋은 규칙을 많이 쓰는 것은 어렵지 않습니다. “application layer는 framework에 의존하지 않는다”, “환경 변수는 registry에 등록한다”, “quarantine test는 만료일을 가진다” 같은 문장을 README에 적으면 됩니다. 문제는 시간이 지난 뒤입니다. 규칙은 남아 있는데 owner가 사라지고, gate matrix는 workflow와 어긋나고, registry row는 코드와 다른 이름을 가리키기 시작합니다.
ca-tmpl은 이 문제를 governance 계약으로 다뤘습니다. registry family를 두고, 각 row가 owner와 required test를 갖게 하며, gate matrix가 실제 Gradle task/test/workflow job과 맞는지 검사합니다. 다만 scorecard badge, 11 gate 전체 hosted release-blocking history, 5분 budget 강제 같은 항목은 아직 구현됐다고 말하면 안 됩니다. 이 글은 구현된 registry/verification slice와 계획으로 남은 governance slice를 분리합니다.
## 본문 outline / Body outline
1. governance는 규칙 목록이 아니라 drift를 줄이는 구조다.
2. registry는 row owner와 required test를 연결한다.
3. verification은 matrix와 실제 task/test/job을 대조한다.
4. test taxonomy는 classpath와 boundary를 지킨다.
5. scorecard는 아이디어와 자동화 범위를 나눠 말한다.
## 본문 / Body
governance라는 단어는 무겁지만, skeleton에서 필요한 질문은 단순합니다. “이 규칙을 누가 소유하는가?”, “이 규칙이 깨지면 어떤 테스트가 실패하는가?”, “문서에 적힌 gate가 실제 CI에 남아 있는가?” ca-tmpl은 이 질문에 답하기 위해 registry, verification, test taxonomy, scorecard를 한 묶음으로 기록했습니다.
registry 축은 `docs/registries/` 아래의 7개 YAML family에서 시작합니다. `error-codes.yaml`, `env-keys.yaml`, `secrets-classification.yaml`, `headers.yaml`, `mdc-keys.yaml`, `metrics.yaml`, `capabilities.yaml`가 있고, `ContractRegistrySchemaGovernanceTest`가 각 family의 schema owner header, identity column, `owner_branch`, `compatibility_impact`, `required_test` 같은 필드를 확인합니다. 핵심은 registry row가 단순 목록이 아니라 “누가 책임지고 어떤 test가 지키는가”를 담는다는 점입니다.
이 registry는 모든 것을 해결하지 않습니다. canonical은 markdown SSOT와 YAML registry의 관계, generated constants/code generator, markdown과 YAML의 full drift gate가 아직 남았다고 구분합니다. 따라서 이 글에서 말할 수 있는 것은 7개 registry artifact와 schema governance test가 존재한다는 사실입니다. registry YAML이 모든 계약의 최종 SSOT라고 말하면 범위를 넘습니다.
verification 축은 `.github/ci-gate-matrix.yml``verify-gate-matrix.sh`에서 잘 드러납니다. matrix row에는 gate id, release blocking 여부, owner branch, mechanism, ref, workflow가 들어갑니다. script는 mechanism별로 실제 존재를 확인합니다. `gradle-custom-task`라면 `tasks.register('<ref>')`가 있어야 하고, `contract-test`라면 test class 파일이 있어야 하며, `workflow-job`이라면 workflow에 job id가 있어야 합니다. 문서와 실행 경로가 벌어지는 것을 줄이려는 구조입니다.
Gradle 쪽 custom gate도 같은 방향입니다. `verifyEnvKeys``.env`, `application.yml`, `docs/registries/env-keys.yaml` 사이를 맞춥니다. required placeholder가 `.env`에 없거나, `.env``APP_` key가 registry에 없으면 실패합니다. `verifyTrivyignore`, `verifyQuarantineSunset`, `verifyCleanArchitectureDependencies` 같은 task도 같은 계열입니다. 규칙은 글로만 남지 않고 build graph에 들어가야 회귀를 잡습니다.
test taxonomy는 boundary를 강제하는 쪽에 가깝습니다. `CleanArchitectureTest`, `DisabledAdapterArchitectureTest`, `NamingConventionTest`, `ProductionClassImportOption`, sample-off source set은 production classpath와 test fixture boundary가 섞이는 것을 줄입니다. Testcontainers integration test도 outbox/idempotency 같은 runtime contract를 검증하는 데 쓰입니다. 다만 canonical은 6 level 전체의 budget 측정과 강제 mechanism이 아직 없다고 명시합니다.
scorecard는 더 조심해서 말해야 합니다. ca-tmpl은 binary pass/fail readiness scorecard를 설계했지만, 별도 CI badge나 자동 산출물까지 구현한 것은 아닙니다. 그래서 이 글에서는 scorecard를 “좋은 방향의 governance 모델”로 설명할 수는 있어도, 자동화된 release readiness dashboard가 존재한다고 쓰면 안 됩니다. 현재 구현의 중심은 registry와 verification, 그리고 일부 Gradle/test gate입니다.
결국 ca-tmpl의 skeleton governance는 개발자의 선의에만 기대지 않으려는 시도입니다. registry row에 owner와 required test를 붙이고, gate matrix와 실제 task/test/job을 대조하며, architecture boundary를 ArchUnit으로 고정합니다. 구현된 것은 이 정도입니다. 조직 전체 rollout, 장기적 defect 감소, hosted release gate 차단 이력은 아직 별도의 근거가 필요합니다.
## 코드 예제 / Code samples (있다면)
```yaml
# 출처: [[wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard]]
# 실제 파일: .github/ci-gate-matrix.yml, ca-tmpl @f6fbd4e196b4
gates:
- id: architecture-test
release_blocking: true
owner_branch: feature-architecture-enforcement-rules
mechanism: contract-test
ref: app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java
runs_in: ci-quality-gates
```
```java
// 출처: [[wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard]]
// 실제 파일: app-bootstrap/.../contract/ContractRegistrySchemaGovernanceTest.java, ca-tmpl @f6fbd4e196b4
private static final List<Registry> REGISTRIES =
List.of(
new Registry("error-codes.yaml", "errors", "code"),
new Registry("env-keys.yaml", "env_keys", "name"),
new Registry("secrets-classification.yaml", "secrets", "name"),
new Registry("headers.yaml", "headers", "name"),
new Registry("mdc-keys.yaml", "mdc_keys", "key"),
new Registry("metrics.yaml", "metrics", "name"),
new Registry("capabilities.yaml", "capabilities", "name"));
```
```groovy
// 출처: [[wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard]]
// 실제 파일: src/build.gradle, ca-tmpl @f6fbd4e196b4
tasks.register('verifyEnvKeys') {
description = 'Verifies src/.env covers application.yml placeholders and every APP_ key is registered.'
File envFile = file("${rootProject.projectDir}/.env")
File appYml = file("${rootProject.projectDir}/app-bootstrap/src/main/resources/application.yml")
File registryFile = file("${rootProject.projectDir}/../docs/registries/env-keys.yaml")
}
```
```bash
# 출처: [[wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard]]
# 실제 파일: .github/scripts/verify-gate-matrix.sh, ca-tmpl @f6fbd4e196b4
# Cross-checks every row of .github/ci-gate-matrix.yml against reality:
# gradle-custom-task -> a tasks.register('<ref>') exists
# contract-test -> the <ref> test-class file exists under src/
# workflow-job -> the <ref> job id exists in .github/workflows/<runs_in>.yml
```
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
- [[wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard]] - 이 글의 1차 canonical. registry files, schema governance test, gate matrix, Gradle verification tasks, test taxonomy, scorecard 미자동화 경계를 따른다.
- [[wiki/concepts/skeleton-governance-registry-verification-test-scorecard]] - 관련 개념 문서. governance/test taxonomy/scorecard의 일반 배경으로만 둔다.
## 사실 vs 의견 / Fact vs opinion 구분
- 사실: ca-tmpl에는 7개 registry family, `.github/ci-gate-matrix.yml`, `ContractRegistrySchemaGovernanceTest`, registry/gate 관련 contract tests, 여러 Gradle verification task가 존재한다. 근거: [[wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard]]
- 사실: `./gradlew check`가 2026-07-02 기준 통과했고, `verifyCleanArchitectureDependencies`, `verifyEnvKeys`, `verifyQuarantineSunset`, `verifyReadmeCommands`, `verifyTrivyignore`가 canonical에 기록되어 있다. 근거: [[wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard]]
- 사실: scorecard CI badge/auto artifact, 11 gate 전체 hosted release-blocking history, generated constants/code generator 전체, 5분 budget 강제는 구현됐다고 말할 수 없다. 근거: [[wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard]]
- 의견: skeleton governance는 규칙 문서보다 owner와 verification mechanism을 같이 남길 때 오래 유지된다.
- 알지 못하는 것: 팀 단위 rollout 효과, 장기 defect 감소율, 실제 release 차단 사례.
## 답할 수 있는 범위 / Answer boundary
- 자신 있게 답할 수 있는 후속 질문:
- registry row에 owner와 required test를 두는 이유는 무엇인가?
- gate matrix와 실제 Gradle/test/workflow를 대조하는 이유는 무엇인가?
- ArchUnit/test taxonomy가 skeleton governance에서 맡는 역할은 무엇인가?
- 다음 글로 넘길 부분:
- scorecard badge와 자동 산출물.
- multi-team governance process.
- hosted release gate 차단 이력.
## 게시 체크리스트 / Publish checklist
- [x] 모든 사실 주장에 canonical 링크 있음
- [x] 사실 vs 의견 분리 명시됨
- [x] 금지 마케팅 표현 없음
- [x] 코드 예제 출처 명시
- [x] 타깃 독자 가정과 톤 일치
- [x] `/lint` 통과
- [ ] 게시 URL 기록 (게시 후):
## Related / 관련
- 후속 글 후보: [[wiki/blog/ca-tmpl-devops-ci-supply-chain-dx-2026-07-02]]
- 후속 글 후보: [[wiki/blog/ca-tmpl-sample-fixture-and-adoption-2026-07-02]]
- 후속 글 후보: [[wiki/blog/ca-tmpl-knowledge-capture-workflow-2026-07-02]]
@@ -0,0 +1,144 @@
---
title: Streaming Response를 지원하지 않는 결정도 계약이다
source_type: blog
status: verified
confidence: high
tags: [blog, ca-tmpl, streaming, archunit, api-design]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-03
canonical_sources:
- wiki/projects/ca-tmpl/streaming-response-support
audience: backend-engineer
target_publish:
status_label: ready
---
# Streaming Response를 지원하지 않는 결정도 계약이다
## Parent / 부모 (필수)
- 핵심 canonical: [[wiki/projects/ca-tmpl/streaming-response-support]]
- 관련 개념 문서: [[wiki/concepts/streaming-response-patterns]] - SSE/WebSocket/long-polling/chunked transfer의 일반 trade-off. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
## 타깃 독자 / Target reader
- 독자 profile: template skeleton에서 streaming/SSE/WebSocket response를 언제 열지 고민하는 백엔드 엔지니어.
- 이미 안다고 가정하는 것: `SseEmitter`, `ResponseBodyEmitter`, WebSocket, `StreamingResponseBody`.
- 처음 듣는다고 가정하는 것: “지원하지 않음”도 문서 문장이 아니라 build-time rule로 고정할 수 있다는 관점.
## 도입 / Hook
기술 선택은 보통 “무엇을 지원할 것인가”로 기록됩니다. 그런데 skeleton에서는 “지금은 열지 않을 것”도 중요한 결정입니다. SSE나 WebSocket을 한 번 열면 응답 envelope, timeout, heartbeat, reconnect, observability, connection cap, reverse proxy 설정까지 같이 따라옵니다. use case가 없는데 surface만 열면 템플릿은 빨리 무거워집니다.
ca-tmpl은 streaming response를 지원하지 않는다는 결정을 그냥 README에 쓰지 않았습니다. production code가 `SseEmitter`, `ResponseBodyEmitter`, Spring/Jakarta WebSocket surface를 import하면 ArchUnit rule이 잡도록 했습니다. 단, 대용량 다운로드용 `StreamingResponseBody`는 차단하지 않았습니다. 이 글은 “미지원도 계약이 될 수 있다”는 관점과, 어디까지가 실제 구현인지 정리합니다.
## 본문 outline / Body outline
1. 미지원도 설계 결정이다.
2. streaming이 깨뜨리는 기존 request-response baseline.
3. 차단 대상과 제외 대상 - `SseEmitter`/WebSocket은 막고 `StreamingResponseBody`는 막지 않는다.
4. ArchUnit rule과 violations-as-data fixture로 검증한다.
5. 나중에 streaming을 열려면 필요한 선행 계약.
## 본문 / Body
Streaming은 매력적인 기능입니다. LLM token streaming, 실시간 알림, export 진행률처럼 server가 client에게 계속 event를 보내야 하는 use case가 생기면 request-response만으로는 답답합니다. 하지만 skeleton의 default surface로 넣기에는 비용이 큽니다. event envelope을 어떻게 만들지, error를 mid-stream에서 어떻게 표현할지, trace id는 connection 단위인지 event 단위인지, proxy timeout과 heartbeat는 어떻게 둘지 정해야 합니다.
ca-tmpl은 현재 sample fixture에 server-push use case가 없기 때문에 streaming을 기본 지원하지 않기로 했습니다. 여기서 핵심은 “아직 안 만들었다”가 아니라 “지금은 열지 않는다는 결정을 build-time rule로 고정했다”입니다. production code가 `SseEmitter`를 import하거나, `ResponseBodyEmitter`를 쓰거나, Spring/Jakarta WebSocket package에 의존하면 ArchUnit rule이 실패합니다.
차단 대상은 이벤트/server-push streaming입니다. `SseEmitter`는 Server-Sent Events surface이고, `ResponseBodyEmitter`는 incremental object emit surface이며, WebSocket은 full-duplex connection model입니다. 이 셋은 request-response API baseline과 다른 운영 계약을 요구합니다. ca-tmpl은 이 표면을 기본 skeleton에 열지 않았습니다.
반대로 `StreamingResponseBody`는 차단하지 않습니다. 이름은 비슷하지만, ca-tmpl project canonical은 이를 대용량 파일 다운로드나 chunked body처럼 request-response 모델을 유지하는 관심사로 봅니다. 이벤트를 계속 push하는 계약과, 하나의 요청에 대해 body를 stream으로 쓰는 계약은 다릅니다. 그래서 over-block guard fixture가 있습니다. `StreamingResponseBodyAllowedFixture`는 streaming ban rule이 이 허용 케이스를 잡지 않아야 통과합니다.
이 구조가 좋은 이유는 “금지 rule이 진짜로 작동하는가”까지 테스트한다는 점입니다. `ArchitectureViolationFixtureTest``SseEmitterUsingFixture`, `ResponseBodyEmitterUsingFixture`, Spring WebSocket fixture, Jakarta WebSocket fixture를 의도적 위반 데이터로 둡니다. 각 rule이 이 fixture를 잡는지 확인하고, WebSocket은 Spring glob과 Jakarta glob을 따로 가져와 vacuous pass를 줄입니다. 동시에 `StreamingResponseBody`는 잡지 않는지 확인합니다.
나중에 streaming을 열 수 없는 것은 아닙니다. 다만 그때는 단순히 controller return type을 바꾸는 일이 아닙니다. SSE인지 WebSocket인지, event envelope을 기존 `{ success, data, meta }`와 어떻게 맞출지, per-event trace를 만들지, reconnect와 timeout, connection cap, reverse proxy 설정을 어떻게 둘지 결정해야 합니다. ca-tmpl 문서는 이 지원 계약을 planned/open 범위로 남겨 두고 있습니다.
따라서 이 글의 결론은 “streaming은 나쁘다”가 아닙니다. ca-tmpl의 결론은 더 좁습니다. 현재 skeleton의 sync request-response baseline에서는 server-push streaming을 기본 surface로 열지 않고, 그 미지원 상태가 우연히 깨지지 않도록 ArchUnit으로 막습니다. 운영 streaming endpoint, connection load, SSE/WebSocket 장애 대응은 이 글에서 말할 수 있는 범위가 아닙니다.
## 코드 예제 / Code samples (있다면)
```java
// 출처: [[wiki/projects/ca-tmpl/streaming-response-support]]
// 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4
static final ArchRule NO_SSE_EMITTER =
noClasses()
.that()
.resideInAPackage("dev.caskeleton..")
.should()
.dependOnClassesThat()
.haveFullyQualifiedName("org.springframework.web.servlet.mvc.method.annotation.SseEmitter");
```
```java
// 출처: [[wiki/projects/ca-tmpl/streaming-response-support]]
// 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4
static final ArchRule NO_WEBSOCKET_HANDLER =
noClasses()
.that()
.resideInAPackage("dev.caskeleton..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("org.springframework.web.socket..", "jakarta.websocket..");
```
```java
// 출처: [[wiki/projects/ca-tmpl/streaming-response-support]]
// 실제 파일: app-bootstrap/.../ArchitectureViolationFixtureTest.java, ca-tmpl @f6fbd4e196b4
void noWebsocketHandlerCatchesJakartaWebsocketFixture() {
EvaluationResult result =
CleanArchitectureTest.NO_WEBSOCKET_HANDLER.evaluate(JAKARTA_WEBSOCKET_FIXTURE_ONLY);
assertThat(result.hasViolation()).isTrue();
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/streaming-response-support]]
// 실제 파일: app-bootstrap/.../StreamingResponseBodyAllowedFixture.java, ca-tmpl @f6fbd4e196b4
public class StreamingResponseBodyAllowedFixture {
public StreamingResponseBody allowed() {
return outputStream -> outputStream.write("data".getBytes());
}
}
```
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
- [[wiki/projects/ca-tmpl/streaming-response-support]] - 이 글의 1차 canonical. streaming 미지원 결정, ArchUnit import-ban rule, violations-as-data fixture, `StreamingResponseBody` 제외 경계, local verification, 운영 미검증 범위를 따른다.
- [[wiki/concepts/streaming-response-patterns]] - 관련 개념 문서. SSE/WebSocket/long-polling/chunked transfer의 일반 trade-off 배경으로만 둔다.
## 사실 vs 의견 / Fact vs opinion 구분
- 사실: ca-tmpl에는 `NO_SSE_EMITTER`, `NO_RESPONSE_BODY_EMITTER`, `NO_WEBSOCKET_HANDLER` ArchUnit rule과 streaming violation fixtures, `StreamingResponseBody` over-block guard fixture가 존재한다. 근거: [[wiki/projects/ca-tmpl/streaming-response-support]]
- 사실: 구현된 것은 streaming 지원이 아니라 streaming 미지원을 강제하는 build-time guard다. 근거: [[wiki/projects/ca-tmpl/streaming-response-support]]
- 사실: 실제 SSE/WebSocket endpoint, connection load test, production streaming metric은 없다. 근거: [[wiki/projects/ca-tmpl/streaming-response-support]]
- 의견: skeleton 초기 surface에서는 real-time use case가 나타나기 전까지 server-push streaming을 닫아두는 편이 계약을 단순하게 유지한다.
- 알지 못하는 것: 실제 streaming workload 요구사항, proxy timeout tuning, per-event tracing 운영 효과.
## 답할 수 있는 범위 / Answer boundary
- 자신 있게 답할 수 있는 후속 질문:
- 왜 streaming을 지금 열지 않았는가?
- 어떤 Spring/Jakarta streaming surface를 ArchUnit으로 막았는가?
-`StreamingResponseBody`는 차단하지 않았는가?
- violations-as-data fixture가 vacuous pass를 어떻게 줄이는가?
- 다음 글로 넘길 부분:
- SSE/WebSocket을 실제로 열 때 필요한 API envelope와 observability 계약.
- connection cap, heartbeat, reconnect, proxy timeout 설계.
- production streaming endpoint 검증.
## 게시 체크리스트 / Publish checklist
- [x] 모든 사실 주장에 canonical 링크 있음
- [x] 사실 vs 의견 분리 명시됨
- [x] 금지 마케팅 표현 없음
- [x] 코드 예제 출처 명시
- [x] 타깃 독자 가정과 톤 일치
- [x] `/lint` 통과
- [ ] 게시 URL 기록 (게시 후):
## Related / 관련
- 후속 글 후보: [[wiki/blog/ca-tmpl-api-error-envelope-design-2026-07-02]]
- 후속 글 후보: [[wiki/blog/ca-tmpl-runtime-container-health-migration-2026-07-02]]
@@ -0,0 +1,201 @@
---
title: Transaction을 Annotation이 아니라 Application Port로 다루기
source_type: blog
status: verified
confidence: high
tags: [blog, ca-tmpl, transaction, clean-architecture]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
canonical_sources:
- wiki/projects/ca-tmpl/transaction-boundary-abstraction
audience: backend-engineer
target_publish:
status_label: ready
---
# Transaction을 Annotation이 아니라 Application Port로 다루기
## Parent / 부모 (필수)
- 핵심 canonical: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]] — ca-tmpl `TransactionPort`, Spring adapter, ArchUnit rule, local verification 범위.
- 관련 개념 문서: [[wiki/concepts/transaction-boundary-abstraction]] — Spring transaction boundary 대안 비교. 현재 concept 문서는 `draft`이므로 이 글의 구현 사실 근거는 verified project canonical에 둔다.
## 타깃 독자 / Target reader
- 독자 profile: Clean Architecture에서 transaction boundary를 application layer에 어떻게 둘지 고민하는 백엔드 엔지니어.
- 이미 안다고 가정하는 것: `@Transactional`, propagation, read/write transaction.
- 처음 듣는다고 가정하는 것: `TransactionPort`로 framework 의존을 adapter에 밀어내는 방식.
## 도입 / Hook
- 문제 / 궁금증: `@Transactional`은 편하지만 application core가 Spring에 묶일 수 있다.
- 이 글이 답하는 것: ca-tmpl이 transaction boundary를 port로 추상화하고 어떤 범위를 검증했는지.
- 이 글이 답하지 않는 것: 모든 DB vendor isolation tuning.
## 본문 outline / Body outline
1. transaction boundary는 use case 책임이다.
2. Spring annotation을 core에 두지 않는 이유.
3. `TransactionPort.inRead`/`inWrite` 류의 모델.
4. propagation/isolation의 owner 분리.
5. local verification과 운영 DB 검증 경계.
## 본문 / Body
Spring Boot에서 transaction을 다루는 가장 익숙한 방법은 `@Transactional`입니다. service method에 annotation을 붙이면 Spring AOP proxy가 method 호출을 감싸고, commit과 rollback을 처리합니다. 실무에서 널리 쓰이고, 단순 CRUD에서는 이 방식이 가장 읽기 쉽습니다.
그런데 Clean Architecture 관점에서는 질문이 하나 생깁니다. application layer가 Spring transaction annotation을 직접 import해도 괜찮은가? ca-tmpl은 이 질문에 대해 보수적인 답을 택했습니다. application core가 Spring transaction API를 직접 알지 않도록 `TransactionPort`를 두고, 실제 Spring transaction 실행은 persistence adapter의 `SpringTransactionPort`가 맡게 했습니다.
여기서 핵심은 `@Transactional`이 나쁘다는 주장이 아닙니다. Spring의 declarative transaction은 표준적이고 좋은 도구입니다. 다만 ca-tmpl은 skeleton template입니다. skeleton은 새 프로젝트가 어떤 adapter와 운영 계약을 붙이더라도 application core의 dependency direction이 유지되어야 합니다. 그래서 transaction도 repository나 HTTP client처럼 port 뒤로 밀어내는 쪽을 선택했습니다.
`TransactionPort`의 표면은 작습니다. write use case는 `inWrite`, read use case는 `inRead`, 독립 commit이 필요한 outbox/audit/compensation 흐름은 `inNew`를 사용합니다. callback은 `Supplier<T>` 또는 `Runnable`입니다. checked exception을 port signature에 노출하지 않고, runtime exception은 Spring transaction template을 통해 rollback되고 다시 전파됩니다. 이 API만 보면 application은 Spring의 propagation enum이나 `TransactionTemplate`을 알 필요가 없습니다.
Spring 구현체는 adapter-persistence 쪽에 있습니다. 현재 코드에서는 `SpringTransactionPort``PlatformTransactionManager`를 주입받고, write/read/requires-new용 `TransactionTemplate`을 미리 만들어 둡니다. write는 `PROPAGATION_REQUIRED` + readOnly false, read는 `PROPAGATION_REQUIRED` + readOnly true, requires-new는 `PROPAGATION_REQUIRES_NEW` + readOnly false입니다. 모두 `ISOLATION_READ_COMMITTED`를 명시합니다.
미리 만들어 둔 template을 쓰는 이유도 중요합니다. `TransactionTemplate`은 설정을 가진 객체입니다. 호출할 때마다 같은 template의 propagation/readOnly/isolation을 바꾸는 방식은 동시성 상황에서 읽기 어려운 race를 만들 수 있습니다. ca-tmpl은 mode별 template을 분리해서 “이 method는 어떤 transaction mode로 실행되는가”를 코드 구조로 고정합니다.
use case 쪽에서는 `@UseCaseCapability`가 같이 등장합니다. 이 annotation은 use case의 transaction mode, idempotency, repository access, 외부 outbound 허용 여부를 드러냅니다. 그러면 class 이름이나 body를 끝까지 읽지 않아도 이 use case가 read인지 write인지, repository를 쓰는지, 외부 호출을 하는지 볼 수 있습니다. 그리고 ArchUnit은 이 선언과 실제 `TransactionPort` 호출이 맞는지 검사합니다.
예를 들어 `CreateWorkLogUseCase``transactionMode = WRITE`, `repositoryAccess = WRITE_REPOSITORY`를 선언하고 `tx.inWrite(...)` 안에서 aggregate 저장과 outbox append를 함께 수행합니다. 반대로 query use case는 `tx.inRead(...)`를 사용합니다. ca-tmpl은 application package에서 `org.springframework.transaction.annotation.Transactional`에 의존하는 것도 ArchUnit으로 막습니다. 즉 annotation을 몰래 붙여서 port를 우회하는 경로를 build-time에 차단합니다.
하지만 `TransactionPort`가 항상 더 좋은 선택이라는 뜻은 아닙니다. framework 교체 가능성이 낮고, 팀이 Spring transaction에 익숙하며, 대부분이 단순 CRUD라면 `@Transactional`을 직접 쓰는 편이 더 단순합니다. `TransactionPort`는 interface, adapter 구현, rule, test를 추가합니다. 이 비용은 skeleton처럼 경계를 학습하고 재사용해야 하는 프로젝트에서는 설명 가능하지만, 모든 팀의 기본값이 될 필요는 없습니다.
검증 범위도 분명히 나눠야 합니다. ca-tmpl에는 `TransactionPort`, `SpringTransactionPort`, capability annotation, ArchUnit rule, unit test가 존재하고 로컬/dev 수준으로 검증됐습니다. 하지만 운영 배포는 없고, 실 DB connection에서 `readOnly`가 flush mode를 어떻게 바꾸는지 측정한 자료도 없습니다. `REQUIRES_NEW`가 outbox/audit에서 실제 connection pool을 얼마나 쓰는지도 별도 통합 검증 대상입니다.
정리하면 ca-tmpl의 transaction boundary 결정은 “Spring을 쓰지 않겠다”가 아닙니다. Spring transaction은 adapter에서 사용합니다. 대신 application core는 “나는 read transaction이 필요하다”, “나는 write transaction이 필요하다”라는 의도만 port로 말합니다. 이 작은 우회 덕분에 application layer의 dependency rule, use case capability, ArchUnit fitness function이 한 줄로 이어집니다.
## 코드 예제 / Code samples (있다면)
```java
// 출처: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]
// 실제 파일: application-core/.../TransactionPort.java, ca-tmpl @f6fbd4e196b4
public interface TransactionPort {
<T> T inWrite(Supplier<T> action);
<T> T inRead(Supplier<T> action);
<T> T inNew(Supplier<T> action);
default void inWrite(Runnable action) { ... }
default void inRead(Runnable action) { ... }
default void inNew(Runnable action) { ... }
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]
// 실제 파일: adapter-persistence-rdbms/.../SpringTransactionPort.java, ca-tmpl @f6fbd4e196b4
@Component
public class SpringTransactionPort implements TransactionPort {
private final TransactionTemplate writeTemplate;
private final TransactionTemplate readTemplate;
private final TransactionTemplate requiresNewTemplate;
public SpringTransactionPort(PlatformTransactionManager transactionManager) {
this.writeTemplate = template(transactionManager, TransactionMode.WRITE,
TransactionDefinition.PROPAGATION_REQUIRED, false);
this.readTemplate = template(transactionManager, TransactionMode.READ_ONLY,
TransactionDefinition.PROPAGATION_REQUIRED, true);
this.requiresNewTemplate = template(transactionManager, TransactionMode.REQUIRES_NEW,
TransactionDefinition.PROPAGATION_REQUIRES_NEW, false);
}
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]
// 실제 파일: application-core/.../UseCaseCapability.java, ca-tmpl @f6fbd4e196b4
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface UseCaseCapability {
TransactionMode transactionMode();
Idempotency idempotency();
RepositoryAccess repositoryAccess();
boolean externalOutboundAllowed() default false;
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]
// 실제 파일: sample-portfolio/.../CreateWorkLogUseCase.java, ca-tmpl @f6fbd4e196b4
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class CreateWorkLogUseCase implements CommandUseCase<CreateWorkLogCommand, WorkLog> {
@Override
public WorkLog handle(CreateWorkLogCommand cmd) {
return tx.inWrite(
() -> {
WorkLog saved = repository.save(...);
appendReservedEvent(saved);
return saved;
});
}
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]
// 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4
@ArchTest
static final ArchRule APPLICATION_DOES_NOT_USE_SPRING_TRANSACTIONAL_ANNOTATION =
noClasses()
.that()
.resideInAPackage("..application..")
.should()
.dependOnClassesThat()
.haveFullyQualifiedName("org.springframework.transaction.annotation.Transactional")
.as("application package must use TransactionPort instead of @Transactional")
.allowEmptyShould(true);
```
```java
// 출처: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]
// 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4
@ArchTest
static final ArchRule USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY =
classes()
.that()
.areAnnotatedWith(UseCaseCapability.class)
.should(callTransactionPortMethodRequiredByCapability())
.as("READ_REPOSITORY+READ_ONLY -> inRead, WRITE_REPOSITORY+WRITE -> inWrite");
```
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
- [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]] — 이 글의 1차 canonical. `TransactionPort`, Spring 구현체, ArchUnit rule, unit/local verification, planned 항목과 과장 금지 경계를 따른다.
- [[wiki/concepts/transaction-boundary-abstraction]] — 관련 개념 문서. 현재 `draft`이므로 구현 사실의 출처로 쓰지 않는다.
## 사실 vs 의견 / Fact vs opinion 구분
- 사실: ca-tmpl에는 `TransactionPort`, `TransactionMode`, `Isolation`, `UseCaseCapability`, `SpringTransactionPort`, transaction 관련 ArchUnit rule과 unit test가 존재한다. 근거: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]
- 사실: application package에서 Spring `@Transactional` 의존을 금지하는 ArchUnit rule이 존재한다. 근거: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]
- 사실: 운영 배포, 실 DB 통합 검증, `readOnly` flush-mode 측정, `inNew` connection pool 실측은 없다. 근거: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]
- 의견: skeleton template에서는 `@Transactional` 직접 부착보다 port 기반 경계가 학습과 검증에 유리할 수 있다.
- 알지 못하는 것: production lock/contention behavior, 실제 DB vendor별 성능 차이.
## 답할 수 있는 범위 / Answer boundary
- 자신 있게 답할 수 있는 후속 질문:
- 왜 application core에 `@Transactional`을 직접 두지 않았는가?
- `TransactionPort.inWrite`/`inRead`/`inNew`는 각각 어떤 의도를 표현하는가?
- `SpringTransactionPort`가 mode별 `TransactionTemplate`을 미리 만드는 이유는 무엇인가?
- ArchUnit은 transaction boundary를 어디까지 강제하는가?
- 다음 글로 넘길 부분:
- vendor-specific isolation tuning.
- 실 DB/Testcontainers 기반 `readOnly`/`REQUIRES_NEW` 동작 검증.
- outbox와 transaction boundary의 통합 검증.
## 게시 체크리스트 / Publish checklist
- [x] 모든 사실 주장에 canonical 링크 있음
- [x] 사실 vs 의견 분리 명시됨
- [x] 금지 마케팅 표현 없음
- [x] 코드 예제 출처 명시
- [x] 타깃 독자 가정과 톤 일치
- [x] `/lint` 통과
- [ ] 게시 URL 기록 (게시 후):
## Related / 관련
- 후속 글 후보: [[wiki/blog/ca-tmpl-transactional-outbox-pattern-2026-07-02]]
- 관련 개념 문서: [[wiki/concepts/transaction-boundary-abstraction]]
@@ -0,0 +1,177 @@
---
title: Transactional Outbox를 Polling 계약으로 구현하기
source_type: blog
status: verified
confidence: high
tags: [blog, ca-tmpl, outbox, event-driven, transaction]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
canonical_sources:
- wiki/projects/ca-tmpl/transactional-outbox-pattern
audience: backend-engineer
target_publish:
status_label: ready
---
# Transactional Outbox를 Polling 계약으로 구현하기
## Parent / 부모 (필수)
- 핵심 canonical: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
- 관련 개념 문서: [[wiki/concepts/transactional-outbox-pattern]] — 일반 outbox 개념. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
## 타깃 독자 / Target reader
- 독자 profile: DB transaction과 message publish 사이의 원자성 문제를 skeleton에서 다루려는 백엔드 엔지니어.
- 이미 안다고 가정하는 것: transaction, message broker, retry.
- 처음 듣는다고 가정하는 것: SKIP LOCKED polling과 per-aggregate FIFO gate를 계약으로 다루는 방식.
## 도입 / Hook
- 문제 / 궁금증: DB commit과 broker publish를 한 번에 성공시키는 것은 생각보다 어렵다.
- 이 글이 답하는 것: ca-tmpl이 transactional outbox를 어떤 구현과 검증 범위로 잡았는지.
- 이 글이 답하지 않는 것: production broker throughput과 장애 복구 실측.
## 본문 outline / Body outline
1. dual write 문제와 outbox의 목적.
2. outbox table과 polling worker.
3. SKIP LOCKED와 per-aggregate FIFO gate.
4. idempotency/retry/DLQ와의 경계.
5. local verification과 운영 검증 없음.
## 본문 / Body
DB 저장과 message publish를 한 use case에서 함께 처리하면 dual-write 문제가 생깁니다. 예를 들어 주문을 DB에 저장한 직후 broker로 이벤트를 보내야 한다고 해보겠습니다. DB commit은 성공했는데 publish 직전에 프로세스가 죽으면, DB에는 상태가 남지만 외부 시스템은 그 사실을 모릅니다. 반대로 publish는 성공했는데 DB transaction이 rollback되면, 외부 시스템은 존재하지 않는 변경을 본 셈이 됩니다.
Transactional outbox는 이 틈을 줄이는 패턴입니다. business table을 수정하는 같은 DB transaction 안에서 outbox table에도 이벤트 row를 저장합니다. 그리고 별도의 relay가 outbox row를 읽어 broker로 publish합니다. 여기서 중요한 점은 “DB와 broker를 한 transaction으로 묶는다”가 아닙니다. broker publish는 여전히 바깥 작업입니다. 대신 DB 안에 “나중에 반드시 publish해야 할 사실”을 남겨서, 프로세스 실패 후에도 다시 이어갈 수 있게 만듭니다.
ca-tmpl은 outbox를 문서상의 패턴으로만 두지 않고, application port와 persistence adapter, PostgreSQL migration, relay use case, scheduler/metrics까지 구현했습니다. `OutboxAppendPort`는 business operation이 여는 `TransactionPort.inWrite(...)` 안에서 호출되어야 합니다. 구현체가 자기 transaction을 새로 열지 않는다는 계약도 중요합니다. 같은 write transaction에 aggregate save와 event append가 함께 있어야 dual-write를 줄이는 의미가 생기기 때문입니다.
outbox row는 상태 머신을 가집니다. 처음에는 `PENDING`이고 relay가 claim하면 `IN_FLIGHT`가 됩니다. publish가 성공하면 `PUBLISHED`, 일시 실패하면 `FAILED`, retry를 모두 소진하면 `DEAD`가 됩니다. `DEAD`는 단순한 로그가 아니라 운영자가 봐야 하는 terminal failure입니다. ca-tmpl 문서와 코드 모두 이 상태를 manual intervention이 필요한 상태로 둡니다.
claim 단계는 PostgreSQL의 `FOR UPDATE SKIP LOCKED`를 사용합니다. 여러 relay가 동시에 row를 읽을 때, 이미 다른 transaction이 잠근 row를 기다리지 않고 건너뛰게 하는 방식입니다. 이것은 multi-instance relay에서 같은 row를 동시에 claim하는 경합을 줄입니다. 다만 `SKIP LOCKED`가 순서 보존까지 해결하지는 않습니다. 그래서 ca-tmpl query에는 같은 aggregate의 더 이른 미게시 row가 있으면 뒤 row를 claim하지 않는 `NOT EXISTS` gate가 같이 들어갑니다.
relay use case의 흐름도 의도적으로 짧은 transaction과 바깥 publish를 나눕니다. 먼저 짧은 write transaction에서 batch를 claim합니다. 그다음 publish는 transaction 밖에서 수행합니다. 성공한 row는 다시 짧은 write transaction으로 `PUBLISHED` 처리합니다. publish 실패는 잡아서 `FAILED` 또는 `DEAD`로 바꾸고 error log를 남깁니다. 반대로 publish 성공 후 `markPublished`가 실패하면 예외를 삼키지 않습니다. row가 `IN_FLIGHT`로 남고 timeout 이후 재claim될 수 있기 때문입니다.
이 구조는 exactly-once delivery를 약속하지 않습니다. outbox relay가 publish 성공 후 상태 갱신에 실패하면 같은 event가 다시 publish될 수 있습니다. 따라서 consumer는 `eventId``idempotencyKey`로 dedupe해야 합니다. ca-tmpl project canonical도 이 지점을 명확히 나눕니다. outbox는 at-least-once delivery를 제공하고, 최종 정합성은 idempotent consumer와 함께 닫힙니다.
Debezium CDC나 Kafka Connect Outbox SMT도 대안입니다. 하지만 ca-tmpl은 skeleton baseline에서 Kafka Connect cluster, connector, WAL slot 운영을 기본 요구로 두지 않았습니다. 수 초 수준 lag를 허용하는 전제에서는 DB table + polling이 더 작은 운영 단위입니다. 대신 lag SLO가 sub-second로 내려가거나 polling query가 DB load를 만들면 CDC 전환을 검토하는 migration trigger를 문서에 남겼습니다.
검증 범위는 local/dev입니다. `./gradlew check`가 통과했고, application relay logic, RDBMS adapter, PostgreSQL Testcontainers 기반 row lifecycle과 SKIP LOCKED claim, publish adapter가 테스트되었습니다. 운영 배포, production lag 측정, DLQ 재처리 운영 경험은 없습니다. 따라서 이 글은 “outbox를 운영에서 검증했다”가 아니라 “ca-tmpl skeleton에 outbox polling 계약을 구현하고 로컬 검증했다”까지 말합니다.
## 코드 예제 / Code samples (있다면)
```java
// 출처: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
// 실제 파일: application-core/.../OutboxAppendPort.java, ca-tmpl @f6fbd4e196b4
public interface OutboxAppendPort {
/**
* Appends event to the outbox table, participating in the caller's existing
* write transaction. Calling outside TransactionPort.inWrite(...) is a
* contract violation.
*/
void append(NewOutboxEvent event);
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
// 실제 파일: application-core/.../OutboxEventStatus.java, ca-tmpl @f6fbd4e196b4
public enum OutboxEventStatus {
PENDING,
IN_FLIGHT,
PUBLISHED,
FAILED,
DEAD
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
// 실제 파일: adapter-persistence-postgresql/.../PostgreSqlOutboxClaimRepository.java
private static final String CLAIM_SQL =
"""
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
""";
```
```java
// 출처: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
// 실제 파일: application-core/.../PublishPendingOutboxEventsUseCase.java
List<OutboxEvent> claimed =
tx.inWrite(() -> store.claimBatch(batchSize, now, inFlightTimeout));
for (OutboxEvent event : sorted) {
publishPort.publish(event); // outside transaction
tx.inWrite(() -> store.markPublished(event.eventId()));
}
```
```sql
-- 출처: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
-- 실제 파일: adapter-persistence-postgresql/.../V3__outbox_event.sql
CREATE TABLE outbox_event (
event_id varchar(64) NOT NULL,
aggregate_id varchar(256) NOT NULL,
event_type varchar(256) NOT NULL,
payload text NOT NULL,
occurred_at timestamptz NOT NULL,
status varchar(16) NOT NULL,
attempt_count integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL,
correlation_id varchar(64) NOT NULL,
idempotency_key varchar(256) NOT NULL,
CONSTRAINT pk_outbox_event PRIMARY KEY (event_id)
);
```
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
- [[wiki/projects/ca-tmpl/transactional-outbox-pattern]] — 이 글의 1차 canonical. outbox 구현, SKIP LOCKED polling, Testcontainers/local verification, prod 미검증 경계를 따른다.
- [[wiki/concepts/transactional-outbox-pattern]] — 관련 개념 문서. 구현 사실 출처로 쓰지 않는다.
## 사실 vs 의견 / Fact vs opinion 구분
- 사실: ca-tmpl에는 `OutboxAppendPort`, `OutboxStorePort`, `OutboxEventStatus`, `PublishPendingOutboxEventsUseCase`, PostgreSQL `FOR UPDATE SKIP LOCKED` claim repository, outbox migration, publish adapter가 존재한다. 근거: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
- 사실: `./gradlew check`, application unit test, RDBMS adapter test, PostgreSQL Testcontainers 기반 row lifecycle/claim 검증이 로컬 범위에 포함된다. 근거: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
- 사실: 운영 배포, production lag 측정, DLQ 재처리 운영 경험은 없다. 근거: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
- 의견: ca-tmpl 같은 skeleton에서는 Debezium CDC보다 polling outbox가 더 작은 baseline일 수 있다.
- 알지 못하는 것: production lag, throughput, broker 장애 상황의 DLQ 운영 결과.
## 답할 수 있는 범위 / Answer boundary
- 자신 있게 답할 수 있는 후속 질문:
- transactional outbox가 dual-write 문제를 어떻게 줄이는가?
- `FOR UPDATE SKIP LOCKED`는 claim 경합에서 무엇을 해결하는가?
- per-aggregate FIFO gate가 왜 별도로 필요한가?
- 왜 outbox가 exactly-once가 아니라 at-least-once + consumer dedupe인가?
- 다음 글로 넘길 부분:
- broker-specific scaling.
- Debezium CDC/Kafka Connect Outbox SMT 전환.
- production lag/DLQ 운영 측정.
## 게시 체크리스트 / Publish checklist
- [x] 모든 사실 주장에 canonical 링크 있음
- [x] 사실 vs 의견 분리 명시됨
- [x] 금지 마케팅 표현 없음
- [x] 코드 예제 출처 명시
- [x] 타깃 독자 가정과 톤 일치
- [x] `/lint` 통과
- [ ] 게시 URL 기록 (게시 후):
## Related / 관련
- 후속 글 후보: [[wiki/blog/ca-tmpl-idempotency-key-design-2026-07-02]]
@@ -0,0 +1,43 @@
---
title: interview-prep / archunit-manual-importer-vs-analyzeclasses
source_type: interview-prep
status: raw
related_branches: [feature-test-taxonomy-fixture-contract, feature-architecture-enforcement-rules]
related_projects: [ca-skeleton]
tags: [interview-prep, ca-skeleton, archunit, test-taxonomy, do-not-include-tests, manual-importer]
created: 2026-06-19
status_label: collecting
---
# interview-prep: archunit-manual-importer-vs-analyzeclasses
> Layer: `raw/interviews/` — 면접 질문 원본 수집·연구 노트. 다듬어진 답변은 `/interviewize` 후 `wiki/interview/` 에 별도 작성.
## Parent / 부모
- [[raw/branch-notes/feature-test-taxonomy-fixture-contract]] — Task 2 에서 "contract·architecture 레벨 test 는 Testcontainers 의존 금지" rule 을 구현할 때 부딪힌 핵심 결정.
## 질문 / Question
- 질문 원문: ArchUnit 에서 `@AnalyzeClasses(importOptions = DoNotIncludeTests.class)``new ClassFileImporter().importPackages(...)` 를 각각 언제 쓰나요? 규칙의 _대상_ 이 test 코드 자체일 때 왜 `@AnalyzeClasses` 만으로는 안 되나요?
- 출처: 예상 질문 (실 면접 아님).
- 받은 날짜·맥락: 아직 없음 — 2026-06-19 test-taxonomy-fixture-contract 구현에서 도출.
## 질문 의도 추론 / Why this question
- 핵심 평가 대상:
- ArchUnit 의 import scope 가 _규칙이 무엇을 볼 수 있는가_ 를 결정한다는 것을 이해하는지. 규칙 본문(`noClasses().should()...`)만 보고 "왜 안 잡히지?" 를 import 설정에서 진단할 수 있는지.
- production 규칙과 test-에-대한 규칙을 한 suite 에 섞었을 때 생기는 vacuous-pass 위험을 인지하는지.
## 답변 골자 / Answer skeleton (raw)
- ca-tmpl 의 production 아키텍처 suite(`CleanArchitectureTest`, `DisabledAdapterArchitectureTest`, `NamingConventionTest`)는 전부 `@AnalyzeClasses(packages = "dev.caskeleton", importOptions = ImportOption.DoNotIncludeTests.class)` — production bytecode 만 본다. 그래야 "domain 은 Spring 의존 금지" 같은 규칙이 test util 의 Spring import 때문에 오탐하지 않는다.
- 그런데 test-taxonomy 계약(§테스트 계약 #4: "contract·architecture _테스트_ 가 Testcontainers 에 의존하면 실패")은 _대상이 test 클래스_ 다. `DoNotIncludeTests` 가 그 클래스를 import 단계에서 제거하므로 `@AnalyzeClasses` 규칙은 영원히 빈 subject 를 받아 vacuously pass 한다.
- 해법: 규칙을 `static final ArchRule` 필드로 정의하고, 별도 `@Test` 에서 `new ClassFileImporter().importPackages("dev.caskeleton.bootstrap.contract")` 로 test bytecode 를 명시적으로 로드해 `rule.evaluate(corpus)` 를 직접 호출한다. `ArchitectureViolationFixtureTest` 가 violation fixture 를 같은 방식으로 로드하는 패턴과 동일하다.
- `importPackages``.class` 바이트를 직접 읽어 JVM class loading 을 하지 않으므로 `testCompileOnly` 타입(Testcontainers 등)이 runtime 에 resolve 되지 않아도 안전하다.
- vacuity 방어: 규칙을 정의했으면 _반드시_ positive control 을 둔다 — 본 작업에서는 Testcontainers 를 실제로 쓰는 `bootstrap.integration` 패키지에 같은 규칙을 평가해 `hasViolation() == true` 를 단언했다. (관련: [[raw/interviews/archunit-static-analysis-limits]], [[raw/blog-topics/archunit-violations-as-data-pattern-2026-05-28]])
## 더 팔 거리 / Follow-ups
- `allowEmptyShould(true)` 를 언제 쓰고 왜 위험한가 (빈 subject 를 의도적으로 허용 → positive control 없으면 vacuous). 관련 errors: [[raw/errors/archunit-empty-should-anchor-2026-05-27]].
- production 규칙(`production_code_does_not_depend_on_test_fixtures`)은 `DoNotIncludeTests` 위에서 동작하는데 어떻게 meta-verify 했나 → fixtureleak violation 패키지를 manual importer 로 로드해 같은 rule 객체를 평가.
@@ -0,0 +1,101 @@
---
title: interview-prep / archunit-static-analysis-limits
source_type: interview-prep
status: raw
related_branches: [feature-architecture-enforcement-rules, feature-application-port-usecase-contract]
related_projects: [ca-skeleton]
tags: [interview-prep, ca-skeleton, archunit, fitness-function, static-analysis, reflection]
created: 2026-05-28
status_label: collecting
---
# interview-prep: archunit-static-analysis-limits
> Layer: `raw/interviews/` — 면접 질문 원본 수집·연구 노트. 다듬어진 답변은 `/interviewize` 후 `wiki/interview/` 에 별도 작성.
## Parent / 부모
- [[raw/branch-notes/feature-architecture-enforcement-rules]] — D11 (`ApplicationContext` 금지) + D12 (string-key bypass 한계) + Claims to Verify 의 violations-as-data 보완.
- [[raw/project-notes/ca-skeleton-operational-contract]] — ca-tmpl skeleton 의 boundary 자동 검증 정책 맥락.
## 질문 / Question
- 질문 원문: ArchUnit 같은 정적 분석 기반 fitness function 의 _한계_ 를 인지하면서 어떻게 _믿을 수 있게_ 만들었나요? runtime reflection 우회 / 빈 scope 의 vacuous pass / generated code 처리 같은 케이스는 어떻게 다뤘나요?
- 출처: 예상 질문 (실 면접 아님).
- 받은 날짜·맥락: 아직 없음.
## 질문 의도 추론 / Why this question
- 핵심 평가 대상:
- 정적 분석의 _한계__구체적_ 으로 인지하는지 (단순히 "있다" 가 아니라 _어떤_ 코드 패턴이 catch 되지 않는지).
- vacuous pass 함정 (scope 가 비어 있을 때도 SUCCESS 반환) 을 인지하고 _negative test 로 보완_ 했는지.
- runtime bypass 를 _code review checklist_ / Sonar / Spring Modulith 같은 _보완 도구_ 로 메우는 감각.
- generated code (MapStruct, Lombok, Spring AOT) 와 fitness function 의 충돌 처리.
- 함정 / 흔히 빠지는 답변 패턴:
- "ArchUnit 으로 다 막을 수 있다" — reflection / `ApplicationContext#getBean(String)` / `Class.forName(String)` 의 catch 불가 인식 없음.
- "rule 이 있으면 catch 된다고 믿는다" — vacuous pass 가능성 인지 못함.
- generated code 를 rule 의 _예외_ 로 처리하지 못해 build 가 깨지는 시나리오.
- 따라올 만한 후속 질문:
- `noClasses().that(...)` rule 이 빈 scope 에서 어떤 동작인가요? 어떻게 _vacuous pass_ 를 막을 수 있나요?
- `ApplicationContext#getBean(String)` 은 왜 ArchUnit 이 못 잡나요? `getBean(Class)` 는 어떻게 다른가요?
- MapStruct generated mapper 를 mapper boundary rule 에 어떻게 _예외_ 처리하나요? Spring AOT 와는?
- custom `ArchCondition` 은 언제 필요하나요? 예시?
## 답변 재료 / Raw answer material
- 사실 1 (근거: `feature-architecture-enforcement-rules.md` D11): ArchUnit 의 banned-class rule (`noClasses().that(pkg).should().dependOnClassesThat().haveFullyQualifiedName("...ApplicationContext")`) 은 _class literal_ 이 bytecode 에 박힌 의존만 catch. ca-tmpl 의 `application_does_not_depend_on_application_context` 가 이 패턴.
- 사실 2 (근거: `feature-architecture-enforcement-rules.md` D12 + `raw/official-docs/archunit-user-guide.md` 의 negative claim): ArchUnit 은 bytecode 의 method/constructor call 만 본다. _string content_ 자체는 bytecode 에 노출되지만 의미 분석은 안 한다. 결과적으로 `getBean("repository")` 같은 string-key bean lookup 과 `Class.forName(System.getenv("FOO"))` 같은 dynamic target 은 catch 불가.
- 사실 3 (근거: 파생 에러 [[raw/errors/archunit-test-scope-sample-ticket-inclusion-2026-05-28]]): ArchUnit 의 vacuous pass 함정 — `@AnalyzeClasses(packages = ...)` 가 패키지 _필터_ 이고 _scan source_ 가 아니다. 분석 대상이 0개일 때도 rule 은 SUCCESS. ca-tmpl 의 첫 시도에서 `app-bootstrap` 의 test classpath 가 `sample-ticket` 을 안 보아 새 rule 이 vacuously pass 한 사례.
- 사실 4 (근거: `feature-architecture-enforcement-rules.md` Claims to Verify 마지막 행 `actually-implemented` + `feature-application-port-usecase-contract.md` 구현 결과 round 2): ca-tmpl 의 보완 — Spring Modulith `example/ninvalid` 패턴 차용. `src/app-bootstrap/src/test/java/.../violations/` 에 6 fixture + `ArchitectureViolationFixtureTest` 에 6 negative test. 각 rule 의 _실 catch 동작_ 을 commit 으로 박음.
- 사실 5 (근거: `feature-architecture-enforcement-rules.md` D9 + `raw/official-docs/mapstruct-generated-annotation-official.md#MS-ANNOT-C1`): MapStruct generated mapper 는 `javax.annotation.processing.Generated` 어노테이션 부착. ArchUnit `.and().areNotAnnotatedWith(Generated.class)`_annotation-based exemption_ 가능. annotation FQN 주의 — MapStruct 와 Spring AOT (`org.springframework.aot.generate.Generated`) 가 다른 클래스.
- 사실 6 (근거: `feature-application-port-usecase-contract.md` D14 + `CleanArchitectureTest#notDeclareKeyedIdempotency`): annotation parameter 의 enum value 검사는 ArchUnit DSL 로 표현 불가 → custom `ArchCondition<JavaClass>` 작성. `JavaAnnotation.get("idempotency")``JavaEnumConstant` 를 반환하므로 _reflection 없이 bytecode 만으로_ enum 값 catch.
- 내가 직접 한 경험:
- ca-tmpl 의 14개 ArchUnit rule 중 `D11` 의 banned-class rule + `D14` 의 custom `ArchCondition` 작성.
- vacuous pass 사례 발견 → `testImplementation project(':sample-ticket')` 으로 scope 확장 → `ArchitectureViolationFixtureTest` 6 negative test 로 catch 동작 보증.
- Lombok 금지 rule (`lombok..` 추가 to `domain_is_pure`) — Lombok 이 generated bytecode 를 만들어서 domain 의 framework 독립성을 흐릴 위험 차단.
- 트레이드오프:
- **정적 분석 한계 인정 vs 만능 도구화**: ArchUnit 으로 _대부분_ 의 boundary 위반은 catch 가능. 단 string-key bypass / reflection / DI runtime lookup 은 catch 불가 — _code review checklist_ + Sonar custom rule 로 보완. ca-tmpl 은 후자를 documented-only 로 유지.
- **rule 작성 비용 vs 위반 catch 정밀도**: 단순 DSL rule 은 빠르지만 vacuous pass 위험. custom condition + negative test fixture 는 catch 정밀도 ↑ 이지만 작성/유지 비용 ↑. ca-tmpl 은 _core rule 14개_ 에만 fixture 적용 (정밀도 우선).
- **generated code exemption**: 너무 넓은 exemption (예: `package..mapper..` 통째 제외) 은 hand-written 위반도 함께 통과. annotation-FQN 기반 exemption 이 _좁고 안전_ — MapStruct `@Generated` vs Spring AOT `@Generated` 의 FQN 차이 인식.
- 한계 / "이건 안 해봤다":
- Sonar custom rule / IDE inspection 으로 string-key bypass 를 _얼마나_ 보완할 수 있는지 정량 측정 미수행.
- Spring Modulith verifier 의 named interface 검증과 ca-tmpl 의 ArchUnit rule 의 _중복/대체_ 비교 미수행.
- `ApplicationContext#getBean(Class)` class-literal 호출이 ca-tmpl 의 D11 rule 로 _실제_ catch 되는지는 negative test 로 보증했지만, 실 사업 도메인에서의 false-positive 비율 측정 안 함.
## Sources / 근거
- [[raw/branch-notes/feature-architecture-enforcement-rules]] — D9, D11, D12 + Claims to Verify status.
- [[raw/branch-notes/feature-application-port-usecase-contract]] — D14 custom ArchCondition + violations-as-data round 2.
- [[raw/errors/archunit-test-scope-sample-ticket-inclusion-2026-05-28]] — vacuous pass 의 실 사례.
- [[raw/official-docs/archunit-user-guide]] — `JavaAnnotation` / `JavaEnumConstant` / `EvaluationResult` 공식 (`ARCHUNIT-UG-C5`, `ARCHUNIT-UG-C6`).
- [[raw/official-docs/mapstruct-generated-annotation-official]] — `@Generated` FQN (`MS-ANNOT-C1`, `MS-ANNOT-C2`).
- [[raw/company-tech-blogs/spring-modulith-archunit-generated-exemption-and-violations-as-data]] — `annotatedWith(Generated.class)` predicate + `example/ninvalid` 패턴 (`SPRING-MOD-AU-C1`, `SPRING-MOD-AU-C2`).
## 미해결 / Unknown
- 모르는 것: Sonar / SpotBugs custom rule 이 _string-key bean lookup_ 류를 얼마나 잘 catch 하는지 — _Sonar Quality Profile_ 의 표준 rule set 보강 필요.
- 모르는 것: Spring Modulith named interface 검증의 internal model 이 ArchUnit 의 `JavaClass` 와 어떻게 다른지 — Modulith 도입 시 중복 rule 청산 비용.
- 확인 방법: `feature-ci-quality-gates-contract` 후속 branch 에서 Sonar custom rule + Modulith verifier 도입 PoC.
## 답변 경계 / Answer boundary
- 자신 있게 말할 수 있는 범위:
- ca-tmpl 의 14 ArchUnit rule + 6 negative test fixture 의 _직접 구현_ 범위.
- vacuous pass 함정 두 갈래 (production 0개 매칭 vs scope 0개 매칭) 의 _구체 사례__보완 방법_.
- custom `ArchCondition` 으로 annotation parameter (enum value) catch 한 D14 의 구현 패턴.
- MapStruct `@Generated` exemption 의 annotation-FQN 기반 패턴 (구현은 안 했지만 `adapter-persistence/CLAUDE.md` 에 example 명시).
- "이 부분은 공식 문서를 다시 보고 답변드리겠습니다" 라고 해야 하는 부분:
- Sonar custom rule 작성의 _Quality Profile_ 표준 운영.
- Spring Modulith verifier 의 named interface 구체 configuration (도입 안 했음).
- 운영 환경에서 ArchUnit rule 변경의 _CI 차단_ 정책 (개인 경험 없음, `feature-ci-quality-gates-contract` 후속).
- **절대 과장하지 말 것**:
- "ArchUnit 으로 모든 boundary 위반을 catch 한다" 표현 금지 — D12 의 string bypass 한계가 명시됨.
- "violations-as-data 가 fitness function 의 _모든_ regression 을 잡는다" 표현 금지 — negative test 자체도 정적이라 reflection bypass 는 못 잡음.
- 운영 환경 검증 경험인 것처럼 표현 금지 — `locally-verified` 등급. ca-tmpl 은 template repository.
## Related / 관련
- 관련 면접 질문 (선행/후속): [[raw/interviews/clean-architecture-boundary-enforcement]] (선행 — boundary 자동 검증의 자매), [[raw/interviews/clean-architecture-module-blueprint]] (선행 — module 분리의 __), [[raw/interviews/transaction-port-vs-spring-transactional]] (자매 — application framework 격리).
- 영감을 받은 채용공고: (없음).
- 관련 블로그 글감: [[raw/blog-topics/archunit-violations-as-data-pattern-2026-05-28]] (같은 작업의 글감).
- 답변 derive 후 위치: 생성 전. 후보 `wiki/interview/architecture/archunit-static-analysis-limits.md`.
@@ -0,0 +1,32 @@
---
title: ArchUnit violations-as-data 패턴 면접 질문
source_type: interview
status: raw
related_branch: feature-streaming-response-contract
tags: [archunit, violations-as-data, testing, clean-architecture, interview]
created: 2026-06-02
---
# ArchUnit violations-as-data 패턴 면접 질문
## Parent
- [[raw/branch-notes/feature-streaming-response-contract]]
## 질문 목록
**Q1.** ArchUnit 에서 `@AnalyzeClasses(importOptions = DoNotIncludeTests.class)` 를 사용하는 이유는?
> 핵심: production code 만 스캔 대상으로 한정. test fixtures 가 의도적으로 규칙을 위반하더라도 production 아키텍처 테스트가 실패하지 않도록.
**Q2.** "vacuous pass" 문제가 무엇이며 violations-as-data 패턴이 어떻게 해결하는가?
> ArchUnit rule 이 production code 에서 아무것도 매칭하지 못할 때 `allowEmptyShould(true)` 없으면 예외, 있으면 통과. 통과 여부가 "규칙이 실제로 위반을 잡는가" 와 무관 → vacuous pass. violations-as-data: 의도적 위반 fixture 에 대해 `rule.evaluate(fixtures).hasViolation() == true` 를 별도로 단언.
**Q3.** `testCompileOnly` 로 선언된 타입을 ArchUnit 위반 fixture 에서 참조할 때 주의사항은?
> `testCompileOnly` 는 compile-time 전용이라 test execution runtime classpath 에 없음. JUnit 이 fixture class 를 로드할 때 superclass/interface 를 즉시 resolve → `NoClassDefFoundError`. annotation 참조는 lazy-resolve 이므로 안전. 따라서 forbidden type 이 `testCompileOnly` 라면 **annotation 으로만** 참조.
**Q4.** over-block guard test 가 필요한 이유는? 예시를 들어 설명하라.
> "차단하지 말아야 할 것을 차단하지 않는다" 를 검증. 예: `no_response_body_emitter` 는 `ResponseBodyEmitter` 를 차단하되 `StreamingResponseBody` 는 차단하지 않아야 함. `ALLOWED_STREAMING_CLASSES` 에서 `hasViolation() == false` 를 단언 → 규칙 경계가 의도대로임을 보장.
@@ -0,0 +1,53 @@
---
title: interview / async-executor-saturation-context-propagation-2026-06-13
source_type: interview-prep
status: raw
related_branches: [feature-background-job-async-contract]
related_projects: [ca-skeleton]
tags: [interview, ca-skeleton, async, threadpool, taskdecorator, mdc, graceful-shutdown, micrometer]
created: 2026-06-13
status_label: captured
---
# interview: async-executor-saturation-context-propagation-2026-06-13
> Layer: `raw/interviews/` — 작업에서 정직하게 도출 가능한 면접 질문. 답은 실제 구현/검증 근거에 묶는다.
## Parent / 부모
- [[raw/branch-notes/feature-background-job-async-contract]] — D5/D6/D7/D8 실 구현에서 도출한 질문.
## 질문 / Questions
### Q1. Spring Boot 의 기본 `@Async` executor 를 운영에서 그대로 쓰면 무슨 문제가 있나?
- 핵심: `ThreadPoolTaskExecutor` 의 queue capacity 기본값이 `Integer.MAX_VALUE`(사실상 unbounded). JDK `ThreadPoolExecutor` 는 큐가 가득 찰 때만 core→max 로 성장하므로, unbounded 큐에서는 `maxPoolSize` 가 영원히 발동하지 않는다. 부하가 몰리면 스레드가 아니라 큐(=힙)가 무한정 쌓여 OOM/지연으로 번진다.
- 후속: 어떻게 고치나? → bounded queue 강제 + 직접 executor 빈 등록(자동 구성은 `@ConditionalOnMissingBean(Executor.class)` 로 back-off). `Integer.MAX_VALUE` 큐 용량은 "이름만 bounded" 이므로 설정 검증에서 거부.
### Q2. saturation(거부)이 발생했을 때 무엇이 "조용히 삼켜지는" 위험인가? 어떻게 막나?
- AbortPolicy 는 `RejectedExecutionException` 을 던지지만, fire-and-forget `@Async` 호출이면 호출부가 그 예외를 못 본다. 따라서 거부 핸들러를 감싸 (1) 구조화 ERROR 로그(error.code), (2) 카운터(`executor.rejected.total`) 를 먼저 남기고 예외를 재던진다. 거부율은 alert(p1)로 노출.
- 후속: CallerRunsPolicy 는 왜 기본이 아닌가? → caller 가 request 스레드면 back-pressure 가 요청 지연을 직접 침식한다. use case 차원에서 명시 선언할 때만 허용.
### Q3. `@Async` 작업에 호출 스레드의 MDC(request_id/trace_id 등)를 어떻게 넘기나? 함정은?
- `TaskDecorator` 로 submit 시점에 `MDC.getCopyOfContextMap()` 스냅숏을 떠 worker 에서 복원. 두 함정: (1) **캡처 시점** — run time 이 아니라 decorate(submit) time 에 떠야 호출 당시 컨텍스트가 잡힌다. (2) **대칭 복원** — 작업 후 worker 의 이전 MDC 로 되돌리지 않으면 풀 재사용 스레드가 한 작업의 MDC 를 다음 작업으로 흘린다(MDC bleed).
- 후속: 왜 `InheritableThreadLocal` 을 안 쓰나? → 풀 스레드는 미리 생성/재사용되므로 상속 시점이 호출과 무관해 stale. 명시적 capture/restore 가 정답.
### Q4. SecurityContext(principal)는 왜 기본 전파하지 않나?
- 풀 스레드 재사용 + `MODE_INHERITABLETHREADLOCAL` 조합은 다른 요청의 principal 이 남아있는 stale context 위험. 그래서 기본 전파 대상은 MDC 4키뿐이고(registry 상 user_principal=`propagation: [none]`), principal 이 필요한 use case 만 `DelegatingSecurityContextTaskExecutor` 로 명시적 opt-in.
### Q5. graceful shutdown 에서 in-flight 배경 작업을 어떻게 다루나? 19s 같은 숫자는 어디서 오나?
- `setWaitForTasksToCompleteOnShutdown(true)` + `setAwaitTerminationSeconds(N)`. 예산 계층: executor await(≤19s) < app shutdown(20s) ≤ `spring.lifecycle.timeout-per-shutdown-phase` < k8s `terminationGracePeriodSeconds`(기본 30s). 19 = 20s 1s 정리 마진. grace period 초과 시 SIGKILL 이라 await 가 그 안에 끝나야 한다.
- 후속: interrupt 에 반응 안 하는 blocking call(JDBC)이면? → awaitTermination 초과 → SIGKILL 노출. 그래서 in-flight 가 19s 를 넘으면 멱등 retry-on-next-startup 을 전제로 설계.
### Q6. retry 횟수(retry_attempt)를 metric 태그로 넣으면 안 되는 이유는?
- 카디널리티 폭발. retry_attempt 는 값 범위가 작아 보여도 job_name×outcome×attempt 조합이 시계열을 곱한다. registry 에서 `job.retry.total` 의 태그는 `job_name`+`outcome`(bounded 4: SUCCESS/RETRY/EXHAUSTED/DLQ)뿐이고, retry_attempt 는 **로그 필드**로만 둔다. 메트릭 레코더의 시그니처에 attempt 를 넣지 않는 이유.
## Sources / 근거
- 로컬 검증: `:app-bootstrap:test` 의 async 패키지 30 테스트 green (AsyncContextTaskDecoratorTest 의 submit-time 캡처·대칭 복원·stale clear, LoggingAbortPolicyTest 의 거부 로그+카운터+재던짐, AsyncExecutorConfigTest 의 bounded queue·19s await·decorator-missing fail).
- 외부 근거: [[raw/official-docs/jdk21-threadpoolexecutor-javadoc]], [[raw/official-docs/spring-framework-threadpooltaskexecutor-javadoc]], [[raw/official-docs/spring-executor-configuration-support-javadoc]], [[raw/official-docs/kubernetes-pod-lifecycle-termination]], [[raw/official-docs/spring-security-concurrency-delegating-security-context-executor]].
@@ -0,0 +1,49 @@
---
title: interview-prep / ci-release-gate-fan-in-blocking
source_type: interview-prep
status: raw
related_branches: [feature-ci-quality-gates-contract]
related_projects: [ca-skeleton]
tags: [interview-prep, ca-skeleton, ci, github-actions]
created: 2026-06-20
status_label: collecting
---
# interview-prep: ci-release-gate-fan-in-blocking
> Layer: `raw/interviews/` — 면접 질문 원본 수집·연구 노트.
> `status_label`: `collecting`
## Parent / 부모
- [[raw/branch-notes/feature-ci-quality-gates-contract]] — release-blocking 게이트 fan-in 을 구현하며 "한 게이트가 실패하면 정말 릴리스가 막히나?" 라는 질문이 자연스럽게 도출됨.
## 질문 / Question
- 질문 원문: "여러 CI 게이트(빌드/테스트/정적분석/계약테스트…)를 하나의 required check 로 묶을 때, 그 중 하나라도 실패하면 머지가 *반드시* 막히도록 어떻게 보장했나요?"
- 출처: 예상 질문 (branch 작업에서 유추 — fan-in status 전파는 branch-note Claim C1 의 핵심 불확실성)
- 받은 날짜·맥락: (예상)
## 질문 의도 추론 / Why this question
- 핵심 평가 대상: CI 도구의 *기본 동작* 을 안다고 착각하지 않고 실제로 검증하는가 + "통과처럼 보이지만 차단 안 되는" 위양성 위험 인지.
- 함정 / 흔히 빠지는 답변 패턴: "aggregator 잡을 만들고 `needs` 로 묶었다" 로 끝내는 것. `needs + if: success()` aggregator 는 상위 실패 시 **`failure` 가 아니라 `skipped`** 가 되고, branch protection 이 skipped 를 통과로 오해할 수 있다 → 차단 실패.
## 모범 답안 뼈대 / Answer skeleton
- 결론 먼저: aggregator 를 `if: always()` 로 두고, `needs.*.result` 를 스캔해 `failure`/`cancelled` 가 하나라도 있으면 명시적으로 `exit 1`. 그래야 "1건 실패 → release block" 이 보장된다.
- 근거: GitHub Actions 의 `needs` 기본은 상위 실패 시 하위 잡 skip. `success()` 는 그 기본을 적은 것일 뿐 aggregator 를 *실패* 로 만들지 않는다. skip 은 차단이 아니다.
- 검증: 의도적으로 matrix 잡 1개를 실패시켜 aggregator 가 *fail* 인지 *skip* 인지 직접 확인(공식 문서만 믿지 않음 — evidence-first).
- 세부: PR-only 잡(예: 라벨 게이트)은 push 이벤트에서 `skipped` 이므로 result 스캔에서 skip 은 OK 로 통과시키고, 비차단 잡(flaky `quarantine`)은 애초에 `needs` 에서 제외한다.
- 확장: 워크플로 간 `needs` 는 불가능 → 여러 워크플로의 required 잡 *합집합* 을 branch protection 에 등록해야 전체 release-blocking 집합이 완성된다.
## 꼬리 질문 / Follow-ups
- "`continue-on-error``if: always()` 의 차이는?" → 전자는 잡을 실패해도 성공으로 *보고*(비차단 게이트용), 후자는 상위 결과와 무관히 *실행*(aggregator 용).
- "matrix 잡 일부만 실패하면?" → `fail-fast: false` + result 스캔이면 모든 조합을 돌려 어떤 adapter 가 깨졌는지까지 본 뒤 차단.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-ci-quality-gates-contract]]
- 관련 error: [[raw/errors/ci-fan-in-skipped-not-failed-and-gitignored-config-2026-06-20]]
- 관련 blog topic: [[raw/blog-topics/ci-gate-wiring-vs-policy-ownership-2026-06-20]]
@@ -0,0 +1,104 @@
---
title: interview-prep / clean-architecture-boundary-enforcement
source_type: interview-prep
status: raw
related_branches: [feature-architecture-enforcement-rules]
related_projects: [ca-skeleton]
tags: [interview-prep, ca-skeleton, architecture, testing, archunit, clean-architecture, gradle]
created: 2026-05-28
status_label: collecting
---
# interview-prep: clean-architecture-boundary-enforcement
> Layer: `raw/interviews/` — 면접 질문 원본 수집·연구 노트. 다듬어진 답변은 `/interviewize` 후 `wiki/interview/` 에 별도 작성.
## Parent / 부모
- [[raw/branch-notes/feature-architecture-enforcement-rules]] — Clean Architecture 경계를 Gradle + ArchUnit fitness function 으로 강제한 결정 (D1~D10) + 검증 결과.
- [[raw/project-notes/ca-skeleton-operational-contract]] — ca-tmpl skeleton 의 module boundary / operational contract SSOT.
## 질문 / Question
- 질문 원문: Clean Architecture 템플릿에서 계층 경계가 시간이 지나도 깨지지 않도록 어떤 방식으로 자동 검증했나요?
- 출처: 예상 질문 (실제 면접에서 받은 것 아님).
- 받은 날짜·맥락: 아직 없음.
## 질문 의도 추론 / Why this question
- 핵심 평가 대상:
- 아키텍처 원칙을 _문서가 아니라_ 자동 검증으로 연결한 경험.
- Gradle multi-module dependency 와 ArchUnit bytecode rule 의 _역할 분리_ 인식 (각자 잡는 위반 종류가 다름).
- 정적 분석의 _한계_ 인식 (runtime reflection, generated code, Spring Modulith 등 보완 도구의 자리).
- 단순 "Clean Architecture 적용했다" 선언이 아니라 _실제 위반 코드를 넣어 red/green 검증_ 한 경험.
- 함정 / 흔히 빠지는 답변 패턴:
- "Clean Architecture 적용했다" 로 끝내고 controller/repository/JPA entity leak 을 _구체적으로 어떻게 막았는지_ 설명 못함.
- Gradle 과 ArchUnit 의 _역할 차이_ 를 묻지 않고 "둘 다 썼다" 로 뭉뚱그림.
- 한계 (reflection, MapStruct generated path, Spring Modulith 도입 안 함) 를 솔직히 말하지 않고 만능처럼 표현.
- 따라올 만한 후속 질문:
- Gradle dependency rule 과 ArchUnit rule 은 각각 _어떤 위반_ 을 잡나요? 한쪽만으로는 왜 안 되나요?
- ArchUnit 이 잡지 못하는 위반은 무엇이고 어떻게 보완할 건가요?
- sample module 이 production code 로 역수입되는 걸 어떻게 막았나요?
- 빈 anchor module 은 ArchUnit 에서 어떻게 처리했나요?
- Spring Modulith 를 도입하지 않은 이유는 무엇이고, 추후 도입한다면 무엇이 _중복_ 되고 무엇이 _보완_ 인가요?
## 답변 재료 / Raw answer material
> 사실은 branch-note Decision ID 또는 외부 source claim ID 로 근거 같이 인용. 경험은 _내가 직접 한 것_ 만.
- 사실 1 (근거: `feature-architecture-enforcement-rules.md` D2): ca-tmpl 의 module 구조는 `domain-core` + `application-core` + `adapter-{web,persistence,outbound}` + `shared-contract` + `sample-ticket` + `app-bootstrap` 8개. module boundary 가 _1차 강제선_, module 내부 package 가 _2차 책임 분류_.
- 사실 2 (근거: `feature-architecture-enforcement-rules.md` D1 + 외부 `governance-archunit-official.md#AU-OFF-C1`): boundary 강제는 _두 층_ — Gradle `verifyCleanArchitectureDependencies` task 가 declared module coverage + allowed project dependency 매트릭스를 검사하고, ArchUnit `CleanArchitectureTest` 가 bytecode/import 수준의 12 rule 을 검사.
- 사실 3 (근거: `feature-architecture-enforcement-rules.md` D3, D4, D5, D6, D7, D8): ArchUnit 이 잡는 위반 — domain purity (Spring/JPA/HTTP import 금지), application → adapter/bootstrap 의존 금지, adapter 간 직접 의존 금지, web DTO boundary, sample-ticket production 역수입 금지, application `@Transactional` 직접 import 금지, controller direct domain response 금지, mapper boundary, shared-contract package allowlist.
- 사실 4 (근거: `feature-architecture-enforcement-rules.md` Claims to Verify 의 status 표): 위 8개 rule 모두 `actually-implemented` 또는 `locally-verified`. red/green 검증 (임시 위반 코드 → 실패 → 제거 → 통과) 까지 수행. `cd src && ./gradlew :app-bootstrap:test verifyCleanArchitectureDependencies``cd src && ./gradlew test` 모두 통과.
- 사실 5 (근거: `feature-architecture-enforcement-rules.md` D8 + `feature-application-port-usecase-contract.md` D3): application 의 `@Transactional` 금지는 _Spring 공식 권고와 충돌_ 하는 의도적 소수파 결정. 다수파 (`@Transactional` 직접 부착, hexagonal-reflectoring) 가 reasonable 함을 인정하면서, template repository 의 _격리 학습 비용_ 흡수가 이유.
- 내가 직접 한 경험:
- ca-tmpl `src/build.gradle``verifyCleanArchitectureDependencies``src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java` 두 파일을 함께 보강.
- 임시 위반 코드 4종 (`shared.ticket` package, controller domain return, mapper → application 의존, application `@Transactional`, `app-bootstrap → sample-ticket` Gradle dep) 추가 → 실패 확인 → 제거 → 통과.
- 빈 skeleton anchor module 의 ArchUnit empty-should failure 를 `allowEmptyShould(true)`_선별_ 해결 (모든 rule 에 일괄 적용 ≠ 빈 상태가 의도된 rule 에만 적용) — [[raw/errors/archunit-empty-should-anchor-2026-05-27]].
- Codex sandbox 의 read-only `~/.gradle` 권한 때문에 Gradle wrapper lock 실패 → 사용자 승인 escalation 으로 재실행 — [[raw/errors/gradle-wrapper-readonly-cache-2026-05-28]]. _코드 문제와 환경 문제를 구분_ 한 경험.
- 트레이드오프:
- **Gradle vs ArchUnit 분업**: Gradle 은 _module-level project dependency_ 를 컴파일 단계에서 확실히 차단하지만 _method return type_ 이나 _annotation import_ 같은 세부 규칙은 못 봄. ArchUnit 은 bytecode 수준의 import / class structure 를 잡지만 _module 간 build-graph 사이클_ 은 깔끔하게 못 잡음. 둘이 _역할이 다르고 둘 다 필요_.
- **다수파 vs 소수파**: `@Transactional` 직접 부착 (다수파, Spring 공식 권고, boilerplate 최소) vs `TransactionPort` 추상화 (소수파, 격리 우선, boilerplate 증가). ca-tmpl 은 _template repository 라서_ 소수파를 의도적 선택. 단일 DB / 단일 transactionManager 의 작은 팀은 다수파가 reasonable.
- **Spring Modulith 도입 안 함**: named interface 검증은 더 강력하지만 ca-tmpl 의 boundary drift 차단 비용 대비 효용이 _이 시점에서는_ 낮다고 판단. 후속 검토 후보로 둠 (`feature-architecture-enforcement-rules.md` D5 Open Risk).
- 한계 / "이건 안 해봤다":
- runtime lookup / reflection 우회 (`ApplicationContext#getBean` 류) 가 현재 ArchUnit rule 을 false-pass 하는지 _실험 미수행_ (`planned`).
- MapStruct generated mapper exemption 의 build path 가 빌드 도구 설정에 따라 어떻게 달라지는지 확인 미완 (`needs-confirmation`, D9 `UNSUPPORTED_DECISION`).
- prod 운영 검증 없음 — ca-tmpl 은 template repository.
## Sources / 근거
- [[raw/branch-notes/feature-architecture-enforcement-rules]] — 결정 D1~D10, Claims to Verify status 표, Closure 의 `locally-verified` 5항목.
- [[raw/branch-notes/feature-skeleton-package-blueprint-contract]] — 자매 결정 (D1~D8). module 분리 자체의 __.
- [[raw/branch-notes/feature-application-port-usecase-contract]] — `@Transactional` 다수파 vs 소수파 trade-off 의 근거 (D3, D4 비교).
- [[raw/official-docs/archunit-user-guide]] — ArchUnit rule DSL (`ARCHUNIT-UG-C5`, `ARCHUNIT-UG-C6`).
- [[raw/official-docs/governance-archunit-official]] — architecture test 거버넌스 (`AU-OFF-C1`, `AU-OFF-C2`).
- [[raw/official-docs/archunit-conditional-on-property-3-layer-pattern]] — predicate/condition 모델 (`AUCP-C1` ~ `AUCP-C5`).
- [[raw/company-tech-blogs/woowahan-hexagonal-multimodule]] — Domain / Application / Framework / Bootstrap 4-module 격리 (`WW-HEX-C1`).
- [[raw/company-tech-blogs/modulith-kakaobank-techblog-2025]] — Gradle multi-module + Hexagonal + Spring Modulith (`KAKAOBANK-MOD-C4`).
- [[wiki/concepts/clean-architecture-package-layout]] — 정제된 layout 개념 (canonical).
- [[wiki/projects/ca-tmpl/clean-architecture-package-layout]] — ca-tmpl 적용 (canonical, 갱신 필요).
## 미해결 / Unknown
- 모르는 것: ArchUnit 이 reflection 우회를 _얼마나_ 못 잡는지 정량 측정 안 함. Spring `ApplicationContext#getBean` 류의 일반적 우회 패턴을 위반 코드로 넣어 실제 false-pass 확인 필요.
- 모르는 것: MapStruct generated mapper exemption 의 표준 처리 방식. Maven vs Gradle / annotation processor 위치에 따라 달라지는 generated source path 의 일반적 표현.
- 확인 방법: `feature-architecture-enforcement-rules.md` Claims to Verify 의 `planned` / `needs-confirmation` 항목을 후속 PoC branch 에서 실험.
## 답변 경계 / Answer boundary
- 자신 있게 말할 수 있는 범위: ca-tmpl `src/build.gradle``verifyCleanArchitectureDependencies``src/app-bootstrap/.../CleanArchitectureTest.java` 의 12 ArchUnit rule 을 _직접 구현 + red/green 검증_ 한 범위. `./gradlew test` + `verifyCleanArchitectureDependencies` 로컬 통과까지.
- "이 부분은 공식 문서를 다시 보고 답변드리겠습니다" 라고 해야 하는 부분:
- MapStruct generated code exemption 의 빌드 도구별 표준 처리.
- Spring Modulith named interface 의 구체 configuration (Modulith 를 _도입한 적 없음_).
- prod 환경에서 ArchUnit / Gradle dependency rule 이 CI 어떤 단계에서 실패시키는 게 안전한지 (운영 경험 없음).
- **절대 과장하지 말 것**:
- prod 운영 검증인 것처럼 말하지 말 것. ca-tmpl 은 template repository 이고 검증 등급은 _`locally-verified`_.
- 우아한형제들 / 카카오뱅크 사례를 _industry standard_ 처럼 말하지 말 것. _case study_ 다 (`Evidence Strength: company-case-study`).
- `@Transactional` 소수파 결정이 _다수파보다 우월하다_ 는 식의 표현 금지. _이 맥락 (template repository) 에서의 선택_ 까지만.
## Related / 관련
- 관련 면접 질문 (선행/후속): [[raw/interviews/clean-architecture-module-blueprint]] (선행 — module 분리 자체의 __), [[raw/interviews/shared-contract-and-sample-isolation]] (자매 — shared/sample 책임), [[raw/interviews/transaction-port-vs-spring-transactional]] (자매 — `@Transactional` 다수파/소수파 trade-off), [[raw/interviews/post-implementation-knowledge-capture]] (워크플로우 자매).
- 영감을 받은 채용공고: (없음).
- 관련 블로그 글감: [[raw/blog-topics/clean-architecture-boundary-enforcement-2026-05-28]] (같은 경험의 글감), [[raw/blog-topics/clean-architecture-module-blueprint-2026-05-28]] (자매 글감), [[raw/blog-topics/transaction-port-abstraction-over-spring-transactional-2026-05-28]] (`@Transactional` trade-off 글감).
- 답변 derive 후 위치: 생성 전. 생성 시 `wiki/interview/architecture/clean-architecture-boundary-enforcement.md` 후보.
@@ -0,0 +1,61 @@
---
title: interview-prep / clean-architecture-domain-onboarding-guardrails
source_type: interview-prep
status: raw
related_branches: [feature-domain-feature-onboarding-contract]
related_projects: [ca-skeleton]
tags: [interview-prep, ca-skeleton, architecture, testing, clean-architecture, multi-module]
created: 2026-06-25
status_label: collecting
---
# interview-prep: clean-architecture-domain-onboarding-guardrails
> Layer: `raw/interviews/` — 실행 가능한 Clean Architecture onboarding guardrail 경험에서 나온 면접 질문 원석.
## Parent / 부모
- [[raw/branch-notes/feature-domain-feature-onboarding-contract]] — 이 branch에서 문서 checklist를 ArchUnit/JUnit dry-run guardrail 로 구현했기 때문에 나올 수 있는 질문.
## 질문 / Question
- 질문 원문: Clean Architecture 템플릿에서 새 도메인 기능을 추가할 때 계층 경계가 무너지지 않는다는 것을 어떻게 검증했나요?
- 출처: 예상 질문
- 받은 날짜·맥락 (실제 받은 경우): 해당 없음
## 질문 의도 추론 / Why this question
- 핵심 평가 대상: 아키텍처 경계 자동화, 테스트 설계, 문서 계약을 실행 가능한 guardrail 로 전환한 경험.
- 함정 / 흔히 빠지는 답변 패턴: “컨벤션으로 조심했다” 수준에서 끝내고 실패 fixture나 negative test evidence를 제시하지 못하는 답변.
- 따라올 만한 후속 질문: ArchUnit 정적 분석으로 잡지 못하는 한계는 무엇이며 어떻게 보완했나요?
## 답변 재료 / Raw answer material
- 사실 1: onboarding 기준은 `domain-core``application-core``adapter-*` 방향의 module slice다. 근거: [[raw/branch-notes/feature-domain-feature-onboarding-contract]] D1, D2.
- 사실 2: read-only slice는 command/write port 없이 query/use case/mapper/controller와 contract 검증으로 충분하다고 정의했다. 근거: [[raw/branch-notes/feature-domain-feature-onboarding-contract]] D3.
- 사실 3: write slice는 command/use case/write port/persistence/transaction boundary가 함께 있어야 한다. 근거: [[raw/branch-notes/feature-domain-feature-onboarding-contract]] D4, D8.
- 내가 직접 한 경험: `DomainFeatureOnboardingContractTest``dev.caskeleton.onboarding.*` Ticket dry-run fixture, `use_case_capability_matches_transaction_port_boundary` ArchUnit rule, shared-contract negative fixture를 구현하고 `./gradlew test`까지 통과시켰다. 근거: [[raw/branch-notes/feature-domain-feature-onboarding-contract]] §구현 결과.
- 트레이드오프: ArchUnit direct-call 분석은 빠르고 CI 친화적이지만 helper 뒤에 숨은 transaction boundary는 잡지 못한다. 이 한계는 branch D8의 Open Risk로 남겼다.
- 한계 / "이건 안 해봤다": 운영 환경 검증은 없다. 이번 증거 등급은 `locally-verified`다.
## Sources / 근거
- [[raw/branch-notes/feature-domain-feature-onboarding-contract]] — 이번 구현과 검증의 primary evidence.
- [[raw/errors/gradle-wrapper-sandbox-lock-2026-06-25]] — 로컬 검증 중 발생한 Gradle sandbox 문제.
## 미해결 / Unknown
- 모르는 것 1: 실제 downstream fork 에서 같은 fixture strategy가 과도한 boilerplate로 받아들여질지.
- 모르는 것 2: helper-mediated transaction boundary를 자동 분석으로 더 깊게 잡을 필요가 있는지.
- 확인 방법: downstream adoption branch 또는 실제 새 도메인 branch에서 fixture 없이 production slice를 추가해 guardrail false positive/negative를 관찰한다.
## 답변 경계 / Answer boundary
- 자신 있게 말할 수 있는 범위: ca-tmpl local Gradle test/ArchUnit 수준에서 새 도메인 onboarding 계약을 실행 가능한 guardrail 로 구현하고 검증했다.
- "이 부분은 공식 문서를 다시 보고 답변드리겠습니다" 라고 해야 하는 부분: ArchUnit이 Java call graph 전체를 완전 분석한다는 식의 주장은 하지 않는다.
- **절대 과장하지 말 것**: `locally-verified``prod-verified` 또는 범용 best practice로 말하지 말 것.
## Related / 관련
- 관련 블로그 글감: [[raw/blog-topics/executable-clean-architecture-onboarding-2026-06-25]]
- 답변 derive 후 위치: 생성 전
@@ -0,0 +1,53 @@
---
title: interview-prep / clean-architecture-identifier-generation
source_type: interview-prep
status: raw
related_branches: [feature-resource-identifier-contract]
related_projects: [ca-skeleton]
tags: [interview-prep, ca-skeleton, identifier, ulid, ddd, clean-architecture, hexagonal]
created: 2026-06-01
status_label: collecting
---
# interview-prep: clean-architecture-identifier-generation
> Layer: `raw/interviews/` — 면접 질문 원본 수집·연구 노트. 다듬어진 답변은 `/interviewize` 후 `wiki/interview/` 에 별도 작성.
## Parent / 부모
- [[raw/branch-notes/feature-resource-identifier-contract]] — D5(도메인 port + application orchestration), D1(ULID), D10(PostgreSQL uuid native) 실 구현.
- [[raw/project-notes/ca-skeleton-operational-contract]] — ca-tmpl skeleton-wide operational contract.
## 질문 / Question
- 질문 원문: 도메인 엔티티의 식별자(ULID)를 인프라(랜덤/시계 소스)에 도메인을 결합시키지 않으면서 server-assigned로 생성하려면 Clean Architecture에서 어느 계층이 책임지나요?
- 출처: 예상 질문 (실 면접 아님).
- 받은 날짜·맥락: 아직 없음. DDD factory / hexagonal port 이해 검증용.
## 질문 의도 추론 / Why this question
- 핵심 평가 대상:
- "도메인이 식별성을 소유"한다는 DDD 명제와 "도메인은 SecureRandom/시계/라이브러리에 결합되면 안 된다"는 순수성 명제를 *동시에* 만족시키는 설계를 아는지.
- factory가 entity가 아니라 도메인 *service/port*라는 Evans DDD의 디테일 인지.
- "도메인 생성 vs application 생성 vs 인프라 생성(Hibernate @GeneratedValue)"의 trade-off를 맥락 의존으로 보는지.
- 함정 / 흔히 빠지는 답변:
- "도메인 entity의 static factory가 `UUID.randomUUID()`를 직접 호출" → 도메인이 JDK 난수에 결합 + 테스트 시 generator 교체 불가 + ULID 같은 라이브러리면 도메인이 인프라 의존.
- "Hibernate `@GeneratedValue`로 DB가 생성" → 도메인이 영속화 메커니즘에 결합, ULID time-ordered/monotonic 보장 불가, PostgreSQL `uuid` native 결정과 충돌.
- "application이 ULID 라이브러리를 직접 호출" → use case가 인프라(UlidCreator)에 결합, ArchUnit `no_uuid_random_in_controller` 위반.
- 따라올 만한 후속 질문:
- 그럼 도메인 port는 누가 호출하나요? (use case) 그건 "application이 생성"하는 것 아닌가요? (생성 *책임*은 도메인 port, *호출 시점*은 orchestration — 구분)
- ULID 26자(Crockford base32)를 DB에는 어떻게 저장하나요? (PostgreSQL `uuid` native 16-byte로 `Ulid.toUuid()` 변환 — external은 ULID, internal은 uuid)
- sealed로 모든 식별자 타입을 닫고 싶은데 모듈 경계 때문에 `permits`가 안 되면? (`no_long_id_pk` ArchUnit rule이 빌드타임 대체)
- resource id / trace id / idempotency-key는 왜 다른 branch가 책임지나요?
## 답변 재료 / Raw answer material
- 구현: 도메인에 `WorkLogIdFactory`(port) 정의 → 인프라 `UlidWorkLogIdFactory`(`@Component`, `UlidCreator.getMonotonicUlid()`, SecureRandom) 구현 → `CreateWorkLogUseCase`가 port를 주입받아 `factory.newId()` 호출 후 `WorkLog.create(id, ...)`로 조립.
- 도메인 `WorkLog``WorkLogId`(26자 regex 검증만 하는 record)만 알고, ULID 라이브러리/난수/시계에 결합 없음.
- "Application layer 생성 거부"라는 단순 표현은 오해를 부른다 — 실제 거부 대상은 *application이 ULID 라이브러리를 직접 호출*하는 것이지, use case가 도메인 port를 orchestrate하는 것은 정합.
- 검증: `WorkLogUseCasesTest`가 fake `WorkLogIdFactory`(테스트는 generator 교체 자유) 주입으로 단위테스트. ArchUnit `no_uuid_random_in_controller`/`no_math_random_for_id`/`no_long_id_pk`가 빌드타임 enforce.
## Related / 관련
- 관련 branch note: [[raw/branch-notes/feature-resource-identifier-contract]]
- 관련 개념: [[raw/blog-topics/identifier-governance-rule-scoping-by-id-kind-2026-06-01]]
@@ -0,0 +1,41 @@
---
title: interview / Clean Architecture 에서 Spring 결합 없이 method-level 인가 거는 법
source_type: interview
status: raw
related_branches: [feature-authentication-authorization-contract]
related_projects: [ca-skeleton]
tags: [interview, ca-skeleton, security, authorization, clean-architecture, spring-security]
created: 2026-06-08
---
# interview: framework-free method authorization (authz contract)
> Layer: `raw/interviews/` — feature-authentication-authorization-contract 구현에서 정직하게 도출되는 면접 질문/답.
## Parent / 부모
- [[raw/branch-notes/feature-authentication-authorization-contract]]
## Q1. 왜 `@PreAuthorize` 대신 use-case `AuthorizationPort` 를 만들었나?
`@PreAuthorize` 는 SpEL + Spring Security 타입에 bean 을 결합시킨다. application/domain layer 는 framework-free 여야 하므로(project §5, `TransactionPort` 선례) 인가 *결정* 을 plain Java port(`AuthorizationPort.requirePermission(AuthorizationPrincipal, Permission)`)로 표현하고, *집행 메커니즘* 만 adapter 의 custom `AuthorizationManager<MethodInvocation>` 에 둔다. 결과: use case 는 `@RequiresPermission("worklog:close")`(Spring-free annotation)만 선언, 집행은 adapter. 트레이드오프: `@PreAuthorize` 대비 boilerplate(annotation manager + advisor wiring) ↑, 대신 layer 순수성 유지.
## Q2. permission 중심 RBAC 를 택한 이유? OWASP 는 ABAC 를 권한다는데?
permission(`resource:action`)=집행 단위, role=permission 묶음. 도메인이 role 을 추가해도 enforcement 코드는 불변(role→permission registry 만 갱신). OWASP 는 dynamic attribute 가 필요하면 ABAC 를 선호하지만(OWASP-PM-C1), 정적 permission + 소수 role 규모에선 YAGNI. 핵심: `AuthorizationPort` 인터페이스가 ABAC 전환 path 를 보장 — 구현체만 owner/relationship predicate 로 교체하면 됨.
## Q3. 인가 거부를 어떻게 403 으로 내보내나? (2-hop)
application port 는 Spring-free 라 Spring `AccessDeniedException` 을 못 던진다. (1) port 가 자체 `AuthorizationDeniedException`(RuntimeException) throw → (2) adapter 의 `AuthorizationManager` 가 이를 잡아 `AuthorizationDecision(false)` 반환 → Spring method-security interceptor 가 `AccessDeniedException` 발생 → `GlobalExceptionHandler#handleForbidden``SecurityErrorClassifier.classifyAccessDenied` 위임 → `AUTHZ_INSUFFICIENT_PERMISSION`(403). error code SSOT 는 security-baseline, 본 계약은 emission producer.
## Q4. AOP proxy bypass 위험은?
method security 는 Spring AOP proxy 기반이라 self-invocation(같은 객체 내부 호출)이나 non-Spring-bean 호출은 advisor 를 우회한다. 또 concrete 타입 주입은 CGLIB(`proxyTargetClass=true`) 여야 proxy 가 subtype 이 된다(→ [[raw/errors/method-security-cglib-vs-jdk-proxy-usecase-injection-2026-06-08]]). 보강: 모든 mutating 진입점이 Spring bean 경유인지 ArchUnit 정적 검증(host=architecture-enforcement-rules).
## Q5. role registry key 를 `ROLE_ADMIN` 으로 안 쓰고 raw `admin` 으로 쓴 이유?
`AuthenticatedUser.roles` 는 IdP 원본 raw role(prefix 없음)을 담고, Spring `GrantedAuthority``ROLE_`+upper prefix 를 받는다. application-core 는 Spring-free 라 `GrantedAuthority` 가 아니라 raw role set 을 consume → registry key = raw role(lowercase 정규화, case-insensitive). 잘못해서 `ROLE_ADMIN` 으로 조회하면 0 권한 fail-closed.
## Q6. unauthenticated vs unauthorized 구분?
인증 없음 → method-security 의 deferred auth supplier 가 `AuthenticationException`(401-family). 권한 부족 → `AccessDeniedException`(403). prod 는 filter chain 이 미인증을 401 로 먼저 차단하므로 method-security 의 미인증 경로는 backstop.
@@ -0,0 +1,107 @@
---
title: interview-prep / clean-architecture-module-blueprint
source_type: interview-prep
status: raw
related_branches: [feature-skeleton-package-blueprint-contract]
related_projects: [ca-skeleton]
tags: [interview-prep, ca-skeleton, architecture, gradle, clean-architecture, hexagonal, multi-module]
created: 2026-05-28
status_label: collecting
---
# interview-prep: clean-architecture-module-blueprint
> Layer: `raw/interviews/` — 면접 질문 원본 수집·연구 노트. 다듬어진 답변은 `/interviewize` 후 `wiki/interview/` 에 별도 작성.
## Parent / 부모
- [[raw/branch-notes/feature-skeleton-package-blueprint-contract]] — single-module feature-first 결정 (2026-05-22) 을 Gradle multi-module + Hexagonal (2026-05-27) 로 _명시적으로 수정_ 한 결정 (D1~D8 + Default Module Blueprint tree + Module Dependency Rule 표).
- [[raw/project-notes/ca-skeleton-operational-contract]] — ca-tmpl skeleton-wide operational contract SSOT.
## 질문 / Question
- 질문 원문: Clean Architecture 템플릿에서 왜 단일 모듈 package 구조가 아니라 Gradle multi-module 구조를 선택했나요? 그리고 처음부터 그렇게 결정한 건가요?
- 출처: 예상 질문.
- 받은 날짜·맥락: 아직 실제 면접 질문으로 받은 것은 아님.
## 질문 의도 추론 / Why this question
- 핵심 평가 대상:
- Clean Architecture 원칙을 _물리적 module boundary_ 로 옮긴 __.
- package convention 과 build-graph enforcement 의 _차이_ 인식.
- small project vs template repository 의 trade-off 인식.
- _첫 결정을 뒤집은 경험_ (case study 검토 후 의사결정 reversion) — 정직함과 evidence-based 사고.
- 함정 / 흔히 빠지는 답변 패턴:
- "멀티모듈이 더 깔끔해서" — 비용 / 단점 / trade-off 언급 없음.
- "처음부터 멀티모듈이 답이라고 생각했다" — 의사결정의 _과정_ 을 숨김.
- 우아한형제들 / 카카오뱅크 사례를 _industry standard_ 처럼 인용 (실제로는 _case study_).
- 따라올 만한 후속 질문:
- small project 에서는 single-module 이 더 낫지 않나요? 어떤 기준으로 multi-module 을 선택해야 하나요?
- Gradle dependency rule 과 ArchUnit rule 은 각각 _무엇을 보장_ 하나요? 한쪽만으로는 왜 안 되나요?
- `domain-core``shared-contract` 를 참조하는 건 Clean Architecture 위반 아닌가요?
- Spring Modulith 가 multi-module 대체가 될 수 있나요?
- 새 사업 도메인이 추가되면 어느 module 에 어떻게 들어가나요? `adapter-messaging` 같은 새 adapter 가 필요해지면?
## 답변 재료 / Raw answer material
- 사실 1 (근거: `feature-skeleton-package-blueprint-contract.md` D1, §결정 사항 2026-05-22 / 2026-05-27): 초기 결정은 single-module feature-first package layout 이었다. 2026-05-27 에 우아한형제들 / 카카오뱅크 사례 검토 후 Gradle multi-module + Clean Architecture / Hexagonal 로 _명시적으로 수정_. 의사결정의 reversion 자체가 evidence.
- 사실 2 (근거: `feature-skeleton-package-blueprint-contract.md` §Default Module Blueprint + Module Dependency Rule 표): ca-tmpl 의 8 module — `app-bootstrap`, `domain-core`, `application-core`, `adapter-web`, `adapter-persistence`, `adapter-outbound`, `shared-contract`, `sample-ticket`. dependency direction 매트릭스로 _허용/금지_ 가 매 module 별로 명시.
- 사실 3 (근거: `feature-skeleton-package-blueprint-contract.md` D2, D3): `domain-core` 는 framework-neutral POJO (Spring/JPA/HTTP 모름), `application-core``domain-core` + `shared-contract` 에만 의존. adapter 구현체는 adapter module 밖으로 안 새어 나옴.
- 사실 4 (근거: `feature-skeleton-package-blueprint-contract.md` D6): `shared-contract` 는 response envelope / error code / header / MDC / metric / registry / annotation 같은 _skeleton-wide operational contract_ 만. business / domain concept 는 금지.
- 사실 5 (근거: `feature-skeleton-package-blueprint-contract.md` D7 + `feature-architecture-enforcement-rules.md` D7): `sample-ticket` 은 fixture/sample consumer 이며 production module 이 import / dependency 선언 시 _Gradle + ArchUnit 양쪽_ 에서 실패.
- 사실 6 (근거: `feature-skeleton-package-blueprint-contract.md` Closure §`locally-verified`): `./gradlew verifyCleanArchitectureDependencies` + `./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest'` + `./gradlew :adapter-web:test --tests '*SettingsTest'` + `./gradlew test` 모두 통과. 로컬 검증 완료.
- 내가 직접 한 경험:
- 기존 reference code (blog domain) 를 production module 에서 `sample-ticket/src/main/java/dev/caskeleton/sample/ticket/...` 로 격리. production module 은 anchor + `package-info.java` 중심으로 정리.
- production package root `dev.caskeleton` 으로 rename + `BlogApplication``CaSkeletonApplication` + `blog.*` 설정 prefix → `ca-skeleton.*`.
- 빈 anchor module 의 ArchUnit empty-should failure 를 `allowEmptyShould(true)` 로 선별 해결 — [[raw/errors/archunit-empty-should-anchor-2026-05-27]].
- sample-ticket 격리 후 `InvalidBearerTokenException` compile error → `spring-boot-starter-oauth2-resource-server` 명시 추가 — [[raw/errors/sample-ticket-oauth2-resource-server-dependency-2026-05-27]].
- 트레이드오프:
- **single-module 의 장점**: build 설정 단순, IDE 탐색 빠름, 처음 학습 비용 낮음. _작은 프로젝트_ 에는 합리적.
- **multi-module 의 장점**: module boundary 가 _컴파일 단계_ 에서 위반을 차단. template 의 _재사용성_ (다음 프로젝트에서 import 해도 경계가 살아 있음).
- ca-tmpl 이 multi-module 을 택한 _이유_: template repository 라서 _새 프로젝트 시작 시점에 경계가 흐트러지지 않도록 학습 비용을 미리 흡수_`feature-skeleton-package-blueprint-contract.md` D8 Open Risk 와 일치.
- **case study 의 한계**: 우아한형제들 / 카카오뱅크 사례는 `company-case-study` 등급. _공식 표준이 아님_. ca-tmpl 채택의 _부분 정당화_ 까지만.
- 한계 / "이건 안 해봤다":
- Spring Modulith named interface 검증은 _기본값으로 도입하지 않음_ (`feature-skeleton-package-blueprint-contract.md` D5 Open Risk).
- 실제 사업 도메인 (e.g., 결제 / 알림 / 인증) 이 들어왔을 때 module 분할 / 새 adapter 추가가 자연스러운지 _검증 안 함_.
- 운영 배포 검증 없음 — `feature-skeleton-package-blueprint-contract.md` Closure §`prod-verified: 없음`.
## Sources / 근거
- [[raw/branch-notes/feature-skeleton-package-blueprint-contract]] — D1~D8, Default Module Blueprint, Module Dependency Rule.
- [[raw/branch-notes/feature-architecture-enforcement-rules]] — 자매 결정 (boundary 강제). 본 module 분리의 _자동 검증 메커니즘_.
- [[raw/branch-notes/feature-application-port-usecase-contract]] — `application-core` 내부 패키지 구조의 후속 (D1: `*UseCase` / `*Port` naming). canonical 정제 시 통합.
- [[raw/company-tech-blogs/woowahan-hexagonal-multimodule]] — Domain / Application / Framework / Bootstrap 4-module 사례 (`WW-HEX-C1`).
- [[raw/company-tech-blogs/modulith-kakaobank-techblog-2025]] — Gradle multi-module + Hexagonal + Modulith 사례 (`KAKAOBANK-MOD-C4`).
- [[raw/official-docs/hexagonal-cockburn-wikipedia-summary]] — Ports & Adapters 원형 (`HEX-WIKI-C5`).
- [[raw/official-docs/arch-clean-architecture-uncle-bob]] — Dependency Rule 의 클래식 근거.
- [[raw/official-docs/layer-first-baeldung-clean-architecture-spring-boot]] — _대안 1: layer-first_ 입문형 사례.
- [[wiki/concepts/clean-architecture-package-layout]] — Clean Architecture package/module layout 개념.
- [[wiki/projects/ca-tmpl/clean-architecture-package-layout]] — ca-tmpl 적용 (canonical, 갱신 필요).
## 미해결 / Unknown
- 모르는 것: Spring Modulith 를 후속 도입했을 때 Gradle multi-module + ArchUnit 과의 _중복/대체_ 관계.
- 모르는 것: 실제 사업 도메인 추가 시 module 분할 패턴 (e.g., 결제 추가 시 `domain-core` 가 결제 / 사용자 / 주문 등 sub-package 로 비대해지는 시점은 어디인가).
- 확인 방법: `feature-application-port-usecase-contract`, `feature-domain-event-outbox-contract`, `feature-business-rule-validation-contract` 후속 branch 적용 결과 관찰.
## 답변 경계 / Answer boundary
- 자신 있게 말할 수 있는 범위:
- ca-tmpl 에서 module rename / package anchor / Gradle dependency verifier / ArchUnit rule / full local test 까지 _직접 수행_ 한 범위.
- 초기 single-module 결정을 multi-module 로 _뒤집은 의사결정 과정_ 과 근거 (case study 검토).
- `domain-core` / `application-core` / `adapter-{web,persistence,outbound}` / `shared-contract` / `sample-ticket` / `app-bootstrap`_책임과 forbidden import_.
- "이 부분은 공식 문서를 다시 보고 답변드리겠습니다" 라고 해야 하는 부분:
- Spring Modulith named interface 의 구체 configuration (도입한 적 없음).
- 회사별 shared kernel / common module 운영 표준 (ca-tmpl 의 결정은 _이 맥락_ 까지만).
- module 수 (4 vs 8 vs 12) 의 최적값 (case study 가 사례별로 다름).
- **절대 과장하지 말 것**:
- 운영 배포 경험인 것처럼 말하지 말 것. ca-tmpl 은 _template repository_ 이고 검증 등급은 `locally-verified`.
- 우아한형제들 / 카카오뱅크 사례를 _업계 표준_ 처럼 표현 금지 — 둘 다 _case study_ (`company-case-study` 등급).
- "처음부터 multi-module 이 답이라고 알았다" 식의 표현 금지 — 결정의 _reversion_ 사실을 숨기지 않음.
## Related / 관련
- 관련 면접 질문 (선행/후속): [[raw/interviews/shared-contract-and-sample-isolation]] (자매 — shared/sample 책임), [[raw/interviews/clean-architecture-boundary-enforcement]] (후속 — 자동 검증), [[raw/interviews/transaction-port-vs-spring-transactional]] (자매 — application 의 framework 격리).
- 영감을 받은 채용공고: (없음).
- 관련 블로그 글감: [[raw/blog-topics/clean-architecture-module-blueprint-2026-05-28]] (같은 경험의 글감), [[raw/blog-topics/clean-architecture-boundary-enforcement-2026-05-28]].
- 답변 derive 후 위치: 생성 전. 생성 시 `wiki/interview/architecture/clean-architecture-module-blueprint.md` 후보.
@@ -0,0 +1,61 @@
---
title: interview-prep / crown-one-query-vs-cqrs-lite-read-model
source_type: interview-prep
status: raw
related_branches: [experiment-nplus1-feed-api-replay]
related_projects: [ca-skeleton]
tags: [interview-prep, ca-skeleton, persistence, application, postgresql, cqrs]
created: 2026-07-15
status_label: drafting
---
# interview-prep: crown-one-query-vs-cqrs-lite-read-model
> Layer: `raw/interviews/` — 면접 질문 원본 수집·연구 노트입니다. 다듬어진 답변은 canonical 문서를 만든 뒤 `wiki/interview/`에 별도로 작성합니다.
## Parent / 부모
- [[raw/branch-notes/experiment-nplus1-feed-api-replay]] — D3가 Crown의 one-query endpoint와 L12의 same-store CQRS-lite read port를 병존시킨 이유와 검증 범위를 소유합니다.
## 질문 / Question
- 질문 원문: Crown의 one native query와 L12의 same-store CQRS-lite read model은 무엇이 다르며, 어떤 경우에 각각을 선택하시겠습니까?
- 출처: N+1 replay 작업에서 예상한 면접 질문입니다.
- 받은 날짜·맥락: 실제 면접에서 받은 질문은 아닙니다.
## 질문 의도 추론 / Why this question
- 핵심 평가 대상: N+1 해결과 query 수 최소화를 동일시하지 않는지, read-model 경계와 트레이드오프를 설명할 수 있는지 평가합니다.
- 함정 / 흔히 빠지는 답변 패턴: 두 query라는 사실만으로 L12를 N+1이라고 부르거나, one query가 모든 read endpoint의 정답이라고 일반화하는 답변입니다.
- 따라올 만한 후속 질문: Crown이 L12를 대체하지 않는 이유는 무엇인가요? native query의 SQL과 결과 mapping은 어떻게 검증했나요?
## 답변 재료 / Raw answer material
- 사실 1: Crown 경로는 visible parent keyset과 parent별 Top-3 child를 하나의 native query로 읽는 endpoint-specific 최적화입니다. (근거: [[raw/branch-notes/experiment-nplus1-feed-api-replay]] D3)
- 사실 2: L12는 parent projection 한 번과 child Top-3 query 한 번을 사용하는 same-store application query port입니다. 해당 integration test에서는 entity/collection hydration이 0으로 기록됐지만, Crown의 one-query endpoint를 대체하지 않습니다. (근거: [[raw/branch-notes/experiment-nplus1-feed-api-replay]] D3, §3 Crown과 L12의 의도적 차이)
- 프로젝트 작업에서 확인한 경험: local Docker HTTP smoke에서 Crown은 `prepared=1`, `entityLoads=0`으로, L12는 20개 item과 parent당 최대 Top-3 child로 확인됐습니다. 이는 local 환경 증거입니다. (근거: [[raw/branch-notes/experiment-nplus1-feed-api-replay]] §Docker HTTP + PostgreSQL smoke — final L12 tag)
- 트레이드오프: 이 작업에서는 query 수를 최소화하면서 Top-N + keyset + visibility를 한 endpoint에서 동시에 만족해야 할 때 Crown을 사용합니다. application read port의 분리를 보여 주거나 aggregate hydration 없이 두 projection query로 read shape를 조립할 때는 L12를 사용합니다. 업계 다수파·소수파에 관한 일반화는 이 raw note의 근거 범위 밖입니다. (근거: [[raw/branch-notes/experiment-nplus1-feed-api-replay]] D3)
- 한계 / "이건 안 해봤다": L12는 별도 read store나 outbox 동기화를 둔 Full CQRS가 아니며, Crown과 같은 visibility/keyset 기능을 모두 담지 않습니다. production 부하·latency SLA도 검증하지 않았습니다. (근거: [[raw/branch-notes/experiment-nplus1-feed-api-replay]] §Out of scope, D3)
## Sources / 근거 (답변의 사실 근거)
- [[raw/branch-notes/experiment-nplus1-feed-api-replay]] D3 — Crown 1-query와 L12 2-query CQRS-lite를 병존시키는 결정과 선택 조건.
- [[raw/branch-notes/experiment-nplus1-feed-api-replay]] D5 — `addScalar`는 runtime 결과 타입 mapping이며 Java compiler의 SQL syntax/schema 검증이 아니라는 경계.
## 미해결 / Unknown
- 실제 production 데이터 분포에서 Crown의 native query plan과 L12의 두 query가 어느 latency/throughput 경계에서 갈리는지 확인하지 않았습니다.
- physical read store와 동기화 계약이 필요한 시점의 Full CQRS 전환 기준은 이 작업 범위에 없습니다.
- 확인 방법: representative PostgreSQL 데이터에서 `EXPLAIN (ANALYZE, BUFFERS)`와 부하 측정을 수행하고, 별도 read store가 필요한 요구가 생기면 application query-bypass contract를 기준으로 새 설계를 작성합니다.
## 답변 경계 / Answer boundary
- 자신 있게 말할 수 있는 범위: local Docker PostgreSQL과 HTTP smoke, focused Gradle integration test에서 Crown의 one-query 관찰값과 L12의 two-query projection 동작을 확인한 범위입니다.
- "이 부분은 공식 문서를 다시 보고 답변드리겠습니다" 라고 해야 하는 부분: CQRS의 일반적 정의, physical read store를 둘 때의 동기화 방식, production scale의 성능 우위입니다.
- **절대 과장하지 말 것**: Crown이 모든 상황에서 더 빠르다고 말하지 않습니다. L12를 Full CQRS나 Crown의 기능적 대체물로 말하지 않습니다. local 검증을 production 검증으로 말하지 않습니다. `addScalar`가 SQL을 compile-time에 검증한다고 말하지 않습니다.
## Related / 관련
- 관련 작업: [[raw/branch-notes/experiment-nplus1-feed-api-replay]]
- 후속 raw 질문 후보: `raw/interviews/native-query-addscalar-runtime-validation.md`
- 답변 derive 후 위치: canonical 문서가 준비된 뒤 `wiki/interview/persistence/crown-one-query-vs-cqrs-lite-read-model.md`
@@ -0,0 +1,42 @@
---
title: interview / deterministic-logback-asyncappender-drop-metric-test-2026-06-14
source_type: interview-prep
status: raw
related_branches: [feature-log-management-contract]
related_projects: [ca-skeleton]
tags: [interview, ca-skeleton, logback, asyncappender, micrometer, testing, determinism, observability, masking]
created: 2026-06-14
status_label: captured
---
# interview: deterministic-logback-asyncappender-drop-metric-test-2026-06-14
> Layer: `raw/interviews/` — 작업에서 파생된 면접/구두설명 질문 원석.
## Parent / 부모
- [[raw/branch-notes/feature-log-management-contract]] — DRIFT-5(`log.appender.dropped.total`) + DRIFT-2(Layer 1 masking) 구현에서 파생.
## Q1. Logback `AsyncAppender` 의 드롭(discard)을 어떻게 *결정론적으로* 테스트하나?
`AsyncAppender` 는 queue 잔여 용량이 `discardingThreshold` 밑으로 떨어지면 ≤INFO 이벤트를 조용히 버린다. 이 드롭은 worker 스레드 drain 타이밍에 의존 → 단순 burst 테스트는 flaky.
**트릭**: `discardingThreshold > queueSize` 로 설정하면 `getRemainingCapacity()`(최대 queueSize) `< discardingThreshold`**항상 true**`isQueueBelowDiscardingThreshold()` 항상 참 → 모든 discardable(≤INFO) 이벤트가 **호출 스레드에서 동기 드롭**. async worker 타이밍이 식에서 제거되어 카운터 단언이 결정론적. (logback `AsyncAppenderBase.start()``discardingThreshold == -1` 일 때만 `queueSize/5` 로 기본값 설정 — 명시값을 상한 캡 하지 않음을 바이트코드로 확인.) WARN/ERROR 는 `isDiscardable()==false` 라 같은 조건에서도 드롭/카운트 안 됨을 같은 테스트로 검증.
## Q2. Logback 이 Spring 보다 먼저 초기화되는데 custom appender 가 Micrometer 카운터를 어떻게 발행하나?
`io.micrometer.core.instrument.Metrics.globalRegistry`(정적 composite)로 발행. Spring Boot 가 애플리케이션 `MeterRegistry` 를 글로벌 composite 에 추가하므로 logback 이 먼저 떠도 결국 actuator/metrics 에 노출. 테스트는 `SimpleMeterRegistry``Metrics.addRegistry` 로 붙였다 `removeRegistry` 로 떼며 격리. 태그 cardinality 는 레지스트리 SSOT(`metrics.yaml`)의 `level∈{INFO,DEBUG}` 로 제한.
## Q3. 구조화 JSON 로그에서 secret 마스킹은 왜 `%replace`(PatternLayout converter)로 부족한가?
`%replace` 는 PatternLayout 단계 converter. 그러나 `LogstashEncoder` 는 PatternLayout 을 **우회**해 JSON 을 직접 생성 → `%replace` 미적용(마스킹 누락). JSON 경로는 `MaskingJsonGeneratorDecorator`(JSON 생성 시점 value masker), pattern 경로는 별도 converter(`%maskedMsg`)로 같은 정규식. 정규식 catalog 를 단일 SSOT 로 두어 양 경로 일관. 상세: [[raw/blog-topics/logback-layer1-secret-masking-json-vs-pattern-2026-06-14]].
## Q4. `javax.crypto.Mac` 이 thread-safe 하지 않은데 singleton pseudonymizer bean 에서 어떻게 다루나?
`Mac` 은 상태를 가져 thread-safe 하지 않다. 옵션: (a) 호출마다 `Mac.getInstance` 새로 생성(단순·안전), (b) `ThreadLocal<Mac>`, (c) 인스턴스 풀. 본 구현은 (a) — `SecretKeySpec`(불변)만 필드로 보관, `pseudonymize()` 마다 `Mac` 생성+init. HMAC-SHA-256 은 JDK 보장 알고리즘이라 checked 예외는 unchecked 로 래핑(사실상 도달 불가). salt 는 생성자에서 방어적 clone.
## 관련 / Related
- [[raw/branch-notes/feature-log-management-contract]]
- [[raw/blog-topics/logback-layer1-secret-masking-json-vs-pattern-2026-06-14]]
- [[raw/errors/webmvctest-component-filter-constructor-dep-breaks-slice-2026-06-14]]
@@ -0,0 +1,64 @@
---
title: interview-prep / digest-first-supply-chain-release-gates
source_type: interview-prep
status: raw
related_branches: [feature-build-release-supply-chain-contract]
related_projects: [ca-skeleton]
tags: [interview-prep, ca-skeleton, ci-cd, build-tooling, slsa, supply-chain]
created: 2026-06-21
status_label: ready-for-derive
---
# interview-prep: digest-first-supply-chain-release-gates
> Layer: `raw/interviews/` — 구현 경험에서 정직하게 파생한 공급망 릴리스 설계 질문 원본.
## Parent / 부모
- [[raw/branch-notes/feature-build-release-supply-chain-contract]] — Gradle/OCI/Cosign/SLSA release gate를 실제로 배선한 branch.
## 질문 / Question
- 질문 원문: Java/Gradle 서비스의 컨테이너 릴리스에서 dependency lock, SBOM, 취약점 검사, Cosign 서명, SLSA provenance를 어떤 순서로 release-blocking하게 설계했나요?
- 출처: 구현 경험에서 유추한 예상 질문.
- 받은 날짜·맥락: 해당 없음.
## 질문 의도 추론 / Why this question
- 핵심 평가 대상: supply-chain 개념 이해, immutable artifact 설계, gate ordering, fail-open 경계, 운영 검증 한계 인식.
- 함정 / 흔히 빠지는 답변 패턴: tag를 artifact identity로 취급하거나, signature “존재”만 확인하고 signer identity/issuer를 검증하지 않는 답변.
- 따라올 만한 후속 질문: rollback retention은 어떻게 검증하는가, SLSA builder ID는 왜 exact match인가, deploy-time admission은 누가 소유하는가.
## 답변 재료 / Raw answer material
- 사실 1: artifact version은 SemVer+git sha이고 image는 digest로 build/sign/verify/promotion한다. 근거: [[raw/branch-notes/feature-build-release-supply-chain-contract]] D1, D4, D9.
- 사실 2: Cosign verify는 exact workflow certificate identity와 GitHub OIDC issuer를 모두 검사한다. 근거: 같은 branch D6, D12 및 [[raw/official-docs/cosign-keyless-identity-verification-policy]].
- 사실 3: SLSA verifier는 source tag/URI와 exact generator builder ID를 검사하고 v1 predicate field도 확인한다. 근거: 같은 branch D7, D13 및 [[raw/official-docs/slsa-v1-provenance-schema]].
- 내가 직접 한 경험: strict Gradle lock positive/negative, 두 clean build SHA-256, release manifest/retention fixture를 구현·검증했다. 근거: 같은 branch §구현 결과.
- 트레이드오프: 표준/다수파 방향은 immutable digest와 keyless identity 검증이다. 팀 정책인 recent 10 OR 90일 retention은 rollback 가용성을 높이지만 registry 비용을 늘린다.
- 한계 / "이건 안 해봤다": 실제 GitHub OIDC/Rekor/GHCR release와 Kubernetes admission 배포는 실행하지 않았다.
## Sources / 근거
- [[raw/official-docs/supply-chain-cosign-keyless-sigstore]] — keyless signature와 transparency log.
- [[raw/official-docs/supply-chain-slsa-provenance-framework]] — provenance와 build-level 판단.
- [[raw/official-docs/slsa-v1-provenance-schema]] — official predicate field와 builder ID.
- [[raw/official-docs/supply-chain-gradle-vs-maven-dependency-locking]] — Gradle dependency lock.
## 미해결 / Unknown
- 모르는 것 1: 실제 repository에서 SLSA generator가 발행한 provenance와 exact builder ID의 최종 payload.
- 모르는 것 2: GHCR retention/garbage collection이 signature·attestation referrer 보존에 미치는 실제 영향.
- 확인 방법: release candidate tag로 GitHub Actions 실행 후 Cosign/SLSA verification과 scheduled retention audit 결과를 보관한다.
## 답변 경계 / Answer boundary
- 자신 있게 말할 수 있는 범위: local code, Gradle/Docker/manifest behavior, gate DAG와 fail-closed 조건.
- "이 부분은 공식 문서를 다시 보고 답변드리겠습니다"라고 해야 하는 부분: 운영 중 Rekor/GHCR SLA와 조직별 admission policy.
- **절대 과장하지 말 것**: local fixture와 정적 workflow 검증을 production release 운영 경험처럼 말하지 않는다.
## Related / 관련
- 관련 error: [[raw/errors/sandbox-build-verification-boundaries-2026-06-21]].
- 관련 blog topic: [[raw/blog-topics/digest-first-java-release-pipeline-2026-06-21]].
- 답변 derive 후 위치: canonical 정제 후 결정.
@@ -0,0 +1,46 @@
---
title: interview / domain-modeling-guardrails-archunit-2026-06-05
source_type: interview
status: raw
related_branches: [feature-domain-modeling-guardrails]
related_projects: [ca-skeleton]
tags: [interview, ca-skeleton, archunit, ddd, value-object, aggregate, domain-event, clean-architecture]
created: 2026-06-05
status_label: captured
---
# interview: domain-modeling-guardrails-archunit-2026-06-05
> Layer: `raw/interviews/` — 이 작업에서 정직하게 도출 가능한 면접 질문. canonical 승급 전 raw.
## Parent
- [[raw/branch-notes/feature-domain-modeling-guardrails]]
## 질문 목록
**Q1.** DDD 전술 패턴(값 객체/애그리거트/도메인 이벤트)을 "문서 권고"가 아니라 빌드에서 강제하려면 어떻게 하나?
- A: stereotype 마커 애너테이션(`@ValueObject`/`@AggregateRoot`/`@DomainEvent`)을 도메인 코어에 두고, ArchUnit fitness function 이 그 마커를 키로 규칙을 평가. 값 객체 = public no-arg 생성자 부재, 애그리거트 = `set*` 비공개, 도메인 이벤트 = record + transport 패키지 의존 금지.
**Q2.** "도메인 순수성(framework-neutral)" 규칙과 "도메인 logger 금지" 규칙을 왜 한 규칙으로 합치지 않고 분리했나?
- A: owner 경계. 도메인 순수성(`domain_is_pure`)은 `feature-architecture-enforcement-rules` 가 소유. 거기에 logging 패키지를 끼우면 한 branch 의 결정이 다른 branch owner 규칙에 섞여 위반 메시지·소유권이 흐려진다. 별도 `domain_has_no_logger` 로 두면 위반 사유가 명확하고 owner 가 분리된다.
**Q3.** 도메인 logger 금지의 "공식 표준 출처"가 있나?
- A: 없다. clean-architecture 통념이지 RFC/vendor 표준이 아니다. 그래서 프로젝트 자체 규약(UNSUPPORTED_DECISION)으로 확정하고, 외부(면접/README)에서 "표준이라 막았다"고 말하지 않는다. 사실 등급을 격상하지 않는 정직성.
**Q4.** 불변식 위반을 도메인에서 어떻게 표현하나? 왜 로그가 아니라 예외인가?
- A: 안전한 명사형 reason enum 을 가진 도메인 예외(`WorkLogInvariantException(Reason)`). 도메인은 로그/운영 에러코드를 모르고(D2/D3), application/web 이 `reason()` 을 error.category·로그로 번역. security-sensitive 사유는 일반화된 category 로만 노출해 단서 누출 방지.
**Q5.** 값 객체 불변식을 단위 테스트 몇 개로 "충분히" 검증했다고 할 수 있나?
- A: 못 한다. 예시 기반 테스트는 저자가 고른 케이스만 본다. jqwik property-based test 로 입력 공간 전체(canonical ULID, 비-canonical, 제외문자/소문자)를 무작위 생성해 불변식이 유일 생성 경로에서 항상 강제됨을 검증.
**Q6.** 도메인 이벤트를 "transport-free" 로 둔다는 게 무슨 의미이고, 통합(integration) 이벤트와 어떻게 분리하나?
- A: 도메인 이벤트는 도메인 타입만 담는 immutable record. Kafka/HTTP/JAX-RS 타입을 참조하면 안 됨(ArchUnit `domain_events_are_transport_free`). wire 표현으로의 변환(값 객체 → primitive flatten, 직렬화 포맷 선택)은 application 경계의 mapper 책임 → `WorkLogReserved`(domain) → `WorkLogReservedIntegrationEvent`(application).
**Q7.** ArchUnit 로 강제 가능한 범위의 한계는?
- A: `set*` prefix 같은 정적 시그니처는 잡지만, `applyXxx`/`markAsXxx` 같은 임의 상태변경 메서드나 Kotlin `copy()`/record wither 우회는 정적으로 못 잡는다. 그 부분은 코드리뷰·네이밍 컨벤션으로 보완하고 Open Risk 로 명시.
## Cross-links
- [[raw/errors/archunit-testcompileonly-class-loading-2026-06-02]] — fixture 작성 중 부딪힌 JUnit discovery 함정
- [[raw/interviews/archunit-static-analysis-limits]] — ArchUnit 정적 분석 한계 일반론
@@ -0,0 +1,49 @@
---
title: interview-prep / formatter-vs-style-linter-responsibility-split-2026-06-20
source_type: interview-prep
status: raw
related_branches: [feature-static-analysis-quality-contract]
related_projects: [ca-skeleton]
tags: [interview-prep, ca-skeleton, static-analysis, spotless, checkstyle, ci, formatter]
created: 2026-06-20
status_label: collecting
---
# interview-prep: formatter-vs-style-linter-responsibility-split-2026-06-20
> Layer: `raw/interviews/` — 면접 질문 원본 수집·연구 노트. 다듬어진 답변은 `/interviewize` 후 `wiki/interview/` 에 별도 작성.
## Parent / 부모
- [[raw/branch-notes/feature-static-analysis-quality-contract]] — Spotless(google-java-format) + Checkstyle 을 한 빌드에 같이 도입할 때 부딪힌 핵심 결정(D1/D2/§3 catalog).
## 질문 / Question
- 질문 원문: 코드 포매터(google-java-format)와 스타일 린터(Checkstyle)를 같은 CI 에 둘 다 넣을 때, 둘의 책임을 어떻게 나눠야 하나요? 나누지 않으면 무슨 일이 일어나나요?
- 출처: 예상 질문 (실 면접 아님).
- 받은 날짜·맥락: 아직 없음 — 2026-06-20 static-analysis-quality-contract 구현에서 도출.
## 질문 의도 추론 / Why this question
- 핵심 평가 대상:
- "도구를 많이 넣는 것" 과 "도구 책임을 분리하는 것" 의 차이를 아는지. 같은 규칙을 두 도구가 강제하면 도구 수가 늘수록 충돌이 는다는 걸 이해하는지.
- CI 가 자기 자신과 싸우는 실패 모드(무한 reformat 루프)를 예측·예방할 수 있는지.
## 답변 뼈대 / Answer skeleton
- **원칙: 한 규칙은 한 도구만 소유한다.** 포매터는 *기계적으로 결정 가능한 표현*(들여쓰기, 줄바꿈, 공백, import 순서)을 소유. 린터는 *포매터가 결정 못 하는 의미*(naming, Javadoc 존재, NeedBraces/FallThrough 같은 logical 규칙)를 소유.
- **나누지 않으면**: google-java-format 이 코드를 A 모양으로 고치고 Checkstyle 의 `Indentation`/`LineLength`/`CustomImportOrder` 가 그걸 위반이라 reject → 개발자가 다시 고치면 포매터가 또 A 로 → CI 무한 reformat 루프(checkstyle 이슈 #6527). 특히 import order 가 양쪽(Spotless `importOrder()` ↔ Checkstyle `CustomImportOrder`)에 다 있으면 영구 충돌.
- **구체적 처리**: Checkstyle ruleset 에서 formatting 모듈(`Indentation`, `LineLength`, `WhitespaceAround`, `LeftCurly`/`RightCurly`, `SeparatorWrap`, `OperatorWrap`, `EmptyLineSeparator`)과 `CustomImportOrder`**아예 빼고**, naming + Javadoc + logical 만 남긴다. google-java-format 은 100-char·결정론적 포맷이라 LineLength 도 포매터가 보장.
- **검증**: `./gradlew spotlessApply && ./gradlew checkstyleMain` 을 연속 실행해 위반 0(서로 안 싸움)을 확인. 의도적 포맷 깨뜨림 후 `spotlessCheck` 가 BUILD FAILED 하는지(gate bites)도 확인.
- **CI 규약**: CI 는 `spotlessApply`(파일 mutate)를 절대 실행하지 않고 `spotlessCheck`(검증)만 — 자동수정은 개발자 로컬에서.
## 꼬리 질문 / Follow-ups
- "그럼 LineLength 를 누가 보장하나?" → 포매터(google-java-format 100-char). 린터에서 빼도 길이는 강제됨.
- "기존 코드가 포맷·Javadoc 을 안 지키면 도입 시 어떻게?" → 포맷은 `spotlessApply` 일괄 적용(표준), Javadoc 처럼 기계수정 불가·대량인 규칙은 warning-tier 로 시작해 점진 승급(또는 ratchet). [[raw/branch-notes/feature-static-analysis-quality-contract]] §3/§4.
- "관용구를 규칙이 false-positive 로 잡으면?" → 코드 rename 말고 규칙 보정(예: ConstantName 이 SLF4J `log` 를 잡으면 패턴에 `log`/`logger` 허용 — Logger 는 Google §5.2.4 상 상수가 아님).
## 관련 / Related
- [[raw/branch-notes/feature-static-analysis-quality-contract]]
- [[raw/blog-topics/gradle9-java21-static-analysis-baseline-2026-06-20]]
@@ -0,0 +1,33 @@
---
title: Gradle sample-off test classpath isolation
source_type: interview
status: raw
tags: [gradle, testing, clean-architecture, sample-fixture]
created: 2026-06-25
---
# Gradle sample-off test classpath isolation
## Parent
- [[raw/branch-notes/feature-sample-removal-adoption-contract]]
## Question
템플릿 저장소가 sample fixture 모듈을 유지해야 하지만 production/core 계약은 sample 없이도 검증되어야 한다. Gradle 멀티모듈에서 이를 어떻게 설계할 수 있는가?
## Expected answer
- sample module은 production dependency가 아니라 fixture/test dependency로 둔다.
- 일반 `test`는 sample-on 축으로 유지한다.
- 별도 `sampleOffTest` source set/task를 만들어 같은 core contract test source를 실행하되 `sample-portfolio` dependency를 classpath에서 제외한다.
- sample을 직접 import하던 core test는 제거하거나 sample module 소유 테스트로 이동한다.
- CI release gate에는 sample-on과 sample-off를 모두 포함한다.
- ArchUnit 같은 bytecode 스캐너는 custom test output을 production output으로 오인하지 않도록 import option을 보강한다.
## Follow-up probes
- 왜 runtime profile이 아니라 build/test matrix인가?
- Custom source set에서 dependency locking과 main output을 왜 별도로 확인해야 하는가?
- sample 제거 후 빈 ArchUnit corpus는 실패로 볼지 정상으로 볼지 어떻게 결정하는가?
- Hosted CI와 local verification의 증거 등급은 어떻게 구분하는가?
@@ -0,0 +1,53 @@
---
title: interview / idempotency-rate-limit-design-tradeoffs-2026-06-09
source_type: interview-prep
status: raw
related_branches: [feature-rate-limit-idempotency-contract]
related_projects: [ca-skeleton]
tags: [interview, ca-skeleton, idempotency, rate-limit, concurrency]
created: 2026-06-09
---
# interview: 멱등성 / rate-limit 설계 트레이드오프
> Layer: `raw/interviews/` — feature-rate-limit-idempotency-contract 구현에서 나올 수 있는 질문.
## Parent / 부모
- [[raw/branch-notes/feature-rate-limit-idempotency-contract]]
## 예상 질문 / Q&A
- **Q. 동시에 같은 idempotency key가 오면?**
A. DB unique 제약(`(tenant, principal, idempotency_key, use_case_name)`)을 동시성 중재자로 사용.
첫 요청이 IN_FLIGHT row 선점(insert), 후속은 insert 실패 → read. read가 IN_FLIGHT면 200ms까지
poll 후 초과 시 409 IDEMPOTENT_IN_FLIGHT(retryable=false, client는 polling).
- **Q. 200ms wait는 표준인가?**
A. 아니다. IETF draft/Toss는 즉시 409 SHOULD. 200ms는 client retry 친화적 "변형"이고 thread를
잡는 비용이 있어 부하 테스트로 튜닝 대상. 면접에서 "표준 따름"으로 말하면 안 됨.
- **Q. 같은 key + 다른 body는?**
A. body SHA-256 fingerprint 비교 → 다르면 422 IDEMPOTENT_REQUEST_MISMATCH(IETF 422 권고 정합).
단 canonicalization(키 순서/공백) 미적용 시 false mismatch 위험 — 본 구현은 직렬화된 payload 기준.
- **Q. single-tenant인데 unique 제약이 동작하나? (tenant NULL)**
A. PostgreSQL은 NULL을 distinct로 취급 → NULL tenant면 dedup 실패. 그래서 tenant 컬럼을
`NOT NULL DEFAULT ''`로 두고 매퍼가 null↔'' 변환.
- **Q. 만료(TTL) 처리?**
A. 읽기에서 만료 row를 absent 취급 + tryBegin에서 만료 row reclaim(delete 후 insert) + 주기적
reaper(@Scheduled bulk delete) 3중. 읽기 필터와 쓰기 선점이 같은 만료 기준을 공유해야 "유령 충돌"이 없음.
- **Q. rate-limit 알고리즘은?**
A. single-node in-process fixed-window counter(ConcurrentHashMap.compute + AtomicInteger).
장점: X-RateLimit-Reset이 창 종료로 정확. 단점: 창 경계 burst 허용, 멀티 인스턴스면 N배(distributed limiter는 out of scope).
- **Q. 왜 filter가 아니라 interceptor?**
A. unauth key가 `IP + route template`을 요구하는데 servlet filter는 handler mapping 전이라 template을 모름.
interceptor는 `BEST_MATCHING_PATTERN_ATTRIBUTE``/v1/worklogs/{id}`를 얻음.
## 관련
- [[raw/branch-notes/feature-rate-limit-idempotency-contract]]
- [[wiki/concepts/idempotency-key-design]]
@@ -0,0 +1,46 @@
---
title: interview-prep / jwt-resource-server-fine-grained-error-classification
source_type: interview-prep
status: raw
related_branches: [feature-security-operational-baseline]
related_projects: [ca-skeleton]
tags: [interview-prep, ca-skeleton, security, jwt, spring-security, error-handling]
created: 2026-06-08
status_label: collecting
---
# interview-prep: jwt-resource-server-fine-grained-error-classification
> Layer: `raw/interviews/` — 면접 질문 원본 수집. 다듬은 답변은 `/interviewize` 후 `wiki/interview/`.
## Parent / 부모
- [[raw/branch-notes/feature-security-operational-baseline]] — JWT Resource Server 인증/인가 실패의 fine-grained 운영 분류 구현.
## 질문 / Question
- 질문 원문: JWT 인증 실패를 401 하나로 뭉개지 않고, 운영자가 missing/expired/signature/issuer/audience/unknown-kid 를 구분할 수 있게 어떻게 구현했나요? 클라이언트에는 무엇을 노출했나요?
- 출처: 예상 질문 (실제 면접 아님).
## 질문 의도 추론 / Why this question
- 핵심 평가 대상:
- Spring Security resource server 의 **실패 처리 위치** 이해 — bearer 토큰 검증 실패는 `BearerTokenAuthenticationFilter`/`ExceptionTranslationFilter``AuthenticationEntryPoint` 로 보내며 `@RestControllerAdvice`**도달하지 않는다**. 그래서 fine-grained 분류는 EntryPoint/AccessDeniedHandler 에 있어야 한다.
- 보안 응답의 **과노출 방지** — 클라이언트엔 generic message(`Authentication failed`)만, 내부엔 분류 code. issuer/audience/token 값을 응답·로그에 흘리지 않기.
- 표준 정합 — 401 은 `WWW-Authenticate` MUST(RFC 9110 §15.5.2), transient(kid/jwks)엔 `Retry-After`.
- 함정:
- "@RestControllerAdvice 에서 `AuthenticationException` 잡으면 된다" — filter-layer 실패는 거기 안 온다.
- exception → code 매핑을 message 문자열 heuristic 에 의존하는 것의 fragility 를 인정 안 함.
- clock skew 를 default 에 맡기고 "Spring 이 알아서" — 버전 업 시 silent drift.
- 후속 질문:
- `JwtValidationException``BadJwtException` 의 차이, 각각 어떤 실패인가?
- unknown kid 를 왜 retryable=true + Retry-After 로 두나? (rotation 중 JWKS refresh 로 해소)
- clock skew 60s 를 명시 설정한 이유? (default 의존 시 drift)
- 다중 audience/validator 동시 실패 시 어떤 code 를 우선하나?
## 답변 재료 / Raw answer material
- 구현: `SecurityErrorClassifier`(exception graph + validator/Nimbus message heuristic, 우선순위 expired>issuer>audience), `EnvelopeAuthenticationEntryPoint`/`EnvelopeAccessDeniedHandler`(공통 `AuthErrorResponseWriter` → Envelope JSON), `JwtDecoderConfig`(`SupplierJwtDecoder` 로 lazy 60s clock skew + issuer + audience validator).
- 12 code 는 `docs/registries/error-codes.yaml` SSOT 와 `OperationalError` enum 일치(status/category/retryable).
- redaction: 응답 body 는 generic message, 로그는 code/category/method/path 만 — token(`eyJ...`) 미노출. contract test 로 강제.
- 한계(솔직): message 문자열 heuristic 은 Spring/Nimbus 버전 메시지 변경에 취약 → unmapped 는 generic 401 fallback(절대 500 아님). 실 IdP 통합 테스트는 미수행(`prod-verified` 아님).

Some files were not shown because too many files have changed in this diff Show More