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
@@ -0,0 +1,182 @@
---
title: ca-tmpl - API Error Envelope 결정 (custom envelope, ProblemDetail 거부)
source_type: project
status: verified
confidence: high
tags: [ca-tmpl, api-design, error-handling, actually-implemented, locally-verified]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
---
# ca-tmpl - API Error Envelope 결정 (custom envelope, ProblemDetail 거부)
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념은 [[wiki/concepts/api-error-envelope-design]] 참고.
## 프로젝트 컨텍스트
ca-tmpl은 Clean Architecture 기반 백엔드 skeleton 프로젝트다. 운영 환경에서 API 실패 응답을 일관된 구조로 직렬화하고, client가 분기/재시도/관측 가능하도록 만들기 위해 자체 error envelope을 설계했다. RFC 7807 ProblemDetail이 Spring 6+ 기본 지원이지만 의식적으로 거부하고 다음 shape을 채택했다.
```text
{
success: boolean,
data: <T> | null,
error: {
code: string,
category: string,
message: string,
retryable: boolean,
details: <항목별 오류 배열> | null
} | null,
meta: { requestId, traceId, correlationId, ... }
}
```
진행 상태: **Phase C2 (구현) 완료 (2026-06-01).** envelope record, exception handler, error response factory가 코드에 존재하고 `./gradlew check` (전 모듈 test + ArchUnit)가 통과한다. 단 `Retry-After` 헤더 발행과 span ERROR 기록은 seam/stub 상태이며 owner branch에 위임돼 있다(아래 명시).
> **Ground-truth 대조 (2026-06-04, ca-tmpl @0c996fc "운영 에러 관측성 foundation 계약 구현", HEAD `db61075`에서도 존재 확인):** 아래 envelope field shape·클래스·enum·ArchUnit/config는 ca-tmpl 코드 실측으로 일치 확인. 패키지 root는 `dev.caskeleton.*` (이전 stale 추출의 `com.example.blog`/`sample-ticket` 류는 발견되지 않음 — 현재 sample 모듈은 `sample-portfolio`). 모듈 경로는 `src/<module>/src/main/java/dev/caskeleton/...`.
## 실제 구현 내용 (`actually-implemented`)
`/home/donghyeon/workspace/ca-tmpl` 코드에 실재 (grep 확인):
- `shared-contract/response/Envelope.java``record Envelope<T>(boolean success, T data, ApiError error, ResponseMeta meta)`. success/failure가 한 shape 공유, `ok()`/`failure()` 팩토리. RFC 7807 거부 javadoc 명시.
- `shared-contract/response/ApiError.java``record ApiError(String code, String category, String message, boolean retryable, Object details)`. `category`가 1급 필드(10-enum 이름), `retryable` 1급, `details`는 code별 polymorphic.
- `shared-contract/response/ResponseMeta.java``record ResponseMeta(String requestId, String traceId, String correlationId, ...)` — 평면 `traceId`를 대체한 meta 객체(D20).
- `shared-contract/error/Category.java` — 10-value 운영 분류 enum.
- `shared-contract/error/OperationalError.java` + `error/ApiErrorCode.java` — code 카탈로그 + `category()` 매핑(`VALIDATION_FAILED`/`MAPPING_FAILED`/… → `VALIDATION`, `UNAUTHENTICATED`/`INVALID_TOKEN``AUTH`, `FORBIDDEN``AUTHZ`, `ROUTE_NOT_FOUND``NOT_FOUND`, `INTERNAL_ERROR``INTERNAL`). `retryable`은 per-code 유지.
- `adapter-web/error/GlobalExceptionHandler.java` + `error/ErrorResponseFactory.java` — 예외 → envelope 변환, `code.category().name()` 주입.
- `adapter-web/envelope/EnvelopeBodyAdvice.java` — 성공 응답 envelope 래핑.
- `feature-api-contract-baseline` 이후 transport failure 매핑 — 413(`PAYLOAD_TOO_LARGE`), 406(`NOT_ACCEPTABLE`), 415(`UNSUPPORTED_MEDIA_TYPE`), 405(`METHOD_NOT_ALLOWED` + `Allow` header), 412(`PRECONDITION_FAILED`) 를 같은 envelope shape으로 반환하되, category/status 의미는 보존한다. Spring MVC `ResponseEntityExceptionHandler` 가 이미 다루는 umbrella exception은 `@ExceptionHandler` 중복 등록이 아니라 protected override로 처리한다.
ProblemDetail 거부가 **빌드 타임에 강제**된다 (코드 실측):
- `app-bootstrap/.../architecture/CleanArchitectureTest.java` (ArchUnit) — `org.springframework.http.ProblemDetail` import 금지 규칙(L355 "D5: RFC 7807 ProblemDetail is explicitly rejected").
- `app-bootstrap/src/main/resources/application.yml``spring.mvc.problemdetails.enabled: false`로 pin.
- `app-bootstrap/.../settings/ProblemDetailDisabledConfigTest.java` — shipped `application.yml`이 그 플래그를 literal `false`로 유지하는지 검증 (default flip 회귀 방지).
## 로컬/dev 검증 (`locally-verified`)
`./gradlew check` (전 모듈 test + `verifyCleanArchitectureDependencies` + ArchUnit `CleanArchitectureTest`) **BUILD SUCCESSFUL** (2026-06-01). 검증 테스트: `EnvelopeTest`/`ApiErrorTest`/`CategoryTest`(shared-contract), `EnvelopeMetaIntegrationTest`(adapter-web standalone MockMvc — meta/category 필드 + leak 차단). 단 운영(prod) 검증은 아직 없음.
`feature-api-contract-baseline` 의 transport failure envelope 범위는 `TransportErrorHandlingTest` 로 413/406/415 distinct + 405 `Allow` header를 검증했고, `WorkLogControllerWireTest``If-Match` mismatch → 412 envelope 흐름을 검증했다. 이 검증은 framework/transport failure를 domain validation과 같은 원인으로 섞는 것이 아니라, 같은 response shape 안에서 status/code/category를 보존하는 범위다.
## 운영 검증 (`prod-verified`)
없음. 운영 배포 자체가 존재하지 않는다.
## 설계 결정 (구현됨 — `actually-implemented` + `locally-verified`)
> 2026-06-01 이전에는 본 섹션 전체가 `documented-only`였으나 Phase C2로 envelope schema가 코드화·로컬 검증됨. 아래 schema 결정·leak catalog는 이제 코드에 반영돼 있다. 단 `Retry-After` 헤더 발행 / 5xx span ERROR 기록은 여전히 **seam/stub**(owner branch 위임), business rule violation → envelope 변환은 **다른 branch 책임**이다(아래 명시).
### Envelope schema 결정
- success / error 대칭 envelope: 성공도 동일한 top-level shape으로 감싸 `success: true/false` 분기를 client에 단일 규칙으로 제공.
- `error.code` (머신리더블 식별자) 와 `error.category` (운영 분류) 를 별도 1급 필드로 분리.
- `error.retryable: boolean`을 1급 필드로 승격. client 재시도 정책을 envelope 자체에서 가이드.
- `error.details`로 항목 단위 오류(예: validation field error) 를 배열로 운반.
- `meta``requestId`, `traceId`, `correlationId`를 1급으로 노출 — 로그/트레이스와 응답을 join 가능.
출처: [[raw/project-notes/ca-skeleton-operational-contract]] §3 (Structured API Response Contract) / §5 (Exception Ownership Contract) / §6 (Operational Error Category).
### 5종 envelope 대안 검토 결과
[[raw/project-notes/ca-skeleton-operational-contract]] §29 Topic 4에서 다음 5종을 비교 후 **custom envelope** 채택.
| 후보 | 거부 사유 |
|------|-----------|
| RFC 7807 ProblemDetail | 실패 전용 평면 shape — success/error 대칭 요구와 구조적 충돌. `code`/`retryable`/`category` 표준 부재로 결국 표준 위에 사실상 custom 레이어 추가가 필요. |
| Google `rpc.Status` | gRPC/protobuf 결합. HTTP REST 전용에서 `Any` 디코딩 부담을 client에 전가. CRUD 비중 큰 skeleton에 과한 표현력. |
| JSON:API errors | `errors[]` + `source.pointer`는 항목 단위 강점이나 `category`/`retryable` 1급 필드 없음. 부분 채택 시 표준성 상실, 완전 채택 시 spec 전체 lock-in. |
| GraphQL errors | HTTP 200 + `errors` 규약. REST envelope과 패러다임 자체가 다름. CDN/proxy/observability 4xx/5xx 알람과 부조화. |
| Custom envelope (채택) | 표준 client SDK가 0개라는 비용을 감수하는 대신 success/error 대칭 + `retryable`/`category` 1급화 + observability 메타 노출이라는 운영 요구를 충족. |
### Exception leak 금지 항목 catalog
응답 envelope에 절대 노출 금지로 계약된 항목:
- exception class fully-qualified name
- stack trace 전체 또는 일부
- SQL / SQL fragment / bind parameter
- token / credential / secret 값
- raw request body / raw upstream response body
출처: [[raw/branch-notes/feature-operational-error-observability-foundation]] (envelope schema SSOT 및 leak 금지 catalog).
### Validation / business rule 매핑
- boundary validation (request DTO 단계): 항목별 오류를 `error.details[]``{field, code, message}` 형태로 매핑. owner = [[raw/branch-notes/feature-boundary-validation-mapping-contract]] (`actually-implemented``VALIDATION_FAILED` details shape).
- business rule violation (use case 내부 invariant): `error.category`로 분류하고 `error.details`로 세부 위반 정보를 운반. owner = [[raw/branch-notes/feature-business-rule-validation-contract]].
foundation 측 exception → envelope 변환 골격(`GlobalExceptionHandler`/`ErrorResponseFactory`)은 구현됨. 위 항목별 매핑 *세부*(validation field 매핑 / business invariant 분류)는 각 owner branch 책임이다.
### Blog-topic ingest: spring-responseentityexceptionhandler-transport-failure-envelope (2026-07-02)
[[raw/blog-topics/spring-responseentityexceptionhandler-transport-failure-envelope-2026-07-02]] 는 Spring MVC transport failure를 custom envelope에 태운 경험을 블로그로 풀기 위한 raw seed다. canonical 승격 기준은 다음처럼 정리했다.
- **locally-verified 로 말할 수 있는 부분**: 413/406/415/405(+`Allow`) transport failure와 412 precondition failure가 ca-tmpl envelope shape으로 매핑되고 테스트된다.
- **source-backed 로 말할 수 있는 부분**: 406/415/405/412/413의 HTTP status 의미는 RFC 9110 계열 근거와 기존 `api-evolution-and-schema` project canonical에 연결된다.
- **project-local implementation 으로 말할 부분**: Spring MVC `ResponseEntityExceptionHandler` 흐름을 깨지 않기 위해 umbrella exception은 protected override로 처리한다는 구현 선택.
- **블로그 전 과장 방지**: Spring MVC의 모든 예외가 envelope으로 포괄된다고 쓰지 않는다. 검증된 transport failure row와 owner branch 범위로 제한한다.
### Blog-topic ingest: operational-error-envelope-meta-category-migration (2026-07-02)
[[raw/blog-topics/operational-error-envelope-meta-category-migration-2026-06-01]] 는 기존 `{success,data,error,traceId}` 응답을 `error.category``meta.{requestId,traceId,correlationId}`가 있는 richer envelope로 additive migration한 경험을 블로그로 풀기 위한 raw seed다.
- **canonical 반영 범위**: verified error envelope 구현 문서에 meta/category migration, enum vocabulary, response meta factory 글감을 연결했다.
- **blogify 전 가능 범위**: 이 canonical은 `verified` 이므로 blogify 후보가 될 수 있다.
- **블로그 전 과장 방지**: 운영 배포/운영 검증이 아니라 코드 구현 + 로컬 검증 범위로 제한한다.
- [[raw/blog-topics/spring-security-filter-layer-error-envelope-2026-06-08]]: Spring Security filter-layer 인증/인가 실패를 custom `AuthenticationEntryPoint` / `AccessDeniedHandler`에서 같은 envelope shape으로 직렬화하는 글감. 보안 adapter 구현 여부와 heuristic 분류 한계를 재확인한다.
## 문서/계획만 존재 (`documented-only` / `planned`)
- **`Retry-After` 헤더 발행** (`planned`): rate-limit owner branch 위임. GlobalExceptionHandler 내 seam/stub 상태. 실제 헤더 발행 로직은 미구현.
- **5xx span ERROR 기록** (`planned`): distributed-tracing owner branch 위임. span 조립/에러 마킹 로직은 seam/stub 상태.
- **business rule violation → `error.category` 매핑 세부** (`planned`): [[raw/branch-notes/feature-business-rule-validation-contract]] 담당. use case 내부 invariant 위반을 `error.category`·`error.details`로 분류하는 세부 정책은 foundation 측 골격만 존재하고 실제 분류 로직은 미구현.
## 면접에서 말할 수 있는 범위
### 자신 있게 답할 수 있는 질문
- ProblemDetail을 채택하지 **않은** 이유 — 실패 전용 평면 shape이라 success/error 대칭 요구와 구조적으로 충돌하고, `code`/`retryable`/`category`가 표준 부재라 결국 표준 위에 custom 레이어가 또 필요해진다.
- `retryable`을 1급 필드로 둔 의미 — client 재시도 정책을 envelope 자체에서 가이드하기 위함. 단, `RetryInfo.retry_delay` 수준의 actionable delay 정보는 잃는다는 trade-off까지 인지.
- validation error를 `error.details`에 매핑하는 정책의 의도 (boundary vs business rule 구분).
- exception leak 금지 항목 catalog와 각 항목이 왜 금지인지.
### 적당히 답할 수 있는 질문
- Stripe / GitHub / 토스페이먼츠 envelope과 ca-tmpl envelope의 차이점.
- JSON:API `source.pointer` 와 ca-tmpl `error.details[].field` 표현의 비교.
### 말할 수 있는 범위 (구현 사실 + 한계)
- "이 envelope을 코드로 구현했는가" — **답: 그렇다 (`actually-implemented` + `locally-verified`).** `Envelope`/`ApiError`/`ResponseMeta` record + `GlobalExceptionHandler`가 코드에 있고 `./gradlew check` 통과. 단 *로컬* 검증까지다.
- "운영에서 어떻게 동작하는가 / 운영 측정값" — **답: 운영(prod) 검증은 없음.** 로컬 빌드/테스트 수준까지만.
- "`Retry-After` 헤더·5xx span ERROR 기록도 동작하는가" — **답: seam/stub 단계.** 헤더 발행/span 조립은 rate-limit·distributed-tracing owner branch 위임.
## 과장 금지 지점
- **"ca-tmpl envelope이 표준이다"** — ❌. 어떤 IETF/W3C 표준도 success/error 대칭 + `retryable` 1급 + `category` 1급을 동시에 강제하지 않는다. 자체 결정이다.
- **"ProblemDetail이 잘못된 설계다"** — ❌. 실패 전용 use case (외부 노출 API, RFC 9457 client 생태계 활용)에서는 유효한 선택이다. ca-tmpl의 요구 조합과 맞지 않았을 뿐이다.
- **"envelope을 운영에서 검증했다"** — ❌. 코드 구현 + `./gradlew check` 로컬 통과까지(`locally-verified`)이며, prod 배포·측정은 없다. "구현했다"는 OK, "운영 검증했다"는 과장.
- **"Stripe/GitHub/토스가 다 custom이니까 표준은 의미 없다"** — ❌. 그들은 SDK가 envelope을 흡수하는 전제 위에 동작한다. 표준 미준수가 정당화되는 게 아니라 trade-off가 다른 것뿐이다.
## 관련 개념
- [[wiki/concepts/api-error-envelope-design]]
## Sources
- [[raw/project-notes/ca-skeleton-operational-contract]] §3 Structured API Response Contract / §5 Exception Ownership Contract / §6 Operational Error Category / §29 Topic 4 (5종 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 매핑과 `TransportErrorHandlingTest` 검증
- [[raw/blog-topics/spring-responseentityexceptionhandler-transport-failure-envelope-2026-07-02]] — transport failure envelope 블로그 글감 raw seed. canonical 반영 범위: verified transport rows + Spring MVC override 경계 + 과장 금지 항목.
- [[raw/blog-topics/operational-error-envelope-meta-category-migration-2026-06-01]] — meta/category migration 블로그 글감 raw seed.
- [[raw/blog-topics/spring-security-filter-layer-error-envelope-2026-06-08]] — Spring Security filter-layer envelope 블로그 글감 raw seed.
- [[raw/branch-notes/feature-boundary-validation-mapping-contract]] — boundary validation → `error.details` 매핑
- [[raw/branch-notes/feature-business-rule-validation-contract]] — business invariant → `error.category` 매핑
## Cluster / 묶음
<!-- GENERATED: derived-blogs:start -->
- [[wiki/blog/ca-tmpl-api-error-envelope-design-2026-07-02]]
<!-- GENERATED: derived-blogs:end -->
@@ -0,0 +1,225 @@
---
title: ca-tmpl - API Evolution & Schema 결정 (versioning + contract baseline + serialization)
source_type: project
status: verified
confidence: high
tags: [ca-tmpl, api-design, versioning, pagination, conditional-request, http-cache, openapi, schema]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
---
# ca-tmpl - API Evolution & Schema 결정 (versioning + contract baseline + serialization)
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념은 [[wiki/concepts/api-evolution-and-schema]] 참고.
## 프로젝트 컨텍스트
ca-tmpl은 Clean Architecture 기반 백엔드 skeleton 프로젝트다. 이 문서는 API surface 의 세 영역을 다룬다.
- **API contract baseline (구현됨)** — versioning (`/v1` path prefix), pagination/sort, conditional request (ETag/If-Match/304/412), HTTP cache policy, OpenAPI producer, long-running operation, batch endpoint. `feature-api-contract-baseline` branch 가 producer-소유 결정을 실제 코드(`adapter-web` + `sample-portfolio`)에 구현하고 단위/슬라이스/임베디드 테스트로 검증했다. **`locally-verified`**.
- **Compatibility / deprecation 축 (설계만)** — `90d public + 30d internal migration window`, 7행 breaking change catalog, RFC 8594 `Sunset` + `Deprecation` 헤더 병기, OpenAPI `deprecated: true` marker. `feature-api-compatibility-deprecation-contract` branch 의 결정이며 **코드 미구현 (`documented-only`)**.
- **Schema / serialization 축 (출력측 부분 구현)** — ISO-8601 offset datetime, `BigDecimal` scale 2 + `HALF_UP`, unknown field strict inbound, null/empty/missing 분리. `feature-schema-serialization-contract` branch 의 결정이다. **직렬화 출력측 핀 (`WRITE_DATES_AS_TIMESTAMPS=false` / `WRITE_BIGDECIMAL_AS_PLAIN=true`) + `new BigDecimal(double)` 정적 차단 ArchUnit 룰 + 직렬화 동작 테스트는 실제 코드로 구현·로컬 검증됨 (`locally-verified`)**. 단 입력측 deser switch·null/empty/missing 3-상태(`Patch<T>`)는 sibling `feature-boundary-validation-mapping-contract` 가 소유하며, OpenAPI drift release gate (D5) · 제거-field 재사용 도구 (D6) · Avro Schema Registry (D7) · money string-vs-number per-API 코드 시연은 미구현 (`documented-only` / `planned` / `needs-confirmation`).
> **Ground-truth 대조 (2026-06-04, ca-tmpl @b15dcf5 "API 계약 baseline 구현")**: contract baseline 축의 아래 `actually-implemented` / `locally-verified` 항목은 ca-tmpl 저장소 commit `b15dcf5` 의 실제 코드(`dev.caskeleton.*` package root)와 1:1 대조해 확인했다.
>
> **Ground-truth 대조 (2026-06-04, ca-tmpl @5d89766 "schema/serialization 계약 직렬화 출력측 구현")**: schema/serialization 축 *출력측* 항목 — `no_bigdecimal_double_constructor` ArchUnit 룰(`CleanArchitectureTest`), `JacksonSerializationPolicyTest`, `application.yml`/`application-test.yml`/`.env` 의 직렬화 핀 두 키 — 은 commit `5d89766` 의 실제 코드와 1:1 대조해 확인했다 (`locally-verified`). compatibility/deprecation 축 + schema 의 D5/D6/D7 + per-API money 직렬화 코드 시연은 여전히 `documented-only` / `planned` / `needs-confirmation`.
## 실제 구현 내용 (`actually-implemented`)
> **API contract baseline 축** (`feature-api-contract-baseline`) + **schema/serialization 축의 출력측** (`feature-schema-serialization-contract`) 이 구현됨. compatibility/deprecation 축 + schema 의 D5/D6/D7 은 코드 부재 (§문서/계획만 존재).
코드에 존재하는 클래스/필터 (테스트 유무와 무관하게 production main 소스에 존재):
- **D2 versioning** — `/v1` path prefix 는 설정 주도(`app-bootstrap/.../application.yml``ca-skeleton.presentation.api-base-path: ${PRESENTATION_API_BASE_PATH:/v1}`) + `adapter-web` `PresentationSettings` (env 누락/`/` 누락 시 warn + 보정). 코드 자체의 default 는 `""`, 운영 default 는 `/v1`.
- **D18/D20 pagination/sort** — `adapter-web` `PageParams` (page≥0, size 1..100, deep-offset>10000 플래그), `SortParam` (Spring native `field,direction` 파싱 + 비-네이티브 reject), `shared-contract` `PageMeta`/`ResponseMeta.page`.
- **D15 conditional request** — `adapter-web/conditional/ETags` (`weakFromVersion` = `W/"<version>"`, lenient `matches`), `PreconditionFailedException`.
- **D16 cache policy** — `adapter-web/filter/CacheControlFilter` (`@Order(HIGHEST_PRECEDENCE+20)`, 모든 응답에 `Cache-Control: no-store` + `Vary: Accept, Accept-Encoding, Authorization`).
- **D22 cursor (SEAM)** — `adapter-web/cursor/CursorCodec` (base64url(`iat:payload`) + HMAC-SHA256 + 24h TTL) + `CursorException`.
- **D17 LRO** — `sample-portfolio` `OperationsController` (`POST /worklogs:export` → 202 + `Location` + `Operation`, `GET /operations/{id}` polling), `shared-contract` `Operation`/`OperationStatus`, `SampleOperationStore`.
- **D8/D9/D12 transport errors** — `adapter-web/error/GlobalExceptionHandler` 가 413(`PAYLOAD_TOO_LARGE`)/406(`NOT_ACCEPTABLE`)/415(`UNSUPPORTED_MEDIA_TYPE`)/405(`METHOD_NOT_ALLOWED` + `Allow` header)/412(`PRECONDITION_FAILED`) 를 envelope 로 매핑.
- **D23 batch** — `sample-portfolio` `WorkLogController``POST /worklogs:batchCreate` (단일 tx atomic, `@Size(max=1000)` cap) + `BatchCreateWorkLogsUseCase`.
- **D10 OpenAPI producer** — `adapter-web/build.gradle``springdoc-openapi-starter-webmvc-api:2.8.6` 의존 추가, `/v3/api-docs` 노출.
### Schema / serialization 출력측 (`feature-schema-serialization-contract`, ca-tmpl @5d89766)
직렬화 *출력측* 계약을 코드에 핀하고 정적으로 차단했다. 입력측 deser switch(`FAIL_ON_UNKNOWN_PROPERTIES`/`FAIL_ON_NULL_FOR_PRIMITIVES`/`READ_UNKNOWN_ENUM_VALUES_AS_NULL=false`)와 null/empty/missing 3-상태(`Patch<T>`)는 sibling `feature-boundary-validation-mapping-contract` 소유이므로 본 축 *출력측* 만 여기서 다룬다.
- **D2 datetime 직렬화 핀** — `app-bootstrap/.../application.yml``spring.jackson.serialization.write-dates-as-timestamps=false` (env `SPRING_JACKSON_SER_WRITE_DATES_AS_TIMESTAMPS` 바인딩). `java.time` 값이 epoch/배열이 아니라 ISO-8601 문자열로 직렬화됨. `JavaTimeModule` 은 Spring Boot `starter-json` auto-config 가 classpath 의 `jackson-datatype-jsr310` 을 자동 등록 — 명시 등록 코드는 없음.
- **D3 BigDecimal plain 직렬화 핀** — `application.yml``spring.jackson.generator.write-bigdecimal-as-plain=true` (env `SPRING_JACKSON_GEN_WRITE_BIGDECIMAL_AS_PLAIN` 바인딩). 지수 표기(`1.23E+10`) 대신 plain notation 으로 직렬화.
- **D3 정적 차단 ArchUnit 룰** — `app-bootstrap/.../architecture/CleanArchitectureTest``no_bigdecimal_double_constructor` (`@ArchTest`). `dev.caskeleton..` production 패키지에서 `callConstructor(BigDecimal.class, double.class)` / `float.class` 호출을 build fail. (`new BigDecimal(0.1)` 의 부동소수 잔차 함정 = SBMS-C3 차단)
- **위반 fixture** — `architecture/violations/serialization/BigDecimalDoubleConstructorFixture` (`new BigDecimal(double/float)` 사용) — 룰의 vacuous-pass 방지용 negative fixture.
- **테스트 리소스 핀** — `application-test.yml` 에 위 두 키를 리터럴(`false`/`true`)로 박아 테스트 프로파일에서도 동일 계약 유지.
이 핀들은 *현재 Spring Boot 기본값과 일치*하나, future default flip 회귀를 차단하기 위해 명시했다 (rationale 은 `.env` 주석에 `spring.mvc.problemdetails.enabled=false` 와 동일 논리로 기록).
compatibility/deprecation 축: 없음 (version interceptor, Sunset/Deprecation header bean, OpenAPI deprecation marker 모두 부재). schema 축의 D5 OpenAPI drift release gate · D6 제거-field 재사용 도구 · D7 Avro Schema Registry · money string-vs-number per-API 코드 시연: 부재 (§문서/계획만 존재 / SEAM).
## 로컬/dev 검증 (`locally-verified`)
위 contract baseline 구현은 단위/슬라이스/임베디드-컨테이너 테스트로 동작이 확인됐다 (`./gradlew check` + ArchUnit gate PASS):
- `TransportErrorHandlingTest` — 413/406/415 distinct + 405 + `Allow` header.
- `WorkLogControllerWireTest` — D15 ETag 발행 / `If-None-Match`→304 / `If-Match` mismatch→412, D7/D18 `meta.page` + size·page 경계 400 + 빈 list `[]` + deep-offset `Deprecation` 헤더, D20 sort 네이티브/비-네이티브, D21 flat filter 무시(`filter_dsl_is_ignored_not_parsed`), D13 HEAD-mirror-GET(`head_on_get_endpoint_is_supported_not_405`), D23 batch size cap(`batch_over_size_cap_is_400`, 1001→400), D3 `Idempotency-Key` POST surface(`post_accepts_idempotency_key_header`, server-tolerant).
- `CacheControlFilterTest` — D16 default `no-store` + `Vary`.
- `CursorCodecTest` — D22 opacity / integrity(서명 변조 탐지) / TTL 3-invariant.
- `ETagsTest`, `PageParamsTest`, `SortParamTest` — adapter 단위 검증.
- `OperationsControllerWireTest` — D17 202 + `Location` + `data.{operationId,statusUrl}` + polling.
- `OpenApiSnapshotTest` — D10 임베디드 RANDOM_PORT 컨테이너에서 `/v3/api-docs` 200 응답 + `WorkLogController` 반영.
- `VersioningPrefixTest` — D2 `/v1/probe` 200, `/probe` 404 (unversioned public endpoint 불가).
- `DateHeaderContractTest` — D24 임베디드 Tomcat 200·404 응답에 `Date` 헤더.
- `ErrorCodeRegistryMappingTest` — D11 405/406/412/413/414/415 row 와 controller 응답 drift FAIL (producer contract test).
Schema / serialization 출력측 (`feature-schema-serialization-contract`, @5d89766) 테스트:
- `JacksonSerializationPolicyTest` — ① `JacksonProperties` 바인딩 assert (`WRITE_DATES_AS_TIMESTAMPS=false`, `WRITE_BIGDECIMAL_AS_PLAIN=true`), ② wired `ObjectMapper` 직렬화 동작 assert: `OffsetDateTime`(UTC)→`"1985-04-12T23:20:50.52Z"`, `LocalDate``"2026-06-02"`, `new BigDecimal("1.10")``1.10` (trailing zero 보존), 대형 값(`12300000000000000000.00`)이 비-scientific notation. `ApplicationContextRunner` 로 effective bean 동작까지 검증해 `JavaTimeModule` 누락 회귀(배열 직렬화)도 잡는다.
- `ArchitectureViolationFixtureTest.no_bigdecimal_double_constructor_catches_double_and_float_constructors` — D3 ArchUnit 룰이 fixture 의 `new BigDecimal(double/float)` 를 실제로 잡는지 검증 (vacuous-pass 방지).
- 검증 명령: `./gradlew verifyCleanArchitectureDependencies` + `:app-bootstrap:test` + 전체 `test` 모두 BUILD SUCCESSFUL.
compatibility/deprecation 축 + schema 의 D5/D6/D7: 없음. Sunset+Deprecation 헤더 응답·`api-version` 헤더 라우팅·OpenAPI drift release gate·제거-field 재사용 도구·Avro compat 자동검사 어느 것도 로컬에서 실행/통합 테스트로 확인된 바 없다. per-API money string-vs-number 직렬화도 sample 도메인에 money 필드가 없어 코드 시연 없음(문서 의무만).
## 운영 검증 (`prod-verified`)
없음. ca-tmpl 은 운영 배포가 없다. contract baseline 항목은 전부 로컬/CI 검증까지이며, compatibility/schema 축은 90d/30d migration window·deprecation cutover·Sunset 시점 410 응답 같은 운영 검증 0건이다.
### SEAM / 계획만 존재 (`planned`) — contract baseline 축
형제 branch 또는 인프라에 막혀 의도적으로 seam 또는 planned 로 남긴 항목 — 면접에서 "구현했다"고 말하면 안 되는 경계:
- **D22 HMAC 키 회전 / 운영 key 주입** — `CursorCodec` 은 주입식 key 와 `withDevKey()` (dev/test 전용) factory 만 제공. production key wiring + rotation 은 `feature-security-operational-baseline` 소유, 미구현. encode/decode·opacity·integrity·TTL 메커니즘 자체는 구현됨.
- **D8 414 URI Too Long end-to-end** — Tomcat/gateway 가 Spring dispatch 전에 거부하므로 code + registry row 만 존재, end-to-end 검증 없음.
- **D3 key shape / replay semantics** — header 이름(`Idempotency-Key`)과 POST surface 수용만 구현. key shape/scope/replay 는 `feature-rate-limit-idempotency-contract` 소유.
- **D5 / D10 drift 릴리스 게이트** — OpenAPI drift release-blocking 집행은 `feature-contract-verification-test-suite` 소유. 이 branch 는 producer(snapshot 발행 + registry mapping 정합 test)까지.
- **D16 cache layer** — Redis/CDN 구현은 `feature-cache-consistency-contract` 소유. 이 branch 는 HTTP 응답 header 정책(`no-store`/`Vary`)만.
- **D22 sample cursor endpoint** — `CursorCodec` 만 있고 cursor 페이징을 노출하는 sample endpoint 는 §Test Contract 미요구 (optional).
- **D14 PATCH `merge-patch+json` 차단** — content type 정책은 이 branch 가 producer 지만 ArchUnit rule `no_merge_patch_json_media_type_string` 와 mapper 구현은 `feature-boundary-validation-mapping-contract` B2 소유 (cross-branch SSOT).
### 근거 미명시 구현 결정 (`UNSUPPORTED_IMPL_DECISION` 잔존)
표준이 *원칙* 만 권고하고 *숫자/메커니즘* 은 project-internal trade-off 인 지점 — 면접에서 "표준이라서"가 아니라 "내가 이렇게 trade-off 했다"로 말해야 함:
- **pagination size cap 100 / min 1 / deep-offset 10000** — Spring 기본 `DEFAULT_MAX_PAGE_SIZE` 는 2000(`PageParams` 주석에도 명시). 100 cap 은 DoS 방지용 추가 제한, 숫자는 표준 근거 없음.
- **ETag lenient(weak) 비교** — RFC 9110 은 `If-Match`*strong* comparison 을 MUST 로 규정하나(`ETags` javadoc 에 명시), skeleton 은 `W/` 마커·따옴표를 무시하는 lenient 비교로 weak-ETag 형태가 그대로 optimistic lock 을 구동하게 했다. production fork 는 strong ETag 로 교체 가능.
- **cursor 24h TTL + HMAC-SHA256 선택** — AIP-158 은 opacity/URL-safe 만 MUST, TTL 숫자와 서명 알고리즘은 project-internal.
- **LRO status enum 5종(PENDING/RUNNING/SUCCEEDED/FAILED/CANCELLED)** — AIP-151 은 `done`/`response`/`error` 이진 모델만 정의, 5종 어휘 매핑은 project-internal.
## 문서/계획만 존재 (`documented-only` / `planned`)
> **Compatibility / deprecation 축** (`feature-api-compatibility-deprecation-contract`) 은 결정/설계만 있고 **코드 미구현(`documented-only`)** 이다. **Schema / serialization 축** 은 *출력측* (datetime/BigDecimal 핀 + ArchUnit) 만 `locally-verified` (위 §실제 구현 내용 참조) 이고, 아래 D5/D6/D7 + per-API money 직렬화는 여전히 미구현이다. contract baseline 의 `locally-verified` 와 혼동하면 안 된다.
다음 항목은 모두 canonical 계약 문서와 branch-note 단계에 머물러 있다. 면접에서 "구현했다 / 운영했다"고 말하면 안 된다.
### Compatibility / deprecation 결정
- **90d public + 30d internal migration window**: 외부 client는 90일, internal client는 30일의 이중 window로 deprecated API를 계속 응답하면서 marker로 신호한다. Stripe의 freeze-forever, GitHub의 24mo EOL과 비교 검토 후 internal-first 환경 trade-off로 90d/30d를 선택.
- **7행 breaking change catalog**: 응답 필드 제거 / 응답 필드 의미 변화 / required request field 추가 / enum value 제거 / enum value 의미 변화 / narrow enum(허용값 축소) / 기본값 변경 — 7항목을 breaking으로 분류. Google AIP-180 정의를 ca-tmpl 도메인에 맞게 행 단위로 catalog화.
- **`Sunset` 헤더 (RFC 8594) + `Deprecation` 헤더 병기**: Sunset 단독은 *언제 사라지는지*만 알리므로 *지금 deprecated인지* 신호인 `Deprecation` 헤더를 함께 보낸다. concept §흔한 오해 항목과 정합.
- **OpenAPI `deprecated: true` marker**: operation / schema 양쪽에 둘 수 있는 표준 marker로 deprecation을 schema SSOT에 박는다.
- **Sunset + Deprecation 헤더 paired 전송 결정 (2026-05-22)**: API deprecation 응답은 `Sunset: <HTTP-date>` + `Deprecation: @<unix-epoch>` 헤더를 **함께** 송신한다. 단독 Sunset 금지. paired invariant는 "Sunset 시점 ≥ Deprecation 시점". 추가로 `Link: <url>; rel="deprecation"` (정책 문서), `Link: <url>; rel="sunset"` (마이그레이션 가이드)를 권장. 근거: [[raw/official-docs/sunset-deprecation-headers-paired-usage]]. 상태: `documented-only` — bean / interceptor 코드 미작성.
출처: [[raw/project-notes/ca-skeleton-operational-contract]] §13 API Contract Surface + §29 G-F (외부 근거 인덱스), [[raw/branch-notes/feature-api-compatibility-deprecation-contract]].
### Blog-topic ingest: api-deprecation-sunset-header-migration-window (2026-07-02)
[[raw/blog-topics/api-deprecation-sunset-header-migration-window-2026-07-02]] 는 위 compatibility/deprecation 축을 블로그로 풀기 위한 raw seed다. canonical 승격 기준은 다음처럼 정리했다.
- **프로젝트 사실로 보존**: D5-D8은 `feature-api-compatibility-deprecation-contract` 에 기록된 ca-tmpl 결정이다. 단 구현/운영 검증이 없으므로 등급은 `documented-only` / `needs-confirmation` 이다.
- **source-backed 로 말할 수 있는 부분**: RFC 8594 `Sunset`, `Deprecation` header paired usage, Google AIP-180 기반 breaking-change 분류, OpenAPI `deprecated: true` marker 의 존재.
- **project-local policy 로만 말할 부분**: `90d public + 30d internal` 숫자, release-blocking diff gate, compatibility fixture 결합 방식. 표준 요구사항처럼 쓰지 않는다.
- **블로그 전 과장 방지**: 실제 API deprecation 운영 경험, 외부 client migration coordination, 410 cutover 실측은 없다.
### Schema / serialization 결정 (출력측은 위 §에서 구현, 아래는 미구현분만)
> 아래 항목 중 datetime/BigDecimal *출력측 핀* 과 `new BigDecimal(double)` 정적 차단은 @5d89766 에서 `locally-verified` (§실제 구현 내용 참조). unknown field strict inbound 와 null/empty/missing 분리는 sibling `feature-boundary-validation-mapping-contract` 가 `locally-verified` (입력측 deser + `Patch<T>`). 여기 남는 미구현분은 D5/D6/D7 + per-API money 직렬화 코드 시연이다.
- **per-API money string-vs-number 직렬화 시연** (`documented-only`): scale 2 + `HALF_UP` 기본 + plain notation 핀은 구현됐으나, 외부/금융 API = string vs 내부 API = number+plain 의 endpoint별 명시 선택은 **문서 의무**(adapter-web 계약 문서)로만 박혔다. sample 도메인(WorkLog)에 money 필드가 없어 `@JsonSerialize(ToStringSerializer)` 같은 코드 시연은 없다.
- **Field 재사용 금지 catalog 정책 (자체 markdown 또는 OpenAPI `x-removed-fields`)** (`needs-confirmation`, D6): Protobuf `reserved` 시맨틱(field number/name 재사용 영구 차단)을 JSON 환경에서 흉내내기 위해 제거된 field 이름/번호를 catalog로 관리하고 CI에서 재사용을 검출. 두 후보 — (a) OpenAPI Specification Extension `x-removed-fields` + 자체 lint, (b) 별도 markdown catalog + CI cross-check — 중 도구 선택이 미정. 2026-05-22 needs-confirmation. 출처: [[raw/official-docs/protobuf-reserved-vs-json-openapi-extension]].
- **OpenAPI drift release gate** (`planned`, D5): response 측 "schema 없는 field 미노출" 의 실제 강제는 verification suite 소유. springdoc producer 는 존재하나 release-blocking drift gate 는 `feature-contract-verification-test-suite` 미구현.
- **Avro Schema Registry compat 자동검사** (`needs-confirmation`, D7): outbox/event 한정 검토 가치. 외부 REST/JSON 은 JSON 유지. Confluent compatibility level enforcement 메커니즘 미확보.
출처: [[raw/project-notes/ca-skeleton-operational-contract]] §16 Schema / Serialization Contract + §29 G-F, [[raw/branch-notes/feature-schema-serialization-contract]].
### Blog-topic ingest: spring-boot-serialization-contract-pins (2026-07-02)
[[raw/blog-topics/spring-boot-serialization-contract-pins-2026-07-02]] 는 schema/serialization 출력측 구현을 블로그로 풀기 위한 raw seed다. canonical 승격 기준은 다음처럼 정리했다.
- **locally-verified 로 말할 수 있는 부분**: `WRITE_DATES_AS_TIMESTAMPS=false`, `WRITE_BIGDECIMAL_AS_PLAIN=true` 설정 pin, wired `ObjectMapper` 직렬화 테스트, `new BigDecimal(double/float)` ArchUnit 차단과 negative fixture.
- **source-backed 로 말할 수 있는 부분**: RFC 3339 datetime 표현, Java `BigDecimal` 생성자/scale/rounding 의미, Jackson serialization feature의 역할.
- **project-local policy 로만 말할 부분**: 현재 Spring Boot 기본값과 같아도 future default drift를 막기 위해 명시 pin + effective-bean test를 둔 결정.
- **블로그 전 과장 방지**: 입력측 deser switch, null/empty/missing 3-상태, per-API money string-vs-number 직렬화 예제는 이 branch의 구현 범위가 아니다. 특히 sample 도메인에는 money field 코드 시연이 없다.
### 5종 대안 검토 결과
concept 문서([[wiki/concepts/api-evolution-and-schema]]) Standard 섹션의 5개 진영 — Stripe date-based / GitHub `X-GitHub-Api-Version` + 24mo EOL / Google AIP-180 / Twitter tier-based / Spring HATEOAS — 을 비교한 결과 internal-first + 단일 팀 trade-off로 **`api-version` 헤더 + 90d/30d migration window + Sunset+Deprecation 병기**를 채택. 사유는 concept 문서 한계 / 주의점 섹션과 동일.
versioning/compatibility 대안 비교 자체는 문서/설계 단계 — version interceptor, Sunset header bean 미작성. (Jackson 직렬화 출력측 핀은 별개로 구현됨, §실제 구현 내용 참조.)
## 면접에서 말할 수 있는 범위
### 자신 있게 답할 수 있는 질문
- **90d public + 30d internal migration window 근거** — Stripe(freeze forever)는 외부 결제 컨슈머 규모에 특화된 trade-off라 internal에 그대로 차용 시 server에 N개 버전 분기를 영구 운반, GitHub 24mo EOL은 catalog에 410 응답 명시가 없으면 사실상 *어느 날 갑자기 410*과 같음. internal-first 단일 팀 환경에서는 deploy lag을 흡수할 수 있는 가장 짧은 두 layer로 90d/30d.
- **`Sunset` vs `Deprecation` 헤더 차이 + 함께 보내는 이유** — `Sunset`(RFC 8594)은 *언제* 사라지는지의 HTTP-date 신호(ABNF: `Sunset = HTTP-date`), `Deprecation` 헤더(draft-ietf-httpapi-deprecation-header)는 *지금 deprecated인지*의 Structured Date 상태 신호. 하나만 보내면 "사라질 날짜는 아는데 권장 여부는 모름" 또는 그 반대 상태가 되므로 **paired 송신이 IETF httpapi WG 권고**. paired invariant는 "Sunset 시점 ≥ Deprecation 시점". 추가로 `Link rel="deprecation"` / `rel="sunset"`으로 사람-가독 가이드 연결. ca-tmpl도 결정 사항에 paired 전송을 명시 박음(2026-05-22).
- **Narrow enum이 breaking인 이유** — server-side에서는 허용값 축소가 invariant 강화처럼 보이지만, 이전 enum value를 합법적으로 보내던 client 입장에서는 어제까지 통과하던 요청이 오늘 거부됨. enum value 추가도 client side에 unknown enum fallback이 contract로 없으면 breaking.
- **Strict inbound + tolerant outbound 의미** — 요청은 unknown field를 거부해 typo/payload smuggling 방어, 응답은 schema 정의 외 field 누출을 막음. 단 concept 문서가 지적하듯 정확한 표현은 "strict inbound / schema-controlled outbound".
- **BigDecimal `new BigDecimal(double)` 함정 + ArchUnit 정적 차단** — `new BigDecimal(0.1)``0.1000...555` 잔차를 담고 `new BigDecimal("0.1")`/`BigDecimal.valueOf`는 정확하다. ca-tmpl 은 이 함정을 `no_bigdecimal_double_constructor` ArchUnit 룰(`callConstructor(BigDecimal.class, double.class)`/`float.class`)로 production 패키지에서 build fail 시키고, vacuous-pass 방지 fixture 테스트까지 둔다 (`locally-verified`, @5d89766). HALF_UP 은 금융 round-half-up 관례와 정합. JSON number 직렬화 시 JS `Number` 정밀도 손실이 있어 외부/금융 API 는 string 직렬화 권장 — 단 per-API string-vs-number 는 *문서 의무*로만 박혔고 sample 도메인에 money 필드가 없어 코드 시연은 없다.
- **serialization 계약을 '기본값'이 아니라 '명시 핀 + effective-bean 테스트'로 고정한 이유** — `WRITE_DATES_AS_TIMESTAMPS=false`/`WRITE_BIGDECIMAL_AS_PLAIN=true`는 현재 Spring Boot 기본값과 일치하나, future default flip 회귀를 차단하려고 `application.yml`/`application-test.yml`/`.env`에 명시 핀했다 (`spring.mvc.problemdetails.enabled=false`와 동일 논리). `JacksonSerializationPolicyTest``ApplicationContextRunner`로 wired `ObjectMapper``OffsetDateTime``"...Z"`/`LocalDate``"YYYY-MM-DD"`/`BigDecimal`→plain 직렬화 동작까지 검증해 `JavaTimeModule` 누락 회귀(배열 직렬화)도 잡는다 (`locally-verified`, @5d89766).
- **conditional request 가 DB optimistic lock 과 같은 충돌의 두 표현이라는 점** — read 응답이 entity `@Version` 으로부터 `W/"<version>"` ETag 를 발행하고(`ETags.weakFromVersion`), write 가 `If-Match` 로 그 버전을 제시한다. 버전이 안 맞으면 HTTP layer 에서 412 Precondition Failed (`PreconditionFailedException``GlobalExceptionHandler`), 같은 충돌이 persistence layer 면 serialization failure 로 표현된다. ca-tmpl 은 `WorkLogControllerWireTest` 로 ETag 발행/304/412 를 검증했다 (locally-verified).
- **인증된 API 의 안전한 cache default = `no-store`** — `CacheControlFilter` 가 모든 응답에 `Cache-Control: no-store` + `Vary: Accept, Accept-Encoding, Authorization` 를 박아 proxy/CDN cache poisoning 을 막는다. cacheable endpoint 만 `ResponseEntity``Cache-Control` 로 opt-in. Spring Security 자체 cache-control 은 비활성화해서 이 필터를 단일 owner 로 둠.
- **pagination 의 size cap 이 왜 DoS 방어인가 + Spring 기본값과의 관계** — `size` 를 1..100 으로 제한하고 `page<0`/`size` 범위 밖은 400 VALIDATION_FAILED (`PageParams`). Spring 의 기본 `DEFAULT_MAX_PAGE_SIZE` 는 Integer.MAX_VALUE 가 아니라 2000 이며, 100 cap 은 그 위에 얹은 project-internal 추가 제한이라는 점까지 말할 수 있다.
- **batch endpoint 의 sync = atomic 결정** — `POST /worklogs:batchCreate` 는 AIP-136 colon-verb + 단일 트랜잭션 all-or-nothing (partial 금지), `@Size(max=1000)` cap. partial failure 는 async LRO polling 응답에서만 허용. `batch_over_size_cap_is_400` 으로 검증.
### 적당히 답할 수 있는 질문
- **Stripe date-based versioning vs ca-tmpl** — Stripe는 account 단위 version pin + freeze forever로 외부 결제 컨슈머 deploy lag을 server 측 영구 분기로 흡수, ca-tmpl은 헤더 기반 + 시한 migration window로 server 분기 부담을 한정. 다만 외부 컨슈머 규모 차이가 trade-off의 본질이라 "ca-tmpl이 더 낫다" 식의 단정은 금지.
### 답하면 안 되는 질문 (모른다고 해야 함)
- **"API deprecation을 운영해 본 경험"** — 답: 없음. ca-tmpl은 운영 배포 자체가 없다.
- **"외부 컨슈머와 migration coordination을 해본 경험"** — 답: 없음. 외부 컨슈머가 존재하지 않는다.
- **"compatibility/deprecation 결정을 코드로 구현했는가"** — 답: 아니다. 계약·설계 단계. (schema/serialization 출력측은 별개로 C2 에서 `locally-verified` — 위 §실제 구현 참조. compatibility 축만 미구현.)
- **"운영 측정값 / cutover 인시던트 / 410 응답 실측"** — 답: 모두 없다.
## 과장 금지 지점
- **"Stripe 방식이 API versioning의 표준이다"** — ❌. IETF/W3C 표준이 아니고 외부 결제 컨슈머 규모에 특화된 trade-off다. ca-tmpl은 다른 trade-off를 택한 것이지 우열을 판정한 게 아니다.
- **"`Sunset` 헤더만 보내면 deprecation 정책으로 충분하다"** — ❌. `Sunset`*언제* 신호이고 `Deprecation`*지금 상태* 신호다. 병기해야 정합.
- **"OpenAPI `deprecated: true`로 marker만 박으면 client가 알아서 migrate한다"** — ❌. schema marker는 신호일 뿐, 실제 cutover는 migration window + contract test + compatibility fixture가 함께 강제해야 한다.
- **"Protobuf `reserved` 시맨틱을 JSON 환경에서 동등하게 흉내낼 수 있다"** — ❌. OpenAPI에는 동등 시맨틱이 없고 `x-` extension으로 흉내내야 하는데 검증 도구 표준이 부재해 효과가 제한적이다. **needs-confirmation**.
- **"Jackson default가 안전하다"** — ❌. `FAIL_ON_UNKNOWN_PROPERTIES=true`는 strict이지만 `FAIL_ON_NULL_FOR_PRIMITIVES=false`는 lenient라 null/missing primitive가 묵시적으로 0이 된다. ca-tmpl 은 후자(입력측 deser switch)를 sibling `feature-boundary-validation-mapping-contract` 가 명시 override 했고 (`locally-verified`), 직렬화 출력측은 본 branch 가 핀했다. "default 라서 안전"이 아니라 "명시 핀 + 테스트"로 강제했다고 말해야 한다.
- **"90d/30d window를 운영에서 검증했다"** — ❌. 운영 배포 0건. 설계 결정의 *근거*는 말할 수 있지만 *경험*은 없다.
- **"envelope처럼 compatibility 결정도 구현했다"** — ❌. compatibility/deprecation 축은 계약/설계 단계, 코드 미구현. schema/serialization 축은 *출력측* (datetime/BigDecimal 핀 + ArchUnit + 직렬화 테스트) 만 `locally-verified` 이고, D5 OpenAPI drift gate · D6 제거-field 도구 · D7 Avro · per-API money string-vs-number 코드 시연은 미구현이다. (contract baseline 축은 별개로 locally-verified)
- **"BigDecimal 을 금액 string 직렬화로 구현했다"** — ❌. `WRITE_BIGDECIMAL_AS_PLAIN=true` + `new BigDecimal(double)` 정적 차단은 구현했으나, 외부 API string 직렬화(`@JsonSerialize(ToStringSerializer)`)는 sample 도메인에 money 필드가 없어 코드 시연이 없다 — per-API string-vs-number 는 *문서 의무*까지다.
- **"OpenAPI drift 로 schema 없는 response field 노출을 차단한다"** — ❌. 직렬화 출력측 핀은 했으나 response 측 "schema 없는 field 미노출"의 release-blocking 강제(D5)는 verification suite(`feature-contract-verification-test-suite`) 소유 planned 이다.
- **"conditional request 를 RFC 9110 대로 strong ETag 로 구현했다"** — ❌. `If-Match` 비교는 weak/lenient 다 (`ETags.matches``W/`·따옴표 무시). RFC 9110 의 strong comparison MUST 와는 다른 skeleton 단순화이며, production fork 에서 교체해야 한다.
- **"cursor pagination 을 운영 key 로 서명해 구현했다"** — ❌. `CursorCodec` 은 dev key factory(`withDevKey()`)만 있고 운영 key 주입/회전은 security branch 소유 planned. 메커니즘(opaque base64url + HMAC + TTL)은 구현·검증됨.
- **"414 URI Too Long 을 end-to-end 로 처리한다"** — ❌. Tomcat/gateway 가 Spring dispatch 전에 거부하므로 registry row + code 만 있고 end-to-end 검증은 없다.
- **"Idempotency 를 구현했다"** — ❌. `Idempotency-Key` header 이름 수용(server-tolerant)만. key shape/replay 는 rate-limit branch 소유.
- **"OpenAPI drift 를 릴리스에서 차단한다"** — ❌. 이 branch 는 snapshot producer + registry mapping 정합 test 까지. release-blocking 집행은 verification-test-suite branch 소유.
## 관련 개념
- [[wiki/concepts/api-evolution-and-schema]]
## Sources
- [[raw/project-notes/ca-skeleton-operational-contract]] §13 API Contract Surface / §16 Schema / Serialization Contract / §25 Default Decisions (API versioning) / §29 G-F (외부 근거 / 대안 조사 인덱스)
- [[raw/branch-notes/feature-api-contract-baseline]] — versioning(`/v1`), pagination/sort, conditional request(ETag/If-Match/304/412 = D15), HTTP cache(`no-store`/`Vary`), OpenAPI producer, LRO, batch endpoint. Ground-truth @b15dcf5 로 대조해 `locally-verified` 확정.
- [[raw/branch-notes/feature-api-compatibility-deprecation-contract]] — 90d/30d migration window, 7행 breaking change catalog, Sunset + Deprecation 헤더 병기, OpenAPI `deprecated: true` marker (documented-only)
- [[raw/blog-topics/api-deprecation-sunset-header-migration-window-2026-07-02]] — compatibility/deprecation 블로그 글감 raw seed. canonical 반영 범위: documented-only project decision + source-backed/header-role 경계 + 과장 금지 항목.
- [[raw/branch-notes/feature-schema-serialization-contract]] — ISO-8601 offset/UTC, BigDecimal scale 2 + HALF_UP, unknown field strict inbound, null/empty/missing 분리. 직렬화 출력측(datetime/BigDecimal 핀 + `no_bigdecimal_double_constructor` ArchUnit + `JacksonSerializationPolicyTest`)은 Ground-truth @5d89766 로 대조해 `locally-verified`; D5/D6/D7 + per-API money 코드 시연은 미구현.
- [[raw/blog-topics/spring-boot-serialization-contract-pins-2026-07-02]] — Spring Boot serialization pin 블로그 글감 raw seed. canonical 반영 범위: output serialization pin + effective ObjectMapper test + BigDecimal constructor guard.
- [[raw/official-docs/sunset-deprecation-headers-paired-usage]] — IETF RFC 8594 + Deprecation draft paired 사용 권고 (Sunset 단독 금지)
- [[raw/official-docs/rfc9110-http-semantics]] — D15 conditional request(ETag/If-Match/If-None-Match/304/412), D8/D9/D12 transport error 의미론
- [[raw/official-docs/rfc9111-http-caching]] — D16 `no-store`/`private`/`max-age` directive 정의
- [[raw/official-docs/openapi-spec-3-1-0]] — D10 OpenAPI = machine-readable contract
- [[raw/official-docs/google-aip-185-resource-versioning]] — D2 major-only `/v1` path versioning
- [[raw/official-docs/spring-data-pageable-defaults]] — D18/D20 Pageable zero-indexed + size default + `DEFAULT_MAX_PAGE_SIZE` 2000
- [[raw/official-docs/schema-jackson-unknown-field-handling]] — Jackson DeserializationFeature default (직렬화/역직렬화 정책 근거)
- [[raw/official-docs/schema-bigdecimal-money-serialization-java]] — Java BigDecimal scale/HALF_UP + `new BigDecimal(double)` 함정 (D3 / SBMS-C1~C4)
- [[raw/official-docs/rfc3339-datetime-utc]] — IETF RFC 3339 datetime UTC + "Z" suffix (D2 datetime 직렬화 normative 근거)
## Cluster / 묶음
<!-- GENERATED: derived-blogs:start -->
- [[wiki/blog/ca-tmpl-api-evolution-and-schema-2026-07-02]]
<!-- GENERATED: derived-blogs:end -->
@@ -0,0 +1,163 @@
---
title: ca-tmpl - 입력 경계 검증 & DTO↔도메인 매핑 계약 (Bean Validation · Patch · ACL · 정적 강제)
source_type: project
status: verified
confidence: high
tags: [ca-tmpl, validation, mapper, boundary, dto, archunit, actually-implemented]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
---
# ca-tmpl - 입력 경계 검증 & DTO↔도메인 매핑 계약
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념은 [[wiki/concepts/boundary-validation-and-dto-mapping]] 참조.
## 프로젝트 컨텍스트
- **프로젝트**: ca-tmpl — Clean Architecture 기반 백엔드 skeleton 템플릿.
- **목표**: request → application → response 의 입력/출력 경계에서 (1) 무엇을 검증하고 (2) 어떤 mapper 를 통과해야 하는지 고정하고, 핵심 정책을 ArchUnit fitness function 으로 **정적 강제**한다. DTO·domain·persistence 모델이 서로 새어 나가는 것을 막는 것이 핵심.
- **이유**: 경계가 흐려지면 도메인/엔티티가 응답에 silent 직렬화되거나, request DTO 가 service layer 까지 leak 되거나, PATCH 가 기존 값을 silent overwrite 하는 회귀가 코드 리뷰만으로는 반복적으로 새어 나간다. 컨벤션을 *코드*(ArchUnit + wire-level 테스트)로 묶어 다음 작업자가 무심코 깨면 build 가 빨갛게 떨어지도록 했다.
- **진행 단계**: **Phase C2 (코드) 구현 + 로컬 검증 완료.** `feature-boundary-validation-mapping-contract` 브랜치에서 ArchUnit rule, exception handler, envelope advice, mapper, sample 도메인(WorkLog) 까지 작성되어 코드 베이스에 존재한다. 운영 배포 / 실 DB 통합 테스트 / 측정값은 없다.
## Ground-truth 대조 (2026-06-04, ca-tmpl @fccb033 "경계 검증 계약 추가 및 sample 모듈 교체")
`/home/donghyeon/workspace/ca-tmpl` 의 commit `fccb033` 코드를 직접 읽고 테스트를 재실행해 검증한 사실:
- 패키지 root 는 `dev.caskeleton.*`. 브랜치 노트의 이전 `com.example.blog` 는 stale.
- fccb033 시점에 **sample 모듈은 이미 `sample-portfolio`(WorkLog 도메인)** 로 교체된 상태다. 즉 "sample 모듈 교체"(sample-ticket → sample-portfolio)는 본 커밋에 포함되어 있다. 브랜치 노트가 참조한 `BoundaryDemoControllerWireTest` 는 교체 과정에서 **`WorkLogControllerWireTest` 로 re-home** 되었고, B1/B2/B3/B8 검증은 WorkLog 엔드포인트로 이전되었다.
- 브랜치 노트는 일부 ArchUnit rule(controller 반환 타입, `@Valid` cascade depth)을 `planned` 으로 표기했으나, **fccb033 에서는 8개 boundary ArchUnit rule 이 모두 실제 구현되어 있다** (아래 §실제 구현 내용). 본 문서는 ground truth 를 우선해 이들을 `actually-implemented` 로 기록한다.
- `./gradlew test verifyCleanArchitectureDependencies` (fccb033 worktree) → **BUILD SUCCESSFUL, 126 tests / 0 failures** (2026-06-04 재실행, exit 0).
- 현재 repo HEAD 는 `db61075`(sibling `feature-business-rule-validation-contract`)로 더 진행되어 `Category`/`ResponseMeta` 등이 추가됨. 본 문서는 **fccb033 기준 사실만** 기록한다.
## 실제 구현 내용 (`actually-implemented`)
ca-tmpl @fccb033 코드에서 직접 확인한 산출물:
**shared-contract (stdlib-only, `dev.caskeleton.shared.*`)**
- `request/Patch.java` — PATCH 필드의 3-state 값 객체. `absent()` / `ofNull()` / `of(value)` + `isAbsent()` / `isExplicitNull()` / `hasValue()`. 웹 어댑터가 Jackson-aware `JsonNullable<T>` 를 이 Jackson-free 타입으로 변환해 application-core 가 wire 표현을 보지 않도록 함 (B2).
- `error/MappingException.java` — 모든 경계 mapper(request→command, response shaping, outbound ACL)가 "구조는 멀쩡하나 의미상 매핑 불가" 일 때 던지는 sentinel `RuntimeException`. shared.error 에 두어 어느 모듈이든 cross-adapter 의존 없이 던질 수 있게 함 (B3 + B7). *(주의: 브랜치 노트 errors 로그는 `application.exception` 으로 이전했다고 기록하나, fccb033 ground truth 에서는 `shared.error` 에 위치 — 이후 모듈 승격/재배치의 결과.)*
- `error/OperationalError.java` (enum) + `error/ApiErrorCode.java` (인터페이스, `code()`/`httpStatus()` int/`retryable()`) — `VALIDATION_FAILED(400,false)`, `MAPPING_FAILED(400,false)`, `BATCH_PARTIAL_FAILURE(200,false)`, `BAD_PARAMETER(400)`, `INTERNAL_ERROR(500,true)` 등. 전송 중립을 위해 Spring `HttpStatus` 대신 plain int.
- `response/Envelope.java` / `response/BulkEnvelope.java` / `response/ApiError.java` — skeleton-wide 응답 봉투 타입.
**adapter-web (`dev.caskeleton.adapter.web.*`)**
- `error/GlobalExceptionHandler.java` (`@RestControllerAdvice extends ResponseEntityExceptionHandler`) — `ProblemDetail` import 0 (D5: RFC 7807 거부). `MappingException``MAPPING_FAILED`, `ConstraintViolationException``VALIDATION_FAILED`(field/message 리스트), `handleMethodArgumentNotValid` override→`VALIDATION_FAILED`(field/rejectedValue/message), `handleHttpMessageNotReadable` override→`VALIDATION_FAILED`(`{cause: <Jackson exception simpleName>}`), method-not-allowed/media-type/route-not-found override, catch-all→`INTERNAL_ERROR`. 모두 `ErrorResponseFactory` 단일 지점으로 envelope 빌드.
- `envelope/EnvelopeBodyAdvice.java` (`ResponseBodyAdvice`) — 모든 JSON 컨트롤러 응답을 `Envelope.ok(body, traceId)` 로 자동 wrap. 이미 `Envelope`/`BulkEnvelope` 면 pass-through, null/void(DELETE 204)·비-JSON skip (D5/D6).
**sample-portfolio (WorkLog 도메인 — 계약 실증)**
- `adapter/web/dto/request/CreateWorkLogRequest.java``@GroupSequence({Syntax.class, Invariant.class, CreateWorkLogRequest.class})` + `@NotBlank/@Size/@NotNull(groups=Syntax.class)` + `@AssertTrue(groups=Invariant.class)` periodEnd≥periodStart. syntax→invariant short-circuit 실증 (B4).
- `adapter/web/dto/request/UpdateWorkLogRequest.java` — 필드를 `JsonNullable<T>` 로 받아 `Patch<T>` 로 변환(`titlePatch()` 등). PATCH 3-state (B2).
- `adapter/web/dto/request/SamplePolymorphicRequest.java``sealed interface` + record subtypes(`Text`/`Image`) + `@JsonTypeInfo(use=NAME, property="kind")` + `@JsonSubTypes` allowlist. allowlist 외 discriminator → `InvalidTypeIdException` (B5).
- `adapter/web/mapper/WorkLogWebMapper.java` — 수기 mapper. 잘못된 link URI 면 `MappingException` wrap (B3). domain→response DTO 변환.
- `adapter/outbound/repostats/RepoStatsAclMapper.java` (+ package-private `RawRepoStatsResponse`) — B7 ACL: normalization(lower-case)/masking(echoedToken drop)/public-field selection 후 domain 타입만 반환. raw 누락 시 `MappingException`.
- `adapter/persistence/mapper/WorkLogPersistenceMapper.java` — 영속 매퍼.
**app-bootstrap — ArchUnit fitness functions** (`architecture/CleanArchitectureTest.java`) — boundary rule **8개 모두 실 ArchRule (allowEmptyShould)**:
- `request_dtos_do_not_silence_unknown_fields``..adapter.web..dto..` 의 class-level `@JsonIgnoreProperties(ignoreUnknown=true)` 금지 (B1).
- `no_jackson_laissez_faire_subtype_validator` + `no_jackson_enable_default_typing_call` — CVE-2019-14379 RCE 벡터 차단 (B5).
- `no_inheritable_thread_local` — virtual thread 누설 방지 (B6).
- `controllers_do_not_return_domain_or_entity_types` — controller public 메서드가 `..domain.entity..`/`..persistence.entity..`/`..repository..` 반환 금지 (§Forbidden). *브랜치 노트는 `planned` 였으나 fccb033 에 구현됨.*
- `application_methods_do_not_accept_web_dtos` — application public 메서드가 `..adapter.web..dto..` 파라미터 수용 금지 (§Forbidden). *동일하게 fccb033 에 구현됨.*
- `no_problem_detail_usage``org.springframework.http.ProblemDetail` import 차단 (D5).
- `no_merge_patch_json_media_type_string` — custom ArchCondition 으로 `application/merge-patch+json` 어노테이션 참조 차단 (B2).
- `valid_cascade_depth_at_most_three` — custom ArchCondition 으로 `@Valid` cascade depth ≤ 3 (B4 DoS 방어). *브랜치 노트는 `planned` 였으나 fccb033 에 구현됨.*
- `outbound_adapter_method_returns_only_domain_or_primitives` — outbound public 메서드가 raw external 응답 타입 escape 금지 (B7 ACL).
- 각 rule 은 `ArchitectureViolationFixtureTest` 의 의도된 위반 fixture(`JsonIgnoreUnknownRequestFixture`, `DefaultTypingFixture`, `InheritableThreadLocalFixture`, `DomainReturningControllerFixture`, `WebDtoAcceptingApplicationFixture`, `ProblemDetailUsingFixture`, `MergePatchJsonFixture`, `DeepCascadeRequestFixture`)로 catch 동작을 보증 (violations-as-data).
## 로컬/dev 검증 (`locally-verified`)
- **wire-level 테스트** `WorkLogControllerWireTest` (`@WebMvcTest`/`@TestPropertySource`) 9 케이스: envelope wrap, 404, blank-title validation, unknown-field 거부(B1), unmappable-link→`MAPPING_FAILED`(B3), PATCH present-only 교체 + explicit-null 수용(B2), repo-stats domain via envelope, bulk partial → `BATCH_PARTIAL_FAILURE`(B8).
- **unit/contract 테스트**: `SamplePolymorphicRequestTest`(B5 sealed type 4 케이스), `BasicPolymorphicTypeValidatorAllowlistTest`(B5 allowlist 4 케이스), `BulkEnvelopeTest`(3 케이스), `GlobalExceptionHandlerTest`, `EnvelopeBodyAdviceTest`, `RepoStatsAclMapperTest`(B7), `WorkLogPersistenceMapperTest`, `DomainExceptionHandlerTest`, `OperationalErrorTest`.
- **virtual-thread MDC**: `VirtualThreadMdcPropagationTest`(unit) + `VirtualThreadMdcE2ETest`(`@SpringBootTest(RANDOM_PORT)` 실 Tomcat + 실 가상스레드 + 실 `RequestLoggingFilter` + `TestRestTemplate`) — server-generated `requestId` 와 client `X-Request-Id` 두 경로가 컨트롤러까지 도달함을 wire-level pin (B6).
- **ArchUnit + 위반 fixture**: `CleanArchitectureTest` + `ArchitectureViolationFixtureTest` 전체 green.
- **전체 빌드**: `./gradlew test verifyCleanArchitectureDependencies` (fccb033) → **126 tests / 0 failures**, 2026-06-04 재실행 exit 0.
- 검증 범위는 JVM 단위/슬라이스/슬라이스-wire/e2e(in-process Tomcat) + 정적 분석까지. **실 DB(Testcontainers) 통합 테스트는 없음** (persistence 매퍼는 unit 레벨).
## 운영 검증 (`prod-verified`)
**없음.** 운영 환경에 배포된 적이 없다. 트래픽·측정값·인시던트·릴리즈 노트 어느 것도 없다. 본 패스는 enforcement + reference + unit/contract + wire-level + e2e(in-process) 단계까지다.
## 문서/계획만 존재 (`documented-only` / `planned`)
다음은 설계/문서/위임 상태이며 **면접에서 "구현했다 / 검증했다"고 말하면 안 된다**.
- **B7-2 실 `WebClient`/`RestClient` + WireMock 통합**: `planned`. 현 패스의 outbound 는 HTTP fetch 를 추상화한 형태이고 실 외부 HTTP 왕복은 미검증.
- **B8-2 OpenAPI response shape 분기(`oneOf`) 명시**: `planned`. OpenAPI 스펙 자체가 부재해 구현 보류.
- **B2 RFC 7396 미채택 사실의 OpenAPI 문서화**: `planned` (OpenAPI 부재).
- **request DTO primitive→wrapper 강제 ArchUnit rule** (B1 component-type): `planned`. Jackson 4-종 스위치 자체는 설정/테스트로 확인되나 component-type ArchUnit rule 은 미작성.
- **실 DB 통합(@DataJpaTest / Testcontainers)**, `@Version` 낙관적 락: `planned` (후속 브랜치).
- **MapStruct generated mapper exemption rule**: `documented-only` / `needs-confirmation`. 현 구현은 수기 mapper 만 사용하며 MapStruct 는 optional 계약으로만 존재.
## 면접에서 말할 수 있는 범위
### 자신 있게 답할 수 있는 질문
- 입력 경계에서 검증/매핑 책임을 어떻게 분리했는가 — Bean Validation `@GroupSequence` 로 syntax→invariant short-circuit, request DTO→command 수기 mapper, response 는 DTO 만 노출.
- `MethodArgumentNotValidException` / `HttpMessageNotReadableException` 을 왜 `VALIDATION_FAILED` 로, mapper 내부 실패를 왜 `MappingException``MAPPING_FAILED` 별도 카테고리로 분류했는가 (Spring 이 전자는 자동 처리, 후자는 안 하므로).
- PATCH 의 absent/explicit-null/value 3-state 를 `JsonNullable<T>``Patch<T>` 로 어떻게 구분했고, 구분 안 하면 어떤 silent overwrite 버그가 나는가.
- CVE-2019-14379 (Jackson default typing gadget chain RCE) 를 ArchUnit 으로 `enableDefaultTyping()` 호출과 `LaissezFaireSubTypeValidator` 참조를 정적 차단하고, 안전한 `@JsonTypeInfo`+`@JsonSubTypes` / `BasicPolymorphicTypeValidator` allowlist 만 허용한 방법.
- RFC 7807 ProblemDetail 을 왜 거부하고 custom envelope 를 썼는가, 그 결정을 `no_problem_detail_usage` ArchUnit 으로 회귀 차단한 방법.
- B7 outbound ACL — 외부 응답 raw 타입이 domain 으로 leak 되지 않도록 mapper + ArchUnit(`outbound_adapter_method_returns_only_domain_or_primitives`)으로 강제한 방법.
- violations-as-data — 각 ArchUnit rule 이 의도된 위반 fixture 를 실제로 잡는지 네거티브 테스트로 보증한 패턴.
### 적당히 답할 수 있는 질문
- virtual thread(`spring.threads.virtual.enabled`) 환경에서 `InheritableThreadLocal` 이 왜 위험하고 MDC/`RequestContextHolder` 로 어떻게 context 를 전파하는가 (단 실 프로덕션 트래픽 검증은 안 함).
- MapStruct vs 수기 mapper 의 trade-off (현 구현은 수기 mapper 채택, MapStruct 는 미사용).
### 답하면 안 되는 질문 (모른다고 해야 함)
- "운영에서 이 검증/매핑 계약이 인시던트를 막은 사례가 있는가? 성능을 측정했는가?" → **운영 배포 없음, 측정 없음.**
- "outbound ACL 을 실 외부 API + WireMock 으로 통합 검증했는가?" → **안 함. HTTP fetch 추상화 단계.**
- "PATCH/검증을 실 DB 통합 테스트로 끝까지 돌렸는가?" → **persistence 는 unit 레벨. Testcontainers 통합 없음.**
- "OpenAPI 로 bulk/단일 응답 shape 분기를 명시했는가?" → **OpenAPI 스펙 부재. `planned`.**
## 과장 금지 지점
- **"운영에서 검증했다 / prod 에서 돌고 있다" → 금지.** 로컬 단위/슬라이스/e2e(in-process Tomcat) + 정적 분석까지가 검증 범위.
- **"실 DB 통합 테스트로 PATCH/매핑을 검증했다" → 금지.** persistence 매퍼는 unit 레벨, Testcontainers 없음.
- **"4-layer validation(syntax/policy/invariant/persistence integrity)은 표준 분류다" → 금지.** Bean Validation spec 은 이 taxonomy 를 정의하지 않는다. ca-tmpl 내부 설계 결정이다([[wiki/concepts/boundary-validation-and-dto-mapping]] 참조).
- **"controller 반환 타입/cascade depth ArchUnit 은 계획만 했다" → (옛 브랜치 노트 표현) 정정.** fccb033 ground truth 에서는 둘 다 구현되어 있다.
- **"ArchUnit 으로 막았으니 RCE/leak 이 원천 불가능하다" → 단정 금지.** 정적 분석은 바이트코드에서 탐지 가능한 carrier(어노테이션/import/호출)만 잡는다. 메서드 본문 내 free-form 문자열 등은 한계가 있다(코드 주석에 명시됨).
- **"`MappingException` 위치가 `application.exception` 이다" → fccb033 기준 정정.** ground truth 에서는 `shared.error` 에 있다.
### Blog-topic ingest: boundary-validation-mapper-responsibility-map (2026-07-02)
[[raw/blog-topics/boundary-validation-mapper-responsibility-map-2026-07-02]] 는 입력 syntax, application policy, domain invariant, persistence integrity, mapper normalization 책임을 한 계층에 몰지 않는 경계 설계를 블로그로 풀기 위한 raw seed다.
- **locally-verified 로 말할 수 있는 부분**: `Patch<T>` 3-state, `MappingException`, DTO/mapper boundary ArchUnit rule, validation/mapping wire·unit test 범위.
- **project-local policy 로 말할 부분**: syntax/policy/invariant/persistence integrity/normalization 책임 분리는 ca-tmpl 내부 taxonomy다.
- **블로그 전 과장 방지**: 모든 validation 책임을 해결하는 보편 구조처럼 쓰지 않고, fccb033 기준 구현·검증 범위와 미구현 OpenAPI/DB integration 범위를 분리한다.
### Blog-topic ingest: archunit-jackson-default-typing-cve block (2026-07-02)
[[raw/blog-topics/archunit-jackson-default-typing-cve-2019-14379-block-2026-05-29]] 는 Jackson default typing RCE 진입점(`enableDefaultTyping`, `LaissezFaireSubTypeValidator`)을 ArchUnit fitness function으로 차단한 글감이다.
- **locally-verified 로 말할 수 있는 부분**: `no_jackson_laissez_faire_subtype_validator`, `no_jackson_enable_default_typing_call`, `DefaultTypingFixture`가 boundary canonical에 이미 구현/검증 범위로 기록돼 있다.
- **블로그 전 과장 방지**: CVE 전체를 제거했다고 쓰지 않고, ca-tmpl 코드에서 특정 위험 API 호출/참조를 정적 rule로 차단한 범위로 제한한다.
## 관련 개념
- [[wiki/concepts/boundary-validation-and-dto-mapping]]
- [[wiki/concepts/transaction-boundary-abstraction]]
## Sources
- [[raw/branch-notes/feature-boundary-validation-mapping-contract]] — 결정(D1~D15)·Decision Evidence Map·Claims To Verify·구현 결과(5/6차 패스)·wiki 추출 대상
- [[raw/blog-topics/boundary-validation-mapper-responsibility-map-2026-07-02]] — boundary validation/mapper 책임 분리 블로그 글감 raw seed. canonical 반영 범위: verified boundary/mapping 구현 + project-local 책임 taxonomy + 과장 금지 항목.
- [[raw/blog-topics/archunit-jackson-default-typing-cve-2019-14379-block-2026-05-29]] — Jackson default typing CVE static block 블로그 글감 raw seed.
- [[raw/project-notes/ca-skeleton-operational-contract]] — §6 Operational Error Category(`VALIDATION_FAILED`/`MAPPING_FAILED`/`BATCH_PARTIAL_FAILURE` 등록), §20 Skeleton Blueprint package convention
- [[raw/official-docs/validation-jakarta-bean-validation-3.0-spec]] — `@GroupSequence` short-circuit, `@Valid` cascade
- [[raw/official-docs/spring-mvc-rest-exception-handling]] — `HttpMessageNotReadableException`/`MethodArgumentNotValidException` → VALIDATION 분류
- [[raw/official-docs/patch-json-merge-rfc7396]] — PATCH null=deletion (미채택 근거)
- [[raw/official-docs/schema-jackson-polymorphic-deserialization]] — CVE-2019-14379 + allowlist API (B5)
- ca-tmpl @fccb033 코드 (ground-truth): `src/shared-contract/.../request/Patch.java` · `.../error/{MappingException,OperationalError,ApiErrorCode}.java`, `src/adapter-web/.../error/GlobalExceptionHandler.java` · `.../envelope/EnvelopeBodyAdvice.java`, `src/sample-portfolio/.../adapter/web/{dto/request,mapper}/*.java` · `.../adapter/outbound/repostats/RepoStatsAclMapper.java`, `src/app-bootstrap/.../architecture/{CleanArchitectureTest,ArchitectureViolationFixtureTest}.java` · `.../controller/WorkLogControllerWireTest.java`
## Cluster / 묶음
<!-- GENERATED: derived-blogs:start -->
- [[wiki/blog/ca-tmpl-boundary-validation-mapping-2026-07-02]]
<!-- GENERATED: derived-blogs:end -->
@@ -0,0 +1,207 @@
---
title: ca-tmpl - Clean Architecture 패키지 레이아웃 결정
source_type: project
status: verified
confidence: high
tags: [ca-skeleton, clean-architecture, package-layout, locally-verified, interview-candidate]
related_projects: [ca-skeleton, ca-tmpl]
last_reviewed: 2026-07-02
---
# ca-tmpl - Clean Architecture 패키지 레이아웃 결정
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념은 `wiki/concepts/clean-architecture-package-layout` 사용.
## 프로젝트 컨텍스트
ca-tmpl은 Java 21 + Spring Boot 3.4 + Gradle multi-module 기반 Clean Architecture skeleton template이다. `blog` 도메인은 reference implementation이며, 새 프로젝트에서는 도메인 이름과 엔티티를 교체하되 module boundary와 dependency direction은 유지한다.
본 문서는 `feature-skeleton-package-blueprint-contract` branch-note의 package/module blueprint가 ca-tmpl repo에 실제 반영된 상태를 기록한다. 이 slice는 `actually-implemented` + `locally-verified`이며, 운영 배포 대상이 아니므로 `prod-verified`는 없다. 2026-06-04 ca-tmpl 레포(`@5d89766`) ground-truth 대조로 아래 사실을 검증함 (§Ground-truth 대조 참조).
## 실제 구현 내용 (`actually-implemented`)
- Gradle include가 `app-bootstrap`, `domain-core`, `application-core`, `adapter-web`, `adapter-persistence`, `adapter-outbound`, `shared-contract`, `sample-portfolio` 8개 module로 전환되었다. (이후 `feature-resource-identifier-contract` branch가 9번째 module `adapter-identifier`를 추가했으나 이는 본 slice 범위 밖이다.)
- production package root는 `dev.caskeleton`이다. 기존 reference blog code는 다음 mapping으로 이동했다.
- `cmd``app-bootstrap` / `dev.caskeleton.bootstrap` (`BlogApplication``CaSkeletonApplication`)
- `domain``domain-core` / `dev.caskeleton.domain`
- `service``application-core` / `dev.caskeleton.application`
- `presentation``adapter-web` / `dev.caskeleton.adapter.web`
- `infra``adapter-persistence` / `dev.caskeleton.adapter.persistence`
- `blog.*` 설정 prefix → `ca-skeleton.*`, `CmdSettings``BootstrapSettings`
- 기존 reference code는 production module에서 격리되어 `sample-portfolio` 내부 `dev.caskeleton.sample.portfolio.{domain,application}.worklog` package로 이동했다.
- `domain-core`, `application-core`, `adapter-persistence`, `adapter-outbound`, `shared-contract`는 skeleton anchor package + `package-info.java` 중심으로 유지된다. 각 module 내부의 실제 business/contract type 구현은 후속 branch slice들(`feature-operational-error-observability-foundation`, `feature-api-contract-baseline` 등)이 채운다.
- `src/build.gradle``verifyCleanArchitectureDependencies` task(root `build.gradle:53`)가 module dependency matrix를 검사한다.
- `app-bootstrap``CleanArchitectureTest`(ArchUnit)가 domain purity, application adapter isolation, adapter 간 직접 의존 금지, web DTO containment, shared-contract package scope, production → `sample-portfolio` dependency 금지를 검사한다.
- **(D9) module 간 의존 선언 정책**: 기본 `implementation`, 소비자의 public ABI에 타 module 타입이 노출될 때만 `api`. ground-truth 확인: 9개 `build.gradle` 모두 `api` 선언 0개, 전부 `implementation` — 정책 충족. 근거 `raw/official-docs/gradle-java-library-api-vs-implementation.md`.
- **(D10) `@SpringBootApplication` 배치**: `dev.caskeleton.bootstrap`(root package)에 두고 default package 금지. multi-module component scan을 위해 `@SpringBootApplication(scanBasePackages = "dev.caskeleton")` 명시. ground-truth 확인: `app-bootstrap/.../bootstrap/CaSkeletonApplication.java`에 일치. 근거 `raw/official-docs/spring-boot-structuring-your-code.md`.
- `README.md`, `AGENTS.md`, root/module `CLAUDE.md`, local clean-architecture rule이 새 module vocabulary로 갱신되었다.
### Boundary enforcement rules (`feature-architecture-enforcement-rules` slice)
> 이 sub-section은 module/package *blueprint* 위에 얹는 **enforcement-rules dimension**이다. 위 blueprint가 "module 경계가 어디 있는가"라면, 아래는 "그 경계가 깨지면 build가 실패하는가"를 다룬다. ca-tmpl `@db61075` ground-truth 대조로 아래 rule 이름·개수·위치를 확인했다(§Ground-truth 대조 — enforcement 참조). ⚠️ ground-truth 파일은 이후 다른 branch slice들이 rule을 더 추가했으므로, 아래는 **본 enforcement-rules slice가 정의·구현한 항목만** 추렸다(타 slice rule은 해당 branch ingest에서 다룬다).
ArchUnit test(`app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java`, `@AnalyzeClasses(packages = "dev.caskeleton", importOptions = DoNotIncludeTests.class)`)가 정적 import/dependency graph를 검사한다. 본 slice가 정의한 rule:
- `domain_is_pure``..domain..``org.springframework..` / `jakarta.persistence..` / `javax.persistence..` / `jakarta.servlet..` / `org.hibernate..` / **`lombok..`**(D3) / `..application..` / `..adapter..` / `..bootstrap..` 등에 의존하면 실패. domain을 framework-neutral POJO로 유지. (`actually-implemented`)
- `application_does_not_depend_on_adapters_or_transport``..application..``..adapter..` / `..bootstrap..` / `org.springframework.web..` / persistence·hibernate에 의존하면 실패. (`actually-implemented`)
- `application_does_not_use_spring_transactional_annotation``..application..``org.springframework.transaction.annotation.Transactional` FQN에 의존하면 실패. (코드 주석상 attribution은 `feature-application-port-usecase-contract D3`이나, 본 enforcement slice의 테스트 계약에도 포함되어 `locally-verified`로 red/green 확인됨.)
- `application_does_not_depend_on_application_context` (**D11**, banned-class rule) — `..application..``org.springframework.context.ApplicationContext` FQN에 의존하면 실패. class-literal 기반 `getBean(Class<T>)` 호출까지는 bytecode access로 catch. (`actually-implemented`) — **한계(D12)**: string-key `getBean(String)`·`Class.forName(String)`·`BeanFactory#getBeansOfType` 같은 reflection-style bypass는 ArchUnit 정적 분석으로 catch 불가. `application-core/CLAUDE.md` forbidden 섹션의 code review checklist로만 보완. ArchUnit이 모든 우회를 잡는다고 말하면 과장.
- `web_adapter_does_not_depend_on_persistence_or_outbound_adapters` / `persistence_adapter_does_not_depend_on_web_or_outbound_adapters` / `outbound_adapter_does_not_depend_on_web_or_persistence_adapters` — adapter module 간 직접 의존 금지. (`actually-implemented`)
- `web_dtos_stay_in_web_adapter``..adapter.web..dto..``..adapter.web..`에서만 접근 가능(DTO containment). (`actually-implemented`)
- `shared_contract_contains_only_operational_contract_packages``..shared..`는 response/request/error/operation/headers/logging/tracing/metrics/registry/annotation operational-contract package allowlist만 허용; business/domain concept 유입 시 실패. (`locally-verified` — 임시 `shared.worklog` 위반으로 red 확인)
- `production_code_does_not_depend_on_sample_portfolio``..sample.portfolio..` 밖 production code가 sample package에 의존하면 실패. (`locally-verified`)
Gradle build-graph 검사는 `verifyCleanArchitectureDependencies` task(`src/build.gradle:53`, root)가 담당한다. `allowedProjectDependencies` matrix로 9개 module의 허용된 `project()` dependency(`api`/`implementation`/`compileOnly`/`runtimeOnly`)를 화이트리스트하고, 허용 외 `ProjectDependency`가 선언되면 `GradleException`을 던진다. ArchUnit이 *source import graph*를, 이 task가 *Gradle project dependency graph*를 막는 이중 방어다. (`actually-implemented` — task 존재 + matrix; `locally-verified` — 임시 `app-bootstrap → sample-portfolio` 선언으로 red 확인)
`allowEmptyShould(true)`: 대부분 rule이 빈 anchor module(아직 구현 type이 없는 module)에서 vacuous하게 통과하지 않도록 명시. 빈 should가 곧 PASS로 둔갑하는 ArchUnit empty-should anchor 문제를 다루기 위함.
### Negative fixture (violations-as-data)
`ArchitectureViolationFixtureTest`(같은 `architecture/` 패키지)가 본 slice의 각 rule이 *실제로* 위반을 catch하는지 commit된 negative test로 보증한다(Spring Modulith `example/ninvalid` 패턴 차용). 본 slice가 추가한 fixture·test(round 2, 2026-05-28): `SpringDependentDomainFixture`(domain_is_pure D3), `ApplicationContextDependentFixture`(D11), `TransactionalAnnotatedFixture`(@Transactional)를 포함한 의도된 위반 class와, 대응 `*_catches_violation` test가 `rule.evaluate(VIOLATION_CLASSES).hasViolation() == true`를 assert한다. fixture는 `src/test/...`에 위치하므로 main `@AnalyzeClasses(importOptions = DoNotIncludeTests.class)` 분석에서 제외 → main suite의 vacuous pass 위험 없음. (`actually-implemented`)
> 이후 branch slice들이 같은 fixture tree에 boundary-validation·streaming·serialization·resource-identifier·api-contract rule용 fixture를 추가해, 현재 ground-truth `ArchitectureViolationFixtureTest`는 본 slice 범위를 넘는 negative test를 다수 포함한다. 본 doc은 enforcement-rules slice가 만든 fixture만 위에 명시했다.
## 로컬/dev 검증 (`locally-verified`)
2026-05-27 ca-tmpl repo에서 다음 명령이 통과했다.
```bash
cd src
./gradlew verifyCleanArchitectureDependencies
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest'
./gradlew :adapter-web:test --tests '*SettingsTest'
./gradlew test
```
검증 의미:
- Gradle project dependency graph가 branch-note의 module dependency direction을 위반하지 않는다.
- ArchUnit이 source-level forbidden dependency를 검사한다.
- web settings binding tests가 package rename 이후에도 통과한다.
- 전체 Gradle test suite가 새 module layout에서 통과한다.
enforcement-rules slice 추가 red/green 검증(2026-05-28, `feature-architecture-enforcement-rules`):
```bash
cd src
./gradlew :app-bootstrap:test verifyCleanArchitectureDependencies # ArchUnit + Gradle graph
./gradlew check # 전체 — round 2 fixture 포함
```
- 임시 위반 코드(`application @Transactional`, controller domain return, mapper → application 의존, `shared.worklog` package)를 추가했을 때 `CleanArchitectureTest`가 실패함을 확인한 뒤 임시 파일을 제거했다.
- 임시 `app-bootstrap → sample-portfolio` project dependency 선언 시 `verifyCleanArchitectureDependencies`가 실패함을 확인한 뒤 제거했다.
- round 2: `domain_is_pure``lombok..` 추가(D3)와 `application_does_not_depend_on_application_context`(D11)가 commit된 negative fixture(`ArchitectureViolationFixtureTest`)로 catch 동작을 보증함을 확인했다.
## 운영 검증 (`prod-verified`)
없음. ca-tmpl은 template repository이며, 이번 package blueprint slice는 운영 배포/운영 로그/운영 metric으로 검증된 항목이 아니다.
## 문서/계획만 존재 (`documented-only` / `planned`)
> 이 등급은 **본 blueprint slice 시점(2026-05-27)** 기준이다. 일부 항목은 이후 별도 branch slice가 구현했을 수 있으며, 그 검증은 해당 branch의 ingest에서 갱신한다(본 slice는 module/package *경계*만 검증).
- `sample-portfolio` 실제 worklog domain fixture business flow는 본 slice 시점엔 anchor 중심이었다. (현재 `dev.caskeleton.sample.portfolio.{domain,application}.worklog`에 worklog 모델/테스트 존재 — 별도 sample fixture branch 산출물.)
- `adapter-outbound` 실제 HTTP client/messaging/cache/notification adapter는 본 slice 시점에 미구현(anchor만).
- `shared-contract` 실제 response/error/header/logging/tracing/metrics/registry/annotation type은 본 slice 시점에 미구현(anchor만). 이후 `feature-operational-error-observability-foundation`·`feature-api-contract-baseline` slice가 일부 채움.
- `application/port/in``application/port/out` package anchor는 존재하지만, reference blog repository port는 본 slice 시점엔 `sample-portfolio/domain/repository`에 남아 있었다. production use case port 정리는 `feature-application-port-usecase-contract` branch에서 수행한다.
- Spring Modulith verifier는 도입하지 않았다. 현재 검증은 Gradle dependency rule + ArchUnit rule이다.
## 면접에서 말할 수 있는 범위
- **자신 있게 답할 수 있는 질문**
- 왜 Gradle multi-module을 1차 boundary로 두고 `domain-core` / `application-core` / `adapter-*`를 물리 분리했는지.
- `shared-contract`를 business common dumping ground로 쓰지 않기 위해 어떤 package와 ArchUnit rule을 두었는지.
- `sample-portfolio`이 presentation layer가 아니라 fixture/sample consumer module인 이유.
- `verifyCleanArchitectureDependencies`와 ArchUnit test가 각각 build graph와 source import graph에서 무엇을 막는지.
- **적당히 답할 수 있는 질문**
- 왜 Spring Modulith를 즉시 도입하지 않았는지.
- reference blog port가 아직 `domain/repository`에 남아 있는 이유와 `feature-application-port-usecase-contract` branch에서 `application/port/out`으로 이동할 계획.
- **답하면 안 되는 질문**
- “운영에서 검증했다”는 표현. 운영 배포/운영 metric 근거가 없다.
- “sample-portfolio worklog business flow까지 이 blueprint slice에서 구현했다”는 표현. 본 slice는 module/package 경계만 검증했고, fixture 구현은 별도 slice다.
- “ArchUnit이 모든 boundary 우회를 잡는다”는 표현. runtime lookup/reflection 우회는 별도 리뷰와 CI 보완이 필요하다.
## 과장 금지 지점
- “ca-tmpl 전체 Phase C2가 완료됐다” → 금지. package/module blueprint slice만 local verification 완료.
- “모든 operational contract가 구현됐다” → 금지. registry/generated constants, outbox, security, runtime, privacy 등은 별도 slice다.
- “Spring Modulith 수준 named interface 검증을 구현했다” → 금지. 현재는 Gradle + ArchUnit 최소 검증이다.
- “prod-verified” → 금지. 운영 환경 검증 없음.
-`application_does_not_depend_on_application_context`(D11) rule이 모든 Spring container 우회를 잡는다” → 금지. class-literal `getBean(Class)`까지만 catch하고, string-key `getBean(String)` / `Class.forName(String)` / `BeanFactory#getBeansOfType` reflection-style bypass는 ArchUnit 정적 분석 범위 밖이다(D12). 이 부분은 code review checklist로만 보완하며 자동 강제 장치가 아니다.
- “ArchUnit/Gradle이 enforcement-rules의 모든 항목을 자동 검증한다” → 금지. MapStruct generated mapper exemption(D9)은 `needs-confirmation`, runtime lookup false-pass 확인은 `planned`로 남아 있다.
## Ground-truth 대조 (2026-06-04, ca-tmpl `@5d89766`)
실제 레포 대조로 검증한 사실 (`locally-verified`):
| 검증 항목 | ca-tmpl 증거 |
|---|---|
| 8 module include | `settings.gradle` 일치 (+ `adapter-identifier`는 별도 branch) |
| production root `dev.caskeleton` | `CaSkeletonApplication` @ `dev.caskeleton.bootstrap` |
| D9 전 module `implementation` | 9개 `build.gradle` 모두 `api` 0개 |
| D10 `scanBasePackages="dev.caskeleton"` | `CaSkeletonApplication.java` |
| boundary guardrail | root `build.gradle:53` `verifyCleanArchitectureDependencies` + `app-bootstrap/.../architecture/CleanArchitectureTest.java` |
| anchor `package-info.java` | domain-core·shared-contract·adapter-* 존재 |
| sample 격리 | `dev.caskeleton.sample.portfolio.*.worklog` |
**대조에서 정정된 1차 추출 오류**: 기존 문서의 `com.example.blog.*`(→ `dev.caskeleton.*`), `sample-ticket`(→ `sample-portfolio`)은 1차 추출 시점의 stale 값이었고 본 ingest에서 ground-truth로 정정함.
### Enforcement-rules dimension 대조 (2026-06-04, ca-tmpl `@db61075`)
`feature-architecture-enforcement-rules` slice가 정의한 항목만 실제 레포와 대조함 (`actually-implemented` / `locally-verified`):
| 검증 항목 | ca-tmpl 증거 |
|---|---|
| ArchUnit suite 진입점 | `app-bootstrap/.../architecture/CleanArchitectureTest.java`, `@AnalyzeClasses(packages = "dev.caskeleton", importOptions = DoNotIncludeTests.class)` |
| domain purity + Lombok ban (D3) | `domain_is_pure` rule의 forbidden package에 `lombok..` 포함 |
| application↔adapter/transport 격리 | `application_does_not_depend_on_adapters_or_transport` |
| @Transactional ban | `application_does_not_use_spring_transactional_annotation` (FQN `org.springframework.transaction.annotation.Transactional`) |
| ApplicationContext banned-class (D11) | `application_does_not_depend_on_application_context` (FQN `org.springframework.context.ApplicationContext`) |
| adapter-adapter 격리 | `web_/persistence_/outbound_adapter_does_not_depend_on_*` 3 rule |
| web DTO containment | `web_dtos_stay_in_web_adapter` |
| shared-contract scope | `shared_contract_contains_only_operational_contract_packages` (operational allowlist) |
| production → sample ban | `production_code_does_not_depend_on_sample_portfolio` |
| Gradle build-graph 검사 | `src/build.gradle:53` `verifyCleanArchitectureDependencies` + `allowedProjectDependencies` matrix(9 module) |
| negative fixture | `ArchitectureViolationFixtureTest` + `architecture/violations/...`(SpringDependentDomainFixture·ApplicationContextDependentFixture·TransactionalAnnotatedFixture 등) |
| D11 한계(string-key bypass) | rule 주석에 명시 — `getBean(Class)`까지만 catch, `getBean(String)`/`Class.forName` 범위 밖 |
> ⚠️ ground-truth `CleanArchitectureTest`는 본 slice 이후 boundary-validation / streaming / serialization / resource-identifier / api-contract slice의 rule도 다수 포함한다(현재 30+ rule). 위 표는 본 enforcement-rules slice 소유 항목만 골랐고, 나머지는 각 branch ingest에서 대조한다.
### Blog-topic ingest: clean-architecture-module-blueprint (2026-07-02)
[[raw/blog-topics/clean-architecture-module-blueprint-2026-05-28]] 는 Clean Architecture skeleton에서 Gradle module boundary를 1차 강제선으로, package 내부 책임 분류를 2차 강제선으로 둔 이유를 블로그로 풀기 위한 raw seed다.
- **locally-verified 로 말할 수 있는 부분**: module include, production root, `scanBasePackages`, Gradle dependency matrix, package anchor, sample isolation, enforcement-rules slice 검증 범위.
- **project-local policy 로 말할 부분**: Spring Modulith를 즉시 도입하지 않고 Gradle + ArchUnit 최소 검증으로 시작한 선택.
- **블로그 전 과장 방지**: 운영 검증이나 전체 Phase C2 완료처럼 쓰지 않고, module/package blueprint slice의 로컬 검증으로 제한한다.
- [[raw/blog-topics/executable-clean-architecture-onboarding-2026-06-25]]: Clean Architecture 체크리스트를 README가 아니라 test-only dry-run slice와 negative fixture로 만들어 새 도메인 추가 경계를 CI에서 반복 검증하는 글감. local verification이며 보편 표준 증명처럼 쓰지 않는다.
- [[raw/blog-topics/clean-architecture-boundary-enforcement-2026-05-28]]: Gradle project-dependency matrix와 ArchUnit bytecode rule을 나눠 Clean Architecture boundary drift를 막는 글감. runtime lookup / MapStruct exemption 같은 planned 항목은 구현 완료로 쓰지 않는다.
- [[raw/blog-topics/archunit-violations-as-data-pattern-2026-05-28]]: ArchUnit rule의 vacuous pass를 막기 위해 violations-as-data fixture와 negative test로 rule 자체를 검증하는 글감. static analysis 한계를 보완하는 패턴이지 reflection bypass를 해결하는 것은 아니다.
- [[raw/blog-topics/archunit-generic-return-type-purity-query-port-2026-06-05]]: `List<DomainType>` 같은 generic return type leak을 `getAllInvolvedRawTypes()`로 잡는 query port purity 글감. Object/downcast/reflection 우회까지 잡는다고 쓰지 않는다.
- [[raw/blog-topics/domain-modeling-guardrails-as-archunit-fitness-functions-2026-06-05]]: DDD marker annotation과 ArchUnit rule로 value object / aggregate / domain event guardrail을 강제하는 글감. marker taxonomy는 ca-tmpl project-local rule로 제한한다.
## 관련 개념
- [[wiki/concepts/clean-architecture-package-layout]]
- [[wiki/concepts/archunit-scope-classpath-vs-package-filter]] — `@AnalyzeClasses` 분석 scope(classpath import vs package filter)와 `allowEmptyShould` empty-anchor 함정의 일반 지식
## Sources
- [[raw/project-notes/ca-skeleton-operational-contract]] (§20 Skeleton Blueprint Contract, §29 Topic 1)
- [[raw/branch-notes/feature-skeleton-package-blueprint-contract]]
- [[raw/blog-topics/clean-architecture-module-blueprint-2026-05-28]] — module/package blueprint 블로그 글감 raw seed
- [[raw/branch-notes/feature-architecture-enforcement-rules]]
- [[raw/branch-notes/feature-domain-feature-onboarding-contract]]
- [[raw/blog-topics/executable-clean-architecture-onboarding-2026-06-25]] — executable onboarding guardrails 블로그 글감 raw seed
- [[raw/blog-topics/clean-architecture-boundary-enforcement-2026-05-28]] — Gradle + ArchUnit boundary enforcement 블로그 글감 raw seed
- [[raw/blog-topics/archunit-violations-as-data-pattern-2026-05-28]] — violations-as-data ArchUnit fixture 블로그 글감 raw seed
- [[raw/blog-topics/archunit-generic-return-type-purity-query-port-2026-06-05]] — generic return type purity guardrail 블로그 글감 raw seed
- [[raw/blog-topics/domain-modeling-guardrails-as-archunit-fitness-functions-2026-06-05]] — domain modeling guardrail 블로그 글감 raw seed
## Cluster / 묶음
<!-- GENERATED: derived-blogs:start -->
- [[wiki/blog/ca-tmpl-clean-architecture-package-layout-2026-07-02]]
<!-- GENERATED: derived-blogs:end -->
@@ -0,0 +1,127 @@
---
title: ca-tmpl - Config & Adapter Templates 결정 (env-driven + ConditionalOnProperty)
source_type: project
status: verified
confidence: high
tags: [ca-tmpl, 12-factor, config, conditional-on-property, actually-implemented, locally-verified]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
---
# ca-tmpl - Config & Adapter Templates 결정 (env-driven + ConditionalOnProperty)
> Layer: `wiki/projects/` — ca-tmpl skeleton 프로젝트의 Config & Adapter 영역 결정 사항. 일반 개념은 [[wiki/concepts/config-and-adapter-templates]] 참조.
## 프로젝트 컨텍스트
**ca-tmpl skeleton** — Clean Architecture 기반 Spring Boot 템플릿. 신규 백엔드 서비스를 시작할 때 use case / port / adapter 경계, env-driven config, optional adapter on/off, 운영 contract(observability / failure / supply chain 등)를 미리 fix해 두는 사내용 skeleton.
본 문서가 다루는 영역(canonical §9 Env-driven Runtime Configuration, §29 Group G-I Adapter Failure & Disabled):
- **Env config 결정**: `APP_` prefix + Duration `30s` 형식 1택 + boolean `true/false` only + no-runtime-reload + `.env.example` drift 검증.
- **Adapter on/off 결정**: optional module + `@ConditionalOnProperty` 3-layer detection (Spring bean + ArchUnit static + `AdapterDisabledException` runtime fail-fast).
**진행 상황**: C2 부분 구현 + 로컬 검증 완료. `docs/registries/env-keys.yaml`, `verifyEnvKeys`, `@ConfigurationProperties` settings, startup safety validator, optional-adapter 조건부 테스트/ArchUnit guard가 존재한다. 모든 provider-specific adapter template가 구현된 것은 아니다.
## 실제 구현 내용 (`actually-implemented`)
- `docs/registries/env-keys.yaml`과 Gradle `verifyEnvKeys` gate가 존재한다.
- `app-bootstrap`, `adapter-web`, `sample-portfolio` 등에 `@ConfigurationProperties` 기반 `*Settings` 타입이 존재한다.
- `StartupSafetyValidator`, `RuntimeNumericBoundsValidator`, `RequiredEnvironmentValidator`, `RequiredAdapterDisabledException`이 startup fail-fast guard를 구성한다.
- `DisabledAdapterArchitectureTest`가 optional adapter bean의 `@ConditionalOnProperty` 부착과 disabled-default boundary를 정적으로 검증한다.
- `EnabledIfRedisCacheEnabled`, `EnabledIfHttpRetryEnabled`, `EnabledIfHttpCircuitBreakerEnabled` 등 optional adapter contract test 조건부 실행 annotation이 존재한다.
## 로컬/dev 검증 (`locally-verified`)
- `./gradlew check` 통과(2026-07-02, `BUILD SUCCESSFUL`, 114 tasks).
- 실행 중 `verifyEnvKeys: OK — 99 env keys, 72 required placeholders covered, 85 APP_ keys registered`가 출력되었다.
- `EnvProfileMatrixContractTest`, `StartupSafetyValidatorTest`, `RuntimeNumericBoundsValidatorTest`, `DisabledAdapterArchitectureTest`가 env/profile/optional adapter contract를 검증한다.
## 운영 검증 (`prod-verified`)
**없음.** ca-tmpl은 skeleton이며 prod 배포 이력 없음.
## 문서/계획만 존재 (`documented-only` / `planned`)
다음 결정은 구현된 gate와 아직 provider-specific adapter template로 남은 부분을 함께 기록한다.
### Env config (canonical §9)
- **`APP_` prefix** — application-owned env는 `APP_` 접두사로 통일, 외부 의존 env(`SPRING_*`, `JAVA_OPTS` 등)와 시각적 분리.
- **Duration 1택** — Spring `Duration` 입력은 `30s` 형식으로 통일(ISO-8601 `PT30S` 금지). 동일 의미 두 표기가 공존하면 grep/diff 비용이 발생.
- **Boolean `true/false` only** — `1/0`, `yes/no`, `on/off` 금지. Spring `Binder`가 허용하더라도 contract 수준에서 1택.
- **No-runtime-reload** — `@RefreshScope`, Spring Cloud Config refresh endpoint, Spring Cloud Kubernetes auto-reload 모두 기본 금지. config 변경은 **재배포로만** 반영.
- **`.env.example` drift verify** — `@ConfigurationProperties`에 선언된 모든 env가 `.env.example`에도 존재해야 함을 빌드 단계에서 강제. 누락 시 build fail.
### 5종 대안 검토 (concept 문서 참조)
[[wiki/concepts/config-and-adapter-templates]]에서 다음 5종을 검토하고 ca-tmpl scope에서는 모두 채택하지 않기로 결정:
- Spring Cloud Config Server — config server SPOF + bootstrap 의존
- k8s ConfigMap + Spring Cloud Kubernetes auto-reload — pod별 partial-state + k8s lock-in
- HashiCorp Consul KV — KV+watch 운영 비용
- AWS Parameter Store / AppConfig — AWS lock-in + per-call billing
- LaunchDarkly / Unleash — product-grade A/B/canary 요구가 발생하기 전에는 over-engineering, ca-tmpl scope 밖
### Adapter templates (canonical §29 G-I)
- **Layer 1 — Spring `@ConditionalOnProperty`**: `APP_ADAPTER_<NAME>_ENABLED=true`일 때만 adapter bean 등록. optional module 자체는 dependency로 두지만 disabled 시 bean 등록 X.
- **Layer 2 — ArchUnit static detection**: `noClasses().that().resideInAPackage("..application..").should().dependOnClassesThat().resideInAPackage("..adapters.<disabled>..")` 형태의 정적 dependency rule. application code가 disabled adapter package를 import하는 것을 빌드 단계에서 차단.
- **Layer 3 — `AdapterDisabledException` runtime fail-fast**: disabled adapter가 어떤 경로로든 호출되면 즉시 `AdapterDisabledException`을 던져 silent failure 방지.
- **ArchUnit Layer 2 정적 검사 범위 명확화 (2026-05-22)** — annotation 존재까지만 정적 보장(`@ConditionalOnProperty` 부착 + `app.adapter.<name>.enabled` naming pattern), runtime active 여부 검사는 Layer 3 (`AdapterDisabledException`)에 위임. 자세한 평가는 [[raw/official-docs/archunit-conditional-on-property-3-layer-pattern]] (status: `needs-confirmation`).
Layer 1/2 일부와 startup fail-fast guard는 구현되어 있다. 다만 Kafka/Slack/Email 같은 모든 provider-specific adapter template와 runtime call path의 disabled sentinel은 범위별로 추가 확인이 필요하다.
## 면접에서 말할 수 있는 범위
### 자신 있게 답할 수 있는 질문
- "12-factor §III. Config가 의미하는 'config와 코드 분리'는 구체적으로 무엇을 강제하는가"
- "ca-tmpl이 no-runtime-reload를 기본 방침으로 둔 결정의 근거는?"
- "`@ConditionalOnProperty` 3-layer (Spring bean 조건 + ArchUnit static + runtime fail-fast)가 각각 어떤 실패 시나리오를 잡는지"
- "Java SPI `ServiceLoader``@ConditionalOnProperty`가 adapter on/off 표현에서 어떻게 다른지"
- "LaunchDarkly 같은 feature flag SaaS와 `@ConditionalOnProperty` startup toggle은 어떤 운영 요구가 생겼을 때 갈라지는지(trade-off)"
### 적당히 답할 수 있는 질문
- "`@RefreshScope`를 금지로 둔 이유" — 결정 근거는 설명 가능. 운영 데이터/사례는 없음.
- "Vault dynamic credential과 `@RefreshScope` 같은 runtime reload 메커니즘이 충돌하는 지점" — 개념적으로는 설명 가능. 직접 운영 경험 없음.
### 답하면 안 되는 질문 (모른다고 해야 함)
- "`@ConfigurationProperties` 검증을 운영 환경에서 어떻게 운용하는가" — 운영 경험 없음.
- "adapter on/off를 실제 환경에서 전환한 경험" — 없음. ca-tmpl은 skeleton 단계.
- "Layer 2 ArchUnit rule이 실제 빌드에서 어떤 위반을 잡았는가" — `DisabledAdapterArchitectureTest``./gradlew check` 통과 범위까지 답할 수 있음. 모든 provider adapter runtime path 검증은 별도 확인 필요.
## 과장 금지 지점
- **"`@RefreshScope`만 도입하면 dynamic config가 된다"** — ❌. ca-tmpl은 `@RefreshScope`를 기본 금지로 두는 결정을 했고, 본인은 dynamic config를 운영한 경험이 없음. "도입 가능" 정도로만 표현해야 함.
- **"`@ConditionalOnProperty` 3-layer가 disabled adapter 호출을 완전 검증한다"** — ❌. Layer 1/2와 startup fail-fast 일부는 검증됐지만, 모든 provider adapter runtime path까지 자동 보장한다고 쓰지 않는다.
- **"ca-tmpl이 LaunchDarkly를 거부했다"** — ❌. "ca-tmpl scope 밖으로 위임했다" / "product-grade A/B/canary 요구가 발생하면 별도 branch로 다룬다"는 표현이 정확.
- **"Config & Adapter 전체가 구현 완료"** — ❌. env registry/gate와 optional-adapter guard는 구현됐지만 provider별 adapter template 완성도는 범위별 확인이 필요하다.
### Blog-topic ingest: env/config/adapter 묶음 (2026-07-02)
- [[raw/blog-topics/env-example-drift-gate-gradle-2026-06-06]]: `.env.example` 중복 사본 대신 실제 `application.yml` placeholder와 tracked `.env` key surface를 대조하는 drift gate 글감. `verifyEnvKeys` 구현과 `./gradlew check` 통과를 근거로 blogify 가능하다.
- [[raw/blog-topics/spring-conditional-on-property-optional-adapter-template-2026-06-09]]: heavy SDK를 기본 dependency로 싣지 않고 optional adapter seam, disabled default, `@ConditionalOnProperty`, ArchUnit, disabled sentinel로 계약을 만드는 글감. 구현 범위는 optional-adapter guard와 startup fail-fast 일부로 제한한다.
- [[raw/blog-topics/spring-boot-3-configprops-record-multi-constructor-binding-2026-06-12]]: Spring Boot 3 record `@ConfigurationProperties`에 보조 생성자를 추가했을 때 constructor binding auto-detect가 깨질 수 있는 troubleshooting 글감. 공식 문서 근거 보강 전까지 일반화하지 않는다.
## 관련 개념
- [[wiki/concepts/config-and-adapter-templates]]
## Sources
- [[raw/project-notes/ca-skeleton-operational-contract]] (§9 Env-driven Runtime Configuration, §29 Group G-I Adapter Failure & Disabled)
- [[raw/branch-notes/feature-env-driven-runtime-configuration]]
- [[raw/branch-notes/feature-integration-adapter-templates]]
- [[raw/blog-topics/env-example-drift-gate-gradle-2026-06-06]] — env key drift gate 블로그 글감 raw seed.
- [[raw/blog-topics/spring-conditional-on-property-optional-adapter-template-2026-06-09]] — optional adapter template 블로그 글감 raw seed.
- [[raw/blog-topics/spring-boot-3-configprops-record-multi-constructor-binding-2026-06-12]] — Spring Boot 3 record configuration binding 블로그 글감 raw seed.
- [[raw/official-docs/archunit-conditional-on-property-3-layer-pattern]] — ArchUnit Layer 2 정적 검사 가능 범위 평가 (needs-confirmation)
## Cluster / 묶음
<!-- GENERATED: derived-blogs:start -->
- [[wiki/blog/ca-tmpl-config-and-adapter-templates-2026-07-02]]
<!-- GENERATED: derived-blogs:end -->
@@ -0,0 +1,255 @@
---
title: ca-tmpl - Data Layer Baseline 결정 (Persistence + Cache + Outbound)
source_type: project
status: verified
confidence: high
tags: [ca-tmpl, persistence, jpa, cache, http-client, actually-implemented, locally-verified]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
---
> **UPDATE 2026-06-15 (스코프: Outbound HTTP 한정):** 본 문서가 2026-05-22 에 기록한 *"Phase C2 미진입 / 코드 없음"* 전제는 **Outbound HTTP 영역에 한해 더 이상 사실이 아니다.** `src/adapter-outbound/.../httpclient/` 에 outbound HTTP 클라이언트가 구현 + 로컬 테스트로 검증되어 있다(아래 "Outbound HTTP Client" 절). 이 절은 [[wiki/explainer/adapter-outbound]] 가 코드 사실의 근거로 인용한다.
>
> **UPDATE 2026-07-02:** `/home/donghyeon/workspace/ca-tmpl/src` 대조 및 `./gradlew check` 통과로 이 문서를 `verified`로 승격했다. Outbound HTTP, lower-layer cache SPI/router/fail-open, idempotency/outbox persistence, OSIV/Hikari startup guard는 구현·로컬 검증됐다. 단 본 문서의 원래 "Cache 결정"(cache-aside + Caffeine + Redisson 분산 lock + after-commit invalidation)은 일부가 lower-layer cache SPI와 다른 층이므로 구현 범위를 분리해서 읽어야 한다.
# ca-tmpl - Data Layer Baseline 결정 (Persistence + Cache + Outbound)
> Layer: `wiki/projects/` — ca-tmpl skeleton의 data layer baseline 결정 사실 기록. 일반 개념·근거는 [[wiki/concepts/data-layer-persistence-cache-outbound]]에 둠. 본 문서는 "내 프로젝트에서 무엇을 결정했고, 어디까지 진행되었는가"만 다룬다.
## 프로젝트 컨텍스트
ca-tmpl은 Clean Architecture 기반 백엔드 skeleton 템플릿이다. 본 문서는 그 안에서 **data layer baseline 3축**(Persistence / Cache / Outbound HTTP)을 어떻게 결정했는지를 기록한다.
- 결정한 baseline:
- **Persistence**: SQLState 9-row classifier matrix + Hibernate **OSIV off** + HikariCP pool wait/exhaustion alert + read replica lag threshold ([[raw/project-notes/ca-skeleton-operational-contract]] §6).
- **Cache**: cache-aside default + Caffeine local lock(single-instance) + Redisson `RLock` distributed mutex(multi-instance HPA) + after-commit invalidation + eventual consistency window **5s** (canonical §11).
- **Outbound HTTP**: Spring **RestClient** baseline + Resilience4j CircuitBreaker · TimeLimiter · Retry + timeout **connect 2s / read 5s / global 10s** + retry **default disabled** (canonical §11, §29 G-C).
- 진행 단계: **C2 부분 구현 + 로컬 검증 완료.** 본 문서의 범위는 구현된 outbound/cache/persistence slice와 아직 planned로 남은 cache-aside/replica-lag/운영 tuning 경계를 분리한다.
## 실제 구현 내용 (`actually-implemented`)
**Persistence / Cache / Outbound 일부 구현됨.** SQLState classifier와 read-replica lag metric은 별도 확인이 필요하지만, idempotency/outbox persistence adapter와 Flyway migration, OSIV/Hikari startup guard, lower-layer cache SPI/router/fail-open/Redis adapter, outbound HTTP baseline은 코드에 존재한다.
### Outbound HTTP Client (`actually-implemented`)
모듈 `src/adapter-outbound/.../httpclient/`, 단일 업스트림 의존성 1개당 인스턴스 1개. 진입 클래스 `OutboundHttpClient`.
**입출력 (공개 API)**
| 메서드 | 입력 | 반환 | 비고 |
|---|---|---|---|
| `static OutboundHttpClient baseline(name, baseUrl, settings, guard, resilience, retryPolicy, errorMapper, logger)` | 협력자 8개 | `OutboundHttpClient` | 정적 팩토리 — **빈으로 등록하지 않음**. fork 프로젝트가 의존성마다 named 인스턴스 생성. static 인 이유: ArchUnit B7(어댑터 타입 반환 public *비*static 메서드 금지) seam |
| `<T> T get(String uri, Class<T> type)` | URI, 응답 타입 | `T` | `exchange(GET, uri, null, type)` 위임 |
| `<T> T exchange(HttpMethod m, String uri, Object body, Class<T> type)` | 메서드/URI/요청바디/응답타입 | `T` | 전체 파이프라인(아래) |
| `<T> T stream(HttpMethod m, String uri, Function<InputStream,T> reader)` | 메서드/URI/스트림 리더 | `T` | **리트라이 없음 · size 인터셉터 없음**. 대용량 응답 전용 |
**호출 파이프라인 (`exchange` 정상 경로)**
1. **셧다운 fast-fail**`guard.isShuttingDown()` 이면 네트워크를 맺지 않고 즉시 `DependencyFailureException(DEPENDENCY_CIRCUIT_OPEN, name, "shutdown in progress — outbound call rejected fail-fast (D8)")` throw, 로그 outcome=`REJECTED`.
2. `deadline = Instant.now().plus(globalCallTimeout)` 산정 → `retryPolicy.beginCall(method, deadline)` (ThreadLocal 에 적재).
3. 데코레이션 합성: `CircuitBreaker.decorateSupplier(cb, Retry.decorateSupplier(retry, countingSupplier))`**합성 순서 = CB(바깥) → Retry(안) → 실제 호출**. 리트라이가 CB 안쪽이라 각 재시도가 독립적으로 CB 윈도우에 카운트됨.
4. 예외 분기: `OutboundResponseSizeExceededException`**분류하지 않고 그대로 재throw**(업스트림 장애가 아니라 "버퍼 API 오용" 계약 위반); 그 외 모든 `Throwable``errorMapper.classify(name, t)` 로 매핑 → 로그 → throw. **호출자는 항상 `DependencyFailureException`(또는 size 예외)만 본다.**
5. `finally` 에서 `retryPolicy.endCall()` 항상 실행(ThreadLocal 누수 방지).
**예외 / 오류코드 매핑 (`OutboundHttpErrorMapper.classify`, cause chain 순회 → 첫 매치 채택)**
진단 메시지는 **server-log-only** — status code + 예외 클래스명만 담고 업스트림 raw 응답 body 는 절대 미포함(D12 PII 안전, `DependencyFailureException` javadoc 계약). 오류코드는 `OperationalError`(SSOT `docs/registries/error-codes.yaml`):
| 매치 (cause chain) | 코드 | HTTP / retryable |
|---|---|---|
| `CallNotPermittedException`(R4j) | `DEPENDENCY_CIRCUIT_OPEN` | 503 / true |
| `UnknownHostException`·`UnresolvedAddressException` | `DEPENDENCY_DNS_FAILED` | 503 / true |
| `HttpConnectTimeoutException` | `DEPENDENCY_CONNECT_FAILED` | 503 / true |
| `ConnectException`(DNS cause 포함) | `DEPENDENCY_DNS_FAILED` | 503 / true |
| `ConnectException`(그 외) | `DEPENDENCY_CONNECT_FAILED` | 503 / true |
| `HttpTimeout`·`SocketTimeout`·`TimeoutException` | `DEPENDENCY_TIMEOUT` | 504 / true |
| `RestClientResponseException` 4xx | `DEPENDENCY_4XX_CLIENT` | 502 / **false** (401→"check credential", 403→"check scope" 힌트) |
| `RestClientResponseException` 5xx | `DEPENDENCY_5XX_SERVER` | 502 / true |
| 매치 없음(fallback) | `DEPENDENCY_CONNECT_FAILED` | 503 / true |
> 순서 주의: `HttpConnectTimeoutException extends HttpTimeoutException` 이라 connect 를 read timeout 보다 먼저 검사. **Open Risk(D12):** 408/429 는 의미상 재시도 가능하지만 현재 모든 4xx 가 non-retryable.
**자료구조 + 선택 이유**
| 구조 | 위치 | 이유 |
|---|---|---|
| `AtomicBoolean running/shuttingDown` | `OutboundHttpShutdownGuard` | 셧다운 스레드 write ↔ 요청 스레드 read 간 가시성 |
| `ThreadLocal<CallContext>` + `record CallContext(HttpMethod, Instant deadline)` | `OutboundRetryPolicy` | 동기 클라이언트라 호출이 한 스레드를 타고 가므로 deadline·method 를 스레드별 격리 |
| `Set.of(GET,HEAD,PUT,DELETE)` | `OutboundRetryPolicy.IDEMPOTENT_METHODS` | 불변 + O(1) 멱등 판정. POST/PATCH 의도적 제외 |
| `int[] attemptCount = {0}` | `OutboundHttpClient.exchange` | 람다가 캡처 지역변수를 못 바꾸므로 1칸 배열을 가변 closure cell 로 사용 |
| `record OutboundHttpSettings` + 중첩 `record Retry/CircuitBreaker`(박싱 `Integer/Double/Float`) | `OutboundHttpSettings` | 불변 값 + `null` = "기본값 적용" 신호 |
| `Optional<Retry>`/`Optional<CircuitBreaker>` | `OutboundHttpResilience` | "데코레이션 없음"(기능 off)을 호출자가 강제로 다루게 |
**Spring / Resilience4j / Micrometer 메커니즘**
- `@ConfigurationProperties(prefix="app.outbound.http")` + `@ConstructorBinding` → env/yaml → record 바인딩, compact 생성자 검증 실패 시 **startup 실패**.
- `SmartLifecycle`(`OutboundHttpShutdownGuard`): `getPhase()=Integer.MAX_VALUE` → 컨텍스트 종료 시 phase **내림차순** stop → 이 빈의 `stop()` 이 가장 먼저 호출(다른 아웃바운드 빈보다 먼저 플래그 set). `ContextClosedEvent` 는 너무 늦고 순서 미보장이라 부적합.
- `BeanPostProcessor`(`OutboundHttpTimeoutEnforcer`, **static @Bean**): raw `RestClient`/`RestClient.Builder` 빈 발견 시 `BeanCreationException` → timeout 미설정 클라이언트 등록을 startup 차단. static 이라 다른 빈보다 일찍 생성돼 가로챔.
- Resilience4j: `decorateSupplier` 합성, `RetryRegistry`/`CircuitBreakerRegistry` 가 dependency 이름별 인스턴스 캐시(= per-dependency 지표), `IntervalFunction.ofExponentialRandomBackoff`(지수 + jitter).
- Micrometer `MeterFilter`(`OutboundHttpResilienceConfig`): 저카디널리티 정규화 — `kind``outcome` 태그 리네임, state 값 대문자화, 그 외 `resilience4j.*` 미터 전부 `DENY`. **활성화 가드(D3):** retry/CB 중 하나라도 켜졌는데 `MeterRegistry` 없으면 `IllegalStateException`.
- `RestClient` 2개: connect timeout = `HttpClient.connectTimeout`, read timeout = `JdkClientHttpRequestFactory.setReadTimeout`. buffered(trace→size 인터셉터) / streaming(trace 만).
**설정 (`app.outbound.http.*`)**
| 키 | 기본값 | 효과 |
|---|---|---|
| `connect-timeout` / `read-timeout` / `global-call-timeout` | 없음(필수) | TCP 연결 / 소켓 읽기 / 리트라이 포함 전체 deadline 예산. 누락 시 startup 실패 |
| `retry-enabled` / `circuit-breaker-enabled` | `false` / `false` | R4j retry / CB 활성. 하나라도 켜면 `MeterRegistry` 필수 |
| `response-size-limit` | `10MB` | buffered 본문 in-memory 상한(초과 시 `OutboundResponseSizeExceededException`) |
| `retry.max-attempts` / `initial-backoff` / `backoff-multiplier` | `3` / `100ms` / `2.0` | 시도 횟수 / 첫 백오프 / 지수 배수 |
| `circuit-breaker.failure-rate-threshold` | `50`(%) | open 임계 |
| `…sliding-window-size` / `…minimum-number-of-calls` | `100` / `100` | COUNT_BASED 윈도우 / rate 계산 최소 호출 |
| `…wait-duration-in-open-state` / `…permitted-calls-in-half-open` | `60s` / `10` | open→half-open 대기 / half-open 시험 호출 수 |
**클래스 연관 (빈 배선)**
- `OutboundHttpClientConfig` 가 공유 빈(`ShutdownGuard`/`TimeoutEnforcer`/`ErrorMapper`/`OutboundHttpDependencyLogger`/`RetryPolicy`)을 `@Bean @ConditionalOnMissingBean` 등록하되 **`OutboundHttpClient` 빈은 일부러 안 만든다**(의존성마다 named 인스턴스).
- `OutboundHttpResilienceConfig``OutboundHttpResilience` 빈 생산 + MeterFilter 설치.
- ⚠️ `retryPolicy` 인스턴스는 `resilience`(`shouldRetry` predicate)와 `OutboundHttpClient`(`beginCall`)가 **같은 것을 공유**해야 한다 — 다르면 `shouldRetry` 가 ctx=null 로 영영 재시도하지 않음.
- 인터셉터: `TraceContextPropagationInterceptor`(MDC→`traceparent`/`baggage` 헤더, 샘플 플래그 `00` 하드코딩, allowlist=`tenant_id`·`request_id`), `ResponseSizeBoundingInterceptor`(Content-Length 또는 `BoundedInputStream` 누적이 limit 초과 시 throw, buffered 전용).
## 로컬/dev 검증 (`locally-verified`)
**Persistence / (data-layer) Cache: 없음** (재확인 안 함).
**Outbound HTTP Client: `locally-verified`**`src/adapter-outbound/src/test/.../httpclient/` 의 단위 테스트로 다음이 검증됨(prod 배포·측정은 없음):
- `OutboundHttpClientTest` — retry-on 500 GET 정확히 3회 / POST 정확히 1회(I4 비멱등 차단) · CB OPEN 시 0회 short-circuit + `DEPENDENCY_CIRCUIT_OPEN` · 셧다운 시 0회 + `REJECTED` · 업스트림 secret body 미유출 · buffered size 초과 시 `OutboundResponseSizeExceededException`(`stream()` 은 성공) · `outcome` 태그 존재/`kind` 태그 부재.
- `OutboundHttpErrorMapperTest` — 위 예외 매핑 테이블 전 행 + 408/429 Open Risk + body 미유출.
- `OutboundHttpResilienceTest` / `OutboundHttpResilienceConfigTest` — decorate 순서 · 기본값(3/100ms/2.0, 50%/100/100/60s/10) · MeterRegistry 가드.
- `OutboundRetryPolicyTest` — 4-조건 게이트(셧다운/멱등/retryable/deadline).
- `OutboundHttpShutdownGuardTest` — phase=`Integer.MAX_VALUE`, start/stop 플래그.
- `OutboundHttpSettingsTest` — config 바인딩 + 잘못된 값 startup `IllegalArgumentException`.
- `TraceContextPropagationInterceptorTest` / `OutboundHttpDependencyLoggerTest` — 헤더 주입 · 로그 레벨/필드.
## 운영 검증 (`prod-verified`)
없음. 운영(prod) 환경에 배포된 적이 없다. 따라서 릴리즈 노트 / 운영 로그 / 모니터링 대시보드 / 인시던트 보고서 어느 것도 존재하지 않는다.
- 운영(prod) 환경에 배포된 적이 없다. 따라서 릴리즈 노트 / 운영 로그 / 모니터링 대시보드 / 인시던트 보고서 어느 것도 존재하지 않는다.
## 문서/계획만 존재 (`documented-only` / `planned`)
다음은 모두 **문서/설계 단계**의 결정이며, 코드로 강제되어 있지 않다. 면접에서 "구현했다"고 말하면 안 되는 부분이다.
### Persistence (`partially-implemented`)
- SQLState 9-row classifier matrix(`08*` connection, `40001` serialization, `40P01` deadlock, `23xxx` integrity, `57014` query canceled 등) → Spring `DataAccessException` hierarchy 위에 `TRANSIENT_DEPENDENCY / CONFLICT / DATA_INTEGRITY` 카테고리 매핑 (`Category.java` 10-enum 정합 — 이전 `PERSISTENCE` 표기는 stale, 부모 §6 2026-06-01 정합 + `error-codes.yaml` authoritative).
- Hibernate **OSIV off**를 baseline으로 결정 (Vlad Mihalcea anti-pattern 평가 + Spring Boot startup WARN 근거).
- HikariCP pool wait p99 / pool exhaustion을 1차 alert 지표로 지정.
- Read replica lag threshold를 SLO에 포함.
- 근거: canonical [[raw/project-notes/ca-skeleton-operational-contract]] §6 + [[raw/branch-notes/feature-persistence-failure-baseline]].
- 구현됨: idempotency/outbox RDBMS adapter, PostgreSQL migration, OSIV-off startup guard, Hikari inter-knob startup guard.
- 남음: SQLState 9-row classifier 전체, read replica lag metric/alert, 운영 pool tuning 측정.
### Cache (`partially-implemented`)
- cache-aside default + Caffeine local lock(`@Cacheable(sync = true)` / `AsyncLoadingCache`) + Redisson `RLock` distributed mutex(multi-instance HPA 가정).
- after-commit invalidation 강제 (Spring `TransactionSynchronizationManager.registerSynchronization``afterCommit()` hook).
- Eventual consistency window 5초로 명시.
- Strict consistency use case(잔액, 인증, idempotency 검증)는 cache bypass.
- Negative cache(존재하지 않는 row, TTL 60s)는 invalidation 채널 적용 대상에서 제외.
- 근거: canonical §11 + [[raw/branch-notes/feature-cache-consistency-contract]].
- 구현됨: `CacheStore`, `FailOpenCacheStore`, `CacheStoreRouter`, `RedisCacheStore`, `CacheBindingSettings`, 관련 단위 테스트.
- 남음: 원래 문서의 cache-aside+Caffeine local lock+Redisson distributed mutex+after-commit invalidation 전체 contract와 운영 consistency window 측정.
### Outbound HTTP (결정 — **현재 구현됨**, 위 "Outbound HTTP Client" 절 참조)
다음은 baseline 결정 *사실*이며, 결정 자체는 그대로 유효하다. **2026-06-15 기준 코드로 구현되어 있다**(시간/리트라이 *기본값*은 결정 당시 수치와 일부 다르게 구현됨 — 아래 표시):
- Spring RestClient(6.1+)를 baseline으로 결정. RestTemplate은 maintenance-only로 신규 채택 제외, WebClient는 MVC servlet baseline의 blocking risk로 extension 분리, OpenFeign은 Spring Cloud 의존으로 baseline에서 제외. → **구현: `RestClient` 2종(buffered/streaming).**
- Resilience4j로 retry / circuit breaker 일원화. Hystrix는 maintenance mode로 배제. → **구현: `OutboundHttpResilience` + `OutboundHttpResilienceConfig`.** (TimeLimiter 대신 동기 클라이언트라 deadline 예산 + `OutboundRetryPolicy` 게이트로 대체.)
- Timeout 계층: connect / read / global **3축 모두 필수 강제**(하나라도 누락 시 startup 실패). → **구현됨. 단 결정 당시 예시값 `2s/5s/10s` 는 *기본값이 아니라 필수 입력*으로 구현**(`@ConfigurationProperties`, 기본값 없음).
- Retry **default disabled**(`retry-enabled=false`) → **구현됨.** idempotency-key 미보장 일반 API 보수적 결정. 켜도 비멱등(POST/PATCH)은 `OutboundRetryPolicy` 가 차단.
- 근거: canonical §11, §29 Group G-C + [[raw/branch-notes/feature-outbound-http-client-baseline]] + 코드 `src/adapter-outbound/.../httpclient/`.
### 대안 검토 범위 (요약 — 상세는 concept 참조)
각 sub-topic마다 5종 이상 대안을 비교했고 baseline을 선정했다. 비교의 출처/세부는 [[wiki/concepts/data-layer-persistence-cache-outbound]]에 있다.
- Persistence: SQLState classifier vs vendor-specific code, JPA blocking vs R2DBC reactive, OSIV on vs off, Hikari sizing 공식.
- Cache: cache-aside vs write-through vs write-behind vs read-through, Caffeine vs Hazelcast(local), Redisson RLock vs SETNX vs Redlock.
- Outbound HTTP: RestClient vs RestTemplate vs WebClient vs OpenFeign vs `@HttpExchange`, Resilience4j vs Hystrix vs Spring Retry.
## 면접에서 말할 수 있는 범위
### 자신 있게 답할 수 있는 질문
- ca-tmpl의 SQLState 9-row classifier matrix를 왜 만들었고, Spring `DataAccessException` hierarchy 위에서 어떤 카테고리(`TRANSIENT_DEPENDENCY / CONFLICT / DATA_INTEGRITY`, `Category.java` 10-enum)로 매핑하기로 했는가.
- Hibernate OSIV를 anti-pattern으로 보는 근거(Vlad Mihalcea + Spring Boot WARN)와 OSIV off를 ca-tmpl baseline으로 둔 이유.
- cache-aside의 eventual consistency window 5초가 의미하는 바, 그리고 strict consistency가 필요한 use case(잔액, 인증, idempotency 검증)를 cache bypass로 분리한 의도.
- Resilience4j를 Hystrix 대신 선택한 이유(Hystrix maintenance mode + Resilience4j functional decorator 모델).
- Outbound HTTP timeout을 connect 2s / read 5s / global 10s로 분리한 의도와, 셋 중 어떤 게 빠지면 어떤 위험이 생기는지.
### 적당히 답할 수 있는 질문
- Caffeine vs Hazelcast 같은 local cache 후보 비교 (개념 수준은 가능, 실측 비교 없음).
- RestClient vs WebClient (concept-level trade-off는 답할 수 있으나 실제 throughput 측정 없음).
- after-commit invalidation을 강제하는 이유 (개념 + Spring API 위치는 설명 가능, 실 hook 코드 없음).
### 답하면 안 되는 질문 (모른다고 해야 함)
- HikariCP pool size 튜닝 경험, pool wait p99 실측치, pool exhaustion 대응 경험 — **측정/운영 경험 없음**.
- cache hit ratio 측정 / TTL 튜닝 / negative cache stale 사례 — **계측 없음**.
- Resilience4j circuit breaker open 운영 경험, half-open probe 동작 관찰, 실제 retry budget 튜닝 — **운영 경험 없음**.
- read replica lag 운영 경험, replica failover 대응 — **운영 경험 없음**.
- 본 baseline을 적용한 서비스의 SLO 달성 여부 — **prod 배포 없음**.
## 과장 금지 지점
이 프로젝트를 외부에 설명할 때 **사실보다 부풀려지기 쉬운 표현**.
- "ca-tmpl에 SQLState classifier 전체를 구현했다" → **❌**. persistence failure classifier 전체는 별도 확인 필요.
- "OSIV off startup guard와 Hikari inter-knob guard를 로컬 검증했다" → 가능.
- "cache-aside + Redisson RLock으로 분산 환경에서 안전한 캐시를 구현했다" → **❌**. lower-layer cache SPI/router와 원래 cache-aside+distributed mutex contract를 혼동하지 않는다.
- "Resilience4j로 circuit breaker/retry baseline을 구현했다" → 가능. 단 운영 장애 대응 경험은 없음.
- "RestClient + timeout 2s/5s/10s로 outbound baseline을 구현하고 로컬 테스트로 검증했다" → 가능. 단 운영 SLO 보장은 아님.
- "성능 측정 후 baseline을 튜닝했다" → **❌**. 측정·튜닝 모두 미수행.
- "운영에서 검증된 baseline이다" → **❌**. prod 배포 없음.
면접·블로그·이력서에서는 항상 "**구현된 slice와 planned slice를 분리**"해야 한다. outbound/cache SPI/idempotency/outbox persistence는 구현·로컬 검증, cache-aside distributed consistency와 운영 tuning은 planned로 둔다.
### Blog-topic ingest: cache/webhook/outbound 묶음 (2026-07-02)
아래 raw seed들은 data-layer/cache/outbound canonical에 연결했다. 이 문서는 2026-07-02 기준 코드와 `./gradlew check`로 검증되어 blogify 가능하다. 단 각 글에서는 구현된 slice와 planned slice를 분리한다.
- [[raw/blog-topics/cache-backend-router-fail-open-decorator-2026-07-02]]: cache 장애를 backend 내부 `try/catch`가 아니라 router/decorator 조립 계약으로 중앙화하는 글감. **말할 수 있는 범위**는 ca-tmpl cache role과 검증된 backend 범위다. "모든 cache 실패를 삼켜도 된다"는 식으로 쓰지 않는다.
- [[raw/blog-topics/cache-consistency-after-commit-stampede-contract-2026-07-02]]: after-commit invalidation, stampede guard, negative TTL, consistency window를 분리하는 글감. **주의**: planned test와 unsupported decision을 implemented처럼 쓰지 않는다.
- [[raw/blog-topics/webhook-full-jitter-dlq-observability-2026-07-02]]: webhook retry를 Full Jitter, DLQ, metric contract로 묶는 글감. **주의**: retry/metric/DLQ 중 planned 항목은 구현 완료로 쓰지 않는다.
- [[raw/blog-topics/webhook-signature-replay-contract-2026-07-02]]: raw bytes, timestamp, message id, replay window를 webhook signature 계약으로 묶는 글감. provider 문서는 universal standard가 아니라 사례/source-backed claim으로만 사용한다.
- [[raw/blog-topics/webhook-ssrf-egress-proxy-redirect-block-2026-07-02]]: webhook endpoint 등록을 URL 저장이 아니라 egress proxy, redirect block, private range 차단 계약으로 다루는 글감. OWASP/source-backed SSRF 방어와 ca-tmpl planned policy를 분리한다.
- [[raw/blog-topics/jdk-httpclient-dns-connectexception-classification-2026-07-02]]: JDK `HttpClient`에서 DNS 실패를 connection failure로 분류할 때 exception wrapping과 retry category를 어떻게 다루는지 정리하는 글감. 운영 장애 사례가 아니라 local/test evidence 중심으로 제한한다.
- [[raw/blog-topics/micrometer-meterfilter-resilience4j-functioncounter-2026-07-02]]: outbound HTTP observability에서 Micrometer `MeterFilter`와 Resilience4j `FunctionCounter` registration/tag policy가 충돌할 수 있는 지점을 정리하는 글감. Micrometer/Resilience4j 자체의 일반 결함처럼 쓰지 않는다.
- [[raw/blog-topics/repository-capability-archunit-fitness-function-2026-07-02]]: repository 접근 권한을 annotation + registry + ArchUnit fitness function으로 강제하는 글감. 모든 repository misuse를 자동 검출한다고 쓰지 않고, 정적 분석 rule이 볼 수 있는 구조로 제한한다.
- [[raw/blog-topics/persistence-audit-metadata-clean-architecture-2026-07-02]]: audit column을 domain model에 섞지 않고 persistence adapter에서 채우는 boundary choice 글감. JPA Auditing이 나쁘다고 쓰지 않고 ca-tmpl skeleton의 선택으로 제한한다.
- [[raw/blog-topics/distributed-lock-transaction-commit-boundary-2026-07-02]]: `lock.close()`와 DB commit 순서가 맞물릴 때 lost update 경계가 생기는 이유를 다루는 글감. local/JDBC lock 검증을 운영 분산 환경 보장으로 표현하지 않는다.
- [[raw/blog-topics/hikaricp-inter-knob-constraints-startup-guard-2026-06-09]]: HikariCP knob 간 제약을 Spring Boot startup guard로 fail-fast 검증하는 글감. 기존 canonical은 persistence/cache 영역이 stale일 수 있으므로 실제 validator/test 존재 여부를 재확인하기 전까지 구현 등급을 올리지 않는다.
## 관련 개념
- [[wiki/concepts/data-layer-persistence-cache-outbound]]
## Sources
- [[raw/project-notes/ca-skeleton-operational-contract]] — §6 Operational Error Category, §11 Adapter Failure Contract, §29 Group G-C 외부 근거 인덱스
- [[raw/branch-notes/feature-persistence-failure-baseline]] — SQLState 9-row matrix, Hikari alert threshold, OSIV off 결정 기록
- [[raw/branch-notes/feature-cache-consistency-contract]] — cache-aside default, Caffeine + Redisson, after-commit invalidation, 5s window 결정 기록
- [[raw/branch-notes/feature-outbound-http-client-baseline]] — RestClient baseline, Resilience4j, timeout 2s/5s/10s, shutdown retry suppression 결정 기록
- [[raw/blog-topics/jdk-httpclient-dns-connectexception-classification-2026-07-02]] — JDK HttpClient DNS/ConnectException classification 블로그 글감 raw seed
- [[raw/blog-topics/micrometer-meterfilter-resilience4j-functioncounter-2026-07-02]] — Micrometer/Resilience4j metric registration 블로그 글감 raw seed
- [[raw/branch-notes/feature-repository-access-permission-contract]] — repository capability/access permission parent branch
- [[raw/blog-topics/repository-capability-archunit-fitness-function-2026-07-02]] — repository capability ArchUnit fitness function 블로그 글감 raw seed
- [[raw/branch-notes/feature-persistence-auditing-contract]] — persistence audit metadata parent branch
- [[raw/blog-topics/persistence-audit-metadata-clean-architecture-2026-07-02]] — persistence audit metadata 블로그 글감 raw seed
- [[raw/branch-notes/feature-distributed-lock-contract]] — distributed lock lifecycle parent branch
- [[raw/blog-topics/distributed-lock-transaction-commit-boundary-2026-07-02]] — distributed lock transaction commit boundary 블로그 글감 raw seed
- [[raw/blog-topics/hikaricp-inter-knob-constraints-startup-guard-2026-06-09]] — HikariCP startup guard 블로그 글감 raw seed
- [[raw/branch-notes/feature-cachestore-multi-backend-router]] — cache backend router/decorator/fail-open 글감의 parent branch
- [[raw/branch-notes/feature-webhook-outbound-contract]] — webhook retry/signature/SSRF outbound 글감의 parent branch
- [[raw/blog-topics/cache-backend-router-fail-open-decorator-2026-07-02]] — cache router/decorator 블로그 글감 raw seed
- [[raw/blog-topics/cache-consistency-after-commit-stampede-contract-2026-07-02]] — cache after-commit/stampede 블로그 글감 raw seed
- [[raw/blog-topics/webhook-full-jitter-dlq-observability-2026-07-02]] — webhook retry/DLQ/observability 블로그 글감 raw seed
- [[raw/blog-topics/webhook-signature-replay-contract-2026-07-02]] — webhook signature/replay 블로그 글감 raw seed
- [[raw/blog-topics/webhook-ssrf-egress-proxy-redirect-block-2026-07-02]] — webhook SSRF/egress 블로그 글감 raw seed
## Cluster / 묶음
<!-- GENERATED: derived-blogs:start -->
- [[wiki/blog/ca-tmpl-data-layer-persistence-cache-outbound-2026-07-02]]
<!-- GENERATED: derived-blogs:end -->
@@ -0,0 +1,141 @@
---
title: ca-tmpl - DevOps Baseline 결정 (CI + Supply chain + DX)
source_type: project
status: verified
confidence: high
tags: [ca-tmpl, devops, ci-cd, supply-chain, sigstore, actually-implemented, locally-verified]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
---
# ca-tmpl - DevOps Baseline 결정 (CI + Supply chain + DX)
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념은 [[wiki/concepts/devops-ci-supply-chain-dx]] 참조.
## 프로젝트 컨텍스트
ca-tmpl은 ca-skeleton의 운영 가능한 백엔드 템플릿 skeleton이다. **현재 C2 구현 + 로컬 검증 완료.** DevOps baseline은 다음 결정으로 고정되어 있고, GitHub workflow / Gradle gate / supply-chain script 일부가 실제 repository에 존재한다 (canonical §29 G-E + 3개 branch-notes).
- **CI**: GitHub Actions `needs:` + `if: success()` 모델, **Gate ↔ Branch Contract Test 소유권 매트릭스 20행**, flaky test quarantine bucket **14일 sunset**.
- **Supply chain**: **Cosign keyless** (Sigstore Fulcio + Rekor) signing 의무, **SLSA provenance attestation** 의무, **Gradle dependency-locking** (`lockMode = STRICT`), reproducible build.
- **DX**: **`./gradlew bootstrap`** 5단계 단일 진입점, **Temurin 21 LTS** + `.tool-versions` 핀, **Testcontainers** `@ServiceConnection` 기반 integration test, `markdown-link-check`.
이 문서는 결정의 사실 범위와 검증 등급을 분리해 기록한다.
## 실제 구현 내용 (`actually-implemented`)
- `.github/workflows/ci-quality-gates.yml`, `dependency-vulnerability.yml`, `build-release-supply-chain.yml`, `link-check.yml`, `supply-chain-retention-audit.yml`가 존재한다.
- `.github/ci-gate-matrix.yml`, `.github/dependency-review-config.yml`, `.github/supply-chain-policy.json`, `.github/scripts/verify-supply-chain-contract.sh`, `.github/scripts/test-supply-chain-scripts.sh`가 gate/supply-chain 정책을 코드화한다.
- `src/build.gradle``verifyCleanArchitectureDependencies`, `verifyEnvKeys`, `verifyQuarantineSunset`, `verifyTrivyignore`, `verifyReadmeCommands` 등이 check graph에 포함된다.
- module별 `gradle.lockfile`, `.trivyignore.yaml`, `flaky-quarantine.yaml`가 존재한다.
## 로컬/dev 검증 (`locally-verified`)
- `./gradlew check` 통과(2026-07-02, `BUILD SUCCESSFUL`, 114 tasks).
- 실행 중 `verifyCleanArchitectureDependencies`, `verifyEnvKeys`, `verifyQuarantineSunset`, `verifyReadmeCommands`, `verifyTrivyignore`가 OK로 통과했다.
- `DeveloperExperienceContractTest`, `ContractRegistrySchemaGovernanceTest`, `SampleRemovalSmokeContractTest` 등 bootstrap/contract tests가 workflow/gate 파일을 검증한다.
## 운영 검증 (`prod-verified`)
없음. hosted GitHub Actions run, 실제 release publication, Rekor/GHCR/Cosign live verification은 이 문서에서 확인하지 않았다.
## 문서/계획만 존재 (`documented-only` / `planned`)
아래 항목은 구현/로컬 검증된 것과 live release 검증이 필요한 것을 분리한다.
### CI (`actually-implemented` / `locally-verified`)
- **GitHub Actions `needs:` + `if: success()`** 기반 release-blocking gate 모델 결정.
- **Gate ↔ Branch Contract Test 소유권 매트릭스 20행** — 각 gate가 어느 branch contract test에 의해 깨질 수 있는지, 누가 소유하는지 명시 (canonical §29 G-E + [[raw/branch-notes/feature-ci-quality-gates-contract]]).
- **OpenAPI snapshot diff** — springdoc + openapi-diff/oasdiff로 controller 변경 자동 감지. dynamic routing 누락 한계 인지됨.
- **Trivy** image vulnerability scan gate.
- **Flaky test quarantine bucket + 14일 sunset** — Spotify/Google/MS 운영 vs Fowler 반대 절충안.
### Supply chain (`partially-implemented`)
- **Cosign keyless signing** — Fulcio 단명(10분) cert + Rekor transparency log. `--certificate-identity` + `--certificate-oidc-issuer` 검증 정책 필요성 인지됨.
- **SLSA provenance attestation** — in-toto attestation, DSSE envelope, Cosign이 동일 envelope 서명.
- **Gradle dependency-locking** — `dependencyLocking { lockAllConfigurations() }` + `lockMode = STRICT`, `--write-locks`로 lockfile 생성.
- **SemVer + git sha suffix** 버전 정책, reproducible build 목표.
- **Cosign verify identity policy** (`--certificate-identity` + `--certificate-oidc-issuer`) — 2026-05-22 후속 보강 결정. 면접 답변 시 "identity 매칭까지 정책에 명시했다"로 정정 가능. 근거: [[raw/official-docs/cosign-keyless-identity-verification-policy]].
- **SLSA v1.0 provenance schema 필드명 정정 결정 (2026-05-22)** — provenance 생성 시 spec 필드명(`buildDefinition.externalParameters`, `runDetails.builder.id`, `runDetails.metadata.invocationId` 등) 사용, branch-note의 약식 명명(`build.config.source`, `build.invocation`, `materials`)은 forbidden. 근거: [[raw/official-docs/slsa-v1-provenance-schema]].
### DX (`actually-implemented` / `locally-verified`)
- **`./gradlew bootstrap`** 5단계: compileTestJava → docker compose up → Flyway migrate → sample profile seed → smoke.
- **Temurin 21 LTS** + `.tool-versions` (asdf/mise 호환).
- **Testcontainers** `@ServiceConnection` (Spring Boot 3.1+), reuse 옵션은 CI 비활성화.
- **markdown-link-check** dead link 검사.
### 검토한 대안 (5+종)
CI provider (GitLab CI / Jenkins / CircleCI / Tekton), signing (GPG vs Cosign), provenance (in-toto vs ad-hoc), dependency lock (Gradle vs Maven Enforcer), tool versioning (mise/asdf vs SDKMAN), dev environment (Devcontainer 단독 vs bootstrap 병행) — 상세 비교는 [[wiki/concepts/devops-ci-supply-chain-dx]] 참조.
## 면접에서 말할 수 있는 범위
### 자신 있게 답할 수 있는 질문
- CI **Gate ↔ Branch Contract Test 소유권 매트릭스**의 의미 (누가 어떤 gate 실패에 책임지는가).
- **Flaky test quarantine 14일 sunset**의 근거 (Spotify/Google/MS 운영 인정 + Fowler 반대 입장 절충).
- **Cosign keyless vs GPG** 트레이드오프 (단명 cert + Rekor 의존성 추가 vs GPG key 관리 비용 제거).
- **SLSA Build L1/L2/L3** 각 레벨이 보장하는 것과 GitHub Actions hosted runner에서 현실적 도달 범위.
- **Gradle dependency-locking 필요성**과 Maven에 transitive lockfile이 1급 시민으로 없는 이유.
- **Testcontainers vs H2** 선택 이유 (production parity vs 시작 비용).
### 적당히 답할 수 있는 질문
- **Tekton vs GitHub Actions** — k8s 인프라 부담과 skeleton 적합도.
- **in-toto attestation** statement/predicate/DSSE envelope 구조.
### 답하면 안 되는 질문 (모른다고 해야 함)
- "**CI pipeline 운영 경험**" — workflow와 local gate 검증은 가능. hosted CI 운영 이력은 별도 확인 필요.
- "**SLSA L3 달성**" — 약식 매핑 단계, hermetic build 미구성.
- "**Cosign signature 검증 운영 경험**" — 정책/스크립트는 존재하지만 live Rekor/GHCR 검증 이력은 별도 확인 필요.
- "이 skeleton으로 실제 release 한 적 있는가" — 없음.
## 과장 금지 지점
- **"Cosign 서명 누락만 차단하면 안전하다"** → ❌. `--certificate-identity` + `--certificate-oidc-issuer` **identity 매칭 정책**이 없으면 임의 OIDC identity가 만든 서명도 통과된다. 정책 표현 형식은 후속 보강 대상(`needs-confirmation`).
- **"SLSA Build L3를 달성했다"** → ❌. 현재는 **약식 매핑 단계**이며, GitHub Actions hosted runner만으로 L3(hermetic/tamper-resistant builder) 도달 어렵다. 현실 목표는 L2.
- **"SLSA spec 필드명에 정확히 매핑됐다"** → ❌. branch-note의 약식 표현(`build.config.source`, `build.invocation`)은 spec 실제 필드명(`buildDefinition.externalParameters`, `runDetails.builder`, `materials`)과 다르며 **정정 필요**.
- **"Google/Spotify가 quarantine 운영하므로 공식 best practice다"** → ❌. *company-tech-blog* 등급이며 Fowler 반대 입장과 양립한다.
- **"`./gradlew bootstrap` 한 줄이 끝났다 = 정상이다"** → ❌. 5단계 중 어디서 실패했는지 step 단위 exit code 분리가 필요.
- 본 문서는 2026-07-02 코드와 `./gradlew check`로 검증되어 `confidence: high`로 승격했다. 단 live release/supply-chain publication 경험과 혼동하지 않는다.
### Blog-topic ingest: gitea-act-dependency-security-gate-portability (2026-07-02)
[[raw/blog-topics/gitea-act-dependency-security-gate-portability-2026-07-02]] 는 GitHub Actions 전용 dependency-review/trivy-action을 Gitea와 act_runner 환경에서도 다룰 수 있도록 플랫폼 독립 CLI gate로 조정한 이유를 블로그로 풀기 위한 raw seed다.
- **canonical 반영 범위**: dependency vulnerability gate portability 글감을 DevOps/Supply-chain canonical에 연결했다.
- **blogify 전 조건**: 충족. 이 문서는 2026-07-02 기준 코드와 `./gradlew check`로 검증됨.
- **블로그 전 과장 방지**: 모든 CI에서 동작한다고 쓰지 않고, portability를 높인 설계로 제한한다.
- [[raw/blog-topics/five-stage-local-bootstrap-contract-2026-06-24]]: 단일 bootstrap 명령의 가치를 compile/dependency/migration/contract/HTTP smoke 실패를 서로 다른 증거로 분리하는 데 둔 글감. Linux local 검증을 모든 OS/CI/prod 보장으로 표현하지 않는다.
- [[raw/blog-topics/trivy-suppression-governance-static-gate-2026-06-20]]: `.trivyignore.yaml` suppression에 만료일·사유 없는 silent bypass가 생기지 않도록 Gradle 정적 게이트로 강제한 글감. 운영에서 취약점 우회를 막았다고 쓰지 않고 locally-verified gate로 제한한다.
- [[raw/blog-topics/ci-gate-wiring-vs-policy-ownership-2026-06-20]]: release-blocking 여부를 정하는 gate wiring과 scanner/threshold를 정하는 policy ownership을 분리하는 글감. `always()` fan-in과 delegated-pending gate의 실제 차단 검증은 별도 확인 대상이다.
- [[raw/blog-topics/digest-first-java-release-pipeline-2026-06-21]]: reproducible JAR, digest-bound SBOM/Cosign/SLSA 검증, rollback manifest를 하나의 release DAG로 묶는 글감. live OIDC/Rekor/GHCR evidence 전까지 production release 성공으로 쓰지 않는다.
- [[raw/blog-topics/gradle9-java21-static-analysis-baseline-2026-06-20]]: Gradle 9 / Java 21 멀티모듈에 Spotless, Checkstyle, SpotBugs, FindSecBugs, ErrorProne을 도입하며 formatter/linter 책임과 BOM classpath 충돌을 다룬 글감. static analysis baseline을 운영 품질 보장처럼 쓰지 않는다.
## 관련 개념
- [[wiki/concepts/devops-ci-supply-chain-dx]]
## Sources
- [[raw/project-notes/ca-skeleton-operational-contract]] §29 Group G-E — DevOps / CI / Supply chain / DX 대안 조사 인덱스 (canonical SSOT).
- [[raw/branch-notes/feature-ci-quality-gates-contract]] — Gate ↔ Branch Contract Test 소유권 매트릭스 20행, flaky quarantine 14d sunset SSOT, OpenAPI snapshot diff, Trivy.
- [[raw/branch-notes/feature-build-release-supply-chain-contract]] — Cosign keyless 의무, SLSA provenance attestation 의무, Gradle dependency-locking, SemVer + git sha suffix, reproducibility.
- [[raw/branch-notes/feature-dependency-vulnerability-management-contract]] — dependency security gate portability parent branch
- [[raw/blog-topics/gitea-act-dependency-security-gate-portability-2026-07-02]] — Gitea/act dependency security gate portability 블로그 글감 raw seed
- [[raw/branch-notes/feature-developer-experience-contract]] — `./gradlew bootstrap` 5단계, Temurin 21 LTS, Testcontainers integration, markdown-link-check.
- [[raw/blog-topics/five-stage-local-bootstrap-contract-2026-06-24]] — five-stage local bootstrap 블로그 글감 raw seed
- [[raw/blog-topics/trivy-suppression-governance-static-gate-2026-06-20]] — Trivy suppression governance static gate 블로그 글감 raw seed
- [[raw/blog-topics/ci-gate-wiring-vs-policy-ownership-2026-06-20]] — CI gate wiring vs policy ownership 블로그 글감 raw seed
- [[raw/blog-topics/digest-first-java-release-pipeline-2026-06-21]] — digest-first Java release pipeline 블로그 글감 raw seed
- [[raw/blog-topics/gradle9-java21-static-analysis-baseline-2026-06-20]] — Gradle 9 / Java 21 static analysis baseline 블로그 글감 raw seed
## Cluster / 묶음
<!-- GENERATED: derived-blogs:start -->
- [[wiki/blog/ca-tmpl-devops-ci-supply-chain-dx-2026-07-02]]
<!-- GENERATED: derived-blogs:end -->
@@ -0,0 +1,121 @@
---
title: ca-tmpl - Idempotency Key 결정 (triple scope + 24h TTL)
source_type: project
status: verified
confidence: high
tags: [ca-tmpl, idempotency, api-design, actually-implemented, locally-verified]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
---
# ca-tmpl - Idempotency Key 결정 (triple scope + 24h TTL)
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념은 [[wiki/concepts/idempotency-key-design]] 참조.
## 프로젝트 컨텍스트
ca-tmpl skeleton 프로젝트의 API contract 설계 트랙 중 하나로 진행한 idempotency key 정책 결정입니다. 다음 형태를 contract 문서에 명시했습니다.
- key shape: `(authenticatedPrincipal, idempotencyKey, useCaseName)` triple scope
- 저장소: DB table (Redis/in-memory가 아님)
- TTL: 24h
- 동시 도착 시: 200ms in-flight wait → 그래도 in-flight면 HTTP `409`
- 같은 key + 다른 body fingerprint: HTTP `422`
**현재 단계: C2 구현 + 로컬 검증 완료.** 2026-07-02 기준 `/home/donghyeon/workspace/ca-tmpl/src`의 실제 코드와 `./gradlew check` 결과를 대조했다. application-core executor, web helper/codec, RDBMS store, PostgreSQL unique constraint contract가 존재한다. 운영 배포 검증은 없다.
## 실제 구현 내용 (`actually-implemented`)
- `application-core``IdempotencyExecutor`, `IdempotencyStorePort`, `IdempotencyRecord`, `RequestFingerprint`, mismatch/in-flight 예외가 구현되어 있다.
- `adapter-web``IdempotencyKeySupport``JsonIdempotentResponseCodec`이 있어 HTTP header/principal/use case scope와 저장 응답 codec을 연결한다.
- `adapter-persistence-rdbms``IdempotencyStoreAdapter`, `IdempotencyRecordEntity`, `IdempotencyRecordJpaRepository`, `IdempotencyReaper`가 구현되어 있다.
- `adapter-persistence-postgresql``V1__idempotency_record.sql`이 DB schema와 unique scope를 소유한다.
- `app-bootstrap``IdempotencyConfig` / `IdempotencySettings`가 store, executor, reaper 설정을 배선한다.
## 로컬/dev 검증 (`locally-verified`)
- `./gradlew check` 통과(2026-07-02, `BUILD SUCCESSFUL`, 114 tasks).
- `IdempotencyExecutorTest`, `RequestFingerprintTest`, `IdempotencyScopeTest`가 executor/mismatch/scope 동작을 검증한다.
- `IdempotencyStoreAdapterTest`, `IdempotencyReaperTest`가 RDBMS adapter와 TTL cleanup을 검증한다.
- `IdempotencyKeySupportTest`, `IdempotencyExceptionMappingTest`가 web boundary와 error envelope mapping을 검증한다.
- `IdempotencyUniqueScopeContractTest`가 PostgreSQL Testcontainers 기반으로 unique scope contract를 검증한다.
## 운영 검증 (`prod-verified`)
없음. 운영 환경에 배포된 적이 없습니다.
## 문서/계획만 존재 (`documented-only` / `planned`)
이 섹션은 구현된 contract의 정책 경계와 아직 과장하면 안 되는 부분을 분리한다.
### Key shape / TTL / 저장소 (canonical §29 Topic 5)
- `(authenticatedPrincipal, idempotencyKey, useCaseName)` triple로 endpoint dimension을 scope에 포함.
- TTL 24h. Stripe v1 minimum과 동일하고, 조사한 reference 중 가장 짧은 축.
- 저장소는 DB table (Redis 단독 의존 회피). Brandur Postgres 패턴의 변형.
- in-flight 처리: 200ms wait 후에도 충돌이면 `409`.
- body fingerprint mismatch: `422`.
### 8종 reference 비교 후 triple 채택
- **검토 대안**: Stripe v1 pair / Stripe v2 triple / Square body-field / PayPal `PayPal-Request-Id` 45일 / Toss 4-tuple 15일 / AWS Lambda Powertools content-hash / GitHub no-dedup / Brandur Postgres lock.
- **채택 근거 (설계 시점)**:
- storage 비용 — 24h TTL이 PayPal 45일·Toss 15일·Stripe v2 30일 대비 가장 짧음.
- key 추측 공격면 — TTL 짧을수록 노출 window 감소.
- endpoint dimension 보강 — Stripe v1 pair의 cross-use-case 충돌 위험 회피.
- URL/method를 scope에서 제외해 (Toss 4-tuple과 달리) HTTP path version migration에 강함.
### 409 vs 422 응답 코드 분리
- `409 Conflict` — 동일 key의 in-flight 충돌 (200ms wait 후에도 원본 미완료).
- `422 Unprocessable Entity` — 동일 key + 다른 body fingerprint (클라이언트 버그 신호).
- IETF draft가 in-flight를 `409`로, fingerprint mismatch를 `422`로 권고(SHOULD)한 라인을 ca-tmpl 응답 코드에 그대로 반영.
IETF draft의 in-flight `409`, fingerprint mismatch `422` 권고는 project policy와 구현에 반영되어 있다. 단 200ms wait 값은 부하 측정 기반 튜닝값이 아니라 ca-tmpl 기본 정책값이다.
## 면접에서 말할 수 있는 범위
- **자신 있게 답할 수 있음**
- "왜 `useCaseName`을 scope에 넣었나" — Stripe v1 pair의 cross-use-case 충돌 회피.
- "왜 TTL 24h인가" — storage 비용·공격면 vs long-running retry window의 trade-off, 짧은 쪽 선택 이유.
- "200ms wait의 의미" — 즉시 `409`로 끊지 않고 client retry 친화적으로 hybrid 처리한 이유.
- "409 vs 422 분리 의도" — in-flight 충돌과 fingerprint mismatch가 클라이언트에게 다른 신호임을 코드로 구분.
- **적당히 답할 수 있음**
- "IETF Idempotency-Key draft와의 정합성" — `SHOULD` 라인은 따랐으나 draft 단계임을 명시.
- **답하면 안 됨 (모른다고 해야 함)**
- "idempotency executor/storage/web helper를 구현했고 로컬 테스트로 검증했다" — 가능. 단 운영 배포 경험은 없음.
- "동시성 부하 테스트로 200ms wait 값을 튜닝했다" — ❌. 측정값 없음.
- "운영에서 422 / 409 비율이 어땠다" — ❌. 운영 배포 자체가 없음.
## 과장 금지 지점
- "ca-tmpl triple이 Stripe pair보다 무조건 안전" — ❌. **v1 pair 한정** 비교. Stripe v2 triple과는 사실상 동급.
- "IETF Idempotency-Key spec을 완전히 준수한다" — ❌. draft 단계이며, 200ms wait는 draft의 "즉시 409" 권고와 deviation.
- "24h TTL이 업계 표준" — ❌. Stripe v1 최소값과 일치할 뿐, 다른 reference는 모두 더 김.
- "8종을 벤치마크해 채택했다" — ❌. **문서 비교**이지 측정 비교가 아님.
- "구현했다 / 로컬 테스트로 검증했다"는 가능. "운영에서 검증했다 / 부하로 튜닝했다"는 금지.
### Blog-topic ingest: application-layer idempotency executor (2026-07-02)
[[raw/blog-topics/idempotency-executor-application-layer-clean-architecture-2026-06-09]] 는 idempotency를 framework middleware가 아니라 application-layer executor와 storage port로 두고, rate-limit은 presentation interceptor가 소유하도록 분리한 글감이다.
- **canonical 반영 범위**: triple scope/TTL/409/422 결정 문서에 layer ownership 글감을 연결했다.
- **blogify 전 조건**: 충족. 이 문서는 2026-07-02 기준 코드와 `./gradlew check`로 검증됨.
- **블로그 전 과장 방지**: IETF draft의 즉시 409 권고와 ca-tmpl의 200ms wait deviation을 분리한다.
## 관련 개념
- [[wiki/concepts/idempotency-key-design]]
## Sources
- [[raw/project-notes/ca-skeleton-operational-contract]] §29 Topic 5
- [[raw/branch-notes/feature-rate-limit-idempotency-contract]]
- [[raw/branch-notes/feature-api-contract-baseline]]
- [[raw/blog-topics/idempotency-executor-application-layer-clean-architecture-2026-06-09]] — application-layer idempotency executor 블로그 글감 raw seed.
## Cluster / 묶음
<!-- GENERATED: derived-blogs:start -->
- [[wiki/blog/ca-tmpl-idempotency-key-design-2026-07-02]]
<!-- GENERATED: derived-blogs:end -->
@@ -0,0 +1,75 @@
---
title: ca-tmpl - Knowledge Capture Workflow 결정
source_type: project
status: verified
confidence: medium
tags: [ca-tmpl, workflow, documentation, agent-workflow, documented-only]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
---
# ca-tmpl - Knowledge Capture Workflow 결정
> Layer: `wiki/projects/` — ca-tmpl 작업 종료 조건에 지식 캡처를 포함한 workflow 결정. 블로그/면접 파생은 이 canonical을 review/verify한 뒤 진행한다.
## 프로젝트 컨텍스트
ca-tmpl 작업에서는 비자명한 구현이 끝난 뒤 코드만 남고, 왜 그렇게 했는지/어떤 오류를 겪었는지/면접과 블로그로 옮길 만한 학습이 무엇인지가 채팅 로그에 흩어지는 문제가 있었다. 이를 줄이기 위해 branch-note, error note, interview prep, blog-topic을 작업 종료 흐름의 일부로 기록하는 workflow를 repo-local rule로 둔 결정이 있다.
## 실제 구현 내용 (`actually-implemented`)
없음. 이 문서가 기록하는 것은 runtime 기능이나 애플리케이션 코드가 아니라 workflow rule과 문서화 결정이다. verified 범위도 애플리케이션 동작이 아니라 repo-local documentation workflow에 한정한다.
## 로컬/dev 검증 (`locally-verified`)
부분적이다. `feature-application-port-usecase-contract` 등 일부 branch에서 branch-note 갱신과 derived raw note 생성이 실제로 수행된 사례가 있고, 이번 `raw/blog-topics` 59개 ingest batch도 workflow의 raw→canonical 승격 사례다. 다만 자동 강제 장치가 아니라 agent workflow rule에 의존한다.
## 운영 검증 (`prod-verified`)
없음. 운영 시스템 기능이 아니며 prod verification 대상이 아니다.
## 문서/계획만 존재 (`documented-only` / `planned`)
- non-trivial 구현 종료 조건에 LLM Wiki capture를 포함한다.
- 캡처 단위는 `raw/branch-notes/`, `raw/errors/`, `raw/interviews/`, `raw/blog-topics/`로 나눈다.
- canonical(`wiki/concepts/`, `wiki/projects/`)과 derived(`wiki/blog/`, `wiki/interview/`, `wiki/portfolio/`)는 명시 요청과 게이트를 거친다.
- derived raw note는 `## Parent`로 branch-note를 가리키고, branch-note는 `## Cluster`에서 되돌아 링크한다.
- 자동 강제(git hook/CI)는 아직 없다.
## 면접에서 말할 수 있는 범위
- 자신 있게: 구현 종료 조건에 decision/error/interview/blog-topic capture를 포함한 이유와 raw/canonical/derived 계층 분리.
- 적당히: agent workflow rule만으로 누락을 줄이는 방식의 장단점.
- 답하면 안 됨: CI나 git hook으로 자동 강제했다고 말하면 안 된다.
## 과장 금지 지점
- "자동으로 캡처된다" → 금지. 현재는 documented workflow rule이며 runtime/CI enforcement가 아니다.
- "모든 branch에서 누락 없이 동작했다" → 금지. 사례는 누적 중이다.
- "raw에서 바로 blog를 만든다" → 금지. blog는 canonical 경유 후 파생한다.
### Blog-topic ingest: post-implementation-knowledge-capture-workflow (2026-07-02)
[[raw/blog-topics/post-implementation-knowledge-capture-workflow-2026-05-28]] 는 구현 완료 조건에 branch-note와 파생 raw note 캡처를 포함하는 workflow를 글감으로 풀기 위한 raw seed다.
- **canonical 반영 범위**: ca-tmpl 작업 종료 조건과 LLM Wiki capture workflow 결정으로 연결했다.
- **blogify 전 조건**: 충족. 이 문서는 workflow 적용 사례와 자동 강제 부재를 분리해 verified로 승격했다.
- **블로그 전 과장 방지**: documented workflow rule을 자동화된 enforcement처럼 쓰지 않는다.
## 관련 개념
- [[wiki/concepts/clean-architecture-package-layout]]
## Sources
- [[raw/blog-topics/post-implementation-knowledge-capture-workflow-2026-05-28]] — knowledge capture workflow 블로그 글감 raw seed.
- [[raw/branch-notes/feature-architecture-enforcement-rules]] — workflow rule 반영 결정.
- [[raw/branch-notes/feature-application-port-usecase-contract]] — workflow 적용 사례.
- [[raw/errors/apply-patch-auto-approval-rejected-2026-05-28]] — workflow 문서 패치 중 도구 차단 사례.
- [[raw/interviews/post-implementation-knowledge-capture]] — 같은 작업에서 파생된 면접 질문.
## Cluster / 묶음
<!-- GENERATED: derived-blogs:start -->
- [[wiki/blog/ca-tmpl-knowledge-capture-workflow-2026-07-02]]
<!-- GENERATED: derived-blogs:end -->
@@ -0,0 +1,125 @@
---
title: ca-tmpl - Multi-tenancy 결정 (opt-in shared DB + tenant_id)
source_type: project
status: verified
confidence: high
tags: [ca-tmpl, multi-tenancy, saas, actually-implemented, locally-verified, documented-only]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
---
# ca-tmpl - Multi-tenancy 결정 (opt-in shared DB + tenant_id)
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념은 [[wiki/concepts/multi-tenancy-isolation-patterns]] 참조.
## 프로젝트 컨텍스트
ca-tmpl(Clean Architecture skeleton)에서 multi-tenancy를 어떻게 다룰지 정한 결정 문서다. baseline은 다음 조합이다.
- **opt-in**: `APP_TENANT_ENABLED=true`일 때만 tenant 로직 활성. single-tenant deployment에서는 비활성화하여 skeleton 적용 범위를 넓힘.
- **shared DB + `tenant_id` column (ULID)**: AWS Pool 모델 / Hibernate DISCRIMINATOR 전략에 해당.
- **Tenant resolution**: JWT claim 우선, `X-Tenant-Id` header는 **admin only(`CROSS_TENANT_ADMIN` capability 보유자)** 에 한해 허용.
- B2B 초기 단계(tenant 수 수십~수백 단위) 가정. isolation 비용 대비 운영 단순성 우선.
**현재 진행 상태**: tenant-aware registry/runbook/capability/idempotency scope 일부 구현 + repository tenant filter는 planned. 본 문서는 구현된 tenant support surface와 아직 없는 storage isolation enforcement를 분리한다.
## 실제 구현 내용 (`actually-implemented`)
- `docs/registries/env-keys.yaml``APP_TENANT_ENABLED`, `docs/registries/headers.yaml``X-Tenant-Id`, `docs/registries/capabilities.yaml``CROSS_TENANT_ADMIN`, `docs/registries/error-codes.yaml`에 tenant error code가 존재한다.
- `docs/runbooks/authz-tenant-mismatch.md`, `docs/runbooks/authz-cross-tenant-violation.md`가 cross-tenant incident response stub을 제공한다.
- `AuthorizationPrincipal`, `RequiresPermission`, `AuthorizationPort`, role/permission registry와 `AuthorizationContractTest`가 capability 기반 authorization foundation을 제공한다.
- `IdempotencyScope``IdempotencyKeySupport`는 tenant-aware scope를 표현할 수 있다.
- repository-level tenant predicate 강제, tenant resolver filter, storage isolation은 아직 구현되지 않았다.
## 로컬/dev 검증 (`locally-verified`)
- `./gradlew check` 통과(2026-07-02, `BUILD SUCCESSFUL`, 114 tasks).
- `AuthorizationContractTest`, `IdempotencyScopeTest`, `IdempotencyKeySupportTest`, registry governance tests가 tenant/capability/registry surface 일부를 검증한다.
- repository tenant filter와 cross-tenant E2E isolation은 검증되지 않았다.
## 운영 검증 (`prod-verified`)
**없음.** ca-tmpl은 skeleton이며 prod 배포 이력 없음.
## 문서/계획만 존재 (`documented-only` / `planned`)
### Tenant resolution + isolation 정책 (partially-implemented)
- JWT claim 우선 → admin only `X-Tenant-Id` header fallback → 해석 실패 시 reject.
- repository 진입점에서 tenant filter 강제(`CROSS_TENANT_ADMIN` capability 없이는 모든 query에 `tenant_id` predicate).
- **status: partially-implemented** — registry/header/capability/runbook/idempotency scope는 존재하지만 repository filter와 tenant resolver filter는 planned.
### 6종 대안 검토 → Pool 채택
검토한 6가지와 채택/기각 사유:
| 대안 | 분류 | 채택 여부 | 사유 |
|------|------|-----------|------|
| **shared DB + tenant_id** (ULID) | AWS Pool / Hibernate DISCRIMINATOR | **채택** | B2B 초기, tenant 수 수십~수백 예상. 운영 단순성. |
| subdomain-based | resolution-only | 기각 | wildcard DNS/TLS·subdomain takeover·local dev 비용. resolution은 isolation을 보장하지 않음. |
| JWT claim only (storage 분리 없음) | resolution-only | 기각 | claim 검증 누락 시 cross-tenant leak. storage layer 강제 필요. |
| schema-per-tenant | Hibernate SCHEMA | 기각 | catalog bloat·`search_path` 전환 plan cache 무효화·HikariCP 설계 복잡. 초기 단계 ROI 부정. |
| db-per-tenant | AWS Silo | 기각 | 운영 비용 폭증(마이그레이션·백업·connection pool 폭발). 규제 요구 부재. |
| hybrid (Azure Deployment Stamps / AWS Bridge) | mixed | 기각 | 운영 복잡도 최고. PMF 이후 단계 검토 사항. |
근거: ca-tmpl은 skeleton이며 초기 도입 대상은 B2B 소규모 SaaS. 결정은 verified 되었지만 storage isolation enforcement는 아직 planned다.
### Migration trigger 3가지 정의
shared DB → schema/db-per-tenant로 전환을 검토할 조건:
1. **규제**: 금융·의료(HIPAA·FedRAMP·data residency) isolation 강제.
2. **규모**: tenant 수 hundreds 도달 + 단일 row 수 억대 진입(noisy neighbor·index 비용 임계).
3. **상품 tier**: enterprise tier 등장으로 isolation을 가격에 반영해야 할 때.
**status: documented-only** — migration trigger는 아직 관측 지표/자동 경보로 구현되지 않았다.
### `CROSS_TENANT_ADMIN` capability 정의
- admin/support 운영 동선용. 보유자만 `X-Tenant-Id` header로 tenant 전환 가능.
- 일반 사용자 경로는 JWT claim 단독, header 무시.
- **status: partially-implemented** — capability vocabulary와 authorization foundation은 존재하지만, repository tenant filter와 admin tenant switching E2E는 미구현.
## 면접에서 말할 수 있는 범위
### 자신 있게
- "Pool / Silo / Bridge의 차이와 각각의 비용·isolation trade-off."
- "tenant resolution에서 JWT claim과 `X-Tenant-Id` header의 trust 차이, header를 admin only로 제한하는 이유."
- "shared DB → 격리 강화 모델로 가는 **migration trigger 3가지**(규제 / 규모 / enterprise tier)."
### 적당히
- ULID vs UUID 선택 이유(정렬 가능성·index locality·시간 정보 노출 trade-off).
- Hibernate multi-tenancy strategy(DATABASE / SCHEMA / DISCRIMINATOR) 차이와 `CurrentTenantIdentifierResolver` 동작 개요.
### 답하면 안 됨 (모른다고 해야 함)
- "tenant 격리를 어떻게 **측정**했는가" — 측정·테스트 부재.
- "cross-tenant 침해 시도/penetration test 결과" — 수행 안 함.
- "schema-per-tenant 운영 경험" — 검토만 했고 운영해 본 적 없음.
- "실제 tenant 수, row 수, 성능 지표" — skeleton에 데이터 없음.
## 과장 금지 지점
- "shared DB + tenant_id가 항상 우월하다" → 금지. 규제 산업(HIPAA·금융·data residency)에서는 Silo가 사실상 강제다. 본 선택은 **B2B 초기 단계 가정에 종속된 결정**이라는 점을 함께 말할 것.
- **Stripe/Citus schema-per-tenant 한계치 단언 금지** — 정확 인용 wording이 미완(raw 자료 `needs-confirmation`). "수백~수천 단위에서 catalog overhead가 보고된다" 정도로 출처와 함께만 언급.
- "ca-tmpl에 multi-tenancy를 **완성했다**" → 금지. tenant-aware registry/capability/scope foundation은 구현됐지만, repository-level tenant filter와 E2E isolation은 planned다.
- "JWT claim만 검증하면 안전하다" → 금지. repository 레벨 tenant filter가 별도로 필요하다.
- "Atlassian이 그렇게 하니까 best practice" → 금지. company-tech-blog는 관점이지 공식 기준이 아니다.
## 관련 개념
- [[wiki/concepts/multi-tenancy-isolation-patterns]] — Pool/Silo/Bridge, Hibernate strategy, resolution 방식 공식 기준
## Sources
- [[raw/project-notes/ca-skeleton-operational-contract]] — §10 Repository Access Permission Contract, §29 Topic 6 Multi-tenancy Isolation
- [[raw/branch-notes/feature-tenant-context-policy]] — tenant resolution(JWT > header admin only) SSOT
- [[raw/branch-notes/feature-repository-access-permission-contract]] — `CROSS_TENANT_ADMIN` capability, repository 레벨 tenant filter contract
## Cluster / 묶음
<!-- GENERATED: derived-blogs:start -->
- [[wiki/blog/ca-tmpl-multi-tenancy-isolation-patterns-2026-07-02]]
<!-- GENERATED: derived-blogs:end -->
@@ -0,0 +1,143 @@
---
title: ca-tmpl - Observability Baseline 결정 (Log + Metric + Trace + Runbook)
source_type: project
status: verified
confidence: high
tags: [ca-tmpl, observability, logging, metrics, tracing, actually-implemented, locally-verified, documented-only]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
---
# ca-tmpl - Observability Baseline 결정 (Log + Metric + Trace + Runbook)
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념은 [[wiki/concepts/observability-log-metric-trace-runbook]] 참조.
## 프로젝트 컨텍스트
ca-tmpl은 Clean Architecture 기반 백엔드 skeleton 프로젝트다. **운영 계약(operational contract)** 단계에서 observability 4축 — **structured JSON Logback + masking, Micrometer dot.case + Prometheus, W3C tracecontext 전파, `runbook://` scheme** — 을 baseline으로 묶어 단일 운영 계약으로 통합하는 결정을 했다.
현재 진행 상태:
- **Phase E (운영 계약 설계) 완료** — 4 sub-topic 각각의 branch-note가 작성되어 대안 검토와 결정 근거가 정리됨.
- **C2 (구현 단계) 미진입** — 어떤 Logback config, Micrometer registry, Sleuth/Tracing 설정 파일도 작성되지 않음.
**정정 (2026-06-04):** "문서/설계 산출물만 존재"는 더 이상 정확하지 않다. 4축(Log/Metric/Trace/Runbook)의 *full* 기능은 여전히 미구현이지만, foundation branch가 소유한 **observability 토대 slice**(MDC snake_case 표준 + 응답-로그 상관 + inbound 헤더 sanitization)는 2026-06-01 Phase C2로 코드화·로컬 검증됐다(아래 actually-implemented / locally-verified).
> **Ground-truth 대조 (2026-06-04, ca-tmpl @0c996fc "운영 에러 관측성 foundation 계약 구현", HEAD `db61075`에서도 존재 확인):** 아래 foundation slice 파일·MDC 키는 ca-tmpl 코드 실측으로 일치 확인. `MdcKeys.java`는 `request_id`/`trace_id`/`span_id`/`correlation_id`/`user_principal` snake_case 상수를 정의하고 **`tenant_id`는 아직 없음**(tenant-context-policy branch 도착 시 조건부). `RequestLoggingFilter.java`는 `adapter-web/filter/`에 위치(observability 패키지 아님). 패키지 root는 `dev.caskeleton.*`, 모듈 경로는 `src/<module>/src/main/java/dev/caskeleton/...`. stale 추출 잔재(`com.example.blog`/`sample-ticket`)는 없음 — sample 모듈은 `sample-portfolio`.
## 실제 구현 내용 (`actually-implemented`)
> 4축(Log/Metric/Trace/Runbook)의 *전체* 구현은 여전히 각 owner branch의 미진입 작업이다(아래 documented-only). 단 **foundation branch([[raw/branch-notes/feature-operational-error-observability-foundation]])가 소유한 observability 토대 slice**는 2026-06-01 Phase C2로 코드화됨 (grep 확인):
- `adapter-web/observability/MdcKeys.java` — MDC key snake_case 상수 표준(`request_id`/`trace_id`/`span_id`/`correlation_id`).
- `app-bootstrap/logback-spring.xml` — snake_case `includeMdcKeyName` 설정.
- `adapter-web/observability/HeaderSanitizer.java` — inbound 헤더 CR/LF·제어문자 strip + length cap (log injection / CWE-117 방어).
- `adapter-web/filter/RequestLoggingFilter.java``X-Request-Id`/`X-Correlation-Id` 수신·생성·MDC set/clear + sanitization 적용.
- `shared-contract/response/ResponseMeta.java` + `adapter-web/observability/ResponseMetaFactory.java``request_id`/`trace_id`/`correlation_id` MDC → `meta.{requestId,traceId,correlationId}` 응답 투영.
이것은 log/metric/trace 신호의 *식별자 토대*(MDC 표준 + 응답-로그 상관 + 헤더 sanitization)이며, 4축의 full 기능(JSON masking/sampling, Prometheus, trace sampling, runbook)은 포함하지 않는다.
## 로컬/dev 검증 (`locally-verified`)
위 foundation slice는 `./gradlew check` (전 모듈 test + ArchUnit) **BUILD SUCCESSFUL** (2026-06-01)로 검증됨 — `HeaderSanitizerTest`/`ResponseMetaFactoryTest`/`RequestLoggingFilterTest`/`EnvelopeMetaIntegrationTest`. **4축 full 구현(masking 효과·alert 발화·trace sampling·runbook link-check)의 로컬 검증은 여전히 없음.**
## 운영 검증 (`prod-verified`)
**없음.** 운영 환경 검증 없음. alert 발화·trace sampling 결과·log masking 효과 측정 모두 없음.
## 문서/계획만 존재 (`documented-only` / `planned`)
운영 계약 문서(canonical §8, §29 G-A)와 4 branch-note에 다음이 **설계 수준**으로만 기록되어 있다.
### Log (`documented-only`)
- Structured JSON Logback 스키마: `@timestamp`, `log.level`, `service.name`, `trace.id` 등 ECS 호환 필드.
- Masking 항목: PII / credential / token 필드 발신지 마스킹 정책.
- Sampling: prod 환경 일반 로그 10% sampling, error/warn 전량 sampling.
- 대안 검토: ECS vs OTel log signal vs Loki 자체 schema — branch-note `feature-log-management-contract`.
### Metric (`documented-only`)
- Micrometer dot.case naming + Prometheus exporter(`_` 변환).
- Alert severity: P1 / P2 / P3 분리.
- Cardinality bound: `userId`·`requestId` 등 unbounded label 금지.
- 대안 검토: SLO burn-rate vs traffic-based threshold — branch-note `feature-metrics-alerting-contract`.
### Trace (`documented-only`)
- W3C traceparent 헤더 채택 (B3 미채택).
- Micrometer Tracing + OTel bridge 방향.
- Sampling: prod 1% head-based.
- 대안 검토: head-based vs tail-based, B3 hybrid 변환 — branch-note `feature-distributed-tracing-contract`.
### Runbook (`documented-only`)
- `runbook://` 내부 URI scheme + repo path 매핑.
- Alert payload에 runbook URL 박아넣는 계약.
- Link-check smoke test로 drift 방지.
- 대안 검토: Confluence runbook vs runbook-as-code vs PagerDuty Runbook Automation — branch-note `feature-operational-runbook-contract`.
**모두 문서/설계 단계.** Logback config, Micrometer registry 설정, Sleuth/Tracing 설정 파일, runbook markdown 본문 모두 미작성.
## 면접에서 말할 수 있는 범위
### 자신 있게 답할 수 있는 (개념·설계 의도)
- Observability 3 pillars(log/metric/trace) 정의와 각 신호가 대체 불가능한 이유.
- W3C tracecontext vs B3 propagation 차이 (128-bit vs 64-bit trace-id, 변환 한계).
- Log masking 범위와 발신지 마스킹이 필요한 이유.
- Runbook drift 방지를 위해 `runbook://` scheme + git 관리 + link-check를 선택한 설계 근거.
### 적당히 답할 수 있는
- SLO burn-rate alert vs traffic-based threshold의 트레이드오프 — SLO 합의 전 단계에서 traffic-based가 합리적인 이유.
- Head-based vs tail-based sampling의 비용/정확도 trade-off.
### 답하면 안 되는 (실측·운영 경험 없음)
- "Grafana 대시보드를 운영하면서…" — 대시보드 미구축.
- "trace 1% sampling 결과 rare-error 누락률은…" — 측정 없음.
- "incident response를 실제로 수행하면서…" — 운영 경험 없음.
- "log masking으로 PII 사고를 막은 사례" — 미적용.
## 과장 금지 지점
- **"OpenTelemetry로 통일했으니 vendor-neutral이다"** → ❌. instrument 표준은 중립이지만 backend(Datadog/Tempo/Jaeger) 선택 시 lock-in 잔존.
- **"SLO burn-rate alert를 채택했다"** → ❌. 설계 단계에서 검토만 했고, SLO 자체가 합의되지 않은 단계에선 traffic-based가 더 운영 가능함을 결론으로 두었다.
- **"운영 환경에서 alert가 동작하는 것을 확인했다"** → ❌. 미구현. alert rule 파일조차 없음.
- **"structured logging을 적용해 PII를 안전하게 처리하고 있다"** → ❌. masking 정책은 문서에만 존재.
- **"trace sampling 1%로 비용을 최적화했다"** → ❌. 적용 결과 없음. 설계상 채택만.
- **"runbook을 자동화했다"** → ❌. `runbook://` scheme은 정의했으나 자동 실행 도구 미도입.
### Blog-topic ingest: w3c-traceparent-fork-activated-seam (2026-07-02)
[[raw/blog-topics/w3c-traceparent-fork-activated-seam-2026-07-02]] 는 OTel SDK를 붙이기 전에 W3C `traceparent` 계약을 먼저 둘 때 생기는 seam과 landmine을 블로그로 풀기 위한 raw seed다.
- **canonical 반영 범위**: distributed tracing contract의 W3C trace context seam을 observability canonical에 연결했다.
- **source-backed 로 말할 부분**: W3C Trace Context와 OTel 관련 설명은 공식 raw source claim으로 확인된 범위에 한정한다.
- **블로그 전 과장 방지**: end-to-end distributed tracing 구현 완료처럼 쓰지 않고, seam/contract 중심으로 제한한다.
- [[raw/blog-topics/runbook-coverage-junit-contract-test-2026-07-02]]: 운영 runbook 링크가 문서에만 존재하는지, error registry와 연결되어 release를 막을 수 있는지 JUnit contract test로 확인하는 글감. runbook 내용 품질까지 자동 보장한다고 쓰지 않고 coverage/link existence 검증으로 제한한다.
- [[raw/blog-topics/logback-layer1-secret-masking-json-vs-pattern-2026-06-14]]: Logback `%replace`가 JSON encoder 경로를 우회하는 문제와 JSON decorator / pattern converter가 같은 masking regex SSOT를 공유해야 하는 이유를 다루는 글감. regex masking이 모든 secret 형태를 잡는다고 쓰지 않는다.
- [[raw/blog-topics/spring-async-taskdecorator-bounded-executor-saturation-shutdown-2026-06-13]]: bounded executor, `TaskDecorator` MDC/context propagation, saturation metric, graceful shutdown budget을 하나의 background job 운영 계약으로 다루는 글감. 숫자값을 부하테스트 튜닝 결과처럼 쓰지 않는다.
## 관련 개념
- [[wiki/concepts/observability-log-metric-trace-runbook]]
## Sources
- [[raw/project-notes/ca-skeleton-operational-contract]] — ca-tmpl 운영 계약 SSOT (§8 Structured Log, §29 G-A).
- [[raw/branch-notes/feature-log-management-contract]] — JSON Logback + masking + trace 상관관계 계약.
- [[raw/branch-notes/feature-metrics-alerting-contract]] — Micrometer dot.case + alert severity 계약.
- [[raw/branch-notes/feature-distributed-tracing-contract]] — W3C tracecontext 전파 + sampling 계약.
- [[raw/blog-topics/w3c-traceparent-fork-activated-seam-2026-07-02]] — W3C traceparent seam 블로그 글감 raw seed
- [[raw/branch-notes/feature-operational-runbook-contract]] — `runbook://` scheme + link-check 계약.
- [[raw/blog-topics/runbook-coverage-junit-contract-test-2026-07-02]] — runbook coverage JUnit contract test 블로그 글감 raw seed.
- [[raw/blog-topics/logback-layer1-secret-masking-json-vs-pattern-2026-06-14]] — Logback JSON vs pattern masking 블로그 글감 raw seed
- [[raw/blog-topics/spring-async-taskdecorator-bounded-executor-saturation-shutdown-2026-06-13]] — async executor context/saturation/shutdown 블로그 글감 raw seed
## Cluster / 묶음
<!-- GENERATED: derived-blogs:start -->
- [[wiki/blog/ca-tmpl-observability-log-metric-trace-runbook-2026-07-02]]
<!-- GENERATED: derived-blogs:end -->
@@ -0,0 +1,134 @@
---
title: ca-tmpl - Privacy / File / Domain Modeling 결정
source_type: project
status: verified
confidence: high
tags: [ca-tmpl, privacy, gdpr, file-upload, ddd, actually-implemented, locally-verified, documented-only]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
---
# ca-tmpl - Privacy / File / Domain Modeling 결정
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념 / 공식 기준 / 트레이드오프는 [[wiki/concepts/privacy-file-domain-modeling]] 참조.
## 프로젝트 컨텍스트
**ca-tmpl skeleton** — 도메인 로직을 얹기 전 단계의 운영/보안/도메인 계약을 사전에 고정하기 위한 Spring Boot 기반 Clean Architecture 템플릿 프로젝트. 2026-07-02 기준 일부 privacy/domain guardrail은 코드화됐고, file handling/DSR/backup erasure는 여전히 계획 또는 문서 단계다.
본 문서는 Phase E Group G-J에서 결정된 3축 — **(1) Privacy / Retention**, **(2) File / Resource Handling**, **(3) Domain Modeling Guardrails** — 의 ca-tmpl 적용 결정사항을 정리한다.
핵심 결정값 요약:
- **Privacy**: 30/180/365일 3-tier log retention, HMAC-SHA-256 + 90일 salt rotation pseudonymization, DSR SLA 30일/14일(intake → execution), `is_sample` 컬럼 기반 sample 데이터 분리.
- **File**: app 10MB / global 12MB / gateway 20MB 3-layer size limit, content-type allowlist 6종(image/jpeg, image/png, image/gif, application/pdf, text/plain, application/zip 등), temp orphan 1h cleanup sweeper, ICAP antivirus gateway 기본값.
- **Domain Modeling**: VO private constructor + factory method, aggregate root mutator non-public(package-private/protected), domain layer logger ban(ArchUnit forbidden import), safe reason enum, invariant in constructor, Vernon Option A(ORM 외부 매핑) 채택.
## 실제 구현 내용 (`actually-implemented`)
- `adapter-identifier``HmacUserPrincipalPseudonymizer``app-bootstrap``PseudonymizationConfig`, `PrivacySettings`가 user principal pseudonymization 기반을 제공한다.
- `RequestLoggingFilter`가 raw principal이 아니라 pseudonymized principal을 MDC에 넣는 흐름을 갖는다.
- `docs/registries/env-keys.yaml`, `secrets-classification.yaml`, `mdc-keys.yaml`, `capabilities.yaml`, `error-codes.yaml`에 privacy/file/domain 관련 registry row가 존재한다.
- domain purity, logger ban, forbidden imports, aggregate boundary guard는 `CleanArchitectureTest` 계열과 domain/sample tests에서 일부 검증된다.
- file upload handler, DSR workflow, backup cryptographic erasure, ICAP integration은 아직 구현되지 않았다.
## 로컬/dev 검증 (`locally-verified`)
- `./gradlew check` 통과(2026-07-02, `BUILD SUCCESSFUL`, 114 tasks).
- `HmacUserPrincipalPseudonymizerTest`, `PseudonymizationConfigTest`, `PrivacySettingsTest`, `RequestLoggingFilterTest`가 pseudonymization/logging path를 검증한다.
- `CleanArchitectureTest`와 domain/sample tests가 domain forbidden import와 invariant 일부를 검증한다.
- file upload, DSR, backup erasure는 로컬 검증되지 않았다.
## 운영 검증 (`prod-verified`)
**없음.** ca-tmpl skeleton 자체가 운영 환경에 배포된 적 없음.
## 문서/계획만 존재 (`documented-only` / `planned`)
다음 항목은 구현된 privacy/domain guardrail과 아직 문서/계획으로 남은 file/DSR/backup 영역을 분리한다.
### Privacy (canonical §19)
- log retention 30/180/365일 3-tier 분류 (info / warn-business / audit-security)
- HMAC-SHA-256 + 90일 salt rotation pseudonymization (PII column 대상)
- DSR SLA: intake → identity verification → execution 30일, internal execution 14일
- `is_sample` boolean column으로 sample / production 데이터 분리, retention job exemption
- backup retention: cryptographic erase 방식 채택 의도(per-principal envelope key는 **미결정**, 후속 보강 후보)
- per-principal envelope key 패턴 선택 (2026-05-22) — **needs-confirmation**, Phase C2 결정 보류. (a) per-principal CMK / (b) per-principal DEK + master CMK envelope / (c) tenant-level CMK 3종 후보. 근거: [[raw/official-docs/gdpr-cryptographic-erasure-envelope-key-pattern]]. HMAC + 90d salt rotation은 forward security만 제공하므로 backup의 Art.17 단건 erasure에는 별도 envelope key 구조가 필요함.
### File (canonical §29 H, branch-notes)
- size limit 3-layer: Spring multipart 10MB (app envelope error) / reverse proxy 12MB / gateway WAF 20MB raw 413
- content-type allowlist 6종 + endpoint별 재검증
- temp file > 1h not closed → orphan 판정, sweeper가 삭제 (tus resumable session과 별도 threshold 필요성은 문서화만)
- ICAP gateway antivirus(ClamAV 등) 기본값, in-app daemon 채택 X
- direct S3 presigned URL은 **검토만 완료**, 채택 미정
### Domain Modeling (canonical §29 I-J, branch-notes)
- Value Object: private constructor + static factory method, invariant in constructor 강제
- Aggregate root: mutator를 public 금지(package-private/protected만 허용)
- Domain layer logger ban: `org.slf4j.Logger`, `java.util.logging.*`, HTTP type, `@Entity`, `@Service` 등 forbidden import — ArchUnit 테스트로 강제할 계획
- safe reason enum (도메인 거부 사유 noun 형태 enum)
- Vernon Option A(ORM 매핑을 domain 외부 mapper/persistence layer에서 수행) 채택, Option B(JPA direct annotation in domain) 거절
- CQRS / event sourcing **미채택**, "domain event = transport-free fact" 정의만 차용
### 검토했으나 채택하지 않은 대안 (concept 참조)
3 sub-topic 각각 5종 이상의 대안을 검토 — 자세한 trade-off는 [[wiki/concepts/privacy-file-domain-modeling]] §"한계 / 주의점".
- Privacy: PII detection SaaS(AWS Macie / OneTrust / TrustArc) — vendor 종속으로 skeleton 기본값 부적절.
- File: in-app ClamAV daemon, direct S3 presigned URL only, tus resumable 표준 채택, magic-byte sniffing only — 각각 trade-off로 인해 채택 보류.
- Domain: Anemic model, Pure DDD aggregates(over-engineering), Event sourcing, JPA direct annotation(Option B), Functional domain modeling(Scala/F#) — 모두 검토 후 거절.
## 면접에서 말할 수 있는 범위
### 자신 있게 답할 수 있는 질문
- GDPR Art.17 backup erasure 처리 방식과 cryptographic erase의 의미
- HMAC + salt rotation의 의미와 anonymization이 아닌 이유 (brute-force 가능 input space에서 tokenization 우위)
- file size 3-layer(app / proxy / gateway)의 defense-in-depth 의미와 trade-off
- ICAP gateway의 한계 (HTTPS E2E TLS 환경에서 평문 검사 불가)
- VO private constructor + factory method 이유 (invariant 보장, 잘못된 인스턴스 생성 차단)
- ORM 외부 매핑(Vernon Option A) vs JPA direct annotation(Option B) trade-off
### 적당히 답할 수 있는 질문
- Vernon Option A vs B의 코드량 / 학습 비용 trade-off 비교
- NIST SP 800-88 cryptographic erase의 backup 적용 메커니즘 (per-principal envelope key 구조 필요성 정도까지)
- DSR 운영 패턴 일반론 (intake → verification → scope → execution → audit)
### 답하면 안 되는 질문 (모른다고 해야 함)
- "GDPR DSR 요청을 실제로 처리해본 경험이 있는가?" → **없음.** ca-tmpl은 skeleton 단계, 운영 데이터 없음.
- "ICAP scan을 운영 환경에서 운영해본 경험은?" → **없음.** 설계 / 문서 단계.
- "domain event sourcing을 도입한 경험은?" → **없음.** ca-tmpl은 event sourcing **미채택**, transport-free fact 정의만 차용.
- "per-principal envelope key를 적용한 경험은?" → **없음.** 후속 보강 후보로 문서화만 됨.
## 과장 금지 지점
외부 설명(면접 / 이력서 / README / 블로그)에서 사실보다 부풀려지기 쉬운 표현들.
- **"HMAC + salt rotation으로 anonymization을 적용했다"** → **부정확 (가장 흔한 과장)**. ENISA / IAPP 기준 명확히 **pseudonymization**이지 anonymization이 아니다. brute-force 가능 input(휴대폰 11자리 등)에서는 tokenization이 우위인 구간이 존재하며, 무엇보다 HMAC + salt rotation은 **forward security만** 제공한다 — rotation 이전에 기록된 backup 안의 hash는 그대로 잔존하므로 GDPR Art.17 backup erasure 수단으로 사용할 수 없다. backup 단건 erasure는 별도의 per-principal envelope key 구조(NIST SP 800-88 § 2.5 CE)가 필요하며 ca-tmpl은 **미결정** 상태이다.
- **"ICAP antivirus gateway로 모든 위협을 막는다"** → 부정확. HTTPS end-to-end TLS 환경에서 gateway가 payload를 평문으로 보지 못하는 한계가 있음. post-upload async scan 보완 필요.
- **"ca-tmpl은 pure DDD 기반이다"** → 부정확. Vernon Option A(ORM 외부 매핑)만 차용했으며, CQRS / event sourcing은 미채택. "transport-free domain event 정의만 차용"이 정확한 표현.
- **"per-principal envelope key 구조를 적용해 GDPR Art.17 backup erasure를 완전 처리한다"** → 부정확. 후속 보강 후보로 **미결정** 상태. 현재는 cryptographic erase 의도만 문서화됨.
- **"DSR SLA 30일은 GDPR 요구치다"** → 부정확. GDPR Art.12는 "원칙적으로 1개월(연장 시 +2개월)"이며 30/14일은 ca-tmpl 내부 운영 결정값.
- **"retention job / file upload handler / DSR workflow를 구현했다"** → 거짓. pseudonymization/logging guard와 domain guardrail 일부는 구현됐지만, 이 운영 기능들은 문서/계획 단계다.
## 관련 개념
- [[wiki/concepts/privacy-file-domain-modeling]]
## Sources
- [[raw/project-notes/ca-skeleton-operational-contract]] §19 Domain Application Readiness Contract, §29 G-J 외부 근거 / 대안 조사
- [[raw/branch-notes/feature-data-retention-privacy-contract]] — log retention by profile, HMAC pseudonymization, DSR SLA, backup retention 결정
- [[raw/branch-notes/feature-file-resource-handling-contract]] — upload size 3-layer, content-type allowlist, temp file cleanup, antivirus position 결정
- [[raw/branch-notes/feature-domain-modeling-guardrails]] — VO private constructor, aggregate mutator non-public, domain forbidden import 결정
## Cluster / 묶음
<!-- GENERATED: derived-blogs:start -->
- [[wiki/blog/ca-tmpl-privacy-file-domain-modeling-2026-07-02]]
<!-- GENERATED: derived-blogs:end -->
@@ -0,0 +1,156 @@
---
title: ca-tmpl - Resource Identifier (ULID) 결정
source_type: project
status: verified
confidence: high
tags: [ca-skeleton, resource-identifier, ulid, actually-implemented]
related_projects: [ca-skeleton, ca-tmpl]
last_reviewed: 2026-07-02
---
# ca-tmpl - Resource Identifier (ULID) 결정
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념(ULID vs UUIDv7 vs UUIDv4 vs Snowflake tradeoff)은 [[wiki/concepts/resource-identifier-format]] 참조.
## 프로젝트 컨텍스트
- **프로젝트**: ca-tmpl — Clean Architecture 기반 백엔드 skeleton 템플릿.
- **목표**: resource ID 형식을 **ULID** (26-char Crockford base32, time-ordered) 로 못박고, ID 가 URL / log / DB primary key / cache key / idempotency / multi-tenancy / privacy 에 미치는 계약을 한 곳에서 결정. ID 형식은 *한 번 노출되면 되돌리기 어렵다* (`/v1/worklogs/<id>` 가 client SDK + log + DB schema + cache key + FK 에 박힘) 는 인식에서 skeleton default 를 future-safe 한 선택으로 고정하는 것이 동기.
- **결정 SSOT**: [[raw/branch-notes/feature-resource-identifier-contract]] (D1~D19 + Decision Evidence Map). 본 문서는 그 중 *실제 코드로 구현된* 사실만 추출한다.
- **진행 단계**: **코드 구현 + 로컬 검증 완료.** `feature-resource-identifier-contract` 브랜치에서 domain VO + port, ULID adapter, persistence mapping, web serializer, ArchUnit rule, 단위 테스트까지 작성되어 코드 베이스에 존재한다. 운영 배포 / 실 DB 통합 테스트 / 측정값은 없다.
- **이 브랜치가 신설한 모듈**: `adapter-identifier` (비-IO 인프라 능력 어댑터). `feature-skeleton-package-blueprint-contract` 가 9번째 모듈로 OUT_OF_BRANCH_SCOPE 표시했던 영역이 본 브랜치의 산출물이다.
## Ground-truth 대조 (2026-06-04, ca-tmpl @c36b764 "ULID 리소스 식별자 계약 구현 및 adapter-identifier 모듈 생성")
`/home/donghyeon/workspace/ca-tmpl` 코드를 직접 읽어 검증한 사실 (현재 checkout HEAD = `db61075`, 본 브랜치 구현 커밋 `c36b764` 는 history 에 존재하며 식별자 코드는 HEAD 에 그대로 잔존):
- 패키지 root 는 `dev.caskeleton.*`.
- **신규 모듈 `adapter-identifier`** 실재 — `src/adapter-identifier/` (Gradle `settings.gradle:13 include 'adapter-identifier'`). `domain-core` 에만 의존하고 `ulid-creator:5.2.3` 를 implementation 으로 선언.
- domain port + marker (`ResourceId`, `IdFactory`) 는 `src/domain-core/.../domain/identifier/` 에 실재.
- sample 도메인 VO + port + adapter (`WorkLogId`, `WorkLogIdFactory`, `UlidWorkLogIdFactory`) 는 `sample-portfolio` 에 실재.
- ArchUnit rule 4개 (`no_long_id_pk` / `no_uuid_random_in_controller` / `no_math_random_for_id` / `no_varchar_255_for_id_column`) + `identifier_adapter_does_not_depend_on_other_adapters_or_bootstrap``src/app-bootstrap/.../architecture/CleanArchitectureTest.java` 에 실재. 5번째 후보 `no_find_by_id_without_tenant` 는 코드에 **없음** (브랜치 결정대로 `feature-tenant-context-policy` 로 이관).
- `./gradlew :adapter-identifier:test :sample-portfolio:test :app-bootstrap:test --tests '*CleanArchitectureTest' --tests '*ArchitectureViolationFixtureTest' --tests '*WorkLogId*' --tests '*UlidCodec*' --tests '*UlidWorkLogIdFactory*' --tests '*WorkLogIdSerializer*'` → BUILD SUCCESSFUL (2026-06-04 재실행, `src/` working dir 기준).
## 실제 구현 내용 (`actually-implemented`)
ca-tmpl 코드에서 직접 확인한 산출물:
**domain-core (재사용 가능 port + marker, `dev.caskeleton.domain.identifier.*`)**
- `ResourceId.java``ResourceId<SELF extends ResourceId<SELF>>` marker interface. `String value()` (canonical 26-char uppercase Crockford base32 ULID) 1 메서드. **의도적으로 `non-sealed`**`permits WorkLogId` 를 쓰면 `domain-core``sample-portfolio` 를 import 하게 되어 모듈 의존 규칙 위반. closed-set 보장은 `no_long_id_pk` ArchUnit rule (빌드타임) 로 대체 (Javadoc 에 사유 명시).
- `IdFactory.java``IdFactory<T extends ResourceId<?>>` domain port. `T newId()` 1 메서드. ID minting *책임* 은 도메인 port 에, 실제 *생성 행위* 는 infrastructure adapter 에 둔다 (D4/D5).
**sample-portfolio domain (`dev.caskeleton.sample.portfolio.domain.worklog.*`)**
- `WorkLogId.java``record WorkLogId(String value) implements ResourceId<WorkLogId>`. compact constructor 에서 `^[0-9A-HJKMNP-TV-Z]{26}$` regex 로 검증 (I/L/O/U 제외 Crockford base32). 도메인 안에 ULID 라이브러리 의존 없음 (canonical form 검증만).
- `WorkLogIdFactory.java``interface WorkLogIdFactory extends IdFactory<WorkLogId>` (type-specific port specialization).
- `WorkLog.java``create(WorkLogId id, ...)` / `rehydrate(WorkLogId id, ...)`. 도메인이 자기 ID 를 `UUID.randomUUID()` 로 self-mint 하지 않음 (id 는 factory 가 만들어 use case 가 주입, D4/D5).
**adapter-identifier (신규 모듈, `dev.caskeleton.adapter.identifier.*`)**
- `UlidCodec.java` — production-level, 도메인 무관 ULID 변환 유틸 (final, private ctor). `normalize(String)` (D3: case-insensitive 입력 → canonical uppercase 26-char, `Ulid.from(in.toUpperCase(Locale.ROOT)).toString()`), `toUuid(String)`, `fromUuid(UUID)` (D10: ULID ↔ 128-bit UUID).
- `package-info.java` — 이 모듈이 *non-IO 인프라 능력 어댑터* 임을 문서화. `adapter-outbound` ("external HTTP/messaging/cache/notifications") 와 구분되는 이유 = ULID 라이브러리 래퍼는 외부 시스템 통합점이 아니라 인프라 능력이라는 것.
- `build.gradle``domain-core` + `ulid-creator:5.2.3` 만 의존.
**sample-portfolio adapter (ULID 생성/직렬화/영속화)**
- `adapter/identifier/UlidWorkLogIdFactory.java``@Component implements WorkLogIdFactory`. `WorkLogId.of(UlidCreator.getMonotonicUlid().toString())`. monotonic factory (동일 ms 내 단조 증가, ULID-C5) + 내부 `SecureRandom` (D9). 주석에 "이 sample 에서 `UlidCreator` 직접 호출 허용은 여기뿐" 명시.
- `adapter/persistence/entity/WorkLogEntity.java``@Id @Column(name="id", columnDefinition="uuid", nullable=false, updatable=false) @JdbcTypeCode(SqlTypes.UUID) private UUID id`. PostgreSQL 16 native `uuid` (16-byte binary), `varchar(26/36)` 아님 (D10). tenant 컬럼은 주석으로만 (deferred to `feature-tenant-context-policy`).
- `adapter/persistence/mapper/WorkLogPersistenceMapper.java``Ulid.from(id.value()).toUuid()` / `Ulid.from(uuid).toString()` 로 ULID↔UUID 변환. persistence 가 `adapter-outbound`(및 `UlidCodec`) 에 의존하지 못하는 boundary rule 때문에 `Ulid` 를 직접 사용 (주석 명시).
- `adapter/web/json/WorkLogIdSerializer.java``@JsonComponent extends JsonSerializer<WorkLogId>`. record 기본 `{"value":"..."}` 대신 bare ULID 문자열로 직렬화 (D6 NO typed prefix, §5).
**app-bootstrap ArchUnit fitness functions** (`architecture/CleanArchitectureTest.java`, D17 결정 SSOT = 본 브랜치):
- `no_long_id_pk``..domain..` 패키지의 `id` 필드는 `ResourceId` 구현체여야 함 (`Long`/`int` 금지). JPA entity (`..adapter.persistence..`) 의 `@Id UUID id` 는 D10 정합으로 검사 대상 제외.
- `no_uuid_random_in_controller``..adapter.web..controller..` + `..application..``UUID.randomUUID()` / `com.github.f4b6a3.ulid.UlidCreator` 직접 호출 금지 (factory 주입 강제). web filter 의 trace-id 생성은 의도적으로 scope 밖 (D18).
- `no_math_random_for_id``dev.caskeleton..` 전역에서 `Math.random()` 금지 (CSPRNG 아님, D9).
- `no_varchar_255_for_id_column``@Column` 매핑된 `id` 필드는 명시적 `columnDefinition`(예: `"uuid"`) 또는 비-default length 의무. `haveExplicitColumnLength()` custom `ArchCondition` 으로 검사 (`columnDefinition` 비어있지 않거나 `length != 255`).
- `identifier_adapter_does_not_depend_on_other_adapters_or_bootstrap``adapter-identifier` 가 sibling adapter / persistence / bootstrap 에 손대지 못하도록 격리 (§4 taxonomy).
## 로컬/dev 검증 (`locally-verified`)
- 단위 테스트 PASS (2026-06-04 재실행, BUILD SUCCESSFUL):
- `WorkLogIdTest` — regex 검증 (valid / invalid / I·L·O·U 포함 거부).
- `UlidCodecTest``normalize`/`toUuid`/`fromUuid` round-trip + case-insensitive 입력.
- `UlidWorkLogIdFactoryTest` — monotonic 생성, 형식 적합.
- `WorkLogIdSerializerTest` — bare ULID 문자열 직렬화.
- `WorkLogPersistenceMapperTest`, `WorkLogRepositoryAdapterTest`, `WorkLogControllerWireTest` — ULID↔UUID 매핑 + D3 정규화 wire 경로.
- ArchUnit fitness function PASS: `CleanArchitectureTest` (위 5개 rule) + `ArchitectureViolationFixtureTest` (의도된 위반 fixture 를 실제로 잡아냄).
- 검증 범위는 **JVM 단위 테스트 + 정적 분석까지**. 실 PostgreSQL 16 connection 으로 `uuid` 컬럼 insert/index 동작을 검증한 통합 테스트는 **없음** (아래 planned).
## 운영 검증 (`prod-verified`)
**없음.** 운영 환경에 배포된 적이 없다. 측정값 / 인시던트 / 릴리즈 노트 / 벤치마크 어느 것도 없다.
## 문서/계획만 존재 (`documented-only` / `planned`)
다음은 설계/문서/위임 상태이며 **면접에서 "구현했다 / 검증했다"고 말하면 안 된다**.
- **CUID2 override (D7)**: privacy-sensitive 도메인용 timestamp-leak-free 대안. 코드에 없음 (`documented-only`).
- **constant-time 비교 미적용 (D9)**: 공개 resource id 는 표준 record `equals` 사용. constant-time 비교는 *비밀값* 영역이라 의도적으로 적용 안 함 (`feature-security-operational-baseline` SSOT).
- **multi-tenancy ID 정합 (D13)**: ID 자체에 tenant 인코딩 거부만 결정. `TenantId` VO / `tenant` 테이블 / composite index / `findByIdAndTenant` / tenant-scoped ArchUnit rule (`no_find_by_id_without_tenant`) 은 코드에 **없음**`feature-tenant-context-policy` (예정) 위임. `WorkLogEntity` 의 tenant 컬럼은 주석으로만 존재 (`documented-only`).
- **Idempotency-Key 처리 (D14)**: resource ID(ULID) 와 idempotency key(UUID v4 client-generated) 의 *형식 분리만* 명시. TTL 저장소 / fingerprint 비교 / 422 응답은 `feature-rate-limit-idempotency-contract` 위임 (`planned`).
- **log scrubber `UlidLogScrubber` (D8/§7)**: user-linked ID redaction 코드 미작성. `feature-log-management-contract` 위임 (`documented-only`).
- **PostgreSQL 16 `uuid` index locality 벤치마크 (D10)**: ULID time-ordered insert 의 BTREE page split 완화 정량 측정 없음 (`planned`, UNSUPPORTED_IMPL_DECISION).
- **dual column (internal BIGINT + external ULID) override (D11)**: skeleton 은 external-only. dual 은 prod-grade 도메인 권고 수준 (`documented-only`).
- **OpenAPI 3.1 `pattern` schema (§5)**: 브랜치 노트의 reference fragment. 실제 generated OpenAPI 문서로의 반영은 본 문서 추출 범위에서 코드로 확인하지 않음 (`documented-only`).
## 면접에서 말할 수 있는 범위
### 자신 있게 답할 수 있는 질문
- 왜 skeleton default resource ID 로 **ULID** 를 골랐는가 — UUID v4(DB B-tree 단편화), Snowflake(worker_id 외부 조율), sequential(enumeration) 거부 + UUID v7 은 Java 21 `java.util.UUID` native 미지원이라 3rd-party 의존이면 ULID 가 URL UX(26 vs 36자) + 라이브러리 성숙도 우위. (실제 `WorkLogId` record + `UlidWorkLogIdFactory` 로 구현.)
- ID 생성 책임을 어느 계층에 뒀는가 — domain port (`IdFactory`/`WorkLogIdFactory`) 가 책임을 소유하고, infrastructure adapter (`UlidWorkLogIdFactory`) 가 실제 생성, application use case 가 주입·orchestration. 도메인이 `UUID.randomUUID()` 로 self-mint 하지 않도록 ArchUnit 으로 강제.
- ULID 를 DB 에 어떻게 저장했는가 — PostgreSQL 16 native `uuid` 타입(16-byte binary), `@JdbcTypeCode(SqlTypes.UUID)` + `columnDefinition="uuid"`, `Ulid.from(...).toUuid()` 변환. `varchar(26/36)` 를 거부한 이유.
- ArchUnit 4개 rule (`no_long_id_pk` / `no_uuid_random_in_controller` / `no_math_random_for_id` / `no_varchar_255_for_id_column`) 로 어떤 anti-pattern 을 빌드타임에 차단했는가, 위반 fixture 로 rule 동작을 보증한 방법.
- `adapter-identifier` 모듈을 왜 신설했는가 — ULID 라이브러리 래퍼는 외부 시스템 통합(`adapter-outbound`)이 아니라 *non-IO 인프라 능력*이라 의미가 다름. 모듈 격리도 ArchUnit 으로 강제.
- `ResourceId` 를 왜 `sealed` 가 아닌 `non-sealed` 로 뒀는가 — `permits WorkLogId``domain-core``sample-portfolio` 역의존을 만들기 때문. closed-set 보장은 `no_long_id_pk` 로 대체.
- Crockford base32 가 I/L/O/U 를 제외하는 이유 + 그래서 ULID 의 URL/case 정책 (canonical uppercase 출력 + case-insensitive 입력 정규화).
### 적당히 답할 수 있는 질문
- ULID vs UUID v7 vs Snowflake 의 일반적 trade-off (정렬성, timestamp leak, 길이, 조율 부담). (개념 수준 — [[wiki/concepts/resource-identifier-format]].)
- time-ordered ID 가 B-tree index locality 에 유리한 *원리* (Percona MySQL 벤치마크는 parallel evidence 로만 인용 — PostgreSQL HEAP/MVCC 에 직접 적용 불가).
- timestamp leak 가 *user-facing* ID 에서 실질 문제인 이유 + CUID2 같은 완화 옵션.
### 답하면 안 되는 질문 (모른다고 해야 함)
- "PostgreSQL 에서 ULID time-ordered insert 가 random UUID 대비 page split 을 줄이는 걸 측정했는가?" → **측정 안 함. 벤치마크 없음.**
- "실 DB 로 `uuid` 컬럼 insert/조회 통합 테스트를 했는가?" → **안 함. JVM 단위 테스트 + 정적 분석까지.**
- "운영에서 인시던트나 성능 사례가 있었는가?" → **운영 배포 없음.**
- "multi-tenant 격리(`WHERE tenant_id = X AND id = Y`)를 구현했는가?" → **안 함. ID 에 tenant 인코딩 거부만 결정, 모델은 `feature-tenant-context-policy` 위임.**
- "Idempotency-Key 처리를 구현했는가?" → **형식 분리만 명시. 처리는 `feature-rate-limit-idempotency-contract` 위임.**
## 과장 금지 지점
- **"운영에서 검증했다 / prod 에서 돌고 있다" → 금지.** 로컬 단위 테스트 + 정적 분석까지가 검증 범위.
- **"ULID 가 PostgreSQL index 성능을 개선하는 걸 측정했다" → 금지.** Percona 벤치마크는 MySQL InnoDB 기준 *parallel evidence* 일 뿐, PostgreSQL 측정값 없음.
- **"multi-tenancy 를 구현했다" → 금지.** ID 형식이 tenant 와 충돌하지 않도록 보장만 했고, tenant 모델은 미구현.
- **"ULID 가 무조건 UUID 보다 우월하다" → 금지.** timestamp leak(privacy), 비표준(IETF 아님), 라이브러리 의존이라는 trade-off 존재. UUID v7 native 가 되는 stack 이면 결정이 달라질 수 있음.
- **"typed prefix(`tk_`)를 안 쓴 게 정답이다" → 단정 금지.** Stripe 는 prefix 를 쓴다 — skeleton 의 bare ULID 는 lock-in 회피를 택한 *하나의* 선택.
### Blog-topic ingest: resource identifier 묶음 (2026-07-02)
아래 raw seed들은 resource identifier canonical에 연결했다.
- [[raw/blog-topics/ulid-crockford-base32-excluded-letters-2026-06-01]]: ULID의 Crockford base32 charset과 예시 값 검증을 다룬다. **주의**: "대충 26자 영숫자"가 아니라 동일 parser로 fixture/example을 교차검증해야 한다.
- [[raw/blog-topics/identifier-governance-rule-scoping-by-id-kind-2026-06-01]]: resource id, trace id, session id, idempotency key, api key처럼 ID 종류별 생성 주체·형식·수명이 다르므로 ArchUnit governance rule도 ID kind별로 scope해야 한다는 글감이다. **주의**: 모든 `UUID.randomUUID()` 금지가 항상 옳다고 쓰지 않는다.
## 관련 개념
- [[wiki/concepts/resource-identifier-format]] — ULID vs UUIDv7 vs UUIDv4 vs Snowflake 일반 trade-off, sortability, timestamp leakage, Crockford base32.
## Sources
- [[raw/branch-notes/feature-resource-identifier-contract]] — D1~D19 + Decision Evidence Map + 구현 결과(2026-06-01). 본 문서의 결정 SSOT.
- [[raw/blog-topics/ulid-crockford-base32-excluded-letters-2026-06-01]] — ULID/Crockford base32 예시 검증 블로그 글감 raw seed
- [[raw/blog-topics/identifier-governance-rule-scoping-by-id-kind-2026-06-01]] — identifier governance scope 블로그 글감 raw seed
- [[raw/project-notes/ca-skeleton-operational-contract]] — §17 Sample Domain Fixture (`WorkLogId`), §22 Sample-portfolio Contract Matrix, §34 Stack Commitment (Java 21 / Spring Boot 3.5.14 / PostgreSQL 16 / archunit-junit5 1.3.0).
- [[raw/branch-notes/feature-skeleton-package-blueprint-contract]] — `adapter-identifier` 를 9번째 모듈로 OUT_OF_BRANCH_SCOPE 표시 (본 브랜치가 그 모듈을 신설).
- ca-tmpl @c36b764 코드 (ground-truth): `src/domain-core/.../domain/identifier/{ResourceId,IdFactory}.java`, `src/adapter-identifier/.../adapter/identifier/{UlidCodec,package-info}.java`, `src/sample-portfolio/.../domain/worklog/{WorkLogId,WorkLogIdFactory}.java`, `.../adapter/identifier/UlidWorkLogIdFactory.java`, `.../adapter/persistence/entity/WorkLogEntity.java`, `.../adapter/persistence/mapper/WorkLogPersistenceMapper.java`, `.../adapter/web/json/WorkLogIdSerializer.java`, `src/app-bootstrap/.../architecture/CleanArchitectureTest.java`.
## Cluster / 묶음
<!-- GENERATED: derived-blogs:start -->
- [[wiki/blog/ca-tmpl-resource-identifier-format-2026-07-02]]
<!-- GENERATED: derived-blogs:end -->
@@ -0,0 +1,149 @@
---
title: ca-tmpl - Runtime / Container / Health / Migration 결정
source_type: project
status: verified
confidence: high
tags: [ca-tmpl, runtime, container, kubernetes, flyway, actually-implemented, locally-verified]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
---
# ca-tmpl - Runtime / Container / Health / Migration 결정
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념은 [[wiki/concepts/runtime-container-health-migration]] 참조.
## 프로젝트 컨텍스트
ca-tmpl은 Clean Architecture 기반 백엔드 skeleton 프로젝트다. **운영 계약(operational contract)** 단계에서 JVM 서비스의 runtime baseline을 세 축으로 묶어 단일 운영 계약으로 통합하는 결정을 했다.
- **Container**: Eclipse Temurin (Adoptium) **JRE slim** + JVM ergonomics (`-XX:MaxRAMPercentage=75`, `-XX:+UseContainerSupport`, `-XX:+ExitOnOutOfMemoryError`) + **UTC / UTF-8** locale 고정.
- **Health**: Kubernetes Probes 3종 (**liveness / readiness / startup**) 분리 + Spring Boot Actuator Health Groups + **Required / Optional Dependency Matrix**.
- **Migration**: Flyway forward-only migration을 **readiness-gated**로 실행 + `repair` / `baselineOnMigrate` / `outOfOrder` 모두 **prod forbidden** + 표준 startup **exit code 78 / 70 / 71 / 72** 매핑.
- **Graceful shutdown budget**: app **20s** + preStop **5s** + terminationGracePeriodSeconds **35s** (10s margin).
현재 진행 상태:
- **C2 구현 + 로컬 검증 완료** — `src/Dockerfile`, runtime safety/startup validators, Actuator health group contract, Flyway prod safety guard, startup exit-code mapping, graceful shutdown settings가 코드화되어 있다. 2026-07-02 `./gradlew check` 통과로 로컬 검증했다. Kubernetes manifest와 운영 rolling update 실측은 없다.
문서/설계 산출물만 존재하며, 코드/검증/측정은 전무하다.
## 실제 구현 내용 (`actually-implemented`)
- `src/Dockerfile`과 runtime settings가 존재한다.
- `app-bootstrap``RuntimeSafetyConfig`, `RuntimeSafetySettings`, `RuntimeNumericBoundsValidator`, `OpenInViewSafetyValidator`, `HikariPoolConstraintValidator`가 startup/runtime guard를 구성한다.
- `MigrationStartupConfig`, `MigrationStartupRunner`, `FlywayProdSafetyValidator`, `StartupFailureException`, `StartupErrorCode`가 migration readiness-gate와 exit code mapping을 구성한다.
- `adapter-web``HealthcheckController``app-bootstrap` health group contract가 liveness/readiness/startup 구분을 검증한다.
## 로컬/dev 검증 (`locally-verified`)
- `./gradlew check` 통과(2026-07-02, `BUILD SUCCESSFUL`, 114 tasks).
- `RuntimeHealthLifecycleContractTest`가 liveness/readiness/startup group membership과 readiness-vs-liveness 분리를 검증한다.
- `FlywayProdSafetyValidatorTest`, `MigrationStartupRunnerTest`, `RequiredEnvironmentValidatorTest`, `StartupErrorCodeTest`, `StartupFailureExceptionTest`가 migration/startup failure contract와 exit code를 검증한다.
- `ContainerRuntimeOomContractTest`, `OperationalContractRuntimeTest`, `RuntimeNumericBoundsValidatorTest`, `OpenInViewSafetyValidatorTest`, `HikariPoolConstraintValidatorTest`가 runtime/container/startup guard를 검증한다.
## 운영 검증 (`prod-verified`)
**없음.** 운영 환경 검증 없음. K8s rolling update 동작, graceful shutdown 실측, cold start latency, migration 실패 복구 모두 없음.
## 문서/계획만 존재 (`documented-only` / `planned`)
운영 계약 문서(canonical §15 Runtime / Lifecycle Contract, §29 G-D)와 3 branch-note에 다음이 **설계 수준**으로만 기록되어 있다.
### Container (`actually-implemented` / `locally-verified`)
- Base image: **Eclipse Temurin JRE slim** 채택 (distroless / alpine+musl / GraalVM native 대안 모두 검토 후 보류).
- JVM ergonomics: `-XX:+UseContainerSupport` (JDK 10+ default 명시) + `-XX:MaxRAMPercentage=75` + `-XX:+ExitOnOutOfMemoryError` + `-XX:HeapDumpPath`.
- Locale: **UTC / UTF-8** 고정 (env `TZ=UTC`, `LANG=C.UTF-8`).
- 대안 검토: distroless (보안 surface 축소 vs 디버깅 손실), alpine+musl (image 크기 vs glibc 호환성 risk), GraalVM native-image (cold start vs reflection/peak throughput 손실, hybrid 사례) — branch-note `feature-container-runtime-contract`.
### Health (`actually-implemented` / `locally-verified`)
- K8s Probes **3 endpoint 분리**: `/livez`, `/readyz`, `/startupz` (or Actuator `/actuator/health/{liveness,readiness}` + startup variant).
- Spring Boot Actuator Health Groups로 endpoint별 HealthIndicator set 분리.
- **Required / Optional Dependency Matrix** — DB·broker는 readiness 필수, 외부 cache는 optional 등 dependency 범위 명시.
- 대안 검토: single `/health` (legacy, restart loop risk), custom HealthIndicator only (default readiness 외부 dependency 미포함), Istio mesh-based health (sidecar/app 구분 모호) — branch-note `feature-runtime-health-lifecycle-contract`.
### Migration (`actually-implemented` / `locally-verified`)
- **Flyway forward-only** + **readiness-gated**: migration 완료 전 readiness probe `false`.
- **prod forbidden**: `flyway.repair`, `flyway.baselineOnMigrate`, `flyway.outOfOrder` 모두 prod에서 사용 금지.
- 표준 startup **exit code 매핑** (sysexits.h 관례):
- `78` — config error (env / property 누락·잘못된 값)
- `70` — internal software error (예상 외 application failure)
- `71` — OS error (system call / resource 실패)
- `72` — critical OS file missing
- 대안 검토: Liquibase (DB-agnostic + rollback, XML/YAML verbose), Hibernate `hbm2ddl=update` (anti-pattern), Atlas (declarative, JVM 외부), K8s Init Container (replica race) vs Job + migration lock — branch-note `feature-migration-startup-contract`.
### Graceful Shutdown (`partially-implemented`)
- App SIGTERM 수신 후 in-flight 처리 **20s** + preStop hook **5s** drain + K8s terminationGracePeriodSeconds **35s** (10s margin).
- Spring Boot `server.shutdown=graceful` + `spring.lifecycle.timeout-per-shutdown-phase` 설정 예정.
Kubernetes manifest와 실제 rolling update/drain 실측은 아직 없다. 따라서 local/runtime guard와 운영 가정의 경계를 분리해서 말해야 한다.
## 면접에서 말할 수 있는 범위
### 자신 있게 답할 수 있는 (개념·설계 의도)
- **JRE slim vs distroless** 선택 근거 — 운영/디버깅 친숙도 vs 보안 surface trade-off.
- **liveness / readiness / startup 3 probe 분리** 이유 — single `/health`로 묶으면 dependency 일시 outage가 container restart loop를 유발하고, startup 단계 liveness 오판이 긴 migration/warmup을 죽일 수 있다.
- **Graceful shutdown 단계** — SIGTERM → app drain 20s → preStop 5s → grace 35s. 각 timeout이 sync되지 않으면 SIGKILL로 inflight 요청 유실.
- **Flyway `repair`가 prod에서 위험한 이유** — 실제 schema 변경 없이 metadata만 수정. 공식이 직접 위험성 경고. `baselineOnMigrate`는 누락 migration skip, `outOfOrder`는 협업 일관성 깨짐.
- **Exit code 78/70/71/72 의미** — sysexits.h 관례. config error / internal / OS / critical OS file missing 진단 분리.
### 적당히 답할 수 있는
- **GraalVM native-image trade-off** — cold start/메모리 우위 vs reflection·dynamic proxy build-time metadata 비용, peak throughput 손실. 우아한형제들도 hybrid 채택.
- **`-XX:MaxRAMPercentage=75`** vs 절대값 `-Xmx` — container memory limit 변경에 따라가는 비율 방식이 안전한 이유.
### 답하면 안 되는 (실측·운영 경험 없음)
- "K8s rolling update를 운영하면서…" — 운영 경험 없음.
- "cold start latency를 측정해보니…" — 측정 없음.
- "DB migration이 prod에서 실패해서 복구한 경험" — 없음.
- "liveness probe 오판으로 restart loop가 발생했을 때…" — 운영 incident 없음.
- "graceful shutdown 35s budget이 실제로 충분했다" — 실측 없음.
## 과장 금지 지점
- **"GraalVM native-image가 곧 표준"** → ❌. reflection-heavy 코드와 peak throughput 손실은 실측 trade-off. ca-tmpl은 채택하지 않았고 hybrid 사례만 참조했다.
- **"K8s probe 동작을 운영에서 확인했다"** → ❌. health group contract는 로컬 테스트로 검증했지만 Kubernetes manifest/cluster 검증은 없다.
- **"Flyway readiness-gated migration이 운영에서 동작한다"** → ❌. startup guard와 prod forbidden option은 로컬 테스트로 검증했지만 prod migration 복구 경험은 없다.
- **"graceful shutdown 35s가 충분히 검증되었다"** → ❌. graceful shutdown 설정은 존재하지만 운영 drain 실측은 없다.
- **"distroless가 보안상 우월하다고 채택했다"** → ❌. ca-tmpl은 **JRE slim 채택**. distroless는 대안으로 검토만 했고 디버깅 손실을 이유로 보류.
- **"exit code 78/70/71/72가 표준이다"** → ❌. sysexits.h는 BSD 관례. POSIX 강제 표준 아님. 조직 enum 명시가 필요.
### Blog-topic ingest: runtime 묶음 (2026-07-02)
[[raw/blog-topics/jvm-oom-vs-container-oomkill-exit-137-2026-07-02]] 는 JVM OOM과 container OOMKill이 비슷한 종료 신호로 보일 때 heap dump/native stderr/runtime signal을 어떻게 구분할지 정리하기 위한 raw seed다.
- **canonical 반영 범위**: container/JVM runtime failure 해석을 runtime/container 결정 문서의 blog-topic 후보로 연결했다.
- **blogify 전 조건**: 충족. 이 문서는 2026-07-02 기준 코드와 `./gradlew check`로 검증됨.
- **블로그 전 과장 방지**: Kubernetes 운영 장애 대응 경험처럼 쓰지 않고, local/container evidence와 운영 가정을 분리한다.
- [[raw/blog-topics/spring-actuator-health-probe-group-split-2026-07-02]]: Spring Actuator health group을 liveness/readiness/startup으로 분리하고 startup guard/shutdown lifecycle을 같은 운영 계약으로 보는 글감. Kubernetes end-to-end readiness 보장처럼 쓰지 않는다.
- [[raw/blog-topics/java21-context-propagation-strategy-virtual-threads-2026-07-02]]: Java 21에서 request/security/tenant context를 ThreadLocal, Micrometer Context Propagation, ScopedValue 중 어디까지 다룰지 선택 기준을 정리하는 글감. ScopedValue 채택 경험처럼 쓰지 않고 후보/기준으로 제한한다.
- [[raw/blog-topics/spring-boot-startup-exit-code-propagation-2026-06-10]]: startup failure exit code를 `ExitCodeGenerator`/Spring Boot uncaught exception path와 sysexits 관례로 분리해 설명하는 글감. POSIX 표준처럼 쓰지 않고, ca-tmpl 내부 convention과 local 검증 경계를 구분한다.
- [[raw/blog-topics/spring-async-taskdecorator-bounded-executor-saturation-shutdown-2026-06-13]]: executor await timeout과 app shutdown / Kubernetes grace period의 계층 부등식을 다루는 글감. executor sizing 숫자를 측정값으로 쓰지 않는다.
## 관련 개념
- [[wiki/concepts/runtime-container-health-migration]]
## Sources
- [[raw/project-notes/ca-skeleton-operational-contract]] — ca-tmpl 운영 계약 SSOT (§15 Runtime / Lifecycle Contract, §29 G-D).
- [[raw/branch-notes/feature-container-runtime-contract]] — Temurin JRE slim + JVM ergonomics + UTC/UTF-8 계약.
- [[raw/blog-topics/jvm-oom-vs-container-oomkill-exit-137-2026-07-02]] — JVM OOM vs container OOMKill 블로그 글감 raw seed. canonical 반영 범위: runtime/container failure interpretation + 과장 금지 항목.
- [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] — liveness/readiness/startup 3-endpoint 분리 + Required/Optional Dependency Matrix.
- [[raw/blog-topics/spring-actuator-health-probe-group-split-2026-07-02]] — Actuator health probe group split 블로그 글감 raw seed.
- [[raw/branch-notes/feature-runtime-context-propagation-contract]] — Java 21 context propagation 선택 기준 parent branch.
- [[raw/blog-topics/java21-context-propagation-strategy-virtual-threads-2026-07-02]] — Java 21 context propagation strategy 블로그 글감 raw seed.
- [[raw/branch-notes/feature-migration-startup-contract]] — Flyway readiness-gated + prod forbidden 옵션 + exit code 78/70/71/72 매핑.
- [[raw/blog-topics/spring-boot-startup-exit-code-propagation-2026-06-10]] — startup exit code propagation 블로그 글감 raw seed.
- [[raw/blog-topics/spring-async-taskdecorator-bounded-executor-saturation-shutdown-2026-06-13]] — async executor shutdown budget 블로그 글감 raw seed.
## Cluster / 묶음
<!-- GENERATED: derived-blogs:start -->
- [[wiki/blog/ca-tmpl-runtime-container-health-migration-2026-07-02]]
<!-- GENERATED: derived-blogs:end -->
@@ -0,0 +1,129 @@
---
title: ca-tmpl - Sample Fixture & Adoption 결정
source_type: project
status: verified
confidence: high
tags: [ca-tmpl, sample-fixture, template, adoption, actually-implemented, locally-verified]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
---
# ca-tmpl - Sample Fixture & Adoption 결정
> Layer: `wiki/projects/` — ca-tmpl 프로젝트 내 sample fixture / removal / adoption 결정 사실 기록. 일반 개념은 [[wiki/concepts/sample-fixture-and-adoption]].
## 프로젝트 컨텍스트
- **프로젝트**: ca-tmpl (clean architecture skeleton template repository).
- **범위**: skeleton 운영 계약(envelope / error / capability / transaction / idempotency)을 트리거하기 위한 **sample fixture** 정의와, 실제 도메인을 얹을 때 sample을 production runtime에서 비활성화하면서 운영 계약을 보존하는 **sample-off / adoption** 절차의 결정.
- **현황**: skeleton 설계 단계. canonical operational contract 문서 작성 진행 중.
- sample-ticket 12 scenario matrix + 6-field minimum model + state machine + optimistic lock + idempotency key 결정 완료(문서).
- sample-off profile + production dependency 차단 + dual-mode CI matrix (sample-on / sample-off 둘 다 release-blocking) + multi-module adoption checklist 결정 완료(문서).
- **C2 구현 + 로컬 검증 완료.** `sample-portfolio` module, sample domain/use case/web/persistence tests, `sampleFixture` configuration, `sampleOffTest`, CI sample-off job이 존재한다. 실제 외부 프로젝트 adoption 사례는 없다.
## 실제 구현 내용 (`actually-implemented`)
- `sample-portfolio` module이 template fixture/reference로 유지된다.
- sample domain, use case, web controller, persistence adapter, OpenAPI snapshot, authz/idempotency/outbox 관련 sample tests가 존재한다.
- `app-bootstrap/build.gradle``sampleFixture` configuration과 `sampleOffTest` task가 존재한다.
- `SampleRemovalSmokeContractTest`가 production dependency 차단, `sampleFixture` wiring, `sampleOffTest`, CI workflow sample-off command를 검증한다.
- `.github/workflows/ci-quality-gates.yml``./gradlew :app-bootstrap:sampleOffTest`가 포함된다.
## 로컬/dev 검증 (`locally-verified`)
- `./gradlew check` 통과(2026-07-02, `BUILD SUCCESSFUL`, 114 tasks).
- `:app-bootstrap:sampleOffTest`, `checkstyleSampleOffTest`, `spotbugsSampleOffTest`가 check graph에 포함되어 실행되었다.
- `SampleRemovalSmokeContractTest`가 sample-off classpath에 `sample-portfolio` jar가 없는지 확인한다.
## 운영 검증 (`prod-verified`)
- 없음. ca-tmpl skeleton 자체가 운영 채택 사례가 없으며, sample-on / sample-off CI matrix가 release를 실제로 차단한 사례도 없다.
## 문서/계획만 존재 (`documented-only` / `planned`)
### Sample fixture 결정 (canonical §17, §22)
- **자체 fixture `sample-ticket` 채택.** 5종 대안(Spring Petclinic / RealWorld / Spring Cloud microservices sample / Stripe testmode / no fixture) 검토 후 선택. 근거는 contract 매트릭스 부재(Petclinic / RealWorld), 인프라 과도(Spring Cloud), 도메인 한정 SaaS sandbox(Stripe), 행위 검증 불가(no fixture).
- **sample-ticket 12 scenario matrix** (canonical §22): create / get / list / update / close / reopen / conflict (optimistic lock) / duplicate (idempotency) / not-found / validation-error / forbidden / transactional rollback 흐름. envelope / error code / capability gate / transaction boundary / idempotency key를 트리거하기 위한 시나리오 집합으로 정의.
- **6-field minimum model**: `TicketId`, `TicketTitle`, `TicketStatus`, `TicketVersion`, `TicketOwner`, `IdempotencyKey`. 비즈니스 기능이 아니라 contract trigger에 필요한 최소 필드만.
- **State machine**: `OPEN → IN_PROGRESS → CLOSED`. reopen은 `CLOSED → OPEN` 한정. 상태 전이 위반은 conflict 시나리오로 검증.
- **Optimistic lock**: `TicketVersion` 기반. 동일 ticket에 대한 동시 update에서 conflict scenario 발생.
- **Idempotency key**: `IdempotencyKey` 필드. 동일 key 재요청 시 동일 응답 보장 scenario.
### Sample-off / adoption 결정 (canonical §17, §29 G-H)
- **Sample-off first adoption**:
1. Spring profile (`sample-off`)로 sample bean / route 제외.
2. `sample-ticket` module은 template fixture/reference로 유지하되, production runtime/default profile과 새 도메인은 sample에 의존하지 않음.
fork한 프로젝트에서 sample 코드를 정리하는 것은 선택 사항이며, ca-tmpl 기본 blueprint의 목표는 module 삭제가 아니라 runtime 노출 차단과 의존성 차단이다.
- **Dual-mode CI matrix**: `sample-on` / `sample-off` 두 mode를 **둘 다 release-blocking** 으로 운영. sample-off 상태에서도 envelope / capability / transaction / idempotency 계약이 그대로 유지되는지 회귀 검증.
- **Multi-module adoption checklist**: `feature-domain-feature-onboarding-contract`의 New Domain Module Slice + Read/Write Difference Table을 따른다. 핵심은 `domain-core`, `application-core`, `adapter-*`, `shared-contract`, `app-bootstrap` 경계에 새 도메인을 얹고 `sample-ticket` import 없이 sample-off smoke를 통과하는 것이다.
- **Reference scaffolding 1순위: GitHub Template Repository.** CI/Actions workflow 파일까지 그대로 복제되어 friction이 최저. Spring Initializr / Cookiecutter / degit / Yeoman / Maven archetype / Backstage 비교 결과.
### 미구현 항목 (planned)
- sample-ticket entity / repository / use case 코드.
- 12 scenario contract test suite.
- `sample-off` profile bean 분기 / `sample-ticket` runtime isolation.
- dual-mode CI matrix GitHub Actions workflow.
- sample-off / adoption checklist를 검증하는 e2e flow.
## 면접에서 말할 수 있는 범위
### 자신 있게 답할 수 있는 질문
- sample-portfolio / WorkLog fixture가 어떤 운영 계약(envelope / error / capability / transaction / idempotency / outbox)을 트리거하기 위한 시나리오 집합인가.
- WorkLog sample model이 contract trigger 역할을 하도록 구성된 이유. production feature가 아니라 skeleton verification fixture라는 점.
- dual-mode CI matrix (`sample-on` / `sample-off` 둘 다 release-blocking)가 막으려는 회귀 시나리오가 무엇인가.
- sample-off first adoption이 즉시 코드 삭제보다 어떤 안전성을 더 주는가.
- Spring Petclinic / RealWorld 대신 자체 fixture를 둔 이유. contract 매트릭스 부재 / minimum 위반.
### 적당히 답할 수 있는 질문
- GitHub Template Repository vs Cookiecutter trade-off. friction 최저 모델과 generator 시점 sample-off 모델의 시맨틱 차이.
- Backstage golden path 도입 임계점. service template / scorecard / catalog를 따로 운영할 조직 규모 이후.
### 답하면 안 되는 질문 (모른다고 해야 함)
- sample-portfolio 구현 + 로컬 검증 경험. 가능. 단 외부 프로젝트 adoption 사례나 hosted release 차단 사례로 확대하지 않는다.
- 12 scenario matrix 전체가 hosted CI에서 contract 위반을 잡아낸 사례. 별도 확인 필요.
- adoption checklist를 실제 프로젝트에 적용한 결과 / 도입 시간 측정값. **운영 채택 없음.**
- dual-mode CI matrix가 hosted release를 실제 차단한 사례. workflow는 존재하지만 hosted CI 차단 이력은 별도 확인하지 않았다.
## 과장 금지 지점
- "sample-portfolio가 production 도메인이다" → ❌. **contract 검증 도구(fixture)** 이며 production feature가 아니다.
- "Spring Initializr / Cookiecutter가 ca-tmpl과 동급 alternative다" → ❌. 두 도구 모두 **generator 시점에 sample을 빼는 모델**이라, sample-on / sample-off 둘 다 release-blocking으로 검증하는 ca-tmpl 운영 모델과 시맨틱이 다르다.
- "12 scenario를 모두 검증했다" → ❌. **시나리오 정의만 있고**, scenario test suite은 작성되지 않았다.
- "GitHub Template Repository가 모든 면에서 우월하다" → ❌. friction(초기 복제 마찰) 기준 1순위일 뿐, sample 제거 / adoption checklist / operational contract 보존은 ca-tmpl 측에서 별도로 정의해야 한다.
- "Backstage가 skeleton repo의 상위 호환이다" → ❌. 조직 규모 임계점 이후의 IDP 진입점이며 동일 레이어가 아니다.
- "sample-off가 production runtime 운영 안전성을 보장한다" → ❌. sample-off는 build/test classpath 격리 검증이며 운영 채택 사례는 없다.
### Blog-topic ingest: sample-domain-contract-fixture-clean-architecture (2026-07-02)
[[raw/blog-topics/sample-domain-contract-fixture-clean-architecture-2026-06-10]] 는 Clean Architecture 템플릿의 sample domain을 데모 기능이 아니라 validation, mapper, transaction, response, conflict 계약을 실제 흐름으로 검증하는 fixture로 다루는 글감이다.
- **canonical 반영 범위**: sample fixture/adoption canonical의 blog-topic 후보로 연결했다.
- **blogify 전 조건**: 충족. 이 문서는 2026-07-02 기준 코드와 `./gradlew check`로 검증됨.
- **블로그 전 과장 방지**: sample domain이 production feature이거나 scenario suite 전체가 검증됐다고 쓰지 않는다.
- [[raw/blog-topics/sample-fixture-dual-mode-build-matrix-2026-06-25]]: sample domain을 runtime toggle이 아니라 sample-on/sample-off build matrix로 격리하는 글감. hosted CI release-blocking 검증과 local gate matrix를 분리한다.
- [[raw/blog-topics/clean-architecture-reference-project-adoption-2026-06-17]]: 외부 reference project를 그대로 복제하지 않고 contract verification, event reliability, adoption checklist로 분해해 ca-tmpl에 흡수하는 글감. 정확성 감사에서 결함이 지적된 계획 문서는 수정 후에만 근거로 쓴다.
## 관련 개념
- [[wiki/concepts/sample-fixture-and-adoption]]
## Sources
- [[raw/project-notes/ca-skeleton-operational-contract]] — canonical §17 Sample Domain Fixture, §22 Sample-ticket Contract Matrix, §29 Group G-H Sample / adoption
- [[raw/branch-notes/feature-sample-domain-contract-fixture]] — sample-ticket 12 scenario matrix + 6-field minimum + state machine + optimistic lock + idempotency key 결정 SSOT branch
- [[raw/blog-topics/sample-domain-contract-fixture-clean-architecture-2026-06-10]] — sample domain contract fixture 블로그 글감 raw seed
- [[raw/branch-notes/feature-sample-removal-adoption-contract]] — 2-step removal + dual-mode CI matrix + 7-step adoption checklist 결정 SSOT branch
- [[raw/blog-topics/sample-fixture-dual-mode-build-matrix-2026-06-25]] — sample fixture dual-mode build matrix 블로그 글감 raw seed
- [[raw/blog-topics/clean-architecture-reference-project-adoption-2026-06-17]] — reference project adoption 블로그 글감 raw seed
## Cluster / 묶음
<!-- GENERATED: derived-blogs:start -->
- [[wiki/blog/ca-tmpl-sample-fixture-and-adoption-2026-07-02]]
<!-- GENERATED: derived-blogs:end -->
@@ -0,0 +1,158 @@
---
title: ca-tmpl - Security Baseline 결정 (JWT + Actuator + Secrets)
source_type: project
status: verified
confidence: high
tags: [ca-tmpl, security, jwt, oauth2, secrets, actually-implemented, locally-verified]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
---
# ca-tmpl - Security Baseline 결정 (JWT + Actuator + Secrets)
> Layer: `wiki/projects/` — ca-tmpl skeleton 내 보안 baseline 결정 사실 문서. 일반 개념/표준 정의는 [[wiki/concepts/security-baseline-jwt-actuator-secrets]] 참고.
## 프로젝트 컨텍스트
`ca-tmpl`은 Clean Architecture 기반 Spring Boot **skeleton/template** 저장소다. 이 문서가 다루는 범위는 운영 계약([[raw/project-notes/ca-skeleton-operational-contract]] §18 Control Plane Contract, §29 Group G-B 외부 근거 인덱스) 중 **보안 baseline 세 축**의 설계 결정이다.
세 축:
1. **데이터면 인증/인가**: JWT Resource Server + AuthN/AuthZ matrix 12행 + JWKS 10분 refresh + clock skew tolerance 60s + key rotation overlap 24h + public path snapshot diff.
2. **제어면 (Actuator)**: management port **9001** 분리 + prod allowlist (`health` / `prometheus` / `info`) + `heapdump`/`threaddump`/`env`/`configprops`/`shutdown` prod forbidden + `loggers` prod read-only + metrics network ACL default.
3. **Secrets / Config**: prod = secret manager OR mounted env, local만 `.env` 허용. `no-runtime-reload` default, `@RefreshScope` 금지. JWT signing key 24h overlap / DB credential dual-bind 60s / API key restart-reload / HMAC salt 90d rotation.
**진행 상태: C2 부분 구현 + 로컬 검증 완료.** JWT Resource Server filter chain, lazy JWT decoder, security error classifier/envelope entry point, actuator management policy, secret source/reload guard는 코드화되어 있다. secret manager 연동과 실제 rotation automation은 아직 없다.
## 실제 구현 내용 (`actually-implemented`)
- `adapter-web``SecurityConfig``SecurityFilterChain``oauth2ResourceServer`를 구성한다.
- `JwtDecoderConfig``SupplierJwtDecoder`로 JWKS discovery를 lazy 처리하고 `JwtTimestampValidator(Duration.ofSeconds(60))`, issuer, audience validator를 명시한다.
- `SecurityErrorClassifier`와 envelope entry point/denied handler 테스트가 filter-layer 보안 실패를 API error envelope으로 분류한다.
- `MethodSecurityConfig`, `RequiresPermission`, `AuthorizationPort`, `AuthorizationContractTest`가 framework-free method authorization path를 구성한다.
- `app-bootstrap``ManagementSecurityConfig`, sample management config, `SecretSource*`, `SecretReloadContractTest`가 actuator/secret baseline 일부를 코드화한다.
## 로컬/dev 검증 (`locally-verified`)
- `./gradlew check` 통과(2026-07-02, `BUILD SUCCESSFUL`, 114 tasks).
- `SecurityErrorClassifierTest`, `JwtDecoderConfigTest`, `EnvelopeAuthenticationEntryPointTest` 등 web security/error path 테스트가 통과한다.
- `ManagementActuatorSecurityContractTest`, `ActuatorSecurityHttpTest`가 management port/exposure/loggers read-only 정책을 검증한다.
- `SecuritySettingsTest`, `SecretSourceTest`, `SecretSourceValidatorTest`, `SecretReloadContractTest`가 설정/secret source/reload guard를 검증한다.
## 운영 검증 (`prod-verified`)
**없음.** ca-tmpl은 skeleton/template이며 운영 배포 대상이 아니다. prod 환경에서 JWT 검증 latency·JWKS rotation·secret rotation·actuator endpoint 노출을 측정한 적이 없다.
## 문서/계획만 존재 (`documented-only` / `planned`)
다음 항목은 구현된 baseline과 아직 `documented-only` / `planned`로 남은 영역을 분리한다. 면접/블로그에서 구현 범위와 혼동하면 안 된다.
### D1. JWT Resource Server 채택 (`actually-implemented` / `locally-verified`)
- **결정**: 데이터면 인증을 OAuth2 Resource Server + JWT (`spring-boot-starter-oauth2-resource-server`) 로 표준화.
- **검토한 대안**:
- Session + Cookie — 분산 session store 비용, stateless 확장성 손실.
- OAuth2 Authorization Code (issuance flow) — 본 baseline은 **검증 side**이므로 직교. issuance 자체는 별도 IdP.
- mTLS (RFC 8705 sender-constrained token) — PKI 운영 비용 + public client(SPA/mobile) 운영 어려움.
- API key + HMAC (AWS SigV4 류) — webhook/외부 호출 인증에는 적합하나 일반 사용자 인증 모델이 아님.
- OPA (Open Policy Agent) — 외부 호출 latency + sidecar 운영. 인가 정책 2~3종에는 과한 인프라.
- **채택 이유**: framework-neutral skeleton 가정과 정합 (Spring Security 6 표준 경로), revocation 한계는 short expiry + JWKS rotation overlap으로 완화.
- **설계만 동결한 파라미터**: JWKS refresh 10분 + unknown `kid` 시 on-demand refresh, clock skew 60s, key rotation overlap 24h, AuthN/AuthZ matrix 12행, public path snapshot diff.
### D2. Actuator management port 9001 분리 + prod allowlist (`actually-implemented` / `locally-verified`)
- **결정**: `management.server.port=9001` 별도 포트 + prod allowlist=`health,prometheus,info` + 그 외 prod forbidden.
- **검토한 대안**:
- Single port (8080) + path ACL — cloud ingress의 path 매칭 신뢰도, filter ordering / regex 우회 risk.
- mTLS for management — 강하지만 cert 운영 부담.
- Network ACL only (VPC SG / NetworkPolicy) — port가 같으면 비즈니스 트래픽과 분리 정책이 복잡.
- Istio sidecar AuthorizationPolicy — mesh 도입 전제, skeleton의 framework-neutral 가정 위배.
- **채택 이유**: 외부 노출 차단을 **네트워크 경계 단순화**(다른 포트 = 다른 ingress 정책)로 풀어 single-port + path ACL의 우회 위험을 피함.
- **설계만 동결한 파라미터**: `heapdump`/`threaddump`/`env`/`configprops`/`shutdown` prod 차단, `loggers` prod read-only, metrics scrape는 internal network ACL default.
### D3. Secrets: secret manager OR mounted env + restart-only rotation + HMAC salt 90d (`partially-implemented`)
- **결정**: prod source = (secret manager) OR (mounted env), `.env`는 local 전용. `__LOCAL_DEV_` sentinel로 prod 오탑재 차단. `@RefreshScope` 금지 / `no-runtime-reload` default. JWT signing key 24h overlap, DB credential dual-bind 60s, API key restart-reload, HMAC salt 90d rotation.
- **검토한 대안**:
- Vault dynamic secrets (short lease) — `@RefreshScope` + bean 재생성을 전제 → connection pool/캐시 lifecycle과 충돌, 본 계약(`@RefreshScope` 금지)과 정면 충돌.
- External Secrets Operator (ESO) — K8s native, 단 etcd 평문 저장은 cluster operator 책임 (이중 신뢰 경계).
- Doppler / 1Password SDK — dev 머신 보호에 강점이나 SaaS 외부 의존.
- **채택 이유**: runtime reload를 거부하면 bean lifecycle / connection pool 충돌이 사라지고, rotation은 **명시적 dual-bind window**로만 처리. HMAC salt 90d 주기는 NIST SP 800-57 cryptoperiod 권고 범위 내에서 누적 노출/downstream re-hash 비용을 절충한 값.
Secret source abstraction과 local/prod guard는 구현되어 있으나, 외부 secret manager/Vault/KMS 통합 및 실제 rotation automation은 미구현이다.
### D4. 한국 보안 사례 reference 추가 (2026-05-22) (`documented-only`)
- **추가된 reference** (raw 출처만, 구현 변경 없음):
- [[raw/company-tech-blogs/security-woowahan-actuator-safe-usage]] — 우아한형제들 SOC팀 "Security Actuator 안전하게 사용하기" (별도 포트 + endpoint allowlist + shutdown/heapdump forbidden 권고). ca-tmpl D2 결정과 정합.
- [[raw/company-tech-blogs/security-toss-actuator-healthcheck]] — 토스 "Spring Boot Actuator의 헬스체크 살펴보기" (health detail 민감성 분류). ca-tmpl D2 + public path misconfiguration 정책과 정합.
- **영향**: Group G-B Actuator 결정의 한국 도메인 사례 근거 보강. 현재 ca-tmpl의 actuator exposure/management security contract와 함께 보조 근거로만 사용한다.
- **여전히 미확보**: 한국 기업의 JWT Resource Server 구현 사례, secret manager / Vault 운영 사례 직접 source는 미발견 — follow-up 후보로 유지.
## 면접에서 말할 수 있는 범위
### 자신 있게 답할 수 있는 질문
- **JWT vs Session 선택 기준** — stateless 확장성, revocation trade-off, cookie 운영 비용, 클라이언트 타입에 따른 결정 근거.
- **JWKS rotation 주기 설계** — 10분 refresh + unknown `kid` 시 on-demand refresh + 24h overlap window의 근거.
- **Management port 분리 이유** — single-port + path ACL의 filter ordering / regex 우회 risk 대비 별도 포트의 네트워크 경계 단순화.
- **Secret rotation 방식 (dual-bind)** — JWT key 24h overlap / DB credential dual-bind 60s / API key restart-reload가 왜 다른지.
- **HMAC salt 90d rotation 근거** — NIST SP 800-57 cryptoperiod 권고 + 누적 노출량 한도 + downstream re-hash 비용 절충.
### 적당히 답할 수 있는 질문
- **OPA vs in-process AUTHZ trade-off** — 외부 호출 latency / sidecar 운영 / 정책 코드 분리 가치 / 정책 종수 임계.
- **Vault dynamic secrets vs static lease** — `@RefreshScope` 강제와 bean lifecycle 충돌, dynamic secret이 본 계약과 왜 충돌하는지.
- **clock skew tolerance 30s vs 60s** — NTP drift 가정, 발급자/검증자 분산도, expired vs replay 창 trade-off.
### 답하면 안 되는 질문 (모른다고 해야 함)
- "**JWT Resource Server baseline을 구현했다**" — 가능. 단 IdP 운영/JWKS rotation 실측은 없음.
- "**Secret rotation을 운영에서 돌려봤다**" — prod 적용 사례 없음. dual-bind window는 설계 값.
- "**Actuator endpoint 보안 침투 테스트 결과**" — pentest 수행 안 함.
- "**JWKS rotation 시 latency가 얼마였다**" — 측정 안 함.
- "**Vault/Secrets Manager를 ca-tmpl에 연결해서 돌려봤다**" — 어떤 secret manager와도 통합하지 않음.
## 과장 금지 지점
- **"JWT는 안전하다"는 단정 금지.** token theft 시 stateless 검증은 즉시 revocation이 어렵다. JWKS rotation overlap + short expiry는 완화책일 뿐 근본 해결책이 아니다.
- **"Vault가 secret 관리의 표준"이라는 표현 금지.** dynamic secrets는 `@RefreshScope` 흐름을 전제하며, ca-tmpl의 `@RefreshScope` 금지 계약과 정면 충돌. 채택 가능한 표준이 단일하지 않다.
- **한국 보안 기술블로그 사례 참조 범위 한정.** 2026-05-22 기준 ca-tmpl이 직접 참조하는 한국 사례는 **Actuator 노출 정책 영역에 한정**된다 ([[raw/company-tech-blogs/security-woowahan-actuator-safe-usage]] / [[raw/company-tech-blogs/security-toss-actuator-healthcheck]]). JWT Resource Server 운영, secret manager 통합, JWKS rotation 같은 영역의 한국 도메인 직접 사례는 부재 — 인용 시 영역을 actuator로 명시할 것.
- **"Actuator를 닫아두면 안전하다"는 단정 금지.** allowlist + 네트워크 경계 + 인증의 다층 방어가 필요하다. `info`만 열어도 build/commit 메타데이터가 attack surface가 될 수 있다.
- **"AuthN/AuthZ matrix 12행 전체가 E2E로 검증됐다"고 말하면 안 됨.** 주요 security/error path와 method authorization contract는 테스트되지만, 모든 matrix row의 외부 IdP 통합 검증은 없다.
### Blog-topic ingest: secret-source-port-restart-only-rotation (2026-07-02)
[[raw/blog-topics/secret-source-port-restart-only-rotation-2026-07-02]] 는 secret source를 문자열 규칙이 아니라 `SecretSource` port, restart-only rotation, `@RefreshScope` 금지 계약으로 닫은 이유를 블로그로 풀기 위한 raw seed다.
- **canonical 반영 범위**: secrets/source/rotation 정책 글감을 security baseline canonical에 연결했다.
- **blogify 전 조건**: 충족. 이 문서는 2026-07-02 기준 코드와 `./gradlew check`로 검증됨.
- **블로그 전 과장 방지**: Vault/KMS dynamic secret 운영이나 secret manager 통합을 구현한 것처럼 쓰지 않고, restart-only contract 범위로 제한한다.
- [[raw/blog-topics/framework-free-method-authorization-clean-architecture-2026-06-08]]: Spring Security annotation을 application layer에 직접 붙이지 않고 plain annotation + authorization port + adapter method-security로 분리하는 글감. Spring Security 자체를 부정하지 않고 ca-tmpl layer boundary 선택으로 제한한다.
- [[raw/blog-topics/spring-security-filter-layer-error-envelope-2026-06-08]]: Spring Security 인증/인가 실패가 filter layer에서 entry point / denied handler로 처리되어 ControllerAdvice에 도달하지 않는다는 점을 envelope 통일과 연결하는 글감. heuristic 분류의 한계를 유지한다.
## 관련 개념
- [[wiki/concepts/security-baseline-jwt-actuator-secrets]]
## Sources
### Canonical project SSOT
- [[raw/project-notes/ca-skeleton-operational-contract]] — §18 Control Plane Contract, §29 Group G-B 외부 근거 인덱스
### Branch-notes (결정 동결 위치)
- [[raw/branch-notes/feature-security-operational-baseline]] — JWT Resource Server + AuthN/AuthZ Matrix 12행 + JWKS 10min refresh + clock skew 60s + rotation overlap 24h + public path snapshot diff
- [[raw/branch-notes/feature-secrets-config-source-contract]] — secret source port + restart-only rotation + `@RefreshScope` 금지 결정
- [[raw/blog-topics/secret-source-port-restart-only-rotation-2026-07-02]] — secret source/restart-only rotation 블로그 글감 raw seed
- [[raw/blog-topics/framework-free-method-authorization-clean-architecture-2026-06-08]] — framework-free method authorization 블로그 글감 raw seed
- [[raw/blog-topics/spring-security-filter-layer-error-envelope-2026-06-08]] — Spring Security filter-layer envelope 블로그 글감 raw seed
- [[raw/branch-notes/feature-management-actuator-security-contract]] — management port 9001 + prod allowlist + heapdump/threaddump prod forbidden + loggers prod read-only + metrics network ACL default
- [[raw/branch-notes/feature-secrets-config-source-contract]] — prod = secret manager OR mounted env + no-runtime-reload default + `__LOCAL_DEV_` sentinel + JWT key 24h overlap / DB credential dual-bind 60s / API key restart-reload + HMAC salt 90d
## Cluster / 묶음
<!-- GENERATED: derived-blogs:start -->
- [[wiki/blog/ca-tmpl-security-baseline-jwt-actuator-secrets-2026-07-02]]
<!-- GENERATED: derived-blogs:end -->
@@ -0,0 +1,146 @@
---
title: ca-tmpl - Skeleton Governance 결정 (Registry + Verification + Test + Scorecard)
source_type: project
status: verified
confidence: high
tags: [ca-tmpl, governance, archunit, testcontainers, scorecard, actually-implemented, locally-verified]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
---
# ca-tmpl - Skeleton Governance 결정 (Registry + Verification + Test + Scorecard)
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념은 [[wiki/concepts/skeleton-governance-registry-verification-test-scorecard]] 참고.
## 프로젝트 컨텍스트
**ca-tmpl skeleton** — Clean Architecture 기반의 재사용 가능한 Spring Boot 템플릿 프로젝트. 이 문서는 그 중 **governance 4축**(Registry / Verification / Test taxonomy / Scorecard)의 설계 결정을 기록한다.
- **현재 단계**: C2 부분 구현 + 로컬 검증 완료.
- **scope**: markdown SSOT + YAML registry + 11 release-blocking gate + 6 test level + binary pass/fail scorecard (15 area).
- **registry yaml 위치**: `/home/donghyeon/workspace/ca-tmpl/docs/registries/` (LLM Wiki 외부, ca-tmpl 저장소 내부).
- **목적**: skeleton을 "남에게 줘도 망가지지 않는 상태"로 굳히기 위한 governance 계약을 명문화. 검증·테스트·도입 준비도가 **branch-note ≈ mini-ADR** 한 장과 1:1로 묶이도록 설계.
자세한 운영 계약은 [[raw/project-notes/ca-skeleton-operational-contract]] (§12 / §21 / §27 / §29 G-G) 참고.
## 실제 구현 내용 (`actually-implemented`)
- `docs/registries/` 아래 `error-codes.yaml`, `env-keys.yaml`, `secrets-classification.yaml`, `headers.yaml`, `mdc-keys.yaml`, `metrics.yaml`, `capabilities.yaml`가 존재한다.
- `.github/ci-gate-matrix.yml`가 gate ↔ owner ↔ mechanism matrix를 코드화한다.
- `ContractRegistrySchemaGovernanceTest`, `OutboxStatusRegistryContractTest`, `EnvProfileMatrixContractTest` 등 registry/gate contract tests가 존재한다.
- `CleanArchitectureTest`, `DisabledAdapterArchitectureTest`, `NamingConventionTest`, `ProductionClassImportOption`, sample-off test source set이 architecture/test taxonomy 일부를 강제한다.
- scorecard 자체는 아직 별도 CI badge/자동 산출물까지 구현되지 않았다.
## 로컬/dev 검증 (`locally-verified`)
- `./gradlew check` 통과(2026-07-02, `BUILD SUCCESSFUL`, 114 tasks).
- 실행 중 `verifyCleanArchitectureDependencies`, `verifyEnvKeys`, `verifyQuarantineSunset`, `verifyReadmeCommands`, `verifyTrivyignore`가 OK로 통과했다.
- outbox/idempotency integration tests가 PostgreSQL Testcontainers 기반으로 실행되어 contract 일부를 검증한다.
## 운영 검증 (`prod-verified`)
**없음.** ca-tmpl은 운영 배포 대상 자체가 아닌 skeleton/template.
## 문서/계획만 존재 (`documented-only` / `planned`)
아래 항목은 구현된 registry/gate/test taxonomy slice와 아직 자동화되지 않은 scorecard/coverage slice를 분리한다.
### Registry (canonical §21)
- **결정**: markdown SSOT (사람이 읽는 정의) + YAML **generated constants** (코드가 읽는 사본). 두 곳을 둬도 SSOT는 markdown 한 곳.
- **7-column schema** 정의: `key / kind / description / since / status / owner / notes`.
- **7개 yaml**: `error.yaml`, `env.yaml`, `secrets.yaml`, `headers.yaml`, `mdc.yaml`, `metrics.yaml`, `capabilities.yaml`.
- **구현됨**: YAML registry files + schema governance test. **남음**: generated constants/code generator 전체와 markdown ↔ yaml 완전 drift gate.
- **ArchUnit annotation-as-registry 대안 평가 (2026-05-22)** — markdown SSOT 유지. framework-neutral + git diff review + 외부 도구 호환 근거. ArchUnit은 verifier 역할 한정. 상세: [[raw/official-docs/archunit-annotation-as-registry-evaluation]].
- 근거: [[raw/branch-notes/feature-contract-registry-governance]].
### Verification (canonical §12)
- **결정**: 11개 release-blocking gate + JSON snapshot 기반 contract 검증. **Pact CDC는 out-of-scope** — single-team / 단일 release train에는 over-engineering.
- gate 예시: ArchUnit / dependency / API snapshot / error envelope / observability / OpenAPI / Testcontainers 강제 / 등.
- **구현됨**: 다수 Gradle verification task와 `.github/ci-gate-matrix.yml`. **남음**: 11 gate 전체의 hosted release-blocking 이력과 gate별 실패 메시지 표준 완전성 확인.
- 근거: [[raw/branch-notes/feature-contract-verification-test-suite]].
### Test taxonomy (canonical §29 G-G)
- **결정**: 6 level test taxonomy. Testcontainers는 **integration level부터 강제** (unit/slice에서 금지).
- **src/testFixtures** 사용: fixture 코드가 main classpath에 새는 것 방지.
- **5min budget**: skeleton local fast feedback loop 목표.
- **구현됨**: sample-off source set, `sampleFixture`, Testcontainers integration tests, ArchUnit fixture pattern. **남음**: 6 level 전체 budget 측정/강제 mechanism.
- 근거: [[raw/branch-notes/feature-test-taxonomy-fixture-contract]].
### Scorecard (canonical §27)
- **결정**: **binary pass/fail** (maturity 점수 X) × **15 area** × **1:1 branch evidence** (각 area는 branch-note 1개를 evidence로 지목).
- 도입 gate 한정 — "이 skeleton을 도입해도 되는가" 여부 판단용. 운영 SLO나 코드 품질 점수 도구가 **아님**.
- **남음**: scorecard CI step, badge, branch-note ↔ area 매핑 자동 검증.
- 근거: [[raw/branch-notes/feature-implementation-readiness-scorecard]].
## 면접에서 말할 수 있는 범위
### 자신 있게 답할 수 있는 질문
- "registry의 SSOT를 markdown에 두는 이유와 code-generated YAML의 역할 분리"
- "Pact CDC를 도입하지 않고 JSON snapshot으로 contract를 잡은 trade-off (단일 팀 / 단일 release train 한정)"
- "Testcontainers를 integration level부터 강제하고 unit/slice에서 금지하는 이유"
- "6 level test taxonomy의 각 level이 무엇을 책임지는지"
- "binary pass/fail vs maturity score를 선택한 이유 — 도입 gate 용도 한정"
- "branch-note를 mini-ADR로 보고 scorecard area와 1:1로 묶는 설계 의도"
### 적당히 답할 수 있는 질문
- "정식 ADR vs branch-note의 관계 — branch-note가 ADR의 경량 대체로 어디까지 커버되는가"
- "fitness function 도입 검토 — ArchUnit 외 어떤 측정 지표를 자동화 후보로 보고 있는가"
### 답하면 안 되는 질문 (모른다고 해야 함)
- "verifier task를 직접 구현해 봤는가" → 일부 구현. `verifyCleanArchitectureDependencies`, `verifyEnvKeys`, `verifyQuarantineSunset`, `verifyTrivyignore` 등은 로컬 check에 포함됨.
- "scorecard 자동화를 CI에서 운영해 봤는가" → ❌. 미작성.
- "5min test budget을 실제로 측정해 봤는가" → ❌. 정책 선언이며 budget gate는 별도 구현 필요.
- "11 gate가 실제로 release를 차단한 사례" → ❌. 없음.
## 과장 금지 지점
- "Pact가 항상 우월하다" → ❌. ca-tmpl 같은 single-team / 단일 release train 환경에는 over-engineering. JSON snapshot이 비용 대비 충분.
- "binary pass/fail이 모든 품질 측정의 절대 기준" → ❌. **skeleton 도입 gate 한정**. 운영 SLO나 코드 품질 maturity 측정에 그대로 쓰면 안 됨.
- "11 gate 검증 자동화를 완성했다" → ❌. 일부 gate는 구현됐지만 전체 완성으로 쓰지 않는다.
- "Testcontainers 5min budget을 보장한다" → ❌. 정책 선언, 실측 / 강제 mechanism 없음.
- "registry YAML이 SSOT다" → ❌. **markdown이 SSOT**, YAML은 generated constants.
### Blog-topic ingest: verification/scorecard 묶음 (2026-07-02)
[[raw/blog-topics/contract-verification-suite-release-gates-2026-07-02]] 는 skeleton 운영 계약을 문서로만 두지 않고 release-blocking test suite로 묶는 이유를 블로그로 풀기 위한 raw seed다.
- **canonical 반영 범위**: verification suite/release gate 글감을 governance/registry/scorecard canonical에 연결했다.
- **blogify 전 조건**: 충족. 이 문서는 2026-07-02 기준 코드와 `./gradlew check`로 검증됨. 단 hosted CI/prod evidence는 분리한다.
- **블로그 전 과장 방지**: verifier 자동화나 release 차단 운영 사례가 이미 있다고 쓰지 않는다. 정의/정책/로컬 검증 범위를 구분한다.
- [[raw/blog-topics/binary-readiness-scorecard-clean-architecture-skeleton-2026-07-02]]: 좋아 보이는 skeleton과 도입 가능한 skeleton을 15개 영역의 binary gate로 분리하는 글감. local readiness를 production readiness나 외부 채택 가능성으로 확대하지 않는다.
- [[raw/blog-topics/contract-registry-schema-owner-vs-row-owner-gate-2026-06-20]]: contract registry에서 schema owner와 row owner를 분리하고 schema gate가 reference row 면제를 명시적으로 검증해야 하는 이유를 다루는 글감. schema 정합을 token 사용 강제나 runtime verification으로 확대하지 않는다.
- [[raw/blog-topics/test-taxonomy-archunit-enforcement-2026-06-19]]: test taxonomy를 README 컨벤션이 아니라 ArchUnit import graph rule로 강제하는 글감. 테스트 품질 전체 보장이 아니라 level misplacement와 dependency boundary 방지로 제한한다.
- [[raw/blog-topics/archunit-violations-as-data-pattern-2026-05-28]]: fitness function 자체를 negative fixture로 검증하는 글감. governance/test scorecard 관점에서는 non-vacuity proof pattern으로 연결한다.
- [[raw/blog-topics/archunit-testcompileonly-fixture-annotation-pattern-2026-06-02]]: `testCompileOnly` 타입을 ArchUnit fixture에서 annotation-only로 안전하게 참조하는 글감. 모든 fixture 참조 패턴에 일반화하지 않는다.
## 관련 개념
- [[wiki/concepts/skeleton-governance-registry-verification-test-scorecard]]
## Sources
- [[raw/project-notes/ca-skeleton-operational-contract]] — §12 (Verification), §21 (Registry), §27 (Scorecard), §29 G-G (Test taxonomy)
- [[raw/branch-notes/feature-contract-registry-governance]]
- [[raw/branch-notes/feature-contract-verification-test-suite]]
- [[raw/blog-topics/contract-verification-suite-release-gates-2026-07-02]] — contract verification suite/release gate 블로그 글감 raw seed
- [[raw/branch-notes/feature-implementation-readiness-scorecard]]
- [[raw/blog-topics/binary-readiness-scorecard-clean-architecture-skeleton-2026-07-02]] — binary readiness scorecard 블로그 글감 raw seed
- [[raw/blog-topics/contract-registry-schema-owner-vs-row-owner-gate-2026-06-20]] — registry schema owner vs row owner gate 블로그 글감 raw seed
- [[raw/blog-topics/test-taxonomy-archunit-enforcement-2026-06-19]] — test taxonomy ArchUnit enforcement 블로그 글감 raw seed
- [[raw/blog-topics/archunit-violations-as-data-pattern-2026-05-28]] — violations-as-data ArchUnit fixture 블로그 글감 raw seed
- [[raw/blog-topics/archunit-testcompileonly-fixture-annotation-pattern-2026-06-02]] — ArchUnit `testCompileOnly` fixture annotation-only 패턴 블로그 글감 raw seed
- [[raw/branch-notes/feature-test-taxonomy-fixture-contract]]
- [[raw/branch-notes/feature-implementation-readiness-scorecard]]
## Cluster / 묶음
<!-- GENERATED: derived-blogs:start -->
- [[wiki/blog/ca-tmpl-skeleton-governance-registry-verification-test-scorecard-2026-07-02]]
<!-- GENERATED: derived-blogs:end -->
@@ -0,0 +1,142 @@
---
title: ca-tmpl - 이벤트 스트리밍 미지원 결정 + ArchUnit 정적 강제
source_type: project
status: verified
confidence: high
tags: [ca-skeleton, streaming, archunit, actually-implemented]
related_projects: [ca-skeleton, ca-tmpl]
last_reviewed: 2026-07-02
---
# ca-tmpl - 이벤트 스트리밍 미지원 결정 + ArchUnit 정적 강제
> Layer: `wiki/projects/` — 내 프로젝트 사실. SSE / WebSocket / long-polling / chunked 의 일반 trade-off 는 [[wiki/concepts/streaming-response-patterns]] 참조.
>
> **핵심 framing**: 본 문서가 `actually-implemented` 로 주장하는 것은 **"스트리밍 지원" 이 아니라 "스트리밍 미지원을 빌드타임에 강제하는 ArchUnit 가드레일"** 이다. ca-skeleton 은 이벤트/server-push 스트리밍을 **지원하지 않으며**, 그 미지원을 코드(ArchUnit rule)로 못박았다. 스트리밍 지원 계약 자체는 `planned`(보류).
## 프로젝트 컨텍스트
- **프로젝트**: ca-tmpl — Clean Architecture 기반 백엔드 skeleton 템플릿.
- **결정**: ca-skeleton 은 **이벤트/server-push 스트리밍(SSE · WebSocket)을 default 미지원으로 확정** (D1) 하고, 그 미지원을 **ArchUnit import-ban rule 3개로 정적 강제** (D3) 한다. controller/adapter 가 streaming API 를 import 하면 build 가 실패한다.
- **왜 미지원을 *결정* 으로 다루는가**: streaming-response 는 독립 결정이 아니라 *통신/전송 프로토콜 계약(HTTP vs gRPC vs streaming)의 한 facet* 이다. 전송 프로토콜은 모든 파생 프로젝트의 기본기로 박을 근거가 가장 약한, skeleton 에서 *가장 마지막에 고정* 해야 할 영역. 현재 sample-portfolio fixture 에 server-push use case 가 없으므로 (YAGNI / speculative generality 회피) "미지원 default + ArchUnit 차단" 을 택했다. 단순한 누락이 아니라 *의도적 미지원 + 정적 강제* 라는 점이 차이다.
- **용어 주의 (핵심)**: 여기서 "streaming response" = **이벤트/server-push 스트리밍** (통신 모델이 request-response → server-push 로 바뀌는 것). 대용량 파일 다운로드용 `StreamingResponseBody`(응답 body 청크 전송, 통신 모델은 여전히 request-response)는 **별개 관심사이며 차단 대상이 아니다** — [[raw/branch-notes/feature-file-resource-handling-contract]] D8 소유.
- **결정 SSOT**: [[raw/branch-notes/feature-streaming-response-contract]] (D1/D3 in-scope + D2 보류 + Decision Evidence Map). 본 문서는 그 중 *실제 코드로 구현된* 사실만 추출한다.
- **진행 단계**: **코드 구현 + 로컬 검증 완료** (ArchUnit rule 3개 + violations-as-data fixtures + over-block guard). 운영 배포 / 측정값 없음.
## Ground-truth 대조 (2026-06-04, ca-tmpl @9693d72 "이벤트 스트리밍 미지원 ArchUnit 검증")
`/home/donghyeon/workspace/ca-tmpl` 코드를 직접 읽어 검증한 사실 (D3 구현 커밋 `9693d72`; 현재 checkout HEAD = `db61075`, 본 streaming 코드는 HEAD 에 그대로 잔존):
- 패키지 root 는 `dev.caskeleton.*`.
- **3개 D3 rule 실재** — `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java``// ---- feature-streaming-response-contract D3 ----` 블록 (line 673~718): `no_sse_emitter`, `no_response_body_emitter`, `no_websocket_handler`. 셋 다 `noClasses().that().resideInAPackage("dev.caskeleton..").should().dependOnClassesThat()...` + `.allowEmptyShould(true)` 형태.
- **scan 범위 = production only** — class 레벨 `@AnalyzeClasses(packages = "dev.caskeleton", importOptions = ImportOption.DoNotIncludeTests.class)`. 테스트 fixture 는 scope 밖.
- **production 코드에 streaming import 0건** — `grep -rln "SseEmitter|ResponseBodyEmitter|web.socket|jakarta.websocket" src/ | grep -v /test/` → 결과 없음. 즉 미지원(ban)이 실제이며 예외 production 사용처 없음.
- **violations-as-data fixtures 실재** (`..architecture/violations/streaming/`): `SseEmitterUsingFixture`, `ResponseBodyEmitterUsingFixture`, `SpringWebSocketHandlerFixture`(`@EnableWebSocket`), `JakartaWebSocketEndpointFixture`(`@ServerEndpoint`).
- **over-block guard fixture 실재** (`..architecture/allowed/streaming/`): `StreamingResponseBodyAllowedFixture` — 3개 rule 모두 이것을 *잡지 않아야* 정상(파일 다운로드 회귀 방지).
- **WebSocket fixture 격리 corpus** — `ArchitectureViolationFixtureTest``SPRING_WEBSOCKET_FIXTURE_ONLY` / `JAKARTA_WEBSOCKET_FIXTURE_ONLY` 로 spring·jakarta glob 을 *각각 독립 import* 해 평가 (공유 풀에서 한 glob 만 동작해도 통과하던 vacuous-pass 갭 차단).
## 실제 구현 내용 (`actually-implemented`)
ca-tmpl 코드에서 직접 확인한 산출물. **차단(ban) 가드레일 + 근거** 가 구현 실체다.
**D3 ArchUnit rule 3개** (`app-bootstrap/.../architecture/CleanArchitectureTest.java`):
| rule | 차단 대상 FQN / 패키지 | 메커니즘 | 근거(차단 대상 정의) |
|---|---|---|---|
| `no_sse_emitter` | `org.springframework.web.servlet.mvc.method.annotation.SseEmitter` | `dependOnClassesThat().haveFullyQualifiedName(...)` | `SPRING-ASYNC-C4` (`SseEmitter` = `ResponseBodyEmitter` subclass, W3C SSE 포맷) |
| `no_response_body_emitter` | `...ResponseBodyEmitter` | 동일 (단일 FQN) | `SPRING-ASYNC-C3` (`ResponseBodyEmitter` = 객체 stream emit, SSE 의 base) |
| `no_websocket_handler` | `org.springframework.web.socket..` + `jakarta.websocket..` (패키지 glob) | `dependOnClassesThat().resideInAnyPackage(...)` | `RFC6455-C1` (full-duplex). spring-websocket handler/STOMP + Jakarta `@ServerEndpoint` 표면 일괄 차단 |
- 셋 다 대상 = `dev.caskeleton..` production code. `.allowEmptyShould(true)` (현재 production 에 streaming 클래스 미사용이므로 빈 결과 허용).
- **명시적 비-차단 (의도적)**: `StreamingResponseBody`(대용량 다운로드, request-response 모델 유지) 는 차단 *안 함* — [[raw/branch-notes/feature-file-resource-handling-contract]] D8 소유. blanket ban 시 파일 다운로드 build 가 깨지므로 의도적으로 제외. rule Javadoc 에 이 경계가 명시됨.
- **suite host** = boundary branch 의 ArchUnit suite ([[raw/branch-notes/feature-boundary-validation-mapping-contract]] D5 `no_problem_detail_usage` 와 동일 import-ban 메커니즘 선례). archunit-junit5 1.3.0 (project §34 Stack Commitment).
**테스트 fixtures** (`testCompileOnly` 의존 + annotation-only 참조 패턴):
- violations-as-data: `SseEmitterUsingFixture`, `ResponseBodyEmitterUsingFixture`, `SpringWebSocketHandlerFixture`, `JakartaWebSocketEndpointFixture` — 각 rule 이 위반을 *실제로 잡아내는지* 검증.
- over-block guard: `StreamingResponseBodyAllowedFixture` — 3개 rule 이 이것을 *잡지 않는지* (false positive 없음) 검증.
- WebSocket fixture 는 `@EnableWebSocket`(spring) / `@ServerEndpoint`(jakarta) annotation-only 참조 — `testCompileOnly` jar 가 runtime classpath 에 없어 `extends``NoClassDefFoundError` 가 나던 문제를 annotation lazy-access 로 회피 ([[raw/errors/archunit-testcompileonly-class-loading-2026-06-02]]).
## 로컬/dev 검증 (`locally-verified`)
- `CleanArchitectureTest` (D3 3개 rule 포함) + `ArchitectureViolationFixtureTest` (위 fixtures) GREEN — 2026-06-02 기준 ArchUnit 33 rules / FixtureTest 24 tests 모두 통과로 branch-note 에 기록.
- 검증한 사실:
- `no_sse_emitter` / `no_response_body_emitter``SseEmitter`·`ResponseBodyEmitter` import fixture 를 실제로 위반으로 잡음.
- `no_websocket_handler``org.springframework.web.socket..` + `jakarta.websocket..` glob 이 spring·jakarta fixture 를 각각 격리 corpus 에서 잡음 (over-block 없음).
- `StreamingResponseBodyAllowedFixture` 가 3개 rule 어디에도 안 걸림 (file-resource D8 다운로드 회귀 방지).
- 검증 범위는 **JVM 정적 분석(ArchUnit bytecode) + 단위 테스트까지**. 실제 SSE/WebSocket 연결을 띄워 동작/부하를 본 것이 아니다 (애초에 미지원이므로 그런 통합 테스트 없음).
## 운영 검증 (`prod-verified`)
**없음.** 운영 환경에 배포된 적이 없다. connection 수 / event throughput / 인시던트 / 릴리즈 노트 어느 것도 없다 (스트리밍 자체가 미지원이므로 운영 streaming 지표도 존재하지 않는다).
## 문서/계획만 존재 (`documented-only` / `planned`)
다음은 설계/문서/보류 상태이며 **면접에서 "구현했다 / 지원한다"고 말하면 안 된다**.
- **이벤트 스트리밍 지원 계약 전체 (D2)**: `planned` / not-adopted. *만약* 지원하기로 하면 필요한 ① 매커니즘 선택(SSE vs WebSocket — 재개 시 SSE 우선) ② event envelope shape(envelope `{success,data,meta}` 적용 여부 vs SSE 고유 `event:/data:` 포맷) ③ per-event trace context 전파 ④ timeout/heartbeat/reconnect/connection cap ⑤ reverse proxy 설정 의무 — **전부 보류**. 근거 raw 6개는 branch-note §Sources 에 보존.
- **재개 트리거**: (a) 실제 server→client push use case 등장 (실시간 알림 / LLM token streaming / 대용량 export 진행률) 또는 (b) 통신/전송 프로토콜 계약 branch 착수. 재개 시 D3 SSE 차단 rule 을 명시적으로 해제해야 함.
- **per-event trace span 정책 (OPEN)**: tracing branch ([[raw/branch-notes/feature-distributed-tracing-contract]] D5/D7)는 traceparent 를 *request 단위* 로만 전파 — "한 long-lived connection 의 N개 event 에 traceId 를 어떻게" 는 기존 Decision 으로 닫히지 않는 진짜 OPEN 갭. D2 재개 시 동시 결정 필요.
- **미지원 시 비동기 우회 경로**: server-push 가 필요하면 LRO polling([[raw/branch-notes/feature-api-contract-baseline]] D17: 202 + `Location` + polling + `Retry-After`) 또는 webhook outbound([[raw/branch-notes/feature-webhook-outbound-contract]], 미결정). 본 branch 가 작성한 코드 아님 — 형제 branch 결정 재사용.
## 면접에서 말할 수 있는 범위
### 자신 있게 답할 수 있는 질문
- ca-skeleton 이 이벤트 스트리밍을 왜 *미지원으로 결정* 했는가 — 전송 프로토콜은 가장 마지막에 고정할 facet + 현재 fixture 에 server-push use case 부재(YAGNI) + api-contract-baseline 의 "request-response only" 선언과의 일관성.
- 그 미지원을 *어떻게 강제* 했는가 — 단순 누락이 아니라 ArchUnit import-ban rule 3개(`no_sse_emitter` / `no_response_body_emitter` / `no_websocket_handler`)로 production 코드가 streaming API 를 import 하면 build 실패. boundary branch 의 `no_problem_detail_usage` import-ban 선례를 차용.
- `StreamingResponseBody` 를 왜 차단 ** 했는가 — 그것은 server-push 가 아니라 대용량 다운로드(request-response 모델 유지)이고 file-resource D8 소유. blanket ban 했으면 다운로드 build 가 깨졌을 것. *무엇을 차단하고 무엇을 제외했는지의 경계* 를 설명할 수 있음.
- rule 동작을 어떻게 보증했는가 — violations-as-data fixtures 로 "위반을 실제로 잡는지" + over-block guard fixture 로 "허용 케이스를 안 잡는지" 양방향 검증. WebSocket spring/jakarta glob 은 격리 import corpus 로 각각 독립 검증(vacuous-pass 차단).
- `testCompileOnly` fixture 에서 `NoClassDefFoundError` 를 어떻게 피했는가 — `extends TextWebSocketHandler` 대신 `@EnableWebSocket` annotation-only 참조 (annotation 은 JVM lazy access 라 class load 시 불필요, ArchUnit bytecode 분석은 정상).
### 적당히 답할 수 있는 질문
- SSE vs WebSocket vs long-polling vs chunked 의 일반 trade-off (단방향 vs 양방향, HTTP 인프라 재사용, proxy 부담). (개념 수준 — [[wiki/concepts/streaming-response-patterns]].)
- 재개 시 왜 SSE 를 우선 후보로 두는가 — 단방향 push 에 적합 + 기존 HTTP 인프라 재사용 + WebSocket 대비 proxy 부담 낮음 (WHATWG-SSE-C / SPRING-ASYNC-C4 근거).
- SSE/WebSocket 운영 부담의 *일반적* 성격 (thundering herd, fan-out, 이벤트 유실) — 우아한형제들 사례를 *참고* 로 인용하되 공식 best practice 로 말하지 않음.
### 답하면 안 되는 질문 (모른다고 해야 함)
- "ca-skeleton 에서 SSE/WebSocket 을 구현/지원하는가?" → **미지원. 오히려 ArchUnit 으로 차단했다.**
- "스트리밍 응답을 운영에서 돌려봤는가 / connection 부하를 측정했는가?" → **미지원이므로 그런 운영 지표 없음.**
- "per-event trace span / reconnect / connection cap 정책을 설계했는가?" → **D2 보류. 설계 안 함.**
## 과장 금지 지점
- **"스트리밍을 지원/구현했다" → 절대 금지.** 구현한 것은 *미지원을 강제하는 차단 rule* 이지 스트리밍 기능이 아니다.
- **"운영에서 검증했다 / prod 에서 돌고 있다" → 금지.** 정적 분석(ArchUnit) + 단위 테스트까지가 검증 범위.
- **우아한형제들 SSE/WebSocket 사례를 "공식 best practice" 로 인용 → 금지.** company-case-study 이며 ca-skeleton 규모에 그대로 일반화 불가.
- **"미지원이 정답이다" → 단정 금지.** real-time 요구가 있는 도메인이면 결정이 달라진다 — skeleton 의 minimalist default 일 뿐, 도입 가능성은 열어둠(D2).
- **`StreamingResponseBody` 도 차단했다고 말하기 → 금지.** 명시적으로 *제외* 했다 (file-resource D8 경계).
### Blog-topic ingest: streaming-response-not-supported-archunit-ban (2026-07-02)
[[raw/blog-topics/streaming-response-not-supported-archunit-ban-2026-07-02]] 는 SSE/WebSocket을 지금 지원하지 않는다는 결정을 문서 선언이 아니라 ArchUnit import-ban으로 고정한 이유를 블로그로 풀기 위한 raw seed다.
- **locally-verified 로 말할 수 있는 부분**: `SseEmitter`, `ResponseBodyEmitter`, Spring/Jakarta WebSocket import-ban rule과 fixture 검증.
- **project-local policy 로 말할 부분**: ca-tmpl skeleton의 sync baseline/minimal default에서는 streaming을 기본 surface로 열지 않는다.
- **블로그 전 과장 방지**: streaming 기술 자체가 나쁘다는 결론으로 쓰지 않고, `StreamingResponseBody` 제외 경계와 D2 보류 범위를 보존한다.
- [[raw/blog-topics/archunit-testcompileonly-fixture-annotation-pattern-2026-06-02]]: `testCompileOnly` WebSocket/Jakarta fixture가 JUnit discovery에서 class loading failure를 내는 문제를 annotation-only 참조로 피한 글감. annotation-only가 모든 fixture 참조를 안전하게 만든다고 쓰지 않는다.
## 관련 개념
- [[wiki/concepts/streaming-response-patterns]] — SSE vs WebSocket vs long-polling vs chunked transfer 의 일반 trade-off, sync-baseline rationale, 언제 스트리밍이 가치 있고 언제 아닌가.
## Sources
- [[raw/branch-notes/feature-streaming-response-contract]] — D1(미지원 확정) / D3(ArchUnit 강제) / D2(지원 계약 보류) + Decision Evidence Map + 구현 가이드(2026-06-02). 본 문서의 결정 SSOT.
- [[raw/blog-topics/streaming-response-not-supported-archunit-ban-2026-07-02]] — streaming 미지원 + ArchUnit ban 블로그 글감 raw seed. canonical 반영 범위: verified import-ban rule + skeleton scope decision + 과장 금지 항목.
- [[raw/blog-topics/archunit-testcompileonly-fixture-annotation-pattern-2026-06-02]] — ArchUnit `testCompileOnly` fixture annotation-only 패턴 블로그 글감 raw seed.
- [[raw/project-notes/ca-skeleton-operational-contract]] — §3 Structured API Response Contract, §13 API Contract Surface, §34 Stack Commitment (archunit-junit5 1.3.0).
- [[raw/branch-notes/feature-boundary-validation-mapping-contract]] — D5 `no_problem_detail_usage` (import-ban 메커니즘 선례 + ArchUnit suite host).
- [[raw/branch-notes/feature-file-resource-handling-contract]] — D8 (`StreamingResponseBody` 소유, 차단 제외 경계).
- [[raw/errors/archunit-testcompileonly-class-loading-2026-06-02]] — `testCompileOnly` fixture `NoClassDefFoundError` + annotation-only 해결 패턴.
- ca-tmpl @9693d72 코드 (ground-truth): `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java` (line 673~718, D3 rule 3개), `.../architecture/violations/streaming/{SseEmitterUsingFixture,ResponseBodyEmitterUsingFixture,SpringWebSocketHandlerFixture,JakartaWebSocketEndpointFixture}.java`, `.../architecture/allowed/streaming/StreamingResponseBodyAllowedFixture.java`, `.../architecture/ArchitectureViolationFixtureTest.java`.
> Claim ID / Decision Evidence Map / UNSUPPORTED_DECISION: handled per branch-note Decision Evidence Map.
## Cluster / 묶음
<!-- GENERATED: derived-blogs:start -->
- [[wiki/blog/ca-tmpl-streaming-response-support-2026-07-02]]
<!-- GENERATED: derived-blogs:end -->
@@ -0,0 +1,142 @@
---
title: ca-tmpl - Transaction Boundary Abstraction 결정 (TransactionPort)
source_type: project
status: verified
confidence: high
tags: [ca-tmpl, transaction, application-layer, actually-implemented]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
---
# ca-tmpl - Transaction Boundary Abstraction 결정 (TransactionPort)
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념은 [[wiki/concepts/transaction-boundary-abstraction]] 참조.
## 프로젝트 컨텍스트
- **프로젝트**: ca-tmpl — Clean Architecture 기반 백엔드 skeleton 템플릿.
- **목표**: application layer가 Spring transaction API(`@Transactional`, `PlatformTransactionManager`, `TransactionTemplate`)를 직접 import하지 않도록 `TransactionPort` abstraction을 도입.
- **이유**: Clean Architecture / Hexagonal 의존성 규칙("application은 framework를 모른다")을 트랜잭션 경계까지 일관되게 적용하기 위함. 부차적으로 use case 단위 테스트에서 Spring context 없이 트랜잭션 경계를 검증할 수 있도록 testability 확보.
- **진행 단계**: **Phase C2 (코드) 구현 + 로컬 검증 완료.** `feature-application-port-usecase-contract` 브랜치에서 contract type, Spring 구현체, ArchUnit fitness function, 단위 테스트, sample 모듈 마이그레이션까지 작성되어 코드 베이스에 존재한다. 운영 배포 / 통합(DB) 테스트 / 측정값은 아직 없다.
## Ground-truth 대조 (2026-06-04, ca-tmpl @ffb0e13 "트랜잭션 포트와 웹 설정")
`/home/donghyeon/workspace/ca-tmpl` 의 commit `ffb0e13` (본 브랜치 구현 커밋) 코드를 직접 읽어 검증한 사실:
- 패키지 root 는 `dev.caskeleton.*` (브랜치 노트의 이전 stale 값 `com.example.blog` 아님). 본 문서의 이전 "구현 없음" 서술이 stale 이었음 — 실제로는 구현 완료 상태.
- contract type 들은 `src/application-core/.../application/transaction|usecase|command|query|capability` 에 실재.
- `SpringTransactionPort``src/adapter-persistence/.../transaction/SpringTransactionPort.java` 에 실재 (`@Component`, `PlatformTransactionManager` 주입, 모드별 pre-built `TransactionTemplate` 3개).
- ffb0e13 시점의 reference sample 모듈명은 **`sample-ticket`** (`PostService` / `UserService`). 이후 커밋(현재 HEAD `db61075`)에서 **`sample-portfolio`** (`WorkLog*` use case) 로 rename 됨. 본 문서는 ffb0e13 기준 사실을 기록하되, 모듈 rename 은 후속 브랜치 사실로 본다.
- `./gradlew :application-core:test :adapter-persistence:test :app-bootstrap:test --tests '*CleanArchitectureTest' --tests '*ArchitectureViolationFixtureTest'` → 현재 checkout 기준 PASS (exit 0, 2026-06-04 재실행).
## 실제 구현 내용 (`actually-implemented`)
ca-tmpl @ffb0e13 코드에서 직접 확인한 산출물:
**application-core (contract types, `dev.caskeleton.application.*`)**
- `transaction/TransactionPort.java` — outbound port. `<T> T inWrite(Supplier<T>)` / `inRead(Supplier<T>)` / `inNew(Supplier<T>)` 3 메서드 + `Runnable` default 오버로드 3개. Javadoc 에 D11(`Supplier`/`Runnable` 만 받아 checked exception 차단 → 호출 측 `RuntimeException` wrap) + D12(`inNew` = REQUIRES_NEW = 새 physical JDBC connection, pool-sizing 공식 `hikari.maximumPoolSize >= (concurrent_threads * (1 + max_inNew_depth)) + 1`, loop 내 호출 forbidden) 명시.
- `transaction/TransactionMode.java``WRITE` / `READ_ONLY` / `REQUIRES_NEW` 3값.
- `transaction/Isolation.java``READ_COMMITTED` **단일 값만 노출** (REPEATABLE_READ / SERIALIZABLE 은 `feature-transaction-concurrency-contract` 로 위임, READ_UNCOMMITTED 는 forbidden).
- `usecase/UseCase.java` / `CommandUseCase.java` / `QueryUseCase.java` — inbound port base + command/query 분리.
- `command/Command.java` / `query/Query.java` — write/read intent marker.
- `capability/UseCaseCapability.java` — runtime-retained annotation (필수 필드). `capability/Idempotency.java``IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT`. `capability/RepositoryAccess.java``NONE` / `READ_REPOSITORY` / `WRITE_REPOSITORY`.
- `application-core/build.gradle``spring-tx` 의존을 의도적으로 선언하지 않음 (주석으로 사유 명시). `spring-boot-starter` 는 유지(DI 목적, D13).
**adapter-persistence**
- `transaction/SpringTransactionPort.java``TransactionPort` 의 Spring 구현. 생성자에서 모드별 `TransactionTemplate` 3개(write / read / requiresNew)를 미리 빌드. 모두 `ISOLATION_READ_COMMITTED` pin. write=REQUIRED+readOnly false, read=REQUIRED+readOnly true, requiresNew=REQUIRES_NEW+readOnly false. 호출당 mutation 으로 인한 동시성 race 차단.
**app-bootstrap (ArchUnit fitness functions)**`architecture/CleanArchitectureTest.java` 에 다음 rule 실재:
- `application_does_not_use_spring_transactional_annotation` — application 패키지에서 `org.springframework.transaction.annotation.Transactional` 의존 금지 (D3).
- `inbound_port_implementations_end_with_use_case``CommandUseCase`/`QueryUseCase` 구현은 `UseCase` suffix 강제 (D1).
- `inbound_port_implementations_declare_capability` — 모든 use case 구현에 `@UseCaseCapability` 강제.
- `inbound_port_implementations_do_not_declare_keyed_idempotency` — custom `ArchCondition` 으로 `Idempotency.KEYED` 선언 차단 (D14 freeze, `feature-rate-limit-idempotency-contract` merge 시 제거 예정).
- `application_does_not_depend_on_application_context` (D11), `application_does_not_depend_on_adapters_or_transport` (+`org.springframework.web..` 추가), `domain_is_pure`.
- `ArchitectureViolationFixtureTest` + `architecture/violations/` 의 의도된 위반 fixture 클래스들 — violations-as-data 네거티브 테스트.
**sample 모듈 마이그레이션 (ffb0e13: `sample-ticket`)**
- `sample-ticket/.../application/PostService.java`, `UserService.java` — 기존 `@Transactional` 을 전부 제거하고 `tx.inWrite(...)` / `tx.inRead(...)` 호출로 교체. `TransactionPort` 를 생성자 주입.
- `sample-ticket/.../adapter/persistence/repository/PostRepositoryAdapter.java``deleteByAuthorId``@Transactional` 제거 (트랜잭션은 호출 측 use case 가 소유).
## 로컬/dev 검증 (`locally-verified`)
- 단위 테스트 PASS: `application-core` (`TransactionPortTest` Supplier/Runnable delegation, `UseCaseCapabilityTest`, `UseCaseContractTest`), `adapter-persistence` (`SpringTransactionPortTest` — 모드별 propagation / isolation / readOnly / rollback-on-exception 확인).
- ArchUnit fitness function PASS: `CleanArchitectureTest` (위 rule들) + `ArchitectureViolationFixtureTest` (각 rule 이 의도된 위반 fixture 를 실제로 잡아냄).
- `./gradlew check` green (브랜치 노트 기록: 25 actionable tasks). 2026-06-04 재실행 시 위 핵심 test task 들 exit 0 확인.
- 검증 범위는 JVM 단위 테스트 + 정적 분석까지. **실 DB 통합 테스트는 아직 없음** (아래 planned 참조).
## 운영 검증 (`prod-verified`)
**없음.** 운영 환경에 배포된 적이 없다. 측정값 / 인시던트 / 릴리즈 노트 어느 것도 없다.
## 문서/계획만 존재 (`documented-only` / `planned`)
다음 항목은 설계/문서/위임 상태이며 **면접에서 "구현했다 / 검증했다"고 말하면 안 된다**.
- **`TransactionalUseCaseRunner` 대안**: 검토 후 미채택. 단일 abstraction(`TransactionPort`)만 채택했으므로 코드에 존재하지 않는다 (`documented-only`).
- **`REPEATABLE_READ` / `SERIALIZABLE` isolation**: `Isolation` enum 에 노출하지 않음. `feature-transaction-concurrency-contract` 로 위임 (`documented-only`).
- **`inNew` (REQUIRES_NEW) 의 outbox/audit 실제 동작 통합 테스트**: `feature-domain-event-outbox-contract` 로 위임. `max_inNew_depth` 실측은 도메인 use case별 통합 테스트 필요 (`planned`).
- **`@UseCaseCapability(idempotency = KEYED)` 활성화**: `feature-rate-limit-idempotency-contract` merge 전까지 ArchUnit rule 로 freeze (`planned` / 의도적 차단).
- **`externalOutboundAllowed` 의 dependency-aware ArchUnit rule** 및 **`*Port` outbound naming rule**: outbound port marker 정의 후 추가 예정 (`documented-only`).
- **`readOnly = true` 의 driver flush-mode 변경 통합 검증**: Testcontainers 환경에서 Hibernate session statistics 측정 PoC 필요. 현재는 단위 테스트로 `TransactionTemplate.isReadOnly() == true` 만 확인 (`planned`).
## 면접에서 말할 수 있는 범위
### 자신 있게 답할 수 있는 질문
- 왜 application layer에서 Spring `@Transactional` 직접 부착을 금지했는가, 어떤 trade-off가 있는가. (실제 `TransactionPort` 로 구현 + ArchUnit 으로 강제까지 함.)
- `TransactionPort` 를 어떻게 설계했는가 — `inWrite`/`inRead`/`inNew` 3 메서드, `Supplier<T>`/`Runnable` 시그니처, `READ_COMMITTED` 단일 isolation, checked exception 을 노출하지 않는 이유(D11).
- `SpringTransactionPort` 가 모드별 `TransactionTemplate` 을 미리 빌드한 이유 (per-call mutation 의 동시성 race 차단).
- ArchUnit fitness function 으로 `org.springframework.transaction.annotation.Transactional` import 를 실제로 차단하고, violations-as-data 네거티브 fixture 로 rule 동작을 보증한 방법.
- AOP self-invocation 문제가 무엇이고 표준 우회가 무엇인지, `TransactionPort` abstraction 과 어떤 관계인지.
- `REQUIRES_NEW`(`inNew`)가 새 physical connection 을 잡아 pool 을 소모하는 비용 + loop 내 호출 anti-pattern.
### 적당히 답할 수 있는 질문
- `REQUIRES_NEW``NESTED` 의 차이, JPA 에서 `NESTED` 가 일반적으로 권장되지 않는 이유 (savepoint / JDBC 한정 / provider 의존). (단 ca-tmpl 은 `NESTED` 를 API 에 노출하지 않음 — 일반 개념 수준 답변.)
- Isolation level 4단계와 dirty/non-repeatable/phantom read 의 관계, vendor default 차이 (PostgreSQL `READ_COMMITTED` vs MySQL InnoDB `REPEATABLE_READ`).
- 단순 CRUD vs 도메인 복잡도가 큰 프로젝트에서 `TransactionPort` 도입 trade-off 가 어떻게 다른가.
### 답하면 안 되는 질문 (모른다고 해야 함)
- "`readOnly = true` 가 실제 driver flush mode 를 바꾸는 것을 측정했는가?" → **측정 안 함. 단위 테스트로 `isReadOnly()` flag 만 확인.**
- "`inNew` 의 outbox REQUIRES_NEW 동작을 실 DB 로 통합 검증했는가?" → **안 함. `feature-domain-event-outbox-contract` 로 위임.**
- "운영에서 어떤 인시던트나 사례가 있었는가? 성능/지연을 `@Transactional` 과 비교 측정했는가?" → **운영 배포 없음, 측정 없음.**
- "`KEYED` idempotency 를 실제로 적용했는가?" → **freeze 상태. ArchUnit rule 로 선언 자체를 차단 중.**
## 과장 금지 지점
- **"운영에서 검증했다 / prod 에서 돌고 있다" → 금지.** 로컬 단위 테스트 + 정적 분석까지가 검증 범위.
- **"실 DB 통합 테스트로 트랜잭션 전파를 검증했다" → 금지.** `SpringTransactionPortTest` 는 mock `PlatformTransactionManager` 로 template 설정값만 확인한다. 실 connection 동작은 미검증.
- **"UNIL 팀과 동일한 경로를 거쳤다" → 금지.** [[raw/company-tech-blogs/transaction-port-clean-ddd-spring-medium]](UNIL, 2024-05)는 동일 결론에 도달한 **별개 외부 사례**다.
- **"AOP `@Transactional` 은 self-invocation 때문에 깨진다" → 단정 금지.** 표준 우회로 다수 production 에서 잘 동작한다. 함정이지 치명적 결함이 아니다.
- **"`TransactionPort` 가 무조건 우월하다" → 금지.** 단순 CRUD + framework 교체 계획 없음 + Spring 숙련 팀이면 `@Transactional` 직접 부착이 합리적이다. Buckpal(hex-arch 공식 reference), Spring Modulith 등 OSS 다수파/공식 incubator 는 오히려 `@Transactional` 직접/meta-annotation 부착을 한다 — ca-tmpl 의 forbidden 정책은 소수파 자체 taste 임을 함께 인정.
### Blog-topic ingest: transaction boundary 묶음 (2026-07-02)
아래 raw seed들은 transaction boundary canonical에 연결했다.
- [[raw/blog-topics/transaction-port-abstraction-over-spring-transactional-2026-05-28]]: application 계층이 Spring `@Transactional`을 직접 import하지 않도록 `TransactionPort`와 ArchUnit fitness function을 결합한 이유를 다룬다. **주의**: `TransactionPort`가 다수파보다 우월하다고 쓰지 않고 ca-tmpl template repository 맥락의 선택으로 제한한다.
- [[raw/blog-topics/transaction-isolation-vendor-default-pin-2026-07-02]]: DB vendor default isolation 차이를 skeleton contract에서 명시 pin/test 대상으로 다루는 이유를 다룬다. **주의**: 모든 concurrency anomaly를 isolation pin으로 해결한다고 쓰지 않는다.
## 관련 개념
- [[wiki/concepts/transaction-boundary-abstraction]]
## Sources
- [[raw/project-notes/ca-skeleton-operational-contract]] — §14 Transaction/Concurrency, §19 Domain Application Readiness, §29 Topic 2 (TransactionPort 결정 사유)
- [[raw/branch-notes/feature-application-port-usecase-contract]] — TransactionPort interface spec, forbidden import 규칙, Decision Evidence Map (D1~D14), 구현 결과 (round 1 + round 2)
- [[raw/blog-topics/transaction-port-abstraction-over-spring-transactional-2026-05-28]] — TransactionPort abstraction 블로그 글감 raw seed
- [[raw/branch-notes/feature-transaction-concurrency-contract]] — isolation default, propagation default, idempotency / lock 분류
- [[raw/blog-topics/transaction-isolation-vendor-default-pin-2026-07-02]] — transaction isolation vendor default pin 블로그 글감 raw seed
- ca-tmpl @ffb0e13 코드 (ground-truth): `src/application-core/.../application/transaction|usecase|command|query|capability/*.java`, `src/adapter-persistence/.../transaction/SpringTransactionPort.java`, `src/app-bootstrap/.../architecture/CleanArchitectureTest.java`
## Cluster / 묶음
<!-- GENERATED: derived-blogs:start -->
- [[wiki/blog/ca-tmpl-transaction-boundary-abstraction-2026-07-02]]
<!-- GENERATED: derived-blogs:end -->
@@ -0,0 +1,143 @@
---
title: ca-tmpl - Transactional Outbox 결정 (SKIP LOCKED polling)
source_type: project
status: verified
confidence: high
tags: [ca-tmpl, outbox, event-driven, actually-implemented, locally-verified]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
---
# ca-tmpl - Transactional Outbox 결정 (SKIP LOCKED polling)
> Layer: `wiki/projects/` — 내 프로젝트(ca-tmpl) 사실. 일반 패턴 정의는 [[wiki/concepts/transactional-outbox-pattern]] 참조.
## 프로젝트 컨텍스트
ca-tmpl은 Clean Architecture 기반 백엔드 skeleton 템플릿입니다. 도메인 변경과 외부 이벤트 발행의 정합성 요구에서 **dual-write를 회피**하기 위해 outbox table + SKIP LOCKED polling 방식을 채택한다는 운영 계약을 문서화한 상태입니다.
진척 상황:
- **C2 구현 + 로컬 검증 완료**: outbox row schema, append/store port, SKIP LOCKED claim repository, relay use case, scheduler, metrics, reaper, disabled publisher, sample event append path가 코드화되어 있다. 2026-07-02 `./gradlew check` 통과로 로컬 검증했다.
본 문서는 그 결정 자체와 검토한 대안, 그리고 "지금 시점에 말할 수 있는 범위"를 분리해 둡니다.
## 실제 구현 내용 (`actually-implemented`)
- `application-core``OutboxAppendPort`, `OutboxStorePort`, `OutboxEvent`, `OutboxEventStatus`, `PublishPendingOutboxEventsUseCase`, `OutboxBackoffPolicy`.
- `adapter-persistence-rdbms``OutboxEventEntity`, `OutboxStoreAdapter`, `OutboxReaper`, `OutboxClaimRepository`, `OutboxEventJpaRepository`.
- `adapter-persistence-postgresql``PostgreSqlOutboxClaimRepository``V3__outbox_event.sql`. claim query는 `FOR UPDATE SKIP LOCKED`를 사용한다.
- `adapter-outbound``OutboxMessagePublishAdapter`, `DisabledOutboxMessagePublisher`, `OutboxEnvelopeJson`.
- `app-bootstrap``OutboxConfig`, `OutboxSettings`, `OutboxRelayScheduler`, `OutboxMetrics`, `OutboxLeaderElectionToken`.
- `sample-portfolio``CreateWorkLogOutboxTest``WorkLogReservedIntegrationEvent*` 계열이 sample domain event → integration event/outbox append path를 검증한다.
## 로컬/dev 검증 (`locally-verified`)
- `./gradlew check` 통과(2026-07-02, `BUILD SUCCESSFUL`, 114 tasks).
- `PublishPendingOutboxEventsUseCaseTest`, `OutboxBackoffPolicyTest`, `NewOutboxEventTest`가 application relay logic을 검증한다.
- `OutboxStoreAdapterTest`, `OutboxReaperTest`, `OutboxReaperWiringTest`가 RDBMS adapter와 cleanup wiring을 검증한다.
- `OutboxRowLifecycleContractTest`, `OutboxPublisherLeaderElectionContractTest`, `OutboxAppendTransactionalContractTest`가 PostgreSQL Testcontainers 기반으로 row lifecycle, SKIP LOCKED multi-relay claim, transactional append를 검증한다.
- `OutboxStatusRegistryContractTest`, `EventPayloadPiiContractTest`, `OutboxMessagePublishAdapterTest`가 registry/status, payload safety, publish adapter를 검증한다.
## 운영 검증 (`prod-verified`)
**없음.** ca-tmpl은 skeleton 템플릿이며 운영 인스턴스가 존재하지 않음.
## 문서/계획만 존재 (`documented-only` / `planned`)
다음 항목들은 모두 canonical operational contract(§11, §29 Topic 3) 및 branch-notes에 합의된 **문서/설계 수준**입니다. 구현 사실 아님.
### Outbox row schema (implemented)
- `id`, `aggregate_type`, `aggregate_id`, `event_type`, `payload`, `headers`, `status`, `attempts`, `next_attempt_at`, `created_at`, `published_at`, `last_error` 컬럼 어휘 합의.
- row status: `PENDING → IN_FLIGHT → PUBLISHED` 정상 경로, 실패 시 `FAILED → DEAD`(DLQ).
- per-aggregate FIFO 순서 보존을 목표로 함.
### Publisher state machine (implemented)
- claim transaction: `READ_COMMITTED` isolation + `SELECT ... FOR UPDATE SKIP LOCKED LIMIT n`.
- multi-instance publisher 운영 시 row 단위 lock으로 중복 claim 방지.
- publish 성공 → `PUBLISHED`로 update + commit.
- publish 실패 → `attempts++`, `next_attempt_at` 갱신(backoff with jitter), `FAILED`로 회귀.
- `attempts >= max(=3)` 도달 시 `DEAD`로 전이 후 DLQ 대상.
### Retry / DLQ vocabulary (partially implemented)
- exponential backoff with jitter, 최대 3회 retry, 그 이후 `DEAD` → DLQ.
- DLQ 상태와 runbook은 존재하지만, 운영 재처리 도구/대시보드는 없다.
### 대안 검토 (decided, not implemented)
ca-tmpl이 outbox 구현 방식을 결정하면서 검토한 7종 대안과 채택 사유:
1. **SKIP LOCKED polling** — 채택. RDB만으로 운영 가능, Kafka Connect 인프라 불요, lag 수 초 허용 범위.
2. **Debezium CDC** — 보류. WAL 기반으로 lag은 짧지만 Kafka Connect 클러스터·connector·slot 운영 인력 부재.
3. **Kafka Connect Outbox SMT (Debezium event router)** — 보류. Debezium 도입 자체가 보류되므로 동반 제외.
4. **Dual-write (직접 publish)** — 명시적 anti-pattern. 채택 안 함(outbox 채택의 negative reference).
5. **Event sourcing** — 미채택. 전달 정합성이 아닌 도메인 모델링 결정이므로 ca-tmpl 범위 밖.
6. **Spring `@TransactionalEventListener`** — 미채택. JVM in-process 한정이라 외부 broker 발행에는 부적합. in-process side effect 용도로만 사용 가능.
7. **Netflix DBLog 류 자체 CDC** — 미채택. 베이스라인 인프라 투자 규모가 ca-tmpl 범위를 초과.
### Migration trigger (planned)
- 다음 가정이 깨지면 Debezium CDC로 마이그레이션 검토:
- publish lag SLO 위반(수 초 허용을 깨는 sub-second 요구가 생김), 또는
- polling 쿼리로 DB load가 포화되는 신호 발생.
- 현 시점에는 가정이 유지된다고만 말할 수 있음. 도입 시점/일정 약속 없음.
## 면접에서 말할 수 있는 범위
### 자신 있게 답할 수 있는 질문
- **dual-write가 왜 위험한가** — DB commit과 broker publish 사이의 프로세스/네트워크 실패가 정합성을 깨는 시나리오를 설명할 수 있음.
- **`FOR UPDATE SKIP LOCKED` semantics** — 잠긴 row를 차단 없이 skip하여 multi-instance publisher 간 claim 경합을 해소하는 원리, 잠금 범위가 row 단위 + 트랜잭션 종료 시 해제임을 설명할 수 있음.
- **outbox cleanup 정책의 필요성** — archived row를 TTL/파티션 회전으로 정리하지 않으면 인덱스 비대·vacuum 비용 증가가 발생하는 이유.
- **at-least-once + idempotent consumer** — outbox + 비동기 publish가 exactly-once가 아니라는 점과, consumer가 `eventId`/`idempotencyKey`로 dedupe해야 정합성이 닫힌다는 점.
### 적당히 답할 수 있는 질문
- **Debezium CDC migration trigger** — 어떤 가정(lag SLO, DB load)이 깨질 때 전환을 정당화하는지 설명 가능. 단, 실제 운영 경험은 없음.
- **outbox row status 머신** — 어휘는 합의되어 있으나 직접 구현하지는 않았음을 전제로 설명.
### 답하면 안 되는 질문 (모른다고 해야 함)
- "outbox를 직접 구현했는가" → **구현했다.** 단 로컬/Testcontainers 검증까지이며 운영 배포 검증은 없다.
- "polling lag을 측정해 본 수치는?" → **측정값 없음.** relay 동작 검증은 있지만 부하/lag 수치 단정 금지.
- "DLQ 운영 / 재처리 경험" → 어휘는 정의했지만 **실제 DLQ를 운영해 본 적 없음**.
- "production에서 outbox로 인한 인시던트 처리 경험" → 운영 인스턴스 자체가 없음.
## 과장 금지 지점
ca-tmpl을 설명할 때 사실보다 부풀려지기 쉬운 표현:
- **"outbox = exactly-once delivery"** → 틀림. 정확한 표현은 **at-least-once delivery + idempotent consumer**. ca-tmpl 운영 계약도 at-least-once 전제.
- **"Debezium도 검토했고 곧 도입 예정"** → 틀림. Debezium은 검토 결과 **migration trigger만 정의된 상태**이며 도입 일정·작업 없음. "lag 가정이 깨질 때만 전환을 검토한다"가 정확.
- **"outbox 패턴을 운영에서 검증했다"** → 틀림. 구현과 로컬/Testcontainers 검증은 있으나 운영 배포·측정은 없다.
- **"SKIP LOCKED로 모든 동시성 문제를 막았다"** → 틀림. SKIP LOCKED는 **claim 단계 row 경합**만 해소. publish 후 commit 실패로 인한 재발행은 별개 문제이며 consumer dedupe가 해결.
- **"event sourcing도 비교 검토했고 도입할 수 있었다"** → 과장. event sourcing은 도메인 재설계 결정이며 ca-tmpl 범위 밖. "비교군으로만 언급"이 정확.
- **DLQ / 재처리 경험을 가진 것처럼 말하기** → 어휘 합의만 있고 운영 경험 없음.
### Blog-topic ingest: outbox ordering gate (2026-07-02)
[[raw/blog-topics/skip-locked-outbox-per-aggregate-fifo-gate-2026-06-11]] 는 `FOR UPDATE SKIP LOCKED` claim이 row 경합은 줄이지만 per-aggregate FIFO와 충돌할 수 있다는 점, 그리고 `NOT EXISTS` head gate로 tail 선발행을 막는 설계를 블로그로 풀기 위한 raw seed다.
- **canonical 반영 범위**: SKIP LOCKED polling 결정 문서에 ordering gate와 strict FIFO trade-off 글감을 연결했다.
- **blogify 전 조건**: 충족. 이 문서는 2026-07-02 기준 코드와 `./gradlew check`로 검증됨.
- **블로그 전 과장 방지**: SKIP LOCKED가 순서 보존까지 해결한다고 쓰지 않고, claim 경합 해소와 ordering gate를 분리한다.
## 관련 개념
- [[wiki/concepts/transactional-outbox-pattern]]
## Sources
- [[raw/project-notes/ca-skeleton-operational-contract]] — §11 Adapter Failure / §29 Topic 3 (outbox 결정 canonical map)
- [[raw/branch-notes/feature-domain-event-outbox-contract]] — outbox publisher SSOT, row status, claim transaction, at-least-once + dedupe 합의
- [[raw/branch-notes/feature-background-job-async-contract]] — outbox publisher가 공유하는 retry/DLQ vocabulary(exp backoff with jitter, max 3, DLQ exhausted) SSOT
- [[raw/blog-topics/skip-locked-outbox-per-aggregate-fifo-gate-2026-06-11]] — SKIP LOCKED vs per-aggregate FIFO gate 블로그 글감 raw seed.
## Cluster / 묶음
<!-- GENERATED: derived-blogs:start -->
- [[wiki/blog/ca-tmpl-transactional-outbox-pattern-2026-07-02]]
<!-- GENERATED: derived-blogs:end -->