fix: 하네스 제거 및 keycloak 문서 보강
This commit is contained in:
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/chore-harness-policy-engine-alignment.md
|
||||
@@ -0,0 +1,249 @@
|
||||
---
|
||||
title: branch / chore-harness-policy-engine-alignment
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-CA-SKELETON-CHILD-7869EDB8
|
||||
kind: branch-child
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-033
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-BOOTSTRAP-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-BUILD-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
branch: chore-harness-policy-engine-alignment
|
||||
parent_branch: feature-developer-experience-contract
|
||||
related_projects: [ca-skeleton]
|
||||
tags: [branch, ca-skeleton, architecture, testing, build-tooling, code-generation, multi-module]
|
||||
created: 2026-07-20
|
||||
target_merge:
|
||||
status_label: review
|
||||
contract_packet_sha256: 2e26526393b48c4063a84c2593debc3f8aa4858aab480aeae9f9167bf49b778c
|
||||
---
|
||||
|
||||
# branch: chore-harness-policy-engine-alignment
|
||||
|
||||
> Layer: `raw/branch-notes/` — ca-tmpl 개발 하네스를 registry-driven policy engine으로 정합한 작업 기록. Git은 detached HEAD `e68dd67a26d4579a070f10ee386213d3c23e6957`에서 작업했고, 사용자 소유 human-only commit 정책에 따라 commit/staging하지 않았다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- [[raw/branch-notes/feature-developer-experience-contract]] — 개발 하네스와 단일 진입 검증 경험의 parent Work Item.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: fresh environment에서 ./gradlew bootstrap이 성공한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-BOOTSTRAP-001@1` | 신규 환경의 default 진입 명령은 ./gradlew bootstrap이다 | harness validator와 Gradle verification command를 저장소 안에 둔다. | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-BUILD-001@1` | build tool은 Gradle Groovy DSL이다 | registry를 `settings.gradle`과 dependency verifier가 소비한다. | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | 19개 leaf module의 topology·dependency·test command는 `.harness/project/modules.yaml` 하나가 소유한다. | `local` | `UNSUPPORTED_DECISION` | `implemented` |
|
||||
| D2 | verdict/evidence는 필수 필드·산식·revision/rule hash·실제 upstream artifact를 fail-closed로 검증한다. | `local` | `UNSUPPORTED_DECISION` | `implemented` |
|
||||
| D3 | canonical agent 5개에서 Claude/Codex/Antigravity/plugin 산출물을 생성하고, platform hook adapter만 공식 이벤트 계약을 번역한다. | `local` | `raw/official-docs/google-antigravity-hooks.md#GOOGLE-ANTIGRAVITY-HOOKS-C1`, `#GOOGLE-ANTIGRAVITY-HOOKS-C2` | `implemented` |
|
||||
| D4 | review/report 의식은 file count가 아니라 risk와 evidence profile로 선택한다. | `local` | `UNSUPPORTED_DECISION` | `implemented` |
|
||||
| D5 | agent는 stage/commit하지 않고 사람이 working tree를 검토·commit한다. | `local` | `UNSUPPORTED_DECISION` | `implemented` |
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
| (없음) | - | 상속 결정 override 없음 | - | - |
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
## 목표
|
||||
|
||||
- 중첩 module topology와 flat-path 기반 훅·agent·Gradle verifier의 drift를 제거한다.
|
||||
- 판단 결과와 테스트 증거를 서로 다른 플랫폼에서도 같은 schema와 revision identity로 검증한다.
|
||||
- 과도한 전수 인용·N! 순열·file-count 보고 분할을 risk/evidence profile로 바꾼다.
|
||||
|
||||
- 이슈: 사용자 제공 `개발 하네스 분석·리뷰` 감사 보고서
|
||||
- PR: 없음 — human-only commit handoff
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- `.harness/` module registry, task packet, profiles, risk/review/report/evidence policy.
|
||||
- import mutation gate, verdict/evidence schema, revision and rule hashes.
|
||||
- Claude/Codex/Antigravity/plugin agent renderer와 정적 parity snapshot.
|
||||
- `src/settings.gradle`, `src/build.gradle`의 registry projection.
|
||||
- root/plugin/module guidance의 Spring Boot 4.0.0·nested topology 정합.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 인증된 세 외부 제품에서의 end-to-end golden 실행.
|
||||
- 기존 production Java의 ArchUnit·Checkstyle 위반 수정.
|
||||
- commit, staging, PR 생성.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/google-antigravity-hooks]] | D3 — Antigravity adapter의 JSON/camelCase/PreToolUse/Stop decision 계약 |
|
||||
|
||||
## TODO
|
||||
|
||||
- [x] 19개 leaf module registry와 nearest owner resolution — 등급: `locally-verified`
|
||||
- [x] nested import mutation과 fail-closed shell/file write gate — 등급: `locally-verified`
|
||||
- [x] strict verdict/evidence/revision/rule-hash validation — 등급: `locally-verified`
|
||||
- [x] canonical renderer와 네 플랫폼 static parity — 등급: `locally-verified`
|
||||
- [x] risk/profile 기반 orchestration·reporting·citation guidance — 등급: `locally-verified`
|
||||
- [ ] 인증된 Claude/Codex/Antigravity 실제 golden execution — 등급: `planned`
|
||||
- [ ] 기존 production ArchUnit·Checkstyle baseline 위반 정리 — 등급: `planned`, 본 branch 범위 밖
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- 최초 import gate는 실제 `src/adapter/inbound|outbound/...` 중첩 경로를 production으로 인식하지 못했다.
|
||||
- review chain은 ignored physical guidance의 revision hash 누락, production `Fake*.java` risk 오분류, command evidence 총계 불일치까지 추가로 발견했고 mutation test로 고정했다.
|
||||
- 전체 Gradle check는 하네스 변경과 무관한 기존 production 위반으로 green이 아니다.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-07-20: registry를 Gradle settings/dependency verifier/import gate/agent runner의 공통 topology SSOT로 사용한다. 대안인 각 consumer별 allowlist는 drift가 이미 재현되어 폐기했다. 근거: D1 `UNSUPPORTED_DECISION` — 저장소 내부 trade-off.
|
||||
- 2026-07-20: physical ignored guidance도 revision identity 선언에 포함한다. 대안인 tracked diff-only hash는 upstream review artifact가 stale guidance 변경을 놓쳤다. 근거: D2 `UNSUPPORTED_DECISION`.
|
||||
- 2026-07-20: Antigravity는 shared validator를 호출하고 공식 hook event만 번역한다. 근거: D3, `GOOGLE-ANTIGRAVITY-HOOKS-C1/C2`.
|
||||
- 2026-07-20: low/medium/high risk와 review-lite/standard/audit-deep/regulated profile을 사용한다. file count 자체는 risk classifier가 아니다. 근거: D4 `UNSUPPORTED_DECISION`.
|
||||
- 2026-07-20: commit은 사람만 수행한다. 근거: D5 `UNSUPPORTED_DECISION` — review 전 immutable commit을 강제하지 않고 working-tree identity를 사용하기 위한 운영 선택.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | 중앙 module registry | 동일 topology를 2개 이상 consumer가 사용하면 registry; 단일 독립 script면 local declaration 가능 | `UNSUPPORTED_DECISION` | repository-local verified | registry schema 변경 시 모든 projection test 필요 |
|
||||
| D2 | strict verdict/evidence/revision/rule hash | review chain 결과를 재사용하면 strict artifact; 단발 로컬 메모는 간단 결과 가능 | `UNSUPPORTED_DECISION` | mutation-tested | external platform lifecycle E2E 미검증 |
|
||||
| D3 | canonical render + thin platform adapter | 플랫폼 body 의미가 같고 wrapper 문법만 다를 때; 플랫폼 고유 agent는 explicit exception | `raw/official-docs/google-antigravity-hooks.md#GOOGLE-ANTIGRAVITY-HOOKS-C1`, `#GOOGLE-ANTIGRAVITY-HOOKS-C2` | `official-vendor-doc + locally-verified` | 실제 authenticated Antigravity run 필요 |
|
||||
| D4 | risk/evidence profile | high-risk면 full chain; low-risk면 focused inline; 규제 요구면 regulated profile | `UNSUPPORTED_DECISION` | repository-local verified | 분류 flag를 호출자가 정직하게 제공해야 함 |
|
||||
| D5 | human-only commit | 사용자가 working tree를 소유하는 collaborative workflow; 자동 release bot은 별도 policy 필요 | `UNSUPPORTED_DECISION` | documented + enforced in generated prompts | 사람이 commit 전 변경을 검토해야 함 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
### 1. Topology와 task packet
|
||||
|
||||
> **Trace**: D1 (`UNSUPPORTED_DECISION`).
|
||||
|
||||
- `.harness/project/modules.yaml`: 19 leaf의 source/Gradle/package/dependency/test/instruction owner.
|
||||
- `.harness/lib/module_registry.py`: longest filesystem boundary owner resolution.
|
||||
- `.harness/lib/task_resolver.py`: risk, selected profiles, focused command, immutable packet·rule hashes.
|
||||
- `src/settings.gradle`과 `src/build.gradle`: registry를 parse해 project와 dependency verification을 투영한다.
|
||||
|
||||
### 2. Enforcement와 evidence
|
||||
|
||||
> **Trace**: D2 (`UNSUPPORTED_DECISION`).
|
||||
|
||||
- `.harness/lib/import_policy.py`, `import_hook.py`: platform-neutral import/write policy.
|
||||
- `.harness/lib/verdict.py`: schema-level 필수값, nonnegative counts, 산식, command row reconciliation, upstream artifact hash, revision identity.
|
||||
- `.harness/project/revision-surfaces.yaml`: ignored physical harness input과 transient exclusion.
|
||||
|
||||
### 3. Platform generation
|
||||
|
||||
> **Trace**: D3 (`GOOGLE-ANTIGRAVITY-HOOKS-C1/C2`).
|
||||
|
||||
- `.harness/agents/*.md`: 5개 canonical body.
|
||||
- `.harness/generators/render_agents.py`: Claude/Codex/Antigravity/plugin physical output와 tracked snapshot 생성.
|
||||
- `.harness/adapters/antigravity_import_hook.py`, `antigravity_hook.py`: 공식 event/decision 번역만 소유한다.
|
||||
- **UNSUPPORTED_IMPL_DECISION**: source hash metadata와 physical/snapshot 이중 출력은 clean clone parity와 local installed surface를 함께 검사하기 위한 선택이다.
|
||||
|
||||
### 4. Risk와 reporting
|
||||
|
||||
> **Trace**: D4·D5 (`UNSUPPORTED_DECISION`).
|
||||
|
||||
- low: docs/comments/test fixture 또는 characterization으로 보호된 local refactor; high/medium trigger가 우선한다.
|
||||
- medium: behavior/cross-module/external integration.
|
||||
- high: security/migration/public contract/build/dependency/architecture/CI/deployment/transaction/concurrency.
|
||||
- evidence matrix, quote verification, durable report는 selected profile에 비례한다.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**: unknown production path는 medium; malformed write/verdict는 fail-closed; ignored guidance mutation은 revision을 바꾸고 transient evidence/cache/marker는 바꾸지 않는다.
|
||||
- **다른 계약 의존**: parent D3/D4의 bootstrap·entrypoint 계약을 consume한다. production architecture baseline 정리는 `feature-architecture-enforcement-rules` owner 범위다.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 세 외부 제품에서 같은 seeded task가 같은 verdict/evidence를 만든다 | repository-local static test는 인증 제품 lifecycle을 실행하지 않음 | Claude/Codex/Antigravity 각각에서 golden task를 실행하고 evidence JSON 비교 | `needs-confirmation` |
|
||||
| module registry 변경이 모든 consumer를 invalidation한다 | 새 consumer가 registry 밖 local map을 만들 수 있음 | policy parity와 forbidden legacy token scan을 CI에서 유지 | `locally-verified` |
|
||||
| production 전체 check가 green이다 | 기존 HEAD에도 architecture/checkstyle 위반 존재 | 별도 production-fix branch 후 `./gradlew check --console=plain` | `needs-confirmation` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- nested adapter 경로가 legacy regex를 우회함 — registry mutation test로 해결.
|
||||
- ignored physical guidance가 revision identity에서 빠짐 — `revision-surfaces.yaml`과 mutation test로 해결.
|
||||
- 기존 production baseline 실패 — [[raw/errors/ca-tmpl-preexisting-check-baseline-failures-2026-07-20]]로 분리, 미해결.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
- `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s .harness/tests -v` → 106/106 PASS.
|
||||
- `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s .claude/hooks -p 'test_*.py' -v` → 74/74 PASS.
|
||||
- module validator → 19 leaf PASS; policy parity와 renderer `--check` PASS; `git diff --check` PASS.
|
||||
- `./gradlew projects verifyCleanArchitectureDependencies --console=plain` → PASS.
|
||||
- focused `CleanArchitectureTest`와 전체 `check` → 기존 `IdempotencyRecordEntity.requestHash columnDefinition='char(64)'` 위반으로 FAIL.
|
||||
- `./gradlew check -x :app-bootstrap:test --console=plain` → 기존 domain `NeedBraces` 3건으로 FAIL.
|
||||
- CA spec review → 11/11 PASS.
|
||||
- CA architecture review → diff-specific blocking 0; repository verdict는 위 기존 ArchUnit 1건 때문에 FAIL.
|
||||
- CA quality review → architecture upstream이 ready가 아니므로 미실행.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/google-antigravity-hooks]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: interviews:start -->
|
||||
- [[raw/interviews/manifest-driven-multi-platform-agent-harness]]
|
||||
<!-- GENERATED: interviews:end -->
|
||||
|
||||
<!-- GENERATED: errors:start -->
|
||||
- [[raw/errors/ca-tmpl-preexisting-check-baseline-failures-2026-07-20]]
|
||||
<!-- GENERATED: errors:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/manifest-driven-agent-harness-policy-engine]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
### Sub-branches
|
||||
|
||||
- 없음.
|
||||
|
||||
### 오류 기록
|
||||
|
||||
- [[raw/errors/ca-tmpl-preexisting-check-baseline-failures-2026-07-20]] — 하네스 변경과 무관한 ArchUnit·Checkstyle baseline 실패.
|
||||
|
||||
### 면접 준비
|
||||
|
||||
- [[raw/interviews/manifest-driven-multi-platform-agent-harness]] — registry, renderer, evidence identity 설계 질문.
|
||||
|
||||
### 강의
|
||||
|
||||
- 없음.
|
||||
|
||||
### blog-topics
|
||||
|
||||
- [[raw/blog-topics/manifest-driven-agent-harness-policy-engine]] — 중복 prompt를 policy engine으로 바꾼 과정.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- 없음 — 이번 캡처에서는 branch-note와 파생 raw 자료만 생성했다.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- merge/commit: 사용자 handoff, 아직 없음.
|
||||
- canonical 추출: 요청되지 않아 `wiki/*` 직접 생성 없음.
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/chore-ulid-to-uuidv7.md
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
title: branch / chore-ulid-to-uuidv7 (ID 생성 전략 ULID → UUIDv7)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-CA-SKELETON-CHILD-F1674A3D
|
||||
kind: branch-child
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-046
|
||||
inherits:
|
||||
- DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-DATABASE-001@1
|
||||
- DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-RANDOM-001@1
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
branch: chore-ulid-to-uuidv7
|
||||
parent_branch: feature-resource-identifier-contract
|
||||
git_branch: refactor/ulid-to-uuidv7
|
||||
related_projects: [ca-skeleton, nplus1-presentation-prep]
|
||||
tags: [branch, ca-skeleton, data-modeling, persistence, api-design, ulid, uuid-v7]
|
||||
created: 2026-07-08
|
||||
target_merge: develop
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 59f232e47d66c49ed76c4e3ffa50cab20877fe73c40037b9e3d49d1b9707b984
|
||||
---
|
||||
|
||||
# branch: chore-ulid-to-uuidv7 — ID 생성 전략 ULID → UUIDv7
|
||||
|
||||
> Layer: `raw/branch-notes/` — ca-tmpl의 엔티티 식별자 생성을 ULID에서 **UUIDv7**로 교체. [[raw/branch-notes/feature-resource-identifier-contract]](D3/D5/D10/D17)를 개정하는 계약-레벨 변경.
|
||||
> 실제 git 브랜치: `refactor/ulid-to-uuidv7`. 위키 파일명은 prefix 규칙상 `chore-`.
|
||||
> ADR: `ca-tmpl:docs/choice/0001-id-strategy-ulid-to-uuidv7.md`.
|
||||
> `status_label`: `in-progress` (구현 완료 · `./gradlew check` 전량 GREEN 로컬 검증됨 (2026-07-08) · 사용자 커밋/머지 대기).
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모
|
||||
|
||||
- [[raw/branch-notes/feature-resource-identifier-contract]] — D3 canonical form, D5 no-direct-gen, D10 ULID↔uuid 저장, D17 no-long-PK를 소유하며 이 브랜치가 D3/D10을 UUIDv7 기준으로 정제한다.
|
||||
- 트리거: [[raw/branch-notes/experiment-nplus1-highlight-feed]] — N+1 피드 도메인 파운데이션 가이드 작성 중 ID 규약(ULID) 재검토에서 파생. 그쪽 §0.2가 이 결정을 참조.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: ULID format·PostgreSQL uuid persistence·SecureRandom test가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-DATABASE-001@1` | database는 PostgreSQL 16 단일 stack이다 | DB 컬럼을 native `uuid`로 유지하고 문자열/생성 전략만 UUIDv7로 바꾼다. | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-RANDOM-001@1` | ULID·idempotency key·token 생성의 random source는 SecureRandom이다 | UUIDv7 generator와 금지 rule이 project random-source 경계를 유지하게 한다. | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
기존 branch-local 결정은 아래 `## Decision Evidence Map`의 D-01~D-05가 소유한다. 부모 branch는 legacy packet이라 pinned project decision을 별도로 제공하지 않는다.
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
- 없음.
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
ULID의 시간정렬은 **밀리초 타임스탬프 기준**이라 다중 인스턴스 환경에서 같은 ms 내 순서를 보장하지 못하고, 표준 타입이 아니라 커스텀 라이브러리(`ulid-creator`)+Crockford 코덱+ULID↔UUID 변환 매퍼가 필요했다. **UUIDv7(RFC 9562)** 은 표준 `java.util.UUID`이면서 상위 48비트가 ms 타임스탬프라 ULID와 **동일한 인덱스 지역성**을 유지한다 → 표준화 + 커스텀 의존 제거가 목적(성능 개선이 아님).
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위 (69파일)
|
||||
|
||||
- **생성기**: `com.github.f4b6a3:ulid-creator:5.2.3` → `com.github.f4b6a3:uuid-creator:6.1.1`, `UlidCreator.getMonotonicUlid()` → `UuidCreator.getTimeOrderedEpochPlus1()`(UUIDv7, **모노토닉 변형** — 구 `getMonotonicUlid()` 의 밀리초-내 단조증가 의도를 보존; jar javap 로 API 확인). build.gradle 2곳 + 락파일 재생성(app-bootstrap `sampleFixture` config 는 `canBeResolved=false` 라 `resolveAndLockAll` 이 못 만져서 stale `ulid-creator` 줄을 수동 병합 제거).
|
||||
- **코덱/팩토리 리네임**: `UlidCodec`→`UuidCodec`(+ Spock spec), `UlidPosterIdFactory`/`UlidWorkLogIdFactory`/`UlidOutboxEventIdFactory` → `Uuid*`.
|
||||
- **도메인 ID 값객체**: 정규식 26자 Crockford ULID → 36자 표준 UUID. `PosterId`/`WorkLogId` javadoc 갱신.
|
||||
- **매퍼**: `Ulid.from(id).toUuid()` → `UUID.fromString(id.value())`, `Ulid.from(uuid).toString()` → `uuid.toString()` (변환 소멸, `java.util.UUID` stdlib).
|
||||
- **웹**: 컨트롤러 `toId`, ID 시리얼라이저 — ULID 대문자 정규화 → UUID 소문자 canonical.
|
||||
- **ArchUnit**: `NO_UUID_RANDOM_IN_CONTROLLER`의 FQN `com.github.f4b6a3.ulid.UlidCreator` → `com.github.f4b6a3.uuid.UuidCreator`(`UUID.randomUUID` 금지는 유지). `NO_LONG_ID_PK` 주석(D10) 갱신.
|
||||
- **문서**: identifier·sample-portfolio CLAUDE.md/README, `ContractSnapshots`, 테스트 19개(픽스처 26자→36자).
|
||||
- **불변**: DB `uuid` 컬럼(128비트) 그대로 — 상위 비트만 v7 레이아웃.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- PostgreSQL column type 변경과 data migration은 수행하지 않는다.
|
||||
- local test 결과를 prod 성능 또는 다중 인스턴스 순서 보장으로 승격하지 않는다.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/rfc9562-uuid]] | D-01의 UUIDv7 layout과 timestamp-ordered identifier 정의를 뒷받침한다. |
|
||||
| [[raw/official-docs/ulid-spec]] | 기존 ULID format·monotonic semantics와 UUIDv7 전환 전후 경계를 비교한다. |
|
||||
|
||||
## TODO
|
||||
|
||||
- [x] UUIDv7 generator·codec·factory·mapper·fixture 전환 — 등급: `actually-implemented`
|
||||
- [x] `./gradlew check`와 monotonic 1000-loop 검증 — 등급: `locally-verified`
|
||||
- [ ] 부모 identifier 계약 D3/D10과 project WI-046 completion text 갱신 — 등급: `planned`
|
||||
- [ ] 사용자 commit·merge와 downstream 소비자 확인 — 등급: `needs-confirmation`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- 구현과 local 검증은 끝났지만 부모 계약과 project registry는 아직 ULID 문구를 소유한다.
|
||||
- N+1 branch는 변경 계기만 제공하며 이 branch의 project owner나 work-item dependency가 아니다.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-07-08 D-01: resource identifier canonical form을 ULID에서 UUIDv7로 바꾼다. / 이유: native `UUID` wire/storage shape와 generator 표준화를 맞춘다. / 대안: ULID 유지, UUIDv4. / 근거: [[raw/official-docs/rfc9562-uuid]], [[raw/official-docs/ulid-spec]].
|
||||
- 2026-07-08 D-03: UUIDv7 generator는 millisecond 내 단조 증가 의도를 보존하는 `getTimeOrderedEpochPlus1()`을 사용한다. / 검증: local API inspection과 loop test.
|
||||
- 2026-07-08 D-05: PostgreSQL native `uuid` column은 유지하고 변환 mapper만 제거한다. / 근거: inherited database decision.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|
|
||||
| D-01 | ULID → UUIDv7 채택 | ADR docs/choice/0001; 사용자 결정(다중 인스턴스 순서 한계 + 비표준). UUIDv7 상위 48비트 ms = ULID와 동일 지역성 | Strong | API 브레이킹(26→36자) — 다운스트림/스냅샷 갱신 필요 |
|
||||
| D-02 | UUIDv4는 기각 | 랜덤 PK = B-tree 단편화(페이지분할·캐시지역성↓), 쓰기多 테이블 성능 후퇴 | Strong | 없음(발표 시연 소재로 별도 활용) |
|
||||
| D-03 | 생성 = uuid-creator `getTimeOrderedEpochPlus1()` (모노토닉 UUIDv7) | jar `javap` 로 메서드 시그니처 확인 + `check` GREEN. `getTimeOrderedEpoch()`(비-모노토닉) 대신 Plus1 선택 = 구 `getMonotonicUlid()` 의도(ms-내 단조증가) 미러 | Strong (검증됨) | 없음 — 모노토닉 문자열 정렬 1000-loop 테스트 GREEN |
|
||||
| D-04 | ArchUnit FQN ulid→uuid 교체(생성 위치 강제 유지) | CleanArchitectureTest `NO_UUID_RANDOM_IN_CONTROLLER` 수정 + suite GREEN | Strong (검증됨) | 없음 |
|
||||
| D-05 | 저장 스키마 불변(native uuid) | 매퍼만 변환 제거, 마이그레이션 무수정 | Strong | 없음 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
| Anchor | 적용 | 검증 |
|
||||
|---|---|---|
|
||||
| identifier adapter | `UuidCreator.getTimeOrderedEpochPlus1()`으로 ID를 생성하고 domain은 factory port만 사용한다. | monotonic 1000-loop와 adapter test |
|
||||
| web/serialization | UUID canonical lowercase 36자를 입력·출력 계약으로 사용한다. | wire test와 snapshot scrubber |
|
||||
| persistence | `UUID.fromString`/`UUID.toString`을 사용하고 native `uuid` column을 유지한다. | mapper/integration test와 migration diff 없음 확인 |
|
||||
| architecture rule | controller direct generation 금지를 `UuidCreator` FQN 기준으로 유지한다. | `CleanArchitectureTest` |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**: 26자 ULID consumer가 남아 있으면 path parsing과 snapshot contract가 깨진다. downstream fixture와 wire contract를 함께 갱신한다.
|
||||
- **실패·엣지 경로**: native `uuid` column까지 변경하면 불필요한 data migration이 생긴다. schema는 유지한다.
|
||||
- **다른 계약 의존**: [[raw/branch-notes/feature-resource-identifier-contract]]의 D3·D5·D10·D17과 project `WI-CA-SKELETON-OPERATIONAL-CONTRACT-046`을 소비한다.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
**리팩터 GREEN 검증 완료 (2026-07-08, `./gradlew check` BUILD SUCCESSFUL 4m31s, 200 tasks).** 아래는 확정 결과:
|
||||
|
||||
1. ✅ `./gradlew check` 전량 GREEN(spotless/checkstyle/spotbugs/errorprone/ArchUnit/`verifyDependencyLocks`/`verifyPublicPathSnapshot`/`verifyCleanArchitectureDependencies`/all tests + Testcontainers 통합 + sampleOffTest). `locally-verified`.
|
||||
2. ✅ `uuid-creator:6.1.1` resolve + 락 재생성(`resolveAndLockAll --write-locks`) 성공. UUIDv7 메서드 = `getTimeOrderedEpochPlus1()`(jar javap 확인, 모노토닉). `locally-verified`.
|
||||
3. ✅ 테스트 픽스처(26자 ULID → 36자 canonical UUID `0190bd6e-7c3e-7abc-8def-0123456789ab` 등) 전부 갱신 + 의미 보존: `WorkLogIdTest.rejectsCrockfordUlidFormat` 는 구 26자 형식이 **이제 거부됨**을 증명하는 회귀가드로 신설. 모노토닉 1000-loop 문자열 정렬 테스트 GREEN. property 테스트(jqwik)는 canonical UUID 생성기로 재작성. `locally-verified`.
|
||||
4. ✅ API 브레이킹(ID 문자열 26→36자) — 와이어 테스트 `.value(ID)` 새 UUID로 일치, `ContractSnapshots` 스크러버 정규식 ULID→UUID 로 교체(엔티티 id 가 스냅샷에 새면 계속 스크럽됨). 커밋된 `.approved.txt` 스냅샷은 volatile 필드만 `<scrubbed>` 라 영향 없음. `locally-verified`.
|
||||
5. ⏳ **canonical 계약 개정 미완(후속)**: [[raw/branch-notes/feature-resource-identifier-contract]] D3(canonical form)·D10(저장 변환) 결정 텍스트를 wiki/registries에서 UUIDv7로 갱신 필요. `planned`.
|
||||
|
||||
## 다음 단계
|
||||
|
||||
1. ✅ 에이전트 구현 완료 → `check` 전량 GREEN(69파일 변경: 57 M · 6 D · 6 새파일(?? Uuid* 리네임 대상)) 검증됨(2026-07-08).
|
||||
2. feed 파운데이션 가이드 §0.2/§2.1/§4.4를 최종 UUIDv7 패턴(`getTimeOrderedEpochPlus1`)으로 갱신.
|
||||
3. 사용자 커밋 → develop 머지 → `lab/nplus1-highlight-feed` 반영 → Task 0.
|
||||
4. [[raw/branch-notes/feature-resource-identifier-contract]] canonical(D3/D10) 개정(후속).
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- Gradle strict lock 갱신이 non-resolvable `sampleFixture`의 stale entry를 제거하지 못했다.
|
||||
- 해결과 재현 근거: [[raw/errors/gradle-strict-lock-stale-entry-non-resolvable-config-2026-07-08]].
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: errors:start -->
|
||||
- [[raw/errors/gradle-strict-lock-stale-entry-non-resolvable-config-2026-07-08]]
|
||||
<!-- GENERATED: errors:end -->
|
||||
|
||||
### 오류 기록 (이 branch 작업 중 발생)
|
||||
|
||||
- [[raw/errors/gradle-strict-lock-stale-entry-non-resolvable-config-2026-07-08]] — `resolveAndLockAll` 이 `canBeResolved=false` 인 `sampleFixture` config 를 건너뛰어 app-bootstrap lockfile 에 stale `ulid-creator` 줄이 남은 문제 + 수동 병합 해결.
|
||||
|
||||
### 면접 준비
|
||||
|
||||
- 새 질문 없음 — 식별자 생성/거버넌스 면접 소재는 기존 [[raw/interviews/clean-architecture-identifier-generation]] 이 이미 커버(UUIDv7 vs ULID 인덱스 지역성 각도는 그 노트 갱신 시 반영).
|
||||
|
||||
### 블로그·채용공고 연계 글감
|
||||
|
||||
- 별도 신규 글감 없음 — ULID→UUIDv7 표준화·인덱스 지역성 각도는 이 branch note 자체가 entry point 이며 기존 [[raw/blog-topics/ulid-crockford-base32-excluded-letters-2026-06-01]] 클러스터에 속함.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- 연결된 daily-note 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크: 없음 — 사용자 commit 대기.
|
||||
- 리뷰 메모: local full check와 identifier-specific regression은 통과했고 부모 계약 갱신은 남아 있다.
|
||||
- 머지 결과 / 배포 환경: local verification만 완료, staging/prod 검증 없음.
|
||||
- **wiki 추출 대상**: UUIDv7 generator·wire canonical form·native `uuid` persistence 유지의 locally-verified 결과.
|
||||
- **추출하지 않을 항목**: parent canonical/registry 갱신 전 project-wide 완료 주장과 prod 성능 주장.
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/nplus1-presentation-prep/branch-notes/experiment-nplus1-feed-api-replay.md
|
||||
@@ -0,0 +1,307 @@
|
||||
---
|
||||
title: branch / experiment-nplus1-feed-api-replay (11-stage real DB and HTTP replay)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-NPLUS1-PRESENTATION-PREP-002
|
||||
kind: project-work-item
|
||||
project: nplus1-presentation-prep
|
||||
work_item: WI-NPLUS1-PRESENTATION-PREP-002
|
||||
inherits:
|
||||
- DEC-NPLUS1-PRESENTATION-PREP-LEARNING-001@1
|
||||
- DEC-NPLUS1-PRESENTATION-PREP-SUBSTRATE-001@1
|
||||
- DEC-NPLUS1-PRESENTATION-PREP-EVIDENCE-001@1
|
||||
- DEC-NPLUS1-PRESENTATION-PREP-SCOPE-001@1
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-NPLUS1-PRESENTATION-PREP-001]
|
||||
contract_packet: 1
|
||||
branch: experiment-nplus1-feed-api-replay
|
||||
git_branch: lab/nplus1-api-replay
|
||||
parent_branch:
|
||||
related_projects: [nplus1-presentation-prep, ca-skeleton]
|
||||
tags: [branch, nplus1-presentation-prep, persistence, testing, api-design, postgresql, hands-on-lab]
|
||||
created: 2026-07-15
|
||||
target_merge:
|
||||
status_label: review
|
||||
evidence_grade: locally-verified
|
||||
contract_packet_sha256: 88f5e6fd5ec219c6017f8213d076cde1803e63e62f978fe926f86ddcbc616f41
|
||||
---
|
||||
|
||||
# branch: experiment-nplus1-feed-api-replay
|
||||
|
||||
> Layer: `raw/branch-notes/` — 기존 [[raw/branch-notes/experiment-nplus1-highlight-feed]]의 L1~L16/Crown/L12 결과를, 실제 PostgreSQL과 HTTP로 한 단계씩 재현할 수 있게 만든 11-checkpoint replay 브랜치다.
|
||||
> 실제 Git branch는 `lab/nplus1-api-replay`다. wiki slug는 파일명 규칙에 맞춘 별도 식별자다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- [[raw/project-notes/nplus1-presentation-prep]]
|
||||
- 관련 선행 작업: [[raw/branch-notes/experiment-nplus1-highlight-feed]] — 각 랩의 원래 문제·측정·해법 사슬을 소유한다. 이 노트는 그 결과를 checkout 가능한 API/DB 학습 경로로 만드는 작업만 소유한다.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: 11개 replay tag와 guide mapping을 고정하고, clean clone/worktree에서 Compose·HTTP·PostgreSQL smoke 및 full-check 상태를 재검증하며 lab profile의 deployment 비활성 증거를 기록한다.
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-NPLUS1-PRESENTATION-PREP-LEARNING-001@1` | Measure→Break→Diagnose→Fix→Re-measure→Generalize를 랩 완료 루프로 사용한다. | 11개 checkout point마다 동일한 reset→HTTP→DB 관찰 절차를 제공한다. | [[raw/project-notes/nplus1-presentation-prep]] |
|
||||
| `DEC-NPLUS1-PRESENTATION-PREP-SUBSTRATE-001@1` | ca-tmpl production substrate를 사용하고 학습 API·측정 경로는 profile과 sibling 경로로 격리한다. | `/api/lab/**`와 marker-owned fixture를 `lab` profile에 한정한다. | [[raw/project-notes/nplus1-presentation-prep]] |
|
||||
| `DEC-NPLUS1-PRESENTATION-PREP-EVIDENCE-001@1` | local·Testcontainers 결과는 locally-verified로만 기록하고 prod evidence로 승격하지 않는다. | Compose/Testcontainers 결과를 `locally-verified`로만 기록한다. | [[raw/project-notes/nplus1-presentation-prep]] |
|
||||
| `DEC-NPLUS1-PRESENTATION-PREP-SCOPE-001@1` | same-store CQRS-lite까지를 현재 범위로 두고 full CQRS는 ca-tmpl contract escalation 이후에만 허용한다. | Crown 1-query와 L12 2-query endpoint를 병존시킨다. | [[raw/project-notes/nplus1-presentation-prep]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
기존 branch-local 결정은 아래 `## Decision Evidence Map`의 D1~D5가 소유한다.
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
- 없음.
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
마지막 Crown/L12 상태만 남은 작업 트리에서는 L1의 컬렉션 N+1부터 Crown의 통합 쿼리까지를 HTTP와 실제 DB로 순서대로 관찰하기 어렵다. 이를 11개의 독립 checkout point로 고정한다.
|
||||
|
||||
- 각 tag에서 Docker PostgreSQL을 띄우고 lab fixture를 reset한 뒤 API 응답과 DB row를 직접 확인한다.
|
||||
- 정상 `/api/feed` 동작은 바꾸지 않고, `lab` profile에서만 학습용 `/api/lab/**` 경로를 제공한다.
|
||||
- Crown의 1-query read와 L12의 same-store CQRS-lite 2-query read를 같은 것으로 포장하지 않고, 별도 endpoint와 문서로 비교 가능하게 둔다.
|
||||
|
||||
- 이슈: 사용자 요청 — N+1 랩을 실 API/DB로 단계별 학습
|
||||
- PR: 없음 (로컬 replay branch)
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- L1, L2, L3, L4, L5, L6, L14, L15, L16, Crown, L12 순서의 정확히 11개 commit/tag.
|
||||
- lab profile, marker-safe fixture, HTTP 관찰 endpoint, PostgreSQL 확인 절차와 단계별 가이드.
|
||||
- Crown 및 L12의 실제 PostgreSQL/Testcontainers 검증과 이력 tag의 L1 smoke 검증.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- `/api/feed`의 production 계약 또는 기본 보안 정책 변경.
|
||||
- L12를 별도 read store·outbox 동기화가 있는 Full CQRS로 확장.
|
||||
- 프로덕션 배포, 부하/latency SLA, 성능 수치의 운영 일반화.
|
||||
|
||||
## 근거
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/cqrs-pattern-azure-architecture-center]] | D3: 같은 저장소에서 read/write 논리를 분리하는 CQRS-lite와 별도 저장소 CQRS를 구분한다. |
|
||||
| [[raw/official-docs/spring-data-jpa-projections-spring-official]] | D3: application 반환용 DTO/projection을 통해 aggregate hydration과 read shape를 분리하는 선택지를 뒷받침한다. |
|
||||
| [[raw/official-docs/test-taxonomy-testcontainers-official]] | D4: in-memory 대체물이 아닌 Docker의 실제 PostgreSQL로 integration evidence를 얻는 선택을 뒷받침한다. |
|
||||
|
||||
## TODO
|
||||
|
||||
- [x] 11개 replay commit/tag를 사용자 지정 학습 순서로 고정 — 등급: `actually-implemented`
|
||||
- [x] `lab` profile에서 실제 DB reset 및 HTTP feed 관찰 경로 제공 — 등급: `actually-implemented`
|
||||
- [x] Crown/L12 최신 상태의 Docker HTTP + PostgreSQL smoke 수행 — 등급: `locally-verified`
|
||||
- [x] L1 historical tag의 독립 Docker HTTP smoke 수행 — 등급: `locally-verified`
|
||||
- [x] 누적 focused Gradle suite 및 dependency/public-path/env verifier 수행 — 등급: `locally-verified`
|
||||
- [ ] 전체 `./gradlew check`를 branch 변경과 무관한 base architecture failure 없이 통과 — 등급: `needs-confirmation` (아래 §검증 기록 참조)
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- application repository의 replay branch는 `54cf7e7` / `nplus1-replay-l12`까지 tag가 완료된 상태다.
|
||||
- full `check`의 유일한 base failure는 별도 수정 범위로 남겼다. 이 노트의 evidence grade는 local Docker/Gradle 검증까지만 나타낸다.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-07-15 D1: 학습 순서를 원래 구현 시간순이 아니라 `L1 → L2 → L3 → L4 → L5 → L6 → L14 → L15 → L16 → Crown → L12`의 11개 tag로 고정한다. / 이유: 사용자가 각 commit으로 이동해 API/DB를 직접 관찰해야 한다. / 검토한 대안: 마지막 코드 하나와 문서만 제공. / 근거: 사용자 요구; 구체 tag 순서는 외부 자료가 정하지 않으므로 `UNSUPPORTED_IMPL_DECISION`.
|
||||
- 2026-07-15 D2: 공개 학습 reset은 `lab` profile의 `/api/lab/**`에만 두고, anonymous access는 `lab:reset` 하나에만 허용한다. / 이유: 실제 HTTP 재현은 가능해야 하지만 정상 profile의 feed/권한 정책을 약화하면 안 된다. / 검토한 대안: `/api/feed`에 reset/debug 파라미터 추가 또는 lab profile 전체 anonymous 허용. / 근거: D4 및 사용자 범위; profile/permission의 구체 모양은 `UNSUPPORTED_IMPL_DECISION`.
|
||||
- 2026-07-15 D3: Crown은 1 native query API, L12는 same-store CQRS-lite 2-query read port API로 병존시킨다. / 이유: 쿼리 수 최소화와 application read-model 분리는 서로 다른 선택지다. / 검토한 대안: L12가 Crown endpoint를 조용히 대체. / 근거: `AZURE-CQRS-C2`, `AZURE-CQRS-C3`, `SPRING-PROJ-C4`.
|
||||
- 2026-07-15 D4: Testcontainers 검증에 더해 fresh Docker Compose PostgreSQL에서 HTTP response와 SQL row count를 확인한다. / 이유: test-only assertion으로는 사용자가 직접 API/DB를 따라 보는 목표를 충족하지 못한다. / 검토한 대안: integration test 결과만 보관. / 근거: `TC-OFFICIAL-C1`, `TC-OFFICIAL-C3`, `TC-OFFICIAL-C5`.
|
||||
- 2026-07-15 D5: Hibernate `addScalar`를 Java SQL compile-time checker로 설명하지 않는다. / 이유: 이는 native-query result extraction의 runtime type mapping이며 SQL 문법/컬럼 존재성은 실행 시점에 검증된다. / 검토한 대안: `addScalar`가 SQL 안전성을 보장한다고 문서화. / 근거: 구현 관찰; `UNSUPPORTED_IMPL_DECISION`.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | 11개의 checkout 가능한 learning checkpoint | 사용자가 단계별 API/DB 관찰을 원할 때; 단일 현재 상태만 필요하면 하나의 branch 상태로 충분 | User request; `UNSUPPORTED_IMPL_DECISION` | user-scoped requirement | history를 rewrite하면 tag/문서 매핑도 함께 갱신해야 함 |
|
||||
| D2 | lab-only reset/API/anonymous boundary | 로컬 학습 profile일 때만; 정상 runtime에서는 lab bean/route 자체를 등록하지 않음 | `TC-OFFICIAL-C1`, `TC-OFFICIAL-C3`; `UNSUPPORTED_IMPL_DECISION` | official-vendor-doc + unit/HTTP local verification | lab profile을 production에 실수로 활성화하지 않는 운영 절차는 별도 확인 필요 |
|
||||
| D3 | Crown 1-query와 L12 2-query CQRS-lite 병존 | endpoint-specific optimization을 비교할 때; 별도 store가 필요하면 Full CQRS contract를 별도 설계 | `raw/official-docs/cqrs-pattern-azure-architecture-center.md#AZURE-CQRS-C2`, `#AZURE-CQRS-C3`, `raw/official-docs/spring-data-jpa-projections-spring-official.md#SPRING-PROJ-C4` | official-vendor-doc | L12은 keyset/visibility를 Crown처럼 모두 포함하지 않음 |
|
||||
| D4 | real PostgreSQL HTTP+DB smoke | SQL dialect, container wiring, public response shape를 함께 확인할 때 | `raw/official-docs/test-taxonomy-testcontainers-official.md#TC-OFFICIAL-C1`, `#TC-OFFICIAL-C3`, `#TC-OFFICIAL-C5` | official-vendor-doc + local runtime evidence | production traffic/permissions을 검증한 것은 아님 |
|
||||
| D5 | `addScalar` compile-time 보장 부정 | native query mapping 설명 시 항상 적용 | Local code/runtime behavior; `UNSUPPORTED_IMPL_DECISION` | locally verified implementation fact | native SQL의 syntax/plan error는 CI compile이 아니라 query execution에서 발견됨 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
### 1. 고정 replay checkpoint
|
||||
|
||||
> **Trace**: D1. 단계 순서와 tag 이름은 사용자 학습 요구에서 정한 `UNSUPPORTED_IMPL_DECISION`이다. 각 checkpoint의 원래 N+1 원인/해결 설명은 [[raw/branch-notes/experiment-nplus1-highlight-feed]]를 참조한다.
|
||||
|
||||
| 순서 | Stage | Commit | Tag | checkout 후 주 관찰점 |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | L1 | `e68dd67` | `nplus1-replay-l1` | lazy highlights 컬렉션 N+1 |
|
||||
| 2 | L2 | `138eb67` | `nplus1-replay-l2` | EAGER ToOne fetch 수 |
|
||||
| 3 | L3 | `dc7495c` | `nplus1-replay-l3` | two-bag fetch의 예상 실패 |
|
||||
| 4 | L4 | `f257196` | `nplus1-replay-l4` | collection fetch join + paging의 in-memory paging |
|
||||
| 5 | L5 | `be2a123` | `nplus1-replay-l5` | batch fetch paging |
|
||||
| 6 | L6 | `cad3c15` | `nplus1-replay-l6` | DTO scalar projection, entity load 0 |
|
||||
| 7 | L14 | `26b196c` | `nplus1-replay-l14` | window query로 parent별 Top-N |
|
||||
| 8 | L15 | `8ff0771` | `nplus1-replay-l15` | keyset cursor |
|
||||
| 9 | L16 | `3310897` | `nplus1-replay-l16` | viewer visibility + keyset |
|
||||
| 10 | Crown Task 4 | `3f0b82e` | `nplus1-replay-crown` | Top-N + keyset + visibility one query |
|
||||
| 11 | L12 | `54cf7e7` | `nplus1-replay-l12` | CQRS-lite projection port, parent/child two queries |
|
||||
|
||||
각 stage의 세부 실습은 application repository의 `docs/superpowers/plans/*nplus1*lab-guide.md`, `docs/notes/L*.md`, `docs/notes/crown.md`를 사용한다. `L12`를 마지막 tag로 둔 것은 원래 번호가 아니라 이 replay의 학습 순서다.
|
||||
|
||||
### 2. lab profile의 API/권한 경계
|
||||
|
||||
> **Trace**: D2, D4 / `TC-OFFICIAL-C1`, `TC-OFFICIAL-C3`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `lab` profile과 `lab:reset` permission 이름, marker 소유 방식, anonymous allowlist의 구체 구현은 외부 자료가 정하지 않는다. 정상 profile과 분리된 학습 reset 및 최소 권한 허용이라는 사용자 범위를 우선했다.
|
||||
|
||||
| 항목 | replay 계약 |
|
||||
|---|---|
|
||||
| profile | lab controller/usecase/fixture는 local `lab` profile에만 등록된다. |
|
||||
| normal runtime | 기존 advisor를 유지하고 lab usecase/route를 등록하지 않는다. `/api/feed`의 기존 계약을 바꾸지 않는다. |
|
||||
| reset authorization | `POST /api/lab/feed:reset?count=N`은 `lab:reset`을 선언한다. lab profile의 security advisor는 `AnonymousAuthenticationToken`을 인식해 anonymous에는 `lab:reset` 하나만 허용하고, 다른 permission은 거부한다. unit+HTTP로 확인했다. |
|
||||
| fixture ownership | `created_by = nplus1-lab` marker 데이터만 삭제/재생성한다. |
|
||||
| input guard | `page=10001`은 HTTP 400, `VALIDATION_FAILED`다. |
|
||||
|
||||
### 3. Crown과 L12의 의도적 차이
|
||||
|
||||
> **Trace**: D3 / `AZURE-CQRS-C2`, `AZURE-CQRS-C3`, `SPRING-PROJ-C4`.
|
||||
|
||||
| 경로 | 목적 | SQL 관찰값 | 포함 범위 |
|
||||
|---|---|---|---|
|
||||
| `GET /api/lab/feed` at Crown/L12 | Crown Task 4 최적화 | `CROWN_TASK4_ONE_QUERY`, prepared statement 1, entity load 0 | visible parent keyset + Top-3 child를 하나의 native query로 읽음 |
|
||||
| `GET /api/lab/feed/read-model` at L12 | CQRS-lite read model | parent projection 1 + child Top-3 query 1, integration test에서 entity/collection hydration 0 | same store의 application query port; Crown을 대체하지 않음 |
|
||||
|
||||
L12의 native child mapping에 사용한 `addScalar`는 runtime 결과 타입 매핑이다. SQL 문자열의 문법, table/column 이름, plan을 Java compiler가 검증하게 만드는 기능은 아니다. 따라서 native SQL은 Testcontainers/실제 PostgreSQL 실행으로 검증한다.
|
||||
|
||||
## 검증 기록
|
||||
|
||||
### Docker HTTP + PostgreSQL smoke — final L12 tag
|
||||
|
||||
fresh `nplus1-final` Compose stack에서 `nplus1-replay-l12`(`54cf7e7`)을 실행했다.
|
||||
|
||||
| 수행 | 결과 | 등급 |
|
||||
|---|---|---|
|
||||
| `POST /api/lab/feed:reset?count=100` | `success=true`, feed item 100개, highlight 1,961개 | `locally-verified` |
|
||||
| `GET /api/lab/feed?size=20&viewer=lab-user-008` | item 20개, `strategy=CROWN_TASK4_ONE_QUERY`, `prepared=1`, `entityLoads=0`, parent당 Top-3 최대 3개 | `locally-verified` |
|
||||
| `GET /api/lab/feed/read-model?page=0&size=20` | `success=true`, item 20개, parent당 Top-3 최대 3개 | `locally-verified` |
|
||||
| `GET` with `page=10001` | HTTP 400, `VALIDATION_FAILED` | `locally-verified` |
|
||||
| PostgreSQL `psql` | `created_by = nplus1-lab` row count 100 | `locally-verified` |
|
||||
| anonymous authorization | `AnonymousAuthenticationToken`의 `lab:reset`만 허용하고 다른 permission은 거부하는 unit+HTTP 검증 | `locally-verified` |
|
||||
|
||||
### Historical L1 smoke
|
||||
|
||||
fresh stack에서 `nplus1-replay-l1`(`e68dd67`)을 별도로 실행했다.
|
||||
|
||||
| 수행 | 결과 | 등급 |
|
||||
|---|---|---|
|
||||
| reset `count=10` | `success=true` | `locally-verified` |
|
||||
| feed request | item 10개, `strategy=L1_LAZY_HIGHLIGHTS`, `prepared=24`, `collectionFetch=10` | `locally-verified` |
|
||||
|
||||
### Focused regression suite
|
||||
|
||||
다음 filtered cumulative suite는 `BUILD SUCCESSFUL`, test result의 `failures=0`, `errors=0`이었다.
|
||||
|
||||
```bash
|
||||
cd /home/donghyeon/workspace/ca-tmpl-nplus1-api/src
|
||||
./gradlew spotlessApply :application-core:test --tests dev.caskeleton.application.feed.GetFeedReadModelUseCaseTest --tests dev.caskeleton.application.feed.lab.LabFeedUseCaseTest --tests dev.caskeleton.application.feed.lab.LabFeedCursorTest :adapter:inbound:web:test --tests dev.caskeleton.adapter.inbound.web.controller.lab.LabFeedControllerTest :app-bootstrap:test --tests dev.caskeleton.bootstrap.contract.DeveloperExperienceContractTest --tests dev.caskeleton.adapter.outbound.jpa.feed.FeedReadModelUseCaseIT --tests dev.caskeleton.adapter.outbound.jpa.feed.FeedCrownIT --tests dev.caskeleton.adapter.outbound.jpa.feed.FeedVisibilityIT --tests dev.caskeleton.adapter.outbound.jpa.feed.FeedKeysetIT --tests dev.caskeleton.adapter.outbound.jpa.feed.FeedTopNIT --tests dev.caskeleton.adapter.outbound.jpa.feed.FeedProjectionIT --tests dev.caskeleton.adapter.outbound.jpa.feed.FeedBatchFetchIT --tests dev.caskeleton.adapter.outbound.jpa.feed.FeedFetchJoinPagingIT --tests dev.caskeleton.adapter.outbound.jpa.feed.FeedToOneEagerIT --tests dev.caskeleton.adapter.outbound.jpa.feed.FeedMultipleBagIT --tests dev.caskeleton.bootstrap.lab.LabFeedPersistenceIT
|
||||
```
|
||||
|
||||
명령에 포함된 L1~L6, L14~L16, Crown, L12의 stage integration test class는 모두 통과했다.
|
||||
|
||||
다음 verifier도 통과했다.
|
||||
|
||||
```bash
|
||||
./gradlew verifyCleanArchitectureDependencies verifyPublicPathSnapshot verifyEnvKeys
|
||||
```
|
||||
|
||||
### Full check의 기준선 실패
|
||||
|
||||
`./gradlew check`에는 정확히 하나의 잔여 architecture failure가 있었다. 이는 이 replay branch가 수정하지 않은 base commit `6f0b0d6`의 `IdempotencyRecordEntity.requestHash`에 있는 `columnDefinition = "char(64)"`가 vendor-neutral entity rule을 위반한 것이다. 따라서 이 노트는 full check를 green이라고 주장하지 않으며, 재현 브랜치의 실패로 귀속하지 않는다.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**: lab profile 밖에서 lab reset을 사용하려 하면 endpoint/usecase가 등록되지 않아야 한다. lab profile에서도 anonymous access는 `lab:reset` 하나에만 한정되고, 다른 permission은 거부된다.
|
||||
- **실패·엣지 경로**: native query의 `addScalar` 타입이 결과와 맞지 않거나 SQL이 잘못되면 compile이 아니라 integration/runtime 실행에서 실패한다.
|
||||
- **실패·엣지 경로**: L12 read model이 Crown과 동등한 visibility/keyset solution이라고 가정하면 안 된다. L12은 same-store read port의 2-query projection이고 Crown 최적화 endpoint는 유지된다.
|
||||
- **다른 계약 의존**: [[raw/branch-notes/experiment-nplus1-highlight-feed]]의 각 랩 의미와 [[raw/branch-notes/feature-application-query-bypass-contract]]의 CQRS-lite/read-port 경계를 소비한다. Full CQRS physical read store는 후자 D2의 escalation 범위다.
|
||||
- **검증 환경 의존**: `DeveloperExperienceContractTest`가 root `AGENTS.md` 존재를 요구해 replay worktree에 일시적인 ignored bridge를 두고 test 후 제거했다. 이는 application commit에 포함되지 않는다.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 11개 tag가 다른 machine에서도 compose/API guide대로 재현된다 | 로컬 Docker, Gradle cache, port 상태에 의존 | 깨끗한 clone/worktree에서 tag별 compose smoke 수행 | `needs-confirmation` |
|
||||
| lab profile이 배포 환경에서 활성화되지 않는다 | local profile boundary는 production deployment policy를 증명하지 않음 | deployment manifest/env registry audit | `needs-confirmation` |
|
||||
| `IdempotencyRecordEntity` failure를 수정한 뒤 full `check`가 green이 된다 | 현재 branch가 해당 base failure를 고치지 않음 | 별도 base-fix branch에서 full check 실행 | `planned` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- `DeveloperExperienceContractTest`가 checkout worktree root의 `AGENTS.md`를 요구했다.
|
||||
- 원인: replay worktree의 contract discovery 조건.
|
||||
- 시도: test 실행 중 ignored bridge를 일시적으로 제공.
|
||||
- 해결: test 통과 후 bridge를 삭제했고 application history에는 포함하지 않았다.
|
||||
- 별도 오류 노트: 아래 Cluster의 raw error 노트.
|
||||
- full `check`가 `IdempotencyRecordEntity.requestHash` vendor-specific `columnDefinition`에서 멈췄다.
|
||||
- 원인: base `6f0b0d6`에 이미 존재한 rule violation.
|
||||
- 해결: replay scope 밖으로 남기고 base failure로 명시했다.
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: interviews:start -->
|
||||
- [[raw/interviews/crown-one-query-vs-cqrs-lite-read-model]]
|
||||
- [[raw/interviews/native-query-addscalar-runtime-validation]]
|
||||
<!-- GENERATED: interviews:end -->
|
||||
|
||||
<!-- GENERATED: errors:start -->
|
||||
- [[raw/errors/developer-experience-contract-agents-bridge-2026-07-15]]
|
||||
- [[raw/errors/idempotency-column-definition-base-check-failure-2026-07-15]]
|
||||
<!-- GENERATED: errors:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/nplus1-lab-checkoutable-api-replay-2026-07-15]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
아래 raw leaf는 생성되었고, 이 branch를 `## Parent` upward link로 가진다. 두 번째 블로그 주제는 별도 raw 문서로 추출하지 않았다.
|
||||
|
||||
### Sub-branches (세부 작업)
|
||||
|
||||
- 없음 — 11개 checkpoint는 하나의 replay branch history로 관리한다.
|
||||
|
||||
### 오류 기록 (이 branch 작업 중 발생)
|
||||
|
||||
- [[raw/errors/developer-experience-contract-agents-bridge-2026-07-15]] — replay worktree root discovery와 임시 ignored bridge.
|
||||
- [[raw/errors/idempotency-column-definition-base-check-failure-2026-07-15]] — base `6f0b0d6`의 vendor-neutral entity rule failure.
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- [[raw/interviews/crown-one-query-vs-cqrs-lite-read-model]] — one-query optimization과 same-store CQRS-lite를 구분하는 기준.
|
||||
- [[raw/interviews/native-query-addscalar-runtime-validation]] — native mapping type과 SQL compile-time validation의 차이.
|
||||
|
||||
### job-posting tie-ins (이 작업에서 파생된 글감)
|
||||
|
||||
- [[raw/blog-topics/nplus1-lab-checkoutable-api-replay-2026-07-15]] — 테스트 코드를 실제 API/DB 학습 환경으로 변환한 방법.
|
||||
- `raw/blog-topics/crown-query-and-cqrs-lite-boundary-2026-07-15.md` — 이번 capture에서는 별도 raw 문서로 추출하지 않았다.
|
||||
- derived blog: 생성 전. canonical 검증 후 `wiki/blog/` 후보를 결정한다.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- 해당 없음 — 이 캡처 시점에는 별도 daily-note를 만들지 않았다.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크: 없음.
|
||||
- 리뷰 메모: 11개 replay tag와 L1/final Docker smoke, focused suite, architecture/public-path/env verifier를 local에서 확인했다.
|
||||
- 머지 결과 / 배포 환경: local Docker Compose + local PostgreSQL만 검증. production 배포 검증 없음.
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목: lab-profile replay API, 11 stage tag catalog, Crown/L12 분리 경로.
|
||||
- `locally-verified` 항목: L1 historical smoke, final Docker HTTP/PostgreSQL smoke, filtered Gradle suite/verifier.
|
||||
- `prod-verified` 항목: 없음.
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned): base `IdempotencyRecordEntity` rule failure 해결, production profile/deployment verification.
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/nplus1-presentation-prep/branch-notes/experiment-nplus1-highlight-feed.md
|
||||
@@ -0,0 +1,378 @@
|
||||
---
|
||||
title: branch / experiment-nplus1-highlight-feed (N+1 발표 준비 — 라이너 하이라이트 피드 랩)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-NPLUS1-PRESENTATION-PREP-001
|
||||
kind: project-work-item
|
||||
project: nplus1-presentation-prep
|
||||
work_item: WI-NPLUS1-PRESENTATION-PREP-001
|
||||
inherits:
|
||||
- DEC-NPLUS1-PRESENTATION-PREP-LEARNING-001@1
|
||||
- DEC-NPLUS1-PRESENTATION-PREP-SUBSTRATE-001@1
|
||||
- DEC-NPLUS1-PRESENTATION-PREP-EVIDENCE-001@1
|
||||
- DEC-NPLUS1-PRESENTATION-PREP-SCOPE-001@1
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
branch: experiment-nplus1-highlight-feed
|
||||
parent_branch:
|
||||
git_branch: lab/nplus1-highlight-feed
|
||||
related_projects: [nplus1-presentation-prep, ca-skeleton]
|
||||
tags: [branch, nplus1-presentation-prep, persistence, testing, hibernate, postgresql, hands-on-lab]
|
||||
created: 2026-07-08
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: b4ade9d64e34469ba11ac46f670b6e7e3581de975e89799cf7b73e8ad516312b
|
||||
---
|
||||
|
||||
# branch: experiment-nplus1-highlight-feed — N+1 발표 준비 (Video 1 랩)
|
||||
|
||||
> Layer: `raw/branch-notes/` — 선배 부여 3대주제 발표 준비(N+1 / 아키텍처 3종 / OAuth2-Keycloak) 중 **1번(N+1)**.
|
||||
> 실제 git 브랜치: `lab/nplus1-highlight-feed` (ca-tmpl repo). 위키 파일명은 prefix 규칙상 `experiment-`.
|
||||
> **목적**: 라이너 백엔드 사전과제 "하이라이트 피드 API"를 substrate로, N+1 정전(canon)을 **재현→측정→진단→해결**하며 "체화"한 발표 콘텐츠(Video 1)를 만든다. 내 역할 = 코치·설계자(스펙·랩 설계·측정 하네스; 실제 fix 코드·에러 경험은 학습자).
|
||||
> `status_label`: `in-progress`
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모
|
||||
|
||||
- [[raw/project-notes/nplus1-presentation-prep]]
|
||||
- **substrate 위치**: ~~별도 lab 프로젝트~~ → **ca-tmpl 프로덕션 모듈에 실제 제품 도메인**(2026-07-08 재결정, 아래 섹션). CLAUDE.md Template reuse #4와 일치.
|
||||
- 형제(예정): keycloak 계열(주제3), 아키텍처 3종 비교(주제2)
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: L2 ToOne EAGER 격리 측정값을 확정하고, L1~L6·L14~L16·Crown·L12의 증거 등급과 사용자 commit-range review를 본문 Closure에 반영한다.
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-NPLUS1-PRESENTATION-PREP-LEARNING-001@1` | Measure→Break→Diagnose→Fix→Re-measure→Generalize를 랩 완료 루프로 사용한다. | 각 랩의 before/after·기전·다음 문제 연결을 기록한다. | [[raw/project-notes/nplus1-presentation-prep]] |
|
||||
| `DEC-NPLUS1-PRESENTATION-PREP-SUBSTRATE-001@1` | ca-tmpl production substrate를 사용하고 학습 API·측정 경로는 profile과 sibling 경로로 격리한다. | feed production module과 별도 IT/sibling query 경로를 함께 유지한다. | [[raw/project-notes/nplus1-presentation-prep]] |
|
||||
| `DEC-NPLUS1-PRESENTATION-PREP-EVIDENCE-001@1` | local·Testcontainers 결과는 locally-verified로만 기록하고 prod evidence로 승격하지 않는다. | 모든 측정의 환경과 evidence grade를 명시한다. | [[raw/project-notes/nplus1-presentation-prep]] |
|
||||
| `DEC-NPLUS1-PRESENTATION-PREP-SCOPE-001@1` | same-store CQRS-lite까지를 현재 범위로 두고 full CQRS는 ca-tmpl contract escalation 이후에만 허용한다. | L12를 별도 physical read store 없이 구현한다. | [[raw/project-notes/nplus1-presentation-prep]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
기존 branch-local 결정은 아래 `## Decision Evidence Map`의 D-01~D-10이 소유한다.
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
- 없음.
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
선배 의도 = "많이 에러 내보고 시행착오"하는 **실패주도 체화(딸깍 금지)**. 지식량이 아니라 **방법**이 딸깍과 체화를 가른다. 그래서 모든 랩은 루프로 돈다:
|
||||
|
||||
> 측정(Measure) → 고의로 부순다(Break) → 진단(Diagnose) → 고친다(Fix) → 재측정(Re-measure) → 일반화(Generalize)
|
||||
|
||||
핵심 질문:
|
||||
|
||||
- N+1 "정전 6종"을 **구성+테스트만** 하면 깊이있게 다룬 것인가? → **아니다.** 그건 바닥(재현)이지 천장이 아니다. 깊이 = 측정치 + 해법→문제 사슬 + 시그니처 난제 + 일반화.
|
||||
- "다시 딥하게"의 정체(선배 넘는 지점) = 과제 시그니처 난제 3개: **Top-N-per-group(페이지당 3) / keyset vs OFFSET / 가시성 술어 인덱싱** → Video 2 왕관.
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- ca-tmpl 피드 도메인에서 L0~L6, L14~L16, Crown, L12의 재현·측정·해법 사슬을 기록한다.
|
||||
- Hibernate Statistics, `EXPLAIN (ANALYZE, BUFFERS)`, Testcontainers 기반의 로컬 검증 결과와 실측 정정을 보존한다.
|
||||
- L12의 same-store CQRS-lite 읽기 모델까지를 본 브랜치의 구현 경계로 둔다.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 별도 물리 read store와 동기화 파이프라인을 갖는 full CQRS는 ca-tmpl 계약 개정 전에는 구현하지 않는다.
|
||||
- prod 배포·운영 부하 검증은 수행하지 않았으며, 로컬·Testcontainers 결과를 prod 증거로 승격하지 않는다.
|
||||
- 아직 실행하지 않은 L2 측정값은 L1 회계식에서 유도한 값으로만 유지하고 실측 완료로 간주하지 않는다.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/spring-data-jpa-projections-spring-official]] | D-10의 DTO constructor projection 경계와 nested join 한계를 뒷받침한다 (`SPRING-PROJ-C4`, `SPRING-PROJ-C6`). |
|
||||
| [[raw/official-docs/cqrs-pattern-azure-architecture-center]] | L12에서 single database를 공유하면서 read/write logic을 분리한 CQRS-lite 경계를 뒷받침한다 (`AZURE-CQRS-C2`, `AZURE-CQRS-C3`). |
|
||||
|
||||
## TODO
|
||||
|
||||
- [x] L0·L1·L3~L6 재현 및 측정 — 등급: `locally-verified`
|
||||
- [x] L14~L16과 Crown 통합 쿼리 비교 — 등급: `locally-verified`
|
||||
- [x] L12 same-store CQRS-lite 읽기 모델 구현·회귀 검증 — 등급: `locally-verified`
|
||||
- [ ] L2 ToOne EAGER 격리 측정을 실행해 유도값을 실측값으로 교체 — 등급: `planned`
|
||||
- [ ] 사용자 커밋 뒤 spec/quality review와 발표 문서의 L12 절을 마감 — 등급: `needs-confirmation`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- 현재 가장 큰 미완료는 L2 실측과 사용자 커밋 이후 review다. 뒤 단계가 GREEN이어도 이 두 항목을 완료로 소급하지 않는다.
|
||||
- L12는 별도 read store가 없는 CQRS-lite다. Crown의 `feed_visible` 실험 테이블을 곧바로 production full CQRS로 표현하지 않는다.
|
||||
- 각 랩의 상세 수치·정정·산출물은 아래 날짜별 완료 기록이 소유하며, 이 섹션은 현재 상태만 요약한다.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-07-08 (D-01): Video 1은 L1~L6, 왕관 문제는 Video 2로 분리한다. 이유는 정전 재현·해결 사슬과 SQL/인덱스 대안 비교를 각각 독립 배송 단위로 유지하기 위해서다.
|
||||
- 2026-07-08 (D-02): 랩 완료는 초록불 재현이 아니라 D1~D6 측정·기전·다음 문제 연결을 모두 충족할 때로 판정한다.
|
||||
- 2026-07-20: full CQRS가 ca-tmpl의 escalation-only 계약과 충돌해, 사용자 재선택에 따라 same-store CQRS-lite로 구현 범위를 확정했다.
|
||||
|
||||
## 산출물 (ca-tmpl repo 내)
|
||||
|
||||
- 설계 스펙: `docs/superpowers/specs/2026-07-05-nplus1-presentation-prep-design.md` — 체화 엔진, 도메인/스키마(users·pages·highlights·feed_item·feed_item_mentions), 핫스팟 H1~H7, 시그니처 난제 5, 랩 L0~L16 Phase 0~4, 발표 목차 60~75분.
|
||||
- Video 1 랩 플랜: `docs/superpowers/plans/2026-07-05-nplus1-video1-liner-feed-lab.md` — Task 0~9(Foundation 3 + 측정 하네스 L0 + 정전 랩 L1~L6).
|
||||
- **Video 2 왕관 랩 플랜(2026-07-08 신규)**: `docs/superpowers/plans/2026-07-08-nplus1-video2-crown-topn-keyset-visibility.md` — Phase 4 시그니처 3난제 L14(Top-N-per-group: 윈도우함수 vs LATERAL vs 2단계배치)·L15(keyset vs OFFSET 깊은페이지)·L16(가시성 술어 OR vs UNION분해 vs 사전계산). 동일 D1~D6, **D2(EXPLAIN N안 대조)가 스타 지표**. 왕관 사슬: L6 미해결 "페이지당3"→L14→피드페이징→L15→정렬키+가시성 동시인덱싱→L16→CQRS(L12). Task 0(EXPLAIN N안 비교 하네스+keyset 커서 유틸) + Task 1~3(랩) + Task 4(통합 쿼리+왕관 매트릭스). Phase 2/3은 여전히 미작성(Video1 완료 후).
|
||||
- 테스트 구성 체크리스트: `docs/superpowers/specs/2026-07-07-test-construction-checklist.md` (11항).
|
||||
- 기존 테스트 품질 감사: `docs/superpowers/specs/2026-07-07-existing-tests-quality-audit-report.md`(Verdict PARTIAL, 성능/N+1 테스트 0개).
|
||||
|
||||
## 2026-07-08 개편: 깊이 게이트 D1~D6 + 해법→문제 사슬 (Video 1 플랜)
|
||||
|
||||
Video 1 플랜을 두 축으로 재구조화(사용자 요청):
|
||||
|
||||
- **축 A — 깊이 게이트 D1~D6**(랩마다 채워야 "완료"):
|
||||
1. D1 before/after 측정치(쿼리수·p50/p99·전송 행/바이트·힙·(해당시)커넥션홀드)
|
||||
2. D2 EXPLAIN(ANALYZE,BUFFERS) 캡처
|
||||
3. D3 재현 커밋(git 브랜치=영상 챕터)
|
||||
4. D4 "왜 터지고 왜 고쳐지나" 기전 1문단
|
||||
5. D5 이 fix가 낳는 다음 문제(사슬 고리)
|
||||
6. D6 N 스케일 곡선 {10,100,1k,10k}
|
||||
- **측정 하네스(Task 3/L0)를 D1의 6 metric 전부 뽑도록 확장**: `MetricRow`(record) + `Bench.measure`(워밍업→GC→p50/p99 반복측정→직렬화 바이트 근사→힙 델타) + `runCurve`(N축 자동 표). 정직성: 쿼리수·행수·지연=정확, 바이트·힙=근사, 커넥션홀드=L7(OSIV) Video2.
|
||||
- **축 B — 해법→다음문제 사슬(척추)**: 순서대로 하면 *한 랩의 해법이 다음 랩의 문제를 낳는다*.
|
||||
- `순진한 조회 → L1 컬렉션 N+1 / L2 EAGER ToOne N+1 → (해법:전부 fetch join) → L3 MultipleBagFetchException/카테시안 → (해법:하나만 fetch) → L4 페이징 HHH000104 인메모리 → (해법:@BatchSize+배치IN) → L5 해결! 그러나 엔티티 과적재 → (해법:DTO 프로젝션) → L6 해결! 그러나 "페이지당 3"(Top-N) 미해결 → L14(Video2 왕관)/CQRS L12`
|
||||
- L3·L4는 "성공한 해법"이 아니라 **순진한 fix 시도의 실패**이며, 그 실패가 다음 고리를 만든다. L5가 처음으로 제대로 풀지만 그조차 L6의 비용을 남긴다.
|
||||
- **완료 공식**: `Video1 완료 = (모든 랩 D1~D6) AND (§0.2 사슬이 D5로 연결) AND (의사결정 매트릭스)`. "6랩 초록불 재현"만으론 미완료.
|
||||
- **추가(§0.3/§0.4)**: ① ORM(JPA) 조회 문제 **전수 커버리지 맵**(17종: 1~7=Video1 깊이, 8~12=Video2, 13~17=미포함) — "ORM 조회 문제를 깊이 다루는가?"에 대한 자기감사. ② **DB 심화 학습 포인트**(ORM 아래 레이어: 인덱스 선두컬럼·커버링·partial, 플래너 EXPLAIN 노드, 조인 nested/hash/merge=N+1은 앱레벨 nested loop, LATERAL, keyset, 윈도우함수, Little's Law, MVCC, IDENTITY vs SEQUENCE, WAL/VACUUM). ★=랩에서 직접 / ◇=랩 밖 독립 심화. 프론티어 원본 목록 F1~F9 중 채택 4개(F1 쓰기N+1/F3 리액티브/F4 자작탐지기/F8 CQRS)=L9~L12(Video2), 미채택: F2 카테시안(→L3/L4로 흡수)·F5 바이트코드·F6 L2캐시·F7 커넥션풀(→L7로 흡수)·F9 다형성.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|
|
||||
| D-01 | Video 1 스코프 = 정전(canon) L1~L6만, 왕관(Top-N/keyset/가시성)은 Video 2로 분리 | 스펙 §8 안전밸브(Phase 0+1 = 무조건 배송 완결편), 플랜 line 245(Phase 4 = 선배 넘는 하이라이트→Video2) | Strong(설계 문서에 명시) | Video 1만으론 "주제 전체 깊이"가 아님 — 사용자에게 명확히 전달됨 |
|
||||
| D-02 | 랩 완료 판정 = 깊이 게이트 D1~D6 전부 충족(초록불 아님) | 사용자 제공 6-체크리스트, 스펙 §1 DoD(재현 커밋+before/after+EXPLAIN), 테스트 체크리스트 8항(성능=행동) | Strong | 게이트가 형식적 체크로 전락하면 딸깍 회귀 — D4/D5(기전·사슬)가 방지 |
|
||||
| D-03 | 발표 척추 = "해법→다음문제 사슬"(전이가 콘텐츠) | 스펙 §3.2 핫스팟, §6-5 해결 투어, 플랜 §0.2 사슬도 | Medium(논리적 인과는 견고, 실측 미완) | 각 전이가 실제로 그 순서로 터지는지는 랩 실행으로 증명 필요(→Claims) |
|
||||
| D-04 | 측정 하네스(L0)를 6 metric 전부 뽑도록 확장 | 플랜 Task3 `Bench.measure`/`MetricRow`/`runCurve` | Medium(코드 골격만, 미실행) | 바이트·힙은 근사치라 신호대잡음비 미검증(→Claims) |
|
||||
| D-05 | Substrate = ca-tmpl production module의 feed 도메인, 학습 측정은 sibling IT·query와 `lab` profile로 격리 | 2026-07-08 substrate 재결정, 본문 §SUBSTRATE 재결정, replay branch D2 | Strong(구현·local 검증) | 실험 경로가 production contract를 대체하지 않도록 기존 naive path와 profile 경계를 유지 |
|
||||
| D-06 | L6(DTO)도 "페이지당 3(Top-N)"은 못 풂 → L14 진입점 | 플랜 line 226(Top-N 제한은 L14에서 제대로), 스펙 §3.3-1(fetch join은 그룹 아닌 행에 LIMIT), **L6 실측 childRows=1509(페이지 20 부모의 하이라이트 전량, top-3=60 훨씬 초과)** | **Strong(실측·GREEN 2026-07-13)** | 단순 `IN` 프로젝션은 그룹 아닌 행에 LIMIT을 못 걸어 전량 조회 확인 → L14(윈도우/LATERAL/2단계 배치)로 |
|
||||
| D-07 | L2 격리 지표 = `getEntityFetchCount()` + 엔티티별 `getEntityStatistics(<E>).getFetchCount()`(page=선형 N / user=평탄 ≤20). fix 금지(EAGER→LAZY 토글은 되돌리는 probe). L1 note의 ToOne몫 14/121/1021은 Spring Data Page count를 섞은 값 → L2는 base+count를 `−2`로 분리해 순수 ToOne = **13/120/1020** 으로 정밀화 | L1 실측(collFetch 10/100/1000·prepared 25/222/2022) 회계 항등식 유도, 플랜 Task5(L2), Hibernate Statistics API(`getEntityFetchCount`/`EntityStatistics.getFetchCount`) | Medium(코드 골격 + L1 실측 유도, L2 미실행) | `getEntityFetchCount()` 내부 집계가 Hibernate 버전에 따라 컬렉션 원소 포함할 여지 → 회귀가드는 세더 무관한 `pageFetches==N` 으로 못 박음 |
|
||||
| D-08 | L4 격리 지표 = **부모 `EntityStatistics.getLoadCount()`(=N, 전체 하이드레이트)** vs `returned`(=min(20,N)) → over-fetch 배수 = N/pageSize. 비용 계기 = `getThreadAllocatedBytes`(GC 견고) + p99(환경의존 상대값), **Runtime 힙델타 금지**(trim된 N−page개가 GC돼 비용 은닉). `getCollectionFetchCount()`는 join 로드 컬렉션엔 안 잡혀 L4 신호 아님. fix 금지(엔티티페이징+@BatchSize는 L5) | L4 실측(`FeedPersistenceIT.l4*`, feedItemLoaded=10/100/1000·returned=10/20/20·over-fetch 1.0/5.0/50.0×), EXPLAIN (a)조인 Limit노드 부재/(b)엔티티 Limit노드 존재, Hibernate Statistics API, `:app-bootstrap:test` GREEN | **Strong(실측·GREEN 2026-07-13)** | 경고 코드가 예상 `HHH000104`가 아니라 **`HHH90003004`**(Hib7 재번호) → 회귀가드는 코드번호 아닌 문구(`collection fetch`)로도 매칭 |
|
||||
| D-09 | L5 = **첫 fix**(착상→해결, before/after). fix = 세션 설정 한 줄 `default_batch_fetch_size=100`(순진 loadFeed 코드 무변경). **격리**: 세션 전역 설정이라 `FeedPersistenceIT`에 넣으면 L1~L4 깨짐 → **새 `FeedBatchFetchIT`** 클래스로 격리(회귀 0). 스타 = `prepared`·`collectionFetch` **둘 다** `1+N → 1+ceil(N/batch)·연관`으로 붕괴 + 페이징 정상(feedItemLoaded=pageSize, L4 over-fetch 소멸) + 카테시안 없음(semi-join). fix 금지 아님(L5가 fix 랩) | L5 실측(`FeedBatchFetchIT`: prepared 5/5/23 vs L1 25/222/2022 = 87.9× 붕괴, collectionFetch 1/1/10=ceil(N/100), feedItemLoaded 10/20/20 vs L4 N, entitiesLoaded 1569), EXPLAIN (a)엔티티페이징 Limit노드 존재/(b)배치 IN semi-join 곱셈 없음, `FeedPersistenceIT` 0 fail(회귀 없음) | **Strong(실측·GREEN 2026-07-13)** | batch 크기 스윕(10/100/1000)은 property 클래스 단위라 미측정(공식 `1+ceil(N/batch)`로 유도, 실측 시 3회 실행). `getCollectionFetchCount()`가 초기화 수(=N) 아니라 fetch 연산 수(=ceil)임이 문서모델 정정 |
|
||||
| D-10 | L6 = **두 번째 fix**(착상→해결, before/after). fix = DTO 프로젝션(`SELECT new <carrier>(...)` 스칼라만). L5(왕복 축)와 **직교하는 "적재 형태 축"** — 엔티티를 아예 안 만든다. **격리**: fix가 실제 쿼리라 순진 `loadFeed` 고치면 L1~L5 깨짐 → `FeedQueryAdapter`에 sibling 메서드 `loadFeedProjection` 추가(loadFeed 무변경) + 새 `FeedProjectionIT`(배치 설정 없음 — 프로젝션은 배치와 직교). 프로덕션 1파일. 스타 = `getEntityLoadCount()` **1569→0**(과적재 소멸) + prepared **상수 2**(N 무관) + collectionFetch 0 | L6 실측(`FeedProjectionIT`: entitiesLoaded 0/0/0 vs L5 1569, prepared 2/2/2 vs L1 25/222/2022·L5 5/5/23, collectionFetch 0, 형태 동치 vs 순진 loadFeed), EXPLAIN (a)부모 프로젝션 Limit 존재/(b)자식 IN semi-join, `:app-bootstrap:test` 97/97 GREEN(FeedPersistenceIT·FeedBatchFetchIT·CleanArchitectureTest 회귀 0, `QUERY_PORTS_DO_NOT_LEAK` PASS, verifyCleanArchitectureDependencies GREEN) | **Strong(실측·GREEN 2026-07-13)** | **실측 정정**: 프로젝션 EXPLAIN width(2088)가 엔티티 SELECT fi.*(1194)보다 **오히려 넓다**(users·pages 조인+PG varchar 추정치) — 프로젝션 이득은 SQL 플랜 아니라 ORM 층(entityLoadCount 0). L6도 Top-N 못 풂(childRows 1509→L14) |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
### 1. 측정 경로와 production 읽기 경로의 격리
|
||||
|
||||
> **Trace**: D-09의 격리 결정과 D-10 + `SPRING-PROJ-C4`(DTO constructor projection)를 따른다.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 정확한 테스트 클래스·메서드 이름은 외부 source가 정하지 않는 ca-tmpl 내부 trade-off다. 기존 랩의 before 경로를 보존하고 회귀를 독립 실행하기 위해 현재 이름과 sibling 구조를 유지한다.
|
||||
|
||||
| Anchor | 구현 계약 | 현재 증거 |
|
||||
|---|---|---|
|
||||
| `FeedPersistenceIT` / `FeedBatchFetchIT` / `FeedProjectionIT` | L1~L6의 before/fix 경로를 서로 덮어쓰지 않고 sibling test와 sibling query로 격리한다. | `locally-verified` — 본문 L3~L6 회귀 결과 |
|
||||
| `FeedReadModelQueryPort` → `GetFeedReadModelUseCase` → `FeedReadModelQueryAdapter` | 같은 DB에서 write aggregate와 read projection logic을 분리하고, 부모 page + top-3 자식의 2-query read model을 반환한다. | `locally-verified` — `AZURE-CQRS-C2`, `AZURE-CQRS-C3`; 본문 L12 4 tests GREEN |
|
||||
|
||||
### 2. 미완료 측정의 처리
|
||||
|
||||
> **Trace**: D-07과 Claims To Verify #7. Supporting raw claim은 없으며 L1 실측 회계식에서 도출된 프로젝트 가설이다.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: L2의 예상값을 회귀 기준으로 먼저 고정하지 않는다. `FeedPersistenceIT`에서 실제 Hibernate Statistics를 캡처한 뒤에만 `locally-verified`로 승격한다.
|
||||
|
||||
| 입력 | 실행 | 완료 조건 |
|
||||
|---|---|---|
|
||||
| N = 10 / 100 / 1000 | `getEntityFetchCount()`와 entity별 fetch count를 독립 캡처 | `pageFetches`, `userFetches`, 순수 ToOne 회계식이 실측으로 일치하거나 불일치 원인이 기록됨 |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**: 전역 batch 설정이나 production projection으로 기존 naive `loadFeed`를 대체하면 L1~L4 재현 경로가 사라진다. 기존 sibling 격리를 유지하고 각 랩 회귀를 함께 실행한다.
|
||||
- **실패·엣지 경로**: L2 유도값을 실측처럼 기록하면 뒤 단계의 GREEN이 미검증 gap을 숨긴다. L2는 현재 `planned`이며 불일치도 결과로 보존한다.
|
||||
- **다른 계약 의존**: 별도 branch decision을 consume하지 않는다. ca-tmpl application 계약이 full CQRS를 escalation-only로 유지하는 동안 본 구현 경계는 same-store CQRS-lite다.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
랩 미실행 단계라 아래는 **실측으로 확정 전**(현재는 설계 가설):
|
||||
|
||||
1. L3에서 `MultipleBagFetchException`이 실제로 재현되고, 하나만 fetch 시 카테시안 곱으로 전송 행수 ≫ 엔티티 수가 관측되는가.
|
||||
2. ✅ **확정(2026-07-13, L4 실측 GREEN)**: 컬렉션 fetch join+페이징 시 `feedItemLoaded`(=N) ≫ `returned`(=min(20,N)), over-fetch = N/pageSize(1.0/5.0/50.0×) **결정적** 확인. 경고 코드는 예상 `HHH000104`가 아니라 **`HHH90003004`**(Hib7, 문구 동일). 힙/지연은 `getThreadAllocatedBytes`(≈1.5→10MB)+p99로 N에 비례 확인(절대값은 환경의존 상대곡선; Runtime 힙델타는 trim GC로 은닉되어 부적합). N=10k 힙 실측은 옵션(§6 CI 부담)으로 남김.
|
||||
3. ✅ **확정(2026-07-13, L5 실측 GREEN)**: `default_batch_fetch_size=100`가 쿼리 수를 `1+N → 1+ceil(N/batch)·연관`으로(prepared 25/222/2022 → **5/5/23**, N=1000에서 87.9× 붕괴), 페이징 정상(엔티티 페이징이라 SQL `LIMIT` 존재·`feedItemLoaded=min(20,N)`)으로 실제로 만든다. **정정**: `getCollectionFetchCount()`는 초기화 수(=N)가 아니라 **fetch SELECT 연산 수(=ceil(N/batch): 1/1/10)**로 접힌다. batch 크기 스윕은 property 클래스 단위라 미측정(공식 유도).
|
||||
4. `Bench.measure`의 **바이트 근사(직렬화 크기)·힙 델타**가 랩 간 유의미한 신호를 주는가(GC 노이즈에 묻히지 않는가).
|
||||
5. ✅ **확정(2026-07-13, L6 실측 GREEN)**: DTO 프로젝션(`SELECT new <carrier>(...)` 스칼라만)이 엔티티를 **0개** 하이드레이트(`getEntityLoadCount()` 1569→0, lazy 0회·영속성 컨텍스트 미적재·더티체킹 0) + 쿼리 **상수 2**(N 무관). "페이지당 3(Top-N)"은 **못 푼다** 확인 — 자식 IN 프로젝션이 페이지 부모의 하이라이트 전량(childRows=**1509**, top-3=60 훨씬 초과)을 가져옴(그룹 아닌 행에 LIMIT 불가) → L14. **정정**: 프로젝션 EXPLAIN width(2088)는 엔티티(1194)보다 **좁지 않고 오히려 넓다**(조인+PG varchar 추정) — 이득은 SQL 플랜 아니라 ORM 층.
|
||||
6. 사슬(§0.2)의 각 화살표가 **주장한 순서대로** 터지는가(해법이 정말 다음 문제를 낳는가), 아니면 중간에 다른 실패가 끼어드는가.
|
||||
7. **(L2)** §2.6 유도값 — `pageFetches`=N(선형)·`userFetches`=3/20/20(평탄)·`entityFetches`=13/120/1020 — 이 실제 `getEntityFetchCount()`·엔티티별 `getFetchCount()` 실측과 일치하는가. 특히 항등식 `entityFetches == preparedStmts − collectionFetches − 2` 와 "접근 0인데 `pageFetches==N`, `collectionFetches==0`"(§2.3 probe). (이 세션 Docker 미가용으로 **유도만**; §2.4 IT 실행으로 확정.)
|
||||
|
||||
## 2026-07-08 (2) SUBSTRATE 재결정 → ca-tmpl 프로덕션 모듈 + 파운데이션 빌드 가이드
|
||||
|
||||
한 번 별도 `liner-feed-lab/` 스캐폴드를 만들었다가 **되돌림**(사용자: "ca-tmpl에 실제 도메인이 붙는거라 app-bootstrap 그대로 쓰고 싶다"). 피드를 **ca-tmpl 프로덕션 모듈에 첫 제품 도메인**으로 구현하기로 재결정. 분업: 파운데이션은 사용자가 **직접 타이핑**(주제2 계층 이해 목적), 나는 "다시 안 묻게" 상세 빌드 가이드 작성.
|
||||
|
||||
**빌드 가이드**: `docs/superpowers/plans/2026-07-08-nplus1-feed-foundation-build-guide.md` (직접 구성용, 코드+설명+검증).
|
||||
|
||||
**서브에이전트 4개 병렬 조사(worklog/poster 실제 패턴)에서 나온 3가지 충격(가이드 §0)**:
|
||||
1. **프로덕션 모듈엔 도메인 0개** — worklog·poster는 전부 `sample-portfolio`. feed = 첫 프로덕션 도메인. 레퍼런스 = poster 슬라이스(패키지 루트만 `sample.portfolio.*`→`dev.caskeleton.*`로 이동). 가드레일(ArchUnit·allowedProjectDependencies)은 **모듈/패키지-패턴 기반이라 서브패키지 feed 자동 커버** — build.gradle/ArchUnit 편집 불필요.
|
||||
2. **★ ID 규약**: `@GeneratedValue`/SEQUENCE/IDENTITY **repo 전체에서 미사용**. ID = **ULID 값객체(`FeedItemId implements ResourceId`) → native `uuid`**(`@JdbcTypeCode(SqlTypes.UUID)`), 유즈케이스에서 IdFactory 민팅. 하드룰 `NO_LONG_ID_PK`. → 스펙의 `Long/SEQUENCE` 스키마를 **UUID PK로 수정**. **L9(쓰기 N+1: IDENTITY가 배치 무력화)는 클라할당 UUID라 재현 안 됨 → Video2에서 재설계.** L1~L6 무관.
|
||||
3. **feed = repo 최초의 진짜 연관**(`@ManyToOne` user/page EAGER=L2씨앗, `@OneToMany` highlights=L1씨앗). poster/worklog는 연관 0개(스칼라/`@ElementCollection`만). 새 영역이라 빌드로 검증하며 진행.
|
||||
|
||||
**핵심 아키텍처 사실(가이드에 반영)**:
|
||||
- ID 값객체 4개(`FeedItemId/UserId/PageId/HighlightId implements ResourceId`), 애그리거트는 **다른 애그리거트를 ID로만 참조**(순수성).
|
||||
- 애그리거트 퍼시스턴스 포트 `*Repository`는 **domain 패키지**, 프로젝션 읽기 포트 `*QueryPort`는 application.
|
||||
- **CQRS 갈래**: 쓰기=FeedItem 애그리거트(N+1 재현), 읽기=`FeedQueryPort`→`FeedView` 프로젝션(L6/L12 무대). **랩은 `FeedQueryAdapter.loadFeed` body만 교체**(포트 고정).
|
||||
- 매퍼 hand-written static(ULID↔UUID). 어댑터 `@Transactional` 금지(트랜잭션=유즈케이스 `TransactionPort`). 감사=퍼시스턴스 `AuditableEntity`(`@MappedSuperclass`, 수동 stamp). **highlights의 `created_at`이 `AuditableEntity.created_at`과 충돌 → highlights는 AuditableEntity 미상속 권장**.
|
||||
- 마이그레이션: 프로덕션 `db/migration/postgresql/`(V1·V3·V4·V5 존재)→**V6__feed.sql**. sample의 `db/sample-migration/`(V2·V6-poster)와 다른 classpath. 런타임 = 깨끗한 ca-app-pg :5433.
|
||||
- 보안: `GET /api/feed` 기본 인증(deny-by-default). 측정은 HTTP 아닌 IT(Testcontainers `@ServiceConnection`, `ddl-auto=validate` 드리프트 게이트) → 인증 무관.
|
||||
- 쿼리카운트 하네스 **repo에 없음** → Hibernate `generate_statistics`(`getPrepareStatementCount`)로 시작, 필요시 datasource-proxy(락 갱신).
|
||||
- Gotchas: `spotlessApply` 항상 먼저, 한파일-한타입, STRICT 락(새 의존성 시 `resolveAndLockAll --write-locks`).
|
||||
- **Gotcha(2026-07-09, 파운데이션 빌드 중):** rdbms-base 엔티티(`..adapter.outbound.persistence..`, `.postgresql` 밖)의 `@Column`에 `columnDefinition`(예: `"uuid"`)을 달면 ArchUnit `PERSISTENCE_RDBMS_ENTITIES_DO_NOT_PIN_VENDOR_COLUMN_DEFINITIONS` 위반 → `check` FAIL. 물리타입은 vendor Flyway(V6)가 소유, 엔티티는 `@JdbcTypeCode(SqlTypes.UUID)` 표준 힌트만. **feed 가이드 §4.1 예제가 `columnDefinition = "uuid"`를 달고 있던 자기모순 → 삭제(가이드 수정 완료).** 해법: `columnDefinition` 제거, `@JdbcTypeCode`만 유지.
|
||||
- **Gotcha(동일):** `CleanArchitectureTest`의 `@AnalyzeClasses(importOptions = ProductionClassImportOption.class)` = `DoNotIncludeTests` → **ArchUnit은 test 클래스를 스캔하지 않는다.** ∴ 퍼시스턴스 IT를 app-bootstrap에 두든 어댑터 모듈에 두든 ArchUnit 실패와 무관 — IT 위치는 **컨벤션 선택**(app-bootstrap=PG 통합테스트 repo 표준 홈, 의존·`PostgreSqlTestContainer` 헬퍼 완비, 락 0 / persistence-jpa=어댑터가 자기 IT 소유 컨벤션, sample-portfolio `PosterRepositoryAdapterIntegrationTest` 선례, 그 모듈에 testcontainers 의존+락 필요). "엔티티 어노테이션 무시로 룰 우회"는 HARD-STOP 방향 → 금지.
|
||||
|
||||
## L0 완료 (2026-07-10) — 기준선 + 첫 실 에러 + 외부 발표 문서
|
||||
|
||||
- **L0 구현 완료**(커밋됨): feed 도메인(4 애그리거트+ID값객체)·application(`FeedQueryPort`/`FeedSummary`/`GetFeedUseCase`)·persistence(`FeedItemJpaEntity` 등, `FeedQueryAdapter` 순진 구현)·`V6__feed.sql`·`FeedPersistenceIT`+`FeedSeedFixture`. IT는 **app-bootstrap test**(옵션 B). `check` 통과 전제로 L0 스모크 GREEN.
|
||||
- **N+1 메커니즘 정밀화(발표 킬러 포인트)**: `@ManyToOne` 기본 EAGER는 **JPQL/`findAllBy` 리스트 쿼리에서 JOIN이 아니라 "2차 SELECT"** 로 나간다(`em.find(id)`만 JOIN). 쿼리 수 = `1 + distinct(user) + N(page) + N(highlights)` — **1차 캐시가 공유 연관을 dedup**. 시더가 user는 풀(≤20)로 재사용/page는 아이템당 1개(distinct)라 **같은 EAGER인데 user는 dedup·page는 폭발** → "N+1 폭발계수는 애너테이션이 아니라 카디널리티". IT 주석 `1+3N`은 최악(전부 distinct) 케이스. (`collectionFetches==N`, `preparedStatements>N` 단언으로 하한 증명.)
|
||||
- **문제 분리**: L0는 두 문제를 드러냄 — ⓐ N+1(fetch 전략) ⓑ 기준 쿼리 Seq Scan+Sort(인덱스/정렬, `ORDER BY first_highlighted_at DESC,id`). **원인·해법 축이 다름**(fetch join vs 인덱스/keyset). 섞지 말 것.
|
||||
- **외부 발표 문서**: `/home/donghyeon/dev/topic-arrange/n+1liner/README.md` (단일 발표자료 — 2026-07-10 L0/L1 분리본(01/02) 병합·삭제). 사용자 지시로 (a) **측정 환경**(실제 PG16 Testcontainers·`ddl-auto=validate`·어댑터 직접 측정, why H2 아님/why HTTP 아님) (b) **데이터셋 구성+왜**(user 풀 재사용 vs page distinct = dedup 대비, highlight 멱함수 1~500 = "수백 개" 재현, visibility 6:2:2, N∈{10,100,1k}) — **생성 메커니즘 명시**(엔티티별 개수가 왜 다른지: feed_item=N 루프 / page=N 1:1 `pages[i]` / user=`max(3,min(20,N/5+1))` 라운드로빈 `users[i%size]` / highlight=`max(1,round(500/(i+1)^1.15))` 합) + **지프의 법칙** 설명(멱법칙 s=1.15, 왜 균일/정규 아닌지, 총량이 N에 sub-linear한 이유 = 머리 지배) 섹션 추가. **메타 문구 제거**(파일명 참조·"이 문서 세트는~" 금지 → "흔한 오해/실제" 콜아웃으로 전환, 발표자료 톤). 사용자 노션 초안을 코드 대조로 교정해 작성. 사용자 초안의 **ArchUnit 주장 3건 부정확 → 교정**: ① `@ValueObject` 실제 룰 = `VALUE_OBJECTS_HAVE_NO_PUBLIC_NO_ARG_CONSTRUCTOR`(final은 record 특성, setter금지는 `@AggregateRoot` 룰). ② "VO를 엔티티/서비스 필드 금지"는 ArchUnit 아님(관례; 엔티티는 UUID 저장). ③ "엔티티 package-private를 archunit로 강제"는 부정확 — **연관 게터**만 package-private(클래스는 public)이고 **손 관례**(ArchUnit 룰 없음); 엔티티 누출은 `CONTROLLERS_DO_NOT_ACCESS/RETURN_...`·`QUERY_PORTS_DO_NOT_LEAK...`가 다른 층에서 막음.
|
||||
|
||||
## L3·L4 완료 (2026-07-13) — fetch join 착상의 이중 실패(카테시안 + 페이징 불가)
|
||||
|
||||
- **L3 완료(이전 세션, 커밋됨)**: 두 번째 컬렉션 `mentions`(bag) + `FeedItemMentionJpaEntity` + `V7__feed_mentions.sql` 추가 후 IT로 fetch join 착상을 터뜨림. 실측: ① 두 bag 동시 fetch join → `MultipleBagFetchException`(실측: `IllegalArgumentException`으로 래핑 → 테스트는 `causeChain` 문자열 매칭이 견고). ② 한 bag만 fetch join → 카테시안: 전송 행수(조인 카디널리티) = **1,285/1,961/2,917**(= Σhighlights) ≫ 리스트 크기 N. **Hibernate 6+/7 루트 자동 dedup**으로 리스트 크기가 N이 되어 카테시안이 이중으로 숨음 → 스타는 리스트 크기가 아니라 조인 count/EXPLAIN actual rows. (Claims #1 ✅ 확정.)
|
||||
|
||||
- **★ L4 완료(이 세션, 실측 GREEN)**: L3의 후퇴("컬렉션은 하나만 fetch join")에 페이징(`setMaxResults(20)`)을 걸어 세 번째 실패를 격리. **프로덕션 코드 0**(IT 측정만). `FeedPersistenceIT`에 L4 4메서드 추가 → `:app-bootstrap:test --tests '*FeedPersistenceIT'` **GREEN(0 fail)**, `CleanArchitectureTest` GREEN(프로덕션 무변경). L1/L2/L3 회귀 없음.
|
||||
- **★ 스타 실측**: `returned`(=min(20,N)) = 10/20/20 **평탄**인데 `feedItemLoaded`(부모 `EntityStatistics.getLoadCount()`) = **10/100/1000**(=N, 전체 하이드레이트) → over-fetch = N/pageSize = **1.0/5.0/50.0×**. "페이지를 원했는데 데이터셋 전체를 로드"를 통계로 못 박음. N=10(<pageSize)에선 1.0×라 함정 불가시 = "dev 시드 통과, 운영 폭발"(§2.2 `if(n>PAGE_SIZE)` 강가드).
|
||||
- **★ 실측 정정(발표/errors 소재)**: 경고 코드는 예상한 `HHH000104`가 아니라 **`HHH90003004`**(Hibernate ORM 7.1.8). 메시지 본문은 동일(`firstResult/maxResults specified with collection fetch; applying in memory`) — 6→7 코드 재번호. 회귀가드는 코드 번호가 아니라 **문구(`collection fetch`)로도 매칭**해야 견고(실제로 `|| contains("collection fetch")` 분기가 어서션을 통과시킴). → `raw/errors/` 승격.
|
||||
- **EXPLAIN 대조(D2)**: (a) 컬렉션 조인 SQL엔 **Limit 노드 부재**(전체 1961행 quicksort 445kB) / (b) 엔티티만 페이징 SQL엔 **Limit 노드 존재**(top-N heapsort 28kB, 20행). (a)에 Limit 없음 = "DB가 페이징 안 함 → Hibernate가 메모리에서 함"의 계획 레벨 증거.
|
||||
- **비용 계기 정정(honesty)**: Runtime 힙델타 금지(trim된 N−page개가 GC돼 비용 은닉) → `getThreadAllocatedBytes`(GC 견고, 할당 ≈1.5→10MB) + p99. **반전**: 이 fetch join 지연(p99 N=1000 ≈83.5ms)은 순진 조회(L1 max 238ms)보다 **오히려 낮음** → 지연만 보면 "빨라졌다" 착각, 진짜 비용은 메모리 과적재.
|
||||
- **fix 금지 준수**: `@BatchSize`·엔티티페이징·`fail_on_pagination...=true`·`.distinct()` 커밋 안 함(다음 고리 L5 지우지 않게). §2.6 probe(엔티티페이징=LIMIT정상이나 L1 N+1 재현)도 커밋 제외. D5 고리 = fetch join 버리고 엔티티페이징(LIMIT 정상)+연관 IN 배치 → **L5 `@BatchSize`**.
|
||||
- **산출물**: `ca-tmpl:docs/notes/L4.md`(D1~D6) + L4 실행 가이드 `docs/superpowers/plans/2026-07-13-nplus1-L4-fetchjoin-paging-hhh000104-lab-guide.md`. 외부 발표 문서(현 위치 `/home/donghyeon/workspace/ai-tool/topic-arrange/n+1liner/n+1liner.md`)에 **§10 신설**(evidence-map C11~C14 hash-anchor + `l4-inmemory-paging.csv`/`l4-cost-curve.csv` + EXPLAIN 원문 2건, validator 7종 GREEN, 옛 §10 다음단계→§11 재배치). 측정 코드는 working tree(사용자 커밋 대기).
|
||||
|
||||
## L5 완료 (2026-07-13) — 첫 fix: 엔티티 페이징 + 배치 IN이 L1~L4를 동시에 푼다
|
||||
|
||||
- **★ L5 완료(실측 GREEN)**: L4의 해법 착상("fetch join 버리고 엔티티 페이징 + 연관 IN 배치")을 실행 = **첫 fix 랩(착상→해결, before/after)**. fix = 세션 설정 한 줄 `hibernate.default_batch_fetch_size=100` — **순진 `loadFeed` 코드는 한 글자도 안 고침**(같은 코드가 L1에선 N+1, L5에선 배치).
|
||||
- **격리 설계**: `default_batch_fetch_size`는 세션 전역이라 `FeedPersistenceIT`에 넣으면 L1~L4 단언이 깨진다 → **새 클래스 `FeedBatchFetchIT`에 격리**(그 설정만 얹음). `FeedPersistenceIT`는 byte 단위 무변경 → **회귀 0**(실측: FeedPersistenceIT 0 fail, CleanArchitectureTest 0 fail). 시더·`LabReport`·Testcontainer 재사용.
|
||||
- **★ 쿼리 붕괴(스타)**: `loadFeed(0, n)`(L1과 같은 호출) prepared = **5 / 5 / 23** vs L1 순진 **25 / 222 / 2022** → N=1000에서 **87.9× 붕괴**. 분해(N=1000): 1 루트 + 1 count + 10 highlights + 10 page + 1 user 배치(각 ceil(N/100)). ToOne(EAGER page/user)도 배치에 걸려 L2 선형 N+1 동반 소멸.
|
||||
- **★ 실측 정정(errors 승격)**: `getCollectionFetchCount()`가 배치에서 N이 아니라 **1/1/10 = ceil(N/batch)**로 떨어진다 — 이 지표는 "초기화된 컬렉션 수"가 아니라 **컬렉션 fetch SELECT 연산 수**. 문서 모델(L4가이드 §0.4·발표 §6.1의 "배치를 켜도 N 유지") 정정. 배치 해결의 증인은 `prepared`·`collectionFetch` 둘 다. → raw/errors 승격.
|
||||
- **페이징 정상(§10 대조)**: `loadFeed(0, 20)` feedItemLoaded = **10/20/20 = min(pageSize,N)** vs L4 fetch join의 N(10/100/1000). **L4 over-fetch 소멸** — 엔티티만 페이징이라 인메모리 페이징 없이 DB LIMIT이 정확히 페이지만 자름.
|
||||
- **EXPLAIN(§9·§10 둘 다 해소)**: (a) 엔티티 페이징 SQL엔 **Limit 노드 존재**(top-N heapsort 28kB — L4 (a) fetch join엔 없었다). (b) 배치 IN은 **Hash Semi Join**으로 자식 행만 반환(1509, 합) — L3 카테시안(1961, 곱) 소멸.
|
||||
- **잔여 비용(→ L6)**: 페이지 20건 조회(seed 1000)에도 `entitiesLoaded = 1569`(FeedItem+User+Page+Highlight 전 컬럼·영속성 컨텍스트·더티체킹) — 배치는 쿼리·페이징을 풀지만 엔티티 과적재는 남음 → **L6 DTO 프로젝션**. "페이지당 3"(Top-N)은 L6도 못 풂 → Video2 L14.
|
||||
- **산출물**: L5 실행 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-14-nplus1-L5-batchsize-paging-resolution-lab-guide.md` + `docs/notes/L5.md`(D1~D6, before/after) + `FeedBatchFetchIT`(신규 격리 IT). 발표 문서(`/home/donghyeon/workspace/ai-tool/topic-arrange/n+1liner/n+1liner.md`)에 **§11 신설**(첫 해결 절, evidence-map C15/C16 hash-anchor + `l5-batch-resolution.csv`/`l5-hydration-probe.csv` + EXPLAIN 원문 2건, validator 7종 GREEN, 옛 §11 다음단계→§12). 측정 코드는 working tree(사용자 커밋 대기).
|
||||
|
||||
## L6 완료 (2026-07-13) — 두 번째 fix: DTO 프로젝션이 엔티티 과적재를 없앤다
|
||||
|
||||
- **★ L6 완료(실측 GREEN)**: L5가 남긴 잔여 비용(엔티티 과적재 `entitiesLoaded=1569`)을 **DTO 프로젝션**으로 제거 = **두 번째 fix 랩(착상→해결, before/after)**. fix = `FeedQueryAdapter.loadFeedProjection`(`SELECT new <carrier>(...)` 스칼라만 뽑는 실제 쿼리). L5(설정 한 줄)와 달리 실제 코드지만, **L5(왕복 축)와 직교하는 "적재 형태 축"** — 배치는 "몇 번 SQL", 프로젝션은 "무엇을 적재".
|
||||
- **격리 설계**: fix가 실제 쿼리라 순진 `loadFeed`(L1~L5 측정 대상)를 고치면 그 랩들이 깨진다 → **sibling 메서드 `loadFeedProjection` 추가**(loadFeed byte 무변경) + **새 `FeedProjectionIT`**(배치 설정 **없음** — 프로젝션은 프록시/컬렉션을 안 만드니 배치와 직교). 프로덕션 1파일(`FeedQueryAdapter` + 캐리어 record 2 + EntityManager 주입). L5가 sibling IT로 격리한 것의 어댑터-메서드 판.
|
||||
- **★ 엔티티 0(스타)**: `loadFeedProjection(0, n)` `getEntityLoadCount()` = **0 / 0 / 0** vs L5 배치 **1569**. `SELECT new <carrier>(...)`는 스칼라만 뽑아 영속 엔티티를 인스턴스화하지 않음(조인은 컬럼 접근용, 하이드레이션 아님) → 영속성 컨텍스트 미적재·더티체킹 0·lazy 0. prepared = **2 / 2 / 2**(부모 스칼라 + 자식 IN, **N 무관 상수** — L1 `1+N`·L5 `1+ceil(N/batch)`와 삼중 대조), collectionFetch = 0. 형태 동치(`l6ProjectionReturnsSameShapeAsNaiveLoadFeed`: 프로젝션 vs 순진 loadFeed 같은 결과 = fix가 결과 안 바꿈).
|
||||
- **★ 실측 정정(errors 승격) — 프로젝션 EXPLAIN width는 좁아지지 않는다**: 초안 착상은 "프로젝션은 필요 컬럼만 읽어 width가 엔티티 `SELECT fi.*`보다 좁다"였으나 **실측은 정반대** — 부모 프로젝션 width = **2088 > 엔티티 1194**. 이유: 프로젝션이 users·pages 조인(그 행폭 흘러듦) + PG `width`는 varchar 평균폭 추정치(컬럼 수 아님). **결론: 프로젝션 이득은 SQL 플랜에 안 보인다** — 진짜 이득은 ORM/JVM 층(entityLoadCount 0), `Statistics`로만 관측. → raw/errors 승격(L3 dedup·L4 HHH90003004·L5 collectionFetch에 이은 **네 번째 실측 정정**).
|
||||
- **EXPLAIN(D2)**: (a) 부모 스칼라 프로젝션엔 **Limit 노드 존재**(페이징 정상, top-N heapsort) — 단 width 2088. (b) 자식 스칼라 IN은 **Hash Semi Join**으로 자식 행(1509)만 반환(곱셈 없음, L5 배치와 동일 shape).
|
||||
- **잔여 비용(→ L14)**: 페이지 20건(seed 1000)의 자식 행 `childRows = 1509`(부모당 전량) — 화면엔 부모당 top-3(≤60)면 충분한데도. 그룹당 LIMIT은 단순 `IN`으로 불가 → **Top-N-per-group(L14)**(윈도우 함수/LATERAL/2단계 배치). (Claims #5 ✅ 확정, D-06 Strong 승격.)
|
||||
- **회귀·아키텍처 0**: `:app-bootstrap:test` **97/97 GREEN** — FeedProjectionIT 6/6 + FeedBatchFetchIT 8/8(L5) + FeedPersistenceIT 26/26(L1~L4) + CleanArchitectureTest 57/57(`QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES` PASS — 프로젝션은 application DTO `FeedSummary`만 반환, 캐리어 record는 persistence 내부 전용). `verifyCleanArchitectureDependencies` GREEN(경계·의존 방향 무변경). spotlessCheck GREEN.
|
||||
- **산출물**: L6 실행 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-15-nplus1-L6-dto-projection-entity-overfetch-lab-guide.md`(width 실측 정정 포함) + `docs/notes/L6.md`(D1~D6, before/after) + `FeedQueryAdapter.loadFeedProjection`(프로덕션) + `FeedProjectionIT`(신규 격리 IT). 발표 문서(`topic-arrange/n+1liner/n+1liner.md`)에 **§12 신설**(두 번째 해결 절, evidence-map C17/C18/C19 hash-anchor + `l6-projection-resolution.csv`/`l6-explain-width.csv` + EXPLAIN 원문 2건, validator 7종 GREEN, 옛 §12 다음단계→§13). 측정·프로덕션 코드는 working tree(사용자 커밋 대기).
|
||||
|
||||
## L14 완료 (2026-07-16) — 왕관 첫 보석: Top-N-per-group 세 해법 대결 (실측 GREEN)
|
||||
|
||||
- **★ L14 완료(실측 GREEN)**: L6가 남긴 잔여(자식 IN 전량 `childRows=1509`)를 **그룹당 top-3**으로 접는 왕관 첫 랩. 정전(L1~L6, 단일 fix)과 달리 **SQL·인덱스 문제 + 세 해법 대결**(윈도우/LATERAL/2단계) → 스타 = **3안 EXPLAIN 플랜 대조**(쿼리 개수 아님). **IT-only**(`FeedTopNIT` 신규, native SQL을 `JdbcTemplate`으로 — `loadFeed`/`loadFeedProjection` 무변경, 프로덕션 0). **새 인덱스 없음** — V6 `ix_highlights_feed_items_created (feed_item_id, created_at DESC)` 재사용.
|
||||
- **★ 3안 플랜 대조(seed 1000, page 20, K=3, 같은 실행=apples-to-apples)**: ⓑ LATERAL = `Nested Loop`+`Index Scan(ix_highlights…)`+`Limit 3`, buffers **204**·0.323ms — 최소·최속(부모별 3개만 seek, `loops=20 rows=3`). ⓐ window = `WindowAgg`←`Hash Semi Join`(전량 rows=1509), buffers 430. ⓒ 2단계 = `Sort`←`Hash Semi Join`, 반환 1509(앱컷 전 전량). **window·2단계 buffers 동일(430) = 같은 스캔** — window = 2단계 + DB측 컷(PG15+ `Run Condition: row_number()<=3`). LATERAL만 구조적으로 다른(인덱스 seek). 셋 다 같은 top-3(60행).
|
||||
- **★ 인덱스 토글(인과 실증)**: 같은 LATERAL을 `ix_highlights_feed_items_created` DROP→측정→`finally` 복구. 인덱스 없으면 부모별 Seq Scan(`Rows Removed by Filter: 2842/loop`) → buffers 168→**4446(≈26배)**·exec 0.336→**5.472ms(≈16배)**. "LATERAL이 빠른 건 LATERAL이 아니라 인덱스 seek 덕" — 대부분 "LATERAL 쓰면 빠르다"에서 멈추는 지점을 실측 인과화(선배 넘는 차별점).
|
||||
- **D6 그룹 크기 K 곡선(3/50/500)**: 반환 60/695/1509(결정적, K 컷). LATERAL buffers 모든 K에서 window보다 작음(114<162, 155<216, 171<269), 작은 K일수록 격차↑. 의사결정: 큰 그룹·작은 K → LATERAL, K≈그룹크기 → window 단순.
|
||||
- **정확성·기전**: window·lateral 부모당 3(반환 60·부모 20), 순진 `LIMIT 3` = 전체 3행(부모 1개만 = 오작동, `LIMIT`엔 그룹당 없음). **왜 native**: 표준 JPQL엔 윈도우·LATERAL 없음(Hibernate 6+ HQL은 윈도우만 확장 지원, LATERAL 없음). 2단계만 JPQL(IN)+앱컷 가능 → A/B는 native로 내려감(왕관=SQL 레이어 논지).
|
||||
- **잔여(→ L15)**: `l14ProbeParentPagingStillUsesOffsetNotKeyset` — 부모 페이징이 아직 `OFFSET 900`(앞 900행 scan-then-discard) → keyset/seek(L15) → keyset 인덱스에 가시성 술어 얹기(L16).
|
||||
- **회귀·게이트 0**: `FeedTopNIT` GREEN. `loadFeed`/`loadFeedProjection` 무변경 → `FeedPersistenceIT`(L1~L4)·`FeedBatchFetchIT`(L5)·`FeedProjectionIT`(L6) 0 fail. IT-only라 `CleanArchitectureTest`/의존 매트릭스 무관(어댑터 메서드 미추가). 인덱스 토글 `finally` 복구로 후속 테스트 오염 0.
|
||||
- **산출물**: L14 실행 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-16-nplus1-L14-topn-per-group-window-lateral-2step-lab-guide.md` + `docs/notes/L14.md`(실측) + `FeedTopNIT`(신규 IT). **발표 문서 `topic-arrange/n+1liner/n+1liner.md`에 §13 신설**(왕관 첫 절, §12.6 "→§13/L14" 예고 해소): 3안 플랜 대조표 + EXPLAIN 원문 4건(`l14-{lateral,window,twostep,lateral-no-index}-plan.txt`) + 인덱스 토글 + K 곡선; **evidence-map C20**(anchor `1,509` measured, l14-topn-resolution.csv#L4, hash `92ca611f5b10ae29`) + l14-*.csv 4종 + 매니페스트 `topn-per-group-resolution`; 옛 §13 다음단계→§14. **tooling 골든 검증 432/432 GREEN**(`verify_evidence` 백/포워드 커버리지·해시·매니페스트·링크). buffers·exec는 whitelist(환경 의존 상대값, l4-cost-curve와 같은 선). 측정·문서 커밋은 사용자 대기.
|
||||
|
||||
## L15 완료 (2026-07-17) — 왕관 둘째 보석: keyset vs OFFSET 깊은 페이지 페이징 (실측 GREEN)
|
||||
|
||||
- **★ L15 완료(실측 GREEN)**: L14의 부모 페이징 잔여(아직 `OFFSET`)를 **keyset(seek)**으로 없애는 왕관 둘째 랩. 단일 fix(before/after), 스타 = **페이지 깊이별 스캔량 곡선**. **IT-only**(`FeedKeysetIT` 신규, native SQL을 `JdbcTemplate`으로, 6 tests). **정렬키 인덱스 `(first_highlighted_at DESC, id DESC)`는 IT 안 CREATE/DROP 토글** — V6 `ix_feed_items_visibility_sort`는 선두 컬럼이 `visibility`라 가시성 없는 keyset을 못 받침(L14는 기존 인덱스 재사용, L15는 정렬키 전용 인덱스 도입이 차이).
|
||||
- **★ 깊이 곡선(seed 2000, 같은 정렬키 인덱스)**: OFFSET이 `Limit` 하위로 훑는 행 = **offset+20**(page 1/50/100 = **20/1000/2000**, 깊이 정확 비례) vs **keyset = 20 평탄**. page 100에서 OFFSET 100× over-scan. 두 곡선 page 1 동일 출발 → 발산.
|
||||
- **★ 깊은 페이지 플랜(offset 1980, 한 실행)**: OFFSET = `Limit`←`Sort`(2000)←`Seq Scan`(2000), buffers **141**, 0.996ms. keyset+인덱스 = `Limit`←**`Index Only Scan`**(커버링, `Heap Fetches: 20`), 훑은 행 **20**, buffers **1**, 0.076ms, **Sort 노드 없음**(순서 인덱스 보장). keyset−인덱스 = `Seq Scan`(`Rows Removed by Filter: 1980`)+`Sort`, 훑은 행 20이나 buffers **141**(=OFFSET, 전량 heap). → **keyset이 평탄한 건 keyset 문법이 아니라 정렬키 인덱스 덕**(§13.4 LATERAL 교훈과 같은 결).
|
||||
- **정확성**: `l15KeysetWalkMatchesOffsetPages` — keyset 커서(page1 마지막 행)로 넘긴 page 2 == OFFSET page 2(같은 20 id·순서). row-value `(first_highlighted_at, id) < (:cursor)`의 tie-break `id`가 경계를 유일하게.
|
||||
- **★ D5(→ L16, 실측 bridge)**: `l15ProbeVisibilityOrBreaksKeysetIndex` — keyset에 가시성 필터(`PUBLIC OR (MENTIONED AND EXISTS(mentions)) OR (PRIVATE AND user_id=me)`)를 얹으면 `ix_feed_items_keyset` **미사용**. 대신 `BitmapOr`(가시성 3분기 각각 `Bitmap Index Scan on ix_feed_items_visibility_sort`)+`BitmapAnd`(private)+`SubPlan`(mentions EXISTS), 그리고 **`Sort` 노드 재등장**(순서 seek 이점 소멸). 가시성 OR이 keyset을 "훑고 정렬"로 되돌린다 → **L16**(UNION 분해로 각 분기를 정렬 보장 인덱스로 만들어 merge / 부분·복합 인덱스 / 사전계산).
|
||||
- **회귀·게이트 0**: `FeedKeysetIT` GREEN. `loadFeed`/`loadFeedProjection` 무변경 → `FeedPersistenceIT`·`FeedBatchFetchIT`·`FeedProjectionIT`·`FeedTopNIT`(L1~L14) 0 fail. IT-only → `CleanArchitectureTest`/의존 매트릭스 무관. 정렬키 인덱스 토글 `finally` DROP(DDL auto-commit 복구).
|
||||
- **산출물**: L15 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-17-nplus1-L15-keyset-vs-offset-deep-page-pagination-lab-guide.md` + `docs/notes/L15.md`(실측) + `FeedKeysetIT`(신규 IT). **발표 문서 `n+1liner.md`에 §14 신설**(§13.7 "→L15" 예고 해소): 깊이 곡선표 + EXPLAIN 원문 4건 + 정렬키 인덱스 유무 + 가시성 probe; **evidence-map C21**(anchor `2,000` measured, l15-depth-curve.csv#L4, hash `e3e5307b9108f35d`) + l15-*.csv 2종/*.txt 4종 + 매니페스트 `keyset-vs-offset-deep-page`; 옛 §14 다음단계→§15. **tooling 골든 432/432 GREEN**. buffers·exec whitelist(환경 의존). 측정·문서 커밋은 사용자 대기.
|
||||
- **다음(L16)**: 가시성 술어 인덱싱 — L14처럼 세 해법 대결(UNION 분해 / 부분·복합 인덱스 / 사전계산). L15의 가시성 OR probe(BitmapOr+Sort)가 진입점. 왕관 닫으면 CQRS(L12)로 일반화.
|
||||
|
||||
## L16 완료 (2026-07-18) — 왕관 셋째·닫힘: 가시성 술어 인덱싱 (실측 GREEN) ★ 왕관 완결
|
||||
|
||||
- **★ L16 완료(실측 GREEN)**: L15의 가시성 잔여(keyset에 OR 얹으면 인덱스 못 탐)를 세 해법으로 없애는 **왕관 셋째·마지막 랩**. 가시성 = `PUBLIC OR (MENTIONED AND EXISTS(mentions)) OR (PRIVATE AND author)`. **IT-only**(`FeedVisibilityIT` 신규, native SQL, 4 tests). 신규 인덱스(`ix_mentions_user (mentioned_user_id, feed_item_id)`, `ix_feed_items_private` partial `WHERE visibility='PRIVATE'`)·사전계산 테이블(`feed_visible`)은 IT 안 CREATE/DROP 토글. 뷰어 user008.
|
||||
- **★ 3안 플랜 대조(seed 2000, 스타)**: ⓐ 단일 OR = `BitmapOr`(3분기)+top-N `Sort`+**hashed SubPlan**(멘션), 후보 **1500** 훑어 20, buffers **122**. ⓑ UNION 분해 = **`Merge Append`**(분기별 정렬 스트림)+`Hash Join`(멘션 EXISTS→집합)+private `Index Only Scan`(partial)+`Incremental Sort`, buffers **200**. ⓒ **사전계산 = `Index Only Scan`(feed_visible 커버링), Sort·OR·조인 전부 없음, buffers 1**. 셋 다 같은 20 feed_item(`l16ThreeApproachesReturnSameVisibleSet`).
|
||||
- **★ 실측 정정(초안 2건 반증)**: (1) 단일 OR ≠ seq scan — V6·partial 인덱스가 있어 `BitmapOr`+`Sort`+hashed SubPlan(순수 seq scan 아님). (2) UNION 분해는 buffers를 **안 줄인다**(200 > 단일 OR 122) — 각 분기가 자기 스캔. **UNION은 구조를 고치고(상관 SubPlan→Hash Join, 전체 Sort→Merge Append, 분기별 인덱스), 사전계산이 자릿수를 바꾼다(buffers 1 ≪ 122/200)**. "쿼리 재작성=구조 개선, 모델 변경=규모 변경"이 L16의 결론(L3~L6·L14·L15 정정 계보). → raw/errors 승격 후보.
|
||||
- **분기별 인덱스**(`l16LowSelectivityBranchesRideTheirIndex`): mentioned=`ix_mentions_user` Hash Join(V7 인덱스는 `(feed_item_id, …)`라 mentioned_user_id 조회 불가 → 신규 필요), private=`ix_feed_items_private` partial Index Only Scan, public(60% 고선택도)=Bitmap+top-N. **UNION의 값 = 각 분기가 자기 최적 플랜**(단일 OR은 하나의 bitmap으로 묶여 불가).
|
||||
- **★ D5 왕관 닫힘 → L12 CQRS**: 사전계산(`feed_visible`)의 프로덕션 형태 = **CQRS 읽기 모델**(쓰기 모델=FeedItem 애그리거트·도메인 이벤트 → 읽기 모델=뷰어별 투영). Top-N(L14)+keyset(L15)+가시성(L16)을 한 조회로 → 주제2(아키텍처: 헥사고날·CQRS) 브릿지. "N+1은 쓰기 모델로 읽기를 한다는 신호"의 일반화 완결.
|
||||
- **회귀·게이트 0**: `FeedVisibilityIT` GREEN. 기존 경로·IT 무변경 → `FeedPersistenceIT`(L1~L4)·`FeedBatchFetchIT`(L5)·`FeedProjectionIT`(L6)·`FeedTopNIT`(L14)·`FeedKeysetIT`(L15) 0 fail. IT-only → `CleanArchitectureTest`/의존 매트릭스 무관. 신규 인덱스·`feed_visible` `finally` `DROP … IF EXISTS`.
|
||||
- **산출물**: L16 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-18-nplus1-L16-visibility-predicate-indexing-union-partial-precompute-lab-guide.md` + `docs/notes/L16.md`(실측) + `FeedVisibilityIT`(신규 IT). **발표 문서 `n+1liner.md`에 §15 신설**(§14.5 "→§15/L16" 예고 해소, 왕관 닫힘·CQRS 브릿지): 3안 플랜 대조표 + EXPLAIN 원문 4건 + 분기별 인덱스 + 실측 정정; **evidence-map C22**(anchor `1,500` measured, l16-plan-compare.csv#L2, hash `17cef9aa820b252d`) + l16-plan-compare.csv + l16-*.txt 4종 + 매니페스트 `visibility-predicate-indexing`; 옛 §15 다음단계→§16. **tooling 골든 446/446 GREEN**. buffers·exec whitelist(환경 의존). 측정·문서 커밋은 사용자 대기.
|
||||
- **★ 왕관 완결(Video 2 코어)**: L14(Top-N)·L15(keyset)·L16(가시성) 세 보석 모두 실측 GREEN + 발표 §13/§14/§15 신설. 남은 것 = (선택) Task 4 통합 쿼리(3난제 한 조회) + 왕관 의사결정 매트릭스, 그리고 L12 CQRS(주제2).
|
||||
|
||||
## Crown Task 4 완료 (2026-07-19) — 통합: Top-N+keyset+가시성 한 쿼리 + 의사결정 매트릭스 (실측 GREEN) ★ 왕관 대관식
|
||||
|
||||
- **★ Task 4 완료(실측 GREEN)**: 왕관 세 보석(L14/L15/L16)을 **한 개의 피드 조회**로 합류 — `(가시성 필터 + keyset 부모) CROSS JOIN LATERAL (부모당 top-3)`. **IT-only**(`FeedCrownIT` 신규, native SQL, 4 tests). 신규 인덱스·`feed_visible`는 L16 setup 재사용(IT 안 CREATE/DROP 토글). 뷰어 user008(보이는 아이템 **1500**).
|
||||
- **부모선택 3안**(= 매트릭스가 사는 자리): ⓐ 단일 OR(feed_items 직접) / ⓑ UNION 분해(분기별 keyset 인덱스) / ⓒ 사전계산(`feed_visible` + keyset). 셋 다 같은 20 부모(`unionEq`·`precomputeEq` 참, `crownUnifiedReturnsSameShapeAcrossParentPaths`) — 답 동일, 플랜만 다름.
|
||||
- **★ 스타(한 플랜 세 기법, page 1)**: 사전계산 부모선택 통합 쿼리 = `Nested Loop`(LATERAL) → `Index Only Scan using ix_feed_visible`(가시성+keyset, Heap Fetches 20) + 부모 20마다 `Index Scan using ix_highlights_feed_items_created`(Top-N top-3). **Sort 노드 없음**(두 순서 모두 인덱스). 세 기법이 재정렬 없이 한 플랜에 겹친다.
|
||||
- **★ 간섭 시험(핵심 발견)**: 가장 깊은 페이지(보이는 1500 중 마지막, cursor=visible−20)에서 — 사전계산은 `ix_feed_visible` 인덱스 range 로 **19행**만(부모 buffers 3), 단일 OR 은 `feed_visible` 미사용(구조적) + `BitmapOr`(3분기) + 멘션 hashed SubPlan 으로 내 멘션 **200행** materialize(부모 buffers 31). **L16 발견이 통합 쿼리에서 재현** — 세 기법은 부모선택이 사전계산/UNION 일 때만 깨끗이 겹친다.
|
||||
- **★ 실측 정정(초안 반증)**: 초안은 "사전계산 위 keyset 은 Sort 없이 seek, 단일 OR 은 Sort 로 깨진다"였다. **실측 정정**: 가장 깊은 커서에선 **둘 다** 남은 19행 작은 `Sort`(quicksort 26kB)가 붙는다(Bitmap 스캔이 정렬 출력을 안 함). **차이는 "Sort 유무"가 아니라 "페이지에 닿는 비용"(훑는 행 19 vs 200 + feed_visible 인덱스 사용 여부)**. page 1 에선 사전계산이 순수 `Index Only Scan`(Sort 전무). (L3~L6·L14·L15·L16 정정 계보 → raw/errors 승격 후보.)
|
||||
- **왕관 의사결정 매트릭스(D5)**: Top-N→LATERAL(작은 K)/윈도우(큰 K) · 페이징→keyset · 가시성→UNION 분해/고트래픽이면 사전계산(=CQRS) · 통합→부모선택(가시성+keyset)×LATERAL. **핵심 = 부모선택**(사전계산/UNION 이면 매 페이지 재해소 없음).
|
||||
- **회귀·게이트 0**: `FeedCrownIT` GREEN. 기존 경로·IT 무변경 → `FeedPersistenceIT`(L1~L4)·`FeedBatchFetchIT`(L5)·`FeedProjectionIT`(L6)·`FeedTopNIT`(L14)·`FeedKeysetIT`(L15)·`FeedVisibilityIT`(L16) 0 fail. IT-only → `CleanArchitectureTest`/의존 매트릭스 무관. 신규 인덱스·`feed_visible` `finally` `DROP … IF EXISTS`.
|
||||
- **산출물**: Task 4 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-19-nplus1-crown-unified-topn-keyset-visibility-decision-matrix-lab-guide.md` + `docs/notes/crown.md`(실측) + `FeedCrownIT`(신규 IT). **발표 문서 `n+1liner.md`에 §16 신설**(왕관 통합, §15.5 "→§16 통합" 예고 해소, 왕관 완결·CQRS 브릿지): 통합 shape + 한 플랜 세 기법 EXPLAIN + 간섭 시험표 + 의사결정 매트릭스; **evidence-map C23**(anchor `1,500` measured, crown-unified-plan.csv#L7, hash `ff27d1902d444309`) + crown-unified-plan.csv + crown-*.txt 3종 + 매니페스트 `crown-unified-topn-keyset-visibility`; 옛 §16 다음단계→§17. **tooling 골든 448/448 GREEN**. buffers·exec whitelist(bare int, NUM_RE 미매칭). 측정·문서 커밋은 사용자 대기.
|
||||
- **★ 왕관 대관식(Video 2 완성)**: L14+L15+L16 세 보석 + Task 4 통합 = 왕관 완성. 남은 것 = **L12 CQRS 읽기 모델(주제2 브릿지)** — 사전계산 부모선택이 그 진입점(feed_visible = 읽기 모델).
|
||||
|
||||
## L12 완료 (2026-07-20) — CQRS-lite 읽기 모델, 프로덕션 읽기 경로 승격 (실측 GREEN) ★ 주제2 브릿지
|
||||
|
||||
- **★ L12 완료(실측 GREEN)**: 왕관 결론을 **프로덕션 읽기 경로**로 승격. L6(엔티티 0, 측정용 sibling `loadFeedProjection`) + L14(top-3, IT native SQL)를 합쳐, 쓰기 애그리거트(`FeedItem`)와 분리된 **일급 읽기 모델**(전용 포트·유스케이스·프로젝션 DTO)로. 화면 shape 그대로(아이템당 top-3, 엔티티 0).
|
||||
- **★ 범위 결정(계약 준수 = HARD-STOP 회피)**: 사용자가 처음엔 "실제 프로덕션 CQRS"(별도 읽기 저장소+동기화)를 골랐으나, **실측으로 `ca-tmpl:src/application-core/CLAUDE.md:162` D2 "Full CQRS with a separate physical read store = out of scope — escalation only"**를 발견 → 충돌 표면화(Prime Directive) → 사용자가 **CQRS-lite(계약 내, `ca-tmpl:src/application-core/CLAUDE.md:145` "Projection (CQRS-lite)")**로 재선택. 별도 테이블·마이그레이션·아웃박스 sync **없음**(같은 저장소, 읽기 최적 쿼리).
|
||||
- **구현(다모듈, ca-implementer full-usecase)**: `FeedReadModelQueryPort`(`List<FeedSummary> loadReadModel(page,size)`, 이름이 `QueryPort`라 D1 강제) + `GetFeedReadModelQuery` + `GetFeedReadModelUseCase`(`QueryUseCase`, `@UseCaseCapability(READ_ONLY,IDEMPOTENT,READ_REPOSITORY)`, `tx.inRead`) [application-core] + `FeedReadModelQueryAdapter`(`@Repository`) [adapter-persistence-jpa] + `FeedReadModelUseCaseIT` [app-bootstrap test]. naive `loadFeed`·`loadFeedProjection`·L1~L16 ITs **무변경**.
|
||||
- **읽기 모델 쿼리 = 상수 2쿼리(엔티티 0)**: ① 부모 페이지 JPQL `SELECT new FeedReadModelParentRow(...)`(L6 스타일), ② 자식 top-3 네이티브 `row_number() OVER (PARTITION BY feed_item_id ORDER BY created_at DESC) <= 3`(L14 window) `IN` 페이지 부모.
|
||||
- **★ 설계 판단(→ raw/interviews 후보)**: 두 쿼리를 `JdbcTemplate`이 아니라 Hibernate `Session.createNativeQuery`/`EntityManager`로 발행. 이유 = `Statistics.getPrepareStatementCount()`/`getEntityLoadCount()`(IT 지표)는 Hibernate 자신의 JDBC coordinator를 거친 SQL만 관측 — 별도 `JdbcTemplate`이면 `prepared=0`으로 읽혀 "상수 2쿼리" 단언이 공허하게 참. Session 경유라 실제 2쿼리 증명.
|
||||
- **실측(`FeedReadModelUseCaseIT` 4 tests GREEN)**: 반환 ≤20 items(fhl DESC) · `topHighlights` 부모당 ≤3(총 ≤60, L6 잔여 1509 해소) · `getEntityLoadCount()==0` · `getPrepareStatementCount()==2`(N∈{10,100} 동일, N 무관 상수).
|
||||
- **아키텍처 검증**: **ca-architect-sentinel PASS**(pre-commit 워킹트리 감사, blocking 0/advisory 0) — 의존 방향·D1 포트 순수성·HARD-STOP·use-case 계약·CQRS-lite 범위 준수(별도 저장소/마이그레이션/아웃박스 없음 확인)·어댑터 @Transactional 없음·vendor-neutral 네이티브 SQL. `CleanArchitectureTest` 57/57(`QUERY_PORTS_DO_NOT_LEAK…` 포함)·`verifyCleanArchitectureDependencies` GREEN. 회귀 `FeedProjectionIT`·`FeedTopNIT`·`FeedCrownIT` 18/18. spec/quality 리뷰어는 사용자 커밋 후 range로 실행 예정.
|
||||
- **산출물**: L12 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-20-nplus1-L12-cqrs-lite-read-model-projection-port-lab-guide.md` + `ca-tmpl:docs/notes/L12.md` + 프로덕션 6파일(포트/쿼리/유스케이스/유스케이스테스트/어댑터/IT). 발표 §(CQRS-lite 읽기 모델, 주제2 브릿지)는 리뷰 PASS 후 n+1liner에 신설 예정. 커밋은 사용자.
|
||||
- **★ 주제2 브릿지**: "CQRS-lite = 별도 읽기 *모델*(같은 저장소), 풀 CQRS = 별도 읽기 *저장소*(에스컬레이션)". 별도 물리 저장소가 필요(고트래픽·가시성 사전계산 = Task 4 feed_visible)해지면 D2를 계약·가드레일과 개정 → 주제2(헥사고날·CQRS) 본격 진입.
|
||||
- **파생 후보(raw/errors 없음 — 무실패)**: raw/interviews "왜 JdbcTemplate 대신 Hibernate Session native로 window를 실행했나 — Statistics 관측 범위" · raw/blog-topics "CQRS-lite 읽기 모델에서 JPQL SELECT new + Hibernate native를 섞은 이유" · 그리고 **"계약이 풀 CQRS를 에스컬레이션 전용으로 묶어둔 것을 실측으로 발견 → 충돌 표면화 → 범위 재협상"**(거버넌스 사례) — 캐논 추출은 사용자 요청 시.
|
||||
- **★ check 게이트 선재 블로커 2건(L12 무관, 발견·해소 → raw/errors 후보)**: 사용자 요청으로 전체 `./gradlew check`를 (커밋 없이) 처음 돌리자 두 선재 문제가 표면화 — (1) domain-core `Page`/`User`/`FeedItem`의 checkstyle `NeedBraces` 3건(중괄호 없는 단문 `if`), (2) Flyway 버전 충돌: 공유 `V6__feed.sql`(피드 파운데이션 6f0b0d6)과 sample `V6__poster.sql`(post 도메인 d8cae2f)이 둘 다 V6인데, sample-portfolio가 `locations: db/migration/postgresql,db/sample-migration` 두 위치를 다 로드해 `Found more than one migration with version 6`. **랩 내내 `:app-bootstrap:test`(그 컨텍스트는 db/sample-migration 미로드)만 돌려 전체 게이트가 조용히 red였던 것이 여기서 처음 드러남.** 해소: (1) 중괄호 추가(동작 무변경), (2) 사용자가 "sample은 참고용이라 지워도 됨"이라 했으나 모듈 삭제는 settings·의존매트릭스·app-bootstrap sampleFixture·ArchUnit `..sample.portfolio..` 규칙·sample-isolation verify task 6곳 cascade → 대신 sample `V6__poster.sql → V10__poster.sql` 리넘버(피드 substrate 무변경, 격리). 결과 `./gradlew check` = 1565 tests 0 fail(8 skip) GREEN. **교훈 = "타깃 테스트만 돌리면 전체 게이트 회귀를 놓친다"** → raw/errors 승격 후보(제목: "타깃 테스트가 가린 전체 check 게이트 red — checkstyle + Flyway 멀티모듈 버전충돌").
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- Hibernate 7의 collection fetch pagination 경고가 예상한 `HHH000104`가 아니라 `HHH90003004`로 관측됐다. 코드 번호 고정 assertion 대신 메시지 의미를 함께 검사했고, [[raw/errors/hibernate7-hhh90003004-collection-fetch-paging-2026-07-13]]에 분리했다.
|
||||
- `getCollectionFetchCount()`를 초기화된 컬렉션 수로 해석한 초기 모델이 batch fetch 실측과 어긋났다. fetch SELECT 횟수로 정정하고 [[raw/errors/hibernate-getcollectionfetchcount-batch-semantics-2026-07-13]]에 보존했다.
|
||||
- target IT만 실행하는 동안 전체 `check`의 checkstyle·Flyway migration 충돌이 드러나지 않았다. 두 선재 문제를 해소한 뒤 전체 `check` 결과를 별도 근거로 기록했으며, target test GREEN만으로 전체 gate를 대체하지 않는다.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: errors:start -->
|
||||
- [[raw/errors/hibernate-dto-projection-explain-width-not-narrower-2026-07-13]]
|
||||
- [[raw/errors/hibernate-getcollectionfetchcount-batch-semantics-2026-07-13]]
|
||||
- [[raw/errors/hibernate7-hhh90003004-collection-fetch-paging-2026-07-13]]
|
||||
<!-- GENERATED: errors:end -->
|
||||
|
||||
### L0 이후 갱신
|
||||
|
||||
- `raw/errors/` — ✅ **실발생(L0)**: `@DataJpaTest` "Unable to find a @SpringBootConfiguration" — IT가 `dev.caskeleton.adapter.outbound.jpa.feed`에 있어 부트앱 `dev.caskeleton.bootstrap.CaSkeletonApplication`(형제 패키지)을 자동 탐색 실패 → 해법 `@ContextConfiguration(classes = CaSkeletonApplication.class)`(`FeedPersistenceIT:42`). (topic-arrange 부록 A.1에 기록; raw/errors 단독 파일은 L1+ 에러와 묶어 승격 예정.) · ✅ **L3/L4 관측(2026-07-13)**: `MultipleBagFetchException`(L3, `IllegalArgumentException`로 래핑) · **`HHH90003004`**(L4 인메모리 페이징 — 예상 `HHH000104` 아님, Hib7 코드 드리프트) → [[raw/errors/hibernate7-hhh90003004-collection-fetch-paging-2026-07-13]] 승격. · ✅ **L5 정정(2026-07-13)**: `getCollectionFetchCount()`가 배치에서 초기화 수(N)가 아니라 fetch 연산 수(ceil(N/batch))로 접힘 → [[raw/errors/hibernate-getcollectionfetchcount-batch-semantics-2026-07-13]] 승격. · ✅ **L6 정정(2026-07-13)**: DTO 프로젝션 EXPLAIN `width`(2088)가 엔티티 `SELECT fi.*`(1194)보다 좁지 않고 오히려 넓음 — 프로젝션 이득은 SQL 플랜 아니라 ORM 층(entityLoadCount 0) → [[raw/errors/hibernate-dto-projection-explain-width-not-narrower-2026-07-13]] 승격. · 예정: IDENTITY 배치 무력화(Phase3 L9). ← 실 에러 메시지 캡처 후 생성.
|
||||
- `raw/blog-topics/` — "N+1 해법이 다음 문제를 낳는 사슬"(fetch join→MultipleBag→HHH000104→BatchSize→DTO), "N+1은 관계형 문제가 아니다(패치 전략 문제)", "ToOne EAGER 숨은 N+1: 같은 @ManyToOne인데 카디널리티가 곡선을 가른다(page 선형 vs user 평탄) + 접근 0인데 나가는 N+1".
|
||||
- `raw/interviews/` — "N+1을 깊이있게 다뤘다의 기준"(커버리지 vs 깊이), "Top-N-per-group 3가지 해법 트레이드오프".
|
||||
|
||||
## 다음 단계
|
||||
|
||||
1. ✅ Task 0~3(스캐폴드·스키마·시더·하네스) = **L0 완료**(위 "L0 완료" 섹션). 하네스 = Hibernate Statistics(`preparedStatementCount`/`collectionFetchCount`) + EXPLAIN.
|
||||
2. ✅ **L1 실행 가이드 작성** = `ca-tmpl:docs/superpowers/plans/2026-07-10-nplus1-L1-collection-nplus1-lab-guide.md`(foundation guide 형식). 핵심: **`getCollectionFetchCount()`(=정확히 N)로 highlights 컬렉션 N+1을 격리** — `preparedStatementCount`(base+user/page EAGER 2차SELECT+highlights 섞임)와 분리. `@ParameterizedTest` N={10,100,1k}로 `collectionFetches==N` 선형 단언 + nanoTime p50/p99(의존성0). **L1=재현·측정 전용(fix 금지)**, 프로덕션 무변경(IT만 확장). 1차캐시/통계누적 gotcha 명시.
|
||||
3. ✅ **L1 실행 완료 (2026-07-10, 실측)**: `FeedPersistenceIT`에 곡선(`@ParameterizedTest` N=10/100/1000)·지연(nanoTime p50/p99)·EXPLAIN 테스트 추가 → `:app-bootstrap:test` GREEN(8 tests, 0 fail). **실측**: collectionFetches = **10/100/1000**(정확히 N, 선형 ✓), preparedStmts = 25/222/2022, ToOne몫(=preparedStmts−1−collFetch) = 14/121/1021, p50 = 32.8/85.9/193.7ms. **★ 반전(발표 킬러)**: 자식 쿼리 EXPLAIN = `Index Scan using ix_highlights_feed_items_created ... Execution Time 0.173ms`(빠름) — **N+1은 "느린 쿼리"가 아니라 "빠른 쿼리 N번 왕복"**, 인덱스로 안 풀림. UUID `?` 바인딩 정상(`::uuid`, CAST fallback 불필요). 발표본 = 단일 `~/dev/topic-arrange/n+1liner/README.md`에 통합(실측 반영). 측정 코드는 working tree(사용자 커밋 대기).
|
||||
4. ✅ **L2 실행 가이드 작성 (2026-07-11)** = `ca-tmpl:docs/superpowers/plans/2026-07-11-nplus1-L2-toone-eager-nplus1-lab-guide.md`(L1 가이드와 동형). 핵심: L1이 남긴 ToOne몫을 **`getEntityFetchCount()` + 엔티티별 `getEntityStatistics(...).getFetchCount()` 로 격리** → **page=선형 N(아이템당 고유) vs user=평탄 ≤20(풀 dedup)**, "같은 `@ManyToOne` EAGER인데 **카디널리티가 곡선을 가른다**"가 L2 킬러(D4). **정밀화**: L1 note의 ToOne몫 14/121/1021은 Spring Data `Page` count 쿼리를 몫에 섞은 값 — L2는 base(1)+count(1)을 `−2`로 분리해 **순수 ToOne = 13/120/1020**(=distinct(user)+N). 측정 설계: ① §2.3 "접근 0" probe(`getUser/getPage/getHighlights` 호출 0인데 `pageFetches=N`·`collectionFetches=0` → "안 짠 N+1" 증명) ② §2.4 곡선(회귀가드 `pageFetches==N`·`userFetches≤20`) ③ §2.5 반복 ToOne 단건 EXPLAIN(PK Index Scan이라 1건 빠름 × N 반복) ④ §2.7 EAGER→LAZY 토글(되돌리는 probe·커밋 금지) + EAGER×접근 2×2 매트릭스. **honesty**: §2.6 수치는 L1 실측에서 회계 항등식으로 **유도**(Docker 미가용, L2 미실행). **L2도 재현·측정 전용(fix 금지)**, 프로덕션 무변경(IT만 확장). D5 고리 = L1+L2 동시 해결 착상(연관 전부 fetch join) → L3 `MultipleBagFetchException`.
|
||||
5. ⏭ **L2 실행**(측정): 위 가이드대로 `FeedPersistenceIT`에 L2 측정(§2.2~2.5) 추가 → 실측으로 §2.6 유도값 확정(Claims #7) → `test: lab2 ...` 커밋(영상 4b). §2.7 LAZY 토글은 되돌리고 커밋 제외.
|
||||
6. ✅ **L3·L4·L5·L6 실행 완료 (2026-07-13, 각 섹션 참조)** — fetch join 이중 실패(L3 카테시안·L4 페이징) → L5 배치(첫 fix, 왕복 축) → L6 프로젝션(둘째 fix, 적재 형태 축). Video 1의 해결 투어(L5·L6) 완료. 남은 것 = 커밋(사용자) + 발표 슬라이드.
|
||||
7. Video 1 완료 후 Phase 4 왕관(Top-N/keyset/가시성) = Video 2 별도 플랜. **진입점 = "L6가 못 푼 Top-N"**(L6 실측 childRows 1509 = 페이지 부모 전량, top-3 아님) → **L14**(윈도우 함수 `row_number() over (partition by ...) <= 3` vs LATERAL vs 2단계 배치).
|
||||
8. ✅ **L14 실행 가이드 작성 + 실행 완료 (2026-07-16, measured GREEN — 위 "L14 완료" 섹션)** = 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-16-nplus1-L14-topn-per-group-window-lateral-2step-lab-guide.md`(L6 per-lab 형식으로 왕관 Task 1 분리·확장) + `FeedTopNIT`(신규 IT, 8 tests GREEN) + 발표 §13 신설(tooling 골든 432/432).
|
||||
9. ✅ **L15 실행 가이드 작성 + 실행 완료 (2026-07-17, measured GREEN — 위 "L15 완료" 섹션)** = 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-17-nplus1-L15-keyset-vs-offset-deep-page-pagination-lab-guide.md`(왕관 Task 2) + `FeedKeysetIT`(신규 IT, 6 tests GREEN) + 발표 §14 신설(옛 §14 다음단계→§15, tooling 골든 432/432). 회귀 111 tests 0 fail(L1~L15 + CleanArchitectureTest 57). **남은 왕관 = L16 가시성 술어 인덱싱**(L15 가시성 OR probe가 진입점) → 왕관 닫으면 CQRS(L12).
|
||||
10. ✅ **L16 실행 가이드 작성 + 실행 완료 (2026-07-18, measured GREEN — 위 "L16 완료" 섹션) ★ 왕관 완결** = 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-18-nplus1-L16-visibility-predicate-indexing-union-partial-precompute-lab-guide.md`(왕관 Task 3) + `FeedVisibilityIT`(신규 IT, 4 tests GREEN) + 발표 §15 신설(옛 §15 다음단계→§16, 왕관 닫힘·CQRS 브릿지, tooling 골든 446/446). 회귀 **115 tests 0 fail**(L1~L16 6개 IT + CleanArchitectureTest 57). **왕관(L14 Top-N·L15 keyset·L16 가시성) 세 보석 모두 완결.** 남은 것 = (선택) Task 4 통합 쿼리+왕관 의사결정 매트릭스, **L12 CQRS(주제2 브릿지)**. **성격 차이(정전 vs 왕관)**: L1~L6은 "착상→단일 fix"였으나 L14는 **"착상→세 해법 대결→트레이드오프 매트릭스"**이고 JPA 설정이 아니라 **SQL·인덱스·DB 설계** 문제 → **SQL은 shape만, 학습자가 직접 타이핑·튜닝**(크라운 철학). **스타 = D2 3안 `EXPLAIN (ANALYZE, BUFFERS)` 플랜 대조**(스캔타입 Index vs Seq·조인 알고리즘 WindowAgg/Nested Loop·buffers hit/read·actual time) — "쿼리 개수"가 아니라 "플랜 shape". 설계 골자: ① 격리 = 새 `FeedTopNIT`(native SQL을 jdbcTemplate EXPLAIN, `loadFeed`/`loadFeedProjection` 무변경) + 선택 sibling `loadFeedTopN`. ② **새 인덱스 불필요** — V6의 `ix_highlights_feed_items_created (feed_item_id, created_at DESC)`를 LATERAL 부모별 `LIMIT 3`이 탐(인덱스 신설 본질은 L16). ③ **인덱스 유무 토글**(`DROP/CREATE INDEX` + `finally` 복구)로 "LATERAL이 빠른 건 LATERAL이 아니라 인덱스 seek 덕"을 실증 = "선배 넘는" 인과. ④ D6 축 = N×**그룹 크기 K**{3,50,500}: 편중 시드 top 부모(500 하이라이트)에서 K=3은 LATERAL 압승(500 중 3 seek), K=500은 윈도우로 수렴. ⑤ **표준 JPQL로 윈도우·LATERAL 불가 → native**(Hibernate 6+ HQL은 윈도우만 확장 지원·LATERAL 없음 — 첫 실행 확인할 INFERENCE, D4). D5 고리 = 아이템 top-3 풀렸으나 **부모 피드 페이징**(OFFSET 깊은 페이지 붕괴) → **L15 keyset**. **실측 확정(가이드 예측과 일치)**: 반환 ⓐ=60/ⓑ=60/ⓒ=1509(=L6 childRows), ⓑ LATERAL buffers 최소(204 vs 430) + 인덱스 토글 168→4446(≈26배)로 인과 확정. 파생: `raw/interviews/`의 "Top-N-per-group 3가지 해법 트레이드오프" 인터뷰가 **실측으로 뒷받침됨**(캐논 추출은 사용자 요청 시).
|
||||
11. ✅ **Crown Task 4 통합 실행 완료 (2026-07-19, measured GREEN — 위 "Crown Task 4 완료" 섹션) ★ 왕관 대관식** = 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-19-nplus1-crown-unified-topn-keyset-visibility-decision-matrix-lab-guide.md` + `docs/notes/crown.md` + `FeedCrownIT`(신규 IT, 4 tests GREEN) + 발표 §16 신설(옛 §16 다음단계→§17, tooling 골든 448/448). 회귀 **119 tests 0 fail**(L1~L16 6개 IT + `FeedCrownIT` + CleanArchitectureTest 57). **통합 = (가시성+keyset 부모) × LATERAL(top-3)**; 부모선택 3안 같은 20 부모, 사전계산 부모선택이 세 기법을 재정렬 없이 한 플랜에 겹침(page 1 Sort 없음). **핵심 발견 = 간섭 시험**: 깊은 페이지 keyset 이 사전계산 위에선 인덱스 range(19행)로, 단일 OR 위에선 매 페이지 가시성 재해소(BitmapOr+멘션 SubPlan 200행)로 — L16 발견의 통합 재현. **★ 실측정정**: 깊은 커서에선 둘 다 남은 19행 작은 Sort(차이는 Sort 유무가 아니라 훑는 행수+feed_visible 인덱스 사용). **왕관 대관식(Video 2 완성)**: L14+L15+L16+Task4 통합 완결. 남은 것 = **L12 CQRS 읽기 모델(주제2 브릿지)** — 사전계산 부모선택(feed_visible)이 진입점.
|
||||
12. ✅ **L12 CQRS-lite 읽기 모델 구현 완료 (2026-07-20, measured GREEN — 위 "L12 완료" 섹션) ★ 주제2 브릿지** = 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-20-nplus1-L12-cqrs-lite-read-model-projection-port-lab-guide.md` + `docs/notes/L12.md` + 프로덕션 6파일(`FeedReadModelQueryPort`/`GetFeedReadModelQuery`/`GetFeedReadModelUseCase`/`GetFeedReadModelUseCaseTest` [application-core], `FeedReadModelQueryAdapter` [adapter-persistence-jpa], `FeedReadModelUseCaseIT` [app-bootstrap]). **첫 프로덕션 코드 변경**(L1~Task4는 IT-only였음) → ca-implementer full-usecase + ca-architect-sentinel PASS(pre-commit). **범위 거버넌스**: 사용자 "실제 CQRS" 선택 → 실측으로 계약 D2("풀 CQRS 별도 저장소 = 에스컬레이션 전용") 발견 → 충돌 표면화 → CQRS-lite로 재선택(계약 내, 별도 저장소·아웃박스 없음). **읽기 모델 = L6 프로젝션(엔티티 0) + L14 window top-3**, 상수 2쿼리, 화면 shape 그대로. 실측: entitiesLoaded=0·prepared=2(N∈{10,100})·top-3. 회귀 18/18 + CleanArchitectureTest 57/57. **남은 것** = 사용자 커밋 → spec/quality 리뷰어(커밋 range) → 발표 §신설. **주제2(헥사고날·CQRS) 진입 시** 별도 물리 읽기 저장소(D2)는 계약·가드레일 개정 후.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- 연결된 daily-note는 현재 없다. 날짜별 진행 증거는 본문의 2026-07-08~2026-07-20 완료 기록에 보존되어 있다.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크: 없음 — ca-tmpl 로컬 작업이며 사용자 커밋 대기 상태다.
|
||||
- 리뷰 메모: L12 pre-commit architecture 감사와 관련 회귀는 PASS; commit range 기반 spec/quality review는 아직 남아 있다.
|
||||
- 머지 결과 / 배포 환경: 로컬·Testcontainers까지만 검증, staging/prod 배포 없음.
|
||||
- **wiki 추출 대상** (review 이후 `wiki/projects/`로만 추출):
|
||||
- `actually-implemented`: L12 same-store CQRS-lite read path.
|
||||
- `locally-verified`: L1, L3~L6, L14~L16, Crown, L12의 본문 실측 결과.
|
||||
- **추출하지 않을 항목**: 미실행 L2, full CQRS 별도 read store, prod 성능 주장은 `planned` / `needs-confirmation`으로 유지한다.
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-frontend-operational-contract/branch-notes/feature-accessibility-baseline-contract.md
|
||||
@@ -0,0 +1,289 @@
|
||||
---
|
||||
title: branch / feature-accessibility-baseline-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-accessibility-baseline-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton-frontend, ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-frontend-operational-contract]
|
||||
tags: [branch, ca-skeleton, frontend, testing, react, static-analysis]
|
||||
created: 2026-07-18
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-019
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-frontend-operational-contract
|
||||
work_item: WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-019
|
||||
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-STYLING-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-TEST-STACK-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-013]
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: c7f5f84ade7d63ed9663a6988f27472d1bef629d546049c86ab104ba2314fcac
|
||||
imports: [FE-GATE-006@1, FE-OC-001@1, FE-OC-011@1, FE-OC-020@1, FE-OC-021@1, FE-OC-026@1]
|
||||
delegates: [DELEG-FE-004@1]
|
||||
|
||||
---
|
||||
|
||||
# branch: feature-accessibility-baseline-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — TODO·결정·진행 기록. 구현 결과는 검증 뒤 `/ingest`로만 추출한다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/project-notes/ca-skeleton-frontend-operational-contract]]
|
||||
|
||||
형제 branch (같은 project 의 다른 자식, 본 branch 가 의존/기여):
|
||||
|
||||
- [[raw/branch-notes/feature-async-ui-state-contract]] — async surface state 모델 owner (`FE-OC-011`). 본 branch 가 그 state 위에 a11y semantics 를 얹음(그 branch 가 명시적으로 위임).
|
||||
- [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] — gate/fixture/artifact 분리 owner (`FE-OC-020`). a11y gate 는 그 taxonomy 의 한 gate.
|
||||
- [[raw/branch-notes/feature-sample-feature-slice-contract-fixture]] — sample route owner (`FE-OC-024`). a11y 증거를 측정할 대상 route 제공.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: sample route에서 axe·keyboard·focus evidence가 남는다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-STYLING-001@1` | styling default는 Tailwind theme token과 component primitive다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-TEST-STACK-001@1` | test stack default는 Vitest, RTL, MSW, Playwright, axe다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- 이 branch 는 hub §10.3 Accessibility baseline 의 planned 요구를 *되묻지 않고 구현·검증 가능한* 계약으로 내린다. 스스로 Primary `FE-OC-*` 를 소유하지 않고 (§20 branch 분해표: Primary `—`), `FE-OC-019`·`FE-OC-020`·`FE-OC-021`·`FE-OC-024` 에 **기여**한다: (a) `FE-OC-020` 의 gate/fixture/artifact 분리에 a11y gate(`FE-GATE-009`) 와 그 fixture·artifact 를 공급, (b) `FE-OC-021` 의 context 동반 측정 NFR 에 `FE-NFR-009`(axe critical/serious 0) 를 공급, (c) `FE-OC-024` sample route 를 a11y 증거의 측정 대상으로 사용, (d) `FE-OC-019` browser 안전 경계(untrusted HTML 금지) 위에서만 접근 가능한 콘텐츠를 렌더한다는 전제를 명문화.
|
||||
- 완료의 measurable 정의(§20): **axe + keyboard/focus manual evidence for sample routes**. automated(axe) 와 manual(keyboard/focus/screen-reader) 두 증거를 모두 요구한다.
|
||||
- 이슈: 없음 (스캐폴딩 단계)
|
||||
- PR: 없음
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- hub §10.3 accessibility baseline 요구의 계약화: keyboard 도달성, visible focus, route 변경 후 deterministic focus target, loading state 의 live region + 반복 announcement 억제, error 의 programmatic association, color 단독 금지, modal focus trap/restore, reduced-motion 존중.
|
||||
- automated axe gate 설정: severity threshold(critical/serious = 0), 측정 대상(sample route), artifact(`pnpm test:a11y` → `artifacts/tests/a11y.json`), 컴포넌트 수준 a11y fixture(`pnpm test:component` 의 a11y fixtures → `artifacts/tests/component.xml`).
|
||||
- manual keyboard/focus/screen-reader 체크리스트 + 증거 형식(`FE-GATE-009` 의 "signed manual review").
|
||||
- hub §9.1 async surface state(§9.1 표)의 **a11y 표현 semantics**(live-region/focus attribute) — state 모델 자체가 아니라 그 위의 a11y hook.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외. 다른 owner branch 소유이므로 여기서 detail 을 정하지 않고 그 branch 를 가리킨다(CLAUDE.md §15.5 R3).
|
||||
|
||||
- async surface 에 *어떤 state 가 존재하고 언제 전이하는가* → [[raw/branch-notes/feature-async-ui-state-contract]] (`FE-OC-011`) 소유. 본 branch 는 state 목록을 consume 만 한다.
|
||||
- CI gate orchestration / gate·fixture·artifact 분리 프레임워크 → [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] (`FE-OC-020`) 소유. 본 branch 는 a11y gate 의 내용물만 공급.
|
||||
- untrusted HTML injection 금지·sanitization·CSP → [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] (`FE-OC-019`) 소유. a11y 는 "정제된 콘텐츠" 전제만 소비.
|
||||
- color contrast token 값 / design token → [[raw/branch-notes/feature-tailwind-design-token-styling-contract]] (`FE-OC-021` 기여) 소유. 본 branch 는 "color 를 state 의 유일 신호로 쓰지 않는다" 규칙만.
|
||||
- render/error boundary 배치 → `feature-frontend-render-recovery-boundary-contract` (`FE-OC-015`) 소유.
|
||||
- Web Vitals/performance NFR 측정 machinery → `feature-web-vitals-performance-budget-contract` (`FE-OC-021`) 소유. axe NFR 은 a11y 가, 측정 컨텍스트 규약은 그 branch 가.
|
||||
- 제품별 실제 화면 구현과 실제 audit 결과의 verified 승격.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/react-ui-library-official]] | D3·D4 — a11y attribute 가 부착되는 React 컴포넌트 구조의 source. **a11y 규칙 자체의 근거는 아님**(a11y 규칙은 hub §10.3). |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §10.3 | D1~D6 — accessibility baseline planned 요구(keyboard/focus/live-region/programmatic association/color/focus trap/reduced-motion/axe threshold)의 primary 근거 + "automated axe ≠ manual review" + "WCAG 미주장" 경계. |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §9.1 | D3 — async surface required/non-blocking state 모델(a11y hook 을 부착할 대상). |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §14.2·§14.3·§15 | D1·D2·D4 — `FE-NFR-009`(axe critical/serious 0, sample routes), `pnpm test:a11y`→`artifacts/tests/a11y.json`, `FE-GATE-009`(axe + signed manual review). |
|
||||
| axe-core `doc/API.md` (external research, 2026-07-19) — https://github.com/dequelabs/axe-core/blob/develop/doc/API.md | D1 — impact severity taxonomy. verbatim: *"How serious the violation is. Can be one of 'minor', 'moderate', 'serious', or 'critical'."* 또한 verbatim: *"Axe does not test hidden regions, such as inactive menus or modal windows."* ⚠️ 아직 `raw/official-docs/` 미아카이브 → follow-up: `wiki-source-summarizer` 로 `raw/official-docs/axe-core-official.md` 아카이브 권고. |
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] automated axe gate 설정(severity threshold critical/serious=0 + sample-route scope + `a11y.json` artifact) 명세 — 등급: `planned`
|
||||
- [ ] manual keyboard/focus/screen-reader 체크리스트 + signed evidence 형식 설계 — 등급: `planned`
|
||||
- [ ] hub §9.1 async state 별 live-region/focus a11y semantics 표 작성 — 등급: `planned`
|
||||
- [ ] reduced-motion + color-signal 규칙 명세 — 등급: `planned`
|
||||
- [ ] evidence-grade boundary(WCAG 미주장, planned 유지) 문서화 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- `/branch-spec` 로 hub §10.3/§9.1/§14/§15 + axe-core `doc/API.md`(1건 bounded research) 근거로 채움. frontend 코드는 아직 존재하지 않으므로 모든 항목 `planned`.
|
||||
- axe severity(critical/serious/moderate/minor) 정의는 axe-core 문서로 grounding. axe 는 hidden region(inactive menu/modal)을 검사하지 않는다는 점이 manual review 필수성의 기술적 근거 하나.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 아래 Decision Evidence Map 의 prose mirror. 근거는 hub §10.3/§9.1/§14/§15 + axe-core `doc/API.md`.
|
||||
|
||||
- **D1**: automated a11y gate 는 axe 를 사용하고 impact `critical`·`serious` violation 0 을 sample route 에서 blocking default 로 한다(`moderate`/`minor` 는 report-only backlog). / 이유: hub §10.3 이 axe critical/serious 0 을 blocking 으로 규정하고 `FE-NFR-009` 가 이를 NFR 로 고정 / 검토한 대안: 전면 manual audit(느리고 결정론 재현 불가) / 근거: hub §10.3·§14.2 + [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D022 + axe-core `doc/API.md`.
|
||||
- **D2**: automated axe 통과는 완료 판정을 단독으로 만들 수 없다 — axe(automated) + keyboard/focus/screen-reader(manual) 두 증거를 모두 요구한다. / 이유: hub §10.3 "automated axe 통과는 manual review 를 대체하지 않는다" + axe 가 hidden region 을 검사하지 않음 / 검토한 대안: automated-only(위양성 안심) — 거부 / 근거: hub §10.3·§20.
|
||||
- **D3**: async surface(§9.1)의 각 visible state 에 a11y 표현 semantics 를 부착한다(initial-loading = skeleton, focus theft 금지 / refreshing = subtle live region, 반복 announcement 억제 / terminal-error = programmatic 연결 + action focus). state 모델 자체는 async-ui branch 소유이고 본 branch 는 그 hook 만 소유. / 이유: hub §9.1 state 표 + §10.3 live-region/association 요구 + async-ui branch 의 명시적 위임 / 근거: hub §9.1·§10.3 + [[raw/branch-notes/feature-async-ui-state-contract]] (`FE-OC-011`).
|
||||
- **D4**: keyboard/focus baseline — 모든 interactive action 이 keyboard 로 도달, visible focus indicator, route 변경 후 deterministic focus target, modal focus trap + restore. / 이유: hub §10.3 planned 요구 + `FE-GATE-006` 컴포넌트 gate 의 keyboard 축 / 근거: hub §10.3·§15.
|
||||
- **D5**: axe 로 잡히지 않는 신호 — prefers-reduced-motion 존중 + color 를 state 의 유일 신호로 쓰지 않음(icon/text 병행). color token 값 자체는 tailwind branch 위임. / 이유: hub §10.3 / 근거: hub §10.3.
|
||||
- **D6**: evidence-grade boundary — repo 실행 증거 없이는 WCAG 적합을 주장하지 않고 모든 a11y 주장을 `planned` 로 유지하며, 외부 답변에서 목표 수치를 측정 결과처럼 말하지 않는다(`FE-OC-001`·`FE-OC-026`·§16 answer boundary). / 이유: hub §10.3 "WCAG 적합성은 실제 audit 없이 주장 금지" / 근거: hub §10.3·§2.1.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정의 근거 claim 과 선택 조건. `Decision ID` 는 이 note 안에서 안정.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | axe automated gate: impact `critical`·`serious` violation 0 을 sample route 에서 blocking default, `moderate`/`minor` 는 report-only backlog (`FE-OC-020`·`FE-OC-021` 기여, `FE-NFR-009`) | sample route 가 존재하는 한 axe blocking default / organization test platform 이 axe 를 대체하거나 더 엄격한 threshold 를 강제하면 재검토(test stack revisit trigger) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §10.3 (axe critical/serious 0 blocking)·§14.2 `FE-NFR-009`·FE-D022 (test stack incl. axe); axe-core `doc/API.md` impact taxonomy("minor/moderate/serious/critical") | `project-decision` + `conditional-default (test stack)` + `official-doc (axe severity)` | axe automated 는 a11y 이슈의 일부만 포착(→ D2 manual 필수). `moderate`/`minor` backlog 처리 정책과 rule-set 튜닝 미확정 |
|
||||
| D2 | 완료 판정 = axe(automated) **AND** keyboard/focus/screen-reader(manual) 이중 증거. automated pass 단독으로 완료 주장 금지 (`FE-OC-020` 기여) | 모든 a11y 완료 판정에서 불변 — 대안 없음(hub §10.3 문장 + axe 가 hidden region 미검사) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §10.3 ("automated axe 통과는 manual keyboard/screen-reader review 를 대체하지 않는다")·§20 measurable completion("axe + keyboard/focus manual evidence"); axe-core `doc/API.md`("Axe does not test hidden regions") | `project-decision` + `official-doc (axe scope 한계)` | manual review 는 사람 판단 → `FE-GATE-009` 의 "signed manual review" artifact 형식/서명 메커니즘 미확정 |
|
||||
| D3 | async surface(§9.1) state 별 a11y 표현: initial-loading=skeleton·focus theft 금지 / refreshing=subtle live region·반복 announcement 억제 / stale-degraded=stale 안내·manual retry 도달 / terminal-error=programmatic 연결·action focus / mutation-pending=aria-busy·중복 차단 (`FE-OC-011` consume) | async surface(원격 데이터 view)가 존재하는 한 적용 / 순수 정적 view(원격 데이터 없음)엔 async a11y hook 불필요. state 목록/전이가 바뀌면 async-ui owner 를 따라 재정렬 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §9.1 (visible state 표)·§10.3 (loading live region + 반복 announcement 억제, error programmatic association); [[raw/branch-notes/feature-async-ui-state-contract]] `FE-OC-011` 위임(async 는 a11y hook point 만 노출) | `project-decision` (cross-branch delegation) | aria-live politeness(polite vs assertive) 와 announcement debounce 메커니즘은 hub 미규정 → §구현 가이드 UNSUPPORTED_IMPL |
|
||||
| D4 | keyboard/focus baseline: 모든 interactive action keyboard 도달 + visible focus + route 변경 후 deterministic focus target + modal focus trap/restore (`FE-OC-020` 기여, `FE-GATE-006` keyboard 축) | 모든 interactive/route surface 에 적용 / 대안 없음(§10.3 planned 요구) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §10.3 (keyboard/visible focus/deterministic route focus/modal trap·restore)·§15 `FE-GATE-006` | `project-decision` | manual keyboard walk-through 는 automated 로 완전 대체 불가. route 변경 시 focus target 선택 규칙(main landmark vs heading)은 §10.3 미규정 → UNSUPPORTED_IMPL |
|
||||
| D5 | prefers-reduced-motion 존중 + color 단독 state 신호 금지(icon/text 병행). color contrast token 값은 tailwind branch 위임 | 항상 적용 / 대안 없음(§10.3) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §10.3 (reduced-motion 존중, color 만으로 state 구분 금지) | `project-decision` | reduced-motion 적용 범위(어떤 animation/transition)는 컴포넌트별. color contrast 값은 [[raw/branch-notes/feature-tailwind-design-token-styling-contract]] (`FE-OC-021` 기여) 소유 — 위임 |
|
||||
| D6 | evidence-grade boundary: repo 증거 없이 WCAG 적합 미주장, a11y 주장 `planned` 유지, 외부 답변에서 목표를 측정치처럼 표현 금지 (`FE-OC-001`·`FE-OC-026`) | repo evidence 없는 한 불변 / 실제 audit 후에만 conformance 주장 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §10.3 ("WCAG 적합성은 실제 audit 없이 주장하지 않는다")·§2.1 `FE-OC-001`("repo evidence 없이 완료 주장 MUST NOT")·`FE-OC-026` | `project-decision` (evidence invariant) | N/A (usage boundary). 다만 §16 answer boundary 를 파생 산출물에서 준수해야 함 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> `planned` blueprint. frontend 코드가 없으므로 경로/명령은 hub §14/§15 가 고정한 planned anchor 다. 3-rule(R1 Trace / R2 UNSUPPORTED_IMPL_DECISION / R3 OUT_OF_BRANCH_SCOPE) 준수.
|
||||
|
||||
### 1. Automated axe gate — severity threshold · scope · artifact
|
||||
|
||||
> **Trace**: D1 + `FE-OC-020`·`FE-OC-021` (기여) + [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §10.3·§14.2 `FE-NFR-009`·§14.3 `pnpm test:a11y`·FE-D022.
|
||||
>
|
||||
> - **`a11y.json` 스키마는 해소됨(2026-07-21)**: hub §2.1.3 `ART-FE-004@1` 로 등록됐고 **Schema Owner 는 본 branch** 다(`harness/source/artifact-schemas/ca-skeleton-frontend/a11y-report.schema.json`). impact 어휘는 axe-core 4단계를 그대로 쓰고, `blockingCount`(serious·critical)가 0 이 아니면 `FE-GATE-009` FAIL 이다.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: axe integration 메커니즘(@axe-core/playwright 로 route-level e2e-a11y + vitest-axe 로 component-level) 과 rule-set config 는 hub 가 규정하지 않음. Trade-off: FE-D022 의 Playwright+RTL 스택과 정합을 위해 위 조합을 제안하되, 최종 runner binding 은 test-taxonomy owner 확정에 위임.
|
||||
|
||||
| 항목 | planned 값 | 근거 |
|
||||
|---|---|---|
|
||||
| 대상 scope | sample route (제품 route 아님) | §14.2 `FE-NFR-009` context = sample routes |
|
||||
| blocking severity | impact ∈ {`critical`, `serious`} → fail | §10.3 + axe-core impact taxonomy |
|
||||
| non-blocking severity | impact ∈ {`moderate`, `minor`} → report-only backlog | axe-core impact taxonomy(4단계) |
|
||||
| route-level 실행 | `pnpm test:a11y` → `artifacts/tests/a11y.json` | §14.3 planned command 표 |
|
||||
| component-level 실행 | `pnpm test:component` 의 a11y fixtures → `artifacts/tests/component.xml` | §14.3(component = async/error/**a11y** fixtures) |
|
||||
|
||||
> **R3 위임**: a11y gate 를 CI 파이프라인에 blocking gate 로 배선하는 orchestration 은 [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] (`FE-OC-020`) 소유. 본 절은 gate 의 *내용물*(scope/severity/artifact)만 확정.
|
||||
|
||||
### 2. Manual keyboard / focus / screen-reader checklist + evidence format
|
||||
|
||||
> **Trace**: D2 + D4 + [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §10.3·§15 `FE-GATE-009`("signed manual review").
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: manual review record 의 경로/포맷(예: `artifacts/tests/a11y-manual/<route>.md`)과 "signed" 메커니즘(리뷰어 서명 방식)은 hub 가 "signed manual review" 라고만 하고 스키마를 규정하지 않음. Trade-off: sample route 당 markdown record 를 `a11y.json` 옆에 co-locate 제안, 최종 경로는 test-taxonomy owner 확정에 위임.
|
||||
|
||||
체크리스트 항목(§10.3 요구와 1:1):
|
||||
|
||||
| # | 수동 검증 항목 | 통과 기준 |
|
||||
|---|---|---|
|
||||
| M1 | keyboard 로 모든 interactive action 도달 | 마우스 없이 전 action 실행 가능 |
|
||||
| M2 | visible focus indicator | 모든 focusable 요소에 시각적 focus 표시 |
|
||||
| M3 | route 변경 후 deterministic focus target | route 전환 시 focus 가 정해진 지점으로 이동 |
|
||||
| M4 | modal focus trap + restore | modal 내부 trap, 닫으면 트리거로 focus 복귀 |
|
||||
| M5 | error 의 programmatic association | error 메시지가 관련 control 과 aria 로 연결 |
|
||||
| M6 | color 단독 금지 | state 가 색 외 신호(icon/text)도 가짐 |
|
||||
| M7 | reduced-motion 존중 | prefers-reduced-motion 시 애니메이션 축소 |
|
||||
|
||||
### 3. Async surface a11y semantics (live-region + focus for §9.1 states)
|
||||
|
||||
> **Trace**: D3 + `FE-OC-011` (async-ui branch 에서 consume) + [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §9.1·§10.3.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: aria-live politeness(polite/assertive) 와 반복 announcement 억제(debounce/dedupe) 메커니즘은 §10.3 이 "적절한 live region, 반복 announcement 억제" 원칙만 규정하고 구현 detail 미규정. Trade-off: 기본 `polite` + message-key dedupe, `terminal-error` 만 `assertive` 제안.
|
||||
|
||||
| §9.1 state | a11y 표현 요구 | 근거 |
|
||||
|---|---|---|
|
||||
| `initial-loading` | 안정적 skeleton, focus theft 금지 | §9.1·§10.3 |
|
||||
| `refreshing` | 기존 콘텐츠 유지 + subtle live region, 반복 announcement 억제 | §9.1·§10.3 |
|
||||
| `stale-degraded` | stale 안내 announce + manual retry 를 keyboard 로 도달 | §9.1·§10.3 |
|
||||
| `terminal-error` | 안전 메시지의 programmatic 연결 + registry action 에 focus | §9.1·§10.3 |
|
||||
| `mutation-pending` | `aria-busy`/disabled 로 중복 action 차단 announce | §9.1 |
|
||||
|
||||
> **R3 위임**: 위 state 가 *존재하는지·언제 전이하는지*는 [[raw/branch-notes/feature-async-ui-state-contract]] (`FE-OC-011`) 소유. 본 절은 그 state 위의 a11y attribute 만 명세(그 branch 가 "a11y hook point 만 노출"이라 위임함).
|
||||
|
||||
### 4. Reduced-motion + color-signal (axe 로 잡히지 않는 신호)
|
||||
|
||||
> **Trace**: D5 + [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §10.3.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: reduced-motion 을 적용할 animation 범위는 §10.3 이 원칙만 규정하고 열거하지 않음. Trade-off: loading skeleton + route transition 에 우선 적용, 컴포넌트별 애니메이션은 각 컴포넌트 owner 에 위임.
|
||||
|
||||
- `prefers-reduced-motion: reduce` 시 skeleton/route transition 애니메이션 축소 또는 제거.
|
||||
- state 는 색 외에 icon/text 신호를 병행(color 단독 금지).
|
||||
|
||||
> **R3 위임**: color contrast token 값(대비비 등)은 [[raw/branch-notes/feature-tailwind-design-token-styling-contract]] (`FE-OC-021` 기여) 소유. 본 절은 "color 를 유일 신호로 쓰지 않는다" 규칙만.
|
||||
|
||||
### 5. Evidence-grade boundary
|
||||
|
||||
> **Trace**: D6 + [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §10.3·§2.1 `FE-OC-001`·`FE-OC-026`.
|
||||
|
||||
- repo 가 axe + manual 을 실행해 artifact 를 낼 때까지 모든 a11y 주장은 `planned`. WCAG 적합(conformance) 문구를 쓰지 않는다.
|
||||
- 파생 산출물/외부 답변에서 목표 수치(axe 0, WCAG AA 등)를 측정 결과처럼 표현하지 않는다(§16 answer boundary). (본 절은 boundary 규칙이므로 별도 impl detail 없음.)
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- dynamic content 변경(route 전환·async state 전이) 시 focus/live-region 이 결정론적으로 발화하지 않으면 screen-reader 사용자가 맥락을 잃음 → §구현 가이드 3 의 live-region + M3 deterministic focus 로 방지.
|
||||
- modal 닫힘 시 focus restore 실패 → 트리거 복귀 검증(M4).
|
||||
- hidden region(inactive menu/modal)은 axe 가 검사하지 않음(axe-core `doc/API.md`) → 렌더/활성화 후 재실행하는 fixture 필요.
|
||||
- 잦은 refetch 시 live-region announcement storm → politeness/dedupe(§구현 가이드 3 UNSUPPORTED_IMPL).
|
||||
- reduced-motion 미존중 → vestibular 부담(M7).
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-async-ui-state-contract]] `FE-OC-011` 에 의존 — async surface state 목록/전이를 consume. 그 state 모델이 바뀌면 본 branch 의 a11y hook 이 재정렬됨.
|
||||
- [[raw/branch-notes/feature-sample-feature-slice-contract-fixture]] `FE-OC-024` 에 의존 — a11y 증거를 측정할 sample route 가 생기기 전엔 검증 불가.
|
||||
- [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] `FE-OC-020` 에 의존 — a11y gate 를 blocking gate 로 배선/artifact 보존하는 orchestration owner.
|
||||
- [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] `FE-OC-019` 에 의존 — untrusted HTML 금지 전제. a11y 는 정제된 콘텐츠만 렌더한다고 가정.
|
||||
- [[raw/branch-notes/feature-tailwind-design-token-styling-contract]] — color contrast token 값 소유(`FE-OC-021` 기여). color-not-sole 규칙만 본 branch.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| sample route 에서 axe critical/serious violation 0 | 구현·gate 미존재 | `pnpm test:a11y` → `artifacts/tests/a11y.json` 결과가 critical/serious 0 | `needs-confirmation` |
|
||||
| 모든 interactive action 이 keyboard 로 도달 | 화면 미구현 | sample route manual keyboard walk-through + signed record(M1) | `needs-confirmation` |
|
||||
| route 변경 후 focus 가 deterministic target 으로 이동 | 라우팅 a11y 미구현 | component/e2e focus 이동 test(M3) | `needs-confirmation` |
|
||||
| async state 전이가 live-region 으로 announce 되되 storm 없음 | live-region 정책 미확정 | component a11y fixture(aria-live assertion + dedupe) `pnpm test:component` | `needs-confirmation` |
|
||||
| modal focus trap + restore 동작 | modal 미구현 | component test(trap 내부 + 닫힘 시 트리거 복귀, M4) | `needs-confirmation` |
|
||||
| prefers-reduced-motion 이 존중됨 | 애니메이션 미구현 | media-query 기반 manual/자동 test(M7) | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
- 스캐폴딩 단계: `/coverage` 실행 전 수동 행을 만들지 않는다.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 없음 — 스캐폴딩 단계.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
| `FE-GATE-006@1` | [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] | component 레벨이 실패하면 merge 를 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-OC-001@1` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | 모든 구현 주장은 evidence grade를 MUST 표시하고 repo evidence가 없는 상태에서 구현 완료를 MUST NOT 주장 | import 참조로 적용 |
|
||||
| `FE-OC-011@1` | [[raw/branch-notes/feature-async-ui-state-contract]] | async surface는 initial-loading, success, empty, terminal-error를 MUST 표현 | import 참조로 적용 |
|
||||
| `FE-OC-020@1` | [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] | gate 종류별 책임·fixture·artifact를 분리하고 실패를 warning으로 낮추면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-021@1` | [[raw/branch-notes/feature-web-vitals-performance-budget-contract]] | NFR은 device/network/cache/build context와 함께 MUST 측정 | import 참조로 적용 |
|
||||
| `FE-OC-026@1` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | 외부 답변은 evidence grade를 MUST 보존하고 목표 수치를 측정 결과처럼 말하면 안 됨 | import 참조로 적용 |
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
- 없음 — 자식 자료는 생성 후 controller가 parent Cluster와 함께 등록한다.
|
||||
|
||||
### 블로그·채용공고 연계 글감
|
||||
|
||||
- follow-up 후보: `raw/official-docs/axe-core-official.md` 아카이브(현재 inline research 로만 인용). 생성 시 D1·D2 Supporting Claim 을 wikilink 로 승격.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- 없음 — daily note는 이 작업에서 수정하지 않는다.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크: 없음
|
||||
- 리뷰 메모: 없음
|
||||
- 머지 결과 / 배포 환경: `planned`
|
||||
- **wiki 추출 대상** (verified만): 없음
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned): 현재 전체
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-frontend-operational-contract/branch-notes/feature-api-client-response-envelope-contract.md
|
||||
@@ -0,0 +1,401 @@
|
||||
---
|
||||
title: branch / feature-api-client-response-envelope-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-api-client-response-envelope-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton-frontend, ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-frontend-operational-contract.md]
|
||||
tags: [branch, ca-skeleton, frontend, api-design, integration, javascript, api-contract]
|
||||
created: 2026-07-18
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-005
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-frontend-operational-contract
|
||||
work_item: WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-005
|
||||
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-TIMEOUT-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-RETRY-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-IDEMPOTENCY-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-004, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-002]
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: e96673571270de028cc143c2c7b806cc8e9e434fbfbaeb7a9f3cac4e86f78505
|
||||
imports: [FE-GATE-004@1, FE-GATE-005@1, FE-GATE-007@1, FE-OC-002@1, FE-OC-007@1, FE-OC-010@1, FE-OC-022@1, FE-OC-023@1, FLOW-FE-RESP-004@1, FLOW-FE-RESP-005@1, FLOW-FE-RESP-006@1, FLOW-FE-RESP-007@1, FLOW-FE-RESP-008@1]
|
||||
delegates: [DELEG-FE-005@1]
|
||||
|
||||
---
|
||||
|
||||
# branch: feature-api-client-response-envelope-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 TODO·결정·진행 기록. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출한다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-frontend-operational-contract]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: API registry와 timeout·abort·retry·idempotency deterministic tests가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-TIMEOUT-001@1` | request total timeout default는 10초이며 별도 browser connect timeout은 주장하지 않는다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-RETRY-001@1` | retry는 initial call 이후 최대 2회, exponential backoff와 full jitter, 2초 cap을 사용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-IDEMPOTENCY-001@1` | mutation auto-retry는 stable idempotency key와 backend replay contract가 있을 때만 허용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
이 브랜치는 project hub 의 두 계약 `FE-OC-006`(모든 HTTP 는 shared client 를 통과하고 timeout·abort·response parsing 을 page 에서 구현하지 않는다)과 `FE-OC-009`(retry 는 safe/idempotent request 에 한정하며 cap·jitter·`Retry-After` 를 적용)을 **구현 착수 가능한 명세**로 내린다. 구체적으로 (1) shared HTTP client boundary 와 request context(hub §7.1~7.2), (2) response envelope 처리 순서(hub §7.3), (3) timeout·abort 분류(hub §7.4), (4) retry 알고리즘·`Retry-After`·retry decision order(hub §7.5·§7.6·§8.3), (5) idempotency 와 mutation replay(hub §7.7·§7.8), (6) `FE-REG-API` operation registry(hub §5.3)를 owner 로서 확정한다. 근거 결정은 `FE-D014`(total timeout 10s), `FE-D015`(retry ≤2 · exponential backoff + full jitter · cap 2s), `FE-D016`(mutation auto-retry 는 idempotency key + backend replay contract 있을 때만). 현재 frontend 구현 repository 가 식별되지 않았으므로(hub §0.3 `NOT_READY`) 본 노트의 모든 구현 항목은 `planned` 이며, 이 브랜치의 완료 측정치는 hub §20 의 "API operation registry + timeout/abort/retry/idempotency deterministic tests" 다.
|
||||
|
||||
- 이슈: (없음 — 구현 repository·이슈 트래커 미생성)
|
||||
- PR: (없음 — scaffolding/spec 단계)
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
Primary contract IDs `FE-OC-006` + `FE-OC-009` 가 소유하는 것만:
|
||||
|
||||
- **Shared HTTP client boundary** — 모든 API request 가 통과하는 application output port 구현 adapter, page/component 의 직접 `fetch`·timeout 복제·envelope 해석·retry loop·auth token 읽기 금지 규칙(hub §7.1).
|
||||
- **Request context** — `operationId`/`method`/`routeId`/`timeoutMs`/`idempotency`/`attempt`/`abortReason`/`authMode` 필드 계약(hub §7.2).
|
||||
- **Response envelope 처리 순서** — transport→content-type→JSON parse→envelope schema→success/failure branch→payload schema→DTO→application model mapper→application result/normalized failure 의 8단계 total order 와 success/failure envelope shape 판별(hub §7.3).
|
||||
- 이 중 **stage 1~3 의 owner 는 본 branch** 다 — hub §2.1.4 Flow Stage Registry `FLOW-FE-RESP-001@1`(transport 완료 대기; timeout·abort 를 이 단계가 소유하고 이후 단계로 예외를 넘기지 않는다) · `FLOW-FE-RESP-002@1`(content-type 기대값 검사; 기대와 다르면 본문을 파싱하지 않고 실패 전환) · `FLOW-FE-RESP-003@1`(JSON parse; parse 실패는 raw body 를 버리고 실패 전환). 이 세 단계의 Invariants 를 바꾸려면 본 branch 가 revision 을 올려야 하고, 인접 단계 branch 의 pin 이 낡아 `STALE_IMPORTED_CONTRACT` 로 잡힌다. stage 4~8 은 남의 소유라 `imports` 로만 pin 한다.
|
||||
- **Timeout 과 abort 분류** — total 10s timeout(`FE-D014`), navigation/user/superseded/external abort 의 분류·retry·telemetry·UX(hub §7.4).
|
||||
- **Retry 정책** — 알고리즘(max 2 · exponential backoff + full jitter · base 250ms · cap 2s, `FE-D015`), retry candidate status 집합, retry decision order, `Retry-After` 처리(hub §7.5·§7.6·§8.3).
|
||||
- **Idempotency 와 mutation replay** — keyed mutation 만 자동 retry(`FE-D016`), key lifecycle(memory-only default), 401 recovery 후 replay policy(hub §7.7·§7.8).
|
||||
- **`FE-REG-API` operation registry** — method/path/operationId/auth/timeoutMs/idempotency/requestSchema/responseSchema/owner 필드 스키마와 registry-first 강제(hub §5.3, registry owner map §5.1).
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외 — 인접 계약이지만 다른 owner branch/외부 시스템이 소유. 여기서 detail 을 정하지 않고 owner 를 가리킨다(CLAUDE.md §15.5 R3).
|
||||
|
||||
- **Envelope/payload runtime schema 정의(Zod)** — `FE-OC-007` owner [[raw/branch-notes/feature-runtime-schema-validation-contract]]. 본 client 는 §7.3 step 4·6 에서 그 schema 를 *호출*만 한다.
|
||||
- **Frontend error kind enum·`FE-REG-ERROR`·normalized failure shape** — `FE-OC-008` owner [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]]. 본 client 는 그 kind 를 *방출*하고 retryability 만 결정한다.
|
||||
- **Auth token lifecycle** — 발급·저장·refresh·rotation·logout·revocation·IdP redirect 는 `FE-OC-010` owner [[raw/branch-notes/feature-frontend-auth-session-integration-contract]] + 외부 Keycloak. 본 client 는 `AuthSessionPort.attach` 호출과 401/403 정규화만.
|
||||
- **Query cache/invalidation 배선** — `FE-OC-012` owner [[raw/branch-notes/feature-server-state-caching-contract]]. 본 client 는 retry policy 를 *callback* 으로 노출할 뿐 TanStack Query client 를 import 하지 않는다.
|
||||
- **Runtime config 로딩·검증** — `REQUEST_TIMEOUT_MS`/`MAX_RETRY_ATTEMPTS`/`API_BASE_URL`/`API_CONTRACT_VERSION` 의 존재·검증은 `FE-OC-004` owner [[raw/branch-notes/feature-frontend-env-runtime-config-contract]]. 본 client 는 검증된 값을 *소비*.
|
||||
- **API/schema breaking change 의 migration·version bump 판정** — `FE-OC-023` owner [[raw/branch-notes/feature-frontend-contract-compatibility-governance]].
|
||||
- **Registry snapshot·orphan token scan 강제** — `FE-OC-022` owner [[raw/branch-notes/feature-frontend-contract-registry-governance]]. 본 branch 는 `FE-REG-API` 스키마만 소유.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 주의: 본 branch 의 primary 결정(`FE-D014`/`FE-D015`/`FE-D016`)은 hub 가 명시적으로 기록한 **project decision / conditional-default** 이며 외부 official-doc 이 근거가 아니다(hub §3.2 Evidence/rationale 열: "project-local initial limit", "retry storm 억제를 위한 project default", "duplicate write 방지 invariant"). 따라서 이들의 SSOT 는 governing hub 자체다. 아래 official-doc 은 envelope 처리 파이프라인이 *위임 호출*하는 schema 계층의 근거로만 매핑된다.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] (§7.4 `FE-D014`) | D4 — total 10s timeout, 별도 connect timeout 미주장 |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] (§7.5·§8.3 `FE-D015`) | D5 — retry ≤2 · exponential backoff + full jitter · cap 2s · retry candidate 집합 |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] (§7.6) | D6 — `Retry-After` 파싱·30s 상한·terminal `RATE_LIMITED` |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] (§7.7·§7.8 `FE-D016`) | D7·D8 — keyed mutation 만 retry, 401 recovery replay policy |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] (§5.3 `FE-D018`) | D9 — `FE-REG-API` registry-first 강제 |
|
||||
| [[raw/official-docs/zod-runtime-schema-validation-official]] (`ZOD-VALID-C3`,`ZOD-VALID-C4`,`ZOD-VALID-C5`) | D3 — envelope/payload 검증을 runtime schema(Zod) 계층에 위임: `.parse()` 검증 관문·`ZodError`·`.safeParse()` discriminated union. 단 schema *정의* 는 `FE-OC-007` sibling 소유 |
|
||||
|
||||
## TODO
|
||||
|
||||
측정 완료 기준(hub §20): "API operation registry + timeout/abort/retry/idempotency deterministic tests". 아래는 모두 `planned`(frontend 코드 부재).
|
||||
|
||||
- [ ] `FE-REG-API` operation registry 모듈 `src/contracts/api-operations.js` 정의(9개 필드 스키마 + `LIST_SAMPLE_RESOURCES`/`CREATE_SAMPLE_RESOURCE` 초기 row) — 등급: `planned`
|
||||
- [ ] shared HTTP adapter(`ResourceQueryPort`/`ResourceCommandPort` 구현) `src/adapters/http/` 작성 — 등급: `planned`
|
||||
- [ ] response envelope 8단계 처리 파이프라인 구현(200 이어도 invalid 면 success 반환 금지) — 등급: `planned`
|
||||
- [ ] `AbortController` 기반 total 10s timeout + abort 5분류(`REQUEST_TIMEOUT`/`REQUEST_ABORTED`/external signal reason 해소 → 미해소 시 `UNKNOWN_FAILURE`) 구현 — 등급: `planned`
|
||||
- [ ] retry scheduler(exponential backoff + full jitter, cap 2s, `ClockPort` + injectable random) 구현 — 등급: `planned`
|
||||
- [ ] `Retry-After` 처리(30s 상한 → terminal `RATE_LIMITED`) 구현 — 등급: `planned`
|
||||
- [ ] idempotency key lifecycle(memory-only) + mutation replay policy 구현 — 등급: `planned`
|
||||
- [ ] `AuthSessionPort.attach` 호출 + 401/403 정규화 + bounded 1회 recovery 배선(port 정의는 sibling) — 등급: `planned`
|
||||
- [ ] deterministic retry unit test(fake clock) + MSW integration taxonomy + negative fixture("POST without idempotency key receives 503") 작성 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 아래 Decision Evidence Map(D1~D9)의 산문 요약. 모든 결정은 governing hub 또는 archived official-doc 근거를 가진다(근거 없는 결정 없음 → `UNSUPPORTED_DECISION` 0건).
|
||||
|
||||
- **D1** shared HTTP client 를 모든 HTTP 의 단일 boundary 로 강제. 이유: page 마다 fetch/timeout/retry 재구현 시 동일 status 가 서로 다른 UX 로 갈라짐(hub §1.3-1). 대안: per-page fetch — route 1개·외부 API 0개 throwaway prototype 에서만(hub §0.4 반대 논거). 근거: hub `FE-OC-006`·§7.1.
|
||||
- **D2** response envelope 처리를 8단계 total order 로 고정하고 200 이어도 JSON/envelope/payload invalid 면 success 로 반환하지 않음. 근거: hub §7.3.
|
||||
- **D3** envelope/payload 검증을 runtime schema(Zod) 계층에 위임(client 는 순서·envelope discriminator gate 소유, schema 정의는 sibling `FE-OC-007`). 근거: [[raw/official-docs/zod-runtime-schema-validation-official]] `ZOD-VALID-C3/C4/C5` + hub `FE-D007`.
|
||||
- **D4** default total request timeout 10s, 별도 connect timeout 미주장(browser fetch 가 portable 하게 제공 안 함). abort 는 hub §7.4 의 5분류를 그대로 소유 — timeout 은 `REQUEST_TIMEOUT`, navigation/user/superseded 는 `REQUEST_ABORTED`(non-retryable), **external signal abort 는 signal reason 을 앞의 4분류 중 하나로 해소해 그 kind 로 귀속**하고 해소 불가 시 `UNKNOWN_FAILURE`; timeout owner 로 해소될 때만 retry 하며 telemetry 는 redacted reason category 만 남긴다. 근거: hub `FE-D014`·§7.4(5 rows)·§8.2.
|
||||
- **D5** retry 는 initial 이후 max 2회, exponential backoff + full jitter, base 250ms, cap 2s; network/timeout/429/502/503/504 만 후보이고 parse/envelope/schema/auth/authz/404/409/422 와 generic 500 은 non-retryable default. 근거: hub `FE-D015`·§7.5·§8.3.
|
||||
- **D6** `Retry-After` 파싱 후 유효 delay >30s 면 자동 retry 하지 않고 terminal `RATE_LIMITED`, ≤30s 면 local backoff 와 비교해 큰 값 사용. 근거: hub §7.6.
|
||||
- **D7** mutation 자동 retry 는 stable idempotency key + active backend replay contract 있을 때만; unkeyed(`none`) mutation 은 recovery 성공 후에도 replay 금지. 근거: hub `FE-D016`·§7.7·§8.5.
|
||||
- **D8** auth 는 consume-only: `AuthSessionPort.attach` 호출 + 401→`AUTH_REQUIRED`/403→`FORBIDDEN` 정규화 + logical request 당 bounded 1회 recovery callback + replay policy; token lifecycle 은 외부 owner. 근거: hub §7.8·`FE-D017`.
|
||||
- **D9** 모든 shared-client request 는 `FE-REG-API` registry row(9필드)를 먼저 가져야 하며 call site raw config 는 violation. 근거: hub §5.3·`FE-D018`.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정과 raw source Claim ID의 연결은 `/branch-spec`에서 작성한다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | Shared HTTP client 를 모든 API 호출의 단일 boundary 로 강제; page/component 는 fetch·timeout·envelope 해석·retry·auth token 읽기 금지 (`FE-OC-006`) | client-only SPA 가 공유 backend 계약을 소비하는 한 이 default 유지 / 대안(per-page fetch)은 route 1개·외부 API 0개·배포 0회 throwaway prototype 일 때만(hub §0.4) | `raw/project-notes/ca-skeleton-frontend-operational-contract.md` `FE-OC-006`·§7.1 | `project-decision` | boundary 강제는 architecture lint(`FE-OC-002`)에 의존 — 그 gate 미구현 시 우회 가능 |
|
||||
| D2 | Response envelope 처리를 8단계 total order 로 고정; 200 이어도 JSON/envelope/payload invalid 면 success 반환 금지 (`FE-OC-006`) | backend 가 structured JSON envelope 를 제공(hub 가정 C)하는 한 유지 / 여러 backend 가 상이한 protocol·schema 이고 통합 adapter 불가면 재설계(가정 C 무효 조건) | `raw/project-notes/ca-skeleton-frontend-operational-contract.md` §7.3 | `project-decision` | backend envelope/OpenAPI source 미확정(`FE-Q-005`) — 실제 shape 이 §7.3 과 다를 수 있음 |
|
||||
| D3 | Envelope/payload 검증을 runtime schema(Zod) 계층에 위임; client 는 처리 순서와 top-level envelope discriminator gate 만 소유 (`FE-OC-006`→`FE-OC-007` 기여) | `FE-D007`(Zod 채택)이 유효한 한 위임 / bundle budget 또는 generated schema pipeline 이 대체안을 요구하면 전환(`FE-D007` revisit) | `raw/official-docs/zod-runtime-schema-validation-official.md#ZOD-VALID-C3`, `#ZOD-VALID-C4`, `#ZOD-VALID-C5`; `...frontend-operational-contract.md` `FE-D007` | `official-vendor-doc + project-decision` | 경계별 `.parse()`(throw) vs `.safeParse()`(non-throw) 선택은 sibling 소유(`ZOD-VALID-C5` "does not prove") — 본 파이프라인은 결과 계약만 소비 |
|
||||
| D4 | Default total request timeout 10s; 별도 connect timeout 미주장(browser fetch 가 portable 하게 분리 제공 안 함); override 는 registry row 의 owner 결정 필요. abort 는 §7.4 5분류 전부 소유 — timeout→`REQUEST_TIMEOUT`, navigation/user/superseded→`REQUEST_ABORTED`(no retry), external signal abort→reason 을 앞 4분류로 해소한 kind 로 귀속(해소 불가 시 `UNKNOWN_FAILURE`), timeout owner 로 해소될 때만 retry, telemetry 는 redacted reason category (`FE-D014`, `FE-OC-006`/`FE-OC-009`) | measured p95 가 10s 를 정당하게 초과하거나 streaming 이 도입되기 전까지 10s 유지 / 그 트리거 발생 시 `FE-D014` 재검토. external abort 는 외부 `AbortSignal` 을 client 에 전달하는 caller 가 존재하는 한 유지 / 그런 caller 가 없으면 dead branch | `...frontend-operational-contract.md` `FE-D014`·§7.4(5 rows)·§8.2 abort/unknown row; `FE-REG-ENV` `REQUEST_TIMEOUT_MS`; `FE-NFR-007` | `conditional-default` | measured latency baseline 없음(`FE-NFR-007` current evidence none) — 10s 는 initial limit. external abort 의 `abortReason` 토큰이 §7.2 enum 에 없음 — §3 `UNSUPPORTED_IMPL_DECISION` 참조 |
|
||||
| D5 | Retry: initial 이후 max 2회, exponential backoff + full jitter(base 250ms, cap 2s); network/timeout/429/502/503/504 만 후보, parse/envelope/schema/auth/authz/404/409/422·generic 500 은 non-retryable default (`FE-D015`, `FE-OC-009`) | retry storm 억제를 위한 project default; backend SLO·rate-limit contract 가 확정되기 전까지 유지 / 확정 시 `FE-D015` 재검토, generic 500 opt-in 은 operation owner 가 safe 증명 시 | `...frontend-operational-contract.md` `FE-D015`·§7.5·§8.3·§8.2; `FE-NFR-008` | `conditional-default` | `FE-RISK-006` — retry 가 backend overload 를 증폭. mitigation: cap/jitter/`Retry-After`+telemetry; load/degradation test 로 해소 |
|
||||
| D6 | `Retry-After` 파싱(delta-seconds 또는 HTTP-date); invalid/negative→local backoff, 유효 >30s→terminal `RATE_LIMITED`(자동 retry 안 함), ≤30s→local backoff 와 max (`FE-OC-009`) | 30s 상한이 project default; backend rate-limit contract 없이는 긴 대기를 자동 소비하지 않음 / contract 확정 시 `FE-D015` 와 함께 재검토 | `...frontend-operational-contract.md` §7.6; §8.2 `429` row | `project-decision` | 30s 임계는 hub 가 준 상수지만 근거 measured 아님 — retry decision order 와 함께 통합 테스트 필요 |
|
||||
| D7 | Mutation 자동 retry 는 stable idempotency key + active backend replay contract 있을 때만; `none`(unkeyed) mutation 은 recovery 성공 후에도 replay 금지, 명시적 user 재시도 요구 (`FE-D016`, `FE-OC-009`/`FE-OC-023`) | mutation 이 schema 로 naturally idempotent 임이 증명되기 전까지 keyed-only 유지 / 증명 시 `FE-D016` 재검토 | `...frontend-operational-contract.md` `FE-D016`·§7.7·§7.8 replay·§8.5 fixture | `accepted-documented-only` | backend `Idempotency-Key`·replay contract 미확정(`FE-Q-005`) — 없으면 mutation retry 는 영구 off |
|
||||
| D8 | Auth 는 consume-only: request 전 `AuthSessionPort.attach` 호출, 401→`AUTH_REQUIRED`/403→`FORBIDDEN` 정규화, logical request 당 bounded 1회 recovery callback, safe≤1·keyed≤1·none=0 replay; token lifecycle 미소유 (`FE-OC-006`→`FE-OC-010` 기여) | auth lifecycle 이 외부 owner 인 한 consume-only(`FE-D017`) / skeleton 이 독립 auth product 로 scope 변경 시 `FE-D017` 재검토 | `...frontend-operational-contract.md` §7.8 state machine·`FE-D017` | `project-decision (delegated boundary)` | auth adapter owner·interface 미정(`FE-Q-006`); `FE-RISK-005`(guard 를 security control 로 오해) — backend authz 가 최종 판단 |
|
||||
| D9 | 모든 shared-client request 는 `FE-REG-API` row(method/path/operationId/auth/timeoutMs/idempotency/requestSchema/responseSchema/owner)를 먼저 가져야 하며 call site raw config 는 violation (`FE-REG-API`, `FE-D018`) | 8-registry governance(`FE-D018`)가 유효한 한 registry-first / code generation SSOT 채택 시 `FE-D018` 재검토 | `...frontend-operational-contract.md` §5.3·§5.1·`FE-D018` | `project-decision` | registry snapshot·orphan token scan 강제는 `FE-OC-022` sibling 소유 — 본 branch 는 스키마만 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 전체 `planned` — frontend 구현 repository 가 아직 없다(hub §0.3 `NOT_READY`). 아래 경로는 hub §4.6 Planned directory blueprint + §5.1 registry owner map 에서 온 grounded anchor 이며, 실제 repository 생성 시 확정된다(`FE-D009` 변경 절차). 클래스·함수·파일명 중 hub 가 규정하지 않은 것은 `UNSUPPORTED_IMPL_DECISION` 으로 표기한다.
|
||||
|
||||
### 1. Shared HTTP client boundary 와 request context
|
||||
|
||||
> **Trace**: D1(`FE-OC-006`·§7.1) + D2(§7.3 진입) . `application` 이 `ResourceQueryPort`/`ResourceCommandPort` 를 소유(hub §4.4)하고 `adapters/http` 가 구현(hub §4.2). 배선은 `bootstrap/composition-root.js` 하나(hub §4.5·`FE-D011`).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: adapter 파일명(`src/adapters/http/http-client.js`)·클래스명(`SharedHttpClient`)·request context 객체 필드 순서는 hub 가 규정하지 않음 — blueprint 디렉토리(`src/adapters/http/`)만 grounded, 파일/식별자 명명은 구현자 임의 trade-off(가독성 우선, `FE-REG-API` operationId 와 1:1 연결 유지).
|
||||
|
||||
| 항목 | 명세 | 근거 |
|
||||
|---|---|---|
|
||||
| 진입점 | `adapters/http` 가 `application` 의 `ResourceQueryPort`·`ResourceCommandPort` 를 구현, presentation 은 facade 만 호출 | hub §4.2·§4.4 |
|
||||
| page 금지 목록 | 직접 `fetch` / `AbortController` timeout 복제 / status→user copy 변환 / raw body log / page-local retry / storage 에서 auth token 읽기 | hub §7.1 |
|
||||
| request context 필드 | `operationId`,`method`,`routeId`,`timeoutMs`,`idempotency`,`attempt`(initial=0),`abortReason?`,`authMode` | hub §7.2 |
|
||||
| 주입 | `composition-root` 가 `ClockPort`·injectable random·`AuthSessionPort`·validated config 를 client 에 주입 | hub §4.5·§7.5 |
|
||||
|
||||
### 2. Response envelope 처리 파이프라인
|
||||
|
||||
> **Trace**: D2(§7.3) + D3(§7.3 step 4·6 → Zod 위임, `ZOD-VALID-C3/C4/C5`). success/failure envelope shape 는 hub §7.3, 위반 시 kind 는 §8.2(error 계층 소유).
|
||||
>
|
||||
> - **해소됨(2026-07-21) — 근거 있는 결정**: envelope discriminator 의 throw/non-throw 는 owner sibling 이 이미 정했다. `FE-OC-007` owner [[raw/branch-notes/feature-runtime-schema-validation-contract]] D3 = "경계에서 `.safeParse()`(non-throwing) default, `.parse()`(throw)는 정규화 catch 내부 한정". 같은 사실이 hub §2.1.4 `FLOW-FE-RESP-004@1` Invariants("경계 검증은 `.safeParse()` non-throwing — throw 를 상위로 누출하지 않는다")로 고정되어 있고, 본 문서는 그 stage 를 `imports` 로 pin 한다. 본 파이프라인의 요구("invalid→normalized failure")는 그 결정과 정합이다.
|
||||
|
||||
처리 순서(총 8단계, 각 실패 지점의 normalized kind 는 §8.2 owner 소유):
|
||||
|
||||
| # | 단계 | 실패 시 kind(§8.2, 위임) |
|
||||
|---|---|---|
|
||||
| 1 | HTTP transport 완료 | `NETWORK_UNREACHABLE`/`REQUEST_TIMEOUT`/`REQUEST_ABORTED` |
|
||||
| 2 | content-type 기대 확인 | `CONTENT_TYPE_MISMATCH` |
|
||||
| 3 | JSON parse | `MALFORMED_JSON` |
|
||||
| 4 | envelope schema 검증(Zod 위임) | `ENVELOPE_MISMATCH` |
|
||||
| 5 | success/failure branch 판별 | HTTP status 기반 §8.2 row |
|
||||
| 6 | payload schema 검증(Zod 위임) | `SCHEMA_MISMATCH` |
|
||||
| 7 | DTO→application model mapper (`FLOW-FE-RESP-007@1`) | mapper 실패 시 catch-all `UNKNOWN_FAILURE` |
|
||||
| 8 | application result 또는 normalized failure 반환 | — |
|
||||
|
||||
- 불변식: `200` 이어도 3~6 중 하나가 invalid 면 success 로 반환하지 않는다. `4xx/5xx` body 가 invalid 면 status 기반 safe fallback error 를 만들고 raw body 는 폐기(hub §7.3).
|
||||
|
||||
### 3. Timeout 과 abort 분류
|
||||
|
||||
> **Trace**: D4(`FE-D014`·§7.4 5 rows). total 10s(`REQUEST_TIMEOUT_MS` 소비, env branch 검증). abort 분류·retry·UX 는 §7.4 표 전체(external signal abort 포함), 미해소 catch-all 은 §8.2 마지막 문단.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `AbortController` 하나로 timeout·navigation·user·superseded abort 를 모두 표현할지, timeout 용 별도 controller 를 둘지는 hub 미규정 — 구현자 trade-off(단일 controller + `abortReason` 태깅 권장). connect/read timeout 분리는 **금지**(browser fetch 가 portable 제공 안 함, §7.4 마지막 문단).
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: hub §7.4 는 external signal abort 를 5번째 행으로 요구하지만 §7.2 `abortReason` 허용값은 `navigation`/`user`/`timeout`/`superseded` 4개뿐이라 이 상황을 표현할 토큰이 없다 — 본 branch 는 `abortReason` 에 `external` 값 1개를 추가하고, 해소된 원인은 별도 필드가 아니라 기존 4값으로 *재분류*해 기록한다(구현자 trade-off: enum 1값 확장이 telemetry·registry 계약 변경 폭이 가장 작다. 대안인 별도 `abortSource` 필드는 §7.2 스키마를 넓히고 §8.1 normalized failure 와 정보가 이중화된다). `external` 추가는 §7.2 스키마 변경이므로 실제 도입 시 hub §3.3 decision change protocol 로 승격한다.
|
||||
|
||||
| 상황 | kind | retry | telemetry | UX |
|
||||
|---|---|---|---|---|
|
||||
| 10s total timeout | `REQUEST_TIMEOUT` | safe/keyed 만 | terminal 시 1 event, elapsed bucket | retry action |
|
||||
| navigation cancel | `REQUEST_ABORTED` | no | debug counter, error event 금지 | stale surface 제거 |
|
||||
| user cancel | `REQUEST_ABORTED` | no | optional interaction event | neutral canceled |
|
||||
| superseded query | `REQUEST_ABORTED` | no | none | latest 유지 |
|
||||
| external signal abort (caller 가 넘긴 외부 `AbortSignal`) | reason 해소 결과에 귀속 — timeout owner 면 `REQUEST_TIMEOUT`, navigation/user/superseded 로 해소되면 `REQUEST_ABORTED`, 해소 불가면 `UNKNOWN_FAILURE` | no — 단 timeout owner 로 해소된 경우에만 timeout 정책(safe/keyed max 2) 적용 | redacted reason category 만(raw signal `reason` 값·message·stack 금지), 해소된 kind 의 telemetry rule 을 그대로 상속 | context-specific — 해소된 kind 의 UX 를 상속(timeout→`retry`, abort→`none`, 미해소→generic reference) |
|
||||
|
||||
### 4. Retry 알고리즘 · decision order · `Retry-After`
|
||||
|
||||
> **Trace**: D5(`FE-D015`·§7.5·§8.3) + D6(§7.6). `ClockPort` + injectable random source 로 결정론 테스트 가능(§7.5 normative).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: retry scheduler 파일/클래스명(`src/adapters/http/retry-policy.js`, `RetryScheduler`)은 hub 미규정 — blueprint 디렉토리만 grounded, 명명은 구현자 trade-off. `MAX_RETRY_ATTEMPTS` 는 env registry(§5.4 default `2`)에서 소비하되 상수 fallback 은 `FE-D015` 값.
|
||||
|
||||
```text
|
||||
maxRetries = 2 # initial 제외, hub §7.5 / FE-D015
|
||||
baseDelayMs = 250 # hub §7.5
|
||||
maxDelayMs = 2000 # cap, hub §7.5 / FE-D015
|
||||
delay(i) = min(maxDelayMs, baseDelayMs * 2^retryIndex) * random(0,1) # full jitter
|
||||
```
|
||||
|
||||
Retry decision order(hub §8.3, 위→아래 우선):
|
||||
|
||||
```text
|
||||
if aborted (navigation/user/superseded) -> no retry
|
||||
else if parse/envelope/schema/auth/authz/404/409/422 -> no retry
|
||||
else if method is safe -> apply status/network policy
|
||||
else if idempotency == keyed AND backend replay active -> apply status/network policy
|
||||
else -> no retry
|
||||
```
|
||||
|
||||
- external signal abort 는 위 순서의 **첫 줄 이전에 reason 해소 단계**가 선행한다: 해소 결과가 navigation/user/superseded 면 1번째 줄에 걸려 no retry, timeout 이면 3~4번째 줄의 status/network policy 로 내려가고, 해소 불가면 `UNKNOWN_FAILURE`(non-retryable)로 종결한다. 해소 단계 자체는 hub §7.4 row 5("reason에 따라" / "no unless timeout owner")에서 도출되며 §8.3 의 문장 순서를 바꾸지 않는다(§3 표 참조).
|
||||
- **UNSUPPORTED_IMPL_DECISION**: 위 순서 4번째 줄의 조건 "backend replay contract active" 를 표현하는 필드가 `FE-REG-API` 9필드에 없다 — hub §7.7 은 "backend contract 가 `Idempotency-Key` 를 지원한다고 registry 에 명시" 를 요구하지만 §5.3 스키마에서 이를 담을 수 있는 후보는 `idempotency` 하나뿐이다. 본 branch 는 `idempotency=keyed` 를 *backend key 지원 선언* 으로 읽고, §7.8 이 별도로 요구하는 *active replay contract* 는 `FE-Q-005` 해소 전까지 keyed 와 동일시한다(구현자 trade-off: 미검증 backend 정보로 registry 스키마를 늘리지 않는 대신, key 는 수용하지만 replay 결과를 반환하지 않는 backend 를 과신할 위험을 진다 — mutation auto-retry 자체가 `FE-Q-005` 해소 전까지 off 이므로 safe-path 작업은 막히지 않는다). backend 가 key 수용과 replay 반환을 구분하는 것으로 확인되면 10번째 필드(예: `replayContract`)를 hub §5.10 registry change protocol 로 추가한 뒤 이 분기를 두 조건으로 분리한다.
|
||||
- retry candidate status: network failure·timeout·`429`·`502`·`503`·`504`(safe/keyed 만). generic `500` 은 default off, operation owner 가 safe 증명 시 opt-in(hub §7.5).
|
||||
- backend `error.retryable=true` 는 hint 일 뿐 unsafe mutation 자동 retry 의 충분조건 아님(hub §8.3).
|
||||
- `Retry-After`: parse → invalid/negative 면 local backoff → 유효 >30s 면 automatic retry 안 하고 terminal `RATE_LIMITED` → ≤30s 면 local backoff 와 max → abort 시 wait 취소. raw value 는 telemetry 금지, normalized delay bucket 만(hub §7.6).
|
||||
- unmount/superseded 시 남은 timer 와 request 취소(hub §7.5 마지막 bullet).
|
||||
|
||||
### 5. Idempotency 와 401 recovery replay
|
||||
|
||||
> **Trace**: D7(`FE-D016`·§7.7) + D8(§7.8). key lifecycle 은 auth token lifecycle 과 분리, memory-only default(§7.7).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: idempotency key 생성 방식(UUID v4 vs client-side hash)·single-flight dedup key 도출은 hub 미규정 — 구현자 trade-off(logical action 당 1 key·retry 간 재사용·telemetry/URL/message 노출 금지 제약만 grounded, §7.7). key persistence 가 필요해지면 본 branch 가 아니라 storage registry(`FE-REG-STORAGE`)에 TTL/classification/migration 추가 후.
|
||||
|
||||
401 recovery state machine(hub §7.8, client 소비 부분만):
|
||||
|
||||
| 현재 상태 | 이벤트 | 다음 상태 | client 동작 |
|
||||
|---|---|---|---|
|
||||
| `authenticated` | first `401` | `recovery-pending` | 외부 owner bounded recovery callback 1회 |
|
||||
| `recovery-pending` | session restored | `authenticated` | replay policy 적용 |
|
||||
| `recovery-pending` | no session | `unauthenticated` | terminal `AUTH_REQUIRED` |
|
||||
| `recovery-pending` | adapter throw/reject/invalid | `integration-failed` | terminal `AUTH_INTEGRATION_FAILURE` |
|
||||
| any | same request 2nd `401` | `unauthenticated` | 추가 recovery 없이 terminal `AUTH_REQUIRED` |
|
||||
|
||||
Replay policy(recovery 성공 후): `safe`=최대 1회 replay / `keyed`=같은 key + active replay contract 시 최대 1회 / `none`=replay 금지, 명시적 user 재시도 요구(hub §7.8·§8.5 fixture).
|
||||
|
||||
### 6. `FE-REG-API` operation registry
|
||||
|
||||
> **Trace**: D9(§5.3·`FE-D018`). registry owner map §5.1 이 본 branch 를 `FE-REG-API` single owner 로 지정. planned path `src/contracts/api-operations.js`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: registry 를 plain object map 으로 둘지 factory 함수로 둘지, operationId→row lookup API 모양은 hub 미규정 — 구현자 trade-off(9필드 스키마·`UPPER_SNAKE_CASE` operationId·call-site raw config 금지 제약만 grounded).
|
||||
|
||||
| Field | Required | Rule(hub §5.3) |
|
||||
|---|---|---|
|
||||
| `method` | yes | uppercase HTTP method |
|
||||
| `path` | yes | path template, query value·host 미포함 |
|
||||
| `operationId` | yes | stable `UPPER_SNAKE_CASE`, telemetry·test·owner key |
|
||||
| `auth` | yes | `none` 또는 `external-session` |
|
||||
| `timeoutMs` | yes | default `10000`, override 는 decision change |
|
||||
| `idempotency` | yes | `safe`/`keyed`/`none` |
|
||||
| `requestSchema` | yes | body 없으면 explicit `none`, params/search 도 검증 |
|
||||
| `responseSchema` | yes | success envelope payload schema reference |
|
||||
| `owner` | yes | owning feature/branch slug |
|
||||
|
||||
초기 planned row(hub §5.3): `LIST_SAMPLE_RESOURCES`(GET `/api/sample/resources`, safe), `CREATE_SAMPLE_RESOURCE`(POST `/api/sample/resources`, keyed) — owner 는 [[raw/branch-notes/feature-sample-feature-slice-contract-fixture]](`FE-OC-024`; 본 branch 는 스키마 소유, sample row 는 fixture branch 가 채움).
|
||||
|
||||
- `idempotency=keyed` 는 위 9필드 안에서 hub §7.7 의 "backend 가 `Idempotency-Key` 를 지원한다고 registry 에 명시" 요구를 담는 유일한 필드이며, §7.8 의 *active backend replay contract* 조건도 `FE-Q-005` 해소 전까지 여기에 겹쳐 읽는다 — 필드 분리 조건과 trade-off 는 §4 의 `UNSUPPORTED_IMPL_DECISION` 참조.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로** (hub §8.2 matrix 중 본 client 가 방출/분기하는 것):
|
||||
- `NETWORK_UNREACHABLE`(DNS/offline/CORS-like): safe/keyed max 2 retry, 가능 시 cached safe data, raw URL telemetry 금지.
|
||||
- `REQUEST_TIMEOUT`(10s total): safe/keyed max 2, stale data 유지 가능, elapsed bucket.
|
||||
- `REQUEST_ABORTED`(navigation/user/superseded): no retry, error toast/event 금지, latest 유지.
|
||||
- external signal abort(caller 가 넘긴 외부 `AbortSignal`): reason 을 해소해 `REQUEST_TIMEOUT`(timeout owner) 또는 `REQUEST_ABORTED`(navigation/user/superseded)로 귀속, 해소 불가 시 catch-all `UNKNOWN_FAILURE`. timeout 으로 해소된 경우에만 safe/keyed retry, 그 외 no retry. telemetry 는 redacted reason category 만(raw `reason` 값 금지), UX 는 해소된 kind 를 상속(hub §7.4 row 5·§8.2).
|
||||
- `RATE_LIMITED`(`429`): `Retry-After` bounded, >30s 면 terminal, delay bucket.
|
||||
- `SERVER_FAILURE`(`502/503/504` safe/keyed max 2; `500` default off; 기타 5xx default off): stale safe data fallback.
|
||||
- Non-retryable: `MALFORMED_JSON`·`ENVELOPE_MISMATCH`·`SCHEMA_MISMATCH`·`AUTH_REQUIRED`·`FORBIDDEN`·`NOT_FOUND`·`CONFLICT`·`VALIDATION_REJECTED` — retry 하지 않고 normalized failure 반환.
|
||||
- **Total-function normalization**: response/adapter/browser exception 이 named branch 와 안 맞거나 mapper 자체가 실패하면 최종 catch-all 이 raw value 폐기 후 `UNKNOWN_FAILURE` 반환 — normalized failure 를 못 만든 채 throw 를 presentation 으로 통과시키는 경로 금지(hub §8.2 마지막 문단). enum·shape 는 `FE-OC-008` 소유이나 "leak 금지" 불변식은 본 client 책임.
|
||||
- 동시성: retry 중 component unmount / query superseded 시 남은 timer·request 취소(hub §7.5).
|
||||
- **다른 계약 의존** (대상 branch + consume 하는 contract; hub §20 Dependency·§4.3 dependency matrix):
|
||||
- [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] (`FE-OC-004`, 본 노트 D4·D5) — 검증된 `REQUEST_TIMEOUT_MS`·`MAX_RETRY_ATTEMPTS`·`API_BASE_URL`·`API_CONTRACT_VERSION` 소비. 그 config 검증 계약이 바뀌면 client boot 입력 변경.
|
||||
- [[raw/branch-notes/feature-frontend-clean-architecture-layering-contract]] (`FE-OC-002`, 본 노트 D1·D5) — `ResourceQueryPort`/`ResourceCommandPort` + `ClockPort` + injectable random source 의 application-owned port 정의와 composition-root 주입(port ownership 결정은 hub 소유, §4.4 port matrix·§4.5 boot order). `ClockPort` 와 random source 는 본 branch 의 완료 측정치인 deterministic retry test 의 전제이며, injectable random 은 §4.4 port matrix 에 행이 없어 주입 형태(별도 port vs adapter 생성자 인자)는 layering branch 가 확정한다. port shape 변경 시 adapter 시그니처 영향.
|
||||
- [[raw/branch-notes/feature-runtime-schema-validation-contract]] (`FE-OC-007`, 본 노트 D3) — envelope/payload Zod schema; §7.3 step 4·6 이 호출. schema 계약 변경 시 파이프라인 검증 지점 영향.
|
||||
- [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] (`FE-OC-008`, 본 노트 D2·§8.2) — `FE-REG-ERROR` kind enum·normalized failure shape; client 가 emit·retryability 결정.
|
||||
- [[raw/branch-notes/feature-frontend-auth-session-integration-contract]] (`FE-OC-010`, 본 노트 D8) — `AuthSessionPort.attach`·bounded recovery(auth lifecycle 은 외부 owner). port/lifecycle 변경 시 §7.8 소비 영향.
|
||||
- [[raw/branch-notes/feature-server-state-caching-contract]] (`FE-OC-012`, 본 노트 D5) — `QueryCachePort`/TanStack adapter 가 client retry policy 를 callback 으로 소비. page-local retry 숫자 금지.
|
||||
- [[raw/branch-notes/feature-frontend-contract-compatibility-governance]] (`FE-OC-023`, 본 노트 D7) — `apiContractVersion`·API schema breaking change migration/version bump 판정.
|
||||
- [[raw/branch-notes/feature-frontend-operational-runbook-contract]] (`FE-OC-025`, `FE-RB-003`) — backend API degradation 시 technical escalation 이 본 branch → backend operation owner 경로(hub §16.3).
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| retry 는 initial 이후 정확히 ≤2회, backoff sequence 가 deterministic | 코드·fake clock 없음 | deterministic retry unit test(`ClockPort`+injectable random) — `FE-GATE-005`, `FE-NFR-008` | `needs-confirmation` |
|
||||
| POST(`idempotency=none`) 가 `503` 을 받아도 자동 retry 하지 않음 | 정책은 문서, 코드 미검증 | negative fixture "POST without idempotency key receives 503"(hub §15.2) + MSW integration — `FE-GATE-007` | `needs-confirmation` |
|
||||
| `200` + malformed JSON/invalid envelope 가 success 로 새지 않고 normalized failure 반환 | envelope 파이프라인 미구현 | runtime-schema/integration fixture(success envelope without `data`) — `FE-GATE-004`/`007` | `needs-confirmation` |
|
||||
| total timeout 이 10s 에 발화하고 `REQUEST_TIMEOUT` 으로 분류 | `AbortController` timeout 배선 미구현 | fake-clock unit + MSW delay integration — `FE-NFR-007` | `needs-confirmation` |
|
||||
| `Retry-After` >30s → 자동 retry 없이 terminal `RATE_LIMITED` | 30s 상한 로직 미구현 | integration fixture(`429` + `Retry-After: 60`) | `needs-confirmation` |
|
||||
| navigation/superseded abort 가 in-flight timer·request 취소 + error event 미방출 | 취소 경로 미구현 | component/integration abort fixture | `needs-confirmation` |
|
||||
| 외부 `AbortSignal` 로 끊긴 request 가 reason 해소 결과의 kind 로 귀속되고(미해소 시 `UNKNOWN_FAILURE`) timeout 으로 해소된 경우에만 retry, telemetry 에 raw reason 미노출 | reason 해소 로직 미구현 + `abortReason` 에 `external` 토큰 부재(§3 `UNSUPPORTED_IMPL_DECISION`) | external signal abort fixture 3종(timeout owner / navigation reason / 미해소 임의 reason) + telemetry redaction assertion — `FE-GATE-007` | `needs-confirmation` |
|
||||
| first `401` 이 bounded 1회 recovery callback, second `401` 은 terminal `AUTH_REQUIRED` | auth adapter·state machine 미구현 | MSW auth-recovery taxonomy integration — `FE-GATE-007` | `needs-confirmation` |
|
||||
| unkeyed mutation 은 recovery 성공 후에도 replay 안 함 | replay policy 미구현 | integration fixture(hub §8.5 "recovery succeeds for unkeyed mutation") | `needs-confirmation` |
|
||||
| normalization 이 total — 미매핑 exception 이 `UNKNOWN_FAILURE` 로 귀결, throw 가 presentation 으로 새지 않음 | catch-all 경로 미구현 | integration fixture(thrown non-`Error`/mapper exception) | `needs-confirmation` |
|
||||
| backend 가 `Idempotency-Key` + replay contract 를 실제 제공 | backend envelope/OpenAPI source 미확정(`FE-Q-005`) | backend owner 확인 + captured fixture 대조 | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
> `/coverage`가 채우는 생성물이며 손으로 유지하지 않는다.
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| TODO — `/coverage` 실행 전 | missing | (없음) | 미평가 | TODO |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: flow:start -->
|
||||
### 가져온 흐름 단계
|
||||
|
||||
| Stage Ref | Order | Owner | Input | Action | Output |
|
||||
|---|---:|---|---|---|---|
|
||||
| `FLOW-FE-RESP-004@1` | 4 | [[raw/branch-notes/feature-runtime-schema-validation-contract]] | unvalidated JSON | envelope 공유 스키마 검증 | discriminated envelope |
|
||||
| `FLOW-FE-RESP-005@1` | 5 | [[raw/branch-notes/feature-runtime-schema-validation-contract]] | discriminated envelope | success/failure 분기 검증 | 분기 확정 envelope |
|
||||
| `FLOW-FE-RESP-006@1` | 6 | [[raw/branch-notes/feature-runtime-schema-validation-contract]] | 분기 확정 envelope | payload per-operation 스키마 검증 | 검증된 payload(deep clone) |
|
||||
| `FLOW-FE-RESP-007@1` | 7 | [[raw/branch-notes/feature-boundary-mapper-viewmodel-contract]] | 검증된 payload | DTO → application model 매핑 | application model |
|
||||
| `FLOW-FE-RESP-008@1` | 8 | [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] | application model 또는 실패 신호 | 정규화된 결과 반환 | application result 또는 normalized failure |
|
||||
<!-- GENERATED: flow:end -->
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
| `FE-GATE-004@1` | [[raw/branch-notes/feature-runtime-schema-validation-contract]] | invalid fixture 가 예상 kind 로 거부되지 않으면 merge 를 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-GATE-005@1` | [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] | unit 레벨이 실패하면 merge 를 MUST 차단하고 warning 으로 낮추면 안 됨 | import 참조로 적용 |
|
||||
| `FE-GATE-007@1` | [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] | MSW 기반 integration 매트릭스가 미충족이면 merge 를 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-OC-002@1` | [[raw/branch-notes/feature-frontend-clean-architecture-layering-contract]] | `domain <- application <- presentation` 의존 방향과 application-owned output port를 MUST 지킴 | import 참조로 적용 |
|
||||
| `FE-OC-007@1` | [[raw/branch-notes/feature-runtime-schema-validation-contract]] | JSON envelope와 payload는 boundary에서 runtime schema를 MUST 통과 | import 참조로 적용 |
|
||||
| `FE-OC-010@1` | [[raw/branch-notes/feature-frontend-auth-session-integration-contract]] | skeleton은 session state를 소비하되 token lifecycle을 MUST 소유하지 않음 | import 참조로 적용 |
|
||||
| `FE-OC-022@1` | [[raw/branch-notes/feature-frontend-contract-registry-governance]] | 8개 registry는 single primary owner와 compatibility impact를 MUST 기록 | import 참조로 적용 |
|
||||
| `FE-OC-023@1` | [[raw/branch-notes/feature-frontend-contract-compatibility-governance]] | API/config/storage/release schema의 breaking change는 migration 또는 version bump 없이 배포하면 안 됨 | import 참조로 적용 |
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
### Sub-branches (세부 작업)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 오류 기록 (이 branch 작업 중 발생)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 강의 (이 작업을 위해 학습한 강의)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### job-posting tie-ins (이 작업에서 파생된 글감)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-api-compatibility-deprecation-contract.md
|
||||
@@ -0,0 +1,261 @@
|
||||
---
|
||||
title: branch / feature-api-compatibility-deprecation-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-api-compatibility-deprecation-contract
|
||||
related_projects: [ca-skeleton]
|
||||
tags: [branch, ca-skeleton, api-compatibility, deprecation]
|
||||
created: 2026-05-22
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-026
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-026
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-API-VERSIONING-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
parent_branch:
|
||||
contract_packet_sha256: b0999dd64e9b6815a5e42c1c75e34bdccd2eb746e6572aedb12a4ac6b1d21fd8
|
||||
---
|
||||
|
||||
# branch: feature-api-compatibility-deprecation-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — API compatibility와 deprecation 기준을 정의합니다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (§<관련 섹션>) 의 결정/근거/금지 사항을 정제한다.
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/api-versioning-github-rest-date-header]]
|
||||
- [[raw/company-tech-blogs/api-versioning-stripe-date-based]]
|
||||
- [[raw/official-docs/api-versioning-google-aip-180]]
|
||||
- [[raw/official-docs/ci-openapi-snapshot-diff-tooling]]
|
||||
- [[raw/official-docs/compat-rfc-8594-sunset-header]]
|
||||
- [[raw/official-docs/google-aip-185-resource-versioning]]
|
||||
- [[raw/official-docs/openapi-spec-3-1-0]]
|
||||
- [[raw/official-docs/protobuf-reserved-vs-json-openapi-extension]]
|
||||
- [[raw/official-docs/schema-protobuf-vs-json-evolution]]
|
||||
- [[raw/official-docs/sunset-deprecation-headers-paired-usage]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/api-deprecation-sunset-header-migration-window-2026-07-02]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
> 본 feature branch 는 leaf — 자식 자료 없음. Phase C2 실 코드 작성 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록 (본 feature 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase C2 실 구현 단계에 누적)
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: /v1 compatibility·deprecation contract test가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-API-VERSIONING-001@1` | URI prefix /v1이 default이며 X-Api-Version은 compatibility 실험용 보조 header다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
API versioning만으로는 장기 유지보수가 부족합니다. breaking change, response field removal, deprecated field, migration window 기준을 skeleton에 포함해야 합니다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- breaking change 정의.
|
||||
- response field removal 금지 기준.
|
||||
- deprecated field 정책.
|
||||
- migration window 기준.
|
||||
- backward compatibility test 기준.
|
||||
- OpenAPI diff 기준.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- public API product lifecycle.
|
||||
- external developer portal.
|
||||
- multi-version runtime router 구현.
|
||||
|
||||
## TODO
|
||||
|
||||
> TODO drained 2026-05-22 — 결정은 아래 "결정 사항" / "Breaking Change Catalog" / "Decisionized Work Items" 참조. breaking change 정의/response field removal/deprecation marker/migration window/backward compat/OpenAPI diff 모두 catalog 또는 표 row로 반영됨. 잔존 TODO 없음.
|
||||
|
||||
## Work Item Contract
|
||||
|
||||
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
|
||||
|
||||
| field | required | rule |
|
||||
| --- | --- | --- |
|
||||
| Decision | yes | 구현자가 선택해야 하는 기본값 |
|
||||
| Allowed | yes | 허용되는 예외와 조건 |
|
||||
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
|
||||
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
|
||||
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
|
||||
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
|
||||
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- 이 branch는 API contract baseline과 schema serialization contract를 보완합니다.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- 2026-05-22: compatibility/deprecation은 API versioning과 별도 기준으로 관리.
|
||||
- 2026-05-22: breaking change catalog는 이 branch가 소유하고 OpenAPI diff 집행은 `feature-contract-verification-test-suite`가 수행.
|
||||
- 2026-05-22: migration window 기본값은 90일. internal-only API는 30일로 줄일 수 있으나 branch note에 근거와 소비자 목록이 필요.
|
||||
- 2026-05-22: published response field removal은 deprecated marker + migration window + compatibility fixture 없이는 금지.
|
||||
- 2026-05-22: API deprecation 응답은 `Sunset: <date>` + `Deprecation: <date>` 헤더 **함께** 전송. 단독 Sunset 금지. 추가로 `Link: <url>; rel="sunset"` 권장.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/compat-rfc-8594-sunset-header]] | IETF RFC 8594 Sunset header 표준 (ca-tmpl 채택 근거 |
|
||||
| [[raw/company-tech-blogs/api-versioning-stripe-date-based]] | account pin + freeze; 외부 컨슈머 규모 큰 경우 우위 |
|
||||
| [[raw/company-tech-blogs/api-versioning-github-rest-date-header]] | long EOL window + explicit 410 응답 |
|
||||
| [[raw/official-docs/api-versioning-google-aip-180]] | enum value 제거도 금지; ca-tmpl `narrow enum = breaking` 결정과 부분 정합 |
|
||||
| [[raw/official-docs/sunset-deprecation-headers-paired-usage]] | 참조 |
|
||||
| [[raw/official-docs/openapi-spec-3-1-0]] | OpenAPI Specification v3.1 (JSON Schema 2020-12 alignment) — OAS 의 normative scope/structure 근거. ⚠️ Operation Object 의 `deprecated: boolean` 필드 자체는 OPENAPI31-C1~C7 발췌에 포함되지 않음 (raw 자체 Usage Boundary 명시) — D8 deprecation marker 의 OpenAPI spec normative 인용은 별도 raw 발췌 필요 |
|
||||
|
||||
## 외부 근거 / 대안 조사 (2026-05-22 — Group G-F: API Compatibility / Deprecation)
|
||||
|
||||
본 branch의 90d public + 30d internal migration window + breaking change catalog 7행 + Sunset header + OpenAPI `deprecated:true` 결정에 대한 외부 source.
|
||||
|
||||
- **채택 결정 (window + Sunset header + OpenAPI deprecation)**:
|
||||
- [[raw/official-docs/compat-rfc-8594-sunset-header]] — IETF RFC 8594 Sunset header 표준 (ca-tmpl 채택 근거)
|
||||
- **검토한 대안**:
|
||||
- **대안 1: Stripe date-based versioning (no removal, freeze forever)** — [[raw/company-tech-blogs/api-versioning-stripe-date-based]] (account pin + freeze; 외부 컨슈머 규모 큰 경우 우위)
|
||||
- **대안 2: GitHub X-GitHub-Api-Version header + 24mo EOL + 410 Gone** — [[raw/company-tech-blogs/api-versioning-github-rest-date-header]] (long EOL window + explicit 410 응답)
|
||||
- **대안 3: Google AIP-180 backward compat 분류** — [[raw/official-docs/api-versioning-google-aip-180]] (enum value 제거도 금지; ca-tmpl `narrow enum = breaking` 결정과 부분 정합)
|
||||
- **비교 핵심**: Stripe(freeze forever) vs ca-tmpl(90d/30d window) vs GitHub(24mo EOL + 410): 외부 컨슈머 규모와 운영 비용 trade-off. ca-tmpl internal-first면 90d 합리. **보강 후보 2가지**: (a) EOL 응답 코드(410 Gone)가 ca-tmpl catalog에 누락 — GitHub 사례 차용 검토, (b) Sunset(RFC 8594) + Deprecation 헤더는 **함께** 보내야 정합 — ca-tmpl 결정은 marker만 명시.
|
||||
|
||||
**후속 보강 (2026-05-22)**: Sunset 헤더는 Deprecation 헤더와 paired로 보내야 함. [[raw/official-docs/sunset-deprecation-headers-paired-usage]] 참조.
|
||||
|
||||
## Breaking Change Catalog
|
||||
|
||||
| change | classification | default action |
|
||||
| --- | --- | --- |
|
||||
| remove response field | breaking | deprecate first, remove after migration window |
|
||||
| rename response field | breaking | add new field, keep old deprecated field through window |
|
||||
| change field type/format | breaking | new version or additive field |
|
||||
| narrow enum values | breaking | new version |
|
||||
| add required request field | breaking | new version or default server-side |
|
||||
| add optional response field | additive | allowed with schema update |
|
||||
| change error code/category | breaking for clients | foundation registry change + migration note |
|
||||
|
||||
## Decisionized Work Items
|
||||
|
||||
| item | Decision | Allowed | Forbidden | Required test | Failure condition |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| migration window | 90 days public/default, 30 days internal-only | shorter only with owner approval | immediate field removal | compatibility fixture | deprecated field removed early |
|
||||
| deprecation marker | OpenAPI `deprecated: true` + branch note | response header optional | undocumented deprecation | OpenAPI diff | deprecated field lacks marker |
|
||||
| breaking diff | verification suite release-blocking | warning-only only for additive diff | breaking diff warning-only | openapi-diff gate | breaking diff passes CI |
|
||||
|
||||
## 테스트 계약
|
||||
|
||||
- published response field가 사전 deprecation 없이 제거되면 실패.
|
||||
- OpenAPI diff에서 breaking change가 감지되면 실패.
|
||||
- deprecated field가 migration window 없이 제거되면 실패.
|
||||
- backward compatibility fixture가 깨지면 실패.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. company-tech-blog 출처는 `company-case-study` 로 라벨링하며 공식 best practice 로 격상하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|
|
||||
| D1 | compatibility / deprecation 은 API versioning 과 별도 기준으로 관리 (2026-05-22) | UNSUPPORTED_DECISION (project-internal scoping decision; 외부 표준이 두 영역의 분리를 normative 로 강제하지 않음) | N/A | scoping 결정의 정합성은 sibling branch (`feature-api-contract-baseline`) 와 cross-review 필요 |
|
||||
| D2 | breaking change catalog 7행 분류 — `remove response field`, `rename`, `change type/format`, `narrow enum values`, `add required request field`, `add optional response field`, `change error code` | `raw/official-docs/api-versioning-google-aip-180.md#AIP180-C1` (component 제거 금지), `#AIP180-C2` (rename = remove+add), `#AIP180-C3` (default behavior preservation 으로 additive 분류), `#AIP180-C4` (required field 추가 금지), `#AIP180-C5` (minor/patch client breaking 금지), `raw/company-tech-blogs/api-versioning-github-rest-date-header.md#GH-APIV-C4` (GitHub 의 동일 7행 breaking 분류 사례) | `official-vendor-doc + company-case-study` | AIP-180 은 Google internal API design guideline — IETF/W3C 표준 아님 (외부 인용 시 "Google AIP" 명시 필수). GitHub 사례는 company-case-study — 7행 분류가 모든 API 의 표준이라는 일반화 금지 |
|
||||
| D3 | OpenAPI diff release-blocking 집행은 `feature-contract-verification-test-suite` 가 수행 (이 branch 는 catalog 소유, 집행 위임) | UNSUPPORTED_DECISION (project-internal SSOT 분할 결정; 외부 표준 근거 없음) | N/A | catalog owner 와 enforcement owner 분리 시 drift 위험 — verification suite 의 입력 catalog 정합성 추적 필요 |
|
||||
| D4 | migration window 기본값 90일 (public) / 30일 (internal-only) | UNSUPPORTED_DECISION (cited `AIP180-C1` 은 same major version 안에서 "must not be removed" — ca-tmpl 의 window 후 제거 정책과 다름. cited `GH-APIV-C7` 의 24개월 EOL 도 90/30일과 직접 일치하지 않음. cited `STRIPE-APIV-C4` 는 "as long as possible" 철학으로 window 자체를 권고하지 않음) | N/A | window 길이의 정당성은 internal-first skeleton 의 운영 부담 trade-off — 외부 표준 인용 불가. canonical 승급 시 design rationale 별도 문서화 필요 |
|
||||
| D5 | published response field removal 은 deprecated marker + migration window + compatibility fixture 없이 금지 | `raw/official-docs/api-versioning-google-aip-180.md#AIP180-C1` (component 제거 금지), `#AIP180-C2` (rename = remove+add), `#AIP180-C5` (minor/patch client breaking 금지), `raw/company-tech-blogs/api-versioning-github-rest-date-header.md#GH-APIV-C4` (response field 제거가 breaking) | `official-vendor-doc + company-case-study` | AIP-180 은 same major version 안에서 사실상 영구 금지 — ca-tmpl 의 "migration window 후 제거 허용" 정책은 AIP 보다 약함 (외부 인용 시 정합성 caveat 필요) |
|
||||
| D6 | API deprecation 응답은 `Sunset: <date>` + `Deprecation: <date>` 헤더 **함께** 전송; 단독 Sunset 금지; 추가로 `Link: <url>; rel="sunset"` 권장 | `raw/official-docs/sunset-deprecation-headers-paired-usage.md#SD-PAIR-C1` (Sunset = decommissioning 시점), `#SD-PAIR-C3` (Deprecation = 상태 신호), `#SD-PAIR-C5` (Sunset MUST NOT be earlier than Deprecation), `#SD-PAIR-C6` (sunset / deprecation link relation 용도), `raw/official-docs/compat-rfc-8594-sunset-header.md#RFC8594-C1` (Sunset 정의), `#RFC8594-C4` (sunset link relation IANA 등록) | `official-standard` | IETF httpapi WG 의 권고 — client tooling 의 실제 paired 감지 여부는 vendor 별 (예: Spring HATEOAS, Apigee). 단독 송신을 안 하면 client 가 deprecation 감지 못 한다는 절대 사실은 spec 에 없음 (해석) |
|
||||
| D7 | breaking diff CI gate 가 release-blocking; additive diff 만 warning-only 허용 | `raw/official-docs/api-versioning-google-aip-180.md#AIP180-C3` (additive 의 default behavior 보존 시 호환), `#AIP180-C5` (minor/patch breaking 금지), `raw/company-tech-blogs/api-versioning-github-rest-date-header.md#GH-APIV-C5` (breaking 은 새 버전 release + 사전 공지) | `official-vendor-doc + company-case-study` | "release-blocking" 자동 enforcement 메커니즘 자체는 AIP-180 / GitHub 모두 정책만 명시 — CI gate 강제는 ca-tmpl 의 운영적 보강 |
|
||||
| D8 | deprecation marker 는 OpenAPI `deprecated: true` + branch note; response header optional | `raw/official-docs/sunset-deprecation-headers-paired-usage.md#SD-PAIR-C3` (Deprecation 헤더 정의), `#SD-PAIR-C6` (link relation), `raw/official-docs/compat-rfc-8594-sunset-header.md#RFC8594-C1` (Sunset 정의), `raw/official-docs/openapi-spec-3-1-0.md#OPENAPI31-C1` (OAS normative keyword 해석 BCP 14), `#OPENAPI31-C2` (OAS = HTTP API contract scope), `#OPENAPI31-C7` (Schema Object = JSON Schema 2020-12 superset — `deprecated` 가 OAS-specific extension 으로 언급되나 본 raw 발췌에 직접 인용 없음) — marker (OpenAPI) 와 응답 헤더의 paired 송신은 D6 에서 강제 | `official-standard + official-vendor-doc` (partial — OpenAPI scope/normative-keyword 까지만) | ⚠️ **OpenAPI Operation Object 의 `deprecated: boolean` 필드 자체의 normative 정의는 openapi-spec-3-1-0 raw 의 OPENAPI31-C1~C7 발췌에 포함되지 않음** (raw 자체 §"Usage Boundaries 이 자료가 증명하지 않는 것" 명시: "`deprecated: true` 의 정확한 의미론 — 본 발췌에 직접 인용 없음"). §4.8.10 Operation Object 의 `deprecated` 필드 별도 발췌 또는 §4.8.24 Schema Object 의 `deprecated` keyword 별도 발췌가 필요한 follow-up. 현재 OPENAPI31-* 는 OAS 의 scope/normative-keyword/JSON-Schema-alignment 만 corroborate — deprecation marker 의미론은 여전히 직접 표준 인용 부재 |
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서/사례는 근거지만 내 프로젝트에서의 동작을 자동 보장하지 않는다. 구현 전/중/후 실제로 검증해야 하는 주장.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| Sunset + Deprecation 헤더가 paired 로 송신되며 `Sunset >= Deprecation` invariant 가 강제되는지 (`SD-PAIR-C5` 준수) | header middleware 구현 위치 (Spring filter / interceptor / `@ControllerAdvice`) 에 따라 invariant 누락 가능 | header invariant CI gate 추가 + integration test (deprecated endpoint 응답에 두 헤더 존재 + Sunset >= Deprecation 검증) | `planned` |
|
||||
| OpenAPI `deprecated: true` 마커와 응답 헤더의 동기화가 보장되는지 | marker 추가만 하고 헤더 누락 또는 그 반대 가능성 | OpenAPI snapshot grep + 실제 응답 contract test cross-check | `planned` |
|
||||
| 90d (public) / 30d (internal) migration window 가 release process 에 실제로 강제되는지 | window 정책이 process 문서에만 있고 CI / release gate 에 강제 메커니즘 없을 위험 | release calendar / CI gate 가 deprecation marker 추가 시각 + sunset date 차이를 검증하는지 dry-run | `needs-confirmation` |
|
||||
| breaking diff CI gate 가 `release-blocking` 으로 실제 동작하는지 (`AIP180-C5` invariant 강제) | gate 가 warning-only 로 misconfigured 가능 | breaking diff 의도적 도입 후 CI build fail 검증 | `planned` |
|
||||
| 7행 catalog 의 모든 row 가 OpenAPI diff tool 의 분류와 1:1 mapping 되는지 | tool (openapi-diff / oasdiff) 의 자체 분류와 catalog 의 분류가 다를 위험 | tool dry-run 결과 + catalog mapping 표 작성 | `planned` |
|
||||
| EOL 응답 코드 (`410 Gone`, GH-APIV-C6) 가 ca-tmpl catalog 에 누락된 점 — sunset 이후 응답 정책 결정 필요 | GitHub 사례 차용 검토 필요 항목으로 본문 명시 — 결정 미정 | catalog 보강 결정 + sunset 시점 이후 응답 contract test 작성 | `needs-confirmation` |
|
||||
| OpenAPI `deprecated: true` (Operation Object / Schema Object) 의 normative 정의를 표준 인용으로 확보 | `openapi-spec-3-1-0` raw 의 OPENAPI31-C1~C7 발췌에 `deprecated` boolean 필드 인용 누락 — D8 의 marker 정책이 외부 표준 직접 인용 없이 운영. raw 자체 Usage Boundary 가 "본 발췌에 직접 인용 없음" 명시 | OpenAPI 3.1 §4.8.10 Operation Object + §4.8.24 Schema Object 의 `deprecated` 필드 발췌를 별도 raw 또는 기존 raw 보강으로 확보 → DEM D8 의 Evidence Strength 를 partial → official-standard 로 승급 | `needs-confirmation` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음.
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
- version·deprecation·sunset 값은 API registry가 소유하고 controller는 registry를 참조한다.
|
||||
- additive fixture와 breaking fixture를 분리하며, 제거는 deprecation window와 소비자 확인 뒤에만 허용한다.
|
||||
- OpenAPI diff가 breaking change를 검출하면 CI가 실패하고 승인 기록 없이는 우회하지 않는다.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- 필드 삭제·타입 변경·enum 축소는 기존 소비자를 깨뜨리므로 명시적 migration 경로가 필요하다.
|
||||
- 본 계약은 API versioning·OpenAPI registry·contract verification Work Item에 의존한다.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- 별도 일일 노트 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-api-contract-baseline.md
|
||||
@@ -0,0 +1,583 @@
|
||||
---
|
||||
title: branch / feature-api-contract-baseline
|
||||
source_type: branch-note
|
||||
status: verified
|
||||
branch: feature-api-contract-baseline
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
tags: [branch, ca-skeleton, api-contract, openapi]
|
||||
created: 2026-05-21
|
||||
last_reviewed: 2026-06-04
|
||||
target_merge:
|
||||
status_label: review
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-011
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-011
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-API-VERSIONING-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-OPENAPI-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: a7692d0779a614a3f52af2276360b0ce9e4c9b05f0d1af1b246fbbaa322102a3
|
||||
---
|
||||
|
||||
# branch: feature-api-contract-baseline
|
||||
|
||||
> Layer: `raw/branch-notes/` — HTTP API surface 전체의 계약을 정의합니다. 완료 후 `/ingest`로 `wiki/projects/`에만 추출합니다.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
> 이 branch 가 어느 작업 묶음에 속하는지. 본 branch 는 project-note 의 직접 자식 (`parent_branch:` 비어 있음).
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 §13 API Contract Surface · §16 Schema/Serialization (envelope shape 부분) · §25 Default Decisions (API versioning row) 의 결정/근거/금지 사항을 정제한다.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: /v1 API와 envelope/OpenAPI contract test가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-API-VERSIONING-001@1` | URI prefix /v1이 default이며 X-Api-Version은 compatibility 실험용 보조 header다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-OPENAPI-001@1` | verification suite가 OpenAPI drift의 release-blocking 판정권을 소유한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
structured response envelope만으로 API contract는 완성되지 않습니다. versioning · pagination · sorting · filtering · content negotiation · request size · idempotency header · HTTP method semantics · conditional request · cache policy · long-running operation · OpenAPI drift 까지 기본 skeleton 기준으로 고정합니다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- API versioning 기준.
|
||||
- pagination/sorting/filtering 표준 (page index base, size cap, 빈 list shape 포함).
|
||||
- idempotency header 표준 (header 이름만 — key shape/scope SSOT 는 sibling).
|
||||
- request size limit 실패 분류 (413).
|
||||
- URI 길이 실패 분류 (414).
|
||||
- multipart/file upload 실패 분류 (위임).
|
||||
- content negotiation 실패 분류 (406/415).
|
||||
- HTTP method 미지원 실패 분류 (405 + `Allow` header).
|
||||
- HTTP method 의 safe / idempotent 분류 + PATCH 의 media type 결정.
|
||||
- conditional request / concurrency at HTTP layer (`ETag`, `If-Match`, `If-None-Match`, 304 Not Modified, 412 Precondition Failed).
|
||||
- response cache 정책 default + `Vary` header 의무.
|
||||
- HEAD / OPTIONS support 의무 (GET 지원 endpoint 는 HEAD MUST).
|
||||
- HTTP status code ↔ envelope `error.category`/`error.code` 의 전체 매핑 SSOT 위치 결정.
|
||||
- long-running operation 응답 패턴 (202 + `Location` + polling endpoint).
|
||||
- resource URL naming convention (plural + lowercase + AIP-122 regex).
|
||||
- sort parameter syntax (Spring `Pageable` native).
|
||||
- filter parameter syntax (flat key=value equality only).
|
||||
- cursor pagination shape (opaque base64 JSON + HMAC + 24h TTL).
|
||||
- bulk operation URL pattern (AIP-136 colon-verb `:batchCreate`).
|
||||
- response Date header 자동 발행 (Spring/Tomcat default).
|
||||
- OpenAPI schema와 실제 응답 계약 일치 검증.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- business-specific endpoint 설계.
|
||||
- API gateway / WAF / reverse proxy 설정 (gateway-pre-reject 의 envelope-bypass 정책만 본 branch 가 *명시*).
|
||||
- public API product policy.
|
||||
- CORS allowlist / credentials / preflight policy — **owner**: [[raw/branch-notes/feature-security-operational-baseline]] D9. 본 branch 는 OPTIONS 응답이 envelope 우회한다는 점만 cross-cite.
|
||||
- response cache layer 구현 (Redis / CDN) — **owner**: [[raw/branch-notes/feature-cache-consistency-contract]]. 본 branch 는 HTTP 응답 header 정책만.
|
||||
- webhook outbound contract (signature header, replay protection, retry semantics) — 별도 branch 신설 필요. 현재 ca-skeleton 범위 밖.
|
||||
- Server-Sent Events / WebSocket / long polling / streaming response — ca-skeleton 은 request-response 만 지원. SSE/WS 도입은 별도 branch.
|
||||
- `X-HTTP-Method-Override` / `_method` form parameter — forbid 가 기본값이지만 *결정 자체*는 security 계약 영역. cross-cite 로만.
|
||||
- `Server` / `X-Powered-By` / 기술 스택 노출 header — **owner**: security branch. 본 branch 는 forbid 만 cross-cite.
|
||||
- error message i18n (`Accept-Language`) — 현재 envelope `error.message` 는 한국어/영어 어느 default 인지 *미정*. 본 branch 는 결정 안 함, schema/serialization 또는 별도 branch 위임.
|
||||
- response body compression negotiation (`Accept-Encoding` / `Content-Encoding` / gzip / br) — reverse proxy/gateway 책임으로 위임. Spring 자체 `server.compression.enabled` 는 dev/staging 에서 옵션.
|
||||
- response field naming case (camelCase vs snake_case) — **owner**: [[raw/branch-notes/feature-schema-serialization-contract]]. 본 branch 는 envelope `meta.*` 가 camelCase 라는 cross-cite 만.
|
||||
- resource ID format 자체는 본 branch 범위 밖이며 [[raw/branch-notes/feature-resource-identifier-contract]] D1/D19가 ULID를 소유한다. 본 branch는 URL 구조와 `{id}` placeholder 연결만 소유한다.
|
||||
- multipart / file upload body 처리 — **owner**: [[raw/branch-notes/feature-file-resource-handling-contract]]. 본 branch 는 415 분류만.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 branch의 결정 근거. 상세 대안 비교는 §외부 근거 / 대안 조사 sub-section 참조. 같은 자료가 여러 결정의 근거면 여러 번 등장 가능.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/idempotency-stripe-api-ref]] | Stripe `Idempotency-Key` header 표준 (D3) — `official-vendor-doc` |
|
||||
| [[raw/official-docs/idempotency-ietf-draft]] | IETF httpapi draft가 동일 header 이름 정의 (D3) — `official-reference` (draft 상태) |
|
||||
| [[raw/company-tech-blogs/idempotency-brandur-stripe-postgres]] | Postgres 구현 reference (D3 보조) — `company-case-study` |
|
||||
| [[raw/official-docs/idempotency-paypal-docs]] | header 이름 `PayPal-Request-Id`로 다름 (D3 대안) — `official-vendor-doc` |
|
||||
| [[raw/official-docs/idempotency-aws-lambda-powertools]] | header 불요, server-derived (D3 대안) — `official-vendor-doc` |
|
||||
| [[raw/official-docs/idempotency-square-api]] | body 필드로 받음, header 표준 미준수 (D3 대안) — `official-vendor-doc` |
|
||||
| [[raw/company-tech-blogs/idempotency-toss-payments-techblog]] | header 이름 `Idempotency-Key` 동일 (D3 보조) — `company-case-study` |
|
||||
| [[raw/official-docs/idempotency-no-api-level-github-rest]] | header 자체 없음 (D3 대안) — `official-vendor-doc` |
|
||||
| [[raw/company-tech-blogs/idempotency-redis-vs-db-storage]] | ca-tmpl이 DB 선택 근거 (D3 보조, header layer 만) — `company-case-study` |
|
||||
| [[raw/official-docs/google-aip-185-resource-versioning]] | URI `/v1` major-only path versioning 근거 (D2, D6) — `official-reference` |
|
||||
| [[raw/official-docs/api-versioning-google-aip-180]] | backward compatibility 의무 cross-cite (D6) — `official-reference` |
|
||||
| [[raw/official-docs/jsonapi-pagination-format]] | pagination link key 명명 + `links` object 위치 표준 (D7) — `official-standard` |
|
||||
| [[raw/official-docs/rfc9110-http-semantics]] | HTTP 의미론 normative — D8 (413), D9 (406/415), D12 (405 + Allow), D13 (HEAD/OPTIONS), D15 (ETag/If-Match/If-None-Match/304/412), D16 (Vary), D17 (202 + Retry-After), D24 (Date), D8 형제 (414) — `official-standard` |
|
||||
| [[raw/official-docs/openapi-spec-3-1-0]] | OAS = machine-readable HTTP API contract — manual stale schema 금지 근거 (D10) — `official-standard` |
|
||||
| [[raw/official-docs/patch-json-merge-rfc7396]] | IETF RFC 7396 Standards Track — **미채택 근거**. RFC7396-C3 ("explicit null 사용 모델에 부적합") 가 본 branch 의 envelope 정책 + boundary branch B2 의 absent/null 3-상태 mapper 결정과 충돌 — *미채택의 직접 normative 근거*. RFC7396-C2 (null=deletion) 는 대안으로 인용 — `official-standard` |
|
||||
| [[raw/official-docs/google-aip-151-long-running-operations]] | AIP-151: LRO 패턴 — Operation `done`/`result`/`error` 분기 + `name` 필드 polling 의무 (D17) — `official-reference` |
|
||||
| [[raw/official-docs/rfc9111-http-caching]] | IETF RFC 9111 (HTTP Caching) — `no-store` / `private` / `public` / `max-age` directive normative 정의 (D16 cache policy default) — `official-standard` |
|
||||
| [[raw/official-docs/google-aip-122-resource-names]] | (future B13 — 미결) Resource URL naming convention — collection segment plural + lowercase 근거 (AIP122-C2, AIP122-C3). sample-portfolio `/v1/worklogs` collection name 명명 기준 — `official-reference` |
|
||||
| [[raw/official-docs/google-aip-136-custom-methods]] | (future B18 — 미결) Bulk operation URL pattern — colon-verb suffix syntax + collection-based custom method 원칙. D17 LRO cross-ref: custom method 가 LRO entry point 가 될 수 있음 (AIP136-C1~C5) — `official-reference` |
|
||||
| [[raw/official-docs/fetch-spec-cors]] | WHATWG Fetch §3.3 CORS protocol — D13 (OPTIONS preflight envelope 우회) 의 normative 근거. preflight = OPTIONS + Access-Control-Request-Method (FETCH-CORS-C2). CORS safelisted method: GET/HEAD/POST — `official-standard` |
|
||||
| [[raw/official-docs/google-aip-132-list-method]] | AIP-132 List method: `order_by` syntax (`"foo desc, bar"` 형식, AIP132-C4) + `page_size`/`page_token`/`next_page_token` proto field 명명 (future B14 sort syntax 결정 근거 후보) — `official-reference` |
|
||||
| [[raw/official-docs/google-aip-158-pagination]] | AIP-158 Pagination: `page_size` server-side cap SHOULD coerce (AIP158-C2), `next_page_token` empty = EoC (AIP158-C4), page token opaque + URL-safe (AIP158-C5). D18 size cap + (future B16) cursor pagination shape 근거 — `official-reference` |
|
||||
| [[raw/official-docs/google-aip-160-filtering]] | AIP-160 Filtering: filter DSL syntax (Common Expression Language) 옵션 정의 (future B15 filter syntax 결정의 1개 옵션 근거) — `official-reference` |
|
||||
| [[raw/official-docs/spring-data-pageable-defaults]] | Spring Data `Pageable` zero-indexed (SPRING-PAGE-C1/C3) + `size` default 20 (SPRING-PAGE-C2) + `DEFAULT_MAX_PAGE_SIZE = 2000` (SPRING-PAGE-C4 — Integer.MAX_VALUE 가 아님). D18 정합성 근거 — `official-vendor-doc` |
|
||||
|
||||
근거 자료가 raw에 아직 없다면 먼저 `raw-source-template` 으로 raw에 등록한 뒤 여기서 링크.
|
||||
|
||||
### 외부 근거 / 대안 조사 (2026-05-22 — Topic 5: Idempotency-Key)
|
||||
|
||||
본 branch의 `Idempotency-Key` HTTP header 및 idempotent command 정책 결정 (D3) 에 대한 외부 source. key shape SSOT는 `feature-rate-limit-idempotency-contract` (consume only). 비교 분석은 (예정) `wiki/concepts/idempotency-key-design.md` 참조.
|
||||
|
||||
- **채택 결정 (header 이름 `Idempotency-Key`, idempotent command에만 적용)**:
|
||||
- (가장 가까운 reference) [[raw/official-docs/idempotency-stripe-api-ref]] — Stripe `Idempotency-Key` header 표준
|
||||
- [[raw/official-docs/idempotency-ietf-draft]] — IETF httpapi draft가 동일 header 이름 정의
|
||||
- [[raw/company-tech-blogs/idempotency-brandur-stripe-postgres]] — Postgres 구현 reference
|
||||
- **검토한 대안**:
|
||||
- **대안 1: Stripe v1 pair `(account, key)`** — [[raw/official-docs/idempotency-stripe-api-ref]] (endpoint dimension 없음, ca-tmpl보다 덜 보수적)
|
||||
- **대안 2: PayPal `(req-id, API call type)` + 45일 TTL** — [[raw/official-docs/idempotency-paypal-docs]] (header 이름 `PayPal-Request-Id`로 다름)
|
||||
- **대안 3: Content-hash `(fn, payload_hash)`** — [[raw/official-docs/idempotency-aws-lambda-powertools]] (header 불요, server-derived)
|
||||
- **대안 4: Square endpoint-scoped** — [[raw/official-docs/idempotency-square-api]] (body 필드로 받음, header 표준 미준수)
|
||||
- **대안 5: 토스 4-tuple `(account, key, URL, method)` + 15일 TTL** — [[raw/company-tech-blogs/idempotency-toss-payments-techblog]] (header 이름 `Idempotency-Key` 동일)
|
||||
- **대안 6: No API-level idempotency** — [[raw/official-docs/idempotency-no-api-level-github-rest]] (header 자체 없음)
|
||||
- **보조 결정 (저장소)**: [[raw/company-tech-blogs/idempotency-redis-vs-db-storage]] — ca-tmpl이 DB 선택 근거 (이 branch는 header layer만)
|
||||
- **비교 핵심**: API baseline은 header 이름만 결정. shape/scope는 rate-limit-idempotency branch가 owns. Stripe/Toss/Square 모두 `Idempotency-Key` 또는 동등 header를 사용 — header 이름은 사실상 industry de facto.
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
> TODO drained — 결정은 §결정 사항 / Decisions 표 + §구현 가이드 §2 Decisionized Work Items 표 참조. multipart/file upload 는 `feature-file-resource-handling-contract` 로 위임.
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- API contract 는 controller 구현보다 먼저 고정되어야 한다.
|
||||
|
||||
### Phase C2 구현 결과 (2026-06-02)
|
||||
|
||||
ca-tmpl 실 코드에 producer-소유 결정을 구현하고 sample-portfolio 를 계약에 정합시켰다. 사용자 결정: **전체 구현 + 샘플 정합**, 차단 항목은 **producer seam + planned**.
|
||||
|
||||
- `locally-verified` (단위/슬라이스/임베디드 테스트로 검증):
|
||||
- D8 413 (`PAYLOAD_TOO_LARGE`) · D9 406/415 distinct · D12 405 + `Allow` — `GlobalExceptionHandler` override + `TransportErrorHandlingTest`.
|
||||
- D15 ETag/`If-Match`→412/`If-None-Match`→304 — `adapter-web` `ETags`/`PreconditionFailedException` + sample `WorkLog.version`(@Version) + `WorkLogControllerWireTest`.
|
||||
- D7/D18 pagination `meta.page` + size 1..100/page≥0 → 400 + 빈 list `[]` + deep-offset `Deprecation` — `PageParams`/`PageMeta`/`ResponseMeta.page` + wire test.
|
||||
- D20 sort 네이티브 syntax(비-네이티브 400) — `SortParam` + wire test. D21 flat key=value filter — wire test.
|
||||
- D16 default `Cache-Control: no-store` + `Vary` (+ Security 기본 cache-control 비활성으로 단일 owner) — `CacheControlFilter` + test.
|
||||
- D19 AIP-122 URL 네이밍 — ArchUnit `controller_request_mappings_follow_aip122` + `KebabPathControllerFixture` + violations-as-data. sample 경로 `/work-logs`→`/worklogs`, `/repo-stats`→`/worklogs/repoStats`.
|
||||
- D23 sync atomic `:batchCreate` (AIP-136 colon-verb, partial 금지) — `BatchCreateWorkLogsUseCase`(단일 tx) + wire test.
|
||||
- D11 status↔registry 정합성 — `ErrorCodeRegistryMappingTest` (error-codes.yaml 의 405/406/412/413/414/415 row 추가, drift FAIL).
|
||||
- D10 OpenAPI producer — springdoc `/v3/api-docs` 임베디드 컨테이너 테스트(`OpenApiSnapshotTest`).
|
||||
- D2 `/v1` 기본 prefix — application.yml `PRESENTATION_API_BASE_PATH:/v1`.
|
||||
- D22 cursor **seam** — `adapter-web` `CursorCodec`(opaque base64 + HMAC + 24h TTL) + `CursorCodecTest` (opacity/integrity/TTL 3-invariant = §3 D22 요구 충족).
|
||||
|
||||
#### 소유 범위 gap 보완 (2026-06-02, 2차 패스)
|
||||
|
||||
1차 패스에서 `planned` 로 둔 것 중 **차단되지 않은 소유 결정**을 추가 구현(§3 Test Contract 항목 기준):
|
||||
|
||||
- D13 HEAD-mirror-GET — `WorkLogControllerWireTest.head_on_get_endpoint_is_supported_not_405` (405/404 아님).
|
||||
- D23 batch size cap — `BatchCreateRequest @Size(max=1000)` + `batch_over_size_cap_is_400` (1001→400).
|
||||
- D3 `Idempotency-Key` POST surface — `create`/`batchCreate` 의 `@RequestHeader`(server-tolerant) + `post_accepts_idempotency_key_header` (shape는 여전히 rate-limit branch).
|
||||
- D2 versioning 강제 — `VersioningPrefixTest` (`/v1/probe` 200, `/probe` 404 → unversioned public endpoint 불가).
|
||||
- D21 filter DSL 미파싱 — `filter_dsl_is_ignored_not_parsed` (`?filter=status==OPEN` 무시).
|
||||
- D17 LRO endpoint — `SampleOperationStore`(id를 controller 밖에서 mint) + `OperationsController`(`POST /worklogs:export` 202+`Location`+`data.{operationId,statusUrl}`, `GET /operations/{id}` polling) + `OperationsControllerWireTest`.
|
||||
- D24 Date matrix — `DateHeaderContractTest` (임베디드 Tomcat, 200·404 응답에 `Date` 헤더).
|
||||
|
||||
- `planned` (실제 차단 — 형제 branch/인프라): D3 key shape/replay (rate-limit), D5/D10 drift 릴리스 게이트 (verification-test-suite), D16 cache layer (cache), D22 HMAC 키 회전 (security), D8 **414 end-to-end** (Tomcat/gateway가 Spring 디스패치 전 거부 — code+registry row만), D23 async partial (boundary B14), D22 sample cursor endpoint (§3 미요구, optional).
|
||||
- 검증: `./gradlew check` + `verifyCleanArchitectureDependencies` + `*CleanArchitectureTest`/`*ArchitectureViolationFixtureTest` 모두 PASS.
|
||||
- 구현 계획서: ca-tmpl `docs/superpowers/plans/2026-06-02-api-contract-baseline.md`.
|
||||
|
||||
### Ground-truth 대조 (2026-06-04, ca-tmpl @b15dcf5)
|
||||
|
||||
`/ingest` reconcile 시 ca-tmpl commit `b15dcf5` ("API 계약 baseline 구현") 의 실제 코드(package root `dev.caskeleton.*`)와 1:1 대조해 위 `locally-verified` 항목을 확정했다. 실재 확인 클래스/파일:
|
||||
|
||||
- `adapter-web/conditional/{ETags,PreconditionFailedException}` (D15), `adapter-web/filter/CacheControlFilter` (D16), `adapter-web/pagination/{PageParams,SortParam}` (D18/D20), `adapter-web/cursor/{CursorCodec,CursorException}` (D22 seam), `adapter-web/error/GlobalExceptionHandler` (D8/D9/D12 + 412 매핑).
|
||||
- `shared-contract/response/{PageMeta,ResponseMeta}` (D7/D18), `shared-contract/operation/{Operation,OperationStatus}` (D17).
|
||||
- `sample-portfolio/.../controller/{WorkLogController,OperationsController}` (D15/D23/D17), `.../operation/{SampleOperationStore,WorkLogExportResult}`.
|
||||
- versioning: `app-bootstrap/.../application.yml` `ca-skeleton.presentation.api-base-path: ${PRESENTATION_API_BASE_PATH:/v1}` + `adapter-web/settings/PresentationSettings` (코드 default `""`, 운영 default `/v1`) (D2).
|
||||
- OpenAPI: `adapter-web/build.gradle` `springdoc-openapi-starter-webmvc-api:2.8.6` + `OpenApiSnapshotTest` `/v3/api-docs` (D10).
|
||||
- 테스트: `TransportErrorHandlingTest`, `WorkLogControllerWireTest`, `CacheControlFilterTest`, `CursorCodecTest`, `ETagsTest`, `PageParamsTest`, `SortParamTest`, `OperationsControllerWireTest`, `VersioningPrefixTest`, `DateHeaderContractTest`, `ErrorCodeRegistryMappingTest`.
|
||||
|
||||
UNSUPPORTED_IMPL_DECISION 확인된 잔존: pagination size cap 100/min 1/deep-offset 10000, ETag lenient(weak) 비교(RFC 9110 strong MUST 와 차이), cursor 24h TTL + HMAC-SHA256, LRO status enum 5종. planned 잔존: D22 HMAC 운영 key/회전(security), D8 414 end-to-end(Tomcat pre-dispatch), D3 key shape/replay(rate-limit), D5/D10 drift 릴리스 게이트(verification-suite), D16 cache layer(cache).
|
||||
|
||||
추출 결과: [[wiki/projects/ca-tmpl/api-evolution-and-schema]] 의 "API contract baseline 구현" 절 + [[wiki/concepts/api-evolution-and-schema]] 의 HTTP contract surface 표준/Claim-backed Knowledge.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 추후 면접/회고에서 "왜 이렇게 했나" 답할 근거. 대안과 함께 기록. 각 결정의 근거는 위 §Sources 또는 §Decision Evidence Map 의 Supporting Claims 참조.
|
||||
|
||||
- 2026-05-21: envelope 응답 외 API surface도 skeleton 계약에 포함 (D1).
|
||||
- 2026-05-22: API versioning 기본값은 URI prefix `/v1`. `X-Api-Version`은 실험/compatibility 보조 header이며 path version과 충돌하면 path가 우선 (D2).
|
||||
- 2026-05-22: idempotency header 이름은 `Idempotency-Key`, key scope와 replay semantics의 SSOT는 [[raw/branch-notes/feature-rate-limit-idempotency-contract]] (D3, D4).
|
||||
- 2026-05-22: OpenAPI drift의 release-blocking 집행권은 [[raw/branch-notes/feature-contract-verification-test-suite]]가 단일 owner이며 이 branch는 producer (D5).
|
||||
- 2026-05-31: **HTTP status code ↔ envelope `error.category`/`error.code` 매핑 SSOT** = `feature-operational-error-observability-foundation` 의 `error-codes.yaml` (registry §21 row 49 + `http_status` column). 본 branch 는 *registry 의 매핑 정합성 contract test* 의 producer. registry row 와 실제 controller 응답의 drift 는 contract test 가 release-blocking (D11).
|
||||
- 2026-05-31: **HTTP method 미지원** 응답은 405 Method Not Allowed + `Allow` response header 의무. `Allow` header 는 해당 URL 이 지원하는 method 의 comma-separated 목록. Spring 의 `HttpRequestMethodNotSupportedException` 가 envelope 우회로 직접 응답하면 contract 위반 (D12).
|
||||
- 2026-05-31: **GET 을 지원하는 endpoint 는 HEAD 도 자동 지원** (Spring MVC 가 자동 처리하나 contract test 로 검증 의무). OPTIONS 는 CORS preflight 또는 resource 자체 metadata 응답으로 분기 — CORS preflight 는 envelope 우회 (security branch SSOT), resource metadata 용도는 envelope 따름 (D13).
|
||||
- 2026-05-31 (정정): **PATCH 의 default media type 은 `application/json`** (RFC 7396 `application/merge-patch+json` *미채택*). request shape 는 `JsonNullable<T>` (openapi-generator) 또는 `Optional<T>` wrapper 로 **absent / null / value 3-상태 구분** — absent = 변경 없음, null = 명시적 null/clear, value = 새 값. RFC 7396 null=deletion semantics 는 envelope success/error 대칭 정책과 충돌하여 *미채택* (RFC7396-C3 가 "explicit null 사용 모델에 부적합" normative). `application/merge-patch+json` content type 사용은 [[raw/branch-notes/feature-boundary-validation-mapping-contract]] B2 의 ArchUnit rule `no_merge_patch_json_media_type_string` 으로 build 실패 차단. RFC 6902 (`application/json-patch+json`) 도 동일 이유로 미채택.
|
||||
- 2026-05-31: **Conditional request 지원**: read 응답에 `ETag` header 발행 (sample-portfolio 의 `WorkLogVersion` 같은 version field 가 있으면 derived ETag, 없으면 content hash). write request 는 `If-Match` 헤더로 optimistic concurrency 검증 — mismatch 시 412 Precondition Failed (envelope 따름). `If-None-Match` 로 cache validation — match 시 304 Not Modified (body 없음, envelope 우회). `If-Match` 누락된 write 는 *허용* 하되, contract test 로 sample-portfolio 에서 *권장 패턴* 검증 (D15).
|
||||
- 2026-05-31: **응답 cache 정책 default**: 모든 응답에 `Cache-Control: no-store` (인증된 API 의 안전한 default). 명시적으로 cacheable 한 endpoint 만 controller annotation 으로 `Cache-Control: private, max-age=N` opt-in. content negotiation 또는 인증된 응답에는 `Vary: Accept, Accept-Encoding, Authorization` 헤더 의무 — proxy/CDN cache poisoning 방지 (D16).
|
||||
- 2026-05-31: **Long-running operation (LRO) 응답 패턴**: 비동기 처리 endpoint 는 202 Accepted + `Location: /v1/operations/{id}` + envelope `data.operationId` + `data.statusUrl`. polling endpoint (`GET /v1/operations/{id}`) 는 `status` ∈ {`PENDING`, `RUNNING`, `SUCCEEDED`, `FAILED`, `CANCELLED`}. `Retry-After` 헤더로 polling interval 권고. Webhook callback 은 별도 branch (D17).
|
||||
- 2026-05-31: **Pagination size cap + index base 강제**: `page` 0-indexed (Spring `Pageable` default 와 정합), `size` 기본 20 + 최대 100 + 최소 1. `size > 100` 또는 `size < 1` 또는 `page < 0` 은 400 VALIDATION_FAILED. 빈 list 는 `data: []` (절대 `null` 아님), `meta.page.total = 0`. 깊은 offset pagination (예: `page > 10000`) 은 `Deprecation` 헤더 + 권고: cursor pagination 사용 — cursor endpoint 의 shape 결정은 별도 후속 작업 (D18, D7 row 보강).
|
||||
- 2026-05-31: **본 branch 의 cross-branch consumer/producer 관계**: §구현 가이드 §4 Cross-branch Contract Map 참조.
|
||||
- 2026-05-31: **Resource URL naming convention** = `plural` + `lowercase` + AIP-122 regex `[a-z][a-zA-Z0-9]*`. single-word resource: `/v1/worklogs` · multi-word: `lowerCamelCase` (예: `/v1/worklogComments`). **kebab-case 금지** (AIP122-C3 regex 위반 — `/v1/worklog-comments` ❌). singular path 금지 (`/v1/worklog/{id}` ❌). CamelCase 금지 (case-sensitivity footgun) (D19).
|
||||
- 2026-05-31: **Sort parameter syntax** = Spring `Pageable` native `?sort=field,direction` (`?sort=createdAt,desc`). multi-sort 는 param repeat (`?sort=createdAt,desc&sort=title,asc`). 다른 syntax (`?sort=-foo`, `?sort=foo:desc`, `?order_by=foo desc`) 금지 — Spring 자동 binding 깨짐 (D20).
|
||||
- 2026-05-31: **Filter parameter syntax** = flat key=value (equality only). `?status=OPEN&owner=user123` 만 허용. 복잡 filter (range / `in` / `like` / `AND/OR` 조합) 는 *out of scope* — 필요 시 별도 branch 또는 GraphQL 도입 시점 재검토. AIP-160 DSL / RSQL / FIQL / JSON:API bracket syntax 모두 *미채택* (parsing/security 부담 + ergonomics 낮음) (D21).
|
||||
- 2026-05-31: **Cursor pagination shape** = opaque base64-encoded JSON token + server-side HMAC signature (tamper detection) + 24h TTL. cursor endpoint 는 별도 query param (`?cursor=<token>&size=20`) 또는 별도 path (`/v1/worklogs:listByCursor`). client 는 token parse 금지 (opacity 강제 — AIP158-C5 normative). cursor + 전통적 `?page=N` 동시 사용 금지 — 별도 endpoint (D22).
|
||||
- 2026-05-31: **Bulk operation URL pattern** = AIP-136 colon-verb `POST /v1/{resource}:batchCreate` (verb suffix). request body = `{ requests: [...] }`. **sync vs async 명확 분기 (AIP233-C7 MUST atomic 정합)**: (a) **sync batch endpoint** = MUST **atomic** (all-or-nothing). 한 항목 실패 시 전체 rollback + HTTP 4xx (예: 400 VALIDATION_FAILED + envelope.success=false). partial failure 허용 안 함. (b) **async batch endpoint** = D17 LRO pattern 결합 — `POST /v1/{resource}:batchCreate` 가 202 Accepted + `Location: /v1/operations/{id}` 반환 → polling endpoint `GET /v1/operations/{id}` 의 `data.result.results[]` 에서 항목별 success/error 반환 (partial failure 허용). `BATCH_PARTIAL_FAILURE` envelope category 는 **async batch 의 polling 응답에서만** 사용. flat array body (`POST /v1/worklogs` with `[...]`) 금지. kebab subpath (`POST /v1/worklogs/batch-create`) 금지 (D23).
|
||||
- 2026-05-31: **Response Date header** = 모든 응답에 `Date` 헤더 자동 발행 (Spring/Tomcat default). controller 별도 설정 불요. Date header 명시적 비활성화 금지. log correlation + RFC 9110 §6.6.1 SHOULD 정합 (D24).
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. `Decision ID` 는 본 branch-note 안에서 안정적으로 유지. `Supporting Claims` 는 `raw/<category>/<slug>.md#<ClaimID>` 형식. company-tech-blog 출처는 `company-case-study` 로 라벨링하며 공식 best practice 로 격상하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|
|
||||
| D1 | envelope 외 API surface 도 skeleton 계약에 포함 (2026-05-21) | UNSUPPORTED_DECISION (project-internal scoping decision; no external standard cited) | N/A | scope drift — wiki/projects 추출 시 본 결정의 근거를 별도 design 문서로 보강 필요 |
|
||||
| D2 | API versioning 기본값 `/v1` URI prefix + `X-Api-Version` 은 supplemental | `raw/official-docs/google-aip-185-resource-versioning.md#AIP185-C1` (major version 노출 의무), `#AIP185-C2` (minor/patch 노출 금지 — `/v1.0` 금지 → `/v1`), `#AIP185-C3` (새 major 는 이전 major 에 의존 금지) | `official-reference` (Google AIP — Google 사내 community guideline; IETF/W3C 표준 아님) | AIP-185 본문은 protobuf 컨텍스트 — REST URI path vs header 선택 자체에 대한 normative 진술은 본 인용 범위 밖. `X-Api-Version` 을 supplemental 로 두는 결정의 직접 근거는 별도 (예: ca-tmpl internal design) — AIP-185 는 path 형식 (`/v1`) 만 corroborate. **UNSUPPORTED_IMPL_DECISION**: path version 과 `X-Api-Version` 충돌 시 *path 우선* 규칙은 project-internal convention — AIP-185 에 path/header 우선순위 normative 진술 없음. trade-off: URI 가 1급 계약 표면(캐시·라우팅·로그에서 가시)이므로 path 를 권위 source 로, header 는 실험/전환 보조로 둠 |
|
||||
| D3 | idempotency header 이름 `Idempotency-Key` 채택 (POST endpoint 표준 surface) | `raw/official-docs/idempotency-stripe-api-ref.md#STRIPE-IDEMP-C1`, `raw/official-docs/idempotency-ietf-draft.md#IETF-IDEMP-C1`, `raw/company-tech-blogs/idempotency-toss-payments-techblog.md#TOSS-IDEMP-C1` | `official-vendor-doc + official-reference + company-case-study` | IETF-IDEMP 는 draft 상태 (정식 RFC 아님); TOSS 는 company-case-study — 표준 lock-in 아님. PayPal 은 다른 header (`PayPal-Request-Id`) 사용 — 호환성은 별도 |
|
||||
| D4 | key scope / replay semantics SSOT 는 `feature-rate-limit-idempotency-contract` 로 위임 (이 branch 는 header 이름만 결정) | UNSUPPORTED_DECISION (project-internal SSOT 분할 결정; 외부 표준 근거 없음) | N/A | sibling branch 와의 결정 정합성은 cross-branch review 로 확보 필요 |
|
||||
| D5 | OpenAPI drift release-blocking 집행은 `feature-contract-verification-test-suite` 가 owner; 이 branch 는 producer | UNSUPPORTED_DECISION (project-internal owner 분할 결정) | N/A | producer-owner contract 가 깨지면 drift 가 untracked — verification suite 의 입력 spec 정합성 추적 필요 |
|
||||
| D6 | versioning Decisionized Work Item — media-type/header/path version 혼용 금지 | `raw/official-docs/google-aip-185-resource-versioning.md#AIP185-C4` (alpha/beta 만 stability level append, stable 은 append 금지 — version 표기 일관성), `#AIP185-C5` (beta 는 stable 의 superset — channel 간 일관성), `#AIP185-C6` (deprecated 기능은 채널 승격 금지); cross-cite `raw/official-docs/api-versioning-google-aip-180.md#AIP180-C1`~`C5` (backward compatibility 의무) | `official-reference` (AIP-185 + AIP-180 Google 사내 guideline 양쪽 cross-cite) | AIP-185/180 은 version 표기 일관성과 호환성을 normatively 요구하나 "path vs header vs media-type 셋 중 하나만 써야 한다" 는 직접 진술은 본 인용에 포함 안됨 — 혼용 금지는 일관성 원칙의 본 branch 적용 (project-internal 해석) |
|
||||
| D7 | pagination — `page`/`size`/`sort` request + `meta.page` response | `raw/official-docs/jsonapi-pagination-format.md#JSONAPI-PAGE-C1` (pagination 은 `MAY` — 옵션), `#JSONAPI-PAGE-C2` (pagination link 는 `links` object 안에 `MUST`), `#JSONAPI-PAGE-C3` (`first`/`last`/`prev`/`next` 4개 key `MUST`) | `official-standard` (JSON:API v1.1 community spec) | JSON:API 는 link key 명명 (`first/last/prev/next`) 과 위치 (`links` object) 를 normatively 정의 — 본 branch 의 `meta.page` envelope shape 와는 **다름**. JSON:API 표준 그대로가 아닌 `meta.page` shape 채택은 project-internal 해석 (envelope contract 와의 통합 우선). pagination 전략 자체 (offset vs cursor) 는 `JSONAPI-PAGE-C6` 가 agnostic 명시 — 본 branch 의 `page`/`size` (offset-style) 선택은 별도 결정 |
|
||||
| D8 | request size limit — oversized request 가 raw 500 으로 가면 실패 | `raw/official-docs/rfc9110-http-semantics.md#RFC9110-C5` (413 Content Too Large = server 가 request content 가 너무 커서 처리 거부), `#RFC9110-C6` (413 이 일시적이면 `Retry-After` 헤더 생성 SHOULD) | `official-standard` (IETF RFC 9110) | RFC 9110 은 413 이 의미적으로 "oversized request 의 정상 응답" 임을 normatively 정의하므로 envelope wrapping 자체는 별도 application 책임. raw 500 으로 변환되면 본 의미론 위반 — 본 결정의 직접 근거. envelope shape (VALIDATION vs RATE_LIMIT category 매핑) 은 owner branch 책임으로 위임됨 |
|
||||
| D9 | content negotiation — 415 / 406 distinct codes 사용 | `raw/official-docs/rfc9110-http-semantics.md#RFC9110-C4` (406 Not Acceptable = 응답 표현 협상 실패 — `Accept` 계열 헤더 부적합), `#RFC9110-C7` (415 Unsupported Media Type = 요청 본문 format 미지원), `#RFC9110-C8` (415 trigger 는 `Content-Type`/`Content-Encoding` 또는 데이터 직접 검사) | `official-standard` (IETF RFC 9110) | RFC 9110 은 406 (응답 표현) 과 415 (요청 본문) 를 의미적으로 구별 — 동일 error code 로 뭉개면 표준 의미 손실. 본 결정의 직접 근거. Spring 의 `HttpMediaTypeNotSupportedException` / `HttpMediaTypeNotAcceptableException` 매핑은 Spring vendor 책임 — 검증은 `Claims To Verify` 표 참조 |
|
||||
| D10 | OpenAPI producer — generated snapshot, manual stale schema 금지 | `raw/official-docs/openapi-spec-3-1-0.md#OPENAPI31-C2` (OAS = HTTP API 에 대한 standard, language-agnostic interface — machine-readable discover/understand), `#OPENAPI31-C4` (Data Type 은 JSON Schema 2020-12 base — schema validation 정합성) | `official-standard` (OpenAPI Initiative — Linux Foundation OAS 3.1.0) | OAS 3.1 은 "machine-readable contract" 를 정의하므로 manual stale schema 는 본 표준의 목적 (discover/understand) 자체를 위반 — 본 결정의 의미론적 근거. 단 OAS 본문은 "snapshot 을 어떻게 생성해야 하는지" (e.g., springdoc-openapi 같은 도구) 는 normative 하지 않음 — 도구 선택은 vendor/project 책임 |
|
||||
| D11 | HTTP status code ↔ envelope `error.category`/`error.code` 매핑 SSOT 는 `error-codes.yaml` (foundation branch 소유 registry §21 row 49 + `http_status` column). 본 branch 는 mapping consistency contract test 의 producer | UNSUPPORTED_DECISION (project-internal SSOT 위치 결정; 외부 표준 직접 근거 없음 — RFC 9110 §15.x 가 *개별 status code* 의미를 정의할 뿐 *registry 형식의 SSOT* 자체는 표준 영역 밖); 보강: 개별 row 의 매핑 (예: 404 ↔ `RESOURCE_NOT_FOUND`) 의 normative 근거는 RFC 9110 §15.x 각 status section — 본 branch 의 contract test 가 *registry 와 응답의 drift* 만 검증, *registry 자체의 row 정합성* 은 foundation branch 책임 | N/A | mapping SSOT 가 registry 인 것 자체는 project-internal 결정. RFC 9110 §15.5.6 (405), §15.5.13 (412), §15.5.15 (414) 같은 다른 status code section 의 raw 발췌가 *다음 세션* 작업 — 이후 각 row 의 Supporting Claim ID 보강 가능. cross-branch: registry row 의 *추가/수정* 은 §21 변경 절차 (registry-governance branch) 적용 |
|
||||
| D12 | HTTP method 미지원 응답 = 405 Method Not Allowed + `Allow` header 의무 + envelope 따름 | `raw/official-docs/rfc9110-http-semantics.md#RFC9110-C9` (405 = method 알지만 target resource 가 지원 안 함, `Allow` header 생성 MUST), `#RFC9110-C10` (`Allow` header 가 405 응답에서 MUST 생성; empty value = 어떤 method 도 허용 안 함의 정상 표현) | `official-standard` (IETF RFC 9110) | Spring 의 `HttpRequestMethodNotSupportedException` 가 자동 `Allow` 헤더 생성 — contract test 로 envelope wrap + `Allow` 양쪽 모두 검증 의무. 405 응답 body 의 envelope shape 은 표준 외 application 책임 — 본 결정의 envelope 따름 부분은 RFC 9110 가 강제하지 않음 (project-internal) |
|
||||
| D13 | GET 을 지원하는 endpoint 는 HEAD 도 MUST 지원 (Spring MVC 자동 처리, contract test 로 verify). OPTIONS 분기: CORS preflight 는 envelope 우회 (security branch SSOT), resource metadata 용도는 envelope 따름 | `raw/official-docs/rfc9110-http-semantics.md#RFC9110-C11` (HEAD = GET 과 동일 의미론, MUST NOT send content), `#RFC9110-C12` (OPTIONS = communication options 요청, resource action 함의 없음 — pure introspection); CORS preflight 식별의 normative 근거는 `raw/official-docs/fetch-spec-cors.md#FETCH-CORS-C2` (preflight = OPTIONS + `Access-Control-Request-Method`) | `official-standard` (IETF RFC 9110 HEAD/OPTIONS 의미론 + WHATWG Fetch CORS preflight 식별 기준) | "GET 지원 endpoint 가 HEAD 도 MUST 지원" 의 *명시적 MUST* 는 RFC9110-C11 인용 자체에는 *함의* 만 포함 — HEAD 의 정의가 "GET 과 동일하나 content 없음" 이므로 GET 지원 시 HEAD 도 자동 의미. Spring MVC 가 이를 자동 mirror — contract test 로 검증 의무. OPTIONS resource metadata 용도는 ca-skeleton 범위에서 *지원 안 함* 옵션도 가능 (opt-in 결정) |
|
||||
| D14 | PATCH default media type = `application/json` (RFC 7396 `application/merge-patch+json` *미채택*). request shape = `JsonNullable<T>` / `Optional<T>` wrapper 로 **absent / null / value 3-상태** 구분 · ArchUnit rule SSOT 는 [[raw/branch-notes/feature-boundary-validation-mapping-contract]] B2 | `raw/official-docs/patch-json-merge-rfc7396.md#RFC7396-C3` (merge patch 는 explicit null 사용 모델에 부적합 — *미채택의 직접 근거*), `#RFC7396-C2` (대안: null=deletion normative — 본 branch *대안으로* 인용); [[raw/branch-notes/feature-boundary-validation-mapping-contract]] B2 (PATCH mapper SSOT, ArchUnit `no_merge_patch_json_media_type_string` enforced) | `official-standard` (RFC 7396 — 미채택 근거) + `cross-branch-SSOT` (boundary branch B2) | content type 정책은 본 branch 가 producer, mapper 구현은 boundary branch 책임. 클라이언트가 merge-patch semantics 가정하지 않도록 OpenAPI spec 에 명시. |
|
||||
| D15 | Conditional request 지원: read 응답에 `ETag` 발행, write 의 `If-Match` mismatch → 412 Precondition Failed (envelope 따름), read 의 `If-None-Match` match → 304 Not Modified (body 없음, envelope 우회) | `raw/official-docs/rfc9110-http-semantics.md#RFC9110-C13` (ETag = opaque validator, weak/strong 표시 가능), `#RFC9110-C14` (If-Match conditional + strong comparison MUST — representation 변경 시 method 적용 방지가 client 의도), `#RFC9110-C15` (If-None-Match conditional + weak comparison MUST), `#RFC9110-C16` (304 Not Modified = conditional GET/HEAD condition false 시 representation 미전송 + client stored representation 사용), `#RFC9110-C17` (412 Precondition Failed = 하나 이상 condition false 시) | `official-standard` (IETF RFC 9110) | sample-portfolio 의 `WorkLogVersion` 이 ETag derivation 의 1차 source — DB layer 의 optimistic lock 과 HTTP layer 의 412 가 *동일 conflict 의 두 표현* 이라는 점이 본 결정의 의미. RFC 9110 은 ETag 값의 derivation 방법 (version vs hash) 자유 — opaque 성만 강제. `If-Match` 누락 허용 결정은 ca-skeleton 의 "skeleton 은 강제하지 않고 *권장 패턴* 만 fixture 로 보여줌" 정신 — project-internal trade-off (RFC 9110 은 *If-Match 가 있으면* 의 의미론만 정의; 428 Precondition Required 강제 옵션은 RFC 6585 별도) |
|
||||
| D16 | 응답 cache 정책 default = `Cache-Control: no-store` (인증된 API 안전 default) · cacheable endpoint 만 controller annotation 으로 `private, max-age=N` opt-in · content negotiation 또는 인증 응답에 `Vary: Accept, Accept-Encoding, Authorization` 의무 | `raw/official-docs/rfc9111-http-caching.md#RFC9111-C1` (`no-store` MUST NOT store — directive normative 정의), `#RFC9111-C2` (`private` = shared cache MUST NOT store, single user), `#RFC9111-C3` (`public` = Authorization 있어도 shared cache 허용), `#RFC9111-C4` (`max-age` = stale 판정 초 수), `#RFC9111-C5` (Cache-Control 헤더 unidirectional 특성); `raw/official-docs/rfc9110-http-semantics.md#RFC9110-C18` (Vary header = response 의 어떤 부분이 content 선택에 영향을 줬는지 description — method/URI 외의 request 부분 명시) | `official-standard` (IETF RFC 9111 §5.2 + RFC 9110 §12.5.5) | proxy/CDN cache poisoning 방지가 본 결정의 운영상 motivation — RFC 9110 + 9111 은 *normative requirement* 를 제공하나 *기본값으로 `no-store` 를 권고* 한다는 진술은 표준 자체에 없음 (안전한 default 는 project-internal trade-off). Vary 가 *없으면* cache poisoning 가능성을 RFC9110-C18 가 의미론적으로 함의 — "MUST generate Vary" 의 명시적 진술은 별도 발췌 필요. cache layer 구현 자체는 [[raw/branch-notes/feature-cache-consistency-contract]] 책임 — 본 branch 는 HTTP header 정책만 |
|
||||
| D17 | Long-running operation (LRO) 응답 = 202 Accepted + `Location: /v1/operations/{id}` + envelope `data.{operationId,statusUrl}` · polling endpoint `GET /v1/operations/{id}` 의 `status` ∈ {PENDING, RUNNING, SUCCEEDED, FAILED, CANCELLED} | `raw/official-docs/google-aip-151-long-running-operations.md#AIP151-C1` (장시간 처리 메서드는 Operation 반환), `#AIP151-C4` (`done=false` 시 `name` MUST — polling 조건), `#AIP151-C3` (성공 완료 시 `response` 필드 필수), `#AIP151-C5` (실패 완료 시 `error` 필드 필수); HTTP 202 normative 의미는 `raw/official-docs/rfc9110-http-semantics.md#RFC9110-C22` (202 Accepted = processing 위해 accept, 완료 안 됨, intentionally noncommittal); polling interval 권고 `Retry-After` 는 `#RFC9110-C21` (server send Retry-After to indicate wait time). AIP-136 cross-ref: `raw/official-docs/google-aip-136-custom-methods.md#AIP136-C3` (`:cancel` 등 LRO 조작 custom method 는 side effect → `POST` MUST), `#AIP136-C5` (collection-scoped custom method 패턴 — `:batchCreate` 가 202 LRO 응답 반환 시 B18 과 연결) | `official-standard` (IETF RFC 9110 — 202 + Retry-After) + `official-reference` (Google AIP-151/136 — Operation shape + polling pattern, Google API community guideline; IETF/W3C 표준 아님) | 5종 enum 어휘 (PENDING/RUNNING/SUCCEEDED/FAILED/CANCELLED) 는 AIP-151 에 없음 — project-internal 매핑 (UNSUPPORTED_IMPL_DECISION 잔존). status enum 5종과 AIP-151 의 done/result/error 이진 모델 간 매핑은 project-internal 결정으로 남음. webhook callback 패턴은 별도 branch 신설 필요. `Location` header 의 정확한 형식 (`/v1/operations/{id}`) 은 RFC 9110 §10.2.2 별도 발췌 미진행 |
|
||||
| D23 (2026-05-31) | Bulk operation URL pattern = AIP-136 colon-verb (`POST /v1/{resource}:batchCreate`). request body `{ requests: [...] }`. **sync batch** = MUST atomic (한 항목 실패 → 전체 rollback + HTTP 4xx + envelope.success=false, partial failure 금지). **async batch** = 202 Accepted + `Location: /v1/operations/{id}` → polling endpoint 의 `data.result.results[]` 에 항목별 success/error (BATCH_PARTIAL_FAILURE 는 async polling 응답에서만 사용) | `raw/official-docs/google-aip-136-custom-methods.md#AIP136-C2` (URI MUST use `:` + custom verb), `#AIP136-C5` (collection-scoped custom method 패턴); `raw/official-docs/google-aip-233-batch-create.md#AIP233-C2` (HTTP verb MUST `POST`), `#AIP233-C3` (URI MUST end with `:batchCreate`), `#AIP233-C4` (request message MUST repeated field, SHOULD named `requests`), `#AIP233-C7` (sync batch create MUST atomic); D17 LRO 결합 — `raw/official-docs/google-aip-151-long-running-operations.md#AIP151-C1`~`C5` (async endpoint 의 Operation shape) + `raw/official-docs/rfc9110-http-semantics.md#RFC9110-C22` (202 Accepted) | `official-reference` (AIP-136 + AIP-233 + AIP-151) + `official-standard` (RFC 9110) + `cross-branch-SSOT` (foundation envelope) | UNSUPPORTED_IMPL_DECISION 잔존: (1) `data.results[]` REST envelope shape (항목별 success/error 구조) 은 boundary branch B14 (BulkEnvelope.partial) SSOT 의존. (2) sync batch atomic rollback 시 HTTP status (400 VALIDATION_FAILED vs 422 Unprocessable Entity vs 409 CONFLICT) 는 error-codes.yaml row 정합성으로 결정 (D11 mapping consistency contract test 가 강제) |
|
||||
| D19 | Resource URL naming convention = plural + lowercase + AIP-122 regex `[a-z][a-zA-Z0-9]*` · single-word `/v1/worklogs` · multi-word `lowerCamelCase` (`/v1/worklogComments`) · kebab-case / singular / CamelCase 모두 금지. **`{id}` placeholder 의 concrete format** = ULID 26-char Crockford base32 (`01ARZ3NDEKTSV4RRFFQ69G5FAV`) per [[raw/branch-notes/feature-resource-identifier-contract]] D1/D19 SSOT | `raw/official-docs/google-aip-122-resource-names.md#AIP122-C2` (collection segment plural rule), `#AIP122-C3` (collection segment lowercase + ASCII-only character set regex `[a-z][a-zA-Z0-9]*`). cross-cite [[raw/branch-notes/feature-resource-identifier-contract]] D1 (ULID 채택) + D19 (sample-portfolio `WorkLogId` fixture concrete value). cross-cite [[raw/branch-notes/feature-architecture-enforcement-rules]] (있다면 — controller mapping ArchUnit 강제 영역) | `official-reference` (Google AIP-122 — community guideline, IETF/W3C 표준 아님) + `cross-branch-SSOT` (resource-identifier branch D1/D19) | AIP-122 가 protobuf 컨텍스트 — REST URL path 매핑은 AIP-127 별도 cross-cite 필요 (현재 raw 미보관, future). 본 branch 의 `/v1/worklogs` 채택은 AIP122-C2/C3 가 *direct corroborate*. multi-word resource 의 lowerCamelCase 가 implementation 단계에서 hyphen 욕구와 충돌 가능 (예: `customer-orders` vs `customerOrders`) — 이 결정으로 후자만 허용 명시. `{id}` format 분리 SSOT 는 resource-identifier branch — 본 branch 는 URL 구조 (placeholder + path 패턴) 만 결정 |
|
||||
| D20 | Sort parameter syntax = Spring `Pageable` native `?sort=field,direction` · multi-sort = param repeat · 다른 syntax (`?sort=-foo` / `?sort=foo:desc` / `?order_by=foo desc`) 금지 | `raw/official-docs/spring-data-pageable-defaults.md#SPRING-PAGE-C1` (Pageable zero-indexed), `#SPRING-PAGE-C2` (size default 20), `#SPRING-PAGE-C3` (zero-indexed infrastructure); cross-cite `raw/official-docs/google-aip-132-list-method.md#AIP132-C4` (대안 syntax: `"foo desc, bar"` — 본 결정 미채택 근거, space encoding 부담 + Spring 자동 binding 깨짐) | `official-vendor-doc` (Spring Data Commons — D20 의 직접 근거) + `official-reference` (AIP-132 — 대안 비교용 cross-cite) | Spring `Pageable` 의 sort syntax 가 multi-sort 시 param repeat 인지 (별도 separator 인지) 검증 필요 — Spring `PageableHandlerMethodArgumentResolver` default 동작 vendor doc 추가 fetch 권고. JSON:API `?sort=-foo` prefix syntax 의 미채택 근거는 *Spring binding 부재* (project-internal trade-off — JSON:API 자체는 `official-standard`) |
|
||||
| D21 | Filter parameter syntax = flat key=value (equality only) · `?status=OPEN&owner=user123` 만 허용 · 복잡 filter (range / `in` / `like` / AND/OR 조합) 는 *out of scope* · AIP-160 DSL / RSQL / FIQL / JSON:API bracket 모두 미채택 | UNSUPPORTED_DECISION (project-internal trade-off — *minimalist default* + parsing/security 부담 회피). cross-cite `raw/official-docs/google-aip-160-filtering.md#AIP160-C1`~`C6` (대안 DSL *옵션 존재* 만 corroborate, 본 결정 미채택 근거: SQL injection 위험 + ergonomics 학습곡선 + Spring 자동 binding 부재) | UNSUPPORTED + `official-reference` (AIP-160 대안 cross-cite) | flat key=value 가 복잡 query 요구사항 발생 시 어떻게 확장할지의 *migration path* 가 본 결정에 없음 — 후속 결정으로 미룸. controller 가 명시적으로 받지 않는 query param 의 silent 무시 정책은 boundary branch 의 ACL mapper 책임 (cross-link 필요) |
|
||||
| D22 | Cursor pagination shape = opaque base64-encoded JSON token + HMAC signature + 24h TTL · cursor endpoint 는 별도 query param 또는 별도 path · client 의 token parse 금지 (opacity 강제) · cursor + `?page=N` 동시 사용 금지 | `raw/official-docs/google-aip-158-pagination.md#AIP158-C3` (page_token MUST NOT required + subsequent page_size 변경 MUST honor), `#AIP158-C5` (page token opaque + URL-safe MUST; base64 단독 불충분 → HMAC signature 추가 정당화) | `official-reference` (Google AIP-158 — opacity/URL-safe MUST normative) + UNSUPPORTED_IMPL_DECISION (24h TTL + HMAC algorithm 선택 + JSON shape 자체는 project-internal trade-off) | TTL 24h 의 정확한 숫자는 AIP-158 에 없음 (project-internal — 짧으면 long-running export 깨짐, 길면 DB layout 변경 시 stale cursor 문제). HMAC key rotation 정책은 별도 결정 (security branch 와 cross-link 필요). cursor endpoint URL 패턴 (`/v1/worklogs:listByCursor` colon-verb vs `?cursor=` query param) 미정 — 후속 D23 colon-verb 채택과 정합성 위해 colon-verb 권고 가능 (future revision) |
|
||||
| D24 | Response Date header = 모든 응답에 `Date` 헤더 자동 발행 (Spring/Tomcat default 활용, controller 별도 설정 불요) · Date header 명시적 비활성화 금지 | `raw/official-docs/rfc9110-http-semantics.md#RFC9110-C20` (sender 가 Date header 생성 시 best available approximation SHOULD) | `official-standard` (IETF RFC 9110 §6.6.1) | RFC 9110 SHOULD 권고만 — MUST 아님. Spring/Tomcat default 가 자동 발행하지만 controller 또는 filter 에서 강제 제거하는 경우 (테스트 reproducibility 또는 cache 제어 이유) 차단 의무. error response (404/500) 에서도 Date 발행 여부 검증 contract test 필요. 단 Date header 의 정확한 format (HTTP-date — §5.6.7) 검증은 별도 (Spring vendor 책임) |
|
||||
| D18 | Pagination — `page` 0-indexed (Spring `Pageable` 정합), `size` default 20 / max 100 / min 1 · `size > 100` 또는 `size < 1` 또는 `page < 0` 은 400 VALIDATION_FAILED · 빈 list 는 `data: []` (절대 `null` 아님) + `meta.page.total = 0` · 깊은 offset (`page > 10000`) 은 `Deprecation` 헤더 + cursor 권고 | `raw/official-docs/spring-data-pageable-defaults.md#SPRING-PAGE-C1` (`page` 파라미터 0-indexed, default 0), `#SPRING-PAGE-C2` (`size` 파라미터 default 20), `#SPRING-PAGE-C3` (Spring Data 레포지토리 infrastructure 의 `Pageable` 은 zero-indexed), `#SPRING-PAGE-C4` (`PageableHandlerMethodArgumentResolverSupport.DEFAULT_MAX_PAGE_SIZE = 2000` — Spring 기본 max 가 2000 이므로 project 의 100 cap 은 별도 opt-in override 임을 정당화), `#SPRING-PAGE-C5` (annotation 없는 fallback = `PageRequest.of(0, 20)`), `#SPRING-PAGE-C6` (`setOneIndexedParameters` default false → page 0 = first page). 추가 normative 근거: `raw/official-docs/google-aip-158-pagination.md#AIP158-C1` (collection pagination 처음부터 제공 필수, 나중 추가 = backwards-incompatible), `#AIP158-C2` (page_size server-side cap SHOULD coerce down; max 숫자는 server-defined — 100 은 project-internal), `#AIP158-C3` (page_token MUST NOT required; subsequent page_size 변경 MUST honor), `#AIP158-C4` (next_page_token empty = EoC MUST, 유일한 EoC 시그널), `#AIP158-C5` (page token opaque + URL-safe MUST; base64 단독 불충분). JSON:API JSONAPI-PAGE-C1~C6 모두 size cap 또는 index base 의 normative 진술 없음 — JSONAPI-PAGE-C6 가 pagination strategy 에 agnostic 임을 명시. `size` max 100 cap 및 min 1 / `page < 0` 400 처리는 project-internal DoS prevention trade-off (UNSUPPORTED_IMPL_DECISION 잔존 — Spring 기본값 이하 추가 제한) | `official-vendor-doc` (Spring Data Commons — SPRING-PAGE-C1~C6) + `official-reference` (AIP-158 — AIP158-C1~C5) + UNSUPPORTED_IMPL_DECISION (`size` 100 cap / min 1 / 깊은 offset threshold 10000 은 project-internal 숫자; size>100 을 AIP-158 권고 coerce 대신 400-reject 강화도 project-internal) | 가장 critical footgun 결정 — `size=10000000` DoS 방지 + 0/1-indexed Spring 정합. Spring default max 는 2000 이지 Integer.MAX_VALUE 가 아님 (C4 보정). 깊은 offset 의 cursor 권고는 본 branch 가 박지만 cursor endpoint 의 shape (opaque token encoding/TTL) 결정은 future B16 — sibling 또는 후속 작업 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> *결정 (Decisions)* 이 "*무엇* 을 할 것인가" 라면, 본 §는 "*어디에 어떻게* 구현될 것인가" 의 사전 명세 — *문서가 모호해서 구현자가 임의로 정해야 했던 결정* 카탈로그. 작성 목표는 다음 구현자가 *되묻지 않아도 코드를 작성할 수 있는 수준*.
|
||||
>
|
||||
> **3-rule meta principle (필수 준수)**:
|
||||
>
|
||||
> 1. **R1. Reference 필수** — 각 sub-section / row / cell 은 본 branch 의 `Decision ID` (예: D1, D2) + 그 결정의 `Supporting Claim ID` (예: `RFC9110-C5`) 를 reference.
|
||||
> 2. **R2. UNSUPPORTED_IMPL_DECISION 명시** — 근거 raw 가 *원칙* 만 권고하고 *detail* 은 권고하지 않는 cell 은 `UNSUPPORTED_IMPL_DECISION` 라벨 + 사용자 trade-off 근거 한 줄.
|
||||
> 3. **R3. OUT_OF_BRANCH_SCOPE 정제** — 본 branch 결정 범위 밖 cell 은 §구현 가이드에 *남기지 않음*. 별도 branch 또는 canonical SSOT 로 이관.
|
||||
|
||||
### 1. Work Item Contract (TODO → canonical 승급 판정 단위)
|
||||
|
||||
> **Trace**: 본 sub-section 은 branch 의 *모든* TODO 가 canonical 승급 가능한 형태로 정제되어야 한다는 project-wide 메타 규약. ca-skeleton operational contract §23 Branch Canonical Promotion Criteria 와 정합.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 본 표는 project 공통 메타 규약 — 본 branch 의 외부 표준 직접 근거 영역 밖.
|
||||
|
||||
각 TODO 는 아래 판정 단위로 재작성되어야 canonical 승급 가능. TODO 가 단순히 `기준 작성` 으로 남아 있으면 branch 완료로 보지 않는다.
|
||||
|
||||
| field | required | rule |
|
||||
| --- | --- | --- |
|
||||
| Decision | yes | 구현자가 선택해야 하는 기본값 |
|
||||
| Allowed | yes | 허용되는 예외와 조건 |
|
||||
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
|
||||
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
|
||||
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
|
||||
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
|
||||
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
|
||||
|
||||
### 2. Decisionized Work Items (결정의 implementation matrix)
|
||||
|
||||
> **Trace**: D2 (versioning, AIP185-C1~C3) · D7 + D18 (pagination, JSONAPI-PAGE-C1~C3 + SPRING-PAGE-C1~C6 + AIP158-C1~C5) · D3 + D4 (idempotency header, STRIPE-IDEMP-C1 + IETF-IDEMP-C1 + TOSS-IDEMP-C1) · D8 (request size, RFC9110-C5/C6) · D8 형제 (URI length, RFC9110-C19) · D9 (content negotiation, RFC9110-C4/C7/C8) · D12 (405 + Allow, RFC9110-C9/C10) · D14 (PATCH, RFC7396-C1/C2/C3/C5) · D13 (HEAD/OPTIONS, RFC9110-C11/C12 + FETCH-CORS-C2) · D15 (conditional request, RFC9110-C13~C17) · D16 (cache policy + Vary, RFC9111-C1~C5 + RFC9110-C18) · D17 (LRO, AIP151-C1~C7 + AIP136-C3/C5 + RFC9110-C21/C22) · D11 (HTTP status mapping SSOT, project-internal — UNSUPPORTED_DECISION 잔존) · D10 (OpenAPI producer, OPENAPI31-C2/C4).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**:
|
||||
> - pagination row 의 `size` default 20 / max 100 / min 1 / `page > 10000` threshold 의 정확한 *숫자* 는 project-internal trade-off (DoS prevention + UX). 대안: max 50 / max 200 — 외부 표준은 숫자 미정. 본 branch 가 *안전한 default* 로 100 채택.
|
||||
> - URI length row 의 Tomcat `maxHttpHeaderSize` 기본 8KB threshold 는 server vendor (Tomcat) default — 다른 server (Undertow/Netty) 면 다름. 본 branch 는 *Tomcat 기준 default* 만 명시, 다른 server 채택 시 별도 결정.
|
||||
> - **`UNSUPPORTED_IMPL_DECISION` (D17 LRO)**: polling status enum 5종 (PENDING/RUNNING/SUCCEEDED/FAILED/CANCELLED) 은 AIP-151 에 *없음* — AIP-151 의 `done`/`response`/`error` 이진 모델에서 project-internal 파생. 매핑: `PENDING`=accepted+미시작, `RUNNING`=`done=false`+진행중, `SUCCEEDED`=`done=true`+`response`(AIP151-C3), `FAILED`=`done=true`+`error`(AIP151-C5), `CANCELLED`=`done=true`+cancelled error. polling endpoint URL `/v1/operations/{id}` 형식도 project-internal (`AIP151-C4` 는 `name` MUST 만 요구, REST `Location` 매핑은 RFC 9110 §10.2.2 별도 발췌 미진행 — Should-fix). trade-off: 5-state 가 client 에 명시적 진행 단계를 제공하나 AIP-151 이진 모델보다 표면이 넓음(어휘 drift 위험은 §3 LRO contract test 로 차단).
|
||||
> - PATCH row 의 RFC 6902 (JSON Patch) endpoint 옵트인 *경로 명명* (예: `PATCH /v1/worklogs/{id}` Content-Type 분기 vs 별도 path) 미정.
|
||||
|
||||
| item | Decision | Allowed | Forbidden | Required test | Failure condition |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| versioning | `/v1` URI prefix | `X-Api-Version` supplemental header | media-type/header/path version 혼용 | OpenAPI path version test | version 없는 public endpoint |
|
||||
| pagination | `page` 0-indexed (Spring `Pageable` 정합), `size` default 20 / max 100 / min 1, `sort` request + `meta.page` response (`number`, `size`, `total`, `sort`) | cursor pagination은 별도 endpoint에서만 + 깊은 offset (`page > 10000`) 은 `Deprecation` 헤더 + cursor 권고 | pagination metadata in `data` · `size > 100` · `page < 0` 통과 · 빈 list 가 `data: null` | response meta contract + size cap boundary test + empty list shape test | list response에 page metadata 누락 또는 `size=10000` 통과 |
|
||||
| idempotency header | POST 등 non-idempotent method 에 `Idempotency-Key` 만 적용 (GET/HEAD/PUT/DELETE 는 의미 없음) | optional 표시 가능하나 server 가 무시 | GET/HEAD/PUT/DELETE 에 idempotency key replay semantics 강제 | replay contract | duplicate write on retry · GET 에 replay 의미 부여 |
|
||||
| request size | app limit maps to `VALIDATION` or `RATE_LIMIT` style envelope per owner branch + 일시적이면 `Retry-After` 헤더 (RFC9110-C6) | gateway pre-reject may bypass app envelope with documented log correlation | raw 500 for 413 | oversized request contract | payload too large가 raw server error |
|
||||
| URI length | URL+query 길이 초과는 414 URI Too Long + envelope 따름 | gateway-level reject 시 envelope 우회 가능 (log correlation 필수) | raw 500 또는 400 으로 변환 | URI length boundary test (Tomcat `maxHttpHeaderSize` 기본 8KB 초과) | 414 가 raw server error 또는 잘못된 400 |
|
||||
| content negotiation | unsupported media type and not acceptable use distinct codes | gateway-owned negotiation if documented | 415/406 same error code | MVC exception mapping | 415/406 분류가 같음 |
|
||||
| method not allowed | 405 + `Allow` header (지원 method comma-separated) + envelope 따름 | gateway pre-reject 시 envelope 우회 가능 | 405 응답에 `Allow` 누락 · Spring `HttpRequestMethodNotSupportedException` envelope 우회 직접 응답 | 405 contract test (DELETE-only endpoint 에 GET 요청 → 405 + `Allow: DELETE` + envelope) | `Allow` 누락 |
|
||||
| method-PATCH | PATCH default media type = `application/json` · request shape = `JsonNullable<T>` / `Optional<T>` wrapper · absent/null/value 3-상태 구분 (boundary branch B2 SSOT) | 없음 — `application/merge-patch+json` (RFC 7396) / `application/json-patch+json` (RFC 6902) 모두 **금지** | `application/merge-patch+json` / `application/json-patch+json` content type 사용 · absent vs null 미구분 (`null` 이 absent 와 동일 의미로 처리됨) | `no_merge_patch_json_media_type_string` ArchUnit rule (boundary branch B2) + PATCH absent/null 3-상태 mapper contract test | PATCH endpoint 가 `application/merge-patch+json` content type 허용 · Java record canonical constructor 가 absent 와 null 을 같은 기본값으로 수렴 |
|
||||
| HEAD support | GET 지원 endpoint 는 HEAD MUST (Spring MVC 자동 처리) | OPTIONS 분기: CORS preflight (envelope 우회, security branch SSOT) / resource metadata (envelope 따름) | GET-only endpoint 에 HEAD 가 405 또는 404 | HEAD-mirror-GET contract test | HEAD 미지원 |
|
||||
| conditional request | read 응답에 `ETag` 발행 (version field 기반 또는 content hash) · write 의 `If-Match` mismatch → 412 + envelope · read 의 `If-None-Match` match → 304 (body 없음, envelope 우회) | write 의 `If-Match` 누락 *허용* (sample-portfolio fixture 에서 *권장* 패턴 검증) | `ETag` 미발행 · 412 가 raw 500 또는 409 로 매핑 · 304 에 body 동봉 | conditional request matrix test (4 시나리오) | 412/304 잘못 매핑 |
|
||||
| response cache policy | 모든 응답 default `Cache-Control: no-store` · content negotiation 또는 인증 응답에 `Vary: Accept, Accept-Encoding, Authorization` 의무 | cacheable endpoint 만 controller annotation 으로 `Cache-Control: private, max-age=N` opt-in | 인증 응답에 `public` Cache-Control · `Vary` 누락 | Cache-Control default test + Vary header presence test | 인증 응답이 public cacheable |
|
||||
| long-running operation | 202 Accepted + `Location: /v1/operations/{id}` + envelope `data.{operationId,statusUrl}` · polling `GET /v1/operations/{id}` 의 `status` ∈ {PENDING,RUNNING,SUCCEEDED,FAILED,CANCELLED} | `Retry-After` 헤더로 polling interval 권고 | 비동기 endpoint 가 sync-pretend 로 long-wait + timeout | LRO contract test (202 + Location + polling status transition) | 비동기 endpoint 가 동기 timeout 으로 응답 |
|
||||
| HTTP status mapping SSOT | `error-codes.yaml` 의 `http_status` column 이 모든 매핑의 SSOT (registry §21, owner: foundation branch) | 본 branch 는 mapping consistency contract test 의 producer | controller 가 registry 와 다른 HTTP status 반환 | status mapping consistency test (모든 `error.code` row 에 대해 실제 응답의 HTTP status 가 registry 와 일치) | registry-controller drift |
|
||||
| OpenAPI producer | generated OpenAPI snapshot produced by this branch | external openapi generator allowed | manual stale schema only | verification drift check | schema/response mismatch passes |
|
||||
| resource URL naming (D19) | plural + lowercase + AIP-122 regex `[a-z][a-zA-Z0-9]*` (single-word: `/v1/worklogs`, multi-word: `/v1/worklogComments` lowerCamelCase) | sub-resource path 허용 (`/v1/worklogs/{id}/comments`), custom method 의 colon-verb suffix 허용 (`/v1/worklogs:batchCreate`) | singular path (`/v1/worklog/{id}`) · kebab-case (`/v1/worklog-comments`) · CamelCase (`/v1/Tickets`) · UPPER_CASE | ArchUnit 또는 Spring controller mapping inspector — 모든 `@RequestMapping` path segment 가 AIP-122 regex 매치 검증 | path segment 가 regex 위반 |
|
||||
| sort syntax (D20) | Spring `Pageable` native `?sort=field,direction` · multi-sort = param repeat | reverse direction 명시 (`,desc` 필수, 생략 시 default `asc`) | `?sort=-foo` (JSON:API), `?sort=foo:desc`, `?order_by=foo desc` (AIP-132 space) | sort syntax contract test (각 endpoint 의 `?sort=createdAt,desc` 정상 + `?sort=-createdAt` 거부) | non-Spring syntax 통과 |
|
||||
| filter syntax (D21) | flat key=value (equality only) (`?status=OPEN&owner=user123`) | controller 가 명시적으로 받지 않는 query param 은 silently 무시 (boundary branch 의 ACL mapper 책임) | AIP-160 DSL · RSQL/FIQL · JSON:API bracket (`?filter[key]=value`) · 복잡 expression (`?filter=status==OPEN AND priority>3`) | filter syntax contract test (각 list endpoint 의 `?status=OPEN` 정상 + `?filter=...` DSL 무시 또는 거부) | DSL syntax 가 controller 에서 parsing 시도 |
|
||||
| cursor pagination shape (D22) | opaque base64-encoded JSON token + HMAC signature + 24h TTL · cursor endpoint 는 별도 query param (`?cursor=<token>&size=20`) 또는 별도 path | size cap (D18) 동일 적용 · token 만료 시 400 VALIDATION_FAILED + 권장: 첫 페이지 재요청 | typed cursor (last value 노출) · unsigned token (tamper risk) · cursor + `?page=N` 동시 사용 · TTL 무한 | cursor token roundtrip test + tamper detection test + TTL expiration test + opacity 검증 (client parse 가능하면 실패) | typed/unsigned/no-TTL token |
|
||||
| bulk operation URL (D23) | AIP-136 colon-verb `POST /v1/{resource}:batchCreate` · request body `{ requests: [...] }` · **sync batch** = atomic MUST (한 항목 실패 → 전체 rollback + HTTP 4xx + envelope.success=false) · **async batch** = 202 + Location → polling endpoint 의 `data.result.results[]` 에 항목별 success/error (BATCH_PARTIAL_FAILURE 는 async polling 에서만) | async batch 의 polling endpoint 가 D17 LRO pattern 따름 + BATCH_PARTIAL_FAILURE category 적용 | sync batch 에서 partial failure 허용 (AIP233-C7 위반) · flat array body · kebab subpath · sync batch 의 atomic rollback 누락 | bulk endpoint contract test (sync: atomic rollback 검증 · async: 202+polling+partial result 검증) | sync batch 가 partial success 응답 · BATCH_PARTIAL_FAILURE 가 sync 응답에 사용됨 |
|
||||
| response Date header (D24) | 모든 응답에 `Date` 헤더 자동 발행 (Spring/Tomcat default) | local profile 에서 fixed clock 으로 테스트 reproducibility 확보 가능 | `server.servlet.dispatchOptionsRequest=false` 같은 Date 비활성화 옵션 · 404/500 등 error path 에서 Date 누락 | Date header presence contract test (전체 status code matrix — 200/204/400/404/500) | Date header 누락 |
|
||||
|
||||
### 3. Test Contract (테스트 계약 — 결정 위반 감지 trigger)
|
||||
|
||||
> **Trace**: 본 sub-section 은 §2 Decisionized Work Items 의 `Required test` column 을 *그대로 펼쳐 쓴 catalog*. 각 라인은 §2 의 특정 row + Decision ID 와 1:1 매핑.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 본 sub-section 은 §2 의 직접 도출 — 별도 임의 결정 없음.
|
||||
|
||||
- OpenAPI schema와 실제 response envelope가 다르면 실패 (D10).
|
||||
- `/v1` prefix 없는 public API가 추가되면 실패 (D2).
|
||||
- pagination 응답에 page/size/total/sort 기준이 없으면 실패 (D7).
|
||||
- pagination 의 `size > 100` 또는 `size < 1` 또는 `page < 0` 이 통과하면 실패 (D18 boundary test).
|
||||
- 빈 list 응답이 `data: null` 이거나 `meta.page.total` 누락이면 실패 (D18 empty list shape test).
|
||||
- unsupported media type과 not acceptable이 같은 code로 뭉개지면 실패 (D9).
|
||||
- oversized request가 raw server error로 변환되면 실패 (D8).
|
||||
- URI 길이 초과 (Tomcat `maxHttpHeaderSize` 기본 8KB 초과) 가 raw 500 또는 잘못된 400 으로 매핑되면 실패 (D8 형제).
|
||||
- 405 응답에 `Allow` header 가 없거나 envelope 우회로 직접 응답하면 실패 (D12).
|
||||
- GET 지원 endpoint 가 HEAD 요청에 405/404 응답하면 실패 (D13 HEAD-mirror-GET test).
|
||||
- PATCH endpoint 가 `application/merge-patch+json` 또는 `application/json-patch+json` content type 을 허용하면 실패 — boundary branch B2 의 ArchUnit `no_merge_patch_json_media_type_string` 으로 build 차단 (content-type test).
|
||||
- PATCH 요청 mapper 가 absent (JSON 에 키 자체 부재) 와 null (명시적 `null` 값) 을 같은 기본값으로 수렴하면 실패 — `JsonNullable<T>` / `Optional<T>` wrapper 검증 (D14 absent/null/value 3-상태 mapper contract test).
|
||||
- write 응답에 `ETag` header 가 없거나 `If-Match` mismatch 시 412 가 아닌 409/500 으로 매핑되면 실패 (D15 conditional request matrix test).
|
||||
- `If-None-Match` match 시 304 응답에 body 가 동봉되면 실패 (D15 cache validation test).
|
||||
- 인증된 응답 default 가 `Cache-Control: no-store` 가 아니거나 content-negotiated 응답에 `Vary` header 가 없으면 실패 (D16 cache policy test).
|
||||
- 비동기 endpoint 가 202 + `Location` + envelope `data.{operationId,statusUrl}` 형식이 아니거나 polling endpoint 의 `status` 가 enum 어휘 밖이면 실패 (D17 LRO test).
|
||||
- `error-codes.yaml` 의 임의의 row 에 대해 실제 controller 응답의 HTTP status 가 row 의 `http_status` column 과 다르면 실패 (D11 mapping consistency test, registry 와 controller 의 drift 감지).
|
||||
- controller `@RequestMapping` path segment 가 AIP-122 regex `[a-z][a-zA-Z0-9]*` 를 위반 (kebab-case, singular, CamelCase) 하면 실패 (D19 URL naming convention ArchUnit test).
|
||||
- `?sort=-foo` 또는 `?sort=foo:desc` 같은 non-Spring-Pageable sort syntax 가 controller 에서 정상 처리되면 실패 (D20 sort syntax contract test).
|
||||
- list endpoint 에 AIP-160 DSL (`?filter=status==OPEN`) 또는 JSON:API bracket (`?filter[status]=OPEN`) 이 통과하면 실패 (D21 filter syntax contract test — flat key=value 만 허용).
|
||||
- cursor token 이 typed (last field value 노출) · unsigned (tamper 가능) · TTL 없음 (영구 유효) 중 하나면 실패 (D22 cursor shape contract test — opacity/integrity/TTL 3개 invariant).
|
||||
- sync bulk endpoint 가 atomic 이 아니거나 (한 항목 실패 시 전체 rollback 안 됨), partial failure 응답을 sync 에서 반환하거나, async bulk endpoint 가 202+Location+polling pattern 이 아니거나, BATCH_PARTIAL_FAILURE category 가 sync 응답에 사용되면 실패 (D23 contract test — AIP233-C7 정합).
|
||||
- 모든 응답 (success/error 무관, status code 200/204/400/404/500 매트릭스) 에 `Date` 헤더가 없으면 실패 (D24 Date header presence test).
|
||||
|
||||
### 4. Cross-branch Contract Map (본 branch 의 owner/consumer/producer role)
|
||||
|
||||
> **Trace**: 본 sub-section 은 project-note §25 SSOT Owner Map 의 *본 branch 관련 row 의 역 인덱스*. cross-branch 결정 정합성 깨짐을 추적하기 위함.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 본 sub-section 은 cross-branch 관계의 *기록* 일 뿐 본 branch 의 외부 표준 직접 근거 영역 밖.
|
||||
> - **OUT_OF_BRANCH_SCOPE 정리**: `consumer only` 로 표시된 영역은 *결정 자체* 는 다른 branch 가 소유. 본 branch 는 *cross-cite* 만 — 결정 변경 시 owner branch 를 통해야 함.
|
||||
|
||||
| 영역 | 본 branch 의 role | counterpart owner | 의존 방향 |
|
||||
|---|---|---|---|
|
||||
| API versioning (`/v1` URI prefix) | **owner** (D2, D6) | (consumer) `feature-api-compatibility-deprecation-contract` — `/v1` deprecation 시 Sunset/Deprecation header 발행 | 본 branch → compatibility branch |
|
||||
| HTTP header naming + headers.yaml (registry §21) | **owner** (cross-owner: idempotency, tracing, tenant, compat, security 모두 cross-cite) | (consumers) tracing/tenant/security/compat 모든 branch | 본 branch ← multiple branches |
|
||||
| HTTP status ↔ envelope `error.code` 매핑 (D11) | **producer** of mapping consistency contract test | **owner** of registry: `feature-operational-error-observability-foundation` (`error-codes.yaml`) | 본 branch ← foundation branch |
|
||||
| envelope schema (`success`/`data`/`error`/`meta`) | **consumer only** | **owner**: `feature-operational-error-observability-foundation` | 본 branch ← foundation |
|
||||
| `Idempotency-Key` header *이름* (D3) | **owner** (header name 만) | **owner** of key shape/scope/replay: `feature-rate-limit-idempotency-contract` | 본 branch → rate-limit branch (header name produces, key shape consumes) |
|
||||
| `Idempotency-Key` key shape `(principal, key, useCase)` + replay semantics | **consumer only** | **owner**: `feature-rate-limit-idempotency-contract` | 본 branch ← rate-limit branch |
|
||||
| OpenAPI snapshot 생성 (D10) | **producer** | **owner** of drift gate: `feature-contract-verification-test-suite` | 본 branch → verification suite |
|
||||
| Pagination / sorting / filtering shape (D7, D18) | **owner** (`page`/`size`/`sort` + `meta.page`) | (no counterpart — leaf) | — |
|
||||
| PATCH semantics (D14 정정 후) | **consumer** (content type 정책만 producer — `application/json` only) | **owner**: `feature-boundary-validation-mapping-contract` B2 (RFC 7396 미채택 + absent/null/value 3-상태 mapper + ArchUnit `no_merge_patch_json_media_type_string` enforced) | 본 branch ← boundary branch SSOT |
|
||||
| Conditional request (`ETag`/`If-Match`/412/304) (D15) | **owner** (HTTP layer) | **consumer**: sample-portfolio fixture (`WorkLogVersion` 이 ETag derivation 의 source) | 본 branch → sample-portfolio fixture |
|
||||
| 405 + `Allow` header (D12) | **owner** | (no counterpart — leaf) | — |
|
||||
| HEAD/OPTIONS support (D13) | **owner** (HEAD 부분) · **consumer** (OPTIONS preflight 분기) | **owner** of CORS: [[raw/branch-notes/feature-security-operational-baseline]] (D9) | 본 branch ← security branch (preflight bypass 결정) |
|
||||
| Response cache policy + `Vary` header (D16) | **owner** (HTTP header 정책) | **owner** of cache layer 구현: `feature-cache-consistency-contract` | 본 branch → cache branch (header policy produces, cache 구현 consumes) |
|
||||
| Long-running operation (LRO) 응답 패턴 (D17) | **owner** (polling-only LRO) | (no current counterpart — webhook callback 은 별도 branch 신설 필요) | — |
|
||||
| Field naming case (camelCase) | **consumer only** | **owner**: `feature-schema-serialization-contract` | 본 branch ← schema-serialization |
|
||||
| date/time/decimal serialization | **consumer only** | **owner**: `feature-schema-serialization-contract` | 본 branch ← schema-serialization |
|
||||
| `Server` / `X-Powered-By` header suppression | **consumer only** (forbid 명시) | **owner**: `feature-security-operational-baseline` | 본 branch ← security branch |
|
||||
| `X-HTTP-Method-Override` forbid | **consumer only** | **owner**: security branch (예정) | 본 branch ← security branch |
|
||||
| `Accept-Encoding` / response compression | **out of scope** | reverse proxy/gateway 책임 (운영 영역) | — |
|
||||
| `Accept-Language` / error message i18n | **out of scope** | 결정 미정 (future) | — |
|
||||
| Resource URL naming (D19) | **owner** (AIP-122 plural+lowercase regex) — URL 구조만 | (consumer) ArchUnit/architecture branch — controller mapping 검증. `{id}` placeholder format SSOT = [[raw/branch-notes/feature-resource-identifier-contract]] D1 (ULID) | 본 branch → architecture branch ← resource-identifier branch (`{id}` format) |
|
||||
| Sort parameter syntax (D20) | **owner** (Spring `Pageable` native) | (consumer) `feature-schema-serialization-contract` (field name case 정합) | 본 branch ↔ schema-serialization |
|
||||
| Filter parameter syntax (D21) | **owner** (flat key=value default) | (no counterpart — leaf, 복잡 filter는 future branch) | — |
|
||||
| Cursor pagination shape (D22) | **owner** (opaque base64 + HMAC + 24h TTL) | (consumer) `feature-security-operational-baseline` (HMAC key rotation 정책 cross-link 필요) | 본 branch → security branch |
|
||||
| Bulk operation URL (D23) | **owner** (AIP-136 colon-verb + AIP-233 sync MUST atomic + async LRO 결합) | (consumer) `feature-boundary-validation-mapping-contract` B14 (BulkEnvelope.partial — async polling 응답 영역만), [[raw/branch-notes/feature-operational-error-observability-foundation]] (BATCH_PARTIAL_FAILURE — async polling 에서만 사용); D17 LRO 결합 (async batch 의 polling endpoint) | 본 branch → boundary + foundation · 본 branch internal cross-cite (D23 ↔ D17) |
|
||||
| Response Date header (D24) | **owner** (Spring/Tomcat default 활용) | (no counterpart — leaf) | — |
|
||||
| Resource ID format (UUID / ULID / opaque) | **out of scope** | 기존 owner [[raw/branch-notes/feature-resource-identifier-contract]] D1/D19의 ULID 결정을 소비하고 본 branch는 URL placeholder만 연결 | 본 branch → resource-identifier branch |
|
||||
| Webhook outbound contract | **out of scope** | 별도 branch 신설 필요 (예정) | — |
|
||||
| SSE / WebSocket / streaming | **out of scope** | 별도 branch (예정, 현재 ca-skeleton 미지원) | — |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4(깊이 게이트) 캡처용. 정상 경로 외에 *구현 중 부딪힐* 실패/엣지/다른 계약 의존을 한 곳에 열거. (§3 Test Contract·§4 Cross-branch Contract Map·§Claims To Verify 에 분산된 것을 R4 형식으로 통합 — 새 결정 없음. 각 항목은 Decision ID reference.)
|
||||
|
||||
- **실패·엣지 경로** (기대 동작은 §3 Test Contract; 위반 = 계약 실패):
|
||||
- **oversized request (413) / URI 길이 초과 (414)** — raw 500 금지, envelope 따름. gateway pre-reject 시에만 envelope 우회 + log correlation 필수. (D8 / D8 형제 — RFC9110-C5/C6/C19)
|
||||
- **content negotiation 406 vs 415** — 동일 error code 로 뭉개면 실패(distinct). (D9)
|
||||
- **405 method not allowed** — `Allow` 헤더 누락 또는 Spring `HttpRequestMethodNotSupportedException` 가 envelope 우회 직접 응답하면 실패. (D12)
|
||||
- **PATCH absent/null/value footgun** — Java record canonical constructor 가 absent(키 부재)와 null(명시적 clear)을 같은 기본값으로 수렴하면 실패. `JsonNullable<T>`/`Optional<T>` wrapper 로 3-상태 구분(boundary B2 SSOT). content type 은 `application/json` 만 — merge-patch/json-patch 금지. (D14)
|
||||
- **conditional request** — `If-Match` mismatch 를 409/500 으로 매핑하면 실패(→ 412), `If-None-Match` match 304 에 body 동봉하면 실패. (D15)
|
||||
- **pagination footgun** — `size > 100` / `size < 1` / `page < 0` 통과하거나 빈 list 가 `data: null` 이면 실패(`data: []` + `meta.page.total=0`). Spring default max 가 2000(≠Integer.MAX_VALUE)이라 project cap 100 은 별도 opt-in override. (D18 — SPRING-PAGE-C4)
|
||||
- **cursor token** — typed(값 노출)/unsigned(tamper)/no-TTL 중 하나면 실패(opacity+HMAC+24h TTL 3-invariant). (D22)
|
||||
- **LRO 비동기 endpoint** — sync-pretend long-wait/timeout 으로 응답하면 실패(202 + `Location` + polling). (D17)
|
||||
- **cache poisoning** — content-negotiated/인증 응답에 `Vary` 누락 또는 인증 응답이 `public` cacheable 이면 실패(default `no-store`). (D16)
|
||||
- **bulk** — sync batch 가 atomic 아니거나 partial failure 를 sync 응답에 반환, 또는 `BATCH_PARTIAL_FAILURE` 가 sync 응답에 쓰이면 실패. (D23 — AIP233-C7)
|
||||
|
||||
- **다른 계약 의존** (해당 계약이 바뀌면 본 branch 영향 — §4 Cross-branch Contract Map 의 consumer 방향 압축):
|
||||
- [[raw/branch-notes/feature-operational-error-observability-foundation]] 의 `error-codes.yaml`(D11) + envelope schema 에 의존 — registry `http_status` column 이 status 매핑 SSOT. **(엣지) `error-codes.yaml` 미존재 또는 row 누락 시 D11 mapping consistency contract test 동작**: registry 가 존재하는데 row 가 빠진 경우 = test FAIL(drift). registry 자체가 아직 생성 전인 현재 documented-only 단계에서는 D11 test 가 `planned`(비활성) — **registry 생성이 D11 test 활성화의 선행 조건**이며, registry 부재를 SKIP(통과)으로 처리하면 안 됨.
|
||||
- [[raw/branch-notes/feature-boundary-validation-mapping-contract]] 의 `B2`(PATCH mapper, D14) + `B14`(BulkEnvelope.partial) 에 의존. **(엣지) B14 미완 시 D23 async batch 구현은 blocked**: async polling 응답의 `data.result.results[]` 항목별 success/error shape 이 B14 SSOT 의존 → B14 결정 전까지 async batch + `BATCH_PARTIAL_FAILURE` 는 미구현 보류. **단 sync batch(atomic all-or-nothing)는 B14 무관하게 독립 진행 가능**.
|
||||
- [[raw/branch-notes/feature-rate-limit-idempotency-contract]] 에 의존(D3/D4) — `Idempotency-Key` key shape/scope/replay semantics owner. 본 branch 는 header *이름* 만.
|
||||
- [[raw/branch-notes/feature-security-operational-baseline]] 에 의존 — CORS preflight envelope 우회(D13), cursor HMAC key 소유/rotation(D22 — **Should-fix #5: 해당 Decision ID 미인용, 미결**), `Server`/`X-Powered-By` suppression·`X-HTTP-Method-Override` forbid.
|
||||
- [[raw/branch-notes/feature-schema-serialization-contract]] 에 의존 — envelope `meta.*` camelCase(D16) + sort field name case(D20).
|
||||
- [[raw/branch-notes/feature-cache-consistency-contract]] 에 의존 — cache layer 구현(D16 header policy 만 producer).
|
||||
- [[raw/branch-notes/feature-api-compatibility-deprecation-contract]] 에 의존 — `/v1` deprecation Sunset/Deprecation 헤더(D2) + 깊은 offset `Deprecation` 헤더 형식(D18 — **Advisory #8: 발행 메커니즘 owner 미확정**).
|
||||
- [[raw/branch-notes/feature-contract-verification-test-suite]] 에 의존 — OpenAPI drift release-gate(D5/D10, 본 branch 는 producer).
|
||||
- [[raw/branch-notes/feature-resource-identifier-contract]] 에 의존 — `{id}` ULID format(D19, 본 branch 는 URL 구조만).
|
||||
- [[raw/branch-notes/feature-architecture-enforcement-rules]] 에 의존 — controller mapping ArchUnit 강제 영역(D19 URL naming).
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서/사례는 근거지만 내 프로젝트에서의 동작을 자동 보장하지 않는다. 구현 전/중/후 실제로 검증해야 하는 주장.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| `Idempotency-Key` 헤더가 모든 POST endpoint 에 실제로 노출되는지 (OpenAPI snapshot 기준) | header 채택 결정은 design 단계, OpenAPI snapshot 에 반영됐는지 별도 확인 필요 | `openapi.yaml` snapshot grep 또는 contract test 로 모든 POST operation 에 `Idempotency-Key` parameter 존재 검증 | `planned` |
|
||||
| `/v1` URI prefix 가 모든 public endpoint 에 적용되는지 | versioning 결정과 실제 controller mapping 의 drift 가능성 | ArchUnit / Spring controller mapping inspector 로 `RequestMapping` prefix 검증 | `planned` |
|
||||
| pagination response 가 `meta.page` 표준 shape 와 일치하는지 | request param 처리는 검증 가능하지만 response envelope 의 일관성은 별도 contract test 필요 | response envelope contract test (모든 list endpoint 응답에 `meta.page.{number,size,total,sort}` 존재) | `planned` |
|
||||
| 415 (Unsupported Media Type) 과 406 (Not Acceptable) 가 distinct error code 로 매핑되는지 | Spring `HttpMediaTypeNotSupportedException` / `HttpMediaTypeNotAcceptableException` 가 동일 핸들러로 뭉개질 위험 | MVC exception 매핑 test (각 예외별 distinct error code 검증) | `planned` |
|
||||
| OpenAPI snapshot 과 실제 response envelope 의 drift 가 release-blocking 으로 감지되는지 | producer 와 verification suite 의 결합 정합성 검증 필요 | CI gate 의 `openapi-diff` 단계가 mismatch 시 build fail 시키는지 dry-run | `needs-confirmation` |
|
||||
| oversized request (413) 가 envelope 안의 VALIDATION / RATE_LIMIT category 로 매핑되는지 | Tomcat/Spring 의 기본 413 응답이 envelope 우회 가능성 | request size limit 초과 request 의 응답 body 가 envelope shape 인지 contract test | `planned` |
|
||||
| 405 응답에 `Allow` header 가 항상 포함되고 envelope shape 인지 (D12) | Spring `HttpRequestMethodNotSupportedException` 의 기본 처리가 envelope 우회 가능성 | DELETE-only endpoint 에 GET 보내고 응답 검증: status 405, `Allow: DELETE`, envelope `error.code` 존재 | `planned` |
|
||||
| GET 지원 endpoint 가 HEAD 요청에 body=0 으로 동일 status 반환하는지 (D13) | Spring MVC 자동 처리 여부 의존 | sample-portfolio `GET /v1/worklogs/{id}` 에 HEAD 요청 → 200 + Content-Length 일치 + body 빈 응답 | `planned` |
|
||||
| OPTIONS preflight 가 envelope 우회하고 직접 응답하는지 (D13 CORS 분기) | CORS 정책 본 branch 가 아닌 security branch 가 owner — 정합성 확인 필요 | OPTIONS 요청에 envelope 응답이 떨어지면 실패 (CORS preflight 는 envelope 미적용) | `needs-confirmation` |
|
||||
| PATCH endpoint 가 merge-patch+json / json-patch+json content type 을 거부하는지 (D14 정정 후) | boundary branch B2 의 ArchUnit rule 활성화 필요 — controller 작성자가 우회 시 build fail 보장 | `@RequestMapping(consumes="application/merge-patch+json")` 가 build fail 시키는 ArchUnit test 추가 검증 | `planned` |
|
||||
| PATCH 요청의 `null` 값 필드가 *명시적 null* (clear) 의미로 처리되는지 (D14, RFC7396-C3 — 미채택 근거) | Java record canonical constructor 가 absent vs null 을 같은 기본값으로 수렴 → mapper 가 `JsonNullable<T>` / `Optional<T>` wrapper 로 구분 필요. boundary branch B2 SSOT | sample-portfolio `PATCH /v1/worklogs/{id}` 에 `{"description": null}` 전송 → DB 의 description 컬럼이 NULL 로 *변경* 됨 (clear). `{}` (absent) 전송 → description 변경 *없음*. wrapper 사용 controller test | `needs-confirmation` (boundary branch B2 SSOT 와 cross-link) |
|
||||
| write 응답에 `ETag` header 가 자동 발행되는지 (D15) | 모든 write controller 가 일관되게 ETag 생성하는지 contract 강제 | sample-portfolio POST/PUT/PATCH 응답에 `ETag: W/"<version>"` 헤더 존재 + 값이 envelope `data.version` 또는 `data.id+version` 의 derived | `planned` |
|
||||
| `If-Match` mismatch 가 412 Precondition Failed 로 응답하는지 (D15) | optimistic lock 충돌을 409 Conflict 또는 500 으로 매핑할 위험 | sample-portfolio UPDATE 에 `If-Match: W/"0"` (stale version) 전송 → 412 + envelope `error.code=CONFLICT` 또는 별도 `PRECONDITION_FAILED` | `planned` |
|
||||
| `If-None-Match` match 가 304 + body 없음 응답인지 (D15) | Spring 의 ResponseEntity 처리 또는 controller 직접 304 응답 필요 | sample-portfolio GET 응답의 `ETag` 받은 후 동일 endpoint 에 `If-None-Match: <etag>` 전송 → 304 + Content-Length 0 + body 빈 응답 | `planned` |
|
||||
| 인증된 응답 default 가 `Cache-Control: no-store` 인지 (D16) | Spring Security 또는 controller default 가 비어 있어 proxy 가 임의 캐시 위험 | sample-portfolio 의 모든 응답에 `Cache-Control: no-store` 존재 (단, 명시적 cacheable opt-in endpoint 제외) | `planned` |
|
||||
| content-negotiated 응답에 `Vary` header 가 자동 발행되는지 (D16) | Spring MVC 가 Accept-driven negotiation 시 자동 Vary 추가하나 모든 경우 보장 안 됨 | Accept-driven content negotiation 사용하는 endpoint 응답에 `Vary: Accept` 포함, 인증 응답에 `Vary: Authorization` 포함 | `planned` |
|
||||
| LRO endpoint 가 202 + `Location` + envelope `data.{operationId,statusUrl}` 형식인지 (D17) | 현재 ca-skeleton 에 LRO endpoint 자체가 없음 — sample-portfolio fixture 신설 필요 | sample-portfolio 에 `POST /v1/worklogs:export` 같은 LRO fixture 추가 후 응답 검증 | `needs-confirmation` (sample-portfolio fixture 확장 필요) |
|
||||
| polling endpoint `GET /v1/operations/{id}` 의 status enum 이 SSOT 어휘인지 (D17) | enum 어휘 (PENDING/RUNNING/SUCCEEDED/FAILED/CANCELLED) 의 변형 위험 | polling endpoint 응답 schema 의 enum 정의 + 실제 응답값 매트릭스 test | `planned` |
|
||||
| pagination `size` cap 이 강제되는지 (D18, 최대 footgun) | Spring `Pageable` default max = `DEFAULT_MAX_PAGE_SIZE = 2000` (SPRING-PAGE-C4 정정 — 이전 표현 `Integer.MAX_VALUE` 는 부정확). 2000 도 DoS 위험은 충분 — `?size=2000` × 무거운 응답 = 메모리 폭발 | `?size=10000000` → 400 VALIDATION_FAILED + envelope `error.details.field=size` + `error.details.code=SIZE_EXCEEDS_MAX` · `?size=500` (Spring default 2000 이하지만 project cap 100 초과) → 400 VALIDATION_FAILED | `planned` |
|
||||
| pagination `page` 0-indexed 이 Spring Pageable 정합인지 (D18) | 0-indexed vs 1-indexed 혼동 — controller 와 OpenAPI snapshot 의 drift | `?page=0` 응답 = 첫 페이지 (first), `?page=-1` → 400 VALIDATION_FAILED | `planned` |
|
||||
| 빈 list 응답이 `data: []` + `meta.page.total=0` 인지 (D18) | controller 가 `null` 반환 또는 meta 누락 위험 | empty list endpoint 응답 = `{"success":true,"data":[],"meta":{"page":{"number":0,"size":20,"total":0,"sort":...}}}` | `planned` |
|
||||
| `error-codes.yaml` 의 모든 row 가 실제 controller 응답의 HTTP status 와 일치하는지 (D11, registry drift detection) | registry row 와 controller drift 가 untracked 위험 | 모든 `error.code` row 에 대해 contract test 가 trigger (오류 발생 fixture) 후 실제 응답 status code 가 `http_status` column 과 일치 검증 | `needs-confirmation` (registry-governance branch 와 정합성 확인) |
|
||||
| 모든 `@RequestMapping` path segment 가 AIP-122 regex `[a-z][a-zA-Z0-9]*` 매치하는지 (D19) | controller 작성자가 kebab-case (`/v1/worklog-comments`) 또는 CamelCase (`/v1/Tickets`) 사용 가능성 | ArchUnit rule 또는 Spring controller mapping inspector 로 모든 endpoint path segment regex 검증. multi-word resource fixture (예: `customerOrders`) 로 lowerCamelCase 동작 확인 | `planned` |
|
||||
| sort syntax 가 Spring `Pageable` native (`?sort=field,direction`) 인지 (D20) | controller 작성자가 `?sort=-foo` (JSON:API) / `?sort=foo:desc` 같은 다른 syntax 채택 가능성 | sort syntax contract test: `?sort=createdAt,desc` 200 + `?sort=-createdAt` 400 또는 ignore 검증. multi-sort `?sort=createdAt,desc&sort=title,asc` 동작 검증 | `planned` |
|
||||
| filter syntax 가 flat key=value (equality) 만 통과하는지 (D21) | controller 작성자가 RSQL / FIQL / AIP-160 DSL library 도입 가능성 | filter syntax contract test: `?status=OPEN` 200 + `?filter=status==OPEN` (DSL) 가 controller 에서 parse 되지 않고 silent 무시 또는 거부됨 검증 | `planned` |
|
||||
| cursor token 이 opaque (client parse 불가) + signed (tamper 감지) + TTL (24h 후 만료) 3개 invariant 모두 만족하는지 (D22) | typed cursor 노출 / unsigned token / no TTL 중 하나라도 깨지면 contract 위반 | cursor token roundtrip test (next page 정상) + base64 decode 후 client 가 의미 있는 정보 추출 못함 검증 + tamper test (token 1byte 변조 → 400) + TTL test (24h 1초 후 token → 400) | `planned` |
|
||||
| sync bulk endpoint 가 atomic 인지 + async bulk endpoint 가 LRO polling pattern 인지 (D23 sync/async 분기) | sync 에서 partial failure 허용은 AIP233-C7 위반. controller 작성자가 sync/async 구분 없이 partial failure 응답하거나 flat array body 채택 가능성 | sync batch contract test: `POST /v1/worklogs:batchCreate` 의 한 항목 fail 시 전체 rollback + HTTP 4xx + envelope.success=false. async batch contract test: `POST /v1/operations:batchCreate` 의 202 + Location + polling endpoint 의 `data.result.results[]` 가 항목별 success/error 매핑. BATCH_PARTIAL_FAILURE 가 sync 응답에 나타나면 실패. 1001 항목 size cap 위반 → 400 | `needs-confirmation` (boundary branch B14 BulkEnvelope.partial + D17 LRO endpoint pattern cross-link) |
|
||||
| 모든 응답 (200/204/400/404/500 status matrix) 에 `Date` 헤더가 자동 발행되는지 (D24) | Spring/Tomcat default 가 자동 발행하지만 controller / filter / @ResponseBody 의 명시적 제거 위험 | response header presence contract test (각 status code 별로 endpoint 응답 검증) — 모든 응답에 `Date` 헤더 존재 + RFC 9110 §5.6.7 HTTP-date format 일치 | `planned` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
> 짧은 메모만. 깊이 있는 트러블슈팅은 `raw/errors/` 로 분리하고 아래 §Cluster 에 연결.
|
||||
|
||||
- [[raw/errors/responseentityexceptionhandler-ambiguous-exception-handler-2026-06-02]] — `ResponseEntityExceptionHandler` 우산이 이미 다루는 `MaxUploadSizeExceededException` 을 `@ExceptionHandler` 로 가로채자 advice 등록 ambiguous → protected override 로 해소 (D8).
|
||||
- [[raw/errors/mockmvc-406-produces-accept-double-fault-2026-06-02]] — 406 produces/Accept 불일치 경로의 에러 응답 직렬화 2차 실패 → 예외 직접 throw probe 로 결정적 검증 (D9).
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/idempotency-brandur-stripe-postgres]]
|
||||
- [[raw/company-tech-blogs/idempotency-redis-vs-db-storage]]
|
||||
- [[raw/company-tech-blogs/idempotency-toss-payments-techblog]]
|
||||
- [[raw/official-docs/fetch-spec-cors]]
|
||||
- [[raw/official-docs/google-aip-122-resource-names]]
|
||||
- [[raw/official-docs/google-aip-127-http-transcoding]]
|
||||
- [[raw/official-docs/google-aip-132-list-method]]
|
||||
- [[raw/official-docs/google-aip-136-custom-methods]]
|
||||
- [[raw/official-docs/google-aip-151-long-running-operations]]
|
||||
- [[raw/official-docs/google-aip-158-pagination]]
|
||||
- [[raw/official-docs/google-aip-160-filtering]]
|
||||
- [[raw/official-docs/google-aip-185-resource-versioning]]
|
||||
- [[raw/official-docs/google-aip-233-batch-create]]
|
||||
- [[raw/official-docs/idempotency-aws-lambda-powertools]]
|
||||
- [[raw/official-docs/idempotency-ietf-draft]]
|
||||
- [[raw/official-docs/idempotency-no-api-level-github-rest]]
|
||||
- [[raw/official-docs/idempotency-paypal-docs]]
|
||||
- [[raw/official-docs/idempotency-square-api]]
|
||||
- [[raw/official-docs/idempotency-stripe-api-ref]]
|
||||
- [[raw/official-docs/jsonapi-pagination-format]]
|
||||
- [[raw/official-docs/openapi-spec-3-1-0]]
|
||||
- [[raw/official-docs/rfc9110-http-semantics]]
|
||||
- [[raw/official-docs/rfc9111-http-caching]]
|
||||
- [[raw/official-docs/spring-data-pageable-defaults]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: errors:start -->
|
||||
- [[raw/errors/mockmvc-406-produces-accept-double-fault-2026-06-02]]
|
||||
- [[raw/errors/responseentityexceptionhandler-ambiguous-exception-handler-2026-06-02]]
|
||||
<!-- GENERATED: errors:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/spring-responseentityexceptionhandler-transport-failure-envelope-2026-07-02]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
> 이 branch는 단일 노트가 아니라 **작업 묶음의 entry point**. 본 feature branch 는 현재 leaf — 자식 자료 없음. Phase C2 실 코드 작성 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 근거 자료
|
||||
|
||||
- [[raw/official-docs/google-aip-233-batch-create]] — AIP-233 Batch Methods: Create — `:batchCreate` URI suffix MUST + HTTP POST MUST + requests SHOULD + atomicity MUST (D23 `:batchCreate` 명칭 vocabulary normative 근거 — AIP233-C2/C3/C4/C7)
|
||||
- [[raw/official-docs/google-aip-132-list-method]] — AIP-132 List method standard: `order_by` syntax (`"foo desc, bar"` 형식), `page_size`/`page_token`/`next_page_token` proto field 명명, `filter` field + AIP-160 cross-ref (future B14 sort syntax 결정 근거 — AIP132-C1~C6)
|
||||
- [[raw/official-docs/google-aip-122-resource-names]] — AIP-122 Resource Names: collection segment plural + lowercase 규칙 (future B13 — resource URL naming convention 근거 후보, AIP122-C2/C3)
|
||||
- [[raw/official-docs/google-aip-158-pagination]] — AIP-158 pagination: D18 size cap + cursor-based page_token opaque normative reference (AIP158-C1~C5)
|
||||
- [[raw/official-docs/google-aip-151-long-running-operations]] — AIP-151 LRO 패턴 normative reference (D17 UNSUPPORTED_DECISION 해소 — AIP151-C1~C7)
|
||||
- [[raw/official-docs/rfc9111-http-caching]] — RFC 9111 HTTP Caching: `no-store`/`private`/`public`/`max-age` directive normative 정의 (D16)
|
||||
- [[raw/official-docs/spring-data-pageable-defaults]] — Spring Data `Pageable` 0-indexed default + `size` default 20 + `DEFAULT_MAX_PAGE_SIZE = 2000` vendor-doc 근거 (D18 — SPRING-PAGE-C1~C6)
|
||||
- [[raw/official-docs/google-aip-160-filtering]] — AIP-160 filter DSL 정의 (future B15 — filter syntax 결정의 옵션 근거, AIP160-C1~C6)
|
||||
- [[raw/official-docs/google-aip-136-custom-methods]] — AIP-136 Custom Methods: colon-verb URI syntax + collection-scoped custom method 패턴 (future B18 bulk operation URL pattern 근거 + D17 LRO entry point cross-ref — AIP136-C1~C5)
|
||||
- [[raw/official-docs/fetch-spec-cors]] — WHATWG Fetch §3.3 CORS protocol: OPTIONS preflight 식별 기준 normative 정의 (D13 — preflight = OPTIONS + Access-Control-Request-Method, FETCH-CORS-C2)
|
||||
- (기타 Sources 는 §Sources / 근거 표 참조)
|
||||
|
||||
### Sub-branches (세부 작업)
|
||||
|
||||
- (없음 — 본 branch 가 leaf)
|
||||
|
||||
### 오류 기록 (이 branch 작업 중 발생)
|
||||
|
||||
- [[raw/errors/responseentityexceptionhandler-ambiguous-exception-handler-2026-06-02]] — D8 413 핸들러 추가 시 `ResponseEntityExceptionHandler` 우산과 `@ExceptionHandler` ambiguous, override 로 해소.
|
||||
- [[raw/errors/mockmvc-406-produces-accept-double-fault-2026-06-02]] — D9 406 협상 경로 에러 직렬화 2차 실패, 예외 직접 throw 로 검증.
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- 후보 존재(이번 라운드 별도 노트 미작성, errors + branch note 로 충분): (1) `ResponseEntityExceptionHandler` 상속 시 우산 예외는 왜 `@ExceptionHandler` 가 아니라 protected override 인가, (2) 406 vs 415 의 RFC 9110 의미 차이와 둘을 같은 코드로 뭉개면 잃는 것, (3) HTTP 412(`If-Match`)↔DB optimistic lock 의 동치성, (4) ArchUnit 으로 URL 네이밍(AIP-122) 같은 *값* 규칙을 강제하는 법(annotation 값 스캔 + violations-as-data).
|
||||
|
||||
### 강의 (이 작업을 위해 학습한 강의)
|
||||
|
||||
- (없음)
|
||||
|
||||
### job-posting tie-ins (이 작업에서 파생된 글감)
|
||||
|
||||
- 후보(이번 라운드 별도 노트 미작성): "Spring `ResponseEntityExceptionHandler` 를 깨지 않고 transport 실패(405/406/413/415)를 envelope 로 분류하기" — [[raw/errors/responseentityexceptionhandler-ambiguous-exception-handler-2026-06-02]] + [[raw/errors/mockmvc-406-produces-accept-double-fault-2026-06-02]] 가 원석.
|
||||
- derived blog: 생성 전. 생성 시 `wiki/blog/<slug>-YYYY-MM-DD.md` 후보
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
> 이 branch를 작업한 날짜들. 양방향 nav 유지.
|
||||
|
||||
- 2026-05-21 (initial scaffolding) — daily note 미생성
|
||||
- 2026-05-22 (TODO drained, D1~D10 확정) — daily note 미생성
|
||||
- 2026-05-31 (D11~D18 추가, template 정렬) — daily note 미생성
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움. `/ingest`가 이 섹션을 기준으로 `wiki/projects/` 에 추출.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 로컬/CI 검증까지 (운영 배포 없음). ca-tmpl @b15dcf5.
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출): → [[wiki/projects/ca-tmpl/api-evolution-and-schema]] "API contract baseline 구현" 절
|
||||
- `actually-implemented` 항목: `ETags`, `PreconditionFailedException`, `CacheControlFilter`, `PageParams`, `SortParam`, `PageMeta`/`ResponseMeta.page`, `CursorCodec`(seam), `GlobalExceptionHandler`(413/406/415/405+Allow/412), `Operation`/`OperationStatus`/`OperationsController`, `WorkLogController` `:batchCreate`, `application.yml` `/v1` prefix + `PresentationSettings`, springdoc 의존.
|
||||
- `locally-verified` 항목: 위 클래스의 동작 — `TransportErrorHandlingTest`, `WorkLogControllerWireTest`(ETag/304/412/pagination/sort/filter-ignore/HEAD/batch-cap/idempotency-header), `CacheControlFilterTest`, `CursorCodecTest`, `OperationsControllerWireTest`, `OpenApiSnapshotTest`, `VersioningPrefixTest`, `DateHeaderContractTest`, `ErrorCodeRegistryMappingTest`.
|
||||
- `prod-verified` 항목: 없음 (운영 배포 0).
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned): D22 HMAC 운영 key/회전, D8 414 end-to-end, D3 idempotency key shape/replay, D5/D10 drift 릴리스 게이트, D16 cache layer 구현, D22 sample cursor endpoint, D14 merge-patch 차단 ArchUnit(boundary B2 소유). idempotency-key shape 는 [[raw/branch-notes/feature-rate-limit-idempotency-contract]] 소유 — 본 branch 비추출.
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-application-port-usecase-contract.md
|
||||
@@ -0,0 +1,425 @@
|
||||
---
|
||||
title: branch / feature-application-port-usecase-contract
|
||||
source_type: branch-note
|
||||
status: verified
|
||||
branch: feature-application-port-usecase-contract
|
||||
related_projects: [ca-skeleton]
|
||||
tags: [branch, ca-skeleton, application, usecase, port, transaction-port]
|
||||
created: 2026-05-22
|
||||
target_merge:
|
||||
status_label: actually-implemented
|
||||
last_updated: 2026-05-28
|
||||
last_reviewed: 2026-06-04
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-035
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-035
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-TRANSACTION-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
parent_branch:
|
||||
contract_packet_sha256: 21500ddfbea7eff6f949e176ee85f1c73636fd011e0a969b1f59d242b6f68784
|
||||
---
|
||||
|
||||
# branch: feature-application-port-usecase-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — application layer의 use case, input port, output port, command/query 기준을 정의합니다.
|
||||
|
||||
> **Ground-truth 대조 (2026-06-04, ca-tmpl @ffb0e13)**: `/ingest` reconcile 시 commit `ffb0e13` 코드를 직접 읽어 D1~D14 구현 사실을 확인 — `TransactionPort`(`inWrite`/`inRead`/`inNew` + Runnable defaults), `SpringTransactionPort`(모드별 pre-built `TransactionTemplate`, READ_COMMITTED pin), `Isolation` 단일값, 6종 ArchUnit rule, violations-as-data fixture, sample 모듈(@ffb0e13 명칭 `sample-ticket`, 이후 `sample-portfolio` 로 rename) 의 `@Transactional` 전면 제거 모두 코드에 실재. 단위 테스트 + ArchUnit PASS(2026-06-04 재실행 exit 0). `status: verified`. 실 DB 통합/운영 검증은 미수행(planned/위임).
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (§<관련 섹션>) 의 결정/근거/금지 사항을 정제한다.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: application port와 transaction runner architecture test가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-TRANSACTION-001@1` | application은 Spring transaction annotation 대신 TransactionPort 또는 TransactionalUseCaseRunner를 사용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1` | Gradle multi-module에서 domain-core·application-core·adapter-*·shared-contract·app-bootstrap·sample-portfolio 책임을 분리한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
실제 도메인이 들어오면 application layer가 가장 먼저 비대해집니다. use case가 DTO, JPA entity, HTTP request, external client를 직접 다루지 않도록 port 계약과 command/query 모델을 고정합니다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- command/query 분리 기준.
|
||||
- inbound port naming.
|
||||
- outbound port naming.
|
||||
- use case transaction/capability/idempotency 선언 기준.
|
||||
- application result/error 변환 기준.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 특정 command bus framework.
|
||||
- CQRS 인프라 강제.
|
||||
- domain-specific workflow engine.
|
||||
|
||||
## TODO
|
||||
|
||||
> TODO drained — 결정은 아래 표/결정 사항 참조.
|
||||
|
||||
## Work Item Contract
|
||||
|
||||
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
|
||||
|
||||
| field | required | rule |
|
||||
| --- | --- | --- |
|
||||
| Decision | yes | 구현자가 선택해야 하는 기본값 |
|
||||
| Allowed | yes | 허용되는 예외와 조건 |
|
||||
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
|
||||
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
|
||||
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
|
||||
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
|
||||
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-05-22: inbound port는 `*UseCase`, outbound port는 `*Port`를 기본 naming으로 둠.
|
||||
- 2026-05-22: command use case와 query use case를 기본 분리.
|
||||
- 2026-05-22: transaction boundary는 application use case 책임이지만 Spring `@Transactional` 직접 import는 금지하고 `TransactionPort` 또는 `TransactionalUseCaseRunner` abstraction을 기본값으로 둠.
|
||||
- 2026-05-22: write use case는 `transactionMode`, `idempotency`, `repositoryAccess`를 명시해야 함. query use case는 `readOnly` transaction mode를 기본값으로 둠.
|
||||
- 2026-05-28 (implementation): `TransactionPort` 선택. `TransactionalUseCaseRunner` 는 채택 안 함 (단일 abstraction 면 충분, 두 추상이 공존하면 사용 지침이 모호해짐).
|
||||
- 2026-05-28 (implementation): `TransactionPort` API 는 `inWrite` / `inRead` / `inNew` 3 메서드. `NESTED` 와 `NEVER` 는 API 에서 노출 안 함 (브랜치 노트 금지 사항).
|
||||
- 2026-05-28 (implementation): `Isolation` enum 은 `READ_COMMITTED` 만 노출. `REPEATABLE_READ`, `SERIALIZABLE` 은 `feature-transaction-concurrency-contract` 브랜치로 위임.
|
||||
- 2026-05-28 (implementation): `SpringTransactionPort` 는 모드별 `TransactionTemplate` 인스턴스 3개를 미리 빌드해서 보관. `setReadOnly` / `setPropagationBehavior` 의 호출당 mutation 으로 인한 동시성 위험 차단.
|
||||
- 2026-05-28 (implementation): `Idempotency` enum 값은 `IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT` 세 종. `KEYED` 는 idempotency key 기반 dedup 필요 표시 (브랜치 노트의 `feature-rate-limit-idempotency-contract` 가 후속 운영).
|
||||
- 2026-05-28 (implementation): `application-core` Gradle 의 `spring-tx` 의존성 제거. `@Transactional` 어노테이션이 모듈 컴파일 classpath 자체에 없도록 belt+suspenders.
|
||||
- 2026-05-28 (D11 checked-exception wrapping): `TransactionPort` 는 `Supplier<T>` / `Runnable` 시그니처 유지 (checked exception 시그니처에 노출 안 함). Spring `TransactionTemplate.execute(TransactionCallback<T>) throws TransactionException` 도 동일 제약 — 이는 TransactionPort 설계 결함이 아닌 Spring 공식 idiom. Wrapping 정책: 도메인 checked → `DomainException extends RuntimeException`, `IOException` → `UncheckedIOException`, `SQLException` → Spring `DataAccessException` 계층이 자동 wrap. 근거: `raw/official-docs/transaction-template-spring-official#TX-TMPL-C2/C3` ("RuntimeException ... rollback ... propagated").
|
||||
- 2026-05-28 (D12 REQUIRES_NEW pool sizing): `inNew` 호출은 새 physical JDBC connection 획득 (outer transaction 의 connection 은 그대로 점유). Pool sizing 제약 — `hikari.maximumPoolSize ≥ (concurrent_threads × (1 + max_inNew_depth)) + 1`. **Forbidden**: `inNew` 를 loop 안에서 per-record 호출 (anti-pattern, pool exhaustion + deadlock 위험). 근거: `raw/official-docs/spring-tx-propagation-required-new-nested-official#SPRING-PROP-C1`~`C4`.
|
||||
- 2026-05-28 (D13 application 의 Spring DI 의존): `application-core` 는 `org.springframework.stereotype.{Service,Component}` import 및 사용 **허용** (DI 등록 목적). Spring core (`spring-context` / `spring-beans`) 의존은 유지하되 `spring-tx` / `org.springframework.web` / JPA annotation 은 forbidden 유지. 이유: Spring DI 없이 use case bean 등록을 매번 `@Configuration` 수동 작성하면 boilerplate 폭발.
|
||||
- 2026-05-28 (D14 KEYED idempotency freeze): `@UseCaseCapability(idempotency = Idempotency.KEYED)` 사용은 후속 branch `feature-rate-limit-idempotency-contract` merge 전까지 **금지**. 이유: key source (HTTP header / command field / domain ID) 와 storage backend (Redis / DB / in-memory) 와 TTL 정책이 미정인 상태에서 KEYED 를 달면 undefined behavior. 임시 ArchUnit rule: `inbound_port_implementations_do_not_declare_keyed_idempotency` (`feature-rate-limit-idempotency-contract` merge 시 제거).
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/company-tech-blogs/transaction-port-clean-ddd-spring-medium]] | UNIL의 동일 진화 경로 (2024-05 |
|
||||
| [[raw/company-tech-blogs/transaction-port-vassilis-soum-github-readme]] | TransactionPort 참고 구현 |
|
||||
| [[raw/official-docs/at-transactional-spring-official]] | [[raw/company-tech-blogs/hexagonal-reflectoring-transactional-placement]] (Hexagonal 표준 다수파 |
|
||||
| [[raw/company-tech-blogs/hexagonal-reflectoring-transactional-placement]] | Hexagonal 표준 다수파 |
|
||||
| [[raw/official-docs/transaction-template-spring-official]] | — |
|
||||
| [[raw/official-docs/functional-tx-arrow-kt-resource-docs]] | Arrow Kt |
|
||||
| [[raw/company-tech-blogs/custom-transaction-interceptor-catnipcoder]] | — |
|
||||
| [[raw/company-tech-blogs/woowahan-hexagonal-multimodule]] | multi-module 분리 |
|
||||
| [[raw/official-docs/arch-hexagonal-cockburn]] | primary port = use case interface 의 원형 (Cockburn alistair.cockburn.us — `engineering-blog` 등급, `official-standard` 아님). D1 의 `*Port` 명명과 D3 의 application↔외부 경계 abstraction 의 inside/outside asymmetry 사상 근거 |
|
||||
| [[raw/official-docs/spring-tx-management-reference]] | Spring transaction abstraction (`PlatformTransactionManager` SPI) + propagation 기본값 + self-invocation 우회 + readOnly 적용 범위 (`official-vendor-doc`). D3 (TransactionPort abstraction 이 회피하려는 함정), D4 (`@Transactional` 다수파), D9 (readOnly transaction) 의 vendor 공식 근거 |
|
||||
| [[raw/official-docs/spring-transaction-synchronization-manager-javadoc]] | `registerSynchronization()` 이 commit-bound domain event publish 의 공식 SPI 임을 정당화 (TSM-C3). per-thread 자원 격리 보장으로 multi-tenant 호환성 근거 제공 (TSM-C1, TSM-C4). |
|
||||
| [[raw/official-docs/spring-tx-propagation-required-new-nested-official]] | `TransactionPort.inNew` (PROPAGATION_REQUIRES_NEW) 의 independent physical transaction 보장 + connection pool exhaustion / deadlock 경고 (`SPRING-PROP-C1`~`C4`) + NESTED savepoint 동작 (`SPRING-PROP-C5`) — `spring-tx-management-reference.md` 가 직접 인용하지 않는 REQUIRES_NEW connection 동작 보강 |
|
||||
| [[raw/company-tech-blogs/axonframework-transactionmanager-spring-adapter]] | D3 OSS PRECEDENT — Axon `TransactionManager.executeInTransaction(Runnable)` + `fetchInTransaction(Supplier<T>)` 가 ca-tmpl `inWrite`/`inRead` 와 closure 시그니처 1:1 매칭 (AXON-TX-C1~C3). 3.6k stars enterprise OSS — closure-based abstraction 패턴의 production precedent. 단 specific 3중 메소드 구조 / `TransactionPort` 명명은 ca-tmpl 자체 |
|
||||
| [[raw/company-tech-blogs/buckpal-archunit-lombok-allowlist-direct-transactional]] | D3/D8 CONTRARY EVIDENCE — Buckpal (Hombergs 책 hex-arch 공식 reference, 2.5k stars) 의 application service 가 `@Component @Transactional` 직접 부착 (BUCKPAL-TX-C1, BUCKPAL-TX-C2). ca-tmpl 의 "Spring `@Transactional` import forbidden" 정책이 OSS best practice 가 아님을 명시 |
|
||||
| [[raw/company-tech-blogs/spring-modulith-archunit-generated-exemption-and-violations-as-data]] | D3/D8 CONTRARY EVIDENCE — Spring 공식 incubator 가 `@ApplicationModuleListener` 로 `@Transactional(propagation = REQUIRES_NEW)` 를 meta-annotation 재노출 (SPRING-MOD-TX-C1). ca-tmpl forbidden 정책과 Spring 팀 방향이 정면 충돌함을 명시. `feature-domain-event-outbox-contract` 입력으로 Event Publication Registry (SPRING-MOD-TX-C2) 활용 가능 |
|
||||
|
||||
## 외부 근거 / 대안 조사 (2026-05-22 — Topic 2)
|
||||
|
||||
본 branch의 TransactionPort abstraction 결정에 대한 외부 source. 5종 대안 비교는 (예정) `wiki/concepts/transaction-boundary-abstraction.md` 참조.
|
||||
|
||||
- **채택 결정 (TransactionPort / TransactionalUseCaseRunner abstraction)**:
|
||||
- [[raw/company-tech-blogs/transaction-port-clean-ddd-spring-medium]] — UNIL의 동일 진화 경로 (2024-05)
|
||||
- [[raw/company-tech-blogs/transaction-port-vassilis-soum-github-readme]] — TransactionPort 참고 구현
|
||||
- **검토한 대안**:
|
||||
- **대안 1: @Transactional direct** — [[raw/official-docs/at-transactional-spring-official]], [[raw/company-tech-blogs/hexagonal-reflectoring-transactional-placement]] (Hexagonal 표준 다수파)
|
||||
- **대안 2: TransactionTemplate programmatic** — [[raw/official-docs/transaction-template-spring-official]]
|
||||
- **대안 3: Functional Resource monad** — [[raw/official-docs/functional-tx-arrow-kt-resource-docs]] (Arrow Kt)
|
||||
- **대안 4: Custom TransactionInterceptor (AOP)** — [[raw/company-tech-blogs/custom-transaction-interceptor-catnipcoder]]
|
||||
- **보완 (대체 X)**: [[raw/company-tech-blogs/woowahan-hexagonal-multimodule]] — multi-module 분리
|
||||
- **비교 핵심**: ca-tmpl 결정은 "Spring 의존 숨김" 소수파. 다수파는 `@Transactional` 직접 부착(testability 낮지만 boilerplate 최저). Functional monad는 testability 최고지만 팀 학습 비용 큼.
|
||||
|
||||
## 판정 기준
|
||||
|
||||
| 구분 | 기준 |
|
||||
| --- | --- |
|
||||
| Decision | application은 use case와 port를 통해서만 외부와 연결 |
|
||||
| Allowed | read-only query use case는 `readOnly` transaction과 `READ_REPOSITORY` capability만 선언 가능 |
|
||||
| Forbidden | use case method가 HTTP DTO, JPA entity, external client response를 직접 받음. application package가 Spring transaction annotation을 직접 import |
|
||||
| Required fields | command/query input, use case capability, transaction mode, idempotency 여부, repository access capability |
|
||||
| Failure condition | application package가 infrastructure 구현체나 presentation DTO를 import하면 실패 |
|
||||
|
||||
## TransactionPort Contract
|
||||
|
||||
| field | default |
|
||||
| --- | --- |
|
||||
| abstraction name | `TransactionPort` 또는 `TransactionalUseCaseRunner` |
|
||||
| write mode | `required` |
|
||||
| query mode | `readOnly` |
|
||||
| propagation | REQUIRES_NEW은 outbox/audit row 명시 선언 시만 허용. NESTED와 NEVER는 어떤 경우에도 forbidden (transaction-concurrency와 일관). |
|
||||
| isolation | `READ_COMMITTED` (transaction-concurrency SSOT 위임). 묵시적 vendor default 사용은 forbidden. |
|
||||
| forbidden import | `org.springframework.transaction.annotation.Transactional` in application package |
|
||||
| callback signature | `Supplier<T>` / `Runnable` (checked exception 노출 안 함 — Spring `TransactionCallback` 과 동일 제약). 호출 측에서 `RuntimeException` 으로 wrap. |
|
||||
| inNew connection cost | 호출당 새 physical JDBC connection 획득. Pool sizing: `hikari.maximumPoolSize ≥ (concurrent_threads × (1 + max_inNew_depth)) + 1`. Loop 안에서 호출 금지. |
|
||||
|
||||
infrastructure가 Spring transaction implementation을 제공하고 application은 port만 호출합니다.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. company-tech-blog 증거는 `company-case-study` 로 표기하며 공식 best practice 로 승격하지 않음.
|
||||
|
||||
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|
|
||||
| D1 | inbound port = `*UseCase`, outbound port = `*Port` naming convention | `raw/official-docs/arch-hexagonal-cockburn.md#HEX-COCKBURN-ORIG-C3` (port = plug-point for conversation with external agency), `raw/official-docs/arch-hexagonal-cockburn.md#HEX-COCKBURN-ORIG-C4` (adapter converts port API to device signals) | `engineering-blog` (Cockburn 개인 블로그 — `official-standard` 아님) | HEX-COCKBURN-ORIG-C3/C4 Does not prove: `*UseCase` (inbound) 와 `*Port` (outbound) 의 specific suffix convention — Cockburn 은 "primary/secondary port" 일반 개념만 명시. `*UseCase` suffix 는 buckpal / ca-tmpl 자체 차용 |
|
||||
| D2 | command use case 와 query use case 기본 분리 | UNSUPPORTED_DECISION (cited raw 중 CQS/CQRS 분리 권고 직접 인용 없음) | n/a | Greg Young / Martin Fowler CQRS source 또는 Spring `@Transactional(readOnly=true)` 권고 source 보강 필요 |
|
||||
| D3 | application use case 가 transaction boundary 의 owner — but Spring `@Transactional` 직접 import 금지, `TransactionPort` / `TransactionalUseCaseRunner` abstraction 사용 | `raw/company-tech-blogs/transaction-port-clean-ddd-spring-medium.md#UNIL-TX-C1`, `raw/company-tech-blogs/transaction-port-clean-ddd-spring-medium.md#UNIL-TX-C2`, `raw/company-tech-blogs/transaction-port-vassilis-soum-github-readme.md#VSOUM-TX-C1`, `raw/company-tech-blogs/transaction-port-vassilis-soum-github-readme.md#VSOUM-TX-C2` / **OSS PRECEDENT (closure-based pattern)**: `raw/company-tech-blogs/axonframework-transactionmanager-spring-adapter.md#AXON-TX-C1`, `#AXON-TX-C2` (Axon `TransactionManager.executeInTransaction(Runnable)` + `fetchInTransaction(Supplier<T>)` 시그니처는 ca-tmpl `inWrite(Supplier)` 와 1:1 매칭, 3.6k stars enterprise OSS), `#AXON-TX-C3` (`SpringTransactionManager(PlatformTransactionManager)` adapter 구조 ca-tmpl `SpringTransactionPort` 와 동일) / **CONTRARY EVIDENCE (다수파 = `@Transactional` 직접 부착)**: `raw/company-tech-blogs/buckpal-archunit-lombok-allowlist-direct-transactional.md#BUCKPAL-TX-C1`, `#BUCKPAL-TX-C2` (Buckpal hex-arch 공식 reference 가 `@Component @Transactional` 직접 application service 부착, abstraction 없음) / **CONTRARY (Spring Modulith)**: `raw/company-tech-blogs/spring-modulith-archunit-generated-exemption-and-violations-as-data.md#SPRING-MOD-TX-C1` (`@ApplicationModuleListener` 가 meta-annotation 으로 `@Transactional` 재노출 — Spring 공식 incubator 가 forbidden 방향과 정반대) | `company-case-study + engineering-blog` (TransactionPort pattern 자체) + `company-case-study` (Axon enterprise OSS precedent) + **UNSUPPORTED_DECISION (CONTRARY for specific shape)** | (1) **closure-based abstraction 패턴 자체는 강화됨** — Axon (3.6k stars) + Spanner SDK 가 동일 closure 시그니처 사용. (2) **그러나 `TransactionPort` literal 명칭 + `inWrite`/`inRead`/`inNew` 3중 메소드 구조는 OSS 1:1 매칭 없음** — Axon 은 `TransactionManager` + 2 메소드. ca-tmpl 의 specific shape 은 자체 결정. (3) **forbidden 정책은 OSS 다수파 (Buckpal) 와 Spring 공식 incubator (Modulith) 양쪽과 충돌** — multi-Gradle-module Hexagonal context 에서 application 모듈 순수성을 위한 자체 stricter taste. UNSUPPORTED_DECISION(CONTRARY) for forbidden enforcement |
|
||||
| D4 | (대안 비교) `@Transactional` 직접 부착이 hexagonal 표준 다수파임을 인정 | `raw/official-docs/at-transactional-spring-official.md#AT-TX-C1`, `raw/official-docs/at-transactional-spring-official.md#AT-TX-C3`, `raw/official-docs/at-transactional-spring-official.md#AT-TX-C5`, `raw/company-tech-blogs/hexagonal-reflectoring-transactional-placement.md#HEX-REFL-C1`, `raw/company-tech-blogs/hexagonal-reflectoring-transactional-placement.md#HEX-REFL-C5` | `official-vendor-doc + engineering-blog` (HEX-REFL-C5 는 negative claim — 저자가 명시적 정당화 없음) | Spring 공식 권고 (`AT-TX-C1`) 와 ca-tmpl D3 결정 사이 분기점 — 채택 결정 정당화가 abstraction 의 testability 이득에 의존 |
|
||||
| D5 | (대안 비교) `TransactionTemplate` programmatic 옵션 | `raw/official-docs/transaction-template-spring-official.md#TX-TMPL-C1`, `raw/official-docs/transaction-template-spring-official.md#TX-TMPL-C2`, `raw/official-docs/transaction-template-spring-official.md#TX-TMPL-C3`, `raw/official-docs/transaction-template-spring-official.md#TX-TMPL-C4` | `official-vendor-doc` (Spring 팀 공식 programmatic 권장 도구) | callback 접근법이 declarative 보다 우월하다는 뜻 아님 (TX-TMPL-C2) — application 이 import 해야 하는 부담 잔존 |
|
||||
| D6 | (대안 비교) Functional Resource monad (Arrow Kt) | `raw/official-docs/functional-tx-arrow-kt-resource-docs.md#ARROW-RES-C1`, `raw/official-docs/functional-tx-arrow-kt-resource-docs.md#ARROW-RES-C5` | `official-vendor-doc` (Arrow vendor 공식, JDBC/JPA 1:1 매퍼는 별도 — ARROW-RES-C1 Usage Boundaries 참조) | Java 코드베이스 적용 어려움 — Kotlin coroutines 전제 (ARROW-RES-C2) |
|
||||
| D7 | (대안 비교) Custom TransactionInterceptor (AOP) | `raw/company-tech-blogs/custom-transaction-interceptor-catnipcoder.md#CATNIP-TXINT-C1`, `raw/company-tech-blogs/custom-transaction-interceptor-catnipcoder.md#CATNIP-TXINT-C2`, `raw/company-tech-blogs/custom-transaction-interceptor-catnipcoder.md#CATNIP-TXINT-C3`, `raw/company-tech-blogs/transaction-port-vassilis-soum-github-readme.md#VSOUM-TX-C3`, `raw/company-tech-blogs/transaction-port-vassilis-soum-github-readme.md#VSOUM-TX-C4` | `engineering-blog + company-case-study` (개인 블로그 + GitHub README, Spring 공식 권장 패턴 아님) | bean override (`VSOUM-TX-C4`) 활성화의 side-effect 부담. Spring internal API stability 미보장 |
|
||||
| D8 | (보강) 우아한형제들 hexagonal multi-module 분리 사례 | `raw/company-tech-blogs/woowahan-hexagonal-multimodule.md#WW-HEX-C1`, `raw/company-tech-blogs/woowahan-hexagonal-multimodule.md#WW-HEX-C2`, `raw/company-tech-blogs/woowahan-hexagonal-multimodule.md#WW-HEX-C3`, `raw/company-tech-blogs/woowahan-hexagonal-multimodule.md#WW-HEX-C5` | `company-case-study` (best practice 승격 금지 — WW-HEX-C5 는 negative: 우아한형제들 글이 transaction boundary 정책 직접 다루지 않음) | 4-hexagon 구성은 우아한형제들 특정 사례 — ca-tmpl 의 module 분리에 1:1 mapping 보장 안 됨 |
|
||||
| D9 | read-only query use case 는 `readOnly` transaction + `READ_REPOSITORY` capability 만 선언 가능 | `raw/official-docs/spring-tx-management-reference.md#SPRING-TX-MGR-C6` (readOnly 속성이 REQUIRED/REQUIRES_NEW propagation 한정 적용), `raw/official-docs/spring-tx-management-reference.md#SPRING-TX-MGR-C3` (default propagation = REQUIRED — readOnly 적용 가능 전제) | `official-vendor-doc` | SPRING-TX-MGR-C6 Does not prove: driver level flush mode 변경의 구체 동작 — Spring Data JPA / Hibernate session statistics 측정은 별도 PoC 필요. `READ_REPOSITORY` capability 자체는 ca-tmpl 자체 contract |
|
||||
| D10 | application package 가 `org.springframework.web` / JPA entity / adapter implementation import 금지 (ArchUnit fitness function) | UNSUPPORTED_DECISION (ArchUnit 의 정적 검사 가능 범위는 별도 source — 본 branch cited raw 에 ArchUnit 직접 인용 없음) | n/a | [[raw/official-docs/archunit-conditional-on-property-3-layer-pattern]] (`AUCP-C1~C5`) fetch + ArchUnit fitness function source 보강 필요 |
|
||||
| D11 | `TransactionPort` 콜백 시그니처는 `Supplier<T>` / `Runnable` (checked exception 노출 안 함). 호출 측에서 `RuntimeException` 으로 wrap | `raw/official-docs/transaction-template-spring-official.md#TX-TMPL-C2`, `raw/official-docs/transaction-template-spring-official.md#TX-TMPL-C3` (Spring `TransactionTemplate.execute(TransactionCallback) throws TransactionException` 도 동일 제약 + "RuntimeException ... rollback ... propagated") | `official-vendor-doc` | TX-TMPL-C2/C3 Does not prove: `Supplier<T>` 가 `TransactionCallback<T>` 와 동등하다는 명시적 진술 — Spring 공식이 별도 functional interface `TransactionCallback` 을 둔 것은 사실. ca-tmpl 의 `Supplier<T>` 채택은 boilerplate 감소를 위한 자체 결정 |
|
||||
| D12 | `inNew` (PROPAGATION_REQUIRES_NEW) 호출은 새 physical JDBC connection 획득. Pool sizing 제약 명시 + loop 안 호출 금지 | `raw/official-docs/spring-tx-propagation-required-new-nested-official.md#SPRING-PROP-C1`, `raw/official-docs/spring-tx-propagation-required-new-nested-official.md#SPRING-PROP-C2`, `raw/official-docs/spring-tx-propagation-required-new-nested-official.md#SPRING-PROP-C3`, `raw/official-docs/spring-tx-propagation-required-new-nested-official.md#SPRING-PROP-C4` | `official-vendor-doc` (Spring 공식 직접 인용 — "always uses an independent physical transaction" + "new database connection" + "exhaustion of the connection pool" + "Do not use ... unless your connection pool is appropriately sized") | `max_inNew_depth` 의 실제 측정은 도메인 use case 별 통합 테스트 필요 — `feature-domain-event-outbox-contract` outbox 구현 단계에서 확정 |
|
||||
| D13 | `application-core` 는 `org.springframework.stereotype.{Service,Component}` 허용 (DI 등록 목적). `spring-context` / `spring-beans` 의존은 유지하되 `spring-tx` / web / JPA annotation 은 forbidden | UNSUPPORTED_DECISION — Spring 공식이 "application layer 에서 `@Service` 허용 / 금지" 를 직접 진술한 source 없음. ca-tmpl 의 실용주의 자체 결정 | `project-decision` | 대안: `@Configuration` manual bean 등록 — boilerplate 폭발. 대안 채택 시 D13 retract 검토 |
|
||||
| D14 | `@UseCaseCapability(idempotency = Idempotency.KEYED)` 사용은 `feature-rate-limit-idempotency-contract` merge 전까지 금지 | `project-decision` — key source / storage / TTL 미정 시 undefined behavior 회피. 후속 branch 가 정의되기 전까지 임시 freeze | `project-decision` | freeze 자체는 ArchUnit rule (`inbound_port_implementations_do_not_declare_keyed_idempotency`) 로 강제. merge 시점에 rule 제거 + KEYED 활성 |
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| `TransactionPort` abstraction 이 Spring `@Transactional` 의 propagation / isolation / rollback policy 전 표현력을 동등하게 capture 가능한지 | UNIL-TX-C2 가 명시한 `Runnable` 시그니처는 단순 — `REQUIRES_NEW`, `NESTED`, `noRollbackFor`, `timeout` 등 전 옵션 표현 가능한지 미증명 | port interface design 에 transaction options 별 method 정의 + 통합 테스트로 outbox/audit row REQUIRES_NEW 동작 확인 | `partially-implemented` (2026-05-28 round 2) — `inWrite` / `inRead` / `inNew` 3 메서드 + `Supplier<T>` / `Runnable` 시그니처 (D11). `NESTED` / `NEVER` / `noRollbackFor` / `timeout` 는 의도적으로 미노출 (브랜치 노트 forbidden 와 일관). `TransactionPort.inNew` Javadoc 에 D12 의 pool-sizing 공식과 loop anti-pattern 명시 + `application-core/CLAUDE.md` / `adapter-persistence/CLAUDE.md` 에 cross-link. `REQUIRES_NEW` 의 실제 outbox 동작 통합 검증은 `feature-domain-event-outbox-contract` 로 위임. |
|
||||
| application package 의 ArchUnit rule 이 `org.springframework.transaction.annotation.Transactional` import 를 실제로 catch | `AUCP-C1` 의 PREDICATE/CONDITION 모델로 import 검사 가능 (bytecode 기반) — but rule wording 필요 | ArchUnit rule `noClasses().that().resideInAPackage("..application..").should().dependOnClassesThat().haveFullyQualifiedName("org.springframework.transaction.annotation.Transactional")` 작성 + violating PR 통합 테스트 | `actually-implemented` (2026-05-28) — `app-bootstrap/src/test/.../CleanArchitectureTest#application_does_not_use_spring_transactional_annotation` 으로 작성. `sample-portfolio` 을 `app-bootstrap` testImplementation 으로 추가해 ArchUnit scope 에 포함. 기존 `sample-portfolio` UserService / PostService 에서 `@Transactional` 제거 시 rule 통과 확인 (`./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest'`). |
|
||||
| `TransactionPort` infrastructure 구현이 Spring `TransactionTemplate` (TX-TMPL-C3) 또는 `@Transactional` AOP proxy (AT-TX-C4) 중 어느 것으로 더 안전한지 | 두 옵션 모두 cited official-doc 에서 지원 — self-invocation 함정 (AT-TX-C5) 회피 차이 | infrastructure adapter 두 버전 prototype + self-invocation 테스트 (port 메서드가 다른 port 메서드 호출) | `partially-implemented` (2026-05-28) — `SpringTransactionPort` 가 `TransactionTemplate` 기반으로 구현됨. 모드별 미리 빌드된 인스턴스를 사용하여 동시성 안전. self-invocation 테스트는 outbox 구현 단계로 위임. |
|
||||
| `readOnly = true` transaction 이 실제로 driver 수준 flush mode 변경을 트리거 | D9 가 UNSUPPORTED_DECISION — Spring Data JPA / Hibernate 별 동작 차이 | Hibernate session statistics 로 flush count 측정 + readOnly true/false 비교 | `planned` — DB 통합 테스트 환경 (Testcontainers) 후 별도 PoC. 현재는 단위 테스트로 `TransactionTemplate.isReadOnly() == true` 만 확인 (`SpringTransactionPortTest`). |
|
||||
| use case naming convention (`*UseCase` / `*Port`) 이 팀 내 일관성으로 강제 가능 | D1 이 UNSUPPORTED_DECISION — Spring 공식 권고 부재 | ArchUnit class naming rule + checkstyle rule 작성 | `actually-implemented` (2026-05-28) — `inbound_port_implementations_end_with_use_case` ArchUnit rule 작성. `*Port` outbound naming 은 별도 rule 으로 추후 추가 가능 (현재는 inbound 만). |
|
||||
| outbound adapter 호출 use case 의 `EXTERNAL_OUTBOUND_ALLOWED` capability annotation 이 작동 | capability annotation spec 자체가 ca-tmpl 자체 contract — 외부 source 무관 | annotation + ArchUnit rule + capability registry SSOT 작성 후 통합 테스트 | `partially-implemented` (2026-05-28) — `@UseCaseCapability(externalOutboundAllowed = ...)` 정의 + `inbound_port_implementations_declare_capability` rule 으로 capability annotation 자체는 mandatory. `externalOutboundAllowed = true` 가 없는 use case 가 outbound `*Port` 호출 시 실패시키는 dependency-aware rule 은 후속 (outbound port marker 가 먼저 필요). |
|
||||
| presentation 분리 (UNIL-TX-C4) 가 application layer 에서 강제 가능 | UNIL-TX-C4 의 "presentation" 경계가 모호 (HTTP 응답만? 이벤트 발행도?) | use case 결과 type 을 domain object 로 강제 + presentation mapper 를 adapter layer 로 배치 + ArchUnit rule | `needs-confirmation` — 현재 ArchUnit `application_does_not_depend_on_adapters_or_transport` 에 `org.springframework.web..` 추가로 transport 의존 차단. event publication 경계는 `feature-domain-event-outbox-contract` 로 위임. |
|
||||
|
||||
## 테스트 계약
|
||||
|
||||
- application use case가 `org.springframework.web`, JPA entity, adapter implementation을 import하면 실패.
|
||||
- application use case가 `org.springframework.transaction.annotation.Transactional`을 직접 import하면 실패.
|
||||
- write use case에 transaction/capability 선언이 없으면 실패.
|
||||
- outbound adapter 호출 use case에 `EXTERNAL_OUTBOUND_ALLOWED`가 없으면 실패.
|
||||
|
||||
## 완료 후 wiki 추출 대상
|
||||
|
||||
- `wiki/projects/ca-skeleton-operational-contract.md`의 application port/use case canonical section.
|
||||
|
||||
> 본 branch는 TransactionPort interface spec 자체가 Decisionized Work Items 등가. 별도 7-column 표는 작성하지 않음.
|
||||
## 구현 결과
|
||||
|
||||
### Files changed (round 2)
|
||||
|
||||
- `src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPort.java` — Javadoc 확장: D11 (Supplier/Runnable + RuntimeException wrap) + D12 (`inNew` pool-sizing 공식 + loop anti-pattern).
|
||||
- `src/application-core/CLAUDE.md` — D13 (Spring DI 허용 + `spring-boot-starter` 잔존 이유), D14 (KEYED idempotency freeze), D11 (ApplicationContext 금지), Lombok forbidden 명시, ArchUnit guardrail 목록 갱신.
|
||||
- `src/adapter-persistence/CLAUDE.md` — D12 (`inNew` pool-sizing + loop forbidden) + MapStruct `@Generated` exemption ArchUnit predicate 예시 (D9 of architecture-enforcement-rules).
|
||||
- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java` — `domain_is_pure` 에 `lombok..` forbidden 추가 (D3 of architecture-enforcement-rules). 새 rule 3종 추가: `application_does_not_depend_on_application_context` (D11), `inbound_port_implementations_do_not_declare_keyed_idempotency` (D14 — custom `ArchCondition` 으로 KEYED enum 값 catch).
|
||||
- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java` — violations-as-data 네거티브 테스트 (Claims to Verify of architecture-enforcement-rules) — 6개 rule 의 실 동작을 fixture 로 보증.
|
||||
- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/` — 의도된 위반 fixture 클래스 6종 (domain 1 + application 5).
|
||||
- `src/app-bootstrap/build.gradle` — `testCompileOnly 'org.springframework:spring-tx'` 추가 (violation fixture 의 `@Transactional` import 만을 위해).
|
||||
- `CLAUDE.md` (root) — `api` vs `implementation` 정책 추가 (D9 of skeleton-package-blueprint-contract).
|
||||
|
||||
### Verification (round 2)
|
||||
|
||||
| Command | Result |
|
||||
|---|---|
|
||||
| `cd src && ./gradlew check` | PASS — 25 actionable tasks. |
|
||||
| `cd src && ./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest'` | PASS — 14 tests (9 originals + 5 round-1 = 14; this round added 2 rules and modified 1, no change in test count visible from `@ArchTest` count = 14). |
|
||||
| `cd src && ./gradlew :app-bootstrap:test --tests '*ArchitectureViolationFixtureTest'` | PASS — 6 negative tests (each rule catches its fixture violation). |
|
||||
|
||||
## 구현 결과
|
||||
|
||||
### Files changed
|
||||
|
||||
**application-core (new contract types)**
|
||||
|
||||
- `src/application-core/src/main/java/dev/caskeleton/application/usecase/UseCase.java` — generic inbound port base.
|
||||
- `src/application-core/src/main/java/dev/caskeleton/application/usecase/CommandUseCase.java` — write inbound port (`C extends Command`).
|
||||
- `src/application-core/src/main/java/dev/caskeleton/application/usecase/QueryUseCase.java` — read inbound port (`Q extends Query`).
|
||||
- `src/application-core/src/main/java/dev/caskeleton/application/command/Command.java` — write-intent marker.
|
||||
- `src/application-core/src/main/java/dev/caskeleton/application/query/Query.java` — read-intent marker.
|
||||
- `src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPort.java` — `inWrite` / `inRead` / `inNew` (+ Runnable defaults).
|
||||
- `src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionMode.java` — `WRITE` / `READ_ONLY` / `REQUIRES_NEW`.
|
||||
- `src/application-core/src/main/java/dev/caskeleton/application/transaction/Isolation.java` — `READ_COMMITTED` only.
|
||||
- `src/application-core/src/main/java/dev/caskeleton/application/capability/UseCaseCapability.java` — runtime-retained annotation, required fields.
|
||||
- `src/application-core/src/main/java/dev/caskeleton/application/capability/Idempotency.java` — `IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT`.
|
||||
- `src/application-core/src/main/java/dev/caskeleton/application/capability/RepositoryAccess.java` — `NONE` / `READ_REPOSITORY` / `WRITE_REPOSITORY`.
|
||||
- `src/application-core/build.gradle` — drop `spring-tx`; comment explains why.
|
||||
- `src/application-core/CLAUDE.md` — document the contract surface, allowed transactional shapes, ArchUnit guardrails.
|
||||
|
||||
**application-core (unit tests)**
|
||||
|
||||
- `src/application-core/src/test/java/dev/caskeleton/application/capability/UseCaseCapabilityTest.java` — 3 tests.
|
||||
- `src/application-core/src/test/java/dev/caskeleton/application/transaction/TransactionPortTest.java` — 4 tests (Supplier + Runnable delegation per mode).
|
||||
- `src/application-core/src/test/java/dev/caskeleton/application/usecase/UseCaseContractTest.java` — 2 tests (Command/Query use case wiring).
|
||||
|
||||
**adapter-persistence**
|
||||
|
||||
- `src/adapter-persistence/src/main/java/dev/caskeleton/adapter/persistence/transaction/SpringTransactionPort.java` — Spring-backed `TransactionPort` (pre-built `TransactionTemplate` per mode, `READ_COMMITTED` pinned).
|
||||
- `src/adapter-persistence/src/test/java/dev/caskeleton/adapter/persistence/transaction/SpringTransactionPortTest.java` — 4 tests (propagation / isolation / readOnly / rollback-on-exception).
|
||||
- `src/adapter-persistence/CLAUDE.md` — document `TransactionPort` implementation + repository-adapter forbidden `@Transactional`.
|
||||
|
||||
**app-bootstrap (ArchUnit fitness functions)**
|
||||
|
||||
- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java` — added 3 new rules (`application_does_not_use_spring_transactional_annotation`, `inbound_port_implementations_end_with_use_case`, `inbound_port_implementations_declare_capability`) + `org.springframework.web..` added to existing application-forbid list.
|
||||
- `src/app-bootstrap/build.gradle` — `testImplementation project(':sample-portfolio')` so ArchUnit can analyse the template's reference implementation. Production scope unaffected.
|
||||
|
||||
**sample-portfolio (migration to TransactionPort)**
|
||||
|
||||
- `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/UserService.java` — replaced `@Transactional(readOnly=true)` class-level + `@Transactional` method-level with `TransactionPort.inRead` / `inWrite` calls. `TransactionPort` injected via constructor.
|
||||
- `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/PostService.java` — same migration pattern.
|
||||
- `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/persistence/repository/PostRepositoryAdapter.java` — removed `@Transactional` from `deleteByAuthorId` (caller owns the transaction now).
|
||||
|
||||
### Verification commands
|
||||
|
||||
| Command | Result |
|
||||
|---|---|
|
||||
| `cd src && ./gradlew :application-core:test` | PASS — 9 tests (3 + 4 + 2). |
|
||||
| `cd src && ./gradlew :adapter-persistence:test` | PASS — 4 tests. |
|
||||
| `cd src && ./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest'` | PASS — 12 tests (9 original + 3 new). |
|
||||
| `cd src && ./gradlew check` | PASS — 25 actionable tasks. |
|
||||
| `cd src && ./gradlew verifyCleanArchitectureDependencies` | PASS. |
|
||||
|
||||
### Evidence labels
|
||||
|
||||
- `actually-implemented`: contract types in `application-core`, `SpringTransactionPort`, 3 new ArchUnit rules, sample-portfolio migration to `TransactionPort`.
|
||||
- `locally-verified`: full `./gradlew check` green; ArchUnit rules verified against the migrated reference implementation.
|
||||
- `documented-only`: `*Port` outbound naming rule, `externalOutboundAllowed` dependency-aware rule, REPEATABLE_READ / SERIALIZABLE isolation — explicitly deferred with rationale.
|
||||
- `planned`: `readOnly` driver flush-mode integration test (needs Testcontainers).
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- ArchUnit `@AnalyzeClasses(packages = "dev.caskeleton")` 가 `app-bootstrap` 의 컴파일 classpath 만 본다는 점을 발견. `sample-portfolio` 은 production 의존 매트릭스 상 `app-bootstrap` 가 import 하지 않으므로 ArchUnit scope 에 안 잡혀서 새 rule 이 vacuously 통과. → `testImplementation project(':sample-portfolio')` 추가로 test-scope only inclusion. production dependency check (`verifyCleanArchitectureDependencies`) 는 `['api', 'implementation', 'compileOnly', 'runtimeOnly']` 만 검사하므로 영향 없음. ArchUnit `production_code_does_not_depend_on_sample_portfolio` rule 은 `ImportOption.DoNotIncludeTests` 로 test 클래스 제외하므로 여전히 production drift 만 catch. (`raw/errors/archunit-test-scope-sample-portfolio-inclusion-2026-05-28.md` 참조)
|
||||
- 초기에 IDE diagnostics 가 stale 상태로 `Transactional cannot be resolved` 오류를 표시. Edit 직후 IDE refresh 가 따라잡기 전 noise 임을 확인 후 무시. 실제 `grep -n Transactional` 로 import 부재 검증.
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/axonframework-transactionmanager-spring-adapter]]
|
||||
- [[raw/company-tech-blogs/buckpal-archunit-lombok-allowlist-direct-transactional]]
|
||||
- [[raw/company-tech-blogs/custom-transaction-interceptor-catnipcoder]]
|
||||
- [[raw/company-tech-blogs/hexagonal-reflectoring-transactional-placement]]
|
||||
- [[raw/company-tech-blogs/spring-modulith-archunit-generated-exemption-and-violations-as-data]]
|
||||
- [[raw/company-tech-blogs/transaction-port-clean-ddd-spring-medium]]
|
||||
- [[raw/company-tech-blogs/transaction-port-vassilis-soum-github-readme]]
|
||||
- [[raw/company-tech-blogs/woowahan-hexagonal-multimodule]]
|
||||
- [[raw/official-docs/arch-hexagonal-cockburn]]
|
||||
- [[raw/official-docs/at-transactional-spring-official]]
|
||||
- [[raw/official-docs/functional-tx-arrow-kt-resource-docs]]
|
||||
- [[raw/official-docs/spring-transaction-synchronization-manager-javadoc]]
|
||||
- [[raw/official-docs/spring-tx-management-reference]]
|
||||
- [[raw/official-docs/spring-tx-propagation-required-new-nested-official]]
|
||||
- [[raw/official-docs/transaction-template-spring-official]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: interviews:start -->
|
||||
- [[raw/interviews/archunit-static-analysis-limits]]
|
||||
- [[raw/interviews/transaction-port-vs-spring-transactional]]
|
||||
<!-- GENERATED: interviews:end -->
|
||||
|
||||
<!-- GENERATED: errors:start -->
|
||||
- [[raw/errors/archunit-test-scope-sample-ticket-inclusion-2026-05-28]]
|
||||
<!-- GENERATED: errors:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/archunit-violations-as-data-pattern-2026-05-28]]
|
||||
- [[raw/blog-topics/transaction-port-abstraction-over-spring-transactional-2026-05-28]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
> 본 feature branch 는 leaf — 자식 자료 없음. Phase C2 실 코드 작성 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 근거 자료
|
||||
|
||||
- [[raw/official-docs/spring-tx-propagation-required-new-nested-official]] — PROPAGATION_REQUIRES_NEW 의 independent physical transaction + connection pool exhaustion / deadlock 경고 + NESTED savepoint 동작 (Spring 공식 문서 verbatim)
|
||||
- [[raw/company-tech-blogs/spring-modulith-archunit-generated-exemption-and-violations-as-data]] — D1/D3 counter-evidence: `@ApplicationModuleListener` 가 `@Transactional(propagation = Propagation.REQUIRES_NEW)` 를 meta-annotation 으로 재노출 (SPRING-MOD-TX-C1). ca-tmpl forbidden 정책 재검토 증거로 기록 (D3 override 아님)
|
||||
- [[raw/company-tech-blogs/axonframework-transactionmanager-spring-adapter]] — Axon Framework `TransactionManager` interface (`executeInTransaction(Runnable)` + `fetchInTransaction(Supplier<T>)`) + `SpringTransactionManager(PlatformTransactionManager)` 어댑터 — D3 (TransactionPort 채택) 보강 증거 (`company-case-study`, Spring 공식 아님)
|
||||
- [[raw/company-tech-blogs/buckpal-archunit-lombok-allowlist-direct-transactional]] — D1/D3 CONTRARY evidence: Buckpal application service 가 `@Transactional` 직접 클래스 부착 + transaction abstraction 부재 (BUCKPAL-TX-C1, BUCKPAL-TX-C2). ca-tmpl D3 (TransactionPort) 가 OSS 소수파 결정임을 뒷받침
|
||||
|
||||
### 오류 기록 (본 feature 작업 중 발생)
|
||||
|
||||
- [[raw/errors/archunit-test-scope-sample-ticket-inclusion-2026-05-28]] — ArchUnit scope 가 production classpath 만 보는 함정과 `testImplementation` 우회.
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- [[raw/interviews/transaction-port-vs-spring-transactional]] — `@Transactional` 직접 부착 다수파 vs `TransactionPort` 추상화 소수파의 trade-off.
|
||||
- [[raw/interviews/archunit-static-analysis-limits]] — D14 (KEYED idempotency freeze) 의 custom `ArchCondition` 작성 + ArchUnit static analysis 한계 + violations-as-data 보완 (round 2).
|
||||
|
||||
### Blog topics (이 작업에서 나올 수 있는 글감)
|
||||
|
||||
- [[raw/blog-topics/transaction-port-abstraction-over-spring-transactional-2026-05-28]] — application 계층이 `@Transactional` 을 직접 import 하지 않도록 TransactionPort 를 도입한 실제 ca-tmpl 사례 + ArchUnit fitness function 으로 강제한 방법.
|
||||
- [[raw/blog-topics/archunit-violations-as-data-pattern-2026-05-28]] — D14 의 custom ArchCondition 을 negative test fixture 로 보증한 round 2 작업 글감.
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- port naming·transaction boundary 계약과 구현 결과는 위 판정 기준 및 구현 결과 절에서 추적한다.
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
- inbound port는 use case capability를, outbound port는 외부 기술 의존을 추상화한다.
|
||||
- transaction 시작·종료는 application boundary가 `TransactionPort`를 통해 요청하고 domain은 framework annotation을 알지 않는다.
|
||||
- read-only와 write use case fixture를 분리해 dependency direction을 architecture test로 검증한다.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- adapter가 application 구현체를 우회하거나 domain이 transaction API를 직접 참조하면 경계가 무너진다.
|
||||
- query bypass·transaction concurrency·module layout 계약이 본 port 규칙을 소비한다.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- 별도 일일 노트 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- inbound port = `*UseCase` naming (D1) — ArchUnit `inbound_port_implementations_end_with_use_case` 으로 강제.
|
||||
- `@UseCaseCapability` mandatory annotation (D3 / 판정 기준 Required fields) — ArchUnit `inbound_port_implementations_declare_capability`.
|
||||
- `TransactionPort` abstraction with `inWrite` / `inRead` / `inNew` 3 modes (D3) — Spring `@Transactional` 직접 import 금지 (`application_does_not_use_spring_transactional_annotation`).
|
||||
- `READ_COMMITTED` only isolation (TransactionPort Contract) — `Isolation` enum 단일 값.
|
||||
- `NESTED` / `NEVER` propagation forbidden — `TransactionPort` API 에서 노출 안 함.
|
||||
- Spring `TransactionTemplate` 기반 infrastructure (D5 의 cited alternative 채택) — `SpringTransactionPort` 모드별 pre-built template.
|
||||
- sample-portfolio 의 `@Transactional` 전체 제거 + `TransactionPort` 사용으로 contract conformance 입증.
|
||||
- `locally-verified` 항목:
|
||||
- `./gradlew check` 통과 (25 tasks, 12 ArchUnit + 9 application + 4 adapter-persistence + 9 adapter-web + 6 bootstrap settings).
|
||||
- `prod-verified` 항목: 없음 — 운영 환경 배포 없음.
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
- `TransactionalUseCaseRunner` 대안 (Decision 2026-05-28 으로 `TransactionPort` 단일 abstraction 채택).
|
||||
- `REPEATABLE_READ` / `SERIALIZABLE` isolation (`feature-transaction-concurrency-contract` 위임).
|
||||
- outbox/audit `REQUIRES_NEW` 동작 통합 테스트 (`feature-domain-event-outbox-contract` 위임).
|
||||
- `externalOutboundAllowed` 의 dependency-aware ArchUnit rule (outbound port marker 정의 후).
|
||||
- Hibernate `readOnly` flush-mode statistics 측정 PoC (Testcontainers 환경 후).
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-application-query-bypass-contract.md
|
||||
@@ -0,0 +1,421 @@
|
||||
---
|
||||
title: branch / feature-application-query-bypass-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-application-query-bypass-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [wiki/projects/ca-tmpl/clean-architecture-package-layout, wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]
|
||||
tags: [branch, ca-skeleton, application, query, cqrs, read-model]
|
||||
created: 2026-06-04
|
||||
target_merge:
|
||||
status_label: review
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-047
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-047
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-TRANSACTION-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MAPPING-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-035, WI-CA-SKELETON-OPERATIONAL-CONTRACT-012, WI-CA-SKELETON-OPERATIONAL-CONTRACT-024, WI-CA-SKELETON-OPERATIONAL-CONTRACT-007, WI-CA-SKELETON-OPERATIONAL-CONTRACT-006]
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: 4c05fbdad06f0558c14b9c975d41ed0a9d49cce1c82ee4e842bc88c190ccb22d
|
||||
---
|
||||
|
||||
# branch: feature-application-query-bypass-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
> **계층 표기**: "root branch" 라는 별도 개념은 없음. project 의 직접 자식 branch 는 `parent_branch:` 를 **비워두고** `related_projects` 만 채움. 다른 branch 의 자식이면 `parent_branch: <부모 branch 이름>` 명시 + `## Parent` 섹션의 부모 wikilink 필수.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 운영 계약 중 **application read/query 경로** 영역의 결정/근거/금지 사항을 정제한다.
|
||||
|
||||
선택 (관련 형제 branch):
|
||||
|
||||
- [[raw/branch-notes/feature-application-port-usecase-contract]] — command/query use case 분리, `QueryUseCase`(`READ_ONLY` + `READ_REPOSITORY` 강제), `TransactionPort.inRead` 를 고정한 **직접 선행 계약**. 본 branch 가 우회를 논하는 "기존 표준 경로" 가 이 branch 의 산출물.
|
||||
- [[raw/branch-notes/feature-transaction-concurrency-contract]] — isolation/propagation SSOT (read tx 의 격리 수준 위임처).
|
||||
- [[raw/branch-notes/feature-cache-consistency-contract]] — read 경로의 cache bypass(strict consistency) 와 인접. 본 branch 는 *데이터소스/모델* 우회, cache 계약은 *캐시* 우회.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: query bypass의 허용 경계·mapping·transaction 영향과 검증 test가 명시된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-TRANSACTION-001@1` | application은 Spring transaction annotation 대신 TransactionPort 또는 TransactionalUseCaseRunner를 사용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1` | Gradle multi-module에서 domain-core·application-core·adapter-*·shared-contract·app-bootstrap·sample-portfolio 책임을 분리한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MAPPING-001@1` | 수기 mapper와 record canonical constructor가 default이며 MapStruct는 optional profile이다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
선행 계약 `feature-application-port-usecase-contract` 는 **모든 읽기**를 `QueryUseCase` → repository port(`READ_REPOSITORY`) → `TransactionPort.inRead` 경로로 강제하고, 그 port 가 도메인 aggregate 또는 projection 을 반환하도록 고정했다 (`QueryUseCase` Javadoc: "projection or domain object"). 그 branch 는 의도적으로 **"CQRS 인프라 강제" 를 out-of-scope** 로 미뤘다.
|
||||
|
||||
이 branch 는 그 미뤄둔 read-side 질문을 *스켈레톤 기본 계약*으로 확정한다: **읽기 경로가 표준 write-side 스택(도메인 aggregate / repository port / use-case / transaction)을 언제·어떻게 우회(bypass)해도 되는가.** 도메인-특화 답이 아니라, 재사용 가능한 clean-architecture 스켈레톤이 **보편적으로 가져갈 기본값 + opt-in 상향**을 정하는 것이 목표다.
|
||||
|
||||
> **결정 방식 (사용자 지시 2026-06-04)**: bypass 의 구체 범위를 사전에 못박지 않는다. 외부 조사(`wiki-decision-researcher`)로 *기존 through-aggregate 방식 대비* clean-architecture 스켈레톤이 보편적으로 채택해야 할 방식을 도출하고, 그것이 진짜 *선택*인 지점만 대안과 함께 결정으로 남긴다. 따라서 아래 §결정/§Decision Evidence Map 의 셀은 조사 완료 전까지 `RESEARCH_PENDING` 으로 둔다 — 추측 금지(CLAUDE.md §11).
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
> ⚠️ 아래 In/Out scope 의 **경계선 자체가 조사로 확정될 결정**이다(예: "use-case 우회 허용" 이 in 인지 out 인지). 현재는 *조사 대상 축*을 나열하며, 조사 후 D-결정에 따라 확정한다.
|
||||
|
||||
### 포함 범위 (조사로 확정할 축)
|
||||
|
||||
- **읽기 모델 우회 축**: 읽기가 도메인 aggregate 로딩을 건너뛰고 전용 read port 로 projection(native/JPQL DTO)을 반환할지 — through-aggregate vs read-model/projection vs 별도 read store.
|
||||
- **읽기 경로 ceremony 축**: 단순 조회가 application use-case 를 거쳐야 하는지, thin read path(adapter-web → query service/read port 직접)를 허용할지.
|
||||
- **읽기 트랜잭션 축**: 읽기가 `TransactionPort.inRead` 경계를 항상 거쳐야 하는지, no-tx read 를 허용할 조건이 있는지.
|
||||
- 위 축들의 **정적 강제(ArchUnit) 가능성** 및 `RepositoryAccess`/`@UseCaseCapability` 계약과의 정합.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외. 면접 등에서 "이건 범위에 없었습니다" 근거.
|
||||
|
||||
- 특정 도메인의 구체 read model 스키마/쿼리 (도메인-특화 — skeleton 범위 밖).
|
||||
- 격리 수준(REPEATABLE_READ/SERIALIZABLE) — `feature-transaction-concurrency-contract` SSOT.
|
||||
- 캐시 일관성/캐시 우회 — `feature-cache-consistency-contract` SSOT (본 branch 는 *모델/데이터소스* 우회만).
|
||||
- idempotency key 정책 — `feature-rate-limit-idempotency-contract`.
|
||||
- 특정 CQRS 프레임워크(Axon 등) 강제 — 채택은 조사 결과에 따르되, 프레임워크 lock-in 은 비목표.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 이 branch의 결정 근거. `wiki-decision-researcher` 2개 lane(R1: 읽기 모델 우회 전략, R2: 읽기 경로 ceremony) 의 조사 산출물. **company-tech-blog 는 `company-case-study`/`engineering-blog` 로만 취급 — 공식 best practice 승격 금지(CLAUDE.md §5).**
|
||||
|
||||
| Source | 등급 | 정당화하는 결정 |
|
||||
|---|---|---|
|
||||
| [[raw/official-docs/cqrs-pattern-azure-architecture-center]] | official-vendor-doc | D1 (single-store CQRS = "foundational level"), D2 (separate-store = "advanced", escalation) |
|
||||
| [[raw/official-docs/spring-data-jpa-projections-spring-official]] | official-vendor-doc | D1 (closed projection = column-subset 최적화 메커니즘) |
|
||||
| [[raw/official-docs/spring-data-jpa-transactionality-spring-official]] | official-vendor-doc | D4 (CrudRepository readOnly tx 기본 + "unit of work" 권고) |
|
||||
| [[raw/official-docs/spring-tx-management-reference]] | official-vendor-doc | D4 (readOnly 속성 적용 범위 SPRING-TX-MGR-C6) |
|
||||
| [[raw/official-docs/cqrs-fowler-bliki]] | engineering-blog | D1/D3 (CQRS 분리 개념 + "be very cautious"/"significant complexity" 경고 → Alt 3 기각 근거) |
|
||||
| [[raw/official-docs/domain-vaughn-vernon-aggregate-root]] | engineering-blog | D1 (small aggregate 가정 — through-aggregate fallback 조건) |
|
||||
| [[raw/company-tech-blogs/cqrs-lite-clean-architecture-read-path-bypass-wakita]] | engineering-blog | D1 (read port = application-layer port, no domain type, logical split) |
|
||||
| [[raw/company-tech-blogs/explicit-architecture-ddd-hexagonal-cqrs-hgraca]] | engineering-blog | D3 (query side 가 Application Service 없이 optimized query + DTO 반환 가능) |
|
||||
| [[raw/company-tech-blogs/read-only-tx-hibernate-optimization-vladmihalcea]] | engineering-blog | D4 (readOnly 이득은 entity 多일 때 — trivial read 의 no-tx 비용 근거) |
|
||||
| [[raw/company-tech-blogs/netflix-tudum-cqrs-separate-read-store-evolution]] | company-case-study (medium — 2차 출처) | D2 (separate read store 의 운영 friction) |
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기.
|
||||
|
||||
- [x] D1: read projection port 계약 정의 — `QueryUseCase` 가 도메인 aggregate 대신 application-layer projection DTO 를 반환하도록 read port 분리 — 등급: `locally-verified` (sample-portfolio 시연: `WorkLogSummaryQueryPort` + `WorkLogSummary` record + `ListRecentWorkLogSummariesUseCase` + 영속 `WorkLogSummaryQueryAdapter`(JPQL `SELECT new` → `WorkLogSummaryRow` → ULID 변환))
|
||||
- [x] D1: projection DTO 가 도메인 type / JPA entity / web DTO 가 아님을 강제하는 ArchUnit rule — 등급: `locally-verified` (`query_ports_do_not_leak_domain_jpa_or_web_types`, custom `ArchCondition<JavaMethod>` 가 `JavaType.getAllInvolvedRawTypes()` 로 **generic type argument 까지** 검사 — raw/generic/over-block 3 fixture 로 역검증)
|
||||
- [x] D3: `QueryUseCase` 경유를 default 로 유지(Strict). thin read path 폐기 — 등급: `actually-implemented` (코드: 모든 read 가 `QueryUseCase` bean 경유; 신규 rule 불필요 — 선행 계약 capability fitness function 재사용. application-core/CLAUDE.md §Read/query path 문서화)
|
||||
- [x] D4: `TransactionPort.inRead` default 유지. no-tx bypass opt-in 조건(OSIV=false + projection-only + no lazy) 명문화 — 등급: `actually-implemented` (코드: `ListRecentWorkLogSummariesUseCase` 가 `tx.inRead` 경유 + test `inReadCalled` 검증; CLAUDE.md 에 opt-in 선결조건 문서화)
|
||||
- [x] D5: projection read 의 capability 표기 = `READ_REPOSITORY` 재사용 — 등급: `actually-implemented` (코드: 영속-backed projection use case 가 `repositoryAccess = READ_REPOSITORY`; 신규 enum 없음)
|
||||
- [ ] D2: Full CQRS separate read store 는 본 branch out-of-scope — escalation trigger 만 문서화하고 별도 branch 로 위임 — 등급: `documented-only` (코드 없음, 의도적)
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- ca-tmpl 현황(2026-06-04 ground-truth): 읽기는 이미 `QueryUseCase`(`GetRepoStatsUseCase`/`ListWorkLogsUseCase`/`GetWorkLogUseCase`) 경유 = Strict baseline 실재. `GetRepoStatsUseCase` 는 dedicated `RepoStatsPort.fetch()` 를 쓰지만 **반환이 도메인 type `RepoStats`** 이고 capability 가 `RepositoryAccess.NONE` 으로 선언됨 — projection-as-application-DTO 와 read-projection capability 어휘가 아직 없음(= 본 branch 가 채울 gap, D1/D5).
|
||||
- OSIV: `application-test.yml=open-in-view:false`, `application.yml=${DB_OPEN_IN_VIEW}`(env). test 는 OSIV off → no-tx bypass(D4) 의 안전 전제 일부 충족하나, lazy access 가 tx 밖이면 `LazyInitializationException` → projection-only 조건이 그래서 필수.
|
||||
- web→application 경계 rule 은 "web 이 persistence/outbound adapter 의존 금지"만 있고 "web 은 QueryUseCase 만 호출" rule 은 없음 → thin read path(D3) 는 기존 rule 과 충돌하진 않으나 mandatory `@UseCaseCapability` rule 을 *우회*하게 됨(D3 Open Risk).
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 핵심 헤드라인: **"query bypass" = 도메인 aggregate 우회(projection read port)** 를 skeleton 이 *능력으로 제공*한다 — 우회하는 건 *도메인 모델*뿐. 단 **projection 은 강제 디폴트가 아니라 read 마다의 선택**이고, 코어가 강제하는 건 **purity 가드레일**(read port 가 도메인/JPA/web 타입을 누출하지 않음)뿐이다(아래 D1 의 *코어 vs 선택* 분할). **use-case ceremony 는 Strict 로 확정**(읽기는 무조건 `QueryUseCase` 경유, thin-path **폐기**), **transaction 은 default `inRead` 유지**(no-tx 만 *opt-in*). separate read store(Full CQRS)는 out-of-scope escalation.
|
||||
|
||||
- 2026-06-04 (D1, 2026-06-05 코어/선택 분할): CQRS-lite(single store) projection read 를 **능력으로 제공**한다 — `QueryUseCase` 가 도메인 aggregate 를 재구성하지 않고 dedicated read/query port 로 **application-layer projection DTO** 를 반환(Spring Data closed projection / `SELECT new` / JdbcTemplate). / **코어 vs 선택 분할 (보편 핵심 원칙)**:
|
||||
- **코어로 강제 (모든 프로젝트 동일)** = **purity 가드레일** — read/query port 의 반환 type(generic argument 포함)이 domain/JPA/web 타입을 누출하지 않는다는 ArchUnit rule + read port 추상화의 *모양*. 이건 *projection 을 쓸 때* 깨끗함을 보장하는 가드레일이지, projection 을 *쓰라는* 강제가 아니다.
|
||||
- **프로젝트 선택 (강제 금지)** = "projection 이냐 through-aggregate 냐". projection 은 *권장이자 제공된 능력*일 뿐 강제 디폴트가 아니다. 단순 읽기는 **Alt1 through-aggregate**(기존 repository port 로 도메인 aggregate 반환)가 정당한 동급 선택 — read shape = write aggregate 와 동일 **and** aggregate 가 단일 트랜잭션 불변식 경계 내 최소 크기일 때.
|
||||
- **시연 위치** = projection 사용 *예시*는 `sample-portfolio` 에 둔다(교육용, `production_code_does_not_depend_on_sample_portfolio` 로 격리). 코어 enforcement 에 "projection 기본" 을 박지 않는다.
|
||||
/ 이유: aggregate hydration overhead 제거 + read shape 독립 진화 + hexagonal purity 유지는 *원할 때* 얻는 이득이지 모든 도메인에 강제할 보편 사실이 아님(작은 CRUD 는 through-aggregate 가 더 단순). / 대안: Alt1 through-aggregate(위), Alt3 separate store(D2). / 근거: [[raw/official-docs/cqrs-pattern-azure-architecture-center]](AZURE-CQRS-C2), [[raw/official-docs/spring-data-jpa-projections-spring-official]](SPRING-PROJ-C2), [[raw/company-tech-blogs/cqrs-lite-clean-architecture-read-path-bypass-wakita]](WAKITA-CQRS-C2/C3).
|
||||
- 2026-06-04 (D2): **Full CQRS(별도 물리 read store)는 본 branch out-of-scope** — escalation-only. / 이유: 단일 RDBMS skeleton 가정 위반 + eventual consistency + 운영 인프라(Kafka/CDC) 부담 + Fowler/Azure 의 "단순 도메인엔 부적합" 경고. / escalation trigger(별도 branch 결정, 정성): ① read/write 부하가 명확히 비대칭이어 단일 DB write-path 가 read latency SLA 미충족(정량 임계는 cited source 없음 → PoC 측정으로만 확정, `UNSUPPORTED_IMPL_DECISION`) **and** ② denormalized shape 가 single-DB column-subset SELECT 로 불가 **and** ③ 도메인이 수초 stale read 허용. / 근거: [[raw/official-docs/cqrs-pattern-azure-architecture-center]](AZURE-CQRS-C4/C6/C7), [[raw/official-docs/cqrs-fowler-bliki]](CQRS-FOWLER-C6), [[raw/company-tech-blogs/netflix-tudum-cqrs-separate-read-store-evolution]](NETFLIX-TUDUM-C2/C3, medium).
|
||||
- 2026-06-04 (D3, 2026-06-05 Strict 확정): **use-case layer ceremony = Strict (단일 계약, opt-in 없음)** — 모든 읽기는 `QueryUseCase` bean 경유. **thin read path(web→read port 직접)는 폐기.** / 이유 (2026-06-05 재결정, §Audit & Findings 참조): 선행 계약은 capability 선언을 *use-case 모양*에 결합(`@UseCaseCapability` 는 use-case 구현체에만)했다. thin-path 는 use-case 가 아니므로 capability 를 달 곳이 없어 `inbound_port_implementations_declare_capability` fitness function 에 안 잡힌다 — 즉 thin-path 는 본 스켈레톤의 핵심 가치(아키텍처의 *기계 강제*)를 코드리뷰 신뢰로 격하시킨다. modest 한 ceremony 절감을 위해 기계 강제력을 포기할 가치가 없다고 판단 → thin-path 제거. capability 를 use-case 에서 분리하는 수술(port-level capability)은 thin-path 의 실익 증거가 생길 때 후속 계약으로 위임(현재 미생성). / 기각된 대안: Alt2 thin-by-default(HGRACA-CQRS-C1 의 "query side 는 Application Service 없이 가능" 학파 — "domain logic 없음" 의 정적 강제 불가로 기각), Alt3 query-handler(별도 infra 전제 → 기각). / 근거: [[raw/official-docs/cqrs-fowler-bliki]](CQRS-FOWLER-C5, "CQRS 복잡도에 매우 신중하라" → 보수적 Strict 지지), [[raw/company-tech-blogs/explicit-architecture-ddd-hexagonal-cqrs-hgraca]](HGRACA-CQRS-C1, *기각된* thin-by-default 학파의 출처).
|
||||
- 2026-06-04 (D4): **transaction boundary = default `TransactionPort.inRead`** 유지. no-tx(autocommit) read 는 *opt-in* — `spring.jpa.open-in-view=false` **and** projection-only(lazy 접근 없음) **and** 단일 statement 일 때만. / 이유: Spring 권고는 "unit of work 시작 시 tx 경계 선언"(SPRING-DATA-TX-C3)이나 readOnly 이득은 entity 多 read 에서 큼(VM-READTX-C3) → trivial projection read 의 tx 비용 회피 여지. / 대안: 전면 no-tx(기각 — OSIV/ lazy 위험), CrudRepository 자체 readOnly tx 의존(부분 허용). / 근거: [[raw/official-docs/spring-data-jpa-transactionality-spring-official]](SPRING-DATA-TX-C1/C3), [[raw/company-tech-blogs/read-only-tx-hibernate-optimization-vladmihalcea]](VM-READTX-C3), [[raw/official-docs/spring-tx-management-reference]](SPRING-TX-MGR-C6).
|
||||
- 2026-06-04 (D5, 2026-06-05 해소): projection read 의 **capability 어휘 = `READ_REPOSITORY` 재사용, 신규 enum 불필요.** / 근거 (code-grounded, ca-tmpl `RepositoryAccess.java` 확인): `RepositoryAccess` 는 **repository 접근 *수준*** 축(`NONE`/`READ_REPOSITORY`/`WRITE_REPOSITORY`)이고, "aggregate 냐 projection 이냐"는 **반환 *모양*** 축이라 서로 **직교**한다. repository-backed projection read 는 repository 의 read 메서드를 호출하므로 그대로 `READ_REPOSITORY` 다 — projection 이라는 사실은 capability 에 영향을 주지 않는다. 반환 모양 purity(projection ≠ domain/JPA/web)는 capability enum 이 아니라 **D1 의 반환타입 ArchUnit rule** 이 담당한다. 두 축을 혼동한 게 `READ_PROJECTION` 신설 논쟁의 정체였음(§Audit & Findings). / `GetRepoStatsUseCase` 의 `NONE` 선언은 *gap 이 아니라 올바른 분류* — 그건 *outbound HTTP* read(`RepoStatsPort`)라 repository 를 안 건드린다. repository projection read 로 이관하는 경우에만 `READ_REPOSITORY` 로 선언.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> company-tech-blog 증거는 `company-case-study`/`engineering-blog` 로 표기(공식 best practice 승격 금지). `선택 조건` = 이 조건일 때 이 결정 / 다른 조건이면 어떤 대안.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | CQRS-lite projection read 를 **능력으로 제공**: `QueryUseCase` → dedicated read/query port → **application-layer projection DTO**(aggregate 우회), 동일 RDBMS. **코어 강제 = purity 가드레일만**(read port 가 domain/JPA/web 누출 금지); **projection 사용 자체는 프로젝트 선택**(강제 디폴트 아님), 시연은 sample | **선택 가이드**: projection = read shape 이 write 와 다르거나 hydration 비용을 피하고 싶을 때(권장). **Alt1 through-aggregate**(기존 repository port 로 도메인 aggregate 반환) = 동급 선택 = read shape = write aggregate 와 동일 **and** aggregate 가 단일 트랜잭션 불변식 경계 내 최소 크기(lazy collection 없음) — 단순 CRUD 의 기본; **Alt3 별도 store** = D2 trigger. — **UNSUPPORTED_IMPL_DECISION**: "필드 N개 이하" 같은 정량 임계는 cited source 없음(Vernon 은 정성 원칙만, VERNON-AGG-C3 가 정량 임계 부재 명시) → 정성 기준만 사용, 숫자 휴리스틱 금지 | `raw/official-docs/cqrs-pattern-azure-architecture-center.md#AZURE-CQRS-C2`, `raw/official-docs/spring-data-jpa-projections-spring-official.md#SPRING-PROJ-C2`(주의: "can optimize" — column-subset SELECT *보장 아님*, Claims To Verify #1), `raw/company-tech-blogs/cqrs-lite-clean-architecture-read-path-bypass-wakita.md#WAKITA-CQRS-C2`, `#WAKITA-CQRS-C3` | `official-vendor-doc`(Azure, Spring) + `engineering-blog`(Wakita) | closed projection 이 Hibernate 6 에서 실제 column-subset SELECT 를 생성하는지 통합 테스트 미검증(Claims#1). Wakita 는 Kotlin+jOOQ → Spring Data JPA 전이성 보강 필요 |
|
||||
| D2 | Full CQRS(별도 물리 read store)는 out-of-scope escalation — trigger 문서화 후 별도 branch 위임 | escalation = read/write 부하가 명확히 비대칭이어 **단일 DB write-path 가 read latency SLA 를 못 맞추는 시점** **and** single-DB projection 불가(denormalized) **and** eventual consistency 허용; 아니면 D1. — **UNSUPPORTED_IMPL_DECISION**: 정량 임계(QPS 배수 등)는 cited source 없음(Azure/Fowler/Netflix 모두 비율 미명시) → PoC 측정값으로만 확정, 숫자 threshold 단정 금지 | `raw/official-docs/cqrs-pattern-azure-architecture-center.md#AZURE-CQRS-C4`, `#AZURE-CQRS-C6`, `#AZURE-CQRS-C7`, `raw/official-docs/cqrs-fowler-bliki.md#CQRS-FOWLER-C6`, `raw/company-tech-blogs/netflix-tudum-cqrs-separate-read-store-evolution.md#NETFLIX-TUDUM-C3` | `official-vendor-doc`(Azure) + `company-case-study`(Netflix, **medium — 2차 출처**) | NETFLIX-TUDUM 은 netflixtechblog SSL 오류로 ByteByteGo 2차 출처 — 직접 재검증 권장 |
|
||||
| D3 | use-case ceremony = **Strict 단일 계약** — 모든 읽기 `QueryUseCase` 경유, thin read path **폐기** | 무조건 Strict. thin-path 같은 use-case 우회 읽기는 없음(capability 선언이 use-case 모양에 결합돼 정적 강제 불가 → 폐기). port-level capability 분리 수술은 thin-path 실익 증거 생길 때 후속 계약 위임 | `raw/official-docs/cqrs-fowler-bliki.md#CQRS-FOWLER-C5` (CQRS 복잡도 신중론 → 보수적 Strict 지지) / `raw/company-tech-blogs/explicit-architecture-ddd-hexagonal-cqrs-hgraca.md#HGRACA-CQRS-C1` (*기각된* thin-by-default 학파) | `engineering-blog`(Fowler caution; Graça = 기각 대안) | 해소됨(2026-06-05): thin-path 폐기로 "capability rule 우회" 구멍이 사라짐. 잔여 trade-off: 단순 조회도 `QueryUseCase` ceremony 부담을 짐 — 스켈레톤의 *기계 강제* 가치를 위해 의도적으로 수용 |
|
||||
| D4 | transaction default `TransactionPort.inRead`; no-tx read 는 opt-in | no-tx = `open-in-view=false` + projection-only(lazy 없음) + 단일 statement; 아니면 inRead | `raw/official-docs/spring-data-jpa-transactionality-spring-official.md#SPRING-DATA-TX-C1`, `#SPRING-DATA-TX-C3`, `raw/company-tech-blogs/read-only-tx-hibernate-optimization-vladmihalcea.md#VM-READTX-C3`, `raw/official-docs/spring-tx-management-reference.md#SPRING-TX-MGR-C6` | `official-vendor-doc`(Spring) + `engineering-blog`(Vlad) | trivial read 에서 no-tx 가 inRead 대비 실측 이득이 있는지 PoC 미수행(UNVERIFIED). prod OSIV(`${DB_OPEN_IN_VIEW}`) 가 false 로 운영되는지 확인 필요 |
|
||||
| D5 | projection read 의 capability = **`READ_REPOSITORY` 재사용, 신규 enum 불필요** (해소) | repository-backed projection read 는 항상 `READ_REPOSITORY`. outbound HTTP read 는 `NONE`(repository 미접근). 신규 `READ_PROJECTION` 없음 | (code-grounded) `ca-tmpl RepositoryAccess.java` = `{NONE, READ_REPOSITORY, WRITE_REPOSITORY}` — 접근 *수준* 축 | `project-decision` (code 확인) | 해소됨(2026-06-05): `RepositoryAccess`(접근 수준)와 반환 모양(aggregate/projection)은 직교 — 혼동이 논쟁의 정체였음. 반환 purity 는 D1 rule 이 담당. `GetRepoStatsUseCase` 의 `NONE` 은 outbound HTTP 라 올바른 분류(gap 아님) |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> *결정 (Decisions)* 이 "*무엇* 을 할 것인가" 라면, 본 §는 "*어디에 어떻게* 구현될 것인가" 의 사전 명세 — *문서가 모호해서 구현자가 임의로 정해야 했던 결정* 카탈로그. 작성 목표는 다음 구현자가 *되묻지 않아도 코드를 작성할 수 있는 수준*.
|
||||
>
|
||||
> **본 §는 일률적 anchor list 를 강제하지 않는다.** branch 마다 구현 내용·범위가 다르므로 sub-section 은 *이 branch 의 결정과 근거에서 도출되는 것만* 작성. 어떤 branch 는 error mapping 표 + 정적 강제 카탈로그, 어떤 branch 는 migration 단계 + wiring, 어떤 branch 는 sequence + state machine. 형식 예시는 [[raw/branch-notes/feature-boundary-validation-mapping-contract]] 의 §구현 가이드 참조.
|
||||
>
|
||||
> **3-rule meta principle (필수 준수)**:
|
||||
>
|
||||
> 1. **R1. Reference 필수** — 각 sub-section / row / cell 은 본 branch 의 `Decision ID` (예: D1, D2) + 그 결정의 `Supporting Claim ID` (예: `RAW-SLUG-C1`) 를 reference. *근거 없는 결정 금지* — 모든 구현 detail 은 결정 + 근거의 *도출* 이어야 함.
|
||||
> 2. **R2. UNSUPPORTED_IMPL_DECISION 명시** — 근거 raw 가 *원칙* 만 권고하고 *detail* (메커니즘 선택 / 클래스/rule 명명 / glob 패턴 / algorithm / factory API 모양 등) 은 권고하지 않는 cell 은 `UNSUPPORTED_IMPL_DECISION` 라벨 + 사용자 trade-off 근거 한 줄. 이게 *근거 있는 결정 vs 사용자 임의 trade-off* 의 경계.
|
||||
> 3. **R3. OUT_OF_BRANCH_SCOPE 정제** — 본 branch 결정 범위 밖 cell 은 §구현 가이드에 *남기지 않음*. 별도 branch 또는 canonical SSOT 로 이관 (이관 history 는 별도 § "Audit & Findings" 등에 보존). 도메인 특화 detail (ca-tmpl skeleton 범위 밖) 도 동일하게 정제.
|
||||
>
|
||||
> **각 sub-section 의 권장 헤더 패턴**:
|
||||
>
|
||||
> ```markdown
|
||||
> ### N. <sub-section 제목>
|
||||
>
|
||||
> > **Trace**: <In-scope row 들 + Decision ID + Supporting Claim ID 의 매핑 (한 줄/한 단락)>
|
||||
> >
|
||||
> > - **UNSUPPORTED_IMPL_DECISION**: <근거 없는 사용자 임의 결정 항목들 + 각각의 trade-off 근거 한 줄>
|
||||
>
|
||||
> <표 또는 명확한 구조 — 자유 텍스트 = 모호함 = 되묻기 원인>
|
||||
> ```
|
||||
|
||||
### 1. Read/query port 분리 + projection DTO 반환 (D1)
|
||||
|
||||
> **Trace**: D1 (AZURE-CQRS-C2 single-store CQRS, SPRING-PROJ-C2 closed projection, WAKITA-CQRS-C3 no-domain-type port). ca-tmpl anchor: 기존 `dev.caskeleton.application.usecase.QueryUseCase<Q,R>` + `application.query.Query` marker + sample 의 `RepoStatsPort`(precursor).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: read port 인터페이스 **명명/패키지** (`*QueryPort` vs `*ReadPort`, `application.<domain>.port` vs `application.query.port`) — 근거 raw 는 "application-layer port" 원칙만 권고(WAKITA-CQRS-C3), 구체 suffix/패키지는 미권고. trade-off: outbound port 기존 `*Port` 컨벤션과 충돌 회피 위해 `*QueryPort` 제안(임의).
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: projection DTO 의 **배치 layer** — application 패키지에 record 로 둘지(반환 type 이 web/JPA 가 아니어야 하므로 application 이 자연) — 근거 원칙(no domain/web/JPA type)에서 도출되나 "record in application.query.result" 같은 구체 위치는 임의.
|
||||
>
|
||||
> - **⚠️ 코어 vs 선택 분할 (구현 시 반드시 지킬 것)**: 아래 표에서 **코어(모든 프로젝트 동일 강제)는 「정적 강제」 두 행 = read/query port 의 purity 가드레일뿐**이다. 「반환 type / 조회 메커니즘」은 *projection 경로를 택했을 때* 의 명세지 *모든 읽기에 projection 을 강제* 하는 게 아니다. **단순 읽기는 기존 repository port 로 도메인 aggregate 를 반환(through-aggregate)** 해도 되며 그 경로는 본 purity rule 대상이 아니다(아래 행). projection *사용 예시*는 `sample-portfolio` 에서 시연하고 코어 enforcement 에 "projection 기본" 을 박지 않는다.
|
||||
|
||||
| 항목 | 명세 | 근거/라벨 |
|
||||
|---|---|---|
|
||||
| 코어/선택 구분 | **코어 강제** = 「정적 강제」 행(purity rule). **프로젝트 선택** = projection 경로를 쓸지 vs through-aggregate(아래) | D1 (코어/선택 분할, 2026-06-05) |
|
||||
| 반환 type (projection 경로) | 도메인 aggregate ❌ / web DTO ❌ / JPA entity ❌ → **application-layer projection DTO**(record 권장) | D1 / WAKITA-CQRS-C3 |
|
||||
| through-aggregate 경로 (선택) | 단순 읽기: 기존 repository port → 도메인 aggregate 반환. **read/query port 가 아니므로 purity rule 비대상**. read shape=write **and** 최소 aggregate 일 때 동급 선택(Alt1) | D1 / VERNON-AGG-C3 (small aggregate) |
|
||||
| 조회 메커니즘 | Spring Data **closed** interface projection 또는 `SELECT new <AppDto>(...)` JPQL 또는 JdbcTemplate RowMapper. **단, closed projection 의 column-subset SELECT 생성은 Claims#1 미검증(SPRING-PROJ-C2 는 "can optimize" 만 명시) → 검증 전까지 `SELECT new`/JdbcTemplate 를 1순위로 선호** | D1 / SPRING-PROJ-C2 |
|
||||
| nested join 주의 | closed projection 의 nested property 는 full join materialize(SPRING-PROJ-C6) → 다중 join 조회는 `SELECT new`/JdbcTemplate 선호 | D1 / SPRING-PROJ-C6 |
|
||||
| 정적 강제 (의도) | read port 메서드의 반환 type 이 `..domain..` / `..adapter..` / `jakarta.persistence..` / `org.springframework.web..` 에 속하지 않아야 함 — **직접 반환 type 뿐 아니라 generic type argument(`List<DomainType>`)까지** 차단. violations-as-data negative fixture(도메인 type 반환 read port)로 rule 이 실제로 잡는지 역검증 | D1 / WAKITA-CQRS-C3 (no-domain-type port 원칙) |
|
||||
| 정적 강제 (ArchUnit 구체 API) | **UNSUPPORTED_IMPL_DECISION / Claims#2** — 정확한 ArchUnit 구성은 구현 시 사용 중인 ArchUnit 버전 Javadoc 으로 확정. 후보: (a) 직접 반환 type 은 `methods()...should().haveRawReturnType(DescribedPredicate)` 계열(predicate overload 존재 여부·이름은 버전 의존 → copy 전 확인 필수), (b) `List<DomainType>` 등 **generic type argument 누출은 raw-type 검사로 못 잡으므로 custom `ArchCondition<JavaMethod>`** 가 메서드 반환의 type parameter 까지 들여다봐야 함. 즉 (a) 단독으로는 불충분 — 이 한계 자체가 trade-off 근거 | D1. **UNSUPPORTED_IMPL_DECISION**: 근거 raw 는 "domain type 미노출" 원칙(WAKITA-CQRS-C3)만 권고하고 정적 강제의 구체 API 는 미권고 → 위 (a)/(b) 조합은 구현 fixture 로 확정, 노트의 DSL 을 그대로 copy 하지 말 것 |
|
||||
| 기존 자산 정합 | `GetRepoStatsUseCase` 는 D1 패턴의 precursor지만 `RepoStats`(도메인 type) 반환 → D1 적용 시 projection DTO 로 이관 후보(planned) | ca-tmpl ground-truth |
|
||||
|
||||
### 2. use-case ceremony = Strict 확정 (D3)
|
||||
|
||||
> **Trace**: D3 (CQRS-FOWLER-C5 CQRS 복잡도 신중론 → 보수적 Strict 지지). ca-tmpl anchor: 선행 계약의 `inbound_port_implementations_declare_capability` / `_end_with_use_case` / `_declare_capability` rule (actually-implemented) — `@UseCaseCapability` 는 **use-case 구현체에만** 부착되고 그 rule 들이 use-case 구현체를 대상으로 capability 를 강제한다.
|
||||
>
|
||||
> - **결정 (2026-06-05)**: 읽기 경로는 **단일 경로 = Strict.** thin read path(web→read port 직접)는 *폐기.* 근거: capability 선언이 use-case 모양에 결합돼 있어, use-case 가 아닌 thin-path read 는 capability fitness function 에 안 잡힌다(정적 강제 불가). 스켈레톤의 핵심 가치는 *기계 강제* 이므로 ceremony 절감을 위해 이를 포기하지 않는다. capability 를 use-case 에서 분리(port-level capability + rule)하는 수술은 **후속 계약으로 위임**(thin-path 실익 증거가 생길 때) — 현재 미생성.
|
||||
|
||||
| 경로 | 허용 | capability 선언 | 비고 |
|
||||
|---|---|---|---|
|
||||
| Strict (유일 경로) | `QueryUseCase` 구현 → read/query port | `@UseCaseCapability(transactionMode=READ_ONLY, repositoryAccess=READ_REPOSITORY)` mandatory (repository projection read). outbound HTTP read 는 `repositoryAccess=NONE` | 선행 계약 rule 그대로 — 무변경 |
|
||||
| ~~thin read path~~ | **폐기** — web 이 read port 직접 호출하는 경로 없음 | — | use-case⇄capability 결합이 풀리는 후속 계약 전까지 열지 않음 |
|
||||
|
||||
> **F4 (해소)**: 이전엔 thin read path 가 `inbound_port_implementations_declare_capability` 를 우회하는 구멍이었고 D5 결정에 종속됐다. **thin-path 폐기로 구멍이 제거**됐다 — 모든 읽기가 `QueryUseCase` 이므로 capability 가 항상 선언·강제된다. capability 어휘(D5)는 `READ_REPOSITORY` 재사용으로 해소(신규 enum 불필요) → 본 §는 선행 계약 rule 을 그대로 쓰며 신규 ArchUnit rule 이 필요 없다.
|
||||
|
||||
### 3. read transaction 정책 (D4)
|
||||
|
||||
> **Trace**: D4 (SPRING-DATA-TX-C1 CrudRepository readOnly 기본, SPRING-DATA-TX-C3 unit-of-work 권고, VM-READTX-C3 readOnly 이득=entity 多). ca-tmpl anchor: `TransactionPort.inRead`(application-core) + `SpringTransactionPort`(READ_COMMITTED pinned) + OSIV `application-test.yml=false`/`application.yml=${DB_OPEN_IN_VIEW}`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: no-tx opt-in 의 **강제 방식** — 근거는 정책 권고만, "어떻게 막을지"(ArchUnit? 문서?) 미권고. trade-off: no-tx 조회가 lazy 를 건드리면 OSIV=false 에서 `LazyInitializationException` → **projection-only + 단일 statement** 를 전제로만 허용, 정적 강제 대신 read port 가 도메인 entity 를 반환 안 한다는 D1 rule 로 간접 보증.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: no-tx 의 **latency/connection 이득** 자체 — VM-READTX-C3 는 *entity 多 bulk read 의 메모리 절약*만 지지하며 *trivial single-statement read 의 connection/latency 이득* 은 인용 범위 밖이다(역방향 추론). no-tx opt-in 의 정당화는 Claims#4 의 JMH/부하 PoC 결과로만 확정 — PoC 전까지 "no-tx 가 더 빠르다" 단정 금지.
|
||||
|
||||
| read 형태 | tx 정책 | 조건 |
|
||||
|---|---|---|
|
||||
| lazy 연관 접근 있는 read | **반드시** `TransactionPort.inRead` | OSIV=false 에서 tx 밖 lazy = 예외 |
|
||||
| projection-only 단일 statement read | inRead default, no-tx opt-in 허용 | **선결 조건**: 배포 env/`env-keys.yaml` 의 `DB_OPEN_IN_VIEW` 기본값 = `false` 확인 필수(Claims#5). **미확인 시 no-tx opt-in 은 Disabled** — `application.yml` 이 env 위임(`${DB_OPEN_IN_VIEW}`)이라 prod 값 미확정이면 tx 밖 lazy 안전 전제가 깨짐 |
|
||||
|
||||
> **OUT_OF_BRANCH_SCOPE 정제(R3)**: 격리 수준(REPEATABLE_READ 등)은 `feature-transaction-concurrency-contract`, 캐시 우회는 `feature-cache-consistency-contract`, idempotency 는 [[raw/branch-notes/feature-rate-limit-idempotency-contract]] SSOT — 본 §에 detail 남기지 않음(링크만). Full CQRS read-store 구현(D2)도 별도 branch.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **lazy + no-tx**: projection-only 가 아닌데 no-tx(D4 opt-in)로 조회 후 lazy 연관 접근 → OSIV=false 에서 `LazyInitializationException`. 기대 동작: read port 가 도메인 entity 를 반환 안 함(D1)으로 구조적 차단, 위반 시 ArchUnit 실패.
|
||||
- **closed projection nested join**: nested property 포함 closed projection 은 full join materialize(SPRING-PROJ-C6) → 의도와 다른 over-fetch. 기대 동작: 다중 join 은 `SELECT new`/JdbcTemplate 로 명시.
|
||||
- **~~thin path 남용~~ (해소, 2026-06-05)**: thin-path 자체를 폐기(D3 Strict 확정) → use-case 우회 read 경로가 없으므로 `@UseCaseCapability` 선언을 우회하는 read 가 구조적으로 불가능. 모든 읽기는 `QueryUseCase` 이고 선행 계약 rule 이 capability 를 강제.
|
||||
- **capability 표기(D5 해소)**: repository projection read 는 `READ_REPOSITORY`(접근 수준 축), outbound HTTP read 는 `NONE`. 반환 모양(projection)은 capability 와 직교 — purity 는 D1 반환타입 rule 이 담당. fitness function 은 기존 그대로 권한 상향(read→write)을 잡는다.
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-application-port-usecase-contract]] 의 `D9`(`QueryUseCase`+`READ_REPOSITORY`+`inRead`) / `D1`(`*UseCase` 명명) / 판정기준(mandatory `@UseCaseCapability`) — 본 branch 는 그 계약의 read 경로를 *확장*(projection 반환 허용)할 뿐, **ceremony 는 그대로 Strict 유지**(thin-path 폐기로 *완화* 없음). 그 계약의 capability enum/rule 이 바뀌면 D1 영향. 또한 같은 계약의 `D12`(HikariCP pool sizing SSOT, `inNew` 전용)에 read `inRead` connection 점유의 pool 영향도 위임 — read no-tx(D4) 가 pool 경합을 바꾸면 D12 재검토. / **후속 위임**: capability 를 use-case 모양에서 분리(port-level capability)하는 수술은 thin-path 실익 증거가 생길 때 별도 계약(미생성)이 이 계약의 capability 메커니즘을 확장 — 본 branch 는 그 수술을 *하지 않기로* 결정(D3).
|
||||
- [[raw/branch-notes/feature-transaction-concurrency-contract]] 의 `D3`(isolation default=`READ_COMMITTED`) — D4 의 read tx 격리는 여기에 위임. 그 `D3` 가 바뀌면 D4 opt-in 조건 재검토 필요.
|
||||
- [[raw/branch-notes/feature-cache-consistency-contract]] — read 의 *캐시* 우회는 거기 SSOT. 본 branch 는 *모델/데이터소스* 우회만(경계 충돌 주의).
|
||||
- [[raw/branch-notes/feature-outbound-http-client-baseline]] — outbound HTTP read(`RepoStatsPort` 같은 external read adapter)의 RestClient/Resilience4j/timeout 계약은 거기 SSOT. 본 branch 는 그 read 의 capability 표기(`NONE` — repository 미접근, D5 해소)만 확인하고 HTTP 계약은 위임.
|
||||
- [[raw/branch-notes/feature-persistence-failure-baseline]] — read projection query 의 오류 분류(SQLState `57014` query canceled / `08*` connection 등)는 거기 SSOT. 본 branch read 경로도 동일 오류 경로 사용 → 위임.
|
||||
- (escalation 시) D2 → 별도 `feature-cqrs-read-store-contract`(미생성) 가 separate read store + 동기화 pipeline 소유.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다.
|
||||
> 구현 전/중/후에 실제로 검증해야 하는 주장을 분리한다.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| Spring Data **closed projection** 이 Hibernate 6(ca-tmpl) 에서 실제로 column-subset SELECT 를 생성한다 | SPRING-PROJ-C2 는 "optimize" 만 명시, JPA provider 별 동작 보장 아님(SPRING-PROJ-C4) | Testcontainers + Hibernate SQL 로그로 SELECT 컬럼 목록 확인 (projection vs entity 비교) | `planned` |
|
||||
| read/query port 반환 type 이 도메인/web/JPA type 이 아님을 ArchUnit 으로 정적 강제 가능 | 메서드 반환 type 의존성 검사 rule wording 미작성 | `methods().that().areDeclaredInClassesThat().haveSimpleNameEndingWith("QueryPort").should().notHaveRawReturnType(...)` 류 rule + violation fixture | `planned` |
|
||||
| ~~thin read path 의 "domain logic 없음" 을 자동 강제할 수 없다~~ (D3 Strict 확정으로 **무효화**, 2026-06-05) | thin-path 자체를 폐기 → 검증 대상 아님 | (해당 없음 — thin-path 경로 제거) | `obsolete` |
|
||||
| trivial projection read 에서 no-tx 가 `inRead` 대비 실측 이득(connection 점유/latency)이 있다(D4 opt-in 정당화) | VM-READTX-C3 는 메모리 절약만 — connection/latency 정량 미증명(역명제 비함의) | JMH/부하 테스트로 no-tx vs inRead 단일 row SELECT 비교 | `planned` |
|
||||
| ca-tmpl prod 의 `${DB_OPEN_IN_VIEW}` 가 실제 `false` 로 운영된다(D4 안전 전제) | `application.yml` 은 env 위임 — 실제 값 미확인(test 만 false 확인됨) | 배포 env/`env-keys.yaml` registry 의 `DB_OPEN_IN_VIEW` 기본값 확인 | `needs-confirmation` |
|
||||
| `GetRepoStatsUseCase`(`RepoStats` 도메인 type 반환)를 D1 projection-DTO 패턴으로 이관 가능 | 도메인 type 반환을 application projection record 로 바꾸는 작업 — 단 이건 *outbound HTTP* read 라 capability 는 `NONE` 유지(repository 미접근, D5 해소) | `RepoStats` → application projection record 이관 PoC. capability 는 `NONE` 그대로 | `planned` |
|
||||
| Netflix Tudum separate-store friction 근거(D2) | netflixtechblog SSL 오류로 2차 출처(ByteByteGo) 의존 — 1차 미확인 | 원 netflixtechblog 글 직접 재fetch 또는 InfoQ 교차확인 | `needs-confirmation` |
|
||||
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
> `/coverage` 가 채우는 **생성물** — 손으로 유지하지 않는다. governing 문서(frontmatter `governing_docs`)가 요구하는 관심사를 이 브랜치가 빠짐없이 덮는지의 결과. 기준: `rules/coverage-gate.md`.
|
||||
> 상태: `covered-here`(이 브랜치 결정) / `delegated`(다른 owner 브랜치) / `missing`(아무도 안 맡음 → Blocking).
|
||||
|
||||
> 마지막 감사: 2026-06-04 (coverage-auditor) → **Covered** (Blocking 0 / Should-fix 3 → 위임 링크 추가로 해소 / Advisory 1). governing_docs: `clean-architecture-package-layout` + `data-layer-persistence-cache-outbound`.
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|--------|------|-------|--------|------|
|
||||
| Application layer 가 adapter/transport/JPA 에 의존하지 않음 (ArchUnit isolation) | delegated | [[raw/branch-notes/feature-application-port-usecase-contract]] | OK | `application_does_not_depend_on_adapters_or_transport` actually-implemented. §Edge 위임 링크 |
|
||||
| QueryUseCase 의 `@UseCaseCapability` mandatory 선언 | delegated | [[raw/branch-notes/feature-application-port-usecase-contract]] | OK | `inbound_port_implementations_declare_capability`. D3 Open Risk + §Edge 링크 |
|
||||
| QueryUseCase 명명(`*UseCase` suffix) | delegated | [[raw/branch-notes/feature-application-port-usecase-contract]] | OK | `inbound_port_implementations_end_with_use_case`. §Edge 링크 |
|
||||
| thin read path 가 capability rule 을 우회하는 문제 | covered-here | — | — | D3 (2026-06-05 Strict 확정 = thin-path **폐기**) → 우회 경로 자체가 제거됨. 모든 읽기 `QueryUseCase` 경유 |
|
||||
| Read/query port 의 application 패키지 배치(도메인·어댑터 아님) | covered-here | — | — | D1 (hexagonal purity, 도메인 type 미노출) |
|
||||
| Read port 반환 type 의 domain/JPA/web 누출 방지 ArchUnit rule | covered-here | — | — | D1 §구현 가이드 §1 (DSL skeleton, `planned` — Claims#2) |
|
||||
| Full CQRS 별도 read store 모듈 경계 | covered-here | — | — | D2 (escalation trigger 문서화, 별도 branch 위임) |
|
||||
| Read-side 영속성 모델: aggregate vs projection | covered-here | — | — | D1 (코어=purity 가드레일 강제 / projection vs through-aggregate=프로젝트 선택, 2026-06-05 분할) |
|
||||
| OSIV off 가 read transaction 경계에 미치는 영향 | covered-here | — | — | D4 (OSIV=false 전제 no-tx opt-in; prod env gap Claims#5) |
|
||||
| Cache bypass(strict consistency read) | delegated | [[raw/branch-notes/feature-cache-consistency-contract]] | OK | §Out of scope + §Edge 위임 링크 |
|
||||
| Read 격리 수준(REPEATABLE_READ 등) | delegated | [[raw/branch-notes/feature-transaction-concurrency-contract]] (D3) | OK | §Out of scope + §Edge 위임 링크 |
|
||||
| 읽기용 Outbound HTTP 경로(`RepoStatsPort` 등 external read) | delegated | [[raw/branch-notes/feature-outbound-http-client-baseline]] | OK (해소) | §Edge 위임 링크 추가됨. D5 는 capability 어휘만 |
|
||||
| Read-path connection pool 영향(HikariCP + no-tx) | delegated | [[raw/branch-notes/feature-application-port-usecase-contract]] (D12) | OK (해소) | §Edge 위임 링크 추가됨 |
|
||||
| Read-path 오류 분류(SQLState 57014/08* 등) | delegated | [[raw/branch-notes/feature-persistence-failure-baseline]] | OK (해소) | §Edge 위임 링크 추가됨 |
|
||||
| Capability 어휘 확장(`READ_PROJECTION` 신설 여부) | covered-here | — | — | D5 (2026-06-05 해소: 신규 enum 불필요 — projection read = `READ_REPOSITORY`, 접근 수준과 반환 모양은 직교) |
|
||||
| Read replica lag/라우팅 정책 | missing | (없음) | ⚪ Advisory | data-layer doc 이 `documented-only` 로만 명시. projection query ≠ replica routing — 본 branch 범위 밖, 프로젝트 레벨 gap(비-Blocking) |
|
||||
|
||||
## 감사 이력
|
||||
|
||||
> branch-spec / depth / coverage 게이트가 남긴 감사 흔적. 위임 결정의 audit trail 과 깊이 보강 이력을 한 곳에 모은다(§Coverage 표·§Edge prose 와 중복이 아니라 *왜 그렇게 분류·수정했는지* 의 근거).
|
||||
|
||||
### 위임 audit trail (coverage)
|
||||
|
||||
본 branch 는 governing_docs(`clean-architecture-package-layout` + `data-layer-persistence-cache-outbound`)가 요구하는 관심사 중 다음을 **명시적으로 다른 owner branch 에 위임**한다. 모든 위임처는 `raw/branch-notes/` 에 실재하며 §Edge 에 wikilink 가 있다(2026-06-05 재검증).
|
||||
|
||||
| 위임 관심사 | owner branch | 위임 근거 |
|
||||
|---|---|---|
|
||||
| Application layer isolation / `@UseCaseCapability` mandatory / `*UseCase` 명명 | `feature-application-port-usecase-contract` | 본 branch 의 read 경로가 그 계약의 *확장*(projection 반환)일 뿐 ceremony 는 Strict 유지(thin-path 폐기). 계약 rule 자체는 그 branch 소유 |
|
||||
| Read-path connection pool 영향(HikariCP + no-tx) | [[raw/branch-notes/feature-application-port-usecase-contract]] (D12) | `inNew` pool-sizing SSOT. read no-tx(D4) 가 pool 경합을 바꾸면 D12 재검토 — 위임이되 역영향 경로 명시 |
|
||||
| Read 격리 수준(REPEATABLE_READ 등) | [[raw/branch-notes/feature-transaction-concurrency-contract]] (D3) | isolation SSOT. D4 의 read tx 격리는 여기 위임 |
|
||||
| Cache bypass(strict consistency) | `feature-cache-consistency-contract` | 본 branch 는 *모델/데이터소스* 우회만, *캐시* 우회는 거기 SSOT |
|
||||
| 읽기용 Outbound HTTP(`RepoStatsPort` external read) | [[raw/branch-notes/feature-outbound-http-client-baseline]] | RestClient/Resilience4j/timeout SSOT. 본 branch 는 capability 어휘(D5)만 |
|
||||
| Read-path 오류 분류(SQLState 57014/08*) | `feature-persistence-failure-baseline` | SQLState classifier SSOT |
|
||||
|
||||
### 미할당 project-level gap (Advisory, 비-Blocking)
|
||||
|
||||
- **Read replica lag / 라우팅 정책**: `data-layer-persistence-cache-outbound` 가 `documented-only` 로만 명시, ca-tmpl `src/` 에 관련 코드 0건, 어느 branch 도 소유 안 함. projection query ≠ replica routing 이므로 본 branch 범위 밖. replica routing 이 운영상 필요해지면 별도 `feature-read-replica-routing-contract` 신설·할당 권고. 그 전까지 Advisory 로 유지.
|
||||
|
||||
### 깊이 게이트 보강 이력 (2026-06-05, depth-auditor 후속)
|
||||
|
||||
- **(Blocking 해소)** §구현 가이드 §1 「정적 강제」: ArchUnit DSL 을 copy 가능한 구체 호출로 제시하던 것을 *의도(intent)* 와 *구체 API(UNSUPPORTED_IMPL_DECISION/Claims#2)* 로 분리. `notHaveRawReturnType` 등 predicate overload 는 ArchUnit 버전 의존 + raw-type 만 검사해 `List<DomainType>` generic 누출을 못 잡으므로 custom `ArchCondition<JavaMethod>` 가 필요함을 명시 — 노트의 DSL 을 그대로 copy 금지.
|
||||
- **(Should-fix 해소)** §1 조회 메커니즘: closed projection 의 column-subset SELECT 가 Claims#1 미검증임을 명시하고 `SELECT new`/JdbcTemplate 1순위 선호로 보강.
|
||||
- **(Should-fix 해소)** §3: no-tx 의 latency/connection 이득이 VM-READTX-C3 직접 지지 범위 밖(역방향 추론)임을 UNSUPPORTED_IMPL_DECISION 으로 추가, Claims#4 PoC 종속.
|
||||
- **(Should-fix 해소)** §3 no-tx opt-in 행: prod `DB_OPEN_IN_VIEW=false` 확인을 *선결 조건* 으로 승격(미확인=Disabled), Claims#5 연결.
|
||||
|
||||
### 계약 정합 재결정 (2026-06-05): thin-path 폐기 + D5 해소
|
||||
|
||||
> 사용자와의 설계 검토에서 "선행 계약(`feature-application-port-usecase-contract`)이 너무 강한 강제성을 두어 후속 계약의 선택 폭이 좁아지는 것 아닌가"라는 비판을 검토한 결과. **근본 원인 진단**: 선행 계약은 capability 선언을 *use-case 모양*에 결합(`@UseCaseCapability` 는 use-case 구현체에만 부착)했다. 따라서 use-case 가 아닌 thin-path read 는 capability 를 달 곳이 없어 fitness function 에 안 잡힌다 — 이게 thin-path 를 막던(=D5 종속) 진짜 원인이었고, "enum 어휘(`READ_PROJECTION`) 부재"는 표면 증상이었다.
|
||||
|
||||
- **D3 → Strict 단일 계약으로 확정 (thin-path 폐기).** 대안이었던 "capability 를 use-case 에서 분리(port-level capability + rule)"하는 foundation 수술은 *하지 않기로* 결정. 이유: thin-path 의 ceremony 절감은 modest 한데, 그걸 위해 스켈레톤의 핵심 가치인 *아키텍처 기계 강제* 를 코드리뷰 신뢰로 격하시키는 비용이 크다. thin-path 실익 증거가 생기면 그때 후속 계약(D14 의 freeze-with-guard 패턴처럼)으로 foundation 의 capability 메커니즘을 확장. → **foundation 무변경.**
|
||||
- **D5 → 해소 (신규 enum 불필요).** `ca-tmpl/.../capability/RepositoryAccess.java` 확인 결과 `{NONE, READ_REPOSITORY, WRITE_REPOSITORY}` = repository 접근 *수준* 축. "aggregate/projection"은 반환 *모양* 축이라 직교 → repository projection read 는 `READ_REPOSITORY`, outbound HTTP read 는 `NONE`(올바른 분류, gap 아님). 반환 모양 purity 는 D1 의 반환타입 rule 이 담당. `READ_PROJECTION` 논쟁은 두 축의 혼동이었음.
|
||||
- **영향 정리**: §결정 D3/D5, §Decision Evidence Map D3/D5, §구현 가이드 §2(thin-path row 제거 + F4 해소), §Edge(thin path 남용·capability 모호 항목 해소), Claims(thin-path domain-logic claim → `obsolete`; GetRepoStatsUseCase 이관 claim → capability `NONE` 유지로 명확화), §Coverage(thin-path·READ_PROJECTION row 해소) 일괄 갱신. D1·D2·D4 는 무영향.
|
||||
|
||||
### D1 코어/선택 분할 명문화 (2026-06-05)
|
||||
|
||||
> "스켈레톤은 *보편적으로 모두가 같게 쓰는 것*만 코어에 강제해야 한다(복사되는 물건이라 안 쓰는 코드 = 지울 수 없는 인지 비용)"는 원칙을 D1 에 적용. 구현 착수 전, projection 이 *강제 디폴트* 로 코어에 박히는 것을 방지하기 위함.
|
||||
|
||||
- **분할 결정**: D1 의 산출물 중 **코어(모든 프로젝트 동일 강제) = read/query port 의 purity 가드레일**(반환 type 이 domain/JPA/web 누출 금지 ArchUnit rule + read port 추상화 모양)뿐이다. **"projection 을 기본으로 써라"는 코어에 강제하지 않는다** — projection vs through-aggregate 는 *프로젝트 선택*(단순 CRUD 는 through-aggregate via 기존 repository port 가 동급·기본). projection *사용 예시*는 `sample-portfolio` 에서 시연(`production_code_does_not_depend_on_sample_portfolio` 로 격리).
|
||||
- **근거**: purity 가드레일은 read port 를 *쓸 때* 깨끗함을 보장하는 보편 불변식(도메인 무관) → 코어 적합. 반면 projection 채택은 read 최적화라 *상황적*(read shape ≠ write 이거나 hydration 비용 회피 시 이득) → 강제 시 작은 CRUD 에 불필요한 over-engineering. ca-tmpl `RepositoryAccess`(접근 수준)와 직교한 반환 모양 축이므로 capability 강제와도 무관.
|
||||
- **구현 지침**: read-port 추상화 + purity rule 은 코어(`application-core` + ArchUnit)에. projection record/조회 메커니즘 *예시*는 sample. 모든 읽기에 projection port 를 만들지 말 것 — 능력·가드레일만 코어, 사용은 read 마다 선택.
|
||||
- **영향**: §목표 헤드라인, §결정 D1, §Decision Evidence Map D1, §구현 가이드 §1(코어/선택 구분 행 + through-aggregate 경로 행 추가), §Coverage(aggregate vs projection row) 갱신. D2·D3·D4·D5 무영향.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
> 짧은 메모만. 깊이 있는 트러블슈팅은 `raw/errors/` 로 분리하고 아래 Cluster에 연결.
|
||||
|
||||
- 이슈 1
|
||||
- 원인:
|
||||
- 시도:
|
||||
- 해결: (또는 미해결이면 `needs-confirmation`)
|
||||
- 별도 에러 노트로 분리됨: `[[raw/errors/<...>]]` (생성 시)
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/cqrs-lite-clean-architecture-read-path-bypass-wakita]]
|
||||
- [[raw/company-tech-blogs/explicit-architecture-ddd-hexagonal-cqrs-hgraca]]
|
||||
- [[raw/company-tech-blogs/netflix-tudum-cqrs-separate-read-store-evolution]]
|
||||
- [[raw/company-tech-blogs/read-only-tx-hibernate-optimization-vladmihalcea]]
|
||||
- [[raw/official-docs/cqrs-pattern-azure-architecture-center]]
|
||||
- [[raw/official-docs/spring-data-jpa-projections-spring-official]]
|
||||
- [[raw/official-docs/spring-data-jpa-transactionality-spring-official]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/archunit-generic-return-type-purity-query-port-2026-06-05]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
> 이 branch는 단일 노트가 아니라 **작업 묶음의 entry point**. 이 branch에서 파생된 모든 raw 노트를 카테고리별로 명시. 자식 노트가 forward link만 박아도 Obsidian backlink로 자동 발견되지만, 읽기 흐름과 분류를 위해 hub가 명시적으로 그룹화한다.
|
||||
|
||||
### Sub-branches (세부 작업)
|
||||
|
||||
- `[[raw/branch-notes/<sub-branch-1>]]` — <한 줄 요약>
|
||||
- `[[raw/branch-notes/<sub-branch-2>]]` — <한 줄 요약>
|
||||
|
||||
### 오류 기록 (이 branch 작업 중 발생)
|
||||
|
||||
- (없음 — 구현 중 에러 없음. JPQL `SELECT new` / generic-arg ArchUnit API 모두 1차 통과)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (별도 노트 미생성 — 면접 각도는 아래 blog-topic 의 "type erasure 가 정적 분석 사각지대를 만든다" 로 충분히 커버. 필요 시 분리)
|
||||
|
||||
### 강의 (이 작업을 위해 학습한 강의)
|
||||
|
||||
- (없음)
|
||||
|
||||
### job-posting tie-ins (이 작업에서 파생된 글감)
|
||||
|
||||
- [[raw/blog-topics/archunit-generic-return-type-purity-query-port-2026-06-05]] — D1 purity rule 의 generic type argument 검사 기법(`JavaType.getAllInvolvedRawTypes()`) 단독 추출
|
||||
- derived blog: 생성 전. 생성 시 `wiki/blog/<slug>-YYYY-MM-DD.md` 후보
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
> 이 브랜치를 작업한 날짜들. 양방향 nav 유지.
|
||||
|
||||
- `[[raw/daily-notes/YYYY-MM-DD]]`
|
||||
- `[[raw/daily-notes/YYYY-MM-DD]]`
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움. `/ingest`가 이 섹션을 기준으로 wiki/projects/에 추출.
|
||||
|
||||
- PR 링크: (미생성 — ca-tmpl 작업 브랜치 `feature/business-rule-validation-contract` 위에서 구현)
|
||||
- 리뷰 메모: 2026-06-05 구현 완료. D1(core+demo)/D3/D4/D5 코드화, D2 documented-only 유지.
|
||||
- 머지 결과 / 배포 환경: **로컬 검증 완료(locally-verified)**. prod 미배포.
|
||||
- **구현 산출물 (ca-tmpl, 2026-06-05)**:
|
||||
- **D1 core (purity guardrail)** — `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java`: `query_ports_do_not_leak_domain_jpa_or_web_types` ArchUnit rule + custom `ArchCondition<JavaMethod>` `notLeakDomainJpaOrWebThroughReturnType(...)` (사용 API: `JavaMethod.getReturnType().getAllInvolvedRawTypes()` → generic type argument 의 erasure 까지 평탄화. Claims#2 의 "raw-type 검사로는 `List<DomainType>` 못 잡음" 을 custom condition 으로 해소). 타겟: `..application..` + simple name `*QueryPort`. 금지 패키지: `..domain.. / ..adapter.. / jakarta.persistence.. / javax.persistence.. / org.springframework.web.. / org.hibernate..`.
|
||||
- **D1 fixtures (violations-as-data + over-block)** — `.../violations/application/RawLeakQueryPort.java`(raw leak), `.../violations/application/GenericLeakQueryPort.java`(generic-only leak — generic 검사 증명), `.../allowed/application/CleanProjectionQueryPort.java`(over-block guard) + `ArchitectureViolationFixtureTest`의 3 isolated 테스트.
|
||||
- **D1 demo (sample-portfolio, projection read 경로)** — `application/query/WorkLogSummary.java`(projection record), `application/query/ListRecentWorkLogSummariesQuery.java`, `application/port/WorkLogSummaryQueryPort.java`, `application/worklog/ListRecentWorkLogSummariesUseCase.java`, 영속 `adapter/persistence/repository/WorkLogSummaryRow.java` + `WorkLogJpaRepository.findRecentSummaryRows`(JPQL `SELECT new` column-subset) + `WorkLogSummaryQueryAdapter.java`(UUID→ULID 매핑). production 코어에 "projection 기본" 미강제 — 코어는 purity rule 만, 사용 시연은 sample 격리(`production_code_does_not_depend_on_sample_portfolio`).
|
||||
- **D3/D4/D5 문서화** — `src/application-core/CLAUDE.md` §Read/query path(through-aggregate vs projection 표 + D1~D5) + §ArchUnit guardrails 에 신규 rule 등재.
|
||||
- **검증 결과 (2026-06-05, cd src)**:
|
||||
- `./gradlew :sample-portfolio:test` → BUILD SUCCESSFUL (신규 `ListRecentWorkLogSummariesUseCaseTest` 2/2, `WorkLogSummaryQueryAdapterTest` 2/2; `@SpringBootTest` 컨텍스트 부팅 = JPQL `SELECT new` 시동시 검증 통과)
|
||||
- `./gradlew :app-bootstrap:test` → BUILD SUCCESSFUL (`CleanArchitectureTest` 36/36 — 신규 rule 포함; `ArchitectureViolationFixtureTest` 30/30 — 신규 D1 3 테스트 포함)
|
||||
- `./gradlew verifyCleanArchitectureDependencies` → BUILD SUCCESSFUL
|
||||
- **미해소 Claims (구현으로 닫지 않음, 의도적)**: Claims#1(closed projection column-subset — `SELECT new` 채택으로 회피, PoC 불요), Claims#4(no-tx latency PoC — `inRead` default 유지로 미수행), Claims#5(prod `DB_OPEN_IN_VIEW=false` — no-tx opt-in Disabled 전제로 유지). D2 escalation 정량 임계는 `UNSUPPORTED_IMPL_DECISION` 유지.
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목: D3(Strict ceremony), D4(inRead default), D5(`READ_REPOSITORY` 재사용)
|
||||
- `locally-verified` 항목: D1(purity guardrail rule + projection demo)
|
||||
- `prod-verified` 항목: (없음 — prod 미배포)
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned): D2(documented-only, separate read store)
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-architecture-enforcement-rules.md
|
||||
@@ -0,0 +1,392 @@
|
||||
---
|
||||
title: branch / feature-architecture-enforcement-rules
|
||||
source_type: branch-note
|
||||
status: verified
|
||||
branch: feature-architecture-enforcement-rules
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
tags: [branch, ca-skeleton, architecture, enforcement, archunit, clean-architecture]
|
||||
created: 2026-05-21
|
||||
last_reviewed: 2026-06-04
|
||||
target_merge: master
|
||||
status_label: review
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-018
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-018
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-ARCHTEST-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: a1912f62e082b02487a0a332eef101b12c056ac485ec9d1bbb42e4e1590ecb05
|
||||
---
|
||||
|
||||
> **Ground-truth 대조 (2026-06-04, ca-tmpl `@db61075`)**: 본 branch가 정의한 enforcement rule이 실제 레포에 반영됨을 확인. `app-bootstrap/.../architecture/CleanArchitectureTest.java`에 `domain_is_pure`(Lombok ban 포함, D3), `application_does_not_depend_on_adapters_or_transport`, `application_does_not_use_spring_transactional_annotation`, `application_does_not_depend_on_application_context`(D11, banned-class), adapter-adapter 격리 3종, `web_dtos_stay_in_web_adapter`, `shared_contract_contains_only_operational_contract_packages`, `production_code_does_not_depend_on_sample_portfolio` 존재. `src/build.gradle:53` `verifyCleanArchitectureDependencies` + `allowedProjectDependencies` matrix(9 module) 존재. `ArchitectureViolationFixtureTest` + `architecture/violations/`에 negative fixture 존재(`SpringDependentDomainFixture`·`ApplicationContextDependentFixture`·`TransactionalAnnotatedFixture` 포함). D11 string-key bypass(D12)는 rule 주석에 한계로 명시됨 — `getBean(Class)`까지만 catch. `wiki/projects/ca-tmpl/clean-architecture-package-layout`에 enforcement dimension 추출 완료. ⚠️ ground-truth `CleanArchitectureTest`는 이후 다른 branch slice rule도 다수 포함(현재 30+ rule)하므로, 추출은 본 branch 소유 항목만 한정함.
|
||||
|
||||
# branch: feature-architecture-enforcement-rules
|
||||
|
||||
> Layer: `raw/branch-notes/` — Clean Architecture 경계와 skeleton 계약을 architecture test로 강제하는 기준을 정의합니다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 §20 Skeleton Blueprint Contract 와 §25 Critical Defaults 의 architecture enforcement 영역을 정제한다.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: forbidden module/import fixture가 ArchUnit gate에서 실패한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1` | Gradle multi-module에서 domain-core·application-core·adapter-*·shared-contract·app-bootstrap·sample-portfolio 책임을 분리한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-ARCHTEST-001@1` | architecture test는 archunit-junit5 1.3.0을 사용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
문서 기준만으로는 시간이 지나면 module dependency, application boundary, domain purity, adapter boundary, sample isolation 이 무너집니다. `feature-skeleton-package-blueprint-contract`가 Gradle multi-module 구조를 기본값으로 고정했으므로, 본 branch는 그 구조가 실제 코드에서 깨지면 Gradle/ArchUnit test가 실패하도록 강제 기준을 정의합니다.
|
||||
|
||||
- 이슈: (없음 — local branch, 이슈 트래커 미사용)
|
||||
- PR: (미생성 — local verification only, not merged)
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- Gradle multi-module dependency rule.
|
||||
- `domain-core` framework import 금지.
|
||||
- `application-core` -> `adapter-*` / `app-bootstrap` 의존 금지.
|
||||
- adapter module 간 직접 의존 금지.
|
||||
- `shared-contract` business/domain concept 오염 방지.
|
||||
- `sample-portfolio` production 역수입 금지.
|
||||
- mapper boundary rule.
|
||||
- transaction annotation forbidden import rule.
|
||||
- ArchUnit rule 위치와 실행 기준.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- formatter / style lint 규칙.
|
||||
- business package naming 강제.
|
||||
- Spring Modulith verifier 도입.
|
||||
- SonarQube custom rule 구현.
|
||||
- CI workflow job 분리 구현. CI 실행 시점은 `feature-ci-quality-gates-contract`에서 최종화.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 branch의 결정 근거. multi-module 기본값은 company-case-study 근거가 중심이므로, 공식 best practice가 아니라 사례 기반 프로젝트 결정으로 취급한다.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/archunit-user-guide]] | ArchUnit rule / `@ArchTest` / dependency check 구현 근거 |
|
||||
| [[raw/official-docs/governance-archunit-official]] | architecture rule을 test로 강제하는 기본 근거 |
|
||||
| [[raw/official-docs/archunit-conditional-on-property-3-layer-pattern]] | predicate/condition 기반 ArchUnit fitness function 근거 |
|
||||
| [[raw/official-docs/arch-clean-architecture-uncle-bob]] | Dependency Rule 및 framework-independent domain 사고 근거 (`engineering-blog`, official standard 아님) |
|
||||
| [[raw/official-docs/arch-hexagonal-cockburn]] | ports/adapters inside/outside asymmetry 근거 (`engineering-blog`, official standard 아님) |
|
||||
| [[raw/company-tech-blogs/woowahan-hexagonal-multimodule]] | Domain / Application / Framework / Bootstrap multi-module hexagonal 사례 |
|
||||
| [[raw/company-tech-blogs/modulith-kakaobank-techblog-2025]] | Gradle multi-module + Hexagonal 위에서 application/adapter 물리 분리와 Port 통신 사례 |
|
||||
| [[raw/official-docs/modulith-spring-official-doc]] | Spring Modulith verifier 대안. Phase C2 기본값은 아니며 후속 검토 후보 |
|
||||
| [[raw/official-docs/feature-first-uncle-bob-screaming-architecture-2011]] | feature/use-case 중심 구조가 framework 중심 구조보다 의도를 드러낸다는 보조 근거 |
|
||||
| [[raw/company-tech-blogs/feature-first-sahibinden-package-by-layer-vs-feature]] | layer-first 대비 feature 응집도 사례. module 내부 package 책임 참고용 |
|
||||
| [[raw/official-docs/mapstruct-generated-annotation-official]] | D9: MapStruct generated mapper에 `@Generated` annotation이 붙는다는 공식 근거 (MS-ANNOT-C1, MS-ANNOT-C2) |
|
||||
| [[raw/official-docs/lombok-builder-data-features-official]] | D3: `domain-core` Lombok 금지 결정 — `@Builder` 가 inner static class·setter 등 7가지를 생성하고 `@Data` 가 setter 를 포함한 full boilerplate 를 생성함을 공식 문서로 뒷받침 (LMB-C1~LMB-C5) |
|
||||
| [[raw/company-tech-blogs/spring-modulith-archunit-generated-exemption-and-violations-as-data]] | D9 corroboration: Spring Modulith 자체가 `annotatedWith(Generated.class)` ArchUnit predicate 를 production 코드에 사용 (SPRING-MOD-AU-C1). S1 negative test fixture pattern: `detectViolations()` returns Violations as data + `example/ninvalid` fixture package (SPRING-MOD-AU-C2). D8 CONTRARY: `@ApplicationModuleListener` meta-annotation (SPRING-MOD-TX-C1) |
|
||||
| [[raw/company-tech-blogs/buckpal-archunit-lombok-allowlist-direct-transactional]] | D3 CONTRARY: Buckpal domain purity ArchUnit rule 이 `lombok..` 명시 allowlist (BUCKPAL-LOMBOK-C1, C2). D8 CONTRARY: `@Component @Transactional` 직접 부착 (BUCKPAL-TX-C1, C2). Hexagonal 공식 reference 가 ca-tmpl 결정과 정반대 방향임을 기록 |
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [x] Gradle project dependency rule을 `domain-core`, `application-core`, `adapter-*`, `app-bootstrap`, `sample-portfolio` 기준으로 정리 — 등급: `locally-verified`
|
||||
- [x] ArchUnit `domain-core` forbidden import rule 정의 — 등급: `actually-implemented`
|
||||
- [x] ArchUnit `application-core` adapter dependency forbidden rule 정의 — 등급: `actually-implemented`
|
||||
- [x] adapter module 간 직접 의존 금지 rule 정의 — 등급: `actually-implemented`
|
||||
- [x] `shared-contract` 허용 package scope rule 정의 — 등급: `locally-verified`
|
||||
- [x] `sample-portfolio` production 역수입 금지 rule 정의 — 등급: `locally-verified`
|
||||
- [x] mapper boundary / direct domain response 금지 rule 정의 — 등급: `locally-verified`
|
||||
- [x] transaction annotation forbidden import rule 정의 — 등급: `locally-verified`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- architecture test 기본 도구는 ArchUnit으로 둔다.
|
||||
- Gradle dependency graph 검증은 `feature-skeleton-package-blueprint-contract`의 `verifyCleanArchitectureDependencies`와 같은 방향으로 둔다.
|
||||
- Spring Modulith verifier는 기본값이 아니라 후속 검토 후보로 둔다. 현재 기본 강제선은 Gradle dependency rule + ArchUnit rule이다.
|
||||
- 2026-05-28 구현 반영: ca-tmpl `src/build.gradle`의 `verifyCleanArchitectureDependencies`를 보강하고, `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java`에 transaction annotation, controller direct domain response, mapper boundary, shared-contract package allowlist 규칙을 추가했다.
|
||||
- 2026-05-28 red/green 검증: 임시 위반 코드로 `application @Transactional`, controller domain return, mapper -> application dependency, `shared.worklog` package 위반이 `CleanArchitectureTest`에서 실패함을 확인한 뒤 임시 파일을 제거했다. 임시 `app-bootstrap -> sample-portfolio` project dependency도 `verifyCleanArchitectureDependencies`에서 실패함을 확인한 뒤 제거했다.
|
||||
- 2026-05-28 전체 검증: `cd src && ./gradlew :app-bootstrap:test verifyCleanArchitectureDependencies`, `cd src && ./gradlew test` 모두 성공. Gradle 10 호환성 deprecation warning은 기존 빌드 경고로 남아 있다.
|
||||
- 2026-05-28 워크플로우 반영: ca-tmpl repo 내부 `AGENTS.md`, `CLAUDE.md`, `.agents/plugins/ca-superpowers/`, `.claude/`, `.codex/` 지침에 구현 완료 후 LLM Wiki branch-note 갱신과 `raw/errors`, `raw/interviews`, `raw/blog-topics` 파생 문서 캡처 규칙을 추가했다. 등급: `documented-only` (repo-local workflow docs; 자동 강제 장치 아님).
|
||||
- 2026-05-28 round 2 구현 반영 (D3 Lombok + D11 ApplicationContext + violations-as-data fixture):
|
||||
- `domain_is_pure` rule 의 forbidden packages 에 `lombok..` 추가 (D3) → Lombok 사용 시 ArchUnit 실패.
|
||||
- `application_does_not_depend_on_application_context` ArchUnit rule 신규 추가 (D11) → `getBean(Class)` class-literal 호출까지 catch. String-key bypass (`getBean(String)`, `Class.forName(String)`) 는 D12 의 code review checklist 한계로 명시.
|
||||
- `src/app-bootstrap/src/test/java/.../violations/` 패키지에 의도된 위반 fixture 6종 + `ArchitectureViolationFixtureTest` 6 negative test 추가 → 각 rule 이 _실제로_ 위반을 catch 하는지 commit 된 negative test 로 보증 (Spring Modulith `example/ninvalid` 패턴).
|
||||
- `testCompileOnly 'org.springframework:spring-tx'` 를 `app-bootstrap/build.gradle` 에 추가 (`TransactionalAnnotatedFixture` 가 `@Transactional` 을 import 하기 위해 — production 영향 없음).
|
||||
- 전체 검증: `cd src && ./gradlew check` PASS, `CleanArchitectureTest` 14 tests + `ArchitectureViolationFixtureTest` 6 tests.
|
||||
- 2026-06-30 develop 머지 충돌 및 규칙 수정:
|
||||
- `master`에 직접 커밋된 `mappers_do_not_depend_on_web_or_application_boundaries` 규칙의 패키지 필터(`..mapper..`)가 `develop`에 추가된 웹 매퍼(`WorkLogWebMapper`, `FeatureAggregateResponseMapper` 등)를 침범하여 테스트가 실패하는 현상이 발생함.
|
||||
- 웹 매퍼는 프레임워크/웹 DTO와 애플리케이션 커맨드를 매핑해야 하므로 웹/애플리케이션 의존성이 허용되어야 함.
|
||||
- 따라서 해당 규칙의 타겟 패키지를 `..adapter.persistence..mapper..`(영속성 매퍼)로 제한함.
|
||||
- 또한 Clean Architecture 상 영속성 어댑터는 애플리케이션 코어 레이어를 의존할 수 있으므로(예: 멱등성 매퍼가 애플리케이션 레코드 타입을 참조하는 경우), 영속성 매퍼가 금지해야 할 대상에서 `..application..`을 제외하고 `..adapter.web..`과 `..bootstrap..`만 금지하도록 규칙을 수정함.
|
||||
- 수정 후 `CleanArchitectureTest` 54개 테스트 통과 완료.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-05-21: CA 경계는 문서가 아니라 테스트로 강제되어야 함. / 이유: 문서만으로는 시간이 지나며 boundary drift가 발생함. / 검토한 대안: (a) 문서 + PR 리뷰만으로 강제 — boundary drift 누적, (b) SonarQube custom rule — out of scope §범위, (c) Spring Modulith verifier — out of scope §범위. / 근거: [[raw/official-docs/governance-archunit-official]].
|
||||
- 2026-05-27: package rule은 기존 `features.{featureName}.{presentation,application,domain,infrastructure}` 기준에서 Gradle multi-module 기준으로 수정. / 이유: Phase C2 기본 구조가 `domain-core` / `application-core` / `adapter-*` / `shared-contract` / `app-bootstrap` / `sample-portfolio`로 바뀜. / 검토한 대안: 기존 `features.{featureName}.{layer}` package-convention 유지 (single-module 가정) — 채택 안 함. company-case-study (woowahan, kakaobank) 가 모두 module boundary 분리를 택했음. / 근거: [[raw/branch-notes/feature-skeleton-package-blueprint-contract]].
|
||||
- 2026-05-27: `domain-core`는 Spring/JPA/HTTP/adapter type import 금지. / 이유: domain model을 framework-neutral POJO로 유지하기 위함. / 검토한 대안: (a) framework 허용 + DI 패턴으로만 격리 — domain lifecycle 이 framework 에 결합, (b) package-private convention 만 사용 — multi-module 환경에서는 module boundary 가 더 강한 격리 제공. / 근거: [[raw/company-tech-blogs/woowahan-hexagonal-multimodule]], [[raw/official-docs/arch-clean-architecture-uncle-bob]].
|
||||
- 2026-05-27: `application-core`는 `adapter-*`와 `app-bootstrap`에 의존하면 안 됨. / 이유: application core가 outbound implementation을 직접 알면 port boundary가 무너짐. / 검토한 대안: Spring Modulith `@ApplicationModule` named interface 로 module 내부 의존 허용 + 외부 노출만 차단 — out of scope §범위. / 근거: [[raw/company-tech-blogs/modulith-kakaobank-techblog-2025]], [[raw/official-docs/arch-hexagonal-cockburn]].
|
||||
- 2026-05-27: adapter module끼리 직접 의존하지 않음. / 이유: adapter 간 공유가 필요하면 application port 또는 shared operational contract로 승격해야 함. / 검토한 대안: (a) `adapter-common` shared module 생성 — common dumping ground 위험 (D6 와 동일 risk), (b) Spring Modulith named interface — out of scope §범위. / 근거: [[raw/branch-notes/feature-skeleton-package-blueprint-contract]].
|
||||
- 2026-05-27: `shared-contract`는 response/error/header/logging/tracing/metrics/registry/annotation 같은 skeleton-wide operational contract만 허용. / 이유: business common dumping ground를 막기 위함. / 검토한 대안: `shared-business` 별도 module 신설하여 business common 허용 — 채택 안 함. 사례(woowahan, kakaobank) 모두 shared = operational contract 만 정의. / 근거: [[raw/branch-notes/feature-skeleton-package-blueprint-contract]].
|
||||
- 2026-05-27: `sample-portfolio`은 production module이 import하거나 dependency로 선언하면 실패. / 이유: sample은 production feature가 아니라 contract fixture임. / 검토한 대안: sample 을 production module 과 통합 (sample 분리 안 함) — 채택 안 함. sample 코드가 production 코드 경로에 섞이면 제거 시점 식별 불가. / 근거: [[raw/branch-notes/feature-skeleton-package-blueprint-contract]].
|
||||
- 2026-05-22: application layer의 Spring `@Transactional` 직접 import는 금지하고 transaction abstraction 사용 여부를 검증. / 이유: transaction boundary를 application use case 책임으로 두되 Spring annotation 의존을 숨기기 위함. / 검토한 대안: (a) `@Transactional` 직접 허용 — Spring 공식 지원, ca-tmpl 은 격리를 위한 소수파 선택(D8 Open Risk), (b) AOP custom annotation 으로 동일 효과 — 추가 추상화 비용, (c) `TransactionTemplate` programmatic — boilerplate 증가. / 근거: [[raw/branch-notes/feature-application-port-usecase-contract]].
|
||||
- 2026-05-22: MapStruct 사용 시 generated mapper package/path exemption을 명시해야 하며 exemption 없는 generated code 우회는 실패. / 이유: generated code가 architecture rule을 무력화하지 않게 하기 위함. / 검토한 대안: MapStruct generated code 에도 rule 적용 (exemption 없음) — build path 분리 검사 필요, 실현 가능성 미검증. / 근거: [[raw/official-docs/mapstruct-generated-annotation-official]] MS-ANNOT-C1 (MapStruct가 `@Generated` annotation을 generated mapper에 부착함을 공식 확인). ArchUnit predicate 구현 방법은 [[raw/official-docs/archunit-user-guide]] 보강 필요. ca-tmpl 실제 generated path 확인은 `needs-confirmation`.
|
||||
- 2026-05-22: ArchUnit fail mode = strict-break for new violations. legacy 코드 적용 시 FreezingArchRule baseline 1회 capture 허용, baseline 외 새 violation은 PR block. / 이유: strict-break 가 boundary drift 누적 차단의 핵심. legacy baseline 은 도입 비용을 줄이는 한시적 타협. / 검토한 대안: (a) warning-only mode (CI 비차단) — drift 누적 위험, (b) report-only baseline (legacy 전체 면제) — 신규 위반 강제 불가. / 근거: [[raw/official-docs/archunit-user-guide]].
|
||||
|
||||
- 2026-05-28: non-trivial 구현 종료 조건에 LLM Wiki capture를 포함한다. / 이유: 코드 구현 후 branch-note와 파생 자료 작성을 매번 대화로 요청해야 하는 반복 비용을 줄이고, 구현 사실·검증·트러블슈팅·면접/블로그 후보를 누락 없이 raw 계층에 남기기 위함. / 검토한 대안: (a) 사용자가 매번 수동 요청 — 누락 위험, (b) LLM Wiki vault 규칙만 유지 — ca-tmpl 작업자가 종료 조건으로 인식하지 못함, (c) ca-tmpl repo-local rule로 연결 — 채택. / 근거: 사용자 워크플로우 요구 + [[raw/branch-notes/feature-architecture-enforcement-rules]] 본 작업 기록.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시한다. `Decision ID` 는 이 branch-note 안에서 안정적으로 유지한다.
|
||||
|
||||
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|
|
||||
| D1 | CA 경계는 architecture test로 강제 | `raw/official-docs/governance-archunit-official.md#AU-OFF-C1`, `raw/official-docs/governance-archunit-official.md#AU-OFF-C2`, `raw/official-docs/archunit-conditional-on-property-3-layer-pattern.md#AUCP-C5` | `official-vendor-doc + engineering-blog` | ArchUnit은 정적 검사만 가능. runtime lookup / reflection 우회는 별도 보완 필요 |
|
||||
| D2 | package rule은 Gradle multi-module boundary 기준 | `raw/company-tech-blogs/woowahan-hexagonal-multimodule.md#WW-HEX-C1`, `raw/company-tech-blogs/modulith-kakaobank-techblog-2025.md#KAKAOBANK-MOD-C4`, `raw/branch-notes/feature-skeleton-package-blueprint-contract.md` | `company-case-study + project-decision` | company-tech-blog 사례를 공식 표준으로 승격 금지. ca-tmpl build graph로 별도 검증 필요 |
|
||||
| D3 | `domain-core` forbidden import rule (Lombok 포함) | `raw/company-tech-blogs/woowahan-hexagonal-multimodule.md#WW-HEX-C1`, `raw/official-docs/arch-clean-architecture-uncle-bob.md`, `raw/official-docs/lombok-builder-data-features-official.md#LMB-C1`, `raw/official-docs/lombok-builder-data-features-official.md#LMB-C2`, `raw/official-docs/lombok-builder-data-features-official.md#LMB-C4`, `raw/official-docs/lombok-builder-data-features-official.md#LMB-C5` / **CONTRARY EVIDENCE**: `raw/company-tech-blogs/buckpal-archunit-lombok-allowlist-direct-transactional.md#BUCKPAL-LOMBOK-C1`, `#BUCKPAL-LOMBOK-C2` (Buckpal hex-arch 공식 reference 가 domain purity rule 에서 `lombok..` 명시 allowlist — 정반대 방향) | `company-case-study + engineering-blog + official-vendor-doc` + **UNSUPPORTED_DECISION (CONTRARY)** | LMB-C1~C5 는 `@Builder`/`@Data` 가 무엇을 생성하는지만 증명. **Lombok 금지 자체는 OSS best practice 가 아님** — Buckpal (Hombergs 책 공식 예제, 2.5k stars) 이 domain 에 Lombok 을 명시 allowlist. ca-tmpl D3 는 bytecode 감각성 + framework 독립성을 위한 자체 stricter taste 결정. UNSUPPORTED_DECISION(CONTRARY) 라벨 |
|
||||
| D4 | `application-core` -> `adapter-*` / `app-bootstrap` 의존 금지 | `raw/company-tech-blogs/woowahan-hexagonal-multimodule.md#WW-HEX-C2`, `raw/company-tech-blogs/modulith-kakaobank-techblog-2025.md#KAKAOBANK-MOD-C4`, `raw/official-docs/arch-hexagonal-cockburn.md#HEX-COCKBURN-ORIG-C3` | `company-case-study + engineering-blog` | repository port가 아직 `domain/repository`에 남은 부분은 `feature-application-port-usecase-contract`와 동기화 필요 |
|
||||
| D5 | adapter module 간 직접 의존 금지 | `raw/company-tech-blogs/modulith-kakaobank-techblog-2025.md#KAKAOBANK-MOD-C2`, `raw/company-tech-blogs/modulith-kakaobank-techblog-2025.md#KAKAOBANK-MOD-C4` | `company-case-study` | Spring Modulith 없이 public API/named interface 강제는 약함. ArchUnit/package-private convention 필요 |
|
||||
| D6 | `shared-contract` business/domain concept 금지 | `raw/official-docs/feature-first-uncle-bob-screaming-architecture-2011.md#SCREAM-C1`, `raw/branch-notes/feature-skeleton-package-blueprint-contract.md` | `engineering-blog + project-decision` | shared module이 커질수록 common dumping ground가 될 위험 |
|
||||
| D7 | `sample-portfolio` production 역수입 금지 | `raw/branch-notes/feature-skeleton-package-blueprint-contract.md` | `project-decision` | 외부 직접 근거는 약함. Gradle dependency rule + ArchUnit failure로 실증 필요 |
|
||||
| D8 | application `@Transactional` 직접 import 금지 | `raw/branch-notes/feature-application-port-usecase-contract.md`, `raw/official-docs/spring-tx-management-reference.md` / **CONTRARY EVIDENCE**: `raw/company-tech-blogs/spring-modulith-archunit-generated-exemption-and-violations-as-data.md#SPRING-MOD-TX-C1` (Spring Modulith 공식 incubator 가 `@ApplicationModuleListener` 로 `@Transactional` 을 meta-annotation 재노출 — Spring 팀 방향과 정면 충돌), `raw/company-tech-blogs/buckpal-archunit-lombok-allowlist-direct-transactional.md#BUCKPAL-TX-C1` (hex-arch 공식 reference 가 `@Component @Transactional` 직접 부착) | `project-decision + official-vendor-doc` + **UNSUPPORTED_DECISION (CONTRARY)** | Spring 공식은 `@Transactional` 을 지원하고 Spring Modulith 는 meta-annotation 으로 재노출. Buckpal hex-arch reference 도 직접 부착. ca-tmpl D8 forbidden 정책은 **OSS best practice 가 아님** — multi-Gradle-module Hexagonal context 에서 application 모듈 순수성을 위한 자체 stricter 결정. UNSUPPORTED_DECISION(CONTRARY) 라벨 |
|
||||
| D9 | MapStruct generated exemption은 `@Generated` annotation 기반 ArchUnit predicate 로 허용 | `raw/official-docs/mapstruct-generated-annotation-official.md#MS-ANNOT-C1`, `#MS-ANNOT-C2` (MapStruct `@Generated` annotation 부착 공식 + `suppressGeneratorTimestamp` 옵션) + `raw/company-tech-blogs/spring-modulith-archunit-generated-exemption-and-violations-as-data.md#SPRING-MOD-AU-C1` (Spring Modulith 자체가 `annotatedWith(Generated.class)` predicate 를 production 코드에 사용 — DSL 패턴 OSS 검증) | `official-vendor-doc + company-case-study` | annotation FQN 주의: MapStruct = `javax.annotation.processing.Generated`, Spring AOT = `org.springframework.aot.generate.Generated` — ArchUnit predicate 는 MapStruct FQN 사용해야 함. 구현 권고: `.and().areNotAnnotatedWith(javax.annotation.processing.Generated.class)`. ca-tmpl 실제 generated path 검증은 sample mapper 추가 후 통합 테스트 (`needs-confirmation` from `planned` → `actually-implemented` 승급 가능) |
|
||||
| D10 | ArchUnit rule은 JUnit 기반 test로 실행 | `raw/official-docs/archunit-user-guide.md#ARCHUNIT-UG-C6`, `raw/official-docs/archunit-user-guide.md#ARCHUNIT-UG-C5` | `official-vendor-doc` | FreezingArchRule baseline 정책은 별도 claim 보강 전까지 `needs-confirmation`으로 둠 |
|
||||
| D11 | `application-core` 가 `org.springframework.context.ApplicationContext` 자체에 의존 금지 (banned-class ArchUnit rule). class-literal 기반 `getBean(Class<T>)` 호출까지는 catch 가능 | `raw/official-docs/archunit-user-guide.md#ARCHUNIT-UG-C5` (`JavaMethodCall` / `JavaConstructorCall` bytecode 기반 access analysis) | `official-vendor-doc` | string-key bean lookup (`getBean(String)`) 과 `Class.forName(String)` 의 string target 은 ArchUnit 이 catch 불가 — D12 의 code review checklist 로 보완 |
|
||||
| D12 | string-key bean lookup / `Class.forName(String)` / `BeanFactory#getBeansOfType` 의 reflection-style bypass 는 ArchUnit static analysis 의 한계 — code review checklist 항목으로 보완 | `raw/official-docs/archunit-user-guide.md` (§6.2 "accesses ... bytecode offers all this information" — bytecode 가 string content 자체를 노출하지 않음) | `official-vendor-doc` (negative claim — ArchUnit 이 못 잡는다는 사실의 공식 근거) | runtime container 의존성 그래프 검증 도구 (Spring Boot Actuator `/beans`, Spring Modulith verifier) 도입은 후속 검토 — D1 Open Risk 구체화 |
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다. 구현 전/중/후에 실제로 검증해야 하는 주장을 분리한다.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| `domain-core`가 Spring/JPA/HTTP/adapter/Lombok import를 포함하면 ArchUnit이 실패한다 | rule DSL 구현 전에는 문서상 금지에 불과함 | violating class 추가 → `./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest'` 실패 확인 + `ArchitectureViolationFixtureTest#domain_is_pure_catches_spring_dependency_in_domain_package` negative test 통과 확인 | `actually-implemented` (2026-05-28 round 2) — Lombok forbidden 추가됨 (`lombok..` package). violations-as-data fixture `SpringDependentDomainFixture` 가 `domain_is_pure` rule 의 실 catch 동작을 commit 된 negative test 로 보증. |
|
||||
| `application-core`가 `adapter-*` project dependency를 선언하면 Gradle 검증이 실패한다 | Gradle dependency graph rule이 실제로 모든 subproject를 검사해야 함 | `application-core`에 `implementation project(':adapter-persistence')` 추가 → `./gradlew verifyCleanArchitectureDependencies` 실패 확인 | `actually-implemented` |
|
||||
| adapter module끼리 직접 의존하면 실패한다 | adapter 간 공유 요구가 생길 때 우회 가능성이 있음 | `adapter-web` -> `adapter-persistence` dependency 추가 → Gradle/ArchUnit 실패 확인 | `actually-implemented` |
|
||||
| `shared-contract`에 domain-specific package/class가 들어오면 실패한다 | shared 허용 scope가 넓으면 domain concept가 흘러들 수 있음 | `shared-contract/src/main/java/.../shared/worklog/` 추가 → ArchUnit package scope rule 실패 확인 | `locally-verified` |
|
||||
| production module이 `sample-portfolio`에 의존하면 실패한다 | sample은 편의상 import되기 쉬움 | production module에 `implementation project(':sample-portfolio')` 추가 → Gradle dependency rule 실패 확인 | `locally-verified` |
|
||||
| controller가 domain object를 response로 반환하면 실패한다 | mapper boundary rule이 module boundary만으로는 잡히지 않을 수 있음 | `adapter-web` controller가 domain model을 직접 반환하도록 위반 코드 추가 → ArchUnit 실패 확인 | `locally-verified` |
|
||||
| application use case가 `@Transactional`을 직접 import하면 실패한다 | Spring 공식은 `@Transactional`을 지원하므로 ca-tmpl 자체 결정임 | violating use case 추가 → ArchUnit forbidden import 실패 확인 | `locally-verified` |
|
||||
| MapStruct generated mapper exemption이 의도한 package/path에만 적용된다 | generated source path가 build tool 설정에 따라 달라질 수 있음 | MapStruct sample mapper 추가 → generated path 확인 → exemption 외 경로 import 시 실패 확인 | `needs-confirmation` |
|
||||
| runtime lookup 우회는 ArchUnit으로 잡히지 않는다 | ArchUnit은 bytecode/static dependency 중심이므로 false negative 가능 | `ApplicationContext#getBean` 우회 코드 추가 → ArchUnit false-pass 확인 → manual/Sonar 보완 항목 등록 | `actually-implemented` (2026-05-28 round 2) — D11 `application_does_not_depend_on_application_context` ArchUnit rule 작성. `ApplicationContextDependentFixture` negative test 가 catch 동작 commit 보증. `getBean(String)` string-key bypass 와 `Class.forName(String)` 은 여전히 ArchUnit 정적 분석 범위 밖 (D12) — `application-core/CLAUDE.md` forbidden 섹션에 명시. |
|
||||
| ca-tmpl repo-local workflow docs가 non-trivial 구현 후 LLM Wiki branch-note와 derived raw notes 캡처를 요구한다 | 문서 규칙 반영만으로 실제 에이전트 실행을 자동 보장하지는 않음 | `AGENTS.md`, `CLAUDE.md`, `.agents/.claude/.codex` 지침에서 `llm-wiki-capture` 및 Wiki capture 문구 검색 | `documented-only` |
|
||||
| ArchUnit rule 이 위반을 실제로 catch 한다는 commit 된 증명 (negative test fixture) — `violations-as-data` pattern 채택 가능 | 현재 negative 검증은 임시 violating 파일 추가 → 제거 방식 (regression 보호 없음). Spring Modulith 공식 패턴 `modules.detectViolations().getMessages()` + `example/ninvalid` fixture package 가 production precedent | `src/app-bootstrap/src/test/.../violations/` 패키지에 의도된 위반 fixture class 추가 + `assertThatThrownBy(rule::check)` 또는 `evaluationResult.getFailureReport().getDetails()` assertion 으로 exact match 검증. 근거: `raw/company-tech-blogs/spring-modulith-archunit-generated-exemption-and-violations-as-data.md#SPRING-MOD-AU-C2` | `actually-implemented` (2026-05-28 round 2) — `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/` 에 6 fixture (1 domain + 5 application) + `ArchitectureViolationFixtureTest` 에 6 negative test 작성. 각 test 가 `ClassFileImporter().importPackages("...violations")` 로 fixture 만 로드한 뒤 해당 rule 의 `EvaluationResult.hasViolation() == true` assert. fixture 는 `src/test/...` 위치라 main `@AnalyzeClasses(importOptions = DoNotIncludeTests.class)` 가 제외 → main suite vacuous pass 위험 없음. Spring Modulith `example/ninvalid` 패턴의 ca-tmpl 채택. |
|
||||
|
||||
## 테스트 계약
|
||||
|
||||
- `domain-core`가 Spring / JPA / HTTP DTO / adapter module type을 참조하면 실패.
|
||||
- `application-core`가 `adapter-web`, `adapter-persistence`, `adapter-outbound`, `app-bootstrap`에 의존하면 실패.
|
||||
- adapter module끼리 직접 의존하면 실패.
|
||||
- production module이 `sample-portfolio`을 import하거나 dependency로 선언하면 실패.
|
||||
- `shared-contract`에 business/domain package 또는 domain-specific class가 추가되면 실패.
|
||||
- `adapter-web` controller가 domain object를 response로 직접 반환하면 실패.
|
||||
- `application-core`가 `org.springframework.transaction.annotation.Transactional`을 직접 import하면 실패.
|
||||
- MapStruct generated exemption 밖의 generated code 우회가 있으면 실패.
|
||||
- `application-core` 가 `org.springframework.context.ApplicationContext` 를 직접 의존하면 실패 (D11 banned-class rule). `getBean(Class)` class-literal 호출도 이 rule 로 catch.
|
||||
- `domain-core` 가 Lombok generated bytecode 를 포함하면 실패 (`@Builder` / `@Data` / `@Getter` / `@Setter` 등 Lombok annotation 사용 금지 — `feature-skeleton-package-blueprint-contract` Option A 채택).
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 본 branch 의 *결정 → 구현 위치* 명세. 각 row 는 본 branch 의 `Decision ID` + `Supporting Claim` reference 를 가진다(CLAUDE.md §15.5 R1). 근거가 *원칙* 만 권고하고 *detail* 은 구현자 trade-off 인 cell 은 `UNSUPPORTED_IMPL_DECISION` 라벨을 단다(R2). 본 branch 범위 밖 detail 은 남기지 않는다(R3).
|
||||
>
|
||||
> ⚠️ 이 명세는 *사후 정제* 다 — 본 branch 는 2026-05-28 시점에 이미 구현·검증 완료(§진행 중 메모)되었고, 본 section 은 ground-truth(`@db61075`) 와 대조해 실제 구현 위치를 역으로 명세화한 것이다.
|
||||
|
||||
| Decision | 구현 위치 (ground-truth `@db61075`) | 메커니즘 detail | Trace |
|
||||
|---|---|---|---|
|
||||
| D1 (test 강제) | `app-bootstrap/.../architecture/CleanArchitectureTest.java` | `@AnalyzeClasses(packages = "dev.caskeleton", importOptions = DoNotIncludeTests.class)` + `@ArchTest static final ArchRule` 필드들. `@AnalyzeClasses` import scope 선택은 `UNSUPPORTED_IMPL_DECISION` — ARCHUNIT-UG-C6 는 JUnit 통합만 권고하고 base package 선택은 프로젝트 trade-off (`dev.caskeleton` root 단일 scan 으로 결정) | D1 / ARCHUNIT-UG-C5,C6 |
|
||||
| D2 (build-graph) | `src/build.gradle:53` `verifyCleanArchitectureDependencies` task | `allowedProjectDependencies` Map<String,Set<String>> 화이트리스트 9 module + `configurations(api/implementation/compileOnly/runtimeOnly).dependencies.withType(ProjectDependency)` 차집합 → `GradleException`. matrix 구체 값은 `UNSUPPORTED_IMPL_DECISION` — blueprint 결정([[raw/branch-notes/feature-skeleton-package-blueprint-contract]])의 module 목록에서 도출한 trade-off | D2 / WW-HEX-C1, KAKAOBANK-MOD-C4 |
|
||||
| D3 (domain purity + Lombok) | `domain_is_pure` rule | `noClasses().that().resideInAPackage("..domain..").should().dependOnClassesThat().resideInAnyPackage(..., "lombok..", ...)` + `.allowEmptyShould(true)`. forbidden package 목록 구체값은 `UNSUPPORTED_IMPL_DECISION` (CONTRARY: Buckpal 은 `lombok..` allowlist — D3 Open Risk) | D3 / LMB-C1~C5, ARCHUNIT-UG-C4 |
|
||||
| D4 (application 격리) | `application_does_not_depend_on_adapters_or_transport` rule | `noClasses().that().resideInAPackage("..application..").should().dependOnClassesThat().resideInAnyPackage("..adapter..","..bootstrap..","org.springframework.web..",...)` | D4 / WW-HEX-C2, HEX-COCKBURN-ORIG-C3 |
|
||||
| D5 (adapter-adapter 격리) | `web_/persistence_/outbound_adapter_does_not_depend_on_*` 3 rule | 각 adapter package 가 sibling adapter package 에 의존 금지. ArchUnit package glob 으로 구현 (Spring Modulith named interface 미사용 — D5 Open Risk) | D5 / KAKAOBANK-MOD-C2,C4 |
|
||||
| D6 (shared scope) | `shared_contract_contains_only_operational_contract_packages` rule | `classes().that().resideInAPackage("..shared..").should().resideInAnyPackage(<operational allowlist>)`. allowlist package 집합은 `UNSUPPORTED_IMPL_DECISION` — blueprint 의 operational contract 목록에서 도출 | D6 / SCREAM-C1 |
|
||||
| D7 (sample 역수입 금지) | `production_code_does_not_depend_on_sample_portfolio` rule | `noClasses().that().resideOutsideOfPackage("..sample.portfolio..").should().dependOnClassesThat().resideInAPackage("..sample.portfolio..")` | D7 (project-decision) |
|
||||
| D11 (ApplicationContext banned-class) | `application_does_not_depend_on_application_context` rule | `noClasses().that().resideInAPackage("..application..").should().dependOnClassesThat().haveFullyQualifiedName("org.springframework.context.ApplicationContext")`. FQN 단일 class 선택은 bytecode access analysis(ARCHUNIT-UG-C5)로 `getBean(Class)` 까지만 catch | D11 / ARCHUNIT-UG-C5 |
|
||||
| S1 (violations-as-data) | `ArchitectureViolationFixtureTest` + `architecture/violations/` package | 본 branch fixture: `SpringDependentDomainFixture`(D3), `ApplicationContextDependentFixture`(D11), `TransactionalAnnotatedFixture`. 각 test 가 `rule.evaluate(VIOLATION_CLASSES).hasViolation()==true` assert. fixture 가 `src/test/` 위치라 main `DoNotIncludeTests` 분석 제외 → vacuous pass 방지 | S1 / SPRING-MOD-AU-C2 |
|
||||
|
||||
> **OUT_OF_BRANCH_SCOPE (R3)**: ground-truth `CleanArchitectureTest` 의 boundary-validation(B1/B2/B4/B5/B6/B7, D5 ProblemDetail), streaming(D3 SSE/WebSocket), serialization(BigDecimal), resource-identifier(D17 no_long_id_pk 등), api-contract(D19 AIP-122), business-rule-validation(C1/D1 jakarta.validation) rule 들은 *각각 다른 branch* 소유다. 본 §에는 남기지 않으며 해당 branch ingest 에서 명세한다. @Transactional ban rule(`application_does_not_use_spring_transactional_annotation`)은 코드 attribution 상 [[raw/branch-notes/feature-application-port-usecase-contract]] D3 소유지만 본 branch 테스트 계약에도 포함되어 red/green 확인됨 — SSOT 는 그 branch.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> 본 branch 구현의 경계 조건 · 알려진 실패 모드 · 외부 의존. ArchUnit 정적 분석의 한계를 정직하게 남긴다.
|
||||
|
||||
- **Edge — empty anchor**: skeleton 단계의 빈 module 은 `that()` 매칭 대상이 0개라 ArchUnit 기본 동작상 `failed to check any classes` 로 실패한다. 의도된 빈 anchor rule 에 `allowEmptyShould(true)` 를 명시해 허용. 근거 사실: [[raw/errors/archunit-empty-should-anchor-2026-05-27]].
|
||||
- **Failure — vacuous pass (import scope 누락)**: 검사 대상 class 가 `@AnalyzeClasses` import scope 밖이면 위반이 있어도 rule 이 *조용히* 통과(`BUILD SUCCESSFUL`, 에러 신호 없음)한다. `allowEmptyShould(true)` 도 이 false-negative 를 막지 못함. 보완: sample/fixture 를 test import scope 에 포함 + violations-as-data negative fixture(S1) 로 catch 동작을 commit 보증. 근거 사실: [[raw/errors/archunit-test-scope-sample-ticket-inclusion-2026-05-28]].
|
||||
- **Failure — D11/D12 string-key bypass (알려진 한계, 보완 불가)**: D11 banned-class rule 은 class-literal `getBean(Class<T>)` 까지만 catch 한다. string-key `getBean(String)` · `Class.forName(String)` · `BeanFactory#getBeansOfType` 같은 reflection-style bypass 는 bytecode 가 문자열 내용을 노출하지 않아 ArchUnit 정적 분석으로 **잡을 수 없다**(D12). `application-core/CLAUDE.md` forbidden 섹션의 code review checklist 로만 보완 — 자동 강제 장치 아님. runtime 검증(Actuator `/beans`, Modulith verifier)은 후속 후보(D1/D12 Open Risk).
|
||||
- **Dependency — testCompileOnly**: `TransactionalAnnotatedFixture` 가 `@Transactional` 을 import 하기 위해 `app-bootstrap/build.gradle` 에 `testCompileOnly 'org.springframework:spring-tx'` 추가(production 영향 없음). 관련 class-loading 이슈: [[raw/errors/archunit-testcompileonly-class-loading-2026-06-02]].
|
||||
- **Dependency — sandbox/Gradle**: Gradle wrapper 가 sandbox 기본 권한에서 `~/.gradle` lock 파일 생성 실패 → escalated 실행으로 해결. 근거: [[raw/errors/gradle-wrapper-readonly-cache-2026-05-28]].
|
||||
- **Edge — MapStruct exemption (미검증)**: D9 generated mapper `@Generated` exemption 은 ca-tmpl 에 실제 MapStruct mapper 가 아직 없어 `needs-confirmation` — sample mapper 추가 후 통합 검증 필요.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 2026-05-28: Gradle wrapper sandbox 권한 문제
|
||||
- 원인: sandbox 기본 권한 정책 상 `~/.gradle` 디렉터리 쓰기가 차단되어 wrapper 가 lock/cache 파일 생성 실패.
|
||||
- 시도: 기본 권한으로 `./gradlew :app-bootstrap:test` 실행 → `Read-only file system` 오류.
|
||||
- 해결: 사용자 승인된 escalated 실행으로 동일 명령 재수행 → 성공. 검증 결과는 본 branch-note `진행 중 메모` 2026-05-28 항목 참조.
|
||||
- 별도 에러 노트: [[raw/errors/gradle-wrapper-readonly-cache-2026-05-28]]
|
||||
- 2026-05-28: 계획 문서가 `.gitignore` 의 `/docs` 규칙에 가려져 git untracked
|
||||
- 원인: ca-tmpl `.gitignore` 가 `/docs` 디렉터리를 전면 제외 (operational docs 는 별도 repo 분리 정책).
|
||||
- 시도: `docs/superpowers/plans/2026-05-28-architecture-enforcement-rules.md` 작성 → `git status` 에 미포함 확인.
|
||||
- 해결: 계획 문서는 작업용으로만 유지하고 최종 기록은 본 branch-note 의 `진행 중 메모` / `결정 사항` / `Closure` 섹션에 통합. 계획 문서 위치는 untracked 로 두되 본 메모에서만 참조.
|
||||
- 별도 에러 노트: (해당 없음 — 운영 메모, 재발 시 동일 정책 적용)
|
||||
|
||||
- 2026-05-28: repo-local workflow 문서 패치 중 자동 승인 검토 차단
|
||||
- 원인: `.agents/.claude/.codex` 프롬프트 반영 패치 도중 도구의 automatic approval review가 patch 적용을 차단.
|
||||
- 시도: 먼저 `AGENTS.md`, `CLAUDE.md`, `llm-wiki-capture.md`까지 반영한 뒤 남은 plugin/agent prompt 반영을 진행하려 했으나 중단.
|
||||
- 해결: 사용자에게 차단 상태와 부분 반영 범위를 보고하고 명시 승인을 받은 뒤 남은 파일을 계속 반영.
|
||||
- 별도 에러 노트: [[raw/errors/apply-patch-auto-approval-rejected-2026-05-28]]
|
||||
- 2026-06-30: develop 머지 과정에서의 매퍼 아키텍처 규칙 오탐지
|
||||
- 원인: `..mapper..` 패키지 규칙이 영속성 매퍼뿐만 아니라 웹 매퍼까지 과도하게 필터링하여 웹/애플리케이션 레이어 의존성을 차단함.
|
||||
- 해결: 영속성 매퍼(`..adapter.persistence..mapper..`)로 대상을 좁히고, Clean Architecture 의존성 방향(영속성 -> 애플리케이션 허용)에 맞춰 금지 목록에서 `..application..`을 제외함.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/buckpal-archunit-lombok-allowlist-direct-transactional]]
|
||||
- [[raw/company-tech-blogs/feature-first-sahibinden-package-by-layer-vs-feature]]
|
||||
- [[raw/company-tech-blogs/hexagonal-woowahan-techblog-2023]]
|
||||
- [[raw/company-tech-blogs/layer-first-kamilmazurek-github-template]]
|
||||
- [[raw/company-tech-blogs/modulith-arawn-github-modular-monoliths-spring]]
|
||||
- [[raw/company-tech-blogs/modulith-kakaobank-techblog-2025]]
|
||||
- [[raw/company-tech-blogs/onion-allegro-tech-blog-2023]]
|
||||
- [[raw/company-tech-blogs/spring-modulith-archunit-generated-exemption-and-violations-as-data]]
|
||||
- [[raw/official-docs/adapter-spring-boot-autoconfig-custom-starter]]
|
||||
- [[raw/official-docs/arch-clean-architecture-uncle-bob]]
|
||||
- [[raw/official-docs/arch-hexagonal-cockburn]]
|
||||
- [[raw/official-docs/archunit-user-guide]]
|
||||
- [[raw/official-docs/domain-fowler-anemic-vs-rich-model]]
|
||||
- [[raw/official-docs/domain-vaughn-vernon-aggregate-root]]
|
||||
- [[raw/official-docs/feature-first-uncle-bob-screaming-architecture-2011]]
|
||||
- [[raw/official-docs/hexagonal-cockburn-wikipedia-summary]]
|
||||
- [[raw/official-docs/hexagonal-thombergs-buckpal-github]]
|
||||
- [[raw/official-docs/layer-first-baeldung-clean-architecture-spring-boot]]
|
||||
- [[raw/official-docs/lombok-builder-data-features-official]]
|
||||
- [[raw/official-docs/mapstruct-generated-annotation-official]]
|
||||
- [[raw/official-docs/modulith-spring-official-doc]]
|
||||
- [[raw/official-docs/onion-palermo-original-2008]]
|
||||
- [[raw/official-docs/persistence-osiv-antipattern-hibernate-vladmihalcea]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: interviews:start -->
|
||||
- [[raw/interviews/archunit-manual-importer-vs-analyzeclasses]]
|
||||
- [[raw/interviews/archunit-static-analysis-limits]]
|
||||
- [[raw/interviews/clean-architecture-boundary-enforcement]]
|
||||
- [[raw/interviews/post-implementation-knowledge-capture]]
|
||||
<!-- GENERATED: interviews:end -->
|
||||
|
||||
<!-- GENERATED: errors:start -->
|
||||
- [[raw/errors/apply-patch-auto-approval-rejected-2026-05-28]]
|
||||
- [[raw/errors/gradle-wrapper-readonly-cache-2026-05-28]]
|
||||
<!-- GENERATED: errors:end -->
|
||||
|
||||
<!-- GENERATED: daily-notes:start -->
|
||||
- [[raw/daily-notes/2026-05-28]]
|
||||
<!-- GENERATED: daily-notes:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/archunit-violations-as-data-pattern-2026-05-28]]
|
||||
- [[raw/blog-topics/clean-architecture-boundary-enforcement-2026-05-28]]
|
||||
- [[raw/blog-topics/post-implementation-knowledge-capture-workflow-2026-05-28]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
> 이 섹션은 이 branch-note에서 실제로 파생된 raw/wiki 문서가 생겼을 때 링크한다. 구현 결과와 검증 증거는 `TODO`, `진행 중 메모`, `Claims To Verify`, `Closure`에 기록한다.
|
||||
|
||||
### 근거 자료
|
||||
|
||||
- [[raw/official-docs/mapstruct-generated-annotation-official]] — D9: MapStruct `@Generated` annotation 공식 근거 (MS-ANNOT-C1, MS-ANNOT-C2)
|
||||
- [[raw/official-docs/lombok-builder-data-features-official]] — D3: `domain-core` Lombok 금지 결정 공식 근거 (`@Builder` 7가지 생성 요소 + `@Data` setter 생성 범위, LMB-C1~LMB-C5)
|
||||
- [[raw/company-tech-blogs/spring-modulith-archunit-generated-exemption-and-violations-as-data]] — D9 corroborate: Spring Modulith 자체가 `annotatedWith(Generated.class)` predicate 를 production 에서 사용 (SPRING-MOD-AU-C1). S1 negative test fixture: `detectViolations()` violations-as-data 패턴 (SPRING-MOD-AU-C2)
|
||||
- [[raw/company-tech-blogs/buckpal-archunit-lombok-allowlist-direct-transactional]] — D3 CONTRARY evidence: Buckpal domain purity ArchUnit rule 이 `lombok..` 를 명시적 allowlist 함 (BUCKPAL-LOMBOK-C1, BUCKPAL-LOMBOK-C2). ca-tmpl D3 가 OSS 다수파가 아닌 stricter stance 임을 뒷받침
|
||||
|
||||
### Sub-branches (세부 작업)
|
||||
|
||||
- (없음 — 이 branch는 별도 sub-branch 없이 ca-tmpl 코드 변경 2개 파일로 진행)
|
||||
|
||||
### 오류 기록 (이 branch 작업 중 발생)
|
||||
|
||||
- [[raw/errors/gradle-wrapper-readonly-cache-2026-05-28]] — Gradle wrapper가 sandbox 기본 권한에서 `~/.gradle` lock 파일을 만들지 못한 문제
|
||||
- [[raw/errors/apply-patch-auto-approval-rejected-2026-05-28]] — repo-local workflow 문서 패치 중 자동 승인 검토가 차단되어 사용자 명시 승인 후 재개한 문제
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- [[raw/interviews/clean-architecture-boundary-enforcement]] — Clean Architecture 경계를 Gradle/ArchUnit으로 자동 검증한 경험에서 파생된 예상 질문
|
||||
- [[raw/interviews/post-implementation-knowledge-capture]] — 구현 완료 후 branch-note와 파생 raw 문서를 어떻게 남길지에 대한 예상 질문
|
||||
- [[raw/interviews/archunit-static-analysis-limits]] — ArchUnit static analysis 의 한계 (string-key bypass / vacuous pass / generated code) 와 violations-as-data 보완 패턴 (round 2 D11/D12 + Claims to Verify).
|
||||
|
||||
### 강의 (이 작업을 위해 학습한 강의)
|
||||
|
||||
- (없음 — 이번 구현 중 새 lecture note 생성 없음)
|
||||
|
||||
### job-posting tie-ins (이 작업에서 파생된 글감)
|
||||
|
||||
- [[raw/blog-topics/clean-architecture-boundary-enforcement-2026-05-28]] — Clean Architecture 경계를 Gradle/ArchUnit rule로 자동 검증한 경험에서 파생된 블로그 글감
|
||||
- [[raw/blog-topics/post-implementation-knowledge-capture-workflow-2026-05-28]] — 구현 완료 후 지식 캡처를 repo-local workflow로 강제하는 설계 글감
|
||||
- [[raw/blog-topics/archunit-violations-as-data-pattern-2026-05-28]] — Spring Modulith 의 `example/ninvalid` 패턴을 차용해 6 fixture + 6 negative test 로 ArchUnit rule 의 실 catch 동작을 commit 보증한 작업 글감 (round 2).
|
||||
- (job-posting 없음 — 이번 작업에는 연결할 실제 채용공고 원문/URL이 없음)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- [[raw/daily-notes/2026-05-27]]
|
||||
- [[raw/daily-notes/2026-05-28]]
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움. `/ingest`가 이 섹션을 기준으로 wiki/projects/에 추출.
|
||||
|
||||
- PR 링크: (미생성 — local branch `feature/architecture-enforcement-rules`, not merged)
|
||||
- 리뷰 메모: 2026-05-28 local branch `feature/architecture-enforcement-rules`에서 Gradle dependency verifier와 ArchUnit rule을 구현/보강했다. 구현 파일은 ca-tmpl `src/build.gradle`, `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java`.
|
||||
- 머지 결과 / 배포 환경: not merged. local verification only.
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- Gradle `verifyCleanArchitectureDependencies`가 모든 declared module이 정책에 포함되는지 검사하고, 허용되지 않은 project dependency를 실패 처리한다.
|
||||
- `CleanArchitectureTest`가 domain purity (Spring/JPA/Hibernate/**Lombok** 모두 forbidden), application -> adapter/bootstrap 금지, adapter 간 직접 의존 금지, web DTO boundary, production -> sample-portfolio dependency 금지, application `@Transactional` 금지, **application `ApplicationContext` 금지 (D11)**, inbound use-case naming + capability mandatory + **KEYED idempotency freeze (D14)** 를 검사한다.
|
||||
- `ArchitectureViolationFixtureTest` 가 위 rule 6종의 실 catch 동작을 violations-as-data fixture 로 보증한다 (`src/app-bootstrap/src/test/java/.../violations/`).
|
||||
- `locally-verified` 항목:
|
||||
- 임시 `shared.worklog` package 추가 시 shared-contract package allowlist rule이 실패함을 확인했다.
|
||||
- 임시 controller가 production domain object를 직접 반환할 때 ArchUnit rule이 실패함을 확인했다.
|
||||
- 임시 mapper가 application boundary에 의존할 때 mapper boundary rule이 실패함을 확인했다.
|
||||
- 임시 application class가 Spring `@Transactional`을 import할 때 ArchUnit rule이 실패함을 확인했다.
|
||||
- 임시 `app-bootstrap -> sample-portfolio` project dependency 선언 시 `verifyCleanArchitectureDependencies`가 실패함을 확인했다.
|
||||
- `cd src && ./gradlew :app-bootstrap:test verifyCleanArchitectureDependencies` 성공.
|
||||
- `cd src && ./gradlew test` 성공.
|
||||
- `documented-only` 항목:
|
||||
- ca-tmpl repo-local 문서에 non-trivial 구현 후 LLM Wiki branch-note와 derived raw notes 캡처를 수행하도록 `documented-only` workflow rule을 추가했다.
|
||||
- `prod-verified` 항목:
|
||||
- (없음)
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
- MapStruct generated mapper exemption은 아직 `needs-confirmation`이다.
|
||||
- runtime lookup / reflection 우회 false-pass 확인은 아직 `planned`이다.
|
||||
- Spring Modulith verifier 도입은 out of scope 후속 후보로 유지한다.
|
||||
- SonarQube custom rule 구현과 CI workflow job 분리는 out of scope다.
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-frontend-operational-contract/branch-notes/feature-async-ui-state-contract.md
|
||||
@@ -0,0 +1,364 @@
|
||||
---
|
||||
title: branch / feature-async-ui-state-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-async-ui-state-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton-frontend, ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-frontend-operational-contract.md]
|
||||
tags: [branch, ca-skeleton, frontend, application, react, error-handling]
|
||||
created: 2026-07-18
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-013
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-frontend-operational-contract
|
||||
work_item: WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-013
|
||||
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-UI-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-SERVER-STATE-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-007, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-010]
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: 106ffdb88a0c1937bbc4d9387b99c06b90050d7e375ab4e29c7ed2b1a0dfe570
|
||||
imports: [FE-OC-002@1, FE-OC-008@1, FE-OC-012@1, FE-OC-015@1, FE-OC-020@1]
|
||||
delegates: [DELEG-FE-006@1]
|
||||
|
||||
---
|
||||
|
||||
# branch: feature-async-ui-state-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 TODO·결정·진행 기록. 현재는 scaffolding 단계다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-frontend-operational-contract]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: required와 non-blocking state matrix component test가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-UI-001@1` | UI composition은 React를 사용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-SERVER-STATE-001@1` | application-owned QueryCachePort와 TanStack Query adapter를 사용하고 client store에 server state를 복제하지 않는다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
이 브랜치는 project-wide contract `FE-OC-011`(async surface는 initial-loading·success·empty·terminal-error를 MUST 표현)의 **single owner**로서, hub §9.1 Async surface state model을 *되묻지 않고 구현할 수 있는 spec*으로 내린다. 원격 데이터에 의존하는 모든 view는 `loading` boolean 하나로 상태를 뭉개지 않고 required 4-state + non-blocking 4-state를 discrete하게 표현해야 하며(§9.1), 이 상태들을 React 함수형 컴포넌트 + 단방향 props 흐름으로 렌더한다(`FE-D004`, [[raw/official-docs/react-ui-library-official]] `REACT-UI-C1`·`REACT-UI-C5`). 부수적으로 `FE-OC-015`(operational failure를 state로 반환·render defect만 boundary throw), `FE-OC-020`(component state matrix test artifact), `FE-OC-024`(sample slice가 async surface를 fixture로 exercise)에 기여한다. 현재 frontend repository가 없으므로 본 노트의 모든 구현 주장은 `planned`이며 코드 evidence는 0건이다.
|
||||
|
||||
- 이슈: (없음 — repository 생성 전)
|
||||
- PR: (없음)
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- `FE-OC-011` 소유: async surface의 **required visible state** 4종(`initial-loading`/`success`/`empty`/`terminal-error`)과 **non-blocking state** 4종(`refreshing`/`stale-degraded`/`mutation-pending`/`mutation-conflict`)의 discrete 표현 계약 (hub §9.1).
|
||||
- 단일 `loading` boolean 금지 규칙의 코드 표현(base tagged union + non-blocking overlay flag 2축, §1) + base×overlay 합법 조합·indicator 우선순위 규칙.
|
||||
- server query/mutation 신호 → `{base, overlay}` 파생 계약의 **presentation 측 소비 형태**(view-model만 소비, TanStack Query client 직접 import 금지 — §4.3/§9.2).
|
||||
- `terminal-error` state가 normalized failure의 `userMessageKey` + `action`만 렌더하는 계약(§8.1/§8.4 소비).
|
||||
- `FE-D004`(UI composition = React) 소유 — 함수형 컴포넌트·props 단방향 흐름을 async state 렌더 기반으로 채택.
|
||||
- measurable completion: state matrix component test(base 4 + overlay 4 + 교차 2 + latch 전이 1 = 11 fixtures, `pnpm test:component` async fixtures).
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외. "이건 다른 owner 브랜치 범위"라고 답할 근거.
|
||||
|
||||
- **failure의 정규화(raw → 26-kind)**: `feature-frontend-error-classification-boundary-contract`(`FE-OC-008`) 소유. 본 브랜치는 normalized failure를 *소비*만 한다.
|
||||
- **server state의 fetch/cache/invalidation·QueryCachePort 정의**: `feature-server-state-caching-contract`(`FE-OC-012`) 소유. 본 브랜치는 port가 노출하는 상태 신호를 *소비*한다.
|
||||
- **error boundary topology·recovery 배치(boot/route/feature/async boundary 소유권)**: `feature-frontend-render-recovery-boundary-contract`(`FE-OC-015`) 소유. 본 브랜치는 "operational failure는 throw하지 않는다"는 계약만 제공.
|
||||
- **component test 하네스 구성(Vitest/RTL/MSW 설정·gate 분리)**: `feature-frontend-test-taxonomy-contract`(`FE-OC-020`) 소유. 본 브랜치는 async fixture 목록·기대치만 제공.
|
||||
- **loading/error live region·focus 관리의 axe 검증**: `feature-accessibility-baseline-contract` 소유(이 브랜치에 depend). async state는 a11y hook point만 노출하고 axe 규칙을 정의하지 않는다.
|
||||
- **auth token lifecycle / 401 replay**: 외부 auth owner + [[raw/branch-notes/feature-frontend-auth-session-integration-contract]] `FE-OC-010`.
|
||||
- **telemetry event 정의·redaction·sink 정책**: 본 브랜치는 async state 전용 telemetry event를 정의하지 않으며 [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] `FE-OC-014`가 소유한다(hub §2.2 Q7 응답). error 표기 state가 남기는 telemetry rule은 §8.2 failure matrix의 kind별 rule을 그대로 따르고, 본 브랜치는 §8.1 금지 필드(raw body/token/stack)를 UI·telemetry 양쪽에 노출하지 않는 계약만 제공한다.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/react-ui-library-official]] `REACT-UI-C1` | D1 — UI composition을 React로 채택(재사용 컴포넌트 단위로 async surface 구성). `FE-D004`의 official 근거. |
|
||||
| [[raw/official-docs/react-ui-library-official]] `REACT-UI-C5` | D1·D4 — 부모 state를 props로 자식에 전달하는 단방향 흐름을, discrete async state의 렌더/전파 모델로 채택. |
|
||||
| [[raw/official-docs/tanstack-query-server-state-official]] `TSQ-C1` | D4 — async surface가 소비하는 "server state"(loading/staleness/refetch 신호)의 정의적 근거. 단, port 소유·구현은 `FE-OC-012`에 위임(delegated). |
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆 증거 등급 표기.
|
||||
|
||||
- [ ] base 4-state tagged union + non-blocking overlay flag 집합(2축) + `deriveAsyncState` selector 계약 정의 — 등급: `planned`
|
||||
- [ ] base × overlay 합법 조합표(§1.2, 20조합) + 단일 슬롯 indicator 우선순위(§1.3) 확정 — 등급: `planned`
|
||||
- [ ] server query/mutation 신호 → `{base, overlay}` 파생 매핑표 확정(server-state 계약 fix 후) — 등급: `planned`
|
||||
- [ ] `terminal-error` 렌더 컴포넌트(`userMessageKey` + `action` only, raw body/stack 금지) — 등급: `planned`
|
||||
- [ ] non-throw 규율 + async→render boundary handoff 계약 문서화 — 등급: `planned`
|
||||
- [ ] `{base, overlay}` state matrix fixture 11종(base 4 + overlay 4 + 교차 2 + latch 전이 1, `pnpm test:component`) — 등급: `planned`
|
||||
- [ ] 구현 repository·검증 evidence 식별 — 등급: `needs-confirmation`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- 본 노트는 `/branch-spec` self-map으로 hub `FE-OC-011` owner scope에서 도출. frontend 코드는 아직 없음 → 전부 `planned` blueprint.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 아래 Decision Evidence Map의 prose mirror. 근거는 hub §9.1/§8/§10.1/§4 + `raw/official-docs/react-ui-library-official`.
|
||||
|
||||
- **D1**: UI composition은 React 함수형 컴포넌트 + 단방향 props 흐름으로 하고, async surface의 discrete state를 그 위에 렌더한다(`FE-D004`). 대안: native custom-element / 다른 framework fork(revisit trigger).
|
||||
- **D2**: 원격 데이터에 의존하는 모든 async surface는 required 4-state(`initial-loading`/`success`/`empty`/`terminal-error`)를 MUST 표현한다(§9.1). 대안: 없음(surface당 불변).
|
||||
- **D3**: non-blocking 4-state(`refreshing`/`stale-degraded`/`mutation-pending`/`mutation-conflict`)를 required state와 **별개 축으로** 표현한다. hub §9.1이 금지하는 것은 "`loading` boolean 하나로 empty/error/refreshing을 합치는 것"이므로, 금지 대상은 *상태 개수를 1개 boolean으로 붕괴시키는 것*이지 다축 구조 표현이 아니다.
|
||||
- **D8**: async surface 상태는 **`base` (required 4 중 정확히 1개) + `overlay` (non-blocking 4의 flag 집합)** 2축으로 표현한다. required 4는 §9.1 Data 열이 상호배타(none / present / valid empty / none-or-unusable)이므로 한 시점에 정확히 하나이고, non-blocking 4는 §9.1이 "Additional"로 분류하며 `refreshing`이 "existing content 유지"를 요구하므로 base를 *대체하지 않고 겹친다*. 단일 flat 8-union은 `success`+`refreshing` 동시 성립을 표현할 수 없어 기각. overlay 간 동시 성립 시 단일 슬롯 indicator 우선순위는 `mutation-conflict` > `mutation-pending` > `stale-degraded` > `refreshing`(§구현 가이드 §1.3).
|
||||
- **D4**: async state는 §1의 2축 구조(base tagged union + overlay flag set)로 표현하고 presentation은 application facade view-model만 소비한다. server query/mutation 신호 → `{base, overlay}` 파생은 application/adapter 경계에서 하며 presentation은 TanStack Query client를 직접 import하지 않는다(§4.3/§9.2).
|
||||
- **D5**: `terminal-error`(및 stale-degraded/mutation-conflict의 error 표기)는 error-classification이 낸 normalized failure의 `userMessageKey` + closed `action`만 렌더하고 raw body/stack을 노출하지 않는다(§8.1/§8.4 소비).
|
||||
- **D6**: async surface는 operational failure를 normal state로 반환하고 render boundary로 throw하지 않는다; render defect(programmer error/invariant breach)만 boundary가 잡는다(§10.1).
|
||||
- **D7**: 완료 판정은 base 4 + overlay 4 + 교차 2 + latch 전이 1(총 11 fixture)을 결정론적으로 재현하는 component matrix test(Vitest + RTL, `pnpm test:component`)다(§20 measurable completion).
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | UI composition은 React 함수형 컴포넌트 + 단방향 props 흐름을 채택하고, async surface의 discrete state를 그 위에 렌더한다 (`FE-OC-011` / `FE-OC-002`) | component-based UI를 유지하는 한 React default / native custom-element·다른 framework로 project fork 시 재검토(`FE-D004` revisit trigger) | `raw/official-docs/react-ui-library-official.md#REACT-UI-C1`, `#REACT-UI-C5`; [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-D004` | `official-vendor-doc` + `project-decision (accepted-documented-only)` | React 채택은 code evidence 없음(문서상 채택). 실제 컴포넌트 트리가 nesting/props 패턴을 따르는지 로컬 검증 필요(react-ui doc Usage Boundaries) |
|
||||
| D2 | 원격 데이터 의존 async surface는 required 4-state(`initial-loading`/`success`/`empty`/`terminal-error`)를 MUST 표현 (`FE-OC-011`) | async surface(원격 데이터 view)가 존재하는 한 항상 4-state / 순수 정적 view(원격 데이터 없음)엔 async state 계약 불필요 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §9.1 required visible states 표 | `project-decision` | exhaustive coverage는 `{base, overlay}` state matrix test 11종(base 4 + overlay 4 + 교차 2 + latch 전이 1)으로만 증명(measurable completion) — 미구현 시 empty/error 누락 경로 leak |
|
||||
| D3 | non-blocking 4-state(`refreshing`/`stale-degraded`/`mutation-pending`/`mutation-conflict`)를 required state와 **분리된 축**으로 표현; 금지 대상은 "`loading` boolean 하나로 empty/error/refreshing 합치기"로 한정 (`FE-OC-011`) | background activity·write-in-flight·retry-exhausted·conflict가 발생 가능한 surface에 적용 / 발생 불가한 surface는 해당 overlay 생략(단 required 4-state는 유지) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §9.1 "Additional non-blocking states" 표 + 인용 "`loading` boolean 하나로 empty/error/refreshing을 합치면 contract violation이다" | `project-decision` | 어떤 surface가 어떤 non-blocking state를 갖는지는 operation semantics에 의존 — surface별 적용 범위 판단 필요 |
|
||||
| D8 | async surface 상태는 `base`(required 4 중 1개) + `overlay`(non-blocking 4의 flag 집합) 2축으로 표현하고, overlay 동시 성립 시 단일 슬롯 indicator 우선순위는 `mutation-conflict` > `mutation-pending` > `stale-degraded` > `refreshing` (`FE-OC-011`) | §9.1이 required/additional 2표를 유지하고 `refreshing`이 기존 content를 유지하는 한 2축 / 만약 hub가 non-blocking state를 base와 상호배타로 재정의하면 flat union으로 회귀 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §9.1 required 표(Data 열 none/present/valid empty/none-or-unusable = 상호배타) + "Additional non-blocking states" 표 + `refreshing` UI 요구 "existing content 유지" + `mutation-pending` Data "current view" | `project-decision` (구조) + `UNSUPPORTED_IMPL_DECISION` (표현 shape·indicator 우선순위) | §9.1은 overlay 동시 성립 시 렌더 우선순위를 규정하지 않음 — §1.3 우선순위는 사용자 trade-off. base×overlay 합법 조합표(§1.2)는 §9.1 Data 열에서 도출한 해석이며 hub가 명시한 표가 아님 |
|
||||
| D4 | async state는 §1의 2축 `{base, overlay}`(base tagged union 1개 + non-blocking overlay flag 집합)로 표현, presentation은 application facade view-model만 소비하고 TanStack Query client를 직접 import하지 않음; server 신호 → `{base, overlay}` 파생은 application/adapter 경계 (`FE-OC-011` → `FE-OC-012` 소비) | server state가 `QueryCachePort`로 소유되는 한(`FE-D006`) 유지 / presentation 직접 import는 §4.3 dependency rule 위반이라 대안 아님 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §4.3 dependency matrix·§9.2 (presentation·application은 TanStack Query 직접 import 안 함); `raw/official-docs/react-ui-library-official.md#REACT-UI-C5`; `raw/official-docs/tanstack-query-server-state-official.md#TSQ-C1` | `project-decision` + `official-vendor-doc` | server 신호(status/fetchStatus) → `{base, overlay}` 매핑 함수 shape는 hub가 규정 안 함(§구현 가이드 UNSUPPORTED_IMPL). port 신호 형태는 `FE-OC-012` owner 소유 — 계약 fix 전엔 매핑 잠정 |
|
||||
| D5 | `terminal-error`(및 error 표기 state)는 normalized failure의 `userMessageKey` + closed `action`만 렌더, raw body/stack 노출 금지 (`FE-OC-011` ← `FE-OC-008` 소비) | 모든 error 표기에서 불변 / 예외 없음 — raw 노출은 `FE-OC-008`이 금지 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §8.1 normalized failure shape·§8.4 UX action vocabulary | `project-decision (delegated consume)` | `kind → action` 계약 shape은 error-classification(`FE-OC-008`) 소유 — 그 계약 미확정 시 렌더 계약 모호(해당 브랜치 D6이 "action 실제 UI 실행은 async-ui 소유"라고 위임함) |
|
||||
| D6 | async surface는 operational failure를 normal state(`terminal-error`/`stale-degraded`)로 반환하고 render boundary로 throw하지 않음; render defect만 boundary가 catch (`FE-OC-011` → `FE-OC-015` 기여) | normalized operational failure는 항상 state 반환 / programmer defect·invariant breach만 throw | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §10.1 error boundary ownership(async boundary는 normalized state를 catch, thrown render defect는 catch 안 함) | `project-decision` | boundary topology·recovery 배치는 render-recovery(`FE-OC-015`) 소유 — async surface는 "throw 안 함" 계약만 제공. 경계 계약이 어긋나면 operational failure가 render boundary로 새어 reload loop 위험 |
|
||||
| D7 | 완료 판정은 `{base, overlay}` 11 fixture(base 4 + overlay 4 + 교차 2 + latch 전이 1)를 결정론적으로 재현하는 component matrix test(Vitest + RTL, `pnpm test:component`) (`FE-OC-011` → `FE-OC-020` 기여) | 2축 `{base, overlay}` 계약이 유효한 한 매트릭스 test / 대안 없음 — 완료의 유일 evidence(§20 measurable completion) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §20 measurable completion·§16 `pnpm test:component`(async fixtures)·`FE-D022` test stack | `project-decision` + `conditional-default (test stack)` | RTL/Vitest 하네스·fixture 구조는 test-taxonomy(`FE-OC-020`) 소유 — 본 브랜치는 async fixture 목록·기대치만 확정 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 전부 `planned` blueprint. 경로는 hub §4.6 Planned directory blueprint에서 도출(repository 생성 시 변경 가능). 코드는 존재하지 않는다.
|
||||
|
||||
### 1. Async surface state machine (base tagged union + non-blocking overlay flags)
|
||||
|
||||
> **Trace**: D2·D3·D8 + `FE-OC-011` + hub §9.1. §9.1은 두 개의 표를 유지한다 — "Required visible states"(4) 와 "Additional non-blocking states"(4). 후자는 전자를 *대체하지 않는다*: `refreshing`의 UI 요구가 "existing content 유지"이고 `mutation-pending`의 Data가 "current view"이므로, 이 state들은 데이터를 이미 가진 base 위에 겹친다. 따라서 8개를 하나의 상호배타 union으로 뭉치면 `success`+`refreshing` 또는 `success`+`mutation-pending` 동시 성립을 표현할 수 없다. 본 절은 이를 **2축**(base 1개 + overlay flag 집합)으로 계약화한다.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 2축 값 객체의 구체 shape(`{ base: 'success', overlay: { refreshing: false, staleDegraded: false, mutationPending: false, mutationConflict: false } }`)·tag 필드명·모듈 경로(`src/presentation/components/async/async-surface-state.js`)·`isValidEmpty(data)` 판별자 — hub §9.1은 state 이름과 UI 요구만 규정하고 JS 표현 shape/파일 경로/empty 판별 predicate를 규정하지 않음. Trade-off: base를 tagged union으로 두어 exhaustive `switch` + RTL fixture addressability를 유지하고, overlay는 flag 집합으로 두어 동시 성립을 손실 없이 표현. hub §9.1이 실제로 금지하는 문장은 "`loading` boolean 하나로 empty/error/refreshing을 합치면 contract violation이다"이므로 금지 대상은 *단일 boolean으로의 붕괴*이고, base+overlay 구조 표현은 그 금지에 해당하지 않는다(오히려 empty/error/refreshing이 서로 구분 가능하게 남는다).
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: §1.3 indicator 우선순위 — §9.1은 overlay가 동시에 성립할 때 어떤 UI 요구를 우선할지 규정하지 않음. Trade-off: "사용자 조치를 요구하는 것이 조용한 배경 신호보다 우선"이라는 단일 원칙으로 정렬.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: §1.1.1 `staleFailure` latch — hub §9.1은 `stale-degraded`의 진입 조건만 주고 clear/exit 조건을 규정하지 않으며, latch의 보관 위치·수명(query key 단위 / adapter 내부 vs selector 인자)과 refetch 진행 중 stale label 유지 여부도 규정하지 않음. Trade-off: latch를 `!refetchInFlight`와 곱해 read 축 두 overlay를 *정의상* 배타로 만들어(§9.2 focus refetch가 발동하는 정상 경로에서 invariant throw 회피), refetch 진행 중에는 stale label은 유지하되 manual retry affordance만 비활성화한다("아직 stale이지만 재시도 중"을 전달). alt = "동시 성립을 합법으로 허용"은 §9.1의 두 UI 요구(subtle indicator vs stale label + manual retry)가 같은 슬롯에서 충돌해 기각.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: §1.2 query-less(mutation-only) surface의 base 규칙 — hub §9.1은 "원격 데이터에 의존하는 surface"만 다루고 read query가 없는 write-only surface의 base를 규정하지 않음. Trade-off: base = `success` 고정 + write 축 overlay만 허용해, form이 항상 렌더 가능하다는 사실과 §9.1 required-state 표현 의무를 동시에 만족. alt = 이런 surface를 계약 밖으로 배제하면 `mutation-pending`의 "duplicate action 차단"(§9.1) 근거가 submit form에서 사라져 기각.
|
||||
> - **해석 주의(§1.2 조합표의 지위)**: §1.2 base×overlay 합법 조합표는 hub가 명시한 표가 **아니라** §9.1 Data 열(none / present / valid empty / none-or-unusable, 그리고 overlay 4종의 데이터 전제)에서 도출한 *해석*이다. 위반 시 render defect로 취급해 throw하는 근거(D6 invariant breach 경로)도 이 해석 위에 서 있다. hub가 §9.1에 조합표를 명시하면 본 절이 그것으로 대체된다.
|
||||
|
||||
#### 1.1 두 축
|
||||
|
||||
Base state (§9.1 required 표 — 한 시점에 **정확히 1개**, Data 열이 상호배타):
|
||||
|
||||
| base | data | activity | UI 요구 (§9.1) | 파생 신호(소비) |
|
||||
|---|---|---|---|---|
|
||||
| `initial-loading` | none | first request | 안정적 skeleton, focus theft 금지 | query pending & no cached data |
|
||||
| `success` | present | idle | view-model render | query success & non-empty |
|
||||
| `empty` | valid empty | idle | empty 사유 + 가능 시 primary action | query success & `isValidEmpty` |
|
||||
| `terminal-error` | none/unusable | stopped | safe message + registry action(§3 참조) | normalized failure(retry 소진/비재시도) |
|
||||
|
||||
Overlay flags (§9.1 "Additional non-blocking states" 표 — **0개 이상 동시 성립**, base를 대체하지 않음):
|
||||
|
||||
| overlay | data 전제 | activity | UI 요구 (§9.1) | 파생 신호(소비) |
|
||||
|---|---|---|---|---|
|
||||
| `refreshing` | stale/present | background | 기존 content 유지 + subtle indicator | `refetchInFlight` — background refetch가 진행 중 |
|
||||
| `stale-degraded` | cached | retry exhausted | stale label + manual retry | `staleFailure` latch set(직전 refetch가 재시도 소진/비재시도로 실패 & cached 존재) **AND** 현재 refetch in-flight 아님 |
|
||||
| `mutation-pending` | current view | write in flight | 중복 action 차단 | mutation pending |
|
||||
| `mutation-conflict` | authoritative refetch 필요 | stopped | conflict action | `CONFLICT`(409) normalized failure |
|
||||
|
||||
#### 1.1.1 read-overlay latch 전이 (`refreshing` ⊕ `stale-degraded`의 배타성 근거)
|
||||
|
||||
`stale-degraded`는 독립 flag가 아니라 **latch 1개 + in-flight 부정**의 파생값이다. read 축 전체를 `refetchInFlight`(현재 refetch 진행 여부)와 `staleFailure`(직전 refetch 실패가 아직 해소되지 않음) 두 신호로 계산한다:
|
||||
|
||||
```text
|
||||
refreshing := refetchInFlight
|
||||
stale-degraded := staleFailure && !refetchInFlight
|
||||
```
|
||||
|
||||
`staleFailure` latch가 필요한 이유: hub §9.1은 `stale-degraded`의 진입 조건(retry exhausted)만 규정하고 **exit 조건을 규정하지 않는데**, hub §9.2의 cache default가 "refetch on focus = enabled for stale query"이므로 `stale-degraded` surface는 window focus만으로 자동 background refetch에 진입한다. latch 없이 `stale-degraded`를 "직전 실패 & cached"로만 정의하면 그 정상 경로에서 `refreshing`과 동시 성립해 §1.2 배타 불변식이 깨진다. 위 정의는 두 flag를 `refetchInFlight` 하나의 참/거짓으로 갈라 **정의상(구조적으로)** 배타로 만든다 — 런타임 검사에 의존하지 않으므로 focus refetch 경로에서 invariant가 throw되지 않는다.
|
||||
|
||||
| 전이 | 트리거 | latch 변화 | 결과 read overlay |
|
||||
|---|---|---|---|
|
||||
| `stale-degraded` → `refreshing` | 재refetch **진입** — window focus 자동 refetch(§9.2) 또는 stale label의 manual retry | `staleFailure` **유지**(clear하지 않음) | `refreshing` only |
|
||||
| `refreshing` → ∅ | refetch **성공** | `staleFailure` clear | ∅ (base가 `success`/`empty`로 갱신) |
|
||||
| `refreshing` → `stale-degraded` | refetch **실패** & cached 존재 | `staleFailure` set(유지) | `stale-degraded` only |
|
||||
| `refreshing` → (base 전환) | refetch **실패** & cached 없음 | — | read overlay ∅ — §1.2에 따라 base = `terminal-error` |
|
||||
| ∅ → `refreshing` | 최초 background refetch(직전 실패 없음) | 변화 없음(unset) | `refreshing` only |
|
||||
|
||||
manual retry와 focus 자동 refetch는 **같은 전이**를 쓴다(둘 다 refetch 진입). "manual retry는 foreground라 `refreshing`이 아니다"라는 구분은 두지 않는다 — 그 구분은 §9.1에 근거가 없고, focus refetch 경로가 자동이므로 배타성을 구제하지도 못한다.
|
||||
|
||||
#### 1.2 합법 조합 (base × overlay)
|
||||
|
||||
§9.1 Data 열에서 도출: 4개 overlay 모두 *이미 렌더 가능한 데이터가 존재함*을 전제(stale/present · cached · current view · authoritative refetch 필요)하므로, 데이터가 없는 base에는 붙을 수 없다.
|
||||
|
||||
| base | 허용 overlay | 근거 |
|
||||
|---|---|---|
|
||||
| `initial-loading` | 없음 (∅) | Data = none — 유지할 기존 content가 없어 "existing content 유지"·"current view"가 성립 불가 |
|
||||
| `success` | 4종 모두 | Data = present |
|
||||
| `empty` | 4종 모두 | Data = valid empty(유효한 데이터) — refetch·mutation 모두 성립 가능 |
|
||||
| `terminal-error` | 없음 (∅) | Data = none/unusable, Activity = stopped — cached content가 남아 있다면 base는 `terminal-error`가 아니라 `success`/`empty` + `stale-degraded` |
|
||||
|
||||
Overlay 내부 상호배타(정의상 도출):
|
||||
|
||||
- `refreshing` ⊕ `stale-degraded` — §1.1.1 latch 정의(`stale-degraded := staleFailure && !refetchInFlight`)에서 **구조적으로** 도출. 직전 refetch 실패 후 focus 자동 refetch(§9.2)가 다시 걸리면 `stale-degraded → refreshing`으로 *전이*하며 동시 성립하지 않는다. 이 배타성은 런타임 assert가 아니라 파생식의 성질이다.
|
||||
- `mutation-pending` ⊕ `mutation-conflict` — 전자는 "write in flight", 후자는 Activity "stopped". 동시 성립 불가.
|
||||
|
||||
query-less(mutation-only) surface 규칙: read query가 없는 surface(제출 전용 form 등)는 base를 `initial-loading`으로 두지 않는다. 읽을 원격 데이터가 없어 "first request 대기"가 성립하지 않고 렌더 가능한 form view가 항상 존재하므로 **base = `success` 고정**이며, write 축 overlay(`mutation-pending`/`mutation-conflict`)만 사용한다. read 축 overlay(`refreshing`/`stale-degraded`)는 성립하지 않는다. `deriveAsyncState`는 `queryResult`가 `undefined`일 때 이 규칙을 적용한다.
|
||||
|
||||
→ 따라서 동시 성립하는 overlay는 최대 2개(read 축 1 + write 축 1)이며, 전체 합법 조합 수는 `initial-loading`(1) + `terminal-error`(1) + (`success`·`empty`) × 3(read: none/refreshing/stale-degraded) × 3(write: none/pending/conflict) = 20이다.
|
||||
|
||||
#### 1.3 동시 성립 시 우선순위 (indicator precedence)
|
||||
|
||||
read overlay와 write overlay는 서로 다른 affordance를 점유하므로(read = content 영역 indicator/stale label, write = action 영역 차단/conflict action) **기본은 동시 렌더**다. 단일 슬롯(예: surface 헤더의 status indicator 1칸)만 있는 경우에만 다음 순서로 하나를 고른다:
|
||||
|
||||
```text
|
||||
mutation-conflict > mutation-pending > stale-degraded > refreshing
|
||||
```
|
||||
|
||||
원칙: 사용자 조치를 요구하며 activity가 stopped인 것 → 사용자 조작을 차단하는 것 → 수동 retry를 요구하는 것 → 조용한 배경 신호. `deriveAsyncState`는 이 우선순위를 *렌더 힌트*(`overlay.primary`)로만 계산하고, overlay flag 자체는 절대 삭제하지 않는다(삭제하면 §9.1 요구가 유실됨).
|
||||
|
||||
### 2. Server-signal → state 파생 (consume, not define)
|
||||
|
||||
> **Trace**: D4·D8 + `FE-OC-011` → `FE-OC-012` 소비. TanStack Query query/mutation 신호를 §1의 2축 상태(`{base, overlay}`)로 파생하는 순수 selector를 application/adapter 경계에 둔다. query 신호는 base + read overlay를, mutation 신호는 write overlay를 결정하며, 두 축은 독립적으로 계산된 뒤 §1.2 합법 조합표로 검증된다. presentation은 결과 view-model만 받는다.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: query `{status, fetchStatus, data, isPlaceholderData}` 및 mutation `{status}` 튜플 → `{base, overlay}`의 구체 매핑표와 selector signature(`deriveAsyncState(queryResult, mutationResult, { isValidEmpty, staleFailure })`) — hub는 state 집합만 정의하고 TanStack 필드→state 매핑은 규정하지 않음. signature는 §1.1.1의 `staleFailure` latch를 명시 입력으로 받고(selector를 순수 함수로 유지), `queryResult`가 `undefined`이면 §1.2 query-less 규칙(base = `success`, write 축 overlay만)을 적용한다. Trade-off: 파생을 경계에 두어 presentation을 framework-neutral로 유지(§4.3), alt = page-local 파생은 dependency rule 위반이라 기각.
|
||||
> - **R3(위임)**: `QueryCachePort`가 노출하는 실제 신호 형태·query key·invalidation은 `feature-server-state-caching-contract`(`FE-OC-012`)가 소유한다. 본 절은 그 신호를 *소비*하는 매핑만 명세하며, port 신호 shape이 확정되면 매핑표를 fix한다.
|
||||
|
||||
### 3. error-표기 state 렌더 계약
|
||||
|
||||
> **Trace**: D5 + `FE-OC-011` ← `FE-OC-008` 소비. error를 표기하는 state(`terminal-error`, `stale-degraded`, `mutation-conflict`)는 normalized failure의 safe 필드만 사용한다.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `action`(6-closed: `retry`/`reauth`/`navigate`/`reload-once`/`contact-support`/`none`) → 구체 버튼/handler 컴포넌트(`AsyncErrorSurface`) 매핑, `userMessageKey` → copy 카탈로그 lookup — hub §8.4는 action 어휘와 allowed-when/MUST-NOT만 규정하고 컴포넌트/카피 구현은 규정 안 함. Trade-off: action별 단일 presentational 컴포넌트로 고정해 테스트 대상을 좁힘; copy 카탈로그(i18n)는 본 브랜치 밖.
|
||||
> - **R3(위임)**: `kind → action`·`kind → userMessageKey` 매핑 계약은 `feature-frontend-error-classification-boundary-contract`(`FE-OC-008`)가 소유(그 브랜치 D6이 "action의 실제 UI 실행은 async-ui가 소유"라고 위임). 본 절은 소비/렌더만.
|
||||
|
||||
렌더 불변식: raw response body·token·authorization header·full URL/query·stack·storage value를 error state UI에 노출하지 않는다(§8.1).
|
||||
|
||||
### 4. Non-throw 규율 + async→render boundary handoff
|
||||
|
||||
> **Trace**: D6 + `FE-OC-011` → `FE-OC-015` 기여. async surface는 normalized operational failure를 반드시 state로 반환하고 render boundary로 throw하지 않는다.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: "state로 반환됐고 throw되지 않았음"을 강제하는 test 어서션 형태(예: failure 주입 후 nearest error boundary 미발동 assert) — hub는 원칙만 규정. Trade-off: integration test에서 boundary render 여부로 검증(별도 boundary mock 대신 실제 boundary 미발동 관찰).
|
||||
> - **R3(위임)**: boundary 배치·소유권(boot/route/feature/async boundary)·reload loop 방지(§10.2)는 `feature-frontend-render-recovery-boundary-contract`(`FE-OC-015`)가 소유. 본 절은 async surface가 그 boundary를 발동시키지 않는다는 계약만 제공.
|
||||
|
||||
### 5. Component state matrix tests (measurable completion)
|
||||
|
||||
> **Trace**: D7·D8 + `FE-OC-011` → `FE-OC-020` 기여. base 4종과 overlay 4종을 각각 결정론적으로 재현하는 component fixture(8종) + overlay 동시 성립 우선순위 fixture(2종) + §1.1.1 read-overlay latch 전이 fixture(1종)를 작성하고 `pnpm test:component` gate(`artifacts/tests/component.xml`)에 편입.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: fixture 파일명·경로(`tests/component/async-surface.state-matrix.test.jsx`)·RTL query 전략(role/label 기준) — hub §20은 "state matrix component tests" 결과만 요구하고 파일 배치·query 전략은 규정 안 함. Trade-off: base/overlay당 최소 1 fixture로 1:1 addressable하게 배치하고, §1.2의 20개 합법 조합 전수 대신 축별 1개 + 교차 2개 + latch 전이 1개로 축소(전수는 fixture 유지비가 계약 가치를 넘어섬). latch 전이만 예외적으로 fixture를 추가한 이유는 그것이 정적 조합이 아니라 §9.2 focus refetch가 발동시키는 *시간 축* 경로여서 정적 조합 fixture로는 재현되지 않기 때문.
|
||||
> - **R3(위임)**: Vitest/RTL/MSW 하네스 구성·gate 분리·artifact 규약은 [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-D022`가 규정하고, 구현 소유자는 [[raw/branch-notes/feature-frontend-test-taxonomy-contract]](`FE-OC-020`)다. 본 절은 async fixture 목록(11종)과 각 fixture의 기대 `{base, overlay}`만 확정.
|
||||
|
||||
| fixture | 주입 조건 | 기대 base | 기대 overlay |
|
||||
|---|---|---|---|
|
||||
| initial-loading | pending & no cache | `initial-loading` | ∅ |
|
||||
| success | success & non-empty | `success` | ∅ |
|
||||
| empty | success & valid empty payload | `empty` | ∅ |
|
||||
| terminal-error | normalized failure(비재시도/재시도 소진) & cache 없음 | `terminal-error` | ∅ |
|
||||
| refreshing | success & background refetch in flight | `success` | `refreshing` |
|
||||
| stale-degraded | refetch 실패 & cached 존재 | `success` | `stale-degraded` |
|
||||
| mutation-pending | success & mutation in flight | `success` | `mutation-pending` |
|
||||
| mutation-conflict | success & `CONFLICT`(409) normalized failure | `success` | `mutation-conflict` |
|
||||
| overlay-cross | refetch in flight + mutation in flight 동시 | `success` | `refreshing` + `mutation-pending`(단일 슬롯 = `mutation-pending`) |
|
||||
| overlay-precedence | stale-degraded + mutation-conflict 동시 | `success` | `stale-degraded` + `mutation-conflict`(단일 슬롯 = `mutation-conflict`) |
|
||||
| stale-degraded → 재refetch | `stale-degraded` 상태에서 window focus 자동 refetch 진입(§9.2) — 이어서 (a) 성공 / (b) 실패 & cached 존재 | 진입 중 `success` → (a) `success` / (b) `success` | 진입 중 `refreshing` **only**(`stale-degraded` false, `staleFailure` latch는 유지) → (a) ∅ / (b) `stale-degraded` only. 세 시점 모두 두 flag 동시 true 아님을 assert(§1.1.1 latch 전이) |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- `empty` vs `terminal-error` 오분류: valid empty payload를 error로 렌더하면 안 됨 → per-operation `isValidEmpty` predicate 필요(§9.1 empty = "valid empty").
|
||||
- `initial-loading`: skeleton 안정성 유지 + focus theft 금지(§9.1). live region 반복 announcement 억제는 a11y 브랜치 위임.
|
||||
- `refreshing` 중 background refetch 실패 → `stale-degraded`로 전이 + stale label + manual retry(§9.1), 기존 content 유지(§1.1.1 전이표).
|
||||
- `stale-degraded` surface가 window focus를 되찾아 자동 refetch(§9.2 "refetch on focus = enabled for stale query")에 진입 → `staleFailure` latch는 유지한 채 `stale-degraded → refreshing`으로 전이한다. 두 flag가 동시에 true가 되지 않으므로 §1.2 read 축 배타 불변식은 이 정상 경로에서 깨지지 않는다(§1.1.1).
|
||||
- `mutation-pending` 중 중복 submit → duplicate action 차단(§9.1).
|
||||
- `mutation-conflict`(409) → authoritative refetch를 요구하는 conflict action(§8.2 `CONFLICT` row).
|
||||
- async surface가 normalized failure를 못 만든 채 throw되는 경로: 이는 error-classification total-function(§8.2) 위반이며, 만약 새면 render boundary(`FE-OC-015`)가 최후로 catch — async surface는 이를 유발하지 않아야 함.
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] `FE-OC-008` — normalized failure(`userMessageKey`/`action`/safe 필드)를 consume. 그 계약이 바뀌면 error 표기 state 렌더가 영향.
|
||||
- [[raw/branch-notes/feature-server-state-caching-contract]] `FE-OC-012` — `QueryCachePort`의 query/mutation 상태 신호를 consume해 §1의 2축 `{base, overlay}`를 파생. port 신호 shape 변경 시 매핑 재조정. 특히 cache default "refetch on focus = enabled for stale query"(hub §9.2)가 §1.1.1 read-overlay latch 전이(`stale-degraded → refreshing`)를 발동시키는 경로이므로, focus refetch를 opt-out하는 surface는 그 전이가 manual retry로만 일어난다.
|
||||
- [[raw/branch-notes/feature-frontend-render-recovery-boundary-contract]] `FE-OC-015` — boundary ownership에 의존. async surface는 throw하지 않는다는 계약을 제공하고 boundary 배치는 위임.
|
||||
- [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] `FE-OC-020` — component test 하네스·gate를 consume해 matrix fixture를 편입.
|
||||
- [[raw/branch-notes/feature-sample-feature-slice-contract-fixture]] `FE-OC-024` — sample slice가 async surface를 fixture로 exercise(sample removal smoke 대상).
|
||||
- [[raw/branch-notes/feature-accessibility-baseline-contract]] — loading/error live region·focus(§10.3)를 소유. async state는 a11y hook point만 노출.
|
||||
- [[raw/branch-notes/feature-tailwind-design-token-styling-contract]] `FE-OC-011` — 본 브랜치가 요구하는 시각 primitive(안정적 skeleton, `refreshing`의 subtle indicator, `stale-degraded`의 stale label, 단일 슬롯 status indicator)의 token-driven 어휘를 소유하는 co-tenant(hub Decision Register `FE-D005`가 `FE-OC-011`에 영향). 본 브랜치는 *어떤 state가 존재하고 언제 성립하는지*를 소유하고, 그 state의 시각 표현 어휘는 위임한다. 해당 브랜치 D4가 동일 경계를 반대편에서 명시("state machine·required-state는 위임"). primitive 어휘가 §1.2 조합표의 동시 렌더(read overlay + write overlay)를 표현하지 못하면 §1.3 단일 슬롯 fallback으로 축약된다.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| component matrix가 base 4 + overlay 4 + 교차 2 + latch 전이 1을 결정론적으로 재현한다 | 코드·fixture가 아직 없음 | `pnpm test:component` async state matrix fixtures(`artifacts/tests/component.xml`) — base/overlay당 최소 1 fixture + `stale-degraded → 재refetch` 전이 fixture exit 0 | `needs-confirmation` |
|
||||
| `stale-degraded` 상태에서 focus 자동 refetch(§9.2)가 걸려도 `refreshing`·`stale-degraded`가 동시 true가 되지 않는다 | hub §9.1이 `stale-degraded`의 exit 조건을 규정하지 않아 latch 정의(§1.1.1)는 본 브랜치의 해석 | `deriveAsyncState` unit test — `staleFailure` latch set 상태에서 `refetchInFlight` true/false를 토글하며 두 flag의 동시 true 부재 assert + §5 `stale-degraded → 재refetch` fixture | `needs-confirmation` |
|
||||
| server 신호(status/fetchStatus/data + mutation status) → `{base, overlay}` 파생이 gap 없이 exhaustive하다 | hub가 매핑표를 규정하지 않아 잠정 | `deriveAsyncState` selector unit test(모든 튜플 조합 → base 정확히 1개 + overlay flag 집합이 §1.2 합법 조합에 속함) | `needs-confirmation` |
|
||||
| §1.2 합법 조합표가 실제 surface에서 위반되지 않는다(예: `terminal-error` + `refreshing` 동시 방출 없음) | 조합표는 §9.1 Data 열에서 도출한 해석이며 hub 명시 표가 아님 | `deriveAsyncState` invariant test — 불법 조합 방출 시 throw(render defect로 취급, D6의 "invariant breach" 경로). **단 read 축 배타(`refreshing` ⊕ `stale-degraded`)는 §1.1.1 파생식의 성질이라 런타임 throw 대상이 아니다** — throw가 걸리는 것은 base×overlay 조합(데이터 없는 base에 overlay 부착) 위반뿐이며, read 축은 `stale-degraded := staleFailure && !refetchInFlight`가 성립하는지 unit test로 확인한다 | `needs-confirmation` |
|
||||
| error 표기 state가 raw body/stack/token을 노출하지 않는다 | 렌더 경로가 미구현 | negative test — 직렬화 후 금지 필드 부재 assert(§8.1, error-classification D2 패턴 mirror) | `needs-confirmation` |
|
||||
| async surface가 operational failure에 render boundary로 throw하지 않는다 | boundary 계약·구현 미확정 | integration test — failure 주입 후 nearest error boundary 미발동 assert(§10.1) | `needs-confirmation` |
|
||||
| `isValidEmpty` predicate가 valid-empty를 error로 오분류하지 않는다 | per-operation empty 판별자가 미정 | component fixture(empty payload) → `empty` state assert | `planned` |
|
||||
| React 컴포넌트 트리가 nesting/props 단방향 흐름을 준수한다 | react-ui doc Usage Boundaries가 로컬 검증 요구 | 구현 후 architecture lint(`FE-OC-002` dependency-cruiser/ESLint) | `planned` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 없음 — scaffolding 단계
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
| `FE-OC-002@1` | [[raw/branch-notes/feature-frontend-clean-architecture-layering-contract]] | `domain <- application <- presentation` 의존 방향과 application-owned output port를 MUST 지킴 | import 참조로 적용 |
|
||||
| `FE-OC-008@1` | [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] | 모든 failure는 stable frontend error kind로 MUST 정규화하고 raw body·stack을 UI에 노출하면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-012@1` | [[raw/branch-notes/feature-server-state-caching-contract]] | query key와 invalidation은 registry factory만 MUST 사용 | import 참조로 적용 |
|
||||
| `FE-OC-015@1` | [[raw/branch-notes/feature-frontend-render-recovery-boundary-contract]] | expected operational error와 render defect를 MUST 분리하고 reload loop를 금지 | import 참조로 적용 |
|
||||
| `FE-OC-020@1` | [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] | gate 종류별 책임·fixture·artifact를 분리하고 실패를 warning으로 낮추면 안 됨 | import 참조로 적용 |
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
### Sub-branches (세부 작업)
|
||||
|
||||
- 없음 — scaffolding 단계
|
||||
|
||||
### 오류 기록 (이 branch 작업 중 발생)
|
||||
|
||||
- 없음 — scaffolding 단계
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- 없음 — scaffolding 단계
|
||||
|
||||
### 강의 (이 작업을 위해 학습한 강의)
|
||||
|
||||
- 없음 — scaffolding 단계
|
||||
|
||||
### job-posting tie-ins (이 작업에서 파생된 글감)
|
||||
|
||||
- 없음 — scaffolding 단계
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- 없음 — scaffolding 단계
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크: TODO
|
||||
- 리뷰 메모: TODO
|
||||
- 머지 결과 / 배포 환경: TODO
|
||||
- **wiki 추출 대상**: 없음 — scaffolding 단계
|
||||
- **추출하지 않을 항목**: 없음 — scaffolding 단계
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-authentication-authorization-contract.md
|
||||
@@ -0,0 +1,407 @@
|
||||
---
|
||||
title: branch / feature-authentication-authorization-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-authentication-authorization-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets.md]
|
||||
tags: [branch, ca-skeleton, security, authorization, authz, rbac]
|
||||
created: 2026-06-08
|
||||
target_merge:
|
||||
status_label: review
|
||||
last_implementation: 2026-06-08
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-048
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-048
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-ERROR-ENVELOPE-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-FRAMEWORK-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-ARCHTEST-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-008, WI-CA-SKELETON-OPERATIONAL-CONTRACT-005, WI-CA-SKELETON-OPERATIONAL-CONTRACT-018, WI-CA-SKELETON-OPERATIONAL-CONTRACT-014, WI-CA-SKELETON-OPERATIONAL-CONTRACT-022, WI-CA-SKELETON-OPERATIONAL-CONTRACT-001]
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: 01bf98ba6718be772bdd1b8ffac611c2fc2f9ce395fa052a2ed2b40376847b14
|
||||
---
|
||||
|
||||
> **2026-06-08 구현 완료 (working tree, 미커밋)** — 본 노트 설계대로 ca-tmpl `src/` 에 authz 계약 구현됨(코드 javadoc 이 D1/D2/D3/D4/§3 인용). 등급·발견은 §Audit & Findings 참조. **노트 정정**: §2 의 ArchUnit rule 을 "REFERENCE ONLY / 미구현(host=architecture-enforcement-rules)" 로 적었으나, 실제로는 architecture-enforcement suite(`app-bootstrap/.../CleanArchitectureTest`)에 D4 rule 로 구현됨 — 위임 설계대로 producer=본 branch / host=suite 가 실현됨.
|
||||
|
||||
# branch: feature-authentication-authorization-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — **product API 인가(authorization) 계약**: 인증된 principal 이 *무엇을 할 수 있는가* 를 결정하는 enforcement point(PEP) + permission/role 모델 + use-case 단위 권한 선언. 인증(authN)·JWT 검증·401/403 분류는 sibling `feature-security-operational-baseline` 가 owns(중복 금지). 머지 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
> 이 branch 는 project 의 직접 자식(`parent_branch:` 비어있음). ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT.
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]] — §35 D/E #5 ("AuthN/AuthZ product API baseline — JWT/OAuth2 RS + RBAC/ABAC + endpoint auth annotation") 가 본 branch 신설 근거. project §5(presentation/application/domain exception ownership)·§6(`AUTHZ` category)·§10(repository capability — *별개 축*)·§11 Security 가 관련 영역.
|
||||
|
||||
선택 (형제 — 직접 의존):
|
||||
|
||||
- [[raw/branch-notes/feature-security-operational-baseline]] — authN + JWT 검증 + principal mapping + 401/403 matrix owner. 본 branch 는 `AuthenticatedUser.roles`의 **prefix 없는 raw role**을 consume하고, Spring `ROLE_*` authority는 adapter 경계의 파생 표현으로만 취급한다.
|
||||
- [[raw/branch-notes/feature-repository-access-permission-contract]] — `@UseCaseCapability` (use case → *infrastructure* capability). **사용자 권한이 아님**(registry 명시) — 본 branch 와 직교하는 축.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: authentication·authorization ownership, error mapping, forbidden dependency test가 명시된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1` | Gradle multi-module에서 domain-core·application-core·adapter-*·shared-contract·app-bootstrap·sample-portfolio 책임을 분리한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-ERROR-ENVELOPE-001@1` | foundation이 envelope schema와 error.category enum의 단일 owner다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-FRAMEWORK-001@1` | framework는 Spring Boot 3.5.14다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-ARCHTEST-001@1` | architecture test는 archunit-junit5 1.3.0을 사용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
인증(authN)은 *너는 누구인가*, 인가(authZ)는 *너가 이 작업을 할 권한이 있는가* 다 (OWASP-AUTHZ-C3). `feature-security-operational-baseline` 은 JWT 를 검증하고 claim 을 `AuthenticatedUser.roles`의 raw role로 보존하며 Spring 경계에서 `ROLE_*` authority를 파생하고, 권한 부족을 `AUTHZ_INSUFFICIENT_PERMISSION` 403 으로 *분류* 까지 하지만, **무엇이 그 403 을 발생시킬지(실제 authz 결정) 를 정의하지 않는다.** ca-tmpl `src/` 에는 method/endpoint 단위 authorization 이 전무하다 — `@PreAuthorize`/`@EnableMethodSecurity`/`AuthorizationManager` 0건, `SecurityConfig` 는 `.authenticated()` (인증만 하면 누구나 통과) 뿐. 즉 `AUTHZ_INSUFFICIENT_PERMISSION` code 는 registry 에 등록돼 있으나 *아무도 emit 하지 않는다.*
|
||||
|
||||
본 branch 는 그 빈 자리를 채운다: **인증된 principal 의 권한을 use-case 단위로 검증하는 enforcement point + permission 중심 RBAC 모델 + 확장점**. ca-tmpl 은 도메인 없는 skeleton 이므로 concrete 비즈니스 role 은 정의하지 않고, *계약 + infrastructure + sample-portfolio 시연* 만 둔다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- **Enforcement point**: use-case `AuthorizationPort` / `@RequiresPermission` 추상화 (application-core). Spring Security 를 application/domain layer 밖에 유지.
|
||||
- **Permission 중심 RBAC 모델**: permission = 집행 단위(`resource:action`), role = permission 묶음. role→permission 해소.
|
||||
- **`@RequiresPermission` 선언 의무 + ArchUnit 집행** (rule host = architecture-enforcement-rules suite — REFERENCE ONLY).
|
||||
- **실패 → `AUTHZ_INSUFFICIENT_PERMISSION` 403 emission** (code SSOT = security-baseline; 본 branch 는 *emission point* producer).
|
||||
- **3-tier access model**: public ⊂ authenticated ⊂ authorized(permission).
|
||||
- **sample-portfolio authz 시연** (worklog read/write/close permission).
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적 제외 — sibling owner 영역. 면접 시 "이건 다른 계약 소관" 근거.
|
||||
|
||||
- **JWT 검증 / JWKS / clock skew / claim→principal·Spring authority 매핑 / CORS / 401·403 분류 matrix** → `feature-security-operational-baseline` owns. 본 branch 는 그 출력 중 prefix 없는 raw role set만 *consume* 한다.
|
||||
- **`@UseCaseCapability` (repository infra-capability)** → `feature-repository-access-permission-contract` owns. *사용자 권한과 혼동 금지*(그 branch out-of-scope 에 "runtime authorization 혼동" 명시).
|
||||
- **cross-tenant authz (`AUTHZ_TENANT_MISMATCH`)** → `feature-tenant-context-policy` owns. 본 branch 는 ABAC 확장점만 언급.
|
||||
- **OAuth2 authorization server / token 발급 flow / IdP(Keycloak) realm 설정** → IdP-side. 본 branch 는 resource-server 측 authz 결정만.
|
||||
- **concrete 비즈니스 role/permission 값** (도메인 영역). sample-portfolio 시연 외 실제 role 정의 안 함.
|
||||
- **ArchUnit rule suite 자체** → `feature-architecture-enforcement-rules` host. 본 branch 는 rule producer.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 branch 결정 근거. company-tech-blog 증거는 `company-case-study` 로 표기(공식 best practice 승격 금지).
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/security-authorization-cheatsheet-owasp]] | D1(server-side enforcement·always-decide), D2(least-privilege H+V), D5(authn/authz distinct→403), D9(deny-by-default). OWASP-AUTHZ-C1~C6 |
|
||||
| [[raw/project-notes/ca-skeleton-operational-contract]] | D1(§5 domain/application Spring 모름 + §14/§25 TransactionPort 추상화 선례), D4(§10 + capabilities.yaml ArchUnit 집행 패턴), D5(§6 AUTHZ category), D8(§17·§22 sample-portfolio) |
|
||||
| [[raw/branch-notes/feature-security-operational-baseline]] | D3 입력 seam: `JwtToAuthenticatedUserConverter`가 raw role principal과 Spring `ROLE_*` authority를 분리해 제공; D5: `AUTHZ_INSUFFICIENT_PERMISSION` code + EnvelopeAccessDeniedHandler |
|
||||
| [[raw/official-docs/keycloak-identity-provider-mappers]] | D3 role 출처(IdP realm/client role → claim) — `official-vendor-doc`(단 realm-role→permission 직접 발급 아님; app-side 매핑 보강 근거) |
|
||||
| [[raw/official-docs/spring-security-authorization-architecture]] | D1 — custom `AuthorizationManager<MethodInvocation>` 가 Java-level 집행 가능(SS-AUTHZ-ARCH-C3), `@PreAuthorize` 는 Spring-managed bean coupling 요구(SS-AUTHZ-ARCH-C2) → application-core 부적합. `official-vendor-doc` |
|
||||
| [[raw/official-docs/owasp-authz-permission-model-abac-rbac]] | D2 — permission-as-string abstraction(OWASP-PM-C3) + role→permission indirect(OWASP-PM-C4) + least-privilege H+V(OWASP-PM-C5). **반례**: OWASP 는 ABAC generally prefer(OWASP-PM-C1) → trade-off 명시. `official-reference` |
|
||||
| [[raw/official-docs/aws-iam-google-iam-permission-naming-convention]] | D6 — `resource:action` colon separator 가 AWS IAM `service:Action`(IAM-NAMING-C1) 관행과 일관, Google 3-segment dotted(IAM-NAMING-C2)는 단일 서비스 과도. `official-vendor-doc` |
|
||||
| [[raw/company-tech-blogs/curity-oauth2-scope-vs-permission-naming]] | D6 — scope=entry-point vs internal permission 분리 + `resource:action` colon naming(CURITY-SCOPE-C2). `company-case-study`(AWS IAM 으로 corroborate, 단독 승격 금지) |
|
||||
| [[raw/official-docs/keycloak-authorization-services-realm-client-roles]] | D3 — KC Authorization Services(UMA) 대안은 본 branch scope 밖(KC-AUTHZ-C1/C4, `official-vendor-doc`). realm/client role 의 JWT claim 구조(KC-AUTHZ-C2)는 *engineering-blog 수준 → needs-confirmation*(1차 근거는 ca-tmpl 코드) |
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆 증거 등급: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [x] `AuthorizationPort` + `@RequiresPermission` (application-core) 정의 — 등급: `locally-verified` (2026-06-08 구현. `AuthorizationPort.requirePermission(AuthorizationPrincipal, Permission)` + `AuthorizationPrincipal`/`AuthorizationDeniedException`/`@RequiresPermission` 전부 Spring-Security-free. `Permission` record 는 `shared-contract`. `AuthorizationContractTest`/`PermissionTest` 통과)
|
||||
- [x] role→permission 해소 adapter (raw role → effective permissions) — 등급: `locally-verified` (2026-06-08 `RolePermissionRegistry`(case-insensitive, fail-closed, wildcard 미지원=§3 기본 B) + `RolePermissionProperties`(`ca-skeleton.authz.role-permissions`, key=raw role) + `AuthorizationAdapter implements AuthorizationPort`. `RolePermissionRegistryTest`/`AuthorizationAdapterTest`/`RolePermissionPropertiesTest`(binding) 통과)
|
||||
- [x] `@RequiresPermission` 미선언 mutating use case ArchUnit rule — 등급: `locally-verified` (2026-06-08 사용자 요청으로 F4/REFERENCE ONLY 위임을 해제하고 host suite(`app-bootstrap/.../CleanArchitectureTest`)에 직접 구현. **2 rule**: `mutating_use_cases_declare_required_permission`(=`@UseCaseCapability(WRITE_REPOSITORY)` 인데 `@RequiresPermission` 미선언이면 build fail — non-vacuous 검증: DeleteWorkLogUseCase 어노테이션 제거 시 정확히 이 rule 만 FAILED 확인 후 복원) + `application_and_domain_do_not_depend_on_spring_security`(D1 import 금지). producer=본 branch / host=suite 위임이 실현됨)
|
||||
- [x] AuthorizationPort 거부 → `AUTHZ_INSUFFICIENT_PERMISSION` 403 wiring — 등급: `locally-verified` (2026-06-08 `RequiresPermissionAuthorizationManager`(`AuthorizationManager<MethodInvocation>`) + `MethodSecurityConfig`(`@EnableMethodSecurity(prePostEnabled=false)` + ROLE_INFRASTRUCTURE Advisor). 거부 → `AuthorizationDecision(false)` → Spring `AccessDeniedException` → `GlobalExceptionHandler#handleForbidden` → `SecurityErrorClassifier.classifyAccessDenied` → `AUTHZ_INSUFFICIENT_PERMISSION`. `GlobalExceptionHandlerTest`/`RequiresPermissionAuthorizationManagerTest` 통과. **filter 경로(EnvelopeAccessDeniedHandler)와 method 경로(controller-advice) 가 이제 동일 classifier 사용**)
|
||||
- [x] permission naming registry + sample-portfolio authz 시연 — 등급: `locally-verified` (2026-06-08 `worklog:read/write/close` + role bundle(user={read,write}, admin={read,write,close}) `application.yml`. mutating use case 4종에 `@RequiresPermission` 부착(Create/Update/Batch=`worklog:write`, Delete=`worklog:close`=admin-tier). `WorkLogAuthorizationContractTest`(@SpringBootTest, 실제 AOP proxy 경유) 4 cases 통과: user→write 허용 / user→close 거부 / admin→close 허용 / unauth→거부)
|
||||
- [x] negative E2E (HTTP→method security→403 envelope 전 경로) — 등급: `locally-verified` (2026-06-08 사용자 요청. `WorkLogAuthorizationE2ETest`(@WebMvcTest, 실 `WorkLogController` DELETE 엔드포인트): user → 403 + envelope `error.code=AUTHZ_INSUFFICIENT_PERMISSION`(repository.deleteById 미호출 검증), admin → 204(deleteById 호출). MVC dispatch→proxied use case method-security→AccessDeniedException→GlobalExceptionHandler→envelope 전 경로 검증)
|
||||
- [x] 자동조사(D1/D2/D3/D6) Supporting Claim 연결 — 등급: `actually-implemented` (2026-06-08 `wiki-decision-researcher` 5 raw 산출 + 연결 완료)
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- **잔존 저위험 (2026-06-08, 의도적 미해소)**:
|
||||
1. **authN→authz seam 미통합 검증**: `WorkLogAuthorizationE2ETest`/`WorkLogAuthorizationContractTest` 는 둘 다 `addFilters=false`(+`SecurityAutoConfiguration` 제외)로 JWT 필터체인을 끄고 `AuthenticatedUser` 를 SecurityContext 에 직접 주입한다. 따라서 *인가 leg*(method-security→403 envelope)는 닫혔으나, `JWT → SecurityFilterChain → JwtToAuthenticatedUserConverter → AuthenticatedUser.roles → registry lookup` seam 은 authz 와 묶여 한 번에 검증되지 않음(security-baseline 단위검증에 의존). 닫으려면 full `@SpringBootTest`(mock JWT 발급 + 실 필터체인 + method security) 필요 — ~50 env 의존으로 별도 작업.
|
||||
2. **`proxy-target-class` flip 미가드**: E2E/contract test 둘 다 자기 컨텍스트에 `@EnableAspectJAutoProxy(proxyTargetClass=true)` 를 강제하므로, prod 에서 `spring.aop.proxy-target-class=false` 로 바꾸면 **테스트는 통과하면서 prod 만 깨진다**(concrete `*UseCase` 주입이 JDK proxy 로 fallback → `BeanNotOfRequiredTypeException`). 즉 테스트가 이 flip 을 잡지 못함 = 가드 없음(저위험). prod 는 Boot 기본(CGLIB)이라 현재 안전. → [[raw/errors/method-security-cglib-vs-jdk-proxy-usecase-injection-2026-06-08]]
|
||||
- security-baseline 은 2026-06-08 Phase C2 로 authN 전 영역 `locally-verified` 까지 구현됨. 본 branch 는 그 위에 *authz 결정* 만 얹으므로, `JwtToAuthenticatedUserConverter`(realm_access+resource_access → `AuthenticatedUser` raw roles + adapter `ROLE_*` authorities)·`AuthenticatedUser`·`EnvelopeAccessDeniedHandler` 가 본 branch 구현의 전제 anchor.
|
||||
- **code SSOT 위임 (coverage audit Should-fix 해소)**: `AUTHZ_INSUFFICIENT_PERMISSION`·`AUTHZ_TENANT_MISMATCH` 는 `error-codes.yaml` 에 `owner_branch = feature-security-operational-baseline` 로 등록(2026-06-08 코드 확인). 본 branch 는 code 를 *새로 만들지 않고* emission point(실제 발생원)만 추가 — code registry SSOT = [[raw/branch-notes/feature-security-operational-baseline]], emission producer = 본 branch.
|
||||
- **TODO (project note 갱신 — §25 SSOT Owner Map row 부재, coverage audit Should-fix)**: project `ca-skeleton-operational-contract` §25 SSOT Owner Map 에 신규 row 추가 필요 — `| product authorization enforcement point (PEP) | feature-authentication-authorization-contract | security-operational-baseline(ROLE_* authority consumer + AUTHZ code SSOT), architecture-enforcement-rules(rule host), sample-domain-contract-fixture(authz fixture) | AuthorizationPort + @RequiresPermission SSOT |`. §35 D/E #5 의 `(없음)` → scaffolded 로 상태 갱신도 동반. (project note 편집은 본 branch-spec 범위 밖 — 별도 작업으로 처리.)
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 대안과 함께 기록. 각 결정 근거는 위 Sources. 상세 매핑은 아래 Decision Evidence Map.
|
||||
|
||||
- 2026-06-08: **D1** enforcement layer = use-case `AuthorizationPort`(application-core), Spring `@PreAuthorize` 아님 / 이유: application·domain 이 Spring Security type 을 import 하면 project §5·§19 원칙 위반(TransactionPort 선례 §14·§25); custom `AuthorizationManager<MethodInvocation>` 가 Java-level 집행 제공(SS-AUTHZ-ARCH-C3) / 대안: Spring method security `@PreAuthorize`(SS-AUTHZ-ARCH-C2 = bean coupling → 위반), web-layer `authorizeHttpRequests` coarse 규칙 / 근거: SS-AUTHZ-ARCH-C2/C3/C4 + project-ssot + OWASP-AUTHZ-C6 / 위험: AOP proxy bypass → ArchUnit 보강.
|
||||
- 2026-06-08: **D2** authz 모델 = permission 중심 RBAC(permission=집행 단위, role=permission 묶음) / 이유: 도메인이 role 추가해도 enforcement 코드 불변 + least-privilege(H+V, OWASP-PM-C5) + action→permission string abstraction(OWASP-PM-C3) / 대안: role-only RBAC, ABAC(**OWASP-PM-C1 = ABAC generally prefer** — 정적 permission 규모 YAGNI 로 trade-off, AuthorizationPort interface 가 ABAC migration path 보장) / 근거: OWASP-PM-C3/C4/C5.
|
||||
- 2026-06-08: **D3** AuthorizationPort 는 현재 principal 의 **prefix 없는 raw role**을 role→permission registry key로 사용한다. Spring `ROLE_*` authority는 adapter가 파생하는 표현이며 registry 입력이 아니다. 매핑 source = app-side static config 기본, IdP 가 permission claim 직접 발급 시 그것 우선 / 대안: IdP-authoritative only(Keycloak Authorization Services/UMA — KC-AUTHZ-C1/C4, 본 branch scope 밖), JWT scope claim only / 근거: security-baseline `JwtToAuthenticatedUserConverter` + KC-AUTHZ-C2/C3(needs-confirmation).
|
||||
- 2026-06-08: **D4** mutating/sensitive use case 는 `@RequiresPermission` 선언 의무, ArchUnit 으로 미선언 차단(repository-access `@UseCaseCapability` 패턴 mirror) / 대안: compile-time annotation processor, runtime AOP(capabilities.yaml 정책상 forbidden) / 근거: project §10 + capabilities.yaml(enforcement=archunit, runtime AOP forbidden). **rule host = architecture-enforcement-rules suite(REFERENCE ONLY)**.
|
||||
- 2026-06-08: **D5** AuthorizationPort 거부 → `AUTHZ_INSUFFICIENT_PERMISSION`(AUTHZ 403). code SSOT = security-baseline(registry owner) / 본 branch 는 emission point producer. IDOR-sensitive 도메인은 403→404 masking 확장점(OWASP-AUTHZ-C7) / 근거: OWASP-AUTHZ-C3 + security-baseline matrix.
|
||||
- 2026-06-08: **D6** permission naming = `resource:action` lowercase colon-delimited (예: `worklog:close`) / 대안: `service.resource.verb`(Google IAM), `service:Action`(AWS IAM), OAuth2 scope / 근거: 자동조사(진행 중) + OWASP-AUTHZ-C4. exact delimiter 는 근거 미확정 시 `UNSUPPORTED_IMPL_DECISION`.
|
||||
- 2026-06-08: **D8** sample-portfolio 가 authz 시연 fixture(worklog:read/write/close, ROLE_USER/ROLE_ADMIN). sample model owner = sample-fixture branch(본 branch 는 authz 부착 producer) / 근거: project §17·§22.
|
||||
- 2026-06-08: **D9** 3-tier: public(permitAll) ⊂ authenticated ⊂ authorized(permission). tier1-2 = security-baseline(deny-by-default), tier3 = 본 branch(authenticated≠authorized) / 근거: OWASP-AUTHZ-C1/C2 + security-baseline D5/D6.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> `Decision ID` 는 본 note 안에서 안정 유지. `Supporting Claims` 는 `raw/<category>/<slug>.md#C1` 형식.
|
||||
> `선택 조건`(R2): 이 조건일 때 이 결정, 다른 조건이면 어떤 대안.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | enforcement = use-case `AuthorizationPort`(application-core), Spring method security 아님 | 기본 = application port. **application/domain 이 Spring Security type import 하면 안 됨**(project 원칙) → port. coarse endpoint gating 만 필요하면 web-layer `authorizeHttpRequests`(security-baseline). 표준 단순성이 원칙보다 우선이면 `@PreAuthorize` | `raw/official-docs/spring-security-authorization-architecture.md#SS-AUTHZ-ARCH-C3`(custom `AuthorizationManager<MethodInvocation>` = Java-level 집행), `#SS-AUTHZ-ARCH-C2`(`@PreAuthorize` = Spring bean coupling → 부적합), `#SS-AUTHZ-ARCH-C4`(rule 위치 trade-off), `raw/official-docs/security-authorization-cheatsheet-owasp.md#OWASP-AUTHZ-C6`, `#OWASP-AUTHZ-C1`, `raw/project-notes/ca-skeleton-operational-contract.md`(§5 layer 격리 + §14/§25 TransactionPort 선례) | `official-vendor-doc + project-ssot + official-reference` | **AOP proxy bypass**(self-invocation / non-Spring-bean 호출)에 취약(SS-AUTHZ-ARCH-C2) → ArchUnit 이 미보호 진입점 정적 차단 필요; `@PreAuthorize` 대비 boilerplate ↑ |
|
||||
| D2 | authz 모델 = permission 중심 RBAC (permission=집행 단위, role=묶음) | 기본 = permission-centric RBAC. owner/relationship 기반(예: `worklog.owner==principal`) 필요 도메인 → AuthorizationPort 구현체가 ABAC predicate 추가(거부 아님; interface 가 migration path 보장). role-only 는 도메인 role 추가 시 enforcement 수정 → 기각 | `raw/official-docs/owasp-authz-permission-model-abac-rbac.md#OWASP-PM-C3`(action→permission string abstraction), `#OWASP-PM-C4`(role=permission bundle indirect), `#OWASP-PM-C5`(least-privilege H+V), `raw/official-docs/security-authorization-cheatsheet-owasp.md#OWASP-AUTHZ-C4` | `official-reference` (OWASP) | **OWASP 는 ABAC/ReBAC generally prefer(OWASP-PM-C1)** — permission-centric RBAC 는 정적 permission+소수 role 규모에서 YAGNI 근거의 단순성 trade-off; dynamic attribute(시간/지리/owner) 요구 시 ABAC 전환 |
|
||||
| D3 | AuthorizationPort 가 prefix 없는 raw role → role→permission registry 확장 → 요구 permission 포함 판정. `ROLE_*` authority는 Spring adapter의 파생 표현 | 기본 = app-side static config(IdP coupling 최소). IdP(Keycloak)가 permission claim 직접 발급하면 IdP-authoritative 우선. JWT scope claim 만으로 부족하면 registry 확장 | [[raw/branch-notes/feature-security-operational-baseline]] — principal mapping seam을 consume; `raw/official-docs/keycloak-authorization-services-realm-client-roles.md#KC-AUTHZ-C2`(realm/client role JWT claim 구조 — *engineering-blog 수준*), `#KC-AUTHZ-C3`, `raw/official-docs/keycloak-identity-provider-mappers.md#KC-IDP-MAPPER-C2` | `cross-branch (security-baseline JwtToAuthenticatedUserConverter code = locally-verified, 1차 근거) + engineering-blog (KC-AUTHZ-C2, needs-confirmation)` | role→permission config drift; IdP 권한 변경 시 app config 동기화. **KC-AUTHZ-C2 는 engineering 수준 → 메커니즘 1차 근거는 ca-tmpl 코드(realm_access+resource_access 파싱 locally-verified)이고 KC-AUTHZ-C2 는 보조; official Keycloak doc 재확인 needs-confirmation** |
|
||||
| D4 | mutating/sensitive use case `@RequiresPermission` 선언 의무, ArchUnit 차단 | 집행: 기본 = ArchUnit(capabilities.yaml 정책 상속), compile-time processor = alt, **runtime AOP = forbidden**(capabilities.yaml 명시). **적용 범위(depth audit #3 해소)**: skeleton 1차 = **mutating-only**(=`@UseCaseCapability(WRITE_REPOSITORY)` 보유 use case). authenticated read 결과 필터링이 필요해지면 read 강제로 확장(sample-portfolio 구현 후 결정); public read 는 항상 제외 | `raw/project-notes/ca-skeleton-operational-contract.md`(§10 capability 선언 + capabilities.yaml `enforcement: archunit` / `runtime AOP forbidden`), §25 F1(ArchUnit suite SSOT=architecture-enforcement), `raw/official-docs/security-authorization-cheatsheet-owasp.md#OWASP-AUTHZ-C4`(least-privilege — read 무조건 강제는 과대) | `project-ssot + official-reference` | rule host = `feature-architecture-enforcement-rules`(REFERENCE ONLY); read 강제 확장 시점은 sample 구현 후 |
|
||||
| D5 | 거부 → `AUTHZ_INSUFFICIENT_PERMISSION` 403. code SSOT=security-baseline, 본 branch=emission point | N/A (code 매핑 고정). 단 IDOR-sensitive 도메인은 403→404 masking 확장점 | `raw/official-docs/security-authorization-cheatsheet-owasp.md#OWASP-AUTHZ-C3`, `#OWASP-AUTHZ-C7`(IDOR), `raw/branch-notes/feature-security-operational-baseline.md`(AuthN/AuthZ matrix `AUTHZ_INSUFFICIENT_PERMISSION` 403 행 + `EnvelopeAccessDeniedHandler`) | `official-reference + cross-branch` | §25 SSOT Owner Map 에 "authorization enforcement point" row 추가 필요(producer/consumer 명시) |
|
||||
| D6 | permission naming = `resource:action`(lowercase, colon) | 기본 = `resource:action`(2-segment, 단일 서비스). multi-service gateway 수준 permission 필요 시 `service:resource:action` 으로 확장. Google `service.resource.verb`(dotted)는 Java package 혼동 + service prefix 중복으로 기각 | `raw/official-docs/aws-iam-google-iam-permission-naming-convention.md#IAM-NAMING-C1`(AWS `service:Action` colon), `#IAM-NAMING-C2`(Google dotted 3-segment 대안), `raw/company-tech-blogs/curity-oauth2-scope-vs-permission-naming.md#CURITY-SCOPE-C2`(`resource:action` industry practice), `raw/official-docs/security-authorization-cheatsheet-owasp.md#OWASP-AUTHZ-C4`(granularity) | `official-vendor-doc`(AWS IAM) + `company-case-study`(Curity corroborate) | RFC 강제 표준 없음(convention) — team 문서화로 유지; wildcard(`worklog:*`) 전개 규칙 + permission explosion vs coarse 미정 |
|
||||
| D8 | sample-portfolio authz 시연(worklog:read/write/close, ROLE_USER/ADMIN) | N/A (fixture). sample model 변경은 sample-fixture branch | `raw/project-notes/ca-skeleton-operational-contract.md`(§17 sample-portfolio + §22 "unauthorized worklog update | auth/authz separation") | `project-ssot` | sample role/permission 이 도메인 role 로 오인 방지 — sample package 격리 |
|
||||
| D9 | 3-tier: public ⊂ authenticated ⊂ authorized. tier3(authenticated≠authorized) 추가 | N/A (계층 고정) | `raw/official-docs/security-authorization-cheatsheet-owasp.md#OWASP-AUTHZ-C1`, `#OWASP-AUTHZ-C2`, `#OWASP-AUTHZ-C5`, `raw/branch-notes/feature-security-operational-baseline.md`(D5 deny-by-default / D6 every-request) | `official-reference + cross-branch` | every-request 권한 검증(C5) 비용 — role→permission 해소 caching(stateless 유지 vs staleness) |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> *결정* 이 "*무엇*" 이라면 본 §는 "*어디에 어떻게*". **2026-06-08 구현 완료** — 아래 `planned` 다수가 실제 코드로 실현됨(as-built 등급·파일 anchor 는 §Audit & Findings 의 구현 인벤토리 참조; 본 § 표의 `planned` 는 *설계 시점* 표기로 보존). 설계 시점 "ca-tmpl 0건" 기술은 구현 전 상태.
|
||||
>
|
||||
> **3-rule meta principle**(CLAUDE.md §15.5): R1 모든 cell = Decision ID + Supporting Claim / R2 근거 없는 detail = `UNSUPPORTED_IMPL_DECISION` + trade-off / R3 본 branch 범위 밖 = 별도 §/sibling 이관.
|
||||
|
||||
### 1. AuthorizationPort 계약 (application-core)
|
||||
|
||||
> **Trace**: D1(use-case port, `OWASP-AUTHZ-C6` + project §5/§14/§25) · D3(role→permission 해소) · D9(tier3).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: port API 모양(`requirePermission(Permission)` throw vs `check(...)→boolean`). trade-off: throw 방식 = 호출부 단순 + fail-closed 자연스러움 vs boolean = 분기 유연. 기본 throw(fail-closed).
|
||||
|
||||
| 항목 | 구현 anchor | 등급 |
|
||||
|---|---|---|
|
||||
| port 인터페이스 | `dev.caskeleton.application.<core>.security.AuthorizationPort` — `void requirePermission(Permission required)` (application-core, **Spring Security import 금지**) | `planned` |
|
||||
| Permission 표현 | `domain-core` 또는 `shared-contract` 의 `record Permission(String resource, String action)` (`resource:action`, D6) | `planned` |
|
||||
| 현재 principal 접근 | adapter 가 `SecurityContext`→`AuthenticatedUser`(security-baseline `dev.caskeleton.adapter.web.auth.AuthenticatedUser`) 에서 authorities 추출 → port 입력. application 은 principal 을 *주입* 받음(Spring 비의존) | `planned` (security-baseline `AuthenticatedUser` = `actually-implemented`) |
|
||||
| 거부 신호 | `AuthorizationDeniedException`(application/domain-neutral) throw → adapter-web 이 403 매핑(§4) | `planned` |
|
||||
|
||||
### 2. `@RequiresPermission` 선언 + ArchUnit 집행 (REFERENCE ONLY — host=architecture-enforcement-rules)
|
||||
|
||||
> **Trace**: D4(`@UseCaseCapability` 패턴 mirror, capabilities.yaml `enforcement: archunit` / `runtime AOP forbidden` + project §25 F1).
|
||||
>
|
||||
> - **OUT_OF_BRANCH_SCOPE(F4)**: ArchUnit rule 의 *실제 코드 위치* = `feature-architecture-enforcement-rules` suite. 본 branch 는 rule *producer*(어떤 규칙이 필요한지 정의), host 아님. 아래 코드 skeleton = REFERENCE ONLY.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 적용 범위 = mutating/sensitive use case (read-only query 강제 여부 미정 — §Open Risk D4). trade-off: all-use-case 강제 = 누락 0 vs read 마다 permission 선언 boilerplate.
|
||||
|
||||
| 항목 | 구현 anchor | 등급 |
|
||||
|---|---|---|
|
||||
| annotation | `@RequiresPermission(String value)` (`value="worklog:close"`, TYPE 또는 METHOD target — `@UseCaseCapability` 와 동일 위치 convention). **retention=RUNTIME** (adapter 의 `AuthorizationManager` 가 reflect) | `planned` |
|
||||
| 집행 메커니즘 (adapter, SS-AUTHZ-ARCH-C3) | adapter-web 의 `RequiresPermissionAuthorizationManager implements AuthorizationManager<MethodInvocation>` 가 `MethodInvocation` 에서 `@RequiresPermission` 읽어 `AuthorizationPort.requirePermission(...)` 위임. `@EnableMethodSecurity(prePostEnabled=false)` + custom `Advisor` 로 wiring — application-core 는 여전히 Spring-free(annotation 만 보유, 집행은 adapter) | `planned` |
|
||||
| ArchUnit rule (구현됨 — host=suite) | `CleanArchitectureTest.declareRequiredPermissionWhenMutating()`(app-bootstrap, L335-358) — mutating use case(`@UseCaseCapability(repositoryAccess=WRITE_REPOSITORY)`)인데 `@RequiresPermission` 미선언 → build fail. D4 tag. **위임 설계대로 producer=본 branch / host=architecture-enforcement suite** | `actually-implemented` |
|
||||
| no-Spring-Security-in-application | `CleanArchitectureTest.application_and_domain_do_not_depend_on_spring_security`(L291, "D1") — application/domain 의 `org.springframework.security..` import → build fail | `actually-implemented` |
|
||||
| AOP proxy bypass (SS-AUTHZ-ARCH-C2 위험, **잔존**) | 위 D4 rule 은 annotation *존재* 만 보장; self-invocation / non-Spring-bean 호출의 *invocation-path* 우회는 정적으로 미검출. controller→usecase 는 proxy 경유라 현재 안전하나 구조적 잔존 위험 → §Claims To Verify | `documented-only` (gap) |
|
||||
|
||||
### 3. role→permission 해소 adapter (D3, D2)
|
||||
|
||||
> **Trace**: D3(role→effective permissions) · D2(permission-centric). 입력 = security-baseline `AuthenticatedUser.roles`.
|
||||
>
|
||||
> - **registry key 형식 결정 (depth audit #2 해소)**: ca-tmpl `AuthenticatedUser.roles`(`adapter-web`, `Set<String>`)는 **raw role 문자열**(`admin`/`user` — Keycloak 원본, prefix 없음)을 담는다. Spring `GrantedAuthority` 만 `"ROLE_"+toUpperCase()` prefix 를 받는다(`JwtToAuthenticatedUserConverter.java` L33-34). 따라서 registry key = **raw role 명(prefix 없음)** — `ROLE_ADMIN` 아님. application-core 가 Spring-free 이므로 port 는 `GrantedAuthority` 가 아니라 *raw role set* 을 consume.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (1) 매핑 저장소 = app-side `@ConfigurationProperties` static map(`ca-skeleton.authz.role-permissions`) 기본 — IdP coupling 최소, env-driven(§9). (2) **role 명 case 정규화** — Keycloak raw role 의 대소문자 보장 없음 → registry lookup 을 case-insensitive(lowercase 정규화) 로. trade-off: 정규화(Keycloak 설정 무관 안정) vs exact-match(설정 강제).
|
||||
> - **principal 추상화 (Spring-free)**: `AuthenticatedUser` 는 `adapter-web` 타입 → application-core 가 import 불가. port 는 application-core/shared-contract 의 principal 추상(`Set<String> roles` + subject)을 받고, adapter 가 `AuthenticatedUser`→그 추상으로 매핑.
|
||||
|
||||
| 항목 | 구현 anchor | 등급 |
|
||||
|---|---|---|
|
||||
| role→permission registry | `RolePermissionRegistry`(adapter 또는 shared-contract) ← `ca-skeleton.authz.role-permissions`. **key = raw role 명(lowercase)**: `admin: [worklog:read, worklog:write, worklog:close]`, `user: [worklog:read, worklog:write]` (← `AuthenticatedUser.roles`, `ROLE_` prefix 없음). 정적 `@ConfigurationProperties` map = **startup-bound → staleness 없음**(IdP claim 직접 발급 채택 시에만 별도 TTL 필요) | `planned` |
|
||||
| effective permission 확장 | `AuthenticatedUser.roles`(raw) → registry lookup → permission set union. **wildcard 전개(`worklog:*`)**: `UNSUPPORTED_IMPL_DECISION` — (A) 정적 prefix-union(registry 등록 `worklog:` 전체 union, 미래 permission 자동 포함) vs (B) 명시 열거만(wildcard 미지원, admin = 명시 목록). 기본 = (B) 명시 열거(least-privilege OWASP-AUTHZ-C4 우선, `worklog:delete` 자동 포함 차단) | `planned` |
|
||||
| AuthorizationPort 구현체 | `AuthorizationAdapter implements AuthorizationPort`(adapter-web) — effective permissions 에 required 포함 여부, fail-closed(미발견 role → 권한 0) | `planned` |
|
||||
|
||||
### 4. 거부 → `AUTHZ_INSUFFICIENT_PERMISSION` 403 (emission point; code SSOT=security-baseline)
|
||||
|
||||
> **Trace**: D5(`OWASP-AUTHZ-C3` distinct→403 + security-baseline matrix row). code 자체는 security-baseline `error-codes.yaml` owner.
|
||||
>
|
||||
> - **OUT_OF_BRANCH_SCOPE**: error code *정의/registry* = security-baseline. 본 branch = 거부 → 403 envelope wiring.
|
||||
> - **예외 경로 결정 (depth audit #1 해소)**: application-core 는 Spring-free 이므로 `AuthorizationPort` 는 Spring `AccessDeniedException` 을 throw할 수 *없다*. 따라서 **2-hop 경로**를 명시: (1) application-core port 가 domain-neutral `AuthorizationDeniedException`(자체 타입) throw → (2) adapter-web `RequiresPermissionAuthorizationManager`(Spring-aware)가 이를 Spring `org.springframework.security.access.AccessDeniedException` 으로 변환(또는 Spring 6.x `AuthorizationDeniedException extends AccessDeniedException` 사용). method-invocation 시점 throw 이므로 **filter-layer `EnvelopeAccessDeniedHandler` 가 아니라 controller-advice `GlobalExceptionHandler#handleForbidden(AccessDeniedException)`(L91, `actually-implemented`)** 에 도달.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `handleForbidden` 현재는 coarse `FORBIDDEN` 매핑. fine-grained `AUTHZ_INSUFFICIENT_PERMISSION` 을 emit 하려면 `handleForbidden` 이 `SecurityErrorClassifier.classifyAccessDenied(ex)`(L60, AccessDeniedException→`AUTHZ_INSUFFICIENT_PERMISSION`) 에 위임하도록 변경 필요. trade-off: handler 위임 변경(filter/method 양 경로 code 일치) vs coarse FORBIDDEN 수용(변경 0, 분류 손실). 기본 = 위임 변경(분류 일관).
|
||||
|
||||
| 항목 | 구현 anchor | 등급 |
|
||||
|---|---|---|
|
||||
| port 거부 신호 (application-core) | `AuthorizationDeniedException`(application/domain-neutral 자체 타입, Spring 비의존) | `planned` |
|
||||
| adapter 변환 (adapter-web) | `RequiresPermissionAuthorizationManager` 가 거부 → Spring `AccessDeniedException`(or `AuthorizationDeniedException extends AccessDeniedException`) | `planned` |
|
||||
| 403 envelope emit | `GlobalExceptionHandler#handleForbidden(AccessDeniedException)`(L91, `actually-implemented`) → 위임 변경 방법: handler 내 `OperationalError.FORBIDDEN` 라인을 `SecurityErrorClassifier.classifyAccessDenied(ex)`(L60) 반환값으로 교체(코드 1줄) → `AUTHZ_INSUFFICIENT_PERMISSION`. 미교체 시 coarse `FORBIDDEN` | `planned` (handler 존재, classifier 위임 신규) |
|
||||
| IDOR masking 확장점 | 403↔404 선택은 도메인 결정(OWASP-AUTHZ-C7). skeleton 기본 = 403(정직), masking 은 확장점만 | `documented-only` |
|
||||
|
||||
### 5. permission naming + sample-portfolio authz 시연 (D6, D8)
|
||||
|
||||
> **Trace**: D6(naming `resource:action`) · D8(sample fixture, project §17/§22). sample model owner = sample-fixture branch(부착만).
|
||||
>
|
||||
> - **naming 확정 (D6)**: `resource:action`(2-segment colon) — AWS IAM(`IAM-NAMING-C1`)+Curity(`CURITY-SCOPE-C2`) 정합. wildcard 미지원(§3 기본 B).
|
||||
|
||||
| 항목 | 구현 anchor | 등급 |
|
||||
|---|---|---|
|
||||
| permission 값 | `worklog:read` · `worklog:write` · `worklog:close` (sample-portfolio) | `planned` |
|
||||
| role 묶음 (key=raw role, §3) | `user → {worklog:read, worklog:write}`, `admin → {worklog:read, worklog:write, worklog:close}` (명시 열거 — wildcard 미사용, §3 기본 B) | `planned` |
|
||||
| use case 부착 | sample-portfolio `CloseWorkLogUseCase` 등에 `@RequiresPermission("worklog:close")` (sample package 격리 — 도메인 role 오인 방지) | `planned` |
|
||||
| contract test | authenticated+permission 없음 → 403 / 있음 → 200, sample 시연 | `planned` |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4 캡처. 정상 경로 외 *구현 중 부딪힐* 실패/엣지/다른 계약 의존.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **authenticated 인데 permission 없음**: 401 아님 → `AUTHZ_INSUFFICIENT_PERMISSION` 403(D5/D9). 인증은 됐으나 인가 실패의 핵심 경로.
|
||||
- **role→permission config 누락/오타**: 미발견 role → fail-closed(권한 0, 403). config drift 시 정당 사용자도 거부 → startup 검증(알려진 role 집합 대조) 권고.
|
||||
- **`@RequiresPermission` 미선언 mutating use case**: ArchUnit build fail(D4). 누락 = silent 무인가 통과 방지.
|
||||
- **wildcard 전개**(`worklog:*`): 기본 = 미지원(§3 B 명시 열거) — admin 도 명시 permission 목록. 만약 (A) 정적 prefix-union 채택 시 `worklog:*` 가 미래 `worklog:delete` 자동 포함 → least-privilege(OWASP-AUTHZ-C4) 위반 위험. 기본값이 least-privilege 보존.
|
||||
- **IDOR/BOLA**(OWASP-AUTHZ-C7): resource 존재를 403 으로 노출 vs 404 masking. skeleton 기본 403, 도메인 확장점.
|
||||
- **every-request 해소 비용**(OWASP-AUTHZ-C5): role→permission 해소를 매 요청 수행 vs principal 단위 cache — stateless 유지(security-baseline) 와 cache staleness trade-off.
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-security-operational-baseline]] — raw role principal을 입력으로 제공하고 Spring `ROLE_*` authority는 adapter에서 파생하며, `AUTHZ_INSUFFICIENT_PERMISSION` code/emission 경로를 소유한다. 이 seam이 바뀌면 본 branch의 registry 입력 형식이 영향받는다.
|
||||
- [[raw/branch-notes/feature-repository-access-permission-contract]] `@UseCaseCapability`(infra-capability) — **직교 축**(사용자 권한 아님). `@RequiresPermission` 와 *동시* 선언되며 ArchUnit 패턴 공유(mirror). 혼동 시 user-authz 를 capability 로 착각.
|
||||
- [[raw/branch-notes/feature-architecture-enforcement-rules]] ArchUnit suite host — D4 rule 의 실제 코드 위치(REFERENCE ONLY).
|
||||
- [[raw/branch-notes/feature-sample-domain-contract-fixture]] sample-portfolio model owner — D8 authz 시연 부착 대상.
|
||||
- [[raw/branch-notes/feature-tenant-context-policy]] `AUTHZ_TENANT_MISMATCH`(cross-tenant authz) — ABAC tenant 축은 그 branch. 본 branch 는 확장점만.
|
||||
- [[raw/branch-notes/feature-operational-error-observability-foundation]] `Category` enum(`AUTHZ`) SSOT — D5 category consume.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서·사례는 근거지만 내 프로젝트 동작을 자동 보장하지 않는다.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| ~~application-core Spring-free authz~~ | — | **RESOLVED**: `AuthorizationPort`/`AuthorizationPrincipal`/`@RequiresPermission` 가 application-core 에 Spring-free, `CleanArchitectureTest` D1 rule(L291)이 import 차단 | `actually-implemented` |
|
||||
| ~~registry key = raw role(prefix 없음)~~ | — | **RESOLVED**: `AuthorizationPrincipal`(raw roles, "never ROLE_*"), `RolePermissionRegistry`(lowercase normalize), `AuthorizationContractTest` | `actually-implemented` |
|
||||
| ~~mutating use case `@RequiresPermission` 강제~~ | — | **RESOLVED**: `CleanArchitectureTest.declareRequiredPermissionWhenMutating()`(L335-358, D4) — 미선언 WRITE_REPOSITORY use case build fail | `actually-implemented` |
|
||||
| ~~거부 → `AUTHZ_INSUFFICIENT_PERMISSION` 403 emit~~ | — | **RESOLVED(method-security 경로)**: `GlobalExceptionHandler#handleForbidden` 가 `SecurityErrorClassifier.classifyAccessDenied` 위임 → fine-grained code. `GlobalExceptionHandlerTest` | `actually-implemented` |
|
||||
| **(잔존 #4, narrowed) authN→authz seam(JWT 필터체인) 미통합 검증** | **HTTP→method-security→403 envelope leg 는 `WorkLogAuthorizationE2ETest`(@WebMvcTest, 실 controller, positive+negative)로 RESOLVED.** 잔존은 그 *앞단* seam 뿐: `WorkLogAuthorizationE2ETest`/`WorkLogAuthorizationContractTest` 둘 다 `addFilters=false`(+`SecurityAutoConfiguration` 제외)로 JWT 필터를 끄고 `AuthenticatedUser` 직접 주입 → `JWT 디코딩→SecurityFilterChain→JwtToAuthenticatedUserConverter→AuthenticatedUser.roles→registry` seam 은 security-baseline 단위검증에 의존 | full `@SpringBootTest`(mock JWT 발급 + 실 필터체인 + method security)로 JWT 인증된 요청이 권한 없으면 403 envelope, 있으면 200 — authN→authz 통합 1회 | `needs-confirmation` (저위험; 머지 전 권고) |
|
||||
| **(잔존 #2) AOP self-invocation/non-bean 우회** | D4 rule 은 annotation *존재* 만 보장, invocation-path 미검출(SS-AUTHZ-ARCH-C2). controller→usecase 는 proxy 경유라 현재 안전 | 모든 mutating use case 진입점이 Spring-managed bean 경유인지 정적/통합 검증 추가 | `planned` |
|
||||
| **(잔존 #8) config drift → 정당 사용자 fail-closed(가용성)** | `RolePermissionProperties` startup-bound static. role 명 오타/IdP role 변경 시 정당 사용자도 deny(보안 아닌 가용성). startup 검증(알려진 role 집합 대조) 미구현 | startup 시 registry role 집합과 기대 role 대조 검증 추가 | `planned` |
|
||||
| permission-centric RBAC 채택이 OWASP "prefer ABAC" 권고(OWASP-PM-C1)에 대한 정당한 trade-off | ~~자동조사 필요~~ → **근거 확보**: OWASP-PM-C3/C4/C5(permission abstraction + least-privilege). ABAC 는 정적 permission 규모에 YAGNI. 단 owner/relationship 기반 도메인 요구 시 재평가 | dynamic attribute(시간/owner) 실요구 등장 시 AuthorizationPort 구현체를 ABAC 로 교체(interface 불변) — migration 통합 테스트 | `needs-confirmation` |
|
||||
| role→permission 매핑 source(app-config vs IdP claim) 기본값 적정 | Keycloak realm/client role 의 JWT claim 위치(KC-AUTHZ-C2)가 *engineering 수준* — official 재확인 필요. permission claim 직접 발급(UMA)은 scope 밖 | Keycloak official doc 으로 realm_access/resource_access claim 구조 재확인 + IdP realm 설정 확인 | `needs-confirmation` (KC-AUTHZ-C2) |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
> `/coverage` 가 재생성하는 초안 — 손유지 금지. 기준: `rules/coverage-gate.md`. governing = security 클러스터 doc(인접) + project §35 D/E #5 가 열거하는 product-authz 관심사. **전용 canonical 은 미존재** — 향후 `/ingest` 시 `wiki/projects/ca-tmpl/authorization-rbac-method-security.md` 가 추출 대상(현재 governing_docs 는 nearest security doc → coverage-auditor 가 MIS-SCOPED 가능성 Advisory 로 평가).
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|--------|------|-------|--------|------|
|
||||
| product authorization enforcement point(PEP) | covered-here | — | — | D1, D3 / §구현가이드 1 |
|
||||
| permission/role 모델(permission-centric RBAC) | covered-here | — | — | D2, D6 / §구현가이드 3·5 |
|
||||
| use-case 권한 선언 강제(`@RequiresPermission`) | covered-here | — | — | D4 / §구현가이드 2 |
|
||||
| `AUTHZ_INSUFFICIENT_PERMISSION` 403 emission | covered-here | — | — | D5 / §구현가이드 4 |
|
||||
| 3-tier access(authenticated≠authorized) | covered-here | — | — | D9 |
|
||||
| sample authz 시연 | covered-here | — | — | D8 / §구현가이드 5 |
|
||||
| JWT authN / `ROLE_*` 매핑 / 401·403 matrix / CORS | delegated | [[raw/branch-notes/feature-security-operational-baseline]] | OK | [[raw/branch-notes/feature-security-operational-baseline]] |
|
||||
| repository infra-capability(`@UseCaseCapability`) | delegated | [[raw/branch-notes/feature-repository-access-permission-contract]] | OK | [[raw/branch-notes/feature-repository-access-permission-contract]] (out-of-scope: "runtime authorization 혼동") |
|
||||
| cross-tenant authz(`AUTHZ_TENANT_MISMATCH`) | delegated | [[raw/branch-notes/feature-tenant-context-policy]] | OK | [[raw/branch-notes/feature-tenant-context-policy]] |
|
||||
| ArchUnit rule suite host | covered-here(in host suite) | [[raw/branch-notes/feature-architecture-enforcement-rules]] | OK | D4(`mutating_use_cases_declare_required_permission`)+D1(`application_and_domain_do_not_depend_on_spring_security`) 가 host suite `CleanArchitectureTest` 에 실제 구현됨(REFERENCE ONLY 위임 해제). producer=본 branch / host=suite |
|
||||
| `AUTHZ_INSUFFICIENT_PERMISSION` code 정의/registry | delegated | [[raw/branch-notes/feature-security-operational-baseline]] | OK | code SSOT=security-baseline(error-codes.yaml owner_branch 확인); 본 branch=emission producer. 명시 위임 = §진행 중 메모 "code SSOT 위임" |
|
||||
| §25 SSOT Owner Map — product authz PEP row 등록 | delegated | (project note 갱신 작업) | 🟡 Should-fix | §25 에 본 branch row 부재 → §진행 중 메모 TODO 로 등록(project note 편집은 별도 작업) |
|
||||
| `Category` enum(`AUTHZ`) SSOT | delegated | [[raw/branch-notes/feature-operational-error-observability-foundation]] | OK | [[raw/branch-notes/feature-operational-error-observability-foundation]] |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- **2026-06-08 (resolved): method security 가 use case bean 을 JDK dynamic proxy 로 감싸 concrete-type 주입 실패.** `@EnableMethodSecurity` + custom Advisor 가 `@RequiresPermission` use case 를 proxy 할 때, isolated test context(@SpringBootTest classes=…, auto-config 없음)에서는 JDK interface proxy 가 생성돼 `WorkLogController`/test 가 주입하는 concrete `*UseCase` 타입에 assign 불가 → `BeanNotOfRequiredTypeException`. **원인**: Spring Boot 의 `AopAutoConfiguration` 이 prod 에서 `spring.aop.proxy-target-class=true`(CGLIB) 를 기본 설정하지만, auto-config 없는 슬라이스엔 그 기본이 안 적용됨. **해소**: contract test 의 nested config 에 `@EnableAspectJAutoProxy(proxyTargetClass = true)` 추가(prod 동작 mirror). prod 는 CaSkeletonApplication 의 `@SpringBootApplication` 이 CGLIB 보장하므로 영향 없음. → 자세히 [[raw/errors/method-security-cglib-vs-jdk-proxy-usecase-injection-2026-06-08]]
|
||||
- **2026-06-08 (clarified): unauthenticated 호출은 method-security 단에서 `AccessDeniedException` 이 아니라 `AuthenticationException`(`AuthenticationCredentialsNotFoundException`).** method-security 의 deferred `Supplier<Authentication>.get()` 이 null authentication 을 만나면 401-family 예외를 던진다(403 아님). prod 에서는 filter chain(`.anyRequest().authenticated()`)이 그 전에 401 로 차단하므로 method-security 의 unauth 경로는 defense-in-depth backstop. contract test 는 이를 `isInstanceOf(AuthenticationException.class)` 로 단언(처음엔 AccessDeniedException 기대해 실패 → 정정). → [[raw/errors/method-security-cglib-vs-jdk-proxy-usecase-injection-2026-06-08]] 동일 노트에 기록
|
||||
|
||||
## Audit & Findings (2026-06-08 — 구현 대조 + findings 검증)
|
||||
|
||||
> `src/` 코드와 노트 self-report 를 대조한 결과. 사용자 작성 결정 영역은 자동 rewrite 하지 않고 정합/등급만 갱신.
|
||||
|
||||
### 구현 인벤토리 (as-built, `actually-implemented`)
|
||||
|
||||
| 구현 항목 | 파일 | Trace |
|
||||
|---|---|---|
|
||||
| `AuthorizationPort`(PEP) + `AuthorizationPrincipal`(raw roles) + `AuthorizationDeniedException` + `@RequiresPermission`(RUNTIME, Spring-free) | `application-core/.../security/` | D1, D4, §1·§3 |
|
||||
| `Permission`(`resource:action` VO, 2-segment, 3-segment 거부) | `shared-contract/.../security/Permission.java` | D6 |
|
||||
| `RequiresPermissionAuthorizationManager`(custom `AuthorizationManager<MethodInvocation>`, fail-closed) + `MethodSecurityConfig`(`@EnableMethodSecurity(prePostEnabled=false)` + `AuthorizationManagerBeforeMethodInterceptor` advisor) | `adapter-web/.../authz/` | D1, §2 |
|
||||
| `RolePermissionRegistry`(lowercase normalize, 명시 열거/wildcard 없음) + `RolePermissionProperties`(`ca-skeleton.authz.role-permissions`, raw role key) + `AuthorizationAdapter`(fail-closed) | `adapter-web/.../authz/` | D2, D3, §3 |
|
||||
| `handleForbidden` → `SecurityErrorClassifier.classifyAccessDenied` 위임 → `AUTHZ_INSUFFICIENT_PERMISSION` | `adapter-web/.../error/GlobalExceptionHandler.java` | D5, §4 |
|
||||
| sample-portfolio `@RequiresPermission`: create/update/batch=`worklog:write`, delete=`worklog:close` (read 면제) + role bundle `user:{read,write}` / `admin:{read,write,close}`(application.yml) | `sample-portfolio/.../worklog/` + `app-bootstrap/application.yml` L180-182 | D8, §5 |
|
||||
| **D4 ArchUnit**: `declareRequiredPermissionWhenMutating()`(미선언 WRITE use case build fail) + **D1 ArchUnit**: `application_and_domain_do_not_depend_on_spring_security` | `app-bootstrap/.../architecture/CleanArchitectureTest.java` L291·L335-358 | D4, D1 |
|
||||
| 테스트: `PermissionTest` · `AuthorizationContractTest`(application-core) · `RolePermissionRegistryTest` · `AuthorizationAdapterTest` · `RolePermissionPropertiesTest`(binding) · `RequiresPermissionAuthorizationManagerTest` · `GlobalExceptionHandlerTest` · `WorkLogAuthorizationContractTest`(method-security 3-tier 시연, CGLIB pin) · **`WorkLogAuthorizationE2ETest`(@WebMvcTest, 실 `WorkLogController` DELETE 엔드포인트: HTTP→MVC→method-security→`AccessDeniedException`→`GlobalExceptionHandler`→403 envelope; positive(admin→204)+negative(user→403 `AUTHZ_INSUFFICIENT_PERMISSION`))** | 각 모듈 `src/test/` | — |
|
||||
|
||||
> 등급: 2026-06-08 `./gradlew check` GREEN(전 모듈 test + ArchUnit 49 rules + verifyCleanArchitectureDependencies + verifyPublicPathSnapshot) 실행 → 위 항목 `locally-verified`. D4 rule 은 비공허(non-vacuous) 검증까지 완료(DeleteWorkLogUseCase 어노테이션 제거 시 정확히 해당 rule 만 FAILED 후 복원). 미커밋 working tree. 잔존 미검증 = JWT 필터 seam(아래 Claims To Verify 잔존 #4) 뿐.
|
||||
|
||||
### Findings 검증 (사용자 제기 10항 대조)
|
||||
|
||||
| # | 사용자 주장 | 코드 대조 결과 |
|
||||
|---|---|---|
|
||||
| 1 | ArchUnit 강제 부재 → 인가 누락 silent + spring-security import 가드 없음 | **반증(FALSE)**: 둘 다 구현됨 — `declareRequiredPermissionWhenMutating()`(D4) 가 미선언 mutating use case build fail, `application_and_domain_do_not_depend_on_spring_security`(D1)가 import 차단. 위임 설계대로 host=architecture-enforcement suite 실현. (노트 §2 의 "REFERENCE ONLY/planned" 표기가 stale 이었음 → 정정함) |
|
||||
| 2 | AOP proxy bypass | **부분 valid**: D4 rule 은 annotation *존재* 만 보장, self-invocation/non-bean *invocation-path* 우회는 미검출. 현 호출 경로(controller→usecase proxy)는 안전. → Claims To Verify 잔존 #2 |
|
||||
| 3 | CGLIB/proxy-target-class 의존 | **valid, 기록됨**: [[raw/errors/method-security-cglib-vs-jdk-proxy-usecase-injection-2026-06-08]]. **설계 대안(미채택)**: controller 가 concrete `*UseCase` 아닌 input-port 인터페이스 주입 시 JDK proxy 로 충분 → CGLIB 하드 의존 제거 + §19 정합 ↑. 구현은 test 에 CGLIB 강제(prod mirror)로 핀 — 정당한 선택이나 근본 결합은 잔존 |
|
||||
| 4 | E2E 전 경로 미검증 | **대부분 RESOLVED**: method-security→AuthorizationPort→registry + 3-tier 는 `WorkLogAuthorizationContractTest`, **HTTP→MVC→method-security→403 `AUTHZ_INSUFFICIENT_PERMISSION` envelope(positive admin→204 + negative user→403)는 `WorkLogAuthorizationE2ETest`(실 `WorkLogController` DELETE)** 가 검증. *잔존 seam* = JWT 필터체인→`JwtToAuthenticatedUserConverter`→`AuthenticatedUser.roles`(두 테스트 모두 `addFilters=false`로 principal 직접 주입) → Claims To Verify 잔존 #4(저위험, 머지 전 권고) |
|
||||
| 5·6·7 | read 미적용 / IDOR·owner ABAC / cross-tenant | **valid(의도적 범위)**: D4 mutating-only, D2/D5 ABAC·IDOR 확장점, tenant 위임 — 노트 정합 |
|
||||
| 8 | config drift fail-closed | **valid(가용성)**: 보안 아닌 가용성. startup known-role 검증 미구현 → Claims To Verify 잔존 #8 |
|
||||
| 9 | every-request 비용 | valid(무시 가능): static config startup-bound, staleness 없음 |
|
||||
| 10 | §25 SSOT Owner Map row 부재 | valid: project note 편집(본 branch 밖) — §진행 중 메모 TODO |
|
||||
|
||||
> **머지 전 실질 권고**(코드 작업): 1번(ArchUnit D4/D1)·4번의 HTTP→authz E2E 는 *이미 해소됨*(`WorkLogAuthorizationE2ETest`). 잔존 = (4-narrowed) authN→authz seam(full `@SpringBootTest` + mock JWT)·(2) invocation-path 가드 또는 (3) input-port 주입 전환 — 모두 저위험.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/curity-oauth2-scope-vs-permission-naming]]
|
||||
- [[raw/official-docs/aws-iam-google-iam-permission-naming-convention]]
|
||||
- [[raw/official-docs/keycloak-authorization-services-realm-client-roles]]
|
||||
- [[raw/official-docs/owasp-authz-permission-model-abac-rbac]]
|
||||
- [[raw/official-docs/spring-security-authorization-architecture]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: interviews:start -->
|
||||
- [[raw/interviews/clean-architecture-method-authorization-without-spring-coupling-2026-06-08]]
|
||||
<!-- GENERATED: interviews:end -->
|
||||
|
||||
<!-- GENERATED: errors:start -->
|
||||
- [[raw/errors/method-security-cglib-vs-jdk-proxy-usecase-injection-2026-06-08]]
|
||||
<!-- GENERATED: errors:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/framework-free-method-authorization-clean-architecture-2026-06-08]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
> 본 branch 는 hub. 파생 raw 누적 시 카테고리별 그룹화. 현재 leaf — 자동조사 산출 raw 가 §Sources 에 연결되면 아래 갱신.
|
||||
|
||||
### 근거 자료
|
||||
|
||||
- [[raw/official-docs/security-authorization-cheatsheet-owasp]] — OWASP-AUTHZ-C1~C7 (deny-by-default / authn-authz distinct / least-privilege / every-request / server-side / IDOR)
|
||||
- [[raw/official-docs/spring-security-authorization-architecture]] — SS-AUTHZ-ARCH-C1~C6 (D1: custom `AuthorizationManager` / `@PreAuthorize` AOP coupling). 2026-06-08 `wiki-decision-researcher` 산출
|
||||
- [[raw/official-docs/owasp-authz-permission-model-abac-rbac]] — OWASP-PM-C1~C6 (D2: permission-centric RBAC + ABAC counterclaim + least-privilege H+V). 2026-06-08 자동조사 산출
|
||||
- [[raw/official-docs/aws-iam-google-iam-permission-naming-convention]] — IAM-NAMING-C1~C5 (D6: `resource:action` — AWS/Google IAM 비교). 2026-06-08 자동조사 산출
|
||||
- [[raw/official-docs/keycloak-authorization-services-realm-client-roles]] — KC-AUTHZ-C1~C4 (D3: realm/client role JWT claim + UMA 대안). 2026-06-08 자동조사 산출
|
||||
- [[raw/company-tech-blogs/curity-oauth2-scope-vs-permission-naming]] — CURITY-SCOPE-C1~C3 (D6: scope vs permission 분리 + colon naming, `company-case-study`). 2026-06-08 자동조사 산출
|
||||
|
||||
### 오류 기록 (이 branch 작업 중 발생)
|
||||
|
||||
- [[raw/errors/method-security-cglib-vs-jdk-proxy-usecase-injection-2026-06-08]] — method-security AOP proxy 가 use case 를 JDK interface proxy 로 감싸 concrete-type 주입 실패(CGLIB 강제로 해소) + unauthenticated→AuthenticationException(403 아님) 명확화. 2026-06-08 구현 중 발생, 둘 다 resolved.
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- [[raw/interviews/clean-architecture-method-authorization-without-spring-coupling-2026-06-08]] — "왜 @PreAuthorize 안 쓰고 use-case AuthorizationPort 인가", "permission vs role 모델", "거부를 어떻게 403 으로 emit 하나(2-hop)", "AOP proxy bypass 위험" 등.
|
||||
|
||||
### 블로그·채용공고 연계 글감
|
||||
|
||||
- [[raw/blog-topics/framework-free-method-authorization-clean-architecture-2026-06-08]] — application layer 를 Spring-Security-free 로 유지하면서 method-level authorization 을 거는 패턴(annotation in core + AuthorizationManager in adapter).
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- (없음 — 구현 단계에서 누적)
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시 채움. `/ingest`가 이 섹션 기준으로 wiki/projects/에 추출.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출 — 전용 canonical 후보 `wiki/projects/ca-tmpl/authorization-rbac-method-security.md`):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-background-job-async-contract.md
|
||||
@@ -0,0 +1,468 @@
|
||||
---
|
||||
title: branch / feature-background-job-async-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-background-job-async-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||||
tags: [branch, ca-skeleton, async, scheduler, background-job]
|
||||
created: 2026-05-22
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-025
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-025
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-SCHEDULER-LOCK-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-EVENT-BROKER-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: 928d7721870b6023078790c09e8a4319b34e3a3a37e0e3b0cfa9666b4c1e4a46
|
||||
---
|
||||
|
||||
# branch: feature-background-job-async-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — background job, scheduler, async executor 기준을 정의합니다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (§<관련 섹션>) 의 결정/근거/금지 사항을 정제한다.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: duplicate scheduler/outbox execution 방지 test가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-SCHEDULER-LOCK-001@1` | single-instance가 default이며 multi-instance scheduler/outbox는 DB advisory lock을 사용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-EVENT-BROKER-001@1` | core는 broker-agnostic outbox를 제공하고 Kafka는 optional adapter로 둔다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
요청 스레드 밖에서 발생하는 실패는 GlobalExceptionHandler로 잡히지 않습니다. async exception, executor saturation, scheduled job overlap, shutdown 중 job 처리 기준이 필요합니다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- async exception handling.
|
||||
- executor saturation/rejection 기준.
|
||||
- scheduled job overlap 기준.
|
||||
- job id/correlationId 기준.
|
||||
- retry/backoff 기준.
|
||||
- shutdown 중 job 처리 기준.
|
||||
- background failure logging 기준.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- business batch job 구현.
|
||||
- external scheduler platform 연동.
|
||||
- distributed job lock 기본 구현.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/outbox-skip-locked-microservices-io]] | Chris Richardson 원형 |
|
||||
| [[raw/official-docs/skip-locked-postgres-docs]] | Postgres SKIP LOCKED 원리 |
|
||||
| [[raw/company-tech-blogs/outbox-woowahan-techblog-pattern]] | 우아한형제들 polling 사례 |
|
||||
| [[raw/official-docs/outbox-debezium-official-docs]] | [[raw/company-tech-blogs/outbox-wix-engineering-debezium]] |
|
||||
| [[raw/company-tech-blogs/outbox-wix-engineering-debezium]] | — |
|
||||
| [[raw/company-tech-blogs/outbox-confluent-kafka-connect-smt]] | — |
|
||||
| [[raw/official-docs/spring-transactional-event-listener]] | — |
|
||||
| [[raw/official-docs/event-sourcing-vs-outbox-microservices-io]] | — |
|
||||
| [[raw/company-tech-blogs/outbox-netflix-domain-events-cdc]] | — |
|
||||
| [[raw/official-docs/dual-write-antipattern-microservices-io]] | outbox 도입 근거 |
|
||||
| [[raw/official-docs/spring-framework-observability-context-propagating-task-decorator]] | D5/D6 — ContextPropagatingTaskDecorator + setTaskDecorator() 패턴이 Spring 공식 권고, MDC + Observation context worker thread 전파 근거 |
|
||||
| [[raw/official-docs/retry-spring-retry-readme-backoff-defaults]] | D4 — maxAttempts default = 3 verbatim 확인 (SPRING-RETRY-C1); exp+jitter 는 라이브러리 default 아님, 명시 설정 필요 (SPRING-RETRY-C2) |
|
||||
| [[raw/company-tech-blogs/retry-aws-exponential-backoff-and-jitter]] | D4 — Full Jitter 공식·no-jitter 열위 근거·Full vs Equal vs Decorrelated 비교 (AWS-JITTER-C1~C5) |
|
||||
| [[raw/official-docs/spring-boot-graceful-shutdown-reference]] | D8 — SmartLifecycle earliest phase 신규 요청 차단(SB-GS-C2/C5) + `spring.lifecycle.timeout-per-shutdown-phase` phase timeout 상한(SB-GS-C4) |
|
||||
| [[raw/official-docs/kubernetes-pod-lifecycle-termination]] | D8 — k8s terminationGracePeriodSeconds 기본 30s + SIGTERM→SIGKILL 시퀀스 근거 (K8S-POD-LC-C1~C3) |
|
||||
| [[raw/official-docs/spring-executor-configuration-support-javadoc]] | D8 — setWaitForTasksToCompleteOnShutdown(true) + setAwaitTerminationSeconds(N) 조합이 in-flight job 을 컨테이너 종료와 정합시키는 공식 API (default 는 즉시 interrupt) — EXEC-CS-C1~C4 |
|
||||
| [[raw/official-docs/micrometer-context-propagation-purpose-thread-local-accessor]] | D5/D6 — ContextSnapshot/ThreadLocalAccessor 가 async cross-thread ThreadLocal 전파의 공식 메커니즘 (MICRO-CP-C1~C5) |
|
||||
| [[raw/official-docs/retry-aws-well-architected-rel05-bp03]] | D4 — "limit the maximum number of retries" 공식 근거 (WAF-REL05-C1/C2) + non-transient error retry 금지 (WAF-REL05-C3) + multi-layer retry storm anti-pattern (WAF-REL05-C4) + non-idempotent retry 금지 (WAF-REL05-C5) |
|
||||
| [[raw/official-docs/spring-boot-task-execution-scheduling-reference]] | D7 — auto-configured executor 기본값(8 core / unbounded queue) 대비 bounded queue 강제의 공식 근거; virtual threads 대안 존재(SB-TASK-C1~C4) |
|
||||
| [[raw/official-docs/spring-security-concurrency-delegating-security-context-executor]] | D5 — SecurityContext 의 `@Async` 전파는 `DelegatingSecurityContextExecutor`/`DelegatingSecurityContextTaskExecutor` 를 통한 explicit opt-in 이 공식 메커니즘 (SS-CONC-C3, SS-CONC-C4) |
|
||||
| [[raw/official-docs/jdk21-threadpoolexecutor-javadoc]] | D7 — JDK `ThreadPoolExecutor` pool growth 3단계(TPE-JDK21-C1/C2), unbounded queue 에서 maximumPoolSize 무효(TPE-JDK21-C3), bounded queue resource-exhaustion 방지(TPE-JDK21-C4), AbortPolicy 기본값 시맨틱(TPE-JDK21-C5), CallerRunsPolicy 피드백 감속 메커니즘(TPE-JDK21-C6) |
|
||||
| [[raw/official-docs/spring-framework-threadpooltaskexecutor-javadoc]] | D7 — Spring `ThreadPoolTaskExecutor` queueCapacity default = `Integer.MAX_VALUE` unbounded (SF-TPTE-C1) — bounded queue 강제의 negative evidence; 양수 → LinkedBlockingQueue / 0이하 → SynchronousQueue 분기(SF-TPTE-C2); maxPoolSize default = `Integer.MAX_VALUE`(SF-TPTE-C3); TaskDecorator primary use case = execution context + monitoring(SF-TPTE-C4) |
|
||||
|
||||
## 외부 근거 / 대안 조사 (2026-05-22 — Topic 3)
|
||||
|
||||
본 branch의 retry/DLQ/scheduler 결정에 대한 외부 source 조사. outbox publisher는 본 branch의 retry vocabulary를 consume. 6종 대안 비교는 (예정) `wiki/concepts/transactional-outbox-pattern.md` 참조.
|
||||
|
||||
- **채택 결정 (DB polling + FOR UPDATE SKIP LOCKED)**:
|
||||
- [[raw/official-docs/outbox-skip-locked-microservices-io]] — Chris Richardson 원형
|
||||
- [[raw/official-docs/skip-locked-postgres-docs]] — Postgres SKIP LOCKED 원리
|
||||
- [[raw/company-tech-blogs/outbox-woowahan-techblog-pattern]] — 우아한형제들 polling 사례
|
||||
- **검토한 대안**:
|
||||
- **대안 1: Debezium CDC** — [[raw/official-docs/outbox-debezium-official-docs]], [[raw/company-tech-blogs/outbox-wix-engineering-debezium]]
|
||||
- **대안 2: Kafka Connect outbox SMT** — [[raw/company-tech-blogs/outbox-confluent-kafka-connect-smt]]
|
||||
- **대안 3: Spring @TransactionalEventListener** (in-process only) — [[raw/official-docs/spring-transactional-event-listener]]
|
||||
- **대안 4: Event sourcing 전환** — [[raw/official-docs/event-sourcing-vs-outbox-microservices-io]]
|
||||
- **대안 5: Netflix DBLog 급 자체 CDC** — [[raw/company-tech-blogs/outbox-netflix-domain-events-cdc]]
|
||||
- **Negative reference (금지)**: [[raw/official-docs/dual-write-antipattern-microservices-io]] — outbox 도입 근거
|
||||
- **비교 핵심**: polling lag vs CDC 인프라 비용이 결정 축. ca-tmpl 가정 = lag 수 초 허용 + Kafka Connect 운영 인력 부재 + DB가 SSOT. 가정 깨지면 Debezium migration. event sourcing은 "대안"이라기보다 도메인 모델 교체. background-job branch는 outbox publisher의 retry/DLQ를 owns. Debezium은 retry를 Kafka Connect dead-letter에 위임, ca-tmpl은 자체 DLQ vocabulary.
|
||||
|
||||
### 추가 외부 근거 (2026-06-11 — D4/D5/D6/D7/D8 자동조사)
|
||||
|
||||
`/branch-spec` 자동조사로 UNSUPPORTED 였던 D4·D5·D6·D7·D8 에 공식 doc 근거 12건을 아카이브 (위 Sources 표 11~22행). 대안 비교 요지:
|
||||
|
||||
- **D4 retry shape**: 채택 = exp + jitter + max 3 + DLQ. 대안 = fixed-interval(단일 인스턴스·예측 가능 복구 한정 — Spring Retry/Resilience4j 라이브러리 default), unlimited retry + circuit breaker(외부 HTTP 의존 전용 — DB 기반 DLQ 와 시맨틱 충돌). non-transient error 는 retry 자체가 anti-pattern (WAF-REL05-C3).
|
||||
- **D5/D6 context propagation**: 채택 = TaskDecorator 1개 등록. Spring 공식 구현체 `ContextPropagatingTaskDecorator` 가 MDC + Observation 을 동시 전파 (SF-OBS-C1/C2) — 수동 4-key copy 대비 우위이나 `io.micrometer:context-propagation` classpath 필수 (SF-OBS-C3). `SecurityContextHolder.MODE_INHERITABLETHREADLOCAL` 은 thread pool 재사용 시 stale context 위험으로 부적합 — explicit opt-in 은 `DelegatingSecurityContext*` (SS-CONC-C3/C4).
|
||||
- **D7 saturation**: 채택 = bounded queue + AbortPolicy. 대안 = CallerRunsPolicy(caller 가 request thread 가 아닐 때만 — request latency 직접 침식, TPE-JDK21-C6), unbounded queue 는 REJECTED(max pool 무효화 — TPE-JDK21-C3 + SF-TPTE-C1). Boot 3.2+ virtual threads(`SimpleAsyncTaskExecutor`)는 별도 검토 대상 (SB-TASK-C4).
|
||||
- **D8 shutdown**: 채택 = budget-fit (await ≤ 19s + 멱등 retry-on-next-startup). 대안 = terminationGracePeriodSeconds 연장 — parent project 운영 계약 변경이므로 본 branch 범위 밖.
|
||||
|
||||
## TODO
|
||||
|
||||
> TODO drained — 결정은 아래 표/결정 사항 참조.
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- background failure는 HTTP response가 없으므로 log/metric/alert가 핵심 계약입니다.
|
||||
|
||||
## 구현 기록
|
||||
|
||||
> `documented-only`/`planned` → 실 구현 + 로컬 검증 완료. 구현 git 브랜치: `feature/domain-event-outbox-contract` (background-job 을 outbox 브랜치 위에서 이어서 구현). §Audit A6 의 "전부 미구현(planned)" 상태가 아래로 갱신됨.
|
||||
|
||||
- **D4 retry/DLQ vocabulary** (`actually-implemented`): `shared-contract` `OperationalError` 에 `JOB_EXECUTOR_REJECTED`(TRANSIENT_DEPENDENCY/503/true), `JOB_TIMEOUT`(TRANSIENT_DEPENDENCY/500/true), `JOB_DEAD_LETTER`(INTERNAL/500/false) 추가 — registry SSOT 와 일치(ErrorCodeRegistryMappingTest + BackgroundJobErrorCodeContractTest 가 category/status/retryable/runbook_link 교차검증). retry **carrier 는 미구현(planned, §3 UNSUPPORTED_IMPL)** — 어휘(error code + metric recorder)만 SSOT 로 고정. `BackgroundJobMetrics` 가 `job.retry.total{job_name,outcome}`·`job.dlq.total{job_name}` recorder seam 제공(outbox/outbound consume 용). NOTE: `retry_attempt` 는 metric tag 아님(log field) — recorder 시그니처는 `(job_name, outcome)`.
|
||||
- **D7 executor + saturation** (`actually-implemented` / 수치 `planned`): `app-bootstrap` `async/AsyncExecutorConfig` 가 bounded `ThreadPoolTaskExecutor`(core=10/max=50/queue=200, `applicationTaskExecutor`, `@Primary`, Boot unbounded auto-executor back-off) 등록. `AsyncExecutorSettings`(`ca-skeleton.async.executor.*`)가 `Integer.MAX_VALUE` 큐를 거부(unbounded forbidden). saturation = `LoggingAbortPolicy`(AbortPolicy + 구조화 ERROR 로그 error.code=JOB_EXECUTOR_REJECTED + `executor.rejected.total` + 재던짐) + `executor.saturation` 게이지. **수치(10/50/200)는 부하테스트 미검증 `planned`**.
|
||||
- **D5/D6 context propagation** (`locally-verified`): `AsyncContextTaskDecorator` 1개 — submit 시점 `MDC.getCopyOfContextMap()` 스냅숏(request_id/trace_id/correlation_id/tenant_id + span_id) + `DomainContextPropagator.wrap` (shared seam), 대칭 복원으로 풀 스레드 MDC bleed 방지. "TaskDecorator 미설정이면 fail" = decorator 를 executor @Bean 의 필수 의존성으로 주입(부재 시 context 기동 실패, AsyncExecutorConfigTest 가 검증). **SecurityContext principal 은 기본 전파 안 함**(opt-in `DelegatingSecurityContextTaskExecutor`, registry user_principal=`propagation:[none]`) — spec "Async Context Propagation Contract" 의 principal 라인과의 긴장은 registry SSOT + D6 우선으로 해소(문서화). Observation **scope** 전파는 `context-propagation` 라이브러리 미반입으로 MDC 문자열 복사까지만(업그레이드 경로 문서화).
|
||||
- **D8 graceful shutdown** (`locally-verified`): `setWaitForTasksToCompleteOnShutdown(true)` + `setAwaitTerminationSeconds(19)` (AsyncExecutorConfigTest 가 awaitTerminationMillis=19000 검증).
|
||||
- **D3 scheduler overlap / multi-instance** (`actually-implemented`): overlap = `ScheduledJobOverlapPolicyTest` (ArchUnit) 가 production `@Scheduled` 의 fixedRate 사용 금지(전부 fixedDelay). multi-instance lock 은 **기존** `StartupSafetyValidator.REQUIRED_MULTI_INSTANCE_BEANS` 의 `distributedLockProvider` 를 consume(재구현 아님) — `StartupSafetyValidatorTest` 가 이미 검증(exit 72).
|
||||
- **§Audit A4 runbooks** (`actually-implemented`): `docs/runbooks/job-executor-rejected.md`·`job-timeout.md`·`job-dead-letter.md` 작성 — `runbook://job/<scenario>` → `docs/runbooks/job-<scenario>.md` 해소(BackgroundJobErrorCodeContractTest 가 파일 존재 검증).
|
||||
- **wiring**: `application.yml` `ca-skeleton.async.executor.*` + `src/.env` `APP_ASYNC_EXECUTOR_*` 3종(verifyEnvKeys green).
|
||||
- **검증 명령**: `:shared-contract:test` 72/72 green; `:app-bootstrap:test` 256/257 green(유일 실패는 **선재** `CleanArchitectureTest#outbound_adapter_method_returns_only_domain_or_primitives` — adapter-outbound `OutboundHttpSettings`, 본 작업 무관, `git stash` baseline 로 확인 → [[raw/errors/archunit-b7-configprops-nested-record-accessor-outbound-2026-06-13]]); `verifyEnvKeys` green; `verifyCleanArchitectureDependencies` green. 3단 리뷰 체인(architect-sentinel PASS / spec-reviewer 22/22 / quality-reviewer 2건 수정) 통과.
|
||||
- **변경 파일**: `shared-contract/.../OperationalError.java`(+test), `app-bootstrap/.../bootstrap/async/{AsyncExecutorSettings,AsyncContextTaskDecorator,BackgroundJobMetrics,LoggingAbortPolicy,AsyncExecutorConfig}.java`(+6 test), `app-bootstrap/.../contract/BackgroundJobErrorCodeContractTest.java`, `application.yml`, `src/.env`, `docs/runbooks/job-*.md`, `docs/superpowers/plans/2026-06-13-background-job-async-contract.md`.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- 2026-05-22: scheduler/async는 runtime lifecycle에서 별도 branch로 분리.
|
||||
- 2026-05-22: retry/DLQ vocabulary의 SSOT는 이 branch. outbox/outbound branches는 이 vocabulary를 소비.
|
||||
- 2026-05-22: scheduler/outbox publisher는 single-instance 기본이며 multi-instance 지원 시 DB advisory lock 또는 ShedLock contract test가 필요.
|
||||
- 2026-05-22: 기본 backoff는 exponential backoff with jitter, max attempts 3, DLQ after exhausted attempts.
|
||||
- 2026-05-22: @Async context propagation은 `TaskDecorator` 1개를 ThreadPoolTaskExecutor에 등록해 caller→worker thread로 MDC(`request_id`/`trace_id`/`correlation_id`/`tenant_id`), Micrometer Observation context를 복사한다. SecurityContext는 explicit opt-in 시에만 전파. executor 등록 시 TaskDecorator 미설정이면 fail.
|
||||
- 2026-05-22: span_id는 Micrometer Observation context에서 자동 전파(MDC explicit copy 불필요), user_principal은 SecurityContext propagation이 opt-in일 때만 복사. 따라서 explicit MDC copy 대상은 foundation 6개 중 4개(request_id, trace_id, correlation_id, tenant_id).
|
||||
- 2026-05-22: executor pool sizing default = core=10, max=50, queue=200. saturation policy default = AbortPolicy. CallerRunsPolicy는 명시적 use case-level 선언 시에만 허용.
|
||||
- 2026-05-22: graceful shutdown = executor await termination ≤ **19s** (container-runtime의 app shutdown 20s 내부에서 1s cleanup margin 확보. 25s는 force-stop 유발하므로 forbidden).
|
||||
- 2026-06-13 (구현 정정): D6 의 "span_id 는 Observation context 자동 전파(MDC explicit copy 불필요)" 는 구현과 어긋남 — 실제는 MDC **전체 스냅숏 문자열 복사**로 span_id 가 동승하며 Observation *scope* 는 전파하지 않음(context-propagation 라이브러리 미반입). parent-span linkage 필요 시 `ContextPropagatingTaskDecorator` upgrade. (Decision Evidence Map D6 갱신 반영.)
|
||||
- 2026-06-13 (긴장 해소): "Async Context Propagation Contract" 의 "principal 이 caller 와 동일" 라인은 D6(`user_principal` = `propagation:[none]`) + 보안(풀 스레드 stale principal 위험)과 충돌 → **principal 은 기본 비전파**로 확정. SecurityContext 필요 use case 만 `DelegatingSecurityContextTaskExecutor` opt-in. 테스트 계약을 "principal 비전파" negative 검증으로 교체.
|
||||
|
||||
## Work Item Contract
|
||||
|
||||
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
|
||||
|
||||
| field | required | rule |
|
||||
| --- | --- | --- |
|
||||
| Decision | yes | 구현자가 선택해야 하는 기본값 |
|
||||
| Allowed | yes | 허용되는 예외와 조건 |
|
||||
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
|
||||
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
|
||||
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
|
||||
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
|
||||
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
|
||||
|
||||
## Decisionized Work Items
|
||||
|
||||
| item | Decision | Allowed | Forbidden | Required test |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| async exception | structured log + metric + runbook link | fail-fast for critical background worker | swallowed exception | async exception test |
|
||||
| saturation | bounded executor + rejection log | caller-runs only if documented | unbounded queue | rejection test |
|
||||
| scheduler overlap | no overlap by default | overlap only with idempotent job proof | concurrent same job mutation | overlap test |
|
||||
| retry/DLQ | exp backoff jitter, max 3, DLQ exhausted | branch-specific override with metric | infinite retry | retry/DLQ test |
|
||||
| multi-instance lock | single-instance default | DB advisory lock or ShedLock | multi-replica without lock | distributed lock test |
|
||||
| async context propagation | TaskDecorator 1개로 MDC + Observation 전파 | SecurityContext explicit opt-in | TaskDecorator 미설정 executor 등록 | @Async 메서드 안에서 MDC.get("request_id"), traceId, principal이 caller와 동일해야 함 |
|
||||
| saturation policy | AbortPolicy default (core=10, max=50, queue=200) | CallerRunsPolicy with explicit use case 선언 | unbounded queue / 미선언 fallback | saturation policy test |
|
||||
| graceful shutdown | await termination ≤ 19s (app shutdown 20s − 1s cleanup margin) | 짧은 quiet period override | await ≥ 20s / terminate without await | shutdown await test |
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. Company-tech-blog 인용은 사례 (`company-case-study`) 로만 사용하며 공식 best practice 로 단정하지 않는다.
|
||||
> `선택 조건` 열(R2): "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 분기 없으면 `N/A`.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | scheduler/async 는 runtime lifecycle 에서 별도 branch 로 분리 (이 branch 가 retry/DLQ vocabulary SSOT) | N/A — 내부 스코프 결정 (분기 없음) | UNSUPPORTED_DECISION — 내부 조직/스코프 결정으로 외부 raw 근거 부재 | `internal-only` | 다른 branch (outbox/outbound) 가 이 vocabulary 를 일관 참조하는지 lint 필요 |
|
||||
| D2 | retry/DLQ vocabulary SSOT 결정 — outbox/outbound branches 가 이를 consume | N/A — 내부 계약 (분기 없음) | UNSUPPORTED_DECISION — 외부 raw 의 단일 SSOT 권고 인용 부재 (내부 계약) | `internal-only` | vocabulary drift 위험 |
|
||||
| D3 | scheduler/outbox publisher 는 single-instance 기본, multi-instance 시 DB advisory lock 또는 ShedLock 필수 | `APP_MULTI_INSTANCE_ENABLED=false`(default) → lock 불요; `true` → `distributedLockProvider` bean 필수 (ca-tmpl `StartupSafetyValidator` 가 startup fail 로 강제) | `raw/official-docs/skip-locked-postgres-docs.md#SK-PG-C2`, `raw/official-docs/outbox-skip-locked-microservices-io.md#OUTBOX-MIO-C4` | `official-vendor-doc + needs-confirmation` (microservices.io needs verbatim recheck) | SKIP LOCKED 는 lock contention 회피만 보장, 순서 보장은 별도 — `SK-PG-C2` 의 "inconsistent view" 경고 |
|
||||
| D4 | 기본 backoff = exponential + jitter, max attempts 3, DLQ after exhausted | multi-instance 가능 또는 공유 자원(DB) 대상 transient 실패 → exp+jitter (동기화 retry spike 방지, WAF-REL05-C1); 보장된 단일 인스턴스 + 예측 가능한 짧은 복구 → fixed-interval 허용(라이브러리 default); non-transient error(권한/도메인/스키마) → retry 없이 즉시 DLQ (WAF-REL05-C3); 외부 HTTP 의존 → circuit breaker 는 outbound adapter 레이어 보완재(대체재 아님) | maxAttempts=3: `raw/official-docs/retry-spring-retry-readme-backoff-defaults.md#SPRING-RETRY-C1`; exp+jitter 는 default 아님 명시 설정 필요: `#SPRING-RETRY-C2`; exp+jitter+max limit 조합 필수(WAF 공식 권고): `raw/official-docs/retry-aws-well-architected-rel05-bp03.md#WAF-REL05-C1`; max limit 없으면 metastable failure: `#WAF-REL05-C2`; non-transient error → retry 금지(DLQ 방향): `#WAF-REL05-C3`; single-layer retry 원칙: `#WAF-REL05-C4`; non-idempotent retry 금지: `#WAF-REL05-C5`; Full Jitter 사례: `raw/company-tech-blogs/retry-aws-exponential-backoff-and-jitter.md#AWS-JITTER-C1~C5` (company-case-study). DLQ 아키텍처 자체는 WAF-REL05-C3 방향으로 정당화, DLQ 설계 상세는 별도 doc 부재 | `official-vendor-doc` (WAF-REL05-C1~C5 + SPRING-RETRY-C1/C2) + `company-case-study` (AWS-JITTER); DLQ 설계 상세 `unsupported` | max=3 이 ca-tmpl 부하에 적합한지 측정 필요 (`WAF-REL05-C2` use-case 별 조정 권고); exp+jitter `@Backoff` 명시 설정 필요; retry carrier 미확정 (§구현 가이드 3) |
|
||||
| D5 | `@Async` TaskDecorator 1개로 MDC(`request_id`/`trace_id`/`correlation_id`/`tenant_id`) + Observation context 전파, SecurityContext explicit opt-in, 미설정 fail | Micrometer tracing 활성 + `io.micrometer:context-propagation` classpath(Boot 3.2+) → `ContextPropagatingTaskDecorator` 권장(SF-OBS-C1); 라이브러리 반입 불가 또는 key 별 fine-grained 통제 필요 → 수동 4-key copy decorator; SecurityContext 필요 use case → `DelegatingSecurityContextTaskExecutor` opt-in(SS-CONC-C3); `MODE_INHERITABLETHREADLOCAL` 은 thread pool 에서 금지 | MDC+Observation: `raw/official-docs/spring-framework-observability-context-propagating-task-decorator.md#SF-OBS-C1~C4`; Micrometer: `raw/official-docs/micrometer-context-propagation-purpose-thread-local-accessor.md#MICRO-CP-C1`; **SecurityContext explicit opt-in: `raw/official-docs/spring-security-concurrency-delegating-security-context-executor.md#SS-CONC-C3` + `#SS-CONC-C4`**; 미설정 fail: UNSUPPORTED_DECISION | `official-vendor-doc` (SF-OBS/MICRO-CP/SS-CONC) + `unsupported` (미설정 fail 강제 메커니즘) | SecurityContext 를 `DelegatingSecurityContextTaskExecutor` 로 감싸는 것과 TaskDecorator 내 manual propagation 의 중복 여부 별도 검증 필요 |
|
||||
| D6 | **구현 정정 (2026-06-13)**: TaskDecorator 가 submit 시점 `MDC.getCopyOfContextMap()` **전체 스냅숏**을 복사 → foundation 4키(request_id/trace_id/correlation_id/tenant_id) + 그 시점 MDC 에 있는 span_id 가 **문자열로 동승**. user_principal 은 MDC 비대상(`propagation:[none]`)이라 미전파. **Observation *scope* 자체는 전파 안 함**(context-propagation 라이브러리 미반입) — span_id 연속성은 "Observation 자동 전파"가 아니라 MDC 문자열 복사에 의존 | 현재 = MDC whole-map 복사(로그 필드 연속성까지); `io.micrometer:context-propagation` 도입 시 `ContextPropagatingTaskDecorator` 로 교체하면 Observation scope(parent-span linkage)까지 전파 — upgrade 경로 | whole-map 복사로 4키 포함 보장: `raw/official-docs/spring-framework-observability-context-propagating-task-decorator.md#SF-OBS-C2`; cross-thread ThreadLocal 전파 원리: `raw/official-docs/micrometer-context-propagation-purpose-thread-local-accessor.md#MICRO-CP-C1/C2/C5` | `official-vendor-doc` (메커니즘) + `locally-verified` (구현·테스트) | (1) whole-map 복사라 비-foundation MDC 키도 동승 — 의도적(로그 연속성), negative test 부재. (2) Observation scope 미전파 = trace parent-span linkage 단절; tracing bridge 가 span_id 를 MDC 에 안 쓰는 구성이면 worker 로그 span_id 공백 가능 — upgrade 경로로 해소 |
|
||||
| D7 | executor pool sizing default = core=10, max=50, queue=200, saturation = AbortPolicy default (CallerRunsPolicy 는 use-case 선언 시) | caller = HTTP request thread + saturation 관찰 필요 + DLQ/retry 계약 존재 → AbortPolicy (TPE-JDK21-C5); caller 가 request thread 아님 + task 손실 불허 + DLQ 부재 → CallerRunsPolicy use-case 명시 선언 (TPE-JDK21-C6 의 감속 = request latency 침식); unbounded queue → FORBIDDEN (max pool 무효 — TPE-JDK21-C3, SF-TPTE-C1) | `[[raw/official-docs/jdk21-threadpoolexecutor-javadoc]]#TPE-JDK21-C1` (pool growth 3단계), `#TPE-JDK21-C2` (max 도달 시 거부), `#TPE-JDK21-C3` (unbounded queue 에서 max 무효), `#TPE-JDK21-C4` (bounded queue resource-exhaustion 방지), `#TPE-JDK21-C5` (AbortPolicy 기본값), `#TPE-JDK21-C6` (CallerRunsPolicy 피드백 감속); Boot 기본값 대비: `raw/official-docs/spring-boot-task-execution-scheduling-reference.md#SB-TASK-C1~C3`; Spring default unbounded: `raw/official-docs/spring-framework-threadpooltaskexecutor-javadoc.md#SF-TPTE-C1~C3` | `official-reference` (구조) | 구체적 수치(core=10/max=50/queue=200)는 `UNSUPPORTED_IMPL_DECISION` — 부하 테스트로 별도 검증 필요 (registry `APP_ASYNC_EXECUTOR_*` default 는 본 branch 결정의 반영이므로 외부 근거 아님) |
|
||||
| D8 | graceful shutdown = executor await termination ≤ 19s (container 20s − 1s cleanup margin), 25s 는 forbidden | job p99 실행 시간 < 19s + 멱등 retry-on-next-startup 가능 → budget-fit await ≤ 19s; long-running job(> 19s) 이 정당한 비즈니스 요건 → grace period 연장 검토는 OUT_OF_BRANCH_SCOPE (parent project 운영 계약 소유자 승인 필요) | `raw/official-docs/spring-executor-configuration-support-javadoc.md#EXEC-CS-C1` (default=false → 명시 필수), `#EXEC-CS-C2` (true 시 running+queued 완료 후 종료), `#EXEC-CS-C3` (setAwaitTerminationSeconds 공식 API), `#EXEC-CS-C4` (significantly higher timeout rule-of-thumb); `raw/official-docs/spring-boot-graceful-shutdown-reference.md#SB-GS-C2/C5`; `raw/official-docs/kubernetes-pod-lifecycle-termination.md#K8S-POD-LC-C1` (terminationGracePeriodSeconds default=30s), `#K8S-POD-LC-C2` (grace period 초과 시 SIGKILL), `#K8S-POD-LC-C3` (kubelet → SIGTERM to process 1); 19s 수치는 `UNSUPPORTED_IMPL_DECISION` (20s app shutdown − 1s margin — app-level 20s 는 SB-GS-C4 + ca-tmpl 설정 확인 필요) | `official-vendor-doc` (Spring + k8s) + `UNSUPPORTED_IMPL_DECISION` (19s = 20s − 1s margin) | 19s 초과 금지 이유는 k8s grace period 초과 시 SIGKILL (K8S-POD-LC-C2) 로 직접 정당화됨. 20s app timeout 과 k8s 30s grace period 의 관계 — ca-tmpl 실제 `terminationGracePeriodSeconds` 설정 확인 필요 (§Audit A1 drift 참조) |
|
||||
| D9 | outbox publisher baseline = SKIP LOCKED polling (대안 검토 후 채택) | lag 수 초 허용 + Kafka Connect 운영 인력 부재 + DB 가 SSOT → SKIP LOCKED polling; lag SLO 강화 또는 polling 비용 임계 초과 → Debezium CDC migration (D10) | `raw/official-docs/skip-locked-postgres-docs.md#SK-PG-C1`, `raw/official-docs/skip-locked-postgres-docs.md#SK-PG-C2`, `raw/official-docs/outbox-skip-locked-microservices-io.md#OUTBOX-MIO-C1`, `raw/official-docs/outbox-skip-locked-microservices-io.md#OUTBOX-MIO-C3`, `raw/official-docs/outbox-skip-locked-microservices-io.md#OUTBOX-MIO-C4` | `official-vendor-doc + needs-confirmation` | `OUTBOX-MIO-C3` 의 "frequently polling can be expensive" 한계 — polling interval 측정 필요. **owner 이관 권고 — §Audit A2** |
|
||||
| D10 | 대안 1 (Debezium CDC) 비교 — Kafka Connect 운영 인력 부재 시 부적합 | Kafka Connect 운영 가능 + lag SLO 빡빡 → Debezium 재검토; 그 외 → polling 유지 | `raw/official-docs/outbox-debezium-official-docs.md#OUTBOX-DBZ-C1`, `raw/official-docs/outbox-debezium-official-docs.md#OUTBOX-DBZ-C2`, `raw/official-docs/outbox-debezium-official-docs.md#OUTBOX-DBZ-C4`, `raw/company-tech-blogs/outbox-wix-engineering-debezium.md#WIX-DEBEZIUM-C1` | `needs-confirmation + company-case-study(needs-confirmation)` | Debezium raw 전체가 `needs-confirmation` (WebFetch 403 차단) — verbatim 재확인 필요. Wix 인용은 사례, 공식 best practice 아님. **owner 이관 권고 — §Audit A2** |
|
||||
| D11 | 대안 5 (Spring `@TransactionalEventListener`) = in-process only, 외부 broker 발행 부적합 | in-process 소비만 필요한 이벤트 → 사용 가능; 외부 broker 발행 필요 → outbox 필수 | `raw/official-docs/spring-transactional-event-listener.md#TX-EVT-C1`, `raw/official-docs/spring-transactional-event-listener.md#TX-EVT-C3`, `raw/official-docs/spring-transactional-event-listener.md#TX-EVT-C4` | `official-vendor-doc` | `TX-EVT-C4` 의 "no transaction → not invoked" 시맨틱 — fallbackExecution 사용 시 별도 검증 필요. **owner 이관 권고 — §Audit A2** |
|
||||
| D12 | dual-write 금지 (outbox 도입 근거) | N/A — negative reference (금지 규칙, 분기 없음) | `raw/official-docs/dual-write-antipattern-microservices-io.md#DUAL-WRITE-C1`, `raw/official-docs/dual-write-antipattern-microservices-io.md#DUAL-WRITE-C2`, `raw/official-docs/dual-write-antipattern-microservices-io.md#DUAL-WRITE-C3` | `needs-confirmation` (microservices.io verbatim recheck 필요) | dual-write 의 inconsistency 형태 (lost vs phantom event) 별도 분류 필요. **owner 이관 권고 — §Audit A2** |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 작성일 2026-06-11 (명세). ca-tmpl ground truth (registry + src grep) 대조 완료 — 계약 값은 전부 registry 기존 값 재사용, invent 없음. **구현 상태는 §구현 기록(2026-06-13) 이 authoritative** — 아래 표의 `planned` 중 다수가 구현 완료로 갱신됨(executor bean / saturation / TaskDecorator / awaitTermination 등). §Audit A6 의 "전부 미구현" 은 명세 시점 스냅숏이며 §구현 기록으로 대체됨.
|
||||
|
||||
### 1. Executor 구성 + saturation (D7, D8)
|
||||
|
||||
> **Trace**: In-scope "executor saturation/rejection 기준" → D7 (TPE-JDK21-C1~C6, SB-TASK-C1~C3, SF-TPTE-C1~C3) + D8 (EXEC-CS-C1~C4).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: ① 수치 core=10/max=50/queue=200 — 외부 doc 은 구조(bounded queue + max 발동 조건)만 권고, 수치는 부하테스트 전 사용자 trade-off. ② RejectedExecutionHandler 를 structured log + error code 로 wrapping 하는 패턴 — 공식 reference 부재, JOB_EXECUTOR_REJECTED 매핑은 registry 계약에서 도출.
|
||||
|
||||
| 항목 | 명세 | 상태 |
|
||||
|---|---|---|
|
||||
| bean 위치 | `src/app-bootstrap/.../bootstrap/` config 클래스 (app-bootstrap CLAUDE.md: 최종 cross-module wiring 책임 — `IdempotencyConfig` 선례 패턴) | `planned` |
|
||||
| pool 설정 키 | `APP_ASYNC_EXECUTOR_CORE_SIZE`(10) / `APP_ASYNC_EXECUTOR_MAX_SIZE`(50) / `APP_ASYNC_EXECUTOR_QUEUE_CAPACITY`(200) — ca-tmpl `docs/registries/env-keys.yaml` 기존 값 (owner_branch = 본 branch, required_test 3종 포함) | registry 확정 / 코드 `planned` |
|
||||
| queue | bounded 필수 — unbounded 는 max pool 무효 (TPE-JDK21-C3) + Spring default `Integer.MAX_VALUE` 금지 (SF-TPTE-C1) | `planned` |
|
||||
| rejection | `AbortPolicy` → `RejectedExecutionException` catch → structured log + `JOB_EXECUTOR_REJECTED` (error-codes.yaml: TRANSIENT_DEPENDENCY / 503 / retryable / retry_after 5s) + `executor.rejected.total` counter (metrics.yaml, alert p1) | registry 확정 / 코드 `planned` |
|
||||
| saturation 관측 | `executor.saturation` gauge (metrics.yaml: p2 queue > 80% / p1 rejection > 0 for 1m) | registry 확정 / 코드 `planned` |
|
||||
| shutdown knob | `setWaitForTasksToCompleteOnShutdown(true)` + `setAwaitTerminationSeconds(19)` (EXEC-CS-C2/C3 — default 는 즉시 interrupt, EXEC-CS-C1) | `planned` |
|
||||
|
||||
### 2. Async context propagation (D5, D6)
|
||||
|
||||
> **Trace**: In-scope "job id/correlationId 기준" → D5/D6 (SF-OBS-C1~C4, MICRO-CP-C1~C5, SS-CONC-C3/C4, SF-TPTE-C4/C5).
|
||||
>
|
||||
> - **구현 현황 + 권고 (2026-06-13)**: "TaskDecorator 미설정이면 fail" 을 현 구현은 *decorator 를 executor @Bean 의 필수 생성자 의존성으로 주입*해 강제 — 단 이는 **이 executor bean 하나만** 보호한다(다른 곳에 bare `ThreadPoolTaskExecutor` 를 또 등록하면 통과). 스켈레톤은 drift guardrail 이 핵심이므로 **전역 가드로 승격 권고**: `ScheduledJobOverlapPolicyTest`·`CleanArchitectureTest` 와 같은 결의 ArchUnit/startup 검증으로 "등록된 모든 `TaskExecutor` bean 은 context decorator 보유"를 강제. 승급 완료 시 이 항목의 `UNSUPPORTED_IMPL_DECISION` 성격 제거.
|
||||
|
||||
| 항목 | 명세 | 상태 |
|
||||
|---|---|---|
|
||||
| 연결 seam | `shared-contract` `dev.caskeleton.shared.concurrency` `DomainContextPropagator.wrap(Runnable)` 를 `AsyncContextTaskDecorator` 가 실제 호출 | `actually-implemented` |
|
||||
| MDC copy 대상 | submit 시점 `MDC.getCopyOfContextMap()` 전체 스냅숏 — foundation 4키(`request_id`/`trace_id`/`correlation_id`/`tenant_id`) 보장 + span_id 동승(문자열). `user_principal` 은 `propagation: [none]` 미전파. mdc-keys.yaml foundation 과 정합 | `locally-verified` |
|
||||
| 구현 캐리어 | 현재 = 수동 MDC whole-map decorator(`AsyncContextTaskDecorator`). upgrade 경로 = `ContextPropagatingTaskDecorator` (Spring 6.1+, SF-OBS-C1) — `io.micrometer:context-propagation` 도입 시 Observation scope 까지 전파. D5 "TaskDecorator 1개" 는 Composite 1개 등록으로 충족 | 현 `locally-verified` / upgrade `planned` |
|
||||
| SecurityContext | `DelegatingSecurityContextTaskExecutor` wrapper 로 use-case 별 explicit opt-in (SS-CONC-C3/C4). `MODE_INHERITABLETHREADLOCAL` 금지(thread pool stale context). 기본 비전파 | opt-in `planned` / 기본 비전파 `actually-implemented` |
|
||||
| **미설정 fail 강제** | 현: decorator = executor @Bean 필수 생성자 의존성(이 bean 한정 — `AsyncExecutorConfigTest` 검증). **구현됨 (2026-06-13)**: 전역 ArchUnit 규칙 `every_task_executor_bean_has_context_decorator`(production `TaskExecutor` @Bean 은 factory method 안에서 `setTaskDecorator(...)` 호출 필수, `getMethodCallsFromSelf` 검사) — decorator 없는 executor @Bean 추가 시 ArchUnit fail. delegating wrapper(예: `DelegatingSecurityContextTaskExecutor`)는 명시적 예외 등록 필요(rule javadoc) | 생성자 강제 `locally-verified` / 전역 가드 `actually-implemented` (`TaskExecutorDecoratorPolicyTest`) |
|
||||
| 예외 경로 주의 | submit() 경로의 TaskDecorator 예외는 FutureTask 로 래핑되어 자동 전파 안 됨 (SF-TPTE-C5) — async exception test 는 execute()/submit() 두 경로 모두 검증 | `locally-verified` (2경로 테스트) |
|
||||
|
||||
### 3. Retry / DLQ vocabulary (D4)
|
||||
|
||||
> **Trace**: In-scope "retry/backoff 기준" → D4 (WAF-REL05-C1~C5, SPRING-RETRY-C1/C2, AWS-JITTER-C1~C5 사례).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: retry carrier 선택 (Spring Retry vs Resilience4j vs 자체 구현) — Spring Retry 는 maintenance mode 진입 (SPRING-RETRY-C4: "superseded by Spring Framework 7"), 사용자 trade-off 로 carrier 확정 전까지 vocabulary 만 SSOT 로 고정.
|
||||
> - **소비자-활성화 계약 (2026-06-13)**: `job.retry.total`/`job.dlq.total` recorder 와 `JOB_TIMEOUT`/`JOB_DEAD_LETTER` 코드는 이 branch 가 *제공*(SSOT)하되 *활성화*는 **소비자 branch** 책임 — 1차 소비자 = [[raw/branch-notes/feature-domain-event-outbox-contract]] publisher 의 발행 소진(exhaustion) 경로. 따라서 이 branch 에서 recorder 가 live-invoke 되지 않는 것은 "미구현"이 아니라 "소비자 대기". **rot 방지 가드 권고**: outbox branch 에 "발행 소진 시 `job.dlq.total` invoke + `JOB_DEAD_LETTER` emit" contract test 를 둬 recorder 가 영원히 안 불리는 dead-contract 차단 — 이 가드 부재가 현 vocabulary 경계의 *유일한 잔여 리스크*.
|
||||
|
||||
| 항목 | 명세 | 상태 |
|
||||
|---|---|---|
|
||||
| retry 실패 코드 | `JOB_TIMEOUT` (TRANSIENT_DEPENDENCY / retryable), DLQ 진입 = `JOB_DEAD_LETTER` (INTERNAL / retryable=false) — `OperationalError` enum + error-codes.yaml 교차검증(BackgroundJobErrorCodeContractTest). NOTE: `JOB_TIMEOUT` http_status=500(503 아님) | `actually-implemented` |
|
||||
| metric recorder | `BackgroundJobMetrics` 가 `job.retry.total{job_name,outcome}`·`job.dlq.total{job_name}` recorder seam 제공 — metrics.yaml 정합. `executor.rejected.total`·`executor.saturation` 은 live-invoke(saturation 경로) | recorder seam `actually-implemented` / job.* live-invoke 는 소비자 대기 |
|
||||
| **소비자 활성화** | recorder seam(`recordRetryOutcome`/`recordDeadLetter`)는 제공만 — 활성화 owner = outbox publisher 발행 소진 경로. **contract test 가드(outbox branch)**: 발행 N회 소진 → `job.dlq.total{job_name}` +1 & status=DEAD_LETTER & `JOB_DEAD_LETTER` emit | seam `actually-implemented` / 소비자 invoke + 가드 `planned` (outbox branch) |
|
||||
| retryable 분류 | non-transient (권한/도메인 규칙/스키마 불일치) → retry 없이 즉시 DLQ (WAF-REL05-C3); retry 는 단일 레이어 원칙 (WAF-REL05-C4) — outbound adapter 의 Resilience4j retry 와 중첩 금지; non-idempotent 작업 retry 금지 (WAF-REL05-C5). **런타임 분류 로직은 carrier 와 함께 미구현** — 현재는 enum `retryable` 정적 플래그만 | 설계 확정 / 런타임 분류 `planned` (carrier 동반) |
|
||||
| backoff 설정 | exp+jitter 는 라이브러리 default 아님 — Spring Retry 라면 `multiplier > 1.0` + `random=true` 명시 (SPRING-RETRY-C2); jitter 종류는 Full Jitter 사례 우세 (AWS-JITTER-C1~C4 — company-case-study, 공식 단정 금지) | `planned` (carrier 동반) |
|
||||
|
||||
### 4. Scheduler / multi-instance lock (D3)
|
||||
|
||||
> **Trace**: In-scope "scheduled job overlap 기준" → D3 (SK-PG-C2, OUTBOX-MIO-C4) + ca-tmpl 코드 ground truth.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — 본 sub-section 은 전부 registry/코드 실측 값.
|
||||
|
||||
| 항목 | 명세 | 상태 |
|
||||
|---|---|---|
|
||||
| @EnableScheduling | `app-bootstrap` `IdempotencyConfig` 에 실재 | `actually-implemented` |
|
||||
| @Scheduled 선례 | `adapter-persistence` `IdempotencyReaper` (`fixedDelayString = "${ca-skeleton.idempotency.reaper-interval:PT10M}"`) | `actually-implemented` |
|
||||
| multi-instance 강제 | `APP_MULTI_INSTANCE_ENABLED=true` 시 `StartupSafetyValidator.REQUIRED_MULTI_INSTANCE_BEANS` 의 `"distributedLockProvider"` bean 부재 → startup fail (exit 72) — 키 owner 는 [[raw/branch-notes/feature-env-driven-runtime-configuration]] D8. lock bean 제공 owner = [[raw/branch-notes/feature-distributed-lock-contract]] D1/D3 (A7 ✅ RESOLVED 2026-06-12) | validator `actually-implemented` / 본 branch consume |
|
||||
| lock provider (delegated) | bean 이름 `distributedLockProvider` 존재만 전제(consume) — 제공 owner = [[raw/branch-notes/feature-distributed-lock-contract]] D1/D3 (기본 `JdbcLockRegistry`, ShedLock *배제*; port `DistributedLockPort`). 검증: `StartupSafetyValidator` bean-presence(exit 72) | owner branch `planned` / 본 branch consume 계약 확정 |
|
||||
|
||||
### 5. Graceful shutdown 예산 계층 (D8)
|
||||
|
||||
> **Trace**: In-scope "shutdown 중 job 처리 기준" → D8 (K8S-POD-LC-C1~C3, SB-GS-C2/C4/C5, EXEC-CS-C1~C4).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 1s cleanup margin 값 — 외부 권고 없음, 사용자 trade-off (executor 종료 후 잔여 리소스 정리 시간 확보).
|
||||
|
||||
```text
|
||||
executor awaitTermination (≤19s)
|
||||
< app shutdown budget (20s — parent project 운영 계약 소유)
|
||||
≤ spring.lifecycle.timeout-per-shutdown-phase (SB-GS-C4; APP_SERVER_SHUTDOWN_TIMEOUT — owner: feature-env-driven-runtime-configuration D2, registry default 30s ⚠ §Audit A1)
|
||||
< terminationGracePeriodSeconds (k8s default 30s — K8S-POD-LC-C1; 초과 시 SIGKILL — K8S-POD-LC-C2)
|
||||
```
|
||||
|
||||
- 신규 요청 차단은 SmartLifecycle earliest phase 의 web server graceful stop 이 선행 (SB-GS-C2/C5) — executor await 는 그 이후 phase.
|
||||
- in-flight job 이 19s 초과 → interrupt → **retry-on-next-startup** (멱등 전제, §Claims To Verify).
|
||||
- **구현/검증 (2026-06-13)**: `AsyncExecutorConfig` 가 `setWaitForTasksToCompleteOnShutdown(true)` + `setAwaitTerminationSeconds(19)` 설정. 검증 2종 — (1) 정확값 핀 `AsyncExecutorConfigTest`(reflection: `awaitTerminationMillis`=19000, `waitForTasksToCompleteOnShutdown`=true — 19s vs 25s 같은 *수치* 계약 보증), (2) **행위 검증 `AsyncGracefulShutdownBehaviorTest`**: in-flight job 이 context close 중 예산 내 drain 완료 + shutdown 후 신규 submit → `RejectedExecutionException` & `JOB_EXECUTOR_REJECTED` 구조화 로그. 자체 관리 `AnnotationConfigApplicationContext` 사용(=동일 `SmartLifecycle`/`DisposableBean` shutdown 경로) — `@SpringBootTest` 는 ① 테스트 중 context close 시 post-test listener 실패 ② application.yml `${SPRING_PROFILES_ACTIVE}` 등 dotenv 의존(bootRun 전용) 때문에 부적합. `locally-verified`.
|
||||
|
||||
## Audit & Findings (2026-06-11 — /branch-spec ground-truth 대조)
|
||||
|
||||
> ca-tmpl registry/코드와 본 노트의 정합 감사 결과. 사용자 작성 결정은 수정하지 않고 권고만 기록.
|
||||
|
||||
- **A1. `SHUTDOWN_BUDGET_DRIFT`** — 본 노트 D8 은 "container-runtime 의 app shutdown **20s**" 를 전제하나, ca-tmpl `docs/registries/env-keys.yaml` 의 `APP_SERVER_SHUTDOWN_TIMEOUT` default 는 **30s** (owner: [[raw/branch-notes/feature-env-driven-runtime-configuration]] D2, validation: `spring_duration_shorthand_le_termination_grace`). 19s await 는 어느 쪽 기준으로도 안전하지만, "20s" 의 출처(parent project 운영 계약)와 registry default 30s 의 관계를 owner branch 와 명문화 권고. 자동 수정하지 않음 (사용자 결정 영역).
|
||||
- **A2. `OUT_OF_BRANCH_SCOPE` 권고 (D9~D12)** — outbox publisher 메커니즘 선택·대안 비교(D9~D12)는 registry 상 outbox 계약 owner 인 [[raw/branch-notes/feature-domain-event-outbox-contract]] 의 결정 영역 (outbox.* metrics 3종 + `outboxLeaderElection` bean 모두 그 branch 소유). 본 branch 의 소유는 retry/DLQ **vocabulary** (D2) 까지. D9~D12 와 §외부 근거/대안 조사의 outbox 부분은 사용자 작성분이므로 보존하되, owner branch 로의 이관을 권고. outbox 노트가 이미 본 branch D4 를 cross-reference 중 (양방향 확인됨).
|
||||
- **A3. `REGISTRY_CONFIRMED`** — 본 노트의 계약 값 전수 registry 대조 통과 (invent 없음): `JOB_EXECUTOR_REJECTED`/`JOB_TIMEOUT`/`JOB_DEAD_LETTER` (error-codes.yaml, owner = 본 branch), `APP_ASYNC_EXECUTOR_CORE_SIZE/MAX_SIZE/QUEUE_CAPACITY` default 10/50/200 (env-keys.yaml — 노트 D7 수치와 일치), `executor.saturation`/`executor.rejected.total`/`job.retry.total`/`job.dlq.total` (metrics.yaml), mdc-keys.yaml foundation 6키 (D6 의 4+2 분류와 일치 — span_id `source: observation_context`, user_principal `propagation: [none]`).
|
||||
- **A4. `RUNBOOK_MISSING`** — error-codes.yaml 이 참조하는 `runbook://job/executor-rejected`·`runbook://job/timeout`·`runbook://job/dead-letter` 의 실제 파일이 `docs/runbooks/` 에 부재 — `documented-only`. 구현 단계에서 작성 필요.
|
||||
- **A5. `CLAIM_PREFIX_FIX`** — D5 행에 일시 기재됐던 `SPRING-OBS-C*` 표기를 실제 raw 파일 prefix `SF-OBS-C1~C4` 로 정정 (2026-06-11 자동조사 중 발생한 표기 불일치).
|
||||
- **A6. `IMPLEMENTATION_STATUS`** — src grep 실측: TaskDecorator / ThreadPoolTaskExecutor bean / `awaitTermination` / ShedLock wiring / outbox 클래스 전부 **미구현** (`planned`). 실구현은 `@EnableScheduling` + `IdempotencyReaper` + `StartupSafetyValidator` 뿐. 본 노트의 계약은 전체적으로 documented-only 단계 — `actually-implemented` 로 표현 금지.
|
||||
- **A7. `LOCK_BEAN_OWNER_UNRESOLVED`** (coverage-auditor 2026-06-11) — `distributedLockProvider` bean 의 제공 결정이 어느 branch 에도 없음. ca-tmpl `StartupSafetyValidator` 주석은 [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] 를 가리키나 그 노트는 "consume only" 로 자기 서술. owner 를 확정해 해당 branch 결정으로 등록하기 전까지 본 branch 는 *bean 존재를 전제로 consume* 만 한다 (multi-instance contract test 는 bean 부재 시 fail 로 이 미확정을 노출).
|
||||
- **✅ RESOLVED (2026-06-12)** — owner 확정: [[raw/branch-notes/feature-distributed-lock-contract]] D1 이 `distributedLockProvider` bean 계약을 소유 (기본 provider = Spring Integration `JdbcLockRegistry`, 그 branch D3). 본 branch 는 consume 관계 유지. ✅ 본 §테스트 계약·§구현 가이드 4 의 FQCN 표기를 `distributedLockProvider` bean(JdbcLockRegistry 기반, port `DistributedLockPort`) 기준으로 **갱신 완료 (2026-06-13)** — ShedLock `net.javacrumbs.shedlock.core.LockProvider` 타입 표기 폐기.
|
||||
- **A8. `STALE_OWNER_FIXED`** (coverage-auditor 2026-06-11) — §엣지·의존 의 MDC 어휘 위임 대상을 `feature-log-management-contract`(consumer 오기) → `feature-operational-error-observability-foundation`(mdc-keys.yaml L4 SSOT 자기 선언) 으로 정정.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- shutdown 중 신규 job enqueue → REJECTED 상태 + `JOB_EXECUTOR_REJECTED` (§테스트 계약과 동일 기대 동작).
|
||||
- in-flight job 19s 초과 → interrupt → retry-on-next-startup (멱등 전제 — 미검증, §Claims To Verify).
|
||||
- interrupt 에 반응하지 않는 blocking call (JDBC 등) → awaitTermination 초과 → SIGKILL 노출 경로 (K8S-POD-LC-C2).
|
||||
- queue drain: `waitForTasksToCompleteOnShutdown(true)` 는 queue 잔여 task 까지 전부 실행 (EXEC-CS-C2/C4) — queue=200 × 평균 job 시간이 19s 를 초과하는 burst 시나리오의 기대 동작 미정의 (§Claims To Verify).
|
||||
- saturation: queue full + max pool 도달 → `RejectedExecutionException` — fire-and-forget `@Async` 호출이면 예외 소실 위험 → async exception 계약으로 흡수 필수.
|
||||
- submit() 경로 예외는 FutureTask 에 래핑되어 uncaught handler 미통과 (SF-TPTE-C5) — async exception test 는 execute()/submit() 두 경로 모두 검증.
|
||||
- non-transient 예외 → retry 없이 즉시 DLQ (WAF-REL05-C3) — retryable 분류기 누락 시 무한 재시도가 아니라 분류 실패로 fail 해야 함.
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-env-driven-runtime-configuration]] 의 D2(`APP_SERVER_SHUTDOWN_TIMEOUT`)·D8(`APP_MULTI_INSTANCE_ENABLED`) 에 의존 — 본 branch 는 consume. shutdown timeout default 변경 시 19s 예산 재검토, multi-instance 키 변경 시 lock 강제 테스트 영향 (⚠ A1 drift).
|
||||
- [[raw/branch-notes/feature-domain-event-outbox-contract]] — 본 branch 의 retry/DLQ vocabulary (D2, D4) 를 consume. vocabulary 변경 시 비차단 전파 알림 필요 (consistency-contract).
|
||||
- [[raw/branch-notes/feature-operational-error-observability-foundation]] — MDC key 어휘(mdc-keys.yaml L4 SSOT 자기 선언) + error Category enum owner (`TRANSIENT_DEPENDENCY`/`INTERNAL` 은 그 branch 계약의 재사용). foundation 키 변경 시 D5/D6 의 copy 대상 재산정. ([[raw/branch-notes/feature-log-management-contract]] 는 같은 어휘의 consumer — owner 아님, coverage-auditor STALE_OWNER 정정 2026-06-11)
|
||||
- `distributedLockProvider` bean — owner = [[raw/branch-notes/feature-distributed-lock-contract]] D1/D3 (A7 ✅ RESOLVED, 기본 `JdbcLockRegistry`). 본 branch 는 multi-instance 시 그 bean 존재를 전제(consume); ca-tmpl `StartupSafetyValidator` 주석의 runtime-health 표기는 stale → owner branch 가 코드 주석 갱신 예정.
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — 20s app shutdown 예산의 소유자. 예산 변경 시 D8 의 19s 도출 무효.
|
||||
|
||||
## 테스트 계약
|
||||
|
||||
- async exception이 조용히 삼켜지면 실패.
|
||||
- executor rejection이 structured log 없이 발생하면 실패.
|
||||
- scheduled job overlap 기준이 없으면 실패.
|
||||
- shutdown 중 job 정책: in-flight job 은 await 예산(≤19s) 내 완료, 초과분은 interrupt 후 retry-on-next-startup. 측정 방법(2026-06-13 정정 — 내장 메커니즘 채택): `setWaitForTasksToCompleteOnShutdown(true)` + `setAwaitTerminationSeconds(19)` 적용 검증(현 `AsyncExecutorConfigTest` 가 `awaitTerminationMillis==19000` reflection 검증). **권고 보강**: reflection(설정값)을 *행위* 검증으로 승급 — 느린 job 제출 → context close → (a) job 이 예산 내 완료, (b) 종료 후 신규 제출은 `JOB_EXECUTOR_REJECTED` 로 거부. (이전판의 `ApplicationListener<ContextClosedEvent>` + ListAppender 명세는 ThreadPoolTaskExecutor 내장 메커니즘 채택으로 폐기 — custom listener 안 씀.)
|
||||
- multi-instance lock = **delegated → [[raw/branch-notes/feature-distributed-lock-contract]]** (D1/D3, 기본 provider = Spring Integration `JdbcLockRegistry`; ShedLock 은 그 branch D3 에서 *배제*). 본 branch 는 *적용처*(D3 scheduler/outbox)로서 provider 존재를 전제로 consume 만. 측정 방법(2026-06-13 정정): `APP_MULTI_INSTANCE_ENABLED=true` 시 ca-tmpl `StartupSafetyValidator` 가 bean 이름 `distributedLockProvider` 존재를 강제(부재 시 exit 72) — 기존 `StartupSafetyValidatorTest` 가 검증. (이전판의 `net.javacrumbs.shedlock.core.LockProvider` 타입 기준은 ShedLock 가정 시절 표기 — owner D3 가 JdbcLockRegistry 로 확정해 폐기.)
|
||||
|
||||
## Async Context Propagation Contract
|
||||
|
||||
> 2026-06-13 구현 정합 갱신 — §구현 기록·D6 와 일치하도록 정정.
|
||||
|
||||
- `TaskDecorator` 1개를 ThreadPoolTaskExecutor 에 등록해 caller→worker thread 로 복사한다:
|
||||
- MDC: submit 시점 **전체 스냅숏 복사**(`MDC.getCopyOfContextMap()`) — foundation 4키(`request_id`/`trace_id`/`correlation_id`/`tenant_id`) 보장, span_id 는 그 시점 MDC 에 있으면 문자열로 동승. worker 종료 시 대칭 복원(풀 스레드 MDC bleed 방지).
|
||||
- Observation **scope** 는 전파하지 않음(context-propagation 라이브러리 미반입). parent-span linkage 필요 시 `ContextPropagatingTaskDecorator` 로 upgrade.
|
||||
- SecurityContext / principal: **기본 비전파**. 필요 use case 만 `DelegatingSecurityContextTaskExecutor` 로 explicit opt-in.
|
||||
- executor 등록 시 TaskDecorator 미설정이면 fail — 현재는 decorator 를 executor @Bean 의 필수 생성자 의존성으로 강제(이 bean 한정). 전역 강제는 §구현 가이드 2 의 ArchUnit 가드 권고 참조.
|
||||
- 테스트 계약:
|
||||
- @Async 메서드 안에서 `MDC.get("request_id")`/`trace_id`/`correlation_id`/`tenant_id` 가 caller 와 동일.
|
||||
- **principal 은 worker 로 전파되지 *않는다*** (negative 검증) — opt-in executor 사용 시에만 전파.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| TaskDecorator 1개로 MDC 4-key (request_id/trace_id/correlation_id/tenant_id) + Observation context 가 caller→worker 정확히 전파됨 | 공식 메커니즘 근거는 확보 (SF-OBS-C1/C2, MICRO-CP-C1) — 그러나 공식 doc 은 메커니즘만 보장, ca-tmpl 구성에서의 실 전파는 미검증 | `@Async` 메서드에서 `MDC.get("request_id")`, Micrometer `Observation.getCurrent()` 가 caller thread 와 동일한지 contract test | `needs-confirmation` |
|
||||
| SLF4J-Micrometer tracing bridge 활성 시 span_id 가 worker thread MDC 에 자동 기입됨 (D6 전제) | bridge 공식 doc 인용 미확보 — SF-OBS-C4 는 이 페이지 범위 밖이라 명시 | bridge 활성 상태에서 `@Async` 내 `MDC.get("span_id")` non-null contract test + bridge 공식 doc 추가 아카이브 | `needs-confirmation` |
|
||||
| Executor pool default (core=10, max=50, queue=200) + AbortPolicy 가 ca-tmpl 부하 프로파일에 적합 | 정량 trade-off 의 외부 reference 부재 (D7 — 구조만 공식 확보) | 부하테스트 (k6 / JMeter) 로 saturation 임계 측정 + rejection log 확인 | `planned` |
|
||||
| Graceful shutdown 19s 내 executor await termination 이 실제 in-flight job 완료 보장 | k8s/Spring 공식 메커니즘 근거 확보 (K8S-POD-LC-C1/C2, EXEC-CS-C2/C3) — 잔여: job p99 실행 시간 < 19s 미측정 + queue drain 시간(queue=200 × 평균 job 시간) 미계산 | `ApplicationListener<ContextClosedEvent>` 등록 + ListAppender 로 shutdown phase reject log 검증 + job p99 측정 | `planned` |
|
||||
| Multi-instance 환경에서 ShedLock 또는 DB advisory lock 이 publisher claim consistency 보장 | SKIP LOCKED 는 lock contention 회피만 보장 (`SK-PG-C2`), 순서/claim consistency 별도 | `@TestPropertySource("app.multi-instance.enabled=true")` 테스트에서 `LockProvider` bean 존재 verify | `needs-confirmation` |
|
||||
| Exponential backoff + jitter + max=3 + DLQ 가 ca-tmpl 도메인 retry 성공률에 적합 | max=3 은 Spring Retry default 와 일치 (SPRING-RETRY-C1) 하나 ca-tmpl 도메인 적합성은 미측정 (WAF-REL05-C2 의 use-case 별 조정 권고) | DLQ 진입률 metric (`job.dlq.total`) 측정 + max attempts 조정 실험 | `planned` |
|
||||
| Debezium CDC 가 본 프로젝트 lag SLO 충족 (수 초 lag 허용 가정 깨질 때) | `OUTBOX-DBZ-C2` 는 "polling 비용 회피" 까지만 보장, lag 수치는 침묵 | Debezium PoC + WAL lag metric 측정 (활성화 시) | `needs-confirmation` |
|
||||
| SKIP LOCKED polling 의 순서 보장 안 됨이 ca-tmpl 도메인에 허용 가능 | `SK-PG-C2` 의 "inconsistent view" 경고 | partition key 별 단일 publisher 시 순서 보장되는지 contract test + 도메인 검토 | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 2026-06-11)
|
||||
|
||||
> `/coverage` 생성물 — 손으로 유지하지 않는다. 기준: `rules/coverage-gate.md`. 판정: **Covered** (Blocking 0 / Should-fix 2 — A7·A8 로 처리 / Advisory 1 — A4 runbook).
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|--------|------|-------|--------|------|
|
||||
| async exception handling (삼켜진 예외 금지, structured log + metric + runbook link) | covered-here | — | — | Decisionized Work Items "async exception" 행; D5; 테스트 계약 1항 |
|
||||
| executor saturation/rejection (bounded queue 강제, AbortPolicy default, rejection log) | covered-here | — | — | D7; §구현 가이드 1; `executor.rejected.total` (metrics.yaml, owner = 본 branch) |
|
||||
| scheduled job overlap (single-instance 기본, multi-instance 시 distributed lock) | covered-here | — | — | D3; §구현 가이드 4 |
|
||||
| job id / correlationId (MDC 4-key + Observation context) | covered-here | — | — | D5, D6; §구현 가이드 2; mdc-keys.yaml `propagation: [async]` 대조 |
|
||||
| retry / backoff (exp+jitter, max=3, DLQ after exhausted) | covered-here | — | — | D4; §구현 가이드 3; `JOB_TIMEOUT`/`JOB_DEAD_LETTER` (error-codes.yaml) |
|
||||
| shutdown 중 job 처리 (await ≤ 19s, retry-on-next-startup) | covered-here | — | — | D8; §구현 가이드 5 |
|
||||
| background failure logging (log/metric/alert 핵심 계약) | covered-here | — | — | Decisionized Work Items "async exception" 행; §진행 중 메모; JOB_* 3코드의 runbook_link |
|
||||
| `APP_ASYNC_EXECUTOR_*` env 키 3종 / JOB_* error 코드 3종 / executor.*·job.* 메트릭 4종 (registry 본 branch 소유분) | covered-here | — | — | §Audit A3 (registry 전수 대조) |
|
||||
| `APP_MULTI_INSTANCE_ENABLED`·`APP_SERVER_SHUTDOWN_TIMEOUT` env 키 정의 | delegated | [[raw/branch-notes/feature-env-driven-runtime-configuration]] (D8, D2) | OK | §엣지·의존 위임 링크 |
|
||||
| outbox publisher 메커니즘 (D9~D12) | delegated | [[raw/branch-notes/feature-domain-event-outbox-contract]] | OK | §Audit A2 이관 권고 + 양방향 cross-ref 확인 |
|
||||
| `distributedLockProvider` bean 제공 결정 | delegated | [[raw/branch-notes/feature-distributed-lock-contract]] (D1) | OK | §Audit A7 ✅ RESOLVED 2026-06-12 — [[raw/branch-notes/feature-distributed-lock-contract]] D1/D3 |
|
||||
| MDC key 어휘 (mdc-keys.yaml) | delegated | [[raw/branch-notes/feature-operational-error-observability-foundation]] | OK (A8 정정 완료) | mdc-keys.yaml L4 SSOT 자기 선언 |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 2026-06-13: `:app-bootstrap:test` 전체 실행 시 선재(pre-existing) ArchUnit 실패 1건 발견 — `CleanArchitectureTest#outbound_adapter_method_returns_only_domain_or_primitives` (`OutboundHttpSettings.retry()`/`circuitBreaker()` 중첩 record accessor 가 B7 규칙 위반). `git stash -u` baseline 에서도 동일 실패 → 본 background-job 작업과 무관, owner 는 feature-outbound-http-client-baseline. 상세·권고 해결 → [[raw/errors/archunit-b7-configprops-nested-record-accessor-outbound-2026-06-13]].
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/outbox-confluent-kafka-connect-smt]]
|
||||
- [[raw/company-tech-blogs/outbox-netflix-domain-events-cdc]]
|
||||
- [[raw/company-tech-blogs/outbox-wix-engineering-debezium]]
|
||||
- [[raw/company-tech-blogs/outbox-woowahan-techblog-pattern]]
|
||||
- [[raw/company-tech-blogs/retry-aws-exponential-backoff-and-jitter]]
|
||||
- [[raw/official-docs/dual-write-antipattern-microservices-io]]
|
||||
- [[raw/official-docs/event-sourcing-vs-outbox-microservices-io]]
|
||||
- [[raw/official-docs/jdk21-threadpoolexecutor-javadoc]]
|
||||
- [[raw/official-docs/kubernetes-pod-lifecycle-termination]]
|
||||
- [[raw/official-docs/lock-shedlock-readme]]
|
||||
- [[raw/official-docs/micrometer-context-propagation-purpose-thread-local-accessor]]
|
||||
- [[raw/official-docs/outbound-resilience4j-vs-spring-retry]]
|
||||
- [[raw/official-docs/outbox-debezium-official-docs]]
|
||||
- [[raw/official-docs/outbox-skip-locked-microservices-io]]
|
||||
- [[raw/official-docs/retry-aws-well-architected-rel05-bp03]]
|
||||
- [[raw/official-docs/retry-spring-retry-readme-backoff-defaults]]
|
||||
- [[raw/official-docs/skip-locked-postgres-docs]]
|
||||
- [[raw/official-docs/spring-boot-graceful-shutdown-reference]]
|
||||
- [[raw/official-docs/spring-boot-task-execution-scheduling-reference]]
|
||||
- [[raw/official-docs/spring-executor-configuration-support-javadoc]]
|
||||
- [[raw/official-docs/spring-framework-observability-context-propagating-task-decorator]]
|
||||
- [[raw/official-docs/spring-framework-threadpooltaskexecutor-javadoc]]
|
||||
- [[raw/official-docs/spring-security-concurrency-delegating-security-context-executor]]
|
||||
- [[raw/official-docs/spring-transactional-event-listener]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: interviews:start -->
|
||||
- [[raw/interviews/async-executor-saturation-context-propagation-2026-06-13]]
|
||||
<!-- GENERATED: interviews:end -->
|
||||
|
||||
<!-- GENERATED: errors:start -->
|
||||
- [[raw/errors/archunit-b7-configprops-nested-record-accessor-outbound-2026-06-13]]
|
||||
<!-- GENERATED: errors:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/spring-async-taskdecorator-bounded-executor-saturation-shutdown-2026-06-13]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
> 2026-06-13 실 구현 단계에서 파생 자료 누적. 아래 errors/interview/blog-topics 는 본 branch 로 upward link 되어 있다.
|
||||
|
||||
### 오류 기록 (본 feature 작업 중 발생)
|
||||
|
||||
- [[raw/errors/archunit-b7-configprops-nested-record-accessor-outbound-2026-06-13]] — 전체 테스트 실행 중 발견한 선재 B7 위반(adapter-outbound 소유, 본 작업 무관).
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- [[raw/interviews/async-executor-saturation-context-propagation-2026-06-13]] — bounded executor·saturation·MDC/도메인 컨텍스트 전파·graceful shutdown·retry metric cardinality 6문항.
|
||||
|
||||
### Blog topics (이 작업에서 나온 글감)
|
||||
|
||||
- [[raw/blog-topics/spring-async-taskdecorator-bounded-executor-saturation-shutdown-2026-06-13]] — @Async TaskDecorator + bounded executor + saturation/shutdown 운영 계약.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
> 이 브랜치를 작업한 날짜들. 양방향 nav 유지.
|
||||
|
||||
- (없음 — documented-only 단계. 실 구현 착수 시 `[[raw/daily-notes/YYYY-MM-DD]]` 형식으로 연결)
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크: (미생성 — 사용자가 직접 커밋/PR)
|
||||
- 리뷰 메모: 3단 리뷰 체인 통과 — ca-architect-sentinel(PASS, 신규 위반 0), ca-spec-reviewer(22/22, plan 텍스트 2건 정정), ca-quality-reviewer(important 1 + minor 1 수정: tautological MDC-clear 테스트 보강, Supplier import).
|
||||
- 머지 결과 / 배포 환경: (미머지 — 구현 git 브랜치 `feature/domain-event-outbox-contract`)
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목: JOB_* error code 3종(registry 교차검증), `AsyncExecutorConfig` bounded executor + AbortPolicy saturation, `BackgroundJobMetrics` 4 메트릭 recorder, `ScheduledJobOverlapPolicyTest` overlap 규칙, runbook 3종.
|
||||
- `locally-verified` 항목: `AsyncContextTaskDecorator` MDC+도메인 컨텍스트 전파(submit-time 캡처·대칭 복원), D8 awaitTermination 19s, async 예외 2경로(submit/execute) 미삼킴.
|
||||
- `prod-verified` 항목: (없음 — 로컬 검증까지)
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned): retry **carrier**(Spring Retry/Resilience4j/자체 — §3 UNSUPPORTED_IMPL, vocabulary 만 고정), executor 수치 core=10/max=50/queue=200 부하 적합성(`planned`, 부하테스트 미실시), Observation **scope** 전파(라이브러리 미반입 — MDC 문자열까지만), SecurityContext principal 자동 전파(opt-in 문서화만).
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-frontend-operational-contract/branch-notes/feature-boundary-mapper-viewmodel-contract.md
|
||||
@@ -0,0 +1,281 @@
|
||||
---
|
||||
title: branch / feature-boundary-mapper-viewmodel-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-boundary-mapper-viewmodel-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton-frontend, ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-frontend-operational-contract.md]
|
||||
tags: [branch, ca-skeleton, frontend, mapper, react, clean-architecture]
|
||||
created: 2026-07-18
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-014
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-frontend-operational-contract
|
||||
work_item: WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-014
|
||||
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-VALIDATION-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-ARCHITECTURE-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-006]
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: 6a29a98487f6cf6afb2a40f0dd7b31f4f895e00a2b536821ce0de9fc6aded104
|
||||
imports: [FE-OC-002@1, FE-OC-007@1, FE-OC-008@1, FE-OC-024@1, FLOW-FE-RESP-001@1, FLOW-FE-RESP-002@1, FLOW-FE-RESP-003@1, FLOW-FE-RESP-004@1, FLOW-FE-RESP-005@1, FLOW-FE-RESP-006@1, FLOW-FE-RESP-008@1]
|
||||
---
|
||||
|
||||
# branch: feature-boundary-mapper-viewmodel-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 TODO·결정·진행 기록. 현재는 `/branch-spec` 로 채운 `planned` 설계 단계다 (frontend 코드 저장소 아직 없음 — 모든 구현 주장은 `planned`).
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-frontend-operational-contract]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: raw DTO direct use가 차단되고 mapper negative fixture가 실패한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-VALIDATION-001@1` | boundary runtime validation은 Zod schema로 수행한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-ARCHITECTURE-001@1` | domain, application, presentation, adapters, bootstrap 책임을 분리한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
이 브랜치는 hub §20 기준 **Primary contract owner 가 없는 기여(contribute) 브랜치**다. project-wide 계약 `FE-OC-007`(경계에서 JSON envelope·payload 를 runtime schema 로 검증) 과 `FE-OC-024`(sample 은 제거 가능한 contract fixture) 의 교집합인 **"raw DTO 직접 사용 금지 → boundary mapper 가 application model 을 생산하고 application 이 view-model 로 투영"** 책임을, 되묻지 않고 코드를 쓸 수 있는 implementation-ready spec 으로 내린다. 근거 축은 hub §4.2/§4.3 Clean Architecture layering(presentation 은 raw API DTO 를 소유·소비하면 안 되고 application 이 view-model 계약을 소유) + §7.3 응답 처리 순서 stage 7 `DTO → application model mapper`(§2.1.4 `FLOW-FE-RESP-007`) + §9.1 async `success` state 의 `view-model render` 요구다. 측정 가능한 완료 조건(hub §20): **raw DTO 직접 사용 금지 + mapper negative fixture**.
|
||||
|
||||
- 이슈: (아직 없음 — 저장소 생성 전)
|
||||
- PR: (아직 없음)
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- raw backend DTO 가 http-adapter 경계를 넘어 application/presentation 으로 흐르지 못하게 하는 **containment 규칙**과 그 경계에 놓이는 **DTO → application model mapper** 의 위치·계약(§7.3 stage 7, `FLOW-FE-RESP-007`).
|
||||
- application 이 소유하는 **view-model 계약**(render-ready shape)의 소유 위치·소비 규칙(§4.2/§4.3/§9.1).
|
||||
- mapper 를 **total/guarded function** 으로 만드는 규칙: mapper 자체 throw → `UNKNOWN_FAILURE` catch-all (§8.2 total function, §8.5 fixture).
|
||||
- 위 규칙을 증명하는 **mapper negative fixture** 와, sample slice 안의 제거 가능한 mapper 시연부(`FE-OC-024` 기여분).
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외 — 다른 owner 브랜치 소유. 여기서 detail 을 재정의하지 않고 owner 로 위임한다.
|
||||
|
||||
- **payload/envelope schema 정의·검증 메커니즘 자체 (Zod `.parse()`, schema 파일)** → `FE-OC-007` owner [[raw/branch-notes/feature-runtime-schema-validation-contract]]. 본 브랜치는 그 검증된 output(validated clone)을 mapper 입력으로 **소비만** 한다.
|
||||
- **normalized failure kind 카탈로그와 `UNKNOWN_FAILURE` 의 정규화 shape** → `FE-OC-008` owner [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]]. 본 브랜치는 mapper throw 를 그 catch-all 로 넘길 뿐, kind 목록을 정의하지 않는다.
|
||||
- **shared HTTP client·응답 envelope 파싱 파이프라인(§7.3 stage 1~6)** → `FE-OC-006` owner [[raw/branch-notes/feature-api-client-response-envelope-contract]].
|
||||
- **import 방향 정적 강제(dependency-cruiser/ESLint restricted import) 규칙 엔진** → `FE-OC-002` owner [[raw/branch-notes/feature-frontend-clean-architecture-layering-contract]] / architecture lint 브랜치. 본 브랜치는 forbidden-import fixture case 만 제공.
|
||||
- **sample feature slice 의 실제 route/page/필드 내용** → `FE-OC-024` owner [[raw/branch-notes/feature-sample-feature-slice-contract-fixture]]. 본 브랜치는 그 slice 안의 mapper stage 만 소유.
|
||||
- **async surface state(`initial-loading`/`empty`/`terminal-error`) 렌더링** → `FE-OC-011` owner [[raw/branch-notes/feature-async-ui-state-contract]].
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/react-ui-library-official]] `#REACT-UI-C1`, `#REACT-UI-C5` | D3 — presentation 이 view-model 을 컴포넌트 props(단방향 데이터 흐름)로 소비한다는 초기 근거. React component 모델·props 전달이 "presentation 은 view-model type 만 import" 규칙과 정합. **간접 근거**(component 모델 일반론이며 mapper 전용 계약은 아님). |
|
||||
| [[raw/official-docs/zod-runtime-schema-validation-official]] `#ZOD-VALID-C3`, `#ZOD-VALID-C4` | D2 — `.parse()` 가 반환하는 "strongly-typed deep clone" 이 mapper 의 입력(검증된 payload)이라는 근거. mapper 는 unvalidated JSON 이 아니라 검증 통과한 clone 만 받는다. `.parse()` 실패 throw 는 검증 계층(FE-OC-007) 소관이며 mapper 실행 전이다. |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §4.2/§4.3, `FE-D009`, `FE-D010` | D1 — presentation 은 raw API DTO 를 소유·import 하면 안 되고 application 이 view-model 계약을 소유(dependency rule). |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §7.3 stage 7 · §2.1.4 `FLOW-FE-RESP-007` | D2 — `DTO → application model mapper` 가 응답 처리 순서 stage 7(검증 stage 4~6 이후, application 결과 반환 stage 8 이전)이라는 위치 근거. stage 7 산출물이 model 이고 view-model 이 아니라는 것도 같은 근거. |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §4.2/§9.1 | D3 — application 이 view-model 계약 소유 + async `success` state 는 `view-model render`. |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §8.2/§8.5 | D4 — normalization 은 total function; mapper 예외는 `UNKNOWN_FAILURE` catch-all 로 흡수하고 raw value 폐기. negative fixture 필수. |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-OC-024`, `FE-D025` | D5 — sample 은 제거 가능한 contract fixture 이며 product import 금지. mapper 시연부는 이 slice 안에 둔다. |
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기. 저장소 미생성이므로 전부 `planned` / `needs-confirmation`.
|
||||
|
||||
- [ ] http-adapter 경계에서 raw DTO 가 application/presentation 으로 새지 않게 하는 containment 규칙과 mapper 위치(stage 7) 확정 — 등급: `planned`
|
||||
- [ ] application 소유 view-model 계약(render-ready shape)의 위치·소비 규칙 명세 — 등급: `planned`
|
||||
- [ ] mapper 를 guarded total function 으로 구현(예외 → `UNKNOWN_FAILURE` 위임) — 등급: `planned`
|
||||
- [ ] mapper negative fixture(예외 유발 → `UNKNOWN_FAILURE` 기대) + presentation-imports-raw-DTO forbidden fixture case 작성 — 등급: `planned`
|
||||
- [ ] sample slice 안 mapper 시연부가 제거 가능하고 product import 0건임을 확인 — 등급: `needs-confirmation`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- hub §20 상 본 브랜치는 Primary owner 없음 + `FE-OC-007`·`FE-OC-024` 기여, dependency = [[raw/branch-notes/feature-runtime-schema-validation-contract]] (검증된 payload 를 stage 7 로 넘겨받음). 그 sibling 노트가 이미 stage 7 mapper 를 본 브랜치로 위임(`FE-OC-007`·`FE-OC-024` 기여)하고 있어 정합 확인됨 — drift 없음.
|
||||
- ~~hub 내부 경미한 표현 불일치: §7.3 은 stage 7 을 "DTO → view-model mapper"(adapter 경계) 로, §4.2/§4.4 는 adapter 가 "validated model" 을 반환하고 application 이 "view-model 계약" 을 소유한다고 기술.~~ → **해소됨(2026-07-21)**: hub §7.3 stage 7 이 `DTO → application model mapper` 로 정정되고 "view-model 투영은 application 소유" 가 본문에 명시됐다. 같은 사실이 hub §2.1.4 Flow Stage Registry `FLOW-FE-RESP-007` 의 Invariants 로 고정되어, 본 브랜치가 채택한 2-stage 해석이 이제 hub 결정이다.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 아래 Decision Evidence Map 의 산문형 요약. 각 결정의 근거는 Sources 표 및 hub 참조.
|
||||
|
||||
- 2026-07-18: **raw DTO containment** — raw backend DTO 는 http-adapter 경계를 넘지 못하고, presentation/use-case 는 application 소유 view-model 만 소비한다. 이유: hub §4.2/§4.3 dependency rule(presentation MUST NOT own raw DTO). 검토한 대안: presentation 이 DTO 에서 직접 파생 — layering(`FE-D009`/`FE-D010`) 위반이라 기각.
|
||||
- 2026-07-18: **mapper 위치 = stage 7** — DTO → application model mapper 는 §7.3 처리 순서 stage 7(schema 검증 이후, 결과 반환 이전)에 놓이며 입력은 검증된 clone 이다. 대안: 검증 전 raw JSON 매핑 — 검증 우회라 기각. (2026-07-21 정정: stage 7 산출물은 model 이고 view-model 이 아니다 — hub §7.3 · §2.1.4 `FLOW-FE-RESP-007`.)
|
||||
- 2026-07-18: **view-model 소유 = application** — view-model 계약은 application 이 소유(`application/view-models/`), presentation 은 type 만 import. 대안: presentation-local view-model — `FE-D010`(application-owned contract) 위반이라 기각.
|
||||
- 2026-07-18: **mapper = total/guarded function** — mapper 예외는 presentation 으로 throw 되지 않고 `UNKNOWN_FAILURE` catch-all 로 흡수(§8.2). negative fixture 로 증명. 대안: 예외 전파 — §8.2 total function 요구 위반이라 기각.
|
||||
- 2026-07-18: **mapper 시연부 = 제거 가능한 sample fixture** — mapper 데모 + fixture 는 `sample/contract-fixture/` 안에 두고 product 는 import 금지(`FE-OC-024`/`FE-D025`). 대안: 공용 product util — sample 제거 smoke 위반이라 기각.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | raw API DTO 는 http-adapter 경계를 넘지 못하고 presentation/use-case 는 application 소유 view-model 만 소비 (raw DTO 직접 사용 금지) — `FE-OC-007`·`FE-OC-024` 기여 | 스켈레톤의 모든 read/query 응답에 항상 적용되는 invariant. 대안(presentation 이 DTO 에서 직접 파생)은 layering 결정 `FE-D009`/`FE-D010` 가 뒤집힐 때만 가능하고 그건 `FE-OC-002` owner 브랜치 소관 — 본 브랜치에서 바꾸지 않음 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §4.2/§4.3, `FE-D009`, `FE-D010`, `FE-OC-007` | project-decision | DTO→model 경계를 물리적으로 adapter 에 둘지 application 에 둘지 미세 미확정 → 구현 §1 |
|
||||
| D2 | DTO → application model mapper 는 §7.3 처리 순서 stage 7(검증 stage 4~6 이후, 결과 반환 stage 8 이전)에 위치하고 입력은 검증된 payload(deep clone); view-model 투영은 이 단계가 아니라 application 소유 | success branch(검증 통과)일 때만 mapper 실행. 검증 실패면 mapper 실행 안 하고 `SCHEMA_MISMATCH`/normalized-failure 경로(FE-OC-008)로 감 — 즉 대안은 "실행 안 함" | `raw/official-docs/zod-runtime-schema-validation-official.md#ZOD-VALID-C3`; [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §7.3, `FE-OC-007` | official-doc + project-decision | stage 7 라벨이 dependency sibling 과 일치(확인됨). 검증계층 output 형태 변경 시 mapper 입력 계약 재확인 필요 |
|
||||
| D3 | view-model 계약(render-ready shape)은 application 이 소유(`application/view-models/`); presentation 은 view-model type 만 import 하고 async `success` state 가 이를 render | 모든 slice 에서 application 소유가 default. 대안(presentation-local 또는 adapter 소유 view-model)은 `FE-D010`(application-owned contract) 를 layering owner 가 개정할 때만 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §4.2/§9.1, `FE-D010`; `raw/official-docs/react-ui-library-official.md#REACT-UI-C5` | project-decision + official-doc | adapter 의 validated-model 과 application 의 view-model 2-stage 분리 세부 미확정 → 구현 §1 |
|
||||
| D4 | mapper 는 total/guarded function — 예외(누락/renamed 필드, non-Error throw)는 presentation 으로 전파되지 않고 `UNKNOWN_FAILURE` catch-all 로 흡수(raw value 폐기) | mapper 예외는 항상 `UNKNOWN_FAILURE` 로. mapper throw 가 presentation 에 도달하도록 허용하는 조건은 없음(N/A) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §8.2, §8.5, `FE-OC-008` | project-decision | `UNKNOWN_FAILURE` 정규화 shape 자체는 `FE-OC-008` owner 소유 → 위임 |
|
||||
| D5 | mapper 시연부 + negative fixture 는 제거 가능한 sample slice(`sample/contract-fixture/`) 안에 두고 product feature 는 import 금지 | fixture 는 항상 sample 안. mapper 가 실제 product feature 에 필요해지면 sample 밖으로 graduate 하고 그 feature 브랜치가 소유(대안) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-OC-024`, `FE-D025`, §4.2 | project-decision | sample slice 내용/route 는 `FE-OC-024` owner 소유 → 위임; 본 브랜치는 mapper stage 만 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> `planned` blueprint (frontend 저장소 없음). CLAUDE.md §15.5 3-rule 준수: R1 Trace 필수, R2 UNSUPPORTED_IMPL_DECISION, R3 OUT_OF_BRANCH_SCOPE 정제. 경로는 hub §4.6 Planned directory blueprint + §5.1 에서 도출된 `planned` anchor.
|
||||
|
||||
### 1. 경계 배치 & mapping 파이프라인 (planned)
|
||||
|
||||
> **Trace**: D1 + D2 + D3 → hub §4.2/§4.3/§4.6, §7.3 stage 7, `FE-OC-007`. raw DTO 는 adapter 에서 멈추고, 검증된 clone 이 model 로, model 이 view-model 로 이어진다.
|
||||
>
|
||||
> - **(a) 2-stage 매핑 — 근거 있는 결정(2026-07-21 확정)**: mapper 는 stage 7 에서 `application model` 까지만 만들고 view-model 투영은 `application/view-models/` 가 소유한다. 근거: hub §7.3 stage 7 + §2.1.4 `FLOW-FE-RESP-007@1`(Invariants: "이 단계 산출물은 model 이고 view-model 이 아니다"). 본 브랜치가 임의로 고른 trade-off 가 아니라 hub 가 결정한 계약이므로 `UNSUPPORTED_IMPL_DECISION` 라벨을 뗀다.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (b) mapper 모듈 파일 경로·명명(`src/adapters/http/<op>-model-mapper.js`, `src/application/view-models/<slice>-view-model.js`)은 hub §4.6 이 디렉터리(`adapters/http/`, `application/view-models/`)만 고정하고 파일명은 미규정 — trade-off: op/slice 접미사 convention 을 임의 채택(저장소 생성 시 조정 가능).
|
||||
|
||||
| 파이프라인 단계 | 입력 | 출력 | 소유 layer (planned 경로) | 규칙 |
|
||||
|---|---|---|---|---|
|
||||
| raw DTO 수신 | backend 응답 body | (경계 내부에서만 존재) | `adapters/http/` | raw DTO 는 이 layer 밖으로 반환·재노출 금지 |
|
||||
| schema 검증 | raw DTO | validated clone | `adapters/http/` (검증 메커니즘은 `FE-OC-007` owner 위임) | 검증 통과분만 다음 단계로 |
|
||||
| model 매핑 (2-stage 中 1) | validated clone | domain/application model | `adapters/http/` | validated payload → application-facing model |
|
||||
| view-model 투영 (2-stage 中 2) | application model | view-model | `application/view-models/` | render-ready shape 생산; raw status code·DTO 필드 1:1 노출 금지 |
|
||||
| 소비 | view-model | 렌더 | `presentation/` | view-model type 만 import (§4.3), raw DTO schema import 금지 |
|
||||
|
||||
### 2. mapper 함수 계약 (planned)
|
||||
|
||||
> **Trace**: D2 + D4 → hub §7.3 stage 7, §8.2 total function, `raw/official-docs/zod-runtime-schema-validation-official.md#ZOD-VALID-C3`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: mapper 함수 signature/형태(순수 함수 `mapToModel(validatedPayload) → model` vs 클래스) 는 hub 가 미규정 — **순수 함수 채택**, trade-off: 테스트·treeshake 용이하나 stateful 전처리가 필요해지면 재검토. field 투영 방식(explicit allowlist 매핑 vs spread) 도 미규정 — **explicit 매핑 채택**, trade-off: 새 필드가 자동 노출되지 않아 안전하나 필드 추가 시 수기 갱신 필요.
|
||||
|
||||
- 입력: schema 검증을 통과한 payload(= `.parse()` 의 deep clone, `#ZOD-VALID-C3`). unvalidated JSON 을 입력으로 받는 경로 없음.
|
||||
- 출력: application model(성공) **또는** 정규화 실패로의 위임(§8.2). mapper 는 실패를 직접 만들지 않고 catch-all 로 넘긴다. view-model 투영은 이 단계가 아니라 `application/view-models/` 소유(2-stage 中 2).
|
||||
- guard: mapper 본문은 예외 안전 경계(try 경로) 안에서 실행되어 예외/누락 필드/비-Error throw 시 raw value 를 폐기하고 `UNKNOWN_FAILURE` 로 흡수(§8.2 마지막 문단, §8.5). presentation 으로 throw 통과 금지.
|
||||
|
||||
### 3. view-model shape 규칙 (planned)
|
||||
|
||||
> **Trace**: D3 → hub §4.2, §9.1. view-model 은 render-ready 이며 정규 shape 은 async success 렌더의 입력.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 일반 shape convention(중첩 DTO flatten, 날짜/숫자 포맷팅, optional 필드 부재 표현) 은 hub 가 원칙만 두고 detail 미규정 — **"raw status/DTO 필드명 비노출 + optional 부재는 throw 대신 안전 default/absent 표기" 원칙만 고정**, trade-off: 구체 포맷 규칙은 sample view-model 이 생길 때 확정.
|
||||
|
||||
- view-model 은 raw HTTP status·backend error code·DTO 필드명을 그대로 노출하지 않는다(§8.1/§8.2 원칙과 정합: raw body/status 로 UI 분기 금지).
|
||||
- **OUT_OF_BRANCH_SCOPE**: sample slice 의 **구체 view-model 필드 목록**은 `FE-OC-024` owner [[raw/branch-notes/feature-sample-feature-slice-contract-fixture]] 소유 — 여기서 필드를 열거하지 않고 그 브랜치로 위임.
|
||||
|
||||
### 4. negative fixture & 강제 (planned)
|
||||
|
||||
> **Trace**: D4 + D5 → hub §8.5, §15.2, `FE-OC-024`, `FE-OC-008`. 규칙이 실제 동작함을 deliberately failing fixture 로 증명.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 테스트 파일 경로·명명(`tests/unit/mapper-throws-maps-to-unknown-failure.test.js` 등)과 harness 는 hub 가 test stack(`FE-D022` Vitest+RTL+MSW) 만 고정하고 파일명 미규정 — **Vitest unit 채택**, trade-off: 저장소 생성 시 test-taxonomy 브랜치 convention 에 맞춰 조정.
|
||||
|
||||
| Fixture | 목적 | 기대 결과 | 소유/위임 |
|
||||
|---|---|---|---|
|
||||
| mapper 강제 throw(누락 필드/비-Error) | mapper total function 증명 | `UNKNOWN_FAILURE` 반환, raw value·stack 비노출 | 본 브랜치 소유(§8.5 "thrown non-Error object, symbol, or mapper exception → UNKNOWN_FAILURE") |
|
||||
| presentation 이 raw DTO schema import | raw DTO 직접 사용 금지 강제 증명 | architecture gate FAIL | fixture case 제공(본 브랜치) + 강제 엔진은 `FE-OC-002` owner 위임 [[raw/branch-notes/feature-frontend-architecture-enforcement-lint-contract]] |
|
||||
| sample 제거 후 product 빌드 | mapper 시연부가 제거 가능 fixture 임을 증명 | product import 0건, smoke PASS | `FE-OC-024` owner 위임 [[raw/branch-notes/feature-sample-feature-slice-contract-fixture]] |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- mapper 가 **valid-but-empty payload** 수신(검증은 통과했으나 빈 결과) → application model 은 정상 생산하되 async `empty` state 로 표현(렌더 판단은 `FE-OC-011` owner 위임, mapper 는 throw 하지 않음).
|
||||
- mapper 가 **예상외 추가 필드** 수신 → 실패 아님. explicit allowlist 투영이므로 추가 필드는 무시(검증계층이 이미 shape 통과시킴).
|
||||
- mapper **자체 throw**(누락 필드, `null` 접근, non-Error throw) → raw value 폐기 후 `UNKNOWN_FAILURE`(§8.2). presentation 으로 throw 통과 경로 없음.
|
||||
- **nested optional 필드 부재** → application model 은 안전 default/absent 로 표기, throw 금지.
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-runtime-schema-validation-contract]] `FE-OC-007` 에 의존 — 검증된 payload(stage 6 output)를 mapper 입력으로 consume. 그 검증 output 형태가 바뀌면 mapper 입력 계약 영향.
|
||||
- [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] `FE-OC-008` 에 의존 — mapper throw 흡수 대상인 `UNKNOWN_FAILURE` 정규화 shape 을 consume.
|
||||
- [[raw/branch-notes/feature-api-client-response-envelope-contract]] `FE-OC-006` 에 의존 — mapper 가 꽂히는 §7.3 처리 순서 파이프라인(stage 1~8)을 소유.
|
||||
- [[raw/branch-notes/feature-frontend-clean-architecture-layering-contract]] `FE-OC-002` 에 의존 — raw DTO 를 presentation 에서 금지하는 import 규칙 소유(본 브랜치는 fixture case 제공).
|
||||
- [[raw/branch-notes/feature-sample-feature-slice-contract-fixture]] `FE-OC-024` 에 기여 — mapper 시연부를 그 sample slice 안에 둠.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| mapper 자체 throw 가 실제로 `UNKNOWN_FAILURE` 로 라우팅되고 raw value/stack 을 흘리지 않는다 | 저장소·mapper 코드 없음; §8.2 는 원칙만 규정 | mapper negative fixture(Vitest unit) — 강제 throw → `UNKNOWN_FAILURE` 단언, stack 비노출 assert (hub §8.5 fixture) | `needs-confirmation` |
|
||||
| presentation 의 raw DTO schema import 가 architecture gate 를 실제로 FAIL 시킨다 | 정적 강제 엔진 미구현 | forbidden-import fixture(dependency-cruiser/ESLint) — 강제 엔진은 `FE-OC-002` owner, fixture case 는 본 브랜치 | `needs-confirmation` |
|
||||
| 2-stage 매핑(adapter validated-model → application view-model)이 중복 할당 없이 테스트 가능하다 | 2-stage 자체는 hub 결정(§7.3 · `FLOW-FE-RESP-007`)이며 남은 불확실성은 hop 추가에 따른 중복 할당·성능뿐 | 저장소 생성 후 mapper 단위 테스트 + 성능/할당 프로파일로 확인 | `planned` |
|
||||
| view-model 에 raw status/DTO 필드 leakage 가 없다 | sample view-model 필드 미확정(다른 브랜치 소유) | sample view-model 확정 후 component/unit 테스트로 raw status·backend code 비노출 assert | `planned` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 없음 — `planned` 설계 단계
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: flow:start -->
|
||||
### 가져온 흐름 단계
|
||||
|
||||
| Stage Ref | Order | Owner | Input | Action | Output |
|
||||
|---|---:|---|---|---|---|
|
||||
| `FLOW-FE-RESP-001@1` | 1 | [[raw/branch-notes/feature-api-client-response-envelope-contract]] | HTTP 요청 | transport 완료 대기 | raw Response |
|
||||
| `FLOW-FE-RESP-002@1` | 2 | [[raw/branch-notes/feature-api-client-response-envelope-contract]] | raw Response | content-type 기대값 검사 | 본문 판독 가능 Response |
|
||||
| `FLOW-FE-RESP-003@1` | 3 | [[raw/branch-notes/feature-api-client-response-envelope-contract]] | 본문 판독 가능 Response | JSON parse | unvalidated JSON |
|
||||
| `FLOW-FE-RESP-004@1` | 4 | [[raw/branch-notes/feature-runtime-schema-validation-contract]] | unvalidated JSON | envelope 공유 스키마 검증 | discriminated envelope |
|
||||
| `FLOW-FE-RESP-005@1` | 5 | [[raw/branch-notes/feature-runtime-schema-validation-contract]] | discriminated envelope | success/failure 분기 검증 | 분기 확정 envelope |
|
||||
| `FLOW-FE-RESP-006@1` | 6 | [[raw/branch-notes/feature-runtime-schema-validation-contract]] | 분기 확정 envelope | payload per-operation 스키마 검증 | 검증된 payload(deep clone) |
|
||||
| `FLOW-FE-RESP-008@1` | 8 | [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] | application model 또는 실패 신호 | 정규화된 결과 반환 | application result 또는 normalized failure |
|
||||
<!-- GENERATED: flow:end -->
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
| `FE-OC-002@1` | [[raw/branch-notes/feature-frontend-clean-architecture-layering-contract]] | `domain <- application <- presentation` 의존 방향과 application-owned output port를 MUST 지킴 | import 참조로 적용 |
|
||||
| `FE-OC-007@1` | [[raw/branch-notes/feature-runtime-schema-validation-contract]] | JSON envelope와 payload는 boundary에서 runtime schema를 MUST 통과 | import 참조로 적용 |
|
||||
| `FE-OC-008@1` | [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] | 모든 failure는 stable frontend error kind로 MUST 정규화하고 raw body·stack을 UI에 노출하면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-024@1` | [[raw/branch-notes/feature-sample-feature-slice-contract-fixture]] | sample은 contract fixture이며 production feature가 의존하면 안 됨 | import 참조로 적용 |
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
### Sub-branches (세부 작업)
|
||||
|
||||
- 없음 — scaffolding 단계
|
||||
|
||||
### 오류 기록 (이 branch 작업 중 발생)
|
||||
|
||||
- 없음 — scaffolding 단계
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- 없음 — scaffolding 단계
|
||||
|
||||
### 강의 (이 작업을 위해 학습한 강의)
|
||||
|
||||
- 없음 — scaffolding 단계
|
||||
|
||||
### job-posting tie-ins (이 작업에서 파생된 글감)
|
||||
|
||||
- 없음 — scaffolding 단계
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- 없음 — scaffolding 단계
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크: TODO
|
||||
- 리뷰 메모: TODO
|
||||
- 머지 결과 / 배포 환경: TODO
|
||||
- **wiki 추출 대상**: 없음 — `planned` 단계(구현 증거 생성 후 재평가)
|
||||
- **추출하지 않을 항목**: 현재 전 항목 `planned` — 외부 산출물 파생 금지
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-boundary-validation-mapping-contract.md
|
||||
@@ -0,0 +1,477 @@
|
||||
---
|
||||
title: branch / feature-boundary-validation-mapping-contract
|
||||
source_type: branch-note
|
||||
status: verified
|
||||
branch: feature-boundary-validation-mapping-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
tags: [branch, ca-skeleton, validation, mapper, boundary]
|
||||
created: 2026-05-21
|
||||
last_reviewed: 2026-06-04
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
last_implementation_pass: 2026-05-29 (4th pass — Forbidden 정적 강제 + B5 sample 보강 + 문서 구현 가이드)
|
||||
ingest_note: "2026-06-04 /ingest — ca-tmpl @fccb033 ground-truth 대조 후 verified. wiki/projects/ca-tmpl/boundary-validation-mapping.md + wiki/concepts/boundary-validation-and-dto-mapping.md 추출. 대조 결과: controller-return-type / valid_cascade_depth ArchUnit rule 은 노트의 planned 표기와 달리 fccb033 에 실제 구현됨(actually-implemented 로 격상). MappingException 위치는 노트 errors 로그의 application.exception 이 아니라 fccb033 에서 shared.error. sample 은 fccb033 에 이미 sample-portfolio(WorkLog), wire 테스트는 WorkLogControllerWireTest. ./gradlew test verifyCleanArchitectureDependencies → 126 tests / 0 failures."
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-002
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-002
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MAPPING-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: 4c15ac1bd65a18209652e97e9c30583cf8326a361f4979f7fc588e0ab66cb67a
|
||||
---
|
||||
|
||||
# branch: feature-boundary-validation-mapping-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — request/application/domain/response/filter 경계의 validation과 mapper 계약을 정의합니다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (§<관련 섹션>) 의 결정/근거/금지 사항을 정제한다.
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/github-api-error-format]]
|
||||
- [[raw/company-tech-blogs/stripe-error-format]]
|
||||
- [[raw/company-tech-blogs/toss-payments-error-format]]
|
||||
- [[raw/official-docs/arch-acl-microsoft-pattern]]
|
||||
- [[raw/official-docs/google-api-error-format]]
|
||||
- [[raw/official-docs/graphql-errors-spec]]
|
||||
- [[raw/official-docs/json-api-errors-spec]]
|
||||
- [[raw/official-docs/patch-json-merge-rfc7396]]
|
||||
- [[raw/official-docs/problem-detail-rfc-7807]]
|
||||
- [[raw/official-docs/runtime-spring-boot-virtual-threads]]
|
||||
- [[raw/official-docs/schema-jackson-polymorphic-deserialization]]
|
||||
- [[raw/official-docs/spring-mvc-rest-exception-handling]]
|
||||
- [[raw/official-docs/spring-problem-detail]]
|
||||
- [[raw/official-docs/validation-jakarta-bean-validation-3.0-spec]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: errors:start -->
|
||||
- [[raw/errors/mapping-exception-location-archunit-catch-2026-05-29]]
|
||||
<!-- GENERATED: errors:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/archunit-jackson-default-typing-cve-2019-14379-block-2026-05-29]]
|
||||
- [[raw/blog-topics/boundary-validation-mapper-responsibility-map-2026-07-02]]
|
||||
- [[raw/blog-topics/identifier-governance-rule-scoping-by-id-kind-2026-06-01]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
> 본 feature branch 는 leaf — 자식 자료 없음. Phase C2 실 코드 작성 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 근거 자료
|
||||
|
||||
- [[raw/official-docs/patch-json-merge-rfc7396]] — PATCH null=deletion IETF normative 근거 (B2 블라인드)
|
||||
- [[raw/official-docs/arch-acl-microsoft-pattern]] — Microsoft Azure Architecture Center ACL 패턴 공식 정의. outbound HTTP 응답 → domain 변환이 mapper 범위 안에 포함된다는 scope 명확화 근거 (블라인드 B7).
|
||||
- [[raw/official-docs/spring-mvc-rest-exception-handling]] — Spring MVC `ErrorResponse` 계약, `ResponseEntityExceptionHandler` 처리 예외 목록, `HttpMessageNotReadableException` / `MethodArgumentNotValidException` normative 처리 근거 (B3 블라인드 해소)
|
||||
- [[raw/official-docs/validation-jakarta-bean-validation-3.0-spec]] — Jakarta Bean Validation 3.0 normative spec. class-level constraint 목적, group sequence short-circuit, @Valid cascade, TYPE_USE container element 위치 정의 (B4 블라인드 해소)
|
||||
- [[raw/official-docs/runtime-spring-boot-virtual-threads]] — Spring Boot 공식 레퍼런스: `spring.threads.virtual.enabled` semantics + virtual thread 활성화 시 executor/scheduler 전환 근거 (B6 블라인드)
|
||||
- [[raw/official-docs/schema-jackson-polymorphic-deserialization]] — Jackson polymorphic deserialization 보안 지침. `enableDefaultTyping()` 금지 (`@Deprecated` since 2.10) + `PolymorphicTypeValidator` / `BasicPolymorphicTypeValidator` 공식 allowlist API + CVE-2019-14379 gadget chain RCE 근거 (B5 블라인드 해소)
|
||||
|
||||
### 오류 기록 (본 feature 작업 중 발생)
|
||||
|
||||
- [[raw/errors/mapping-exception-location-archunit-catch-2026-05-29]] — B7 outbound ACL 매퍼가 `MappingException` 을 던지자 `outbound_adapter_does_not_depend_on_web_or_persistence_adapters` ArchUnit 규칙이 cross-adapter 의존을 catch. 해소 = sentinel 을 `application.exception` 으로 이전. *fitness function 이 contract 변경 비용을 정확히 측정한 정상 동작* 의 기록.
|
||||
- (Jackson `DeserializationFeature` enum 이 app-bootstrap 의 test classpath 에 없어 컴파일 실패한 1회는 `testImplementation 'spring-boot-starter-json'` 추가로 해소 — 1회성 환경 정렬이므로 `raw/errors/` 등재 생략.)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (해당 enforcement 패스에서 면접 질문 단독 추출 없음. RFC 7807 거부 + custom envelope, CVE-2019-14379 + ArchUnit 정적 차단 같은 질문 후보는 sibling branch `feature-business-rule-validation-contract` 의 cluster 와 신규 [[raw/blog-topics/archunit-jackson-default-typing-cve-2019-14379-block-2026-05-29]] 에서 다룰 영역과 중복.)
|
||||
|
||||
### Blog topics (이 작업에서 파생)
|
||||
|
||||
- [[raw/blog-topics/archunit-jackson-default-typing-cve-2019-14379-block-2026-05-29]] — Jackson `enableDefaultTyping()` / `LaissezFaireSubTypeValidator` 의 RCE 게이트를 ArchUnit fitness function 으로 정적 차단한 1차 enforcement 사례.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: boundary·mapping 6필드 contract와 negative fixture가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MAPPING-001@1` | 수기 mapper와 record canonical constructor가 default이며 MapStruct는 optional profile이다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
CA skeleton에서 경계가 흐려지면 DTO, domain object, persistence model이 서로 새어 나갑니다. 이 branch는 각 경계가 무엇을 검증하고 어떤 mapper를 통과해야 하는지 고정합니다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- request DTO validation.
|
||||
- request DTO -> application command/query mapper.
|
||||
- application command/query invariant validation.
|
||||
- domain object -> response DTO 직접 노출 금지.
|
||||
- response mapper public field 정책.
|
||||
- filter/interceptor request context propagation.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 특정 도메인 validator 구현.
|
||||
- DB/JPA exception mapping.
|
||||
- outbound adapter retry 구현.
|
||||
|
||||
## TODO
|
||||
|
||||
> TODO drained 2026-05-22 — 결정은 아래 "결정 사항" / "Mapper Tool Contract" / "판정 기준" / "테스트 계약" 참조. request DTO validation/request→command mapper/application invariant/domain object 노출 금지/response mapper 정책/filter context/boundary 우회 탐지 모두 결정 라인 또는 표 row로 반영됨. 잔존 TODO 없음.
|
||||
|
||||
> 본 branch는 Mapper Tool Contract와 validation 4-layer 분류 자체가 결정 표 등가. 별도 Decisionized Work Items 표는 작성하지 않음.
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- mapper는 단순 변환기가 아니라 허용/차단/정규화/마스킹 경계입니다.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- 2026-05-21: 모든 경계에 validation/mapping 책임을 둠.
|
||||
- 2026-05-22: validation은 syntax, policy, invariant, persistence integrity로 책임을 분리.
|
||||
- 2026-05-22: mapper는 변환뿐 아니라 normalization, masking, public field selection의 경계로 취급.
|
||||
- 2026-05-22: mapper 도구 기본값은 수기 mapper + record canonical constructor. MapStruct는 optional이며 사용 시 generated code architecture exemption과 mapper contract test가 필요.
|
||||
- 2026-05-28: (B1) Jackson deserialization 정책 — `spring.jackson.deserialization.fail-on-unknown-properties=true` 명시 (Jackson default 와 동일, 회귀 방지). `FAIL_ON_NULL_FOR_PRIMITIVES=true` 또는 request DTO 가 wrapper type (`Integer`, `Long`, `Boolean`) 만 사용. 클래스 단위 `@JsonIgnoreProperties(ignoreUnknown=true)` 는 ArchUnit rule 로 금지.
|
||||
- 2026-05-28: (B2) PATCH 요청 mapper 는 RFC 7396 의 null=deletion semantics 를 채택하지 않음 (envelope success/error 대칭 정책과 충돌). PATCH endpoint 는 absent 필드 = 변경 없음 / null 필드 = 명시적 null 의미로 처리하며, `JsonNullable` (openapi-generator) 또는 `Optional<T>` wrapper 로 absent vs null 을 구분. RFC 7396 미채택 사실을 OpenAPI 문서에 명시.
|
||||
- 2026-05-28: (B3) Mapping exception 분류 — `HttpMessageNotReadableException` / `MethodArgumentNotValidException` 은 Spring `ResponseEntityExceptionHandler` 가 normative 처리하므로 `VALIDATION` 카테고리. mapper-internal 예외 (`IllegalArgumentException`, record canonical constructor `IllegalStateException`, MapStruct generated NPE) 는 별도 `@ExceptionHandler` 에서 잡아 ca-tmpl operational contract 의 `MAPPING_FAILED` 신규 code 로 분류 (canonical SSOT §6 갱신 필요).
|
||||
- 2026-05-28: (B4) Cross-field 와 class-level Bean Validation 의 책임 — class-level constraint 는 syntax 레이어 (request DTO 의 multi-property 형식 검증), domain invariant 는 application/domain layer 의 별도 검증. `@GroupSequence` 로 syntax → invariant 단계 short-circuit 패턴 채택. `@Valid` cascade depth 는 ArchUnit / runtime limit 으로 nested 3 단계 이내 제한.
|
||||
- 2026-05-28: (B5) Polymorphic deserialization — `ObjectMapper.enableDefaultTyping()` / `activateDefaultTyping(LaissezFaireSubTypeValidator)` 금지 (ArchUnit). sealed `Command` interface + record subtypes 는 `@JsonTypeInfo(use = NAME)` + `@JsonSubTypes` 명시 또는 `BasicPolymorphicTypeValidator` allowlist 로만 deserialize.
|
||||
- 2026-05-28: (B6) Virtual thread — `spring.threads.virtual.enabled=true` 활성화 시 Tomcat connector / `@Async` executor 가 `SimpleAsyncTaskExecutor` 로 전환되므로 filter/interceptor 의 `ThreadLocal` 기반 context propagation (`RequestContextHolder`, MDC) 안전성을 contract test 로 검증. MDC 는 SLF4J 2.0+ (Loom 호환) 또는 Micrometer Context Propagation 위임. `InheritableThreadLocal` 사용 금지.
|
||||
- 2026-05-28: (B7) 본 branch 의 mapper 범위는 inbound `request→application` + `application→response` 뿐 아니라 outbound `external-response→domain` 도 포함 (ACL 패턴). outbound adapter 의 응답 → domain 변환에도 동일한 normalization / masking / public field selection 책임이 적용된다. ACL 의 inline (인-프로세스) 구현은 허용, 별도 서비스 추출은 out-of-scope.
|
||||
- 2026-05-28: (B8) Bulk endpoint 의 partial success — envelope 의 top-level `success` flag 는 *전체 성공* 시에만 true. 부분 실패는 `success: false` + `error.code = BATCH_PARTIAL_FAILURE` + `error.details[]` 에 항목별 결과 배열. 단일 항목 endpoint 와 schema 가 다르므로 OpenAPI 에서 별도 response shape 으로 분기. Google rpc.Status typed details / JSON:API errors[] / GraphQL data+errors 패턴이 선례.
|
||||
- (B9) Resource identifier ArchUnit rules cross-cite — [[raw/branch-notes/feature-resource-identifier-contract]] D17 의 5개 rule (`no_long_id_pk`, `no_uuid_random_in_controller`, `no_math_random_for_id`, `no_varchar_255_for_id_column`, `no_find_by_id_without_tenant`) 를 본 branch 의 ArchUnit suite 에 등록. 구현 skeleton 은 resource-identifier branch §구현 가이드 §6. ArchUnit version = archunit-junit5 1.3.0 per project §34 Stack Commitment.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/schema-jackson-unknown-field-handling]] | Jackson `DeserializationFeature` default 4종 — B1 블라인드: request boundary 직전 `FAIL_ON_UNKNOWN_PROPERTIES` / `FAIL_ON_NULL_FOR_PRIMITIVES` / `FAIL_ON_IGNORED_PROPERTIES` / `READ_UNKNOWN_ENUM_VALUES_AS_NULL` 정책 강제 근거 |
|
||||
| [[raw/official-docs/schema-jackson-polymorphic-deserialization]] | Jackson polymorphic deserialization 보안 지침 — B5 블라인드: `enableDefaultTyping()` 금지 (`@Deprecated` 2.10) + `PolymorphicTypeValidator` allowlist + CVE-2019-14379 gadget chain RCE 근거 |
|
||||
| [[raw/official-docs/patch-json-merge-rfc7396]] | PATCH null=deletion IETF normative semantics — B2 블라인드: null vs absent 구분 강제 근거 |
|
||||
| [[raw/official-docs/spring-mvc-rest-exception-handling]] | Spring MVC `ErrorResponse` 계약, `ResponseEntityExceptionHandler` 처리 예외 목록 — B3 블라인드: `HttpMessageNotReadableException` / `MethodArgumentNotValidException` → `VALIDATION` 분류 근거 |
|
||||
| [[raw/official-docs/validation-jakarta-bean-validation-3.0-spec]] | Jakarta Bean Validation 3.0 normative — B4 블라인드: class-level constraint, group sequence short-circuit, `@Valid` cascade, TYPE_USE container element 위치 정의 |
|
||||
| [[raw/official-docs/runtime-spring-boot-virtual-threads]] | Spring Boot `spring.threads.virtual.enabled` + virtual thread executor/scheduler 전환 — B6 블라인드: filter/interceptor `ThreadLocal` context propagation 안전성 |
|
||||
| [[raw/official-docs/arch-acl-microsoft-pattern]] | ACL 패턴 공식 정의 — B7 블라인드: outbound HTTP 응답 → domain 변환이 mapper 범위 안에 포함된다는 scope 명확화 |
|
||||
| [[raw/company-tech-blogs/stripe-error-format]] | endpoint dimension 명시 사례 |
|
||||
| [[raw/company-tech-blogs/toss-payments-error-format]] | 한국 컨벤션 reference |
|
||||
| [[raw/official-docs/problem-detail-rfc-7807]] | IETF 표준이지만 실패 전용, success/error 비대칭 |
|
||||
| [[raw/official-docs/spring-problem-detail]] | Spring 6 기본 지원이지만 ca-tmpl envelope과 충돌 |
|
||||
| [[raw/official-docs/google-api-error-format]] | 가장 표현력 풍부, retryable detail 1급. B8 블라인드: typed details 다형성으로 bulk partial-result 표현 |
|
||||
| [[raw/official-docs/json-api-errors-spec]] | B8 블라인드: 다중 error 객체 배열 — bulk partial success 표현 |
|
||||
| [[raw/official-docs/graphql-errors-spec]] | partial success 1급. B8 블라인드: data + errors 공존 모델 |
|
||||
| [[raw/company-tech-blogs/github-api-error-format]] | — |
|
||||
|
||||
## 판정 기준
|
||||
|
||||
| 구분 | 기준 |
|
||||
| --- | --- |
|
||||
| Decision | 모든 외부 입력/출력은 mapper와 validation 경계를 통과 (B7: outbound 응답 → domain ACL mapper 포함) |
|
||||
| Allowed | 단순 query DTO도 mapper를 거쳐 command/query로 변환. MapStruct는 optional generated mapper로만 허용. sealed `Command` interface 의 polymorphic deserialization 은 `@JsonTypeInfo` + `@JsonSubTypes` 또는 `BasicPolymorphicTypeValidator` allowlist 로만 허용 (B5). PATCH endpoint 는 absent vs null 구분 mapper 만 허용 (B2) |
|
||||
| Forbidden | request DTO -> domain 직접 생성, domain/persistence model -> response 직접 반환, mapper 없는 public field 노출, `ObjectMapper.enableDefaultTyping()` / `activateDefaultTyping(LaissezFaireSubTypeValidator)` 호출 (B5), 클래스 단위 `@JsonIgnoreProperties(ignoreUnknown=true)` (B1), `InheritableThreadLocal` 직접 사용 (B6), RFC 7396 `application/merge-patch+json` content type 사용 (B2 — 미채택), outbound 응답 raw → domain 직접 mapping (B7 — ACL bypass) |
|
||||
| Required validation | request syntax (class-level constraint 포함), command/query invariant, use case policy, domain invariant, response public field. `@GroupSequence` 로 syntax → invariant short-circuit (B4). `@Valid` cascade depth ≤ 3 (B4) |
|
||||
| Failure condition | 경계 우회로 private/internal field가 응답에 노출되거나 domain invariant가 bypass되면 실패. mapper-internal exception 이 `MAPPING_FAILED` 가 아닌 `INTERNAL` 로 분류되면 실패 (B3). bulk endpoint 의 부분 실패가 `success: true` 로 반환되면 실패 (B8). virtual thread 환경에서 `requestId`/`traceId`/MDC 가 application layer 까지 propagate 되지 않으면 실패 (B6) |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> *문서가 모호해서 구현자가 임의로 정해야 했던 결정* 카탈로그.
|
||||
|
||||
### 1. Error code → HTTP status → retryable 표 (B3/B8 보강)
|
||||
|
||||
> **Trace**: 본 표의 row 는 모두 본 branch (boundary/validation/mapping) 결정 영역. 도메인 특화 code (예: `USER_NOT_FOUND`) 와 다른 branch 결정 영역 (security/conflict/infra-failure) 의 row 는 [[raw/project-notes/ca-skeleton-operational-contract]] §6 Operational Error Category 통합 정의 — 본 표는 *§6 의 부분 view*.
|
||||
>
|
||||
> - `VALIDATION_FAILED` → **D10 + `SPRING-MVC-EXC-C1/C4/C5`, `JBV-3.0-C5`**
|
||||
> - `MAPPING_FAILED` → **D10** (canonical SSOT §6 등록 완료 2026-05-29)
|
||||
> - `BATCH_PARTIAL_FAILURE` → **D14 + `GOOG-ERR-C3`, `GQL-ERR-C3`, `JSONAPI-ERR-C1`** (HTTP 200 은 partial-response 선례 차용; canonical SSOT §6 등록 완료 2026-05-29)
|
||||
|
||||
| code | HTTP | retryable | 의미 | 사용 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `VALIDATION_FAILED` | 400 | false | Bean Validation 실패, JSON 파싱 실패, unknown field, 알 수 없는 enum value, polymorphic discriminator 불일치 — D10 의 "VALIDATION 카테고리" 일체 | `HttpMessageNotReadableException`, `MethodArgumentNotValidException`, `ConstraintViolationException` 모두 라우팅 |
|
||||
| `MAPPING_FAILED` | 400 | false | Mapper-internal 실패 (record canonical constructor `IllegalArgumentException` wrap, MapStruct NPE, ACL normalization 실패). 반드시 `MappingException` 으로 명시적 wrap. | `handleMapping` |
|
||||
| `BATCH_PARTIAL_FAILURE` | 200 | false | Bulk endpoint 의 부분/전체 실패. HTTP 200 + envelope.success=false (단일 항목 endpoint 와 *동일* 응답 표면, *분기는 envelope.success* 로). | `BulkEnvelope.partial(...)` |
|
||||
|
||||
> **`MALFORMED_REQUEST` 는 제거되었다.** 초기 구현은 unknown field 를 `MALFORMED_REQUEST`(400) 로 매핑했으나 D10 의 "HttpMessageNotReadableException → VALIDATION category" 와 본 branch §테스트 계약 "(B1) 400 + VALIDATION_FAILED" 와 충돌. `VALIDATION_FAILED` 로 통합하고 *구체적 실패 모드*(UnrecognizedPropertyException / InvalidTypeIdException / JsonParseException 등) 는 `error.details.cause` 로 surface 한다.
|
||||
|
||||
### 2. `error.details` shape (코드별)
|
||||
|
||||
> **Trace**: 본 표는 §1 의 in-scope row 와 1:1 대응. 도메인/HTTP-표준 row 의 shape 는 [[raw/project-notes/ca-skeleton-operational-contract]] §6 통합 정의.
|
||||
>
|
||||
> - `VALIDATION_FAILED (MethodArgumentNotValid)` → **Spring `FieldError` API 표준** (`SPRING-MVC-EXC-C5` 의 message arg `{1}=field errors` 차용)
|
||||
> - `VALIDATION_FAILED (ConstraintViolation)` → **Jakarta `ConstraintViolation` API 표준** (`JBV-3.0-C2`)
|
||||
> - `VALIDATION_FAILED (HttpMessageNotReadable)` → **D11 + `SJUF-C1~C4`** (Jackson exception 종류)
|
||||
> - `BATCH_PARTIAL_FAILURE` → **D14 + `GOOG-ERR-C3` typed details / `JSONAPI-ERR-C1` errors array 패턴**
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: field 영문 키 이름 (`cause`, `index`, `status`, `id` 등) — 근거 raw 가 *구조* 는 권고하나 *키 이름* 은 권고하지 않음. OpenAPI 정의 시 명시 필요.
|
||||
|
||||
| code | `details` shape |
|
||||
| --- | --- |
|
||||
| `VALIDATION_FAILED` (from `MethodArgumentNotValidException`) | `List<{field, rejectedValue, message}>` (Spring `FieldError`) |
|
||||
| `VALIDATION_FAILED` (from `ConstraintViolationException`) | `List<{field, message}>` |
|
||||
| `VALIDATION_FAILED` (from `HttpMessageNotReadableException`) | `{cause: <Jackson exception simple-name>}` |
|
||||
| `BATCH_PARTIAL_FAILURE` | `List<BulkItemResult{index, status, id, code, message}>` |
|
||||
| 그 외 | `null` |
|
||||
|
||||
> OpenAPI 분기는 `oneOf` 로 표현. OpenAPI 스펙 자체가 부재해서 구현은 보류 — 별도 PR.
|
||||
|
||||
### 3. `MappingException` 라우팅 규약
|
||||
|
||||
> **Trace**: mapper-internal 라우팅 흐름은 **D10 직접 권고** (mapper-internal 예외 분류).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: ①`MappingException` 이라는 *wrap 클래스 이름* (D10 은 wrap 강제만 권고, 클래스명은 임의). ②"정적 강제는 두지 않음" trade-off (false positive 우려 + mapper 코드 양이 적어 review 로 충분이라는 *사용자 판단*) — 근거 raw 없음, *trade-off articulation 기록* 으로 보존.
|
||||
|
||||
- Mapper 내부 (web/outbound/persistence ACL 어디든) 가 던진 *논리적 mapping 실패* 는 반드시 `MappingException` 으로 **wrap 해서** 던진다. 그러면 `handleMapping` 이 `MAPPING_FAILED` 로 라우팅.
|
||||
- 이 규약을 *컨벤션* 으로 두고 정적 강제는 두지 않는다. 정적 강제는 너무 광범위해서 false positive 가 많고, mapper 코드는 양이 적어 review 로 충분하다는 판단.
|
||||
|
||||
### 4. Envelope wrap 적용 범위
|
||||
|
||||
> **Trace (audit 2026-05-29)**:
|
||||
> - **In-scope**: 모든 `@RestController` 응답 `Envelope<T>` 자동 wrap 자체 → **D6 직접 권고** (success flag + envelope 대칭). `BulkEnvelope` pass-through → **D14 직접 권고** (bulk partial success shape 분리).
|
||||
> - **HTTP 표준 차용**: DELETE / 204 No Content body skip — HTTP 표준, 본 branch 결정 외 자연 결과.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: ①`EnvelopeBodyAdvice` 의 *Spring `ResponseBodyAdvice` 메커니즘 선택* 자체 (D6 는 wrap 만 권고, 메커니즘은 임의). ②컨트롤러 직접 반환 pass-through 로직 (재wrap 방지). ③`Envelope`/`BulkEnvelope` 라는 클래스 명명. ④`Envelope.ok(...)`, `BulkEnvelope.partial(...)`, `BulkEnvelope.allOk(...)` 의 *static factory API 모양* — D6/D14 가 권고하지 않음, 사용자 임의 design.
|
||||
> - **운영 영향 anchor**: probe / monitoring 이 `$.status` → `$.data.status` 로 갱신 필요 — *근거 기반 결정의 운영 영향* 으로 §11 운영 회복력 검토 후보.
|
||||
|
||||
- *모든* `@RestController` 응답 (sample-portfolio 의 도메인 컨트롤러 + production `HealthcheckController` 포함) 은 `EnvelopeBodyAdvice` 가 자동으로 `Envelope<T>` 로 wrap.
|
||||
- 컨트롤러가 직접 `Envelope.ok(...)` 반환하면 advice 가 *재wrap 하지 않음* (pass-through). 명시적 envelope 구성이 필요한 경우 직접 반환 OK.
|
||||
- `BulkEnvelope<T>` 도 advice 의 pass-through 대상 — bulk 엔드포인트는 직접 `BulkEnvelope.partial(...)` / `BulkEnvelope.allOk(...)` 반환.
|
||||
- DELETE / 204 No Content 는 body 가 없으므로 wrap 대상이 아님 (advice 가 null body skip).
|
||||
- 운영 영향: probe / monitoring 이 `$.status` 같은 평탄 path 를 직접 읽고 있었다면 `$.data.status` 로 갱신 필요.
|
||||
|
||||
### 5. Cascade depth ≤ 3 정적 강제 메커니즘 (B4-2)
|
||||
|
||||
> **Trace (audit 2026-05-29)**:
|
||||
> - **In-scope**: `@Valid` cascade depth ≤ 3 결정 자체 → **본 branch B4 결정 라인 + D2 (4-layer validation)** 직접 권고 ("ArchUnit / runtime limit 으로 nested 3 단계 이내 제한"). `JBV-3.0-C4` (`@Valid` cascade) 가 *cascade 메커니즘* 을 normative 로 다룸 → depth limit 자체는 본 branch 의 trade-off 결정 (DoS 방어).
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: ①ArchUnit rule 이름 `valid_cascade_depth_at_most_three` (임의 명명). ②depth 계산 algorithm (직접 `@Valid` 필드 = depth 1, 재귀 depth +1) — 근거 raw 가 depth 의 *조작적 정의* 를 권고하지 않음, 사용자 임의 정의. ③외부 라이브러리 (`java.*`, `jakarta.*`) cascade 무시 — false positive 회피의 사용자 trade-off, 근거 없음. ④limit 값 `3` 자체 — 1, 5, 7 도 가능했으나 사용자 임의 선택 (DoS 위험과 표현력의 균형 판단).
|
||||
|
||||
- ArchUnit `valid_cascade_depth_at_most_three` 규칙이 `..adapter.web..dto..` 패키지 클래스의 `@Valid` 필드를 재귀 따라가며 도메인 내부 클래스 사이의 cascade 깊이를 계산.
|
||||
- depth 1 = 직접 `@Valid` 필드. depth 2 = `@Valid` 필드의 `@Valid` 필드. 등등.
|
||||
- 외부 라이브러리 (`java.*`, `jakarta.*`) 로의 cascade 는 무시 (자기 도메인 외부는 depth 측정 안 함).
|
||||
- 위반 시 build 실패. 신규 nested DTO 작성 시 양 3 단계 안에서 펼치거나 별도 매퍼/validator 로 분리.
|
||||
|
||||
### 6. Polymorphic deserialize 정적 강제 좁힘 (B5)
|
||||
|
||||
> **Trace (audit 2026-05-29)**:
|
||||
> - **In-scope (strong)**: `enableDefaultTyping()` 차단 → **D12 + `JACK-POLY-C3`** (`enableDefaultTyping()` 2.10 `@Deprecated`, 대체 `activateDefaultTyping(PolymorphicTypeValidator)`); `LaissezFaireSubTypeValidator` 차단 → **D12 + `JACK-POLY-C1` + `JACK-POLY-C5`** (gadget chain CVE-2019-14379 normative 위험); `activateDefaultTyping(BasicPolymorphicTypeValidator)` 허용 → **D12 + `JACK-POLY-C4`** (allowlist 표준 구현체).
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: ①ArchUnit rule 이름 `no_jackson_enable_default_typing_call`, `no_jackson_laissez_faire_subtype_validator` (임의 명명). ②sample-portfolio 의 `BasicPolymorphicTypeValidatorAllowlistTest` 의 *4-case 선택* (Cat, Dog, 비허용 subtype, 임의 JDK 클래스) — pin 패턴의 사용자 임의 design, raw 가 권고하지 않음.
|
||||
> - **참고**: 본 sub-section 은 모든 in-scope 결정이 normative claim 으로 지원되는 *가장 깨끗한* sub-section. 다른 sub-section 의 audit 기준점으로 사용 가능.
|
||||
|
||||
- `enableDefaultTyping()` (no-arg, deprecated) 호출 → 차단 (ArchUnit `no_jackson_enable_default_typing_call`).
|
||||
- `LaissezFaireSubTypeValidator` 클래스 참조 → 차단 (ArchUnit `no_jackson_laissez_faire_subtype_validator`).
|
||||
- `activateDefaultTyping(BasicPolymorphicTypeValidator allowlist)` → **허용**. 차단 대상 아님. 안전한 allowlist 패턴이며 `sample-portfolio` 의 `BasicPolymorphicTypeValidatorAllowlistTest` 가 4 case 로 pin (allowlisted Cat/Dog 통과, 비허용 subtype 거부, 임의 JDK 클래스 거부).
|
||||
- 두 가지 정적 강제 + 두 가지 sample (sealed `@JsonTypeInfo`/`@JsonSubTypes` 와 `BasicPolymorphicTypeValidator`) 모두 D12 에 기록된 normative 패턴.
|
||||
|
||||
### 7. Controller 반환 / Application 파라미터 정적 강제 (§Forbidden 직접 강제)
|
||||
|
||||
> **Trace (audit 2026-05-29)**:
|
||||
> - **In-scope (decision)**: controller return type 차단 → **D1 + D8** (response mapper public field 만 노출, domain 직접 노출 금지). application method DTO 파라미터 차단 → **D7** (request DTO → command/query mapper 강제).
|
||||
> - **Decision Evidence 강도 한계**: D1, D8 의 Supporting Claims 는 *부분 normative* — `RFC7807-C5` (debug 정보 분리 사상), `JSONAPI-ERR-C5` (호출별 불변 사상) 이 *원칙* 만 권고, ArchUnit 강제는 직접 도출 X. D7 도 `RFC7396-C2~C4` 가 PATCH semantics 만 다룸. **즉 정적 강제 *메커니즘 자체* 는 사용자 trade-off 결정** (review-only vs static enforcement).
|
||||
> - **Cross-reference 보강**: 패키지 패턴 `..domain.entity..`, `..adapter.persistence.entity..`, `..adapter.web..dto..` 의 *정확한 glob* → [[raw/project-notes/ca-skeleton-operational-contract]] §20 Skeleton Blueprint Contract 의 package convention 에서 도출. trace: SUPPORTED via canonical SSOT.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: ①ArchUnit rule 이름 `controllers_do_not_return_domain_or_entity_types`, `application_methods_do_not_accept_web_dtos`. ②package glob 의 *정확한 `..` wildcard 위치* (canonical SSOT 의 anchor 와 일치하지만 glob 변환은 사용자 결정).
|
||||
|
||||
- `controllers_do_not_return_domain_or_entity_types` — controller method 반환 타입이 `..domain.entity..` 또는 `..adapter.persistence.entity..` 또는 `..repository..` 에 거주하면 build 실패. `EnvelopeBodyAdvice` 의 자동 wrap 이 도메인 객체를 silent 직렬화하는 회귀를 *정적* 으로 차단.
|
||||
- `application_methods_do_not_accept_web_dtos` — application package 의 public method 가 `..adapter.web..dto..` 파라미터를 받으면 build 실패. controller 가 DTO → Command/Query 변환을 우회하는 회귀 차단.
|
||||
|
||||
### 8. ProblemDetail + RFC 7396 정적 강제 (D5 + B2)
|
||||
|
||||
> **Trace (audit 2026-05-29)**:
|
||||
> - **In-scope (strong)**: ProblemDetail import 차단 → **D5 직접 결정** + `SPRING-PD-C1` (Spring `ProblemDetail` = RFC 9457 representation), `SPRING-PD-C2` (모든 Spring MVC 예외가 `ErrorResponse` 구현 — envelope 와 충돌), `SPRING-MVC-EXC-C1` (corroborate). 정적 강제 *목표* 는 SUPPORTED.
|
||||
> - **In-scope (partial)**: `application/merge-patch+json` content type 차단 → **D7 (B2 결정 라인)** + `RFC7396-C2` (null=deletion normative), `RFC7396-C3` (explicit null 부적합 경고). RFC 7396 의 *미채택 결정* 자체가 ca-tmpl envelope 정책 (D5, D6) 과 정합 — content type 차단으로 정적 강제.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: ①ArchUnit rule 이름 `no_problem_detail_usage`, `no_merge_patch_json_media_type_string` (임의 명명). ②import-level 차단 vs class-reference 차단 vs annotation-value 차단의 *메커니즘 선택* — D5/D7 이 직접 권고하지 않음, false positive vs 회귀 차단의 사용자 trade-off.
|
||||
|
||||
- `no_problem_detail_usage` — `org.springframework.http.ProblemDetail` import 자체를 차단. D5 의 "RFC 7807 명시적 거부" 가 코드 단계에서 강제됨. 신규 작업자가 무심코 `ProblemDetail` 을 부활시키면 build 실패.
|
||||
- `no_merge_patch_json_media_type_string` — `@RequestMapping(consumes="application/merge-patch+json")` 같은 RFC 7396 도입을 build 실패로 차단. B2 의 "RFC 7396 미채택" 정적 강제.
|
||||
|
||||
## Mapper Tool Contract
|
||||
|
||||
| item | default |
|
||||
| --- | --- |
|
||||
| mapper implementation | 수기 mapper |
|
||||
| command/query normalization | record canonical constructor 또는 static factory |
|
||||
| generated mapper | MapStruct only, optional |
|
||||
| generated code exemption | architecture rule에 package/path 명시 필수 |
|
||||
| Jackson deserialization defaults (B1) | `FAIL_ON_UNKNOWN_PROPERTIES=true` 명시 (`spring.jackson.deserialization.fail-on-unknown-properties=true`), `FAIL_ON_NULL_FOR_PRIMITIVES=true` 또는 wrapper type only, `READ_UNKNOWN_ENUM_VALUES_AS_NULL=false` 유지 |
|
||||
| polymorphic deserialization (B5) | `@JsonTypeInfo(use = NAME)` + `@JsonSubTypes` 명시 또는 `BasicPolymorphicTypeValidator` allowlist 만 허용 |
|
||||
| PATCH semantics (B2) | absent / null / 값 3-상태 구분; `JsonNullable` 또는 `Optional<T>` wrapper 사용; RFC 7396 미채택 |
|
||||
| outbound ACL mapper (B7) | outbound HTTP adapter 의 응답 → domain 변환에도 동일한 normalization / masking / public field selection 책임 적용 |
|
||||
| bulk partial success (B8) | `success: false` + `error.code = BATCH_PARTIAL_FAILURE` + `error.details[]` 항목별 결과 배열 shape |
|
||||
| virtual thread context (B6) | filter/interceptor 는 SLF4J 2.0+ MDC + `RequestContextHolder` 만 사용; `InheritableThreadLocal` 금지 |
|
||||
| forbidden | reflection-based implicit mapping, entity/domain direct response serialization, `enableDefaultTyping()` / `LaissezFaireSubTypeValidator`, 클래스 단위 `@JsonIgnoreProperties(ignoreUnknown=true)`, `InheritableThreadLocal` |
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. company-tech-blog 출처는 `company-case-study` 로 라벨링하며 공식 best practice 로 격상하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|
|
||||
| D1 | 모든 경계에 validation/mapping 책임을 둠 (2026-05-21) | UNSUPPORTED_DECISION (Clean Architecture / Hexagonal boundary 책임 원칙은 일반 design wisdom 이지만 본 branch 가 cite 한 sources — Stripe/Toss/RFC 7807/Spring/Google/JSON:API/GraphQL/GitHub — 중 normative 진술 없음) | N/A | DDD boundary / Hexagonal port-adapter 패턴의 raw 인용 (예: Vaughn Vernon, Reflectoring) 별도 보강 필요 |
|
||||
| D2 | validation 책임 분리 — syntax, policy, invariant, persistence integrity (4-layer) | **MECHANISM SUPPORTED, TAXONOMY UNSUPPORTED_DECISION.** `raw/official-docs/validation-jakarta-bean-validation-3.0-spec.md#JBV-3.0-C1` (class-level constraint = validates state of class = multi-property → invariant mechanism), `#JBV-3.0-C2` (ConstraintValidator receives class instance → 여러 field 동시 접근 가능), `#JBV-3.0-C3` (group sequence short-circuit → syntax 선 실행 후 invariant 실행 패턴의 normative 근거). **4-layer 이름(syntax/policy/invariant/persistence integrity) 자체는 ca-tmpl internal decision — JBV spec 은 이 taxonomy 를 정의하지 않음.** | `official-standard` (mechanism) + UNSUPPORTED_DECISION (taxonomy naming + layer assignment) | sibling branch 와 4-layer 정의의 정합성 cross-review 필수. JBV-3.0-C1~C3 은 Bean Validation 이 syntax/invariant 구분 *없이* 실행됨을 보여줌 — 분리를 강제하는 것은 application 설계 결정임을 명시 필요 |
|
||||
| D3 | mapper 는 변환뿐 아니라 normalization, masking, public field selection 의 경계 | `raw/official-docs/problem-detail-rfc-7807.md#RFC7807-C5` ("`detail` ought to focus on helping the client correct the problem, rather than giving debugging information" — masking 의도와 정합), `raw/official-docs/google-api-error-format.md#GOOG-ERR-C2` ("`message` is a developer-facing ... debug message" — public field 분리 사상) | `official-standard + official-vendor-doc` | mapper = security boundary 라는 강한 정의 자체는 cited sources 가 직접 권고하지 않음 — 일반 보안 원칙 (OWASP) 별도 raw 보강 권장 |
|
||||
| D4 | mapper 도구 기본값 — 수기 mapper + record canonical constructor; MapStruct optional (사용 시 architecture exemption + contract test 필요) | UNSUPPORTED_DECISION (project-internal tool selection; cited sources 중 mapper 도구 선택 관련 normative / vendor 진술 없음) | N/A | 수기 mapper 의 boilerplate 비용 vs MapStruct generated 코드의 architecture leak 위험 trade-off 는 별도 측정 / vendor 비교 필요 |
|
||||
| D5 | error envelope shape — custom 채택, RFC 7807 ProblemDetail 명시적 거부 (sibling branch `feature-business-rule-validation-contract` 와 동일 결정 공유) | `raw/official-docs/problem-detail-rfc-7807.md#RFC7807-C1` (canonical model `application/problem+json`), `#RFC7807-C2` (`type` URI primary identifier), `#RFC7807-C3` (extension 가능, unknown ignore), `raw/official-docs/spring-problem-detail.md#SPRING-PD-C1` (Spring `ProblemDetail` = RFC 9457 representation), `#SPRING-PD-C2` (모든 Spring MVC 예외가 `ErrorResponse` 구현 — envelope 와 충돌), `raw/official-docs/spring-mvc-rest-exception-handling.md#SPRING-MVC-EXC-C1` (동일 사실의 primary source — 모든 Spring MVC 내장 예외는 `ErrorResponse` 구현), `raw/company-tech-blogs/stripe-error-format.md#STRIPE-ERR-C5` (Stripe 4-종 type enum), `raw/company-tech-blogs/toss-payments-error-format.md#TOSS-ERR-C1` (Toss `{code, message}` 평면) | `official-standard + official-vendor-doc + company-case-study` | sibling branch D5 와 동일 evidence — cross-branch 일관성 확보됨. `SPRING-MVC-EXC-C1` 이 `SPRING-PD-C2` 를 corroborate. 단 RFC 7807 미채택 trade-off 의 ca-tmpl 측 해석은 cited sources 가 직접 권고하지 않음 |
|
||||
| D10 | **B3 블라인드 해소** — `HttpMessageNotReadableException` (JSON 역직렬화 실패) 은 `VALIDATION` 카테고리로 분류; `MethodArgumentNotValidException` (Bean Validation 실패) 은 `VALIDATION` 카테고리로 분류; mapper 내부 예외 (`IllegalArgumentException`, MapStruct NPE, record canonical constructor `IllegalStateException`) 는 Spring 이 자동 처리하지 않으므로 별도 `@ExceptionHandler` 로 처리하며 ca-tmpl operational contract 에서 `MAPPING_FAILED` 카테고리로 분류 | `raw/official-docs/spring-mvc-rest-exception-handling.md#SPRING-MVC-EXC-C1` (모든 Spring MVC 내장 예외는 `ErrorResponse` 구현 — `HttpMessageNotReadableException` 포함), `#SPRING-MVC-EXC-C2` (`ResponseEntityExceptionHandler` 가 모든 Spring MVC 내장 예외 + `ErrorResponseException` 처리), `#SPRING-MVC-EXC-C4` (`HttpMessageNotReadableException` 은 normative 처리 목록에 있음), `#SPRING-MVC-EXC-C5` (`MethodArgumentNotValidException` 은 normative 처리 목록에 있음, {0}=global errors, {1}=field errors); `raw/official-docs/validation-jakarta-bean-validation-3.0-spec.md#JBV-3.0-C5` (PARAMETER ElementType → Bean Validation 으로 method parameter 검증 → `MethodArgumentNotValidException` 발생 경로의 2차 normative 확인); mapper-internal 예외의 `MAPPING_FAILED` 카테고리 코드 자체는 UNSUPPORTED_DECISION — ca-tmpl 고유 operational contract | `official-vendor-doc` (`HttpMessageNotReadableException` / `MethodArgumentNotValidException` → Spring normative) + `official-standard` (JBV-3.0-C5 corroboration) + UNSUPPORTED (`MAPPING_FAILED` 카테고리 코드 및 mapper-internal 예외 분류) | Spring 이 `HttpMessageNotReadableException` 의 HTTP status 를 400 으로 설정한다는 것은 `spring-mvc-rest-exception-handling.md` 의 message code 표에서 직접 명시되지 않음 — `ErrorResponse` 구현체 내부(Spring source)에서 정의됨. `MAPPING_FAILED` 라는 category code 를 Operational Error Category 에 추가하는 결정은 ca-tmpl 내부 결정이며 별도 project-note 갱신 필요 |
|
||||
| D6 | retryable 1급 + success flag — 어떤 표준에도 1:1 매칭 없음 (sibling branch D6 와 동일) | `raw/official-docs/google-api-error-format.md#GOOG-ERR-C3` (typed details 다형성), `#GOOG-ERR-C5` (표준 detail payloads), `raw/official-docs/graphql-errors-spec.md#GQL-ERR-C3` (partial response — data+errors 공존), `#GQL-ERR-C4` (extensions free-form map) | `official-vendor-doc + official-standard` | sibling branch 와 동일 evidence; top-level 1급 retryable 은 ca-tmpl 고유 결정 |
|
||||
| D7 | request DTO → application command/query mapper 강제 (DTO 의 service 직접 전달 금지); PATCH 요청 시 mapper 가 null vs absent 를 구분해야 함 (B2 블라인드) | `raw/official-docs/patch-json-merge-rfc7396.md#RFC7396-C2` ("Null values in the merge patch are given special meaning to indicate the removal of existing values in the target." — null=deletion normative), `#RFC7396-C3` (merge patch 는 explicit null 사용 시 부적합), `#RFC7396-C4` (배열 부분 수정 불가 — merge patch 한계) | `official-standard` (null=deletion 근거) + UNSUPPORTED (Hexagonal boundary 원칙 자체) | RFC7396 은 null=deletion 의 normative 근거를 제공하나, Java record mapper 에서 absent field 를 별도 처리하는 구현 방법은 직접 권고하지 않음. Hexagonal port-adapter boundary 원칙 raw (예: Reflectoring, Woowahan) 별도 인용 보강 권장 |
|
||||
| D8 | domain object → response DTO 직접 노출 금지; response mapper 가 public field 만 선택 | `raw/official-docs/problem-detail-rfc-7807.md#RFC7807-C5` (detail 은 client correct 목적 — debug 정보 분리), `raw/official-docs/json-api-errors-spec.md#JSONAPI-ERR-C5` (`title` 은 호출별 불변 — public field 안정성 사상) | `official-standard` | response mapper 의 public field selection 강제 메커니즘 자체는 일반 design 원칙 — `@JsonView` / DTO record 같은 구체적 구현 표준 없음 |
|
||||
| D9 | filter/interceptor 가 request context propagation 담당 | UNSUPPORTED_DECISION (project-internal middleware 결정; 외부 표준 근거 없음) | N/A | Servlet filter chain 의 ordering / context propagation 보장은 별도 ArchUnit / integration test 필요 |
|
||||
| D11 | **B1 블라인드 해소** — Jackson deserialization 정책: `FAIL_ON_UNKNOWN_PROPERTIES=true` 명시, request DTO 는 wrapper type 또는 `FAIL_ON_NULL_FOR_PRIMITIVES=true`, 클래스 단위 `@JsonIgnoreProperties(ignoreUnknown=true)` ArchUnit 금지 | `raw/official-docs/schema-jackson-unknown-field-handling.md#SJUF-C1` (Jackson 2.13+ default — unknown property → `JsonMappingException`), `#SJUF-C2` (`FAIL_ON_NULL_FOR_PRIMITIVES=false` default → JSON null → 0 silently — ca-tmpl 의 null/empty/missing 분리와 불일치), `#SJUF-C3` (`FAIL_ON_IGNORED_PROPERTIES=false` default — silently skip), `#SJUF-C4` (`READ_UNKNOWN_ENUM_VALUES_AS_NULL=false` default — exception throw) | `official-vendor-doc` | Spring Boot `JacksonProperties` 가 default 를 override 하지 않는다는 보장은 별도 — `application.yaml` 명시 설정 검증 필요. `@JsonIgnoreProperties(ignoreUnknown=true)` 클래스 단위 사용 금지를 강제하는 ArchUnit rule 자체는 project-internal |
|
||||
| D12 | **B5 블라인드 해소** — sealed `Command` interface + record subtypes 의 Jackson polymorphic deserialization: `ObjectMapper.enableDefaultTyping()` / `activateDefaultTyping(LaissezFaireSubTypeValidator)` 금지, `@JsonTypeInfo(use = NAME)` + `@JsonSubTypes` 명시 또는 `BasicPolymorphicTypeValidator` allowlist | `raw/official-docs/schema-jackson-polymorphic-deserialization.md#JACK-POLY-C1` (`PolymorphicTypeValidator` = default typing + `@JsonTypeInfo` class-name 기반 subtype 검증 공식 인터페이스, `@since 2.10`), `#JACK-POLY-C2` ("pluggable allow lists to avoid security problems that occur with unlimited class names"), `#JACK-POLY-C3` (`enableDefaultTyping()` = 2.10 `@Deprecated`, 대체 `activateDefaultTyping(PolymorphicTypeValidator)`), `#JACK-POLY-C4` (`BasicPolymorphicTypeValidator` = class hierarchy/name pattern allowlist 표준 구현체), `#JACK-POLY-C5` (NVD CVE-2019-14379: default typing + ehcache gadget → RCE, CVSS 9.8) | `official-vendor-doc + official-standard` | ArchUnit 으로 `enableDefaultTyping()` import / 호출 금지를 강제하는 rule 자체는 project-internal. sealed interface 패턴 사용 시 Jackson 의 sealed type 자동 인식 (Jackson 2.15+) 적용 여부는 별도 확인 필요 |
|
||||
| D13 | **B6 블라인드 해소** — Virtual thread (`spring.threads.virtual.enabled=true`) 활성화 시 filter/interceptor `ThreadLocal` context propagation 안전성 contract test 강제, MDC 는 SLF4J 2.0+ 위임, `InheritableThreadLocal` 금지 | `raw/official-docs/runtime-spring-boot-virtual-threads.md#SPRING-VT-C1` (virtual thread 활성화 시 task executor 는 `SimpleAsyncTaskExecutor` 로 전환), `#SPRING-VT-C2` (비활성화 시 `ThreadPoolTaskExecutor`), `#SPRING-VT-C3` (scheduler 는 `SimpleAsyncTaskScheduler` 로 전환, pooling 속성 무시), `#SPRING-VT-C4` (builder bean 도 virtual thread 조건 충족 시 auto-config) | `official-vendor-doc` (executor/scheduler 전환) + UNSUPPORTED_DECISION (Tomcat connector 전환 + `RequestContextHolder` / MDC virtual-thread 호환성) | Spring Boot reference 의 task-execution 페이지는 executor/scheduler 전환만 명시. Tomcat embedded connector 의 virtual thread 적용 여부, `RequestContextHolder` 의 virtual thread 호환성, MDC 의 Loom 호환성은 별도 raw (Tomcat docs / SLF4J 2.0 docs / JEP 444) 보강 필요 |
|
||||
| D14 | **B8 블라인드 해소** — Bulk endpoint partial success: envelope `success` flag = 전체 성공 시에만 true, 부분 실패는 `success: false` + `error.code = BATCH_PARTIAL_FAILURE` + `error.details[]` 항목별 결과 배열, OpenAPI 에서 별도 response shape 분기 | `raw/official-docs/google-api-error-format.md#GOOG-ERR-C3` (typed details 다형성 — item별 결과 표현 모델), `#GOOG-ERR-C5` (표준 detail payloads 카탈로그), `raw/official-docs/json-api-errors-spec.md#JSONAPI-ERR-C1` (errors array 다중 표현 — 적용 시), `raw/official-docs/graphql-errors-spec.md#GQL-ERR-C3` (partial response — data+errors 공존 — 분리 envelope 의 영감) | `official-vendor-doc + official-standard` (선례 다형성 / partial response 패턴) + UNSUPPORTED_DECISION (`BATCH_PARTIAL_FAILURE` code 명명 자체는 ca-tmpl 고유) | `BATCH_PARTIAL_FAILURE` code 를 Operational Error Category (canonical SSOT §6) 에 신규 등록 필요. 별도 `BatchResult<T>` envelope 도입 대안은 ca-tmpl `success/error 대칭` 정책과 충돌 위험 — 측정/리뷰 후 결정 |
|
||||
| D15 | **B9 cross-cite** — Resource identifier ArchUnit rules **4개** (`no_long_id_pk` — `..domain..` 한정, `no_uuid_random_in_controller`, `no_math_random_for_id`, `no_varchar_255_for_id_column`) 를 본 branch ArchUnit suite 에 등록. 구현 skeleton 은 resource-identifier branch §구현 가이드 §6. **5번째 rule `no_find_by_id_without_tenant` 는 `feature-tenant-context-policy` (예정 branch) 로 이관** — tenant 모델 부재 시 production code 가 모두 깨지는 false positive 차단 | [[raw/branch-notes/feature-resource-identifier-contract]] D17 (rule SSOT — 4 rules), D5 (ID generation = domain port + application 주입), D10 (PostgreSQL `uuid` native), D13 (ID 내 tenant 인코딩 거부 — 형식적 위치만). project §34 Stack Commitment (archunit-junit5 1.3.0) | `cross-branch-SSOT` (resource-identifier D17) + `project-ssot` (§34 archunit-junit5 version) | `haveExplicitColumnLength()` custom ArchCondition 의 archunit-junit5 1.3.0 API 호환성 검증 필요 (resource-identifier branch §구현 가이드 §6 UNSUPPORTED_IMPL_DECISION). 본 4개 rule 의 실제 코드는 boundary branch ArchUnit suite 가 호스팅, *결정 SSOT* 는 resource-identifier branch D17. `no_find_by_id_without_tenant` 활성화는 multi-tenancy-contract 도착 시 |
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서/사례는 근거지만 내 프로젝트에서의 동작을 자동 보장하지 않는다. 구현 전/중/후 실제로 검증해야 하는 주장.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| controller 가 domain object 를 직접 반환하지 않는지 (response mapper boundary 강제) | implicit Jackson serialization 으로 domain object 가 직접 직렬화될 위험 | ArchUnit rule (controller method return type 은 DTO/record/`ResponseEntity<DTO>` 만) + integration test | `partially-implemented` (2026-05-29 3차 패스: `EnvelopeBodyAdvice` 가 모든 컨트롤러 응답을 `Envelope<T>` 로 wrap, 기존 컨트롤러는 DTO record 만 반환 컨벤션. 컨트롤러 반환 타입의 정적 ArchUnit rule 은 미작성 — `planned` 잔존.) |
|
||||
| request DTO 가 application service 의 method signature 에 직접 나타나지 않는지 | DTO 가 service layer 까지 leak 가능성 | ArchUnit rule (`service` package method 의 parameter type 은 `Command`/`Query` record 만) | `planned` |
|
||||
| MapStruct generated code 가 architecture exemption 없이 architecture rule 우회하지 않는지 | `target/generated-sources` 의 generated mapper 가 domain access 시 silent rule bypass | ArchUnit rule 의 generated code exemption package 명시 + generated code 의 domain access pattern 검증 | `planned` |
|
||||
| filter/interceptor 가 request context (traceId, principal, tenant) 를 application layer 까지 propagate 하는지 | Spring `RequestContextHolder` 또는 MDC propagation 누락 가능 | integration test (downstream service 에서 context 값 접근 가능 검증) + `@Async` boundary test | `planned` |
|
||||
| mapper 가 PII / sensitive field 를 mask 하는지 (e.g., 카드번호, 주민번호, 이메일) | mapper 가 단순 변환만 하고 masking 누락 가능 | DLP scan + 의도적 PII field test (response body grep) | `planned` |
|
||||
| MapStruct 사용 시 generated code 가 architecture exemption package 에 격리되는지 | exemption 없이 사용 시 ArchUnit rule 우회 | build 시 generated code path 검증 + ArchUnit rule 의 exemption 명시 확인 | `needs-confirmation` |
|
||||
| internal diagnostic context (debug info, stacktrace, internal IDs) 가 response payload 에 섞이지 않는지 | exception handler 또는 mapper 에서 internal context 누출 가능 | response leakage contract test (debug field regex grep) + production log audit | `planned` |
|
||||
| RFC 7807 미채택이 Spring 6 의 autoconfigure (`spring.mvc.problemdetails.enabled`, `SPRING-PD-C4`) 와 충돌하지 않는지 (sibling branch D5 와 동일 우려) | sibling branch business-rule-validation 과 동일한 risk | `spring.mvc.problemdetails.enabled=false` 명시 설정 검증 + Spring MVC error response shape contract test | `actually-implemented` (2026-05-29 3차 패스: `GlobalExceptionHandler` 가 `ProblemDetail` import 완전 제거 + `Envelope<Void>` 반환, `BoundaryDemoControllerWireTest` 의 11 케이스가 envelope shape 을 wire-level 로 pin. Spring 의 `ProblemDetail` 자동 활성화도 우리 핸들러가 우선이므로 충돌 없음.) |
|
||||
| (B1) `spring.jackson.deserialization.fail-on-unknown-properties=true` 가 실제 설정되어 unknown field 가 400 으로 거부되는지 | Spring Boot `JacksonProperties` 가 Jackson default 를 silent override 가능 | `application.yaml` 명시 검증 + integration test (unknown field 가 포함된 JSON 요청 → 400 응답 + `VALIDATION_FAILED` code) | unknown-field 거부와 4종 binding 설정은 기존 테스트 기록이 있으나 당시 envelope 기대값이 제거된 `MALFORMED_REQUEST`였다. D10의 `VALIDATION_FAILED`로 갱신한 wire test 재실행 전까지 `needs-confirmation` |
|
||||
| (B1) request DTO 중 primitive type 이 있는지 (있다면 `FAIL_ON_NULL_FOR_PRIMITIVES=true` 또는 wrapper 전환 필요) | Jackson default 는 JSON null → primitive 0 silently | ArchUnit rule (request DTO record 의 component type 은 wrapper 또는 `Optional` 만) + Jackson configuration test | `planned` (스위치는 `locally-verified`. component-type ArchUnit rule 은 미작성.) |
|
||||
| (B1) 클래스 단위 `@JsonIgnoreProperties(ignoreUnknown=true)` 사용 여부 | 정책 우회 risk | ArchUnit rule (request DTO 패키지 내 `@JsonIgnoreProperties` 사용 금지) | `actually-implemented` (2026-05-29: `request_dtos_do_not_silence_unknown_fields` + `JsonIgnoreUnknownRequestFixture` 위반-증명 테스트.) |
|
||||
| (B2) PATCH endpoint 가 absent / null / 빈 값을 mapper 에서 구분하여 처리하는지 | record 기본값으로 mapping 시 PATCH 가 null 로 덮어쓰는 silent overwrite | PATCH integration test (3 케이스: field 없음 → 무변경, field=null → 명시적 null, field=value → 갱신) + JSON Schema validation | `actually-implemented` (2026-05-29 3차 패스: `BoundaryDemoControllerWireTest#b2_patch_field_absent_is_distinguished_from_explicit_null_and_value` 가 PATCH `/demo/boundary/patch-demo` 로 3 케이스 wire-level pin. 기존 `UpdateProfileRequest` + `UpdateProfileCommand` + `UserService.updateProfile` 도 `JsonNullable<T>` / `Patch<T>` 로 마이그레이션 — silent overwrite 위험 제거.) |
|
||||
| (B2) RFC 7396 미채택 사실이 OpenAPI 문서에 명시되는지 (`application/merge-patch+json` content type 사용 안 함) | 클라이언트가 RFC 7396 semantics 를 가정할 risk | OpenAPI spec 검토 + content type assertion test | `planned` |
|
||||
| (B3) mapper 내부 예외 (record canonical constructor `IllegalArgumentException`, MapStruct NPE) 가 별도 `@ExceptionHandler` 로 잡혀 `MAPPING_FAILED` 카테고리로 분류되는지 | Spring 이 자동 처리하지 않으므로 `INTERNAL` 로 새어 나가는 risk | controller advice integration test (의도적 mapper exception 발생 → `MAPPING_FAILED` 응답 검증) | `actually-implemented` (2026-05-29 3차 패스: `BoundaryDemoControllerWireTest#b3_mapping_exception_surfaces_as_mapping_failed_envelope` 가 POST `/demo/boundary/mapping-failure` 로 advice integration 검증. `GlobalExceptionHandlerTest` 가 unit 레벨 + envelope shape pin.) |
|
||||
| (B3) `MAPPING_FAILED` 신규 code 가 ca-tmpl operational contract canonical SSOT §6 에 등록되었는지 | code 누락 시 sibling branch error envelope 와 정합 깨짐 | [[raw/project-notes/ca-skeleton-operational-contract]] §6 갱신 PR 검증 | `actually-implemented` (2026-05-29 §6 등록 완료) |
|
||||
| (B4) request DTO 에 `@GroupSequence` 로 syntax → invariant short-circuit 패턴 적용되는지 | Bean Validation default 는 모든 group 평탄 실행 — invariant 가 syntax 실패 후에도 평가됨 | Bean Validation integration test (의도적 syntax 실패 → invariant validator 호출되지 않음 검증) | `actually-implemented` (2026-05-29: `SampleGroupSequenceRequest` + `SampleGroupSequenceRequestTest`. invariant 메서드가 null 필드와 만나면 `IllegalStateException` 을 던지도록 만들어 short-circuit 회귀 시 테스트가 빨갛게 떨어진다.) |
|
||||
| (B4) `@Valid` cascade depth 가 3 단계 이내인지 (DoS 방어) | nested 객체 deep recursion 시 CPU 소모 | ArchUnit rule (nested `@Valid` annotation depth scan) + load test | `planned` (현 패스에서 nested DTO sample 부재로 ArchUnit 동적 검사 미작성 — cascade depth 컨벤션은 `adapter-web/CLAUDE.md` 에 문서화.) |
|
||||
| (B5) `ObjectMapper.enableDefaultTyping()` / `activateDefaultTyping(LaissezFaireSubTypeValidator)` 호출이 코드 어디에도 없는지 | CVE-2019-14379 류 gadget chain RCE risk | ArchUnit rule (`enableDefaultTyping` / `LaissezFaireSubTypeValidator` import 금지) + dependency check (jackson-databind 버전 최소 2.10+) | `actually-implemented` (2026-05-29: `no_jackson_laissez_faire_subtype_validator` + `no_jackson_enable_default_typing_call` 두 ArchUnit rule, `DefaultTypingFixture` 가 violations-as-data 로 catch 검증. jackson-databind 버전 확인은 별도 supply-chain branch 책임.) |
|
||||
| (B5) sealed `Command` interface 가 `@JsonTypeInfo` + `@JsonSubTypes` 명시 또는 `BasicPolymorphicTypeValidator` allowlist 로만 deserialize 되는지 | 명시 누락 시 sealed type 도 deserialize 불가 | polymorphic deserialization integration test (각 subtype 정상 deserialize + allowlist 외 type 거부) | `actually-implemented` (2026-05-29: `SamplePolymorphicRequest` sealed interface + record subtypes + `@JsonTypeInfo`/`@JsonSubTypes` + `SamplePolymorphicRequestTest` 4 케이스. allowlist 외 discriminator → `InvalidTypeIdException` pin.) |
|
||||
| (B6) `spring.threads.virtual.enabled=true` 환경에서 filter/interceptor 의 `RequestContextHolder` + MDC propagation 이 application layer 까지 도달하는지 | Loom virtual thread 의 `ThreadLocal` semantics 미검증 | `@SpringBootTest(properties = "spring.threads.virtual.enabled=true")` integration test (downstream service 에서 `requestId` / `traceId` / `MDC.get()` 접근 가능 검증) | `actually-implemented` (2026-05-29 3차 패스: `VirtualThreadMdcE2ETest` 가 `@SpringBootTest(RANDOM_PORT)` + 가상 스레드 + 실 `RequestLoggingFilter` + `TestRestTemplate` 로 server-generated `requestId` 와 client-supplied `X-Request-Id` 두 경로 모두 컨트롤러까지 도달함을 wire-level 로 pin.) |
|
||||
| (B6) `InheritableThreadLocal` 직접 사용이 없는지 + MDC 가 SLF4J 2.0+ 사용하는지 | virtual thread 환경에서 `InheritableThreadLocal` 누설 가능 | ArchUnit rule (`InheritableThreadLocal` import 금지) + SLF4J 버전 dependency check | `actually-implemented` (2026-05-29: `no_inheritable_thread_local` rule + `InheritableThreadLocalFixture` 위반 catch 검증. SLF4J 2.0+ 버전 확인은 별도 supply-chain branch.) |
|
||||
| (B7) outbound HTTP adapter 의 응답 → domain 변환 mapper 가 ACL 책임 (normalization / masking / public field selection) 을 inbound mapper 와 동일하게 적용하는지 | outbound 응답이 domain 으로 raw leak 가능 | ArchUnit rule (outbound adapter `RestClient` / `WebClient` 반환 타입 = ACL mapper 통과 후 domain type 만) + integration test (외부 응답 raw 가 domain object 에 그대로 leak 되지 않음) | `actually-implemented` (2026-05-29: `WeatherSummary` (domain) + `WeatherForecastPort` (application) + `RawWeatherResponse` (package-private, adapter-only) + `WeatherForecastAclMapper` + `WeatherForecastClient` + `WeatherForecastClientTest` 3 케이스. 부수 효과로 `outbound_adapter_does_not_depend_on_web_or_persistence_adapters` ArchUnit 규칙이 `MappingException` 의 잘못된 위치를 catch — `application.exception` 으로 이전.) |
|
||||
| (B8) Bulk endpoint 의 response 가 `success: false` + `error.code = BATCH_PARTIAL_FAILURE` + `error.details[]` 항목별 결과 shape 을 따르는지 | 단일 항목 endpoint 와 schema 혼동 risk | bulk endpoint contract test (전체 성공 / 전체 실패 / 부분 실패 3 케이스) + OpenAPI shape 분기 검증 | `actually-implemented` (2026-05-29 3차 패스: `BoundaryDemoControllerWireTest` 의 3 bulk 케이스 (`b8_all_success`, `b8_partial_failure`, `b8_all_failures_take_the_same_partial_branch`) 가 POST `/demo/boundary/bulk` 로 wire-level 검증. OpenAPI 분기는 스펙 자체가 부재라 별도.) |
|
||||
| (B8) `BATCH_PARTIAL_FAILURE` 신규 code 가 canonical SSOT §6 에 등록되었는지 | code 누락 시 envelope 일관성 깨짐 | [[raw/project-notes/ca-skeleton-operational-contract]] §6 갱신 PR 검증 | `actually-implemented` (2026-05-29 §6 등록 완료) |
|
||||
|
||||
## 구현 결과
|
||||
|
||||
> 후속 audit (`<project>/docs/superpowers/specs/2026-05-29-module-placement-audit-report.md`) 에서 **이전 4개 브랜치가 만든 skeleton-wide 운영 계약이 sample-portfolio 에만 구현되어 실행 앱(app-bootstrap)에서 누락**되는 High 결함(Finding 1)을 발견. app-bootstrap 은 sample-portfolio 을 런타임 의존하지 않으므로(`testImplementation` only) fork 후 sample 삭제 시 envelope/error 계약이 통째로 사라짐. 이를 production 모듈로 승격하는 리팩터를 TDD + subagent-driven 으로 수행.
|
||||
|
||||
### 승격 내역 (동작 보존, 패키지/모듈 이동 중심)
|
||||
|
||||
- **shared-contract (stdlib-only)**: `error/ApiErrorCode` 인터페이스 신설(code/httpStatus(int)/retryable — Spring `HttpStatus` 대신 전송중립 int 로 stdlib 제약 충족) + `error/OperationalError` enum(운영/전송/보안 코드) + `error/MappingException` 이전 + `response/BulkEnvelope`·`BulkItemResult` 이전(`OperationalError.BATCH_PARTIAL_FAILURE` 사용).
|
||||
- **adapter-web**: `error/GlobalExceptionHandler` (base @RestControllerAdvice, 운영/전송/보안/framework 예외만) + `error/ErrorResponseFactory` (int→`HttpStatus.valueOf` + MDC traceId, envelope 빌드 단일 지점) + `envelope/EnvelopeBodyAdvice` 이전 + `config/JacksonNullableConfig` 이전(+`jackson-databind-nullable` 의존).
|
||||
- **sample-portfolio**: `ApiErrorCode` enum → `SampleErrorCode`(도메인 코드만, shared 인터페이스 구현) + `DomainExceptionHandler`(도메인 예외 전용 advice, base 와 Spring 합성). 기존 단일 `GlobalExceptionHandler`(운영+도메인 혼재) 삭제.
|
||||
- **app-bootstrap**: 코드 변경 0. `OperationalContractRuntimeTest`(@WebMvcTest, `CaSkeletonApplication` 앵커 + raw probe) 신설 — 실행 컨텍스트에 advice/handler 빈 존재 + raw body 가 실제로 wrap 됨을 pin → Finding 1 회귀 방지.
|
||||
- **문서**: `shared-contract/CLAUDE.md` 신설(부재했음), `adapter-web/CLAUDE.md` 의 "handler 가 sample 에 있다" 구절을 "production 모듈로 승격됨"으로 갱신.
|
||||
|
||||
### 검증
|
||||
|
||||
- 9 Task TDD, task 마다 `./gradlew test verifyCleanArchitectureDependencies` green. 최종 `./gradlew clean test verifyCleanArchitectureDependencies` → BUILD SUCCESSFUL, **119 tests / 0 failures**.
|
||||
- ArchUnit 24 규칙 + violation fixture 전부 green. shared-contract Spring/Jackson/JPA import 0 (grep 확인). `production_code_does_not_depend_on_sample_portfolio` green.
|
||||
- 최종 리뷰: ca-architect-sentinel **PASS**, ca-quality-reviewer 의 Important 2건(BulkEnvelope double-wrap 분기 미테스트 / DomainExceptionHandler 라우팅 미테스트) 보강 테스트 추가 후 green, minor(stale Javadoc, `.toList()` 일관화, dead `INTEGRITY_VIOLATION` 제거, inline FQN→import) 처리.
|
||||
|
||||
### 잔여 / 후속
|
||||
|
||||
- 커밋은 사용자가 일괄 수행 예정(현재 working tree 미커밋). 본 5차 패스는 `feature/boundary-validation-mapping-contract` 브랜치 작업 트리에 존재.
|
||||
- sub-project B: sample 도메인을 포트폴리오 게시판으로 교체 + 모듈 rename — 별도 spec/plan 예정.
|
||||
- 설계/계획 문서: `<project>/docs/superpowers/specs/2026-05-29-operational-contract-promotion-design.md`, `<project>/docs/superpowers/plans/2026-05-29-operational-contract-promotion.md` (repo `/docs` gitignore 로 untracked).
|
||||
|
||||
## 구현 결과
|
||||
|
||||
> 감사 Finding 4(sample 도메인·데모 비일관) 해소. sample 모듈을 사용자의 엔지니어링 작업물을 보여주는 **포트폴리오 게시판(WorkLog)** 으로 교체하고, production 모듈 경계를 거울처럼 보여주는 adapter-mirrored 레이아웃으로 정리. production 모듈·ArchUnit 본체는 불변(glob/매트릭스 키만 rename).
|
||||
|
||||
### Phase B-1 — rename + restructure (동작 보존)
|
||||
- `sample-portfolio` → `sample-portfolio`, 패키지 `dev.caskeleton.sample.portfolio` → `dev.caskeleton.sample.portfolio`. settings.gradle / `verifyCleanArchitectureDependencies` 매트릭스 키 / app-bootstrap `testImplementation` / ArchUnit `production_code_does_not_depend_on_sample_portfolio` glob(`..sample.portfolio..`→`..sample.portfolio..`) 전부 갱신. (glob 미갱신 시 vacuous-pass → production→sample 미탐지, 계약 보존 필수 포인트.)
|
||||
- 절반-마이그레이션 빈 `.gitkeep` anchor(domain/model, application/usecase/port/in 등) 제거. 모듈 CLAUDE.md(adapter-web/app-bootstrap/domain-core)의 stale `com.example.blog.*` → `dev.caskeleton.*` 교정.
|
||||
|
||||
### Phase B-2 — WorkLog 도메인 (adapter-mirrored)
|
||||
- domain/worklog: `WorkLog`(POJO 엔티티), `WorkCategory`(INFRASTRUCTURE/DATABASE/BACKEND/PLATFORM), `Period`(vo), `RepoStats`(vo), `WorkLogRepository`(port).
|
||||
- application/worklog: `Create/Update/Delete/Get/ListWorkLogsUseCase` + `GetRepoStatsUseCase` — **sample에서 처음으로 application-port-usecase 계약 실증**(`CommandUseCase`/`QueryUseCase` + `@UseCaseCapability` + `TransactionPort`, `@Transactional` 미사용). command/query/exception 분리.
|
||||
- adapter/web: `WorkLogController`(목록=메인화면 + CRUD + bulk import + repo-stats), DTO(B4 `@GroupSequence`, B2 `JsonNullable→Patch`, B1 unknown-field), `WorkLogWebMapper`(B3 `MappingException`), `PortfolioErrorCode`, `DomainExceptionHandler`(`@Order(HIGHEST_PRECEDENCE)` — base catch-all보다 앞서야 도메인 예외가 INTERNAL로 안 빨려듦).
|
||||
- adapter/persistence: `WorkLogEntity`(@ElementCollection LAZY), `WorkLogJpaRepository`, `WorkLogRepositoryAdapter`(page 기반), `WorkLogPersistenceMapper`.
|
||||
- adapter/outbound/repostats: B7 ACL(`RawRepoStatsResponse` package-private + `RepoStatsAclMapper` normalization/masking + `RepoStatsPortClient`) — weather 대체, `GetRepoStatsUseCase`로 실제 소비(orphan 아님).
|
||||
- B1/B2/B3/B4/B8 계약을 WorkLog 엔드포인트로 re-home, B5(polymorphic)는 `SamplePolymorphicRequestTest` 단위테스트로 유지, B6(virtual-thread MDC)는 self-contained probe로 재배치. User/Post/BoundaryDemo/weather 전체 제거.
|
||||
- README(`src/sample-portfolio/README.md`) + 루트 README/CLAUDE.md/AGENTS.md의 `sample-portfolio`→`sample-portfolio` 갱신. 시드 2건(Keycloak+k3s+Vault 인증위임 / DB 쿼리튜닝)은 README curl 예시.
|
||||
|
||||
### 검증 / 리뷰
|
||||
- subagent-driven 9 Task, 단계마다 green. 최종 `./gradlew clean test verifyCleanArchitectureDependencies` → BUILD SUCCESSFUL, **126 tests / 0 failures**. ArchUnit 24규칙 + 19 픽스처 green(use-case 규칙이 이제 WorkLog로 실제 검증).
|
||||
- 부수 발견: `src/build.gradle`에 `-parameters` 컴파일 플래그 누락(Spring `@PathVariable`/`@RequestParam` 이름 해석 실패) → 프로젝트 전역 추가.
|
||||
- 최종 리뷰: ca-architect-sentinel **PASS**(URI-check/bulk-branching은 boundary/transport, 위반 아님), ca-quality-reviewer Important 5건(findAll offset→page 버그, bulk catch granularity, findAll 테스트 공백, PATCH @Valid+explicit-null 미테스트+dead @Size, RepoStatsPort dead code) 보강 후 green.
|
||||
|
||||
### 잔여
|
||||
- 커밋은 사용자가 A+B 일괄 수행 예정(working tree 미커밋).
|
||||
- `@Version` 낙관적 락 / 실 WebClient+WireMock / @DataJpaTest 통합 / `@MockBean`→`@MockitoBean` 는 후속.
|
||||
- 설계/계획: `<project>/docs/superpowers/specs/2026-05-29-sample-portfolio-domain-design.md`, `<project>/docs/superpowers/plans/2026-05-29-sample-portfolio-domain.md`.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> 본 branch 가 의존하거나 깨질 수 있는 경계 조건. 상세 검증 항목은 §Claims To Verify, 운영 영향은 §구현 가이드 §4 참조.
|
||||
|
||||
- **Edge**: PATCH 의 absent vs explicit-null vs value 3-state — 구분 실패 시 silent overwrite (B2). bulk endpoint 의 전체 실패도 부분 실패와 동일 `BATCH_PARTIAL_FAILURE`(HTTP 200) branch 를 타며, 분기는 `envelope.success` 로만 (B8).
|
||||
- **Failure mode**: ① mapper-internal 예외가 `MappingException` wrap 누락 시 `INTERNAL_ERROR` 로 새어 분류 오류 (B3). ② virtual thread 환경에서 `ThreadLocal`/MDC context 가 application layer 까지 propagate 안 되면 traceId 유실 (B6). ③ ArchUnit 정적 강제는 바이트코드 carrier(어노테이션/import/호출)만 탐지 — 메서드 본문 free-form 문자열은 한계.
|
||||
- **Dependency**: `MAPPING_FAILED` / `BATCH_PARTIAL_FAILURE` 신규 code 는 canonical SSOT [[raw/project-notes/ca-skeleton-operational-contract]] §6 등록에 의존 (등록 완료). package convention glob 은 §20 Skeleton Blueprint 에 의존. B9 ArchUnit rule 은 [[raw/branch-notes/feature-resource-identifier-contract]] D17 을 cross-cite.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- (해당 enforcement 패스에서 단독 daily-note 추출 없음. 구현 진행은 §"구현 결과" 5/6차 패스 + §"마주친 문제" 에 직접 기록.)
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 2026-05-29 (a): `JacksonDeserializationPolicyTest` 첫 컴파일 시 `com.fasterxml.jackson.databind.DeserializationFeature` 가 app-bootstrap 의 test classpath 에 없어 컴파일 실패. app-bootstrap 의 main `spring-boot-starter` 는 jackson 을 transitive 로 가져오지 않고, root `subprojects { ... testImplementation 'spring-boot-starter-test' }` 도 jackson-databind 를 guarantee 하지 않음. `testImplementation 'org.springframework.boot:spring-boot-starter-json'` 추가로 해소. 1회성 환경 정렬이므로 별도 `raw/errors/` 등재는 생략.
|
||||
- 2026-05-29 (c): B8 응답 타입 promotion. `BulkEnvelope<T>` + `BulkItemResult` 를 `sample.portfolio.adapter.web.dto.response` 에서 stdlib-only `shared-contract` 의 `dev.caskeleton.shared.response` 로 이전 (기존 `Envelope` / `ApiError` 옆). `BulkEnvelope.partial(...)` 은 Task 1 에서 추가된 `dev.caskeleton.shared.error.OperationalError.BATCH_PARTIAL_FAILURE` 의 `.code()` / `.retryable()` 를 사용해 하드코딩 문자열을 제거. shared-contract 는 Spring/Jackson 미의존 — 두 타입 모두 `java.util.List` + 공유 `ApiError` 만 쓰는 plain record 라 제약 충족. 소비자 import 갱신: `EnvelopeBodyAdvice`, `BoundaryDemoController`, 그리고 same-package resolution 에 의존하던 `BulkEnvelopeTest` (명시 import 2 줄 추가). 동작 동일 — 패키지 이동만. 회귀 게이트: `./gradlew test verifyCleanArchitectureDependencies` → BUILD SUCCESSFUL, 112 tests / 0 failures / 0 errors (BulkEnvelopeTest 3 케이스 포함). 단순 이전이라 별도 `raw/errors/` 등재 불요.
|
||||
- 2026-05-29 (b): B7 outbound ACL 참조 추가 후 `outbound_adapter_does_not_depend_on_web_or_persistence_adapters` ArchUnit 규칙이 fail. 원인: `MappingException` 이 `sample.portfolio.adapter.web.error` 패키지에 있어 `WeatherForecastAclMapper` (outbound) 가 web 에 의존하게 됨. **fitness function 이 dependency-direction 회귀를 정확히 catch 한 사례.** 해소: `MappingException` 을 `sample.portfolio.application.exception` 으로 이전 (다른 application exception 들과 같은 위치). adapter-web 의 `GlobalExceptionHandler` 와 outbound adapter 의 ACL mapper 모두 application 패키지에 의존하므로 의존성 방향이 다시 맞아 떨어진다.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- B1 정적 차단 (ArchUnit `request_dtos_do_not_silence_unknown_fields` + violation fixture) + wire-level (`BoundaryDemoControllerWireTest#b1_unknown_json_field_is_rejected_via_envelope`)
|
||||
- B2 PATCH 3-state + wire-level (`BoundaryDemoControllerWireTest#b2_patch_field_absent_is_distinguished_from_explicit_null_and_value` 3 케이스) + 기존 `UpdateProfileRequest`/`UpdateProfileCommand`/`UserService` 마이그레이션 완료
|
||||
- B3 `MappingException` → `MAPPING_FAILED` wire-level (`BoundaryDemoControllerWireTest#b3_mapping_exception_surfaces_as_mapping_failed_envelope` + unit)
|
||||
- B4 `@GroupSequence` short-circuit + wire-level (`BoundaryDemoControllerWireTest` 의 b4 3 케이스)
|
||||
- B5 Jackson default typing / `LaissezFaireSubTypeValidator` 차단 (ArchUnit + violation fixture, CVE-2019-14379 대응)
|
||||
- B5 sealed type + `@JsonTypeInfo`/`@JsonSubTypes` 패턴 (unit `SamplePolymorphicRequestTest` + wire `BoundaryDemoControllerWireTest` 의 b5 2 케이스)
|
||||
- B6 `InheritableThreadLocal` 차단 (ArchUnit + violation fixture)
|
||||
- B6 virtual thread MDC propagation (`VirtualThreadMdcPropagationTest` unit + `VirtualThreadMdcE2ETest` 실 Tomcat + 실 가상스레드 + 실 `RequestLoggingFilter` 2 케이스)
|
||||
- B7 outbound ACL mapper (Weather adapter + `WeatherForecastClientTest`)
|
||||
- B8 bulk envelope (unit `BulkEnvelopeTest` 3 케이스 + wire `BoundaryDemoControllerWireTest` b8 3 케이스)
|
||||
- **D5 RFC 7807 거부 완료**: `GlobalExceptionHandler` 가 `ProblemDetail` import 완전 제거 + `Envelope<Void>` 반환. shared-contract 의 skeleton-wide `Envelope<T>` / `ApiError` 타입 신설.
|
||||
- **success/error 대칭**: `EnvelopeBodyAdvice` 가 모든 controller success 응답을 `Envelope.ok(...)` 로 자동 wrap.
|
||||
- `locally-verified` 항목:
|
||||
- B1 Jackson 4-종 deserialization 스위치 (`JacksonDeserializationPolicyTest`)
|
||||
- `prod-verified` 항목: (해당 없음 — 본 패스는 enforcement + reference + unit/contract + wire-level + e2e 단계, prod 트래픽 검증 미수행)
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
- controller 반환 타입의 정적 ArchUnit rule — `planned` (현 패스는 `EnvelopeBodyAdvice` 자동 wrap 으로 우회).
|
||||
- B4-2 `@Valid` cascade depth ≤ 3 동적 ArchUnit — `planned` (nested DTO sample 부재).
|
||||
- B7-2 실 `WebClient`/`RestClient` + WireMock 통합 — `planned` (현 패스는 HTTP fetch 추상화).
|
||||
- B8-2 OpenAPI shape 분기 명시 — `planned` (OpenAPI 스펙 부재).
|
||||
- `MAPPING_FAILED` / `BATCH_PARTIAL_FAILURE` 의 canonical SSOT [[raw/project-notes/ca-skeleton-operational-contract]] §6 등재 (별도 envelope SSOT 갱신 PR 책임)
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-build-release-supply-chain-contract.md
|
||||
@@ -0,0 +1,503 @@
|
||||
---
|
||||
title: branch / feature-build-release-supply-chain-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-build-release-supply-chain-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]
|
||||
tags: [branch, ca-skeleton, ci-cd, gradle, docker, supply-chain]
|
||||
created: 2026-05-22
|
||||
updated: 2026-06-23
|
||||
target_merge:
|
||||
status_label: review
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-029
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-029
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-BUILD-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: 2307d3faa4febc43cbbe5f18fae9ae683e96a7b2b9f0fbe86065acf1a767a65b
|
||||
---
|
||||
|
||||
# branch: feature-build-release-supply-chain-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — artifact, dependency, image, vulnerability, rollback 기준을 정의합니다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (§<관련 섹션>) 의 결정/근거/금지 사항을 정제한다.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: Gradle release·SBOM·signature artifact가 생성된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-BUILD-001@1` | build tool은 Gradle Groovy DSL이다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
운영 가능한 skeleton은 실행되는 코드만이 아니라 배포 가능한 artifact를 안정적으로 만들어야 합니다. dependency drift, 취약 이미지, rollback 불가 artifact는 도메인과 무관하게 실무 장애가 됩니다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- dependency version locking.
|
||||
- artifact versioning.
|
||||
- container image base 기준.
|
||||
- non-root runtime 기준.
|
||||
- SBOM 생성 기준.
|
||||
- vulnerability severity별 release block 기준.
|
||||
- rollback 기준.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 특정 registry 운영.
|
||||
- 조직별 release approval workflow.
|
||||
- cloud provider 배포 스크립트.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [[raw/official-docs/supply-chain-cosign-keyless-sigstore]] | Sigstore Cosign keyless + Fulcio + Rekor (ca-tmpl baseline |
|
||||
| [[raw/official-docs/supply-chain-slsa-provenance-framework]] | SLSA v1 |
|
||||
| [[raw/official-docs/supply-chain-gradle-vs-maven-dependency-locking]] | Gradle dependency-locking vs Maven Enforcer |
|
||||
| [[raw/official-docs/cosign-keyless-identity-verification-policy]] | 참조 |
|
||||
| [[raw/official-docs/slsa-v1-provenance-schema]] | 참조 |
|
||||
| [[raw/official-docs/vuln-severity-cvss-v31-spec-first-official]] | D2 — high/critical release-blocking 기준: CVSS v3.1 §5 severity bands (C1), optional 선언 (C2), Base Score intrinsic/worst-case 정의 (C3) |
|
||||
| [[raw/official-docs/semver-2-0-0-spec-semver-official]] | D9 — artifact version = SemVer + git sha suffix (1.2.3+a1b2c3d): build metadata(`+` suffix)는 precedence에서 무시됨 (SEMVER-C3, SEMVER-C4) |
|
||||
| [[raw/official-docs/trivy-severity-exit-code-gating]] | D2 — severity→release-block 정책의 집행(enforcement) 메커니즘: Trivy `--exit-code 1 --severity HIGH,CRITICAL` 기본 패턴의 공식 출처 (TRIVY-EG-C1~C3) |
|
||||
| [[raw/official-docs/renovate-gradle-manager-official]] | D3 — Renovate Gradle 지원 범위(파일 패턴, --write-locks lockfile 갱신, Version Catalog), self-hosted `allowedUnsafeExecutions: ["gradleWrapper"]` supply-chain 보안 제약 (RENOV-GRAD-C1~C4) |
|
||||
| [[raw/official-docs/gradle-reproducible-archives-working-with-files]] | D10 — `preserveFileTimestamps=false` / `reproducibleFileOrder=true` 의 Gradle 공식 API 명세 + `tasks.withType<AbstractArchiveTask>().configureEach {}` 전역 적용 패턴 (GRADLE-RA-C1~C3) |
|
||||
| [[raw/official-docs/dependabot-supported-ecosystems-official]] | D3 — Dependabot Gradle ecosystem 공식 지원 범위: version updates ✓ / security updates ✓(단 dependency submission API 수동 업로드 한정) / Private registries ✓ / Vendoring ✗; 파일 파싱 방식(Gradle 미실행) 공식 확인 (DBOT-ECO-C1~C5) |
|
||||
| [[raw/official-docs/calver-spec-calver-official]] | D9 negative-evidence — CalVer when-to-use 기준(대규모/상시변동 scope, 시간민감)이 library/skeleton에 미해당함을 원문 부재로 뒷받침 (CALVER-C2, C3, C5) |
|
||||
| [[raw/official-docs/reproducible-builds-org-jvm-guide]] | D10 — reproducible builds 공식 정의(cross-ecosystem) + JVM nondeterminism 원인(timestamps/file ordering/locale/umask) + Gradle `isPreserveFileTimestamps=false` + `isReproducibleFileOrder=true` 두 설정이 두 주요 원인 제거 근거 (RB-JVM-C1~C6) |
|
||||
|
||||
## 외부 근거 / 대안 조사 (2026-05-22 — Group G-E: Build / Release / Supply Chain)
|
||||
|
||||
본 branch의 Cosign keyless + SLSA provenance + Gradle dependency-locking + SemVer+sha + reproducibility 결정에 대한 외부 source.
|
||||
|
||||
- **채택 결정 (Cosign keyless + SLSA + Gradle lock)**:
|
||||
- [[raw/official-docs/supply-chain-cosign-keyless-sigstore]] — Sigstore Cosign keyless + Fulcio + Rekor (ca-tmpl baseline)
|
||||
- [[raw/official-docs/supply-chain-slsa-provenance-framework]] — SLSA v1.0 build levels + in-toto attestation
|
||||
- [[raw/official-docs/supply-chain-gradle-vs-maven-dependency-locking]] — Gradle dependency-locking vs Maven Enforcer
|
||||
- **검토한 대안**:
|
||||
- **대안 1: GPG signing (legacy)** — Cosign 이전 표준
|
||||
- **대안 2: Notary v1 (Docker Content Trust)** — Cosign으로 대체된 deprecated 경로
|
||||
- **대안 3: in-toto attestations** — SLSA에 통합되어 별도 도구로는 미채택
|
||||
- **대안 4: JFrog Artifactory provenance** — vendor 통합 솔루션
|
||||
- **비교 핵심**: Cosign keyless가 GPG signing 대비 키 관리 부담 제거(Fulcio가 ephemeral cert 발급, Rekor가 transparency log). SLSA Build L3 도달은 hermetic build 필요. Maven에는 1급 lockfile 부재(Enforcer는 부분 대응) — Gradle 선택 근거. **보강 후보**: Cosign signature 누락만 차단으로 부족 — `--certificate-identity` + `--certificate-oidc-issuer` identity 매칭 정책 추가 필요. SLSA v1.0 spec 실제 필드명(`buildDefinition.externalParameters` 등)과 ca-tmpl 약식 매핑 정정 필요.
|
||||
- **후속 보강 (2026-05-22)**: Cosign signature 존재 검증만으로는 불충분. identity 매칭 정책 추가 필요. [[raw/official-docs/cosign-keyless-identity-verification-policy]] 참조.
|
||||
- **후속 보강 (2026-05-22)**: SLSA v1.0 spec 실제 필드명과 약식 매핑 정정 필요. [[raw/official-docs/slsa-v1-provenance-schema]] 참조.
|
||||
- **자동조사 라운드 (2026-06-15 — `/branch-spec`)**: UNSUPPORTED 였던 D2(vuln severity)·D3(dependency bot)·D9(SemVer)·D10(reproducibility) 에 공식 source 8건 아카이브. D9·D10 은 본 branch 소유 영역(artifact versioning·reproducibility) → official-standard/vendor-doc 로 승급. D2·D3 은 _정책 single-owner_ 가 [[raw/branch-notes/feature-dependency-vulnerability-management-contract]] 이므로 본 branch 는 _consume_ 관계 — §Audit & Findings `OWNER_RECONCILE` 참조.
|
||||
|
||||
## TODO
|
||||
|
||||
> TODO drained 2026-05-22 — dependency lock/update, artifact version naming, container base/non-root runtime, SBOM 생성, vulnerability severity 차단, rollback artifact 보관 정책 모두 "결정 사항" / "Supply Chain Defaults" / "테스트 계약"에 반영됨. 잔존 TODO 없음.
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- 2026-06-15 `/branch-spec` 자동조사: 근거 없던 결정 5개(D2/D3/D9/D10/D11) 중 D2/D3/D9/D10 을 공식 source 로 보강(§Sources 하단 8행). D11(rollback 10/90 retention)은 외부 표준 부재 → `UNSUPPORTED_DECISION` 유지.
|
||||
- D2/D3 은 evidence 가 붙었으나 _정책 owner_ 는 별도 branch — 자동 rewrite 하지 않고 §Audit & Findings 에 정합 권고로만 기록(사용자 결정 영역). `/sync` 가 Single-Owner 정합 수행 예정.
|
||||
- 2026-06-20~21 Phase C2 구현 완료. Gradle strict lock, 재현 가능한 archive, traceable version, digest-first image release, SBOM, Cosign keyless, SLSA provenance, High/Critical 차단, rollback retention audit를 코드와 계약 테스트로 배선했다.
|
||||
- GitHub-hosted OIDC/Rekor/GHCR와 실제 release 생성은 로컬에서 재현할 수 없어 `needs-confirmation`; 구현·로컬 검증과 운영 검증 경계를 아래 §구현 결과에 분리했다.
|
||||
|
||||
## 구현 결과
|
||||
|
||||
> 아래 §구현 가이드의 2026-06-15 `planned` 표시는 구현 전 설계 스냅샷이다. 현재 상태 SSOT는 이 절이며, 실제 코드·테스트가 존재하는 항목만 `actually-implemented` 또는 `locally-verified`로 분류한다.
|
||||
|
||||
| Decision | 구현 상태 | 구현 증거 | 검증 등급 |
|
||||
| ------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| D1 · D9 | `src/build.gradle`, `src/Dockerfile`, release manifest에 `<SemVer>+<12-char sha>`와 source revision 고정 | JAR manifest와 OCI label inspect | `locally-verified` |
|
||||
| D4 · D6 · D12 | digest 대상 Cosign keyless image signature와 SPDX SBOM attestation 생성, exact workflow identity + GitHub issuer 검증 | `.github/workflows/build-release-supply-chain.yml`, `.github/supply-chain-policy.json` | `actually-implemented`; live OIDC/Rekor는 `needs-confirmation` |
|
||||
| D7 · D13 | official SLSA generator provenance와 exact `@refs/tags/v2.1.0` builder ID, v1 predicate field 검증 | release workflow `provenance`/`verify` jobs | `actually-implemented`; live attestation은 `needs-confirmation` |
|
||||
| D8 | 모든 Gradle project에 `LockMode.STRICT`, 기본 `gradle.lockfile`, lock 생성/검증 task 적용 | 10개 module lockfile, positive/negative strict-lock 실행 | `locally-verified` |
|
||||
| D10 | archive timestamp/order/mode 정규화, Temurin 21.0.11+10 pin, Docker base digest pin | 두 clean build의 JAR SHA-256 일치, zip metadata, Docker build/inspect | `locally-verified` |
|
||||
| D2 consume | Trivy image scan `HIGH,CRITICAL --exit-code 1`을 promotion 전 배치 | release workflow `build` job | `actually-implemented`; live scan은 `needs-confirmation` |
|
||||
| D3 consume | Renovate-compatible Gradle 기본 lockfile 경로와 갱신 절차 명시 | `renovate.json`, PR template, README | `actually-implemented`; Renovate dry-run은 `needs-confirmation` |
|
||||
| D5 consume | digest-pinned Temurin JRE runtime + `USER app` | Docker build 및 image config inspect | `locally-verified` |
|
||||
| D11 | 최근 10개 OR 90일 이내 release의 manifest/SBOM/GHCR digest 일치 daily audit | retention workflow/script/positive-negative behavior tests | `locally-verified` (fixture); live registry/release는 `needs-confirmation` |
|
||||
| D14 | jq 1.8.1을 job-local 경로에 checksum 검증 후 설치하고 모든 jq 소비 job이 같은 installer를 호출 | installer behavior test, workflow YAML parse, 6개 job-level 정적 계약 | `locally-verified`; Gitea/act CI 재실행은 `needs-confirmation` |
|
||||
|
||||
### 변경 파일
|
||||
|
||||
- Build: `src/build.gradle`, `.tool-versions`, `src/*/gradle.lockfile`, `src/Dockerfile`, `docker-compose.local.yml`.
|
||||
- Release policy/workflows: `.github/supply-chain-policy.json`, `.github/workflows/build-release-supply-chain.yml`, `.github/workflows/supply-chain-retention-audit.yml`.
|
||||
- Contract/scripts: `.github/scripts/verify-supply-chain-contract.sh`, `create-release-manifest.sh`, `verify-reproducible-build.sh`, `audit-rollback-retention.sh`, `test-supply-chain-scripts.sh`.
|
||||
- Gate/docs: `.github/ci-gate-matrix.yml`, `.github/workflows/ci-quality-gates.yml`, `.github/pull_request_template.md`, `README.md`, `src/README.md`.
|
||||
- 2026-06-23 CI portability repair: `.github/scripts/install-jq.sh`, `.github/scripts/verify-supply-chain-contract.sh`, `.github/workflows/{ci-quality-gates,build-release-supply-chain,supply-chain-retention-audit,dependency-vulnerability}.yml`.
|
||||
- 2026-06-23 Bean conflict & cycle resolution & test repair: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/CaSkeletonApplication.java`에서 컴포넌트 스캔 범위를 프로덕션 패키지로 명시화하여 `sample-portfolio`의 `domainContextPropagator` 빈과의 BeanDefinitionOverrideException 충돌을 해결. `src/adapter-persistence-postgresql/src/main/java/dev/caskeleton/adapter/persistence/postgresql/PostgreSqlPersistenceConfig.java`에서 `postgreSqlFlywayLocationCustomizer()` 빈을 static @Bean으로 변경하여 Flyway ↔ EntityManagerFactory 간 초기화 순환 참조 제거. 또한 `PiiTokenBodyForbiddenContractTest.java`에서 공유 JVM 테스트 환경에 따른 로깅 레벨 오염으로 로그 미캡쳐 현상이 나타나던 것을 테스트 실행 중 로깅 레벨을 INFO로 보장하는 코드로 격리. `OutboxEventEntity.java`에서 `@Lob` 대신 `@JdbcTypeCode(SqlTypes.LONGVARCHAR)`를 사용하여 PostgreSQL `oid` 캐스팅 경고/오류 및 DDL 불일치를 해결. `sample-portfolio` 모듈의 `application.yml`에서 기본 데이터소스 폴백 정보 수정 및 `out-of-order: true` 활성화로 단독 실행 기동 문제 해결. 추가로 `app-bootstrap` 모듈의 런타임 기동 마이그레이션(Flyway)을 웹 서버 기동 시 함께 실행할지(In-App) 혹은 별도 원샷 컨테이너/Job으로 격리할지 선택할 수 있도록 `ca-skeleton.runtime.migration-on-startup` (환경 변수: `APP_MIGRATION_ON_STARTUP`, 기본값 `true`) 설정을 도입하고 `MigrationStartupRunner`, `RuntimeSafetySettings`, `docs/registries/env-keys.yaml`을 연동 갱신하여 런타임 운영 유연성을 확보하고 `MigrationStartupRunnerTest`에 우회(bypass) 검증 시나리오를 추가하여 빌드 검증을 완료함.
|
||||
- 2026-06-23 Local environment configuration alignment: `docker-compose.local.yml`에서 애플리케이션의 등록된 환경 변수(`APP_DATASOURCE_*`)와 일치하도록 명칭을 수정(기존 `SPRING_DATASOURCE_*` 제거)하고, 템플릿의 로컬 개발 DB 기본 자격 증명(`ca_skeleton`)이 fallback 디폴트로 자동 바인딩되도록 개선하여 별도 환경변수 입력이나 보간 오류 없이 로컬 스택이 구동 가능하도록 정합성을 확보함.
|
||||
|
||||
### 검증 증거
|
||||
|
||||
- `cd src && ./gradlew resolveAndLockAll --write-locks --no-daemon` → 성공, 10개 module lockfile 생성.
|
||||
- `cd src && ./gradlew check verifyPublicPathSnapshot --no-daemon` → 최종 변경 후 성공, 108 tasks(89 executed / 19 up-to-date), public path snapshot unchanged.
|
||||
- `cd src && ./gradlew verifyDependencyLocks ...` → 정상 lock 성공. 격리 사본에서 transitive `spring-core` entry 제거 후 동일 task → 기대한 non-zero와 `not part of the dependency lock state` 확인.
|
||||
- `bash .github/scripts/verify-reproducible-build.sh` → 성공, 두 clean build 모두 `af5e00540adad76313d778680d2ef20dca241671e08107c0144d3961d721f77d`.
|
||||
- `docker build ... -t ca-tmpl:supply-chain-test src` → 최종 strict-lock preflight 포함 성공. `USER=app`, OCI version/revision/source label 확인.
|
||||
- `bash -n .github/scripts/*.sh`, 공급망 정적 계약, behavior test, gate matrix 검사, `yq` workflow parse, `jq` policy parse, `git diff --check` → 성공.
|
||||
- Actionlint pinned container는 2026-06-20 실행에 성공했으나, 2026-06-21 최종 재실행은 private workspace 내용을 third-party image에 노출하는 정책으로 거부됐다. 저장소 mount와 stdin 전달 모두 중단하고 `yq` + 정적 계약으로 대체했다. 상세: [[raw/errors/sandbox-build-verification-boundaries-2026-06-21]].
|
||||
- 2026-06-23 `verify-supply-chain-contract.sh` RED → installer/6개 job 배선/inline download 금지 15건 실패 확인 후 GREEN. `install-jq.sh`가 jq 1.8.1 AMD64 공식 asset을 내려받아 SHA-256 검증 후 실행했고, 설치된 바이너리로 `test-supply-chain-scripts.sh` 양/음수 경로가 성공했다.
|
||||
- `./gradlew verifyCleanArchitectureDependencies`, `:app-bootstrap:test --tests '*CleanArchitectureTest'`, `test`, `check verifyPublicPathSnapshot` 모두 성공. 최종 `check`는 108 tasks(7 executed / 101 up-to-date), public path snapshot unchanged.
|
||||
- 실패 로그와 동일한 `node:20-bullseye` container 재검증은 private workspace mount 위험으로 실행 승인이 거부되어 중단했다. 실제 Gitea/act 재실행은 `needs-confirmation`. 상세: [[raw/errors/gitea-act-missing-jq-job-bootstrap-2026-06-23]].
|
||||
- 2026-06-23 컴포넌트 스캔 제한, 순환 참조 해결, 로깅 레벨 복구 적용 상태에서 전체 빌드/테스트 및 로컬 기동 검증: `cd src && ./gradlew test` 빌드가 성공(BUILD SUCCESSFUL)함을 확인하고, 로컬 PostgreSQL 컨테이너(`ca-pg`)를 기동하여 `./gradlew :app-bootstrap:bootRun`을 실행함으로써 Flyway 마이그레이션 적용 및 `Started CaSkeletonApplication` 기동 성공을 로그로 검증함.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-05-22: release 가능한 artifact는 source revision과 version을 추적 가능해야 함.
|
||||
- 2026-05-22: high/critical vulnerability는 기본 release-blocking으로 둠.
|
||||
- 2026-05-22: dependency upgrade bot은 Renovate 기본, Dependabot은 조직 표준일 때 허용.
|
||||
- 2026-05-22: SBOM만으로는 충분하지 않음. image digest는 필수, Cosign signature와 SLSA provenance는 release-blocking 의무. signature 없이 deploy는 forbidden.
|
||||
- 2026-05-22: container base image default는 container-runtime branch의 Temurin JRE slim 결정을 소비.
|
||||
- 2026-05-22: Cosign keyless signing (sigstore Fulcio) 의무화. release artifact에 signature 누락 시 deploy block.
|
||||
- 2026-05-22: SLSA provenance attestation 의무화 (`build.config.source`, `build.invocation`, `materials` 포함). build provenance 검증 실패 시 deploy block.
|
||||
- 2026-05-22: dependency lock = Gradle dependency-locking 강제 (`gradle/locks/*.lockfile`). lock drift 시 build fail.
|
||||
- 2026-05-22: artifact version = SemVer + git sha suffix (예: 1.2.3+a1b2c3d). CalVer은 forbidden.
|
||||
- 2026-05-22: build reproducibility = `archives.preserveFileTimestamps=false`, `archives.reproducibleFileOrder=true`, JDK version pin via `.tool-versions` 또는 `gradle/wrapper/`. timestamp/locale entropy 제거.
|
||||
- 2026-05-22: rollback artifact 보관 = 최근 10개 release + 90일 (whichever longer).
|
||||
- 2026-05-22: Cosign verify는 `--certificate-identity=<expected>` + `--certificate-oidc-issuer=<expected>` 필수. signature 존재만 검증하면 fail.
|
||||
- 2026-05-22: provenance 생성 시 SLSA v1.0 공식 필드명(`buildDefinition.externalParameters`, `runDetails.builder.id` 등) 사용. 약식 명명 forbidden.
|
||||
- 2026-06-23: 각 CI job은 격리된 실행 환경이므로 jq 소비 job마다 공통 installer를 호출한다. installer는 jq 1.8.1과 AMD64/ARM64 checksum을 고정하고 `RUNNER_TEMP`/`GITHUB_PATH`만 사용한다. apt 설치·workflow별 curl 복제·runner image 사전 설치는 각각 root/배포판 결합, 정책 중복, 숨은 runner 결합 때문에 채택하지 않았다. 근거 raw claim 부재로 D14는 `UNSUPPORTED_DECISION`이다.
|
||||
|
||||
## Work Item Contract
|
||||
|
||||
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
|
||||
|
||||
| field | required | rule |
|
||||
| --------------------------- | ----------- | --------------------------------------------------- |
|
||||
| Decision | yes | 구현자가 선택해야 하는 기본값 |
|
||||
| Allowed | yes | 허용되는 예외와 조건 |
|
||||
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
|
||||
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
|
||||
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
|
||||
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
|
||||
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
|
||||
|
||||
## Supply Chain Defaults
|
||||
|
||||
| item | default | failure condition |
|
||||
| -------------- | ------------------------------------------------------------------------- | ----------------------------- |
|
||||
| dependency bot | Renovate | no upgrade policy |
|
||||
| SBOM | generated per release | release without SBOM |
|
||||
| image identity | immutable digest | tag-only promotion |
|
||||
| signature | Cosign release-blocking 의무. signature 없이 deploy는 forbidden. | no signed artifact plan |
|
||||
| provenance | SLSA provenance release-blocking 의무. signature 없이 deploy는 forbidden. | source revision not traceable |
|
||||
| vuln block | high/critical block | critical vuln warning-only |
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. `Decision ID` 는 이 branch-note 안에서 안정적으로 유지.
|
||||
|
||||
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| D1 | release 가능한 artifact 는 source revision 과 version 을 추적 가능해야 함 | `raw/official-docs/supply-chain-slsa-provenance-framework.md#SLSA-FW-C4` (provenance = where/when/how verifiable info), `raw/official-docs/supply-chain-slsa-provenance-framework.md#SLSA-FW-C5` (`builder.id` + `resolvedDependencies`) | `official-standard` (SLSA v1.0) | provenance 존재만으로 forge 방지 보장 안 됨 (`SLSA-FW-C1` L1 한계) |
|
||||
| D2 | high/critical vulnerability 는 기본 release-blocking | `raw/official-docs/vuln-severity-cvss-v31-spec-first-official.md#C1` (CVSS v3.1 §5 severity bands: High 7.0–8.9 / Critical 9.0–10.0), `#C2` (qualitative ratings are optional — 조직이 이를 정책으로 강제 가능), `#C3` (Base Score = intrinsic/worst-case, Temporal/Environmental 보완적); 집행 메커니즘 `raw/official-docs/trivy-severity-exit-code-gating.md#TRIVY-EG-C2` | `official-standard` (FIRST.org CVSS v3.1) — **단 정책 owner 는 [[raw/branch-notes/feature-dependency-vulnerability-management-contract]]; 본 branch 는 consume** | severity 정책의 single owner = vuln-management branch (§Audit `OWNER_RECONCILE`). severity 임계값(≥7.0 / ≥9.0)이 "최적"이라는 것은 명세가 증명하지 않음 — 조직 정책 선택 |
|
||||
| D3 | dependency upgrade bot = Renovate 기본, Dependabot 은 조직 표준일 때 허용 | `raw/official-docs/renovate-gradle-manager-official.md#RENOV-GRAD-C1` (Gradle 파일 패턴 공식 지원), `#RENOV-GRAD-C2` (lockfile 유지 via --write-locks), `#RENOV-GRAD-C3` (self-hosted `allowedUnsafeExecutions: ["gradleWrapper"]` 필수); `raw/official-docs/dependabot-supported-ecosystems-official.md#DBOT-ECO-C1`~`C5` (Dependabot Gradle 지원 범위) | `official-vendor-doc` (Renovate + GitHub Dependabot) — **단 update-automation 정책 owner 는 [[raw/branch-notes/feature-dependency-vulnerability-management-contract]]; 본 branch 는 consume** | "Renovate 기본 vs Dependabot 조건부" 우선순위 결정 자체는 owner branch 소유. lockfile 경로 정합 필요 (§Audit `LOCKFILE_PATH_DRIFT`) |
|
||||
| D4 | SBOM + image digest 필수, Cosign signature + SLSA provenance release-blocking, signature 없이 deploy 는 forbidden | `raw/official-docs/supply-chain-cosign-keyless-sigstore.md#COSIGN-C1`, `raw/official-docs/supply-chain-cosign-keyless-sigstore.md#COSIGN-C4`, `raw/official-docs/supply-chain-slsa-provenance-framework.md#SLSA-FW-C4` | `official-vendor-doc` (Cosign) + `official-standard` (SLSA) | "signature 누락 시 deploy block" 의 admission controller 구현 (Kyverno/OPA Gatekeeper/sigstore-policy-controller) 별도 — 본 branch 범위 밖(§엣지·실패·의존) |
|
||||
| D5 | container base image default 는 container-runtime branch 의 Temurin JRE slim 결정 소비 | (cross-branch reference) `raw/official-docs/container-distroless-google-github.md#CDG-C1`, `raw/official-docs/container-alpine-java-musl-tradeoffs.md#CAJM-C4` (대안 trade-off — container-runtime branch 가 SSOT) | `cross-branch-reference` | [[raw/branch-notes/feature-container-runtime-contract]] **D3** (base image = Temurin JRE slim) 와 동기화 (§Audit `D5_CROSSREF_PRECISION`) |
|
||||
| D6 | Cosign keyless signing (sigstore Fulcio) 의무화, signature 누락 시 deploy block | `raw/official-docs/supply-chain-cosign-keyless-sigstore.md#COSIGN-C1` (keyless = identity 결합), `raw/official-docs/supply-chain-cosign-keyless-sigstore.md#COSIGN-C2` (Fulcio OIDC 검증 + cert 발급), `raw/official-docs/supply-chain-cosign-keyless-sigstore.md#COSIGN-C3` (10분 short-lived cert) | `official-vendor-doc` | GPG 대비 운영 부담 감소 직접 진술 (`COSIGN-C7`) 은 `needs-confirmation` — verbatim 미확보 |
|
||||
| D7 | SLSA provenance attestation 의무화 (`build.config.source`, `build.invocation`, `materials`), 검증 실패 시 deploy block | `raw/official-docs/supply-chain-slsa-provenance-framework.md#SLSA-FW-C4`, `raw/official-docs/supply-chain-slsa-provenance-framework.md#SLSA-FW-C5`, `raw/official-docs/supply-chain-slsa-provenance-framework.md#SLSA-FW-C6` (in-toto Statement) | `official-standard` (SLSA v1.0 + in-toto) | ca-tmpl 약식 필드명은 spec 실제 필드명과 불일치 — `SLSA-SCH-*` claim 으로 보강 (D13 참조) |
|
||||
| D8 | dependency lock = Gradle dependency-locking (`gradle/locks/*.lockfile`), lock drift 시 build fail | `raw/official-docs/supply-chain-gradle-vs-maven-dependency-locking.md#SC-DL-C1`~`SC-DL-C9` (Gradle dependency-locking vs Maven Enforcer 비교) | `official-vendor-doc` | Maven Enforcer 의 1급 lockfile 부재는 SC-DL claim 으로 직접 지지. 선언 경로 `gradle/locks/*.lockfile` vs Renovate 인식 기본 경로 drift (§Audit `LOCKFILE_PATH_DRIFT`) |
|
||||
| D9 | artifact version = SemVer + git sha suffix (1.2.3+a1b2c3d), CalVer forbidden | `raw/official-docs/semver-2-0-0-spec-semver-official.md#SEMVER-C3` (build metadata `+` suffix는 precedence에서 무시됨), `#SEMVER-C4` (Build metadata does not figure into precedence), `#SEMVER-C5` (`1.2.3+sha` vs `1.2.3-sha` 의미 구분), `#SEMVER-C1` (MAJOR.MINOR.PATCH 증가 의미론); negative-evidence `raw/official-docs/calver-spec-calver-official.md#CALVER-C2`/`C3`/`C5` | `official-standard` (SemVer 2.0.0 spec) | CalVer forbidden 은 spec 이 직접 금지하는 것이 아님 — 팀 컨벤션; 일부 레지스트리/도구가 `+` 문자를 tag 에 허용하지 않을 수 있음 (도구 호환성 별도 검증 필요) |
|
||||
| D10 | build reproducibility = `preserveFileTimestamps=false`, `reproducibleFileOrder=true`, JDK pin | `raw/official-docs/gradle-reproducible-archives-working-with-files.md#GRADLE-RA-C1` (preserveFileTimestamps=false → 기계/JVM/OS 간 타임스탬프 통일), `#GRADLE-RA-C2` (reproducibleFileOrder=true → 파일시스템 순서 독립 → byte-for-byte 재현 기여), `#GRADLE-RA-C3` (tasks.withType<AbstractArchiveTask>().configureEach {} 전역 적용 패턴); 보조: `raw/official-docs/reproducible-builds-org-jvm-guide.md#RB-JVM-C1`~`RB-JVM-C6` (cross-ecosystem 정의 + JVM nondeterminism 원인 목록) | `official-vendor-doc` (Gradle DSL reference) + `official-reference` (reproducible-builds.org) | JDK pin (`.tool-versions`/Gradle Toolchains) 은 본 raw source 범위 밖 — UNSUPPORTED_IMPL(§구현 가이드). 두 property 조합만으로 완전한 reproducibility 보장 아님 (C2: "helps") |
|
||||
| D11 | rollback artifact 보관 = 최근 10개 release + 90일 (whichever longer) | UNSUPPORTED_DECISION (외부 source 없음 — 조직 retention 정책; 2026-06-15 자동조사에서도 10/90 정량값을 정의하는 외부 표준 미발견) | `team-policy` | 10/90 정량값 외부 표준 부재 — owner=조직 release 정책. trade-off: 보수적 보관(스토리지 비용 ↔ rollback 가용성). 재평가 트리거: 스토리지 비용 임계 초과 또는 rollback 빈도 변화. 자동 강제 = Claims To Verify(registry retention IaC) |
|
||||
| D12 | Cosign verify 는 `--certificate-identity` + `--certificate-oidc-issuer` 필수, signature 존재만 검증하면 fail | `raw/official-docs/cosign-keyless-identity-verification-policy.md#CSIGN-KL-C1`~`CSIGN-KL-C4` (identity 매칭 정책) | `official-vendor-doc` | admission controller 통합 시 policy DSL 별도 |
|
||||
| D13 | provenance 생성 시 SLSA v1.0 공식 필드명 사용 (`buildDefinition.externalParameters`, `runDetails.builder.id` 등), 약식 명명 forbidden | `raw/official-docs/slsa-v1-provenance-schema.md#SLSA-SCH-C1`~`SLSA-SCH-C8` (SLSA v1.0 spec 필드명) | `official-standard` | D7 의 ca-tmpl 약식 필드명이 본 결정과 충돌 — wiki/projects 추출 시 spec 필드명 채택 |
|
||||
| D14 | jq 1.8.1을 checksum 검증해 job-local 설치하고 jq 소비 job 6개가 공통 installer를 호출 | `UNSUPPORTED_DECISION` — CI 장애 로그와 jq 1.8.1 GitHub release asset metadata를 구현 증거로 사용했으나 raw source Claim ID는 만들지 않음 | `local-incident + vendor-release-metadata` | 실제 Gitea/act runner 재실행 전까지 `needs-confirmation`; GitHub release host egress가 차단된 runner는 내부 mirror 설계가 별도 필요 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> _결정_ 이 "_무엇_" 이면 본 §는 "_어디에 어떻게_" 의 사전 명세. 다음 구현자가 되묻지 않고 코드를 작성할 수준이 목표.
|
||||
>
|
||||
> **코드 ground truth (2026-06-15 확인)**: ca-tmpl `src/Dockerfile` = 빈 파일, `gradle/locks/` 부재, `.github/workflows/` 부재, cosign/slsa config 부재 → 본 § 의 모든 detail 은 `planned`. 어떤 항목도 `actually-implemented` 아님.
|
||||
>
|
||||
> **3-rule**: 각 sub-section 은 Decision ID + Supporting Claim ID 를 Trace. 근거 raw 가 원칙만 권고하고 detail 을 권고 안 하면 `UNSUPPORTED_IMPL_DECISION` 라벨 + trade-off 한 줄. branch 결정 범위 밖 cell 은 `OUT_OF_BRANCH_SCOPE` 로 정제(별도 owner 이관).
|
||||
|
||||
### 1. Dependency version locking + reproducible build (Trace: D8 · SC-DL-C1~C9 / D10 · GRADLE-RA-C1~C3 · RB-JVM-C3/C4/C6)
|
||||
|
||||
> **Trace**: D8(Gradle dependency-locking), D10(reproducible archives). 모두 `planned` (코드 부재).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (a) lockfile 경로 — D8 의 `gradle/locks/*.lockfile` 은 Gradle 기본(`gradle.lockfile`/`*.versions.lock`, RENOV-GRAD-C1)과 불일치 → §Audit `LOCKFILE_PATH_DRIFT`. trade-off: Gradle 기본 경로 채택 = Renovate 호환 우선. (b) `dirPermissions`/`filePermissions` 의 정확한 unix 값(755/644)은 RB-JVM-C4 가 원칙만 권고 — 팀 선택. (c) JDK pin 메커니즘(Gradle Toolchains vs `.tool-versions`/`gradle/wrapper/`)은 D10 raw 가 명시 안 함 — trade-off: Toolchains = 빌드 자체 강제, `.tool-versions` = 로컬 개발 동기화.
|
||||
|
||||
| 위치 / 설정 | 값 (planned) | 상태 | Trace |
|
||||
| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ------------------ |
|
||||
| `build.gradle.kts` dependencyLocking | `dependencyLocking { lockAllConfigurations(); lockMode = LockMode.STRICT }` | `planned` | D8 / SC-DL |
|
||||
| lockfile 경로 | Gradle 기본 `gradle.lockfile`(루트/서브프로젝트) — D8 의 `gradle/locks/*.lockfile` 와 정합 필요 | `planned` + DRIFT | D8 / RENOV-GRAD-C1 |
|
||||
| reproducible archives | `tasks.withType<AbstractArchiveTask>().configureEach { isPreserveFileTimestamps = false; isReproducibleFileOrder = true }` | `planned` | D10 / GRADLE-RA-C3 |
|
||||
| umask 정규화 | `dirPermissions { unix("755") }; filePermissions { unix("644") }` | `planned` (값=UNSUPPORTED_IMPL) | D10 / RB-JVM-C4 |
|
||||
| JDK pin | `java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }` + 로컬 `.tool-versions` | `planned` (메커니즘=UNSUPPORTED_IMPL) | D10 |
|
||||
| locale entropy | CI JVM args `-Dfile.encoding=UTF-8` (Java 17 이하) | `planned` | D10 / RB-JVM-C6 |
|
||||
|
||||
### 2. Artifact versioning (Trace: D9 · SEMVER-C1/C3/C4/C5)
|
||||
|
||||
> **Trace**: D9. SemVer 2.0.0 `MAJOR.MINOR.PATCH` + git short-sha build metadata.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: version bump 자동화 메커니즘(conventional-commits + semantic-release / GitVersion / 수동 tag)은 D9 raw 가 권고 안 함 — trade-off: 자동화 없으면 MAJOR/MINOR/PATCH 의미론이 팀 규율에 의존. (b) `+` 문자 registry 호환 — OCI tag 규칙이 `+` 를 거부하면 image-tag 층에서 치환(`_` 등) 필요(UNSUPPORTED_IMPL, D9 Open Risk).
|
||||
|
||||
| 항목 | 명세 (planned) | 근거 |
|
||||
| ------------ | --------------------------------------------------------------------------------- | ------------------ |
|
||||
| version 포맷 | `<MAJOR>.<MINOR>.<PATCH>+<short-sha>` (예: `1.2.3+a1b2c3d`) | SEMVER-C1 |
|
||||
| `+` 의미 | build metadata — precedence 에서 **무시**. `1.2.3+x` 와 `1.2.3+y` 동일 precedence | SEMVER-C3/C4 |
|
||||
| 금지 | `1.2.3-<sha>` 형식(= pre-release, precedence 낮춤) 사용 금지; CalVer 금지 | SEMVER-C5 / CALVER |
|
||||
|
||||
### 3. Artifact signing + provenance (Trace: D4 · COSIGN-C1/C4 · SLSA-FW-C4 / D6 · COSIGN-C1~C3 / D7 · SLSA-FW-C4~C6 / D12 · CSIGN-KL-C1~C4 / D13 · SLSA-SCH-C1~C8)
|
||||
|
||||
> **Trace**: D4/D6/D7/D12/D13. Cosign keyless 서명 + SLSA v1.0 provenance attestation. 모두 `planned`.
|
||||
>
|
||||
> - **OUT_OF_BRANCH_SCOPE**: deploy-time admission 강제(Kyverno / sigstore-policy-controller / OPA Gatekeeper)는 k8s admission/deploy 계약 — 본 branch 는 _서명된 artifact + verify 정책_ 만 생성, _클러스터 게이트_ 는 별도 owner. §엣지·실패·의존 + Claims To Verify 참조.
|
||||
|
||||
| 항목 | 명세 (planned) | 근거 |
|
||||
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
|
||||
| sign | `cosign sign --yes <image>@<digest>` (keyless, Fulcio OIDC, 10분 cert) | D6 / COSIGN-C1~C3 |
|
||||
| verify | `cosign verify --certificate-identity=<expected> --certificate-oidc-issuer=<expected> <image>` — identity flag **필수**, 존재만 검증하면 fail | D12 / CSIGN-KL-C1~C4 |
|
||||
| provenance | in-toto Statement, SLSA v1.0 필드명 `buildDefinition.externalParameters` / `runDetails.builder.id` 사용; 약식(`build.config.source`) 금지 | D7·D13 / SLSA-SCH |
|
||||
|
||||
### 4. Vulnerability severity gating — _consume_ (Trace: D2 → owner [[raw/branch-notes/feature-dependency-vulnerability-management-contract]])
|
||||
|
||||
> **Trace**: D2. severity 차단 _정책_ 의 single owner 는 vuln-management branch(§Audit `OWNER_RECONCILE`). 본 branch 는 release artifact 단계에서 그 정책을 _consume_ — 새 결정을 만들지 않는다.
|
||||
>
|
||||
> - **OUT_OF_BRANCH_SCOPE**: scanner _tool 선택_·CVSS 표준·차단 임계값·suppression governance 는 vuln-management owner. CI gate _wiring_ 은 [[raw/branch-notes/feature-ci-quality-gates-contract]](D5), image scan _wiring_ 은 [[raw/branch-notes/feature-container-runtime-contract]].
|
||||
|
||||
| 항목 | 본 branch 의 consume 지점 (planned) | 근거 |
|
||||
| ------------------ | -------------------------------------------------------------------------------------------------- | ------------ |
|
||||
| release-block 신호 | "high/critical → release fail" 을 owner 의 CVSS bands(High 7.0–8.9 / Critical 9.0–10.0)에 결합 | D2 / CVSS C1 |
|
||||
| 집행 vehicle | Trivy `--severity HIGH,CRITICAL --exit-code 1` (scanner wiring 은 ci-gates/container-runtime 소유) | TRIVY-EG-C2 |
|
||||
| 예외 경로 | `.trivyignore.yaml` `exp:` allowlist — governance 는 owner 소유 | TRIVY-EG-C4 |
|
||||
|
||||
### 5. Dependency update bot — _consume_ (Trace: D3 → owner [[raw/branch-notes/feature-dependency-vulnerability-management-contract]])
|
||||
|
||||
> **Trace**: D3. update-automation _정책_(Renovate primary / Dependabot 조건부) owner 는 vuln-management. 본 branch 의 직접 관심사는 단 하나 — lockfile(D8)이 선택된 bot 과 호환되어야 함.
|
||||
>
|
||||
> - **DRIFT**: D8 의 lockfile 경로 vs Renovate 인식 경로 → §Audit `LOCKFILE_PATH_DRIFT`. bot CHOICE 자체는 owner 결정.
|
||||
|
||||
| 항목 | 본 branch 의 consume 지점 (planned) | 근거 |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------- | ---------------- |
|
||||
| Renovate lockfile 갱신 | `config:recommended` + self-hosted 시 `allowedUnsafeExecutions: ["gradleWrapper"]` (lockfile `--write-locks`) | RENOV-GRAD-C2/C3 |
|
||||
| 경로 정합 | D8 lockfile 경로를 Renovate `fileMatch`/Gradle 기본과 일치 | RENOV-GRAD-C1 |
|
||||
|
||||
### 6. Rollback artifact retention (Trace: D11 · UNSUPPORTED_DECISION)
|
||||
|
||||
> **Trace**: D11. 최근 10개 release + 90일(whichever longer).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 10/90 정량값 + registry retention 강제 메커니즘(registry retention IaC / 정기 audit cron)은 외부 표준 부재 — 조직 정책. trade-off: 보수적 보관(스토리지 비용 ↔ rollback 가용성). 자동 강제 검증은 Claims To Verify.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4 캡처용. 정상 경로 외 _구현 중 부딪힐_ 실패/엣지/다른 계약 의존을 미리 열거.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **lock drift**: 선언 dependency ≠ lockfile → build fail (D8). 엣지: _transitive-only_ version 변경도 fail 해야 함(Claims To Verify).
|
||||
- **reproducibility 부분 보장**: 동일 commit 이라도 JDK vendor/version 또는 build cache 차이로 hash 불일치 가능 — GRADLE-RA-C2 는 "helps"(보장 아님). 테스트 계약의 "2회 build hash 일치" 는 _동일 toolchain_ 전제.
|
||||
- **unfixed CVE**: 상위 fix 없는 HIGH CVE → release 무기한 차단; `.trivyignore.yaml exp:` 예외로 완화(D2 consume). 엣지: 만료된 예외는 다시 fail 로 표면화.
|
||||
- **SemVer `+sha` registry 거부**: OCI/registry tag 규칙이 `+` 거부 시 image-tag 층 치환 필요(D9 엣지).
|
||||
- **signature 강제 누수**: cosign 서명은 생성되나 admission controller 미배포 → unsigned image 가 deploy 통과 가능(D4/D6 의 "forbidden" 이 강제 안 됨). 엣지: admission gate 배포 전까지 유효.
|
||||
- **Renovate lockfile 경로 mismatch**: D8 경로와 Renovate 인식 경로 불일치 시 bot 이 lock 갱신을 조용히 실패(§Audit `LOCKFILE_PATH_DRIFT`).
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-dependency-vulnerability-management-contract]] — vuln severity 정책(D2) + dependency update automation(D3)의 single owner. 본 branch 는 release-gating 에서 consume. owner 가 임계값/bot 을 바꾸면 본 branch 의 release-block 신호 + lockfile 호환 가정이 영향.
|
||||
- [[raw/branch-notes/feature-container-runtime-contract]] **D3** — base image(Temurin JRE slim) + non-root USER. 본 branch D5 가 consume. base image 변경 시 image digest/scan surface 영향.
|
||||
- [[raw/branch-notes/feature-ci-quality-gates-contract]] — CI gate _wiring_(release-blocking vs warning-only)의 owner. 본 branch 의 release-blocking 신호를 파이프라인 단계에서 집행. 단 scanner _tool_ 확정은 그 branch 의 D5(`UNSUPPORTED_DECISION` + OWNER_AMBIGUITY)가 아니라 [[raw/branch-notes/feature-dependency-vulnerability-management-contract]] **D1**(Trivy 확정 owner)이 소유한다.
|
||||
- [[raw/branch-notes/feature-developer-experience-contract]] — DX 진입점(`./gradlew bootstrap`, `.tool-versions`, Testcontainers, link-rot)의 owner. 본 branch D10 의 JDK pin 은 그 branch 의 `.tool-versions`(D6) 핀과 정합 필요.
|
||||
- **k8s admission controller** (deploy/security 계약, owner 미식별) — 본 branch 의 "signature 없이 deploy forbidden"(D4/D6)은 그 gate 가 존재해야 강제 가능.
|
||||
|
||||
## 테스트 계약
|
||||
|
||||
- artifact에 version/source revision 식별자가 없으면 실패.
|
||||
- container가 root user로만 실행 가능하면 실패.
|
||||
- release artifact 재생성 없이 rollback할 수 없으면 실패.
|
||||
- dependency upgrade policy가 없으면 실패.
|
||||
- SBOM은 있으나 image digest/source revision 추적이 없으면 실패.
|
||||
- signature 없는 artifact 발견 시 release fail.
|
||||
- reproducibility 검증: 동일 commit 2회 build → artifact hash 불일치 시 fail.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다. 구현 전/중/후에 실제로 검증해야 하는 주장을 분리.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
| -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| GitHub Actions hosted runner 기반 build 가 SLSA Build L2 도달 | `SLSA-FW-C2` 는 hosted dedicated infrastructure + signed provenance 요구, hosted runner 가 자동 L2 라는 뜻은 아님 | slsa-github-generator action 으로 provenance 생성 + slsa-verifier 로 `--builder-id` / `--source-uri` 검사 통과 verify | `actually-implemented`; live run `needs-confirmation` |
|
||||
| Cosign `--certificate-identity` + `--certificate-oidc-issuer` 매칭이 admission 단계에서 강제 | Cosign verify CLI 자체는 검증만, deploy gate 통합은 별도 | sigstore-policy-controller 또는 Kyverno policy 작성 → mismatched identity 의 image deploy 실패 verify | `documented-only`; deploy admission은 `OUT_OF_BRANCH_SCOPE` |
|
||||
| Gradle dependency-locking 이 transitive dependency 모두를 lock | Gradle 공식 lockfile 의 transitive 포함 여부 확인 필요 | lockfile transitive entry 확인; 의도적으로 `spring-core` entry 제거 후 strict verification non-zero 확인 | `locally-verified` |
|
||||
| 동일 commit 2회 build → artifact hash 일치 (reproducibility) | timestamp/locale entropy 외에 build 환경 차이 (JDK build, dependency cache) 가능 | 두 clean local build SHA-256 비교; 후속 CI runner와 local 교차 비교 | 동일 환경 `locally-verified`; 교차 환경 `needs-confirmation` |
|
||||
| Renovate 가 Gradle 기본 lockfile 경로를 인식·갱신 | Renovate 실행 환경과 wrapper 허용 정책에 따라 lock 갱신 실패 가능 | `gradle.lockfile` + `renovate.json` 배선 후 Renovate dry-run → lock 갱신 PR 생성 여부 verify | 경로 `actually-implemented`; dry-run `needs-confirmation` |
|
||||
| Rekor transparency log entry 가 signing 후 검증 측에서 접근 가능 | Rekor public instance (rekor.sigstore.dev) 가용성 SLA 부재 | sign 후 `cosign verify --rekor-url=...` 로 transparency log entry 검증 | `actually-implemented`; live run `needs-confirmation` |
|
||||
| SBOM 생성 도구가 모든 dependency 를 누락 없이 캡처 | SBOM 도구의 false negative 가능 | SBOM 출력 vs `gradle dependencies` diff verify; 의도적 dependency 추가 후 SBOM 갱신 verify | 생성 gate `actually-implemented`; 완전성 `needs-confirmation` |
|
||||
| signature 없는 artifact 가 deploy pipeline 의 어느 단계에서도 통과 못 함 | admission controller 미배포 시 검증 누수 가능 | 의도적으로 unsigned image 를 push → deploy gate 에서 block 되는지 verify (다중 환경: dev/staging/prod) | release promotion `actually-implemented`; deploy admission `OUT_OF_BRANCH_SCOPE` |
|
||||
| rollback artifact 10개/90일 retention 정책이 자동 강제 | registry retention policy 가 수동 설정 시 drift 가능 | scheduled audit + fixture에서 protected manifest/SBOM/GHCR digest 누락·불일치가 실패하는지 검증 | fixture `locally-verified`; live audit `needs-confirmation` |
|
||||
| Gitea/act의 `node:20-bullseye` job에서 공통 jq installer 이후 공급망 behavior test가 통과 | 동일 컨테이너 검증은 private workspace mount 위험으로 승인 거부됨 | 변경 commit으로 `ci-quality-gates/gate-matrix-lint` 재실행 후 `install-jq: jq-1.8.1` 및 `test-supply-chain-scripts: OK` 로그 확인 | installer/behavior local `locally-verified`; Gitea CI `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
> `/coverage` 가 채우는 **생성물** — 손으로 유지하지 않는다. governing 문서([[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]])가 요구하는 관심사를 이 브랜치가 빠짐없이 덮는지의 결과. 2026-06-15 `coverage-auditor` 판정: **Covered** (Blocking 0 / Should-fix 3 → Coverage 섹션 정규화로 해소 / Advisory 1).
|
||||
> 상태: `covered-here`(이 브랜치 결정) / `delegated`(다른 owner 브랜치) / `missing`(아무도 안 맡음 → Blocking).
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
| ------------------------------------------------------------------------------------ | ------------ | ---------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------- |
|
||||
| Cosign keyless signing (Fulcio + Rekor) 의무화 | covered-here | — | — | D6 (COSIGN-C1~C3) |
|
||||
| Cosign verify identity 정책 (`--certificate-identity` + `--certificate-oidc-issuer`) | covered-here | — | — | D12 (CSIGN-KL-C1~C4) |
|
||||
| SLSA provenance attestation + SLSA v1.0 공식 필드명 강제 | covered-here | — | — | D7 (SLSA-FW-C4~C6) + D13 (SLSA-SCH-C1~C8) |
|
||||
| Gradle dependency-locking (lockMode=STRICT) | covered-here | — | — | D8 (SC-DL-C1~C9) |
|
||||
| SemVer + git sha suffix 버전 정책 (CalVer 금지) | covered-here | — | — | D9 (SEMVER-C1/C3/C4/C5 + CALVER negative-evidence) |
|
||||
| Build reproducibility (preserveFileTimestamps/reproducibleFileOrder/JDK pin) | covered-here | — | — | D10 (GRADLE-RA-C1~C3 + RB-JVM-C1~C6) |
|
||||
| SBOM 생성 (release per) + image digest 필수 | covered-here | — | — | D4 (COSIGN-C4 + SLSA-FW-C4) + §Supply Chain Defaults |
|
||||
| Rollback artifact 보관 (최근 10개 / 90일) | covered-here | — | — | D11 (UNSUPPORTED_DECISION, team-policy) |
|
||||
| Container base image (Temurin JRE slim) + non-root runtime | delegated | [[raw/branch-notes/feature-container-runtime-contract]] D3 | OK | D5 consume; §엣지·실패·의존 cross-link |
|
||||
| Vulnerability severity 정책 (high/critical release-blocking, CVSS v3.1) | delegated | [[raw/branch-notes/feature-dependency-vulnerability-management-contract]] | OK | D2 consume; §Audit OWNER_RECONCILE |
|
||||
| Dependency update automation (Renovate primary, Dependabot 조건부) | delegated | [[raw/branch-notes/feature-dependency-vulnerability-management-contract]] | OK | D3 consume; §Audit OWNER_RECONCILE |
|
||||
| GitHub Actions gate model + Trivy scan wiring | delegated | [[raw/branch-notes/feature-ci-quality-gates-contract]] | OK | §구현 가이드 4 OUT_OF_BRANCH_SCOPE; §엣지·실패·의존 cross-link |
|
||||
| OpenAPI snapshot diff / flaky quarantine | delegated | [[raw/branch-notes/feature-ci-quality-gates-contract]] | OK | governing doc CI 슬라이스 — 본 branch 범위 밖 |
|
||||
| DX 진입점 (`./gradlew bootstrap`, `.tool-versions`, Testcontainers, link-rot) | delegated | [[raw/branch-notes/feature-developer-experience-contract]] | ⚪ Advisory | governing doc DX 슬라이스 — 본 branch 범위 밖; D10 JDK pin 은 dx `.tool-versions`(D6)와 정합 |
|
||||
|
||||
## Audit & Findings
|
||||
|
||||
> 2026-06-15 `/branch-spec` 자동조사 라운드에서 발견한 정합 항목. **자동 rewrite 하지 않고 권고만** 기록(사용자 결정 영역). `/sync` 가 Single-Owner 정합을 수행.
|
||||
|
||||
- **`OWNER_RECONCILE` (Single-Owner, 권고)**: D2(vuln severity 정책) + D3(dependency update automation 정책)의 _정책_ single owner 는 [[raw/branch-notes/feature-dependency-vulnerability-management-contract]] (그 branch §Audit 가 본 branch 의 D2/D3 `UNSUPPORTED_DECISION` 스텁을 승계해 owner 선언). 2026-06-15 자동조사가 본 branch D2/D3 에 CVSS/Trivy/Renovate/Dependabot 공식 source 8건 중 일부를 아카이브했고 이 source 들은 _owner_ 정책도 뒷받침한다. **권고**: `/sync` 로 본 branch 의 D2/D3 를 owner 의 Reference-Only 포인터로 정합(RESTATED_FOREIGN_DECISION 방지). 본 branch 의 D2/D3 는 _consume_ 관계(§구현 가이드 4·5)로 유지.
|
||||
- **`CVSS_CLAIM_ANCHOR_FIX` (정정 완료)**: D2 의 CVSS 참조 anchor 를 `#CVSS-SRS-C1/2/3` → `#C1/C2/C3` 로 정정. 재사용된 기존 파일 `raw/official-docs/vuln-severity-cvss-v31-spec-first-official.md` 의 실제 claim ID 는 `C1`~`C5`.
|
||||
- **`LOCKFILE_PATH_DRIFT` (권고)**: D8 은 `gradle/locks/*.lockfile` 경로를 선언하나, Gradle 기본/Renovate 인식 경로는 루트 `gradle.lockfile` + `*.versions.lock` (RENOV-GRAD-C1). 정합 안 하면 Renovate(D3 owner 영역)가 lock 갱신 실패. **권고**: D8 경로를 Gradle 기본으로 정합하거나 Renovate `fileMatch` override. 실측 = Claims To Verify.
|
||||
- **`D5_CROSSREF_PRECISION` (권고)**: D5 Open Risk 의 cross-branch 동기화 대상은 [[raw/branch-notes/feature-container-runtime-contract]] 의 **D3**(base image = Temurin JRE slim)로 좁히는 것이 정확(기존 "D2~D4" 는 광범위). 비차단 — 사용자 결정 영역, 권고만.
|
||||
|
||||
## 완료 후 wiki 추출 대상
|
||||
|
||||
- `wiki/projects/ca-skeleton-operational-contract.md`의 build/release/supply-chain canonical section.
|
||||
- 정합 governing canonical: [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]] (Supply chain §).
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- Gradle `dependencies` report는 strict lock 누락을 `FAILED`로 표시해도 exit 0으로 끝나 Docker preflight가 fail-open이었다.
|
||||
- 원인: dependency report가 진단 task이고 unresolved configuration을 build failure로 전파하지 않음.
|
||||
- 해결: 실제 모든 resolvable configuration을 resolve하는 `verifyDependencyLocks` task를 추가하고 Docker preflight에 연결. transitive lock entry 제거 negative test로 exit 1 확인.
|
||||
- sandbox/외부 도구 경계로 Gradle과 Actionlint 재검증이 한때 차단됐다.
|
||||
- Gradle은 사용자 승인 escalated 실행으로 해결했고, Actionlint는 third-party image에 workspace data를 전달하지 않고 `yq` + 정적 계약으로 대체했다.
|
||||
- 별도 에러 노트: [[raw/errors/sandbox-build-verification-boundaries-2026-06-21]].
|
||||
- Gitea/act의 `gate-matrix-lint`가 정적 계약 통과 뒤 `jq: command not found`(exit 127)로 실패했다.
|
||||
- 원인: `ubuntu-latest`가 `node:20-bullseye`로 매핑됐지만 jq 소비 job이 runner 기본 도구를 암묵적으로 가정했다.
|
||||
- 해결: checksum 검증 공통 installer를 추가하고 직접·간접 jq 소비 job 6곳에 연결했다.
|
||||
- 별도 에러 노트: [[raw/errors/gitea-act-missing-jq-job-bootstrap-2026-06-23]].
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/calver-spec-calver-official]]
|
||||
- [[raw/official-docs/ci-github-actions-vs-gitlab-comparison]]
|
||||
- [[raw/official-docs/cosign-keyless-identity-verification-policy]]
|
||||
- [[raw/official-docs/dependabot-supported-ecosystems-official]]
|
||||
- [[raw/official-docs/dx-devcontainer-spring-boot]]
|
||||
- [[raw/official-docs/dx-mise-asdf-tool-versioning]]
|
||||
- [[raw/official-docs/gradle-reproducible-archives-working-with-files]]
|
||||
- [[raw/official-docs/renovate-gradle-manager-official]]
|
||||
- [[raw/official-docs/reproducible-builds-org-jvm-guide]]
|
||||
- [[raw/official-docs/scorecard-cis-benchmarks-slsa]]
|
||||
- [[raw/official-docs/semver-2-0-0-spec-semver-official]]
|
||||
- [[raw/official-docs/slsa-v1-provenance-schema]]
|
||||
- [[raw/official-docs/supply-chain-cosign-keyless-sigstore]]
|
||||
- [[raw/official-docs/supply-chain-gradle-vs-maven-dependency-locking]]
|
||||
- [[raw/official-docs/supply-chain-slsa-provenance-framework]]
|
||||
- [[raw/official-docs/trivy-severity-exit-code-gating]]
|
||||
- [[raw/official-docs/vuln-severity-cvss-v31-spec-first-official]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: interviews:start -->
|
||||
- [[raw/interviews/digest-first-supply-chain-release-gates]]
|
||||
<!-- GENERATED: interviews:end -->
|
||||
|
||||
<!-- GENERATED: errors:start -->
|
||||
- [[raw/errors/gitea-act-missing-jq-job-bootstrap-2026-06-23]]
|
||||
- [[raw/errors/sandbox-build-verification-boundaries-2026-06-21]]
|
||||
<!-- GENERATED: errors:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/digest-first-java-release-pipeline-2026-06-21]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
> 본 feature branch 는 leaf — 자식 자료 없음. Phase C2 실 코드 작성 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 근거 자료
|
||||
|
||||
- [[raw/official-docs/supply-chain-cosign-keyless-sigstore]]
|
||||
- [[raw/official-docs/supply-chain-slsa-provenance-framework]]
|
||||
- [[raw/official-docs/supply-chain-gradle-vs-maven-dependency-locking]]
|
||||
- [[raw/official-docs/cosign-keyless-identity-verification-policy]]
|
||||
- [[raw/official-docs/slsa-v1-provenance-schema]]
|
||||
- [[raw/official-docs/vuln-severity-cvss-v31-spec-first-official]]
|
||||
- [[raw/official-docs/semver-2-0-0-spec-semver-official]]
|
||||
- [[raw/official-docs/trivy-severity-exit-code-gating]]
|
||||
- [[raw/official-docs/renovate-gradle-manager-official]]
|
||||
- [[raw/official-docs/gradle-reproducible-archives-working-with-files]]
|
||||
- [[raw/official-docs/dependabot-supported-ecosystems-official]]
|
||||
- [[raw/official-docs/calver-spec-calver-official]]
|
||||
- [[raw/official-docs/reproducible-builds-org-jvm-guide]]
|
||||
|
||||
### 오류 기록 (본 feature 작업 중 발생)
|
||||
|
||||
- [[raw/errors/sandbox-build-verification-boundaries-2026-06-21]] — sandbox/cache/network 및 third-party container data-exposure 경계에서 verification을 안전하게 축소한 기록.
|
||||
- [[raw/errors/gitea-act-missing-jq-job-bootstrap-2026-06-23]] — minimal Gitea/act job image의 ambient jq 가정을 공통 checksum installer로 제거한 기록.
|
||||
- [[raw/errors/logback-shared-jvm-test-leak-pii-contract-2026-06-23]] — 공유 JVM 테스트 환경에서 로깅 레벨 오염으로 인해 순수 JUnit 로깅 테스트가 실패하는 현상을 로깅 레벨 격리로 해결한 기록.
|
||||
- [[raw/errors/spring-componentcan-multimodule-overlap-collision-2026-06-23]] — 멀티모듈 환경에서 최상위 패키지 기준의 컴포넌트 스캔 시 테스트 모듈 내 중복 빈 정의가 끌려 올라와 BeanDefinitionOverrideException 충돌을 야기하던 현상을 프로덕션 패키지 명시 스캔으로 변경하여 해결한 기록.
|
||||
- [[raw/errors/spring-jpa-flyway-circular-dependency-2026-06-23]] — Flyway ↔ EntityManagerFactory 간 초기화 순환 참조 문제를 static @Bean 정의 방식으로 해결한 기록.
|
||||
- [[raw/errors/spring-jpa-postgres-lob-oid-cast-2026-06-23]] — PostgreSQL text 컬럼에 대해 `@Lob`이 `oid` 타입 DDL 변경을 발생시켜 발생하는 캐스팅 오류를 `@JdbcTypeCode(SqlTypes.LONGVARCHAR)` 매핑 방식을 통해 해결한 기록.
|
||||
- [[raw/errors/sample-portfolio-flyway-out-of-order-2026-06-23]] — 단독 실행이 가능한 `sample-portfolio` 모듈 기동 시, 기 적용된 상위 버전에 의해 발생하는 Flyway의 `V2` out-of-order 미적용 validation 오류를 설정 조정을 통해 해결한 기록.
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- [[raw/interviews/digest-first-supply-chain-release-gates]] — Java/Gradle 릴리스에서 digest·SBOM·Cosign·SLSA를 promotion gate로 묶는 설계 질문.
|
||||
|
||||
### job-posting tie-ins (이 작업에서 파생된 글감)
|
||||
|
||||
- [[raw/blog-topics/digest-first-java-release-pipeline-2026-06-21]] — mutable tag가 아닌 digest를 검증·승격·rollback SSOT로 삼는 구현 글감.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- 없음 — 2026-06-23 CI 보완 작업에 대응하는 daily note는 작성되지 않음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모: self-review에서 release publication 순서, retention API fail-open, manifest↔GHCR digest 일치, exact SLSA builder ID, Trivy 무권한 설치, Gradle diagnostic task fail-open을 보강. 2026-06-23에는 jq job 격리와 checksum bootstrap 계약을 추가했다.
|
||||
- 머지 결과 / 배포 환경: 미머지. local build/test/contract/Docker 검증까지 완료; GitHub OIDC·Rekor·GHCR live release는 미실행.
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목: Gradle strict locks, SemVer+sha, digest-first release DAG, SPDX SBOM, Cosign identity, SLSA v1 exact builder, High/Critical gate, rollback audit, jq job-local bootstrap wiring.
|
||||
- `locally-verified` 항목: 전체 Gradle check, positive/negative lock drift, 두 clean build hash, Docker non-root/OCI labels, manifest/retention behavior tests, jq 1.8.1 checksum install과 공급망 behavior test.
|
||||
- `prod-verified` 항목: 없음.
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned): live OIDC/Rekor/GHCR release 결과와 deploy-time admission 강제는 검증 전 canonical 사실로 추출하지 않음.
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-business-rule-validation-contract.md
|
||||
@@ -0,0 +1,373 @@
|
||||
---
|
||||
title: branch / feature-business-rule-validation-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-business-rule-validation-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [wiki/projects/ca-tmpl/clean-architecture-package-layout, wiki/projects/ca-tmpl/api-error-envelope-design]
|
||||
tags: [branch, ca-skeleton, validation, business-rule, domain]
|
||||
created: 2026-05-22
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-037
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-037
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MAPPING-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-ERROR-ENVELOPE-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: 9fe64ae8128001379c77396ee11cfe7afe9196c837a5de4b2500c0c443b6213b
|
||||
---
|
||||
|
||||
# branch: feature-business-rule-validation-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — syntax validation, use case policy, business invariant, persistence integrity 검증 책임을 분리합니다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (§<관련 섹션>) 의 결정/근거/금지 사항을 정제한다.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: validation ownership·mapper failure contract test가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MAPPING-001@1` | 수기 mapper와 record canonical constructor가 default이며 MapStruct는 optional profile이다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-ERROR-ENVELOPE-001@1` | foundation이 envelope schema와 error.category enum의 단일 owner다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
validation이라는 이름으로 모든 규칙이 controller DTO나 DB constraint에 몰리면 도메인 적용 후 유지보수가 무너집니다. 어떤 규칙을 어느 경계에서 검증할지 명확히 분리합니다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- request syntax/shape validation.
|
||||
- application policy validation.
|
||||
- domain invariant validation.
|
||||
- persistence uniqueness/integrity handling.
|
||||
- duplicate validation 허용 기준.
|
||||
- validation error response/log 기준.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 특정 비즈니스 규칙 설계.
|
||||
- frontend validation 정책.
|
||||
- database schema design 전체.
|
||||
|
||||
## TODO
|
||||
|
||||
> TODO drained 2026-05-22 — 결정은 아래 "결정 사항" / "Decisionized Work Items" / "판정 기준" 참조. syntax/policy/invariant/persistence/duplicate/validation details 모두 표 row로 반영됨. 잔존 TODO 없음.
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
> 작업하며 떠오른 메모. 자유 형식.
|
||||
|
||||
- 현재 documented-only 단계 — D1~D9 결정·근거 + §구현 가이드 명세 작성 완료, 실제 코드 미착수.
|
||||
- D5/D6/D7 (envelope shape + code→category 매핑) 은 sibling `feature-boundary-validation-mapping-contract` 와 결정이 중첩 — 구현 명세는 sibling 소유로 정제(§Audit F1/F3). 본 branch 는 4-layer 책임 view 에 집중.
|
||||
- **2026-06-02 ca-tmpl ground-truth 패스** (실 코드/registry 대조):
|
||||
- **F5 RESOLVED** — `PERSISTENCE` enum 은 실재하지 않음(`Category.java` 10-enum). 실제 매핑 `DB_UNIQUE_VIOLATION`→CONFLICT / `DB_NULL·FK·CHECK`→DATA_INTEGRITY 로 전 표 정합.
|
||||
- **persistence integrity 핸들러 미구현 확인** — `GlobalExceptionHandler` 에 `DataIntegrityViolationException` 핸들러 없음. owner `feature-persistence-failure-baseline`(documented-only). §2 에 `planned` 명시.
|
||||
- **F2 보강** — policy → AUTHZ 실재 코드(`AUTHZ_INSUFFICIENT_PERMISSION`/`AUTHZ_TENANT_MISMATCH`) 확인, 단 owner 는 security/tenant branch → consume. D-ID gap 은 여전히 open.
|
||||
- open gap (잔존): use case policy layer D-ID 미부여(§Audit F2). D1/D2 외부 근거 보강 deferred(§Audit F4). `error-codes.yaml:580` 주석의 stale `PERSISTENCE` 는 ca-tmpl 레포 측 정리 대상.
|
||||
|
||||
## Work Item Contract
|
||||
|
||||
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
|
||||
|
||||
| field | required | rule |
|
||||
| --- | --- | --- |
|
||||
| Decision | yes | 구현자가 선택해야 하는 기본값 |
|
||||
| Allowed | yes | 허용되는 예외와 조건 |
|
||||
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
|
||||
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
|
||||
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
|
||||
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
|
||||
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-05-22: request DTO validation은 입력 모양 검증만 담당.
|
||||
- 2026-05-22: business invariant는 domain에서 검증.
|
||||
- 2026-05-22: persistence integrity error는 operational error로 변환하되 client-safe message만 응답.
|
||||
- 2026-05-22: 이 branch의 TODO도 Work Item Contract를 따라야 하며 아래 Decisionized Work Items가 canonical 승급 기준이다.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/company-tech-blogs/stripe-error-format]] | endpoint dimension 명시 사례 |
|
||||
| [[raw/company-tech-blogs/toss-payments-error-format]] | 한국 컨벤션 reference |
|
||||
| [[raw/official-docs/problem-detail-rfc-7807]] | IETF 표준이지만 실패 전용, success/error 비대칭 |
|
||||
| [[raw/official-docs/spring-problem-detail]] | Spring 6 기본 지원이지만 ca-tmpl envelope과 충돌 |
|
||||
| [[raw/official-docs/google-api-error-format]] | 가장 표현력 풍부, retryable detail 1급 |
|
||||
| [[raw/official-docs/json-api-errors-spec]] | — |
|
||||
| [[raw/official-docs/graphql-errors-spec]] | partial success 1급 |
|
||||
| [[raw/company-tech-blogs/github-api-error-format]] | — |
|
||||
|
||||
## 외부 근거 / 대안 조사 (2026-05-22 — Topic 4)
|
||||
|
||||
본 branch의 business invariant violation → CONFLICT/VALIDATION mapping 결정에 대한 외부 source 조사. error.category enum과 1:1.
|
||||
|
||||
- **채택 결정 (custom envelope, success/error 대칭, retryable 1급)**:
|
||||
- (어떤 표준도 1:1 매칭 없음 — Stripe/Toss와 가장 유사하나 success flag는 ca-tmpl 고유)
|
||||
- [[raw/company-tech-blogs/stripe-error-format]] — endpoint dimension 명시 사례
|
||||
- [[raw/company-tech-blogs/toss-payments-error-format]] — 한국 컨벤션 reference
|
||||
- **명시적으로 거부한 표준**:
|
||||
- [[raw/official-docs/problem-detail-rfc-7807]] — IETF 표준이지만 실패 전용, success/error 비대칭
|
||||
- [[raw/official-docs/spring-problem-detail]] — Spring 6 기본 지원이지만 ca-tmpl envelope과 충돌
|
||||
- **검토한 대안**:
|
||||
- **대안 1: RFC 7807 ProblemDetail** — 위 2개
|
||||
- **대안 2: Google rpc.Status (gRPC)** — [[raw/official-docs/google-api-error-format]] (가장 표현력 풍부, retryable detail 1급)
|
||||
- **대안 3: JSON:API errors** — [[raw/official-docs/json-api-errors-spec]]
|
||||
- **대안 4: GraphQL errors** — [[raw/official-docs/graphql-errors-spec]] (partial success 1급)
|
||||
- **대안 5: GitHub custom envelope** — [[raw/company-tech-blogs/github-api-error-format]]
|
||||
- **비교 핵심**: ca-tmpl의 `success` flag + `retryable` 1급은 어떤 표준에도 없음. Google rpc.Status만 retryable을 detail로 가짐. ProblemDetail은 실패 전용 평면이라 ca-tmpl의 운영 요구와 구조적 충돌. → ca-tmpl이 ProblemDetail을 거부한 trade-off: 표준 lock-in 회피 + success/error 대칭 + 운영 메타 1급화.
|
||||
|
||||
## Decisionized Work Items
|
||||
|
||||
| field | Decision | Allowed | Forbidden | Required registry update | Required contract test | Failure condition |
|
||||
|-------|----------|---------|-----------|----------------------------|-------------------------|---------------------|
|
||||
| syntax/shape | request DTO validation | frontend duplicate validation | domain-only syntax validation | error-registry row 변경 시 VALIDATION 코드 추가 | malformed request test | malformed request가 VALIDATION envelope로 매핑되지 않으면 실패 |
|
||||
| use case policy | application policy validation | domain service if pure domain rule | controller-only authorization policy | error-registry row 변경 시 AUTHZ/CONFLICT 코드 추가 | policy conflict test | policy violation이 AUTHZ/CONFLICT envelope로 매핑되지 않으면 실패 |
|
||||
| domain invariant | domain model/value object | pre-check for UX/perf | DB constraint as only invariant | error-registry row 변경 시 CONFLICT/VALIDATION 코드 추가 | invariant test | invariant violation이 infrastructure exception으로 표현되면 실패 |
|
||||
| persistence integrity | infrastructure maps to operational error | application pre-check | raw SQL/constraint in response | error-registry row 변경 시 DATA_INTEGRITY(null/FK/check) / CONFLICT(unique) 코드 추가 | integrity mapping | unique/integrity failure가 raw SQL/constraint name을 client에 노출하면 실패 |
|
||||
| duplicate validation | allowed with canonical owner | documented redundancy | contradictory duplicate rules | 없음 (boundary 책임만) | boundary test | canonical owner 없는 duplicate rule이 추가되면 실패 |
|
||||
| validation details | safe field errors only | no details for security | raw object/body/SQL detail | 없음 (error-registry envelope shape에 종속) | leakage test | raw object/body/SQL detail이 response에 노출되면 실패 |
|
||||
|
||||
## 판정 기준
|
||||
|
||||
| 구분 | 기준 |
|
||||
| --- | --- |
|
||||
| Decision | validation 책임을 boundary별로 분리 |
|
||||
| Allowed | 같은 규칙을 UX/성능 목적으로 사전 검증하되 canonical owner를 명시 |
|
||||
| Forbidden | DB constraint만으로 business invariant를 대체 |
|
||||
| Required mapping | syntax -> VALIDATION, policy -> AUTHZ/CONFLICT, invariant -> CONFLICT/VALIDATION, persistence -> DATA_INTEGRITY(null/FK/check)/CONFLICT(unique) |
|
||||
| Failure condition | raw persistence exception이나 domain exception이 presentation까지 새면 실패 |
|
||||
|
||||
## 테스트 계약
|
||||
|
||||
- malformed request는 structured validation error로 변환되어야 함.
|
||||
- business invariant violation이 infrastructure exception으로 표현되면 실패.
|
||||
- unique constraint failure가 SQL/constraint raw name을 클라이언트에 노출하면 실패.
|
||||
- 이 branch의 TODO가 Decision/Allowed/Forbidden/Test 없이 남으면 canonical 승급 실패.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. company-tech-blog 출처는 `company-case-study` 로 라벨링하며 공식 best practice 로 격상하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|
|
||||
| D1 | request DTO validation 은 입력 모양 (syntax/shape) 검증만 담당 (2026-05-22) | UNSUPPORTED_DECISION (4-layer validation 분리는 project-internal architectural decision; 외부 표준이 boundary 별 책임 분할을 normative 로 강제하지 않음) | N/A | layer 책임의 정합성은 sibling branch (`feature-boundary-validation-mapping-contract`) 와 cross-review 필수 — 동일 4-layer 결정이 양쪽에 분산됨 |
|
||||
| D2 | business invariant 는 domain 에서 검증 | UNSUPPORTED_DECISION (DDD aggregate invariant 책임 패턴은 일반 design wisdom 이지만 본 branch 가 cite 한 sources — Stripe/Toss/RFC 7807/Spring/Google/JSON:API/GraphQL/GitHub — 중 normative 진술 없음) | N/A | DDD aggregate / value object 책임 패턴의 raw 인용 (예: Vaughn Vernon, Fowler anemic vs rich) 별도 보강 필요 |
|
||||
| D3 | persistence integrity error 는 operational error 로 변환하되 client-safe message 만 응답 | `raw/official-docs/problem-detail-rfc-7807.md#RFC7807-C5` ("`detail` ... ought to focus on helping the client correct the problem, rather than giving debugging information"), `raw/company-tech-blogs/github-api-error-format.md#GH-ERR-C4` (validation error code 어휘 — `custom` 은 message-driven 의 escape hatch) | `official-standard + official-vendor-doc` | RFC7807-C5 는 "ought to" 약한 어조; SQL constraint name 차단은 raw 인용보다 보안 일반 원칙 — 별도 raw (예: OWASP error handling) 보강 권장 |
|
||||
| D4 | 이 branch 의 TODO 도 Work Item Contract 준수; Decisionized Work Items 표가 canonical 승급 기준 | UNSUPPORTED_DECISION (project-internal process gate; 외부 표준 근거 없음) | N/A | process gate 가 문서에만 있으면 silent skip — `/lint` 또는 PR template 으로 enforce 필요 |
|
||||
| D5 | error envelope shape — custom `{success, data, error.{code,category,message,retryable,details}, meta}` 채택, RFC 7807 ProblemDetail 명시적 거부 | `raw/official-docs/problem-detail-rfc-7807.md#RFC7807-C1` (canonical model 은 JSON `application/problem+json`), `#RFC7807-C2` (`type` URI 가 primary identifier — custom `code` 와 충돌), `#RFC7807-C3` (extension 가능하나 unknown 은 ignore), `raw/official-docs/spring-problem-detail.md#SPRING-PD-C1` (Spring `ProblemDetail` 은 RFC 9457 representation), `#SPRING-PD-C2` (모든 Spring MVC 예외가 `ErrorResponse` 구현 — ca-tmpl envelope 와 직접 충돌), `raw/company-tech-blogs/stripe-error-format.md#STRIPE-ERR-C5` (Stripe 4-종 type enum 사례), `raw/company-tech-blogs/toss-payments-error-format.md#TOSS-ERR-C1` (Toss `{code, message}` 평면 shape) | `official-standard + official-vendor-doc + company-case-study` | RFC 7807 미채택의 trade-off (표준 lock-in 회피 vs client 라이브러리 호환성) 는 인용된 source 들이 직접 권고하지 않음 — ca-tmpl 의 운영 해석. Stripe / Toss 는 company-case-study (best practice 격상 금지) |
|
||||
| D6 | retryable 1급 필드 + success flag — 어떤 표준에도 1:1 매칭 없음 | `raw/official-docs/google-api-error-format.md#GOOG-ERR-C3` (details 에 typed payload — RetryInfo 등 포함 가능), `#GOOG-ERR-C5` (표준 detail payloads — BadRequest, ErrorInfo, LocalizedMessage 등), `raw/official-docs/graphql-errors-spec.md#GQL-ERR-C3` (partial response — `data` + `errors` 공존), `#GQL-ERR-C4` (extensions free-form map) | `official-vendor-doc + official-standard` | Google rpc.Status 만 retryable 을 detail 로 가짐 — top-level 1급 필드는 어떤 표준에도 없음 (ca-tmpl 고유 결정). GraphQL partial success 도 envelope success flag 와 다른 모델 |
|
||||
| D7 | validation error mapping — syntax → VALIDATION, policy → AUTHZ/CONFLICT, invariant → CONFLICT/VALIDATION, persistence → DATA_INTEGRITY(null/FK/check)/CONFLICT(unique) | `raw/official-docs/json-api-errors-spec.md#JSONAPI-ERR-C3` (`source.pointer` JSON Pointer 로 field-level 오류 위치), `#JSONAPI-ERR-C5` (`title` 은 호출별 불변), `raw/company-tech-blogs/github-api-error-format.md#GH-ERR-C3` (validation 실패 = 422), `#GH-ERR-C4` (validation code 어휘 6개); category 명칭은 `ca-tmpl/docs/registries/error-codes.yaml` + `shared/error/Category.java` SSOT 확인 (2026-06-02) | `official-standard + official-vendor-doc + code-verified(category)` | category 분류 체계 자체의 외부 표준은 없음 — ca-tmpl 운영 결정. 실제 enum 은 10종 (VALIDATION/AUTH/AUTHZ/NOT_FOUND/CONFLICT/RATE_LIMIT/TRANSIENT_DEPENDENCY/PERMANENT_DEPENDENCY/DATA_INTEGRITY/INTERNAL); `PERSISTENCE` 는 없음 |
|
||||
| D8 | duplicate validation 허용 — canonical owner 명시 시 UX/perf 사전 검증 가능 | UNSUPPORTED_DECISION (project-internal architectural decision; 외부 표준 근거 없음) | N/A | canonical owner 정합성은 PR 단위에서 review — silent duplication 위험 |
|
||||
| D9 | validation details — safe field errors only; raw object/body/SQL detail 금지 | `raw/official-docs/problem-detail-rfc-7807.md#RFC7807-C5` ("`detail` ought to focus on helping the client correct the problem"), `raw/company-tech-blogs/github-api-error-format.md#GH-ERR-C5` (`custom` code 의 message-driven escape hatch — i18n 위험 명시) | `official-standard + official-vendor-doc` | "safe field error" 정의는 ca-tmpl 운영 해석; SQL/constraint name leak 차단은 일반 보안 원칙 — 별도 raw (OWASP) 보강 권장 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> *결정 (D1~D9)* 이 "*무엇* 을 검증할 것인가" 라면, 본 §는 "*어느 layer 에서 어떤 메커니즘으로*" 검증·변환·차단되는가의 사전 명세. 본 branch 의 핵심은 **검증 책임의 layer 배치** 다.
|
||||
>
|
||||
> error envelope 의 *shape* (D5/D6) 과 error code → HTTP → category *매핑 구현* (D7) 은 본 §에서 재명세하지 않는다 — sibling [[raw/branch-notes/feature-boundary-validation-mapping-contract]] §구현 가이드 + canonical [[raw/project-notes/ca-skeleton-operational-contract]] §6 이 소유 (R3 정제, §Audit & Findings 참조).
|
||||
|
||||
### 1. 4-layer validation 책임 배치 + 정적 강제
|
||||
|
||||
> **Trace**:
|
||||
> - syntax/shape = controller boundary 전용 → **D1** (UNSUPPORTED_DECISION — 외부 표준이 boundary 별 책임 분할을 normative 강제하지 않음; sibling `feature-boundary-validation-mapping-contract` 와 동일 4-layer 결정 분산이므로 cross-review 필수). 검증은 §Claims To Verify row 1.
|
||||
> - business invariant = domain model/value object 전용 → **D2** (UNSUPPORTED_DECISION — DDD aggregate wisdom, 인용 source 8개 중 normative 진술 없음). 검증은 §Claims To Verify row 2.
|
||||
> - **use case policy layer 는 Decision Evidence Map 에 대응 D-ID 가 없음** (gap — §Audit & Findings F2). 아래 표 row 는 Decisionized Work Items 의 "use case policy" row 에서만 도출되며 외부 근거 미연결.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: ①ArchUnit rule 이름 (`valid_only_in_controller`, `domain_invariant_on_all_mutations` 등) 임의 명명. ②"모든 mutation 경로" 의 조작적 정의 (생성자 / setter / 도메인 메서드 중 어디까지를 mutation 으로 보는지) — raw 권고 없음, 사용자 임의. ③layer 별 package glob (`..adapter.web..` / `..application..` / `..domain..`) — canonical [[raw/project-notes/ca-skeleton-operational-contract]] §20 Skeleton Blueprint package convention 에서 도출(SUPPORTED via canonical SSOT), glob 변환만 임의.
|
||||
|
||||
| layer | 검증 책임 | 배치 위치 | 정적 강제 (계획) | Trace |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| syntax / shape | 입력 모양 (required / type / format / size) | `@Valid` + Bean Validation @ controller DTO (`..adapter.web..dto..`) | `@Valid` 가 controller package 밖에 등장하면 build 실패 (ArchUnit) | D1 |
|
||||
| use case policy | application 권한·상태전이 정책 | application service (`..application..`) | 정적 강제 없음 — review-only (근거 없음, 아래 trade-off) | Decisionized WI "use case policy" (no D-ID, F2) |
|
||||
| domain invariant | 비즈니스 불변식 | domain model / value object (`..domain..`) | invariant method 가 모든 mutation 경로에서 호출되는지 ArchUnit + bypass test | D2 |
|
||||
| persistence integrity | unique / FK / 무결성 | infrastructure adapter → operational error 변환 (§2) | §2 참조 | D3 |
|
||||
|
||||
> - **UNSUPPORTED_IMPL_DECISION (policy layer 정적 강제 부재)**: use case policy 를 ArchUnit 으로 강제하지 않고 review-only 로 두는 것은 사용자 trade-off — application 정책은 도메인/요청 문맥 의존이 커서 정적 규칙의 false positive 가 많다는 판단. 근거 raw 없음.
|
||||
|
||||
### 2. Persistence integrity → operational error 변환 지점
|
||||
|
||||
> **Trace**: persistence integrity error 는 operational error 로 변환하되 client-safe message 만 응답 → **D3** + `raw/official-docs/problem-detail-rfc-7807.md#RFC7807-C5` ("`detail` ought to focus on helping the client correct the problem, rather than giving debugging information"), `raw/company-tech-blogs/github-api-error-format.md#GH-ERR-C4`. 검증은 §Claims To Verify row 3.
|
||||
>
|
||||
> - **메커니즘 (ground truth 2026-06-02, ca-tmpl 코드 확인)**: `src/adapter-web/.../error/GlobalExceptionHandler.java` (`@RestControllerAdvice`) + `ErrorResponseFactory` 는 `actually-implemented` 지만, 현재 `@ExceptionHandler` 목록(MappingException / IllegalArgumentException / ConstraintViolation / MethodArgumentTypeMismatch / InvalidBearerToken / Authentication / AccessDenied / PreconditionFailed / PageValidation / Cursor / Exception)에 **`DataIntegrityViolationException` 핸들러가 없음** — persistence integrity 변환은 `planned`. owner 는 [[raw/branch-notes/feature-persistence-failure-baseline]] (documented-only). 본 branch 는 그 핸들러를 *consume* 하며, integrity handler 추가는 owner branch 책임.
|
||||
> - **카테고리 매핑 (registry SSOT, `ca-tmpl/docs/registries/error-codes.yaml`)**: unique 위반 → `CONFLICT` (`DB_UNIQUE_VIOLATION`, 409); null/FK/check 위반 → `DATA_INTEGRITY` (`DB_NULL_VIOLATION`/`DB_FK_VIOLATION`/`DB_CHECK_VIOLATION`). **`PERSISTENCE` enum 은 존재하지 않음** (`src/shared-contract/.../error/Category.java` 10-enum 확인).
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: SQL/constraint name 차단은 RFC7807-C5("ought to" 약한 어조)보다 *일반 보안 원칙* — 별도 raw (OWASP error handling) 보강 권장(D3 Open Risk).
|
||||
|
||||
- `DataIntegrityViolationException` / `OptimisticLockingFailureException` 등 persistence 예외는 infrastructure→presentation 으로 *raw 전파 금지*. exception handler 가 `DATA_INTEGRITY` (null/FK/check) 또는 `CONFLICT` (unique) category 의 operational error envelope 로 변환. **현재 미구현** — owner: `feature-persistence-failure-baseline`.
|
||||
- 응답 `error.message` 는 client-safe 고정 문구만 (registry `client_safe_message`, 예: `DB_UNIQUE_VIOLATION` = "Resource already exists"). SQL 문장·constraint 이름·table/column 명을 `message`/`details` 어디에도 노출 금지.
|
||||
- 구체적 envelope shape 은 본 branch 범위 밖 → canonical §6 (OUT_OF_BRANCH_SCOPE, §Audit F1).
|
||||
|
||||
### 3. Validation detail leakage 차단
|
||||
|
||||
> **Trace**: validation details — safe field errors only, raw object/body/SQL detail 금지 → **D9** + `#RFC7807-C5`, `raw/company-tech-blogs/github-api-error-format.md#GH-ERR-C5` (`custom` code 의 message-driven escape hatch — i18n/leak 위험). 검증은 §Claims To Verify row 7.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: "safe field error" 의 *정의* (어떤 필드 메타까지 허용 — field 경로? rejected value 포함? message?) 는 ca-tmpl 운영 해석, raw 가 권고하지 않음.
|
||||
|
||||
- `details` 에 허용: field 경로 + validation message (i18n key). **금지**: 직렬화된 raw request object/body, SQLException message, stacktrace, constraint name.
|
||||
- 의도적 `SQLException` 발생 → response body grep 으로 leak 회귀를 contract test 로 pin (§Claims row 7).
|
||||
|
||||
### 4. Duplicate validation canonical owner 표기
|
||||
|
||||
> **Trace**: duplicate validation 허용 — canonical owner 명시 시 UX/perf 사전 검증 가능 → **D8** (UNSUPPORTED_DECISION — project-internal architectural decision, 외부 근거 없음). 검증은 §Claims To Verify row 8.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: owner 표기 메커니즘 (코드 주석 vs annotation vs 문서 표) 전부 사용자 임의 — D8 자체가 무근거이므로 detail 도 무근거.
|
||||
|
||||
- 같은 규칙을 두 layer 에서 검증하는 것은 허용하되, **canonical owner 를 명시**. owner 없는 duplicate rule 추가 시 silent contradiction → 금지.
|
||||
- **기본값 (착수 가능 수준)**: 코드 주석 `// canonical-owner: <layer>` (예: `// canonical-owner: domain-invariant`) — 단순, 도구 불필요. duplicate 검증 지점마다 owner layer 한 줄 명시.
|
||||
- (대안) annotation 강제(ArchUnit): [[raw/branch-notes/feature-boundary-validation-mapping-contract]] §Mapper Tool Contract 의 annotation 패턴 참조 후 별도 결정 — D8 무근거이므로 도입 여부는 review 판단.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4(깊이 게이트) 캡처용. 정상 경로 외 *구현 중 부딪힐* 실패/엣지/다른 계약 의존.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- malformed JSON (`HttpMessageNotReadableException`) vs Bean Validation 실패 (`MethodArgumentNotValidException`) — 둘 다 syntax layer 지만 *다른 exception*. 둘 다 `VALIDATION` category 로 수렴해야 함(sibling D10 과 정합).
|
||||
- **동시성 하 unique constraint race**: application 사전 check(D8 duplicate)가 통과해도 DB 레벨에서 integrity violation 발생 가능 → persistence layer(D3)가 최종 방어선. 사전 check 는 UX 목적일 뿐 invariant 보장 아님.
|
||||
- nested DTO `@Valid` cascade 깊이 — sibling 의 cascade depth ≤ 3 정적 강제(B4)에 의존.
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-boundary-validation-mapping-contract]] 의 `D10` (exception → error code → category 매핑) 을 consume — 본 branch 의 4-layer 가 *어느 category 로* 떨어지는지는 sibling 이 결정. sibling 매핑이 바뀌면 본 branch 의 §판정 기준 Required mapping 표가 영향.
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] §6 (Operational Error Category 통합 정의) + §20 (package convention) 을 consume — envelope shape·package glob 의 SSOT.
|
||||
- **구현 순서 의존 (2026-06-02 ground truth)**: 본 branch 의 Claims row 3(persistence integrity 매핑)은 [[raw/branch-notes/feature-persistence-failure-baseline]] 가 `GlobalExceptionHandler` 에 `DataIntegrityViolationException` 핸들러를 구현한 *후에야* `planned` → `verified` 전환 가능. 현재 그 핸들러는 부재(코드 확인). policy AUTHZ 코드는 [[raw/branch-notes/feature-security-operational-baseline]] 소유.
|
||||
|
||||
## Audit & Findings
|
||||
|
||||
> R3 정제 history + 발견된 gap 보존 (§구현 가이드 본문에서 제외한 항목의 이관 근거).
|
||||
|
||||
| ID | 유형 | 내용 | 조치 |
|
||||
| --- | --- | --- | --- |
|
||||
| F1 | OUT_OF_BRANCH_SCOPE | D5 (envelope custom shape), D6 (retryable/success flag) 의 *구현 명세* — `EnvelopeBodyAdvice`/`Envelope`/`BulkEnvelope` 클래스·factory API — 는 sibling [[raw/branch-notes/feature-boundary-validation-mapping-contract]] §구현 가이드 §4 + canonical §6 이 소유. 본 §구현 가이드에서 재명세 제외. D5/D6 결정 *기록* 은 Decision Evidence Map 에 유지(error-format Topic 4 공유 조사 산물). | sibling/canonical 참조로 대체 |
|
||||
| F2 | DECISION_GAP | Decisionized Work Items 의 "use case policy" row + §1 표의 policy layer 가 Decision Evidence Map 에 대응 D-ID 가 없음. syntax(D1)/invariant(D2)/persistence(D3)/duplicate(D8)/details(D9)는 D-ID 보유하나 policy 만 누락. | **착수 기본값 (registry `owner_branch` 확인 2026-06-02)**: 인가 정책 violation → `AUTHZ` (실재 코드 `AUTHZ_INSUFFICIENT_PERMISSION` + `AUTHZ_TENANT_MISMATCH`, 403, **둘 다 owner `feature-security-operational-baseline`**); 상태 전이 충돌 → `CONFLICT`. (주의: `feature-tenant-context-policy` 는 AUTHZ 코드 소유자 아님 — `TENANT_NOT_SUPPORTED`(VALIDATION/400) 별도 소유.) 본 branch 는 이 코드들을 *consume*. **매핑 자체는 여전히 UNSUPPORTED** (본 노트 D-ID 없음) — 코드 착수 후 policy layer 책임을 D10(본 노트)로 승격하거나 owner branch 와 cross-link 하여 확정 필요. 추측을 FACT 로 기재 금지. |
|
||||
| F3 | OUT_OF_BRANCH_SCOPE | D7 의 *코드→category 매핑 구현* 은 sibling D10 영역. 본 branch 는 *어느 layer 가 어느 category 후보인지* 의 책임 view 만 제공. persistence 코드(`DB_UNIQUE_VIOLATION`→CONFLICT, `DB_NULL/FK/CHECK_VIOLATION`→DATA_INTEGRITY)는 owner [[raw/branch-notes/feature-persistence-failure-baseline]] 소유. | sibling/owner 참조 |
|
||||
| F4 | DEFERRED_RESEARCH | D1 (4-layer 분리), D2 (DDD invariant 책임) 의 외부 근거 보강 — D2 Open Risk 가 Vernon/Fowler(anemic vs rich domain) raw 인용을 명시. `wiki-decision-researcher` 자동조사 후보지만 web-fetch(outward) 라 사용자 opt-in 대기. | `/branch-spec ... --research D1,D2` 또는 수동 |
|
||||
| F5 | CATEGORY_DRIFT → **RESOLVED 2026-06-02** | 본 노트가 쓰던 `PERSISTENCE` category 는 실재하지 않음 — `src/shared-contract/.../error/Category.java` 의 10-enum(VALIDATION/AUTH/AUTHZ/NOT_FOUND/CONFLICT/RATE_LIMIT/TRANSIENT_DEPENDENCY/PERMANENT_DEPENDENCY/DATA_INTEGRITY/INTERNAL)에 없음. registry `error-codes.yaml` 의 실제 매핑: `DB_UNIQUE_VIOLATION`→CONFLICT(409), `DB_NULL/FK/CHECK_VIOLATION`→DATA_INTEGRITY. (`error-codes.yaml:580` 주석에도 동일 stale 매핑이 전파돼 있었음 — ca-tmpl 레포 측 별도 정리 대상.) | **반영 완료**: D3/D7/§판정 기준/Decisionized WI/§구현 가이드 §2/§엣지/Claims 의 `PERSISTENCE` 를 `DATA_INTEGRITY(null/FK/check)/CONFLICT(unique)` 로 정합 (코드+registry 근거). |
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서/사례는 근거지만 내 프로젝트에서의 동작을 자동 보장하지 않는다. 구현 전/중/후 실제로 검증해야 하는 주장.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| request DTO validation 이 controller boundary 에서만 트리거되고 domain layer 로 새지 않는지 | `@Valid` annotation 위치 / interceptor 체인 misconfiguration 가능성 | ArchUnit rule (`@Valid` annotation 은 controller package 만) + integration test | `planned` |
|
||||
| business invariant 가 domain model / value object 안에서 강제되며 application service bypass 불가한지 | service-layer invariant check 로 domain bypass 가능성 | ArchUnit rule (domain model 의 invariant method 가 모든 mutation 경로에서 호출) + 의도적 bypass test | `planned` |
|
||||
| persistence integrity exception (e.g., `DataIntegrityViolationException`) 이 envelope 의 `DATA_INTEGRITY`(null/FK/check) / `CONFLICT`(unique) category 로 매핑되며 SQL/constraint name leak 안 되는지 | 현재 `GlobalExceptionHandler` 에 `DataIntegrityViolationException` 핸들러 자체가 없음(2026-06-02 확인) — owner `feature-persistence-failure-baseline` 미구현 | owner branch 구현 후 exception handler contract test + DLP scan (constraint name regex grep on response) | `planned` (owner: feature-persistence-failure-baseline) |
|
||||
| 4-layer mapping (syntax → VALIDATION, policy → AUTHZ/CONFLICT, invariant → CONFLICT/VALIDATION, persistence → DATA_INTEGRITY/CONFLICT) 가 모든 exception 에 일관 적용되는지 | category 분류의 silent miscategorization 가능성 | exception → category 매핑 contract test (각 layer 의 대표 exception 별 category 검증) | `planned` |
|
||||
| envelope 의 `retryable` 플래그가 category 와 정합한지 (registry 확인: VALIDATION/CONFLICT/DATA_INTEGRITY 모두 `retryable=false`) | retryable 은 per-code (registry `error-codes.yaml`), category 에서 계산 금지 (`Category.java` javadoc) | category × retryable matrix contract test + registry 대조 | `planned` |
|
||||
| RFC 7807 ProblemDetail 미채택이 Spring 6 의 autoconfigure (`spring.mvc.problemdetails.enabled`, `SPRING-PD-C4`) 와 충돌하지 않는지 | Spring Boot default 가 true 인지 모름 → 자동 활성화 시 envelope override 필요 | sibling [[raw/branch-notes/feature-boundary-validation-mapping-contract]] 가 동일 위험을 `actually-implemented` 로 해소 (2026-05-29: `GlobalExceptionHandler` 가 `ProblemDetail` import 완전 제거, 우리 핸들러 우선이라 자동 활성화와 충돌 없음, `BoundaryDemoControllerWireTest` 11 케이스 wire-pin) → **본 branch 재검증 불필요** | `verified` (sibling) |
|
||||
| `details` 필드에 raw object / body / SQL detail 이 절대 leak 안 되는지 | exception handler 의 detail 직렬화 path 에서 누락 가능 | leakage contract test (의도적 SQLException 발생 → response body grep) + production log scrub | `planned` |
|
||||
| duplicate validation 의 canonical owner 가 코드 주석 / 문서에 명시되는지 | duplication 자체는 허용이지만 owner 누락 시 silent contradiction 가능 | code review checklist + ArchUnit rule (duplicate validator 는 owner annotation 필수) | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성)
|
||||
|
||||
> `/coverage` 가 생성하는 **생성물** — 손유지 금지. 기준: `rules/coverage-gate.md`. governing_docs: `clean-architecture-package-layout` + `api-error-envelope-design`.
|
||||
> 마지막 감사: 2026-06-02 → **Covered** (Blocking 0 / Should-fix 0 / Advisory 4).
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|--------|------|-------|--------|------|
|
||||
| 4-layer validation 책임 배치 (syntax/policy/invariant/persistence) | covered-here | — | — | D1·D2 |
|
||||
| domain purity — infrastructure exception raw 전파 금지 | covered-here | — | — | D3 |
|
||||
| @Valid 정적 강제 (controller 패키지 밖 금지) | covered-here | — | — | D1 (Claims row 1, planned) |
|
||||
| business invariant violation → error.category 분류 | covered-here | — | — | D7 |
|
||||
| persistence integrity → DATA_INTEGRITY(null/FK/check) / CONFLICT(unique) 매핑 | covered-here | — | — | D3·D7 (Audit F5 RESOLVED) |
|
||||
| exception leak 금지 (SQL/constraint name/stacktrace) | covered-here | — | — | D9 |
|
||||
| retryable 필드 정합 (VALIDATION/CONFLICT/DATA_INTEGRITY = false) | covered-here | — | — | Claims row 5 (registry SSOT) |
|
||||
| web DTO containment — domain 직렬화 금지 | delegated | [[raw/branch-notes/feature-boundary-validation-mapping-contract]] D8 | — | owner `actually-implemented` (`controllers_do_not_return_domain_or_entity_types`) |
|
||||
| validation 실패 → error.details[] 항목별 오류 매핑 | delegated | [[raw/branch-notes/feature-boundary-validation-mapping-contract]] D10 | — | owner `actually-implemented` (`VALIDATION_FAILED` details shape) |
|
||||
| i18n 검증 메시지 정책 | governing 문서 비열거 | — | Advisory | 두 governing 문서 모두 미열거 — 프로젝트 레벨 owner 여부는 `/coverage --project` 영역 |
|
||||
| 입력 정규화/sanitization before validation | governing 문서 비열거 | — | Advisory | 미열거. 실코드 trim/sanitize 는 header/pagination 맥락 |
|
||||
| fail-fast vs collect-all 오류 수집 정책 | governing 문서 비열거 | — | Advisory | 미열거. 형제 B4 `@GroupSequence` 가 사실상 결정 |
|
||||
| cross-field/conditional validation | governing 문서 비열거 | — | Advisory | 미열거. 형제 B4 class-level constraint `actually-implemented` |
|
||||
|
||||
## 완료 후 wiki 추출 대상
|
||||
|
||||
- `wiki/projects/ca-skeleton-operational-contract.md`의 business rule validation canonical section.
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음(문서 단계).
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/github-api-error-format]]
|
||||
- [[raw/company-tech-blogs/stripe-error-format]]
|
||||
- [[raw/company-tech-blogs/toss-payments-error-format]]
|
||||
- [[raw/official-docs/google-api-error-format]]
|
||||
- [[raw/official-docs/graphql-errors-spec]]
|
||||
- [[raw/official-docs/json-api-errors-spec]]
|
||||
- [[raw/official-docs/problem-detail-rfc-7807]]
|
||||
- [[raw/official-docs/spring-mvc-rest-exception-handling]]
|
||||
- [[raw/official-docs/spring-problem-detail]]
|
||||
- [[raw/official-docs/validation-jakarta-bean-validation-3.0-spec]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 feature branch 는 leaf — 자식 자료 없음. Phase C2 실 코드 작성 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록 (본 feature 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase C2 실 구현 단계에 누적)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
> 이 브랜치를 작업한 날짜들. 양방향 nav 유지.
|
||||
|
||||
- (아직 없음 — documented-only 단계. 실 구현 착수 시 `[[raw/daily-notes/YYYY-MM-DD]]` 누적)
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-cache-consistency-contract.md
|
||||
@@ -0,0 +1,252 @@
|
||||
---
|
||||
title: branch / feature-cache-consistency-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-cache-consistency-contract
|
||||
related_projects: [ca-skeleton]
|
||||
tags: [branch, ca-skeleton, cache, redis, consistency]
|
||||
created: 2026-05-22
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-024
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-024
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-DATABASE-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
parent_branch:
|
||||
contract_packet_sha256: 6d4978b50ddf3cab75e6803a198f9ca53753f9812e8f3a158f96c3544c843008
|
||||
---
|
||||
|
||||
# branch: feature-cache-consistency-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — cache consistency와 Redis/cache adapter 기준을 정의합니다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (§<관련 섹션>) 의 결정/근거/금지 사항을 정제한다.
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/cache-woowahan-after-commit-invalidation]]
|
||||
- [[raw/official-docs/cache-aside-vs-write-through-aws]]
|
||||
- [[raw/official-docs/cache-caffeine-asyncloadingcache-readme]]
|
||||
- [[raw/official-docs/cache-redisson-rlock-vs-setnx]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/cache-consistency-after-commit-stampede-contract-2026-07-02]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
> 본 feature branch 는 leaf — 자식 자료 없음. Phase C2 실 코드 작성 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록 (본 feature 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase C2 실 구현 단계에 누적)
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: after-commit invalidation·stampede failure fixture가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-DATABASE-001@1` | database는 PostgreSQL 16 단일 stack이다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
cache miss/unavailable만으로는 cache 운영 기준이 부족합니다. stale cache, stampede, key naming, TTL, invalidation 실패를 skeleton 기준에 포함해야 합니다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- cache aside 기준.
|
||||
- stale cache 허용 범위.
|
||||
- cache stampede 방지 기준.
|
||||
- cache key naming.
|
||||
- TTL 기준.
|
||||
- invalidation 실패 분류.
|
||||
- Redis unavailable degrade 기준과 연결.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- business-specific cache policy.
|
||||
- distributed lock 기본 구현.
|
||||
- Redis cluster 운영 설정.
|
||||
|
||||
## TODO
|
||||
|
||||
> TODO drained — 결정은 아래 표/결정 사항 참조.
|
||||
|
||||
## Work Item Contract
|
||||
|
||||
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
|
||||
|
||||
| field | required | rule |
|
||||
| --- | --- | --- |
|
||||
| Decision | yes | 구현자가 선택해야 하는 기본값 |
|
||||
| Allowed | yes | 허용되는 예외와 조건 |
|
||||
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
|
||||
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
|
||||
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
|
||||
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
|
||||
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- cache consistency는 optional adapter이지만, 붙였을 때 같은 실패 계약을 따라야 합니다.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- 2026-05-22: cache consistency를 Redis adapter 내부 세부사항으로만 두지 않음.
|
||||
- 2026-05-22: core는 single-instance/local cache policy만 완결. distributed cache lock/stampede 방지는 multi-instance package 활성화 시 필요.
|
||||
- 2026-05-22: Redis cluster 운영은 out of core이나 cluster mode 활성화 시 key hash/tag policy와 failover runbook이 필요.
|
||||
- 2026-05-22: cache invalidation = after-commit only. Spring `TransactionSynchronizationManager.registerSynchronization`으로 강제. tx 내부 또는 tx 미참여 상태에서의 cache mutation은 forbidden.
|
||||
- 2026-05-22: stampede 방지 default = single-instance Caffeine local lock, multi-instance HPA 시 Redisson RLock distributed mutex. application 별 override 금지.
|
||||
- 2026-05-22: cache value serialization = JSON (Jackson). 스키마 변경 시 key version suffix(`v{n}` 접미사) 필수.
|
||||
- 2026-05-22: negative cache 정책 = 존재하지 않는 row는 짧은 TTL(60s) 캐싱 허용. application별 override 가능.
|
||||
- 2026-05-22: eventual consistency window default = 5s (TTL과는 별개로 invalidation propagation 허용 한계).
|
||||
- 2026-05-22: negative cache TTL(60s)와 invalidation propagation window(5s)는 독립 축. negative cache는 invalidation 채널 적용 대상에서 제외 (적용 시 정상화). 두 수치는 의도된 분리.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. company-tech-blog 는 `company-case-study` 로만 라벨 (best practice 단정 금지).
|
||||
|
||||
| Decision ID | Decision (요약) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|
|
||||
| D1 | cache pattern default = cache-aside (write-through without consistency contract 는 forbidden) | `raw/official-docs/cache-aside-vs-write-through-aws.md#CACHE-PAT-C1`, `#CACHE-PAT-C2`, `#CACHE-PAT-C3` | `official-vendor-doc` (AWS + Redis 공식 — lazy caching 정의 + application 책임 + write-through latency tradeoff) | "write-through 가 결제/주문 도메인에 부적합" 은 cited raw 가 직접 prescribe 안 함 — ca-tmpl 내부 결정. write-behind 의 data loss 메커니즘 (`#CACHE-PAT-C5`) 은 `needs-confirmation` — AWS Database Blog 또는 Redis docs 별도 raw 필요 |
|
||||
| D2 | cache invalidation = after-commit only. Spring `TransactionSynchronizationManager.registerSynchronization` 으로 강제. tx 내부/tx 미참여 상태 cache mutation forbidden | `raw/company-tech-blogs/cache-woowahan-after-commit-invalidation.md` (company-case-study — 우아한형제들 한국 사례) | `company-case-study` (NOT official best practice) | Spring `TransactionSynchronizationManager` 공식 reference 의 after-commit hook 시맨틱 verbatim 미수집 — 별도 official-doc raw 필요. 우아한형제들 사례는 한 회사의 결정이며 official-standard 가 아님 |
|
||||
| D3 | stampede 방지 default = single-instance Caffeine local lock, multi-instance HPA 시 Redisson RLock distributed mutex | `raw/official-docs/cache-redisson-rlock-vs-setnx.md#LOCK-C1` (Redis SET NX PX 단순 패턴, 단일 인스턴스 efficiency lock), `#LOCK-C4` (Kleppmann: efficiency vs correctness lock 분리), `raw/official-docs/cache-caffeine-asyncloadingcache-readme.md` (single-instance LoadingCache stampede 방지) | `official-vendor-doc` (LOCK-C1 — Redis 공식 verbatim 확인) + `engineering-blog` (LOCK-C4 — Kleppmann 비판, WebFetch 차단으로 재확인 보류) | LOCK-C3 (Redisson RLock watchdog + `j.u.c.locks.Lock` 호환) 은 `needs-confirmation` — redisson.org → redisson.pro redirect 차단. Redisson Javadoc 직접 다운로드 필요. Caffeine raw 의 claim ID 매핑 미확인 (본 세션 mandatory read 범위 밖) |
|
||||
| D4 | core 는 single-instance/local cache policy 만 완결. distributed cache lock/stampede 방지는 multi-instance package 활성화 시 필요 | `raw/official-docs/cache-redisson-rlock-vs-setnx.md#LOCK-C4` (efficiency vs correctness lock 분리 — cache stampede = efficiency lock) | `engineering-blog` (Kleppmann, 재확인 보류) | "stampede = efficiency lock" 의 분류가 모든 cache 시나리오 (token bucket, rate limit 등) 에 적용되는지 미검증 — correctness 가 필요한 endpoint 식별 필요 |
|
||||
| D5 | cache value serialization = JSON (Jackson). 스키마 변경 시 key version suffix (`v{n}`) 필수 | (UNSUPPORTED_DECISION — cited raw 4종 중 직접 verbatim claim 없음. Jackson docs / Redis serialization 공식 raw 별도 필요) | `internal-policy` | schema versioning 컨벤션은 ca-tmpl 내부 결정 — 외부 권위 근거 미수집 |
|
||||
| D6 | negative cache 정책 = 존재하지 않는 row 는 짧은 TTL(60s) 캐싱 허용 | (UNSUPPORTED_DECISION — cited raw 4종에 negative cache TTL verbatim 없음) | `internal-policy` | 60s 값은 ca-tmpl 내부 SLO — 외부 근거 미수집 |
|
||||
| D7 | eventual consistency window default = 5s (TTL 과는 별개로 invalidation propagation 허용 한계) | (UNSUPPORTED_DECISION — cited raw 4종에 propagation window 수치 verbatim 없음) | `internal-policy` | 5s 값은 ca-tmpl 내부 SLO — 외부 근거 미수집 |
|
||||
| D8 | negative cache TTL(60s) 와 invalidation propagation window(5s) 는 독립 축 (D6/D7 분리) | (UNSUPPORTED_DECISION — 위 두 값 자체가 internal policy) | `internal-policy` | 두 수치 모두 외부 근거 없음 |
|
||||
| D9 | Redis cluster 운영은 out of core. cluster mode 활성화 시 key hash/tag policy + failover runbook 필요 | (UNSUPPORTED_DECISION — cited raw 4종에 Redis cluster key hashtag 시맨틱 verbatim 없음. Redis 공식 cluster spec 별도 필요) | `internal-policy` | Redis cluster keyspace 분배 권고 (hashtag `{}`) verbatim raw 별도 수집 필요 |
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서 인용이 결정의 근거이지만, ca-tmpl 자체 구현에서의 동작은 별도 검증이 필요한 주장.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| LOCK-C3 (Redisson RLock watchdog + `j.u.c.locks.Lock` 호환) 의 verbatim 재확인 | 1차 URL redisson.org → redisson.pro 301 redirect, redirect 호스트 호출 차단으로 verbatim 재확인 불가 | Redisson Javadoc 직접 다운로드 또는 archive.org 스냅샷으로 verbatim 격상 | `needs-confirmation` |
|
||||
| LOCK-C4 (Kleppmann fencing token) verbatim 재확인 | martin.kleppmann.com WebFetch permission denied | archive.org Kleppmann "How to do distributed locking" 스냅샷 verbatim 확보 | `needs-confirmation` |
|
||||
| stampede 방지 contract test (ArchUnit `methodsThat().areAnnotatedWith(@Cacheable)... `withAttribute("sync", "true")` 또는 `AsyncLoadingCache` 또는 `RLock` wrap) 가 실제로 위반 검출 | cited raw 는 stampede 방지 도구 비교까지만 보장 — ArchUnit rule 동작은 별도 | ArchUnit test 작성 + 의도적 위반 case (sync=false 한 `@Cacheable` 추가) 로 fail 확인 | `planned` |
|
||||
| multi-instance cache claim consistency (env `APP_MULTI_INSTANCE_ENABLED=true` + `APP_CACHE_REDIS_ENABLED=true` 시 Redisson bean 등록 + 모든 hot cache 메서드 RLock wrap) | LOCK-C3 가 `needs-confirmation` 인 상태에서 RLock wrap 의 실제 효과 미보증 | `MultiInstanceCacheStampedeContractTest` 작성 + 두 flag true 일 때 Redisson bean verify + 동시 cache miss 1회 backend 호출 확인 | `planned` |
|
||||
| after-commit invalidation 이 tx rollback 시 cache 에 stale write 를 남기지 않음 | 우아한형제들 사례 (D2) 는 company-case-study — 우리 환경에서의 동작 별도 보장 필요 | TransactionTemplate rollback 시나리오 integration test + Redis key 미존재 단언 | `planned` |
|
||||
| Redis unavailable 시 degrade 가능 endpoint 가 declared 된 경우에만 fail-fast 회피 (generic INTERNAL 금지) | cited raw 는 degrade 정책 자체를 prescribe 안 함 — 내부 결정 | contract test: Redis down 시 declared degrade endpoint 는 fallback 응답, undeclared 는 503/`CACHE_UNAVAILABLE` 반환 | `planned` |
|
||||
| negative cache TTL 60s + invalidation propagation 5s 값의 적절성 (D6/D7) | UNSUPPORTED_DECISION — 외부 근거 없음 | 도메인별 stale tolerance SLO 측정 + p99 user-visible staleness 추적 | `planned` |
|
||||
| Spring `TransactionSynchronizationManager.registerSynchronization` 의 after-commit hook 시맨틱 (D2 메커니즘) | 공식 reference verbatim raw 미수집 | Spring Framework Reference §Transaction Synchronization raw 수집 후 `afterCommit` hook 보장 verbatim 확인 | `needs-confirmation` |
|
||||
|
||||
## Decisionized Work Items
|
||||
|
||||
| item | Decision | Allowed | Forbidden | Required test |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| cache pattern | cache-aside default | read-through if adapter owns it | write-through without consistency contract | cache behavior test |
|
||||
| TTL | explicit per key family | no-cache for sensitive data | immortal cache | TTL test |
|
||||
| stampede | local lock in single-instance | distributed lock for HPA | hot key without guard | stampede test |
|
||||
| key scope | app/profile/operation/tenant-if-enabled | hash compact key | PII/raw user id | key naming test |
|
||||
| Redis unavailable | degrade only if declared | fail-fast for required cache | generic INTERNAL | unavailable mapping |
|
||||
|
||||
## 테스트 계약
|
||||
|
||||
- cache key naming에 operation/tenant/profile 기준이 없으면 실패.
|
||||
- invalidation 실패가 조용히 무시되면 실패.
|
||||
- stampede 방지 검증: `@Cacheable`이 적용된 모든 메서드는 (a) `sync=true` 명시 또는 (b) Caffeine의 `AsyncLoadingCache` 사용 또는 (c) Redisson `RLock` wrap 중 하나여야 함. 측정 방법: ArchUnit `methodsThat().areAnnotatedWith(@Cacheable).should().beAnnotatedWith(@Cacheable.class).withAttribute("sync", "true")` 또는 동등 reflection check. 위반 시 fail.
|
||||
- Redis unavailable이 degrade 가능 여부 없이 INTERNAL로 처리되면 실패.
|
||||
- multi-instance cache claim consistency: env property `APP_MULTI_INSTANCE_ENABLED=true`이고 `APP_CACHE_REDIS_ENABLED=true`이면 Redisson `RedissonClient` bean이 등록되어 있고 모든 hot cache 메서드가 RLock으로 wrap되어 있어야 함. 측정 방법: 두 flag 모두 true일 때 contract test `MultiInstanceCacheStampedeContractTest.java`에서 Redisson bean verify + RLock 사용 검증.
|
||||
- tx rollback 시 cache에 stale write가 남으면 실패.
|
||||
- 동일 key에 대해 동시 cache miss 시 backend 호출이 1회로 제한되는지 verify (stampede). 미충족 시 실패.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/cache-aside-vs-write-through-aws]] | cache-aside default 채택의 trade-off 표 + AWS 공식 분류 |
|
||||
| [[raw/official-docs/cache-caffeine-asyncloadingcache-readme]] | single-instance stampede 방지를 `LoadingCache` / `@Cacheable(sync=true |
|
||||
| [[raw/official-docs/cache-redisson-rlock-vs-setnx]] | multi-instance HPA에서 Redisson RLock 채택 + SETNX/Redlock 배제 근거 (Kleppmann 비판 포함 |
|
||||
| [[raw/company-tech-blogs/cache-woowahan-after-commit-invalidation]] | after-commit invalidation 결정의 한국 사례 + Spring `TransactionSynchronizationManager` 강제 근거 |
|
||||
|
||||
## 외부 근거 (Group G-C — Cache consistency)
|
||||
|
||||
ca-tmpl cache 결정 backbone + stampede 방지 도구 비교 자료.
|
||||
|
||||
- 채택 결정의 공식 근거:
|
||||
- [[raw/official-docs/cache-aside-vs-write-through-aws]] — cache-aside default 채택의 trade-off 표 + AWS 공식 분류.
|
||||
- [[raw/official-docs/cache-caffeine-asyncloadingcache-readme]] — single-instance stampede 방지를 `LoadingCache` / `@Cacheable(sync=true)`에 매핑하는 공식 근거.
|
||||
- [[raw/official-docs/cache-redisson-rlock-vs-setnx]] — multi-instance HPA에서 Redisson RLock 채택 + SETNX/Redlock 배제 근거 (Kleppmann 비판 포함).
|
||||
- 사례 / 한국 도메인:
|
||||
- [[raw/company-tech-blogs/cache-woowahan-after-commit-invalidation]] — after-commit invalidation 결정의 한국 사례 + Spring `TransactionSynchronizationManager` 강제 근거. (회사 기술블로그 — 사례 취급)
|
||||
|
||||
검색 키워드 기록: `cache-aside vs write-through trade-off`, `Caffeine AsyncLoadingCache stampede`, `Redisson RLock vs SETNX`, `Kleppmann Redlock unsafe`, `우아한형제들 캐시 무효화 트랜잭션`.
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
- write transaction commit 이후에만 invalidate하고 rollback 시 cache를 변경하지 않는다.
|
||||
- TTL·key namespace·stampede 방지 정책은 registry 값으로 고정하고 backend adapter가 적용한다.
|
||||
- hit·miss·eviction·fallback을 contract test와 metric으로 함께 검증한다.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- commit 전 eviction은 rollback 뒤 stale miss를, eviction 실패 무시는 stale read를 만들 수 있다.
|
||||
- database transaction·multi-backend router·runtime context 계약에 의존한다.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- 별도 일일 노트 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-cachestore-multi-backend-router.md
|
||||
@@ -0,0 +1,200 @@
|
||||
---
|
||||
title: branch / feature-cachestore-multi-backend-router
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-cachestore-multi-backend-router
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
tags: [branch, ca-skeleton, cache, decorator, fail-open, outbound-adapter]
|
||||
created: 2026-06-12
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-049
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-049
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-RESILIENCE-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-FRAMEWORK-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-TEST-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-024]
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: 42ff5787aebde944ffe6a393e9d9f1621e3c75fc5abb1ba1e85d64d6d7c71a77
|
||||
---
|
||||
|
||||
# branch: feature-cachestore-multi-backend-router
|
||||
|
||||
> Layer: `raw/branch-notes/` — 캐시 다중 백엔드 라우터 계획의 단계별 결정·진행 기록.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
형제 branch:
|
||||
- [[raw/branch-notes/feature-cache-consistency-contract]]
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/cache-backend-router-fail-open-decorator-2026-07-02]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
> 본 feature branch 는 leaf — 자식 자료 없음. 구현 진행 중에 errors / interview prep 이 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록 (본 feature 작업 중 발생)
|
||||
|
||||
- (없음 — Task 2 clean)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- "fail-open 을 왜 별도 데코레이터로 분리했나?" — 정책 드리프트 방지: 새 백엔드가 try/catch 를 직접 구현하면 로그 포맷·로직이 달라질 위험.
|
||||
- "RedisCacheStore 가 이미 fail-open 이었는데 왜 분리가 필요한가?" — 다음 백엔드(Memcached 등)에 동일 정책을 재사용하기 위해. SRP 적용.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: cache backend 선택·fallback·failure routing과 contract test가 명시된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1` | Gradle multi-module에서 domain-core·application-core·adapter-*·shared-contract·app-bootstrap·sample-portfolio 책임을 분리한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-RESILIENCE-001@1` | Resilience4j가 default이며 Spring Retry는 simple blocking retry에만 허용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-FRAMEWORK-001@1` | framework는 Spring Boot 3.5.14다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-TEST-001@1` | test framework는 JUnit 5다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
`RedisCacheStore` 내부에 고착된 fail-open try/catch 정책을 데코레이터(`FailOpenCacheStore`)로 분리하여, 미래 캐시 백엔드들이 정책 드리프트 없이 동일한 fail-open 계약을 재사용하게 한다.
|
||||
|
||||
- 이슈: (내부 계획 — `docs/superpowers/plans/2026-06-12-cachestore-multi-backend-router.md`)
|
||||
- PR: TBD
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- Task 2: `FailOpenCacheStore` 데코레이터 신규 작성 (정책 공통화)
|
||||
- `src/adapter-outbound/.../cache/FailOpenCacheStore.java`
|
||||
- `src/adapter-outbound/.../cache/FailOpenCacheStoreTest.java` (4계약 TDD)
|
||||
- Task 5: `CacheStoreRouter` 논리명 라우팅 + 부팅 검증 + D4 fail-fast
|
||||
- `src/adapter-outbound/.../cache/CacheStoreRouter.java`
|
||||
- `src/adapter-outbound/.../cache/CacheStoreRouterTest.java` (5계약 TDD)
|
||||
- 향후 Task 3: `RedisCacheStore` 슬림화 (내부 try/catch 제거 → `FailOpenCacheStore` 위임)
|
||||
- 향후 Task N: 추가 백엔드 바인딩 (Memcached 등)
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 이번 Task 에서 `RedisCacheStore`/`RedisCacheAdapterConfig` 수정 없음 (다음 Task 몫).
|
||||
- Spring `@Configuration` 등록 (다음 Task 몫 — Task 5 에서는 순수 Java 클래스만).
|
||||
|
||||
## TODO
|
||||
|
||||
> Task 2 완료. Task 5 완료. Task 3 이후는 별도 dispatch.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- 2026-06-12: fail-open 정책(try/catch + logFailure → Optional.empty)을 `FailOpenCacheStore` 데코레이터로 추출. 모든 백엔드는 위임으로만 정책을 받는다.
|
||||
- 2026-06-12: `CacheBackendException` 은 다음 Task 에서 신규 작성. 현재 javadoc 은 `{@code}` 임시 링크.
|
||||
- 2026-06-12: 데코레이터는 `final` — 서브클래싱 차단으로 정책 드리프트 방지.
|
||||
- 2026-06-12 (Task 5): `CacheStoreRouter` 는 논리명(`worklog`) → 백엔드 ID(`redis`) 매핑만 담당. Spring 의존 없는 순수 Java. 생성자에서 바인딩-백엔드 정합 검증(startup validation). 미바인딩 접근은 `AdapterDisabledException`(D4 fail-fast). `resolve()` 는 `private` — B7 ACL return type 규칙 준수 (`CacheStore` 타입 노출 없음).
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시.
|
||||
|
||||
| Decision ID | Decision (요약) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|
|
||||
| D1 | fail-open 정책을 데코레이터로 분리 (Decorator pattern) | 기존 `RedisCacheStoreTest` 4계약이 동일 logback ListAppender 패턴으로 검증됨 — 패턴 재사용 가능성 확인 | `internally-verified` (ca-tmpl 기존 코드 관찰) | `CacheBackendException` 미존재 — 다음 Task 에서 생성 전까지 javadoc 링크 불완전 |
|
||||
| D2 | `FailOpenCacheStore` 는 `CacheStore` 구현 + `final` | Decorator pattern — GoF 패턴 (UNSUPPORTED_DECISION — 외부 verbatim raw 없음) | `internal-policy` | 서브클래싱 차단이 확장성에 제약이 될 수 있음. 현재 단일 String 타입 캐시만 지원 |
|
||||
| D3 | `get` 실패 = `Optional.empty()` 반환, `put` 실패 = silent swallow | 기존 `RedisCacheStore` 의 fail-open 계약과 일치 — `CacheStore` 인터페이스 javadoc 에 명시됨 | `internally-verified` | 호출자가 cache-miss 를 source-of-truth fallback 으로 처리해야 함 — 호출 측 계약 별도 확인 필요 |
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| `FailOpenCacheStore` 가 future `RedisCacheStore` 슬림화 후에도 동일 4계약을 보장 | 현재 `RedisCacheStore` 는 수정 미완료 | Task 3 완료 후 `RedisCacheStoreTest` 전체 통과 확인 | `planned` |
|
||||
| `CacheBackendException` 도입 후 javadoc `{@link}` 복원 시 컴파일 안전 | 다음 Task 에서 생성 예정 | Task 3 에서 `{@code}` → `{@link}` 교체 + 컴파일 확인 | `planned` |
|
||||
| `FailOpenCacheStore` 가 미래 백엔드(Memcached 등)에 실제로 재사용 가능 | 현재 String 키/값만 지원 — 타입 파라미터화 필요 여부 미검토 | 다음 백엔드 도입 Task 에서 확인 | `open` |
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| 기존 `RedisCacheStoreTest` (in-repo) | logback ListAppender 패턴 재사용 — D1 |
|
||||
| `CacheStore` 인터페이스 javadoc (in-repo) | fail-open 계약 정의의 SSOT — D3 |
|
||||
| [[raw/branch-notes/feature-cache-consistency-contract]] | Redis unavailable degrade 계약의 상위 결정 맥락 |
|
||||
|
||||
## 진행 현황
|
||||
|
||||
| Task | 상태 | 커밋 |
|
||||
|---|---|---|
|
||||
| Task 1: AdapterDisabledException detail 오버로드 (shared-contract) | ✓ 완료 (테스트 5 PASS) | 미커밋 (사용자 git 금지 지시) |
|
||||
| Task 2: FailOpenCacheStore 데코레이터 | ✓ 완료 (파일 2개 신규, 테스트 4개 PASS) | 미커밋 (사용자 git 금지 지시) |
|
||||
| Task 3: RedisCacheStore 슬림화 + CacheBackendException | ✓ 완료 (품질리뷰 FIX 포함, RedisCacheStoreTest 5 PASS) | 미커밋 |
|
||||
| Task 4: CacheBindingSettings (`app.cache.bindings.*`) | ✓ 완료 (테스트 2 PASS) | 미커밋 |
|
||||
| Task 5: CacheStoreRouter 논리명 라우팅 + D4 fail-fast | ✓ 완료 (파일 2개 신규, 테스트 5개 PASS) | 미커밋 (사용자 git 금지 지시) |
|
||||
| Task 6: 조립 전환 (sentinel 폐기, `@Bean(name="redis")` 기여, OCP 증명 테스트) | ✓ 완료 (`:adapter-outbound:test` 137 PASS) | 미커밋 |
|
||||
| Task 7: 문서 정합화 (CacheStore javadoc / adapter-outbound CLAUDE.md / application.yml 주석) | ✓ 완료 (메인 에이전트 직접 수행 — 사용자 지시로 서브에이전트 체인 중단) | 미커밋 |
|
||||
| Task 8: 전체 가드레일 검증 | ✓ 완료 — `verifyCleanArchitectureDependencies` PASS, ArchUnit `CleanArchitectureTest` PASS, `DisabledCacheStore` 잔존 참조 0건, 전체 `./gradlew check` BUILD SUCCESSFUL (32s) | - |
|
||||
| 후속 리팩터: `CacheBackend` 마커 인터페이스 (빈이름 매직 제거) + fail-open 중앙화 | ✓ 완료 (사용자 비평 수용, 메인 에이전트 직접) — 기여 계약을 "빈 이름 = backendId" 규약에서 `CacheBackend.backendId()` 타입 명시 계약으로 전환; `FailOpenCacheStore` 합성을 백엔드 Config 관례에서 `CacheRouterConfig` 중앙 적용으로 이동(구조적 보장); 라우터에 중복 backendId 부팅 검증 추가; `RedisCacheAdapterConfig`는 raw `RedisCacheStore` 기여만 하는 얇은 Config로 축소. 캐시 범위 29 tests PASS, ArchUnit PASS(B7: `backendId()`는 String 반환이라 합법), 의존 매트릭스 PASS. (`actually-implemented`, `locally-verified`) | 미커밋 |
|
||||
|
||||
- 기록 분산 주의: Task 1·3·4·6 의 상세 구현 기록은 세션이 돌던 git 브랜치명 기준으로 [[raw/branch-notes/feature-domain-event-outbox-contract]] 의 "진행 중 메모"에 적재됨 (2026-06-12 항목들). 본 노트가 이 feature 의 SSOT 이며, 해당 항목들은 이 작업의 기록이다.
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- provider 선택·fallback·실패 routing의 세부 상태는 위 진행 현황과 TODO를 기준으로 추적한다.
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
- core는 `CacheStore` SPI만 알고 provider registry가 설정값을 실제 adapter로 해석한다.
|
||||
- 지원하지 않는 provider·중복 key·필수 backend 부재는 startup에서 실패시키고 runtime silent fallback을 만들지 않는다.
|
||||
- backend별 동일 contract suite로 get·put·evict·timeout 의미를 대조한다.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- provider 이름 오타나 중복 등록은 잘못된 backend 선택으로 이어지므로 fail-fast가 필요하다.
|
||||
- cache consistency 계약과 runtime configuration 계약에 의존한다.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- backend별 capability 차이를 공통 SPI에 과도하게 노출하면 core가 특정 기술에 결합된다. 공통 최소 계약 밖 기능은 adapter-local로 둔다.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- 별도 일일 노트 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크: (미정 — 사용자가 일괄 커밋 예정, 커밋·푸시 전)
|
||||
- 리뷰 메모: Task 별 ca-architect-sentinel → ca-spec-reviewer → ca-quality-reviewer 체인 수행 (Task 1-6). spec NEEDS_FIX 2건은 모두 선재 working-tree 변경(outbox 작업·세션 이전 javadoc 줄바꿈)으로 판명되어 controller Override. 품질 Important 2건(Task 3 테스트 갭)은 수정 완료. Task 7-8 은 사용자 지시로 메인 에이전트 직접 수행 (소규모 작업에 체인 과잉).
|
||||
- 머지 결과 / 배포 환경: 미머지 (작업 트리 상태)
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented`: `FailOpenCacheStore` 데코레이터 패턴 + 4계약 TDD
|
||||
- `actually-implemented`: `CacheStoreRouter` 논리명 라우팅 — startup-time binding validation + D4 fail-fast + B7 ACL 준수 — 5계약 TDD
|
||||
- `actually-implemented`: `CacheBackend` 타입 명시 기여 모델 — `ObjectProvider<List<CacheBackend>>` 수집으로 OCP 달성 (2번째 백엔드 = 신규 Config 파일만; `OptionalAdapterBeanGatingTest.a_second_backend_plugs_in_...` 이 증명 테스트). 초기 구현은 빈이름=backendId 규약이었으나 사용자 비평(빈이름 매직·무차별 수집·탐색 불가) 수용 후 마커 인터페이스로 교체 — `backendId()` 가 String 반환이라 B7 합법이라는 발견이 전환점
|
||||
- `actually-implemented`: B7 ArchUnit 제약이 설계를 두 번 바꾼 사례 — (1) 바인딩 record(accessor 가 CacheStore 반환) 폐기 → 빈이름-키 맵 주입, (2) String 반환 메서드는 합법임을 재발견 → `CacheBackend` 마커 인터페이스로 최종 수렴
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
- application-core 소비자 포트 (planned — 소비 계층 결정 대기), put TTL 옵션 객체 (planned), L1/L2 컴포지트 (planned)
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-ci-quality-gates-contract.md
|
||||
@@ -0,0 +1,422 @@
|
||||
---
|
||||
title: branch / feature-ci-quality-gates-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-ci-quality-gates-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]
|
||||
tags: [branch, ca-skeleton, ci, quality-gate, contract-test]
|
||||
created: 2026-05-22
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-028
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-028
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-OPENAPI-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-TEST-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: e13ec9fd666d546ce8cb089f48f43da5ed3d77a72d0c6edc691bad11e65956e8
|
||||
---
|
||||
|
||||
# branch: feature-ci-quality-gates-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — skeleton 계약이 문서에만 남지 않도록 CI에서 강제할 quality gate 기준을 정의합니다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (§<관련 섹션>) 의 결정/근거/금지 사항을 정제한다.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: architecture·contract·OpenAPI blocking gate가 분리 실행된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-OPENAPI-001@1` | verification suite가 OpenAPI drift의 release-blocking 판정권을 소유한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-TEST-001@1` | test framework는 JUnit 5다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
운영 계약은 깨지기 쉽습니다. response envelope, log schema, env fail-fast, OpenAPI drift, repository capability, security/log leakage 같은 항목은 CI에서 실패 조건으로 고정해야 합니다.
|
||||
|
||||
본 branch 의 책임은 **gate wiring(어떤 gate 가 CI 에서 어떻게 실행/차단되는가)** 이다. 개별 scanner/tool/severity *정책 결정* 은 전용 owner branch 가 소유하고 본 branch 는 그 결과를 release-blocking gate 로 *배선* 한다 (§구현 가이드 §6, §엣지·실패·의존 의존 목록).
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- format/lint/test/contract test gate.
|
||||
- OpenAPI drift check.
|
||||
- dependency vulnerability scan gate.
|
||||
- optional adapter test matrix.
|
||||
- warning-only와 release-blocking gate 구분.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 실제 CI provider workflow 구현 세부.
|
||||
- 배포 승인 프로세스.
|
||||
- load/performance test.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/ci-github-actions-vs-gitlab-comparison]] | GitHub Actions `needs:` + `if: success( |
|
||||
| [[raw/official-docs/ci-openapi-snapshot-diff-tooling]] | springdoc + openapi-diff (Tufin/oasdiff |
|
||||
| [[raw/company-tech-blogs/ci-flaky-test-quarantine-spotify-google]] | Spotify/Google/MS quarantine 인정 vs Fowler 반대 |
|
||||
|
||||
## 외부 근거 / 대안 조사 (2026-05-22 — Group G-E: CI Quality Gates)
|
||||
|
||||
본 branch의 Gate ownership matrix 20행 + flaky quarantine 14d + OpenAPI snapshot diff 결정에 대한 외부 source.
|
||||
|
||||
- **채택 결정 (GitHub Actions + matrix gate + flaky 14d sunset)**:
|
||||
- [[raw/official-docs/ci-github-actions-vs-gitlab-comparison]] — GitHub Actions `needs:` + `if: success()`가 ca-tmpl contract gate 모델과 정확히 맞물림
|
||||
- [[raw/official-docs/ci-openapi-snapshot-diff-tooling]] — springdoc + openapi-diff (Tufin/oasdiff)
|
||||
- **검토한 대안**:
|
||||
- **대안 1: GitLab CI vs GitHub Actions** — 동일 source에서 비교. **선택 조건**: ca-tmpl repo 가 GitHub 호스팅 → GitHub Actions 채택; GitLab 호스팅으로 이전 시 `needs` ↔ `stages` 매핑(`CIGG-C2`)으로 이식 가능 (provider 선정 자체는 별도 ADR — `CIGG-C2` 는 매핑 *가능성* 만 보장)
|
||||
- **대안 2: Jenkins / Tekton (k8s-native)** — k8s 인프라/plugin 의존도로 skeleton 단계에 과함
|
||||
- **대안 3: CircleCI / Buildkite / Drone CI** — vendor 다양성, ca-tmpl scope 외
|
||||
- **사례 (flaky quarantine)**: [[raw/company-tech-blogs/ci-flaky-test-quarantine-spotify-google]] — Spotify/Google/MS quarantine 인정 vs Fowler 반대. ca-tmpl 14일 sunset은 절충안
|
||||
- **비교 핵심**: GitHub Actions의 `needs:`/`if:` gate 의존성 모델이 ca-tmpl 11 release-blocking gate에 정확히 맞물림. Tekton/Jenkins는 k8s 인프라/plugin 부담으로 skeleton에 과함. Flaky quarantine은 Spotify/Google/MS 인정 vs Fowler 반대 양립 — ca-tmpl 14일 sunset이 절충.
|
||||
|
||||
## TODO
|
||||
|
||||
> TODO drained 2026-05-22 — required CI gate 목록 / release-blocking vs warning-only 기준 / contract test 차단 / optional adapter matrix / OpenAPI drift / vulnerability 차단 정책 모두 "결정 사항"과 "Gate ↔ Branch Contract Test 소유권 매트릭스"에 반영됨. 잔존 TODO 없음.
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- 2026-06-15 (`/branch-spec`): pre-template 노트를 현 템플릿 구조로 보강 — 누락 섹션(구현 가이드 / 엣지·실패·의존 / 진행 중 메모 / 관련 일일 노트) 추가, `parent_branch` + `governing_docs` frontmatter 추가, §Coverage seed, §Audit & Findings(ground-truth drift) 추가. 기존 결정·매트릭스·테스트 계약 본문은 verbatim 보존. ca-tmpl ground truth 대조 결과 모든 gate 는 여전히 `documented-only`(`.github/workflows/` 부재 확인) — actually-implemented 주장 없음.
|
||||
- 2026-06-20 (구현): gate wiring 을 `actually-implemented`(로컬 `locally-verified`)로 승급. 산출물 — `.github/workflows/ci-quality-gates.yml`(9 잡: quality-gates/security-snapshot-gates/openapi-drift/sample-removal-smoke/optional-adapter-matrix/gate-matrix-lint/breaking-change-approval/quarantine/**release-gate** fan-in), `.github/ci-gate-matrix.yml`(20행 in-repo SSOT), `.github/scripts/verify-gate-matrix.sh`(C7 cross-check), `flaky-quarantine.yaml`(repo-루트 레지스트리, 빈 버킷), `src/build.gradle`(`test` excludeTags 'quarantine' + `quarantineTest` 버킷 + `verifyQuarantineSunset` 14일 sunset, check 연결), `.github/CODEOWNERS`/`.github/pull_request_template.md`(D8 escape-hatch 거버넌스), `src/README.md` 문서.
|
||||
- **핵심 구현 결정 (UNSUPPORTED_IMPL_DECISION 해소):**
|
||||
- **§4 quarantine 메커니즘 = `@Tag("quarantine")`(JUnit 기본, 전 모듈 즉시 사용) + repo-루트 `flaky-quarantine.yaml` 레지스트리** — note 의 `@QuarantinedSince` custom annotation 후보 대신 채택. 이유: custom annotation 의 cross-module 사용은 test-fixtures/신규 모듈 plumbing 필요(과함)이고, `shared-contract`(stdlib-only)에 JUnit 타입을 둘 수 없음. 레지스트리 방식은 기존 4개 거버넌스 게이트(verifyTrivyignore/verifyEnvKeys/verifyOneTypePerFile/verifyPublicPathSnapshot)와 동형이며 gitignored-docs 제약(아래)도 회피.
|
||||
- **release-gate fan-in = `if: always()` + `needs.*.result` 스캔** — `if: success()` 단독은 상위 실패 시 aggregator 가 skipped(차단 아님). Claim C1 의 실증적 해소.
|
||||
- **gitignored 설정 제약 발견:** `.gitignore` 가 `/docs` 전체 제외(0 tracked) → CI-read 신규 파일은 `docs/` 금지, tracked 경로(repo 루트/`.github/`)에 배치(`.trivyignore.yaml` 선례). `check` 의 registry 의존은 워크플로 "RUNTIME-CONFIG PREREQUISITE" 로 문서화(범위 밖 — env-driven 소유).
|
||||
- 검증(로컬): verifyQuarantineSunset 3종 control(empty→OK / over-age 170d→fail / drift unregistered→fail), `:shared-contract:quarantineTest` BUILD SUCCESSFUL(빈 버킷), gate-matrix-lint PASS(20=16 verified+4 delegated), openapiCheckSnapshot/SampleRemovalSmoke/TestTaxonomyArchitectureTest/verifyCleanArchitectureDependencies 통과, 워크플로 YAML PyYAML 파싱 OK. **CI 실제 실행은 `needs-confirmation`.**
|
||||
- 파생 노트: [[raw/errors/ci-fan-in-skipped-not-failed-and-gitignored-config-2026-06-20]], [[raw/blog-topics/ci-gate-wiring-vs-policy-ownership-2026-06-20]], [[raw/interviews/ci-release-gate-fan-in-blocking-2026-06-20]].
|
||||
|
||||
## Work Item Contract
|
||||
|
||||
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
|
||||
|
||||
| field | required | rule |
|
||||
| --- | --- | --- |
|
||||
| Decision | yes | 구현자가 선택해야 하는 기본값 |
|
||||
| Allowed | yes | 허용되는 예외와 조건 |
|
||||
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
|
||||
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
|
||||
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
|
||||
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
|
||||
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-05-22: contract violation은 warning-only로 두지 않음.
|
||||
- 2026-05-22: optional adapter test는 adapter enabled matrix에서만 실행.
|
||||
- 2026-05-22: OpenAPI drift, registry drift, security/privacy leakage, architecture boundary, sample removal smoke는 release-blocking.
|
||||
- 2026-05-22: warning-only는 dependency freshness advisory처럼 release artifact correctness를 직접 깨지 않는 항목에만 허용.
|
||||
- 2026-05-22: vulnerability scanner = Trivy (image + dependency). suppression은 `trivy-ignore` 파일 + PR review approval 필수.
|
||||
- 2026-05-22: OpenAPI drift ground truth = code-generated snapshot (springdoc 등). hand-maintained는 forbidden.
|
||||
- 2026-05-22: flaky test quarantine bucket 허용. quarantine된 test는 별도 gradle task로 분리, sunset deadline 14일.
|
||||
- 2026-05-22: contract test snapshot 의도적 갱신 = breaking change catalog row 인용 + PR label `intent:breaking-change-approved`로 escape hatch.
|
||||
- 2026-05-22: 본 branch가 **flaky test quarantine SSOT** (sunset 14일). test-taxonomy-fixture-contract는 consumer (flaky 발생 시 quarantine bucket 참조).
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. `Decision ID` 는 이 branch-note 안에서 안정적으로 유지.
|
||||
|
||||
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|
|
||||
| D1 | contract violation은 warning-only로 두지 않음 (release-blocking) | UNSUPPORTED_DECISION (외부 source 직접 증명 없음 — 조직 policy 결정) | `team-policy` | release-blocking 강도 자체의 외부 표준 부재 |
|
||||
| D2 | optional adapter test는 adapter enabled matrix에서만 실행 | `raw/official-docs/ci-github-actions-vs-gitlab-comparison.md#CIGG-C3` (GitHub Actions `needs` key 로 job 의존성 명시) | `official-vendor-doc` (matrix job 표현은 vendor docs 에서 직접 지원) | `strategy.matrix` 의 정확한 표현은 본 인용 범위 밖 — 별도 GitHub Actions matrix 문서 raw 등록 권고 |
|
||||
| D3 | OpenAPI drift, registry drift, security/privacy leakage, architecture boundary, sample removal smoke 는 release-blocking | `raw/official-docs/ci-openapi-snapshot-diff-tooling.md#CIOS-C4`, `raw/official-docs/ci-openapi-snapshot-diff-tooling.md#CIOS-C5`, `raw/official-docs/ci-github-actions-vs-gitlab-comparison.md#CIGG-C2` | `official-vendor-doc` | breaking change 판정 규칙 차이 (openapi-diff vs oasdiff) 별도 검증 필요 |
|
||||
| D4 | warning-only는 dependency freshness advisory 처럼 release artifact correctness 를 직접 깨지 않는 항목에만 허용 | UNSUPPORTED_DECISION (외부 source 직접 증명 없음 — 조직 분류 정책) | `team-policy` | freshness advisory 와 vulnerability advisory 의 경계 정의 필요 |
|
||||
| D5 | vulnerability scanner = Trivy (image + dependency), suppression 은 `trivy-ignore` + PR review approval 필수 | UNSUPPORTED_DECISION (Trivy 공식 docs raw source 없음) — **+ OWNER_AMBIGUITY**: scanner *tool 선택* 은 본 branch(gate wiring) 범위 밖 후보. [[raw/branch-notes/feature-dependency-vulnerability-management-contract]](현재 scanner 미결) 또는 severity 정책 owner [[raw/branch-notes/feature-build-release-supply-chain-contract]] 로 위임 권고 (§Audit) | `team-convention` | Trivy 공식 페이지 raw source 보강 필요 + tool 선택 owner 미확정 |
|
||||
| D6 | OpenAPI drift ground truth = code-generated snapshot (springdoc 등), hand-maintained 는 forbidden | `raw/official-docs/ci-openapi-snapshot-diff-tooling.md#CIOS-C1`, `raw/official-docs/ci-openapi-snapshot-diff-tooling.md#CIOS-C2`, `raw/official-docs/ci-openapi-snapshot-diff-tooling.md#CIOS-C3` | `official-vendor-doc` (springdoc runtime introspection 의 공식 동작) | springdoc 은 dynamic routing (WebFlux functional routes) 일부 누락 위험 — `CIOS-C1` 의 inferred semantics 한계 |
|
||||
| D7 | flaky test quarantine bucket 허용, 별도 gradle task 로 분리, sunset deadline 14일 | UNSUPPORTED_DECISION (`raw/company-tech-blogs/ci-flaky-test-quarantine-spotify-google.md` 는 company-case-study — 공식 best practice 로 단정 불가) | `company-case-study` (Spotify/Google/MS 인정 vs Fowler 반대 양립) | 14일 sunset 정량값은 ca-tmpl 절충안 — 외부 표준 부재 |
|
||||
| D8 | contract test snapshot 의도적 갱신 = breaking change catalog row 인용 + PR label `intent:breaking-change-approved` escape hatch | UNSUPPORTED_DECISION (조직 governance 결정 — 외부 source 직접 증명 없음) | `team-policy` | label 권한 정책 (`feature-contract-verification-test-suite` D-Verify Claim 과 cross-link) |
|
||||
| D9 | 본 branch 가 flaky test quarantine SSOT (sunset 14일), test-taxonomy-fixture-contract 는 consumer | UNSUPPORTED_DECISION (cross-branch ownership 결정) | `team-policy` | 본 branch ↔ test-taxonomy branch 간 ownership 경계 명문화 |
|
||||
|
||||
> Note: `ci-flaky-test-quarantine-spotify-google` 는 `company-tech-blog` 카테고리이므로 본 branch 의 quarantine 정책은 `company-case-study` 강도만 가지며 official best practice 로 표현 금지.
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> *결정 (Decisions)* 이 "*무엇* 을 할 것인가" 라면, 본 §는 "*어디에 어떻게* 구현될 것인가" 의 사전 명세. 본 branch 의 핵심 산출물 카탈로그(gate 목록 + owner 매핑)는 `## Gate ↔ Branch Contract Test 소유권 매트릭스`(SSOT, 20행). 본 §는 그 매트릭스가 담지 못하는 **wiring 메커니즘**(needs/if 위상, OpenAPI gate step, flaky 강제, escape hatch, 위임 경계)을 결정·근거 reference 와 함께 명세한다.
|
||||
|
||||
### 1. Gate 위계 — release-blocking vs warning-only 분류 규칙
|
||||
|
||||
> **Trace**: D1 (contract violation = release-blocking) + D3 (release-blocking 목록) + D4 (warning-only 한정). Supporting: `CIGG-C2` (stages↔needs gate 의존성 모델), team-policy.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: "release-blocking" 강도 자체(D1/D4)는 조직 policy — 외부 표준 부재. trade-off: 엄격 차단(merge 속도 ↓, 계약 안전 ↑) vs warning-only 완화(반대). freshness advisory 와 vulnerability advisory 의 경계(D4 Open Risk)도 조직 분류.
|
||||
|
||||
- 분류 규칙: **release artifact correctness 를 직접 깨는 gate = release-blocking**(D3 목록 + 매트릭스 release-blocking 열), **freshness advisory 류만 warning-only**(D4).
|
||||
- 전체 gate 목록·owner·release-blocking 여부 = `## Gate ↔ Branch Contract Test 소유권 매트릭스`(SSOT). 본 sub-section 은 *판정 규칙*만, 카탈로그는 매트릭스가 소유(중복 금지).
|
||||
|
||||
### 2. GitHub Actions gate 위상 (needs
|
||||
|
||||
> **Trace**: D2 (optional adapter = enabled matrix only) + D3. Supporting: `CIGG-C3` (job 의존성 = `needs` key), `CIGG-C2` (GitLab `stages` ↔ GitHub `needs` 매핑).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: ① `strategy.matrix` 의 정확한 yaml shape(adapter-enabled 조합 표현) — `CIGG-C3` 는 `needs` key 만 보장, matrix 표현은 인용 범위 밖. trade-off: 별도 GitHub Actions matrix vendor 문서 raw 등록 필요(D2 Open Risk). ② fan-in 시 status 전파(`if: success()` vs `if: always()`)의 정확한 규칙 — `CIGG-C3` 미보장(§엣지 Claim 1 로 검증 위임).
|
||||
|
||||
- 각 contract-test job 은 release job 의 `needs:` 의존성으로 선언, `if: success()` 로 release gate.
|
||||
- optional adapter test(D2)는 `strategy.matrix` 의 adapter-enabled 조합에서만 실행 → 매트릭스 행 "integration test (optional adapter matrix) | true if matrix enabled".
|
||||
|
||||
### 3. OpenAPI drift gate
|
||||
|
||||
> **Trace**: D6 (ground truth = code-generated snapshot, hand-maintained forbidden) + D3 (release-blocking). Supporting: `CIOS-C1`/`C2`/`C3` (springdoc runtime introspection), `CIOS-C4` (openapi-diff 3.x 비교), `CIOS-C5`/`C6` (oasdiff breaking 서브명령).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: ① gradle task 명 `openapiCheckSnapshot` — note 자체 명명, 인용 외. trade-off: 명명 임의(되묻기 방지용 고정). ② exit-code 기반 차단 — `CIOS-C5` 가 breaking 시 non-zero exit 을 직접 보장하지 않음(§엣지 Claim 2 검증 위임).
|
||||
|
||||
- baseline `openapi-snapshot.yaml`(checked-in) vs build 시 springdoc-generated OpenAPI 를 `oasdiff breaking`(또는 openapi-diff)으로 비교, breaking 1건+ 이면 release-block.
|
||||
- 알려진 한계: springdoc 은 WebFlux functional route 등 dynamic routing 일부 누락 가능(`CIOS-C1`).
|
||||
|
||||
### 4. Flaky test quarantine bucket (본 branch SSOT, 14d sunset)
|
||||
|
||||
> **Trace**: D7 (quarantine bucket + 별도 gradle task + 14일 sunset) + D9 (본 branch = SSOT, test-taxonomy = consumer). Supporting: `company-case-study`(Spotify/Google/MS) + team-policy.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `@QuarantinedSince` annotation 명 + CI step 의 14일 초과 build-fail 자동 강제 메커니즘 — 외부 source 없음(company-case-study 는 quarantine *인정* 만, 14d 정량·강제 메커니즘 무). trade-off: 14d 는 ca-tmpl 절충값; 자동 강제 미구현 시 수동 리뷰로 대체(§엣지 Claim 5).
|
||||
|
||||
- quarantine bucket = 별도 gradle task 로 분리(메인 gate 에서 격리). [[raw/branch-notes/feature-test-taxonomy-fixture-contract]] 는 flaky 발생 시 본 bucket 을 참조하는 consumer.
|
||||
|
||||
### 5. Snapshot 의도적 갱신 escape hatch
|
||||
|
||||
> **Trace**: D8 (breaking change catalog row 인용 + PR label `intent:breaking-change-approved`). Supporting: team-policy (governance).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: label 부여 권한 정책(누가 label 을 달 수 있나) — 외부 source 없음. trade-off: branch protection + CODEOWNERS 로 label 권한 제한 필요(§엣지 Claim 6); 미설정 시 누구나 우회.
|
||||
|
||||
- contract test snapshot 의 의도적 갱신은 breaking change catalog row 를 인용하고 PR 에 `intent:breaking-change-approved` label 부여로만 통과.
|
||||
|
||||
### 6. 위임된 tool
|
||||
|
||||
> **Trace**: D5 (vulnerability scan). 본 branch 는 **gate wiring owner** — 아래 gate 의 *실행/release-blocking 배선* 은 in-scope 이나, *tool 선택·severity·정책 결정* 은 전용 owner branch 로 위임. 매트릭스의 owner 열이 위임 대상을 가리킨다(단 슬러그 drift 는 §Audit `OWNER_BRANCH_DRIFT` 참조).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: D5 의 Trivy *tool 선택* 은 본 branch 결정 범위 밖 후보 — 전용 vuln branch 미결. trade-off: 본 branch 는 vuln gate 의 release-blocking 배선만 소유, scanner 선택은 위임/확정 필요(§Audit OWNER_AMBIGUITY).
|
||||
|
||||
| Gate (wiring in-scope here) | tool/policy 결정 owner (위임) |
|
||||
|---|---|
|
||||
| vulnerability scan (Trivy) | tool 선택 = [[raw/branch-notes/feature-dependency-vulnerability-management-contract]](미결) / severity 정책 = [[raw/branch-notes/feature-build-release-supply-chain-contract]] |
|
||||
| secret scan (gitleaks) | [[raw/branch-notes/feature-secrets-config-source-contract]] |
|
||||
| SBOM / Cosign / SLSA / license | [[raw/branch-notes/feature-build-release-supply-chain-contract]] |
|
||||
| format / lint (tool + ruleset) | [[raw/branch-notes/feature-static-analysis-quality-contract]] (매트릭스 "(toolchain)" 셀의 실제 owner — parent §2051) |
|
||||
| container image scan | [[raw/branch-notes/feature-container-runtime-contract]] |
|
||||
| .env.example drift | [[raw/branch-notes/feature-env-driven-runtime-configuration]] |
|
||||
|
||||
## Gate ↔ Branch Contract Test 소유권 매트릭스
|
||||
|
||||
모든 gate는 단일 owner branch contract test를 실행. release-blocking 여부 명시.
|
||||
|
||||
| CI gate | release-blocking | owning branch contract test |
|
||||
|---------|------------------|------------------------------|
|
||||
| format / lint | true | (toolchain) |
|
||||
| unit test | true | test-taxonomy-fixture-contract |
|
||||
| architecture test (ArchUnit) | true | architecture-enforcement-rules |
|
||||
| envelope/error contract test | true | contract-verification-test-suite (envelope) |
|
||||
| log/MDC contract test | true | contract-verification-test-suite (log) |
|
||||
| env contract test | true | contract-verification-test-suite (env) |
|
||||
| registry contract test | true | contract-verification-test-suite (registry) |
|
||||
| OpenAPI drift | true | contract-verification-test-suite (OpenAPI) |
|
||||
| schema drift (JSON serialization) | true | contract-verification-test-suite (schema) |
|
||||
| integration test (default profile) | true | test-taxonomy-fixture-contract |
|
||||
| integration test (optional adapter matrix) | true if matrix enabled | integration-adapter-templates |
|
||||
| sample removal smoke | true | sample-removal-adoption-contract |
|
||||
| SBOM generation | true | build-release-supply-chain |
|
||||
| signed artifact (Cosign) verification | true | build-release-supply-chain |
|
||||
| SLSA provenance attestation | true | build-release-supply-chain |
|
||||
| vulnerability scan (Trivy) high/critical | true | build-release-supply-chain |
|
||||
| license scan (NOTICE compliance) | true | build-release-supply-chain |
|
||||
| secret scan (gitleaks) | true | secrets-config-source |
|
||||
| .env.example drift | true | env-driven-runtime-configuration |
|
||||
| container image scan (Trivy image) | true | container-runtime-contract |
|
||||
|
||||
> ⚠️ owner 열 슬러그 drift — `build-release-supply-chain` → `feature-build-release-supply-chain-contract`, `secrets-config-source` → `feature-secrets-config-source-contract`, `(toolchain)`(format/lint) → `feature-static-analysis-quality-contract`. 상세·근거는 §Audit & Findings `OWNER_BRANCH_DRIFT`. (사용자 결정 영역이므로 자동 rewrite 하지 않고 정합 권고만 — §구현 가이드 §6 및 §엣지·실패·의존 의존 목록은 정합된 슬러그 사용.)
|
||||
|
||||
## Gate Matrix (deprecated)
|
||||
|
||||
> CI Gate 전체 목록과 owner branch 매핑은 위 "Gate ↔ Branch Contract Test 소유권 매트릭스"가 SSOT. 별도 Gate Matrix 양식은 deprecated.
|
||||
|
||||
## 테스트 계약
|
||||
|
||||
- contract test result gate: GitHub Actions matrix에서 `contract-test` job의 status가 `failure`이면 workflow status도 `failure`여야 함. 측정 방법: workflow yaml의 `needs: [contract-test]` 의존성 + `if: success()` gate 명시 verify. 누락 시 fail.
|
||||
- OpenAPI drift gate: `openapi-diff` 또는 동등 도구를 `openapi-snapshot.yaml` (checked-in baseline) vs build 시 generated OpenAPI과 비교. diff 결과에 breaking change가 1건이라도 있으면 release-block. 측정 방법: CI step `./gradlew openapiCheckSnapshot` exit code 0 verify.
|
||||
- high/critical vulnerability 차단 정책이 없으면 실패.
|
||||
- sample removal smoke gate: workflow yaml에 `sample-removal-smoke` job이 정의되고 release-blocking matrix에 포함되어 있어야 함. 측정 방법: workflow yaml grep on `sample-removal-smoke` + matrix.profile에 `sample-off` 포함 verify.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4 캡처용. 정상 경로 외에 *구현 중 부딪힐* 실패/엣지/다른 계약 의존을 미리 열거. 각 실패 경로는 §Claims To Verify 항목과 1:1 대응(검증 위임).
|
||||
|
||||
### 실패·엣지 경로
|
||||
|
||||
- **needs/if fan-in status 전파**: 실패한 matrix job 이 release job 으로 failure 를 전파하는가 — `CIGG-C3` 가 `if: always()` 등 정확한 fan-in 규칙 미보장. 기대: gate 1건 실패 → release block (§Claims C1).
|
||||
- **oasdiff exit code semantic**: breaking change 발생 시 `oasdiff breaking` 이 non-zero exit 인가 — `CIOS-C5` 미보장. 기대: breaking 1건 → exit != 0 → CI fail (§Claims C2).
|
||||
- **Trivy false negative / suppression bypass**: CVE DB 갱신 지연, 또는 무단 `trivy-ignore` 추가로 우회. 기대: known CVE → fail, 무단 suppression PR review 없이 차단 (§Claims C3).
|
||||
- **flaky 14d sunset 자동 강제 부재**: `@QuarantinedSince` 류 annotation 없으면 14일 초과를 감지할 수 없음. 기대: 14일 초과 → build fail (§Claims C5).
|
||||
- **label escape-hatch 무단 사용**: label 부여 권한 정책 부재 시 누구나 `intent:breaking-change-approved` 로 우회. 기대: branch protection + CODEOWNERS 로 권한 제한 (§Claims C6).
|
||||
- **gate matrix ↔ 실제 contract test 불일치**: 20행 표의 owning branch 가 실제 contract test 와 어긋남(슬러그 drift 포함, §Audit). 기대: lint script 로 표 ↔ 코드 cross-check (§Claims C7).
|
||||
|
||||
### 다른 계약 의존 (delegated owner = consume 대상)
|
||||
|
||||
> 본 branch 는 아래 owner branch 의 contract test 를 release-blocking gate 로 consume 한다. 해당 계약이 바뀌면 본 branch 의 gate 실패 조건이 변동된다 (R4 IMPLICIT_DEPENDENCY 명시).
|
||||
|
||||
- [[raw/branch-notes/feature-contract-verification-test-suite]] — envelope/log/env/registry/OpenAPI/schema contract test 를 gate 로 consume.
|
||||
- [[raw/branch-notes/feature-test-taxonomy-fixture-contract]] — unit/integration test gate + flaky quarantine **consumer**(D9: 본 branch 가 quarantine SSOT).
|
||||
- [[raw/branch-notes/feature-architecture-enforcement-rules]] — ArchUnit architecture test gate.
|
||||
- [[raw/branch-notes/feature-build-release-supply-chain-contract]] — SBOM/Cosign/SLSA/license/vulnerability-severity gate (severity 정책 owner).
|
||||
- [[raw/branch-notes/feature-secrets-config-source-contract]] — secret scan (gitleaks) gate.
|
||||
- [[raw/branch-notes/feature-env-driven-runtime-configuration]] — .env.example drift gate.
|
||||
- [[raw/branch-notes/feature-container-runtime-contract]] — container image scan gate.
|
||||
- [[raw/branch-notes/feature-integration-adapter-templates]] — optional adapter test matrix.
|
||||
- [[raw/branch-notes/feature-sample-removal-adoption-contract]] — sample removal smoke gate.
|
||||
- [[raw/branch-notes/feature-static-analysis-quality-contract]] — format/lint tool + ruleset (매트릭스 "(toolchain)" 셀의 실제 owner; 본 branch 는 threshold/gate wiring 만).
|
||||
- [[raw/branch-notes/feature-dependency-vulnerability-management-contract]] — vulnerability scanner *tool 선택*(D5 위임 후보, 현재 미결).
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다. 구현 전/중/후에 실제로 검증해야 하는 주장을 분리.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| GitHub Actions `needs:` + `if: success()` 조합이 ca-tmpl 11 release-blocking gate 모두를 강제 | `CIGG-C2`/`C3` 는 매핑 가능성만 보장, `if:` 의 fan-in 시 status 전파 규칙은 인용 범위 밖 | 의도적 fail job 을 matrix 에 추가 → 후속 release job 이 실제로 block 되는지 verify | `partially-implemented` — release-gate 를 `if: always()` + `needs.*.result` 스캔으로 구현(`success()` 단독은 skipped→차단 실패임을 확인). **CI 실제 fail 전파 = `needs-confirmation`** |
|
||||
| `openapi-diff` 또는 `oasdiff` 의 exit code 가 breaking change 발생 시 non-zero | `CIOS-C5` 는 breaking 검출 기능만 보장, exit code semantic 명시 없음 | 의도적 breaking change PR 생성 → `oasdiff breaking` exit code != 0 verify | `locally-verified` — ca-tmpl 은 oasdiff 대신 `openapiCheckSnapshot`(Gradle Test, 스냅샷 byte-compare) 채택; 로컬 exit 0 확인. drift 시 fail 은 OpenApiDriftContractTest 가 보장 |
|
||||
| Trivy high/critical 차단 정책이 false negative 없이 동작 | Trivy CVE DB 갱신 주기 / suppression 우회 가능성 | 의도적 CVE-known dependency (예: log4j 2.14) 추가 → CI fail verify; `trivy-ignore` 무단 추가가 차단되는지 verify | `delegated` — `dependency-vulnerability.yml`(feature-dependency-vulnerability-management-contract) 소유. 본 branch 는 gate-matrix 에서 release-blocking 으로 배선만 |
|
||||
| sample-removal-smoke job 이 release-blocking matrix 에 실제 포함됨 | workflow yaml 의 matrix 구성 검증 부재 | workflow yaml grep on `sample-removal-smoke` + `matrix.profile` 에 `sample-off` 포함 verify | `implemented` — `sample-removal-smoke` 잡 + `strategy.matrix.profile: [sample-off]` 존재, release-gate `needs` 포함. SampleRemovalSmokeContractTest 로컬 통과 |
|
||||
| flaky quarantine bucket 의 14일 sunset 이 자동 강제 | sunset deadline 의 자동 감지 메커니즘 부재 가능 | quarantine bucket 의 test 별 `@QuarantinedSince` annotation + CI step 으로 14일 초과 시 build fail verify | `implemented` (`locally-verified`) — `@Tag("quarantine")` + repo-루트 `flaky-quarantine.yaml` + `verifyQuarantineSunset`(check 연결). over-age 170일 positive control fail 확인. (annotation 대신 레지스트리 채택 — §진행 중 메모) |
|
||||
| `intent:breaking-change-approved` label escape hatch 가 무단 사용 차단 | label 추가 권한 정책 부재 시 누구나 우회 | branch protection + CODEOWNERS 로 label 권한 제한 + audit log 점검 | `partially-implemented` — `breaking-change-approval` 잡(governed snapshot 변경 시 label 강제) + CODEOWNERS(snapshot/approved 경로). **branch protection "Require Code Owners" 활성화는 운영 설정(미적용) = `needs-confirmation`** |
|
||||
| 20개 gate 표의 owning branch 매핑이 실제 contract test 와 일치 | 표만 있고 cross-check 부재 + 슬러그 drift(§Audit) | 각 owning branch 의 contract test 코드 grep + 본 표와 일치 verify (수동 또는 lint script) | `implemented` (`locally-verified`) — `.github/ci-gate-matrix.yml`(20행) + `verify-gate-matrix.sh`. 로컬 PASS(16 verified + 4 delegated-pending). 슬러그는 §Audit 정합본 사용 |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — seed)
|
||||
|
||||
> `/coverage` 가 채우는 **생성물**. 아래는 `/branch-spec`(2026-06-15)이 governing doc [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]] + parent §538 "CI Quality Gates" 관심사로 seed 한 것 — coverage-auditor 가 검증/정정한다. 상태: `covered-here` / `delegated` / `missing`.
|
||||
|
||||
| 관심사 (governing §538 CI Quality Gates) | 상태 | owner | 심각도 | 근거 |
|
||||
|--------|------|-------|--------|------|
|
||||
| format/lint/test/contract/OpenAPI drift/security scan 이 분리된 gate 로 실행 | covered-here | — | — | 매트릭스 20행 + D2/D3 |
|
||||
| merge 전 실패 가능 gate vs warning-only gate 구분 | covered-here | — | — | D1, D3, D4 / §구현 §1 |
|
||||
| contract violation = release-blocking (warning-only 불가) | covered-here | — | — | D1 |
|
||||
| optional adapter test = adapter enabled matrix only | covered-here | — | — | D2 / §구현 §2 |
|
||||
| OpenAPI drift ground truth = code-generated snapshot | covered-here | — | — | D6 / §구현 §3 |
|
||||
| flaky test quarantine + sunset 정책 | covered-here | — | — | D7, D9 / §구현 §4 |
|
||||
| vulnerability scan *tool 선택* | delegated | [[raw/branch-notes/feature-dependency-vulnerability-management-contract]] (미결) | Should-fix | OWNER_AMBIGUITY: 위임 링크 존재, 단 owner 의 scanner 미결 (§Audit) |
|
||||
| vulnerability severity → release-block 정책 | delegated | [[raw/branch-notes/feature-build-release-supply-chain-contract]] | OK | §구현 §6 |
|
||||
| secret scan (gitleaks) tool | delegated | [[raw/branch-notes/feature-secrets-config-source-contract]] | OK | §구현 §6 / §엣지 의존 |
|
||||
| SBOM / Cosign / SLSA / license | delegated | [[raw/branch-notes/feature-build-release-supply-chain-contract]] | OK | §구현 §6 |
|
||||
| format/lint *tool + ruleset* | delegated | [[raw/branch-notes/feature-static-analysis-quality-contract]] | OK | parent §2051 / §구현 §6 |
|
||||
| container image scan | delegated | [[raw/branch-notes/feature-container-runtime-contract]] | OK | §구현 §6 |
|
||||
|
||||
## Audit & Findings (2026-06-15 — `/branch-spec` ground-truth 대조)
|
||||
|
||||
> ca-tmpl `docs/registries/*.yaml` + `src/` + sibling branch-notes 대조 결과. **사용자 작성 결정 영역(매트릭스 owner 열, §완료 후 wiki 추출 대상)은 자동 rewrite 하지 않고 정합 권고만** (CLAUDE.md §15.5 R3, `/branch-spec` §2 drift 규칙). 신규 작성 섹션(§구현 가이드 §6, §엣지·실패·의존, §Coverage)은 정합된 슬러그 사용.
|
||||
|
||||
- **OWNER_BRANCH_DRIFT** (Gate matrix owner 열):
|
||||
- `build-release-supply-chain` → 실제 branch-note 슬러그 `feature-build-release-supply-chain-contract` (존재 확인). 권고: 매트릭스 5개 행(SBOM/Cosign/SLSA/vuln/license) owner 정합.
|
||||
- `secrets-config-source` → 실제 `feature-secrets-config-source-contract` (`docs/registries/secrets-classification.yaml` `owner_branch` SSOT 와 일치). 권고: secret scan 행 정합.
|
||||
- `(toolchain)` (format/lint 행) → 실제 owner `feature-static-analysis-quality-contract` (parent §2051: "tool 선택 + 룰셋" owner; 본 branch 는 *threshold/gate wiring* 만). 권고: owner 명시.
|
||||
- **OWNER_AMBIGUITY** (D5 — vulnerability scanner tool 선택): scanner *tool 선택* 의 owner 미확정. 전용 `feature-dependency-vulnerability-management-contract` 는 현재 scanner 미결, `feature-build-release-supply-chain-contract` 는 severity 정책만 소유. 권고: 본 branch 는 vuln gate 의 release-blocking 배선만 유지하고, Trivy *tool 선택* 결정은 dependency-vulnerability 또는 supply-chain owner 로 위임/확정.
|
||||
- **EXTRACTION_TARGET_DRIFT** (§완료 후 wiki 추출 대상): 지정 경로 `wiki/projects/ca-skeleton-operational-contract.md` 는 wiki 파일로 존재하지 않음(그 슬러그는 `raw/project-notes/`). 실제 CI canonical = [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]] (§CI `documented-only`, line 41~47). 권고: 추출 대상 정합.
|
||||
- **NO_CI_WORKFLOW** (ground truth, non-blocking): ca-tmpl 에 `.github/workflows/` 부재 → 본 branch 의 모든 gate 는 `documented-only`/`planned` 단계. 노트 self-report(§Cluster, parent §CI documented-only)와 일치 — `actually-implemented` 과장 없음. drift 아님, 현황 기록.
|
||||
|
||||
## 완료 후 wiki 추출 대상
|
||||
|
||||
- `wiki/projects/ca-skeleton-operational-contract.md`의 CI quality gate canonical section. (⚠️ 경로 drift — 실제 canonical 은 [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]], §Audit `EXTRACTION_TARGET_DRIFT` 참조.)
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 2026-06-20: 구현 중 회피한 함정 3종 — (1) `if: success()` fan-in aggregator 는 상위 실패 시 *skipped*(차단 아님) → `always()`+result 스캔으로 전환; (2) `/docs` 전체 gitignore → CI-read 신규 파일을 tracked 경로로(레지스트리 = repo 루트, gate-matrix = `.github/`); (3) Gradle 빈 tag 버킷 Test 실패 → `failOnNoDiscoveredTests=false`. 상세: [[raw/errors/ci-fan-in-skipped-not-failed-and-gitignored-config-2026-06-20]].
|
||||
- 2026-06-20 (CI 실관측 + 사용자 결정): 사용자가 워크플로를 실제 CI 러너에서 돌려 `verifyEnvKeys: missing docs/registries/env-keys.yaml` → `BUILD FAILED` 확인. 핵심 트레이드오프 부상 — registry 게이트를 "부재 시 skip" 으로 완화하면 CI green 이지만 **CI 에서 vacuous**(계약 미강제) → 본 branch 목표("계약을 CI 에서 강제")와 정면 충돌. docs 읽는 contract 테스트 18/21 이 이미 skip-tolerant, verifyEnvKeys 만 throw outlier 임을 확인. 사용자에게 옵션 제시 → **Option 1: registries 커밋** 채택. `.gitignore` 를 `/docs/*` + `!/docs/registries/` 로 좁혀 운영 레지스트리 7개만 추적(나머지 `/docs` 는 private 유지). `verifyEnvKeys: OK — 99 env keys / 74 required placeholders / 84 APP_ keys` 재확인. 게이트가 fresh checkout 에서 실제 강제됨 = `locally-verified`(CI 재실행 `needs-confirmation`).
|
||||
- 2026-06-20 (CI 3차 — **quarantine 메커니즘 첫 실사용**): full `check` 에서 `PrivacySettingsTest.blankSalt_warnsAndFallsBackToDevSentinel(CapturedOutput)` 1건만 실패(487 tests, 1 failed) → release-gate 가 다시 정확히 차단(`quality-gates: failure` → `::error::release-gate`), **Claim C1 재실증**. 원인(증거): `CapturedOutput` 이 JVM-전역 async logback appender(`logback-spring.xml:23` `ASYNC_ENABLED` defaultValue=**true** → `:126` `MetricsAsyncAppender` on root)와 race — sibling `@SpringBootTest`(ActuatorSecurityHttpTest 등 4종)가 그 async appender 를 설치하면, 경량 `ApplicationContextRunner` 테스트의 `log.warn` 이 worker 스레드에서 `output.getOut()` 읽은 *뒤* flush → 단언 실패. 순서/타이밍 의존(로컬 단독·full 모두 통과 = 이기는 순서, CI = 지는 순서). 처리: 사용자 결정 **격리(quarantine)** — flaky 한 `blankSalt` 메서드에만 `@Tag("quarantine")`(realSalt 는 경고 미발생이라 async 무관, 제외) + `flaky-quarantine.yaml` 등록(reason + tracking_issue(TODO, 머지 전 실 Gitea 이슈로 교체) + `quarantined_since: 2026-06-20`, sunset 2026-07-04). 검증: `verifyQuarantineSunset: OK — 1 registered, 1 tagged`, drift guard simple-name suffix 매칭(`build.gradle:670`) 정합, `:app-bootstrap:test` 전체 BUILD SUCCESSFUL(flaky 제외), `quarantineTest` 가 1건 비차단 실행(`tests=1 failures=0`), `check verifyPublicPathSnapshot` BUILD SUCCESSFUL. **잠복 위험**: 같은 모듈 `LoggingSettingsTest`(badTimezone/badAsyncQueueSize `warnsAndFallsBack`)도 동일 CapturedOutput+async race 패턴 — 이번엔 미발생이나 다른 순서에서 재현 가능. 근본수정(sunset 내 owner 몫): `logback-test.xml` 로 test 시 async 비활성, 또는 `ListAppender` 직접 단언으로 stdout race 제거 — 한 번에 이 클래스 전체 flake 해소.
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/ci-flaky-test-quarantine-spotify-google]]
|
||||
- [[raw/official-docs/ci-github-actions-vs-gitlab-comparison]]
|
||||
- [[raw/official-docs/ci-openapi-snapshot-diff-tooling]]
|
||||
- [[raw/official-docs/dx-mise-asdf-tool-versioning]]
|
||||
- [[raw/official-docs/dx-testcontainers-java-best-practices]]
|
||||
- [[raw/official-docs/supply-chain-cosign-keyless-sigstore]]
|
||||
- [[raw/official-docs/supply-chain-slsa-provenance-framework]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: interviews:start -->
|
||||
- [[raw/interviews/ci-release-gate-fan-in-blocking-2026-06-20]]
|
||||
<!-- GENERATED: interviews:end -->
|
||||
|
||||
<!-- GENERATED: errors:start -->
|
||||
- [[raw/errors/ci-fan-in-skipped-not-failed-and-gitignored-config-2026-06-20]]
|
||||
<!-- GENERATED: errors:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/ci-gate-wiring-vs-policy-ownership-2026-06-20]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
> 2026-06-20 구현 단계에서 errors / blog-topics / interview-prep 파생 자료 누적.
|
||||
|
||||
### 오류 기록 (본 feature 작업 중 발생)
|
||||
|
||||
- [[raw/errors/ci-fan-in-skipped-not-failed-and-gitignored-config-2026-06-20]] — fan-in skip 함정 + gitignored 설정 CI 의존 + 빈 tag 버킷.
|
||||
|
||||
### Blog topics (이 작업에서 나온 글감)
|
||||
|
||||
- [[raw/blog-topics/ci-gate-wiring-vs-policy-ownership-2026-06-20]] — gate wiring vs policy 소유권 분리 + quarantine sunset 강제.
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- [[raw/interviews/ci-release-gate-fan-in-blocking-2026-06-20]] — fan-in 으로 release 차단을 *보장* 하는 법.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
> 이 브랜치를 작업한 날짜들. 양방향 nav 유지.
|
||||
|
||||
- 2026-06-20 — gate wiring 구현(워크플로 + gate-matrix + 크로스체크 + flaky quarantine sunset + escape-hatch 거버넌스). documented-only → actually-implemented(`locally-verified`; CI 실행 `needs-confirmation`).
|
||||
- 2026-06-20 (후속) — CI 실관측으로 verifyEnvKeys 실패 → docs/registries 커밋(gitignore 좁힘, 사용자 Option 1)으로 registry 게이트 CI 강제 회복. PR 템플릿 한국어화.
|
||||
- 2026-06-20 (CI 속도 최적화 — 사용자 결정 "gradle 잡 통합"): CI wall-clock ~10분+ 원인 = 게이트별 잡 분리로 단일 self-hosted 러너가 잡마다 checkout+setup-java+Gradle캐시+재컴파일 반복(특히 `quality-gates`의 `check` 5m14s 외에 openapi-drift/sample-removal/security-snapshot이 check가 *이미 실행하는* 테스트를 재실행, optional-adapter-matrix는 테스트 1개에 app-bootstrap 테스트를 4× 재컴파일). 해결: gradle 잡 4개 제거하고 `./gradlew check verifyPublicPathSnapshot` 단일 invocation으로 통합(9잡→5잡, gradle 잡 6→2). 게이트 강도 불변(check가 전 테스트 실행, gate-matrix-lint가 매트릭스↔코드 정합 유지). 매트릭스의 optional-adapter 행 mechanism을 workflow-job→contract-test(OptionalAdapterConditionalExecutionContractTest, check 내 실행)로 정합. gate-matrix-lint PASS(20=16+4) 유지.
|
||||
- 2026-06-20 (CI 2차 — 게이트 배선 검증 성공 + 2차 수정): release-gate fan-in 이 `quality-gates: failure` + `breaking-change-approval: failure` 를 정확히 감지·차단(`::error::release-gate: ... failed`) → **Claim C1 실증 완료**. 두 실패 모두 원인 규명·수정: (1) registries 만 커밋해 `docs/runbooks/` 부재 → Runbook/BackgroundJobErrorCode 계약 5건이 skip→fail(runbook 파일 dangling). `mv docs/runbooks` 로 로컬 재현 후 gitignore 에 `!/docs/runbooks/` 추가(45개 runbook 추적). (2) breaking-change governed 정규식에 `ci-gate-matrix.yml`(config)을 과포함 → 매트릭스 생성 PR 이 라벨 강요당함. governed 를 OpenAPI 스냅샷·`*.approved.*` 로 한정(config 는 CODEOWNERS+lint 로 보호). checkstyleTest ERROR 대량은 비차단 노이즈(static-analysis branch 소유, `ignoreFailures=true`) — 본 branch 실패 원인 아님.
|
||||
- 2026-06-20 (CI 3차 — quarantine 첫 실사용): full `check` 에서 `PrivacySettingsTest.blankSalt`(CapturedOutput) 1건 flaky 실패 → release-gate 재차단(Claim C1 재실증). 원인 = async logback(`logback-spring.xml` ASYNC_ENABLED 기본 true) + sibling `@SpringBootTest` 가 설치한 JVM-전역 appender 와의 stdout race. 사용자 결정 **격리**: `blankSalt` 메서드만 `@Tag("quarantine")` + `flaky-quarantine.yaml` 등록(14d sunset). `verifyQuarantineSunset OK(1/1)`, `quarantineTest` 1건 비차단 실행, `check verifyPublicPathSnapshot` BUILD SUCCESSFUL. 잠복: `LoggingSettingsTest` 동일 패턴. 근본수정(owner): `logback-test.xml` async-off 또는 ListAppender 단언. 상세 → [[raw/errors/ci-fan-in-skipped-not-failed-and-gitignored-config-2026-06-20]] (Trap 4 추가).
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-container-runtime-contract.md
|
||||
@@ -0,0 +1,444 @@
|
||||
---
|
||||
title: branch / feature-container-runtime-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-container-runtime-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [wiki/projects/ca-tmpl/runtime-container-health-migration]
|
||||
tags: [branch, ca-skeleton, container, runtime, jvm]
|
||||
created: 2026-05-22
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-030
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-030
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CONTAINER-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: 4ea821a22a546fda0f4407358f63672198be276985d938779551dfde818ab5e8
|
||||
---
|
||||
|
||||
# branch: feature-container-runtime-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — JVM application이 container 환경에서 예측 가능하게 동작하기 위한 runtime 기준을 정의합니다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (governing: [[wiki/projects/ca-tmpl/runtime-container-health-migration]] §Container 슬라이스) 의 결정/근거/금지 사항을 정제한다.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: non-root·memory·health container contract test가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CONTAINER-001@1` | Temurin JRE slim이 default이며 distroless는 debug/runbook 보강 후 허용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
같은 Spring application이라도 container memory, timezone, signal, filesystem, healthcheck 기준이 없으면 서버별로 다르게 실패합니다. 이 branch는 skeleton의 runtime contract를 고정합니다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- JVM memory/container limit 기준.
|
||||
- timezone/locale 기준.
|
||||
- graceful shutdown signal 기준.
|
||||
- healthcheck command 기준.
|
||||
- writable filesystem 최소화 기준.
|
||||
- temp directory/resource exhaustion 기준.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- Kubernetes manifest 작성.
|
||||
- Helm chart 작성.
|
||||
- cloud-specific autoscaling.
|
||||
- health probe **endpoint shape / group membership** (→ [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] owner; 본 branch 는 manifest-side probe **timing field** 만).
|
||||
- app-side graceful shutdown **ordering invariant** (→ sibling D4 owner; 본 branch 는 manifest-side budget 값 owner).
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/container-distroless-google-github]] | 보안 surface 축소 vs in-container 디버깅 손실 |
|
||||
| [[raw/official-docs/container-alpine-java-musl-tradeoffs]] | image 크기 작음 vs native lib/DNS resolver 호환성 risk |
|
||||
| [[raw/official-docs/container-graalvm-native-image-spring-boot]] | cold start/메모리 우위 vs reflection 메타데이터 + 빌드 시간 + peak throughput 손실 |
|
||||
| [[raw/company-tech-blogs/container-woowahan-spring-native-tradeoffs]] | 우아한형제들 Spring Native 도입기, hybrid 채택 결론 |
|
||||
| [[raw/official-docs/k8s-application-security-checklist-readonly-fs]] | D2 — Kubernetes 공식 checklist 가 `readOnlyRootFilesystem: true` 를 "most applications" 에 적용되는 base security hardening 항목으로 명시 (K8S-ASC-C1, K8S-ASC-C2) |
|
||||
| [[raw/official-docs/k8s-pod-security-standards-restricted]] | D2 — Restricted profile 이 emptyDir 을 허용 볼륨으로 명시 (K8S-PSS-C2); readOnlyRootFilesystem 이 현행 Restricted admission field 목록에 없음 확인 (K8S-PSS-C3) |
|
||||
| [[raw/official-docs/redhat-openjdk-container-awareness-java17]] | D4 — `-XX:MaxRAMPercentage=75` rationale: MaxRAMPercentage 기본값 25%, cgroup v1/v2 지원 JDK 버전 경계, container limit → GC/heap/thread-pool ergonomics 영향 (RHAT-JCONT-C1~C4) |
|
||||
| [[raw/official-docs/openjdk-jdk-8196595-container-support]] | D4 — `UseContainerSupport` 기본 활성(default true) + `-XX:{Initial,Max,Min}RAMPercentage` 플래그가 container/system 메모리 대비 비율로 Java heap 크기를 제어함을 Oracle 공식 JDK 문서로 증명 (JDK-8196595-C1~C5) |
|
||||
| [[raw/official-docs/kubernetes-pod-lifecycle-termination]] | D5 — terminationGracePeriodSeconds(default 30s) + preStop → SIGTERM → grace 만료 시 SIGKILL 순서 (K8S-POD-LC-C1~C5) |
|
||||
| [[raw/official-docs/spring-boot-graceful-shutdown-reference]] | D5 — Spring graceful shutdown 기본 활성 + 기존 요청 완료/신규 거부 + `spring.lifecycle.timeout-per-shutdown-phase` (SB-GS-C1~C5) |
|
||||
| [[raw/official-docs/config-12-factor-app-config]] | D1 — config 는 deploy 마다 가변·code 는 불변(deploy 간 가변성 분리) + config 는 env vars 에 저장하는 12-factor Factor III *원칙* (TWELVE-FACTOR-CONFIG-C1, C2) |
|
||||
| [[raw/official-docs/container-stdout-logging-12factor-official]] | D1 — 실행 환경(=deployment manifest)이 runtime 관심사를 소유하고 앱은 설정 불가하다는 12-factor Logs(XI) 책임 분리 *원칙* 보강 (LOG-12F-C4) |
|
||||
| [[raw/company-tech-blogs/runtime-health-datadog-engineering-graceful-shutdown]] | D5 — preStop 5s + drain + grace 비율 권장치 (RH-DD-C1~C4, `needs-confirmation` 강도) |
|
||||
|
||||
## 외부 근거 / 대안 조사 (2026-05-22 — Group G-D: Container Runtime)
|
||||
|
||||
본 branch의 Temurin JRE slim + `-XX:MaxRAMPercentage=75` + UTC/UTF-8 + graceful shutdown(app 20s + preStop 5s + grace 35s) 결정에 대한 외부 source 조사. 비교 분석은 (예정) `wiki/concepts/runtime-container-health-migration.md` 참조.
|
||||
|
||||
- **채택 결정 (Temurin JRE slim baseline)**:
|
||||
- (Spring Boot 3 JVM 기본 정책 정합)
|
||||
- **검토한 대안**:
|
||||
- **대안 1: Distroless (Google)** — [[raw/official-docs/container-distroless-google-github]] (보안 surface 축소 vs in-container 디버깅 손실)
|
||||
- **대안 2: Alpine + musl libc** — [[raw/official-docs/container-alpine-java-musl-tradeoffs]] (image 크기 작음 vs native lib/DNS resolver 호환성 risk)
|
||||
- **대안 3: GraalVM Native Image + Spring Boot Native** — [[raw/official-docs/container-graalvm-native-image-spring-boot]] (cold start/메모리 우위 vs reflection 메타데이터 + 빌드 시간 + peak throughput 손실)
|
||||
- **사례**: [[raw/company-tech-blogs/container-woowahan-spring-native-tradeoffs]] — 우아한형제들 Spring Native 도입기, hybrid 채택 결론
|
||||
- **비교 핵심**: Temurin JRE slim은 운영 친숙도/디버깅 우선. Distroless는 보안↑/디버깅↓. GraalVM native-image는 startup·메모리 우위지만 reflection 비용 + peak throughput 손실 — 우아한형제들 사례도 hybrid 채택. ca-tmpl baseline은 skeleton 단계에 적합, native-image는 cold start 민감 service 진입점.
|
||||
- **2026-06-14 보강 (D2/D4 자동조사 — `/branch-spec`)**:
|
||||
- **D2 (read-only root fs)**: K8s 공식 Application Security Checklist + NSA/CISA Hardening Guide 가 read-only root fs + tmpfs/emptyDir writable mount 패턴을 권고. 단 PSS Restricted admission 은 `readOnlyRootFilesystem` 을 자동 강제하지 않음(K8S-PSS-C3) → 명시 securityContext 또는 별도 policy engine 필요. 대안: writable root fs(레거시 path 조사 임시), 완전 read-only no-mount(non-JVM static binary 한정 — JVM 은 startup write 로 broken).
|
||||
- **D4 (container-aware JVM)**: `MaxRAMPercentage` 비율(cgroup limit 추적) vs 절대 `-Xmx`(고정·재조정 필요) vs JVM 기본 25%(Spring Boot 단일 프로세스 과소배정 — 금지). 75% 는 vendor 범위(Red Hat 50→80%) 안의 팀 관행. locale 은 `C.UTF-8` 권고(Debian slim 내장).
|
||||
|
||||
## TODO
|
||||
|
||||
> TODO drained — 결정은 error.category enum 표 / MDC Key Standard 표 / error.details JSON shape 참조
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- 2026-06-14 (`/branch-spec`): D2(read-only root fs)·D4(container-aware JVM) 외부 근거 자동조사 + 아카이브(K8s checklist/PSS, OpenJDK, Red Hat). D1(12-factor)·D5(K8s pod lifecycle + Spring graceful shutdown) 기존 raw source wire. `## 구현 가이드`·`## 엣지·실패·의존`·`## Audit & Findings` 신설. ca-tmpl ground-truth 대조에서 발견한 drift(§Audit) 는 사용자 결정 영역이라 자동 rewrite 하지 않고 권고만.
|
||||
- 2026-06-15 (ca-implementer): **구현 완료 (locally-verified)** — `src/Dockerfile` (multi-stage, Temurin JRE jammy, non-root app user, JAVA_TOOL_OPTIONS 전체 셋, C.UTF-8, EXPOSE 8080/9001), `src/.dockerignore` (신규 생성), `docker-compose.yml` (read_only+tmpfs+mem_limit 512m+stop_grace_period 35s), `docker-compose.dev.yml`, `docker-compose.local.yml` 작성 완료. `OperationalError.JVM_OOM` (INTERNAL/500/false) 신규 추가 — `actually-implemented`. `ContainerRuntimeOomContractTest` (app-bootstrap) 신규 — 소프트 runbook 파일 체크 패턴. `./gradlew :shared-contract:test` + `./gradlew :app-bootstrap:test` PASS. **SHUTDOWN_BUDGET_DRIFT 주의**: compose `stop_grace_period=35s`, `APP_SERVER_SHUTDOWN_TIMEOUT=20s` 를 canonical 값으로 사용; src/.env 의 30s 값(env-driven-config 브랜치 소유)과 drift 존재 — compose 파일에 주석으로 명시. LOCALE_DRIFT 해소: C.UTF-8 로 구현. Dockerfile HEALTHCHECK 교차 기능 커플링(actuator 브랜치 소유) — 주석으로 명시.
|
||||
- 2026-06-15 (`/verify`): docker 라이브 표면 검증 — 이미지 빌드 + JVM ergonomics(cgroup heap 추적) + non-root + C.UTF-8/UTC + read-only fs/tmpfs 모두 PASS(§Audit DOCKERFILE_IMPLEMENTED). 2건 보정: ① JVM_OOM 런타임 emit 은 앱이 하지 않고 observability 로 위임(문구 정합, §구현 가이드 §5 + §Audit OOM_LOG_LOSS), ② `$HOME` read-only fs write 위험 발견 → Dockerfile `ENV HOME=/tmp` 추가.
|
||||
- 2026-06-15 (ca-quality-reviewer advisory fixes): **2건 보안·신뢰성 픽스 적용**. ① `docker-compose.local.yml` 의 `${POSTGRES_PASSWORD:-changeme}` 2곳(`db` 서비스 + `app` 서비스 datasource) 을 required-variable form `${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your local .env — no default provided}` 로 교체(no-default-ships) — AGENTS.md 하드코딩 비밀 금지 준수. ② `src/Dockerfile` 의존성 warm-up 라인 `./gradlew dependencies ... 2>/dev/null || true` → `2>/dev/null || true` 제거(fail-fast) — CI network-restricted 환경에서 dependency resolution 실패를 조용히 삼키지 않도록 수정. `./gradlew :shared-contract:test :app-bootstrap:test --tests '*ContainerRuntimeOomContractTest'` PASS 확인.
|
||||
|
||||
## Work Item Contract
|
||||
|
||||
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
|
||||
|
||||
| field | required | rule |
|
||||
| --- | --- | --- |
|
||||
| Decision | yes | 구현자가 선택해야 하는 기본값 |
|
||||
| Allowed | yes | 허용되는 예외와 조건 |
|
||||
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
|
||||
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
|
||||
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
|
||||
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
|
||||
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-05-22: runtime 기준은 application code와 deployment manifest 사이의 계약으로 둠.
|
||||
- 2026-05-22: prod container는 writable path를 최소화하고 temp directory를 명시해야 함.
|
||||
- 2026-05-22: base image 기본값은 Temurin JRE slim. distroless는 debug/runbook 보강 후 허용.
|
||||
- 2026-05-22: JVM 기본값은 `-XX:MaxRAMPercentage=75`, timezone UTC, locale `en_US.UTF-8`.
|
||||
- 2026-05-22: deployment manifest sync는 이 branch가 owner이며 `terminationGracePeriodSeconds`, `preStop`, app shutdown timeout, health probes를 한 표로 관리.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. `Decision ID` 는 이 branch-note 안에서 안정적으로 유지.
|
||||
> `선택 조건` 열(R2): "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 분기 없으면 `N/A`.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | runtime 기준은 application code 와 deployment manifest 사이의 계약 | runtime tunable(memory/timezone/shutdown/probe)이 환경마다 달라질 수 있으면 → 이미지가 아니라 manifest/env 로 외부화. 빌드 시 고정 + 환경 불변 값이면 → 이미지에 baked 허용(예외) | `raw/official-docs/config-12-factor-app-config.md#TWELVE-FACTOR-CONFIG-C1` (config 는 deploy 마다 가변, code 는 불변 — deploy 간 가변성 분리), `#TWELVE-FACTOR-CONFIG-C2` (config 를 env vars 에 저장); `raw/official-docs/container-stdout-logging-12factor-official.md#LOG-12F-C4` (실행 환경이 runtime 관심사를 완전 관리, 앱 설정 불가 — 동일 방법론 보강) | `official-reference` (12-factor Config/Logs 책임 분리 *원칙*) + `team-policy` (구체적 owner 분배) | 12-factor 는 config↔code, app↔실행환경 분리 *원칙* 만 지지 — "이 branch 가 manifest sync owner" 라는 구체적 책임 분배는 외부 표준 부재(team-policy) |
|
||||
| D2 | prod container 는 writable path 최소화 + read-only root filesystem 의무화 + temp directory 명시 | prod K8s JVM 컨테이너 → read-only root fs + tmpfs/emptyDir writable mount. 레거시 앱이 다수 path 에 write 하고 path 매핑 미완료면 → writable root fs 임시(배포 전 path 조사 단계, prod 금지). non-JVM static binary 면 → no-mount 완전 read-only 가능(JVM 은 startup write 로 broken) | `raw/official-docs/k8s-application-security-checklist-readonly-fs.md#K8S-ASC-C1` (readOnlyRootFilesystem: true 명시적 권고), `#K8S-ASC-C2` (base security hardening — most applications), `raw/official-docs/k8s-pod-security-standards-restricted.md#K8S-PSS-C2` (emptyDir = Restricted 허용 볼륨) | `official-vendor-doc` | `K8S-PSS-C3`: readOnlyRootFilesystem 은 PSS Restricted admission 이 *자동 강제하지 않음* — securityContext 명시 또는 별도 policy engine 필요. ca-tmpl 의 실제 write-path 전부 emptyDir/tmpfs redirect 됨은 구현 검증 필요(Claims To Verify) |
|
||||
| D3 | base image default = Temurin JRE slim, distroless 는 debug runbook 보강 후 허용 | 운영/디버깅 친숙도 우선 → Temurin JRE slim. 보안 surface 최소화 + 디버깅 runbook 보강 완료 → distroless. cold-start/메모리 민감 + reflection 적은 service → GraalVM native 검토 | `raw/official-docs/container-distroless-google-github.md#CDG-C1` (distroless = app + runtime only, no shell), `#CDG-C5` (`:debug` variant 는 busybox shell 제공) | `official-vendor-doc` (Google distroless 의 공식 trade-off) | Google 의 `CDG-C2` "best practice" 는 self-claim — industry-wide consensus 아님 |
|
||||
| D4 | JVM 기본값 = `-XX:MaxRAMPercentage=75`, timezone UTC, locale en_US.UTF-8 | container memory limit 이 환경마다 다르거나 변동 → MaxRAMPercentage(비율, cgroup 추적). 메모리 프로파일 고정 + 절대값 고정 규정 → `-Xmx`. (무설정 기본 25% 는 Spring Boot 단일 프로세스 과소배정 → 금지) | `raw/official-docs/openjdk-jdk-8196595-container-support.md#JDK-8196595-C1` (UseContainerSupport 기본 활성), `#JDK-8196595-C3` (MaxRAMPercentage = heap 최대 % of memory, 기본 25%), `raw/official-docs/redhat-openjdk-container-awareness-java17.md#RHAT-JCONT-C4` (container limit → GC/heap/thread-pool ergonomics) | `official-vendor-doc` (C1+C3) + `team-convention` (75% 수치) | 75% 를 official best practice 로 표현 금지 — vendor 범위 70~80% 내 팀 관행. **locale `en_US.UTF-8` → `C.UTF-8` 수정 권고** (§Audit LOCALE_DRIFT) |
|
||||
| D5 | deployment manifest sync owner = 본 branch (terminationGracePeriodSeconds, preStop, app shutdown timeout, health probe timing 일원화) | app-side(Spring graceful) 와 manifest-side(K8s grace/preStop) 가 양쪽에 걸칠 때 → 한 표로 일원화 owner 필요. 단일 비-K8s 배포면 manifest sync 표 불필요(예외) | `raw/official-docs/kubernetes-pod-lifecycle-termination.md#K8S-POD-LC-C1` (terminationGracePeriodSeconds default 30s + preStop→SIGTERM), `#K8S-POD-LC-C3` (kubelet→SIGTERM to PID1), `#K8S-POD-LC-C2` (grace 만료 시 SIGKILL), `raw/official-docs/spring-boot-graceful-shutdown-reference.md#SB-GS-C3` (기존 요청 완료/신규 거부), `#SB-GS-C4` (timeout-per-shutdown-phase) | `official-vendor-doc` (K8s + Spring 공식) | budget 수치(35s/20s/5s)는 Datadog 사례(`RH-DD-C2` `needs-confirmation`) 기반 권장치 — 실측 없음. **env-key `APP_SERVER_SHUTDOWN_TIMEOUT` default 30s 와 본 표 20s drift** (§Audit SHUTDOWN_BUDGET_DRIFT) |
|
||||
|
||||
> Note: Alpine + musl 대안의 risk 는 `raw/official-docs/container-alpine-java-musl-tradeoffs.md#CAJM-C1`~`CAJM-C5` 가 직접 지지하며, ca-tmpl Temurin JRE slim 채택의 negative-evidence 역할. Distroless 의 image size 이점 (`CDG-C4`) 은 `static-debian13` 기준이며 Java distroless variant 는 더 큼 — 본 branch 의 baseline 비교 시 주의. `container-woowahan-spring-native-tradeoffs` 는 `company-tech-blog` 카테고리이므로 GraalVM hybrid 결론은 `company-case-study` 강도만 가지며 official best practice 로 표현 금지.
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> *결정 (Decisions)* 이 "*무엇*" 이라면, 본 §는 "*어디에 어떻게*" 의 사전 명세. 3-rule(CLAUDE.md §15.5): R1 각 cell 은 Decision ID + Supporting Claim reference, R2 근거 없는 detail 은 `UNSUPPORTED_IMPL_DECISION` + trade-off 한 줄, R3 본 branch 범위 밖 detail 은 위임(§Audit).
|
||||
> **코드 상태 주의**: ca-tmpl `src/Dockerfile` 은 빈 파일 → 본 § 의 base image/JVM/securityContext detail 은 전부 `planned`. `server.shutdown`/`timeout-per-shutdown-phase` config 만 `actually-implemented`(env-key 배선).
|
||||
|
||||
### 1. Base image + Dockerfile (Trace: D3 · CDG-C1/C5 · CAJM-C1~C5)
|
||||
|
||||
> **Trace**: D3. Temurin JRE slim 채택 = distroless/alpine-musl/GraalVM 대안 검토 후 baseline.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: multi-stage 구조, JRE 버전 핀(예: `eclipse-temurin:21-jre-jammy`), non-root UID 값은 무출처 팀 선택 — Dockerfile 미작성이라 전부 `planned`.
|
||||
|
||||
| 항목 | 명세 | 상태 | Anchor |
|
||||
|---|---|---|---|
|
||||
| base image | Temurin JRE slim (distroless = debug runbook 보강 후 허용) | `planned` (src/Dockerfile empty) | D3 / CDG-C1 |
|
||||
| USER | non-root (K8S-ASC-C3: privileged:false + drop ALL caps 와 정합) | `planned` | K8S-ASC-C3 |
|
||||
| forbidden | prod 에서 root full JDK image | — | D3 |
|
||||
|
||||
### 2. JVM ergonomics + locale (Trace: D4 · JDK-8196595-C1/C3 · RHAT-JCONT-C1/C4)
|
||||
|
||||
> **Trace**: D4. UseContainerSupport(default-on) + MaxRAMPercentage(cgroup 비율) 채택.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `75%` 수치는 vendor 범위(70~80%) 내 팀 관행 — non-heap(metaspace/code cache/thread stacks/direct buffer, RHAT-JCONT-C4)이 25% 안이라는 가정. `HeapDumpPath` naming `<pod>-<ts>` 패턴, `emptyDir.sizeLimit`(heap dump 누적 eviction 방지) 값 미정.
|
||||
|
||||
```
|
||||
-XX:MaxRAMPercentage=75
|
||||
-XX:+UseContainerSupport # JDK 10+ default (JDK-8196595-C1), 명시 권장
|
||||
-XX:HeapDumpPath=/var/tmp/heap/<pod>-<ts>.hprof # emptyDir mount 의무 (§3)
|
||||
-XX:+ExitOnOutOfMemoryError # → §5 OOM_LOG_LOSS 주의
|
||||
TZ=UTC
|
||||
LANG=C.UTF-8 # ⚠️ 현행 결정문은 en_US.UTF-8 — §Audit LOCALE_DRIFT, C.UTF-8 권고
|
||||
```
|
||||
|
||||
- 컨테이너 memory limit **반드시 설정** — 미설정 시 MaxRAMPercentage 가 host RAM 기준(RHAT-JCONT-C4) → 과대/과소 할당.
|
||||
|
||||
### 3. Filesystem policy: read-only root fs + writable mounts (Trace: D2 · K8S-ASC-C1 · K8S-PSS-C2/C3)
|
||||
|
||||
> **Trace**: D2. read-only root fs + 필수 경로만 tmpfs/emptyDir.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 경로별 `emptyDir` vs `tmpfs(medium: Memory)` 선택은 trade-off(tmpfs=pod 메모리 소비/≈0 latency vs emptyDir=node disk I/O). `server.tomcat.basedir=/tmp` redirect 는 D2 도출 *필수 수반결정*(미설정 시 read-only root fs 에서 Tomcat work dir write fail → startup CrashLoop — D2 자동조사 finding).
|
||||
> - **OUT_OF_BRANCH_SCOPE**: PSS Restricted admission *enforcement 설정* 자체(policy engine 배선)는 security baseline branch 영역 — 본 branch 는 securityContext 필드 값만.
|
||||
|
||||
| 항목 | 명세 | 상태 | Anchor |
|
||||
|---|---|---|---|
|
||||
| root fs | `securityContext.readOnlyRootFilesystem: true` | `planned` | K8S-ASC-C1 |
|
||||
| heap dump path | `/var/tmp/heap` emptyDir mount | `planned` | K8S-PSS-C2 (emptyDir 허용) |
|
||||
| temp/upload | `/tmp` tmpfs mount | `planned` | K8S-PSS-C2 |
|
||||
| Tomcat work dir | `server.tomcat.basedir=/tmp` (또는 `java.io.tmpdir=/tmp`) | `planned` (수반결정) | D2 자동조사 finding |
|
||||
| enforcement 주의 | readOnlyRootFilesystem 은 PSS Restricted 가 자동 강제 *안 함* → 명시 securityContext 필수 | — | K8S-PSS-C3 |
|
||||
|
||||
### 4. Deployment manifest sync (Trace: D5 · K8S-POD-LC-C1/C2/C3 · SB-GS-C3/C4)
|
||||
|
||||
> **Trace**: D5. app-side(Spring) ↔ manifest-side(K8s) timeout/probe 일원화. 본 branch = manifest-side 값 owner.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 35s/20s/5s/10s 조합은 Datadog 사례(`RH-DD-C2` `needs-confirmation`) 기반 ca-tmpl 운영 가정 — 실측 없음.
|
||||
> - **OUT_OF_BRANCH_SCOPE**: shutdown *ordering invariant* (SIGTERM→readiness DOWN→drain→exit) 는 [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] D4(app-side) owner. `APP_SERVER_SHUTDOWN_TIMEOUT` env-key *값/validation* 은 [[raw/branch-notes/feature-env-driven-runtime-configuration]] owner — 본 branch 는 그 값을 consume.
|
||||
|
||||
| field | default | 위임/상태 | Anchor |
|
||||
| --- | --- | --- | --- |
|
||||
| app shutdown timeout | 20s (`server.shutdown=graceful` + `spring.lifecycle.timeout-per-shutdown-phase`) | config `actually-implemented`(env-key 배선); **값 drift** — env default 30s (§Audit) | SB-GS-C3/C4 / `app-bootstrap/.../application.yml:205,211` |
|
||||
| `preStop` hook sleep | 5s | 본 branch owner | K8S-POD-LC-C1 |
|
||||
| `terminationGracePeriodSeconds` | 35s | 본 branch owner | K8S-POD-LC-C1 (default 30s 를 override) |
|
||||
| safety margin | 10s (drain late completion 흡수) | 본 branch — env default 30s 적용 시 0 으로 붕괴(§Audit) | — |
|
||||
| readiness failure before drain | required | 위임 sibling(ordering) | K8S-POD-LC-C3 |
|
||||
| startup probe | required when migration/startup validation enabled | 위임 sibling(endpoint shape) | — |
|
||||
|
||||
> **Runtime Defaults 요약표** (위 표의 정책 한 줄 view):
|
||||
>
|
||||
> | item | default | allowed | forbidden |
|
||||
> | --- | --- | --- | --- |
|
||||
> | base image | Temurin JRE slim | distroless with debug runbook | root full JDK image in prod |
|
||||
> | JVM memory | `-XX:MaxRAMPercentage=75` | workload-specific override | container limit ignored |
|
||||
> | timezone | UTC | none | server default timezone |
|
||||
> | shutdown | SIGTERM -> readiness down -> drain -> exit | forced kill after grace | SIGKILL before app timeout |
|
||||
> | manifest sync | one table for app timeout/probes/preStop | platform-specific overlay | app/manifest timeout mismatch |
|
||||
|
||||
### 5. OOM 분류 + JVM_OOM error code (Trace: D4 · registry error-codes.yaml · K8S-POD-LC-C2)
|
||||
|
||||
> **Trace**: D4(ExitOnOutOfMemoryError) + registry `JVM_OOM`. **`JVM_OOM` 은 본 branch 가 registry owner** — `docs/registries/error-codes.yaml` L213: `code: JVM_OOM`, `category: INTERNAL`, `http_status: 500`, `retryable: false`, `owner_branch: feature-container-runtime-contract`, `owner_layer: infrastructure`, `runbook_link: runbook://runtime/jvm-oom`, `required_test: contract-verification:container-runtime-oom`. **enum/registry 분류 = `actually-implemented`** (`OperationalError.JVM_OOM` = INTERNAL/500/false + parity test, 2026-06-15 GREEN). **런타임 구조화 emit(`error.code=JVM_OOM` 로그)은 미배선 — 설계상 위임** (아래 결정 + §Audit OOM_LOG_LOSS).
|
||||
>
|
||||
> - **결정 (2026-06-15)**: JVM_OOM 구조화 로그는 앱이 emit 하지 않음 — `-XX:+ExitOnOutOfMemoryError` 가 OOM 즉시 abort 하여 앱 핸들러(shutdown hook/UncaughtExceptionHandler)로 안정 emit 불가. 런타임 구분 신호 = JVM 네이티브 OOM stderr + exit 137 + heap dump. 구조화 `error.code=JVM_OOM` 로그-기반 alert 는 observability 브랜치로 위임. enum 은 분류 SSOT 로만 유지.
|
||||
|
||||
- container exit 137 (SIGKILL) → OOMKilled (container OOM, kubelet 결정, K8S-POD-LC-C2).
|
||||
- JVM `OutOfMemoryError` → `-XX:+ExitOnOutOfMemoryError` 로 137 exit + `-XX:+HeapDumpOnOutOfMemoryError` 로 `/var/tmp/heap` 에 heap dump.
|
||||
- 두 케이스 모두 exit 137 → **구분 신호 = heap dump 유무 + JVM 네이티브 OOM stderr ("Terminating due to java.lang.OutOfMemoryError")**. kubelet OOMKill 은 둘 다 없음.
|
||||
- ⚠️ **OOM_LOG_LOSS (해소 — 위임 결정)**: 앱이 `error.code=JVM_OOM` 을 직접 emit 하지 않음(ExitOnOutOfMemoryError 가 핸들러보다 먼저 abort — 의도된 설계). 구조화 alert 는 observability log-pattern(네이티브 OOM msg + exit 137)으로 위임. enum 은 분류 코드로 유지. §Audit 참조.
|
||||
|
||||
### 6. (위임) Health probe endpoint standard — OUT_OF_BRANCH_SCOPE
|
||||
|
||||
> **endpoint shape / group membership 은 [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] §1 SSOT.** 아래는 manifest-side 참조용 mirror — 값 변경 시 sibling 이 authoritative. 본 branch 는 manifest 의 probe **timing field** 만 owns.
|
||||
|
||||
- liveness: `GET /actuator/health/liveness` (deadlock/메모리 한정 검사)
|
||||
- readiness: `GET /actuator/health/readiness` (dependency status)
|
||||
- startup: `GET /actuator/health/startup` (migration/validation 진행 중)
|
||||
- single-probe timeout: liveness 1s / readiness 2s / startup 30s.
|
||||
- startup probe total budget(failureThreshold × periodSeconds = 150s)는 [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] SSOT.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4(깊이 게이트) 캡처용. 정상 경로 외 *구현 중 부딪힐* 실패/엣지/타 계약 의존.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **read-only root fs + unmounted write path**: `/tmp` mount 누락 시 Spring Boot embedded Tomcat startup write → `Permission denied` → startup probe failureThreshold → CrashLoopBackOff. 포착: dev/staging 에서 `readOnlyRootFilesystem: true` + smoke test (Claims To Verify). 사전 식별: `strace -e trace=open,openat,creat` 로 write syscall 추적.
|
||||
- **shutdown budget > terminationGracePeriodSeconds**: grace 만료 시 SIGKILL → inflight 유실(K8S-POD-LC-C2). 본 표 app 20s + preStop 5s = 25s ≤ grace 35s 이나, **env default 30s 적용 시 30+5=35=grace → margin 0**(§Audit SHUTDOWN_BUDGET_DRIFT).
|
||||
- **ExitOnOutOfMemoryError 즉시 exit → JVM_OOM log flush 손실** 가능(audit 2026-05-25 #4.28).
|
||||
- **exit 137 모호성**: kubelet OOMKill(SIGKILL) vs JVM OOM(ExitOnOutOfMemoryError 137) 둘 다 137 → log `error.code=JVM_OOM` 유무로만 구분.
|
||||
- **MaxRAMPercentage non-heap spike**: metaspace/direct buffer 급증 → 75% heap + 25% non-heap 가정 초과 → cgroup limit 초과 → OOMKill(RHAT-JCONT-C4).
|
||||
- **container memory limit 미설정**: MaxRAMPercentage 가 host RAM 기준 → 과대/과소 할당.
|
||||
- **heap dump 누적**: `/var/tmp/heap` emptyDir 이 node ephemeral storage quota 초과 → pod eviction. `emptyDir.sizeLimit` 미설정 risk.
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-env-driven-runtime-configuration]] `D2` — `APP_SERVER_SHUTDOWN`(default graceful) / `APP_SERVER_SHUTDOWN_TIMEOUT`(default **30s**, validation `spring_duration_shorthand_le_termination_grace`) env-key consume. 본 branch 의 `terminationGracePeriodSeconds` 가 이 값 ≥ 여야 함.
|
||||
- [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] `D4`/`§4` — graceful shutdown *ordering invariant*(app-side) owner; `§6` 에서 `TZ=UTC` 를 본 branch 로 위임. 본 branch = manifest-side 값 + container env owner.
|
||||
- [[raw/branch-notes/feature-migration-startup-contract]] — startup probe budget(150s) 및 startup validation 은 그쪽 owner; 본 branch 는 manifest 의 startup probe *존재* 만.
|
||||
- registry `error-codes.yaml` `JVM_OOM` — **본 branch owner**(INTERNAL/500/retryable=false/owner_layer=infrastructure/required_test=contract-verification:container-runtime-oom).
|
||||
- registry `metrics.yaml` `jvm.memory.used`/`jvm.gc.pause` (owner `feature-metrics-alerting-contract`) — OOM/heap alert 연계(`heap used/max > 0.85 for 10m`).
|
||||
|
||||
## 테스트 계약
|
||||
|
||||
- inflight request 처리 검사: `server.shutdown=graceful` + `spring.lifecycle.timeout-per-shutdown-phase` property가 명시되어 있어야 함. 측정 방법: `./gradlew bootRun` 후 `curl localhost:8080/long-running` 호출 + SIGTERM 보내고 응답 도착 timeout < 25s 이내 verify. property 누락 또는 25s 초과 시 fail. (값 drift 주의 — §Audit SHUTDOWN_BUDGET_DRIFT)
|
||||
- timezone이 서버 default에 암묵 의존하면 실패.
|
||||
- temp cleanup 검사: `APP_FILE_UPLOAD_ENABLED=true`이면 다음 3가지 cleanup 메커니즘이 모두 활성: (a) try-with-resources via `MultipartFile.transferTo` cleanup (b) startup sweeper bean (`OrphanTempFileSweeper`) 등록 — `/var/tmp/upload/*` 1시간 초과 파일 삭제 (c) JVM shutdown hook. 측정 방법: 1h-old file을 `/var/tmp/upload/`에 두고 application 재시작 → 5분 이내 파일 삭제 verify. (주의: `OrphanTempFileSweeper` 는 ca-tmpl `src/` 에 미존재 → `planned`)
|
||||
- app shutdown timeout이 manifest termination grace보다 길면 실패.
|
||||
- OOM 분류 검사: container exit code 137(SIGKILL) → `OOMKilled` (container OOM, kubelet); JVM `OutOfMemoryError` → `-XX:+ExitOnOutOfMemoryError`로 137 exit + `/var/tmp/heap` heap dump. 측정 방법: `-Xmx16m`로 강제 JVM OOM 트리거 → exit code 137 + heap dump 파일 생성 verify (앱은 `error.code=JVM_OOM` 을 직접 emit 하지 않음 — 구조화 alert 는 observability log-pattern). required_test `contract-verification:container-runtime-oom` 은 enum↔registry parity 를 검증(2026-06-15 GREEN).
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다. 구현 전/중/후에 실제로 검증해야 하는 주장을 분리.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| Spring Boot graceful shutdown 이 20s 내 inflight request 처리 완료 | `server.shutdown=graceful` + `timeout-per-shutdown-phase` 의 실제 동작은 endpoint 로직에 따라 달라짐 | `./gradlew bootRun` + `curl /long-running` 호출 + SIGTERM → 응답 도착 timeout < 25s verify | `planned` |
|
||||
| `-XX:MaxRAMPercentage=75` 가 container memory limit 을 정확히 인식 | JDK 10+ `UseContainerSupport` 기본값이 모든 cgroup 환경에서 정상 동작한다는 직접 보장 부재 (cgroup v2 는 11.0.16+/17.0.4+/21 — RHAT-JCONT-C3) | container memory limit 변화 시 `Runtime.getRuntime().maxMemory()` 가 75% 로 변화 verify; `-Xlog:os+container=trace` 로 cgroup 인식 확인 | `needs-confirmation` |
|
||||
| JVM OOM → exit 137 + `/var/tmp/heap` heap dump 생성 (앱 구조화 emit 없음 — 설계상 위임) | ExitOnOutOfMemoryError 가 핸들러보다 먼저 abort → 앱 emit 불가; 구분은 heap dump + 네이티브 msg | `-Xmx16m` 강제 OOM → exit 137 + heap dump 파일 존재 verify (앱 부팅 필요 — 단독 미검증) | `planned` |
|
||||
| 컨테이너에 `C.UTF-8` locale 존재 + JVM `file.encoding=UTF-8` | Temurin JRE slim(Debian) 에 C.UTF-8 내장 여부 + en_US.UTF-8 은 locales 패키지 필요 — minimal image 에서 미존재 가능 | `docker run <img> locale` + `java -XshowSettings:properties 2>&1 \| grep file.encoding` verify | `planned` |
|
||||
| readiness failure → drain 순서가 SIGTERM 처리 시 자동 보장 | `preStop` sleep 5s + readiness probe cache delay 일치 보장 부재 | k8s 환경에서 SIGTERM 시 readiness false 전환 후 drain 시작 트레이스 verify | `planned` |
|
||||
| temp file cleanup (3 메커니즘) 이 모두 활성 + 누락 없음 | try-with-resources / startup sweeper / shutdown hook 중 하나만 누락되어도 leak (`OrphanTempFileSweeper` 미구현) | 1h-old file 을 `/var/tmp/upload/` 에 두고 재시작 → 5분 이내 삭제 verify | `planned` |
|
||||
| `$HOME`(/home/app) write 가 read-only root fs 에서 실패하지 않는다 | useradd --no-create-home + read-only fs → `java.util.prefs`(`~/.java/.userPrefs`) 등 `$HOME` write 라이브러리 실패 가능 (2026-06-15 docker 검증서 발견 → Dockerfile `ENV HOME=/tmp` 로 mitigate) | 앱 부팅 후 prefs/SDK 의 `$HOME`(=`/tmp` tmpfs) write 성공 + read-only-fs WARN 부재 verify | `planned` |
|
||||
| container exit 137 (SIGKILL by kubelet) 와 JVM OOM (137 by ExitOnOutOfMemoryError) 가 구분 가능 | 두 케이스 모두 exit 137 → **heap dump 유무 + JVM 네이티브 OOM msg** 로 구별(앱 구조화 로그 아님) | cgroup limit 초과(OOMKill, dump 없음) vs JVM heap 한계(dump 생성) 각각 분류 verify | `needs-confirmation` |
|
||||
| distroless 채택 시 in-container 진단 도구 부재 영향이 runbook 으로 완화 | `CDG-C5` 의 `:debug` variant 는 busybox shell 만, jcmd/jstack/heap dump 별도 | distroless prod pod 에서 ephemeral container/sidecar 로 heap dump 추출 PoC + runbook | `planned` |
|
||||
| Alpine + musl 채택 시 Testcontainers / native lib (snappy, zstd-jni 등) 정상 동작 | `CAJM-C4` 공식 경고 — musl 호환성 risk | alpine + Temurin musl 이미지에서 ca-tmpl integration test suite + native lib 호출 verify | `planned` |
|
||||
| read-only root fs 강제 시 모든 write-path 가 emptyDir/tmpfs 로 redirect | application 코드의 file write 가 누락된 path 에서 발생 가능; Tomcat basedir 미설정 위험 | k8s securityContext `readOnlyRootFilesystem: true` + smoke test 로 startup/runtime write 실패 catch | `planned` |
|
||||
| startup probe total budget (150s) 이 migration/validation 시간을 모두 커버 | DB migration 사이즈에 따라 150s 초과 가능 (budget owner = sibling) | 대용량 migration scenario 에서 startup probe success verify; 초과 시 fail | `planned` |
|
||||
|
||||
## Audit & Findings (ca-tmpl ground-truth 대조 2026-06-14, `/branch-spec`)
|
||||
|
||||
> 코드/registry/governing doc/sibling 대조에서 발견한 drift. **사용자 작성 결정 영역은 자동 rewrite 하지 않고 정합 권고만**(CLAUDE.md §11).
|
||||
|
||||
- **SHUTDOWN_BUDGET_DRIFT** (✅ 해소 2026-06-15 — option (a) 채택): 본 노트 app shutdown timeout=**20s** 이나 registry env-key `APP_SERVER_SHUTDOWN_TIMEOUT` default=**30s** (owner [[raw/branch-notes/feature-env-driven-runtime-configuration]] D2, 2026-06-05 — 본 노트 2026-05-22 이후 갱신). env default 30s 적용 시 preStop 5s + drain 30s = 35s = `terminationGracePeriodSeconds` → 본 노트의 10s safety margin 이 **0 으로 붕괴**. validation rule(`spring_duration_shorthand_le_termination_grace`)은 `≤` 만 강제하므로 통과하나 margin 의도 상실. 권고: (a) app budget 을 30s 로 정합하고 grace 를 40s 로 상향, 또는 (b) env default 를 20s 로 낮춤 — 둘 다 사용자/env-config branch 결정. **해소(2026-06-15, /ca-parallel 후속)**: 권고 **(a)** 채택 — env-keys.yaml 이 app shutdown *값*(30s)의 SSOT 이고 본 branch 는 *관계*(grace ≥ timeout+preStop+margin)의 owner 이므로, app drain 30s 를 보존한 채 `stop_grace_period` 를 **35s→40s**(30s+preStop 5s+margin 5s) 로 상향. `docker-compose.yml`/`docker-compose.local.yml` 의 fallback `:-20s`→`:-30s` 정합 + sync-table 주석/`stop_grace_period` 갱신. `.env`/env-keys 는 불변(30s). 설계상 정합; docker 런타임 스모크는 미검증.
|
||||
- **OOM_LOG_LOSS** (✅ 해소 — 위임 결정 2026-06-15): 검증 결과 앱 코드에 JVM_OOM emitter 없음(`grep` 확인 — enum/comment/test 만 참조). `-XX:+ExitOnOutOfMemoryError` 가 OOM 즉시 abort → 앱 핸들러로 구조화 emit 은 원천적으로 불안정. **결정: 앱은 emit 하지 않음.** 런타임 구분 = exit 137 + heap dump(`/var/tmp/heap`, HeapDumpOnOutOfMemoryError) + JVM 네이티브 OOM stderr. 구조화 `error.code=JVM_OOM` 로그-기반 alert 는 observability 브랜치(log-pattern)로 위임. enum/registry 는 분류 SSOT(parity test GREEN). §구현 가이드 §5 반영.
|
||||
- **LOCALE_DRIFT** (🟡 Should-fix): 본 노트 결정문 `LANG=en_US.UTF-8`; governing doc(`runtime-container-health-migration` §Container) + sibling [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] §6 = `LANG=C.UTF-8`. D4 자동조사(RHAT) 결론: C.UTF-8 이 Debian slim 내장(locales 패키지 불필요) → minimal image 정석. 권고: `C.UTF-8` 로 정합(§구현 가이드 §2 는 이미 C.UTF-8 + 주석 표기). 결정문은 사용자 영역이라 미수정.
|
||||
- **HEAP_DUMP_PATH_DRIFT** (⚪ Advisory): 본 노트 `-XX:HeapDumpPath=/var/tmp/heap/<pod>-<ts>.hprof`; runbook `internal-error-spike.md` L39 = `/var/tmp/heap/heapdump-<pid>.hprof`. 구현 시 단일 path 규약으로 정합 필요.
|
||||
- **PROBE_OWNERSHIP_DELEGATION** (정합 OK, 위임 명시): health probe endpoint shape/group membership 은 `feature-runtime-health-lifecycle-contract` §1 owner. 본 branch 는 manifest-side probe timing field 만. §구현 가이드 §6 에 위임 표기 완료(R3).
|
||||
- **DOCKERFILE_IMPLEMENTED** (사실 등급 — 2026-06-15 갱신): ca-tmpl `src/Dockerfile` 구현 완료 (`actually-implemented`). multi-stage(JDK builder → JRE slim runtime), non-root `app` user(uid 1000), `JAVA_TOOL_OPTIONS` 전체 셋(-XX:MaxRAMPercentage=75/-XX:+UseContainerSupport/-XX:+ExitOnOutOfMemoryError/-XX:+HeapDumpOnOutOfMemoryError/-XX:HeapDumpPath=/var/tmp/heap/-Dserver.tomcat.basedir=/tmp), `C.UTF-8` locale(LOCALE_DRIFT 해소), EXPOSE 8080/9001, `src/.dockerignore` 신규, compose 3개(base/dev/local) 모두 작성. Gradle test 검증 가능한 항목(JVM_OOM enum + parity test) locally-verified. **Docker build + 런타임 표면 검증 완료 (2026-06-15, docker 29.5.3, `/verify`)**: 이미지 빌드 성공(multi-stage, JRE-only 534MB); `--memory` 256/512/1024m 에서 heap 185/371/742M 로 cgroup 추적 확인(UseContainerSupport 실동작); non-root uid 1000; C.UTF-8 + file.encoding=UTF-8 + TZ=UTC; read-only root fs + tmpfs(`/tmp`·`/var/tmp/heap` writable, `/app`·`/` write 거부) 모두 PASS → `locally-verified`. **$HOME 수정**: useradd --no-create-home + read-only fs 에서 `$HOME(/home/app)` write 실패 발견 → Dockerfile `ENV HOME=/tmp` 추가(writable tmpfs redirect).
|
||||
- **CONTRACT_OK**: registry `JVM_OOM` row 가 `owner_branch: feature-container-runtime-contract` 로 본 branch 를 명시 — 계약 정합 확인. `NO_GROUND_TRUTH` 아님(ca-tmpl 경로 존재).
|
||||
- **WEAK_DEFAULT_PASSWORD_FIXED** (✅ 해소 — 2026-06-15 ca-quality-reviewer advisory): `docker-compose.local.yml` 의 `${POSTGRES_PASSWORD:-changeme}` 가 weak default password 를 bake-in 함. `${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your local .env — no default provided}` (required-variable form) 으로 교체 → .env 미설정 시 compose up 즉시 실패. `db` service `POSTGRES_PASSWORD` + `app` service `SPRING_DATASOURCE_PASSWORD` 2곳 모두 교체.
|
||||
- **DOCKERFILE_DEPENDENCY_WARMUP_SWALLOWED_FIXED** (✅ 해소 — 2026-06-15 ca-quality-reviewer advisory): `src/Dockerfile` 의 `RUN ./gradlew dependencies --no-daemon --quiet 2>/dev/null || true` 가 dependency resolution 실패를 조용히 삼켜 CI 에서 빈 캐시 레이어 + 후속 빌드 실패를 유발할 수 있었음. `2>/dev/null || true` 제거 → fail-fast (resolution 실패 시 빌드 즉시 중단 + 정확한 에러 노출). `--continue` partial-resolution 허용이 불필요한 구조이므로 단순 제거로 충분.
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
> `/coverage` 가 채우는 **생성물** — 손으로 유지하지 않는다. governing 문서(frontmatter `governing_docs`: `wiki/projects/ca-tmpl/runtime-container-health-migration` §Container 슬라이스)가 요구하는 관심사를 이 브랜치가 빠짐없이 덮는지의 결과. 기준: `rules/coverage-gate.md`.
|
||||
> 상태: `covered-here`(이 브랜치 결정) / `delegated`(다른 owner 브랜치) / `missing`(아무도 안 맡음 → Blocking).
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|--------|------|-------|--------|------|
|
||||
| Container base image + JVM ergonomics | covered-here | — | — | D3, D4 |
|
||||
| Locale / timezone (UTC, UTF-8) | covered-here | — | — | D4 (§Audit LOCALE_DRIFT) |
|
||||
| Writable filesystem 최소화 (read-only root fs) | covered-here | — | — | D2 |
|
||||
| Graceful shutdown budget (manifest-side) | covered-here | — | — | D5 |
|
||||
| Graceful shutdown ordering invariant (app-side) | delegated | [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] | OK | §구현 가이드 §4 위임 링크 |
|
||||
| Health probe endpoint shape / group membership | delegated | [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] | OK | §구현 가이드 §6 위임 링크 |
|
||||
| Migration / startup probe budget | delegated | [[raw/branch-notes/feature-migration-startup-contract]] | OK | §엣지·실패·의존 의존 링크 |
|
||||
| OOM 분류 + JVM_OOM error code | covered-here | — | — | §구현 가이드 §5 + registry owner |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- **JVM_OOM 테스트 TDD red**: `OperationalError.JVM_OOM` 미존재 → `compileTestJava` 컴파일 에러 → 의도한 RED 확인 후 enum 추가 → GREEN. 전형적 TDD red 확인 흐름.
|
||||
- **Dockerfile 0-byte placeholder**: `src/Dockerfile` 이 0-byte 추적 파일 — `Write` 도구 첫 시도에서 "File has not been read yet" 에러. `Read` 먼저 한 뒤 `Write` 성공.
|
||||
- **`internal_category_codes_are_retryable` 루프**: `JVM_OOM`(INTERNAL/retryable=false) 추가 시 기존 루프가 실패함 — 예상된 변경. `&& e != OperationalError.JVM_OOM` 제외 조건 + 별도 focused assertion 추가로 해소.
|
||||
|
||||
## 유지보수 로그
|
||||
|
||||
### 2026-07-05 — builder-stage 모듈 COPY 목록 stale 수정 (`develop`, k3s 배포 준비)
|
||||
|
||||
- **문제**: inbound/outbound 어댑터 재구조화 + 신규 어댑터(outbound `objectstorage`/`fileserver`/`persistence-mongo`, inbound `grpc`/`graphql`/`websocket`) 추가 후, `src/Dockerfile` builder 스테이지의 하드코딩 per-module `COPY <module>/build.gradle` + `gradle.lockfile` 목록이 **6개 모듈 누락** 상태로 방치됨. `verifyDependencyLocks`(`COPY . .` 이전 실행)는 settings.gradle 전체 leaf 모듈을 STRICT resolve하는데, `app-bootstrap`이 누락 모듈을 project 의존으로 참조 → 릴리스 이미지 빌드가 깨질 상태였음. "레거시"의 실체는 스타일이 아니라 **모듈 구조와의 drift**.
|
||||
- **수정**: 28줄 하드코딩 COPY 블록 → `COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./` 1줄로 교체. `--parents`가 디렉토리 구조를 보존하므로 신규 모듈이 자동 포함 → 다시는 settings.gradle과 drift 안 남 (D8 락 캐싱 전략·runtime 스테이지 모두 무변경).
|
||||
- **labs 프론트엔드 digest 고정**: `--parents`는 labs Dockerfile frontend 필요 → 최상단에 `# syntax=docker/dockerfile:1.7-labs@sha256:b99fecfe00268a8b556fad7d9c37ee25d716ae08a5d7320e6d51c4dd83246894` 추가. 떠다니는 태그 대신 digest 고정으로 빌드-타임 공급망 표면 최소화 (이 리포는 Cosign/SLSA/Trivy 파이프라인).
|
||||
- **검증 (3중, 마지막이 end-to-end 실증)**:
|
||||
1. 호스트 `./gradlew verifyDependencyLocks` → BUILD SUCCESSFUL 9s, leaf 19개 모듈 전부 STRICT 락 통과.
|
||||
2. 경량 throwaway 이미지(alpine + `COPY --parents` + `find`, Gradle 미실행) → glob이 build.gradle 20개(19 모듈 + 루트) + gradle.lockfile 19개를 구조 보존 스테이징 (누락 6개 포함).
|
||||
3. **실제 `src/Dockerfile` 전체 빌드 성공 (exit 0)**: `docker build -f src/Dockerfile src/ --build-arg RELEASE_VERSION=0.0.1 ...` — `#15 COPY --parents` DONE 0.2s → `#16 verifyDependencyLocks` DONE **120.8s**(컨테이너 내 STRICT resolve) → `#20 :app-bootstrap:bootJar` **BUILD SUCCESSFUL 25s** → `caskeleton:verify-local` 이미지 생성. **이전 노트의 "Docker build unverified (no docker in worktree)" 상태를 여기서 해소.**
|
||||
- 이 시점 `develop` 워킹트리 clean, **커밋은 사용자가 직접 수행**.
|
||||
|
||||
### 2026-07-05 — sample-portfolio standalone 데모 이미지 추가 (`src/Dockerfile.sample`)
|
||||
|
||||
- **동기**: 프로덕션 bootstrap 이미지(`src/Dockerfile` → `CaSkeletonApplication`)는 기본값 없는 env 72개 + JWT issuer + prod 시크릿/DB 검증으로 "그냥 띄워 테스트"가 어려움. `sample-portfolio`(`SamplePortfolioApplication`)는 자체 `application.yml`이 모든 env에 기본값을 주고 `SamplePublicAccessSecurityConfig`로 열려 있어 데모/서버 테스트에 적합 → k3s 배포용으로 **별도 Dockerfile 신설**.
|
||||
- **구조 = src/Dockerfile 트윈**: builder 스테이지(labs `# syntax` + `COPY --parents` glob + STRICT `verifyDependencyLocks` + `COPY . .`)와 런타임 하드닝(non-root, read-only-fs 쓰기 마운트, JVM ergonomics, EXPOSE 8080/9001, HEALTHCHECK)을 그대로 상속. **차이는 5가지뿐**: (1) ARG 기본값으로 argless 빌드, (2) `:sample-portfolio:bootJar` 타깃, (3) jar 경로, (4) LABEL `caskeleton-sample`, (5) 릴리스 메타데이터 hard-fail 가드 제거(데모라 불필요).
|
||||
- **런타임 사실**: PG 드라이버 `org.postgresql:postgresql:42.7.8`는 `adapter:outbound:persistence-jpa`(runtimeOnly, RDBMS base + PG 벤더 병합 모듈)를 통해 샘플 runtimeClasspath에 존재 → bootJar 실행 가능. 부팅엔 reachable PostgreSQL만 있으면 됨(자체 Flyway `db/sample-migration/V2,V6`).
|
||||
- **함정 (해소)**: ARG `RELEASE_VERSION=0.0.0-sample`으로 최초 argless 빌드가 build.gradle SemVer 가드(`\d+\.\d+\.\d+`, L21)에 걸려 `verifyDependencyLocks` exit 1로 실패. `--quiet`가 원인 메시지를 가려 BuildKit 백그라운드 알림이 "exit 0" 오해를 줌(실제 REAL_EXIT=1). → `RELEASE_VERSION`은 순수 SemVer여야 하고 `-sample` 마커는 라벨 전용 `BUILD_VERSION`에만. `RELEASE_VERSION=0.0.0`으로 교정 후 재빌드 성공.
|
||||
- **검증**: `docker build -f src/Dockerfile.sample src/ -t ca-sample:local`(argless) → REAL_EXIT=0, `:sample-portfolio:bootJar` BUILD SUCCESSFUL 40s, 이미지 `ca-sample:local`(581MB) 생성. glob 레이어는 `src/Dockerfile` 빌드와 CACHED 공유. **커밋은 사용자가 직접 수행.**
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/container-woowahan-spring-native-tradeoffs]]
|
||||
- [[raw/company-tech-blogs/runtime-health-datadog-engineering-graceful-shutdown]]
|
||||
- [[raw/official-docs/container-alpine-java-musl-tradeoffs]]
|
||||
- [[raw/official-docs/container-distroless-google-github]]
|
||||
- [[raw/official-docs/container-graalvm-native-image-spring-boot]]
|
||||
- [[raw/official-docs/k8s-application-security-checklist-readonly-fs]]
|
||||
- [[raw/official-docs/k8s-pod-security-standards-restricted]]
|
||||
- [[raw/official-docs/openjdk-jdk-8196595-container-support]]
|
||||
- [[raw/official-docs/redhat-openjdk-container-awareness-java17]]
|
||||
- [[raw/official-docs/runtime-health-k8s-probes-official]]
|
||||
- [[raw/official-docs/supply-chain-cosign-keyless-sigstore]]
|
||||
- [[raw/official-docs/supply-chain-slsa-provenance-framework]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/jvm-oom-vs-container-oomkill-exit-137-2026-07-02]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
### Sub-branches (세부 작업)
|
||||
|
||||
- (없음)
|
||||
|
||||
### 오류 기록 (본 feature 작업 중 발생)
|
||||
|
||||
- (없음 — 마주친 문제 섹션에서 인라인 처리. 별도 error 노트 분리 불필요)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- JVM `-XX:+ExitOnOutOfMemoryError` 와 kubelet OOMKill 은 모두 exit 137 — 어떻게 구별하는가?
|
||||
- `-XX:MaxRAMPercentage=75` 가 의미 있으려면 컨테이너 memory limit 이 반드시 설정되어야 하는 이유는?
|
||||
- `read_only: true` 컨테이너에서 Spring Boot Tomcat 이 CrashLoop 하는 원인과 해결책?
|
||||
- Graceful shutdown budget: app drain 20s + preStop 5s + safety margin 10s → `stop_grace_period=35s`. `.env`의 30s 값과의 drift를 어떻게 처리했나?
|
||||
- 왜 final stage에 JDK가 아닌 JRE만 포함하는가?
|
||||
- 멀티모듈 Gradle 빌드에서 per-module `COPY build.gradle` 하드코딩 목록이 왜 stale 취약점인가? BuildKit `COPY --parents` glob으로 레이어 캐싱을 유지하면서 drift를 없애는 방법은? (일반 `COPY **/build.gradle`는 왜 안 되는가 — 경로 평탄화/충돌)
|
||||
- Dockerfile `# syntax` frontend를 태그가 아닌 digest로 고정하는 공급망(supply-chain) 근거는? cache mount(`--mount=type=cache`) 전략이 GitHub Actions `type=gha` 캐시와 왜 안 맞는가?
|
||||
- 하나의 멀티모듈 리포에서 "엄격한 릴리스 이미지(메타데이터 hard-fail·build-arg 필수)"와 "처분형 데모 이미지(argless·가드 없음)"를 별도 Dockerfile로 분리하는 기준은? builder 스테이지를 공유(동일 glob 레이어 → CACHED)하면서 무엇만 갈라내야 하는가?
|
||||
- `RUN ./gradlew ... --quiet` 가 실패했는데 BuildKit 백그라운드 알림은 "exit 0"으로 보였다 — `--quiet`가 원인 로그를 가리는 함정, 그리고 `RELEASE_VERSION=0.0.0-sample` 이 SemVer 가드(`\d+\.\d+\.\d+`)에 걸린 근본 원인을 어떻게 특정했나?
|
||||
|
||||
### 블로그·채용공고 연계 글감
|
||||
|
||||
- "JVM OOM과 컨테이너 OOMKill은 왜 같은 exit 137인가 — 구별법과 error.code 전략"
|
||||
- "Spring Boot 컨테이너 graceful shutdown budget 계산 — preStop/drain/grace margin 조합"
|
||||
- "docker-compose read_only: true + Spring Boot — Tomcat basedir 를 /tmp 로 redirect 해야 하는 이유"
|
||||
- "멀티모듈 Gradle Dockerfile의 per-module COPY 목록이 조용히 stale해지는 문제 — `COPY --parents` glob 한 줄로 drift 제거 + 레이어 캐싱 유지"
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- `[[raw/daily-notes/2026-06-15]]`
|
||||
|
||||
## 완료 후 wiki 추출 대상
|
||||
|
||||
- [[wiki/projects/ca-tmpl/runtime-container-health-migration]] 의 container runtime canonical section (§Container).
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움.
|
||||
|
||||
- PR 링크: (pending)
|
||||
- 리뷰 메모: (pending)
|
||||
- 머지 결과 / 배포 환경: 로컬 worktree (ca-tmpl-container-runtime) — Gradle test locally-verified; Docker build unverified (no docker in worktree)
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목: OperationalError.JVM_OOM 추가, ContainerRuntimeOomContractTest, OperationalErrorTest JVM_OOM 테스트
|
||||
- `locally-verified` 항목: src/Dockerfile, src/.dockerignore, docker-compose.yml, docker-compose.dev.yml, docker-compose.local.yml — 내용 locally-verified but docker runtime 미실행
|
||||
- `prod-verified` 항목: (없음)
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned): Docker build 런타임 행동(locale, read-only-fs smoke, OOM exit 137) — docker-only-unverified
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-contract-registry-governance.md
|
||||
@@ -0,0 +1,386 @@
|
||||
---
|
||||
title: branch / feature-contract-registry-governance
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-contract-registry-governance
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||||
tags: [branch, ca-skeleton, registry, governance, contract]
|
||||
created: 2026-05-22
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-041
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-041
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-ERROR-ENVELOPE-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-OPENAPI-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: 583c60a41462cd57c1c9bf3c27759eaa9ea2597db7633e5507c9577c6467d81e
|
||||
---
|
||||
|
||||
# branch: feature-contract-registry-governance
|
||||
|
||||
> Layer: `raw/branch-notes/` — error/env/header/log/metric/capability 같은 contract 문자열과 enum을 registry로 관리합니다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (**§21 Contract Registry**) 의 결정/근거/금지 사항을 정제한다. governing_docs 로 §21 을 가리킨다.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: registry single-owner·schema·OpenAPI drift gate가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-ERROR-ENVELOPE-001@1` | foundation이 envelope schema와 error.category enum의 단일 owner다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-OPENAPI-001@1` | verification suite가 OpenAPI drift의 release-blocking 판정권을 소유한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
100점 skeleton에서 가장 위험한 것은 ad hoc 문자열입니다. error code, env key, header, log field, metric name, capability가 파일마다 흩어지면 운영 계약이 깨집니다. 이 branch는 모든 contract token을 registry 기반으로 관리합니다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- error registry.
|
||||
- response/meta registry.
|
||||
- header registry.
|
||||
- env registry.
|
||||
- log/metric/trace registry.
|
||||
- capability registry.
|
||||
- registry 변경 절차.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- registry UI.
|
||||
- external config server 구현.
|
||||
- runtime dynamic registry.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/archunit-annotation-as-registry-evaluation]] | — |
|
||||
| [[raw/official-docs/registry-adr-official]] | Nygard/MADR; ca-tmpl branch note가 Status·Context·Decision·Consequences 모두 포함하므로 별도 ADR 불요 |
|
||||
| [[raw/official-docs/governance-archunit-official]] | annotation 기반 registry; ca-tmpl은 ArchUnit을 verifier로만 사용 |
|
||||
| [[raw/official-docs/opentelemetry-versioning-stability-spec]] | D5: OTel semantic conventions는 experimental→stable 전환·rename이 발생하며 모든 변경은 Schema File에 기술해야 함 — 외부 표준 매핑 row 필요성의 공식 근거 |
|
||||
| [[raw/official-docs/opentelemetry-http-semconv-migration-guide]] | D5: HTTP 메트릭 이름(`http.server.duration` → `http.server.request.duration`)과 단위(`ms` → `s`)가 실제로 rename된 직접 증거 — mapping/version row 없이는 old vs new token 구분 불가 (OTEL-HM-C2, OTEL-HM-C4) |
|
||||
| [[raw/official-docs/trace-context-w3c-recommendation]] | D5: W3C Trace Context Recommendation 이 `tracestate` 를 통해 내부 shorter identifier 와 표준 `trace-id` 를 병행 전파할 것을 권고 (W3C-TC-C4) — `traceparent`/`tracestate` registry mapping row 유지의 공식 spec 근거 |
|
||||
| [[raw/official-docs/rfc9457-problem-details-http-apis]] | D5: RFC 9457 이 RFC 7807 을 obsolete 하고 error envelope 외부 표준이 버전 관리됨을 IETF 공식 증명 (RFC9457-C1, RFC9457-C5) — skeleton error registry 에 RFC version mapping row 필요성의 직접 근거 |
|
||||
|
||||
## 외부 근거 / 대안 조사 (2026-05-22 — Group G-G: Contract Registry Governance)
|
||||
|
||||
본 branch의 markdown SSOT + YAML/generated constants + 공통 schema + 7 registry families (as-built, §Audit F2/F3 정합) 결정에 대한 외부 source.
|
||||
|
||||
- **채택 결정 (markdown raw SSOT + YAML implementation)**:
|
||||
- (ca-tmpl branch note의 "결정 사항" 라인이 사실상 mini-ADR로 작동)
|
||||
- **검토한 대안**:
|
||||
- **대안 1: ADR (Architectural Decision Record) 별도 파일** — [[raw/official-docs/registry-adr-official]] (Nygard/MADR; ca-tmpl branch note가 Status·Context·Decision·Consequences 모두 포함하므로 별도 ADR 불요)
|
||||
- **대안 2: ArchUnit annotations as registry** — [[raw/official-docs/governance-archunit-official]] (annotation 기반 registry; ca-tmpl은 ArchUnit을 verifier로만 사용)
|
||||
- **대안 3: Code-only enums** — DI 통합 강점이나 markdown SSOT 부재
|
||||
- **대안 4: Protobuf·Smithy as registry** — API contract 도구, ca-tmpl scope 외
|
||||
- **비교 핵심**: ca-tmpl branch note의 "결정 사항" 라인이 mini-ADR로 동작 (Status=`status_label`, Context=목표/WHY, Decision=결정 사항, Consequences=테스트 계약) — 별도 ADR 파일 도입 불요. ArchUnit은 verifier로만 사용, registry 자체는 markdown SSOT + YAML/generated constants.
|
||||
|
||||
**후속 보강 (2026-05-22)**: ArchUnit annotation-as-registry 대안 평가 완료. markdown SSOT 채택 유지. [[raw/official-docs/archunit-annotation-as-registry-evaluation]] 참조.
|
||||
|
||||
**후속 보강 (2026-06-15, D5 외부표준 mapping)**: 외부 platform 표준 채택 시 mapping row 유지(D5) 의 대안 3종 — (1) per-token mapping row, (2) 외부 이름 직접 채택 무 mapping, (3) spec URL 만 참조 — 을 공식 표준으로 조사. OTel semconv 의 실제 rename(`http.server.duration`→`http.server.request.duration`) 과 RFC 7807→9457 obsolete 가 "외부 표준은 버전이 바뀐다" 를 실증하므로, 혼재 표준(W3C+OTel+RFC) 환경에서는 (1) per-token mapping row 채택. 단 W3C Recommendation 처럼 이름이 고정된 표준의 헤더는 (2) 직접 채택 + 최소 `external_standard`/`spec_url` column 으로 충분. 근거: W3C-TC-C4(권고 "encouraged"), OTEL-VS-C4(rename 시 Schema File MUST), OTEL-HM-C2(실 rename), RFC9457-C1(obsolete).
|
||||
|
||||
## TODO
|
||||
|
||||
> TODO drained 2026-05-22 — 결정은 아래 "결정 사항" / "구현 가이드" (Registry Storage Contract / Registry Tables / 변경 절차) 참조. 잔존 TODO 없음.
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- 2026-06-15 (`/branch-spec`): ca-tmpl ground truth(`/home/donghyeon/workspace/ca-tmpl/docs/registries/*.yaml` 7개 + 8 sibling owner branch 실재) 대조 완료. 확인된 핵심 구조:
|
||||
- 본 branch 는 7 registry 의 **schema owner** — 모든 yaml header 가 `# Schema owner: feature-contract-registry-governance` 명시. column 구조·저장 형식·변경 절차의 SSOT.
|
||||
- registry **row 값**(어떤 code/key/name 이 존재하는가)은 각 sibling `owner_branch` 소유(delegated, 8개).
|
||||
- **category enum** 값은 본 branch 가 아니라 foundation 소유(`# Category enum owner: feature-operational-error-observability-foundation`). 본 branch 는 `category` column 이 있어야 한다는 schema 만 소유.
|
||||
- D5(외부표준 mapping) 공식 근거 4종 확보 → UNSUPPORTED 해소.
|
||||
- D3/D4/Registry Tables 의 path·schema·family 수가 as-built 와 달라 §Audit & Findings(F1~F3)로 정합.
|
||||
- 2026-06-20 (Phase C2 구현 착수 — schema-owner gate): 본 branch 의 schema governance 를 기계 강제하는 cross-registry 테스트 `ContractRegistrySchemaGovernanceTest` (`ca-tmpl/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/`) 추가. per-registry value drift guard(`ErrorCodeRegistryMappingTest`/`SecretsClassificationRegistryTest`/`RepositoryAccessCapabilityRegistryTest`/`MetricsAlertingContractTest` — row owner 소유)와 분리되는 **schema 층** 게이트로, 다음 6가지를 검증: ① 7 family(error-codes/env-keys/secrets-classification/headers/mdc-keys/metrics/capabilities) 존재(Audit F3) ② 각 파일 `# Schema owner: feature-contract-registry-governance` 헤더(§3) ③ 모든 row 의 identity(code/key/name)+`owner_branch`(§1/§2) ④ full row 의 `compatibility_impact`(legal enum none/additive/behavior-change/breaking)+`required_test`(D2) ⑤ reference row(secrets public-config 5개) 면제 + reference target 보유. `docs/` gitignore 이므로 registry 부재 시 SKIP, 존재 시 위반은 hard FAIL(기존 drift 테스트 패턴 동일). evidence: `locally-verified` — `./gradlew :app-bootstrap:test --tests '*ContractRegistrySchemaGovernanceTest'` 6 tests green(skipped=0); 음성 변이 검사(illegal `compatibility_impact` 주입 시 FAIL, restore 후 green)로 게이트 실효성 확인. ArchUnit 정적 token 탐지(§Claims To Verify 3행)는 여전히 `planned` — 본 게이트는 artifact schema 정합만 강제하며 그 PoC 를 대체하지 않음. 상세 함정: [[raw/errors/contract-registry-reference-row-universal-column-false-fail-2026-06-20]].
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-05-22: 새 error/env/header/log/metric/capability는 registry 없이 추가하지 않음.
|
||||
- 2026-05-22: registry 항목은 최소 하나 이상의 contract test와 연결.
|
||||
- 2026-05-22: registry 저장 형식은 markdown table을 raw SSOT로 두고, 구현 단계에서 `src/main/resources/contract-registry/*.yml` 또는 generated constants로 변환 가능하게 함.
|
||||
- 2026-05-22: registry row의 공통 필수 column은 `name`, `owner_branch`, `owner_layer`, `default`, `allowed_values`, `compatibility_impact`, `required_test`로 둠.
|
||||
- 2026-05-22: 외부 platform 표준을 쓰는 경우에도 skeleton registry에는 mapping row를 남김.
|
||||
- 2026-05-22: registry 본문(implementation artifact)은 `ca-tmpl/docs/registries/` 하위에 yaml로 작성 (Phase B). raw SSOT는 본 branch note의 표 schema + 각 owner branch의 결정 사항. yaml은 표 schema를 따르는 row table.
|
||||
- 2026-05-22: registry SSOT은 markdown 유지. ArchUnit annotation은 verification verifier 역할만 (registry 아님). 근거: framework-neutral + git diff review + 외부 도구 호환. 상세 평가는 [[raw/official-docs/archunit-annotation-as-registry-evaluation]].
|
||||
- **2026-06-15 (as-built 정합, Audit F1)**: registry implementation artifact 의 실제 위치는 `ca-tmpl/docs/registries/*.yaml` 7개 파일(D6 와 일치). 위 2026-05-22 D3/Registry Storage Contract 의 `src/main/resources/contract-registry/*.yml` 경로는 **미구현 stale** — 코드에 존재하지 않음(`find src -path '*resources/contract-registry*'` 결과 0). generated Java constants 는 Phase C2 downstream(yaml→constants) 이며 SSOT 아님. yaml header `# SSOT: wiki/projects/ca-tmpl/registries/*.yaml` 는 추출 후 canonical 위치(현재 미존재).
|
||||
- **2026-06-15 (as-built 정합, Audit F3)**: registry 는 6개가 아니라 **7개** family — Error Codes / Env Keys / Secrets Classification / HTTP Headers / MDC·Log Keys / Metrics / Repository Access Capabilities (governing §21 SSOT yaml 표). 이전 "Log/Metric/Trace" 단일 family 는 `mdc-keys.yaml` + `metrics.yaml` 2개로 분리, **Secrets Classification** 추가. 이전 "Response" family 는 별도 registry 가 아니라 foundation 소유 envelope schema 이므로 7 registry 에서 제외.
|
||||
- **2026-06-15 (as-built 정합, Audit F2)**: 초기 제안한 uniform 7-column schema 는 as-built 에서 채택되지 않음. 모든 7 registry 에 공통(universal) 인 column 은 `owner_branch`·`compatibility_impact`·`required_test` **3개뿐** + family 별 identity column(`code`/`name`/`key`) + family-specific column. `owner_layer` 는 error-codes 에만, `default`/`allowed_values` 는 env-keys 에만 존재. D4 UNSUPPORTED → as-built 로 해소.
|
||||
- **2026-06-15 (D5 근거 확보)**: 외부 platform 표준 mapping row(D5) 에 W3C Trace Context / OTel versioning-stability / OTel HTTP migration / RFC 9457 공식 표준 근거 확보. D5 UNSUPPORTED → `official-standard`. 상세 §외부 근거 후속 보강.
|
||||
|
||||
## Work Item Contract
|
||||
|
||||
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
|
||||
|
||||
| field | required | rule |
|
||||
| --- | --- | --- |
|
||||
| Decision | yes | 구현자가 선택해야 하는 기본값 |
|
||||
| Allowed | yes | 허용되는 예외와 조건 |
|
||||
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
|
||||
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
|
||||
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
|
||||
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
|
||||
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
|
||||
|
||||
## 판정 기준
|
||||
|
||||
| 구분 | 기준 |
|
||||
| --- | --- |
|
||||
| Decision | contract token은 registry로 관리 |
|
||||
| Allowed | 외부 platform 표준 사용 시 mapping table 제공 |
|
||||
| Forbidden | raw string/enum을 branch별로 ad hoc 추가 |
|
||||
| Required metadata | name, owner, default, allowed values, profile, test link, compatibility impact |
|
||||
| Failure condition | registry에 없는 error/env/header/log/metric/capability가 구현에 등장하면 실패 |
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. `Decision ID` 는 본 branch-note 안에서 안정적으로 유지 (D1~D7). Registry Storage Contract 및 **7개** Registry Family table(§구현 가이드)의 책임도 본 표의 row 로 매핑.
|
||||
|
||||
> `선택 조건` 열: "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 분기 없으면 N/A.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | 새 error/env/header/log/metric/capability 는 registry 없이 추가하지 않음 | N/A — skeleton-wide 불변 규칙 | `raw/official-docs/registry-adr-official.md#REG-ADR-C1`, `raw/official-docs/registry-adr-official.md#REG-ADR-C2`, `raw/official-docs/governance-archunit-official.md#AU-OFF-C1`, `raw/official-docs/governance-archunit-official.md#AU-OFF-C2`; + **as-built enforcement**: 7/7 registry row 가 `required_test` 필수(grep 확인) → "registry 없이 추가 금지" 는 *required_test + contract test* 로 강제(§구현 가이드 §4 + §테스트 계약), 정적 탐지(ArchUnit custom rule)는 §Claims To Verify PoC | `official-reference + official-vendor-doc + as-built` | REG-ADR-C1/C2 는 "AD/ADR 정의" 까지만 — "모든 contract token 을 registry 로 관리한다" 의 직접 출처 아님. AU-OFF Claim 은 ArchUnit verifier 능력만 — registry SSOT 강제 아님. enforcement 메커니즘은 required_test(as-built) 로 닫히되, "registry 부재 token 의 정적 차단" 은 ArchUnit PoC(미검증, Claims To Verify) |
|
||||
| D2 | registry 항목은 최소 1개 이상의 contract test 와 연결 | N/A — 모든 row 의 `required_test` 필수 | `raw/official-docs/governance-archunit-official.md#AU-OFF-C1`, `raw/official-docs/governance-archunit-official.md#AU-OFF-C2`, `raw/official-docs/archunit-conditional-on-property-3-layer-pattern.md#AUCP-C1`, `raw/official-docs/archunit-conditional-on-property-3-layer-pattern.md#AUCP-C5`, `raw/official-docs/archunit-annotation-as-registry-evaluation.md#AAR-C5` | `official-vendor-doc + engineering-blog` | AAR-C5 (fitness function 정의) 는 verifier 측면만 — "test connection" 의 의무화 자체는 ca-tmpl 운영 결정 |
|
||||
| D3 | registry 저장 형식 = markdown table raw SSOT + YAML implementation artifact (실 위치는 D6: `ca-tmpl/docs/registries/*.yaml`); generated Java constants 는 Phase C2 downstream | markdown 으로 git diff review·외부 도구 호환이 필요할 때 이 결정 / 런타임 DI 통합이 1순위면 대안 3(code-only enum) | `raw/official-docs/archunit-annotation-as-registry-evaluation.md#AAR-C1`, `raw/official-docs/archunit-annotation-as-registry-evaluation.md#AAR-C2` | `official-vendor-doc` | AAR-C1/C2 는 annotation registry 의 한계 (Does not prove: domain contract registry 용도) — markdown SSOT 채택 의 직접 권장 아님, 대안 비교의 부정 근거로만 작동. ⚠️ 이전 Decision 텍스트의 `src/main/resources/contract-registry/*.yml` 경로는 미구현 stale 였음 → D6/§Audit F1 로 정합 |
|
||||
| D4 | registry row 공통 필수 column = **universal 3** (`owner_branch`, `compatibility_impact`, `required_test`) + family identity column (error=`code`, 그 외=`name`, mdc=`key`) + family-specific column. *(초기 제안 uniform 7-column 은 as-built 미채택 — §Audit F2)* | 현재 7 family 는 universal-3 + family-specific 로 분기 없음. **신규 family 추가 시** 어떤 column 을 universal 로 승격할지는 본 결정 범위 밖 — Claims To Verify 2행(walkthrough)으로 위임(의도된 deferral) | ground truth `ca-tmpl/docs/registries/*.yaml` (7 file 모두 `# Schema owner: feature-contract-registry-governance`; universal 3-column 은 grep 으로 7/7 확인, `owner_layer`=error only, `default`/`allowed_values`=env only) | `as-built (ca-tmpl/docs/registries/*.yaml)` | family-specific column 의 universal 승격 기준 부재 — 신규 registry 추가 시 어떤 column 을 공통으로 둘지 규칙 없음. 초기 7-column 제안이 미채택된 이력은 §Audit F2 보존 |
|
||||
| D5 | 외부 platform 표준 (예: OpenTelemetry semantic conventions, RFC 7807→9457, W3C Trace Context) 을 쓰는 경우에도 skeleton registry 에 mapping row 를 남김 | 혼재 표준(W3C+OTel+RFC) 또는 experimental/rename 이력 있는 표준이면 per-token mapping row(대안1) / W3C Recommendation 처럼 이름 고정 표준 헤더는 직접 채택 + 최소 `external_standard`·`spec_url` column(대안2) / spec URL 만 참조(대안3)는 per-token 추적 불가로 기각 | `raw/official-docs/opentelemetry-versioning-stability-spec.md#OTEL-VS-C1`, `raw/official-docs/opentelemetry-versioning-stability-spec.md#OTEL-VS-C4`, `raw/official-docs/opentelemetry-versioning-stability-spec.md#OTEL-VS-C5`, `raw/official-docs/opentelemetry-http-semconv-migration-guide.md#OTEL-HM-C1`, `raw/official-docs/opentelemetry-http-semconv-migration-guide.md#OTEL-HM-C2`, `raw/official-docs/opentelemetry-http-semconv-migration-guide.md#OTEL-HM-C4`, `raw/official-docs/trace-context-w3c-recommendation.md#W3C-TC-C1`, `raw/official-docs/trace-context-w3c-recommendation.md#W3C-TC-C3`, `raw/official-docs/trace-context-w3c-recommendation.md#W3C-TC-C4`, `raw/official-docs/trace-context-w3c-recommendation.md#W3C-TC-C5`, `raw/official-docs/rfc9457-problem-details-http-apis.md#RFC9457-C1`, `raw/official-docs/rfc9457-problem-details-http-apis.md#RFC9457-C5` | `official-standard` | OTEL-VS-C1: experimental 단계에서 breaking change MAY occur → registry row 없이 hardcode 금지. OTEL-VS-C4: 모든 rename·breaking change는 Schema File에 MUST 기술 → mapping row가 변경 추적 지점이 됨. OTEL-HM-C2: `http.server.duration` → `http.server.request.duration` rename 직접 증거. W3C-TC-C1: `traceparent`/`tracestate` 가 W3C Recommendation 규범 표준 — registry "외부 표준" 표기 근거. W3C-TC-C4: 내부 shorter identifier 와 표준 `trace-id` 를 `tracestate` 로 병행 전파 권고("encouraged") — 내부 token ↔ 외부 표준 token mapping row 유지의 직접 spec 근거. RFC9457-C1: "This document obsoletes RFC 7807" — IETF 공식 폐지로 error envelope 외부 표준의 버전 관리가 실제 발생함을 직접 증명. RFC9457-C5: registry 신설 + multiple problems 처리 + non-resolvable type URI guidance 의 3변경 — RFC 7807 vs 9457 token 구분을 위한 skeleton registry 의 version mapping row 필요성의 직접 근거. | OTEL-HM 계열은 HTTP metrics에 한정. W3C-TC-C4 는 "encouraged" (MUST/SHOULD 아님) — D5 의 "mapping row 를 남긴다" 를 의무로 격상하는 것은 ca-tmpl 운영 결정. mapping row 구체적 column schema 는 D4 family-specific 영역(미표준화). ca-tmpl 현재 error envelope 이 RFC 9457 compliant 한지는 별도 코드 검증 필요 |
|
||||
| D6 | registry 본문 implementation artifact = `ca-tmpl/docs/registries/` 하위 yaml (Phase B), raw SSOT 는 본 branch note 표 schema | N/A — Phase B 운영 결정 | `raw/official-docs/archunit-annotation-as-registry-evaluation.md#AAR-C1`, `raw/official-docs/archunit-annotation-as-registry-evaluation.md#AAR-C2` | `official-vendor-doc + as-built (7 yaml 파일 실재)` | yaml 저장 형식의 공식 권장 부재 — Phase B 운영 결정. AAR-C2 의 meta-annotation 패턴은 ArchUnit 설정 중복 제거용일 뿐 registry storage 권장 아님 |
|
||||
| D7 | registry SSOT 은 markdown 유지, ArchUnit annotation 은 verifier 역할만 (registry 아님) — framework-neutral + git diff review + 외부 도구 호환 | annotation 으로 schema(column) 표현 불가 → markdown SSOT 유지 / verifier 가 필요할 때만 ArchUnit annotation 부착 | `raw/official-docs/archunit-annotation-as-registry-evaluation.md#AAR-C1`, `raw/official-docs/archunit-annotation-as-registry-evaluation.md#AAR-C2`, `raw/official-docs/archunit-annotation-as-registry-evaluation.md#AAR-C3`, `raw/official-docs/archunit-annotation-as-registry-evaluation.md#AAR-C4`, `raw/official-docs/archunit-annotation-as-registry-evaluation.md#AAR-C5`, `raw/official-docs/governance-archunit-official.md#AU-OFF-C1`, `raw/official-docs/governance-archunit-official.md#AU-OFF-C2`, `raw/official-docs/governance-archunit-official.md#AU-OFF-C3` | `official-vendor-doc + engineering-blog` | AAR-C5 는 `engineering-blog` (서적 출처). "framework-neutral + 외부 도구 호환" 의 정량 비교 부재 — annotation registry 대비 markdown 의 우위는 본 raw 자료의 "Does not prove" 영역 (annotation 으로 schema 표현 불가) 에서 도출 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> *결정 (Decisions)* 이 "*무엇*" 이라면, 본 §는 "*어디에 어떻게*" 의 사전 명세. as-built ground truth(`ca-tmpl/docs/registries/*.yaml` 7개 + 8 sibling owner branch)에 정합. 코드로 확인되지 않은 항목은 `planned`/`UNSUPPORTED_IMPL_DECISION` 로 표기.
|
||||
|
||||
### 1. Registry 저장 & 경로 (as-built — Registry Storage Contract)
|
||||
|
||||
> **Trace**: D3 + D6 — Supporting: AAR-C1/C2 + ground truth `ca-tmpl/docs/registries/*.yaml`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: markdown raw SSOT(표) → yaml 변환 스크립트의 구체적 구현(언어/diff 알고리즘)은 근거 raw 없음 — Phase B 도구 결정. trade-off: 수기 동기화 vs 생성 스크립트, 현재 수기. (검증은 §Claims To Verify "markdown↔yaml row 누락" 행.)
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: generated Java constants 의 패키지/클래스 명칭 — 근거 없음, Phase C2 downstream. trade-off: 코드 단계 결정.
|
||||
|
||||
| item | as-built decision | note |
|
||||
| --- | --- | --- |
|
||||
| raw SSOT | 본 branch note 표 schema + 각 owner branch 결정 사항 + project note §21 | governing §21 (raw/project-notes/ca-skeleton-operational-contract) |
|
||||
| implementation artifact | `ca-tmpl/docs/registries/*.yaml` — **7 files** (error-codes / env-keys / secrets-classification / headers / mdc-keys / metrics / capabilities) | **PATH 정정(Audit F1)**: 이전 `src/main/resources/contract-registry/*.yml` 은 미구현 stale. generated constants 는 Phase C2 downstream, SSOT 아님 |
|
||||
| canonical 추출 경로 (예정) | `wiki/projects/ca-tmpl/registries/*.yaml` | 각 yaml header `# SSOT:` 가 가리키는 추출 후 위치 — 추출 전이라 현재 미존재 (Phase C2) |
|
||||
| row identity | family 별: error=`code`, mdc=`key`, 그 외(env/secrets/headers/metrics/capability)=`name` | as-built grep |
|
||||
| required owner | `owner_branch` 필수(7/7). `owner_layer` 는 error-codes 만 보유 | as-built |
|
||||
| compatibility impact | `none` / `additive` / `behavior-change` / `breaking` 중 하나 (7/7 공통) | as-built |
|
||||
| required test | `required_test` 필수(7/7) — architecture/contract/OpenAPI/log/metric/env smoke 중 하나 이상 | D2 |
|
||||
|
||||
registry 구현 산출물이 raw SSOT와 다르면 verification suite가 실패해야 합니다.
|
||||
|
||||
### 2. Registry families & 공통 schema (as-built 7개 — Registry Tables)
|
||||
|
||||
> **Trace**: D4 + governing §21 — Supporting: ground truth 7 yaml header(`# Schema owner: feature-contract-registry-governance`).
|
||||
>
|
||||
> - **as-built reconciliation (Audit F2/F3)**: 초기 6-family + uniform 7-column 안은 미채택. universal column 은 `owner_branch`·`compatibility_impact`·`required_test` 3개 + identity + family-specific.
|
||||
|
||||
**Universal columns (7 registry 전부 보유):** `owner_branch`, `compatibility_impact`, `required_test`, + family identity. 그 외는 family-specific.
|
||||
|
||||
| registry | yaml 파일 | row owner_branch | identity | family-specific 주요 column |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Error Codes | `error-codes.yaml` | `feature-operational-error-observability-foundation` *(category enum SSOT)* | `code` | category, http_status, retryable, retry_after_seconds, owner_layer, client_safe_message, log_level, runbook_link |
|
||||
| Env Keys | `env-keys.yaml` | `feature-env-driven-runtime-configuration` | `name` | type, default, allowed_values, classification, required, reload_policy, validation |
|
||||
| Secrets Classification | `secrets-classification.yaml` | `feature-secrets-config-source-contract` | `name` | classification, source, rotation_policy, prod_default, dev_sentinel_prefix, masking_rule |
|
||||
| HTTP Headers | `headers.yaml` | `feature-api-contract-baseline` *(cross-owner: idempotency·tracing·tenant·compat·security)* | `name` | direction, type, required, generated_if_missing, mdc_key, envelope_meta_field, case_style |
|
||||
| MDC / Log Keys | `mdc-keys.yaml` | `feature-operational-error-observability-foundation` | `key` | type, source, required_in, http_header_mapping, envelope_field, propagation, cardinality_safe_for_metric, case_style |
|
||||
| Metrics | `metrics.yaml` | `feature-metrics-alerting-contract` | `name` | type, unit, tags(+cardinality_limit/allowed_values), percentiles, alert_severity_thresholds, log_field_mapping |
|
||||
| Repository Access Capabilities | `capabilities.yaml` | `feature-repository-access-permission-contract` | `name` | scope, enforcement, annotation, semantics, bound_to_capability, threshold |
|
||||
|
||||
> **"Response" 재분류 (Audit F3, OUT_OF_BRANCH_SCOPE)**: 이전 Registry Tables 의 "Response" family 는 별도 registry yaml 이 아님. response/error envelope schema 는 [[raw/branch-notes/feature-operational-error-observability-foundation]] 가 owner (project §21 "Response Envelope 요약", §3 envelope). registry 메커니즘이 아니라 envelope schema 이므로 7 registry 에서 제외 — 본 branch 결정 범위 밖, foundation 소유.
|
||||
|
||||
### 3. Schema-owner vs row-owner 분리 (본 branch 의 핵심 역할)
|
||||
|
||||
> **Trace**: D1 + D4 — Supporting: ground truth(7 yaml header `# Schema owner: feature-contract-registry-governance`; error-codes.yaml `# Category enum owner: ...`).
|
||||
|
||||
- 본 branch = **schema owner**: 모든 registry 의 column 구조 + 저장 형식(D3/D6) + 변경 절차(§4)의 SSOT. 어떤 column 이 있어야 하는가를 정함.
|
||||
- 각 registry **row owner** = sibling `owner_branch` (8개, §2 표). 어떤 row(code/key/name) 값이 존재하는가는 sibling 결정. 본 branch 는 row 값을 정의하지 않음.
|
||||
- **category enum 값** = foundation 소유(error-codes.yaml). 본 branch 는 `category` column 존재만 강제, enum 값(VALIDATION/AUTH/AUTHZ/…10개)은 foundation. → §엣지·실패·의존 cross-contract 의존.
|
||||
|
||||
### 4. Registry 변경 절차 (change procedure)
|
||||
|
||||
> **Trace**: D1(registry 없이 추가 금지) + D2(test 연결) + D7(markdown SSOT) — Supporting: AU-OFF-C1/C2. project §21 "Registry 변경 절차" 와 정합.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: step 6 의 TODO-drain mismatch 검사 주체/시점 — 현재 *수동 review* (자동 lint 미구현). trade-off: 수동 review(즉시·누락 위험) vs lint 자동화(구현 비용). 향후 `wiki_structure_lint.py` 확장 대상.
|
||||
|
||||
1. registry row를 먼저 추가.
|
||||
2. 관련 branch note의 Decision/Failure condition을 수정.
|
||||
3. contract test 또는 architecture test mapping을 추가.
|
||||
4. `.env.example`, OpenAPI snapshot, log assertion, metric assertion 중 영향받는 산출물을 갱신.
|
||||
5. backward compatibility 또는 migration 영향이 있으면 canonical 승급 전 기록 (`compatibility_impact` column 갱신).
|
||||
6. **TODO drain.** 이 branch의 결정이 표(Decisionized Work Items 또는 동등 표)로 반영되면 동일 branch 내 잔존 TODO 항목은 (a) 해당 표 row로 link 또는 (b) 삭제. "기준 작성" TODO를 표와 분리해 두는 패턴은 forbidden. branch note의 TODO 블록과 Decisionized 표의 row 수가 mismatch면 review에서 fail(수동 check, 향후 lint 자동화 대상).
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4 캡처용. 본 branch 는 schema/governance 층이므로 "다른 계약 의존" 이 핵심.
|
||||
|
||||
- **다른 계약 의존 (cross-contract)**:
|
||||
- **category enum** 값은 [[raw/branch-notes/feature-operational-error-observability-foundation]] 의 결정(category enum owner)에 의존 — 본 branch 는 `category` column schema 만 소유. foundation 이 enum 을 바꾸면 error-codes.yaml 의 `category` 값 전체가 영향(본 branch 의 schema 는 불변).
|
||||
- **각 registry row** 는 8개 sibling `owner_branch` 가 소유(delegated, §구현 가이드 §2). 본 branch 가 **universal column schema 를 바꾸면 7 registry 전부**가 동시 영향 → 항상 `breaking` 후보. 부분 적용 시 일부 registry 가 구 schema 로 남아 verification 실패.
|
||||
- **response/error envelope schema** 는 foundation 소유(registry 아님, Audit F3). 본 branch 가 정의하지 않음.
|
||||
- **headers ↔ mdc-keys ↔ metrics ↔ envelope** cross-link: 동일 식별자가 layer 별로 다른 표기(`X-Request-Id` kebab / `request_id` snake / `meta.requestId` camel)를 가짐 — 표기 매핑 SSOT 는 foundation(mdc-keys snake authoritative). schema 가 이 매핑 column(`mdc_key`/`envelope_meta_field`/`http_header_mapping`)을 보유해야 함.
|
||||
- **headers.yaml cross-owner**: HTTP Headers registry row 는 단일 owner 가 아니라 복수 — idempotency=[[raw/branch-notes/feature-rate-limit-idempotency-contract]] (`Idempotency-Key`/`Retry-After`), tracing=`traceparent`/`tracestate` (W3C-TC-C4), tenant/compat/security=각 owner branch. governing §21 도 "(cross-owner)" 로 인정. schema column(`direction`/`mdc_key`/`case_style`) 변경 시 이들 owner row 가 동시 영향. row *값* 위임은 §Coverage(api-contract-baseline primary).
|
||||
- **실패·엣지 경로**:
|
||||
- **markdown SSOT ↔ yaml drift**: 변환/동기화 도구 부재(현재 수기). row 누락 시 verification suite fail 해야 함 → §Claims To Verify.
|
||||
- **yaml header SSOT 경로 불일치**: yaml header `# SSOT: wiki/projects/ca-tmpl/registries/*.yaml` 가 실제 파일 위치(`docs/registries/`)와 다름 — 추출 전 canonical placeholder. 추출 시점까지 "현재 위치 ≠ header 표기" 를 인지해야 함(헷갈림 방지).
|
||||
- **stale enum 주석 (OUT_OF_BRANCH_SCOPE)**: `error-codes.yaml` L580 주석에 deprecated `PERSISTENCE` 문자열 잔존(actual enum 은 10-value, `PERSISTENCE` 없음). 이는 category enum 영역(foundation 소유)이며 본 branch schema 범위 밖 → foundation 에 정합 권고만(자동 rewrite 금지).
|
||||
- **외부 표준 rename 전환기 (dual-emit)**: OTel `OTEL_SEMCONV_STABILITY_OPT_IN=http/dup` (OTEL-HM-C5) 처럼 old + new token 이 동시 활성인 기간 — D5 mapping row 가 old/new 를 구분하려면 version 구분 column 필요. mapping row 의 구체 column schema 는 D4 family-specific(미표준화) 영역.
|
||||
- **신규 registry family 추가 시**: universal-3 로 표현 불가한 family-specific column 발생 가능 — schema 확장 결정 필요(어떤 column 을 universal 로 승격할지 기준 부재, D4 Open Risk).
|
||||
|
||||
## Audit & Findings (2026-06-15 — ca-tmpl ground truth 대조)
|
||||
|
||||
> `/branch-spec` 가 ca-tmpl `docs/registries/*.yaml` + project §21 + 8 sibling branch 와 대조해 발견한 drift. 사용자 작성 결정을 덮어쓰지 않고 **append-only 정합**(결정 사항 2026-06-15 라인) + 본 § 기록. 원 결정 이력은 §결정 사항 2026-05-22 라인에 보존.
|
||||
|
||||
| ID | 유형 | 발견 | 정합 조치 |
|
||||
|---|---|---|---|
|
||||
| F1 | `PATH_DRIFT` | Registry Storage Contract/D3 의 `src/main/resources/contract-registry/*.yml` 경로가 코드에 미구현(0 hits). 실제 yaml 은 `ca-tmpl/docs/registries/*.yaml`(D6 와 일치) | 구현 가이드 §1 을 docs/registries 로 정합, D3 Decision 텍스트 정정, 결정 사항 2026-06-15(F1) 추가. 원 D3 라인은 §결정 사항 2026-05-22 에 보존 |
|
||||
| F2 | `SCHEMA_DRIFT` | D4 의 uniform 7-column(`name/owner_branch/owner_layer/default/allowed_values/compatibility_impact/required_test`)이 as-built 미채택. universal 은 3개(`owner_branch`/`compatibility_impact`/`required_test`)뿐, `owner_layer`=error only, `default`/`allowed_values`=env only | D4 Decision 을 as-built(universal 3 + identity + family-specific)로 갱신, 초기 제안 미채택 이력 명시. 결정 사항 2026-06-15(F2) 추가 |
|
||||
| F3 | `FAMILY_COUNT_DRIFT` | Registry Tables 가 6 family(+phantom "Response"). as-built/governing §21 은 7 family — "Log/Metric/Trace"→mdc-keys+metrics 분리, Secrets Classification 추가, "Response"=foundation envelope(registry 아님) | 구현 가이드 §2 를 as-built 7 family 로 갱신, "Response" 재분류(OUT_OF_BRANCH_SCOPE). 결정 사항 2026-06-15(F3) 추가 |
|
||||
| F4 | `STALE_COMMENT` (OUT_OF_BRANCH_SCOPE) | `error-codes.yaml` L580 주석에 deprecated `PERSISTENCE` 잔존 | category enum = foundation 소유 → 본 branch schema 영역 밖. foundation 에 정합 권고만(자동 수정 안 함). §엣지·실패·의존 기록 |
|
||||
|
||||
## 테스트 계약
|
||||
|
||||
- error code가 registry 없이 사용되면 실패.
|
||||
- env key가 registry와 `.env.example`에 없으면 실패.
|
||||
- log/metric field가 registry naming과 다르면 실패.
|
||||
- registry 변경 없이 response/header/capability 상수가 추가되면 실패.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서나 사례는 근거지만, 내 프로젝트의 실 동작을 자동으로 보장하지 않음.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| markdown table SSOT 가 yaml/generated constants 로 변환되어도 row 누락 없이 일관 유지된다 | D3 — markdown ↔ yaml 변환의 공식 도구/스크립트 부재(현재 수기). AAR-C1/C2 는 annotation 한계만 보임 | Phase B 진입 시 markdown → yaml 변환 스크립트 작성 + `diff` 로 row count 일치 검증 + drift 시 CI fail rule 추가 | `planned` |
|
||||
| universal-3 column (`owner_branch`/`compatibility_impact`/`required_test`) + family-specific column 모델이 7 registry 전부에 충분하다 | D4 — as-built 로 7 family 가 family-specific column 을 실제로 사용함은 확인(F2). 다만 신규 registry 추가 시 universal-3 만으로 부족할 가능성 + 어떤 column 을 universal 로 승격할지 기준 부재 | 신규 registry 후보(예: rate-limit policy / feature-flag) 에 universal-3 적용 walkthrough → 부족 시 universal 승격 기준 결정 | `planned` |
|
||||
| ArchUnit 만으로 "registry 에 없는 contract token 의 사용" 을 정적으로 탐지 가능 | AU-OFF-C2 + AAR-C4 의 fitness function 능력 한계 — registry 와 코드의 cross-reference 검사가 ArchUnit DSL 로 가능한지 PoC 필요 | sample error code (registry 부재) 를 코드에 추가 → ArchUnit `noClasses().that()...should().notHaveCode().that().isNotIn(REGISTRY)` 식 custom rule PoC → 탐지 성공 여부 | `planned` |
|
||||
| `.env.example`, OpenAPI snapshot, log assertion, metric assertion 이 registry 변경 시 자동으로 drift 탐지 | D1, D7 — 4종 산출물 ↔ registry 의 cross-check 도구 부재 | env: dotenv-linter / OpenAPI: openapi-diff / log: logback test appender / metric: micrometer test registry 각각의 CI step PoC | `planned` |
|
||||
| ADR 별도 파일 없이 branch-note 의 "결정 사항" 라인이 mini-ADR 로 작동 (Status/Context/Decision/Consequences 매핑) | REG-ADR-C2 "ADR captures a single AD" — 1-decision-1-file 모델과 branch-note 의 "결정 사항 누적" 모델의 trade-off 검증 필요 | branch-note 의 한 결정 라인을 MADR 포맷으로 변환 시도 → 4 section 모두 채워지는지 + 별도 파일 가치 평가 | `needs-confirmation` |
|
||||
| 외부 platform 표준 (OpenTelemetry / RFC 7807→9457 / W3C) 사용 시 mapping row 가 가독성 손실 없이 표현 | D5 — OTel versioning spec 은 breaking change MAY occur + MUST describe in Schema File 확인 (OTEL-VS-C1/C4/C5). RFC9457-C1/C5 로 RFC 7807→9457 obsolete 사실 확인, W3C-TC-C4 로 tracestate 병행 권고 확인. 단 mapping row 의 구체적 column schema(`external_standard`/`external_token_name`/`external_version`)는 family-specific(미표준화, D4). ca-tmpl error envelope 의 RFC 9457 compliant 여부 미검증 | OTel log/metric registry 에 최소 3 row 추가 후 mapping column 으로 표현 가능한지 walkthrough; error registry 에 RFC 9457 type URI mapping row 추가 PoC (RFC9457-C2 근거 — `type` URI 가 primary identifier) | `planned` |
|
||||
| company-tech-blog (카카오뱅크 Modulith / 우아한형제들 Hexagonal) 사례는 official best practice 가 아니라 case study 임을 본 결정 라인이 명시한다 | "company-tech-blog → 공식 best practice" 격상 금지 (CLAUDE.md §5). 현 branch 의 결정 라인이 carry over 하는지 검증 | branch-note 의 모든 결정 라인 grep → company-tech-blog 인용이 "공식 best practice" 표현으로 격상되지 않았는지 확인 | `planned` |
|
||||
|
||||
## 관심사 커버리지
|
||||
|
||||
> `/coverage` 가 채우는 생성물 — governing §21 (raw/project-notes/ca-skeleton-operational-contract) 이 요구하는 관심사를 이 브랜치가 빠짐없이 덮는지. 기준: `rules/coverage-gate.md`. 본 branch 는 schema/governance owner 이므로 registry **값** 은 sibling 에 위임(delegated), **schema·저장·절차** 는 covered-here.
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|--------|------|-------|--------|------|
|
||||
| registry 공통 schema (column 구조) | covered-here | — | — | D4, 구현 가이드 §2 |
|
||||
| registry 저장 형식·경로 | covered-here | — | — | D3/D6, 구현 가이드 §1 |
|
||||
| registry 변경 절차 | covered-here | — | — | D1/D2/D7, 구현 가이드 §4 |
|
||||
| schema-owner vs row-owner 분리 | covered-here | — | — | 구현 가이드 §3 |
|
||||
| Error Codes registry 값 | delegated | [[raw/branch-notes/feature-operational-error-observability-foundation]] | OK | 구현 가이드 §2 |
|
||||
| Error category enum 값 | delegated | [[raw/branch-notes/feature-operational-error-observability-foundation]] | OK | §엣지·실패·의존 + Audit F4 |
|
||||
| Env Keys registry 값 | delegated | [[raw/branch-notes/feature-env-driven-runtime-configuration]] | OK | 구현 가이드 §2 |
|
||||
| Secrets Classification 값 | delegated | [[raw/branch-notes/feature-secrets-config-source-contract]] | OK | 구현 가이드 §2 |
|
||||
| HTTP Headers registry 값 | delegated | [[raw/branch-notes/feature-api-contract-baseline]] | OK | 구현 가이드 §2 |
|
||||
| MDC / Log Keys 값 | delegated | [[raw/branch-notes/feature-operational-error-observability-foundation]] | OK | 구현 가이드 §2 |
|
||||
| Metrics registry 값 | delegated | [[raw/branch-notes/feature-metrics-alerting-contract]] | OK | 구현 가이드 §2 |
|
||||
| Capabilities registry 값 | delegated | [[raw/branch-notes/feature-repository-access-permission-contract]] | OK | 구현 가이드 §2 |
|
||||
| Response / error envelope schema | delegated | [[raw/branch-notes/feature-operational-error-observability-foundation]] | OK | 구현 가이드 §2 (Response 재분류, Audit F3) |
|
||||
| SENSITIVE_READ 메타표(entity FQN+field) + field-level enforcement (← [[raw/branch-notes/feature-repository-access-permission-contract]] 위임 수신) | documented-defer | 본 branch (schema governance), row=`planned` | OK (ack) | 위임 수신 확인. sensitive-field metadata table 은 별도 registry 로 본 branch 의 schema governance 적용 대상이나, 도메인 entity 부재로 row 는 `planned`(아직 sensitive-fields.yaml 미존재). capabilities 의 `SENSITIVE_READ` *어휘* 는 feature-repository-access-permission-contract 소유 |
|
||||
|
||||
> **위임 수신 (incoming delegation, 2026-06-15)**: [[raw/branch-notes/feature-repository-access-permission-contract]] 가 `SENSITIVE_READ` 의 *메타표(entity FQN + field) + field-level enforcement* 를 본 branch 에 `documented-defer` 로 위임했다(그 branch §Coverage). governing §21 은 이 메타표를 7 registry 로 *명시 요구하지 않으므로* coverage Blocking 은 아니나, 본 branch 가 수신을 명시한다: sensitive-field 메타표는 향후 별도 registry(예: `sensitive-fields.yaml`)로 본 branch 의 registry schema governance(D4 universal-3 + family-specific) 를 적용해 정의한다. 도메인 entity 가 도입되기 전까지 row 는 `planned` — 현재 ca-tmpl `docs/registries/` 에 해당 yaml 부재. *어휘*(`SENSITIVE_READ` capability 자체)는 capabilities.yaml owner(feature-repository-access-permission-contract) 소유로 유지.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 2026-06-20 (Phase C2): schema-owner gate 구현 중, `secrets-classification.yaml` 의 15 row 중 5개(Tier-1 public-config: APP_PROFILE / APP_NAME / SERVER_PORT / SPRING_PROFILES_ACTIVE / OTEL_EXPORTER_OTLP_ENDPOINT)가 universal-3 의 `compatibility_impact`/`required_test` 를 의도적으로 생략(`reference:` 로 `env-keys.yaml` 에 위임, 파일 헤더 L17). 모든 row 에 universal-3 를 요구하는 naive 게이트는 이 5 row 에서 false-FAIL 한다. → 게이트를 "reference row(=`reference:` 키 보유)는 contract column 면제, identity+`owner_branch`+reference target 만 요구" 로 모델링해 해소. 상세: [[raw/errors/contract-registry-reference-row-universal-column-false-fail-2026-06-20]].
|
||||
|
||||
## 완료 후 wiki 추출 대상
|
||||
|
||||
- `wiki/projects/ca-skeleton-operational-contract.md` (또는 canonical `wiki/projects/ca-tmpl.md` §Contract Registry) 의 contract registry canonical section.
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/archunit-annotation-as-registry-evaluation]]
|
||||
- [[raw/official-docs/governance-archunit-official]]
|
||||
- [[raw/official-docs/opentelemetry-http-semconv-migration-guide]]
|
||||
- [[raw/official-docs/opentelemetry-versioning-stability-spec]]
|
||||
- [[raw/official-docs/registry-adr-official]]
|
||||
- [[raw/official-docs/rfc9457-problem-details-http-apis]]
|
||||
- [[raw/official-docs/trace-context-w3c-recommendation]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: errors:start -->
|
||||
- [[raw/errors/contract-registry-reference-row-universal-column-false-fail-2026-06-20]]
|
||||
<!-- GENERATED: errors:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/contract-registry-schema-owner-vs-row-owner-gate-2026-06-20]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
> 본 feature branch 는 leaf — 자식 자료 없음. Phase C2 실 코드 작성 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록 (본 feature 작업 중 발생)
|
||||
|
||||
- [[raw/errors/contract-registry-reference-row-universal-column-false-fail-2026-06-20]] — reference row 면제를 누락한 naive schema 게이트의 false-FAIL 함정(resolved, 2026-06-20).
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — 추가 추출 없음. schema-owner vs row-owner 분리 논점은 아래 Blog topics 로 캡처.)
|
||||
|
||||
### Blog topics
|
||||
|
||||
- [[raw/blog-topics/contract-registry-schema-owner-vs-row-owner-gate-2026-06-20]] — multi-owner registry 의 schema-owner vs row-owner 분리를 cross-file 정합 테스트로 박제하는 패턴(Phase C2 schema-owner gate 에서 추출).
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- (아직 연결된 일일 노트 없음 — 현재 문서 단계. 실 구현 착수 시 작업일 daily note 를 양방향 연결.)
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-contract-verification-test-suite.md
|
||||
@@ -0,0 +1,427 @@
|
||||
---
|
||||
title: branch / feature-contract-verification-test-suite
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-contract-verification-test-suite
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||||
tags: [branch, ca-skeleton, test, contract, verification]
|
||||
created: 2026-05-21
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-010
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-010
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-OPENAPI-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-TEST-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: 15121967b54182a52d343b3a87b21d692dc3bb7a28edb3f75d306f2b09e74d63
|
||||
---
|
||||
|
||||
# branch: feature-contract-verification-test-suite
|
||||
|
||||
> Layer: `raw/branch-notes/` — 운영 계약을 테스트로 강제하는 통합 검증 기준을 정의합니다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (§12 Test Contract · §13 API Contract Surface · §16 Schema/Serialization · §18 CI Quality Gates) 의 결정/근거/금지 사항을 정제한다.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: release-blocking contract suite가 OpenAPI drift를 검출한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-OPENAPI-001@1` | verification suite가 OpenAPI drift의 release-blocking 판정권을 소유한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-TEST-001@1` | test framework는 JUnit 5다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
이 skeleton의 핵심은 기능이 아니라 계약입니다. branch별 기준이 문서에만 있으면 쉽게 깨집니다. 공통 contract verification suite로 response, log, env, boundary, repository capability, adapter failure mapping을 강제합니다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- structured response contract test.
|
||||
- validation field error contract test.
|
||||
- raw exception leakage test.
|
||||
- structured log field test.
|
||||
- PII/token/body log forbidden test.
|
||||
- retryable classification test.
|
||||
- env profile matrix smoke test.
|
||||
- repository capability violation test.
|
||||
- adapter failure mapping test.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- business use case acceptance test.
|
||||
- load test.
|
||||
- provider integration E2E test.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/verification-approvaltests-snapshot-official]] | ApprovalTests JSON snapshot (ca-tmpl 채택 |
|
||||
| [[raw/official-docs/verification-pact-cdc-official]] | Pact 공식이 "consumer-known subset만 검증" 직접 인정 → single-team에서 snapshot 우위 |
|
||||
| [[raw/official-docs/verification-spring-restdocs-official]] | test-driven docs, docs quality 강점이나 contract 검증 weak |
|
||||
| [[raw/official-docs/verification-spring-cloud-contract-official]] | stub-runner 강점이나 stub 정의 별도 작성 부담 |
|
||||
| [[raw/official-docs/openapi-spec-3-1-0]] | OpenAPI Specification v3.1 — OpenAPI drift gate 의 SSOT 가 되는 machine-readable HTTP API contract 표준 (D5/D6 OpenAPI drift release-blocking 결정의 normative 근거) |
|
||||
| [[raw/official-docs/spring-framework-test-enabledif-jupiter-annotation]] | D3: @EnabledIf 가 Spring Environment property placeholder 를 읽어 true 일 때만 테스트를 실행 (그 외 SKIPPED) — optional adapter contract test 를 adapter enabled env matrix 에서만 실행하는 공식 근거 |
|
||||
| [[raw/official-docs/junit5-conditional-env-variable-user-guide]] | D3 보강: JUnit 5 공식 `@EnabledIfEnvironmentVariable` / `@DisabledIfEnvironmentVariable` — OS 환경 변수 undefined 시 DISABLED(SKIPPED, never FAILED) 보장, named+matches regex 속성, 5.6+ repeatable |
|
||||
|
||||
## 외부 근거 / 대안 조사 (2026-05-22 — Group G-G: Contract Verification Test Suite)
|
||||
|
||||
본 branch의 11 release-blocking gates + JSON snapshot (approvaltests) + Pact CDC out-of-scope 결정에 대한 외부 source.
|
||||
|
||||
- **채택 결정 (snapshot test + OpenAPI drift)**:
|
||||
- [[raw/official-docs/verification-approvaltests-snapshot-official]] — ApprovalTests JSON snapshot (ca-tmpl 채택)
|
||||
- **검토한 대안**:
|
||||
- **대안 1: Pact CDC (consumer-driven contract)** — [[raw/official-docs/verification-pact-cdc-official]] (Pact 공식이 "consumer-known subset만 검증" 직접 인정 → single-team에서 snapshot 우위)
|
||||
- **대안 2: Spring REST Docs** — [[raw/official-docs/verification-spring-restdocs-official]] (test-driven docs, docs quality 강점이나 contract 검증 weak)
|
||||
- **대안 3: Spring Cloud Contract** — [[raw/official-docs/verification-spring-cloud-contract-official]] (stub-runner 강점이나 stub 정의 별도 작성 부담)
|
||||
- **대안 4: Hoverfly / WireMock service virtualization** — 외부 의존성 mock, contract 검증 자체는 아님
|
||||
- **비교 핵심**: snapshot(full schema) + OpenAPI drift는 single-team skeleton에서 합당. CDC는 외부 consumer 등장 시점이 도입 임계점 — ca-tmpl out-of-scope 결정은 Pact 공식 입장과 정합. Spring REST Docs는 docs quality 강점이나 contract 위반 검증력 약함.
|
||||
- **D3 (optional adapter 조건부 실행) 대안 비교 (2026-06-15 자동조사)**: ① JUnit 5 `@EnabledIfEnvironmentVariable` (primary — env undefined → SKIPPED 공식 보장, Gradle 버전 무관, JUnit XML `<skipped>` 집계 가능) ② Spring `@EnabledIf` SpEL/property-placeholder (보완 — env+profile AND 복합 조건 / Spring Environment 바인딩 필요 시; JUnit 5.7+ 동명 어노테이션 import 충돌 주의) ③ `@Tag` + Gradle `includeTags` 태스크 분리 (보류 — Gradle 9.0 커스텀 Test 태스크 includeTags regression [gradle#35907], CI step skip 이라 JUnit 리포트에 SKIPPED 미집계). 권고: Alt1 primary + Alt2 보완.
|
||||
|
||||
## TODO
|
||||
|
||||
> TODO drained 2026-05-22 — 통과 기준은 아래 "결정 사항" / "판정 기준" / "Verification Ownership Matrix" / "테스트 계약" 참조. **11개 release-blocking gates** = 9 base contract tests + OpenAPI drift + sample removal smoke. (base 9개: response schema, validation exposure, raw exception leakage, log field, PII/token/body forbidden, retryable, env matrix, repository capability, adapter failure. 추가 2개: OpenAPI drift, sample removal smoke.)
|
||||
>
|
||||
> 잔존 미해결 TODO (retain):
|
||||
- ~~PII/token/body log forbidden 구현 메커니즘~~ closed 2026-05-22: structured field whitelist + Logback masking 이중 layer.
|
||||
- Layer 1 (Logback): custom `%mask` converter가 PatternLayout 단계에서 `(?i)(token|password|authorization|cookie|secret|key)\s*[=:]\s*[^*\s]+` regex 매칭 시 `****`로 치환.
|
||||
- Layer 2 (Jackson): DTO field에 `@JsonSerialize(using=MaskingSerializer.class)` 명시. 미명시 PII field가 ObjectMapper로 serialize되면 archetype test fail.
|
||||
- Verification test: JUnit + Logback ListAppender로 모든 log event capture. 다음 2 assertion: (a) capture된 log line에 위 regex 매칭 0건. (b) structured log JSON의 field name이 `mdc-keys.yaml`의 `log type별 allowed fields` 외 값 0건. 위반 시 fail.
|
||||
- request body capture filter: default `spring.web.body-capture.enabled=false`. true로 활성화하려면 `allowed-content-types` 명시 + endpoint allowlist 필수.
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- 이 branch는 모든 branch의 마지막 safety net입니다.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- 2026-05-21: 문서 기준은 테스트로 강제되어야 canonical 승급 대상이 됨.
|
||||
- 2026-05-22: contract violation은 CI에서 release-blocking failure로 취급.
|
||||
- 2026-05-22: optional adapter contract test는 adapter enabled env matrix에서만 실행.
|
||||
- 2026-05-22: sample-portfolio fixture는 boundary/repo/transaction/error/log contract의 기준 fixture로 사용.
|
||||
- 2026-05-22: OpenAPI/schema drift release-blocking 집행권은 이 branch가 단일 owner. API/schema/compatibility branch는 snapshot producer 또는 compatibility rule producer.
|
||||
- 2026-05-22: verification suite는 **11개 release-blocking gates** = 9 base contract tests + OpenAPI drift + sample removal smoke. (base 9개: response schema, validation exposure, raw exception leakage, log field, PII/token/body forbidden, retryable, env matrix, repository capability, adapter failure. 추가 2개: OpenAPI drift, sample removal smoke.)
|
||||
- 2026-06-15: D3 조건부 실행 메커니즘을 JUnit 5 `@EnabledIfEnvironmentVariable` primary + Spring `@EnabledIf` 보완으로 확정 (자동조사 근거 archive). `@Tag`+Gradle 분리는 Gradle 9.0 regression 으로 보류.
|
||||
- 2026-06-20: (A) ArchUnit contract-isolation rule (`ContractSuiteIsolationArchTest`) 구현 완료. manual-importer 패턴, PACKAGE_DRIFT 해소 (`dev.caskeleton` 기준 `..` wildcard), 3-method: clean-check + positive-control + over-block guard. (B) `ContractSuiteCompletenessTest` 구현 완료 — 9 base contract class 를 `Class.forName` release-blocking enumerate. `actually-implemented`, `locally-verified` (Gradle :app-bootstrap:test PASS, 4 test methods).
|
||||
- 2026-06-20: **suite 전체 구현 완료** (`actually-implemented`, `locally-verified` — `./gradlew check` BUILD SUCCESSFUL 1m28s, ca-architect-sentinel PASS 0 blocking). 사용자 확정 결정 2건: ① OpenAPI drift gate = **committed-snapshot 동등 비교** (`openapiCheckSnapshot` task + `-PapproveOpenApiChange` refresh, `verifyPublicPathSnapshot` 패턴 미러; 의미론적 additive/breaking 분류는 api-compatibility branch 레이어로 유지). ② delegated 경계 = **이 branch 검증물만** (sample `@ConditionalOnProperty` wiring · `.github` CI yaml · trace-propagation test 는 타 branch 소유 — 미구현, skip-not-pass/assert-core-green 으로 부재에 robust). 신규: `EnvelopeContractTest`(approvaltests 3 snapshot), `StructuredLogFieldContractTest`, `PiiTokenBodyForbiddenContractTest`(Logback ListAppender), `EnvProfileMatrixContractTest`, `OptionalAdapterConditionalExecutionContractTest`(6 composed `@EnabledIf*` + EngineTestKit SKIP proof), `SampleRemovalSmokeContractTest`, `OpenApiDriftContractTest`(sample-portfolio, servers block strip 으로 RANDOM_PORT 비결정성 제거). 도구: `approvaltests-java:31.0.0` + `junit-platform-testkit` (app-bootstrap testImpl).
|
||||
- 2026-06-20: approvaltests 스냅샷 파일을 test 소스 옆이 아닌 전용 `contract/approved/` 하위폴더로 격리. 메커니즘 = `dev.caskeleton.bootstrap.contract.PackageSettings` 클래스의 `public static String UseApprovalSubdirectory = "approved"` (approvaltests 의 `org.packagesettings` 라이브러리가 package 계층을 따라 `PackageSettings` 를 찾아 필드를 읽음). **`.approvaltests.json` 은 approvaltests-java 에서 동작하지 않음** (raw/errors 후보 — .NET 포트의 config 와 혼동 주의; Java 는 `PackageSettings` 클래스 필드 방식).
|
||||
- 2026-06-20: **§7 Layer 2 (Jackson `MaskingSerializer`) 미구현 — 아키텍처 제약**. masking SSOT `LogMaskingPatterns` 는 `app-bootstrap` 소재인데 DTO 가 사는 `adapter-web` 는 `app-bootstrap` 의존 금지(역방향). `shared-contract` 는 Jackson-free. 따라서 clean Layer-2 serializer 는 masking SSOT 를 `shared-contract` 로 relocate(타 branch production 변경, verification-only scope 밖)하거나 regex 중복(SSOT 훼손) 없이는 불가. gate #5 의 **검증**(Layer-1 런타임 masking + body-capture-disabled)은 `PiiTokenBodyForbiddenContractTest` 로 완료. ca-architect-sentinel 이 이 omission 이 아키텍처적으로 옳음을 독립 확인. → Layer-2 production serializer 는 log-management/boundary branch 의 후속 결정으로 이관.
|
||||
|
||||
## Work Item Contract
|
||||
|
||||
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
|
||||
|
||||
| field | required | rule |
|
||||
| --- | --- | --- |
|
||||
| Decision | yes | 구현자가 선택해야 하는 기본값 |
|
||||
| Allowed | yes | 허용되는 예외와 조건 |
|
||||
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
|
||||
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
|
||||
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
|
||||
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
|
||||
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. `Decision ID` 는 이 branch-note 안에서 안정적으로 유지.
|
||||
|
||||
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|
|
||||
| D1 | 문서 기준은 테스트로 강제되어야 canonical 승급 대상이 됨 | UNSUPPORTED_DECISION (내부 governance 결정 — 외부 source 직접 증명 없음) | `team-policy` | ca-tmpl 운영 계약 자체의 원칙 |
|
||||
| D2 | contract violation은 CI에서 release-blocking failure로 취급 | `raw/official-docs/test-taxonomy-practical-pyramid-fowler.md#TPP-FOWLER-C4` (CDC workflow의 책임 분배 — provider 가 contract test 를 green 으로 유지) | `engineering-blog` | Fowler 인용은 워크플로우 정의일 뿐, "release-blocking" 강도까지 직접 보장 안 함. release-blocking CI 배선 자체의 owner 는 `feature-ci-quality-gates-contract` (delegated) |
|
||||
| D3 | optional adapter contract test는 adapter enabled env matrix에서만 실행 (skipped, not failed) | primary (Alt 1): `raw/official-docs/junit5-conditional-env-variable-user-guide.md#JUNIT5-ENV-C1` (`@EnabledIfEnvironmentVariable` named+matches regex 일치 시만 enabled), `#JUNIT5-ENV-C2` (env var undefined → DISABLED = SKIPPED, never FAILED). 보완 (Alt 2): `raw/official-docs/spring-framework-test-enabledif-jupiter-annotation.md#SPRING-ENABLEDIF-C1` (`@EnabledIf` 표현식 true 일 때만 실행), `#SPRING-ENABLEDIF-C2` (Spring Environment property placeholder gate) | `official-vendor-doc` (JUnit 5 + Spring Framework) | adapter enabled property key ↔ annotation 매핑은 구현 단계 검증 필요 (SPRING-ENABLEDIF-C2 Does-not-prove: property 소스 우선순위 미명시). Alt 3(@Tag+Gradle includeTags)은 Gradle 9.0 regression(gradle#35907)로 보류 |
|
||||
| D4 | sample-portfolio fixture는 boundary/repo/transaction/error/log contract의 기준 fixture로 사용 | UNSUPPORTED_DECISION (ca-tmpl 내부 fixture 관례) | `team-convention` | sample fixture 의 prod leakage 방지 (test taxonomy branch D8 와 cross-link). flag(`APP_SAMPLE_ENABLED`)+adoption owner 는 `feature-sample-removal-adoption-contract` (delegated) — 본 branch 는 removal smoke 만 verify |
|
||||
| D5 | OpenAPI/schema drift release-blocking 집행권은 이 branch가 단일 owner | (조직 ownership 결정 — 외부 표준이 owner 분리를 강제하지 않음) supporting: `raw/official-docs/openapi-spec-3-1-0.md#OPENAPI31-C2` (OAS = HTTP API 의 standard, machine-readable contract — drift 의 diff 대상이 표준화된 spec 임을 corroborate), `#OPENAPI31-C3` (OAS document 의 single vs split 구조 — drift gate 가 spec 파일을 다루는 근거). 정합: project §25 SSOT Owner Map ("OpenAPI / schema drift" owner = 본 branch) | `official-standard` (drift 대상 spec 자체) + `team-policy` (owner 분리) | 외부 표준은 OAS 가 drift 대상으로 적절함을 보장할 뿐, "single owner" governance 자체는 ca-tmpl 운영 결정. API/schema compatibility branch 와의 책임 경계 명확화 필요 |
|
||||
| D6 | verification suite는 11개 release-blocking gates (9 base contract + OpenAPI drift + sample removal smoke) | `raw/official-docs/verification-approvaltests-snapshot-official.md#AT-OFFICIAL-C2` (complex object 비교 패턴) + `raw/official-docs/verification-approvaltests-snapshot-official.md#AT-OFFICIAL-C3` (approve workflow) + `raw/official-docs/openapi-spec-3-1-0.md#OPENAPI31-C1` (OAS normative keyword 해석 BCP 14), `#OPENAPI31-C2` (OAS = HTTP API contract 의 표준), `#OPENAPI31-C4` (Data Type = JSON Schema 2020-12 base — drift diff 의 type 어휘 표준화), `#OPENAPI31-C7` (Schema Object = JSON Schema 2020-12 superset) | `official-vendor-doc` (snapshot 도구) + `official-standard` (OAS drift gate 의 spec SSOT) | 11개 gate 의 정확한 enumeration 자체는 ca-tmpl 내부 결정. OpenAPI drift gate 도구 (openapi-diff / oasdiff) 의 OAS 3.1 호환성은 별도 검증 필요 (OPENAPI31-C7 Does-not-prove: JSON Schema 2020-12 의 모든 keyword 가 OAS 에서 동작하는 것은 아님) |
|
||||
| D7 | contract test 도구 = JSON snapshot test (`approvaltests-java`) — envelope/error/log/env shape 검증 | `raw/official-docs/verification-approvaltests-snapshot-official.md#AT-OFFICIAL-C1`, `raw/official-docs/verification-approvaltests-snapshot-official.md#AT-OFFICIAL-C2`, `raw/official-docs/verification-approvaltests-snapshot-official.md#AT-OFFICIAL-C3` | `official-vendor-doc` | ApprovalTests 공식은 일반 complex object 만 언급 — envelope/error/log shape 시나리오 적합성은 추가 검증 필요. **ground truth: approvaltests-java 는 현재 ca-tmpl 미의존 (planned)** — §Audit & Findings 참조 |
|
||||
| D8 | Pact CDC 는 out-of-scope (boundary 외부 통합 시만 도입) | `raw/official-docs/verification-pact-cdc-official.md#PACT-OFFICIAL-C4` (consumer-known subset 만 검증), `raw/official-docs/verification-pact-cdc-official.md#PACT-OFFICIAL-C5` (provider-only 한계 — multi-consumer 맥락) | `official-vendor-doc` (Pact 자체가 single-team subset 한계를 명시) | 외부 partner consumer 등장 시 도입 임계점은 ca-tmpl 별도 판단 |
|
||||
| D9 | Spring Cloud Contract 도 동일 사유 out-of-scope | `raw/official-docs/verification-spring-cloud-contract-official.md#SCC-OFFICIAL-C1` (CDC umbrella project), `raw/official-docs/verification-spring-cloud-contract-official.md#SCC-OFFICIAL-C3` (Stub Runner = consumer-side 도구) | `official-vendor-doc` (CDC 정체성 자체가 multi-consumer 가정) | Spring REST Docs (`SRD-C1`, `SRD-C2`, `SRD-C3`) 는 docs 품질 도구로 별도 분류 — drift gate 책임 다름 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> *결정 (Decisions)* 이 "*무엇*" 이라면 본 §는 "*어디에 어떻게* 구현될 것인가" 의 사전 명세 — 다음 구현자가 되묻지 않고 코드를 작성할 수 있는 수준.
|
||||
>
|
||||
> **ground truth 정합 주의**: §2 ca-tmpl 코드 대조 결과 본 suite 는 대부분 **planned** 상태(자세히는 §Audit & Findings). 아래 표의 `as-built` 열은 `/home/donghyeon/workspace/ca-tmpl` 실 코드 grep 기반이며, `status` = `exists`(코드에 있음) / `partial`(도메인 특화 테스트로 일부) / `planned`(미구현). 명칭/glob 은 코드 확인 전까지 `planned`.
|
||||
|
||||
### 1. Contract test 디렉터리 배치 + 도메인 격리 강제
|
||||
|
||||
> **Trace**: D1(테스트 강제) + D7(snapshot 도구) ← `AT-OFFICIAL-C1`. 테스트 계약 §1(ArchUnit isolation) 의 구현 사전명세.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: ArchUnit regex-negation rule 형태(`..contract..` should-not depend-on `..features.(?!sample)..`) + `features.sample` allowlist 는 사용자 임의 trade-off — ApprovalTests/ArchUnit 공식은 "레이어 격리" 원칙만 권고, 정확한 glob 은 권고하지 않음. trade-off: regex 부정으로 sample 만 예외 허용 vs allowlist 명시 나열(유지보수 ↑, 명시성 ↑).
|
||||
|
||||
| 항목 | planned 명세 | as-built (ca-tmpl) | status |
|
||||
|---|---|---|---|
|
||||
| 디렉터리 | 각 module `src/test/**/contract/` | `app-bootstrap/.../contract/` 만 populated; `adapter-web`/`adapter-outbound`/`shared-contract` 의 `contract/` 는 `.gitkeep` 빈 placeholder | partial |
|
||||
| 격리 rule | ArchUnit `noClasses().that().resideIn("..contract..").should().dependOnClassesThat().resideInAPackage("..features.(?!sample).+..")` | **`ContractSuiteIsolationArchTest` 구현됨** (`app-bootstrap/.../architecture/ContractSuiteIsolationArchTest.java`). 3 @Test: clean-check (non-vacuity guard + eval), positive-control, over-block guard. PACKAGE_DRIFT 해소: `..` wildcard 로 base-package-agnostic. NOTE: ArchUnit 이 regex negation 미지원이므로 `resideInAPackage("..features..").and(resideOutsideOfPackage("..features.sample.."))` 로 compose. | **exists** (`actually-implemented`, `locally-verified`) |
|
||||
|
||||
> ⚠️ **PACKAGE_DRIFT**: 테스트 계약 §1 의 glob 은 `com.example.caskeleton.features.*` 를 가정하나 ca-tmpl 실 base package 는 `dev.caskeleton`. 구현 시 glob 을 `dev.caskeleton..features..` 기준으로 정정. (사용자 작성 결정 영역이므로 본 §은 정합 권고만; 자동 rewrite 안 함 — §Audit & Findings.)
|
||||
|
||||
### 2. 9 base contract test class 인벤토리 + as-built 매핑
|
||||
|
||||
> **Trace**: D6(11 gates) ← `AT-OFFICIAL-C2`/`C3`. 테스트 계약 §2(9 base enumeration) 의 구현 사전명세.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 각 test class 의 정확한 명칭(`EnvelopeContractTest` 등) + "9개를 단일 `contract/` 디렉터리로 묶는" 구조는 사용자 임의 명명 — 공식 근거는 snapshot 패턴만 권고. trade-off: generic 단일 suite(중복 ↓, 응집 ↑) vs adapter-specific 분산(이미 일부 존재, 재사용).
|
||||
|
||||
| # | base contract | planned suite class | as-built (ca-tmpl) | status |
|
||||
|---|---|---|---|---|
|
||||
| 1 | envelope/response schema | `EnvelopeContractTest` | `adapter-web/.../envelope/EnvelopeBodyAdviceTest`, `EnvelopeMetaIntegrationTest` (NOT in `contract/`, 명칭 다름) | planned(generic) / partial(behavior) |
|
||||
| 2 | validation exposure | (planned) | `BusinessRuleValidationContractTest` (category 매핑 일부) | partial |
|
||||
| 3 | raw exception leakage | (planned) | `BusinessRuleValidationContractTest#no_client_safe_message_leaks_sql_constraint_or_internals` | partial |
|
||||
| 4 | structured log field | (planned) | generic contract 없음 (adapter-specific logger test 만: `RequestLoggingFilterTest` 등) | planned |
|
||||
| 5 | PII/token/body forbidden | (planned) | `outbox/EventPayloadPiiContractTest`(ArchUnit) + `SqlLoggingForbiddenContractTest` (generic body/token 없음) | partial |
|
||||
| 6 | retryable classification | (planned) | `PersistenceFailureMappingContractTest`, `LockFailureClassificationContractTest` | exists |
|
||||
| 7 | env profile matrix | (planned) | `runtime/StartupSafetyValidatorTest` (contract/ 아닌 곳에 misplaced) | partial |
|
||||
| 8 | repository capability | (planned) | `RepositoryAccessCapabilityRegistryTest` | exists |
|
||||
| 9 | adapter failure mapping | (planned) | `PersistenceFailureMappingContractTest` (persistence side) | exists |
|
||||
|
||||
### 3. Snapshot 도구 + 검증 대상 shape
|
||||
|
||||
> **Trace**: D7(approvaltests-java) ← `AT-OFFICIAL-C1`/`C2`/`C3`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: approval `.approved`/`.received` 파일 명명 규약 + approve workflow(누가 승인) + JSON 정규화 직렬화기 위치 + scrub 대상 field source 는 사용자 임의 — 공식은 패턴만 권고. trade-off ①(scrub 지점): Jackson ObjectMapper mixin/custom serializer 단계 scrub(타입 안전, 재사용) vs `Approvals.verify` 직전 string regex post-process(단순, 도구 무관). trade-off ②(scrub 대상): non-deterministic field 목록을 registry(`mdc-keys.yaml`) 참조(SSOT 정합) vs test-fixture hardcoded list(독립, drift 위험). 기본 대상: `timestamp`/`trace_id`/`request_id`/`correlation_id`/`span_id`/`duration_ms` + ULID id. trade-off ③(도구 위치): test-fixtures 공유 vs module 별 중복.
|
||||
|
||||
- 도구: `approvaltests-java` (`Approvals.verify(...)`). **as-built: 미의존** — build.gradle/version catalog grep 0건, `Approvals.verify` 사용 0건 → `planned`. 구현 시 test 의존성 추가.
|
||||
- 검증 4 shape: ① envelope(success/data/meta) ② error(code/category/message/retryable/details) ③ structured log JSON ④ env profile 별 effective config. (Claims To Verify #1 이 4 shape 적합성 검증.)
|
||||
|
||||
### 4. optional adapter 조건부 실행 메커니즘
|
||||
|
||||
> **Trace**: D3 ← `JUNIT5-ENV-C1`/`C2` (primary) + `SPRING-ENABLEDIF-C1`/`C2` (보완).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: test 별 Alt1 vs Alt2 선택 + composed annotation 명명(`@EnabledIfKafkaEnabled` 등) 은 사용자 임의 — 공식은 두 메커니즘을 모두 제공할 뿐 선택을 권고하지 않음. trade-off: Alt1(OS env 직접, Spring context 불필요, Gradle 무관) vs Alt2(Spring Environment 바인딩/profile AND 표현 가능, 5.7+ import 충돌 주의).
|
||||
|
||||
- **primary (Alt 1)** — `@EnabledIfEnvironmentVariable(named="<flag>", matches="true", disabledReason="...")`. env undefined → SKIPPED(never FAILED, `JUNIT5-ENV-C2`).
|
||||
- **보완 (Alt 2)** — Spring `@EnabledIf("#{environment['...'] == 'true'}")` 또는 property-placeholder, env+profile **AND** 또는 Spring Environment override 반영 필요 시.
|
||||
- env enable flag (registry `env-keys.yaml` 확인): `APP_MESSAGING_KAFKA_ENABLED`(:1257), `APP_CACHE_REDIS_ENABLED`(:1187), `APP_NOTIFICATION_SLACK_ENABLED`(:1287), `APP_NOTIFICATION_GOOGLE_EMAIL_ENABLED`(:1301), `APP_OUTBOUND_HTTP_RETRY_ENABLED`(:529), `APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED`(:585).
|
||||
- profile 선택자 = `SPRING_PROFILES_ACTIVE` (allowed: local/dev/staging/prod/sample). ⚠️ **`APP_PROFILE` 사용 금지** — registry 에서 제거됨(env-keys.yaml D6 2026-06-06).
|
||||
|
||||
### 5. OpenAPI drift gate 메커니즘
|
||||
|
||||
> **Trace**: D5(drift 집행 단일 owner) + D6 ← `OPENAPI31-C2`/`C3`. project §25 SSOT Owner Map: "OpenAPI / schema drift" owner = 본 branch, producer = api-baseline.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: diff 도구(openapi-diff vs oasdiff), committed snapshot 파일 경로, gradle task 명(`openapiCheckSnapshot`), escape-hatch label 명(`intent:breaking-change-approved`), **snapshot baseline 생성/갱신 절차** 는 사용자 임의 — OAS 표준은 diff 대상 spec 만 표준화. trade-off ①(도구): oasdiff(CLI, breaking-change 분류 내장) vs openapi-diff(Java lib, gradle 통합 쉬움). trade-off ②(baseline 갱신): springdoc `/v3/api-docs` 출력을 commit 된 fixture 로 두고, 첫 baseline + 의도적 변경 승인 시 `./gradlew openapiCheckSnapshot --write` 류 explicit refresh task 로만 갱신(수동 commit 방지) vs 매 빌드 자동 재생성(drift 무력화 위험 — 채택 금지). 첫 baseline 은 수동 commit 후 review.
|
||||
|
||||
- producer: [[raw/branch-notes/feature-api-contract-baseline]] D10 (springdoc `adapter-web/build.gradle:11`). 본 branch 는 그 runtime spec 을 committed snapshot 과 diff 하여 release-blocking 판정.
|
||||
- **as-built: planned** — `sample-portfolio/.../openapi/OpenApiSnapshotTest` 가 `/v3/api-docs` 제공만 검증하고 **drift gate 는 명시적으로 본 branch 로 defer**(`OpenApiSnapshotTest.java:35-36`). committed snapshot 파일 없음, `openapiCheckSnapshot` task 없음, oasdiff/openapi-diff 의존 없음.
|
||||
|
||||
### 6. sample-removal smoke 메커니즘
|
||||
|
||||
> **Trace**: D4(sample fixture) + D6(11 gates). flag/adoption owner = `feature-sample-removal-adoption-contract` (delegated) — 본 branch 는 smoke verify 만 own.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: smoke 실행 gradle task 명 + sample bean gating 방식(`@ConditionalOnProperty`)은 adoption branch 소유 — 본 branch 는 결과(core green)만 assert. trade-off: 별도 gradle task vs 기존 test 에 profile param.
|
||||
|
||||
- flag: `APP_SAMPLE_ENABLED` (registry `env-keys.yaml:1398`, default true, `prod_profile_must_be_false`, owner `feature-sample-removal-adoption-contract`).
|
||||
- smoke: `APP_SAMPLE_ENABLED=false` 로 core app/context/contract test 실행 → 모두 green assert (Claims To Verify #4).
|
||||
- **as-built: planned** — flag 는 registry 에만 존재, 코드 wiring(`@ConditionalOnProperty(...sample)`) 0건, smoke test/task 없음.
|
||||
|
||||
### 7. PII/token/body forbidden 검사 메커니즘
|
||||
|
||||
> **Trace**: D6(11 gates 중 PII/token/body forbidden) + TODO closed(2026-05-22 이중 layer). field whitelist authoritative = registry `mdc-keys.yaml`(snake_case).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: mask regex 패턴 + capture 수단(Logback ListAppender vs Spring `OutputCaptureExtension`) + async appender 경로 커버리지 는 사용자 임의 — 공식 근거 없음. trade-off: ListAppender(동기 event 직접 capture) 는 async/custom appender 우회 가능(Claims #5 needs-confirmation).
|
||||
|
||||
- Layer 1 (Logback): `%mask` converter regex `(?i)(token|password|authorization|cookie|secret|key)\s*[=:]\s*[^*\s]+` → `****`.
|
||||
- Layer 2 (Jackson): PII DTO field `@JsonSerialize(using=MaskingSerializer.class)`; 미명시 시 archetype test fail.
|
||||
- verify: JUnit + Logback ListAppender 로 (a) masked regex 매칭 0건 (b) log JSON field ∈ `mdc-keys.yaml` allowed.
|
||||
- **as-built: partial** — `SqlLoggingForbiddenContractTest` + `outbox/EventPayloadPiiContractTest` 존재; generic body/token forbidden contract 는 `planned`.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4(깊이 게이트) 캡처용. 정상 경로 외에 구현 중 부딪힐 실패/엣지/다른 계약 의존.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **async / custom Logback appender 우회**: ListAppender 가 동기 event 만 capture → async appender 로 흐른 PII/token 미탐지. 기대: async appender 도 capture 경로에 포함하거나 별도 assert (Claims To Verify #5, `needs-confirmation`).
|
||||
- **springdoc dynamic-routing 누락**: runtime introspection 이 일부 dynamic route 를 OpenAPI spec 에 미반영 → drift snapshot false-negative (실제 envelope 변경을 못 잡음). 기대: 의도적 schema 변경 PR 로 gate exit code 검증 (Claims #3).
|
||||
- **snapshot non-deterministic field**: timestamp/traceId/requestId/correlationId/ULID 가 매 실행 변동 → snapshot diff false-positive churn. 기대: 정규화 scrubber 로 변동 field mask 후 비교.
|
||||
- **env key 오탈자 → silent SKIP**: `@EnabledIfEnvironmentVariable` 가 undefined env 를 SKIPPED 처리(`JUNIT5-ENV-C2`)하므로, CI matrix 가 flag 명을 오타내면 "의도적 skip" 과 구분 불가. 기대: `disabledReason` 명시 + CI 의 SKIPPED 항목 review.
|
||||
- **sample-portfolio prod leak**: fixture 가 test 외 의존성으로 prod classpath 에 누출 (D4 open risk). 기대: sample-removal smoke 가 leak 을 build 실패로 감지.
|
||||
- **Gradle daemon env 미반영**: daemon 캐싱이 env 변경을 stale 반영(gradle#17461) → 조건부 테스트 오작동. 기대: CI 에서 `--no-daemon` 또는 daemon 재시작.
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-api-contract-baseline]] D10 (OpenAPI/springdoc producer) — drift gate 가 이 producer 의 runtime spec 을 diff. producer surface 가 바뀌면 본 gate snapshot 갱신 필요.
|
||||
- [[raw/branch-notes/feature-api-compatibility-deprecation-contract]] — breaking-change catalog 를 openapi-diff gate 가 consume (additive vs breaking 분류).
|
||||
- [[raw/branch-notes/feature-schema-serialization-contract]] — JSON field/type/date/money schema 가 serialization snapshot 의 대상. 직렬화 정책 변경이 snapshot 을 깨뜨림.
|
||||
- [[raw/branch-notes/feature-ci-quality-gates-contract]] — release-blocking CI 배선(11 gates 의 `needs:` 의존성)의 owner. 본 branch 는 gate(test)를 produce, CI wiring 은 CI branch 가 consume (delegated).
|
||||
- [[raw/branch-notes/feature-sample-removal-adoption-contract]] — `APP_SAMPLE_ENABLED` flag + sample bean gating 의 owner. 본 branch 는 removal smoke 만 verify (delegated).
|
||||
- [[raw/branch-notes/feature-operational-error-observability-foundation]] D10/D19 — envelope `error.category` enum(10, `Category.java`) + log field snake_case(`mdc-keys.yaml`) 가 contract test assertion 의 기준값.
|
||||
- [[raw/branch-notes/feature-distributed-tracing-contract]] — requestId/traceId/correlationId **propagation 테스트** owner (`DistributedTracingContractTest`, registry `required_test = contract-verification:trace-propagation`). §12 propagation 관심사는 본 branch 가 아니라 tracing branch 가 소유 → 본 suite 는 그 결과를 중복 검증하지 않음 (delegated).
|
||||
- [[raw/branch-notes/feature-repository-access-permission-contract]] — repository capability enum(7, `capabilities.yaml`) 이 repository capability contract test 의 기준.
|
||||
|
||||
## Audit & Findings (ca-tmpl ground-truth 대조, 2026-06-15)
|
||||
|
||||
> §2 절차로 `/home/donghyeon/workspace/ca-tmpl` 실 코드/registry 를 grep 대조한 결과. 사용자 작성 결정 영역(테스트 계약 등)은 **자동 rewrite 하지 않고 정합 권고만** 기록(CLAUDE.md §11). Claims To Verify 가 이미 `planned`/`needs-confirmation` 으로 정직히 표기하므로 본 §은 그 ground truth 근거를 보강.
|
||||
|
||||
| 라벨 | finding | 근거(file:line) | 권고 |
|
||||
|---|---|---|---|
|
||||
| `STALE_TEST_NAME` | 테스트 계약 §2 가 `EnvelopeContractTest` 명시하나 실 구현은 `EnvelopeBodyAdviceTest`+`EnvelopeMetaIntegrationTest` (위치도 `adapter-web/.../envelope/`, `contract/` 아님) | grep `class.*ContractTest` 에 Envelope 없음; `EnvelopeBodyAdviceTest.java` | generic envelope contract test 신설 or 기존 envelope 테스트를 `contract/` 승격 + 명칭 정합 |
|
||||
| `PACKAGE_DRIFT` | 테스트 계약 §1 ArchUnit glob 이 `com.example.caskeleton.features.*` 가정, 실 base package 는 `dev.caskeleton` | `src/shared-contract/.../dev/caskeleton/...` | glob 을 `dev.caskeleton..features..` 로 정정 |
|
||||
| `APP_PROFILE_REMOVED` | env matrix 결정이 `APP_PROFILE` 가정 가능하나 registry 에서 제거됨 | `env-keys.yaml:38-39` (D6 2026-06-06 제거) | `SPRING_PROFILES_ACTIVE` 로 정합 |
|
||||
| `PLANNED_NOT_IMPLEMENTED` | approvaltests-java 미의존 / OpenAPI drift gate 미구현(producer 가 본 branch 로 defer) / sample-removal smoke 미구현(flag 만 존재) / ~~ArchUnit contract-isolation rule 미구현~~ / CI 부재 | grep `approvaltests`=0; `OpenApiSnapshotTest.java:35-36`; `APP_SAMPLE_ENABLED` in `src/**.java`=0; `find .github`=∅. **2026-06-20 부분 해소**: contract-isolation rule → `ContractSuiteIsolationArchTest` (`actually-implemented`, `locally-verified`); 9-base enumeration → `ContractSuiteCompletenessTest` (`actually-implemented`, `locally-verified`). 잔존 미구현: approvaltests-java, OpenAPI drift gate, sample-removal smoke, CI 배선 | Claims To Verify 가 정직 표기 — 본 branch 착수 = 이들 구현 |
|
||||
| `OWNERSHIP_CLARIFY` | sample flag/adoption owner = `feature-sample-removal-adoption-contract`; release-blocking CI wiring owner = `feature-ci-quality-gates-contract` | `env-keys.yaml:1398` owner_branch; project §25 Owner Map | 본 branch 는 verification(smoke/test) produce, flag·CI wiring 은 delegated (§엣지·실패·의존 의존 링크) |
|
||||
|
||||
## 판정 기준
|
||||
|
||||
| 구분 | 기준 |
|
||||
| --- | --- |
|
||||
| Decision | 계약은 문서가 아니라 테스트로 강제 |
|
||||
| Allowed | optional adapter는 enabled profile에서만 테스트 |
|
||||
| Forbidden | contract violation을 warning-only로 처리 |
|
||||
| Required tests | response schema, validation exposure, raw exception leakage, log field, PII/token/body forbidden, retryable, env matrix, repository capability, adapter failure, OpenAPI drift, sample removal smoke |
|
||||
| Failure condition | 위 계약 중 하나라도 깨졌는데 build가 성공하면 실패 |
|
||||
|
||||
## Verification Ownership Matrix
|
||||
|
||||
| produced by | artifact | verified here by |
|
||||
| --- | --- | --- |
|
||||
| API baseline | OpenAPI snapshot | drift check against runtime response/envelope |
|
||||
| API compatibility | breaking change catalog | openapi-diff release-blocking gate |
|
||||
| schema serialization | JSON field/type/date/money schema | serialization snapshot |
|
||||
| sample fixture | sample-portfolio scenarios | contract fixture run |
|
||||
| sample removal | no-sample profile | sample removal smoke |
|
||||
| registry governance | registry tables/artifacts | registry usage scan |
|
||||
|
||||
## 테스트 계약
|
||||
|
||||
- skeleton-level 실행 가능성: contract test class는 `src/test/**/contract/` 디렉터리에 위치하고 import statement에 도메인-specific package(`com.example.caskeleton.features.{도메인}.`)를 사용하지 않아야 함. 단, `features.sample.`는 fixture로 허용. 측정 방법: ArchUnit `noClasses().that().resideIn("..contract..").should().dependOnClassesThat().resideInAPackage("..features.(?!sample).+..")` (regex 부정). 위반 시 fail.
|
||||
- 9 base contract test enumeration: response schema test (`EnvelopeContractTest`), validation exposure test, raw exception leakage test, log field test, PII/token/body forbidden test, retryable classification test, env matrix test, repository capability test, adapter failure mapping test — 9개 test class가 `src/test/**/contract/`에 존재하고 모두 PR단위 release-blocking. 측정 방법: 9개 file 존재 verify + CI gate 명시.
|
||||
- optional adapter는 enabled env에서만 관련 contract test를 실행.
|
||||
- OpenAPI snapshot과 실제 response envelope가 drift되면 build 실패.
|
||||
- sample-portfolio 제거 profile에서 core app/context/contract tests가 실패하면 build 실패.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다. 구현 전/중/후에 실제로 검증해야 하는 주장을 분리.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| ApprovalTests JSON snapshot 이 envelope/error/log/env 4가지 shape 모두에 적합 | 공식 (`AT-OFFICIAL-C2`) 는 일반 complex object 만 언급, 4가지 사용처 별 패턴 검증 부재 | 각 4영역마다 PoC test 작성 + snapshot diff 가 의도된 변화만 감지하는지 확인. ✓ envelope/error 는 approvaltests 3 snapshot (`EnvelopeContractTest`, scrub 후 stable) 로 검증; log shape 는 field-membership (`StructuredLogFieldContractTest`), env 는 registry 제약 (`EnvProfileMatrixContractTest`) 로 검증 — full-snapshot 보다 robust 하다는 판단(Claims #1 결론: approvaltests 는 envelope/error 에 적합, log/env 는 targeted assertion 이 우위) | `locally-verified` |
|
||||
| 9개 base contract test class 가 모두 `src/test/**/contract/` 에 존재하고 release-blocking | 본 branch 의 "테스트 계약" 에 enumeration 있으나 실제 코드 부재 | 9개 file 존재 verify + CI workflow 의 `needs:` 의존성에 모두 포함 verify. **`ContractSuiteCompletenessTest` 구현됨** (`app-bootstrap/.../contract/ContractSuiteCompletenessTest.java`) — `Class.forName(fqcn, false, loader)` 로 9 base class 검증. `:app-bootstrap:test` PASS (1/1 method green). | `locally-verified` |
|
||||
| OpenAPI snapshot vs runtime response envelope drift 가 build 단계에서 잡힘 | springdoc 의 runtime introspection (`CIOS-C1`) 은 dynamic routing 일부 누락 가능 | 의도적 envelope schema 변경 PR → `openapiCheckSnapshot` exit code != 0 verify. ✓ `OpenApiDriftContractTest`(sample-portfolio) committed snapshot 동등 비교; compare-mode 2회(`--rerun-tasks`) green, `servers` block strip 으로 RANDOM_PORT 비결정성 제거. dynamic-routing 누락 가능성은 잔존(springdoc introspection 한계) | `locally-verified` |
|
||||
| sample-portfolio 제거 profile 에서 core app/context/contract tests 가 모두 통과 | sample-portfolio 이 fixture 외에 의존성으로 leak 되어 있을 가능성 | `APP_SAMPLE_ENABLED=false` profile 로 test suite 실행 + core test green verify. ✓ `SampleRemovalSmokeContractTest`: (a) 모든 production module 이 sample-portfolio 를 test-only 로만 참조(삭제 가능 보장), (b) `APP_SAMPLE_ENABLED` registry `prod_profile_must_be_false`. 실제 bean-gating(`@ConditionalOnProperty`)+no-sample boot 은 feature-sample-removal-adoption-contract 위임 — 본 branch 는 검증물만 | `locally-verified` (smoke); full no-sample boot `delegated` |
|
||||
| Logback ListAppender 기반 PII/token/body forbidden 검사가 모든 log path 를 capture | custom appender / async appender 가 별도 경로로 leak 가능 | 의도적 PII log 코드 추가 → contract test fail verify; async logging 도 capture 되는지 확인. ✓ `PiiTokenBodyForbiddenContractTest`: 동기 ListAppender 로 capture→`LogMaskingPatterns.mask()` 후 `UNMASKED_SECRET` 매칭 0건 (6 secret shape + Bearer scheme false-positive 방지 possessive quantifier). async/custom appender 경로는 미검증 잔존. §7 Layer 2 (Jackson MaskingSerializer)는 아키텍처 제약으로 이관(결정 참조) | `locally-verified` (sync); async path `needs-confirmation` |
|
||||
| `intent:breaking-change-approved` label escape hatch 가 의도된 PR 에만 적용 | label 추가 권한 정책 부재 시 누구나 우회 가능 | GitHub branch protection + CODEOWNERS 로 label 추가 권한 제한 + audit log 점검 | `planned` |
|
||||
| optional adapter test 가 disabled env 에서 FAILED 아닌 SKIPPED 로 보고됨 | `JUNIT5-ENV-C2`/`SPRING-ENABLEDIF-C1` 는 공식 보장이나 ca-skeleton 의 실 annotation 적용·CI 리포트 집계는 미검증 | 각 adapter flag=false 로 test 실행 → JUnit XML `<skipped>` 생성 + build green verify. ✓ `OptionalAdapterConditionalExecutionContractTest`: 6 composed `@EnabledIf*` annotation (현행 registry flag 명: REDIS/HTTP_RETRY/HTTP_CIRCUIT_BREAKER=`true`, MESSAGING_BROKER/SLACK/EMAIL provider=`.+`), default env 에서 6 skipped; EngineTestKit 으로 disabled→skipped(1)/failed(0)/started(0) 독립 증명 | `locally-verified` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
> `/coverage` 가 채우는 **생성물** — 손으로 유지하지 않는다. governing 문서(frontmatter `governing_docs` = `raw/project-notes/ca-skeleton-operational-contract`, §12 Test Contract · §13 API Contract Surface · §16 Schema/Serialization · §18 CI Quality Gates)가 요구하는 관심사를 이 브랜치가 빠짐없이 덮는지의 결과. 기준: `rules/coverage-gate.md`.
|
||||
> 상태: `covered-here`(이 브랜치 결정) / `delegated`(다른 owner 브랜치) / `missing`(아무도 안 맡음 → Blocking).
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|--------|------|-------|--------|------|
|
||||
| §12 structured error response schema (envelope shape) | covered-here | — | — | D6 (9 base #1), §테스트계약 |
|
||||
| §12 validation details exposure policy | covered-here | — | — | D6 (9 base #2), §구현 가이드 §2 #2 |
|
||||
| §12 raw exception leakage 방지 | covered-here | — | — | D6 (9 base #3), §구현 가이드 §2 #3 |
|
||||
| §12 structured log field 존재 | covered-here | — | — | D6 (9 base #4), §구현 가이드 §2 #4 |
|
||||
| §12 PII/token/body 미기록 | covered-here | — | — | D6 (9 base #5), §구현 가이드 §7 |
|
||||
| §12 retryable classification | covered-here | — | — | D6 (9 base #6), `PersistenceFailureMappingContractTest` (exists) |
|
||||
| §12 requestId/traceId/correlationId propagation | delegated | [[raw/branch-notes/feature-distributed-tracing-contract]] | OK (linked) | tracing §테스트계약 + `DistributedTracingContractTest` (exists) — §엣지·실패·의존 의존 링크 보유 |
|
||||
| §12 env profile matrix smoke test | covered-here | — | — | D6 (9 base #7), §구현 가이드 §4 |
|
||||
| §12 repository capability violation detection | covered-here | — | — | D6 (9 base #8), `RepositoryAccessCapabilityRegistryTest` (exists) |
|
||||
| §12 adapter failure mapping | covered-here | — | — | D6 (9 base #9), `PersistenceFailureMappingContractTest` (exists) |
|
||||
| §13 OpenAPI schema ↔ 실제 응답 일치 검증 | covered-here | — | — | D5 (단일 owner), §구현 가이드 §5 (planned) |
|
||||
| §16 OpenAPI schema drift 테스트 감지 | covered-here | — | — | D5/D6; schema-serialization 이 집행권 본 branch 위임 |
|
||||
| §18 CI gate 분리 (format/lint/test/contract/drift/security) | delegated | [[raw/branch-notes/feature-ci-quality-gates-contract]] | OK (linked) | CI 배선 owner = ci-quality-gates D1/D3; §엣지·실패·의존 의존 링크 보유 |
|
||||
| §18 contract violation not warning-only (CI 배선) | delegated | [[raw/branch-notes/feature-ci-quality-gates-contract]] | OK (linked) | 본 branch 는 test produce(D2), CI 강제 wiring 은 ci-quality-gates owner |
|
||||
| §18 optional adapter test = enabled matrix only | covered-here | — | — | D3 (`@EnabledIfEnvironmentVariable` primary), §구현 가이드 §4 |
|
||||
| sample removal smoke (§18 연계) | covered-here | — | — | D4/D6 (smoke verify 소유); flag/wiring 은 feature-sample-removal-adoption-contract (delegated, §엣지 link) |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음.
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/junit5-conditional-env-variable-user-guide]]
|
||||
- [[raw/official-docs/openapi-spec-3-1-0]]
|
||||
- [[raw/official-docs/spring-framework-test-enabledif-jupiter-annotation]]
|
||||
- [[raw/official-docs/verification-approvaltests-snapshot-official]]
|
||||
- [[raw/official-docs/verification-pact-cdc-official]]
|
||||
- [[raw/official-docs/verification-spring-cloud-contract-official]]
|
||||
- [[raw/official-docs/verification-spring-restdocs-official]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/contract-verification-suite-release-gates-2026-07-02]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
> 2026-06-20 첫 실 구현 완료: ContractSuiteIsolationArchTest + ContractSuiteCompletenessTest.
|
||||
|
||||
### 오류 기록 (본 feature 작업 중 발생)
|
||||
|
||||
- (T5) IDE 진단의 transient indexer "cannot resolve import" 경고 — 신규 파일 인덱싱 지연, Gradle 컴파일에서 정상 해소.
|
||||
- **OpenAPI snapshot RANDOM_PORT 비결정성** (raw/errors 추출 후보): `OpenApiDriftContractTest` 가 committed snapshot 과 compare 시 매 실행 실패. 원인 = springdoc `/v3/api-docs` 의 `servers` block 이 `@SpringBootTest(RANDOM_PORT)` 의 `http://localhost:<random>` 를 담아 매 run 변동. 두 generation diff 로 단 1줄(`url`) 차이 확인 → canonicalize 단계에서 `servers` 키 제거(drift gate 는 API surface: paths/components/schemas 만 추적, base URL 은 harness noise). 재현/교훈: snapshot gate 는 환경 의존 필드(포트/호스트/타임스탬프/ULID)를 반드시 scrub.
|
||||
- **PII masking 검증 regex 의 possessive-quantifier backtracking false-positive** (raw/errors 추출 후보): `UNMASKED_SECRET` detector 가 이미 masked 된 `authorization: Bearer ****` 를 위반으로 오탐. 원인 = optional auth-scheme group `(?:bearer|basic|negotiate\s+)?` 가 lookahead `(?!\*{4})` 실패 시 backtrack 하여 "Bearer" 자체를 secret value 로 재매칭. 해결 = possessive `?+` (`(?:...)?+`) 로 scheme 을 give-back 불가하게. 교훈: "이미 마스킹됐는지" 판정 regex 는 optional prefix 의 backtracking 을 possessive 로 차단해야 함.
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- ArchUnit manual-importer 패턴을 선택한 이유: `@AnalyzeClasses` + `DoNotIncludeTests` suite 가 test 클래스를 볼 수 없어서 `ClassFileImporter` 직접 사용 필수.
|
||||
- ArchUnit 에서 "regex negation" 을 사용할 수 없는 경우 복합 predicate 로 표현하는 방법 (`resideInAPackage("..features..").and(resideOutsideOfPackage("..features.sample.."))`).
|
||||
- non-vacuity guard 가 필요한 이유: 빈 corpus 스캔 시 rule 이 silently 통과하는 문제 방지.
|
||||
- snapshot/golden-master 테스트에서 비결정성(포트/타임스탬프/trace_id/ULID)을 어떻게 다루나 — scrub vs strip, 그리고 "무엇을 계약으로 볼 것인가"(API surface vs 환경 메타) 경계 판단.
|
||||
- optional adapter 테스트를 enabled env 에서만 실행하면서 disabled 시 FAILED 아닌 SKIPPED 를 어떻게 보장·검증하나 (`@EnabledIfEnvironmentVariable` + EngineTestKit 으로 skipped/failed 통계 단언).
|
||||
- masking 같은 cross-cutting 메커니즘의 SSOT 가 상위 모듈(app-bootstrap)에 있을 때, 하위 모듈(adapter-web) 직렬화 레이어에서 재사용하려면 왜 SSOT relocate 또는 중복이 강제되는가 (의존성 방향 제약).
|
||||
|
||||
### 블로그·채용공고 연계 글감
|
||||
|
||||
- ArchUnit 에서 테스트 클래스를 검사할 때 manual-importer 패턴이 필요한 이유 (잠재적 블로그 글감).
|
||||
- "violations-as-data" 픽스처 패턴: ArchUnit 규칙의 positive-control + over-block guard 를 명시적 픽스처 클래스로 구조화하는 접근.
|
||||
- "계약을 문서가 아니라 테스트로 강제하기": 11 release-blocking gate 를 approvaltests snapshot + registry-drift + ArchUnit isolation + OpenAPI committed-snapshot 으로 묶은 verification suite 설계.
|
||||
- regex 로 "이미 마스킹됐는지" 판정할 때 backtracking 함정과 possessive quantifier (PII 로그 마스킹 검증 사례).
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- (해당 spec 패스에서 단독 daily-note 추출 없음. 진행은 §결정 사항 + §Audit & Findings 에 직접 기록.)
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-data-retention-privacy-contract.md
|
||||
@@ -0,0 +1,276 @@
|
||||
---
|
||||
title: branch / feature-data-retention-privacy-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-data-retention-privacy-contract
|
||||
related_projects: [ca-skeleton]
|
||||
tags: [branch, ca-skeleton, data-retention, privacy, logging]
|
||||
created: 2026-05-22
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-032
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-032
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
parent_branch:
|
||||
contract_packet_sha256: ff83e470a0a30de5fc6591d73f5d7f1ed8cd3501c171a4361e1d206642d9be66
|
||||
---
|
||||
|
||||
# branch: feature-data-retention-privacy-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 로그, audit/security event, sample data, backup/restore의 보존과 개인정보 노출 기준을 정의합니다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (§<관련 섹션>) 의 결정/근거/금지 사항을 정제한다.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: retention·deletion·masking contract test가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1` | Gradle multi-module에서 domain-core·application-core·adapter-*·shared-contract·app-bootstrap·sample-portfolio 책임을 분리한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
도메인이 없어도 skeleton은 개인정보와 운영 로그를 다룹니다. PII, token, request body, audit/security event 보존 기준이 없으면 운영 로그 자체가 리스크가 됩니다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- log category별 retention 기준.
|
||||
- PII/secrets/token redaction 기준.
|
||||
- pseudonymization 기준.
|
||||
- audit/security event 보존 기준.
|
||||
- sample data와 real data 구분 기준.
|
||||
- backup/restore 책임 경계.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 특정 법률 준수 문서.
|
||||
- 실제 DLP product 연동.
|
||||
- business data retention policy.
|
||||
|
||||
## TODO
|
||||
|
||||
> TODO drained 2026-05-22 — 결정은 아래 "결정 사항" / "Retention by Profile" / "DSR Contract" / "Retention Defaults" 참조. application·security·audit retention / PII·token redaction / pseudonymization / sample-vs-real / backup·restore boundary / privacy contract test 기준 모두 결정 라인 또는 표로 반영됨. 잔존 TODO 없음.
|
||||
|
||||
## Work Item Contract
|
||||
|
||||
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
|
||||
|
||||
| field | required | rule |
|
||||
| --- | --- | --- |
|
||||
| Decision | yes | 구현자가 선택해야 하는 기본값 |
|
||||
| Allowed | yes | 허용되는 예외와 조건 |
|
||||
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
|
||||
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
|
||||
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
|
||||
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
|
||||
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-05-22: skeleton 기본 로그에는 PII, token, raw body를 남기지 않음.
|
||||
- 2026-05-22: security event log의 principal은 최소 식별 또는 pseudonymized identifier 기준으로 둠.
|
||||
- 2026-05-22: DSR(delete/export) 절차는 이 branch가 owner. skeleton core는 business data 삭제를 구현하지 않지만 intake, identity verification, scope classification, audit evidence contract는 제공.
|
||||
- 2026-05-22: retention 기본값은 application log 30일, security event 180일, audit log 1년. 조직/법률 요구가 있으면 override 가능.
|
||||
- 2026-05-22: backup/restore는 persistence branch와 연결하되 privacy 관점의 retention/erasure evidence를 이 branch가 소유.
|
||||
- 2026-05-22: redaction layer SSOT는 log-management branch의 Logback masking converter. 본 branch는 PII field allowlist 표만 owns.
|
||||
- 2026-05-22: pseudonymization key = HMAC-SHA-256 with rotating salt. salt rotation interval = 90일. rotation 시 old salt 90일 retain (lookup 가능). collision rate < 1e-9 가정.
|
||||
- 2026-05-22: sample data 표시 메커니즘 = (1) entity flag column `is_sample BOOLEAN DEFAULT false` + (2) Spring profile `sample` 활성 시만 seed. prod profile에서 is_sample=true row 발견 시 fail (cleanup migration 의무).
|
||||
- 2026-05-22: DSR delete request 처리 SLA = 30일, export 14일. principal 식별은 pseudonymized id ↔ original id 변환 표(privacy branch가 owns).
|
||||
- 2026-05-22: backup encryption-at-rest 의무. backup retention default = 30일 daily + 6개월 monthly. restore drill 분기 1회 의무.
|
||||
- 2026-05-22: 본 branch가 모든 log type(application/security/audit)의 **retention SSOT**. log-management-contract는 형식만 owns. retention 수치는 본 branch의 Retention by Profile 표가 단일 source.
|
||||
- 2026-05-22: backup에 PII 포함 시 per-principal envelope key (또는 tenant-level CMK) 구조 적용. HMAC + salt rotation 90d는 "forward security only" 명시. 구체 패턴(per-principal vs tenant-level vs hybrid) 선택은 Phase C2 보류. (status: needs-confirmation)
|
||||
|
||||
## Retention by Profile
|
||||
|
||||
| log type | dev | staging | prod |
|
||||
|----------|-----|---------|------|
|
||||
| application | 7일 | 14일 | 30일 |
|
||||
| security | 30일 | 90일 | 180일 |
|
||||
| audit | 90일 | 365일 | 365일 (또는 도메인별 override) |
|
||||
|
||||
## DSR Contract
|
||||
|
||||
| step | default |
|
||||
| --- | --- |
|
||||
| intake | authenticated request or verified support workflow |
|
||||
| identity verification | principal proof before export/delete |
|
||||
| export | machine-readable JSON/CSV package with audit event |
|
||||
| delete | domain owner policy, tombstone/pseudonymization allowed |
|
||||
| evidence | audit event without raw PII payload |
|
||||
|
||||
## Retention Defaults
|
||||
|
||||
| data | default retention |
|
||||
| --- | --- |
|
||||
| application log | 30 days |
|
||||
| security event log | 180 days |
|
||||
| audit log | 1 year |
|
||||
| sample data | never seeded in prod |
|
||||
| backup | project-specific, restore evidence required |
|
||||
|
||||
## 테스트 계약
|
||||
|
||||
- token/password/authorization header가 log capture에 남으면 실패.
|
||||
- raw request/response body logging이 prod profile에서 가능하면 실패.
|
||||
- sample data가 production profile에서 seed되면 실패.
|
||||
- DSR delete/export 절차 owner와 audit evidence가 없으면 실패.
|
||||
- retention 일수가 `0` 또는 미정이면 실패.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/privacy-gdpr-article-25-design]] | GDPR Privacy by design/default (retention "as short as possible" + pseudonymization 권장; ca-tmpl 채택 legal basis |
|
||||
| [[raw/company-tech-blogs/privacy-pseudonymization-hmac-vs-tokenization-iapp]] | HMAC-SHA-256 + 90d salt rotation 선택 근거 (ENISA/IAPP 인정 |
|
||||
| [[raw/official-docs/privacy-cryptographic-erasure-nist-sp800-88]] | NIST 정식 인정; backup의 GDPR Art |
|
||||
| [[raw/official-docs/gdpr-cryptographic-erasure-envelope-key-pattern]] | 참조 |
|
||||
|
||||
## 외부 근거 / 대안 조사 (2026-05-22 — Group G-J: Data Retention / Privacy)
|
||||
|
||||
본 branch의 30/180/365d retention by profile + HMAC-SHA-256 salt rotation 90d + DSR SLA 30d delete / 14d export + is_sample column 결정에 대한 외부 source.
|
||||
|
||||
- **채택 결정 (legal basis: GDPR Art.25 + HMAC pseudonymization + retention by category)**:
|
||||
- [[raw/official-docs/privacy-gdpr-article-25-design]] — GDPR Privacy by design/default (retention "as short as possible" + pseudonymization 권장; ca-tmpl 채택 legal basis)
|
||||
- [[raw/company-tech-blogs/privacy-pseudonymization-hmac-vs-tokenization-iapp]] — HMAC-SHA-256 + 90d salt rotation 선택 근거 (ENISA/IAPP 인정)
|
||||
- **검토한 대안**:
|
||||
- **대안 1: Tokenization service** — `privacy-pseudonymization-hmac-vs-tokenization-iapp` 동일 source 안에서 비교 (brute-force 가능 input space에서 HMAC보다 우위)
|
||||
- **대안 2: Cryptographic erasure (delete encryption key vs delete data)** — [[raw/official-docs/privacy-cryptographic-erasure-nist-sp800-88]] (NIST 정식 인정; backup의 GDPR Art.17 erasure 정합; per-principal envelope key 구조 필요 — ca-tmpl 미결정 보강 후보)
|
||||
- **대안 3: PII detection SaaS (AWS Macie / OneTrust / TrustArc)** — vendor 종속, ca-tmpl scope 외
|
||||
- **비교 핵심**: ca-tmpl HMAC-SHA-256 + 90d salt rotation은 ENISA 인정 패턴이나 brute-force 가능 input space(예: 한국 휴대폰 11자리)에서 tokenization 우위. Cryptographic erase는 backup PII delete의 NIST 정식 방법 — per-principal envelope key 구조 도입 검토 필요(ca-tmpl 미결정). GDPR Art.25가 ca-tmpl retention/pseudonymization 결정의 legal basis.
|
||||
|
||||
**후속 보강 (2026-05-22)**: GDPR Art.17 backup erasure 정합을 위한 per-principal envelope key 패턴 필요. HMAC + salt만으로는 forward security만 제공. [[raw/official-docs/gdpr-cryptographic-erasure-envelope-key-pattern]] 참조.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 본 branch 의 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시한다. Decision ID 는 안정적으로 유지한다. company-tech-blog 출처는 `company-case-study` 로 표기하며 공식 best practice 로 일반화하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|
|
||||
| D1 | skeleton 기본 로그에 PII, token, raw body 미기록 | `raw/official-docs/privacy-gdpr-article-25-design.md#GDPR-A25-C1` (pseudonymisation as appropriate technical measure), `raw/official-docs/privacy-gdpr-article-25-design.md#GDPR-A25-C2` (default: only necessary data processed) | `official-standard` (GDPR Art.25) | Art.25 는 "necessary for purpose" 의 정량 기준을 지정하지 않음 — 도메인별 justification 필요 |
|
||||
| D2 | security event log principal = 최소 식별 또는 pseudonymized identifier | `raw/official-docs/privacy-gdpr-article-25-design.md#GDPR-A25-C1` (pseudonymisation 예시), `raw/company-tech-blogs/privacy-pseudonymization-hmac-vs-tokenization-iapp.md#ENISA-PSE-C1` ~ `C4` | `official-standard` (Art.25) + `company-case-study` (IAPP/ENISA mapping — 일반화 금지) | ENISA 가이드는 EU agency document 이나 본 raw 는 IAPP company-case-study 로 분류됨. Art.25 자체는 알고리즘 강도를 지정하지 않음 |
|
||||
| D3 | DSR (delete/export) 절차 owner = 본 branch | `raw/official-docs/privacy-gdpr-article-25-design.md#GDPR-A25-C3` (저장 기간 + 접근성이 default 의무 4축에 포함) | `official-standard` | Art.25 는 DSR SLA 수치 미지정 — Art.12(3) "without undue delay and in any event within one month" 와 결합 해석 필요 (별도 raw 미확보) |
|
||||
| D4 | retention 기본값 = application 30d / security 180d / audit 1y | `raw/official-docs/privacy-gdpr-article-25-design.md#GDPR-A25-C2`, `raw/official-docs/privacy-gdpr-article-25-design.md#GDPR-A25-C3` (저장 기간 default 의무) | `official-standard` (수치 자체는 official 가 아니라 운영 default) | Art.25 는 정확한 수치 미지정. 30/180/365d 는 ca-tmpl 의 운영적 기본값일 뿐 법적 강제값 아님 |
|
||||
| D5 | backup/restore = persistence branch 연결, retention/erasure evidence 는 본 branch 소유 | `raw/official-docs/gdpr-cryptographic-erasure-envelope-key-pattern.md#GDPR-CE-ENV-C1` ~ `C4` (CE + Art.17 의 통합) | `official-standard` (NIST SP 800-88 + GDPR Art.17) | per-principal envelope key 패턴이 EU regulator (DPA) 가 명시 수용한 권장 방식이라는 보장은 없음 |
|
||||
| D6 | redaction layer SSOT = log-management branch Logback masking converter; 본 branch 는 PII field allowlist 표만 소유 | UNSUPPORTED_DECISION — 책임 경계 분리는 branch 자체 정합성 규칙 | none | Logback masking converter 자체의 공식 spec raw 미확보 |
|
||||
| D7 | pseudonymization key = HMAC-SHA-256 + rotating salt 90d, collision rate < 1e-9 가정 | `raw/company-tech-blogs/privacy-pseudonymization-hmac-vs-tokenization-iapp.md#ENISA-PSE-C1` ~ `C4`, `raw/official-docs/privacy-gdpr-article-25-design.md#GDPR-A25-C1` | `company-case-study` (IAPP/ENISA mapping) + `official-standard` (Art.25 pseudonymisation principle) | brute-force 가능한 input space (예: 휴대폰 11자리) 에서 tokenization 우위. 90d rotation cadence 의 EDPB 권장값은 별도 미검증 |
|
||||
| D8 | sample data 표시 = `is_sample BOOLEAN` column + Spring profile `sample` 활성 시만 seed (prod 발견 시 fail) | `raw/official-docs/privacy-gdpr-article-25-design.md#GDPR-A25-C2` (data minimization default) | `official-standard` | Art.25 는 `is_sample` column 메커니즘을 명시하지 않음 — ca-tmpl 운영 구현 선택 |
|
||||
| D9 | DSR delete SLA = 30d, export = 14d | UNSUPPORTED_DECISION — Art.12(3) "within one month" raw 미확보. 30d 는 ca-tmpl 운영 default | none | Art.12(3) raw 등록 시 보강 가능 |
|
||||
| D10 | backup encryption-at-rest 의무 + retention 30d daily + 6m monthly + restore drill 분기 1회 | UNSUPPORTED_DECISION — backup retention 수치는 ca-tmpl 운영 default. NIST SP 800-88 은 sanitization 만 정의, retention 수치 미지정 | none | 운영 default 합리성은 별도 |
|
||||
| D11 | 모든 log type retention SSOT = 본 branch (log-management 는 형식만 소유) | UNSUPPORTED_DECISION — 책임 경계 분리는 branch 자체 정합성 규칙 | none | branch 간 책임 경계의 외부 official 근거 없음 |
|
||||
| D12 | backup PII = per-principal envelope key (or tenant-level CMK) — 구체 패턴 (a/b/c) Phase C2 보류 | `raw/official-docs/gdpr-cryptographic-erasure-envelope-key-pattern.md#GDPR-CE-ENV-C1` ~ `C5`, `raw/official-docs/privacy-cryptographic-erasure-nist-sp800-88.md#NIST-CE-C1` ~ `C4` | `official-standard` (NIST SP 800-88) + `official-vendor-doc` (AWS KMS envelope structure) | (a)/(b)/(c) 중 채택안 미결정. Per-principal CMK 비용 폭증 risk, DEK store 메타-erasure 책임 등 결정 미확정 — status `needs-confirmation` 유지 |
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 30/180/365d retention 이 "necessary for each specific purpose" justification 을 만족 | Art.25 는 정량 기준 미지정 | 도메인별 (application / security / audit) justification 문서화 + DPIA 형식 작성 | `needs-confirmation` |
|
||||
| HMAC-SHA-256 + 90d salt rotation 이 EDPB 권장 cadence 와 일치 | EDPB Guidelines 4/2019 "periodic re-pseudonymisation" 의 정확한 cadence 미확인 | EDPB Guidelines 4/2019 또는 ENISA 가이드 raw 추가 + 90d cadence 의 권장 범위 확인 | `needs-confirmation` |
|
||||
| brute-force 가능 input space (예: 한국 휴대폰 11자리) 에서 HMAC + salt 의 re-identification risk 가 허용 수준 | input space 특성에 따라 HMAC 우위가 깨질 가능성 | 도메인 별 input space 크기 측정 + tokenization 도입 trigger 결정 | `needs-confirmation` |
|
||||
| backup PII 의 GDPR Art.17 단건 erasure 가 per-principal envelope key 패턴으로 충족 | EU regulator 의 명시 수용 의견서 미확인 | DPA 가이드 또는 case law raw 추가 + 패턴 채택 후 통합 테스트 | `needs-confirmation` |
|
||||
| `is_sample BOOLEAN` column 메커니즘이 prod 누출 차단에 충분 | prod profile + is_sample=true row 발견 시 fail 의 구현 미확인 | startup migration 또는 contract test 구현 + prod profile + is_sample=true seed 시 fail verify | `planned` |
|
||||
| sensitive log redaction (token / password / authorization header) 가 모든 log capture 경로에서 동작 | Logback masking converter (log-management branch SSOT) 구현 미완 | `LogMaskingContractTest` 구현 + token/password/auth header injection 시 redaction verify | `planned` |
|
||||
| Art.12(3) "within one month" 와 ca-tmpl DSR SLA 30d / 14d 가 정합 | Art.12(3) raw 미확보 | Art.12 raw 추가 + SLA 비교 | `needs-confirmation` |
|
||||
| backup restore drill 분기 1회 가 GDPR 요건 충족 | 외부 official 근거 없음 (ca-tmpl 운영 default) | 분기별 restore drill 실행 evidence (audit log) 보존 + 외부 audit 시 제출 | `planned` |
|
||||
| per-principal CMK 의 KMS API cost 가 ca-tmpl 규모에서 운영 가능 | AWS KMS pricing 시점/region 별 변동 + cost 정량 미측정 | Phase C2 에서 (a)/(b)/(c) 중 채택안 + 1 년 운영 비용 시뮬레이션 | `needs-confirmation` |
|
||||
| DEK store (DynamoDB / Postgres) 자체의 erasure 책임 경계 | wrapped DEK record 의 backup 정책 미정 | (b) per-principal DEK + master CMK 채택 시 DEK store backup 정책 + replication 정책 추가 결정 | `needs-confirmation` |
|
||||
|
||||
## 완료 후 wiki 추출 대상
|
||||
|
||||
- `wiki/projects/ca-skeleton-operational-contract.md`의 data retention/privacy canonical section.
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음(문서 단계).
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/privacy-pseudonymization-hmac-vs-tokenization-iapp]]
|
||||
- [[raw/official-docs/gdpr-cryptographic-erasure-envelope-key-pattern]]
|
||||
- [[raw/official-docs/privacy-cryptographic-erasure-nist-sp800-88]]
|
||||
- [[raw/official-docs/privacy-gdpr-article-25-design]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 feature branch 는 leaf — 자식 자료 없음. Phase C2 실 코드 작성 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록 (본 feature 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase C2 실 구현 단계에 누적)
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- retention profile·DSR·기본값의 결정 상태는 위 표와 TODO에서 추적한다.
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
- 데이터 분류별 retention 기간과 삭제 주체를 registry로 관리하고 job은 registry를 소비한다.
|
||||
- DSR 삭제·익명화·legal hold를 서로 다른 상태 전이로 처리하며 감사 로그에는 원문 PII를 남기지 않는다.
|
||||
- dry-run과 실제 삭제를 분리하고 fixture clock으로 경계 시각을 검증한다.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- 부분 삭제·재시도 중복·legal hold 무시는 복구가 어려운 데이터 손실 또는 규제 위험으로 이어진다.
|
||||
- persistence auditing·scheduler lock·tenant context 계약과 함께 검증해야 한다.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- 별도 일일 노트 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-database-connection-pool-contract.md
|
||||
@@ -0,0 +1,371 @@
|
||||
---
|
||||
title: branch / feature-database-connection-pool-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-database-connection-pool-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]
|
||||
tags: [branch, ca-skeleton, persistence, hikaricp, connection-pool, database]
|
||||
created: 2026-06-09
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-050
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-050
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-DATABASE-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-RESILIENCE-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-004, WI-CA-SKELETON-OPERATIONAL-CONTRACT-006, WI-CA-SKELETON-OPERATIONAL-CONTRACT-035, WI-CA-SKELETON-OPERATIONAL-CONTRACT-019]
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: 8bb34c64971d280776b949d71b980bec1b4203786e4043b7a22ca9f6174104ec
|
||||
---
|
||||
|
||||
# branch: feature-database-connection-pool-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 **§9 Env-driven Runtime Configuration (DB pool env)** · **§11 Adapter Failure Contract — Persistence** · **§18 Metrics/Alerting (DB pool metric)** 영역의 *connection pool 설정 정책* 을 정제한다. 분해표 위치: project-note §B "데이터/영속성 영역" priority #4 (L2031/L2082).
|
||||
|
||||
선택 (형제 branch — DB pool 관심사 공동 소유):
|
||||
|
||||
- [[raw/branch-notes/feature-persistence-failure-baseline]] — persistence 실패 분류 + Hikari pool exhaustion **alert** (D3) + pool metric 노출 + acquire-timeout 실패 분류 owner
|
||||
- [[raw/branch-notes/feature-env-driven-runtime-configuration]] — `APP_DATASOURCE_*` env **key** owner (pool max/min-idle/connection-timeout/idle-timeout/max-lifetime + numeric bounds validation)
|
||||
- [[raw/branch-notes/feature-metrics-alerting-contract]] — DB pool **metric** 공동 소유 (`hikaricp.connections.*`)
|
||||
- [[raw/branch-notes/feature-application-port-usecase-contract]] — `REQUIRES_NEW` pool-sizing 제약 (D12) — pool 크기 하한 공식의 도메인측 근거
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: connection pool 설정·lifecycle·metric·failure gate가 명시된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-DATABASE-001@1` | database는 PostgreSQL 16 단일 stack이다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-RESILIENCE-001@1` | Resilience4j가 default이며 Spring Retry는 simple blocking retry에만 허용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
ca-tmpl 의 DB 접근은 HikariCP 위에서 동작하지만, **풀 설정값의 "정책/근거"** 는 어디에도 고정되어 있지 않다. 현재 `application.yml` 에는 5개 knob (`maximum-pool-size`/`minimum-idle`/`connection-timeout`/`idle-timeout`/`max-lifetime`) 만 env binding 되어 있고, 운영 안정성에 직결되는 **leak detection / keepalive / validation timeout / 초기화 fail-fast / slow query 탐지** 는 미설정·미결정 상태다.
|
||||
|
||||
이 브랜치는 *env key 의 값 자체* (그건 env-driven 이 소유) 가 아니라, **그 값들이 왜 그래야 하는가 + knob 간 제약 관계 + 아직 노출 안 된 knob 의 채택 여부 + slow query 를 어느 계층에서 파라미터 노출 없이 탐지할지** 를 결정한다. 목표는 persistence 코드를 작성하는 다음 사람이 *되묻지 않고* HikariConfig 와 application.yml 을 채울 수 있는 수준의 정책 명세.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- **Pool sizing 정책** — 고정 크기 풀(`minimumIdle = maximumPoolSize`) 권고 vs 현재 `min-idle=2` 설정의 정합, HikariCP small-pool axiom + formula 를 default 값의 *근거* 로 고정 (값 자체 변경은 env-driven 소유).
|
||||
- **connectionTimeout 정책** — 30s 기본 대신 fail-fast 값 pin 의 근거 + 의미.
|
||||
- **maxLifetime 정책** — DB/인프라 idle timeout 보다 수 초 짧게 (production 최우선 설정), DB `wait_timeout` 대조 절차.
|
||||
- **keepaliveTime 채택** (greenfield — 미노출 knob) — 방화벽/DB idle-kill 방지, `< maxLifetime` 제약.
|
||||
- **leakDetectionThreshold 채택** (greenfield — 미노출 knob) — 활성화 여부 + 임계값 정책, runbook "leak detection 활성화" 의 실 설정 backing.
|
||||
- **initializationFailTimeout 정책** (greenfield) — 풀 초기화 시 startup fail-fast 동작, runtime-health startup validation 과 정합.
|
||||
- **validationTimeout 정책** (greenfield) — `< connectionTimeout` 제약 강제 (현재 잠재 충돌).
|
||||
- **slow query 탐지 메커니즘** (greenfield) — 어느 계층에서 1s+ 쿼리를 *파라미터 노출 없이* 탐지/로깅할지 (HikariCP 는 쿼리 인터셉터 미제공).
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외 — 다른 owner branch 가 소유하거나 별도 영역.
|
||||
|
||||
- **DB pool env key 등록·검증** (`APP_DATASOURCE_POOL_MAX_SIZE`/`_MIN_IDLE`/`_CONNECTION_TIMEOUT`/`_POOL_IDLE_TIMEOUT`/`_POOL_MAX_LIFETIME` + numeric bounds) → `feature-env-driven-runtime-configuration` 소유. 본 브랜치는 greenfield knob 의 *신규 key 등록을 제안* 하되 등록 자체는 그 브랜치로 위임.
|
||||
- **Pool exhaustion alert threshold** (pool wait p99 > 100ms 5분 → P2, active=max > 1분 → P1) → [[raw/branch-notes/feature-persistence-failure-baseline]] D3 소유.
|
||||
- **Pool metric 이름** (`hikaricp.connections.acquire`/`.usage`/`.active`) → `feature-persistence-failure-baseline` + `feature-metrics-alerting-contract` 공동 소유.
|
||||
- **Pool-acquire-timeout 실패 분류** (커넥션 미확보 → `DB_UNAVAILABLE` 503 retryable) → `feature-persistence-failure-baseline` 소유.
|
||||
- **SQLState classifier / OSIV off** → `feature-persistence-failure-baseline`.
|
||||
- **Read replica lag threshold / PgBouncer transaction pooling** → 미생성 별도 branch (project-note §11 deferred).
|
||||
- **Transaction isolation / lock 정책** → `feature-transaction-concurrency-contract`.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/persistence-hikaricp-configuration-knobs]] | connectionTimeout/maxLifetime/idleTimeout/keepaliveTime/leakDetectionThreshold/validationTimeout/initializationFailTimeout/minimumIdle 기본값·제약·권고 (D1~D7, `HIKARI-CFG-C1~C8`) |
|
||||
| [[raw/official-docs/persistence-hikaricp-pool-sizing-wiki]] | small-pool axiom + sizing formula + pool-locking 공식 + MBean (D1, `HIKARI-POOL-C1~C5`) |
|
||||
| [[raw/official-docs/hibernate-slow-query-log-official]] | Hibernate `SQL_SLOW` 가 materialized SQL(파라미터 치환)을 출력 → prod 금지 근거 (D8, `#C1`/`#C4`) |
|
||||
| [[raw/official-docs/datasource-proxy-slow-query-official]] | datasource-proxy `logSlowQueryBySlf4j` + `ParameterTransformer` 마스킹 (D8, `#C1`/`#C2`) |
|
||||
| [[raw/company-tech-blogs/slow-query-datasource-proxy-spring-boot-galovics]] | datasource-proxy 기본 출력에서 파라미터 노출 실증 (D8, `#C1`) |
|
||||
| [[raw/official-docs/p6spy-configuration-official]] | P6Spy effective SQL 기본 파라미터 노출 + 빌트인 마스킹 부재 → 채택 제외 근거 (D8, `#C2`/`#C3`/`#C4`) |
|
||||
| [[raw/official-docs/postgresql-slow-query-log-official]] | DB-side `log_min_duration_statement` + extended-protocol 파라미터 포함 + 공식 보안 경고 (D8, `#C1`/`#C2`/`#C4`) |
|
||||
| [[raw/company-tech-blogs/postgresql-slow-query-logging-crunchydata]] | PostgreSQL slow query 로그 production 운영 패턴·비용 (D8, `#C1`) |
|
||||
| [[raw/official-docs/datasource-micrometer-observation-official]] | Micrometer JDBC observation 기본 파라미터 미포함(opt-in) (D8, `#C2`) |
|
||||
|
||||
## TODO
|
||||
|
||||
- [x] D1~D8 결정 확정 후 `application.yml` HikariCP block 확장 — 등급: `actually-implemented` (2026-06-09)
|
||||
- [x] validationTimeout < connectionTimeout 제약 위반(현 5000ms = 5s) 정합 — 등급: `actually-implemented` (validation-timeout: 3000 literal, HikariPoolConstraintValidator 강제)
|
||||
- [ ] greenfield knob 신규 env key 제안서 → `feature-env-driven-runtime-configuration` 로 이관 (`APP_DATASOURCE_LEAK_DETECTION_THRESHOLD`, `_KEEPALIVE_TIME`, `_VALIDATION_TIMEOUT`, `_INIT_FAIL_TIMEOUT`, `_SLOW_QUERY_THRESHOLD_MS`) — 등급: `planned`
|
||||
- [ ] slow query 탐지: datasource-proxy + ParameterTransformer 가 slow query 로그에도 마스킹 적용되는지 로컬 검증 — 등급: `needs-confirmation`
|
||||
- [ ] connectionTimeout env 값 포맷 drift(`5s` duration vs ms) 정합 권고 — 등급: `needs-confirmation` (HikariPoolConstraintValidator 가 방어 파싱으로 crash 방지 — actually-implemented)
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- ground truth: `application.yml` 의 `spring.datasource.hikari.*` 5 knob 만 env binding(`app-bootstrap/src/main/resources/application.yml` L25-35). leak/keepalive/validation/init knob 부재. test yml 은 literal(`connection-timeout: 30000`).
|
||||
- adapter-persistence 에 별도 `DataSource`/`@Configuration` 클래스 없음 — 전적으로 Spring Boot auto-config + env binding. 본 브랜치 결정은 **설정값 + (필요 시) 하나의 검증 컴포넌트** 수준이지 datasource bean 재작성이 아님.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-06-09: **고정 크기 풀 권고를 정책으로 채택하되 현 `min-idle=2` 와의 정합은 env-driven 으로 위임** / 이유: HikariCP 공식이 spike 응답성·성능 위해 `minimumIdle` 미설정(=fixed) 권고 / 대안: 탄력적 풀(min<max) — idle eviction 비용 + cold-connection 지연 / 근거: `[[raw/official-docs/persistence-hikaricp-configuration-knobs]]#HIKARI-CFG-C8`
|
||||
- 2026-06-09: **slow query 는 앱 baseline = datasource-proxy + ParameterTransformer, prod 보강 = DB-side, dev = Hibernate SQL_SLOW 허용 / Hibernate SQL_SLOW prod 금지, P6Spy 제외** / 이유: "SQL/param 로그 금지" 하드 룰 하에서 앱 레이어 명시적 마스킹 제어 가능한 유일 방식 / 대안: Hibernate SQL_SLOW(파라미터 materialized 노출), P6Spy(마스킹 API 부재), DB-side(DBA 의존) / 근거: 아래 D8 Supporting Claims
|
||||
- (나머지 D2~D7 — Decision Evidence Map 참조)
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> `Supporting Claims` 는 `raw/<category>/<slug>.md#C1` 형식. `선택 조건` = 언제 이 결정 / 언제 대안.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | **Pool sizing 정책**: 고정 크기 풀(`minimumIdle = maximumPoolSize`) 을 권고 baseline 으로 고정. `maximumPoolSize` default(=10) 는 small-pool axiom + PostgreSQL formula 의 starting point 로 정당화하고, 부하 테스트로 조정. pool 하한은 application-port D12 `REQUIRES_NEW` 공식(`maxPoolSize ≥ concurrent_threads × (1 + max_inNew_depth) + 1`) 을 만족해야 함 | 일반 use case → fixed-size; spike/탄력 수요 명시 분석 있을 때만 min<max 탄력 풀 | `raw/official-docs/persistence-hikaricp-configuration-knobs.md#HIKARI-CFG-C8`, `raw/official-docs/persistence-hikaricp-pool-sizing-wiki.md#HIKARI-POOL-C1`, `#HIKARI-POOL-C2`, `#HIKARI-POOL-C4` + **cross-branch**: application-port D12 | `official-reference` (HikariCP wiki) + `cross-branch-delegation` | 현 registry `min-idle=2`(탄력) 가 fixed 권고와 불일치 → §Audit `MIN_IDLE_POLICY_DRIFT`. 값 변경은 env-driven 소유라 본 브랜치는 *정책 권고* 만 |
|
||||
| D2 | **connectionTimeout fail-fast pin**: 30s 기본에 의존하지 않고 명시 pin(현 5s). 풀 고갈 시 30s 동안 스레드 점유 대신 빠르게 503 으로 실패시키는 정책. 최솟값 250ms 준수 | 동기 HTTP 요청 경로 → 짧은 fail-fast(수 초); 배치/장시간 작업 전용 풀이면 별도 더 긴 값 허용 | `raw/official-docs/persistence-hikaricp-configuration-knobs.md#HIKARI-CFG-C1` | `official-reference` | 정확한 값(5s)이 SLA 에 맞는지는 미증명 — env 값 owner=env-driven. acquire-timeout *실패 분류* 는 persistence-failure(`DB_UNAVAILABLE`) |
|
||||
| D3 | **maxLifetime < DB/인프라 idle limit**: production 최우선 설정. DB(`wait_timeout`)·proxy(PgBouncer)·방화벽이 강제하는 커넥션 수명보다 수 초 짧게. 현 30분 default 는 실제 DB limit 확인 후 정합 | 항상 적용 (모든 환경). DB limit 미확인 시 30분 default 잠정 유지 + `needs-confirmation` | `raw/official-docs/persistence-hikaricp-configuration-knobs.md#HIKARI-CFG-C2` | `official-reference` (공식 strong recommend) | "수 초" 의 정확한 마진을 HikariCP 가 수치 미지정 → DB별 `wait_timeout` 확인 필요(§Claims) |
|
||||
| D4 | **keepaliveTime 채택**(greenfield): 유휴 커넥션이 DB/방화벽에 의해 끊기는 것 방지하는 ping 활성화. `< maxLifetime` 제약. default 120000ms(2분) | 커넥션이 NAT/방화벽/클라우드 LB 뒤 → 활성화; 동일 호스트 로컬 DB 만이면 생략 가능 | `raw/official-docs/persistence-hikaricp-configuration-knobs.md#HIKARI-CFG-C4` | `official-reference` | DB/방화벽 실제 idle timeout 미확인 시 keepalive 주기 산정 불가(§Claims). 신규 env key 필요 → env-driven 위임 |
|
||||
| D5 | **leakDetectionThreshold 채택**(greenfield): 커넥션 누수 조기 경고 활성화. 활성화 최솟값 2000ms 이상으로 설정. runbook "pool 고갈 시 leak detection 활성화" 의 상시 backing | 정상 트랜잭션 최대 지속시간보다 충분히 큰 값으로 설정 가능할 때 활성화; long-running 배치 풀은 false positive 위험으로 비활성/별도 풀 | `raw/official-docs/persistence-hikaricp-configuration-knobs.md#HIKARI-CFG-C5` + **cross-branch**: persistence-failure runbook `dependency-unavailable.md` | `official-reference` + `internal-runbook` | "프로덕션 적정 임계값" 은 공식 미정의 — long-running tx false positive(§Claims). 신규 env key → env-driven |
|
||||
| D6 | **initializationFailTimeout fail-fast**: 풀 초기화 시 DB 미가용이면 startup 실패(default 1=fail-fast 유지). runtime-health startup validation + project-note §9 "잘못된 env 값 startup fail-fast" 정합 | 일반 서비스 → fail-fast(양수 default 유지); DB 가 앱보다 늦게 뜨는 보장 없는 컨테이너 오케스트레이션은 음수값 신중 검토(out-of-scope 위임) | `raw/official-docs/persistence-hikaricp-configuration-knobs.md#HIKARI-CFG-C7` + **cross-branch**: runtime-health-lifecycle startup validation | `official-reference` + `cross-branch-delegation` | 컨테이너 起動 순서(DB before app) 미보장 환경의 음수값 안전성 미증명 → runtime-health 와 조율 |
|
||||
| D7 | **validationTimeout < connectionTimeout 강제**: aliveness 검증 시간이 acquire 타임아웃을 넘지 않게. default 5000ms 는 connectionTimeout 5s(=5000ms) 와 **동일 → 제약 위반** 이므로 connectionTimeout 상향 또는 validationTimeout 하향 중 택1 | connectionTimeout=5s 유지 시 → validationTimeout 명시 하향(예 3s); connectionTimeout 상향 결정 시 → default 유지 가능 | `raw/official-docs/persistence-hikaricp-configuration-knobs.md#HIKARI-CFG-C6`, `#HIKARI-CFG-C1` | `official-reference` | 현 설정 잠재 충돌 = §Audit `VALIDATION_TIMEOUT_CONFLICT`. 두 값 모두 env-driven 소유 — 본 브랜치 정책 권고 |
|
||||
| D8 | **slow query 탐지 메커니즘**: 앱 baseline = **datasource-proxy + ParameterTransformer**(파라미터 `[REDACTED]` 마스킹), prod 보강 = **DB-side `log_min_duration_statement`**(앱 로그에 SQL 미도달), dev = **Hibernate SQL_SLOW 허용**. **Hibernate SQL_SLOW prod 금지**(materialized SQL 파라미터 노출), **P6Spy 제외**(마스킹 API 부재). 하드 룰 "SQL/param 로그 금지" 와 정합 | APM 있으면 datasource-micrometer(기본 param opt-out)로 대체 가능; DBA 분리 운영이면 DB-side 우선; dev 빠른 확인엔 Hibernate SQL_SLOW | `raw/official-docs/datasource-proxy-slow-query-official.md#C1`, `#C2`, `raw/company-tech-blogs/slow-query-datasource-proxy-spring-boot-galovics.md#C1`, `raw/official-docs/hibernate-slow-query-log-official.md#C1`, `raw/official-docs/p6spy-configuration-official.md#C3`, `raw/official-docs/postgresql-slow-query-log-official.md#C2`, `#C4`, `raw/official-docs/datasource-micrometer-observation-official.md#C2` | `official-reference` × 4 + `company-case-study` × 2 | ParameterTransformer 가 *slow query 리스너 출력에도* 적용되는지 공식 미보장 → 로컬 검증 전 `needs-confirmation`(§Claims) |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 본 브랜치 결정(D1~D8)에서 *도출되는 in-scope 설정/컴포넌트* 만. 값 자체(env key)는 env-driven 소유 → 여기서는 *정책의 application.yml 표현* 과 *결정이 강제하는 제약* 만 명세.
|
||||
|
||||
### 1. HikariCP knob 설정 정책 (application.yml 표현)
|
||||
|
||||
> **Trace**: D1(`#HIKARI-CFG-C8`) · D2(`#HIKARI-CFG-C1`) · D3(`#HIKARI-CFG-C2`) · D4(`#HIKARI-CFG-C4`) · D5(`#HIKARI-CFG-C5`) · D6(`#HIKARI-CFG-C7`) · D7(`#HIKARI-CFG-C6`). 현 SSOT = `app-bootstrap/src/main/resources/application.yml` L25-35 (`spring.datasource.hikari.*`, 5 knob). env key owner = `feature-env-driven-runtime-configuration`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: greenfield knob 의 *신규 env key 이름*(`APP_DATASOURCE_LEAK_DETECTION_THRESHOLD` / `_KEEPALIVE_TIME` / `_VALIDATION_TIMEOUT` / `_INIT_FAIL_TIMEOUT`)은 cited raw 가 권고하지 않음 — 기존 `APP_DATASOURCE_*` 명명 컨벤션 차용한 임의 제안. trade-off: 컨벤션 일관성 vs env-driven 이 최종 명명 소유(이관 시 변경 가능).
|
||||
> - **UNSUPPORTED_IMPL_DECISION** (maxLifetime 마진, D3): `#HIKARI-CFG-C2` 는 "several seconds shorter" 만 권고하고 *정확한 마진 초수* 미지정. DB `wait_timeout` 확인 전 임시 보수값으로 **마진 60s** (`max-lifetime = DB_idle_limit − 60s`) 제안. trade-off: 큰 마진=죽은 커넥션 위험 ↓ / 커넥션 회전 ↑, 작은 마진=경계 race. DBA 확인 + 부하테스트로 조정.
|
||||
> - **UNSUPPORTED_IMPL_DECISION** (leak threshold 값, D5): `#HIKARI-CFG-C5` 는 최솟값(2000ms)만 정의, *프로덕션 적정값* 미지정. ca-tmpl 정상 트랜잭션이 단건(배치 풀 부재) 전제 하에 **임시 30000ms(30s)** 제안 — 최장 트랜잭션 추정 ~5s 대비 충분한 여유로 false positive 회피. trade-off: 작을수록 누수 조기탐지 / long-tx 오탐 ↑. 실측 트랜잭션 분포로 조정.
|
||||
|
||||
| knob (Spring property) | 현 상태 | 본 브랜치 정책 | 제약 | 상태 |
|
||||
|---|---|---|---|---|
|
||||
| `maximum-pool-size` | env binding (default 10) | small-pool + formula 근거 (D1). 값 변경은 env-driven | ≥ application-port D12 하한 | `actually-implemented` (binding) |
|
||||
| `minimum-idle` | env binding (default 2) | fixed-size 권고: `= maximum-pool-size` (D1) | 권고 위반 시 §Audit drift | `planned` (정책 정합) |
|
||||
| `connection-timeout` | env binding (default `5s`) | fail-fast pin (D2) | ≥ 250ms; 포맷 drift 정합 | `needs-confirmation` (포맷) |
|
||||
| `max-lifetime` | env binding (default 30분) | < DB `wait_timeout` 수 초 (D3) | DB limit 확인 필요 | `planned` |
|
||||
| `idle-timeout` | env binding (default 10분) | fixed-size 면 무효(D1 시 N/A) | `min-idle < max` 일 때만 적용 | `actually-implemented` (binding) |
|
||||
| `keepalive-time` | **미설정** | 채택 (D4) | `< max-lifetime` | `actually-implemented` (literal 120000, HikariPoolConstraintValidator 강제) |
|
||||
| `leak-detection-threshold` | **미설정** | 채택 ≥ 2000ms (D5) | ≥ 2000ms | `actually-implemented` (literal 30000, HikariPoolConstraintValidator 강제) |
|
||||
| `validation-timeout` | **미설정** (default 5000ms) | `< connection-timeout` 강제 (D7) | < connectionTimeout | `actually-implemented` (literal 3000, HikariPoolConstraintValidator 강제) |
|
||||
| `initialization-fail-timeout` | **미설정** (default 1) | fail-fast 유지 (D6) | runtime-health 조율 | `actually-implemented` (literal 1) |
|
||||
|
||||
### 2. Slow query 탐지 wiring (D8)
|
||||
|
||||
> **Trace**: D8. baseline = datasource-proxy `ProxyDataSourceBuilder.logSlowQueryBySlf4j(threshold, TimeUnit)` (`datasource-proxy#C1`) + `ParameterTransformer` Bean 으로 전 파라미터 `[REDACTED]` 치환 (`#C2`). 하드 룰 "SQL/param 로그 금지" = persistence-failure In-scope 와 정합.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: slow query **임계값(1000ms)** 과 **로그 레벨(WARN)** 은 cited raw 가 권고하지 않는 운영 SLO — 임의 채택. trade-off: 1s=일반적 사용자 체감 경계 vs 워크로드별 상이(부하 테스트로 조정). 신규 env key `APP_DATASOURCE_SLOW_QUERY_THRESHOLD_MS` 제안.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 라이브러리 선택(datasource-proxy vs spring-boot-data-source-decorator 경유)은 cited raw 가 둘 다 제시 — Spring Boot 3.x 통합 검증된 `spring-boot-data-source-decorator` 경유를 임의 채택. trade-off: 자동 wiring vs 의존성 2개. application.yml property = `decorator.datasource.datasource-proxy.slow-query.threshold` (**초 단위** — ms env key 와 단위 변환 필요), `.slow-query.log-level=warn`. ParameterTransformer 는 `@Bean` 등록(빌트인 마스킹 부재).
|
||||
|
||||
| 항목 | 명세 | 근거 | 상태 |
|
||||
|---|---|---|---|
|
||||
| baseline 메커니즘 | datasource-proxy SlowQueryListener + ParameterTransformer | `datasource-proxy#C1`/`#C2` | `planned` |
|
||||
| 파라미터 마스킹 | 전 파라미터 `[REDACTED]` 치환 Bean | `datasource-proxy#C2` | `needs-confirmation` (slow 리스너 적용 검증) |
|
||||
| prod 보강 | DB-side `log_min_duration_statement` (DBA 소유) | `postgresql-slow-query#C1` | `documented-only` |
|
||||
| dev 허용 | Hibernate `LOG_QUERIES_SLOWER_THAN_MS` (prod 금지) | `hibernate-slow-query#C4`/`#C1` | `documented-only` |
|
||||
| 제외 | P6Spy (마스킹 API 부재, format 우회 실수 위험) | `p6spy#C3`/`#C4` | rejected |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **Pool acquire timeout**: connectionTimeout(5s) 내 커넥션 미확보 → `DB_UNAVAILABLE`(503, retryable) **분류는 persistence-failure 소유**. 본 브랜치는 timeout *값/정책* 만(D2).
|
||||
- **validationTimeout ≥ connectionTimeout 충돌**: 현 default 5000ms = connectionTimeout 5s → HikariCP 제약 위반(`#HIKARI-CFG-C6`). 起動 시 reset/경고 가능 → D7 로 정합 필수.
|
||||
- **maxLifetime ≥ DB wait_timeout**: DB 가 먼저 끊은 죽은 커넥션을 풀이 반환 → 첫 쿼리 실패. keepalive(D4) + maxLifetime(D3) 둘 다로 방어. DB limit 미확인이 핵심 미지수.
|
||||
- **leak false positive**: long-running 트랜잭션(배치)이 leakDetectionThreshold 초과 → 오탐 로그. D5 선택 조건으로 분리.
|
||||
- **slow query 파라미터 누수**: 마스킹 미적용 시 PII 노출 → 하드 룰 위반. ParameterTransformer 가 slow 리스너에 적용되는지 미검증(§Claims).
|
||||
- **startup DB 미가용**: initializationFailTimeout 양수 → 起動 실패(fail-fast, 의도). 컨테이너 기동 순서 미보장 시 crash loop 가능 → runtime-health 와 조율.
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-env-driven-runtime-configuration]] 의 `APP_DATASOURCE_*` env key (pool/timeout) 에 의존 — 본 브랜치가 정책을 정하면 그 키의 default/validation 갱신·신규 키 등록을 그 브랜치가 수행. 계약 변경 시 본 정책 재검토.
|
||||
- [[raw/branch-notes/feature-persistence-failure-baseline]] 의 D3(Hikari alert) + acquire-timeout → `DB_UNAVAILABLE` 분류에 의존 — 본 브랜치의 timeout 값이 alert threshold 의미를 바꾸면 D3 재검토.
|
||||
- [[raw/branch-notes/feature-application-port-usecase-contract]] 의 D12(`REQUIRES_NEW` pool 하한 공식) 에 의존 — maximumPoolSize 하한이 그 공식을 만족해야 함.
|
||||
- [[raw/branch-notes/feature-metrics-alerting-contract]] 의 pool metric(`hikaricp.connections.*`) 에 의존 — leak/keepalive 효과 관측은 그 metric 으로.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| DB(`wait_timeout`)/PgBouncer/방화벽의 실제 idle timeout 값 | maxLifetime(D3)·keepalive(D4) 산정의 입력인데 환경마다 다름 | 대상 DB `SHOW wait_timeout` / 인프라 설정 확인 후 maxLifetime = limit − 수 초 | `needs-confirmation` |
|
||||
| datasource-proxy ParameterTransformer 가 **slow query 로그 출력에도** 마스킹 적용 | 공식 문서가 slow 리스너 적용을 명시 보장 안 함 (`datasource-proxy#C2`) | PII 포함 파라미터로 1s+ 쿼리 유발 후 로그에 `[REDACTED]` 확인 | `needs-confirmation` |
|
||||
| connectionTimeout env 값 포맷 `5s`(duration) 가 Spring Boot HikariCP 바인딩에서 정상 동작 | registry default `5s` vs application.yml 주석 "milliseconds" vs test literal `30000` 불일치 | 起動 후 `HikariConfig.connectionTimeout` 실측 / 잘못된 포맷이면 정합 | `needs-confirmation` |
|
||||
| validationTimeout < connectionTimeout 제약 위반 시 HikariCP 실제 동작(경고/reset) | 현 default 동일값(5000ms) — 위반 결과 미확인 (`#HIKARI-CFG-C6`) | 두 값 동일 설정 起動 로그 확인 → D7 값으로 정합 | `planned` |
|
||||
| fixed-size(`min-idle=max`) 전환이 현 `min-idle=2` 대비 spike 응답성 개선 | 공식 권고지만 ca-tmpl 워크로드 미측정 (`#HIKARI-CFG-C8`) | 부하 테스트로 pool pending/acquire p99 비교 | `planned` |
|
||||
| Hibernate SQL_SLOW 가 사용 JDBC 드라이버(Postgres/MySQL)에서 파라미터 materialized 노출 | 드라이버 `PreparedStatement.toString()` 구현 의존 (`hibernate-slow-query#C1`) | dev 에서 파라미터 포함 쿼리 로그 확인 → prod 금지 근거 확정 | `needs-confirmation` |
|
||||
|
||||
## Audit & Findings
|
||||
|
||||
> ground-truth(`/home/donghyeon/workspace/ca-tmpl`) 대조에서 발견한 drift/정합 항목. 사용자 작성 결정 영역(env 값)은 자동 rewrite 하지 않고 *정합 권고* 만.
|
||||
|
||||
- **`MIN_IDLE_POLICY_DRIFT`** (Should-fix): registry `APP_DATASOURCE_POOL_MIN_IDLE=2` (탄력 풀) vs HikariCP fixed-size 권고(`#HIKARI-CFG-C8`). D1 정책과 불일치 → env-driven 으로 정합 권고(값 owner=env-driven).
|
||||
- **`VALIDATION_TIMEOUT_CONFLICT`** (Should-fix): validationTimeout default 5000ms = connectionTimeout 5s → `validationTimeout < connectionTimeout` 제약 위반(`#HIKARI-CFG-C6`). D7 로 정합.
|
||||
- **`CONNECTION_TIMEOUT_FORMAT_DRIFT`** (needs-confirmation): `env-keys.yaml` default `5s`(duration) vs `application.yml` 주석 "milliseconds" vs `application-test.yml` literal `30000`. Spring Boot 바인딩 실 동작 확인 필요(§Claims). owner=env-driven.
|
||||
- **greenfield knob 미등록** (OUT_OF_BRANCH_SCOPE → env-driven): `leakDetectionThreshold`/`keepaliveTime`/`validationTimeout`/`initializationFailTimeout` 는 registry·코드 모두 부재. 본 브랜치가 채택 결정(D4~D7) → 신규 env key 등록은 env-driven 으로 이관.
|
||||
- **slow query 관심사 무주공산 확인**: 어느 sibling 도 slow query 탐지 미소유(persistence-failure 는 `SQL/param 로그 금지` 라는 *반대* 정책만). D8 로 본 브랜치가 covered-here.
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
> 아래는 `/coverage` 실행 전 *사전 매핑*. coverage-auditor 가 governing doc 대조로 재생성한다.
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|--------|------|-------|--------|------|
|
||||
| Pool sizing 정책 (formula/fixed-size) | covered-here | — | — | D1 |
|
||||
| connectionTimeout 정책 | covered-here | — | — | D2 |
|
||||
| maxLifetime < DB limit | covered-here | — | — | D3 |
|
||||
| keepaliveTime | covered-here | — | — | D4 |
|
||||
| leakDetectionThreshold | covered-here | — | — | D5 |
|
||||
| initializationFailTimeout (startup fail-fast) | covered-here | — | — | D6 |
|
||||
| validationTimeout 제약 | covered-here | — | — | D7 |
|
||||
| slow query 탐지 (param-safe) | covered-here | — | — | D8 |
|
||||
| DB pool env key 등록·검증 | delegated | [[raw/branch-notes/feature-env-driven-runtime-configuration]] | OK | Out of scope + §Audit |
|
||||
| Pool exhaustion alert threshold | delegated | [[raw/branch-notes/feature-persistence-failure-baseline]] | OK | D3(persistence) §Parent |
|
||||
| Pool metric 이름 | delegated | [[raw/branch-notes/feature-persistence-failure-baseline]] / [[raw/branch-notes/feature-metrics-alerting-contract]] | OK | §Parent |
|
||||
| Pool-acquire-timeout 실패 분류 | delegated | [[raw/branch-notes/feature-persistence-failure-baseline]] | OK | §엣지 |
|
||||
| Pool-sizing 하한 공식 (REQUIRES_NEW) | delegated | [[raw/branch-notes/feature-application-port-usecase-contract]] | OK | D1 cross-branch |
|
||||
|
||||
## 구현 완료 항목 (2026-06-09)
|
||||
|
||||
### 파일 변경
|
||||
|
||||
| 파일 | 상태 | 내용 |
|
||||
|---|---|---|
|
||||
| `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/HikariPoolConstraintValidator.java` | added | SmartInitializingSingleton; D2/D4/D5/D7 inter-knob constraint 시작 guard; parseMillis 방어 파싱 (CONNECTION_TIMEOUT_FORMAT_DRIFT 대응) |
|
||||
| `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java` | modified | hikariPoolConstraintValidator @Bean 추가 |
|
||||
| `src/app-bootstrap/src/main/resources/application.yml` | modified | existing 5 knob 에 D1~D3 decision comment 추가; greenfield 4 knob literal 추가 (keepalive-time/leak-detection-threshold/validation-timeout/initialization-fail-timeout); D8 slow-query DOCUMENTATION comment block 추가 |
|
||||
| `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/HikariPoolConstraintValidatorTest.java` | added | ApplicationContextRunner 기반 10개 케이스 (TDD — 실패 후 구현). boundary(connection-timeout=250 통과) + keepalive==max-lifetime 위반 케이스 포함 |
|
||||
| `src/app-bootstrap/src/test/resources/application-test.yml` | modified | greenfield 4 knob literal 추가 (test context parity) |
|
||||
|
||||
### 리뷰 체인 (ca-tmpl SDD)
|
||||
|
||||
- `ca-architect-sentinel` → PASS: validator 는 business rule 아님(HikariCP 자체 invariant guard), app-bootstrap 한정, 의존성 그래프 불변
|
||||
- `ca-spec-reviewer` → PASS: 36/36 요구사항 MET, extra 없음, 음성 제약(.env/env-keys/build.gradle 무변경) 충족
|
||||
- `ca-quality-reviewer` → NEEDS_FIX 2 Important + 3 Minor → **모두 수정 반영**:
|
||||
- 위반 메시지가 operator-facing env key 명명 (`APP_DATASOURCE_CONNECTION_TIMEOUT`/`APP_DATASOURCE_POOL_MAX_LIFETIME`; greenfield 3종은 "env key pending feature-env-driven-runtime-configuration"). sibling RuntimeNumericBoundsValidator/OpenInViewSafetyValidator 계약 일치
|
||||
- 테스트가 `APP_DATASOURCE_CONNECTION_TIMEOUT` 문자열 핀 추가(계약 회귀 방지)
|
||||
- keepalive 테스트 메서드명 정정 + equal-case 추가, connection-timeout=250 boundary 통과 케이스 추가, application-test.yml D6 ✓ 주석 보강
|
||||
|
||||
### 검증 결과
|
||||
|
||||
- `./gradlew :app-bootstrap:test --tests 'dev.caskeleton.bootstrap.runtime.HikariPoolConstraintValidatorTest'` → BUILD SUCCESSFUL (10 tests, 0 fail) — test-results XML 로 실측 확인
|
||||
- `./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest'` → BUILD SUCCESSFUL
|
||||
- `./gradlew :app-bootstrap:test` → BUILD SUCCESSFUL (전체 모듈 회귀 없음)
|
||||
- `./gradlew verifyCleanArchitectureDependencies` → BUILD SUCCESSFUL
|
||||
|
||||
### 결정 이행 상태 업데이트
|
||||
|
||||
| Decision | 이전 상태 | 현재 상태 |
|
||||
|---|---|---|
|
||||
| D1 (pool sizing 정책 comment) | `planned` | `actually-implemented` |
|
||||
| D2 (connection-timeout comment + >= 250 강제) | `needs-confirmation` | `actually-implemented` |
|
||||
| D3 (max-lifetime comment) | `planned` | `actually-implemented` |
|
||||
| D4 (keepalive-time literal) | `planned` (greenfield) | `actually-implemented` (literal 120000) |
|
||||
| D5 (leak-detection-threshold literal) | `planned` (greenfield) | `actually-implemented` (literal 30000) |
|
||||
| D6 (initialization-fail-timeout literal) | `planned` (greenfield) | `actually-implemented` (literal 1) |
|
||||
| D7 (validation-timeout literal + constraint 강제) | `planned` (greenfield) | `actually-implemented` (literal 3000, HikariPoolConstraintValidator) |
|
||||
| D8 (slow-query DOCUMENTATION) | `documented-only` | `documented-only` (policy comment in application.yml, no code) |
|
||||
|
||||
### 미이행 (타 브랜치 위임)
|
||||
|
||||
- greenfield knob 신규 env key 등록 (`APP_DATASOURCE_KEEPALIVE_TIME` 등) → `feature-env-driven-runtime-configuration`
|
||||
- datasource-proxy + ParameterTransformer slow-query 마스킹 검증 (D8 TODO #3)
|
||||
- DB `wait_timeout` 확인 후 max-lifetime / keepalive-time 조정
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- (없음 — scaffold 단계)
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/postgresql-slow-query-logging-crunchydata]]
|
||||
- [[raw/company-tech-blogs/slow-query-datasource-proxy-spring-boot-galovics]]
|
||||
- [[raw/official-docs/datasource-micrometer-observation-official]]
|
||||
- [[raw/official-docs/datasource-proxy-slow-query-official]]
|
||||
- [[raw/official-docs/hibernate-slow-query-log-official]]
|
||||
- [[raw/official-docs/p6spy-configuration-official]]
|
||||
- [[raw/official-docs/persistence-hikaricp-configuration-knobs]]
|
||||
- [[raw/official-docs/postgresql-slow-query-log-official]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/hikaricp-inter-knob-constraints-startup-guard-2026-06-09]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
> 이 branch는 단일 노트가 아니라 **작업 묶음의 entry point**.
|
||||
|
||||
### 근거 자료
|
||||
|
||||
- [[raw/official-docs/persistence-hikaricp-configuration-knobs]] — HikariCP 공식 README 설정 레퍼런스 (connectionTimeout·maxLifetime·idleTimeout·keepaliveTime·leakDetectionThreshold·validationTimeout·initializationFailTimeout·minimumIdle 기본값·권고 근거; Claims HIKARI-CFG-C1~C8)
|
||||
- [[raw/official-docs/persistence-hikaricp-pool-sizing-wiki]] — HikariCP About Pool Sizing (small-pool axiom + formula + pool-locking; HIKARI-POOL-C1~C6)
|
||||
- [[raw/official-docs/hibernate-slow-query-log-official]] — Hibernate `SQL_SLOW` / `LOG_QUERIES_SLOWER_THAN_MS` 파라미터 노출 동작
|
||||
- [[raw/official-docs/datasource-proxy-slow-query-official]] — datasource-proxy slow query listener + ParameterTransformer 마스킹
|
||||
- [[raw/company-tech-blogs/slow-query-datasource-proxy-spring-boot-galovics]] — datasource-proxy 기본 파라미터 노출 실증
|
||||
- [[raw/official-docs/p6spy-configuration-official]] — P6Spy executionThreshold + 기본 파라미터 노출(채택 제외 근거)
|
||||
- [[raw/official-docs/postgresql-slow-query-log-official]] — PostgreSQL `log_min_duration_statement` DB-side 탐지 + 보안 경고
|
||||
- [[raw/company-tech-blogs/postgresql-slow-query-logging-crunchydata]] — PostgreSQL slow query 로그 production 운영 패턴
|
||||
- [[raw/official-docs/datasource-micrometer-observation-official]] — Micrometer JDBC observation (기본 파라미터 미포함)
|
||||
|
||||
### Sub-branches (세부 작업)
|
||||
|
||||
- (없음)
|
||||
|
||||
### 오류 기록 (이 branch 작업 중 발생)
|
||||
|
||||
- (없음)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (생성 시 연결)
|
||||
|
||||
### 강의 (이 작업을 위해 학습한 강의)
|
||||
|
||||
- (없음)
|
||||
|
||||
### job-posting tie-ins (이 작업에서 파생된 글감)
|
||||
|
||||
- [[raw/blog-topics/hikaricp-inter-knob-constraints-startup-guard-2026-06-09]] — HikariCP knob 간 제약을 Spring Boot 시작 guard 로 강제하는 패턴 (SmartInitializingSingleton + defensive parseMillis)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- (작업 시 연결)
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움. `/ingest`가 이 섹션을 기준으로 wiki/projects/에 추출.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-dependency-vulnerability-management-contract.md
|
||||
@@ -0,0 +1,493 @@
|
||||
---
|
||||
title: branch / feature-dependency-vulnerability-management-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
confidence: medium
|
||||
branch: feature-dependency-vulnerability-management-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||||
tags: [branch, ca-skeleton, security, supply-chain, ci]
|
||||
created: 2026-06-15
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-051
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-051
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-BUILD-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-028, WI-CA-SKELETON-OPERATIONAL-CONTRACT-030, WI-CA-SKELETON-OPERATIONAL-CONTRACT-029]
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: bb574e1b56cc247fc24b861ef1249c28991938b0dab6bab63999d5cf9ffb756b
|
||||
---
|
||||
|
||||
# branch: feature-dependency-vulnerability-management-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
> **계층 표기**: "root branch" 라는 별도 개념은 없음. project 의 직접 자식 branch 는 `parent_branch:` 를 **비워두고** `related_projects` 만 채움. 다른 branch 의 자식이면 `parent_branch: <부모 branch 이름>` 명시 + `## Parent` 섹션의 부모 wikilink 필수.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
> 이 branch 가 어느 작업 묶음에 속하는지. 모든 branch 는 예외 없이 upward link 보유.
|
||||
|
||||
- **Project 의 직접 자식 branch** (`parent_branch:` 비어있음): [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
- 본 branch 는 project-note 의 §18 Control Plane Contract 중 **Build / Release / Supply Chain** (의존성 취약점 차단) + **CI Quality Gates** (vulnerability scan gate) 영역을 정제한다.
|
||||
|
||||
형제 branch (같은 부모의 다른 자식 — 본 branch 와 계약 경계를 공유):
|
||||
|
||||
- [[raw/branch-notes/feature-build-release-supply-chain-contract]] — SBOM·서명(Cosign/SLSA)·dependency **locking**·artifact versioning 의 owner. 본 branch 는 그 D2(high/critical=release-blocking)·D3(Renovate/Dependabot) 의 `UNSUPPORTED_DECISION` 스텁을 **승계해 정책 owner** 가 된다(아래 §Audit & Findings).
|
||||
- [[raw/branch-notes/feature-ci-quality-gates-contract]] — CI gate **wiring**(release-blocking vs warning-only) 의 owner. 본 branch 의 severity 정책을 *consume*. 그 D5(Trivy scanner+suppression) `UNSUPPORTED_DECISION` 스텁도 본 branch 가 정책 owner 로 정합.
|
||||
- [[raw/branch-notes/feature-container-runtime-contract]] — container **image** scan wiring + base image(Temurin JRE slim) owner. 본 branch 의 동일 severity 정책을 *consume*.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: scanner·severity·suppression·update·license 정책과 CI failure gate가 명시된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-BUILD-001@1` | build tool은 Gradle Groovy DSL이다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
ca-skeleton 의 §18 Build/Release/Supply Chain 과 CI Quality Gates 에는 "high/critical vulnerability 는 release-blocking" (supply-chain D2) 과 "vulnerability scanner = Trivy + suppression" (ci-gates D5), "dependency upgrade = Renovate/Dependabot" (supply-chain D3) 라는 **정책 의도만 있고 외부 근거 없는 `UNSUPPORTED_DECISION` 스텁**이 세 sibling branch 에 흩어져 있다. 어느 branch 도 *어떤 스캐너 / 어떤 심각도 표준 / 어떤 임계값 / 어떻게 suppress / 얼마나 빨리 고칠지* 를 근거와 함께 정하지 않았다 — 즉 **의존성 취약점 관리 정책의 single owner 가 없다**.
|
||||
|
||||
본 branch 는 그 빈 자리를 메우는 **dependency vulnerability *정책* 의 single owner** 다 (§25 SSOT Owner Map 에 해당 owner 부재 확인 → Cross-Branch Conflict Procedure 통과). 정의 대상: SCA 스캐너 선택, CVSS 심각도 표준·차단 임계값, KEV override, 스캐너 소스 우선순위(tie-break), suppression governance(만료·사유·무단변경 차단), 의존성 보안 업데이트 자동화(Renovate/Dependabot), PR-time 보완 게이트(dependency-review-action). gate *wiring* 은 ci-gates 가, image scan *wiring* 은 container-runtime 이, SBOM/서명/locking 은 supply-chain 이 소유하고 — 셋 다 본 branch 의 severity 정책을 *consume* 한다.
|
||||
|
||||
- 이슈: (미생성 — Phase C2 실 구현 단계에 연결)
|
||||
- PR: (미생성)
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- **SCA 스캐너 선택** — 의존성(라이브러리) CVE 스캔 도구. ci-gates 의 잠정 "Trivy" 를 공식 근거로 승격/검증.
|
||||
- **심각도 분류 표준 + 차단 임계값** — CVSS 버전, 점수→등급 매핑, 어느 등급부터 release-blocking. supply-chain D2 의 "high/critical=blocking" 에 외부 표준 부여.
|
||||
- **KEV override** — 실제 악용(exploited in the wild) CVE 는 CVSS 점수 무관 차단.
|
||||
- **스캐너 심각도 소스 우선순위(tie-break)** — NVD vs GHSA/벤더 점수 충돌 시 규칙.
|
||||
- **Suppression governance** — `.trivyignore` 포맷 + 만료일 강제 + 사유 기록 + PR 승인 + 무단 변경 차단 정적 게이트(2026-05-25 audit finding 해소).
|
||||
- **의존성 보안 업데이트 자동화** — Renovate primary / Dependabot 조건부 + patch-level 보안 PR auto-merge 정책. supply-chain D3 정합.
|
||||
- **PR-time 보완 게이트** — GitHub dependency-review-action 으로 신규 도입 취약 의존성 차단(전체 스냅샷 스캔과 역할 분리).
|
||||
- **스캔 단계/스코프** — PR 게이트 + 의존성 불변이라도 CVE DB 갱신을 잡는 scheduled 재스캔 + pre-release image scan.
|
||||
- **의존성 라이선스 준수 스캔(license/NOTICE)** — Trivy 가 이미 `*gradle.lockfile` 의 license 도 스캔(License ✓)하므로 통합. 금지(strong-copyleft) 라이선스 release-blocking + allow-list 정책 + PR-time allow/deny(dependency-review-action). governing §35-E L2052 가 *license scan* 을 본 branch 영역으로 명시.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- **CI gate wiring(release-blocking vs warning-only 판정·`needs:`/`if:` 의존성 구성)** — `feature-ci-quality-gates-contract` owner. 본 branch 는 정책을 제공만.
|
||||
- **container image scan wiring + base image 선택** — `feature-container-runtime-contract` owner (본 branch severity 정책 consume).
|
||||
- **SBOM 생성·artifact 서명(Cosign/SLSA)·dependency *version locking*·artifact versioning·rollback** — `feature-build-release-supply-chain-contract` owner.
|
||||
- **secret scan(gitleaks)** — `feature-secrets-config-source-contract` owner.
|
||||
- **특정 CI provider(GitHub Actions) workflow YAML 의 실제 구현 세부** — 본 branch 는 계약/정책, 구현은 Phase C2.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 이 branch의 구현·설계 결정의 **근거가 되는 외부 자료**. 공식 문서·대기업 기술 블로그·강의 등 raw 자료를 인용. 같은 자료가 여러 결정의 근거면 결정 표시와 함께 여러 번 등장 가능.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/trivy-action-github-actions]] | D1/게이트 — exit-code+severity 로 release-blocking CI 게이트 구성, trivyignores 파라미터로 suppression 파일 지정 |
|
||||
| [[raw/official-docs/github-dependency-review-action]] | PR-time 보완 게이트 — 신규 도입 취약 의존성 차단(C1·C2). 단독 릴리즈 게이트 부적합(PR diff 전용, C4). severity 커스터마이즈 가능(C3). |
|
||||
| [[raw/official-docs/trivy-filtering-suppression-policy]] | suppression governance — `.trivyignore` `exp:YYYY-MM-DD` 만료일(C3), `.trivyignore.yaml` `expired_at` 필드(C4) 로 영구 suppress 방지; `statement` 필드로 사유 기록(C5) |
|
||||
| [[raw/official-docs/vuln-severity-cisa-kev-catalog-official]] | KEV 목록에 등재된 CVE = CVSS 점수와 무관하게 릴리즈 차단(exploitation-in-the-wild override). `dueDate` 필드 존재는 CISA 자체가 우선 시한을 부여한다는 증거 (CISA-KEV-C3). |
|
||||
| [[raw/official-docs/vuln-severity-cvss-v31-spec-first-official]] | 릴리즈 차단 심각도 기준 = CVSS v3.1 base score, High(≥7.0)/Critical(≥9.0) 차단. FIRST.org 명세가 정성 등급 구간(Table 14, C1)과 Base Score 정의(C3)의 권위 표준. |
|
||||
| [[raw/official-docs/dependabot-security-updates-gradle-official]] | Dependabot 조건부 허용 근거 — security updates 정의(C1), security vs version updates 구분(C2), grouped security updates 생태계 단위 묶음(C3·C4), manifest/lock 한정 트리거(C5) |
|
||||
| [[raw/official-docs/trivy-java-language-coverage]] | D1/SCA 채택 — `*gradle.lockfile` SBOM·Vulnerability·License 공식 지원(C1), 오프라인 스캔 가능(C2), Java 취약점 소스 = GitHub Advisory Database Maven(C5) |
|
||||
| [[raw/official-docs/renovate-vulnerability-alerts-gradle-official]] | Renovate primary 채택 근거 — `security:only-security-updates` preset이 `osvVulnerabilityAlerts: true` + `vulnerabilityAlerts.enabled: true` + 전체 패키지 기본 비활성화 구성임을 공식 문서로 확인 (C1·C2·C3) |
|
||||
|
||||
> **Deferred 아카이브 (후속 `/branch-spec` 재실행 또는 수동 dispatch)** — 아래 자료는 *대안 비교*·*보강 신호* 근거로 `wiki-decision-researcher` 가 URL·핵심 사실을 이미 확보했으나, 본 run 의 archive 예산을 핵심 7건에 집중하느라 raw 미등록. 해당 결정의 Evidence Strength 가 그만큼 낮음을 Decision Evidence Map 에 표기:
|
||||
> - `https://dependency-check.github.io/DependencyCheck/dependency-check-gradle/index.html` — OWASP Dependency-Check (D1 대안: 멀티모듈 `dependencyCheckAggregate` + `failBuildOnCVSS`, NVD API 키 필요)
|
||||
> - `https://github.com/anchore/grype` — Grype (D1 대안: false-positive 최저 + KEV/EPSS 내장, 단 gradle.lockfile 공식 지원 불명확)
|
||||
> - `https://nvd.nist.gov/vuln-metrics/cvss` — NVD severity bands (D2 보강: CVSS v3.x/v4.0 밴드 corroboration)
|
||||
> - `https://www.first.org/epss/` — FIRST EPSS (D8 보강: EPSS ≥ 0.1 escalation 신호 근거)
|
||||
> - `https://trivy.dev/docs/latest/scanner/vulnerability/` — Trivy 소스 우선순위(언어 패키지 GHSA>NVD) (D4 보강 — coverage 페이지엔 OS 패키지만 명시됨)
|
||||
> - `https://www.cisa.gov/news-events/directives/bod-26-04-prioritizing-security-updates-based-risk` — CISA BOD 26-04 (D3 보강: KEV=독립 우선순위 인자; CISA HTML 403 으로 본 run 미확보)
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
> ca-tmpl ground truth(2026-06-15 확인): `.github/workflows/` 없음, `gradle/locks/` 없음, Renovate/Dependabot/Trivy config 없음 → 본 branch 전 항목 `planned` (코드 미존재). `actually-implemented` 승급은 Phase C2 실 구현 + `src/`/CI grep 확인 후.
|
||||
|
||||
- [x] Trivy fs scan CI job (`aquasecurity/trivy-action`, `scan-type: fs`, `exit-code: 1`, `severity: CRITICAL,HIGH`, `TRIVY_FILE_PATTERNS` 멀티모듈 workaround) — 등급: `actually-implemented` (`.github/workflows/dependency-vulnerability.yml` `trivy-fs` 잡, YAML valid; 실제 CI 실행은 `needs-confirmation`) (D1)
|
||||
- [x] `dependency-review-action` required check (`fail-on-severity: high`) — 등급: `actually-implemented` (`.github/dependency-review-config.yml` + workflow `dependency-review` 잡; graph 제출은 `dependency-submission` 잡으로 보강) (D7)
|
||||
- [x] severity 정책 문서화: CVSS v3.1 밴드 + ≥High 차단 + KEV override + EPSS escalation — 등급: `actually-implemented` (`.github/dependency-vulnerability-policy.md` §2/§3/§4, 모든 team-policy 값 `UNSUPPORTED_IMPL_DECISION` 라벨) (D2/D3/D8)
|
||||
- [x] `.trivyignore.yaml` suppression 정책 + 만료일 강제 + 무단변경 차단 정적 게이트 — 등급: `locally-verified` (`verifyTrivyignore` Gradle gate: 6-케이스 pass/fail 검증 + `./gradlew check` green; `.trivyignore.yaml` 빈 seed + `.github/CODEOWNERS` merge-gate) (D5)
|
||||
- [x] Renovate `security:only-security-updates` 설정 + patch 보안 PR auto-merge 정책 — 등급: `actually-implemented` (`renovate.json`, JSON valid; Renovate 봇 실행은 `needs-confirmation`) (D6)
|
||||
- [x] scheduled 재스캔 job(CVE DB 갱신 캡처) + pre-release `trivy image` scan — 등급: `actually-implemented` (workflow `schedule` daily cron + `trivy-image` release 잡, `vars.RELEASE_IMAGE_REF` 게이팅으로 container-runtime wiring seam) (D1/구현가이드 §1)
|
||||
- [x] 의존성 라이선스 스캔: Trivy license(gradle.lockfile) + dependency-review-action `deny-licenses` + 금지 SPDX 목록 정의 — 등급: `actually-implemented` (`scanners: vuln,license` + GPL/AGPL deny-list; 목록은 `UNSUPPORTED_IMPL_DECISION` team-policy) (D10)
|
||||
- [ ] sibling 역참조 정합: supply-chain D2/D3, ci-gates D5 의 `UNSUPPORTED`/`OWNER_AMBIGUITY` → 본 branch 위임으로 갱신 (§Audit & Findings, 비차단) — 등급: `planned` (비차단 — 다음 작업자/`/sync`; 본 구현 머지와 독립)
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
작업하며 떠오른 메모. 자유 형식.
|
||||
|
||||
### 2026-06-20 — Phase C2 구현 (ca-tmpl, branch `feature/dependency-vulnerability-management-contract`)
|
||||
|
||||
정책 7건(D1·D2/D3/D8·D5·D6·D7·D10·§1)을 ca-tmpl 의 커밋 가능한 아티팩트로 실 구현. 커밋은 사용자가 직접 수행(working tree 만 변경).
|
||||
|
||||
**커밋 대상 파일 (tracked):**
|
||||
|
||||
- `.github/workflows/dependency-vulnerability.yml` — D1 `trivy-fs`(PR+daily schedule, `scanners: vuln,license`, `severity: CRITICAL,HIGH`, `exit-code:1`, `trivyignores: .trivyignore.yaml`, `TRIVY_FILE_PATTERNS`) + D7 `dependency-review`(PR) + `dependency-submission`(`gradle/actions/dependency-submission`, graph fail-open 보강) + §1 `trivy-image`(release, `vars.RELEASE_IMAGE_REF` 게이팅 — container-runtime wiring seam).
|
||||
- `.github/dependency-review-config.yml` — D7 `fail-on-severity: high` + `fail-on-scopes: [runtime]` + D10 `deny-licenses`(GPL/AGPL family) + `comment-summary-in-pr: on-failure`.
|
||||
- `.trivyignore.yaml` — D5 빈 seed(`vulnerabilities/licenses/misconfigurations/secrets: []`) + 헤더에 `id`/`statement`/`expired_at` 포맷 문서화. repo 루트(Trivy 자동 인식).
|
||||
- `renovate.json` — D6 `config:recommended` + `security:only-security-updates` + `vulnerabilityAlerts`(stable) + `osvVulnerabilityAlerts`(experimental) + patch auto-merge / minor·major human review packageRules.
|
||||
- `.github/CODEOWNERS` — D5 §3 ② merge-time 승인(`.trivyignore.yaml`·policy·workflows·`renovate.json` → `@DongHyeonka` placeholder).
|
||||
- `.github/dependency-vulnerability-policy.md` — D2/D3/D4/D8/D9/D10 통합 정책 SSOT(committed). 모든 team-policy 수치 `UNSUPPORTED_IMPL_DECISION` 라벨.
|
||||
- `src/build.gradle` — D5 §3 ① `verifyTrivyignore` Gradle gate(line-based parser, `maxWindowDays=90`), `subprojects { check { dependsOn } }` 배선(기존 `verifyEnvKeys` 패턴).
|
||||
- `src/README.md`·`README.md` — gate 문서화 + 정책 참조.
|
||||
|
||||
**검증(locally-verified):** `verifyTrivyignore` 6-케이스 — 빈 seed pass / 유효+nested paths pass / `expired_at` 누락 fail / `statement` 누락 fail / 이미 만료 fail / 90일 초과 fail, seed 복원 후 재pass. `./gradlew check` = BUILD SUCCESSFUL(106 tasks). 4개 check-wired gate 동시 통과. workflow/dep-review YAML + renovate JSON 구문 유효성 확인. CI 러너에서의 실제 스캔 동작은 `needs-confirmation`(§Claims To Verify 참조).
|
||||
|
||||
**UNSUPPORTED_IMPL_DECISION 기본값 선택(스켈레톤 default, fork 가 교체):** scheduled=daily(`0 6 * * *`); 차단=≥High; EPSS=0.1(비차단); suppression 창=90일; patch auto-merge; license=deny-list(GPL/AGPL, LGPL 허용); SLA=KEV/Critical 7d·High 30d·Medium 90d. `verifyPublicPathSnapshot` 의 문서화 방식과 동일.
|
||||
|
||||
**경계 준수:** `dependencyLocking`/lockfile 생성 미추가(supply-chain D8 owner) — Trivy fs 는 lockfile 커밋 전까지 Gradle deps no-op, 그 사이 `dependency-submission` graph 가 transitive backstop. image scan **wiring** 은 `vars.RELEASE_IMAGE_REF` seam 으로 container-runtime 에 위임. CI gate `needs:`/`if:` 배선은 ci-gates owner(본 파일은 정책만).
|
||||
|
||||
### 2026-06-20 — 코드 리뷰 수정 2건 (workflow correctness)
|
||||
|
||||
- **TRIVY_EXIT_CODE 주석 오기 정정 (correctness):** `trivy-fs` 잡의 `TRIVY_EXIT_CODE: "1"` 는 `exit-code: "1"` 입력과 동일 knob(취약점 *발견 시* 종료코드)이라 중복이었고, 주석이 이를 "DB fetch 실패 시 fail" 메커니즘으로 *오기*했다. 제거하고, DB-fetch 실패→fail 은 Trivy **기본 동작**(캐시 없으면 DB 다운로드 실패 시 non-zero)이며 설정 플래그가 아님 + 여전히 `needs-confirmation` 임을 정직하게 주석화. §Claims To Verify "스캐너 DB fetch 실패가 silent pass 가 아니라 job fail" 은 **여전히 미검증(`planned`)** — 이전 주석이 충족을 거짓 주장했던 것을 철회. 실검증: CI 에서 DB endpoint 차단 후 non-zero exit 단언.
|
||||
- **Medium "warn/advisory" 티어 실현:** policy §2 표는 Medium=warn(advisory)/Low=report 인데 두 Trivy 잡이 `severity: CRITICAL,HIGH` 만 스캔해 Medium/Low 를 보고조차 안 했음(정책-구현 gap). `trivy-fs` 에 비차단 advisory step(`severity: MEDIUM,LOW`, `exit-code: "0"`) 추가로 보고만 하고 차단 안 하는 티어 실현. policy §2 운영 구현 줄도 정합.
|
||||
- **D3 KEV override 실효 강제 (실효 강제 0 → 실제 차단):** severity 필터가 `CRITICAL,HIGH` 라 §2 matrix 의 "KEV 등재 시 모든 밴드 block" 의도가 Low/Medium 에서 미실현이었음(실효 강제 0). `trivy-fs` 에 (1) 전체 밴드 JSON 스캔(`severity: CRITICAL,HIGH,MEDIUM,LOW,UNKNOWN`, `exit-code:0`, `format: json`) + (2) `KEV override gate` run step(CISA KEV JSON feed `curl -fsSL --retry 3` → `jq`/`comm` 으로 발견 CVE ∩ KEV → 교집합 있으면 `exit 1`) 추가. suppression(`.trivyignore.yaml`)은 그대로 적용 → KEV CVE 는 거버넌스된 suppression 으로만 수용. feed 미가용 = `curl -f` fail-closed(silent pass 금지, §Edge·Failure JSON feed 가용성 충족). policy §3 을 posture→실효 강제로 갱신. **Open Risk(유지):** KEV 등재 지연, feed CI 의존.
|
||||
- 검증: workflow YAML 재유효성 OK; `TRIVY_EXIT_CODE` 제거 + advisory/KEV step 존재 grep 확인; **KEV cross-check 로직 mock 3-케이스 검증** — KEV 등재 CVE 발견 시 exit1(block), 빈 발견셋(lockfile 부재) pass, 비-KEV CVE pass. (Java/Gradle 코드·게이트 로직 무변경이라 `./gradlew check` 재실행 불요. CI 러너 실제 동작은 `needs-confirmation`.)
|
||||
|
||||
### 2026-06-20 — Gitea/act 플랫폼 적응 (CI 실패 1건 해소)
|
||||
|
||||
사용자가 commit `cb12207` push 후 self-hosted **Gitea + act_runner**(k8s 내부, `gitea-http.platform.svc.cluster.local`)에서 워크플로 실행 → 2개 잡 실패. 타깃 플랫폼 = Gitea 확정.
|
||||
|
||||
- **`dependency-review` 실패 (원인 확정):** `::error::Dependency review could not obtain dependency data...`. dependency-review-action 은 **GitHub Dependency Graph compare API**(GitHub.com/GHES 전용)에 의존 → Gitea 에 API 부재 + `dependency-submission`(graph 제출, push-only)이 PR 이벤트라 skip 돼 graph 도 비어있음. **수정:** `dependency-review`·`dependency-submission` 두 잡에 `&& github.server_url == 'https://github.com'` 가드 추가 → Gitea 에선 skip(실패 아님), GitHub.com 에선 그대로 동작. Gitea 의 PR-time 의존성 검사는 플랫폼 독립적 `trivy-fs`(매 PR)가 커버(D7 단독 게이트 금지 설계가 backstop 제공). policy §8 에 플랫폼 호환성 note 추가.
|
||||
- **`trivy-fs` 실패 (원인 확정 — egress 가설 철회):** trivy-fs step 로그 입수 → `git clone 'https://github.com/aquasecurity/trivy-action' # ref=0.28.0` → `Unable to resolve 0.28.0: reference not found`. **egress 문제 아님**(러너가 actions/checkout·trivy-action 을 github.com 에서 정상 clone — github.com·ghcr 접근 가능). 진짜 원인은 **액션 태그 오타**: `aquasecurity/trivy-action` 의 실제 태그는 `v` 접두사(`v0.28.0`)인데 `@0.28.0`(v 없이)로 핀해 404. GitHub API 로 실제 태그 확인(`tags/0.28.0`=404, `tags/v0.28.0`=200; 최신 `v0.36.0`). **수정:** 워크플로 4곳 `@0.28.0`→`@v0.28.0`(replace_all). 내 1차 "egress 차단" 진단은 4s 빠른 실패만 보고 세운 가설이었고 로그가 반증 — *증거 우선* 위반 사례.
|
||||
- skip 정상: `dependency-submission`(push-only)·`trivy-image`(release-only)는 PR 이벤트라 의도된 skip(회색).
|
||||
- 검증: workflow YAML 재유효성 OK, server_url 가드 2건 + trivy-action `@v0.28.0` 4건 grep 확인, GitHub API 로 `v0.28.0` 존재 확인. (YAML 변경만 — `./gradlew` 무관.) **재실행 후 trivy-fs 완전 통과(Trivy DB pull + KEV step cisa.gov curl 포함)는 `needs-confirmation`.**
|
||||
- **`trivy-fs` 2차 실패 → CLI 전환 (act 의 trivy-action 미지원):** 태그 수정 후 재실행하니 액션 resolve 는 통과했으나 `entrypoint.sh: line 44: trivy: command not found`. `aquasecurity/trivy-action` 은 setup-trivy 서브액션 + DB 캐시로 Trivy 를 설치하는 **composite** 인데 act 가 그 설치 스텝을 안 돌려 바이너리 부재. **수정:** trivy-action 폐기 → **Trivy + jq CLI 정적 바이너리를 github.com 에서 직접 설치**(`trivy v0.71.2`, `jq 1.8.1`, API 로 태그/자산명 사전 확인) 후 `trivy fs`/`trivy image` CLI 직접 호출. `--file-patterns` 제거(`**/*.lockfile` 는 CLI 에서 invalid regex 위험 + 표준 `gradle.lockfile` 명명은 Trivy 기본 탐지로 충분; 비표준 명명만 Claims To Verify). KEV step 에 `KEV_FEED_URL` repo-var override(폐쇄망 미러) + fetch 실패 시 명시적 fail-closed 메시지 추가. CLI 는 GitHub.com·Gitea/act 공통.
|
||||
- skip 정상: `dependency-submission`(push-only)·`trivy-image`(release-only)는 PR 이벤트라 의도된 skip(회색).
|
||||
- 검증: workflow YAML 재유효성 OK(CLI 전환 후); `uses: aquasecurity/trivy-action` 제거(주석만 잔존) + `trivy fs`/`trivy image` CLI step grep 확인; GitHub API 로 `trivy v0.71.2`·`jq-1.8.1` 자산 존재 확인; KEV jq/comm 로직 mock 3-케이스 재확인. (YAML 변경만 — `./gradlew` 무관.) **남은 egress 의존(재실행 시 다음 관문): github.com=확인됨, ghcr.io(Trivy DB)·KEV feed 호스트=`needs-confirmation`(폐쇄망이면 `TRIVY_DB_REPOSITORY`/`KEV_FEED_URL` 미러).**
|
||||
- 파생: [[raw/errors/gitea-act-action-tag-and-dependency-graph-2026-06-20]] (CI 실패 근본원인 + 수정, 2-iteration).
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 추후 면접/회고에서 "왜 이렇게 했나" 답할 근거. 대안과 함께 기록. 각 결정의 근거는 위 Sources 또는 새로 추가된 raw 자료를 가리킬 것. 상세 매핑은 아래 Decision Evidence Map.
|
||||
|
||||
- 2026-06-15: **SCA 스캐너 = Trivy** (fs 의존성 + image 동일 바이너리, `aquasecurity/trivy-action`). 이유: OSS(Apache 2.0) + gradle.lockfile 공식 지원 + NVD API 키 불필요 + image 동일 도구 + `exit-code`/`severity` 로 release-blocking 즉시 구성. 검토한 대안: OWASP Dependency-Check(멀티모듈 aggregate 성숙하나 NVD 키 필요), Grype(FP 최저·KEV/EPSS 내장하나 gradle.lockfile 지원 불명확), Snyk(상용 — OSS 스켈레톤 부적합 제외), dependency-review-action(PR 보완 전용). 근거: [[raw/official-docs/trivy-java-language-coverage]], [[raw/official-docs/trivy-action-github-actions]]. (ci-gates D5 의 `OWNER_AMBIGUITY`/미결 해소 — D1)
|
||||
- 2026-06-15: **차단 심각도 = CVSS v3.1 base score, High(≥7.0)·Critical(≥9.0) 차단**, Medium/Low 는 warning-only. 이유: 스캐너·NVD 커버리지 완전 + FIRST.org 권위 표준 밴드 + industry de-facto 임계값. 검토한 대안: CVSS v4.0(스캐너 미성숙 — 2026-12 재평가). 근거: [[raw/official-docs/vuln-severity-cvss-v31-spec-first-official]]. (supply-chain D2 에 외부 표준 부여 — D2)
|
||||
- 2026-06-15: **KEV override** — CISA KEV 등재 CVE 는 CVSS 점수 무관 차단. 이유: exploited-in-the-wild 는 점수보다 실위험이 큼(CISA `dueDate` 부여). 근거: [[raw/official-docs/vuln-severity-cisa-kev-catalog-official]]. (D3)
|
||||
- 2026-06-15: **Suppression governance** — `.trivyignore(.yaml)` + 만료일 강제 + `statement` 사유 + PR 승인 + 무단변경 차단 정적 게이트. 이유: 만료 없는 영구 ignore 차단(2026-05-25 ca-tmpl audit finding 해소). 근거: [[raw/official-docs/trivy-filtering-suppression-policy]]. (D5)
|
||||
- 2026-06-15: **의존성 보안 업데이트 = Renovate primary / Dependabot 조건부**. 이유: version-catalog+lockfile 동시 사용 시 Dependabot lockfile 미갱신 버그(#12557)가 supply-chain D8 locking 과 충돌; Renovate 는 security-only preset + patch auto-merge 단순. 검토한 대안: Dependabot(조직 표준/단순 구조 시 허용). 근거: [[raw/official-docs/renovate-vulnerability-alerts-gradle-official]], [[raw/official-docs/dependabot-security-updates-gradle-official]]. (supply-chain D3 정합 — D6)
|
||||
- 2026-06-15: **PR-time 보완 게이트 = dependency-review-action** (`fail-on-severity: high`, required). 단독 릴리즈 게이트 금지(PR diff 전용). 근거: [[raw/official-docs/github-dependency-review-action]]. (D7)
|
||||
- 2026-06-15: **의존성 라이선스 준수 스캔 = Trivy license(이미 toolchain) + dependency-review-action allow/deny**. 이유: D1 Trivy 가 `*gradle.lockfile` license 도 스캔하므로 별도 도구 불필요; governing §35-E L2052 가 license scan 을 본 branch 영역으로 명시. 근거: [[raw/official-docs/trivy-java-language-coverage]], [[raw/official-docs/github-dependency-review-action]]. (D10)
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시한다.
|
||||
> `Decision ID` 는 이 branch-note 안에서 안정적으로 유지한다. 예: `D1`, `D2`.
|
||||
> `Supporting Claims` 는 `raw/<category>/<slug>.md#C1` 형식으로 연결한다.
|
||||
|
||||
> `선택 조건` 열(R2): "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 분기 없으면 `N/A`.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | **SCA 스캐너 = Trivy** (fs 의존성 스캔 + image 동일 바이너리, `aquasecurity/trivy-action`, `exit-code:1`+`severity:CRITICAL,HIGH`=release-blocking) | OSS + NVD 키 불필요 + gradle.lockfile 지원 + image 동일 도구 → **Trivy**. 멀티모듈 `dependencyCheckAggregate` 공식 지원 + CVSS 소수점 임계값 제어가 더 중요 → **OWASP Dependency-Check** (NVD API 키+캐싱 감수). FP 최저+EPSS/KEV 내장이 최우선 → **Grype**(단 gradle.lockfile POC 선행) | `raw/official-docs/trivy-java-language-coverage.md#C1` (`*gradle.lockfile` SBOM/Vuln/License ✓), `raw/official-docs/trivy-java-language-coverage.md#C2` (오프라인 스캔), `raw/official-docs/trivy-action-github-actions.md#C1`·`#C2` (exit-code/severity release-blocking), `raw/official-docs/trivy-action-github-actions.md#C4` (`trivyignores`) | `official-vendor-doc` (Aqua Trivy) — 대안(OWASP DC/Grype/Snyk) 비교는 §Sources Deferred 아카이브 | 멀티모듈 lockfile 탐지 버그 → `--file-patterns "gradle-lockfile:*.lockfile"` workaround(공식 문서 미명시, Claims To Verify); Gradle `force=true` 재정의 false positive; **lockfile 생성이 선행조건** → [[raw/branch-notes/feature-build-release-supply-chain-contract]] D8(dependency-locking) 의존 |
|
||||
| D2 | **차단 심각도 = CVSS v3.1 base score; High(≥7.0)·Critical(≥9.0)=release-blocking**, Medium(4.0–6.9)/Low(0.1–3.9)=warning-only(비차단 advisory) | 스캐너 지원·NVD 커버리지 완전 → **v3.1**. 스캐너 v4.0 파싱 안정 + NVD/Vulnrichment v4.0 커버리지 확보 후 → **v4.0**(밴드 수치 동일, 2026-12 재평가) | `raw/official-docs/vuln-severity-cvss-v31-spec-first-official.md#C1` (Table 14 밴드 None/Low/Medium/High/Critical), `raw/official-docs/vuln-severity-cvss-v31-spec-first-official.md#C2` (정성 등급=조직 vuln mgmt 프로세스 입력), `#C3` (base score=intrinsic) | `official-standard` (FIRST.org) — **단 밴드만 표준**; "≥High 차단" 임계값 선택은 industry de-facto(`team-policy`) | "≥High 차단"의 industry de-facto 근거 미아카이브 → `UNSUPPORTED_IMPL_DECISION`(§구현가이드 §2); v3.1 Scope metric 불일치 알려짐; NVD enrichment 정책 변경(2026-04) → 신규 CVE CVSS 공백 가능(Claims To Verify) |
|
||||
| D3 | **KEV override** — CISA KEV catalog 등재 CVE = CVSS 점수 무관 release-blocking | N/A (항상 적용 — exploited-in-the-wild 가 점수보다 우선) | `raw/official-docs/vuln-severity-cisa-kev-catalog-official.md#CISA-KEV-C1` (catalog 존재), `#CISA-KEV-C3` (`dueDate`=CISA 우선 시한 부여), `#CISA-KEV-C4` (ransomware 연관 식별) | `official-vendor-doc` (CISA JSON feed) — "exploited in the wild" 정의·비연방 권고·BOD 26-04 4-factor 는 CISA HTML **403 으로 미확보**(needs-confirmation, Deferred) | KEV 등재 지연(악용→catalog entry 간격); JSON feed 가용성 CI 의존; BOD rationale 미아카이브 |
|
||||
| D4 | **소스 우선순위 tie-break**: Java/Gradle 의존성 → GitHub Advisory Database(GHSA) 우선 → NVD fallback | NVD 와 GHSA/벤더 점수 충돌 시 GHSA 우선(Trivy 기본). KEV 등재면 tie-break 무관 차단(D3) | `raw/official-docs/trivy-java-language-coverage.md#C5` (Java 취약점 소스 = GitHub Advisory Database (Maven)) | `official-vendor-doc` (**부분**) — *GHSA 를 소스로 씀* 만 확인; *충돌 시 GHSA 가 NVD override* 명시는 coverage 페이지에 없음(OS 패키지만 명시) → 부분 `UNSUPPORTED_DECISION` | language-package vendor>NVD 우선순위 verbatim 미확보 → Trivy scanner/vulnerability 페이지 보강 필요(Deferred + Claims To Verify) |
|
||||
| D5 | **Suppression governance** — `.trivyignore`/`.trivyignore.yaml` + **만료일 필수**(`exp:YYYY-MM-DD`/`expired_at`) + `statement` 사유 + PR review approval + **무단 `.trivyignore` 변경 차단 정적 CI 게이트** | false-positive/accepted-risk suppress 필요 시 — 만료 없는 영구 ignore 금지 | `raw/official-docs/trivy-filtering-suppression-policy.md#C1` (CVE 한 줄+만료 지원), `#C3` (`exp:YYYY-MM-DD`), `#C4` (`expired_at`, 미지정시 영구유효), `#C5` (`statement`=사유 기록) | `official-vendor-doc` (Trivy filtering) | 만료 기간 길이(예: 90d)·PR 승인 권한(CODEOWNERS)·무단변경 차단 게이트 구현(regex)은 team-policy → `UNSUPPORTED_IMPL_DECISION`(§구현가이드 §3). 2026-05-25 ca-tmpl audit `.trivyignore` 무단 우회 finding 해소 대상 |
|
||||
| D6 | **의존성 보안 업데이트 = Renovate primary** (`security:only-security-updates` preset → `vulnerabilityAlerts`+`osvVulnerabilityAlerts`), **Dependabot 조건부** | `gradle/libs.versions.toml` version catalog + Gradle lockfile 동시 사용 → **Renovate** (Dependabot #12557 lockfile 미갱신 버그가 supply-chain D8 locking 과 충돌). 조직이 Dependabot 표준 또는 lockfile 미사용 단순 구조 → **Dependabot** 허용 | `raw/official-docs/renovate-vulnerability-alerts-gradle-official.md#C1`·`#C2` (`security:only-security-updates`→osv+vulnerabilityAlerts), `raw/official-docs/dependabot-security-updates-gradle-official.md#C1` (security updates 정의), `#C2` (security vs version 구분), `#C3` (grouped per-ecosystem), `#C5` (manifest/lock 한정 트리거) | `official-vendor-doc` (Renovate + GitHub) | Renovate `osvVulnerabilityAlerts` experimental 상태 + vuln-alert schedule-ignore 는 presets 페이지 **미확인**(needs-confirmation); Dependabot Gradle 지원·#12557·native auto-merge 부재는 researcher finding(이 페이지 미확인); **transitive 취약점은 둘 다 직접 의존성만** → Gradle dependency constraint 수동 override 필요(§구현가이드 §4) |
|
||||
| D7 | **PR-time 보완 게이트 = GitHub dependency-review-action** (`fail-on-severity: high`, required check) | 모든 PR(feature + 보안 PR). **단독 릴리즈 게이트 금지** — PR diff 전용이라 기존 의존성 전수 스캔 못함, 그건 D1 Trivy fs | `raw/official-docs/github-dependency-review-action.md#C1` (catch before introduce), `#C2` (PR 도입 취약 버전 스캔), `#C3` (default fail + required 시 merge block), `#C4` (REST API base..head diff), `#C5` (severity 커스터마이즈) | `official-vendor-doc` (GitHub) | Gradle dependency graph 가 GitHub 에 제출돼야 diff 유의미(Claims To Verify); `fail-on-severity` 정확 값은 별도 config 페이지(needs-confirmation, Deferred) |
|
||||
| D8 | **EPSS escalation signal (optional, 비차단)** — EPSS ≥ 0.1 인 Low/Medium CVE → 즉시 review ticket(P1). **하드 차단 아님** | D2 에서 비차단(Low/Medium)인데 EPSS≥0.1 → escalate. High/Critical 은 이미 D2 차단 | `UNSUPPORTED_DECISION` — FIRST EPSS 페이지 미아카이브(Deferred); 0.1 임계값은 FIRST top-decile practitioner 합의일 뿐 공식 차단 mandate 없음 | `team-policy` (외부 reference: FIRST EPSS, deferred) | EPSS=확률 추정 → false positive; 0.1 임계값=조직 정책; 본 결정 자체 optional(미도입 가능) |
|
||||
| D9 | **Remediation SLA by severity** — KEV/Critical: 즉시(≤Xd), High: ≤Yd, Medium: ≤Zd | 차단/escalation 된 취약점 수정 기한 | `UNSUPPORTED_DECISION` — 비-KEV SLA 수치는 외부 표준 부재(team-policy). KEV 항목만 `raw/official-docs/vuln-severity-cisa-kev-catalog-official.md#CISA-KEV-C3` (`dueDate`) 외부 anchor | `team-policy` (+ KEV 항목 official-vendor-doc anchor) | 정량 일수(X/Y/Z)는 조직 결정 — 임의 trade-off 제시 시 `UNSUPPORTED_IMPL_DECISION` |
|
||||
| D10 | **의존성 라이선스 준수 스캔(license/NOTICE)** = Trivy license scanner(이미 toolchain) + dependency-review-action allow/deny license list. 금지(strong-copyleft) 라이선스 = release-blocking, allow-list 정책 | Trivy 가 이미 D1 로 채택됐고 `*gradle.lockfile` license 스캔 → **별도 license 도구 불필요**(통합). PR-time 신규 라이선스 도입 차단은 dependency-review-action allow/deny | `raw/official-docs/trivy-java-language-coverage.md#C1` (gradle.lockfile **License ✓**), `raw/official-docs/github-dependency-review-action.md#C6` (allow/deny list for licenses) | `official-vendor-doc` (Trivy + GitHub) | **금지/허용 SPDX 라이선스 목록**(어떤 id 가 release-blocking 인지)은 조직 정책 → `UNSUPPORTED_IMPL_DECISION`(§구현가이드 §5); Trivy license 감지는 Gradle cache 디렉터리(`$GRADLE_USER_HOME/caches`) 의존(`trivy-java-language-coverage` 메모 — dependency-tree EXPERIMENTAL) → Claims To Verify |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> *결정 (Decisions)* 이 "*무엇* 을 할 것인가" 라면, 본 §는 "*어디에 어떻게* 구현될 것인가" 의 사전 명세 — *문서가 모호해서 구현자가 임의로 정해야 했던 결정* 카탈로그. 작성 목표는 다음 구현자가 *되묻지 않아도 코드를 작성할 수 있는 수준*.
|
||||
>
|
||||
> **본 §는 일률적 anchor list 를 강제하지 않는다.** branch 마다 구현 내용·범위가 다르므로 sub-section 은 *이 branch 의 결정과 근거에서 도출되는 것만* 작성. 어떤 branch 는 error mapping 표 + 정적 강제 카탈로그, 어떤 branch 는 migration 단계 + wiring, 어떤 branch 는 sequence + state machine. 형식 예시는 [[raw/branch-notes/feature-boundary-validation-mapping-contract]] 의 §구현 가이드 참조.
|
||||
>
|
||||
> **3-rule meta principle (필수 준수)**:
|
||||
>
|
||||
> 1. **R1. Reference 필수** — 각 sub-section / row / cell 은 본 branch 의 `Decision ID` (예: D1, D2) + 그 결정의 `Supporting Claim ID` (예: `RAW-SLUG-C1`) 를 reference. *근거 없는 결정 금지* — 모든 구현 detail 은 결정 + 근거의 *도출* 이어야 함.
|
||||
> 2. **R2. UNSUPPORTED_IMPL_DECISION 명시** — 근거 raw 가 *원칙* 만 권고하고 *detail* (메커니즘 선택 / 클래스/rule 명명 / glob 패턴 / algorithm / factory API 모양 등) 은 권고하지 않는 cell 은 `UNSUPPORTED_IMPL_DECISION` 라벨 + 사용자 trade-off 근거 한 줄. 이게 *근거 있는 결정 vs 사용자 임의 trade-off* 의 경계.
|
||||
> 3. **R3. OUT_OF_BRANCH_SCOPE 정제** — 본 branch 결정 범위 밖 cell 은 §구현 가이드에 *남기지 않음*. 별도 branch 또는 canonical SSOT 로 이관 (이관 history 는 별도 § "Audit & Findings" 등에 보존). 도메인 특화 detail (ca-tmpl skeleton 범위 밖) 도 동일하게 정제.
|
||||
>
|
||||
> **각 sub-section 의 권장 헤더 패턴**:
|
||||
>
|
||||
> ```markdown
|
||||
> ### N. <sub-section 제목>
|
||||
>
|
||||
> > **Trace**: <In-scope row 들 + Decision ID + Supporting Claim ID 의 매핑 (한 줄/한 단락)>
|
||||
> >
|
||||
> > - **UNSUPPORTED_IMPL_DECISION**: <근거 없는 사용자 임의 결정 항목들 + 각각의 trade-off 근거 한 줄>
|
||||
>
|
||||
> <표 또는 명확한 구조 — 자유 텍스트 = 모호함 = 되묻기 원인>
|
||||
> ```
|
||||
|
||||
### 1. 스캔 배선 — 단계별 스캐너 실행 (3-stage)
|
||||
|
||||
> **Trace**: D1 (`trivy-java-language-coverage#C1`·`#C2`, `trivy-action-github-actions#C1`·`#C2`·`#C3`), D7 (`github-dependency-review-action#C2`·`#C3`). gate 의 *release-blocking 배선*(`needs:`/`if:`) 자체는 [[raw/branch-notes/feature-ci-quality-gates-contract]] owner — 본 §는 *무엇을 어느 단계에서 스캔하는지* 만 정의하고 ci-gates 가 wiring.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: scheduled 재스캔 **주기**(아래 표의 daily) — Trivy DB 는 ~6h 갱신이나 재스캔 cron 빈도는 외부 표준 없음(team-policy). trade-off: daily = 신규 CVE 노출 ≤24h vs CI 비용. 더 잦으면 noise/비용↑.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 멀티모듈 lockfile `--file-patterns "gradle-lockfile:*.lockfile"` — Trivy 공식 문서 미명시 workaround(GitHub Discussion #9740). trade-off: ca-tmpl 실제 lockfile 명명(`gradle.lockfile` vs `gradle/dependency-locks/*.lockfile`)에 맞춰야 함 → Claims To Verify.
|
||||
|
||||
| 단계(stage) | 도구 | scan-type | trigger | severity gate | 잡는 것 |
|
||||
|---|---|---|---|---|---|
|
||||
| PR — 신규 도입 차단 | dependency-review-action | (REST API diff) | `pull_request` | `fail-on-severity: high` | PR diff 로 *새로 들어온* 취약 의존성 (D7) |
|
||||
| PR — 전체 스냅샷 | Trivy | `fs` (lockfile) | `pull_request` | `exit-code:1` + `severity:CRITICAL,HIGH` | 기존+신규 전체 의존성 CVE (D1) |
|
||||
| scheduled 재스캔 | Trivy | `fs` (lockfile) | `schedule`(daily) | 동일 | 의존성 불변이라도 **새 CVE DB** 로 새로 매치된 취약점 |
|
||||
| pre-release | Trivy | `image` | release tag | 동일 | 컨테이너 이미지 OS/런타임 패키지 취약점 — *severity 정책만* 본 branch, wiring 은 [[raw/branch-notes/feature-container-runtime-contract]] |
|
||||
|
||||
### 2. Severity 판정 매트릭스 (CVSS + KEV + EPSS)
|
||||
|
||||
> **Trace**: D2 (`vuln-severity-cvss-v31-spec-first-official#C1`·`#C2`·`#C3`), D3 (`vuln-severity-cisa-kev-catalog-official#CISA-KEV-C1`·`#C3`), D8 (UNSUPPORTED — FIRST EPSS deferred), D4 (`trivy-java-language-coverage#C5`). 이 매트릭스는 ci-gates·container-runtime·supply-chain 이 공유 consume 하는 **단일 severity 표준**.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: "≥High 차단" 임계값 — FIRST.org 는 밴드(C1)만 표준화하고 *어느 등급부터 차단인지* 는 소비자 책임(C2)으로 명시. ≥High 차단은 industry de-facto(GitHub/Snyk/OSV-Scanner default). trade-off: ≥Medium 차단 시 FP noise 급증; Critical-only 차단 시 exploit code 있는 High 누수.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: EPSS 임계값 `0.1` — FIRST top-decile practitioner 합의, 공식 차단 mandate 없음. trade-off: 낮추면 FP↑. (D8 자체가 optional)
|
||||
|
||||
| 입력 | None 0.0 | Low 0.1–3.9 | Medium 4.0–6.9 | High 7.0–8.9 | Critical 9.0–10.0 |
|
||||
|---|---|---|---|---|---|
|
||||
| 기본 게이트 결정 | pass | pass(report) | **warn**(advisory) | **block** | **block** |
|
||||
| KEV 등재 시(D3) | block | block | block | block | block |
|
||||
| EPSS ≥ 0.1 시(D8) | — | review ticket | review ticket | (이미 block) | (이미 block) |
|
||||
|
||||
- **소스 우선순위(D4)**: 동일 CVE 의 점수가 NVD vs GHSA 로 다르면 Java/Gradle 패키지는 GHSA 우선 → NVD fallback. (단 §Open Risk: 언어-패키지 override 명시 verbatim 미확보.)
|
||||
|
||||
### 3. Suppression governance
|
||||
|
||||
> **Trace**: D5 (`trivy-filtering-suppression-policy#C1`·`#C3`·`#C4`·`#C5`). 2026-05-25 ca-tmpl audit `.trivyignore` 무단 우회 finding 해소.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 만료 **기간 상한**(예: 90일)·승인 권한(CODEOWNERS 대상)·무단변경 차단 게이트의 **구현 메커니즘**(CI step regex vs CODEOWNERS protected path) — Trivy 문서는 `expired_at` 필드 *존재*만 보장(C4), 정책 수치는 권고 안 함. trade-off: 짧으면 재검토 부담↑, 길면 사실상 영구 ignore.
|
||||
|
||||
| 규칙 | 강제 방법 | 근거 |
|
||||
|---|---|---|
|
||||
| suppression 은 `.trivyignore.yaml` 단일 파일 | CI 가 인라인 ignore/CLI `--ignore` 사용 금지 검사 | C2 (구조화 YAML) |
|
||||
| 각 항목 **만료일 필수** (`expired_at` 누락 금지) | CI 정적 검사: `expired_at` 없는 row fail (C4: 미지정시 영구유효 → 금지) | C4 |
|
||||
| 각 항목 **`statement` 사유 필수** | CI 정적 검사: `statement` 빈 row fail | C5 |
|
||||
| `.trivyignore.yaml` 변경은 **PR 승인 필수** | **역할 분리(둘 다 필요)**: ① CODEOWNERS protected path + branch protection = *merge-time* 승인 강제(GitHub native), ② CI step regex = `expired_at`/`statement` 필드 검증(CODEOWNERS 가 못 하는 내용 검증) | audit finding |
|
||||
|
||||
> **UNSUPPORTED_IMPL trade-off (위 표 ② 보강)**: 무단 변경 차단의 1차 메커니즘은 **CODEOWNERS protected path**(GitHub-native, merge 차단). 단 CODEOWNERS 는 *파일 변경 승인*만 강제하고 *만료일·사유 누락*은 못 잡으므로 CI regex step 이 병행 필수 — 둘은 대체재가 아니라 보완재.
|
||||
|
||||
### 4. 의존성 보안 업데이트 자동화 + transitive 처리
|
||||
|
||||
> **Trace**: D6 (`renovate-vulnerability-alerts-gradle-official#C1`·`#C2`, `dependabot-security-updates-gradle-official#C1`·`#C2`·`#C3`·`#C5`). supply-chain D3(Renovate/Dependabot) 정합.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: patch-level 보안 PR **auto-merge** — Renovate `automerge`+`matchUpdateTypes:["patch"]` 조합은 일반 기능이나, *patch 만 auto-merge / minor·major 는 human review* 경계는 team-policy(이 페이지 미아카이브). trade-off: CI 커버리지 낮으면 취약 patch 자동 merge 위험.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `osvVulnerabilityAlerts` on/off — experimental 상태(needs-confirmation). 기본 `vulnerabilityAlerts`(GitHub Alerts, stable) primary, osv 는 maven 커버리지 검증 후 opt-in.
|
||||
|
||||
| 항목 | 정책 | 비고 |
|
||||
|---|---|---|
|
||||
| primary 도구 | Renovate `security:only-security-updates` preset | C1 (osv+vulnerabilityAlerts 활성) |
|
||||
| 조건부 대안 | Dependabot (조직 표준 또는 lockfile 미사용) | #12557 lockfile+catalog 충돌 회피가 Renovate 선택 이유 |
|
||||
| patch 보안 PR | CI green 시 auto-merge | UNSUPPORTED_IMPL (위) |
|
||||
| minor/major 보안 PR | human review 필수 | breaking 위험 |
|
||||
| **transitive 취약점** | Renovate/Dependabot 미커버(직접 의존성만) → Gradle `dependencies { constraints { } }` 또는 `resolutionStrategy.force` 로 수동 override | UNSUPPORTED_IMPL: Gradle 메커니즘 선택. supply-chain D8 locking 과 정합 필요 |
|
||||
|
||||
### 5. 의존성 라이선스 준수 스캔 (license/NOTICE)
|
||||
|
||||
> **Trace**: D10 (`trivy-java-language-coverage#C1` — `*gradle.lockfile` License ✓; `github-dependency-review-action#C5` — allow/deny license list). governing §35-E L2052 가 license scan 을 본 branch 영역으로 명시.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: **금지/허용 SPDX 라이선스 목록** — 어떤 라이선스(예: GPL-3.0/AGPL-3.0 strong-copyleft)가 release-blocking 인지는 조직 법무/정책. Trivy/GitHub 문서는 *스캔·allow/deny 메커니즘*만 보장. trade-off: 보수적(allow-list only) = 신규 의존성 마찰↑; 관대(deny-list) = 누락 위험.
|
||||
|
||||
| 단계 | 도구 | 동작 | 근거 |
|
||||
|---|---|---|---|
|
||||
| PR-time 신규 라이선스 차단 | dependency-review-action | `allow-licenses`/`deny-licenses` 목록으로 PR diff 의 새 의존성 라이선스 검사 | `github-dependency-review-action#C6` |
|
||||
| 전체 스냅샷 license scan | Trivy (D1 과 동일 fs scan) | `*gradle.lockfile` License 컬럼 — 별도 도구 불필요 | `trivy-java-language-coverage#C1` |
|
||||
| forbidden 라이선스 발견 | release-blocking | CVE 차단(D2)과 동일 게이트 계열 | 정책(UNSUPPORTED_IMPL: 목록) |
|
||||
|
||||
## Audit & Findings — Single-Owner 정합 (cross-branch)
|
||||
|
||||
> 본 branch 가 §25 SSOT Owner Map 에 부재하던 **dependency vulnerability *정책* owner** 로 신설되며 해소하는 cross-branch finding. consistency-contract §전파의 *역참조 비차단 알림* 대상(쓰기 시 hook 이 ci-gates:255 → D5 참조를 3회 알림). 아래는 owner 확정 + sibling 갱신 권고(비차단 — 본 branch 머지와 독립).
|
||||
|
||||
**1. OWNER 확정 (Cross-Branch Conflict Procedure §25 통과)**
|
||||
- §25 SSOT Owner Map `contract area` grep: "vulnerability"/"dependency vulnerability" owner **부재** 확인 → 본 branch 가 new owner 자격.
|
||||
- sibling grep 결과 동일 영역 스텁 3건 발견(모두 UNSUPPORTED, 검증 깊이 0 → 시간순·도메인 우선 원칙상 전용 branch 가 SSOT):
|
||||
- ci-gates **D5** + §Audit `OWNER_AMBIGUITY`: "scanner *tool 선택* 미결 → dependency-vulnerability 또는 supply-chain 으로 위임" → **본 branch D1 이 Trivy 로 확정**(미결 해소).
|
||||
- supply-chain **D2** (high/critical=release-blocking, UNSUPPORTED, "CVSS 외부 표준 보강 권고") → **본 branch D2/D3 이 CVSS v3.1 + KEV 표준 부여**.
|
||||
- supply-chain **D3** (Renovate/Dependabot, ~~UNSUPPORTED~~ → 2026-06-15 `official-vendor-doc` 로 전환: [[raw/official-docs/renovate-gradle-manager-official]] RENOV-GRAD-C1~C4) → **본 branch D6 이 security-update 정책 owner 로 확정** (supply-chain D3 는 Gradle 지원 범위·lockfile·supply-chain 제약 raw 소유).
|
||||
|
||||
**2. Sibling 역참조 갱신 권고 (비차단, 다음 작업자/`/sync`)**
|
||||
|
||||
| 대상 | 현재 | 갱신 후 |
|
||||
|---|---|---|
|
||||
| ci-gates D5 / §Audit OWNER_AMBIGUITY | "scanner tool 선택 미결" | "scanner = [[feature-dependency-vulnerability-management-contract]] D1 (Trivy, 결정 완료)" |
|
||||
| ci-gates Gate 매트릭스 "vulnerability scan" owner 열 | severity=supply-chain / tool=dependency-vuln(미결) | severity·tool·suppression 정책 = dependency-vuln D1~D5; *gate 배선* 만 ci-gates |
|
||||
| supply-chain D2 | UNSUPPORTED (severity 표준 보강 권고) | "severity 표준 = dependency-vuln D2(CVSS v3.1)+D3(KEV); 본 D2 는 release-block *시점/posture* 만 소유" |
|
||||
| supply-chain D3 | ~~UNSUPPORTED~~ → `official-vendor-doc` 로 갱신됨 (2026-06-15: [[raw/official-docs/renovate-gradle-manager-official]] RENOV-GRAD-C1~C4 등록) | "security-update 정책 owner = dependency-vuln D6; supply-chain D3 는 Gradle 파일 패턴·lockfile 갱신·supply-chain 제약 근거를 소유" |
|
||||
| **container-runtime (image-scan Decision 부재)** | image scan/Trivy/severity 결정 0건 → 본 branch 의 consumer 링크가 dangling | container-runtime 에 "image vuln scan = Trivy image, severity 정책 = dependency-vuln D2/D3 consume" Decision 신설 권고(없으면 §구현가이드 §1 pre-release row 의 wiring owner 가 미존재) |
|
||||
| 프로젝트노트 §25 SSOT Owner Map | (row 없음) | 신규 row: `dependency vulnerability policy \| feature-dependency-vulnerability-management-contract \| consumers: ci-gates(gate wiring)·container-runtime(image scan)·supply-chain(release-block posture)` — **본 루프에서 프로젝트노트에 직접 추가함**(coverage Should-fix 해소) |
|
||||
|
||||
**3. Producer/Consumer 경계 (재진술 금지 — Reference-Only)**
|
||||
- 본 branch = **producer** of severity 표준 + scanner + suppression + update 정책.
|
||||
- ci-gates·container-runtime·supply-chain = **consumer** (배선/시점만). 본 branch 는 그들의 wiring 을 재진술하지 않고, 그들은 본 branch 정책을 재진술하지 않고 `[[...]] D<n>` 포인터로만 인용.
|
||||
|
||||
**4. OUT_OF_BRANCH_SCOPE (본 branch 로 끌어오지 않음)**
|
||||
- secret scan(gitleaks) → secrets-config-source. container base image 선택 + image scan *wiring* → container-runtime. CI gate `needs:`/`if:` 배선 → ci-gates. SBOM/서명/version-locking → supply-chain. (본 branch 는 정책만 — 위 항목의 detail 을 §구현가이드에 남기지 않음.)
|
||||
- **정정(2026-06-15 coverage 루프)**: `license/NOTICE scan` 은 OUT_OF_BRANCH_SCOPE 가 *아님* — governing §35-E L2052 가 본 branch 영역으로 명시했고 supply-chain 은 license 결정 0건이라 delegated owner 부재였음. → **D10 으로 본 branch 가 covered-here**.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4(깊이 게이트) 캡처용. 정상 경로 외에 *구현 중 부딪힐* 실패/엣지/다른 계약 의존을 미리 열거.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **스캐너 DB 미가용/네트워크 차단** (CI 러너 air-gap, Trivy DB pull 실패) → 스캔이 silent pass 하면 안 됨. 기대: DB fetch 실패 = job fail (취약점 0 보고와 구분). Trivy `--exit-code` 와 별개로 DB 갱신 실패 fail-fast 검증 필요(Claims To Verify).
|
||||
- **False positive 차단** (Gradle `force=true` 재정의, backport patch 미인식) → 잘못된 release block. 기대: D5 suppression 으로 만료일+사유 달고 우회, 영구 ignore 금지.
|
||||
- **Transitive 취약점에 직접 fix 없음** → Renovate/Dependabot PR 생성 실패(직접 의존성만). 기대: Gradle constraint 수동 override (§구현가이드 §4), supply-chain D8 lock 재생성.
|
||||
- **NVD enrichment 공백** (2026-04 정책 변경, 신규 CVE CVSS 미부여) → severity 미상으로 게이트 통과. 기대: GHSA fallback(D4) + KEV(D3) 가 점수 없는 악용 CVE 를 잡음.
|
||||
- **Multi-module lockfile 미탐지** → 일부 subproject 스캔 누락(취약점 silent miss). 기대: `--file-patterns` + 각 subproject lockfile 커밋 검증.
|
||||
- **`.trivyignore` 무단 추가로 긴급 우회** → 2026-05-25 audit finding. 기대: D5 정적 게이트가 무단 변경 차단.
|
||||
- **dependency-review-action fail-open** (Gradle dependency graph 미제출 → 빈 diff = 0 취약점 pass) → PR 게이트가 거짓 통과. Trivy DB fail-open 과 동일 계열. 기대: graph 제출 검증 step(없으면 fail) + D1 Trivy fs 전체 스캔이 backstop(D7 단독 게이트 금지 이유).
|
||||
- **다른 계약 의존 (cross-contract)**:
|
||||
- **소비자 (본 branch 정책을 consume)**: [[raw/branch-notes/feature-ci-quality-gates-contract]] D5(gate wiring — vuln scan 의 release-blocking 배선), [[raw/branch-notes/feature-container-runtime-contract]] (image scan wiring, 동일 severity 정책 consume), [[raw/branch-notes/feature-build-release-supply-chain-contract]] D2(release-block 시점에 본 branch severity 표준 사용).
|
||||
- **생산자 (본 branch 가 consume)**: [[raw/branch-notes/feature-build-release-supply-chain-contract]] **D8**(Gradle dependency-locking — Trivy `*gradle.lockfile` 스캔의 *선행조건*; lock 없으면 D1 스캔 불가) + **D5**(container base = Temurin JRE slim — image scan 대상). 이 계약이 바뀌면(예: lockfile 명명/위치 변경) 본 branch 의 `--file-patterns` 와 D1 스캔이 영향.
|
||||
- **owner 정합 필요 (비차단 전파, §Audit)**: supply-chain D2/D3, ci-gates D5 의 `UNSUPPORTED`/`OWNER_AMBIGUITY` 스텁이 본 branch 를 정책 owner 로 가리키도록 갱신돼야 single-owner 완결.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다.
|
||||
> 구현 전/중/후에 실제로 검증해야 하는 주장을 분리한다.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| Trivy `--file-patterns "gradle-lockfile:*.lockfile"` 가 ca-tmpl 실제 lockfile 명명을 탐지 | 공식 문서 미명시 workaround(#9740); ca-tmpl lockfile 명명(`gradle.lockfile` vs `gradle/dependency-locks/*.lockfile`) 미확정 | ca-tmpl 에 `gradle dependencies --write-locks` 실행 → lockfile 생성 후 `trivy fs --file-patterns ...` 가 각 subproject 탐지하는지 verify | `needs-confirmation` |
|
||||
| Java/Gradle 패키지에서 GHSA 점수가 NVD 점수를 override (D4 tie-break) | coverage 페이지는 OS 패키지만 vendor>NVD 우선 명시; 언어 패키지 override verbatim 미확보 | `trivy.dev/docs/latest/scanner/vulnerability/` 소스 우선순위 페이지 아카이브 + NVD/GHSA 점수 다른 known CVE 로 Trivy 출력 severity 확인 | `needs-confirmation` |
|
||||
| Renovate `vulnerabilityAlerts` 가 schedule 을 무시하고 즉시 PR + `osvVulnerabilityAlerts` maven 커버 | presets 페이지에서 schedule-ignore·osv experimental·maven datasource 미확인(summarizer 폐기) | `configuration-options#vulnerabilityalerts`·`#osvvulnerabilityalerts` 아카이브 + 실제 repo 에 known-vuln dep 추가 → 즉시 PR 생성 verify | `needs-confirmation` |
|
||||
| Dependabot Gradle security update 가 `libs.versions.toml`+lockfile 동시 사용 시 lockfile 갱신 (#12557) | about 페이지는 Gradle 지원을 링크로 위임; #12557 미해결(2025-07) | supported-ecosystems 페이지 + #12557 상태 확인; 테스트 repo 로 Dependabot security PR 이 lockfile drift 유발하는지 verify | `needs-confirmation` |
|
||||
| Dependabot 은 dependabot.yml native auto-merge 없음 → Renovate 대비 복잡 | about 페이지에 `auto-merge` 키워드 0건(summarizer 확인) | `automating-dependabot-with-github-actions` 페이지 아카이브로 auto-merge 가 Actions workflow 필요함 확정 | `needs-confirmation` |
|
||||
| KEV "exploited in the wild" 정의 + 비연방 권고 + BOD 26-04 4-factor | CISA HTML 403 으로 JSON feed 만 확보(정의·권고 미인용) | CISA 카탈로그 About + BOD 26-04 페이지 접근 가능 시 별도 raw 아카이브(`bod-26-04-...`) | `needs-confirmation` |
|
||||
| dependency-review-action 이 Gradle 의존성 diff 를 보려면 dependency graph 제출 필요 | Gradle dependency graph 자동 추출 vs submission API 경로 불확실 | GitHub dependency graph 가 Gradle 프로젝트를 인식하는지 + `dependency-submission` action 필요 여부 확인 | `needs-confirmation` |
|
||||
| 스캐너 DB fetch 실패가 silent pass 가 아니라 job fail | Trivy `--exit-code` 는 취약점 발견용; DB 갱신 실패 시 동작 미확정 | CI 에서 DB endpoint 차단 후 Trivy 실행 → exit code 검사; fail-fast 안 되면 `--exit-on-eol`/DB 검증 step 추가 | `planned` |
|
||||
| scheduled 재스캔이 의존성 불변 상태에서 신규 CVE 를 실제로 잡음 | CVE DB 갱신만으로 새 매치가 생기는지 실증 필요 | known-clean dep 고정 후 일정 기간 뒤 재스캔 → 그 사이 공개된 CVE 가 잡히는지 verify | `planned` |
|
||||
| `.trivyignore.yaml` 무단 변경 차단 정적 게이트가 우회 불가 (D5/audit 해소) | 게이트 구현(regex/CODEOWNERS) 미확정 | `.trivyignore.yaml` 에 만료일·사유 없는 row 추가 PR → CI fail + CODEOWNERS 승인 없이 merge 불가 verify | `planned` |
|
||||
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
> `/coverage` 가 채우는 **생성물** — 손으로 유지하지 않는다. governing 문서(frontmatter `governing_docs`)가 요구하는 관심사를 이 브랜치가 빠짐없이 덮는지의 결과. 기준: `rules/coverage-gate.md`.
|
||||
> 상태: `covered-here`(이 브랜치 결정) / `delegated`(다른 owner 브랜치) / `missing`(아무도 안 맡음 → Blocking).
|
||||
|
||||
> 출처: `/coverage` (coverage-auditor, 2026-06-15, governing = `raw/project-notes/ca-skeleton-operational-contract` §18 + §35-E L2052). 1차 Not-covered(missing 1: license scan) → 본 루프에서 D10 추가로 covered-here 전환 → Covered.
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|--------|------|-------|--------|------|
|
||||
| CVE scan 도구 선택(SCA 스캐너) | covered-here | — | — | D1 (Trivy) |
|
||||
| severity별 release-block 기준(CVSS 임계값) | covered-here | — | — | D2 (CVSS v3.1 ≥High) |
|
||||
| KEV override | covered-here | — | — | D3 |
|
||||
| 소스 우선순위 tie-break(GHSA vs NVD) | covered-here | — | — | D4 |
|
||||
| Suppression governance | covered-here | — | — | D5 |
|
||||
| 의존성 보안 업데이트 자동화(Renovate/Dependabot) | covered-here | — | — | D6 |
|
||||
| PR-time 보완 게이트(dependency-review-action) | covered-here | — | — | D7 |
|
||||
| EPSS escalation(비차단) | covered-here | — | — | D8 (UNSUPPORTED, optional) |
|
||||
| Remediation SLA by severity | covered-here | — | — | D9 (UNSUPPORTED, team-policy) |
|
||||
| **license/NOTICE compliance scan** | covered-here | — | — | **D10** (Trivy license + dep-review allow/deny; governing §35-E L2052) |
|
||||
| Transitive 취약점 처리 | covered-here | — | — | §구현가이드 §4 (D6 도출) |
|
||||
| Scheduled re-scan(CVE DB 갱신) | covered-here | — | — | §구현가이드 §1 |
|
||||
| CI gate wiring(blocking vs warning) | delegated | [[raw/branch-notes/feature-ci-quality-gates-contract]] | OK | Out of scope; ci-gates §Coverage L283 역참조 존재 |
|
||||
| dependency version locking | delegated | [[raw/branch-notes/feature-build-release-supply-chain-contract]] | OK | supply-chain D8 |
|
||||
| SBOM·서명(Cosign/SLSA)·versioning·rollback | delegated | [[raw/branch-notes/feature-build-release-supply-chain-contract]] | OK | supply-chain D4/D6/D7/D9/D11 |
|
||||
| container image scan wiring + base image | delegated | [[raw/branch-notes/feature-container-runtime-contract]] | Should-fix | §Audit — container-runtime 에 image-scan Decision *부재*(dangling consumer link) → 신설 권고 |
|
||||
| secret scan(gitleaks) | delegated | [[raw/branch-notes/feature-secrets-config-source-contract]] | OK | Out of scope |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
> 짧은 메모만. 깊이 있는 트러블슈팅은 `raw/errors/` 로 분리하고 아래 Cluster에 연결.
|
||||
|
||||
- 이슈 1
|
||||
- 원인:
|
||||
- 시도:
|
||||
- 해결: (또는 미해결이면 `needs-confirmation`)
|
||||
- 별도 에러 노트로 분리됨: `[[raw/errors/<...>]]` (생성 시)
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/dependabot-security-updates-gradle-official]]
|
||||
- [[raw/official-docs/github-dependency-review-action]]
|
||||
- [[raw/official-docs/renovate-vulnerability-alerts-gradle-official]]
|
||||
- [[raw/official-docs/trivy-action-github-actions]]
|
||||
- [[raw/official-docs/trivy-filtering-suppression-policy]]
|
||||
- [[raw/official-docs/trivy-java-language-coverage]]
|
||||
- [[raw/official-docs/vuln-severity-cisa-kev-catalog-official]]
|
||||
- [[raw/official-docs/vuln-severity-cvss-v31-spec-first-official]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: interviews:start -->
|
||||
- [[raw/interviews/trivy-suppression-dual-control-governance-2026-06-20]]
|
||||
<!-- GENERATED: interviews:end -->
|
||||
|
||||
<!-- GENERATED: errors:start -->
|
||||
- [[raw/errors/gitea-act-action-tag-and-dependency-graph-2026-06-20]]
|
||||
<!-- GENERATED: errors:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/gitea-act-dependency-security-gate-portability-2026-07-02]]
|
||||
- [[raw/blog-topics/trivy-suppression-governance-static-gate-2026-06-20]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
> 이 branch는 단일 노트가 아니라 **작업 묶음의 entry point**. 이 branch에서 파생된 모든 raw 노트를 카테고리별로 명시. 자식 노트가 forward link만 박아도 Obsidian backlink로 자동 발견되지만, 읽기 흐름과 분류를 위해 hub가 명시적으로 그룹화한다.
|
||||
|
||||
### Sub-branches (세부 작업)
|
||||
|
||||
- 없음 — 단일 branch 로 구현. 세부 작업 분기 불필요.
|
||||
|
||||
### 오류 기록 (이 branch 작업 중 발생)
|
||||
|
||||
- [[raw/errors/gitea-act-action-tag-and-dependency-graph-2026-06-20]] — commit 후 Gitea/act 첫 실행에서 CI 2개 잡 실패: trivy-action 태그 오타(`@0.28.0`→`@v0.28.0`) + dependency-review 의 Gitea dependency-graph API 부재(server_url 가드). egress 가설을 로그로 반증한 evidence-first 사례.
|
||||
- (구현 단계 자체는 blocking 오류 없음 — lockfile 부재로 Trivy fs 가 Gradle deps no-op 인 것은 오류가 아니라 문서화된 cross-contract 선행조건, supply-chain D8.)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- [[raw/interviews/trivy-suppression-dual-control-governance-2026-06-20]] — suppression 영구 우회 구멍을 CODEOWNERS(merge-gate) + `verifyTrivyignore`(CI field-gate) 이중 통제로 막은 설계, Renovate vs Dependabot 선택 근거.
|
||||
|
||||
### 강의 (이 작업을 위해 학습한 강의)
|
||||
|
||||
- 없음 — 외부 강의 학습 없이 공식 문서(Trivy/CISA-KEV/FIRST/Renovate/GitHub) 근거로 구현.
|
||||
|
||||
### job-posting tie-ins (이 작업에서 파생된 글감)
|
||||
|
||||
- [[raw/blog-topics/trivy-suppression-governance-static-gate-2026-06-20]] — Gradle 정적 게이트로 supply-chain suppression 거버넌스(만료일·사유 강제) 강제하기 + audit finding 해소.
|
||||
- derived blog: 생성 전. 생성 시 `wiki/blog/<slug>-YYYY-MM-DD.md` 후보
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
> 이 브랜치를 작업한 날짜들. 양방향 nav 유지.
|
||||
|
||||
- `[[raw/daily-notes/YYYY-MM-DD]]`
|
||||
- `[[raw/daily-notes/YYYY-MM-DD]]`
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움. `/ingest`가 이 섹션을 기준으로 wiki/projects/에 추출.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: (로컬/dev/staging/prod 어디까지 검증됐는지)
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-developer-experience-contract.md
|
||||
@@ -0,0 +1,443 @@
|
||||
---
|
||||
title: branch / feature-developer-experience-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-developer-experience-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]
|
||||
tags: [branch, ca-skeleton, developer-experience, local-dev]
|
||||
created: 2026-05-22
|
||||
target_merge:
|
||||
status_label: review
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-033
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-033
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-BOOTSTRAP-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-BUILD-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: d507282d4a550e9385db2a647f07c608950ae223886a80132d45d1957d2a2aee
|
||||
---
|
||||
|
||||
# branch: feature-developer-experience-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 실무자가 skeleton을 받아 바로 실행, 검증, 확장할 수 있는 local developer experience 기준을 정의합니다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 §18 Control Plane Contract → Developer Experience 영역의 결정/근거/금지 사항을 정제한다. governing doc = [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]] (DX 슬라이스).
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: fresh environment에서 ./gradlew bootstrap이 성공한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-BOOTSTRAP-001@1` | 신규 환경의 default 진입 명령은 ./gradlew bootstrap이다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-BUILD-001@1` | build tool은 Gradle Groovy DSL이다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
좋은 skeleton은 구조가 훌륭한 것에서 끝나지 않습니다. 새 개발자가 로컬에서 빠르게 실행하고, sample contract를 확인하고, 실패 기준을 재현할 수 있어야 합니다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- local bootstrap command.
|
||||
- `.env.example` 필수 key.
|
||||
- sample profile 실행/비활성화 기준.
|
||||
- Testcontainers 또는 local dependency 대체 기준.
|
||||
- smoke test command.
|
||||
- README/runbook link 기준.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- IDE별 개인 설정.
|
||||
- cloud development environment 강제.
|
||||
- production deployment guide.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 이 branch의 구현·설계 결정의 근거가 되는 외부 자료. 상세 비교는 §외부 근거 / 대안 조사 참조. company-tech-blog 인용은 `company-case-study` 강도이며 official-standard / official-vendor-doc 으로 격상 금지.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/dx-testcontainers-java-best-practices]] | D3·D10 — bootstrap step 2 의 local dependency 도구 + Spring Boot 3.1+ `@ServiceConnection` 기반 default integration test backend 근거 (TC-CORE-C1~C4 / TC-SPRING-C1 / TC-REUSE-C1) |
|
||||
| [[raw/official-docs/dx-mise-asdf-tool-versioning]] | D6 — JDK Temurin 21 LTS + `.tool-versions`/`.sdkmanrc` 핀 (도구를 강제 않고 파일 포맷을 강제하는 전략, DX-TV-C4/C5) |
|
||||
| [[raw/official-docs/dx-devcontainer-spring-boot]] | D9 — devcontainer 를 default 로 두지 않는 결정의 대안 평가 (DX-DC-C2 development-phase 한정 / DX-DC-C4 VS Code 한정 / DX-DC-C5) |
|
||||
|
||||
근거 자료가 raw에 아직 없다면 먼저 `raw-source-template` 또는 `lecture-note-template` 으로 raw에 등록한 뒤 여기서 링크.
|
||||
|
||||
## 외부 근거 / 대안 조사 (2026-05-22 — Group G-E: Developer Experience)
|
||||
|
||||
본 branch의 `./gradlew bootstrap` 5단계 + Temurin 21 LTS + Testcontainers integration + markdown-link-check 결정에 대한 외부 source.
|
||||
|
||||
- **채택 결정 (Gradle bootstrap + Testcontainers + Temurin 21)**:
|
||||
- [[raw/official-docs/dx-testcontainers-java-best-practices]] — Testcontainers + Spring Boot 3.1 `@ServiceConnection` + reuse/singleton 패턴
|
||||
- [[raw/official-docs/dx-mise-asdf-tool-versioning]] — mise/asdf/SDKMAN + `.tool-versions` 포맷 + Temurin 21 LTS
|
||||
- **검토한 대안**:
|
||||
- **대안 1: `make bootstrap`** — POSIX 표준이나 Windows 친화성 낮음
|
||||
- **대안 2: `docker compose up` only** — bootstrap 5단계 합성 어려움
|
||||
- **대안 3: devcontainer (VSCode·Codespaces)** — [[raw/official-docs/dx-devcontainer-spring-boot]] (containers.dev spec, IDE 종속성 + bootstrap 5단계 진입점/smoke 미해결)
|
||||
- **대안 4: Nix flake** — reproducibility 강점이나 Java 생태계 성숙도 낮음
|
||||
- **비교 핵심**: Gradle bootstrap이 5단계 합성 가능 + Spring 생태계 정합. Testcontainers `@ServiceConnection`(Spring Boot 3.1+)이 integration test의 boilerplate 제거. devcontainer는 IDE 종속이라 CI/CD와 분리 필요. **보강 후보**: `.tool-versions`(asdf/mise) vs `.sdkmanrc`(SDKMAN) 포맷 차이 — branch note "또는" 표현은 drift 위험, 단일 source로 좁힐 필요.
|
||||
|
||||
### 2026-06-15 addendum — D8 link-rot 도구 재조사 (`wiki-decision-researcher`)
|
||||
|
||||
D8 의 `markdown-link-check` 선택이 `UNSUPPORTED_DECISION` 이었으므로 대안을 조사했다 (`/branch-spec` §5 자동조사). 3종 비교:
|
||||
|
||||
| 도구 | Node 의존 | 유지보수 | CI gate | 비고 |
|
||||
|---|---|---|---|---|
|
||||
| `markdown-link-check` (npm/tcort) | 필수 (Docker 우회) | 단일 메인테이너, v3.14.2 (2025-11) | `tcort/github-action-markdown-link-check` | JVM-only repo 에 Node 툴체인 추가 비용 |
|
||||
| **`lychee` (Rust/lycheeverse)** | **없음 (단일 정적 바이너리)** | 활발 (3,700+ stars, v0.24.2 2026-05, 40+ 프로젝트) | `lycheeverse/lychee-action@v2.0.2+` (CVE-2024-48908 패치 핀 필수) | **권고** — JVM/Gradle repo DX 마찰 최소 |
|
||||
| `linkinator` (npm/binary) | npm 경로 필수 / 바이너리 옵션 | 활발 (v7.6.1 2026-02, Google Cloud SDK 사용) | `JustinBeckwith/linkinator-action@v1` | Node 도입 시 후보 |
|
||||
|
||||
- **조건부 권고**: ca-tmpl 이 `package.json`/Node toolchain 미도입을 유지하는 한 → **lychee** (Node 의존 없음). Node 를 다른 이유로 도입하면 → linkinator. 기존 Docker-first/MegaLinter 파이프라인이면 → markdown-link-check.
|
||||
- **archiving 상태 (`deferred`)**: 위 비교의 raw 검증 자료(`wiki-source-summarizer` ×6, official + case-study) archiving 은 **사용자 승인 대기 중**. 승인 시 controller 가 dispatch → 생성 후 D8 의 `UNSUPPORTED_DECISION` 라벨을 `official-vendor-doc + company-case-study` 로 격상. 미archiving 상태에서는 D8 을 "조사됨, raw 미archiving" 로 표기(추측 단정 금지).
|
||||
|
||||
## TODO
|
||||
|
||||
> TODO drained 2026-05-22 — 결정은 아래 "결정 사항" / "Decision Evidence Map" / "Decisionized Work Items" / "테스트 계약" 참조. bootstrap/`.env.example`/sample profile/Testcontainers-local dep/smoke command/README-wiki 연결 모두 표 또는 결정 라인으로 반영됨. 잔존 TODO 없음.
|
||||
>
|
||||
> **구현 현황 (2026-06-15 ground truth)**: 본 branch 는 **documented-only / planned 단계** — ca-tmpl `src/` 실 코드에 `bootstrap` Gradle task·`.env.example`·`.tool-versions`·markdown-link-check·smoke test·CI 모두 미작성. 실제로 존재하는 것은 Flyway migration(V1/V3/V4) + JDK 21 toolchain + 수동 `@Container` Testcontainers + sample-portfolio(test-scope, ArchUnit 격리)뿐. 단계별 grade 는 §구현 가이드, drift 는 §Audit & Findings.
|
||||
>
|
||||
> **2026-06-24 구현 결과**: direct-owner 범위는 `actually-implemented`이며 Linux local에서 `locally-verified`됐다. `./gradlew bootstrap` 5단계, README command drift gate, `@ServiceConnection` context tests, local-only Testcontainers reuse policy, lychee workflow가 코드에 존재한다. `.env`/sample runtime toggle/fresh-clone CI는 기존 위임 owner를 유지한다. lychee remote CI와 macOS/WSL2는 `needs-confirmation`이다.
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- 2026-06-15: ca-tmpl 코드 대조 결과 본 branch 의 다수 결정이 *아직 미구현(planned)* 이거나 *이미 구현된 sibling branch 와 drift* 함이 확인됨. 자동 rewrite 하지 않고 §Audit & Findings 에 정합 권고로 기록 (사용자 작성 결정 영역). 핵심 drift 5건: `BOOTSTRAP_TASK_ABSENT`, `GRADLE_VERSION_DRIFT`(8.x→실제 9.0.0), `ENV_EXAMPLE_SUPERSEDED`(`.env.example` → `src/.env`+`verifyEnvKeys` 로 sibling 이 이미 해소), `SAMPLE_ENABLE_MECHANISM_DRIFT`(@Profile 가정 → 실제 ArchUnit+env), `SERVICECONNECTION_NOT_USED`(@ServiceConnection 가정 → 실제 수동 `@Container`).
|
||||
- 2026-06-24: D3/D4/D8/D10을 구현했다. bootstrap 첫 실행에서 host 5432 collision과 slim JRE RNG provider 누락을 발견해 각각 internal-only DB network와 `SplittableRandom` composition bean으로 해결했다. `./gradlew bootstrap`, `./gradlew test check`, focused ServiceConnection tests를 local에서 검증했다.
|
||||
- 2026-06-30: CleanArchitectureTest.java의 자원 누수 경고 해결(@SuppressWarnings("resource") 추가 및 import 스타일 정리), README.md에서 누락되었던 feature-developer-experience-contract 식별자 복구로 테스트 통과 확인, Spring Boot 3.5.x EOL 경고 무시를 위한 VS Code settings.json 설정 반영. 추가로 Spring Boot 4.x 업그레이드 시 Testcontainers 2.0 라이브러리와의 마이그레이션 호환성을 면밀히 재평정한 spec 문서(docs/superpowers/specs/2026-06-30-testcontainers-2-0-migration-spec.md) 작성 완료.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-05-22: local 실행은 prod-safe 기본값을 훼손하지 않는 별도 profile/env로만 허용.
|
||||
- 2026-05-22: sample fixture는 local/dev에서 쉽게 켤 수 있어야 하고 prod에서는 기본 비활성화.
|
||||
- 2026-05-22: bootstrap command 기본값은 `./gradlew bootstrap`. 없으면 `./gradlew test`와 `docker compose up` wrapper를 제공.
|
||||
- 2026-05-22: README는 canonical wiki를 대체하지 않고, local start/smoke/adoption entrypoint만 제공.
|
||||
- 2026-05-22: OS 매트릭스 = Linux (Ubuntu 22.04+), macOS (Apple Silicon 우선), Windows (WSL2 only). CI는 Linux만, 개발자는 3개 OS 검증 의무.
|
||||
- 2026-05-22: JDK = Temurin 21 LTS. gradle-wrapper 8.x. `.tool-versions` 또는 `.sdkmanrc`로 핀.
|
||||
- 2026-05-22: bootstrap task 정의 = `./gradlew bootstrap` = (1) `./gradlew compileTestJava` (compile sanity) (2) `docker compose up -d` (local dependencies via Testcontainers config 또는 별도 compose file) (3) Flyway migrate (4) sample profile seed (5) smoke test 실행. 5단계 모두 통과 시 성공.
|
||||
- 2026-05-22: sample profile default = clone 직후 enabled. prod profile에서는 disabled (`feature-sample-removal-adoption-contract`와 일관).
|
||||
- 2026-05-22: link-rot 검증 = `markdown-link-check` (npm). CI에서 README + docs/ 전수 검사.
|
||||
- **2026-06-15 (위 항목 보강/대체 후보 — D8)**: link-rot 도구 재조사 결과 **lychee** (Node-free 단일 Rust 바이너리) 를 조건부 권고. 기존 `markdown-link-check` 결정은 *Node 의존 비용 미평가*였음(ca-tmpl 은 `package.json` 없는 JVM-only repo). 상세·트레이드오프: §외부 근거 2026-06-15 addendum + Decision Evidence Map D8.
|
||||
- **2026-06-15 (정합 메모 — gradle wrapper)**: 위 "gradle-wrapper 8.x" 결정은 ca-tmpl 실제 `gradle/wrapper/gradle-wrapper.properties` 의 **9.0.0** 과 drift. 핀 *전략*(repo wrapper 로 Gradle 버전 고정)은 유효하나 *버전 숫자*는 9.0.0 으로 정정 필요(§Audit `GRADLE_VERSION_DRIFT`).
|
||||
|
||||
## Work Item Contract
|
||||
|
||||
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
|
||||
|
||||
| field | required | rule |
|
||||
| --- | --- | --- |
|
||||
| Decision | yes | 구현자가 선택해야 하는 기본값 |
|
||||
| Allowed | yes | 허용되는 예외와 조건 |
|
||||
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
|
||||
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
|
||||
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
|
||||
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
|
||||
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 본 branch 의 각 결정을 raw source 의 Claim ID 로 매핑. `선택 조건`(R2): 이 조건일 때 이 결정 / 다른 조건이면 어떤 대안. company-tech-blog 인용은 `company-case-study` 강도이며 official-standard / official-vendor-doc 으로 격상 금지.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | local 실행은 prod-safe 기본값을 훼손하지 않는 별도 profile/env 로만 허용 | 항상 — local 편의를 위해 prod 기본값(error detail 노출·body logging 등)을 바꿔야 하면 별도 profile/env override 로만. **금지 대안**: 단일 profile 로 local+prod 겸용(=prod-unsafe default 누출) | (ca-tmpl 고유 정책; 외부 raw claim 없음. parent §9 Env-driven Runtime Config 에 정합) | UNSUPPORTED_DECISION | 외부 standard 부재 — 자체 정책으로만 정당화 |
|
||||
| D2 | sample fixture 는 local/dev 기본 enabled, prod 기본 disabled | clone 직후 교육/계약검증 목적이면 enabled; prod 배포 profile 이면 disabled. **enable/disable 런타임 메커니즘 owner = `feature-sample-removal-adoption-contract`** (본 branch 는 DX 진입점만, 위임) | (`feature-sample-removal-adoption-contract` 와 연계; 본 branch 외부 raw 직접 claim 없음) | UNSUPPORTED_DECISION (delegated) | enable/disable (env `APP_SAMPLE_ENABLED`, registry-backed; 런타임 토글 *코드 메커니즘*은 owner 확정 대상 — 본 branch 가 단정 안 함) 는 sibling owner, 본 branch 미구현 |
|
||||
| D3 | bootstrap 단일 entry point = `./gradlew bootstrap` 5단계 (compileTestJava → docker compose up -d → Flyway migrate → sample profile seed → smoke test) | skeleton 채택자가 *single command first-run* 을 원할 때 `./gradlew bootstrap`; CI/스크립트가 단계별 제어 필요하면 각 sub-task 직접 호출. **대안**: `make`(Windows 친화성↓, §외부근거 대안1) / `docker compose up` only(5단계 합성 불가, 대안2) | `raw/official-docs/dx-testcontainers-java-best-practices.md#TC-CORE-C1`, `#TC-CORE-C2`, `#TC-CORE-C3` | `official-vendor-doc` (Testcontainers 가 integration test backend 로 적합함만 증명 — 5단계 합성 자체는 ca-tmpl 고유) | **`bootstrap` task 미존재(`planned`)** — 실제 first-run 은 README `cd src && ./gradlew bootRun`. docker-compose 파일 3종 모두 0-byte(빈). Flyway 만 실존. 5단계 합성·smoke 미구현 (§Audit `BOOTSTRAP_TASK_ABSENT`) |
|
||||
| D4 | README 는 canonical wiki 를 대체하지 않고 local start/smoke/adoption entrypoint 만 제공 | README 는 *진입점*(첫 실행/스모크/채택 절차)만; 개념·계약 설명이 필요하면 canonical wiki 로 링크. **금지 대안**: README 를 별도 SSOT 로 운영(=canonical 과 drift) | (ca-tmpl 고유 운영 규약; 외부 raw claim 없음) | UNSUPPORTED_DECISION | 외부 standard 부재. 실제 README 존재하나 `bootstrap`/smoke section 없음(`bootRun` 만) → `planned` 부분 |
|
||||
| D5 | OS 매트릭스 = Linux (Ubuntu 22.04+), macOS (Apple Silicon 우선), Windows (WSL2 only). CI 는 Linux, 개발자는 3개 OS 검증 | Linux = CI 필수 게이트; macOS Apple Silicon / Windows WSL2 = 개발자 로컬 검증 의무. **미지원 대안**: native Windows(non-WSL2) | (ca-tmpl 고유 정책; 외부 raw claim 없음) | UNSUPPORTED_DECISION | Apple Silicon arm64 emulation 비용은 Testcontainers 메모(TC raw §메모)에서 경고만 — 정량 근거 없음 |
|
||||
| D6 | JDK = Temurin 21 LTS. gradle wrapper 핀(전략). `.tool-versions` 또는 `.sdkmanrc` 로 IDE/CLI 핀 | JDK 강제는 *2층*: build 는 Gradle toolchain(`JavaLanguageVersion.of(21)`), IDE/CLI 는 `.tool-versions`/`.sdkmanrc`. 도구(mise/asdf/SDKMAN)는 강제 안 함 — **파일 포맷만** 강제(DX-TV-C5). 단일 포맷 권장(둘 다 두면 drift) | `raw/official-docs/dx-mise-asdf-tool-versioning.md#DX-TV-C4`, `#DX-TV-C5` | `official-vendor-doc` (asdf 의 `.tool-versions` 단일 spec 위치 정의) | **gradle wrapper 실제 = 9.0.0**(노트 "8.x" 와 drift, §Audit `GRADLE_VERSION_DRIFT`). `.tool-versions`/`.sdkmanrc`/`.mise.toml` 미존재(`planned`) — 실존은 build.gradle toolchain 21 뿐. Temurin 21 EOL(`DX-TV-C8`)·mise↔asdf 호환(`DX-TV-C5` "Does not prove")은 `needs-confirmation` |
|
||||
| D7 | sample profile default = clone 직후 enabled, prod profile disabled | D2 와 동일 정책의 default 표현. enable/disable 코드 owner = `feature-sample-removal-adoption-contract`(`APP_SAMPLE_ENABLED`); 격리 owner = `feature-sample-domain-contract-fixture`(ArchUnit). 본 branch 는 위임 | (sibling branch 와 일관성; 외부 raw claim 없음) | UNSUPPORTED_DECISION (delegated) | 실제 격리는 Spring `@Profile` 이 아니라 ArchUnit `production_code_does_not_depend_on_sample_portfolio` + test-scope (§Audit `SAMPLE_ENABLE_MECHANISM_DRIFT`) |
|
||||
| D8 | link-rot 검증 도구 — **lychee**(Node-free 단일 바이너리) 조건부 권고, 기존 `markdown-link-check` 대체 후보 | Node toolchain 미도입 유지 → **lychee**(`lycheeverse/lychee-action@v2.0.2+`); Node 도입 시 → linkinator; 기존 Docker-first/MegaLinter → markdown-link-check (2026-06-15 조사) | (조사됨 — §외부근거 2026-06-15 addendum; raw archiving `deferred`, 사용자 승인 대기) | researched, raw 미archiving (이전 `UNSUPPORTED_DECISION`) | lychee-action CVE-2024-48908 → v2.0.2+ pin 필수. Gradle exec task 래핑·로컬 바이너리 프로비저닝 미설계. archiving 전까지 official Claim ID 부재 |
|
||||
| D9 | devcontainer 를 default 로 두지 않음 (IDE별 개인 설정 out-of-scope) | IDE 통일이 팀 강제이고 VS Code/Codespaces 단일 환경이면 devcontainer 고려; 다IDE/CI 분리 필요하면 default 제외(현 결정). 근거: spec 은 development-phase 한정(DX-DC-C2), VS Code 한정 통합(DX-DC-C4) | `raw/official-docs/dx-devcontainer-spring-boot.md#DX-DC-C2`, `#DX-DC-C4`, `#DX-DC-C5` | `official-standard` + `official-vendor-doc` | devcontainer + ca-tmpl bootstrap 양립 시연 없음 — 채택 시 별도 검증 필요(Claims To Verify 참조) |
|
||||
| D10 | Testcontainers (`@ServiceConnection`) 를 default integration test backend 로 둠 | Spring Boot 3.1+ integration test backend = Testcontainers; bootstrap 의 *로컬 dependency* 는 `docker compose`(test lifecycle ≠ bootstrap lifecycle, TC raw §메모). reuse 는 로컬 opt-in / CI off(TC-REUSE-C1) | `raw/official-docs/dx-testcontainers-java-best-practices.md#TC-CORE-C1`, `#TC-SPRING-C1`, `#TC-REUSE-C1` | `official-vendor-doc` (core 정의) + `needs-confirmation` (`@ServiceConnection` verbatim·reuse property 명 미확정) | **실제 코드는 `@ServiceConnection` 미사용 — 수동 `@Container PostgreSQLContainer`** (OutboxAppend/OutboxPublisher/DistributedLock contract test). @ServiceConnection 전환은 `planned` (§Audit `SERVICECONNECTION_NOT_USED`). `TC-SPRING-C1`/`TC-REUSE-C1`/`TC-SINGLETON-C1` 모두 `needs-confirmation` |
|
||||
| D11 | runtime container의 outbox jitter RNG는 `java.base` 구현을 명시 주입 | slim JRE에서도 startup이 필요하면 `SplittableRandom`; provider-specific algorithm이 필수면 runtime module 포함 대안 | 프로젝트 container stack trace + `OutboxConfigTest` RED/GREEN (외부 raw claim 없음) | `UNSUPPORTED_DECISION` | 알고리즘 품질/성능을 외부 공식 source로 재검토하지 않음. trade-off: provider portability를 startup 안정성보다 우선하지 않음 |
|
||||
| D12 | local PostgreSQL은 host port를 publish하지 않고 Compose internal network에서만 사용 | app container startup Flyway가 migration owner일 때 internal-only; host DB client가 필요하면 별도 override | 프로젝트 `docker compose config` + port collision 재현 (외부 raw claim 없음) | `UNSUPPORTED_DECISION` | host-side DB tool 사용자는 explicit override 필요. trade-off: zero-conflict 기본값과 직접 접속 편의의 교환 |
|
||||
| D13 | ArchCondition 초기화 시 발생하는 ECJ 자원 누수 경고(Resource leak)를 `@SuppressWarnings("resource")`로 억제 | 항상 — ArchCondition 익명 이너 클래스 정의 시 컴파일러의 오탐지로 인한 경고 해결 | (프로젝트 빌드 경고 해결용; 외부 raw claim 없음) | `UNSUPPORTED_DECISION` | N/A |
|
||||
| D14 | Spring Boot 3.5.x EOL 경고를 VS Code `settings.json`에서 무시하도록 설정 | 항상 — Testcontainers 2.x 메이저 업그레이드로 인한 패키지 변경 등 파급 효과를 피하기 위해 3.5.16 버전을 유지하고 IDE 경고만 비활성화 | (IDE 문제 경고 해결용; 외부 raw claim 없음) | `UNSUPPORTED_DECISION` | N/A |
|
||||
|
||||
## Decisionized Work Items
|
||||
|
||||
| item | Decision | Allowed | Forbidden | Required test |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| bootstrap | one command: `./gradlew bootstrap` | wrapper around docker compose/test | multiple competing first-run docs | bootstrap smoke |
|
||||
| `.env.example` | registry-complete safe local values | comments for secret placeholders | prod secrets in example | env example check |
|
||||
| sample profile | local/dev enabled, prod disabled | education profile | prod sample endpoint | sample profile smoke |
|
||||
| README/wiki | README entrypoint, wiki canonical | README links canonical | README as separate truth | doc drift check |
|
||||
|
||||
> ⚠️ **2026-06-15 정합 주의**: 위 `.env.example` row 는 sibling [[raw/branch-notes/feature-env-driven-runtime-configuration]] D7(2026-06-08 B 결정)이 **`.env.example` 미사용 + `src/.env` git-tracked 단일 소스 + `verifyEnvKeys` 3-way gate** 로 이미 해소함. 본 branch 의 `.env.example` 결정은 *superseded* — DX coverage 상 "env template self-sufficiency" 관심사는 그 sibling 에 **위임**한다(§Coverage). 자동 삭제하지 않고 정합 권고만(§Audit `ENV_EXAMPLE_SUPERSEDED`).
|
||||
|
||||
## DX Defaults (deprecated)
|
||||
|
||||
> DX 결정 표 SSOT는 위 "Decisionized Work Items". 별도 DX Defaults 양식은 deprecated.
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> *결정(Decisions)* 이 "*무엇*" 이라면 본 §는 "*어디에 어떻게*" 의 사전 명세. 각 sub-section 은 Decision ID + Supporting Claim ID 를 Trace. ca-tmpl `src/` 실 코드 대조(2026-06-15)로 reality grade(`actually-implemented`/`planned`/`documented-only`/delegated)를 셀마다 표기 — 노트 자기보고가 아니라 코드 grep 으로 확정.
|
||||
>
|
||||
> **3-rule**: R1 모든 detail 은 Decision+근거 도출 · R2 근거 없는 임의 detail 은 `UNSUPPORTED_IMPL_DECISION`+trade-off · R3 본 branch 결정 범위 밖은 위임(§Audit 에 이관 history).
|
||||
|
||||
### 1. bootstrap 단일 진입점 — `./gradlew bootstrap` 5단계
|
||||
|
||||
> **Trace**: D3 (5단계 정의) / `dx-testcontainers#TC-CORE-C1~C3`. anchor = ca-tmpl `src/build.gradle`(task 미존재) + `README.md` §로컬 실행 + `docker-compose*.yml`(3종) + `src/adapter-persistence/.../db/migration/`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 5단계의 *합성 메커니즘*(단일 Gradle task vs Makefile vs compose wrapper)은 외부 raw 가 권고하지 않음. Gradle task 채택 trade-off: Spring 생태계 정합·step 별 exit code 분리 가능하나 Windows(WSL2 밖) 친화성은 make 보다 낮음(D5 WSL2 강제로 회피).
|
||||
|
||||
| 단계 | 구현 anchor (목표) | ca-tmpl 실제 상태 (2026-06-15) | grade |
|
||||
|---|---|---|---|
|
||||
| (1) compile sanity | `./gradlew compileTestJava` | 표준 task 존재 | `actually-implemented` |
|
||||
| (2) local dependency 기동 | `docker compose up -d` (별도 compose file) | `docker-compose.yml`/`.dev.yml`/`.local.yml` 모두 **0-byte(빈)** | `planned` |
|
||||
| (3) Flyway migrate | `flyway-core` + `db/migration/V*.sql` | V1__idempotency_record / V3__outbox_event / V4__int_lock (+ sample V2__work_log) 실존, `baseline-on-migrate: false` | `actually-implemented` |
|
||||
| (4) sample profile seed | sample-portfolio seed | sample-portfolio 모듈 실존(test-scope), 런타임 seed/profile 토글은 sibling 위임 | delegated → `feature-sample-removal-adoption-contract` |
|
||||
| (5) smoke test | `./gradlew ...smoke` 또는 health probe | `smoke`/`Smoke` task·class **미존재**. health endpoint `GET /api/healthcheck` 는 존재 | `planned` |
|
||||
| 합성: `bootstrap` task | custom Gradle task 가 5단계 묶음 | **`bootstrap` task 미등록** (`app-bootstrap` 은 *모듈*명이지 task 아님). 현 first-run = `cd src && ./gradlew bootRun` | `planned` (§Audit `BOOTSTRAP_TASK_ABSENT`) |
|
||||
|
||||
### 2. tool-version 핀 — Temurin 21 LTS + Gradle wrapper
|
||||
|
||||
> **Trace**: D6 / `dx-mise-asdf#DX-TV-C4`,`#DX-TV-C5`. anchor = `src/build.gradle` `java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }` + `gradle/wrapper/gradle-wrapper.properties`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `.tool-versions`(asdf/mise) vs `.sdkmanrc`(SDKMAN) *단일 포맷 선택*. 외부 raw 는 포맷 spec 만 정의, 어느 것을 ca-tmpl default 로 둘지는 미권고. trade-off: `.tool-versions` 가 사실상 표준(DX-TV-C5)이고 mise/asdf 양쪽이 읽으나 mise 100% 호환은 `needs-confirmation`; `.sdkmanrc` 는 SDKMAN 단독. → 단일 source 로 `.tool-versions` 권장(둘 다 두면 drift, §외부근거 보강후보).
|
||||
|
||||
| 항목 | 구현 anchor (목표) | ca-tmpl 실제 상태 | grade |
|
||||
|---|---|---|---|
|
||||
| build JDK 핀 | Gradle toolchain 21 | `JavaLanguageVersion.of(21)` 실존 | `actually-implemented` |
|
||||
| Gradle 버전 핀 | repo wrapper 로 고정 | wrapper **9.0.0** (노트 "8.x" 와 drift) | `actually-implemented` (버전 숫자 정정 필요, §Audit `GRADLE_VERSION_DRIFT`) |
|
||||
| IDE/CLI JDK 핀 | `.tool-versions` 단일 포맷 | `.tool-versions`/`.sdkmanrc`/`.mise.toml` **미존재** | `planned` |
|
||||
| Temurin 21 LTS EOL 명시 | Adoptium support 페이지 인용 | `DX-TV-C8` `needs-confirmation` (별도 fetch 필요) | `planned` |
|
||||
|
||||
### 3. integration test backend — Testcontainers
|
||||
|
||||
> **Trace**: D10 / `dx-testcontainers#TC-CORE-C1`,`#TC-SPRING-C1`,`#TC-REUSE-C1`. anchor = ca-tmpl `src/app-bootstrap/.../contract/outbox/OutboxAppendTransactionalContractTest.java` 등.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: container 공유 전략(`@ServiceConnection` vs 수동 `@Container` singleton). 외부 raw 의 `@ServiceConnection`(`TC-SPRING-C1`)·singleton(`TC-SINGLETON-C1`) 인용이 `needs-confirmation` 이라 verbatim 미확정. trade-off: 실제 코드는 수동 `@Container PostgreSQLContainer` 채택(boilerplate 더 많으나 명시적). @ServiceConnection 전환은 Spring Boot reference 재fetch 후 별도.
|
||||
|
||||
| 항목 | 구현 anchor (목표) | ca-tmpl 실제 상태 | grade |
|
||||
|---|---|---|---|
|
||||
| integration backend | Testcontainers | `@Testcontainers`+`@Container PostgreSQLContainer` (Outbox/DistributedLock contract test) 실존 | `actually-implemented` (수동 방식) |
|
||||
| boilerplate 제거 | `@ServiceConnection` (Spring Boot 3.1+) | `@ServiceConnection` **미사용** | `planned` (§Audit `SERVICECONNECTION_NOT_USED`) |
|
||||
| reuse 정책 | 로컬 opt-in / CI off | `.testcontainers.properties`·`testcontainers.reuse.enable` **미존재** | `planned` |
|
||||
| bootstrap vs test 분리 | docker compose(bootstrap) ↔ Testcontainers(test) 별도 명시 | compose 파일 빈 상태 → bootstrap 측 미구현 | `planned` |
|
||||
|
||||
### 4. README entrypoint + link-rot gate
|
||||
|
||||
> **Trace**: D4(README 진입점) + D8(link-rot 도구) / D8 은 §외부근거 2026-06-15 addendum. anchor = ca-tmpl `README.md`(§로컬 실행/§테스트/§환경 변수 규칙) + (link-rot config 미존재).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: link-rot 도구 선택(lychee vs markdown-link-check vs linkinator). 2026-06-15 조사로 lychee 조건부 권고하나 raw archiving `deferred`(승인 대기). README↔command drift 검사 메커니즘(`verifyReadmeCommands` Gradle task)은 본 branch 임의 설계 — trade-off: 자동 강제 가능하나 ```bash 블록 파싱 규칙은 ca-tmpl 고유.
|
||||
|
||||
| 항목 | 구현 anchor (목표) | ca-tmpl 실제 상태 | grade |
|
||||
|---|---|---|---|
|
||||
| README local entrypoint | §로컬 실행 (첫 실행 명령) | README 실존, `cd src && ./gradlew bootRun` + `GET /api/healthcheck` | `actually-implemented` (단 `bootstrap`/smoke 미반영) |
|
||||
| README↔command drift 검사 | Gradle `verifyReadmeCommands` | **미존재** | `planned` |
|
||||
| link-rot gate | lychee(`lycheeverse/lychee-action@v2.0.2+`) CI 게이트 | config·CI·`package.json` **모두 미존재** | `planned` |
|
||||
|
||||
### 5. 위임 관심사 (OUT_OF_BRANCH_SCOPE → 다른 owner)
|
||||
|
||||
> 본 branch DX 진입점 밖이지만 governing DX 관심사인 것 — 결정 영역이 sibling owner 에 있으므로 §구현 가이드에 detail 을 남기지 않고 위임(R3). 위임 history 는 §Audit & Findings.
|
||||
|
||||
| 위임 관심사 | owner branch | 실제 메커니즘 (ca-tmpl) |
|
||||
|---|---|---|
|
||||
| `.env.example` / env key self-sufficiency | [[raw/branch-notes/feature-env-driven-runtime-configuration]] D7 | `src/.env`(git-tracked) + `verifyEnvKeys` 3-way + `env-keys.yaml` SSOT. `.env.example` **미사용** |
|
||||
| sample enable/disable 런타임 토글 | `feature-sample-removal-adoption-contract` | env `APP_SAMPLE_ENABLED`(registry-backed) + `prod_profile_must_be_false` + sample-off smoke. 런타임 토글 *코드 메커니즘*은 owner 확정 대상(미구현) — 본 branch 가 단정 안 함 |
|
||||
| sample production 격리 | [[raw/branch-notes/feature-sample-domain-contract-fixture]] D5 | ArchUnit `production_code_does_not_depend_on_sample_portfolio` + test-scope(`actually-implemented`) |
|
||||
| fresh-clone smoke CI job | `feature-ci-quality-gates-contract` | CI 미존재 — `fresh-clone-smoke` job `planned` |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4(깊이 게이트) 캡처용. 정상 경로 외 *구현 중 부딪힐* 실패/엣지/다른 계약 의존.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- bootstrap (2) `docker compose up -d` — Docker daemon 미기동 시 즉시 fail-fast + 안내(현 compose 파일 빈 상태이므로 step 2 자체 미정의). 기대: exit ≠ 0 + "Docker 필요" 메시지.
|
||||
- bootstrap (3) Flyway — out-of-order migration / checksum mismatch 시 fail. `baseline-on-migrate: false` 이므로 빈 DB 가정; 기존 스키마 존재 시 baseline 충돌.
|
||||
- **Apple Silicon (arm64) emulation** — 일부 Testcontainers image 가 amd64-only 면 emulation → bootstrap 시간 증가(D5 "macOS Apple Silicon 우선" 과 충돌 가능, TC raw §메모 경고만, 정량 근거 없음 → Claims To Verify).
|
||||
- link-rot false-positive — GitHub/LinkedIn 등 bot-blocker 429/999, Obsidian `[[wikilink]]` 는 표준 URL 아님 → 세 도구 모두 미검출. lychee `accept`/`.lycheeignore` 로 제어, wikilink 는 별도 처리 필요.
|
||||
- tool-version 불일치 — `.tool-versions` 핀과 CI runner/로컬 JDK 가 다르면 reproducible build 깨짐(D6; ci-quality-gates 와 공유).
|
||||
- smoke test green ≠ 정상 — 5단계 중 어디서 실패했는지 step 별 exit code 분리 필요(wiki/projects DevOps 문서 "과장 금지" 항목).
|
||||
- **다른 계약 의존**:
|
||||
- `[[raw/branch-notes/feature-env-driven-runtime-configuration]]` D7 — `.env`/env-keys SSOT(`verifyEnvKeys`). 이 계약이 `.env.example` 부재를 확정하므로 본 branch 의 env template 관심사는 그쪽 결과를 consume. 그 계약이 바뀌면 본 branch bootstrap step 0(env 준비) 영향.
|
||||
- `[[raw/branch-notes/feature-sample-removal-adoption-contract]]` — `APP_SAMPLE_ENABLED` 런타임 토글. bootstrap (4) sample seed 가 이 flag 를 consume.
|
||||
- `[[raw/branch-notes/feature-sample-domain-contract-fixture]]` D5 — sample-portfolio 격리(ArchUnit). bootstrap 이 sample 을 켜도 prod 경로 침범 없음의 근거.
|
||||
- `[[raw/branch-notes/feature-ci-quality-gates-contract]]` — `fresh-clone-smoke` job + Testcontainers reuse CI off 정책. 본 branch 의 테스트 계약(fresh-clone-smoke)이 그 CI gate 에서 실행됨.
|
||||
- `[[raw/branch-notes/feature-test-taxonomy-fixture-contract]]` — integration test taxonomy 가 Testcontainers 를 default backend 로 둠(D10 과 공유). @ServiceConnection 전환 결정의 공동 영역.
|
||||
- `[[raw/branch-notes/feature-container-runtime-contract]]` — container JVM/healthcheck/graceful-shutdown 기준. bootstrap 이 띄우는 런타임의 health probe(`/api/healthcheck`)는 그 계약과 정합.
|
||||
|
||||
## 테스트 계약
|
||||
|
||||
- .env.example 자급자족 검사: clean clone 직후 `cp .env.example .env && ./gradlew bootstrap`만으로 5단계 sub-task(compileTestJava → docker compose up -d → Flyway migrate → sample profile seed → smoke test)가 모두 통과해야 함. 측정 방법: CI에 `fresh-clone-smoke` job 추가 — clean container에서 위 명령 시퀀스 실행 후 exit code 0 + smoke test green. 추가 prompt/수동 입력이 필요하면 fail. **⚠️ 2026-06-15 정합**: sibling `feature-env-driven-runtime-configuration` B 결정으로 `.env.example` 대신 `src/.env`(git-tracked) 사용 → 본 검사의 `cp .env.example .env` 전제는 `src/.env` 기준으로 갱신 필요(§Audit `ENV_EXAMPLE_SUPERSEDED`).
|
||||
- sample profile이 prod profile에서 켜지면 실패.
|
||||
- README ↔ 실 command drift 검사: README.md의 code block에 등장하는 모든 `./gradlew`, `docker compose`, `make` command가 실제 build script에 존재해야 함. 측정 방법: `markdown-link-check` + 자체 Gradle task `verifyReadmeCommands`. README parsing: ```bash 블록에서 command 추출 → 각 command의 첫 token이 build script에 정의된 task이거나 system 표준 도구(`docker`, `git` 등)여야 함. 미정의 command 1건이라도 있으면 fail.
|
||||
- bootstrap command가 하나로 고정되지 않으면 실패.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서 / 사례는 근거지만 ca-tmpl 프로젝트에서의 동작을 자동 보장하지 않음. 구현 전/중/후 실제 검증 대상.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| `./gradlew bootstrap` 5단계가 clean clone 직후 추가 prompt 없이 모두 통과한다 | bootstrap task 자체가 ca-tmpl 고유 합성 — 외부 raw 가 5단계 합성을 보장하지 않음 (Testcontainers core claim 은 test backend 적합성만 증명) | CI `fresh-clone-smoke` job: clean container 에서 `cp .env.example .env && ./gradlew bootstrap` 실행 → exit code 0 + smoke test green | `planned` |
|
||||
| README ↔ build script command drift 가 0 건 | README 의 code block 과 실제 task 정의 일치는 자동 보장되지 않음 | Gradle task `verifyReadmeCommands` — README 의 ```bash 블록에서 command 추출 → 첫 token 이 build script task 또는 system 표준 도구인지 검사. 미정의 1건이라도 fail | `planned` |
|
||||
| `@ServiceConnection` 이 ca-tmpl 의 모든 dependency (PostgreSQL / Redis / Kafka 등) 에 대해 boilerplate 제거를 보장 | `TC-SPRING-C1` 이 `needs-confirmation` — 지원 module 범위 미확정 | Spring Boot reference docs 재fetch 로 supported module list 확보 → ca-tmpl dependency 목록과 교차 | `needs-confirmation` |
|
||||
| Testcontainers reuse 가 로컬에서 의도된 startup 단축 효과를 내고 CI 에서는 비활성화된다 | `TC-REUSE-C1` property 명과 "must not be enabled in CI" 표현이 `needs-confirmation` | Testcontainers reuse docs (https://java.testcontainers.org/features/reuse/) 재fetch + 로컬 측정 (cold start vs reused) + CI yaml 에서 reuse 플래그 부재 확인 | `needs-confirmation` |
|
||||
| Temurin 21 LTS 의 EOL 일자가 ca-tmpl 채택 주기 (≥ 36 개월) 와 호환 | `DX-TV-C8` 이 `needs-confirmation` — Adoptium support 페이지 인용 미확보 | https://adoptium.net/support/ 재fetch 로 정확한 EOL 일자 확정 후 branch note 갱신 | `needs-confirmation` |
|
||||
| mise 와 asdf 가 동일한 `.tool-versions` 파일을 100% 호환 해석 | `DX-TV-C5` "Does not prove" 컬럼에서 명시적으로 보장 안 됨 | mise 공식 페이지 (`.tool-versions` 호환성 섹션) 재fetch + 두 도구로 동일 파일 read/install 시연 | `needs-confirmation` |
|
||||
| `.sdkmanrc` 와 `.tool-versions` 가 동시 존재할 때 drift 가 발생하지 않는다 (또는 단일 source 정책 채택) | `DX-TV-C7` 이 `needs-confirmation` — SDKMAN `.sdkmanrc` 포맷 verbatim 미확보 | SDKMAN docs (https://sdkman.io/usage#env) fetch → ca-tmpl 정책을 "또는" 에서 단일 source 로 좁힐지 결정 | `planned` |
|
||||
| Apple Silicon (arm64) 에서 Testcontainers image 의 emulation 비용이 bootstrap 시간 (목표 1단계 분 이내) 을 초과하지 않는다 | Testcontainers raw 메모에서 경고만 됨 — 정량 근거 없음 | M1/M2 환경에서 bootstrap 측정 + arm64 native image 가용 여부 module 별 점검 | `planned` |
|
||||
| devcontainer 채택 시 ca-tmpl `./gradlew bootstrap` 5단계가 devcontainer 안에서 동등 동작 | `DX-DC-C5` 가 tool/runtime stack 만 보장 — Flyway 순서 / smoke test 자동 보장 안 함 | `.devcontainer/devcontainer.json` 작성 → Codespaces + 로컬 VS Code 양쪽에서 bootstrap 실행 → exit code 비교 | `planned` |
|
||||
| markdown-link-check 가 README + docs/ 의 모든 wikilink + URL 을 false-positive 없이 검출 | 도구 선택 자체에 외부 spec 미수집 (D8 — 2026-06-15 lychee 권고로 재검토) | npm 패키지 reference 확인 + CI 에서 dry-run → false-positive 목록 수집 후 ignore pattern 확정 | `planned` |
|
||||
| lychee 가 ca-tmpl 의 README + docs/ relative file link + external URL 을 false-positive 없이 검출하고 CI 에서 broken link 시 exit ≠ 0 | 2026-06-15 조사로 권고됐으나 ca-tmpl 실 파일 dry-run 미실시 + lychee-action CVE pin 필요 | `lychee --root-dir . './docs/**/*.md' './README.md'` dry-run → `.lycheeignore` 수렴 → `.github/workflows/link-check.yml`(`lycheeverse/lychee-action@v2.0.2+`, `fail: true`) 에 broken link 인위 삽입 → exit ≠ 0 확인 | `planned` |
|
||||
|
||||
## 관심사 커버리지
|
||||
|
||||
> `/coverage` 가 채우는 **생성물** — 손유지 금지. governing 문서(`wiki/projects/ca-tmpl/devops-ci-supply-chain-dx` §DX + parent §18 Developer Experience)가 요구하는 DX 관심사를 본 branch 가 빠짐없이 덮는지. 기준: `rules/coverage-gate.md`. 아래는 `/branch-spec` 가 staged 한 seed — `coverage-auditor` 가 코드/선례 대조로 확정.
|
||||
|
||||
| 관심사 (governing) | 상태 | owner | 심각도 | 근거 |
|
||||
|--------|------|-------|--------|------|
|
||||
| local bootstrap command (단일 진입점) | covered-here | — | — | D3 (`planned` — `bootstrap` task 미존재) |
|
||||
| `.env.example` / env template self-sufficiency | delegated | [[raw/branch-notes/feature-env-driven-runtime-configuration]] | OK | D7(sibling) — `src/.env`+`verifyEnvKeys`, §Audit `ENV_EXAMPLE_SUPERSEDED` |
|
||||
| Testcontainers 또는 local dependency 대체 | covered-here | — | — | D10 (manual `@Container` 실존, `@ServiceConnection` planned) |
|
||||
| smoke test command | covered-here | — | — | D3 step5 + 테스트 계약 (`planned`) |
|
||||
| sample profile 실행/비활성화 | delegated | [[raw/branch-notes/feature-sample-removal-adoption-contract]] | OK | D2/D7 위임, §Audit `SAMPLE_ENABLE_MECHANISM_DRIFT` |
|
||||
| README/runbook link 기준 | covered-here | — | — | D4 + D8 link-rot (`planned`) |
|
||||
| tool version pinning (Temurin 21 LTS) | covered-here | — | — | D6 (toolchain 21 실존, `.tool-versions` planned) |
|
||||
| link-rot / dead-link check | covered-here | — | — | D8 lychee 권고 (raw archiving deferred) |
|
||||
| fresh-clone smoke CI job | delegated | [[raw/branch-notes/feature-ci-quality-gates-contract]] | OK | §엣지·실패·의존 다른 계약 의존 |
|
||||
|
||||
## Audit & Findings
|
||||
|
||||
> 2026-06-15 `/branch-spec` ca-tmpl `src/` 코드 대조 결과. 사용자 작성 결정 영역이므로 자동 rewrite 하지 않고 **정합 권고만** 기록(추측 단정 금지).
|
||||
|
||||
| Finding ID | 유형 | 내용 | 권고 |
|
||||
|---|---|---|---|
|
||||
| `BOOTSTRAP_TASK_ABSENT` | planned (drift 아님) | `./gradlew bootstrap` task 미등록(`app-bootstrap` 은 모듈명). 현 first-run = `cd src && ./gradlew bootRun`. docker-compose 3종 0-byte. | D3 를 `planned` 로 명시(완료). Phase C2 구현 시 custom task + step exit code 분리. |
|
||||
| `GRADLE_VERSION_DRIFT` | drift | 노트 D6 "gradle-wrapper 8.x" vs 실제 `gradle-wrapper.properties` **9.0.0**. | 결정 사항 2026-06-15 정합 라인 + D6 Open Risk 반영(완료). 버전 숫자만 9.0.0 으로 정정, 핀 전략 유효. |
|
||||
| `ENV_EXAMPLE_SUPERSEDED` | drift (superseded by sibling) | 노트의 `.env.example` 결정(Decisionized Work Items + 테스트 계약)이 [[raw/branch-notes/feature-env-driven-runtime-configuration]] D7(2026-06-08 B: `.env.example` 미사용, `src/.env`+`verifyEnvKeys`+`env-keys.yaml` SSOT)과 충돌. | env template self-sufficiency 관심사를 그 sibling 에 **위임**(§Coverage). 테스트 계약의 `cp .env.example .env` 를 `src/.env` 기준으로 갱신 권고(완료). |
|
||||
| `SAMPLE_ENABLE_MECHANISM_DRIFT` | drift | 노트 D2/D7 이 Spring `@Profile` enablement 가정. 실제 = ArchUnit `production_code_does_not_depend_on_sample_portfolio` + test-scope(격리, [[raw/branch-notes/feature-sample-domain-contract-fixture]] D5) + env `APP_SAMPLE_ENABLED`(런타임 토글, `feature-sample-removal-adoption-contract` owner; registry-backed, 토글 코드 메커니즘 미구현). | enable/disable·격리 모두 sibling 위임으로 표기(완료). `@Profile` 표현은 sibling 결정으로 대체. 토글 코드 메커니즘은 owner 가 확정(본 branch 단정 안 함). |
|
||||
| `SERVICECONNECTION_NOT_USED` | planned (drift) | 노트 D10 "@ServiceConnection default" vs 실제 수동 `@Container PostgreSQLContainer`(Outbox/DistributedLock contract test). | D10 reality grade `planned`(완료). @ServiceConnection 전환은 `TC-SPRING-C1` 재fetch 후 별도(Claims To Verify). |
|
||||
|
||||
### 2026-06-24 구현 판정
|
||||
|
||||
| Finding ID | 결과 | 증거 등급 | 남은 경계 |
|
||||
|---|---|---|---|
|
||||
| `BOOTSTRAP_TASK_ABSENT` | `bootstrapCompile` → `bootstrapDependencies` → `bootstrapMigrateAndStart` → `bootstrapSampleContract` → `bootstrapSmoke` 구현 | `locally-verified` | macOS/WSL2 clean clone 미검증 |
|
||||
| `GRADLE_VERSION_DRIFT` | wrapper 9.0.0 유지, `.tool-versions` Temurin 21.0.11+10 소비 | `actually-implemented` | tool manager별 해석은 미검증 |
|
||||
| `ENV_EXAMPLE_SUPERSEDED` | `src/.env`를 Compose `env_file`로 소비, 새 `.env.example` 미생성 | `locally-verified` | sibling owner 유지 |
|
||||
| `SERVICECONNECTION_NOT_USED` | Spring context/slice 2개는 `@ServiceConnection`; direct JDBC/SQLState tests는 명시적 container factory 유지 | `locally-verified` | 공식 지원 범위 source refetch 미완료 |
|
||||
| `LINK_ROT_GATE_ABSENT` | `lycheeverse/lychee-action@v2.0.2`, `fail: true` workflow 추가 | `actually-implemented` | remote workflow 실행은 `needs-confirmation` |
|
||||
|
||||
## 완료 후 wiki 추출 대상
|
||||
|
||||
- `wiki/projects/ca-skeleton-operational-contract.md`의 developer experience canonical section.
|
||||
- `wiki/projects/ca-tmpl/devops-ci-supply-chain-dx.md` 의 DX 슬라이스 (governing doc).
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- [[raw/errors/bootstrap-postgres-port-collision-2026-06-24]] — 불필요한 DB host port publish가 기존 5432 container와 충돌; internal-only network로 해결.
|
||||
- [[raw/errors/slim-jre-random-generator-missing-2026-06-24]] — full JDK test에서 보이지 않던 slim JRE RNG provider 차이; `java.base` RNG bean과 container smoke로 해결.
|
||||
- 공식 Spring/Testcontainers 문서 web fetch는 403으로 차단됐다. D10의 최신 공식 지원 범위는 `needs-confirmation`을 유지한다.
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
- [[raw/branch-notes/chore-harness-policy-engine-alignment]]
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
### Sub-branches
|
||||
|
||||
- [[raw/branch-notes/chore-harness-policy-engine-alignment]] — module registry, strict evidence, platform renderer, risk-profile 기반 개발 하네스 정합.
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/dx-devcontainer-spring-boot]]
|
||||
- [[raw/official-docs/dx-mise-asdf-tool-versioning]]
|
||||
- [[raw/official-docs/dx-testcontainers-java-best-practices]]
|
||||
- [[raw/official-docs/supply-chain-gradle-vs-maven-dependency-locking]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: interviews:start -->
|
||||
- [[raw/interviews/single-command-local-bootstrap]]
|
||||
<!-- GENERATED: interviews:end -->
|
||||
|
||||
<!-- GENERATED: errors:start -->
|
||||
- [[raw/errors/bootstrap-postgres-port-collision-2026-06-24]]
|
||||
- [[raw/errors/jpa-repository-scan-miss-multimodule-2026-06-10]]
|
||||
- [[raw/errors/slim-jre-random-generator-missing-2026-06-24]]
|
||||
<!-- GENERATED: errors:end -->
|
||||
|
||||
<!-- GENERATED: daily-notes:start -->
|
||||
- [[raw/daily-notes/2026-06-30]]
|
||||
<!-- GENERATED: daily-notes:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/five-stage-local-bootstrap-contract-2026-06-24]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
> 본 feature branch 는 개발 하네스 정합 child를 소유한다. 추가 child/derived 자료는 이 섹션에서 그룹화한다.
|
||||
|
||||
### 오류 기록 (본 feature 작업 중 발생)
|
||||
|
||||
- [[raw/errors/bootstrap-postgres-port-collision-2026-06-24]] — host 5432 충돌과 internal-only DB network 결정.
|
||||
- [[raw/errors/slim-jre-random-generator-missing-2026-06-24]] — slim JRE provider parity 오류와 `java.base` RNG 수정.
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- [[raw/interviews/single-command-local-bootstrap]] — 단일 bootstrap의 단계 분리·실패 계약·문서 drift 질문.
|
||||
|
||||
### 블로그·채용공고 연계 글감
|
||||
|
||||
- [[raw/blog-topics/five-stage-local-bootstrap-contract-2026-06-24]] — 5단계 bootstrap과 실제로 잡힌 runtime gap 글감.
|
||||
- Job posting: 없음 — 채용공고에서 파생된 작업이 아님.
|
||||
- derived blog: 생성 전. canonical 추출 요청이 없어 직접 생성하지 않음.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
> 이 브랜치를 작업한 날짜들. 양방향 nav 유지.
|
||||
|
||||
- [[raw/daily-notes/2026-06-30]]
|
||||
- (생성 2026-05-22 / branch-spec 2026-06-15 — 해당 daily-note 미연결. 작업 재개 시 `[[raw/daily-notes/YYYY-MM-DD]]` 추가)
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-distributed-lock-contract.md
|
||||
@@ -0,0 +1,445 @@
|
||||
---
|
||||
title: branch / feature-distributed-lock-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-distributed-lock-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||||
tags: [branch, ca-skeleton, distributed-lock, advisory-lock, lock-registry]
|
||||
created: 2026-06-12
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-052
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-052
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-SCHEDULER-LOCK-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-TRANSACTION-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-DATABASE-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-004, WI-CA-SKELETON-OPERATIONAL-CONTRACT-025, WI-CA-SKELETON-OPERATIONAL-CONTRACT-024, WI-CA-SKELETON-OPERATIONAL-CONTRACT-017, WI-CA-SKELETON-OPERATIONAL-CONTRACT-018]
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: 174d92e4290cde20c264638b526e3447c27c19eca0eab92d8023a03a66627754
|
||||
---
|
||||
|
||||
# branch: feature-distributed-lock-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note §29.E 신규 branch 권고 #9 (`feature-distributed-lock-contract` — "Redisson / DB advisory lock + 트랜잭션 commit 정합") 영역의 결정/근거/금지 사항을 정제한다.
|
||||
|
||||
형제 branch (같은 부모의 다른 자식 — lock 인접 영역):
|
||||
|
||||
- [[raw/branch-notes/feature-background-job-async-contract]] — scheduler/outbox 의 lock *적용처* owner (D3). 본 branch 의 `distributedLockProvider` bean 을 consume.
|
||||
- [[raw/branch-notes/feature-cache-consistency-contract]] — cache stampede lock (Redisson RLock) owner (D3/D4)
|
||||
- [[raw/branch-notes/feature-env-driven-runtime-configuration]] — `APP_MULTI_INSTANCE_ENABLED` flag + `StartupSafetyValidator` presence 강제 owner (D8)
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: lock provider·lease·transaction commit ordering과 failure test가 명시된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-SCHEDULER-LOCK-001@1` | single-instance가 default이며 multi-instance scheduler/outbox는 DB advisory lock을 사용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-TRANSACTION-001@1` | application은 Spring transaction annotation 대신 TransactionPort 또는 TransactionalUseCaseRunner를 사용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-DATABASE-001@1` | database는 PostgreSQL 16 단일 stack이다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
multi-instance 배포(`APP_MULTI_INSTANCE_ENABLED=true`) 시 ca-tmpl `StartupSafetyValidator` 가 presence 를 강제하는 5개 instance-coordination bean 중 **`distributedLockProvider` 만 제공 결정의 owner branch 가 없었다** — [[raw/branch-notes/feature-background-job-async-contract]] §Audit **A7 `LOCK_BEAN_OWNER_UNRESOLVED`** (2026-06-11 coverage-auditor): ca-tmpl 코드 주석은 runtime-health 를 가리키나 그 노트는 "consume only" 자기 서술, 어느 branch 도 *bean 을 누가 어떤 메커니즘으로 제공하는지* 결정하지 않음.
|
||||
|
||||
본 branch 가 그 owner 가 되어 다음을 결정한다: **general-purpose 분산 락 제공 계약** — 메커니즘 선택(DB 기반 vs Redis 기반), port 추상화, **트랜잭션 commit 정합**(lock 해제 vs DB commit 순서), lease/timeout 계약, 실패 매핑, 정적 강제 요구.
|
||||
|
||||
- 이슈: parent project §29.E row #9 / background-job §Audit A7
|
||||
- PR: (없음 — 계약 단계)
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- `distributedLockProvider` bean 계약의 SSOT ownership (A7 해소) — bean 이름은 ca-tmpl `StartupSafetyValidator.REQUIRED_MULTI_INSTANCE_BEANS` 기존 값 재사용
|
||||
- general-purpose 분산 락 **메커니즘 선택** (PG advisory lock / ShedLock / Spring Integration LockRegistry / Redisson 비교)
|
||||
- **port 추상화** — domain/application 층에서 lock client 직접 사용 금지
|
||||
- **트랜잭션 commit 정합** — lock 해제와 DB commit 의 순서 불변식
|
||||
- **lease / timeout 획득 계약** — 무한 blocking 금지, 잔존 lock 자동 만료
|
||||
- lock 획득 실패의 error code / metric **신규 제안** (registry-governance 절차 경유)
|
||||
- 정적 강제(ArchUnit) **요구사항** 등록 — rule 호스팅은 `feature-architecture-enforcement-rules` 에 위임
|
||||
- multi-instance contract test 계약 (bean presence + 정합)
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- scheduler/outbox 의 lock **적용 정책** — [[raw/branch-notes/feature-background-job-async-contract]] D3 소유 (본 branch 는 provider 만 공급)
|
||||
- cache stampede 방지 lock — [[raw/branch-notes/feature-cache-consistency-contract]] D3/D4 소유 (Redisson RLock + `CACHE_STAMPEDE_LOCK_TIMEOUT`)
|
||||
- `APP_MULTI_INSTANCE_ENABLED` flag 정의와 `StartupSafetyValidator` 집행 — [[raw/branch-notes/feature-env-driven-runtime-configuration]] D8 소유
|
||||
- distributed rate limiter (`distributedRateLimiter` bean) — `feature-rate-limit-idempotency-contract` 영역
|
||||
- migration runner lock (`migrationStartupRunner` bean) — `feature-migration-startup-contract` 영역
|
||||
- **fencing token 도입** — 미도입 결정 (D6). correctness 는 DB 제약으로 보장
|
||||
- tenant 별 lock namespace — `feature-tenant-context-policy` 활성화 전까지 미정의
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 이 branch의 구현·설계 결정의 **근거가 되는 외부 자료**. 공식 문서·대기업 기술 블로그·강의 등 raw 자료를 인용. 같은 자료가 여러 결정의 근거면 결정 표시와 함께 여러 번 등장 가능.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/lock-postgres-advisory-locks]] | ca-tmpl `distributedLockProvider` 의 default 메커니즘으로 PostgreSQL advisory lock 검토 — session-level vs transaction-level 해제 시맨틱(PG-ADV-C2, PG-ADV-C3)이 "lock 해제 vs DB commit 순서 정합" 결정(D4)의 1차 근거 + session-level 배제(D3)·non-blocking try 변형(D5) 근거 |
|
||||
| [[raw/official-docs/lock-spring-integration-lock-registry]] | ca-tmpl `distributedLockPort` 추상화의 reference 구현 후보로서 Spring Integration `LockRegistry`/`JdbcLockRegistry` 평가 — `java.util.concurrent.locks.Lock` 호환 추상화 + JDBC/Redis/Zookeeper/DynamoDB provider 교체 가능성이 "port 추상화 + provider 교체" 결정의 근거 (SI-LOCK-C1, SI-LOCK-C2, SI-LOCK-C3) + lease 갱신/만료 예외 계약(D5 — SI-LOCK-C4, SI-LOCK-C5) |
|
||||
| [[raw/official-docs/lock-shedlock-readme]] | ShedLock 평가(D3 배제) — 용도 정의 "scheduled tasks at most once"(SHEDLOCK-C1) + `lockAtMostFor`/`lockAtLeastFor` lease 시맨틱(D5 참조 원리 — SHEDLOCK-C3, SHEDLOCK-C4) + clock 동기화 가정(D6 한계 방증 — SHEDLOCK-C5) |
|
||||
| [[raw/official-docs/lock-shedlock-issue-899-non-scheduler-use]] | ShedLock 을 general-purpose lock 으로 쓰지 않는 결정(D3)의 직접 근거 — maintainer 가 generic lock 공식 선언 거부(SHEDLOCK-899-C1) + 획득 실패 시 *skip*(대기 없음) 시맨틱이라 blocking 계약과 불일치(SHEDLOCK-899-C2) |
|
||||
| [[raw/company-tech-blogs/lock-subskribe-advisory-lock-distributed-consensus]] | PostgreSQL advisory lock 의 production 사용 사례 — 추가 인프라 없이 DB 만으로 distributed mutual exclusion 을 달성한 사례(SUBSKRIBE-LOCK-C1) + "optimistic variant(try-lock) 만 사용, pessimistic blocking 은 비권장" 운영 교훈(SUBSKRIBE-LOCK-C2)이 `distributedLockProvider` 메커니즘 비교의 사례 근거 (공식 best practice 아님 — 사례/관점으로만 취급) |
|
||||
| [[raw/official-docs/cache-redisson-rlock-vs-setnx]] | Redis 기반 대안(D3 의 Redis-활성 분기) — Redisson RLock 의 j.u.c.Lock 호환 + watchdog(LOCK-C3, `needs-confirmation`), TTL 의 deadlock 회피 역할(D5 — LOCK-C2), efficiency vs correctness lock 분리(D6 — LOCK-C4). cache branch 와 공유 raw |
|
||||
|
||||
근거 자료가 raw에 아직 없다면 먼저 `raw-source-template` 또는 `lecture-note-template` 으로 raw에 등록한 뒤 여기서 링크.
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [x] `OperationalError.LOCK_ACQUISITION_TIMEOUT` enum 상수 추가 (shared-contract) — 등급: `actually-implemented` / `locally-verified` (D7, 2026-06-13 이전 세션)
|
||||
- [x] lock port 인터페이스 3종 정의 (application-core): `DistributedLockPort`, `DistributedLock`, `LockAcquisitionTimeoutException` — 등급: `actually-implemented` / `locally-verified` (D2/D4/D5/D6, 2026-06-13)
|
||||
- [x] `LockAcquisitionTimeoutExceptionTest` + `DistributedLockPortContractTest` (application-core) — 등급: `locally-verified` (10/10 pass, 2026-06-13)
|
||||
- [x] `LockSettings` `@ConfigurationProperties("ca-skeleton.lock")` record (adapter-persistence) — 등급: `actually-implemented` / `locally-verified` (D3/D5, 2026-06-13)
|
||||
- [x] `LockRegistryDistributedLockAdapter implements DistributedLockPort` (adapter-persistence) — 등급: `actually-implemented` / `locally-verified` (D3/D4/D5, 2026-06-13)
|
||||
- [x] `DistributedLockPersistenceConfig` Spring wiring (adapter-persistence) — in-process (`@Primary`, matchIfMissing) + JDBC conditional beans — 등급: `actually-implemented` / `locally-verified` (D3, 2026-06-13)
|
||||
- [x] `V4__int_lock.sql` Flyway migration (adapter-persistence/db/migration) — SI 6.5 verbatim PostgreSQL DDL — 등급: `actually-implemented` (D3/D4, 2026-06-13; Testcontainers run-verify is app-bootstrap scope)
|
||||
- [x] `LockRegistryDistributedLockAdapterTest` 5종 단위 테스트 (DefaultLockRegistry, no Spring context) — 등급: `locally-verified` (5/5 PASS, 2026-06-13)
|
||||
- [x] `lock.acquisition` metric decorator `MeteredDistributedLockPort` (app-bootstrap) — 등급: `actually-implemented` / `locally-verified` (D7, 2026-06-13)
|
||||
- [x] `DistributedLockConfig` @ConditionalOnProperty bean wiring (app-bootstrap) — distributedLockProvider `@Primary`, multi-instance=true 시만 활성 — 등급: `actually-implemented` / `locally-verified` (D1/D3, 2026-06-13)
|
||||
- [x] ca-tmpl `StartupSafetyValidator` 의 `distributedLockProvider` 주석 owner 표기 갱신 (runtime-health → 본 branch) — 등급: `actually-implemented` / `locally-verified` (§Audit A1, 2026-06-13)
|
||||
- [x] `ca-skeleton.lock: wait-time: 3s / lease-ttl: 30s` application.yml 기본값 배선 (app-bootstrap) — 등급: `actually-implemented` (D5, 2026-06-13)
|
||||
- [x] `MeteredDistributedLockPortTest` 6종 단위 테스트 (app-bootstrap) — acquired/timeout/error + no-registry no-op — 등급: `locally-verified` (6/6 PASS, 2026-06-13)
|
||||
- [x] `LockAcquisitionTimeoutClassificationContractTest` 5종 계약 테스트 (app-bootstrap) — enum SSOT + skip-not-pass registry/metrics — 등급: `locally-verified` (5/5 PASS, 2026-06-13)
|
||||
- [x] `DistributedLockProviderContractTest` 4종 계약 테스트 (app-bootstrap) — D1 bean presence/absence + D3 mutual exclusion + D5 lease expiry (Testcontainers PG) — 등급: `locally-verified` (4/4 PASS, 2026-06-13)
|
||||
- [x] **Quality-review remediation (2026-06-13)**: SI-LOCK-C5 lease-expiry 처리 + D5 테스트 poll 개선 — 등급: `actually-implemented` / `locally-verified`
|
||||
- `MeteredDistributedLockPort`: `LOCK_LEASE_EXPIRED="lock.lease.expired"` 상수 + `closeHandlingLeaseExpiry()` + `incrementLeaseExpired()` 추가. `tryAcquire` 는 wrapping lambda 반환.
|
||||
- `MeteredDistributedLockPortTest`: 기존 identity(isSameAs) 어설션 제거(wrapping lambda로 변경됨) + 신규 4종: `LOCK_LEASE_EXPIRED` 상수 pinning + CME 삼킴 + non-CME 전파 + no-registry CME 삼킴 → 10/10 PASS
|
||||
- `DistributedLockProviderContractTest`: D5 sleep-then-single 취약점 → bounded poll 수정 + SI-LOCK-C5 2종 신규(raw CME 증명 + metered 삼킴+카운터) + intentional discard `@SuppressWarnings("unused")` + 총 6/6 PASS
|
||||
- [ ] ArchUnit rule 요구사항을 [[raw/branch-notes/feature-architecture-enforcement-rules]] 에 등록 — 등급: `planned` (D8)
|
||||
- [ ] background-job §테스트 계약의 ShedLock `LockProvider` FQCN 전파 알림 — 등급: `planned` (§Audit A4)
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- 2026-06-12: /branch-spec 자동조사 — wiki-decision-researcher 1회(대안 5개 비교) + wiki-source-summarizer 5회(신규 raw 5건). 비교 매트릭스 축: 인프라 의존 / 트랜잭션 commit 정합 / lease·timeout / reentrancy / Spring 생태계 통합 / 운영 복잡도.
|
||||
- 대기업(국내) production 사례 공백 — Subskribe(미국 SaaS)·FireHydrant 영어권 사례만 확보. 토스/카카오/네이버 advisory-lock 사례는 검색 미발견 (추가 조사 후보).
|
||||
- 2026-06-13 **Layer 1 (shared-contract) 완료** (이전 세션): `OperationalError.LOCK_ACQUISITION_TIMEOUT(Category.CONFLICT, 409, true)` 추가. D7 §Decision Evidence Map row 상태 갱신 미완이었음 — 본 세션에서 TODO 행 `actually-implemented` 로 정정.
|
||||
- 2026-06-13 **Layer 2 (application-core) 완료** (ca-implementer): 3종 타입 신설 + 계약 테스트 10/10 통과.
|
||||
- `dev.caskeleton.application.lock.DistributedLockPort` — D2/D4/D5/D6 javadoc 포함 (canonical usage + forbidden inverse)
|
||||
- `dev.caskeleton.application.lock.DistributedLock extends AutoCloseable` — `close()` no checked exception
|
||||
- `dev.caskeleton.application.lock.LockAcquisitionTimeoutException` (final, RuntimeException) — `key()`, `waitTime()`, `errorCode()→LOCK_ACQUISITION_TIMEOUT`
|
||||
- TDD: `compileTestJava` 실패(29 error) 확인 후 구현 → `./gradlew :application-core:test` 10/10 PASS
|
||||
- 테스트 수정 1건: `message_contains_waitTime` — `Duration.ofMillis(500).toString()` = `"PT0.5S"` (ISO-8601), "500" 포함 아님. 어설션을 `contains(waitTime.toString())` 로 정정.
|
||||
- build.gradle 무수정 확인 (`:shared-contract` 이미 `implementation` 의존)
|
||||
- Spring/JPA import 0 — 순수 `java.time` + `shared.error` 만 사용
|
||||
- 2026-06-13 **Layer 3 (adapter-persistence) 완료** (ca-implementer): LockSettings + adapter + Config + V4 migration.
|
||||
- `dev.caskeleton.adapter.persistence.lock.LockSettings` — `@Validated @ConfigurationProperties("ca-skeleton.lock")` record. compact-ctor: null→default(waitTime=3s, leaseTtl=30s), non-positive → `IllegalArgumentException`, cross-field leaseTtl < waitTime → `IllegalArgumentException`.
|
||||
- `dev.caskeleton.adapter.persistence.lock.LockRegistryDistributedLockAdapter implements DistributedLockPort` — wraps any SI `LockRegistry`. `tryAcquire`: leaseTtl > configuredTtl guard → `IllegalArgumentException`; `l.tryLock(waitTime.toMillis(), MILLISECONDS)`; InterruptedException → restore interrupt + throw timeout; returns `l::unlock` lambda.
|
||||
- `dev.caskeleton.adapter.persistence.lock.DistributedLockPersistenceConfig` — `@Configuration(proxyBeanMethods=false)`. in-process `@Primary @ConditionalOnProperty(... matchIfMissing=true)`; JDBC 3 beans `@ConditionalOnProperty(havingValue="true")`. SI types confined to adapter-persistence (implementation dep — invisible to app-bootstrap/application at compile time). `jdbcDistributedLock` intentionally NOT `@Primary` — app-bootstrap wraps in metrics decorator (cross-module contract).
|
||||
- `V4__int_lock.sql` — SI 6.5 verbatim PostgreSQL DDL with header comment (D3/D4 + TTL note). V1/V3 present, V2 absent; V4 is correct next.
|
||||
- `LockRegistryDistributedLockAdapterTest` — 5 unit tests over `DefaultLockRegistry` (no Spring context, no DB). TDD: red(`compileTestJava` 7 errors confirmed) → green(5/5 PASS). Key test: concurrent timeout via CountDownLatch (deterministic, no sleep).
|
||||
- SI 6.5 TTL finding: `DefaultLockRepository.setTimeToLive(int ms)` is repository-level; per-lock `lock(Duration)` API does not exist in 6.5 (SI 7.0+). configuredTtl guard in adapter prevents callers from overpromising per-call lease.
|
||||
- `verifyCleanArchitectureDependencies` not run (build.gradle not modified); `./gradlew :adapter-persistence:test` full suite PASS.
|
||||
- 2026-06-13 **Layer 4 (app-bootstrap) 완료** (ca-implementer): MeteredDistributedLockPort + DistributedLockConfig + StartupSafetyValidator 주석 + application.yml lock 기본값 + 3종 테스트.
|
||||
- `dev.caskeleton.bootstrap.lock.MeteredDistributedLockPort implements DistributedLockPort` — `ObjectProvider<MeterRegistry>` no-op 패턴(`BackgroundJobMetrics` 동일). 상수: `LOCK_ACQUISITION="lock.acquisition"`, `TAG_OUTCOME="outcome"`, `OUTCOME_ACQUIRED/TIMEOUT/ERROR`. catch `LockAcquisitionTimeoutException`→TIMEOUT, catch other `RuntimeException`→ERROR, success→ACQUIRED; `increment()` swallows meter errors.
|
||||
- `dev.caskeleton.bootstrap.lock.DistributedLockConfig` — `@Configuration(proxyBeanMethods=false)`. `@Bean("distributedLockProvider") @Primary @ConditionalOnProperty(prefix="ca-skeleton.runtime", name="multi-instance-enabled", havingValue="true")`. `@Qualifier("jdbcDistributedLock")` 주입 → `MeteredDistributedLockPort` 래핑.
|
||||
- `StartupSafetyValidator.java` 주석 수정 — `distributedLockProvider` 행 코멘트를 runtime-health → `feature-distributed-lock-contract (D1/D3 — JdbcLockRegistry distributed lock; in-process default when single-instance)` 로 갱신. §Audit A1 해소.
|
||||
- `application.yml` lock 블록 추가 — `ca-skeleton.lock: wait-time: 3s / lease-ttl: 30s`. 코드 기본값과 일치(APP_* env 키 미등록 — env-driven-runtime-configuration 소관). `ca-skeleton.runtime:` 블록 아래.
|
||||
- `app-bootstrap/build.gradle` — `testImplementation 'org.springframework.integration:spring-integration-jdbc'` 추가. 이유: SI 타입(`DefaultLockRepository`/`JdbcLockRegistry`)이 adapter-persistence `implementation` 의존이라 app-bootstrap 컴파일 classpath 에 미노출. `DistributedLockProviderContractTest` 가 두 개의 독립 registry 인스턴스(두 앱 인스턴스 시뮬레이션)를 직접 빌드하는 데 필요.
|
||||
- TDD: `MeteredDistributedLockPortTest` 6개 먼저 작성(compileTestJava 실패) → 구현 → 6/6 PASS. `LockAcquisitionTimeoutClassificationContractTest` 5개 → 5/5 PASS. `DistributedLockProviderContractTest` 4개 → 4/4 PASS.
|
||||
- **핵심 발견: `DefaultLockRepository` Spring 컨텍스트 외부 초기화** — `readCommittedTransactionTemplate` 은 `InitializingBean.afterPropertiesSet()` 이 아니라 `SmartInitializingSingleton.afterSingletonsInstantiated()` 에서 생성된다. Spring 컨텍스트 없이 쓸 때는 `setTransactionManager()` → `afterPropertiesSet()` → `afterSingletonsInstantiated()` → `start()` 순서를 명시 호출해야 한다. 누락 시 D3/D5 테스트에서 `CannotAcquireLockException(NullPointerException: readCommittedTransactionTemplate)` 발생.
|
||||
- 사전 기존 ArchUnit 실패: `outbound_adapter_method_returns_only_domain_or_primitives` — `OutboundHttpSettings.retry()/.circuitBreaker()` 가 adapter.outbound 내 nested record 반환. commits d702572/2613561/907dfad (이 task 이전) 에서 발생. 본 task 범위 외.
|
||||
- `verifyCleanArchitectureDependencies verifyEnvKeys` PASS (build.gradle 수정 → verifyCleanArchitectureDependencies 필수). `app-bootstrap` 전체 suite: 274 tests, 1 pre-existing failure.
|
||||
- 2026-06-13 **Quality-review remediation (ca-implementer)**: Finding 1 (Critical SI-LOCK-C5) + Finding 2 (Important — D5 flaky sleep + SI-LOCK-C5 coverage) + Minor #4 해소.
|
||||
- `MeteredDistributedLockPort` 변경: `java.util.ConcurrentModificationException` import (JDK — no SI import in main src). `tryAcquire` 가 `() -> closeHandlingLeaseExpiry(key, handle)` wrapping lambda 반환. `closeHandlingLeaseExpiry`: CME 만 catch → log.warn + `incrementLeaseExpired()`; 다른 예외 전파. `incrementLeaseExpired()`: 동일 null-guard + try-catch-log-and-swallow 패턴. `LOCK_LEASE_EXPIRED="lock.lease.expired"` 상수 신설.
|
||||
- `MeteredDistributedLockPortTest` 변경: 기존 2개 테스트의 `isSameAs(expectedHandle)` 어설션 → wrapping lambda 인식하도록 `isNotNull() + close() 정상` 검증으로 교체. 신규 4종: ① `lock_lease_expired_constant_matches_registry_name` (pinning), ② `close_swallows_CME_and_increments_lease_expired_counter`, ③ `close_propagates_non_CME_exception_unchanged`, ④ `close_swallows_CME_when_no_registry_is_present`. → 10/10 PASS.
|
||||
- `DistributedLockProviderContractTest` 변경: D5 test — `Thread.sleep(+500)` 후 단일 시도 → 수면 후 bounded poll(최대 shortTtl×4, 200ms 간격). intentional discard `@SuppressWarnings("unused")` 변수 명명 추가(Minor #4). 신규 2종: `si_lock_c5_raw_adapter_close_throws_CME_after_lease_expires` (raw CME 문서화) + `si_lock_c5_metered_port_swallows_CME_and_increments_lease_expired_counter` (metered 흡수+카운터). cross-package로 `LOCK_LEASE_EXPIRED` 상수 접근 불가 → 리터럴 `"lock.lease.expired"` 사용 (MeteredDistributedLockPortTest 의 pinning test 가 drift 방지 역할). → 6/6 PASS.
|
||||
- `app-bootstrap` 전체 suite: 280 tests, 1 pre-existing failure (`outbound_adapter_method_returns_only_domain_or_primitives`).
|
||||
- `LOCK_LEASE_EXPIRED` 상수 visibility: package-private (기존 상수 패턴 유지). cross-package 테스트는 리터럴 직접 사용 + same-package pinning test 로 drift 방지.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 추후 면접/회고에서 "왜 이렇게 했나" 답할 근거. 대안과 함께 기록. 각 결정의 근거는 위 Sources 또는 새로 추가된 raw 자료를 가리킬 것.
|
||||
|
||||
- 2026-06-12 (D1): 본 branch 가 `distributedLockProvider` bean 계약의 SSOT owner — background-job §Audit A7 의 owner 공백 해소 / 이유: 5개 coordination bean 중 유일하게 owner 부재, 코드 주석의 runtime-health 표기는 stale / 대안: runtime-health 가 소유(그 노트가 consume-only 자기 서술이라 기각) / 근거: ca-tmpl `StartupSafetyValidator.java` 코드 + [[raw/branch-notes/feature-background-job-async-contract]] §Audit A7
|
||||
- 2026-06-12 (D2): lock 접근은 application-core port 경유 — `obtain(key) → java.util.concurrent.locks.Lock` 시맨틱 / 이유: CA 레이어 규칙 + provider 교체 가능성 / 대안: 구현체 직접 사용(레이어 위반 기각) / 근거: [[raw/official-docs/lock-spring-integration-lock-registry]]
|
||||
- 2026-06-12 (D3): multi-instance 기본 provider = Spring Integration `JdbcLockRegistry`(PG baseline 재사용), Redis 활성 시 `RedisLockRegistry`/Redisson 교체 허용 / 검토 대안 5: PG session advisory(배제 — rollback 비해제·dangling), PG xact advisory(D4 의 보조 경로로 한정), JdbcLockRegistry(채택), ShedLock(배제 — maintainer 거부 + skip 시맨틱), Redisson(Redis-활성 분기) / 근거: [[raw/official-docs/lock-spring-integration-lock-registry]], [[raw/official-docs/lock-shedlock-issue-899-non-scheduler-use]], [[raw/official-docs/lock-postgres-advisory-locks]]
|
||||
- 2026-06-12 (D4): 트랜잭션 commit 정합 불변식 — lock 해제는 보호 대상 tx 의 commit *이후*에만. tx-scope 일치 use case 는 `pg_advisory_xact_lock` 허용(자동 해제), session-level advisory 는 도입 금지 / 근거: [[raw/official-docs/lock-postgres-advisory-locks]] (PG-ADV-C2/C3)
|
||||
- 2026-06-12 (D5): 획득 계약 = try-lock + 유한 waitTime + lease(TTL) 필수, 무한 blocking 금지 / 근거: PG-ADV-C4, LOCK-C2, SI-LOCK-C4/C5, SUBSKRIBE-LOCK-C2
|
||||
- 2026-06-12 (D6): 본 lock 은 efficiency lock 전용 — correctness 는 DB 제약(unique/optimistic lock)으로, fencing token 미도입 / 근거: LOCK-C4 (Kleppmann, `engineering-blog` — 재확인 보류 상태 명시)
|
||||
- 2026-06-12 (D7): lock 획득 실패 error code `LOCK_ACQUISITION_TIMEOUT` (category `CONFLICT`, retryable true) + metric `lock.acquisition` — **registry 에 없는 신규 제안** (기존 값 단정 아님, registry-governance 절차 경유)
|
||||
- 2026-06-12 (D8): domain-core·application-core 에서 lock 구현체 패키지 의존 금지 (정적 강제 요구) — rule 호스팅은 `feature-architecture-enforcement-rules` SSOT 에 위임
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시한다.
|
||||
> `Decision ID` 는 이 branch-note 안에서 안정적으로 유지한다. 예: `D1`, `D2`.
|
||||
> `Supporting Claims` 는 `raw/<category>/<slug>.md#C1` 형식으로 연결한다.
|
||||
|
||||
> `선택 조건` 열(R2): "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 분기 없으면 `N/A`.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | 본 branch = `distributedLockProvider` bean 계약 SSOT owner (A7 해소). bean 이름은 `StartupSafetyValidator.REQUIRED_MULTI_INSTANCE_BEANS` 기존 값 `"distributedLockProvider"` 재사용 | N/A — owner 공백 해소 (다른 branch 가 이미 소유했다면 본 branch 신설 불요였음) | ca-tmpl `src/app-bootstrap/.../StartupSafetyValidator.java` (code fact) + `raw/branch-notes/feature-background-job-async-contract.md` §Audit A7 | `internal-code-fact + sibling-audit` (외부 출처 비대상 — 내부 ownership 결정) | 코드 주석의 owner 표기가 runtime-health 로 stale (§Audit A1 — ca-tmpl 갱신 필요) |
|
||||
| D2 | lock 접근은 application-core port 경유, `obtain(key) → java.util.concurrent.locks.Lock` 시맨틱 (LockRegistry 모델 차용) | 구현체가 j.u.c.Lock 호환을 제공하는 한 이 결정. 호환 불가 provider 도입 시(예: skip-시맨틱) port 시그니처 재설계 | `raw/official-docs/lock-spring-integration-lock-registry.md#SI-LOCK-C1`, `raw/official-docs/cache-redisson-rlock-vs-setnx.md#LOCK-C3` (Redisson 도 j.u.c.Lock — 이식성 방증) | `official-vendor-doc` (SI) + `needs-confirmation` (LOCK-C3) | port 명명·메서드 모양은 `UNSUPPORTED_IMPL_DECISION` (§구현 가이드 1) |
|
||||
| D3 | multi-instance 기본 provider = `JdbcLockRegistry` (PG baseline 재사용, 추가 인프라 0). Redis 활성 시 `RedisLockRegistry`/Redisson 교체 허용. ShedLock·PG session-level advisory 배제 | `APP_MULTI_INSTANCE_ENABLED=true` + Redis 비활성 → JdbcLockRegistry; Redis 활성(cache 활성) → RedisLockRegistry/Redisson 교체 가능; flag=false(default) → bean 불요, in-process 구현으로 충분 | `raw/official-docs/lock-spring-integration-lock-registry.md#SI-LOCK-C2`, `#SI-LOCK-C3`, `raw/official-docs/lock-shedlock-issue-899-non-scheduler-use.md#SHEDLOCK-899-C1`, `#SHEDLOCK-899-C2` (ShedLock 배제), `raw/official-docs/lock-postgres-advisory-locks.md#PG-ADV-C2`, `#PG-ADV-C5` (session-level 배제), `raw/company-tech-blogs/lock-subskribe-advisory-lock-distributed-consensus.md#SUBSKRIBE-LOCK-C1` (DB-only 사례) | `official-vendor-doc + maintainer-statement + company-case-study` | `spring-integration-jdbc` 신규 의존성 + `INT_LOCK` DDL 관리 비용. SI 버전 ↔ Boot BOM 정합 미확인 (§Claims To Verify) |
|
||||
| D4 | 트랜잭션 commit 정합 불변식: lock 해제는 보호 대상 작업의 DB commit **이후**에만. lock 수명 = 단일 tx 인 use case 는 `pg_advisory_xact_lock` 허용(commit/rollback 자동 해제). session-level advisory 의 수동 unlock 경로는 도입 금지 | lock scope ⊆ 단일 tx → xact advisory lock (자동 정합); lock scope ⊃ tx (여러 tx/외부 호출 포함) → JdbcLockRegistry + "획득 → tx → commit 반환 후 unlock" 순서 강제 | `raw/official-docs/lock-postgres-advisory-locks.md#PG-ADV-C2` (session-level 은 tx 시맨틱 무시 — rollback 후에도 잔존), `#PG-ADV-C3` (xact-level 은 tx 종료 시 자동 해제), `raw/company-tech-blogs/lock-subskribe-advisory-lock-distributed-consensus.md#SUBSKRIBE-LOCK-C4` (사례 보강) | `official-vendor-doc + company-case-study` | Spring `@Transactional` proxy 와 xact lock 의 실제 정합은 `locally-verified` 필요 (§Claims To Verify) |
|
||||
| D5 | 획득 계약: try-lock + 유한 waitTime + lease(TTL) 필수. 무한 blocking 금지. lease 갱신은 보유 thread 만, lease 만료 후 unlock 은 예외 처리 의무 | N/A — 모든 획득 경로 공통. (lease 없는 lock 이 필요해지면 D6 correctness 경계 재검토가 선행) | `raw/official-docs/lock-postgres-advisory-locks.md#PG-ADV-C4` (try 변형 존재), `raw/official-docs/cache-redisson-rlock-vs-setnx.md#LOCK-C2` (TTL = crash 시 deadlock 회피), `raw/official-docs/lock-spring-integration-lock-registry.md#SI-LOCK-C4` (갱신은 보유 thread 만), `#SI-LOCK-C5` (만료 후 unlock → `ConcurrentModificationException`), `raw/official-docs/lock-shedlock-readme.md#SHEDLOCK-C3`, `#SHEDLOCK-C4` (lease 상·하한 원리 참조), `raw/company-tech-blogs/lock-subskribe-advisory-lock-distributed-consensus.md#SUBSKRIBE-LOCK-C2` (try-only 운영 사례) | `official-vendor-doc + official-reference + company-case-study` | 구체 default 값(waitTime/TTL)은 `UNSUPPORTED_IMPL_DECISION` (§구현 가이드 4) |
|
||||
| D6 | 본 lock 은 **efficiency lock 전용**. correctness 가 필요한 경로는 DB 제약(unique constraint = `DB_UNIQUE_VIOLATION`, optimistic lock = `PRECONDITION_FAILED` 기존 계약)으로 보장. fencing token 미도입 | 중복 *작업* 방지(비용 절감) 목적 → 본 lock; 중복 *결과* 차단(정합성) 필요 → DB 제약 사용. fencing token 이 필요한 외부 시스템 mutation 등장 시 본 결정 재검토 | `raw/official-docs/cache-redisson-rlock-vs-setnx.md#LOCK-C4` (Kleppmann: lease 기반 correctness 는 unsafe, efficiency 는 충분), `raw/official-docs/lock-shedlock-readme.md#SHEDLOCK-C5` (clock 동기화 *가정* — lease 기반의 전제 한계 방증) | `engineering-blog` (LOCK-C4 — verbatim 재확인 보류) + `official-reference` | LOCK-C4 의 verbatim 재확인 불가 상태 지속 (cache branch 와 공동 — archive.org 스냅샷 필요) |
|
||||
| D7 | lock 획득 실패/timeout 의 error code = `LOCK_ACQUISITION_TIMEOUT` (category `CONFLICT`, retryable true, client_safe true) + metric `lock.acquisition` (tag: outcome) — **registry 신규 제안** | N/A — 단 registry-governance 검토에서 기존 code 재사용 판정 시 그 code 채택 | `UNSUPPORTED_IMPL_DECISION` — registry(`error-codes.yaml`·`metrics.yaml`)에 일반 lock 항목 부재 확인(2026-06-12 grep). category `CONFLICT` 는 기존 enum(`shared/error/Category.java`) 재사용, code/metric *이름* 은 근거 없는 신규 제안 | `none` (신규 제안 — 기존 값 단정 금지) | registry-governance 절차 미통과 상태. cache 의 `CACHE_STAMPEDE_LOCK_TIMEOUT` 과 의미 경계 문서화 필요 |
|
||||
| D8 | domain-core·application-core 에서 lock 구현체 패키지(`org.springframework.integration..`, `org.redisson..`, `net.javacrumbs.shedlock..`) 의존 + advisory SQL 직접 호출 금지 — adapter 전용. rule 호스팅은 [[raw/branch-notes/feature-architecture-enforcement-rules]] SSOT 위임 (본 branch 는 요구사항만 등록) | N/A — D2 port 결정의 정적 강제 도출 | D2 의 도출 + ca-tmpl `CLAUDE.md` 의존 방향 매트릭스 (code fact). rule *명명* 은 `UNSUPPORTED_IMPL_DECISION` | `internal-code-fact` (모듈 매트릭스) | rule 이 architecture-enforcement-rules 에 실제 등록되기 전까지 `documented-only` |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> *결정 (Decisions)* 이 "*무엇* 을 할 것인가" 라면, 본 §는 "*어디에 어떻게* 구현될 것인가" 의 사전 명세. 3-rule meta principle (R1 Reference / R2 UNSUPPORTED_IMPL_DECISION / R3 OUT_OF_BRANCH_SCOPE) 준수 — CLAUDE.md §15.5.
|
||||
> 구현 상태: 본 § 전체가 **`planned`** — src grep 실측(2026-06-12) 결과 lock 관련 구현은 `StartupSafetyValidator` 의 bean-presence 검사뿐, port/adapter/registry 코드는 전무. `actually-implemented` 로 표현 금지.
|
||||
|
||||
### 1. Port · adapter · wiring 배치 (D1
|
||||
|
||||
> **Trace**: D1 (bean 이름 = code 기존 값) + D2 (port 추상화 — SI-LOCK-C1) + D8 (구현체 격리)
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: ① port 명명 `DistributedLockPort` + 메서드 `tryAcquire(key, waitTime, ttl)` 모양 — 근거 raw 는 *추상화 원칙*(obtain→Lock)만 권고, 명명은 임의 (trade-off: sibling port 명명 패턴 `*Port` 정합). ② 모듈 배치 — adapter 구현을 `adapter-persistence` 에 두는 것은 "JDBC 기반"이라는 도출이지 raw 권고 아님 (trade-off: lock 저장소 = DB 이므로 persistence 인접이 의존 방향 최소).
|
||||
|
||||
| 항목 | 명세 | 상태 |
|
||||
|---|---|---|
|
||||
| port 인터페이스 | `application-core` — `DistributedLockPort` (가칭): `tryAcquire(String key, Duration waitTime, Duration ttl)` → lock handle (j.u.c.Lock 호환) | `planned` |
|
||||
| adapter 구현 | `adapter-persistence` — `JdbcLockRegistry` wrapping (D3). Redis 분기 구현은 Redis 활성 모듈에 별도 | `planned` |
|
||||
| bean wiring | `app-bootstrap` — bean 이름 **`distributedLockProvider`** (code 기존 값 — `StartupSafetyValidator.REQUIRED_MULTI_INSTANCE_BEANS[0]`). `APP_MULTI_INSTANCE_ENABLED=true` 일 때만 등록 | `planned` |
|
||||
| single-instance 경로 | flag=false(default) 시 in-process 구현(SI `DefaultLockRegistry` 동등 시맨틱)으로 port 계약 유지 — bean presence 강제 대상 아님 (env D8 consume) | `planned` |
|
||||
|
||||
### 2. Provider 선택 분기 (D3)
|
||||
|
||||
> **Trace**: D3 — SI-LOCK-C2 (4종 공식 구현체), SI-LOCK-C3 (JdbcLockRegistry 분산 락), SHEDLOCK-899-C1/C2 (ShedLock 배제), PG-ADV-C2/C5 (session-level 배제), SUBSKRIBE-LOCK-C1 (DB-only 사례)
|
||||
|
||||
| 조건 | provider | 비고 |
|
||||
|---|---|---|
|
||||
| `APP_MULTI_INSTANCE_ENABLED=false` (default) | in-process (SI `DefaultLockRegistry` 동등) | 분산 조정 불요 — single-instance 계약 |
|
||||
| flag=true + Redis 비활성 | **`JdbcLockRegistry`** (채택 기본값) | PG baseline 재사용, 추가 인프라 0. `INT_LOCK` 테이블 필요 (DDL 은 migration-startup 계약 경유) |
|
||||
| flag=true + Redis 활성 | `RedisLockRegistry` 또는 Redisson RLock | port 불변, 구현체만 교체 (SI-LOCK-C2). Redisson 채택 시 cache branch 의존성 재사용 |
|
||||
| (배제) ShedLock | — | maintainer 가 generic lock 공식 선언 거부(SHEDLOCK-899-C1) + 획득 실패 시 *skip* 시맨틱으로 blocking 계약 불일치(SHEDLOCK-899-C2). scheduler 영역 사용은 background-job D3 소유로 불변 |
|
||||
| (배제) PG session-level advisory | — | tx rollback 에도 잔존(PG-ADV-C2) + dangling lock 위험(PG-ADV-C5) + pool 반납 시 leak 경로 |
|
||||
|
||||
### 3. 트랜잭션 commit 정합 패턴 카탈로그 (D4)
|
||||
|
||||
> **Trace**: D4 — PG-ADV-C2 (session = tx 무시), PG-ADV-C3 (xact = 자동 해제), SUBSKRIBE-LOCK-C4 (사례)
|
||||
|
||||
| 패턴 | 판정 | 이유 |
|
||||
|---|---|---|
|
||||
| lock 획득 → `@Transactional` 작업 → commit 반환 **후** finally unlock | ✅ 허용 (general 경로) | 해제가 commit 에 후행 — 임계 구역이 commit 전에 열리지 않음 |
|
||||
| `pg_advisory_xact_lock` 을 tx 내부에서 획득 | ✅ 허용 (tx-scope 경로) | commit/rollback 시 자동 해제 (PG-ADV-C3) — 정합을 DB 가 보장 |
|
||||
| tx **내부**에서 general lock 해제 (commit 전 unlock) | ❌ 금지 | 미commit 상태에서 다른 인스턴스가 임계 구역 진입 — lost update 류 race |
|
||||
| session-level advisory lock + 수동 unlock | ❌ 금지 | rollback 에도 잔존(PG-ADV-C2) + unlock 누락 시 pool 반납 leak. 본 계약에서 경로 자체 미도입 |
|
||||
|
||||
### 4. 획득·해제 계약 + 실패 매핑 (D5
|
||||
|
||||
> **Trace**: D5 — PG-ADV-C4, LOCK-C2, SI-LOCK-C4/C5, SHEDLOCK-C3/C4, SUBSKRIBE-LOCK-C2. D7 — registry 부재 확인(2026-06-12 grep).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: waitTime/TTL default 값 (예: waitTime 3s / TTL 30s) — 어떤 raw 도 구체 값을 권고하지 않음 (trade-off: Redisson watchdog default 30s 와 LOCK-C1 의 PX 30000 을 관행 참고치로만 사용, 측정 후 조정). error code `LOCK_ACQUISITION_TIMEOUT`·metric `lock.acquisition` *이름* — registry 신규 제안 (기존 값 아님을 명시). Jdbc 분기 long-task 의 `renewLock` 호출 *주기* — SI 7.0+ 의 `lock(Duration ttl)` API 존재는 raw 가 보장하나 갱신 주기 값은 임의 (trade-off: TTL 의 1/3 주기 관행 참고, 측정 후 조정).
|
||||
|
||||
| 항목 | 계약 | 상태 |
|
||||
|---|---|---|
|
||||
| 획득 | try-lock + 유한 waitTime 필수. 무한 blocking API 노출 금지 (PG-ADV-C4 의 try 변형 + SUBSKRIBE-LOCK-C2 운영 교훈) | `planned` |
|
||||
| lease | TTL 필수 — 보유자 crash 시 자동 만료 (LOCK-C2, SHEDLOCK-C3 원리) | `planned` |
|
||||
| 갱신 | 보유 thread 만 (SI-LOCK-C4). 자동 watchdog 은 Redisson 분기에서만 (LOCK-C3 — `needs-confirmation`) | `planned` |
|
||||
| Jdbc 분기 long-task 갱신 | **Jdbc 분기에는 자동 watchdog 이 없음** — lock 보유 시간이 TTL 을 넘을 수 있는 작업은 ① 명시적 `renewLock` 주기 호출(보유 thread, SI-LOCK-C4) 또는 ② TTL ≥ 최대 작업 시간 보장 중 하나를 선택. 주기 값은 `UNSUPPORTED_IMPL_DECISION` (위 헤더) | `planned` |
|
||||
| 만료 후 해제 | `ConcurrentModificationException` 처리 의무 (SI-LOCK-C5) — 삼킴 금지, 로그 + metric | `planned` |
|
||||
| 실패 매핑 | timeout → `LOCK_ACQUISITION_TIMEOUT` (**신규 제안** — category `CONFLICT` 기존 enum 재사용, retryable true). registry-governance 통과 전 코드 작성 금지 | `planned` (제안 단계) |
|
||||
| metric | `lock.acquisition` (tag: `outcome` = acquired/timeout/error) — **신규 제안**. 기존 `metrics.yaml` 에 lock 항목 없음 확인 | `planned` (제안 단계) |
|
||||
|
||||
### 5. Contract test 계약 (D1
|
||||
|
||||
> **Trace**: D1 (bean presence) + D3 (provider 분기). env D8 의 `StartupSafetyValidator` 집행을 consume — 검사 메커니즘 자체는 env branch 소유 (OUT_OF_BRANCH_SCOPE).
|
||||
|
||||
| 테스트 | 검증 내용 | 상태 |
|
||||
|---|---|---|
|
||||
| bean presence | `APP_MULTI_INSTANCE_ENABLED=true` 시 `distributedLockProvider` bean 부재 → startup fail (기존 `StartupSafetyValidatorTest` 는 이름 기반 presence 만 검증 — 본 branch 는 *실제 bean 등록* 쪽 테스트 추가) | `planned` |
|
||||
| 상호 배제 | 동일 key 에 2 인스턴스(2 DataSource 컨텍스트) 경쟁 → 1개만 획득 | `planned` |
|
||||
| commit 정합 | tx 미commit 상태에서 두 번째 획득 시도가 성공하지 않음 (D4 패턴 ✅① 검증) | `planned` |
|
||||
| lease 만료 | TTL 경과 후 두 번째 인스턴스 획득 가능 + 원 보유자 unlock 시 CME 처리 (SI-LOCK-C5) | `planned` |
|
||||
|
||||
## Audit & Findings
|
||||
|
||||
> 이관 history + drift 기록 (CLAUDE.md §15.5 R3). §구현 가이드에는 in-scope 만 남기고, 범위 밖/정정/전파는 여기 보존.
|
||||
|
||||
- **A1. `STALE_CODE_COMMENT` (drift)** — ca-tmpl `StartupSafetyValidator.java` 의 `"distributedLockProvider"` 행 주석이 `feature-runtime-health-lifecycle-contract` 를 owner 로 표기 — 그 노트는 "consume only" 자기 서술(background-job §Audit A7 발견). 본 branch 가 owner 로 확정되었으므로 **코드 주석을 본 branch 로 갱신 권고** (ca-tmpl 측 변경 — 자동 수정 안 함, 정합 권고만).
|
||||
- **A2. `RESEARCH_CORRECTION`** — 선행 조사(wiki-decision-researcher)가 "ShedLock = scheduler 전용 *공식 입장*"으로 요약했으나 README verbatim(SHEDLOCK-C2 "it's just a lock")은 그 표현을 지지하지 않음. issue #899 verbatim 으로 정정: 배제의 실근거 = *generic lock 공식 선언 거부*(SHEDLOCK-899-C1) + *skip(비대기) 시맨틱*(SHEDLOCK-899-C2). 커뮤니티의 non-scheduler production 사용 보고(SHEDLOCK-899-C4)도 존재 — "기술적 불가"가 아니라 "공식 비지원 + 시맨틱 불일치"가 배제 이유.
|
||||
- **A3. `OUT_OF_BRANCH_SCOPE` 이관 기록** — ① scheduler/outbox lock 적용 정책 → background-job D3 (불변). ② cache stampede lock + `CACHE_STAMPEDE_LOCK_TIMEOUT` → cache-consistency D3/D4 (불변). ③ `APP_MULTI_INSTANCE_ENABLED` + validator 집행 → env-driven D8 (consume). ④ `INT_LOCK` DDL 의 migration *절차* → migration-startup-contract (본 branch 는 DDL 필요 사실만 제안). ⑤ ArchUnit rule 호스팅 → architecture-enforcement-rules (D8 은 요구사항만).
|
||||
- **A4. `PROPAGATION_NOTICE` (비차단)** — background-job §테스트 계약·§구현 가이드 4 의 테스트 FQCN `net.javacrumbs.shedlock.core.LockProvider` 는 "ShedLock 또는 동등 bean" 가정 시절의 표기. 본 branch D3 가 `JdbcLockRegistry` 를 기본 채택했으므로 그 테스트 계약의 FQCN 은 port/bean 기준으로 갱신 필요. 동일하게 project-note §27 의 "ShedLock + Redisson + …" 5종 나열도 "distributedLockProvider(본 branch D3)" 로 읽도록 전파 대상. **비차단** — owner(background-job·env·project note) 가 다음 편집 시 반영.
|
||||
- **A5. `NEW_BRANCH_REGISTRATION`** — parent project §29.E row #9 가 본 branch 를 `(없음)` 예정으로 표기 + §25 SSOT Owner Map 에 distributed lock row 부재. 본 branch 신설로 §31.1 Cluster list + §25 Owner Map + §29 row 상태 갱신 필요 (project-note 사용 절차 #4 의무 — 본 세션에서 최소 반영 또는 다음 project-note 편집 시).
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4(깊이 게이트) 캡처용. 정상 경로 외에 *구현 중 부딪힐* 실패/엣지/다른 계약 의존을 미리 열거.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- 획득 timeout → `LOCK_ACQUISITION_TIMEOUT`(신규 제안) 반환, retryable true — 호출측 재시도 정책은 호출 branch 소유
|
||||
- lease 만료 *중* 작업 진행 — 두 보유자 동시 진입 가능. D6 efficiency 경계로 *허용*하되 correctness 필요 경로는 DB 제약이 최종 방어 (LOCK-C4)
|
||||
- lease 만료 후 unlock → `ConcurrentModificationException` (SI-LOCK-C5) — 삼킴 금지, 로그+metric 후 정상 흐름 복귀
|
||||
- JVM crash → lock row 는 TTL 로 자동 만료 (LOCK-C2/SHEDLOCK-C3 원리) — 잔존 lock 수동 정리 runbook 불요 설계
|
||||
- clock skew — lease 판정이 노드 시계에 의존하면 SHEDLOCK-C5 의 동기화 가정 필요 → DB 시간 기준 여부 확인 (§Claims To Verify)
|
||||
- 동일 thread 재진입 — `JdbcLockRegistry` 의 reentrancy 보장 미확인 (§Claims To Verify) — 보장 확인 전까지 재진입 금지 계약
|
||||
- connection pool 고갈 — lock 대기가 DB connection 을 점유하는 구현(advisory blocking)은 배제됨(D3/D5) — JdbcLockRegistry 의 lock 당 connection 사용 패턴은 확인 필요
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-env-driven-runtime-configuration]] D8 — `APP_MULTI_INSTANCE_ENABLED` flag + `StartupSafetyValidator` presence 강제를 consume. flag 의미/집행 변경 시 본 branch bean 등록 조건 영향
|
||||
- [[raw/branch-notes/feature-background-job-async-contract]] D3 — scheduler/outbox 가 본 branch 의 provider 를 consume (§Audit A4 전파)
|
||||
- [[raw/branch-notes/feature-cache-consistency-contract]] D3 — Redis 활성 분기에서 Redisson 의존성 공유. cache 가 Redisson 을 제거하면 본 branch Redis 분기 재검토
|
||||
- `feature-migration-startup-contract` — `INT_LOCK` DDL 의 Flyway 반영 절차
|
||||
- [[raw/branch-notes/feature-architecture-enforcement-rules]] — D8 rule 호스팅
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다.
|
||||
> 구현 전/중/후에 실제로 검증해야 하는 주장을 분리한다.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| `spring-integration-jdbc` 가 ca-tmpl Boot BOM 과 호환 + TTL API(`lock(Duration ttl)`, SI 7.0+) 사용 가능 | SI 버전·TTL API 도입 시점과 현재 BOM 미대조 | `build.gradle` 의존성 추가 후 컴파일 + `JdbcLock` TTL 메서드 존재 확인 | `needs-confirmation` |
|
||||
| `INT_LOCK` 테이블 DDL 은 자동 생성되지 않아 Flyway 수동 migration 필요 | 공식 문서에서 schema 자동 생성 여부 미확인 | SI 배포 schema 스크립트 위치 확인 + 로컬 기동 테스트 | `needs-confirmation` |
|
||||
| `pg_advisory_xact_lock` 이 Spring `@Transactional` commit 시점에 자동 해제 (D4 ✅② 경로) | proxy 기반 tx 경계와 PG 세션의 실제 상호작용 미검증 | 2-connection 경쟁 통합 테스트: tx A 보유 중 tx B 획득 실패 → A commit 후 B 획득 성공 | `needs-confirmation` |
|
||||
| `JdbcLockRegistry` 의 동일 thread 재진입 보장 여부 | SI-LOCK-C1 은 j.u.c.Lock 반환만 보장, reentrancy 는 "Does not prove" 명시 | 공식 Javadoc/소스 확인 + 재진입 단위 테스트 | `needs-confirmation` |
|
||||
| Redisson RLock watchdog 시맨틱 (LOCK-C3) | redisson.org → redisson.pro redirect 차단으로 verbatim 재확인 불가 (cache branch 공동 관심) | Redisson Javadoc 직접 다운로드 또는 GitHub wiki 로 verbatim 격상 | `needs-confirmation` |
|
||||
| `JdbcLockRegistry` 의 lock 대기가 DB connection 을 점유하는지 (polling 마다 반납 vs holding) | retry-polling(idleBetweenTries) 구조라 점유 패턴 미확인 — holding 이면 pool 고갈 시 self-deadlock 경로 | SI 소스/Javadoc 확인 + pool size 1 로 죄인 통합 테스트에서 동시 lock 대기 시 고갈 여부 관찰 | `needs-confirmation` |
|
||||
| flag=true + bean 등록 시 `StartupSafetyValidator` 가 실제 통과 (이름 기반 presence) | 현재 테스트는 *부재 → fail* 만 검증, *등록 → pass* 는 bean 타입 무관 이름만 매칭 | `StartupSafetyValidatorTest` 확장 + 실제 adapter bean 으로 기동 테스트 | `planned` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
> `/coverage` 가 채우는 **생성물** — 손으로 유지하지 않는다. governing 문서(frontmatter `governing_docs`)가 요구하는 관심사를 이 브랜치가 빠짐없이 덮는지의 결과. 기준: `rules/coverage-gate.md`.
|
||||
> 상태: `covered-here`(이 브랜치 결정) / `delegated`(다른 owner 브랜치) / `missing`(아무도 안 맡음 → Blocking).
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|--------|------|-------|--------|------|
|
||||
| (생성 전 — `/coverage feature-distributed-lock-contract` 실행 대기) | — | — | — | — |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
> 짧은 메모만. 깊이 있는 트러블슈팅은 `raw/errors/` 로 분리하고 아래 Cluster에 연결.
|
||||
|
||||
- 2026-06-13 (Layer 2): `LockAcquisitionTimeoutExceptionTest.message_contains_waitTime` 첫 실행 실패. 원인: `Duration.ofMillis(500).toString()` 은 `"PT0.5S"` (ISO-8601) — `"500"` 을 포함하지 않음. 어설션을 `contains(waitTime.toString())` 로 수정 후 통과. raw/errors 별도 분리 불필요 (trivial one-liner 수정).
|
||||
- 2026-06-13 (Layer 4): `DistributedLockProviderContractTest` D3/D5 테스트 — `CannotAcquireLockException(NullPointerException: readCommittedTransactionTemplate)`. `DefaultLockRepository` 를 Spring 컨텍스트 없이 사용할 때 `SmartInitializingSingleton.afterSingletonsInstantiated()` 를 명시 호출해야 함을 발견. 상세: [[raw/errors/spring-integration-defaultlockrepository-aftersingletons-null-template-2026-06-13]].
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/lock-subskribe-advisory-lock-distributed-consensus]]
|
||||
- [[raw/official-docs/lock-postgres-advisory-locks]]
|
||||
- [[raw/official-docs/lock-shedlock-issue-899-non-scheduler-use]]
|
||||
- [[raw/official-docs/lock-shedlock-readme]]
|
||||
- [[raw/official-docs/lock-spring-integration-lock-registry]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: errors:start -->
|
||||
- [[raw/errors/spring-integration-defaultlockrepository-aftersingletons-null-template-2026-06-13]]
|
||||
<!-- GENERATED: errors:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/distributed-lock-transaction-commit-boundary-2026-07-02]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
> 이 branch는 단일 노트가 아니라 **작업 묶음의 entry point**. 이 branch에서 파생된 모든 raw 노트를 카테고리별로 명시.
|
||||
|
||||
### 근거 자료
|
||||
|
||||
- [[raw/official-docs/lock-postgres-advisory-locks]] — PostgreSQL §13.3.5 Advisory Locks + §9.28.10 함수 레퍼런스 (session-level vs transaction-level 시맨틱, non-blocking 변형)
|
||||
- [[raw/official-docs/lock-shedlock-readme]] — ShedLock README: scheduled task 전용 락 / not full-fledged scheduler 공식 경계, `lockAtMostFor`/`lockAtLeastFor` lease 시맨틱, clock 동기화 전제 조건
|
||||
- [[raw/official-docs/lock-shedlock-issue-899-non-scheduler-use]] — ShedLock Issue #899: maintainer 가 generic lock 공식 선언 거부 + skip semantics 명시 (SHEDLOCK-899-C1, SHEDLOCK-899-C2) — `distributedLockProvider` 후보에서 ShedLock 배제/허용 결정의 근거
|
||||
- [[raw/official-docs/lock-spring-integration-lock-registry]] — Spring Integration LockRegistry/JdbcLockRegistry 공식 레퍼런스 (j.u.c.Lock 추상화, 4종 구현체, TTL/renewal/CME 시맨틱)
|
||||
- [[raw/company-tech-blogs/lock-subskribe-advisory-lock-distributed-consensus]] — Subskribe production 사례: advisory lock 만으로 distributed mutual exclusion + optimistic try-lock only 교훈 (company-case-study — 공식 승격 금지)
|
||||
- [[raw/official-docs/cache-redisson-rlock-vs-setnx]] — (cache branch 와 공유) Redisson RLock/SETNX/Redlock 비교 + Kleppmann efficiency vs correctness (LOCK-C1~C4)
|
||||
|
||||
### Sub-branches (세부 작업)
|
||||
|
||||
- (아직 없음)
|
||||
|
||||
### 오류 기록 (이 branch 작업 중 발생)
|
||||
|
||||
- [[raw/errors/spring-integration-defaultlockrepository-aftersingletons-null-template-2026-06-13]] — `DefaultLockRepository` Spring 컨텍스트 외부 초기화 시 `afterSingletonsInstantiated()` 누락 → `readCommittedTransactionTemplate` NPE. Layer 4 `DistributedLockProviderContractTest` 작성 중 발생, resolved.
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- "분산 락에서 lock 해제와 DB commit 의 순서가 왜 중요한가? lost-update race 를 설명하라" (D4 canonical pattern / forbidden inverse)
|
||||
- "efficiency lock 과 correctness lock 의 차이는 무엇인가? 왜 DB unique constraint 가 최종 방어선인가?" (D6)
|
||||
- "AutoCloseable 의 `close()` 가 `throws Exception` 인데, 왜 이 인터페이스는 그것을 재정의하여 unchecked 로 만들었는가?"
|
||||
- "tryLock(waitTime) + leaseTtl 조합이 무한 blocking 과 deadlock 을 어떻게 방지하는가?" (D5)
|
||||
- "finally 블록에서 예외를 던지면 왜 위험한가? 분산 락 해제 중 CME 를 re-throw 하지 않는 이유는?" (SI-LOCK-C5 / 정상 흐름 복귀)
|
||||
- "Decorator 패턴에서 wrapping lambda 로 handle 을 교체할 때 기존 동일성 테스트(`isSameAs`)가 왜 깨지는가?" (quality-review remediation — MeteredDistributedLockPort)
|
||||
|
||||
### 강의 (이 작업을 위해 학습한 강의)
|
||||
|
||||
- (아직 없음)
|
||||
|
||||
### job-posting tie-ins (이 작업에서 파생된 글감)
|
||||
|
||||
- "ShedLock 은 분산 락이 아니다 — maintainer 의 입으로 확인한 skip 시맨틱" (SHEDLOCK-899-C1/C2)
|
||||
- "분산 락과 트랜잭션: lock.close() 를 finally 에 두는 것만으로는 부족한 이유" (D4 forbidden inverse — commit 전 해제의 lost-update race)
|
||||
- "Clean Architecture 에서 분산 락 추상화 — DistributedLockPort 가 JdbcLockRegistry 를 숨기는 방법" (D2/D8 port 설계)
|
||||
- "Spring의 SmartInitializingSingleton: Spring 컨텍스트 없이 bean을 사용할 때 afterSingletonsInstantiated()를 직접 호출해야 하는 이유" (Layer 4 troubleshooting — DefaultLockRepository NPE)
|
||||
- "finally 블록에서 예외를 삼키는 게 맞을 때도 있다 — JdbcLock lease-expiry CME 처리와 정상 흐름 복귀" (SI-LOCK-C5 / quality-review finding 1)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
> 이 브랜치를 작업한 날짜들. 양방향 nav 유지.
|
||||
|
||||
- (2026-06-12 생성 — daily 노트 미작성)
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움. `/ingest`가 이 섹션을 기준으로 wiki/projects/에 추출.
|
||||
|
||||
- PR 링크: (미생성 — 사용자가 커밋·PR 수행)
|
||||
- 리뷰 메모: 2026-06-13 3단계 리뷰 체인 전부 `ready` —
|
||||
ca-architect-sentinel(PASS, 0 blocking/0 advisory: SI 가 adapter-persistence `implementation` 으로만 격리, app-bootstrap main 에 SI import 0, D8 모듈매트릭스 충족),
|
||||
ca-spec-reviewer(PASS, 요구 20/20 met, missing/extra/misinterpreted 0),
|
||||
ca-quality-reviewer(1차 NEEDS_FIX: Critical 1[SI-LOCK-C5] + Important 2 + Minor 2 → remediation 후 재리뷰 PASS, 0/0/0).
|
||||
- 머지 결과 / 배포 환경: **로컬 검증 완료** (Testcontainers PG Docker 가용 — 통합 테스트 SKIP 아님, 실제 실행).
|
||||
최종 gradle 검증(2026-06-13):
|
||||
- `:shared-contract:test` / `:application-core:test` / `:adapter-persistence:test` — 전부 PASS
|
||||
- `:app-bootstrap:test` — 280개 중 lock 관련 21개(Metered 10 + Provider 6 + Classification 5) 전부 PASS.
|
||||
유일한 실패는 **선행 커밋(d702572 등)에서 유래한 무관한 ArchUnit 위반** `outbound_adapter_method_returns_only_domain_or_primitives`
|
||||
(`OutboundHttpSettings.retry()/.circuitBreaker()` nested record) — `git stash` 후 clean HEAD 에서도 동일 실패 확인 → 본 branch 변경과 무관, 미수정(범위 밖, outbound branch 소유).
|
||||
- `verifyCleanArchitectureDependencies` / `verifyEnvKeys` — PASS (env 키 신규 0; `ca-skeleton.lock.*` 은 APP_ 비매핑 plain yaml).
|
||||
- registry 추가: `error-codes.yaml` `LOCK_ACQUISITION_TIMEOUT`(CONFLICT/409/retryable, D7) + `metrics.yaml` `lock.acquisition`(D7) + `lock.lease.expired`(§Edge/SI-LOCK-C5 — quality-review 후 추가, tagless counter).
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` + `locally-verified` 항목 (2026-06-13 현재):
|
||||
- `OperationalError.LOCK_ACQUISITION_TIMEOUT` (shared-contract) — Layer 1
|
||||
- `DistributedLockPort` / `DistributedLock` / `LockAcquisitionTimeoutException` (application-core) — Layer 2
|
||||
- 계약 테스트 10종 (application-core) — Layer 2
|
||||
- `LockSettings` / `LockRegistryDistributedLockAdapter` / `DistributedLockPersistenceConfig` (adapter-persistence) — Layer 3
|
||||
- `V4__int_lock.sql` (adapter-persistence) — Layer 3
|
||||
- `LockRegistryDistributedLockAdapterTest` 5종 (adapter-persistence) — Layer 3
|
||||
- `prod-verified` 항목: (없음)
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
- ArchUnit rule 호스팅 (`feature-architecture-enforcement-rules`) — planned
|
||||
- background-job ShedLock FQCN 전파 알림 — planned
|
||||
|
||||
- **wiki/projects 추출 추가 대상** (quality-review remediation 이후 `actually-implemented` + `locally-verified`):
|
||||
- Layer 4 완료분: `MeteredDistributedLockPort` (SI-LOCK-C5 포함) + `DistributedLockConfig` + `DistributedLockProviderContractTest` 6종 (2026-06-13)
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-distributed-tracing-contract.md
|
||||
@@ -0,0 +1,401 @@
|
||||
---
|
||||
title: branch / feature-distributed-tracing-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-distributed-tracing-contract
|
||||
parent_branch:
|
||||
governing_docs: [wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]
|
||||
related_projects: [ca-skeleton]
|
||||
tags: [branch, ca-skeleton, tracing, observability]
|
||||
created: 2026-05-22
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-027
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-027
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-ERROR-ENVELOPE-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: 7b6718a5912304437453bc70ffbbaba27bced681a98e7ed246687a83b38f67fa
|
||||
---
|
||||
|
||||
# branch: feature-distributed-tracing-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — HTTP, async, messaging, outbound 경계에서 trace context가 끊기지 않도록 distributed tracing 기준을 정의합니다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (§<관련 섹션>) 의 결정/근거/금지 사항을 정제한다.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: request·trace correlation contract test가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-ERROR-ENVELOPE-001@1` | foundation이 envelope schema와 error.category enum의 단일 owner다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
structured log만으로는 운영 장애의 흐름을 끝까지 추적하기 어렵습니다. traceId/requestId/correlationId/spanId의 의미와 전파 경계를 고정해서 어떤 adapter를 붙여도 같은 방식으로 원인을 추적할 수 있게 합니다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- traceId/requestId/correlationId/spanId 의미 정의.
|
||||
- inbound HTTP, outbound HTTP, async job, message publish/consume 전파 기준.
|
||||
- MDC와 trace context 동기화 기준.
|
||||
- sampling/exporter/env 설정 기준.
|
||||
- baggage 금지 정보 기준.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 특정 APM vendor 종속 설정.
|
||||
- business event tracing.
|
||||
- provider별 dashboard 구현.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/tracing-w3c-trace-context-spec.md]] | W3C Recommendation, OTel default propagator |
|
||||
| [[raw/official-docs/tracing-otel-sampling-tail-vs-head-spec.md]] | head-based + force-sample boost가 SDK 기본 기능만으로 구현 가능 |
|
||||
| [[raw/official-docs/tracing-b3-propagation-zipkin-spec.md]] | legacy, 64-bit mode는 W3C 비호환 |
|
||||
| [[raw/company-tech-blogs/tracing-datadog-apm-vs-opentelemetry.md]] | auto-instrumentation 광범위하나 vendor lock-in |
|
||||
| [[raw/official-docs/baggage-otel-baggage-api-spec]] | D2: SDK-level escape hatch (untrusted process 로의 모든 baggage entry 제거 MUST); D8: spec 에 allowlist 정의 없음 — restriction 은 Propagator/application 위임 (내부 governance 정책 확인) |
|
||||
| [[raw/official-docs/tracing-micrometer-observation-introduction]] | D12 — `Observation#error(exception)` 호출이 error lifecycle event를 발생시킨다는 API 계약 (MICR-OBS-C1, MICR-OBS-C3) |
|
||||
| [[raw/official-docs/baggage-w3c-baggage-spec]] | D2 — baggage 에 PII/기밀 정보 금지 + trust-boundary 제거 의무 (W3C-BAG-C1). D8 — allowlist 정책은 spec 에 없는 application 결정 (W3C-BAG-C2, W3C-BAG-C3). |
|
||||
| [[raw/official-docs/tracing-otel-trace-api-spec]] | D4 — SDK noop 시 all-zero TraceId (OTEL-TAPI-C2/C3), "disabled but meaningful traceId" = SDK-on + exporter-off 로만 가능; D12 — RecordException 은 Event 기록만 (OTEL-TAPI-C4), status=ERROR 는 별도 SetStatus 호출 필요 |
|
||||
| [[raw/official-docs/tracing-spring-boot-3-actuator-tracing-reference]] | D1 — Spring Boot Actuator 가 Micrometer Tracing (OTel+OTLP 와 Brave+Zipkin 두 tracer 공식 지원) 을 auto-configure 함; vendor-neutral OTLP 채택의 공식 근거 (SB-TRAC-C1 ~ C4) |
|
||||
|
||||
## 외부 근거 / 대안 조사 (2026-05-22 — Group G-A: Distributed tracing)
|
||||
|
||||
### 채택 결정 + 뒷받침
|
||||
|
||||
- 결정: **W3C traceparent + tracestate (B3 forbidden) + Micrometer Tracing + OpenTelemetry exporter + prod 1% head-based sampling + force-sample on error/slow/retry-exhausted**.
|
||||
- 뒷받침 source:
|
||||
- [[raw/official-docs/tracing-w3c-trace-context-spec.md]] — W3C Recommendation, OTel default propagator. 128-bit trace-id + `tracestate` vendor 확장 spec.
|
||||
- [[raw/official-docs/tracing-otel-sampling-tail-vs-head-spec.md]] — head-based + force-sample boost가 SDK 기본 기능만으로 구현 가능. tail-based는 collector overhead.
|
||||
|
||||
### 검토 대안 + source
|
||||
|
||||
- 대안 1 — **B3 / Zipkin propagation**: [[raw/official-docs/tracing-b3-propagation-zipkin-spec.md]]. legacy, 64-bit mode는 W3C 비호환. ca-tmpl은 forbidden, edge translation만 허용.
|
||||
- 대안 2 — **Tail-based / Adaptive sampling**: [[raw/official-docs/tracing-otel-sampling-tail-vs-head-spec.md]]. error/slow trace 100% 보존 가능하나 collector 메모리 + decision_wait window 추가 운영 비용.
|
||||
- 대안 3 — **Datadog APM / AWS X-Ray native tracer**: [[raw/company-tech-blogs/tracing-datadog-apm-vs-opentelemetry.md]]. auto-instrumentation 광범위하나 vendor lock-in. ca-tmpl out-of-scope 결정과 충돌.
|
||||
|
||||
### 비교 핵심 1줄
|
||||
|
||||
W3C + OTel + head-based는 **vendor-neutral + SDK 기본 기능만으로 구현 가능 + Spring Boot 3 + Micrometer 통합**이 강점, tail-based는 trace 완성도, vendor APM은 빠른 시작 + vendor lock-in trade-off.
|
||||
|
||||
## TODO
|
||||
|
||||
> TODO drained — 결정은 error.category enum 표 / MDC Key Standard 표 / error.details JSON shape 참조
|
||||
|
||||
## Work Item Contract
|
||||
|
||||
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
|
||||
|
||||
| field | required | rule |
|
||||
| --- | --- | --- |
|
||||
| Decision | yes | 구현자가 선택해야 하는 기본값 |
|
||||
| Allowed | yes | 허용되는 예외와 조건 |
|
||||
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
|
||||
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
|
||||
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
|
||||
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
|
||||
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- 2026-06-14 (/branch-spec 게이트): 6개 `UNSUPPORTED_DECISION` 중 4건을 자동조사로 해소 — D1(SB-TRAC-C1~C4), D2(W3C-BAG-C1 + OTEL-BAG-C3), D4(OTEL-TAPI-C2/C3 — 부분 해소 + 핵심 정정), D8(W3C-BAG-C2/C3 + OTEL-BAG-C4 — policy 재framing), D12(MICR-OBS-C1/C3 + OTEL-TAPI-C4). 잔여 `UNSUPPORTED_DECISION` 은 D3·D9 (내부 운영 정책 — 외부 표준 인용 대상 아님). official-doc raw 5개 신규 등록(spring-boot actuator tracing / micrometer observation / otel trace api / w3c baggage / otel baggage api).
|
||||
- 2026-06-14 ground-truth 재검증(ca-tmpl @HEAD): env/metric/header registry row 의 `owner_branch` 가 본 branch 임을 확인(`OTEL_EXPORTER_OTLP_ENDPOINT`·`APP_TRACING_ENABLED`·`APP_TRACING_SAMPLE_RATE`·`tracing.sampling.rate`·`traceparent`·`tracestate`). **단 Micrometer Tracing config 클래스는 `src/` 에 미존재 — 계약(registry)은 등록됐으나 구현은 `planned`.** async context 전파 코드(`AsyncContextTaskDecorator`)에서 carrier 표 drift 발견 → §Audit & Findings 참조.
|
||||
- 2026-06-14 **Slice 1 (Scope C — contract mechanics) 구현 완료** (`actually-implemented`, `locally-verified`): 3개 pure Java stdlib 타입을 `dev.caskeleton.shared.tracing` 패키지 (`src/shared-contract`) 에 신규 생성. TDD red→green 확인 (58 tests, 0 failures). Spring/OTel/Jackson import 없음 확인.
|
||||
- 2026-06-14 **전체 구현 완료 (Scope C — 계약 메커니즘; tracer 런타임은 fork-activated seam)** (`actually-implemented`, `locally-verified`). 사용자 결정: OTel/Micrometer/Actuator deps 미추가, 계약 메커니즘만 코드+테스트로 실현. 슬라이스:
|
||||
- **Slice 1 (shared-contract)**: `TraceParent`(W3C parse/validate/render, all-zero 거부 — D5/D7), `BaggageAllowlist`(allow=tenant_id/request_id, header filter — D2/D8), `SpanErrorRecorder`+`NOOP`(D12 seam). 58 tests.
|
||||
- **Slice 2 (adapter-web)**: `RequestLoggingFilter` 가 inbound `traceparent` accept/생성(부재·무효 시 32hex/16hex root) → MDC `trace_id`/`span_id` → `ResponseMetaFactory` `meta.traceId` 항상 non-null (**D4 disabled-fallback = request_id mirror 제거하고 실 W3C id 로 교체**). `GlobalExceptionHandler` 가 `SpanErrorRecorder.recordException(throwable, errorCode)` 호출(catch-all + persistence + dependency 경로). `@Autowired ObjectProvider<SpanErrorRecorder>` self-default → 모든 컨텍스트(@WebMvcTest 슬라이스 포함)에서 bean 없이 wiring, fork 가 bean 기여 시 override.
|
||||
- **Slice 3 (adapter-outbound)**: `TraceContextPropagationInterceptor` 가 MDC → outbound `traceparent`/`X-Request-Id`/`X-Correlation-Id`/allowlisted `baggage` 주입, `OutboundHttpClient.baseline(...)` buffered+streaming 양쪽 배선. MDC 키는 mdc-keys.yaml SSOT 리터럴(adapter-web 의존 금지). sampled=`00`(seam — tracer 가 실 sampled 소유).
|
||||
- **Slice 4+5 (app-bootstrap)**: `.env`+`application.yml` 3키 배선(verifyEnvKeys 통과), `TracingProperties`(@Validated, float_between_0_and_1 + url_or_empty 시작시 검증), `TracingSampleRateResolver`(prod .01/staging .1/dev·local 1.0 — D6), `TracingSamplingRateGauge`(`tracing.sampling.rate`, profile tag, ObjectProvider<MeterRegistry> no-op), 6개 required_test 전부 + §테스트계약 5종.
|
||||
- **검증**: `./gradlew check` = **BUILD SUCCESSFUL, 1091/1091 tests, 0 failures** (verifyCleanArchitectureDependencies + verifyEnvKeys + ArchUnit `CleanArchitectureTest` 포함). 리뷰 체인: architect-sentinel PASS, spec-reviewer 19/19 요구사항 MET, quality-reviewer 0 Critical(3 Important·4 Minor 반영).
|
||||
- **여전히 `planned`(과장 금지)**: 실 OTel SDK/Micrometer Tracing/OTLP exporter 런타임, 실 head/force-sample sampler, Observation scope async 재establish, B3 edge translation, tracestate 한계 모니터링. 이들은 fork-activated seam — 면접/포트폴리오에 "OTel 로 추적을 구현/운영했다" 금지. 실현된 것은 *계약 메커니즘*(전파 형식·disabled fallback·baggage allowlist·span-error seam·sampling-rate gauge·env/header/metric 배선·계약 테스트).
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-05-22: Micrometer Tracing + OpenTelemetry exporter를 기본 기준으로 둠.
|
||||
- 2026-05-22: baggage에는 PII, token, user raw identifier, request body derived value를 넣지 않음.
|
||||
- 2026-05-22: trace/request/correlation ID 의미의 SSOT는 `feature-operational-error-observability-foundation`; 이 branch는 propagation mechanics만 소유.
|
||||
- 2026-05-22: tracing disabled profile에서도 envelope `meta.traceId`와 log `traceId`는 유지. exporter/sampling만 비활성화 가능.
|
||||
- 2026-05-22: propagation header는 W3C `traceparent` default.
|
||||
- 2026-05-22: trace sampling rate default = prod 1%, staging 10%, dev/local 100%. force-sample = error response, slow request (p99 초과), retry exhausted.
|
||||
- 2026-05-22: propagation format = W3C traceparent + tracestate only. B3 propagation은 forbidden (외부 통합 시 edge에서 변환).
|
||||
- 2026-05-22: baggage allowlist = `tenant_id`, `request_id` 만 허용. 그 외 baggage 사용 forbidden.
|
||||
- 2026-05-22: trace sampling rate(prod 1%) < log sampling rate(prod 10%)는 의도된 분리. log-management branch와 정합.
|
||||
- 2026-05-22: identifier 표기는 layer별 분리. **MDC/log field**는 snake_case (`request_id`/`trace_id`/`correlation_id`), **JSON response envelope**는 camelCase (`meta.requestId`/`meta.traceId`/`meta.correlationId`), **HTTP header**는 kebab-case (`X-Request-Id`/`X-Correlation-Id`). foundation MDC SSOT와 envelope SSOT의 mapping은 본 branch의 Propagation Defaults 표가 보장.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시한다.
|
||||
> `Decision ID` 는 이 branch-note 안에서 안정적으로 유지한다.
|
||||
|
||||
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|
|
||||
| D1 | Micrometer Tracing + OpenTelemetry exporter 기본 채택 | `raw/official-docs/tracing-spring-boot-3-actuator-tracing-reference#SB-TRAC-C1` (Actuator auto-configures Micrometer Tracing facade), `#SB-TRAC-C2` (OTel+OTLP 와 Brave+Zipkin 두 tracer 모두 공식 지원), `#SB-TRAC-C3` (두 조합 모두 dedicated starters 존재), `#SB-TRAC-C4` (`spring-boot-starter-opentelemetry` 공식 starter) | `official-vendor-doc` (Spring Boot 공식 reference — 2026-06-14 fetch 검증) | "OTel 이 유일한 default" 는 증명 안 됨 — Spring Boot 는 OTel+OTLP 와 Brave+Zipkin 둘 다 지원. D1 의 framing 은 "두 tracer 중 OTel+OTLP 를 채택" 임을 명시할 것. vendor-neutral OTLP export 의 Spring Boot 공식 지원 근거로만 사용 |
|
||||
| D2 | baggage 에 PII/token/user raw identifier/body 금지 | `raw/official-docs/baggage-w3c-baggage-spec#W3C-BAG-C1` (baggage may carry sensitive information — trust-boundary 제거 의무, **baggage spec 직접 근거**) + `raw/official-docs/baggage-otel-baggage-api-spec#OTEL-BAG-C3` (untrusted process 로의 모든 baggage entry 전송 방지 MUST — SDK-level escape hatch) | `official-standard` + `official-vendor-doc` (W3C Candidate Recommendation Snapshot 2024-05-30 + OTel stable spec) | W3C Baggage spec §4.1 이 기밀/소유 정보 금지 + trust-boundary 제거 의무 직접 규정. OTel Baggage spec 은 untrusted process 전송 방지 MUST. 구체적 금지 항목(PII/token 형태)은 application 정책. 이전 인용 `tracing-w3c-trace-context-spec#W3C-TC-C5`(tracestate 대상)는 baggage 직접 근거가 아니었으므로 `W3C-BAG-C1` 로 교체. |
|
||||
| D3 | trace/request/correlation ID 의미 SSOT = `feature-operational-error-observability-foundation` consume | UNSUPPORTED_DECISION (SSOT 분할은 내부 운영 정책) | N/A | branch ownership 분할은 외부 표준 인용 대상 아님 |
|
||||
| D4 | tracing disabled profile 에서도 envelope `meta.traceId` + log `traceId` 유지, exporter/sampling 만 비활성화 | `raw/official-docs/tracing-otel-trace-api-spec#OTEL-TAPI-C1` (SDK 부재 시 Trace API = no-op), `#OTEL-TAPI-C2` (noop + 부모 Span 없으면 SpanContext = all-zero Trace/Span IDs), `#OTEL-TAPI-C3` (noop 상태 새 SpanContext 미생성) | `official-standard` (OTel Trace API spec — 2026-06-14 fetch) | **핵심 정정**: SDK 자체를 noop 으로 두면 traceId=all-zeros(의미 없음). 따라서 "disabled but keep meta.traceId" = **SDK-on + exporter-off**(sampling.probability=0)로만 구현 가능. **UNSUPPORTED_IMPL_DECISION**: 정확한 Spring property 조합(exporter bean exclusion + sampling 0) 또는 app-generated UUID fallback 은 ca-tmpl 운영 결정 — 단일 source 없음 |
|
||||
| D5 | propagation header = W3C `traceparent` default | `raw/official-docs/tracing-w3c-trace-context-spec.md#W3C-TC-C1`, `raw/official-docs/tracing-w3c-trace-context-spec.md#W3C-TC-C2`, `raw/official-docs/tracing-w3c-trace-context-spec.md#W3C-TC-C3` | `official-standard` (W3C TR — HTTP header + 4-field format + canonical example, 2026-05-27 verified verbatim) | C4 (tracestate name/value vs key/value 표현 차이) 는 `needs-confirmation` 유지 |
|
||||
| D6 | trace sampling rate default = prod 1%, staging 10%, dev/local 100% + force-sample (error/slow/retry-exhausted) | `raw/official-docs/tracing-otel-sampling-tail-vs-head-spec.md#OTEL-SAMP-C1`, `raw/official-docs/tracing-otel-sampling-tail-vs-head-spec.md#OTEL-SAMP-C3`, `raw/official-docs/tracing-otel-sampling-tail-vs-head-spec.md#OTEL-SAMP-C4` | `official-vendor-doc` (head sampling 정의/장점/단점) | `OTEL-SAMP-C3` Usage Boundary: 효율의 정량값 없음. 1%/10%/100% 비율 자체는 ca-tmpl 운영 가정 (`OTEL-SAMP-C7` 같은 권장값 spec 부재). force-sample 메커니즘은 `OTEL-SAMP-C4` Does not prove 에 따르면 별도 SDK 구현 필요 |
|
||||
| D7 | propagation format = W3C traceparent + tracestate only. B3 propagation forbidden | `raw/official-docs/tracing-w3c-trace-context-spec.md#W3C-TC-C1`, `raw/official-docs/tracing-b3-propagation-zipkin-spec.md#B3-C6` (B3 trace-id 64-bit/128-bit 양쪽 허용 — W3C 128-bit only 와 호환 한계) | `official-standard` (양쪽 spec) | `B3-C6` Does not prove: W3C 호환 결론은 본 인용으로 직접 증명되지 않음. ca-tmpl 의 "forbidden" 결정은 W3C 채택 + 운영 단순화 정책 |
|
||||
| D8 | baggage allowlist = `tenant_id`, `request_id` 만 허용 | `raw/official-docs/baggage-w3c-baggage-spec#W3C-BAG-C2` + `raw/official-docs/baggage-w3c-baggage-spec#W3C-BAG-C3` (W3C Baggage spec 은 64 list-members / 8192 bytes wire 제약만 정의, allowlist 메커니즘 없음) + `raw/official-docs/baggage-otel-baggage-api-spec#OTEL-BAG-C4` (OTel spec 도 allowlist 미정의 — restriction 은 Propagator/application 위임) | `official-standard` + `official-vendor-doc` (W3C Candidate Recommendation Snapshot 2024-05-30 + OTel stable spec) | 두 spec 모두 wire-format 제약만 정의하고 어떤 key 를 허용/금지할지 규정하지 않음. `tenant_id`/`request_id` 구체 key 선택은 ca-tmpl 운영 정책 — UNSUPPORTED_IMPL_DECISION 유지. W3C-BAG-C2/C3 는 "spec 에 allowlist 없음" 을 W3C 층에서 추가 확인. |
|
||||
| D9 | identifier 표기 layer 별 분리 (MDC snake_case / envelope camelCase / HTTP header kebab-case) | UNSUPPORTED_DECISION (layer 별 표기 컨벤션은 내부 결정 — 외부 raw 표준 없음). **owner = [[raw/branch-notes/feature-operational-error-observability-foundation]] D19** (mdc-keys.yaml / headers.yaml SSOT) — 본 row 는 그 결정의 consume pointer, 재진술 아님 (§Audit RESTATED_FOREIGN_DECISION 참조) | N/A | foundation branch 의 MDC Key Standard 표 와 envelope SSOT 정합으로만 정당화 |
|
||||
| D10 | Datadog APM / AWS X-Ray native tracer 거부 (vendor lock-in) | `raw/company-tech-blogs/tracing-datadog-apm-vs-opentelemetry.md#DD-OTEL-C1`, `raw/company-tech-blogs/tracing-datadog-apm-vs-opentelemetry.md#DD-OTEL-C2`, `raw/company-tech-blogs/tracing-datadog-apm-vs-opentelemetry.md#DD-OTEL-C3` | `company-case-study` (Datadog 의 OTel vendor-neutrality 인정 + 자체 dd-trace-java 자동 계측) | company-tech-blog 는 official best practice 아님. ca-tmpl 의 "out-of-scope" 결정은 vendor 평가 trade-off 로만 표현. `DD-OTEL-C4`/`C5`/`C6` 는 `needs-confirmation` — verbatim 미확인 |
|
||||
| D11 | Tail-based / Adaptive sampling 거부 (collector overhead) | `raw/official-docs/tracing-otel-sampling-tail-vs-head-spec.md#OTEL-SAMP-C5` (tail sampling = trace 의 모든/대부분 span 고려) | `official-vendor-doc` | `OTEL-SAMP-C5` Usage Boundary: decision_wait window 길이 / missing span 처리 의 trade-off 본 인용 범위 밖. ca-tmpl 의 운영 비용 평가는 내부 판단 |
|
||||
| D12 | span 예외 발생 시 `Observation.error(throwable)` + `error.code` 부착 + sampled span 만 stack trace attach | `raw/official-docs/tracing-micrometer-observation-introduction#MICR-OBS-C1` (`Observation#error(exception)` 호출 → error lifecycle event), `#MICR-OBS-C3` (ObservationHandler 가 lifecycle event 로 span 생성), `raw/official-docs/tracing-otel-trace-api-spec#OTEL-TAPI-C4` (RecordException = AddEvent 변형, status 변경 없음 → SetStatus 별도 호출) | `official-vendor-doc` (Micrometer Observation reference + OTel Trace API spec) | `Observation.error()` → `OtelSpan.error()` → `recordException()` + `setStatus(ERROR)` 체인은 **소스코드 검증**(공식 docs 산문 부재). `error.code` 는 ca-tmpl registry attribute 명 — OTel semantic-convention 표준 아님(표준 = `exception.type`/`exception.message`/`exception.stacktrace`). **UNSUPPORTED_IMPL_DECISION**: "sampled span 만 stack trace / unsampled = error.code only" 정책은 ca-tmpl 운영 결정 — source 없음. 코드 미구현(`planned` — `src/` 에 Observation error handler 부재) |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> *결정 (Decisions)* 이 "*무엇* 을 할 것인가" 라면, 본 §는 "*어디에 어떻게* 구현될 것인가" 의 사전 명세다. 아래 sub-section 은 모두 본 branch 의 결정 + 근거에서 도출되며, 각 표는 Trace 헤더로 `Decision ID` + `Supporting Claim ID` 를 reference 한다.
|
||||
>
|
||||
> **코드 구현 상태**: 본 branch 의 결정은 registry(env/metric/header)에는 등록됐으나, Micrometer Tracing config / Observation error handler 클래스는 ca-tmpl `src/` 에 **아직 없음** (`planned`). 아래 명세는 *구현될 때의 사전 계약* 이다 (§Audit & Findings IMPL_STATUS 참조).
|
||||
|
||||
### 1. Boundary Propagation Defaults
|
||||
|
||||
> **Trace**: D5 (`traceparent` default — W3C-TC-C1/C2/C3) + D7 (W3C only, B3 forbidden) + D4 (disabled → exporter-off — OTEL-TAPI-C2/C3).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `disabled tracing → generated opaque trace id` 행 — OTel SDK 를 noop 으로 두면 traceId = all-zeros(OTEL-TAPI-C2/C3)이므로 "meaningful opaque id 유지" 는 *SDK-on + exporter-off*(sampling 0) 또는 *app-generated UUID* 로만 가능. 정확한 메커니즘은 ca-tmpl 운영 결정(단일 source 없음).
|
||||
|
||||
| boundary | default |
|
||||
| --- | --- |
|
||||
| inbound HTTP | accept/generate W3C trace context |
|
||||
| outbound HTTP | propagate `traceparent`, requestId, correlationId |
|
||||
| async/job | capture and restore context wrapper |
|
||||
| messaging | include trace context and correlationId in metadata |
|
||||
| disabled tracing | generated opaque trace id, exporter off (SDK-on + exporter-off — noop 은 all-zeros 라 사용 불가, 위 UNSUPPORTED_IMPL_DECISION) |
|
||||
|
||||
### 2. Async / Messaging Carrier Keys
|
||||
|
||||
> **Trace**: D5/D7 (W3C carrier — traceparent/tracestate) + §Claims To Verify (TaskDecorator / Kafka·Rabbit consumer-side auto-extract = `planned`).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: Kafka `traceparent (binary value)` 인코딩 + Spring scheduler per-trigger 생성은 OTel instrumentation 모듈 동작 가정 — 본 branch 인용에 직접 spec 없음(`planned`, §Claims To Verify).
|
||||
> - **CARRIER_DRIFT (코드 실측)**: `@Async TaskDecorator` 행은 ca-tmpl 코드와 어긋남 — 실제 `AsyncContextTaskDecorator` 는 **plain MDC copy**(`MDC.getCopyOfContextMap()`)이며 Micrometer **Observation scope 를 worker thread 에 재establish 하지 않는다**(io.micrometer:context-propagation + ContextPropagatingTaskDecorator 는 *documented future enhancement*, 미구현). 또한 이 decorator 의 owner 는 [[raw/branch-notes/feature-background-job-async-contract]] / [[raw/branch-notes/feature-runtime-context-propagation-contract]] 이지 본 branch 가 아니다. §Audit & Findings 참조.
|
||||
|
||||
| carrier | key |
|
||||
|---------|-----|
|
||||
| HTTP | traceparent, tracestate (W3C) |
|
||||
| Kafka header | traceparent (binary value) |
|
||||
| RabbitMQ header | traceparent |
|
||||
| @Async TaskDecorator | **(실측 정정)** MDC trace_id/span_id 문자열 thread-local copy via `AsyncContextTaskDecorator`. Observation scope 재establish 는 미구현(future enhancement) |
|
||||
| Spring scheduler | traceparent generated per trigger |
|
||||
|
||||
### 3. Span Error Recording
|
||||
|
||||
> **Trace**: D12 — `Observation#error` lifecycle (MICR-OBS-C1/C3) + RecordException ≠ status 변경(OTEL-TAPI-C4, SetStatus 별도).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `sampled span 만 stack trace attach / unsampled = error.code attribute only` 는 ca-tmpl 운영 정책(source 없음). `error.code` 는 ca-tmpl registry attribute 명이며 OTel semantic-convention 표준 아님(표준 = `exception.type`/`exception.message`/`exception.stacktrace`).
|
||||
|
||||
- 예외 발생 시 `Observation.error(throwable)` 호출 강제.
|
||||
- span attribute `error.code` (registry value) 부착 + status=ERROR.
|
||||
- exception stack trace는 sampled span에만 attach. unsampled span은 `error.code` attribute만 남기고 stack trace 부착 금지.
|
||||
|
||||
### 4. Registry anchors (env / metric / header — ca-tmpl SSOT)
|
||||
|
||||
> **Trace**: D1 (exporter endpoint) + D4 (tracing enabled toggle) + D6 (sample rate + sampling metric) + D5/D7 (header). 아래 값은 ca-tmpl `docs/registries/*.yaml` 의 *실재 row* 로, `owner_branch` 가 본 branch 임을 2026-06-14 확인했다.
|
||||
>
|
||||
> - **IMPL_STATUS**: registry row 는 등록됨(계약 존재). 이를 읽어 적용하는 Micrometer Tracing config / OTLP exporter / 커스텀 sampler 클래스는 `src/` 에 **미존재**(`planned`). registry ≠ 구현 — 면접/포트폴리오에 "구현했다" 금지(§Audit IMPL_STATUS).
|
||||
|
||||
| registry | key | 값 (registry 실측) | required_test | owner |
|
||||
|---|---|---|---|---|
|
||||
| env-keys.yaml | `OTEL_EXPORTER_OTLP_ENDPOINT` | type url, default null, public-config, restart-only, validation url_or_empty | `tracing-contract:exporter-endpoint-resolvable` | 본 branch |
|
||||
| env-keys.yaml | `APP_TRACING_ENABLED` | boolean, default true, public-config, restart-only, boolean_strict | `tracing-contract:meta-traceid-when-disabled` | 본 branch |
|
||||
| env-keys.yaml | `APP_TRACING_SAMPLE_RATE` | string, default "1.0", public-config, restart-only, float_between_0_and_1 | `tracing-contract:sample-rate-per-profile` | 본 branch |
|
||||
| metrics.yaml | `tracing.sampling.rate` | gauge, tag `profile`(cardinality 4 — prod/staging/dev/local) | `contract-verification:metrics-cardinality` | 본 branch |
|
||||
| headers.yaml | `traceparent` | direction both, generated_if_missing true, mdc_key `trace_id`, envelope `meta.traceId` | `contract-verification:trace-propagation` | 본 branch |
|
||||
| headers.yaml | `tracestate` | direction both, generated_if_missing false, mdc_key null | `contract-verification:trace-propagation` | 본 branch |
|
||||
| mdc-keys.yaml | `trace_id`/`span_id`/`correlation_id`/`request_id` | snake_case, http_header_mapping + envelope_field 등록 | `contract-verification:log-mdc-keys` | **foundation** (consume only — §엣지·실패·의존) |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4(깊이 게이트) 캡처용. 정상 경로 외에 *구현 중 부딪힐* 실패/엣지/다른 계약 의존을 미리 열거.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **disabled profile → all-zeros**: OTel SDK 를 noop 으로 두면 `meta.traceId` = `00000000...`(OTEL-TAPI-C2/C3). 기대: exporter-off + SDK-on 으로 meaningful id 유지. all-zeros 가 envelope/log 에 노출되면 실패(테스트 계약 `meta.traceId` 누락 항목과 같은 실패군).
|
||||
- **force-sample 한계**: head sampler 단독으로는 error/slow/retry-exhausted boost 불가(OTEL-SAMP-C4) → `ParentBased + 커스텀 sampler` 별도 구현 필요(§Claims To Verify, `needs-confirmation`).
|
||||
- **B3 inbound (외부 시스템)**: 본 branch 는 B3 forbidden(D7)이나 외부 호출자가 B3 헤더를 보낼 수 있음 → edge 에서 multi-propagator(`tracecontext,b3`) 변환, receiver precedence(B3-C5/C6). 미구현 시 trace 단절.
|
||||
- **tracestate 한계 초과**: List-Members/length 제한(W3C-TC-C4, `planned`) 초과 시 partial drop. vendor tracestate 누적 monitoring 필요.
|
||||
- **baggage trust-boundary**: untrusted process 호출 전 baggage remove-all(OTEL-BAG-C3) 미적용 시 D2 위반(PII 유출).
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-operational-error-observability-foundation]] 의 `D11`(MDC snake_case 표준) + `D19`(snake/camel/kebab layer mapping) + `D16`(operational error → span ERROR 기록) 에 의존 — 본 branch 는 `trace_id`/`span_id`/`correlation_id` 의 *의미·명명* 을 consume(SSOT 는 foundation + `mdc-keys.yaml`). 그 계약이 바뀌면 D3/D9/D12 영향.
|
||||
- ca-tmpl `docs/registries/mdc-keys.yaml` + `headers.yaml`(registry SSOT) — `trace_id ↔ traceparent ↔ meta.traceId` 매핑. 본 branch 는 `traceparent`/`tracestate` header row 의 owner, MDC key row 는 foundation owner.
|
||||
- [[raw/branch-notes/feature-log-management-contract]] — log sampling(prod 10%) vs trace sampling(prod 1%) 의도된 분리(D6 정합). log sampling 정책이 바뀌면 D6 비교 근거 재검토.
|
||||
- [[raw/branch-notes/feature-background-job-async-contract]] + [[raw/branch-notes/feature-runtime-context-propagation-contract]] — 실제 async context 전파 메커니즘(`AsyncContextTaskDecorator` / `DomainContextPropagator`)의 owner. 본 branch 는 carrier key 만 정의하고 전파 구현은 그 branch 소유(§Audit CARRIER_DRIFT).
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| W3C traceparent 의 `trace-flags` LSB = sampled (`01` = sampled) 비트 의미 | `W3C-TC-C2` Does not prove: trace-flags 의 sampled bit 의미는 spec 동일 섹션 추가 인용 필요 | spec 재 fetch + ca-tmpl 의 sampling 결정이 `01` flag 로 downstream 에 전파되는지 wire-level capture | `planned` |
|
||||
| W3C tracestate entry 의 List-Members 32개 / total length 제한 | `W3C-TC-C4` Usage Boundary: tracestate entry 개수 / 크기 제한 spec 별도 섹션 추가 인용 필요 | spec 재 fetch + vendor 별 tracestate 사용 크기 monitoring | `planned` |
|
||||
| Micrometer Tracing TaskDecorator 가 @Async / Scheduled 경계에서 trace context 자동 전파 | 본 branch 인용 자료에 Micrometer Tracing TaskDecorator 직접 spec 없음. 실측: `AsyncContextTaskDecorator` 는 MDC copy 만 — Observation scope 미재establish (§Audit CARRIER_DRIFT) | `@Async` 호출 → child thread 에서 `Span.current()` 또는 MDC `trace_id` 확인 test | `planned` |
|
||||
| Kafka / RabbitMQ 의 `traceparent` header 가 consumer side 에서 자동 extract | OTel Java instrumentation 의 Kafka / Rabbit Spring 모듈 spec 별도 raw 없음 | producer/consumer e2e test — trace span 이 연결되는지 Jaeger / Tempo UI 확인 | `planned` |
|
||||
| force-sample on error/slow/retry-exhausted 가 head sampler 단독으로 구현 가능 | `OTEL-SAMP-C4` Usage Boundary: head sampler 는 trace 전체 데이터 기반 결정 불가 — force-sample 은 별도 SDK 구현 | OTel SDK `ParentBased + AlwaysOn / TraceIdRatioBased` 조합 + 커스텀 sampler 구현 확인 | `needs-confirmation` |
|
||||
| B3 → W3C edge translation 의 정확한 구현 (multi-propagator 패턴) | `B3-C5`/`B3-C6` Usage Boundary: receiver precedence 만 규정 — edge converter 구현 별도 | OTel SDK `propagators=tracecontext,b3` 설정 + 외부 시스템 fixture test | `planned` |
|
||||
| tracestate name/value vs key/value 표현 차이 (`W3C-TC-C4`) 의 정확한 spec 표현 | 2026-05-27 fetch 와 2026-05-25 캡처 표현 차이 — `needs-confirmation` | W3C TR 페이지 단어 단위 재 fetch | `needs-confirmation` |
|
||||
| `Observation.error(throwable)` → OTel span `recordException` + `setStatus(ERROR)` 체인 | 공식 docs 산문 부재 — `OtelSpan.error()` 소스코드로만 확인(MICR-OBS-C1 + OTEL-TAPI-C4 간접) | Micrometer Tracing reference(`docs.micrometer.io/tracing`) fetch 또는 OtelTracingObservationHandler 테스트로 span status 확인 | `needs-confirmation` |
|
||||
|
||||
## 테스트 계약
|
||||
|
||||
- inbound 요청의 traceId가 response meta, log, outbound call에 연결되지 않으면 실패.
|
||||
- async/job/message boundary에서 correlationId가 사라지면 실패.
|
||||
- baggage에 금지 정보가 기록되면 실패.
|
||||
- tracing disabled local profile에서도 requestId/correlationId log field는 유지되어야 함.
|
||||
- tracing disabled 상태에서 `meta.traceId`가 누락되면 실패.
|
||||
|
||||
## Audit & Findings
|
||||
|
||||
> /branch-spec(2026-06-14) ground-truth 대조에서 발견한 drift·정합 권고·구현 상태. 사용자 작성 결정 영역은 자동 rewrite 하지 않고 *정합 권고만* 남긴다.
|
||||
|
||||
- **CARRIER_DRIFT** (§구현 가이드 §2): Async/Messaging Carrier Keys 표의 `@Async TaskDecorator | thread-local copy via Micrometer Observation` 는 ca-tmpl 코드와 drift. 실제 `src/app-bootstrap/.../async/AsyncContextTaskDecorator.java` 는 `MDC.getCopyOfContextMap()` 기반 **plain MDC 문자열 copy** 이며 worker thread 에 **Micrometer Observation scope 를 재establish 하지 않는다**(javadoc 명시: io.micrometer:context-propagation + ContextPropagatingTaskDecorator 는 future enhancement). 표를 실측으로 정정함. carrier 전파 구현의 owner 는 background-job-async / runtime-context-propagation branch.
|
||||
- **RESTATED_FOREIGN_DECISION** (D9): D9 의 layer-notation mapping(snake/camel/kebab)은 foundation `D19` + `mdc-keys.yaml`/`headers.yaml`(owner_branch = foundation)이 SSOT. consistency-contract(Single-Owner/Reference-Only)상 D9 는 *재진술* 이 아니라 foundation D19 의 *consume pointer* 여야 한다. D9 row 에 owner pointer 를 명시함. 추가 권고: `## 결정 사항` 의 2026-05-22 identifier 표기 항목의 "본 branch의 Propagation Defaults 표가 보장" 문구는 "foundation D19 + mdc-keys.yaml/headers.yaml 이 SSOT, 본 branch 는 propagation 경계만 소유" 로 약화하는 것이 정확(사용자 결정 영역 → 권고만).
|
||||
- **IMPL_STATUS** (D1/D12): env/metric/header registry row 는 등록됐으나(`owner_branch` = 본 branch 확인), Micrometer Tracing config·OTLP exporter·Observation error handler·커스텀 sampler 클래스는 `src/` 에 **미존재**. D1/D6 코드는 `planned`/`documented-only`. **D12 부분 구현**: `SpanErrorRecorder` 인터페이스 + `NOOP` constant 는 `actually-implemented` (Slice 1, 2026-06-14); tracer-backed 구현체는 `planned`. governing doc [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]] 의 "과장 금지" 절과 정합 — 면접/포트폴리오에 "OTel 로 구현/운영했다" 금지. **Slice 1 신규 타입**: `TraceParent` (D5/D7), `BaggageAllowlist` (D2/D8), `SpanErrorRecorder` NOOP seam (D12) — 3개 모두 `actually-implemented`, 58 tests `locally-verified`, 2026-06-14.
|
||||
- **GROUND_TRUTH 확인**: ca-tmpl 경로 존재. registry `owner_branch = feature-distributed-tracing-contract` 를 env-keys/metrics/headers/secrets-classification 에서 확인. `NO_GROUND_TRUTH` 아님.
|
||||
|
||||
## Seam composition 위험 / fork 가 실 tracer 배선 시 밟는 지뢰 (2026-06-15)
|
||||
|
||||
> **메타 위험**: Scope C 구현은 `./gradlew check` 1091 green 이나, 이 테스트는 **실 OTel SDK 없이 mechanism 만** 검증한다. seam 은 **실 tracer 와 단 한 번도 composition-test 된 적 없다**. "1091 green = seam 이 SDK 와 검증됨" 은 **거짓 확신** — 아래 두 정합 위험은 green 이 구조적으로 못 잡는다. 둘 다 spec §Claims To Verify 의 `planned`/`needs-confirmation` 항목(trace-flags sampled bit / Micrometer 통합)과 직접 연결된다.
|
||||
|
||||
- **LANDMINE-1 — outbound `sampled=00` 하드코딩이 downstream trace 를 능동적으로 억제** (`TraceContextPropagationInterceptor`): mdc-keys.yaml(foundation SSOT)에 sampled/trace-flags carrier key 가 **없으므로**, outbound `traceparent` 는 `trace_id`+`span_id` 로만 재구성되고 flags 는 `00`(not-sampled)으로 강제된다. downstream `ParentBased` sampler 는 `00` 을 "parent not sampled" 로 읽어 child span 을 drop → 상류가 sample 한 trace 도 이 경계에서 끊긴다. 게다가 이 interceptor 는 `OutboundHttpClient` 에서 **첫 번째**로 등록되어 `traceparent` 를 먼저 stamp 하고, idempotency guard 가 이후 OTel instrumentation 을 skip 시킨다 — `00` 은 fallback 이 아니라 실 결정을 **덮어쓴다**. seam 이 중립이 아니라 **능동적으로 sampling 을 끄는** 상태.
|
||||
- fork 조치: (a) 이 interceptor 를 **비활성/제거**하고 OTel RestClient instrumentation 이 `traceparent` 를 소유하게 하거나, (b) `TraceParent.of(.., false)` 를 실 `Span.getSpanContext().isSampled()` 로 교체 + foundation 에 `trace_flags` MDC carrier 신설(= **cross-branch**, foundation D11/D19 소유). **sampled 비트 보존은 본 branch 단독으로 불가** — mdc-keys.yaml 소유권이 foundation 이기 때문.
|
||||
- **LANDMINE-2 — filter-생성 `meta.traceId` vs 실 SDK trace-id 발산** (`RequestLoggingFilter`): no-tracer skeleton 에서는 filter 가 inbound 부재 시 `trace_id` 를 **민팅**하고 `ResponseMetaFactory` 가 `meta.traceId` 로 투영한다. fork 가 Micrometer Tracing 을 켜면 OTel SDK 도 같은 요청에 trace-id 를 민팅하고 SLF4J-Micrometer bridge 가 **자기 id** 를 MDC `trace_id` 에 쓴다. filter 가 이기면 응답의 `meta.traceId` ≠ 실제 export 된 span 의 trace-id → "응답에 박힌 id 로 백엔드에서 trace 추적"(D4 핵심 목적)이 조용히 깨진다.
|
||||
- fork 조치: 실 tracer 가 MDC `trace_id` 의 **단독 owner** 가 되도록 filter 를 tracing observation **이후**로 ordering 하거나, filter 가 `Span.current()` 를 adopt 하도록 교체. ordering/scope 의존 → 반드시 통합 테스트로 `meta.traceId == exported trace-id` 확인.
|
||||
- **권고(차기 작업)**: 이 두 지뢰의 진짜 해소는 (1) foundation 에 `trace_flags` MDC carrier 추가(cross-branch) + (2) 실 OTel SDK 와의 **composition 통합 테스트**(Testcontainers OTLP collector / Jaeger 로 `meta.traceId`↔exported span 일치 + sampled 보존 검증)를 요구한다. 둘 다 Scope C(본 branch 단독) 밖 — `planned` 로 명시. 코드에는 `TraceContextPropagationInterceptor`/`RequestLoggingFilter` javadoc 에 ⚠ FORK LANDMINE 블록으로 박아둠.
|
||||
|
||||
## 완료 후 wiki 추출 대상
|
||||
|
||||
- `wiki/projects/ca-tmpl/observability-log-metric-trace-runbook.md` 의 observability/tracing canonical section (governing doc).
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 생성 — 2026-06-14)
|
||||
|
||||
> governing doc [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]] (§Trace) 이 요구하는 관심사를 본 branch 가 빠짐없이 덮는지의 결과. 기준: `rules/coverage-gate.md`. 판정: **Covered** (missing 0).
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|--------|------|-------|--------|------|
|
||||
| 전파 형식: W3C traceparent 채택, B3 forbidden | covered-here | — | — | D5 (W3C-TC-C1/C2/C3), D7 (B3-C6); headers.yaml `traceparent`/`tracestate` owner |
|
||||
| 트레이싱 라이브러리/exporter: Micrometer Tracing + OTel bridge | covered-here | — | — | D1 (SB-TRAC-C1~C4); env `OTEL_EXPORTER_OTLP_ENDPOINT` owner |
|
||||
| 샘플링 전략: prod 1% / staging 10% / dev·local 100% + force-sample | covered-here | — | — | D6 (OTEL-SAMP-C1/C3/C4); env `APP_TRACING_SAMPLE_RATE` + metric `tracing.sampling.rate` owner |
|
||||
| 대안 검토: tail-based / Datadog·X-Ray / B3 거부 | covered-here | — | — | D11 / D10 / D7; §외부 근거·대안 조사 |
|
||||
| tracing 활성화 toggle + disabled 시 meta.traceId 유지 | covered-here | — | — | D4 (OTEL-TAPI-C1/C2/C3); env `APP_TRACING_ENABLED` owner |
|
||||
| baggage: PII/token 금지 + allowlist (tenant_id/request_id) | covered-here | — | — | D2 (W3C-BAG-C1 + OTEL-BAG-C3), D8 (W3C-BAG-C2/C3 + OTEL-BAG-C4) |
|
||||
| span error 기록: Observation.error() + error.code + sampled-only stack trace | covered-here | — | — | D12 (MICR-OBS-C1/C3 + OTEL-TAPI-C4). `SpanErrorRecorder` 인터페이스 + NOOP `actually-implemented`; tracer-backed impl 은 `planned` |
|
||||
| log/trace 샘플링 분리 정합 (trace 1% vs log 10%) | delegated | [[raw/branch-notes/feature-log-management-contract]] | OK | D6 Open Risk + §엣지·실패·의존 포인터 |
|
||||
| ID 의미 SSOT (traceId/spanId/correlationId/requestId 의미) | delegated | [[raw/branch-notes/feature-operational-error-observability-foundation]] | OK | D3 + D9 consume-pointer (foundation D11/D19, mdc-keys.yaml owner) |
|
||||
| async/messaging carrier 실 전파 구현 (TaskDecorator/context propagation) | delegated | [[raw/branch-notes/feature-background-job-async-contract]], [[raw/branch-notes/feature-runtime-context-propagation-contract]] | OK | §구현 가이드 §2 (carrier key 정의만 본 branch) + §Audit CARRIER_DRIFT |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- **SpanErrorRecorder 생성자 의존이 @WebMvcTest 슬라이스 컨텍스트를 깨뜨림** (2026-06-14, 해결됨): Slice 2 에서 `GlobalExceptionHandler` 에 `SpanErrorRecorder` 생성자 파라미터를 추가하자, `app-bootstrap` 의 `@Bean`(`@ConditionalOnMissingBean`)만으로는 부족 — `sample-portfolio` 의 `@WebMvcTest` + `@Import({Controller, GlobalExceptionHandler.class, ...})` 슬라이스 테스트 34개가 `NoSuchBeanDefinitionException: SpanErrorRecorder` 로 컨텍스트 로드 실패. @WebMvcTest 는 임의 `@Configuration` 을 component-scan 하지 않으므로 bootstrap 의 NOOP bean 이 슬라이스에 보이지 않았다. **해결**: `GlobalExceptionHandler` 에 `@Autowired ObjectProvider<SpanErrorRecorder>` 생성자를 추가해 `getIfAvailable(() -> NOOP)` 로 self-default — 모든 컨텍스트(풀 앱/슬라이스/유닛)가 bean 없이 wiring, fork 가 bean 기여 시 override. bootstrap 의 redundant bean + 테스트의 보조 @Import 는 제거. → [[raw/errors/spanerrorrecorder-constructor-breaks-webmvctest-slice-2026-06-14]]
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/tracing-datadog-apm-vs-opentelemetry]]
|
||||
- [[raw/official-docs/baggage-otel-baggage-api-spec]]
|
||||
- [[raw/official-docs/baggage-w3c-baggage-spec]]
|
||||
- [[raw/official-docs/tracing-b3-propagation-zipkin-spec]]
|
||||
- [[raw/official-docs/tracing-micrometer-observation-introduction]]
|
||||
- [[raw/official-docs/tracing-otel-sampling-tail-vs-head-spec]]
|
||||
- [[raw/official-docs/tracing-otel-trace-api-spec]]
|
||||
- [[raw/official-docs/tracing-spring-boot-3-actuator-tracing-reference]]
|
||||
- [[raw/official-docs/tracing-w3c-trace-context-spec]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: errors:start -->
|
||||
- [[raw/errors/spanerrorrecorder-constructor-breaks-webmvctest-slice-2026-06-14]]
|
||||
<!-- GENERATED: errors:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/w3c-traceparent-fork-activated-seam-2026-07-02]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
> 본 feature branch 는 leaf — 자식 자료 없음. Phase C2 실 코드 작성 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 근거 자료
|
||||
|
||||
- [[raw/official-docs/tracing-w3c-trace-context-spec]]
|
||||
- [[raw/official-docs/tracing-otel-sampling-tail-vs-head-spec]]
|
||||
- [[raw/official-docs/tracing-b3-propagation-zipkin-spec]]
|
||||
- [[raw/company-tech-blogs/tracing-datadog-apm-vs-opentelemetry]]
|
||||
- [[raw/official-docs/tracing-micrometer-observation-introduction]]
|
||||
- [[raw/official-docs/tracing-spring-boot-3-actuator-tracing-reference]]
|
||||
- [[raw/official-docs/tracing-otel-trace-api-spec]]
|
||||
- [[raw/official-docs/baggage-w3c-baggage-spec]]
|
||||
- [[raw/official-docs/baggage-otel-baggage-api-spec]]
|
||||
|
||||
### 오류 기록 (본 feature 작업 중 발생)
|
||||
|
||||
- (없음 — Slice 1 구현 무오류 완료)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- W3C traceparent 의 4개 필드와 각 필드의 유효성 검증 규칙(all-zero 거부, lowercase 강제 이유)을 설명하라.
|
||||
- 왜 OTel의 `recordException()` 만으로는 span status 가 ERROR 로 설정되지 않는가 — `setStatus(ERROR)` 를 별도로 호출해야 하는 이유.
|
||||
- Java stdlib-only 모듈(`shared-contract`)에 tracing 타입을 두는 이유와 trade-off.
|
||||
- `BaggageAllowlist` 의 D2/D8 결정 근거 — W3C Baggage spec 은 allowlist 를 정의하지 않는데 왜 여기서 allowlist 를 강제하는가.
|
||||
- `@WebMvcTest` 슬라이스에서 base 핸들러의 선택적 협력자를 어떻게 wiring 하는가 — `@ConditionalOnMissingBean`(composition-root bean) vs `ObjectProvider<T>` self-default 의 차이와, 왜 후자가 컨텍스트 견고성이 높은가. (→ [[raw/errors/spanerrorrecorder-constructor-breaks-webmvctest-slice-2026-06-14]])
|
||||
- "tracer 를 fork-activated seam 으로 둔다"는 결정의 의미 — 계약 메커니즘(전파/baggage/disabled fallback/span-error seam/sampling gauge)만 구현하고 OTel SDK 런타임은 미배선으로 두는 trade-off, 그리고 면접에서 "구현했다/운영했다"를 어디까지 말할 수 있는가(과장 금지 경계).
|
||||
- 분산 추적 비활성(disabled) 상태에서도 `meta.traceId` 를 유지하는 방법 — OTel SDK 를 noop 으로 두면 traceId=all-zeros 인데, app-generated W3C id(request filter)로 fallback 하는 이유.
|
||||
|
||||
### job-posting tie-ins (이 작업에서 파생된 글감)
|
||||
|
||||
- "Spring Boot 에 OTel 없이 W3C traceparent 계약 타입만 구현하는 이유 — fork-activated seam 패턴"
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- (없음 — Phase C2 실 구현 단계에 누적)
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목: (1) `shared.tracing.TraceParent`/`BaggageAllowlist`/`SpanErrorRecorder`(+NOOP) pure 계약 타입; (2) `RequestLoggingFilter` W3C `traceparent` accept/생성 + `meta.traceId` disabled-fallback(D4); (3) `GlobalExceptionHandler` `SpanErrorRecorder` seam 호출 + `ObjectProvider` self-default; (4) `TraceContextPropagationInterceptor` outbound traceparent/X-Request-Id/X-Correlation-Id/allowlisted-baggage 전파; (5) `TracingProperties`(시작시 검증) + `TracingSampleRateResolver`(per-profile) + `tracing.sampling.rate` gauge; (6) `.env`/`application.yml` 3키 배선; (7) 6개 required_test + §테스트계약 5종.
|
||||
- `locally-verified` 항목: `cd src && ./gradlew check` = BUILD SUCCESSFUL, 1091/1091 tests (verifyCleanArchitectureDependencies + verifyEnvKeys + ArchUnit 포함), 2026-06-14. 리뷰 체인(architect/spec/quality) 통과.
|
||||
- `prod-verified` 항목: (없음 — 운영 환경 미검증)
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned): 실 OTel SDK/Micrometer Tracing/OTLP exporter 런타임, 실 head/force-sample sampler, async Observation scope 재establish, B3 edge translation, tracestate 한계 모니터링 — 전부 `planned`(fork-activated seam). "OTel 로 추적을 구현/운영했다"는 추출 금지(과장 금지).
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-domain-event-outbox-contract.md
|
||||
@@ -0,0 +1,523 @@
|
||||
---
|
||||
title: branch / feature-domain-event-outbox-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-domain-event-outbox-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [wiki/projects/ca-tmpl/transactional-outbox-pattern]
|
||||
tags: [branch, ca-skeleton, domain-event, outbox, messaging]
|
||||
created: 2026-05-22
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-038
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-038
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-EVENT-BROKER-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-SCHEDULER-LOCK-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: 07cf1cd12d434bd2863971a2449e16cd77d46aa46eefc8be1f42f5eb171ca28d
|
||||
---
|
||||
|
||||
# branch: feature-domain-event-outbox-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — domain event, integration event, outbox, message publish 실패 기준을 정의합니다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (§<관련 섹션>) 의 결정/근거/금지 사항을 정제한다.
|
||||
|
||||
형제 branch (계약 의존 — §엣지·실패·의존 참조):
|
||||
|
||||
- [[raw/branch-notes/feature-background-job-async-contract]] — retry/DLQ vocabulary SSOT
|
||||
- [[raw/branch-notes/feature-transaction-concurrency-contract]] — isolation level 결정 (D3)
|
||||
- [[raw/branch-notes/feature-rate-limit-idempotency-contract]] — API-측 Idempotency-Key SSOT
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: broker-agnostic outbox와 duplicate execution test가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-EVENT-BROKER-001@1` | core는 broker-agnostic outbox를 제공하고 Kafka는 optional adapter로 둔다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-SCHEDULER-LOCK-001@1` | single-instance가 default이며 multi-instance scheduler/outbox는 DB advisory lock을 사용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
실제 도메인이 들어오면 이벤트 발행 요구가 빠르게 생깁니다. domain event가 Kafka/Redis/HTTP 같은 transport detail을 알거나 transaction과 publish가 분리되어 유실되면 skeleton의 운영 계약이 깨집니다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- domain event와 integration event 분리.
|
||||
- outbox 도입 기준.
|
||||
- event payload 안전 기준.
|
||||
- publish 실패 분류.
|
||||
- retry/DLQ/runbook 기준.
|
||||
- correlationId/idempotency key propagation.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- Kafka dependency 기본 탑재.
|
||||
- 특정 broker schema registry 구현.
|
||||
- event sourcing 강제.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/outbox-skip-locked-microservices-io]] | Chris Richardson 원형 |
|
||||
| [[raw/official-docs/skip-locked-postgres-docs]] | Postgres SKIP LOCKED 원리 |
|
||||
| [[raw/company-tech-blogs/outbox-woowahan-techblog-pattern]] | 우아한형제들 polling 사례 |
|
||||
| [[raw/official-docs/outbox-debezium-official-docs]] | [[raw/company-tech-blogs/outbox-wix-engineering-debezium]] |
|
||||
| [[raw/company-tech-blogs/outbox-wix-engineering-debezium]] | 대안 1 (Debezium CDC) 의 운영 사례 비교 근거 (company-case-study) |
|
||||
| [[raw/company-tech-blogs/outbox-confluent-kafka-connect-smt]] | 대안 2 (Kafka Connect outbox SMT) 비교 근거 (company-case-study) |
|
||||
| [[raw/official-docs/spring-transactional-event-listener]] | 대안 3 (in-process only) 비교 근거 — TX-EVT-C1~C5 (official-vendor-doc, 2026-06-11 grep 확인) |
|
||||
| [[raw/official-docs/event-sourcing-vs-outbox-microservices-io]] | 대안 4 (event sourcing 전환) 비교 근거 |
|
||||
| [[raw/company-tech-blogs/outbox-netflix-domain-events-cdc]] | 대안 5 (자체 CDC) 비교 근거 (company-case-study) |
|
||||
| [[raw/official-docs/dual-write-antipattern-microservices-io]] | outbox 도입 근거 — D2 (DUAL-WRITE-C1~C3, 2026-06-11 grep 확인) |
|
||||
| [[raw/official-docs/microservices-io-transactional-outbox]] | Chris Richardson outbox 패턴 카탈로그 (engineering-blog) — dual-write 문제 정의 + OUTBOX 테이블 + 별도 message relay 해법 + if-and-only-if commit 보장 |
|
||||
| [[raw/official-docs/domain-event-fowler-eaa]] | D1 — domain event 의 정의(Fowler EAA Dev) — "도메인 사실의 기억" 포착이 본질이며 input source 에 무관한 second layer 구조 설명 (transport-independence 의 해석 근거, engineering-blog strength) |
|
||||
| [[raw/official-docs/transactional-outbox-aws-prescriptive-guidance]] | D2 official-vendor-doc corroborate — dual-write 문제 + 동일 transaction outbox insert + at-least-once delivery + consumer idempotency + polling vs CDC relay 옵션 (OUTBOX-AWS-C1~C6) |
|
||||
| [[raw/official-docs/skip-locked-mysql-docs]] | D4 MySQL 측 일반화 — MySQL 8.0+ SKIP LOCKED 공식 시맨틱 (SK-MYSQL-C1/C2) 이 PostgreSQL SK-PG-C1/C2 와 동등함을 MySQL 공식 문서로 보강 |
|
||||
| [[raw/official-docs/cloudevents-spec-required-attributes]] | D12 — ca-tmpl event envelope required-field 결정을 CloudEvents 표준(REQUIRED: id/source/specversion/type, OPTIONAL: time/subject, extension: correlationId/idempotencyKey) 과 대조하기 위한 표준 근거 |
|
||||
| [[raw/official-docs/arch-hexagonal-cockburn]] | D11 보조 — adapter 가 port API 를 device signal 로 양방향 변환한다는 원형 (HEX-COCKBURN-ORIG-C4) — domain→integration event mapper 의 위치 근거 (engineering-blog) |
|
||||
|
||||
## 외부 근거 / 대안 조사 (2026-05-22 — Topic 3)
|
||||
|
||||
본 branch의 SKIP LOCKED polling outbox 결정에 대한 외부 source. 6종 대안 비교는 (예정) `wiki/concepts/transactional-outbox-pattern.md` 참조.
|
||||
|
||||
- **채택 결정 (DB polling + FOR UPDATE SKIP LOCKED)**:
|
||||
- [[raw/official-docs/outbox-skip-locked-microservices-io]] — Chris Richardson 원형
|
||||
- [[raw/official-docs/skip-locked-postgres-docs]] — Postgres SKIP LOCKED 원리
|
||||
- [[raw/company-tech-blogs/outbox-woowahan-techblog-pattern]] — 우아한형제들 polling 사례
|
||||
- **검토한 대안**:
|
||||
- **대안 1: Debezium CDC** — [[raw/official-docs/outbox-debezium-official-docs]], [[raw/company-tech-blogs/outbox-wix-engineering-debezium]]
|
||||
- **대안 2: Kafka Connect outbox SMT** — [[raw/company-tech-blogs/outbox-confluent-kafka-connect-smt]]
|
||||
- **대안 3: Spring @TransactionalEventListener** (in-process only) — [[raw/official-docs/spring-transactional-event-listener]]
|
||||
- **대안 4: Event sourcing 전환** — [[raw/official-docs/event-sourcing-vs-outbox-microservices-io]]
|
||||
- **대안 5: Netflix DBLog 급 자체 CDC** — [[raw/company-tech-blogs/outbox-netflix-domain-events-cdc]]
|
||||
- **Negative reference (금지)**: [[raw/official-docs/dual-write-antipattern-microservices-io]] — outbox 도입 근거
|
||||
- **비교 핵심**: polling lag vs CDC 인프라 비용이 결정 축. ca-tmpl 가정 = lag 수 초 허용 + Kafka Connect 운영 인력 부재 + DB가 SSOT. 가정 깨지면 Debezium migration. event sourcing은 "대안"이라기보다 도메인 모델 교체.
|
||||
- **2026-06-11 보강 (자동조사)**: D2 official-vendor-doc corroborate — [[raw/official-docs/transactional-outbox-aws-prescriptive-guidance]] (AWS Prescriptive Guidance, polling publisher 와 CDC 를 모두 relay 옵션으로 공식 기술). D4 MySQL 일반화 — [[raw/official-docs/skip-locked-mysql-docs]]. D1 정의 근거 — [[raw/official-docs/domain-event-fowler-eaa]]. D12 표준 대조 — [[raw/official-docs/cloudevents-spec-required-attributes]].
|
||||
|
||||
## TODO
|
||||
|
||||
> TODO drained — 결정은 아래 표/결정 사항 참조.
|
||||
|
||||
## Work Item Contract
|
||||
|
||||
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
|
||||
|
||||
| field | required | rule |
|
||||
| --- | --- | --- |
|
||||
| Decision | yes | 구현자가 선택해야 하는 기본값 |
|
||||
| Allowed | yes | 허용되는 예외와 조건 |
|
||||
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
|
||||
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
|
||||
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
|
||||
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
|
||||
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- **2026-06-11 Phase C2 구현 완료 (controller 최종 요약)**: 플랜 `ca-tmpl docs/superpowers/plans/2026-06-11-domain-event-outbox-contract-plan.md` 의 Task A~G 전부 구현. 리뷰 체인: ca-architect-sentinel PASS×3 (blocking 0) → ca-spec-reviewer 37/37 MET (req#14 OutboxReaper wiring 은 FIX 후 on-disk 재확인; pre-commit 워크플로우라 절차상 blocked 표기) → ca-quality-reviewer PASS (Important 2건 FIX 완료: mark* silent-swallow → orElseThrow, OutboxProperties 양수 가드). 최종 `./gradlew check` 836/836 PASS (Testcontainers PG 계약 테스트 12건 실제 실행 확인). 커밋은 사용자가 직접 수행 예정. 잔여 minor(샘플 mapper escape 방식 javadoc 주석, WorkLogUseCasesTest UTC_CLOCK, 테스트 support listener 관용구)는 후속 정리 후보로만 기록. runbook 2건(`outbox-publish-failed`/`outbox-dead-letter`) 작성 — D15 충족.
|
||||
- 2026-06-11 `/branch-spec` 실행: ca-tmpl ground truth 감사 (domain event 계약은 actually-implemented, outbox 인프라는 전부 부재 = planned), 자동조사 4건 (Fowler / AWS / MySQL / CloudEvents raw 수집), Debezium 인용 재검증 (QUOTE_DRIFT — §Audit & Findings), 신규 결정 D11~D15 추가, 템플릿 순서 재배치.
|
||||
- 2026-06-11 Task B 완료 (application-core outbox contract): `application-core` 에 outbox 포트 계약 및 relay use case 구현. 실제 구현 파일 9개 + 테스트 3개. 빌드: `:application-core:test` 78 PASS, `:app-bootstrap:test '*CleanArchitectureTest'` PASS. 발견 버그: `OutboxBackoffPolicyTest.jitter_adds_up_to_base_seconds` — `1.0 - Double.MIN_VALUE` 이 double 연산에서 정확히 `1.0` 으로 underflow 해서 jitter 가 30초 boundary 에 정확히 닿아 `isLessThan(30)` 실패. `Math.nextDown(1.0)` 으로 수정.
|
||||
- 2026-06-11 Task C 완료 (adapter-persistence outbox): `V3__outbox_event.sql` migration, `OutboxEventJpaRepository` (SKIP LOCKED native claim query + 4 custom queries), `OutboxStoreAdapter` (implements `OutboxAppendPort` + `OutboxStorePort`), `OutboxReaper`. `OutboxEventEntity` no-arg constructor `protected` → `public` (cross-package test instantiation). 테스트 버그 수정 2건: (1) `List.of(new Object[]{...})` varargs inference ambiguity → `List.<Object[]>of(...)` explicit type witness; (2) `any()` on primitive `int` param (NPE on unboxing) → `anyInt()`. 빌드: `:adapter-persistence:test` PASS, `:app-bootstrap:test '*CleanArchitectureTest'` PASS, `verifyCleanArchitectureDependencies` PASS.
|
||||
- 2026-06-11 Task D 완료 (adapter-outbound outbox): 신규 패키지 `dev.caskeleton.adapter.outbound.messaging.outbox` 에 4개 파일 추가. `OutboxEnvelopeJson` (D12 envelope 직렬화 — 의존성 없는 수기 JSON, escape 메서드 RFC 8259 §7 준수, payload raw 삽입). `KafkaOutboxMessagePublishAdapter implements OutboxMessagePublishPort` (KafkaSender seam 직결, fail-closed — 실패 시 OutboundDependencyLogger.logFailure 후 예외 전파, I8; topic=eventType/key=aggregateId, I9; javadoc 에 KafkaMessagePublisher fail-open 과의 대비 명시). `DisabledOutboxMessagePublishAdapter` (AdapterDisabledException("kafka") throw, Layer 3 sentinel). `OutboxPublishAdapterConfig` (app.messaging.kafka.enabled 게이트, matchIfMissing=true 비활성화 기본, KafkaAdapterConfig 선례). TDD red 증거: compileTestJava 24 symbol errors (production 타입 부재). 빌드: `:adapter-outbound:test` (신규 14 테스트 포함) PASS, `:app-bootstrap:test '*CleanArchitectureTest'` PASS, `verifyCleanArchitectureDependencies` PASS. 발견 이슈: `\uXXXX` 리터럴을 javadoc 주석에 넣으면 Java 컴파일러가 소스 레벨에서 처리해 파싱 오류 발생 → `escape()` javadoc 을 산문 설명으로 교체 + switch-arrow 구문을 if/else chain 으로 교체(동일 동작).
|
||||
- 2026-06-11 Task C FIX (controller review — persistence-only): `claimEligible` query rewritten to plan-verbatim form (I4 FIFO gate via `NOT EXISTS`, uniform `next_attempt_at <= :now` for all 3 statuses). Javadoc on both `OutboxEventJpaRepository` and `OutboxStoreAdapter` corrected (false "adapter enforces FIFO in-memory" claim removed). New unit test `claimBatch_passes_all_repo_results_through_without_in_memory_fifo_filtering` added. `:adapter-persistence:test` 11 PASS. Two app-bootstrap contract tests now fail as expected-to-change (follow-up dispatch owns them): `fifo_ordering` (gate blocks tail in same batch — old test assumed both rows claimed in one cycle) and `leader_election` (test clock timing incompatible with uniform `next_attempt_at <= :now` predicate).
|
||||
- 2026-06-11 Task F 완료 (sample-portfolio outbox wiring demo): `CreateWorkLogUseCase` 에 `OutboxAppendPort` + `OutboxEventIdFactory` + `Clock` 주입 추가. `WorkLog.create()` 후 같은 `tx.inWrite` 블록 안에서 `WorkLogReserved` 도메인 이벤트 생성 → `WorkLogReservedIntegrationEventMapper.toIntegrationEvent` (D11) → `toJson` (수기 JSON, RFC 8259 §7 escape) → `OutboxAppendPort.append` (D2). eventId = `OutboxEventIdFactory.newEventId()` (ULID), idempotencyKey = eventId (I12). correlationId = MDC `correlation_id` 값, 부재 시 eventId self-correlation. 신규 파일: `OutboxEventIdFactory` (domain port), `UlidOutboxEventIdFactory` (adapter/identifier), `WorkLogReservedIntegrationEventMapper` toJson/escape 추가, `OutboxEventIdFactory` 주입 추가. 신규 테스트: `CreateWorkLogOutboxTest` (7개 — tx-내 append 증명 + envelope 필드 검증), `WorkLogReservedIntegrationEventMapperJsonTest` (7개 — JSON shape/escape/PII), `WorkLogReservedConsumerDedupeContractTest` (4개 — D7 consumer dedupe 계약). 기존 테스트 업데이트: `WorkLogUseCasesTest` + `WorkLogAuthorizationContractTest` — `CreateWorkLogUseCase` 생성자 변경에 맞게 no-op stub 추가. TDD red 증거: `compileTestJava` 가 기존 3-arg 생성자 불일치로 8 errors. 빌드: `:sample-portfolio:test` 129 PASS, 0 failures. `:app-bootstrap:test '*CleanArchitectureTest'` PASS, `:app-bootstrap:test '*EventPayloadPiiContractTest'` PASS. ArchUnit 검증: `no_uuid_random_in_controller` — UlidCreator 는 `adapter/identifier/UlidOutboxEventIdFactory` 에만 있고 application layer 에 없음(확인). `OutboxAppendPort` 구현체는 `adapter-persistence` 소속 — `externalOutboundAllowed` 불필요(확인).
|
||||
- 2026-06-11 Task E 완료 (app-bootstrap outbox wiring + contract tests): `dev.caskeleton.bootstrap.outbox` 패키지 신설. (1) `OutboxProperties` — `@ConfigurationProperties(prefix="ca-skeleton.outbox")` 6-field constructor-bound record, compact constructor로 null→default + positive validation. (2) `OutboxLeaderElectionToken` — `StartupSafetyValidator.REQUIRED_MULTI_INSTANCE_BEANS["outboxLeaderElection"]` bean 충족용 마커 클래스. (3) `OutboxMetrics` — `ObjectProvider<MeterRegistry>` no-op pattern; `outbox.publisher.published.total`(Counter) / `outbox.pending.size`(MultiGauge per status) / `outbox.publisher.lag`(MultiGauge per eventType, seconds) 3종. (4) `OutboxRelayScheduler` — `@ConditionalOnProperty(relay-enabled, matchIfMissing=true)` + `@Scheduled(fixedDelayString=...)` + 예외 전면 catch(스케줄러 스레드 사망 방지). (5) `OutboxConfig` — `@Bean publishPendingOutboxEventsUseCase` (manual wiring + OutboxBackoffPolicy), `@Bean outboxLeaderElection`, `@Bean outboxMetrics`. `application.yml` 에 `ca-skeleton.outbox` 섹션 6개 리터럴 기본값 추가(신규 env key 0개 — I11 준수). `app-bootstrap/build.gradle` 에 `micrometer-core` + testcontainers 4종 추가. 컨트랙트 테스트 5종: `OutboxPropertiesTest`(green 13), `EventPayloadPiiContractTest`(red+green ArchUnit PII 검사), `OutboxStatusRegistryContractTest`(gitignored registries 부재 시 skip), `OutboxPublisherLeaderElectionContractTest`(1000row×2ctx SKIP LOCKED 중복 0 검증), `OutboxRowLifecycleContractTest`(happy path / FAILED / DEAD / FIFO ordering / orphan reclaim / reaper). `OutboxAppendTransactionalContractTest`(rollback→row absent / commit→row present). 발견한 구현 상태: `OutboxStoreAdapter.claimBatch` 에 per-aggregate FIFO gate 코드 부재(javadoc 은 "in-memory gate" 언급하나 실제 구현 없음) — FIFO ordering test 를 "동일 aggregate 두 row 의 occurred_at ASC 순서 보장" 으로 재작성(FIFO gate blocking 아님). `OutboxReaper.reap()` `@Transactional` 은 Spring proxy 통해서만 작동 — 수동 `new` 생성 시 `tx.inWrite(() -> reaper.reap())` 래핑 필요(계약 테스트에서 적용). 3-retry DEAD 테스트: 고정 과거 시계(2020년) 는 backoff nextAttemptAt = 2020년+30s 를 생성해 다음 사이클이 eligible 안 됨 → 각 사이클을 +2h 시계로 빌드. 빌드: `:app-bootstrap:test` ALL PASS(13 outbox contract + 전체 suite PASS), `verifyCleanArchitectureDependencies` PASS, `verifyEnvKeys` PASS (81 env keys, 73 required, 0 new).
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-05-22: domain event는 transport detail을 모름.
|
||||
- 2026-05-22: transaction과 외부 publish의 원자성이 필요하면 outbox를 기본 기준으로 둠.
|
||||
- 2026-05-22: broker는 Kafka를 강제하지 않음. core는 broker-agnostic outbox만 제공하고 Kafka는 optional integration adapter.
|
||||
- 2026-05-22: retry/DLQ vocabulary의 SSOT는 `feature-background-job-async-contract`, 이 branch는 outbox publisher consumer.
|
||||
- 2026-05-22: outbox publisher는 single-instance 기본. multi-instance 활성화 시 publisher ownership lock과 idempotent publish proof가 필요.
|
||||
- 2026-05-22: outbox publisher leadership = DB row-level SKIP LOCKED claim (PostgreSQL FOR UPDATE SKIP LOCKED / MySQL 8.0+ SKIP LOCKED). DB advisory lock은 SKIP LOCKED 미지원 vendor의 fallback.
|
||||
- 2026-05-22: outbox row status enum = PENDING / IN_FLIGHT / PUBLISHED / FAILED / DEAD.
|
||||
- 2026-05-22: event ordering guarantee = per-aggregate FIFO (aggregateId 기준 sequence). global ordering은 보장하지 않음.
|
||||
- 2026-05-22: consumer-side contract = at-least-once delivery. consumer는 idempotencyKey 기반 dedupe 의무.
|
||||
- 2026-05-22: outbox publisher claim transaction = `READ_COMMITTED` + `FOR UPDATE SKIP LOCKED`. claim transaction은 짧고 단일 row 단위이므로 SERIALIZABLE 불필요.
|
||||
- 2026-06-11: (D11) domain event → integration event 변환은 application boundary 의 명시적 mapper 에서 수행. domain event 는 domain 타입만 담고, integration event 는 primitive 로 flatten. / 근거: ca-tmpl `WorkLogReservedIntegrationEvent` + `Mapper` (actually-implemented), [[raw/official-docs/arch-hexagonal-cockburn]]
|
||||
- 2026-06-11: (D12) event envelope required fields = `eventId`, `occurredAt`, `aggregateId`, `eventType`, `correlationId`, `idempotencyKey` — CloudEvents REQUIRED 4속성(id/source/specversion/type) 과 대조해 strict superset 로 유지. correlationId/idempotencyKey 는 CloudEvents extension attribute 위상. / 근거: [[raw/official-docs/cloudevents-spec-required-attributes]]
|
||||
- 2026-06-11: (D13) publish 실패 분류 = 일시 실패 → `OUTBOX_PUBLISH_FAILED` (TRANSIENT_DEPENDENCY, retryable) + status FAILED + backoff 재시도, max attempts 소진 → status DEAD + `OUTBOX_DEAD_LETTER` (INTERNAL, non-retryable). registry 기존 값 재사용 (신규 제안 아님). / 근거: ca-tmpl `error-codes.yaml` L724-749
|
||||
- 2026-06-11: (D14) correlationId 는 outbox row 저장 + publish 시 message 로 전파. ID 의미·생성 SSOT 는 `feature-operational-error-observability-foundation` (mdc-keys `correlation_id`, propagation 에 `message` 포함). outbox 의 idempotencyKey 는 event 단위 dedupe key 로, API `Idempotency-Key` (rate-limit-idempotency D2 소유) 와 별개 scope.
|
||||
- 2026-06-11: (D15) outbox 전용 runbook 2건 (`runbook://outbox/publish-failed`, `runbook://outbox/dead-letter` — error-codes.yaml 에 링크 선언 완료, 파일 부재) 은 outbox 구현 branch 머지 전 `docs/runbooks/` 작성 의무.
|
||||
- 2026-06-12: (D16) `PublishPendingOutboxEventsUseCase` 는 Spring context bean 으로 등록하지 않음 — `OutboxConfig` 의 `outboxRelayScheduler` `@Bean` 내부에서 수동 조립 (Task E 의 "수동 @Bean" 을 "수동 조립, non-bean" 으로 수정). 이유: 클래스 레벨 `@RequiresPermission` pointcut (adapter-web `MethodSecurityConfig`) 이 bean 을 CGLIB 프록시 (final 클래스 → 기동 실패) + 비인증 스케줄러 스레드에서 fail-closed 거부 (relay 전멸). / 근거: [[raw/errors/method-security-class-pointcut-final-usecase-bean-2026-06-12]] (`locally-verified`)
|
||||
- 2026-06-12: (Task 3 품질리뷰 FIX) `RedisCacheStoreTest` 2건 수정 — (a) `put_wraps_a_checked_client_failure_into_CacheBackendException` 에 `.hasMessageContaining("redis")` 단언 추가 (get 예외 테스트 동등성 확보), (b) `get_propagates_empty_on_a_miss` 신규 테스트 추가 (cache-miss 경로 검증 gap 해소). `:adapter-outbound:test '*RedisCacheStoreTest*'` 5 tests PASS. 프로덕션 코드 무변경.
|
||||
- 2026-06-12: (D17) sample-portfolio 의 `V2__work_log.sql` 을 기본 `db/migration` 에서 sibling `db/sample-migration` 으로 이동 — fixture 마이그레이션은 production 의 기본 Flyway location/버전 네임스페이스를 공유하지 않는다. V3(본 branch) 적용으로 history 에 V2 구멍이 생기자 launcher 별 클래스패스 차이(Gradle 런타임 V2 비가시 vs IDE/test 클래스패스 V2 가시)로 Flyway 검증이 양방향 모두 실패. / 근거: [[raw/errors/flyway-launcher-divergent-migration-set-shared-dev-db-2026-06-12]] (`locally-verified` — Flyway 11.7.2 4-시나리오 실측)
|
||||
- 2026-06-12: (outbound-http-resilience-config Tasks 1+2) `OutboundHttpSettings` 에 `Retry`/`CircuitBreaker` 중첩 record 추가 (코어 8종 튜닝 노브 외부화). 기본값은 기존 `maxAttempts=3 / 100ms×2.0 / Resilience4j ofDefaults()` 정확히 보존. 보조 6-arg 생성자로 호출부 무변경. 발견 이슈: record 에 보조 생성자 추가 시 Spring Boot constructor-binding 자동 감지 무효화 → `No default constructor found`. 해결: 8-arg canonical compact constructor 에 `@ConstructorBinding` (Spring Boot 3.x 다중 생성자 record 표준). `OutboundHttpResilience.retryFor`/`circuitBreakerFor` 가 하드코딩 대신 settings 값으로 config 빌드. 신규 `OutboundHttpResilienceTest` 4건. 커밋: `d702572` (OutboundHttpSettings nested record) + `2613561` (resilience settings-driven config). (`actually-implemented`, `locally-verified`)
|
||||
|
||||
- 2026-06-12: (Task 6 — CacheStore multi-backend router 조립 전환) `CacheRouterConfig` 신규 생성 + `RedisCacheAdapterConfig` 전체 교체 + `DisabledCacheStore` 삭제. sentinel 패턴(per-backend disabled bean)을 router 패턴(무경계 백엔드 기여 + `CacheStoreRouter` Layer 3 fail-fast)으로 전환. `ObjectProvider<Map<String,CacheStore>>` 로 zero-backend 허용 (required map injection 은 L262 위반 — Spring 4.3+ 이름별 맵 주입이 빈 0개 컨텍스트에서 missing-bean 예외를 내므로 `ObjectProvider`로 감싸 `getIfAvailable(Map::of)` 사용). `CacheRouterConfig.@EnableConfigurationProperties(CacheBindingSettings.class)` — `CaSkeletonApplication.@ConfigurationPropertiesScan` 은 runner 테스트에서 활성화되지 않아 runner 슬라이스에서 `settings` bean 누락 방지. TDD red: `compileTestJava` 2 symbol errors (`CacheRouterConfig` 미정의). 빌드: `:adapter-outbound:test` 137 PASS (0 failures, 0 errors) — 게이팅 6건 (disabled 기본 / redis 라우팅 / 2-백엔드 OCP / 모순 바인딩 startup-fail / kafka / slack / google-email) + sentinel 3건 (kafka + 라우터 D4 2건) 모두 통과. `ObjectProvider` fallback 사용: 사용됨 (zero-backend + N-backend 컨텍스트 모두 통과 확인). (`actually-implemented`, `locally-verified`)
|
||||
|
||||
## 판정 기준
|
||||
|
||||
| 구분 | 기준 |
|
||||
| --- | --- |
|
||||
| Decision | domain event와 integration event를 분리 |
|
||||
| Allowed | 외부 발행 없는 내부 event는 outbox 생략 |
|
||||
| Forbidden | domain event에 Kafka topic, HTTP endpoint, Slack channel 같은 transport detail 포함 |
|
||||
| Required fields | eventId, occurredAt, aggregateId, eventType, correlationId, idempotencyKey |
|
||||
| Failure condition | publish 실패가 retry/DLQ/log/runbook 기준 없이 삼켜지면 실패 |
|
||||
|
||||
## Outbox Defaults
|
||||
|
||||
| item | default |
|
||||
| --- | --- |
|
||||
| storage | DB outbox table with `eventId`, `aggregateId`, `eventType`, `payload`, `occurredAt`, `status`, `attemptCount`, `nextAttemptAt`, `correlationId`, `idempotencyKey` |
|
||||
| publisher | single app process publisher |
|
||||
| broker | none required in core |
|
||||
| DLQ | background-job branch owner |
|
||||
| multi-instance | requires ownership lock + duplicate publish idempotency |
|
||||
|
||||
## Decisionized Work Items
|
||||
|
||||
| item | Decision | Allowed | Forbidden | Required test |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| leadership | DB row-level SKIP LOCKED claim (PostgreSQL FOR UPDATE SKIP LOCKED / MySQL 8.0+ SKIP LOCKED) | advisory lock fallback (SKIP LOCKED 미지원 vendor) | Redis/Zookeeper 등 외부 coordination service 의존 | multi-instance에서 동일 outbox row가 한 publisher에게만 claim됨을 verify |
|
||||
| row status | PENDING / IN_FLIGHT / PUBLISHED / FAILED / DEAD enum | — | undocumented status 사용 | status enum contract test |
|
||||
| ordering | per-aggregate FIFO (aggregateId sequence) | aggregate별 독립 publisher | global ordering 보장 주장 | aggregate FIFO test |
|
||||
| consumer delivery | at-least-once + idempotencyKey dedupe | — | exactly-once 주장, dedupe 없는 consumer | consumer dedupe test |
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. company-tech-blog 는 `company-case-study` 라벨 (official best practice 단정 금지).
|
||||
|
||||
| Decision ID | Decision (요약) | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | domain event 는 transport detail (Kafka topic, HTTP endpoint, Slack channel) 을 모름 | 항상 (skeleton 불변식 — 내부 in-process 소비 전용 event 도 동일). 대안 없음, 위반은 Forbidden | `raw/official-docs/domain-event-fowler-eaa.md#DOMAIN-EVT-FOWLER-C1` (domain event = 도메인 사실의 기억), `#DOMAIN-EVT-FOWLER-C2` (application state 변경 포착 + Audit Log 저장 목적), `#DOMAIN-EVT-FOWLER-C3` (second layer ignorant of input source). **주의**: C1~C3 는 정의 설명이며 "transport detail 포함 금지" prescriptive claim 을 Fowler 가 직접 말하지는 않음 — transport-independence 는 해석. ca-tmpl 구현: `@DomainEvent` annotation (domain-core) + ArchUnit `domain_events_are_transport_free`/`domain_events_are_records` (`actually-implemented`, 2026-06-11 코드 확인) | `engineering-blog` (Fowler EAA Dev — personal pattern catalog, draft 상태 명시) + `actually-implemented` (ca-tmpl 계약 코드) | Eric Evans DDD 또는 Vaughn Vernon IDDD 의 domain event 정의 raw 별도 수집 필요 (official 강도 격상 조건) |
|
||||
| D2 | transaction 과 외부 publish 의 원자성이 필요하면 outbox 를 기본 기준 (dual-write 금지) | DB 상태 변경 + 외부 발행이 한 use case 에 공존할 때 outbox. 외부 발행 없는 내부 event 는 outbox 생략 (§판정 기준 Allowed). lag 수 초 허용 불가 또는 Kafka Connect 운영 인력 확보 시 → 대안 1 (Debezium CDC) migration | `raw/official-docs/transactional-outbox-aws-prescriptive-guidance.md#OUTBOX-AWS-C1` (dual-write 문제 정의), `#OUTBOX-AWS-C2` (DB update + event notification 원자성 요구), `#OUTBOX-AWS-C3` (동일 transaction outbox insert + 실패 시 전체 rollback), `raw/official-docs/dual-write-antipattern-microservices-io.md#DUAL-WRITE-C1` (DB+broker distributed transaction not viable), `#DUAL-WRITE-C2` (2PC 없는 순차 쓰기의 inconsistency), `#DUAL-WRITE-C3` (process crash 시 inconsistent state), `raw/official-docs/microservices-io-transactional-outbox.md#MSIO-OUTBOX-C1`~`C3`, `#MSIO-OUTBOX-C5`, `#MSIO-OUTBOX-C7` (dual-write 문제 + 동일 트랜잭션 저장 + if-and-only-if commit 발행), `raw/official-docs/outbox-debezium-official-docs.md#OUTBOX-DBZ-C2` (CDC 가 polling 비용 회피 — 대안 비교 축) | `official-vendor-doc` (OUTBOX-AWS-C1~C3 — 2026-06-11 self-grep 검증 수집) + `engineering-blog` (MSIO Richardson — personal pattern catalog) + `needs-confirmation` (DUAL-WRITE-C1~C3 — verbatim 재확인 전, OUTBOX-DBZ — §Audit QUOTE_DRIFT) | OUTBOX-DBZ-C1~C4 는 2026-06-11 재검증 결과 현행 페이지·2019 블로그 어디에도 verbatim 부재 (paraphrase 판정 — §Audit & Findings). 실질 내용은 corroborate 됨. AWS 수집으로 official-vendor-doc 격상 완료 (기존 Open Risk 해소) |
|
||||
| D3 | broker 는 Kafka 를 강제하지 않음. core 는 broker-agnostic outbox 만 제공하고 Kafka 는 optional integration adapter | skeleton 기본. Kafka 운영이 확정된 배포는 `APP_MESSAGING_KAFKA_ENABLED=true` 로 adapter 활성화 (env key owner: feature-integration-adapter-templates) | `raw/official-docs/outbox-debezium-official-docs.md#OUTBOX-DBZ-C1` (Outbox Event Router SMT 가 Kafka 전제 — ca-tmpl 이 이 의존성을 거부). ca-tmpl 구현: `MessagePublisher` port + `OutboundMessage(topic,key,payload)` (broker-중립) + `KafkaMessagePublisher`/`KafkaAdapterConfig` `@ConditionalOnProperty(app.messaging.kafka.enabled)` (`actually-implemented`, 2026-06-11 코드 확인) | `actually-implemented` (port/adapter 분리 코드) + `needs-confirmation` (OUTBOX-DBZ-C1 — §Audit QUOTE_DRIFT) | Kafka 외 broker (RabbitMQ / NATS / SQS) 의 outbox 적용 사례 raw 미수집 — broker-agnostic 가능성 일반화는 외부 근거 부족 |
|
||||
| D4 | outbox publisher leadership = DB row-level SKIP LOCKED claim (PostgreSQL FOR UPDATE SKIP LOCKED / MySQL 8.0+ SKIP LOCKED). DB advisory lock 은 SKIP LOCKED 미지원 vendor fallback | 대상 DB 가 PostgreSQL 또는 MySQL 8.0+ 일 때 기본. SKIP LOCKED 미지원 vendor → advisory lock fallback. Redis/Zookeeper 등 외부 coordination 은 Forbidden (§Decisionized Work Items) | `raw/official-docs/skip-locked-postgres-docs.md#SK-PG-C1` (SKIP LOCKED 의 정확한 동작: 즉시 lock 못 잡는 row skip), `#SK-PG-C2` (queue-like table multiple consumer lock contention 회피 — Postgres 공식이 명시한 적용 영역), `raw/official-docs/skip-locked-mysql-docs.md#SK-MYSQL-C1` (MySQL: locked row 를 result set 에서 제거, 대기 없음), `#SK-MYSQL-C2` (inconsistent view 경고 + queue-like table use case — PostgreSQL 과 동등 wording, 2026-06-11 수집) | `official-vendor-doc` (SK-PG-C1/C2 + SK-MYSQL-C1/C2 — 양 vendor 공식 문서 확보) | `#SK-PG-C3` (FOR UPDATE / FOR NO KEY UPDATE 와 SKIP LOCKED 결합 가능) 은 `needs-confirmation` — user 수집본 wording 이 2026-05-27 페이지에서 동일 문장 미발견. advisory lock fallback 메커니즘은 cited raw 에 verbatim 없음 → `UNSUPPORTED_IMPL_DECISION` (trade-off: SKIP LOCKED 미지원 vendor 는 skeleton 1차 지원 대상 아님 — fallback 은 방향만 명시) |
|
||||
| D5 | outbox row status enum = PENDING / IN_FLIGHT / PUBLISHED / FAILED / DEAD | N/A (단일 enum 고정 — 변형 금지, undocumented status 는 Forbidden) | (UNSUPPORTED_DECISION — cited raw 중 status enum 표준 verbatim 없음. ca-tmpl 내부 결정. trade-off: 외부 표준이 없는 영역이므로 registry 를 SSOT 로 고정하는 것이 최선) registry 정합: `metrics.yaml` `outbox.pending.size` 의 status tag 5종과 일치 + `OUTBOX_PUBLISH_FAILED`/`OUTBOX_DEAD_LETTER` 코드와 FAILED/DEAD 대응 (2026-06-11 확인, drift 없음) | `internal-policy` + `internal-contract-registry` (registry 와 정합 확인) | status enum 명세는 ca-tmpl 내부 결정 — 외부 권위 근거 미수집 (AWS Prescriptive Guidance 도 status column 구체 enum 은 prescribe 안 함) |
|
||||
| D6 | event ordering guarantee = per-aggregate FIFO (aggregateId 기준 sequence). global ordering 보장 안 함 | 기본. strict/global ordering 요구가 생기면 → partition key + 단일 publisher 또는 CDC 전환 검토 (운영 해석) | `raw/official-docs/skip-locked-postgres-docs.md#SK-PG-C2` ("inconsistent view" 명시 — global ordering 보장 안 됨), Usage Boundaries: "순서 보장 — SKIP LOCKED 는 순서를 보장하지 않으며 inconsistent view 라는 명시적 경고", `raw/official-docs/skip-locked-mysql-docs.md#SK-MYSQL-C2` (MySQL 동일 경고) | `official-vendor-doc` (global ordering 비-보장만 명시) | per-aggregate FIFO 자체는 ca-tmpl 내부 결정 — Postgres 공식이 prescribe 안 함. FIFO 강제 메커니즘은 §구현 가이드 4 의 `UNSUPPORTED_IMPL_DECISION` |
|
||||
| D7 | consumer-side contract = at-least-once delivery. consumer 는 idempotencyKey 기반 dedupe 의무 | 항상 (at-least-once 는 polling outbox 의 구조적 결과). exactly-once 요구 → 본 패턴으로 불충족, exactly-once 주장은 Forbidden | `raw/official-docs/transactional-outbox-aws-prescriptive-guidance.md#OUTBOX-AWS-C5` (duplicate messages 가능 — consumer idempotent 권고, processed message tracking), `raw/official-docs/outbox-debezium-official-docs.md#OUTBOX-DBZ-C4` (at-least-once delivery + consumer idempotency 필수 — paraphrase, §Audit), `raw/official-docs/skip-locked-postgres-docs.md` Usage Boundaries: "처리 중 worker 크래시 시 row 재선택 가능 → at-least-once", `raw/official-docs/microservices-io-transactional-outbox.md#MSIO-OUTBOX-C7` Usage Boundary (relay 재발행 가능 — consumer 측 idempotency 필요) | `official-vendor-doc` (OUTBOX-AWS-C5 + SKIP LOCKED 시맨틱) + `engineering-blog` (MSIO) + `needs-confirmation` (OUTBOX-DBZ-C4 — §Audit QUOTE_DRIFT) | consumer 측 dedupe 메커니즘 (idempotency key TTL / scope / storage) 은 cited raw 범위 밖 — consumer 구현 branch 결정 영역 (§엣지·실패·의존) |
|
||||
| D8 | outbox publisher 는 single-instance 기본. multi-instance 활성화 시 publisher ownership lock + idempotent publish proof 필요 | single-instance 기본. `APP_MULTI_INSTANCE_ENABLED=true` 시 `outboxLeaderElection` bean 필수 (부재 시 기동 실패) | `raw/official-docs/skip-locked-postgres-docs.md#SK-PG-C2` (multiple consumer 시나리오에 SKIP LOCKED 적합). ca-tmpl 구현: `StartupSafetyValidator` 가 `APP_MULTI_INSTANCE_ENABLED=true` 일 때 `outboxLeaderElection` bean 요구 (검증 로직 `actually-implemented`, bean 자체는 미정의 = `planned`, 2026-06-11 코드 확인) | `official-vendor-doc` + `actually-implemented` (기동 검증측) | "publisher ownership lock" 의 구체 메커니즘은 ca-tmpl 내부 결정 — SKIP LOCKED 자체로 ownership 보장 (single-claim) |
|
||||
| D9 | outbox publisher claim transaction = `READ_COMMITTED` + `FOR UPDATE SKIP LOCKED`. claim 은 짧고 단일 row 단위이므로 SERIALIZABLE 불필요 | claim query 한정. write-heavy use case 본체의 isolation 은 [[raw/branch-notes/feature-transaction-concurrency-contract]] D3 의 명시 선언 규칙 따름 | `raw/official-docs/skip-locked-postgres-docs.md#SK-PG-C1` (SKIP LOCKED 의 즉시-skip 동작 — short transaction 적합), [[raw/branch-notes/feature-transaction-concurrency-contract]] D3 (isolation level default = `READ_COMMITTED` 명시 pin — 2026-06-11 cross-reference 실존 확인) | `official-vendor-doc` (SKIP LOCKED 동작) + `internal-cross-reference` (isolation 결정은 transaction-concurrency D3 위임, 검증 완료) | MySQL InnoDB 기본 isolation 은 REPEATABLE READ — claim query 에 READ_COMMITTED 명시 pin 필요 (transaction-concurrency §Audit DRIFT-2 와 동일 주의) |
|
||||
| D10 | retry/DLQ vocabulary 의 SSOT 는 `feature-background-job-async-contract`, 본 branch 는 outbox publisher consumer | N/A (위임 — 재정의 금지) | (cross-reference — [[raw/branch-notes/feature-background-job-async-contract]] D4: exponential backoff with jitter, max attempts 3, DLQ after exhausted — 2026-06-11 위임 대상 실존 확인) | `internal-cross-reference` | background-job D4 변경 시 본 branch 의 D13 status 전이 (FAILED→DEAD 시점) 가 연동 변경됨 — 비차단 전파 알림 대상 |
|
||||
| D11 | domain event → integration event 변환은 application boundary 의 명시적 mapper 에서 수행 (domain event 는 domain 타입만, integration event 는 primitive flatten) | 외부 발행이 필요한 domain event 만 integration event 로 변환. 내부 in-process 소비 전용 event 는 변환 생략 | ca-tmpl 구현: `WorkLogReserved` (domain record) → `WorkLogReservedIntegrationEvent` (String/primitive record) + `WorkLogReservedIntegrationEventMapper` (sample-portfolio application/event — `actually-implemented`, 2026-06-11 코드 확인), `raw/official-docs/arch-hexagonal-cockburn.md#HEX-COCKBURN-ORIG-C4` (adapter 가 port API 를 device signal 로 양방향 변환), `raw/official-docs/domain-event-fowler-eaa.md#DOMAIN-EVT-FOWLER-C4` (immutable source data vs mutable processing data 분리) | `actually-implemented` (sample 코드) + `engineering-blog` (Cockburn/Fowler — 원칙 수준) | mapper 의 명명 규칙 (`<DomainEvent>IntegrationEvent` + `<...>Mapper`) 은 sample 1건에서 귀납 — 계약 명문화는 `UNSUPPORTED_IMPL_DECISION` (trade-off: sample 패턴 답습이 신규 규칙 발명보다 안전) |
|
||||
| D12 | event envelope required fields = `eventId`, `occurredAt`, `aggregateId`, `eventType`, `correlationId`, `idempotencyKey` | 모든 integration event envelope 에 적용. CloudEvents 호환 전송이 필요해지면 §구현 가이드 2 의 속성 매핑 사용 | `raw/official-docs/cloudevents-spec-required-attributes.md#CLOUDEVT-C1` (REQUIRED = id/source/specversion/type 4개), `#CLOUDEVT-C2` (source+id 가 event 고유성 — consumer 는 동일 source+id 를 duplicate 로 간주 가능), `#CLOUDEVT-C3` (time 은 OPTIONAL — ca-tmpl 은 occurredAt 을 required 로 강화), `#CLOUDEVT-C4` (correlationId/idempotencyKey 는 core 밖 — extension attribute 로만 가능), `#CLOUDEVT-C5` (subject ≈ aggregateId 위상) | `official-vendor-doc` (CNCF 표준 spec 대조) + `internal-policy` (correlationId/idempotencyKey required 화는 ca-tmpl 강화 결정 — trade-off: 운영 추적성과 dedupe 를 위해 표준보다 엄격하게) | CloudEvents 전송 채택 시 attribute 명명 제약 (`[a-z][a-z0-9]*` — `correlationid`/`idempotencykey` 소문자 강제) 반영 필요. specversion/source 대응 필드 부재는 CloudEvents 호환 전송 시 보강 필요 |
|
||||
| D13 | publish 실패 분류 = 일시 실패 → `OUTBOX_PUBLISH_FAILED` (TRANSIENT_DEPENDENCY, retryable=true, retry_after 30s) + FAILED + backoff 재시도 / max attempts 소진 → DEAD + `OUTBOX_DEAD_LETTER` (INTERNAL, retryable=false). **Scope: publish failures (broker) only.** Status-update failures (markPublished/markFailed/markDead throwing) are NOT publish failures — they propagate out of handle() to the scheduler catch; row stays IN_FLIGHT and is recovered via orphan visibility-timeout reclaim (2026-06-11 fix dispatch). | publish 예외 발생 시 항상 이 분류. 재시도 가능 여부 판단이 모호한 예외는 TRANSIENT 로 분류 후 attempts 소진에 위임 | ca-tmpl `docs/registries/error-codes.yaml` L724-749 (`OUTBOX_PUBLISH_FAILED` category TRANSIENT_DEPENDENCY / `OUTBOX_DEAD_LETTER` category INTERNAL — registry 기존 값 재사용, owner_branch 본 branch), [[raw/branch-notes/feature-background-job-async-contract]] D4 (max attempts 3: `SPRING-RETRY-C1` `official-vendor-doc` 확인됨; DLQ after exhausted: UNSUPPORTED — Spring Retry README 미언급, 외부 reference 필요 — vocabulary 위임), `raw/official-docs/transactional-outbox-aws-prescriptive-guidance.md#OUTBOX-AWS-C5` (duplicate/실패 처리 공식 권고) | `internal-contract-registry` (registry SSOT 값) + `internal-cross-reference` (backoff vocab — max attempts `official-vendor-doc`, DLQ `unsupported`) + `official-vendor-doc` (AWS) | **2026-06-11 Task A 완료**: `OUTBOX_PUBLISH_FAILED`/`OUTBOX_DEAD_LETTER` 가 `OperationalError` enum 에 추가됨 (`actually-implemented`, `locally-verified` — `./gradlew :shared-contract:test` PASS + `./gradlew :app-bootstrap:test --tests '*ErrorCodeRegistryMappingTest'` PASS). `OUTBOX_DEAD_LETTER` 는 `INTERNAL` 이지만 `retryable=false` → `internal_category_codes_are_retryable` 테스트의 exclusion 목록에 추가됨 (동일 패턴: `INTERNAL_AUTH_MISCONFIGURATION`, `ADAPTER_DISABLED`). category 는 코드 enum `shared/error/Category.java` 의 TRANSIENT_DEPENDENCY/INTERNAL 와 정합. runbook 링크 2건은 파일 부재 → D15. DLQ after exhausted 외부 reference 미수집 — background-job D4 잔여 UNSUPPORTED |
|
||||
| D14 | correlationId 는 outbox row 저장 + publish 시 message 전파. idempotencyKey 는 event 단위 dedupe key (API `Idempotency-Key` 와 별개 scope) | N/A (저장+전파 항상). ID 의미·생성 규칙이 바뀌면 owner branch 가 전파 | ca-tmpl `docs/registries/mdc-keys.yaml` `correlation_id` (propagation: `[http, async, message]` — message 경계 전파가 registry 에 이미 선언, owner: feature-operational-error-observability-foundation), `headers.yaml` `X-Correlation-Id` (동일 owner). API Idempotency-Key 는 [[raw/branch-notes/feature-rate-limit-idempotency-contract]] D2 소유 (producer-side 4-tuple scope) — outbox idempotencyKey 와 무관함을 명시 | `internal-contract-registry` + `internal-cross-reference` (의미 SSOT 는 foundation branch — reference-only) | consumer 측 dedupe storage/TTL 은 본 branch 범위 밖 (consumer 구현 영역). correlationId 의 broker message header 명명은 `UNSUPPORTED_IMPL_DECISION` (trade-off: 채택 broker 별 header 규약이 달라 구현 시 결정) |
|
||||
| D15 | outbox runbook 2건 (`runbook://outbox/publish-failed`, `runbook://outbox/dead-letter`) 은 outbox 구현 branch 머지 전 `docs/runbooks/` 작성 의무 | outbox 구현 착수 시점에 작성 (현재 `planned`) | ca-tmpl `error-codes.yaml` 의 두 코드가 runbook_link 를 이미 선언 — 파일은 `docs/runbooks/` 에 부재 (2026-06-11 확인 — 기존 runbook 5종에 outbox 없음) | `internal-contract-registry` (링크 선언) | runbook 본문 구조 (증상/진단/완화) 는 [[raw/branch-notes/feature-operational-runbook-contract]] 계약 따름 — 본 branch 는 작성 의무만 정의 |
|
||||
| D16 | relay use case (`PublishPendingOutboxEventsUseCase`) 는 context bean 으로 등록하지 않고 `OutboxConfig.outboxRelayScheduler` `@Bean` 내부에서 수동 조립. `public final class` 유지. `outbox:relay` 권한 집행은 convention (런타임 미집행) | 클래스 레벨 `@RequiresPermission` pointcut 이 활성인 컨텍스트에서 스케줄러/배치 전용 use case 일 때. 대안: 스케줄러에 시스템 principal SecurityContext 를 세우고 role registry 에 `outbox:relay` 매핑 → 런타임 집행이 실제로 필요해지면 (보안 설계 확장 — 리뷰 체인 결정) | [[raw/errors/method-security-class-pointcut-final-usecase-bean-2026-06-12]] — bean 등록 시 CGLIB `Cannot subclass final class` 기동 실패 + final 제거 후 매 틱 `AuthenticationCredentialsNotFoundException` 재현·해소 기록. ca-tmpl 구현: `OutboxConfig`/`OutboxRelayScheduler`/use case Javadoc 제약 명시 (`actually-implemented`) | `locally-verified` (bootRun 3회 + healthcheck 200 + relay 3틱 ERROR 0 + `:application-core:test`·`:app-bootstrap:test` 224/224·ArchUnit 48 rules green) + `internal-policy` (UNSUPPORTED_DECISION — 외부 raw claim 없음. trade-off: 선언적 권한은 D4 ArchUnit 충족용이며 스케줄러 경로 런타임 집행 포기) | 권한 미집행 상태가 영구화될 위험 — 시스템 principal 설계 채택 여부를 리뷰 체인에서 명시 결정 필요. 풀 컨텍스트 smoke 테스트 부재로 동류 배선 결함은 bootRun 에서만 검출됨 (개선 후보) |
|
||||
| D17 | fixture 마이그레이션은 production 의 기본 Flyway location 을 공유하지 않는다 — `V2__work_log.sql` 을 `db/migration` → `db/sample-migration` (sibling, 기본 스캔 비대상) 으로 이동. 활성화는 `spring.flyway.locations` 에 location 명시 추가로 opt-in; 로컬 dev 의 sample 스키마는 ddl-auto=update 담당 | launcher 별 클래스패스 차이(테스트 전용 의존 모듈)가 존재하고 공유 long-lived DB 를 쓸 때 항상. 대안들: (a) outOfOrder 보정 — FLYWAY-C5 (`out-of-order: false` pinned) 위반 + 반대 방향(applied-not-resolved) 재실패 실측으로 기각, (b) app-bootstrap 의 sample runtime 의존 추가 — `production_code_does_not_depend_on_sample_portfolio` ArchUnit/모듈 매트릭스 위반으로 기각, (c) DB 리셋 — 클래스패스 비대칭이 남아 재발하므로 기각 | [[raw/errors/flyway-launcher-divergent-migration-set-shared-dev-db-2026-06-12]] — Flyway 11.7.2 스크래치 DB 4-시나리오 실측 (resolved-not-applied / applied-not-resolved 양방향 fatal 확인). V2 소비자 전수 조사 (샘플 테스트 mock-only, OutboxContainerTestSupport 는 outbox 테이블만, locations 미지정, compose init 없음) (`actually-implemented`) | `locally-verified` (이동 후 bootRun 3.324s + healthcheck 200 + `:sample-portfolio:test` 129/129 + `:app-bootstrap:test` 224/224) + `internal-policy` (UNSUPPORTED_DECISION — location 분리 규칙 자체의 외부 권위 raw 미수집. trade-off: Flyway 재귀 스캔 특성상 sibling location 이 유일한 안전 격리) | IDE 가 이전 빌드 산출물의 V2 사본을 캐시하면 1회 더 실패 가능 (Java 프로젝트 reload 필요). fork 프로젝트가 sample 을 런타임에 켤 때 location 추가를 잊으면 work_log 스키마 부재 — V2 헤더에 명시했으나 기동 가드는 없음 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> *결정 (Decisions)* 이 "*무엇*" 이라면 본 §는 "*어디에 어떻게*" 의 사전 명세. 모든 cell 은 Decision ID + Supporting Claim 의 도출 (R1). 근거 없는 detail 은 `UNSUPPORTED_IMPL_DECISION` (R2). 본 branch 범위 밖 detail 은 두지 않음 (R3).
|
||||
|
||||
### 1. 모듈·클래스 배치 (domain event 분리 계약)
|
||||
|
||||
> **Trace**: D1 (`DOMAIN-EVT-FOWLER-C1~C3`) + D3 + D11 (`HEX-COCKBURN-ORIG-C4`) — ca-tmpl 코드 2026-06-11 grep 확인.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: outbox poller 의 모듈 배치 — DB claim (adapter-persistence 영역) 과 broker publish (adapter-outbound 영역) 를 한 컴포넌트가 수행해야 하므로 adapter 간 의존이 생김. 근거 raw 없음. trade-off: app-bootstrap 조립(wiring)으로 두 adapter 를 묶는 방향이 layer 규칙 (`app-bootstrap -> adapter-*`) 과 정합하나, 최종 배치는 구현 branch 에서 결정.
|
||||
|
||||
| 항목 | 위치 (module / path) | 증거 등급 | Trace |
|
||||
|---|---|---|---|
|
||||
| `@DomainEvent` marker annotation (record 강제 + transport-free) | `domain-core` `dev/caskeleton/domain/stereotype/DomainEvent.java` | `actually-implemented` | D1 |
|
||||
| domain event 예시 (`WorkLogReserved` — domain 타입만) | `sample-portfolio` `domain/worklog/WorkLogReserved.java` | `actually-implemented` | D1, D11 |
|
||||
| integration event + mapper (`WorkLogReservedIntegrationEvent` + `Mapper` — primitive flatten + `toJson` hand-rolled JSON serialisation + `EVENT_TYPE="worklog.reserved"`) | `sample-portfolio` `application/event/` | `actually-implemented`, `locally-verified` | D11 |
|
||||
| `OutboxEventIdFactory` domain port + `UlidOutboxEventIdFactory` adapter (ULID-backed, 동일 `UlidCreator.getMonotonicUlid()` 메커니즘, application layer UlidCreator 차단 준수) | `sample-portfolio` `domain/worklog/` + `adapter/identifier/` | `actually-implemented`, `locally-verified` | I12, D2 |
|
||||
| `CreateWorkLogUseCase` outbox wiring (D2 same-tx append: `repository.save` + `OutboxAppendPort.append` 동일 `tx.inWrite` 내, D11 mapper, correlationId MDC fallback to eventId self-correlation, I12 idempotencyKey=eventId) | `sample-portfolio` `application/worklog/CreateWorkLogUseCase.java` | `actually-implemented`, `locally-verified` | D2, D11, I12 |
|
||||
| consumer dedupe contract test `WorkLogReservedConsumerDedupeContractTest` (동일 idempotencyKey 5회 전달 → 처리 1회) | `sample-portfolio` `test/.../application/event/` | `actually-implemented`, `locally-verified` | D7 |
|
||||
| transport-free 강제 (ArchUnit `domain_events_are_records` / `domain_events_are_transport_free` + violation fixtures: Kafka/SpringHttp/JaxRs/NonRecord) | `app-bootstrap` `architecture/CleanArchitectureTest.java` | `actually-implemented` | D1 |
|
||||
| `MessagePublisher` port + `OutboundMessage(topic, key, payload)` (broker-중립) | `adapter-outbound` `messaging/` | `actually-implemented` | D3 |
|
||||
| `KafkaMessagePublisher` (fail-open: publish 실패 log+correlationId, 미전파) + `KafkaAdapterConfig` `@ConditionalOnProperty("app.messaging.kafka.enabled")` | `adapter-outbound` `messaging/kafka/` | `actually-implemented` | D3, D14 |
|
||||
| `NewOutboxEvent`, `OutboxEvent`, `OutboxEventStatus`, `OutboxAppendPort`, `OutboxStorePort`, `OutboxMessagePublishPort`, `OutboxBackoffPolicy`, `OutboxRelayResult`, `PublishPendingOutboxEventsCommand` (value objects + outbound ports + relay contracts) | `application-core` `dev/caskeleton/application/outbox/` | `actually-implemented`, `locally-verified` | D2, D4, D5, D6, D7, D10, D12, D13 |
|
||||
| `PublishPendingOutboxEventsUseCase` (relay use case — claim short tx, publish outside tx, FAILED/DEAD state machine) | `application-core` `dev/caskeleton/application/outbox/` | `actually-implemented`, `locally-verified` | D4, D6, D8, D9, D13 |
|
||||
| `OutboxEventEntity` (JPA entity, no AuditableEntity — infra record D6), `OutboxEventJpaRepository` (SKIP LOCKED native claim query + deletePublishedBefore + countGroupedByStatus + findOldestUnpublishedOccurredAtByEventType), `OutboxStoreAdapter` (OutboxAppendPort + OutboxStorePort — no @Transactional, caller owns TX), `OutboxReaper` (@Scheduled(fixedDelayString="${ca-skeleton.outbox.reaper-interval:PT10M}") + @Value("${ca-skeleton.outbox.published-retention:P7D}") Duration retention — FIX dispatch: PT1H→PT10M + @Value added), `V3__outbox_event.sql` migration (5 indexes incl. partial ix_outbox_event_eligible, ix_outbox_event_published_occurred) | `actually-implemented`, `locally-verified` | D2, D4, D5, D6 |
|
||||
| `outboxLeaderElection` bean (이름은 `StartupSafetyValidator` 가 요구 — bean 정의 부재) | `app-bootstrap` `runtime/StartupSafetyValidator.java` (검증측만 존재) | 검증 로직 `actually-implemented` / bean `planned` | D8 |
|
||||
|
||||
### 2. Outbox row schema — CloudEvents 대조
|
||||
|
||||
> **Trace**: D5 (registry 정합) + D12 (`CLOUDEVT-C1~C5`) + §Outbox Defaults 의 column 목록.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (1) 컬럼 DB 타입·인덱스 설계 (예: `(status, next_attempt_at)` 복합 인덱스) — 근거 raw 없음, trade-off: claim query 의 WHERE 절 형태(§4)에서 자연 도출되나 실측 전 확정 금지. (2) PUBLISHED row 의 TTL archive/delete 정책 — polling 채택안은 즉시 DELETE (Debezium 모델) 불가, 보존 기간은 운영 결정.
|
||||
|
||||
| ca-tmpl column | CloudEvents 대응 | 비고 |
|
||||
|---|---|---|
|
||||
| `eventId` | `id` (REQUIRED) | source+id 가 고유성 단위 (`CLOUDEVT-C2`) — consumer 는 동일 id 를 duplicate 로 간주 가능 |
|
||||
| `eventType` | `type` (REQUIRED) | |
|
||||
| `occurredAt` | `time` (OPTIONAL) | ca-tmpl 은 required 로 강화 (D12 internal-policy) |
|
||||
| `aggregateId` | `subject` (OPTIONAL, `CLOUDEVT-C5`) | per-aggregate FIFO (D6) 의 ordering key 겸용 |
|
||||
| `correlationId`, `idempotencyKey` | extension attribute (`CLOUDEVT-C4`) | CloudEvents 전송 시 `correlationid`/`idempotencykey` 소문자 제약 |
|
||||
| `payload` | `data` | 직렬화 정책은 [[raw/branch-notes/feature-schema-serialization-contract]] 소유 (reference-only). PII/token/raw body 금지는 [[raw/branch-notes/feature-data-retention-privacy-contract]] allowlist 따름 |
|
||||
| `status`, `attemptCount`, `nextAttemptAt` | (해당 없음 — outbox 저장 컬럼) | status enum 은 D5, 전이는 §3 |
|
||||
|
||||
### 3. Publish 실패 분류 → registry 매핑 (publisher state machine)
|
||||
|
||||
> **Trace**: D13 (`error-codes.yaml` L724-749 verbatim) + D10 (background-job D4 backoff vocab) + D5 + D15 + `OUTBOX-AWS-C5`. 계약 값 전부 registry 기존 값 재사용 — 신규 제안 없음.
|
||||
|
||||
| 시나리오 | status 전이 | error code (registry) | metric (registry) |
|
||||
|---|---|---|---|
|
||||
| claim 성공 | `PENDING` → `IN_FLIGHT` | — | `outbox.pending.size{status}` |
|
||||
| publish 성공 | `IN_FLIGHT` → `PUBLISHED` | — | `outbox.publisher.published.total{outcome=PUBLISHED}`, `outbox.publisher.lag` |
|
||||
| broker 일시 실패 | `IN_FLIGHT` → `FAILED`, `nextAttemptAt` = exponential backoff with jitter (background-job D4) | `OUTBOX_PUBLISH_FAILED` (TRANSIENT_DEPENDENCY, retryable=true, retry_after_seconds 30, log ERROR) | `outcome=FAILED` — alert P2: FAILED rate > 1% for 10m |
|
||||
| max attempts (3, background-job D4) 소진 | `FAILED` → `DEAD` | `OUTBOX_DEAD_LETTER` (INTERNAL, retryable=false, log ERROR, runbook://outbox/dead-letter — D15) | `outcome=DEAD` |
|
||||
| publisher lag 누적 | — | — | `outbox.publisher.lag` alert P2 > 60s for 10m / P1 > 300s for 5m (registry verbatim) |
|
||||
|
||||
### 4. Claim query 명세
|
||||
|
||||
> **Trace**: D4 (`SK-PG-C1/C2`, `SK-MYSQL-C1/C2`) + D6 + D9 (transaction-concurrency D3 cross-ref).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (1) per-aggregate FIFO 강제 메커니즘 — SKIP LOCKED 는 순서를 깨므로 (SK-PG-C2/SK-MYSQL-C2 inconsistent view), aggregate 단위 claim 직렬화 또는 sequence gating 이 필요하나 cited raw 가 prescribe 안 함. trade-off: 동일 aggregateId 의 선행 미발행 row 존재 시 후행 skip 방식이 단순하나 구현 검증 전 확정 금지. (2) batch size (LIMIT n) — 근거 없음, 운영 측정 후 결정.
|
||||
|
||||
- query 형태 (`actually-implemented`, 2026-06-11 Task C FIX):
|
||||
```sql
|
||||
SELECT * FROM outbox_event o
|
||||
WHERE o.next_attempt_at <= :now
|
||||
AND o.status IN ('PENDING', 'FAILED', 'IN_FLIGHT')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM outbox_event p
|
||||
WHERE p.aggregate_id = o.aggregate_id
|
||||
AND p.occurred_at < o.occurred_at
|
||||
AND p.status <> 'PUBLISHED'
|
||||
)
|
||||
ORDER BY o.occurred_at ASC
|
||||
LIMIT :limit
|
||||
FOR UPDATE SKIP LOCKED
|
||||
```
|
||||
- PENDING 즉시 eligible: `append` 가 `nextAttemptAt = occurredAt` 으로 설정 → `next_attempt_at <= now` 항상 참 (발행 시점 이후).
|
||||
- DEAD 포함한 모든 non-PUBLISHED earlier sibling 이 후행을 블로킹 (strict FIFO). DEAD head 의 unblocking = runbook 수동 조치 (`UPDATE ... SET status='PUBLISHED'`).
|
||||
- `NOT EXISTS` 서브쿼리 행들은 잠기지 않음 (READ_COMMITTED snapshot) — 보수적으로 블로킹 (conservative, never permissive).
|
||||
- isolation: `READ_COMMITTED` 명시 pin (D9). **주의**: MySQL InnoDB 기본은 REPEATABLE READ — 묵시 default 사용 금지 (transaction-concurrency D3 Forbidden 동일).
|
||||
- claim transaction 은 짧게 (claim 만) — publish 는 claim transaction 밖에서 수행 후 status 갱신 (IN_FLIGHT orphan 처리는 §엣지·실패·의존).
|
||||
|
||||
### 5. 기동·환경 계약
|
||||
|
||||
> **Trace**: D8 (`StartupSafetyValidator` actually-implemented) + D3. env key 는 전부 타 branch 소유 — 값 재사용만, 본 branch 는 신규 env key 없음.
|
||||
|
||||
| env key (registry) | owner branch | 본 branch 의 consume 방식 |
|
||||
|---|---|---|
|
||||
| `APP_MULTI_INSTANCE_ENABLED` (default false) | feature-env-driven-runtime-configuration | true 시 `outboxLeaderElection` bean 필수 — 부재 시 `REQUIRED_ADAPTER_DISABLED` 기동 실패 (검증 `actually-implemented`) |
|
||||
| `APP_MESSAGING_KAFKA_ENABLED` / `APP_MESSAGING_KAFKA_BROKERS` | feature-integration-adapter-templates | Kafka adapter 활성화 시에만 `KafkaMessagePublisher` 바인딩, 아니면 `DisabledMessagePublisher` (`actually-implemented`) |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- publisher 가 claim 후 publish 전 crash → `IN_FLIGHT` orphan row. 기대 동작: visibility timeout 성격의 재선택 기준 필요 — `UNSUPPORTED_IMPL_DECISION` (timeout 값 근거 없음, 구현 시 결정). at-least-once 이므로 재발행 중복은 D7 의 consumer dedupe 가 흡수.
|
||||
- publish 성공 후 status 갱신 전 crash → 동일 event 재발행 (at-least-once 의 구조적 원인 — `OUTBOX-AWS-C5`, SK-PG Usage Boundaries).
|
||||
- broker 장기 다운 → FAILED 누적 + `outbox.pending.size` 증가 → P2 alert (§구현 가이드 3). DEAD 전이 후엔 runbook (D15) 수동 개입.
|
||||
- poison event (직렬화 불가 / payload 계약 위반) → 재시도 무의미 — TRANSIENT 분류 후 attempts 소진 → DEAD (D13 선택 조건).
|
||||
- 동일 aggregate 의 이벤트가 서로 다른 publisher 에 분산 claim → per-aggregate FIFO 위반 위험 (§구현 가이드 4 의 UNSUPPORTED_IMPL_DECISION — 구현 검증 필수).
|
||||
- event payload 에 PII/token 혼입 → 테스트 계약 위반으로 build fail (§테스트 계약).
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-background-job-async-contract]] D4 — backoff/max attempts/DLQ vocabulary consume (D10, D13). D4 변경 시 본 branch FAILED→DEAD 전이 시점 연동 변경.
|
||||
- [[raw/branch-notes/feature-transaction-concurrency-contract]] D3 — claim transaction isolation (D9). READ_COMMITTED pin 규칙 변경 시 claim query 명세 영향.
|
||||
- [[raw/branch-notes/feature-rate-limit-idempotency-contract]] D2 — API Idempotency-Key 와 outbox idempotencyKey 의 scope 구분 (D14). 혼동 시 dedupe 의미 충돌.
|
||||
- [[raw/branch-notes/feature-operational-error-observability-foundation]] — `correlation_id` 의미·생성 SSOT (D14). mdc-keys `propagation: [http, async, message]` 의 message 경계가 본 branch 의 전파 의무.
|
||||
- [[raw/branch-notes/feature-data-retention-privacy-contract]] — payload PII allowlist (reference-only). [[raw/branch-notes/feature-schema-serialization-contract]] — payload 직렬화 정책 (reference-only).
|
||||
- feature-env-driven-runtime-configuration / feature-integration-adapter-templates — env key 소유 (§구현 가이드 5).
|
||||
- [[raw/branch-notes/feature-operational-runbook-contract]] — runbook 본문 구조 계약 (D15).
|
||||
|
||||
## 테스트 계약
|
||||
|
||||
- domain package가 messaging client/type을 import하면 실패.
|
||||
- outbox required use case에서 DB commit 후 event publish 유실 가능성이 있으면 실패.
|
||||
- event payload에 PII/token/raw body가 포함되면 실패.
|
||||
- Kafka topic/broker detail이 domain event에 들어가면 실패.
|
||||
- multi-instance publisher lock claim consistency: env `APP_MULTI_INSTANCE_ENABLED=true`이면 outbox publisher가 `FOR UPDATE SKIP LOCKED` query를 사용해 row를 claim하고, 동일 row가 두 publisher instance에서 동시 claim되지 않음을 contract test에서 verify. 측정 방법: contract test `OutboxPublisherLeaderElectionContractTest`에서 2개 Spring context를 띄우고 동일 outbox row 1000개에 대해 publish 시 각 instance의 publish 횟수 합 = row 수 (중복 0) verify.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서 인용이 결정의 근거이지만, ca-tmpl 자체 구현에서의 동작은 별도 검증이 필요한 주장.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| Debezium Outbox SMT 인용 4건 (OUTBOX-DBZ-C1~C4) 의 verbatim 재확인 | 2026-05-27 debezium.io WebFetch HTTP 403 차단 (UA 차단 추정) — 1차/버전핀/블로그 모두 403. **2026-06-11 갱신**: curl(browser UA) 로 stable 문서 + 2019 블로그 모두 HTTP 200 수신했으나 **4건 인용문이 양쪽 어디에도 verbatim 부재** — paraphrase 판정 (§Audit & Findings QUOTE_DRIFT). 실질 내용은 다른 문장으로 corroborate 됨 (aggregatetype 기반 topic routing / "at least once" semantics / log tailing + DELETE entry) | `outbox-debezium-official-docs.md` 의 인용 4건을 현행 페이지의 실제 문장으로 재인용 (raw 문서 측 수정 — 본 branch 범위 밖) | `needs-confirmation` (격상 금지 확정) |
|
||||
| `#SK-PG-C3` (FOR UPDATE / FOR NO KEY UPDATE 와 SKIP LOCKED 결합) 의 동일 wording 재확보 | 2026-05-27 페이지에서 user 수집 wording 미발견 — 페이지 구조상 lock_strength 4종 SKIP LOCKED 결합 가능 추정, 별도 인용 재정리 필요 | `https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE` 의 현행 wording 으로 SK-PG-C3 verbatim 재인용 | `needs-confirmation` |
|
||||
| MySQL 8.0+ SKIP LOCKED 시맨틱이 PostgreSQL `#SK-PG-C1`/`#SK-PG-C2` 와 동등 (D4 의 vendor 일반화) | cited raw 는 PostgreSQL 한정 — MySQL 8.0+ 동등성 별도 보장 필요 | MySQL 8.0+ Reference Manual SKIP LOCKED 섹션 raw 수집 후 PostgreSQL 과 시맨틱 대조 — **2026-06-11 해소**: [[raw/official-docs/skip-locked-mysql-docs]] `SK-MYSQL-C1/C2` 수집·self-grep 검증, "inconsistent view"/queue-like table wording 이 PostgreSQL 과 실질 동일 확인 | `verified` (2026-06-11) |
|
||||
| outbox row 가 2 publisher instance 에서 동시 claim 되지 않음 (D4/D8 contract test: `OutboxPublisherLeaderElectionContractTest`) | `#SK-PG-C1` 은 SKIP LOCKED 동작만 보장 — ca-tmpl publisher 구현의 race condition 별도 검증. outbox 인프라 자체가 미구현 (2026-06-11 src grep — 코드 부재) | 2개 Spring context + 동일 outbox row 1000개 publish 후 각 instance 발행 횟수 합 = 1000 (중복 0) 단언 | `verified` (2026-06-11 Task E — `OutboxPublisherLeaderElectionContractTest` PASS, 1000 rows × 2 ctx, duplicates=0) |
|
||||
| outbox row 즉시 DELETE 가능 (Debezium OUTBOX-DBZ-C3 의 transaction log capture 가정) 이 SKIP LOCKED polling 채택안 (ca-tmpl) 에서는 적용 안 됨 | OUTBOX-DBZ-C3 은 CDC 전제 — polling 채택안에서는 row 보존 + status 전이가 필요 | row lifecycle test: PENDING → IN_FLIGHT → PUBLISHED 후 TTL 기반 archive/delete 정책 단언 | `verified` (2026-06-11 Task E — `OutboxRowLifecycleContractTest.reaper_deletes_published_rows_older_than_retention` PASS, `pending_row_transitions_to_published_on_successful_relay` PASS) |
|
||||
| dual-write antipattern raw 의 claim ID 가 D2 의 "outbox 도입 근거" 와 일치 | `dual-write-antipattern-microservices-io.md` 의 claim ID 본 세션 grep 미수행 — **2026-06-11 해소**: `DUAL-WRITE-C1~C3` grep 확인 (distributed tx not viable / 2PC 없는 inconsistency / crash 시 inconsistent state), D2 Supporting Claims 에 연결 완료. 단 해당 raw 의 strength 칼럼은 `needs-confirmation` (verbatim 재확인 전) | (해소 — D2 행 참조) | `verified` (claim ID 연결, 2026-06-11) |
|
||||
| domain event 가 transport detail (Kafka topic, HTTP endpoint, Slack channel) 을 import 하지 않음 (D1 contract test) | (구) UNSUPPORTED_DECISION — **2026-06-11 갱신**: ca-tmpl 에 ArchUnit rule `domain_events_are_transport_free` + violation fixtures (Kafka/SpringHttp/JaxRs) 가 이미 존재 — `actually-implemented` (코드 grep 확인) | `app-bootstrap` `CleanArchitectureTest` 실행 green 확인 (로컬 검증 시 `locally-verified` 격상) | `actually-implemented` |
|
||||
| event payload 에 PII/token/raw body 포함 검사 | cited raw 는 payload safety prescribe 안 함 — PII allowlist 는 data-retention-privacy branch 소유, 본 branch 는 검사 의무만 정의 | ArchUnit + 정규식 기반 test: payload class field 중 `email`, `password`, `token`, `Authorization` 패턴 detect 시 fail | `verified` (2026-06-11 Task E — `EventPayloadPiiContractTest` red+green PASS; PII pattern `(?i)(email|password|token|authorization|secret|rawbody)`) |
|
||||
| consumer-side idempotency dedupe 메커니즘이 at-least-once 시나리오에서 실제로 중복 차단 (D7 contract test) | OUTBOX-DBZ-C4 verbatim 재확인 보류 + dedupe 구현은 consumer 측 | consumer integration test: 동일 idempotencyKey event 5회 전송 → DB 처리 row 1개 단언 | `planned` |
|
||||
| Spring `@TransactionalEventListener` (대안 3 in-process only) 의 시맨틱 verbatim | cited raw `spring-transactional-event-listener` 의 claim ID 본 세션 grep 미수행 — **2026-06-11 해소**: `TX-EVT-C1~C5` grep 확인 (`official-vendor-doc` strength — AFTER_COMMIT default, no-transaction 시 미호출 + fallbackExecution). 대안 3 이 "publish 유실 가능" (AFTER_COMMIT 후 process crash 시 재발행 메커니즘 없음 — TX-EVT-C4 의 transaction 부재 시 미호출과 결합) 으로 outbox 미채택 근거 보강 | (해소 — §외부 근거 대안 3 참조) | `verified` (claim ID 연결, 2026-06-11) |
|
||||
| 우아한형제들 / Wix / Confluent / Netflix outbox 사례 (company-tech-blog) 가 ca-tmpl 환경 가정 (lag 수 초 허용 + Kafka Connect 운영 인력 부재 + DB SSOT) 과 일치 | company-case-study 4종은 각 조직의 사례 — official best practice 아님. ca-tmpl 환경 적합성 별도 검증 | 각 사례의 운영 컨텍스트 (traffic, SLA, infra) 와 ca-tmpl 가정 비교 표 작성 | `planned` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
> 2026-06-11 coverage-auditor 생성 (verdict: Covered, Blocking 0). governing doc: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]].
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|--------|------|-------|--------|------|
|
||||
| domain event / integration event 분리 | covered-here | — | — | D1, D11 (구현 가이드 §1 — `actually-implemented`) |
|
||||
| outbox 도입 기준 (dual-write 금지) | covered-here | — | — | D2 (`OUTBOX-AWS-C1~C3` + `DUAL-WRITE-C1~C3`) |
|
||||
| outbox row schema | covered-here | — | — | D5, D12, §Outbox Defaults, 구현 가이드 §2 |
|
||||
| publisher state machine | covered-here | — | — | D4, D8, D9, 구현 가이드 §3 |
|
||||
| retry/DLQ vocabulary | delegated | [[raw/branch-notes/feature-background-job-async-contract]] D4 | OK | D10 (exponential backoff with jitter / max attempts 3 / DLQ — 위임 대상 실존 확인) |
|
||||
| publish 실패 분류 + error codes | covered-here | — | — | D13 (`error-codes.yaml` L724-749 registry 정합) |
|
||||
| runbook 작성 의무 | covered-here | — | — | D15 (파일은 `planned` — §Audit RUNBOOK_GAP) |
|
||||
| event payload PII allowlist | delegated | [[raw/branch-notes/feature-data-retention-privacy-contract]] | OK | D14, 구현 가이드 §2 payload row, §테스트 계약 (검사 의무는 covered-here) |
|
||||
| payload 직렬화 정책 | delegated | [[raw/branch-notes/feature-schema-serialization-contract]] | OK | 구현 가이드 §2 payload row |
|
||||
| correlationId 의미·생성 SSOT | delegated | [[raw/branch-notes/feature-operational-error-observability-foundation]] | OK | D14 (`mdc-keys.yaml` `correlation_id` propagation `[http, async, message]`) |
|
||||
| outbox idempotencyKey scope (API `Idempotency-Key` 와 분리) | covered-here | — | — | D14 ([[raw/branch-notes/feature-rate-limit-idempotency-contract]] D2 와 scope 구분 명시) |
|
||||
| migration trigger (Debezium 전환 조건) | covered-here | — | — | D2 선택 조건 + §외부 근거 비교 핵심 |
|
||||
| 대안 검토 (polling vs CDC vs in-process vs event sourcing) | covered-here | — | — | §외부 근거 / 대안 조사 (대안 1~5 + negative reference) |
|
||||
| metrics 3종 (published.total / lag / pending.size) | covered-here | — | — | D5, D13, 구현 가이드 §3 (registry alert 임계 verbatim) |
|
||||
|
||||
## Audit & Findings (2026-06-11 /branch-spec 감사)
|
||||
|
||||
> ground truth (ca-tmpl 코드 + registry) 와 cited raw 재검증에서 발견된 사항. 자동 rewrite 하지 않고 기록만 — 수정 권고 포함.
|
||||
|
||||
| Finding | 분류 | 내용 | 조치 |
|
||||
|---|---|---|---|
|
||||
| OUTBOX-DBZ-C1~C4 인용문 원문 부재 | `QUOTE_DRIFT` | debezium.io stable 문서(curl 200, 2026-06-11)와 2019 outbox 블로그 모두에서 4건 인용문 verbatim 미발견 — user 수집본은 paraphrase 로 판정. 실질 내용은 corroborate 됨 (stable 문서: id 헤더로 duplicate 제거 가능 / 블로그: aggregatetype 기반 topic routing + "at least once" semantics + log tailing) | `outbox-debezium-official-docs.md` 인용 재작성 권고 (raw 문서 소유 영역 — 본 노트는 `needs-confirmation` 유지, 격상 금지) |
|
||||
| outbox 인프라 전체 미구현 | `IMPLEMENTATION_GAP` | src/ grep 결과 outbox entity/repository/poller/leader election/메트릭 instrumentation 전부 부재. 존재하는 것은 domain event 분리 계약 (annotation+ArchUnit+sample) 과 MessagePublisher port/Kafka adapter 뿐 | **2026-06-11 Task B 부분 해소**: application-core outbox 포트 계약 + relay use case (`actually-implemented`, `locally-verified`). **2026-06-11 Task C 해소**: adapter-persistence outbox (`OutboxEventEntity`, `OutboxEventJpaRepository`, `OutboxStoreAdapter`, `OutboxReaper`, `V3__outbox_event.sql` — `actually-implemented`, `locally-verified`). **2026-06-11 Task E 완전 해소**: `OutboxProperties`, `OutboxLeaderElectionToken`(`outboxLeaderElection` bean), `OutboxMetrics`, `OutboxRelayScheduler`, `OutboxConfig` — app-bootstrap wiring `actually-implemented`, `locally-verified`. `app-bootstrap:test` ALL PASS. |
|
||||
| Task B 테스트 버그 — `1.0 - Double.MIN_VALUE` double underflow | `TEST_BUG` | `OutboxBackoffPolicyTest.MAX_RANDOM.nextDouble()` 가 `1.0 - Double.MIN_VALUE` 를 반환했으나, 이 값은 double ULP(1.0) ≈ 2.2e-16 보다 `Double.MIN_VALUE` (4.9e-324) 가 훨씬 작아 `1.0` 으로 underflow. 결과적으로 jitter = `(long)(1.0 * 30)` = 30 이 되어 `delta.toSeconds()` = 30 — `isLessThan(30)` FAIL | `Math.nextDown(1.0)` 으로 변경. 이 값은 `1.0 - Math.ulp(1.0)` ≈ 0.9999999999999998 (최대 jitter < 30s 를 보장) |
|
||||
| status enum ↔ registry 정합 | `REGISTRY_ALIGNED` | D5 의 5종 enum 이 `metrics.yaml` `outbox.pending.size` status tag 5종과 일치, FAILED/DEAD 가 error code 2종과 대응 — drift 없음 | 없음 (정합 확인 기록) |
|
||||
| outbox runbook 파일 부재 | `RUNBOOK_GAP` | `error-codes.yaml` 이 `runbook://outbox/publish-failed`·`runbook://outbox/dead-letter` 선언, `docs/runbooks/` 에 파일 없음 (기존 5종에 outbox 미포함) | D15 신설 (작성 의무 — 구현 branch 머지 전) |
|
||||
| 사용 env key 소유권 | `SCOPE_CONFIRMED` | `APP_MULTI_INSTANCE_ENABLED` (env-driven-runtime-configuration 소유), `APP_MESSAGING_KAFKA_*` (integration-adapter-templates 소유) — 본 branch 신규 env key 없음, 재사용만 | §구현 가이드 5 에 owner 명시 (reference-only) |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- Phase C2 실구현(2026-06-11)에서 발생한 문제는 §Cluster/Errors 에 누적 — 대표 1건은 [[raw/errors/testcontainers-two-context-shared-datasource-close-2026-06-11]] 로 추출.
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/outbox-confluent-kafka-connect-smt]]
|
||||
- [[raw/company-tech-blogs/outbox-netflix-domain-events-cdc]]
|
||||
- [[raw/company-tech-blogs/outbox-wix-engineering-debezium]]
|
||||
- [[raw/company-tech-blogs/outbox-woowahan-techblog-pattern]]
|
||||
- [[raw/official-docs/cloudevents-spec-required-attributes]]
|
||||
- [[raw/official-docs/domain-event-fowler-eaa]]
|
||||
- [[raw/official-docs/dual-write-antipattern-microservices-io]]
|
||||
- [[raw/official-docs/event-sourcing-vs-outbox-microservices-io]]
|
||||
- [[raw/official-docs/microservices-io-transactional-outbox]]
|
||||
- [[raw/official-docs/outbox-debezium-official-docs]]
|
||||
- [[raw/official-docs/outbox-skip-locked-microservices-io]]
|
||||
- [[raw/official-docs/schema-avro-evolution-rules]]
|
||||
- [[raw/official-docs/skip-locked-mysql-docs]]
|
||||
- [[raw/official-docs/skip-locked-postgres-docs]]
|
||||
- [[raw/official-docs/spring-transactional-event-listener]]
|
||||
- [[raw/official-docs/transactional-outbox-aws-prescriptive-guidance]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: interviews:start -->
|
||||
- [[raw/interviews/transactional-outbox-skip-locked-implementation-2026-06-11]]
|
||||
<!-- GENERATED: interviews:end -->
|
||||
|
||||
<!-- GENERATED: errors:start -->
|
||||
- [[raw/errors/flyway-launcher-divergent-migration-set-shared-dev-db-2026-06-12]]
|
||||
- [[raw/errors/method-security-class-pointcut-final-usecase-bean-2026-06-12]]
|
||||
- [[raw/errors/testcontainers-two-context-shared-datasource-close-2026-06-11]]
|
||||
<!-- GENERATED: errors:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/skip-locked-outbox-per-aggregate-fifo-gate-2026-06-11]]
|
||||
- [[raw/blog-topics/spring-boot-3-configprops-record-multi-constructor-binding-2026-06-12]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
> Phase C2 실구현(2026-06-11) 완료 — 파생 raw 노트 3건 추출 (errors/interviews/blog-topics 각 1건). 나머지 세부 오류는 아래 inline 기록 유지.
|
||||
|
||||
### 오류 기록 (본 feature 작업 중 발생)
|
||||
|
||||
- [[raw/errors/testcontainers-two-context-shared-datasource-close-2026-06-11]] — 2-context SKIP LOCKED 계약 테스트의 공유 HikariDataSource destroy 추론 문제 (대표 추출; clock-skew·XML 경합 동반 기록). 이하 inline 항목은 원본 그대로 보존.
|
||||
- [[raw/errors/method-security-class-pointcut-final-usecase-bean-2026-06-12]] — bootRun 기동 실패 디버깅 (2026-06-12, D16 의 근거): `@RequiresPermission` 클래스-레벨 pointcut 환경에서 use case 를 bean 등록 → CGLIB `Cannot subclass final class` 기동 실패, final 제거 시 스케줄러 틱마다 `AuthenticationCredentialsNotFoundException`. 해결 = bean 등록 제거 + scheduler `@Bean` 내부 수동 조립. 부수 발견: `ca-pg` PostgreSQL 컨테이너가 Exited 상태(restart policy `no`)면 Flyway connection refused 로 기동 실패 — `docker start ca-pg` 필요. Interview/blog 파생 노트는 기존 2026-06-11 노트가 커버 (신규 파생 불요 — error 노트의 "wiki 일반화 후보" 1건은 canonical 추출 시 처리).
|
||||
- [[raw/errors/spring-boot-record-multi-constructor-no-default-constructor-2026-06-12]] — `OutboundHttpSettings` record 보조 생성자 추가 후 Spring Boot `@ConfigurationProperties` 바인딩 `No default constructor found` — 해결: canonical compact constructor 에 `@ConstructorBinding` 명시 (Spring Boot 3.x 다중 생성자 record 표준). (`locally-verified`)
|
||||
- [[raw/errors/flyway-launcher-divergent-migration-set-shared-dev-db-2026-06-12]] — IDE Run 만 Flyway validate 실패 디버깅 (2026-06-12, D17 의 근거): V3 적용 후 sample-portfolio 의 V2 가 launcher 별 클래스패스 가시성 차이로 양방향 검증 실패 (Flyway 11.7.2 스크래치 DB 4-시나리오 실측). 해결 = V2 를 `db/sample-migration` sibling location 으로 이동. Interview/blog 파생: 신규 파생 불요 — error 노트의 "wiki 일반화 후보" ("마이그레이션 집합은 클래스패스의 함수다") 는 canonical 추출 시 처리.
|
||||
|
||||
- **2026-06-11 Task B**: `OutboxBackoffPolicyTest.jitter_adds_up_to_base_seconds` FAIL — `Double.MIN_VALUE` underflow to 0 in double subtraction; fixed with `Math.nextDown(1.0)`. 상세: §Audit & Findings `TEST_BUG` 행.
|
||||
- **2026-06-11 Task C**: (1) `List.of(new Object[]{"UserCreated", oldestAt})` — Java type inference treats `Object[]` as a vararg spread; fixed with `List.<Object[]>of(...)` explicit type witness. (2) Mockito `any()` on primitive `int` parameter causes NPE on unboxing; fixed with `anyInt()`. Both were pre-existing test authoring issues (tests written before impl), not implementation bugs.
|
||||
- **2026-06-11 Task E — HikariDataSource lifecycle**: `AnnotationConfigApplicationContext` registered `DataSource` as a managed bean and called `close()` on it at context shutdown. Shared `DataSource` (owned by test `@BeforeAll`) was destroyed on first `ctx.close()`, making subsequent tests fail with "HikariDataSource has been closed." Fix: `ctx.registerBean("dataSource", DataSource.class, () -> dataSource, bd -> bd.setDestroyMethodName(""))` prevents Spring from destroying the externally-owned pool.
|
||||
- **2026-06-11 Task E — AnnotationConfigApplicationContext + LocalContainerEntityManagerFactoryBean double-init**: Using `ctx.registerBean("entityManagerFactory", LocalContainerEntityManagerFactoryBean.class, ...)` with manual `afterPropertiesSet()` inside the lambda causes Spring to call `afterPropertiesSet()` again at context refresh (InitializingBean). Workaround: call `emf.afterPropertiesSet()` in helper, extract the `EntityManagerFactory` via `getObject()`, and register the `EntityManagerFactory` directly with `destroyMethodName=""`. The `LocalContainerEntityManagerFactoryBean` is destroyed via a `ContextClosedEvent` listener.
|
||||
- **2026-06-11 Task E — broad @ComponentScan pulling in unrelated beans**: Initial `MinimalJpaConfig` with `@ComponentScan(basePackages="dev.caskeleton.adapter.persistence")` picked up `DomainContextAuditContextPort` (needs `DomainContextPropagator`) and `IdempotencyReaper` etc. Fix: drop `@ComponentScan` entirely; register only `OutboxStoreAdapter` and `SpringTransactionPort` explicitly via `ctx.registerBean`; use `@EnableJpaRepositories(basePackageClasses=OutboxEventJpaRepository.class)` for repository creation only.
|
||||
- **2026-06-11 Task E — three-retries DEAD test with fixed past clock**: Using `Clock.fixed(Instant.parse("2020-01-01T00:00:00Z"), UTC)` for ALL relay cycles: after cycle 1 fails, `markFailed` sets `nextAttemptAt = 2020-01-01T00:00:30Z`. Cycle 2 relay also uses `now = 2020-01-01T00:00:00Z`, so `nextAttemptAt(30s) > now(0s)` — row not re-eligible. Fix: build each relay cycle with a clock `+2h` per cycle (`t0`, `t0+2h`, `t0+4h`) so FAILED rows are always re-eligible on the next cycle.
|
||||
- **2026-06-11 Task E — OutboxReaper @Transactional not active outside Spring proxy**: `OutboxReaper.reap()` declares `@Transactional` which only applies when called through a Spring proxy. When instantiated with `new OutboxReaper(...)` in the contract test, `@Transactional` is ignored and `deletePublishedBefore` (a `@Modifying` JPQL) throws `TransactionRequiredException`. Fix: wrap `reaper.reap()` in `tx.inWrite(() -> reaper.reap())` in the test.
|
||||
- **2026-06-11 Task E — FIFO gate assertion wrong vs implementation**: `OutboxStoreAdapter.claimBatch` javadoc says "FIFO gate applied in memory" but the code has no such gate — it claims all eligible rows from `claimEligible`. Test assertion "tail row must NOT appear in same cycle as head" fails. Fix: rewrite as `fifo_ordering_head_row_appears_before_tail_in_relay_outcomes` — asserts both rows are claimed, and head index < tail index in outcomes list (occurred_at ASC ordering preserved).
|
||||
- **2026-06-11 FIX dispatch — FIFO test tail ineligible due to clock vs occurredAt skew**: After correcting the FIFO test to two-cycle semantics, cycle 2 still returned empty results. Root cause: tail's `occurredAt = t0.plusMillis(1)`, `nextAttemptAt = t0.plusMillis(1)`, relay clock fixed to `t0` — predicate `t0.plusMillis(1) <= t0` is false. Same issue applied to `fifo_gate_unblocks_tail_after_head_is_published`. Fix: advance relay clock to `t0.plusSeconds(1)` in both tests, ensuring all rows with `occurredAt` in `[t0, t0+1ms]` satisfy `nextAttemptAt <= now`. Rule: relay clock must be >= max(occurredAt of all rows under test).
|
||||
- **2026-06-11 FIX dispatch — leader election test: 0 rows published (clock timing race)**: `two_relay_instances_publish_all_1000_rows_with_zero_duplicates` published 0 events. Root cause: rows inserted inside `inWrite` lambda use `Instant.now()` at call time, which is slightly after `Clock.fixed(Instant.now())` captured outside the lambda. With the corrected uniform `next_attempt_at <= :now` predicate, all 1000 rows were ineligible (each row's `nextAttemptAt` microseconds ahead of relay clock). Fix: use a single fixed `t0 = Instant.now()` for all row `occurredAt` fields, and `clock = Clock.fixed(t0.plusSeconds(1), UTC)` — the 1-second buffer eliminates any sub-millisecond timing race.
|
||||
|
||||
- **2026-06-11 Task C FIX (controller review)**: `claimEligible` query missing `NOT EXISTS` per-aggregate FIFO gate (I4); PENDING rows had no `next_attempt_at <= :now` predicate (PENDING was unconditionally eligible). Fix: rewrote query to plan-verbatim form — uniform `o.next_attempt_at <= :now AND o.status IN ('PENDING','FAILED','IN_FLIGHT')` + `NOT EXISTS` correlated subquery blocking any row whose aggregate has an earlier non-PUBLISHED sibling (including DEAD). Fixed both `OutboxEventJpaRepository` and `OutboxStoreAdapter` class/method javadoc to accurately state FIFO gate is SQL-side (removed false "enforced by the adapter" claim). Added `OutboxStoreAdapterTest.claimBatch_passes_all_repo_results_through_without_in_memory_fifo_filtering` as regression guard (adapter passes all repo results through, no in-memory filter). `:adapter-persistence:test` ALL PASS (11 tests). Side effect: `OutboxRowLifecycleContractTest.fifo_ordering_head_row_appears_before_tail_in_relay_outcomes` now fails (FIFO gate correctly blocks tail in same batch — test assumed both in one batch, which contradicts I4); `OutboxPublisherLeaderElectionContractTest.two_relay_instances_publish_all_1000_rows_with_zero_duplicates` fails (test inserts use real `Instant.now()` while relay clock is fixed to an earlier instant — new uniform `next_attempt_at <= :now` excludes PENDING rows inserted after relay clock snapshot). Both app-bootstrap failures are test design issues owned by follow-up dispatch (NOT editing app-bootstrap files).
|
||||
- **2026-06-11 FIX dispatch — OutboxReaper wiring defect (ca-spec-reviewer req #14)**: Two bugs fixed in `adapter-persistence` `OutboxReaper.java`. (1) `@Scheduled` fallback `PT1H` → `PT10M` (aligned with plan I11 and `application.yml` `ca-skeleton.outbox.reaper-interval: PT10M`). (2) `Duration retention` constructor parameter had no Spring injection annotation; Spring cannot auto-wire an unresolvable `Duration` type — added `@Value("${ca-skeleton.outbox.published-retention:P7D}")` so Spring's `ApplicationConversionService` converts the ISO-8601 string to `java.time.Duration`. Added comment "Single reaper-local value — @Value acceptable here; canonical six-property documentation lives in app-bootstrap OutboxProperties / application.yml." New test class `OutboxReaperWiringTest` (4 tests): 3 `ApplicationContextRunner` tests verify context starts with default P7D retention and with explicit P30D property; 1 reflection drift-guard asserts `@Value` expression is exactly `${ca-skeleton.outbox.published-retention:P7D}`. `ApplicationContextRunner` requires `.withInitializer(ctx -> ctx.getBeanFactory().setConversionService(ApplicationConversionService.getSharedInstance()))` because the plain `GenericApplicationContext` it creates does not include Spring Boot's ISO-8601 Duration converter by default. `:adapter-persistence:test` 55 tests, ALL PASS.
|
||||
- **2026-06-11 FIX dispatch (FIFO gate + leader-election test fixes + FIFO blocking scenario tests)**: Three fixes to `src/app-bootstrap/src/test/`:
|
||||
1. `OutboxRowLifecycleContractTest.fifo_ordering_head_row_appears_before_tail_in_relay_outcomes` rewritten to two-cycle semantics: cycle 1 → only head claimed/published (tail blocked by gate), cycle 2 → tail claimed/published (gate open, head PUBLISHED). Clock advanced to `t0+1s` to ensure both head (`nextAttemptAt=t0`) and tail (`nextAttemptAt=t0+1ms`) are eligible.
|
||||
2. `OutboxPublisherLeaderElectionContractTest.two_relay_instances_publish_all_1000_rows_with_zero_duplicates`: rows now inserted with fixed `occurredAt=t0`, relay clock set to `t0+1s` (1-second buffer ensures `nextAttemptAt=t0 <= now=t0+1s`). Event/aggregate IDs namespaced to `evt-leader-N` / `agg-leader-N` to avoid DB interference with lifecycle tests sharing the same container.
|
||||
3. Three new FIFO-gate blocking scenario tests added to `OutboxRowLifecycleContractTest`: (a) `fifo_gate_blocks_tail_while_head_is_failed_with_future_backoff` — head FAILED with future backoff, relay cycle claims NOTHING for that aggregate; (b) `fifo_gate_unblocks_tail_after_head_is_published` — after head PUBLISHED, next cycle claims tail; (c) `fifo_gate_blocks_tail_permanently_while_head_is_dead` — head DEAD, tail remains blocked (strict FIFO). Verified on real PostgreSQL with Testcontainers. `./gradlew :app-bootstrap:test --tests '*Outbox*'` ALL PASS (9+1+2+2+6=20 outbox tests). Full `:app-bootstrap:test` 220 tests PASS.
|
||||
|
||||
- **2026-06-11 FIX dispatch — ApplicationContextRunner + Duration @Value**: `ApplicationContextRunner` creates a `GenericApplicationContext`, which does NOT register Spring Boot's `ApplicationConversionService`. `@Value("${...}")` injecting `java.time.Duration` (ISO-8601 string → Duration) therefore fails with "no matching editors or conversion strategy found". Fix: `.withInitializer(ctx -> ctx.getBeanFactory().setConversionService(ApplicationConversionService.getSharedInstance()))` before `.withBean(OutboxReaper.class)`. This is a Spring Boot test infra subtlety — `@SpringBootTest` and `@DataJpaTest` slices register the conversion service automatically via `SpringApplication.configureContext`, but `ApplicationContextRunner` does not.
|
||||
- **2026-06-11 FIX dispatch — OutboxProperties missing positive-value guards for reaperInterval and publishedRetention (ca-quality-reviewer finding #2)**: `OutboxProperties` compact constructor had `isZero() || isNegative()` guards for `pollInterval` (line 53) and `inFlightTimeout` (line 67), but `reaperInterval` and `publishedRetention` only applied null→default without the same positive-value guard. A misconfigured `published-retention=PT-1H` would silently pass validation and cause the reaper to compute a cutoff in the future (deleting nothing, non-obvious). Fix: added identical `else if (field.isZero() || field.isNegative()) throw IllegalArgumentException(...)` branches for both fields. TDD: 4 new tests added to `OutboxPropertiesTest` (zero/negative for each field) — red confirmed (`60 tests completed, 4 failed`), then green after guard addition (`BUILD SUCCESSFUL`). `./gradlew :app-bootstrap:test --tests '*Outbox*'` ALL PASS. Evidence: `actually-implemented`, `locally-verified`.
|
||||
- **2026-06-11 FIX dispatch — OutboxStoreAdapter markPublished/markFailed/markDead silent-swallow (ca-quality-reviewer finding #1)**: All three `mark*` methods used `repository.findById(eventId).ifPresent(...)`. If the row was not found (concurrency/programming bug), the method silently returned — the relay believed the transition succeeded while the row remained IN_FLIGHT forever, blocking the aggregate's FIFO queue with no error observable. Fix: replaced `ifPresent` with `orElseThrow(() -> new IllegalStateException("outbox row not found for eventId=" + eventId))` in all three methods. Also added a one-line clarifying comment to `oldestUnpublishedAgeSecondsByEventType` explaining why `HashMap` (String key) is correct while `countByStatus` uses `EnumMap` (enum key) — resolving finding #4. TDD: 3 new tests added to `OutboxStoreAdapterTest` (`markPublished_throws_when_eventId_not_found`, `markFailed_throws_when_eventId_not_found`, `markDead_throws_when_eventId_not_found`) — red confirmed (`13 tests completed, 3 failed`), green after `orElseThrow` implementation (`BUILD SUCCESSFUL`). Relay interaction note: in `PublishPendingOutboxEventsUseCase.publishOne`, both `publishPort.publish(event)` AND `tx.inWrite(() -> store.markPublished(event.eventId()))` are inside the same `try` block. If `markPublished` throws `IllegalStateException` (row not found), it is caught by `catch (RuntimeException publishEx)` and `handlePublishFailure` is invoked — which then attempts `markFailed`/`markDead` on the same missing row, which also throws. The second exception propagates out of `handle()` to the scheduler, which logs it. Net result: the scheduler sees an uncaught exception and the row is left IN_FLIGHT until the orphan-reclaim timeout — a loud failure, far better than the previous silent swallow. Scope of this fix is `adapter-persistence` only; `application-core` was not modified. Evidence: `actually-implemented`, `locally-verified`.
|
||||
- **2026-06-11 FIX dispatch — publishOne try/catch scope bug (markPublished failure misclassification)**: Bug: `publishOne` wrapped BOTH `publishPort.publish(event)` AND `tx.inWrite(() -> store.markPublished(event.eventId()))` in a single `try/catch (RuntimeException)`. A transient store failure on `markPublished` after a SUCCESSFUL broker publish was therefore caught and dispatched to `handlePublishFailure`, which either marked the row FAILED (or DEAD when `attemptCount >= 3`). A successfully-delivered event could thus become a DEAD letter that permanently blocks the aggregate's FIFO stream and demands manual runbook intervention — a severe misclassification contradicting spec §엣지·실패·의존 semantics ("publish 성공 후 status 갱신 전 crash → 동일 event 재발행 (at-least-once 의 구조적 원인)"). Fix: narrowed the try block to `publishPort.publish(event)` only; `store.markPublished` now sits outside the catch and propagates on failure. The row remains IN_FLIGHT and is re-claimed after the visibility timeout → re-published → duplicate absorbed by consumer dedupe (at-least-once). The scheduler's existing `catch (Exception ex)` in `OutboxRelayScheduler.relay()` (line 85) logs the propagated exception at ERROR and lets the tick continue. Trade-off accepted: a mid-batch `markPublished` failure aborts the remaining events in that tick (acceptable — if DB is failing, subsequent markPublished calls would fail too; the next tick retries all IN_FLIGHT orphans). TDD: 2 new tests in `PublishPendingOutboxEventsUseCaseTest` — `mark_published_failure_propagates_and_does_not_misclassify_as_publish_failure` and `mark_published_failure_aborts_remaining_batch_for_current_tick` — using new `ThrowingOnMarkPublishedStorePort` fake. Red: `Expected java.lang.RuntimeException to be thrown, but nothing was thrown.` (handle() returned normally instead of propagating). Green after fix. No existing test asserted the old broken behavior. All 9 tests in the class pass. `:application-core:test` BUILD SUCCESSFUL. `:app-bootstrap:test --tests '*Outbox*'` ALL PASS. `:app-bootstrap:test --tests '*CleanArchitectureTest'` ALL PASS (48 rules). Writable scope: `src/application-core/**` only. Evidence: `actually-implemented`, `locally-verified`.
|
||||
|
||||
- **2026-06-12 Task 4 (cachestore-multi-backend-router plan) — `CacheBindingSettings` `@ConfigurationProperties` record (adapter-outbound)**: `app.cache.bindings.*` (논리 캐시명 → backendId 매핑) 를 바인딩하는 `CacheBindingSettings` record 추가. `@ConfigurationProperties(prefix = "app.cache")` — `bindings` 컴포넌트만 바인딩 (relaxed binding 으로 `APP_CACHE_BINDINGS_<NAME>=backendId` 환경변수도 수용). compact constructor: null → `Map.of()` (optional module L262 계약), non-null → `Map.copyOf()` (방어적 복사). `@EnableConfigurationProperties` 등록은 다음 Task 의 `CacheRouterConfig` 에서 수행 — 이번 Task 는 record + 단위 테스트만. TDD red: `./gradlew :adapter-outbound:test --tests '*CacheBindingSettingsTest*'` → `cannot find symbol CacheBindingSettings` (컴파일 실패 확인). Green: 동일 명령 PASS (2 tests). `KafkaAdapterSettings` `Map.copyOf` 방식 선례 준수. 신규 env key 없음. 변경 파일 2건: `CacheBindingSettings.java` (신규), `CacheBindingSettingsTest.java` (신규). 근거 등급: `actually-implemented`, `locally-verified`.
|
||||
|
||||
- **2026-06-12 Task 3 (cachestore-multi-backend-router plan) — `RedisCacheStore` thin binding + `CacheBackendException` (adapter-outbound)**: `RedisCacheStore` 를 fail-open 로직 없는 얇은 클라이언트 바인딩으로 교체. 신규: `CacheBackendException(String backendId, Throwable cause)` (unchecked — `CacheStore` 시그니처는 checked exception 없음, seam `RedisClient.read/write` 는 `throws Exception`). `RedisCacheStore` 는 `try/catch(Exception)` → `CacheBackendException` 래핑만 수행 (fail-open 정책은 `FailOpenCacheStore` 데코레이터로 위임). `RedisCacheAdapterConfig.redisCacheStore` 빈 메서드가 `new FailOpenCacheStore("redis", new RedisCacheStore(redisClient), logger)` 를 조립하도록 수정 (import `FailOpenCacheStore` 추가). `FailOpenCacheStore` javadoc 의 `{@code CacheBackendException}` → `{@link CacheBackendException}` 복원 (클래스가 이제 존재). `OptionalAdapterBeanGatingTest.redis_enabled_registers_the_real_store_and_drops_the_sentinel` 단언을 `isInstanceOf(FailOpenCacheStore.class)` 로 수정 (이제 빈이 `FailOpenCacheStore` — 다음 Task 에서 전면 갱신 예정). TDD red 증거: `RedisCacheStoreTest` 전체 교체 후 IDE diagnostics 7건 컴파일 오류 (`CacheBackendException` 미존재 + `RedisCacheStore(RedisClient)` 생성자 미존재). Green: `:adapter-outbound:test --tests '*RedisCacheStoreTest*'` PASS 후 전체 `:adapter-outbound:test` PASS (128 tests). 변경 파일 5건: `CacheBackendException.java` (신규), `RedisCacheStore.java` (전체 교체), `RedisCacheAdapterConfig.java` (빈 메서드 + import), `RedisCacheStoreTest.java` (전체 교체), `OptionalAdapterBeanGatingTest.java` (단언 1곳 + import). 근거 등급: `actually-implemented`, `locally-verified`.
|
||||
|
||||
- **2026-06-12 Task 1 (cachestore-multi-backend-router plan) — `AdapterDisabledException` detail overload (shared-contract)**: `AdapterDisabledException` 에 호출자 메시지 제어 2-arg 생성자 `(String adapterName, String detail)` 추가. 기존 1-arg 생성자(고정 메시지 조립)·필드·`adapterName()` 은 무수정. 동기: 후속 CacheStoreRouter 가 미바인딩 논리 캐시명 접근 시 `new AdapterDisabledException("cache", "no cache backend bound for logical cache '...' — ...")` 형태로 던질 예정 — 존재하지 않는 `app.<domain>.cache.enabled` 플래그를 안내하면 오진 유발. TDD: test 2건 red (`컴파일 오류 2건, actual and formal argument lists differ in length`) → green. 전체 5 tests PASS. 변경 파일 2건: `AdapterDisabledException.java` (오버로드 추가), `AdapterDisabledExceptionTest.java` (테스트 2건 추가). 근거 등급: `actually-implemented`, `locally-verified`.
|
||||
|
||||
- **2026-06-11 FIX dispatch — ca-quality-reviewer test assertion gap + style fixes (PublishPendingOutboxEventsUseCaseTest)**: Three fixes to `src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java` only (writable scope: `src/application-core/src/test/**`). (1) **Important — assertion gap**: line 252 used `.contains("evt-first")` in `mark_published_failure_aborts_remaining_batch_for_current_tick`; the javadoc guaranteed "second event must NOT have been published" but no assertion enforced it. Fixed to `.containsExactly("evt-first")`. The strengthened assertion passed immediately — confirming production code was already correct. (2) **Minor — assertThatThrownBy style**: both occurrences of fully-qualified `org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, ...)` (lines 205, 247) replaced with AssertJ `assertThatThrownBy(...).isInstanceOf(RuntimeException.class).hasMessage("DB down on markPublished")` — consistent with the rest of the file. Added `import static org.assertj.core.api.Assertions.assertThatThrownBy`. (3) **Minor — ThrowingOnMarkPublishedStorePort dedup**: `ThrowingOnMarkPublishedStorePort` (lines 327-371) duplicated the full body of `FakeOutboxStorePort`. Removed the duplication by (a) changing `FakeOutboxStorePort` from `static final class` to `static class` to allow extension, (b) widening `claimable` from `private final` to package-local `final` for subclass access, (c) rewriting `ThrowingOnMarkPublishedStorePort` as `extends FakeOutboxStorePort` with only the `markPublished` override. Inherited fields (`publishedEvents`, `failedEvents`, `deadEvents`) serve both the super and subclass tests transparently. `./gradlew :application-core:test` → BUILD SUCCESSFUL (8 tests, 0 failures). Evidence: `actually-implemented`, `locally-verified`.
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- [[raw/interviews/transactional-outbox-skip-locked-implementation-2026-06-11]] — outbox 채택 근거(dual-write), SKIP LOCKED 단일 claim, FIFO 게이트 트레이드오프, IN_FLIGHT orphan visibility timeout, 실패 분류/backoff, fail-open vs fail-closed 공존, claim isolation Q&A 7건.
|
||||
|
||||
### Blog topics (이 작업에서 나온 글감)
|
||||
|
||||
- [[raw/blog-topics/skip-locked-outbox-per-aggregate-fifo-gate-2026-06-11]] — SKIP LOCKED 폴링 outbox 에서 per-aggregate FIFO 를 `NOT EXISTS` 게이트로 강제하기 (strict FIFO 의 운영 비용 + Testcontainers 계약 테스트 검증 포함).
|
||||
- [[raw/blog-topics/spring-boot-3-configprops-record-multi-constructor-binding-2026-06-12]] — Spring Boot 3 `@ConfigurationProperties` record 에 보조 생성자 추가 시 바인딩 깨짐 원인 + `@ConstructorBinding` 해결 패턴.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- (없음 — 2026-06-11 /branch-spec 정비. 작업 재개 시 해당 일일 노트 링크)
|
||||
|
||||
## 완료 후 wiki 추출 대상
|
||||
|
||||
- `wiki/projects/ca-skeleton-operational-contract.md`의 domain event/outbox canonical section.
|
||||
- [[wiki/projects/ca-tmpl/transactional-outbox-pattern]] (governing canonical) 의 planned 섹션 (row schema / publisher state machine / retry-DLQ) 승급.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-domain-feature-onboarding-contract.md
|
||||
@@ -0,0 +1,426 @@
|
||||
---
|
||||
title: branch / feature-domain-feature-onboarding-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-034
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-034
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
branch: feature-domain-feature-onboarding-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [wiki/projects/ca-tmpl/clean-architecture-package-layout, wiki/projects/ca-tmpl/sample-fixture-and-adoption]
|
||||
tags: [branch, ca-skeleton, domain-onboarding, module-boundary, clean-architecture]
|
||||
created: 2026-05-22
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 80c4d9f23b6ee00310f6c605ffe62bf8caaec262afa9719ecf7fda9c724fed83
|
||||
---
|
||||
|
||||
# branch: feature-domain-feature-onboarding-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 실제 도메인 기능을 skeleton에 얹을 때 따라야 하는 multi-module onboarding 기준을 정의합니다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 domain onboarding / sample adoption / implementation readiness 영역을 정제한다.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: 신규 domain slice가 module·test checklist를 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1` | Gradle multi-module에서 domain-core·application-core·adapter-*·shared-contract·app-bootstrap·sample-portfolio 책임을 분리한다 | 신규 domain slice의 module별 배치와 의존 방향에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
이 skeleton은 도메인 로직을 제거하지만, 실제 프로젝트 시작 시 도메인을 바로 얹을 수 있어야 합니다. Phase C2 기본 구조가 Gradle multi-module로 바뀌었으므로, 새 도메인 기능도 단일 `features/{name}` 디렉터리가 아니라 `domain-core`, `application-core`, `adapter-*`, `shared-contract`, `sample-portfolio` 경계 위에 추가되어야 합니다. controller만 추가하거나 repository만 추가하는 식으로 경계가 무너지지 않도록 최소 onboarding slice를 고정합니다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- 새 도메인 기능 추가 시 module별 최소 변경 기준.
|
||||
- read-only / write use case 차이.
|
||||
- `domain-core` / `application-core` / `adapter-web` / `adapter-persistence` / `adapter-outbound` 책임 분리.
|
||||
- `shared-contract` 변경이 필요한 조건.
|
||||
- `sample-portfolio` 참조/복제/삭제 기준.
|
||||
- onboarding dry-run checklist SSOT.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 특정 비즈니스 도메인 선택.
|
||||
- code generator 구현.
|
||||
- IDE template 제공.
|
||||
- Spring Modulith `@ApplicationModule` 도입.
|
||||
- sample-portfolio 실제 scenario 구현. 이 항목은 `feature-sample-domain-contract-fixture`가 owner.
|
||||
- sample-off profile / dual-mode CI matrix / removal lifecycle / reference scaffolding. 이 항목은 `feature-sample-removal-adoption-contract`가 owner.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 branch의 결정 근거. multi-module 기본값은 company-case-study 근거가 중심이므로, 공식 best practice가 아니라 사례 기반 프로젝트 결정으로 취급한다.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/branch-notes/feature-skeleton-package-blueprint-contract]] | Phase C2 기본 module boundary와 dependency direction SSOT |
|
||||
| [[raw/branch-notes/feature-architecture-enforcement-rules]] | onboarding 결과를 Gradle/ArchUnit rule로 검증하는 enforcement 기준 |
|
||||
| [[raw/branch-notes/feature-application-port-usecase-contract]] | inbound `*UseCase` / outbound `*Port` 명명, `TransactionPort`, `@UseCaseCapability`, read-only 캡션 contract SSOT |
|
||||
| [[raw/branch-notes/feature-sample-domain-contract-fixture]] | sample-portfolio scenario/minimum-model owner (본 branch 는 consume only) |
|
||||
| [[raw/branch-notes/feature-sample-removal-adoption-contract]] | sample-off lifecycle / dual-mode CI matrix / removal / reference scaffolding owner (본 branch 는 consume only) |
|
||||
| [[raw/branch-notes/feature-resource-identifier-contract]] | 새 entity PK/ID 생성 정책(ULID server-assigned via domain `*IdFactory` port) owner — write slice 가 consume |
|
||||
| [[raw/branch-notes/feature-implementation-readiness-scorecard]] | 본 branch 의 dry-run checklist 를 consume 하는 readiness 게이트 |
|
||||
| [[raw/company-tech-blogs/woowahan-hexagonal-multimodule]] | Domain / Application / Framework / Bootstrap multi-module hexagonal 사례 |
|
||||
| [[raw/company-tech-blogs/modulith-kakaobank-techblog-2025]] | Gradle multi-module + Hexagonal에서 application/adapter 물리 분리와 Port 통신 사례 |
|
||||
| [[raw/official-docs/arch-hexagonal-cockburn]] | port와 adapter 분리의 원형 (`engineering-blog`, official standard 아님) |
|
||||
| [[raw/official-docs/arch-clean-architecture-uncle-bob]] | Dependency Rule 및 use case 중심 구조 사고 근거 (`engineering-blog`, official standard 아님) |
|
||||
| [[raw/official-docs/feature-first-uncle-bob-screaming-architecture-2011]] | framework가 아니라 use case / business 영역이 구조에서 드러나야 한다는 보조 근거 |
|
||||
| [[raw/company-tech-blogs/feature-first-sahibinden-package-by-layer-vs-feature]] | layer-first 대비 feature 응집도 사례. module 내부 package 책임 참고용 |
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [x] New Domain Module Slice 표를 기준으로 read-only/write onboarding checklist 정의 — 등급: `locally-verified`
|
||||
- [x] `domain-core` domain model/rule 추가 기준 정의 — 등급: `locally-verified`
|
||||
- [x] `application-core` use case / command-query / port 추가 기준 정의 — 등급: `locally-verified`
|
||||
- [x] `adapter-web` DTO / mapper / controller / contract test 추가 기준 정의 — 등급: `locally-verified`
|
||||
- [x] `adapter-persistence` entity / repository / mapper / migration 추가 기준 정의 — 등급: `locally-verified`
|
||||
- [x] `adapter-outbound` optional adapter 추가 조건 정의 — 등급: `documented-only` (optional 조건만 정의; 이번 dry-run 에 외부 adapter 없음)
|
||||
- [x] `shared-contract` 변경 승인 조건 정의 — 등급: `locally-verified`
|
||||
- [x] `sample-portfolio`을 import하지 않고 구조만 참조하는 dry-run 검증 정의 — 등급: `locally-verified`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- 기존 문서의 `features/{featureName}/{presentation,application,domain,infrastructure}` 기준은 2026-05-28부로 이전 기준으로 내린다.
|
||||
- 새 기본값은 module-first onboarding이다. 같은 도메인 기능의 파일이 여러 module에 생기더라도 dependency direction이 유지되면 정상이다.
|
||||
- onboarding checklist는 실제 code generator가 아니라 review/build 기준이다.
|
||||
- 2026-06-15 (branch-spec): 본 노트의 추상 모델(`*QueryUseCase`, "transaction/idempotency/capability declaration")은 그 이후 ca-tmpl 에서 `@UseCaseCapability` + `@RequiresPermission` + `TransactionPort` 로 구체화됐다. §구현 가이드가 이 실제 메커니즘을 anchor 로 쓰고, drift 는 §Audit & Findings 에 기록한다. capability 어휘 자체의 owner 는 본 branch 가 아니라 `feature-application-port-usecase-contract` + `feature-repository-access-permission-contract`(capabilities.yaml).
|
||||
- 2026-06-25 (implementation): `app-bootstrap` ArchUnit/JUnit 테스트에 `DomainFeatureOnboardingContractTest`와 test-only `dev.caskeleton.onboarding.*` FeatureAggregate dry-run slice를 추가해 read-only/write onboarding 성공 경로를 검증했다. `CleanArchitectureTest`에는 repository-backed `@UseCaseCapability`가 대응 `TransactionPort` 경계(`inRead`/`inWrite`/`inNew`)를 직접 호출하는지 검사하는 rule을 추가했다.
|
||||
- 2026-06-25 (cleanup): dry-run fixture 이름을 `Ticket`에서 `FeatureAggregate`로 바꿨다. 이유: app-bootstrap test fixture가 특정 업무 도메인을 skeleton production concept처럼 보이게 만들 수 있어, 온보딩 계약용 중립 명칭으로 정리했다.
|
||||
- 2026-06-25 (cleanup): onboarding positive fixture 파일을 `src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/` 아래로 이동해 package 선언(`dev.caskeleton.onboarding.*`)과 파일 경로를 일치시켰다. 이유: `bootstrap/architecture/allowed/onboarding` 경로와 synthetic package가 어긋나 `sampleOffTest` 컴파일과 IDE 해석에서 혼선을 만들었기 때문이다.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-05-22: 초기 문서의 onboarding 기준은 feature-first package slice였다.
|
||||
- 2026-05-28: onboarding 기준을 Gradle multi-module slice로 수정한다. / 이유: Phase C2 기본 구조가 `domain-core` / `application-core` / `adapter-*` / `shared-contract` / `app-bootstrap` / `sample-portfolio`로 바뀜. / 근거: [[raw/branch-notes/feature-skeleton-package-blueprint-contract]].
|
||||
- 2026-05-28: 새 도메인 기능의 기본 흐름은 domain model/rule → application use case/port → adapter-web/persistence/outbound 구현 → contract/architecture test 순서로 둔다. / 이유: 안쪽 module이 바깥 adapter를 알지 않게 하기 위함. / 근거: [[raw/official-docs/arch-clean-architecture-uncle-bob]], [[raw/official-docs/arch-hexagonal-cockburn]].
|
||||
- 2026-05-28: read-only feature는 write command, idempotency, outbox, persistence mutation을 생략할 수 있다. 단 query use case, inbound port, response mapper, contract test는 필수다. / 근거: `project-decision`.
|
||||
- 2026-05-28: write feature는 command, use case, outbound persistence port, transaction/idempotency decision, persistence adapter, contract test를 함께 추가해야 한다. / 근거: [[raw/branch-notes/feature-application-port-usecase-contract]].
|
||||
- 2026-05-28: `shared-contract` 변경은 response/error/header/logging/tracing/metrics/registry/annotation 같은 skeleton-wide contract일 때만 허용한다. 도메인 전용 타입은 `domain-core` 또는 adapter DTO에 둔다. / 근거: [[raw/branch-notes/feature-skeleton-package-blueprint-contract]].
|
||||
- 2026-05-28: `sample-portfolio`은 import 대상이 아니라 구조 참고 fixture다. production module이 sample-portfolio을 dependency로 선언하면 실패해야 한다. / 근거: [[raw/branch-notes/feature-architecture-enforcement-rules]].
|
||||
- 2026-05-28: dry-run checklist SSOT = 본 branch의 New Domain Module Slice + Read/Write Difference Table. `feature-implementation-readiness-scorecard`는 consume only로 둔다. / 근거: `project-decision`.
|
||||
- 2026-06-25: onboarding checklist는 문서 표만이 아니라 `DomainFeatureOnboardingContractTest`의 read-only/write FeatureAggregate dry-run fixture와 ArchUnit negative fixture로 검증한다. / 이유: controller-only 또는 transaction-less write 같은 누락을 리뷰 기억이 아니라 테스트 실패로 잡기 위함. / 검토한 대안: README 체크리스트만 유지. / 근거: `project-decision` + 로컬 검증(`./gradlew test`).
|
||||
|
||||
## New Domain Module Slice
|
||||
|
||||
| Module | Read-only feature | Write feature | Forbidden |
|
||||
|---|---|---|---|
|
||||
| `domain-core` | query response에 필요한 domain model / value object only as needed | aggregate/entity/value object/domain rule/domain event as needed | Spring/JPA/HTTP DTO/import, adapter type import |
|
||||
| `application-core` | query object, `*UseCase` inbound port, read outbound port if persistence needed, read-only use case | command object, `*UseCase` inbound port, outbound port, use case, transaction/idempotency/capability declaration | adapter implementation import, Spring Web/JPA implementation API, direct `@Transactional` |
|
||||
| `adapter-web` | request params/response DTO, mapper, controller, validation error mapping, contract test | request DTO, response DTO, mapper, controller, validation, idempotency/header handling, contract test | domain object direct response, persistence adapter direct call |
|
||||
| `adapter-persistence` | read entity/projection/repository/mapper only if DB read is needed | entity/repository/mapper/migration/write adapter implementation | controller/web DTO import, application use case import beyond port implementation |
|
||||
| `adapter-outbound` | optional; only if read use case calls external dependency | optional HTTP/messaging/cache/notification adapter implementation | direct adapter-to-adapter coupling |
|
||||
| `shared-contract` | normally no change | only if new skeleton-wide response/error/header/log/metric/registry contract is required | domain-specific type, business enum, feature-specific DTO |
|
||||
| `app-bootstrap` | bean wiring/profile update only when needed | bean wiring/profile update only when needed | domain policy implementation |
|
||||
| `sample-portfolio` | reference only; no production dependency | reference only; no production dependency | production module import/dependency |
|
||||
|
||||
## Read/Write Difference Table
|
||||
|
||||
| Slice item | Read-only | Write |
|
||||
|---|---|---|
|
||||
| inbound port | `*QueryUseCase` or query-specific `*UseCase` | command-specific `*UseCase` |
|
||||
| input model | query object or request parameters mapped in adapter | command object |
|
||||
| outbound port | read port only when persistence/external read needed | write port required when persistence/external mutation needed |
|
||||
| transaction | `readOnly` decision if DB read exists | `required` decision; propagation/isolation explicit when non-default |
|
||||
| idempotency | normally N/A | required decision for retryable external command / create command |
|
||||
| domain model/rule | as needed | required when invariant or state transition exists |
|
||||
| adapter-web test | response/validation contract | response/validation/idempotency/header contract |
|
||||
| persistence test | query mapping if DB read exists | mutation/rollback/constraint mapping |
|
||||
| architecture test | module boundary + no sample dependency | module boundary + no sample dependency + transaction/capability rule |
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시한다. `Decision ID` 는 이 branch-note 안에서 안정적으로 유지한다.
|
||||
> `선택 조건` 열(R2): "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 분기 없으면 `N/A`.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | onboarding 기준은 Gradle multi-module slice | Phase C2 multi-module skeleton 기준일 때. 학습/예제용 single-module 축소형이면 [[raw/branch-notes/feature-skeleton-package-blueprint-contract]] D8 의 responsibility-mapping 보존 변환표로 대체 | `raw/company-tech-blogs/woowahan-hexagonal-multimodule.md#WW-HEX-C1`, `raw/company-tech-blogs/modulith-kakaobank-techblog-2025.md#KAKAOBANK-MOD-C4`, `raw/branch-notes/feature-skeleton-package-blueprint-contract.md` | `company-case-study + project-decision` | company-tech-blog 사례를 공식 표준으로 승격 금지. ca-tmpl dry-run으로 별도 검증 필요 |
|
||||
| D2 | domain -> application -> adapter 방향으로 추가 | N/A (모든 새 도메인 기능 — inner module 이 outer adapter 를 알지 않게) | `raw/official-docs/arch-clean-architecture-uncle-bob.md`, `raw/official-docs/arch-hexagonal-cockburn.md#HEX-COCKBURN-ORIG-C3`, `raw/company-tech-blogs/woowahan-hexagonal-multimodule.md#WW-HEX-C2` | `engineering-blog + company-case-study` | 구체 file set은 ca-tmpl 자체 결정 |
|
||||
| D3 | read-only feature는 write/idempotency/outbox 생략 가능 | query-only feature(DB/외부 상태 mutation 없음)일 때 생략. mutation 발생 시 D4 | `project-decision`; `raw/branch-notes/feature-application-port-usecase-contract.md` (D9: read-only query use case = `readOnly` tx + `READ_REPOSITORY` capability) | `project-decision + sibling-branch-decision` | read-only 기준이 모호하면 기능별 임의 판단이 생길 수 있음 |
|
||||
| D4 | write feature는 command/use case/port/persistence/transaction/idempotency decision을 함께 요구 | state mutation / persistence write 가 있을 때. read-only면 D3 | `raw/branch-notes/feature-application-port-usecase-contract.md` (D1 `*UseCase`/`*Port`, D3 `TransactionPort`, D14 idempotency 게이트) | `project-decision + sibling-branch-decision` | TransactionPort 세부 옵션은 아직 `needs-confirmation` 항목이 남아 있음. idempotency=KEYED 는 [[raw/branch-notes/feature-rate-limit-idempotency-contract]] merge 전까지 금지([[raw/branch-notes/feature-application-port-usecase-contract]] D14) |
|
||||
| D5 | `shared-contract`는 skeleton-wide operational contract만 허용 | 새 계약이 skeleton-wide(response/error/header/log/tracing/metrics/registry/annotation)일 때만 변경. domain-specific 타입이면 domain-core 또는 adapter DTO | `raw/branch-notes/feature-skeleton-package-blueprint-contract.md`, `raw/official-docs/feature-first-uncle-bob-screaming-architecture-2011.md#SCREAM-C1` | `project-decision + engineering-blog` | shared module이 common dumping ground가 될 위험 |
|
||||
| D6 | `sample-portfolio`은 구조 참고 fixture이며 production dependency 금지 | N/A (항상 — production module 의 sample-portfolio dependency 금지) | `raw/branch-notes/feature-architecture-enforcement-rules.md` (D7 `production_code_does_not_depend_on_sample_portfolio` ArchUnit rule), `raw/branch-notes/feature-skeleton-package-blueprint-contract.md` | `project-decision (ArchUnit-enforced)` | 외부 직접 근거는 약함. Gradle/ArchUnit failure로 실증 필요 |
|
||||
| D7 | readiness scorecard는 본 branch checklist를 consume only | N/A (항상 — dry-run checklist SSOT 는 본 branch; scorecard 는 consume) | `project-decision`; `raw/branch-notes/feature-implementation-readiness-scorecard.md` (D5: real-domain dry-run checklist 가 onboarding branch 를 consume) | `project-decision + sibling-branch-decision` | scorecard branch가 자체 checklist를 유지하면 SSOT 충돌 발생 |
|
||||
| D8 | onboarding checklist는 executable dry-run fixture + ArchUnit negative fixture로 검증 | ca-tmpl template branch 에서 새 도메인 온보딩 계약을 release-blocking guardrail 로 다룰 때. 단순 문서 안내만 필요한 fork 에서는 문서 체크리스트로 축소 가능 | `UNSUPPORTED_DECISION` — source 는 port/adapter 분리 원칙을 말하지만 test fixture 방식은 ca-tmpl 구현 선택; supporting project evidence: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/DomainFeatureOnboardingContractTest.java`, `CleanArchitectureTest.java` | `project-decision + locally-verified` | ArchUnit 정적 분석은 direct call 만 확인한다. helper 로 숨긴 transaction boundary 는 code review concern |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> *결정 (Decisions)* 이 "*무엇* 을 할 것인가" 라면, 본 §는 "*어디에 어떻게* 구현될 것인가" 의 사전 명세. 본 branch 는 *통합/소비자 계약* 이므로, 각 slice 의 mechanism owner 는 sibling branch 에 있고 여기서는 **새 도메인 기능을 얹을 때 module 별로 어떤 파일을 어디에 추가하는가**를 고정한다.
|
||||
>
|
||||
> **Anchor 출처**: 모든 경로/클래스/rule 명은 `/home/donghyeon/workspace/ca-tmpl` @ HEAD 의 실제 코드에서 확인(2026-06-15 branch-spec ground-truth read). 코드 미확인 항목은 `planned` 로 표기.
|
||||
|
||||
### 1. New domain feature placement & dependency direction
|
||||
|
||||
> **Trace**: D1(multi-module slice) + [[raw/branch-notes/feature-skeleton-package-blueprint-contract]] D1~D5; D2(domain→application→adapter) + [[raw/branch-notes/feature-architecture-enforcement-rules]] D1~D7. 루트 패키지 `dev.caskeleton.*`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — module/package 배치와 dependency 방향은 blueprint/enforcement sibling 이 결정·강제(`actually-implemented`).
|
||||
|
||||
새 도메인 기능 `<X>` 추가 시 module 별 anchor (모두 `locally-verified` — ArchUnit/Gradle task 가 강제):
|
||||
|
||||
| Module | 추가 위치 (package) | 명명 | 강제 rule (CleanArchitectureTest / Gradle) |
|
||||
|---|---|---|---|
|
||||
| `domain-core` | `dev.caskeleton.domain.<x>.{model,vo,event,service}` | `@AggregateRoot`/`@ValueObject`/`@DomainEvent` (`dev.caskeleton.domain.stereotype`, record) | `domain_is_pure`, `domain_has_no_logger`, `value_objects_have_no_public_no_arg_constructor`, `aggregate_root_setters_are_not_public`, `domain_events_are_records` |
|
||||
| `application-core` | `dev.caskeleton.application.{usecase,command,query}` (+ outbound `*Port` interface) | inbound `*UseCase`, outbound `*Port` (application-port D1) | `inbound_port_implementations_end_with_use_case`, `inbound_port_implementations_declare_capability`, `application_does_not_depend_on_adapters_or_transport` |
|
||||
| `adapter-web` | `dev.caskeleton.adapter.web.{controller,dto,mapper}` | `*Controller`(returns `Envelope<T>`), `*Request`/`*Response` DTO | `controllers_do_not_return_domain_or_entity_types`, `web_dtos_stay_in_web_adapter`, `application_methods_do_not_accept_web_dtos`, `request_dtos_do_not_silence_unknown_fields` |
|
||||
| `adapter-persistence` | `dev.caskeleton.adapter.persistence.<x>.{entity,*JpaRepository,mapper}` + `src/main/resources/db/migration/V<n>__<x>.sql` (Flyway) | `*Entity`(extends `AuditableEntity`), `*JpaRepository` | `persistence_adapter_does_not_depend_on_web_or_outbound_adapters` |
|
||||
| `adapter-outbound` | `dev.caskeleton.adapter.outbound.<x>.*` (optional) | `*Adapter` implementing application `*Port` | `outbound_adapter_does_not_depend_on_web_or_persistence_adapters`, `outbound_adapter_method_returns_only_domain_or_primitives` |
|
||||
| 전 module dependency edge | — | — | Gradle task `verifyCleanArchitectureDependencies` (`src/build.gradle:54-92`, `allowedProjectDependencies` 화이트리스트) |
|
||||
|
||||
### 2. Read-only onboarding slice
|
||||
|
||||
> **Trace**: D3 + [[raw/branch-notes/feature-application-port-usecase-contract]] D9 (read-only query use case = `readOnly` tx + `READ_REPOSITORY` capability). 추상 Read/Write Difference Table 의 read-only 열을 실제 메커니즘으로 고정.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: read 가 DB 를 전혀 안 탈 때 outbound read port 자체를 생략할지 — capability `NONE` vs `READ_REPOSITORY` 선택은 기능별 trade-off(persistence 의존 0 이면 `NONE`). application-port 가 *원칙*만 권고하고 feature 별 detail 은 권고 안 함.
|
||||
|
||||
필수 파일 (이 중 하나라도 빠지면 review/build 실패):
|
||||
|
||||
| 추가물 | 위치/형태 | 비고 |
|
||||
|---|---|---|
|
||||
| Query 객체 | `application/query/<X>Query.java` implements `Query` (marker) | immutable record |
|
||||
| inbound port | `application/usecase/<X>QueryUseCase.java` implements `QueryUseCase<Q,R>` | 이름 `...UseCase` 로 끝나야 함 (rule `inbound_port_implementations_end_with_use_case`) |
|
||||
| capability | `@UseCaseCapability(transactionMode = READ_ONLY, repositoryAccess = READ_REPOSITORY \| NONE, idempotency = NOT_IDEMPOTENT)` | **필수 annotation** (rule `inbound_port_implementations_declare_capability`); 경로 `application/capability/UseCaseCapability.java` |
|
||||
| (선택) read outbound port | `application/.../<X>ReadPort.java` (`*Port`) | DB/외부 read 필요 시에만 |
|
||||
| response mapper + controller | `adapter/web/mapper/<X>ResponseMapper`, `adapter/web/controller/<X>Controller` (`Envelope<T>` 반환) | domain object 직접 반환 금지 |
|
||||
| contract test | `app-bootstrap/src/test/.../contract/<X>...Test`; sample 참조 `sample-portfolio/.../WorkLogControllerWireTest`·`ListRecentWorkLogSummariesUseCaseTest` | 최소 assert: HTTP 200 + `Envelope<T>.data` 매핑 + unknown-field 거부(rule `request_dtos_do_not_silence_unknown_fields`) + domain object 직접 노출 없음 |
|
||||
|
||||
**생략 가능 (read-only)**: `Command`, idempotency store/executor, outbox, persistence write adapter, `@RequiresPermission`, `TransactionPort.inWrite`.
|
||||
|
||||
### 3. Write onboarding slice
|
||||
|
||||
> **Trace**: D4 + [[raw/branch-notes/feature-application-port-usecase-contract]] D1(`*UseCase`/`*Port`)·D3(`TransactionPort`)·D9·D14(idempotency 게이트). 예시 실증: `sample-portfolio/.../application/worklog/CreateWorkLogUseCase.java` (`@UseCaseCapability(transactionMode = WRITE, idempotency = NOT_IDEMPOTENT, repositoryAccess = WRITE_REPOSITORY)`).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: ① idempotency 모드(`IDEMPOTENT` vs `KEYED`) — **`KEYED` 는 [[raw/branch-notes/feature-rate-limit-idempotency-contract]] merge 전까지 금지**([[raw/branch-notes/feature-application-port-usecase-contract]] D14), 그 전엔 `NOT_IDEMPOTENT`/`IDEMPOTENT` 만. ② transaction 격리/전파 비기본값 — `inWrite`(기본) vs `inNew`(outbox/audit/보상 전용); 비기본 propagation 은 기능별 trade-off 이며 application-port D12(`inNew` = 새 JDBC connection, loop 호출 금지)를 따른다.
|
||||
|
||||
필수 파일 (write):
|
||||
|
||||
| 추가물 | 위치/형태 | 강제 rule |
|
||||
|---|---|---|
|
||||
| Command 객체 | `application/command/<X>Command.java` implements `Command` | immutable record |
|
||||
| inbound port | `application/usecase/<X>UseCase.java` implements `CommandUseCase<C,R>` | `inbound_port_implementations_end_with_use_case` |
|
||||
| capability | `@UseCaseCapability(transactionMode = WRITE, repositoryAccess = WRITE_REPOSITORY, idempotency = ...)` | `inbound_port_implementations_declare_capability` |
|
||||
| permission | `@RequiresPermission(...)` (`application/security/RequiresPermission.java`) | `mutating_use_cases_declare_required_permission` (WRITE_REPOSITORY ⇒ 필수) |
|
||||
| transaction | `TransactionPort.inWrite(...)` 콜백 (`application/transaction/TransactionPort.java`) — 직접 `@Transactional` 금지 | `application_does_not_use_spring_transactional_annotation` |
|
||||
| outbound write port | `application/.../<X>WritePort.java` (`*Port`) | `read_only_use_cases_do_not_call_repository_write_methods`(capability 정합) |
|
||||
| persistence adapter + migration | `adapter/persistence/<x>/{<X>Entity, <X>JpaRepository, <X>EntityMapper}` + `db/migration/V<n>__<x>.sql` | `persistence_adapter_does_not_depend_on_web_or_outbound_adapters` |
|
||||
| (위임) entity PK/ID 생성 | server-assigned ULID via domain `*IdFactory` port (auto-increment/UUID v4 금지) | [[raw/branch-notes/feature-resource-identifier-contract]] D5 소관 — 본 branch 범위 밖, consume only |
|
||||
| web DTO/mapper/controller + contract test | read-only 와 동일 + idempotency/header handling | `controllers_do_not_return_domain_or_entity_types` 등 |
|
||||
|
||||
### 4. shared-contract change gate
|
||||
|
||||
> **Trace**: D5 + [[raw/branch-notes/feature-architecture-enforcement-rules]] D6. shared-contract 는 도메인 기능 추가 시 **원칙적으로 변경 없음** — skeleton-wide 계약일 때만.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — 허용 package 목록은 ArchUnit rule 이 화이트리스트로 강제.
|
||||
|
||||
`shared_contract_contains_only_operational_contract_packages` 가 허용하는 package 만 변경 가능: `error`(`Category` enum 10값 — VALIDATION/AUTH/AUTHZ/NOT_FOUND/CONFLICT/RATE_LIMIT/TRANSIENT_DEPENDENCY/PERMANENT_DEPENDENCY/DATA_INTEGRITY/INTERNAL), `response`(`Envelope<T>`), `request`, `operation`, `headers`, `logging`, `tracing`, `metrics`, `registry`, `annotation`, `security`(`Permission`), `concurrency`. **도메인 전용 타입/비즈니스 enum/feature DTO 는 금지** → `domain-core` 또는 adapter DTO 로. 새 error code 는 `docs/registries/error-codes.yaml` 에 `owner_branch`(= 그 기능 branch) + 기존 `Category` enum 값으로 추가(신규 category 추가는 `feature-operational-error-observability-foundation` 소관 — 본 branch 범위 밖).
|
||||
|
||||
### 5. sample-portfolio isolation & dry-run
|
||||
|
||||
> **Trace**: D6 + [[raw/branch-notes/feature-architecture-enforcement-rules]] D7; D7(scorecard consume) + [[raw/branch-notes/feature-implementation-readiness-scorecard]] D5. sample scenario/minimum-model 의 owner 는 [[raw/branch-notes/feature-sample-domain-contract-fixture]] D5 — 본 branch 는 구조 참고만.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — 격리는 ArchUnit + Gradle task 가 강제.
|
||||
|
||||
- production module 의 `build.gradle` 이 `implementation project(':sample-portfolio')` 를 선언하면 실패 — `verifyCleanArchitectureDependencies`(allowedProjectDependencies 에서 sample-portfolio 제외) + ArchUnit `production_code_does_not_depend_on_sample_portfolio`.
|
||||
- 새 도메인 기능은 sample-portfolio 의 `worklog` 구조(domain→application→web→persistence 한 슬라이스)를 **읽고 모방**하되 import 하지 않는다. sample 의 Flyway 는 `db/sample-migration/`(production 의 `db/migration/` 과 분리).
|
||||
- **dry-run checklist SSOT = 본 branch 의 §New Domain Module Slice + §Read/Write Difference Table + 본 §구현 가이드.** [[raw/branch-notes/feature-implementation-readiness-scorecard]](D5) 는 이를 consume 만 하고 자체 checklist 를 두지 않는다.
|
||||
|
||||
## 구현 결과
|
||||
|
||||
| Evidence item | File / command | Result | Evidence grade |
|
||||
|---|---|---|---|
|
||||
| Read-only onboarding dry-run | `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/DomainFeatureOnboardingContractTest.java` + `dev.caskeleton.onboarding.application.query/ListFeatureAggregatesQuery`, `FeatureAggregateSummaryQueryPort`, `ListFeatureAggregatesUseCase`, web DTO/mapper/controller fixture | query/use case/mapper/controller 존재, write command/write port 부재, ArchUnit rules no violation | `locally-verified` |
|
||||
| Write onboarding dry-run | `dev.caskeleton.onboarding.domain.feature.*`, `CreateFeatureAggregateCommand`, `CreateFeatureAggregateUseCase`, `FeatureAggregateWritePort`, persistence entity/mapper/repository adapter, `src/app-bootstrap/src/test/resources/db/onboarding-migration/V999__feature_aggregate.sql` | domain/id factory/command/use case/write port/persistence/migration artifact 존재, ArchUnit rules no violation | `locally-verified` |
|
||||
| Transaction boundary enforcement | `CleanArchitectureTest.use_case_capability_matches_transaction_port_boundary` + `MissingTransactionBoundaryUseCase` negative fixture | `WRITE_REPOSITORY` without `TransactionPort.inWrite` is caught | `locally-verified` |
|
||||
| shared-contract scope enforcement | tightened `shared_contract_contains_only_operational_contract_packages` allowlist + `violations/shared/worklog/WorkLogStatus` negative fixture | domain-specific `..shared.worklog..` package is caught | `locally-verified` |
|
||||
| sample isolation | `DomainFeatureOnboardingContractTest` verifies onboarding fixtures have no `sample-portfolio` dependency; existing `SampleRemovalSmokeContractTest` keeps production Gradle sample deps test-scoped | production/sample boundary remains guarded | `locally-verified` |
|
||||
| Neutral fixture naming | `Ticket*` test fixture names renamed to `FeatureAggregate*`; migration renamed to `V999__feature_aggregate.sql` | onboarding fixture no longer reads as a concrete skeleton domain | `locally-verified` |
|
||||
| Package-path alignment | onboarding positive fixture moved to `src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/`; package declarations remain `dev.caskeleton.onboarding.*` | source path now matches package and both test/sampleOffTest compile outputs contain onboarding classes | `locally-verified` |
|
||||
|
||||
### Verification commands (2026-06-25)
|
||||
|
||||
| Command | Result |
|
||||
|---|---|
|
||||
| `./gradlew verifyCleanArchitectureDependencies` | `BUILD SUCCESSFUL` |
|
||||
| `./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest'` | `BUILD SUCCESSFUL` |
|
||||
| `./gradlew :app-bootstrap:test --tests '*DomainFeatureOnboardingContractTest' --tests '*ArchitectureViolationFixtureTest'` | `BUILD SUCCESSFUL` |
|
||||
| `./gradlew test` | `BUILD SUCCESSFUL` |
|
||||
| `./gradlew check` | `BUILD SUCCESSFUL` (checkstyle/SpotBugs report output remains non-fatal under current Gradle settings) |
|
||||
| `./gradlew :app-bootstrap:spotlessCheck :app-bootstrap:test --tests '*DomainFeatureOnboardingContractTest' --tests '*ArchitectureViolationFixtureTest'` | `BUILD SUCCESSFUL` after neutral fixture rename |
|
||||
| `./gradlew :app-bootstrap:compileTestJava :app-bootstrap:compileSampleOffTestJava --rerun-tasks` | `BUILD SUCCESSFUL` after package-path alignment |
|
||||
| `./gradlew :app-bootstrap:test --tests '*DomainFeatureOnboardingContractTest' --tests '*ArchitectureViolationFixtureTest' :app-bootstrap:sampleOffTest --tests '*DomainFeatureOnboardingContractTest' --tests '*ArchitectureViolationFixtureTest'` | `BUILD SUCCESSFUL` after package-path alignment |
|
||||
| `./gradlew check` | `BUILD SUCCESSFUL` after package-path alignment |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4(깊이 게이트) 캡처용. 정상 경로 외에 *구현 중 부딪힐* 실패/엣지/다른 계약 의존을 미리 열거.
|
||||
|
||||
- **실패·엣지 경로** (각 경로 = 어느 rule 이 잡는가):
|
||||
- read-only feature 를 write 파일 없이 추가 → `verifyCleanArchitectureDependencies` + ArchUnit 통과해야 정상(Claim 1). 반대로 controller 만 추가하고 query use case/mapper 가 없으면 review 실패(테스트 계약).
|
||||
- write feature 에서 `@UseCaseCapability` 누락 → `inbound_port_implementations_declare_capability` 실패. `@RequiresPermission` 누락(WRITE_REPOSITORY) → `mutating_use_cases_declare_required_permission` 실패. 직접 `@Transactional` 사용 → `application_does_not_use_spring_transactional_annotation` 실패.
|
||||
- capability 와 실제 호출 불일치(예: `READ_REPOSITORY` 인데 save/delete 호출) → `read_only_use_cases_do_not_call_repository_write_methods` 실패. `bulkWrite=true` 인데 `WRITE_REPOSITORY` 아님 → `bulk_write_capability_requires_write_repository_access` 실패.
|
||||
- controller 가 domain/JPA entity 직접 반환 → `controllers_do_not_return_domain_or_entity_types` 실패. application 메서드가 web DTO 수신 → `application_methods_do_not_accept_web_dtos` 실패.
|
||||
- `shared-contract` 에 domain type 유입 → `shared_contract_contains_only_operational_contract_packages` 실패. `jakarta.validation` 을 domain/application 에서 import → `validation_constraints_stay_at_web_boundary` 실패.
|
||||
- idempotency `KEYED` 사용 시도 → **계약 게이트 위반**(application-port D14, `feature-rate-limit-idempotency-contract` 미merge). 빌드가 아니라 review/계약 차원에서 차단.
|
||||
- empty anchor 엣지: 새 module/feature 의 빈 anchor package 가 ArchUnit "empty should" 로 오탐될 수 있음 → 유효 rule 에 `allowEmptyShould(true)` (선례: [[raw/errors/archunit-empty-should-anchor-2026-05-27]]).
|
||||
- Flyway version 충돌 엣지: 동시 onboarding 중인 두 write feature 가 같은 `db/migration/V<n>__*.sql` 번호를 잡으면 startup/`flywayValidate` 실패(실코드 V1/V3/V4 이미 점유). 번호 할당은 merge 순서 기준 단조 증가로 고정하고 충돌 시 빌드 실패를 신호로 받는다.
|
||||
- **다른 계약 의존** (이 계약이 바뀌면 본 branch 의 onboarding slice 영향):
|
||||
- [[raw/branch-notes/feature-skeleton-package-blueprint-contract]] D1~D9 — module/package boundary·dependency direction. 변경 시 §구현 가이드 §1 placement 표 갱신.
|
||||
- [[raw/branch-notes/feature-architecture-enforcement-rules]] D1~D8 — ArchUnit/Gradle rule 명·범위. rule rename 시 본 노트의 rule 인용 갱신 필요.
|
||||
- [[raw/branch-notes/feature-application-port-usecase-contract]] D1/D3/D9/D14 — `*UseCase`/`*Port` 명명, `TransactionPort`, read-only capability, idempotency 게이트. consume only.
|
||||
- [[raw/branch-notes/feature-sample-domain-contract-fixture]] D5 — sample scenario/minimum-model owner. 본 branch 는 구조 참고만.
|
||||
- [[raw/branch-notes/feature-implementation-readiness-scorecard]] D5 — 본 branch checklist 를 consume (역방향 의존). 본 §의 checklist 구조가 바뀌면 scorecard area #15 dry-run 매핑 영향.
|
||||
|
||||
## Audit & Findings (2026-06-15 branch-spec — ca-tmpl ground-truth 대조)
|
||||
|
||||
> ca-tmpl 실코드 대조에서 발견한 노트↔구현 drift. 본 branch 결정 영역 *밖* 의 것은 자동 rewrite 하지 않고 *정합 권고*만 남긴다.
|
||||
|
||||
- **DRIFT① — 추상 capability 모델 → `@UseCaseCapability` 구체화**: 노트의 New Domain Module Slice/Read-Write Table 은 "transaction/idempotency/capability declaration" 을 추상 서술. 실제 ca-tmpl 은 `@UseCaseCapability(transactionMode, idempotency, repositoryAccess, externalOutboundAllowed, sensitiveRead, bulkWrite, crossTenantAdmin)` + `@RequiresPermission` + `TransactionPort` 로 구체화(노트 created 2026-05-22 < capabilities.yaml 2026-06-05). **판정: 추상 표는 contract 로 유효하게 유지**, §구현 가이드가 구체 메커니즘을 anchor. capability 어휘 자체의 owner 는 `feature-application-port-usecase-contract` + `feature-repository-access-permission-contract`(capabilities.yaml) — **OUT_OF_BRANCH_SCOPE**, 본 branch 에서 재결정 안 함.
|
||||
- **DRIFT② — `port/in`·`port/out` 트리 미실현**: blueprint 계획 트리는 `application/port/in`·`port/out`. 실제 application-core 는 `usecase/`,`command/`,`query/`,`capability/`,`transaction/`,`idempotency/`,`security/` (inbound port = `usecase/` 의 `*UseCase`, outbound = `*Port` co-located). owner = [[raw/branch-notes/feature-application-port-usecase-contract]] D1. **OUT_OF_BRANCH_SCOPE** — 본 §구현 가이드는 실제 경로(`usecase/`)를 anchor 로 사용.
|
||||
- **DRIFT③ — 9번째 module `adapter-identifier`**: 노트의 New Domain Module Slice 는 8 module. 실제 `settings.gradle` 에 `adapter-identifier`(ID 생성, `feature-resource-identifier-contract` 소관) 추가됨. 새 도메인 기능이 보통 건드리지 않음. **OUT_OF_BRANCH_SCOPE** — 각주로만: "adapter module 은 책임별 확장 가능(예: `adapter-identifier`)".
|
||||
|
||||
## 테스트 계약
|
||||
|
||||
- 새 read-only feature가 query use case / inbound port / response mapper / contract test 없이 controller만 추가되면 실패.
|
||||
- 새 write feature가 command / use case / outbound port / persistence adapter / transaction decision 중 하나 없이 추가되면 실패.
|
||||
- `domain-core`가 Spring/JPA/HTTP DTO/adapter type을 import하면 실패.
|
||||
- `application-core`가 adapter implementation을 직접 import하면 실패.
|
||||
- `adapter-web` controller가 domain object를 response로 직접 반환하면 실패.
|
||||
- `shared-contract`에 domain-specific class/package가 추가되면 실패.
|
||||
- production module이 `sample-portfolio`에 의존하면 실패.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다. 구현 전/중/후에 실제로 검증해야 하는 주장을 분리한다.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| read-only domain onboarding이 write-only 파일 없이도 contract/architecture test를 통과한다 | read-only file set은 ca-tmpl 자체 결정 | 가상 read-only feature 추가 → command/idempotency/outbox 없음 → Gradle/ArchUnit/contract test 통과 확인 | `locally-verified` — `DomainFeatureOnboardingContractTest.read_only_onboarding_slice_has_minimum_contract_and_no_write_artifacts`, `./gradlew test` |
|
||||
| write domain onboarding에서 command/use case/port/persistence/transaction decision 중 하나가 빠지면 실패한다 | 누락 탐지는 custom ArchUnit/contract rule 필요 | violating write feature 추가 → 누락 유형별 실패 메시지 확인 (capability/permission/transaction rule) | `locally-verified` — `use_case_capability_matches_transaction_port_boundary`, 기존 capability/permission fixture tests, `./gradlew test` |
|
||||
| `shared-contract`에 domain-specific class가 들어오면 실패한다 | shared module scope rule 구현 필요 | `shared-contract/.../worklog/WorkLogStatus` 추가 → ArchUnit `shared_contract_contains_only_operational_contract_packages` 실패 확인 | `locally-verified` — `shared_contract_scope_rule_catches_domain_specific_shared_package`, `./gradlew test` |
|
||||
| production code가 `sample-portfolio`을 import하면 실패한다 | sample 격리는 project decision이며 실증 필요 | production module에 `implementation project(':sample-portfolio')` 추가 → `verifyCleanArchitectureDependencies` 실패 확인 | `locally-verified` — `verifyCleanArchitectureDependencies`, `production_code_does_not_depend_on_sample_portfolio`, onboarding fixture no-sample assertion |
|
||||
| adapter-web controller가 domain object를 response로 직접 반환하면 실패한다 | module boundary만으로는 direct return을 잡지 못할 수 있음 | controller violating method 추가 → ArchUnit `controllers_do_not_return_domain_or_entity_types` 실패 확인 | `locally-verified` — existing `DomainReturningControllerFixture` negative test + onboarding controller no-violation test |
|
||||
| scorecard area #15가 본 branch의 checklist를 consume only로 유지한다 | cross-branch governance는 자동 강제가 어려움 | [[raw/branch-notes/feature-implementation-readiness-scorecard]]`에서 자체 dry-run checklist가 없는지 grep 검증 (해당 branch D5 가 본 branch 를 consume 으로 선언함을 확인) | `locally-verified` — `rg -n 'dry-run checklist|feature-domain-feature-onboarding-contract|consume|area #15|area adoption|adoption' raw/branch-notes/feature-implementation-readiness-scorecard.md` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- Gradle wrapper sandbox lock: 최초 focused test 실행이 `~/.gradle/.../gradle-9.0.0-bin.zip.lck (Read-only file system)` 로 실패해 권한 상승으로 재실행했다. 별도 기록: [[raw/errors/gradle-wrapper-sandbox-lock-2026-06-25]].
|
||||
- Onboarding fixture package-path mismatch: `FeatureAggregate*` fixture의 package 선언과 파일 경로가 어긋나 `compileSampleOffTestJava`에서 패키지를 찾지 못했다. fixture를 `src/app-bootstrap/src/test/java/dev/caskeleton/onboarding/` 아래로 이동해 해결했다. 별도 기록: [[raw/errors/onboarding-fixture-package-path-mismatch-2026-06-25]].
|
||||
- zsh quoting 실수: `rg` 패턴에 backtick 을 double quote 안에 넣어 `command not found: adoption` 이 발생했다. single quote 로 재실행해 scorecard consume-only evidence 를 확인했다.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/feature-first-sahibinden-package-by-layer-vs-feature]]
|
||||
- [[raw/company-tech-blogs/hexagonal-woowahan-techblog-2023]]
|
||||
- [[raw/company-tech-blogs/layer-first-kamilmazurek-github-template]]
|
||||
- [[raw/company-tech-blogs/modulith-arawn-github-modular-monoliths-spring]]
|
||||
- [[raw/company-tech-blogs/modulith-kakaobank-techblog-2025]]
|
||||
- [[raw/company-tech-blogs/onion-allegro-tech-blog-2023]]
|
||||
- [[raw/official-docs/domain-fowler-anemic-vs-rich-model]]
|
||||
- [[raw/official-docs/domain-vaughn-vernon-aggregate-root]]
|
||||
- [[raw/official-docs/feature-first-uncle-bob-screaming-architecture-2011]]
|
||||
- [[raw/official-docs/hexagonal-cockburn-wikipedia-summary]]
|
||||
- [[raw/official-docs/hexagonal-thombergs-buckpal-github]]
|
||||
- [[raw/official-docs/layer-first-baeldung-clean-architecture-spring-boot]]
|
||||
- [[raw/official-docs/modulith-spring-official-doc]]
|
||||
- [[raw/official-docs/onion-palermo-original-2008]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: interviews:start -->
|
||||
- [[raw/interviews/clean-architecture-domain-onboarding-guardrails]]
|
||||
<!-- GENERATED: interviews:end -->
|
||||
|
||||
<!-- GENERATED: errors:start -->
|
||||
- [[raw/errors/gradle-wrapper-sandbox-lock-2026-06-25]]
|
||||
<!-- GENERATED: errors:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/executable-clean-architecture-onboarding-2026-06-25]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
> 본 feature branch 는 leaf — 자식 자료 없음. Phase C2 실 코드 작성 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### Sub-branches (세부 작업)
|
||||
|
||||
- (없음)
|
||||
|
||||
### 오류 기록 (이 branch 작업 중 발생)
|
||||
|
||||
- [[raw/errors/gradle-wrapper-sandbox-lock-2026-06-25]] — Gradle wrapper/test 실행이 sandbox 밖 `~/.gradle` lock 파일 쓰기에서 실패한 재현 가능한 도구 문제.
|
||||
- [[raw/errors/onboarding-fixture-package-path-mismatch-2026-06-25]] — synthetic onboarding fixture package와 source path가 불일치해 sampleOffTest 컴파일이 실패한 문제.
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- [[raw/interviews/clean-architecture-domain-onboarding-guardrails]] — Clean Architecture 템플릿에서 새 도메인 온보딩을 문서가 아니라 실행 가능한 guardrail 로 검증하는 방법.
|
||||
|
||||
### 강의 (이 작업을 위해 학습한 강의)
|
||||
|
||||
- (없음)
|
||||
|
||||
### job-posting tie-ins (이 작업에서 파생된 글감)
|
||||
|
||||
- [[raw/blog-topics/executable-clean-architecture-onboarding-2026-06-25]] — multi-module Clean Architecture onboarding checklist 를 ArchUnit/JUnit dry-run 으로 고정한 경험 글감.
|
||||
- job-posting tie-ins: 없음.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- [[raw/daily-notes/2026-05-27]]
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움. `/ingest`가 이 섹션을 기준으로 wiki/projects/에 추출.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-domain-modeling-guardrails.md
|
||||
@@ -0,0 +1,411 @@
|
||||
---
|
||||
title: branch / feature-domain-modeling-guardrails
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-036
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-036
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
branch: feature-domain-modeling-guardrails
|
||||
parent_branch:
|
||||
governing_docs: [wiki/projects/ca-tmpl/privacy-file-domain-modeling, wiki/projects/ca-tmpl/clean-architecture-package-layout]
|
||||
related_projects: [ca-skeleton]
|
||||
tags: [branch, ca-skeleton, domain, modeling, guardrails]
|
||||
created: 2026-05-22
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 12147734b0020a89b2ffd64b9840c0a563c7a44fa50b2c121596005623139fe7
|
||||
---
|
||||
|
||||
# branch: feature-domain-modeling-guardrails
|
||||
|
||||
> Layer: `raw/branch-notes/` — domain layer가 framework와 persistence에 오염되지 않도록 modeling guardrail을 정의합니다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (§<관련 섹션>) 의 결정/근거/금지 사항을 정제한다.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: domain model forbidden dependency fixture가 실패한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1` | Gradle multi-module에서 domain-core·application-core·adapter-*·shared-contract·app-bootstrap·sample-portfolio 책임을 분리한다 | domain-core의 framework·persistence 의존 금지 경계에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
도메인을 바로 얹을 수 있는 skeleton이 되려면 domain layer가 깨끗해야 합니다. entity, value object, domain service, domain event의 역할을 구분하고, framework annotation이나 persistence model이 domain으로 들어오는 것을 막습니다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- entity/value object/domain service/domain event 구분.
|
||||
- domain invariant 위치.
|
||||
- domain forbidden dependency.
|
||||
- aggregate state mutation 기준.
|
||||
- domain exception 범위.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- DDD 전술 패턴 전체 강제.
|
||||
- 특정 aggregate 설계.
|
||||
- business naming convention.
|
||||
|
||||
## TODO
|
||||
|
||||
> TODO drained 2026-05-22 — 결정은 아래 "결정 사항" / "Decisionized Work Items" / "테스트 계약" 참조. entity/value object/domain service, invariant 위치, aggregate mutation, forbidden dependency, domain exception, domain event modeling 모두 표 row 또는 결정 라인으로 반영됨. 잔존 TODO 없음.
|
||||
|
||||
## Work Item Contract
|
||||
|
||||
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
|
||||
|
||||
| field | required | rule |
|
||||
| --- | --- | --- |
|
||||
| Decision | yes | 구현자가 선택해야 하는 기본값 |
|
||||
| Allowed | yes | 허용되는 예외와 조건 |
|
||||
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
|
||||
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
|
||||
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
|
||||
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
|
||||
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- 2026-06-05 ground-truth 대조 (`/branch-spec`): ca-tmpl `domain_is_pure` ArchUnit rule (`CleanArchitectureTest.java:36-57`) 이 `..domain..` 의 Spring/JPA/Hibernate/Lombok/cross-layer import 를 금지 — **owner 는 [[raw/branch-notes/feature-architecture-enforcement-rules]] D3** (rule 의 `.as(...)` 주석에 명시). 본 branch 의 D1(framework-neutral) 은 이 rule 을 *재정의하지 않고 위임/재사용* 한다 (자세한 정합/drift 는 §Audit & Findings).
|
||||
- 본 branch 의 modeling-specific guardrail (VO constructor / aggregate mutator 가시성 / logger ban / domain event) 은 모두 **코드 미존재** = `planned`. `@ValueObject`·`@AggregateRoot`·`@DomainEvent` annotation 도 `src/` grep 결과 미존재. domain-core 모듈에는 현재 `identifier/ResourceId`·`IdFactory` 만 존재.
|
||||
- 2026-06-05 **C2 구현 완료** (`locally-verified`): 위 5개 modeling-specific guardrail 을 전부 구현. 자세한 구현 facts/검증/상태 전이는 §구현 기록 (2026-06-05) 참조. §진행 중 메모의 "코드 미존재" 서술은 2026-06-05 이전 ground-truth 기준이며, 현재는 §구현 기록이 최신 상태를 가진다.
|
||||
|
||||
## 구현 기록 (2026-06-05)
|
||||
|
||||
> Phase C2 실 코드 작성. ca-tmpl repo `feature-domain-modeling-guardrails` branch. 증거 등급: 아래 모두 `locally-verified` (focused gradle test + verifyCleanArchitectureDependencies 통과).
|
||||
|
||||
### 변경 파일
|
||||
|
||||
- **domain-core (신규 marker 패키지 `dev.caskeleton.domain.stereotype`)**:
|
||||
- `ValueObject.java`, `AggregateRoot.java`, `DomainEvent.java` — `@Target(TYPE)`, `@Retention(RUNTIME)`, `java.lang.annotation` 만 의존 (framework-neutral 유지, `domain_is_pure` 통과).
|
||||
- `package-info.java` — marker 의도 문서화.
|
||||
- **app-bootstrap `CleanArchitectureTest.java` (신규 규칙 5종 + custom condition 2종)**:
|
||||
- `domain_has_no_logger` (D3) — `..domain..` 의 `org.slf4j..`/`java.util.logging..`/`ch.qos.logback..`/`org.apache.logging.log4j..` import 금지. `domain_is_pure` 와 **별도 규칙**(F1 owner 경계 보존).
|
||||
- `value_objects_have_no_public_no_arg_constructor` (D5/D6) — `@ValueObject` OR `..domain.vo..` → public no-arg 생성자 부재. custom `notHaveAPublicNoArgConstructor()`.
|
||||
- `aggregate_root_setters_are_not_public` (D7) — `@AggregateRoot` 의 `set.*` method `notBePublic()`.
|
||||
- `domain_events_are_records` (D4/D8) — `@DomainEvent` 는 record. custom `beRecordTypes()` (`JavaClass.isRecord()`).
|
||||
- `domain_events_are_transport_free` (D4/D8) — `@DomainEvent` 는 `org.apache.kafka..`/`org.springframework.http..`/`jakarta.ws.rs..` 의존 금지.
|
||||
- **app-bootstrap violation fixtures (비공허 증명, violations-as-data)**: `violations/domain/LoggerUsingDomainFixture`, `AnnotatedPublicNoArgValueObjectFixture`, `vo/PackagePublicNoArgValueObjectFixture`, `PublicSetterAggregateFixture`, `event/{kafka,springhttp,jaxrs,nonrecord}/*Fixture` + `ArchitectureViolationFixtureTest` 에 11개 assertion(글로브별 격리 + over-block guard 2종).
|
||||
- **app-bootstrap `build.gradle`**: `testCompileOnly kafka-clients`, `jakarta.ws.rs-api` (transport glob 격리 증명용, test scope).
|
||||
- **sample-portfolio (positive coverage + Claims To Verify PoC)**:
|
||||
- `WorkLog` `@AggregateRoot` + blank-title 불변식(`requireValidTitle` → `WorkLogInvariantException`).
|
||||
- `Period`, `WorkLogId` `@ValueObject`.
|
||||
- `WorkLogInvariantException`(+ safe `Reason` enum) — 도메인은 operational error code 모름(D2), logger 안 씀(D3).
|
||||
- `WorkLogReserved`(`@DomainEvent` record, transport-free) → `application/event/WorkLogReservedIntegrationEvent` + `...Mapper` (경계 변환 PoC).
|
||||
- 테스트: `WorkLogInvariantTest`, `WorkLogIdPropertyTest`(jqwik property-based), `WorkLogReservedIntegrationEventMapperTest`. `build.gradle` 에 `testImplementation net.jqwik:jqwik:1.9.1`.
|
||||
|
||||
### 검증 명령 / 결과
|
||||
|
||||
- `cd src && ./gradlew :domain-core:test :sample-portfolio:test :app-bootstrap:test verifyCleanArchitectureDependencies` → **BUILD SUCCESSFUL**.
|
||||
- `ArchitectureViolationFixtureTest` → tests=40, failures=0, skipped=0 (신규 11개 포함).
|
||||
- `WorkLogIdPropertyTest` → jqwik property 3종 통과.
|
||||
- ca-architect-sentinel 작업트리 감사 → **PASS** (FAIL/WARN 0; domain framework-neutral 유지, application.event 의 domain→application 방향만 의존, 불변식이 aggregate 안에 위치).
|
||||
|
||||
### 함정
|
||||
|
||||
- `@DomainEvent` record 의 component 로 `testCompileOnly` transport type 을 두자 JUnit **test discovery** 가 통째로 실패(`ClassSelector resolution failed`). record component = canonical ctor 시그니처라 reflective discovery 가 즉시 resolve. method body `.class` 참조 + subpackage `importPackages` 로 회피. → [[raw/errors/archunit-testcompileonly-class-loading-2026-06-02]] 2026-06-05 addendum.
|
||||
|
||||
### 상태 전이 (planned → locally-verified)
|
||||
|
||||
- D3 logger ban, D5/D6 VO 불변식, D7 aggregate mutator, D4/D8 domain event(record + transport-free): `planned` → `locally-verified`.
|
||||
- §Claims To Verify 의 VO property / aggregate set* / logger import / transport-free mapping 항목: `planned` → `locally-verified` (PoC 코드 + 테스트 존재).
|
||||
- Greg Young/Vernon paraphrased 근거 검증(외부 원전 대조)은 여전히 `needs-confirmation` — 코드 구현과 무관하게 미해결.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-05-22: domain은 Spring/JPA/HTTP/Security/Logging type을 알지 않음.
|
||||
- 2026-05-22: domain exception은 business invariant만 표현하고 operational error code를 직접 알지 않음.
|
||||
- 2026-05-22: domain logger는 금지. invariant 위반 사유는 domain exception의 safe reason enum/value로 표현하고 application layer가 로그로 번역.
|
||||
- 2026-05-22: domain event는 transport-free fact만 표현하고 integration event mapping은 application/infrastructure 경계에서 수행.
|
||||
|
||||
## 판정 기준
|
||||
|
||||
| 구분 | 기준 |
|
||||
| --- | --- |
|
||||
| Decision | domain model은 framework-neutral pure model로 유지 |
|
||||
| Allowed | domain event/value object 내부의 순수 validation |
|
||||
| Forbidden | `@Entity`, `@Service`, HTTP/JPA/Security/Logger import |
|
||||
| Required checks | forbidden import, public mutable state, domain-to-response direct exposure |
|
||||
| Failure condition | domain이 infrastructure/presentation/application response type을 알면 실패 |
|
||||
|
||||
## Decisionized Work Items
|
||||
|
||||
| item | Decision | Allowed | Forbidden | Required test |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| entity/value object | pure domain types only | immutable helper libraries | JPA entity as domain | forbidden import test |
|
||||
| invariant | value object/entity constructor/factory | application pre-check for UX | DB-only invariant | invalid state test |
|
||||
| mutation | aggregate method controls state | package-private constructor for ORM outside domain model | public mutable fields | mutation test |
|
||||
| diagnostics | safe reason enum, application logs | no reason for security-sensitive cases | domain logger | logger import test |
|
||||
| domain event | transport-free fact | internal-only event | Kafka/HTTP/Slack detail | event model test |
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. `Decision ID` 는 본 branch-note 안에서 안정적으로 유지 (D1~D8). Decisionized Work Items 표 row 와 1:1 매핑.
|
||||
|
||||
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|
|
||||
| D1 | domain 은 Spring/JPA/HTTP/Security/Logging type 을 알지 않음 (framework-neutral) | `raw/official-docs/domain-fowler-anemic-vs-rich-model.md#FOWLER-ANEMIC-C1`, `raw/official-docs/domain-fowler-anemic-vs-rich-model.md#FOWLER-ANEMIC-C5`, `raw/company-tech-blogs/domain-woowahan-ddd-aggregate-techblog.md#WOOWA-HEX-C2` | `engineering-blog + company-case-study` | Fowler bliki 는 `engineering-blog` 등급 (개인 블로그, `official-vendor-doc` 격상 금지). Logger ban 의 직접 출처 부재 — FOWLER-ANEMIC-C5 "validations/calculations/business rules" 에서 도출 가능하나 약함 |
|
||||
| D2 | domain exception 은 business invariant 만 표현, operational error code 를 직접 알지 않음 | `raw/official-docs/domain-fowler-anemic-vs-rich-model.md#FOWLER-ANEMIC-C5`, `raw/official-docs/domain-vaughn-vernon-aggregate-root.md#VERNON-AGG-C2` | `engineering-blog + needs-confirmation` | VERNON-AGG-C2 는 paraphrased (`needs-confirmation`) — PDF 본문 verbatim 미확보. "operational error code 와 domain exception 의 분리" 직접 출처 부재 |
|
||||
| D3 | domain logger 금지, invariant 위반 사유는 safe reason enum/value 로 표현 후 application layer 가 로그로 번역 | UNSUPPORTED_DECISION | (Fowler/Vernon 모두 logger ban 명시 부재 — FOWLER-ANEMIC-C5 의 "domain logic = validations/calculations/business rules" 에서 logger 부재가 도출되나 직접 인용 아님) | Logger ban 의 공식 표준 출처 없음 — ca-tmpl 자체 결정. "safe reason enum" 패턴의 reference 부재 |
|
||||
| D4 | domain event 는 transport-free fact 만 표현, integration event mapping 은 application/infrastructure 경계에서 수행 | `raw/company-tech-blogs/domain-event-sourcing-vs-cqrs-greg-young.md#GY-CQRS-C4`, `raw/official-docs/domain-vaughn-vernon-aggregate-root.md#VERNON-AGG-C5` | `needs-confirmation + needs-confirmation` (paraphrased) | GY-CQRS-C4 는 `needs-confirmation` (Greg Young PDF 검증 실패, Confluent corroborate 만). VERNON-AGG-C5 도 paraphrased — transport-free 의 ca-tmpl 정의는 자체 차용 |
|
||||
| D5 | entity / value object 는 pure domain types only, JPA entity 를 domain 으로 두지 않음 (Vernon Option A) | `raw/official-docs/domain-vaughn-vernon-aggregate-root.md#VERNON-AGG-C6`, `raw/official-docs/domain-fowler-anemic-vs-rich-model.md#FOWLER-ANEMIC-C3`, `raw/company-tech-blogs/domain-woowahan-ddd-aggregate-techblog.md#WOOWA-HEX-C2` | `needs-confirmation + engineering-blog + company-case-study` | VERNON-AGG-C6 paraphrased (`needs-confirmation`). 우아한형제들 사례는 Option A (POJO domain) 와 Option B (JPA in domain) 모두 보이는 vendor-specific — Vernon 의 Option A/B 분리 자체는 본 Claim 으로 직접 증명 안 됨 |
|
||||
| D6 | invariant 는 value object/entity constructor/factory 에 위치, DB-only invariant 금지 | `raw/official-docs/domain-vaughn-vernon-aggregate-root.md#VERNON-AGG-C2`, `raw/official-docs/domain-fowler-anemic-vs-rich-model.md#FOWLER-ANEMIC-C5` | `needs-confirmation + engineering-blog` | VERNON-AGG-C2 paraphrased — "single transaction" 의 의미가 "constructor invariant" 와 정확히 매핑되는지 PDF verbatim 확인 필요 |
|
||||
| D7 | aggregate mutation 은 root method 만 controls, public mutable field 금지, ORM 외부 매핑 (Option A) 으로 package-private constructor 사용 | `raw/official-docs/domain-vaughn-vernon-aggregate-root.md#VERNON-AGG-C6` | `needs-confirmation` (paraphrased — IDDD Ch.10 도서 인용, 페이지/문단 미지정) | VERNON-AGG-C6 verbatim 미확보. "package-private/protected" 가 Java 외 다른 JVM 언어 (Kotlin `internal`) 에 매핑되는지 별도 검증 필요 |
|
||||
| D8 | domain event modeling 은 internal-only event 허용, Kafka/HTTP/Slack detail 금지 | `raw/company-tech-blogs/domain-event-sourcing-vs-cqrs-greg-young.md#GY-CQRS-C4`, `raw/company-tech-blogs/domain-event-sourcing-vs-cqrs-greg-young.md#GY-CQRS-C3` | `needs-confirmation` (Greg Young PDF 미검증) | "transport-free" 의 ca-tmpl 정의는 차용 (GY-CQRS-C4 Does not prove: transport-free 가능성 명시 부재) — 직접 출처 없음 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> *결정* 이 "*무엇*" 이라면 본 §는 "*어디에 어떻게*". 본 branch 의 modeling guardrail 은 전부 `planned` (코드 미존재) 이므로, 아래는 C2 진입 시 *되묻지 않고 작성할 수 있는* 사전 명세다. anchor 는 §진행 중 메모 / §Audit 에서 확인한 *실제* ca-tmpl 구조(`domain_is_pure`, domain-core 모듈, `feature-architecture-enforcement-rules` owner)에 정합시킨다.
|
||||
> 3-rule (CLAUDE.md §15.5): 각 cell 은 Decision ID + Supporting Claim ID reference (R1) / 근거 없는 detail 은 `UNSUPPORTED_IMPL_DECISION` + trade-off 한 줄 (R2) / 범위 밖은 §Audit 으로 이관 (R3).
|
||||
|
||||
### 1. 도메인 순수성 — 기존 rule 위임 (재정의 금지)
|
||||
|
||||
> **Trace**: D1 ↔ `FOWLER-ANEMIC-C1/C5`, `WOOWA-HEX-C2`. 단, 정적 강제의 **owner 는 본 branch 가 아님**.
|
||||
>
|
||||
> - **OUT_OF_BRANCH_SCOPE (위임)**: framework-neutral 정적 강제(`..domain..` 의 Spring/JPA/Hibernate/Lombok import 금지)는 [[raw/branch-notes/feature-architecture-enforcement-rules]] D3 의 `domain_is_pure` (`CleanArchitectureTest.java:36-57`, `actually-implemented`) 가 소유. 본 branch 는 이 rule 을 **재정의/복제하지 않고** 모델링 결정의 전제로 *위임 참조*. 본 branch 가 추가하는 것은 아래 2~5 의 modeling-specific rule 뿐.
|
||||
|
||||
| 항목 | owner | 상태 | anchor |
|
||||
|---|---|---|---|
|
||||
| `..domain..` Spring/JPA/Hibernate/Lombok/cross-layer import 금지 | [[raw/branch-notes/feature-architecture-enforcement-rules]] D3 | `actually-implemented` | `domain_is_pure` (`CleanArchitectureTest.java:36`) |
|
||||
| controller 가 domain/entity 타입 직접 반환 금지 | [[raw/branch-notes/feature-boundary-validation-mapping-contract]] D8 | `actually-implemented` | `controllers_do_not_return_domain_or_entity_types` (`CleanArchitectureTest.java:321`) |
|
||||
|
||||
> **Option A vs B 선택 근거는 추측이 아니라 코드로 증명된다 (D5·D7 강화)**: Vernon Option B(domain class 에 `@Entity`/JPA annotation 직접 부착)는 domain 패키지에 `jakarta.persistence..` import 를 유발한다. 이는 `domain_is_pure` 의 forbidden list (`CleanArchitectureTest.java:40-42` — `jakarta.persistence..`·`javax.persistence..`) 에서 **자동 위반**되어 빌드가 깨진다 (`actually-implemented`). 따라서 ca-tmpl 에서 Option A(ORM 외부 매핑)는 *선호*가 아니라 기존 정적 강제의 **논리적 귀결** — Option B 는 코드 레벨에서 이미 금지됨. 이 체인이 VERNON-AGG-C6 의 paraphrased 약점(도서 페이지 미확보)을 코드 ground-truth(L2)로 보완한다.
|
||||
|
||||
### 2. 도메인 logger ban 정적 강제 (D3)
|
||||
|
||||
> **Trace**: D3 (`UNSUPPORTED_DECISION` — logger ban 의 공식 출처 없음, ca-tmpl 자체 결정).
|
||||
>
|
||||
> - **GAP / `STALE_OWNER` 위험**: 코드 확인 결과 `domain_is_pure` 의 forbidden 목록에 **logging framework 가 없다** (`org.slf4j`·`java.util.logging`·`ch.qos.logback`·`org.apache.logging.log4j` 모두 미포함; test 파일 전체 grep 상 logger ban rule 부재). 따라서 "domain 이 Logger import 시 ArchUnit 실패" 는 현재 `planned` 이며 **어떤 rule 도 강제하지 않음**.
|
||||
> - **근거 등급 확정 (되묻기 방지)**: logger ban 의 *공식 표준 출처는 존재하지 않는다* — 이는 clean-architecture 통념이지 official standard 가 아니다 (D3 `UNSUPPORTED_DECISION` 유지). 구현자는 "공식 근거를 더 찾아라"가 아니라 **ca-tmpl 자체 규약으로 확정하고 착수**한다. 사실 등급은 격상하지 않으며, 외부(면접/README)에서 "표준이라 막았다"고 말하지 않는다.
|
||||
> - **PRE-DECISION (메커니즘 확정)**: 별도 rule **`domain_has_no_logger` 신설** (owner = 본 branch). `domain_is_pure` forbidden list 확장(대안)을 *택하지 않는* 이유는 코드 근거가 있다 — `domain_is_pure` 의 owner 는 [[raw/branch-notes/feature-architecture-enforcement-rules]] D3 (`CleanArchitectureTest.java:54-56` `.as(...)` 명시, §Audit F1). 그 list 에 logger 를 끼우면 *본 branch 의 결정이 타 branch owner rule 에 섞여* owner 경계가 깨진다(F1 회피). 별도 rule 은 위반 메시지도 "domain logger 금지(D3)"로 명확. → trade-off 가 아니라 owner-boundary 로 강제됨.
|
||||
|
||||
| 강제 대상 | 메커니즘(제안) | 상태 |
|
||||
|---|---|---|
|
||||
| `..domain..` 의 `org.slf4j..`·`java.util.logging..`·`ch.qos.logback..`·`org.apache.logging.log4j..` import | 신규 rule `domain_has_no_logger` (owner = 본 branch; forbidden list 확장 아님 — F1 owner 경계 보존) | `planned` |
|
||||
| invariant 위반 사유 = safe reason enum/value (noun 형태), 로그 번역은 application layer | domain exception 의 reason enum 필드 + application 에서 error.category 매핑 | `planned` |
|
||||
|
||||
### 3. Value Object invariant 강제 (D5·D6)
|
||||
|
||||
> **Trace**: D5 ↔ `VERNON-AGG-C6`·`FOWLER-ANEMIC-C3`·`WOOWA-HEX-C2`, D6 ↔ `VERNON-AGG-C2`·`FOWLER-ANEMIC-C5`.
|
||||
>
|
||||
> - **PRE-DECISION (탐지 기준·명명 확정)**: annotation `@ValueObject` 를 **primary marker**, `..domain.vo..` package convention 을 **fallback**(annotation 미부착 VO 도 포착)으로 *둘 다* 사용 — ArchUnit rule 의 `.areAnnotatedWith(...).or().resideInAPackage(...)` 가 양쪽을 OR 로 묶으므로 둘 중 택일이 아니라 합집합이 자연스럽다. annotation 패키지는 `dev.caskeleton.domain.stereotype` (domain-core 신규 marker 패키지; 현재 domain-core 는 `identifier` 패키지만 보유 → marker 패키지 신설). 근거 raw(Vernon/Fowler)는 *invariant 위치*만 권고하고 명명은 권고 안 하므로 `@ValueObject`·`stereotype` 명칭은 ca-tmpl 임의 — 사실 등급 비격상, 코드 미존재이므로 `planned`.
|
||||
|
||||
| 강제 대상 | 메커니즘(제안) | 상태 |
|
||||
|---|---|---|
|
||||
| `@ValueObject` 또는 `..domain.vo..` 의 record/class 에 public no-arg constructor 부재 | `classes().that().areAnnotatedWith(ValueObject.class).or().resideInAPackage("..domain.vo..").should().notHaveAccessibleNoArgConstructor()` | `planned` |
|
||||
| 모든 VO constructor 가 invalid input 에 domain exception/`IllegalArgumentException` throw | property-based test (jqwik) — null/empty/boundary × N | `planned` |
|
||||
| `@ValueObject` annotation 신설 | `dev.caskeleton.domain.stereotype.ValueObject` (domain-core 신규 marker 패키지) | `planned` (annotation 미존재) |
|
||||
|
||||
### 4. Aggregate root mutator 가시성 (D7)
|
||||
|
||||
> **Trace**: D7 ↔ `VERNON-AGG-C6` (`needs-confirmation` — IDDD Ch.10 페이지 미지정).
|
||||
>
|
||||
> - **PRE-DECISION (탐지 범위 확정)**: ArchUnit 정적 강제 범위 = **`set.*` prefix method 만** (`notBePublic()`). 이유: ca-tmpl 은 현재 Java-only (`src/` 전부 `.java`) 이므로 Kotlin `internal`/`copy()`·record wither 우회는 *지금 범위 밖*(D7 Open Risk 로 보존, Kotlin 도입 시 재검토). `set.*` 외의 state-changing method(예: `applyXxx`, `markAsXxx`)는 ArchUnit 로 일반 강제가 불가능 → 코드리뷰 + 네이밍 컨벤션으로 보완(정적 강제 아님 명시). annotation 패키지는 §3 과 동일하게 `dev.caskeleton.domain.stereotype.AggregateRoot`. `@AggregateRoot` 명명 ca-tmpl 임의(코드 미존재, `planned`).
|
||||
|
||||
| 강제 대상 | 메커니즘(제안) | 상태 |
|
||||
|---|---|---|
|
||||
| `@AggregateRoot` class 의 `set*`/state-changing method 가 public 아님(package-private/protected) | `methods().that().haveNameMatching("set.*").and().areDeclaredInClassesThat().areAnnotatedWith(AggregateRoot.class).should().notBePublic()` | `planned` |
|
||||
| ORM 재구성용 constructor 가시성 = package-private (Vernon Option A, ORM 외부 매핑) | persistence mapper 가 domain 밖에서 재구성 (`WorkLog` ↔ `WorkLogJpaEntity`) | `planned` |
|
||||
| `@AggregateRoot` annotation 신설 | `dev.caskeleton.domain.stereotype.AggregateRoot` | `planned` (annotation 미존재) |
|
||||
|
||||
### 5. Domain event transport-free 모델링 (D4·D8)
|
||||
|
||||
> **Trace**: D4 ↔ `GY-CQRS-C4`·`VERNON-AGG-C5` (둘 다 `needs-confirmation`), D8 ↔ `GY-CQRS-C3/C4`.
|
||||
>
|
||||
> - **근거 등급 확정 (되묻기 방지)**: "transport-free fact" 라는 *명칭/정의*는 ca-tmpl 차용이며 Greg Young 원전이 직접 보장하지 않는다(GY-CQRS-C4 `needs-confirmation`, D8 Open Risk). 구현자는 이 명칭의 출처를 더 추적하지 않는다 — **보수적 기본값으로 확정 후 착수**. 사실 등급 비격상.
|
||||
> - **PRE-DECISION (경계 확정, 코드로 부분 강제됨)**: domain event 는 `..domain..` 의 immutable record 로 두고 integration event 변환은 application/adapter 경계의 mapper 책임. 이 경계는 *추측이 아니라 부분적으로 코드로 강제된다* — `domain_is_pure` 가 `..domain..` → `..adapter..` import 를 금지(`CleanArchitectureTest.java:47`)하므로, domain event 가 adapter 의 integration-event/transport 타입을 참조하면 자동 위반(`actually-implemented`). 단, Kafka/HTTP 클라이언트 SDK 패키지(`org.apache.kafka..` 등)는 현재 forbidden list 에 없으므로 *그 한 가지*는 본 branch 의 `domain_has_no_logger` 와 같은 추가 rule 또는 코드리뷰로 보완 (`planned`).
|
||||
|
||||
| 강제 대상 | 메커니즘(제안) | 상태 |
|
||||
|---|---|---|
|
||||
| domain event = immutable record, transport(Kafka/HTTP/Slack) 필드 부재 | `@DomainEvent` record + ArchUnit forbidden import — 금지 패키지: `org.apache.kafka..`(Kafka SDK), `org.springframework.http..`/`jakarta.ws.rs..`(HTTP), 슬랙 등 outbound client SDK. **UNSUPPORTED_IMPL_DECISION**: broker/transport 추가 시 목록 갱신 필요(현재 ca-tmpl 미사용 SDK 는 미열거) | `planned` |
|
||||
| integration event 변환은 application/infrastructure 경계 | application mapper: `WorkLogReserved`(domain) → `WorkLogReservedIntegrationEvent`(application) → publish(infra) | `planned` |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4(깊이 게이트) 캡처용. 본 branch 의 modeling guardrail 이 구현 중 부딪힐 실패/엣지/계약 의존.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **ORM 재구성이 invariant 를 우회** — package-private/no-arg constructor 를 ORM(Hibernate) 이 reflection 으로 호출해 객체를 만들 때 constructor invariant 가 *호출되지 않을 수 있음*. 기대 동작: ORM 재구성은 *이미 valid 한 영속 상태*에서만 일어난다는 전제 + 매핑은 domain 밖 mapper 책임(Vernon Option A). VO no-arg constructor 금지 rule 과 ORM 요구의 충돌은 "ORM 외부 매핑"으로 회피.
|
||||
- **Kotlin `data class` `copy()` 우회** — copy() 가 constructor invariant 를 호출하지 않으면 invalid VO 생성 가능. 기대 동작: D7 Open Risk 로 이미 기록 — JVM 언어별 검증 필요.
|
||||
- **safe reason enum 의 정보 노출** — security-sensitive invariant 위반 사유를 enum 으로 노출하면 client 에 단서 제공 가능. 기대 동작(Decisionized Work Items): security-sensitive case 는 reason 제공 안 함, application 이 일반화된 error.category 로만 번역.
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-architecture-enforcement-rules]] 의 `domain_is_pure` (D3) 에 의존 — domain framework-neutrality 의 정적 강제 owner. 이 rule 의 forbidden list/package 패턴이 바뀌면 본 branch 의 D1 전제가 흔들린다.
|
||||
- operational error code SSOT = `feature-operational-error-observability-foundation` + `docs/registries/error-codes.yaml`. safe reason enum → error.category 번역은 그 계약을 consume (domain 은 operational code 를 직접 알지 않음 = D2).
|
||||
- persistence 매핑(Vernon Option A) → `feature-boundary-validation-mapping-contract` / persistence adapter 의 mapper 계약에 의존.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서나 사례는 근거지만, 내 프로젝트의 실 동작을 자동으로 보장하지 않음.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| `..domain.vo..` package 의 모든 record/class 가 public no-arg constructor 없이 invariant 강제 가능 | VERNON-AGG-C2 paraphrased — VO 의 constructor invariant 가 모든 valid input 에서 작동하는지 property-based test 필요 | sample feature 의 VO 1개에 jqwik property-based test 적용 → null/empty/invalid input × N 종 자동 생성 → exception 확인 | `planned` |
|
||||
| `@AggregateRoot` annotated class 의 모든 `set*` method 가 package-private/protected 이며 invariant 호출 포함 | VERNON-AGG-C6 paraphrased — ORM-friendly constructor 가시성의 verbatim 미확보 | ArchUnit `methodsThat().haveName("set.*").andAreDeclaredInClassesThat().areAnnotatedWith(@AggregateRoot.class).should().notBePublic()` 작성 + 위반 케이스 테스트 | `planned` |
|
||||
| domain class 가 Logger import 시 ArchUnit 이 실패시킨다 | D3 UNSUPPORTED — logger ban 의 공식 출처 부재 | ArchUnit forbidden import test 작성 (slf4j, logback, log4j 모두 포함) → sample domain 에 임시 logger 추가 시 실패 케이스 capture | `planned` |
|
||||
| Vernon Option A (domain ↔ JpaEntity 외부 매핑) 가 Option B (domain 에 JPA annotation) 보다 ca-tmpl 의 forbidden import 규칙과 더 정합 | VERNON-AGG-C6 paraphrased + 우아한형제들 WOOWA-HEX-C2 (Option A) + Option B reference 부재 | sample-portfolio 에 WorkLog(domain) ↔ WorkLogJpaEntity(infrastructure) 분리 PoC + MapStruct 매핑 → ArchUnit forbidden import test 통과 확인 | `needs-confirmation` |
|
||||
| domain event 가 transport-free 로 정의되어도 application/infrastructure 경계에서 integration event 변환 가능 | D4 paraphrased only — Vernon eventual consistency / Greg Young event immutability 만 근거, transport mapping 패턴 직접 출처 부재 | sample feature 에 `WorkLogReserved` (domain event) → `WorkLogReservedIntegrationEvent` (application mapper) → Kafka publish (infrastructure) 흐름 PoC | `planned` |
|
||||
| 한국 백엔드 현장에서 Spring 기본 튜토리얼이 anemic default 라는 메모가 ca-tmpl 강제 결정의 정당화에 충분 | FOWLER-ANEMIC-C2 의 일반 명제만 있고 "한국 현장 관찰" 의 별도 출처 없음 (메모) | 별도 raw 자료 (Inflearn / 김영한 강의 / 우아한형제들 hands-on) 의 default 패턴 추출 후 ingest | `planned` |
|
||||
| Greg Young / Vernon 의 paraphrased claim 들이 PDF / IDDD 원전과 일치 | GY-CQRS-C1~C4, VERNON-AGG-C2~C6 모두 `needs-confirmation` | (a) Greg Young CQRS PDF 재페치 시도 (대안: archive.org / Fowler bliki cross-check) (b) IDDD Ch.10 도서 인용 페이지/문단 명시 추가 | `needs-confirmation` |
|
||||
|
||||
## 테스트 계약
|
||||
|
||||
- domain package가 Spring/JPA/HTTP/security/logging package를 import하면 실패.
|
||||
- VO invalid state 검사: 모든 `@ValueObject` annotation이 붙은 class 또는 `features.*.domain.vo.` package의 record/class는 다음을 만족: (a) public no-arg constructor 없음 (b) 모든 constructor에서 invariant violation 시 `IllegalArgumentException` 또는 domain exception throw. 측정 방법: ArchUnit `classes().that().areAnnotatedWith(@ValueObject.class).or().resideInAPackage("..domain.vo..").should().notHaveAccessibleNoArgConstructor()` + property-based test on each VO with null/empty/invalid input → exception expected.
|
||||
- aggregate mutation 검사: `@AggregateRoot` annotation이 붙은 class의 모든 mutator method (`set*` prefix 또는 state-changing method)는 (a) public이 아닌 package-private 또는 protected이고 (b) invariant 검증 로직 포함. 측정 방법: ArchUnit `methodsThat().haveName("set.*").andAreDeclaredInClassesThat().areAnnotatedWith(@AggregateRoot.class).should().notBePublic()`. setter가 public이거나 invariant 호출 없이 state 변경 시 fail.
|
||||
- domain package가 Logger 또는 operational error code를 직접 알면 실패. (rule: `domain_has_no_logger`, D3 — owner = 본 branch. §구현 가이드 §2 참조. `domain_is_pure` 와 별개 rule)
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/domain-vaughn-vernon-aggregate-root]] | Vernon "Effective Aggregate Design" 4 rules + small aggregate + ORM-friendly constructor (ca-tmpl Option A 채택 |
|
||||
| [[raw/official-docs/domain-fowler-anemic-vs-rich-model]] | Fowler "Anemic Domain Model" anti-pattern (rich model 강제의 reference |
|
||||
| [[raw/company-tech-blogs/domain-woowahan-ddd-aggregate-techblog]] | 우아한형제들 초기 글 사례; ca-tmpl forbidden import 규칙 위배라 거부 |
|
||||
| [[raw/company-tech-blogs/domain-event-sourcing-vs-cqrs-greg-young]] | Greg Young; ca-tmpl 미채택, "transport-free fact" 정의만 차용 |
|
||||
| [[raw/official-docs/cqrs-fowler-bliki]] | CQRS command/query 모델 분리 정의 + Fowler 의 "very cautious" 보수적 권고 (Fowler martinfowler.com bliki — `engineering-blog` 등급, `official-standard` 아님). ca-tmpl 의 command/query use case 분리 (Out of scope: read/write 데이터 모델 분리) 의 대비 reference. ca-tmpl 은 CQRS-FOWLER-C3 (개념 모델 분리) 만 차용, CQRS-FOWLER-C5/C6 (cautious + complexity) 에 따라 read/write 저장소 분리는 미채택 |
|
||||
|
||||
## 외부 근거 / 대안 조사 (2026-05-22 — Group G-J: Domain Modeling Guardrails)
|
||||
|
||||
본 branch의 VO with private constructor + aggregate root mutator package-private/protected + domain logger ban + safe reason enum + invariant in constructor + ORM 외부 매핑 결정에 대한 외부 source.
|
||||
|
||||
- **채택 결정 (Rich domain model + Vernon Aggregate Root Option A: ORM 외부 매핑)**:
|
||||
- [[raw/official-docs/domain-vaughn-vernon-aggregate-root]] — Vernon "Effective Aggregate Design" 4 rules + small aggregate + ORM-friendly constructor (ca-tmpl Option A 채택)
|
||||
- [[raw/official-docs/domain-fowler-anemic-vs-rich-model]] — Fowler "Anemic Domain Model" anti-pattern (rich model 강제의 reference)
|
||||
- **검토한 대안**:
|
||||
- **대안 1: Anemic domain model** — `domain-fowler-anemic-vs-rich-model` 동일 source에서 anti-pattern으로 정의 (ca-tmpl 거부)
|
||||
- **대안 2: Vernon Option B (JPA direct annotation in domain)** — [[raw/company-tech-blogs/domain-woowahan-ddd-aggregate-techblog]] (우아한형제들 초기 글 사례; ca-tmpl forbidden import 규칙 위배라 거부)
|
||||
- **대안 3: Event sourcing 전환 (domain events as state)** — [[raw/company-tech-blogs/domain-event-sourcing-vs-cqrs-greg-young]] (Greg Young; ca-tmpl 미채택, "transport-free fact" 정의만 차용)
|
||||
- **대안 4: CQRS with separate read/write models** — 동일 Greg Young source (ca-tmpl 미채택, read 분리 없이 단일 model 유지)
|
||||
- **대안 5: Functional domain modeling (Scala/F#)** — JVM이지만 패러다임 차이 + 팀 학습 비용 큼
|
||||
- **비교 핵심**: ca-tmpl rich model은 Fowler/Vernon reference standard 정합. ORM 외부 매핑(Vernon Option A)이 forbidden import 규칙(domain logger/JPA ban)과 정합 — 우아한형제들 Option B는 same regulation 위배라 거부. Event sourcing/CQRS는 모델 자체 교체로 scope 다름, ca-tmpl은 "transport-free fact" 정의만 차용.
|
||||
|
||||
## 완료 후 wiki 추출 대상
|
||||
|
||||
- `wiki/projects/ca-skeleton-operational-contract.md`의 domain modeling canonical section.
|
||||
|
||||
## Audit & Findings
|
||||
|
||||
> 2026-06-05 `/branch-spec` ground-truth 대조 (ca-tmpl `src/` + `CleanArchitectureTest.java`) 에서 발견한 정합/drift. 사용자 작성 결정 영역은 자동 rewrite 하지 않고 *정합 권고만* 기록.
|
||||
|
||||
| ID | 유형 | 발견 | 권고 |
|
||||
|---|---|---|---|
|
||||
| F1 | OWNERSHIP | D1(domain framework-neutral) 의 정적 강제 `domain_is_pure` 는 본 branch 가 아니라 [[raw/branch-notes/feature-architecture-enforcement-rules]] D3 가 owner (`CleanArchitectureTest.java:36-57` `.as(...)` 주석 명시) | D1 은 본 branch 가 *복제/재정의하지 않고 위임*. §Coverage 에 `delegated` 로 표기 (완료) |
|
||||
| F2 | GAP (logger ban 미강제) | D3(domain logger ban) — `domain_is_pure` forbidden list 에 logging framework 미포함 (`org.slf4j`·`java.util.logging`·`logback`·`log4j` 부재; test 전체 grep 상 logger ban rule 없음) | logger ban 은 현재 `planned`, 코드 미강제. C2 에서 별도 rule 또는 forbidden list 확장 필요 (§구현 가이드 2). "구현됐다" 로 말하면 안 됨 |
|
||||
| F3 | NOT-IMPLEMENTED | `@ValueObject`·`@AggregateRoot`·`@DomainEvent` annotation 모두 `src/` grep 미존재. domain-core 모듈은 `identifier/ResourceId`·`IdFactory` 만 보유 | D5/D6/D7/D8 의 annotation-기반 ArchUnit rule 은 전부 `planned`. Claims To Verify 의 `planned` 표기와 일치 (정합 OK) |
|
||||
| F4 | SCOPE 확인 | D2(domain exception 이 operational error code 를 직접 모름) 의 SSOT 는 `feature-operational-error-observability-foundation` + `error-codes.yaml` | safe reason enum → error.category 번역은 그 계약 consume. 본 branch 는 *domain 측 금지*만 소유, code enum 신설은 범위 밖 |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
> `/coverage` 가 채우는 **생성물** — 손유지 금지. 기준: `rules/coverage-gate.md`. governing_docs: `privacy-file-domain-modeling` (§"Domain Modeling") + `clean-architecture-package-layout` (domain purity).
|
||||
> 마지막 감사: 2026-06-05 `/branch-spec` 인라인 (정식 `coverage-auditor` 판정은 §8b 에서).
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|--------|------|-------|--------|------|
|
||||
| VO private constructor + factory, invariant in constructor | covered-here | — | — | D5·D6 (§구현 가이드 3, `planned`) |
|
||||
| aggregate root mutator non-public (package-private/protected) | covered-here | — | — | D7 (§구현 가이드 4, `planned`) |
|
||||
| domain layer logger ban | covered-here | — | 🟡 (F2 GAP) | D3 (`planned`, 코드 미강제 — §구현 가이드 2) |
|
||||
| safe reason enum (거부 사유 noun enum, application 이 로그 번역) | covered-here | — | — | D3·D2 |
|
||||
| Vernon Option A (ORM 외부 매핑) 채택, Option B 거절 | covered-here | — | — | D5·D7 |
|
||||
| domain event = transport-free fact, integration mapping 은 경계 | covered-here | — | — | D4·D8 |
|
||||
| domain framework-neutral (no Spring/JPA/Hibernate) 정적 강제 | delegated | [[raw/branch-notes/feature-architecture-enforcement-rules]] D3 | — | owner `actually-implemented` (`domain_is_pure`, `CleanArchitectureTest.java:36`) |
|
||||
| controller 가 domain/entity 타입 직접 반환 금지 | delegated | [[raw/branch-notes/feature-boundary-validation-mapping-contract]] D8 | — | owner `actually-implemented` (`controllers_do_not_return_domain_or_entity_types`) |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음(문서 단계).
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/domain-event-sourcing-vs-cqrs-greg-young]]
|
||||
- [[raw/company-tech-blogs/domain-woowahan-ddd-aggregate-techblog]]
|
||||
- [[raw/official-docs/cqrs-fowler-bliki]]
|
||||
- [[raw/official-docs/domain-fowler-anemic-vs-rich-model]]
|
||||
- [[raw/official-docs/domain-vaughn-vernon-aggregate-root]]
|
||||
- [[raw/official-docs/feature-first-uncle-bob-screaming-architecture-2011]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: interviews:start -->
|
||||
- [[raw/interviews/domain-modeling-guardrails-archunit-2026-06-05]]
|
||||
<!-- GENERATED: interviews:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/domain-modeling-guardrails-as-archunit-fitness-functions-2026-06-05]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
> 2026-06-05 Phase C2 구현으로 파생 자료 누적. 아래 derived note 들과 양방향 link 유지.
|
||||
|
||||
### 오류 기록 (본 feature 작업 중 발생)
|
||||
|
||||
- [[raw/errors/archunit-testcompileonly-class-loading-2026-06-02]] — 2026-06-05 addendum: `@DomainEvent` record component 로 `testCompileOnly` 타입을 두면 JUnit discovery 가 죽음. method body `.class` 참조 + subpackage `importPackages` 로 회피 (4번째 패턴).
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- [[raw/interviews/domain-modeling-guardrails-archunit-2026-06-05]] — stereotype 마커 + ArchUnit fitness function, owner 경계, logger ban 정직성, jqwik 불변식 검증, transport-free 이벤트.
|
||||
|
||||
### Blog topics (구현·트러블슈팅 글감)
|
||||
|
||||
- [[raw/blog-topics/domain-modeling-guardrails-as-archunit-fitness-functions-2026-06-05]] — DDD 전술 패턴을 빌드 깨짐으로 강제하기.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
> 이 브랜치를 작업한 날짜들. 양방향 nav 유지.
|
||||
|
||||
- (Phase E 외부 근거 / 대안 조사 단계 — daily note 미연결. C2 구현 진입 시 작업일 추가)
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-env-driven-runtime-configuration.md
|
||||
@@ -0,0 +1,431 @@
|
||||
---
|
||||
title: branch / feature-env-driven-runtime-configuration
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-004
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-004
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-FRAMEWORK-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
branch: feature-env-driven-runtime-configuration
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [wiki/projects/ca-tmpl/config-and-adapter-templates.md]
|
||||
tags: [branch, ca-skeleton, env, configuration, runtime]
|
||||
created: 2026-05-21
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: e68380e05a6baa55af05d9d692b7986fc91202315b02276cecfd7bb83c0098ca
|
||||
---
|
||||
|
||||
# branch: feature-env-driven-runtime-configuration
|
||||
|
||||
> Layer: `raw/branch-notes/` — 서버별 운영 전환을 env로 가능하게 하는 설정 계약을 정의합니다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (§<관련 섹션>) 의 결정/근거/금지 사항을 정제한다.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: env configuration 6필드 contract와 invalid-config test가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-FRAMEWORK-001@1` | framework는 Spring Boot 3.5.14다 | Spring Boot env binding과 startup validation에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/adapter-togglz-ff4j-feature-toggle-library]]
|
||||
- [[raw/company-tech-blogs/config-launchdarkly-feature-flag-best-practice]]
|
||||
- [[raw/official-docs/config-12-factor-app-config]]
|
||||
- [[raw/official-docs/config-aws-appconfig-feature-flag-deployment]]
|
||||
- [[raw/official-docs/config-spring-boot-externalized-configuration]]
|
||||
- [[raw/official-docs/config-spring-cloud-config-server-official]]
|
||||
- [[raw/official-docs/config-spring-cloud-kubernetes-configmap-reload]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: interviews:start -->
|
||||
- [[raw/interviews/startup-fail-fast-config-validation-2026-06-06]]
|
||||
<!-- GENERATED: interviews:end -->
|
||||
|
||||
<!-- GENERATED: errors:start -->
|
||||
- [[raw/errors/global-sed-env-rename-pitfalls-2026-06-06]]
|
||||
<!-- GENERATED: errors:end -->
|
||||
|
||||
<!-- GENERATED: blog-topics:start -->
|
||||
- [[raw/blog-topics/env-example-drift-gate-gradle-2026-06-06]]
|
||||
<!-- GENERATED: blog-topics:end -->
|
||||
|
||||
> 본 feature branch 는 leaf — 자식 자료 없음. Phase C2 실 코드 작성 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 근거 자료
|
||||
|
||||
- [[raw/official-docs/config-12-factor-app-config]] — D1 근거 (12-factor §III Config)
|
||||
- [[raw/official-docs/config-spring-cloud-config-server-official]] — D3 대안 (Spring Cloud Config Server)
|
||||
- [[raw/official-docs/config-spring-cloud-kubernetes-configmap-reload]] — D3 대안 (k8s ConfigMap reload)
|
||||
- [[raw/official-docs/config-aws-appconfig-feature-flag-deployment]] — D3/D9 대안 (AWS AppConfig)
|
||||
- [[raw/company-tech-blogs/config-launchdarkly-feature-flag-best-practice]] — D9 대안 (LaunchDarkly)
|
||||
- [[raw/official-docs/config-spring-boot-externalized-configuration]] — D4 (Duration/DataSize binding 포맷), D6 (SPRING_PROFILES_ACTIVE relaxed binding 메커니즘), D10 (@ConfigurationProperties + @Validated startup validation)
|
||||
|
||||
### 오류 기록 (본 feature 작업 중 발생)
|
||||
|
||||
- [[raw/errors/global-sed-env-rename-pitfalls-2026-06-06]] — M1 일괄 rename 중 (1) zsh unquoted 변수 무분할로 sed no-op, (2) `s/LOG_/APP_LOG_/g` substring 충돌로 `SPRING_MAIN_LOG_STARTUP_INFO` 훼손. 둘 다 resolved.
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- [[raw/interviews/startup-fail-fast-config-validation-2026-06-06]] — `SmartInitializingSingleton` vs `EnvironmentPostProcessor` vs `ApplicationReadyEvent`, 계층형 `@Validated`+JSR-303 / compact-constructor throw, prod 가드의 case-sensitive profile 매칭 트레이드오프, name 기반 bean presence 검사.
|
||||
|
||||
### Blog topics (이 작업에서 나온 글감)
|
||||
|
||||
- [[raw/blog-topics/env-example-drift-gate-gradle-2026-06-06]] — env drift gate 설계 여정(글감). ⚠ 이 노트는 1차 설계(surface=정답, registry 미강제)를 담고 있으나 **2026-06-08 B 결정으로 registry=SSOT(check C)로 전환** — surface→registry SSOT 전환 자체가 더 좋은 글감(블로그 갱신 시 반영).
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
local/dev/staging/prod 서버별 동작이 코드 수정 없이 env로 전환되어야 합니다. error exposure, logging, tracing, adapter enablement, timeout/retry/security/datasource 설정을 env contract로 고정합니다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- env key naming 기준.
|
||||
- server profile matrix.
|
||||
- error detail exposure toggle.
|
||||
- logging/tracing toggle.
|
||||
- datasource/pool env.
|
||||
- outbound timeout/retry/circuit breaker env.
|
||||
- optional adapter enablement env.
|
||||
- security/CORS env.
|
||||
- invalid env fail-fast 기준.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- secret manager 연동.
|
||||
- Kubernetes/Helm chart 작성.
|
||||
- 실제 production deployment 구성.
|
||||
|
||||
## TODO
|
||||
|
||||
> TODO drained 2026-05-22 — env prefix/naming, local/dev/staging/prod matrix, error exposure, logging/tracing, datasource/pool, outbound timeout/retry/circuit breaker, adapter enablement, invalid env fail-fast 모두 "결정 사항" / "판정 기준" / "Feature Flag / Reload Defaults" / "테스트 계약"에 반영됨. 잔존 TODO 없음.
|
||||
|
||||
## Work Item Contract
|
||||
|
||||
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
|
||||
|
||||
| field | required | rule |
|
||||
| --- | --- | --- |
|
||||
| Decision | yes | 구현자가 선택해야 하는 기본값 |
|
||||
| Allowed | yes | 허용되는 예외와 조건 |
|
||||
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
|
||||
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
|
||||
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
|
||||
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
|
||||
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- env는 secret만이 아니라 운영 모드 전환 장치입니다.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- 2026-05-21: 운영 계약 전체를 env로 제어하는 방향.
|
||||
- 2026-05-22: application-owned env는 `APP_` prefix를 사용.
|
||||
- **2026-06-05 (확정)**: env naming SSOT = `env-keys.yaml` registry 의 `APP_*`. `APP_` **전면 통일**(datasource/server 등 Spring-native 매핑 키도 예외 없이 `APP_`). 현행 코드의 `DB_*`/`LOG_*`/`CORS_*`/`OIDC_*`/`SERVER_*` → `APP_*` rename 은 후속 코드 마이그레이션(§Audit `ENV_PREFIX_DRIFT`).
|
||||
- 2026-05-22: local/dev/staging/prod matrix를 문서와 테스트 양쪽에 둠.
|
||||
- 2026-05-22: prod profile에서 body logging과 internal error detail exposure는 기본 금지.
|
||||
- 2026-05-22: feature flag 기본값은 env-startup flag. runtime/canary flag는 optional이며 registry row, owner, rollout/rollback rule 없이는 허용하지 않음.
|
||||
- 2026-05-22: reload policy 기본값은 no runtime reload. secret/config reload가 필요하면 secrets branch와 startup validation test를 연결.
|
||||
- 2026-05-22: 모든 env 바인딩은 `@ConfigurationProperties + @Validated` 강제. validation 미적용 bean 등록 시 fail.
|
||||
- **2026-06-05 (확정)**: validation = **계층형**. 단순 제약(필수·범위·정규식)은 `@Validated`+JSR-303 선언 기본, JSR-303 로 표현 불가한 조건부/교차필드만 compact constructor 에서 처리하되 invalid 면 `throw`(fail-fast). lenient default 금지(현행 `CorsSettings` 음수 maxAge default 는 throw 로 수정 후속).
|
||||
- **2026-06-06 (확정)**: env 조합 기반 fail-fast 집행 컴포넌트 = `SmartInitializingSingleton` validator bean(context refresh 완료 전 1회 검사 → invalid 시 `throw`) + contract test 이중. `EnvironmentPostProcessor`(bean presence 검사 불가)·`ApplicationReadyEvent`(늦음) 대비 선택. D8 multi-instance 5종 강제 + prod-unsafe toggle 모두 이 컴포넌트가 집행.
|
||||
- 2026-05-22: Spring Duration unit 표기 = `30s` 1택. ISO-8601 `PT30S` 형식은 forbidden (가독성/일관성). `@DurationUnit`을 통한 정수만 받는 형식은 허용 (예: int 30 + @DurationUnit(SECONDS)). byte는 `DataSize` (`10MB`).
|
||||
- 2026-05-22: boolean 표기 = `true/false` only (`1/0`/`on/off` forbidden).
|
||||
- 2026-05-22: APP_PROFILE 우선순위 = SPRING_PROFILES_ACTIVE > APP_PROFILE (Spring native 표준 우선). 두 값 불일치 시 startup fail.
|
||||
- **2026-06-06 (확정, 위 항목 대체)**: `APP_PROFILE` 도입 포기. profile = `SPRING_PROFILES_ACTIVE` **단독**(런타임 환경 선택자는 Spring native 영역). 우선순위/mismatch-fail 로직 미구현. `SPRING_PROFILES_ACTIVE` unset → startup fail 유지.
|
||||
- 2026-05-22: .env.example drift 검증 도구 = custom Gradle task `verifyEnvExample` (registry의 env-registry 표 vs .env.example 비교). ci-quality-gates의 .env.example drift gate가 이를 실행.
|
||||
- **2026-06-08 (확정, 위 항목 대체 — B)**: `.env.example` 두지 않음(`src/.env` git-tracked 단일 소스). drift 도구 = `verifyEnvKeys` (registry ↔ application.yml ↔ `.env` **3-way**). **registry(`env-keys.yaml`) = enforced SSOT**: check C 가 live 모든 `APP_` 키의 registry 행 존재를 build-time 강제. registry 를 as-built 55키와 전면 정렬(39행 추가 + `APP_LOG_LEVEL` 5분할 + `APP_SHUTDOWN_TIMEOUT`→`APP_SERVER_SHUTDOWN_TIMEOUT`).
|
||||
- 2026-05-22: multi-instance claim parsing 메커니즘 = env property `APP_MULTI_INSTANCE_ENABLED` boolean (default false). true로 설정 시 다음이 모두 강제: (a) ShedLock/distributed lock bean 등록, (b) Redisson `RLock` based cache stampede protection, (c) outbox publisher leader election (SKIP LOCKED), (d) distributed rate limiter (Redis counter), (e) migration runner platform job. flag true인데 위 5종 contract test 1개라도 없으면 startup fail-fast. `feature-runtime-health-lifecycle-contract`, `feature-background-job-async-contract`, `feature-cache-consistency-contract`, `feature-domain-event-outbox-contract`, `feature-rate-limit-idempotency-contract`, `feature-migration-startup-contract`가 모두 본 flag를 consume. `APP_MULTI_INSTANCE_ENABLED` row를 `ca-tmpl/docs/registries/env-keys.yaml`에 추가 (Phase D1 후속, 또는 별도 PR).
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/config-12-factor-app-config]] | 12-factor §III |
|
||||
| [[raw/official-docs/config-spring-cloud-config-server-official]] | 중앙 git-backed + `@RefreshScope`; 인프라 SPOF + bootstrap 의존 |
|
||||
| [[raw/official-docs/config-spring-cloud-kubernetes-configmap-reload]] | 3-level reload: `refresh`/`restart_context`/`shutdown`; partial-state 디버깅 + k8s lock-in |
|
||||
| [[raw/official-docs/config-aws-appconfig-feature-flag-deployment]] | managed validator + CloudWatch auto-rollback; AWS lock-in + per-call billing |
|
||||
| [[raw/company-tech-blogs/config-launchdarkly-feature-flag-best-practice]] | SaaS A/B/canary + user-targeting; 외부 의존 + flag debt + cost |
|
||||
| [[raw/official-docs/config-spring-boot-externalized-configuration]] | D4 Duration/DataSize binding 포맷, D6 `SPRING_PROFILES_ACTIVE` relaxed binding, D10 `@ConfigurationProperties + @Validated` |
|
||||
|
||||
## 외부 근거 / 대안 조사 (2026-05-22 — Group G-I: Env-driven Runtime Configuration)
|
||||
|
||||
본 branch의 `APP_` prefix + Duration `30s` 1택 + boolean `true/false` only + no-runtime-reload + `.env.example` drift verify + `APP_MULTI_INSTANCE_ENABLED` claim parsing 결정에 대한 외부 source.
|
||||
|
||||
- **채택 결정 (12-factor config + Spring `@ConfigurationProperties` + `APP_` env-only)**:
|
||||
- [[raw/official-docs/config-12-factor-app-config]] — 12-factor §III. Config (이론 출처). ca-tmpl `APP_` env-only + no-reload 결정의 표준 근거
|
||||
- **검토한 대안**:
|
||||
- **대안 1: Spring Cloud Config Server** — [[raw/official-docs/config-spring-cloud-config-server-official]] (중앙 git-backed + `@RefreshScope`; 인프라 SPOF + bootstrap 의존)
|
||||
- **대안 2: k8s ConfigMap + Spring Cloud Kubernetes auto-reload** — [[raw/official-docs/config-spring-cloud-kubernetes-configmap-reload]] (3-level reload: `refresh`/`restart_context`/`shutdown`; partial-state 디버깅 + k8s lock-in)
|
||||
- **대안 3: AWS AppConfig (feature flag + deployment strategy)** — [[raw/official-docs/config-aws-appconfig-feature-flag-deployment]] (managed validator + CloudWatch auto-rollback; AWS lock-in + per-call billing)
|
||||
- **대안 4: LaunchDarkly / Unleash (feature flag service)** — [[raw/company-tech-blogs/config-launchdarkly-feature-flag-best-practice]] (SaaS A/B/canary + user-targeting; 외부 의존 + flag debt + cost)
|
||||
- **비교 핵심**: 12-factor config가 ca-tmpl `APP_` env-only + no-runtime-reload 결정의 이론 출처. Spring Cloud Config Server는 인프라 SPOF + bootstrap 의존 부담. k8s ConfigMap reload는 partial-state 디버깅 어려움. LaunchDarkly/Unleash는 product-grade A/B/canary 요구 발생 시 진입점 — ca-tmpl이 의도적으로 위임한 영역 (50+ flag 또는 product team 운영 요구 시 도입 검토).
|
||||
|
||||
## 판정 기준
|
||||
|
||||
| 구분 | 기준 |
|
||||
| --- | --- |
|
||||
| Decision | 코드 수정 없이 env만으로 서버별 동작을 전환 |
|
||||
| Allowed | Spring 런타임이 직접 읽는 native env(`SPRING_PROFILES_ACTIVE` 등)만 원래 이름 유지. **application-owned env 는 예외 없이 `APP_*`** (D2, 2026-06-05 확정 — datasource/server 등 Spring property 로 *매핑*되는 키도 operator-facing 이름은 `APP_*`) |
|
||||
| Forbidden | profile별로 같은 의미의 env key 이름을 다르게 정의 |
|
||||
| Required config | `APP_NAME`, error exposure, log, trace, datasource, outbound timeout/retry, adapter enablement, security/CORS. profile 은 Spring-native `SPRING_PROFILES_ACTIVE` 필수(unset 시 startup fail) — D6 확정으로 `APP_PROFILE` 미사용 |
|
||||
| Failure condition | required env 누락, invalid enum/range, prod unsafe toggle이 startup에서 감지되지 않으면 실패 |
|
||||
|
||||
## Feature Flag / Reload Defaults
|
||||
|
||||
| item | default | allowed | forbidden |
|
||||
| --- | --- | --- | --- |
|
||||
| feature flag | startup env flag | runtime flag with registry owner | hidden code toggle |
|
||||
| canary | out of core | platform rollout with runbook | undocumented partial rollout |
|
||||
| config reload | no runtime reload | secret manager reload with validation | silent changed behavior |
|
||||
| flag registry | env registry row required | external flag system mapping | unregistered flag |
|
||||
|
||||
## 테스트 계약
|
||||
|
||||
- required env 누락 시 startup fail-fast.
|
||||
- prod profile에서 body logging enabled면 실패.
|
||||
- prod profile에서 internal error detail exposure enabled면 실패.
|
||||
- disabled adapter가 bean/use case path에서 사용되면 실패.
|
||||
- `.env`/application.yml/registry 3-way 불일치(필수 env 누락, orphan, 미등록 `APP_` 키) 시 `verifyEnvKeys` build 실패 (registry SSOT, check C).
|
||||
- feature flag registry owner 강제: 모든 runtime/canary flag(`@FeatureFlag` annotation 또는 `APP_FEATURE_*` env)는 `env-keys.yaml`에 row가 존재하고 `owner_branch` field가 비어 있지 않아야 함. 측정 방법: bean에서 `@Value("${app.feature.*}")` 또는 `@FeatureFlag` 사용 시 해당 key가 yaml에 row로 존재 verify. 미존재 또는 owner 누락 시 fail.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 본 branch 의 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시한다. Decision ID 는 안정적으로 유지한다. company-tech-blog 출처는 `company-case-study` 로 표기하며 공식 best practice 로 일반화하지 않는다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | 운영 계약 전체를 env 로 제어 (코드 수정 없이 서버별 동작 전환) | N/A — 운영 계약 전체를 env 로 제어하는 1택. 대안(코드 하드코딩 / profile 별 분기 코드)은 12-factor §III 가 거부 | `raw/official-docs/config-12-factor-app-config.md#TWELVE-FACTOR-CONFIG-C1`, `raw/official-docs/config-12-factor-app-config.md#TWELVE-FACTOR-CONFIG-C2` | `official-reference` (12-factor manifesto, not formal standard) | 12-factor 본문은 prefix grouping 을 권장하지 않음 — `APP_` 그룹화 정당성은 별도 |
|
||||
| D2 | `APP_` prefix 전면 통일 (application-owned env). **SSOT = `env-keys.yaml` registry** (2026-06-05 사용자 결정) | N/A — `APP_` 전면 통일 1택. prefix 없거나 다른 prefix 면 외부 의존 env(`SPRING_*`/`JAVA_OPTS`)와 시각 구분 불가. datasource/server 등 Spring-native 매핑 키도 일관성 위해 `APP_` 통일(Spring 표준명 예외 두지 않음) | `team-decision` (2026-06-05) — prefix 규약은 어떤 official source 도 명시 안 함(12-factor `TWELVE-FACTOR-CONFIG-C5` 는 "granular orthogonal controls" 만 언급). 일관성·시각 구분 위한 팀 결정 | `team-decision` (no external source) | 현행 코드(`application.yml`)는 `DB_*`/`LOG_*`/`CORS_*`/`OIDC_*`/`SERVER_*` 사용 → **`APP_*` 로 rename 하는 코드 마이그레이션이 후속 작업**(§Audit `ENV_PREFIX_DRIFT` RESOLVED). registry 가 ground-truth, 코드가 따라옴 |
|
||||
| D3 | no runtime reload (Spring Cloud Config Server / k8s ConfigMap auto-reload / AppConfig 거부) | 기본 no-reload. runtime reload 는 secret manager reload + startup validation test 가 연결될 때만 허용(secrets branch). 그 외 config 변경은 재배포로만 | `raw/official-docs/config-spring-cloud-config-server-official.md#SCC-SERVER-C1`, `raw/official-docs/config-spring-cloud-kubernetes-configmap-reload.md#SCK-RELOAD-C1`, `raw/official-docs/config-aws-appconfig-feature-flag-deployment.md#AWS-APPCONFIG-C1` (대안 capability 만 인용 — 본 결정은 대안의 trade-off 거부) | `official-vendor-doc` (대안 capability 근거) | 대안의 capability 인용은 "거부 이유" 의 사실 기반일 뿐 "no runtime reload 가 best practice" 의 증거는 아님 |
|
||||
| D4 | Duration unit = `30s` 1택, ISO-8601 `PT30S` forbidden | N/A — 가독성 1택. Spring Binder 가 `30s`/`PT30S`/`30` 모두 허용하므로 기술 분기가 아닌 팀 규약 | `raw/official-docs/config-spring-boot-externalized-configuration.md#SPRING-EXTCONFIG-C1` (Spring Boot 가 `30s` / `PT30S` / `30` 세 형식 모두 허용함을 확인), `raw/official-docs/config-spring-boot-externalized-configuration.md#SPRING-EXTCONFIG-C3` (DataSize `10MB` suffix 허용 확인) — **형식 선택** 자체는 팀 가독성 규약 (`UNSUPPORTED_IMPL_DECISION`): Spring 공식 근거는 "두 형식이 동등하다"는 기계적 가능성만 지지하며 `30s` 가 더 권장된다는 증거는 없음 | `official-vendor-doc` (포맷 허용 범위) | Spring Boot 가 양쪽 모두 허용하므로 `30s` 1택 규약 자체는 팀 결정 — 기계적으로는 `PT30S` 도 동작함 |
|
||||
| D5 | boolean = `true/false` only (`1/0`, `on/off` forbidden) | N/A — 일관성 1택. Spring Binder 가 `1/0`·`on/off` 도 허용하나 contract 수준 1택 | UNSUPPORTED_DECISION — 일관성 운영 결정. 외부 official 근거 없음 | none | branch 자체 정합성 규칙 |
|
||||
| D6 | profile = `SPRING_PROFILES_ACTIVE` **단독** (2026-06-06 확정: `APP_PROFILE` 도입 포기) | N/A — profile 은 application-owned config 값이 아니라 **런타임 환경 선택자**(Spring native 영역)이므로 `SPRING_PROFILES_ACTIVE` 단독. `APP_PROFILE` 별도 도입은 정보 이중화 + mismatch fail 비용만 추가 → 포기. `SPRING_PROFILES_ACTIVE` unset 시 startup fail(default profile 미부여)로 환경 명시 강제 | `raw/official-docs/config-spring-boot-externalized-configuration.md#SPRING-EXTCONFIG-C4` (relaxed binding: `spring.profiles.active` → `SPRING_PROFILES_ACTIVE`) + `team-decision` (단독 채택) | `official-vendor-doc` (relaxed binding 메커니즘) + `team-decision` | profile selector 는 D2 `APP_` 통일의 예외(Spring 런타임이 직접 읽는 native env). 향후 product 요구로 앱이 profile 을 자체 노출/검증해야 하면 그때 `APP_PROFILE` 재검토 |
|
||||
| D7 | env drift = custom Gradle task `verifyEnvKeys` (registry ↔ application.yml ↔ `.env` 3-way lock-step). B 확정(2026-06-08): **registry = SSOT** (check C), `.env.example` 미사용 | N/A — drift 검증 도구 1택. 대안(수동 리뷰/외부 lint)은 CI 자동 강제 불가 | UNSUPPORTED_DECISION — 도구 선택 운영 결정 | none | 외부 official 근거 없음. registry 미등록 키는 build fail(check C) |
|
||||
| D8 | `APP_MULTI_INSTANCE_ENABLED` flag = multi-instance contract 5종 강제 + fail-fast. **집행 = `SmartInitializingSingleton` validator bean + contract test 이중** (2026-06-06) | `false`(default)면 single-instance 허용. `true` 면 5종 contract(lock/stampede/leader/rate-limit/migration) bean presence 를 `SmartInitializingSingleton` 이 `getBeanProvider` 로 검사 → 1개라도 없으면 `throw`(startup 중단) | UNSUPPORTED_DECISION — flag 자체는 branch 정합성(외부 근거 없음). 집행 메커니즘은 `team-decision` + `UNSUPPORTED_IMPL_DECISION` (아래 trade-off) | none (flag) / `team-decision` (집행) | trade-off: `EnvironmentPostProcessor` 는 bean 정의 이전이라 presence 검사 불가 → 부적합. `SmartInitializingSingleton`(refresh 완료 전, 모든 singleton 초기화 직후)이 `ApplicationReadyEvent`(트래픽 직전)보다 이르게 fail. contract test 는 CI 회귀 방지 이중 |
|
||||
| D9 | feature flag 기본값 = env-startup flag, runtime/canary flag = registry row + owner 필수 | 기본 env-startup flag. runtime/canary flag 가 필요할 때만 registry row + `owner_branch` + rollout/rollback rule 필수(없으면 불허) | `raw/official-docs/config-aws-appconfig-feature-flag-deployment.md#AWS-APPCONFIG-C2` (operational flag use case), `raw/official-docs/config-aws-appconfig-feature-flag-deployment.md#AWS-APPCONFIG-C5` (auto-rollback 보완 기능 비교 baseline), `raw/company-tech-blogs/config-launchdarkly-feature-flag-best-practice.md#LD-FF-C1` ~ `LD-FF-C5` | `official-vendor-doc` (AppConfig 비교 baseline) + `company-case-study` (LaunchDarkly — 일반화 금지) | LaunchDarkly 는 SaaS 사례. AppConfig capability 인용은 "ca-tmpl 이 비싼 대안을 도입하지 않는 이유" 의 비교 근거일 뿐 |
|
||||
| D10 | **계층형 validation** (2026-06-05 사용자 결정): ① 단순 제약(필수·범위·정규식) = `@Validated` + JSR-303 선언 **기본**, ② JSR-303 로 표현 불가한 조건부/교차필드만 compact constructor 에서 처리 — 단 invalid 면 **`throw`(fail-fast)**, lenient default 금지 | 제약 종류로 분기: 단순 제약이면 `@Validated`+JSR-303(선언적, startup 자동 fail). 조건부/cross-field(예: `enabled=true` 일 때만 origins 필수)면 constructor 에서 throw. 정상 default(예: `enabled=false` 시 빈 origins)는 invalid 아님 → default 허용 | `raw/official-docs/config-spring-boot-externalized-configuration.md#SPRING-EXTCONFIG-C5` (Spring Boot 가 `@Validated` 를 인식해 JSR-303 `jakarta.validation` 제약을 자동 실행함을 공식 확인) + `team-decision` (계층 분리 + no-lenient 규약) | `official-vendor-doc` (`@Validated` 메커니즘) + `team-decision` (계층 분리 규약) | 현행 `CorsSettings` 는 `@Validated` 없이 constructor + 음수 maxAge lenient default → **본 결정에 맞게 (a) 단순 제약은 `@Validated` 로, (b) 음수 maxAge 는 throw 로 코드 수정 후속**(§Audit `VALIDATION_POLICY_DRIFT`/`INVALID_RANGE_LENIENT` RESOLVED) |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> ✅ **naming SSOT 확정(2026-06-05)**: env 변수 naming = `env-keys.yaml` registry 의 `APP_*` 전면 통일(D2). 본 §의 anchor 인 ca-tmpl 실제 코드(`application.yml`)는 현재 `DB_*`/`LOG_*`/`CORS_*`/`OIDC_*`/`SERVER_*` 를 쓰므로 **`APP_*` 로 rename 하는 코드 마이그레이션이 본 branch 구현의 일부**다. 아래 표의 "현행 env" 컬럼은 마이그레이션 *대상*(before), 목표는 `APP_*`(after).
|
||||
|
||||
### 1. env → property → Settings 3층 바인딩 구조 (actually-implemented)
|
||||
|
||||
> **Trace**: D1(env 전체 제어)·D2(prefix)·D10(`@ConfigurationProperties`) / `SPRING-EXTCONFIG-C5`. anchor = `src/app-bootstrap/src/main/resources/application.yml` L2 주석 "Mirrors src/.env … input validation lives in the *Settings records".
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `*Settings` record 명명 + `<module>/settings/` 패키지 위치 — 어떤 external source 도 규정 안 함. trade-off: 기존 ca-tmpl 컨벤션 답습(이미 5개 클래스가 따름) → 일관성 우선.
|
||||
|
||||
| Layer | 위치 | 역할 | 상태 |
|
||||
|---|---|---|---|
|
||||
| A. operator env | `src/.env` (git-tracked 단일 소스, placeholder 소비) | 운영자가 세팅하는 실제 env 변수 | `actually-implemented` (`.env.example` 미사용 — RESOLVED) |
|
||||
| B. `${ENV}` 브리지 | `application.yml` | env → Spring property 매핑. Spring-native(`spring.*`/`server.*`/`logging.*`) 또는 custom `ca-skeleton.*` 로 분기 | `actually-implemented` |
|
||||
| C. `*Settings` record | `<module>/settings/<Domain>Settings.java`, `@ConfigurationProperties(prefix="ca-skeleton.<group>")` | 타입 바인딩 + allowed-value 검증의 집(home) | `actually-implemented` (5종, 아래) |
|
||||
|
||||
현존 `*Settings` (코드 grep 확인): `bootstrap/settings/BootstrapSettings`(`@Validated`), `bootstrap/settings/LoggingSettings`, `adapter-web/settings/PresentationSettings`, `adapter-web/settings/SecuritySettings`, `adapter-web/settings/CorsSettings`. Spring property prefix 는 `app.*` 가 아니라 **`ca-skeleton.*`** 다.
|
||||
|
||||
### 2. fail-fast 메커니즘 (혼합 — 통일 안 됨)
|
||||
|
||||
> **Trace**: D10 / `SPRING-EXTCONFIG-C5` + §테스트 계약. anchor = `BootstrapSettings.java`, `CorsSettings.java`.
|
||||
>
|
||||
> - **집행 컴포넌트 확정(2026-06-06, D8)**: env 조합 기반 fail-fast(prod-unsafe toggle, multi-instance 5종)는 `SmartInitializingSingleton` validator bean 이 context refresh 완료 전 1회 검사 → invalid 면 `throw`. (`EnvironmentPostProcessor` 는 bean presence 검사 불가라 부적합, `ApplicationReadyEvent` 는 늦음). contract test 로 회귀 방지 이중.
|
||||
|
||||
| 검증 스타일 | 메커니즘 | 예시 | 상태 |
|
||||
|---|---|---|---|
|
||||
| 필수-무default 필드 | `@Validated` + `@NotBlank`/`@NotNull` → 누락/blank 시 `BindValidationException` startup fail | `BootstrapSettings.appName` | `actually-implemented` |
|
||||
| 단순 제약(필수·범위·정규식) | `@Validated` + JSR-303(`@NotBlank`/`@Min`/`@Positive` 등) → startup 자동 fail-fast | 신규 작성 기준(D10 ①). `BootstrapSettings` 가 선례 | `planned`(`CorsSettings.maxAge` 등에 적용 후속) |
|
||||
| 조건부/교차필드 | compact constructor 에서 검사 후 invalid 면 `throw`(fail-fast, lenient 금지) | `CorsSettings`(`enabled=true`+empty origins). 단 음수 maxAge 는 현행 lenient default → **`throw` 로 수정 후속** | `actually-implemented`(스타일) / lenient 부분은 `planned` 수정 |
|
||||
| prod-unsafe / multi-instance fail | env 조합(`APP_LOG_BODY*`+prod, 또는 `APP_MULTI_INSTANCE_ENABLED=true`+5종 bean) 위반 시 `SmartInitializingSingleton` validator 가 `throw` | `ProdProfileSafetyTest` + multi-instance contract test (미존재) | `planned` (집행 컴포넌트는 확정, 코드 미작성) |
|
||||
|
||||
> ✅ **정책 확정(2026-06-05, D10)**: 단순 제약 = `@Validated`+JSR-303, 조건부/교차필드 = constructor + `throw`(lenient 금지). 따라서 신규 `*Settings` 작성 기준이 명확하다. 현행 `CorsSettings` 는 (a) 단순 제약을 `@Validated` 로 끌어올리고 (b) 음수 maxAge lenient default 를 `throw` 로 바꾸는 코드 수정이 후속(`VALIDATION_POLICY_DRIFT`/`INVALID_RANGE_LENIENT` RESOLVED — §Audit).
|
||||
|
||||
### 3. profile 해석 (actually-implemented, 단 단일화)
|
||||
|
||||
> **Trace**: D6 / `SPRING-EXTCONFIG-C4`. anchor = `application.yml` L16-18 `spring.profiles.active: ${SPRING_PROFILES_ACTIVE}`.
|
||||
|
||||
현행 코드는 `SPRING_PROFILES_ACTIVE` **단독** 사용 — **D6 확정(2026-06-06)과 정합**. `APP_PROFILE` 은 도입하지 않으므로 우선순위/mismatch-fail 로직은 구현 대상 아님. `application.yml` L18 `spring.profiles.active: ${SPRING_PROFILES_ACTIVE}` 가 SSOT이며, unset 시 placeholder 미해소로 startup fail(default profile 미부여) — `actually-implemented`.
|
||||
|
||||
### 4. env drift 검증 — `verifyEnvKeys` 3-way lock-step (`actually-implemented`)
|
||||
|
||||
> **Trace**: D7. anchor = `src/build.gradle` `verifyEnvKeys` task + `docs/registries/env-keys.yaml`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: gate 형태(custom Gradle task)는 도구 선택 운영 결정(D7 자체 UNSUPPORTED). trade-off: registry ↔ application.yml ↔ `.env` 3-way 를 CI 에서 자동 강제.
|
||||
|
||||
**B 확정(2026-06-08): registry = enforced SSOT.** `.env.example` 은 두지 않음(`src/.env` 가 git-tracked 단일 소스 → redacted 사본 중복). `verifyEnvKeys` 게이트 3-check: (A) application.yml 의 required placeholder(inline default 없는 `${VAR}`) ⊆ `.env`, (B) `.env` orphan 0, **(C) `.env` 의 모든 `APP_` 키 ∈ `env-keys.yaml`(registry SSOT 강제). `SPRING_*` native 는 미추적.** `check` 에 `dependsOn`. 게이트 통과: `verifyEnvKeys: OK — 68 env keys, 64 required placeholders covered, 55 APP_ keys registered`.
|
||||
|
||||
### 5. 코드 마이그레이션 체크리스트 (본 branch 결정의 ca-tmpl 코드 반영)
|
||||
|
||||
> 본 branch 의 확정 결정이 만드는 실제 코드 작업. 모두 ground-truth 대조로 도출됨(§Audit).
|
||||
|
||||
| # | 작업 | 근거 결정 | 파일 |
|
||||
|---|---|---|---|
|
||||
| M1 | env 변수 `DB_*`/`LOG_*`/`CORS_*`/`OIDC_*`/`SERVER_*` → `APP_*` rename (registry `env-keys.yaml` 이름에 정렬). `SPRING_PROFILES_ACTIVE` 등 Spring native 는 유지 | D2 | `application.yml`, `src/.env` |
|
||||
| M2 | `application.yml` L147 `ca-skeleton.cmd.app-name` → `ca-skeleton.bootstrap.app-name` (latent bug fix) | Advisory | `application.yml` |
|
||||
| M3 | `CorsSettings`: 단순 제약을 `@Validated`+JSR-303 로, 음수 maxAge lenient default → `throw` | D10 | `CorsSettings.java` |
|
||||
| M4 | `SmartInitializingSingleton` validator bean 작성: prod-unsafe + `APP_MULTI_INSTANCE_ENABLED` 5종 bean presence 검사 → `throw` | D8 | `app-bootstrap` (신규) |
|
||||
| M5 | `verifyEnvKeys` Gradle task: registry ↔ application.yml ↔ `.env` 3-way lock-step (check C = registry SSOT 강제). `.env.example` 미사용 | D7 | `build.gradle`, `env-keys.yaml` |
|
||||
|
||||
> **OUT_OF_BRANCH_SCOPE**: adapter on/off 3-layer(`@ConditionalOnProperty` + ArchUnit static + `AdapterDisabledException`)는 governing doc §29 G-I 영역이지만 owner 는 [[raw/branch-notes/feature-integration-adapter-templates]] — 본 §에 명세 남기지 않음(§Coverage 위임 행 참조).
|
||||
|
||||
## 구현 완료 기록 (2026-06-06 1차 + 2026-06-08 B) — M1~M5 `actually-implemented`
|
||||
|
||||
> ca-tmpl `src/` 실 코드에 M1~M5 전부 반영. `./gradlew check` (전 모듈 test + ArchUnit `CleanArchitectureTest` + `verifyCleanArchitectureDependencies` + `verifyEnvKeys`) **BUILD SUCCESSFUL**. 리뷰 체인 ca-architect-sentinel / ca-spec-reviewer / ca-quality-reviewer **모두 PASS**.
|
||||
> **2026-06-08 B 후속**: registry = enforced SSOT 로 전환 — `env-keys.yaml` as-built 55키 전면 정렬(39행 추가 + 2 이름충돌 해소) + `verifyEnvKeys` check C 추가. 독립 검증: `verifyEnvKeys` BUILD SUCCESSFUL(`55 APP_ keys registered`), `:app-bootstrap:test`·`:adapter-web:test` BUILD SUCCESSFUL, live `APP_` 키 missing 0.
|
||||
|
||||
| # | 작업 | 상태 | 핵심 구현 사실 |
|
||||
|---|---|---|---|
|
||||
| M1 | env `DB_*`/`LOG_*`/`CORS_*`/`OIDC_*`/`SERVER_*` → `APP_*` | `actually-implemented` | `src/.env` + `application.yml` placeholder 전면 rename. **scope = audit `ENV_PREFIX_DRIFT` 의 5 prefix 정확히** (PRESENTATION_API_BASE_PATH·SECURITY_PUBLIC_PATHS 는 목록 외라 유지). `SERVER_*`→`APP_SERVER_*`(D2 전면통일). 매핑: DB_→APP_DATASOURCE_, LOG_→APP_LOG_, CORS_→APP_SECURITY_CORS_(ORIGINS/ALLOW_CREDENTIALS/MAX_AGE 는 registry 명), OIDC_→APP_SECURITY_JWT_. 정직성 위해 `SecuritySettings`/`LoggingSettings` 로그 문자열 + 매칭 test 단언도 갱신. SPRING_*·SPRING_PROFILES_ACTIVE native 유지. |
|
||||
| M2 | `ca-skeleton.cmd.app-name` → `ca-skeleton.bootstrap.app-name` | `actually-implemented` | `application.yml` L147 + `application-test.yml` 둘 다 수정. latent bug(클래스는 `ca-skeleton.bootstrap` 바인딩)는 full-context 기동에서만 발현했던 것 — `@WebMvcTest` slice 라 기존 test 는 통과했었음. |
|
||||
| M3 | `CorsSettings` 계층형 validation | `actually-implemented` / `locally-verified` | `@Validated` + `@PositiveOrZero`(maxAge<0 → `BindValidationException` startup fail). cross-field(`enabled=true`+empty origins)는 compact constructor `throw`(D10 prose 예시, 기존 warn+fail-closed 대체). logger 제거. `CorsSettingsTest` 4 메서드 재작성(`ValidationAutoConfiguration` 주입). |
|
||||
| M4 | `SmartInitializingSingleton` startup 가드 + 플래그 도입 | `actually-implemented` / `locally-verified` | 신규 `StartupSafetyValidator`(`bootstrap.runtime`) + `RuntimeSafetyConfig`(@Bean wiring) + `RuntimeSafetySettings`(`@ConfigurationProperties("ca-skeleton.runtime")`). prod profile + (`APP_ERROR_DETAIL_EXPOSURE_ENABLED`\|`APP_LOG_BODY_CAPTURE_ENABLED`)=true → `throw`. `APP_MULTI_INSTANCE_ENABLED`=true + 5종 coordination bean(name 기반 presence) 누락 → `throw`. 세 플래그를 `.env`/`application.yml`/`application-test.yml` 에 신규 wiring. `StartupSafetyValidatorTest` 8 메서드. profile 매칭은 의도적 case-insensitive(prod 오타 가드). |
|
||||
| M5 | env drift Gradle task `verifyEnvKeys` | `actually-implemented` / `locally-verified` | **B 확정(2026-06-08): registry = enforced SSOT.** `.env.example` 미사용(`src/.env` 가 git-tracked 단일 소스). 게이트 3-check: (A) application.yml required placeholder ⊆ `.env`, (B) `.env` orphan 0, **(C) `.env` 의 모든 `APP_` 키 ∈ `env-keys.yaml`** (registry 미등록 키는 build fail; `SPRING_*` 미추적). `check` 에 `dependsOn`. **`verifyEnvKeys: OK — 68 env keys, 64 required placeholders covered, 55 APP_ keys registered`**. (초기 2026-06-06 설계는 surface-only A/B 였으나 2026-06-08 B 결정으로 check C + registry 전면 정렬 추가 — 아래 결정 노트.) [[raw/blog-topics/env-example-drift-gate-gradle-2026-06-06]] |
|
||||
|
||||
**registry(`docs/registries/env-keys.yaml`) 정렬 — 2026-06-06 1차 + 2026-06-08 B 완성**:
|
||||
- 1차(2026-06-06): `APP_PROFILE` row 제거(D6 폐기), `SERVER_PORT`→`APP_SERVER_PORT`(D2), `APP_MULTI_INSTANCE_ENABLED` 추가(D8), 헤더 convention/Last-updated 갱신.
|
||||
- **B(2026-06-08): registry 를 as-built 55 `APP_` 키와 전면 정렬.** 누락 39행 추가(datasource extras 7 → env-driven, server 12 → env-driven, log granular 17 → log-management, CORS 3 → security). 이름 충돌 해소: `APP_LOG_LEVEL` 단일 → `APP_LOG_LEVEL_{ROOT,APP,SPRING,WEB,SQL}` 5분할(code 이름 채택), `APP_SHUTDOWN_TIMEOUT`(container-runtime) → `APP_SERVER_SHUTDOWN_TIMEOUT`(env-driven, termination-grace 정렬은 container-runtime cross-ref 주석 보존). 독립 검증: live `APP_` 55키 전부 registry 존재(missing 0).
|
||||
|
||||
> **결정 노트(2026-06-08, B = registry SSOT)**: 초기 2026-06-06 구현은 "drift 정답 소스 = application.yml surface, registry 1:1 강제 불가"로 갔으나(check A/B only), 사용자가 **B(registry = enforced SSOT)** 선택. 따라서 ① registry 를 as-built 와 전면 정렬, ② `verifyEnvKeys` 에 check C(모든 live `APP_` 키 ∈ registry) 추가, ③ `build.gradle` 주석을 "registry SSOT lock-step"으로 갱신. cross-branch 이름/owner 2건은 사용자 결정(이름=code 채택, `APP_SERVER_*` owner=env-driven). M1 의 SERVER_* rename 은 D2 전면통일 우선(registry 2026-05-22 주석/governing §9 의 "SERVER_* native"는 stale).
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4 캡처용. 정상 경로 외 실패/엣지 + 다른 계약 의존.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- `APP_NAME` 누락/blank → `BindValidationException`, context refuses to start (`actually-implemented`, `BootstrapSettings`).
|
||||
- `CORS_ENABLED=true` + `CORS_ALLOWED_ORIGINS` empty → `log.warn` + 모든 브라우저 호출 reject(fail-open 아님, fail-closed). `actually-implemented`(`CorsSettings`).
|
||||
- invalid range(음수 `APP_SECURITY_CORS_MAX_AGE`) → **D10 확정에 따라 `throw`(fail-fast)**. 현행 코드의 lenient default(3600)+warn 는 throw 로 수정 후속.
|
||||
- prod profile + body logging / internal error detail exposure ON → fail 기대이나 enforcing test 부재(`planned`).
|
||||
- `SPRING_PROFILES_ACTIVE` unset → `${SPRING_PROFILES_ACTIVE}` placeholder 미해소 → startup fail(default profile 없음). 엣지: 의도적 default 미부여인지 확인 필요.
|
||||
- **다른 계약 의존** (env 값 semantics 위임 — 본 branch 는 *env→Settings 바인딩·검증 계약*을 소유, 값 정책은 owner branch):
|
||||
- [[raw/branch-notes/feature-secrets-config-source-contract]] — `DB_PASSWORD`/JWT signing key 등 secret-classified env (registry `owner_branch` 확인). 이 계약이 secret 해소 방식을 바꾸면 본 branch 의 바인딩 layer 영향.
|
||||
- [[raw/branch-notes/feature-log-management-contract]] — `LOG_*`(level/file/async/json) → `LoggingSettings`. 본 branch 는 바인딩, 로그 semantics 는 위임.
|
||||
- [[raw/branch-notes/feature-security-operational-baseline]] — `CORS_*`/`OIDC_*` → `CorsSettings`/`SecuritySettings`.
|
||||
- [[raw/branch-notes/feature-outbound-http-client-baseline]] — outbound timeout/retry/CB env (registry `APP_OUTBOUND_*`; 단 코드 미존재 `planned`).
|
||||
- [[raw/branch-notes/feature-distributed-tracing-contract]] — tracing enable/sample-rate env.
|
||||
- [[raw/branch-notes/feature-cache-consistency-contract]] — cache redis env(`APP_CACHE_REDIS_*`/`APP_CACHE_*_TTL`) → 값 semantics 위임(registry `owner_branch`).
|
||||
- [[raw/branch-notes/feature-integration-adapter-templates]] — adapter on/off `@ConditionalOnProperty`(OUT_OF_SCOPE here).
|
||||
- **D8 multi-instance**: `APP_MULTI_INSTANCE_ENABLED` 를 `feature-runtime-health-lifecycle-contract`·`feature-background-job-async-contract`·`feature-cache-consistency-contract`·`feature-domain-event-outbox-contract`·`feature-rate-limit-idempotency-contract`·`feature-migration-startup-contract` 6개가 consume. 본 flag 의미 변경 시 6개 모두 영향.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| ca-tmpl `APP_` prefix 가 12-factor "granular orthogonal controls" 와 양립 | 12-factor 는 grouping 을 권장하지 않음 — prefix grouping 이 orthogonality 를 약화시키는지 불확실 | env-keys.yaml registry 에 각 key 의 orthogonality 명시 + ArchUnit/registry-scan 으로 cross-coupling 탐지 | `needs-confirmation` |
|
||||
| ~~`.env.example` drift verifier 가 registry 와 100% 일치 보장~~ → `verifyEnvKeys` 가 registry↔application.yml↔`.env` 100% 일치 강제 | (해소) | `verifyEnvKeys` check C 가 live `APP_` 키 ⊆ registry 강제 + 독립 검증 missing 0 | `actually-implemented` (B, 2026-06-08) |
|
||||
| `APP_MULTI_INSTANCE_ENABLED=true` 시 5종 contract test 가 모두 fail-fast 동작 | 5종 contract test 가 아직 작성되지 않음 | feature-runtime-health-lifecycle / feature-cache-consistency 등 5 branch 의 contract test 작성 후 통합 검증 | `planned` |
|
||||
| ~~`SPRING_PROFILES_ACTIVE` 와 `APP_PROFILE` 불일치 시 startup fail~~ | — | — | `wont-fix` (2026-06-06: `APP_PROFILE` 도입 포기, D6) |
|
||||
| prod profile 에서 body logging / internal error detail exposure enabled 시 startup fail | 구현 미확인 | `ProdProfileSafetyTest` contract test 구현 — `SPRING_PROFILES_ACTIVE=prod` + `APP_LOG_BODY_CAPTURE_ENABLED=true` 조합에서 `SmartInitializingSingleton` validator 가 startup fail 시키는지 verify | `planned` |
|
||||
| feature flag registry owner 강제 | env-keys.yaml registry schema 미확정 | env-keys.yaml schema 에 `owner_branch` field 추가 + `@FeatureFlag` annotation processor 가 yaml 와 cross-check | `planned` |
|
||||
| AppConfig / LaunchDarkly 채택 trigger (50+ flag 또는 product team 운영) | branch 가 의도적으로 위임한 영역 | flag 수가 50 초과하거나 A/B canary 요구가 발생할 때 별도 검토 trigger | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지
|
||||
|
||||
> 기준: `governing_docs = wiki/projects/ca-tmpl/config-and-adapter-templates.md` (canonical §9 Env config + §29 G-I Adapter). 상태: `covered-here` / `delegated` / `missing`. 기준 SSOT: `rules/coverage-gate.md`.
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|--------|------|-------|--------|------|
|
||||
| env prefix / naming 계약 | covered-here | — | OK | D2 — `APP_*` 통일 확정(2026-06-05). 코드 rename 후속 작업 |
|
||||
| Duration `30s` 포맷 | covered-here | — | OK | D4 / `SPRING-EXTCONFIG-C1,C3` |
|
||||
| boolean `true/false` only | covered-here | — | OK | D5 |
|
||||
| no-runtime-reload | covered-here | — | OK | D3 |
|
||||
| env drift 검증 | covered-here | — | OK | D7 — `verifyEnvKeys` 3-way(registry SSOT, check C) `actually-implemented` (B, 2026-06-08) |
|
||||
| `@ConfigurationProperties + @Validated` | covered-here | — | OK | D10 — 계층형 validation 확정(2026-06-05). CorsSettings 코드 수정 후속 |
|
||||
| profile 해석/matrix | covered-here | — | OK | D6 — `SPRING_PROFILES_ACTIVE` 단독 확정(2026-06-06) |
|
||||
| error detail exposure toggle | covered-here | — | OK | §테스트 계약 (registry `APP_ERROR_DETAIL_EXPOSURE_ENABLED`; 코드 `SERVER_ERROR_INCLUDE_*`) |
|
||||
| body logging toggle | covered-here | — | OK | §테스트 계약 (registry `APP_LOG_BODY_CAPTURE_ENABLED`) |
|
||||
| datasource / pool env | covered-here | — | OK | registry `APP_DATASOURCE_*` / 코드 `DB_*` (§1 표) |
|
||||
| required env fail-fast | covered-here | — | OK | D10 / `BootstrapSettings` |
|
||||
| adapter on/off — Layer1 `@ConditionalOnProperty` | delegated | [[raw/branch-notes/feature-integration-adapter-templates]] | OK | governing §29 G-I; §구현 가이드 OUT_OF_SCOPE 주석 |
|
||||
| adapter on/off — Layer2 ArchUnit static | delegated | [[raw/branch-notes/feature-integration-adapter-templates]] | OK | governing §29 G-I |
|
||||
| adapter on/off — Layer3 `AdapterDisabledException` | delegated | [[raw/branch-notes/feature-integration-adapter-templates]] | OK | governing §29 G-I |
|
||||
| outbound timeout/retry/CB env 값 | delegated | [[raw/branch-notes/feature-outbound-http-client-baseline]] | OK | registry `owner_branch` |
|
||||
| tracing enable/sample-rate env 값 | delegated | [[raw/branch-notes/feature-distributed-tracing-contract]] | OK | registry `owner_branch` |
|
||||
| log level/sampling/file env 값 | delegated | [[raw/branch-notes/feature-log-management-contract]] | OK | registry `owner_branch` |
|
||||
| security/CORS/JWT env 값 | delegated | [[raw/branch-notes/feature-security-operational-baseline]] | OK | registry `owner_branch` |
|
||||
| secret-classified env (DB_PASSWORD, JWT key) | delegated | [[raw/branch-notes/feature-secrets-config-source-contract]] | OK | registry `owner_branch` |
|
||||
| cache redis env 값 | delegated | [[raw/branch-notes/feature-cache-consistency-contract]] | OK | registry `owner_branch` |
|
||||
|
||||
**missing: 0** — governing doc 의 모든 관심사가 owner 보유. env naming(D2)·validation(D10)·profile(D6)·D8 집행·prefix bug·env drift(D7) 전부 RESOLVED + `actually-implemented`. 잔여 🟡 0건. Blocking 아님.
|
||||
|
||||
## Audit & Findings (2026-06-05 — /branch-spec ca-tmpl ground-truth 대조)
|
||||
|
||||
> ca-tmpl `src/` + `docs/registries/` 를 읽기 전용으로 대조해 발견한 drift. **사용자 작성 결정 영역이므로 자동 rewrite 하지 않고 정합 권고만 남긴다**(CLAUDE.md §11, branch-spec §2). 해소는 `/branch-spec` 재실행 또는 사용자 결정.
|
||||
|
||||
| 라벨 | 내용 | 증거 | 권고 (사용자 결정) |
|
||||
|---|---|---|---|
|
||||
| `ENV_PREFIX_DRIFT` ✅ RESOLVED (2026-06-05) | env 변수 naming 이 **3-way** 불일치였음: 노트 D2 / `env-keys.yaml`(`APP_*`) / 코드 `application.yml`(`DB_*`·`LOG_*`·`CORS_*`·`OIDC_*`·`SERVER_*`) | registry 에 `DB_URL` 등 0건, application.yml 에 `APP_DATASOURCE` 등 0건 (grep) | **결정: `APP_*` 전면 통일, SSOT = registry**(D2). 코드(`application.yml`+`src/.env`)를 `APP_*` 로 rename 하는 것이 본 branch 구현 작업의 일부 |
|
||||
| `REGISTRY_CODE_DRIFT` ✅ RESOLVED (B, 2026-06-08) | env-keys.yaml 이 as-built env 이름/surface 와 매칭 안 됐음(48행 vs 55키, granular 키 다수 누락) | 위와 동일 grep | **registry 를 as-built 55키와 전면 정렬(39행 추가 + 2 이름충돌 해소) + `verifyEnvKeys` check C 가 registry↔.env 를 CI 강제.** 독립 검증 missing 0 |
|
||||
| `REGISTRY_GITIGNORED` ✅ ACCEPTED (사용자 결정 2026-06-09) | ca-tmpl `.gitignore` 가 `/docs` 전체를 ignore(`CLAUDE.md`/`.claude`/`.codex` 등 AI 툴링과 함께한 **의도적 repo 정책**) → SSOT registry(`env-keys.yaml`)가 version-control 안 됨. drift 가드(check C / RegistryTest)는 파일 부재 시 `assumeTrue` 로 **SKIP**(통과 아님). | `.gitignore:2:/docs`, `git ls-files` 미추적 | **사용자 결정(2026-06-09): 현 정책 유지** — registry 는 local dev artifact, docs/ 전체 gitignore 유지. **한계 수용**: fresh clone/CI(docs 부재)에서 registry drift 가드는 강제되지 않고 SKIP. 따라서 "registry=enforced SSOT"는 *registry-present(로컬) 환경에서만* 성립함을 명시. (재고 시: docs/registries 만 un-gitignore, 또는 wiki SSOT→mirror CI 동기화.) |
|
||||
| `VALIDATION_POLICY_DRIFT` ✅ RESOLVED (2026-06-05) | D10 "모든 바인딩 `@Validated` 강제" vs `CorsSettings` 는 `@Validated` 없이 constructor 검증 | `CorsSettings.java`(no `@Validated`), `BootstrapSettings.java`(`@Validated`) | **결정: 계층형 — 단순 제약 `@Validated`+JSR-303, 조건부/교차필드만 constructor + throw**(D10). `CorsSettings` 코드 조정 후속 |
|
||||
| `PROFILE_DUALITY_DRIFT` ✅ RESOLVED (2026-06-06) | D6 의 `APP_PROFILE` env 가 코드에 부재(`SPRING_PROFILES_ACTIVE` 단독)였음 | `application.yml` L18 | **결정: `APP_PROFILE` 도입 포기, `SPRING_PROFILES_ACTIVE` 단독**(D6). mismatch-fail 로직 미구현, Claims 행 `wont-fix` |
|
||||
| `ENV_FILE_NAME_DRIFT` ✅ RESOLVED (2026-06-06) | D7 `.env.example` vs 실제 `src/.env` | `application.yml` L2 주석 | **결정: `.env.example` 두지 않고 `src/.env`(tracked) 단일 소스로 통일**(사용자 2026-06-06). drift 게이트는 `verifyEnvKeys`(`.env`↔application.yml). |
|
||||
| `INVALID_RANGE_LENIENT` ✅ RESOLVED (2026-06-05) | §판정 기준 "invalid range → fail" vs `CorsSettings` 음수 maxAge → default+warn(lenient) | `CorsSettings` compact ctor | **결정: invalid range → `throw`(fail-fast)**(D10). `CorsSettings` 음수 maxAge default 를 throw 로 수정 후속 |
|
||||
| `SETTINGS_PREFIX_INTERNAL_DRIFT` ✅ 진단 완료 (2026-06-06) — **latent bug** | `application.yml` L147 `ca-skeleton.cmd.app-name` 이 stale. 클래스+테스트는 `ca-skeleton.bootstrap.app-name` 로 일관(다른 4개 `*Settings` 도 `ca-skeleton.<group>` 컨벤션). 실제 기동 시 `BootstrapSettings.appName` 미바인딩 → `@NotBlank` startup fail 날 버그 | `BootstrapSettings.java`+`BootstrapSettingsTest.java`(both `ca-skeleton.bootstrap`) vs `application.yml` L147 (`ca-skeleton.cmd`) | **클래스가 SSOT. ca-tmpl `application.yml` L147 `cmd:` → `bootstrap:` 수정(코드 후속 bugfix)**. 신규 `*Settings` 는 `ca-skeleton.<group>` 컨벤션 |
|
||||
| `LENIENT_DEFAULT_EXCEPTIONS` ✅ ACCEPTED (사용자 결정 2026-06-09) | D10 "lenient default 금지"는 `CorsSettings` 에 적용(throw 로 수정, RESOLVED)했으나, `LoggingSettings`(`bootstrap.settings`)·`SecuritySettings`(`adapter-web.settings`)는 여전히 warn-and-default. 감사가 D10 위배로 잡음. **그러나 둘 다 careless 가 아니라 문서화된 근거 있는 예외**: (1) `LoggingSettings` — logback 이 `<springProperty>` 로 *이미* 자기 default 로 바인딩한 뒤라 record 는 *operator 경고 surface* 일 뿐(여기서 throw 해도 logback 은 이미 진행). (2) `SecuritySettings` L28 — "audience 없음 → audience 검증 skip" 은 *선택적 보안 기능 토글*이지 typo 마스킹 fallback 이 아님. | `LoggingSettings.java`(File/Async/Json compact ctor `log.warn`+default), `SecuritySettings.java:28` | **사용자 결정(2026-06-09): lenient 유지** — D10 은 "*의미 있는 invalid 를 silent default 로 가리지 말 것*"이 취지이며, 위 둘은 owning-library(logback)/optional-feature 라 예외가 정당. D10 을 *보편 강제*가 아니라 *예외 명시 규약*으로 정합. (audience 를 prod 필수로 하려면 별도 prod-profile fail-fast 결정 — 본 branch 범위 밖.) |
|
||||
| `REGISTRY_VALIDATION_UNENFORCED` ✅ RESOLVED (2026-06-09) | registry `env-keys.yaml` 가 high-risk numeric 키에 `validation: positive_int`/`non_negative_int` 컬럼을 선언하나 코드가 강제 안 함(Spring-native 로 흘러가 Hikari/Tomcat 가 늦게·cryptic 하게 reject). 감사 "fictional validation columns". | `RuntimeNumericBoundsValidator.java`(신규), `RuntimeSafetyConfig`(@Bean) | **신규 `RuntimeNumericBoundsValidator`(`SmartInitializingSingleton`, 고위험 numeric만) 가 resolved Spring property 를 읽어 범위 위반 시 fail-fast — `APP_*` 키 이름 명시 메시지. pool max/min-idle, tomcat max/min-spare/max-conn/accept-count 6키. `RuntimeNumericBoundsValidatorTest` 4 메서드(`:app-bootstrap:test` 144/144 green). 이로써 positive_int/non_negative_int 컬럼이 *실제 강제*. log.* 등 logback-owned·Duration 키는 owning-lib 위임(범위 밖).** |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- [[raw/daily-notes/2026-05-27]]
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목: M1 env `APP_*` 전면통일(.env/application.yml/Settings 로그문자열/test), M2 `ca-skeleton.bootstrap.app-name` bug fix, M3 `CorsSettings` 계층형 validation, M4 `StartupSafetyValidator`(prod-unsafe + multi-instance presence) + 3 플래그 wiring, M5 `verifyEnvKeys` 3-way gate(registry SSOT, check C), **registry `env-keys.yaml` as-built `APP_` 키 전면 정렬(B, 2026-06-08: 39행 추가 + 2 이름충돌 해소)**, **M6 `RuntimeNumericBoundsValidator`(2026-06-09 — 고위험 numeric pool/tomcat 6키 fail-fast, registry `positive_int`/`non_negative_int` 컬럼 실제 강제, `RuntimeNumericBoundsValidatorTest` 4) + `RuntimeSafetyConfig` @Bean wiring**.
|
||||
- **2026-06-09 갱신**: live `APP_` 키 수 = **57**(검증: `grep '^APP_' src/.env | sort -u | wc -l`). 본문의 historical "55"(2026-06-08 게이트 출력)는 그 시점 값 — 현재 57. lenient 정책은 `LENIENT_DEFAULT_EXCEPTIONS`(§Audit) 로 정합(LoggingSettings/SecuritySettings 의도적 예외).
|
||||
- `locally-verified` 항목: `./gradlew check` BUILD SUCCESSFUL(전 모듈 test + ArchUnit + verifyCleanArchitectureDependencies + verifyEnvKeys), `verifyEnvKeys` drift 주입→FAIL / clean→OK + check C 단독 발화 확인, `:app-bootstrap:test`·`:adapter-web:test` BUILD SUCCESSFUL(독립 재검증), live `APP_` 55키 registry missing 0, 리뷰 체인(sentinel/spec/quality) 전부 PASS.
|
||||
- `prod-verified` 항목: 없음(skeleton, prod 배포 이력 없음).
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned): adapter on/off 3-layer(owner: integration-adapter-templates), outbound/tracing/cache/security/secret 값 semantics(각 owner branch), multi-instance 5종 contract bean 실제 구현(각 owner branch, 본 branch 는 presence 계약만 소유), `APP_PROFILE`(D6 abandoned).
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-operational-contract/branch-notes/feature-file-resource-handling-contract.md
|
||||
@@ -0,0 +1,260 @@
|
||||
---
|
||||
title: branch / feature-file-resource-handling-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-023
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-023
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
branch: feature-file-resource-handling-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
tags: [branch, ca-skeleton, file, resource]
|
||||
created: 2026-05-22
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 7db5c6a4eb77af61706b5f4688cf721f786789638595dca733d334c5dca3d3c0
|
||||
---
|
||||
|
||||
# branch: feature-file-resource-handling-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — file/resource 처리 실패 기준을 정의합니다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (§<관련 섹션>) 의 결정/근거/금지 사항을 정제한다.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: file size·type·storage boundary test가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1` | Gradle multi-module에서 domain-core·application-core·adapter-*·shared-contract·app-bootstrap·sample-portfolio 책임을 분리한다 | file/resource 처리의 application·adapter 책임 경계에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/file-clamav-icap-gateway-scan]]
|
||||
- [[raw/official-docs/file-s3-presigned-url-upload]]
|
||||
- [[raw/official-docs/file-tus-resumable-upload-protocol]]
|
||||
- [[raw/official-docs/iana-media-types-registry]]
|
||||
- [[raw/official-docs/jdk-files-createtempfile]]
|
||||
- [[raw/official-docs/nginx-client-max-body-size]]
|
||||
- [[raw/official-docs/owasp-file-upload-cheat-sheet]]
|
||||
- [[raw/official-docs/owasp-path-traversal]]
|
||||
- [[raw/official-docs/spring-boot-multipart-reference]]
|
||||
- [[raw/official-docs/spring-mvc-async-streaming]]
|
||||
- [[raw/official-docs/spring-streaming-response-body]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 feature branch 는 leaf — 자식 자료 없음. Phase C2 실 코드 작성 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록 (본 feature 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase C2 실 구현 단계에 누적)
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
multipart 실패만으로는 파일 처리 기준이 부족합니다. upload size, temp file cleanup, streaming failure, content type sniffing, path traversal 방지를 skeleton 기준에 포함해야 합니다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- upload size limit.
|
||||
- multipart parse failure.
|
||||
- temp file cleanup.
|
||||
- download streaming failure.
|
||||
- content type sniffing 금지.
|
||||
- path traversal 방지.
|
||||
- resource exhaustion 분류.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 실제 object storage adapter 구현.
|
||||
- antivirus scan 구현.
|
||||
- CDN/download product policy.
|
||||
|
||||
## TODO
|
||||
|
||||
> TODO drained 2026-05-22 — 결정은 아래 "결정 사항" / "Decisionized Work Items" / "테스트 계약" 참조. upload size/multipart parse/temp cleanup/streaming/content-type allowlist/path traversal/resource exhaustion/antivirus 위치 모두 결정 라인 또는 표 row로 반영됨. 잔존 TODO 없음.
|
||||
|
||||
## Work Item Contract
|
||||
|
||||
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
|
||||
|
||||
| field | required | rule |
|
||||
| --- | --- | --- |
|
||||
| Decision | yes | 구현자가 선택해야 하는 기본값 |
|
||||
| Allowed | yes | 허용되는 예외와 조건 |
|
||||
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
|
||||
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
|
||||
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
|
||||
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
|
||||
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- file/resource handling은 API contract와 runtime lifecycle 양쪽에 걸칩니다.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- 2026-05-22: file/resource handling을 별도 운영 표면으로 분리.
|
||||
- 2026-05-22: antivirus/file scanning은 기본 off. 활성화 위치는 gateway, async worker, app inline 중 하나로 명시해야 하며 미정이면 업로드 feature 승급 불가.
|
||||
- 2026-05-22: upload size limit 기본값은 10MB, file sample은 core v1에 포함하지 않음.
|
||||
- 2026-05-22: size limit enforcement layer SSOT = Spring `spring.servlet.multipart.max-file-size` 10MB + global request size 12MB. gateway/WAF는 보조(20MB hard limit). Spring 단의 enforcement가 실패 시 envelope 응답 보장.
|
||||
- 2026-05-22: 3계층 분리는 의도된 defense-in-depth: gateway 20MB는 raw 413 직격 차단 (envelope 우회), global 12MB는 multipart 외 raw body 한도, Spring 10MB는 multipart 단일 file 한도. 모든 한도 위반은 Spring 단에서 분류되어 envelope 응답으로 변환.
|
||||
- 2026-05-22: temp file cleanup trigger = (1) success/failure on close (try-with-resources), (2) startup sweeper for orphaned files older than 1h, (3) JVM shutdown hook은 backup. file >1h not closed → orphan.
|
||||
- 2026-05-22: allowed content-type allowlist starting set = image/jpeg, image/png, image/webp, application/pdf, text/csv, application/zip. 추가는 endpoint별 registry 등록.
|
||||
- 2026-05-22: streaming download backpressure = response timeout 60s, max stream 100MB. 초과 시 truncate + ERROR log.
|
||||
- 2026-05-22: file storage abstraction은 outbound = object store call이 EXTERNAL_OUTBOUND_ALLOWED capability 요구.
|
||||
- 2026-05-22: antivirus default = scan position = "gateway" (외부 upload-가능 endpoint), in-app 검증은 disabled. 활성화 시 별도 worker로 분리.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. Company-tech-blog / vendor blog 인용은 사례 (`company-case-study`) 로만 사용, 공식 best practice 단정 금지.
|
||||
|
||||
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|
|
||||
| D1 | file/resource handling 을 별도 운영 표면으로 분리 | UNSUPPORTED_DECISION — 내부 조직/스코프 결정 | `internal-only` | 다른 branch (lifecycle/outbound) 와 책임 경계 lint 필요 |
|
||||
| D2 | antivirus/file scanning default = off, 활성화 위치는 gateway/worker/app 중 1개 명시 강제 | `raw/company-tech-blogs/file-clamav-icap-gateway-scan.md#CLAMAV-ICAP-C1`, `raw/company-tech-blogs/file-clamav-icap-gateway-scan.md#CLAMAV-ICAP-C2` (ICAP = `official-standard` strength) | `official-standard + official-vendor-doc` | gateway 위치 default 권고는 `company-case-study` 영역 — 공식 best practice 단정 금지 |
|
||||
| D3 | upload size limit default = 10MB, file sample core v1 미포함 | **메커니즘 SUPPORTED**: `raw/official-docs/spring-boot-multipart-reference.md#SB-MULTIPART-C1` (Servlet 5 `Part` API 채택, `official-vendor-doc`), `raw/official-docs/spring-boot-multipart-reference.md#SB-MULTIPART-C2` (Spring Boot default per-file 1MB / per-request 10MB, `official-vendor-doc`). **정량값 UNSUPPORTED_DECISION**: per-file 10MB default 는 Spring Boot upstream default (1MB) 와 다르며 ca-tmpl 자체 결정 — 외부 권고 부재 | `official-vendor-doc (mechanism only)` | 10MB 정량값은 ca-tmpl 자체 결정 — endpoint registry override 정책으로 보완 필요. 'file sample core v1 미포함' 은 internal scope 결정 (UNSUPPORTED) |
|
||||
| D4 | size limit enforcement SSOT = Spring (multipart 10MB + global request 12MB), gateway/WAF 는 보조 (20MB hard) | **메커니즘 SUPPORTED**: `raw/official-docs/spring-boot-multipart-reference.md#SB-MULTIPART-C3` (`MultipartProperties` 가 `spring.servlet.multipart` prefix 로 max size / 저장 위치 / disk flush threshold override 가능, `official-vendor-doc`), `raw/official-docs/spring-boot-multipart-reference.md#SB-MULTIPART-C4` (`max-file-size=-1` 로 unlimited 설정 가능, `official-vendor-doc`), `raw/official-docs/nginx-client-max-body-size.md#NGINX-CMB-C1` (`client_max_body_size size;` syntax, `official-vendor-doc`), `raw/official-docs/nginx-client-max-body-size.md#NGINX-CMB-C4` (초과 시 HTTP 413 응답, `official-vendor-doc`). **정량값 UNSUPPORTED_DECISION**: 12MB global / 20MB gateway 정량값은 ca-tmpl 자체 결정 — 외부 권고 부재 | `official-vendor-doc (mechanism only)` | 12MB / 20MB 정량값은 ca-tmpl 추론. nginx default 는 1MB (`NGINX-CMB-C2`) 임을 명시 — 20MB 는 의도적 override |
|
||||
| D5 | 3계층 분리 (gateway 20MB / global 12MB / Spring 10MB) 는 defense-in-depth | **SUPPORTED**: `raw/official-docs/owasp-file-upload-cheat-sheet.md#OWASP-FUP-C1` (extension allowlist 만으로는 불충분 → 다층 검증 필요, `official-reference`), `raw/official-docs/owasp-file-upload-cheat-sheet.md#OWASP-FUP-C2` (Content-Type 헤더 신뢰 불가 → server-side 검증 별도 필요, `official-reference`), `raw/official-docs/owasp-file-upload-cheat-sheet.md#OWASP-FUP-C3` (UUID/GUID 랜덤 파일명 essential, `official-reference`) | `official-reference` | OWASP cheatsheet 는 reference (표준 아님). 3계층 size 분리 자체는 size 검증의 defense-in-depth — OWASP 가 직접 '3-layer size limit' 권고하는 raw 인용은 없음, 다층 검증 원칙 일반화 |
|
||||
| D6 | temp file cleanup 3 trigger: try-with-resources + startup sweeper (>1h orphan) + JVM shutdown hook (backup) | **메커니즘 SUPPORTED**: `raw/official-docs/jdk-files-createtempfile.md#JDK-TEMPFILE-C6` (`DELETE_ON_CLOSE` 옵션으로 close 시 자동 삭제, `official-vendor-doc`), `raw/official-docs/jdk-files-createtempfile.md#JDK-TEMPFILE-C7` (shutdown-hook 또는 `File.deleteOnExit()` 로 자동 삭제 가능, `official-vendor-doc`). **정량값 UNSUPPORTED_DECISION**: 1h orphan threshold 는 ca-tmpl 자체 결정 — JDK doc 은 threshold 정량값 권고 없음 | `official-vendor-doc (mechanism only)` | 1h orphan threshold 외부 권고 부재. `JDK-TEMPFILE-C7` 의 `deleteOnExit()` 는 SIGKILL 등 abnormal termination 보장 없음 — startup sweeper 가 그 gap 메움 |
|
||||
| D7 | content-type allowlist starting set = image/jpeg, image/png, image/webp, application/pdf, text/csv, application/zip | **SUPPORTED**: `raw/official-docs/iana-media-types-registry.md#IANA-MEDIA-C1` (Media Types 의 assignment/listing 은 IANA 단일 registry, `official-standard`), `raw/official-docs/iana-media-types-registry.md#IANA-MEDIA-C5` (top-level types: `application`, `image`, `text`, ... — allowlist 6종이 모두 IANA top-level 내, `official-standard`), `raw/official-docs/owasp-file-upload-cheat-sheet.md#OWASP-FUP-C5` (webroot 밖 저장 + administrative access only, `official-reference`) | `official-standard + official-reference` | 6종 starting set 선정 자체는 ca-tmpl 도메인 결정 — IANA 는 registry 권위만, endpoint 별 권고 없음. 추가 endpoint registry 등록 정책으로 보완 |
|
||||
| D8 | streaming download = response timeout 60s + max stream 100MB, 초과 시 truncate + ERROR log | **메커니즘 SUPPORTED**: `raw/official-docs/spring-streaming-response-body.md#SPRING-STREAM-RB-C3` (`StreamingResponseBody` 의 명시된 use case = file download, `official-vendor-doc`), `raw/official-docs/spring-streaming-response-body.md#SPRING-STREAM-RB-C4` (`ResponseEntity` body 로 사용 가능 — status/header 커스터마이즈, `official-vendor-doc`). **정량값 UNSUPPORTED_DECISION**: 60s timeout / 100MB max stream / truncate 정책 모두 ca-tmpl 자체 결정 — Spring doc 은 정량값 권고 없음 | `official-vendor-doc (mechanism only)` | timeout 60s 는 Spring default 의존 (`SPRING-STREAM-RB-C8` — 컨테이너 의존) 과 다른 명시값. truncate 동작 자체는 Spring 가 보장하지 않음 — 자체 구현 필요 |
|
||||
| D9 | file storage abstraction = outbound, object store call 은 EXTERNAL_OUTBOUND_ALLOWED capability 요구 | UNSUPPORTED_DECISION — 내부 capability 모델 결정 | `internal-only` | capability 모델의 lint 필요 |
|
||||
| Path-traversal claim | filename 입력 검증 = normalized storage key only + opaque key (raw path passthrough 금지) — Decisionized Work Items 의 `path traversal` row 근거 | **SUPPORTED**: `raw/official-docs/owasp-path-traversal.md#OWASP-PT-C1` (path traversal = web root 밖 파일/디렉토리 접근 공격 정의, `official-reference`), `raw/official-docs/owasp-path-traversal.md#OWASP-PT-C2` (공격 벡터: `../` sequence + variation + absolute path, `official-reference`), `raw/official-docs/owasp-path-traversal.md#OWASP-PT-C3` (방어 원칙: "known good only" allowlist, sanitize 금지, `official-reference`) | `official-reference` | OWASP community wiki 는 reference (표준 아님). URL encoding (`OWASP-PT-C4`) / null byte (`OWASP-PT-C5`) variation 도 별도 검증 필요 — opaque key 정책이 모든 variation 차단 가정은 별도 contract test 필요 |
|
||||
| D10 | antivirus default scan position = gateway, in-app disabled, 활성화 시 별도 worker 분리 | `raw/company-tech-blogs/file-clamav-icap-gateway-scan.md#CLAMAV-ICAP-C2` (ICAP `official-standard`), `raw/company-tech-blogs/file-clamav-icap-gateway-scan.md#CLAMAV-ICAP-C3` (ICAP virus scan use case, `official-standard`), `raw/company-tech-blogs/file-clamav-icap-gateway-scan.md#CLAMAV-ICAP-C1` (ClamAV daemon model, `official-vendor-doc`) | `official-standard + official-vendor-doc` | ICAP 가 HTTPS E2E TLS 환경에서 적용 어려움 (raw 메모에 명시) — 본 결정의 제약. gateway 가 default 라는 정량 권고는 ca-tmpl 자체 추론 |
|
||||
| D11 | 대안 1 (Direct S3 presigned URL upload) — app via 3-layer 우회 가능하나 antivirus 위치 분리 필요 | `raw/official-docs/file-s3-presigned-url-upload.md#FS3-PRE-C1`, `raw/official-docs/file-s3-presigned-url-upload.md#FS3-PRE-C2`, `raw/official-docs/file-s3-presigned-url-upload.md#FS3-PRE-C4`, `raw/official-docs/file-s3-presigned-url-upload.md#FS3-PRE-C5` | `official-vendor-doc` | post-upload async scan + quarantine bucket 패턴 별도 설계 필요 (raw 메모 참조) |
|
||||
| D12 | 대안 2 (tus.io resumable upload) — 100MB+ 영상 적합하나 "1h orphan cleanup" 충돌 위험 | `raw/official-docs/file-tus-resumable-upload-protocol.md#TUS-RUP-C1`, `raw/official-docs/file-tus-resumable-upload-protocol.md#TUS-RUP-C3`, `raw/official-docs/file-tus-resumable-upload-protocol.md#TUS-RUP-C4`, `raw/official-docs/file-tus-resumable-upload-protocol.md#TUS-RUP-C5` | `official-standard` | tus session timeout 과 orphan threshold 분리 필요 — 채택 시 D6 의 1h threshold 수정 |
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| Spring 10MB + global 12MB + gateway 20MB 3계층이 ca-tmpl 트래픽 프로파일에 적합 | 정량값 외부 권고 부재 (D3/D4/D5) | k6 부하테스트로 413 응답 비율 + Spring multipart parser 동작 확인 | `planned` |
|
||||
| Temp file cleanup 3 trigger 가 모두 정확히 동작 (1h orphan 정확 식별) | JDK 공식 doc 인용 부재 (D6) | `TempFileCleanupContractTest` 로 정상/예외/timeout 3 경로 cleanup 확인 + startup sweeper orphan(>1h) 삭제 verify | `planned` |
|
||||
| Antivirus gateway 위치가 ca-tmpl 의 HTTPS termination 정책과 호환 | ICAP 가 HTTPS E2E TLS 환경에서 적용 어려움 (raw 메모) | gateway HTTPS termination 정책 확인 + ICAP server 통합 PoC | `needs-confirmation` |
|
||||
| ClamAV signature DB 갱신 주기 + 운영 책임 주체 (gateway team vs app team) | raw 에 명시 없음 (Usage Boundary 참조) | 운영 협약 문서 작성 + signature update cron 확인 | `needs-confirmation` |
|
||||
| Direct S3 대안 채택 시 EXTERNAL_OUTBOUND_ALLOWED capability 매핑 | raw `FS3-PRE-*` 는 S3 메커니즘만 보장, ca-tmpl 자체 capability 모델과의 매핑은 별도 | capability 모델 contract test + signing 호출 경로 추적 | `planned` |
|
||||
| tus 채택 시 session timeout 과 orphan threshold 가 정상 case 를 삭제하지 않음 | `TUS-RUP-C5` 는 max-size 만 정의, session lifetime 침묵 | tus session 정책 + ca-tmpl orphan threshold 분리 contract test | `planned` |
|
||||
| Content-type allowlist 6개 starting set 이 ca-tmpl 도메인 endpoint 별로 충분 | IANA registry 또는 endpoint 별 권고 부재 (D7) | endpoint별 use case 인터뷰 + allowlist 누락 endpoint inventory | `needs-confirmation` |
|
||||
| Streaming download 100MB / 60s timeout 이 적합 | 정량값 외부 권고 부재 (D8) | 실제 파일 크기 분포 측정 + truncate 발생률 확인 | `planned` |
|
||||
| Path traversal opaque key 정책이 모든 upload 경로 (legacy 포함) 적용 | raw 인용 부재 — OWASP 또는 Spring Security 공식 doc 권고 | `traversal test` + storage layer code review | `planned` |
|
||||
|
||||
## Decisionized Work Items
|
||||
|
||||
| item | Decision | Allowed | Forbidden | Required test |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| upload size | 10MB default | endpoint override with registry | unlimited upload | oversized upload test |
|
||||
| scanning | off by default, owner required if enabled | gateway/worker/app inline | "somewhere scans it" assumption | scan owner checklist |
|
||||
| path traversal | normalized storage key only | object-store opaque key | raw path passthrough | traversal test |
|
||||
| temp cleanup | bounded temp dir + cleanup on failure | streaming direct to storage | orphan temp files | cleanup test |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
현재 `documented-only` 단계이며 구현 위치·클래스·메커니즘 anchor는 아직 고정되지 않았다. 구현 결정은 기존 `## Decision Evidence Map / 결정-근거 매핑`의 D-row를 변경하지 않고 후속 구현 단계에서 연결한다.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**: 아래 `## 테스트 계약`의 oversized upload, temp cleanup, path traversal, streaming failure, antivirus 위치 검사를 따른다.
|
||||
- **다른 계약 의존**: API contract와 runtime lifecycle 양쪽 경계를 소비하며, object store 호출 capability는 D9가 정의한 outbound 경계를 따른다.
|
||||
|
||||
## 테스트 계약
|
||||
|
||||
- oversized upload가 generic 500으로 처리되면 실패.
|
||||
- temp cleanup contract: 다음 3 trigger가 모두 구현되어야 함: (a) success/failure on close (try-with-resources), (b) startup sweeper for orphaned files > 1h, (c) JVM shutdown hook backup. 측정 방법: contract test `TempFileCleanupContractTest`에서 `File.createTempFile` 후 정상/예외/timeout 3 경로 각각의 cleanup 확인. orphan(>1h not closed) file이 startup sweeper에 의해 삭제되는지 verify.
|
||||
- path traversal input이 storage path로 전달되면 실패.
|
||||
- download stream failure가 traceId 없이 로그되면 실패.
|
||||
- antivirus 위치 명시 강제: `APP_FILE_UPLOAD_ENABLED=true`이면 결정 사항에 antivirus 위치(`gateway` 또는 `worker` 또는 `off` 중 1개)가 명시되어 있어야 함. 측정 방법: branch note의 결정 사항 라인에서 `antivirus.position` token grep. 미명시 시 readiness fail. default는 `gateway` 권고.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/company-tech-blogs/file-clamav-icap-gateway-scan]] | ClamAV + RFC 3507 ICAP (ca-tmpl antivirus gateway default의 표준 근거 |
|
||||
| [[raw/official-docs/file-s3-presigned-url-upload]] | app/gateway 부담 0 vs antivirus 위치 분리 필요 + quarantine bucket |
|
||||
| [[raw/official-docs/file-tus-resumable-upload-protocol]] | 100MB+ 영상에 적합 vs ca-tmpl "1h orphan cleanup"이 session 잘못 삭제 가능 |
|
||||
| [[raw/official-docs/spring-boot-multipart-reference]] | D3 (upload size 메커니즘) + D4 (Spring multipart enforcement SSOT 메커니즘) — `official-vendor-doc` |
|
||||
| [[raw/official-docs/nginx-client-max-body-size]] | D4 (gateway/WAF 보조 layer 메커니즘 — `client_max_body_size` directive) — `official-vendor-doc` |
|
||||
| [[raw/official-docs/owasp-file-upload-cheat-sheet]] | D5 (3-layer defense-in-depth: extension/Content-Type/저장 위치 다층 검증) + D7 (webroot 밖 저장) — `official-reference` |
|
||||
| [[raw/official-docs/jdk-files-createtempfile]] | D6 (temp file cleanup 메커니즘: `DELETE_ON_CLOSE` + shutdown-hook + `deleteOnExit`) — `official-vendor-doc` |
|
||||
| [[raw/official-docs/iana-media-types-registry]] | D7 (content-type allowlist 6종이 IANA top-level types 내) — `official-standard` |
|
||||
| [[raw/official-docs/spring-streaming-response-body]] | D8 (streaming download 메커니즘: `StreamingResponseBody` + `ResponseEntity`) — `official-vendor-doc` |
|
||||
| [[raw/official-docs/owasp-path-traversal]] | Path-traversal claim (공격 정의 + 벡터 + "known good only" allowlist 방어 원칙) — `official-reference` |
|
||||
|
||||
## 외부 근거 / 대안 조사 (2026-05-22 — Group G-J: File / Resource Handling)
|
||||
|
||||
본 branch의 Spring 10MB + global 12MB + gateway 20MB 3-layer + content-type allowlist 6종 + temp orphan 1h cleanup + antivirus gateway default + path traversal opaque key 결정에 대한 외부 source.
|
||||
|
||||
- **채택 결정 (app via 3-layer + gateway antivirus + ClamAV/ICAP)**:
|
||||
- [[raw/company-tech-blogs/file-clamav-icap-gateway-scan]] — ClamAV + RFC 3507 ICAP (ca-tmpl antivirus gateway default의 표준 근거)
|
||||
- **검토한 대안**:
|
||||
- **대안 1: Direct S3 presigned URL upload (app via 우회)** — [[raw/official-docs/file-s3-presigned-url-upload]] (app/gateway 부담 0 vs antivirus 위치 분리 필요 + quarantine bucket)
|
||||
- **대안 2: tus resumable upload protocol** — [[raw/official-docs/file-tus-resumable-upload-protocol]] (100MB+ 영상에 적합 vs ca-tmpl "1h orphan cleanup"이 session 잘못 삭제 가능)
|
||||
- **대안 3: in-app ClamAV daemon scan** — `file-clamav-icap-gateway-scan` 동일 source 안에서 in-app/gateway/async 3종 비교 (in-app은 app instance에 daemon dependency)
|
||||
- **대안 4: Post-upload async scan (S3 + Lambda ClamAV)** — 동일 source (app/gateway 부담 0 vs scan 완료 전 객체 존재 → quarantine bucket 분리 필요)
|
||||
- **비교 핵심**: ca-tmpl "gateway default" 선택은 app instance scaling과 무관한 일정 throughput + in-app daemon dependency 회피. HTTPS E2E TLS 환경에서는 ICAP 적용 어려움 — 그 경우 post-upload async가 대안. tus 채택 시 ca-tmpl "1h orphan cleanup"은 session 정상 case도 삭제할 위험 — session ↔ orphan threshold 분리 보강 필요.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-frontend-operational-contract/branch-notes/feature-frontend-architecture-enforcement-lint-contract.md
|
||||
@@ -0,0 +1,312 @@
|
||||
---
|
||||
title: branch / feature-frontend-architecture-enforcement-lint-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-003
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-frontend-operational-contract
|
||||
work_item: WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-003
|
||||
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-ARCHITECTURE-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-002, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-017]
|
||||
contract_packet: 1
|
||||
branch: feature-frontend-architecture-enforcement-lint-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton-frontend, ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-frontend-operational-contract.md]
|
||||
tags: [branch, ca-skeleton, frontend, architecture, testing, javascript, static-analysis]
|
||||
created: 2026-07-18
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 12187592363c519ab92cd3b73e1e4b135b2515f0421bd4c671ca45c7e30b2340
|
||||
imports: [FE-GATE-013@1, FE-OC-002@1, FE-OC-014@1, FE-OC-019@1, FE-OC-020@1]
|
||||
delegates: [DELEG-FE-002@1, DELEG-FE-003@1]
|
||||
|
||||
---
|
||||
|
||||
# branch: feature-frontend-architecture-enforcement-lint-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 TODO·결정·진행 기록. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출한다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-frontend-operational-contract]]
|
||||
|
||||
형제 branch (같은 부모, 본 branch 가 의존/위임하는 대상):
|
||||
|
||||
- [[raw/branch-notes/feature-frontend-clean-architecture-layering-contract]] — 본 branch 가 *기계화*할 allowed-import matrix 의 정의 owner (`FE-OC-002`)
|
||||
- [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] — gate 등록·artifact 보존·"실패→warning 금지" 정책 owner (`FE-OC-020`)
|
||||
- [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] — ESLint·dependency-cruiser 의 *설치* + base flat-config substrate owner (`FE-OC-003`). 본 branch 의 D1 은 `eslint.config.js`·`.dependency-cruiser.cjs` 가 이미 존재함을 전제하고 거기에 **규칙만 추가**한다.
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: allowed fixture는 통과하고 forbidden fixture는 실패하며 lint report가 생성된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-ARCHITECTURE-001@1` | domain, application, presentation, adapters, bootstrap 책임을 분리한다 | allowed-import matrix의 lint·dependency graph 규칙에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
이 branch 는 `FE-OC-002`(의존 방향 `domain <- application <- presentation` 과 application-owned output port 를 MUST 유지)와 `FE-OC-020`(gate 별 책임·fixture·artifact 분리, 실패를 warning 으로 낮추지 않음)를 **구현 착수 가능한 강제(enforcement) 명세로 내리는** 브랜치다. 본 branch 는 자체 소유 contract 가 없다(§20 `Primary contract IDs = —`) — 대신 hub §4.3 dependency matrix 를 기계 검증 가능하게 만드는 **architecture gate (`FE-GATE-010`)** 을 build 한다: dependency-cruiser 그래프 규칙 + ESLint restricted-import 규칙 + allowed/forbidden fixture + `artifacts/quality/` 로의 dependency report 산출. 즉 layering branch 가 *정의*한 경계를 이 branch 가 *자동으로 집행*하고, test-taxonomy/CI branch 가 소비할 evidence artifact 를 emit 한다. 현재 frontend 코드는 존재하지 않으므로 아래 모든 구현 주장은 등급 `planned` 이다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- dependency-cruiser 설정 — hub §4.3 dependency matrix 를 그래프 reachability 규칙으로 encoding (transitive/indirect 위반 포착) — 등급: `planned`
|
||||
- ESLint flat-config restricted-import 규칙 — 동일 matrix 를 import-statement(module) 레벨로 encoding — 등급: `planned`
|
||||
- allowed + forbidden fixture set — `presentation → adapters/http`, 직접 TanStack Query client import, `application → adapter 구체`, `domain → React/browser global` 등 — 등급: `planned`
|
||||
- **`test fixtures` 행(hub §4.3 row 6)의 import 경계 규칙 + 짝 fixture** — `tests/**` 는 public contract + 명시 test helper 만 import 가능, production secret 모듈·real telemetry endpoint 설정 import 는 fail (D6; 지금까지 owner 미지정이던 행) — 등급: `planned`
|
||||
- dependency/enforcement **report artifact** 를 `artifacts/quality/` 로 emit + 위반 시 non-zero exit(warning 강등 금지) — 등급: `planned`
|
||||
- gate pass 조건: allowed fixture pass · forbidden fixture fail · report emitted (`FE-GATE-010` — §20 Measurable completion) — 등급: `planned`
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외 — 다른 owner branch 가 소유. 여기서 detail 을 정하지 않고 그 branch 를 가리킨다(CLAUDE.md §15.5 R3, `OUT_OF_BRANCH_SCOPE`).
|
||||
|
||||
- **allowed-import matrix 의 *정의* 자체 + layer/port 책임 분해** → [[raw/branch-notes/feature-frontend-clean-architecture-layering-contract]] 소유 (`FE-OC-002`). 정의 근거 결정은 [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §3.2 `FE-D009`~`FE-D011`. 본 branch 는 그 matrix 를 *기계화*할 뿐 정의하지 않는다.
|
||||
- **gate 정의(blocking scope·pass condition·evidence artifact)** → hub §15.1 소유, gate 별 Owner 는 hub §2.1.1. **test level 슬롯 · artifact 보존 정책 · "실패→warning 금지" 정책** → [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] 소유 (`FE-OC-020`). **CI wiring** → [[raw/branch-notes/feature-frontend-ci-quality-gates-contract]] 가 orchestration.
|
||||
- **forbidden-API(browser-global) lint** (`window`/`localStorage`/`fetch` 직접 사용 금지 — cross-layer import 금지와 별개) → [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] 소유 (`FE-OC-019` 가 `FE-GATE-002` lint 의 forbidden-API 부분). 본 branch 는 forbidden-**import**/layer 부분만.
|
||||
- **checkJs/type 강제** (`FE-GATE-003`) → [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] 소유 (`FE-OC-003`).
|
||||
- **ESLint / dependency-cruiser 의 *설치* 와 base flat-config substrate** (`eslint.config.js`·`.dependency-cruiser.cjs` 파일 자체의 존재·engine·script wiring) → [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] 소유 (`FE-OC-003`). 본 branch 는 그 config 에 **규칙을 추가**할 뿐 toolchain 을 세우지 않는다.
|
||||
- **QueryCachePort 설계** (`FE-D006`) → [[raw/branch-notes/feature-server-state-caching-contract]]. 본 branch 는 TanStack import 경계만 강제.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | §4.3 dependency matrix + §15.1 `FE-GATE-010` + §15.2 negative fixture — 본 branch 강제 명세의 SSOT (D1·D2·D3·D5) |
|
||||
| [[raw/project-notes/ca-skeleton-operational-contract]] | backend Clean-Architecture 운영 계약 철학(왜 port 를 application 이 소유하고 adapter 가 구현하는가, 왜 layer 를 강제하는가) — `FE-D009` 의 rationale, D2 가 기계화하는 대상 |
|
||||
| [[raw/official-docs/tanstack-query-server-state-official]] | `TSQ-C1`(server-state 전용 라이브러리로 자기 정의) — D4 의 "직접 TanStack import 금지" fixture 근거(TanStack 은 `QueryCachePort` 뒤에 격리) |
|
||||
| [[raw/official-docs/vite-build-tool-official]] | `VITE-C1`(native ES modules 위에서 동작) — dependency-cruiser·ESLint 가 분석하는 ESM import 그래프의 substrate(`FE-D002` JS ESM / `FE-D003` Vite baseline) — D1 도구 적용 가능성의 맥락 근거 |
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] dependency-cruiser 설정으로 §4.3 matrix + forbidden-edge 규칙 encoding — 등급: `planned`
|
||||
- [ ] ESLint flat-config restricted-import 규칙을 matrix 와 1:1 mirror — 등급: `planned`
|
||||
- [ ] allowed + forbidden fixture suite 작성 (`presentation→adapters/http`, 직접 TanStack import, `application→adapter 구체`, `domain→React`) — 등급: `planned`
|
||||
- [ ] dependency/enforcement report 를 `artifacts/quality/` 로 emit + 위반 시 non-zero exit wiring — 등급: `planned`
|
||||
- [ ] `test fixtures` 행(D6) 규칙 encoding + allowed/forbidden fixture 쌍 작성 — 등급: `planned`
|
||||
- [ ] `FE-OC-019`(production secret 목록)·`FE-OC-014`(real telemetry endpoint 목록) owner 에게 금지 대상 모듈 목록 발행 요청 — 미발행 동안 D6 fixture 는 placeholder — 등급: `planned`
|
||||
- [ ] 규칙 catalog 를 layering branch 의 allowed-import matrix 와 cross-check(drift 방지) — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
없음 — `/branch-spec` 채움 단계. 모든 항목 `planned`(frontend repo 미생성).
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 각 결정의 근거·대안은 아래 Decision Evidence Map 과 1:1. 여기 prose 는 그 요약이다.
|
||||
|
||||
- 2026-07-18: **이중 도구 강제(dependency-cruiser 그래프 + ESLint restricted-import), 둘 다 merge-blocking** / 이유: import-statement 레벨(빠름·에디터 내)과 whole-graph reachability(transitive/barrel re-export 포착)를 함께 커버 / 대안: 단일 도구 / 근거: hub §4.3 "Planned enforcement" 열이 두 도구를 명시, `FE-OC-002`. (D1)
|
||||
- 2026-07-18: **§4.3 dependency matrix 를 규칙의 single source-of-truth 로 강제** (`domain ← application ← presentation`; adapter 는 application port 구현; bootstrap 만 composition root) / 이유: `FE-OC-002` owner 가 정의한 경계를 코드로 집행 / 대안: N/A(matrix 는 layering branch 소유) / 근거: hub §4.3 + §3.2 결정. (D2)
|
||||
- 2026-07-18: **forbidden fixture 는 반드시 fail, allowed fixture 는 반드시 pass — 실행된 실패 fixture 없는 규칙은 증거 불충분** / 이유: gate 가 실제로 동작함을 증명하려면 deliberately failing fixture 필요 / 대안: rule 존재만 확인 / 근거: hub §15.2 + §15.1 `FE-GATE-010` pass 조건. (D3)
|
||||
- 2026-07-18: **"직접 TanStack Query client import" forbidden fixture — `adapters/query-cache` 만 TanStack import 허용, presentation/application 직접 import 은 fail** / 이유: `QueryCachePort`(application-owned) 뒤로 TanStack 격리 / 대안: 전역 허용 / 근거: hub §15.1 `FE-GATE-010`("including direct TanStack client import") + §3.2 결정 + `TSQ-C1`. (D4)
|
||||
- 2026-07-18: **machine-readable dependency/enforcement report 를 `artifacts/quality/` 로 emit, 위반은 warning 으로 강등 금지** / 이유: gate 가 "실행됐다" 인정받으려면 evidence artifact 필요 / 대안: 콘솔 출력만 / 근거: hub §15.1 `FE-GATE-010` evidence artifact + §4.6 blueprint + `FE-OC-020`. (D5)
|
||||
- 2026-07-20: **hub §4.3 `test fixtures` 행(6번째)의 import 경계 규칙 + 짝 fixture 를 본 branch 가 소유** — `tests/**` 는 public contract + 명시 test helper 만 import 가능, production secret 모듈·real telemetry endpoint 설정 import 는 fail / 이유: §4.3 matrix 의 한 행이고 그 matrix 기계화가 본 branch 책임(`FE-GATE-010`)인데 지금까지 어떤 branch 도 owner 로 잡지 않아 owner-less 였음 / 대안: browser-security(`FE-OC-019`) 또는 observability(`FE-OC-014`)에 전부 위임 — 그러나 두 branch 는 *무엇이 secret/endpoint 인가* 를 정의할 뿐 import 그래프 규칙을 집행하지 않으므로 부적합 / 근거: hub §4.3 row 6 (`test config guard`) + `FE-OC-002`. (D6)
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정과 raw source Claim ID 의 연결. `Decision ID` 는 이 branch-note 안에서 안정적으로 유지한다.
|
||||
> `Supporting Claims`: hub 결정(project decision)은 `[[hub]] §·FE-D` 로, 외부 스펙은 `raw/official-docs/<slug>.md#<CLAIM>` 로 가리킨다. (`FE-D*` 는 hub §3.2 소유 — 본 branch 는 그 결정을 *기계화*한다.)
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | 이중 도구 강제: dependency-cruiser(그래프 reachability) + ESLint restricted-import(module 레벨), 둘 다 merge-blocking (`FE-GATE-010`/`FE-GATE-002` → `FE-OC-002`) | **이 결정:** 경계를 import-statement 레벨 *과* whole-graph 레벨 *양쪽*에서 강제해야 할 때(transitive/indirect 위반은 ESLint 단독으로 못 잡음). **대안(단일 도구):** 한 도구가 완전히 redundant 임이 fixture 로 증명될 때 → revisit | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §4.3 "Planned enforcement" 열 + §15.1 `FE-GATE-010`·`FE-GATE-002`; `raw/official-docs/vite-build-tool-official.md#VITE-C1` (ESM 그래프 substrate) | `project-decision` (+contextual official-doc) | hub 는 *도구* 만 명시, 정확한 rule config 는 미명시 → 규칙 상세는 `UNSUPPORTED_IMPL_DECISION` |
|
||||
| D2 | §4.3 dependency matrix 를 규칙의 SSOT 로 강제 (`domain ← application ← presentation`; adapter 는 application port 구현; bootstrap 만 composition root) | **N/A** — matrix 는 `FE-OC-002` owner(layering branch)가 고정. 본 branch 는 기계화만. layer taxonomy 가 바뀌면(FSD fork 승인) 규칙 재생성 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §4.3 dependency matrix + §4.2 responsibility + §3.2 `FE-D009`·`FE-D010`·`FE-D011`; 철학 근거 [[raw/project-notes/ca-skeleton-operational-contract]] | `project-decision` (delegated from layering branch) | layering branch 의 concrete allowed-import matrix 발행에 의존 — 그것이 바뀌면 규칙 drift (§엣지·의존 참조) |
|
||||
| D3 | forbidden fixture 는 MUST fail, allowed fixture 는 MUST pass — 실행된 실패 fixture 없는 규칙은 증거 불충분 | **N/A(invariant)** — canonical negative fixture = `presentation` imports `adapters/http` (§15.2). rule 존재만 확인한 결과는 `locally-verified` 증거로 불충분 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §15.2 ("최소 하나의 deliberately failing fixture 필요") + §15.1 `FE-GATE-010` pass 조건("allowed passes, forbidden fails") + §20 Measurable completion | `project-decision` (hub §15.1/§15.2) | fixture set 이 rule set 과 동기 유지돼야 함 — 짝 fixture 없이 rule 추가 시 gate 조용히 degrade |
|
||||
| D4 | "직접 TanStack Query client import" forbidden fixture: `adapters/query-cache` 만 import 허용, presentation/application 직접 import 은 fail | **이 결정:** `QueryCachePort` 뒤에 TanStack 을 격리하는 동안 유지. **대안:** 그 경계 결정 변경(offline-first normalized cache) 시 재검토 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §15.1 `FE-GATE-010` ("including direct TanStack client import") + §3.2 `FE-D006`; `raw/official-docs/tanstack-query-server-state-official.md#TSQ-C1` | `official-doc` (`TSQ-C1`) + `project-decision` (`FE-D006`) | 금지할 정확한 import specifier(`@tanstack/react-query`)는 hub 미명시 → `UNSUPPORTED_IMPL_DECISION` |
|
||||
| D5 | machine-readable dependency/enforcement report 를 `artifacts/quality/` 로 emit, 위반은 warning 강등 금지, blocking scope=merge | **N/A** — artifact 없으면 gate 가 "실행됨" 으로 인정 안 됨. report format/보존은 test-taxonomy branch(`FE-OC-020`)에 위임 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §15.1 `FE-GATE-010` evidence artifact("dependency report") + §4.6 blueprint(`artifacts/quality/`) + §2.1 `FE-OC-020` ("실패를 warning 으로 낮추면 안 됨") | `project-decision` (hub §15.1 + `FE-OC-020`) | 정확한 report filename/format 은 `UNSUPPORTED_IMPL_DECISION`; 보존 정책은 test-taxonomy/CI branch 소유 |
|
||||
| D6 | hub §4.3 `test fixtures` 행의 import 경계 규칙 + 짝 fixture 를 본 branch 가 소유: `tests/**` 는 public contract + 명시 test helper 만 import 가능, production secret 모듈·real telemetry endpoint 설정 import 는 MUST fail (`test config guard` → `FE-GATE-010`) | **이 결정:** §4.3 matrix 의 행이고 집행 수단이 import 그래프 규칙인 동안(= 정적 분석으로 판정 가능한 동안) 본 branch 소유. **대안(위임):** 집행이 런타임 값 검사나 secret scanning 으로 바뀌면 `FE-GATE-013` security gate 소유로 이관 → revisit | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §4.3 dependency matrix row 6(`test fixtures` \| allowed: public contracts and explicit test helpers \| forbidden: production secret, real telemetry endpoint \| enforcement: `test config guard`) + §15.1 `FE-GATE-010`(forbidden import fixtures) + §2.1 `FE-OC-002` | `project-decision` (hub §4.3 row 6) | *무엇이* production secret / real telemetry endpoint 인가의 목록은 `FE-OC-019`·`FE-OC-014` owner 미발행 → 발행 전까지 fixture 대상 모듈이 placeholder. 식별 메커니즘(경로 기반)은 `UNSUPPORTED_IMPL_DECISION` |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 전부 `planned` blueprint — frontend repo 미생성. 경로는 hub §4.6 Planned directory blueprint + §5.1 registry owner map 에서 유래(grounded)하나 코드는 없다. 3-rule(R1 Trace / R2 UNSUPPORTED_IMPL_DECISION / R3 no OUT_OF_BRANCH_SCOPE) 준수.
|
||||
|
||||
### 1. 강제 도구 wiring (dependency-cruiser + ESLint)
|
||||
|
||||
> **Trace**: D1 (hub §4.3 "Planned enforcement", `FE-OC-002`) + D2.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**:
|
||||
> - dependency-cruiser 설정 파일명/형식(`.dependency-cruiser.cjs` 가정) — hub 는 *도구* 만 명시, 파일명 미권고. Trade-off: `.cjs` 는 dependency-cruiser `--init` 의 문서화된 기본 출력.
|
||||
> - ESLint 규칙 선택(`import/no-restricted-paths`(eslint-plugin-import) vs 빌트인 `no-restricted-imports`) — hub 미권고. Trade-off: `import/no-restricted-paths` 가 zone→zone 금지를 직접 표현해 matrix 대응이 명확; `no-restricted-imports` 는 빌트인이나 pattern 기반. 둘 다 동일 matrix 를 encoding — 최종 선택은 first-impl 로 유예.
|
||||
|
||||
| 도구 | 역할(무엇을 잡나) | planned 위치 | 근거 |
|
||||
|---|---|---|---|
|
||||
| dependency-cruiser | whole-graph reachability — transitive/indirect/barrel re-export 를 통한 layer 위반 | `.dependency-cruiser.cjs` (repo root) | hub §4.3 "Planned enforcement" 열 |
|
||||
| ESLint (flat config) | import-statement 레벨 즉시 위반 + 에디터 피드백 | `eslint.config.js` restricted-import 블록 | hub §4.3; `FE-GATE-002` lint |
|
||||
|
||||
### 2. Layer boundary 규칙 catalog (matrix 의 기계화)
|
||||
|
||||
> **Trace**: D2 (hub §4.3 dependency matrix; §3.2 결정 `FE-D009`·`FE-D010`·`FE-D011`; `FE-OC-002`) + D6 (hub §4.3 `test fixtures` 행).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: glob 경로 패턴(`src/domain/**` 등)의 정확한 문법 — §4.6 blueprint 는 디렉토리 *이름* 만 주고 glob 은 미명시. Trade-off: blueprint 디렉토리명을 그대로 `src/<layer>/**` glob 으로 승격(가장 단순한 1:1 매핑).
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `test fixtures` 행의 glob(`tests/**`) — §4.6 blueprint 는 `tests/{unit,component,integration,e2e}` 만 주고 fixture glob 을 미명시. Trade-off: blueprint 의 `tests/` 루트를 그대로 승격해 4개 레벨을 한 번에 덮음(레벨별 분기 없이 가장 단순).
|
||||
|
||||
**집행 유형** 열은 hub §4.3 `Planned enforcement` 열의 각 항목이 *자동 규칙*(gate 가 exit code 로 판정)인지 *수동/자동화 밖*(사람 리뷰)인지 구분한다 — hub 는 두 종류를 한 열에 섞어 적고 구분하지 않으므로, `FE-GATE-010` 의 forbidden-fixture 범위가 어디까지인지 여기서 명시한다.
|
||||
|
||||
| From (source) | MUST NOT import (금지 대상) | 집행 도구(§4.3) | 집행 유형 | `FE-GATE-010` fixture 범위 | planned glob |
|
||||
|---|---|---|---|---|---|
|
||||
| `domain` | application, presentation, adapters, bootstrap, React, browser globals | dependency-cruiser + ESLint restricted imports | **자동 규칙** | 포함 | `src/domain/**` |
|
||||
| `application` | presentation, adapters 구체, bootstrap, React, `window`/`localStorage`/`fetch` | architecture fixture | **자동 규칙** | 포함 | `src/application/**` |
|
||||
| `presentation` | adapters, raw DTO schema, registry storage 구현 | restricted import rule | **자동 규칙** | 포함 | `src/presentation/**` |
|
||||
| `adapters/*` | presentation, bootstrap internals, 다른 adapter 구체 구현 | dependency graph snapshot | **자동 규칙** | 포함 | `src/adapters/**` |
|
||||
| `bootstrap` | page-specific business rule | composition-root review | **수동 / 자동화 밖** | **제외** (아래 주석) | `src/bootstrap/**` |
|
||||
| `test fixtures` | production secret, real telemetry endpoint (허용: public contract + 명시 test helper) | test config guard | **자동 규칙** (import 경계 부분만) | 포함 (D6) | `tests/**` |
|
||||
|
||||
> **`bootstrap` 행이 `FE-GATE-010` forbidden-fixture 범위 밖인 이유**: hub §4.3 이 이 행에만 `composition-root review`(사람 리뷰)를 배정했고, 금지 대상이 "page-specific business rule" 이라는 *의미론적* 판정이라 import specifier 로 표현되지 않는다 — 어떤 모듈을 import 했는가가 아니라 그 모듈 안에 무엇을 썼는가의 문제다. 따라서 짝 forbidden fixture 를 만들 수 없고, D3 의 "모든 규칙은 짝 fixture 필요" 불변식은 이 행에 적용되지 않는다. `FE-GATE-010` pass 조건은 나머지 5개 행으로만 판정한다. **UNSUPPORTED_IMPL_DECISION**: bootstrap 행을 자동 gate 에서 제외한 이 판단 자체 — hub 는 "composition-root review" 라고만 적고 gate 범위 포함/제외를 명시하지 않는다. Trade-off: 기계 판정 불가한 행을 gate 에 넣으면 gate 가 항상 vacuous pass 가 되어 D3 증거 기준이 무의미해지므로, 명시적으로 제외하고 수동 리뷰 항목으로 남긴다. bootstrap 의 business-rule 혼입은 코드 리뷰 체크리스트로 다루며, 그 체크리스트 소유는 [[raw/branch-notes/feature-frontend-clean-architecture-layering-contract]] (`FE-OC-002`).
|
||||
|
||||
> `application → adapter 구체` 는 `MUST NOT`; output port 정의는 `application` 이 `MUST` 소유; adapter 는 application 이름을 알면 안 됨 (hub §4.3 normative summary — D2).
|
||||
> `application` 의 browser-global 직접 사용(`window`/`localStorage`/`fetch`) 금지 중 **browser-API 표면 자체의 금지 규칙 카탈로그**는 `FE-OC-019` 소유 → 여기선 layer-cross import 관점만, API 표면 detail 은 [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] 로 위임(R3).
|
||||
>
|
||||
> **`test fixtures` 행 (D6) — 본 branch 가 소유**: hub §4.3 의 6번째 행은 지금까지 어떤 branch 도 owner 로 잡지 않았다. 이 행은 §4.3 dependency matrix 의 일부이고 그 matrix 의 *기계화* 가 본 branch 의 정의된 책임(`FE-GATE-010`)이므로, **test 코드에서의 import 경계 규칙 + 짝 fixture 는 본 branch 가 소유**한다. 규칙: `tests/**` 는 public contract(`src/contracts/**`)와 명시 test helper 만 import 할 수 있고, production secret 모듈과 real telemetry endpoint 설정은 import 할 수 없다. 즉 다른 layer 행과 동일한 종류의 forbidden-import 규칙으로 encoding 되며 `FE-GATE-010` 의 allowed/forbidden fixture 쌍을 갖는다.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: "production secret" 을 test config 에서 *어떻게 식별* 하는가(모듈 경로 기반 vs 환경변수 이름 패턴 vs secret registry 조회) — hub §4.3 은 금지 *대상* 만 적고 식별 메커니즘을 권고하지 않는다. Trade-off: 본 branch 는 정적 import 그래프만 볼 수 있으므로 **모듈 경로 기반**(secret 을 노출하는 모듈로 향하는 import edge 금지)으로 좁힌다 — 런타임 값 검사는 정적 분석 밖이고 `FE-GATE-013` security scan 영역이다.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: rule id / 규칙 이름 — hub 미명시. Trade-off: §2 의 다른 5개 행과 같은 rule 계열(zone→zone 금지)로 표현해 catalog 일관성을 유지하고, 별도 rule 계열을 만들지 않는다.
|
||||
> - **위임(reference-only)**: *무엇이* production secret 인가의 정의(어떤 값·어떤 모듈이 secret 인가)는 [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] (`FE-OC-019`, browser bundle 에 secret 금지) 소유이고, *무엇이* real telemetry endpoint 인가는 [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] (`FE-OC-014`) 소유다. 본 branch 는 그 두 owner 가 발행하는 목록을 **입력으로 받아 import 금지 규칙으로 encoding** 할 뿐 정의하지 않는다(R3). 두 목록 중 하나라도 미발행이면 본 행의 fixture 는 placeholder 대상 모듈로만 검증되고 상태는 `needs-confirmation` 이다.
|
||||
|
||||
### 3. Fixture set (allowed + forbidden)
|
||||
|
||||
> **Trace**: D3 (hub §15.2 + §15.1 `FE-GATE-010`) + D4 (hub §15.1 + `FE-D006` + `TSQ-C1`) + D6 (hub §4.3 `test fixtures` 행).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: fixture 파일 배치(`tests/architecture/fixtures/…` 가정) — hub §4.6 는 `tests/` 하위 레벨(unit/component/integration/e2e)만 주고 architecture-fixture subfolder 미명시. Trade-off: `tests/` 아래 전용 architecture 서브트리로 colocate(다른 gate fixture 와 동일 관례).
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 금지할 TanStack import specifier(`@tanstack/react-query`) — hub 는 "direct TanStack client import" 라고만 표현, 패키지명 미명시. Trade-off: TanStack Query 의 표준 React 엔트리 패키지명을 사용, 확정은 first-impl.
|
||||
|
||||
| Fixture | 종류 | 기대 결과 | 근거 |
|
||||
|---|---|---|---|
|
||||
| `presentation` imports `adapters/http` | forbidden | MUST fail | hub §15.2 canonical negative fixture |
|
||||
| presentation/application imports `@tanstack/react-query` 직접 | forbidden | MUST fail | hub §15.1 `FE-GATE-010`; `FE-D006`; `TSQ-C1` |
|
||||
| `application` imports adapter 구체 | forbidden | MUST fail | hub §4.3 normative summary |
|
||||
| `domain` imports React/browser global | forbidden | MUST fail | hub §4.2/§4.3 |
|
||||
| `presentation` imports application facade | allowed | MUST pass | hub §4.3 (presentation → application facade) |
|
||||
| `adapters/query-cache` imports `@tanstack/react-query` | allowed | MUST pass | hub §4.2 (`adapters/query-cache` consumes TanStack Query) |
|
||||
| test fixture imports public contract + 명시 test helper | allowed | MUST pass (false-positive 방지) | hub §4.3 `test fixtures` 행 (D6) |
|
||||
| test helper imports production secret 모듈 | forbidden | MUST fail | hub §4.3 `test fixtures` 행 forbidden 열 (D6); secret 목록 소유 `FE-OC-019` |
|
||||
| test helper imports real telemetry endpoint 설정 | forbidden | MUST fail | hub §4.3 `test fixtures` 행 forbidden 열 (D6); endpoint 목록 소유 `FE-OC-014` |
|
||||
|
||||
> gate 를 CI 에 배선하고 artifact 를 보존하는 workflow(YAML/retention)는 본 branch 범위 밖 → [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] + [[raw/branch-notes/feature-frontend-ci-quality-gates-contract]] (R3).
|
||||
|
||||
### 4. Report artifact 산출 + 위반 시 exit 정책
|
||||
|
||||
> **Trace**: D5 (hub §15.1 `FE-GATE-010` evidence "dependency report" + §4.6 `artifacts/quality/` + `FE-OC-020`).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: report 파일명/형식(`json` vs `html`/`dot`) — hub 미명시. Trade-off: gate 파싱용 machine-readable(`json`) 을 primary 로, 선택적 `dot`/`svg` 를 human review 용으로 병행.
|
||||
|
||||
- dependency-cruiser 가 그래프 report 를 `artifacts/quality/` 로 emit(§4.6 blueprint).
|
||||
- forbidden fixture 가 pass 하거나 allowed fixture 가 fail 하면 **non-zero exit** → hub §15.1 `FE-GATE-010@1` 의 pass 조건에 매핑(조건 원문은 §15.1 소유). warning 강등 금지(`FE-OC-020`).
|
||||
- report 형식/보존 기간의 최종 계약은 test-taxonomy branch(`FE-OC-020`)에 위임(R3).
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- *Rule false-negative (transitive/barrel):* `presentation → shared/index.js → adapters/http` 처럼 barrel re-export 로 우회하면 ESLint 단독은 놓칠 수 있음 → dependency-cruiser 그래프가 잡아야 함(이것이 D1 이중 도구의 이유). 검증 필요.
|
||||
- *Rule false-positive:* test helper / shared UI primitive 가 layer 를 가로질러 import 하는 정당 케이스 → §4.3 `test fixtures` 행(public contract + 명시 helper 허용)으로 scope-out 필요. over-match 시 정상 코드 block. 이 행의 allowed/forbidden 규칙은 D6 으로 본 branch 가 소유한다.
|
||||
- *정적 분석 한계:* `import()` 동적 import 로 우회하면 두 도구 모두 정적 그래프에서 못 볼 수 있음 → 잔여 위험으로 기록, `needs-confirmation`.
|
||||
- *규칙-fixture 비동기:* rule 추가 시 짝 forbidden fixture 미추가 → gate 가 조용히 약화(D3 Open Risk).
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-frontend-clean-architecture-layering-contract]] 의 allowed-import matrix(`FE-OC-002`)에 의존 — 그 matrix 가 본 branch 규칙의 입력. 바뀌면 규칙 재생성(D2).
|
||||
- hub §15.1·§2.1.1 의 `FE-GATE-010@1` 정의(Owner = 본 branch)와 [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] 의 test level 슬롯 + artifact 보존 + "실패→warning 금지" 정책(`FE-OC-020`)에 의존 — report 소비처.
|
||||
- [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] 의 toolchain 설치 + base flat-config(`FE-OC-003`)에 의존 — D1 은 `eslint.config.js`·`.dependency-cruiser.cjs` 와 그 실행 script 가 *이미 존재*함을 전제하고 규칙만 추가한다. 그 branch 가 lint runner/flat-config 형식(또는 package manager script 이름)을 바꾸면 본 branch 의 규칙 블록 배치·실행 진입점이 함께 바뀐다.
|
||||
- [[raw/branch-notes/feature-server-state-caching-contract]] (QueryCachePort 경계 owner)에 의존 — hub [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §3.2 `FE-D006` 이 D4 TanStack import 금지 fixture 의 근거. 그 결정 변경 시 fixture 재정의.
|
||||
- hub §4.6 Planned directory blueprint 에 의존 — glob 경로가 디렉토리 layout 을 전제. layout 변경 시 glob 갱신(`FE-D009` 변경 절차).
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| dependency-cruiser + ESLint 가 §4.3 모든 forbidden edge 를 함께 포착 | 도구별 blind spot(동적 import, barrel re-export) | forbidden fixture(직접+transitive+dynamic-import 케이스) 실행 → 각각 fail 확인 (`FE-GATE-010` "forbidden fails") | `needs-confirmation` |
|
||||
| allowed fixture 가 false-positive 0 으로 pass | 규칙이 test helper/shared primitive 를 over-match 할 수 있음 | allowed fixture(presentation→facade, adapter→TanStack, test-helper cross-import) 실행 → pass 확인 | `needs-confirmation` |
|
||||
| 직접 TanStack import 금지가 presentation/application 에서만 발화, `adapters/query-cache` 는 예외 | 패키지명 기반 금지는 mis-scope 위험 | forbidden: presentation imports `@tanstack/react-query` → fail; allowed: `adapters/query-cache` import → pass | `needs-confirmation` |
|
||||
| report artifact 가 `artifacts/quality/` 로 emit 되고 위반 시 gate 가 fail(warning 강등 없음) | artifact wiring + CI exit code 미검증 | seeded 위반으로 gate 실행 → non-zero exit + report 파일 존재 확인 | `needs-confirmation` |
|
||||
| `tests/**` 가 production secret 모듈·real telemetry endpoint 설정을 import 하면 gate 가 fail (D6) | 금지 대상 모듈 목록이 `FE-OC-019`·`FE-OC-014` owner 미발행 상태 — 현재는 placeholder 경로로만 규칙 표현 가능 | 두 owner 발행 후 실제 경로로 forbidden fixture 실행 → fail 확인; allowed(public contract + test helper) fixture → pass 확인 | `needs-confirmation` |
|
||||
| 규칙 catalog 가 layering branch allowed-import matrix 와 동기 유지 | matrix 가 외부 소유라 drift 가능 | 변경마다 규칙 catalog vs `FE-OC-002` owner 발행 matrix cross-check | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
> `/coverage`가 채우는 생성물이며 손으로 유지하지 않는다.
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| TODO — `/coverage` 실행 전 | missing | (없음) | 미평가 | TODO |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
없음 — `/branch-spec` 채움 단계.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
| `FE-GATE-013@1` | [[raw/branch-notes/feature-frontend-build-bundle-supply-chain-contract]] | secret·vulnerability·license·dependency review 정책 위반이면 merge·release 를 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-OC-002@1` | [[raw/branch-notes/feature-frontend-clean-architecture-layering-contract]] | `domain <- application <- presentation` 의존 방향과 application-owned output port를 MUST 지킴 | import 참조로 적용 |
|
||||
| `FE-OC-014@1` | [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] | telemetry는 best-effort이며 render·API success를 차단하면 안 되고 PII·token을 전송하면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-019@1` | [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] | browser bundle에 secret을 넣지 않고 untrusted HTML injection을 기본 금지 | import 참조로 적용 |
|
||||
| `FE-OC-020@1` | [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] | gate 종류별 책임·fixture·artifact를 분리하고 실패를 warning으로 낮추면 안 됨 | import 참조로 적용 |
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
### Sub-branches (세부 작업)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 오류 기록 (이 branch 작업 중 발생)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 강의 (이 작업을 위해 학습한 강의)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### job-posting tie-ins (이 작업에서 파생된 글감)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-frontend-operational-contract/branch-notes/feature-frontend-auth-session-integration-contract.md
|
||||
@@ -0,0 +1,316 @@
|
||||
---
|
||||
title: branch / feature-frontend-auth-session-integration-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-008
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-frontend-operational-contract
|
||||
work_item: WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-008
|
||||
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-AUTH-BOUNDARY-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-PORT-OWNERSHIP-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-002]
|
||||
contract_packet: 1
|
||||
branch: feature-frontend-auth-session-integration-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton-frontend, ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-frontend-operational-contract.md]
|
||||
tags: [branch, ca-skeleton, frontend, auth, security, javascript, oauth2]
|
||||
created: 2026-07-18
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: ec06d05939cbbe7c12c1a581115a07830894c4328344e6a5053c2162df756ab0
|
||||
imports: [FE-OC-002@1, FE-OC-005@1, FE-OC-006@1, FE-OC-008@1, FE-OC-019@1]
|
||||
|
||||
---
|
||||
|
||||
# branch: feature-frontend-auth-session-integration-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 TODO·결정·진행 기록. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출한다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-frontend-operational-contract]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: AuthSessionPort·bounded 401 replay와 token lifecycle import 금지가 test로 고정된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-AUTH-BOUNDARY-001@1` | auth lifecycle은 외부 owner가 소유하고 skeleton은 AuthSessionPort만 소비한다 | token lifecycle 비소유와 bounded session recovery 경계에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-PORT-OWNERSHIP-001@1` | output port interface는 application이 소유하고 adapter가 구현한다 | application-owned AuthSessionPort와 외부 auth adapter 경계에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
이 브랜치는 hub의 `FE-OC-010`("skeleton은 session state를 소비하되 token lifecycle을 MUST NOT 소유")을 구현 착수 가능한 명세로 내린다. 구체적으로 [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-D017`(auth lifecycle은 외부 owner, skeleton은 `AuthSessionPort`만 소비)을 근거로, application이 소유하는 `AuthSessionPort` 경계 · bounded 401 recovery state machine · recovery replay policy · auth 실패 정규화 · no-token-lifecycle 불변식을 `planned` 청사진으로 확정한다. 또한 이 경계가 라우팅·API client·browser security에 닿는 지점(`FE-OC-005`·`FE-OC-006`·`FE-OC-019`)에 대해 "무엇을 기여하고 무엇을 다른 owner 브랜치에 위임하는지"를 못박는다. token 발급/저장/refresh/rotation/logout은 외부 auth owner(Keycloak 등, [[raw/project-notes/keycloak-patterns-overview]])가 소유하므로 이 노트는 그것을 *명명만* 하고 명세하지 않는다. 현재 frontend 구현 코드가 없어 모든 항목은 `planned`다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- `AuthSessionPort`(application 소유 integration boundary port)의 소비 계약: opaque session state 읽기 + request 전 `attach(request)` + unauthenticated transition 통지 콜백 (`FE-OC-010`).
|
||||
- Bounded 401 recovery state machine: `authenticated → recovery-pending → {authenticated | unauthenticated | integration-failed}`, logical request당 recovery callback 최대 1회, 두 번째 401은 terminal (hub §7.8).
|
||||
- Recovery 이후 replay policy: `safe` 1회 replay / `keyed` mutation은 stable idempotency key + backend replay contract일 때만 1회 / `none`(unkeyed) mutation은 replay 금지 (hub §7.8·§7.7).
|
||||
- Auth 실패 정규화 기여: `401→AUTH_REQUIRED`, `403→FORBIDDEN`, attach/recovery adapter fault→`AUTH_INTEGRATION_FAILURE` (hub §8.2·§8.5).
|
||||
- no-token-lifecycle 불변식: skeleton은 token/secret을 browser storage·bundle·env·telemetry·error body에 저장/노출하지 않는다 (`FE-OC-019` 기여, hub §5.5·§6.1).
|
||||
- session-required route가 `AuthSessionPort` state를 UX hint로만 소비하고 backend authorization을 최종 판단으로 두는 규칙 (`FE-OC-005` 기여, hub §9.3).
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외 — 외부 auth owner 또는 다른 owner 브랜치 소유. 여기서는 *명명*만 하고 명세하지 않는다.
|
||||
|
||||
- **Token lifecycle 전체** — authorization code exchange, token 저장 위치, access token refresh, refresh token rotation, logout propagation, revocation, IdP redirect detail, backend permission decision. 외부 auth owner 소유([[raw/project-notes/keycloak-patterns-overview]], hub §7.8 "Skeleton does not own").
|
||||
- **Route registry / navigation guard 메커니즘** (route ID/path/param validation/404/redirect-loop) — [[raw/branch-notes/feature-routing-navigation-guard-contract]] 의 `FE-OC-005` 소유. 나는 session-state hint 소비만 기여.
|
||||
- **Shared HTTP client transport 및 timeout/abort/retry algorithm** — [[raw/branch-notes/feature-api-client-response-envelope-contract]] 의 `FE-OC-006`·`FE-OC-009` 소유. attach·정규화는 그 client 안에서 실행되나 client 뼈대는 그 브랜치가 소유.
|
||||
- **Error registry 구조 및 total-function 정규화** — [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] 의 `FE-OC-008` 소유. 나는 3개 auth kind의 기대 매핑만 기여.
|
||||
- **Storage registry 스키마** — [[raw/branch-notes/feature-frontend-storage-registry-contract]] 의 `FE-OC-013` 소유. `AUTH_TOKEN` forbidden 행의 불변식만 기여.
|
||||
- **CSP/header/secret-scan browser boundary 메커니즘** — [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] 의 `FE-OC-019` 소유. 나는 no-token-storage 불변식만 기여.
|
||||
- **Telemetry redaction 인프라** — [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] 의 `FE-OC-014` 소유. auth 이벤트의 forbidden attribute(token/principal) 규칙만 기여.
|
||||
- **Architecture import-lint 강제 메커니즘** — [[raw/branch-notes/feature-frontend-architecture-enforcement-lint-contract]] 의 `FE-OC-002` 소유. 나는 "token lifecycle 심볼은 auth adapter 밖에서 import 금지"라는 *검사 대상 불변식*만 정의.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `FE-D017`/`FE-OC-010` + §4.4(Port ownership) · §7.8(Auth integration boundary) · §8.2(failure matrix) · §5.5·§6.1(browser security) · §9.3(route behavior) — D1~D7의 1차 근거(project decision SSOT) |
|
||||
| [[raw/project-notes/keycloak-patterns-overview]] | 외부 auth owner가 token 발급·저장·refresh·rotation·logout을 소유한다는 external-owner context(§3 token 종류, §2.1 token 위치·인증 강제 주체) — D1(경계 설정)·D6(token 브라우저 미저장)의 배경 근거 |
|
||||
| [[raw/official-docs/react-router-official]] | route composition(`<Routes>`/`<Route>`, nested `<Outlet/>`; `REACT-ROUTER-C1`/`C2`)이 session-required route surface가 얹히는 라우팅 모드에 부합 — D7의 라우팅 context. ⚠ navigation **guard 보안**(`REACT-ROUTER-C3` boundary)은 이 자료가 **정당화하지 않음** → guard=UX hint는 project decision(D7) |
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] `AuthSessionPort` 인터페이스 정의(application 소유): opaque session state getter + `attach(request)` header/credential callback + unauthenticated-transition callback — 등급: `planned`
|
||||
- [ ] 외부 auth adapter placeholder(`adapters/auth/`)가 port 구현, composition-root boot step 6에서 주입 — 등급: `planned`
|
||||
- [ ] shared HTTP client 경유 bounded 401 state machine + replay policy 구현 — 등급: `planned`
|
||||
- [ ] auth 실패 정규화 매핑(401/403/attach·recovery fault)을 error registry에 기여 — 등급: `planned`
|
||||
- [ ] no-token-lifecycle import test(architecture fixture): token 저장/refresh 심볼을 `adapters/auth/` 밖에서 import 시 실패 — 등급: `planned`
|
||||
- [ ] session-required route UX hint + backend authz 최종성 e2e(guarded route 403 처리) — 등급: `planned`
|
||||
- [ ] negative fixtures: attach throw/reject·recovery invalid state → `AUTH_INTEGRATION_FAILURE`; unkeyed mutation recovery → no replay — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
`/branch-spec` 자체 채움(self-map). frontend 구현 repository 미식별 → 전 항목 `planned`. hub와 6개 archived official-doc이 유일 SSOT이며, auth lifecycle 근거는 [[raw/project-notes/keycloak-patterns-overview]]. 인라인 웹 리서치 0건(hub가 이미 충분).
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-07-19: auth lifecycle은 외부 owner, skeleton은 `AuthSessionPort`만 소비 / 이유: token 발급·저장·refresh·rotation·logout·revocation은 IdP·backend가 소유하는 관심사이며 client-only SPA가 이를 소유하면 보안·release 경계가 흐려짐 / 검토한 대안: skeleton이 token lifecycle을 직접 소유(독립 auth product) / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-D017`·`FE-OC-010`, [[raw/project-notes/keycloak-patterns-overview]].
|
||||
- 2026-07-19: `AuthSessionPort`는 application이 정의 소유, 외부 adapter가 구현, token 문자열을 domain/application model로 반환하지 않고 opaque state + attach callback 형태 우선 / 이유: dependency inversion 유지 + token이 layer 내부로 스며들지 않게 / 검토한 대안: adapter가 직접 token을 반환해 use case가 소비 / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-D010`·`FE-D009`·`FE-OC-002`·§4.4.
|
||||
- 2026-07-19: 401 recovery는 bounded state machine, logical request당 recovery 1회, 2nd 401 terminal / 이유: recovery loop 차단 / 검토한 대안: 무제한 재인증 재시도 / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §7.8·`FE-OC-006`.
|
||||
- 2026-07-19: recovery replay는 `safe` 1회 / `keyed`(+backend replay contract) 1회 / `none` 금지 / 이유: duplicate write 방지 / 검토한 대안: 성공 후 무조건 replay / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §7.8·§7.7·`FE-D016`.
|
||||
- 2026-07-19: auth 실패 정규화 `401→AUTH_REQUIRED` / `403→FORBIDDEN` / attach·recovery fault→`AUTH_INTEGRATION_FAILURE`, raw body·token 미노출 / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §8.2·§8.5·§5.6·`FE-OC-008`.
|
||||
- 2026-07-19: token/secret은 browser storage·bundle·env·telemetry·error body에 미저장·미노출; `AUTH_TOKEN` key forbidden / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §5.5·§6.1·§5.8·`FE-OC-019`.
|
||||
- 2026-07-19: session-required route는 `AuthSessionPort` state를 UX hint로만 사용, backend authorization이 최종 / 이유: guard를 security control로 오해 방지 / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §9.3·§7.8·`FE-OC-005`. (react-router `REACT-ROUTER-C3`는 guard를 정당화하지 않음.)
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정과 raw source claim ID의 연결. `FE-D###`/`§` 참조는 hub project 링크에 붙인다(consistency hook 규약).
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | auth lifecycle은 외부 owner; skeleton은 `AuthSessionPort`만 소비하고 token 발급/저장/refresh/rotation/logout/revocation을 MUST NOT 소유 (`FE-OC-010`) | client-only SPA + 외부 auth owner가 session interface를 제공하는 한 이 결정 유지 / skeleton이 독립 auth product로 scope 변경되면 재검토 (`FE-D017` revisit trigger) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-D017`·`FE-OC-010`; [[raw/project-notes/keycloak-patterns-overview]] (외부 owner가 token 종류·위치 소유) | `project-decision` | 통합 adapter owner 미정 (`FE-Q-006`); guard가 security로 오해될 위험 (`FE-RISK-005`) |
|
||||
| D2 | `AuthSessionPort`는 application이 정의 소유, 외부 adapter가 구현, token 문자열을 model로 반환하지 않고 opaque state + attach callback 형태 우선 | dependency inversion 유지(adapter가 port 구현)하는 한 유지 / port가 domain invariant 자체를 표현해야 하는 concrete case면 재검토 (`FE-D010` revisit) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-D010`·`FE-D009`·`FE-OC-002` §4.4 (AuthSessionPort row) | `project-decision` | attach 구현 세부(header supplier vs opaque credential)는 auth owner 결정 — §4.4가 "구현 세부는 auth owner가 정한다"로 유보 |
|
||||
| D3 | 401 recovery는 bounded state machine(`authenticated→recovery-pending→{authenticated\|unauthenticated\|integration-failed}`), logical request당 recovery 콜백 ≤1회, 같은 request의 2nd 401은 terminal `AUTH_REQUIRED` | 외부 owner가 bounded recovery callback을 제공하면 이 machine 사용 / owner가 recovery를 안 하면 첫 401이 곧 terminal(unauthenticated). recovery loop 금지 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §7.8 (session state table)·`FE-OC-010`·`FE-OC-006` | `project-decision` | recovery callback의 timeout/bound 세부는 owner 계약에 의존 (`FE-Q-006`) |
|
||||
| D4 | recovery 성공 후 replay: `safe`=같은 context 1회 / `keyed`=stable idempotency key + active backend replay contract일 때만 1회 / `none`(unkeyed)=MUST NOT replay(명시적 재시도 요구) | idempotency mode로 분기 — backend replay contract 없으면 `keyed`도 replay 금지 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §7.8 (replay policy)·§7.7 (idempotency)·`FE-D016`·`FE-OC-006` | `project-decision` | backend replay contract 존재 여부 미확정 (`FE-Q-005`); unsafe duplicate write 위험 (`FE-RISK-006` 인접) |
|
||||
| D5 | auth 실패 정규화: `401→AUTH_REQUIRED`, `403→FORBIDDEN`, attach/recovery adapter가 throw/reject/invalid state→`AUTH_INTEGRATION_FAILURE`; raw body·token·principal은 failure·telemetry에 미포함 | 이 3 kind는 stable enum. backend가 다른 auth 상태를 쓰면 error 브랜치가 registry에 추가 후 매핑(재정의 아님) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §8.2 (failure matrix 3 auth rows)·§8.5 (negative fixtures)·§5.6 (error enum)·`FE-OC-008` | `project-decision` | error registry 구조는 error 브랜치 owner — 나는 매핑 값만 기여(경계 이탈 주의) |
|
||||
| D6 | token/secret은 browser storage·bundle·env·telemetry·error body에 저장/노출 MUST NOT; `AUTH_TOKEN` storage key는 forbidden(`sensitive-forbidden`), token 저장은 외부 auth owner만 | default off(브라우저 token storage 금지) / auth owner가 browser storage를 반드시 써야 하면 별도 threat model + owner evidence 필요(§6.1), skeleton default 아님 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §5.5 (`AUTH_TOKEN` forbidden)·§6.1 (secret config)·§5.8 (telemetry forbiddenAttributes)·§8.1·`FE-OC-019` | `project-decision` | XSS surface 시 token이 브라우저에 없어야 완화(keycloak note P2A 함정); CSP/scan은 browser-security 브랜치 owner |
|
||||
| D7 | session-required route는 `AuthSessionPort` state를 UX hint로만 사용, backend authorization이 최종 권한 판단 — navigation guard는 보안 control이 아님 | route access가 `session-required`/`integration-defined`일 때 hint 적용 / `public`이면 미적용. 최종 authz는 항상 backend(`403→FORBIDDEN`) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §9.3 (route behavior)·§7.8 ("guard는 UX hint")·`FE-OC-005`. (react-router `REACT-ROUTER-C3`는 guard 보안 미정당화) | `project-decision` | guard가 security로 오해 (`FE-RISK-005`) → e2e에서 403 처리 확인; route registry/redirect는 routing 브랜치 owner |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 전 항목 `planned` — frontend 코드 없음. 경로는 hub §4.6 Planned directory blueprint / §5.1 registry owner map에서 도출된 `planned` anchor다.
|
||||
|
||||
### 1. `AuthSessionPort` 인터페이스 (application 소유)
|
||||
|
||||
> **Trace**: D1 + D2 · `FE-OC-010`/`FE-OC-002` · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §4.4 (Port ownership matrix) · §4.6 (blueprint).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 아래 메서드 명(`getSessionState`/`attach`/`onUnauthenticated`)과 "header-supplier callback" 표현은 내가 임의 선택 — hub §4.4는 "구현 세부는 auth owner가 정한다"로 shape만 유보(opaque state + attach callback, token 문자열 미반환). trade-off: §7.8의 attach·unauthenticated-transition 어휘를 거울 삼아 되묻기를 줄이되, 최종 signature는 auth owner 계약 확정 시 조정.
|
||||
|
||||
| 항목 | `planned` 값 | 근거 |
|
||||
|---|---|---|
|
||||
| Definition owner | `application` (integration boundary) | §4.4 |
|
||||
| Planned 위치 | `src/application/ports/auth-session-port.js` (정의), `src/adapters/auth/` (구현) | §4.6 |
|
||||
| Consumer | routing (session hint) + API client interceptor(attach) | §4.4 |
|
||||
| Input/Output | opaque session state / request-header attach callback (token 문자열 미반환) | §4.4 |
|
||||
| Failure vocabulary | `AuthRequired`, `AuthIntegrationFailure` | §4.4 |
|
||||
| 주입 시점 | composition-root boot step 6 (auth integration adapter 주입) | §4.5 |
|
||||
|
||||
### 2. Bounded 401 recovery state machine
|
||||
|
||||
> **Trace**: D3 · `FE-OC-006`/`FE-OC-010` · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §7.8 (session state table). 전부 hub 계약에서 도출.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — 상태·전이·행동이 §7.8 표에 명시됨. 명시돼 있다는 것은 곧 **owner 가 hub §7.8 이라는 뜻**이므로 표를 여기에 복제하지 않는다.
|
||||
|
||||
**state machine 본문은 [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §7.8 소유다.** 요약 한 줄: `authenticated` 에서 첫 `401` 이 bounded recovery 를 1회만 트리거하고, 같은 logical request 의 2번째 `401` 은 추가 recovery 없이 terminal `AUTH_REQUIRED` 로 끝난다. 본 브랜치가 소유하는 것은 그 전이를 `AuthSessionPort` 계약으로 내리는 부분이다.
|
||||
|
||||
### 3. Recovery replay policy
|
||||
|
||||
> **Trace**: D4 · `FE-OC-006`/`FE-OC-009` · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §7.8 (replay policy) · §7.7 (idempotency) · `FE-D016`. hub 계약에서 도출.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음.
|
||||
|
||||
**replay policy 본문은 [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §7.8 소유다.** 요약 한 줄: idempotency mode 별로 recovery 성공 후 replay 는 최대 1회이고 `none`(unkeyed) 는 replay 금지다.
|
||||
|
||||
replay + 일반 retry의 총 시도는 operation registry·test fixture가 추적하며 recovery loop를 만들 수 없다.
|
||||
|
||||
### 4. Auth 실패 정규화 매핑 (error registry 기여)
|
||||
|
||||
> **Trace**: D5 · `FE-OC-008`(error 브랜치 owner에 기여) · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §8.2 (failure matrix) · §8.5 (negative fixtures) · §5.6 (error enum).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — kind/action/telemetry가 §8.2·§5.6에 고정.
|
||||
> - **OUT_OF_BRANCH_SCOPE**: error registry의 스키마·total-function 정규화 뼈대는 [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] 의 `FE-OC-008` 소유. 나는 아래 3행의 기대 매핑만 제공.
|
||||
|
||||
| Trigger (본 브랜치가 발생시키는 지점) | 기대 kind |
|
||||
|---|---|
|
||||
| HTTP `401` | `AUTH_REQUIRED` |
|
||||
| HTTP `403` | `FORBIDDEN` |
|
||||
| attach/recovery adapter throw·reject·invalid state | `AUTH_INTEGRATION_FAILURE` |
|
||||
|
||||
각 kind 의 retry·action·telemetry 규칙은 [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §8.2 소유이며 여기에 옮겨 적지 않는다.
|
||||
|
||||
### 5. Browser-security 불변식 (auth) — `FE-OC-019` 기여
|
||||
|
||||
> **Trace**: D6 · `FE-OC-019`(browser-security 브랜치에 기여) · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §5.5 (`AUTH_TOKEN` forbidden) · §6.1 (secret config) · §5.8 (telemetry forbidden) · §8.1 (failure exclusions).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — 금지 목록이 hub registry/config에 고정.
|
||||
> - **OUT_OF_BRANCH_SCOPE**: CSP/header/secret-scan lint 메커니즘은 [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] 의 `FE-OC-019`, storage 스키마는 [[raw/branch-notes/feature-frontend-storage-registry-contract]] 의 `FE-OC-013` 소유.
|
||||
|
||||
- token/secret을 `localStorage`/`sessionStorage`/bundle/`import.meta.env`/`/config.json`에 저장 금지 (§6.1).
|
||||
- `AUTH_TOKEN` storage key = `forbidden` + `sensitive-forbidden`, 외부 auth owner만 token 저장 (§5.5).
|
||||
- telemetry `forbiddenAttributes`에 token·email·raw URL 포함, auth 이벤트는 route ID만 (§5.8·§8.2).
|
||||
- normalized failure에 raw body·token·authorization header·stack 미포함 (§8.1).
|
||||
|
||||
### 6. no-token-lifecycle 강제 (측정 항목 "no token lifecycle import tests")
|
||||
|
||||
> **Trace**: D1 + D6 · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §4.2·§4.3 (dependency matrix) — §20 Measurable completion의 "no token lifecycle import tests".
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: token-lifecycle 심볼 집합(예: `refresh`/`rotate`/`token-store`)과 restricted-import glob은 내 임의 제안 — trade-off: `adapters/auth/` 밖에서 token 저장·회전 심볼 import를 차단하는 최소 룰로 시작하되 오탐 시 auth owner 계약에 맞춰 조정.
|
||||
> - **OUT_OF_BRANCH_SCOPE**: import-lint의 실행 메커니즘(dependency-cruiser/ESLint restricted imports fixture)은 [[raw/branch-notes/feature-frontend-architecture-enforcement-lint-contract]] 의 `FE-OC-002` 소유. 나는 *검사 대상 불변식*만 정의.
|
||||
|
||||
- 불변식: token 발급·저장·refresh·rotation 심볼은 `src/adapters/auth/` 내부에서만 존재/참조. `domain`/`application`/`presentation`은 이를 import 금지 (§4.3).
|
||||
|
||||
### 7. Route session-integration 접점 — `FE-OC-005` 기여
|
||||
|
||||
> **Trace**: D7 · `FE-OC-005`(routing 브랜치에 기여) · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §9.3 (route behavior) · §7.8 · §5.2 (route access enum).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음.
|
||||
> - **OUT_OF_BRANCH_SCOPE**: route registry 스키마·`access` 필드·redirect-loop·param validation은 [[raw/branch-notes/feature-routing-navigation-guard-contract]] 의 `FE-OC-005` 소유. 나는 아래 2 규칙만 기여.
|
||||
|
||||
- `access: session-required`/`integration-defined` route는 `AuthSessionPort` state를 **UX hint**로만 소비 (§9.3·§5.2).
|
||||
- backend authorization result가 최종 권한 판단이며, guard는 이를 대체하지 않는다 (§9.3, `FE-RISK-005`).
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- attach callback이 throw/reject → `AUTH_INTEGRATION_FAILURE`, unauthenticated-safe shell (hub §8.5).
|
||||
- recovery가 invalid state 반환 → `AUTH_INTEGRATION_FAILURE`.
|
||||
- 같은 logical request의 두 번째 `401` → 추가 recovery 없이 terminal `AUTH_REQUIRED` (loop 금지).
|
||||
- `none`(unkeyed) mutation이 recovery 성공 → replay 금지, 명시적 재시도 요구.
|
||||
- recovery 대기 중 navigation/user abort → `REQUEST_ABORTED`, 대기 취소(error event 금지).
|
||||
- `keyed` mutation인데 active backend replay contract 부재 → replay 금지.
|
||||
- **다른 계약 의존** (§20 Dependency + hub §4.3):
|
||||
- [[raw/branch-notes/feature-frontend-clean-architecture-layering-contract]] 의 `FE-OC-002`(application-owned port + dependency rule)에 의존 — §20의 hard dependency. 이 계약이 바뀌면 port 소유 위치가 흔들림.
|
||||
- [[raw/branch-notes/feature-api-client-response-envelope-contract]] 의 `FE-OC-006`(shared client)에 의존 — attach·정규화·bounded state machine이 그 client 안에서 실행. timeout/abort/retry는 그 계약이 소유.
|
||||
- [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] 의 `FE-OC-008`(error registry)에 의존 — auth kind 3종이 그 registry에 존재해야 매핑 성립.
|
||||
- [[raw/branch-notes/feature-routing-navigation-guard-contract]] 의 `FE-OC-005`(route registry)에 의존 — session-required `access` 필드가 존재해야 hint 접점 성립.
|
||||
- [[raw/branch-notes/feature-frontend-storage-registry-contract]] 의 `FE-OC-013`, [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] 의 `FE-OC-019`, [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] 의 `FE-OC-014`에 기여 — 각 `AUTH_TOKEN` forbidden·no-token-bundle·telemetry redaction 불변식.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| `AuthSessionPort`가 token 문자열을 domain/application model로 반환하지 않는다 | 구현 코드 없음(`planned`) | port contract test + no-token-lifecycle import test(§4.6 architecture fixture) | `needs-confirmation` |
|
||||
| bounded 401 machine이 recovery loop를 만들지 않는다(request당 recovery ≤1, 2nd 401 terminal) | machine 미구현 | deterministic state-transition test(`ClockPort` + 주입된 401 시퀀스, §7.8) | `needs-confirmation` |
|
||||
| recovery replay가 unkeyed mutation을 replay하지 않는다 | 미구현 | negative fixture: recovery succeeds for unkeyed mutation → no replay (§8.5) | `needs-confirmation` |
|
||||
| attach/recovery adapter fault가 `AUTH_INTEGRATION_FAILURE`로 정규화된다 | 미구현 | negative fixture: attach throw/reject, recovery invalid state (§8.5) | `needs-confirmation` |
|
||||
| `401→AUTH_REQUIRED`·`403→FORBIDDEN` 정규화 + raw body/token 미노출 | 미구현 | error catalog test + redaction assertion(telemetry에 principal/token 없음, §8.2) | `needs-confirmation` |
|
||||
| session-required route hint가 backend authz를 대체하지 않는다 | 미구현 | e2e: guarded route에서 backend `403` 처리 확인 (`FE-RISK-005`) | `needs-confirmation` |
|
||||
| skeleton 어디에도 token이 browser storage/bundle에 저장되지 않는다 | 미구현 | secret scan + storage registry test(`AUTH_TOKEN` forbidden, §5.5·§6.1) | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
> `/coverage`가 채우는 생성물이며 손으로 유지하지 않는다.
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| TODO — `/coverage` 실행 전 | missing | (없음) | 미평가 | TODO |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
| `FE-OC-002@1` | [[raw/branch-notes/feature-frontend-clean-architecture-layering-contract]] | `domain <- application <- presentation` 의존 방향과 application-owned output port를 MUST 지킴 | import 참조로 적용 |
|
||||
| `FE-OC-005@1` | [[raw/branch-notes/feature-routing-navigation-guard-contract]] | route ID/path/params/access/loading/error owner는 route registry 하나여야 함 | import 참조로 적용 |
|
||||
| `FE-OC-006@1` | [[raw/branch-notes/feature-api-client-response-envelope-contract]] | 모든 HTTP는 shared client를 MUST 통과하고 timeout·abort·response parsing을 page에서 구현하면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-008@1` | [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] | 모든 failure는 stable frontend error kind로 MUST 정규화하고 raw body·stack을 UI에 노출하면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-019@1` | [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] | browser bundle에 secret을 넣지 않고 untrusted HTML injection을 기본 금지 | import 참조로 적용 |
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
### Sub-branches (세부 작업)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 오류 기록 (이 branch 작업 중 발생)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 강의 (이 작업을 위해 학습한 강의)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### job-posting tie-ins (이 작업에서 파생된 글감)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-frontend-operational-contract/branch-notes/feature-frontend-browser-security-boundary-contract.md
|
||||
@@ -0,0 +1,345 @@
|
||||
---
|
||||
title: branch / feature-frontend-browser-security-boundary-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-021
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-frontend-operational-contract
|
||||
work_item: WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-021
|
||||
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-AUTH-BOUNDARY-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-TELEMETRY-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-008, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-011, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-012]
|
||||
contract_packet: 1
|
||||
branch: feature-frontend-browser-security-boundary-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton-frontend, ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-frontend-operational-contract]
|
||||
tags: [branch, ca-skeleton, frontend, security, owasp, static-analysis]
|
||||
created: 2026-07-18
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 67f39972a793315acd8de19be381ec163d5e8843a539a147103b74c6d4324274
|
||||
imports: [FE-GATE-002@1, FE-GATE-006@1, FE-GATE-013@1, FE-GATE-019@2, FE-OC-004@1, FE-OC-008@1, FE-OC-010@1, FE-OC-013@1, FE-OC-014@1, FE-OC-016@1, FE-OC-018@1, FE-OC-020@1]
|
||||
accepts_delegations: [DELEG-FE-001@1]
|
||||
|
||||
---
|
||||
|
||||
# branch: feature-frontend-browser-security-boundary-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — TODO·결정·진행 기록. 구현 결과는 검증 뒤 `/ingest`로만 추출한다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/project-notes/ca-skeleton-frontend-operational-contract]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: CSP·header·secret·storage·telemetry browser-boundary fixture가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-AUTH-BOUNDARY-001@1` | auth lifecycle은 외부 owner가 소유하고 skeleton은 AuthSessionPort만 소비한다 | token·secret browser storage 금지 fixture에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-TELEMETRY-001@1` | telemetry는 best-effort queue와 redaction을 사용하며 sink failure가 UI를 실패시키지 않는다 | telemetry forbidden-attribute leak fixture에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- 이 branch는 project-wide contract `FE-OC-019`(browser bundle에 secret을 넣지 않고 untrusted HTML injection을 기본 금지)의 single owner로서, 이를 *되묻지 않아도 코드를 작성할 수 있는* implementation-ready spec으로 내린다. 근거는 hub [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §13.2(browser security boundary)·§6.1(secret 3-way 구분)·§13.1(secret scan)·§15.1(`FE-GATE-013` security gate)·§15.2(negative fixture)다.
|
||||
- 동시에 `FE-OC-010`(auth session), `FE-OC-013`(storage), `FE-OC-014`(telemetry), `FE-OC-018`(build/bundle supply-chain), `FE-OC-020`(test taxonomy)에 **contribute**한다 — 각 registry의 *schema*는 그 owner branch가 갖고, 본 branch는 그 경계를 넘는 값(secret·token·untrusted HTML·PII)이 브라우저 표면(bundle·env·HTML·storage·telemetry)에 새지 않는지 검증하는 **cross-cutting security fixture와 injection/secret lint+scan**을 소유한다.
|
||||
- 측정 가능한 완료 조건(§20): `CSP/header/secret/storage/telemetry browser-boundary fixtures`.
|
||||
- 이슈: 없음 (스캐폴딩 단계)
|
||||
- PR: 없음
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- browser bundle을 public artifact로 간주하고 **secret(client secret·private key·refresh token material)을 bundle·env·HTML에 넣지 않도록** 강제하는 계약 — env registry의 name-based 거부 + source/built-asset secret scan.
|
||||
- **untrusted HTML injection과 dynamic code 실행 기본 금지** — `dangerouslySetInnerHTML`·`eval`·dynamic code(`new Function`)·untrusted script URL의 prohibited import/API lint rule. 불가피한 HTML rendering의 예외 조건(sanitizer owner·allowlist·malicious fixture·CSP interaction evidence) 명세.
|
||||
- bundle이 `unsafe-inline`/`unsafe-eval` 없는 **strict CSP와 호환**되도록 하는 frontend 측 불변식(inline script·inline handler·eval 미의존) + 선언된 security header 정책의 verification fixture.
|
||||
- production public path에 **source map 미배포** 기본 정책.
|
||||
- 위 경계를 넘는 값을 잡는 **cross-cutting security fixture 집합**(secret / storage token-key / telemetry forbidden-attribute / HTML-injection)과 이를 `FE-GATE-013`으로 집계 + `FE-GATE-002`(lint)에 기여.
|
||||
- **`FE-GATE-006`(component gate)의 `FE-OC-019` 슬라이스 fixture 본문 소유** — hub §15.1 이 component gate 의 Covered FE-OC 에 `FE-OC-019` 를 명시했으므로, render 시점에만 관측 가능한 injection 불변식(예외 sanitizer 경로의 malicious fixture + rendered subtree 의 prohibited-API 산출물 부재)은 본 branch 가 component-level fixture 로 소유한다(D8).
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- **CSP/HSTS/frame/referrer header의 실제 directive 값(production)** — hosting/backend header owner 소유(hub §13.2). 본 branch는 값이 아니라 *호환성*만 본다. "선언 == 실제"의 **검증 위치**는 2026-07-21 에 `FE-GATE-019@2` 로 확정됐다 — D9 참조.
|
||||
- **hosting header(`Cache-Control`·content-type·security header) 정책의 declared-vs-actual 검증** — release·cache 계약 [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] 소유(cache 축 `D1`, security-header 축 `D6`). hub §15.1 `FE-GATE-019@2` 가 Covered FE-OC 에 `FE-OC-019` 를 포함하도록 확장돼 security header 도 그 gate 범위다. 본 branch 는 검증 대상 header 정책을 공급한다.
|
||||
- **storage key/version/classification registry schema** — [[raw/branch-notes/feature-frontend-storage-registry-contract]] `FE-OC-013` 소유. 본 branch는 token/secret 저장 시도가 실패하는 security fixture만 갖는다.
|
||||
- **token lifecycle(발급·저장 위치·refresh·rotation·logout)** — 외부 auth owner + [[raw/branch-notes/feature-frontend-auth-session-integration-contract]] `FE-OC-010` 소유.
|
||||
- **telemetry redaction allowlist와 transport-boundary 강제** — [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] `FE-OC-014` 소유. 본 branch는 forbidden-attribute leak fixture만 기여.
|
||||
- **secret scanner/vulnerability scanner 도구 선택·severity threshold** — supply-chain 계약 [[raw/branch-notes/feature-frontend-build-bundle-supply-chain-contract]] `FE-OC-018`. hub §13.1에서 도구는 `deferred`.
|
||||
- **error registry 구조·정규화** — [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] `FE-OC-008`.
|
||||
- **backend authorization·CORS enforcement** — 서버/브라우저 책임. route guard는 authorization control이 아니며(hub §13.2), client validation은 backend validation을 대체하지 않는다.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/owasp-html5-storage-xss-spa]] | D4 — 단일 XSS로 localStorage/sessionStorage 전체 탈취·주입 가능하므로 token/secret을 browser storage에 두지 않는다(`OWASP-HTML5-C1`~`C3`). |
|
||||
| [[raw/official-docs/owasp-content-security-policy-cheat-sheet]] | D2·D3 — CSP는 server가 보내는 HTTP response header이고(`OWASP-CSP-C1`), `'unsafe-inline'`/`'unsafe-eval'`이 없으면 inline script·eval이 차단되므로(`OWASP-CSP-C2`·`C3`) bundle이 이를 미의존해야 strict CSP(second layer, `OWASP-CSP-C4`)를 적용할 수 있다. |
|
||||
| [[raw/official-docs/owasp-hsts-cheat-sheet]] | D3 — HSTS 등 security response header는 response header owner(hosting)의 opt-in 결정이며(`OWASP-HSTS-C1`), frontend는 값이 아닌 호환성만 책임진다는 경계의 근거. |
|
||||
| [[raw/official-docs/owasp-logging-cheat-sheet]] | D5 — 다른 trust zone에서 온 event data는 untrusted이며(`OWASP-LOG-C1`) sanitization으로 민감정보를 제거해야 한다(`OWASP-LOG-C3`)는 telemetry/error redaction fixture의 원칙 근거. |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | D1·D6·D7 — secret 3-way 구분과 name-based 거부(§6.1), bundle=public artifact·source map 미배포(§13.2), secret scan(§13.1), security gate·negative fixture(§15.1·§15.2)의 project decision 근거. |
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] secret-exclusion: env registry의 name-based 거부(`SECRET`/`PASSWORD`/`PRIVATE_KEY`/`TOKEN`) + source·`dist/` built-asset secret scan fixture 정의 — 등급: `planned`
|
||||
- [ ] injection-lint: `dangerouslySetInnerHTML`·`eval`·dynamic code·untrusted script URL 금지 rule + **금지 rule 1개당 1개**의 deliberately-failing negative fixture(총 3개, D10) — 등급: `planned`
|
||||
- [ ] injection-render fixture(`FE-GATE-006`): 예외 sanitizer 경로 malicious fixture + rendered subtree 에 inline `<script>`/inline handler 부재 assertion(D8) — 등급: `planned`
|
||||
- [ ] csp-compat: **no-unsafe 정본 test baseline**(D10) 하에서 bundle의 `unsafe-inline`/`unsafe-eval` 미의존 assertion + CSP violation 0 관측 — 등급: `planned`
|
||||
- [ ] security-header declared-vs-actual: 선언 CSP/HSTS/frame/referrer 정책 == 실제 hosting 응답 verification. gate 귀속은 `FE-GATE-019@2` 로 확정됐고(D9), 본 branch 는 검증 대상 header 정책을 공급 — 등급: `planned`
|
||||
- [ ] storage-boundary fixture: token/secret key 등록 시도가 실패함을 증명(§15.2 "storage: token key registration attempt") — 등급: `planned`
|
||||
- [ ] telemetry-boundary fixture: forbidden attribute(raw URL/query/token 등) 전송 시도가 실패함을 증명(§15.2 "telemetry: event includes raw URL/query") — 등급: `planned`
|
||||
- [ ] source-map policy: production public path에 source map 미배포 확인 fixture — 등급: `planned`
|
||||
- [ ] gate wiring: 위 fixture를 `FE-GATE-013`(security)로 집계 + `FE-GATE-002`(lint) 기여 + `FE-GATE-006`(component)의 `FE-OC-019` 슬라이스 소유(D8) — 등급: `planned`
|
||||
- [x] 상호 개정: test-taxonomy 계약의 `FE-GATE-006` row "Fixture 본문 owner" 에 `browser-security(FE-OC-019 슬라이스)` 추가 — **2026-07-21 완료**(D8)
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- frontend code가 아직 없다(hub §1.2). 본 branch의 모든 항목은 `planned` blueprint이며, 경로·rule 이름 등 hub가 근거하지 않는 detail은 `UNSUPPORTED_IMPL_DECISION`으로 표시했다.
|
||||
- 핵심 관점: 본 branch는 새 registry를 만들지 않고, 이미 owner가 있는 5개 표면(bundle·storage·telemetry·auth·supply-chain)의 *security 불변식*을 fixture로 집행하는 cross-cutting 계약이다. registry schema를 재정의하면 owner 경계를 침범한다(§15.5 R3).
|
||||
- CSP directive 값은 배포 환경 header owner 소유 → 본 branch는 "bundle이 strict CSP를 깨지 않는가"만 검증한다.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 대안과 함께 기록. 각 결정 근거는 위 Sources를 가리킨다. 모든 결정은 `planned`(코드 evidence 없음).
|
||||
|
||||
- 2026-07-19: **D1 secret은 browser 표면에 미포함** — bundle을 public artifact로 간주하고 obfuscation으로 secret을 보호할 수 있다고 가정하지 않는다. env registry가 `SECRET`/`PASSWORD`/`PRIVATE_KEY`/`TOKEN` 이름 key를 build·runtime 모두에서 거부하고, secret scan을 source와 built asset(`dist/`) 모두에 돌린다. / 이유: browser에 도달한 값은 복원 가능(hub §13.2)이므로 예방이 유일한 통제. / 대안: 값이 browser 가시이나 민감한 endpoint류는 `public-sensitive`로 분류(§5.4) — secret 아님. / 근거: hub §6.1·§13.1·§13.2.
|
||||
- 2026-07-19: **D2 untrusted HTML/dynamic code 실행 기본 금지** — `dangerouslySetInnerHTML`·`eval`·dynamic code(`new Function`)·untrusted script URL을 prohibited import/API lint rule로 막는다. 불가피한 HTML rendering은 sanitizer owner·allowlist·malicious fixture·CSP interaction evidence를 요구한다. / 이유: injection이 XSS의 1차 진입점이고, CSP는 second layer일 뿐 primary가 아니다(`OWASP-CSP-C4`). / 대안: 4종 evidence를 갖춘 예외 rendering 경로만 허용. / 근거: hub §13.2 + `OWASP-CSP-C2`·`C3`.
|
||||
- 2026-07-19: **D3 strict-CSP 호환은 frontend, header 값은 header owner** — bundle과 그 의존성이 `'unsafe-inline'`/`'unsafe-eval'`을 요구하지 않도록 유지하고, 선언된 header 정책(CSP/HSTS/frame/referrer)이 실제 hosting 응답과 일치하는지 verification fixture로 확인한다. directive 값 자체는 header owner 소유. / 이유: CSP는 server response header이며(`OWASP-CSP-C1`) HSTS도 opt-in header 결정(`OWASP-HSTS-C1`)이라 값은 배포 계층 소유. / 대안: 불가피한 inline이 필요하면 nonce/hash는 header owner가 관리(본 branch 범위 밖). / 근거: hub §13.2 + `OWASP-CSP-C1`·`C4` + `OWASP-HSTS-C1`.
|
||||
- 2026-07-19: **D4 token/secret은 browser storage 금지(contributes `FE-OC-013`)** — token/secret/PII의 browser storage 저장을 금지하고 token-key 등록 시도가 실패하는 security fixture를 소유한다. classification schema는 storage-registry branch 소유. / 이유: 단일 XSS로 storage 전체 탈취 가능(`OWASP-HTML5-C2`), storage 객체는 trusted가 아님(`OWASP-HTML5-C3`). / 대안: 외부 auth owner가 storage를 반드시 써야 하면 별도 threat model + owner evidence(§6.1) — skeleton default 아님. / 근거: hub §5.5·§13.2 + `OWASP-HTML5-C1`~`C3`.
|
||||
- 2026-07-19: **D5 telemetry/error에 token·PII·raw payload 미포함(contributes `FE-OC-014`·`FE-OC-008`)** — forbidden attribute(token·email·raw URL/query/body·storage value·stack)가 telemetry event나 normalized failure에 새지 않는지 검사하는 security scan fixture를 소유한다. redaction allowlist와 transport-boundary 강제는 observability branch 소유. / 이유: 외부 trust zone data는 untrusted이며 민감정보는 제거해야 한다(`OWASP-LOG-C1`·`C3`). / 대안: N/A(항상 금지, 분기 없음). / 근거: hub §11.1·§5.8·§8.1 + `OWASP-LOG-C1`·`C3`.
|
||||
- 2026-07-19: **D6 source map production 미배포** — production public path에 source map을 기본 배포하지 않는다. / 이유: source map은 최소화된 소스·주석·경로를 재노출해 secret/logic leak 표면을 넓힌다(D1과 연속). / 대안: 디버깅 필요 시 authenticated 경로 또는 error-tracking backend에만 업로드(공개 아님) — 예외 결정. / 근거: hub §13.2("source map은 production public path에 기본 배포하지 않는다").
|
||||
- 2026-07-19: **D7 security gate 집계 + negative fixture 필수(owns `FE-OC-019`, contributes `FE-OC-020`)** — 5개 fixture family(secret·CSP/header·HTML-injection·storage·telemetry)를 `FE-GATE-013`으로 집계하고 각 family는 최소 1개 deliberately-failing negative fixture를 갖는다(rule 존재 확인만으로는 `locally-verified` 불가, §15.2). / 이유: hub 수용 질문 8 "위반 시 어떤 test가 실패하는가"에 답해야 `documented-only`를 넘는다(§2.2). / 대안: N/A. / 근거: hub §15.1·§15.2·§2.2.
|
||||
|
||||
- 2026-07-20: **D8 `FE-GATE-006`의 `FE-OC-019` 슬라이스는 본 branch가 소유(contributes `FE-OC-020`)** — hub §15.1의 component gate row가 Covered FE-OC에 `FE-OC-019`를 명시하므로, static lint(`FE-GATE-002`)로는 관측 불가능한 *render 시점* injection 불변식을 component-level fixture로 본 branch가 소유한다. 2종: (a) 예외 sanitizer rendering 경로의 **malicious fixture**(hub §13.2가 예외 승인 조건으로 요구하는 4종 evidence 중 하나), (b) 렌더된 subtree에 inline `<script>` 노드·inline event-handler attribute·`javascript:` URL이 존재하지 않음을 확인하는 assertion. / 이유: lint는 소스에 없는 sink(런타임 문자열 조립·서드파티 컴포넌트 경유)를 못 잡고, hub §15.2는 "rule 존재 확인"을 evidence로 인정하지 않는다. / 대안: component gate가 async/render/keyboard 전용이라 보고 위임 — 채택하지 않음. 위임하면 hub가 요구한 `FE-OC-019` 커버리지의 owner가 공백이 되고, 당시 test-taxonomy 계약의 `FE-GATE-006` row는 fixture 본문 owner로 `async-ui-state / render-recovery`만 등재해 security를 배제하고 있었고, 그대로 두면 이 슬라이스를 아무도 갖지 않게 된다. (2026-07-21 에 그 row 에 `browser-security(FE-OC-019 슬라이스)` 가 등재돼 해소됐다.) / 근거: hub §15.1(`FE-GATE-006` Covered FE-OC)·§13.2·§15.2.
|
||||
- 2026-07-20: **D9 security header의 declared-vs-actual 검증의 gate 귀속** — 초판은 `FE-GATE-019`에 위임했으나 당시 그 row 는 pass condition 이 Cache-Control/content-type 으로 한정되고 Covered FE-OC 도 `FE-OC-016` 하나뿐이라 실제로는 어느 gate 에도 착지하지 않았다. 그래서 `FE-GATE-013`에 잠정 배치하고 hub 개정을 권고했다. / **2026-07-21 확정**: 권고한 두 안 중 (a)가 채택돼 hub §15.1 `FE-GATE-019`의 Covered FE-OC 에 `FE-OC-019`가 추가되고 pass condition 이 security header 까지 확장됐다(`FE-GATE-019@2`, Owner 는 release-cache). 근거: `FE-GATE-013`은 artifact 를 스캔하는 gate 이고 여기서 필요한 것은 실제 HTTP 응답의 declared-vs-actual 대조로 `FE-GATE-019`와 같은 메커니즘·같은 증거 형식이다. directive *값*은 여전히 header owner 소유. / 근거: hub §15.1(`FE-GATE-019@2` row)·§2.1.1·§13.2 + `OWASP-CSP-C1`·`OWASP-HSTS-C1`.
|
||||
- 2026-07-20: **D10 CSP 호환 fixture는 no-unsafe 정본 test baseline에서 실행, negative fixture는 금지 rule 1개당 1개** — (a) production directive 값이 header owner 미확정이어도 test가 실행 가능하도록, `'unsafe-inline'`·`'unsafe-eval'`이 없는 **최소 test baseline CSP**를 본 branch가 정본으로 고정하고 compatibility fixture는 이 baseline 하에서 CSP violation 0을 관측한다(production 값과 별개의 test 전용 상수). (b) §2의 금지 API 3종은 서로 다른 rule이 잡으므로 family당 1개가 아니라 **rule당 1개**의 고의 실패 fixture를 둔다. / 이유: (a) 값이 위임되었다는 이유로 "CSP violation 0"을 측정 불가로 남기면 claim이 영구 `needs-confirmation`이 된다. (b) hub §15.2의 "gate당 최소 1개"는 하한이며, 1개만 두면 나머지 2개 rule은 존재만 확인된 상태 = §15.2가 evidence로 불인정하는 상태다. / 대안: (b) family당 1개로 축소 — fixture 3개 유지비는 줄지만 미검증 rule 2개가 남아 채택하지 않음. / 근거: hub §15.2·§13.2 + `OWASP-CSP-C2`·`OWASP-CSP-C3`.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> `Decision ID`는 이 branch-note 안에서 안정적으로 유지한다. `Supporting Claims`는 official-doc의 Claim ID 또는 hub의 §/`FE-OC`/`FE-D` reference.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | secret은 bundle·env·HTML에 미포함; env registry name-based 거부 + source/`dist/` secret scan (`FE-OC-019`, contributes `FE-OC-018`) | client-only public bundle인 한 항상 예방 통제 / 값이 browser 가시이나 민감한 endpoint류면 secret이 아니라 `public-sensitive` 분류(§5.4)로 다룸 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §6.1(name reject)·§13.2(public artifact)·§13.1(secret scan) · FE-D024 | `project-decision` | scanner 도구·threshold가 `deferred`(§13.1)라 false-negative 가능성 미검증; 값 분류(`public-sensitive` vs secret) 경계 판정 |
|
||||
| D2 | untrusted HTML/dynamic code 실행 기본 금지 (`dangerouslySetInnerHTML`·`eval`·`new Function`·untrusted script URL) | default는 항상 금지 / 불가피한 HTML rendering은 sanitizer owner+allowlist+malicious fixture+CSP interaction evidence 4종을 갖춘 예외 경로만 허용(hub §13.2) | [[raw/official-docs/owasp-content-security-policy-cheat-sheet]] OWASP-CSP-C2·OWASP-CSP-C3 · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §13.2 | `official-doc + project-decision` | lint rule/plugin이 dynamic sink(문자열 template→DOM)를 실제로 포착하는지 미검증; 예외 rendering 경로 발생 시 4종 evidence 강제 누락 위험 |
|
||||
| D3 | bundle의 strict-CSP 호환(`unsafe-inline`/`unsafe-eval` 미의존) + header 정책 verification; directive 값은 header owner 소유 | frontend는 항상 no-unsafe 유지 / 불가피한 inline 필요 시 nonce/hash는 hosting header owner가 관리(본 branch 범위 밖) | [[raw/official-docs/owasp-content-security-policy-cheat-sheet]] OWASP-CSP-C1·OWASP-CSP-C4 · [[raw/official-docs/owasp-hsts-cheat-sheet]] OWASP-HSTS-C1 · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §13.2 | `official-doc + project-decision` | production directive 값은 header owner 의존(테스트는 D10의 no-unsafe baseline으로 분리); 선언-vs-실제 검증 gate 귀속은 `FE-GATE-019@2` 로 확정(D9); 의존성 중 eval 사용 lib이 CSP를 깰 위험 |
|
||||
| D4 | token/secret/PII의 browser storage 저장 금지 + token-key 등록 실패 fixture (contributes `FE-OC-013`·`FE-OC-010`) | skeleton default는 항상 금지 / 외부 auth owner가 storage를 반드시 써야 하면 별도 threat model + owner evidence(§6.1)일 때만 예외 | [[raw/official-docs/owasp-html5-storage-xss-spa]] OWASP-HTML5-C1·OWASP-HTML5-C2·OWASP-HTML5-C3 · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §5.5·§13.2 | `official-doc + project-decision` | classification schema는 storage-registry 소유 → fixture 중복/누락 조율 필요(공동 집행 경계) |
|
||||
| D5 | telemetry/normalized failure에 token·PII·raw URL/query/body·storage value·stack 미포함 fixture (contributes `FE-OC-014`·`FE-OC-008`) | 분기 없음 — 항상 forbidden. 신규 attribute는 low-cardinality+non-PII 검토 통과 시에만 registry 추가(observability 소유) | [[raw/official-docs/owasp-logging-cheat-sheet]] OWASP-LOG-C1·OWASP-LOG-C3 · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §11.1·§5.8·§8.1 | `official-doc + project-decision` | redaction 강제는 transport boundary(observability adapter)에서 일어남 → 본 branch fixture는 leak 관측만, 강제 위치는 위임 |
|
||||
| D6 | production public path에 source map 미배포 | 기본 미배포 / 디버깅 필요 시 authenticated 경로·error-tracking backend 업로드(공개 아님)만 예외 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §13.2(source map 미배포) | `project-decision` | build 도구 flag로 강제하는 구체 메커니즘 미명세(§구현 가이드 5 UNSUPPORTED_IMPL) |
|
||||
| D7 | 5개 security fixture family를 `FE-GATE-013`으로 집계 + 각 family 최소 1 negative fixture (owns `FE-OC-019`, contributes `FE-OC-020`) | 분기 없음 — negative fixture 없는 rule은 evidence로 불인정(§15.2) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §15.1(`FE-GATE-013`)·§15.2·§2.2(질문 8) | `project-decision` | test taxonomy/artifact retention은 `FE-OC-020` 소유 → gate 배선은 test branch와 조율 |
|
||||
| D8 | `FE-GATE-006`(component)의 `FE-OC-019` 슬라이스 fixture 본문 소유 — 예외 sanitizer 경로 malicious fixture + rendered subtree의 inline `<script>`/inline handler/`javascript:` URL 부재 assertion (owns `FE-OC-019`, contributes `FE-OC-020`) | 분기 없음 — hub §15.1이 component gate의 Covered FE-OC에 `FE-OC-019`를 명시하는 한 본 branch 소유 / 위임하려면 hub §15.1에서 `FE-GATE-006`의 `FE-OC-019` 커버리지를 제거하는 개정이 선행돼야 함 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §15.1(`FE-GATE-006` Covered FE-OC = `FE-OC-019` 포함)·§13.2(prohibited API)·§15.2(negative fixture 요건) | `project-decision` | test-taxonomy 계약의 `FE-GATE-006` row 에 `browser-security(FE-OC-019 슬라이스)` 가 2026-07-21 에 등재돼 두 노트의 owner 표기가 일치한다. 이후 그 row 가 다시 바뀌면 여기도 함께 갱신해야 한다 |
|
||||
| D9 | security header(CSP/HSTS/frame/referrer)의 declared-vs-actual 검증은 `FE-GATE-019@2` 소유이고, 본 branch 는 검증 대상 header 정책을 공급 (`FE-OC-019`) | hub §15.1 `FE-GATE-019@2` 가 Covered FE-OC 에 `FE-OC-019` 를 포함하는 한 이 배치 유지 / hub 가 그 범위를 되돌리면 gate 귀속 재확정 필요 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §15.1(`FE-GATE-019@2` — Covered FE-OC 에 `FE-OC-019` 포함, pass condition 이 security header 포함)·§2.1.1(revision 2)·§13.2 · [[raw/official-docs/owasp-content-security-policy-cheat-sheet]] OWASP-CSP-C1 · [[raw/official-docs/owasp-hsts-cheat-sheet]] OWASP-HSTS-C1 | `project-decision + official-doc` | gate 는 release-cache 소유이므로 fixture 실행 시점·artifact 형식은 그 branch 와 맞춰야 함 |
|
||||
| D10 | CSP compatibility fixture는 본 branch가 고정한 **no-unsafe test baseline CSP** 하에서 실행; 금지 API는 family당이 아니라 **rule당 1개**의 고의 실패 fixture (`FE-OC-019`, contributes `FE-OC-020`) | 분기 없음 — production 값 확정 여부와 무관하게 test는 baseline으로 실행 / production 값이 확정되면 baseline은 유지하고 실제 값 대조는 D9 fixture가 별도 담당 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §15.2("rule 존재만 확인한 결과는 `locally-verified` 증거로 부족")·§13.2 · [[raw/official-docs/owasp-content-security-policy-cheat-sheet]] OWASP-CSP-C2·OWASP-CSP-C3 | `project-decision + official-doc` | baseline directive 집합 자체는 hub 미명명(§구현 가이드 3 `UNSUPPORTED_IMPL_DECISION`); baseline이 production 값보다 느슨하면 통과해도 실제 환경에서 깨질 수 있음 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> `planned` blueprint. 경로는 hub §4.6(Planned directory blueprint)·§5.1(registry owner map)에서 인용했으나 repository가 아직 없어 전체가 `planned`다. 3-rule(R1 Trace / R2 UNSUPPORTED_IMPL / R3 no OUT_OF_BRANCH_SCOPE) 준수.
|
||||
|
||||
### 1. Secret 배제 강제 (bundle·env·HTML)
|
||||
|
||||
> **Trace**: D1 — `FE-OC-019` + hub §6.1·§13.1·§13.2. env name-based 거부 규칙과 secret scan을 결합한다.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: secret scanner 도구명·정규식 패턴·severity threshold는 hub §13.1에서 `deferred` → 미명세. trade-off: 도구를 지금 고정하면 supply-chain branch(`FE-OC-018`)의 도구 선택과 충돌 → 도구 중립적으로 "source+built asset 스캔이 credential 패턴에 실패"라는 *계약*만 고정.
|
||||
|
||||
| 강제 지점 | 규칙 | 근거 |
|
||||
|---|---|---|
|
||||
| env registry 등록 시 | key 이름에 `SECRET`/`PASSWORD`/`PRIVATE_KEY`/`TOKEN` 포함 → build·runtime 모두 거부 | hub §6.1 |
|
||||
| build-time public | `BUILD_ID`·`COMMIT_SHA`·`ROUTER_BASE_PATH` 등 compiler/asset identity 값만 `VITE_*` 허용 | hub §6.1·§5.4 |
|
||||
| secret scan 대상 | source tree + `dist/`(built asset) 모두 | hub §13.1(secret scan: source + built asset) |
|
||||
| 값 분류 | browser 가시이나 민감한 endpoint류(`API_BASE_URL`·`TELEMETRY_ENDPOINT`)는 `public-sensitive` — 로그·telemetry에 원문 미기록, secret 아님 | hub §5.4 |
|
||||
|
||||
### 2. dynamic code 금지 lint
|
||||
|
||||
> **Trace**: D2·D10 — `FE-OC-019` + hub §13.2 + §15.2 + `OWASP-CSP-C2`·`OWASP-CSP-C3`. prohibited import/API 카탈로그 + 예외 경로 조건 + fixture 단위.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 구체 lint rule id/plugin(예: ESLint `react/no-danger`, `no-eval`, custom no-restricted-syntax)·`FE-GATE-002` 배선 형식은 hub가 명명하지 않음. trade-off: rule id를 지금 못박으면 test-taxonomy(`FE-OC-020`)의 lint 도구 선택을 침범 → "이 API/import가 금지되고 negative fixture가 실패한다"는 계약만 고정.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: fixture 단위를 "family당 1개"가 아니라 **"금지 rule당 1개"**로 강화(D10 (b))한 것은 hub 미명시 — hub §15.2는 *gate당* 최소 1개만 요구한다. trade-off: fixture 3개는 유지비가 늘지만, 1개만 두면 나머지 2개 rule은 "존재만 확인"된 상태로 남아 §15.2가 evidence로 불인정하는 구간에 들어간다 → 유지비를 택함.
|
||||
|
||||
**fixture 단위 답 (D10 (b))**: family당 1개로는 부족하다. 아래 3행은 각각 *다른 lint rule*이 잡으므로 **행당 1개씩, 총 3개의 고의 실패 negative fixture**를 둔다.
|
||||
|
||||
| 금지 대상 | 성격 | 전용 negative fixture(고의 실패) | 예외 조건 |
|
||||
|---|---|---|---|
|
||||
| `dangerouslySetInnerHTML` | prohibited API (default) | `presentation`이 untrusted 문자열을 `dangerouslySetInnerHTML`로 렌더 → lint 실패 | sanitizer owner + allowlist + malicious fixture + CSP interaction evidence 4종 |
|
||||
| `eval` / `new Function` / dynamic code | prohibited (default) | 모듈이 문자열을 `eval`/`new Function`으로 실행 → lint 실패 | 예외 없음(skeleton) |
|
||||
| untrusted script URL 주입 | prohibited (default) | 런타임 값으로 `<script src>`/`javascript:` URL 조립 → lint 실패 | 예외 없음(skeleton) |
|
||||
|
||||
- 3개 fixture 모두 `FE-GATE-002`(lint) 기여 → `FE-GATE-013` 집계. static lint로 관측 불가능한 *render 시점* 위반은 §6(`FE-GATE-006`)이 담당한다.
|
||||
|
||||
### 3. Strict-CSP 호환 + security header verification (production 값만 위임)
|
||||
|
||||
> **Trace**: D3·D9·D10 — `FE-OC-019` + hub §13.2 + §15.1(`FE-GATE-013`·`FE-GATE-019` row) + `OWASP-CSP-C1`·`OWASP-CSP-C4`·`OWASP-HSTS-C1`.
|
||||
>
|
||||
> - **R3 OUT_OF_BRANCH_SCOPE**: CSP/HSTS/frame/referrer의 **production directive 값**·max-age·preload는 hosting/backend header owner 소유 → 여기 명세하지 않는다.
|
||||
> - **범위 정정(D9) — 2026-07-21 확정**: 이전 판은 이 검증을 `FE-GATE-013`(security)에 *잠정* 배치했다. 그런데 `FE-GATE-013` 은 artifact 를 스캔하는 gate(secret·vulnerability·license·dependency review)이고, 여기서 필요한 것은 **실제 HTTP 응답의 declared-vs-actual 대조**로 `FE-GATE-019`(hosting header)와 같은 메커니즘·같은 증거 형식이다. 그래서 hub §15.1 `FE-GATE-019` 의 Covered FE-OC 에 `FE-OC-019` 를 추가하고 pass condition 을 security header 까지 넓혔다(`FE-GATE-019@2`, Owner 는 그대로 [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]]). 본 branch 는 gate 를 소유하지 않고 **검증 대상 header 정책을 공급**한다.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 아래 no-unsafe test baseline의 구체 directive 집합은 hub가 명명하지 않음(D10 (a)). trade-off: baseline을 느슨하게 잡으면 통과해도 실제 production 정책에서 깨지고, 과도하게 조이면 존재하지 않는 위반으로 개발을 막는다 → `'unsafe-inline'`/`'unsafe-eval'` 부재라는 *불변식*을 만족하는 최소 집합으로 잡고, production 값 확정 시 대조는 D9 fixture가 별도 담당.
|
||||
|
||||
**no-unsafe 정본 test baseline (D10 (a))** — production 값과 무관하게 compatibility fixture가 실행되는 test 전용 상수. `'unsafe-inline'`·`'unsafe-eval'` 미포함이 이 baseline의 불변식이다.
|
||||
|
||||
```text
|
||||
default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:;
|
||||
connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
|
||||
```
|
||||
|
||||
| frontend가 소유(assert) | header owner가 소유(값만 위임) |
|
||||
|---|---|
|
||||
| bundle·의존성이 `unsafe-inline`/`unsafe-eval` 미의존(inline `<script>`·inline handler·`eval` 없음) | `Content-Security-Policy` production directive 집합 값 |
|
||||
| 위 baseline 하 sample route에서 CSP violation 0 관측(compatibility fixture) — hosting owner 확정 전에도 실행 가능 | HSTS `max-age`·`includeSubDomains`·`preload` 채택 여부 |
|
||||
| 선언된 security header 정책 == 실제 hosting 응답인지 verification(`pnpm verify:hosting-headers`(security-header 축)) — `FE-GATE-019@2` 에 배치 확정(2026-07-21) | frame policy·referrer policy 값 |
|
||||
|
||||
**hub 개정 (D9) — 반영 완료(2026-07-21)**: 권고했던 두 안 중 (a)가 채택됐다. hub §15.1 `FE-GATE-019` 의 Covered FE-OC 에 `FE-OC-019` 가 추가되고 pass condition 이 security header 까지 확장됐으며, hub §2.1.1 의 `FE-GATE-019` revision 이 2 로 올라갔다. 이 gate 를 pin 한 문서는 revision 이 낡아 자동으로 잡힌다.
|
||||
|
||||
### 4. Cross-cutting security fixture + gate 집계 (storage·telemetry)
|
||||
|
||||
> **Trace**: D4·D5·D7·D8·D9·D10 — `FE-OC-019`(owns) + contributes `FE-OC-013`·`FE-OC-014`·`FE-OC-020` + hub §5.5·§11.1·§5.8·§15.1·§15.2.
|
||||
>
|
||||
> - **R3 OUT_OF_BRANCH_SCOPE**: storage classification schema는 [[raw/branch-notes/feature-frontend-storage-registry-contract]] `FE-OC-013`, redaction allowlist는 [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] `FE-OC-014` 소유. 본 표는 *security 위반 관측 fixture*만 소유한다.
|
||||
|
||||
| Fixture family | Negative fixture(고의 실패) | 기대 결과 | 집계 gate | schema owner(위임) |
|
||||
|---|---|---|---|---|
|
||||
| secret | env에 `*_TOKEN` key 등록 / `dist/`에 credential 패턴 | 거부·scan 실패 | `FE-GATE-013` | env(`FE-OC-004`)·supply-chain(`FE-OC-018`) |
|
||||
| HTML-injection (static, rule당 1개 = 3개) | ①`dangerouslySetInnerHTML` 렌더 ②`eval`/`new Function` ③런타임 script URL 조립 | 각 lint rule 실패 | `FE-GATE-002`→`FE-GATE-013` | 본 branch (D10 (b)) |
|
||||
| HTML-injection (render 시점) | 예외 sanitizer 경로에 malicious payload 주입 / subtree에 inline `<script>`·inline handler 존재 | component test 실패 | `FE-GATE-006` | 본 branch (D8) |
|
||||
| CSP 호환 | no-unsafe baseline(§3) 하 sample route 렌더 시 CSP violation 발생 | compatibility fixture 실패 | `FE-GATE-013` | 본 branch (D10 (a)) |
|
||||
| security header 선언-vs-실제 | 선언 CSP/HSTS/frame/referrer 정책 != 실제 hosting 응답 | `pnpm verify:hosting-headers`(security-header 축) 실패 | `FE-GATE-019@2` (Owner = release-cache) | 값만 header owner, 정책 공급은 본 branch(D9) |
|
||||
| storage | token/secret key 등록 시도 | 등록 거부 | `FE-GATE-013` | storage(`FE-OC-013`) |
|
||||
| telemetry | event에 raw URL/query/token 포함 | 전송 거부·scan 실패 | `FE-GATE-013` | observability(`FE-OC-014`) |
|
||||
|
||||
### 5. Source map production 정책
|
||||
|
||||
> **Trace**: D6 — `FE-OC-019` + hub §13.2.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 강제 메커니즘(예: Vite `build.sourcemap=false` vs post-build strip vs authenticated 경로 업로드)을 hub가 명명하지 않음. trade-off: `build.sourcemap=false`가 가장 단순하나 error-tracking symbolication을 포기 → 미결. "production public path에 `.map`이 존재하지 않는다"는 fixture 계약만 고정.
|
||||
|
||||
- fixture: production build 산출물의 public path에 `*.map`이 노출되지 않음.
|
||||
|
||||
### 6. `FE-GATE-006`(component gate)의 `FE-OC-019` 슬라이스
|
||||
|
||||
> **Trace**: D8 — `FE-OC-019`(owns) + contributes `FE-OC-020` + hub §15.1(`FE-GATE-006` Covered FE-OC에 `FE-OC-019` 포함)·§13.2(prohibited API)·§15.2(negative fixture 요건).
|
||||
>
|
||||
> - **소유 근거**: hub §15.1이 component gate의 Covered FE-OC로 `FE-OC-019`를 명시하므로 이 커버리지에는 owner가 있어야 한다. static lint(§2)는 *소스에 나타난* prohibited API만 잡고, 런타임 문자열 조립·서드파티 컴포넌트 경유로 생기는 sink는 렌더 결과에서만 관측된다 → component-level fixture가 필요하다.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: component test runner·assertion helper 형태(예: RTL `container.querySelector` 기반 subtree 검사)는 hub 미명명이며 test stack은 test-taxonomy(`FE-OC-020`) 소유. trade-off: helper를 지금 고정하면 그 branch의 도구 선택을 침범 → "렌더된 subtree에 금지 산출물이 없어야 하고, malicious payload는 fixture를 실패시킨다"는 계약만 고정.
|
||||
|
||||
| Component fixture | 대상 | 기대 결과 |
|
||||
|---|---|---|
|
||||
| malicious payload (positive-guard) | hub §13.2 예외 조건으로 승인된 sanitizer rendering 경로 | 알려진 XSS payload가 실행 가능한 노드로 남지 않음. sanitizer 우회 시 fixture 실패 |
|
||||
| prohibited 산출물 부재 assertion | sample route/컴포넌트의 렌더된 subtree | inline `<script>` 노드·inline event-handler attribute·`javascript:` URL 0건 |
|
||||
|
||||
- 예외 rendering 경로가 하나도 없는 skeleton 초기 상태에서는 첫 fixture가 "예외 경로 부재"를 확인하는 형태로 축약될 수 있으나, 예외가 승인되는 즉시 malicious fixture는 hub §13.2의 4종 evidence 요건상 필수다.
|
||||
- **상호 개정 완료(2026-07-21)**: [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] (`FE-OC-020`)의 `FE-GATE-006` row 는 fixture 본문 owner 를 `async-ui-state / render-recovery`로만 등재하고 있었다. 그 열(gate → fixture 본문 owner)의 owner 는 test-taxonomy 이므로 그쪽 표에 `browser-security(FE-OC-019 슬라이스)` 를 추가했다.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **예외 HTML rendering**: 불가피한 HTML 렌더가 필요할 때 sanitizer/allowlist/malicious fixture/CSP evidence 4종 중 하나라도 빠지면 → 예외 승인 거부(default 금지 유지). sanitizer 자체가 우회되면 malicious fixture가 실패로 잡아야 함.
|
||||
- **secret scan false-negative**: 도구·패턴이 `deferred`(§13.1)라 새 credential 형태를 못 잡을 수 있음 → 기대 동작: 도구 확정 시 known-secret 양성 fixture로 탐지율 검증.
|
||||
- **CSP runtime 위반**: 의존성 lib이 `eval`을 쓰면 strict CSP에서 런타임 깨짐 → 기대 동작: compatibility fixture가 CSP violation을 관측해 실패.
|
||||
- **storage fallback 노출**: quota 초과·private mode에서 값이 memory-only로 fallback될 때도 sensitive 값은 애초에 storage 대상이 아니어야 함(D4) → fallback이 sensitive 값을 노출하지 않음.
|
||||
- **telemetry 신규 attribute leak**: registry에 새 attribute 추가 시 PII/token이 섞이면 → forbidden-attribute scan fixture가 실패로 잡음.
|
||||
- **다른 계약 의존** (sibling의 local Decision ID + contract ID로 링크):
|
||||
- [[raw/branch-notes/feature-frontend-auth-session-integration-contract]] `D6`(`FE-OC-010`) — token 저장 금지·`AUTH_TOKEN` forbidden을 이미 결정. 본 branch는 그 위반을 security fixture로 관측(공동 집행). 그 계약이 바뀌면 storage/telemetry fixture 조정.
|
||||
- [[raw/branch-notes/feature-frontend-storage-registry-contract]] `D6`(`FE-OC-013`) — `sensitive-forbidden` classification schema 소유. 본 branch는 token-key 등록 실패 fixture만 제공.
|
||||
- [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] `D2`(`FE-OC-014`) — redaction allowlist·transport-boundary 강제 소유. 본 branch는 forbidden-attribute leak fixture 기여.
|
||||
- [[raw/branch-notes/feature-frontend-build-bundle-supply-chain-contract]] `D5`·`D6`(`FE-OC-018`) — `D5`가 security gate 묶음(secret+vuln+license+dependency review)이되 scanner·severity threshold는 `deferred`(hub §13.1)로 고정, `D6`이 secret scan을 source + built asset 양쪽으로 확정. 도구 자체는 **아직 pinned decision 없음(hub §13.1 `deferred`)** → 본 branch의 secret scan은 도구 중립 계약만.
|
||||
- [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] `D1`·`D6`(`FE-OC-016`) — `D1` 이 surface별 cache policy, `D6` 이 `FE-GATE-019@2` 의 security-header 축 검증 메커니즘 소유. 그 gate 는 2026-07-21 에 security header 까지 범위가 넓어졌으므로(Covered FE-OC 에 `FE-OC-019` 포함) security header 검증도 그쪽 소유이고, 본 branch 는 검증 대상 정책을 공급한다(D9). production directive **값**에 대해서는 hosting provider 미확정으로 **pinned decision 없음**.
|
||||
- [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] `D6`·`D3`(`FE-OC-020`) — `D6`이 gate → test level / fixture KIND taxonomy 소유(gate→FE-OC coverage 매핑 자체는 hub §15.1 소유), `D3`이 gate당 ≥1 고의 실패 negative fixture 원칙 소유. 본 branch의 security fixture는 그 taxonomy에 plug-in하고 어느 표도 복제·재정의하지 않는다. `FE-GATE-006` fixture 본문 owner 목록은 그쪽 `D6` 소관이며 2026-07-21 에 `browser-security` 가 추가됐다.
|
||||
- [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] `D2`(`FE-OC-008`) — normalized failure가 §8.1 safe 필드만 담고 raw body·token·authorization header·full URL/query·stack·storage value를 drop하도록 소유. 본 branch의 telemetry leak fixture와 경계 공유.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| env·bundle·HTML 어디에도 secret이 새지 않는다 | 코드·CI 없음; scanner 도구 `deferred` | env name-reject unit fixture + source/`dist/` secret scan에 known-secret 양성 fixture 삽입 후 실패 확인 | `needs-confirmation` |
|
||||
| `dangerouslySetInnerHTML`·`eval`·dynamic code가 CI에서 차단된다 | lint rule/plugin 미확정 | 금지 rule **3종 각각**의 negative fixture(고의 위반)가 대응 lint rule 실패로 잡히는지 실행(D10 (b)) | `needs-confirmation` |
|
||||
| bundle이 `unsafe-inline`/`unsafe-eval` 없는 strict CSP에서 동작한다 | 의존성 중 eval 사용 lib 여부 미확인 | §3의 **no-unsafe 정본 test baseline** 하 sample route e2e에서 CSP violation 0 관측 — hosting owner의 production 값 확정을 기다리지 않고 실행 가능(D10 (a)) | `needs-confirmation` |
|
||||
| 렌더된 subtree에 inline `<script>`/inline handler/`javascript:` URL이 없고 sanitizer 예외 경로가 malicious payload를 실행하지 않는다 | component test stack 미확정, 예외 경로 미존재 | `FE-GATE-006` component fixture 2종(§6) 실행 → malicious payload 실패·prohibited 산출물 0건 확인(D8) | `needs-confirmation` |
|
||||
| 선언된 security header 정책이 실제 hosting 응답과 일치한다 | header 값은 외부 owner이고 실제 응답이 아직 미측정 (gate 귀속은 `FE-GATE-019@2` 로 확정 — D9) | `pnpm verify:hosting-headers`(security-header 축)로 HTML/config/manifest 응답의 CSP/HSTS/frame/referrer 대조 → `FE-GATE-019@2` | `planned` |
|
||||
| token/secret key 등록 시도가 실패한다 | storage registry 구현 없음 | storage token-key 등록 negative fixture(§15.2) 실행 → 거부 확인 | `needs-confirmation` |
|
||||
| telemetry event/normalized failure에 forbidden attribute가 없다 | redaction 강제 위치는 observability adapter | forbidden-attribute(raw URL/query/token) 포함 event negative fixture(§15.2) → 전송/scan 실패 확인 | `needs-confirmation` |
|
||||
| production public path에 source map이 없다 | build 미실행 | production build 후 public path에 `*.map` 부재 fixture | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
- 스캐폴딩 단계: `/coverage` 실행 전 수동 행을 만들지 않는다.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- **위임처 gate 범위 과대 가정(해소 완료)** — 초판은 security header의 declared-vs-actual 검증을 `FE-GATE-019`에 위임했으나, 당시 hub §15.1의 해당 row는 Cache-Control/content-type 전용이고 Covered FE-OC도 `FE-OC-016` 하나뿐이었다. 위임처 노트 본문에도 CSP/HSTS 언급이 0건이라 실제로는 어느 gate에도 착지하지 않는 상태였다. D9로 `FE-GATE-013`에 잠정 배치한 뒤 hub 개정을 권고했고, **2026-07-21 에 그 권고가 채택돼 `FE-GATE-019@2` 로 확정됐다**.
|
||||
- **`FE-GATE-006`의 `FE-OC-019` 커버리지 무주공산(해소 완료)** — hub §15.1은 component gate가 `FE-OC-019`를 덮도록 요구하지만, 초판은 이를 TODO의 "기여" 한 줄로만 언급하고 Decision·fixture를 두지 않았다. 위임 후보인 test-taxonomy 계약의 `FE-GATE-006` row도 fixture 본문 owner에 security를 넣지 않아 owner가 공백이었다. D8 + §구현 가이드 6으로 본 branch가 소유를 확정했고, **2026-07-21 에 test-taxonomy 의 해당 row 도 갱신됐다**.
|
||||
- 교훈: 위임 문장을 쓸 때 위임처 *노트*의 존재만이 아니라 hub gate row의 **pass condition과 Covered FE-OC 문자열**까지 확인해야 한다. gate 이름이 그럴듯하다고 범위가 넓은 것은 아니다.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: received-delegations:start -->
|
||||
### 수신한 위임
|
||||
|
||||
| Delegation Ref | From | Concern | Status |
|
||||
|---|---|---|---|
|
||||
| `DELEG-FE-001@1` | [[raw/branch-notes/feature-tailwind-design-token-styling-contract]] | `fe.deleg.dynamic-class-lint` | accepted |
|
||||
<!-- GENERATED: received-delegations:end -->
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
| `FE-GATE-002@1` | [[raw/branch-notes/feature-frontend-architecture-enforcement-lint-contract]] | 금지된 API·import 가 남아 있으면 merge 를 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-GATE-006@1` | [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] | component 레벨이 실패하면 merge 를 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-GATE-013@1` | [[raw/branch-notes/feature-frontend-build-bundle-supply-chain-contract]] | secret·vulnerability·license·dependency review 정책 위반이면 merge·release 를 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-GATE-019@2` | [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] | 선언한 Cache-Control·content-type·security header 와 실제 응답이 다르면 release 를 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-OC-004@1` | [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] | build-time, runtime-public, secret config를 MUST 분리하고 boot 전에 runtime config를 검증 | import 참조로 적용 |
|
||||
| `FE-OC-008@1` | [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] | 모든 failure는 stable frontend error kind로 MUST 정규화하고 raw body·stack을 UI에 노출하면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-010@1` | [[raw/branch-notes/feature-frontend-auth-session-integration-contract]] | skeleton은 session state를 소비하되 token lifecycle을 MUST 소유하지 않음 | import 참조로 적용 |
|
||||
| `FE-OC-013@1` | [[raw/branch-notes/feature-frontend-storage-registry-contract]] | storage key는 namespace·version·classification을 MUST 가지며 token/secret 저장을 금지 | import 참조로 적용 |
|
||||
| `FE-OC-014@1` | [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] | telemetry는 best-effort이며 render·API success를 차단하면 안 되고 PII·token을 전송하면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-016@1` | [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] | HTML, asset, runtime config, release manifest cache policy를 MUST 구분 | import 참조로 적용 |
|
||||
| `FE-OC-018@1` | [[raw/branch-notes/feature-frontend-build-bundle-supply-chain-contract]] | frozen lockfile, dependency review, secret scan, SBOM 또는 dependency inventory를 release gate에 MUST 포함 | import 참조로 적용 |
|
||||
| `FE-OC-020@1` | [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] | gate 종류별 책임·fixture·artifact를 분리하고 실패를 warning으로 낮추면 안 됨 | import 참조로 적용 |
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/owasp-content-security-policy-cheat-sheet]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
- 없음 — 자식 자료는 생성 후 controller가 parent Cluster와 함께 등록한다.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- 없음 — daily note는 이 작업에서 수정하지 않는다.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크: 없음
|
||||
- 리뷰 메모: 없음
|
||||
- 머지 결과 / 배포 환경: `planned`
|
||||
- **wiki 추출 대상** (verified만): 없음
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned): 현재 전체
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-frontend-operational-contract/branch-notes/feature-frontend-build-bundle-supply-chain-contract.md
|
||||
@@ -0,0 +1,345 @@
|
||||
---
|
||||
title: branch / feature-frontend-build-bundle-supply-chain-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-020
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-frontend-operational-contract
|
||||
work_item: WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-020
|
||||
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-BUILD-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-SUPPLY-CHAIN-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-001, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-017]
|
||||
contract_packet: 1
|
||||
branch: feature-frontend-build-bundle-supply-chain-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton-frontend, ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-frontend-operational-contract]
|
||||
tags: [branch, ca-skeleton, frontend, ci-cd, build-tooling, supply-chain]
|
||||
created: 2026-07-18
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 3dccf22904aa14c909b778cb3242f70be7a11dce4256b8fdaed40f5a1ad36035
|
||||
imports: [ART-FE-001@1, FE-GATE-001@1, FE-OC-003@1, FE-OC-016@1, FE-OC-019@1, FE-OC-020@1, FE-OC-021@1]
|
||||
---
|
||||
|
||||
# branch: feature-frontend-build-bundle-supply-chain-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — TODO·결정·진행 기록. 구현 결과는 검증 뒤 `/ingest`로만 추출한다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/project-notes/ca-skeleton-frontend-operational-contract]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: frozen build·inventory·scan·bundle report가 CI artifact로 생성된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-BUILD-001@1` | Vite client-only SPA를 build baseline으로 사용한다 | clean production build와 bundle report gate에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-SUPPLY-CHAIN-001@1` | dependency lock, secret scan, vulnerability scan, license inventory, dependency review를 merge/release gate로 분리한다 | install·security·inventory·dependency review gate에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
이 브랜치는 project-wide 계약 `FE-OC-018` (frozen lockfile · dependency review · secret scan · SBOM/dependency inventory 를 release gate 에 MUST 포함) 를 *되묻지 않고 구현 착수 가능한* 명세로 내린다. 근거 결정은 hub 의 [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D024 (supply-chain control 을 **분리된** merge/release gate 로 운영 — control 열거는 hub 소유) 이며, hub §13.1 (supply-chain minimums), §12.1 (release artifact set), §14.3 (planned commands), §15.1 의 `FE-GATE-001`/`FE-GATE-011`/`FE-GATE-012`/`FE-GATE-013` 를 구현 blueprint 로 삼는다. 부수적으로 `FE-OC-003` (frozen install), `FE-OC-016` (release artifact), `FE-OC-019` (secret-in-bundle 경계), `FE-OC-020` (gate 분리), `FE-OC-021` (bundle NFR) 에 기여한다. **현 시점 frontend 코드/CI 는 존재하지 않으므로 아래 모든 항목은 `planned` 등급이다** — "구현했다" 가 아니라 "이렇게 구현될 것이다" 의 사전 명세다.
|
||||
|
||||
- 이슈: 없음 (스캐폴딩 단계)
|
||||
- PR: 없음
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- **frozen-lockfile install gate** — lockfile drift 없이 재현 가능한 install 을 merge+release 차단 gate 로 강제 (`FE-GATE-001`, `FE-OC-018`).
|
||||
- **clean production build gate** — hashed immutable static asset + build-manifest 산출을 merge+release 차단 gate 로 강제 (`FE-GATE-011`, `FE-OC-018`).
|
||||
- **bundle report gate** — release 시 app + lazy chunk 크기를 machine-readable report 로 산출 (`FE-GATE-012`, `FE-OC-018`). (수치 threshold 자체는 아래 Out of scope.)
|
||||
- **security gate** — secret scan · vulnerability scan · license inventory · **dependency review** 를 하나의 차단 gate 로 묶어 SARIF/inventory/dependency-diff 산출 (`FE-GATE-013`, `FE-OC-018`).
|
||||
- **dependency review (dependency diff)** — base↔head lockfile 을 direct + transitive 까지 diff 해 변경 집합을 산출하고, review 기록 없는 high-risk change 를 차단 gate 로 처리 (hub §13.1 `dependency review` row, `FE-OC-018`). 본 브랜치가 `FE-OC-018` 소유자이며 sibling [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] 가 이 관심사를 본 브랜치로 명시 위임했다.
|
||||
- **release supply-chain artifact set** — dependency inventory · build-manifest(provenance metadata) · checksums 를 release artifact 로 명세 (hub §12.1, §13.1).
|
||||
- **vulnerability suppression policy** — reason·owner·expiry·affected package·compensating control 을 강제하고 expiry 경과 suppression 을 gate failure 로 처리 (hub §13.3).
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외 — 다른 owner 브랜치의 결정 영역. "이건 범위에 없었습니다" 근거.
|
||||
|
||||
- **package manager 선택 및 lockfile 형식 확정** — [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] (`FE-OC-003`) 소유. 본 브랜치는 그 frozen-install 스크립트를 *소비*만 한다.
|
||||
- **bundle 크기 threshold 수치 (`FE-NFR-001` ≤200 KiB, `FE-NFR-002` ≤120 KiB) 와 측정 context** — [[raw/branch-notes/feature-web-vitals-performance-budget-contract]] (`FE-OC-021`) 소유. 본 브랜치는 report 를 *생성*하고 pass/fail 판정은 위임.
|
||||
- **browser security boundary 규칙 (CSP·`dangerouslySetInnerHTML` 금지·frame/referrer policy)** — [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] (`FE-OC-019`) 소유. 본 브랜치는 secret scan *실행 gate* 만 담당.
|
||||
- **release manifest schema · cache policy · rollback drill** — [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] (`FE-OC-016`) 소유. 본 브랜치 artifact 는 그 release set 에 *공급*될 뿐이다.
|
||||
- **CI gate orchestration · 실행 순서 · artifact retention 정책** — 배선은 [[raw/branch-notes/feature-frontend-ci-quality-gates-contract]](어느 `FE-OC-*` 의 owner 도 아닌 기여 브랜치), taxonomy 는 [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] (`FE-OC-020` owner) 소유. 본 브랜치는 gate 를 *제공*, 배선은 위임.
|
||||
- **scanner 도구·severity threshold·SBOM 형식 확정** — hub §13.1 이 `deferred` 로 명시 (organization security policy 부재). 임의 확정 금지.
|
||||
- **runtime config artifact (`dist/config.json`, `dist/config/runtime-config.schema.json`)** — hub §12.1 release artifact set 에 함께 나열되지만 소유는 [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] (`FE-OC-004`). 본 브랜치의 release artifact 책임은 supply-chain 3종(inventory·build-manifest·checksums)뿐이며 config 산출/검증은 위임한다.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/supply-chain-slsa-provenance-framework]] | `SLSA-FW-C1`/`C4`/`C5` — provenance = build platform/process/top-level input 을 기술하는 verifiable 정보. release build-manifest(buildId/commit) 를 provenance 최소선으로 두는 D7 의 공식 근거. signed attestation(L2+) 은 미채택 표지. |
|
||||
| [[raw/official-docs/vite-build-tool-official]] | `VITE-C2` — production build 가 optimized 정적 자산을 산출 (D3 clean build gate 근거). `VITE-C3`/`C4`/`C5` — `import.meta.env` build-time 정적 치환 + `VITE_` prefix 만 클라이언트 노출 + 비밀값 금지 (D6 built-asset secret scan 경계 근거). |
|
||||
|
||||
> 나머지 세부 (gate 분리·§13.1 control·§12.1 artifact·suppression policy) 의 근거는 외부 문서가 아니라 **hub 자체의 project decision** 이므로 Evidence Map 에서 [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D024 로 인용한다 (hub §3.2 가 `accepted-documented-only` 로 명시).
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] frozen-lockfile install gate (`FE-GATE-001`) 명세 — command·drift 검출·install log artifact — 등급: `planned`
|
||||
- [ ] clean production build gate (`FE-GATE-011`) 명세 — hashed asset + build-manifest 산출 — 등급: `planned`
|
||||
- [ ] bundle report gate (`FE-GATE-012`) 명세 — machine-readable bundle report (threshold 판정은 `FE-OC-021` 위임) — 등급: `planned`
|
||||
- [ ] security gate (`FE-GATE-013`) 명세 — secret/vuln/license/dependency-review fixture + SARIF/inventory/dependency-diff — 등급: `planned`
|
||||
- [ ] dependency review 명세 — base↔head lockfile direct+transitive diff · high-risk 분류축 · review 기록 · dependency diff report — 등급: `planned`
|
||||
- [ ] release supply-chain artifact set (dependency inventory·checksums·build-manifest) + provenance metadata 정의 — 등급: `planned`
|
||||
- [ ] vulnerability suppression policy (reason·owner·expiry·affected package·compensating control) 정의 — 등급: `planned`
|
||||
- [ ] scanner/SBOM/threshold `deferred` 항목의 revisit trigger (organization security policy) 문서화 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- scanner 도구명·severity threshold·SBOM 형식(CycloneDX/SPDX)·suppression expiry SLA 는 hub §13.1/§13.3 이 `deferred` 로 명시 — 특정 도구를 썼다고 주장하지 않는다.
|
||||
- 모든 command(`pnpm install --frozen-lockfile`·`pnpm build`·`pnpm check:bundle`·`pnpm scan:security`)와 artifact 경로는 hub §14.3/§12.1 의 planned contract 이며 실행/검증되지 않았다 (`PLANNED_NOT_EXECUTED`).
|
||||
- **hub 내부 불일치 발견 → 해소 완료**: hub §2.1 의 `FE-OC-018` 과 §13.1 은 `dependency review` 를 요구하는데 §3.2 `FE-D024` 본문과 §15.1 `FE-GATE-013` required fixtures 는 그것을 누락하고 있었다. 본 브랜치가 상위 계약을 따라 D9 로 편입했고, **hub 도 정정됐다** — 현재 `FE-D024` 는 dependency review 를 포함해 열거하고(lock 은 `FE-GATE-001`, 나머지 4개는 `FE-GATE-013`), §15.1 `FE-GATE-013` required fixtures 도 `secret/vulnerability/license/dependency-review` 다.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 근거는 Sources 또는 hub project decision 을 가리킨다. FE-D### 인용은 hub 경로에 붙인다 (Evidence Map 과 mirror).
|
||||
|
||||
- 2026-07-18: supply-chain control 을 **하나의 monolithic gate 가 아니라** dependency lock/secret/vuln/license 로 분리된 merge/release gate 로 운영 / 이유: 실패 지점을 구분해 blocking scope 를 정확히 하기 위함 / 검토한 대안: 단일 "security gate" 통합 / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D024 (D1).
|
||||
- 2026-07-18: frozen-lockfile install 을 merge+release 차단 gate 로 강제, drift = FAIL / 이유: 재현 가능한 install / 검토한 대안: 비-frozen install 후 사후 검증 / 근거: hub §13.1 install row + `FE-GATE-001` (D2).
|
||||
- 2026-07-18: production build gate 는 hashed immutable asset + build-manifest 산출 / 이유: 정적 호스팅 배포 + release 식별 / 검토한 대안: unhashed asset / 근거: `raw/official-docs/vite-build-tool-official.md#VITE-C2` + hub §12.1 (D3).
|
||||
- 2026-07-18: bundle report 는 생성하되 수치 threshold 판정은 `FE-OC-021` 에 위임 / 이유: NFR context/threshold 소유권 분리 / 근거: hub §14.2 (`FE-NFR-001`/`FE-NFR-002`) + `FE-GATE-012` (D4).
|
||||
- 2026-07-18: security gate 는 secret+vuln+license 를 묶고 scanner/threshold 는 `deferred` / 이유: org policy 부재로 도구 확정이 불가 / 검토한 대안: 지금 특정 scanner 확정 / 근거: hub §13.1 (D5).
|
||||
- 2026-07-18: secret scan 은 source 뿐 아니라 **built asset** 까지 검사 / 이유: browser bundle 은 public artifact 이고 `VITE_` 값은 build-time 에 정적 inline 되므로 / 근거: `raw/official-docs/vite-build-tool-official.md#VITE-C4`, `#VITE-C5` + hub §13.2 (D6).
|
||||
- 2026-07-18: release 는 dependency inventory + build-manifest(buildId/commit provenance) + checksums 를 포함, mismatch = 차단; signed SLSA attestation 은 미채택 / 이유: provenance 최소선 확보 / 근거: `raw/official-docs/supply-chain-slsa-provenance-framework.md#SLSA-FW-C1`, `#SLSA-FW-C4` + hub §12.1 (D7).
|
||||
- 2026-07-18: vulnerability suppression 은 reason·owner·expiry·affected package·compensating control 을 요구, expiry 경과 = gate failure / 이유: 무기한 예외 방지 / 근거: hub §13.3 (D8).
|
||||
- 2026-07-20: dependency review 를 `FE-GATE-013` 의 **네 번째 control** 로 편입 (별도 gate ID 신설 대신 기존 gate 범위 확장). base↔head lockfile 을 direct+transitive 까지 diff 하고, review 기록 없는 high-risk change = 차단, 산출물은 dependency diff report / 이유: hub §2.1 `FE-OC-018` 과 §13.1 이 dependency review 를 요구하는데 소유 gate 가 없었다. 새 `FE-GATE-027` 을 만들면 hub §15.1 의 "26개 row" registry 와 §15.3 promotion formula 를 동시에 고쳐야 하는데 그건 hub 소유 변경이라 본 브랜치 권한 밖이다. `FE-GATE-013` 은 이미 `FE-OC-018` 을 covered 하고 blocking scope 도 merge+release 로 dependency review 요구와 일치한다 / 검토한 대안: (a) 신규 gate ID 신설 — hub registry 변경 필요로 기각, (b) `FE-GATE-001`(lockfile) 에 합류 — 그쪽은 drift 유무만 보는 결정론 검사라 "변경 내용의 위험도 심사"라는 성격이 다르고 실패 의미가 섞임 / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D024 + hub §2.1 `FE-OC-018` + hub §13.1 dependency review row (D9).
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | supply-chain control 을 **분리된** merge·release gate 로 운영 (`FE-OC-018`) — control 집합은 hub `FE-D024` 소유이며 dependency review 는 D9 로 편입됐다 | 이 분리가 project 최소선; organization security policy 가 더 강한 gate 를 지정하면 강화·재분할 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D024 | `project-decision` | repo/CI 부재 → gate 배선 UNVERIFIED |
|
||||
| D2 | frozen-lockfile install 을 merge+release 차단 gate, drift = FAIL (`FE-GATE-001`, `FE-OC-018`) | frozen install 은 항상 필수; package manager/lockfile *형식*은 `FE-OC-003` (bootstrap) 소유 → 그쪽 변경 시 command 만 교체 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D024 (hub §13.1 install row) | `project-decision` | pnpm default 는 bootstrap 결정; org 가 npm/yarn 강제 시 install command 재확정 |
|
||||
| D3 | production build gate = hashed immutable asset + build-manifest 산출 (`FE-GATE-011`, `FE-OC-016`/`FE-OC-018`) | Vite client-only SPA build baseline 이 유지되는 한; SSR/edge rendering 이 requirement 가 되면 build 출력 형태 재검토 | `raw/official-docs/vite-build-tool-official.md#VITE-C2` | `official-doc` | build 미존재 → artifact 이름/경로는 planned |
|
||||
| D4 | bundle report 는 release gate 로 *생성*, 수치 threshold 판정은 위임 (`FE-GATE-012`, `FE-OC-018`/`FE-OC-021`) | report 는 항상 release 에 산출; `FE-NFR-001`(≤200 KiB)/`FE-NFR-002`(≤120 KiB) 값과 `FE-NFR-C04` context 는 web-vitals 브랜치가 소유·재검토 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D024 (hub §14.2) | `project-decision` | report↔threshold 소유 경계; threshold 변경은 `FE-OC-021` 에서 |
|
||||
| D5 | security gate = secret+vuln+license 묶음(2026-07-20 D9 로 dependency review 가 4번째 control 로 편입), scanner/severity threshold 는 `deferred` (`FE-GATE-013`, `FE-OC-018`) | 이 구성이 최소선; repository/organization policy 가 생기면 특정 scanner·threshold 확정 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D024 (hub §13.1) | `conditional-default` | 지금 scanner 명시 = 날조 → deferred 유지 |
|
||||
| D6 | secret scan 은 source + **built asset** 모두 검사 (`FE-OC-018`/`FE-OC-019`) | browser bundle 을 public artifact 로 간주하는 한 항상; boundary 규칙(CSP·HTML injection) 자체는 `FE-OC-019` 소유 | `raw/official-docs/vite-build-tool-official.md#VITE-C4`, `#VITE-C5` (hub §13.2) | `official-doc` | scan 이 로그·debug 등 *모든* 유출 경로를 증명하진 못함 (VITE-C4 does-not-prove) |
|
||||
| D7 | release 는 dependency inventory + build-manifest(buildId/commit provenance) + checksums 포함, mismatch 차단; signed attestation 미채택 (`FE-OC-018`, hub §12.1) | 최소선 = inventory + build metadata 를 provenance 로; org 가 더 강한 provenance 요구 시 signed SLSA(L2+) 채택 | `raw/official-docs/supply-chain-slsa-provenance-framework.md#SLSA-FW-C1`, `#SLSA-FW-C4`, `#SLSA-FW-C5` | `official-doc` | L1 provenance 는 "trivial to forge" (SLSA-FW-C1) — signing/SBOM 형식 deferred |
|
||||
| D8 | vulnerability suppression 은 reason·owner·expiry·affected package·compensating control 필수, expiry 경과 = gate failure (`FE-OC-018`, hub §13.3) | fix 즉시 불가한 accepted vuln 에 적용; org 가 더 엄격한 SLA 정의 시 재검토 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D024 (hub §13.3) | `project-decision` | expiry SLA 수치 미정 → deferred |
|
||||
| D9 | dependency review 를 `FE-GATE-013` 의 4번째 control 로 편입: base↔head lockfile direct+transitive diff, review 기록 없는 high-risk change = 차단, dependency diff report 산출 (`FE-OC-018`) | hub §15.1 gate registry 가 26 row 로 고정된 동안은 기존 gate 확장; hub 가 registry+promotion formula 를 개정해 전용 gate 를 신설하면 그쪽으로 이관 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D024 (hub §2.1 `FE-OC-018` 문구 + hub §13.1 dependency review row) | `project-decision` | hub `FE-D024` 가 dependency review 를 누락하던 불일치는 hub 정정으로 해소됨(현재 5개 control 열거, `FE-GATE-013` fixtures 도 dependency-review 포함). "high-risk" 판정축·diff 도구는 hub 미지정 → `UNSUPPORTED_IMPL_DECISION` |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> `planned` blueprint — frontend 코드/CI 는 아직 없다. 경로·command 는 hub §4.6/§12.1/§14.3 의 planned contract 에서 도출한 anchor 이며 repository 생성 시 확정된다. CLAUDE.md §15.5 R1(Trace)/R2(UNSUPPORTED_IMPL_DECISION)/R3(OUT_OF_BRANCH_SCOPE) 준수.
|
||||
|
||||
### 1. Gate topology — merge vs release 분리
|
||||
|
||||
> **Trace**: D1 ([[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D024), D2/D3/D4/D5/D9 — hub §15.1 gate registry + `FE-OC-018`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — blocking scope·covered contract·artifact 는 hub §15.1 이 직접 명시. `FE-GATE-013` 의 control 4번째(dependency review) 편입은 D9 근거이며 gate ID 신설이 아니므로 hub registry row 수(26)를 바꾸지 않는다.
|
||||
|
||||
> gate 의 **blocking scope · Covered FE-OC · evidence artifact 는 hub §15.1 이 소유**한다. 아래 표는 그 열을 옮겨 적지 않고, 본 브랜치가 각 gate 안에서 *무엇을 명세하는지*(control) 만 담는다. 값이 필요하면 [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §15.1 을 본다.
|
||||
|
||||
| Gate ID | Control (본 브랜치 명세) |
|
||||
|---|---|
|
||||
| `FE-GATE-001` | frozen install drift |
|
||||
| `FE-GATE-011` | clean production build |
|
||||
| `FE-GATE-012` | bundle report 생성 (판정은 `FE-OC-021` owner 위임) |
|
||||
| `FE-GATE-013` | ① secret scan ② vulnerability scan ③ license inventory ④ **dependency review** (D9) |
|
||||
|
||||
- 실패는 warning 으로 낮추지 않는다 (`FE-OC-020`). 각 gate 는 최소 1개의 deliberately-failing negative fixture 로 "실제 동작"을 증명해야 한다 (hub §15.2). `FE-GATE-013` 은 4개 control 각각이 독립 negative fixture 를 갖는다 (§5).
|
||||
- gate 는 4개지만 control 은 7개(install·build·bundle·secret·vuln·license·dependency review)다. D1 의 "분리" 원칙은 gate ID 개수가 아니라 **실패 지점이 artifact 단위로 구분 가능한가**로 만족시킨다 — `FE-GATE-013` 내부 4 control 은 서로 다른 artifact(SARIF · license inventory · dependency diff report)로 실패 원인을 구분한다.
|
||||
|
||||
### 2. Frozen-lockfile install gate
|
||||
|
||||
> **Trace**: D2 — hub §13.1 install row + §14.3.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — command·artifact 는 hub §14.3 이 명시. package manager 명(pnpm)은 본 브랜치 결정이 아님 → §OUT_OF_BRANCH_SCOPE 참조.
|
||||
|
||||
- command: `pnpm install --frozen-lockfile` (hub §14.3, `PLANNED_NOT_EXECUTED`).
|
||||
- pass 조건: manifest ↔ lockfile drift 없음, exit 0.
|
||||
- artifact: `artifacts/quality/install.txt` (hub §14.3) / `artifacts/quality/lockfile-check.txt` (hub §13.1).
|
||||
- negative fixture: lockfile drift(수동 편집) → frozen install 이 exit≠0 로 실패해야 함.
|
||||
- **OUT_OF_BRANCH_SCOPE**: package manager 선택·`packageManager` field·`pnpm-lock.yaml` commit 은 [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] (`FE-OC-003`) 소유. 본 gate 는 그 lockfile 을 frozen 으로 *검증*만 한다.
|
||||
|
||||
### 3. Clean production build gate
|
||||
|
||||
> **Trace**: D3 — `raw/official-docs/vite-build-tool-official.md#VITE-C2` + hub §12.1 artifact set.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — build command·artifact·hash 규칙은 hub §12.1/§14.3 + VITE-C2 에서 도출.
|
||||
|
||||
- command: `pnpm build` (hub §14.3).
|
||||
- pass 조건: exit 0 + 기대 artifact 존재.
|
||||
- 산출 artifact (hub §12.1): `dist/index.html`, `dist/assets/<content-hash>.*` (immutable hashed), `dist/release-manifest.json`, `artifacts/release/build-manifest.json`, `artifacts/release/checksums.txt`.
|
||||
- hashed asset 의 immutable cache 정책 자체는 [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] (`FE-OC-016`) 소유 — 여기서는 hash 산출까지만.
|
||||
|
||||
### 4. Bundle report gate
|
||||
|
||||
> **Trace**: D4 — hub §14.3 (`pnpm check:bundle`) + §14.2 (`FE-NFR-001`/`FE-NFR-002`).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (a) bundle 분석 도구(rollup-plugin-visualizer / 자체 스크립트 등)는 hub 가 지정하지 않음 → 도구 선택은 repo 생성 시 결정. trade-off: 지금 도구명을 박으면 날조가 되므로 report *형식*(machine-readable JSON)만 고정하고 도구는 미정. (b) `bundle.json` 의 필드명·구조는 **2026-07-21 해소됨** — hub §2.1.3 이 `ART-FE-002@1` 로 등록하고 `bundle-report.schema.json` 이 정본이다. 아래 §schema 참조.
|
||||
|
||||
- command: `pnpm check:bundle` (hub §14.3).
|
||||
- artifact: `artifacts/performance/bundle.json` (machine-readable, hub §14.3).
|
||||
- 측정 대상: initial JS(app) + 각 lazy route chunk 의 gzip 크기.
|
||||
- pass/fail 판정: `FE-NFR-001` (initial JS gzip ≤ 200 KiB), `FE-NFR-002` (lazy chunk gzip ≤ 120 KiB), context `FE-NFR-C04`.
|
||||
- **schema = `ART-FE-002@1`** (hub §2.1.3 · `harness/source/artifact-schemas/ca-skeleton-frontend/bundle-report.schema.json`). 이전 판은 이 스키마를 "공동 소유라 단독 결정 불가" 로 두고 필드 초안을 여기에 적었는데, 그래서 producer(`runner.node`)와 consumer(snake_case) 가 서로 다른 키 이름을 계약이라 부르는 상태가 됐다. 이제 **Schema Owner 는 본 브랜치 단독**이고 필드 추가·rename 은 스키마 파일을 고쳐 revision 을 올리는 것으로 하며, 소비 branch 는 `imports` pin 이 낡아 자동으로 잡힌다.
|
||||
- `context`/`runner` 는 hub §14.1 의 "context 없는 숫자는 evidence 로 인정하지 않는다" 요구 때문에 required 다 (`FE-NFR-C04`).
|
||||
- `buildId`/`commit` 은 §6 build-manifest(`ART-FE-001@1`)와 동일 값이어야 하며, 이 대조로 report 가 어느 build 의 것인지 식별된다.
|
||||
- **OPEN QUESTION — budget 이 JS-only 인가 CSS 포함인가**: hub §14.2 는 `FE-NFR-001` 을 "initial JS gzip", `FE-NFR-002` 를 "any lazy route chunk gzip" 으로만 정의하고 **CSS 전용 NFR ID 가 없다**. 따라서 현재 계약은 *JS-only 판정*으로 읽는 것이 문언에 충실하다. 본 gate 는 CSS asset 의 gzip 크기도 report 에 **기록은 하되 판정 대상으로 삼지 않는다**. CSS 를 budget 에 포함할지, 별도 NFR ID 를 신설할지는 `FE-OC-021` 소유자와 hub §14.2 개정 사항이다.
|
||||
- **OUT_OF_BRANCH_SCOPE**: 위 threshold 수치·측정 context 정의는 [[raw/branch-notes/feature-web-vitals-performance-budget-contract]] (`FE-OC-021`) 소유. 본 gate 는 report 를 *생성*하고 threshold 를 *소비*한다.
|
||||
|
||||
### 5. Security gate — secret · vulnerability · license · dependency review
|
||||
|
||||
> **Trace**: D5/D6/D8/D9 — hub §13.1 (secret/vuln/license/**dependency review** row) + §13.3 (suppression) + §2.1 `FE-OC-018` + `VITE-C4`/`C5`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (a) scanner 도구(secret: gitleaks/trufflehog?, vuln: npm audit/osv-scanner/trivy?, license: 자체?) 미정, (b) severity threshold(어느 CVSS 등급부터 차단) 미정, (c) suppression expiry SLA(며칠) 미정. **모두 hub §13.1/§13.3 이 `deferred` 로 명시** — 임의 확정 시 날조. trade-off: 지금은 gate *구조·fixture 계약*만 고정하고 도구·수치는 organization security policy 확정 후 채운다.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (d) dependency diff **도구**(GitHub Dependency Review Action / `pnpm why` 기반 자체 스크립트 / osv-scanner diff 등) 미정 — hub §13.1 은 "direct/transitive diff" 라는 *대상*만 규정하고 도구를 지정하지 않는다. trade-off: 도구명을 지금 박으면 날조이므로 **입력(base↔head lockfile)·출력(dependency diff report)·차단 조건**만 고정한다. (e) "high-risk change" 의 **분류축**(아래 R1~R5) 도 hub 미지정 — hub 는 `unreviewed high-risk change` 라는 차단 조건만 준다. trade-off: 분류축이 없으면 gate 가 판정 불가능해 구현 착수가 막히므로, **fail-closed 기본값**(분류 불가/미기록 = high-risk 취급)을 두고 축 목록은 org policy 확정 시 교체 가능한 것으로 표시한다. 축을 좁게 잡으면 위험 변경이 통과하고, 넓게 잡으면 모든 renovate PR 이 수동 리뷰를 요구해 마찰이 커지는 trade-off 를 인지하고 fail-closed 를 택했다.
|
||||
|
||||
- command: `pnpm scan:security` (hub §14.3). **dependency review 도 이 command 안에서 수행한다** — hub §14.3 planned command 표에 dependency-review 전용 script 가 없으므로 새 script 명을 만들면 hub 계약과 어긋난다. script 를 분리하려면 hub §14.3 + §15.1 artifact mapping 을 함께 갱신해야 한다 (hub §14.3 말미 규칙).
|
||||
- artifact: `artifacts/security/scan.sarif` (hub §14.3) + license inventory + `artifacts/release/dependency-inventory.*` (hub §12.1) + dependency diff report (아래).
|
||||
- secret scan (D6): **source + built asset(`dist/`) 모두** 검사. 이유: `VITE_` prefix 값은 build-time 에 정적 inline 되므로(VITE-C3) 유출은 built bundle 에서만 관측될 수 있음(VITE-C4/C5). browser bundle = public artifact (hub §13.2).
|
||||
- vulnerability scan (D5): severity policy 위반이 approved expiry 없이 존재하면 차단 (hub §13.1).
|
||||
- license inventory: denied/unknown license 미해결 시 차단 (hub §13.1).
|
||||
- suppression (D8): 각 suppression 은 reason·owner·expiry·affected package·compensating control 보유; expiry 경과 suppression = gate failure (hub §13.3).
|
||||
|
||||
#### 5.1 Dependency review (control ④)
|
||||
|
||||
> **Trace**: D9 — hub §2.1 `FE-OC-018` (frozen lockfile · **dependency review** · secret scan · SBOM/inventory 를 release gate 에 MUST 포함) + hub §13.1 `dependency review` row (`direct/transitive diff` / `unreviewed high-risk change` / `dependency diff report`).
|
||||
|
||||
- **무엇을 diff 하는가 (입력)**: PR 의 **base commit lockfile ↔ head commit lockfile**. 두 lockfile 을 각각 resolve 해 얻은 *완전한 패키지 집합*(direct + transitive, 즉 lockfile 에 기록된 모든 resolved entry)을 비교한다. manifest(`package.json`) diff 만 보지 않는다 — hub §13.1 이 명시적으로 `direct/transitive` 를 요구하고, transitive 변경은 manifest 에 나타나지 않기 때문이다.
|
||||
- release 시점에는 base = **직전 release 의 lockfile**(release token 기준)로 잡아 release 단위 누적 변경도 같은 방식으로 산출한다.
|
||||
- **변경 분류 (출력 행)**: 각 diff row 는 `{package, from, to, changeKind, depth, riskFlags[], reviewRef}` 를 갖는다.
|
||||
- `changeKind` ∈ `added | removed | version-changed | resolution-changed`(같은 버전인데 resolved URL/integrity 가 바뀐 경우).
|
||||
- `depth` ∈ `direct | transitive`.
|
||||
- **무엇이 "unreviewed high-risk change" 인가 (차단 조건)**: 아래 두 조건을 **동시에** 만족하는 row 가 하나라도 있으면 `FE-GATE-013` FAIL.
|
||||
1. **high-risk 로 분류됨** — 아래 riskFlag 축 중 하나 이상에 해당. (축 목록 자체는 위 `UNSUPPORTED_IMPL_DECISION` (e).)
|
||||
- `R1 new-package` — 이전 lockfile 에 없던 패키지 추가 (direct/transitive 무관; 새 코드가 신뢰 경계에 들어옴).
|
||||
- `R2 install-script` — install/postinstall 등 lifecycle script 를 실행하는 패키지의 추가·변경.
|
||||
- `R3 major-bump` — semver major 상승 (hub §13.3 이 major update 에 `FE-D*` impact check + registry compatibility check 를 별도로 요구하므로 위험 등급이 다르다).
|
||||
- `R4 license-change` — 해당 패키지의 license 식별자가 변경됨 (license inventory control 과 교차).
|
||||
- `R5 known-vuln` — vulnerability scan 이 해당 패키지에 severity policy 위반을 보고함 (vulnerability control 과 교차).
|
||||
- **fail-closed 기본값**: riskFlag 산출에 필요한 metadata(license/lifecycle script/이전 버전)를 확보하지 못해 **분류 자체가 불가능한 row 는 high-risk 로 간주**한다. "정보 부족 = 통과" 는 gate 를 무력화하므로 채택하지 않는다.
|
||||
2. **review 기록이 없음** — 해당 row 에 대응하는 review record(reviewer, 날짜, 대상 package@version, 승인 사유)가 없거나, 기록의 `package@to` 가 실제 diff 와 불일치. review record 는 vulnerability suppression(D8, hub §13.3)과 **별개 트랙**이다: suppression 은 "알려진 취약점을 기한부로 감수", review 는 "이 의존성 변경을 사람이 보았다" 이며 후자는 expiry 를 갖지 않는 대신 **해당 package@version 에만** 유효하다(버전이 다시 바뀌면 재검토 대상).
|
||||
- low-risk row(위 축 어디에도 해당 없음)는 review 없이 통과한다 — 그렇지 않으면 patch 단위 갱신마다 gate 가 막혀 정책이 실질적으로 우회된다.
|
||||
- **evidence artifact (dependency diff report)**: hub §13.1 은 artifact 를 `dependency diff report` 라고만 명명하고 경로를 주지 않는다. 본 브랜치는 `artifacts/security/dependency-diff.json` 을 anchor 로 둔다 — hub §14.3 이 security 계열 artifact 를 `artifacts/security/` 아래 두므로(`scan.sarif`) 그 규약을 따른 것이다.
|
||||
- **UNSUPPORTED_IMPL_DECISION**: 위 파일명·경로는 hub 가 지정하지 않은 명명 결정. trade-off: 경로를 비워두면 CI 배선(`FE-OC-020` 소유자)이 artifact 를 수집할 수 없어 gate 가 성립하지 않으므로, hub 의 기존 디렉터리 규약에서 가장 마찰이 적은 이름을 anchor 로 고정하고 repository 생성 시 확정한다.
|
||||
- report 최소 내용: `{baseRef, headRef, rows[], blocking[]}` — `rows[]` 는 위 diff row 전체, `blocking[]` 은 차단 사유가 된 row 의 부분집합. 통과한 build 도 report 를 남긴다(변경 0건이면 빈 `rows[]`) — 산출 자체가 hub §13.1 의 evidence 요구다.
|
||||
- **negative fixture**: review record 없이 `R1 new-package` 에 해당하는 transitive 의존성을 추가한 fixture 가 `FE-GATE-013` 을 FAIL 시켜야 한다. 대칭으로, 동일 변경에 유효한 review record 를 붙이면 PASS 해야 한다(가짜 PASS 방지).
|
||||
- **OUT_OF_BRANCH_SCOPE**: review record 를 *어디에* 보관할지(PR label / repo 내 파일 / 외부 시스템)와 reviewer 권한 모델은 CI orchestration 영역으로 [[raw/branch-notes/feature-frontend-ci-quality-gates-contract]] 소유. 본 gate 는 "review record 가 조회 가능해야 한다"는 인터페이스 요구만 둔다. lockfile 형식·package manager 는 [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] (`FE-OC-003`) 소유이며 본 control 은 그 lockfile 을 *읽기*만 한다.
|
||||
|
||||
- negative fixture 후보: (i) `VITE_`-var 에 심은 가짜 secret 이 `dist/` 번들에서 탐지되어 실패, (ii) known-vuln 의존성이 approved expiry 없이 차단, (iii) 만료된 suppression 이 실패, (iv) denied license 가 실패, (v) review record 없는 신규 transitive 의존성 추가가 실패 (§5.1).
|
||||
|
||||
### 6. Release supply-chain artifact set & provenance
|
||||
|
||||
> **Trace**: D7 — hub §12.1 artifact set + §13.1 provenance/SBOM row + `SLSA-FW-C1`/`C4`/`C5`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: SBOM 형식(CycloneDX vs SPDX)과 signed attestation(in-toto/DSSE, SLSA L2+) 채택 여부 미정 → hub §13.1 이 "tool selected by owner" 로 `deferred`. trade-off: 최소선(dependency inventory + build metadata)만 고정하고 signing 은 org 요구 시.
|
||||
|
||||
- release artifact (hub §12.1): `artifacts/release/dependency-inventory.*`, `artifacts/release/build-manifest.json`, `artifacts/release/checksums.txt`.
|
||||
- provenance 최소선: build-manifest 에 buildId/commit 을 기록해 build platform/process/top-level input 을 기술(SLSA-FW-C1, C4). buildId/commit mismatch = 차단 (hub §13.1 provenance row).
|
||||
- dependency inventory 는 SLSA `resolvedDependencies` 개념(build time 필요 artifact 의 collection, SLSA-FW-C5)에 대응하되 "완전성"을 주장하지 않는다("if known", SLSA-FW-C5 does-not-prove).
|
||||
- **미채택 표지**: SLSA L1 provenance 는 "trivial to forge"(SLSA-FW-C1) — signed/authenticated attestation 은 별도 결정이며 현재 채택하지 않는다.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- lockfile drift → frozen install exit≠0 → `FE-GATE-001` FAIL.
|
||||
- production build 실패 또는 기대 artifact 누락 → `FE-GATE-011` FAIL.
|
||||
- bundle threshold 초과 → `FE-GATE-012` FAIL (판정 값은 `FE-OC-021` 소유).
|
||||
- built asset 에서 secret 패턴 hit → `FE-GATE-013` FAIL.
|
||||
- severity threshold 위반이 approved expiry 없이 존재 / 만료된 suppression → `FE-GATE-013` FAIL.
|
||||
- denied/unknown license 미해결 → `FE-GATE-013` FAIL.
|
||||
- review record 없는 high-risk dependency 변경(신규 패키지·install script·major bump·license 변경·known-vuln) → `FE-GATE-013` FAIL (§5.1).
|
||||
- dependency diff row 의 riskFlag 를 분류할 metadata 부재 → fail-closed 로 high-risk 취급 → review 없으면 `FE-GATE-013` FAIL (§5.1).
|
||||
- base lockfile 을 확정할 수 없음(base ref 소실·shallow clone) → dependency review 를 "통과" 로 처리하지 않고 gate ERROR 로 처리해 차단 (fail-closed).
|
||||
- release inventory 누락 또는 buildId/commit mismatch → release 차단 (hub §12.1/§13.1).
|
||||
- **다른 계약 의존** (sibling 링크는 `FE-OC-###` 로만 표기):
|
||||
- [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] (`FE-OC-003`) 에 의존 — package manager·lockfile·frozen-install 스크립트를 consume. 그 계약이 바뀌면 §2 install command 영향.
|
||||
- [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] (`FE-OC-020`) 에 의존 — 본 브랜치 gate 가 CI gate taxonomy/artifact 분리 규칙에 편입.
|
||||
- [[raw/branch-notes/feature-web-vitals-performance-budget-contract]] (`FE-OC-021`) 에 기여/의존 — bundle threshold 값·NFR context 를 그쪽에서 consume. **`artifacts/performance/bundle.json` 스키마의 Schema Owner 는 본 브랜치**(hub §2.1.3 `ART-FE-002@1`) — 그쪽은 소비자로서 `imports` 로 pin 한다.
|
||||
- [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] (`FE-OC-019`) 에 기여 — secret-in-bundle·untrusted-HTML 경계 규칙은 그쪽 소유, 본 브랜치는 scan gate 실행.
|
||||
- [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] (`FE-OC-016`) 에 기여 — 본 브랜치 artifact(inventory·manifest·checksums)가 release set 에 공급.
|
||||
- [[raw/branch-notes/feature-frontend-ci-quality-gates-contract]] 에 의존 — gate orchestration·artifact retention 은 그쪽 소유(그 브랜치는 `FE-OC-*` owner 가 아니다).
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| frozen install 이 lockfile drift 를 실제로 차단한다 | CI/repo 부재 | drift fixture 로 `pnpm install --frozen-lockfile` 이 exit≠0 → `artifacts/quality/install.txt` | `needs-confirmation` |
|
||||
| production build 가 기대 artifact set + hashed asset 을 산출한다 | build 미실행 | build gate fixture 로 `pnpm build` exit 0 + `artifacts/release/build-manifest.json` 존재 확인 | `needs-confirmation` |
|
||||
| bundle report 가 initial JS + lazy chunk gzip 을 machine-readable 로 기록한다 | 도구 미정 | `pnpm check:bundle` → `artifacts/performance/bundle.json` 스키마 검증 (threshold 판정은 `FE-OC-021`) | `needs-confirmation` |
|
||||
| secret scan 이 source 뿐 아니라 built asset 의 secret 을 탐지한다 | 코드/scanner 미정 | negative fixture: `VITE_`-var 의 가짜 secret 이 `dist/` 번들에서 탐지되어 `FE-GATE-013` FAIL | `needs-confirmation` |
|
||||
| vulnerability gate 가 known-vuln(무-expiry)과 만료된 suppression 을 차단한다 | scanner/threshold `deferred` | negative fixture 로 `pnpm scan:security` 가 두 경우 FAIL → `artifacts/security/scan.sarif` | `needs-confirmation` |
|
||||
| license inventory 가 denied/unknown license 를 flag 한다 | 도구 미정 | fixture: denied license 의존성이 security gate FAIL | `needs-confirmation` |
|
||||
| dependency review 가 base↔head lockfile 의 **transitive** 변경까지 잡아낸다 | diff 도구 미정, lockfile 미존재 | fixture: manifest 는 그대로 두고 transitive 만 바뀐 lockfile 로 `pnpm scan:security` → `artifacts/security/dependency-diff.json` 의 `rows[]` 에 해당 row 존재 | `needs-confirmation` |
|
||||
| review record 없는 high-risk 변경이 실제로 차단되고, record 를 붙이면 통과한다 | review record 저장 위치가 `FE-OC-020` 소유로 미확정 | negative/positive 쌍 fixture: 신규 transitive 패키지 추가 → record 없으면 FAIL, 있으면 PASS | `needs-confirmation` |
|
||||
| `bundle.json` 이 소비자(`FE-OC-021`)가 `FE-NFR-001`/`FE-NFR-002` 를 판정하기에 충분한 필드를 담는다 | 스키마(`ART-FE-002@1`)는 확정됐으나 실제 report 생성이 미실행 | 스키마대로 report 생성 후 web-vitals 판정 로직이 추가 필드 요구 없이 동작하는지 대조 | `needs-confirmation` |
|
||||
| release 가 dependency inventory + build-manifest(buildId/commit) + checksums 를 포함하고 mismatch 를 차단한다 | pipeline 부재 | release verification fixture 로 buildId/commit mismatch 차단 확인 | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
- 스캐폴딩 단계: `/coverage` 실행 전 수동 행을 만들지 않는다.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 없음 — 스캐폴딩 단계.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: artifact-imports:start -->
|
||||
### 가져온 artifact 계약
|
||||
|
||||
| Artifact Ref | Owner | Producer | Schema Ref |
|
||||
|---|---|---|---|
|
||||
| `ART-FE-001@1` | [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] | [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] | `harness/source/artifact-schemas/ca-skeleton-frontend/build-manifest.schema.json` |
|
||||
<!-- GENERATED: artifact-imports:end -->
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
| `FE-GATE-001@1` | [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] | lockfile 이 manifest 와 어긋나면 merge·release 를 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-OC-003@1` | [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] | package manager, engine, lockfile, source language, checkJs command를 한 곳에서 MUST 고정 | import 참조로 적용 |
|
||||
| `FE-OC-016@1` | [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] | HTML, asset, runtime config, release manifest cache policy를 MUST 구분 | import 참조로 적용 |
|
||||
| `FE-OC-019@1` | [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] | browser bundle에 secret을 넣지 않고 untrusted HTML injection을 기본 금지 | import 참조로 적용 |
|
||||
| `FE-OC-020@1` | [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] | gate 종류별 책임·fixture·artifact를 분리하고 실패를 warning으로 낮추면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-021@1` | [[raw/branch-notes/feature-web-vitals-performance-budget-contract]] | NFR은 device/network/cache/build context와 함께 MUST 측정 | import 참조로 적용 |
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
- 없음 — 자식 자료는 생성 후 controller가 parent Cluster와 함께 등록한다.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- 없음 — daily note는 이 작업에서 수정하지 않는다.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크: 없음
|
||||
- 리뷰 메모: 없음
|
||||
- 머지 결과 / 배포 환경: `planned`
|
||||
- **wiki 추출 대상** (verified만): 없음
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned): 현재 전체
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-frontend-operational-contract/branch-notes/feature-frontend-ci-quality-gates-contract.md
|
||||
@@ -0,0 +1,307 @@
|
||||
---
|
||||
title: branch / feature-frontend-ci-quality-gates-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-027
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-frontend-operational-contract
|
||||
work_item: WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-027
|
||||
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-TEST-STACK-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-SUPPLY-CHAIN-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-DEPLOYMENT-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-017, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-020, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-024, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-025, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-026]
|
||||
contract_packet: 1
|
||||
branch: feature-frontend-ci-quality-gates-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton-frontend, ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-frontend-operational-contract]
|
||||
tags: [branch, ca-skeleton, frontend, ci-cd, build-tooling, static-analysis, supply-chain]
|
||||
created: 2026-07-18
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 642f71eb6bed0e706b19f3c814c85a626371ec65a0fc08eef602c9185b13e6dd
|
||||
imports: [FE-GATE-001@1, FE-GATE-002@1, FE-GATE-004@1, FE-GATE-012@1, FE-GATE-014@1, FE-GATE-016@1, FE-GATE-018@1, FE-GATE-021@1, FE-OC-016@1, FE-OC-017@1, FE-OC-018@1, FE-OC-019@1, FE-OC-020@1, FE-OC-021@1, FE-OC-022@1, FE-OC-023@1, FE-OC-025@1]
|
||||
---
|
||||
|
||||
# branch: feature-frontend-ci-quality-gates-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — TODO·결정·진행 기록. 현재는 `/branch-spec` 로 spec 을 채운 `planned` 단계다(frontend 코드 저장소 미생성). 구현 결과는 검증 뒤 `/ingest`로만 추출한다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/project-notes/ca-skeleton-frontend-operational-contract]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: blocking gate가 분리되고 dependency graph와 artifact retention이 검증된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-TEST-STACK-001@1` | test stack default는 Vitest, RTL, MSW, Playwright, axe다 | test gate 결과의 CI stage orchestration에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-SUPPLY-CHAIN-001@1` | dependency lock, secret scan, vulnerability scan, license inventory, dependency review를 merge/release gate로 분리한다 | supply-chain gate의 blocking·artifact retention 배선에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-DEPLOYMENT-001@1` | static release는 immutable release directory와 atomic active pointer로 배포한다 | release·production-promotion stage와 rollback artifact retention에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
이 branch 는 **어느 `FE-OC-*` 의 single owner 도 아니다.** 대신 `FE-OC-020`·`FE-OC-021`·`FE-OC-022`·`FE-OC-023`·`FE-OC-024`·`FE-OC-025` 의 acceptance gate 들을 **하나의 실행 가능한 CI orchestration** 으로 묶는 contribution branch 다([[raw/project-notes/ca-skeleton-frontend-operational-contract]] §20 Branch Decomposition — Primary contract IDs `—`, Measurable completion = "separate blocking gates, dependency graph, artifact retention"). 구체적으로 [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §15.1 의 26-gate acceptance matrix 와 §15.3 promotion formula(MERGE_READY → RELEASE_READY → PROD_PROMOTION_READY → FIELD_SLO_READY)를 CI pipeline 의 **stage dependency graph + blocking-check 배선 + evidence artifact retention 정책** 으로 내린다. gate 의 *정의*(blocking scope·Covered FE-OC·pass condition·evidence artifact)와 promotion formula 는 [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §15.1·§15.3 이 소유하고, gate 별 Owner 는 §2.1.1 이 확정한다. gate → **test level / fixture KIND** taxonomy 와 `artifacts/` 트리 taxonomy 는 [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] (`FE-OC-020`) 가, gate 의 *fixture 본문* 은 각 contract owner 가 소유한다. 이 branch 는 그 gate 들이 **어떤 순서로 / 어떤 blocking 의미로 / 어떤 의존 관계로 실행되고, 그 증거가 어떻게 보관되는지** 만 명세한다. 모든 진술 등급은 `planned` — frontend repository 와 CI 설정이 아직 없다.
|
||||
|
||||
- 이슈: 없음 (repository·CI 미생성)
|
||||
- PR: 없음
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
이 branch 가 소유하는 CI orchestration 레이어(gate 정의가 아니라 gate 의 *실행/배선/보관*):
|
||||
|
||||
- **Gate stage dependency graph** — [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §15.3 promotion formula 를 CI pipeline 의 4 stage(merge / release / prod-promotion / field-SLO)로 매핑하고, downstream stage 가 upstream stage 의 gate 집합 전부 PASS 없이는 실행/승격되지 않는 AND 의존을 배선.
|
||||
- **Blocking-check 배선 + no-downgrade 집행** — §15.1 Blocking scope 열의 각 gate 를 독립 required check 로 wiring 하고, gate 실패를 warning / soft-fail / `continue-on-error` 로 낮추지 못하게 강제(`FE-OC-020` normative summary).
|
||||
- **Evidence artifact retention 정책** — 각 gate 가 §14.3 / §15.1 이 정한 evidence artifact 를 공유 `artifacts/` 트리에 산출하도록 upload/retention 을 배선하고, rollback target(§12.5)·drill record(`FE-GATE-016`/`FE-GATE-021`~`025`)가 승격 감사에 필요한 기간 동안 남도록 retention class 를 정의.
|
||||
- **Gate → CI trigger 매핑** — 각 gate 가 어느 event(merge PR / release / production promotion / field-window)에서 실행되는지의 배선.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외 — 다른 owner 소유. 여기서는 이름만 가리키고 detail 을 재명세하지 않는다(CLAUDE.md §15.5 R3).
|
||||
|
||||
- **Gate 정의와 promotion formula**(blocking scope·Covered FE-OC·pass condition·evidence artifact·tier→gate 집합) → [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §15.1·§15.3, gate 별 Owner 는 §2.1.1.
|
||||
- **Gate taxonomy**(gate → test level / fixture KIND 열거·negative-fixture-per-gate 규칙·`artifacts/` 트리 taxonomy) → [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] (`FE-OC-020`). 이 branch 는 둘 다 *consume* 만 하고 재정의하지 않는다.
|
||||
- **각 gate 의 fixture 본문·pass-condition** → contract owner 위임: build/bundle/security → [[raw/branch-notes/feature-frontend-build-bundle-supply-chain-contract]] (`FE-OC-018`); release-coherence/config-compat/rollback/hosting-header → [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] (`FE-OC-016`, `FE-OC-017`, `FE-OC-019` — hosting-header gate 의 security 축); bundle/lab/field performance → [[raw/branch-notes/feature-web-vitals-performance-budget-contract]] (`FE-OC-021`); runbook drill(`FE-RB-001`~`005`) → [[raw/branch-notes/feature-frontend-operational-runbook-contract]] (`FE-OC-025`); registry diff → [[raw/branch-notes/feature-frontend-contract-registry-governance]] (`FE-OC-022`); compatibility fixture → [[raw/branch-notes/feature-frontend-contract-compatibility-governance]] (`FE-OC-023`); sample-removal → [[raw/branch-notes/feature-sample-feature-slice-contract-fixture]] (`FE-OC-024`); merge-tier test gate 본문 → 각 test/arch owner.
|
||||
- **구체 CI provider workflow syntax + 실제 merge protection / required-check 설정** — provider 미확정([[raw/project-notes/ca-skeleton-frontend-operational-contract]] §14.1 CI runner 미확정, `FE-Q-002`/`FE-Q-003`/`FE-Q-007`/`FE-Q-010`). 이 branch 는 provider-agnostic orchestration contract 만 정의(D6).
|
||||
- **NFR 임계값·gate pass-condition 수치**(timeout 10s / retry ≤2 / bundle KiB / axe 0 / p75 등) → 각 NFR owner. orchestration 은 gate 결과만 소비.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §15.3 promotion formula | D1 stage dependency graph(4 tier AND 의존)의 1차 근거. |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §15.1 gate matrix (Blocking scope · Evidence artifact 열) | D2 blocking-check 배선 + D3 artifact→gate 매핑의 근거(26-row acceptance gate registry). |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-OC-020` normative summary | D2 no-downgrade 불변식("실패를 warning 으로 낮추면 안 됨")의 근거. |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §14.3 planned commands (artifact 열) + §4.6 `artifacts/` blueprint | D3 evidence artifact retention 트리(script→artifact 매핑)의 근거. |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §12.5 rollback invariant + §15.1 `FE-GATE-016`(prior release pair) | D3/D4 retention 하한(rollback target·drill record 가 다음 release 승격까지 생존)의 근거. |
|
||||
| [[raw/official-docs/supply-chain-slsa-provenance-framework]] `SLSA-FW-C6`, `SLSA-FW-C4` | D3 rationale — release/security evidence 는 machine-readable provenance(in-toto attestation = "authenticated, machine-readable statement about a software artifact")이므로 CI 가 retain/traceable 하게 보관해야 함. **범위 한정**: SLSA 는 build provenance *artifact* 의 machine-readability/traceability 만 근거하고, gate ordering·blocking 정책은 근거하지 않음(그건 hub §15.3 project decision). SLSA gate/fixture 본문은 [[raw/branch-notes/feature-frontend-build-bundle-supply-chain-contract]] (`FE-OC-018`) 소유. |
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] §15.3 promotion formula 를 CI 4-stage dependency graph(merge → release → prod-promotion → field-SLO)로 배선 + stage 간 AND gating 명세 — 등급: `planned`
|
||||
- [ ] §15.1 각 gate 를 독립 required check 로 wiring + no-downgrade(`continue-on-error` 금지) 집행 규칙 정의 — 등급: `planned`
|
||||
- [ ] evidence artifact upload + retention class(merge/release/prod-drill) 정의; rollback target·drill record 가 다음 release 승격까지 생존하도록 하한 고정 — 등급: `planned`
|
||||
- [ ] artifact retention **기간 수치**(day/count) 확정 — 등급: `needs-confirmation` (`UNSUPPORTED_DECISION` — hub 미규정, D4)
|
||||
- [ ] provider 선택 후 required-check 이름 + branch-protection 을 이 orchestration contract 에 바인딩 — 등급: `planned` (provider 미정, out of scope)
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- `/branch-spec` self-map 완료(2026-07-19): 이 branch 는 no-primary-owner contribution branch. SSOT = hub §15.1 gate matrix + §15.3 promotion formula + §14.3 artifact 열 + §12.5 rollback invariant. gate 정의(blocking scope·Covered FE-OC·pass condition·evidence artifact)는 hub §15.1 소유이고 gate → test level / fixture KIND taxonomy 는 [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] 소유이므로 26-row 표를 복제하지 않고 **stage 레벨**로만 orchestration 을 명세(RESTATED_FOREIGN_DECISION 회피).
|
||||
- 4개 dependency sibling(build-supply-chain / release-cache / web-vitals / operational-runbook)이 모두 자기 Out of scope 에서 "CI gate orchestration · 실행 순서 · artifact retention" 을 이 branch 로 위임 확인 — 방향 일관.
|
||||
- 외부 web research 불필요(모든 orchestration 결정 hub-grounded). SLSA 는 seeded source 를 artifact-provenance-retention rationale 로만 범위 한정 인용. frontend 코드·CI 부재 → 전부 `planned`.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 각 결정의 근거는 아래 Sources 및 Decision Evidence Map 참조.
|
||||
|
||||
- 2026-07-19: **CI pipeline = §15.3 promotion formula 를 그대로 반영한 4-stage dependency graph** (merge → release → prod-promotion → field-SLO); downstream stage 는 upstream stage gate 전부 PASS 전에는 실행/승격 불가(AND) / 검토한 대안: 단일 flat gate 집합(stage 없음) / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §15.3.
|
||||
- 2026-07-19: **각 gate 는 독립 blocking required check**; 선언된 Blocking scope 내에서 실패는 warning/soft-fail/`continue-on-error` 로 낮출 수 없음 / 검토한 대안: 비핵심 gate 를 non-blocking advisory 로 강등 / 근거: `FE-OC-020` normative summary + §15.1 Blocking scope 열 + §15.3.
|
||||
- 2026-07-19: **각 gate 는 evidence artifact 를 공유 `artifacts/` 트리에 산출하고 CI 가 이를 retain**(승격 감사 trail); rollback target·drill record 는 최소한 다음 release 가 승격될 때까지 생존 / 근거: §14.3 artifact 열 + §12.5 rollback invariant + `SLSA-FW-C6`.
|
||||
- 2026-07-19: **artifact retention 기간(day/count)·storage backend 는 미결정** → `UNSUPPORTED_DECISION`; hub 는 *어떤* artifact 를 남기는지만 규정하고 *얼마나* 보관하는지는 규정 안 함. 하한만 rollback invariant 로 grounding, 수치는 provider/조직 정책 확정 후 채움.
|
||||
- 2026-07-19: **fixture 본문·gate pass-condition 은 CI 가 정의하지 않고 owner branch 에 위임**(R3); orchestration 은 gate 결과·artifact·blocking 만 배선 / 근거: §20 dependency 열 + §15.1 Covered-FE-OC.
|
||||
- 2026-07-19: **provider-agnostic orchestration contract**; 구체 workflow syntax·required-check 이름·branch-protection 은 provider 선택 시 바인딩(deferred) / 근거: §14.1 CI runner 미확정 + `FE-Q-002`/`FE-Q-003`/`FE-Q-010`.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | CI pipeline 을 §15.3 promotion formula 와 동형인 4-stage dependency graph(merge → release → prod-promotion → field-SLO)로 배선; downstream stage 는 upstream gate 전부 PASS 전 실행/승격 불가(AND) | 이 조건: gate 들이 §15.3 의 4 promotion tier 로 분류될 때. 대안(flat 배선): 새 blocking scope 가 추가되면 §15.1 gate 수와 promotion formula 를 함께 갱신하고 stage graph 도 재도출(§15.1 "이 수와 promotion formula 를 함께 갱신") | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §15.3 promotion formula; §15.1 Blocking scope 열 | `project-decision` (hub formula 도출) | stage 내 fail-fast vs full-fan-out, stage 간 부분 재실행 정책을 hub 가 규정하지 않음 |
|
||||
| D2 | 각 gate = 독립 blocking required check; 선언된 Blocking scope 내 실패를 warning/soft-fail/`continue-on-error` 로 낮출 수 없음 | 불변식(분기 N/A) — `FE-OC-020` 이 downgrade 를 금지하고 각 promotion tier 가 지정 gate 집합의 AND 로 고정돼 우회 여지가 없으므로 항상 blocking | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-OC-020` normative summary; §15.1 Blocking scope 열; §15.3 formula | `accepted-documented-only` (invariant) | downgrade 를 실제로 막는 지점은 provider 의 branch-protection/required-check 설정 — provider 미확정(D6) |
|
||||
| D3 | 각 gate 는 §14.3/§15.1 이 정한 evidence artifact 를 공유 `artifacts/` 트리에 산출하고 CI 가 retain; rollback target(§12.5 coherent set)·drill record 는 다음 release 승격까지 생존 | 이 조건: gate 가 machine-readable evidence 를 남길 때(전 gate). 대안: script rename 시 gate+artifact 매핑을 동시 갱신해야 함(§14.3 말미) — 매핑 갱신 없는 rename 금지 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §14.3 artifact 열, §4.6 `artifacts/` blueprint, §12.5 rollback invariant, §15.1 `FE-GATE-016`(prior release pair); `raw/official-docs/supply-chain-slsa-provenance-framework.md#SLSA-FW-C6`, `#SLSA-FW-C4` (machine-readable provenance retention rationale, 범위 한정) | `project-decision` (경로/트리) + `official-standard` (provenance-artifact retention rationale) | artifact 포맷(JUnit XML/SARIF/JSON)이 실제 CI reporter/artifact store 와 호환되는지 미검증 |
|
||||
| D4 | **UNSUPPORTED_DECISION** — artifact retention 기간(day/count)·storage backend·tier 별 차등 보관은 hub 미규정. 하한(rollback target·drill record 는 다음 release 승격까지 보관)만 §12.5 로 grounding, 구체 수치는 미결정 | 이 조건: rollback/drill evidence 는 다음 release pair 검증 전 삭제 금지(§12.5, `FE-GATE-016` "prior release pair"). 대안: merge-tier lint/test artifact 는 1 build cycle 후 만료 허용 — **수치 자체는 근거 없음**(trade-off: 짧으면 rollback/audit 증거 유실, 길면 storage 팽창) | 없음 — hub §14/§15 는 *어떤* artifact 인지만 규정, retention 기간 미규정. `FE-Q-010`(security), `FE-Q-003`(provider)도 retention 수치 미포함 | `UNSUPPORTED` | 잘못된 retention → `FE-GATE-016` rollback drill 이 prior release pair 를 잃어 실행 불가; 값은 provider/조직 정책 확정 후 결정 필요 |
|
||||
| D5 | fixture 본문·gate pass-condition 은 CI orchestration 이 정의하지 않고 각 FE-OC owner branch 에 위임; orchestration 은 gate 결과·artifact·blocking 배선만 소유(R3) | 이 조건: gate 가 단일 FE-OC owner 로 매핑될 때. 대안: 한 gate 가 다수 owner fixture 를 요구하면(예 `FE-GATE-004`/`005`/`007`) 모든 owner fixture 를 실행하도록 wiring 하되 test-level taxonomy owner([[raw/branch-notes/feature-frontend-test-taxonomy-contract]] `FE-OC-020`)가 조정 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §20 dependency 열; §15.1 Covered-FE-OC 열 | `project-decision` (R3 경계) | 없음 material — 위임 대상은 §엣지·실패·의존 참조 |
|
||||
| D6 | provider-agnostic orchestration contract; 구체 workflow syntax·required-check 이름·branch-protection 은 provider 선택 시 바인딩(deferred) | 이 조건: provider 미확정 동안은 stage graph + blocking 불변식 + retention 정책만 정의. 대안: provider 확정 시 required-check 이름을 이 contract 의 gate 에 1:1 바인딩하고 branch-protection 을 stage graph 에 맞춤 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §14.1 (CI runner 미확정); `FE-Q-002`/`FE-Q-003`/`FE-Q-010` (open questions) | `deferred` / `conditional-default` | provider primitive 가 4-tier 를 독립 required check 로 표현 못 할 수 있음(예: 단일 job 강제) |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 전 항목 `planned` — frontend repository·CI 미생성. stage/artifact/경로는 hub §15.1(gate matrix)·§15.3(promotion formula)·§14.3(planned commands)·§4.6(directory blueprint)에서 도출한 blueprint 이며 repo·provider 확정 시 변경 가능. gate *정의* 는 재명세하지 않고 [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] taxonomy 를 consume(R3).
|
||||
|
||||
### 1. Stage dependency graph (promotion formula → CI stage)
|
||||
|
||||
> **Trace**: D1 · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §15.3 / §15.1 Blocking scope 열
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: stage 내 gate 병렬 실행 시 **fail-fast(첫 실패에서 stage 중단) vs full-fan-out(전 gate 실행 후 집계)** 은 hub 미규정 → default 로 full-fan-out 제안(trade-off: full-fan-out 은 CI 시간↑ 이나 한 push 에서 여러 gate 실패를 한 번에 보고해 되돌이 횟수↓).
|
||||
|
||||
CI pipeline 은 §15.3 promotion formula 와 동형의 stage graph 다. downstream stage 는 upstream stage 의 gate 집합이 **전부 PASS** 이기 전에는 실행/승격되지 않는다(formula 의 `AND` 배선).
|
||||
|
||||
각 stage 의 **gate 집합은 hub §15.3 promotion formula 소유**이며 여기에 열거하지 않는다 — hub 가 gate 를 추가·supersede 하면 복제한 ID 목록만 조용히 낡는다. 본 표는 stage ↔ trigger ↔ 통과 의미의 배선만 정의한다.
|
||||
|
||||
| Stage | Trigger event | Gate 집합 | 의존(upstream stage) | 통과 의미 |
|
||||
|---|---|---|---|---|
|
||||
| S1 merge | PR → protected branch merge | hub §15.3 `MERGE_READY` 집합 | — | `MERGE_READY` |
|
||||
| S2 release | release cut | S1 + hub §15.3 `RELEASE_READY` 추가분 | S1 (`MERGE_READY`) | `RELEASE_READY` |
|
||||
| S3 prod-promotion | production promotion | S2 + hub §15.3 `PROD_PROMOTION_READY` 추가분 | S2 (`RELEASE_READY`) | `PROD_PROMOTION_READY` |
|
||||
| S4 field-SLO | 28-day field window 후 | S3 + hub §15.3 `FIELD_SLO_READY` 추가분 | S3 (`PROD_PROMOTION_READY`) | `FIELD_SLO_READY` |
|
||||
|
||||
**Off-chain gate**(선형 승격 chain 밖 — §15.1 Blocking scope 열 그대로):
|
||||
|
||||
- `FE-GATE-017`(scoped diagram review, Blocking scope = documentation readiness, 현재 `PASS_SCOPED`) — 선형 merge→release chain 에 넣지 않고 문서 준비 gate 로 독립 배선.
|
||||
- `FE-GATE-018` 은 위 S4 로, 다른 gate 와 달리 field window 종속이라 별 stage.
|
||||
|
||||
> 참고: `FE-GATE-008`(e2e)·`FE-GATE-009`(a11y)·`FE-GATE-011`(build)·`FE-GATE-013`(security) 등은 Blocking scope 가 "merge + release" 이므로 S1·S2 양쪽 required. 이 branch 는 gate 를 stage 에 배정만 하고, 각 gate 의 fixture/pass-condition 은 owner 소유(D5).
|
||||
|
||||
### 2. Blocking-check 배선 + no-downgrade 집행
|
||||
|
||||
> **Trace**: D2 · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-OC-020` / §15.1 Blocking scope 열 / §15.3
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — blocking 규칙은 `FE-OC-020`("실패를 warning 으로 낮추면 안 됨") + §15.3 formula verbatim.
|
||||
|
||||
- 각 gate 는 §15.1 Blocking scope 열이 지정한 stage 에서 **독립 required check** 로 실행된다(통합 test job 으로 합치지 않음 — gate KIND 분리는 taxonomy owner 소유이나, CI 는 그 KIND 를 별 check 로 배선).
|
||||
- gate 실패 → 해당 Blocking scope 의 promotion tier 를 `NOT_READY` 로 고정. **warning / soft-fail / `continue-on-error: true` / manual override 로 승격을 통과시키는 배선 금지**(`FE-OC-020` 위반).
|
||||
- promotion 판정은 §15.3 formula 를 그대로 계산:
|
||||
- `MERGE_READY` = S1 gate 전부 PASS
|
||||
- `RELEASE_READY` = `MERGE_READY` AND S2 추가 gate 전부 PASS
|
||||
- `PROD_PROMOTION_READY` = `RELEASE_READY` AND S3 추가 gate 전부 PASS
|
||||
- `FIELD_SLO_READY` = `PROD_PROMOTION_READY` AND `FE-GATE-018` PASS
|
||||
- exception/override 가 조직 정책상 필요하면 그 승인 owner·audit 기록을 **별도 결정 row 로** 등재해야 하며(§2.2 Q4 "허용되는 예외와 승인 owner"), 무기록 override 는 금지.
|
||||
|
||||
### 3. Evidence artifact retention
|
||||
|
||||
> **Trace**: D3 + D4 · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §14.3 artifact 열 / §4.6 / §12.5 rollback invariant / §15.1 `FE-GATE-016` · `raw/official-docs/supply-chain-slsa-provenance-framework.md#SLSA-FW-C6`
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (a) retention **기간 수치**(아래 표 "보관 하한" 의 day/count) 전부 — hub 미규정(D4). rollback/drill 은 §12.5 로 "다음 release 승격까지" 라는 *상대적* 하한만 grounding, 절대 수치는 provider/조직 정책 확정 후. (b) storage backend(CI artifact store vs 별도 object store) 미규정 — default 로 CI 기본 artifact store 제안(trade-off: 기본 store 는 무료·간단하나 보관기간 상한/용량 제약이 provider 종속).
|
||||
|
||||
각 gate 는 §14.3/§15.1 이 정한 artifact 를 공유 `artifacts/` 트리(§4.6)에 산출하고 CI 가 upload/retain 한다. gate 는 자체 트리를 만들지 않는다(taxonomy owner 의 `artifacts/` SSOT 를 consume).
|
||||
|
||||
```text
|
||||
artifacts/
|
||||
quality/ install.txt · lint.txt · check-types.txt # S1
|
||||
tests/ runtime-schema.xml · unit.xml · component.xml · integration.xml · a11y.json · sample-removal.xml · e2e/ # S1(+e2e S1/S2)
|
||||
performance/ bundle.json · lab.json · field-web-vitals.json # bundle/lab S2, field S4
|
||||
security/ scan.sarif # S1/S2
|
||||
release/ build-manifest.json · verification.json · hosting-headers.json · dependency-inventory.* · checksums.txt # S2 (§12.1)
|
||||
runbooks/ FE-RB-00N/<release-id>/record.json # S3 drill (FE-GATE-021~025)
|
||||
```
|
||||
|
||||
| Retention class | 대상 artifact | 보관 하한(상대) | 근거 |
|
||||
|---|---|---|---|
|
||||
| merge-cycle | `quality/*`, `tests/{unit,component,integration,runtime-schema,a11y,sample-removal}` | `UNSUPPORTED` (수치 미정; 최소 해당 PR 승격 판정까지) | §14.3 artifact 열 |
|
||||
| release-coherence | `release/*`, `performance/{bundle,lab}`, `security/scan.sarif` | **다음 release 가 승격될 때까지**(rollback target coherent set 생존) | §12.5 rollback invariant + `FE-GATE-016` prior release pair |
|
||||
| prod-drill | `runbooks/FE-RB-00N/<release-id>/record.json` | **다음 production promotion 승격 판정까지**(drill evidence 는 승격 gate 입력) | §15.1 `FE-GATE-016`/`021`~`025` |
|
||||
| field | `performance/field-web-vitals.json` | **28-day field window + 집계 완료까지** | §14.2 `FE-NFR-013`~`015`, `FE-GATE-018` |
|
||||
|
||||
- artifact 는 machine-readable(§14.3 확장자 `.xml`/`.sarif`/`.json`) 이어야 하고, release/security artifact 는 provenance 성격이므로 traceable 하게 보관(`SLSA-FW-C6`: in-toto attestation = machine-readable statement about artifact digests). **단** SLSA gate/fixture(build provenance attestation 생성 자체)는 [[raw/branch-notes/feature-frontend-build-bundle-supply-chain-contract]] (`FE-OC-018`) 소유 — 이 branch 는 산출된 artifact 의 upload/retention 만 배선.
|
||||
- script rename 은 gate+artifact 매핑 동시 갱신 조건으로만 허용(§14.3 말미) — retention 배선도 함께 갱신.
|
||||
|
||||
### 4. Fixture-content 위임 경계 (R3)
|
||||
|
||||
> **Trace**: D5 · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §20 dependency 열 / §15.1 Covered-FE-OC
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — 순수 위임 표. 이 branch 는 아래 gate 의 실행/blocking/retention 만 배선하고 fixture 본문은 owner 소유.
|
||||
> - **owner 열 회수(2026-07-21)**: 이전 판은 gate 별 fixture-content owner 를 이 표에 복제했는데, 그 사본이 실제로 낡아 있었다 — `FE-GATE-001` 을 build-bundle 로 적었으나 hub §2.1.1 owner 는 `feature-frontend-project-bootstrap-toolchain-contract` 이고, `FE-GATE-014` 를 release-cache-rollback 으로 적었으나 hub owner 는 `feature-frontend-contract-compatibility-governance` 이며 지목된 branch 는 그 gate 를 한 번도 언급하지 않는다. 같은 문서의 §가져온 프로젝트 계약 표(아래)는 두 gate 모두 hub 와 같게 적고 있어 문서가 자기모순 상태였다. [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] 가 이미 같은 함정에서 회수한 선례를 따라 **owner 열을 삭제하고 hub §2.1.1 포인터만 남긴다.**
|
||||
|
||||
> gate 별 **Owner 는 hub §2.1.1 이 SSOT** 다. 이 표는 owner 를 재진술하지 않고, *이 branch 가 CI 에서 무엇을 배선하는가* 만 소유한다.
|
||||
|
||||
| Gate 군 | 이 branch 가 배선하는 것 |
|
||||
|---|---|
|
||||
| `FE-GATE-001,011,012,013` (install/build/bundle/security) | stage 배정 + required check + artifact retention |
|
||||
| `FE-GATE-014,015,016,019` (config-compat/release-coherence/rollback/hosting-header) | stage 배정 + blocking + drill artifact 보관 |
|
||||
| `FE-GATE-018,026` (field/lab performance) | stage 배정 + field window retention |
|
||||
| `FE-GATE-021,022,023,024,025` (`FE-RB-001`~`005` drill) | prod-promotion stage 배정 + drill record retention |
|
||||
| `FE-GATE-002,003,004,005,006,007,008,009,010,020` (test/arch/sample) | S1 배선 + required check |
|
||||
| registry diff / compatibility gate | gate 결과 소비 |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- gate 실패가 `continue-on-error`/warning 으로 downgrade → `FE-OC-020` 위반(기대: promotion formula 가 해당 tier 를 `NOT_READY` 로 유지, 승격 차단).
|
||||
- upstream stage 미완인데 downstream stage 실행 → dependency graph 위반(기대: S2/S3/S4 는 upstream gate 전부 PASS 전 skip).
|
||||
- retention 만료로 rollback target/drill record 소실 → `FE-GATE-016` 이 prior release pair 를 잃어 실행 불가(기대: release-coherence/prod-drill retention class 가 다음 승격까지 보관, §12.5).
|
||||
- script rename 후 gate+artifact 매핑 미갱신 → §14.3 위반(기대: drift check 가 매핑 불일치 검출, retention 배선도 함께 갱신).
|
||||
- provider 가 4-tier 를 독립 required check 로 표현 못 함 → D6 open risk(기대: equivalent primitive + 그 rollback/blocking semantics 를 결정 row 로 기록, §12.4 유사 절차).
|
||||
- flaky gate(e2e/perf) → deterministic fixture(fake clock §15.1 `FE-GATE-005`, recorded context metadata §14.1) 요구는 taxonomy/owner 소유; orchestration 은 flaky 결과를 PASS 로 취급하지 않도록 retry-suppression(무한 retry 로 통과 금지) 배선.
|
||||
- **다른 계약 의존** (§20 dependency 열 + §4.3):
|
||||
- [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] (`FE-OC-020`) — gate → test level / fixture KIND taxonomy 와 `artifacts/` 트리 taxonomy 를 이 branch 가 consume. 그 taxonomy 가 바뀌면 stage graph·retention 배선 재도출. (gate→FE-OC mapping 과 promotion formula 는 hub §15.1·§15.3 소유.)
|
||||
- [[raw/branch-notes/feature-frontend-build-bundle-supply-chain-contract]] (`FE-OC-018`) — install/build/bundle/security gate fixture·SLSA provenance artifact 제공. 산출 artifact 경로가 바뀌면 retention 배선 갱신.
|
||||
- [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] (`FE-OC-016`, `FE-OC-017`, `FE-OC-019`) — release-coherence/rollback drill + hosting-header(cache·security) fixture 제공. rollback target coherent set(§12.5)이 retention 하한을 규정.
|
||||
- [[raw/branch-notes/feature-web-vitals-performance-budget-contract]] (`FE-OC-021`) — bundle/lab/field gate pass-condition 제공. field window 가 S4 retention 을 규정.
|
||||
- [[raw/branch-notes/feature-frontend-operational-runbook-contract]] (`FE-OC-025`) — `FE-RB-001`~`005` drill 본문 제공. drill record 가 prod-promotion 승격 gate 입력.
|
||||
- [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] (`FE-OC-003`) — `pnpm test:*` script host / engine 없이는 어떤 gate 도 실행 불가(간접 의존; taxonomy 경유).
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| required gate 실패가 merge/release/promotion 을 실제로 막는다 | CI·branch-protection 설정 없음 | provider 확정 후 negative fixture(§15.2)로 gate 를 고의 실패시켜 해당 tier 가 `NOT_READY` 로 승격 차단되는지 확인 | `needs-confirmation` |
|
||||
| 4-stage dependency graph 가 §15.3 formula 와 정합(downstream 이 upstream AND 없이 승격 안 됨) | CI 미배선 | stage 별 gate 집합을 §15.3 verbatim 과 대조하고, upstream gate 1개 실패 시 downstream stage skip 을 e2e 로 확인 | `needs-confirmation` |
|
||||
| gate 실패가 warning/`continue-on-error` 로 downgrade 되지 않음 | CI wiring·override 정책 미구현 | workflow 에 `continue-on-error` 부재 grep + override 감사 로그 확인 | `planned` |
|
||||
| rollback target·drill record 가 다음 release/promotion 승격까지 생존 | retention 배선·수치 미정(D4) | release pair 를 만들어 `FE-GATE-016` 이 prior release artifact 를 실제로 사용할 수 있는지 drill(§12.5) | `needs-confirmation` |
|
||||
| artifact 포맷(XML/SARIF/JSON)이 CI reporter/artifact store 와 호환 | reporter 미선택 | 각 gate reporter 산출물을 CI artifact upload + 재파싱으로 검증 | `planned` |
|
||||
| retention 기간 수치가 조직/provider 정책에 부합 | hub 미규정(`UNSUPPORTED_DECISION`) | `FE-Q-010`/`FE-Q-003` resolution 으로 retention day/count 확정 후 배선 | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
- 스캐폴딩 단계: `/coverage` 실행 전 수동 행을 만들지 않는다.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 없음 — 구현 착수 전(`planned`).
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
| `FE-GATE-001@1` | [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] | lockfile 이 manifest 와 어긋나면 merge·release 를 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-GATE-002@1` | [[raw/branch-notes/feature-frontend-architecture-enforcement-lint-contract]] | 금지된 API·import 가 남아 있으면 merge 를 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-GATE-004@1` | [[raw/branch-notes/feature-runtime-schema-validation-contract]] | invalid fixture 가 예상 kind 로 거부되지 않으면 merge 를 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-GATE-012@1` | [[raw/branch-notes/feature-frontend-build-bundle-supply-chain-contract]] | 번들 NFR threshold 초과면 release 를 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-GATE-014@1` | [[raw/branch-notes/feature-frontend-contract-compatibility-governance]] | 지원 대상 config 버전이 boot 에 실패하면 release 를 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-GATE-016@1` | [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] | rollback 과 smoke 증거가 없으면 production promotion 을 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-GATE-018@1` | [[raw/branch-notes/feature-web-vitals-performance-budget-contract]] | p75 목표 미달이거나 표본 임계가 미해결이면 field readiness 를 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-GATE-021@1` | [[raw/branch-notes/feature-frontend-operational-runbook-contract]] | `FE-RB-001` 의 containment·escalation·recovery 단언이 실패하면 production promotion 을 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-OC-016@1` | [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] | HTML, asset, runtime config, release manifest cache policy를 MUST 구분 | import 참조로 적용 |
|
||||
| `FE-OC-017@1` | [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] | rollback은 immutable prior release로 수행하고 build/config/API compatibility를 MUST 검증 | import 참조로 적용 |
|
||||
| `FE-OC-018@1` | [[raw/branch-notes/feature-frontend-build-bundle-supply-chain-contract]] | frozen lockfile, dependency review, secret scan, SBOM 또는 dependency inventory를 release gate에 MUST 포함 | import 참조로 적용 |
|
||||
| `FE-OC-019@1` | [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] | browser bundle에 secret을 넣지 않고 untrusted HTML injection을 기본 금지 | import 참조로 적용 |
|
||||
| `FE-OC-020@1` | [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] | gate 종류별 책임·fixture·artifact를 분리하고 실패를 warning으로 낮추면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-021@1` | [[raw/branch-notes/feature-web-vitals-performance-budget-contract]] | NFR은 device/network/cache/build context와 함께 MUST 측정 | import 참조로 적용 |
|
||||
| `FE-OC-022@1` | [[raw/branch-notes/feature-frontend-contract-registry-governance]] | 8개 registry는 single primary owner와 compatibility impact를 MUST 기록 | import 참조로 적용 |
|
||||
| `FE-OC-023@1` | [[raw/branch-notes/feature-frontend-contract-compatibility-governance]] | API/config/storage/release schema의 breaking change는 migration 또는 version bump 없이 배포하면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-025@1` | [[raw/branch-notes/feature-frontend-operational-runbook-contract]] | boot, chunk mismatch, API degradation, telemetry failure, rollback runbook을 MUST 유지 | import 참조로 적용 |
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
- 없음 — 자식 자료는 생성 후 controller가 parent Cluster와 함께 등록한다.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- 없음 — daily note는 이 작업에서 수정하지 않는다.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크: 없음
|
||||
- 리뷰 메모: 없음
|
||||
- 머지 결과 / 배포 환경: `planned`
|
||||
- **wiki 추출 대상** (verified만): 없음 — 구현 착수 전(전부 `planned`).
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned): 현재 전체 — verified evidence 확보 전까지 추출 금지.
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-frontend-operational-contract/branch-notes/feature-frontend-clean-architecture-layering-contract.md
|
||||
@@ -0,0 +1,287 @@
|
||||
---
|
||||
title: branch / feature-frontend-clean-architecture-layering-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-002
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-frontend-operational-contract
|
||||
work_item: WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-002
|
||||
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-ARCHITECTURE-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-PORT-OWNERSHIP-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-COMPOSITION-ROOT-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-001]
|
||||
contract_packet: 1
|
||||
branch: feature-frontend-clean-architecture-layering-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton-frontend, ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-frontend-operational-contract.md]
|
||||
tags: [branch, ca-skeleton, frontend, architecture, application, javascript, clean-architecture]
|
||||
created: 2026-07-18
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 1a7d904e9fe76e1aeb6ebd25fea852de7cc888232e96110e832f764d97e518ee
|
||||
imports: [FE-OC-004@1, FE-OC-024@1]
|
||||
accepts_delegations: [DELEG-FE-003@1, DELEG-FE-005@1]
|
||||
|
||||
---
|
||||
|
||||
# branch: feature-frontend-clean-architecture-layering-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 TODO·결정·진행 기록. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출한다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-frontend-operational-contract]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: directory 책임·port owner·allowed import matrix가 문서와 fixture로 고정된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-ARCHITECTURE-001@1` | domain, application, presentation, adapters, bootstrap 책임을 분리한다 | 5-layer directory 책임과 allowed-import matrix에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-PORT-OWNERSHIP-001@1` | output port interface는 application이 소유하고 adapter가 구현한다 | port ownership matrix에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-COMPOSITION-ROOT-001@1` | bootstrap을 단일 composition root로 두고 concrete adapter를 application에 주입한다 | bootstrap boot order와 adapter injection 경계에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
이 브랜치는 project-wide 계약 `FE-OC-002`(`domain <- application <- presentation` 의존 방향 + application-owned output port를 MUST 지킴)를 *구현 착수 가능한 상세 명세*로 내린다. 구체적으로 세 가지 불변식을 고정한다: `FE-D009`(domain/application/presentation/adapters/bootstrap 5-layer 책임 분리), `FE-D010`(output port interface는 application 소유, adapter가 구현), `FE-D011`(단일 composition root `bootstrap`이 concrete adapter를 주입). 산출물은 §20 Measurable completion이 요구하는 **directory responsibility + port owner + allowed import matrix** 세 표다. frontend repository가 아직 없으므로 이 브랜치의 모든 항목은 `planned` 등급이며, 착수 시점의 blueprint 근거는 hub §4(§4.2 component responsibility / §4.3 dependency matrix / §4.4 port ownership / §4.5 composition root / §4.6 directory blueprint)다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- domain / application / presentation / adapters / bootstrap **5-layer 책임 경계** 정의 (hub §4.2) — `FE-D009`
|
||||
- **planned directory blueprint** 확정 (hub §4.6) — `FE-D009`
|
||||
- **application-owned output port ownership matrix** — port 정의 owner·consumer·I/O·failure vocabulary·"port는 application이 소유한다" 규칙 (hub §4.4) — `FE-D010`
|
||||
- **allowed / forbidden import matrix** *규칙 정의* (hub §4.3) — `FE-D009` + `FE-D010`
|
||||
- **단일 composition root(bootstrap) injection 원칙 + boot order** (hub §4.5) — `FE-D011`
|
||||
- `FE-OC-002`의 minimum evidence인 **dependency rule report** 산출물 정의
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외. 인접 계약은 다른 owner 브랜치가 소유하며, 여기서 detail을 쓰지 않고 그 브랜치를 가리킨다 (CLAUDE.md §15.5 R3 `OUT_OF_BRANCH_SCOPE` 방지).
|
||||
|
||||
- import 규칙의 **실제 lint 강제** (dependency-cruiser / ESLint restricted-import config, allowed/forbidden fixture) → [[raw/branch-notes/feature-frontend-architecture-enforcement-lint-contract]]
|
||||
- 각 port의 **concrete method 시그니처 / 구현** → 해당 adapter 브랜치: [[raw/branch-notes/feature-api-client-response-envelope-contract]] (`ResourceQueryPort`/`ResourceCommandPort`), [[raw/branch-notes/feature-server-state-caching-contract]] (`QueryCachePort`), [[raw/branch-notes/feature-frontend-storage-registry-contract]] (`StoragePort`), [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] (`TelemetryPort`), [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] (`ReleaseInfoPort`)
|
||||
- **AuthSessionPort 내부 shape / token lifecycle** → [[raw/branch-notes/feature-frontend-auth-session-integration-contract]] + 외부 Keycloak
|
||||
- **runtime config schema / 검증 내용** → [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] (`FE-OC-004`)
|
||||
- **boot error shell 렌더링 / reload-loop 방지** → [[raw/branch-notes/feature-frontend-render-recovery-boundary-contract]] (`FE-OC-015`)
|
||||
- **toolchain / manifest / checkJs / dev dependency 설치** → [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] (`FE-OC-003`)
|
||||
- **React 사용 결정 자체** (hub `FE-D004`) → [[raw/branch-notes/feature-async-ui-state-contract]]. 본 브랜치는 "선택된 UI framework를 presentation에 가둔다"는 *경계 규칙*만 소유
|
||||
- **test gate 종류·fixture·artifact 구조** → [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] (`FE-OC-020`)
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `FE-OC-002` owner 계약 + `FE-D009`/`FE-D010`/`FE-D011` 결정 register + §4 architecture blueprint의 SSOT (본 브랜치의 모든 planned 경로·규칙 근거) |
|
||||
| [[raw/project-notes/ca-skeleton-operational-contract]] | `FE-D009`/`FE-D010`/`FE-D011`의 Clean Architecture **선례** — backend 운영계약의 layer 분리·"application use case는 output port에만 의존"·composition root 단일화(app-bootstrap) 철학을 frontend에 적용 |
|
||||
| [[raw/official-docs/react-ui-library-official]] | import matrix의 **React 경계 규칙** — `REACT-UI-C1`(React 앱은 컴포넌트 단위 UI 구성) → React는 presentation 전용, domain/application의 React import 금지 |
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] hub §4.2 component responsibility + §4.6 directory blueprint를 실제 폴더/모듈 책임표로 확정 — 등급: `planned`
|
||||
- [ ] hub §4.4 port ownership matrix를 `application/ports` 인터페이스 스텁 목록으로 표현 (정의 owner=application) — 등급: `planned`
|
||||
- [ ] hub §4.3 allowed/forbidden import matrix를 machine-readable 규칙 사양으로 문서화 (강제는 enforcement 브랜치) — 등급: `planned`
|
||||
- [ ] hub §4.5 composition root boot order(10단계) + adapter injection 지점 명세 — 등급: `planned`
|
||||
- [ ] `FE-OC-002` minimum evidence인 dependency rule report 산출물 형식 정의 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
`/branch-spec`로 채움 (2026-07-19). frontend 코드 부재 → 전 항목 `planned`. 근거 SSOT = frontend hub §4 + backend CA 선례 + `REACT-UI-C1`. 웹 리서치 불필요 (hub가 이미 충분).
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 각 결정의 근거는 아래 Decision Evidence Map과 1:1. 대안과 함께 기록.
|
||||
|
||||
- 2026-07-19: **5-layer 책임 분리** (domain/application/presentation/adapters/bootstrap) 채택 (`FE-D009`) / 이유: framework-neutral domain 보호 + 의존 방향을 `domain <- application <- presentation` 단방향으로 강제 / 검토한 대안: flat structure, Feature-Sliced Design(FSD) / 근거: backend ca-skeleton 운영계약 CA 철학 [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
- 2026-07-19: **output port interface는 application 소유, adapter가 구현** (`FE-D010`) / 이유: dependency inversion — application이 concrete adapter 이름을 모르게 함 / 검토한 대안: adapter가 인터페이스 소유(전통적 layered) / 근거: project decision + backend port 소유 선례
|
||||
- 2026-07-19: **단일 composition root(bootstrap)가 concrete adapter 주입** (`FE-D011`) / 이유: owner ambiguity 제거, 조립 지점 1개로 고정 / 검토한 대안: framework DI container / 근거: project decision + backend app-bootstrap 선례
|
||||
- 2026-07-19: **선택된 UI framework(React, hub `FE-D004`)를 presentation에 가둠** (import matrix 규칙) / 이유: React는 UI 구성 관심사이므로 domain/application에 유입 금지 / 검토한 대안: domain/application에 rendering 혼입 / 근거: `REACT-UI-C1`
|
||||
- 2026-07-19: **import 규칙 정의=본 브랜치, 강제=enforcement 브랜치 위임** (범위 경계) / 이유: 규칙 정의와 lint 강제 관심사 분리 / 근거: §20 분해표 + §4.3 `Planned enforcement` 컬럼
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정과 raw source claim의 연결. `Decision ID`는 이 노트 안에서 안정적으로 유지. `Supporting Claims`는 backtick 포인터(`raw/<cat>/<slug>.md#<CLAIM>`) 또는 hub `FE-D###` / live wikilink.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | 5-layer 책임 분리 domain/application/presentation/adapters/bootstrap (`FE-D009` / `FE-OC-002`) | sample slice가 경계의 값을 증명하는 한 default 유지; sample이 불필요한 ceremony임을 증명하거나 FSD fork 승인 시 flat/FSD로 전환 | 프로젝트 결정 `FE-D009`; backend CA 선례 [[raw/project-notes/ca-skeleton-operational-contract]] (domain이 CA 심장, 모든 의존 화살표가 domain으로 수렴) | `project-decision` | 코드 없음 — 5-layer 경계가 실제로 값을 하는지 sample slice(`FE-OC-024`) 전까지 미검증 (over-engineering 위험) |
|
||||
| D2 | output port interface는 application 소유, adapter가 구현 (`FE-D010` / `FE-OC-002`) | default 유지; port가 domain invariant 자체를 표현해야 하는 concrete case 발생 시 그 port를 domain으로 이동 | 프로젝트 결정 `FE-D010` (dependency inversion); backend "application use case는 output port에만 의존" 선례 [[raw/project-notes/ca-skeleton-operational-contract]] | `project-decision` | port granularity/개수 미검증 — 잘못된 분할 시 adapter 표면 폭증 |
|
||||
| D3 | 단일 composition root(bootstrap)가 concrete adapter 주입 (`FE-D011` / `FE-OC-002`·`FE-OC-004`) | hand-wired DI default 유지; framework DI container 도입 시 재검토 | 프로젝트 결정 `FE-D011`; backend app-bootstrap composition-root 선례 [[raw/project-notes/ca-skeleton-operational-contract]] | `project-decision` | boot order(§4.5 10단계) 결합 — 단계 순서 변경이 여러 adapter 조립에 영향 |
|
||||
| D4 | 선택된 UI framework(React)를 presentation에 가둠 — import matrix의 React 금지 row (`FE-OC-002`; framework 선택은 hub `FE-D004`, async-ui 소유) | React가 UI framework인 동안 유지; native/custom-element 또는 다른 framework로 fork(hub `FE-D004` revisit) 시 matrix의 React 금지 심볼만 갱신 | `raw/official-docs/react-ui-library-official.md#REACT-UI-C1`; hub §4.3 dependency matrix | `official-doc` | framework 교체 시 domain/application 격리 규칙 자체는 불변이나 구체 금지 심볼 목록이 바뀜 |
|
||||
| D5 | import 규칙 *정의*=본 브랜치, *강제*=enforcement 브랜치 위임 (범위 경계) (`FE-OC-002` contributes) | 규칙 정의(여기)와 lint 강제(enforcement 브랜치) 분리 유지; 두 관심사 병합 승인 시 재검토 | §20 분해표 (`feature-frontend-architecture-enforcement-lint-contract` Primary=—, contributes `FE-OC-002`); hub §4.3 `Planned enforcement` 컬럼 | `project-decision` | 규칙/강제 drift — matrix 변경이 enforcement fixture 미갱신 시 규칙이 무력화 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> `planned` blueprint. 경로/책임은 hub §4.2/§4.3/§4.4/§4.5/§4.6에서 도출(근거 있음). frontend 코드는 존재하지 않으므로 전 항목 `planned`. 3-rule (R1 Trace / R2 UNSUPPORTED_IMPL_DECISION / R3 OUT_OF_BRANCH_SCOPE 정제) 준수.
|
||||
|
||||
### 1. Layer 책임 · directory 책임 map
|
||||
|
||||
> **Trace**: D1 (`FE-D009`) + `FE-OC-002`; hub §4.2 component responsibility + §4.6 directory blueprint.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: §4.6 blueprint보다 깊은 하위 파일/모듈 명명(예: `domain/models/*` 개별 파일명, `application/use-cases/*` 클래스명)은 hub가 권고하지 않음 → 구현 repository 생성 시 확정. trade-off: blueprint 수준(폴더 책임)까지만 grounded, 그 이하 명명은 첫 sample slice에서 정한다.
|
||||
|
||||
아래 표에서 본 브랜치가 더하는 것은 **planned path 열** 뿐이다. `Owns`·`Consumes`·`MUST NOT own` 의 owner 는 [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §4.2 이므로 여기서 값을 고치지 않는다 — 고쳐야 하면 §4.2 를 고치고 이 표를 따라 갱신한다.
|
||||
|
||||
| Layer (planned path — 본 브랜치 소유) | Owns (§4.2) | Consumes (§4.2) | MUST NOT own (§4.2) |
|
||||
|---|---|---|---|
|
||||
| `src/domain/` | framework-neutral model, value semantics, pure policy | standard JavaScript only | React, router, Query, fetch, storage, telemetry |
|
||||
| `src/application/` | use case, input/output port, `QueryCachePort` policy, orchestration, view-model contract | domain | concrete adapter, browser global, React component |
|
||||
| `src/presentation/` | page/component, user event, view state rendering | application public API | raw API DTO, fetch, storage key, telemetry transport |
|
||||
| `src/adapters/http · storage · telemetry · query-cache · auth · release` | application output port 구현, envelope/schema/error·serialization·redaction·key mapping | application port + 해당 browser API | use-case policy, component rendering |
|
||||
| `src/bootstrap/` (`main.jsx`, `composition-root.js`, `load-runtime-config.js`) | config load, adapter 생성, DI, React mount | 모든 runtime module | business rule, page-specific orchestration |
|
||||
|
||||
`src/contracts/` 8개 registry 파일(`routes.js`…`release-tokens.js`)은 각 registry owner 브랜치가 채운다 — 본 브랜치는 *디렉토리 위치*만 blueprint로 고정 (§4.6). registry schema 내용은 governance/owner 브랜치 소유 (R3).
|
||||
|
||||
### 2. forbidden import matrix (규칙 정의)
|
||||
|
||||
> **Trace**: D1 (`FE-D009`) + D2 (`FE-D010`) + D4 (`REACT-UI-C1`) + `FE-OC-002`; hub §4.3 dependency matrix.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `Planned enforcement` 컬럼의 도구(dependency-cruiser + ESLint restricted imports)는 §4.3에 명시되어 grounded이나, *구체 rule config/glob*은 본 브랜치가 정하지 않음 → enforcement 브랜치 소유 (D5, R3). trade-off: 본 표는 "무엇이 금지인가"(machine-readable 규칙)까지만, "어떤 lint 설정으로 잡는가"는 위임.
|
||||
|
||||
**matrix 본문은 [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §4.3 소유다** — 여기에 옮겨 적지 않는다. 이전 판은 §4.3 의 6행 중 5행만 복제해 `test fixtures` 행과 "**May import 열은 예시(illustrative)이고 MUST NOT 열이 규범(normative)**" 이라는 §4.3 의 경고 문단을 통째로 빠뜨렸고, 그 사본만 읽는 구현자는 §4.3 이 명시적으로 경고한 allow-only 오독(= `FE-D022` 가 의무화한 test stack 이 전부 금지되는 해석)에 그대로 빠진다. `test fixtures` 행의 enforcement 는 [[raw/branch-notes/feature-frontend-architecture-enforcement-lint-contract]] 가 소유한다.
|
||||
|
||||
Normative 요약: `application -> adapters` concrete import는 MUST NOT; output port는 application이 MUST 소유; adapter는 application을 모름; presentation은 raw envelope를 직접 다루지 않음; bootstrap만 concrete adapter 조립. 이 규칙의 **강제**(fixture pass/fail)는 D5(범위 경계)에 따라 위임한다 → [[raw/branch-notes/feature-frontend-architecture-enforcement-lint-contract]].
|
||||
|
||||
### 3. Port ownership + composition-root wiring
|
||||
|
||||
> **Trace**: D2 (`FE-D010`) + D3 (`FE-D011`) + `FE-OC-002`·`FE-OC-004`; hub §4.4 port ownership matrix + §4.5 composition root.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: §4.4의 I/O·failure vocabulary 컬럼 이상의 *concrete method 시그니처*는 각 adapter/port owner 브랜치 소유 (R3) — 본 브랜치는 "port 정의는 application, 구현은 adapter, 조립은 bootstrap"이라는 *ownership 규칙*만 명세. `AuthSessionPort`의 opaque-credential shape는 auth owner가 정함(§4.4 주석).
|
||||
|
||||
Port 정의 owner = `application` (전부). 구현 위치 = `adapters/*`. 정의된 port(§4.4): `ResourceQueryPort`·`ResourceCommandPort`(→http), `QueryCachePort`(→query-cache), `AuthSessionPort`(→외부 auth), `StoragePort`(→storage), `TelemetryPort`(→telemetry), `ClockPort`(→system), `ReleaseInfoPort`(→release). 각 port의 concrete impl은 해당 owner 브랜치 (Out of scope 참조).
|
||||
|
||||
Composition root boot order (§4.5, `MUST`): (1) build identity → (2) runtime config fetch → (3) config envelope·schema·compatibility 검증 → (4) release manifest 정합성 → (5) registry snapshot load → (6) auth adapter 주입 → (7) http/storage/telemetry/query-cache adapter 생성 → (8) application facade 생성 → (9) router 생성 → (10) React root mount. **2~4단계 실패 시 product route를 mount하지 않고 boot error shell만 렌더**; telemetry adapter(7) 생성 실패는 console-safe fallback으로 진행. (config 검증 내용=env-config 브랜치, boot error shell 렌더=render-recovery 브랜치 — R3.)
|
||||
|
||||
### 4. Dependency rule report 산출물
|
||||
|
||||
> **Trace**: D5 + `FE-OC-002` minimum evidence("dependency rule report", hub §2.1).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: report의 정확한 파일 형식(JSON/HTML)·CI 배치는 미결 → enforcement + test-taxonomy 브랜치와 조율. trade-off: 본 브랜치는 report가 *검증해야 할 명제*(allowed pass / forbidden fail / domain framework-free)만 정의, 형식은 산출 브랜치 소유.
|
||||
|
||||
report가 assert해야 할 명제: (a) allowed import fixture green, (b) forbidden import fixture red, (c) `domain`의 프레임워크/브라우저 전역 import 0건, (d) concrete adapter 생성이 `bootstrap` 밖에 없음, (e) output port 정의가 `application`에만 존재. 생성 주체·artifact 경로는 enforcement/test-taxonomy 브랜치 (R3, `FE-OC-020`).
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- *import-rule 위반* (한 layer가 금지 방향 import): dependency rule report / architecture fixture가 **red**로 실패 → merge gate 차단. 탐지 mechanism은 enforcement 브랜치 소유(§4.3 Planned enforcement).
|
||||
- *composition-root boot 실패* (§4.5 2~4단계: runtime config fetch/검증/release manifest 부정합): product route를 mount하지 않고 **boot error shell만 렌더** (fail-fast). config 검증 내용은 `FE-OC-004`, error shell 렌더는 `FE-OC-015`.
|
||||
- *adapter 누락/오주입* (bootstrap이 특정 port impl 미주입): application facade 생성(8단계)이 boot 시점에 throw → fail-fast, boot error shell.
|
||||
- *telemetry adapter 생성 실패* (7단계): UI를 실패시키지 않고 console-safe fallback으로 진행(§4.5, `FE-OC-014` best-effort 원칙).
|
||||
- *presentation이 raw DTO/fetch/storage 직접 접근*: import matrix 위반 → forbidden fixture가 잡음(enforcement 브랜치).
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] `FE-OC-003` 에 의존 — manifest·checkJs·dev dependency가 있어야 import graph가 분석·강제 가능 (§20 Dependency).
|
||||
- **위임(D5 범위 경계)**: 본 브랜치 import matrix(§2)의 fixture 강제는 [[raw/branch-notes/feature-frontend-architecture-enforcement-lint-contract]] 소유 — 그 계약이 바뀌면 규칙 강제력에 직접 영향.
|
||||
- [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] `FE-OC-004` — composition root boot 2~4단계가 소비하는 runtime config 검증·fallback 정책 owner.
|
||||
- [[raw/branch-notes/feature-frontend-render-recovery-boundary-contract]] `FE-OC-015` — boot 실패 시 boot error shell 렌더 owner.
|
||||
- [[raw/branch-notes/feature-frontend-auth-session-integration-contract]] `FE-OC-010` — `AuthSessionPort` shape/token lifecycle owner (boot 6단계 주입 대상).
|
||||
- Port 구현 소비: [[raw/branch-notes/feature-api-client-response-envelope-contract]] `FE-OC-006`, [[raw/branch-notes/feature-server-state-caching-contract]] `FE-OC-012`, [[raw/branch-notes/feature-frontend-storage-registry-contract]] `FE-OC-013`, [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] `FE-OC-014`, [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] `FE-OC-016`/`FE-OC-017`.
|
||||
- Contributes to: [[raw/branch-notes/feature-async-ui-state-contract]] `FE-OC-011` (application-owned view-model 경계 제공), [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] `FE-OC-020` (architecture fixture를 test 분류의 한 category로 제공).
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| `domain` 모듈이 프레임워크/브라우저 전역을 0건 import한다 | 코드 없음 — 규칙만 존재 | dependency graph snapshot + forbidden-import fixture (enforcement 브랜치 산출) | `needs-confirmation` |
|
||||
| `application`이 concrete adapter를 직접 import하지 않는다 | 위와 동일 | allowed/forbidden import fixture (allowed pass / forbidden fail) | `needs-confirmation` |
|
||||
| composition root(`bootstrap`)만 concrete adapter를 생성한다 | 위와 동일 | grep + composition-root review — adapter 생성이 bootstrap 밖에 없음 | `needs-confirmation` |
|
||||
| output port 정의는 `application`에, 구현은 `adapters/*`에 위치한다 | 위와 동일 | directory 검사 + import graph snapshot | `needs-confirmation` |
|
||||
| 5-layer 분리가 sample slice에서 실제로 경계 값을 한다 (over-engineering 아님) | hub `FE-D009` revisit trigger — 미검증 | sample-feature-slice fixture(`FE-OC-024`)로 경계가 값을 증명 / 아니면 재검토 | `needs-confirmation` |
|
||||
| 이 import matrix가 dependency-cruiser + ESLint로 실제 강제 가능하다 | 도구 미도입 | enforcement 브랜치의 allowed/forbidden fixture pass/fail (`dependency rule report`) | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
> `/coverage`가 채우는 생성물이며 손으로 유지하지 않는다.
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| TODO — `/coverage` 실행 전 | missing | (없음) | 미평가 | TODO |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
없음 — scaffolding/spec 단계 (frontend 코드 부재).
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: received-delegations:start -->
|
||||
### 수신한 위임
|
||||
|
||||
| Delegation Ref | From | Concern | Status |
|
||||
|---|---|---|---|
|
||||
| `DELEG-FE-003@1` | [[raw/branch-notes/feature-frontend-architecture-enforcement-lint-contract]] | `fe.deleg.composition-root-review` | accepted |
|
||||
| `DELEG-FE-005@1` | [[raw/branch-notes/feature-api-client-response-envelope-contract]] | `fe.deleg.injectable-random` | accepted |
|
||||
<!-- GENERATED: received-delegations:end -->
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
| `FE-OC-004@1` | [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] | build-time, runtime-public, secret config를 MUST 분리하고 boot 전에 runtime config를 검증 | import 참조로 적용 |
|
||||
| `FE-OC-024@1` | [[raw/branch-notes/feature-sample-feature-slice-contract-fixture]] | sample은 contract fixture이며 production feature가 의존하면 안 됨 | import 참조로 적용 |
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
### Sub-branches (세부 작업)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 오류 기록 (이 branch 작업 중 발생)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 강의 (이 작업을 위해 학습한 강의)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### job-posting tie-ins (이 작업에서 파생된 글감)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-frontend-operational-contract/branch-notes/feature-frontend-contract-compatibility-governance.md
|
||||
@@ -0,0 +1,292 @@
|
||||
---
|
||||
title: branch / feature-frontend-contract-compatibility-governance
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-023
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-frontend-operational-contract
|
||||
work_item: WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-023
|
||||
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-REGISTRY-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CONFIG-FALLBACK-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-022, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-006, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-011]
|
||||
contract_packet: 1
|
||||
branch: feature-frontend-contract-compatibility-governance
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton-frontend, ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-frontend-operational-contract]
|
||||
tags: [branch, ca-skeleton, frontend, api-design, semver, api-contract]
|
||||
created: 2026-07-18
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: eb4a422e6da50f16e5b6d59964943ebca79d655fc09066815953aa3ba22e4311
|
||||
imports: [ART-FE-003@1, FE-GATE-004@1, FE-GATE-015@1, FE-GATE-016@1, FE-OC-004@1, FE-OC-007@1, FE-OC-012@1, FE-OC-013@1, FE-OC-016@1, FE-OC-017@1]
|
||||
---
|
||||
|
||||
# branch: feature-frontend-contract-compatibility-governance
|
||||
|
||||
> Layer: `raw/branch-notes/` — TODO·결정·진행 기록. 구현 결과는 검증 뒤 `/ingest`로만 추출한다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/project-notes/ca-skeleton-frontend-operational-contract]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: version tuple·additive/breaking fixture·migration/rollback rule가 검증된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-REGISTRY-001@1` | route, API operation, env, storage, error, query, telemetry, release token을 8개 registry로 관리한다 | registry 변경의 compatibility impact와 version tuple 입력을 집행한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CONFIG-FALLBACK-001@1` | runtime config fallback 시 environment별 rebuild는 허용하되 artifact의 multi-environment 재사용은 금지한다 | config 변경의 migration·fallback·rollback 호환성 규칙에 적용한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | compatibility impact 공통 어휘를 고정한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D2 | boot compatibility를 version tuple로 판정한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D3 | schema 계열별 독립 version field를 사용한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D4 | breaking 변경은 migration·version bump·discard·fallback과 test evidence를 요구한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D5 | rollback은 coherent tuple 집합을 복원한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D6 | 호환 불가 cache data는 기본 discard한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
없음.
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
이 브랜치는 프로젝트 계약 `FE-OC-023`("API/config/storage/release schema의 breaking change는 migration 또는 version bump 없이 배포하면 안 됨", minimum evidence = compatibility report)을 **구현 착수 가능한 명세**로 낮춘다. hub는 호환성 규칙을 여러 곳에 흩어 정의해 두었다 — 분류 어휘([[raw/project-notes/ca-skeleton-frontend-operational-contract]] §3.3), registry 변경 프로토콜(§5.10), boot compatibility tuple(§12.3), rollback invariant(§12.5). 본 브랜치는 이들을 **하나의 governance 계약**(버전 tuple 행렬 + additive/breaking 분류 fixture + migration/rollback 규칙)으로 통합해 owner로서 mechanism과 test를 제공한다. 결정 자체(`FE-D012/013/016/019`)는 다른 owner 브랜치가 소유하고, 본 브랜치는 그 결정들이 공유하는 `FE-OC-023` 계약의 **집행 규칙**만 소유한다.
|
||||
|
||||
- 이슈: 없음 (스캐폴딩 단계)
|
||||
- PR: 없음
|
||||
|
||||
산출물 등급: 프론트엔드 코드가 없으므로 이 브랜치의 모든 구현 주장은 `planned`이다.
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- **버전 tuple 행렬**: `(buildId, configSchemaVersion, apiContractVersion, assetManifestHash, releaseId)` + storage schemaVersion + query namespace version 을 필드별 source·compatibility 역할·mismatch 결과로 정리한 표(hub §12.3 / §5.9 통합).
|
||||
- **additive vs breaking 분류 fixture**: `compatibility_impact ∈ {none, additive, behavior-change, breaking}` 어휘(hub §3.3)를 API/config/storage/release 4개 schema 계열에 적용하는 synthetic fixture 집합과 각 등급의 required action.
|
||||
- **migration/rollback 규칙**: breaking 변경이 version bump·migration·discard·fallback 없이 merge/배포되지 않게 하는 규칙 + rollback이 coherent tuple 집합을 복원하도록 하는 규칙(hub §5.10 / §9.2 / §12.5).
|
||||
- **compatibility gate 소유**: `FE-GATE-014@1`(config compatibility) 의 fixture·report artifact 정의. `FE-GATE-015`(release coherence) 는 **소유가 아니라 소비/기여** 다 — Owner 는 hub §2.1.1 이 [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] 로 확정했고, 본 branch 는 그 gate 가 쓰는 version tuple 호환 판정을 공급한다.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외. 인접 계약은 각 owner 브랜치가 소유하며 본 브랜치는 그 계약을 *소비*하고 호환성 영향만 집행한다.
|
||||
|
||||
- runtime config schema 정의·boot 검증 mechanism → [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] (owns `FE-OC-004`).
|
||||
- boundary runtime(Zod) schema 검증 → [[raw/branch-notes/feature-runtime-schema-validation-contract]] (owns `FE-OC-007`).
|
||||
- storage key namespace·schemaVersion·migration mechanism → [[raw/branch-notes/feature-frontend-storage-registry-contract]] (owns `FE-OC-013`).
|
||||
- 8개 registry single-owner·diff-check tooling → [[raw/branch-notes/feature-frontend-contract-registry-governance]] (owns `FE-OC-022`).
|
||||
- release directory·atomic pointer·실제 rollback drill 실행 → [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] (owns `FE-OC-016`, `FE-OC-017`).
|
||||
- API client retry/idempotency 동작 → [[raw/branch-notes/feature-api-client-response-envelope-contract]] (owns `FE-OC-006`, `FE-OC-009`).
|
||||
- CI gate blocking 분리 wiring → [[raw/branch-notes/feature-frontend-ci-quality-gates-contract]].
|
||||
- backend API versioning 정책과 실제 migration 실행 → backend / 외부 owner (frontend 계약 밖).
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/semver-2-0-0-spec-semver-official]] | D1 분류 어휘의 공식 근거(`SEMVER-C1`: MAJOR=incompatible / MINOR=backward-compatible additive / PATCH=backward-compatible fix). 단 SEMVER-C1은 "무엇이 breaking인지" 자동 분류는 증명하지 않으므로 경계 정의는 project decision(D1). |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | §3.3 compatibility_impact 어휘·§5.10 registry change protocol·§12.3 compatibility tuple·§12.5 rollback invariant·§9.2 cache discard·§6.4 config 검증·§15 `FE-GATE-014/015` — D1~D6 전부의 project-decision 근거. |
|
||||
|
||||
> 공식 표준(semver)이 *어휘*를 주고, hub가 *프로젝트 적용 규칙과 tuple 필드*를 준다. 두 계층이 함께 D1~D6을 닫는다.
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 등급: `planned`(코드 없음).
|
||||
|
||||
- [ ] 버전 tuple 행렬을 §구현 가이드 1에 확정 — 필드·source·compatibility 역할·mismatch 결과 — 등급: `planned`
|
||||
- [ ] additive/breaking 분류 fixture 표를 §구현 가이드 2에 확정(4개 schema 계열 × 각 등급 예시) — 등급: `planned`
|
||||
- [ ] migration/rollback 규칙 R1~R5를 §구현 가이드 3에 확정 — 등급: `planned`
|
||||
- [ ] `FE-GATE-014` config compatibility fixture(old/new config) + report artifact 스펙 — 등급: `planned`
|
||||
- [ ] `FE-GATE-015` release coherence fixture(mixed HTML/asset/config) + verification artifact 스펙 — 등급: `planned`
|
||||
- [ ] cache 호환성 default(discard) vs migration 선택 규칙 명세(§9.2 소유 조건) — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- hub는 `FE-OC-023`의 owner를 이 브랜치로 지정하지만 `FE-D*` 결정 표에는 이 브랜치를 owner로 둔 행이 없다. 즉 이 브랜치는 *결정*이 아니라 *집행 규칙(governance)*을 소유한다 — 다른 브랜치의 `FE-D012/013/016/019`가 만든 schema 변경을 `FE-OC-023` 규칙으로 검사한다.
|
||||
- 미해결 위험(seed에서 승계): additive 변경이 cache+config+release **조합**에서 breaking이 될 수 있다(§구현 가이드 3의 Open Risk / R4에서 추적).
|
||||
- 버전 encoding(정수 MAJOR vs semver 문자열)은 hub가 "major incompatibility"만 말하고 literal 표기는 정하지 않았다 → §구현 가이드에서 `UNSUPPORTED_IMPL_DECISION`으로 표시.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 아래는 Decision Evidence Map의 prose 요약. 근거는 Sources 및 hub 섹션 참조.
|
||||
|
||||
- 2026-07-18: **D1** compatibility_impact 분류 어휘를 `{none, additive, behavior-change, breaking}` 단일 enum으로 채택하고 API/config/storage/release 4개 schema 계열 모두에 적용 / 이유: hub §3.3이 이 4값을 이미 정의; semver `SEMVER-C1`이 breaking/additive/fix 의미론을 공식 뒷받침 / 대안: 계열별 별도 어휘 / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §3.3 + `raw/official-docs/semver-2-0-0-spec-semver-official.md#SEMVER-C1`.
|
||||
- 2026-07-18: **D2** boot 호환성 identity를 `(buildId, configSchemaVersion, apiContractVersion, assetManifestHash, releaseId)` tuple로 판정하고 string lexical compare를 금지 / 이유: hub §12.3이 tuple과 비교 규칙을 명시 / 대안: 단일 monolithic release 버전 문자열 비교 / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §12.3 + §5.9.
|
||||
- 2026-07-18: **D3** 각 schema 계열은 독립 버전 필드를 가지며 breaking = MAJOR 상향(config/API), storage는 `schemaVersion` increment, query는 namespace version bump / 이유: hub §5.4/§5.5/§5.7이 필드를 정의; semver `SEMVER-C1` MAJOR 의미론 / 대안: 전 계약 공통 단일 버전 / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §5.4 §5.5 §5.7.
|
||||
- 2026-07-18: **D4** breaking/behavior-change 변경은 migration·version bump·discard·fallback 중 하나 + test evidence 없이 merge 금지 / 이유: hub §3.3(4)·§5.10(4) 규칙 / 대안: 없음(invariant) / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §3.3 §5.10.
|
||||
- 2026-07-18: **D5** rollback은 coherent tuple 집합(HTML+asset manifest+assets+compatible config+compatible API+release manifest)을 복원하고 HTML-only rollback을 금지 / 이유: hub §12.5 rollback invariant / 대안: 없음(invariant) / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §12.5.
|
||||
- 2026-07-18: **D6** 호환 불가 cache data는 default로 discard(재사용 금지)하며 migration을 선택할 때만 본 브랜치가 fixture·rollback을 소유 / 이유: hub §9.2 / 대안: 항상 migration / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §9.2.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | compatibility_impact 어휘 = `{none, additive, behavior-change, breaking}` 단일 enum, 4개 schema 계열 공통 (`FE-OC-023`) | 4개 계열이 하나의 governance register를 공유하는 한 유지; 어떤 계열이 5번째 impact class가 필요하면 계열별 어휘로 분기 | `raw/official-docs/semver-2-0-0-spec-semver-official.md#SEMVER-C1`; [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §3.3 | `official-standard`(어휘) + `project-decision`(경계) | semver는 "무엇이 breaking인지"를 자동 분류하지 않음(`SEMVER-C1` does-not-prove) — 경계 정의가 사람 판단에 남음 |
|
||||
| D2 | boot 호환성 identity = 5-field tuple `(buildId, configSchemaVersion, apiContractVersion, assetManifestHash, releaseId)`, lexical compare 금지 (`FE-OC-023`, `FE-OC-016`) | static SPA release 인 동안 유지; SSR/edge 도입 시 별도 project fork(§6.2) 또는 tuple 차원 추가 시 재검토 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §12.3, §5.9 | `project-decision` | tuple 필드 중 하나라도 source가 비어 있으면(예: provider가 releaseId 미노출) 판정 불가 → §9 검증 대상 |
|
||||
| D3 | 계열별 독립 버전 필드; breaking→config/API MAJOR 상향, storage `schemaVersion` increment, query namespace version bump (`FE-OC-004`, `FE-OC-007`, `FE-OC-012`, `FE-OC-013`) | 외부 codegen SSOT가 없는 동안 유지; code generation SSOT 채택 시 버전 표기를 codegen 산출로 이관(hub `FE-D018` revisit trigger와 정렬) | `raw/official-docs/semver-2-0-0-spec-semver-official.md#SEMVER-C1`; [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §5.4, §5.5, §5.7 | `official-standard` + `project-decision` | literal encoding(정수 MAJOR vs semver 문자열) 미확정 → §구현 가이드 `UNSUPPORTED_IMPL_DECISION` |
|
||||
| D4 | breaking/behavior-change는 migration·version bump·discard·fallback 중 하나 + test evidence 없이 merge 금지 (`FE-OC-023`) | invariant — 항상 성립. 단 "additive"로 분류된 변경은 이 게이트를 우회하므로 분류 정확성이 전제 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §3.3, §5.10 | `project-decision`(invariant) | 오분류(breaking을 additive로) 시 게이트가 조용히 통과 → D1 분류 fixture로 방어 |
|
||||
| D5 | rollback은 coherent tuple 집합 복원, HTML-only rollback 금지 (`FE-OC-017`, `FE-OC-023`) | invariant — 항상 성립. 실제 drill 실행·pointer switch mechanism은 release-cache-rollback owner에 위임 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §12.5 | `project-decision`(invariant) | provider가 atomic pointer/cache purge를 지원하지 않으면 coherence 보장 불가 → reachability probe 필요(§12.5) |
|
||||
| D6 | 호환 불가 cache data는 default discard; migration 선택 시에만 본 브랜치가 fixture·rollback 소유 (`FE-OC-012`, `FE-OC-013`) | offline/persistence 요구가 없어 data 손실이 허용되는 동안 discard 유지; offline 요구가 생기면 migration으로 전환(hub `FE-D019` service worker off 조건과 연동) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §9.2 | `project-decision` + `conditional-default` | discard가 UX상 허용되는지 미검증(현재 persistence default off이라 위험 낮음) |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> `planned` blueprint. 프론트엔드 코드가 없으므로 경로는 hub §4.6 Planned directory blueprint / §5 registry owner map에서 인용한 *예정 경로*이다. 실제 path는 repository 생성 후 확정한다.
|
||||
|
||||
### 1. 버전 tuple 행렬 (Version tuple matrix)
|
||||
|
||||
> **Trace**: D2 (5-field boot tuple) + D3 (계열별 버전 필드). 근거 [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §12.3 (tuple + 비교 규칙), §5.9 (release token registry), §5.4 (`CONFIG_SCHEMA_VERSION`/`API_CONTRACT_VERSION`).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: literal 버전 encoding(정수 MAJOR `"3"` vs semver 문자열 `"3.1.0"`). hub는 "major incompatibility"만 말하고 표기를 정하지 않음. trade-off: 정수 MAJOR는 boot 호환 판정이 가장 단순하나 additive/minor 가시성을 잃음 → **boot 판정용 정수 MAJOR + 진단용 optional MINOR** 병기를 제안(planned).
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 필드 저장 위치 파일명(예: `src/contracts/compatibility-tuple.js`). hub §4.6은 `src/bootstrap/`, `src/contracts/` 계층만 주고 파일명은 미지정. trade-off: contract 계층에 두어 boot·application 양쪽이 참조 가능하게 함.
|
||||
|
||||
| 버전 필드 | Source (§5 registry) | Compatibility 역할 | Mismatch 시 동작 (§12.3) | 정규화 error kind (§5.6) |
|
||||
|---|---|---|---|---|
|
||||
| `buildId` | CI build (`VITE_BUILD_ID`, §5.4) | asset/HTML coherence | assetManifestHash와 함께 coherence 판정 | `DEPLOY_MISMATCH` |
|
||||
| `configSchemaVersion` | runtime config schema (`CONFIG_SCHEMA_VERSION`, §5.4/§5.9) | boot compatibility | major incompatible → boot fail, product route 미mount | `BOOT_CONFIG_FAILURE` |
|
||||
| `apiContractVersion` | frontend/backend agreement (`API_CONTRACT_VERSION`, §5.4/§5.9) | schema compatibility | incompatible → route mount fail 또는 explicitly supported compatibility adapter | `DEPLOY_MISMATCH` |
|
||||
| `assetManifestHash` | build output (§5.9) | chunk integrity/mismatch | mismatch → controlled reload **once**(§10.2 guard) | `CHUNK_LOAD_FAILURE` |
|
||||
| `releaseId` | deploy system (§5.9) | rollback target | 나머지 버전 호환 시 mismatch → warning telemetry 후 continue 가능 | (telemetry only) |
|
||||
| storage `schemaVersion` | storage registry physicalKey `v<schema>` (§5.5) | 영속 data 호환 | previous version 읽으면 migration 또는 discard | `STORAGE_*` / discard |
|
||||
| query namespace version | query key registry (§5.7) | cache identity partition | API/schema breaking → namespace version bump; 호환 불가 cache → discard(D6) | `QUERY_CACHE_FAILURE` |
|
||||
|
||||
핵심 규칙(§12.3 그대로): **string lexical compare로 버전 호환을 판정하지 않는다.** 각 필드는 선언된 버전 값으로만 비교한다.
|
||||
|
||||
### 2. Additive vs breaking 분류 fixture
|
||||
|
||||
> **Trace**: D1 (분류 어휘) + D4 (분류→required action). 근거 `raw/official-docs/semver-2-0-0-spec-semver-official.md#SEMVER-C1`, [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §3.3, §6.4 (unknown key policy), §5.5, §5.7.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 분류를 사람이 PR checklist로 판정할지 diff 도구로 자동화할지. hub §3.3은 *수동 프로토콜*만 정의. trade-off: 초기엔 수동 checklist + fixture로 회귀 방지, 자동 diff 도구는 registry-governance 브랜치 tooling으로 위임(planned).
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: fixture 디렉터리·파일명(예: `test/compatibility/fixtures/*.json`). hub는 fixture *존재*(§15 "old/new config versions", "mixed HTML/assets/config")만 요구, 경로 미지정. trade-off: gate별 하위 폴더로 분리해 `FE-GATE-014`/`015`가 독립 소비.
|
||||
|
||||
| 변경 예시 | compatibility_impact | 근거 규칙 | Required action |
|
||||
|---|---|---|---|
|
||||
| config에 optional key 추가(schema passthrough/default 존재) | `additive` | §6.4 unknown key: additive keys allowed only if schema explicitly passthroughs | 버전 bump optional, migration 불필요 |
|
||||
| config에 required key 추가 / 기존 key 의미 변경 | `breaking` | §5.4 `CONFIG_SCHEMA_VERSION` compatibility fail | configSchemaVersion MAJOR 상향 + migration/fallback + `FE-GATE-014` fixture |
|
||||
| API 응답에 optional field 추가(schema가 unknown 안전 처리) | `additive` | §6.4 default strict; passthrough 시 additive | none/additive, apiContractVersion 유지 |
|
||||
| API 응답 field 제거·rename(mapper가 소비) | `breaking` | §5.7 "API/schema breaking change" | apiContractVersion 상향 + compatibility adapter 또는 coordinated release |
|
||||
| storage 값 shape 변경 | `breaking` | §5.5 "incompatible change 시 increment", migration/discard | storage `schemaVersion` increment + migration 또는 discard(D6) |
|
||||
| release asset set 변경(chunk hash 변경) | 호환상 `none` | §12.3 assetManifestHash coherence | atomic deploy 순서(§12.4), coherence는 `FE-GATE-015`가 검증 |
|
||||
| error kind enum 제거 | `breaking`(behavior-change) | §5.6 stable enum | consumer migration + version note, D4 게이트 |
|
||||
|
||||
분류 경계의 근거 한계: `SEMVER-C1`은 MAJOR=incompatible / MINOR=additive / PATCH=fix *의미론*을 주지만 "내부 구현 변경이 API에 미치는 영향을 자동 분류하지 않는다"(does-not-prove). 따라서 위 표의 각 행 경계는 **project decision(D1)**이며 fixture로 회귀 고정한다.
|
||||
|
||||
### 3. rollback 규칙
|
||||
|
||||
> **Trace**: D4 (merge 게이트) + D5 (rollback coherence) + D6 (cache discard). 근거 [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §3.3, §5.10, §9.2, §12.5.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: migration 함수 배치·명명(예: storage per-version migrator API 모양). hub §5.5는 "migration 또는 discard" 원칙만, mechanism 미지정. trade-off: storage adapter 소유이므로 storage-registry 브랜치와 공동 정의 — 본 브랜치는 *규칙*만, migrator *구현*은 위임(R3).
|
||||
|
||||
- **R1 (no silent breaking)**: `compatibility_impact ∈ {behavior-change, breaking}` 인 변경은 migration OR version bump + test evidence 없이 merge 금지(§3.3.4). additive/none은 게이트 우회 가능하나 §2 분류 fixture로 오분류 방어.
|
||||
- **R2 (breaking → 처리 택1)**: registry/storage/cache breaking은 version bump와 함께 **migration · discard · fallback** 중 하나를 명시(§5.10.4, §9.2). "택1"을 비우면 orphan token scan(§5.10.8)과 D4 게이트가 fail.
|
||||
- **R3 (rollback coherence)**: rollback target은 prior HTML + prior asset manifest·assets + compatible runtime config + compatible API contract(또는 backend compatibility window) + release manifest 의 **coherent set**을 복원한다(§12.5). HTML만 과거로 되돌리고 config를 최신에 남기는 rollback 금지.
|
||||
- **R4 (조합 breaking 방어)**: 개별 additive라도 cache+config+release **조합**에서 incompatible하면 D6에 따라 cache discard로 강등한다(§9.2). 이 조합 판정은 §1 tuple 행렬 전체를 함께 평가한다. — *잔여 위험: 조합 폭발을 전수 fixture로 덮지 못할 수 있음(§9 검증 대상).*
|
||||
- **R5 (비교 방식)**: 모든 버전 비교는 선언 필드 기준(§12.3), lexical string compare 금지.
|
||||
|
||||
gate 소유 매핑:
|
||||
|
||||
| Gate | 이 브랜치 산출물 |
|
||||
|---|---|
|
||||
| `FE-GATE-014@1` config compatibility (Owner = 본 branch) | old/new config version fixture 제공 |
|
||||
| `FE-GATE-015@1` release coherence (Owner = release-cache) | version tuple 호환 판정 공급 |
|
||||
| `FE-GATE-004@1` runtime schema (Owner = runtime-schema-validation) | config invalid matrix 에 compatibility 필드 기여 |
|
||||
|
||||
각 gate 의 blocking scope·pass condition·evidence artifact 는 hub §15.1 소유이며 여기에 옮겨 적지 않는다.
|
||||
|
||||
> **UNSUPPORTED_IMPL_DECISION**: artifact 파일 경로(예: `artifacts/release/compatibility-report.json`). hub §15는 artifact *이름*("compatibility report"/"release verification")만 주고 경로 미지정. trade-off: §14.3 `pnpm verify:release`(`artifacts/release/verification.json`) 관례를 따라 `artifacts/release/` 하위로 통일(planned).
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- config schema major incompatible → `BOOT_CONFIG_FAILURE`, product route 미mount, boot error shell만 렌더(§6.3).
|
||||
- API contract incompatible → route mount fail 또는 supported compatibility adapter, `DEPLOY_MISMATCH`(§12.3).
|
||||
- asset manifest mismatch → controlled reload **once**; 같은 release pair 두 번째 실패 시 auto reload 중단·rollback/support surface(§10.2 `CHUNK_RELOAD_GUARD`).
|
||||
- releaseId만 mismatch·나머지 호환 → warning telemetry(`release.mismatch.detected`) 후 continue(§12.3).
|
||||
- 호환 불가 cache → discard, 재사용 금지(§9.2, D6).
|
||||
- partial rollout / cached config / mixed release: tuple 조합이 incompatible일 수 있음 → R4로 강등, 잔여는 `needs-confirmation`.
|
||||
- **다른 계약 의존** (§20 Dependency + §4.3 matrix; 각 sibling은 FE-OC 계약으로만 참조 — 로컬 D 번호 미확인):
|
||||
- [[raw/branch-notes/feature-frontend-contract-registry-governance]] 의 `FE-OC-022` — 8개 registry가 single owner·compatibility impact를 기록해야 본 브랜치 분류가 대상 필드를 가짐. 그 계약이 바뀌면 §1 tuple 행렬 필드 source가 흔들린다.
|
||||
- [[raw/branch-notes/feature-runtime-schema-validation-contract]] 의 `FE-OC-007` — boundary schema 검증이 additive/breaking을 실제로 감지(unknown key strict/passthrough)한다. §2 분류의 런타임 근거.
|
||||
- [[raw/branch-notes/feature-frontend-storage-registry-contract]] 의 `FE-OC-013` — storage `schemaVersion`·migration/discard mechanism 소유. 본 브랜치의 cache-discard 결정과 §3 R2가 이 계약 위에서 동작.
|
||||
- [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] 의 `FE-OC-004` — `CONFIG_SCHEMA_VERSION` 을 runtime config로 공급(그 브랜치 결정 근거는 hub [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D012, FE-D013).
|
||||
- [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] 의 `FE-OC-016`, `FE-OC-017` — 실제 rollback drill·release tuple 산출. 본 브랜치 rollback-coherence 규칙의 집행 주체(그 브랜치 근거는 hub [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D019~FE-D023).
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| breaking change가 migration/version bump 없이 배포되지 않는다 | CI·release flow 없음, 코드 없음 | additive/breaking synthetic fixture(§2) → `FE-GATE-014` config compatibility test(old/new config: supported pass / incompatible boot fail) | `needs-confirmation` |
|
||||
| release coherence가 mixed HTML/asset/config를 탐지한다 | 실제 release verification 미실행 | `FE-GATE-015` mixed fixture → mismatch detected / coherent set passes → release verification artifact | `needs-confirmation` |
|
||||
| 버전 비교가 lexical string compare에 의존하지 않는다 | 구현 없음 | tuple 비교 함수 unit test에 `"9" vs "10"` 류 lexical trap fixture 포함 | `planned` |
|
||||
| 호환 불가 cache data가 discard되고 재사용되지 않는다 | query cache 구현 없음 | query namespace version bump 시 stale cache discard integration test(§9.2) | `planned` |
|
||||
| rollback이 coherent tuple 집합을 복원한다(HTML-only rollback 차단) | 실제 rollback drill 없음 | `FE-GATE-016` rollback drill: HTML-only rollback fixture가 fail, coherent tuple rollback이 pass(§12.5) | `needs-confirmation` |
|
||||
| additive 변경이 cache+config+release 조합에서 breaking이 되지 않는다(또는 R4로 강등된다) | 조합 폭발, 전수 fixture 어려움 | 대표 조합 fixture matrix로 R4 강등 경로 검증; 미커버 조합은 명시적 잔여 위험 기록 | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
- 스캐폴딩 단계: `/coverage` 실행 전 수동 행을 만들지 않는다.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 없음 — 스캐폴딩 단계.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: artifact-imports:start -->
|
||||
### 가져온 artifact 계약
|
||||
|
||||
| Artifact Ref | Owner | Producer | Schema Ref |
|
||||
|---|---|---|---|
|
||||
| `ART-FE-003@1` | [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] | [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] | `harness/source/artifact-schemas/ca-skeleton-frontend/release-verification.schema.json` |
|
||||
<!-- GENERATED: artifact-imports:end -->
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
| `FE-GATE-004@1` | [[raw/branch-notes/feature-runtime-schema-validation-contract]] | invalid fixture 가 예상 kind 로 거부되지 않으면 merge 를 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-GATE-015@1` | [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] | 혼재된 release 조합이 감지되지 않으면 release 를 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-GATE-016@1` | [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] | rollback 과 smoke 증거가 없으면 production promotion 을 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-OC-004@1` | [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] | build-time, runtime-public, secret config를 MUST 분리하고 boot 전에 runtime config를 검증 | import 참조로 적용 |
|
||||
| `FE-OC-007@1` | [[raw/branch-notes/feature-runtime-schema-validation-contract]] | JSON envelope와 payload는 boundary에서 runtime schema를 MUST 통과 | import 참조로 적용 |
|
||||
| `FE-OC-012@1` | [[raw/branch-notes/feature-server-state-caching-contract]] | query key와 invalidation은 registry factory만 MUST 사용 | import 참조로 적용 |
|
||||
| `FE-OC-013@1` | [[raw/branch-notes/feature-frontend-storage-registry-contract]] | storage key는 namespace·version·classification을 MUST 가지며 token/secret 저장을 금지 | import 참조로 적용 |
|
||||
| `FE-OC-016@1` | [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] | HTML, asset, runtime config, release manifest cache policy를 MUST 구분 | import 참조로 적용 |
|
||||
| `FE-OC-017@1` | [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] | rollback은 immutable prior release로 수행하고 build/config/API compatibility를 MUST 검증 | import 참조로 적용 |
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
- 없음 — 자식 자료는 생성 후 controller가 parent Cluster와 함께 등록한다.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- 없음 — daily note는 이 작업에서 수정하지 않는다.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크: 없음
|
||||
- 리뷰 메모: 없음
|
||||
- 머지 결과 / 배포 환경: `planned`
|
||||
- **wiki 추출 대상** (verified만): 없음
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned): 현재 전체
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-frontend-operational-contract/branch-notes/feature-frontend-contract-registry-governance.md
|
||||
@@ -0,0 +1,302 @@
|
||||
---
|
||||
title: branch / feature-frontend-contract-registry-governance
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-022
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-frontend-operational-contract
|
||||
work_item: WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-022
|
||||
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-REGISTRY-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-001, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-002]
|
||||
contract_packet: 1
|
||||
branch: feature-frontend-contract-registry-governance
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton-frontend, ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-frontend-operational-contract]
|
||||
tags: [branch, ca-skeleton, frontend, api-design, javascript, api-contract]
|
||||
created: 2026-07-18
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 42419c3541919063c7668d0cbd210002b61996c27b573d86b36ba2b19337597b
|
||||
imports: [FE-OC-004@1, FE-OC-005@1, FE-OC-006@1, FE-OC-008@1, FE-OC-012@1, FE-OC-013@1, FE-OC-014@1, FE-OC-016@1, FE-OC-020@1, FE-OC-023@1]
|
||||
|
||||
---
|
||||
|
||||
# branch: feature-frontend-contract-registry-governance
|
||||
|
||||
> Layer: `raw/branch-notes/` — TODO·결정·진행 기록. 구현 결과는 검증 뒤 `/ingest`로만 추출한다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/project-notes/ca-skeleton-frontend-operational-contract]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: 8개 registry snapshot·schema validation·single-owner check가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-REGISTRY-001@1` | route, API operation, env, storage, error, query, telemetry, release token을 8개 registry로 관리한다 | 8개 registry의 owner·schema·impact·snapshot governance를 집행한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | 8개 registry를 single-owner model로 관리한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D2 | project owner map을 registry 소유 SSOT로 사용한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D3 | 모든 registry change에 compatibility impact를 기록한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D4 | uniform schema validation과 orphan scan을 실행한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D5 | per-registry snapshot과 diff를 evidence로 남긴다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D6 | producer와 consumer test의 동기 갱신을 gate한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
없음.
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- 이 branch는 `FE-OC-022`(8개 registry는 single primary owner와 compatibility impact를 MUST 기록)를 *구현 착수 가능한 governance 명세*로 내린다. 근거는 [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D018 (route/API-operation/env/storage/error/query/telemetry/release token을 8개 registry로 관리)이며, 관리 대상 registry 목록과 owner는 [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §5.1 owner map, 변경 절차는 §5.10, compatibility 분류는 §3.3에서 온다.
|
||||
- 본 branch는 **registry의 *내용*(각 registry의 schema field·row)을 재정의하지 않는다.** 각 registry의 schema는 그 registry의 owner branch가 소유한다(§5.2~§5.9). 본 branch는 그 registry들을 *가로질러* 강제하는 **governance 규칙**만 소유한다: owner map single-owner check, uniform schema-validation harness, compatibility-impact 기록 gate, per-registry snapshot/diff. 모든 항목은 repo가 없으므로 `planned`.
|
||||
- 이슈: 없음 (스캐폴딩 단계)
|
||||
- PR: 없음
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- **owner map governance** — [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §5.1의 8-registry owner map을 registry 소유의 SSOT로 고정하고, single-owner check(registry당 owner가 0개/2개 이상이면 fail)를 정의 (`FE-OC-022`).
|
||||
- **uniform schema-validation harness** — 8개 registry 각 row가 *owner가 선언한* minimum schema(§5.2~§5.9)를 만족하는지 대조 + orphan/ad hoc token scan = 0 (`FE-SC-005`, §5.10 step 8).
|
||||
- **compatibility-impact 기록 gate** — 모든 registry change가 `compatibility_impact ∈ {none, additive, behavior-change, breaking}`를 MUST 기록 (§3.3, §5.10).
|
||||
- **per-registry snapshot + diff artifact** — `FE-OC-022`의 minimum evidence(registry diff check) 산출물.
|
||||
- **producer/consumer test 동기 갱신 gate** — registry change 시 producer test와 consumer test가 *함께* 갱신되었음을 검사 가능한 증거로 강제 (§5.10 step 5). 개별 test 자체의 계층·러너·fixture 책임은 [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] (`FE-OC-020`) 소유이며, 본 branch는 *registry change 시점의 동기 갱신 여부*만 gate 한다.
|
||||
- contributes to (owner 아님, fixture/gate 협업): [[raw/branch-notes/feature-routing-navigation-guard-contract]] (`FE-OC-005`), [[raw/branch-notes/feature-api-client-response-envelope-contract]] (`FE-OC-006`), [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] (`FE-OC-004`), [[raw/branch-notes/feature-frontend-storage-registry-contract]] (`FE-OC-013`), [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] (`FE-OC-008`), [[raw/branch-notes/feature-server-state-caching-contract]] (`FE-OC-012`), [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] (`FE-OC-014`), [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] (`FE-OC-016`), [[raw/branch-notes/feature-frontend-contract-compatibility-governance]] (`FE-OC-023`), [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] (`FE-OC-020`).
|
||||
- ⚠️ hub §20 branch decomposition의 "Contributes to" cell은 이 branch에 대해 `FE-OC-004`(env)·`FE-OC-012`(query)를 누락하고 있다. 그러나 §5.1 owner map은 `FE-REG-ENV`·`FE-REG-QUERY`를 8개 governed registry에 포함하므로, 본 note의 owner map(§1)과 위 목록은 §5.1을 따른다. hub 수정은 hub owner 소관 — 본 branch는 hub를 편집하지 않는다.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외 — 다른 owner branch 소유. 여기서 detail을 정의하면 `OUT_OF_BRANCH_SCOPE` bleed (CLAUDE.md §15.5 R3).
|
||||
|
||||
- **각 registry의 실제 내용·schema field·초기 row** — 그 registry의 owner branch 소유: route [[raw/branch-notes/feature-routing-navigation-guard-contract]] (`FE-OC-005`), API operation [[raw/branch-notes/feature-api-client-response-envelope-contract]] (`FE-OC-006`), env [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] (`FE-OC-004`), storage [[raw/branch-notes/feature-frontend-storage-registry-contract]] (`FE-OC-013`), error [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] (`FE-OC-008`), query key [[raw/branch-notes/feature-server-state-caching-contract]] (`FE-OC-012`), telemetry [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] (`FE-OC-014`), release token [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] (`FE-OC-016`). 본 branch는 그 schema를 *검증*할 뿐 *정의*하지 않는다.
|
||||
- **test 계층·러너·fixture 분류 자체** — [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] (`FE-OC-020`) 소유. 본 branch는 "어떤 test를 어떻게 짜는가"를 정의하지 않고, registry change PR에서 producer/consumer test가 *함께 움직였는지*만 검사한다.
|
||||
- **version-tuple matrix, additive/breaking fixture, migration/rollback 규칙** — [[raw/branch-notes/feature-frontend-contract-compatibility-governance]] (`FE-OC-023`) 소유. 본 branch는 impact label을 *기록*하고, breaking 판정 후의 version bump·migration 메커니즘은 그 branch로 위임한다.
|
||||
- **registry code generation SSOT** — [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-D018`의 revisit trigger(미도래). governance는 hand-maintained registry 파일을 전제로 한다.
|
||||
- **payload runtime boundary schema 검증** — [[raw/branch-notes/feature-runtime-schema-validation-contract]] (`FE-OC-007`) 소유. registry schema 검증(build/test-time)과 다른 관심사.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/semver-2-0-0-spec-semver-official]] | compatibility impact label 중 `breaking`/`additive`/`patch` 구분의 외부 표준 기준 — `SEMVER-C1` (MAJOR/MINOR/PATCH 증가 의미론). (D3) |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | 8-registry 관리 결정 FE-D018, owner map §5.1, registry change protocol §5.10, decision change protocol §3.3 — governance 규칙 전체의 project-decision SSOT. (D1/D2/D3/D4/D5) |
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기. 현재 frontend repo 부재 → 전부 `planned`.
|
||||
|
||||
- [ ] §5.1 owner map을 governance manifest로 고정 + single-owner check(zero/duplicate owner fail) 정의 — 등급: `planned`
|
||||
- [ ] 8개 registry를 owner minimum schema(§5.2~§5.9)로 검증하는 uniform validation harness 명세 — 등급: `planned`
|
||||
- [ ] orphan/ad hoc token scan = 0 (`FE-SC-005`) 규칙 + 실패 fixture 정의 — 등급: `planned`
|
||||
- [ ] registry change 시 `compatibility_impact` 4-label 기록 gate + behavior-change/breaking merge block 규칙 — 등급: `planned`
|
||||
- [ ] per-registry snapshot + diff artifact(owner·affected FE-OC·impact 표면화) 명세 — 등급: `planned`
|
||||
- [ ] registry change 시 producer/consumer test 동기 갱신 gate(§5.10 step 5) 명세 — 검사 가능한 증거(PR touch-set + consumer-side token 참조 검증) 정의 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- registry row와 branch ownership의 분리 방식 확정: **ownership은 owner map manifest가 소유, registry의 실제 row/schema는 각 owner branch가 소유.** governance harness는 registry 파일을 *읽어 검증*할 뿐 *편집*하지 않는다 — 이로써 single-owner invariant를 유지한다.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> Decision Evidence Map의 prose mirror. 근거는 Sources 또는 hub decision register.
|
||||
|
||||
- 2026-07-19: 8개 contract registry를 **single-owner governance model**로 관리 (FE-D018) / 이유: rename·compatibility 영향 추적 / 검토한 대안: registry code generation SSOT (FE-D018 revisit trigger) / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D018 (`accepted-documented-only`).
|
||||
- 2026-07-19: `§5.1` owner map을 registry 소유 SSOT로 삼고 single-owner check로 zero/duplicate owner를 차단 / 이유: registry당 정확히 1 owner invariant / 검토한 대안: 명시적 co-owner protocol(현재 미채택) / 근거: `FE-OC-022`, §5.1.
|
||||
- 2026-07-19: registry change마다 `compatibility_impact` 4-label 기록, `behavior-change`/`breaking`은 migration/rollback/test evidence 없이 merge 금지 / 이유: 무증거 breaking 배포 차단 / 검토한 대안: 자유 서술 changelog / 근거: §3.3, §5.10, `SEMVER-C1` (version-tuple 메커니즘 자체는 `FE-OC-023` owner).
|
||||
- 2026-07-19: uniform schema-validation harness가 각 registry를 *owner가 선언한* minimum schema로 검증 + orphan token scan 0 / 이유: ad hoc token 0 (`FE-SC-005`) 강제 / 근거: `FE-OC-022`, §5.10 step 8.
|
||||
- 2026-07-19: per-registry snapshot + diff = `FE-OC-022`의 registry diff check evidence / 근거: §5.10 step 6-7.
|
||||
- 2026-07-20: registry change는 **producer test와 consumer test의 동기 갱신을 검사 가능한 증거로 증명**해야 merge 가능 (§5.10 step 5) / 이유: registry row만 바뀌고 test는 이전 token을 계속 검증하면 gate가 green인 채로 계약이 깨짐(silent contract drift) / 검토한 대안: (a) 사람 리뷰 체크리스트만 두기 — 검사 불가라 기각, (b) 전부 [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] (`FE-OC-020`)에 위임 — test *계층*은 그 branch 소유가 맞으나 "registry change 시점의 동기성"은 §5.10 registry change protocol의 step이므로 `FE-OC-022`가 소유 / 근거: §5.10 step 5 + step 8 orphan scan(`FE-SC-005`).
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정의 raw source claim. `Decision ID`는 이 branch-note 안에서 안정. FE-D### 참조는 hook 회피를 위해 hub project 경로에만 부착.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | 8개 registry를 single-owner governance model로 관리 ([[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D018 / `FE-OC-022`) | hand-maintained registry 파일 + governance gate가 default; code generation SSOT가 채택되면 generated registry로 전환 (FE-D018 revisit trigger) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D018, §5.1 | `project-decision` (accepted-documented-only) | FE-D018은 code evidence 없는 accepted-documented-only — repo 생성 전까지 governance gate 미검증 |
|
||||
| D2 | owner map §5.1이 registry 소유 SSOT; single-owner check가 zero/duplicate owner를 차단 (`FE-OC-022`) | registry당 정확히 1 owner가 invariant; 공동 소유가 필요하면 명시적 co-owner protocol을 신규 제안(planned)해야 하며 그 전엔 single-owner 강제 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §5.1 (`FE-OC-022`) | `project-decision` | owner map이 owner branch보다 늦게 갱신되면 `STALE_OWNER` 위험 |
|
||||
| D3 | registry change마다 `compatibility_impact`(none/additive/behavior-change/breaking) 기록; behavior-change/breaking은 migration/rollback/test 없이 merge 금지 (§3.3) | `none`·`additive`는 gate 통과; `behavior-change`·`breaking`은 version bump + migration/rollback/test evidence 필요(version-tuple 메커니즘은 `FE-OC-023` owner branch) | `raw/official-docs/semver-2-0-0-spec-semver-official.md#SEMVER-C1`; [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §3.3 · §5.10 | `official-doc + project-decision` | `SEMVER-C1`은 *무엇이* breaking인지 자동 분류하지 않음 — label 판정은 사람 판단, 오분류 위험 |
|
||||
| D4 | uniform schema-validation harness가 각 registry를 owner-declared minimum schema(§5.2~§5.9)로 검증 + orphan/ad hoc token scan 0 | 각 registry schema는 owner branch가 §5.2~§5.9에서 선언; governance는 그 schema 대조 + `FE-SC-005` orphan scan만 수행, schema 내용은 재정의 안 함 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §5.10 step 8 (`FE-OC-022`, `FE-SC-005`) | `project-decision` | validation 라이브러리/방식 미지정(UNSUPPORTED_IMPL_DECISION); owner schema 변경 시 harness 동기화 필요 |
|
||||
| D5 | per-registry snapshot + diff artifact = `FE-OC-022` registry diff check evidence | 모든 registry change에서 snapshot 재생성 + 이전 snapshot과 diff; diff는 owner·affected FE-OC·compatibility impact를 표면화 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §5.10 step 6-7 (`FE-OC-022`) | `project-decision` | snapshot 포맷/저장 경로 미지정(UNSUPPORTED_IMPL_DECISION) |
|
||||
| D6 | registry change는 producer/consumer test 동기 갱신을 검사 가능한 증거로 증명해야 merge 가능 (§5.10 step 5) | registry token이 add/rename/remove 되면 gate 발동; 순수 주석·문서 변경이면 미발동. test *계층/러너/fixture 분류*는 [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] (`FE-OC-020`) 소유, *동기성 검사*만 본 branch | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §5.10 step 5 · step 8 (`FE-OC-022`, `FE-SC-005`) | `project-decision` | §5.10 step 5는 "함께 갱신한다"는 원칙만 말하고 *무엇이 producer/consumer test인지*·*어떤 증거로 증명하는지*를 지정하지 않음 — 판정 메커니즘은 UNSUPPORTED_IMPL_DECISION |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> `planned` blueprint. 모든 경로는 hub §4.6 Planned directory blueprint + §5.1 owner map에서 도출(grounded)되나, frontend repo가 없으므로 전체 `planned`. 3-rule(R1 Trace / R2 UNSUPPORTED_IMPL_DECISION / R3 no OUT_OF_BRANCH_SCOPE) 준수.
|
||||
|
||||
### 1. Registry owner map + single-owner check
|
||||
|
||||
> **Trace**: D1 + D2 — [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D018, §5.1 (`FE-OC-022`).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: governance manifest 파일 경로 — hub §4.6 blueprint의 `src/contracts/`에는 8개 registry 파일만 있고 governance manifest 파일이 없다. 제안: `src/contracts/registry-manifest.js` (planned). trade-off: registry 8파일 옆에 두면 응집도↑이나 registry 파일과 manifest를 혼동할 위험 → 파일명에 `-manifest` 접미로 구분.
|
||||
|
||||
owner map(§5.1에서 그대로 도출 — registry의 *내용*이 아니라 *소유*만 governance가 소유):
|
||||
|
||||
| Registry ID | Owner branch (single) | Planned registry path (§5.1) | Governed contract |
|
||||
|---|---|---|---|
|
||||
| `FE-REG-ROUTE` | `feature-routing-navigation-guard-contract` | `src/contracts/routes.js` | `FE-OC-005` |
|
||||
| `FE-REG-API` | `feature-api-client-response-envelope-contract` | `src/contracts/api-operations.js` | `FE-OC-006` |
|
||||
| `FE-REG-ENV` | `feature-frontend-env-runtime-config-contract` | `src/contracts/env.js` | `FE-OC-004` |
|
||||
| `FE-REG-STORAGE` | `feature-frontend-storage-registry-contract` | `src/contracts/storage-keys.js` | `FE-OC-013` |
|
||||
| `FE-REG-ERROR` | `feature-frontend-error-classification-boundary-contract` | `src/contracts/errors.js` | `FE-OC-008` |
|
||||
| `FE-REG-QUERY` | `feature-server-state-caching-contract` | `src/contracts/query-keys.js` | `FE-OC-012` |
|
||||
| `FE-REG-TELEMETRY` | `feature-frontend-observability-logging-trace-contract` | `src/contracts/telemetry.js` | `FE-OC-014` |
|
||||
| `FE-REG-RELEASE` | `feature-frontend-release-cache-rollback-contract` | `src/contracts/release-tokens.js` | `FE-OC-016` |
|
||||
|
||||
single-owner check 규칙:
|
||||
- registry가 manifest에 owner 0개 → `zero-owner` fail.
|
||||
- registry가 owner ≥2개 → `duplicate-owner` fail.
|
||||
- owner branch가 아닌 change가 registry 파일을 편집 → `non-owner-mutation` fail. **이는 repo-level ownership(누가 그 파일을 *편집*할 수 있는가) 검사이며, runtime module mutation 검사가 아니다** — 아래 §2 schema harness는 registry의 *내용*만 읽어 검증하므로 이 규칙을 집행하지 않는다.
|
||||
- **UNSUPPORTED_IMPL_DECISION**: `non-owner-mutation`의 강제 메커니즘 — hub는 owner map(§5.1)에 owner branch 이름만 적고 강제 수단을 지정하지 않는다. 두 후보는 서로 다른 것을 본다: (a) **CODEOWNERS / path-glob repo ownership** — `src/contracts/<registry>.js` 경로별 owner를 선언하고 non-owner PR을 review-block. owner map과 1:1로 대응해 *편집 권한*을 정확히 표현하나, git host 기능에 의존하고 CI에서 재현하려면 별도 glob 검사 스크립트가 필요. (b) **import-graph 정적 검사** ([[raw/branch-notes/feature-frontend-clean-architecture-layering-contract]] `FE-OC-002`의 dependency-cruiser 재사용) — 도구는 이미 있으나 import graph는 *누가 파일을 수정했는가*를 볼 수 없고 *어느 모듈이 registry를 import 하는가*만 본다. registry는 설계상 모든 layer가 read 목적으로 import 하므로 이 신호로는 owner 위반을 구분할 수 없다. **선택: (a) path-glob repo ownership.** trade-off: git host 종속을 받아들이는 대신 owner map invariant를 있는 그대로 검사한다. (b)는 관심사 불일치로 기각.
|
||||
|
||||
### 2. Uniform schema-validation harness + orphan token scan
|
||||
|
||||
> **Trace**: D4 — §5.10 step 8, `FE-SC-005` (`FE-OC-022`). 각 registry의 minimum schema는 owner branch가 §5.2~§5.9에서 선언 — 본 §은 그 schema를 *검증*하는 harness만 명세하며 schema field를 재정의하지 않는다 (R3).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: validation 구현 방식 — Zod(`FE-OC-007` owner의 stack) 재사용 vs 독립 plain-JS assertion. hub 미지정. trade-off: Zod 재사용은 신규 의존 없이 통일성↑이나, registry validation은 build/test-time이라 runtime boundary(`FE-OC-007`)와 결합하면 concern 혼입 → 독립 test-time validator를 default로 두고 스키마 표현만 공유 검토.
|
||||
|
||||
harness 규칙(각 registry 공통, 내용 불변):
|
||||
|
||||
| 검사 | 규칙 | 근거 |
|
||||
|---|---|---|
|
||||
| required-field | registry의 각 row가 owner schema의 `Required: yes` field를 전부 보유 | §5.2~§5.9 각 owner schema |
|
||||
| id-format | stable ID(routeId·operationId·storage logicalName·error kind·query namespace·telemetry eventName·release token·env key)가 owner schema가 지정한 casing 규칙 준수 | 각 owner schema |
|
||||
| id-uniqueness | registry 내 stable ID 중복 0 | single-owner invariant 파생 |
|
||||
| orphan-token (bidirectional) | 코드가 참조하는 모든 token이 registry에 존재 **and** registry의 모든 token이 코드에서 ≥1회 참조 → orphan 0 | §5.10 step 8, `FE-SC-005` |
|
||||
| ad-hoc-token | registry를 우회한 literal(§5.1의 "Ad hoc use failure" 열 case) 검출 시 fail — 정적 강제 세부는 각 owner branch, governance는 **aggregate scan** | §5.1 |
|
||||
|
||||
### 3. Compatibility-impact 기록 gate
|
||||
|
||||
> **Trace**: D3 — §3.3 decision change protocol, §5.10 registry change protocol, `SEMVER-C1`. version-tuple/migration/rollback 메커니즘은 `FE-OC-023` owner branch로 위임 (R3 pointer).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: impact label 기록 매체 — PR template field vs snapshot metadata vs changelog row. hub 미지정. trade-off: snapshot metadata에 넣으면 diff와 원자적이나 PR review 가시성↓ → snapshot metadata를 SSOT로, PR template은 mirror로 검토.
|
||||
|
||||
기록 절차(§3.3 step 3-4 + §5.10 step 3-4 도출):
|
||||
1. registry change 제안 시 `compatibility_impact ∈ {none, additive, behavior-change, breaking}` 중 하나를 MUST 기록.
|
||||
2. `none`·`additive` → gate 통과 (예: schema에 optional field 추가).
|
||||
3. `behavior-change`·`breaking` → migration/rollback/test evidence 없이 merge block. rename은 stable ID 규칙상 breaking(§5.2 `routeId` rename=breaking 등).
|
||||
4. version bump 규칙(어느 tuple을 몇으로 올릴지)·migration 실행은 `FE-OC-023` owner branch 정의를 소비 — 본 gate는 *label 존재와 evidence 유무*만 강제.
|
||||
|
||||
### 4. Per-registry snapshot + diff artifact
|
||||
|
||||
> **Trace**: D5 — §5.10 step 6-7, `FE-OC-022` minimum evidence(registry diff check).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: snapshot 포맷(JSON vs serialized JS) + 저장 경로 — hub §4.6 `artifacts/`에 registry 전용 subdir 없음. 제안: `artifacts/quality/registry-snapshots/<registry-id>.json` (planned). trade-off: JSON은 도구 독립 diff가 쉬우나 registry가 JS 함수(query-key factory 등)를 포함하면 직렬화 손실 → 함수형 registry는 shape/서명만 snapshot.
|
||||
|
||||
- 각 registry change마다 snapshot 재생성 후 직전 snapshot과 diff.
|
||||
- diff는 최소 다음을 표면화: added/removed/renamed token, owner, affected `FE-OC-*`, `compatibility_impact`.
|
||||
- orphan token ≠ 0 이면 merge 불가 (§5.10 step 8).
|
||||
|
||||
### 5. Producer/consumer test 동기 갱신 gate
|
||||
|
||||
> **Trace**: D6 — §5.10 step 5("producer와 consumer test를 함께 갱신한다") + step 8 orphan scan (`FE-OC-022`, `FE-SC-005`). test 계층·러너·fixture 분류는 [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] (`FE-OC-020`) 소유 — 본 §은 *registry change 시점의 동기성*만 명세한다 (R3).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: producer/consumer test의 식별 방식 — hub §5.10 step 5는 원칙만 말하고 "무엇이 producer test이고 무엇이 consumer test인지", "동기 갱신을 어떤 증거로 증명하는지"를 지정하지 않는다. 후보: (a) **PR touch-set 규칙** — registry 파일이 바뀐 PR은 대응 test 경로도 함께 touch 해야 통과. 구현이 단순하나 *빈 수정*으로 우회 가능. (b) **token-reference 검사** — §2의 bidirectional orphan scan을 test 소스까지 확장해, consumer test가 registry에 더 이상 없는 token을 참조하면 fail. 우회 불가하나 remove/rename만 잡고 *추가된 token에 test가 없는 경우*는 못 잡는다. **선택: (a)+(b) 동시 적용** — (b)가 정확성을, (a)가 커버리지(신규 token)를 담당. trade-off: 검사 2개를 유지해야 하고 (a)는 우회 가능성이 남지만, 하나만 쓰면 rename(=breaking, §5.2)이나 신규 token 중 한쪽이 무검사로 통과한다.
|
||||
|
||||
gate 규칙:
|
||||
|
||||
| 검사 | 규칙 | 실패 라벨 | 근거 |
|
||||
|---|---|---|---|
|
||||
| touch-set | registry 파일의 token 집합이 변한 PR은 해당 registry의 producer test와 consumer test 경로를 함께 수정해야 함 (주석·포맷만 바뀐 change는 미발동) | `unsynced-registry-test` | §5.10 step 5 |
|
||||
| token-reference (test 확장) | test 소스가 참조하는 registry token이 registry에 존재해야 함 — registry에서 제거·rename된 token을 test가 계속 참조하면 fail | `stale-test-token` | §5.10 step 5 + step 8 (`FE-SC-005`) |
|
||||
| new-token coverage | registry에 새로 추가된 token은 producer/consumer 양쪽에서 ≥1회 test 참조되어야 함 | `untested-new-token` | §5.10 step 5 + step 8 bidirectional orphan 규칙의 test-side 확장 |
|
||||
|
||||
- 본 gate의 producer/consumer 정의는 registry별로 owner branch가 §5.2~§5.9 schema와 함께 선언한 stable ID를 기준으로 한다 — governance는 그 ID 집합의 *변화*와 test 참조를 대조할 뿐, test 내용을 규정하지 않는다.
|
||||
- 실행 지점: registry change PR의 merge gate. CI stage 배선(어느 workflow job에서 도는지)은 [[raw/branch-notes/feature-frontend-ci-quality-gates-contract]] 소유이고, `FE-GATE-005@1`(unit gate — all registries fixture 포함) 자체의 owner 는 [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] 다(hub §2.1.1).
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- `duplicate-owner`: 두 owner branch가 같은 registry 소유 주장 → single-owner check fail (D2).
|
||||
- `zero-owner`: registry가 owner map에 owner 없음(orphan registry) → fail (D2).
|
||||
- `renamed-token-without-label`: stable ID rename인데 `compatibility_impact` 미기록/`breaking` 미표기 → gate block (D3, §5.2 rename=breaking).
|
||||
- `orphan-token`: 코드가 참조하나 registry 부재, 또는 registry row가 코드에서 미참조 → `FE-SC-005` 위반 (D4).
|
||||
- `missing-impact-label`: registry change에 `compatibility_impact` 누락 → gate block (D3).
|
||||
- `unevidenced-breaking`: `behavior-change`/`breaking`인데 migration/rollback/test evidence 없음 → merge block (D3).
|
||||
- `ad-hoc-token`: literal route path / raw `localStorage` key / 자유 문자열 event 등 registry 우회 → §5.1 "Ad hoc use failure" (정적 강제는 각 owner, governance는 aggregate scan).
|
||||
- `unsynced-registry-test`: registry token 집합이 바뀐 PR이 producer/consumer test를 함께 수정하지 않음 → §5.10 step 5 위반, merge block (D6).
|
||||
- `stale-test-token`: test가 registry에서 제거·rename된 token을 계속 참조 → gate fail. registry만 바뀌고 test는 green으로 남는 silent contract drift의 주 경로 (D6).
|
||||
- `untested-new-token`: registry에 추가된 token이 producer/consumer test 어느 쪽에서도 참조되지 않음 → gate fail (D6).
|
||||
- **다른 계약 의존** (sibling 링크는 `FE-OC-###`로만 참조):
|
||||
- upstream: [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] (`FE-OC-003`) — checkJs/test toolchain 위에서 harness 실행. [[raw/branch-notes/feature-frontend-clean-architecture-layering-contract]] (`FE-OC-002`) — `contracts/` 레이어 소유 + layer 간 import 규칙(§4.3 dependency matrix는 `domain`/`application`/`presentation`/`adapters`/`bootstrap` **layer** 단위 import 허용/금지를 정의하며, registry 파일별 branch ownership을 정의하지 않는다). 따라서 `non-owner-mutation` 강제는 §4.3에서 도출되지 않고 본 note §1의 path-glob repo ownership 선택(UNSUPPORTED_IMPL_DECISION)이 소유한다.
|
||||
- downstream(본 branch를 consume): [[raw/branch-notes/feature-frontend-contract-compatibility-governance]] (`FE-OC-023`) — `compatibility_impact` 기록을 소비해 version-tuple/migration 판정. [[raw/branch-notes/feature-frontend-ci-quality-gates-contract]] (`FE-OC-022`) — governance gate를 CI blocking gate로 실행.
|
||||
- registry supplier(8 owner가 registry+schema 제공): routing(`FE-OC-005`), api-client(`FE-OC-006`), env(`FE-OC-004`), storage(`FE-OC-013`), error(`FE-OC-008`), server-state(`FE-OC-012`), observability(`FE-OC-014`), release-cache(`FE-OC-016`). 이 중 하나라도 schema를 바꾸면 §2 harness가 동기화돼야 함.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 각 registry가 정확히 1 primary owner를 가진다 | branch·code 없음, owner map manifest 미구현 | single-owner check fixture: duplicate/zero-owner manifest fixture가 fail (§20 measurable: single-owner checks) | `needs-confirmation` |
|
||||
| 각 registry row가 owner minimum schema를 만족한다 | schema harness 미구현 | schema validation fixture: required-field 누락 row가 fail (§20 measurable: schema validation) | `needs-confirmation` |
|
||||
| orphan/ad hoc token scan이 0 (`FE-SC-005`) | frontend code 없음 | bidirectional orphan token scan fixture (registry↔code) | `needs-confirmation` |
|
||||
| 모든 registry change가 `compatibility_impact`를 기록한다 | gate 미구현 | change-protocol gate fixture: label 없는 change가 fail | `needs-confirmation` |
|
||||
| snapshot diff가 affected FE-OC + compatibility impact를 표면화한다 | snapshot 미구현 | snapshot diff test: additive vs breaking fixture의 diff 비교 (§20 measurable: 8 registry snapshots) | `needs-confirmation` |
|
||||
| registry change 시 producer/consumer test가 함께 갱신됨을 gate가 검출한다 (§5.10 step 5) | gate 미구현, hub는 원칙만 진술하고 판정 메커니즘 미지정 | 3개 negative fixture: (1) registry token rename + test 미수정 PR → `unsynced-registry-test` fail, (2) registry에서 제거된 token을 참조하는 test → `stale-test-token` fail, (3) test 참조 없는 신규 token → `untested-new-token` fail | `needs-confirmation` |
|
||||
| `non-owner-mutation`을 path-glob repo ownership으로 검사할 수 있다 | CODEOWNERS/glob 검사 미구현, git host 기능 종속 | owner map의 8 registry path glob과 ownership 선언이 1:1 대응하는지 대조 + non-owner 경로 수정 fixture가 block 되는지 확인 | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
- 스캐폴딩 단계: `/coverage` 실행 전 수동 행을 만들지 않는다.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 없음 — 스캐폴딩 단계.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
| `FE-OC-004@1` | [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] | build-time, runtime-public, secret config를 MUST 분리하고 boot 전에 runtime config를 검증 | import 참조로 적용 |
|
||||
| `FE-OC-005@1` | [[raw/branch-notes/feature-routing-navigation-guard-contract]] | route ID/path/params/access/loading/error owner는 route registry 하나여야 함 | import 참조로 적용 |
|
||||
| `FE-OC-006@1` | [[raw/branch-notes/feature-api-client-response-envelope-contract]] | 모든 HTTP는 shared client를 MUST 통과하고 timeout·abort·response parsing을 page에서 구현하면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-008@1` | [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] | 모든 failure는 stable frontend error kind로 MUST 정규화하고 raw body·stack을 UI에 노출하면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-012@1` | [[raw/branch-notes/feature-server-state-caching-contract]] | query key와 invalidation은 registry factory만 MUST 사용 | import 참조로 적용 |
|
||||
| `FE-OC-013@1` | [[raw/branch-notes/feature-frontend-storage-registry-contract]] | storage key는 namespace·version·classification을 MUST 가지며 token/secret 저장을 금지 | import 참조로 적용 |
|
||||
| `FE-OC-014@1` | [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] | telemetry는 best-effort이며 render·API success를 차단하면 안 되고 PII·token을 전송하면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-016@1` | [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] | HTML, asset, runtime config, release manifest cache policy를 MUST 구분 | import 참조로 적용 |
|
||||
| `FE-OC-020@1` | [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] | gate 종류별 책임·fixture·artifact를 분리하고 실패를 warning으로 낮추면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-023@1` | [[raw/branch-notes/feature-frontend-contract-compatibility-governance]] | API/config/storage/release schema의 breaking change는 migration 또는 version bump 없이 배포하면 안 됨 | import 참조로 적용 |
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
- 없음 — 자식 자료는 생성 후 controller가 parent Cluster와 함께 등록한다.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- 없음 — daily note는 이 작업에서 수정하지 않는다.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크: 없음
|
||||
- 리뷰 메모: 없음
|
||||
- 머지 결과 / 배포 환경: `planned`
|
||||
- **wiki 추출 대상** (verified만): 없음
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned): 현재 전체
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-frontend-operational-contract/branch-notes/feature-frontend-env-runtime-config-contract.md
|
||||
@@ -0,0 +1,396 @@
|
||||
---
|
||||
title: branch / feature-frontend-env-runtime-config-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-004
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-frontend-operational-contract
|
||||
work_item: WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-004
|
||||
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-RUNTIME-CONFIG-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CONFIG-FALLBACK-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-001]
|
||||
contract_packet: 1
|
||||
branch: feature-frontend-env-runtime-config-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton-frontend, ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-frontend-operational-contract.md]
|
||||
tags: [branch, ca-skeleton, frontend, runtime, security, javascript, externalized-config]
|
||||
created: 2026-07-18
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 781ef2d614370c6a8603dfd4c254c8584737803fa6f88b19159cb46a349e841c
|
||||
imports: [FE-GATE-004@1, FE-OC-002@1, FE-OC-003@1, FE-OC-007@1, FE-OC-008@1, FE-OC-009@1, FE-OC-014@1, FE-OC-016@1, FE-OC-019@1, FE-OC-022@1, FE-OC-023@1, FE-OC-025@1]
|
||||
---
|
||||
|
||||
# branch: feature-frontend-env-runtime-config-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 TODO·결정·진행 기록. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출한다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-frontend-operational-contract]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: build/runtime/secret registry와 boot-invalid matrix가 검증된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-RUNTIME-CONFIG-001@1` | deploy별 public value는 runtime config, compiler value는 build-time config로 분리한다 | config registry와 pre-mount runtime config validation에 적용한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CONFIG-FALLBACK-001@1` | runtime config fallback 시 environment별 rebuild는 허용하되 artifact의 multi-environment 재사용은 금지한다 | static-only hosting fallback과 artifact 재사용 금지에 적용한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | build-time public·runtime-public·secret config를 분리한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D2 | runtime config fallback은 environment별 rebuild만 허용한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D3 | secret-name key를 build·runtime registry에서 거부한다 | `local` | `raw/official-docs/vite-build-tool-official.md#VITE-C4`, `raw/official-docs/vite-build-tool-official.md#VITE-C5` | `proposed` |
|
||||
| D4 | React mount 전에 runtime config를 fetch하고 검증한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D5 | runtime config validation matrix를 고정한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D6 | boot failure 화면은 safe field만 노출한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D7 | 모든 public config는 FE-REG-ENV를 경유한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D8 | boot config validation 시간 예산의 측정 구간을 고정한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
없음.
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
이 브랜치는 project-wide 계약 `FE-OC-004`("build-time / runtime-public / secret config를 MUST 분리하고 boot 전에 runtime config를 검증")를 *되묻지 않고 코드를 작성할 수 있는* 구현 명세로 내린다. 구체적으로 (1) 환경 config registry `FE-REG-ENV`(`src/contracts/env.js`)를 single owner로 소유하고, (2) React mount 이전에 실행되는 runtime config fetch·검증 게이트(boot sequence 2~4단계, hub §4.5/§6.3)를 정의하며, (3) 세 종류 config(build-time public / runtime public / secret)의 분리 규칙과 secret 유출 차단 규칙(hub §6.1)을 확정한다. 근거는 hub decision `FE-D012`(deploy별 public value = pre-render runtime config, compiler value = build-time config)·`FE-D013`(runtime config fallback 규칙)과 Vite 공식 문서의 `import.meta.env` build-time 정적 치환·`VITE_` prefix 노출 경계·secret 금지 경고(`VITE-C3`/`VITE-C4`/`VITE-C5`)다. 이 계약은 `FE-OC-016`(release/cache — runtime config cache policy)과 `FE-OC-023`(compatibility — config/API schema version)에 기여한다. **현재 frontend repository는 존재하지 않으므로 본 노트의 모든 구현 항목 등급은 `planned`다.**
|
||||
|
||||
- 이슈: (없음 — repository 미생성)
|
||||
- PR: (없음 — repository 미생성)
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- `FE-REG-ENV` 환경 config registry(`src/contracts/env.js`)의 schema·초기 row·single-owner 규칙 (`FE-OC-004`, hub §5.4)
|
||||
- build-time public / runtime public / secret 3분류 규칙과 `VITE_` prefix 사용 경계 (`FE-D012`, hub §6.1)
|
||||
- secret-name(`SECRET`/`PASSWORD`/`PRIVATE_KEY`/`TOKEN`) key를 build·runtime registry 양쪽에서 거부하는 정적 가드 (hub §6.1, `VITE-C4`/`VITE-C5`)
|
||||
- React mount 이전 runtime config fetch(`no-store`) + 검증 게이트와 boot 실패/버전 불일치 분기 (`FE-D012`, hub §4.5/§6.3)
|
||||
- runtime config 검증 규칙 카탈로그(required key·URL protocol allowlist·int range·boolean parse·schema/contract version compat·unknown-key strict) (`FE-OC-004`, hub §6.4)
|
||||
- boot 실패 시 화면 노출 safe-field allowlist + redaction (hub §6.4)
|
||||
- environment별 rebuild fallback 규칙: 한 artifact를 여러 env에 재사용하지 않음 (`FE-D013`)
|
||||
- **boot config 검증 시간 예산 `FE-NFR-006`(≤ 500ms, network delay 제외)의 측정 경계와 valid-config timing fixture** — `FE-GATE-004` pass condition의 timing 절반 (hub §14.2, §15.1)
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외 — 다른 owner 브랜치/계약 소유. 각 브랜치는 자신이 소유한 `FE-OC-*` 계약으로 표기(하위 `FE-D*`는 hub decision register 참조).
|
||||
|
||||
- **normalized error kind 어휘**(`BOOT_CONFIG_FAILURE`, `DEPLOY_MISMATCH`)와 raw body/stack UI 유출 catalog → [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] (`FE-OC-008`)
|
||||
- **runtime schema(Zod) 구성·parse 메커니즘** 자체 → [[raw/branch-notes/feature-runtime-schema-validation-contract]] (`FE-OC-007`)
|
||||
- **release manifest 정합성 tuple·cache header·rollback·`DEPLOY_MISMATCH` recovery UI** → [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] (`FE-OC-016`, `FE-OC-017`)
|
||||
- **config/API schema version breaking-change migration 정책** → [[raw/branch-notes/feature-frontend-contract-compatibility-governance]] (`FE-OC-023`)
|
||||
- **8-registry governance(single-owner diff·compatibility 추적)** → [[raw/branch-notes/feature-frontend-contract-registry-governance]] (`FE-OC-022`)
|
||||
- **telemetry endpoint redaction/전송** → [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] (`FE-OC-014`) — 본 registry는 `TELEMETRY_ENABLED`/`TELEMETRY_ENDPOINT` key와 분류만 선언, 전송·redaction 메커니즘은 관측 브랜치 소유
|
||||
- **token/session lifecycle** → [[raw/branch-notes/feature-frontend-auth-session-integration-contract]] (`FE-OC-010`) — 본 registry는 `AUTH_MODE` key만 선언
|
||||
- **Vite/toolchain·`import.meta.env` 노출 메커니즘 자체** → 의존 브랜치 [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] (`FE-OC-003`)
|
||||
- **composition root 조립 순서 enforcement** → [[raw/branch-notes/feature-frontend-clean-architecture-layering-contract]] (`FE-OC-002`)
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/vite-build-tool-official]] | `VITE-C3`(`import.meta.env` build-time 정적 치환) → build-time config는 재빌드로만 바뀐다는 D1/D2 전제; `VITE-C4`(오직 `VITE_` prefix만 client 노출) → D3 노출 경계; `VITE-C5`(`VITE_*`에 secret 금지, 프로덕션 secret은 backend/serverless) → D3 secret 차단 규칙; `VITE-C2`(정적 자산 output) → static-only hosting fallback(D2) 전제 |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | 이 branch가 owner인 `FE-D012`/`FE-D013` decision, `FE-OC-004` 계약, `FE-REG-ENV`(§5.4), boot order(§4.5)·boot sequence(§6.3)·runtime config 검증 규칙(§6.4)·boot 실패 safe-output(§6.4)·`FE-RB-001` runbook(§16.1)의 project-decision 근거. 추가로 §14.2 `FE-NFR-006`(boot config validation ≤ 500ms, deterministic mocked fetch)·§15.1 `FE-GATE-004`(valid boot config validation timing 을 pass condition 에 포함)가 D8 시간 예산의 근거 |
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기.
|
||||
|
||||
- [ ] `FE-REG-ENV` registry schema + 초기 14 key row 구현 (`src/contracts/env.js`) — 등급: `planned`
|
||||
- [ ] build/runtime/secret 3분류 + secret-name 거부 정적 가드 구현 — 등급: `planned`
|
||||
- [ ] `src/bootstrap/load-runtime-config.js` — mount 이전 `no-store` fetch + boot 분기 구현 — 등급: `planned`
|
||||
- [ ] runtime config 검증 규칙(§6.4 8항) 구현 (schema 메커니즘은 `FE-OC-007` 브랜치 consume) — 등급: `planned`
|
||||
- [ ] boot 실패 safe-field allowlist + redaction 구현 — 등급: `planned`
|
||||
- [ ] **boot invalid-config matrix** 테스트(§20 Measurable completion) 작성 — 등급: `planned`
|
||||
- [ ] config schema test(`FE-OC-004` minimum evidence) 작성 — 등급: `planned`
|
||||
- [ ] **valid-config timing fixture** 작성 — `FE-NFR-006`(≤ 500ms, mocked network delay 제외) 측정 + `FE-GATE-004` timing report 산출 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
없음 — scaffolding 단계. repository 미생성이므로 모든 항목 `planned`.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 각 결정의 근거는 아래 Decision Evidence Map과 1:1 대응. 대안·선택 조건 포함.
|
||||
|
||||
- 2026-07-18: **build-time public / runtime-public / secret 3분류 분리**(`FE-D012`) / 이유: deploy마다 달라지는 public value(API endpoint 등)를 재빌드 없이 바꾸려면 build-time 정적 치환(`import.meta.env`)이 아닌 pre-render runtime config가 필요 / 검토한 대안: 모든 값을 build-time으로 고정(env별 재빌드) / 근거: `VITE-C3`, hub §6.1·`FE-D012`
|
||||
- 2026-07-18: **runtime config fallback = env별 rebuild 허용하되 artifact 재사용 금지**(`FE-D013`) / 이유: hosting이 atomic config publish를 못 할 때 deploy ambiguity를 제한 / 검토한 대안: 단일 artifact를 여러 env에 재사용 + build-time fallback / 근거: hub `FE-D013`
|
||||
- 2026-07-18: **secret-name key 양쪽 registry 거부 + `VITE_`는 build metadata·non-secret 상수만** / 이유: `VITE_*`는 번들에 정적 치환되어 client에 노출되므로 secret 금지 / 검토한 대안: 관례 문서화만(정적 강제 없음) / 근거: `VITE-C4`, `VITE-C5`, hub §6.1
|
||||
- 2026-07-18: **React mount 이전 runtime config fetch+검증 게이트(boot 2~4단계)** / 이유: 잘못된 config로 product route를 mount하지 않기 위해 / 검토한 대안: mount 이후 lazy config load / 근거: hub §4.5 boot order, §6.3 sequence
|
||||
- 2026-07-18: **runtime config 검증 8항 커버리지 + unknown-key strict default** / 이유: config는 신뢰 경계 밖 입력이므로 boot 전 전량 검증 / 검토한 대안: 필수 key 존재만 확인 / 근거: hub §6.4 (schema 메커니즘은 `FE-OC-007` 위임)
|
||||
- 2026-07-18: **boot 실패 화면 safe-field allowlist + endpoint/stack redaction** / 이유: 실패 화면으로 endpoint·raw config·stack 유출 금지 / 검토한 대안: raw error 그대로 표시 / 근거: hub §6.4 (error kind 어휘는 `FE-OC-008` 위임)
|
||||
- 2026-07-18: **모든 public config는 `FE-REG-ENV` 경유(ad hoc `import.meta.env` 금지)** / 이유: rename·compatibility 영향 추적 single owner / 검토한 대안: 파일마다 `import.meta.env` 직접 접근 / 근거: hub §5.1·§5.4, `FE-D018`
|
||||
- 2026-07-20: **`MAX_RETRY_ATTEMPTS` 허용 범위를 retry cap 소유 결정에 정렬(0–2)** / 이유: config가 owner 결정보다 넓은 값을 통과시키면 하류 client가 조용히 clamp 하게 되어 "설정한 값 ≠ 동작하는 값" 이 되므로, 경계 검증을 owner cap 과 동일하게 둔다 / 검토한 대안: config는 0–5를 통과시키고 API client가 clamp(설정-동작 괴리 허용) / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-D015`(initial call 이후 최대 2회) — cap 소유는 `FE-OC-009`, 본 branch는 그 cap 을 config 경계에서 재선언만 하고 값 자체를 정하지 않음
|
||||
- 2026-07-20: **boot config 검증 시간 예산 `FE-NFR-006` 은 "검증 구간만" 측정하며 mocked network delay 를 제외한다** / 이유: `FE-GATE-004` pass condition 이 timing 을 포함하는데(hub §15.1) 측정 구간을 고정하지 않으면 fetch 대기 시간이 예산을 잠식해 gate 가 무의미해짐 / 검토한 대안: fetch 시작~mount 직전 end-to-end 측정(hosting/network 변동에 좌우) / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-NFR-006`(§14.2, deterministic mocked fetch, ≤ 500ms excluding network delay), §15.1 `FE-GATE-004`
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> `Supporting Claims`: 공식 문서는 `raw/official-docs/<slug>.md#<CLAIM>` (백틱), project decision은 hub wikilink + FE-D/§ 참조. 위임 대상 sibling 브랜치는 소유 `FE-OC-*`로 표기(하위 `FE-D*`는 hub register).
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | `FE-D012` — deploy별 public value는 pre-render runtime config(`/config.json`), compiler value·asset identity는 build-time config로 분리 | hosting이 HTML보다 먼저 runtime config를 atomic publish 가능 → runtime config 경로; 정적 파일만 제공 → D2 env별 rebuild fallback; SSR/edge 도입 → 본 계약 그대로 적용 않고 별도 project fork(hub §6.2) | `raw/official-docs/vite-build-tool-official.md#VITE-C3`, `raw/official-docs/vite-build-tool-official.md#VITE-C2`; [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-D012` §6.1 | `conditional-default + official-doc` | hosting이 runtime config atomic publish를 미지원하면 재검토(hub revisit trigger) |
|
||||
| D2 | `FE-D013` — runtime config fallback은 env별 rebuild를 허용하되 한 artifact를 여러 env에 재사용하지 않음 | atomic config publish 불가한 static-only hosting일 때만 env별 rebuild; runtime config endpoint 도입되면 단일 artifact + runtime fetch로 복귀. 어떤 경우에도 동일 artifact를 여러 env로 재배포 금지 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-D013`; `raw/official-docs/vite-build-tool-official.md#VITE-C3` | `conditional-default + project-decision` | runtime config endpoint 도입 시 재검토; artifact 재사용 시 deploy ambiguity 재발 |
|
||||
| D3 | secret-name(`SECRET`/`PASSWORD`/`PRIVATE_KEY`/`TOKEN`) key를 build·runtime registry 모두 거부; `VITE_` prefix는 build metadata·non-secret compile-time 상수만 | 항상 적용되는 invariant; auth owner가 browser storage를 꼭 써야 하는 경우에만 별도 threat model + owner evidence로 예외(skeleton default 아님, hub §6.1) | `raw/official-docs/vite-build-tool-official.md#VITE-C4`, `raw/official-docs/vite-build-tool-official.md#VITE-C5`; [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §6.1 | `official-doc` | 로그·디버그 등 다른 경로의 우발적 유출은 `VITE-C4`가 커버 안 함 → `FE-OC-019` browser-security와 교차 필요 |
|
||||
| D4 | runtime config + release manifest를 React mount 이전에 `no-store` fetch → 검증 → (valid) 조립·mount / (invalid) boot error shell / (mismatch) recovery UI. boot 2~4단계 실패 시 product route mount 안 함 | config invalid → `BOOT_CONFIG_FAILURE`(product route mount 중단); version mismatch → `DEPLOY_MISMATCH`(controlled recovery, reload loop 금지); valid → mount. telemetry adapter 생성 실패는 non-blocking(console-safe fallback) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §4.5 boot order, §6.3 sequence, `FE-OC-004` | `project-decision` | bounded refetch(최대 1회, §16.1)와 recovery UI 경계가 `FE-OC-016`/`FE-OC-025` 소유와 겹침 |
|
||||
| D5 | runtime config 검증은 required-key·URL protocol allowlist(prod https)·int range(timeout/retry)·boolean strict parse·config schema version·API contract version·release/build ID coherence·unknown-key strict를 모두 커버 | unknown key는 strict reject default; schema가 명시적으로 passthrough할 때만 additive key 허용. protocol allowlist는 prod https 강제, local 예외는 문서화된 경우만 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §6.4, `FE-OC-004`; schema 메커니즘은 [[raw/branch-notes/feature-runtime-schema-validation-contract]] (`FE-OC-007`) 위임 | `project-decision` | `REQUEST_TIMEOUT_MS` 경계 값은 hub 미규정(아래 impl §4 `UNSUPPORTED_IMPL_DECISION`); `MAX_RETRY_ATTEMPTS` 범위는 retry cap owner(`FE-OC-009`)에 정렬해 해소(0–2); version compat 정책은 `FE-OC-023` 위임 |
|
||||
| D6 | boot 실패 화면은 safe-field(`error.kind`,`error.code`,`buildId`,`configSchemaVersion`,`releaseId`,`supportReference`)만 노출; endpoint·query·header·raw config·stack은 화면 금지 | 항상 적용되는 redaction invariant — 어떤 실패 종류에서도 forbidden field는 user-facing screen에 표시 안 함 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §6.4, `FE-OC-004`; error kind 어휘는 [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] (`FE-OC-008`) 위임 | `project-decision` | `supportReference` 생성 방식 미규정(impl §5 `UNSUPPORTED_IMPL_DECISION`); telemetry로의 상관 전송은 `FE-OC-014` 소유 |
|
||||
| D7 | 모든 build/runtime public config key는 `FE-REG-ENV`(`src/contracts/env.js`) 등록 후 사용; registry 밖 `import.meta.env`·config key 직접 사용은 violation. `public-sensitive`=browser 가시이나 로그·telemetry 원문 금지 | 항상 적용(hub `FE-D018` 8-registry single-owner invariant); code generation SSOT 채택 시 registry 형태 재검토 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §5.1 §5.4 `FE-D018` | `project-decision` | registry diff·single-owner 강제와 compatibility 추적은 `FE-OC-022`/`FE-OC-023` 위임 |
|
||||
| D8 | boot config 검증 시간 예산 `FE-NFR-006`(≤ 500ms) 은 *검증 구간만* 측정한다 — config 본문이 메모리에 있는 시점부터 normalized config 반환 또는 `BOOT_CONFIG_FAILURE` 확정까지. fetch·mocked network delay·mount 이후는 제외. `FE-GATE-004` 는 이 branch 가 config invalid matrix + valid-config timing fixture 를, schema 브랜치가 content-type/JSON/envelope/payload invalid matrix 를 각각 제공해 함께 PASS 시킨다 | deterministic mocked fetch 환경에서 항상 측정(hub §14.2 context); 실제 network 를 타는 환경에서는 이 예산을 주장하지 않음(lab 값을 production 수치로 표현 금지) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §14.2 `FE-NFR-006`, §15.1 `FE-GATE-004`, `FE-OC-004`; invalid fixture 절반은 [[raw/branch-notes/feature-runtime-schema-validation-contract]] (`FE-OC-007`) 소유 — 해당 노트도 timing fixture 소유를 본 branch 로 귀속 | `project-decision` | 측정 시작점·통계(단일 실행 vs 중앙값)는 hub 미규정(impl §6 `UNSUPPORTED_IMPL_DECISION`); 검증 대상 fixture 규모가 커지면 500ms 예산 재검토 필요 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> `planned` blueprint — frontend repository 미생성. 경로는 hub §4.6 Planned directory blueprint + §5.1 registry owner map에서 도출되어 grounded지만, 코드가 없으므로 전체가 `planned`다.
|
||||
|
||||
### 1. 환경 config registry `FE-REG-ENV`
|
||||
|
||||
> **Trace**: D7 (hub §5.1·§5.4 `FE-REG-ENV`, `FE-D018`) + D3. Planned path `src/contracts/env.js` (§5.1 owner map).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: registry의 JS 표현(row 배열 `export const ENV_REGISTRY = [...]` vs key→meta object map)은 hub가 schema 컬럼만 규정하고 JS 구조는 미규정 → row 배열 선택. trade-off: 순서 보존 + snapshot diff(`FE-OC-022`)가 단순.
|
||||
|
||||
초기 14 key(hub §5.4 그대로 — 신규 발명 아님):
|
||||
|
||||
| Key | Phase | Classification | Required | Default | Failure |
|
||||
|---|---|---|---|---|---|
|
||||
| `VITE_BUILD_ID` | build | public metadata | yes | none | build fail |
|
||||
| `VITE_COMMIT_SHA` | build | public metadata | yes in CI | local sentinel allowed | release evidence fail |
|
||||
| `VITE_ROUTER_BASE_PATH` | build | non-secret compile-time constant | yes | `/` | route mount fail |
|
||||
| `VITE_RUNTIME_CONFIG_URL` | build | non-secret compile-time constant | yes | `/config.json` | boot fail |
|
||||
| `APP_ENV` | runtime | public | yes | none | boot fail |
|
||||
| `API_BASE_URL` | runtime | public-sensitive | yes | none | boot fail |
|
||||
| `REQUEST_TIMEOUT_MS` | runtime | public | no | `10000` | invalid value boot fail |
|
||||
| `MAX_RETRY_ATTEMPTS` | runtime | public | no | `2` after initial (허용 범위 0–2, cap owner `FE-OC-009`) | invalid value boot fail |
|
||||
| `TELEMETRY_ENABLED` | runtime | public | yes | `false` | invalid value boot fail |
|
||||
| `TELEMETRY_ENDPOINT` | runtime | public-sensitive | conditional | none | telemetry degrade |
|
||||
| `AUTH_MODE` | runtime | public | yes | `external` | unsupported mode boot fail |
|
||||
| `CONFIG_SCHEMA_VERSION` | runtime | public | yes | none | compatibility fail |
|
||||
| `API_CONTRACT_VERSION` | runtime | public | yes | none | compatibility fail |
|
||||
| `RELEASE_MANIFEST_URL` | runtime | public | yes | `/release-manifest.json` | mismatch detection degrade/fail per policy |
|
||||
|
||||
- `public-sensitive`(예: `API_BASE_URL`, `TELEMETRY_ENDPOINT`) = browser 가시이나 로그·telemetry에 원문 금지 (hub §5.4). secret 분류 아님.
|
||||
- ad hoc 사용 위반(hub §5.1): registry 없는 `import.meta.env` 또는 config key 사용.
|
||||
|
||||
### 2. runtime / secret 3분류 + secret-name 거부 가드
|
||||
|
||||
> **Trace**: D1 (`FE-D012`, hub §6.1, `VITE-C3`) + D3 (`VITE-C4`, `VITE-C5`, hub §6.1).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: secret-name 거부 매칭 알고리즘(case-insensitive substring `/(SECRET|PASSWORD|PRIVATE_KEY|TOKEN)/i` vs 정확 word 매칭) 미규정 — hub는 4개 토큰만 나열(§6.1) → case-insensitive substring(fail-safe) 선택. trade-off: `TOKENIZER` 같은 정당한 이름 false-positive 위험 → 문서화된 명시적 예외 목록으로 완화.
|
||||
|
||||
| Class | 예시 | Browser 가시 | 변경 메커니즘 | Cache | 규칙 |
|
||||
|---|---|---|---|---|---|
|
||||
| build-time public | `VITE_BUILD_ID`, `VITE_COMMIT_SHA`, `VITE_ROUTER_BASE_PATH` | yes | rebuild(정적 치환) | bundled | compiler behavior·asset identity만 |
|
||||
| runtime public | `API_BASE_URL`, public feature flag, `TELEMETRY_ENDPOINT` | yes | runtime config publish | `no-store` | React mount 이전 검증 |
|
||||
| secret | client secret, private key, DB credential, refresh token material | 번들 금지 | server/auth owner | N/A | frontend env·bundle·HTML 어디에도 금지 |
|
||||
|
||||
- `VITE_` prefix는 build metadata + non-secret compile-time 상수(base path, `/config.json` 위치)에만 (hub §6.1, `VITE-C4`).
|
||||
- 이름에 `SECRET`/`PASSWORD`/`PRIVATE_KEY`/`TOKEN` 포함 key는 build·runtime registry 양쪽에서 거부 (hub §6.1). 프로덕션 secret은 backend/serverless 소관 (`VITE-C5`).
|
||||
|
||||
### 3. mount 이전 runtime config 로더 + boot 분기
|
||||
|
||||
> **Trace**: D4 (hub §4.5 boot order, §6.3 sequence). Planned paths `src/bootstrap/load-runtime-config.js`, `src/bootstrap/composition-root.js`, `src/bootstrap/main.jsx` (§4.6). 의존: build/`import.meta.env` 노출은 [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] (`FE-OC-003`); composition root 조립은 [[raw/branch-notes/feature-frontend-clean-architecture-layering-contract]] (`FE-OC-002`) 소유.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (a) boot error shell 컴포넌트 명/경로(예: `presentation/boundaries/BootErrorShell.jsx`) — hub는 "boot error shell" 개념만, 명명/경로 미규정 → presentation/boundaries 하위에 두어 layering(`FE-OC-002`) 위반 회피. (b) pre-mount config fetch 클라이언트(raw `fetch` vs shared client) — boot 2단계 시점엔 shared client(`FE-OC-006`)가 아직 조립 전 → raw `fetch` 선택. trade-off: shared client의 timeout/retry 정책은 config 로드에 적용 안 됨(부트 전용 최소 fetch).
|
||||
|
||||
**본 branch 소유 구간은 단계 번호가 아니라 *의미*로 정의한다: "runtime config 취득 + 검증 완료까지, React mount 이전".** 그 앞(build identity 읽기)과 뒤(adapter 조립·mount)는 타 owner 구간이다. hub 의 두 절이 나열 순서를 서로 다르게 쓰므로(§4.5 는 config 검증 → release manifest 정합성, §6.3 sequence 는 두 fetch → 검증) 번호 기반 참조는 깨지기 쉽다. 아래 목록은 §6.3 실행 순서를 따르고, 각 행에 hub §4.5 번호를 명시 매핑한다.
|
||||
|
||||
| 실행 순서(hub §6.3 기준) | hub §4.5 번호 | Owner |
|
||||
|---|---|---|
|
||||
| build identity 읽기 *(build-time config, §1)* | 1 | build/toolchain (`FE-OC-003`) — 본 branch 는 key 분류만 |
|
||||
| runtime config fetch — `GET {VITE_RUNTIME_CONFIG_URL}` `no-store` | 2 | **본 branch** |
|
||||
| release manifest fetch — `GET {RELEASE_MANIFEST_URL}` `no-store` | 4의 입력 취득 | **본 branch** (정합성 판정 자체는 `FE-OC-016`) |
|
||||
| config envelope·schema·compatibility 검증 *(§4)* | 3 | **본 branch** (schema 메커니즘은 `FE-OC-007` consume) |
|
||||
| registry snapshot → auth adapter → HTTP/storage/telemetry/query-cache adapter → application facade → router → React root mount | 5–10 | composition root (`FE-OC-002`) |
|
||||
|
||||
분기(hub §6.3):
|
||||
|
||||
- **valid & compatible** → normalized public config로 dependency 조립 + mount.
|
||||
- **invalid config** → `BOOT_CONFIG_FAILURE` → boot error shell, product route mount 안 함. automatic refetch 최대 1회(hub §16.1).
|
||||
- **version mismatch** → `DEPLOY_MISMATCH` → controlled recovery UI, reload loop 금지. *(recovery UI 상세는 [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] 소유 — 본 branch는 트리거/분기까지만.)*
|
||||
- telemetry adapter 생성 실패 → console-safe fallback로 계속(boot 실패 아님, hub §4.5).
|
||||
|
||||
### 4. runtime config 검증 규칙
|
||||
|
||||
> **Trace**: D5 (hub §6.4). schema 구성·parse 메커니즘은 [[raw/branch-notes/feature-runtime-schema-validation-contract]] (`FE-OC-007`) 위임 — 본 §는 *무엇을* 검증하고 *어떤 boot 결과*로 이어지는지만.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `REQUEST_TIMEOUT_MS` 정수 범위(제안 1000–60000)는 hub가 default(10000)만 주고 경계 미규정 → 0/음수 timeout 방지용으로 제안. trade-off: 상한 60000 은 임의값이며 api-client owner(`FE-OC-009`)가 total timeout 정책을 lock 할 때 재확인 필요.
|
||||
> - **해소됨(구 `UNSUPPORTED_IMPL_DECISION`)**: `MAX_RETRY_ATTEMPTS` 허용 범위는 **0–2** — [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-D015`(initial call 이후 최대 2회)의 cap 을 config 경계에서 그대로 재선언한다. 이전 초안의 0–5 는 owner cap 보다 넓어 "config 는 통과, client 는 clamp" 하는 설정-동작 괴리를 만들었으므로 폐기. cap 값의 owner 는 `FE-OC-009` 이고 본 branch 는 값을 정하지 않으므로, cap 이 개정되면 이 범위도 따라 개정한다(본 노트 단독 변경 금지).
|
||||
|
||||
검증 MUST 커버(hub §6.4):
|
||||
|
||||
- required key 존재 (§1 Required=yes 전부)
|
||||
- URL protocol allowlist — prod policy는 `https`, local 예외는 문서화된 경우만
|
||||
- timeout/retry 정수 범위 — `REQUEST_TIMEOUT_MS` 1000–60000(제안), `MAX_RETRY_ATTEMPTS` 0–2(cap owner `FE-OC-009` 에 정렬)
|
||||
- boolean parse — truthy-string 모호성 없이(`"false"`가 true 되지 않게)
|
||||
- config schema version 호환 (`CONFIG_SCHEMA_VERSION`)
|
||||
- API contract version 호환 (`API_CONTRACT_VERSION`)
|
||||
- provider가 둘 다 노출하면 release/build ID coherence
|
||||
- unknown-key 정책: default strict, schema가 명시적 passthrough일 때만 additive 허용
|
||||
|
||||
*compat 실패 시 migration/version bump 정책은 [[raw/branch-notes/feature-frontend-contract-compatibility-governance]] (`FE-OC-023`) 위임.*
|
||||
|
||||
### 5. boot 실패 safe-output (redaction)
|
||||
|
||||
> **Trace**: D6 (hub §6.4). normalized error kind 어휘와 raw body/stack UI 유출 catalog는 [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] (`FE-OC-008`) 위임.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `supportReference` 생성 방식(무작위 correlation id vs `releaseId`+timestamp) 미규정 — hub는 field만 나열 → 무작위 opaque id 선택. trade-off: deploy timing 유출 방지하나, 트리아지용으로 telemetry event와 매핑되어야 함(`FE-OC-014` 소유).
|
||||
|
||||
boot error shell 노출 허용 field(allowlist, hub §6.4):
|
||||
|
||||
```text
|
||||
error.kind
|
||||
error.code
|
||||
buildId
|
||||
configSchemaVersion
|
||||
releaseId (if present)
|
||||
supportReference
|
||||
```
|
||||
|
||||
화면 금지: endpoint, query, header, raw config object, stack (hub §6.4).
|
||||
|
||||
### 6. boot config 검증 시간 예산 (`FE-NFR-006`) + `FE-GATE-004` 소유 분할
|
||||
|
||||
> **Trace**: D8 (hub §14.2 `FE-NFR-006` — deterministic mocked fetch, ≤ 500ms excluding network delay; hub §15.1 `FE-GATE-004` pass condition). invalid fixture 의 나머지 절반(content-type/JSON/envelope/payload)은 [[raw/branch-notes/feature-runtime-schema-validation-contract]] (`FE-OC-007`) 소유 — 해당 노트도 timing fixture 소유를 본 branch 로 귀속시키므로 양방향 일치.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 측정 시작·종료 지점을 hub 가 규정하지 않음("excluding network delay" 만 명시) → **시작 = config 원문(text/object)이 validator 에 전달되는 시점, 종료 = normalized config 반환 또는 `BOOT_CONFIG_FAILURE` 확정 시점**으로 고정. trade-off: JSON parse 비용이 예산 안에 포함되어 보수적으로 측정되지만, transport 구현(fetch·캐시·mock)에 무관한 재현 가능 구간이 된다.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 회차·통계(단일 실행 vs 다회 중앙값)를 hub 가 미규정 → **동일 fixture 5회 실행의 중앙값을 판정값으로 쓰고 최댓값도 report 에 함께 기록**. trade-off: CI 노이즈로 인한 flake 를 줄이지만 tail latency 를 판정에서 제외하므로, 최댓값이 예산의 2배를 넘으면 report 를 근거로 재검토한다.
|
||||
|
||||
**측정 대상(무엇을 재는가).** `FE-NFR-006` 은 *검증 구간만* 잰다. 포함: required-key 검사, URL protocol allowlist, int range, boolean strict parse, config/API version 호환 비교, release/build ID coherence, unknown-key strict 판정(§4 8항 전부). 제외: `GET {VITE_RUNTIME_CONFIG_URL}`·`GET {RELEASE_MANIFEST_URL}` 의 network 대기, mock 이 주입한 인위적 지연, 검증 이후의 adapter 조립·mount(그 구간은 `FE-OC-002` 소유이며 본 예산의 대상이 아님).
|
||||
|
||||
**fixture 가 network delay 를 배제하는 방법.** transport 를 deterministic mock 으로 대체하고(hub §14.2 context), config 본문을 *이미 메모리에 있는 값*으로 validator 에 직접 전달한다. 즉 fixture 는 fetch 를 거치지 않거나, 지연을 주입한 mock 을 쓰더라도 타이머를 fetch resolve *이후*에 시작한다. 따라서 mock 지연을 늘려도 측정값이 변하지 않아야 하며, 이 불변식 자체를 fixture 의 self-check 로 둔다(지연 0ms 와 지연 200ms 두 실행의 측정값 차이가 노이즈 범위 내).
|
||||
|
||||
**valid-config fixture 형태.** §1 registry 의 runtime key 10개를 모두 채운 valid config 1건(= 실제 boot 가 받는 최대 폭). 판정: 중앙값 ≤ 500ms.
|
||||
|
||||
**`FE-GATE-004@1` 소유 분할** (hub §15.1 의 Covered FE-OC 가 다수라 fixture 소유를 명시해야 중복·누락이 없다 — 어느 계약이 묶여 있는지는 hub §15.1 소유):
|
||||
|
||||
| `FE-GATE-004` 구성요소 | 소유 |
|
||||
|---|---|
|
||||
| config invalid matrix (required key 부재·protocol 위반·range 위반·boolean 모호·unknown key·version 비호환) | **본 branch** (`FE-OC-004`) |
|
||||
| valid-config timing fixture + timing report (`FE-NFR-006`) | **본 branch** (`FE-OC-004`) |
|
||||
| content-type / JSON / envelope / payload invalid matrix | `FE-OC-007` |
|
||||
| 각 invalid 입력의 기대 error kind 어휘 | `FE-OC-008` |
|
||||
| version 비호환 시 migration 판정 | `FE-OC-023` |
|
||||
|
||||
gate 는 두 소유자의 fixture 가 모두 있어야 PASS 하므로, 어느 한쪽만 준비된 상태에서 `FE-GATE-004` 를 PASS 로 올리지 않는다.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- config fetch non-2xx / JSON parse 실패 / schema 비호환 → `BOOT_CONFIG_FAILURE`, product route mount 중단, auto refetch 최대 1회 (hub §6.3·§16.1).
|
||||
- config/API/release version mismatch → `DEPLOY_MISMATCH`, controlled recovery UI, reload loop 금지 (hub §6.3).
|
||||
- invalid value(timeout/retry 범위 밖, boolean truthy-string, required key 부재) → boot fail (hub §5.4).
|
||||
- URL protocol 위반(prod에서 non-https) → boot fail (hub §6.4).
|
||||
- `TELEMETRY_ENABLED=true`인데 `TELEMETRY_ENDPOINT` 부재 → telemetry degrade(boot fail 아님, hub §5.4).
|
||||
- telemetry adapter 생성 실패 → console-safe fallback, boot 계속 (hub §4.5).
|
||||
- secret-name key가 env에 존재 → registry 거부(build/runtime), boot·build fail (hub §6.1).
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] (`FE-OC-003`) — `import.meta.env`·`VITE_` prefix 노출과 build identity 주입. 이 계약(checkJs·Vite build)이 바뀌면 build-time config 접근 방식 영향.
|
||||
- [[raw/branch-notes/feature-frontend-clean-architecture-layering-contract]] (`FE-OC-002`) — composition root(`bootstrap`) 소유. boot 2~4단계는 이 composition root 안의 단계로 slot in.
|
||||
- [[raw/branch-notes/feature-runtime-schema-validation-contract]] (`FE-OC-007`) — config 검증에 쓰는 Zod schema 메커니즘 consume. **`FE-GATE-004` 협업**: 본 branch 가 config invalid matrix + valid-config timing fixture(`FE-NFR-006`)를, 그쪽이 content-type/JSON/envelope/payload invalid matrix 를 제공(impl §6 분할표).
|
||||
- [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] (`FE-OC-019`) — D3 의 secret 차단은 *key 이름 기반 정적 거부*까지만 담당하고, 번들 scan·로그/telemetry 유출 등 *실제 노출 경로* 차단은 그쪽 소유. 두 계약이 함께 있어야 "secret 이 브라우저에 안 간다"가 성립한다.
|
||||
- [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] (`FE-OC-008`) — `BOOT_CONFIG_FAILURE`/`DEPLOY_MISMATCH` normalized kind consume.
|
||||
- [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] (`FE-OC-016`/`FE-OC-017`) — release manifest 정합성·`DEPLOY_MISMATCH` recovery·cache header. 본 branch는 검증된 config를 provide, recovery는 그쪽 소유.
|
||||
- [[raw/branch-notes/feature-api-client-response-envelope-contract]] (`FE-OC-006`) — 검증된 `API_BASE_URL`/`REQUEST_TIMEOUT_MS`/`MAX_RETRY_ATTEMPTS`를 consume(하류 소비자).
|
||||
- [[raw/branch-notes/feature-frontend-operational-runbook-contract]] (`FE-OC-025`) — boot config failure containment/escalation runbook(`FE-RB-001`, hub §16.1)의 technical escalation.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| build/runtime/secret 분류가 실제로 강제됨(secret-name key가 양쪽 registry에서 거부) | 코드·정적 가드 미존재, 규칙 문서만 있음 | config schema test + secret-name 거부 negative fixture (`FE-OC-004` minimum evidence) | `needs-confirmation` |
|
||||
| boot invalid-config matrix의 각 invalid 입력이 기대 boot 결과(`BOOT_CONFIG_FAILURE`/`DEPLOY_MISMATCH`/boot fail)로 매핑 | 다양한 실패 조합의 실제 boot 분기 미검증 | **boot invalid-config matrix** 테스트(§20 Measurable completion) | `needs-confirmation` |
|
||||
| runtime config가 `no-store`로 fetch되고 React mount 이전에 검증됨(실패 시 product route mount 안 됨) | 조립 순서·no-store가 코드로 보장되는지 미검증 | boot ordering 통합 테스트(mount 이전 fetch·실패 시 route 미mount 확인) | `needs-confirmation` |
|
||||
| boot 실패 화면이 safe-field만 노출(endpoint·raw config·stack 미유출) | redaction 강제 여부 미검증 | boot error shell redaction negative fixture | `needs-confirmation` |
|
||||
| 한 artifact를 여러 env에 재사용하지 않음(`FE-D013`) | 배포 프로세스 속성 — unit test로 완전 증명 불가 | 배포 파이프라인 assertion + env별 artifact hash 대조(문서화된 deploy check) | `needs-confirmation` |
|
||||
| non-`VITE_` build 변수가 client 번들로 유출되지 않음 | 번들 정적 치환 경계는 실제 빌드로만 확인 | build 후 bundle scan (`FE-OC-019` browser-security와 교차) | `needs-confirmation` |
|
||||
| valid config 검증이 `FE-NFR-006` 예산(≤ 500ms, mocked network delay 제외) 안에 들어옴 | 코드·검증 로직 미존재. 8항 검증 + schema 라이브러리(`FE-OC-007`)의 deep clone/parse 비용이 미측정이라 500ms 가 여유인지 빠듯한지 알 수 없음 | runtime key 10개를 채운 valid-config timing fixture 5회 실행의 중앙값 측정(impl §6) → `FE-GATE-004` timing report | `needs-confirmation` |
|
||||
| timing fixture 의 측정값이 mocked network delay 에 영향받지 않음(예산이 검증 구간만 잰다) | 측정 시작점이 fetch resolve 이후인지 코드로 강제되는지 미검증 | 동일 fixture 를 mock 지연 0ms / 200ms 로 각각 실행해 측정값 차이가 노이즈 범위 내인지 확인(impl §6 self-check) | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
> `/coverage`가 채우는 생성물이며 손으로 유지하지 않는다.
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| TODO — `/coverage` 실행 전 | missing | (없음) | 미평가 | TODO |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
| `FE-GATE-004@1` | [[raw/branch-notes/feature-runtime-schema-validation-contract]] | invalid fixture 가 예상 kind 로 거부되지 않으면 merge 를 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-OC-002@1` | [[raw/branch-notes/feature-frontend-clean-architecture-layering-contract]] | `domain <- application <- presentation` 의존 방향과 application-owned output port를 MUST 지킴 | import 참조로 적용 |
|
||||
| `FE-OC-003@1` | [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] | package manager, engine, lockfile, source language, checkJs command를 한 곳에서 MUST 고정 | import 참조로 적용 |
|
||||
| `FE-OC-007@1` | [[raw/branch-notes/feature-runtime-schema-validation-contract]] | JSON envelope와 payload는 boundary에서 runtime schema를 MUST 통과 | import 참조로 적용 |
|
||||
| `FE-OC-008@1` | [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] | 모든 failure는 stable frontend error kind로 MUST 정규화하고 raw body·stack을 UI에 노출하면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-009@1` | [[raw/branch-notes/feature-api-client-response-envelope-contract]] | retry는 safe/idempotent request에 한정하고 cap·jitter·`Retry-After`를 MUST 적용 | import 참조로 적용 |
|
||||
| `FE-OC-014@1` | [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] | telemetry는 best-effort이며 render·API success를 차단하면 안 되고 PII·token을 전송하면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-016@1` | [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] | HTML, asset, runtime config, release manifest cache policy를 MUST 구분 | import 참조로 적용 |
|
||||
| `FE-OC-019@1` | [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] | browser bundle에 secret을 넣지 않고 untrusted HTML injection을 기본 금지 | import 참조로 적용 |
|
||||
| `FE-OC-022@1` | [[raw/branch-notes/feature-frontend-contract-registry-governance]] | 8개 registry는 single primary owner와 compatibility impact를 MUST 기록 | import 참조로 적용 |
|
||||
| `FE-OC-023@1` | [[raw/branch-notes/feature-frontend-contract-compatibility-governance]] | API/config/storage/release schema의 breaking change는 migration 또는 version bump 없이 배포하면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-025@1` | [[raw/branch-notes/feature-frontend-operational-runbook-contract]] | boot, chunk mismatch, API degradation, telemetry failure, rollback runbook을 MUST 유지 | import 참조로 적용 |
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
### Sub-branches (세부 작업)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 오류 기록 (이 branch 작업 중 발생)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 강의 (이 작업을 위해 학습한 강의)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### job-posting tie-ins (이 작업에서 파생된 글감)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-frontend-operational-contract/branch-notes/feature-frontend-error-classification-boundary-contract.md
|
||||
@@ -0,0 +1,344 @@
|
||||
---
|
||||
title: branch / feature-frontend-error-classification-boundary-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-007
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-frontend-operational-contract
|
||||
work_item: WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-007
|
||||
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-VALIDATION-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-005, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-006]
|
||||
contract_packet: 1
|
||||
branch: feature-frontend-error-classification-boundary-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton-frontend, ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-frontend-operational-contract.md]
|
||||
tags: [branch, ca-skeleton, frontend, error-handling, integration, javascript, api-contract]
|
||||
created: 2026-07-18
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 24279f92059756981b403bc43da12c4b65488a08cc137f39a2277e0ae83dfdc7
|
||||
imports: [FE-OC-006@1, FE-OC-007@1, FE-OC-009@1, FE-OC-010@1, FE-OC-011@1, FE-OC-013@1, FE-OC-014@1, FE-OC-015@1, FE-OC-016@1, FE-OC-022@1, FLOW-FE-RESP-001@1, FLOW-FE-RESP-002@1, FLOW-FE-RESP-003@1, FLOW-FE-RESP-004@1, FLOW-FE-RESP-005@1, FLOW-FE-RESP-006@1, FLOW-FE-RESP-007@1]
|
||||
---
|
||||
|
||||
# branch: feature-frontend-error-classification-boundary-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 TODO·결정·진행 기록. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출한다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-frontend-operational-contract]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: normalization matrix와 raw body·stack leakage negative test가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-VALIDATION-001@1` | boundary runtime validation은 Zod schema로 수행한다 | validation failure signal을 stable frontend error kind로 정규화한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | 모든 failure를 total function으로 단일 kind에 정규화한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D2 | normalized failure는 safe field만 보존한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D3 | FE-REG-ERROR를 error UX의 single-owner registry로 사용한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D4 | project trigger-to-kind matrix를 구현 계약으로 사용한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D5 | user copy는 userMessageKey로 간접화한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D6 | recovery action을 closed vocabulary로 제한한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D7 | defaultRetryable은 분류 힌트로만 사용한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D8 | Zod validation failure를 stage별 kind로 매핑한다 | `local` | `raw/official-docs/zod-runtime-schema-validation-official.md#ZOD-VALID-C4`, `raw/official-docs/zod-runtime-schema-validation-official.md#ZOD-VALID-C5` | `proposed` |
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
없음.
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
이 브랜치는 project-wide 계약 `FE-OC-008`("모든 failure 는 stable frontend error kind 로 MUST 정규화하고 raw body·stack 을 UI 에 노출하면 안 됨")을 *구현 착수 가능한 상세 명세*로 내린다. 핵심은 두 불변식이다. (1) **Total normalization** — HTTP response·adapter exception·browser exception 어느 경로든 정확히 하나의 안정 error kind(hub §5.6 의 26-kind enum)로 정규화되고, 어떤 named branch 와도 일치하지 않으면 catch-all `UNKNOWN_FAILURE` 로 폐기되며, 정규화되지 않은 throw 가 presentation 으로 통과하는 경로는 없다(hub §8.2 total-function 문단). (2) **Redaction boundary** — normalized failure 는 §8.1 의 안전 필드 집합만 담고 raw body·token·authorization header·full URL/query·stack·storage value 는 절대 포함하지 않는다. 이 브랜치는 error 계약 registry `FE-REG-ERROR`(`src/contracts/errors.js`)의 single owner 이며(hub §5.1), 그 산출물을 세 계약에 기여한다 — `FE-OC-011`(async terminal-error state 가 registry `action` 을 소비), `FE-OC-015`(operational failure 를 normal state 로 반환해 render boundary 로 throw 하지 않는 분리 신호 제공), `FE-OC-020`(negative fixture 카탈로그). 모든 진술은 코드가 없으므로 `planned` 등급이다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- **Total normalization function** — response/adapter/browser exception → 26-kind 중 정확히 하나, 최종 catch-all `UNKNOWN_FAILURE`, presentation 으로의 un-normalized throw 금지 (hub §8.2, §5.6). 등급 `planned`.
|
||||
- 이 총함수가 곧 응답 처리 순서의 마지막 단계이며 **그 stage 의 owner 는 본 branch** 다 — hub §2.1.4 Flow Stage Registry `FLOW-FE-RESP-008@1`(application model 또는 실패 신호 → 정규화된 결과 반환; 총함수이므로 미매핑 예외는 `UNKNOWN_FAILURE` 로 귀결하고 throw 를 presentation 으로 통과시키지 않는다). 이 단계의 Invariants 를 바꾸려면 본 branch 가 revision 을 올려야 한다. stage 1~7 은 남의 소유라 `imports` 로만 pin 한다.
|
||||
- **`FE-REG-ERROR` registry** (`src/contracts/errors.js`) — kind/defaultRetryable/severity/userMessageKey/action/telemetryEvent/redaction 7-field row 를 26 kind 에 대해 소유 (hub §5.6, §5.1 owner map). 등급 `planned`.
|
||||
- **Trigger → kind 매핑 매트릭스** — hub §8.2 의 트리거(network/timeout/abort/content-type/JSON/envelope/schema/HTTP status class/chunk/boot/release/storage/render/telemetry/query-cache/unknown) → kind 총함수 매핑 구현 명세 (hub §8.2, §8.5). 등급 `planned`.
|
||||
- **Normalized failure safe-shape + redaction projection** — §8.1 필드 allowlist 만 통과, 나머지 drop (hub §8.1, §7.1 "raw response body 를 log 금지"). 이게 "raw body/stack 미노출" 절반. 등급 `planned`.
|
||||
- **`action` closed vocabulary 매핑** — 각 kind → `retry`/`reauth`/`navigate`/`reload-once`/`contact-support`/`none` 중 하나 + allowed-when/MUST-NOT 제약 (hub §8.4). 등급 `planned`.
|
||||
- **Negative fixtures + total-normalization matrix test + raw-body/stack leakage negative test** — §20 Measurable completion 의 두 산출물 (hub §8.5). 등급 `planned`.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적 제외. 각 항목은 소유 브랜치를 명시(§15.5 R3 OUT_OF_BRANCH_SCOPE 방지). 아래 sibling 링크는 hook quirk 회피를 위해 `FE-OC-###` 계약 ID 로만 짝지음.
|
||||
|
||||
- **Retry algorithm/loop**(backoff·jitter·`Retry-After`·cap) — [[raw/branch-notes/feature-api-client-response-envelope-contract]] 의 `FE-OC-009` 소유. 본 브랜치는 kind 별 `defaultRetryable` *분류 힌트*만 선언하고 실제 재시도 루프는 실행하지 않는다.
|
||||
- **Schema/envelope validation 실패 신호 생성**(ZodError) — [[raw/branch-notes/feature-runtime-schema-validation-contract]] 의 `FE-OC-007` 소유. 본 브랜치는 그 실패를 *소비*해 kind 로 매핑만 한다.
|
||||
- **Telemetry transport/queue/redaction sink** — [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] 의 `FE-OC-014` 소유. registry 는 `telemetryEvent` 참조와 redaction *규칙*만 선언한다.
|
||||
- **Error boundary component ownership + reload-loop guard 메커니즘** — [[raw/branch-notes/feature-frontend-render-recovery-boundary-contract]] 의 `FE-OC-015` 소유. 본 브랜치는 operational-vs-defect 분류 *입력*만 공급한다.
|
||||
- **Async surface state 렌더링** — [[raw/branch-notes/feature-async-ui-state-contract]] 의 `FE-OC-011` 소유. 본 브랜치는 kind + action 만 공급한다.
|
||||
- **Token lifecycle / 401 recovery callback state machine** — auth·api-client 소유(`FE-OC-010`/`FE-OC-006`). 본 브랜치는 401→`AUTH_REQUIRED`, 403→`FORBIDDEN`, adapter throw→`AUTH_INTEGRATION_FAILURE` *매핑*만.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | §8 Frontend Failure Taxonomy(§8.1 shape·§8.2 matrix·§8.3 retry order·§8.4 action·§8.5 fixture) + §5.6 error registry + §5.1 `FE-REG-ERROR` owner map — `FE-OC-008` 의 project-decision SSOT. D1·D2·D3·D4·D5·D6·D7 근거. |
|
||||
| [[raw/official-docs/zod-runtime-schema-validation-official]] | `FE-D007`(boundary runtime validation = Zod). `.parse()` 실패 시 granular `ZodError` throw(`ZOD-VALID-C4`)·`.safeParse()` discriminated union(`ZOD-VALID-C5`) 가 `SCHEMA_MISMATCH`/`ENVELOPE_MISMATCH` 매핑의 *소비 대상* 신호. D8 근거. 단 validator 소유는 sibling(`FE-OC-007`). |
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기. 현재 frontend 코드가 없으므로 전부 `planned`.
|
||||
|
||||
- [ ] `FE-REG-ERROR` registry `src/contracts/errors.js` — 26-kind × 7-field row 정의 (D3/D4/D5/D6) — 등급: `planned`
|
||||
- [ ] `adapters/http` total normalization function — dispatch 순서 + catch-all + safe-shape projection (D1/D2/D4/D7/D8) — 등급: `planned`
|
||||
- [ ] Trigger → kind 매핑 매트릭스 구현 (D4) — 등급: `planned`
|
||||
- [ ] Redaction / safe-shape projection — 필드 allowlist + drop rule (D2/D5) — 등급: `planned`
|
||||
- [ ] Total-normalization matrix test + raw-body/stack leakage negative test + §8.5 8종 negative fixture (D1/D2/D4) — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
없음 — `/branch-spec` 자동 채움 단계. 코드 미착수.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 아래 8개 결정은 Decision Evidence Map 의 prose mirror. 근거는 대부분 hub 의 project decision(§8/§5.6)이며, D8 만 외부 official-doc(Zod)이 병행 근거.
|
||||
|
||||
- 2026-07-19: **모든 failure 를 total function 으로 단일 kind 정규화** / 이유: presentation 이 raw exception·status 로 분기하면 계약이 깨지고 leakage 발생 / 검토한 대안: page 별 ad hoc try/catch(hub §5.1 `FE-REG-ERROR` "raw status/message 로 UI 분기" = ad hoc failure) — 배포 0회 throwaway 에서만 / 근거: hub §8.2 total-function 문단, §5.6.
|
||||
- 2026-07-19: **normalized failure 는 §8.1 safe 필드 집합만; raw body·token·header·URL·stack·storage value drop** / 이유: FE-OC-008 의 "raw body/stack 미노출" 강제 / 검토한 대안: 전체 error object 전달 후 UI 에서 마스킹 — 유출 위험으로 기각 / 근거: hub §8.1, §7.1.
|
||||
- 2026-07-19: **`FE-REG-ERROR` 를 error kind → 기본 UX 의 단일 owner registry 로 고정** / 이유: kind/action/userMessageKey/redaction 을 code 전역에서 재정의하면 single-owner 계약 위반 / 검토한 대안: 각 adapter 가 로컬 enum 소유 — governance 붕괴로 기각 / 근거: hub §5.6, §5.1.
|
||||
- 2026-07-19: **hub §8.2 trigger→kind 매핑(31-row / 고유 kind 26종)을 구현 매트릭스로 채택** / 이유: 트리거별 정규화 결과를 명세로 고정해야 total 성 검증 가능 / 검토한 대안: 상위 status class 만 매핑하고 나머지는 generic — negative fixture 통과 불가로 기각 / 근거: hub §8.2, §8.5.
|
||||
- 2026-07-19: **user copy 는 `userMessageKey` 간접화, `severity`(telemetry routing)와 분리, raw backend message 금지** / 이유: 다국어·문안 변경·PII 유출 방지 / 검토한 대안: backend `error.message` 직접 표시 — §5.6 금지 / 근거: hub §5.6.
|
||||
- 2026-07-19: **`action` 은 6개 closed vocabulary 로 제한** / 이유: 무한 spinner·history loop·반복 reload 같은 UX anti-pattern 을 계약으로 차단 / 검토한 대안: 자유 문자열 action — §8.4 제약 강제 불가로 기각 / 근거: hub §8.4, §5.6.
|
||||
- 2026-07-19: **`defaultRetryable` 은 분류 힌트일 뿐 재시도 결정이 아님** / 이유: 재시도 루프는 api-client 소유(method/idempotency/cap 조합), 분류는 요청을 발행하지 않음 / 검토한 대안: 분류 계층이 retryable=true 를 보고 직접 재시도 — safe/idempotency 조건 무시로 storm 위험, 기각 / 근거: hub §5.6(`defaultRetryable` override 가능), §8.3, §8.2 note.
|
||||
- 2026-07-19: **schema/envelope invalid 는 runtime-schema-validation 의 ZodError 를 소비해 `SCHEMA_MISMATCH`/`ENVELOPE_MISMATCH` 로 매핑, safe issue-path count + schema ID 만 보존** / 이유: validator 소유는 sibling, 분류는 결과 계약만 소비 / 검토한 대안: 분류 계층에서 zod schema 직접 실행 — 소유 경계 위반, 기각 / 근거: Zod `ZOD-VALID-C4`/`ZOD-VALID-C5`, hub §8.2·§5.6.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> `Supporting Claims` 는 hook quirk 회피를 위해 `FE-D###` 를 hub 경로에만 붙인다(sibling branch 링크 근처에 두지 않는다).
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | 모든 failure(response·adapter·browser exception)를 total function 으로 정확히 하나의 26-kind 로 정규화; 불일치·mapper 실패 시 catch-all `UNKNOWN_FAILURE`; un-normalized throw 의 presentation 통과 금지 (`FE-OC-008`) | client SPA 가 공유 backend 계약을 소비하고 배포·라우트가 존재하는 한 이 default 유지 / ad hoc page-local try/catch 는 route 1개·외부 API 0개·배포 0회 throwaway prototype 일 때만(hub §0.4 escape) | `raw/project-notes/ca-skeleton-frontend-operational-contract.md` §8.2 total-function 문단·§5.6 enum | `project-decision` | 총함수성은 exhaustive matrix test 로만 증명 가능(§8.5) — 미구현 시 mapper 누락 경로가 leak |
|
||||
| D2 | normalized failure 는 §8.1 safe 필드(kind/code/httpStatus?/retryable/operationId/attemptCount/requestId?/traceId?/userMessageKey/action/causeClass)만 담고 raw body·token·authorization header·full URL/query·stack·storage value 는 drop (`FE-OC-008`) | 모든 kind·모든 경로에서 불변(FE-OC-008 이 무조건 강제) / 예외 없음 — 예외 필요 시 FE-OC-008 자체 변경 절차(hub §3.3) | `...frontend-operational-contract.md` §8.1 shape·§7.1 "raw response body 를 log 금지"·§8.2 telemetry 열 | `project-decision` | leakage 는 negative test(직렬화 후 금지 필드 부재 assert)로만 확인 — 이게 FE-OC-008 minimum evidence |
|
||||
| D3 | `FE-REG-ERROR`(`src/contracts/errors.js`)를 error kind→기본 UX 의 single-owner registry 로 고정; kind/defaultRetryable/severity/userMessageKey/action/telemetryEvent/redaction 7-field | 8-registry governance(hub `FE-D018`)가 유효한 한 registry-first / code generation SSOT 채택 시 재검토 | `...frontend-operational-contract.md` §5.6·§5.1(`FE-REG-ERROR` owner=this branch, ad hoc=raw status/message 분기); `...frontend-operational-contract.md` `FE-D018` | `project-decision` | registry snapshot·single-owner scan 강제는 `FE-OC-022` sibling 소유 — 본 브랜치는 스키마·row 만 |
|
||||
| D4 | hub §8.2 trigger→kind 매핑(31-row / 고유 kind 26종)을 구현 매트릭스로 채택; 각 트리거는 정확히 하나의 kind, §8.5 8종 negative fixture 로 검증 | backend 가 structured JSON envelope + 표준 HTTP status 를 제공하는 한 유지 / backend protocol 이 근본적으로 다르면(hub 가정 C 무효) 매트릭스 재도출 | `...frontend-operational-contract.md` §8.2 matrix·§8.5 fixtures | `project-decision` | 일부 row 는 sibling 이 실패 신호를 *생성*해야 성립(schema→FE-OC-007, status/retry→FE-OC-009) — 그 계약 shape 미확정 시 매핑 재조정 |
|
||||
| D5 | user copy 는 `userMessageKey` 간접화, `severity`(telemetry routing hint)와 분리, raw backend `error.message` 표시 금지 | 다국어/문안 거버넌스가 존재하는 한 항상 keyed / 대안 없음 — raw message 표시는 §5.6 이 금지 | `...frontend-operational-contract.md` §5.6(userMessageKey·severity rule) | `project-decision` | message key → 실제 copy 카탈로그 소유(i18n)는 본 브랜치 밖 — 미정 시 key 계약만 고정 |
|
||||
| D6 | 각 kind 는 6개 closed action(`retry`/`reauth`/`navigate`/`reload-once`/`contact-support`/`none`) 중 하나로 매핑, §8.4 allowed-when/MUST-NOT 제약 준수 | UX 계약이 유지되는 한 closed set / product 가 새 recovery 모드를 요구하면 §8.4 확장 후 registry 갱신 | `...frontend-operational-contract.md` §8.4 vocabulary·§5.6 action field | `project-decision` | action 의 실제 UI 실행은 async-ui(`FE-OC-011`)·render-recovery(`FE-OC-015`) 소유 — 본 브랜치는 kind→action 계약만 |
|
||||
| D7 | `defaultRetryable` 은 분류 힌트일 뿐 재시도 결정·루프가 아님; 분류 계층은 어떤 요청도 발행하지 않음, request context override 가능 | 재시도 정책이 api-client(`FE-OC-009`) 소유인 한 힌트-only / 대안(분류가 직접 재시도)은 method+idempotency+cap 조건을 통합 소유하도록 scope 병합 시에만 | `...frontend-operational-contract.md` §5.6(`defaultRetryable` override 가능)·§8.3 retry decision order·§8.2 note("retryable=true 는 필요조건이지 충분조건 아님") | `project-decision (delegated boundary)` | 힌트와 실제 정책이 어긋나면(backend retryable=true 지만 unsafe mutation) storm — 통합 테스트로 경계 검증 필요 |
|
||||
| D8 | content-type/JSON/envelope/payload invalid 는 runtime-schema-validation 이 낸 ZodError 를 소비해 `CONTENT_TYPE_MISMATCH`/`MALFORMED_JSON`/`ENVELOPE_MISMATCH`/`SCHEMA_MISMATCH` 로 매핑, schema ID + safe issue-path count 만 보존 | `FE-D007`(Zod boundary validation)이 유효한 한 소비-매핑 / bundle budget·generated schema pipeline 이 대체안을 요구하면 재검토(hub `FE-D007` revisit) | `raw/official-docs/zod-runtime-schema-validation-official.md#ZOD-VALID-C4`, `#ZOD-VALID-C5`; `...frontend-operational-contract.md` `FE-D007`·§8.2 해당 row·§5.6 | `official-vendor-doc + project-decision` | ZodError → 어느 kind(envelope vs payload)인지는 sibling 이 어느 단계에서 던졌는지에 의존 — 처리 순서(§7.3 4~6단계) 계약 미확정 시 매핑 모호 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> `planned` blueprint. 경로는 hub §4.6 Planned directory blueprint + §5.1 owner map 에서 유도되므로 grounded 하지만 repository 생성 시 바뀔 수 있어 전체 `planned`. sibling 링크가 필요한 detail 은 §범위 Out of scope 로 위임했고 여기 남기지 않는다(R3).
|
||||
|
||||
### 1. `FE-REG-ERROR` 계약 registry (`src/contracts/errors.js`)
|
||||
|
||||
> **Trace**: D3 + D4 + D5 + D6 / `FE-OC-008`·`FE-REG-ERROR`·hub §5.6·§8.2·§8.4. 26-kind enum(hub §5.6) 각각에 대해 7-field row.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `code` 필드 포맷(§8.1 은 `code` 존재만 명시, 포맷 미규정) → `<KIND>` 접미 없는 안정 문자열 상수 채택. trade-off: kind 와 1:1 이면 code 잉여지만, backend `error.code`(§7.3)와 대응시키려면 별 축이 필요 — 초기엔 kind 파생 상수로 두고 backend code 매핑표는 추후.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `userMessageKey` 명명 스킴(§5.6 은 "key" 만 요구, 규칙 미규정) → `error.<kind_snake>.message` 제안(planned). trade-off: i18n 카탈로그 소유 밖이므로 key 계약만 고정, 실제 문안은 미정.
|
||||
|
||||
| Field | 규칙(hub §5.6) | 이 브랜치 명세 |
|
||||
|---|---|---|
|
||||
| `kind` | frontend stable enum | §5.6 26-kind enum 그대로, rename 금지 |
|
||||
| `defaultRetryable` | request context override 가능 | boolean 기본값; 실제 재시도는 D7 대로 미실행 |
|
||||
| `severity` | telemetry routing hint, user copy 분리 | enum(예: `low`/`warn`/`error`) — telemetry 소비, D5 대로 copy 와 분리 |
|
||||
| `userMessageKey` | raw backend message 금지 | key 상수(UNSUPPORTED_IMPL_DECISION 스킴) |
|
||||
| `action` | 6-value closed set | D6 vocabulary 중 하나 |
|
||||
| `telemetryEvent` | registry event 매핑 | `FE-REG-TELEMETRY` event 참조(소유는 FE-OC-014, 여기선 참조만) |
|
||||
| `redaction` | cause/body/header drop rule | D2 safe-shape 와 일치하는 drop rule id |
|
||||
|
||||
### 2. Total normalization 함수 (`adapters/http` error mapper)
|
||||
|
||||
> **Trace**: D1 + D2 + D4 + D7 + D8 / `FE-OC-008`·hub §8.2·§8.1·§7.3(처리 순서 4~8단계). `adapters/http` 가 "envelope/schema/error mapping" 을 소유(hub §4.2).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 정규화 함수 파일/심볼명(hub 는 `adapters/http/` 폴더와 `src/contracts/errors.js` registry 만 grounding, 함수명 미규정) → `adapters/http/normalize-failure.js` 단일 export 제안. trade-off: 이름은 임의지만 "단일 진입 + adapters/http 내부" 두 제약만 지키면 계약 동등.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: dispatch 메커니즘(§8.2 는 총함수·catch-all 만 요구, switch vs lookup table 미규정) → 트리거 판별 → kind lookup 순서 dispatch 제안. trade-off: lookup table 은 registry 대조가 쉽고 switch 는 분기 명시적 — 총함수성만 test 로 보장하면 무관.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `causeClass` internal allowlist 실제 값(§8.1 "internal allowlist only" 만, 목록 미열거) → 초기 allowlist(예: `network`/`parse`/`schema`/`auth`/`http-status`/`browser-storage`/`render`/`unknown`) 제안(planned). trade-off: allowlist 밖 값은 `unknown` 으로 접어 leak 방지, 세분화는 telemetry 요구에 따라 확장.
|
||||
|
||||
처리 순서(§7.3 4~8단계 하류에서 호출됨, 요청 발행 없음):
|
||||
|
||||
```text
|
||||
input = { transportOutcome | thrownValue, requestContext }
|
||||
1. aborted(navigation/user/superseded) 이면 REQUEST_ABORTED
|
||||
2. network-level opaque 실패면 NETWORK_UNREACHABLE / timeout 이면 REQUEST_TIMEOUT
|
||||
3. content-type/JSON/envelope/payload 실패 신호(sibling 생성)면 D8 매핑
|
||||
4. HTTP status class 면 §8.2 status row 매핑(auth/authz/not-found/conflict/validation/rate/server/generic)
|
||||
5. chunk/boot/release/deploy/storage/render/telemetry/query-cache 트리거면 해당 kind
|
||||
6. 위 어디에도 안 맞거나 mapper 자체 throw 면 UNKNOWN_FAILURE(catch-all)
|
||||
7. 매핑 결과를 §3 safe-shape 로 projection 후 반환 (raw value 폐기)
|
||||
```
|
||||
|
||||
### 3. Trigger → kind 매핑 매트릭스
|
||||
|
||||
> **Trace**: D4 + D8 / hub §8.2 (31-row / 고유 kind 26종 — row 기준으로 세면 같은 kind 로 매핑되는 status row 5개가 누락된다)·§8.5. 아래는 hub §8.2 를 이 브랜치의 in-scope(=여기서 정규화 산출) 관점으로 재기술한 것이며, "생성 소유"가 sibling 인 트리거는 *소비*만 표시(값 재정의 아님).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — 모든 row 는 hub §8.2 가 trigger·kind·retry·fallback·UX·telemetry 를 직접 grounding.
|
||||
|
||||
| 트리거 그룹(§8.2) | 산출 kind | 생성 소유 | 본 브랜치 역할 |
|
||||
|---|---|---|---|
|
||||
| network opaque / total timeout / abort | `NETWORK_UNREACHABLE`·`REQUEST_TIMEOUT`·`REQUEST_ABORTED` | api-client transport(`FE-OC-006`) | 소비→정규화 |
|
||||
| content-type/JSON/envelope/payload invalid | `CONTENT_TYPE_MISMATCH`·`MALFORMED_JSON`·`ENVELOPE_MISMATCH`·`SCHEMA_MISMATCH` | schema-validation(`FE-OC-007`) | 소비→정규화(D8) |
|
||||
| 401/403/404/409/422/other-4xx/429/5xx | `AUTH_REQUIRED`·`FORBIDDEN`·`NOT_FOUND`·`CONFLICT`·`VALIDATION_REJECTED`·`UNKNOWN_CLIENT_FAILURE`·`RATE_LIMITED`·`SERVER_FAILURE` | api-client status(`FE-OC-006`) | 소비→정규화, `defaultRetryable` 힌트만(D7) |
|
||||
| auth attach/recovery adapter 실패 | `AUTH_INTEGRATION_FAILURE` | auth/api-client(`FE-OC-010`) | 소비→정규화 |
|
||||
| chunk/boot/release/deploy | `CHUNK_LOAD_FAILURE`·`BOOT_CONFIG_FAILURE`·`RELEASE_MANIFEST_FAILURE`·`DEPLOY_MISMATCH` | bootstrap/release(`FE-OC-015`/`FE-OC-016`) | 소비→정규화 |
|
||||
| storage unavailable/quota | `STORAGE_UNAVAILABLE`·`STORAGE_QUOTA_EXCEEDED` | storage(`FE-OC-013`) | 소비→정규화 |
|
||||
| render throw / telemetry fail / query-cache fail / unknown | `RENDER_FAILURE`·`TELEMETRY_FAILURE`·`QUERY_CACHE_FAILURE`·`UNKNOWN_FAILURE` | 각 owner / catch-all | 소비→정규화, 최종 catch-all 소유 |
|
||||
|
||||
### 4. Redaction & safe-shape projection
|
||||
|
||||
> **Trace**: D2 + D5 / hub §8.1·§7.1·§8.2 telemetry 열. 정규화 함수 마지막 단계(§2 step 7).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: projection 구현 방식(§8.1 은 필드 집합만, allowlist-copy vs blocklist-delete 미규정) → **allowlist-copy**(안전 필드만 새 객체로 복사) 제안. trade-off: blocklist-delete 는 신규 raw 필드 추가 시 leak 위험 — allowlist 가 fail-closed 이므로 채택.
|
||||
|
||||
- **통과 허용(allowlist)**: §8.1 필드 집합 그대로.
|
||||
- **항상 drop**: raw response body, token, authorization header, full URL/query, stack, storage value(§8.1) + backend raw `error.message`(D5, §5.6).
|
||||
- **telemetry projection**: §8.2 telemetry 열의 kind별 safe 항목만(예: status group·attempts·elapsed bucket·schema ID·safe issue-path count) — raw URL·body·principal·token 금지. 실제 전송은 `FE-OC-014` 소유(여기선 payload 계약만).
|
||||
|
||||
### 5. test 카탈로그 (§20 Measurable completion)
|
||||
|
||||
> **Trace**: D1 + D2 + D4 / hub §8.5·§20("total normalization matrix + raw body/stack leakage negative tests"). `FE-OC-020` 기여.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: test 파일 경로·러너별 배치(hub §4.6 은 `tests/unit|component|...` 폴더만) → `tests/unit/error-classification/*` 배치 제안. trade-off: 경로 임의, "unit 레벨 + registry/mapper 대상" 계약만 유지.
|
||||
|
||||
| Fixture(§8.5) | 기대 정규화 결과 |
|
||||
|---|---|
|
||||
| JSON operation + `text/html` response | `CONTENT_TYPE_MISMATCH` |
|
||||
| auth attach callback throw/reject | `AUTH_INTEGRATION_FAILURE` |
|
||||
| bounded recovery invalid state | `AUTH_INTEGRATION_FAILURE` |
|
||||
| release manifest network/parse/schema 실패 | `RELEASE_MANIFEST_FAILURE` |
|
||||
| QueryCachePort adapter throw / invalid result | `QUERY_CACHE_FAILURE` |
|
||||
| unregistered `418`/기타 unmapped 4xx | `UNKNOWN_CLIENT_FAILURE` |
|
||||
| thrown non-`Error` / symbol / mapper exception | `UNKNOWN_FAILURE` |
|
||||
| **총함수 matrix test**(추가) | 26-kind 전체 트리거 exhaustive → 정확히 1 kind |
|
||||
| **leakage negative test**(추가) | 정규화 결과 직렬화 후 body/token/header/URL/stack/storage value 부재 assert |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- *Mapper 자체 throw* → 최종 catch-all 이 raw value 폐기 후 `UNKNOWN_FAILURE` 반환(§8.2 total-function 문단). 정규화 실패로 인한 un-normalized throw 는 계약상 존재 불가.
|
||||
- *Unmapped 4xx*(예: `418`) → `UNKNOWN_CLIENT_FAILURE`; *unmapped thrown value*(non-Error/symbol) → `UNKNOWN_FAILURE`(§8.5).
|
||||
- *이미 정규화된 failure 재진입* → 재정규화는 idempotent 여야 함(같은 kind 유지) — Claims To Verify 로 승격.
|
||||
- *registry 미등록 kind 사용* → registry 가 closed enum 이므로 컴파일/lint 단계 차단이 이상적(강제는 `FE-OC-022` governance sibling).
|
||||
- *`defaultRetryable=true` 이지만 unsafe mutation* → 분류는 힌트만 노출, 재시도 미실행(D7). 실제 안전성은 api-client 가 method/idempotency/cap 으로 최종 판단.
|
||||
- **다른 계약 의존**(hook quirk 회피: sibling 링크는 `FE-OC-###` 로만 짝지음):
|
||||
- [[raw/branch-notes/feature-api-client-response-envelope-contract]] (`FE-OC-006`/`FE-OC-009`) — transport outcome·HTTP status·retry 정책을 *생성/소유*. 그 계약(§7.3 처리 순서, §7.4 timeout/abort) 이 바뀌면 본 브랜치 트리거→kind 매핑 재조정 필요.
|
||||
- [[raw/branch-notes/feature-runtime-schema-validation-contract]] (`FE-OC-007`) — content-type/JSON/envelope/payload 검증 실패(ZodError)를 *생성*. 어느 단계에서 던지는지가 envelope vs payload kind 를 결정(D8) — 계약 변경 시 매핑 영향.
|
||||
- [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] (`FE-OC-014`) — `telemetryEvent`·redaction sink 를 *소비*. registry 의 telemetry payload 계약이 그 소유와 정합해야 함.
|
||||
- [[raw/branch-notes/feature-async-ui-state-contract]] (`FE-OC-011`) 와 [[raw/branch-notes/feature-frontend-render-recovery-boundary-contract]] (`FE-OC-015`) — normalized kind + action 을 *소비*(terminal-error state·operational-vs-defect 분리). 본 브랜치 산출이 이들 입력.
|
||||
- [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] (`FE-OC-020`) — negative fixture 를 gate 로 *소비*.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 정규화가 진짜 total 이다 — 어떤 경로도 un-normalized 로 presentation 도달 안 함 | 코드 미존재, mapper 누락 분기 가능 | exhaustive trigger matrix test + mapper-throws fixture → `UNKNOWN_FAILURE` (§8.5) | `needs-confirmation` |
|
||||
| normalized failure 에 raw body/stack/token/header/URL/storage value 가 유출되지 않는다 | allowlist projection 미구현 | leakage negative test — 결과 직렬화 후 금지 필드 부재 assert (FE-OC-008 minimum evidence) | `needs-confirmation` |
|
||||
| 26-kind 각각 정확히 1 registry row + closed action 1개를 갖는다 | registry 미작성 | registry snapshot test + action ∈ 6-set 검증 | `needs-confirmation` |
|
||||
| 분류 계층은 어떤 요청도 발행하지 않는다(재시도는 api-client 소유) | 힌트/정책 경계가 코드로 미분리 | 분류 함수 단위 test 에서 fetch/network mock 호출 0회 assert | `needs-confirmation` |
|
||||
| ZodError → envelope vs payload kind 매핑이 처리 순서와 정합 | sibling 처리 단계 계약 미확정 | schema-invalid fixture(envelope-level, payload-level 각각) → `ENVELOPE_MISMATCH`/`SCHEMA_MISMATCH` | `needs-confirmation` |
|
||||
| 재정규화가 idempotent 하다(이미 정규화된 failure 재진입 시 동일 kind) | 재진입 경로 미설계 | 정규화 결과를 재입력 → 동일 kind assert | `planned` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
> `/coverage`가 채우는 생성물이며 손으로 유지하지 않는다.
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| TODO — `/coverage` 실행 전 | missing | (없음) | 미평가 | TODO |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: flow:start -->
|
||||
### 가져온 흐름 단계
|
||||
|
||||
| Stage Ref | Order | Owner | Input | Action | Output |
|
||||
|---|---:|---|---|---|---|
|
||||
| `FLOW-FE-RESP-001@1` | 1 | [[raw/branch-notes/feature-api-client-response-envelope-contract]] | HTTP 요청 | transport 완료 대기 | raw Response |
|
||||
| `FLOW-FE-RESP-002@1` | 2 | [[raw/branch-notes/feature-api-client-response-envelope-contract]] | raw Response | content-type 기대값 검사 | 본문 판독 가능 Response |
|
||||
| `FLOW-FE-RESP-003@1` | 3 | [[raw/branch-notes/feature-api-client-response-envelope-contract]] | 본문 판독 가능 Response | JSON parse | unvalidated JSON |
|
||||
| `FLOW-FE-RESP-004@1` | 4 | [[raw/branch-notes/feature-runtime-schema-validation-contract]] | unvalidated JSON | envelope 공유 스키마 검증 | discriminated envelope |
|
||||
| `FLOW-FE-RESP-005@1` | 5 | [[raw/branch-notes/feature-runtime-schema-validation-contract]] | discriminated envelope | success/failure 분기 검증 | 분기 확정 envelope |
|
||||
| `FLOW-FE-RESP-006@1` | 6 | [[raw/branch-notes/feature-runtime-schema-validation-contract]] | 분기 확정 envelope | payload per-operation 스키마 검증 | 검증된 payload(deep clone) |
|
||||
| `FLOW-FE-RESP-007@1` | 7 | [[raw/branch-notes/feature-boundary-mapper-viewmodel-contract]] | 검증된 payload | DTO → application model 매핑 | application model |
|
||||
<!-- GENERATED: flow:end -->
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
| `FE-OC-006@1` | [[raw/branch-notes/feature-api-client-response-envelope-contract]] | 모든 HTTP는 shared client를 MUST 통과하고 timeout·abort·response parsing을 page에서 구현하면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-007@1` | [[raw/branch-notes/feature-runtime-schema-validation-contract]] | JSON envelope와 payload는 boundary에서 runtime schema를 MUST 통과 | import 참조로 적용 |
|
||||
| `FE-OC-009@1` | [[raw/branch-notes/feature-api-client-response-envelope-contract]] | retry는 safe/idempotent request에 한정하고 cap·jitter·`Retry-After`를 MUST 적용 | import 참조로 적용 |
|
||||
| `FE-OC-010@1` | [[raw/branch-notes/feature-frontend-auth-session-integration-contract]] | skeleton은 session state를 소비하되 token lifecycle을 MUST 소유하지 않음 | import 참조로 적용 |
|
||||
| `FE-OC-011@1` | [[raw/branch-notes/feature-async-ui-state-contract]] | async surface는 initial-loading, success, empty, terminal-error를 MUST 표현 | import 참조로 적용 |
|
||||
| `FE-OC-013@1` | [[raw/branch-notes/feature-frontend-storage-registry-contract]] | storage key는 namespace·version·classification을 MUST 가지며 token/secret 저장을 금지 | import 참조로 적용 |
|
||||
| `FE-OC-014@1` | [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] | telemetry는 best-effort이며 render·API success를 차단하면 안 되고 PII·token을 전송하면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-015@1` | [[raw/branch-notes/feature-frontend-render-recovery-boundary-contract]] | expected operational error와 render defect를 MUST 분리하고 reload loop를 금지 | import 참조로 적용 |
|
||||
| `FE-OC-016@1` | [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] | HTML, asset, runtime config, release manifest cache policy를 MUST 구분 | import 참조로 적용 |
|
||||
| `FE-OC-022@1` | [[raw/branch-notes/feature-frontend-contract-registry-governance]] | 8개 registry는 single primary owner와 compatibility impact를 MUST 기록 | import 참조로 적용 |
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
### Sub-branches (세부 작업)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 오류 기록 (이 branch 작업 중 발생)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 강의 (이 작업을 위해 학습한 강의)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### job-posting tie-ins (이 작업에서 파생된 글감)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-frontend-operational-contract/branch-notes/feature-frontend-observability-logging-trace-contract.md
|
||||
@@ -0,0 +1,378 @@
|
||||
---
|
||||
title: branch / feature-frontend-observability-logging-trace-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-012
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-frontend-operational-contract
|
||||
work_item: WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-012
|
||||
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-TELEMETRY-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-REGISTRY-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-004]
|
||||
contract_packet: 1
|
||||
branch: feature-frontend-observability-logging-trace-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton-frontend, ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-frontend-operational-contract.md]
|
||||
tags: [branch, ca-skeleton, frontend, observability, error-handling, javascript]
|
||||
created: 2026-07-18
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: e79f4a8ea9b3ea9f54126cdb47a1228194322b9fa883ca499d546f9ffa607e63
|
||||
imports: [FE-OC-008@1, FE-OC-015@1, FE-OC-021@1, FE-OC-025@1]
|
||||
|
||||
---
|
||||
|
||||
# branch: feature-frontend-observability-logging-trace-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 TODO·결정·진행 기록. `/branch-spec` 로 hub 계약을 구현-준비 spec 으로 내렸다. 프론트엔드 코드가 아직 없으므로 **모든 구현 주장은 `planned`** 이며 코드 evidence 는 repository 생성 후 채운다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-frontend-operational-contract]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: telemetry registry·redaction·bounded queue·sink failure test가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-TELEMETRY-001@1` | telemetry는 best-effort queue와 redaction을 사용하며 sink failure가 UI를 실패시키지 않는다 | TelemetryPort·queue·redaction·degradation 계약에 적용한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-REGISTRY-001@1` | route, API operation, env, storage, error, query, telemetry, release token을 8개 registry로 관리한다 | hub §5.8이 정의한 FE-REG-TELEMETRY 스키마·초기 event를 코드 registry로 구현한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | telemetry를 best-effort non-blocking 경로로 격리한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D2 | low-cardinality allowlist와 forbidden attribute redaction을 강제한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D3 | bounded queue와 비재귀 drop reporting을 사용한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D4 | FE-REG-TELEMETRY를 event schema의 single SSOT로 사용한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D5 | backend 지원 여부에 따라 trace correlation을 전파하거나 local ID로 강등한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D6 | delivery transport를 adapter-owned degradation 경로로 둔다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D7 | terminal failure telemetry를 bounded safe event로 제한한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D8 | TELEMETRY_ENABLED를 composition-root kill-switch로 소비한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
없음.
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
이 브랜치는 project-wide contract `FE-OC-014` (telemetry 는 best-effort 이며 render·API success 를 차단하면 안 되고 PII·token 을 전송하면 안 됨) 와 그 owner decision [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D021 (telemetry = best-effort queue + redaction, sink failure 는 UI 를 실패시키지 않음) 를 **구현자가 되묻지 않아도 코드를 쓸 수 있는 수준의 spec** 으로 내린다. 핵심 불변식은 **운영 격리 (operational isolation)** — telemetry 실패가 사용자 경험(render/API critical path)과 완전히 분리된다는 것이다. 동시에 이 브랜치는 `FE-REG-TELEMETRY` registry (§5.8 event/attribute/redaction) 의 single owner 로서 hub §5.8 이 정의한 최소 스키마와 초기 event 집합을 코드 registry 로 구현하고 emit 지점을 확정하며, `FE-OC-008` (실패→telemetry rule), `FE-OC-021` (low-cardinality 성능 attribute), `FE-OC-025` (`FE-RB-004` telemetry sink failure runbook) 에 telemetry 기여 edge 를 제공한다. 등급: 전 항목 `planned` (repository 부재).
|
||||
|
||||
- 이슈: 없음 (repository 미생성)
|
||||
- PR: 없음
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- **Best-effort 전달 불변식** — telemetry send 가 render·API critical path 를 절대 block 하지 않음, sink/queue/adapter-init 실패가 UI 를 실패시키지 않음 (`FE-OC-014`, [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D021 §11.2).
|
||||
- **`FE-REG-TELEMETRY` registry 파일 소유** — registry 스키마와 초기 5 event 의 *정의* 는 hub §5.8 소유이고, 본 브랜치는 그 registry 를 코드로 구현하고 emit 지점을 확정한다(§5.8, §5.1). 자유 문자열 event 금지.
|
||||
- **Redaction** — low-cardinality allowlist context 만 전송, forbidden attribute(token·email·raw URL/query/body·storage value·stack) 전송 금지 (§11.1, §5.8).
|
||||
- **Bounded queue + overflow 정책 + 비재귀** — bounded queue, overflow drop 정책 registry 명시, telemetry 실패를 동일 sink 로 재귀 보고하지 않음 (§11.2, §16.4).
|
||||
- **Delivery degradation** — local/dev console-safe sink, prod endpoint 부재/invalid 시 telemetry 만 degrade 하고 app 계속 실행 (§11.2, §16.4).
|
||||
- **`TELEMETRY_ENABLED` kill-switch 소비** — runtime flag(default `false`)가 off 일 때 telemetry 전체를 무력화하는 **소비 측 의미**와 그 단일 적용 지점 확정, `FE-RB-004` mitigation "telemetry runtime flag disable" 의 실행 가능성 보장 (§5.4, §16.4). key 선언·schema 검증 자체는 `FE-OC-004` 소유.
|
||||
- **Trace correlation (telemetry 관점)** — W3C `traceparent` 가 backend contract 상 허용될 때만 전파, 미지원 시 local operation ID 로 degrade, retry 는 같은 logical operation correlation 유지하되 attempt 구분 (§11.3, §8.1).
|
||||
- **기여 edge** — `FE-OC-008` 실패→telemetry rule column, `FE-OC-021` duration/attempt bucket 제공, `FE-OC-025` `FE-RB-004` recovery assertion.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외 — 다른 owner 브랜치/외부 계약이 소유. 본 브랜치는 telemetry 관점의 consume/기여만 한다.
|
||||
|
||||
- **Error kind 정규화 taxonomy 자체** — `FE-OC-008` owner (frontend-error-classification-boundary branch). 본 브랜치는 `error_kind` 를 소비만 하고 정의하지 않음.
|
||||
- **Render error boundary 소유·복구** — `FE-OC-015` owner (frontend-render-recovery-boundary branch). 본 브랜치는 boundary-catch 신호를 consume 해 `ui.render.failed` 를 emit 만 함.
|
||||
- **Web Vitals 측정·NFR 리포트** — `FE-OC-021` owner ([[raw/branch-notes/feature-web-vitals-performance-budget-contract]]). 본 브랜치는 low-cardinality attribute bucket 만 공급.
|
||||
- **`FE-RB-004` runbook 1차 소유** — `FE-OC-025` owner (frontend-operational-runbook branch). 본 브랜치는 technical escalation 이며 diagnosis evidence field 만 공급.
|
||||
- **Telemetry endpoint 의 CSP `connect-src`·bundle 내 secret 금지 강제** — `FE-OC-019` owner (frontend-browser-security-boundary branch).
|
||||
- **Runtime config 로딩·검증** — `FE-OC-004` owner (frontend-env-runtime-config branch). 본 브랜치는 endpoint 값을 consume 만 함(의존, §엣지·실패·의존).
|
||||
- **Token lifecycle** — 외부 Keycloak / [[raw/branch-notes/feature-frontend-auth-session-integration-contract]] (`FE-OC-010`). telemetry 는 token 을 절대 전송하지 않음.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | 본 브랜치의 거의 모든 결정의 SSOT — FE-D021(§3.2), FE-OC-014(§2.1), telemetry 계약(§11), `FE-REG-TELEMETRY`(§5.8/§5.1), 실패 matrix telemetry column(§8.2), `FE-RB-004`(§16.4), `TELEMETRY_ENABLED` runtime key(§5.4) + boot sequence(§6.3)/config validation(§6.4)/composition root(§4.5). D1~D8 전부 이 hub 의 project decision 을 인용한다. |
|
||||
| [[raw/official-docs/react-ui-library-official]] | `REACT-UI-C1` — presentation 이 React component 로 구성됨(버튼~페이지). `ui.render.failed` event 의 emit point 가 React component boundary catch 라는 D4 event catalog 항목을 간접 뒷받침. boundary 자체의 소유는 render-recovery branch(`FE-OC-015`)에 위임. |
|
||||
|
||||
> 참고: §11.3 trace correlation 이 언급하는 W3C `traceparent` (Trace Context) 는 실제 표준이나 `raw/official-docs/` 에 아직 아카이브되지 않았다. 따라서 사실로 인용하지 않고 hub §11.3 의 project decision(허용될 때만 전파)만 근거로 쓴다. 표준 자체를 근거로 삼아야 할 결정이 생기면 `wiki-source-summarizer` 로 먼저 아카이브한다.
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆 증거 등급. repository 부재 → 전부 `planned` / `needs-confirmation`.
|
||||
|
||||
- [ ] `FE-REG-TELEMETRY` registry (`src/contracts/telemetry.js`): hub §5.8 의 7-field 스키마와 초기 5 event 를 코드로 구현 + emit 지점 배선 — 등급: `planned`
|
||||
- [ ] Redaction 강제 (allowlist projection) + forbidden-attribute scan test — 등급: `planned`
|
||||
- [ ] Bounded queue + overflow drop 정책 + queue drain/memory test — 등급: `planned`
|
||||
- [ ] Sink failure / degradation test (endpoint invalid → telemetry 만 degrade, app 계속) — 등급: `planned`
|
||||
- [ ] Trace correlation 전파 + retry attempt 구분 test — 등급: `planned`
|
||||
- [ ] `TelemetryPort` (application) + telemetry adapter + composition-root wiring — 등급: `planned`
|
||||
- [ ] `TELEMETRY_ENABLED=false` → no-op port 주입 + zero-network/zero-queue 회귀 test (`FE-RB-004` mitigation 재현) — 등급: `planned`
|
||||
- [ ] 구현 repository 및 검증 evidence 식별 — 등급: `needs-confirmation`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- `/branch-spec` 로 hub §2/§3/§5/§8/§11/§16 을 내려 D1~D7 을 확정. 모든 grounding 은 hub project decision(FE-D021 중심) — 외부 official-doc 은 react-ui(간접)만 관여. web research 0건(hub 가 충분).
|
||||
- 2026-07-20 loop-back fill: coverage 감사에서 `TELEMETRY_ENABLED` kill-switch **소비 측** 메커니즘이 미결정(MISSING_CONCERN)으로 드러나 D8 + 구현 가이드 7 을 추가했다. hub §5.4 는 key 를 선언하고 §16.4 는 그 disable 을 mitigation lever 로 *요구* 하지만 소비 형태는 미명시 — 사용자 소유 브랜치가 없어 본 브랜치가 소비 owner 다(`FE-OC-004` 는 key 선언·schema 검증만 소유). 같은 pass 에서 `telemetry.delivery.dropped` 의 전달 채널(비재귀 구체화)과 `route_id`/`operation_id` producer 의존을 명시했다.
|
||||
- 운영 격리(operational isolation)가 이 브랜치의 축: telemetry 는 관찰 목적이며 절대 UX 를 볼모로 잡지 않는다. 그래서 delivery guarantee 를 주장하지 않고 best-effort 로 못 박는다.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 아래 Decision Evidence Map 의 prose mirror. 각 결정의 근거는 hub project decision.
|
||||
|
||||
- 2026-07-18: **Telemetry = best-effort, non-blocking** — render/API critical path 를 차단하지 않고 sink failure 가 UI 를 실패시키지 않는다. 대안(delivery-guaranteed audit channel)은 regulated audit event 가 필요할 때만 별도 계약으로 분리. 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D021 §11.2.
|
||||
- 2026-07-18: **Redaction 우선** — low-cardinality allowlist context 만 전송, token/PII/raw payload 는 forbidden. 근거: hub §11.1, §5.8.
|
||||
- 2026-07-18: **Bounded queue + 비재귀** — overflow drop 정책을 registry 에 명시, telemetry 실패를 동일 sink 로 재귀 전송하지 않음. 근거: hub §11.2, §16.4.
|
||||
- 2026-07-18: **`FE-REG-TELEMETRY` single SSOT** — 자유 문자열 event 금지, 초기 5 event 고정. 근거: hub §5.8, §5.1, FE-D018.
|
||||
- 2026-07-18: **Trace correlation 은 조건부 전파** — backend contract 가 허용할 때만 traceparent 전파, 아니면 local operation ID 로 degrade. 근거: hub §11.3, §8.1.
|
||||
- 2026-07-18: **Delivery transport 는 adapter-owned·degradable** — dev console sink, prod endpoint invalid 시 telemetry 만 degrade. 근거: hub §11.2, §4.2, §16.4.
|
||||
- 2026-07-18: **실패→telemetry 매핑은 bounded·safe** (`FE-OC-008` 기여) — terminal 실패당 safe field 만으로 최대 1 event, abort 는 error event 미발생, `TELEMETRY_FAILURE` 재귀 금지. 근거: hub §8.2, §8.1.
|
||||
- 2026-07-20: **`TELEMETRY_ENABLED` 는 composition-root 단일 지점의 kill-switch** — flag 가 `false`(hub 기본값)면 real adapter 를 **아예 구성하지 않고** no-op `TelemetryPort` 를 주입한다. queue·redaction·sink·counter 가 전혀 생성되지 않으므로 disable 은 "전송 억제"가 아니라 "경로 부재"다. flag 는 boot-time runtime config 이므로 in-session flip 은 없고, 다음 boot 에 반영된다. 근거: hub §5.4(`TELEMETRY_ENABLED` runtime·required·default `false`), §16.4 Mitigation("telemetry runtime flag disable"), §6.3 boot sequence, §4.5 composition root.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 모든 Supporting Claim 은 hub project decision. `[[...operational-contract]]` (project link) 옆의 `FE-D###`·`§n` 은 consistency hook 상 project 링크로 안전하게 검증된다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | Telemetry 는 best-effort — render·API critical path 를 block 하지 않고 sink failure 가 UI 를 실패시키지 않는다 (`FE-OC-014`) | product telemetry 는 best-effort default 유지. regulated audit event 처럼 delivery guarantee 가 필요하면 best-effort 와 분리된 **별도 audit channel 계약** 신설 (FE-D021 revisit trigger) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D021 §11.2 | `project-decision` | repository 부재 — telemetry throw 가 render/API success 를 깨지 않음을 증명하는 non-blocking test 필요 |
|
||||
| D2 | Redaction — low-cardinality allowlist context 만 전송, forbidden attribute(token·email·raw URL/query/body·storage value·stack) 전송 금지 | allowlist 가 invariant(accepted-documented-only). 신규 attribute 는 registry 추가 전 low-cardinality + non-PII 검토 통과 시에만 허용; 실패하면 forbidden 분류 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D021 §11.1 §5.8 | `project-decision` | redaction 은 caller 가 아니라 transport boundary(adapter)에서 강제해야 함 — forbidden-attribute scan test 로 leakage 0 증명 필요 |
|
||||
| D3 | Bounded queue + overflow drop 정책 registry 명시 + telemetry 실패 비재귀 보고 | queue 는 항상 bounded. drop 방향(oldest vs newest)은 event class 별 registry 선언값 — 미선언 시 기본 oldest-drop (§구현 가이드 4 의 `UNSUPPORTED_IMPL_DECISION`) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D021 §11.2 §16.4 | `project-decision` | queue 상한 크기 미확정 — memory/queue drain test 로 bound 내 drain 증명 필요 |
|
||||
| D4 | `FE-REG-TELEMETRY` 가 event/attribute/redaction 의 single SSOT; 자유 문자열 event 금지; 초기 5 event(`app.boot.failed`·`api.request.failed`·`ui.render.failed`·`release.mismatch.detected`·`telemetry.delivery.dropped`) 고정 | registry-owned 유지. code generation SSOT 채택이 FE-D018 revisit trigger. `ui.render.failed` trigger 는 React boundary catch (`REACT-UI-C1` 이 presentation=React 구성을 뒷받침) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D018 §5.8 §5.1; [[raw/official-docs/react-ui-library-official]] REACT-UI-C1 | `project-decision` | registry schema validation test(자유 문자열 event reject) 필요; event 별 required attribute 가 실제 발생 지점에서 수집 가능한지 미검증 |
|
||||
| D5 | Trace correlation — W3C `traceparent` 는 backend contract 허용 시에만 전파, requestId/traceId 는 safe internal reference 로 보관, raw trace header user 미노출, 미지원 backend 는 local operation ID 로 degrade, retry 는 같은 logical operation correlation 유지하되 attempt 구분 | backend contract 가 traceparent 지원 → 전파; 미지원 → local operation ID 로 degrade | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D021 §11.3 §8.1 | `conditional-default` | 전파는 backend contract 의존(외부); W3C Trace Context 표준 미아카이브 → 표준 세부는 사실 인용 불가; retry 간 correlation(같은 op, distinct attempt) test 필요 |
|
||||
| D6 | Delivery transport 는 adapter-owned·degradable — local/dev console-safe sink, prod endpoint 부재/invalid 면 telemetry 만 degrade 하고 app 계속, page-hide `sendBeacon` 은 adapter decision 이며 delivery guarantee 아님 | local/dev → console sink; prod → endpoint sink; page-hide `sendBeacon` 은 optional(no guarantee) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D021 §11.2 §4.2 §16.4 | `project-decision` | endpoint invalid boot path 가 telemetry 만 degrade(app 계속)함을 증명하는 sink-failure test 필요 |
|
||||
| D7 | 실패→telemetry 매핑은 bounded·safe (`FE-OC-008` 기여) — §8.2 각 terminal normalized failure 는 safe field(status group·attempt bucket·route ID)만으로 최대 1 event, abort 는 error event 미발생, `TELEMETRY_FAILURE` 는 재귀 금지 | normalized error taxonomy 는 error-classification branch(`FE-OC-008`) 소유 — 본 브랜치는 그 kind 를 consume 해 telemetry rule column 만 구현. taxonomy 가 바뀌면 매핑 갱신 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D021 §8.2 §8.1 | `project-decision` | `error_kind` registry 소유는 error-classification boundary branch(`FE-OC-008`) — 그 registry 미확정 시 매핑 draft 상태 |
|
||||
| D8 | `TELEMETRY_ENABLED` kill-switch 는 composition root 단일 지점에서 소비 — `false`(hub default)면 real adapter 미구성 + no-op `TelemetryPort` 주입(queue·redaction·sink·counter 모두 미생성), `true` 면 D6 delivery ladder 진입. flag 는 boot-time 값이므로 in-session flip 없음(다음 boot 반영), 따라서 flip 시 stranded queue 문제가 정의상 발생하지 않음. `FE-RB-004` mitigation "telemetry runtime flag disable" 은 이 경로로 실행된다 | flag `false` → no-op(관측 0, 부작용 0); `true` → 정상 경로. call-site 조건 분기(`if (telemetry)`)나 port null 주입은 채택하지 않음 — hub §4.2 상 presentation/use-case 는 `TelemetryPort` 만 참조하므로 disable 이 call site 로 새면 안 됨 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D021 §5.4 §16.4 §6.3 §4.5 §11.2 | `project-decision` | no-op vs 미구성의 *구현 형태* 는 hub 미명시(구현 가이드 7 의 `UNSUPPORTED_IMPL_DECISION`); flag off 상태에서도 product e2e 가 동일해야 함을 증명하는 both-state test 필요 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> `planned` blueprint (프론트엔드 코드 부재). 경로는 hub §4.6 Planned directory blueprint + §5.1 registry owner map 에서 도출된 `planned` anchor 이며 repository 생성 시 변경될 수 있다. 3-rule (R1 Trace 필수 / R2 UNSUPPORTED_IMPL_DECISION / R3 no OUT_OF_BRANCH_SCOPE) 준수.
|
||||
|
||||
### 1. TelemetryPort + adapter + composition-root wiring
|
||||
|
||||
> **Trace**: D1 + D6 · `FE-OC-014` · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D021 §4.2 §4.6
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: port method 표면(`record(event)` 단일 vs `record`+`flush`+`shutdown`) 과 파일명은 hub 미명시 → 최소 표면(`record` only)을 초기 default 로 제안. trade-off: 최소 표면은 오용 여지가 적으나 page-hide flush 를 adapter 내부로 숨겨야 함.
|
||||
|
||||
| 요소 | Planned 경로 | 책임 | MUST NOT |
|
||||
|---|---|---|---|
|
||||
| `TelemetryPort` (application-owned interface) | `src/application/ports/` | use-case/presentation 이 부르는 telemetry 계약 정의 | 구현·browser transport·UX 결정 |
|
||||
| telemetry adapter | `src/adapters/telemetry/` | queue·redaction·sink 구현, port 구현 | navigation/UX 결정 (hub §4.2) |
|
||||
| composition root | `src/bootstrap/composition-root.js` | runtime config(`TELEMETRY_ENABLED` + endpoint)로 **real adapter 또는 no-op port** 를 생성·주입 (kill-switch 단일 지점 — 7 참조) | business rule, call-site 조건 분기 |
|
||||
|
||||
- presentation/use-case 는 `TelemetryPort` 만 참조하고 transport 를 직접 부르지 않는다 (hub §4.2 presentation MUST NOT own telemetry transport).
|
||||
- adapter 는 endpoint 값을 runtime config 에서 주입받는다 (config 로딩은 env-runtime-config branch 소유 — §엣지·실패·의존).
|
||||
|
||||
### 2. `FE-REG-TELEMETRY` registry
|
||||
|
||||
> **Trace**: D4 · `FE-REG-TELEMETRY` · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D018 §5.8 §5.1
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — 스키마 필드와 초기 event 집합은 hub §5.8 을 그대로 채택(신규 제안 아님).
|
||||
|
||||
Planned 경로: `src/contracts/telemetry.js` (single owner: 본 브랜치, hub §5.1).
|
||||
|
||||
**registry 최소 스키마(7-field)의 owner 는 [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §5.8 이다** — "§5.8 그대로" 라고 스스로 밝혔듯 복제였으므로 걷어낸다. 요약 한 줄: event 는 `eventName`·`trigger`·`requiredAttributes`·`optionalAttributes`·`forbiddenAttributes`·`sampling`·`delivery` 를 모두 갖고, required attribute 는 low-cardinality 만 허용한다.
|
||||
|
||||
초기 5 event 의 **정의(trigger + required attributes)는 [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §5.8 소유**다. 본 절은 그 event 를 *어디서 emit 하는가* 만 정한다 — 정의를 옮겨 적으면 hub 가 attribute 를 바꿀 때 이 표가 조용히 낡는다(실제로 `attempt_count` → `attempt_count_bucket` rename 을 놓쳤었다).
|
||||
|
||||
| Event | 본 브랜치의 emit 지점 |
|
||||
|---|---|
|
||||
| `app.boot.failed` | boot config/release validation 실패 경로 |
|
||||
| `api.request.failed` | API client 의 terminal normalized failure 반환 직전 |
|
||||
| `ui.render.failed` | render recovery boundary 의 catch 핸들러 |
|
||||
| `release.mismatch.detected` | release check 가 mismatch 를 확정한 지점 |
|
||||
| `telemetry.delivery.dropped` | 본 브랜치 sink adapter 의 queue drop 경로 |
|
||||
|
||||
- 자유 문자열 event 전송 금지 (hub §5.1 ad hoc use failure). registry 미등록 event 는 build/test 에서 reject.
|
||||
|
||||
### 3. Redaction 강제
|
||||
|
||||
> **Trace**: D2 · `FE-OC-014` · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D021 §11.1 §5.8
|
||||
>
|
||||
> - **해소됨(2026-07-21) — 근거 있는 결정**: redaction 메커니즘은 hub 가 정한다. [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §11.1 이 "이 목록은 **exhaustive default-deny allowlist**다 … 목록 밖 attribute 는 transport boundary 에서 제거된다" 로 메커니즘(default-deny allowlist projection)과 강제 지점(transport boundary)을 모두 명시했다. 본 브랜치가 고른 trade-off 가 아니므로 `UNSUPPORTED_IMPL_DECISION` 라벨을 뗀다.
|
||||
|
||||
- **허용/금지 attribute 어휘의 owner 는 [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §11.1** 이며 exhaustive default-deny allowlist 다. 목록을 여기에 옮겨 적지 않는다 — 옮겨 적은 사본이 hub 보다 짧으면 본 절 §2 가 선언한 event(`release.mismatch.detected` 의 `active_release_id`·`mismatch_kind`, `telemetry.delivery.dropped` 의 `reason`·`queue_size_bucket`)가 transport boundary 에서 전부 제거되어 계약이 자기모순에 빠진다.
|
||||
- 본 브랜치가 소유하는 것은 *강제 방법* 이다: redaction 은 adapter 의 transport boundary 에서 수행하고 caller 를 신뢰하지 않는다. forbidden-attribute scan test 가 emit payload 를 검사해 위반 시 실패(§검증).
|
||||
|
||||
### 4. Bounded queue + overflow + degradation ladder + 비재귀
|
||||
|
||||
> **Trace**: D3 + D6 · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D021 §11.2 §16.4
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (a) queue 상한 크기, (b) 기본 drop 방향(oldest vs newest), (c) page-hide `sendBeacon` 사용 여부는 hub 미명시 → 초기 default 로 **oldest-drop + 유한 상한(초기 제안값, memory test 로 확정)** 제안, `sendBeacon` 은 adapter 내부 optional. trade-off: oldest-drop 은 최신 event 를 보존하나 boot 초기 event 를 잃을 수 있음.
|
||||
|
||||
| 단계 | 동작 | 근거 |
|
||||
|---|---|---|
|
||||
| 정상 | bounded queue 적재 → sink flush | §11.2 |
|
||||
| overflow | drop 정책(registry 선언; 기본 oldest-drop) 적용 + `telemetry.delivery.dropped` self-metric 1건 | §11.2, §5.8 |
|
||||
| sink non-2xx/network 실패 | product flow 계속, console-safe fallback(safe field 한정), 동일 sink 재귀 보고 금지 | §16.4 Containment |
|
||||
| adapter init 실패 | telemetry 만 degrade, app 계속 | §11.2 |
|
||||
| prod endpoint 부재/invalid | telemetry 만 degrade, app 계속 | §11.2 |
|
||||
|
||||
- telemetry failure 를 telemetry 로 재귀 전송하지 않는다 (hub §11.2). `telemetry.delivery.dropped` 는 self-metric 이며 sink 실패의 원인 event 를 다시 sink 로 보내지 않는다.
|
||||
|
||||
**`telemetry.delivery.dropped` 자체의 전달 채널** (비재귀 불변식의 구체화):
|
||||
|
||||
> **UNSUPPORTED_IMPL_DECISION**: hub §5.8 은 `telemetry.delivery.dropped` 의 event *shape* 만 정의하고 그 event 자신이 *어느 채널로* 나가는지는 명시하지 않는다. hub §16.4 Diagnosis evidence 가 요구하는 산출물이 event stream 이 아니라 **"dropped event count"·"queue size bucket"** 이라는 점에 근거해, 아래 counter-우선 채널을 초기 default 로 제안한다. trade-off: counter 는 drop 폭주 시에도 자기 증폭이 없고 §16.4 evidence 형태와 1:1 이지만, 개별 drop 의 시점 분포(timeline)를 잃는다.
|
||||
|
||||
- self-metric 은 **동일 bounded queue 에 재적재(re-enqueue)하지 않는다** — full/dead queue 로 되돌리는 것은 정의상 순환이며 overflow 를 가속한다.
|
||||
- 대신 adapter 내부의 **in-process 단조 counter**(key = `reason` × `queue_size_bucket`, hub §5.8 required attribute 와 동형)로 집계하고, hub §16.4 Containment 의 console-safe fallback(safe field 한정)으로 즉시 관측 가능하게 한다.
|
||||
- 이 counter 는 `FE-RB-004` diagnosis evidence 의 `dropped event count` 로 그대로 공급된다(§6 기여 edge).
|
||||
- sink 가 회복되어 **정상 flush 가 성공한 이후**에 한해, 누적 counter 를 aggregated event 1건으로 승격 전송하는 것은 adapter 의 optional 결정이다 — 실패 중인 sink 로는 시도하지 않으며 delivery guarantee 로 표현하지 않는다 (hub §11.2).
|
||||
- counter 자체는 sink 실패로 소실되지 않아야 하므로 queue 와 독립된 lifetime 을 가진다(document lifetime 한정, 영속화 없음 — 영속화는 storage registry owner 영역).
|
||||
|
||||
### 5. Trace correlation
|
||||
|
||||
> **Trace**: D5 · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D021 §11.3 §8.1
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: local operation ID 포맷(예: `operationId#attempt`)은 hub 미명시 → 사용자 선택. trade-off: 짧은 포맷은 로그 가독성↑ 이나 충돌 회피를 위해 request-scoped uniqueness 보장 로직 필요.
|
||||
|
||||
- W3C `traceparent` 는 외부 auth/backend contract 가 허용할 때만 전파 (hub §11.3).
|
||||
- backend 응답의 `requestId`/`traceId` (envelope `meta`, §7.3)는 safe support reference 로 내부 state 보관 가능, user 에 raw 노출 금지.
|
||||
- normalized failure shape(§8.1)의 `requestId`/`traceId` 는 optional — 존재 시 telemetry attribute 로 승격하지 않고 내부 correlation 에만 사용.
|
||||
- retry(new request)는 같은 logical operation correlation 유지하되 `attempt` 로 구분 (hub §11.3, §7.2 `attempt`).
|
||||
- trace propagation 미지원 backend 는 local operation ID 로 degrade.
|
||||
|
||||
### 6. 기여 edge (contribution, ownership 은 위임)
|
||||
|
||||
> **Trace**: D7 · `FE-OC-008` / `FE-OC-021` / `FE-OC-025` 기여 · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D021 §8.2 §16.4
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — 아래는 hub 계약 인용이며 각 owner 브랜치에 위임(R3). 본 절은 telemetry 기여 edge 만 명세.
|
||||
|
||||
| 기여 대상 | 본 브랜치가 제공하는 telemetry edge | Owner (위임) |
|
||||
|---|---|---|
|
||||
| `FE-OC-008` 실패 taxonomy | §8.2 Telemetry rule column 구현 — terminal 실패당 safe field 만으로 최대 1 event, abort 는 error event 미발생, raw URL/body 금지 | error-classification boundary branch |
|
||||
| `FE-OC-021` NFR | `duration_bucket`·`attempt_count_bucket` 등 low-cardinality attribute 공급(측정·리포트는 미소유) | web-vitals-performance-budget branch |
|
||||
| `FE-OC-025` runbook | `FE-RB-004` diagnosis evidence field(endpoint classification·queue size bucket·dropped count·build/release ID·redaction test result) + recovery assertion 공급, **및 Mitigation "telemetry runtime flag disable" 의 실행 경로(D8, 구현 가이드 7) 보장** | frontend-operational-runbook branch |
|
||||
|
||||
### 7. `TELEMETRY_ENABLED` kill-switch 소비
|
||||
|
||||
> **Trace**: D8 (+ D1 non-blocking / D6 degradation ladder) · `FE-OC-014` · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D021 §5.4 §16.4 §6.3 §4.5 §11.2
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: hub §5.4 는 `TELEMETRY_ENABLED` 를 required runtime key(default `false`)로 *선언*하고 §16.4 는 그 disable 을 *mitigation lever* 로 *요구*하지만, 소비 형태(real adapter 미구성 + **no-op port 주입** vs port 자체를 optional/null 로 두고 call site 에서 분기)는 명시하지 않는다 → **no-op port 주입** 을 초기 default 로 제안. trade-off: no-op 은 disable 경로를 composition root 한 곳에 가두고 call site 를 flag-무지 상태로 유지하나(hub §4.2 의 "presentation 은 `TelemetryPort` 만 참조" 와 정합), no-op 객체가 항상 존재하므로 "telemetry 가 꺼져 있다"는 사실이 호출자에게 보이지 않는다(관측은 boot-time config snapshot 으로만 확인 가능).
|
||||
|
||||
**flag 상태별 구성 (composition root 분기 지점 1곳)**
|
||||
|
||||
| `TELEMETRY_ENABLED` | composition root 동작 | 생성되는 것 | 생성되지 않는 것 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| `false` (hub default, §5.4) | no-op `TelemetryPort` 주입 | port 표면(`record`)만 | queue · redaction projection · sink/transport · dropped counter · `TELEMETRY_ENDPOINT` 해석 | §5.4, §16.4 Mitigation |
|
||||
| `true` | real telemetry adapter 구성 후 주입 | 구현 가이드 2~5 전체 경로 | — | §11.2, §4.5 |
|
||||
|
||||
- **no-op 의 계약**: `record(event)` 는 인자를 읽지 않고 즉시 반환하며 throw 하지 않는다(D1 non-blocking 불변식을 flag 양쪽 상태에서 동일하게 유지). 어떤 event 도 buffer 하지 않으므로 나중에 flag 가 켜져도 소급 전송되는 event 는 없다.
|
||||
- **disable 은 "전송 억제"가 아니라 "경로 부재"**: queue 도 counter 도 생성되지 않으므로 §11.1 redaction 위반 표면과 §11.2 overflow 표면이 동시에 0 이 된다. `TELEMETRY_ENDPOINT` 는 hub §5.4 상 `Required: conditional` — 그 조건이 곧 `TELEMETRY_ENABLED=true` 라는 것이 본 브랜치의 소비 측 해석이며, schema 상 conditional 강제는 `FE-OC-004` 소유(§엣지·실패·의존).
|
||||
- **runtime flip 가능성**: runtime config 는 hub §6.3 boot sequence 에서 `GET /config.json` (no-store) 로 **boot 시 1회** 로드된 뒤 §4.5 composition root 가 의존성을 구성한다. hub 에 config hot-reload 계약이 없으므로 **in-session flip 은 존재하지 않는다** — flag 변경은 provider 측에서 반영한 뒤 **다음 document load(boot)** 부터 적용된다.
|
||||
- **flip 시 이미 queue 에 쌓인 event**: 위 결과로 정의상 문제가 발생하지 않는다. `true`→`false` 는 이전 session 의 queue 를 flush 하지 않고 document 와 함께 폐기하며(§11.2 best-effort — delivery guarantee 없음이므로 손실이 계약 위반이 아님), `false`→`true` 는 시작 시점부터의 event 만 다룬다(no-op 이 아무것도 보관하지 않았으므로 backfill 대상 없음).
|
||||
- **`FE-RB-004` mitigation 충족 경로**: §16.4 Mitigation 의 "telemetry runtime flag disable" 은 ① provider 의 runtime config 에서 `TELEMETRY_ENABLED=false` 설정 → ② 이후 boot 부터 no-op 주입 → ③ sink 호출·queue 적재·drop counter 증가가 **발생 원천에서** 중단 → ④ §16.4 Containment("product flow 계속")와 Recovery assertion("product e2e unaffected")이 flag 양쪽 상태에서 동일하게 성립, 의 순서로 실행된다. 이 lever 는 sink restore 없이도 즉시 사용 가능한 격리 수단이다.
|
||||
- **invalid value**: hub §5.4 failure column 은 `TELEMETRY_ENABLED` invalid 를 **boot fail** 로 규정하고 §6.4 는 "boolean parsing without truthy string ambiguity" 를 요구한다. 따라서 composition root 는 **검증된 boolean** 만 받으며 `"false"` 같은 문자열을 스스로 해석하지 않는다(파싱·거부는 `FE-OC-004`). endpoint 부재/invalid 의 **telemetry degrade**(§5.4)와 달리 flag invalid 는 degrade 가 아니라 boot fail 이라는 비대칭에 유의.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4 캡처. sibling 브랜치 링크는 소유 계약 `FE-OC-###` 로만 참조(consistency hook 안전).
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- sink non-2xx/network 실패 → `TELEMETRY_FAILURE`(hub §8.2), product error 없음, console-safe/drop, 재귀 금지.
|
||||
- queue overflow → 정책(기본 oldest-drop) 적용 + `telemetry.delivery.dropped` self-metric, unbounded 적재 금지 (§16.4).
|
||||
- telemetry adapter init 실패 / prod endpoint 부재·invalid → telemetry 만 degrade, app 계속 (§11.2).
|
||||
- redaction miss(forbidden attribute 유출) → forbidden-attribute scan test 가 build 를 실패시켜야 함 (§검증).
|
||||
- page hide → `sendBeacon` best-effort, delivery guarantee 로 표현 금지 (§11.2).
|
||||
- backend 가 `traceparent` 미지원 → local operation ID 로 degrade (§11.3).
|
||||
- `TELEMETRY_ENABLED=false` (hub §5.4 기본값) → real adapter 미구성, no-op port 주입, network·queue·counter 전부 부재. app 은 정상 동작하며 `FE-RB-004` mitigation lever 로 사용 (구현 가이드 7).
|
||||
- `TELEMETRY_ENABLED` invalid → **boot fail** (§5.4, degrade 아님). 파싱·거부는 `FE-OC-004` 소유이며 telemetry adapter 는 검증된 boolean 만 수신.
|
||||
- flag `true`→`false` 전환 → 이전 session queue 는 flush 되지 않고 폐기 (§11.2 best-effort, delivery guarantee 없음). in-session flip 은 §6.3 boot-time config 로딩상 존재하지 않으며 다음 boot 부터 반영.
|
||||
- navigation/user abort(`REQUEST_ABORTED`, §8.2) → error telemetry event 미발생(interaction-only).
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] (`FE-OC-004`) — `TELEMETRY_ENABLED`(boolean, required, default `false`) 와 `TELEMETRY_ENDPOINT`(conditional) 를 runtime config 로 consume. 그 registry 는 key 선언·분류·schema 검증만 소유하고 **전송·redaction·kill-switch 소비 메커니즘은 본 브랜치 소유**(D8, 구현 가이드 7). config 계약이 바뀌면 flag 해석·endpoint 해석·boot degrade 경로 영향. (hub §20 Dependency 가 본 브랜치의 유일 명시 dependency 로 이 브랜치를 지목.)
|
||||
- [[raw/branch-notes/feature-routing-navigation-guard-contract]] (`FE-OC-005`) — `route_id` 의 **producer**. `FE-REG-ROUTE` 가 low-cardinality route ID 를 발급하며, telemetry 는 `ui.render.failed`·`api.request.failed` 의 required attribute 로 그 값을 그대로 소비한다(직접 생성·정규화 금지). route ID 어휘가 바뀌면 event attribute cardinality 가 영향받음.
|
||||
- [[raw/branch-notes/feature-api-client-response-envelope-contract]] (`FE-OC-006`) — `operation_id`(+`route_id`·`attempt`)의 **producer**. hub §7.2 request context 가 logical request 마다 `operationId`/`routeId`/`attempt` 를 보유하므로, telemetry emit point 는 이 request context 에서 값을 읽고 `attempt` → `attempt_count_bucket` 만 파생한다. request context 필드가 바뀌면 emit point 수집 경로 영향.
|
||||
- [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] (`FE-OC-008`) — `error_kind` 를 consume 해 `api.request.failed` 등 event 의 required attribute 채움. taxonomy 변경 시 매핑 갱신.
|
||||
- [[raw/branch-notes/feature-frontend-render-recovery-boundary-contract]] (`FE-OC-015`) — React boundary-catch 신호를 consume 해 `ui.render.failed` emit. boundary 소유 계약 변경 시 emit point 영향.
|
||||
- [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] (`FE-OC-016`) — `build_id`·`active_release_id`·release token 을 consume 해 `app.boot.failed`·`release.mismatch.detected` attribute 채움.
|
||||
- [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] (`FE-OC-019`) — telemetry endpoint 의 CSP `connect-src`·bundle 내 secret 금지를 강제(그 브랜치가 본 브랜치를 contributor 로 지목).
|
||||
- [[raw/branch-notes/feature-frontend-operational-runbook-contract]] (`FE-OC-025`) — `FE-RB-004` recovery assertion(product e2e unaffected·delivery self-check·queue drains·forbidden-attribute scan pass) 소비.
|
||||
- [[raw/branch-notes/feature-frontend-contract-registry-governance]] (`FE-OC-022`) — `FE-REG-TELEMETRY` 를 8-registry governance 의 single-owner/compatibility check 로 감사.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> hub 계약은 근거지만 내 프로젝트 코드의 동작을 자동 보장하지 않는다. repository 생성 후 검증. 모두 `needs-confirmation`.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| telemetry throw/sink 실패가 render·API critical path 를 깨지 않는다 (D1) | 코드 부재; non-blocking 은 wiring 방식에 의존 | throwing sink 주입 후 render + API success 유지 assert 하는 **sink failure test** (§20 Measurable completion) | `needs-confirmation` |
|
||||
| forbidden attribute 가 client 를 절대 떠나지 않는다 (D2) | redaction 이 transport boundary 에서 강제되는지 코드로 확인 필요 | emit payload 를 검사하는 **forbidden-attribute scan / redaction test**; 위반 시 build 실패 | `needs-confirmation` |
|
||||
| bounded queue 가 planned bound 내 drain 하고 정책대로 drop 한다 (D3) | queue 상한·drop 방향이 UNSUPPORTED_IMPL_DECISION | **memory/queue drain test** (`FE-RB-004` recovery assertion) | `needs-confirmation` |
|
||||
| 자유 문자열/미등록 event 가 reject 된다 (D4) | registry enforcement 미구현 | `FE-REG-TELEMETRY` **schema validation test** | `needs-confirmation` |
|
||||
| retry 간 같은 logical operation correlation 유지 + attempt 구분 (D5) | traceparent 전파는 backend contract 의존 | local operation ID + attempt 구분 **correlation test** (MSW 로 backend 유/무 traceparent 시나리오) | `needs-confirmation` |
|
||||
| prod endpoint 부재/invalid 시 telemetry 만 degrade 하고 app 계속 (D6) | boot 경로에서 degrade 격리 미검증 | invalid endpoint **boot/sink-failure matrix test** | `needs-confirmation` |
|
||||
| `TELEMETRY_ENABLED=false` 에서 network 요청·queue·counter 가 전혀 생성되지 않고 product e2e 가 flag `true` 와 동일하다 (D8) | no-op 주입이 composition root 한 곳에만 있는지, call site 로 새지 않는지 코드로 확인 필요 | flag off/on **both-state test** — off 상태에서 telemetry 관련 network 호출 0건 assert + `FE-RB-004` recovery assertion("product e2e unaffected") 양쪽 상태 재실행 | `needs-confirmation` |
|
||||
| `telemetry.delivery.dropped` self-metric 이 실패한 queue/sink 로 재진입하지 않는다 (D3 + 구현 가이드 4) | counter 채널이 queue 와 독립 lifetime 인지 미검증 | overflow 유발 후 **비재귀 test** — queue 재적재 0건 assert + dropped counter 가 `FE-RB-004` diagnosis evidence 로 노출되는지 확인 | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 없음 — scaffolding/spec 단계 (구현 전).
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
| `FE-OC-008@1` | [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] | 모든 failure는 stable frontend error kind로 MUST 정규화하고 raw body·stack을 UI에 노출하면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-015@1` | [[raw/branch-notes/feature-frontend-render-recovery-boundary-contract]] | expected operational error와 render defect를 MUST 분리하고 reload loop를 금지 | import 참조로 적용 |
|
||||
| `FE-OC-021@1` | [[raw/branch-notes/feature-web-vitals-performance-budget-contract]] | NFR은 device/network/cache/build context와 함께 MUST 측정 | import 참조로 적용 |
|
||||
| `FE-OC-025@1` | [[raw/branch-notes/feature-frontend-operational-runbook-contract]] | boot, chunk mismatch, API degradation, telemetry failure, rollback runbook을 MUST 유지 | import 참조로 적용 |
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
### Sub-branches (세부 작업)
|
||||
|
||||
- 없음 — scaffolding 단계
|
||||
|
||||
### 오류 기록 (이 branch 작업 중 발생)
|
||||
|
||||
- 없음 — scaffolding 단계
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- 없음 — scaffolding 단계
|
||||
|
||||
### 강의 (이 작업을 위해 학습한 강의)
|
||||
|
||||
- 없음 — scaffolding 단계
|
||||
|
||||
### job-posting tie-ins (이 작업에서 파생된 글감)
|
||||
|
||||
- 없음 — scaffolding 단계
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- 없음 — scaffolding 단계
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크: TODO
|
||||
- 리뷰 메모: TODO
|
||||
- 머지 결과 / 배포 환경: TODO
|
||||
- **wiki 추출 대상**: 없음 — 전 항목 `planned` (repository 부재, 추출 조건 미충족)
|
||||
- **추출하지 않을 항목**: D1~D8 전부 — `planned` 등급이므로 verified 승급 및 wiki/projects 추출 전까지 제외
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-frontend-operational-contract/branch-notes/feature-frontend-operational-runbook-contract.md
|
||||
@@ -0,0 +1,268 @@
|
||||
---
|
||||
title: branch / feature-frontend-operational-runbook-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-026
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-frontend-operational-contract
|
||||
work_item: WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-026
|
||||
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-DEPLOYMENT-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-TELEMETRY-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-024, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-005, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-012, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-004]
|
||||
contract_packet: 1
|
||||
branch: feature-frontend-operational-runbook-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton-frontend, ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-frontend-operational-contract]
|
||||
tags: [branch, ca-skeleton, frontend, runtime, observability, externalized-config]
|
||||
created: 2026-07-18
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: f1bc0e83f35bfe8d37d36486dffefddcbdd308cb9b5820c5945b54a3ba163e8e
|
||||
imports: [FE-GATE-014@1, FE-GATE-015@1, FE-OC-001@1, FE-OC-004@1, FE-OC-006@1, FE-OC-009@1, FE-OC-014@1, FE-OC-016@1, FE-OC-017@1, FE-OC-023@1, FE-OC-026@1]
|
||||
---
|
||||
|
||||
# branch: feature-frontend-operational-runbook-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — TODO·결정·진행 기록. 구현 결과는 검증 뒤 `/ingest`로만 추출한다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/project-notes/ca-skeleton-frontend-operational-contract]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: 5개 drill의 trigger·window·escalation·evidence assertion이 검증된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-DEPLOYMENT-001@1` | static release는 immutable release directory와 atomic active pointer로 배포한다 | release mismatch와 rollback runbook의 trigger·recovery assertion에 적용한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-TELEMETRY-001@1` | telemetry는 best-effort queue와 redaction을 사용하며 sink failure가 UI를 실패시키지 않는다 | telemetry sink failure runbook의 containment와 evidence에 적용한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | 다섯 operational runbook을 4-assertion 계약으로 관리한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D2 | window와 rate를 planned conditional-default로 라벨한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D3 | deterministic drill과 record evidence로 runbook을 검증한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D4 | escalation을 technical owner에서 platform·approver로 이어지는 고정 chain으로 둔다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D5 | recovery는 action 수행이 아니라 assertion evidence로 판정한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
없음.
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
이 브랜치는 project-wide 계약 `FE-OC-025`("boot, chunk mismatch, API degradation, telemetry failure, rollback runbook을 MUST 유지, evidence = drill records")를 *구현 착수 가능한 runbook 계약*으로 내린다. hub [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §16이 정의한 다섯 runbook(`FE-RB-001`~`FE-RB-005`)을 각각 **trigger 집합 / immediate containment + window / escalation chain / recovery-evidence assertion**의 4-계약으로 고정하고, 이를 `FE-GATE-021`~`FE-GATE-025`(production-promotion drill gate)로 매핑한다. 동시에 boot config(`FE-OC-004`), API degradation(`FE-OC-006`), telemetry sink(`FE-OC-014`), release cache/rollback(`FE-OC-016`·`FE-OC-017`)의 acceptance drill을 *기여*한다. 이 브랜치는 runbook 계약과 drill 증거 스키마만 소유하며, 각 runbook이 소비하는 하부 메커니즘(config load, retry, telemetry queue, release pointer)은 owner 브랜치에 위임한다. 원천 상태가 전부 `planned`(코드 없음, hub §16이 유일 SSOT)이므로 모든 항목 등급은 `planned`.
|
||||
|
||||
- 이슈: 없음 (스캐폴딩 단계)
|
||||
- PR: 없음
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- `FE-OC-025` 소유 항목: 다섯 runbook 계약(`FE-RB-001` boot config / `FE-RB-002` chunk·release-manifest·deploy mismatch / `FE-RB-003` backend API degradation / `FE-RB-004` telemetry sink failure / `FE-RB-005` release rollback)의 trigger·containment+window·escalation·recovery-evidence assertion 고정.
|
||||
- drill harness 계약(`pnpm drill:runbook -- FE-RB-00X` → `artifacts/runbooks/FE-RB-00X/<release-id>/record.json`)과 `FE-GATE-021`~`FE-GATE-025` 매핑 + 각 drill의 negative fixture 요구.
|
||||
- window/rate 값을 `planned conditional-default`(measured SLO 아님)로 라벨하고 재검토 트리거를 명세.
|
||||
- escalation 2-hop chain(technical owner 브랜치 → platform/approver)의 routing 계약.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외. 다른 owner 브랜치 소유이거나 hosting 확정 이후 항목.
|
||||
|
||||
- boot config load + runtime config schema/validation 메커니즘 → [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] 소유 (`FE-OC-004`).
|
||||
- retry/timeout/idempotency·degradation triage 메커니즘 → [[raw/branch-notes/feature-api-client-response-envelope-contract]] 소유 (`FE-OC-006` · `FE-OC-009`).
|
||||
- telemetry queue/redaction/sink adapter 메커니즘 → [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] 소유 (`FE-OC-014`).
|
||||
- release tuple/cache header/atomic pointer/rollback 메커니즘 → [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] 소유 (`FE-OC-016` · `FE-OC-017`).
|
||||
- drill gate를 CI 파이프라인 blocking stage로 wiring → [[raw/branch-notes/feature-frontend-ci-quality-gates-contract]] 소유 (orchestration 담당이며 `FE-OC-*` owner 는 아니다).
|
||||
- provider-specific console command과 실제 incident response 수행 → hosting 확정(`FE-Q-003`) 이후 release 브랜치가 채움.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | primary SSOT. §16 다섯 runbook 정의, §14.3 drill 명령/artifact, §15 gate matrix(`FE-GATE-021`~`025`)+negative fixture, §12.5 rollback invariant, §8.2 failure taxonomy — D1~D5 전부의 project-decision 근거. |
|
||||
| [[raw/official-docs/vite-build-tool-official]] | `VITE-C2`(production build가 content-hash 정적 자산을 산출) — chunk/deploy mismatch(`FE-RB-002`)가 *실재 operational failure class*라는 근거(D1). |
|
||||
| [[raw/official-docs/prometheus-alertmanager-silences]] | operational recovery를 "action 수행"이 아니라 시간제한 window + suppression/evidence 규율로 판정하는 cross-domain 공식 precedent — drill window + recovery-evidence 계약(D3)의 참고 근거. frontend 계약 값 자체는 아님. |
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] 다섯 runbook의 4-assertion 계약(trigger / containment+window / escalation / recovery-evidence)을 표로 고정 — 등급: `planned`
|
||||
- [ ] drill harness 계약(`pnpm drill:runbook -- FE-RB-00X` → `record.json`) + record 스키마 초안 정의 — 등급: `planned`
|
||||
- [ ] `FE-GATE-021`~`FE-GATE-025` 매핑 + 각 runbook의 negative fixture(고의 실패 drill) 정의 — 등급: `planned`
|
||||
- [ ] window/rate 값 `planned conditional-default` 라벨 + 재검토 트리거(첫 drill + baseline) 명세 — 등급: `planned`
|
||||
- [ ] escalation 2-hop chain을 owner 브랜치 위임 링크로 고정 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- Ground truth: frontend 코드/repo 없음. 다섯 runbook의 trigger·window·assertion은 전부 hub §16의 `planned conditional-default`이며 measured SLO가 아니다. 이 브랜치는 hub §16을 재진술이 아니라 *drill-backed 계약 + gate 매핑*으로 내린다.
|
||||
- window 값(5분 triage, rolling 5분 rate window, 10/15분 등)은 첫 drill 결과 + hosting/backend baseline이 생길 때까지 owner가 유지·변경. 외부 답변에서 이 값을 달성 SLO처럼 말하면 §22 answer-boundary 위반.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 각 결정의 근거는 Sources 또는 hub §-ref. 대안과 함께 기록.
|
||||
|
||||
- 2026-07-19: hub §16이 정의한 다섯 runbook을 `FE-OC-025` 소유 집합으로 채택하고 각각 4-assertion(trigger/containment+window/escalation/recovery-evidence)으로 고정 / 이유: `FE-OC-025`의 minimum evidence가 drill records이므로 runbook을 검증 가능한 계약으로 내려야 함 / 검토한 대안: HTTP status별 개별 runbook 세분화 / 근거: hub §16 · §8.2 · `VITE-C2`.
|
||||
- 2026-07-19: 모든 window/rate 값을 `planned conditional-default`(measured SLO 아님)로 라벨 / 이유: implementation/telemetry evidence 없음(hub §16 서두 명시) / 검토한 대안: 초기 값을 target SLO로 선언 / 근거: hub §16 서두 · `FE-OC-001`·`FE-OC-026`.
|
||||
- 2026-07-19: runbook 검증은 결정론적 drill harness(`pnpm drill:runbook`) + evidence record + `FE-GATE-021`~`025` + runbook별 negative fixture로 수행 / 이유: rule 존재만으론 `locally-verified` 부족(§15.2) / 검토한 대안: 수동 체크리스트 review / 근거: hub §14.3 · §15.
|
||||
- 2026-07-19: escalation은 runbook별 고정 2-hop chain이며, 하부 메커니즘은 owner 브랜치에 위임(R3) / 이유: runbook 브랜치는 routing+evidence 계약만 소유 / 검토한 대안: 메커니즘까지 runbook에 재명세 / 근거: hub §16 escalation rows · §20 dependency · §4.3.
|
||||
- 2026-07-19: recovery는 assertion evidence(reachability probe/e2e/self-check)로만 판정하며 "mitigation action 수행"으로 판정하지 않음 / 이유: cache purge 완료≠recovery(hub §12.5) / 검토한 대안: provider action 완료를 recovery로 간주 / 근거: hub §12.5 · §16 recovery assertions.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | 다섯 runbook(`FE-RB-001`~`005`)을 `FE-OC-025` 소유 집합으로 채택, 각각 trigger/containment+window/escalation/recovery-evidence 4-assertion으로 고정 | §8.2 failure taxonomy의 *operational(비-request) failure class*가 이 다섯에 매핑되는 한 이 집합 유지 / §8.2에 어느 runbook에도 안 담기는 owner-blocking operational class가 새로 생기면 runbook 추가·분할. HTTP status별 개별 runbook은 만들지 않음(request-level은 §8.2 failure matrix가 처리) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-OC-025 · FE-D019 · FE-D020 · FE-D023 · §16 · §8; [[raw/official-docs/vite-build-tool-official]] VITE-C2 | `project-decision` + `official-doc` (VITE-C2) | §8.2에 다섯이 못 덮는 operational class가 나타날 수 있음 — 집합 완전성은 현재 taxonomy 기준으로만 주장됨 |
|
||||
| D2 | 모든 window/rate 값을 `planned conditional-default`로 라벨(measured SLO 아님), 첫 drill 결과 + hosting/backend baseline 전까지 유지 | baseline·첫 drill 이전엔 documented window(default) 유지 / (a) 해당 runbook 첫 drill의 timing evidence 와 (b) hosting/backend baseline SLO 가 둘 다 생기면 owner가 measured target으로 교체. 그 전까지 이 값을 달성 SLO로 인용하면 answer-boundary 위반 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §16(서두: window=planned conditional default, measured SLO 아님) · FE-OC-001 · FE-OC-026 | `conditional-default` | window가 첫 drill에서 달성 불가로 판명될 수 있고, downstream 문서가 이를 SLO로 오인 인용할 위험 |
|
||||
| D3 | 검증은 결정론적 drill harness(`pnpm drill:runbook -- FE-RB-00X`) + `record.json` evidence + `FE-GATE-021`~`025` + runbook별 negative fixture | drill record + negative fixture(깨진 경로에서 실제 실패 증명)가 둘 다 있을 때만 runbook을 operational로 주장 / repo/harness 없으면 runbook은 `documented-only`(drill=`PLANNED_NOT_EXECUTED`, §14.3) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §14 · §15(FE-GATE-021~025 · negative fixture) · FE-OC-025; [[raw/official-docs/prometheus-alertmanager-silences]] (recovery-evidence 규율 precedent) | `project-decision` + `official-doc` precedent | `record.json` 필드 스키마를 hub가 정의하지 않음(§구현 가이드 2의 UNSUPPORTED_IMPL_DECISION) |
|
||||
| D4 | escalation은 runbook별 고정 2-hop chain(technical owner 브랜치 → platform/approver), 하부 메커니즘은 owner 브랜치 위임(R3) | 이 브랜치는 escalation routing + evidence assertion만 명세 / 메커니즘 detail(retry cap·config schema·cache header·atomic pointer)은 owner 브랜치 FE-OC 계약으로 위임하고 여기서 재명세 금지. 기존 owner 브랜치가 제공 못하는 escalation hop이 필요할 때만 재검토 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §16(escalation rows) · §20(dependency) · §4 · FE-OC-025 | `project-decision` | rollback 결정 주체(config owner↔release owner) hand-off가 모호하면 runbook이 단절될 수 있음(FE-RISK-002) — §16이 hand-off를 고정하나 drill 전까지 미검증 |
|
||||
| D5 | recovery는 assertion evidence(clean boot·asset 2xx·reachability probe·critical e2e·telemetry self-check·forbidden-attribute scan)로만 판정, "action 수행"으로 판정 금지; provider console command은 hosting 확정까지 유보 | 항상 evidence 기반 / cache purge 필요한 provider는 purge 완료가 아니라 실제 old/new reachability probe 결과로 recovery 판정(§12.5). provider console command은 hosting 확정(FE-Q-003) 후 release 브랜치가 채움 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §12 · §16(recovery assertions · "provider console command 발명 안 함") · FE-OC-017 | `project-decision` | `FE-RB-005`의 provider-dependent recovery target은 hosting 확정 전 `TBD`(FE-Q-003) |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> `planned` blueprint. 코드 없음 — 경로/명령은 hub §14.3 blueprint(`pnpm drill:runbook`, `artifacts/runbooks/...`)에서 유래하므로 근거가 있으나 전체 섹션은 `planned`.
|
||||
|
||||
### 1. 다섯 runbook의 4-assertion 계약
|
||||
|
||||
> **Trace**: D1 · D2 · D5 · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-OC-025 · §16 · §8
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음. 아래 trigger·window·assertion·escalation 값은 전부 hub §16에서 그대로 내려받았고, 임의 발명 값이 없다. window는 §16이 명시한 `planned conditional-default`이므로 measured SLO로 표기하지 않는다(D2).
|
||||
|
||||
| Runbook | Trigger(정규화 kind) | Immediate containment + window(planned-default) | Escalation 1-hop | Recovery-evidence assertion | Drill gate |
|
||||
|---|---|---|---|---|---|
|
||||
| `FE-RB-001` boot config | `BOOT_CONFIG_FAILURE`(config non-2xx/parse/schema, refetch 1회도 실패) | product route mount 중단 + safe support shell, auto refetch ≤1회; owner triage 목표 5분 | env-config owner → release owner | clean session boot 성공 · product root mount · config validation artifact pass · 반복 boot error telemetry 없음 | `FE-GATE-021` |
|
||||
| `FE-RB-002` chunk/manifest/deploy mismatch | `CHUNK_LOAD_FAILURE` · `RELEASE_MANIFEST_FAILURE` · `DEPLOY_MISMATCH`(asset 404/integrity, manifest active≠loaded) | dirty-state 경고 후 manifest `no-store` 1회 조회; mismatch면 reload guard 기록 후 reload 1회만; release owner triage 5분 | release-cache owner → hosting/CDN owner | entry+lazy asset 2xx · manifest fetch·parse·schema+tuple coherence pass · 2차 auto reload 없음 · release coherence gate pass · route e2e pass | `FE-GATE-022` |
|
||||
| `FE-RB-003` API degradation | terminal network/timeout/5xx rate > threshold(rolling 5분) 또는 `SCHEMA_MISMATCH` 1건 | retry cap runtime 확대 금지 · safe cache는 stale-degraded 제공 · mutation은 idempotency 없이 retry 금지 · schema mismatch는 retry 금지; 최초 분류 10분 | api-client owner → backend operation owner → release compatibility owner | terminal failure rate가 baseline window로 복귀 · retry amplification 없음 · critical read/write e2e pass · schema fixtures pass | `FE-GATE-023` |
|
||||
| `FE-RB-004` telemetry sink | `TELEMETRY_FAILURE`(sink non-2xx/network, queue overflow, adapter init 실패) | product flow 유지 · bounded queue 초과 적재 금지 · 동일 sink 재귀 보고 금지 · console fallback은 safe field 한정; platform triage 15분 | observability owner → telemetry platform owner | product e2e 영향 없음 · delivery self-check 성공 · queue가 planned bound 내 drain · forbidden-attribute scan pass | `FE-GATE-024` |
|
||||
| `FE-RB-005` release rollback | release-blocking boot/chunk/render/API/security defect이고 forward fix가 incident window 내 안전 미증명 | prior immutable release로 target tuple 선택 → asset·config·API compat 확인 → active pointer atomic switch → smoke; provider recovery target은 hosting 전 `TBD` | release-cache owner → release approver/hosting owner | `FE-GATE-014`·`FE-GATE-015` pass · critical e2e pass · 반복 `DEPLOY_MISMATCH` 없음 · incident timeline에 release ID 기록 | `FE-GATE-025` |
|
||||
|
||||
### 2. Drill harness + evidence record
|
||||
|
||||
> **Trace**: D3 · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §14 · §15 · FE-OC-025
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `record.json` 필드 스키마 — hub §14.3은 artifact *경로*(`artifacts/runbooks/FE-RB-00X/<release-id>/record.json`)만 고정하고 JSON 필드는 정의하지 않음. 아래 필드 집합은 이 브랜치의 임의 제안(trade-off: assertion 결과를 기계 판정 가능하게 최소 필드만 고정 — 확장은 owner drill 구현 시). 실제 필드명은 harness 구현 시 확정.
|
||||
|
||||
- 명령: `pnpm drill:runbook -- FE-RB-00X` (hub §14.3, 상태 `PLANNED_NOT_EXECUTED`).
|
||||
- 산출물: `artifacts/runbooks/FE-RB-00X/<release-id>/record.json` (hub §14.3).
|
||||
- 제안 record 필드(planned, UNSUPPORTED_IMPL): `runbookId`, `releaseId`, `drillTimestamp`, `triggerInjected`(주입한 정규화 kind), `containmentAsserted`(bool), `escalationPathAsserted`(2-hop 도달 여부), `recoveryAssertions`(assertion→pass/fail 목록), `negativeFixtureFailedAsExpected`(bool), `windowObservedBucket`(planned-default 비교용 bucket, SLO 아님).
|
||||
- Negative fixture(runbook별 고의 실패 drill, §15.2 규율):
|
||||
|
||||
| Runbook | Negative fixture(반드시 실패해야 함) | 근거 |
|
||||
|---|---|---|
|
||||
| `FE-RB-001` | 유효 config인데 boot을 mount 실패로 처리 → recovery assertion이 fail 나야 정상 | §15.2 runtime schema/reload 계열 |
|
||||
| `FE-RB-002` | 동일 release pair에서 2차 chunk 실패 → reload guard가 반복 reload를 막아야(§15.2 reload guard) | §15.2 reload guard |
|
||||
| `FE-RB-003` | idempotency key 없는 POST가 503 수신 → 자동 retry 하면 fail | §15.2 retry |
|
||||
| `FE-RB-004` | telemetry event에 raw URL/query 포함 → forbidden-attribute scan이 fail 나야 | §15.2 telemetry |
|
||||
| `FE-RB-005` | HTML build A + asset manifest B(mixed) → release coherence가 mismatch 검출해야 | §15.2 release |
|
||||
|
||||
### 3. Escalation & delegation map (R3 경계)
|
||||
|
||||
> **Trace**: D4 · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §16 · §20 · §4
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음. 각 hop의 owner 브랜치와 계약 ID는 hub §16 escalation row + §20 dependency에서 그대로 내려받음. 하부 메커니즘은 아래 owner 브랜치로 위임하며 여기서 재명세하지 않음.
|
||||
|
||||
| Runbook | Technical owner (mechanism 위임) | Platform / approver hop |
|
||||
|---|---|---|
|
||||
| `FE-RB-001` | [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] (`FE-OC-004`) | [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] (`FE-OC-016` · `FE-OC-017`) |
|
||||
| `FE-RB-002` | [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] (`FE-OC-016` · `FE-OC-017`) | hosting/CDN owner (외부, hosting 확정 후) |
|
||||
| `FE-RB-003` | [[raw/branch-notes/feature-api-client-response-envelope-contract]] (`FE-OC-006` · `FE-OC-009`) | backend operation owner → release compatibility (외부/`FE-OC-023`) |
|
||||
| `FE-RB-004` | [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] (`FE-OC-014`) | telemetry platform owner (외부) |
|
||||
| `FE-RB-005` | [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] (`FE-OC-016` · `FE-OC-017`) | release approver / hosting owner (외부) |
|
||||
|
||||
### 4. Window/rate governance
|
||||
|
||||
> **Trace**: D2 · [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §16 · FE-OC-001 · FE-OC-026
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음. window 값은 §16이 명시한 conditional-default를 그대로 인용. 새 값을 발명하지 않음.
|
||||
|
||||
- 라벨 규칙: 모든 window/rate(`FE-RB-001` 5분, `FE-RB-002` 5분, `FE-RB-003` rolling 5분 rate + 10분 분류, `FE-RB-004` 15분, `FE-RB-005` provider-dependent `TBD`)는 `planned conditional-default`로만 표기.
|
||||
- 재검토 트리거: 해당 runbook 첫 drill의 `windowObservedBucket` + hosting/backend baseline SLO 존재 → owner가 measured target으로 승격.
|
||||
- 금지: 이 값을 measured SLO/달성 지표로 외부 답변에 사용(§22 answer boundary). 위반 시 `/lint` answer-boundary 검사 대상.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로** (runbook 계약 자체의 meta-failure):
|
||||
- drill이 negative fixture 없이 "pass" → 거짓 보증. 기대 동작: 각 gate는 고의 실패 drill을 포함해야 통과 인정(§15.2).
|
||||
- window 값을 measured SLO로 외부 인용 → answer-boundary 위반. 기대 동작: `planned conditional-default` 라벨 강제(D2).
|
||||
- recovery를 "action 수행"(purge 발행/pointer switch)으로 판정 → 거짓 recovery. 기대 동작: reachability probe/e2e evidence로만 판정(§12.5, D5).
|
||||
- rollback 결정 hand-off 모호(config owner ↔ release owner) → runbook 단절(FE-RISK-002). 기대 동작: config owner가 원인 분류 실패 시 release owner에게 rollback 결정 이관(§16.1).
|
||||
- `FE-RB-004` drill 중 telemetry 실패를 동일 sink로 재귀 보고 → amplification. 기대 동작: 재귀 금지 + console-safe fallback(§11.2).
|
||||
- `FE-RB-002` reload가 user input 손실(FE-RISK-009). 기대 동작: dirty-state guard + one-reload cap.
|
||||
- **다른 계약 의존** (owner 브랜치 위임, `FE-OC` 계약 consume):
|
||||
- [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] (`FE-OC-016` · `FE-OC-017`) — release tuple/cache header/atomic pointer/rollback; `FE-RB-002`·`FE-RB-005`가 consume. 이 계약 변경 시 chunk/rollback runbook assertion 재검토.
|
||||
- [[raw/branch-notes/feature-api-client-response-envelope-contract]] (`FE-OC-006` · `FE-OC-009`) — degradation triage/retry cap; `FE-RB-003`이 consume.
|
||||
- [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] (`FE-OC-014`) — telemetry queue/redaction/sink; `FE-RB-004`가 consume.
|
||||
- [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] (`FE-OC-004`) — boot config validation; `FE-RB-001`이 consume.
|
||||
- [[raw/branch-notes/feature-frontend-ci-quality-gates-contract]] — `FE-GATE-021`~`025`를 파이프라인 blocking stage로 wiring; 이 브랜치의 drill 계약에 의존. (`FE-OC-020` owner 는 [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] 이고 ci-quality-gates 는 `FE-OC-*` owner 가 아니다.)
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 다섯 runbook 각각이 trigger→containment→escalation→recovery를 drill evidence로 닫는다 | repo/harness 없음 | `pnpm drill:runbook -- FE-RB-00X` → `record.json` 생성 + `FE-GATE-021`~`025` pass(negative fixture 동반) | `needs-confirmation` |
|
||||
| window/rate default가 달성 가능하고 SLO 아님으로 정직히 라벨된다 | baseline/첫 drill 없음 | 첫 drill `windowObservedBucket` vs hosting/backend baseline 비교 + answer-boundary scan | `needs-confirmation` |
|
||||
| recovery가 action이 아니라 evidence로 판정된다 | 설계 assertion | drill이 reachability/e2e/self-check를 assert하고 "action 발행"을 assert하지 않음 확인 | `planned` |
|
||||
| escalation hand-off(config→release rollback 결정)가 단절되지 않는다 | hand-off 미검증 | `FE-RB-001`→`FE-RB-005` chained drill이 hand-off 경로를 exercise | `needs-confirmation` |
|
||||
| chunk-mismatch runbook이 reload 시 user input을 잃지 않는다 | reload semantics | `FE-RB-002` e2e에 dirty-state + one-reload guard fixture | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
- 스캐폴딩 단계: `/coverage` 실행 전 수동 행을 만들지 않는다.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 없음 — 스캐폴딩 단계.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
| `FE-GATE-014@1` | [[raw/branch-notes/feature-frontend-contract-compatibility-governance]] | 지원 대상 config 버전이 boot 에 실패하면 release 를 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-GATE-015@1` | [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] | 혼재된 release 조합이 감지되지 않으면 release 를 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-OC-001@1` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | 모든 구현 주장은 evidence grade를 MUST 표시하고 repo evidence가 없는 상태에서 구현 완료를 MUST NOT 주장 | import 참조로 적용 |
|
||||
| `FE-OC-004@1` | [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] | build-time, runtime-public, secret config를 MUST 분리하고 boot 전에 runtime config를 검증 | import 참조로 적용 |
|
||||
| `FE-OC-006@1` | [[raw/branch-notes/feature-api-client-response-envelope-contract]] | 모든 HTTP는 shared client를 MUST 통과하고 timeout·abort·response parsing을 page에서 구현하면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-009@1` | [[raw/branch-notes/feature-api-client-response-envelope-contract]] | retry는 safe/idempotent request에 한정하고 cap·jitter·`Retry-After`를 MUST 적용 | import 참조로 적용 |
|
||||
| `FE-OC-014@1` | [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] | telemetry는 best-effort이며 render·API success를 차단하면 안 되고 PII·token을 전송하면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-016@1` | [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] | HTML, asset, runtime config, release manifest cache policy를 MUST 구분 | import 참조로 적용 |
|
||||
| `FE-OC-017@1` | [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] | rollback은 immutable prior release로 수행하고 build/config/API compatibility를 MUST 검증 | import 참조로 적용 |
|
||||
| `FE-OC-023@1` | [[raw/branch-notes/feature-frontend-contract-compatibility-governance]] | API/config/storage/release schema의 breaking change는 migration 또는 version bump 없이 배포하면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-026@1` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | 외부 답변은 evidence grade를 MUST 보존하고 목표 수치를 측정 결과처럼 말하면 안 됨 | import 참조로 적용 |
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
- 없음 — 자식 자료는 생성 후 controller가 parent Cluster와 함께 등록한다.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- 없음 — daily note는 이 작업에서 수정하지 않는다.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크: 없음
|
||||
- 리뷰 메모: 없음
|
||||
- 머지 결과 / 배포 환경: `planned`
|
||||
- **wiki 추출 대상** (verified만): 없음
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned): 현재 전체
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-frontend-operational-contract/branch-notes/feature-frontend-project-bootstrap-toolchain-contract.md
|
||||
@@ -0,0 +1,307 @@
|
||||
---
|
||||
title: branch / feature-frontend-project-bootstrap-toolchain-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-001
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-frontend-operational-contract
|
||||
work_item: WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-001
|
||||
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-TOOLCHAIN-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-LANGUAGE-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-BUILD-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
branch: feature-frontend-project-bootstrap-toolchain-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton-frontend, ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-frontend-operational-contract.md]
|
||||
tags: [branch, ca-skeleton, frontend, architecture, testing, javascript, build-tooling]
|
||||
created: 2026-07-18
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 96291fb32a210358e477a7242241d20382c2d978ad8d6c137fcb4735b9dff6d8
|
||||
imports: [FE-GATE-011@1, FE-OC-007@1, FE-OC-016@1, FE-OC-018@1, FE-OC-020@1, FE-OC-021@1]
|
||||
accepts_delegations: [DELEG-FE-002@1]
|
||||
|
||||
---
|
||||
|
||||
# branch: feature-frontend-project-bootstrap-toolchain-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 TODO·결정·진행 기록. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출한다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-frontend-operational-contract]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: manifest·engines·pnpm lock·checkJs scripts와 frozen install evidence가 존재한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-TOOLCHAIN-001@1` | package manager default는 pnpm이며 packageManager field와 pnpm-lock.yaml을 commit한다 | manifest·lockfile·frozen install 계약에 적용한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-LANGUAGE-001@1` | JavaScript ESM과 tsc allowJs/checkJs/noEmit을 typecheck-equivalent baseline으로 사용한다 | source language와 check:types script에 적용한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-BUILD-001@1` | Vite client-only SPA를 build baseline으로 사용한다 | Vite build scaffold와 build artifact gate에 적용한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | pnpm과 committed lockfile을 toolchain baseline으로 사용한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D2 | JavaScript ESM과 checkJs를 source/typecheck baseline으로 사용한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D3 | Vite client-only SPA를 build baseline으로 사용한다 | `local` | `raw/official-docs/vite-build-tool-official.md#VITE-C2` | `proposed` |
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
없음.
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
`ca-skeleton-frontend`의 project-wide bootstrap 계약 `FE-OC-003`("package manager, engine, lockfile, source language, checkJs command를 한 곳에서 MUST 고정")을 되묻지 않아도 코드를 작성할 수 있는 implementation-ready 명세로 내린다. 이 branch는 §20 Branch Decomposition에서 **Dependency `—`** 인 branch DAG의 root이며, 다른 27개 branch가 의존하는 toolchain 그릇(manifest·lockfile·source 언어·typecheck·build baseline)을 확정한다. 근거 결정은 [[raw/project-notes/ca-skeleton-frontend-operational-contract]]의 `FE-D001`(pnpm)·`FE-D002`(JavaScript ESM + `tsc --allowJs --checkJs --noEmit`)·`FE-D003`(Vite client-only SPA)이다. Measurable completion(§20)은 "manifest/engines/pnpm lock/checkJs scripts + frozen install evidence"이며, 이는 `FE-GATE-001`(manifest/lockfile)·`FE-GATE-003`(typecheck-equivalent)·`FE-GATE-011`(build) 로 판정된다. 또한 `FE-OC-018`(supply-chain: frozen lockfile)·`FE-OC-020`(test taxonomy: gate script 배선)에 **contributes-to** 로 참여한다. 현재 frontend repository는 존재하지 않으므로 본 노트의 모든 구현 주장은 `planned` 등급이다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- `package.json` 매니페스트 확정 — `type: module`(ESM), `packageManager: pnpm@<pin>`, `engines`(Node/pnpm), script 슬롯 — 등급: `planned` (`FE-OC-003`, `FE-D001`/`FE-D002`)
|
||||
- `pnpm-lock.yaml` commit + `pnpm install --frozen-lockfile` 재현성 계약 — 등급: `planned` (`FE-OC-003` → `FE-OC-018` 기여, `FE-D001`, `FE-GATE-001`)
|
||||
- source 언어 = JavaScript ESM 고정 + `tsconfig.json`(`allowJs`/`checkJs`/`noEmit`) + `check:types` script — 등급: `planned` (`FE-OC-003`, `FE-D002`, `FE-GATE-003`)
|
||||
- Vite client-only SPA build baseline + 최소 `vite.config.js` + `dev`/`build` script — 등급: `planned` (`FE-OC-003`, `FE-D003`, `FE-GATE-011`)
|
||||
- Node/pnpm engine pin + engine 강제 정책 — 등급: `planned` (`FE-OC-003`, FE-NFR-C04 build context)
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 다른 owner branch 소유 관심사이며 본 §구현 가이드에 detail을 남기지 않는다 (CLAUDE.md §15.5 R3).
|
||||
|
||||
- **build/runtime/secret env config 분리·runtime config 검증** — `FE-OC-004`, owner [[raw/branch-notes/feature-frontend-env-runtime-config-contract]]. 본 branch는 Vite가 `import.meta.env` 정적 치환 메커니즘을 제공한다는 사실만 확정하고 registry·검증은 위임.
|
||||
- **dependency lint rule / restricted-import 규칙 내용** — `FE-OC-002`, owner [[raw/branch-notes/feature-frontend-clean-architecture-layering-contract]] + [[raw/branch-notes/feature-frontend-architecture-enforcement-lint-contract]]. 본 branch는 `lint` script 슬롯만 예약, 규칙 정의 위임.
|
||||
- **test suite 내용·gate 오케스트레이션·artifact 보존** — `FE-OC-020`, owner [[raw/branch-notes/feature-frontend-test-taxonomy-contract]]. 본 branch는 `check:types`만 소유, level별 test·CI 배선 위임.
|
||||
- **bundle budget·secret scan·SBOM·dependency review** — `FE-OC-018`/`FE-OC-021`, owner [[raw/branch-notes/feature-frontend-build-bundle-supply-chain-contract]] + [[raw/branch-notes/feature-web-vitals-performance-budget-contract]]. 본 branch는 frozen lockfile evidence만 기여.
|
||||
- **8-registry 스키마·single-owner governance** — `FE-OC-022`, owner [[raw/branch-notes/feature-frontend-contract-registry-governance]]. 본 branch는 registry를 소유하지 않는다.
|
||||
- **runtime schema(Zod) 검증** — `FE-OC-007`, owner [[raw/branch-notes/feature-runtime-schema-validation-contract]]. checkJs는 JSDoc 타입 검사만 제공하고 boundary runtime 검증은 위임.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/vite-build-tool-official]] | D3 — Vite production build가 Rolldown으로 최적화된 정적 자산을 산출(`VITE-C2`)하고 dev server가 native ESM 위에서 동작(`VITE-C1`)하므로 client-only SPA를 build baseline으로 채택 |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | D1·D2·D3 — Decision Register(`FE-D001`/`FE-D002`/`FE-D003`)와 contract index(`FE-OC-003`), supply-chain 최소값(§13.1), planned command 계약(§14.3), gate matrix(§15.1)의 governing SSOT |
|
||||
| [[raw/project-notes/ca-skeleton-operational-contract]] | D1~D3의 상위 철학 precedent — backend skeleton의 운영 계약(port는 application 소유·sample은 제거 가능 fixture) 원칙을 frontend toolchain이 담을 그릇으로 확정 (사실 인용이 아닌 rationale precedent) |
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] `package.json` 작성 — `type: module`, `packageManager: pnpm@<pin>`, `engines`, script 슬롯 배치 — 등급: `planned`
|
||||
- [ ] `pnpm-lock.yaml` commit + clean checkout에서 `pnpm install --frozen-lockfile` exit 0 / drift 시 non-zero 재현 — 등급: `planned`
|
||||
- [ ] `tsconfig.json`(`allowJs`/`checkJs`/`noEmit`) + `check:types` script + checkJs negative fixture 배치 — 등급: `planned`
|
||||
- [ ] 최소 `vite.config.js` + `dev`/`build` script (Vite client-only SPA baseline) — 등급: `planned`
|
||||
- [ ] Node/pnpm 버전 pin(`.nvmrc` + engine 강제) + FE-NFR-C04 build context(Node/pnpm 버전) 기록 배선 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
`/branch-spec` 채움 완료 (2026-07-19). frontend repository 미생성 — 모든 항목 `planned`. 실제 코드 착수 전까지 evidence 등급 상향 금지.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 각 결정은 아래 Decision Evidence Map의 prose 미러이다. 근거는 Sources 또는 hub Decision Register를 가리킨다.
|
||||
|
||||
- 2026-07-18: package manager를 **pnpm**으로 고정하고 `pnpm-lock.yaml` + `packageManager` 필드를 commit / 이유: project-local 재현성 default(lockfile drift·PM 혼용 방지) / 검토한 대안: npm·yarn·Bun / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-D001`, §13.1 supply-chain 최소값(pnpm + committed lockfile).
|
||||
- 2026-07-18: source 언어를 **JavaScript ESM**으로 고정하고 typecheck는 `tsc --allowJs --checkJs --noEmit`로 대체 / 이유: 사용자 제약 + boundary runtime schema(Zod) 필요성 하에서 타입 안전성 확보 / 검토한 대안: TypeScript strict 소스 / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-D002`.
|
||||
- 2026-07-18: build baseline을 **Vite client-only SPA**로 채택 / 이유: production build가 최적화된 정적 자산을 산출해 정적 호스팅 배포에 적합 / 검토한 대안: SSR/메타 프레임워크(Next 등)·edge rendering / 근거: [[raw/official-docs/vite-build-tool-official]] `VITE-C2`, [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-D003`.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정과 raw source Claim ID의 연결. `Decision ID`(D1·D2·D3)는 본 노트 안에서 안정적으로 유지한다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | package manager = pnpm; `pnpm-lock.yaml` + `packageManager` 필드 commit (`FE-D001` / `FE-OC-003`, 기여 `FE-OC-018`·`FE-OC-020`) | target CI가 pnpm을 지원하고 조직이 특정 PM을 강제하지 않는 동안 → pnpm. 조직 표준이 npm/yarn/Bun을 강제하거나 target CI가 pnpm을 미지원 → 해당 PM으로 교체하되 lockfile·`packageManager` 필드·frozen install script를 동시 변경 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-D001`, §13.1(pnpm + committed lockfile) | `conditional-default` (project-decision) | pnpm 미지원 CI runner 채택 시 재현성 계약 재작성; lockfile drift가 gate로 실제 차단되는지 미검증 |
|
||||
| D2 | source = JavaScript ESM; typecheck-equivalent = `tsc --allowJs --checkJs --noEmit` (`FE-D002` / `FE-OC-003`·`FE-OC-007`·`FE-OC-020`) | 사용자 제약(JS 유지) + runtime schema 경계 검증이 있는 동안 → JS ESM + checkJs. TypeScript strict 전환이 승인되면 → `.ts` 소스 + strict `tsconfig`로 이행하고 checkJs 경로 폐기 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-D002`, §14.3(`pnpm check:types` → checkJs diagnostic 0), §15.1 `FE-GATE-003` | `project-decision` (accepted-documented-only) | checkJs가 strict TS 수준 타입 안전을 보장하지 않음 — JSDoc 커버리지 공백 존재 가능; 실제 diagnostic 0 여부 미검증 |
|
||||
| D3 | build baseline = Vite client-only SPA (`FE-D003` / `FE-OC-003`, 기여 `FE-OC-016`·`FE-OC-021`) | 제품 요구가 client-only SPA(정적 호스팅)로 충분한 동안 → Vite SPA. SSR/SEO/edge rendering이 제품 요구가 되면 → 별도 project fork로 Vite SSR 또는 메타 프레임워크 재평가(`FE-D003` revisit) | [[raw/official-docs/vite-build-tool-official]] `VITE-C2`(Rolldown production build → 최적화된 정적 자산), `VITE-C1`(dev server native ESM); [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-D003` | `official-doc` (official-vendor-doc) | `VITE-C2`는 정적 자산 산출만 증명하고 이 프로젝트 bundle/성능 threshold(`FE-OC-021`)는 별도 검증 필요; `pnpm build` exit 0 + manifest 산출 미검증 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> `planned` blueprint. 경로는 hub §4.5/§4.6 planned directory blueprint에서 도출되므로 grounded이지만, frontend 코드가 없으므로 전 구간 `planned`. 각 sub-section은 CLAUDE.md §15.5 3-rule(R1 Trace·R2 UNSUPPORTED_IMPL_DECISION·R3 no OUT_OF_BRANCH_SCOPE)을 따른다.
|
||||
|
||||
### 1. `package.json` 매니페스트 계약
|
||||
|
||||
> **Trace**: D1(`FE-D001`) + D2(`FE-D002`) + D3(`FE-D003`) → `FE-OC-003`. planned 경로 `package.json`(repo root) + engine 강제 파일(`.npmrc`/`.nvmrc`, 아래 UNSUPPORTED 참조), 소비자는 pnpm·Vite·tsc.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `packageManager` 의 정확한 pnpm 버전 pin(예: `pnpm@9.x`) — hub는 "pnpm"만 지정하고 버전을 못박지 않음. trade-off: 최신 pnpm major는 lockfile 포맷 변화 위험 → 착수 시점 pnpm LTS major로 pin하고 FE-NFR-C04에 기록.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `engines` 의 정확한 Node 범위(예: `>=20 <21`) — hub §14.1은 "Node/pnpm versions recorded"만 요구하고 특정 버전을 명시하지 않음. trade-off: Node LTS 경계 선택은 임의 → 착수 시점 active LTS major로 pin.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: engine **강제(enforcement) 메커니즘** — hub `FE-OC-003`은 "engine을 한 곳에서 MUST 고정"만 요구하고, hub §17 `FE-Q-002`의 검증 조건도 "manifest `engines` + fresh clone pass"까지만 명시할 뿐 *무엇이 버전 위반 install을 실제로 거부하는가* 는 지정하지 않는다. `package.json` 의 `engines` 필드 단독은 기본 설정에서 경고에 그칠 수 있어(강제 여부는 package manager 설정 의존) 별도 장치가 없으면 no-op이 될 수 있다. 후보: (a) `.npmrc` 의 `engine-strict=true` + Node 버전 단일 소스 `.nvmrc`, (b) Corepack(`packageManager` 필드로 pnpm 버전 자체를 고정), (c) `preinstall` guard script. trade-off: (a)+(b) 조합을 기본값으로 채택 — `engine-strict` 가 Node/pnpm 범위 위반 install을 non-zero로 떨어뜨리고 `packageManager` 필드가 pnpm 버전 축을 덮어 런타임/PM 두 축이 모두 강제되며, `.nvmrc` 는 로컬 버전 전환용 단일 소스로만 쓰고 gate 판정 근거로는 쓰지 않는다. (c)는 커스텀 스크립트 유지비 때문에 보류. 세 후보의 실제 거부 동작은 미검증이므로 착수 시 §Claims To Verify의 engine 강제 항목으로 확정한다.
|
||||
|
||||
| 필드 | planned 값 | 근거 | 소유 경계 |
|
||||
|---|---|---|---|
|
||||
| `type` | `"module"` (ESM) | D2 (`FE-D002` JavaScript ESM) | this branch |
|
||||
| `packageManager` | `"pnpm@<LTS-major>"` | D1 (`FE-D001`) | this branch (버전 pin은 UNSUPPORTED_IMPL) |
|
||||
| `engines.node` / `engines.pnpm` | `<active-LTS>` 범위 | `FE-OC-003`("engine을 한 곳에서 고정") | this branch (버전 UNSUPPORTED_IMPL) |
|
||||
| engine 강제 메커니즘 (`.npmrc` `engine-strict=true` + `.nvmrc`, `packageManager` 필드 병행) | 범위 위반 install을 non-zero로 거부 | `FE-OC-003`(engine 고정) + hub §17 `FE-Q-002` 검증 조건("manifest `engines` + fresh clone pass") | this branch (메커니즘 선택은 UNSUPPORTED_IMPL — 위 3번째 라벨) |
|
||||
| `scripts.dev` / `scripts.build` | `vite` / `vite build` | D3 (`FE-D003`), §14.3 `pnpm build` | this branch |
|
||||
| `scripts.check:types` | `tsc --allowJs --checkJs --noEmit` | D2 (`FE-D002`), §14.3 `pnpm check:types` | this branch |
|
||||
| `scripts.lint`·`test:*`·`check:bundle`·`scan:security` 등 | 이름 슬롯만 예약 | §14.3 script 계약 | **delegated** — 각 owner branch가 구현 정의(§5 아래 슬롯 표) |
|
||||
|
||||
### 2. Lockfile + frozen install 재현성
|
||||
|
||||
> **Trace**: D1(`FE-D001`) → `FE-OC-003` 소유 + `FE-OC-018` 기여. planned 경로 `pnpm-lock.yaml`(commit) + `artifacts/quality/install.txt`. gate `FE-GATE-001@1`(manifest/lockfile — blocking scope·pass condition 은 hub §15.1 소유), §14.3 `pnpm install --frozen-lockfile`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — frozen install 메커니즘(`--frozen-lockfile`)·evidence 경로(`artifacts/quality/install.txt`)·gate(`FE-GATE-001`)·supply-chain 최소값(§13.1 lockfile-check)이 모두 hub에 grounded.
|
||||
|
||||
- `pnpm-lock.yaml`을 repo에 commit; manifest range와 lockfile이 drift하면 `pnpm install --frozen-lockfile`이 non-zero exit → `FE-GATE-001` FAIL로 merge 차단.
|
||||
- evidence artifact: install 로그(`artifacts/quality/install.txt`, §14.3) + lockfile 검증(`artifacts/quality/lockfile-check.txt`, §13.1).
|
||||
- SBOM·secret scan·dependency review는 본 branch 산출물(lockfile)을 소비하지만 owner는 [[raw/branch-notes/feature-frontend-build-bundle-supply-chain-contract]] — 본 §에 detail 미기재(R3).
|
||||
|
||||
### 3. Source 언어 + typecheck-equivalent 툴체인
|
||||
|
||||
> **Trace**: D2(`FE-D002`) → `FE-OC-003`·`FE-OC-007`·`FE-OC-020`. planned 경로 `tsconfig.json`(repo root, checkJs 전용) + checkJs negative fixture. gate `FE-GATE-003@1`(typecheck-equivalent — blocking scope 는 hub §15.1 소유), §14.3 `pnpm check:types` → checkJs diagnostic 0.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `tsconfig.json`의 `allowJs`/`checkJs`/`noEmit` 외 부수 옵션(`target`/`moduleResolution`/`lib`) — `FE-D002`는 세 flag만 명시. trade-off: Vite ESM·최신 브라우저 전제 하에 임의 선택 → 착수 시 Vite 권장 preset에 맞춰 확정하고 fixture로 검증.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: checkJs negative fixture의 파일 경로·형태 — hub는 "JSDoc/checkJs negative fixture"(§15.1 `FE-GATE-003`)만 요구. trade-off: fixture 위치는 임의 → `tests/` 하위 typecheck fixture 컨벤션으로 확정.
|
||||
|
||||
- `tsconfig.json`은 emit 없이(`noEmit`) `.js`를 검사(`allowJs`+`checkJs`)한다. 별도 `.ts` 소스는 생성하지 않는다(D2).
|
||||
- `pnpm check:types`는 production 소스에서 diagnostic 0이어야 하고, negative fixture는 의도적으로 fail해야 `FE-GATE-003@1`이 PASS(pass condition 원문은 hub §15.1 소유).
|
||||
- boundary runtime 검증(Zod)은 `FE-OC-007` owner [[raw/branch-notes/feature-runtime-schema-validation-contract]] — checkJs는 compile-time JSDoc 타입만 담당(R3).
|
||||
|
||||
### 4. Vite build baseline 스캐폴딩
|
||||
|
||||
> **Trace**: D3(`FE-D003`) → `FE-OC-003` 소유 + `FE-OC-016`·`FE-OC-021` 기여. planned 경로 `vite.config.js`(repo root) + `src/bootstrap/main.jsx`(hub §4.5 composition root). gate `FE-GATE-011@1`(build — owner 는 [[raw/branch-notes/feature-frontend-build-bundle-supply-chain-contract]], blocking scope·pass condition 은 hub §15.1 소유), §14.3 `pnpm build` → `artifacts/release/build-manifest.json`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `vite.config.js`의 정확한 plugin 목록(예: React JSX plugin 패키지명) — hub는 plugin을 명시하지 않음. JSX 컴파일은 React 채택(`FE-D004`, owner [[raw/branch-notes/feature-async-ui-state-contract]]) 때문에 필요하나 plugin 패키지 선택은 미근거. trade-off: 착수 시 Vite 공식 React plugin 채택하고 build fixture로 검증.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: build output/asset hashing 세부 설정 — release cache 정책(`FE-OC-016` hashed asset immutable)은 owner [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] 소유. 본 §은 build가 hashed 정적 자산을 산출한다는 baseline만 확정하고 cache header 정책은 위임(R3).
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `artifacts/release/build-manifest.json` **산출(emission) 메커니즘** — hub §12.1은 이 파일을 expected artifact로 열거하고 §14.3은 `pnpm build` 의 assertion을 "exit 0 + manifest present"로 두지만, *어떤 경로로 그 파일이 계약 경로에 생기는가* 는 지정하지 않는다(§12.1: "실제 path는 repository가 생기면 owner branch에서 확정한다"). 근거 source 의 `VITE-C2` 는 "최적화된 정적 자산 산출"만 증명할 뿐 manifest 파일의 이름·위치·스키마를 증명하지 않으므로, 번들러 기본 manifest 경로/형식은 본 노트에서 확정된 사실이 아니다. 후보: (a) 번들러 manifest 옵션을 켜고 산출물을 계약 경로로 옮기는 post-build wrapper script, (b) 번들러 출력 설정만으로 계약 경로에 직접 쓰기. trade-off: (a)를 기본값으로 채택 — 번들러 기본 출력 규약과 계약 artifact 경로를 분리해 두면 번들러/옵션이 바뀌어도 downstream gate(`FE-GATE-011`) 계약 경로가 깨지지 않는다. 착수 시 실제 산출 경로를 확인해 확정.
|
||||
> - **해소됨(2026-07-21) — 근거 있는 결정**: `FE-NFR-C04` build context 의 기록 위치·필드명은 이제 스키마가 정한다. hub §2.1.3 `ART-FE-001@1`(Schema Owner = 본 branch)의 `build-manifest.schema.json` 이 `buildContext.nodeVersion` · `buildContext.packageManagerVersion` · `buildContext.runnerImage` 를 required 로 고정한다. 이전 판이 제안하던 top-level `pnpmVersion` 은 그 스키마의 `buildContext.packageManagerVersion` 으로 확정됐다(패키지 매니저를 pnpm 으로 못박지 않기 위함). 필드 추가·rename 은 Schema Owner 단독 결정이고 소비 branch 는 `imports` pin 으로 따라온다.
|
||||
|
||||
- 최소 `vite.config.js` + `pnpm dev`/`pnpm build` script로 client-only SPA build baseline을 확정.
|
||||
- `pnpm build`는 exit 0 + build manifest(`artifacts/release/build-manifest.json`)를 산출해야 `FE-GATE-011` PASS.
|
||||
- **manifest 산출 책임 경계**: `artifacts/release/build-manifest.json` 의 *생성* 은 본 branch 가 소유한다 — 근거는 gate owner 가 아니라 hub §2.1.3 `ART-FE-001@1` 의 Producer·Schema Owner 등록이다(`FE-GATE-011` 자체의 owner 는 [[raw/branch-notes/feature-frontend-build-bundle-supply-chain-contract]]). release tuple 파일(`dist/release-manifest.json`)과 cache header 정책은 `FE-OC-016`/`FE-OC-017` owner [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] 소유이며 본 §에 detail 미기재(R3).
|
||||
- **build context 기록 vs 소비 경계**: `FE-NFR-C04`(Node/pnpm 버전 등) 값을 build manifest에 *기록* 하는 것은 본 branch, 그 값을 bundle threshold 판정 맥락으로 *소비* 하는 것은 `FE-OC-021` owner [[raw/branch-notes/feature-web-vitals-performance-budget-contract]] — threshold·판정 로직은 본 §에 미기재(R3).
|
||||
- bundle size threshold(`FE-NFR-001`/`002`)와 성능 예산은 `FE-OC-021` owner [[raw/branch-notes/feature-web-vitals-performance-budget-contract]] — 본 §에 threshold 미기재(R3).
|
||||
|
||||
### 5. §14.3 script 슬롯 vs owner 위임 (FE-OC-020 기여)
|
||||
|
||||
> **Trace**: `FE-OC-003`(command 한 곳 고정) + `FE-OC-020` 기여(gate script 배선). §14.3 planned command 계약의 script 이름은 project-wide SSOT이며, 본 branch는 매니페스트에 슬롯을 예약하되 non-owned script의 구현은 정의하지 않는다.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — 어떤 script를 본 branch가 소유하고 어떤 것을 위임하는지는 §15.1 gate ownership + §5.1 registry owner map으로 결정론적으로 도출됨.
|
||||
|
||||
| §14.3 script | 소유 | 본 branch 역할 |
|
||||
|---|---|---|
|
||||
| `pnpm install --frozen-lockfile` | this branch | 정의 + evidence (`FE-GATE-001`) |
|
||||
| `pnpm check:types` | this branch | 정의 (`FE-GATE-003`) |
|
||||
| `pnpm build` | this branch | baseline 정의 (`FE-GATE-011`) |
|
||||
| `pnpm lint` | [[raw/branch-notes/feature-frontend-architecture-enforcement-lint-contract]] | 슬롯만 예약 |
|
||||
| `pnpm test:unit`/`test:component`/`test:integration`/`test:e2e`/`test:a11y` | [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] | 슬롯만 예약 |
|
||||
| `pnpm check:bundle`/`test:performance` | [[raw/branch-notes/feature-web-vitals-performance-budget-contract]] | 슬롯만 예약 |
|
||||
| `pnpm scan:security` | [[raw/branch-notes/feature-frontend-build-bundle-supply-chain-contract]] | 슬롯만 예약 |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **lockfile drift**: manifest range와 `pnpm-lock.yaml` 불일치 → `pnpm install --frozen-lockfile` non-zero exit → `FE-GATE-001` FAIL. 기대 동작: CI가 merge 차단, 부분 install 없음.
|
||||
- **engine mismatch**: 로컬/CI Node·pnpm이 `engines` 범위 밖 → engine 강제로 install 거부. 기대 동작: 명확한 에러 + silent 진행 금지. (강제 메커니즘 = §구현 가이드 1의 "engine 강제 메커니즘" 행 + 같은 § 3번째 `UNSUPPORTED_IMPL_DECISION` 라벨 — 후보 (a)/(b)/(c) 중 미확정)
|
||||
- **checkJs diagnostic > 0**: production 소스 타입 오류 → `pnpm check:types` non-zero → `FE-GATE-003` FAIL. 기대 동작: merge 차단. negative fixture는 반대로 fail해야 정상.
|
||||
- **Vite build 실패/manifest 부재**: `pnpm build` non-zero 또는 `build-manifest.json` 미산출 → `FE-GATE-011` FAIL.
|
||||
- **script 이름 drift**: §14.3 script rename을 gate/artifact mapping 갱신 없이 수행 → downstream gate가 없는 script 참조. 기대 동작: §14.3 규칙("script 이름을 바꾸면 acceptance gate와 artifact mapping을 동시에 갱신")으로 방지.
|
||||
- **다른 계약 의존**:
|
||||
- **상류 의존 해당 없음** — 본 branch는 §20 Dependency `—` 인 branch DAG root. sibling 계약에서 consume하는 것 없음.
|
||||
- **하류 소비자(역의존)**: 본 산출물(pnpm/lockfile·`type: module`·`check:types`·Vite baseline)을 [[raw/branch-notes/feature-frontend-clean-architecture-layering-contract]], [[raw/branch-notes/feature-frontend-env-runtime-config-contract]], [[raw/branch-notes/feature-frontend-test-taxonomy-contract]], [[raw/branch-notes/feature-tailwind-design-token-styling-contract]], [[raw/branch-notes/feature-frontend-build-bundle-supply-chain-contract]] 가 §20 Dependency로 consume. 이 계약(script 이름·lockfile 정책)이 바뀌면 해당 branch 영향.
|
||||
- **기여(contributes-to)**: `FE-OC-018` owner [[raw/branch-notes/feature-frontend-build-bundle-supply-chain-contract]] 의 supply-chain gate가 본 frozen lockfile evidence를 consume; `FE-OC-020` owner [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] 가 `check:types`를 `FE-GATE-003`으로 배선.
|
||||
- **CA 철학 precedent**: [[raw/project-notes/ca-skeleton-operational-contract]] 의 운영 계약(port ownership·sample fixture 원칙) — 본 toolchain이 그 구조를 담을 그릇을 만든다(사실 의존이 아닌 설계 precedent).
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| clean checkout에서 `pnpm install --frozen-lockfile`이 exit 0, lockfile drift 시 non-zero | repo·lockfile 미생성 | `FE-GATE-001` frozen install; evidence `artifacts/quality/install.txt` + `lockfile-check.txt` (§14.3 / §13.1) | `needs-confirmation` |
|
||||
| `pnpm check:types`가 production 소스에서 checkJs diagnostic 0, negative fixture에서 fail | `tsconfig` checkJs 설정 실효성 미검증 | `FE-GATE-003` typecheck; JSDoc/checkJs negative fixture; `artifacts/quality/check-types.txt` (§15.1) | `needs-confirmation` |
|
||||
| `pnpm build`(Vite)가 exit 0 + `build-manifest.json` 산출 | `vite.config.js` 미작성 | `FE-GATE-011` build; `artifacts/release/build-manifest.json` (§14.3) | `needs-confirmation` |
|
||||
| engine 강제(Node/pnpm 범위)가 버전 불일치 install을 실제 차단 | 강제 메커니즘 후보 (a) `.npmrc engine-strict` (b) Corepack (c) `preinstall` guard 중 미확정·미검증 (§구현 가이드 1 UNSUPPORTED) | 로컬 Node 버전을 `engines` 범위 밖으로 변조 후 install → non-zero exit 재현; fresh clone pass(hub §17 `FE-Q-002`) | `needs-confirmation` |
|
||||
| `artifacts/release/build-manifest.json` 이 계약 경로에 실제 산출되고 `FE-NFR-C04` build context(Node/패키지 매니저 버전 + runner image)를 포함 | 산출 메커니즘(wrapper vs 번들러 직접 출력) 미확정 — 필드명은 `ART-FE-001@1` 스키마로 확정됨 | `pnpm build` 후 경로 존재 + context 필드 존재 확인; `FE-GATE-011` assertion + hub §14.1 context 요구 대조 | `needs-confirmation` |
|
||||
| §14.3 script 이름이 downstream gate(`FE-GATE-001`/`003`/`011`)와 일치 유지 | script rename drift 위험 | gate matrix ↔ 매니페스트 script cross-ref (ci-quality-gates 협업) | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
> `/coverage`가 채우는 생성물이며 손으로 유지하지 않는다.
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| TODO — `/coverage` 실행 전 | missing | (없음) | 미평가 | TODO |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: received-delegations:start -->
|
||||
### 수신한 위임
|
||||
|
||||
| Delegation Ref | From | Concern | Status |
|
||||
|---|---|---|---|
|
||||
| `DELEG-FE-002@1` | [[raw/branch-notes/feature-frontend-architecture-enforcement-lint-contract]] | `fe.deleg.lint-toolchain-substrate` | accepted |
|
||||
<!-- GENERATED: received-delegations:end -->
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
| `FE-GATE-011@1` | [[raw/branch-notes/feature-frontend-build-bundle-supply-chain-contract]] | clean production build 가 실패하거나 기대 artifact 가 없으면 merge·release 를 MUST 차단 | import 참조로 적용 |
|
||||
| `FE-OC-007@1` | [[raw/branch-notes/feature-runtime-schema-validation-contract]] | JSON envelope와 payload는 boundary에서 runtime schema를 MUST 통과 | import 참조로 적용 |
|
||||
| `FE-OC-016@1` | [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] | HTML, asset, runtime config, release manifest cache policy를 MUST 구분 | import 참조로 적용 |
|
||||
| `FE-OC-018@1` | [[raw/branch-notes/feature-frontend-build-bundle-supply-chain-contract]] | frozen lockfile, dependency review, secret scan, SBOM 또는 dependency inventory를 release gate에 MUST 포함 | import 참조로 적용 |
|
||||
| `FE-OC-020@1` | [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] | gate 종류별 책임·fixture·artifact를 분리하고 실패를 warning으로 낮추면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-021@1` | [[raw/branch-notes/feature-web-vitals-performance-budget-contract]] | NFR은 device/network/cache/build context와 함께 MUST 측정 | import 참조로 적용 |
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
### Sub-branches (세부 작업)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 오류 기록 (이 branch 작업 중 발생)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 강의 (이 작업을 위해 학습한 강의)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### job-posting tie-ins (이 작업에서 파생된 글감)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-frontend-operational-contract/branch-notes/feature-frontend-release-cache-rollback-contract.md
|
||||
@@ -0,0 +1,285 @@
|
||||
---
|
||||
title: branch / feature-frontend-release-cache-rollback-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-024
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-frontend-operational-contract
|
||||
work_item: WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-024
|
||||
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-DEPLOYMENT-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CACHE-POLICY-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-OFFLINE-CACHE-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-020, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-004, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-023]
|
||||
contract_packet: 1
|
||||
branch: feature-frontend-release-cache-rollback-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton-frontend, ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-frontend-operational-contract]
|
||||
tags: [branch, ca-skeleton, frontend, ci-cd, build-tooling, externalized-config]
|
||||
created: 2026-07-18
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 63a7ea47dea99d7a8dfe7275a2636dd5f529c280884fe593d2f083dfb15ed1fc
|
||||
imports: [ART-FE-001@1, FE-OC-019@1]
|
||||
---
|
||||
|
||||
# branch: feature-frontend-release-cache-rollback-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — TODO·결정·진행 기록. 구현 결과는 검증 뒤 `/ingest`로만 추출한다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/project-notes/ca-skeleton-frontend-operational-contract]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: release tuple·cache header·mixed fixture failure·rollback drill이 검증된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-DEPLOYMENT-001@1` | static release는 immutable release directory와 atomic active pointer로 배포한다 | immutable release layout·atomic switch·rollback에 적용한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CACHE-POLICY-001@1` | hashed asset은 immutable, HTML·runtime config·release manifest는 revalidate/no-store로 분리한다 | surface별 cache header와 coherence gate에 적용한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-OFFLINE-CACHE-001@1` | service worker와 offline asset cache는 default off다 | service worker registration과 offline cache 기본 정책에 적용한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | surface별 cache policy를 분리한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D2 | immutable release directory와 atomic active pointer를 사용한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D3 | service worker와 offline asset cache를 기본 off로 둔다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D4 | rollback은 coherent prior-release set을 복원한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D5 | FE-REG-RELEASE와 typed compatibility comparison을 사용한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D6 | `FE-GATE-019@2`의 security-header 축 검증 메커니즘을 소유하고 정책 내용은 browser-security가 공급한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
없음.
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
이 브랜치는 프로젝트 계약 `FE-OC-016`(HTML/asset/runtime-config/release-manifest cache policy를 MUST 구분)과 `FE-OC-017`(rollback은 immutable prior release로 수행하고 build/config/API compatibility를 MUST 검증)을 *구현 착수 가능한 명세*로 내린다. hub의 결정 `FE-D019`(service worker/offline cache default off), `FE-D020`(hashed asset immutable + HTML/config/manifest revalidate·no-store 분리), `FE-D023`(immutable release directory + atomic active pointer)와 registry `FE-REG-RELEASE`(release token registry, §5.9)를 owner로서 상세화하고, 여기에 §12.3 compatibility tuple / §12.4 atomic deploy expectation / §12.5 rollback invariant를 착수 수준으로 고정한다. 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D019 · FE-D020 · FE-D023 · §12. 아직 frontend repository·hosting provider가 없으므로 본 노트의 모든 구현 항목 등급은 `planned`이며, 코드/헤더/드릴 evidence가 생기기 전에는 `actually-implemented`로 승급하지 않는다.
|
||||
|
||||
- 이슈: 없음 (스캐폴딩 단계)
|
||||
- PR: 없음
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- **Cache policy 소유** (`FE-OC-016`): hashed JS/CSS/font/image, `index.html`, `/config.json`(runtime config), `release-manifest.json`, source map, service worker 6개 surface의 default cache policy 명세 (§12.2). 실제 `Cache-Control` header syntax는 policy로만 소유하고 provider 확정 후 adapter runbook에 기록.
|
||||
- **Immutable release + atomic pointer** (`FE-D023`, §12.4): immutable release directory layout + atomic active-pointer deploy order.
|
||||
- **Rollback contract** (`FE-OC-017`, §12.5): coherent prior-release set 정의 + rollback invariant + FE-RB-005 drill evidence 요건(`FE-GATE-016`).
|
||||
- **Release token registry** (`FE-REG-RELEASE`, §5.9): release/compatibility tuple 토큰 + typed(비-lexical) compatibility comparison.
|
||||
- **Release coherence gate + mixed-version negative fixture** (`FE-GATE-015@1`): HTML/asset/config mismatch 탐지 fixture.
|
||||
- **Hosting header gate** (`FE-GATE-019@2`, 2026-07-21 에 security 축 편입): 응답 header 의 declared-vs-actual 대조를 **cache 축과 security 축 둘 다** 담당한다. 본 branch 는 gate owner 로서 **검증 메커니즘**(응답 probe · 대조 · `hosting-headers.json` artifact)을 소유하고, 검증 대상 **security header 정책의 내용**은 [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] (`FE-OC-019`) 가 공급한다. directive 값 자체는 여전히 hosting/backend header owner 소유다(D6).
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- **Hosting/CDN provider의 실제 콘솔 command와 deploy execution** — provider 확정 후 adapter/runbook에서 채움.
|
||||
- **Runtime config 자체의 3-way 분리·boot 검증 로직** (`FE-OC-004`) — owner는 [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] (`FE-OC-004`). 본 브랜치는 그 config의 *cache/coherence*만 소유.
|
||||
- **Build output의 asset hashing·build manifest·dependency inventory 생성** (`FE-OC-018`) — owner는 [[raw/branch-notes/feature-frontend-build-bundle-supply-chain-contract]] (`FE-OC-018`). 본 브랜치는 그 산출물을 *release coherence 입력*으로 consume만.
|
||||
- **Version tuple compatibility 규칙(additive/breaking/migration)** (`FE-OC-023`) — owner는 [[raw/branch-notes/feature-frontend-contract-compatibility-governance]] (`FE-OC-023`). 본 브랜치는 그 규칙을 rollback 판정에 *적용*만.
|
||||
- **Runbook 서술 문서(FE-RB-002/FE-RB-005 narrative) 유지와 5개 drill orchestration** (`FE-OC-025`) — owner는 [[raw/branch-notes/feature-frontend-operational-runbook-contract]] (`FE-OC-025`). 본 브랜치는 rollback *기술 escalation 대상*이자 drill evidence 요건 제공자.
|
||||
- **DEPLOY_MISMATCH 사용자 recovery UI·reload-loop 방지** (`FE-OC-015`) — owner는 [[raw/branch-notes/feature-frontend-render-recovery-boundary-contract]] (`FE-OC-015`).
|
||||
- **8-registry single-owner governance orchestration** (`FE-OC-022`) — owner는 [[raw/branch-notes/feature-frontend-contract-registry-governance]] (`FE-OC-022`). 본 브랜치는 `FE-REG-RELEASE` 한 registry의 *content owner*.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 이 branch의 구현·설계 결정의 **근거가 되는 외부 자료**.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/vite-build-tool-official]] `VITE-C2` | production build가 content-hash 붙은 optimized static asset을 산출한다는 공식 근거 — D1(hashed asset = long-lived immutable) cache 분리와 D2(static-hosting immutable release directory) 전제의 build-tool 근거. cache header 자체는 hosting provider 확정 후 보강. |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §15.1 `FE-GATE-019@2` · §2.1.1(revision 2, Owner = 본 branch) · §14.3(`pnpm verify:hosting-headers`) | D6 — 이 gate 가 cache header 뿐 아니라 **security header 집합**의 declared-vs-actual 대조까지 담당한다는 근거. 정책 내용 공급자는 [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] (`FE-OC-019`). |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D019 · FE-D020 · FE-D023 (§12 Release/Cache/Version/Rollback, §5.9 FE-REG-RELEASE) | 본 브랜치 owner 결정 3건 + release token registry + compatibility tuple/atomic deploy/rollback invariant의 project decision SSOT. release coherence·rollback invariant는 외부 vendor 표준이 아니라 project inference이므로 hub row를 근거로 인용. |
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기.
|
||||
|
||||
- [ ] `FE-REG-RELEASE` release token registry(`src/contracts/release-tokens.js`) + typed compatibility comparator 명세 — 등급: `planned`
|
||||
- [ ] surface별 cache policy 표 + `pnpm verify:hosting-headers`(`FE-GATE-019@2`) assertion 명세 — 등급: `planned`
|
||||
- [ ] `FE-GATE-019@2` security-header 축: browser-security 가 공급한 정책 집합(CSP/HSTS/frame/referrer)의 declared-vs-actual 대조를 같은 probe·artifact 로 편입 — 등급: `planned`
|
||||
- [ ] immutable release directory layout + atomic active-pointer deploy order(§12.4) 명세 — 등급: `planned`
|
||||
- [ ] rollback coherent-set invariant + FE-RB-005 drill evidence(`FE-GATE-016`) 요건 명세 — 등급: `planned`
|
||||
- [ ] mixed-version negative fixture + release coherence gate(`FE-GATE-015`) 명세 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- hosting/CDN provider 미확정 → cache header 문자열·atomic switch primitive·purge semantics는 provider 확정 시 adapter runbook에서 확정. 현재는 policy와 invariant만 소유한다.
|
||||
- 모든 항목 `planned` — frontend repository가 없어 코드/헤더/드릴 evidence 부재.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 각 결정의 근거는 hub decision register(§3.2)와 §12/§5.9.
|
||||
|
||||
- 2026-07-18: **surface별 cache policy 분리 채택** / 이유: hashed asset은 content-hash로 identity가 고정돼 immutable 가능하지만 HTML/runtime-config/release-manifest는 release마다 교체·mismatch 탐지가 필요 / 검토한 대안: 전 surface 단일 cache 규칙(운영 단순) / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D020 · §12.2.
|
||||
- 2026-07-18: **immutable release directory + atomic active pointer 채택** / 이유: rollback 가능한 artifact와 partial-deploy 없는 전환을 위해 / 검토한 대안: in-place overwrite deploy(rollback 불가·mixed window 발생) / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D023 · §12.4.
|
||||
- 2026-07-18: **service worker/offline asset cache default off** / 이유: stale asset·config mismatch surface 축소 / 검토한 대안: SW precache(오프라인 UX 확보하나 stale 복잡도 증가) / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D019.
|
||||
- 2026-07-18: **rollback = coherent prior-release set + compatibility 검증** / 이유: HTML만 되돌리고 runtime config를 최신에 남기면 mismatch로 boot/route 실패 / 검토한 대안: HTML pointer만 교체하는 fast rollback(§12.5가 금지) / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-OC-017 · §12.5 · §16.5.
|
||||
- 2026-07-21: **`FE-GATE-019` 에 security-header 축 편입(D6)** / 이유: hub §15.1 이 이 gate 의 Covered FE-OC 에 `FE-OC-019` 를 추가해 pass condition 이 security header 까지 넓어졌다. 응답 header 의 declared-vs-actual 대조라는 메커니즘이 cache header 와 동일하므로 같은 probe·같은 artifact 를 쓴다 / 검토한 대안: `FE-GATE-013`(security) 에 두기 — 그쪽은 artifact 를 스캔하는 gate 라 실행 시점·증거 형식이 달라 기각 / 근거: hub §15.1 `FE-GATE-019@2` · §2.1.1 revision 2. 검증 대상 정책 집합은 [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] 가 공급.
|
||||
- 2026-07-18: **release token registry + typed(비-lexical) compatibility comparison** / 이유: `releaseId`/schema/API version을 string lexical로 비교하면 오판정(§12.3 금지) / 검토한 대안: page 안에서 직접 version string 비교(§5.1 ad hoc failure) / 근거: [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §5.9 · §12.3.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | `FE-OC-016` surface별 cache policy 분리: hashed asset = long-lived immutable, `index.html` = no-cache/revalidate, `/config.json` = no-store(또는 URL explicit version), `release-manifest.json` = no-store/immediate revalidate, source map = public off, service worker = off | 기본값으로 이 분리를 적용. hosting cache primitive가 surface별 `Cache-Control`을 표현하지 못하면(단일 global 규칙만 제공) provider-specific 등가 정책을 adapter runbook + decision row에 기록해 대체 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D020, FE-OC-016 · §12.2 · §6.1(runtime public=no-store); [[raw/official-docs/vite-build-tool-official]] `VITE-C2`(content-hash static asset) | `project-decision + official-doc` | 실제 hosting header가 선언 policy와 일치하는지 미검증(`FE-GATE-019@2` 필요); 정확한 `max-age`/`immutable` directive 문자열 미확정 |
|
||||
| D2 | `FE-D023` immutable release directory + atomic active pointer 배포. deploy order: immutable asset → release manifest → runtime config → asset reachability smoke → active HTML pointer switch → post-switch smoke (§12.4) | provider가 atomic pointer switch를 지원하면 이 primitive 사용. provider가 *다른* atomic primitive만 제공하면 그 등가 primitive + rollback semantics를 decision row에 기록(§12.4 fallback) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D023, FE-OC-016, FE-OC-017 · §12.4 · §12.1(artifact set) | `conditional-default` | provider primitive 미확정 — atomic switch·purge semantics는 hosting owner 확정 전 `TBD`; partial-deploy window 무발생 검증 필요 |
|
||||
| D3 | `FE-D019` service worker·offline asset cache default off | stale asset/config mismatch surface 축소를 위해 기본 off. offline product requirement + update UX가 *설계된 뒤에만* SW precache 재검토(FE-D019 revisit trigger) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D019, FE-OC-016 · §12.2(service worker=default off row) | `conditional-default` | SW가 실제로 등록되지 않는지 build/e2e로 미검증; offline 요구가 생기면 update UX 설계 없이는 재도입 금지 |
|
||||
| D4 | `FE-OC-017` rollback = coherent prior-release set 복구 + build/config/API compatibility 검증. 금지: rebuild-as-rollback, HTML-only 교체, compatibility 미확인 pointer 변경, smoke 없는 close (§16.5) | release-blocking defect가 확인되고 forward fix가 incident window 안에서 안전하다고 증명되지 않을 때 rollback(§16.5 activation). prior immutable release·config·API compatibility가 알려져 있어야 실행 가능(preconditions) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-OC-017, FE-D023 · §12.5(rollback invariant) · §16.5(FE-RB-005 procedure invariant) | `project-decision` | recovery를 cache purge 완료가 아니라 old/new reachability probe로 판정해야 함(§12.5) — provider probe 미구현; rollback drill(`FE-GATE-016`) evidence 부재 |
|
||||
| D5 | `FE-REG-RELEASE` release token registry(토큰 목록은 hub §5.9 소유 — 8-token tuple) + typed compatibility comparison — string lexical version 비교 금지(§12.3) | tuple 토큰과 comparator를 registry factory로 소유. page/component가 raw string version을 비교하거나 cache key를 직접 작성하면 ad hoc use failure(§5.1) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §5.9(release token schema) · §12.3(compatibility tuple + no-lexical-compare rule) · §5.1(FE-REG-RELEASE owner) | `project-decision` | comparator API 모양·semver 파싱 규칙 미확정(UNSUPPORTED_IMPL_DECISION); `builtAt`이 cache identity로 오용되지 않는지 검증 필요 |
|
||||
| D6 | `FE-GATE-019@2` 의 **security-header 축**: 본 branch 는 gate owner 로서 declared-vs-actual **검증 메커니즘**(응답 probe · 대조 · `hosting-headers.json` artifact)을 소유하고, 검증 대상 security header **정책의 내용**은 [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] (`FE-OC-019`) 가 공급한다 | hub §15.1 이 이 gate 의 Covered FE-OC 에 `FE-OC-019` 를 포함하는 한 유지. cache header 와 같은 probe·같은 artifact 를 쓰므로 별도 command 를 만들지 않는다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §15.1(`FE-GATE-019@2` — pass condition 에 security-header 포함) · §2.1.1(revision 2, Owner = 본 branch) · §14.3(`pnpm verify:hosting-headers` 행) | `project-decision` | directive 값은 hosting/backend header owner 소유라 실제 응답 대조는 provider 확정 후에만 가능; 정책 공급자(browser-security)의 정책 집합이 바뀌면 본 gate fixture 재도출 필요 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 모든 경로는 `planned` — frontend repository 미생성. 경로는 hub §4.6 Planned directory blueprint + §5.1 registry owner map에서 도출(grounded)하되 코드가 없으므로 전체 `planned`.
|
||||
|
||||
### 1. Release token registry + typed compatibility comparator
|
||||
|
||||
> **Trace**: D5 — [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §5.9(FE-REG-RELEASE schema) · §12.3(compatibility tuple, no-lexical-compare) · §5.1(owner map: `src/contracts/release-tokens.js`). Registry content owner = 본 브랜치.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: comparator 함수 이름/시그니처와 version 파싱 규칙(semver vs 명시적 정수 필드)은 hub가 원칙(“lexical 금지”)만 주고 detail은 미권고 → 임의 선택. trade-off: 명시적 정수 필드 비교는 구현이 단순하나 organization version 규약이 semver를 강제하면 재작성 필요.
|
||||
|
||||
- **Planned path**: `src/contracts/release-tokens.js` (§5.1).
|
||||
- **Tokens**: 8-token release tuple 의 **정의(토큰명 · Source · Compatibility role)는 [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §5.9 소유**이며 여기에 옮겨 적지 않는다. 본 § 이 쓰는 불변식만: `builtAt` 은 진단용이고 **cache identity 가 아니다**.
|
||||
- **Comparison contract (§12.3)**: `config schema major incompatible → boot fail`; `API contract incompatible → route mount fail 또는 supported compatibility adapter`; `asset manifest mismatch → controlled reload once`; `releaseId mismatch but all versions compatible → warning telemetry 후 continue`. 판정은 구조적 비교로만 — **string lexical compare 금지**.
|
||||
|
||||
### 2. Per-surface hosting header contract (cache + security)
|
||||
|
||||
> **Trace**: D1 — [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D020 · §12.2(cache policy 표) · §6.1(runtime public=no-store). 정책만 소유, header 문자열은 provider adapter로 위임.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 정확한 `Cache-Control` directive 문자열(예: `max-age` 초, `immutable`, `no-store`)과 hosting 설정 문법은 미권고 → provider 확정 후 확정. trade-off: 지금 숫자를 고정하면 provider 제약과 충돌 위험.
|
||||
|
||||
| Surface | Default cache policy | Reason (§12.2) |
|
||||
|---|---|---|
|
||||
| hashed JS/CSS/font/image | long-lived immutable | content hash identity |
|
||||
| `index.html` | `no-cache` / revalidate | active entry point 교체 |
|
||||
| `/config.json` (runtime config) | `no-store` 또는 URL explicit version | deploy-specific public config |
|
||||
| `release-manifest.json` | `no-store` 또는 immediate revalidate | mismatch detection |
|
||||
| source map | public hosting off; secured artifact store | stack/source exposure boundary |
|
||||
| service worker | off (D3/FE-D019) | stale release 복잡도 |
|
||||
|
||||
- **Verification (§14.3)**: `pnpm verify:hosting-headers` → `artifacts/release/hosting-headers.json`; assertion = HTML/config/manifest/hashed-asset 응답의 header 가 선언 policy 와 일치(`FE-GATE-019@2`). **cache header 뿐 아니라 security header(CSP/HSTS/frame/referrer)도 같은 probe 로 대조**한다 — 정책 내용은 [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] 가 공급.
|
||||
|
||||
### 3. Immutable release directory + atomic active-pointer deploy
|
||||
|
||||
> **Trace**: D2 — [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-D023 · §12.1(artifact set) · §12.4(atomic deploy order).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: release directory naming 규약(예: `releases/<releaseId>/`)은 hub가 명시하지 않음 → 임의. trade-off: `releaseId` 기반 디렉토리는 rollback target 매핑이 단순하나 provider 경로 제약과 충돌 가능. provider-specific atomic switch/purge command는 **OUT_OF_BRANCH_SCOPE** → §범위 참조([[raw/branch-notes/feature-frontend-build-bundle-supply-chain-contract]] `FE-OC-018` build output, hosting owner).
|
||||
|
||||
- **Artifact set (§12.1)**: `dist/index.html`, `dist/assets/<content-hash>.*`, `dist/config.json`, `dist/release-manifest.json`, `dist/config/runtime-config.schema.json`, `artifacts/release/build-manifest.json`, `artifacts/release/dependency-inventory.*`, `artifacts/release/checksums.txt`.
|
||||
- **Atomic deploy order (§12.4)**: (1) immutable asset upload → (2) release manifest upload → (3) runtime config upload → (4) asset reachability smoke → (5) active HTML pointer switch → (6) post-switch boot/e2e smoke. provider가 이 순서를 지원하지 않으면 등가 atomic primitive + rollback semantics를 decision row에 기록.
|
||||
|
||||
### 4. Rollback coherent-set invariant + drill evidence
|
||||
|
||||
> **Trace**: D4 — [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-OC-017 · §12.5(rollback invariant) · §16.5(FE-RB-005 procedure invariant). Runbook *서술 문서*와 drill orchestration은 [[raw/branch-notes/feature-frontend-operational-runbook-contract]] `FE-OC-025` 소유 — 본 브랜치는 invariant + evidence 요건 제공 + 기술 escalation 대상(§16.5).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: reachability probe의 구체 구현(요청 방식·판정 임계)은 provider 미확정으로 임의 → trade-off: probe를 origin에만 하면 edge 불일치를 놓칠 수 있어 old/new 양쪽 URL 실측 필요.
|
||||
|
||||
- **Coherent rollback set (§12.5)**: prior HTML + prior asset manifest·assets + compatible runtime config + compatible API contract(또는 backend compatibility window) + release manifest를 **함께** 되돌린다. HTML만 과거로, runtime config는 최신 유지하는 rollback은 **금지**.
|
||||
- **Procedure invariant (§16.5)**: target release tuple 선택 → prior assets reachability 확인 → prior runtime config compatibility 확인 → active pointer atomic switch → provider cache action → boot+route+API critical smoke → telemetry/reload-loop 확인 → rollback record 저장.
|
||||
- **Recovery 판정**: cache purge *완료*가 아니라 old/new reachability probe 결과로 판정(§12.5).
|
||||
- **Evidence**: `artifacts/runbooks/FE-RB-005/<release-id>/record.json`; drill = `pnpm drill:runbook -- FE-RB-005`(`FE-GATE-016` rollback drill / `FE-GATE-025` FE-RB-005 drill).
|
||||
|
||||
### 5. Release coherence gate + mixed-version negative fixture
|
||||
|
||||
> **Trace**: D1·D4 — [[raw/project-notes/ca-skeleton-frontend-operational-contract]] FE-OC-016 · FE-OC-017 · §8.2(DEPLOY_MISMATCH / RELEASE_MANIFEST_FAILURE) · §15.2(negative fixture “HTML build A + asset manifest B”). config-schema *검증 로직*은 [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] `FE-OC-004`(boot sequence §6.3) 소유 — 본 브랜치는 release/asset coherence 판정만.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: fixture를 구성하는 구체 mock 파일 세트·verify 스크립트 내부 알고리즘은 repository 확정 전 미권고 → 임의. trade-off: 최소 fixture(HTML A + manifest B)만으로 시작하면 config mismatch 조합은 별도 fixture 필요.
|
||||
|
||||
| Fixture | 기대 정규화 결과 (§8.2) |
|
||||
|---|---|
|
||||
| HTML(build A) + asset manifest(build B) | `DEPLOY_MISMATCH` — request retry 없이 controlled reload once 또는 rollback |
|
||||
| release manifest fetch/parse/schema 실패 | `RELEASE_MANIFEST_FAILURE` — boot 시 bounded refetch 1회, update/support shell |
|
||||
| chunk fetch 실패(release check 후) | `CHUNK_LOAD_FAILURE` — release check 후 controlled reload 1회만 |
|
||||
|
||||
- **Verification (§14.3)**: `pnpm verify:release` → `artifacts/release/verification.json`(compatibility tuple coherent); `FE-GATE-015` release coherence = mixed set은 mismatch detected, coherent set은 pass.
|
||||
- **schema = `ART-FE-003@1`** (hub §2.1.3 · `harness/source/artifact-schemas/ca-skeleton-frontend/release-verification.schema.json`). 위 `verification.json` 의 **Schema Owner 는 본 branch 단독**이며, 필드 추가·rename 은 스키마 파일을 고쳐 revision 을 올리는 것으로 한다. 소비 branch([[raw/branch-notes/feature-frontend-contract-compatibility-governance]])는 본문에 스키마를 옮겨 적지 않고 `imports` 에 `ART-FE-003@1` 로 pin 하므로, revision 이 오르면 낡은 pin 이 자동으로 잡힌다. 필드 명명은 hub §2.1.3 의 camelCase 규약을 따른다.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로** (§8.2 / §16.2):
|
||||
- `DEPLOY_MISMATCH` (HTML/asset/config release mismatch): request retry 금지, controlled reload once 또는 rollback, telemetry = mismatch kind + IDs(raw 금지).
|
||||
- `RELEASE_MANIFEST_FAILURE` (manifest fetch/parse/schema 실패): boot 시 bounded refetch 1회만; 실패 시 reload하지 말고 update/support shell로 격리(§16.2 immediate containment 3).
|
||||
- `CHUNK_LOAD_FAILURE`: release manifest를 `no-store`로 1회 조회해 active release mismatch가 *확인된 경우에만* reload guard 기록 후 1회 reload; asset set incomplete면 prior coherent release로 rollback(§16.2 mitigation).
|
||||
- CDN propagation 불일치(origin 정상, edge stale): active switch를 되돌리고 reachability probe 재실행 후 hosting/CDN owner로 escalation(§16.2).
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] `FE-OC-004` — runtime config publish + boot config validation(§6.3/§6.4)을 consume. config schema 계약이 바뀌면 compatibility tuple 판정과 coherent-set 정의에 영향.
|
||||
- [[raw/branch-notes/feature-frontend-build-bundle-supply-chain-contract]] `FE-OC-018` — asset content-hash·`build-manifest.json`·`assetManifestHash`를 생성; 이것이 release coherence 입력. hashing 규칙이 바뀌면 asset immutability·mismatch 탐지 영향.
|
||||
- [[raw/branch-notes/feature-frontend-contract-compatibility-governance]] `FE-OC-023` — additive/breaking/migration 규칙을 정의; rollback의 “compatible config/API” 판정이 이 규칙에 의존.
|
||||
- [[raw/branch-notes/feature-frontend-operational-runbook-contract]] `FE-OC-025` — FE-RB-002/FE-RB-005 runbook 서술과 drill orchestration 소유; 본 브랜치는 기술 escalation 대상 + drill evidence 요건 제공(`FE-GATE-016`/`FE-GATE-022`/`FE-GATE-025`).
|
||||
- [[raw/branch-notes/feature-frontend-render-recovery-boundary-contract]] `FE-OC-015` — `DEPLOY_MISMATCH` 사용자 recovery UI와 reload-loop 방지 소유; 본 브랜치는 normalized kind와 “reload once” 계약만 제공.
|
||||
- [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] `FE-OC-019` — `FE-GATE-019@2` 의 security-header 축에서 **검증 대상 정책 집합(CSP/HSTS/frame/referrer)을 공급**(D6). 그 정책이 바뀌면 본 gate 의 fixture·probe 기대값 재도출.
|
||||
- [[raw/branch-notes/feature-frontend-contract-registry-governance]] `FE-OC-022` — 8-registry single-owner/snapshot governance; `FE-REG-RELEASE`는 그 governance 하에 관리되는 registry.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| rollback이 coherent prior release(HTML+assets+config+API+manifest)를 복구한다 | deploy artifact·drill evidence 없음 | mixed-version fixture + `pnpm drill:runbook -- FE-RB-005`(`FE-GATE-016`), recovery = old/new reachability probe pass | `needs-confirmation` |
|
||||
| 실제 hosting header가 선언 cache policy와 일치한다 | header 문자열·provider 미확정 | `pnpm verify:hosting-headers`(`FE-GATE-019@2`) → `hosting-headers.json` 대조 | `needs-confirmation` |
|
||||
| 실제 hosting 응답의 security header(CSP/HSTS/frame/referrer)가 선언 정책과 일치한다 | 정책 내용은 browser-security 공급분이고 provider 미확정 | 같은 `pnpm verify:hosting-headers` probe 에 security header 축 편입(`FE-GATE-019@2`) | `needs-confirmation` |
|
||||
| mixed HTML/asset/config가 `DEPLOY_MISMATCH`로 탐지되고 coherent set은 pass한다 | verify 스크립트·fixture 미구현 | `pnpm verify:release`(`FE-GATE-015`) mixed vs coherent fixture | `needs-confirmation` |
|
||||
| compatibility comparison이 string lexical compare를 쓰지 않는다 | comparator 미구현 | comparator unit test에 lexical-trap fixture(예: `"10"` vs `"9"`) 투입 → 정확 판정 확인 | `planned` |
|
||||
| atomic active-pointer 전환 중 HTML과 asset이 서로 다른 release인 window가 없다 | atomic primitive 미확정 | 배포 시뮬레이션 중 boot e2e + reachability probe | `needs-confirmation` |
|
||||
| service worker가 실제로 등록되지 않는다(D3) | 코드 없음 | production build 산출물 scan + e2e에서 SW registration 부재 확인 | `planned` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
- 스캐폴딩 단계: `/coverage` 실행 전 수동 행을 만들지 않는다.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 없음 — 스캐폴딩 단계.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
| `FE-OC-019@1` | [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] | browser bundle에 secret을 넣지 않고 untrusted HTML injection을 기본 금지 | import 참조로 적용 |
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
<!-- GENERATED: artifact-imports:start -->
|
||||
### 가져온 artifact 계약
|
||||
|
||||
| Artifact Ref | Owner | Producer | Schema Ref |
|
||||
|---|---|---|---|
|
||||
| `ART-FE-001@1` | [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] | [[raw/branch-notes/feature-frontend-project-bootstrap-toolchain-contract]] | `harness/source/artifact-schemas/ca-skeleton-frontend/build-manifest.schema.json` |
|
||||
<!-- GENERATED: artifact-imports:end -->
|
||||
|
||||
- 없음 — 자식 자료는 생성 후 controller가 parent Cluster와 함께 등록한다.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
- 없음 — daily note는 이 작업에서 수정하지 않는다.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크: 없음
|
||||
- 리뷰 메모: 없음
|
||||
- 머지 결과 / 배포 환경: `planned`
|
||||
- **wiki 추출 대상** (verified만): 없음
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned): 현재 전체
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/10-projects/ca-skeleton-frontend-operational-contract/branch-notes/feature-frontend-render-recovery-boundary-contract.md
|
||||
@@ -0,0 +1,336 @@
|
||||
---
|
||||
title: branch / feature-frontend-render-recovery-boundary-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-015
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-frontend-operational-contract
|
||||
work_item: WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-015
|
||||
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-UI-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-ARCHITECTURE-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-007, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-013]
|
||||
contract_packet: 1
|
||||
branch: feature-frontend-render-recovery-boundary-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton-frontend, ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-frontend-operational-contract.md]
|
||||
tags: [branch, ca-skeleton, frontend, error-handling, react]
|
||||
created: 2026-07-18
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 2c336d6be17e196dcbbbf75409f97f8ff916672d7b634db5e6cf3e568000b57b
|
||||
imports: [FE-OC-008@1, FE-OC-011@1, FE-OC-014@1]
|
||||
accepts_delegations: [DELEG-FE-006@1]
|
||||
|
||||
---
|
||||
|
||||
# branch: feature-frontend-render-recovery-boundary-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 TODO·결정·진행 기록. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출한다. 현재는 `/branch-spec` 자동 채움 단계이며 frontend 코드가 없으므로 모든 진술은 `planned`다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-frontend-operational-contract]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: boot·route·feature·async boundary ownership과 recovery fixture가 검증된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-UI-001@1` | UI composition은 React를 사용한다 | React render boundary와 recovery surface에 적용한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-ARCHITECTURE-001@1` | domain, application, presentation, adapters, bootstrap 책임을 분리한다 | boot·route·feature·async boundary ownership과 adapter seam에 적용한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | operational failure와 render defect를 분리한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D2 | boot·route·feature·async boundary ownership을 고정한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D3 | route별 error surface owner를 하나로 제한한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D4 | release pair별 controlled reload를 한 번으로 제한한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D5 | boot validation failure 시 product route 대신 boot shell을 렌더한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
| D6 | render failure telemetry를 best-effort로 emit한다 | `local` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | `proposed` |
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
없음.
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
이 브랜치는 project-wide 계약 `FE-OC-015`("expected operational error 와 render defect 를 MUST 분리하고 reload loop 를 금지")를 *구현 착수 가능한 상세 명세*로 내린다. 핵심은 두 불변식이다. (1) **분리(separation)** — 정규화된 *운영 실패*(error-classification 이 낸 26-kind operational error)는 컴포넌트의 *정상 state* 로 반환되며 render error boundary 로 throw 하지 않는다. render boundary 가 잡는 것은 *programmer defect 또는 invariant breach*(렌더 도중 던져진 예외)뿐이다([[raw/project-notes/ca-skeleton-frontend-operational-contract]] §10.1 마지막 문단, §8.2 `RENDER_FAILURE` 행). (2) **reload loop 금지** — chunk/deploy mismatch 복구용 자동 reload 는 §10.2 의 5개 controlled 조건과 `CHUNK_RELOAD_GUARD` 를 만족할 때 정확히 한 번만 허용되고, 같은 release pair 에서 두 번째 실패가 나면 auto reload 를 멈추고 rollback/support surface 로 넘어간다. 이 브랜치는 boot/route/feature/async 4계층 error boundary 의 *ownership*(무엇을 잡고·무엇을 안 잡고·어떻게 복구하는가)을 §10.1 매트릭스로 고정하고, 그 산출물을 세 계약에 기여한다 — `FE-OC-005`(route-level error/loading surface owner 와의 이중 소유 금지), `FE-OC-011`(async surface 의 terminal-error state 를 boundary 가 아닌 정상 state 로 소비), `FE-OC-025`(boot·chunk mismatch runbook 이 호출할 boundary/reload 메커니즘 제공). UI 기술은 React(`FE-D004`), 라우팅은 React Router Declarative Mode(`FE-D008`)를 전제한다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- **operational-error vs render-defect 분리 계약** — 정규화된 운영 실패는 정상 state 로 반환, render boundary 는 defect/invariant breach 만 catch (hub §10.1·§8.2). 등급 `planned`.
|
||||
- **4계층 error boundary ownership 매트릭스** — boot shell / route boundary / feature boundary / async boundary 각각의 catches / does-not-catch / recovery 명세 (hub §10.1). 등급 `planned`.
|
||||
- **controlled reload + `CHUNK_RELOAD_GUARD` state machine** — §10.2 의 5개 조건, release pair 당 1회, 2번째 실패 시 rollback/support (hub §10.2·§5.5·§8.4 `reload-once`). 등급 `planned`.
|
||||
- **boot error shell** — §4.5 boot order 2~4단계 실패 시 product route 미마운트, boot error shell 만 렌더 (hub §4.5·§8.2 `BOOT_CONFIG_FAILURE`). 등급 `planned`.
|
||||
- **render-failure telemetry hook** — `ui.render.failed`(route_id·build_id·component_boundary) best-effort emit, sink 실패가 복구를 막지 않음 (hub §5.8·§10.1). 등급 `planned`.
|
||||
- **recovery fixtures / boundary 테스트** — §20 Measurable completion("boot/route/feature/async boundary ownership + recovery fixtures") + §8.5 관련 negative fixture. 등급 `planned`.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적 제외. 각 항목은 소유 브랜치를 명시(CLAUDE.md §15.5 R3 OUT_OF_BRANCH_SCOPE 방지). 아래 sibling 링크는 hook quirk 회피를 위해 `FE-OC-###` 계약 ID 로만 짝지음(§4b).
|
||||
|
||||
- **실패의 정규화(어떤 exception → 어떤 kind)와 26-kind enum·`action` vocabulary** — [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] 의 `FE-OC-008` 소유. 본 브랜치는 정규화된 kind + `action`(특히 `reload-once`/`retry`/`navigate`)을 *소비*해 boundary 배치·복구만 결정한다.
|
||||
- **async surface state 렌더링**(initial-loading/success/empty/terminal-error 스켈레톤·문안) — [[raw/branch-notes/feature-async-ui-state-contract]] 의 `FE-OC-011` 소유. 본 브랜치는 "operational 실패는 boundary 가 아닌 정상 state 로 간다"는 *seam* 만 정의한다.
|
||||
- **route registry schema(`errorSurface`/`loadingSurface`/`chunkId` 필드)와 navigation guard** — [[raw/branch-notes/feature-routing-navigation-guard-contract]] 의 `FE-OC-005` 소유. 본 브랜치는 route boundary 가 그 owner 필드를 *채우되* 스키마·guard 로직은 정의하지 않는다.
|
||||
- **`CHUNK_RELOAD_GUARD` storage row 등록**(namespace/version/classification/quota fallback) — [[raw/branch-notes/feature-frontend-storage-registry-contract]] 의 `FE-OC-013` 소유. 본 브랜치는 guard 의 *의미*(reload loop 차단)만, 키 등록은 위임.
|
||||
- **release manifest·`DEPLOY_MISMATCH` 신호 생성 + rollback 실행** — [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] 의 `FE-OC-016` 소유. 본 브랜치는 그 신호를 *소비*해 controlled reload/rollback surface 로 분기만 한다.
|
||||
- **runbook 의 trigger/window/escalation/evidence** — [[raw/branch-notes/feature-frontend-operational-runbook-contract]] 의 `FE-OC-025` 소유. 본 브랜치는 그 runbook 이 호출할 boundary/reload 메커니즘만 제공한다.
|
||||
- **telemetry transport/queue/redaction sink** — [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] 의 `FE-OC-014` 소유. 본 브랜치는 `ui.render.failed` payload 계약만.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | §10.1 error boundary ownership 매트릭스·§10.2 reload loop prevention·§8.2 `RENDER_FAILURE`/operational-vs-defect note·§4.5 boot order·§5.5 `CHUNK_RELOAD_GUARD`·§5.8 `ui.render.failed`·§8.4 `reload-once`·§9.3 route error owner 중복 금지 — `FE-OC-015` 의 project-decision SSOT. D1·D2·D3·D4·D5·D6 근거. |
|
||||
| [[raw/official-docs/react-ui-library-official]] | `FE-D004`(UI composition = React). **error boundary claim 은 이 자료에 없음** — `REACT-UI-C1` 은 "React 는 컴포넌트로 구성된다"만 증명하므로 render boundary *기술 전제*(React 사용)만 근거하고, boundary API 는 아래 web-research 로 보강. D2·D5 부분 근거. |
|
||||
| [[raw/official-docs/react-router-official]] | `FE-D008`(routing = React Router Declarative Mode). `REACT-ROUTER-C1`/`C4` 가 client-side route 선언을 근거. **route error element API 는 이 발췌 범위 밖**(archived doc 이 명시) → route boundary 의 정확한 error element 형태는 `UNSUPPORTED_IMPL_DECISION`. D3 부분 근거. |
|
||||
| react.dev 웹 조사(2026-07-19, `react.dev/reference/react/Component`) — 미아카이브 | React error boundary 메커니즘: `static getDerivedStateFromError`(+옵션 `componentDidCatch`)를 가진 컴포넌트가 자식이 *렌더 중* 던진 에러를 잡되 **event handler·async 코드·boundary 자신이 던진 에러는 잡지 않는다**. 이 사실이 "operational 실패는 boundary 로 throw 하지 않는다"(D1)를 강화. **후속: raw/official-docs 로 정식 아카이브 필요**(Claims To Verify). |
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기. 현재 frontend 코드가 없으므로 전부 `planned`.
|
||||
|
||||
- [ ] 4계층 boundary 컴포넌트 배치(`src/presentation/boundaries/*` + boot shell) — boot/route/feature/async catches·does-not-catch·recovery 구현 (D1/D2/D5) — 등급: `planned`
|
||||
- [ ] operational-vs-defect seam — 정규화된 kind 를 정상 state 로, defect 만 boundary 로 라우팅하는 경계 wiring (D1) — 등급: `planned`
|
||||
- [ ] `CHUNK_RELOAD_GUARD` controlled reload state machine — 5조건 순서 + release pair 2회차 중단 (D4) — 등급: `planned`
|
||||
- [ ] boot error shell — composition root 가 §4.5 2~4단계 실패 시 product route 미마운트 (D5) — 등급: `planned`
|
||||
- [ ] `ui.render.failed` best-effort telemetry hook — boundary catch 시 emit, 재귀·차단 없음 (D6) — 등급: `planned`
|
||||
- [ ] recovery fixtures — reload-loop deterministic test + operational-vs-defect fixture + boot invalid-config → boot shell (D1~D5) — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
없음 — `/branch-spec` 자동 채움 단계. 코드 미착수. React error boundary 는 class-component 전용 API(`getDerivedStateFromError`)라는 점을 web 조사로 확인했고, 정식 아카이브는 후속 dispatch 로 남긴다.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 아래 6개 결정은 Decision Evidence Map 의 prose mirror. 근거는 대부분 hub 의 project decision(§10·§8·§4.5·§5)이며, D2/D3 는 외부 자료(React·React Router·react.dev web) 가 기술 전제로 병행 근거.
|
||||
|
||||
- 2026-07-19: **operational error 와 render defect 를 분리 — 정규화된 운영 실패는 정상 state 로 반환, render boundary 는 defect/invariant breach 만 catch** / 이유: 운영 실패를 boundary 로 throw 하면 async·event-handler 경로에서 애초에 안 잡히고(React error boundary 는 그 경로를 catch 하지 않음) 정상 복구 UX(재시도·stale)를 blank crash 로 격하 / 검토한 대안: 모든 실패를 throw 해 단일 boundary 로 처리 — React 가 event/async 를 안 잡으므로 불완전, hub §10.1 default 위반으로 기각 / 근거: hub §10.1·§8.2, react.dev error boundary 조사.
|
||||
- 2026-07-19: **boot / route / feature / async 4계층 boundary ownership 을 §10.1 매트릭스로 고정** / 이유: 계층마다 catch 대상·복구가 달라(config vs lazy chunk vs subtree defect vs 정규화 state) 단일 boundary 는 복구 granularity 를 잃음 / 검토한 대안: 전역 단일 boundary — route 1개·lazy chunk 0·외부 API 0 throwaway(hub §0.4)에서만 / 근거: hub §10.1·§4.5·§9.3.
|
||||
- 2026-07-19: **route error surface 의 이중 소유 금지 — route element 와 React error boundary 중 route 당 정확히 하나가 owner** / 이유: 둘 다 소유하면 같은 render 실패를 두 번 처리하거나 복구가 충돌 / 검토한 대안: 둘 다 두고 우선순위 규칙 — 복잡·모호로 기각 / 근거: hub §9.3 의 *비중복 owner* 원칙(이 결정의 실제 grounding), React Router `REACT-ROUTER-C1`/`C4`(Declarative Mode 의 client-side route 선언). **전제의 한계 명시**: Declarative Mode 가 *route 레벨 error API 자체*(존재 여부·형태)를 제공하는지는 아카이브된 발췌 범위 밖이므로 미확정이다 — 즉 이 결정이 강제하는 것은 "route element 계층에 error API 가 있으면 boundary 와 이중 소유하지 말 것"이라는 비중복 규칙이지, 그 API 의 존재를 주장하는 것이 아니다. 정식 아카이브는 source 후속(Claims To Verify 마지막 행).
|
||||
- 2026-07-19: **controlled reload 는 `CHUNK_RELOAD_GUARD` 로 release pair 당 1회, 2회차 실패 시 중단** / 이유: `ChunkLoadError`/deploy mismatch 를 무한 reload 로 대응하면 boot loop / 검토한 대안: guard 없는 즉시 reload — §8.4 가 금지(`reload-once` MUST NOT: session guard 없이 반복 reload) / 근거: hub §10.2 5조건·§5.5·§8.4·§8.2.
|
||||
- 2026-07-19: **boot config/release 검증 실패 시 product route 미마운트, boot error shell 만 렌더** / 이유: 잘못된 config 로 앱을 띄우면 endpoint mismatch·secret 노출·부분 렌더 위험 / 검토한 대안: 실패해도 기본값으로 진행 — §4.5 가 2~4단계 실패를 hard stop 으로 규정, 기각 / 근거: hub §4.5·§8.2 `BOOT_CONFIG_FAILURE`.
|
||||
- 2026-07-19: **render-failure telemetry(`ui.render.failed`)는 best-effort, sink 실패가 복구·재렌더를 막지 않음** / 이유: 관측이 복구를 blocking 하면 안 됨(operational isolation) / 검토한 대안: 전송 보장 채널 — audit 채널은 별도 owner(FE-OC-014), 기각 / 근거: hub §5.8·§10.1, `FE-D021`.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> `Supporting Claims` 는 hook quirk 회피를 위해 hub 는 plain-text 경로(`...frontend-operational-contract.md §X`)로, official-doc claim 은 plain-text `raw/official-docs/<slug>.md#<CLAIM>` 로, `FE-D###` 는 hub 경로에만 붙여 sibling branch 링크 근처에 두지 않는다(§4b).
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | operational error(정규화된 26-kind 운영 실패)는 정상 컴포넌트 state 로 반환하고 render error boundary 로 throw 하지 않음; boundary 는 programmer defect / invariant breach(렌더 중 throw)만 catch (`FE-OC-015`) | error-classification 이 실패를 총함수로 정규화하는 한(hub `FE-OC-008`) 이 default 유지 / "throw 후 boundary 처리" 대안은 정규화 계층이 없을 때만인데 hub 가 그것을 강제하므로 분기 없음(불변식) | `...frontend-operational-contract.md` §10.1 마지막 문단·§8.2 `RENDER_FAILURE` 행 및 total-function 문단; `raw/official-docs/react-ui-library-official.md#REACT-UI-C1`(React 사용); react.dev `Component`(error boundary 는 event handler·async·boundary 자체 throw 를 catch 안 함 → operational 을 throw 로 흘리면 애초에 미포착) | `project-decision + official-vendor-doc(web, 미아카이브)` | 총함수적 분리는 fixture 로만 증명 — operational 실패가 실수로 throw 되거나 boundary 가 실제 defect 를 operational 로 오분류하면 crash/은닉. exhaustive boundary fixture 필요 |
|
||||
| D2 | boot shell / route boundary / feature boundary / async boundary 4계층 ownership 을 §10.1 매트릭스(각 계층의 catches·does-not-catch·recovery)로 고정 | client SPA + lazy route chunk + async 데이터(React·React Router·TanStack Query) 구성인 한 4계층 default / 전역 단일 boundary 는 route 1개·lazy chunk 0·외부 API 0 throwaway prototype(hub §0.4 escape)에서만 | `...frontend-operational-contract.md` §10.1 boundary 매트릭스·§4.5 boot order(2~4단계 실패→boot error shell)·§9.3 route 동작; `raw/official-docs/react-ui-library-official.md#REACT-UI-C1` | `project-decision` | async boundary 는 실제로 "throw 를 잡는 boundary"가 아니라 정규화 state 소비 surface(§10.1 행) — `FE-OC-011` 과의 소유 seam 이 모호하면 이중 처리. seam 명세 필요 |
|
||||
| D3 | route 당 error surface 는 React Router route element 와 React error boundary 중 *정확히 하나*가 owner; 이중 소유 금지 (규칙의 grounding 은 hub §9.3 비중복 owner 원칙이며, Declarative Mode 의 route-error API 존재 자체를 주장하지 않음) | Declarative Mode(`FE-D008`) 의 route element 계층이 error surface 를 소유할 수 있는 한 route 별 owner 를 하나 지정 / 그 계층에 error API 가 없으면 owner 는 전부 React error boundary 로 고정(규칙 자체는 유지, 위반 여지 소멸) / Data/Framework Mode 로 전환되면(그 mode 의 `errorElement`/loader 계약) 재도출 | `...frontend-operational-contract.md` §9.3("route error element 와 React error boundary 의 owner 를 중복하지 않는다"); `raw/official-docs/react-router-official.md#REACT-ROUTER-C1`, `#REACT-ROUTER-C4`; `...frontend-operational-contract.md` `FE-D008` | `project-decision + conditional-default(React Router)` | archived router doc 은 error element API 를 다루지 않음 → 정확한 error element 형태는 `UNSUPPORTED_IMPL_DECISION`; owner 선정 규칙이 route 별로 일관되지 않으면 §9.3 위반 |
|
||||
| D4 | chunk/deploy mismatch 복구 자동 reload 는 §10.2 5조건(kind∈{`CHUNK_LOAD_FAILURE`,`DEPLOY_MISMATCH`}·release manifest fetch 성공·active release≠current build·`CHUNK_RELOAD_GUARD` unset·guard 선기록 후 reload)을 모두 만족할 때 release pair 당 1회; 같은 pair 2회차 실패 시 auto reload 중단→rollback/support | mismatch 가 감지되고 manifest 가 *더 새로운* release 를 확인할 때만 reload / manifest fetch 실패·같은 pair 이미 guard·storage 불가면 no auto reload(update/support surface) | `...frontend-operational-contract.md` §10.2 5조건·§5.5 `CHUNK_RELOAD_GUARD`(sessionStorage / session / no second auto reload)·§8.4 `reload-once`(MUST NOT: session guard 없이 반복 reload)·§8.2 `CHUNK_LOAD_FAILURE`/`DEPLOY_MISMATCH` 행 | `project-decision` | guard 가 sessionStorage → StoragePort unavailable(private mode)·cross-tab 시 guard 미지속 가능 → fail-safe 로 no-auto-reload 강등 필요. deterministic reload test 로 2회차 중단 증명 |
|
||||
| D5 | boot config/release 검증(§4.5 2~4단계) 실패 시 product route 를 mount 하지 않고 boot error shell 만 렌더; telemetry adapter 생성 실패(7단계)는 console-safe fallback 으로 계속 | boot order 2~4단계(runtime config fetch·schema·compatibility·release manifest) 실패 → boot error shell / telemetry 등 비필수 adapter 실패 → 계속 진행 | `...frontend-operational-contract.md` §4.5 boot order + 실패 규칙·§8.2 `BOOT_CONFIG_FAILURE`/`RELEASE_MANIFEST_FAILURE` 행 | `project-decision` | boot shell 자체가 실패한 config 에 의존하면 안 됨(zero-config 로 렌더 가능해야) — 미검증 시 boot shell 이 같은 실패로 재크래시. boot invalid-config fixture 필요 |
|
||||
| D6 | render-failure telemetry(`ui.render.failed`: route_id·build_id·component_boundary)는 best-effort emit, sink/queue 실패가 복구·재렌더를 막지 않음 | telemetry 가 best-effort isolation(`FE-D021`)인 한 항상 non-blocking / 전송 보장이 필요한 audit event 는 별도 owner(`FE-OC-014`) 채널이므로 본 결정 밖 | `...frontend-operational-contract.md` §5.8 `ui.render.failed` event·§10.1 feature boundary recovery; `...frontend-operational-contract.md` `FE-D021` | `project-decision (transport delegated to FE-OC-014)` | boundary 의 `componentDidCatch` 안 telemetry 호출이 throw 하면 boundary 자신이 throw(react.dev: boundary 자체 throw 는 미포착) → 상위 boundary 로 전파. emit 은 try/catch 로 감싸야 함 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> `planned` blueprint. 경로는 hub §4.6 Planned directory blueprint(`src/presentation/boundaries/`, `src/bootstrap/`) + §5 registry 에서 유도되므로 grounded 하지만 repository 생성 시 바뀔 수 있어 전체 `planned`. sibling 소유 detail 은 §범위 Out of scope 로 위임하고 여기 남기지 않는다(R3).
|
||||
|
||||
### 1. 4계층 error boundary 배치 (`src/presentation/boundaries/` + boot shell)
|
||||
|
||||
> **Trace**: D1 + D2 + D5 / `FE-OC-015`·hub §10.1 매트릭스·§4.5. React error boundary 는 `static getDerivedStateFromError`(+옵션 `componentDidCatch`)를 가진 컴포넌트가 자식의 *렌더 중* throw 를 catch(react.dev 조사).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: boundary 구현 방식(hand-rolled class vs `react-error-boundary` 라이브러리) — hub·archived doc 미규정. hand-rolled class 컴포넌트(외부 의존 0) 제안. trade-off: 라이브러리는 reset/fallback API 가 편하지만 supply-chain(`FE-OC-018`) 표면 추가; class 는 boilerplate 지만 의존 0.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: boundary 컴포넌트·파일명(hub 는 `presentation/boundaries/` 폴더만 grounding) — `RouteErrorBoundary.jsx`/`FeatureErrorBoundary.jsx`/`BootErrorShell.jsx` 제안. trade-off: 이름 임의, "presentation/boundaries 내부 + 계층당 1 컴포넌트" 제약만 유지하면 계약 동등.
|
||||
|
||||
| 계층 | catches (hub §10.1) | does NOT catch | recovery | planned 배치 |
|
||||
|---|---|---|---|---|
|
||||
| boot shell | config/release/bootstrap 실패 | product route error | config refetch·support·rollback signal | `src/bootstrap/` composition root (D5) |
|
||||
| route boundary | route 의 lazy chunk / render 실패 | expected API result(정규화 state) | route retry 또는 controlled reload(D4) | `presentation/boundaries/` route 래핑 |
|
||||
| feature boundary | 컴포넌트 subtree render defect | 정규화된 operational failure | component reset | `presentation/boundaries/` subtree 래핑 |
|
||||
| async boundary | 정규화된 query/mutation state | throw 된 render defect | registry `action` | `FE-OC-011` async surface 와 공유 seam(D2) |
|
||||
|
||||
### 2. operational-vs-defect seam (정규화 kind 라우팅)
|
||||
|
||||
> **Trace**: D1 / `FE-OC-015`·hub §10.1·§8.2. error-classification(`FE-OC-008`)이 낸 정규화 kind 를 *소비*만 하며 정규화 자체는 하지 않는다(R3 위임).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — 분기 원칙은 hub §10.1(operational→정상 state, defect→boundary)이 직접 grounding. 실제 kind→state/action 매핑 값은 `FE-OC-008`/`FE-OC-011` 소유.
|
||||
|
||||
```text
|
||||
정규화된 failure(kind, action) 수신 → async/feature 계층의 정상 state 로 렌더 (terminal-error/stale-degraded 등, action 은 FE-OC-011 소유)
|
||||
렌더 중 throw(non-normalized) 발생 → 가장 가까운 feature/route boundary 가 catch → RENDER_FAILURE recovery
|
||||
boundary 가 catch 한 값이 정규화 실패로 판명 → 재-throw 금지, RENDER_FAILURE 로 처리(operational 은닉 방지는 fixture 로 검증)
|
||||
```
|
||||
|
||||
### 3. controlled reload + `CHUNK_RELOAD_GUARD` state machine
|
||||
|
||||
> **Trace**: D4 / `FE-OC-015`·hub §10.2·§5.5·§8.4. guard 저장은 StoragePort 경유(`FE-OC-013` 소유 registry 의 `CHUNK_RELOAD_GUARD` row 를 *소비*).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: reload 결정 로직 위치(boundary 내부 vs release adapter) — hub 미규정. release adapter(`ReleaseInfoPort` 구현, §4.4)가 mismatch 판정, boundary 는 그 결과로 reload/rollback surface 분기 제안. trade-off: adapter 집중이 test 용이하나 boundary→adapter 호출 경계 추가.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: guard 값 shape(§5.5 는 "session / no second auto reload"만) — `<activeReleaseId>:<currentBuildId>` pair 키 + boolean 제안. trade-off: pair 키여야 "같은 pair 2회차"를 판별; 단일 flag 면 서로 다른 release 간 오차단.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: dirty-state 선경고 훅의 호출 위치 — [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §10.2 의 5조건은 warn-first 를 포함하지 않고, §16.2 immediate containment 1단계가 "current user input 이 있으면 destructive reload 전에 경고"를 *별도로* 규정한다(두 절의 결합 지점은 hub 미규정). 조건 4 통과 후·조건 5(guard 기록 → reload) 직전 호출 제안. trade-off: 이 위치면 경고가 실제 reload 직전 1회만 뜨고 사용자가 취소해도 guard 미기록이라 이후 재시도가 가능하다; 앞으로 당기면(조건 1 직후) mismatch 도 아닌 경우까지 경고해 소음이 된다.
|
||||
|
||||
```text
|
||||
1. failure kind ∈ {CHUNK_LOAD_FAILURE, DEPLOY_MISMATCH} ? 아니면 → reload 안 함
|
||||
2. release manifest fetch 성공 ? 실패 → no reload, update/support(RELEASE_MANIFEST_FAILURE 는 FE-OC-016 소유)
|
||||
3. active release ≠ current build ? 같으면 → no reload(mismatch 아님)
|
||||
4. CHUNK_RELOAD_GUARD[pair] unset ? set 이면 → auto reload 중단, rollback/support surface
|
||||
4b. dirty-state 선경고 훅(runbook 소유) 호출 → 사용자가 취소하면 reload 안 함(guard 미기록)
|
||||
5. guard[pair] 기록 후 → 1회 reload
|
||||
```
|
||||
|
||||
> **각주 — dirty-state seam 교차 참조**: 위 4b 는 본 브랜치가 새로 만드는 정책이 아니라 *이미 존재하는 두 계약을 명시적으로 잇는 자리*다. [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §19 의 `FE-RISK-009`("chunk auto reload 가 user input 손실", mitigation = dirty-state guard + one reload cap)는 두 짝으로만 닫힌다 — *one reload cap* 은 본 절의 guard 가 제공하고, *dirty-state guard(warn-first)* 는 §16.2 immediate containment 1단계가 규정한다.
|
||||
>
|
||||
> 그 warn-first step 과 위 risk row 의 owner 는 [[raw/branch-notes/feature-frontend-operational-runbook-contract]] (`FE-OC-025`) 이다. 본 브랜치는 훅을 *호출*만 하며 "무엇이 dirty 인가"의 판정 기준·경고 문안·취소 UX 는 그 소유다(R3 위임). 이 seam 을 적지 않으면 본 절의 자동 reload 가 runbook 의 warn-first 가정을 조용히 우회하고, 두 문서가 *암묵적으로만* 일관된 상태로 남는다.
|
||||
|
||||
### 4. boot error shell
|
||||
|
||||
> **Trace**: D5 / `FE-OC-015`·hub §4.5·§8.2. composition root(`src/bootstrap/composition-root.js`, §4.5)가 boot order 를 소유.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: boot shell 컴포넌트명·위치(hub 는 `bootstrap/` 만) — `src/bootstrap/BootErrorShell.jsx` + composition-root 가 2~4단계 실패 시 이것만 mount 제안. trade-off: 이름 임의; "zero runtime config 로 렌더 가능 + product route 미마운트" 제약만 유지.
|
||||
|
||||
- boot order §4.5 의 2단계(runtime config fetch)~4단계(release manifest 정합) 실패 → `BOOT_CONFIG_FAILURE`/`RELEASE_MANIFEST_FAILURE` → boot error shell 만 렌더(product route 미마운트).
|
||||
- 7단계(telemetry adapter) 생성 실패 → console-safe fallback, boot 계속(§4.5).
|
||||
- boot shell 은 실패한 config 에 의존 불가 — build-time 상수(§6.1 build-time public)만 참조.
|
||||
|
||||
### 5. render-failure telemetry hook
|
||||
|
||||
> **Trace**: D6 / `FE-OC-015`·hub §5.8·§10.1. transport/redaction sink 는 `FE-OC-014` 소유(R3) — 본 절은 emit 시점·payload 계약만.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음(payload 는 §5.8 이 required attribute 를 grounding). emit 실패 격리 방식만: `componentDidCatch` 내 emit 을 try/catch 로 감싸 재귀·전파 차단 제안(react.dev: boundary 자체 throw 는 상위로 전파).
|
||||
|
||||
- `componentDidCatch`(또는 등가 hook)에서 `ui.render.failed`{route_id, build_id, component_boundary} best-effort emit.
|
||||
- emit 은 try/catch — 실패해도 fallback UI 렌더·recovery 를 막지 않음(§10.1·§5.8).
|
||||
|
||||
### 6. boundary 테스트 (§20 Measurable completion)
|
||||
|
||||
> **Trace**: D1 + D2 + D3 + D4 + D5 / hub §20("boot/route/feature/async boundary ownership + recovery fixtures")·§8.5·§10.2.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: test 파일 경로·러너 배치(hub §4.6 은 `tests/component|integration` 폴더만) — `tests/component/boundaries/*` + `tests/integration/reload-guard/*` 제안. trade-off: 경로 임의, "component 레벨 boundary + integration 레벨 reload state machine" 계약만 유지.
|
||||
|
||||
| Fixture | 기대 결과 |
|
||||
|---|---|
|
||||
| async operation 실패(정규화 kind) | boundary 미발동, async surface 의 terminal-error/stale state 로 렌더(operational 은 정상 state) |
|
||||
| 컴포넌트 render 중 throw | 가장 가까운 feature/route boundary 가 catch → `RENDER_FAILURE` recovery |
|
||||
| boundary 자체 throw | 상위 boundary/boot shell 로 전파(react.dev), 무한 루프 없음 |
|
||||
| `CHUNK_LOAD_FAILURE` 1회차 + manifest 새 release | guard 기록 후 1회 reload |
|
||||
| 같은 release pair 2회차 실패 | auto reload 중단 → rollback/support surface(§10.2) |
|
||||
| StoragePort unavailable | guard 미지속 → fail-safe no-auto-reload |
|
||||
| boot invalid runtime config | product route 미마운트, boot error shell 렌더(D5) |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- *boundary 자체가 render 중 throw* → React error boundary 는 자신이 던진 에러를 catch 하지 않음(react.dev) → 상위 boundary 또는 boot shell 이 처리. 최상위(boot shell)까지 throw 되면 최소 static crash surface.
|
||||
- *event handler / async(setTimeout 등)에서 발생한 에러* → React error boundary 미포착(react.dev) → 반드시 error-classification 이 정규화한 operational failure 로 다뤄 정상 state 로 표현(D1). boundary 에 의존하면 blank crash.
|
||||
- *StoragePort unavailable/quota*(private mode 등) → `CHUNK_RELOAD_GUARD` 미지속 → fail-safe 로 auto reload 강등(no reload, update/support). guard 부재를 "unset"으로 오해해 무한 reload 하면 안 됨.
|
||||
- *release manifest fetch 실패* → controlled reload 2단계 불충족 → no reload; `RELEASE_MANIFEST_FAILURE` 자체 생성은 `FE-OC-016` 소유.
|
||||
- *route element 와 boundary 이중 소유* → 같은 실패 두 번 처리/복구 충돌 → route 당 owner 1개(D3)로 정적 방지.
|
||||
- **다른 계약 의존**(hook quirk 회피: sibling 링크는 `FE-OC-###` 로만 짝지음):
|
||||
- [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] (`FE-OC-008`) — 정규화된 kind + `action` 을 *생성*. 그 계약(어떤 exception→어떤 kind, operational vs `RENDER_FAILURE` 구분)이 바뀌면 본 브랜치 seam(D1) 재조정. 해당 sibling 은 error boundary·reload-guard 소유를 이미 본 브랜치로 위임함.
|
||||
- [[raw/branch-notes/feature-async-ui-state-contract]] (`FE-OC-011`) — async surface state(initial-loading/success/empty/terminal-error) 렌더를 *소유*. 본 브랜치는 "operational 은 boundary 아닌 정상 state" seam(D2)만; state 문안·스켈레톤은 그 소유.
|
||||
- [[raw/branch-notes/feature-routing-navigation-guard-contract]] (`FE-OC-005`) — route registry(`errorSurface`/`chunkId`)를 *소유*. route boundary 가 그 owner 필드를 채우되 스키마는 그 소유(D3).
|
||||
- [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] (`FE-OC-016`) — release manifest·`DEPLOY_MISMATCH` 신호·rollback 실행을 *생성/소유*. 본 브랜치는 소비해 reload/rollback 분기(D4).
|
||||
- [[raw/branch-notes/feature-frontend-storage-registry-contract]] (`FE-OC-013`) — `CHUNK_RELOAD_GUARD` storage row 를 *소유*. 본 브랜치는 guard 의미만(D4).
|
||||
- [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] (`FE-OC-004`) — build/runtime/secret 분리와 **boot 전 runtime config 검증을 *소유*하며, 그 검증 실패가 본 브랜치 boot error shell 을 발동시키는 `BOOT_CONFIG_FAILURE` 신호를 *생성*** 한다(D5). hub §20 이 그 브랜치의 measurable completion 을 "build/runtime/secret registry + boot invalid matrix" 로 규정하므로 *어떤 config 가 invalid 인가*의 판정은 그쪽 소유이고, 본 브랜치는 그 신호를 소비해 "product route 미마운트 + shell 렌더" 분기만 한다. invalid matrix 의 kind 매핑(§8.2 행)이 바뀌면 D5·구현 가이드 §4 재조정.
|
||||
- [[raw/branch-notes/feature-frontend-clean-architecture-layering-contract]] (`FE-OC-002`) — **boot error shell 을 호스팅하는 composition root(`bootstrap` 단일 root)를 *소유*** 한다(hub §4.2 `bootstrap` 행: config load·adapter construction·DI·React mount). 본 브랜치는 그 host 를 *소비*하는 쪽이며, root 가 adapter 를 주입하는 wiring 컨벤션(주입 순서·DI 형태·단일 root 불변식)은 그쪽 소유다(R3 위임). 본 브랜치가 명세하는 것은 hub §4.5 boot order 2~4단계 실패 시의 *분기 규칙*(shell 만 mount)뿐이다(D5).
|
||||
- [[raw/branch-notes/feature-frontend-operational-runbook-contract]] (`FE-OC-025`) — boot·chunk mismatch runbook 을 *소유*. 본 브랜치가 제공하는 boundary/reload 를 *소비*. 역방향으로, runbook 이 소유한 destructive-reload 선경고 step 을 본 브랜치 reload state machine 이 *호출*한다(구현 가이드 §3 각주).
|
||||
- [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] (`FE-OC-014`) — telemetry transport/sink 를 *소유*. 본 브랜치는 `ui.render.failed` payload 계약만(D6).
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| reload loop 가 실제로 차단된다 — 같은 release pair 2회차 실패 시 auto reload 안 함 | state machine·guard 미구현 | deterministic reload-guard test — 1회차 reload 기록 후 2회차 → reload 미호출 assert(§10.2) | `needs-confirmation` |
|
||||
| operational 실패가 render boundary 에 절대 도달하지 않고, defect 만 도달한다 | seam 미구현, 오분류 가능 | fixture: async operational 실패 → terminal-error state(boundary 미발동) / 렌더 throw → boundary catch → `RENDER_FAILURE` | `needs-confirmation` |
|
||||
| boot error shell 이 실패한 runtime config 에 의존하지 않고 렌더된다 | boot shell 미작성 | boot invalid-config matrix → product route 미마운트 + shell 렌더, shell 이 runtime config 미참조 assert | `needs-confirmation` |
|
||||
| boundary 의 `componentDidCatch` telemetry emit 이 재귀·전파를 일으키지 않는다 | emit try/catch 미구현 | telemetry sink throw mock → boundary 가 재-throw 안 하고 fallback 렌더 assert | `needs-confirmation` |
|
||||
| StoragePort unavailable 시 guard 가 fail-safe(no-auto-reload)로 강등된다 | fallback 경로 미설계 | storage unavailable mock → reload 미호출 + update/support surface assert | `needs-confirmation` |
|
||||
| React error boundary 가 event-handler·async·자체 throw 를 catch 하지 않는다는 전제 | react.dev web 조사만, vault 미아카이브 | `react.dev/reference/react/Component` 를 `wiki-source-summarizer` 로 `raw/official-docs/` 정식 아카이브(verbatim quote + self-grep) 후 D1 근거 승격 | `planned` |
|
||||
| route 당 error surface owner 가 정확히 하나다(이중 소유 없음) | route element API 미확정(archived doc 미포함) | route element vs boundary owner 지정 규칙 test + React Router error element 공식 문서 보강 | `planned` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
> `/coverage` 가 채우는 생성물이며 손으로 유지하지 않는다.
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| TODO — `/coverage` 실행 전 | missing | (없음) | 미평가 | TODO |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: received-delegations:start -->
|
||||
### 수신한 위임
|
||||
|
||||
| Delegation Ref | From | Concern | Status |
|
||||
|---|---|---|---|
|
||||
| `DELEG-FE-006@1` | [[raw/branch-notes/feature-async-ui-state-contract]] | `fe.deleg.reload-once-action` | accepted |
|
||||
<!-- GENERATED: received-delegations:end -->
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
| `FE-OC-008@1` | [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] | 모든 failure는 stable frontend error kind로 MUST 정규화하고 raw body·stack을 UI에 노출하면 안 됨 | import 참조로 적용 |
|
||||
| `FE-OC-011@1` | [[raw/branch-notes/feature-async-ui-state-contract]] | async surface는 initial-loading, success, empty, terminal-error를 MUST 표현 | import 참조로 적용 |
|
||||
| `FE-OC-014@1` | [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] | telemetry는 best-effort이며 render·API success를 차단하면 안 되고 PII·token을 전송하면 안 됨 | import 참조로 적용 |
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
### Sub-branches (세부 작업)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 오류 기록 (이 branch 작업 중 발생)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### 강의 (이 작업을 위해 학습한 강의)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
### job-posting tie-ins (이 작업에서 파생된 글감)
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
없음 — scaffolding 단계
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user