feat: 공식 문서 근거자료, 브랜치 기능 문서 작성
This commit is contained in:
@@ -26,6 +26,10 @@ contract_packet_sha256: 928d7721870b6023078790c09e8a4319b34e3a3a37e0e3b0cfa9666b
|
||||
|
||||
> Layer: `raw/branch-notes/` — background job, scheduler, async executor 기준을 정의합니다.
|
||||
|
||||
> [!note] 귀속 정정 (2026-07-28)
|
||||
> 최초 표기는 본 branch 를 multi-instance 활성화 검증(H8)의 이전 원천으로 적었으나 **오류**였다. 실제 owner 는 [[raw/branch-notes/feature-env-driven-runtime-configuration]] D8 이다 (설계 `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §5.1 정정 참조).
|
||||
> 본 branch D3 은 그 계약의 **소비자**(`distributedLockProvider` bean 필요)로 남으며 이전 대상이 아니다. 다만 판정 메커니즘이 bean 이름 presence 에서 typed descriptor 로 바뀌면 D3 의 표현을 그에 맞춰 갱신해야 한다 — 새 owner: [[raw/branch-notes/feature-capability-provider-selection-contract]].
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
|
||||
@@ -25,6 +25,17 @@ contract_packet_sha256: 6d4978b50ddf3cab75e6803a198f9ca53753f9812e8f3a158f96c354
|
||||
|
||||
> Layer: `raw/branch-notes/` — cache consistency와 Redis/cache adapter 기준을 정의합니다.
|
||||
|
||||
> [!important] 주도권 이전 고지 (2026-07-28)
|
||||
> 아래 관심사의 **owner 가 신규 branch 로 이동**했다. 근거·절차: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §5.
|
||||
>
|
||||
> | 이전 ID | 대상 D-row | 이전한 관심사 | 신규 owner |
|
||||
> |---|---|---|---|
|
||||
> | H3 | D3·D4·D5·D6·D7·D8·D9 | cache 전략 — stampede·TTL·직렬화·negative·cluster | [[raw/branch-notes/feature-cache-strategy-registry-contract]] |
|
||||
>
|
||||
> **본문은 아직 제거하지 않았다.** 신규 branch 는 현재 스캐폴딩 상태(D-row 없음)이므로, 지금 본문을 지우면 결정 근거가 소실된다. 위 D-row 는 신규 branch 의 `/branch-spec` 이 재판정을 마칠 때까지 **잠정 근거**로만 유효하며, 그 시점에 설계 §5.2 6단계에 따라 포인터(`superseded → [[<신규 branch>]] D<m>`)로 치환한다.
|
||||
>
|
||||
> 이 기간 중 새 결정을 본 branch 에 추가하지 않는다 — 신규 owner 에 작성한다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
title: branch / feature-cache-strategy-registry-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-cache-strategy-registry-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||||
tags: [branch, ca-skeleton, cache, strategy, stampede, ttl]
|
||||
created: 2026-07-28
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-067
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-067
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CACHE-STRATEGY-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CACHE-FAILURE-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-061]
|
||||
imports: []
|
||||
delegates: []
|
||||
accepts_delegations: []
|
||||
contract_packet: 1
|
||||
---
|
||||
|
||||
# branch: feature-cache-strategy-registry-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
> **스캐폴딩 상태** — 결정(D-row)·구현 가이드는 비어 있다. `/branch-spec feature-cache-strategy-registry-contract` 로 채운다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> 분해 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §4.2 — 능력 계약 (Tier C). 본 branch 는 project §8.0 `WI-CA-SKELETON-OPERATIONAL-CONTRACT-067` 의 실행 단위다.
|
||||
|
||||
형제 branch (같은 부모의 다른 자식 — 인접 영역):
|
||||
|
||||
- [[raw/branch-notes/feature-cache-consistency-contract]]
|
||||
- [[raw/branch-notes/feature-cachestore-multi-backend-router]]
|
||||
- [[raw/branch-notes/feature-redis-runtime-role-isolation-contract]]
|
||||
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
> project Work Item 에서 내려온 실행 계약의 snapshot. 여기에는 **pinned pointer + 1줄 요약 + branch 적용점**만 쓰고 상세를 복제하지 않는다.
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: 전략 registry·TTL 4종·single-flight·실패 등급 분리 test 가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CACHE-STRATEGY-001@1` | cache 기본 전략은 cache-aside와 after-commit 무효화이고 SWR·refresh-ahead·L1L2·분산 stampede 억제는 opt-in이며 maxmemory-policy는 배포 설정이 소유한다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CACHE-FAILURE-001@1` | optional cache의 backend 실패는 fail open이고 codec·프로그래밍 결함은 해당 연산을 fail closed로 처리하며 cache miss로 위장하지 않는다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다. 근거 없이 추측해 채우지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- `WI-CA-SKELETON-OPERATIONAL-CONTRACT-067` 의 완료 조건을 구현한다: 전략 registry·TTL 4종·single-flight·실패 등급 분리 test 가 통과한다
|
||||
- **주도권 이전 수신** — H3 ([[raw/branch-notes/feature-cache-consistency-contract]] D3~D9) · H4 ([[raw/branch-notes/feature-cachestore-multi-backend-router]] D1~D3). 이전 절차는 `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §5.2 6단계를 따른다. 기존 branch 의 D-row 는 `/branch-spec` 으로 본 branch 결정이 확정된 뒤 포인터로 치환한다.
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- `CacheRegionPort` 결과 모델 — HIT / MISS / NEGATIVE_HIT / DEGRADED
|
||||
- positive·negative·soft·hard TTL 과 jitter
|
||||
- process-local single-flight 과 분산 stampede 억제 (opt-in)
|
||||
- 전략 registry — cache-aside + after-commit 무효화 기본, SWR·refresh-ahead·probabilistic·L1L2 opt-in
|
||||
- backend 실패(fail open) 와 codec·프로그래밍 결함(fail closed) 의 등급 분리
|
||||
- 대량 무효화 — 세대 키와 SCAN+UNLINK, 정기 `KEYS` 금지
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- Redis 연결·키·코덱 물리 형식 — #061 소유
|
||||
- `maxmemory-policy` 값 선택 — 배포 설정 소유
|
||||
- 도메인별 캐시 대상 선정
|
||||
- 심층 명세 — 본 노트는 스캐폴딩. D-row 와 §구현 가이드는 `/branch-spec` 이 채운다.
|
||||
- project decision registry 변경 — owner 는 project-note
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 외부 근거 미등록. `/branch-spec feature-cache-strategy-registry-contract` 단계에서 verbatim 인용과 함께 `raw/official-docs/` 로 수집한 뒤 여기서 링크한다.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| (미등록) | 1차 수집 대상: AWS/Redis 공식 — caching strategies / key eviction · ca-tmpl platform 설계 §8.4 (기존 raw 재사용: cache-aside-vs-write-through-aws) |
|
||||
|
||||
**프로젝트 내부 설계 참조 (등급 `internal-design-doc` — 공식 문서 아님, best practice 로 격상 금지):**
|
||||
|
||||
- ca-tmpl `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md`
|
||||
- llm-wiki `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md`
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [ ] `/branch-spec feature-cache-strategy-registry-contract` 로 D-row·§구현 가이드 작성 — 등급: `planned`
|
||||
- [ ] 전략 registry·TTL 4종·single-flight·실패 등급 분리 test 가 통과한다 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
project 결정 외 branch-local 결정은 아직 없음. `/branch-spec` 단계에서 기록한다.
|
||||
|
||||
<!-- section-id: decision-evidence -->
|
||||
## 결정-근거 매핑
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
`/branch-spec` 단계에서 source claim 기반으로 작성한다. 3-rule meta principle(R1 Reference 필수 / R2 UNSUPPORTED_IMPL_DECISION 명시 / R3 OUT_OF_BRANCH_SCOPE 정제) 적용.
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**: `/branch-spec` 단계에서 구체화한다.
|
||||
- **다른 계약 의존**: `WI-CA-SKELETON-OPERATIONAL-CONTRACT-061`([[raw/branch-notes/feature-redis-runtime-role-isolation-contract]]) 의 계약에 의존
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 본 branch 가 소유할 관심사가 sibling branch 결정과 겹치지 않는다 | 스캐폴딩 시점에는 D-row 가 없어 경계가 문장으로만 존재 | `/branch-spec` 후 `/sync` 실행 — owner 중복 검출 | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
`/coverage` 실행 전.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
해당 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -25,6 +25,17 @@ contract_packet_sha256: 42ff5787aebde944ffe6a393e9d9f1621e3c75fc5abb1ba1e85d64d6
|
||||
|
||||
> Layer: `raw/branch-notes/` — 캐시 다중 백엔드 라우터 계획의 단계별 결정·진행 기록.
|
||||
|
||||
> [!important] 주도권 이전 고지 (2026-07-28)
|
||||
> 아래 관심사의 **owner 가 신규 branch 로 이동**했다. 근거·절차: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §5.
|
||||
>
|
||||
> | 이전 ID | 대상 D-row | 이전한 관심사 | 신규 owner |
|
||||
> |---|---|---|---|
|
||||
> | H4 | D1·D2·D3 | cache 실패 등급 — fail-open 경계 (codec 결함을 miss 로 위장하지 않음) | [[raw/branch-notes/feature-cache-strategy-registry-contract]] |
|
||||
>
|
||||
> **본문은 아직 제거하지 않았다.** 신규 branch 는 현재 스캐폴딩 상태(D-row 없음)이므로, 지금 본문을 지우면 결정 근거가 소실된다. 위 D-row 는 신규 branch 의 `/branch-spec` 이 재판정을 마칠 때까지 **잠정 근거**로만 유효하며, 그 시점에 설계 §5.2 6단계에 따라 포인터(`superseded → [[<신규 branch>]] D<m>`)로 치환한다.
|
||||
>
|
||||
> 이 기간 중 새 결정을 본 branch 에 추가하지 않는다 — 신규 owner 에 작성한다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
---
|
||||
title: branch / feature-capability-provider-selection-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-capability-provider-selection-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||||
tags: [branch, ca-skeleton, capability-platform, provider-selection, readiness]
|
||||
created: 2026-07-28
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-060
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-060
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-SELECTION-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-READINESS-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
imports: []
|
||||
delegates: []
|
||||
accepts_delegations: []
|
||||
contract_packet: 1
|
||||
---
|
||||
|
||||
# branch: feature-capability-provider-selection-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
> **스캐폴딩 상태** — 결정(D-row)·구현 가이드는 비어 있다. `/branch-spec feature-capability-provider-selection-contract` 로 채운다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> 분해 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §4.2 — 플랫폼 (Tier P). 본 branch 는 project §8.0 `WI-CA-SKELETON-OPERATIONAL-CONTRACT-060` 의 실행 단위다.
|
||||
|
||||
형제 branch (같은 부모의 다른 자식 — 인접 영역):
|
||||
|
||||
- [[raw/branch-notes/feature-env-driven-runtime-configuration]]
|
||||
- [[raw/branch-notes/feature-integration-adapter-templates]]
|
||||
- [[raw/branch-notes/feature-runtime-health-lifecycle-contract]]
|
||||
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
> project Work Item 에서 내려온 실행 계약의 snapshot. 여기에는 **pinned pointer + 1줄 요약 + branch 적용점**만 쓰고 상세를 복제하지 않는다.
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: capability descriptor·provider 선택·readiness 등급 검증 test 가 통과하고 bean 이름 기반 판정이 제거된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-SELECTION-001@1` | production capability는 available by default이고 inactive by default이며 활성화는 typed descriptor 기반 provider 선택으로만 이뤄진다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-READINESS-001@1` | 모든 capability는 R0~R3 readiness 등급과 capability card를 가지며 R0 seam을 R2 provider로 표기하지 않는다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 상세 근거와 선택 조건은 아래 `## 결정-근거 매핑`의 동일 D-row 가 소유한다. 여기에는 요약과 관계만 둔다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | 런타임 능력 어휘를 use-case capability 와 분리 | `local` | `internal-code-fact` | `proposed` |
|
||||
| D2 | 활성화 SSOT 는 provider/mode/boolean 중 한 축만 | `local` | `spring-boot-condition-annotations.md#SB-COND-C1` | `proposed` |
|
||||
| D3 | 활성화 property 는 단일 prefix + `APP_*` env registry 주입 | `refines DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-SELECTION-001@1` | `config-spring-boot-externalized-configuration.md#SPRING-EXTCONFIG-C7` | `proposed` |
|
||||
| D4 | production capability 에 `matchIfMissing = true` 금지 (프레임워크 기본값 유지) | `local` | `spring-boot-conditional-on-property-javadoc.md#SB-COND-JD-C1` | `proposed` |
|
||||
| D5 | 활성화·안전 판정은 typed descriptor 로 (bean 이름 아님) | `local` | `spring-boot-condition-annotations.md#SB-COND-C3` | `proposed` |
|
||||
| D6 | 토폴로지 검증은 refresh 완료 전, 실패 시 기동 거부 | `local` | `spring-boot-application-availability-startup.md#SB-AVAIL-C4` | `proposed` |
|
||||
| D7 | readiness 반영은 descriptor 의 `readinessImpact` 로 판정 | `local` | `runtime-health-spring-actuator-groups.md#SB-HEALTH-C12` | `proposed` |
|
||||
| D8 | 모든 capability 에 R0~R3 등급 + 등급별 증거 | `refines DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-READINESS-001@1` | `UNSUPPORTED_DECISION` | `proposed` |
|
||||
| D9 | capability card 에 보증·비보증 + unsafe recipe ↔ 대체 + 경합 test 이름 | `refines DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-READINESS-001@1` | `UNSUPPORTED_DECISION` | `proposed` |
|
||||
| D10 | startup 실패는 기존 registry code 재사용, 신규 code 금지 | `local` | `internal-code-fact` | `proposed` |
|
||||
| D11 | descriptor registry 는 governance 스키마 준수 + 별도 파일 | `local` | sibling [[raw/branch-notes/feature-contract-registry-governance]] D4 | `proposed` |
|
||||
| D12 | project note §11 실패 정책 매트릭스 채택 + `failurePolicy` 맵 파생 | `local` | project note §11 (owner 선언) | `proposed` |
|
||||
| D13 | 비활성 capability 는 연결·워커·스키마·health contributor 미생성 | `local` | `spring-boot-condition-annotations.md#SB-COND-C1` | `proposed` |
|
||||
| D14 | legacy 키 병행 시 SSOT 불일치는 startup 오류 | `local` | project note §36.3 규범 3 | `proposed` |
|
||||
| D15 | capability card 필수 항목은 §36.2 원문 상속, narrow 금지 | `refines DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-READINESS-001@1` | project note §36.2 | `proposed` |
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- `WI-CA-SKELETON-OPERATIONAL-CONTRACT-060` 의 완료 조건을 구현한다: capability descriptor·provider 선택·readiness 등급 검증 test 가 통과하고 bean 이름 기반 판정이 제거된다
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- capability descriptor 필수 필드 (capabilityId·providerId·readiness·guarantee·failurePolicy·multiInstance·requiredRole·readinessImpact·implVersion)
|
||||
- 활성화 SSOT — `provider: disabled|<id>` / `dispatch-mode` / leaf `enabled` 의 적용 조건
|
||||
- readiness 등급 R0~R3 정의와 승급 증거
|
||||
- capability card 필수 항목 (unsafe recipe ↔ 안전한 대체 포함)
|
||||
- fail-fast 토폴로지 검증 규칙
|
||||
- 능력별 실패 정책 매트릭스의 owner
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- 개별 capability 의 provider 구현 — 각 Tier T/C branch 소유
|
||||
- capability 별 임계값·용량 수치
|
||||
- 심층 명세 — 본 노트는 스캐폴딩. D-row 와 §구현 가이드는 `/branch-spec` 이 채운다.
|
||||
- project decision registry 변경 — owner 는 project-note
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> `/branch-spec feature-capability-provider-selection-contract` 단계에서 verbatim 인용과 함께 `raw/official-docs/` 로 수집한 뒤 여기서 링크한다.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/spring-boot-application-availability-startup]] | capability 토폴로지 검증은 startup 에서 fail-fast 로 수행하고 검증 실패는 컨텍스트 기동을 거부한다 / availability state(readiness·liveness)는 런타임 신호이지 startup 검증의 대체물이 아니다 — `SB-AVAIL-C1`~`C5` |
|
||||
| [[raw/official-docs/spring-boot-condition-annotations]] | production capability 활성화를 `@ConditionalOnProperty` 로 게이트할 때 `matchIfMissing=true` 를 금지하는 결정(SB-COND-C1/C2), 조건 평가 시점·순서 의존성의 한계(SB-COND-C3/C4) |
|
||||
| [[raw/official-docs/config-spring-boot-externalized-configuration]] | capability 활성화 property 를 단일 prefix 로 통일 — relaxed binding(SPRING-EXTCONFIG-C6/C7)·canonical form 권장(C8)·`@ConfigurationProperties` prefix→bean 명명 규칙(C9)·type-safe binding 이점(C10)·PropertySource 우선순위(C11, OS 환경변수가 config data 파일보다 우선)를 근거로, `ca-skeleton.*`/`app.*` 이중 prefix 를 `APP_*` 환경변수 registry 와 정합되는 단일 prefix 로 통일 |
|
||||
| [[raw/official-docs/runtime-health-spring-actuator-groups]] | 활성화된 provider 만 readiness 에 반영 — optional cache 실패는 unready 미유발, session/idempotency/필수 lock 실패는 unready 유발. `StatusAggregator` 기반 aggregation(`SB-HEALTH-C10`,`C11`) + health group include/exclude 메커니즘(`SB-HEALTH-C9`) + "essential 하지 않은 외부 시스템은 포함하지 말 것" 판단 기준(`SB-HEALTH-C12`)이 근거. 단 "capability descriptor 로 판정 주체를 통일한다"는 것 자체는 본 자료가 증명하지 않는 ca-skeleton 자체 설계 결정 |
|
||||
| [[raw/official-docs/spring-boot-conditional-on-property-javadoc]] | D4("`matchIfMissing=true` 금지")의 메커니즘 축 승급 근거 — `matchIfMissing` 의 선언된 기본값은 `false` 이고, property 가 `Environment` 에 전혀 없을 때 기본적으로 조건은 match 되지 않는다는 것이 annotation 공식 Javadoc 에 명시됨(`SB-COND-JD-C1`/`C2`). 단 "그래서 명시적으로 켜지 않는다"는 정책 자체는 여전히 internal |
|
||||
| (추가 수집 대상) | 1차 수집 대상: 설계 §6 / §9 · ca-tmpl platform 설계 §6·§7 |
|
||||
|
||||
**프로젝트 내부 설계 참조 (등급 `internal-design-doc` — 공식 문서 아님, best practice 로 격상 금지):**
|
||||
|
||||
- ca-tmpl `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md`
|
||||
- llm-wiki `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md`
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [x] `/branch-spec` 로 D-row 11개·§구현 가이드 6절·§Audit & Findings 작성 — 등급: `documented-only`
|
||||
- [ ] **미결 3건 사용자 확정** — (1) 런타임 능력 어휘, (2) 통일할 property prefix, (3) 등급 승급 자동 검사 방법 — 등급: `planned`
|
||||
- [ ] A4 registry 미등록 키 4종 등록 — 등급: `planned`
|
||||
- [ ] A3 notification dead config 를 notification-provider-spi 와 협의 — 등급: `planned`
|
||||
- [ ] A5 `matchIfMissing=true` 6곳 개별 판정 — 등급: `planned`
|
||||
- [ ] descriptor 도입 + `StartupSafetyValidator` 판정 입력 교체 — 등급: `planned`
|
||||
- [ ] capability descriptor·provider 선택·readiness 등급 검증 test 가 통과하고 bean 이름 기반 판정이 제거된다 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- **게이트 이력 (2026-07-28)**: depth 3회 · coverage 2회 실행. **coverage 최종 = Covered**(Blocking 0 / Should-fix 0 / Advisory 2, 2회차). **depth 최종 = Not ready**(Blocking 1 / Should-fix 4 / Advisory 3, 3회차) — `/branch-spec` 루프 천장 2회 도달로 종료. 잔여 finding 은 다음 세션 재개 대상.
|
||||
- ⚠️ **최종 게이트 이후 적용된 편집 2건 (게이트 미검증)**: (1) §구현 가이드 3-1 의 대조축 시점 배정 — depth 3회차 Blocking #1 이 지적한 자기모순(§11 이 런타임 로딩되지 않는데 "startup 거부"라 씀)의 해소, (2) `failureMode`→`failurePolicy` 잔존 4곳 정정. **둘 다 게이트 재판정을 거치지 않았다.**
|
||||
- 2026-07-28 `/branch-spec` 1회차. 외부 공식 문서 **5건** 수집(전량 self-grep 검증), sibling 3건 대조, ca-tmpl 코드 7개 영역 확인.
|
||||
- depth/coverage 게이트 1회차가 Blocking 4건(depth 3 + coverage 1)을 잡아 보강 루프 1회 수행 — D12·D13·D14 신설, descriptor 기여·수집 메커니즘 확정, readiness group 정적 목록 현행(A8) 기록.
|
||||
- 5번째 수집(`ConditionalOnProperty` Javadoc)은 depth Blocking #1 해소용 후속 dispatch. **`matchIfMissing` 기본값 `false` 확인** → D4 를 통째 `UNSUPPORTED_DECISION` 에서 "메커니즘 official / 정책 internal" 2축으로 승급.
|
||||
- 수집 중 확인된 **근거 부재 2건**을 결정에 반영: (1) `matchIfMissing` 금지의 벤더 권고 부재 → D4 `UNSUPPORTED_DECISION`, (2) default readiness group 의 외부 의존성 정책 verbatim 부재(`SB-HEALTH-C8`) → §Claims To Verify.
|
||||
- 같은 URL 이 이미 보관돼 있던 2건(external config / actuator health)은 새 파일을 만들지 않고 **다중 부모로 확장**했다 (`rules/linking-rules.md` §3).
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-07-28: 런타임 능력 어휘를 use-case capability 와 분리 (D1) / 이유: `capabilities.yaml` 7 row 와 `@UseCaseCapability` 가 이미 그 낱말을 점유 / 검토한 대안: 기존 어휘 재정의(7 row + ArchUnit rule cascade) / 근거: `internal-code-fact`
|
||||
- 2026-07-28: 활성화 판정을 bean 이름 presence 에서 typed descriptor 로 이전 (D5) / 이유: 같은 이름의 아무 bean 이나 있으면 안전으로 보고됨 + 조건 평가가 bean definition 순서에 의존 / 검토한 대안: bean 타입 기반 lookup(순서 의존은 동일) / 근거: `raw/official-docs/spring-boot-condition-annotations.md#SB-COND-C3`
|
||||
- 2026-07-28: `matchIfMissing = true` 금지는 **외부 권고가 아니라 내부 정책**임을 명시 (D4) / 이유: Spring 공식 문서가 이 attribute 의 부재 시 의미를 설명하지 않음 / 근거: 코드 사실 — gRPC 서버와 filesystem object storage 가 설정 없이 활성
|
||||
|
||||
<!-- section-id: decision-evidence -->
|
||||
## 결정-근거 매핑
|
||||
|
||||
> `Supporting Claims` 의 `raw/...#Cn` 은 verbatim 인용 + self-grep 검증을 마친 claim. `internal-code-fact` 는 ca-tmpl 코드를 직접 읽어 확인한 사실(경로·행 명시). 외부 공식 근거가 없는 결정은 `UNSUPPORTED_DECISION` 으로 라벨한다 — 추측으로 채우지 않는다.
|
||||
>
|
||||
> **근거의 두 축을 섞지 않는다.** 공식 문서가 증명하는 것은 *메커니즘*(Spring 이 어떻게 동작하는가)이고, *정책*(그래서 우리는 무엇을 금지하는가)은 대부분 내부 결정이다. 각 행의 Evidence Strength 가 이 경계를 표시한다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | 런타임 능력에는 `capability` 라는 낱말을 단독으로 쓰지 않는다 — 그 어휘는 이미 use-case repository access 개념이 점유하고 있으므로 런타임 축은 별도 어휘로 분리한다 | 기존 `@UseCaseCapability` + `capabilities.yaml` 을 유지 → 런타임 축에 신규 어휘. 기존 어휘를 런타임 의미로 재정의 → 7개 capability row 와 ArchUnit rule 전체 cascade (선택 안 함) | `internal-code-fact`: `src/application-core/src/main/java/dev/caskeleton/application/capability/UseCaseCapability.java`(+`RepositoryAccess`·`Idempotency`), `docs/registries/capabilities.yaml`(owner_branch `feature-repository-access-permission-contract`, 7 row) | `internal-code-fact` | 어휘 **명칭 자체**는 근거 없음 → §구현 가이드 1 에서 `UNSUPPORTED_IMPL_DECISION`. 명칭 확정 전 다른 branch 가 `capability` 를 런타임 의미로 쓰기 시작하면 되돌리기 비용 증가 |
|
||||
| D2 | 활성화 SSOT 는 축 하나로 고정한다 — provider 축(`provider: disabled\|<id>`), mode 축(`dispatch-mode: ...`), 또는 축이 없는 leaf 의 boolean 중 **정확히 하나**만 한 capability 에 적용한다 | provider 대안이 둘 이상 → provider 축. 동작 방식만 갈리고 provider 는 하나 → mode 축. 켜고 끄는 것 외에 선택지가 없음 → boolean. 두 축을 동시에 두는 구성은 금지 | `SB-COND-C1`(`raw/official-docs/spring-boot-condition-annotations.md#SB-COND-C1` — property 존재·값 기준 매칭 메커니즘). 현행 4종 혼용: `internal-code-fact` — boolean(`ca-skeleton.tracing.enabled`·`ca-skeleton.grpc.enabled`·`ca-skeleton.fileserver.enabled`·`ca-skeleton.persistence-mongo.enabled`·`app.cache.redis.enabled`), provider id(`ca-skeleton.objectstorage.backend`·`app.messaging.broker`), mode(`app.cache.redis.client-mode`) | **메커니즘 `official-vendor-doc` / 정책 `internal-policy`** | 축 통일은 기존 5개 이상 config 클래스의 property 이름 변경 → breaking. 이행 창(legacy key 병행) 설계가 없으면 배포 중단 위험 |
|
||||
| D3 | capability 활성화 property 는 단일 prefix 로 통일하고, 그 값은 `env-keys.yaml` 에 등록된 `APP_*` 환경변수에서만 주입한다 | 이미 `APP_*` 로 통일된 env registry 를 SSOT 로 유지 → property prefix 를 그에 맞춤. registry 를 포기하고 property 를 SSOT 로 삼는 대안은 sibling D2·D7 과 충돌하므로 선택 안 함 | `SPRING-EXTCONFIG-C7`·`C8`·`C9`(`raw/official-docs/config-spring-boot-externalized-configuration.md` — `_` 치환 relaxed binding, canonical kebab-case 권장, `@ConfigurationProperties` prefix→bean 명명). sibling: [[raw/branch-notes/feature-env-driven-runtime-configuration]] D2(`APP_` 전면 통일, registry=SSOT)·D7(`verifyEnvKeys` 3-way drift 검증) | **메커니즘 `official-vendor-doc` / 정책 `internal-policy` (sibling 결정 상속)** | 현행 `ca-skeleton.*` 4종(grpc·fileserver·persistence-mongo·objectstorage)은 `env-keys.yaml` 에 row 가 **0개** — 통일 전에 registry 등록이 선행돼야 하며 그 사이 `verifyEnvKeys` 가 실패할 수 있다 |
|
||||
| D4 | production capability 는 **명시적 선택으로만** 활성화된다 — `matchIfMissing = true`·classpath 존재·local provider 기본값 **셋 다** 활성화 근거가 될 수 없다 (project note §36.3 규범 1 의 3 메커니즘 전부) | 운영 표면·보안 표면·외부 연결을 만드는 capability 의 **활성화 게이트** → 금지. 순수 관측(로깅 포맷 등) 또는 안전한 no-op 기본값 → 예외 허용하되 사유를 registry row 에 남김. **mode 축의 기본값 선택은 활성화 조건이 아니므로 D4 적용 밖** — 예: `app.cache.redis.client-mode=managed`(`matchIfMissing=true`)는 이미 활성화된 capability 안에서 구현 방식을 고르는 것이고, 실제 활성화 게이트는 `app.cache.redis.enabled`(`matchIfMissing=false`)다 | **메커니즘**: `SB-COND-JD-C2`(`raw/official-docs/spring-boot-conditional-on-property-javadoc.md#SB-COND-JD-C2` — "If the property is not contained in the `Environment` at all, the `matchIfMissing()` attribute is consulted. **By default missing attributes do not match.**"), `SB-COND-JD-C1`(`#SB-COND-JD-C1` — `matchIfMissing` **Defaults to false**). 즉 프레임워크 기본값이 이미 fail-closed 이고, `= true` 는 그 기본값을 **명시적으로 뒤집는 행위**다. `SB-COND-C2`(reference 페이지 — attribute 존재만 확인, L0)는 보조. 금지 대상 근거는 `internal-code-fact`: `adapter/inbound/grpc/.../GrpcServerConfig.java:18-22`(gRPC 서버 기본 ON), `adapter/outbound/objectstorage/.../ObjectStorageConfig.java:32-37`(filesystem object storage 기본 ON), 그 외 `TracingConfig.java:56-60`·`OutboxConfig.java:33-36`·`RedisCacheAdapterConfig.java:30-33`·`DistributedLockPersistenceConfig.java:25-29` | **메커니즘 `official-vendor-doc`(javadoc) / 금지 정책 `internal-policy`** — Javadoc 이 기본값 `false` 와 "부재 시 미매칭"을 명시하므로 메커니즘은 확정. 다만 Javadoc 은 *언제 이 attribute 를 쓰라/쓰지 말라* 는 권고를 담지 않으므로, production capability 에 대한 **금지 자체는 내부 정책**이다 (2026-07-28 Javadoc 수집으로 통째 `UNSUPPORTED_DECISION` 에서 승급) | 6곳 중 lock local provider(`havingValue="false"` + `matchIfMissing=true`)는 **의도된 single-instance 기본값**일 수 있다 — 일괄 금지 적용 전 개별 판정 필요(§Claims To Verify 1행). 또한 Javadoc 기본값이 `false` 라는 사실은 **`= true` 를 쓴 6곳이 전부 의도적 override** 였음을 뜻한다 — 각 override 의 원래 의도를 확인하지 않고 제거하면 기본 동작이 바뀐다 |
|
||||
| D5 | 활성화·안전 판정은 bean 이름 presence 가 아니라 typed descriptor 로 한다 | 판정 대상이 "그 계약을 실제로 구현했는가" → typed descriptor. 단순 "빈이 있는가" 로 충분한 내부 배선 → 기존 방식 유지 가능 | `SB-COND-C3`(`#SB-COND-C3` — 조건은 "지금까지 처리된 것" 기준 평가, bean definition **순서**에 매우 주의), `SB-COND-C4`(`#SB-COND-C4` — 그래서 bean 조건은 auto-configuration 클래스에서만 권고). `internal-code-fact`: `app-bootstrap/.../runtime/StartupSafetyValidator.java:23-30` 이 문자열 5개(`distributedLockProvider`·`cacheStampedeProtection`·`outboxLeaderElection`·`distributedRateLimiter`·`migrationStartupRunner`)의 `containsBean` 으로 판정. sibling: [[raw/branch-notes/feature-env-driven-runtime-configuration]] D8 (동일 메커니즘을 자기 노트에서 이미 `UNSUPPORTED_IMPL_DECISION` 으로 라벨) | **`official-vendor-doc`** — 순서 의존 위험은 벤더 진술 | 이름이 같은 아무 bean 이나 있으면 안전으로 판정된다. descriptor 전환 전까지 multi-instance 안전 보고는 신뢰할 수 없다 |
|
||||
| D6 | 토폴로지 검증은 context refresh 완료 **전**에 실행하고 실패 시 컨텍스트 기동을 거부한다 — `ApplicationRunner`/`CommandLineRunner` 로 옮기지 않는다 | 검증이 bean 실체를 봐야 함 + 실패 시 기동 거부가 필요 → refresh 전 singleton 초기화 시점. 기동 후 1회성 작업 → Runner | `SB-AVAIL-C4`(`raw/official-docs/spring-boot-application-availability-startup.md#SB-AVAIL-C4` — Runner 는 "once SpringApplication has started" 실행), `SB-AVAIL-C3`(`#SB-AVAIL-C3` — Liveness 이벤트는 `ApplicationStartedEvent`(refresh 이후) 다음), `SB-AVAIL-C5`(`#SB-AVAIL-C5` — startup 예외는 `ApplicationFailedEvent` 별도 경로). sibling: env-driven D8 의 trade-off(`EnvironmentPostProcessor` 는 bean 정의 이전이라 presence 검사 불가) | **`official-vendor-doc` (시점) + `internal-policy` (거부 정책)** | 검증 위치는 유지되고 *판정 방식*만 D5 로 바뀐다. 두 결정을 한 PR 에서 섞으면 회귀 원인 분리가 어렵다 |
|
||||
| D7 | readiness 반영 여부는 descriptor 의 `readinessImpact` 로 결정하고, health group 구성은 그 값에서 파생한다 | optional·fallback 가능 capability → readiness 제외. 실패 시 요청을 올바르게 처리할 수 없는 capability(session·strict idempotency·필수 lock·필수 producer) → readiness 포함 | `SB-AVAIL-C2`(`#SB-AVAIL-C2` — readiness = 트래픽 라우팅 게이트), `SB-HEALTH-C9`(`raw/official-docs/runtime-health-spring-actuator-groups.md#SB-HEALTH-C9` — `management.endpoint.health.group.<name>` 으로 indicator include/exclude), `SB-HEALTH-C11`(`#SB-HEALTH-C11` — group 은 `StatusAggregator` 상속, override 가능), `SB-HEALTH-C12`(`#SB-HEALTH-C12` — 비필수 외부 시스템은 포함하지 말 것, essential 판단은 개발자 책임) | **메커니즘 `official-vendor-doc` / 어떤 capability 가 essential 인지는 `internal-policy`** | `SB-HEALTH-C8` 이 `needs-confirmation` 으로 남아 있다 — "default readiness group 이 외부 의존성을 포함하지 않는다" 의 단일 문장 verbatim 미확보. C12 가 부분 해소했을 뿐이다 |
|
||||
| D8 | 모든 capability 는 R0~R3 readiness 등급을 갖고, 등급 주장에는 등급별 증거가 필요하다 | 실제 provider 없이 타입·seam 만 → `R0`. 단일 로컬 프로세스에서만 검증 → `R1`. 실서비스 통합 + 동시성·실패 test + runbook 확보 → `R2`. cluster·failover·rolling upgrade 검증 → `R3`. 증거가 다음 등급에 못 미치면 **낮은 등급 유지**가 기본이고 승급 주장은 금지 | **조사 범위(Spring 공식 4건) 내 대응 근거 미확보** — production-readiness/maturity 계열은 이번 회차에서 조사하지 않았다(비존재 단정 아님). 근거는 `internal-design-doc`: ca-tmpl `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md` §6 | **`UNSUPPORTED_DECISION`** (조사 범위 내 미확보 + 내부 설계 결정) | 등급 판정이 사람의 선언에 의존한다. 자동 검사가 없으면 R0 를 R2 로 적는 것을 막지 못한다 — §구현 가이드 5 의 gate 가 유일한 방어 |
|
||||
| D9 | capability card 는 보증과 **비보증**을 둘 다 적고, 흔한 unsafe recipe 와 그 안전한 대체를 쌍으로 제시하며, 그 경합을 재현하는 test 이름을 명시한다 | 모든 R2 이상 capability → 필수. R0/R1 → card 없이 한계 문서화만 | **조사 범위 내 미확보** — 프로젝트 고유 요구이며 대응 외부 표준을 이번 회차에서 조사하지 않았다. 근거는 `internal-design-doc`(platform 설계 §6) + 사용자 요구("함수를 쓰다 부딪히는 문제를 개발자가 알 수 있게") | **`UNSUPPORTED_DECISION`** (내부 결정) | card 작성이 형식적으로 흐를 위험. "경합 test 이름 명시" 를 필수 칸으로 둔 것이 유일한 강제 장치다 |
|
||||
| D10 | startup 실패는 기존 registry 어휘를 재사용하고 새 error code 를 만들지 않는다 | descriptor 검증 실패 → `STARTUP_VALIDATION_FAILED`. 필수 provider 부재 → `REQUIRED_ADAPTER_DISABLED`. prod 금지 토글 → `PROFILE_MISMATCH`. 비활성 adapter 런타임 호출 → `ADAPTER_DISABLED` | `internal-code-fact`: `docs/registries/error-codes.yaml` — `STARTUP_VALIDATION_FAILED`(L841)·`REQUIRED_ADAPTER_DISABLED`(L855)·`PROFILE_MISMATCH`(L889) owner_branch `feature-migration-startup-contract`, `ADAPTER_DISABLED`(L875) owner_branch `feature-integration-adapter-templates`. 코드: `app-bootstrap/.../runtime/startup/StartupFailures.java` | `internal-code-fact` | 본 branch 는 **소비자**다. 의미 확장이 필요하면 owner branch 의 registry 변경 절차를 거쳐야 하며 여기서 단독 결정할 수 없다 |
|
||||
| D15 | capability card 의 필수 항목은 project note §36.2 원문을 **그대로 상속**하고 본 branch 는 그 목록을 narrow 하지 않는다. D9 는 그중 강제력이 약해 별도 규범이 필요한 3개만 추가로 못박는다 | 13개 항목 전체 → §36.2 상속. 보증·비보증 동시 기재 / unsafe recipe ↔ 안전한 대체 / 경합 test 이름 → D9 가 추가 규범. 기본 실패 정책·override → D12 가 값 집합 소유 | project note §36.2 (13항목) | `internal-policy` (project note 상속 — 재진술 아님) | 상속 선언은 §36.2 가 바뀌면 자동 따라간다. 반대로 §36.2 개정 시 본 branch 가 알아채는 장치는 없다 — `/sync` 의 역참조 전파에 의존 |
|
||||
| D12 | project note §11 의 실패 정책 매트릭스를 **그대로 채택**하고, descriptor 는 단일 `failureMode` 가 아니라 **(실패 종류 → 정책 kind) 맵**을 갖는다. provider 가 배정된 정책과 다른 kind 를 선언하면 startup 을 거부한다 | 매트릭스는 *(capability × 실패 종류)* 조합이다 — cache 는 backend 실패·codec 결함 2행, outbox 는 append·dispatcher 2행, rate limit 은 strict·availability 2행, notification 은 best-effort·durable 2행, lock 은 efficiency·fenced 2행. 따라서 capability 하나가 **여러 행에 대응**하며 단일 enum 파생은 성립하지 않는다. 어느 행에도 해당하지 않는 조합 → **매트릭스에 행을 추가하는 것이 선행**(본 branch 가 §11 owner). 값이 아니라 *선택지·파라미터*인 2행(efficiency lock = "선언된 use case 정책에 따라", strict rate limit = "fail closed **또는** bounded local emergency limiter")은 정책 kind `delegate-to-use-case` 로 표기하고 실제 값은 use case 선언을 참조한다 | project note §11 "Capability 별 실패 정책 매트릭스"(owner 선언 + 12행). `internal-design-doc`: ca-tmpl platform 설계 §7 "Failure policy is capability-specific, not globally 'fail open' or 'fail closed'." | `internal-policy` (project note §11 상속 — 재진술 아님, 값 집합 파생과 검증만 본 branch 소유) | 12행이 앞으로 등장할 capability 를 전부 덮는지 미검증. 매트릭스에 없는 조합이 나오면 provider 는 startup 을 통과하지 못하고 §11 개정이 선행돼야 한다 — 이 경직성은 의도된 것이나 이행 초기에 마찰을 만든다 |
|
||||
| D13 | 비활성 capability 는 연결·백그라운드 워커·스키마·health contributor 를 만들지 않으며, 이를 negative fixture 로 검증한다 | 모든 선택 가능 capability → 기본 적용. classpath 존재만으로 auto-configuration 이 도는 third-party starter → 별도 배제 설정이 필요하며 그 사실을 registry row 에 남김 | project note §36.3 규범 2 ("미사용 capability 는 연결·백그라운드 워커·스키마·암묵적 런타임 동작을 만들지 않는다"). 메커니즘: `SB-COND-C1`(조건 미충족 시 configuration 미포함) | **정책 `internal-policy` (project note 상속) / 메커니즘 `official-vendor-doc`** | `@ConditionalOnProperty` 는 **본 프로젝트가 정의한 bean** 만 막는다. third-party starter 의 auto-configuration 이나 모듈 classpath 존재로 생기는 부수 효과는 이 조건으로 막히지 않는다 — negative fixture 가 그 차이를 드러내야 한다 |
|
||||
| D14 | 이행 창 동안 legacy 활성화 키를 병행할 수 있으나, legacy 값과 SSOT 값이 **불일치하면 startup 오류**(`STARTUP_VALIDATION_FAILED`)로 처리한다 | 키 rename·축 통일(D2·D3) 이행 중 → 병행 허용 + 불일치 시 거부. 이행 완료 후 → legacy 키 제거, 잔존 시 startup 오류 | project note §36.3 규범 3 ("legacy flag 를 한시적으로 유지하는 경우, SSOT 와의 불일치는 startup 오류다"). 실패 코드는 D10 재사용 | `internal-policy` (project note 상속) | 병행 **메커니즘**은 미정 — §구현 가이드 2 의 `UNSUPPORTED_IMPL_DECISION` 참조. 불일치 판정 자체는 이 결정으로 확정되나, 두 키를 어떻게 동시에 읽을지는 별개 |
|
||||
| D11 | capability descriptor registry 는 registry-governance 의 공통 스키마를 따르고, 기존 `capabilities.yaml` 과 **다른 파일**로 둔다 | 신규 registry family → universal 3 column(`owner_branch`·`compatibility_impact`·`required_test`) + family identity column 필수. 기존 파일 확장은 D1 의 어휘 충돌을 registry 층까지 끌고 오므로 선택 안 함 | sibling: [[raw/branch-notes/feature-contract-registry-governance]] D1(registry 없이 추가 금지)·D4(universal 3 column)·D6(구현 artifact 는 `ca-tmpl/docs/registries/` yaml) | `internal-policy` (sibling 결정 상속) | 신규 registry 파일명은 D1 의 어휘 확정에 종속 → §구현 가이드 1 의 `UNSUPPORTED_IMPL_DECISION` 과 같은 미결 항목 |
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
> 3-rule meta principle 적용 — R1 각 sub-section 은 Decision ID + Claim ID reference, R2 근거 없는 detail 은 `UNSUPPORTED_IMPL_DECISION` + trade-off 한 줄, R3 본 branch 결정 범위 밖은 이관.
|
||||
>
|
||||
> 코드 위치 표기: ca-tmpl repo 기준 상대 경로. 본 branch 는 **계약 SSOT** 이고 실제 코드 작성은 ca-tmpl plan 이 소유한다 — 아래 클래스명·경로는 `planned` 이며 코드로 확인된 것만 `actually-implemented` 로 표기했다.
|
||||
|
||||
### 1. Descriptor 형태와 registry row
|
||||
|
||||
> **Trace**: D1(어휘 분리) · D11(registry 스키마) · sibling [[raw/branch-notes/feature-contract-registry-governance]] D4
|
||||
>
|
||||
> - **descriptor 기여·수집 메커니즘 (근거 있음 — 라벨 불필요)**: provider 가 자기 descriptor 를 **기여**하고 `app-bootstrap` 이 **수집·검증**한다. 근거 `internal-design-doc`: ca-tmpl platform 설계 §7 Capability descriptors — "Each active provider contributes a typed descriptor with at least:" (L318) 및 "`app-bootstrap` validates descriptors and selected settings. It does not accept arbitrary beans with a magic name." (L330). 즉 판정 입력은 **런타임 객체**이지 registry yaml 이 아니다.
|
||||
> - **registry row 와 런타임 descriptor 의 역할 분리**: registry row(D11)는 *governance 기록* — 어떤 capability 가 존재하고 누가 owner 이며 어떤 test 가 강제하는지. 런타임 descriptor 는 *판정 입력* — 이번 기동에서 어떤 provider 가 실제로 활성이고 무엇을 보증하는지.
|
||||
> - **둘의 대조는 startup 이 아니라 build/test 단계다** (2026-07-28 정정). 근거 `internal-code-fact`: registry yaml 은 런타임 resource 로 packaging 되지 않는다(`src/build.gradle` 에 registries → resources 복사 0건). 기존 drift 검사도 전부 빌드/테스트 시점이다 — `verifyEnvKeys` 는 Gradle task 로 `${rootProject.projectDir}/../docs/registries/env-keys.yaml` 을 읽고(`src/build.gradle:808-816`), `RepositoryAccessCapabilityRegistryTest` 는 `/docs` 가 gitignore 라 파일 부재 시 `Assumptions.assumeTrue` 로 **SKIP**(`:26-27`, `:53-55`). 따라서 registry 불일치는 **test 실패**이지 `STARTUP_VALIDATION_FAILED` 가 아니다.
|
||||
> - **startup 이 대조하는 것은 descriptor ↔ 선택된 설정**이다 — 활성 provider 의 descriptor 가 선언한 보증·multi-instance 지원·필요 backing role 이 현재 설정과 맞는지. 여기서의 불일치만 `STARTUP_VALIDATION_FAILED`(D10)다.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 런타임 축의 **어휘와 registry 파일명**. 후보는 `runtime-capability` / `provider-capability` / `runtime-feature` 이며 어느 것도 외부 근거가 없다. trade-off — `runtime-capability` 는 기존 `capabilities.yaml`(use-case capability)과 낱말이 겹쳐 grep 혼선을 남기고, `runtime-feature` 는 sibling [[raw/branch-notes/feature-env-driven-runtime-configuration]] D9 의 "feature flag" 와 겹친다. **사용자 확정 전까지 어느 쪽도 코드에 박지 않는다.**
|
||||
|
||||
descriptor 필수 필드 (`planned`):
|
||||
|
||||
| 필드 | 의미 | 값 출처 |
|
||||
|---|---|---|
|
||||
| `capabilityId` | 안정 식별자. rename 은 breaking | 신규 registry row |
|
||||
| `providerId` | 선택된 구현체 식별자 | 활성화 property 값 (D2) |
|
||||
| `readiness` | `R0`\|`R1`\|`R2`\|`R3` (D8) | provider 가 선언, gate 가 검증 |
|
||||
| `guarantee` | 이 provider 가 보장하는 것 (문자열 enum) | capability 별 branch 가 정의 |
|
||||
| `failurePolicy` | **(실패 종류 → 정책 kind) 맵**. 단일 값이 아니다 — §11 매트릭스는 capability 당 1행이 아니라 *(capability × 실패 종류)* 조합이기 때문 (D12) | project note §11 매트릭스에서 파생 |
|
||||
| `multiInstanceSupported` | multi-instance 에서 유효한가 | D5 판정 입력 |
|
||||
| `requiredBackingRole` | 필요한 백엔드 역할 (예: Redis coordination role) | 기술 런타임 branch 가 정의 |
|
||||
| `readinessImpact` | `none`\|`degraded`\|`unready` (D7) | health group 파생 입력 |
|
||||
| `implementationVersion` | 구현 버전 — rolling upgrade 판정용 | 빌드 정보 |
|
||||
|
||||
registry row 는 universal 3 column(`owner_branch`·`compatibility_impact`·`required_test`)을 추가로 갖는다 (sibling [[raw/branch-notes/feature-contract-registry-governance]] D4).
|
||||
|
||||
### 2. 활성화 property 규약
|
||||
|
||||
> **Trace**: D2(축 단일화) · D3(prefix·env 정합) · D4(matchIfMissing 금지) · `SB-COND-C1` · `SPRING-EXTCONFIG-C7`~`C9`
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 통일할 **prefix 문자열**(`ca-skeleton.*` 로 모을지 `app.*` 로 모을지). trade-off — `app.*` 은 `APP_` 환경변수와 relaxed binding 이 1:1(`APP_CACHE_REDIS_ENABLED` → `app.cache.redis.enabled`)이라 registry 정합 비용이 낮고, `ca-skeleton.*` 은 프로젝트 식별성이 높지만 `CA_SKELETON_*` 환경변수가 `APP_*` 통일(sibling D2)과 충돌한다. **정합 비용만 보면 `app.*` 이 우세하나 결정 근거는 없다.**
|
||||
|
||||
| 축 | 형태 | 적용 조건 |
|
||||
|---|---|---|
|
||||
| provider | `<prefix>.<capability>.provider = disabled \| <providerId>` | 구현체 대안이 2개 이상 |
|
||||
| mode | `<prefix>.<capability>.<mode-name> = disabled \| <mode>` | provider 는 하나이고 동작 방식만 갈림 |
|
||||
| boolean | `<prefix>.<capability>.enabled = true \| false` | 켜고 끄는 것 외 선택지 없음 |
|
||||
|
||||
규범:
|
||||
|
||||
- 한 capability 에 두 축을 동시에 두지 않는다 (D2).
|
||||
- 모든 활성화 키는 `env-keys.yaml` row 를 가지며 `APP_*` 환경변수에서 주입된다 (D3). registry row 없는 키는 `verifyEnvKeys` 가 실패시킨다 (sibling env-driven D7).
|
||||
- production capability 에는 `matchIfMissing = true` 를 쓰지 않는다 (D4). 프레임워크 기본값이 이미 `false` 이므로(`SB-COND-JD-C1`) **속성 부재 = 비활성**은 별도 조치 없이 성립한다.
|
||||
- 나머지 두 활성화 경로도 막는다 (D4, §36.3 규범 1): **classpath 존재**는 D2 의 축 선언이 없으면 provider 로 인정하지 않으며, **local provider 기본값**(예: filesystem object storage)은 다른 provider 와 동일하게 명시 선택을 요구한다 — 기본 provider 라는 이유로 `matchIfMissing` 을 붙이지 않는다.
|
||||
- **표기 계약**: `matchIfMissing = false` 를 **명시**한다 — 생략해도 의미는 같다(기본값 `false`).
|
||||
|
||||
> **UNSUPPORTED_IMPL_DECISION**: 위 "명시 vs 생략" 표기 규약. **as-built 는 혼재다** — main 소스의 `@ConditionalOnProperty` 22곳 중 `matchIfMissing` 을 지정한 것은 11곳(`= true` 6 / `= false` 5)이고 나머지 11곳은 생략이다. 즉 생략이 명시-false 보다 많다. trade-off — 명시는 장황하지만 self-documenting 이고 `= true` 를 실수로 넣었을 때 diff 에서 눈에 띈다. 생략은 간결하고 기본값을 신뢰하지만, 검토자가 Javadoc 기본값을 알아야 의도를 판정할 수 있다. **as-built 다수는 생략이므로 정합만으로는 tie-break 가 안 되고, 어느 쪽도 외부 근거는 없다.**
|
||||
- placeholder 참조는 canonical kebab-case 로 쓴다 (`SPRING-EXTCONFIG-C8`).
|
||||
- 이행 창 동안 legacy 키를 병행하되 SSOT 값과 불일치하면 startup 을 거부한다 (D14).
|
||||
|
||||
> **UNSUPPORTED_IMPL_DECISION**: legacy 키 **병행 메커니즘**. 후보 — (a) `application.yml` 에 두 키를 모두 두고 새 키를 SSOT 로 읽기, (b) `EnvironmentPostProcessor` 로 legacy → 신규 alias 주입, (c) legacy 키 사용 시 deprecated 경고만 남기고 값은 신규 키에서만 읽기. trade-off — (a)는 가장 단순하나 `verifyEnvKeys` 의 3-way drift 검사(sibling env-driven D7)를 이행 기간 동안 우회해야 하고, (b)는 registry 를 건드리지 않지만 relaxed binding 과 alias 가 겹칠 때 우선순위가 불투명하며, (c)는 깔끔하나 legacy 키만 설정한 배포를 조용히 비활성으로 만들어 D14 의 "불일치 = 오류" 와 충돌한다. **(a)가 D14 와 가장 정합하나 결정 근거는 없다.**
|
||||
|
||||
### 3. Startup 검증 배치와 실패 매핑
|
||||
|
||||
> **Trace**: D5(typed descriptor) · D6(검증 시점) · D10(실패 어휘) · `SB-COND-C3`/`C4` · `SB-AVAIL-C3`/`C4`/`C5`
|
||||
|
||||
- 검증 위치는 현행을 유지한다 — `SmartInitializingSingleton` (refresh 완료 전, 모든 singleton 초기화 후). `ApplicationRunner` 는 "이미 시작된 후" 실행되므로 부적합하다 (`SB-AVAIL-C4`).
|
||||
- 판정 입력만 교체한다: `beanFactory.containsBean("<name>")` → 등록된 descriptor 집합. `actually-implemented` 현행은 `app-bootstrap/.../runtime/StartupSafetyValidator.java:23-30`(이름 목록) + `:78-79`(`containsBean` 호출 루프) — **교체 대상은 둘 다**.
|
||||
- 실패 매핑 (D10 — 전부 기존 registry code):
|
||||
|
||||
| 조건 | code | 기존 owner |
|
||||
|---|---|---|
|
||||
| descriptor 스키마·정합성 위반 | `STARTUP_VALIDATION_FAILED` | `feature-migration-startup-contract` |
|
||||
| 필수 capability 의 provider 부재 또는 보증 미달 | `REQUIRED_ADAPTER_DISABLED` | `feature-migration-startup-contract` |
|
||||
| prod 프로파일에서 금지 토글 활성 | `PROFILE_MISMATCH` | `feature-migration-startup-contract` |
|
||||
| 비활성 capability 를 런타임에 호출 | `ADAPTER_DISABLED` | `feature-integration-adapter-templates` |
|
||||
|
||||
### 3-1. 실패 정책 kind 와 매핑 규칙
|
||||
|
||||
> **Trace**: D12 · project note §11 (재진술 아님 — 값 집합 파생과 검증만 소유)
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 정책 kind 의 **명칭**. 아래 7개는 §11 12행의 동작을 축약한 것이며 외부 근거가 없다. trade-off — 축약하면 descriptor 가 짧아지지만 §11 문장과 1:1 대조가 어려워지고, 문장을 그대로 값으로 쓰면 대조는 쉬우나 오타·표기 흔들림이 생긴다.
|
||||
|
||||
| 정책 kind | §11 대응 행 | 의미 |
|
||||
|---|---|---|
|
||||
| `fail-open` | optional cache(backend 실패), best-effort notification·message | source 로 fallback 또는 무시하고 진행, degraded 신호 |
|
||||
| `fail-closed` | cache codec 결함, session store, keyed mutation idempotency, fenced correctness lock, strict rate limit(기본) | 해당 연산을 실패시킨다 |
|
||||
| `degrade-with-fallback` | availability 지향 rate limit | 제한된 local fallback 으로 계속하되 degraded 신호 필수, 조용한 무제한 통과 금지 |
|
||||
| `rollback-transaction` | outbox append | 업무 트랜잭션을 롤백 |
|
||||
| `accumulate-and-alert` | outbox dispatcher 장애 | write 는 계속 축적, lag·backlog 알림 |
|
||||
| `durable-retry` | durable notification·message | outbox/inbox + 재시도 + 종단 실패 경로 |
|
||||
| `delegate-to-use-case` | efficiency lock, strict rate limit(변형 선택 시) | 값이 아니라 선택지 — 실제 동작은 use case 선언이 정한다 |
|
||||
|
||||
규범: descriptor 의 `failurePolicy` 맵 키는 **실패 종류**(예: `backend-unavailable`, `codec-error`, `append`, `dispatch`)이고 값은 위 kind 다.
|
||||
|
||||
**대조축의 시점 배정** (§구현 가이드 1 이 세운 "governance 문서는 런타임 입력이 아니다" 원칙을 그대로 적용):
|
||||
|
||||
| 대조 | 시점 | 실패 표현 |
|
||||
|---|---|---|
|
||||
| §11 매트릭스 ↔ descriptor 선언 — 그 (capability × 실패 종류) 조합이 매트릭스에 존재하는가, 배정된 kind 와 같은가 | **build/test** (§11 은 런타임 로딩되지 않는다) | contract test 실패 |
|
||||
| descriptor 선언 ↔ 선택된 설정 — 예: `degrade-with-fallback` 인데 fallback 설정이 없음 | **startup** | `STARTUP_VALIDATION_FAILED` (D10) |
|
||||
|
||||
이 분리로 app-bootstrap 에 §11 12행을 상수 테이블로 재진술할 필요가 없다 — D12 의 Reference-Only 를 코드 층까지 유지한다.
|
||||
|
||||
> **UNSUPPORTED_IMPL_DECISION**: build/test 대조의 **구현 위치**. 후보 — (a) 기존 registry drift test 패턴을 따라 `app-bootstrap` test 에 두기(단, `/docs` 가 gitignore 라 파일 부재 시 SKIP 되는 한계를 그대로 물려받는다), (b) `verifyEnvKeys` 처럼 Gradle task 로 두기(CI 에서 확실히 돌지만 IDE 피드백이 늦다). trade-off 는 "기존 패턴 일관성" vs "SKIP 되지 않는 강제력" 이며 어느 쪽도 외부 근거가 없다.
|
||||
|
||||
### 3-2. 비활성 capability 의 리소스 미생성 검증
|
||||
|
||||
> **Trace**: D13 · `SB-COND-C1`
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: third-party starter 배제 **수단**. 후보 — (a) `spring.autoconfigure.exclude` 목록, (b) 해당 starter 를 optional 모듈에만 두고 core classpath 에서 제외, (c) 배제 없이 negative fixture 로 검출만. trade-off — (a)는 즉시 적용되나 클래스명 하드코딩이 버전 업그레이드에 취약하고, (b)는 근본적이나 모듈 registry 변경(19-leaf 고정)을 건드리며, (c)는 비용이 없지만 문제를 막지 못하고 알려주기만 한다.
|
||||
|
||||
negative fixture 가 assert 할 4종 (D13 의 "연결·워커·스키마·health contributor"):
|
||||
|
||||
| 대상 | 관측 방법 |
|
||||
|---|---|
|
||||
| 연결 | 해당 provider 의 client/connection factory bean 부재 |
|
||||
| 백그라운드 워커 | scheduler·listener·relay bean 부재 및 스레드 미생성 |
|
||||
| 스키마 | 해당 capability 소유 테이블·인덱스 DDL 미실행 |
|
||||
| health contributor | `/actuator/health` component 목록에 해당 이름 부재 |
|
||||
|
||||
registry row 의 `required_test` 에는 위 fixture 이름을 적는다 (sibling [[raw/branch-notes/feature-contract-registry-governance]] D2 — "registry 항목은 최소 1개 이상의 contract test 와 연결").
|
||||
|
||||
### 4. Readiness 반영 규칙
|
||||
|
||||
> **Trace**: D7 · `SB-AVAIL-C2` · `SB-HEALTH-C9`/`C11`/`C12` · as-built `application.yml:250-251`
|
||||
>
|
||||
> - **현행 (as-built)**: readiness group 은 **정적 목록**이다 — `src/app-bootstrap/src/main/resources/application.yml:250-251` 의 `readiness: include: readinessState,db`. 소유 주석은 같은 파일 L236-237 로 [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] 를 가리킨다.
|
||||
> - **시점 문제 (미확인 가정)**: descriptor 는 `SmartInitializingSingleton`(refresh 완료 전 후반, D6)에 수집되는 반면 health group 은 property 바인딩으로 구성된다 — 두 시점의 선후는 **수집한 자료로 확인되지 않았다**. 수집한 actuator 문서는 group 의 property 구성(`SB-HEALTH-C9`)·aggregation(`C10`/`C11`)·essential 판단(`C12`)만 담고 group 구성 시점이나 프로그래매틱 확장점을 다루지 않는다. 이 가정이 틀리면 아래 후보 (a)·(c)의 기각 근거가 무너진다 → §Claims To Verify.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 파생 메커니즘. 후보 — (a) `EnvironmentPostProcessor` 가 descriptor 선언을 읽어 include 목록을 주입, (b) 정적 yaml 을 유지하고 descriptor 와의 **일치를 test 로 강제**, (c) 커스텀 group registry 를 직접 구현. trade-off — (a)는 시점을 맞추지만 descriptor 를 bean 이전에 알아야 해서 D5 의 typed 판정과 다른 소스(설정)를 봐야 하고, (b)는 구현이 가장 작고 D6 시점과 충돌하지 않으나 **설정 누락을 배포 후에야 test 로 잡는다**, (c)는 자유도가 크지만 Spring 의 group 계약을 재구현하는 비용이 든다(**미확인 — 프로그래매틱 확장점 존재 여부를 확인하지 않았다**). **비용만 보면 (b)가 우세하나 결정 근거는 없다.**
|
||||
> - **`db` 처럼 capability 모델 밖 indicator**: 현행 include 의 `db` 는 Spring Boot DataSource 가 자동 기여하는 indicator 로 본 branch 의 descriptor 모델에 대응 행이 없다. **capability 모델 밖 indicator 의 group 편입은 [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] D10(Required vs Optional Dependency Matrix) 소유**이며 본 branch 는 건드리지 않는다.
|
||||
|
||||
- `readinessImpact: unready` 인 활성 capability 만 readiness group 에 포함한다. group 구성은 `management.endpoint.health.group.readiness` 의 include 목록으로 표현한다 (`SB-HEALTH-C9`).
|
||||
- `readinessImpact: degraded` 는 component health 에는 나타나되 readiness group 에는 넣지 않는다 — 비필수 외부 시스템을 readiness 에 넣지 말라는 기준 (`SB-HEALTH-C12`).
|
||||
- 비활성 capability 는 health contributor 를 등록하지 않는다. 등록되지 않은 것은 aggregation 에도 참여하지 않는다 (`SB-HEALTH-C10`).
|
||||
- liveness group 에는 어떤 capability 도 넣지 않는다 — liveness 는 프로세스 자체 신호다 (`SB-AVAIL-C1`).
|
||||
|
||||
> **OUT_OF_BRANCH_SCOPE**: health endpoint 의 **shape·노출·인증**은 [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] D2 와 [[raw/branch-notes/feature-management-actuator-security-contract]] 소유다. 본 branch 는 *어떤 capability 가 어느 group 에 들어가는지* 의 판정 입력만 정한다.
|
||||
|
||||
### 5. Readiness 등급과 card 게이트
|
||||
|
||||
> **Trace**: D8 · D9
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 등급 승급의 **자동 검사 방법**. 후보는 (a) descriptor 의 `readiness` 값과 test 태그를 대조하는 Gradle 검사, (b) card 문서의 필수 칸 존재를 검사하는 린터, (c) 리뷰 체크리스트(수동). trade-off — (a)만이 위조를 막지만 test 태그 규약이 선행돼야 하고, (c)는 즉시 가능하나 D8 의 Open Risk 를 해소하지 못한다.
|
||||
|
||||
- R2 이상을 주장하려면 card 에 **보증과 비보증이 둘 다** 있어야 한다 (D9).
|
||||
- card 의 "unsafe recipe ↔ 안전한 대체" 칸에는 그 경합을 재현하는 **test 이름**을 적는다. test 이름이 없으면 그 보증은 card 에 쓸 수 없다.
|
||||
- 등급별 요구 증거는 project note §36.1 을 상속한다 — 본 branch 는 재진술하지 않는다.
|
||||
- **card 의 나머지 항목(owner 모듈·semantic port / provider ID·readiness 등급 / 필요한 토폴로지·영속·축출 정책 / 설정 키·secret / liveness·readiness 영향 / bounded cardinality metric·trace span / 적합·부적합 지침·비용 모델·자원 한계 / 집중·통합·실패 test 명령 / rolling upgrade·복구 노트 / runbook 링크)도 project note §36.2 원문을 그대로 상속한다.** 본 branch 는 그 목록을 narrow 하지 않으며, D9 는 그중 **강제력이 약해 별도 규범이 필요한 3개**(보증·비보증 동시 기재 / unsafe recipe ↔ 안전한 대체 / 경합 test 이름)만 추가로 못박는다.
|
||||
- card 의 "기본 실패 정책과 허용되는 override" 항목은 **D12** 가 정한 값 집합을 쓴다 — §11 매트릭스 12행에서 파생된 enum 외의 값은 card 에 쓸 수 없다.
|
||||
|
||||
### 6. 현행 → 목표 이행
|
||||
|
||||
> **Trace**: D2·D3·D4·D5 의 적용 대상. 각 항목의 현행 상태는 §Audit & Findings 의 코드 근거와 1:1.
|
||||
|
||||
| 단계 | 내용 | 선행 조건 |
|
||||
|---|---|---|
|
||||
| 1 | 미등록 활성화 키 4종(grpc·fileserver·persistence-mongo·objectstorage)을 `env-keys.yaml` 에 등록 | sibling env-driven D7 절차 |
|
||||
| 2 | notification dead config 해소 — 조건 키와 yml 키 일치 (A3) | owner 판정 필요 (§Audit A3) |
|
||||
| 3 | `matchIfMissing=true` 6곳 개별 판정 → production capability 는 제거 | D4 |
|
||||
| 4 | 활성화 축 통일 + prefix 통일 (legacy key 병행 창 포함) | D2·D3, 어휘·prefix 확정 |
|
||||
| 5 | descriptor 도입 후 `StartupSafetyValidator` 판정 입력 교체 | D5, 1~4 완료 |
|
||||
|
||||
> **OUT_OF_BRANCH_SCOPE**: 각 capability 의 **provider 구현**(Redis role, Kafka producer, session store 등)은 Tier T/C branch 소유다. 본 §은 활성화·판정·readiness 반영의 공통 계약만 다룬다.
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**
|
||||
- descriptor 를 선언하지 않은 provider 가 활성화됨 → `STARTUP_VALIDATION_FAILED` 로 기동 거부 (D10). 조용히 통과시키면 D5 가 해결하려는 문제가 그대로 남는다.
|
||||
- 같은 capability 에 두 provider 가 동시에 활성 → 기동 거부. `@ConditionalOnProperty` 는 상호 배타를 보장하지 않으므로 descriptor 수집 시점에 중복을 검사해야 한다 (D2).
|
||||
- 활성화 property 는 켜져 있으나 필요한 backing role 설정이 없음 → `REQUIRED_ADAPTER_DISABLED`.
|
||||
- descriptor 는 `multiInstanceSupported: false` 인데 `APP_MULTI_INSTANCE_ENABLED=true` → 기동 거부 (H8 이전 대상 계약).
|
||||
- 비활성 capability 의 port 를 use case 가 호출 → `ADAPTER_DISABLED` (런타임 실패, 기동 실패 아님).
|
||||
- readiness group 에 넣을 capability 가 하나도 없음 → **capability 유래 항목만** 비우고 기동은 계속. `readinessState` 는 capability 가 아니므로 항상 유지한다(제거하면 k8s readiness probe 게이트 자체를 잃는다). readiness 가 항상 UP 이 되는 것을 **정상**으로 문서화한다 (`SB-HEALTH-C12` 의 판단 기준).
|
||||
- 검증 자체가 실패로 기동을 막을 때 `ApplicationFailedEvent` 경로로 빠지며 availability state 는 설정되지 않는다 (`SB-AVAIL-C5`) — 로그만이 유일한 진단 수단이다.
|
||||
|
||||
- **다른 계약 의존**
|
||||
- [[raw/branch-notes/feature-env-driven-runtime-configuration]] D2·D7·D8 — `APP_` prefix 통일, `verifyEnvKeys` drift 검증, multi-instance flag. **D8 의 판정 메커니즘은 본 branch 로 이전(H8)**, flag 자체와 registry row 는 env-driven 유지. 그 계약이 바뀌면 D3·D5 가 직접 영향받는다.
|
||||
- [[raw/branch-notes/feature-contract-registry-governance]] D1·D4·D6 — 신규 registry family 의 스키마와 저장 위치. D11 이 전적으로 의존한다.
|
||||
- [[raw/branch-notes/feature-migration-startup-contract]] — `STARTUP_VALIDATION_FAILED`·`REQUIRED_ADAPTER_DISABLED`·`PROFILE_MISMATCH` 의 owner. D10 은 소비만 한다.
|
||||
- [[raw/branch-notes/feature-integration-adapter-templates]] — `ADAPTER_DISABLED` owner. 본 branch 가 활성화 SSOT 를 가져가면 그 branch 는 소비자로 강등된다(설계 §5.3).
|
||||
- [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] D2·D9·D10 — health endpoint shape 와 required/optional dependency 구분의 owner. D7 은 그 위에 판정 입력만 얹는다.
|
||||
- [[raw/branch-notes/feature-repository-access-permission-contract]] — `capabilities.yaml` 과 `@UseCaseCapability` 의 owner. D1 의 어휘 분리는 이 계약을 **건드리지 않기 위한** 결정이다.
|
||||
|
||||
## Audit & Findings
|
||||
|
||||
> 2026-07-28 `/branch-spec` 의 §2 ground truth 확인에서 ca-tmpl 코드·registry·yml 을 직접 읽어 발견한 정합 문제. **전부 `actually-implemented` 사실**(경로·행 명시)이며 추측이 아니다. 각 항목은 §구현 가이드 6 의 이행 단계와 1:1 대응한다.
|
||||
>
|
||||
> 본 branch 가 단독으로 고칠 수 없는 항목은 owner 를 명시했다 — 자동 수정하지 않고 정합 권고만 남긴다.
|
||||
|
||||
| ID | 발견 | 근거 (ca-tmpl 상대 경로) | 판정 | owner |
|
||||
|---|---|---|---|---|
|
||||
| A1 | **활성화 규약 4종 혼용** — boolean `enabled` / provider id `backend`·`broker` / mode `client-mode` 가 동시에 쓰인다 | `GrpcServerConfig.java:18-22`, `FileExportConfig.java:26-29`, `MongoPersistenceConfig.java:21-24`, `ObjectStorageConfig.java:32-37`, `KafkaAdapterConfig.java:23`, `RedisCacheAdapterConfig.java:30-38` | `ACTIVATION_AXIS_DRIFT` — D2 로 통일 대상 | 본 branch |
|
||||
| A2 | **property prefix 이중화** — `ca-skeleton.*` 와 `app.*` 이 공존. 반면 env registry 는 `APP_*` 로 통일됨 | 위 파일들 + `docs/registries/env-keys.yaml`(108행 중 106행이 `APP_` 105 + `SPRING_PROFILES_ACTIVE` 1. 예외 2건은 third-party 소유 키 — `MANAGEMENT_SERVER_PORT`(L72)·`OTEL_EXPORTER_OTLP_ENDPOINT`(L711)) | `PREFIX_DRIFT` — D3 대상. prefix 문자열은 미확정 | 본 branch (+ env-driven 협의) |
|
||||
| A3 | **notification dead config** — 어댑터는 `app.notification.google-email.enabled` / `app.notification.slack-webhook.enabled` 로 조건 평가하나, `application.yml` 과 registry 에는 `app.notification.{slack,email}.provider` 만 존재. `matchIfMissing=false` 이므로 **문서화된 설정으로는 두 어댑터가 활성화되지 않는다** | `GoogleEmailNotificationAdapterConfig.java:19`, `SlackNotificationAdapterConfig.java:19` vs `app-bootstrap/src/main/resources/application.yml:500-506`, `env-keys.yaml`(`APP_NOTIFICATION_SLACK_PROVIDER`·`APP_NOTIFICATION_EMAIL_PROVIDER` 2행) | `DEAD_CONFIG` — 활성화 불가 | [[raw/branch-notes/feature-notification-provider-spi]] 와 협의 필요 |
|
||||
| A4 | **registry 미등록 활성화 키 4종** — grpc·fileserver·persistence-mongo·objectstorage 관련 키가 `env-keys.yaml` 에 **0개** | `grep -c "name: APP_{GRPC,FILESERVER,MONGO,OBJECTSTORAGE}" docs/registries/env-keys.yaml` = 0 | `REGISTRY_GAP` — sibling [[raw/branch-notes/feature-contract-registry-governance]] D1("registry 없이 추가하지 않음") 위반 상태 | 본 branch (등록) + registry-governance (절차) |
|
||||
| A5 | **속성 부재로 활성화되는 운영 표면 3건** — gRPC 서버, filesystem object storage, outbox relay scheduler 가 아무 설정 없이 켜진다. 나머지 3곳(`TracingConfig`·`RedisCacheAdapterConfig` mode 축·`DistributedLockPersistenceConfig` local provider)은 관측 또는 mode 축·의도된 single-instance 기본값으로 **D4 적용 밖**(판정 근거는 §Claims To Verify 1행) | `GrpcServerConfig.java:18-22`, `ObjectStorageConfig.java:32-37`, `OutboxConfig.java:33-36`(`ca-skeleton.outbox.relay-enabled` — DB 폴링 + broker 발행이라는 외부 연결을 만든다) | `IMPLICIT_ACTIVATION` — D4 대상 | 본 branch (계약) + 각 adapter branch (적용) |
|
||||
| A6 | **bean 이름 문자열 판정** — multi-instance 안전을 bean 이름 5개의 presence 로 판정. 같은 이름의 아무 bean 이나 있으면 안전으로 보고된다 | `StartupSafetyValidator.java:23-30` (`REQUIRED_MULTI_INSTANCE_BEANS` + `containsBean`) | `UNTYPED_SAFETY_CHECK` — D5 대상. 공식 문서의 순서 의존 경고(`SB-COND-C3`)와도 어긋남 | **본 branch (H8 이전 수신)** — 원 owner [[raw/branch-notes/feature-env-driven-runtime-configuration]] D8 |
|
||||
| A8 | **readiness group 이 정적 목록** — `readiness: include: readinessState,db` 가 yml 에 손으로 적혀 있고, `db` 는 Spring Boot DataSource 자동 기여 indicator 라 본 branch 의 descriptor 모델에 대응 행이 없다 | `src/app-bootstrap/src/main/resources/application.yml:250-251`(소유 주석 L236-237 → [[raw/branch-notes/feature-runtime-health-lifecycle-contract]]) | `STATIC_READINESS_GROUP` — D7 의 "descriptor 에서 파생" 과 시점·형태가 어긋난다. 파생 메커니즘은 §구현 가이드 4 의 `UNSUPPORTED_IMPL_DECISION` | 본 branch (판정 입력) + [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] (group shape·비-capability indicator) |
|
||||
| A7 | **`capability` 어휘 충돌** — `capabilities.yaml`(7 row)과 `@UseCaseCapability` 가 use-case repository access 의미로 이미 점유 | `docs/registries/capabilities.yaml`, `application-core/.../application/capability/UseCaseCapability.java` | `TERM_COLLISION` — D1 로 분리. 명칭 미확정 | 본 branch |
|
||||
|
||||
**이관 기록 (H8, 2026-07-28)** — multi-instance 활성화 판정 메커니즘의 owner 가 [[raw/branch-notes/feature-env-driven-runtime-configuration]] D8 에서 본 branch 로 이동했다. `APP_MULTI_INSTANCE_ENABLED` flag 자체·`env-keys.yaml` row·`APP_` prefix 통일(D2)·drift 검증(D7)은 env-driven 이 계속 소유한다. 설계 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §5.1. 최초 설계는 이전 원천을 `feature-background-job-async-contract` 로 잘못 적었고 본 `/branch-spec` 에서 정정했다.
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| `matchIfMissing=true` 6곳의 D4 적용/비적용 분류가 옳다 — 특히 lock local provider 는 의도된 single-instance 기본값이고, `client-mode` 는 활성화 게이트가 아닌 mode 축이다 | D4 를 일괄 적용하면 single-instance 기본 동작이 깨질 수 있는데, 그 의도가 코드 주석에만 있고 결정으로 박혀 있지 않다 | `DistributedLockPersistenceConfig.java:25-29` 의 `havingValue="false"` 조합을 [[raw/branch-notes/feature-distributed-lock-contract]] D3 와 대조 + `RedisCacheAdapterConfig.java:30-40` 의 두 조건(`client-mode` mode 축 / `enabled` 활성화 게이트) 분리 확인 | `needs-confirmation` |
|
||||
| notification 어댑터가 실제로 활성화된 적이 없다 (A3) | 조건 키가 yml·registry 어디에도 없으나, 테스트나 별도 profile 에서 주입될 가능성을 배제하지 못했다 | ca-tmpl 에서 `grep -rn "google-email.enabled\|slack-webhook.enabled" src/` 로 test·resources 포함 전수 확인 | `needs-confirmation` |
|
||||
| descriptor 수집이 `SmartInitializingSingleton` 시점에 완전하다 | 모든 singleton 초기화 후이지만, lazy bean 이나 조건부로 늦게 등록되는 provider 가 있으면 누락될 수 있다 | descriptor 미등록 provider 를 심은 negative fixture 로 `STARTUP_VALIDATION_FAILED` 발생 확인 | `planned` |
|
||||
| readiness group 에서 제외된 `degraded` capability 가 실제로 트래픽 차단을 유발하지 않는다 | `SB-HEALTH-C11` 상 group 이 aggregator 를 상속하므로, component health 의 DOWN 이 다른 경로로 readiness 에 전파될 여지가 남는다 | Redis 를 내린 상태에서 `/actuator/health/readiness` 가 UP 을 유지하는지 통합 test | `planned` |
|
||||
| "default readiness group 은 외부 의존성을 포함하지 않는다" | 공식 문서에서 단일 문장 verbatim 을 확보하지 못했다 (`SB-HEALTH-C8`). `SB-HEALTH-C12` 가 판단 기준만 부분 제공 | Spring Boot Kubernetes probes 섹션 재수집 또는 실제 기본 group 멤버십을 런타임에서 확인 | `needs-confirmation` |
|
||||
| health group 구성 시점이 descriptor 수집(`SmartInitializingSingleton`)보다 이르다 | §구현 가이드 4 의 후보 (a)·(c) 기각이 이 선후에 달려 있는데, 수집한 actuator 문서에 group 구성 시점·프로그래매틱 확장점 진술이 없다 | Spring Boot actuator health-groups 문서 재수집(프로그래매틱 확장점 포함) 또는 실제 기동 순서를 로그로 확인 | `needs-confirmation` |
|
||||
| registry yaml 은 런타임에 읽히지 않는다 | `src/build.gradle` 에 registries → resources packaging 이 0건임을 grep 으로 확인했으나, 다른 경로(예: jar 외부 마운트)로 읽힐 가능성을 배제하지 못했다 | ca-tmpl 에서 `grep -rn "docs/registries" src/ --include=*.java --include=*.gradle` 전수 확인 | `needs-confirmation` |
|
||||
| 본 branch 가 소유할 관심사가 sibling branch 결정과 겹치지 않는다 | D-row 11개 작성 후에도 owner 경계가 문장으로만 존재 | `/sync` 실행 — owner 중복·재진술 검출 | `needs-confirmation` |
|
||||
|
||||
## Coverage / 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
> 2026-07-28 1회차 `coverage-auditor` 판정 = **Not-covered** (Blocking 1 / Should-fix 3). 아래 표는 **판정 시점** 상태이며, 각 finding 의 보강을 같은 날 적용했다(우측 "1회차 보강" 열). **재판정 전까지 Covered 로 간주하지 않는다.**
|
||||
> governing: project note §11 · §25 · §36 + 분해 설계 §6 · §9
|
||||
|
||||
| 관심사 | 판정 시 상태 | owner | 심각도 | 1회차 보강 |
|
||||
|--------|------|-------|--------|------|
|
||||
| capability descriptor 최소 필드 9개 (§36.3) | covered-here | — | — | — (§36.3 과 1:1 일치 확인됨) |
|
||||
| 활성화 SSOT 축 선택 (provider/dispatch-mode/enabled) | covered-here | — | — | — |
|
||||
| 활성화 규범① 암묵 활성화 금지 | covered-here | — | — | D4 선택 조건에 mode 축 경계 추가 |
|
||||
| 활성화 규범④ typed descriptor 판정 | covered-here | — | — | 기여·수집 메커니즘을 §구현 가이드 1 에 명시 |
|
||||
| **활성화 규범② 미사용 capability 리소스 미생성** | missing | (없음) | 🟡 Should-fix | **D13 신설** |
|
||||
| **활성화 규범③ legacy flag 불일치 = startup 오류** | missing | (없음) | 🟡 Should-fix | **D14 신설** + §구현 가이드 2 에 병행 메커니즘 라벨 |
|
||||
| readiness 등급 R0~R3 + 승급 증거 | covered-here | — | — | D8 선택 조건을 등급 분기로 재작성 |
|
||||
| fail-fast 토폴로지 검증 시점/거부 | covered-here | — | — | — |
|
||||
| **capability card 필수 항목 13개 (§36.2)** | covered-here (3/13) | — | 🟡 Should-fix | §구현 가이드 5 에 **나머지 10개 상속 선언** + D12 연결 |
|
||||
| **§11 실패 정책 매트릭스 12행의 owner** | **missing** | (없음) | 🔴 Blocking | **D12 신설** — 매트릭스 채택 + `failurePolicy` 맵 파생 (2회차 지적으로 단일 enum → 맵 정정) + 불일치는 build/test 대조 |
|
||||
| startup 실패 코드 매핑 | delegated | [[raw/branch-notes/feature-migration-startup-contract]], [[raw/branch-notes/feature-integration-adapter-templates]] | OK | — |
|
||||
| descriptor registry 스키마·저장 위치 | delegated | [[raw/branch-notes/feature-contract-registry-governance]] | OK | — |
|
||||
| readiness endpoint shape/노출/인증 | delegated | [[raw/branch-notes/feature-runtime-health-lifecycle-contract]], [[raw/branch-notes/feature-management-actuator-security-contract]] | OK | A8 로 정적 include 현행 기록 + 비-capability indicator 취급을 명시 위임 |
|
||||
| 개별 capability 의 provider 구현 | delegated | 각 Tier T/C branch | OK | — |
|
||||
|
||||
> **위임 판정 주의**: §11 매트릭스가 `missing` 인 이유는 위임 후보 6개 sibling(cache-strategy-registry / idempotency-ownership-protocol / fenced-lock-coordination / outbox-dispatch-mode / notification-durable-delivery / redis-runtime-role-isolation)이 **전부 scaffolding 상태**(D-row 0개)라 실제로 아무도 맡고 있지 않았기 때문이다. 그 branch 들이 `/branch-spec` 을 마치면 일부 행은 delegated 로 재분류될 수 있다.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
해당 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -27,6 +27,17 @@ contract_packet_sha256: 174d92e4290cde20c264638b526e3447c27c19eca0eab92d8023a03a
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
|
||||
> [!important] 주도권 이전 고지 (2026-07-28)
|
||||
> 아래 관심사의 **owner 가 신규 branch 로 이동**했다. 근거·절차: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §5.
|
||||
>
|
||||
> | 이전 ID | 대상 D-row | 이전한 관심사 | 신규 owner |
|
||||
> |---|---|---|---|
|
||||
> | H5 | D6 | correctness lock·fencing token (efficiency mutex 는 본 branch 유지) | [[raw/branch-notes/feature-fenced-lock-coordination-contract]] |
|
||||
>
|
||||
> **본문은 아직 제거하지 않았다.** 신규 branch 는 현재 스캐폴딩 상태(D-row 없음)이므로, 지금 본문을 지우면 결정 근거가 소실된다. 위 D-row 는 신규 branch 의 `/branch-spec` 이 재판정을 마칠 때까지 **잠정 근거**로만 유효하며, 그 시점에 설계 §5.2 6단계에 따라 포인터(`superseded → [[<신규 branch>]] D<m>`)로 치환한다.
|
||||
>
|
||||
> 이 기간 중 새 결정을 본 branch 에 추가하지 않는다 — 신규 owner 에 작성한다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
|
||||
@@ -26,6 +26,17 @@ contract_packet_sha256: 07cf1cd12d434bd2863971a2449e16cd77d46aa46eefc8be1f42f5eb
|
||||
|
||||
> Layer: `raw/branch-notes/` — domain event, integration event, outbox, message publish 실패 기준을 정의합니다.
|
||||
|
||||
> [!important] 주도권 이전 고지 (2026-07-28)
|
||||
> 아래 관심사의 **owner 가 신규 branch 로 이동**했다. 근거·절차: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §5.
|
||||
>
|
||||
> | 이전 ID | 대상 D-row | 이전한 관심사 | 신규 owner |
|
||||
> |---|---|---|---|
|
||||
> | H6 | 행 상태 머신 관련 D-row | outbox 행 모델·dispatch 전략 (polling|cdc) | [[raw/branch-notes/feature-outbox-dispatch-mode-contract]] |
|
||||
>
|
||||
> **본문은 아직 제거하지 않았다.** 신규 branch 는 현재 스캐폴딩 상태(D-row 없음)이므로, 지금 본문을 지우면 결정 근거가 소실된다. 위 D-row 는 신규 branch 의 `/branch-spec` 이 재판정을 마칠 때까지 **잠정 근거**로만 유효하며, 그 시점에 설계 §5.2 6단계에 따라 포인터(`superseded → [[<신규 branch>]] D<m>`)로 치환한다.
|
||||
>
|
||||
> 이 기간 중 새 결정을 본 branch 에 추가하지 않는다 — 신규 owner 에 작성한다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
title: branch / feature-edge-rate-limit-policy-registry-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-edge-rate-limit-policy-registry-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||||
tags: [branch, ca-skeleton, rate-limit, algorithm, policy-registry, transport-edge]
|
||||
created: 2026-07-28
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-068
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-068
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-RATE-LIMIT-ALGORITHM-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-RATE-LIMIT-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-062]
|
||||
imports: []
|
||||
delegates: []
|
||||
accepts_delegations: []
|
||||
contract_packet: 1
|
||||
---
|
||||
|
||||
# branch: feature-edge-rate-limit-policy-registry-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
> **스캐폴딩 상태** — 결정(D-row)·구현 가이드는 비어 있다. `/branch-spec feature-edge-rate-limit-policy-registry-contract` 로 채운다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> 분해 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §4.2 — 능력 계약 (Tier C). 본 branch 는 project §8.0 `WI-CA-SKELETON-OPERATIONAL-CONTRACT-068` 의 실행 단위다.
|
||||
|
||||
형제 branch (같은 부모의 다른 자식 — 인접 영역):
|
||||
|
||||
- [[raw/branch-notes/feature-rate-limit-idempotency-contract]]
|
||||
- [[raw/branch-notes/feature-redis-atomic-program-catalog-contract]]
|
||||
- [[raw/branch-notes/feature-api-contract-baseline]]
|
||||
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
> project Work Item 에서 내려온 실행 계약의 snapshot. 여기에는 **pinned pointer + 1줄 요약 + branch 적용점**만 쓰고 상세를 복제하지 않는다.
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: 알고리즘 6종 교체·policy registry·decision 기반 Retry-After·fallback test 가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-RATE-LIMIT-ALGORITHM-001@1` | rate limit은 policyId 단위로 알고리즘을 선택하고 fixed window·sliding counter·token bucket을 기본 제공하며 sliding log·GCRA·leaky bucket은 opt-in이다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-RATE-LIMIT-001@1` | authenticated는 principal, unauthenticated는 IP와 normalized route를 rate-limit key로 사용한다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다. 근거 없이 추측해 채우지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- `WI-CA-SKELETON-OPERATIONAL-CONTRACT-068` 의 완료 조건을 구현한다: 알고리즘 6종 교체·policy registry·decision 기반 Retry-After·fallback test 가 통과한다
|
||||
- **주도권 이전 수신** — H2 ([[raw/branch-notes/feature-rate-limit-idempotency-contract]] D4·D5). 이전 절차는 `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §5.2 6단계를 따른다. 기존 branch 의 D-row 는 `/branch-spec` 으로 본 branch 결정이 확정된 뒤 포인터로 치환한다.
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- transport-edge 계약 — `RateLimitRequest(policyId, subjectHash, cost)` → `RateLimitDecision(allowed, remaining, retryAfter, resetAt)`
|
||||
- `policyId` registry — 알고리즘·용량·window·burst·cost·실패 모드·subject 차원 선언
|
||||
- 알고리즘 6종 — fixed window / sliding counter / token bucket 기본, sliding log / GCRA / leaky bucket opt-in
|
||||
- 키 차원 `environment + policyId + tenant? + subjectHash` 와 원문 PII·토큰·URL 금지
|
||||
- decision 기반 `Retry-After` (고정 1초 대체)
|
||||
- Redis 장애 시 local emergency limiter 의 경계와 degraded 신호
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- idempotency — #070 소유
|
||||
- HTTP 응답 코드·헤더 표면 — `feature-api-contract-baseline` 소유
|
||||
- Lua program 실행 기반 — #062 소유
|
||||
- 심층 명세 — 본 노트는 스캐폴딩. D-row 와 §구현 가이드는 `/branch-spec` 이 채운다.
|
||||
- project decision registry 변경 — owner 는 project-note
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 외부 근거 미등록. `/branch-spec feature-edge-rate-limit-policy-registry-contract` 단계에서 verbatim 인용과 함께 `raw/official-docs/` 로 수집한 뒤 여기서 링크한다.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| (미등록) | 1차 수집 대상: Redis 공식 — rate-limiter use case · ca-tmpl platform 설계 §8.5 |
|
||||
|
||||
**프로젝트 내부 설계 참조 (등급 `internal-design-doc` — 공식 문서 아님, best practice 로 격상 금지):**
|
||||
|
||||
- ca-tmpl `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md`
|
||||
- llm-wiki `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md`
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [ ] `/branch-spec feature-edge-rate-limit-policy-registry-contract` 로 D-row·§구현 가이드 작성 — 등급: `planned`
|
||||
- [ ] 알고리즘 6종 교체·policy registry·decision 기반 Retry-After·fallback test 가 통과한다 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
project 결정 외 branch-local 결정은 아직 없음. `/branch-spec` 단계에서 기록한다.
|
||||
|
||||
<!-- section-id: decision-evidence -->
|
||||
## 결정-근거 매핑
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
`/branch-spec` 단계에서 source claim 기반으로 작성한다. 3-rule meta principle(R1 Reference 필수 / R2 UNSUPPORTED_IMPL_DECISION 명시 / R3 OUT_OF_BRANCH_SCOPE 정제) 적용.
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**: `/branch-spec` 단계에서 구체화한다.
|
||||
- **다른 계약 의존**: `WI-CA-SKELETON-OPERATIONAL-CONTRACT-062`([[raw/branch-notes/feature-redis-atomic-program-catalog-contract]]) 의 계약에 의존
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 본 branch 가 소유할 관심사가 sibling branch 결정과 겹치지 않는다 | 스캐폴딩 시점에는 D-row 가 없어 경계가 문장으로만 존재 | `/branch-spec` 후 `/sync` 실행 — owner 중복 검출 | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
`/coverage` 실행 전.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
해당 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -26,6 +26,17 @@ contract_packet_sha256: e68380e05a6baa55af05d9d692b7986fc91202315b02276cecfd7bb8
|
||||
|
||||
> Layer: `raw/branch-notes/` — 서버별 운영 전환을 env로 가능하게 하는 설정 계약을 정의합니다.
|
||||
|
||||
> [!important] 주도권 이전 고지 (2026-07-28)
|
||||
> 아래 관심사의 **owner 가 신규 branch 로 이동**했다. 근거·절차: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §5.
|
||||
>
|
||||
> | 이전 ID | 대상 D-row | 이전한 관심사 | 신규 owner |
|
||||
> |---|---|---|---|
|
||||
> | H8 | D8 의 **집행 메커니즘** 부분 | multi-instance 활성화 판정 — bean 이름 presence 검사(`SmartInitializingSingleton` + `getBeanProvider`) → typed capability descriptor | [[raw/branch-notes/feature-capability-provider-selection-contract]] |
|
||||
>
|
||||
> **이전 범위는 판정 메커니즘뿐이다.** `APP_MULTI_INSTANCE_ENABLED` flag 자체, `env-keys.yaml` row, `APP_` prefix 통일(D2), env drift 검증(D7)은 **본 branch 가 계속 소유**한다.
|
||||
>
|
||||
> **본문은 아직 제거하지 않았다.** 신규 branch 는 스캐폴딩 상태이므로 D8 은 재판정 전까지 잠정 근거로 유효하며, 그 시점에 `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §5.2 6단계에 따라 포인터로 치환한다. D8 의 집행 메커니즘은 이미 `UNSUPPORTED_IMPL_DECISION` 라벨이 붙어 있어 재판정 대상임이 노트 자체에 기록되어 있다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
title: branch / feature-fenced-lock-coordination-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-fenced-lock-coordination-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||||
tags: [branch, ca-skeleton, lock, fencing, leader-election, coordination]
|
||||
created: 2026-07-28
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-071
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-071
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-LOCK-FENCING-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-SCHEDULER-LOCK-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-060]
|
||||
imports: []
|
||||
delegates: []
|
||||
accepts_delegations: []
|
||||
contract_packet: 1
|
||||
---
|
||||
|
||||
# branch: feature-fenced-lock-coordination-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
> **스캐폴딩 상태** — 결정(D-row)·구현 가이드는 비어 있다. `/branch-spec feature-fenced-lock-coordination-contract` 로 채운다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> 분해 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §4.2 — 능력 계약 (Tier C). 본 branch 는 project §8.0 `WI-CA-SKELETON-OPERATIONAL-CONTRACT-071` 의 실행 단위다.
|
||||
|
||||
형제 branch (같은 부모의 다른 자식 — 인접 영역):
|
||||
|
||||
- [[raw/branch-notes/feature-distributed-lock-contract]]
|
||||
- [[raw/branch-notes/feature-background-job-async-contract]]
|
||||
- [[raw/branch-notes/feature-redis-atomic-program-catalog-contract]]
|
||||
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
> project Work Item 에서 내려온 실행 계약의 snapshot. 여기에는 **pinned pointer + 1줄 요약 + branch 적용점**만 쓰고 상세를 복제하지 않는다.
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: fencing token 단조성·high watermark 거부·lease-lost 신호 test 가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-LOCK-FENCING-001@1` | DistributedLockPort는 efficiency mutex로 고정하고 correctness가 필요한 경로는 별도 FencedLockPort와 보호 자원의 high watermark 거부로만 보장한다 | `/branch-spec` 에서 적용 경계 작성 | [[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을 사용한다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다. 근거 없이 추측해 채우지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- `WI-CA-SKELETON-OPERATIONAL-CONTRACT-071` 의 완료 조건을 구현한다: fencing token 단조성·high watermark 거부·lease-lost 신호 test 가 통과한다
|
||||
- **주도권 이전 수신** — H5 ([[raw/branch-notes/feature-distributed-lock-contract]] D6). 이전 절차는 `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §5.2 6단계를 따른다. 기존 branch 의 D-row 는 `/branch-spec` 으로 본 branch 결정이 확정된 뒤 포인터로 치환한다.
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- `FencedLockPort` 와 leader election·semaphore·work claim 의 분리
|
||||
- fencing counter 와 만료 lease 의 분리 (counter 는 만료·초기화 없음)
|
||||
- 보호 자원의 high watermark 거부 규칙과 동일 owner 예외
|
||||
- lease-lost 신호와 보호 작업 중단 의무
|
||||
- blind `DEL`·무한 watchdog 금지, 멱등 release
|
||||
- provider 별 보증 — local / JDBC / Redis 의 명시적 한계
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- efficiency mutex — `feature-distributed-lock-contract` 유지
|
||||
- Redis 연결·스크립트 기반 — #061·#062 소유
|
||||
- 심층 명세 — 본 노트는 스캐폴딩. D-row 와 §구현 가이드는 `/branch-spec` 이 채운다.
|
||||
- project decision registry 변경 — owner 는 project-note
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 외부 근거 미등록. `/branch-spec feature-fenced-lock-coordination-contract` 단계에서 verbatim 인용과 함께 `raw/official-docs/` 로 수집한 뒤 여기서 링크한다.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| (미등록) | 1차 수집 대상: Redis 공식 — distributed locks patterns · ca-tmpl platform 설계 §10 (기존 raw 재사용: cache-redisson-rlock-vs-setnx) |
|
||||
|
||||
**프로젝트 내부 설계 참조 (등급 `internal-design-doc` — 공식 문서 아님, best practice 로 격상 금지):**
|
||||
|
||||
- ca-tmpl `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md`
|
||||
- llm-wiki `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md`
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [ ] `/branch-spec feature-fenced-lock-coordination-contract` 로 D-row·§구현 가이드 작성 — 등급: `planned`
|
||||
- [ ] fencing token 단조성·high watermark 거부·lease-lost 신호 test 가 통과한다 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
project 결정 외 branch-local 결정은 아직 없음. `/branch-spec` 단계에서 기록한다.
|
||||
|
||||
<!-- section-id: decision-evidence -->
|
||||
## 결정-근거 매핑
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
`/branch-spec` 단계에서 source claim 기반으로 작성한다. 3-rule meta principle(R1 Reference 필수 / R2 UNSUPPORTED_IMPL_DECISION 명시 / R3 OUT_OF_BRANCH_SCOPE 정제) 적용.
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**: `/branch-spec` 단계에서 구체화한다.
|
||||
- **다른 계약 의존**: `WI-CA-SKELETON-OPERATIONAL-CONTRACT-060`([[raw/branch-notes/feature-capability-provider-selection-contract]]) 의 계약에 의존
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 본 branch 가 소유할 관심사가 sibling branch 결정과 겹치지 않는다 | 스캐폴딩 시점에는 D-row 가 없어 경계가 문장으로만 존재 | `/branch-spec` 후 `/sync` 실행 — owner 중복 검출 | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
`/coverage` 실행 전.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
해당 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -25,6 +25,12 @@ contract_packet_sha256: 7db5c6a4eb77af61706b5f4688cf721f786789638595dca733d334c5
|
||||
|
||||
> Layer: `raw/branch-notes/` — file/resource 처리 실패 기준을 정의합니다.
|
||||
|
||||
> [!note] 개정 예정 고지 (2026-07-28)
|
||||
> 본 branch 는 **owner 를 유지**하되 내용을 코드 사실에 맞춰 개정한다. 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §5.3.
|
||||
>
|
||||
> - **개정 사유**: 전체 파일 버퍼링 + 비원자 덮어쓰기. staged publish(임시 파일 → fsync → atomic rename → 디렉터리 fsync) 프로토콜과 quota·retention·CSV 수식 주입 방어를 재판정한다. object storage 스트리밍은 신규 branch 로 분리.
|
||||
> - **참조 설계**: ca-tmpl `docs/superpowers/specs/2026-07-26-fileserver-production-capability-design.md` §11·§12·§13 (등급 `internal-design-doc` — 공식 문서 아님)
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ status_label: in-progress
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CAPABILITY-001@1` | 신규 runtime capability 6종은 FE-REG-CAPABILITY flag로 default OFF이며 활성화는 owner·gate·runbook을 동반한다 | `CAP_FE_BINARY_IO` 를 이 branch 가 소유하고 default OFF 로 유지한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-BINARY-STORE-001@1` | 로컬 바이너리 backend는 IndexedDB를 default로 하고 OPFS는 대용량 순차 write에 opt-in, Cache Storage는 service worker 호스팅 response cache 전용이다 | `BlobStorePort` adapter 의 backend 선택 순서에 적용한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-BINARY-STORE-001@1` | 로컬 바이너리 backend는 IndexedDB를 default로 하고 OPFS는 대용량 순차 write에 opt-in, Cache Storage는 정책상 service worker 호스팅 response cache 전용으로 제한한다 | `BlobStorePort` adapter 의 backend 선택 순서에 적용한다 | [[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, capability를 9개 registry로 관리한다 | `FE-REG-STORAGE` 신규 4행(`UPLOAD_PART_STATE`·`TRANSFER_OBJECT_BUFFER`·`QUERY_CACHE_SNAPSHOT`·`SW_RESPONSE_CACHE`)의 `payloadClass`·`evictionOrder` 를 소비한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
@@ -181,7 +181,7 @@ status_label: in-progress
|
||||
| D3 | `<input type=file>` + `<a download>` 를 기준선, File System Access picker 를 progressive enhancement | 항상. 대상 브라우저 매트릭스가 picker 전량 지원으로 확정되면(`FE-Q-007`) 기준선을 picker 로 올릴 수 있음 | `mdn-file-system-access-opfs.md#C1`(not Baseline), `#C2`(transient activation), `#C4`(secure context) | `official-reference` | 두 경로의 UX 가 달라진다 — picker 는 저장 위치 선택, fallback 은 브라우저 다운로드 폴더 고정 |
|
||||
| D4 | 취소와 "위험 파일 거부"를 구분하지 않고 `FILE_PICKER_DISMISSED` 로 정규화 | 항상. 브라우저가 두 사유를 구분 가능한 신호로 분리하면 재검토 | `mdn-file-system-access-opfs.md#C3`(둘 다 같은 `AbortError`) | `official-reference` | 거부 사유를 사용자에게 설명할 수 없다. "선택된 파일이 없습니다" 수준의 중립 문구만 가능 |
|
||||
| D5 | OPFS 는 비동기 API 기본, 동기 handle 이 필요할 때만 `WorkerTaskPort` 경유 | 대용량 순차 write 로 메인 스레드 블로킹이 측정될 때만 동기 경로. 그 전에는 비동기 | `mdn-file-system-access-opfs.md#C5`(동기 API 는 worker 전용), `#C6`(속도 우위의 비교 대상은 File System Access API) | `official-reference` | worker 경유는 `CAP_FE_BACKGROUND_EXEC` 활성을 전제한다 — 두 capability 가 얽힌다 |
|
||||
| D6 | `evictionOrder` 는 애플리케이션 주도 정리 순서로만 유효 | 항상. 브라우저가 per-key eviction 힌트 API 를 제공하면 재검토 | `mdn-storage-quotas-eviction-persistence.md#C1`(origin 전량 삭제), `#C3`(LRU origin 단위) | `official-reference` | hub §5.5 의 필드 설명이 이 경계를 담고 있지 않다 — §Audit & Findings `EVICTION_SCOPE_DRIFT` |
|
||||
| D6 | `evictionOrder` 는 애플리케이션 주도 정리 순서로만 유효 | 항상. 브라우저가 per-key eviction 힌트 API 를 제공하면 재검토 | `mdn-storage-quotas-eviction-persistence.md#C1`(origin 전량 삭제), `#C3`(LRU origin 단위) | `official-reference` | 없음 — 2026-07-28 hub §5.5 필드 Rule 이 이 경계를 담도록 정정됐다(§Audit & Findings `EVICTION_SCOPE_DRIFT` `resolved`) |
|
||||
| D7 | correctness 값 사용 시 `persist()` 1회 요청 + 미허가를 표면화 | correctness 값(`UPLOAD_PART_STATE` 등)을 쓰는 capability 가 활성일 때. 순수 캐시만 쓰면 요청하지 않음 | `mdn-storage-quotas-eviction-persistence.md#C4`(persist 는 LRU 제외), `#C5`(허가는 브라우저 재량), `#C7`(Safari 7일 규칙) | `official-reference` | persist 미허가 + Safari ITP 조합이면 재개 가능 전송이 7일 만에 무효가 된다. 제품이 이를 수용 가능한지 미확인 |
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
@@ -284,14 +284,14 @@ status_label: in-progress
|
||||
|
||||
## Audit & Findings
|
||||
|
||||
> `/branch-spec` 2026-07-28 조사에서 발견한 **상위 계약의 사실 오류**. 이 branch 가 자동 수정하지 않고 정합 권고만 남긴다 — `FE-D027` 과 `FE-REG-STORAGE` 는 hub 소유이며 변경은 hub §3.3 프로토콜을 따라야 한다.
|
||||
> `/branch-spec` 2026-07-28 조사에서 발견한 **상위 계약의 사실 오류**. `FE-D027` 과 `FE-REG-STORAGE` 는 hub 소유이므로 이 branch 는 권고만 냈고, 실제 반영은 사용자 승인 후 hub §3.3 프로토콜(둘 다 `compatibility_impact: none`, revision 유지)로 수행했다.
|
||||
|
||||
| Finding ID | 대상 | 현재 서술 | 조사 결과 | 권고 |
|
||||
|---|---|---|---|---|
|
||||
| `EVICTION_SCOPE_DRIFT` | hub §5.5 `FE-REG-STORAGE.evictionOrder` | "quota 압박 시 제거 순서(정수, 낮을수록 먼저)" — 주체가 명시되지 않아 브라우저 eviction 에도 적용되는 것처럼 읽힌다 | `mdn-storage-quotas-eviction-persistence#C1`: "When an origin's data is evicted by the browser, **all of its data, not parts of it**, is deleted at the same time." `#C3`: LRU 는 **origin 단위** | 필드 Rule 에 "**애플리케이션 주도** 정리 순서. 브라우저 eviction 은 origin 전량 삭제이므로 이 순서가 적용되지 않는다" 를 추가 |
|
||||
| `CACHE_STORAGE_CONSTRAINT_DRIFT` | hub `FE-D027` / `DEC-…-BINARY-STORE-001@1` | "Cache Storage는 service worker 호스팅 response cache 전용이다" — 기술 제약처럼 읽힌다 | `mdn-object-url-cache-storage#C3`: "you're not limited to only using it with service workers", `Window.caches` 로 접근 가능 | 결정 자체는 유효(정책 선택). §5.5 또는 `FE-D027` rationale 에 "플랫폼 제약이 아니라 release coherence 를 위한 **정책** 선택" 임을 명시 |
|
||||
| `NO_GROUND_TRUTH` | `/branch-spec` §2 ca-tmpl 대조 | 명령은 `/home/donghyeon/workspace/ca-tmpl` 의 registry·코드와 대조하라고 요구 | `ca-tmpl` 은 Gradle/Java **백엔드 전용**(`domain-core`·`adapter-web`·`shared-contract`). `package.json`·`vite.config`·`.jsx` 부재. frontend 구현 repo 는 hub §0.3 대로 미식별 | 이 branch 의 모든 명세는 `planned`. `actually-implemented` 승격은 frontend repo 식별(`FE-Q-001`) 이후 |
|
||||
| `CAPABILITY_NAME_COLLISION` | `ca-tmpl/docs/registries/capabilities.yaml` vs hub `FE-REG-CAPABILITY` | 두 registry 가 같은 "capability" 어휘를 쓴다 | ca-tmpl 은 *use case → infrastructure 접근 권한*(`READ_REPOSITORY` 등), hub 는 *브라우저 런타임 기능 flag*(`CAP_FE_BINARY_IO` 등). **다른 개념** | 계약을 상호 참조하지 않는다. 혼동 방지를 위해 frontend 쪽은 `CAP_FE_` prefix 를 유지 |
|
||||
| Finding ID | 대상 | 현재 서술 | 조사 결과 | 권고 | 처리 |
|
||||
|---|---|---|---|---|---|
|
||||
| `EVICTION_SCOPE_DRIFT` | hub §5.5 `FE-REG-STORAGE.evictionOrder` | "quota 압박 시 제거 순서(정수, 낮을수록 먼저)" — 주체가 명시되지 않아 브라우저 eviction 에도 적용되는 것처럼 읽힌다 | `mdn-storage-quotas-eviction-persistence#C1`: "When an origin's data is evicted by the browser, **all of its data, not parts of it**, is deleted at the same time." `#C3`: LRU 는 **origin 단위** | 필드 Rule 에 "**애플리케이션 주도** 정리 순서. 브라우저 eviction 은 origin 전량 삭제이므로 이 순서가 적용되지 않는다" 를 추가 | `resolved` 2026-07-28 — hub §5.5 필드 Rule + 해설 문단 반영, §6.1 개정 기록 등재 |
|
||||
| `CACHE_STORAGE_CONSTRAINT_DRIFT` | hub `FE-D027` / `DEC-…-BINARY-STORE-001@1` | "Cache Storage는 service worker 호스팅 response cache 전용이다" — 기술 제약처럼 읽힌다 | `mdn-object-url-cache-storage#C3`: "you're not limited to only using it with service workers", `Window.caches` 로 접근 가능 | 결정 자체는 유효(정책 선택). §5.5 또는 `FE-D027` rationale 에 "플랫폼 제약이 아니라 release coherence 를 위한 **정책** 선택" 임을 명시 | `resolved` 2026-07-28 — `FE-D027` 서술·rationale + `DEC-…-BINARY-STORE-001` Summary·Evidence 정정, 위 상속 표 동기화 |
|
||||
| `NO_GROUND_TRUTH` | `/branch-spec` §2 ca-tmpl 대조 | 명령은 `/home/donghyeon/workspace/ca-tmpl` 의 registry·코드와 대조하라고 요구 | `ca-tmpl` 은 Gradle/Java **백엔드 전용**(`domain-core`·`adapter-web`·`shared-contract`). `package.json`·`vite.config`·`.jsx` 부재. frontend 구현 repo 는 hub §0.3 대로 미식별 | 이 branch 의 모든 명세는 `planned`. `actually-implemented` 승격은 frontend repo 식별(`FE-Q-001`) 이후 | `open` — `FE-Q-001` 해소 전까지 유지 |
|
||||
| `CAPABILITY_NAME_COLLISION` | `ca-tmpl/docs/registries/capabilities.yaml` vs hub `FE-REG-CAPABILITY` | 두 registry 가 같은 "capability" 어휘를 쓴다 | ca-tmpl 은 *use case → infrastructure 접근 권한*(`READ_REPOSITORY` 등), hub 는 *브라우저 런타임 기능 flag*(`CAP_FE_BINARY_IO` 등). **다른 개념** | 계약을 상호 참조하지 않는다. 혼동 방지를 위해 frontend 쪽은 `CAP_FE_` prefix 를 유지 | `accepted` — prefix 유지로 종결, 추가 조치 없음 |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ id: BR-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-029
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-frontend-operational-contract
|
||||
work_item: WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-029
|
||||
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CAPABILITY-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CROSS-TAB-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-SERVER-STATE-001@1]
|
||||
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CAPABILITY-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CROSS-TAB-001@2, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-SERVER-STATE-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-010, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-011]
|
||||
@@ -44,7 +44,7 @@ status_label: in-progress
|
||||
|
||||
- **생성 시 프로젝트 개정**: `2`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: version 파티션·탭 간 무효화·채널 부재 fallback fixture가 통과한다
|
||||
- **완료 조건**: version 파티션·탭 간 무효화·채널 부재 시 탭 내 무효화 fixture가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
@@ -52,7 +52,7 @@ status_label: in-progress
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CAPABILITY-001@1` | 신규 runtime capability 6종은 FE-REG-CAPABILITY flag로 default OFF이며 활성화는 owner·gate·runbook을 동반한다 | `CAP_FE_CACHE_PERSISTENCE` 를 이 branch 가 소유하고 default OFF 로 유지한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CROSS-TAB-001@1` | 탭 간 무효화는 BroadcastChannel 우선에 storage event fallback을 쓰고 leader election 없이 무효화 key만 전파한다 | `CrossTabSyncPort` adapter 의 transport 선택과 메시지 봉투에 적용한다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CROSS-TAB-001@2` | 탭 간 무효화는 BroadcastChannel만 쓰고 별도 fallback 경로를 두지 않으며 leader election 없이 무효화 key만 전파한다 | `CrossTabSyncPort` adapter 의 transport 선택과 메시지 봉투에 적용한다 | [[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를 복제하지 않는다 | 영속 tier 를 추가해도 `QueryCachePort` 를 우회하지 않는다 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
@@ -60,9 +60,15 @@ status_label: in-progress
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | 영속 캐시 파티션 키는 `releaseId`·`configSchemaVersion`·`apiContractVersion` 세 값을 모두 포함하고 하나라도 불일치하면 복원하지 않고 폐기한다 | `refines DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-SERVER-STATE-001@1` | 근거 raw 미수집 (`FE-Q-011`) | `proposed` |
|
||||
| D2 | 탭 간 메시지는 무효화 key 만 싣고 값을 싣지 않는다 | `refines DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CROSS-TAB-001@1` | 근거 raw 미수집 (`FE-Q-011`) | `proposed` |
|
||||
| D3 | `CachePersistencePort` 는 `BlobStorePort` 를 재사용하지 않고 자체 백엔드를 가진다 | `local` | 근거 raw 미수집 (`FE-Q-011`) | `proposed` |
|
||||
| D1 | 영속 캐시 파티션 키는 `releaseId`·`configSchemaVersion`·`apiContractVersion` 세 값을 모두 포함하고 하나라도 불일치하면 복원하지 않고 폐기한다 | `refines DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-SERVER-STATE-001@1` | `raw/official-docs/tanstack-query-persistence-hydration-official.md#C1`, `#C2` (폐기 동작). **tuple 구성은 project-local** | `proposed` |
|
||||
| D2 | 탭 간 메시지는 무효화 key 만 싣고 값을 싣지 않는다 | `refines DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CROSS-TAB-001@2` | `raw/official-docs/mdn-broadcastchannel-storage-event.md#C5` (structured clone 이라 값 전송은 *가능* — 금지는 우리 선택) | `proposed` |
|
||||
| D3 | `CachePersistencePort` 는 `BlobStorePort` 를 재사용하지 않고 자체 백엔드를 가진다 | **`UNSUPPORTED_DECISION`** — 조사 후에도 근거 없음. 오히려 `raw/official-docs/mdn-storage-quotas-eviction-persistence.md#C1`·`#C2` 는 백엔드를 나눠도 **quota·eviction 은 origin 단위로 함께** 움직인다고 말하므로 "quota 격리" 를 이 결정의 근거로 쓸 수 없다. trade-off: 그럼에도 분리를 택한 이유는 `FE-OC-002` 의 port 경계다 — 캐시 스냅샷과 전송 버퍼는 수명·폐기 규칙·소유 branch 가 전부 다르고, 하나의 port 로 묶으면 한쪽 폐기 규칙이 다른 쪽에 샌다. 비용은 같은 origin 에 IndexedDB 사용처가 둘로 늘어나는 것이며 이는 quota 경쟁으로 나타난다 | `proposed` |
|
||||
| D4 | 로컬 무효화는 채널을 거치지 않고 직접 수행한다. adapter 는 컨텍스트당 `BroadcastChannel` 객체를 **정확히 1개** 유지한다 | `local` | `raw/official-docs/mdn-broadcastchannel-storage-event.md#C2` (보낸 **객체**만 제외) | `proposed` |
|
||||
| D5 | 채널을 만들 수 없으면 대체 경로를 만들지 않고 탭 내 무효화만 수행한다 | `refines DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CROSS-TAB-001@2` | `raw/official-docs/mdn-broadcastchannel-storage-event.md#C4` (2022-03 부터 모든 주요 브라우저에서 동작), `#C10` (대체 후보였던 `sessionStorage` 경로는 다른 탭에 도달하지 않음) | `proposed` |
|
||||
| D6 | 복원은 bootstrap 에서 await 하여 **첫 렌더 이전에** 끝낸다. 복원 실패·불일치는 부팅을 막지 않고 메모리 캐시로 진행한다 | `local` | `raw/official-docs/tanstack-query-persistence-hydration-official.md#C5` (복원 중 렌더는 mount·fetch 와 경합) | `proposed` |
|
||||
| D7 | 영속 대상은 **성공한 query** 중 `FE-REG-QUERY.persistenceTier` 가 `memory` 가 아닌 행으로 한정하고, 직렬화는 adapter 책임이다 | `local` | `raw/official-docs/tanstack-query-persistence-hydration-official.md#C6` (기본이 성공 query 만), `#C7` (직렬화는 소비자 책임) | `proposed` |
|
||||
|
||||
**deferred (이번 회차 조사 범위 밖)**: D8 — 영속 write 의 최소 간격과 병합 정책. `tanstack-query-persistence-hydration-official.md#C4` 는 **번들 persister** 가 1초 throttle 을 쓴다고만 말하므로 custom adapter 값의 근거가 아니다. 적정 간격은 스냅샷 크기 실측 후 정한다.
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
@@ -115,7 +121,7 @@ status_label: in-progress
|
||||
### 포함 범위
|
||||
|
||||
- `CachePersistencePort` — 캐시 스냅샷 직렬화·영속·복원과 복원 거부
|
||||
- `CrossTabSyncPort` — BroadcastChannel 우선, `storage` event fallback, 채널 부재 시 탭 내 무효화만
|
||||
- `CrossTabSyncPort` — BroadcastChannel 전송, 채널 부재 시 탭 내 무효화만
|
||||
- `persistenceTier`·`crossTabScope` 규칙(`FE-REG-QUERY` 확장) 소비
|
||||
- release·config·API version 파티션과 불일치 시 폐기
|
||||
- `CAP_FE_CACHE_PERSISTENCE` capability 행 소유
|
||||
@@ -134,9 +140,12 @@ status_label: in-progress
|
||||
|---|---|
|
||||
| `[[docs/superpowers/specs/2026-07-28-ca-skeleton-frontend-runtime-adapter-features-design]]` §5.1·§6.2 | port 분해와 `FE-REG-QUERY` 확장 |
|
||||
| [[raw/official-docs/tanstack-query-server-state-official]] | `QueryCachePort` 정책의 상위 근거 (persistence 는 이 문서가 다루지 않음) |
|
||||
| [[raw/official-docs/tanstack-query-persistence-hydration-official]] | D1 불일치 캐시 전량 폐기 · D6 복원/렌더 경합 gating · D7 영속 대상 선별과 직렬화 책임 |
|
||||
| [[raw/official-docs/mdn-broadcastchannel-storage-event]] | D2 메시지 봉투 제약 · D4 발신자 자기 수신 불가 · D5 fallback 이 `localStorage` 여야 하는 이유 |
|
||||
| [[raw/official-docs/mdn-storage-quotas-eviction-persistence]] | D2 fallback 크기 예산(`#C6`) · D3 반증(`#C1`·`#C2` — 백엔드를 나눠도 quota 는 분리되지 않음) |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §5.7·§9.2 | query key registry 와 cache defaults |
|
||||
|
||||
**근거 등급 경계**: `FE-D028`(탭 간 무효화 transport)의 rationale 은 `project-local default, 외부 source claim 아님` 이다. TanStack Query 공식 문서는 persister 를 다루지만 이 repo 의 raw 발췌에는 그 내용이 없으며, BroadcastChannel·`storage` event 근거도 미수집이다(`FE-Q-011`).
|
||||
**근거 등급 경계**: 2026-07-28 `/branch-spec` 조사로 채널의 **의미론**(전달 범위·자기 제외·직렬화)과 캐시 영속의 **폐기·복원 동작**은 공식 문서 근거를 확보했다. 아직 근거가 없는 것은 파티션 키를 **세 값으로** 구성한다는 D1 의 tuple 구성이다 — 라이브러리는 buster 가 문자열이라는 것만 말한다(`tanstack-query-persistence-hydration-official#C1`). §검증해야 할 주장에 기록했다. 조사 중 나왔던 "대체 경로의 존재 이유 부족" 은 경로 자체를 삭제해 해소했다(§Audit & Findings).
|
||||
|
||||
## TODO
|
||||
|
||||
@@ -144,46 +153,112 @@ status_label: in-progress
|
||||
- [ ] `CrossTabSyncPort` 인터페이스와 메시지 봉투 확정 — 등급: `planned`
|
||||
- [ ] version 불일치 캐시 폐기 fixture — 등급: `planned`
|
||||
- [ ] 탭 A mutation → 탭 B 무효화 integration fixture — 등급: `planned`
|
||||
- [ ] BroadcastChannel 부재 시 `storage` event fallback fixture — 등급: `planned`
|
||||
- [ ] 두 transport 모두 불가 시 `CROSS_TAB_CHANNEL_UNAVAILABLE` 처리 — 등급: `planned`
|
||||
- [ ] BroadcastChannel 부재 시 `CROSS_TAB_CHANNEL_UNAVAILABLE` + 탭 내 무효화만 (D5) — 등급: `planned`
|
||||
- [ ] 발신 탭 자기 수신 없음 negative fixture (D4) — 등급: `planned`
|
||||
- [ ] 복원 await 가 첫 렌더보다 앞서는지 순서 fixture (D6) — 등급: `planned`
|
||||
- [ ] `persistenceTier` 미지정 query 가 영속되지 않음을 확인하는 fixture (D7) — 등급: `planned`
|
||||
- [ ] 파티션 폐기·fallback 진입·채널 부재 telemetry event 등록 (관심사 커버리지 should-fix) — 등급: `planned`
|
||||
- [ ] `FE-GATE-028` cache tier report 산출 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
`crossTabScope: same-origin` 이 값이 아니라 key 만 전파하는 이유는 두 가지다. 값을 전파하면 (1) 수신 탭이 자기 권한으로 얻지 않은 데이터를 갖게 되고, (2) 메시지가 커져 `storage` event fallback 의 크기 제한에 부딪힌다. 수신 탭은 key 를 받아 자기 `QueryCachePort` 로 refetch 한다.
|
||||
`crossTabScope: same-origin` 이 값이 아니라 key 만 전파하는 이유는 하나다. 값을 전파하면 수신 탭이 자기 권한으로 얻지 않은 데이터를 갖게 된다. 수신 탭은 key 를 받아 자기 `QueryCachePort` 로 refetch 한다.
|
||||
|
||||
이건 플랫폼 제약이 아니다. `mdn-broadcastchannel-storage-event#C5` 대로 structured clone 이라 값을 그대로 보낼 수 있다 — 안 보내는 건 우리 정책이다.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-07-28: 파티션 키에 세 version 을 모두 포함 / 이유: 하나만 쓰면 config 만 바뀐 배포에서 stale 캐시가 살아남음 / 검토한 대안: `releaseId` 단독 / 근거: 근거 raw 미수집, project-local 판단 (`FE-Q-011`)
|
||||
- 2026-07-28: leader election 미도입 / 이유: 탭 간 무효화에 리더가 필요 없고 리더 선출 자체가 새 실패 모드 / 검토한 대안: Web Locks 기반 리더 / 근거: `FE-D028`
|
||||
- 2026-07-28: `storage` event 대체 경로 삭제 / 이유: BroadcastChannel 이 2022-03 부터 모든 주요 브라우저에서 동작하고, 대체가 필요한 환경이 지원 대상에 있다는 근거가 없다. 평소 실행되지 않는 경로는 테스트로도 검증되지 않으면서 저장 예산·자기 수신 제외·연속 동일값 회피 장치를 계속 요구한다 / 검토한 대안: `localStorage` signal key 유지 / 근거: `mdn-broadcastchannel-storage-event#C4`·`#C10`, hub `CROSS-TAB-001` revision 2
|
||||
|
||||
<!-- section-id: decision-evidence -->
|
||||
## 결정-근거 매핑
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | 세 version 파티션 + 불일치 시 폐기 | 항상. migration 이 폐기보다 싼 대용량 캐시가 생기면 재검토 | 없음 — `FE-Q-011` | `project-local default` | 매 배포마다 캐시가 비워져 첫 로드가 느려질 수 있음 |
|
||||
| D2 | key 만 전파 | 항상. 값 전파가 필요한 실시간 협업 요구가 생기면 재검토 | 없음 — `FE-Q-011` | `project-local default` | 수신 탭의 refetch 가 몰려 backend 부하가 튈 수 있음 |
|
||||
| D3 | `CachePersistencePort` 가 자체 백엔드 보유 | 항상. 두 port 가 같은 IndexedDB 를 두고 quota 경쟁하면 재검토 | 없음 — `FE-Q-011` | `project-local default` | 같은 origin 에서 두 개의 IndexedDB 사용처가 생김 |
|
||||
| D1 | 세 version 파티션 + 불일치 시 전량 폐기 | 항상. migration 이 폐기보다 싼 대용량 캐시가 생기면 재검토 | `tanstack-query-persistence-hydration-official.md#C1`(buster 불일치 → discarded), `#C2`(`removeClient()` 후 즉시 폐기, 부분 복원 경로 없음) | `official-reference`(폐기 동작) + `project-local default`(tuple 구성) | 매 배포마다 캐시가 비워져 첫 로드가 느려진다. 세 값 중 어느 것이 실제로 캐시를 무효화해야 하는지는 미측정 |
|
||||
| D2 | key 만 전파 | 항상. 값 전파가 필요한 실시간 협업 요구가 생기면 재검토 | `mdn-broadcastchannel-storage-event.md#C5`(structured clone — 값 전송은 기술적으로 가능), `mdn-storage-quotas-eviction-persistence.md#C6`(localStorage 5 MiB) | `project-local default`(금지 자체) + `official-reference`(제약) | 수신 탭의 refetch 가 몰려 backend 부하가 튄다. dedup 경계가 `QueryCachePort` 와 얇다 |
|
||||
| D3 | `CachePersistencePort` 가 자체 백엔드 보유 | 항상. 단, quota 격리를 근거로 쓰면 안 된다 | 없음 — 조사 결과 **반증**만 나왔다(`mdn-storage-quotas-eviction-persistence.md#C1`·`#C2`: origin 단위 전량 eviction) | `UNSUPPORTED_DECISION` | 같은 origin 에 IndexedDB 사용처가 둘. 한쪽이 quota 를 소진하면 **다른 쪽도 함께** 브라우저 eviction 대상이 된다 |
|
||||
| D4 | 로컬 무효화는 채널 왕복 없이 직접 수행 | 항상. 발신자 echo 가 없으므로 예외 없음 | `mdn-broadcastchannel-storage-event.md#C2` | `official-reference` | 채널 객체를 실수로 2개 만들면 자기 메시지를 자기가 받는 경로가 생겨 무효화가 2회 실행된다 |
|
||||
| D5 | 대체 경로 없음 — 채널 부재 시 탭 내 무효화만 | 항상. 지원 대상 브라우저에 BroadcastChannel 미동작 환경이 실제로 들어오면 재검토 | `mdn-broadcastchannel-storage-event.md#C4`, `#C10` | `official-reference` | 미동작 환경이 나중에 발견되면 그 환경의 사용자는 다중 탭에서 stale 을 본다. 감지는 `CROSS_TAB_CHANNEL_UNAVAILABLE` 계측에 의존한다 |
|
||||
| D6 | 복원을 첫 렌더 이전에 await | 항상. 복원 시간이 체감 가능해지면 skeleton UI 로 보완하되 순서는 유지 | `tanstack-query-persistence-hydration-official.md#C5` | `official-reference` | 복원이 느린 저사양 기기에서 첫 페인트가 지연된다 |
|
||||
| D7 | 성공 query + `persistenceTier` 로 대상 한정, 직렬화는 adapter | 항상. error/pending 캐시를 살려야 할 요구가 생기면 재검토 | `tanstack-query-persistence-hydration-official.md#C6`, `#C7` | `official-reference` | registry 에 `persistenceTier` 를 빠뜨린 신규 query 는 조용히 영속되지 않는다 — gate 가 잡아야 함 |
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
> 근거 raw 자료(`FE-Q-011`) 수집 전까지 비워 둔다. 지금 채우면 모든 cell 이 `UNSUPPORTED_IMPL_DECISION` 이 된다.
|
||||
> 2026-07-28 `/branch-spec` 조사(MDN 2건 + TanStack 공식 2페이지)로 in-scope detail 을 채웠다. 근거가 원칙만 지지하고 detail 은 지지하지 않는 cell 은 `UNSUPPORTED_IMPL_DECISION` + trade-off 한 줄로 표시했다(CLAUDE.md §15.5 R2). 다른 branch 결정 영역(`QueryCachePort` 정책, storage key·classification)은 남기지 않았다(R3).
|
||||
|
||||
### 1. `CrossTabSyncPort` — 채널 하나와 발신자 처리
|
||||
|
||||
> **Trace**: D4(로컬 무효화 직접 수행) ← `mdn-broadcastchannel-storage-event.md#C2` / D5(대체 경로 없음) ← 같은 문서 `#C4`·`#C10` / 상속 `DEC-…-CROSS-TAB-001@2`
|
||||
|
||||
adapter 는 부팅 시 채널을 한 번 만들고 그 뒤로 바꾸지 않는다. 경로는 두 개뿐이다.
|
||||
|
||||
| 조건 | 동작 |
|
||||
|---|---|
|
||||
| `BroadcastChannel` 생성 성공 | 채널 객체를 **컨텍스트당 1개** 만들어 발행·수신 모두에 쓴다 |
|
||||
| 생성 실패 | `CROSS_TAB_CHANNEL_UNAVAILABLE` 로 표면화하고 **탭 내 무효화만** 수행한다(§8.2). 대체 전송을 만들지 않는다 |
|
||||
|
||||
**발신 탭의 무효화는 채널을 거치지 않고 직접 호출**한다. `#C2` 가 보낸 객체를 수신 대상에서 제외하므로, echo 를 기다리는 구현은 발신 탭만 stale 로 남긴다.
|
||||
|
||||
대체 경로를 두지 않는 이유는 `#C4` 다 — BroadcastChannel 은 2022년 3월부터 모든 주요 브라우저에서 동작하며, 대체가 필요한 환경이 지원 대상에 있다는 근거는 조사에서 나오지 않았다. 유력 후보였던 `storage` event 경로는 `#C10` 대로 `sessionStorage` 에서 다른 탭에 도달하지 않아, 잘못 구현하면 **조용히 아무 일도 하지 않는** 경로가 된다. 평소 실행되지 않아 테스트로도 걸리지 않는 경로를 미리 만들어 두지 않고, 미동작 환경이 실제로 관측되면(`CROSS_TAB_CHANNEL_UNAVAILABLE` 계측) 그때 추가한다.
|
||||
|
||||
`UNSUPPORTED_IMPL_DECISION` — 부재 판정을 `typeof` 검사 + 생성 시도로 하는 것. `#C4` 는 Baseline 이라고만 말하고 *부재를 어떻게 감지하는지*는 말하지 않는다. trade-off: 생성까지 시도해야 차단 환경의 예외를 잡을 수 있어 객체 1개 비용을 감수했다.
|
||||
|
||||
### 2. 메시지 봉투
|
||||
|
||||
> **Trace**: D2(key 만 전파) ← `mdn-broadcastchannel-storage-event.md#C5` / D4 ← `#C2`
|
||||
|
||||
봉투는 두 필드만 갖는다.
|
||||
|
||||
- `keys` — 무효화할 query key 배열. **`FE-OC-012` 의 registry factory 가 만든 key 만** 허용한다. 값·응답 본문·사용자 식별자를 넣지 않는다
|
||||
- `origin` — 발신 컨텍스트 식별자. 수신부가 자기 발신을 걸러내는 2차 방어(1차는 D4 의 채널 자체 제외)
|
||||
|
||||
값을 싣지 않는 것은 기술 제약이 아니다. `#C5` 대로 structured clone 이라 객체를 그대로 보낼 수 있다. 금지하는 이유는 수신 탭이 자기 권한으로 얻지 않은 데이터를 갖게 되기 때문이며, 수신 탭은 key 를 받아 자기 `QueryCachePort` 로 다시 가져온다.
|
||||
|
||||
수신부는 방어적으로 판독한다. 봉투 파싱 실패는 던지지 않고 계측한 뒤 무시한다 — 무효화 신호 하나를 놓치는 것이 UI 를 죽이는 것보다 낫다.
|
||||
|
||||
`UNSUPPORTED_IMPL_DECISION` — `origin` 필드로 2차 방어를 두는 것. `#C2` 만으로 발신자 제외가 보장되므로 원칙적으로는 불필요하다. trade-off: 채널 객체가 실수로 2개 만들어졌을 때 중복 무효화를 막는 안전망이며, 문자열 하나의 비용으로 D4 위반을 런타임에 흡수한다.
|
||||
|
||||
### 3. `CachePersistencePort` — 파티션 키와 복원 순서
|
||||
|
||||
> **Trace**: D1(3-tuple + 폐기) ← `tanstack-query-persistence-hydration-official.md#C1`·`#C2` / D6(렌더 이전 await) ← `#C5` / D7(대상 선별·직렬화) ← `#C6`·`#C7` / 상속 `DEC-…-SERVER-STATE-001@1`
|
||||
|
||||
파티션 키는 `<releaseId>:<configSchemaVersion>:<apiContractVersion>` 단일 문자열로 만들어 스냅샷과 함께 저장한다. 복원 시 이 문자열이 **정확히 일치하지 않으면** 스냅샷을 폐기하고 빈 캐시로 시작한다. 부분 복원·필드 단위 migration 경로를 두지 않는 것은 `#C2` 가 채택 라이브러리의 기존 동작(`removeClient()` 후 즉시 폐기)임을 확인해 준다.
|
||||
|
||||
복원 순서는 bootstrap §4.5 의 캐시 복원 단계에서 **await** 한다. `#C5` 가 경고하는 경합(복원 중 query mount → fetch)이 정확히 이 순서를 어겼을 때 나타난다. 복원 실패는 부팅을 막지 않는다 — `CACHE_PERSISTENCE_FAILURE` 로 계측하고 메모리 캐시로 계속한다(§8.2 와 정합).
|
||||
|
||||
영속 대상은 `FE-REG-QUERY.persistenceTier !== 'memory'` 인 행 **중 성공한 query** 로 한정한다. `#C6` 의 기본 동작과 같은 방향이며, registry 를 상위 필터로 두어 "라이브러리 기본값이 바뀌면 우리 계약도 바뀌는" 결합을 끊는다. 직렬화는 adapter 책임이다(`#C7`).
|
||||
|
||||
`UNSUPPORTED_IMPL_DECISION` — 파티션 키를 `:` 구분 단일 문자열로 만드는 것. `#C1` 은 buster 가 문자열이라는 것만 말하고 구성·구분자를 말하지 않는다. trade-off: 구조화 객체 대신 문자열을 택해 비교를 동등성 1회로 끝냈다. 비용은 세 값 중 **무엇이** 불일치했는지 폐기 시점에 알 수 없는 것이며, 이는 telemetry 에 세 값을 따로 실어 보완한다.
|
||||
|
||||
### 4. 이 branch 가 남기지 않는 것 (R3)
|
||||
|
||||
- query 의 stale time·gc·refetch·invalidation 매핑 → [[raw/branch-notes/feature-server-state-caching-contract]] 소유(`FE-OC-012`)
|
||||
- `QUERY_CACHE_SNAPSHOT` 의 physical key·namespace·classification·quota fallback → `DELEG-FE-009` 로 [[raw/branch-notes/feature-frontend-storage-registry-contract]] 에 위임
|
||||
- 브라우저 eviction 자체에 대한 방어(`persist()`) → [[raw/branch-notes/feature-frontend-binary-file-io-store-contract]] `D7` 소유. 이 branch 의 캐시는 폐기돼도 correctness 를 잃지 않으므로 persist 를 요청하지 않는다
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**
|
||||
- 직렬화·영속·복원 실패 → `CACHE_PERSISTENCE_FAILURE`, 메모리 캐시만 사용하고 제품 흐름을 막지 않음
|
||||
- BroadcastChannel 과 `storage` event 모두 불가 → `CROSS_TAB_CHANNEL_UNAVAILABLE`, 탭 내 무효화만 수행
|
||||
- 파티션 키 불일치 캐시 발견 → 복원하지 않고 폐기. 부분 복원 금지
|
||||
- 직렬화·영속·복원 실패 → `CACHE_PERSISTENCE_FAILURE`, 메모리 캐시만 사용하고 제품 흐름을 막지 않음 (D6)
|
||||
- BroadcastChannel 생성 불가 → `CROSS_TAB_CHANNEL_UNAVAILABLE`, 탭 내 무효화만 수행. 대체 전송을 시도하지 않는다 (D5)
|
||||
- 파티션 키 불일치 캐시 발견 → 복원하지 않고 폐기. 부분 복원 금지 (D1)
|
||||
- 수신 탭이 무효화 key 를 받았으나 해당 query 를 구독하지 않음 → 무시 (에러 아님)
|
||||
- 다중 탭이 동시에 같은 key 를 무효화 → 중복 refetch 를 `QueryCachePort` 의 dedup 이 흡수해야 함
|
||||
- **발신 탭이 자기 무효화를 놓침** → 채널이 발신자에게 echo 하지 않으므로(`mdn-broadcastchannel-storage-event#C2`) 로컬 무효화는 직접 호출한다 (D4). echo 대기 구현은 이 경로에서 조용히 실패한다
|
||||
- **채널 객체를 2개 이상 만든 경우** → `#C2` 의 제외 단위가 *객체*라서 같은 문서의 두 번째 객체가 자기 메시지를 수신해 무효화가 중복 실행된다. adapter 는 컨텍스트당 1개를 강제하고 `origin` 필드로 흡수한다 (D4)
|
||||
- **닫힌 채널에 발행** → `InvalidStateError`(`#C6`). unmount 후 발행 경로가 남아 있다는 신호이므로 삼키지 않고 계측한다
|
||||
- 봉투 JSON 파싱 실패 → 던지지 않고 계측 후 무시. 신호 1건 손실이 UI 중단보다 낫다
|
||||
- **다른 계약 의존**
|
||||
- [[raw/branch-notes/feature-server-state-caching-contract]] 의 `QueryCachePort` 정책에 의존 — invalidation 매핑이 바뀌면 전파 대상이 바뀜
|
||||
- [[raw/branch-notes/feature-frontend-storage-registry-contract]] 의 `QUERY_CACHE_SNAPSHOT` 행에 의존 (`DELEG-FE-009`)
|
||||
- [[raw/branch-notes/feature-frontend-contract-compatibility-governance]] 의 version tuple 에 의존 — 파티션 키가 그 tuple 에서 나옴
|
||||
- [[raw/branch-notes/feature-server-state-caching-contract]] `D?` — `QueryCachePort` 의 invalidation 매핑과 dedup 책임. 매핑이 바뀌면 전파 대상이 바뀌고, dedup 이 없으면 D2 의 refetch 폭주 위험이 이 branch 로 되돌아온다. 해당 branch 의 Decision ID 는 `/branch-spec` 미실행이라 아직 부여되지 않았다 — 확정 시 이 줄을 D-ID 로 갱신한다
|
||||
- [[raw/branch-notes/feature-frontend-storage-registry-contract]] — `QUERY_CACHE_SNAPSHOT` 행의 physical key·classification·TTL·`quotaFallback`. `DELEG-FE-009` 로 위임했고 D1·D7 이 그 행을 소비한다
|
||||
- [[raw/branch-notes/feature-frontend-contract-compatibility-governance]] — `releaseId`·`configSchemaVersion`·`apiContractVersion` version tuple 의 정의. D1 의 파티션 키가 이 tuple 에서 나오므로 tuple 구성이 바뀌면 D1 도 바뀐다
|
||||
- [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] `D4` — `CAP_FE_CACHE_PERSISTENCE` 를 포함한 capability flag 의 해석 시점. flag 가 OFF 면 이 branch 의 adapter 는 번들에 없어야 한다(`FE-GATE-033`)
|
||||
- [[raw/branch-notes/feature-frontend-binary-file-io-store-contract]] `D7` — `navigator.storage.persist()` 요청 소유권. 이 branch 는 요청하지 않으며, 캐시가 브라우저 eviction 으로 사라져도 폐기와 같은 경로로 처리한다
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장
|
||||
@@ -192,13 +267,42 @@ status_label: in-progress
|
||||
|---|---|---|---|
|
||||
| version 불일치 캐시가 복원되지 않는다 | 부분 복원이 조용히 성공하기 쉬움 | negative fixture — 이전 version 스냅샷 주입 후 복원 시도가 폐기로 끝나는지 | `planned` |
|
||||
| 탭 A mutation 이 탭 B 캐시를 무효화한다 | BroadcastChannel 은 같은 origin 의 다른 탭에서만 동작 | integration test — 두 컨텍스트에서 발행/수신 확인 | `planned` |
|
||||
| BroadcastChannel 부재 시 `storage` event 로 대체된다 | fallback 경로가 실제로 도달하는지 불확실 | fixture — BroadcastChannel 을 undefined 로 만들고 전파 확인 | `planned` |
|
||||
| BroadcastChannel 부재 시 탭 내 무효화만 하고 조용히 넘어가지 않는다 | 부재를 감지 못하면 다중 탭 stale 이 무음으로 남는다 | fixture — BroadcastChannel 을 undefined 로 만든 뒤 `CROSS_TAB_CHANNEL_UNAVAILABLE` 계측과 탭 내 무효화 동작 확인 | `planned` |
|
||||
| key 만 전파해도 UI 가 일관된다 | 수신 탭의 refetch 타이밍에 따라 잠깐 어긋날 수 있음 | integration test — 전파 후 두 탭의 최종 상태 일치 | `needs-confirmation` |
|
||||
| 다중 탭 동시 무효화가 refetch 폭주를 만들지 않는다 | dedup 이 `QueryCachePort` 책임인지 이 branch 책임인지 경계가 얇음 | 부하 fixture — N개 탭 시뮬레이션 후 실제 요청 수 측정 | `needs-confirmation` |
|
||||
| 지원 대상 브라우저에 BroadcastChannel 미동작 환경이 실제로 있는지 | `#C4` 는 2022-03 이후 모든 주요 브라우저에서 동작한다고 말하지만 지원 대상 목록(`FE-Q-007`)이 미확정이다. 있으면 `CROSS-TAB-001@2` 의 "대체 경로 없음" 을 되돌려야 한다 | `FE-Q-007` 확정 후 대조 + `CROSS_TAB_CHANNEL_UNAVAILABLE` 발생률 관측 | `needs-confirmation` |
|
||||
| 발신 탭이 자기 무효화를 받지 못한다 | `#C2` 는 명시적이나 구현이 echo 를 기대하기 쉬움 | negative fixture — 탭 A 발행 후 탭 A 의 수신 handler 가 호출되지 않는지 | `planned` |
|
||||
| 세 version 중 무엇이 실제로 캐시를 무효화해야 하는지 | 세 값을 모두 넣은 것은 project-local 판단이며 과잉일 수 있음 | 배포 로그 대조 — 각 값이 단독으로 바뀐 배포에서 stale 캐시가 실제 문제를 냈는지 | `needs-confirmation` |
|
||||
| 복원 await 가 첫 페인트를 유의미하게 늦추지 않는다 | `#C5` 는 경합만 말하고 비용은 말하지 않음 | 저사양 기기에서 복원 유/무 FCP 비교 | `needs-confirmation` |
|
||||
|
||||
## Audit & Findings
|
||||
|
||||
> `/branch-spec` 2026-07-28 조사에서 발견한 **상위 계약과 조사 결과의 불일치**. `FE-D028` 은 hub 소유이므로 이 branch 는 정합 권고만 남기고 자동 수정하지 않는다(hub §3.3).
|
||||
|
||||
| Finding ID | 대상 | 현재 서술 | 조사 결과 | 권고 | 처리 |
|
||||
|---|---|---|---|---|---|
|
||||
| `FALLBACK_JUSTIFICATION_GAP` | hub `FE-D028` / `DEC-…-CROSS-TAB-001@1` | "BroadcastChannel 우선에 `storage` event fallback" — fallback 이 필요한 환경을 특정하지 않는다 | `mdn-broadcastchannel-storage-event#C4`: "It's been available across browsers since March 2022" (Baseline Widely available). fallback 은 `localStorage` write·5 MiB 예산·연속 동일값 회피 장치를 추가로 요구한다 | 근거 없는 대체 경로를 유지하지 말고 삭제할 것. 필요해지면 그때 추가 | `resolved` 2026-07-28 — 사용자 결정으로 fallback 삭제. `CROSS-TAB-001` revision 1→2(`behavior-change`), 이 노트의 D5·§구현 가이드·§엣지·TODO 동기화 |
|
||||
| `STORAGE_EVENT_SCOPE_UNSPECIFIED` | hub `FE-D028` 및 §4.2 `adapters/cross-tab` | "`storage` event fallback" 이라고만 적어 backend 를 명시하지 않는다 | `mdn-broadcastchannel-storage-event#C10`: `sessionStorage` 의 `storage` event 는 "not other tabs" — 탭 간 신호로 동작하지 않는다 | 서술을 좁히거나, 위 finding 대로 경로 자체를 삭제할 것 | `resolved` 2026-07-28 — fallback 삭제로 해소. 고칠 대상 자체가 사라졌다 |
|
||||
| `NO_GROUND_TRUTH` | `/branch-spec` §2 ca-tmpl 대조 | 명령은 `/home/donghyeon/workspace/ca-tmpl` 의 registry·코드와 대조하라고 요구 | `ca-tmpl` 은 Gradle/Java **백엔드 전용**. frontend 구현 repo 는 hub §0.3 대로 미식별 | 이 branch 의 모든 명세는 `planned`. `actually-implemented` 승격은 frontend repo 식별(`FE-Q-001`) 이후 | `open` — `FE-Q-001` 선행 |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
미생성.
|
||||
> 2026-07-28 `/branch-spec` §8b. **agent dispatch 없이 수기 판정**했다(세션 제약). governing doc = [[raw/project-notes/ca-skeleton-frontend-operational-contract]]. 기준은 hub §2.2 의 10개 universal acceptance question.
|
||||
|
||||
| # | 관심사 | 판정 | 근거 |
|
||||
|---:|---|---|---|
|
||||
| 1 | 문제와 실패 모드가 구체적인가 | covered-here | §목표 — 탭 간 stale 과 release 를 넘은 캐시 파싱 실패. §엣지에 10개 실패 경로 |
|
||||
| 2 | 상속한 결정을 실제로 적용했는가 | covered-here | `CROSS-TAB-001@2` → D2·D5, `SERVER-STATE-001` → D1·D7, `CAPABILITY-001` → `CAP_FE_CACHE_PERSISTENCE` 소유 |
|
||||
| 3 | project-wide default 와 limit | covered-here | D1(3-tuple 폐기), D4(echo 없음), D5(대체 경로 없음), D6(렌더 이전 await), D7(성공 query 한정) |
|
||||
| 4 | 대안을 검토했는가 | covered-here | §결정 사항 — `releaseId` 단독 / Web Locks 리더 / `storage` event 대체 경로(삭제 결정, §Audit). D3 는 반증까지 기록 |
|
||||
| 5 | 금지 구현 | covered-here | §구현 가이드 2 — 값·응답 본문·사용자 식별자 전송 금지, factory 밖 key 금지. §구현 가이드 1 — 대체 전송 신설 금지 |
|
||||
| 6 | 실패 경로가 error kind 로 매핑되는가 | covered-here | `CACHE_PERSISTENCE_FAILURE`·`CROSS_TAB_CHANNEL_UNAVAILABLE` (hub §8.2 와 정합) |
|
||||
| 7 | 관측 가능한가 | should-fix | 파티션 폐기·fallback 진입·채널 부재를 구분할 telemetry event 를 `FE-REG-TELEMETRY` 에 등록하지 않았다. D1 의 "세 값 중 무엇이 불일치했는지" 보완도 여기에 걸린다 |
|
||||
| 8 | 위임 경계가 명확한가 | covered-here | `DELEG-FE-009` (storage registry), §구현 가이드 4 의 R3 목록 |
|
||||
| 9 | 검증 수단이 있는가 | covered-here | §검증해야 할 주장 11행, `FE-GATE-028` fixture + cache tier report |
|
||||
| 10 | 말할 수 있는 범위 | covered-here | 전 항목 `planned` — `NO_GROUND_TRUTH` 로 구현 repo 미식별 |
|
||||
|
||||
**판정: Covered (missing 0)** · Should-fix 1건(관심사 7 — telemetry event 미등록). Blocking 아님.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
|
||||
@@ -60,9 +60,16 @@ status_label: in-progress
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | transfer adapter 는 shared client 의 auth interceptor 체인을 재사용하지 않고 별도 transport 를 갖는다 | `refines DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-TRANSFER-CREDENTIAL-001@1` | 근거 raw 미수집 (`FE-Q-011`) | `proposed` |
|
||||
| D2 | presigned URL 은 telemetry·로그·`Referrer` 어디에도 남기지 않는다 | `local` | 근거 raw 미수집 (`FE-Q-011`) | `proposed` |
|
||||
| D3 | `MediaUrlPolicy` 는 port 가 아니라 `application/policies/` 의 순수 함수다 | `local` | 근거 raw 미수집 (`FE-Q-011`) | `proposed` |
|
||||
| D1 | transfer adapter 는 shared client 의 auth interceptor 체인을 재사용하지 않고 별도 transport 를 갖는다 | `refines DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-TRANSFER-CREDENTIAL-001@1` | **`UNSUPPORTED_DECISION`** — 조사 후에도 구현 형태를 지지하는 외부 근거 없음. trade-off: 대안인 "interceptor 에 skip 플래그" 는 *기본이 첨부, 예외가 미첨부* 라 플래그를 빠뜨린 새 경로가 곧바로 유출이 된다. 별도 transport 는 *기본이 미첨부* 라 실수의 방향이 안전한 쪽이다. 비용은 timeout·retry 정책이 두 벌로 갈라지는 것 | `proposed` |
|
||||
| D2 | presigned URL 은 로그·telemetry·에러 객체에 남기지 않는다. `Referer` 경로는 브라우저 기본 정책이 이미 막으므로 **"presigned URL 을 페이지 URL 에 넣지 않는다"** 는 금지 규칙 하나로 대체한다 | `local` | `raw/official-docs/mdn-referrer-policy.md#C1`, `#C2` (기본값이 cross-origin 에 path·query 미전송) | `proposed` |
|
||||
| D3 | `MediaUrlPolicy` 는 port 가 아니라 `application/policies/` 의 순수 함수다 | `local` | **`UNSUPPORTED_DECISION`** — 조사 후에도 근거 없음. trade-off: URL 파생에 I-O 가 없어 port 로 만들면 test double 만 늘고 composition root 가 커진다. 비용은 서명된 URL 을 요구하는 CDN 을 만나는 순간 순수 함수 가정이 깨져 port 승격 리팩터가 필요해지는 것 | `proposed` |
|
||||
| D4 | 다운로드 재개는 `Accept-Ranges` 로 지원을 판별하고 `Range` + `If-Range` 로 수행하며, 응답이 `206` 이 아니면 **재개가 아니라 전체 재전송**으로 취급한다. `416` 은 terminal | `local` | `raw/official-docs/mdn-http-range-fetch-transfer.md#C1`, `#C2`, `#C3`, `#C4`, `#C5`, `#C6` | `proposed` |
|
||||
| D6 | part 재시도는 원본 `Blob` 을 **다시 slice** 해서 새 body 를 만든다. 첫 시도의 body 를 보관했다 재사용하지 않는다 | `local` | `raw/official-docs/mdn-http-range-fetch-transfer.md#C10` (읽힌 스트림은 disturbed 되어 재사용 불가) | `proposed` |
|
||||
| D7 | 취소는 `AbortController` 로만 하고 `AbortError` 를 `REQUEST_ABORTED` 로 매핑한다. 응답 수신 후 body 읽기 중 취소도 같은 kind 다 | `local` | `raw/official-docs/mdn-http-range-fetch-transfer.md#C7`, `#C8` | `proposed` |
|
||||
| D8 | `TRANSFER_PART_SIZE_BYTES` 는 자유값이 아니라 **부팅 시 하한 검증 대상**이다. 하한은 선택 vendor 가 정하며(`FE-Q-012`), 미확정 동안은 5 MiB 를 잠정 하한으로 강제한다. part size × part 수 상한이 최대 전송 크기이므로 그 값을 계약값으로 노출한다 | `refines DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-RESUMABLE-TRANSFER-001@1` | `raw/official-docs/aws-s3-multipart-upload-limits.md#C1`(5 MiB~5 GiB), `#C2`(마지막 part 예외), `#C3`(10,000 part 상한) | `proposed` |
|
||||
| D10 | `UPLOAD_PART_STATE` 유실은 **오류가 아니라 정상 경로**다. 유실을 감지하면 재개 불가로 표시하고 처음부터 전송한다. 이 branch 는 `persist()` 를 요청하지 않는다 | `local` | `raw/official-docs/mdn-storage-quotas-eviction-persistence.md#C1`(origin 전량 eviction), `#C4`·`#C5`(persist 는 브라우저 재량), `#C7`(Safari ITP 7일) | `proposed` |
|
||||
|
||||
**deferred (이번 회차 조사 범위 밖)**: D11 — **스트리밍 업로드**(요청 body 를 `ReadableStream` 으로 전달). `mdn-http-range-fetch-transfer.md` §적용 경계 대로 이 페이지는 `duplex` 옵션과 HTTP/2 요구를 다루지 않는다. 현 계약은 part 단위 `Blob` 전송을 전제하며, 스트리밍 업로드가 필요해지면 별도 조사가 선행되어야 한다.
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
@@ -137,9 +144,13 @@ status_label: in-progress
|
||||
|---|---|
|
||||
| `[[docs/superpowers/specs/2026-07-28-ca-skeleton-frontend-runtime-adapter-features-design]]` §5.1·§5.2·§9.3 | port 분해, `MediaUrlPolicy` 가 port 가 아닌 이유, 전송 surface state |
|
||||
| [[raw/official-docs/owasp-content-security-policy-cheat-sheet]] | browser 경계에서 credential·URL 노출을 줄이는 상위 관점 |
|
||||
| [[raw/official-docs/mdn-http-range-fetch-transfer]] | D4 재개 프로토콜 · D6 part 재시도의 body 재생성 · D7 취소의 error 매핑 |
|
||||
| [[raw/official-docs/aws-s3-multipart-upload-limits]] | D8 `TRANSFER_PART_SIZE_BYTES` 하한 검증과 part 수 상한이 전송 크기를 결정한다는 사실 |
|
||||
| [[raw/official-docs/mdn-referrer-policy]] | D2 유출 경로 3개의 우선순위 재조정 (`Referer` 는 브라우저 기본값이 이미 방어) |
|
||||
| [[raw/official-docs/mdn-storage-quotas-eviction-persistence]] | D10 `UPLOAD_PART_STATE` 의 실질 수명 상한 |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §5.3·§7.8 | API operation registry 와 auth integration 경계 |
|
||||
|
||||
**근거 등급 경계**: `FE-D029`(credential 경계)의 rationale 은 `credential 유출 방지 invariant, project decision` 이고 `FE-D030`(part 정책)은 `project-local default, 외부 source claim 아님` 이다. presigned URL·multipart 의 vendor 별 제약을 다룬 raw 자료는 없으며 `FE-Q-012` 가 수집을 소유한다. part size 8 MiB·병렬도 3 은 측정값이 아니라 초기 default 다.
|
||||
**근거 등급 경계**: 2026-07-28 `/branch-spec` 조사로 **프로토콜 사실**(range 재개, body 1회 소비, abort error, referrer 기본값)과 **vendor 제약의 존재**(S3 part size 하한)는 공식 문서 근거를 확보했다. 그러나 다음은 여전히 미근거다. (1) `FE-D029` 가 정한 *transport 분리 방식 자체* — 유출 방지라는 목표는 자명하나 "interceptor 재사용 금지" 라는 구현 형태를 지지하는 외부 근거는 없다(D1). (2) part size **8 MiB** 와 병렬도 **3** 이라는 구체 값 — `aws-s3-multipart-upload-limits#C1` 은 5 MiB 하한만 말하고 최적값을 말하지 않으며, 애초에 vendor 가 미확정이다(`FE-Q-012`). (3) `MediaUrlPolicy` 를 port 로 만들지 않는다는 D3. 세 항목 모두 아래 표에 라벨로 표시했다.
|
||||
|
||||
## TODO
|
||||
|
||||
@@ -150,6 +161,12 @@ status_label: in-progress
|
||||
- [ ] 무결성 검증과 불일치 시 재전송 1회 — 등급: `planned`
|
||||
- [ ] range 기반 다운로드 재개 — 등급: `planned`
|
||||
- [ ] `MediaUrlPolicy` 순수 함수와 허용 transform 강제 — 등급: `planned`
|
||||
- [ ] `Accept-Ranges` 판별 후에만 `Range` 를 보내는 경로 (D4) — 등급: `planned`
|
||||
- [ ] 재개 응답이 `206` 이 아니면 전체 재전송으로 전환 (D4) — 등급: `planned`
|
||||
- [ ] part 재시도 시 `Blob` 재slice negative fixture (D6) — 등급: `planned`
|
||||
- [ ] `TRANSFER_PART_SIZE_BYTES` 하한 + part 수 상한 부팅 검증 (D8) — 등급: `planned`
|
||||
- [ ] part state 유실 시 "재개 불가" 표면화 (D10) — 등급: `planned`
|
||||
- [ ] 전송 payload 에 presigned URL 문자열 0건을 증명하는 관측 등록 (관심사 7 should-fix) — 등급: `planned`
|
||||
- [ ] `FE-GATE-029` transfer report 산출 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
@@ -166,14 +183,81 @@ status_label: in-progress
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | 별도 transport 로 credential 분리 | 항상. 스토리지가 same-origin proxy 만 제공하면 `FE-D029` 재검토 | 없음 — `FE-Q-011`, `FE-Q-012` | `project decision` | 두 transport 의 timeout·retry 정책이 갈라질 수 있음 |
|
||||
| D2 | presigned URL 을 어디에도 남기지 않음 | 항상 | 없음 — `FE-Q-011` | `project-local default` | 디버깅이 어려워짐. 실패 시 operation ID 만으로 추적 가능해야 함 |
|
||||
| D3 | `MediaUrlPolicy` 는 순수 함수 | 항상. CDN 이 서명된 URL 을 요구하면 port 로 승격 재검토 | 없음 — `FE-Q-011` | `project-local default` | 서명 필요 CDN 을 만나면 설계가 바뀜 |
|
||||
| D1 | 별도 transport 로 credential 분리 | 항상. 스토리지가 same-origin proxy 만 제공하면 `FE-D029` 재검토 | 없음 — 조사 후에도 구현 형태의 근거 없음 | `UNSUPPORTED_DECISION` | 두 transport 의 timeout·retry 정책이 갈라진다. 한쪽만 고쳐지는 drift 가 생김 |
|
||||
| D2 | presigned URL 을 로그·telemetry 에 남기지 않고, `Referer` 는 "페이지 URL 에 넣지 않기" 로 대체 | 항상 | `mdn-referrer-policy.md#C1`(기본값), `#C2`(cross-origin 은 origin 만) | `official-reference`(Referer 경로) + `project-local default`(로그 금지) | 디버깅이 어려워진다. 실패를 operation ID 만으로 추적할 수 있어야 함 |
|
||||
| D3 | `MediaUrlPolicy` 는 순수 함수 | 항상. CDN 이 서명된 URL 을 요구하면 port 로 승격 재검토 | 없음 — 조사 후에도 근거 없음 | `UNSUPPORTED_DECISION` | 서명 필요 CDN 을 만나면 순수 함수 가정이 깨져 리팩터가 필요 |
|
||||
| D4 | `Accept-Ranges` 판별 + `Range`/`If-Range`, `206` 아니면 재개 아님 | 서버가 range 를 지원할 때만 재개. 미지원이면 `resumeStrategy` 를 끄고 처음부터 | `mdn-http-range-fetch-transfer.md#C1`~`#C6` | `official-reference` | `If-Range` validator 로 `ETag` 를 쓸지 `Last-Modified` 를 쓸지 미정. vendor 가 무엇을 주는지에 달림(`FE-Q-012`) |
|
||||
| D6 | part 재시도는 `Blob` 재slice | 항상. 예외 없음 — 프로토콜 제약이다 | `mdn-http-range-fetch-transfer.md#C10` | `official-reference` | 원본 `Blob` 을 재시도 시점까지 살려 둬야 하므로 handle 수명이 `DELEG-FE-008` 과 엮임 |
|
||||
| D7 | `AbortController` 단일 취소 경로 | 항상 | `mdn-http-range-fetch-transfer.md#C7`, `#C8` | `official-reference` | 이미 전송된 바이트가 서버에서 정리되는지는 vendor 책임이며 프론트가 보장하지 못함 |
|
||||
| D8 | part size 하한 검증 + 최대 전송 크기 노출 | 항상. vendor 확정 시 하한을 그 값으로 교체 | `aws-s3-multipart-upload-limits.md#C1`, `#C2`, `#C3` | `official-reference`(하한의 존재) + `project-local default`(8 MiB 라는 값) | 8 MiB × 10,000 ≈ 80 GB 라는 상한이 제품 요구를 넘는지 미확인. 이 계산은 우리 도출이지 AWS 서술이 아님 |
|
||||
| D10 | part state 유실은 정상 경로 | 항상. `persist()` 요청은 하지 않음 | `mdn-storage-quotas-eviction-persistence.md#C1`, `#C4`, `#C5`, `#C7` | `official-reference` | 대용량 전송 중 origin eviction 이 일어나면 사용자는 진행률만 보다가 재개 불가를 통보받는다. UX 문구 미설계 |
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
> 근거 raw 자료(`FE-Q-011`, `FE-Q-012`) 수집 전까지 비워 둔다. 특히 part size·병렬도·만료 처리는 storage vendor 제약에 직접 의존하므로, vendor 확정 전에 쓰면 전부 `UNSUPPORTED_IMPL_DECISION` 이 된다.
|
||||
> 2026-07-28 `/branch-spec` 조사(MDN 2건 + AWS S3 1건)로 **프로토콜에 의존하는 detail** 을 채웠다. vendor 값에 의존하는 detail(`FE-Q-012`)은 여전히 미확정이므로 값이 아니라 **검증 규칙**으로만 적었다. 근거가 원칙만 지지하는 cell 은 `UNSUPPORTED_IMPL_DECISION` + trade-off 로 표시했다(CLAUDE.md §15.5 R2).
|
||||
|
||||
### 1. credential 경계 — 두 transport 의 분리 형태
|
||||
|
||||
> **Trace**: D1(별도 transport) ← 근거 없음(project decision) / D2(URL 미기록) ← `mdn-referrer-policy.md#C1`·`#C2` / 상속 `DEC-…-TRANSFER-CREDENTIAL-001@1`
|
||||
|
||||
presign **획득**은 shared client 를 통과한다(`FE-OC-006`). byte **전송**은 shared client 를 쓰지 않는 별도 함수가 수행하며, 그 함수는 auth 를 붙이는 코드에 접근할 수 없어야 한다 — 정책이 아니라 **도달 불가능성**으로 강제한다. 첨부 금지를 런타임 조건문으로 구현하면 조건이 하나 빠지는 순간 유출이므로, transport 자체를 분리해 첨부 코드가 그 경로에 존재하지 않게 한다.
|
||||
|
||||
유출 경로는 세 개가 아니라 **두 개**다. `#C1`+`#C2` 대로 브라우저 기본 정책(`strict-origin-when-cross-origin`)이 cross-origin 요청에 path·query 를 이미 보내지 않으므로 `Referer` 는 방어된 상태다. 남은 실질 경로는 (a) 우리 로그·telemetry·에러 객체, (b) presigned URL 이 페이지 URL·`history` 에 들어가는 코드. fixture 밀도를 (a)·(b) 에 몰고, `Referer` 에 대해서는 (b) 금지 규칙 하나만 둔다.
|
||||
|
||||
`UNSUPPORTED_IMPL_DECISION` — 분리를 "별도 모듈 + 별도 fetch wrapper" 로 구현하는 것. 근거는 목표(유출 방지)만 지지하고 형태는 지지하지 않는다. trade-off: 대안(interceptor skip 플래그)은 실수의 방향이 유출 쪽이라 배제했다. 비용은 timeout·retry 정책이 두 벌이 되는 것이며, 이를 registry 값 공유로 완화한다.
|
||||
|
||||
### 2. 다운로드 재개 프로토콜
|
||||
|
||||
> **Trace**: D4 ← `mdn-http-range-fetch-transfer.md#C1`~`#C6` / D7(취소) ← `#C7`·`#C8`
|
||||
|
||||
`StreamingDownloadPort` 는 재개를 **낙관적으로 시도하지 않는다**. 순서는 고정이다.
|
||||
|
||||
1. 최초 응답의 `Accept-Ranges` 를 기록한다. 헤더가 없거나 `none` 이면 `resumeStrategy` 를 비활성화한다(`#C1`)
|
||||
2. 재개 시 `Range` 와 함께 **`If-Range`** 를 보낸다. `#C5` 가 요구하는 "원본 미변경 보장" 을 이 헤더가 담당한다
|
||||
3. 응답 상태를 검사한다. `206` 이면 `Content-Range` 로 위치를 확인하고 이어붙인다(`#C2`). **`200` 이면 재개가 아니라 전체 재전송**이므로 받아 둔 앞부분을 버리고 처음부터 쓴다(`#C4`·`#C6`). `416` 은 재개 위치가 리소스 밖이라는 뜻이므로 terminal 로 보고 `TRANSFER_INTEGRITY_MISMATCH` 로 매핑한다(`#C3`)
|
||||
|
||||
3번이 이 절의 핵심이다. `200` 을 성공으로 처리하면 앞부분 + 전체가 이어붙어 **길이가 늘어난 파일**이 만들어지고, 무결성 검사가 없으면 그대로 저장된다.
|
||||
|
||||
취소는 `AbortController` 하나로 통일한다. `#C7`(fetch reject)과 `#C8`(body 읽기 중 reject)은 **다른 시점의 같은 오류**이므로 둘 다 `REQUEST_ABORTED` 로 매핑하고 구분하지 않는다.
|
||||
|
||||
`UNSUPPORTED_IMPL_DECISION` — `If-Range` 의 validator 로 `ETag` 를 우선하고 없으면 `Last-Modified` 를 쓰는 것. `#C5`·`#C6` 는 조건부 재개의 필요성만 말하고 validator 선택을 말하지 않는다. trade-off: `ETag` 가 더 정밀하지만 vendor 가 무엇을 주는지 미확정(`FE-Q-012`)이라 둘 다 처리하는 쪽을 택했다.
|
||||
|
||||
### 3. part 분할·재시도
|
||||
|
||||
> **Trace**: D6(재slice) ← `mdn-http-range-fetch-transfer.md#C10` / D8(하한 검증) ← `aws-s3-multipart-upload-limits.md#C1`~`#C3` / 상속 `DEC-…-RESUMABLE-TRANSFER-001@1`
|
||||
|
||||
**재시도는 body 를 재생성한다.** `#C10` 대로 한 번 읽힌 스트림은 disturbed 상태가 되어 누구도 다시 읽지 못하므로, 재시도 시 원본 `Blob` 을 같은 오프셋으로 다시 `slice()` 한다. 첫 시도의 body 객체를 보관했다 재사용하는 구현은 두 번째 시도에서 **빈 본문**을 보내고, 서버는 그것을 성공으로 받는다.
|
||||
|
||||
part size 는 값이 아니라 **검증 규칙**으로 고정한다.
|
||||
|
||||
| 규칙 | 근거 |
|
||||
|---|---|
|
||||
| `TRANSFER_PART_SIZE_BYTES ≥ 하한` 을 부팅 시 검사하고 위반이면 boot fail | `#C1` — 하한 미만은 마지막 part 를 제외한 전 구간에서 거부된다 |
|
||||
| 마지막 part 만 하한 예외 | `#C2` — "no minimum size limit on the last part" |
|
||||
| `ceil(총크기 / partSize) ≤ part 수 상한` 을 전송 시작 전 검사 | `#C3` — 상한 초과 분할은 전송 자체가 불가능 |
|
||||
| 최대 전송 크기 = `partSize × part 수 상한` 을 계약값으로 노출 | 위 두 값의 **우리 도출**이며 AWS 서술이 아님 |
|
||||
|
||||
vendor 미확정 동안 하한은 5 MiB, part 수 상한은 10,000 을 잠정값으로 쓴다. 이 두 값의 출처가 S3 라는 사실을 registry 주석에 남겨, vendor 확정 시 무엇을 바꿔야 하는지가 코드에서 보이게 한다.
|
||||
|
||||
`UNSUPPORTED_IMPL_DECISION` — part size **8 MiB** 와 병렬도 **3** 이라는 값. `#C1` 은 5 MiB~5 GiB 라는 허용 범위만 말하고 최적값을 말하지 않는다. trade-off: 하한(5 MiB)에 붙이면 part 수가 늘어 상한(10,000)에 빨리 닿고, 크게 잡으면 재시도 1회의 손실이 커진다. 8 MiB 는 그 사이의 임의 지점이며 측정으로 대체되어야 한다.
|
||||
|
||||
### 4. part 상태의 수명
|
||||
|
||||
> **Trace**: D10 ← `mdn-storage-quotas-eviction-persistence.md#C1`·`#C4`·`#C5`·`#C7`
|
||||
|
||||
`UPLOAD_PART_STATE` 는 **사라질 수 있는 값**으로 다룬다. `#C1` 대로 브라우저 eviction 은 origin 전량 삭제라 이 행만 보호할 방법이 없고, `#C4`·`#C5` 대로 `persist()` 는 허가가 브라우저 재량이다. 따라서 adapter 는 재개 전 part 상태의 존재와 정합성을 검사하고, 없으면 **오류가 아니라 "재개 불가"** 로 표면화한 뒤 처음부터 전송한다.
|
||||
|
||||
Safari ITP 의 7일 규칙(`#C7`)은 이 계약에서 **구속 조건이 아니다** — `FE-REG-STORAGE` 의 `UPLOAD_PART_STATE` TTL 이 24시간이라 7일보다 먼저 만료된다. 구속하는 것은 시간이 아니라 저장 압박이며, 그것은 예고 없이 온다.
|
||||
|
||||
`persist()` 요청은 이 branch 가 하지 않는다 — 소유자는 [[raw/branch-notes/feature-frontend-binary-file-io-store-contract]] `D7` 이다. 두 branch 가 각자 요청하면 프롬프트가 중복된다.
|
||||
|
||||
### 5. 이 branch 가 남기지 않는 것 (R3)
|
||||
|
||||
- presign 발급 endpoint 설계·서명 알고리즘·만료 시간 정책 → backend 소유, §범위 제외
|
||||
- `File`/`Blob` handle 과 object URL 수명 → `DELEG-FE-008` 로 [[raw/branch-notes/feature-frontend-binary-file-io-store-contract]] 에 위임
|
||||
- storage vendor 선택 → `FE-Q-012`. 이 절은 vendor 값을 쓰지 않고 **검증 규칙**만 고정했다
|
||||
- CSP·`Referrer-Policy` 헤더의 실제 설정 → [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] 소유(`FE-OC-019`)
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
@@ -185,10 +269,18 @@ status_label: in-progress
|
||||
- 다운로드 스트림 중단 → `STREAM_INTERRUPTED`, `resumeStrategy: range` 면 받은 범위부터 재개
|
||||
- 사용자 취소 → `REQUEST_ABORTED` 재사용, part 상태 폐기 여부를 명시
|
||||
- `UPLOAD_PART_STATE` 저장 실패 → `BLOB_STORE_UNAVAILABLE`. 이 행은 fallback 이 없으므로 전송을 재개 불가로 표시하고 조용히 memory 로 넘어가지 않음
|
||||
- **재개 요청에 `200` 응답** → 재개가 아니라 전체 재전송이다(`mdn-http-range-fetch-transfer#C4`·`#C6`). 받아 둔 앞부분을 버리고 처음부터 쓴다. 성공으로 처리하면 길이가 늘어난 파일이 만들어진다 (D4)
|
||||
- **재개 위치가 리소스 밖** → `416`(`#C3`). 재시도로 회복되지 않으므로 terminal 로 보고 `TRANSFER_INTEGRITY_MISMATCH` 로 매핑한다
|
||||
- **`Accept-Ranges` 부재·`none`** → 재개 자체가 불가(`#C1`). `resumeStrategy` 를 끄고 중단 시 처음부터 다시 받는다
|
||||
- **재시도 시 body 재사용** → disturbed 스트림이라 빈 본문이 전송된다(`#C10`). 원본 `Blob` 을 재slice 한다 (D6)
|
||||
- **part state 유실** → 오류가 아니라 재개 불가로 표면화하고 처음부터 전송 (D10)
|
||||
- **part size 가 vendor 하한 미만** → 마지막 part 를 제외한 전 구간이 거부된다(`aws-s3-multipart-upload-limits#C1`). 부팅 시 검사해 boot fail (D8)
|
||||
- **다른 계약 의존**
|
||||
- [[raw/branch-notes/feature-frontend-binary-file-io-store-contract]] 의 `BlobStorePort`·handle 수명에 의존 (`DELEG-FE-008`)
|
||||
- [[raw/branch-notes/feature-api-client-response-envelope-contract]] 의 shared client 에 의존 — presign 획득 경로
|
||||
- [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] 의 header·CSP 정책에 의존 — `Referrer-Policy` 로 URL 유출 차단
|
||||
- [[raw/branch-notes/feature-frontend-binary-file-io-store-contract]] `D1`·`D7` — object URL·handle 수명(`DELEG-FE-008`)과 `persist()` 요청 소유권. D6 이 재시도까지 원본 `Blob` 을 살려 둬야 하므로 handle 수명 규칙이 이 branch 의 재시도 가능 범위를 정한다
|
||||
- [[raw/branch-notes/feature-frontend-storage-registry-contract]] — `UPLOAD_PART_STATE` 의 `quotaFallback: 없음`·`evictionOrder: null`·TTL 24h. D10 이 이 세 값에 직접 의존한다
|
||||
- [[raw/branch-notes/feature-api-client-response-envelope-contract]] — shared client 의 timeout·retry·envelope. presign **획득** 경로가 이를 통과하며, D1 이 전송 경로를 여기서 떼어낸다
|
||||
- [[raw/branch-notes/feature-frontend-browser-security-boundary-contract]] — `Referrer-Policy`·CSP 헤더의 실제 설정(`FE-OC-019`). D2 는 이 branch 가 기본값을 바꾸지 않는다고 전제한다 — `unsafe-url` 로 완화되면 D2 의 전제가 깨진다(`mdn-referrer-policy#C4`)
|
||||
- [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] — `PRESIGN_EXPIRED`·`UPLOAD_PART_FAILED`·`TRANSFER_INTEGRITY_MISMATCH`·`STREAM_INTERRUPTED` 의 kind 등록과 retryable 기본값
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장
|
||||
@@ -201,10 +293,42 @@ status_label: in-progress
|
||||
| 만료 후 재획득으로 같은 위치에서 재개된다 | 재개 위치 계산이 vendor 별로 다름 | integration test — 만료 강제 후 이어받기 지점 확인 | `needs-confirmation` |
|
||||
| part size 8 MiB 가 실제 환경에서 합리적이다 | 측정 없이 정한 초기 default | 벤치마크 — 네트워크 프로파일별 처리량 비교, `FE-D030` revisit trigger 에 연결 | `needs-confirmation` |
|
||||
| `UPLOAD_PART_STATE` 가 quota 초과 시 memory 로 넘어가지 않는다 | fallback 이 기본 동작으로 새기 쉬움 | negative fixture — quota 초과 강제 후 재개 불가로 표시되는지 | `planned` |
|
||||
| 재개 요청에 `200` 이 오면 처음부터 다시 쓴다 | 성공 상태 코드라 통과시키기 쉽고, 통과하면 길이가 늘어난 파일이 만들어짐 | negative fixture — `Range` 를 무시하고 `200` + 전체 본문을 주는 서버 stub 에 대해 최종 파일 크기가 원본과 같은지 | `planned` |
|
||||
| `Accept-Ranges` 부재 시 재개를 시도하지 않는다 | 판별을 건너뛰고 낙관적으로 `Range` 를 보내기 쉬움 | fixture — 헤더 없는 응답 후 중단·재개 시 `Range` 헤더가 나가지 않는지 | `planned` |
|
||||
| part 재시도가 빈 본문을 보내지 않는다 | `#C10` 의 disturbed 스트림 문제는 첫 시도 성공 시 드러나지 않음 | fixture — 첫 시도 실패 강제 후 두 번째 요청의 `Content-Length` 가 part size 와 같은지 | `planned` |
|
||||
| part size 하한 위반이 부팅을 막는다 | 검증을 넣지 않으면 런타임에 vendor 거부로만 드러남 | negative fixture — 하한 미만 값으로 boot 시 실패하는지 | `planned` |
|
||||
| 최대 전송 크기(`partSize × part 수 상한`)가 제품 요구를 넘는다 | 8 MiB × 10,000 ≈ 80 GB 는 **우리 도출**이며 요구를 확인하지 않았음 | 제품 요구 확인 후 registry 값 재계산 | `needs-confirmation` |
|
||||
| `If-Range` validator 로 무엇을 써야 하는지 | vendor 가 `ETag` 를 주는지 `Last-Modified` 를 주는지 미확정 (`FE-Q-012`) | vendor 확정 후 응답 헤더 실측 | `needs-confirmation` |
|
||||
| 대용량 전송 중 origin eviction 이 실제로 얼마나 자주 일어나는지 | `#C1` 은 가능성만 말하고 빈도를 말하지 않음. UX 문구 설계가 이 빈도에 달림 | telemetry — `UPLOAD_PART_STATE` 유실로 재개 불가가 된 전송 비율 | `needs-confirmation` |
|
||||
|
||||
## Audit & Findings
|
||||
|
||||
> `/branch-spec` 2026-07-28 조사에서 발견한 **상위 계약과 조사 결과의 불일치**. hub 소유 항목은 정합 권고만 남긴다(hub §3.3).
|
||||
|
||||
| Finding ID | 대상 | 현재 서술 | 조사 결과 | 권고 | 처리 |
|
||||
|---|---|---|---|---|---|
|
||||
| `PART_SIZE_UNVALIDATED` | hub §5.4 `TRANSFER_PART_SIZE_BYTES` / `FE-D030` | part size·병렬도를 registry 로 "고정" 한다고만 적고 **허용 범위**를 말하지 않는다 | `aws-s3-multipart-upload-limits#C1`: part size 는 5 MiB~5 GiB 라는 **vendor 하드 제약**을 받고, `#C3`: part 수 상한 10,000 이 최대 전송 크기를 결정한다. 하한 미만 값은 마지막 part 를 제외한 전 구간에서 거부된다 | env key 정의에 하한 검증과 part 수 상한 검사를 추가할 것. 값 자체는 vendor 확정(`FE-Q-012`) 전까지 잠정 | `open` — `FE-Q-012` 선행이나, **검증 규칙**은 vendor 무관하므로 먼저 넣을 수 있음 |
|
||||
| `REFERRER_RISK_MISWEIGHTED` | 이 branch 의 `D2` 초안 | "telemetry·로그·`Referrer` 어디에도" — 세 경로를 같은 무게로 나열했다 | `mdn-referrer-policy#C1`·`#C2`: 기본 정책 `strict-origin-when-cross-origin` 이 cross-origin 요청에 path·query 를 이미 보내지 않는다. 게다가 `Referer` 는 *요청을 유발한 문서*의 URL 이지 요청 대상 URL 이 아니다 | `Referer` 항목을 "presigned URL 을 페이지 URL 에 넣지 않는다" 는 금지 규칙으로 좁히고, fixture 밀도를 로그·telemetry 로 옮길 것 | `resolved` 2026-07-28 — D2 서술과 §구현 가이드 1 에 반영 |
|
||||
| `NO_GROUND_TRUTH` | `/branch-spec` §2 ca-tmpl 대조 | 명령은 ca-tmpl registry·코드와 대조하라고 요구 | `ca-tmpl` 은 Gradle/Java 백엔드 전용. frontend 구현 repo 미식별 | 전 항목 `planned` 유지. 승격은 `FE-Q-001` 이후 | `open` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
미생성.
|
||||
> 2026-07-28 `/branch-spec` §8b. **agent dispatch 없이 수기 판정**했다(세션 제약). governing doc = [[raw/project-notes/ca-skeleton-frontend-operational-contract]]. 기준은 hub §2.2 의 10개 universal acceptance question.
|
||||
|
||||
| # | 관심사 | 판정 | 근거 |
|
||||
|---:|---|---|---|
|
||||
| 1 | 문제와 실패 모드가 구체적인가 | covered-here | §목표 — 제3자 도메인으로의 credential 전달. §엣지 12개 경로 |
|
||||
| 2 | 상속한 결정을 실제로 적용했는가 | covered-here | `TRANSFER-CREDENTIAL-001` → D1·D2, `RESUMABLE-TRANSFER-001` → D8·D10, `CAPABILITY-001` → `CAP_FE_LARGE_TRANSFER` 소유 |
|
||||
| 3 | project-wide default 와 limit | covered-here | D4(206 아니면 재개 아님), D6(재slice), D8(하한 검증 + 최대 전송 크기), D10(유실은 정상 경로) |
|
||||
| 4 | 대안을 검토했는가 | covered-here | §결정 사항 — interceptor skip 플래그 / `MediaUrlPort`. D1·D3 는 trade-off 를 명시 |
|
||||
| 5 | 금지 구현 | covered-here | §구현 가이드 — 전송 경로에 auth 첨부 코드 도달 금지, body 재사용 금지, URL 을 로그·페이지 URL 에 넣기 금지 |
|
||||
| 6 | 실패 경로가 error kind 로 매핑되는가 | covered-here | `PRESIGN_EXPIRED`·`UPLOAD_PART_FAILED`·`TRANSFER_INTEGRITY_MISMATCH`·`STREAM_INTERRUPTED`·`REQUEST_ABORTED`·`BLOB_STORE_UNAVAILABLE` |
|
||||
| 7 | 관측 가능한가 | should-fix | 진행률·재개·part 재시도 telemetry 는 hub §5.8 에 있으나, **URL 미유출을 증명하는** 관측(payload grep gate)이 fixture 로만 있고 registry event 로 등록되지 않았다 |
|
||||
| 8 | 위임 경계가 명확한가 | covered-here | `DELEG-FE-008`, §구현 가이드 5 의 R3 목록 |
|
||||
| 9 | 검증 수단이 있는가 | covered-here | §검증해야 할 주장 14행, `FE-GATE-029` fixture + transfer report |
|
||||
| 10 | 말할 수 있는 범위 | covered-here | 전 항목 `planned` — `NO_GROUND_TRUTH` |
|
||||
|
||||
**판정: Covered (missing 0)** · Should-fix 1건(관심사 7). Blocking 아님.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
|
||||
@@ -60,9 +60,14 @@ status_label: in-progress
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | 이 branch 는 **신규 port 를 정의하지 않는다**. GraphQL·gRPC-Web·Connect-Web adapter 는 기존 `ResourceQueryPort`/`ResourceCommandPort` 를 구현한다 | `refines DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-PROTOCOL-001@1` | 근거 raw 미수집 (`FE-Q-011`) | `proposed` |
|
||||
| D2 | transport status 만으로 성공을 판정하지 않는다. protocol 별 성공 판정 함수가 별도로 존재한다 | `local` | 근거 raw 미수집 (`FE-Q-011`) | `proposed` |
|
||||
| D3 | GraphQL `200 OK` + `errors[]` 는 부분 성공이 아니라 `PARTIAL_RESULT_FAILURE` 로 정규화한다 | `local` | 근거 raw 미수집 (`FE-Q-011`) | `proposed` |
|
||||
| D1 | 이 branch 는 **신규 port 를 정의하지 않는다**. GraphQL·gRPC-Web·Connect-Web adapter 는 기존 `ResourceQueryPort`/`ResourceCommandPort` 를 구현한다 | `refines DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-PROTOCOL-001@1` | **`UNSUPPORTED_DECISION`** — 조사 후에도 외부 근거 없음. trade-off: 프로토콜별 port 를 만들면 use case 가 프로토콜을 알게 되어, backend 가 REST 에서 gRPC 로 옮길 때 use case 를 다시 써야 한다. 비용은 프로토콜 고유 기능(양방향 스트림 등)이 필요해질 때 port 를 새로 뚫어야 하는 것 | `proposed` |
|
||||
| D2 | transport status 만으로 성공을 판정하지 않는다. protocol 별 성공 판정 함수가 별도로 존재한다 | `local` | `raw/official-docs/graphql-over-http-draft-status-errors.md#C3` (상태 코드와 무관하게 본문 처리), `raw/official-docs/grpc-connect-status-codes-error-model.md#C3` (status 는 transport 와 별도), `#C5` (Connect 는 non-200) | `proposed` |
|
||||
| D3 | GraphQL 응답에 `errors` 가 있으면 `PARTIAL_RESULT_FAILURE` 로 정규화한다. **규격은 이를 "successful execution" 이라 부르므로 이 결정은 의도적 이탈이다** | `local` | `raw/official-docs/graphql-over-http-draft-status-errors.md#C4` (규격의 명명), `#C6` (`data: null` 이면 errors 가 반드시 있음) | `proposed` |
|
||||
| D4 | GraphQL 성공 판정은 상태 코드가 아니라 본문의 `data`·`errors` 구조로 한다. **`200` 만 검사하지 않는다** | `local` | `raw/official-docs/graphql-over-http-draft-status-errors.md#C1` (data+errors 는 `294` 권고), `#C2` (data 있으면 `2xx`), `#C3` (상태 코드 무관 처리) | `proposed` |
|
||||
| D6 | `grpc-status` → 재시도 가능 여부 매핑은 **우리 프로젝트 결정**으로 등록하고 그 근거를 남긴다. 규격 인용으로 대신하지 않는다 | `local` | `raw/official-docs/grpc-connect-status-codes-error-model.md#C1` (코드 목록은 확정), `#C2` (재시도 판정은 애플리케이션 몫이라고 규격이 명시) | `proposed` |
|
||||
| D7 | gRPC-Web adapter 와 Connect adapter 를 **분리한다**. 성공 판정 코드를 공유하지 않는다 | `local` | `raw/official-docs/grpc-connect-status-codes-error-model.md#C5` (Connect 오류는 non-200), `#C6` (Connect 는 trailer 미사용), `#C7` (gRPC-Web 은 다른 프로토콜) | `proposed` |
|
||||
|
||||
**deferred (이번 회차 조사 범위 밖)**: D8 — persisted-document ID 기반 GraphQL 요청의 전송 방식(GET vs POST, 캐시 가능성, media type 협상). GraphQL over HTTP draft §5·§6.2 를 따로 읽어야 하며, 이번 조사는 §6.4 상태 코드·오류 처리에 한정했다.
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
@@ -139,18 +144,22 @@ GraphQL·gRPC-Web·Connect-Web adapter 가 기존 `ResourceQueryPort`/`ResourceC
|
||||
|---|---|
|
||||
| `[[docs/superpowers/specs/2026-07-28-ca-skeleton-frontend-runtime-adapter-features-design]]` §5.2·§6.2 | 신규 port 0개 결론과 `FE-REG-API` 확장 |
|
||||
| [[raw/official-docs/zod-runtime-schema-validation-official]] | 디코드 후 검증의 상위 근거 |
|
||||
| [[raw/official-docs/graphql-over-http-draft-status-errors]] | D3 `errors` 응답 정규화가 규격 이탈임을 밝히는 근거 · D4 상태 코드가 아니라 본문으로 성공 판정 |
|
||||
| [[raw/official-docs/grpc-connect-status-codes-error-model]] | D2 protocol 별 성공 판정 분리 · D6 재시도 판정이 우리 결정인 근거 · D7 gRPC-Web 과 Connect 를 분리하는 근거 |
|
||||
| [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §2.1.4·§7.3·§8.2 | 응답 흐름 8단계와 실패 정규화 |
|
||||
|
||||
**근거 등급 경계**: `FE-D031`(protocol opt-in)의 rationale 은 `project-local default, 외부 source claim 아님` 이다. gRPC-Web·Connect 프로토콜 명세와 GraphQL over HTTP 규약을 다룬 raw 자료는 이 repo 에 없다(`FE-Q-011`). 특히 `grpc-status` ↔ 정규화 kind 매핑표는 명세 확인 없이 확정할 수 없으므로 §구현 가이드를 비워 둔다.
|
||||
**근거 등급 경계**: 2026-07-28 `/branch-spec` 조사로 세 프로토콜의 **오류 표현 방식**은 규격 근거를 확보했다. 다만 두 가지를 구분해야 한다. (1) gRPC 코드 목록(0~16)은 규격이 확정해 주지만, **어떤 코드가 재시도 가능한지는 규격이 명시적으로 애플리케이션에 위임한다** — `grpc-connect-status-codes-error-model#C2`. 즉 매핑표는 "규격 확인 후 채운다" 가 아니라 "우리가 정하고 근거를 남긴다" 다. (2) GraphQL over HTTP 는 **draft** 이며, 그 규격은 field error 상황을 오히려 "successful execution" 이라 부른다(`graphql-over-http-draft-status-errors#C4`). 우리 `PARTIAL_RESULT_FAILURE` 결정은 규격 준수가 아니라 **의도적 이탈**이다. 신규 port 0개(D1)는 여전히 외부 근거가 없다.
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] `FE-REG-API.protocol` 별 adapter 선택 규칙 확정 — 등급: `planned`
|
||||
- [ ] protocol 별 성공 판정 함수 분리 — 등급: `planned`
|
||||
- [ ] GraphQL `errors[]` → `PARTIAL_RESULT_FAILURE` 매핑 — 등급: `planned`
|
||||
- [ ] `grpc-status` → 정규화 kind 매핑표 (명세 확인 후) — 등급: `planned`
|
||||
- [ ] GraphQL 본문 `errors` → `PARTIAL_RESULT_FAILURE` 매핑 (상태 코드 무관, `294` 포함) — 등급: `planned`
|
||||
- [ ] `grpc-status` → 정규화 kind 매핑표를 **우리 결정으로 작성 후 backend 소유자와 대조** — 등급: `planned`
|
||||
- [ ] gRPC-Web adapter 와 Connect adapter 분리 유지 fixture — 등급: `planned`
|
||||
- [ ] codec decode 실패 → `CODEC_DECODE_FAILURE` — 등급: `planned`
|
||||
- [ ] REST gateway fallback 경로 — 등급: `planned`
|
||||
- [ ] protocol 차원과 gateway fallback 발생률 telemetry 등록 (관심사 7 should-fix) — 등급: `planned`
|
||||
- [ ] `FE-GATE-030` protocol mapping report 산출 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
@@ -160,36 +169,93 @@ GraphQL·gRPC-Web·Connect-Web adapter 가 기존 `ResourceQueryPort`/`ResourceC
|
||||
## 결정 사항
|
||||
|
||||
- 2026-07-28: 신규 port 0개 / 이유: 프로토콜은 registry 데이터이지 타입이 아니며, port 로 새면 dependency inversion 이 무너짐 / 검토한 대안: `GraphQLPort`·`GrpcWebPort` 분리 / 근거: 설계문서 §5.2
|
||||
- 2026-07-28: `200 + errors[]` 를 실패로 정규화 / 이유: 부분 데이터를 성공으로 취급하면 빈 화면이 정상처럼 보임 / 검토한 대안: 부분 성공 상태 신설 / 근거: 근거 raw 미수집, project-local 판단 (`FE-Q-011`)
|
||||
- 2026-07-28: `errors` 있는 응답을 실패로 정규화 / 이유: 부분 데이터를 성공으로 취급하면 빈 화면이 정상처럼 보임 / 검토한 대안: 부분 성공 상태 신설 / 근거: **규격 이탈을 자각한 project decision**. GraphQL over HTTP draft 는 이를 "successful execution" 이라 부른다(`graphql-over-http-draft-status-errors#C4`)
|
||||
- 2026-07-28: 성공 판정에서 상태 코드를 빼고 본문 구조만 봄 / 이유: 규격이 data+errors 에 `294` 를 권고하고 클라이언트에게 상태 코드와 무관한 처리를 요구함 / 검토한 대안: `200`·`294` 를 모두 허용 목록에 넣기 / 근거: `graphql-over-http-draft-status-errors#C1`·`#C3`. 허용 목록 방식은 draft 가 코드를 바꾸면 다시 깨진다
|
||||
- 2026-07-28: gRPC-Web 과 Connect adapter 분리 / 이유: Connect 는 오류를 non-200 으로 보내고 trailer 를 쓰지 않아 판정 규칙이 정반대 / 검토한 대안: 공통 gRPC 계열 adapter / 근거: `grpc-connect-status-codes-error-model#C5`·`#C6`·`#C7`
|
||||
|
||||
<!-- section-id: decision-evidence -->
|
||||
## 결정-근거 매핑
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | 신규 port 0개 | 항상. 프로토콜 고유 기능(gRPC 양방향 스트림 등)이 use case 레벨에 필요해지면 재검토 | 없음 — `FE-Q-011` | `project decision` | 추상화가 새는 프로토콜 기능이 있을 수 있음 |
|
||||
| D2 | protocol 별 성공 판정 함수 분리 | 항상 | 없음 — `FE-Q-011` | `project-local default` | 판정 로직이 프로토콜마다 흩어져 중복될 수 있음 |
|
||||
| D3 | `200 + errors[]` → `PARTIAL_RESULT_FAILURE` | 항상. 제품이 부분 데이터를 의미 있게 쓸 수 있으면 재검토 | 없음 — `FE-Q-011` | `project-local default` | 일부 필드만 실패한 응답을 통째로 버리게 됨 |
|
||||
| D1 | 신규 port 0개 | 항상. 프로토콜 고유 기능(gRPC 양방향 스트림 등)이 use case 레벨에 필요해지면 재검토 | 없음 — 조사 후에도 외부 근거 없음 | `UNSUPPORTED_DECISION` | 추상화가 새는 프로토콜 기능이 있을 수 있음 |
|
||||
| D2 | protocol 별 성공 판정 함수 분리 | 항상. 세 프로토콜의 성공 신호 위치가 서로 달라 예외가 없다 | `graphql-over-http-draft-status-errors.md#C3`, `grpc-connect-status-codes-error-model.md#C3`·`#C5` | `official-reference` | 판정 로직이 프로토콜마다 흩어져 중복될 수 있음 |
|
||||
| D3 | `errors` 있으면 `PARTIAL_RESULT_FAILURE` | 항상. 제품이 부분 데이터를 의미 있게 쓸 수 있으면 재검토 | `graphql-over-http-draft-status-errors.md#C4`(규격은 성공이라 부름), `#C6` | `project decision` — **규격 이탈을 자각한 선택** | 일부 필드만 실패한 응답을 통째로 버린다. 규격을 따르는 다른 클라이언트와 동작이 달라진다 |
|
||||
| D4 | 상태 코드가 아니라 본문으로 성공 판정 | 항상 | `graphql-over-http-draft-status-errors.md#C1`·`#C2`·`#C3` | `official-reference` (draft) | draft 라 `294` 권고가 바뀔 수 있다. 다만 "본문으로 판정" 은 코드가 바뀌어도 유효 |
|
||||
| D6 | 재시도 매핑을 우리 결정으로 등록 | 항상. 규격이 판단을 위임했으므로 위임을 받은 쪽이 근거를 남겨야 한다 | `grpc-connect-status-codes-error-model.md#C1`, `#C2` | `official-reference`(위임 사실) + `project decision`(매핑 내용) | 매핑이 backend 의 코드 사용 관습과 어긋나면 재시도가 과하거나 부족해진다 |
|
||||
| D7 | gRPC-Web 과 Connect adapter 분리 | 항상. 두 프로토콜의 오류 위치가 정반대다 | `grpc-connect-status-codes-error-model.md#C5`, `#C6`, `#C7` | `official-reference` | 유사한 코드가 두 벌 생긴다. 공유하려는 리팩터가 나중에 회귀를 만든다 |
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
> 근거 raw 자료(`FE-Q-011`, `FE-Q-013`) 수집 전까지 비워 둔다. `grpc-status` 코드별 매핑과 Connect 의 error 표현은 명세를 읽지 않고 쓸 수 없으며, 추측으로 쓰면 전부 `UNSUPPORTED_IMPL_DECISION` 이다.
|
||||
> 2026-07-28 `/branch-spec` 조사(GraphQL over HTTP draft + gRPC status codes + Connect protocol)로 채웠다. 규격이 확정해 주는 것과 우리가 정해야 하는 것을 절마다 구분했다. 후자는 `UNSUPPORTED_IMPL_DECISION` + trade-off 로 표시했다(CLAUDE.md §15.5 R2).
|
||||
|
||||
### 1. 성공 판정 — 프로토콜마다 신호가 다른 곳에 있다
|
||||
|
||||
> **Trace**: D2(판정 함수 분리) ← `graphql-over-http-draft-status-errors.md#C3`, `grpc-connect-status-codes-error-model.md#C3`·`#C5` / D4(본문 판정) ← `#C1`·`#C2`·`#C3` / D7(adapter 분리) ← `#C5`·`#C6`·`#C7`
|
||||
|
||||
성공을 판정하는 위치가 세 프로토콜에서 전부 다르다. 이것이 판정 함수를 공유할 수 없는 이유다.
|
||||
|
||||
| protocol | 성공 신호 위치 | 틀리기 쉬운 구현 |
|
||||
|---|---|---|
|
||||
| REST | HTTP 상태 코드 | — (기준선) |
|
||||
| GraphQL | **본문의 `data`·`errors` 구조**. 상태 코드는 `200` 일 수도 `294` 일 수도 있다 | `200` 만 검사 → `294` 응답을 실패로 오분류. `2xx` 만 검사 → `errors` 를 놓침 |
|
||||
| gRPC-Web | **trailer 의 `grpc-status`**. HTTP 는 200 이어도 실패일 수 있다 | HTTP 상태만 검사 → 실패를 성공으로 처리 |
|
||||
| Connect | **HTTP 상태 코드**(오류는 non-200) + 본문 JSON 의 `code` | gRPC-Web 과 같은 코드로 처리 → 정반대 규칙이라 반드시 틀림 |
|
||||
|
||||
GraphQL 행이 특히 함정이다. 규격은 data 와 errors 가 함께 있으면 `294` 를 권고하고(`#C1`), 클라이언트는 상태 코드와 무관하게 본문을 처리하라고 명시한다(`#C3`). 그래서 판정은 **상태 코드를 보지 않고** `data` 와 `errors` 의 존재로 한다.
|
||||
|
||||
gRPC-Web 과 Connect 를 한 adapter 로 묶지 않는다. Connect 는 trailer 를 아예 쓰지 않고(`#C6`) 오류를 non-200 으로 보내며(`#C5`), 규격 자신이 gRPC-Web 과 다른 프로토콜이라고 밝힌다(`#C7`). 이름이 비슷하다는 이유로 공유하면 한쪽이 반드시 틀린다.
|
||||
|
||||
`UNSUPPORTED_IMPL_DECISION` — 판정 함수를 protocol 값으로 조회하는 registry 형태로 두는 것. 규격은 판정 규칙만 정하고 우리 코드 구조를 정하지 않는다. trade-off: 조건 분기 대신 조회 표를 쓰면 새 프로토콜을 추가할 때 등록 누락이 boot 시점에 드러난다. 비용은 간접 참조가 한 겹 늘어나는 것.
|
||||
|
||||
### 2. GraphQL `errors` 처리 — 규격과 다르게 간다
|
||||
|
||||
> **Trace**: D3 ← `graphql-over-http-draft-status-errors.md#C4`·`#C6`
|
||||
|
||||
**규격은 field error 가 있는 부분 응답을 "successful execution" 이라고 부른다**(`#C4`). 우리는 이를 `PARTIAL_RESULT_FAILURE` 로 정규화한다. 규격 준수가 아니라 **의도적 이탈**이며, 이 문장이 노트에 남아야 다음 사람이 근거를 오해하지 않는다.
|
||||
|
||||
이탈하는 이유는 부분 데이터가 화면에서 정상처럼 보이기 때문이다. 목록의 절반이 비어 온 응답을 성공으로 넘기면 사용자는 "데이터가 없다" 고 읽고, 우리는 오류를 관측하지 못한다.
|
||||
|
||||
telemetry 에는 error 개수와 path 개수만 남기고 error message 본문은 남기지 않는다. `#C6` 대로 `data: null` 이면 errors 가 반드시 있으므로, `data` 유무만으로 request error 와 field error 를 가를 수 있다.
|
||||
|
||||
`UNSUPPORTED_IMPL_DECISION` — 부분 데이터를 **버리는** 것(수신은 하되 use case 에 넘기지 않음). 규격도 우리 근거도 "버려라" 라고 말하지 않는다. trade-off: 살려서 넘기면 use case 마다 "이 데이터가 완전한가" 를 판단해야 하고 그 판단이 빠지는 순간 조용한 오류가 된다. 비용은 일부 필드만 실패한 응답에서 쓸 수 있는 데이터까지 잃는 것.
|
||||
|
||||
### 3. gRPC 코드 매핑 — 규격이 우리에게 넘긴 결정
|
||||
|
||||
> **Trace**: D6 ← `grpc-connect-status-codes-error-model.md#C1`·`#C2`
|
||||
|
||||
코드 목록은 규격이 준다 — 0(OK)부터 16(UNAUTHENTICATED)까지 17개(`#C1`). 그러나 **재시도 가능 여부는 규격이 정하지 않는다.** 원문이 명시적이다: *"individual applications must make their own determination as to which status codes should cause an RPC to be retried"*(`#C2`).
|
||||
|
||||
따라서 매핑표는 `FE-REG-ERROR` 에 **우리 결정으로 등록**하고, 각 행에 왜 그렇게 정했는지를 남긴다. "규격이 그렇다" 는 근거로 쓸 수 없다. 매핑이 backend 의 코드 사용 관습과 어긋나면 재시도가 과하거나 부족해지므로, 표를 만든 뒤 backend 소유자와 대조하는 것이 `FE-GATE-030` 이전 단계로 필요하다.
|
||||
|
||||
`UNSUPPORTED_IMPL_DECISION` — 17개 코드를 어떤 kind 로 접을지, 그중 무엇을 retryable 로 둘지. 규격이 판단을 위임했으므로 이 표 전체가 우리 trade-off 다. 지금 값을 적지 않는 이유는 backend 대조 없이 정하면 두 번 정하게 되기 때문이다.
|
||||
|
||||
### 4. 이 branch 가 남기지 않는 것 (R3)
|
||||
|
||||
- 디코드 이후 payload 의 스키마 검증 → `DELEG-FE-010` 로 [[raw/branch-notes/feature-runtime-schema-validation-contract]] 에 위임(`FLOW-FE-RESP-004`~`006`)
|
||||
- error kind 의 등록과 총함수 정규화 → [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] 소유(`FE-OC-008`). 이 branch 는 신규 3종의 **진입 조건**만 정한다
|
||||
- timeout·retry·idempotency 기본값 → [[raw/branch-notes/feature-api-client-response-envelope-contract]] 소유(`FE-OC-006`)
|
||||
- 스키마·코드 생성 파이프라인 SSOT → `FE-Q-013`
|
||||
- 스트림 protocol(`sse`·`websocket`·`poll`) → [[raw/branch-notes/feature-frontend-realtime-subscription-lifecycle-contract]] 소유
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**
|
||||
- HTTP 200 + `grpc-status` 비0 → `PROTOCOL_STATUS_MISMATCH`. protocol status 가 재시도 가능일 때만 safe/keyed 재시도
|
||||
- HTTP 200 + `grpc-status` 비0 → `PROTOCOL_STATUS_MISMATCH`. status 는 transport 와 별도로 전달되므로(`grpc-connect-status-codes-error-model#C3`) HTTP 상태만 보는 구현은 실패를 성공으로 처리한다 (D2)
|
||||
- protobuf/GraphQL 디코드 실패 → `CODEC_DECODE_FAILURE`, 본문을 telemetry 에 남기지 않음
|
||||
- GraphQL `200 OK` + `errors[]` → `PARTIAL_RESULT_FAILURE`, error path count 만 telemetry
|
||||
- **GraphQL 응답에 `errors` 존재** → `PARTIAL_RESULT_FAILURE`, error 개수와 path 개수만 telemetry. 규격은 이를 "successful execution" 이라 부르므로 의도적 이탈이다(`graphql-over-http-draft-status-errors#C4`) (D3)
|
||||
- **GraphQL 상태 코드가 `294`** → 실패가 아니다. data 와 errors 가 함께 있다는 규격 권고 신호이므로(`#C1`) 본문 구조로 판정한다. `200` 만 성공으로 보는 구현은 이 응답을 잘못 분류한다 (D4)
|
||||
- **Connect 오류를 HTTP 200 으로 기대** → Connect 는 오류를 non-200 으로 보낸다(`#C5`). gRPC-Web 판정 코드를 그대로 쓰면 여기서 어긋난다 (D7)
|
||||
- capability OFF 또는 브라우저 미지원 → REST gateway 로 fallback (`degraded-alternative`)
|
||||
- gateway 도 없으면 `CAPABILITY_UNSUPPORTED`
|
||||
- **다른 계약 의존**
|
||||
- [[raw/branch-notes/feature-api-client-response-envelope-contract]] 의 timeout·retry·idempotency 에 의존
|
||||
- [[raw/branch-notes/feature-runtime-schema-validation-contract]] 의 `FLOW-FE-RESP-004`~`006` 에 의존 (`DELEG-FE-010`)
|
||||
- [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] 의 총함수 정규화에 의존 — 신규 3종 kind 가 매핑되어야 함
|
||||
- [[raw/branch-notes/feature-boundary-mapper-viewmodel-contract]] 의 DTO→model 매핑에 의존 — 디코드 산출물이 mapper 입력
|
||||
- [[raw/branch-notes/feature-api-client-response-envelope-contract]] — timeout·retry·idempotency 기본값(`FE-OC-006`). D6 의 재시도 매핑이 이 기본값 위에 얹히므로, 상위 retry 상한과 protocol status 기반 재시도가 곱해지지 않아야 한다
|
||||
- [[raw/branch-notes/feature-runtime-schema-validation-contract]] — `FLOW-FE-RESP-004`~`006`(`DELEG-FE-010`). 디코드 산출물이 이 단계로 넘어가며, 디코드가 타입을 보장한다고 건너뛰면 안 된다
|
||||
- [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] — 신규 3종 kind(`PROTOCOL_STATUS_MISMATCH`·`CODEC_DECODE_FAILURE`·`PARTIAL_RESULT_FAILURE`)의 등록과 `defaultRetryable`. D6 의 매핑표가 이 registry 에 들어간다
|
||||
- [[raw/branch-notes/feature-boundary-mapper-viewmodel-contract]] — DTO→model 매핑. 디코드 산출물이 mapper 입력이며, 프로토콜별로 산출물 모양이 달라지면 mapper 가 프로토콜을 알게 되어 D1 이 무너진다
|
||||
- [[raw/branch-notes/feature-frontend-env-runtime-config-contract]] `D4` — `CAP_FE_ALT_PROTOCOL` 해석 시점. flag OFF 면 protocol adapter 가 번들에 없어야 한다(`FE-GATE-033`)
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장
|
||||
@@ -201,11 +267,41 @@ GraphQL·gRPC-Web·Connect-Web adapter 가 기존 `ResourceQueryPort`/`ResourceC
|
||||
| protocol adapter 가 신규 port 를 만들지 않았다 | 구현 중 편의로 port 가 늘어나기 쉬움 | architecture fixture — `application/ports/` 파일 수가 늘지 않았는지 | `planned` |
|
||||
| REST gateway fallback 이 실제로 도달한다 | capability OFF 경로가 테스트에서 빠지기 쉬움 | integration test — flag OFF 로 같은 operation 호출 | `planned` |
|
||||
| codec 산출물이 반드시 스키마 검증을 거친다 | 디코드가 이미 타입을 보장한다고 착각하기 쉬움 | negative fixture — 스키마 위반 디코드 결과 주입 후 `SCHEMA_MISMATCH` | `planned` |
|
||||
| `grpc-status` 매핑표가 명세와 일치한다 | 명세 미확인 상태 | `FE-Q-011` 수집 후 명세 대조 | `needs-confirmation` |
|
||||
| `grpc-status` 매핑표가 backend 의 코드 사용과 일치한다 | 규격은 재시도 판정을 애플리케이션에 위임했으므로(`grpc-connect-status-codes-error-model#C2`) 대조 상대가 명세가 아니라 **backend 소유자**다 | 매핑표 초안 작성 후 backend 소유자와 코드별 의미 대조 | `needs-confirmation` |
|
||||
| GraphQL 상태 코드 `294` 응답이 올바르게 처리된다 | `200` 만 검사하는 구현이 흔하고, draft 권고라 실제로 오는지도 미확인 | negative fixture — `294` + data + errors 응답 주입 후 `PARTIAL_RESULT_FAILURE` 확인. 별도로 backend 가 `294` 를 보내는지 실측 | `needs-confirmation` |
|
||||
| Connect adapter 가 gRPC-Web 판정 코드를 공유하지 않는다 | 이름이 비슷해 리팩터로 합쳐지기 쉬움 | architecture fixture — 두 adapter 가 같은 성공 판정 함수를 참조하지 않는지 | `planned` |
|
||||
| GraphQL 성공 판정이 상태 코드에 의존하지 않는다 | 상태 코드 검사가 습관적으로 들어감 | fixture — 같은 본문을 `200`·`294` 두 상태로 주입했을 때 판정 결과가 같은지 | `planned` |
|
||||
| 부분 데이터를 버리는 정책이 제품에서 수용 가능하다 | 규격은 이를 성공이라 부르므로 이탈 비용을 제품이 감당해야 함 | 제품 소유자 확인 — 목록 절반이 실패한 응답을 통째로 버려도 되는지 | `needs-confirmation` |
|
||||
|
||||
## Audit & Findings
|
||||
|
||||
> `/branch-spec` 2026-07-28 조사에서 규격 원문과 대조해 발견한 것. hub 소유 항목은 정합 권고만 남긴다(hub §3.3).
|
||||
|
||||
| Finding ID | 대상 | 현재 서술 | 조사 결과 | 권고 | 처리 |
|
||||
|---|---|---|---|---|---|
|
||||
| `GRAPHQL_STATUS_ASSUMPTION` | hub §8.2 실패 매트릭스 및 이 노트의 `D3` 초안 | "GraphQL `200 OK` + `errors[]`" — 상태 코드를 `200` 으로 특정한다 | `graphql-over-http-draft-status-errors#C1`: data 와 errors 가 함께 있으면 규격은 **`294`** 를 권고한다. `#C3`: 클라이언트는 상태 코드와 무관하게 본문을 처리해야 한다 | 진입 조건을 "`200 OK` + `errors[]`" 가 아니라 "응답 본문에 `errors` 존재" 로 바꿀 것. 상태 코드를 조건에 넣으면 `294` 를 놓친다 | `open` — hub §8.2 정정 후보 (`compatibility_impact: none`, 진입 조건의 정확화) |
|
||||
| `SPEC_DEVIATION_UNDECLARED` | 이 노트의 `D3` 초안 | "부분 성공이 아니라 `PARTIAL_RESULT_FAILURE` 로 정규화한다" — 규격을 따르는 것처럼 읽힌다 | `graphql-over-http-draft-status-errors#C4`: 규격은 field error 상황을 "partial response" 이자 **"successful execution"** 이라고 부른다 | 이탈임을 명시할 것. 이탈 자체는 유효하나 근거를 규격으로 오인하면 안 된다 | `resolved` 2026-07-28 — D3 서술과 §구현 가이드 2 에 명시 |
|
||||
| `RETRY_MAPPING_MISATTRIBUTED` | 이 노트의 TODO "`grpc-status` → 정규화 kind 매핑표 (명세 확인 후)" | 명세를 읽으면 매핑이 나온다고 전제한다 | `grpc-connect-status-codes-error-model#C2`: *"individual applications must make their own determination as to which status codes should cause an RPC to be retried"* — 규격이 판단을 명시적으로 위임한다 | "명세 확인 후" 를 "우리가 정하고 backend 와 대조" 로 바꿀 것 | `resolved` 2026-07-28 — D6 신설, TODO 문구 교체 |
|
||||
| `NO_GROUND_TRUTH` | `/branch-spec` §2 ca-tmpl 대조 | 명령은 ca-tmpl registry·코드와 대조하라고 요구 | `ca-tmpl` 은 Gradle/Java 백엔드 전용. frontend 구현 repo 미식별 | 전 항목 `planned` 유지 | `open` — `FE-Q-001` 선행 |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
미생성.
|
||||
> 2026-07-28 `/branch-spec` §8b. **agent dispatch 없이 수기 판정**했다(세션 제약). governing doc = [[raw/project-notes/ca-skeleton-frontend-operational-contract]]. 기준은 hub §2.2 의 10개 universal acceptance question.
|
||||
|
||||
| # | 관심사 | 판정 | 근거 |
|
||||
|---:|---|---|---|
|
||||
| 1 | 문제와 실패 모드가 구체적인가 | covered-here | §목표 — 빈 화면이 정상처럼 보이는 경로. §엣지 7개 |
|
||||
| 2 | 상속한 결정을 실제로 적용했는가 | covered-here | `PROTOCOL-001` → D1·D7, `VALIDATION-001` → `DELEG-FE-010`, `CAPABILITY-001` → `CAP_FE_ALT_PROTOCOL` 소유 |
|
||||
| 3 | project-wide default 와 limit | covered-here | D2(판정 분리), D4(본문 판정), D6(매핑은 우리 결정), D7(adapter 분리) |
|
||||
| 4 | 대안을 검토했는가 | covered-here | §결정 사항 — 프로토콜별 port / 부분 성공 상태 신설. D1 은 trade-off 명시 |
|
||||
| 5 | 금지 구현 | covered-here | §구현 가이드 1 — 판정 함수 공유 금지, 상태 코드만 검사 금지. §구현 가이드 3 — 규격 인용으로 재시도 근거 대체 금지 |
|
||||
| 6 | 실패 경로가 error kind 로 매핑되는가 | covered-here | 신규 3종 + `CAPABILITY_UNSUPPORTED`. 등록은 error-classification branch 소유 |
|
||||
| 7 | 관측 가능한가 | should-fix | `PARTIAL_RESULT_FAILURE` 의 error·path 개수는 정했으나, 어느 protocol 로 처리됐는지를 구분하는 차원이 `FE-REG-TELEMETRY` 에 없다. gateway fallback 발생률도 미등록 |
|
||||
| 8 | 위임 경계가 명확한가 | covered-here | `DELEG-FE-010`, §구현 가이드 4 의 R3 목록 |
|
||||
| 9 | 검증 수단이 있는가 | covered-here | §검증해야 할 주장 11행, `FE-GATE-030` fixture + protocol mapping report |
|
||||
| 10 | 말할 수 있는 범위 | covered-here | 전 항목 `planned` — `NO_GROUND_TRUTH` |
|
||||
|
||||
**판정: Covered (missing 0)** · Should-fix 1건(관심사 7). Blocking 아님.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
title: branch / feature-graphql-production-baseline-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-graphql-production-baseline-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||||
tags: [branch, ca-skeleton, graphql, complexity, persisted-query, dataloader]
|
||||
created: 2026-07-28
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-073
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-073
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-READINESS-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-API-VERSIONING-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-060]
|
||||
imports: []
|
||||
delegates: []
|
||||
accepts_delegations: []
|
||||
contract_packet: 1
|
||||
---
|
||||
|
||||
# branch: feature-graphql-production-baseline-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
> **스캐폴딩 상태** — 결정(D-row)·구현 가이드는 비어 있다. `/branch-spec feature-graphql-production-baseline-contract` 로 채운다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> 분해 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §4.2 — 전송·조회 (Tier X). 본 branch 는 project §8.0 `WI-CA-SKELETON-OPERATIONAL-CONTRACT-073` 의 실행 단위다.
|
||||
|
||||
형제 branch (같은 부모의 다른 자식 — 인접 영역):
|
||||
|
||||
- [[raw/branch-notes/feature-api-contract-baseline]]
|
||||
- [[raw/branch-notes/feature-websocket-cluster-delivery-contract]]
|
||||
- [[raw/branch-notes/feature-application-query-bypass-contract]]
|
||||
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
> project Work Item 에서 내려온 실행 계약의 snapshot. 여기에는 **pinned pointer + 1줄 요약 + branch 적용점**만 쓰고 상세를 복제하지 않는다.
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: 깊이·복잡도·persisted query·field authorization·스키마 gate test 가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-READINESS-001@1` | 모든 capability는 R0~R3 readiness 등급과 capability card를 가지며 R0 seam을 R2 provider로 표기하지 않는다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-API-VERSIONING-001@1` | URI prefix /v1이 default이며 X-Api-Version은 compatibility 실험용 보조 header다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다. 근거 없이 추측해 채우지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- `WI-CA-SKELETON-OPERATIONAL-CONTRACT-073` 의 완료 조건을 구현한다: 깊이·복잡도·persisted query·field authorization·스키마 gate test 가 통과한다
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- operation·field authorization 과 공유 인증/tenant 문맥
|
||||
- parser 문자·토큰·rule 깊이 한계
|
||||
- query depth 와 cost/complexity 계측
|
||||
- persisted query allowlist 프로파일
|
||||
- DataLoader/batch-loader 규약과 N+1 계약
|
||||
- cursor connection·페이지 상한, error extension 정제
|
||||
- introspection/GraphiQL 운영 정책과 스키마 breaking-change gate
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- subscription 전송 — #075 소유
|
||||
- REST 표면 계약 — `feature-api-contract-baseline` 소유
|
||||
- 심층 명세 — 본 노트는 스캐폴딩. D-row 와 §구현 가이드는 `/branch-spec` 이 채운다.
|
||||
- project decision registry 변경 — owner 는 project-note
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 외부 근거 미등록. `/branch-spec feature-graphql-production-baseline-contract` 단계에서 verbatim 인용과 함께 `raw/official-docs/` 로 수집한 뒤 여기서 링크한다.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| (미등록) | 1차 수집 대상: GraphQL Java 공식 — query limits · ca-tmpl platform 설계 §14.2 |
|
||||
|
||||
**프로젝트 내부 설계 참조 (등급 `internal-design-doc` — 공식 문서 아님, best practice 로 격상 금지):**
|
||||
|
||||
- ca-tmpl `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md`
|
||||
- llm-wiki `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md`
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [ ] `/branch-spec feature-graphql-production-baseline-contract` 로 D-row·§구현 가이드 작성 — 등급: `planned`
|
||||
- [ ] 깊이·복잡도·persisted query·field authorization·스키마 gate test 가 통과한다 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
project 결정 외 branch-local 결정은 아직 없음. `/branch-spec` 단계에서 기록한다.
|
||||
|
||||
<!-- section-id: decision-evidence -->
|
||||
## 결정-근거 매핑
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
`/branch-spec` 단계에서 source claim 기반으로 작성한다. 3-rule meta principle(R1 Reference 필수 / R2 UNSUPPORTED_IMPL_DECISION 명시 / R3 OUT_OF_BRANCH_SCOPE 정제) 적용.
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**: `/branch-spec` 단계에서 구체화한다.
|
||||
- **다른 계약 의존**: `WI-CA-SKELETON-OPERATIONAL-CONTRACT-060`([[raw/branch-notes/feature-capability-provider-selection-contract]]) 의 계약에 의존
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 본 branch 가 소유할 관심사가 sibling branch 결정과 겹치지 않는다 | 스캐폴딩 시점에는 D-row 가 없어 경계가 문장으로만 존재 | `/branch-spec` 후 `/sync` 실행 — owner 중복 검출 | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
`/coverage` 실행 전.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
해당 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
title: branch / feature-grpc-production-baseline-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-grpc-production-baseline-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||||
tags: [branch, ca-skeleton, grpc, protobuf, mtls, deadline]
|
||||
created: 2026-07-28
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-074
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-074
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-READINESS-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-OPTIONAL-ADAPTER-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-060]
|
||||
imports: []
|
||||
delegates: []
|
||||
accepts_delegations: []
|
||||
contract_packet: 1
|
||||
---
|
||||
|
||||
# branch: feature-grpc-production-baseline-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
> **스캐폴딩 상태** — 결정(D-row)·구현 가이드는 비어 있다. `/branch-spec feature-grpc-production-baseline-contract` 로 채운다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> 분해 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §4.2 — 전송·조회 (Tier X). 본 branch 는 project §8.0 `WI-CA-SKELETON-OPERATIONAL-CONTRACT-074` 의 실행 단위다.
|
||||
|
||||
형제 branch (같은 부모의 다른 자식 — 인접 영역):
|
||||
|
||||
- [[raw/branch-notes/feature-streaming-response-contract]]
|
||||
- [[raw/branch-notes/feature-api-contract-baseline]]
|
||||
- [[raw/branch-notes/feature-security-operational-baseline]]
|
||||
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
> project Work Item 에서 내려온 실행 계약의 snapshot. 여기에는 **pinned pointer + 1줄 요약 + branch 적용점**만 쓰고 상세를 복제하지 않는다.
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: proto 호환·TLS/mTLS·deadline·크기 한계·drain test 가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-READINESS-001@1` | 모든 capability는 R0~R3 readiness 등급과 capability card를 가지며 R0 seam을 R2 provider로 표기하지 않는다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-OPTIONAL-ADAPTER-001@1` | optional adapter stack은 축마다 구현체 하나를 고정하고 core stack과 분리된 matrix로 관리한다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다. 근거 없이 추측해 채우지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- `WI-CA-SKELETON-OPERATIONAL-CONTRACT-074` 의 완료 조건을 구현한다: proto 호환·TLS/mTLS·deadline·크기 한계·drain test 가 통과한다
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- protobuf 생성·버전 규약과 호환성 검사
|
||||
- TLS/mTLS 와 service/method authorization interceptor
|
||||
- 클라이언트 deadline 필수화와 서버 cancellation 전파
|
||||
- 요청/응답·메타데이터 크기 한계
|
||||
- retry 허용 status/method 의미와 keepalive 조정
|
||||
- unary·streaming backpressure 와 cancellation
|
||||
- 표준 health 상태의 startup/drain/shutdown 갱신, reflection 의 운영 정책
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- gRPC 로 노출할 도메인 서비스 정의
|
||||
- HTTP 표면 — web adapter 소유
|
||||
- 심층 명세 — 본 노트는 스캐폴딩. D-row 와 §구현 가이드는 `/branch-spec` 이 채운다.
|
||||
- project decision registry 변경 — owner 는 project-note
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 외부 근거 미등록. `/branch-spec feature-grpc-production-baseline-contract` 단계에서 verbatim 인용과 함께 `raw/official-docs/` 로 수집한 뒤 여기서 링크한다.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| (미등록) | 1차 수집 대상: gRPC 공식 — deadlines / retry · ca-tmpl platform 설계 §14.3 |
|
||||
|
||||
**프로젝트 내부 설계 참조 (등급 `internal-design-doc` — 공식 문서 아님, best practice 로 격상 금지):**
|
||||
|
||||
- ca-tmpl `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md`
|
||||
- llm-wiki `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md`
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [ ] `/branch-spec feature-grpc-production-baseline-contract` 로 D-row·§구현 가이드 작성 — 등급: `planned`
|
||||
- [ ] proto 호환·TLS/mTLS·deadline·크기 한계·drain test 가 통과한다 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
project 결정 외 branch-local 결정은 아직 없음. `/branch-spec` 단계에서 기록한다.
|
||||
|
||||
<!-- section-id: decision-evidence -->
|
||||
## 결정-근거 매핑
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
`/branch-spec` 단계에서 source claim 기반으로 작성한다. 3-rule meta principle(R1 Reference 필수 / R2 UNSUPPORTED_IMPL_DECISION 명시 / R3 OUT_OF_BRANCH_SCOPE 정제) 적용.
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**: `/branch-spec` 단계에서 구체화한다.
|
||||
- **다른 계약 의존**: `WI-CA-SKELETON-OPERATIONAL-CONTRACT-060`([[raw/branch-notes/feature-capability-provider-selection-contract]]) 의 계약에 의존
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 본 branch 가 소유할 관심사가 sibling branch 결정과 겹치지 않는다 | 스캐폴딩 시점에는 D-row 가 없어 경계가 문장으로만 존재 | `/branch-spec` 후 `/sync` 실행 — owner 중복 검출 | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
`/coverage` 실행 전.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
해당 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
title: branch / feature-idempotency-ownership-protocol-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-idempotency-ownership-protocol-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||||
tags: [branch, ca-skeleton, idempotency, owner-token, lease, replay]
|
||||
created: 2026-07-28
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-070
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-070
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-IDEMPOTENCY-OWNERSHIP-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-IDEMPOTENCY-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-060]
|
||||
imports: []
|
||||
delegates: []
|
||||
accepts_delegations: []
|
||||
contract_packet: 1
|
||||
---
|
||||
|
||||
# branch: feature-idempotency-ownership-protocol-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
> **스캐폴딩 상태** — 결정(D-row)·구현 가이드는 비어 있다. `/branch-spec feature-idempotency-ownership-protocol-contract` 로 채운다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> 분해 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §4.2 — 능력 계약 (Tier C). 본 branch 는 project §8.0 `WI-CA-SKELETON-OPERATIONAL-CONTRACT-070` 의 실행 단위다.
|
||||
|
||||
형제 branch (같은 부모의 다른 자식 — 인접 영역):
|
||||
|
||||
- [[raw/branch-notes/feature-rate-limit-idempotency-contract]]
|
||||
- [[raw/branch-notes/feature-kafka-consumer-inbox-contract]]
|
||||
- [[raw/branch-notes/feature-transaction-concurrency-contract]]
|
||||
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
> project Work Item 에서 내려온 실행 계약의 snapshot. 여기에는 **pinned pointer + 1줄 요약 + branch 적용점**만 쓰고 상세를 복제하지 않는다.
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: owner token 프로토콜·lease 분리·보증 등급별 provider 공통 계약 test 가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-IDEMPOTENCY-OWNERSHIP-001@1` | idempotency는 owner token 기반 claim·renew·complete·release 프로토콜을 쓰고 실행 lease와 replay TTL을 분리하며 보증 등급을 명시한다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-IDEMPOTENCY-001@1` | idempotency scope는 principal·key·useCase이며 tenant 활성화 시 tenant를 prefix한다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다. 근거 없이 추측해 채우지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- `WI-CA-SKELETON-OPERATIONAL-CONTRACT-070` 의 완료 조건을 구현한다: owner token 프로토콜·lease 분리·보증 등급별 provider 공통 계약 test 가 통과한다
|
||||
- **주도권 이전 수신** — H1 ([[raw/branch-notes/feature-rate-limit-idempotency-contract]] D2·D3·D6·D7·D8·D9). 이전 절차는 `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §5.2 6단계를 따른다. 기존 branch 의 D-row 는 `/branch-spec` 으로 본 branch 결정이 확정된 뒤 포인터로 치환한다.
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- owner token 기반 `claim / renew / complete / release` 프로토콜
|
||||
- claim 결과 — `ACQUIRED` / `REPLAY` / `IN_PROGRESS` / `FINGERPRINT_MISMATCH`
|
||||
- 실행 lease 와 완료 응답 replay TTL 의 분리
|
||||
- stale owner 연산의 ownership-lost 반환과 새 claim 변조 금지
|
||||
- fingerprint 정규화·버전, 저장 응답 크기·암호화·codec 버전 상한
|
||||
- 보증 등급 — `REQUEST_REPLAY` / `SAME_STORE_TRANSACTIONAL` / `EXTERNAL_IDEMPOTENCY`
|
||||
- JPA·Redis provider 공통 계약 suite
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- rate limit — #068 소유
|
||||
- `Idempotency-Key` 헤더 표면 — `feature-api-contract-baseline` 소유
|
||||
- 심층 명세 — 본 노트는 스캐폴딩. D-row 와 §구현 가이드는 `/branch-spec` 이 채운다.
|
||||
- project decision registry 변경 — owner 는 project-note
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 외부 근거 미등록. `/branch-spec feature-idempotency-ownership-protocol-contract` 단계에서 verbatim 인용과 함께 `raw/official-docs/` 로 수집한 뒤 여기서 링크한다.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| (미등록) | 1차 수집 대상: IETF idempotency draft / Stripe / Toss (기존 raw 4종 재사용) · ca-tmpl platform 설계 §9 |
|
||||
|
||||
**프로젝트 내부 설계 참조 (등급 `internal-design-doc` — 공식 문서 아님, best practice 로 격상 금지):**
|
||||
|
||||
- ca-tmpl `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md`
|
||||
- llm-wiki `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md`
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [ ] `/branch-spec feature-idempotency-ownership-protocol-contract` 로 D-row·§구현 가이드 작성 — 등급: `planned`
|
||||
- [ ] owner token 프로토콜·lease 분리·보증 등급별 provider 공통 계약 test 가 통과한다 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
project 결정 외 branch-local 결정은 아직 없음. `/branch-spec` 단계에서 기록한다.
|
||||
|
||||
<!-- section-id: decision-evidence -->
|
||||
## 결정-근거 매핑
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
`/branch-spec` 단계에서 source claim 기반으로 작성한다. 3-rule meta principle(R1 Reference 필수 / R2 UNSUPPORTED_IMPL_DECISION 명시 / R3 OUT_OF_BRANCH_SCOPE 정제) 적용.
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**: `/branch-spec` 단계에서 구체화한다.
|
||||
- **다른 계약 의존**: `WI-CA-SKELETON-OPERATIONAL-CONTRACT-060`([[raw/branch-notes/feature-capability-provider-selection-contract]]) 의 계약에 의존
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 본 branch 가 소유할 관심사가 sibling branch 결정과 겹치지 않는다 | 스캐폴딩 시점에는 D-row 가 없어 경계가 문장으로만 존재 | `/branch-spec` 후 `/sync` 실행 — owner 중복 검출 | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
`/coverage` 실행 전.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
해당 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -27,6 +27,12 @@ contract_packet_sha256: 1442f6124a72b8a5b62b10f02f014af447a26ecf28849e0baa7ca41a
|
||||
|
||||
> Layer: `raw/branch-notes/` — Kafka/Redis/Slack/Google Email 같은 선택형 adapter template와 실패 계약을 정의합니다.
|
||||
|
||||
> [!note] 개정 예정 고지 (2026-07-28)
|
||||
> 본 branch 는 **owner 를 유지**하되 내용을 코드 사실에 맞춰 개정한다. 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §5.3.
|
||||
>
|
||||
> - **개정 사유**: "optional adapter 는 disabled env 가 기본" 을 capability descriptor 모델로 대체한다. 활성화 SSOT 는 `feature-capability-provider-selection-contract` 로 이동하고 본 branch 는 소비자가 된다.
|
||||
> - **참조 설계**: ca-tmpl `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md` §7 (등급 `internal-design-doc` — 공식 문서 아님)
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
---
|
||||
title: branch / feature-kafka-consumer-inbox-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-kafka-consumer-inbox-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||||
tags: [branch, ca-skeleton, kafka, consumer, inbox, backpressure]
|
||||
created: 2026-07-28
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-064
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-064
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-DELIVERY-SEMANTICS-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-IDEMPOTENCY-OWNERSHIP-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-063, WI-CA-SKELETON-OPERATIONAL-CONTRACT-070]
|
||||
imports: []
|
||||
delegates: []
|
||||
accepts_delegations: []
|
||||
contract_packet: 1
|
||||
---
|
||||
|
||||
# branch: feature-kafka-consumer-inbox-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
> **스캐폴딩 상태** — 결정(D-row)·구현 가이드는 비어 있다. `/branch-spec feature-kafka-consumer-inbox-contract` 로 채운다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> 분해 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §4.2 — 기술 런타임 (Tier T). 본 branch 는 project §8.0 `WI-CA-SKELETON-OPERATIONAL-CONTRACT-064` 의 실행 단위다.
|
||||
|
||||
형제 branch (같은 부모의 다른 자식 — 인접 영역):
|
||||
|
||||
- [[raw/branch-notes/feature-kafka-producer-runtime-contract]]
|
||||
- [[raw/branch-notes/feature-idempotency-ownership-protocol-contract]]
|
||||
- [[raw/branch-notes/feature-background-job-async-contract]]
|
||||
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
> project Work Item 에서 내려온 실행 계약의 snapshot. 여기에는 **pinned pointer + 1줄 요약 + branch 적용점**만 쓰고 상세를 복제하지 않는다.
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: inbound leaf 등록·manual ack·rebalance·DLT·inbox 멱등 계약 test 가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-DELIVERY-SEMANTICS-001@1` | end-to-end 메시징 보증은 at-least-once 전달과 멱등 consumer·inbox로 표현하고 DB와 broker를 걸친 exactly-once를 주장하지 않는다 | consume 측 경계: 오프셋 ack 는 (비즈니스 write + inbox insert) DB 커밋 **이후**에만 수행하고(D3), 중복 재전달은 inbox dedupe(D10·D11)가 흡수한다. Kafka 트랜잭션으로 DB 를 포함한 exactly-once 를 주장하지 않는다 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-IDEMPOTENCY-OWNERSHIP-001@1` | idempotency는 owner token 기반 claim·renew·complete·release 프로토콜을 쓰고 실행 lease와 replay TTL을 분리하며 보증 등급을 명시한다 | consume 측 경계: 기본은 insert-once inbox(D10)이며 owner token claim/renew 재사용은 **조건부**(worker fan-out 으로 zombie consumer 동시 처리가 가능해질 때 — D12). 프로토콜 자체의 owner 는 [[raw/branch-notes/feature-idempotency-ownership-protocol-contract]] | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 상세 근거·선택 조건·Open Risk 는 아래 결정-근거 매핑 §의 동일 D-row 가 소유한다. 여기에는 요약과 relation 만 둔다(복제 금지).
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | 신규 inbound leaf `adapter:inbound:messaging-kafka` 를 module registry 에 등록(19→20)하고 의존은 기존 inbound leaf 4종과 동일하게 제한 | `local` | ca-tmpl `.harness/project/modules.yaml` (실측) | `proposed` |
|
||||
| D2 | seam 은 유지하되 스켈레톤이 `spring-kafka` 기반 기본 구현을 제공하고, broker 미선택 기동에서는 Kafka auto-config 가 켜지지 않아야 한다 (#063 D2 와 같은 축) | `local` | [[raw/branch-notes/feature-kafka-producer-runtime-contract]] D2 | `needs-approval` (#063 D2 와 동시 승인) |
|
||||
| D3 | `enable.auto.commit=false` + use case 성공과 DB 커밋 **이후에만** 오프셋 ack | `refines DEC-CA-SKELETON-OPERATIONAL-CONTRACT-DELIVERY-SEMANTICS-001@1` | `raw/official-docs/kafka-consumer-offset-commit-semantics-apache-javadoc.md#KAFKA-OFFSET-C2` | `proposed` |
|
||||
| D4 | 순서 단위는 파티션 — 동일 파티션 레코드는 항상 직렬 처리(공유 단일 큐 금지) | `local` | `raw/company-tech-blogs/kafka-multi-tier-retry-topic-dlq-uber.md#UBER-REPROC-C2` | `needs-confirmation` |
|
||||
| D5 | backpressure 는 큐 포화 시 `pause()`/drain 후 `resume()` — 레코드 거부(drop) 금지 | `local` | `raw/official-docs/spring-kafka-listener-container-pause-resume-backpressure.md#SPRK-PAUSE-C2` | `proposed` |
|
||||
| D6 | `CooperativeStickyAssignor` + `max.poll.*` 명시 pin, `onPartitionsRevoked` 를 유일 커밋 체크포인트로 신뢰 금지 | `local` | `raw/official-docs/kafka-incremental-cooperative-rebalance-kip429.md#KIP429-C5` | `proposed` |
|
||||
| D7 | 역직렬화 실패(poison)는 리스너 호출 이전 단계에서 감지하고 **non-retryable** 로 분류 | `local` | `raw/official-docs/spring-kafka-error-handling-deserializer-poison-record.md#SPRK-EHD-C3` | `proposed` |
|
||||
| D8 | 재시도 기본은 **blocking bounded retry**(파티션 순서 보존), non-blocking retry topic 은 기본 기각 | `local` | `raw/official-docs/spring-kafka-non-blocking-retry-topic-ordering-loss.md#SPRK-RETRYTOPIC-C3` | `proposed` |
|
||||
| D9 | dead-letter 회수 + 감사. 발행은 **application-core port 경유** — inbound leaf 가 producer 를 직접 보유하지 않음 | `local` | `raw/official-docs/spring-kafka-default-error-handler-dlt-fatal-exceptions.md#SPRK-ERRH-C4` | `proposed` |
|
||||
| D10 | `InboxStorePort` 기록과 비즈니스 write 를 **동일 `TransactionPort.inWrite` 경계**에서 커밋 | `refines DEC-CA-SKELETON-OPERATIONAL-CONTRACT-IDEMPOTENCY-OWNERSHIP-001@1` | `raw/official-docs/idempotent-consumer-microservices-io.md#MSIO-IDEMPC-C3` | `proposed` |
|
||||
| D11 | dedupe key = envelope `idempotencyKey` 1차 + `(idempotencyKey, eventType)` 복합 유니크. `(topic, partition, offset)` 단독 채택 금지 | `local` | [[raw/branch-notes/feature-domain-event-outbox-contract]] D12·D14 | `proposed` |
|
||||
| D12 | owner token 프로토콜(#070) 재사용은 조건부 — worker fan-out 도입 시에만 | `local` | [[raw/branch-notes/feature-idempotency-ownership-protocol-contract]] §범위 (D-row 미확정) | `needs-approval` |
|
||||
| D13 | inbox row TTL 은 **수치 미확정** — 관계식(`retention.ms`/replay 창 중 긴 쪽 이상 + 무한 보관 금지)만 고정 | `local` | (UNSUPPORTED_DECISION — 외부 근거 부재) | `proposed` |
|
||||
| D14 | consumer/inbox 용 error code·metric·env key 는 registry 에 **없음** — 전부 "신규 제안" 으로만 표기 | `local` | ca-tmpl `docs/registries/*.yaml` (실측: 해당 row 부재) | `proposed` |
|
||||
| D15 | 등록된 **`(topic, eventType)`** 조합만 소비하고 미등록 조합은 D7 경로로 회수. allowlist 는 코드 handler 등록부로 둔다 (`schemaVersion` 축은 envelope 확장 후) | `local` | `raw/official-docs/spring-kafka-default-error-handler-dlt-fatal-exceptions.md#SPRK-ERRH-C1` (라우팅 불일치 계열은 fatal) + outbox D12 (envelope `eventType`) | `proposed` |
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- `WI-CA-SKELETON-OPERATIONAL-CONTRACT-064` 의 완료 조건을 구현한다: inbound leaf 등록·manual ack·rebalance·DLT·inbox 멱등 계약 test 가 통과한다
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- `adapter:inbound:messaging-kafka` leaf 신설과 모듈 registry migration
|
||||
- application 성공 이후 manual acknowledgement
|
||||
- handler/schema/version allowlist
|
||||
- bounded concurrency·queue 와 pause/resume backpressure
|
||||
- rebalance·`max.poll` 처리, poison/역직렬화 실패 분류
|
||||
- retry topic 또는 지연 재시도, DLT 와 감사된 replay
|
||||
- `InboxStorePort` scope 와 같은 트랜잭션 커밋 규칙
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- producer 설정 — #063 소유
|
||||
- idempotency owner token 프로토콜 자체 — #070 소유
|
||||
- 심층 명세 — 본 노트는 스캐폴딩. D-row 와 §구현 가이드는 `/branch-spec` 이 채운다.
|
||||
- project decision registry 변경 — owner 는 project-note
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 외부 근거 추가 수집 진행 중. `/branch-spec feature-kafka-consumer-inbox-contract` 단계에서 verbatim 인용과 함께 `raw/official-docs/` 로 추가 수집한 뒤 여기서 링크한다.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/company-tech-blogs/kafka-multi-tier-retry-topic-dlq-uber]] | 다단계 retry topic + DLQ 대안이 실제 운영에서 어떤 전제(단계별 backoff, 순서 비보장 수용, idempotent consumer) 위에 성립하는지의 사례 근거 — `company-case-study` 등급, 공식 best practice 아님. 순서 비보장 전제가 ca-skeleton per-aggregate FIFO 계약과 충돌 가능한 지점을 표시 |
|
||||
| [[raw/official-docs/kafka-consumer-offset-commit-semantics-apache-javadoc]] | ca-skeleton 의 Kafka consumer 가 "application use case 성공 + inbox/비즈니스 트랜잭션 커밋 이후에만 offset 을 커밋(ack)" 하는 계약을 채택하고 `enable.auto.commit` 자동 커밋을 기각하는 근거 — 자동 커밋의 at-least-once 전제조건(poll 이후 전량 소비 필요)과 수동 커밋의 중복 창(commit 직전 crash → 재소비) 메커니즘. **한계**: rebalance·backpressure·DLT 상세는 이 자료 범위 밖 |
|
||||
| [[raw/company-tech-blogs/kafka-poison-pill-consumer-stuck-offset-confluent]] | 역직렬화 실패(poison pill)를 재시도 무의미로 분류하고 즉시 DLT 로 보내야 하는 근거 — poison pill 이 consumer offset 을 전진시키지 못한 채 무한 재시도 루프에 빠뜨리는 실패 메커니즘, 그리고 역직렬화 실패가 `poll()` 반환 이전에 발생해 리스너 레벨 예외 처리로는 잡을 수 없다는 것 — `company-case-study` 등급(Confluent 벤더 블로그), 공식 best practice 로 격상 금지 |
|
||||
| [[raw/official-docs/spring-kafka-listener-container-pause-resume-backpressure]] | bounded queue 포화 시 consumer 를 그룹에서 이탈시키지 않고 소비만 멈추는 backpressure 를 `pause()`/`resume()` 로 구현하는 근거 — pause 중에도 `poll()` 이 계속되어 rebalance 를 회피한다는 공식 동작과 반영 시점(poll 경계 vs `pauseImmediate`). **한계**: 파티션 단위 pause API(`pausePartition`/`resumePartition`)는 이 자료 범위 밖(별도 페이지, 추가 수집 필요) |
|
||||
| [[raw/official-docs/spring-kafka-pause-resume-partitions-on-listener-containers]] | D4(파티션별 독립 bounded queue)와 D5(포화 시 pause)를 **그 파티션만** pause 하는 형태로 구현할 근거 — `pausePartition(TopicPartition)`/`resumePartition(TopicPartition)` API(2.7~), poll() 경계 반영 시점, `isPartitionPauseRequested()`/`isPartitionPaused()` 상태 조회 API. **한계(중요)**: rebalance·재배정 시 파티션 pause 상태의 운명(유지/초기화)은 이 문서도, 컨테이너 레벨 자매 문서도 **다루지 않는다**(`SPRK-PAUSEPART-C5` — 부재 확인) — §구현 가이드 3 의 `onPartitionsAssigned` 행이 "pause 상태 반드시 초기화"를 `KIP429-C3`+`SPRK-PAUSE-C1` 근거로 적어 두었으나, 두 claim 모두 이 구체 동작을 직접 말하지 않아 `UNSUPPORTED_IMPL_DECISION` 재라벨 후보로 남는다(branch 소유자 판단 필요, 본 자료는 근거 부재만 보고) |
|
||||
| [[raw/official-docs/spring-kafka-non-blocking-retry-topic-ordering-loss]] | non-blocking retry topic 체인의 채택/배제 선택 조건 근거 — 공식 문서가 자인하는 순서 보장 손실(SPRK-RETRYTOPIC-C3)을 per-aggregate 순서 보장 요구사항과 대조해 언제 이 대안을 배제하는지 판단하는 근거. 위 Uber 사례(`kafka-multi-tier-retry-topic-dlq-uber`)의 순서 비보장 전제가 공식 문서로도 뒷받침됨을 확인 |
|
||||
| [[raw/company-tech-blogs/kafka-consumer-rebalance-cooperative-sticky-verygoodsecurity]] | `cooperative-sticky` 파티션 할당 전략 + `max.poll.*` 튜닝 병행 채택의 운영 사례 근거 — 잦은 rebalance 로 consumer 가 그룹에서 이탈하고 커밋이 실패하던 환경에서 두 조치를 적용한 결과. `company-case-study` 등급(VGS 벤더 블로그), 공식 best practice 로 격상 금지 — 규모(100 consumers/partitions, aiokafka Python 클라이언트) 전제가 ca-skeleton 과 다를 수 있음 |
|
||||
| [[raw/official-docs/kafka-consumer-configs-max-poll-and-commit-defaults]] | `max.poll.interval.ms`(초과 시 그룹 이탈·rebalance)/`max.poll.records`/`enable.auto.commit`·`auto.commit.interval.ms`/`session.timeout.ms`·`heartbeat.interval.ms`/`partition.assignment.strategy` 각각의 공식 정의·기본값 기준선 — 임의 수치 발명 방지. 기본 `partition.assignment.strategy`(`[RangeAssignor, CooperativeStickyAssignor]`)가 이미 CooperativeStickyAssignor 로의 단일 rolling-bounce 업그레이드 경로를 지원함을 확인 |
|
||||
| [[raw/official-docs/spring-kafka-default-error-handler-dlt-fatal-exceptions]] | poison/역직렬화 예외 6종 기본 fatal 분류(`SPRK-ERRH-C1`) · blocking retry backoff 가 consumer 스레드를 정지시켜 `max.poll.interval.ms` 초과 시 rebalance 위험을 만들고 그래서 `ContainerPausingBackOffHandler` 가 제공된다는 메커니즘(`SPRK-ERRH-C2`) · DLT 기본 명명 `<originalTopic>-dlt` + partition 요건 + recoverer 의 producer(`KafkaTemplate`/`KafkaOperations`) 요구(`SPRK-ERRH-C3`/`C4`) · recoverer 미구성 시 기본 동작이 로그만이라는 사실(`SPRK-ERRH-C5`) |
|
||||
| [[raw/official-docs/kafka-incremental-cooperative-rebalance-kip429]] | `partition.assignment.strategy=CooperativeStickyAssignor`(incremental cooperative rebalance) 채택의 Kafka 공식 사양(KIP-429, Accepted 2.4.0) 근거 — EAGER 는 rebalance 마다 소유한 모든 파티션을 revoke 하지만 COOPERATIVE 는 소유 파티션을 유지한다는 정의(`KIP429-C2`/`KIP429-C3`), 그리고 cooperative 프로토콜에서 `onPartitionsRevoked` 가 아예 호출되지 않을 수 있어 이를 rebalance 시작 신호나 유일한 커밋 체크포인트로 신뢰하면 안 된다는 공식 근거(`KIP429-C5`) — VGS 사례(`kafka-consumer-rebalance-cooperative-sticky-verygoodsecurity`)의 `cooperative-sticky` 채택을 공식 사양으로 보강 |
|
||||
| [[raw/official-docs/spring-kafka-error-handling-deserializer-poison-record]] | poison message(역직렬화 실패)를 리스너 호출 이전 단계(deserializer 레벨)에서 감지해 error handler/DLT 경로로 회수하는 방식 채택 — `ErrorHandlingDeserializer` 가 위임 deserializer 실패 시 null 값 + `DeserializationException` 헤더(원인 + raw bytes)를 실어 보내고, 컨테이너가 리스너 대신 `ErrorHandler` 를 호출한다는 Spring 공식 메커니즘(`SPRK-EHD-C2`/`C3`). Confluent 사례(`kafka-poison-pill-consumer-stuck-offset-confluent`)의 "재시도 무의미" 판단을 Spring 프레임워크 레벨의 구체적 반환값·라우팅 계약으로 보강 |
|
||||
| [[raw/official-docs/idempotent-consumer-microservices-io]] | inbox(PROCESSED_MESSAGE류) 테이블에 처리한 메시지 ID 를 기록해 at-least-once 재전달 중복을 탐지·폐기하는 방식 채택 근거 — ID INSERT 가 message handler 의 DB 트랜잭션 경계 안에서 이뤄지고 (subscriberId, messageID) 복합 유니크 제약으로 duplicate INSERT 가 실패·rollback 된다는 메커니즘(`MSIO-IDEMPC-C3`/`C4`), 그리고 별도 테이블 대신 비즈니스 엔티티 자체에 ID 를 저장하는 변형 옵션(`MSIO-IDEMPC-C5`). `engineering-blog` 등급(Chris Richardson 개인 패턴 카탈로그) — 공식 벤더 문서로 격상 금지, "동일 트랜잭션 요구"의 명시적 문장은 미발견(원본 raw 의 Usage Boundaries 참고) |
|
||||
| [[raw/official-docs/spring-kafka-ack-mode-manual-commit-and-concurrency]] | D3 의 manual ack 계약을 `ContainerProperties.AckMode` 층에서 어떻게 표현하는지의 근거 — `AckMode.MANUAL`/`MANUAL_IMMEDIATE` 정의(`SPRK-ACKMODE-C1`/`C2`), 기본값이 `MANUAL` 이 아니라 `BATCH` 라 명시 설정이 필요하다는 것(`SPRK-ACKMODE-C4`), `nack()`/`acknowledge(index)` 의 리스너·consumer 스레드 제약(`SPRK-ACKMODE-C5`/`C6`), `concurrency` > 파티션 수일 때 하향 조정(`SPRK-ACKMODE-C7` — D4/D6 의 "파티션당 컨슈머 1개" 전제와 정합, 단 Kafka 프로토콜 레벨 보장 자체의 대체 근거는 아님). **한계**: 사용자가 요청한 "Acknowledgment 를 별도 워커 스레드에서 호출해도 되는가"(일반 `acknowledge()` 의 스레드 규칙)·ack 순서 제약·`asyncAcks` trade-off 3가지는 이 페이지에서 self-grep 0건으로 미발견 확인 — 별도 페이지("Manually Committing Offsets") 조사 필요 |
|
||||
|
||||
**추가 수집 필요** (`/branch-spec` 단계): rebalance 시 `ConsumerRebalanceListener` 공식 API 세부 — **파티션 단위 pause/resume API 자체는 2026-07-28 `[[raw/official-docs/spring-kafka-pause-resume-partitions-on-listener-containers]]` 수집으로 해소되었으나, 그 문서도 rebalance·재배정 시 pause 상태의 운명은 다루지 않아 `ConsumerRebalanceListener`/`onPartitionsAssigned` 상호작용 근거는 여전히 미수집**, ca-tmpl platform 설계 §11.4, DLT 실제 라우팅 구성(`DefaultErrorHandler`+`DeadLetterPublishingRecoverer`)의 "Handling Exceptions" 공식 페이지
|
||||
|
||||
**프로젝트 내부 설계 참조 (등급 `internal-design-doc` — 공식 문서 아님, best practice 로 격상 금지):**
|
||||
|
||||
- ca-tmpl `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md`
|
||||
- llm-wiki `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md`
|
||||
|
||||
## 외부 근거 / 대안 조사 (2026-07-28 — `/branch-spec` 자동조사 6건)
|
||||
|
||||
결정마다 대안을 실제로 비교한 기록. 6개 결정 topic 을 `wiki-decision-researcher` 로 조사했고(회당 bound 6, 초과분 없음), 채택안·기각안의 근거 자료를 위 §근거 표에 raw 로 보존했다.
|
||||
|
||||
| # | 조사한 결정 topic | 비교한 대안 | 채택 / 기각 | 대응 D-row |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 오프셋 커밋(ack) 방식 | (a) `enable.auto.commit=true` / (b) raw `commitSync`·`commitAsync` 수동 커밋 / (c) 컨테이너 ack 모드 위임 | (a) **기각** — "성공 후 ack" 목표를 구조적으로 만족 못 함. (b)/(c) 는 D2(SPI vs concrete)에 종속되는 구현 형태 차이 | D3, D2 |
|
||||
| 2 | 장시간 처리와 rebalance 안정성 | (a) `max.poll.*` 튜닝 / (b) `pause()`+별도 워커 / (c) cooperative-sticky + rebalance listener | (b) 채택(범위상 기정) + (c) 병행 채택 + (a) 는 defense-in-depth 안전판 | D5, D6 |
|
||||
| 3 | 동시성·backpressure | (a) 파티션당 전담 스레드 / (b) bounded queue + pause/resume / (c) reactive(Reactor Kafka) backpressure | (a)를 기본, 넘칠 때 (b) 로 확장(파티션별 독립 큐 강제). (c) **기각** — sibling 이 스레드 기반 어휘를 SSOT 로 확립해 어휘가 분기됨 | D4, D5 |
|
||||
| 4 | poison·역직렬화 실패 | (a) deserializer 경계 감지 + 회수 / (b) `byte[]`/`String` 소비 후 application 파싱 / (c) skip-and-log | (a) 를 파싱 실패 경계로, (b) 를 도메인 규칙 위반 경계로 **역할 분담**. (c) **기각** — 감사 흔적 없이 조용히 유실 | D7 |
|
||||
| 5 | 재시도 전략·DLT | (a) blocking retry + DLT / (b) non-blocking retry topic 체인 / (c) 외부 지연 큐·DB 기반 지연 재시도 | (a) 채택 — (b) 는 공식 문서가 순서 손실을 자인해 D4 와 충돌. (c) 는 dead-letter 를 DB row 로 두는 대안으로 D9 에 보존 | D8, D9 |
|
||||
| 6 | consumer 중복 차단(inbox) | (a) inbox 테이블 + 동일 트랜잭션 / (b) 비즈니스 자연 유니크 제약 / (c) 외부 캐시(Redis) dedupe | (a) 채택(감사·범용성). (b) 는 1이벤트=1row 조건부 대안. (c) **기각** — Redis 는 optional adapter 라 정합성 근거를 mandatory 의존 밖에 두게 됨 | D10, D11, D12, D13 |
|
||||
|
||||
**비교의 핵심 축**: ① 순서 보장(파티션 단위)을 지킬 것인가 처리량을 살 것인가 — 5번이 여기서 갈린다. ② 정합성 근거를 mandatory 의존(PostgreSQL) 안에 둘 것인가 — 6번이 여기서 갈린다. ③ 스켈레톤이 SPI 인가 concrete 구현인가 — 1번의 (b)/(c) 선택이 여기에 종속되며 D2 가 이를 닫는다.
|
||||
|
||||
**조사 후에도 근거가 없어 라벨링한 것**: inbox TTL 수치(D13 `UNSUPPORTED_DECISION`), 감사된 replay 기록 스키마·계약 값 명명(§구현 가이드 4·6 의 `UNSUPPORTED_IMPL_DECISION`).
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [x] `/branch-spec feature-kafka-consumer-inbox-contract` 로 D-row·§구현 가이드 작성 — 등급: `documented-only` (D1~D15 + §구현 가이드 7절 작성, 2026-07-28)
|
||||
- [ ] `SOURCE_GAP-1` 해소 — 파티션-소비자 배타 배정의 Kafka 공식 verbatim 수집 후 D4 격상. **이미 수집한 `raw/official-docs/kafka-consumer-offset-commit-semantics-apache-javadoc.md` 와 동일한 KafkaConsumer Javadoc 페이지의 §Consumer Groups and Topic Subscriptions 절**에 해당 문장이 있으므로 새 URL fetch 없이 claim 추가 추출로 닫힌다 — 등급: `planned`
|
||||
- [x] `SOURCE_GAP-2` 해소(2026-07-28) — 파티션 단위 pause/resume API 공식 페이지(`[[raw/official-docs/spring-kafka-pause-resume-partitions-on-listener-containers]]`) 수집 완료: API 존재(`pausePartition`/`resumePartition`, 2.7~)·타이밍·상태조회는 확인. "재배정 시 pause 상태 초기화" 자체는 이 문서도 다루지 않음을 확인해 §구현 가이드 3 의 `onPartitionsAssigned` 행을 `UNSUPPORTED_IMPL_DECISION`(trade-off: 보수적으로 명시 resume)으로 강등 완료 — 등급: `documented-only`
|
||||
- [x] `SOURCE_GAP-5` 대부분 해소(2026-07-28) — spring-kafka `AckMode` 공식 페이지(`[[raw/official-docs/spring-kafka-ack-mode-manual-commit-and-concurrency]]`) 수집: ack 모드 정의·**기본값 `BATCH`**·리스너 타입 제약·concurrency 하향 조정 확보 — 등급: `documented-only`
|
||||
- [ ] `SOURCE_GAP-5` 잔여 — 일반 `acknowledge()` 의 호출 스레드 규칙과 ack 순서 제약은 위 페이지에 **부재 확인**. Spring Kafka "Manually Committing Offsets" 페이지에서 수집해 §구현 가이드 2 의 잔여 `UNSUPPORTED_IMPL_DECISION` 해소 — 등급: `planned`
|
||||
- [ ] **D2 는 #063 D2 와 동시 승인** — 승인 전까지 Kafka SDK 를 classpath 에 반입하지 않는다(capability-provider D13 충돌 위험) — 등급: `planned`
|
||||
- [ ] `feature-kafka-producer-runtime-contract`(#063)·`feature-idempotency-ownership-protocol-contract`(#070) 의 `/branch-spec` 완료 후 D11·D12 재검토 — 등급: `planned`
|
||||
- [ ] **`/depth` 재실행 (최우선)** — 루프 천장에서 종료했고, 마지막 depth Blocking(정지 시점 in-flight 계약)의 처방을 감사 **이후**에 적용해 재검증되지 않았다(§Audit `GATE_CEILING`) — 등급: `needs-confirmation`
|
||||
- [ ] stop 계약의 phase 배치·예산을 [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] D4 · [[raw/branch-notes/feature-background-job-async-contract]] owner 와 협의 — 등급: `planned`
|
||||
- [ ] inbound leaf 등록·manual ack·rebalance·DLT·inbox 멱등 계약 test 가 통과한다 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- 2026-07-28 `/branch-spec` 실행: ca-tmpl ground truth 감사(consumer·inbox 인프라 전부 부재, outbound messaging 은 SPI seam 으로 `actually-implemented`), 자동조사 6건(ack / rebalance / backpressure / poison / retry·DLT / inbox), raw 11건 수집(official-doc 8 + company-tech-blog 3), D1~D14 작성.
|
||||
- 2026-07-28 게이트 loop 1: depth `Not ready`(Blocking 2 — D4 선택 축 성립 불가 / 프레임워크 층 ack 공백) + coverage `Not-covered`(Blocking 1 — allowlist 무주인, 위임 주장이 거짓). 조치: D4 재작성, raw 2건 추가 수집(`spring-kafka-ack-mode-...`, `spring-kafka-pause-resume-partitions-...`), **D15 신설**, 위임 정정. → coverage **Covered**(Blocking 0) 달성.
|
||||
- 2026-07-28 게이트 loop 2: depth 재감사에서 신규 Blocking 1건(**poll 배치 흡수 규칙 부재** — drop 금지·블로킹 금지·pause 지연이 동시 성립해 합법 행동이 없어지는 구멍) + Should-fix 5건. 조치: 흡수 불변식·ack 발화 지점·모델별 poll 예산 관계식·pause 단위(파티션)·`concurrency` 행·구독 토픽 출처를 각각 명시. 미해소 항목은 §Audit & Findings 의 `SOURCE_GAP-1`·`SOURCE_GAP-5`(잔여).
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-07-28: (D1) 신규 inbound leaf 를 module registry 에 등록해 만든다(19→20). 검토한 대안: 기존 `adapter:outbound:messaging` 에 consumer 를 얹기 — outbound leaf 에 inbound role 이 섞여 기각. / 근거: ca-tmpl `.harness/project/modules.yaml`·`src/settings.gradle` 실측
|
||||
- 2026-07-28: (D2) seam 은 유지하되 스켈레톤이 `spring-kafka` 기반 기본 구현을 제공하고, broker 미선택 기동에서는 Kafka auto-config 가 켜지지 않아야 한다. **초안은 현행 코드(`KafkaSender` javadoc "The skeleton carries no Kafka SDK dependency")만 보고 순수 SPI 로 썼다가, 같은 날 작성된 sibling #063 D2 와 축이 갈리는 것을 발견해 재작성했다**(§Audit `SIBLING_DRIFT`). 검토한 대안: 순수 seam-only 유지 — SDK 가 다른 broker 구현을 오염시킨다고 확인되면 그때 후퇴하되 producer 와 함께 결정. / 근거: [[raw/branch-notes/feature-kafka-producer-runtime-contract]] D2
|
||||
- 2026-07-28: (D3) `enable.auto.commit=false` + use case 성공·DB 커밋 이후에만 ack. 검토한 대안: 자동 커밋 — 전제조건이 bounded queue 비동기 처리와 충돌해 기각. / 근거: [[raw/official-docs/kafka-consumer-offset-commit-semantics-apache-javadoc]], [[raw/official-docs/kafka-consumer-configs-max-poll-and-commit-defaults]]
|
||||
- 2026-07-28: (D4) 순서 단위는 파티션이며 동일 파티션 레코드는 직렬 처리한다(공유 단일 큐 금지). / 근거: [[raw/company-tech-blogs/kafka-multi-tier-retry-topic-dlq-uber]] + outbox D6 + producer [[raw/branch-notes/feature-kafka-producer-runtime-contract]] D5 (`key = aggregateId` 매핑이 `actually-implemented`). 공식 근거 보강은 `SOURCE_GAP-1`
|
||||
- 2026-07-28: (D5) backpressure 는 pause/resume 으로 표현하고 레코드를 거부하지 않는다. 검토한 대안: background-job 의 `AbortPolicy` 재사용 — 거부는 유실이라 at-least-once 위반으로 기각. / 근거: [[raw/official-docs/spring-kafka-listener-container-pause-resume-backpressure]]
|
||||
- 2026-07-28: (D6) `CooperativeStickyAssignor` pin + `max.poll.*` 명시 pin + `onPartitionsRevoked` 를 유일 커밋 체크포인트로 신뢰 금지. / 근거: [[raw/official-docs/kafka-incremental-cooperative-rebalance-kip429]], [[raw/company-tech-blogs/kafka-consumer-rebalance-cooperative-sticky-verygoodsecurity]]
|
||||
- 2026-07-28: (D7) 역직렬화 실패는 리스너 이전 단계에서 감지하고 non-retryable 로 분류한다. 검토한 대안: skip-and-log — 감사 흔적 없이 유실되어 기각. / 근거: [[raw/official-docs/spring-kafka-error-handling-deserializer-poison-record]], [[raw/company-tech-blogs/kafka-poison-pill-consumer-stuck-offset-confluent]]
|
||||
- 2026-07-28: (D8) 재시도는 blocking bounded retry 기본, non-blocking retry topic 기각. / 근거: [[raw/official-docs/spring-kafka-non-blocking-retry-topic-ordering-loss]] — "By using this strategy you lose Kafka's ordering guarantees for that topic."
|
||||
- 2026-07-28: (D9) dead-letter 발행은 application-core port 경유 — inbound leaf 가 producer 를 직접 보유하지 않는다. / 근거: [[raw/official-docs/spring-kafka-default-error-handler-dlt-fatal-exceptions]] + modules.yaml 의존 제한
|
||||
- 2026-07-28: (D10·D11) inbox 기록과 비즈니스 write 를 동일 트랜잭션에서 커밋하고, dedupe key 는 `idempotencyKey` + `eventType` 복합. `(topic,partition,offset)` 단독은 outbox 재발행에서 깨져 기각. / 근거: [[raw/official-docs/idempotent-consumer-microservices-io]], [[raw/branch-notes/feature-domain-event-outbox-contract]] D12·D14
|
||||
- 2026-07-28: (D12) owner token(#070) 재사용은 worker fan-out 도입 시로 조건화 — 위임 대상이 스캐폴딩이라 `needs-approval`
|
||||
- 2026-07-28: (D13) inbox TTL 수치는 정하지 않고 관계식만 고정 — 외부 근거 부재(`UNSUPPORTED_DECISION`)
|
||||
- 2026-07-28: (D14) consumer/inbox 계약 값은 registry 에 없으므로 전부 "신규 제안" 으로만 표기한다. / 근거: ca-tmpl `docs/registries/*.yaml` 실측
|
||||
- 2026-07-28: (D15, loop 1 추가) 등록된 `(topic, eventType)` 조합만 소비하고 미등록 조합은 non-retryable 로 회수한다. allowlist 는 코드 handler 등록부로 둔다. 검토한 대안: yaml/env 런타임 등록부(코드와 갈라짐), allowlist 없이 skip(조용한 유실), Schema Registry 위임(스키마 진화만 보고 라우팅을 보지 않음) — 모두 기각. **이 관심사는 초안에서 `feature-schema-serialization-contract` 로 위임한다고 잘못 적었다가 coverage 게이트에서 `missing` 판정을 받아 회수했다**(§Audit `FALSE_DELEGATION`). / 근거: [[raw/official-docs/spring-kafka-default-error-handler-dlt-fatal-exceptions]] `SPRK-ERRH-C1` + outbox D12
|
||||
|
||||
<!-- section-id: decision-evidence -->
|
||||
## Decision Evidence Map / 결정-근거 매핑
|
||||
|
||||
> 2026-07-28 `/branch-spec` 작성. company-tech-blog 는 `company-case-study` 라벨(공식 best practice 단정 금지). ca-tmpl 코드/registry 대조 결과는 `internal-contract-registry` / `actually-implemented`.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | 신규 inbound leaf `adapter:inbound:messaging-kafka` 를 `.harness/project/modules.yaml` 에 등록(현재 19 → 20). `role: inbound`, package root `dev.caskeleton.adapter.inbound.messaging.kafka`, `allowed_dependencies = [application-core, domain-core, shared-contract]` | 새 inbound transport 는 항상 별도 leaf. **대안(기각)**: 기존 `adapter:outbound:messaging` 에 consumer 를 얹기 — outbound leaf 에 inbound role 이 섞이고 `settings.gradle` 이 registry 를 읽어 include 하므로 role/의존 계약이 흐려진다. 도메인 핸들러가 단 하나뿐인 초소형 fork 라면 leaf 없이 `app-bootstrap` 배선만으로 시작할 수는 있으나, 그때도 registry row 없이 새 소스 디렉터리를 만들 수 없다 | ca-tmpl `.harness/project/modules.yaml` (2026-07-28 실측: modules 19개, inbound 4종 `web`/`grpc`/`graphql`/`websocket` 전수가 동일 3-의존 목록), `src/settings.gradle` (registry 를 읽어 `include` — registry 미등록 모듈은 빌드에 존재조차 못 함) | `internal-contract-registry` + `actually-implemented` | leaf 등록은 `verifyCleanArchitectureDependencies` 와 `CleanArchitectureTest` 의 검사 대상이 늘어나는 것이므로, `mutation_import` 값 선정 등 registry row 의 나머지 필드는 구현 시 기존 inbound row 를 복제해 결정해야 함(§구현 가이드 1 의 `UNSUPPORTED_IMPL_DECISION`) |
|
||||
| D2 | consumer 도 producer 와 **같은 축**을 따른다: `KafkaSender` 류 seam 은 유지하되 스켈레톤이 `spring-kafka` 기반 **기본 구현을 제공**하고, `app.messaging.broker` 가 kafka 로 선택되지 않은 기동에서는 Kafka 관련 auto-configuration 이 활성화되지 않아야 한다 | sibling [[raw/branch-notes/feature-kafka-producer-runtime-contract]] D2 가 producer 측에서 이 축을 이미 결정했으므로 consumer 만 순수 SPI 로 남기면 같은 저장소 안에서 두 축이 갈린다. **대안**: Kafka SDK 를 classpath 에 올리는 것이 다른 broker 구현까지 오염시킨다고 확인되면 그때 seam-only 로 후퇴 — 그 판단은 producer 측과 **함께** 내려야 한다(두 결정이 같은 classpath 를 공유) | sibling [[raw/branch-notes/feature-kafka-producer-runtime-contract]] D2 verbatim: "`KafkaSender` seam 은 유지하되 skeleton 이 spring-kafka 기반 기본 구현을 제공하며, **broker 가 선택되지 않은 기동에서는 Kafka 관련 auto-configuration 이 활성화되지 않아야 한다**" + 같은 D2 가 "inbound(consumer)는 신규 leaf 가 필요해 #064 가 모듈 registry migration(19→20)을 소유한다" 로 본 branch 의 D1 을 명시 승인. 현재 코드: `adapter/outbound/messaging/kafka/KafkaSender.java` javadoc "The skeleton carries no Kafka SDK dependency — it is added by the project that enables Kafka." (`actually-implemented` — **아직 SDK-free 상태**), `KafkaAdapterConfig` `@ConditionalOnProperty(app.messaging.broker, havingValue="kafka")` | `internal-cross-reference` (#063 D2 — 그 branch 도 `proposed` 상태) + `actually-implemented` (현행 seam 코드) | **현행 코드(SDK-free)와 #063 D2(기본 구현 제공)가 아직 어긋나 있다** — 본 D2 는 코드가 아니라 sibling 결정에 정렬한 것이고, #063 D2 자체가 `proposed` 다. 또한 #063 D2 의 Open Risk 가 그대로 본 branch 에도 적용된다: classpath 에 SDK 를 올리면 [[raw/branch-notes/feature-capability-provider-selection-contract]] D13("비활성 capability 는 연결·워커·스키마·health contributor 를 만들지 않는다")과 충돌할 수 있고, ca-tmpl 에 `spring.autoconfigure.exclude` 전례가 0건이다. consumer 는 auto-config 가 켜지는 순간 **listener container 가 실제로 broker 에 연결을 시도**하므로 producer 보다 이 충돌이 더 즉각적이다 |
|
||||
| D3 | 오프셋 ack 계약: `enable.auto.commit=false`. application use case 성공 **그리고** (비즈니스 write + inbox insert) DB 트랜잭션 커밋이 끝난 뒤에만 오프셋을 커밋한다 | 부작용(비즈니스 write)이 있는 모든 핸들러에서 항상. **대안(기각)**: `enable.auto.commit=true` — 자동 커밋이 at-least-once 를 주기 위한 전제("매 poll 반환분을 다음 poll 전에 전부 소비")가 본 branch 범위의 bounded queue 비동기 처리와 정면 충돌하며, 위반 시 committed offset 이 consumed position 을 앞질러 레코드가 유실된다. 순수 조회(부작용 없음) 핸들러만 있는 토픽이면 이 결정의 위험이 사라지지만 본 branch 범위 밖 | `raw/official-docs/kafka-consumer-offset-commit-semantics-apache-javadoc.md#KAFKA-OFFSET-C2` (자동 커밋의 전제조건 + 위반 시 missing records), `#KAFKA-OFFSET-C3` ("a message should not be considered as consumed until it is completed processing"), `#KAFKA-OFFSET-C4` (insert 후 commit 전 실패 → 재소비 = at-least-once 의 구조), `#KAFKA-OFFSET-C5` (`commitSync` 블로킹 vs `commitAsync` 비블로킹), `raw/official-docs/kafka-consumer-configs-max-poll-and-commit-defaults.md#KAFKA-CONSCFG-C3` (`enable.auto.commit` **기본값 true** — 명시적으로 꺼야 함), `#KAFKA-CONSCFG-C4` (`auto.commit.interval.ms` 기본 5000ms), `raw/official-docs/spring-kafka-ack-mode-manual-commit-and-concurrency.md#SPRK-ACKMODE-C4` ("The default AckMode is BATCH." — 프레임워크 층에서도 **명시 설정하지 않으면 자동 배치 커밋**), `#SPRK-ACKMODE-C2` (`MANUAL_IMMEDIATE` = acknowledge() 호출 즉시 커밋), `#SPRK-ACKMODE-C1` (`MANUAL` 은 이후 `BATCH` 시맨틱), `#SPRK-ACKMODE-C3` (리스너가 `AcknowledgingMessageListener` 여야 함) | `official-vendor-doc` | `commitSync` vs `commitAsync` 선택은 미확정 — 동기 커밋은 처리량을 깎고 비동기 커밋은 실패가 콜백으로만 전달된다(`KAFKA-OFFSET-C5`). §구현 가이드 2 의 `UNSUPPORTED_IMPL_DECISION` |
|
||||
| D4 | 소비 병렬성의 **순서 단위는 파티션**이다. 동일 파티션의 레코드는 항상 직렬로 처리하며, 여러 파티션의 레코드를 하나의 공유 큐/워커풀에 섞는 구현은 금지. **스켈레톤 기본 형태는 "poll 스레드 + 파티션별 독립 bounded queue + 파티션당 직렬 워커"** 다 | 선택 축은 동시성 크기가 **아니다** — 직렬 제약 때문에 어느 형태든 병렬도 상한은 파티션 수로 같다. 진짜 축은 **poll 스레드를 처리 지연에서 분리할 필요가 있는가**다. 핸들러 처리시간이 `max.poll.interval.ms` 예산 안에서 끝난다고 보장할 수 없거나(외부 I/O 포함) 재시도 backoff 가 그 예산을 잠식하면 → 큐 분리(기본). **대안**: 핸들러가 순수 CPU·단일 DB write 로 짧고 p99 가 예측 가능하면 리스너 인라인 처리로 단순화 가능 — 이때는 큐가 없으므로 D5 의 pause 도 불필요해지고, 대신 `max.poll.records` 를 낮춰 poll 예산을 지킨다. 순서 무관 이벤트만 싣는 토픽이라도 공유 큐는 기본이 아니며 토픽별 명시 선언이 필요하다 | `raw/company-tech-blogs/kafka-multi-tier-retry-topic-dlq-uber.md#UBER-REPROC-C2` ("Kafka only guarantees in-order processing within partitions and not across them" — 순서 단위가 파티션이라는 사실), [[raw/branch-notes/feature-domain-event-outbox-contract]] D6 (per-aggregate FIFO 를 보장 단위로 정의, global ordering 미보장), [[raw/branch-notes/feature-kafka-producer-runtime-contract]] D5 (순서를 파티션 단위로만 주장하고 `key = aggregateId` 로 per-aggregate FIFO 에 대응 — `OutboxMessagePublishAdapter` 의 `topic=eventType, key=aggregateId` 가 `actually-implemented` 로 확인됨, 2026-07-28), `raw/official-docs/spring-kafka-ack-mode-manual-commit-and-concurrency.md#SPRK-ACKMODE-C7` ("If the concurrency is greater than the number of TopicPartitions, the concurrency is adjusted down such that each container gets one partition." — **프레임워크가 병렬도를 파티션 수로 잘라낸다**는 공식 근거로, "동시성을 키워도 파티션 수가 상한" 이라는 D4 의 선택 축 재정의를 뒷받침) | `company-case-study` (UBER-REPROC-C2) + `internal-cross-reference` (outbox D6 · producer D5) + `actually-implemented` (key 매핑) | **"파티션 하나는 그룹 내 정확히 한 consumer 가 소비한다"는 Kafka 공식 verbatim 을 아직 수집하지 못했다** — 현재 이 사실의 직접 근거는 회사 블로그 1건뿐이다(§Audit `SOURCE_GAP-1`). 또한 producer D5 의 Open Risk 가 그대로 전이된다: **파티션 수를 늘리면 같은 `aggregateId` 가 다른 파티션으로 가서 per-aggregate FIFO 가 깨지고**, 그 순간 본 D4 의 직렬 처리 단위도 의미를 잃는다 |
|
||||
| D5 | backpressure 는 bounded queue 포화 시 **해당 파티션만** `pausePartition()`, drain 후 `resumePartition()` 로 처리한다. 레코드를 거부(drop)하거나 예외로 버리지 않으며, poll 스레드를 블로킹하지도 않는다. 이 셋이 동시에 성립하려면 **pause 요청 시점에 직전 poll 배치를 흡수할 큐 여유가 남아 있어야** 한다(§구현 가이드 2 의 흡수 불변식) | 항상. **대안(기각)**: sibling [[raw/branch-notes/feature-background-job-async-contract]] D7·D8 의 `AbortPolicy`(거부 후 `JOB_EXECUTOR_REJECTED` 로그)를 그대로 적용 — 거부는 레코드 유실이므로 at-least-once 계약(상속 결정)을 깬다. 다운스트림 장기 장애로 pause 로도 흡수가 안 되면 D8 재시도 → D9 회수 경로 | `raw/official-docs/spring-kafka-listener-container-pause-resume-backpressure.md#SPRK-PAUSE-C2` ("When a container is paused, it continues to poll() the consumer, avoiding a rebalance if group management is being used, but it does not retrieve any records" — pause 가 그룹 이탈을 유발하지 않는 근거), `#SPRK-PAUSE-C1` (pause 는 다음 poll 직전, resume 은 현재 poll 반환 직후 반영), `#SPRK-PAUSE-C3` (`pauseImmediate` 기본 false = 이전 poll 분 처리 완료 후 적용), `#SPRK-PAUSE-C4` (`isConsumerPaused()` 라야 실제 정지 확인), `raw/official-docs/spring-kafka-pause-resume-partitions-on-listener-containers.md#SPRK-PAUSEPART-C1` (2.7~ `pausePartition(TopicPartition)`/`resumePartition(TopicPartition)` — **파티션 단위** pause 가 D4 의 파티션별 큐와 짝을 이룬다), `#SPRK-PAUSEPART-C2` (poll 경계 반영), `#SPRK-PAUSEPART-C3` (요청 vs 실제 정지 구분), `#SPRK-PAUSEPART-C4` (`ConsumerPartitionPausedEvent`/`ResumedEvent` — 관측 지점) | `official-vendor-doc` | **rebalance·재배정 시 파티션 pause 상태가 유지되는지 초기화되는지는 어느 문서도 말하지 않는다**(`#SPRK-PAUSEPART-C5` 부재 확인 — 파티션 pause 페이지·컨테이너 pause 페이지 모두 rebalance 어휘 자체가 없다) → §구현 가이드 3 의 `onPartitionsAssigned` 행을 `UNSUPPORTED_IMPL_DECISION` 으로 라벨링했다. 또한 pause 의 안전성은 **poll 루프 스레드가 블로킹되지 않는다**는 전제 위에서만 성립한다 |
|
||||
| D6 | rebalance 안정성: `partition.assignment.strategy` 를 `CooperativeStickyAssignor` 로 pin 하고, `max.poll.interval.ms`/`max.poll.records` 를 실측 처리시간 기준으로 명시 pin 한다. `onPartitionsRevoked` 를 rebalance 시작 신호나 **유일한** 커밋 체크포인트로 신뢰하지 않는다 | 파티션·컨슈머 수가 많고 배포로 rebalance 가 잦은 배포에서 cooperative. **대안**: 파티션이 한 자릿수이고 그룹이 안정적이면 기본값(`[RangeAssignor, CooperativeStickyAssignor]`) 을 그대로 두어도 되며, 이 기본 목록 덕분에 나중에 `RangeAssignor` 만 제거하는 **단일 rolling bounce** 로 전환할 수 있다 | `raw/official-docs/kafka-incremental-cooperative-rebalance-kip429.md#KIP429-C2` (EAGER = rebalance 전 소유 파티션 전량 revoke), `#KIP429-C3` (COOPERATIVE = 소유 파티션 유지), `#KIP429-C5` ("it is possible for #onPartitionsRevoked to never be invoked at all during a rebalance, and should not be relied on to signal that a rebalance has started"), `#KIP429-C1` (Accepted, Kafka 2.4.0), `#KIP429-C4` (`onPartitionsLost` 의미), `raw/official-docs/kafka-consumer-configs-max-poll-and-commit-defaults.md#KAFKA-CONSCFG-C1` (`max.poll.interval.ms` 기본 300000ms, 초과 시 실패 간주 + rebalance), `#KAFKA-CONSCFG-C2` (`max.poll.records` 기본 500), `#KAFKA-CONSCFG-C7` (기본 전략 목록 + 단일 rolling bounce 업그레이드), `raw/company-tech-blogs/kafka-consumer-rebalance-cooperative-sticky-verygoodsecurity.md#VGS-REBAL-C2`·`#VGS-REBAL-C3`·`#VGS-REBAL-C4` (운영 사례) | `official-vendor-doc` (KIP-429 + consumer configs) + `company-case-study` (VGS — 100 consumer/aiokafka 규모 전제가 다름, best practice 로 격상 금지) | 구체 pin 값(`max.poll.interval.ms` 를 얼마로) 은 처리시간 실측 없이 정할 수 없다 — §구현 가이드 3 의 `UNSUPPORTED_IMPL_DECISION`. D5 의 pause 기반 backpressure 를 쓰면 poll 이 계속되므로 이 값의 압박은 줄지만, poll 스레드가 블로킹되는 순간 동일 실패로 되돌아간다 |
|
||||
| D7 | poison/역직렬화 실패는 **리스너 호출 이전 단계(deserializer 경계)** 에서 감지하고 `non-retryable` 로 분류해 첫 실패에 곧바로 회수 경로(D9)로 보낸다 | 구조적 파싱 실패(스키마 불일치·깨진 바이트)일 때. **대안/보완**: 파싱은 성공했지만 도메인 규칙(허용 handler·schema·version allowlist)을 위반하는 "의미상 poison" 은 이 경로가 아니라 application 경계의 예외 분류로 다룬다 — 두 실패는 발생 위치가 달라 상호 배타가 아니라 역할 분담이다 | `raw/official-docs/spring-kafka-error-handling-deserializer-poison-record.md#SPRK-EHD-C1` (역직렬화 실패는 `poll()` 반환 이전에 발생해 리스너 레벨에서 처리 불가), `#SPRK-EHD-C2` (실패 시 null + `DeserializationException` 헤더 with cause + raw bytes), `#SPRK-EHD-C3` (헤더가 있으면 컨테이너의 ErrorHandler 호출, "The record is not passed to the listener"), `raw/official-docs/spring-kafka-default-error-handler-dlt-fatal-exceptions.md#SPRK-ERRH-C1` (`DeserializationException` 등 6종을 기본 fatal 로 분류 — "since these exceptions are unlikely to be resolved on a retried delivery"), `raw/company-tech-blogs/kafka-poison-pill-consumer-stuck-offset-confluent.md#CONF-POISON-C3`·`#CONF-POISON-C4` (미처리 시 offset 정체 + 무한 고속 재시도) | `official-vendor-doc` (Spring reference 2종) + `company-case-study` (Confluent 벤더 블로그 — 메커니즘 설명, 타사 운영 사례 아님) | 네트워크 truncation 처럼 **실제로는 일시적인데 역직렬화 실패로 나타나는** 엣지가 fatal 로 오분류된다(sibling [[raw/branch-notes/feature-outbound-http-client-baseline]] D12 의 "4xx 일괄 PERMANENT 분류" 와 동형 미해결). 이 엣지의 처리는 구현 시 결정 |
|
||||
| D8 | 재시도 기본값은 **blocking bounded retry** — 같은 파티션에서 backoff 재시도하고 순서를 보존한다. non-blocking retry topic 체인(`topic-retry-N`)은 기본 기각. backoff/max attempts 어휘는 sibling 에 위임 | 순서 보장(D4)이 요구되는 토픽이면 blocking. **대안**: 특정 토픽이 순서 무관 이벤트만 싣고 처리량이 최우선이면 그 토픽에 한해 retry topic 채택 — 단 "이 토픽은 순서를 포기한다" 를 명시 선언해야 한다. 또한 backoff 총합이 `max.poll.interval.ms` 를 넘길 위험이 있으면 스레드 정지형이 아니라 **컨테이너 pause 형 backoff** 를 쓴다 | `raw/official-docs/spring-kafka-non-blocking-retry-topic-ordering-loss.md#SPRK-RETRYTOPIC-C3` ("By using this strategy you lose Kafka's ordering guarantees for that topic." — 공식 자인), `#SPRK-RETRYTOPIC-C1` (retry topic 은 back-off timestamp 로 파티션 소비를 일시 중지), `#SPRK-RETRYTOPIC-C2` (소진 시 DLT), `raw/official-docs/spring-kafka-default-error-handler-dlt-fatal-exceptions.md#SPRK-ERRH-C2` (기본 backoff 는 consumer 스레드를 정지시키며, 지연이 `max.poll.interval.ms` 보다 길 때를 위해 `ContainerPausingBackOffHandler` 제공), `raw/company-tech-blogs/kafka-multi-tier-retry-topic-dlq-uber.md#UBER-REPROC-C1`·`#UBER-REPROC-C2`·`#UBER-REPROC-C4` (다단계 retry topic 사례 — 순서 비보장 수용이 전제), [[raw/branch-notes/feature-background-job-async-contract]] D4 (exponential backoff with jitter / max attempts 3 / DLQ after exhausted — 어휘 위임) | `official-vendor-doc` (Spring reference 2종) + `company-case-study` (Uber — 순서 비보장 전제가 본 계약과 다름) + `internal-cross-reference` (backoff 어휘) | background-job D4 의 "DLQ after exhausted" 는 그 branch 에서 아직 외부 근거가 없는 항목이다(위임 대상의 잔여 `UNSUPPORTED`). 또한 blocking retry 는 실패가 잦아지면 해당 파티션 전체를 정체시킨다 — 실패율 임계와 pause 전환 기준은 미확정(§구현 가이드 4) |
|
||||
| D9 | 최종 실패(재시도 소진 또는 D7 non-retryable)는 dead-letter 로 회수하고 감사 흔적을 남긴다. **발행은 inbound leaf 가 직접 producer 를 들지 않고 `application-core` 의 outbound port 를 경유**한다 | 항상. **대안**: dead-letter 를 Kafka 토픽이 아니라 **DB row 로만** 표현하면 producer 자체가 불필요해 모듈 경계 문제가 사라진다(outbox `SKIP LOCKED` 선례 재사용). 외부 시스템이 DLT 토픽을 직접 구독해야 하는 요구가 있으면 토픽 방식, 내부 운영자만 조회하면 DB row 방식 | `raw/official-docs/spring-kafka-default-error-handler-dlt-fatal-exceptions.md#SPRK-ERRH-C4` ("The recoverer requires a KafkaTemplate<Object, Object>, which is used to send the record." — DLT 발행에 producer 필수), `#SPRK-ERRH-C3` (기본 명명 `<originalTopic>-dlt` + 동일 partition + partition 수 요건), `#SPRK-ERRH-C5` (recoverer 미구성 시 기본은 로그만 — DLT 로 안 감), ca-tmpl `.harness/project/modules.yaml` (inbound leaf 의 `allowed_dependencies` 에 outbound leaf 가 **없음** — 2026-07-28 실측), `raw/company-tech-blogs/kafka-multi-tier-retry-topic-dlq-uber.md#UBER-REPROC-C5` (DLQ = 지속 실패의 종착점) | `official-vendor-doc` + `internal-contract-registry` (모듈 경계) + `company-case-study` | **감사된 replay(누가·언제·무엇을 재처리했는지)를 규정하는 외부 근거는 어디에도 없다** — Spring/Confluent/Uber 어느 문서도 replay audit trail 을 다루지 않는다. audit 기록 스키마는 `UNSUPPORTED_IMPL_DECISION`(§구현 가이드 4) |
|
||||
| D10 | `InboxStorePort`(application-core, **신규 — 현재 코드 부재**) 에 처리한 메시지 식별자를 기록하고, 그 insert 를 비즈니스 write 와 **동일 `TransactionPort.inWrite` 경계**에서 커밋한다. ack 는 그 커밋 성공 이후(D3) | 이벤트가 여러 aggregate/부작용에 걸치거나 감사·replay 가시성이 필요할 때(본 branch 범위가 "감사된 replay" 를 포함하므로 기본). **대안**: 이벤트가 정확히 하나의 aggregate row 를 1회성으로 만들고 그 row 에 자연 유니크 키가 있으면 별도 inbox 없이 비즈니스 엔티티 자체에 ID 를 저장하는 변형으로 대체 가능 — 대신 처리 이력 조회를 포기 | `raw/official-docs/idempotent-consumer-microservices-io.md#MSIO-IDEMPC-C2` (처리한 메시지 ID 를 DB 에 기록해 멱등), `#MSIO-IDEMPC-C3` ("After starting the database transaction, the message handler inserts the message's ID into the PROCESSED_MESSAGE table."), `#MSIO-IDEMPC-C4` (복합 PK 위반으로 duplicate INSERT 실패), `#MSIO-IDEMPC-C5` (비즈니스 엔티티 저장 변형), `raw/official-docs/microservices-io-transactional-outbox.md#MSIO-OUTBOX-C5` ("The solution is for the service that sends the message to first store the message in the database as part of the transaction that updates the business entities." — producer 측 동일-트랜잭션 원리의 명시 앵커. consumer 측 대칭 적용의 근거로 인용하되 원문은 producer 문맥임을 유지), [[raw/branch-notes/feature-domain-event-outbox-contract]] D7 (consumer 는 at-least-once + idempotencyKey dedupe 의무 — 메커니즘은 consumer branch 위임), ca-tmpl `application/transaction/TransactionPort.java` (`actually-implemented`) | `engineering-blog` (microservices.io = Chris Richardson 개인 패턴 카탈로그 — 벤더 공식 아님) + `internal-cross-reference` + `actually-implemented` (TransactionPort) | **"메시지 ID 기록과 비즈니스 데이터 갱신이 같은 트랜잭션이어야 한다" 는 명시 문장은 consumer 측 인용 원문에 없다**(원 raw 의 Usage Boundaries 에 기록됨) — 원문은 handler 가 트랜잭션을 시작해 ID 를 INSERT 한다는 것까지만 말한다. 동일 트랜잭션 요구는 `MSIO-OUTBOX-C5`(producer 측 동일-트랜잭션 원리)의 대칭 적용이라는 **해석**이다. 그 해석이 필요한 이유는 연역으로 닫힌다: **inbox insert 만 커밋되고 비즈니스 write 가 롤백되면 그 메시지는 이후 영구히 "처리됨" 으로 스킵된다**(조용한 누락). 반대로 비즈니스 write 만 커밋되면 중복 실행이 남는다 — 두 실패 모두 트랜잭션을 합쳐야만 사라진다 |
|
||||
| D11 | dedupe key 는 producer envelope 의 `idempotencyKey`(= `eventId`, ULID) 를 1차로 쓰고, inbox 유니크 제약은 `(idempotencyKey, eventType)` 복합으로 건다. `(topic, partition, offset)` 은 **유일 dedupe key 로 채택하지 않는다** | 항상. **대안 기각 이유**: outbox relay 가 publish 성공 후 status 갱신 전 crash 하면 **같은 논리 이벤트가 다른 offset 으로 재발행**되므로 `(topic,partition,offset)` 는 그것을 서로 다른 이벤트로 오판한다. 반대로 토픽 재생성/DR 미러링 시엔 offset 이 0부터 재할당되어 정상 이벤트를 중복으로 오판할 수 있다. 복합 키를 쓰는 이유는 CloudEvents 가 dedup 단위를 `source + id` **조합**으로 규정하는 것과 같은 취지 | [[raw/branch-notes/feature-domain-event-outbox-contract]] D12 (envelope required fields = `eventId`/`occurredAt`/`aggregateId`/`eventType`/`correlationId`/`idempotencyKey`, 구현상 `idempotencyKey = eventId` ULID), 동 D14 (outbox idempotencyKey 는 API `Idempotency-Key` 와 별개 scope), 동 §엣지 ("publish 성공 후 status 갱신 전 crash → 동일 event 재발행"), `raw/official-docs/cloudevents-spec-required-attributes.md#CLOUDEVT-C2` (`source`+`id` 조합이 고유성 단위, consumer 는 동일 조합을 duplicate 로 간주 가능), `raw/official-docs/idempotent-consumer-microservices-io.md#MSIO-IDEMPC-C4` (복합 PK 로 중복 탐지) | `internal-cross-reference` (outbox 계약) + `official-vendor-doc` (CloudEvents) + `engineering-blog` (MSIO) | 이 key 는 **producer 가 동일 논리 이벤트에 항상 같은 `eventId` 를 재사용한다**는 전제에 의존한다. outbox branch 에 그 보장을 명시한 D-row 는 없다(2026-07-28 확인) — 어긋나면 정상 이벤트가 조용히 누락된다. `(idempotencyKey, eventType)` 복합 제약이 그 오류를 탐지하는 최소 방어선 |
|
||||
| D12 | owner token 프로토콜(claim/renew/complete/release, #070) 재사용은 **조건부**다 — 파티션당 직렬 처리(D4 기본)에서는 insert-once inbox 로 충분하고, worker fan-out 을 도입해 rebalance 중 zombie consumer 가 같은 메시지를 동시 처리할 수 있게 되면 그때 claim/lease 를 재사용한다 | 동기 직렬 처리 → 단순 inbox. worker fan-out + rebalance 노출 → owner token(`SAME_STORE_TRANSACTIONAL` 등급). 판단은 D4 의 concurrency 모델 확정 이후 | [[raw/branch-notes/feature-idempotency-ownership-protocol-contract]] §범위 (claim 결과 `ACQUIRED`/`REPLAY`/`IN_PROGRESS`/`FINGERPRINT_MISMATCH`, 실행 lease 와 replay TTL 분리, 보증 등급 3종 — **D-row 는 아직 미작성**, 2026-07-28 확인), `raw/official-docs/kafka-incremental-cooperative-rebalance-kip429.md#KIP429-C4` (`onPartitionsLost` = 이미 소유권을 잃은 상태의 콜백 → zombie 구간 존재의 근거) | `internal-cross-reference` (위임 대상이 스캐폴딩 상태 — `needs-confirmation`) + `official-vendor-doc` (KIP429-C4) | 위임 대상 #070 이 스캐폴딩(D-row 0개)이라 재사용 비용을 아직 평가할 수 없다. #070 의 `/branch-spec` 완료 후 본 D-row 재검토 필요 — 그 전에는 `needs-approval` |
|
||||
| D13 | inbox row 보존 기간(TTL)의 **구체 수치는 정하지 않는다**. 원칙만 고정: (a) 토픽 `retention.ms` 와 (b) 지원하려는 최대 dead-letter replay 창 중 **긴 쪽 이상**, 그리고 (c) 무한 보관 금지(reaper 필요). 이에 따라 **dedupe 보증은 TTL 창 안의 재전달에 한정**되며, 창 밖 재전달은 신규 처리로 간주된다 — 이 한계를 계약으로 명시한다 | 항상 원칙 적용. 수치는 운영 환경의 retention/replay 창이 확정된 뒤 결정 | **UNSUPPORTED_DECISION** — 외부 근거 부재. 이번 조사에서 CloudEvents spec / AWS Prescriptive Guidance / microservices.io 어디에도 inbox TTL 수치를 규정한 문장이 없음을 확인했다. 실무 관행값(Stripe 계열 ~72시간, Toss 15일)은 **API-level idempotency-key 도메인**의 사례라 consumer inbox 로 직접 이전할 수 없다. 사용자 trade-off: TTL 이 replay 창보다 짧으면 replay 된 메시지가 신규 처리로 오판되므로, 수치를 지어내는 것보다 관계식만 고정하는 편이 안전하다 | `internal-policy` (외부 근거 없음 명시) | 절대 상한이 없으면 "무한 보관 금지" 가 실질적으로 집행되지 않는다 — reaper 주기와 상한은 구현 시 outbox reaper(`ca-skeleton.outbox.published-retention` 선례)를 모델로 결정 |
|
||||
| D15 | **handler/schema/version allowlist**: 이 leaf 는 등록된 **`(topic, eventType)`** 조합만 소비하고(`schemaVersion` 축은 envelope 에 그 필드가 생긴 뒤 추가 — Open Risk 참조), 미등록 조합은 처리하지 않고 D7 의 non-retryable 경로로 회수한다. allowlist 는 **코드에 선언된 handler 등록부**(핸들러가 자기 `(topic, eventType, 지원 schemaVersion 범위)` 를 선언하고 기동 시 조합의 중복·공백을 검증)이며, 별도 런타임 설정 파일이나 registry yaml 로 두지 않는다 | 항상 — 미등록 조합을 조용히 무시하거나(유실) 아무 handler 에나 라우팅하는 것(오처리)이 둘 다 금지되므로 명시 allowlist 가 필요하다. **대안(기각)**: (a) env/yaml 기반 런타임 allowlist — 코드의 handler 와 설정이 갈라져 "등록했는데 handler 가 없는" 상태가 런타임에만 드러난다. (b) allowlist 없이 미등록 조합을 skip — 유실이 조용해져 at-least-once 계약의 관측성을 깬다. (c) Schema Registry 의 호환성 검사에 위임 — 그 검사는 *payload 스키마 진화*를 보고 *어느 handler 가 이 이벤트를 맡는가*를 보지 않는다 | **UNSUPPORTED_DECISION (부분)** — allowlist 의 *존재 필요성* 은 근거가 있다: `raw/official-docs/spring-kafka-default-error-handler-dlt-fatal-exceptions.md#SPRK-ERRH-C1` 이 `MethodArgumentResolutionException`·`NoSuchMethodException`·`ClassCastException`(= 라우팅/시그니처 불일치 계열)을 **fatal** 로 분류해 재시도 대상이 아님을 확정하고, `raw/company-tech-blogs/kafka-poison-pill-consumer-stuck-offset-confluent.md#CONF-POISON-C1` 이 "항상 실패하는 레코드" 개념을 정의한다. envelope 의 `eventType` 은 [[raw/branch-notes/feature-domain-event-outbox-contract]] D12 가 required 로 확정. **그러나 "코드 등록부 vs 설정 파일" 이라는 형태 선택과 `schemaVersion` 필드의 존재 자체는 외부 근거가 없다** — envelope required 6필드에 `schemaVersion` 은 **없다**(outbox D12 실측). 사용자 trade-off: 설정과 코드가 갈라지는 실패를 없애려면 등록부를 코드에 두는 편이 안전하고, 버전 축은 필요해질 때 envelope 확장으로 추가한다(지금 발명하지 않음) | `official-vendor-doc` (fatal 분류) + `internal-cross-reference` (envelope) + `internal-policy` (형태 선택 — 근거 없음) | **`schemaVersion` 이 현재 envelope 에 없다** — 이 축을 실제로 쓰려면 outbox D12 의 required 필드 확장이 필요하고 그것은 [[raw/branch-notes/feature-domain-event-outbox-contract]] 소유다. 확장 전까지 allowlist 의 실효 키는 `(topic, eventType)` 2축뿐이다. 또한 이 관심사는 노트 초안에서 [[raw/branch-notes/feature-schema-serialization-contract]] 로 위임했다고 적었으나 **그 branch 는 이 관심사를 소유하지 않음**(2026-07-28 grep 재확인 — handler/topic/routing 언급 0건, Avro/JSON 직렬화 전용). §Audit `FALSE_DELEGATION` 참조 |
|
||||
| D14 | consumer/inbox 용 error code·metric·env key 는 ca-tmpl registry 에 **하나도 등록돼 있지 않다**. 본 노트는 전부 "신규 제안" 으로만 표기하고 기존 값처럼 단정하지 않는다. category 는 반드시 `Category.java` 의 10종 안에서 고른다 | 항상. registry 반영은 구현 branch 착수 시 `owner_branch: feature-kafka-consumer-inbox-contract` 로 등록 | ca-tmpl `docs/registries/error-codes.yaml` (2026-07-28 grep: `OUTBOX_*`/`JOB_*` 는 있으나 consumer/inbox row **부재**), `metrics.yaml` (`outbox.*`/`job.*` 만 존재), `env-keys.yaml` (`APP_MESSAGING_BROKER`/`APP_MESSAGING_KAFKA_BROKERS` 만 존재, consumer 키 부재 — owner 는 `feature-integration-adapter-templates`), `src/shared-contract/.../error/Category.java` (10-value enum, 코드 SSOT) | `internal-contract-registry` + `actually-implemented` (Category enum) | 신규 코드/메트릭 명명 자체는 외부 근거가 없다 — §구현 가이드 5 의 `UNSUPPORTED_IMPL_DECISION`. registry 의 `required_test` / `runbook_link` 필드는 retryable=true 행에 runbook 을 강제하므로 제안 시 runbook 작성 의무가 따라온다 |
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
> *결정(D-row)* 이 "*무엇*" 이라면 본 §는 "*어디에 어떻게*" 의 사전 명세. 모든 cell 은 Decision ID + Supporting Claim 의 도출(R1). 근거 없는 detail 은 `UNSUPPORTED_IMPL_DECISION`(R2). 본 branch 결정 범위 밖 detail 은 두지 않음(R3).
|
||||
>
|
||||
> **증거 등급 주의**: 아래 "위치" 열의 클래스·포트는 2026-07-28 ca-tmpl `src/` grep 결과 **전부 부재**다. 기존 코드(`TransactionPort`, `MessageBroker`, `KafkaSender`, `Category`)만 `actually-implemented` 이고 나머지는 `planned` 이다.
|
||||
|
||||
### 1. 모듈 등록·배치 (D1, D2)
|
||||
|
||||
> **Trace**: D1 (ca-tmpl `.harness/project/modules.yaml` 실측 — modules 19개, inbound 4종 전수 동일 3-의존; sibling #063 D2 가 "모듈 registry migration(19→20)은 #064 소유" 로 명시 승인) + D2 (#063 D2 와 같은 축 + 현행 `KafkaSender`/`MessageBroker` javadoc).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (1) registry row 의 `mutation_import` 값 — 기존 inbound 4종은 전부 `dev.caskeleton.adapter.outbound.persistence.OutboxStoreAdapter` 를 쓰지만 그 선정 근거는 문서화돼 있지 않다. trade-off: 선례를 그대로 복제하는 편이 새 값을 발명하는 것보다 안전. (2) leaf slug 를 `messaging-kafka` 로 둘지 `kafka` 로 둘지 — outbound 는 `messaging` 아래 broker 를 두는 2단 구조(`messaging/kafka/`)인데 inbound 는 1단(`web`/`grpc`)이다. trade-off: project §8.0 이 `adapter:inbound:messaging-kafka` 를 이미 명시했으므로 그 이름을 따른다(발명 아님).
|
||||
|
||||
| 항목 | 위치 (module / path) | 증거 등급 | Trace |
|
||||
|---|---|---|---|
|
||||
| module registry row 추가 (`id: adapter-inbound-messaging-kafka`, `role: inbound`, `gradle_path: :adapter:inbound:messaging-kafka`, `source_path: src/adapter/inbound/messaging-kafka`, `allowed_dependencies: [application-core, domain-core, shared-contract]`, `claude: src/adapter/inbound/messaging-kafka/CLAUDE.md`) | ca-tmpl `.harness/project/modules.yaml` | `planned` (현재 19 modules) | D1 |
|
||||
| Gradle include — **별도 작업 없음**. `src/settings.gradle` 이 registry 를 읽어 `include` 하므로 registry row 추가만으로 모듈이 생긴다 | ca-tmpl `src/settings.gradle` | `actually-implemented` | D1 |
|
||||
| leaf 로컬 규칙 문서 (`CLAUDE.md`) — 기존 inbound leaf 와 동일 골격(Registered identity / Responsibility / Boundaries / Tests) | `src/adapter/inbound/messaging-kafka/CLAUDE.md` | `planned` | D1 |
|
||||
| consumer seam + 스켈레톤 기본 구현 — outbound `KafkaSender` 대칭 seam 을 두되 `spring-kafka` 기반 기본 listener 구현을 스켈레톤이 제공 | `adapter:inbound:messaging-kafka` `dev/caskeleton/adapter/inbound/messaging/kafka/` | `planned` | D2 |
|
||||
| 활성화 게이트 — `app.messaging.broker=kafka` 가 아니면 consumer 구성이 **켜지지 않아야** 한다(연결·listener container 미생성). outbound `KafkaAdapterConfig` 의 `@ConditionalOnProperty(app.messaging.broker, havingValue="kafka")` 패턴을 따른다 | `adapter:inbound:messaging-kafka` config | `planned` (패턴 자체는 outbound 에 `actually-implemented`) | D2 |
|
||||
| 대칭 선례 (변경 없음, 참조만) — `MessageBroker` SPI + `KafkaSender` + `MessagingConfig` 중앙 조립 + `app.messaging.broker` 선택 | `adapter:outbound:messaging` `dev/caskeleton/adapter/outbound/messaging/` | `actually-implemented` | D2 |
|
||||
|
||||
### 2. 소비 파이프라인과 ack 순서 (D3, D5, D10)
|
||||
|
||||
> **Trace**: D3 (`KAFKA-OFFSET-C2`/`C3`/`C4`, `KAFKA-CONSCFG-C3`/`C4`) + D5 (`SPRK-PAUSE-C1`~`C4`) + D10 (`MSIO-IDEMPC-C3`/`C4`) + ca-tmpl `TransactionPort` (`actually-implemented`).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (1) `commitSync` vs `commitAsync` — `KAFKA-OFFSET-C5` 는 둘의 시맨틱만 말하고 어느 쪽을 쓰라고 권고하지 않는다. trade-off: 커밋 실패가 조용히 삼켜지면 재처리 폭이 커지므로 **동기 커밋을 기본**으로 두고, 처리량 문제가 실측되면 비동기 + 실패 임계 카운터로 전환. (2) 큐 용량·pause 임계(high/low watermark) 수치 — 근거 없음. trade-off: 값 자체보다 "큐 포화가 pause 로 이어진다"는 관계를 계약으로 고정하고 수치는 설정으로 노출. (3) **D2 가 고른 프레임워크 층(spring-kafka)에서 ack 를 무엇으로 표현하고 어느 스레드에서 호출하는가** — 아래 "ack 메커니즘" 표 참조. D4 가 poll 스레드와 워커를 분리하므로 "워커 스레드에서 ack 를 호출해도 되는가" 가 곧바로 문제가 된다.
|
||||
|
||||
**ack 메커니즘 (D2 의 프레임워크 층)** — 이 work item 의 완료 조건이 "manual ack 계약 test 통과" 이므로 공백으로 둘 수 없다:
|
||||
|
||||
| 항목 | 명세 | 근거 |
|
||||
|---|---|---|
|
||||
| ack 모드 | `AckMode` 를 **명시적으로** `MANUAL_IMMEDIATE` 로 설정한다. **설정을 빠뜨리면 기본값이 `BATCH`** 라 poll 배치 단위 자동 커밋으로 조용히 되돌아간다 — D3 위반이 침묵으로 발생하는 지점 | `SPRK-ACKMODE-C4` ("The default AckMode is BATCH."), `SPRK-ACKMODE-C2` (`MANUAL_IMMEDIATE` = acknowledge() 호출 즉시 커밋) |
|
||||
| `MANUAL` 을 쓰지 않는 이유 | `MANUAL` 은 acknowledge() 이후 `BATCH` 와 같은 시맨틱(= poll 반환분 전체 처리 후 커밋)이라 "DB 커밋 직후 그 레코드만 ack" 라는 D3 의 시점 계약을 표현하지 못한다 | `SPRK-ACKMODE-C1` |
|
||||
| 리스너 타입 제약 | `MANUAL`/`MANUAL_IMMEDIATE` 는 리스너가 `AcknowledgingMessageListener`(또는 배치형)여야 한다 — 리스너 시그니처가 이 결정에 종속된다 | `SPRK-ACKMODE-C3` |
|
||||
| ack 호출 스레드 | **poll/리스너 스레드에서 ack 한다.** 워커에서 직접 ack 하지 않고 완료 offset 을 리스너 스레드로 되돌린다 | `SPRK-ACKMODE-C5` (`nack()` 은 리스너를 호출한 consumer 스레드에서만 호출 가능), `SPRK-ACKMODE-C6` (부분 배치 acknowledge 도 "리스너 스레드에서 호출되어야 한다") — `Acknowledgment` 의 최소 두 메서드가 스레드에 묶여 있으므로 워커 스레드 ack 를 문서 근거 없이 가정하지 않는다 |
|
||||
| 커밋 순서 | 파티션별 **완료 연속 구간의 최솟값**까지만 ack — 워커 완료 순서가 poll 순서와 어긋나도 offset 이 앞질러 가지 않는다 | D3(`KAFKA-OFFSET-C2` 의 "committed offset 이 consumed position 을 앞지르면 유실") + 위 스레드 제약 |
|
||||
| ack 발화 지점 | 워커는 완료분을 파티션별 pending 구조에 적재하되 **offset 뿐 아니라 ack 수단(레코드의 `Acknowledgment` 핸들)까지 함께 보관**한다 — 커밋은 `Acknowledgment.acknowledge()` 호출로만 일어나므로(`SPRK-ACKMODE-C2`) offset 만으로는 나중에 ack 할 수단이 없다. flush 는 **다음 리스너 진입 시점**에 그 파티션의 완료 연속 구간까지 수행한다 | `SPRK-ACKMODE-C2` + 위 "ack 호출 스레드" 행의 스레드 제약 |
|
||||
| 유휴 파티션 flush | **`UNSUPPORTED_IMPL_DECISION`** — `concurrency` 를 파티션 수로 pin 하면(§3) 파티션이 pause 되거나 유휴인 동안 리스너 진입이 아예 없어 "다음 리스너 진입 시 flush" 가 발화하지 않는다. 컨테이너 idle 이벤트 계열 훅이 후보이나 **그 훅이 어느 스레드에서 발화하는지가 수집한 raw 에 없다**(잔여 `SOURCE_GAP-5`). trade-off: **리스너/consumer 스레드에서 실행되는 idle 훅만 사용**하고, 그런 훅이 없다고 확인되면 `MANUAL_IMMEDIATE` 대신 완료 즉시 ack 하는 인라인 모델(D4 대안)로 후퇴한다 — 별도 스케줄러 스레드에서 ack 를 호출하는 방식은 근거 없이 채택하지 않는다 | `SPRK-ACKMODE-C5`/`C6` (인접 API 의 스레드 구속), `SPRK-ACKMODE-C7` (concurrency 하향 조정) |
|
||||
|
||||
> **잔여 `UNSUPPORTED_IMPL_DECISION`**: `acknowledge()`(nack/부분배치가 아닌 일반 ack)를 **다른 스레드에서 호출했을 때의 동작**은 수집한 문서가 직접 규정하지 않는다(`SPRK-ACKMODE-C5`/`C6` 는 각각 `nack()`·부분 배치 ack 에 한정된 제약이다). trade-off: 두 인접 API 가 모두 스레드에 묶여 있으므로 **보수적으로 리스너 스레드 ack 를 계약으로 고정**한다 — 반대로 갔다가 틀리면 유실이지만, 이 방향으로 틀리면 성능만 손해다.
|
||||
|
||||
정상 경로 순서 (이 순서를 어기면 D3 위반):
|
||||
|
||||
워커 풀은 **이 leaf 전용**이며 파티션당 1 워커로 둔다 — sibling background-job 의 executor 를 공유하지 않는다(그 branch 의 saturation 정책이 레코드 거부를 뜻해 D5 와 충돌하므로). 컨테이너 스레드가 파티션 수만큼 생기므로(§3 `concurrency`) 총 스레드는 대략 `파티션 수 × 2` 이고, 워커 drain await 예산은 그 branch 의 shutdown 예산 안에 들어가야 한다(§엣지의 stop 계약).
|
||||
|
||||
```text
|
||||
poll() → (파티션별 bounded queue 에 적재; 포화면 그 파티션만 pause — D5)
|
||||
→ worker: application use case 실행
|
||||
→ TransactionPort.inWrite { 비즈니스 write + InboxStorePort.insert } ← 여기서 커밋 (D10)
|
||||
→ 커밋 성공 확인 후에만 offset ack ← (D3)
|
||||
→ 큐 여유 생기면 resume — D5
|
||||
```
|
||||
|
||||
| 규칙 | 근거 | 어겼을 때 |
|
||||
|---|---|---|
|
||||
| `enable.auto.commit=false` 를 **명시** 설정 (기본값이 `true` 이므로 안 끄면 자동 커밋됨) | `KAFKA-CONSCFG-C3` (기본 true), `KAFKA-CONSCFG-C4` (5000ms 주기) | 처리 완료와 무관하게 5초마다 커밋 → 유실 |
|
||||
| poll 이 반환한 레코드를 큐에 넘기는 즉시 처리 완료로 간주하지 않음 | `KAFKA-OFFSET-C3` | 자동 커밋의 at-least-once 전제(`KAFKA-OFFSET-C2`)가 깨짐 |
|
||||
| ack 는 DB 커밋 **이후**. 반대 순서(ack 먼저)는 금지 | `KAFKA-OFFSET-C4` | ack 후 커밋 실패 시 재전달 없이 영구 손실 = at-most-once 로 후퇴 |
|
||||
| pause 는 poll 을 멈추는 것이 아니다 — 컨테이너는 계속 poll 하며 레코드만 안 가져온다 | `SPRK-PAUSE-C2` | poll 자체를 멈추면 `max.poll.interval.ms` 초과 → 그룹 이탈(§3) |
|
||||
| **pause 단위는 파티션** — `pausePartition(TopicPartition)`/`resumePartition(TopicPartition)` 을 쓴다. 컨테이너 전역 `pause()` 는 한 파티션의 포화로 나머지 파티션까지 굶기므로 기본 경로가 아니다 | `SPRK-PAUSEPART-C1` (2.7~ 파티션 단위 API), `SPRK-PAUSEPART-C2` (poll 경계 반영) | 전역 pause 를 쓰면 D4 의 파티션별 독립 큐가 무의미해진다 |
|
||||
| pause 요청과 실제 정지를 구분 — 파티션 단위는 `isPartitionPauseRequested()` ≠ `isPartitionPaused()` (컨테이너 단위의 `isPauseRequested()`/`isConsumerPaused()` 와 같은 구조) | `SPRK-PAUSEPART-C3`, `SPRK-PAUSE-C4` | 정지 전에 큐를 비었다고 판단해 resume → 포화 반복 |
|
||||
| pause/resume 전이는 관측 가능해야 한다 — `ConsumerPartitionPausedEvent`/`ConsumerPartitionResumedEvent` 를 지표(D14 의 `consumer.paused.seconds`)로 연결 | `SPRK-PAUSEPART-C4` | 조용한 정체를 탐지할 방법이 없어진다 |
|
||||
| poll 루프 스레드에서 블로킹 대기 금지 (큐 offer 는 non-blocking) | `SPRK-PAUSE-C2` 의 전제 + `KAFKA-CONSCFG-C1` | pause 여부와 무관하게 `max.poll.interval.ms` 타이머가 흐름 |
|
||||
| **poll 배치 흡수 불변식** — pause 를 요청하는 high watermark 는 `큐 용량 − max.poll.records` 이하로 둔다. 즉 **직전 poll 이 반환한 배치를 통째로 넣을 여유가 남아 있을 때 pause 를 요청**한다 | `SPRK-PAUSEPART-C2` (파티션 pause 도 poll 경계에서 반영 — pause 요청 후 추가 유입이 **배치 1개로 상한**된다는 핵심 근거), `KAFKA-CONSCFG-C2` (`max.poll.records` 기본 500 = 흡수해야 할 최대치), `SPRK-PAUSE-C3` (컨테이너 레벨에서 확인된 보수적 상한 — 기본 pause 는 "이전 poll 의 모든 레코드 처리가 끝난 뒤" 발효. 이 옵션이 파티션 단위 API 에도 동일 적용되는지는 원문에 명시가 없어 **더 보수적인 쪽**으로 채택) | 불변식이 깨지면 "drop 금지(D5) · 블로킹 금지 · pause 미발효" 가 동시에 성립해 **합법적 행동이 남지 않는다** |
|
||||
|
||||
### 3. rebalance·poll 설정 계약 (D6)
|
||||
|
||||
> **Trace**: D6 (`KIP429-C1`~`C5`, `KAFKA-CONSCFG-C1`/`C2`/`C5`/`C6`/`C7`, `VGS-REBAL-C2`~`C5`).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (1) `max.poll.interval.ms`·`max.poll.records` 의 **실제 pin 값**. 공식 문서는 기본값만 말하고 VGS 사례의 값(600000ms / 5)은 100-consumer aiokafka 환경 전제라 그대로 옮길 수 없다. trade-off: 값을 지어내는 대신 **모델별 관계식**을 계약으로 두고 기본값은 공식 기본값을 상속한다 — **큐 기본 모델(D4 기본)**: `poll→enqueue 소요 + pending ack flush(동기 커밋) 소요 < max.poll.interval.ms`. poll 스레드가 핸들러를 실행하지 않으므로 핸들러 p99 는 이 식에 들어가지 않는다. 흡수 불변식(§2)은 이 식의 **대기 항을 0 으로 만드는 전제**이지 항이 아니다 — 여유가 확보돼 있으므로 enqueue 는 블로킹하지 않는다. **인라인 대안 모델(D4 대안)**: `핸들러 p99 × max.poll.records < max.poll.interval.ms`. (2) 큐 용량·high/low watermark 의 절대값 — 불변식(`high watermark ≤ 용량 − max.poll.records`)만 계약이고 수치는 설정으로 노출.
|
||||
|
||||
| 설정 | 공식 기본값 (근거) | 본 branch 의 계약 |
|
||||
|---|---|---|
|
||||
| `enable.auto.commit` | `true` (`KAFKA-CONSCFG-C3`) | **`false` 로 명시 pin** (D3) |
|
||||
| `max.poll.interval.ms` | `300000` (`KAFKA-CONSCFG-C1`) | 실측 기반 명시 pin. 초과 시 consumer 가 실패로 간주되어 파티션이 재할당됨 |
|
||||
| `max.poll.records` | `500` (`KAFKA-CONSCFG-C2`) | 큐 용량(§2)과 함께 결정. 배치 크기가 poll 주기 예산을 좌우 |
|
||||
| `session.timeout.ms` / `heartbeat.interval.ms` | `45000` / `3000`, heartbeat 는 session 의 1/3 이하 권장 (`KAFKA-CONSCFG-C5`/`C6`) | 기본값 상속 — 본 branch 는 재정의하지 않음(처리 지연은 `max.poll.interval.ms` 축이 담당) |
|
||||
| 컨테이너 `concurrency` | (프레임워크 속성) 파티션 수보다 크면 **하향 조정**된다 (`SPRK-ACKMODE-C7`) | 파티션 수를 상한으로 pin. D4 의 "병렬도 상한 = 파티션 수" 가 프레임워크 차원에서도 강제된다 — 큐 모델에서도 이 값을 넘겨 잡지 않는다 |
|
||||
| `partition.assignment.strategy` | `[RangeAssignor, CooperativeStickyAssignor]` (`KAFKA-CONSCFG-C7`) | `CooperativeStickyAssignor` 로 pin. 기본 목록 덕에 `RangeAssignor` 만 제거하는 단일 rolling bounce 로 전환 가능 |
|
||||
|
||||
rebalance 리스너 계약 (D6):
|
||||
|
||||
| 콜백 | 계약 | 근거 |
|
||||
|---|---|---|
|
||||
| `onPartitionsRevoked` | 호출되면 그 파티션의 **완료분까지만** 커밋. **호출을 전제하지 않는다** — cooperative 에서는 아예 호출되지 않을 수 있다 | `KIP429-C5` |
|
||||
| `onPartitionsAssigned` | 새로 배정된 파티션의 pause 상태를 초기화(resume)한다. 안 하면 배정받고도 소비하지 않는 좀비 파티션이 된다 — **`UNSUPPORTED_IMPL_DECISION`**: 재배정 시 파티션 pause 상태가 유지되는지 초기화되는지를 규정한 문서가 없다(`SPRK-PAUSEPART-C5` — 파티션 pause 페이지·컨테이너 pause 페이지 모두 rebalance 어휘 자체가 부재). trade-off: **보수적으로 명시 resume 을 호출**한다 — 이미 resume 상태에 resume 을 부르는 것은 무해하지만, pause 가 잔존하면 그 파티션이 조용히 멈춘다(비대칭 위험) | `SPRK-PAUSEPART-C5` (부재 확인), `SPRK-PAUSEPART-C3` (요청 vs 실제 정지 구분 API 로 상태 확인 가능) |
|
||||
| `onPartitionsLost` | 이미 소유권을 잃은 뒤의 정리 전용 — 이 시점의 커밋은 무효로 간주 | `KIP429-C4` |
|
||||
| 미완료 큐 항목 | 폐기(커밋하지 않음). 재할당 consumer 가 마지막 커밋 offset 부터 재소비 → **중복이지 유실 아님**, D10/D11 이 흡수 | `KAFKA-OFFSET-C4` + D10 |
|
||||
|
||||
### 4. 실패 분류 → 회수 경로 (D7, D8, D9)
|
||||
|
||||
> **Trace**: D7 (`SPRK-EHD-C1`~`C3`, `SPRK-ERRH-C1`, `CONF-POISON-C3`/`C4`) + D8 (`SPRK-RETRYTOPIC-C3`, `SPRK-ERRH-C2`, background-job D4 위임) + D9 (`SPRK-ERRH-C3`/`C4`/`C5`, modules.yaml 경계).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (1) **감사된 replay 의 기록 스키마**(누가/언제/어느 offset/결과) — Spring·Confluent·Uber 어느 문서도 replay audit 를 규정하지 않는다(3개 조사 모두 "외부 근거 부재" 로 확인). trade-off: 최소 필드(`replayedBy`, `replayedAt`, 원본 `topic/partition/offset`, `idempotencyKey`, 결과)를 inbox/dead-letter row 에 남기는 방향만 정하고 상세는 구현 시. (2) blocking retry 를 pause 형 backoff 로 전환하는 **실패율 임계** — 근거 없음. trade-off: backoff 총합이 `max.poll.interval.ms` 를 넘길 수 있으면 전환한다는 조건만 계약화. (3) **fatal 6종(`SPRK-ERRH-C1`) 외에 어떤 프로젝트 예외를 non-retryable 로 확장할지** — 프레임워크는 목록 확장 수단만 제공하고 무엇을 넣을지는 말하지 않는다. trade-off: 도메인 검증 실패 계열(`VALIDATION`/`DATA_INTEGRITY` category)을 우선 후보로 두되, 애매한 예외는 확장하지 않고 재시도 예산 소진에 맡기는 편이 유실보다 안전(D7 Open Risk 의 truncation 엣지도 이 원칙으로 흡수).
|
||||
|
||||
| 시나리오 | 분류 | 처리 경로 | offset | Trace |
|
||||
|---|---|---|---|---|
|
||||
| 역직렬화 실패 (깨진 바이트·스키마 불일치) | **non-retryable** | 리스너 호출 없이 error handler → dead-letter 회수 (D9) | 회수 성공 후 전진 | D7 (`SPRK-EHD-C2`/`C3`, `SPRK-ERRH-C1`) |
|
||||
| handler/schema/version allowlist 위반 (파싱은 성공, 미등록 `(topic, eventType)`) | **non-retryable** | application 예외 → dead-letter 회수 | 회수 후 전진 | **D15**, D7 선택 조건 (§구현 가이드 6) |
|
||||
| 다운스트림 일시 실패 (DB·외부 의존 타임아웃) | **retryable** | blocking bounded retry (backoff+jitter, max attempts 3 — background-job D4 위임) | 성공 시 전진 / 소진 시 아래 | D8 |
|
||||
| 재시도 소진 | 종단 실패 | dead-letter 회수 + 감사 기록 | 전진 | D8, D9 |
|
||||
| backoff 총합이 `max.poll.interval.ms` 를 넘길 위험 | — | 스레드 정지형 backoff 대신 **컨테이너 pause 형** backoff | — | D8 (`SPRK-ERRH-C2`) |
|
||||
| dead-letter 회수 자체가 실패 | 종단 실패 | ack 하지 않음 → 재전달되어 재시도 (중복은 D10/D11 흡수) | 전진하지 않음 | D3, D9 |
|
||||
|
||||
dead-letter 발행 경계 (D9) — **모듈 규칙이 방식을 강제한다**:
|
||||
|
||||
- `DeadLetterPublishingRecoverer` 는 레코드를 보내기 위해 producer(`KafkaTemplate`/`KafkaOperations`)를 요구한다(`SPRK-ERRH-C4`). 그런데 inbound leaf 의 `allowed_dependencies` 에는 outbound leaf 가 없다(modules.yaml 실측).
|
||||
- 따라서 **inbound leaf 가 producer 를 직접 들 수 없다**. dead-letter 발행은 `application-core` 에 정의한 outbound port 를 통해 나가고, 그 구현은 `adapter:outbound:messaging` 이 맡는다(Dependency Inversion). 대안으로 dead-letter 를 DB row 로만 표현하면 producer 자체가 필요 없다(D9 선택 조건).
|
||||
- recoverer 를 명시 구성하지 않으면 기본 동작은 **로그만** 이고 dead-letter 로 가지 않는다(`SPRK-ERRH-C5`) — "설정 안 하면 안전" 이 아니라 "설정 안 하면 조용히 유실" 이다.
|
||||
- 토픽 방식 채택 시 기본 명명은 `<originalTopic>-dlt`, 원본과 같은 partition 이며 DLT 토픽의 partition 수가 원본 이상이어야 한다(`SPRK-ERRH-C3`).
|
||||
|
||||
### 5. Inbox 스키마·dedupe key (D10, D11, D12, D13)
|
||||
|
||||
> **Trace**: D10 (`MSIO-IDEMPC-C2`~`C5`, ca-tmpl `TransactionPort`) + D11 (outbox D12/D14, `CLOUDEVT-C2`, `MSIO-IDEMPC-C4`) + D12 (#070 위임) + D13 (UNSUPPORTED — TTL 수치).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (1) 테이블·컬럼 물리 설계(DB 타입, 인덱스) — 근거 없음. trade-off: outbox `V3__outbox_event.sql` 선례의 컬럼 명명·인덱스 패턴을 따르는 편이 새 규칙을 만드는 것보다 일관적. (2) reaper 주기·보존 상한 — D13 의 관계식만 있고 수치 근거가 없다. trade-off: outbox reaper(`ca-skeleton.outbox.published-retention`) 를 모델로 설정 키로 노출.
|
||||
|
||||
| 항목 | 명세 | 근거 |
|
||||
|---|---|---|
|
||||
| 포트 | `InboxStorePort` (`application-core`, `dev/caskeleton/application/inbox/`) — **신규, 현재 부재** | D10, `planned` |
|
||||
| 저장 어댑터 | `adapter:outbound:persistence-jpa` (`dev/caskeleton/adapter/outbound/persistence/`) — outbox 의 `OutboxStoreAdapter`/`OutboxEventEntity` 선례와 같은 자리. inbound leaf 는 포트만 호출하고 저장 구현을 알지 못한다 | D1(의존 제한), D10 |
|
||||
| 마이그레이션 | 신규 inbox 테이블의 Flyway 스크립트. 버전 번호 배정·적용 순서·`out-of-order` 정책은 [[raw/branch-notes/feature-migration-startup-contract]] 소유 — 본 branch 는 소비자 | D10 (reference-only) |
|
||||
| 트랜잭션 경계 | `TransactionPort.inWrite { 비즈니스 write + inbox insert }` — 포트 구현이 자체 트랜잭션을 열지 않는다(outbox `OutboxStorePort` 규약과 동일) | D10, ca-tmpl `TransactionPort` (`actually-implemented`) |
|
||||
| 중복 탐지 | 유니크 제약 위반으로 INSERT 실패 → 트랜잭션 rollback → 중복 처리 원천 차단 | `MSIO-IDEMPC-C4` |
|
||||
| dedupe key | `idempotencyKey`(= `eventId`, ULID) 1차 + 유니크 제약은 `(idempotencyKey, eventType)` 복합 | D11, outbox D12 |
|
||||
| 금지 | `(topic, partition, offset)` 단독 key — outbox relay 재발행이 같은 논리 이벤트를 다른 offset 으로 싣는다 | D11 (outbox §엣지) |
|
||||
| 기록 필드(최소) | `idempotencyKey`, `eventType`, `aggregateId`, `correlationId`, 처리 시각, 원본 `topic/partition/offset`(감사용) | D11, outbox D12 envelope |
|
||||
| 대안 (별도 테이블 없이) | 비즈니스 엔티티 자체에 메시지 ID 저장 — 1 이벤트 = 1 row 인 경우만. 처리 이력 조회는 포기 | `MSIO-IDEMPC-C5`, D10 선택 조건 |
|
||||
| owner token 재사용 | 기본 미사용(insert-once). worker fan-out 도입 시 #070 의 claim/lease 로 승격 | D12 |
|
||||
| 보존(TTL) | 수치 미정. `retention.ms` 와 replay 창 중 긴 쪽 이상 + 무한 보관 금지 | D13 (UNSUPPORTED_DECISION) |
|
||||
|
||||
### 6. handler/schema/version allowlist (D15, D7)
|
||||
|
||||
> **Trace**: D15 (`SPRK-ERRH-C1` — 라우팅/시그니처 불일치 계열이 fatal, outbox D12 — envelope `eventType` required) + D7 (미등록 조합의 처리 경로를 공유).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (1) 등록부를 **코드에 둘지 설정에 둘지** — 외부 근거 없음. trade-off: 설정과 handler 가 갈라지면 "등록됐는데 handler 없음" 이 런타임에만 드러나므로 코드 등록부가 안전. (2) `schemaVersion` 축 — **현재 envelope 에 그 필드가 없다**(outbox D12 required 6필드 실측). trade-off: 지금 발명하지 않고 실효 키를 `(topic, eventType)` 2축으로 두되, 버전 축이 필요해지면 outbox D12 확장을 요청한다.
|
||||
|
||||
| 항목 | 명세 | Trace |
|
||||
|---|---|---|
|
||||
| allowlist 의 키 | `(topic, eventType)` — `schemaVersion` 은 envelope 확장 후 추가 | D15, outbox D12 |
|
||||
| 2축이 실효를 갖는 전제 | 현재 내부 producer 는 `topic = eventType` 이라(#063 D5) 두 축이 **1:1 로 축약**되어 allowlist 가 걸러낼 것이 없다. 이 검사가 실제로 작동하는 경우는 (a) 외부 시스템이 우리 토픽에 발행하거나 (b) 한 토픽에 여러 `eventType` 을 싣는 매핑을 도입할 때다. 그 전까지 D15 의 실효 방어선은 **구독 목록 자체**(§위 행)이며, allowlist 는 그 시점을 대비한 계약이다 | D15, #063 D5 (`actually-implemented`) |
|
||||
| 등록 주체 | 각 handler 가 자신이 담당하는 조합을 선언. 별도 yaml/env 등록부를 두지 않는다 | D15 |
|
||||
| 구독 토픽 목록의 출처 | **handler 선언의 topic 합집합** — 별도 env/yaml 로 토픽을 나열하지 않는다. 따라서 "구독했는데 handler 없음" 은 구조적으로 발생하지 않고, 반대로 handler 가 늘면 구독도 함께 는다 | D15 |
|
||||
| 기동 시 검증 | 같은 `(topic, eventType)` 조합을 두 handler 가 선언(중복)하면 기동 거부. handler 가 0개면(= 구독 토픽 0개) 이 leaf 자체가 비활성으로 취급되어 listener container 를 만들지 않는다(D2 의 활성화 게이트와 동일 원칙). 기동 거부의 실패 표현은 [[raw/branch-notes/feature-migration-startup-contract]] 계약을 따른다 | D15, D2 (reference-only) |
|
||||
| 미등록 조합 수신 | 처리하지 않고 **non-retryable** 로 분류해 D9 회수 경로. 조용한 skip 금지 | D15, D7 |
|
||||
| 검사 위치 | 역직렬화 성공 **이후**, use case 호출 **이전** — D7 의 deserializer 경계 검사와 단계가 다르다(그쪽은 파싱 자체의 실패) | D15, D7 |
|
||||
|
||||
### 7. 계약 값 — 전부 신규 제안 (D14)
|
||||
|
||||
> **Trace**: D14 (ca-tmpl `docs/registries/*.yaml` 실측 — consumer/inbox row 부재, `Category.java` 10-value enum).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 아래 code/metric/env 의 **명명 자체**는 외부 근거가 없다. trace-off: 기존 registry 의 도메인 접두 관행(`OUTBOX_*`/`JOB_*`, `outbox.*`/`job.*`, `APP_MESSAGING_*`)을 그대로 따르는 편이 새 네이밍 축을 만드는 것보다 일관적. **아래는 제안이며 registry 반영 전까지 기존 값처럼 인용 금지.**
|
||||
|
||||
| 종류 | 제안 값 | 제안 속성 | 대응 결정 |
|
||||
|---|---|---|---|
|
||||
| error code (신규 제안) | `CONSUMER_DESERIALIZATION_FAILED` | category `DATA_INTEGRITY` 또는 `PERMANENT_DEPENDENCY` 중 택일(둘 다 기존 enum 값), `retryable: false`, runbook 필수 | D7, D14 |
|
||||
| error code (신규 제안) | `CONSUMER_DEAD_LETTER` | category `INTERNAL`, `retryable: false` — `OUTBOX_DEAD_LETTER`/`JOB_DEAD_LETTER` 선례와 동형 | D9, D14 |
|
||||
| error code (신규 제안) | `INBOX_DUPLICATE_SKIPPED` | 오류가 아니라 정상 경로 — **code 대신 metric 으로만 표현**하는 편이 registry 오염이 적다(대안 명시) | D10, D14 |
|
||||
| metric (신규 제안) | `consumer.records.total{topic,outcome}` | outcome ∈ {PROCESSED, DUPLICATE, RETRIED, DEAD} — `job.retry.total` 의 tag 패턴 참고 | D7~D11 |
|
||||
| metric (신규 제안) | `consumer.lag` / `consumer.queue.depth` / `consumer.paused.seconds` | pause 지속·큐 적체가 조용한 정체의 유일한 관측 지점 | D5 |
|
||||
| env key (신규 제안) | `APP_MESSAGING_KAFKA_CONSUMER_*` (group id, 동시성, 큐 용량, `max.poll.*`) | 기존 `APP_MESSAGING_KAFKA_BROKERS` 접두 관행 상속. env key owner 는 `feature-env-driven-runtime-configuration`/`feature-integration-adapter-templates` — 등록은 협의 필요 | D2, D6 |
|
||||
| env key (**본 branch 대상 아님**) | `security.protocol`·TLS/SASL 자격증명 계열 | broker 접속 보안 키는 [[raw/branch-notes/feature-kafka-producer-runtime-contract]] **D7** 소유 — consumer 는 같은 키 집합을 상속하고 신규 정의하지 않는다 | D2 |
|
||||
| runbook (의무) | `runbook://consumer/dead-letter`, `runbook://consumer/poison-record` | registry 규약상 `retryable=true` 행과 `retryable=false` + INTERNAL 계열은 runbook_link 필수 | D9, D14 |
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **DB 커밋 성공 후 ack 전 crash** → 같은 메시지 재전달. 기대 동작: inbox 유니크 제약 위반으로 중복 감지 → 처리 없이 ack (D10, D11). at-least-once 의 정상적 결과다.
|
||||
- **ack 후 DB 커밋 실패** → 발생해서는 안 되는 순서. D3 이 금지하는 배치이며, 어기면 재전달 없는 영구 손실(`KAFKA-OFFSET-C4`).
|
||||
- **poll 루프 스레드 블로킹(GC·큐 offer 대기)** → pause 여부와 무관하게 `max.poll.interval.ms` 초과 → 그룹 이탈·파티션 재할당(`KAFKA-CONSCFG-C1`). 기대 동작: 큐 offer 는 non-blocking, 포화는 pause 로 표현(D5, §구현 가이드 2).
|
||||
- **rebalance 중 in-flight 레코드** → 미완료분은 커밋하지 않고 폐기. 재할당 consumer 가 마지막 커밋 offset 부터 재소비하여 중복 발생 — D10/D11 이 흡수. `onPartitionsRevoked` 가 호출되지 않을 수 있으므로 그 콜백을 유일 체크포인트로 삼지 않는다(`KIP429-C5`).
|
||||
- **재배정 후 pause 상태 미초기화** → 파티션을 배정받고도 fetch 하지 않는 좀비 파티션. 기대 동작: `onPartitionsAssigned` 에서 강제 resume(§구현 가이드 3).
|
||||
- **poison record 무한 재시도** → offset 이 전진하지 않아 해당 파티션 소비가 정지(`CONF-POISON-C3`/`C4`). 기대 동작: deserializer 경계에서 non-retryable 로 분류해 첫 실패에 회수(D7).
|
||||
- **dead-letter recoverer 미구성** → 재시도 소진 후 기본 동작이 로그만이라 조용히 유실(`SPRK-ERRH-C5`). 기대 동작: recoverer(또는 DB dead-letter row) 구성 없이 이 leaf 를 활성화하지 못하게 기동 검증.
|
||||
- **producer 신뢰 붕괴 (서로 다른 논리 이벤트가 같은 `idempotencyKey`)** → 정상 이벤트를 중복으로 오판해 **조용히 누락**. 기대 동작: `(idempotencyKey, eventType)` 복합 제약이 불일치를 탐지해 경고로 격상(D11).
|
||||
- **inbox TTL < replay 창** → 감사된 replay 로 되돌린 메시지가 "처음 보는 메시지" 로 재처리됨(D13 관계식이 막으려는 모순).
|
||||
- **backoff 총합 > `max.poll.interval.ms`** → 재시도 도중 그룹 이탈. 기대 동작: 스레드 정지형이 아니라 컨테이너 pause 형 backoff 로 전환(D8, `SPRK-ERRH-C2`).
|
||||
- **배포 순서 편차 (producer 선배포 → 구 consumer 가 신규 이벤트 타입을 모름)** — 현재 producer 는 `topic = eventType` 으로 발행하므로(#063 D5, `actually-implemented`) 이 경우가 **두 갈래로 갈린다**:
|
||||
- (a) **미구독 신규 토픽**: 구독 목록이 handler 선언의 합집합이므로(§구현 가이드 6) 그 토픽을 아무도 구독하지 않는다 → 레코드가 consumer 에 **도달조차 하지 않고** 토픽에 적체되다 `retention.ms` 만료로 유실될 수 있다. D15 의 allowlist 는 이 경로를 막지 못한다. 기대 동작: consumer 배포 전까지 적체를 견디도록 해당 토픽 retention 을 확보하고, 미구독 토픽 존재를 운영이 인지할 수단(브로커 측 토픽 목록 대조)이 필요하다 — **본 branch 결정 범위 밖의 운영 절차**.
|
||||
- (b) **구독 중인 토픽에 미등록 `eventType` 투입**(외부 producer 또는 1토픽-다eventType 매핑을 쓰는 경우): D15 에 따라 non-retryable 로 분류되어 dead-letter 로 회수된다 → 유실이 아니며 consumer 배포 후 감사된 replay(D9)로 재처리. 관측은 `consumer.records.total{outcome=DEAD}` 급증(D14 제안 지표).
|
||||
- **정지(SIGTERM) 시점의 in-flight** → 파티션 큐에 남은 레코드 · 아직 flush 되지 않은 pending ack · `TransactionPort.inWrite` 실행 중인 워커가 동시에 존재한다. 기대 동작(**stop 계약**): ① 모든 파티션 `pausePartition` → ② 워커 drain 을 bounded 하게 await → ③ **완료 연속 구간까지 pending ack flush** → ④ consumer close. 예산 안에 끝나지 않은 미완료분은 **ack 하지 않고 폐기**한다 — 재시작 후 재전달되며 중복은 D10/D11 이 흡수한다(유실보다 중복을 택하는 D3 와 같은 방향). 정지 순서상의 위치와 예산 배분은 [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] **D4**(SIGTERM → readiness DOWN → inflight drain → outbound 컴포넌트 descending stop → exit)와 [[raw/branch-notes/feature-background-job-async-contract]](executor `setWaitForTasksToCompleteOnShutdown(true)` + `setAwaitTerminationSeconds(19)`, 기본값은 즉시 interrupt)의 예산 안에 들어가야 하므로 **두 owner 와 phase 배치 협의 필요**. F2 의 지연 ack 때문에 이 경로는 매 배포마다 반드시 발생한다.
|
||||
- **유휴 파티션의 pending ack 미발화** → 워커가 완료 offset 을 적재했는데 리스너 진입이 끊겨 ack 이 나가지 않음. 기대 동작: 유휴 flush 경로가 이를 밀어낸다(§구현 가이드 2 "ack 발화 지점"). 없으면 재기동 시 대량 재전달 + `consumer.lag` 왜곡.
|
||||
- **broker 장기 다운** → 큐 적체 → pause 지속. 관측 지점이 없으면 조용한 정체가 된다 — `consumer.paused.seconds`/`consumer.queue.depth`(D14 신규 제안)가 필요한 이유.
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-kafka-producer-runtime-contract]] (`WI-CA-SKELETON-OPERATIONAL-CONTRACT-063`, **D1~D13 작성 완료 — 2026-07-28 확인**):
|
||||
- **D5**(순서 보장은 파티션 단위, `key = aggregateId` 로 per-aggregate FIFO 대응) — 본 branch D4 의 전제를 producer 측에서 실현한다. 그 노트가 `OutboxMessagePublishAdapter` 의 `topic=eventType, key=aggregateId` 를 `actually-implemented` 로 확인했으므로 D4 의 "파티션 = aggregate 단위" 가정은 근거를 얻는다. 다만 **그 D5 의 Open Risk(파티션 수를 늘리면 같은 `aggregateId` 가 다른 파티션으로 가서 per-aggregate FIFO 가 깨짐)가 본 branch 의 순서 계약에도 그대로 전이**된다.
|
||||
- **D2**(seam 유지 + 스켈레톤이 spring-kafka 기반 기본 구현 제공 + broker 미선택 시 auto-config 비활성) — 본 branch D2 가 같은 축으로 정렬. 같은 D2 가 "모듈 registry migration(19→20)은 #064 소유" 로 본 branch D1 을 명시 승인한다.
|
||||
- **D10**(`OutboundMessage` 에 headers 추가 — `mdc-keys` 의 `propagation: [message]` 4종 전파) — consume 시 복원해야 할 header 집합의 producer 측 계약. 그 D10 이 바뀌면 본 branch 의 MDC 복원 대상이 바뀐다.
|
||||
- **D3**(`MessageBroker.send` 반환 확장) / **D11**(producer 전용 error code 미생성) — 본 branch D14 가 "신규 코드 최소화" 방향을 참고할 선례.
|
||||
- [[raw/branch-notes/feature-idempotency-ownership-protocol-contract]] (`WI-CA-SKELETON-OPERATIONAL-CONTRACT-070`) — D12 의 owner token 재사용 판단이 이 branch 의 claim/lease API 확정에 의존. **현재 스캐폴딩(D-row 0개, 2026-07-28 확인)** — 확정 후 D12 재검토.
|
||||
- [[raw/branch-notes/feature-capability-provider-selection-contract]] **D13**(비활성 capability 는 연결·워커·스키마·health contributor 를 만들지 않는다) — D2 가 SDK 를 classpath 에 올리는 순간 이 계약과 충돌할 수 있다. consumer 는 auto-config 활성화가 곧 broker 연결 시도이므로 producer 보다 충돌이 즉각적이다.
|
||||
- [[raw/branch-notes/feature-domain-event-outbox-contract]] D7·D12·D14 — envelope 필수 필드와 `idempotencyKey` scope 를 consume 한다(D11). envelope 가 바뀌면 dedupe key 계약이 연동 변경된다.
|
||||
- [[raw/branch-notes/feature-background-job-async-contract]] D4 — backoff/max attempts/DLQ 어휘를 consume 한다(D8). 단 **saturation 정책(`AbortPolicy`)은 consume 하지 않는다** — 거부는 레코드 유실이라 at-least-once 를 깨므로 consumer 경계에서는 pause 로 대체(D5).
|
||||
- [[raw/branch-notes/feature-integration-adapter-templates]] — `APP_MESSAGING_BROKER`/`APP_MESSAGING_KAFKA_BROKERS` env key owner. consumer 용 신규 키(D14) 등록 시 협의 대상.
|
||||
- [[raw/branch-notes/feature-operational-error-observability-foundation]] — `correlation_id` 의미·전파 SSOT(`mdc-keys.yaml` 의 `propagation` 에 `message` 포함). consume 시 envelope `correlationId` → MDC 복원 의무는 이 계약을 따른다(reference-only).
|
||||
- [[raw/branch-notes/feature-schema-serialization-contract]] — payload **직렬화·스키마 진화** 정책 owner (Avro/JSON, date/decimal 표현 등). ⚠️ **handler/schema/version allowlist 는 이 branch 가 소유하지 않는다** — 초안에서 그쪽으로 위임한다고 적었으나 2026-07-28 grep 결과 handler/topic/routing 언급 0건으로 확인돼 본 branch 의 D15 로 회수했다(§Audit `FALSE_DELEGATION`). 이 계약과의 실제 접점은 payload 직렬화 형식뿐이다.
|
||||
- [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] **D4** — SIGTERM 이후의 정지 순서(readiness DOWN → inflight drain → outbound 컴포넌트 descending stop → exit) owner. 본 branch 의 stop 계약(§엣지의 pause→drain→pending ack flush→close)이 그 순서의 어느 phase 에 들어가는지 **협의 대상**이다. 그 D4 가 바뀌면 본 branch 의 정지 시퀀스가 연동 변경된다.
|
||||
- [[raw/branch-notes/feature-background-job-async-contract]] — retry 어휘(D8) 외에 **shutdown 예산**도 이 계약을 따른다: executor `setWaitForTasksToCompleteOnShutdown(true)` + `setAwaitTerminationSeconds(19)`(기본은 즉시 interrupt). 워커 drain await 예산이 이 안에 들어가야 한다. **워커 풀 소유는 본 branch** — 그 branch 의 saturation 정책(`AbortPolicy`)은 consume 하지 않는다(D5).
|
||||
- [[raw/branch-notes/feature-migration-startup-contract]] — 신규 inbox 테이블의 Flyway 마이그레이션 순서·번호 배정과, "필수 구성 누락 시 기동 거부" 를 어떤 실패로 표현할지(§엣지의 recoverer 미구성 기동 검증)의 owner. 본 branch 는 소비자다.
|
||||
- [[raw/branch-notes/feature-kafka-producer-runtime-contract]] **D7** (`security.protocol` 명시 선택 + prod 에서 `PLAINTEXT` 기동 거부 + TLS/SASL 자격증명은 `secrets-classification.yaml` 의 secret tier) — consumer 도 **같은 broker 접속 계약을 상속**한다. 보안 관련 env key 는 그 branch 소유이며 본 branch 가 신규 정의하지 않는다(D14 의 신규 제안 대상 밖).
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장 / Claims To Verify
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 본 branch 가 소유할 관심사가 sibling branch 결정과 겹치지 않는다 | 스캐폴딩 시점에는 D-row 가 없어 경계가 문장으로만 존재 | `/branch-spec` 후 `/sync` 실행 — owner 중복 검출 | `needs-confirmation` |
|
||||
| "파티션 하나는 consumer group 안에서 정확히 한 consumer 가 소비한다" (D4 의 전제) | 현재 근거가 회사 블로그 1건(`UBER-REPROC-C2`)뿐 — Kafka 공식 verbatim 미수집. `kafka.apache.org/documentation` 은 JS SPA 라 정적 fetch 불가가 이미 확인됨 | Kafka 공식 정적 페이지(예: `ConsumerConfig`/`KafkaConsumer` javadoc 의 group management 절) 또는 Confluent 미러에서 verbatim 수집 후 D4 근거 격상 | `needs-confirmation` |
|
||||
| 파티션 단위 pause/resume API(`pausePartition`/`resumePartition`)가 D4·D5 조합(파티션별 큐 + 그 파티션만 pause)을 지원한다 | 2026-07-28 `[[raw/official-docs/spring-kafka-pause-resume-partitions-on-listener-containers]]` 수집으로 API 존재·타이밍·상태조회는 확인됨(`SPRK-PAUSEPART-C1`~`C4`). **단, rebalance 재배정 시 이 pause 상태가 유지되는지 초기화되는지는 이 문서도 다루지 않음**(`SPRK-PAUSEPART-C5` — 부재 확인) | 해소(API 존재) — 잔여: Spring Kafka 소스 코드(`KafkaMessageListenerContainer`) 또는 통합 테스트로 재배정 시 pause 상태 동작 검증 | `needs-confirmation` (API 존재는 confirmed, rebalance 상호작용은 미확인 유지) |
|
||||
| SPI seam(D2)만으로 D3·D5·D7·D8·D9 정책이 fork 구현에서 실제로 지켜지는지 검증 가능하다 | 정책의 실행 주체가 fork 의 client 구현이므로, 스켈레톤이 계약 테스트를 어떻게 제공할지 미확정 | fake client seam 기반 계약 테스트 스위트를 설계해, ack 순서·pause 전이·poison 회수·dead-letter 경로를 fake 로 단언 가능한지 실증 | `planned` |
|
||||
| inbox 유니크 제약 위반이 중복 처리를 실제로 rollback 시킨다 (D10) | `MSIO-IDEMPC-C4` 는 패턴 카탈로그의 서술이며 ca-tmpl 의 `TransactionPort` + JPA 조합에서의 동작은 별도 검증 필요 | 계약 테스트: 동일 `idempotencyKey` 메시지 5회 전달 → 비즈니스 row 1건, inbox row 1건, 처리 횟수 1회 단언 | `planned` |
|
||||
| ack 가 DB 커밋 이후에만 발생한다 (D3) | 코드 순서를 지키는지는 리뷰로 보장되지 않는다 | 계약 테스트: 커밋 직전 예외 주입 → 오프셋이 전진하지 않고 재전달됨을 단언 / 커밋 성공 후 ack 예외 주입 → 재전달 시 중복 스킵됨을 단언 | `planned` |
|
||||
| rebalance 중 재할당 파티션이 pause 상태로 남지 않는다 (D6) | `onPartitionsRevoked` 미호출 가능성(`KIP429-C5`)과 결합하면 상태 초기화 누락이 조용히 남는다 | 계약 테스트: pause 상태에서 파티션 재할당 시뮬레이션 → `onPartitionsAssigned` 후 해당 파티션이 resume 상태임을 단언 | `planned` |
|
||||
| dead-letter 발행 경로가 모듈 경계를 위반하지 않는다 (D9) | producer 요구(`SPRK-ERRH-C4`)와 inbound leaf 의존 제한이 충돌하므로 배선 실수가 나기 쉽다 | `./gradlew verifyCleanArchitectureDependencies` + `:app-bootstrap:test --tests '*CleanArchitectureTest'` 통과 확인 (inbound leaf 가 outbound leaf 를 import 하면 실패) | `planned` |
|
||||
| producer 가 동일 논리 이벤트에 항상 같은 `eventId` 를 재사용한다 (D11 의 전제) | outbox branch 에 이 보장을 명시한 D-row 가 없음(2026-07-28 확인) | outbox relay 재발행 시 `eventId` 재사용 여부를 `PublishPendingOutboxEventsUseCase` 코드로 확인하고, 필요하면 outbox branch 에 D-row 추가 요청 | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
> 2026-07-28 `coverage-auditor` 1회차 결과 + 그 지적을 반영한 loop 1 수정 상태. governing doc: [[raw/project-notes/ca-skeleton-operational-contract]] (§8.0 `WI-CA-SKELETON-OPERATIONAL-CONTRACT-064`, §Owner Map) + 분해 설계 §4.2 L146. **1회차 판정은 `Not-covered`(Blocking 1 — allowlist)** 였고, 아래 표는 D15 신설 후 상태다.
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|--------|------|-------|--------|------|
|
||||
| 신규 inbound leaf 등록 + 모듈 registry migration(19→20) | covered-here | — | — | D1 (`.harness/project/modules.yaml` 실측, #063 D2 가 소유권 명시 승인) |
|
||||
| manual acknowledgement (use case 성공 + DB 커밋 이후 ack) | covered-here | — | — | D3 |
|
||||
| handler/schema/version allowlist | covered-here | — | — | **D15 (2026-07-28 loop 1 신설)** — 1회차에서 `missing` 🔴 Blocking 이었고, 초안의 위임 주장이 거짓으로 확인돼(§Audit `FALSE_DELEGATION`) 본 branch 로 회수 |
|
||||
| bounded concurrency·queue + pause/resume backpressure | covered-here | — | — | D4, D5 |
|
||||
| rebalance·`max.poll` 처리 + poison/역직렬화 실패 분류 | covered-here | — | — | D6, D7 |
|
||||
| 재시도 + DLT + 감사된 replay | covered-here | — | — | D8, D9 (replay audit 스키마는 `UNSUPPORTED_IMPL_DECISION` — coverage gap 아님) |
|
||||
| `InboxStorePort` 트랜잭션 커밋 규칙 + dedupe key | covered-here | — | — | D10, D11 |
|
||||
| 상속: at-least-once + 멱등 consumer/inbox, exactly-once 미주장 | covered-here | — | — | 상속 표 branch application + D3·D10·D11 |
|
||||
| 상속: owner token 재사용 여부 | delegated | [[raw/branch-notes/feature-idempotency-ownership-protocol-contract]] | 🟡 Should-fix | D12 — 위임 링크는 있으나 owner 가 스캐폴딩(D-row 0개). `needs-approval` + TODO 로 추적 중 |
|
||||
| consume 시 correlationId → MDC 복원 | delegated | [[raw/branch-notes/feature-operational-error-observability-foundation]] | OK | §엣지·의존 링크. owner 는 `verified` 상태 |
|
||||
| broker 접속 보안(`security.protocol`·TLS/SASL secret) | delegated | [[raw/branch-notes/feature-kafka-producer-runtime-contract]] D7 | OK | §엣지·의존 + §구현 가이드 7 (본 branch 신규 정의 없음) |
|
||||
| inbox 테이블 마이그레이션·기동 검증 표현 | delegated | [[raw/branch-notes/feature-migration-startup-contract]] | OK | §엣지·의존 + §구현 가이드 5 |
|
||||
| consumer/inbox 신규 error code·metric·env key 제안 | covered-here | — | — | D14 (registry 부재 확인 후 "신규 제안" 라벨) |
|
||||
|
||||
## Audit & Findings (2026-07-28 `/branch-spec` ground-truth 대조)
|
||||
|
||||
> ca-tmpl 코드·registry 실측과 인용 재검증에서 나온 사항. 자동 수정하지 않고 기록만 한다.
|
||||
|
||||
| Finding | 분류 | 내용 | 조치 |
|
||||
|---|---|---|---|
|
||||
| consumer·inbox 인프라 전부 부재 | `IMPLEMENTATION_GAP` | 2026-07-28 `src/` grep: `InboxStorePort` 0건, inbound messaging leaf 없음(`src/adapter/inbound/` = web/grpc/graphql/websocket 4종), modules.yaml 19개. 존재하는 것은 outbound 측 `MessageBroker`/`KafkaSender`/`KafkaMessageBroker`/`MessagingConfig` 와 `TransactionPort` 뿐 | §구현 가이드의 신규 항목을 전부 `planned` 로 표기. 구현 branch 착수 시 해소 |
|
||||
| SPI 대칭성이 consumer 측에서 깨질 수 있음 | `SPI_ASYMMETRY` | outbound 코드는 현재 SDK-free seam(`KafkaSender`)으로 성립하지만, consumer 측 정책(D7·D8·D9)의 근거는 전부 `spring-kafka` API(`ErrorHandlingDeserializer`/`DefaultErrorHandler`/`DeadLetterPublishingRecoverer`)다. seam 만 두면 정책의 *모양*만 규정되고 실행은 fork 몫이 된다 | sibling #063 D2 가 "스켈레톤이 spring-kafka 기반 기본 구현 제공" 으로 이미 축을 정했으므로 본 branch D2 를 그 축에 정렬해 해소. 잔여 위험(classpath 오염 ↔ capability-provider D13)은 D2 Open Risk 로 이월 |
|
||||
| 본 노트의 1차 D2 가 현행 코드만 보고 작성돼 sibling 결정과 어긋났음 | `SIBLING_DRIFT` | 세션 초반 확인 시 #063 은 스캐폴딩(192줄, D-row 0)이었으나 같은 날 D1~D13 이 작성됨(487줄). 그 D2 가 "skeleton 이 spring-kafka 기반 기본 구현을 제공" 을 결정해, 코드 실측(`KafkaSender` javadoc "The skeleton carries no Kafka SDK dependency")만으로 세운 본 노트의 초안 D2(순수 SPI)와 충돌 | 본 D2 를 #063 D2 축으로 **재작성 완료**(2026-07-28). 두 D2 모두 `proposed` 이므로, SDK 를 classpath 에 올릴지 여부는 두 branch 가 **함께** 확정해야 한다 |
|
||||
| dead-letter 발행이 모듈 경계와 충돌 | `MODULE_BOUNDARY` | DLT 발행에 producer 필수(`SPRK-ERRH-C4`) vs inbound leaf 의 `allowed_dependencies` 에 outbound leaf 없음(modules.yaml 실측) | D9 에서 application-core port 경유로 해소. 대안(DB dead-letter row)도 명시 |
|
||||
| 파티션-소비자 배타 배정의 공식 근거 미수집 | `SOURCE_GAP-1` | D4 의 핵심 전제가 회사 블로그 1건에만 의존. `kafka.apache.org/documentation` 은 JS SPA 로 정적 fetch 불가(선례: `raw/official-docs/kafka-message-delivery-semantics-design.md` §URL Fetch 실패 기록) | §검증해야 할 주장에 등재. 공식 정적 페이지에서 verbatim 수집 후 D4 격상 |
|
||||
| spring-kafka 층의 ack 메커니즘 근거 미수집 | `SOURCE_GAP-5` | D2 가 프레임워크를 고정했는데 vault 에 `AckMode`/`Acknowledgment` 를 다루는 raw 가 0건이었다 | **대부분 해소**(2026-07-28 loop 1) — `raw/official-docs/spring-kafka-ack-mode-manual-commit-and-concurrency.md` 수집(`SPRK-ACKMODE-C1`~`C7`). ack 모드·기본값 `BATCH`·리스너 타입 제약·concurrency 하향 조정 확보. **잔여**: 일반 `acknowledge()` 의 호출 스레드 규칙과 ack 순서 제약 문장은 그 페이지에 **부재 확인**(인접 `nack()`·부분배치 제약만 존재) → §구현 가이드 2 의 잔여 `UNSUPPORTED_IMPL_DECISION` 으로 라벨링. 인접 페이지 "Manually Committing Offsets" 재조사 후보 |
|
||||
| 파티션 단위 pause API 근거 미수집 | `SOURCE_GAP-2` | 1차 수집분(컨테이너 레벨 pause/resume 페이지)에 `pausePartition`/`resumePartition` 부재(`SPRK-PAUSE-C5`) | **해소**(2026-07-28 loop 1) — `raw/official-docs/spring-kafka-pause-resume-partitions-on-listener-containers.md` 수집(`SPRK-PAUSEPART-C1`~`C5`). D5 근거 보강 완료. 단 **rebalance 시 pause 상태의 운명은 그 문서에도 없음이 확인**돼(`C5`) §구현 가이드 3 의 해당 행은 `UNSUPPORTED_IMPL_DECISION` 로 라벨링 |
|
||||
| 감사된 replay 의 외부 근거 부재 | `SOURCE_GAP-3` | Spring / Confluent / Uber / AWS 어느 문서도 "누가·언제·무엇을 재처리했는가" 의 audit trail 을 규정하지 않음(3개 조사에서 각각 확인) | §구현 가이드 4 의 `UNSUPPORTED_IMPL_DECISION` 로 라벨링. 최소 필드만 방향 제시 |
|
||||
| inbox TTL 수치의 외부 근거 부재 | `SOURCE_GAP-4` | CloudEvents / AWS Prescriptive Guidance / microservices.io 어디에도 수치 없음. 실무 관행값(~72시간, 15일)은 API-level idempotency-key 도메인 사례라 전용 불가 | D13 을 `UNSUPPORTED_DECISION` 으로 라벨링하고 관계식만 고정 |
|
||||
| consumer/inbox 계약 값 registry 미등록 | `REGISTRY_GAP` | `error-codes.yaml`·`metrics.yaml`·`env-keys.yaml` 에 consumer/inbox row 0건 (2026-07-28 grep) | D14 로 "신규 제안" 표기. 구현 branch 가 `owner_branch` 를 본 branch 로 등록 |
|
||||
| 게이트 루프 천장에서 종료 — depth 미통과 상태 | `GATE_CEILING` | `/branch-spec` 의 루프 천장(2회)에 도달했다. **coverage 는 `Covered`(Blocking 0) 로 통과**했으나 **depth 는 마지막 감사 시점에 `Not ready`(Blocking 1 / Should-fix 4 / Advisory 2)** 였다. Blocking 은 "정지(SIGTERM) 시점 in-flight 처리 계약 부재" 였고, 감사기가 제시한 처방(owner 링크 2건 + 엣지 1행 + stop 계약 1행, 새 조사 불필요)을 **감사 이후에 적용**했다 — 즉 **이 수정은 재감사로 검증되지 않았다** | 다음 세션에서 `/depth feature-kafka-consumer-inbox-contract` 를 먼저 재실행해 Blocking 해소를 확인할 것. 함께 적용한 Should-fix 4건(ack 핸들 보관·유휴 flush 라벨 / poll 예산 식 교정 / 배포 편차 엣지 2갈래 분리 + D15 2축 전제 / 워커 풀 소유·자원 상한)도 같은 재감사에서 확인 대상. **미적용 잔여**: `SOURCE_GAP-1`(D4 의 "파티션당 1 consumer" official 근거 — 기존 KafkaConsumer Javadoc 에서 claim 추가 추출로 닫힘) |
|
||||
| 초안이 존재하지 않는 위임처를 가리킴 | `FALSE_DELEGATION` | 초안 §엣지·의존이 "handler/schema/version allowlist 의 형식은 [[raw/branch-notes/feature-schema-serialization-contract]] 를 따른다" 고 적었으나, 2026-07-28 grep 결과 그 branch 는 handler/topic/routing 을 **한 번도 언급하지 않는다**(Avro/JSON·date/decimal 직렬화 전용). governing 설계 §4.2 L146 과 본 노트 §포함 범위가 모두 이 관심사를 **본 branch 것**으로 명시한다 | **D15 신설로 회수**(2026-07-28 loop 1). §엣지·의존의 위임 문장도 정정 — 그 계약과의 실제 접점은 payload 직렬화 형식뿐임을 명시 |
|
||||
| 동시성 모델의 선택 축이 성립하지 않았음 | `INCOHERENT_CRITERION` | 초안 D4 가 "파티션 수 이내면 단일 스레드, 그 이상이면 큐로 확장" 이라 썼는데, D4 자신이 파티션당 직렬을 못박으므로 두 형태의 병렬도 상한이 동일하다 — 확장 트리거가 성립 불가. 반면 §구현 가이드 2 의 시퀀스와 D5 의 "항상 pause" 는 큐 모델을 무조건 전제 | **D4 재작성**(2026-07-28 loop 1) — 축을 "poll 스레드를 처리 지연에서 분리할 필요가 있는가" 로 교체하고 큐 모델을 스켈레톤 기본으로 고정, 인라인 처리를 조건부 대안으로 강등 |
|
||||
| 의존 sibling 1종이 스캐폴딩 상태 | `DEPENDENCY_SCAFFOLD` | `depends_on` 중 **#063(producer)은 D1~D13 작성 완료**(2026-07-28 확인)이나 **#070(idempotency owner token)은 여전히 D-row 0개** — D12 의 재사용 판단 근거가 아직 문장 수준 | D12 를 `needs-approval` 로 유지. #070 의 `/branch-spec` 완료 후 재검토. #063 쪽 의존은 §엣지·실패·의존에 D-row 단위로 명시 완료 |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
해당 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -0,0 +1,623 @@
|
||||
---
|
||||
title: branch / feature-kafka-producer-runtime-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-kafka-producer-runtime-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||||
tags: [branch, ca-skeleton, kafka, producer, messaging, delivery-semantics]
|
||||
created: 2026-07-28
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-063
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-063
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-DELIVERY-SEMANTICS-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-EVENT-BROKER-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-060]
|
||||
imports: []
|
||||
delegates: []
|
||||
accepts_delegations: []
|
||||
contract_packet: 1
|
||||
---
|
||||
|
||||
# branch: feature-kafka-producer-runtime-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
> 2026-07-28 `/branch-spec` 1회차로 D-row 14개·§구현 가이드 10절·§Audit & Findings 10건을 채웠고, 같은 날 depth·coverage 게이트 1회차의 Blocking 5건을 보강했다(§진행 중 메모 게이트 이력). 코드 착수 전이며 아래 detail 은 코드로 확인한 것만 `actually-implemented` 로 표기했다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> 분해 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §4.2 — 기술 런타임 (Tier T). 본 branch 는 project §8.0 `WI-CA-SKELETON-OPERATIONAL-CONTRACT-063` 의 실행 단위다.
|
||||
|
||||
형제 branch (같은 부모의 다른 자식 — 인접 영역):
|
||||
|
||||
- [[raw/branch-notes/feature-messaging-multibroker-router]] — `MessageBroker` SPI·중앙 조립·fail-open/closed decorator 의 owner
|
||||
- [[raw/branch-notes/feature-kafka-consumer-inbox-contract]] — consumer·inbox (#064)
|
||||
- [[raw/branch-notes/feature-outbox-dispatch-mode-contract]] — outbox 행 모델·dispatch 모드 (#072)
|
||||
- [[raw/branch-notes/feature-domain-event-outbox-contract]] — envelope 필드·발행 실패 분류·per-aggregate FIFO 의 owner
|
||||
- [[raw/branch-notes/feature-capability-provider-selection-contract]] — 활성화 SSOT·readiness R0~R3 (#060, `depends_on`)
|
||||
- [[raw/branch-notes/feature-integration-adapter-templates]] — optional adapter 3-Layer 게이팅·`ADAPTER_DISABLED` owner
|
||||
- [[raw/branch-notes/feature-env-driven-runtime-configuration]] — `env-keys.yaml` registry 의 enforced SSOT owner (`APP_` prefix 통일 D2 · `verifyEnvKeys` 3-way drift D7). 본 branch 가 신규 등록할 13키가 그 계약의 적용을 받는다
|
||||
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
> project Work Item 에서 내려온 실행 계약의 snapshot. 여기에는 **pinned pointer + 1줄 요약 + branch 적용점**만 쓰고 상세를 복제하지 않는다.
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: acknowledgement·delivery timeout·순서·보안 설정 계약 test 가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-DELIVERY-SEMANTICS-001@1` | end-to-end 메시징 보증은 at-least-once 전달과 멱등 consumer·inbox로 표현하고 DB와 broker를 걸친 exactly-once를 주장하지 않는다 | D1 이 producer 측 보증을 "idempotent producer 가 **재전송 중복**만 제거한다"로 좁혀 표현하고, Kafka transactional producer 미채택을 명시한다 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-EVENT-BROKER-001@1` | core는 broker-agnostic outbox를 제공하고 Kafka는 optional adapter로 둔다 | D2 가 Kafka 런타임을 기존 `adapter:outbound:messaging` 모듈 안에 가두고, D3 의 포트 확장이 broker-agnostic 성질을 깨지 않도록 SDK 타입 비노출을 유지한다 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 상세 근거와 선택 조건은 아래 `결정-근거 매핑` 표의 동일 D-row 가 소유한다. 여기에는 요약과 관계만 둔다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | producer 보증 = idempotent producer 기반 재전송 중복 억제 + at-least-once. transactional producer 미채택 | `refines DEC-CA-SKELETON-OPERATIONAL-CONTRACT-DELIVERY-SEMANTICS-001@1` | `kafka-message-delivery-semantics-design.md#KAFKA-SEM-C3` | `proposed` |
|
||||
| D2 | Kafka producer 런타임은 기존 messaging 모듈 안에서 완결. seam 유지 + SDK auto-config 배제 | `refines DEC-CA-SKELETON-OPERATIONAL-CONTRACT-EVENT-BROKER-001@1` | `adapter-spring-boot-autoconfig-custom-starter.md#SBAC-C1` | `proposed` |
|
||||
| D3 | `MessageBroker.send` 를 transport-neutral 전송 결과 반환으로 확장 (SDK 타입 비노출) | `local` | `spring-kafka-sending-messages-kafkatemplate.md#SPRING-KAFKA-SEND-C2` | **`blocked-on-sibling`** — [[raw/branch-notes/feature-messaging-multibroker-router]] 의 SPI 확장 수용 결정이 선행돼야 착수 가능 (§Audit A9) |
|
||||
| D4 | 재시도 예산은 `retries` 횟수가 아니라 bounded `delivery.timeout.ms` | `local` | `kafka-producer-configs-delivery-timeout.md#KAFKA-PRODCFG-C2` | `proposed` |
|
||||
| D5 | 순서 보장은 파티션 단위. key=`aggregateId` 로 per-aggregate FIFO 대응 | `local` | `kafka-producer-javadoc-acks-idempotence.md#KAFKA-PROD-JD-C5` | `proposed` |
|
||||
| D6 | 처리량·크기 상한은 **키를 계약으로 고정하고 값은 배포 소유** | `local` | `kafka-producer-configs-delivery-timeout.md#KAFKA-PRODCFG-C5` | `proposed` |
|
||||
| D7 | `security.protocol` 명시 선택 + prod 에서 `PLAINTEXT` 거부, 자격증명은 secret tier | `local` | `kafka-client-security-ssl-sasl-config.md#KAFKA-SEC-C6` | `proposed` |
|
||||
| D8 | payload 는 사전 직렬화 문자열 + identity serializer. Schema Registry 는 조건부 opt-in | `local` | `internal-code-fact` + 대안 조사 (2026-07-28) | `proposed` |
|
||||
| D9 | graceful shutdown 은 flush→close 순서로 bounded 하게. 무기한 blocking 기본형 금지 | `local` | `kafka-producer-javadoc-acks-idempotence.md#KAFKA-PROD-JD-C8` | `proposed` |
|
||||
| D10 | `OutboundMessage` 에 headers 추가 — mdc-keys 의 `propagation: [message]` 4종 전파 | `local` | `internal-contract-registry` + sibling outbox D14 (위임 수신) | **`blocked-on-sibling`** — D3 과 같은 SPI 를 건드린다 (§Audit A9) |
|
||||
| D11 | producer 전용 error code 를 만들지 않는다 (기존 registry 어휘 재사용) | `local` | `internal-code-fact` | `proposed` |
|
||||
| D12 | 현행 readiness 는 `R0`. 본 branch 완료 = `R1`, `R2` 는 실 broker 통합 + runbook 확보 시 | `refines DEC-CA-SKELETON-OPERATIONAL-CONTRACT-EVENT-BROKER-001@1` | project note §36.1 (상속) | `proposed` |
|
||||
| D13 | 관측은 기존 dependency log 계약 재사용 + producer metric·span 은 신규 row **제안** | `local` | `internal-code-fact` | `proposed` |
|
||||
| D14 | producer capability descriptor 중 **이 branch 가 값을 정하는 필드**를 확정한다 (`requiredBackingRole` 포함, `multiInstanceSupported` 는 검증 전까지 안전한 `false`) | `local` | sibling [[raw/branch-notes/feature-capability-provider-selection-contract]] 구현가이드 1 (필드별 값 출처) + D12 (`failurePolicy` 맵 키 규범) | `proposed` |
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
없음 — 상속한 두 project 결정을 좁히기만 하고(D1·D2·D12 의 `refines`) 뒤집지 않는다.
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- `WI-CA-SKELETON-OPERATIONAL-CONTRACT-063` 의 완료 조건을 구현한다: acknowledgement·delivery timeout·순서·보안 설정 계약 test 가 통과한다
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- acknowledgement-aware send 결과 계약
|
||||
- `acks=all` + idempotent producer 설정
|
||||
- bounded delivery timeout 과 재시도 예산
|
||||
- key/partition 순서 보장 경계
|
||||
- 압축·배치 상한, TLS/SASL 과 secret 참조
|
||||
- 스키마 serializer 와 호환성 검증
|
||||
- graceful flush·shutdown
|
||||
- producer 발행의 관측 계약(로그 필드 재사용 + metric·span 의 소유 경계)
|
||||
- producer capability descriptor 중 이 branch 가 값을 정하는 필드
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- consumer·inbox — #064 소유
|
||||
- outbox 행 모델과 dispatch 모드 — #072 소유
|
||||
- Kafka producer 트랜잭션을 DB write 와 원자로 주장하는 것
|
||||
- project decision registry 변경 — owner 는 project-note
|
||||
- JSON 필드 명명·날짜·decimal·unknown field 정책 — [[raw/branch-notes/feature-schema-serialization-contract]] 소유 (D8 이 위임)
|
||||
- relay 의 attempt 카운트·`FAILED`/`DEAD` 전이 판정 — [[raw/branch-notes/feature-domain-event-outbox-contract]] D13 + #072 소유 (D4 가 경계 명시)
|
||||
- broker vendor·관리형 서비스 선택 — 분해 설계 §3.2 Out of scope
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 2026-07-28 `/branch-spec` 에서 5건을 신규 수집(전량 verbatim + self-grep 검증)하고 4건을 기존 raw 에서 재사용했다.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/spring-kafka-sending-messages-kafkatemplate]] | messaging 포트가 `void send` 대신 acknowledgement-aware 한 전송 결과를 반환해야 하는지, 그 결과 타입이 SDK 타입(`SendResult`)인지 프로젝트 자체 값 타입인지 판단할 메커니즘 근거(반환 타입 `CompletableFuture<SendResult<K,V>>`, `SendResult` 구성, `get()` 동기 대기, `flush()`, 실패 전달 방식) — D3·D9 |
|
||||
| [[raw/official-docs/kafka-message-delivery-semantics-design]] | at-least-once/exactly-once 정의, idempotent producer 의 broker-ID+sequence-number 중복 제거 메커니즘, transactional producer 의 Kafka 내부(토픽 간) 원자성 범위 — `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-DELIVERY-SEMANTICS-001@1` 상속 결정의 공식 문장 근거 (D1). **한계**: "파티션 단위" 정확한 문구는 이 자료에 없음(dedup 은 producer-ID+sequence-number 로만 서술) → 순서 축 근거는 아래 javadoc 이 담당. **또한 이 자료는 Confluent 미러다** (Apache 원문 페이지가 JS 렌더링 SPA 라 정적 fetch 불가 — §Audit A7) |
|
||||
| [[raw/official-docs/kafka-producer-javadoc-acks-idempotence]] | `acks=all` + idempotent producer 기본값 확정(`enable.idempotence=true` → `acks=all`/`retries=MAX_VALUE` 자동), 파티션 단위 순서 보장과 그 실패 경로(`OutOfOrderSequenceException`), graceful `flush()`/`close()` blocking semantics, `buffer.memory`/`max.block.ms` backpressure — D1·D5·D9. **한계**: `max.in.flight.requests.per.connection` 수치 근거는 이 자료 범위 밖(negative control 0건) — 아래 참조 |
|
||||
| [[raw/official-docs/kafka-producer-configs-delivery-timeout]] | 재시도 예산을 `retries` 횟수가 아니라 bounded `delivery.timeout.ms`(기본 120000ms, ≥ request.timeout.ms + linger.ms)로 표현하는 근거, `max.in.flight.requests.per.connection`(기본 5, idempotence 활성 시 상한 5) 과 순서 보장의 정확한 관계, `acks`(기본 all)/`enable.idempotence`(기본 true) 상호 제약, `compression.type`/`batch.size`/`linger.ms`/`buffer.memory`/`max.block.ms` 압축·배치·요청 크기 상한값 — D4·D5·D6 |
|
||||
| [[raw/official-docs/kafka-client-security-ssl-sasl-config]] | TLS/SASL 설정 키 이름을 공식 키 이름으로 확정 — `security.protocol`(PLAINTEXT/SSL/SASL_PLAINTEXT/SASL_SSL) 값 집합, SSL client `ssl.truststore.location`/`ssl.truststore.password`/`ssl.keystore.location`/`ssl.keystore.password`/`ssl.key.password`, SASL client `sasl.mechanism`(GSSAPI/PLAIN/SCRAM-SHA-256/SCRAM-SHA-512/OAUTHBEARER)/`sasl.jaas.config`. `sasl.jaas.config` 값과 keystore/truststore 비밀번호를 secret 분류 대상으로 지정하는 근거(자격증명 평문 저장·전송 경고 포함) — D7 |
|
||||
| [[raw/official-docs/spring-boot-graceful-shutdown-reference]] | graceful shutdown 이 context close 의 일부이며 SmartLifecycle 정지의 **가장 이른 phase** 에서 수행된다는 점(`SB-GS-C2`), 기존 요청만 완료 허용(`C3`), timeout property(`C4`) — D9 의 종료 순서 |
|
||||
| [[raw/official-docs/spring-smartlifecycle-reference]] | phase 순서(낮은 phase 먼저 start, 역순 stop — `SPRING-SMARTLC-C3`), `stop(Runnable)` 의 phase 단위 timeout 대기(`C7`), stop 없이 destroy 될 수 있음(`C8`) — D9 |
|
||||
| [[raw/official-docs/adapter-spring-boot-autoconfig-custom-starter]] | auto-configuration 이 보통 `@ConditionalOnClass` 로 조건화된다는 메커니즘(`SBAC-C1`), `@ConditionalOnProperty` 기본 매칭 규칙(`SBAC-C3`) — D2 의 "SDK 를 classpath 에 올리면 auto-config 가 함께 켜질 수 있다"는 위험 판단 |
|
||||
| [[raw/official-docs/schema-avro-evolution-rules]] · [[raw/official-docs/schema-protobuf-vs-json-evolution]] | Avro/Protobuf 가 **스키마 층에서** 강제하는 호환성 규칙(`SAER-C1`~`C4`, `SPVJ-C1`~`C5`) — D8 이 "JSON 문자열 경로에는 동등 강제 장치가 없다"는 비보증을 세우는 대조군 |
|
||||
| (다음 회차 수집 대상 — 대안 조사가 URL 을 특정했으나 이번 회차 bound 초과로 **deferred 7건**) | Confluent Schema Registry serdes(공통/Avro/Protobuf/JSON Schema) 4건 + Kafka producer serializer config 1건 + 사례 2건(LinkedIn Avro, Clear Street Avro→Protobuf 전환). D8 의 조건부 분기(Registry 도입 시)를 근거화하려면 필요 |
|
||||
|
||||
**프로젝트 내부 설계 참조 (등급 `internal-design-doc` — 공식 문서 아님, best practice 로 격상 금지):**
|
||||
|
||||
- ca-tmpl `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md` — **본 세션에서 파일 부재 확인**(gitignored, §Audit A7). §11.3/§11.4 참조는 검증 불가하므로 어떤 결정의 근거로도 쓰지 않았다
|
||||
- llm-wiki `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §4.2(소유 관심사)·§10(Kafka client = spring-kafka, producer 먼저)·§3.2(성능 수치 주장 금지)
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [x] `/branch-spec feature-kafka-producer-runtime-contract` 로 D-row 14개·§구현 가이드 10절·§Audit 10건 작성 (depth·coverage 게이트 1회차 보강 포함) — 등급: `documented-only`
|
||||
- [ ] **미결 1 — D3·D10 의 착수 차단 해소 (사용자 결정, 조사로 못 메움)**: (a) [[raw/branch-notes/feature-messaging-multibroker-router]] 에 "SPI 반환 확장 + headers 수용" amendment 결정을 신설하고 그 ID 를 D3·D10 Supporting 에 연결, 또는 (b) D3·D10 을 본 branch 범위에서 제외하고 완료 조건의 "acknowledgement" 항목과 `adapter-contract:kafka-send-receipt` test 를 재정의 — 등급: `planned`
|
||||
- [ ] **미결 2 — 값 확정 3건**: (1) 배제할 Kafka auto-config FQN 목록(SDK 도입 시), (2) `capabilityId` 문자열(sibling 어휘 미결에 종속), (3) `request_id`·`trace_id`·`tenant_id` 의 message header 이름(`correlation_id` 는 `headers.yaml:165-166` 으로 확정됨) — 등급: `planned`
|
||||
- [ ] **D8 gap 닫기** — outbox payload 를 덮는 빌드 단계 gate 가 **없음이 확인**됐다. (i) 전용 schema snapshot test 를 본 branch 범위로 끌어오거나 (ii) Registry serializer 분기를 앞당긴다 — 등급: `planned`
|
||||
- [ ] **역참조 전파** — [[raw/branch-notes/feature-kafka-consumer-inbox-contract]](#064, 이미 채워짐)가 본 노트의 D1·D2·D5·D7·D11·D13 을 인용한다. 이번 회차에서 D2(A8 정정 + half ② 불채택)·D8·D11·D13·D14 의 의미가 보강됐으므로 `/sync` 로 참조 요약의 최신성을 대조해야 한다 — 등급: `planned`
|
||||
- [ ] **`/depth` 3회차 재판정** — 2회차 Blocking 3건 보강분(§7 half ② 불채택 · §7-1 19키 3열표 · §9 `failurePolicy` 키 재작성)은 **아직 게이트를 통과하지 않았다**. `/branch-spec` 루프 천장 2회 도달로 이번 세션은 종료 — 등급: `planned`
|
||||
- [ ] **후속 registry 과제 2건** — (1) `KafkaSender` javadoc 의 "The skeleton carries no Kafka SDK dependency" 문장은 D2 채택 시 사실이 아니게 되므로 코드와 함께 정정, (2) `headers.yaml` 에 **message-header 이름 필드**가 없어 `correlation_id` 이름이 주석으로만 존재한다 — registry owner 에게 필드 신설 제안 — 등급: `planned`
|
||||
- [ ] **외부 consumer 팀 존재 여부 확인** — D8 의 조건부 분기가 여기에 종속된다(0개면 현행 유지, 2개 이상이면 Registry serializer 검토) — 등급: `planned`
|
||||
- [ ] A1 runbook 의 `APP_MESSAGING_KAFKA_ENABLED` 잔존 정정 (owner 협의) — 등급: `planned`
|
||||
- [ ] A2·A3 `OUTBOX_PUBLISH_FAILED` 의 registry `log_level: ERROR` ↔ 코드 WARN 불일치 + `error_code` 필드 부재 해소 — 등급: `planned`
|
||||
- [ ] A5 Kafka secret row 를 `secrets-classification.yaml` 에 신규 등록 — 등급: `planned`
|
||||
- [ ] A6 producer metric row 를 `metrics.yaml` 에 신규 등록 — 등급: `planned`
|
||||
- [ ] deferred 7건(Schema Registry serdes 공식 문서 4 + serializer config 1 + 사례 2) 수집 — 등급: `planned`
|
||||
- [ ] acknowledgement·delivery timeout·순서·보안 설정 계약 test 가 통과한다 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- **게이트 이력 (2026-07-28, 최종)**: **coverage 2회차 = `Covered`**(Blocking 0 / Should-fix 0 / Advisory 7 — Advisory 는 전부 `R2` 승급 시점 또는 sibling 종속 항목). **depth 는 2회차에도 `Not ready`**(Blocking 3 / Should-fix 4 / Advisory 3)였고 그 3건을 다시 보강한 뒤 `/branch-spec` 의 **루프 천장 2회에 도달해 종료**했다 — 3회차 재판정은 다음 세션 몫이다.
|
||||
- **2회차 depth 가 잡은 3건(전부 보강 완료, 재판정 전)**: (1) §7-1 이 "`spring.kafka.*` 를 쓰지 않는 이유 = 배제가 죽여서" 라고 적었는데 §7 의 half ②(재수입)가 그 배제를 되살리므로 **자기모순**이었다 → half ② 를 **불채택**으로 정정하고 근거를 "보증 키의 이중 소스 금지(D1)" 로 교체, Mongo 선례가 반대 선택(벤더 네임스페이스 채택)을 한 사실과 그 divergence 이유를 명시. (2) "①→② 는 relaxed binding 이 담당" 이 틀렸다 — `verifyEnvKeys` **check B** 가 placeholder 작성을 강제하므로 ①→② 는 **이름을 결정하는 작성 행위**다 → 19키 3열표 신설 + 두 `application.yml` 갱신 규범 추가("13키" 산식도 정정). (3) §9 의 `failurePolicy` 가 **owner 스키마를 위반**했다(키를 실패 종류가 아니라 연산명 `publish` 로 쓰고 같은 키를 두 번 배정) → 실패 종류 키로 재작성 + descriptor 1개/2개 미결을 `UNSUPPORTED_IMPL_DECISION` 으로 명시.
|
||||
- 2회차 Should-fix 4건도 함께 반영: D3 tie-break 입력·차단 범위 국소성 명시 · `requiredBackingRole` 을 도달성 주장이 아닌 **선언**으로 축소 · `multiInstanceSupported` 를 검증 전까지 **안전한 `false`** 로 · seam 공존 규칙(`@ConditionalOnMissingBean` + `KafkaSender` javadoc 정정 의무).
|
||||
- **게이트 이력 1회차**: depth **Not ready**(Blocking 4 / Should-fix 4 / Advisory 3), coverage **Not-covered**(Blocking 1 / Should-fix 1 / Advisory 8). 두 게이트의 Blocking 5건 + Should-fix 5건을 같은 세션에서 보강했다(아래).
|
||||
- depth Blocking 1 → **A8 이 틀렸다**. `spring.autoconfigure.exclude` 를 리터럴로 grep 해 0건이 나왔지만 YAML 중첩 표기라 **false negative** 였다. Mongo 가 이미 `exclude` + 게이트된 `@ImportAutoConfiguration` 2-half 패턴을 쓴다 → A8 정정, §7 라벨 해소, D2 승격.
|
||||
- depth Blocking 2 → §7-1 신설(env var → Spring property → Kafka client property 3층 매핑). `spring.kafka.*` 를 쓰지 않는 이유가 D2 의 auto-config 배제와 한 몸임을 명시.
|
||||
- depth Blocking 3 → [[raw/branch-notes/feature-env-driven-runtime-configuration]] D2·D7 을 형제·의존에 추가(신규 13키가 그 계약의 `verifyEnvKeys` 강제를 받는다).
|
||||
- depth Blocking 4 → D3·D10 을 `blocked-on-sibling` 로 표기하고 착수 차단 조건(a)(b)를 §TODO 미결 1로 승격.
|
||||
- coverage Blocking → **D14 신설 + §9 신설** — `requiredBackingRole = kafka-broker` 등 descriptor 기여값.
|
||||
- Should-fix 5건: header 이름(`headers.yaml:165-166` 으로 `correlation_id` 확정) · D8 전제 반증 확인 · A10 신설(envelope 필드 drift) · D11 조건 발동 후 의식적 거절 명시 · trace span 유예 스텁.
|
||||
- **2026-07-28 `/branch-spec` 1회차**. 외부 공식 문서 **5건 신규 수집**(전량 verbatim + self-grep 검증), 기존 raw **4건 재사용**, 대안 조사 **1건**(serializer 4후보), ca-tmpl 코드·registry **9개 영역** 확인.
|
||||
- **수집 중 확인된 근거 부재·한계를 결정에 그대로 반영했다** — 이것이 이 회차에서 가장 중요한 작업이다:
|
||||
- `KAFKA-SEM-C3` 은 "per-partition" 이라는 단어를 **쓰지 않는다**(수집 agent 의 negative control 로 확인). 따라서 D1 은 "파티션 단위" 를 이 claim 이 아니라 `KAFKA-PROD-JD-C5`(같은 파티션 콜백 순서)에서 끌어온다.
|
||||
- `KAFKA-PROD-JD-C3` 은 `max.in.flight ≤ 5` 를 **언급하지 않는다**(negative control 0건). 그 수치는 `KAFKA-PRODCFG-C3`·`C4` 가 담당한다.
|
||||
- `SPRING-KAFKA-SEND-C1` 은 future 가 **언제** 완료되는지를 진술하지 않는다. 그래서 D3 은 "broker 가 확인한 시점" 주장을 `KAFKA-PROD-JD-C1`(acks=all = full commit 대기)과 **병기**해야만 성립한다.
|
||||
- **`KAFKA-SEM-*` 은 Confluent 미러 인용이다.** `kafka.apache.org/documentation` 이 JS 렌더링 SPA 로 바뀌어 정적 fetch 가 redirect stub 만 돌려줬고, 지시대로 fallback 했다. producer configs·security 2건은 수집 agent 가 redirect 매핑을 추적해 **Apache 4.3 원문**(`/43/configuration/producer-configs/`, `/43/security/*`)에 도달했다.
|
||||
- **가장 큰 구조적 발견**: 이 branch 는 "설정값을 고르는 branch" 가 아니라 **포트 시그니처를 바꾸는 branch** 다. acknowledgement-aware send(D3)와 header 전파(D10)는 둘 다 sibling [[raw/branch-notes/feature-messaging-multibroker-router]] 가 소유·검증(`./gradlew check` 1254 pass)한 `MessageBroker`/`OutboundMessage` 를 건드린다. **협의 없이 진행하면 Single-Owner 를 깬다**(§Audit A9).
|
||||
- **두 번째 발견**: Kafka 3.0+ 기본값이 이미 idempotence 요건 3종을 충족하므로, 이 branch 의 설정 계약이 하는 일은 *값을 정하는 것*이 아니라 **보증을 깨는 override 를 거부하는 것**이다.
|
||||
- ca-tmpl 의 Kafka 는 현재 **R0 seam** 이다 — `KafkaSender` javadoc 이 "The skeleton carries no Kafka SDK dependency" 라고 직접 진술하고, `adapter/outbound/messaging/build.gradle` 에 Kafka 의존성이 0건이다.
|
||||
- **대안 조사 결과 요약 (D8)**: serializer 4후보(문자열 유지 / Avro+Registry / Protobuf+Registry / JSON Schema+Registry)를 비교했고, 조건부 권고는 "외부 consumer 팀 0개 + Registry 운영 인력 미배정이면 현행 유지" 다. 다만 조사가 **현행 유지의 전제가 이미 약하다**는 점을 지적했다 — sibling `feature-schema-serialization-contract` D5(drift gate)가 outbox payload 를 실제로 커버하는지 미확인이라, 그 커버리지 없이는 "빌드 단계에서 검증한다"가 허구가 된다. 이 요구를 D8 의 필수 후속 조건으로 넣었다.
|
||||
- **deferred 7건** — 대안 조사가 특정한 raw 수집 대상(Schema Registry serdes 4 + serializer config 1 + 사례 2)은 이번 회차 자동조사 bound(6) 안에 넣지 않았다. D8 의 **기본 분기**(현행 유지)는 코드 사실만으로 서고, deferred 는 **조건부 분기**(Registry 도입 시)를 근거화할 때 필요하다.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-07-28: producer 보증을 "idempotent producer 가 **재전송으로 인한** 중복만 제거한다"로 좁혀 표기 (D1) / 이유: 상속 결정이 DB↔broker exactly-once 를 금지하는데, Kafka javadoc 은 idempotent producer 를 "at least once → exactly once 로 강화" 라고 표현해 그대로 인용하면 금지된 주장이 된다 / 검토한 대안: transactional producer(`transactional.id`) 채택 — Kafka 내부 topic 간에만 유효(`KAFKA-SEM-C5`)하므로 DB→broker 경로에 이득이 없어 기각 / 근거: `raw/official-docs/kafka-message-delivery-semantics-design.md#KAFKA-SEM-C4`·`#KAFKA-SEM-C5`, `raw/official-docs/kafka-producer-javadoc-acks-idempotence.md#KAFKA-PROD-JD-C2`
|
||||
- 2026-07-28: 재시도 예산을 횟수가 아니라 시간으로 표현 (D4) / 이유: Kafka 공식이 "`retries` 를 두지 말고 `delivery.timeout.ms` 로 통제하라"고 직접 권고 / 검토한 대안: `retries` 상한 고정 — 공식 권고와 반대이며 `retries=0` 은 idempotence 요건(`retries > 0`)을 깨서 D1 과 충돌 / 근거: `raw/official-docs/kafka-producer-configs-delivery-timeout.md#KAFKA-PRODCFG-C2`·`#KAFKA-PRODCFG-C3`
|
||||
- 2026-07-28: Kafka 기본값을 뒤집지 않는 것을 기본 계약으로 채택 (D1·D6) / 이유: Kafka 3.0+ 는 `enable.idempotence=true`·`acks=all`·`retries=MAX_VALUE`·`max.in.flight=5` 가 이미 기본값이라 idempotence 요건 3종을 **아무 설정 없이 충족**한다. 계약이 할 일은 값을 새로 정하는 게 아니라 *뒤집는 override 를 거부*하는 것이다 / 근거: `#KAFKA-PRODCFG-C3`·`#KAFKA-PROD-JD-C3`
|
||||
- 2026-07-28: producer 전용 error code 를 만들지 않기로 결정 (D11) / 이유: sibling 이 이미 publish 실패(`OUTBOX_PUBLISH_FAILED`/`OUTBOX_DEAD_LETTER`)·비활성 어댑터(`ADAPTER_DISABLED`)·기동 검증(`STARTUP_VALIDATION_FAILED`) 어휘를 소유 / 검토한 대안: `KAFKA_PRODUCER_TIMEOUT` 신설 — registry owner 절차가 필요하고 4번째 유사 코드를 만들어 기각 / 근거: `internal-code-fact` (`docs/registries/error-codes.yaml` L724·L738·L875·L841)
|
||||
- 2026-07-28: serializer 는 현행(사전 직렬화 문자열)을 유지하되 **조건부 분기와 필수 후속 조건을 함께 명시** (D8) / 이유: 외부 인프라(Schema Registry)를 skeleton 기본으로 만들지 않는다는 원칙과, "빌드 단계 검증" 이라는 현행 전제가 실제로는 미확인이라는 조사 지적을 둘 다 반영해야 한다 / 검토한 대안: Avro+Registry(LinkedIn 사례) / Protobuf+Registry(Clear Street 사례) / JSON Schema+Registry(사례 미확보) / 근거: `internal-code-fact` + 2026-07-28 대안 조사(raw 아카이빙은 deferred)
|
||||
|
||||
<!-- section-id: decision-evidence -->
|
||||
## Decision Evidence Map / 결정-근거 매핑
|
||||
|
||||
> `Supporting Claims` 의 `raw/...#Cn` 은 verbatim 인용 + self-grep 검증을 마친 claim. `internal-code-fact` 는 ca-tmpl 코드를 직접 읽어 확인한 사실(경로·행 명시)이다.
|
||||
>
|
||||
> **근거의 두 축을 섞지 않는다.** 공식 문서가 증명하는 것은 *메커니즘*(Kafka/Spring 이 어떻게 동작하는가)이고, *정책*(그래서 우리는 무엇을 계약으로 삼는가)은 대부분 내부 결정이다. 각 행의 Evidence Strength 가 그 경계를 표시한다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | producer 측 보증은 **"idempotent producer 가 재전송으로 인한 중복을 로그에 남기지 않는 at-least-once"** 로 표현한다. `transactional.id` 기반 transactional producer 는 채택하지 않으며, 어떤 문서·로그·card 에도 DB 와 broker 를 걸친 exactly-once 를 적지 않는다 | 발행 경로가 **DB → broker**(outbox relay)일 때 이 결정. 발행 경로가 **Kafka topic → Kafka topic** 인 파이프라인이면 Kafka Streams 의 transactional EOS 가 실제 대안이 된다(`KAFKA-SEM-C5` 가 그 범위를 "between Kafka topics" 로 한정) — 그러나 ca-skeleton 의 발행원은 DB outbox 이므로 해당 없음 | `KAFKA-SEM-C1`(at-least-once 정의: "delivered one or more times ... may be delivered more than once"), `KAFKA-SEM-C3`(broker 가 producer 별 ID + sequence number 로 재전송 중복 제거), `KAFKA-SEM-C4`(transactional delivery 는 **Kafka 브로커에 대한** ack/재전송 동작), `KAFKA-SEM-C5`(내장 EOS 범위 = "between Kafka topics", 그 외 기본은 at-least-once), `KAFKA-PROD-JD-C2`(idempotent producer = 재시도가 중복을 만들지 않음), `KAFKA-PROD-JD-C4`(**단일 세션 내에서만** idempotence 보장). sibling: [[raw/branch-notes/feature-domain-event-outbox-contract]] D7(consumer 는 idempotencyKey dedupe 의무) | **메커니즘 `official-vendor-doc` / 보증 표현은 `internal-policy`**(상속 결정을 좁힌 것) | `KAFKA-PROD-JD-C4` 의 **세션 스코프**가 이 보증의 실질 한계다 — producer 가 재시작하면 새 PID 를 받으므로 재시작을 가로지르는 재전송은 중복을 만든다. outbox relay 는 재시작 후 `IN_FLIGHT` row 를 재claim 하므로 이 경로가 실재한다. 즉 **consumer dedupe 없이는 D1 이 유지되지 않는다** — 그런데 그 dedupe 를 소유하는 #064 는 registry 상 본 branch 에 depends 하는 *후속* 작업이다 |
|
||||
| D2 | Kafka producer 런타임은 **기존 `adapter:outbound:messaging` 모듈 안에서 완결**하고 신규 leaf 모듈을 만들지 않는다. `KafkaSender` seam 은 유지하되 skeleton 이 spring-kafka 기반 기본 구현을 제공하며, **broker 가 선택되지 않은 기동에서는 Kafka 관련 auto-configuration 이 활성화되지 않아야 한다** | inbound(consumer)는 신규 leaf 가 필요해 #064 가 모듈 registry migration(19→20)을 소유한다. outbound producer 는 이미 `MessageBroker` SPI 와 `KafkaMessageBroker` 가 존재하므로 **파일 추가만으로 끝난다** → 모듈 신설 안 함. 반대로 Kafka 전용 의존성이 다른 broker 구현까지 오염시킨다는 것이 확인되면 그때 leaf 분리 | `SBAC-C1`(auto-configuration 은 보통 `@ConditionalOnClass` + `@ConditionalOnMissingBean` 으로 조건화된다 — 즉 **classpath 존재만으로 켜지는 경로가 표준**), `SBAC-C3`(`@ConditionalOnProperty` 기본 매칭 규칙). `internal-code-fact`: `src/adapter/outbound/messaging/build.gradle`(Kafka 의존성 0건, `spring-boot-autoconfigure` 는 이미 존재), `.../kafka/KafkaSender.java:6-8`("The skeleton carries no Kafka SDK dependency — it is added by the project that enables Kafka"), `.../kafka/KafkaAdapterConfig.java:23`(`@ConditionalOnProperty(app.messaging.broker, havingValue="kafka")`), `.harness/project/modules.yaml`(19 모듈). **배제 패턴의 in-repo 선례** (`internal-code-fact`, 2026-07-28 A8 정정으로 추가): `src/app-bootstrap/src/main/resources/application.yml:6-22`(Mongo auto-config 3개를 `spring.autoconfigure.exclude` 로 무력화 + 주석이 "classpath-triggered, **independent of the module's `@ConditionalOnProperty` gate**" 를 관측 사실로 진술) 와 `src/adapter/outbound/persistence-mongo/.../mongo/MongoPersistenceConfig.java:22-42`(게이트된 `@ImportAutoConfiguration` 으로 재수입). `internal-design-doc`: 분해 설계 §10("Kafka client · spring-kafka · 도입 예정 · producer 먼저") | **메커니즘 `official-vendor-doc` + `internal-code-fact`(선례) / 모듈 배치·seam 유지 정책 `internal-policy`** | classpath 에 SDK 를 올리면 sibling [[raw/branch-notes/feature-capability-provider-selection-contract]] D13("비활성 capability 는 연결·워커·스키마·health contributor 를 만들지 않는다")을 깬다 — **이제 이것은 가설이 아니라 Mongo 에서 이미 관측된 동작**이다(A8 정정). 남은 위험은 두 가지로 좁혀졌다: (1) 배제할 Kafka auto-config **FQN 목록**이 SDK 도입 전에는 확정 불가(§구현 가이드 7 의 잔여 라벨), (2) 배제가 `spring.kafka.*` 바인딩까지 죽이므로 **§구현 가이드 7-1 의 property 매핑과 한 몸**이다 |
|
||||
| D3 | `MessageBroker.send` 의 반환을 `void` 에서 **transport-neutral 확인 결과**(topic·partition·offset·전송시각)로 확장한다. 반환 타입은 프로젝트 자체 값 타입이며 `SendResult`/`RecordMetadata` 같은 SDK 타입을 어댑터 밖으로 내보내지 않는다. fail-open 일반 publisher 는 결과를 **버리고**, fail-closed outbox 경로만 소비한다 | outbox relay 가 "무엇이 어느 partition/offset 에 커밋됐는지"를 진단·감사에 써야 하면 결과 반환. 정말 fire-and-forget 만 필요한 best-effort 통보 경로라면 `void` 유지가 더 단순하다 — 그래서 **두 포트 중 outbox 쪽만** 결과를 소비하도록 비대칭을 유지한다 | `SPRING-KAFKA-SEND-C1`(`send()` 는 `CompletableFuture<SendResult<K,V>>` 반환 — 즉 SDK 층에는 결과가 **이미 존재**한다), `SPRING-KAFKA-SEND-C2`(`SendResult` = `ProducerRecord` + `RecordMetadata`), `SPRING-KAFKA-SEND-C3`(동기 대기는 timeout 있는 `get()` 권장), `SPRING-KAFKA-SEND-C5`(실패는 `KafkaProducerException`+`producerRecord`), `KAFKA-PROD-JD-C1`(`acks=all` = full commit 대기 — **"broker 가 확인한 결과"라는 의미를 여기서 얻는다**). `internal-code-fact`: `core/MessageBroker.java:19`(`void send(...) throws Exception`), `core/MessagePublisher.java:7-8`("The `void` return keeps broker SDK types from escaping the adapter (B7)"), `outbox/OutboxMessagePublishAdapter.java:32-48`(결과를 쓰지 않음) | **메커니즘 `official-vendor-doc` / 포트 시그니처 변경은 `internal-policy` + sibling 협의 필요** | **착수 차단 조건 (`blocked-on-sibling`)** — 이 결정은 sibling [[raw/branch-notes/feature-messaging-multibroker-router]] D2(통합 단일 `MessageBroker` SPI)의 시그니처를 바꾸는데, 그 branch 는 이미 `./gradlew check` 1254 pass 로 머지·검증된 상태이고 **이 변경을 수용한 결정이 그쪽에 없다**(§Audit A9). 따라서 **다음 둘 중 하나가 선행되기 전에는 이 결정으로 코드를 건드리지 않는다**: (a) multibroker 노트에 "SPI 반환 확장 수용" amendment 결정을 신설하고 그 Decision ID 를 본 행의 Supporting 에 추가, 또는 (b) D3 을 본 branch 범위에서 제외하고 `adapter-contract:kafka-send-receipt` test 와 완료 조건의 "acknowledgement" 항목을 재정의. **tie-break 입력 (2회차 depth 게이트 지적 반영)**: "완료 조건의 *acknowledgement* 항목과 `adapter-contract:kafka-send-receipt` 를 **`R1` 판정에 필수로 둘 것인가**" 가 판정 기준이다 — 필수면 (a)(amendment 를 선행 작업으로 올린다), `R2` 로 미룰 수 있으면 (b)(descope 하고 완료 조건을 그만큼 좁힌다). 이 입력 자체는 사용자 소유이며 조사로 메울 수 없다 — §TODO 미결 1번. **차단 범위는 D3·D10 국소다** — 나머지 12개 결정과 §구현 가이드 1·4·5·7·7-1·8·9 는 차단 대상이 아니며 먼저 착수할 수 있다. 그때까지 D3 의 나머지 내용(반환 정보의 형태·비대칭 소비)은 *요구 명세*로만 유효하다. 부수 위험: `SPRING-KAFKA-SEND-C1` 의 한계상 "future 완료 = broker ack" 는 **`acks` 설정과 병기해야만** 참이라, `acks=0` override 시 반환된 offset 의 의미가 달라진다 |
|
||||
| D4 | producer 재시도 예산은 **`delivery.timeout.ms` 하나로 표현**하고 `retries` 는 건드리지 않는다. `delivery.timeout.ms ≥ request.timeout.ms + linger.ms` 정합을 **기동 시 검증**해 위반이면 기동을 거부한다 | Kafka 공식이 "일반적으로 `retries` 를 unset 으로 두고 `delivery.timeout.ms` 로 재시도 동작을 통제하라"고 직접 권고하므로 항상 이 결정. 예외는 `retries=0`(재시도 금지)을 명시해야 하는 경우인데, 그것은 idempotence 요건(`retries > 0`)을 깨므로 D1 과 충돌 → 채택 불가 | `KAFKA-PRODCFG-C1`("An upper bound on the time to report success or failure after a call to send() returns" + 기본값 120000 + "The value of this config should be greater than or equal to the sum of request.timeout.ms and linger.ms"), `KAFKA-PRODCFG-C2`("Users should generally prefer to leave this config unset and instead use delivery.timeout.ms to control retry behavior" + `retries` 기본 2147483647), `KAFKA-PRODCFG-C3`(idempotence 요건 중 `retries > 0`) | **`official-vendor-doc`** (정합 제약과 권고가 모두 벤더 진술) | **재시도 예산이 이중이 된다.** outbox relay 는 sibling [[raw/branch-notes/feature-background-job-async-contract]] D4 의 max attempts 3 + backoff 를 쓰고, 그 각 attempt 안에서 producer 가 최대 `delivery.timeout.ms` 동안 자체 재시도한다. 총 지연은 두 예산의 **곱**이며 그 상한을 아무도 계약하지 않았다 → §Claims To Verify. 또한 `retry.backoff.ms` 와 실제 재시도 횟수는 `KAFKA-PRODCFG-C2` 의 한계상 미증명 |
|
||||
| D5 | 순서 보장은 **파티션 단위로만** 주장한다. per-aggregate FIFO 는 `key = aggregateId` 로 파티션에 대응시켜 얻으며, (a) 파티션 수 변경, (b) `OutOfOrderSequenceException` 수신 후 계속 전송, (c) idempotence 비활성 + `max.in.flight > 1` + 재시도 활성 — 이 셋은 **명시적 비보증**이다 | 기본. 전역(topic 전체) 순서가 필요하면 파티션 1개 또는 단일 publisher 로 가야 하고 그것은 처리량을 포기하는 다른 설계다 — sibling outbox D6 이 이미 "global ordering 보장 안 함"을 소유하므로 본 branch 는 그 결정을 broker 층에서 **실현**만 한다 | `KAFKA-PROD-JD-C5`("Callbacks for records being sent to the same partition are guaranteed to execute in order"), `KAFKA-PROD-JD-C6`(`OutOfOrderSequenceException` 후 계속 보내면 out-of-order 가능, 순서를 지키려면 producer 를 close 하고 새 인스턴스 생성), `KAFKA-PRODCFG-C4`("if this configuration is set to be greater than 1 and enable.idempotence is set to false, there is a risk of message reordering ... if retries are disabled or if enable.idempotence is set to true, ordering will be preserved" + idempotence 시 ≤5 상한), `KAFKA-SEM-C3`(재전송이 로그 순서를 유지). `internal-code-fact`: `outbox/OutboxMessagePublishAdapter.java:34`(`new OutboundMessage(event.eventType(), event.aggregateId(), envelope)` — **topic=eventType, key=aggregateId 는 이미 구현·테스트됨**, `OutboxMessagePublishAdapterTest` javadoc L30-31). sibling: outbox D6(per-aggregate FIFO, global 비보증) | **`official-vendor-doc`**(순서 보존 조건이 전부 벤더 진술) + `actually-implemented`(key 매핑) | **파티션 수를 늘리면 같은 `aggregateId` 가 다른 파티션으로 갈 수 있고 그 순간 per-aggregate FIFO 가 깨진다** — 이 인과는 수집한 4건 어디에도 verbatim 이 없다(§Claims To Verify). 또한 `KAFKA-PROD-JD-C6` 의 복구 절차("producer 를 close 하고 새 인스턴스")는 D1 의 세션 스코프 한계와 정면으로 맞물린다 — **순서를 지키려고 producer 를 새로 만들면 idempotence 세션이 끊긴다** |
|
||||
| D6 | 처리량·크기 관련 설정은 **키를 계약으로 고정하되 값은 배포가 소유**한다. `batch.size`·`linger.ms`·`compression.type`·`max.request.size`·`buffer.memory`·`max.block.ms` 를 env registry 에 등록하고, skeleton 은 Kafka 기본값을 그대로 쓰며, 계약이 검증하는 것은 **값의 좋고 나쁨이 아니라 D1·D4·D5 를 깨는 override 인지** 여부다 | 값이 워크로드마다 달라지는 성능 파라미터 → 키만 계약. 값이 **보증을 결정**하는 파라미터(`acks`·`enable.idempotence`·`max.in.flight`)는 D1·D5 가 값까지 고정한다. 이 경계가 "설정 계약"과 "성능 튜닝"을 가른다 | `KAFKA-PRODCFG-C5`(`buffer.memory` 33554432 + `max.block.ms` 60000 → "the producer will block for max.block.ms after which it will fail with an exception" = **bounded** backpressure), `KAFKA-PRODCFG-C6`(`batch.size` 16384 / `linger.ms` 5, "whichever happens first"), `KAFKA-PRODCFG-C7`(`compression.type` 기본 `none`, 압축률이 배치 효율에 종속), `KAFKA-PROD-JD-C9`(buffer 소진 시 `max.block.ms` 후 `TimeoutException`). `internal-design-doc`: 분해 설계 §3.2("성능 수치·개선 서사 — '무엇에서 무엇으로 개선' 형태의 측정 주장은 만들지 않는다") | **기본값·메커니즘 `official-vendor-doc` / "값은 배포 소유" 는 `internal-policy`** | 어떤 `compression.type` 을 고를지는 `KAFKA-PRODCFG-C7` 이 명시적으로 증명하지 않는다(그 claim 의 한계에 기재) → §구현 가이드 1 의 `UNSUPPORTED_IMPL_DECISION`. 또한 `max.block.ms` 기본 60초는 **호출 스레드를 1분 붙잡는다** — outbox relay 스케줄러 스레드에서 이 값이 그대로 쓰이면 relay tick 이 밀린다. 이 상호작용은 아무 결정도 다루지 않는다 |
|
||||
| D7 | `security.protocol` 은 **명시 선택**이며 prod 프로파일에서 `PLAINTEXT` 값은 기동을 거부한다. TLS/SASL 자격증명(`ssl.*.password`, `ssl.key.password`, `sasl.jaas.config` 전체 문자열)은 `secrets-classification.yaml` 의 **`secret` tier** 로 등록하고 마스킹 규칙 `full` 을 적용한다 | broker 가 선택된 모든 배포에 적용. 로컬 dev 는 `PLAINTEXT` 를 허용하되 sibling 의 `__LOCAL_DEV_` sentinel 규약을 따른다. mTLS 필요 여부(=`ssl.keystore.*` 3키 추가)는 배포 환경이 정하며 계약은 **키의 분류**만 고정한다 | `KAFKA-SEC-C1`(4값 + "The plaintext protocol provides no security"), `KAFKA-SEC-C2`(단방향 최소 3키: `security.protocol`·`ssl.truststore.location`·`ssl.truststore.password`), `KAFKA-SEC-C3`(mTLS 추가 3키: `ssl.keystore.location`·`ssl.keystore.password`·`ssl.key.password`), `KAFKA-SEC-C5`(mechanism 5종: GSSAPI/PLAIN/SCRAM-SHA-256/SCRAM-SHA-512/OAUTHBEARER), `KAFKA-SEC-C6`("SASL/PLAIN should be used only with SSL as transport layer to ensure that clear passwords are not transmitted on the wire without encryption" + 기본 구현이 JAAS 설정에 자격증명을 평문으로 둔다). sibling: [[raw/branch-notes/feature-secrets-config-source-contract]](3-tier 분류·`__LOCAL_DEV_` prefix·masking `full`) | **키 목록·평문 위험 `official-vendor-doc` / prod 거부 정책 `internal-policy`** | `secrets-classification.yaml` 에 Kafka 관련 row 가 **0건**이다(§Audit A5) — 등록이 선행되지 않으면 `secrets-contract:*` 계열 검증이 이 키들을 아예 보지 못한다. 또한 `sasl.jaas.config` 는 **단일 문자열 안에 자격증명이 박히는** 형태라 기존 row 들의 "키 1개 = 값 1개" 마스킹 모델과 모양이 다르다 |
|
||||
| D8 | producer 가 받는 payload 는 **이미 직렬화된 UTF-8 문자열**이고 Kafka serializer 는 그것을 바이트로 옮기기만 한다(identity 급 `StringSerializer` 상당). Schema Registry 기반 serializer 는 skeleton 기본이 아니며 **조건부 opt-in** 이다. JSON 필드 명명·타입 규칙은 [[raw/branch-notes/feature-schema-serialization-contract]] 에 위임한다 | **외부 consumer 팀 0개 + Registry 운영 인력 미배정 → 현행 유지**(기본). **독립 배포 주기의 consumer 팀 ≥2 + Registry 운영 가능 → Registry serializer 검토** — 이때 JVM 중심·codegen 회피면 Avro, 성능/페이로드 우선 또는 gRPC 병행이면 Protobuf. 어느 쪽이든 **fail-closed outbox 경로에만 먼저** 도입하고 별도 게이트 플래그로 격리한다(fail-open 경로에 그대로 넣으면 스키마 위반이 broker 장애와 뭉개져 삼켜진다) | `internal-code-fact`: `outbox/OutboxEnvelopeJson.java:6-12`("no Jackson — the module deliberately keeps `jackson-databind` off its classpath" + "payload MUST already be a valid serialised JSON value; it is inserted verbatim"), `application/outbox/OutboxEvent.java:17`("payload serialised event payload (pre-serialised JSON string)"), `core/OutboundMessage.java:8-13`("already-serialized `payload` string ... deliberately does NOT depend on any broker SDK type"). 대조군(대안이 무엇을 강제하는가): `SAER-C1`~`C4`(Avro schema resolution — reader 에 default 없고 writer 에 필드 없으면 **error 신호**), `SPVJ-C2`·`SPVJ-C3`(Protobuf 는 삭제 필드 번호 재사용을 **금지·reserved 강제**), `SPVJ-C5`(JSON encoding 에서는 field name 재사용이 특히 위험). 2026-07-28 대안 조사 4후보 비교(raw 아카이빙은 **deferred 7건**) | **대안의 호환성 메커니즘 `official-standard` / 채택 자체는 `internal-policy` + `internal-code-fact`. 조건부 분기의 Registry 측 근거는 아직 `deferred`** | **현행 유지의 전제는 미확인이 아니라 사실상 반증됐다 (2026-07-28 depth 게이트 1회차 정정).** "호환성은 빌드 단계에서 검증한다"의 근거로 삼으려던 sibling [[raw/branch-notes/feature-schema-serialization-contract]] **D5** 를 직접 읽어 확인한 결과: 그 결정은 (a) 범위가 **response-side OpenAPI drift** 이고 outbox envelope payload 를 언급하지 않으며, (b) Evidence 등급 자체가 `UNSUPPORTED_DECISION`("외부 raw 직접 근거 없음")이고, (c) 그 행의 Open Risk 가 "**sibling branch 미작성/미착수 시 … 본 branch 완료 후에도 미보증 상태**" 라고 스스로 적는다. 즉 **outbox payload 를 덮는 빌드 단계 gate 는 현재 존재하지 않는다.** 따라서 D8 의 비보증 — "스키마 breaking change 는 producer 에서 아무 실패도 만들지 않고 통과하며 consumer 측 사후 관찰로만 발견된다" — 은 가설이 아니라 **현행 상태의 서술**이고, capability card 의 필수 비보증 문장으로 승격해야 한다. 이 gap 을 닫으려면 (i) 별도 gate 를 본 branch 범위로 끌어오거나 (ii) Registry serializer 조건부 분기를 앞당기는 두 길뿐이다 → §TODO |
|
||||
| D9 | 종료 시 producer 는 **web server 정지 이후** 남은 record 를 flush 하고 close 하며, 그 전체가 `spring.lifecycle.timeout-per-shutdown-phase` 안에서 끝나야 한다. 인자 없는 `flush()`/`close()` 는 **무기한 blocking** 이므로 그대로 쓰지 않고 timeout 있는 형태를 쓴다 | 항상. graceful shutdown 이 비활성인 배포(`server.shutdown=immediate`)라면 in-flight HTTP 요청 자체가 없으므로 flush 대상이 적지만, outbox relay 는 web 요청과 무관하게 돌므로 **relay 정지 → producer flush → close** 순서는 그대로 필요하다 | `SB-GS-C2`("It occurs as part of closing the application context and is performed in the earliest phase of stopping SmartLifecycle beans" — web server 가 **가장 먼저** 멈춘다), `SB-GS-C3`(기존 요청만 완료 허용), `SB-GS-C4`(`spring.lifecycle.timeout-per-shutdown-phase`), `SPRING-SMARTLC-C3`("When stopping, the reverse order is followed"), `SPRING-SMARTLC-C7`(`DefaultLifecycleProcessor` 가 phase 별로 callback 을 timeout 까지 대기), `SPRING-SMARTLC-C8`(stop 없이 destroy 될 수 있으므로 그 경로도 안전해야 함), `KAFKA-PROD-JD-C7`(`flush()` 는 buffer 를 즉시 전송 가능하게 만들고 **acks 설정에 따른 완료까지 블로킹**), `KAFKA-PROD-JD-C8`(`close()` 는 기본이 `close(Long.MAX_VALUE, MILLISECONDS)`), `SPRING-KAFKA-SEND-C4`(`linger.ms` 설정 시 flush 로 partial batch 즉시 전송) | **`official-vendor-doc`**(시점·순서·blocking 시맨틱 전부 벤더 진술) / **phase 수치 배정은 `internal-policy`** | `SB-GS-C2` 는 web server 가 **earliest phase** 에서 멈춘다고만 말하고 그 phase **수치**를 주지 않는다(그 claim 의 한계에 명시). producer 를 그보다 늦게 멈추려면 수치를 알아야 하는데 근거가 없다 → §구현 가이드 5 의 `UNSUPPORTED_IMPL_DECISION`. 또한 `KAFKA-PROD-JD-C7`+`C8` 의 무기한 blocking 과 `SPRING-SMARTLC-C7` 의 phase timeout 이 충돌하면 **timeout 이 먼저 끝나고 미전송 record 가 사라질 수 있다** → §Claims To Verify |
|
||||
| D10 | `OutboundMessage` 에 **headers 를 추가**하고, `mdc-keys.yaml` 이 `propagation: [http, async, message]` 로 선언한 4종(`request_id`·`trace_id`·`correlation_id`·`tenant_id`)을 broker record header 로 전파한다. header 이름은 mdc key 이름을 그대로 쓴다 | 항상 — registry 가 이미 `message` 경계 전파를 **선언**했는데 전파 수단이 없는 상태이기 때문이다. 반대로 envelope JSON 안에 이미 들어 있는 값(예: `correlationId`)은 **중복이 되므로**, header 는 "broker 층에서 payload 를 열지 않고 읽을 수 있어야 하는 값"에만 쓴다 | `internal-contract-registry`: `docs/registries/mdc-keys.yaml`(`request_id` L15-21·`trace_id` L30-36·`correlation_id` L61-67·`tenant_id` L78-84 가 모두 `propagation: [http, async, message]`), `docs/registries/headers.yaml:167-177`(`X-Correlation-Id` → `mdc_key: correlation_id`, `envelope_meta_field: correlationId`, owner `feature-operational-error-observability-foundation`). `internal-code-fact`: `core/OutboundMessage.java:15`(`record OutboundMessage(String topic, String key, String payload)` — **headers 필드 없음**), `support/OutboundCorrelation.java:14`(`MDC_KEY = "correlation_id"`), `outbox/OutboxEnvelopeJson.java:32-37`(envelope 이 이미 `correlationId`·`idempotencyKey` 를 담음). sibling: [[raw/branch-notes/feature-domain-event-outbox-contract]] D14 — "correlationId 의 broker message header 명명은 `UNSUPPORTED_IMPL_DECISION`(채택 broker 별 header 규약이 달라 구현 시 결정)" → **본 branch 가 그 위임을 수신** | **정책 `internal-contract-registry` 상속 / `correlation_id` header 이름은 `internal-contract-registry` 근거 있음 (2026-07-28 정정) / 나머지 3키 이름은 근거 없음** | **착수 차단 조건 (`blocked-on-sibling`)** — D3 와 같은 이유로 `OutboundMessage` 를 바꾸므로 multibroker amendment 가 선행돼야 한다(§Audit A9, §TODO 미결 1번). 나머지 위험: (1) `correlation_id` 를 제외한 3키(`request_id`·`trace_id`·`tenant_id`)의 header 이름은 근거가 없다 → §구현 가이드 6 의 잔여 `UNSUPPORTED_IMPL_DECISION`, (2) `tenant_id` 전파는 [[raw/branch-notes/feature-tenant-context-policy]] 의 allowlist 정책과 교차하므로 그 branch 확인 필요, (3) **envelope 과의 중복 판단이 §Audit A10 에 의해 바뀐다** — registry 는 `request_id`·`trace_id` 에 `envelope_field: meta.*` 를 선언하지만 실제 envelope 에는 그 두 필드도 `meta.` 계층도 없으므로, 그 둘에 대해 header 는 중복이 아니라 **유일한 전파 수단**이다 |
|
||||
| D11 | producer 전용 error code 를 **신설하지 않는다**. outbox 발행 실패는 `OUTBOX_PUBLISH_FAILED`/`OUTBOX_DEAD_LETTER`, 비활성 broker 호출은 `ADAPTER_DISABLED`, 설정 정합 위반은 `STARTUP_VALIDATION_FAILED` 를 쓴다 | 기존 어휘로 표현 가능한 실패 → 재사용. 기존 어휘 어디에도 대응되지 않는 실패가 나오면 **owner branch 의 registry 변경 절차**를 거친다(본 branch 단독 결정 불가). **이 트리거는 이미 발동했고 의식적으로 거절했다 (2026-07-28)** — `max.block.ms` 초과 `TimeoutException` 은 broker 실패가 아니라 client-side backpressure 라 `TRANSIENT_DEPENDENCY` 의 의미와 정확히 맞지 않는다. 그럼에도 신규 code 를 만들지 않고, **runbook 이 필요로 하는 분기는 로그의 `error` 필드(예외 타입·메시지, `FailOpenDependencyLogger.java:46-47`)로 대체**한다. 근거: 4번째 유사 code 의 비용이 진단 이득보다 크다는 판단이며, 이 판단이 틀리면(운영에서 두 실패를 구분 못 해 오진이 발생하면) 그때 registry 절차를 개시한다 | `internal-code-fact`: `docs/registries/error-codes.yaml` — `OUTBOX_PUBLISH_FAILED`(L724, category `TRANSIENT_DEPENDENCY`, retryable true, retry_after 30, log_level ERROR, owner `feature-domain-event-outbox-contract`), `OUTBOX_DEAD_LETTER`(L738, `INTERNAL`), `ADAPTER_DISABLED`(L875, owner `feature-integration-adapter-templates`), `STARTUP_VALIDATION_FAILED`(L841, owner `feature-migration-startup-contract`). 카테고리 실체: `src/shared-contract/src/main/java/dev/caskeleton/shared/error/Category.java`(10값 enum — `TRANSIENT_DEPENDENCY`·`PERMANENT_DEPENDENCY`·`INTERNAL` 존재). sibling: outbox D13(발행 실패 분류 owner), capability D10(신규 code 금지 선례) | `internal-code-fact` + `internal-cross-reference` | **producer 고유 실패가 기존 3개 어디에도 정확히 대응하지 않는다.** 예: `max.block.ms` 초과 `TimeoutException`(`KAFKA-PROD-JD-C9`)은 broker 장애가 아니라 **client 측 backpressure** 이고, `delivery.timeout.ms` 만료는 재시도 소진이다. 둘 다 `OUTBOX_PUBLISH_FAILED`(TRANSIENT)로 뭉뚱그리면 runbook 진단이 흐려진다 → §구현 가이드 8 의 분류 규칙(`UNSUPPORTED_IMPL_DECISION`) |
|
||||
| D12 | Kafka producer capability 의 현행 readiness 는 **`R0`**(타입·seam 만 존재)이다. 본 branch 의 계약 test 통과 = **`R1`**, 실 broker 통합 test + 보안 설정 + runbook 확보 = **`R2`**. R0 를 R2 로 표기하지 않으며, R2 를 주장할 때 capability card 에 **보증과 비보증을 둘 다** 적는다 | project note §36.1 등급 정의를 그대로 적용. 등급을 올리는 것은 문서가 아니라 **증거**다 — 증거가 다음 등급에 못 미치면 낮은 등급 유지가 기본 | project note §36.1(R0~R3 정의 + "R0 seam 을 R2 provider 로 표기하는 것" 금지)·§36.2(card 필수 13항목). sibling: [[raw/branch-notes/feature-capability-provider-selection-contract]] D8(등급별 증거)·D9(보증·비보증 + 경합 test 이름)·D15(§36.2 원문 상속). `internal-code-fact`: `kafka/KafkaSender.java:6-8`(SDK 없음 = R0 근거), `kafka/KafkaMessageBroker.java`·`KafkaAdapterConfig.java`(타입·게이팅만 존재) | `internal-policy`(project note 상속 — 재진술 아님) | sibling capability D8 자체가 `UNSUPPORTED_DECISION`(R0~R3 의 외부 근거 미확보)이다. 즉 **본 branch 의 등급 주장은 근거가 내부 정의에만 의존**한다. 승급을 자동 검사할 수단도 그 branch 의 미결 사항이라, 현 상태에서 R1 주장을 막는 장치는 리뷰뿐이다 |
|
||||
| D13 | 관측은 기존 `FailOpenDependencyLogger` 의 필드 계약(`dependency_name`·`dependency_type`·`operation`·`outcome`·`correlation_id`)을 재사용하고, producer 전용 metric 은 `metrics.yaml` 에 **신규 row 로 제안**한다(기존 row 재사용 아님). **trace span 은 `R1` 범위에서 만들지 않으며 `R2` 승급 시 결정한다** — 현재 producer 발행 구간의 span 은 0개다. fail-closed outbox 실패 로그는 registry 가 선언한 `log_level` 과 일치해야 한다 | 기존 필드로 표현 가능한 관측 → 재사용. producer 고유 차원(broker·전송 결과)이 필요한 관측 → 신규 metric row(등록 선행). **broker 경계를 넘는 trace 연결이 필요해지는 시점 = `R2` 주장 시점** — 그때 [[raw/branch-notes/feature-distributed-tracing-contract]] 의 carrier 계약을 consume 하며, 본 branch 는 D10 의 header 전파로 그 전제(`trace_id` 가 record header 에 실림)만 미리 만든다 | `internal-code-fact`: `support/FailOpenDependencyLogger.java:26-48`(필드 5종, 실패는 **WARN 고정** — javadoc L7-10 이 "these adapters are fail-open ... it is observed, not escalated" 라고 명시), `core/OutboundMessagePublisher.java:30-35`·`outbox/OutboxMessagePublishAdapter.java:37-46`(둘 다 같은 로거 사용), `docs/registries/metrics.yaml:342-395`(messaging/producer row **0건**, `outbox.*` 3종은 owner `feature-domain-event-outbox-contract`), `docs/registries/error-codes.yaml:733`(`OUTBOX_PUBLISH_FAILED` `log_level: ERROR`) | `internal-code-fact` + `internal-contract-registry` | **§Audit A2·A3 가 이 결정의 전제를 깬다** — fail-closed outbox 경로가 fail-open 용 WARN 로거를 쓰고 있어 registry 의 `ERROR` 선언과 어긋나고, 로그 라인에 `error_code`·`event_type`·`attempt_count` 가 아예 없어 runbook 절차가 성립하지 않는다. 두 문제는 본 branch 단독으로 못 고친다(로거 owner 는 support 모듈, code owner 는 outbox branch). span 은 `R1` 범위 밖으로 명시 유예했으므로 gap 이 아니라 **선언된 미착수**다 |
|
||||
| D14 | producer capability descriptor 의 필드 중 **값 출처가 "기술 런타임 branch" 로 지정된 것**을 본 branch 가 확정한다 — `requiredBackingRole = kafka-broker`(선택된 broker 의 endpoint 집합이 실제로 도달 가능해야 함), `guarantee` = D1 의 보증 문장, `multiInstanceSupported = true`(Kafka producer 는 인스턴스마다 독립 PID 를 받으므로 leader election 을 요구하지 않는다). `capabilityId` 문자열과 `readinessImpact` 는 본 branch 가 단독으로 정하지 않는다 | 값 출처가 sibling 표에서 "capability 별 branch" 또는 "기술 런타임 branch" 인 필드 → 본 branch 가 확정. "활성화 property 값"·"health group 파생 입력" 처럼 **플랫폼이 파생**하는 필드 → sibling 이 소유하고 본 branch 는 입력만 제공 | sibling [[raw/branch-notes/feature-capability-provider-selection-contract]] §구현 가이드 1 의 descriptor 필드표 — `guarantee` 는 "capability 별 branch 가 정의", `requiredBackingRole` 은 "기술 런타임 branch 가 정의", `readinessImpact` 는 그 branch D7 이 health group 을 파생하는 입력. 분해 설계 §4.2 가 본 branch 를 **Tier T(기술 런타임)** 으로 분류. `guarantee` 의 내용 근거는 D1 의 claim 집합 그대로 | `internal-policy`(sibling 결정 상속 — 필드 정의는 재진술하지 않고 값만 채움) | `multiInstanceSupported = true` 는 **`KAFKA-PROD-JD-C4`(idempotence 는 단일 세션 한정)에서 추론한 것**이지 벤더가 "multi-instance 안전" 이라고 말한 것이 아니다 — 인스턴스가 늘면 같은 aggregate 의 event 를 서로 다른 producer 가 보낼 수 있고 그때 파티션 단위 순서는 유지되나 **중복 억제는 인스턴스별로만** 성립한다. outbox relay 가 single-instance(sibling outbox D8)라 현재는 문제가 드러나지 않지만, multi-instance relay 를 켜는 순간 이 값의 의미를 재검토해야 한다 → §Claims To Verify. `capabilityId` 문자열은 sibling 의 어휘 미결(그 branch §구현 가이드 1 의 `UNSUPPORTED_IMPL_DECISION`)에 종속돼 지금 확정할 수 없다 |
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
> 3-rule meta principle 적용 — R1 각 sub-section 은 Decision ID + Claim ID reference, R2 근거 없는 detail 은 `UNSUPPORTED_IMPL_DECISION` + trade-off 한 줄, R3 본 branch 결정 범위 밖은 이관.
|
||||
>
|
||||
> 코드 위치 표기는 ca-tmpl repo 기준 상대 경로다. 본 branch 는 **계약 SSOT** 이고 실제 코드 작성은 ca-tmpl plan 이 소유한다 — 코드로 확인된 것만 `actually-implemented` 로 표기했고 나머지는 전부 `planned` 다.
|
||||
|
||||
### 1. Producer 설정 계약표
|
||||
|
||||
> **Trace**: D1(보증) · D4(재시도 예산) · D5(순서) · D6(상한) · `KAFKA-PRODCFG-C1`~`C7` · `KAFKA-PROD-JD-C1`·`C3`·`C9`
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `compression.type` 의 **값 선택**(`none`/gzip/snappy/lz4/zstd). `KAFKA-PRODCFG-C7` 은 기본이 `none` 이고 압축률이 배치 효율에 종속된다는 것만 말하고 **어느 코덱이 적절한지는 증명하지 않는다**(그 claim 의 한계에 명시). trade-off — 기본 `none` 유지는 CPU 를 안 쓰고 계약을 단순하게 두지만 네트워크·저장 비용을 그대로 내고, 코덱을 고르면 워크로드 측정 없이는 그 선택을 정당화할 수 없다(분해 설계 §3.2 가 성능 주장을 금지). **기본값 유지가 계약으로는 정직하나 결정 근거는 없다.**
|
||||
|
||||
계약의 성격을 **두 종류**로 나눈다 — 값까지 고정하는 키와, 키만 등록하고 값은 배포가 정하는 키.
|
||||
|
||||
| 설정 키 | 계약 성격 | Kafka 기본값 | 계약이 검증하는 것 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| `enable.idempotence` | **값 고정** (`true`) | `true` (3.0+) | `false` override 거부 | `KAFKA-PRODCFG-C3`, `KAFKA-PROD-JD-C3` |
|
||||
| `acks` | **값 고정** (`all`) | `all` (idempotence 활성 시) | `0`/`1` override 거부 — D3 의 "확인된 결과" 의미가 깨짐 | `KAFKA-PROD-JD-C1`·`C3`, `KAFKA-PRODCFG-C3` |
|
||||
| `max.in.flight.requests.per.connection` | **상한 고정** (≤ 5) | `5` | 5 초과 거부 (idempotence 요건) | `KAFKA-PRODCFG-C3`·`C4` |
|
||||
| `retries` | **건드리지 않음** | `2147483647` | 명시 설정 자체를 경고 — 예산은 `delivery.timeout.ms` 가 소유 | `KAFKA-PRODCFG-C2` |
|
||||
| `delivery.timeout.ms` | **정합 검증** | `120000` | `≥ request.timeout.ms + linger.ms` 위반 시 기동 거부 | `KAFKA-PRODCFG-C1` |
|
||||
| `request.timeout.ms` / `linger.ms` | 키 등록 + 정합 입력 | `linger.ms` = `5` (4.0 에서 0→5) | 위 정합식의 피연산자 | `KAFKA-PRODCFG-C1`·`C6` |
|
||||
| `batch.size` | 키 등록, 값은 배포 | `16384` | — (성능 파라미터) | `KAFKA-PRODCFG-C6` |
|
||||
| `compression.type` | 키 등록, 값은 배포 | `none` | — (위 `UNSUPPORTED_IMPL_DECISION`) | `KAFKA-PRODCFG-C7` |
|
||||
| `buffer.memory` / `max.block.ms` | 키 등록 + **bounded 성질 보존** | `33554432` / `60000` | `max.block.ms` 를 무한대로 두는 설정 거부 | `KAFKA-PRODCFG-C5`, `KAFKA-PROD-JD-C9` |
|
||||
| `max.request.size` | 키 등록, 값은 배포 | **본 회차 미수집** | — | 근거 미확보 → §Claims To Verify |
|
||||
|
||||
> **핵심**: Kafka 3.0+ 기본값이 이미 idempotence 요건 3종(`acks=all`·`retries>0`·`max.in.flight≤5`)을 충족하므로(`KAFKA-PRODCFG-C3`), 계약이 하는 일은 **값을 새로 정하는 것이 아니라 뒤집는 override 를 거부하는 것**이다. 이것이 이 branch 의 설정 계약이 "튜닝 가이드"가 아닌 이유다.
|
||||
|
||||
모든 키는 `APP_MESSAGING_KAFKA_*` 형태로 `docs/registries/env-keys.yaml` 에 등록한다(`planned`). 현재 등록된 messaging 키는 `APP_MESSAGING_BROKER`(L1290)·`APP_MESSAGING_KAFKA_BROKERS`(L1304) **2개뿐**이며 둘 다 owner 가 `feature-integration-adapter-templates` 다(`actually-implemented`) — 신규 row 의 `owner_branch` 는 본 branch 로 둔다.
|
||||
|
||||
### 2. Send 결과 계약과 포트 확장
|
||||
|
||||
> **Trace**: D3 · `SPRING-KAFKA-SEND-C1`·`C2`·`C3`·`C5` · `KAFKA-PROD-JD-C1`
|
||||
>
|
||||
> - **SDK 타입 비노출은 근거 있는 제약이다 (라벨 대상 아님)** — `internal-code-fact`: `core/MessagePublisher.java:7-8`("The `void` return keeps broker SDK types from escaping the adapter (B7)")·`core/OutboundMessage.java:8`("deliberately does NOT depend on any broker SDK type"). 즉 `SendResult`/`RecordMetadata` 를 그대로 반환하는 선택지는 애초에 없다.
|
||||
> - **UNSUPPORTED_IMPL_DECISION (범위 축소)**: 값 타입의 **모양·이름·`Optional` 여부**. 후보 — (a) `SendReceipt(String topic, int partition, long offset, Instant timestamp)` 값 record, (b) `Optional<SendReceipt>`(결과를 못 주는 broker 대비), (c) 기존 `void` 유지 + 별도 조회 포트. trade-off — (a)는 가장 단순하지만 결과를 못 주는 broker 구현이 생기면 거짓 값을 채워야 하고, (b)는 정직하지만 모든 호출부에 분기를 강요하며, (c)는 포트를 안 건드리지만 "무엇의 결과인지"를 다시 이어붙여야 한다. **(a)가 현행 record 스타일과 일관되나 결정 근거는 없다.**
|
||||
|
||||
- **현행 (`actually-implemented`)**: `core/MessageBroker.java:19` 가 `void send(OutboundMessage) throws Exception`. `core/MessagePublisher.java:7-8` 의 javadoc 이 `void` 반환을 **의도적 설계**(B7 — SDK 타입 비노출)로 명시한다.
|
||||
- **SDK 층에는 결과가 이미 있다** — `SPRING-KAFKA-SEND-C1`/`C2`(`CompletableFuture<SendResult>` = `ProducerRecord` + `RecordMetadata`). 즉 이 확장은 새 능력을 발명하는 게 아니라 **이미 버려지고 있는 정보를 포트 경계까지 끌어올리는 것**이다.
|
||||
- **"확인된"의 의미는 `acks` 에서 온다** — `KAFKA-PROD-JD-C1`("The default setting `all` will result in blocking on the full commit of the record"). `SPRING-KAFKA-SEND-C1` 만으로는 future 완료 시점을 말할 수 없다(그 claim 의 한계에 명시). 따라서 **D3 의 결과 계약은 §1 의 `acks=all` 고정과 한 쌍이며 분리해서 구현할 수 없다.**
|
||||
- **두 포트의 비대칭을 유지한다**: fail-open `OutboundMessagePublisher`(`core/OutboundMessagePublisher.java:27-36`)는 결과를 버리고 기존 동작을 그대로 두고, fail-closed `OutboxMessagePublishAdapter`(`outbox/OutboxMessagePublishAdapter.java:32-48`)만 결과를 소비한다. 이렇게 해야 sibling multibroker D4(두 실패 계약의 분리 보존)를 깨지 않는다.
|
||||
- 동기 대기가 필요하면 timeout 있는 형태를 쓴다(`SPRING-KAFKA-SEND-C3`). 실패는 `KafkaProducerException` 으로 오고 실패한 record 를 담고 있으므로(`SPRING-KAFKA-SEND-C5`) §8 의 분류 입력으로 쓸 수 있다.
|
||||
|
||||
> **OUT_OF_BRANCH_SCOPE**: `MessageBroker` SPI 자체의 **소유권**은 [[raw/branch-notes/feature-messaging-multibroker-router]] D2 다. 본 §은 *어떤 정보가 포트 경계를 넘어야 하는지* 의 요구만 정하고, 시그니처 변경은 그 branch 와의 협의·공동 PR 로 진행한다(§Audit A9).
|
||||
|
||||
### 3. 순서 계약과 파티션 키
|
||||
|
||||
> **Trace**: D5 · `KAFKA-PROD-JD-C5`·`C6` · `KAFKA-PRODCFG-C4` · sibling outbox D6
|
||||
|
||||
- **현행 (`actually-implemented`)**: `outbox/OutboxMessagePublishAdapter.java:34` 가 `new OutboundMessage(event.eventType(), event.aggregateId(), envelope)` — **topic = `eventType`, key = `aggregateId`**. `OutboxMessagePublishAdapterTest` javadoc(L30-31)이 이 매핑을 계약으로 검증한다.
|
||||
- 따라서 per-aggregate FIFO 를 위한 **키 매핑은 이미 존재**하고, 본 branch 가 추가하는 것은 그 위의 **보증·비보증 문장과 그것을 지키는 설정 조건**이다.
|
||||
|
||||
| 보증 | 조건 | 근거 |
|
||||
|---|---|---|
|
||||
| 같은 파티션으로 간 record 의 콜백은 전송 순서대로 실행 | 무조건 | `KAFKA-PROD-JD-C5` |
|
||||
| 재시도가 있어도 순서 보존 | `enable.idempotence=true` **또는** 재시도 비활성 | `KAFKA-PRODCFG-C4` |
|
||||
| idempotence 하에서 in-flight 5개까지 순서 보존 | `max.in.flight ≤ 5` | `KAFKA-PRODCFG-C4` |
|
||||
|
||||
| **비보증** (card 에 명시) | 이유 |
|
||||
|---|---|
|
||||
| topic 전역 순서 | 파티션 간 순서는 어떤 claim 도 보장하지 않음 (`KAFKA-PROD-JD-C5` 한계) |
|
||||
| 파티션 수 변경 후의 per-aggregate 순서 | 같은 key 가 다른 파티션으로 갈 수 있음 — **verbatim 근거 미확보**, §Claims To Verify |
|
||||
| `OutOfOrderSequenceException` 수신 후 계속 전송했을 때의 순서 | `KAFKA-PROD-JD-C6` 이 명시적으로 out-of-order 가능이라고 진술 |
|
||||
| producer 재시작을 가로지르는 중복 억제 | `KAFKA-PROD-JD-C4`(단일 세션 한정) |
|
||||
|
||||
- `OutOfOrderSequenceException` 복구 절차는 "producer 를 close 하고 새 인스턴스를 만든다"(`KAFKA-PROD-JD-C6`)이며, 이는 **idempotence 세션을 끊는다**(`KAFKA-PROD-JD-C4`). 즉 순서 복구와 중복 억제가 상충하므로 **복구 후 구간은 consumer dedupe 에 의존한다**는 것을 card 의 비보증에 적는다.
|
||||
|
||||
### 4. 보안 설정과 secret 등록
|
||||
|
||||
> **Trace**: D7 · `KAFKA-SEC-C1`~`C6` · sibling [[raw/branch-notes/feature-secrets-config-source-contract]]
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `sasl.jaas.config` 의 **분류 단위**. 후보 — (a) 문자열 전체를 하나의 `secret` row 로, (b) `username`/`password` 를 별도 키로 쪼개 조립, (c) Kafka 2.0+ 의 커스텀 callback handler 로 자격증명을 설정 밖에 두기(`KAFKA-SEC-C6` 이 존재만 언급). trade-off — (a)는 기존 row 모델과 모양이 가장 가깝지만 마스킹이 전부-가림이라 진단 시 어떤 mechanism 인지도 안 보이고, (b)는 진단은 쉬우나 JAAS 문자열 조립 코드를 우리가 소유하게 되며, (c)는 가장 안전하지만 Kafka 문서가 구현체를 권고하지 않아 우리가 설계해야 한다. **어느 쪽도 외부 근거가 없다.**
|
||||
|
||||
| 설정 키 | 분류 | 근거 |
|
||||
|---|---|---|
|
||||
| `security.protocol` | `public-config` (값 자체는 비밀 아님) | `KAFKA-SEC-C1` |
|
||||
| `ssl.truststore.location` | `public-config` (경로) | `KAFKA-SEC-C2` |
|
||||
| `ssl.truststore.password` | **`secret`** · masking `full` | `KAFKA-SEC-C2` |
|
||||
| `ssl.keystore.location` | `public-config` (mTLS 시) | `KAFKA-SEC-C3` |
|
||||
| `ssl.keystore.password` / `ssl.key.password` | **`secret`** · masking `full` | `KAFKA-SEC-C3` |
|
||||
| `sasl.mechanism` | `public-config` (5값 중 하나) | `KAFKA-SEC-C5` |
|
||||
| `sasl.jaas.config` | **`secret`** · masking `full` — 자격증명이 문자열 안에 평문으로 들어감 | `KAFKA-SEC-C6` |
|
||||
|
||||
규범:
|
||||
|
||||
- prod 프로파일에서 `security.protocol=PLAINTEXT` 는 **기동 거부**(`STARTUP_VALIDATION_FAILED`, D11). 근거는 `KAFKA-SEC-C1`("The plaintext protocol provides no security") + sibling `PROFILE_MISMATCH` 선례. 로컬 dev 는 허용.
|
||||
- SASL/PLAIN 을 쓴다면 `SASL_SSL` 과만 조합한다 — `KAFKA-SEC-C6` 이 "should be used only with SSL as transport layer" 라고 직접 진술한다. `SASL_PLAINTEXT` + `PLAIN` 조합은 거부.
|
||||
- 모든 `secret` row 는 sibling 규약을 따른다: `prod_default: null`, dev sentinel `__LOCAL_DEV_` prefix, masking `full`(진짜 secret). 현재 `secrets-classification.yaml` 에 Kafka row 는 **0건**이므로 전부 신규 등록이다(§Audit A5).
|
||||
|
||||
### 5. Graceful shutdown 순서
|
||||
|
||||
> **Trace**: D9 · `SB-GS-C2`·`C3`·`C4` · `SPRING-SMARTLC-C3`·`C7`·`C8` · `KAFKA-PROD-JD-C7`·`C8` · `SPRING-KAFKA-SEND-C4`
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: producer lifecycle 의 **phase 수치**. `SB-GS-C2` 는 web server 가 "earliest phase" 에서 멈춘다고만 하고 수치를 주지 않는다(그 claim 의 한계에 명시). 후보 — (a) `SmartLifecycle` 을 구현하고 web server 보다 **큰** phase 값(정지 역순이므로 producer 가 web 보다 **먼저** 멈춤 — 원하는 것과 반대), (b) web server 보다 **작은** phase 값(= 나중에 멈춤, 원하는 순서), (c) lifecycle 대신 `DisposableBean`/`@PreDestroy` 로 context close 후반에 처리. trade-off — (b)가 의도한 순서지만 "web 보다 작은 값"이 정확히 얼마인지 프레임워크 상수를 알아야 하고, (c)는 phase 계산이 필요 없으나 `SPRING-SMARTLC-C7` 의 phase timeout 보호를 못 받는다. **어느 쪽도 근거가 없고 선택은 §Claims To Verify 의 확인 결과에 종속된다.**
|
||||
|
||||
의도한 종료 순서 (`planned`):
|
||||
|
||||
1. **web server 정지** — 신규 요청 차단, 기존 요청만 완료 (`SB-GS-C2`·`C3`). 이미 Spring Boot 기본 동작이다.
|
||||
2. **outbox relay 스케줄러 정지** — 새 batch claim 중단. owner 는 [[raw/branch-notes/feature-background-job-async-contract]] 이며 본 branch 는 순서 요구만 건다.
|
||||
3. **producer `flush()`** — buffer 의 남은 record 를 전송하고 `acks` 기준 완료까지 대기 (`KAFKA-PROD-JD-C7`). `linger.ms` 기본이 5ms 이므로 partial batch 가 실재한다 (`KAFKA-PRODCFG-C6`, `SPRING-KAFKA-SEND-C4`).
|
||||
4. **producer `close(timeout)`** — 인자 없는 `close()` 는 `close(Long.MAX_VALUE)` 와 같아 **무기한 블로킹**이다 (`KAFKA-PROD-JD-C8`). 반드시 timeout 형을 쓴다.
|
||||
|
||||
전체는 `spring.lifecycle.timeout-per-shutdown-phase` 안에서 끝나야 한다 (`SB-GS-C4`, `SPRING-SMARTLC-C7`). `SPRING-SMARTLC-C8` 에 따라 **`stop()` 없이 destroy 되는 경로**도 안전해야 하므로, 이미 닫힌 producer 를 다시 닫아도 문제없게 만든다.
|
||||
|
||||
> **위험**: 3·4 의 blocking 시간이 phase timeout 을 넘으면 `SPRING-SMARTLC-C7` 의 대기가 먼저 끝나고 **미전송 record 가 조용히 사라질 수 있다**. 이 조합은 수집한 어떤 claim 도 다루지 않는다 → §Claims To Verify. 유실이 실재하면 outbox row 는 `IN_FLIGHT` 로 남아 재claim 되므로 **유실이 아니라 중복**으로 전환되며, 그 경우 D1 의 비보증과 정합한다.
|
||||
|
||||
### 6. Message header 전파
|
||||
|
||||
> **Trace**: D10 · `mdc-keys.yaml` · `headers.yaml` · sibling outbox D14(위임 수신)
|
||||
>
|
||||
> - **`correlation_id` 의 header 이름은 근거 있는 결정이다 (2026-07-28 depth 게이트 1회차로 라벨 축소)**: `internal-contract-registry (주석 인용 — 기계 강제 필드 아님)`. **2회차 단서**: `headers.yaml` 의 *필드*는 `mdc_key`·`envelope_meta_field` 뿐이고 message header 이름을 담는 필드가 없다. 아래 근거는 그 row 위의 **source 주석**(다른 branch note 를 인용한 것)이므로 어떤 test 도 이 이름을 강제하지 않는다 — registry 에 message-header 이름 필드를 신설하는 것이 후속 과제다(§TODO). 그 한계를 감안하고도 이름 자체는 근거가 있다: `docs/registries/headers.yaml:165-166` 의 source 주석이 `"correlation_id | inbound header X-Correlation-Id 또는 생성 | HTTP X-Correlation-Id, **message header correlation_id**"` 로 **message 경계의 header 이름을 이미 명시**한다(owner `feature-operational-error-observability-foundation`). 따라서 후보 (a)(mdc key 이름 그대로)를 채택하며, `OutboundCorrelation.MDC_KEY` 상수를 그대로 재사용한다.
|
||||
> - **남은 `UNSUPPORTED_IMPL_DECISION`**: 나머지 3키(`request_id`·`trace_id`·`tenant_id`)의 header 이름. registry 는 이들에 대해 message header 이름을 적지 않았다. trade-off — `correlation_id` 와 같은 규칙(mdc key 이름 그대로)을 확장 적용하는 것이 일관되지만 그것은 *유추*이고, 키마다 별도 명시를 요구하면 정확하나 registry owner 의 결정을 기다려야 한다. **일관성 확장이 우세하나 registry 진술은 `correlation_id` 한 건뿐이다.**
|
||||
|
||||
- **현행 (`actually-implemented`)**: `core/OutboundMessage.java:15` 는 `(topic, key, payload)` 3필드로 **header 를 담을 자리가 없다**. 반면 `mdc-keys.yaml` 은 4개 키에 `propagation: [http, async, message]` 를 이미 선언했다 — **선언은 있고 수단이 없는 상태**다.
|
||||
- 전파 대상 (registry 선언과 1:1):
|
||||
|
||||
| mdc key | registry 선언 | header 로 전파 | 이유 |
|
||||
|---|---|---|---|
|
||||
| `correlation_id` | `propagation: [http, async, message]` | ✅ | broker 층 진단이 payload 를 열지 않고 추적할 수 있어야 함 |
|
||||
| `request_id` | 동일 | ✅ | 동일 |
|
||||
| `trace_id` | 동일 | ✅ | 동일 |
|
||||
| `tenant_id` | 동일 | ⚠️ 조건부 | [[raw/branch-notes/feature-tenant-context-policy]] 의 allowlist 정책 확인 후 |
|
||||
|
||||
- **중복 주의**: `outbox/OutboxEnvelopeJson.java:32-37` 이 envelope 안에 이미 `correlationId` 를 담는다. header 는 그 값을 **대체하지 않고 병행**하며, 둘이 어긋나면 envelope 이 SSOT 다(payload 가 감사 기록이므로).
|
||||
- 값이 없을 때는 `OutboundCorrelation.UNKNOWN`(`"unknown"`, `support/OutboundCorrelation.java:17`)을 그대로 쓴다 — header 를 생략하면 "전파했는데 비었다"와 "전파 자체를 안 했다"를 구분할 수 없다.
|
||||
|
||||
> **OUT_OF_BRANCH_SCOPE**: mdc key 의 **의미·생성 규칙**은 `feature-operational-error-observability-foundation` 이, `X-Correlation-Id` HTTP header 는 `headers.yaml` 의 같은 owner 가 소유한다. 본 §은 *broker 경계에서의 표현*만 정한다.
|
||||
|
||||
### 7. 활성화·모듈 배치와 auto-config 배제
|
||||
|
||||
> **Trace**: D2 · D12 · `SBAC-C1`·`SBAC-C3` · sibling capability D13 · sibling integration-adapter-templates D2(Layer 1)
|
||||
>
|
||||
> - **배제 수단은 근거 있는 결정이다 (2026-07-28 depth 게이트 1회차로 라벨 해소 — §Audit A8 정정)**: **in-repo 선례를 그대로 채택**한다 — `spring.autoconfigure.exclude` 로 Kafka auto-configuration 을 무력화하고, `app.messaging.broker=kafka` 로 게이트된 `@ImportAutoConfiguration` 으로 되살리는 **2-half 패턴**. `internal-code-fact`: `src/app-bootstrap/src/main/resources/application.yml:6-22` + `src/adapter/outbound/persistence-mongo/.../mongo/MongoPersistenceConfig.java:22-42` 가 Mongo 에 대해 이 패턴을 완성형으로 구현했고, javadoc 이 두 half 가 왜 **둘 다** 필요한지("`ImportAutoConfiguration` is an *explicit* import that `spring.autoconfigure.exclude` does not affect")까지 진술한다. 즉 후보 (b)(별도 모듈 격리)·(c)(검출만)는 선례와 어긋나므로 채택하지 않는다.
|
||||
> - **남은 `UNSUPPORTED_IMPL_DECISION`**: 배제할 **auto-configuration 클래스 FQN 목록**. Mongo 는 3개(`MongoAutoConfiguration`·`DataMongoAutoConfiguration`·`DataMongoRepositoriesAutoConfiguration`)를 하드코딩했는데, Kafka 쪽 대응 목록은 spring-kafka 를 실제로 올려보기 전에는 확정할 수 없다. trade-off — FQN 하드코딩은 Spring Boot 버전 업그레이드 때 깨질 수 있으나(Mongo 선례도 같은 부채를 이미 지고 있다), 목록을 비워 두면 배제 자체가 성립하지 않는다. **선례와 같은 부채를 지는 쪽을 택하되 목록 확정은 SDK 도입 시점으로 미룬다.**
|
||||
|
||||
- **현행 (`actually-implemented`)**: `kafka/KafkaAdapterConfig.java:23` 이 `@ConditionalOnProperty(name="app.messaging.broker", havingValue="kafka")` 로 게이트하고, `MessagingConfig.java:49-71` 이 미선택 시 `Disabled*` sentinel 을 바인딩하며, 선택했는데 기여 bean 이 없으면 `IllegalStateException` 으로 기동을 실패시킨다. 즉 **우리 코드의 게이팅은 이미 올바르다.**
|
||||
- **문제는 우리 코드 밖이다** — `SBAC-C1` 이 말하듯 auto-configuration 은 보통 `@ConditionalOnClass` 로 조건화되므로, spring-kafka 를 classpath 에 올리면 **우리가 선언하지 않은 bean 이 생긴다**. `@ConditionalOnProperty`(`SBAC-C3`)는 **우리가 정의한 bean** 만 막는다 — sibling capability D13 의 Open Risk 와 같은 형태다. **이것은 추정이 아니라 이 repo 에서 이미 관측된 사실**이다: `application.yml:8-13` 주석이 Mongo 에 대해 "classpath-triggered, **independent of the module's `@ConditionalOnProperty` gate**. Left alone it would start a Mongo client (and a mongo health contributor, turning `/actuator/health` DOWN) on every boot" 라고 기록한다.
|
||||
- **채택 패턴 (`planned`) — Mongo 선례의 half ①만 채택하고 half ②는 채택하지 않는다.** `app-bootstrap` 의 `application.yml` `spring.autoconfigure.exclude` 에 Kafka auto-configuration FQN 을 올려 classpath 를 무력화하되(①), `@ImportAutoConfiguration` 으로 되살리지는 **않고**(②-불채택) `KafkaAdapterConfig`(이미 `app.messaging.broker=kafka` 로 게이트됨)가 게이트된 `@Bean` 으로 producer 를 **직접 조립**한다.
|
||||
- **Mongo 선례와의 의도적 divergence (2회차 depth 게이트 지적 반영)**: Mongo 는 half ② 를 채택했고, 그 결과 연결 배선을 **벤더 네임스페이스에 맡겼다** — `MongoPersistenceSettings.java:10-13` javadoc 이 "The Mongo **connection URI** is intentionally NOT modelled here — it is read from Spring's own standard `spring.data.mongodb.uri` (owned by Spring Boot's `MongoProperties`), which keeps credentials/host wiring in the one place operators already expect" 라고 명시한다. **Kafka 에는 이 선택을 그대로 쓸 수 없다** — 이유는 D1 이다. `acks`·`enable.idempotence`·`max.in.flight` 는 *보증을 결정하는* 값이라 §1 의 거부 로직이 **단일 소유**해야 하는데, `spring.kafka.*` 바인딩을 살려 두면 같은 값에 두 개의 입력 경로가 생겨 "override 를 거부한다"는 계약 자체가 성립하지 않는다. Mongo 의 URI 는 보증을 결정하지 않으므로 그 branch 의 선택은 그 맥락에서 옳다.
|
||||
- **그래서 `spring.kafka.*` 는 쓰지 않는다** — 근거는 "배제가 그 바인딩을 죽여서"가 아니라(②를 채택했다면 되살아났을 것이다) **보증 키의 이중 소스를 금지하는 D1 정책**이다.
|
||||
- **bean 경합**: half ② 를 채택하지 않으므로 auto-configuration 이 만드는 `ProducerFactory`/`KafkaTemplate` 와 우리가 조립하는 producer 가 **동시에 존재할 일이 없다**. 이것이 ②-불채택의 부수 이득이며, `adapter-contract:kafka-inactive-creates-no-resources` 가 그 부재를 검증한다.
|
||||
- **seam 공존 규칙 (`planned`)**: skeleton 이 기본 `KafkaSender` 구현을 제공하면 fork 가 이미 제공하던 구현과 겹친다. 기본 구현은 `@ConditionalOnMissingBean` 으로 등록해 **fork 의 구현이 이긴다**(`SBAC-C1` 이 `@ConditionalOnClass` + `@ConditionalOnMissingBean` 을 auto-configuration 의 표준 조합으로 진술). 동시에 `KafkaSender.java:6-8` 의 javadoc("The skeleton carries no Kafka SDK dependency — it is added by the project that enables Kafka")은 **사실이 아니게 되므로 함께 정정**해야 한다 — 이 문장 정정을 구현 항목으로 등록한다.
|
||||
- 모듈 배치 (`planned`): 신규 leaf 를 만들지 않고 `adapter:outbound:messaging` 의 `kafka` 패키지에 둔다. 근거 — `MessagingConfig` javadoc(L18-21)이 "adding a broker is new files only and this config never changes" 를 설계 의도로 명시하고 `KafkaAdapterConfig` 가 이미 그 패턴을 따른다. 모듈 수는 19 를 유지하며(`.harness/project/modules.yaml`), 20 으로의 migration 은 #064 의 inbound leaf 가 소유한다.
|
||||
- readiness 표기 (D12): spring-kafka 기본 구현이 들어오면 `R0` → `R1`. **card 에 `R2` 를 적으려면** 실 broker 통합 test·보안 설정·runbook 이 함께 있어야 한다 (project note §36.1).
|
||||
|
||||
### 7-1. 설정 키의 3층 매핑 (env var → Spring property → Kafka client property)
|
||||
|
||||
> **Trace**: D2(auto-config 배제) · D4·D6·D7(키 목록) · sibling [[raw/branch-notes/feature-env-driven-runtime-configuration]] D2(`APP_` prefix 통일, registry = SSOT)·D7(`verifyEnvKeys` 3-way drift 강제)
|
||||
>
|
||||
> 2026-07-28 depth 게이트 1회차가 **누락으로 지적한 절**이다. §1·§4 가 native Kafka client 키 이름(`enable.idempotence`·`security.protocol` …)을 나열하면서 동시에 "`APP_MESSAGING_KAFKA_*` 로 등록"이라고만 적어, 그 사이의 층이 비어 있었다.
|
||||
|
||||
현재 이 프로젝트에는 **세 개의 이름 공간**이 동시에 존재한다:
|
||||
|
||||
| 층 | 예 | 소유 | 확인 |
|
||||
|---|---|---|---|
|
||||
| ① 환경변수 (registry SSOT) | `APP_MESSAGING_KAFKA_BROKERS` | [[raw/branch-notes/feature-env-driven-runtime-configuration]] D2 — `APP_` 전면 통일, `env-keys.yaml` 이 enforced SSOT | `actually-implemented` (`docs/registries/env-keys.yaml:1304`) |
|
||||
| ② Spring property | `app.messaging.kafka.brokers` | 본 branch 의 settings record | `actually-implemented` (`kafka/KafkaAdapterSettings.java:14` — `@ConfigurationProperties(prefix = "app.messaging.kafka")`) |
|
||||
| ③ Kafka client property | `bootstrap.servers`·`enable.idempotence`·`security.protocol` … | Kafka 자체 | 미구현 — SDK 부재 |
|
||||
|
||||
규범 (`planned`):
|
||||
|
||||
- **①→② 는 자동이 아니다 — placeholder 를 손으로 써야 한다** (2회차 depth 게이트 지적 반영). `src/build.gradle` 의 `verifyEnvKeys` **check B** 가 "Every `.env` key must be referenced by some `application.yml` placeholder" 를 강제하므로(`"verifyEnvKeys: src/.env declares keys no application.yml \${...} placeholder uses"`), `.env` 에 키만 넣고 yml 에 `${...}` 를 쓰지 않으면 **빌드가 깨진다**. 즉 ①→② 는 relaxed binding 이 알아서 해 주는 것이 아니라 **프로퍼티 이름을 결정하는 작성 행위**다. 현행 선례: `app-bootstrap/.../application.yml:520` 의 `brokers: ${APP_MESSAGING_KAFKA_BROKERS:}`.
|
||||
- **placeholder 는 두 파일에 있다** — `app-bootstrap`(`:520`)과 `sample-portfolio`(`:228`)가 같은 블록을 각각 바인딩한다. `verifyEnvKeys` 는 `app-bootstrap` 만 읽으므로 **sample 쪽 누락은 조용히 통과한다** — 신규 키 추가 시 두 파일을 함께 갱신한다.
|
||||
- check C 는 그와 별개로 "모든 `APP_` 키가 `env-keys.yaml` 에 등록될 것"을 강제한다(env-driven D7 = registry SSOT).
|
||||
- ②→③ 은 **본 branch 가 소유하는 매핑**이다. `app.messaging.kafka.*` settings record 가 Kafka client property 맵을 조립해 producer 에 넘긴다. `spring.kafka.*` 를 쓰지 않는 근거는 §7 에 있다 — **보증 키의 이중 소스 금지(D1)** 이지 auto-config 배제의 부수 효과가 아니다.
|
||||
- 따라서 §1 의 "계약이 검증하는 것" 열은 **③ 값이 아니라 ② 값**을 검증한다 — 검증 지점이 settings record 의 생성자/validator 이며, 이는 `KafkaAdapterSettings` 가 이미 `brokers` 에 대해 하는 일(`host:port` 형식 검증)의 확장이다.
|
||||
|
||||
**신규 등록 대상 키 (총 19개 — 기존 2개와 별도, `planned`)**. `retries` 는 §1 이 "건드리지 않음" 이므로 등록하지 않는다.
|
||||
|
||||
| ① `APP_` 환경변수 | ② Spring property (`app.messaging.kafka.*`) | ③ Kafka client key | 분류 |
|
||||
|---|---|---|---|
|
||||
| `APP_MESSAGING_KAFKA_ACKS` | `acks` | `acks` | public-config (값 고정 검증) |
|
||||
| `APP_MESSAGING_KAFKA_ENABLE_IDEMPOTENCE` | `enable-idempotence` | `enable.idempotence` | public-config (값 고정 검증) |
|
||||
| `APP_MESSAGING_KAFKA_MAX_IN_FLIGHT` | `max-in-flight` | `max.in.flight.requests.per.connection` | public-config (상한 검증) |
|
||||
| `APP_MESSAGING_KAFKA_DELIVERY_TIMEOUT` | `delivery-timeout` | `delivery.timeout.ms` | public-config (정합 검증) |
|
||||
| `APP_MESSAGING_KAFKA_REQUEST_TIMEOUT` | `request-timeout` | `request.timeout.ms` | public-config |
|
||||
| `APP_MESSAGING_KAFKA_LINGER` | `linger` | `linger.ms` | public-config |
|
||||
| `APP_MESSAGING_KAFKA_BATCH_SIZE` | `batch-size` | `batch.size` | public-config |
|
||||
| `APP_MESSAGING_KAFKA_COMPRESSION_TYPE` | `compression-type` | `compression.type` | public-config |
|
||||
| `APP_MESSAGING_KAFKA_BUFFER_MEMORY` | `buffer-memory` | `buffer.memory` | public-config |
|
||||
| `APP_MESSAGING_KAFKA_MAX_BLOCK` | `max-block` | `max.block.ms` | public-config |
|
||||
| `APP_MESSAGING_KAFKA_MAX_REQUEST_SIZE` | `max-request-size` | `max.request.size` | public-config |
|
||||
| `APP_MESSAGING_KAFKA_SECURITY_PROTOCOL` | `security-protocol` | `security.protocol` | public-config |
|
||||
| `APP_MESSAGING_KAFKA_SSL_TRUSTSTORE_LOCATION` | `ssl.truststore-location` | `ssl.truststore.location` | public-config |
|
||||
| `APP_MESSAGING_KAFKA_SSL_TRUSTSTORE_PASSWORD` | `ssl.truststore-password` | `ssl.truststore.password` | **secret** |
|
||||
| `APP_MESSAGING_KAFKA_SSL_KEYSTORE_LOCATION` | `ssl.keystore-location` | `ssl.keystore.location` | public-config |
|
||||
| `APP_MESSAGING_KAFKA_SSL_KEYSTORE_PASSWORD` | `ssl.keystore-password` | `ssl.keystore.password` | **secret** |
|
||||
| `APP_MESSAGING_KAFKA_SSL_KEY_PASSWORD` | `ssl.key-password` | `ssl.key.password` | **secret** |
|
||||
| `APP_MESSAGING_KAFKA_SASL_MECHANISM` | `sasl-mechanism` | `sasl.mechanism` | public-config |
|
||||
| `APP_MESSAGING_KAFKA_SASL_JAAS_CONFIG` | `sasl-jaas-config` | `sasl.jaas.config` | **secret** |
|
||||
|
||||
기존 등록 2개(`APP_MESSAGING_BROKER`·`APP_MESSAGING_KAFKA_BROKERS` → `bootstrap.servers`)는 그대로 두고 `owner_branch` 도 바꾸지 않는다.
|
||||
|
||||
> **UNSUPPORTED_IMPL_DECISION**: ② 열의 **kebab-case 이름**. 위 표는 Kafka 키에서 기계적으로 파생한 것이며(`.ms` 접미사 제거, `.` → `-`), 어떤 외부 문서도 이 변환을 규정하지 않는다. trade-off — 접미사를 남기면(`delivery-timeout-ms`) Kafka 키와 1:1 대조가 쉬우나 Spring 의 `Duration` 바인딩 관례(단위는 타입이 표현)와 어긋나고, 제거하면 관례에는 맞으나 대조 시 한 단계 변환이 필요하다. **위 표는 후자를 택했으나 근거는 없다.**
|
||||
|
||||
> **UNSUPPORTED_IMPL_DECISION**: ②→③ 매핑의 **표현 방식**. 후보 — (a) settings record 의 필드마다 Kafka 키를 1:1 로 매핑하는 명시적 코드, (b) `Map<String,String> properties` passthrough 한 칸을 두고 임의 Kafka 키를 통과시키기, (c) (a)+(b) 혼합(보증 관련 키는 타입 필드, 나머지는 passthrough). trade-off — (a)는 오타·미지원 키를 컴파일/바인딩 시점에 잡지만 Kafka 키가 늘 때마다 코드를 고쳐야 하고, (b)는 유연하나 D1 을 깨는 override(`acks=0`)를 타입 시스템이 못 막아 §1 의 거부 로직을 손으로 짜야 하며, (c)는 둘의 장점을 취하나 "어느 키가 어느 쪽인지"의 경계를 사람이 유지해야 한다. **(c)가 §1 의 두-종류 계약(값 고정 vs 키만 등록)과 구조적으로 일치하나 외부 근거는 없다.**
|
||||
|
||||
### 8. 실패 분류와 관측 매핑
|
||||
|
||||
> **Trace**: D11 · D13 · `KAFKA-PROD-JD-C9` · `KAFKA-PRODCFG-C1`·`C5` · `SPRING-KAFKA-SEND-C5` · sibling outbox D13
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: producer 고유 실패의 **분류 규칙**. 아래 표의 배정은 기존 code 의미에서 유추한 것이며 registry 나 공식 문서가 지시하지 않는다. trade-off — 전부 `OUTBOX_PUBLISH_FAILED`(TRANSIENT)로 뭉치면 재시도 동작은 맞지만 runbook 이 "broker 를 보라"고만 말하게 되어 client-side backpressure 를 진단하지 못하고, 세분하면 진단은 되지만 D11 이 금지한 신규 code 가 필요해진다.
|
||||
|
||||
| producer 실패 | 성격 | 배정 code | 근거 |
|
||||
|---|---|---|---|
|
||||
| `delivery.timeout.ms` 만료 | 재시도 예산 소진 (broker 측) | `OUTBOX_PUBLISH_FAILED` → attempts 소진 시 `OUTBOX_DEAD_LETTER` | `KAFKA-PRODCFG-C1`, sibling outbox D13 |
|
||||
| `max.block.ms` 초과 `TimeoutException` | **client-side backpressure** (broker 정상일 수 있음) | 위와 동일 (`UNSUPPORTED_IMPL_DECISION` — 성격이 다른데 같은 code) | `KAFKA-PROD-JD-C9`, `KAFKA-PRODCFG-C5` |
|
||||
| 직렬화 실패 / payload 계약 위반 | **재시도 무의미** (poison) | `OUTBOX_PUBLISH_FAILED` → DEAD (현행 runbook 이 "재시도 무의미, attempts 소진 후 DEAD" 를 의도된 동작으로 기술) | runbook `outbox-publish-failed.md` §3 |
|
||||
| broker 미선택 상태에서 publish 호출 | 구성 오류 | `ADAPTER_DISABLED` (`actually-implemented` — `core/DisabledMessagePublisher.java:15` 가 `AdapterDisabledException("messaging")`) | `internal-code-fact` |
|
||||
| `delivery.timeout.ms < request.timeout.ms + linger.ms` | 설정 정합 위반 | `STARTUP_VALIDATION_FAILED` | `KAFKA-PRODCFG-C1`, D4 |
|
||||
| `acks`/`enable.idempotence` 를 D1 에 반하게 override | 보증 파괴 설정 | `STARTUP_VALIDATION_FAILED` | `KAFKA-PRODCFG-C3`, D1 |
|
||||
|
||||
관측 (`planned`):
|
||||
|
||||
- 로그 필드는 기존 `FailOpenDependencyLogger` 계약을 재사용한다 — `dependency_name`(brokerId)·`dependency_type`(`"messaging"`)·`operation`(`"publish"`)·`outcome`·`correlation_id`. 이 5개는 `actually-implemented` 다(`support/FailOpenDependencyLogger.java:26-48`).
|
||||
- **단, fail-closed 경로의 로그 레벨·필드가 registry·runbook 과 어긋난다** — §Audit A2·A3. 본 branch 는 정합 권고만 남기고 로거를 직접 고치지 않는다(owner 가 다름).
|
||||
- producer metric 은 `metrics.yaml` **신규 row 제안**(기존 row 재사용 아님). 후보 이름은 `messaging.producer.send.total`(tag: `broker`·`outcome`)·`messaging.producer.send.duration`.
|
||||
- **cardinality 는 태그 선택으로 유한하게 묶는다** — `broker`(활성 broker 1개) × `outcome`(성공/실패 2값)이므로 상한이 구조적으로 작다. **`topic` 을 태그로 쓰지 않는다**: topic 은 `eventType` 에서 오므로(§3) 도메인이 늘수록 무한히 증가한다. 이는 기존 `outbox.publisher.published.total` 이 `event_type` 에 `cardinality_limit: 50` 을 명시한 것과 같은 문제를 태그를 빼는 방식으로 피하는 것이다. 정확한 상한 숫자·이름은 **registry owner 절차를 거쳐야 확정**되므로 여기서 단정하지 않는다.
|
||||
- **trace span 은 만들지 않는다 (D13 — `R1` 범위 밖의 선언된 미착수)**. 현재 producer 발행 구간의 span 은 0개이며, `R2` 를 주장할 때 [[raw/branch-notes/feature-distributed-tracing-contract]] 의 carrier 계약을 consume 해 결정한다. D10 의 `trace_id` header 전파가 그 전제를 미리 만든다.
|
||||
|
||||
> **OUT_OF_BRANCH_SCOPE**: outbox row 의 `FAILED`/`DEAD` **상태 전이 규칙**과 attempt 카운트는 [[raw/branch-notes/feature-domain-event-outbox-contract]] D13 + #072 소유다. 본 §은 *producer 가 무엇을 던지고 그것이 어느 code 로 표현되는지* 까지만 정한다.
|
||||
|
||||
### 9. Capability descriptor 기여값
|
||||
|
||||
> **Trace**: D14 · D1(guarantee 내용) · D12(readiness 등급) · sibling [[raw/branch-notes/feature-capability-provider-selection-contract]] §구현 가이드 1
|
||||
>
|
||||
> 2026-07-28 coverage 게이트 1회차가 **Blocking 으로 지적한 절**이다. sibling 의 descriptor 필드표가 `requiredBackingRole` 을 "기술 런타임 branch 가 정의" 로 배정했는데 본 노트에 그 값이 없었다.
|
||||
|
||||
descriptor 는 sibling 이 **형식**을 소유하고 본 branch 는 **값**을 기여한다. 아래는 그 기여분이며 필드 정의는 재진술하지 않는다.
|
||||
|
||||
> **UNSUPPORTED_IMPL_DECISION**: descriptor 를 **1개로 둘지 2개로 쪼갤지**. project note §11 은 messaging 을 `best-effort notification·message`(fail-open)와 `durable notification·message`(durable-retry) **두 행**으로 나누고, 이 branch 의 두 포트가 정확히 그 두 행에 대응한다 — 그렇다면 capability 도 2개일 수 있다. trade-off — 1개로 두면 하나의 Kafka 런타임이 하나의 descriptor 를 갖는다는 직관에 맞으나 `failurePolicy` 맵의 같은 실패 종류가 포트마다 다른 kind 를 갖는 모순이 남고, 2개로 쪼개면 §11 매트릭스와 1:1 이 되나 같은 producer·같은 연결을 두 capability 가 공유한다고 선언해야 한다. **개수 결정은 `capabilityId` 어휘(sibling 미결)에 종속되므로 지금 확정하지 않는다** — 아래 표는 필드별 *값*만 정하고 개수에는 중립이다.
|
||||
|
||||
| 필드 | 본 branch 가 채우는 값 | 근거 / 상태 |
|
||||
|---|---|---|
|
||||
| `capabilityId` | 미확정 | sibling 의 런타임 어휘 미결에 종속 (`UNSUPPORTED_IMPL_DECISION` — 그 branch 소유) |
|
||||
| `providerId` | `kafka` | `actually-implemented` — `kafka/KafkaMessageBroker.java:14`(`BROKER_ID = "kafka"`) 가 이미 그 문자열을 쓰고, `MessagingConfig.java:62-69` 가 설정값과의 불일치를 기동 실패로 검증 |
|
||||
| `readiness` | 현재 `R0` → 본 branch 완료 시 `R1` | D12 |
|
||||
| `guarantee` | D1 의 보증 문장 (재전송 중복 억제된 at-least-once, 파티션 단위 순서, DB↔broker exactly-once 비보증) | D1 · D5 |
|
||||
| `failurePolicy` 맵 | **키는 연산명이 아니라 실패 종류**다 (2회차 depth 게이트 지적 반영) — `broker-unavailable` → `durable-retry`(outbox 경로) / `broker-unavailable` → `fail-open`(일반 경로), `client-backpressure`(`max.block.ms` 초과) → 해당 경로의 같은 kind, `config-invalid` → 기동 거부. sibling 규범: "descriptor 의 `failurePolicy` 맵 키는 **실패 종류**(예: `backend-unavailable`, `codec-error`, `append`, `dispatch`)이고 값은 kind 다" | D11 · §8 · sibling capability D12 의 맵 규범 · sibling multibroker D4(두 실패 계약 분리) |
|
||||
| `multiInstanceSupported` | **`false` (안전한 기본값)** — 검증 전까지 낮은 쪽을 유지한다 (2회차 depth 게이트 지적 반영) | D14. `true` 는 `KAFKA-PROD-JD-C4` 에서의 **추론**이고 §Claims To Verify 가 `needs-confirmation` 인데, sibling 은 이 값을 **기동 거부 가드의 입력**으로 쓴다("descriptor 는 `multiInstanceSupported: false` 인데 `APP_MULTI_INSTANCE_ENABLED=true` → 기동 거부"). 미검증 추론이 가드를 *여는* 방향으로 작동하면 안 되므로 검증 후 승격한다 |
|
||||
| `requiredBackingRole` | **`kafka-broker`** — **역할의 *선언*이지 도달성 주장이 아니다** (2회차 depth 게이트 지적 반영). startup 이 대조하는 것은 "이 role 에 대응하는 설정(`app.messaging.kafka.brokers`)이 존재하는가" 이며, broker 에 실제로 접속해 보지 않는다 | D14. sibling 의 startup 검증 범위가 "descriptor ↔ 선택된 설정" 대조이므로 도달성 프로브는 그 계약 밖이다. **도달성은 R2 통합 test·health 소관**이며, 기동을 broker 가용성에 종속시키면 D12(R1 은 실 broker 불요)·sibling D13(비활성은 연결 미생성)과 충돌한다 |
|
||||
| `readinessImpact` | 본 branch 단독 결정 아님 — 입력만 제공 | sibling D7 이 health group 을 파생한다. project note §11 이 messaging 을 "durable notification·message"(durable-retry)와 "best-effort message"(fail-open) 두 행으로 나누므로 **포트별로 값이 달라진다**는 점만 본 branch 가 지적한다 |
|
||||
| `implementationVersion` | 빌드 정보 (본 branch 결정 사항 아님) | sibling 표 그대로 |
|
||||
|
||||
> **OUT_OF_BRANCH_SCOPE**: descriptor 의 **스키마·수집 메커니즘·startup 검증 시점**은 전부 [[raw/branch-notes/feature-capability-provider-selection-contract]] D5·D6·D11 소유다. 본 §은 *이 provider 가 무엇을 선언하는가* 만 채운다.
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**
|
||||
- **broker 미선택 상태에서 publish 호출** → `AdapterDisabledException("messaging")` 즉시 실패. 조용한 no-op 아님 (`actually-implemented`: `core/DisabledMessagePublisher.java:15`).
|
||||
- **`app.messaging.broker=kafka` 인데 `KafkaSender` bean 이 없음** → `KafkaAdapterConfig.kafkaMessageBroker` 가 bean 을 만들 수 없어 기동 실패. seam 이 채워지지 않은 fork 의 기본 상태다.
|
||||
- **`app.messaging.broker=kafka` 인데 `brokers` 가 비어 있음** → `IllegalStateException` 으로 기동 거부 (`actually-implemented`: `kafka/KafkaAdapterConfig.java:25-29`).
|
||||
- **producer buffer 소진** → `max.block.ms`(기본 60초) 동안 호출 스레드가 블로킹된 뒤 `TimeoutException` (`KAFKA-PROD-JD-C9`, `KAFKA-PRODCFG-C5`). **outbox relay 스케줄러 스레드에서 발생하면 relay tick 이 최대 1분 밀린다** — 이 상호작용은 어느 결정도 아직 계약하지 않았다.
|
||||
- **`delivery.timeout.ms` 만료** → send 가 실패로 보고되고 outbox 는 `FAILED` 로 전이해 backoff 재시도. 재시도 예산이 relay(attempts 3) × producer(timeout) 로 **이중**이다 (D4 Open Risk).
|
||||
- **`OutOfOrderSequenceException`** → 계속 보내면 순서가 깨지고(`KAFKA-PROD-JD-C6`), 순서를 지키려 producer 를 재생성하면 idempotence 세션이 끊긴다(`KAFKA-PROD-JD-C4`). **어느 쪽을 택해도 무언가를 잃는 경로**이므로 card 의 비보증에 명시한다.
|
||||
- **producer 재시작을 가로지르는 재전송** → 새 PID 를 받으므로 broker 측 중복 제거가 적용되지 않는다. outbox 의 `IN_FLIGHT` 재claim 이 이 경로를 실재하게 만든다 → **consumer dedupe 없이는 중복이 남는다**.
|
||||
- **shutdown 중 phase timeout 초과** → flush 완료 전에 대기가 끝나 미전송 record 가 사라질 수 있다(`SPRING-SMARTLC-C7` + `KAFKA-PROD-JD-C7`). outbox row 가 `IN_FLIGHT` 로 남아 재claim 되므로 결과적으로 **유실이 아니라 중복**으로 전환된다.
|
||||
- **`security.protocol` 미설정** → Kafka 기본은 `PLAINTEXT`(`KAFKA-SEC-C1`), 즉 **아무 보안 없이 붙는다**. prod 에서 이것을 기동 거부로 잡지 않으면 조용히 평문으로 나간다.
|
||||
- **`SASL_PLAINTEXT` + `PLAIN` 조합** → 자격증명이 평문으로 네트워크에 나간다 (`KAFKA-SEC-C6`). 설정 검증에서 거부한다.
|
||||
- **스키마 breaking change** → D8 의 현행(문자열 payload) 경로에서는 producer 가 **아무 실패도 만들지 않는다**. 검증이 빌드 단계에만 있고 그 커버리지가 미확인이므로, 사고는 항상 consumer 측 사후 관찰로만 발견된다.
|
||||
|
||||
- **다른 계약 의존**
|
||||
- [[raw/branch-notes/feature-messaging-multibroker-router]] **D2**(통합 `MessageBroker` SPI)·**D4**(fail-open/closed decorator 분리) — D3(결과 반환)과 D10(headers)이 **둘 다 이 SPI 의 시그니처를 바꾼다.** 그 계약이 바뀌면 본 branch 의 두 결정이 직접 무효화된다. 공동 진행 필요(§Audit A9).
|
||||
- [[raw/branch-notes/feature-domain-event-outbox-contract]] **D6**(per-aggregate FIFO)·**D7**(at-least-once + consumer dedupe)·**D12**(envelope 필드)·**D13**(발행 실패 분류)·**D14**(broker header 명명 위임) — D1·D5·D10·D11 이 전부 이 결정들 위에 얹힌다. 특히 **D14 는 본 branch 로의 명시적 위임**이다.
|
||||
- [[raw/branch-notes/feature-outbox-dispatch-mode-contract]] (#072) — "broker ack 기한"이 그 branch 범위 문구에 있어 D4 와 겹친다. 제안 경계: **producer 내부 시간 상한 = 본 branch(`delivery.timeout.ms`), relay 의 attempt 판정·포기 = #072**(§Audit A4).
|
||||
- [[raw/branch-notes/feature-background-job-async-contract]] **D4**(max attempts 3 + backoff) — D4 의 이중 재시도 예산의 다른 한쪽. 그 값이 바뀌면 총 지연 상한이 바뀐다. shutdown 순서 2단계(relay 정지)도 그 branch 소유다.
|
||||
- [[raw/branch-notes/feature-capability-provider-selection-contract]] **D2**(활성화 축 단일화)·**D8**(R0~R3)·**D12**(failurePolicy 맵)·**D13**(비활성 capability 리소스 미생성) — D2·D12 가 그 계약의 소비자다. 특히 **D13 은 본 branch 의 SDK 도입과 정면으로 관련**된다.
|
||||
- [[raw/branch-notes/feature-env-driven-runtime-configuration]] **D2**(`APP_` prefix 통일, `env-keys.yaml` = enforced SSOT)·**D7**(`verifyEnvKeys` 3-way drift) — §구현 가이드 7-1 의 신규 19키가 이 계약의 **빌드 게이트**를 통과해야 한다. 등록 누락·placeholder 누락은 곧바로 빌드 실패다.
|
||||
- [[raw/branch-notes/feature-secrets-config-source-contract]] — D7 의 secret 분류·마스킹·`__LOCAL_DEV_` 규약 owner. 본 branch 는 row 를 추가하는 소비자다.
|
||||
- [[raw/branch-notes/feature-schema-serialization-contract]] — D8 이 JSON 필드 계약을 위임하는 대상이자, **그 D5(drift gate)가 outbox payload 를 덮어야 D8 의 전제가 성립**한다.
|
||||
- [[raw/branch-notes/feature-kafka-consumer-inbox-contract]] (#064) — registry 상 그 branch 가 본 branch 에 depends 하지만, **D1 의 보증이 성립하려면 그 branch 의 inbox dedupe 가 반드시 있어야 한다** — 보증 관점에서는 상호 의존이다. (2026-07-28 그 branch `/branch-spec` 완료: dedupe 는 **D10**(inbox 기록과 비즈니스 write 를 동일 트랜잭션에서 커밋)·**D11**(dedupe key = envelope `idempotencyKey` + `eventType` 복합)이 소유. 또한 그 branch **D2** 가 본 branch D2 의 축 — seam 유지 + 스켈레톤 spring-kafka 기본 구현 + broker 미선택 시 auto-config 비활성 — 에 정렬해 있으므로 **두 D2 는 함께 승인해야 한다**.)
|
||||
|
||||
## Audit & Findings
|
||||
|
||||
> 2026-07-28 `/branch-spec` §2 ground truth 확인에서 ca-tmpl 코드·registry·runbook 을 직접 읽어 발견한 정합 문제. **전부 `actually-implemented` 사실**(경로·행 명시)이며 추측이 아니다.
|
||||
>
|
||||
> 본 branch 가 단독으로 고칠 수 없는 항목은 owner 를 명시했다 — 자동 수정하지 않고 정합 권고만 남긴다.
|
||||
|
||||
| ID | 발견 | 근거 (ca-tmpl 상대 경로) | 판정 | owner |
|
||||
|---|---|---|---|---|
|
||||
| A1 | **runbook 이 폐기된 env key 를 지시** — `outbox-publish-failed.md` §2 Step 1 이 "`APP_MESSAGING_KAFKA_ENABLED` 값과 broker endpoint 가용성" 확인을 지시하나, 그 키는 sibling multibroker D3 에서 `APP_MESSAGING_BROKER` 로 **깨끗이 교체**되어 registry·`.env`·`application.yml` 어디에도 없다 | `docs/runbooks/outbox-publish-failed.md`(§2 Step 1) vs `docs/registries/env-keys.yaml`(messaging 키는 `APP_MESSAGING_BROKER` L1290·`APP_MESSAGING_KAFKA_BROKERS` L1304 2개뿐), `src/.env:26-27`, `src/app-bootstrap/src/main/resources/application.yml:513-519` | `RUNBOOK_ENV_KEY_DRIFT` — 장애 대응 중 존재하지 않는 키를 찾게 된다 | [[raw/branch-notes/feature-domain-event-outbox-contract]] (runbook owner) + [[raw/branch-notes/feature-operational-runbook-contract]] |
|
||||
| A2 | **로그 레벨이 registry 와 불일치** — `OUTBOX_PUBLISH_FAILED` 는 registry 에 `log_level: ERROR` 로 선언됐으나, 실제 발행 실패 경로는 `FailOpenDependencyLogger.logFailure` 를 통해 **WARN** 으로 남는다. 그 로거의 javadoc 은 WARN 고정이 **fail-open 어댑터용 의도**라고 명시하는데 outbox 경로는 fail-**closed** 다 | `docs/registries/error-codes.yaml:733`(`log_level: ERROR`) vs `src/adapter/outbound/support/.../FailOpenDependencyLogger.java:36-48`(`log.warn`) + javadoc L7-10, 호출부 `outbox/OutboxMessagePublishAdapter.java:40`·`:44` | `LOG_LEVEL_DRIFT` — runbook §3 의 "ERROR log query" 가 아무것도 못 찾는다 | outbox branch (code owner) + support 모듈 로거 owner. 본 branch 는 발행 실패 경로 당사자로서 권고 |
|
||||
| A3 | **로그에 `error_code` 등 진단 필드가 없다** — runbook §2 는 "ERROR log 에서 `OUTBOX_PUBLISH_FAILED` 라인 확인: `event_type`, `event_id`, `correlation_id`, `attempt_count` 추출"을 지시하지만, 실제 로그 라인의 필드는 `dependency_name`·`dependency_type`·`operation`·`outcome`·`correlation_id`·`error` 6개뿐이다 — `error_code`·`event_type`·`event_id`·`attempt_count` **전부 없다** | `FailOpenDependencyLogger.java:39-47`(포맷 문자열 전체) vs `docs/runbooks/outbox-publish-failed.md` §2·§3. `docs/registries/mdc-keys.yaml` 에 `error_code`·`event_type` 키는 **존재**하나 이 경로에서 쓰이지 않음 | `ERROR_CODE_NOT_EMITTED` — 선언된 진단 절차가 코드상 성립하지 않는다 | 동 A2 |
|
||||
| A4 | **범위 문구 중복** — #072 의 포함 범위에 "polling — ... broker ack 기한 ..."이 있어 본 branch 의 `delivery.timeout.ms` 와 같은 말로 읽힌다 | `raw/branch-notes/feature-outbox-dispatch-mode-contract.md`(§포함 범위) vs 본 노트 D4 | `SCOPE_OVERLAP` — Single-Owner 위반 소지. **제안 경계**: producer 내부 시간 상한 = 본 branch, relay 의 attempt 판정·포기 = #072 | 양 branch 협의 (#072 는 아직 scaffolding — D-row 0개) |
|
||||
| A5 | **Kafka secret row 0건** — `secrets-classification.yaml` 에 messaging/Kafka 관련 row 가 하나도 없다. Redis 는 `APP_CACHE_REDIS_PASSWORD` 가 이미 등록돼 있어 대비된다 | `docs/registries/secrets-classification.yaml`(등록 row 15개 전수 확인 — Kafka/messaging 0건) | `NO_SECRET_ROWS` — D7 의 TLS/SASL 자격증명이 분류·마스킹 체계 밖에 있다 | 본 branch (등록) + [[raw/branch-notes/feature-secrets-config-source-contract]] (절차) |
|
||||
| A6 | **producer metric row 0건** — `metrics.yaml` 의 messaging 계열은 `outbox.publisher.published.total`·`outbox.publisher.lag`·`outbox.pending.size` 3종뿐이고 전부 owner 가 outbox branch 다. broker 전송 자체(성공/실패/지연)를 보는 metric 이 없다 | `docs/registries/metrics.yaml:342-395` | `NO_PRODUCER_METRICS` — capability card §36.2 의 "bounded cardinality metric" 항목을 만족할 수 없다 | 본 branch (제안) + metrics registry owner |
|
||||
| A7 | **참조된 내부 설계 문서가 repo 에 없다 + Apache 문서 정적 fetch 불가** — (a) 본 노트와 project note 가 인용하는 ca-tmpl `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md` 가 실재하지 않는다(gitignored; 그 디렉터리엔 `2026-07-20-harness-policy-engine-design.md` 1건뿐). (b) `kafka.apache.org/documentation` 이 JS 렌더링 SPA 로 바뀌어 앵커 fetch 가 redirect stub 만 반환한다 | `ls /home/donghyeon/workspace/ca-tmpl/docs/superpowers/specs/`; 수집 agent 3건의 fetch 로그(19,985-byte redirect stub) | `SOURCE_ACCESS_LIMIT` — (a) §11.3/§11.4 참조는 **어떤 결정의 근거로도 쓰지 않았다**. (b) delivery semantics 는 Confluent 미러로 대체(대조는 §Claims To Verify), producer configs·security 는 버전 경로 추적으로 Apache 원문 확보 | 정보성 (본 branch 조치 없음) |
|
||||
| A8 | **~~auto-configuration 배제 전례 0건~~ → 정정: 전례가 이미 존재한다** (2026-07-28 depth 게이트 1회차가 잡음). 최초 grep(`"autoconfigure.exclude"` 리터럴)은 YAML 이 중첩 표기라 **false negative** 였다. 실제로 Mongo 가 정확히 D2 가 필요로 하는 형태 — `spring.autoconfigure.exclude` 로 classpath 를 무력화하고, opt-in 프로퍼티로 게이트된 `@ImportAutoConfiguration` 으로 되살리는 2-half 패턴 — 을 이미 쓴다 | `src/app-bootstrap/src/main/resources/application.yml:6-22`(`spring.autoconfigure.exclude` 3개 + 주석 "Spring Boot's own Mongo auto-configuration is **classpath-triggered, independent of the module's `@ConditionalOnProperty` gate**. Left alone it would start a Mongo client (and a mongo health contributor, turning `/actuator/health` DOWN) on every boot"), `src/adapter/outbound/persistence-mongo/.../mongo/MongoPersistenceConfig.java:22-28`(javadoc: "`ImportAutoConfiguration` is an *explicit* import that `spring.autoconfigure.exclude` does not affect, so re-importing them here turns MongoDB back on for the opted-in profile") + `:32-42`(어노테이션 실체) | `PRECEDENT_EXISTS` — §구현 가이드 7 의 배제 수단 `UNSUPPORTED_IMPL_DECISION` 을 **해소**했고, "classpath 존재만으로 켜진다"는 위험도 **추정이 아니라 in-repo 관측 사실**로 승격됐다 | 본 branch (Kafka 적용) + [[raw/branch-notes/feature-capability-provider-selection-contract]] D13 (공통 규범) |
|
||||
| A10 | **mdc-keys 의 envelope 필드 선언이 실제 envelope 과 어긋난다** — registry 는 `request_id → meta.requestId`, `trace_id → meta.traceId`, `correlation_id → meta.correlationId` 를 선언하는데, 실제 outbox envelope 은 **`meta.` 계층이 없는 평평한 구조**이고 `requestId`·`traceId` 필드 자체가 없다 | `docs/registries/mdc-keys.yaml:20`·`:35`·`:66`(`envelope_field: meta.*`) vs `src/adapter/outbound/messaging/.../outbox/OutboxEnvelopeJson.java:19-40`(방출 필드 = `eventId`·`eventType`·`aggregateId`·`occurredAt`·`correlationId`·`idempotencyKey`·`payload`) | `ENVELOPE_FIELD_DRIFT` — A1~A3 와 같은 급의 registry↔code 불일치. **D10 의 "header vs envelope 중복" 판단 근거를 흔든다** — envelope 에 없는 값(`requestId`·`traceId`)은 중복이 아니라 header 가 **유일한** 전파 수단이다 | [[raw/branch-notes/feature-domain-event-outbox-contract]] D12(envelope 필드 owner) + `feature-operational-error-observability-foundation`(mdc-keys owner) |
|
||||
| A9 | **본 branch 의 두 결정이 머지된 sibling 구현을 바꾼다** — D3(send 결과)·D10(headers)이 [[raw/branch-notes/feature-messaging-multibroker-router]] 가 소유·검증(`./gradlew check` 1254 pass)한 `MessageBroker`/`OutboundMessage` 를 수정한다 | `core/MessageBroker.java:19`, `core/OutboundMessage.java:15`, sibling 노트 D2·§검증 | `CROSS_BRANCH_SIGNATURE_CHANGE` — 단독 진행 시 Single-Owner 위반 + 회귀 위험 | 양 branch 공동 (본 branch 는 요구, 시그니처는 multibroker 소유) |
|
||||
|
||||
## 테스트 계약
|
||||
|
||||
> Work Item 완료 조건("acknowledgement·delivery timeout·순서·보안 설정 계약 test 가 통과한다")을 판정 단위로 분해한 것. 이름은 registry `required_test` 규약(messaging 계열의 기존 prefix `adapter-contract:`)을 따르는 **신규 제안**이며 등록 전이다.
|
||||
|
||||
| 계약 test (제안) | 무엇이 실패해야 하는가 | Trace |
|
||||
|---|---|---|
|
||||
| `adapter-contract:kafka-producer-guarantee-config` | `acks`/`enable.idempotence`/`max.in.flight` 가 D1 을 깨는 값으로 override 되면 기동 실패 | D1 · §구현 가이드 1 |
|
||||
| `adapter-contract:kafka-delivery-timeout-consistency` | `delivery.timeout.ms < request.timeout.ms + linger.ms` 면 기동 실패 | D4 · `KAFKA-PRODCFG-C1` |
|
||||
| `adapter-contract:kafka-send-receipt` | 성공 send 가 topic·partition·offset 을 반환하지 않으면 실패. 반환 타입에 SDK 타입이 새어나오면 ArchUnit 실패 | D3 · §구현 가이드 2 |
|
||||
| `adapter-contract:kafka-partition-key-ordering` | outbox 발행의 key 가 `aggregateId` 가 아니면 실패 (현행 동작의 회귀 방지) | D5 · `actually-implemented` 매핑 |
|
||||
| `adapter-contract:kafka-security-protocol-prod-guard` | prod 프로파일 + `PLAINTEXT` 또는 `SASL_PLAINTEXT`+`PLAIN` 조합이면 기동 실패 | D7 · `KAFKA-SEC-C1`·`C6` |
|
||||
| `secrets-contract:kafka-credentials-no-leak` | TLS/SASL 자격증명이 actuator·로그에 노출되면 실패 | D7 · A5 |
|
||||
| `adapter-contract:kafka-graceful-flush-shutdown` | shutdown 시 buffer 의 record 가 flush 되지 않고 종료되면 실패 | D9 · §구현 가이드 5 |
|
||||
| `adapter-contract:kafka-message-header-propagation` | `propagation: [message]` 로 선언된 mdc key 가 record header 에 없으면 실패 | D10 · `mdc-keys.yaml` |
|
||||
| `adapter-contract:kafka-inactive-creates-no-resources` | broker 미선택 기동에서 Kafka 관련 bean·연결·스레드가 생기면 실패 (negative fixture). Mongo 선례와 같은 형태 | D2 · sibling capability D13 · A8(정정본) |
|
||||
| `adapter-contract:kafka-property-mapping` | `APP_MESSAGING_KAFKA_*` 가 settings record 에 바인딩되지 않거나, `spring.kafka.*` 네임스페이스가 코드에 등장하면 실패 | §구현 가이드 7-1 · env-driven D2 |
|
||||
| `env-contract:kafka-producer-keys-registered` | §1·§4 의 신규 키가 `env-keys.yaml` 에 없으면 `verifyEnvKeys` 가 빌드를 깬다 (기존 도구 재사용) | env-driven D7 · §구현 가이드 7-1 |
|
||||
| `adapter-contract:kafka-capability-descriptor` | producer descriptor 가 `requiredBackingRole`·`guarantee`·`failurePolicy` 를 선언하지 않거나, `failurePolicy` 맵 키가 실패 종류가 아니면 기동 실패 | D14 · §구현 가이드 9 · sibling capability D5·D12 |
|
||||
| `adapter-contract:kafka-sender-seam-precedence` | fork 가 제공한 `KafkaSender` bean 이 있는데 skeleton 기본 구현이 이기면 실패 (`@ConditionalOnMissingBean` back-off 검증) | D2 · §구현 가이드 7 seam 공존 규칙 |
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장 / Claims To Verify
|
||||
|
||||
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다. 아래는 **수집한 claim 이 증명하지 못한** 것들이다.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 파티션 수를 늘리면 같은 `aggregateId` 가 다른 파티션으로 가서 per-aggregate FIFO 가 깨진다 | D5 의 비보증 중 **유일하게 verbatim 근거가 없는** 항목이다. 수집한 4건은 파티셔너의 key→partition 매핑 규칙이나 파티션 수 변경의 영향을 다루지 않는다 | Kafka `Partitioner`/기본 파티셔너 공식 문서 추가 수집 + 파티션 수를 바꾼 통합 test 에서 같은 key 의 partition 이동 관측 | `needs-confirmation` |
|
||||
| **배제해야 할 Kafka auto-configuration 클래스 FQN 목록** | 메커니즘 자체는 더 이상 불확실하지 않다 — A8 정정으로 Mongo 선례(`application.yml:6-22` + `MongoPersistenceConfig.java:22-42`)가 "classpath 존재만으로 auto-config 가 켜진다"와 "exclude + 게이트된 `@ImportAutoConfiguration` 이 해법"을 **이 repo 안에서 이미 증명**했다. 남은 미지는 Kafka 쪽 대응 클래스가 **정확히 몇 개, 어떤 FQN 인지**이며 SDK 를 올려보기 전에는 확정 불가다 | spring-kafka 를 추가한 브랜치에서 `app.messaging.broker` 미설정으로 기동해 어떤 Kafka bean 이 생기는지 `ApplicationContext` 로 열거 → 그 목록을 `spring.autoconfigure.exclude` 에 반영 (= `adapter-contract:kafka-inactive-creates-no-resources`) | `planned` |
|
||||
| producer `flush()`+`close()` 를 `spring.lifecycle.timeout-per-shutdown-phase` 안에서 끝낼 수 있다 | `KAFKA-PROD-JD-C7`·`C8` 은 둘 다 **무기한 blocking** 을 진술하고 `SPRING-SMARTLC-C7` 은 phase timeout 후 대기를 멈춘다. 두 진술의 상호작용(미전송 record 의 운명)은 어느 문서도 다루지 않는다 | broker 를 정지시킨 상태에서 buffer 에 record 를 넣고 shutdown 을 걸어 timeout 초과 시 로그·outbox row 상태를 관측 | `planned` |
|
||||
| producer lifecycle 을 web server 정지 **이후**에 배치할 phase 수치 | `SB-GS-C2` 가 "earliest phase" 라고만 하고 수치를 주지 않는다. §구현 가이드 5 의 후보 선택이 여기에 종속된다 | Spring Boot 의 web server graceful-shutdown lifecycle phase 상수를 코드/문서로 확인하거나, 실제 기동 후 stop 순서를 로그로 관측 | `needs-confirmation` |
|
||||
| relay 재시도(attempts 3) × producer 재시도(`delivery.timeout.ms`)의 총 지연 상한 | 두 예산이 곱해지는데 그 상한을 어느 결정도 계약하지 않았다. broker 장기 장애 시 DEAD 전이까지 걸리는 최악 시간이 불명이다 | 두 값을 고정한 뒤 broker 를 내린 상태에서 PENDING→DEAD 까지의 실측 시간 + `outbox.publisher.lag` 관측 | `planned` |
|
||||
| idempotence 의 세션 스코프(`KAFKA-PROD-JD-C4`)가 실제로 중복을 만든다 | producer 재시작 + outbox `IN_FLIGHT` 재claim 경로가 겹칠 때만 발생하는데 그 조합을 재현해 본 적이 없다 | 발행 직후 프로세스를 강제 종료하고 재기동해 같은 event 가 broker 에 2건 들어가는지 확인 | `planned` |
|
||||
| ~~sibling drift gate 가 outbox envelope payload 를 덮는다~~ → **확인 완료: 덮지 않는다.** 남은 검증 대상은 "그 gap 을 무엇으로 닫을 것인가" | 2026-07-28 depth 게이트 1회차에서 [[raw/branch-notes/feature-schema-serialization-contract]] D5 를 직접 읽어 확인했다 — 범위가 **response-side OpenAPI drift** 이고, 등급이 `UNSUPPORTED_DECISION` 이며, Open Risk 가 "sibling branch 미작성/미착수 시 … 본 branch 완료 후에도 미보증 상태" 라고 스스로 적는다. 즉 **outbox payload 를 덮는 빌드 단계 gate 는 없다** → D8 Open Risk 로 승격 | 두 길 중 선택: (i) outbox envelope 전용 schema snapshot test 를 본 branch 범위로 끌어와 `adapter-contract:*` 로 등록, (ii) Registry serializer 조건부 분기를 앞당겨 런타임 강제로 전환(= deferred 7건 수집 선행) | `planned` |
|
||||
| `multiInstanceSupported = true`(D14)가 multi-instance relay 에서도 성립한다 | 이 값은 `KAFKA-PROD-JD-C4`(idempotence 는 단일 세션 한정)에서 **추론**한 것이지 벤더 진술이 아니다. 현재 outbox relay 가 single-instance(sibling outbox D8)라 문제가 드러나지 않을 뿐, relay 를 multi-instance 로 켜면 같은 aggregate 의 event 를 서로 다른 producer 가 보낼 수 있고 중복 억제는 인스턴스별로만 성립한다 | `APP_MULTI_INSTANCE_ENABLED=true` + relay 2인스턴스 구성에서 같은 aggregate 의 event 발행 순서·중복을 관측 | `needs-confirmation` |
|
||||
| `KAFKA-SEM-C1`~`C5` 가 Apache Kafka 원문과 동일하다 | 이 5개 claim 은 Apache 원문이 아니라 **Confluent 미러**에서 인용했다(A7). 문장이 upstream 과 다를 수 있다 | Apache 4.3 문서 사이트의 design/delivery-semantics 경로를 버전 경로 추적으로 확보해 5개 인용을 대조 | `needs-confirmation` |
|
||||
| `max.request.size` 의 기본값과 의미 | §구현 가이드 1 의 표에서 **유일하게 근거 없이 비어 있는 행**이다. 이번 회차 인용에 포함되지 않았다 | `kafka-producer-configs-delivery-timeout.md` 의 원본 페이지에서 해당 항목 추가 발췌 | `planned` |
|
||||
| 본 branch 가 소유할 관심사가 sibling branch 결정과 겹치지 않는다 | D-row 13개 작성 후에도 owner 경계가 문장으로만 존재하며, A4·A9 는 이미 겹침을 발견했다 | `/sync` 실행 — owner 중복·재진술 검출 | `needs-confirmation` |
|
||||
|
||||
## Coverage / 관심사 커버리지 (coverage-auditor 자동 생성 — 손으로 유지하지 않는다)
|
||||
|
||||
> 2026-07-28 `coverage-auditor` **2회차 판정 = `Covered`** (Blocking 0 / Should-fix 0 / Advisory 7). 1회차는 `Not-covered`(Blocking 1: `requiredBackingRole` 부재)였고 D14 + §구현 가이드 9 신설로 해소됐다.
|
||||
> governing: project note §11 · §25 · §36 · §8.0 + 분해 설계 §4.2 #4 행. 기준 위계는 `rules/coverage-gate.md` §1.
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|--------|------|-------|--------|------|
|
||||
| acknowledgement-aware send 결과 | covered-here | — | — | D3 (`blocked-on-sibling` 은 *착수* 차단이지 결정 부재가 아님 — depth 소관) |
|
||||
| `acks=all` + idempotent producer | covered-here | — | — | D1 · §구현 가이드 1 |
|
||||
| bounded delivery timeout·재시도 예산 | covered-here | — | — | D4 |
|
||||
| key/partition 순서 경계 | covered-here | — | — | D5 · §구현 가이드 3 |
|
||||
| 압축·배치 상한 | covered-here | — | — | D6 |
|
||||
| TLS/SASL + secret 분류 | covered-here | — | — | D7 · §구현 가이드 4 |
|
||||
| 스키마 serializer·호환성 검증 | covered-here | — | — | D8 (조건부 분기 + 전제 반증 기록) |
|
||||
| graceful flush·shutdown | covered-here | — | — | D9 · §구현 가이드 5 |
|
||||
| producer 실패 정책 (§11 매트릭스) | covered-here | — | — | D11 · §구현 가이드 8 |
|
||||
| correlationId 전파 | covered-here | — | — | D10 (`headers.yaml:166` verbatim 확인) |
|
||||
| **capability descriptor 필드 9종 (§36.3)** | **covered-here** | — | — | **D14 · §구현 가이드 9 — sibling 필드표 9개 1:1 대응 확인 (1회차 Blocking 해소)** |
|
||||
| producer trace span | covered-here (유예 선언) | [[raw/branch-notes/feature-distributed-tracing-contract]] | — | D13 · §구현 가이드 8 — "R1 범위 밖, R2 시 consume" (1회차 Should-fix 해소) |
|
||||
| producer metric | covered-here (제안, registry 미등록) | — | — | D13 · §구현 가이드 8 · §Audit A6 |
|
||||
| consumer·inbox·deserialization·DLT | delegated | [[raw/branch-notes/feature-kafka-consumer-inbox-contract]] (#064) | OK | §제외 범위 명시 링크 |
|
||||
| outbox 행 모델·`FAILED`/`DEAD` 전이 | delegated | [[raw/branch-notes/feature-domain-event-outbox-contract]] D13 · #072 | OK | §구현 가이드 8 OUT_OF_BRANCH_SCOPE |
|
||||
| `MessageBroker` SPI 소유권 | delegated | [[raw/branch-notes/feature-messaging-multibroker-router]] | OK (링크 有, 사용자 결정 대기) | §구현 가이드 2 OUT_OF_BRANCH_SCOPE · §Audit A9 · §TODO 미결 1 |
|
||||
| §36.2 R2 card 항목 (토폴로지·비용 모델·unsafe recipe·rolling upgrade·runbook 링크) | N/A-now | 본 branch (R2 승급 시) | ⚪ Advisory ×5 | D12 가 readiness 를 `R1` 로 고정 — §36.2 는 "R2 **이상을 주장하는** capability" 에만 발동 |
|
||||
| Schema Registry 대안 근거 (deferred 7건) | covered-here(기본 분기) | — | ⚪ Advisory | D8 — 조건부 분기만 근거 대기 |
|
||||
| `capabilityId` 문자열 확정 | delegated | [[raw/branch-notes/feature-capability-provider-selection-contract]] | ⚪ Advisory | 그 branch 자체가 어휘 미결(`UNSUPPORTED_IMPL_DECISION`) |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
아직 없음 (구현 착수 전).
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
### 외부 근거 (2026-07-28 수집)
|
||||
|
||||
- [[raw/official-docs/kafka-producer-javadoc-acks-idempotence]]
|
||||
- [[raw/official-docs/kafka-producer-configs-delivery-timeout]]
|
||||
- [[raw/official-docs/kafka-message-delivery-semantics-design]]
|
||||
- [[raw/official-docs/kafka-client-security-ssl-sasl-config]]
|
||||
- [[raw/official-docs/spring-kafka-sending-messages-kafkatemplate]]
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
해당 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -0,0 +1,596 @@
|
||||
---
|
||||
title: branch / feature-mongo-runtime-baseline-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-mongo-runtime-baseline-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||||
tags: [branch, ca-skeleton, mongodb, persistence, change-stream, index-manifest]
|
||||
created: 2026-07-28
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-065
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-065
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MONGO-BASELINE-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-OPTIONAL-ADAPTER-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-060]
|
||||
imports: []
|
||||
delegates: []
|
||||
accepts_delegations: []
|
||||
contract_packet: 1
|
||||
---
|
||||
|
||||
# branch: feature-mongo-runtime-baseline-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
> **2026-07-28 `/branch-spec` 완료** — D1~D18(+D9-a) + §구현 가이드 10절 + §Audit 6건 작성. 근거는 공식 벤더 문서 13건(verbatim + self-grep 검증)과 ca-tmpl `internal-code-fact`. 착수 시점 readiness 는 **`R0` Contract**(project note §36.1).
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> 분해 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §4.2 — 기술 런타임 (Tier T). 본 branch 는 project §8.0 `WI-CA-SKELETON-OPERATIONAL-CONTRACT-065` 의 실행 단위다.
|
||||
|
||||
형제 branch (같은 부모의 다른 자식 — 인접 영역):
|
||||
|
||||
- [[raw/branch-notes/feature-persistence-failure-baseline]]
|
||||
- [[raw/branch-notes/feature-read-consistency-query-contract]]
|
||||
- [[raw/branch-notes/feature-outbox-dispatch-mode-contract]]
|
||||
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
> project Work Item 에서 내려온 실행 계약의 snapshot. 여기에는 **pinned pointer + 1줄 요약 + branch 적용점**만 쓰고 상세를 복제하지 않는다.
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: concern·index manifest·replica-set 트랜잭션·change stream checkpoint test 가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MONGO-BASELINE-001@1` | MongoDB는 read preference·read concern·write concern을 명시하고 트랜잭션과 change stream은 replica-set 요건 검증을 통과할 때만 활성화한다 | concern 3축은 D5·D6·D7 이 명시 규칙으로, replica-set 요건 검증은 D8(transaction)·D11(change stream)과 §구현 가이드 4 의 공통 게이트로 구체화 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-OPTIONAL-ADAPTER-001@1` | optional adapter stack은 축마다 구현체 하나를 고정하고 core stack과 분리된 matrix로 관리한다 | D1 이 Mongo 축을 boolean leaf 하나로 고정(provider 축 미승격). index 도구 축도 하나로 고정 — D13 이 자체 러너를 택하고 Mongock·Liquibase 를 명시적으로 기각 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 상세 근거와 선택 조건은 아래 §결정-근거 매핑의 동일 D-row 가 소유한다. 여기에는 요약과 관계만 둔다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | 활성화 축은 boolean leaf 유지 (`persistence-mongo.enabled`), provider 축으로 승격하지 않음 | `refines DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-OPTIONAL-ADAPTER-001@1` | `internal-code-fact` + sibling [[raw/branch-notes/feature-capability-provider-selection-contract]] D2 | `proposed` |
|
||||
| D2 | 연결 URI 는 Spring 표준 키가 소유하되, 활성인데 URI 미설정이면 startup 거부 | `local` | `internal-code-fact` + `MONGO-CONNSTR-C7` | `proposed` |
|
||||
| D3 | timeout 3종·pool 2종을 드라이버 기본값에 맡기지 않고 명시 | `local` | `MONGO-CONNSTR-C1`~`C5` | `proposed` |
|
||||
| D4 | `tls` 를 연결 문자열 형식의 기본값에 맡기지 않고 명시 | `local` | `MONGO-CONNSTR-C7` | `proposed` |
|
||||
| D5 | read concern 을 연산 단위로 명시, 기본 `majority` · `available` 금지 | `refines DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MONGO-BASELINE-001@1` | `MONGO-READCONCERN-C1`~`C12` | `proposed` |
|
||||
| D6 | write concern 을 명시하고 `wtimeout` 초과를 "write 취소"로 해석하지 않음 | `refines DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MONGO-BASELINE-001@1` | `MONGO-WRITECONCERN-C2`~`C8` | `proposed` |
|
||||
| D7 | read preference 기본 `primary`, secondary 는 stale 허용 선언 경로만 | `refines DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MONGO-BASELINE-001@1` | `MONGO-READPREF-C1`,`C3`,`C4`,`C5` | `proposed` |
|
||||
| D8 | transaction 은 토폴로지·FCV·storage engine 검증 통과 시에만 활성화 | `refines DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MONGO-BASELINE-001@1` | `MONGO-TXN-C2`~`C4` · `MONGO-TXN-PROD-C1` | `proposed` |
|
||||
| D9 | transaction 의 시간·크기 한계를 계약에 명시하고 무제한 사용을 전제하지 않음 | `local` | `MONGO-TXN-PROD-C2`~`C5` | `proposed` |
|
||||
| D10 | 드라이버 retryable writes 1회 재시도 위에 애플리케이션 재시도를 중첩하지 않음 | `local` | `MONGO-RETRYWRITE-C3`,`C5`,`C6` | `proposed` |
|
||||
| D11 | change stream 은 토폴로지 검증 통과 시에만 활성화하고 resume token 을 영속화 | `refines DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MONGO-BASELINE-001@1` | `MONGO-CHANGESTREAM-C1`,`C2`,`C4`~`C8` | `proposed` |
|
||||
| D12 | oplog window 를 감시해 resume token 무효화 **전에** 경보 | `local` | `MONGO-CHANGESTREAM-C7` | `proposed` |
|
||||
| D13 | index 는 auto-index-creation 금지 + 선언적 manifest + drift 감지, 구현은 자체 `IndexOperations` 러너 | `local` | `SD-MONGO-INDEX-C1`~`C3`,`C5` · `MONGOCK-C2` · `LIQUIBASE-MONGO-PRO-C1` | `proposed` |
|
||||
| D14 | index migration runner 는 단일 실행을 보장하는 lock 을 갖는다 | `local` | `MONGOCK-C3` + project note §25 Multi-Instance Guardrail | `proposed` |
|
||||
| D15 | Mongo 실패 분류는 기존 `Category` enum 재사용, error code 는 **신규 제안** | `local` | `internal-code-fact` | `proposed` |
|
||||
| D16 | mongo 모듈에 JPA/Hibernate/Flyway/PostgreSQL 의존 금지 ArchUnit rule 신설 | `local` | `internal-code-fact` (`CleanArchitectureTest.java:823-828`) | `proposed` |
|
||||
| D17 | same-store Mongo outbox/inbox 는 **가능 조건**만 소유하고 행 모델·프로토콜은 위임 | `local` | `internal-code-fact` + sibling 위임 | `proposed` |
|
||||
| D18 | Mongo 쿼리 filter·document 값이 로그로 새지 않게 driver logger 를 값 미노출 레벨로 고정 | `local` | `MONGO-JAVA-LOG-C1`,`C3`~`C6` | `proposed` |
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- `WI-CA-SKELETON-OPERATIONAL-CONTRACT-065` 의 완료 조건을 구현한다: concern·index manifest·replica-set 트랜잭션·change stream checkpoint test 가 통과한다
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- URI·topology·TLS·credential·timeout·pool 설정
|
||||
- read preference / read concern / write concern 명시 규칙
|
||||
- 트랜잭션·change stream 의 replica-set 요건 검증
|
||||
- index manifest·unique/TTL index·drift 감지·migration runner
|
||||
- change stream resume token·checkpoint 저장소와 oplog window 감시
|
||||
- same-store Mongo outbox/inbox 옵션
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- 예시 업무 document — sample 이 아닌 production 모듈에 두지 않음
|
||||
- PostgreSQL 계약 — [[raw/branch-notes/feature-persistence-failure-baseline]](SQLState 매핑·Hikari) · [[raw/branch-notes/feature-database-connection-pool-contract]] 소유
|
||||
- `ReadConsistency` 정책과 replica 라우팅 판정 — [[raw/branch-notes/feature-read-consistency-query-contract]] 소유. 본 branch 는 Mongo 측 표현 수단만 (D7)
|
||||
- outbox 행 모델·dispatch 모드·owner token 프로토콜 — [[raw/branch-notes/feature-outbox-dispatch-mode-contract]] · [[raw/branch-notes/feature-idempotency-ownership-protocol-contract]] 소유. 본 branch 는 same-store 가능 조건만 (D17)
|
||||
- env key 이름·수치·bounds 등록과 활성화 property prefix 문자열 — [[raw/branch-notes/feature-env-driven-runtime-configuration]] · [[raw/branch-notes/feature-capability-provider-selection-contract]] 소유. 본 branch 는 명시 대상 키 집합만 (D3·D4)
|
||||
- error code / metric 이름 확정 — [[raw/branch-notes/feature-contract-registry-governance]] 절차 소관. 본 branch 는 신규 제안만 (D12·D15)
|
||||
- `TransactionPort` 의 Mongo 구현 여부 — application-core 포트 계약 영향이 있어 조율 선행 (§구현 가이드 7)
|
||||
- project decision registry 변경 — owner 는 project-note
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 2026-07-28 수집 완료 — 13건 전부 `wiki-source-summarizer` 가 verbatim 인용 + self-grep 검증을 마친 `official-doc` 이다. 각 자료의 `Usage Boundaries` 가 **이 자료가 증명하지 않는 것**을 명시하며, 그 gap 은 §검증해야 할 주장으로 승계했다.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/mongodb-connection-string-options]] | timeout(`serverSelectionTimeoutMS`/`connectTimeoutMS`/`socketTimeoutMS`)·pool(`maxPoolSize`/`minPoolSize`/`maxIdleTimeMS`)·`tls`·`retryWrites`/`retryReads` 를 드라이버 기본값에 맡기지 않고 명시하고 env registry 로 노출한다 |
|
||||
| [[raw/official-docs/read-concern-mongodb-official]] | MongoDB 조회는 read concern 을 명시하고 기본값(`local` implicit default)에 의존하지 않는다 — `local`/`available`/`majority`/`linearizable`/`snapshot` 5개 level 의 보증/비보증 차이, `majority` 의 replica-set·multi-document transaction 관계(write concern `"majority"` 커밋 조건부 보장), level별 topology/operation 제약(`linearizable` primary 전용, `snapshot` 트랜잭션 밖 find/aggregate/distinct-unsharded-only) 근거(`MONGO-READCONCERN-C1`~`C12`) |
|
||||
| [[raw/official-docs/write-concern-mongodb-official]] | MongoDB write 는 write concern 을 명시하고 기본값(`w: "majority"` 여부)에 의존하지 않는다 + journaling(`j`) / `wtimeout` 을 명시한다 — `w`/`j`/`wtimeout` 의미, implicit default, `wtimeout` 초과 시 write 가 rollback 되지 않는다는 실패 모드 근거 |
|
||||
| [[raw/official-docs/spring-data-mongodb-template-config-official]] | D5·D6·D7 이 "concern 을 명시한다"고 결정한 것을 **Spring Data `MongoTemplate` API 어디에 붙이는지**(적용 seam) 확인 — `WriteConcernResolver` 가 연산 단위(remove/update/insert/save) write concern 결정 수단으로 실재함(D6 근거), `ReadPreference` 는 template 의 설정 가능 property 로 존재함(D7 근거). **read concern 설정 수단은 이 문서에 부재**(self-grep 0 매치) — D5 의 Spring Data 측 seam 은 이 자료로 닫히지 않으며 `UNSUPPORTED_DECISION` 후보로 승계 |
|
||||
| [[raw/official-docs/spring-data-mongodb-index-management-official]] | automatic index creation 기본값(OFF, 버전 3.0+)과 explicit/programmatic index 생성 권고(`IndexResolver`+`IndexOperations`) 확인 — "auto-index-creation 에 맡기지 않는다" 결정의 부분 근거. "manifest"/"migration runner"/"drift 감지" 자체는 이 자료가 증명하지 않음 (해당 raw 문서 Usage Boundaries 참조) |
|
||||
| [[raw/official-docs/read-preference-mongodb-official]] | 조회 replica 라우팅을 read preference mode로 명시하고 secondary 읽기는 stale read 허용 경로에만 쓴다 — mode 5종 정의 + secondary 읽기 stale 경고 + maxStalenessSeconds 메커니즘 + 트랜잭션 내 read preference 제약(`primary` 고정) 근거. maxStalenessSeconds 의 수치 최소값 제약은 이 자료 범위 밖(별도 페이지 확인 필요, 해당 raw 문서 Usage Boundaries 참조) |
|
||||
| [[raw/official-docs/change-streams-mongodb-official]] | change stream 은 replica set/sharded cluster 요건(WiredTiger, read concern majority 지원 여부 무관)을 검증할 때만 활성화하고, resume token 을 checkpoint 저장소에 영속화하며(resumeAfter/startAfter/startAtOperationTime 3가지 재개 경로), oplog window 부족 시 resume 실패·invalidate event 발생 시 stream 종료를 감지해 `startAfter` 로 재개하는 계약의 근거 |
|
||||
| [[raw/official-docs/retryable-writes-mongodb-official]] | Mongo write 재시도 의미를 명시한다 — 드라이버의 retryable writes 가 무엇을 보장하고 무엇을 보장하지 않는지를 계약에 적고, 애플리케이션 재시도와 겹치지 않게 한다. retryable writes 기본 활성 여부(4.2+ 호환 드라이버) + 재시도 정확히 1회 + 재시도 가능(단일 문서 연산, acknowledged write concern)/불가능(`w:0`, multi-document update/delete, 트랜잭션 내부 개별 write) 연산 목록 + replica-set/sharded-cluster 배포 요건(standalone 불가) 근거 |
|
||||
| [[raw/official-docs/transactions-mongodb-official]] | multi-document transaction 은 replica-set/sharded 요건 검증을 통과할 때만 활성화하고(FCV Replica Set≥4.0/Sharded Cluster≥4.2, primary WiredTiger 요건, `writeConcernMajorityJournalDefault:false` shard 배제) + transaction 은 시간·크기 한계를 가지므로 무제한 사용을 전제하지 않는다(runtime limit 이 공식 usage consideration 으로 인정됨 — 단 구체 수치는 별도 Production Considerations 페이지, 이 자료 범위 밖) 근거. 부수적으로 transaction 내 read concern(기본 `"local"`)/write concern(기본 `w:"majority"`/`w:1"`) 결정 체인 근거 |
|
||||
| [[raw/official-docs/transactions-production-considerations-mongodb-official]] | (부분) transaction 은 시간·크기 한계를 가지므로 무제한 사용을 전제하지 않고, 한계 초과 시 동작을 계약에 명시한다 — runtime limit 기본값("less than one minute", `transactionLifetimeLimitSeconds` 초과 시 periodic cleanup 에 의한 abort)과 oplog entry 크기 한계(transaction 전체 아닌 entry 단위 16MB) 근거. **`TransientTransactionError`/`UnknownTransactionCommitResult` 라벨 기반 재시도 계약은 이 자료로 정당화되지 않음** — 두 라벨 모두 이 URL 에 부재(self-grep 0 매치), 별도 raw 자료 필요(`UNSUPPORTED_DECISION` 후보) |
|
||||
| [[raw/official-docs/liquibase-mongodb-pro-drift-report-official]] | MongoDB index drift 감지를 Liquibase 로 얻지 않고 자체 러너로 구현한다 — Liquibase 의 MongoDB drift report 접근이 **Liquibase MongoDB Pro extension(유료)** 기능 목록에 명시된다는 근거(`LIQUIBASE-MONGO-PRO-C1`, `C2`). **주의**: 무료(OSS) extension 에 drift 가 전혀 없다는 부정 명제는 이 자료가 직접 증명하지 않음(해당 raw 문서 Usage Boundaries 참조) — "무료 티어 미충족"을 최종 결정 근거로 쓰려면 이 gap 을 D-row 에 `needs-confirmation` 으로 명시할 것 |
|
||||
| [[raw/official-docs/mongock-migration-lock-maintenance-official]] | index manifest 적용 + drift 감지를 Mongock 이 아니라 Spring Data `IndexOperations` 기반 자체 러너로 구현한다 — Mongock 미채택 근거(신규 개발이 후속 프로젝트 Flamingock 으로 이전, critical bug fix/security update 만 지속)와 채택 시 이점(멀티 인스턴스 동시 실행 방지 DB 영속 pessimistic lock 내장)을 모두 인지한 trade-off 근거(`MONGOCK-C1`~`C4`) |
|
||||
| [[raw/official-docs/java-driver-logging-mongodb-official]] | MongoDB 쿼리 filter·document 값이 로그로 새지 않게 억제한다(`D18` 후보, §엣지·실패·의존 표에 이미 전방 참조된 결정) — 어느 logger(`org.mongodb.driver.protocol`/하위 `org.mongodb.driver.protocol.command`)가 command 내용을 어느 레벨(DEBUG)로 남기는지, SLF4J 바인딩 설정으로 logger 이름 단위 레벨을 조정하는 방법, `maxDocumentLength()`(기본 1000자)로 로그 메시지 길이를 제한할 수 있으나 이는 truncation 이지 필드 마스킹이 아니라는 것의 근거(`MONGO-JAVA-LOG-C1`~`C6`) |
|
||||
|
||||
**프로젝트 내부 설계 참조 (등급 `internal-design-doc` — 공식 문서 아님, best practice 로 격상 금지):**
|
||||
|
||||
- llm-wiki `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §4.2 #6
|
||||
- ⚠️ ca-tmpl `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md` (§12.3) — **저장소에 존재하지 않음**(§Audit A3). 본 branch 의 D-row 는 이 문서를 인용하지 않으며, 모든 근거는 위 공식 벤더 문서 13건 또는 `internal-code-fact` 다.
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [x] `/branch-spec` 로 D-row(D1~D18)·§구현 가이드 10절·§Audit 6건 작성 — 등급: `actually-implemented` (문서 작업)
|
||||
|
||||
**선행 — 자료 수집 5건** (되돌릴 코드를 먼저 쓰지 않기 위해 착수 앞에 둔다):
|
||||
|
||||
- [ ] Mongock v5 기능/lock 페이지 + Liquibase 무료 OSS extension 기능 목록 — D13 기각 논리 확정용. **D13·D14 코드보다 먼저** — 등급: `planned`
|
||||
- [ ] oplog window 측정 수단 공식 자료 — **D12 코드보다 먼저** — 등급: `planned`
|
||||
- [ ] Spring Data / 드라이버의 **read concern 적용 API** — §구현 가이드 2-1 의 미확정 seam. **D5 코드보다 먼저** — 등급: `planned`
|
||||
- [ ] MongoDB Java driver transactions 에러 처리(재시도 라벨) — D9-a 확정용. **대응 구현 TODO 없음 → 순서 제약 없음** — 등급: `planned`
|
||||
- [ ] MongoDB TTL index 만료 정밀도 · unique index 기존 데이터 실패 — **§구현 가이드 6 의 lock 후보 (a)(unique+TTL 컬렉션)를 채택한다면 D14 코드보다 먼저** (TTL 만료 지연이 lock 안전성의 전제) — 등급: `planned`
|
||||
|
||||
**구현**:
|
||||
|
||||
- [ ] 연결 계약 — URI 필수화 + timeout·pool·tls 명시 (D2·D3·D4) — 등급: `planned`
|
||||
- [ ] concern 3종 명시 규칙 (D5·D6·D7 · §구현 가이드 2-1) — 등급: `planned`
|
||||
- [ ] 토폴로지 검증 게이트 (D8·D11 · §구현 가이드 4) — 등급: `planned`
|
||||
- [ ] 쿼리·document 값 로그 유출 억제 (D18 · §구현 가이드 8) — 등급: `planned`
|
||||
- [ ] ArchUnit rule 신설 (D16) — 등급: `planned`
|
||||
- [ ] Mongo 실패 매핑 registry 신규 제안 (D15) — 등급: `planned`
|
||||
- [ ] change stream checkpoint + oplog window 감시 (D11·D12) — 등급: `planned`
|
||||
- [ ] index manifest + migration runner + drift (D13·D14) — 등급: `planned`
|
||||
- [ ] 로컬 replica-set 컨테이너 + 통합 test — 완료 조건 검증 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- 2026-07-28: 이 branch 착수 시점의 ca-tmpl 상태는 **`R0` Contract** 다 — 모듈은 있으나 wiring 뿐이고, 연결 URI 조차 어디에도 설정돼 있지 않다(§Audit A1). "opt-in 설정만 존재" 라는 project note §34 표기는 정확하다.
|
||||
- 근거 수집은 MongoDB 공식 9건 + Spring 2건 + 도구 2건(Mongock·Liquibase) = 13건. 전부 verbatim 인용 + self-grep 검증을 거쳤다. (2회차 게이트 반영으로 Spring Data template config · MongoDB Java driver logging 2건 추가.)
|
||||
- 수집 과정에서 **부재를 부재로 기록**한 항목이 여러 개 있다(`transactionLifetimeLimitSeconds` 정수값, `TransientTransactionError` 라벨, `maxStalenessSeconds` 하한). 이 값들을 추측해 채우지 않았고 §검증해야 할 주장으로 승계했다.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-07-28: **read/write concern 과 read preference 를 "명시" 로 고정** / 이유: 세 축 모두 기본값이 존재하지만(`local` read concern·`{w:"majority"}` write concern·`primary` read preference) 배포 설정과 연결 문자열 형식에 따라 조용히 달라질 수 있다 / 검토한 대안: 기본값 신뢰(기각 — `MONGO-CONNSTR-C7` 처럼 형식만 바꿔도 뒤집히는 축이 실재) / 근거: [[raw/official-docs/read-concern-mongodb-official]] · [[raw/official-docs/write-concern-mongodb-official]] · [[raw/official-docs/read-preference-mongodb-official]]
|
||||
- 2026-07-28: **index 관리는 자체 `IndexOperations` 러너** / 이유: drift 감지가 이 branch 의 1급 요구사항인데 Mongock 은 EOL 공지 + drift 확인 불가, Liquibase 는 drift 가 Pro 전용 / 검토한 대안: Mongock · Liquibase-mongodb · 앱 밖 배포 파이프라인 Job / 근거: [[raw/official-docs/spring-data-mongodb-index-management-official]] · [[raw/official-docs/mongock-migration-lock-maintenance-official]] · [[raw/official-docs/liquibase-mongodb-pro-drift-report-official]] — 단 기각 논리 2건은 `needs-confirmation`(§검증해야 할 주장 — *Mongock drift 부재* · *Liquibase 무료 티어 drift 부재*)
|
||||
- 2026-07-28: **드라이버 재시도 위에 애플리케이션 재시도를 겹치지 않음** / 이유: `retryWrites` 가 공식 드라이버에서 이미 기본 `true` 이고 재시도는 정확히 1회라, 이를 모르고 재시도를 추가하면 시도 횟수가 2배가 된다 / 근거: [[raw/official-docs/retryable-writes-mongodb-official]] · [[raw/official-docs/mongodb-connection-string-options]]
|
||||
|
||||
<!-- section-id: decision-evidence -->
|
||||
## Decision Evidence Map / 결정-근거 매핑
|
||||
|
||||
> `Supporting Claims` 의 `raw/...#Cn` 은 verbatim 인용 + self-grep 검증을 마친 claim. `internal-code-fact` 는 ca-tmpl 코드를 직접 읽어 확인한 사실(경로·행 명시). 외부 공식 근거가 없는 결정은 `UNSUPPORTED_DECISION` 으로 라벨한다 — 추측으로 채우지 않는다.
|
||||
>
|
||||
> **근거의 두 축을 섞지 않는다.** MongoDB 공식 문서가 증명하는 것은 *메커니즘·기본값*(드라이버·서버가 어떻게 동작하는가)이고, *정책*(그래서 우리는 무엇을 금지·강제하는가)은 대부분 내부 결정이다. 각 행의 Evidence Strength 가 이 경계를 표시한다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | Mongo capability 의 활성화 축은 **boolean leaf** 로 유지한다 — `ca-skeleton.persistence-mongo.enabled`. provider 축(`provider: disabled\|<id>`)으로 승격하지 않는다 | 구현체 대안이 없고 켜고 끄는 것 외 선택지가 없음 → boolean (현행 유지). 관리형 Mongo/DocumentDB 등 **provider 대안이 2개 이상** 생기면 → provider 축으로 이관하고 그때 breaking rename 을 감수. 두 축 동시 보유는 금지 | `internal-code-fact`: `adapter/outbound/persistence-mongo/.../MongoPersistenceConfig.java:33-36` (`@ConditionalOnProperty(prefix="ca-skeleton.persistence-mongo", name="enabled", havingValue="true")`, `matchIfMissing` 미지정), `MongoPersistenceSettings.java:23` (`enabled=false`), `app-bootstrap/.../application.yml:336-338`. sibling: [[raw/branch-notes/feature-capability-provider-selection-contract]] D2(축 단일화)·D4(`matchIfMissing=true` 금지 — 본 모듈은 미지정이므로 이미 준수) | **정책 `internal-policy` (sibling 결정 상속) / 현행 `internal-code-fact`** | prefix 통일(sibling D3)이 `app.*` 로 확정되면 `ca-skeleton.persistence-mongo.*` 는 rename 대상이 된다. 본 branch 는 축만 고정하고 **prefix 문자열은 sibling 이 소유** — 두 결정이 다른 PR 로 나뉘면 이행 창에 키가 둘로 갈린다 |
|
||||
| D2 | 연결 URI 는 Spring 표준 `spring.data.mongodb.uri` 가 계속 소유한다(모듈 Settings 로 옮기지 않음). 단 **capability 가 활성인데 URI 가 미설정이면 startup 을 거부**한다 | URI 는 운영자가 이미 아는 표준 키 → Spring 소유 유지. 모듈이 URI 를 자체 property 로 재정의 → 키가 둘이 되어 `verifyEnvKeys` 3-way drift 검사(sibling env-driven D7)를 통과할 수 없으므로 선택 안 함. **미설정 시 조용한 기본값 사용은 어느 조건에서도 선택하지 않는다** | `internal-code-fact`: `MongoPersistenceSettings.java:10-13` 이 URI 미모델링을 **의도적 결정**으로 주석에 명시. 그러나 `spring.data.mongodb.*` 는 `application.yml`·`src/.env`(126행) 어디에도 **존재하지 않음**(grep 0건). → 플래그를 켰을 때 **드라이버/Spring 기본 URI 로 연결을 시도하는지, 아니면 Spring 이 먼저 실패시키는지는 `needs-confirmation`** — 수집한 13건 중 Spring Boot `MongoProperties` 기본값을 다루는 자료가 없다(§검증해야 할 주장 — *플래그를 켰을 때 기본 URI 로 붙는지*). **어느 쪽이든 "설정하지 않은 채 활성화" 가 유효한 상태가 아니라는 점은 같으므로 D2 의 거부 정책은 성립한다.** `MONGO-CONNSTR-C7`: `tls` 기본값이 연결 문자열 **형식마다 다르므로**(SRV=true / Standard=false) URI 문자열 자체가 보안 결정을 담는다. 실패 코드는 기존 `STARTUP_VALIDATION_FAILED`(`error-codes.yaml:841`, owner `feature-migration-startup-contract`) 재사용 | **`internal-code-fact` + 메커니즘 `official-vendor-doc`(C7) / 거부 정책 `internal-policy`** | 이 gap 은 **현재 실재한다** — §Audit A1 참조. env-keys.yaml 에 mongo row 가 0개라 `verifyEnvKeys` 도 이 부재를 잡지 못한다. registry 등록이 선행돼야 startup 검증이 성립한다 |
|
||||
| D3 | timeout 3종(`serverSelectionTimeoutMS`·`connectTimeoutMS`·`socketTimeoutMS`)과 pool 2종(`maxPoolSize`·`minPoolSize`)을 **드라이버 기본값에 맡기지 않고 명시**한다 | 운영 환경(dev/staging/prod) → 5개 전부 명시 필수. 로컬 단일 개발자 실행 → 기본값 허용하되 그 사실을 프로파일에 명시. **`socketTimeoutMS` 만은 어느 환경에서도 명시**한다 — 기본값이 무제한이라 미명시 = 무한 대기 | `MONGO-CONNSTR-C3`(`raw/official-docs/mongodb-connection-string-options.md#MONGO-CONNSTR-C3` — "The time in milliseconds to attempt a send or receive on a socket before the attempt times out. **The default is no timeout**, though different drivers might vary."), `C1`(serverSelection 기본 30,000ms), `C2`(connect 기본 10,000ms, 드라이버별 상이), `C4`(maxPoolSize 기본 100), `C5`(minPoolSize 기본 0) | **기본값 `official-vendor-doc` / 명시 정책 `internal-policy`** | 값 자체는 본 branch 소유가 아니다 — sibling [[raw/branch-notes/feature-database-connection-pool-contract]] 가 Hikari 값을 `feature-env-driven-runtime-configuration` 에 위임한 선례와 동일하게, **본 branch 는 "명시 대상 키 집합"만 고정하고 수치는 env-driven 이 소유**한다. `maxIdleTimeMS` 는 원문에 숫자 기본값이 없어(`C6`) 명시 대상에서 제외 — 필요해지면 별도 결정 |
|
||||
| D4 | `tls` 를 연결 문자열 형식의 암묵적 기본값에 맡기지 않고 **항상 명시**한다 | prod/staging → `tls=true` 명시. 로컬 docker → `tls=false` 를 **명시**(생략 금지). SRV 형식이라 "어차피 true" 라는 이유로 생략하는 것은 선택하지 않는다 | `MONGO-CONNSTR-C7`(`#MONGO-CONNSTR-C7` — `tls` 기본값이 SRV 형식은 `true`, Standard 형식은 `false`). 즉 **연결 문자열 형식을 바꾸면 TLS 여부가 조용히 뒤집힌다** | **메커니즘 `official-vendor-doc` / 명시 정책 `internal-policy`** | URI 는 secret 분류 대상(자격증명 포함)이라 `secrets-classification.yaml` row 가 필요한데 현재 0건 — owner 는 [[raw/branch-notes/feature-secrets-config-source-contract]]. 본 branch 는 **신규 제안**만 하고 등록은 그 branch 절차를 따른다 |
|
||||
| D5 | 조회는 read concern 을 **연산 단위로 명시**하고 기본은 `majority` 로 한다. `available` 은 금지, `linearizable`·`snapshot` 은 제약 충족 시에만 | 정확성이 필요한 업무 조회 → `majority`. 오래된 데이터를 감수해도 되는 대량 분석/근사 조회 → `local` 을 **명시적 opt-in** 으로만. 단일 document 를 고유 식별하는 강한 읽기 → `linearizable`(primary 전용). 트랜잭션 또는 `find`/`aggregate`/`distinct`(unsharded) 의 시점 고정 조회 → `snapshot`. **sharded collection 조회 → `available` 금지** | `MONGO-READCONCERN-C2`(`raw/official-docs/read-concern-mongodb-official.md#MONGO-READCONCERN-C2` — `local` 이 "Default for reads against the primary and secondaries"), `C1`(local 은 과반수 기록 보장 없음 · "Data may be rolled back"), `C4`(majority 는 "Returned documents are durable, even if a failure occurs"), `C5`(majority 는 WiredTiger 요건), `C3`(available 은 sharded 조회에서 orphaned document 반환), `C7`/`C8`(linearizable 은 primary + 단일 document 식별 필터일 때만), `C9`~`C11`(snapshot 의 범위·연산 제한), `C6`(트랜잭션 안의 majority 는 write concern `"majority"` 커밋일 때만 보장) | **`official-vendor-doc`** — level 별 보증/제약은 전부 벤더 진술. "기본을 majority 로" 라는 **선택 자체는 `internal-policy`** | `C6`/`C10` 때문에 read concern 단독으로는 트랜잭션 안에서 보증이 성립하지 않는다 — D6 의 write concern 결정과 **쌍으로만** 유효하다. 두 결정을 따로 적용하면 트랜잭션 경로에서 보증이 조용히 사라진다 |
|
||||
| D6 | write 는 write concern 을 명시하고, **`wtimeout` 초과를 "write 취소"로 해석하지 않는다**. 트랜잭션 내부 개별 write 에는 write concern 을 설정하지 않는다 | 업무 write → `w:"majority"` 명시. 유실 감수 가능한 보조 write → 낮은 `w` 를 **명시적 opt-in**. `j` 는 배포의 journaling 정책에 따라 선언. `wtimeout` 은 명시하되 **초과 시 재시도는 멱등성이 보장된 연산에만** 허용 | `MONGO-WRITECONCERN-C7`(`raw/official-docs/write-concern-mongodb-official.md#MONGO-WRITECONCERN-C7` — "When these write operations return, MongoDB does not undo successful data modifications performed before the write concern exceeded the `wtimeout` time limit"), `C6`(한계 초과 시 "even if the required write concern will eventually succeed" write concern error 반환), `C5`(wtimeout 은 primary 성공 **후** 전파 시간 제한), `C3`(majority 의미), `C4`(j 의미), `C8`(implicit default 가 `{w:"majority"}`), `MONGO-TXN-C7`(트랜잭션 내부 개별 write 에 write concern 설정 시 에러) | **`official-vendor-doc`** — 특히 C7 은 실패 해석의 핵심 벤더 진술 | C8 이 "implicit default 가 이미 majority" 라고 말하므로 명시가 무의미해 보일 수 있으나, 배포가 CWWC(cluster-wide write concern)를 바꾸면 기본값이 달라진다 — 이 branch 가 명시를 요구하는 이유이나 **CWWC override 메커니즘 자체는 이번 회차에서 조사하지 않았다**(비존재 단정 아님) |
|
||||
| D7 | 조회의 replica 라우팅은 read preference 로 명시하고 **기본은 `primary`**, secondary 읽기는 stale 을 허용한다고 **선언한 경로에만** 허용한다 | 기본/미지정 조회 → `primary`. stale 감수를 선언한 조회 → `secondaryPreferred` + `maxStalenessSeconds` 명시. read 를 포함한 트랜잭션 → **`primary` 강제**(선택지 없음). `nearest` 는 지연 최적화가 정확성보다 중요한 경로에만 | `MONGO-READPREF-C1`(`raw/official-docs/read-preference-mongodb-official.md#MONGO-READPREF-C1` — primary 가 default), `C3`("All read preference modes except `primary` may return stale data ... Ensure that your application can tolerate stale data"), `C4`(maxStalenessSeconds 로 지연 상한 지정), `C5`("Transactions that contain read operations must use read preference `primary`"), `C2`/`C6`/`C7`/`C8`(mode 별 fallback 동작) | **`official-vendor-doc`(메커니즘·제약) / "언제 stale 을 허용하는가" 는 `internal-policy`** | **`ReadConsistency` 정책 → 라우팅 판정 매핑은 본 branch 소유가 아니다** — [[raw/branch-notes/feature-read-consistency-query-contract]] 가 `DEC-…-READ-CONSISTENCY-001@1` 의 owner 다. 본 branch 는 *Mongo 측 표현 수단*(mode·maxStalenessSeconds)만 고정한다. 또한 `maxStalenessSeconds` 의 **수치 하한은 미확보**(해당 raw 의 Usage Boundaries — 별도 페이지) |
|
||||
| D8 | multi-document transaction 은 **토폴로지·FCV·storage engine 검증을 통과할 때만** 활성화한다. standalone 배포에서는 활성화 자체를 거부한다 | 여러 document/collection 에 걸친 원자성이 실제로 필요 → transaction 활성화 + 기동 시 요건 검증. **단일 document 로 모델링 가능** → transaction 을 쓰지 않는다(이미 원자적). 요건 미충족 배포 → `STARTUP_VALIDATION_FAILED` 로 기동 거부(조용한 비활성 아님) | `MONGO-TXN-PROD-C1`(`raw/official-docs/transactions-production-considerations-mongodb-official.md#MONGO-TXN-PROD-C1` — "MongoDB standalone deployments do not support transactions. To use transactions, your deployment must be a multiple node replica set."), `MONGO-TXN-C2`(FCV — Replica Set ≥`4.0` / Sharded Cluster ≥`4.2`), `C3`(primary WiredTiger, secondary WiredTiger 또는 in-memory), `C4`(`writeConcernMajorityJournalDefault:false` shard 가 있는 sharded cluster 에서 실행 불가), `C1`("In MongoDB, an operation on a single document is atomic" — transaction 이 많은 경우 불필요) | **`official-vendor-doc`(요건) / 기동 거부 정책 `internal-policy`** | 검증 시점은 sibling [[raw/branch-notes/feature-capability-provider-selection-contract]] D6(refresh 완료 **전**, `SmartInitializingSingleton`)을 상속해야 한다 — 본 branch 가 별도 시점을 정의하면 startup 검증이 두 곳으로 갈린다. **FCV·storage engine 을 런타임에 어떤 명령으로 질의할지는 미정** — §구현 가이드 4 의 `UNSUPPORTED_IMPL_DECISION` |
|
||||
| D9 | transaction 의 **시간·크기 한계를 계약에 명시**하고 무제한 사용을 전제하지 않는다 | 기본 → 1분 미만 runtime 을 전제로 트랜잭션 범위를 설계. 1분을 넘길 수밖에 없는 배치성 작업 → transaction 이 아니라 **분할 + 멱등 재시도**로 설계(파라미터 상향은 마지막 수단). 대량 write → oplog entry 당 16MB 한계를 설계 제약으로 취급 | `MONGO-TXN-PROD-C2`(`#MONGO-TXN-PROD-C2` — "By default, a transaction must have a runtime of less than one minute. You can modify this limit using transactionLifetimeLimitSeconds" + "Transactions that exceeds this limit are considered expired and will be aborted by a periodic cleanup process."), `C3`(전체 16MB 한계는 제거됐으나 **개별 oplog entry 는 여전히 16MB BSON 한계**), `C4`(`TransactionTooLargeForCache` 에러와 함께 abort), `C5`("When you encounter an error during individual operation in the transaction, abort and retry the transaction.") | **`official-vendor-doc`** — 단 **정확한 기본 정수 초는 원문에 없음**("less than one minute" 서술만). "60초" 라고 적지 않는다 | `TransientTransactionError` / `UnknownTransactionCommitResult` 라벨 기반 **재시도 계약은 근거 미확보** — 두 라벨 모두 해당 URL 에 self-grep 0 매치. 아래 D9-a 로 분리해 `UNSUPPORTED_DECISION` 라벨 유지 |
|
||||
| D9-a | 트랜잭션 재시도 책임(어떤 에러 라벨에서 트랜잭션 전체를 재시도할 것인가)을 애플리케이션 계약으로 못박는다 | (선택 조건 미확정 — 근거 확보 후 작성) | **조사 범위 내 미확보** — `MONGO-TXN-PROD-C5` 는 "에러 시 abort 후 retry" 라는 **사후 대응 권고**만 제공하고 라벨 이름을 주지 않는다. `TransientTransactionError`·`UnknownTransactionCommitResult` 는 해당 URL 에 부재(grep 0 매치, 비존재 단정 아님 — 드라이버별 에러 처리 페이지 미조사) | **`UNSUPPORTED_DECISION`** | 라벨 없이 재시도 조건을 코드에 박으면 "어떤 에러에서 재시도해야 하는가" 를 구현자가 임의로 정하게 된다. **착수 전 별도 dispatch 1건**(MongoDB Java driver transactions 에러 처리 페이지)으로 닫는 것이 권고 |
|
||||
| D10 | 드라이버의 retryable writes(기본 1회 재시도) 위에 **애플리케이션 재시도를 중첩하지 않는다**. 재시도가 필요한 경로는 멱등성을 스스로 보장한다 | 단일 document write → 드라이버 재시도에 위임(추가 재시도 없음). `updateMany`/`deleteMany` 등 multi-document write → 드라이버가 재시도하지 않으므로 **멱등 설계 + 명시적 재시도**가 필요. `w:0` → 재시도 대상이 아니므로 업무 write 에 사용 금지. 트랜잭션 내부 write → 개별 재시도가 없으므로 **트랜잭션 전체 재시도**(D9-a 소관) | `MONGO-RETRYWRITE-C3`(`raw/official-docs/retryable-writes-mongodb-official.md#MONGO-RETRYWRITE-C3` — "**By default, MongoDB retries writes once**. One retry attempts to address transient network errors and replica set elections, but not persistent network errors."), `C5`(`w:0` 및 `updateMany`/`deleteMany` 는 재시도 불가), `C6`(트랜잭션 내부 write 는 개별 재시도 없음, commit/abort 는 `retryWrites` 와 무관하게 1회 재시도), `C4`(재시도 대상 = acknowledged write concern + 단일 document 연산), `C1`(standalone 미지원), `C2`(4.2+ 드라이버 기본 활성), `C7`(6.1+ `NoWritesPerformed` 라벨) | **`official-vendor-doc`(재시도 의미) / 중첩 금지 정책 `internal-policy`** | 기본이 `true`(`MONGO-CONNSTR-C8`)라 **아무도 결정하지 않아도 재시도가 이미 일어나고 있다** — 이 사실을 모르는 상태에서 애플리케이션 재시도를 추가하면 실제 시도 횟수가 2배가 된다. D3 의 명시 대상 키에 `retryWrites` 를 포함할지는 §구현 가이드 2 에서 다룬다 |
|
||||
| D11 | change stream 은 **토폴로지 검증을 통과할 때만** 활성화하고, resume token 을 checkpoint 저장소에 **영속화**한다. invalidate 이후 재개는 `startAfter` 로 한다 | replica set / sharded cluster + WiredTiger → 활성화 허용. standalone → 활성화 거부. 정상 재개 → `resumeAfter`(저장된 token). **invalidate 발생 후 재개 → `startAfter`**(`resumeAfter` 는 invalidate 이후 재개 불가). token 이 없는 최초 기동 → `startAtOperationTime` | `MONGO-CHANGESTREAM-C1`(`raw/official-docs/change-streams-mongodb-official.md#MONGO-CHANGESTREAM-C1` — "Change streams are available for replica sets and sharded clusters"), `C2`(WiredTiger 요건), `C4`(resumeAfter), `C5`("Unlike resumeAfter , startAfter can resume notifications after an invalidate event"), `C6`(startAtOperationTime 은 oplog 시간 범위 안이어야 함), `C8`(cursor 종료 4조건 — 명시적 close / invalidate / 연결 종료·timeout / shard 제거), `C3`(read concern majority 지원 여부와 **무관**하게 사용 가능) | **`official-vendor-doc`** | checkpoint **저장소 선택**(same-store Mongo 컬렉션 vs 별도 store)은 근거 없음 → §구현 가이드 5 의 `UNSUPPORTED_IMPL_DECISION`. 또한 checkpoint 저장이 이벤트 처리와 원자적이지 않으면 **at-least-once** 가 되며, 이 보증 등급은 project note §11 의 durable 경로 정책과 정합돼야 한다 |
|
||||
| D12 | oplog window 를 감시해 **resume token 이 무효화되기 전에** 경보한다 | change stream 을 활성화한 배포 → 감시 필수. 비활성 배포 → 해당 없음. 경보 임계는 "남은 oplog 시간 < 소비자 최대 다운타임 허용치" 로 정의 | `MONGO-CHANGESTREAM-C7`(`#MONGO-CHANGESTREAM-C7` — "The oplog must have enough history to locate the operation associated with the token or the timestamp, if the timestamp is in the past.") — 즉 oplog 가 롤오버되면 **저장해 둔 token 으로 재개할 수 없다** | **위험 근거 `official-vendor-doc` / 임계값·metric 이름은 `internal-policy`** | metric 이름·임계값은 **신규 제안**이다 — `metrics.yaml` 에 mongo row 가 0건이고 registry owner 절차(sibling [[raw/branch-notes/feature-contract-registry-governance]])를 거쳐야 한다. 본 branch 단독으로 확정 불가 |
|
||||
| D13 | index 는 **automatic index creation 에 맡기지 않고** 선언적 manifest + 적용 러너 + drift 감지로 관리하며, 구현은 **자체 `IndexOperations` 러너**로 한다(Mongock·Liquibase 미채택) | drift 감지가 1급 요구사항이고 Spring Boot 4 호환 리스크를 없애야 함 → 자체 러너(기본). changelog 이력·rollback 추적이 drift 보다 중요 → Mongock 을 **시한부**로 채택하되 EOL 을 계약에 명시. 조직이 이미 Liquibase Pro 라이선스 보유 → Liquibase-mongodb Pro | `SD-MONGO-INDEX-C1`(`raw/official-docs/spring-data-mongodb-index-management-official.md#SD-MONGO-INDEX-C1` — "Automatic index creation is turned OFF by default as of version 3.0"), `C2`("index creation must be explicitly enabled since version 3.0 to prevent undesired effects on collection lifecycle and performance"), `C3`(programmatic 생성이 "(Recommended)" 로 표기, `IndexResolver`+`IndexOperations`), `C5`("Explicit index creation provides better control than automatic creation"). **대안 기각 근거** — `MONGOCK-C2`(`raw/official-docs/mongock-migration-lock-maintenance-official.md#MONGOCK-C2` — "Mongock will continue receiving critical bug fixes and security updates only. All innovation is happening in Flamingock."), `LIQUIBASE-MONGO-PRO-C1`(`raw/official-docs/liquibase-mongodb-pro-drift-report-official.md#LIQUIBASE-MONGO-PRO-C1` — drift report 접근이 **Pro** 기능 목록에 포함) | **채택 근거 `official-vendor-doc` / 기각 근거 `official-vendor-doc`(EOL·Pro gating) + `needs-confirmation`** | **기각 논리에 gap 이 있다** — Liquibase 자료는 "Pro 에 drift 가 있다" 를 증명할 뿐 "**무료 OSS extension 에 drift 가 없다**" 는 부정 명제를 직접 증명하지 않는다(해당 raw 의 Usage Boundaries). Mongock 역시 "drift 기능이 없다" 가 아니라 "이 페이지에서 확인되지 않음" 이다. 따라서 **기각은 `needs-confirmation`** 이며 §검증해야 할 주장 2행으로 승계한다. manifest 포맷·diff 알고리즘은 §구현 가이드 6 의 `UNSUPPORTED_IMPL_DECISION` |
|
||||
| D14 | index migration runner 는 **단일 실행을 보장하는 lock** 을 갖는다 | multi-instance 배포 → lock 필수. 단일 인스턴스 로컬 → lock 없이 실행 허용하되 그 사실을 명시. lock 획득 실패 → 기동 거부가 아니라 **대기 후 실패**(다른 인스턴스가 적용 중일 수 있음) | `MONGOCK-C3`(`#MONGOCK-C3` — "As more than one instance of the client-service may be running simultaneusly in the environment [...] Mongock uses a pesimistic lock that is persisted in database") — 기성 도구도 이 문제를 DB 영속 lock 으로 푼다는 **벤더 확인**. project note §25 Multi-Instance Guardrail("migration runner: one app startup runner / multi-instance 활성화 시 migration lock 검증 필요"). sibling: [[raw/branch-notes/feature-distributed-lock-contract]] 가 migration runner lock 을 **자기 범위 밖**으로 선언 → 범용 `DistributedLockPort` 재사용 아님 | **문제 실재 `official-vendor-doc` / lock 메커니즘 선택 `internal-policy`** | lock 구현 수단(unique index + TTL 컬렉션)은 근거 없음 → §구현 가이드 6 의 `UNSUPPORTED_IMPL_DECISION`. 특히 **TTL index 의 만료 정밀도**가 lock 안전성에 미치는 영향은 미조사 → §검증해야 할 주장 — *lock 컬렉션 TTL 만료 정밀도* |
|
||||
| D15 | Mongo 실패 분류는 **기존 `Category` enum 10종을 재사용**하고, error code 는 기존 `DB_*` 를 재사용하지 않고 **신규 제안**한다 | 실패가 기존 category 의미에 들어맞음 → 재사용(`TRANSIENT_DEPENDENCY`/`CONFLICT`/`DATA_INTEGRITY`/`INTERNAL`). code 는 기존 `DB_*` 가 **SQLState 기반**이라 Mongo 에 매핑되지 않음 → 신규 code 제안. **기존 code 를 의미 확장해 재사용하는 것은 선택하지 않는다**(owner 가 다른 branch) | `internal-code-fact`: `shared-contract/.../shared/error/Category.java:11-20`(enum 10종), `docs/registries/error-codes.yaml` — `DB_UNAVAILABLE`(L231)~`DB_QUERY_CANCELED`(L343) 9종이 전부 `owner_branch: feature-persistence-failure-baseline` 이고 주석이 **SQLState 매트릭스**(`08*`, `40001`, `40P01` …) 출처를 명시. mongo grep 0건. 선례: sibling [[raw/branch-notes/feature-integration-adapter-templates]] 가 `ADAPTER_DISABLED` 를 **신규 code** 로 만든 근거(startup lifecycle ≠ runtime lifecycle 혼동 방지) | `internal-code-fact` | 본 branch 는 registry **소비자이자 신규 제안자**다. code 이름·retryable·http_status 확정은 `feature-contract-registry-governance` 의 변경 절차를 거쳐야 하며 여기서 단독 확정할 수 없다 — §구현 가이드 3 은 **제안 표**로만 둔다 |
|
||||
| D16 | mongo 모듈이 JPA/Hibernate/Flyway/PostgreSQL 타입에 의존하지 못하게 하는 **ArchUnit rule 을 신설**한다 | NoSQL adapter 모듈이 존재하는 한 → rule 필수. 모듈이 제거되면 → rule 도 함께 제거 | `internal-code-fact`: `app-bootstrap/src/test/java/.../architecture/CleanArchitectureTest.java:823-828` — "Future NoSQL adapter modules (for example adapter-persistence-mongodb) must be added as sibling modules ... **When such a module exists, add an ArchUnit rule forbidding** `jakarta.persistence..`, `org.hibernate..`, `org.springframework.data.jpa..`, `org.flywaydb..`, and `dev.caskeleton.adapter.outbound.persistence.postgresql..` **dependencies from that module.**" 그 모듈은 **이미 존재**하나(`modules.yaml:202-212`) rule 은 미작성(mongo grep = 이 주석 1건뿐) | `internal-code-fact` — 코드 주석이 조건과 대상 패키지를 **명시적으로 지정**함 | rule 이 없는 동안 mongo 모듈이 JPA 타입을 import 해도 빌드가 막지 않는다. 단 `src/build.gradle` 의 `allowedProjectDependencies` 가 프로젝트 의존만 통제하므로(`modules.yaml:207` = `[application-core, shared-contract]`) **외부 라이브러리 import** 는 현재 무방비다 |
|
||||
| D17 | same-store Mongo outbox/inbox 는 본 branch 가 **가능 조건**(어떤 요건이 충족돼야 Mongo 에 둘 수 있는가)만 소유하고, 행 모델·상태 머신·프로토콜은 소유하지 않는다 | Mongo 가 업무 store 이고 D8 트랜잭션 게이트를 통과 → same-store outbox 가능. 트랜잭션 요건 미충족 → **불가**(append 원자성이 성립하지 않음). 행 모델·dispatch 모드 → [[raw/branch-notes/feature-outbox-dispatch-mode-contract]]. owner token 프로토콜 → [[raw/branch-notes/feature-idempotency-ownership-protocol-contract]] | `internal-code-fact`: 현행 `adapter/outbound/persistence-mongo/CLAUDE.md` 가 "does **not** reimplement idempotency / outbox / lock on Mongo (those stay JPA-only)" 로 **명시 금지**. project note §11 "outbox append → 업무 트랜잭션 롤백"(append 는 업무 write 와 원자) → Mongo same-store 는 D8 게이트에 종속. `MONGO-TXN-C1`(단일 document 는 이미 원자적) | **`internal-code-fact` + `internal-policy`(project note §11 상속)** | 현행 모듈 CLAUDE.md 의 금지를 **해제**하려면 그 파일을 고쳐야 하는데, 이는 ca-tmpl 측 변경이다. 본 branch 는 해제 **조건**만 정의하고 실제 해제는 outbox branch 착수 시점에 이뤄진다 — 두 문서가 어긋난 채 방치되면 다음 작업자가 "금지인가 허용인가" 를 되묻게 된다 |
|
||||
| D18 | Mongo 쿼리 filter·document 값이 로그로 새지 않게 **driver logger 를 값 미노출 레벨로 고정**한다 — 관계형 계약의 "SQL/parameter 로그 금지" 에 대응하는 Mongo 측 규칙 | 운영 프로파일 → `org.mongodb.driver.protocol.command` 를 DEBUG 미만으로 고정(값 노출 차단). 로컬 디버깅 → DEBUG 허용하되 **운영 데이터가 없는 환경에서만**. 진단 목적으로 운영에서 DEBUG 가 필요 → `LoggerSettings.maxDocumentLength` 축소 + 한시적 활성화 + 종료 시각 명시 | `MONGO-JAVA-LOG-C3`(`raw/official-docs/java-driver-logging-mongodb-official.md#MONGO-JAVA-LOG-C3` — `org.mongodb.driver.protocol.command` 가 **DEBUG** 레벨로 command 시작/성공 기록), `C4`(그 DEBUG 라인의 `Command:` 필드에 **command document 전체가 그대로** 남으며 예시에 `filter` 키 포함), `C1`(`org.mongodb.driver.protocol` = "Commands sent to and replies received"), `C2`(`org.mongodb.driver.connection` 은 별개 관심사 — 연결/pool 은 값을 담지 않으므로 함께 끄지 않아도 됨), `C5`(`LoggerSettings.maxDocumentLength()` 기본 `1000`자), `C6`(logger 이름 단위 레벨 조정). governing doc §11 Persistence "SQL/parameter 로그 금지" 의 Mongo 대응 | **`official-vendor-doc`(무엇이 어느 레벨에 남는가) / 금지 정책 `internal-policy`(project note §11 상속)** | `maxDocumentLength` 기본값이 **1000자**라 축소해도 **값의 앞부분은 남는다** — 길이 제한은 유출 완화이지 차단이 아니다. 차단은 레벨 고정으로만 성립한다. 또한 [[raw/branch-notes/feature-log-management-contract]] D1 의 masking 계층은 **구현돼 있으나**(`internal-code-fact`: `logback-spring.xml:45-46`,`:81` + `SecretMaskingMessageConverter`) `LogMaskingPatterns` **카탈로그 기반 고정 패턴**이라 **부분만 덮는다** — 카탈로그 키(`password`/`passwd`/`pwd`/`secret`/`token`/`api_key`/`access_token`/`refresh_token`/`client_secret` + `authorization` 헤더 + 독립 `bearer`)에 해당하는 filter 필드 값은 실제로 마스킹되지만, **그 외 임의 업무 필드 값은 덮이지 않는다**. 부분 커버리지는 "마스킹되니 안전하다" 는 오해를 만들기 때문에 오히려 위험하다 — **값 차단은 레벨 고정으로만 성립한다**. 잔여 미검증 채널은 §구현 가이드 8 의 "덮지 않는 채널" 표 참조 |
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
> 3-rule meta principle 적용 — R1 각 sub-section 은 Decision ID + Supporting Claim ID reference, R2 근거 없는 detail 은 `UNSUPPORTED_IMPL_DECISION` + trade-off 한 줄, R3 본 branch 결정 범위 밖은 이관.
|
||||
>
|
||||
> 코드 위치 표기: ca-tmpl repo 기준 상대 경로. 본 branch 는 **계약 SSOT** 이고 실제 코드 작성은 ca-tmpl plan 이 소유한다 — 아래 클래스명·경로는 코드로 확인된 것만 `actually-implemented` 로 표기했고 나머지는 `planned` 다.
|
||||
|
||||
### 1. 현행 as-built 와 이 branch 가 바꾸는 것
|
||||
|
||||
> **Trace**: D1(활성화 축) · D2(URI 소유) · D16(ArchUnit) — 전부 `internal-code-fact`
|
||||
|
||||
| 항목 | 현행 상태 | 등급 | 본 branch 후 |
|
||||
|---|---|---|---|
|
||||
| 모듈 존재 | `adapter/outbound/persistence-mongo` (소스 2개: `MongoPersistenceConfig`·`MongoPersistenceSettings`) | `actually-implemented` | 유지 |
|
||||
| opt-in gate | `MongoPersistenceConfig.java:33-36` — `matchIfMissing` 미지정(프레임워크 기본 `false`) | `actually-implemented` | 유지 (D1) |
|
||||
| classpath 무력화 | `app-bootstrap/.../application.yml:19-21` `spring.autoconfigure.exclude` 3건 ↔ `MongoPersistenceConfig.java:38-42` `@ImportAutoConfiguration` 3건 | `actually-implemented` | 유지 — **두 half 는 함께 바뀌어야 함** |
|
||||
| repository 스캔 범위 | `:43` `@EnableMongoRepositories(basePackageClasses = MongoPersistenceConfig.class)` | `actually-implemented` | 유지 |
|
||||
| 연결 URI | `spring.data.mongodb.*` **어디에도 없음** (`application.yml`·`src/.env` 126행 grep 0건) | — | D2 로 신설 + 미설정 시 startup 거부 |
|
||||
| timeout·pool·tls | 미설정 (드라이버 기본값) | — | D3·D4 로 명시 |
|
||||
| concern 3종 | 미설정 | — | D5·D6·D7 로 명시 |
|
||||
| transaction | `TransactionPort` 구현체는 `persistence-jpa/.../SpringTransactionPort` **1개뿐** | `actually-implemented`(JPA만) | D8 게이트. Mongo 측 구현 여부는 §7 |
|
||||
| change stream | 없음 | — | D11·D12 로 신설 |
|
||||
| index manifest | 없음 | — | D13·D14 로 신설 |
|
||||
| ArchUnit rule | `CleanArchitectureTest.java:823-828` 에 **TODO 주석만**, rule 미작성 | `documented-only` | D16 으로 신설 |
|
||||
| registry row | `env-keys`·`metrics`·`error-codes`·`secrets-classification` 전부 **mongo row 0건** | — | D2·D3·D12·D15 가 신규 제안 |
|
||||
| 로컬 실행 자산 | `docker-compose*.yml` 에 Mongo 서비스 없음, Mongo Testcontainers test 없음 | — | R1 승급의 선행 조건(§8) |
|
||||
|
||||
**Readiness 등급 판정 (project note §36.1)**: 현행은 **`R0` Contract** — 타입·seam·placeholder 만 존재. 근거: 문서/document/repository 0개, 로컬 서비스 없음, 통합 test 없음. **`R0` 를 `R2` 로 표기하는 것은 §36.1 금지 항목**이므로, 본 branch 완료 시점의 목표 등급은 `R1` Local(로컬 replica-set 컨테이너 + 집중 통합 test)이며 `R2` 주장은 실서비스 통합·runbook 확보 후에만 한다.
|
||||
|
||||
### 2. 연결 설정 계약 (명시 대상 키 집합)
|
||||
|
||||
> **Trace**: D2(URI) · D3(timeout·pool) · D4(tls) · D10(retryWrites) — `MONGO-CONNSTR-C1`~`C8`
|
||||
>
|
||||
> - **본 § 는 "어떤 키를 명시할 것인가" 만 고정한다.** 수치 값은 sibling [[raw/branch-notes/feature-env-driven-runtime-configuration]] 이 소유 — [[raw/branch-notes/feature-database-connection-pool-contract]] 가 Hikari 값을 그 branch 에 위임한 선례와 동일.
|
||||
|
||||
| 키 | 드라이버 기본값 (근거) | 명시 필수? | 이유 |
|
||||
|---|---|---|---|
|
||||
| `serverSelectionTimeoutMS` | 30,000ms (`MONGO-CONNSTR-C1`) | 운영 필수 | 30s 는 요청 타임아웃보다 길어 상류에서 먼저 끊긴다 |
|
||||
| `connectTimeoutMS` | 10,000ms, 드라이버별 상이 (`C2`) | 운영 필수 | "드라이버별로 다를 수 있다" 는 원문 단서 자체가 명시 사유 |
|
||||
| `socketTimeoutMS` | **no timeout** (`C3`) | **항상 필수** | 미명시 = 무한 대기. 유일하게 로컬에서도 명시 |
|
||||
| `maxPoolSize` | 100 (`C4`) | 운영 필수 | 100 은 대부분 배포에서 과다 |
|
||||
| `minPoolSize` | 0 (`C5`) | 운영 필수 | 0 이면 첫 요청이 연결 비용을 부담 |
|
||||
| `maxIdleTimeMS` | **원문에 숫자 기본값 없음** (`C6`) | 제외 | 기본값을 모르는 키를 "기본값 대비 명시" 로 규정할 수 없음. **단 이 제외는 문서 부재를 근거로 한 것이고 드라이버의 실제 동작은 미확인** |
|
||||
| `timeoutMS` (CSOT 통합 timeout) | (미조사) | **제외 — 채택 시 D10 재도출 필요** | `MONGO-RETRYWRITE-C3` 의 적용 범위가 "`timeoutMS` 를 별도 설정하지 않은 **기본 동작**" 이고, 같은 자료가 "`timeoutMS` 설정 시 몇 회까지 재시도되는지의 상한은 명시하지 않음" 을 Does-not-prove 로 남긴다. 즉 **`timeoutMS` 를 켜면 D10 의 "정확히 1회 재시도" 전제가 무효**가 된다 |
|
||||
| `tls` | SRV=`true` / Standard=`false` (`C7`) | **항상 필수** | 연결 문자열 형식을 바꾸면 TLS 가 조용히 뒤집힘 (D4) |
|
||||
| `retryWrites` / `retryReads` | 공식 드라이버 `true` (`C8`) | 명시 권고 | 기본이 켜져 있다는 사실을 계약에 드러내야 D10 의 "중첩 금지" 가 성립 |
|
||||
|
||||
> **UNSUPPORTED_IMPL_DECISION**: 이 키들을 **URI 쿼리스트링에 담을지, Spring `spring.data.mongodb.*` 개별 property 로 분리할지**. trade-off — 쿼리스트링은 드라이버 문서와 1:1 이라 대조가 쉽지만 URI 전체가 secret 분류가 되어 개별 값의 registry 검증(`verifyEnvKeys`)이 불가능해진다. 개별 property 는 registry 검증이 되지만 Spring 이 노출하는 property 집합이 드라이버 옵션 전체를 덮는지 확인되지 않았다. **registry 정합만 보면 개별 property 가 우세하나 결정 근거는 없다.**
|
||||
|
||||
### 2-1. concern·read preference 적용 seam (D5·D6·D7 이 *어디에* 붙는가)
|
||||
|
||||
> **Trace**: D5(read concern) · D6(write concern) · D7(read preference) — `SD-MONGO-TEMPLATE-C1`~`C6` · `MONGO-READPREF-C5` · `MONGO-TXN-C7`
|
||||
>
|
||||
> D5·D6·D7 은 *무엇을 명시할 것인가*를 정했다. 본 절은 Spring Data MongoDB 에서 **그 값이 붙는 지점**을 고정한다 — 이것이 없으면 구현자가 "repository 인가 template 인가 client 설정인가" 를 되묻는다.
|
||||
|
||||
| 축 | 적용 seam | 근거 | 상태 |
|
||||
|---|---|---|---|
|
||||
| write concern (기본값) | `MongoTemplate` 의 기본 `WriteConcern` 속성. 미설정 시 드라이버(`MongoClient`)의 DB/Collection 설정으로 폴백 | `SD-MONGO-TEMPLATE-C3` — "If it has not yet been specified through the driver at a higher level (such as `com.mongodb.client.MongoClient`), you can set the `com.mongodb.WriteConcern` property that the `MongoTemplate` uses ... If the `WriteConcern` property is not set, it defaults to the one set in the MongoDB driver's DB or Collection setting." | 확정 |
|
||||
| **write concern (연산별)** | **`WriteConcernResolver`** 를 `MongoTemplate` 에 구성 — `remove`/`update`/`insert`/`save` 별로 다른 값 결정 | `SD-MONGO-TEMPLATE-C4`(per-operation 전략 인터페이스) · `C5`(`WriteConcern resolve(MongoAction action)`) · `C6`(`MongoAction` 이 collection 명·POJO 클래스·변환된 Document·연산 종류(`REMOVE`/`UPDATE`/`INSERT`/`INSERT_LIST`/`SAVE`)를 제공) | 확정 — **D6 의 "연산별 명시" 를 실현하는 수단** |
|
||||
| read preference (기본값) | `MongoTemplate` 의 선택적 속성 중 하나. **`Query` 수준 지정이 template 기본값보다 우선** | `SD-MONGO-TEMPLATE-C1`(설정 가능 속성 목록에 `ReadPreference` 포함) · `C2`("The default read preference applied to read operations **if no other preference was defined via the Query**") | 확정 |
|
||||
| read preference (트랜잭션 내) | 선택 불가 — `primary` 강제 | `MONGO-READPREF-C5` | 확정 |
|
||||
| write concern (트랜잭션 내 개별 write) | **설정 금지** — 설정 시 에러. transaction/session/client 레벨에서만 지정 | `MONGO-TXN-C7` | 확정 |
|
||||
| **read concern** | **미확정** | 해당 페이지에 "read concern"/"ReadConcern" 이 **0회 등장**(self-grep 0 매치) — `SD-MONGO-TEMPLATE` Does-not-prove | **`UNSUPPORTED_IMPL_DECISION`** |
|
||||
|
||||
> **UNSUPPORTED_IMPL_DECISION — read concern 적용 seam.** 수집한 자료 중 Spring Data MongoDB 가 read concern 을 어떤 API 로 노출하는지 다루는 것이 없다(위 페이지는 `WriteConcern`·`ReadPreference` 만 다룬다). 후보 — (a) `MongoClientSettings` 수준에서 전역 설정(연산별 분기 불가), (b) `Query`/`AggregationOptions` 수준 옵션이 존재한다면 그것, (c) `MongoTemplate` 을 감싼 자체 wrapper 에서 명시. trade-off — (a)는 확실히 존재하지만 **D5 의 "연산 단위로 명시" 를 충족하지 못한다**(정확성 조회와 근사 조회가 같은 값을 쓰게 됨). (b)가 있으면 D5 를 그대로 실현하지만 **존재 여부 자체가 미확인**이다. (c)는 항상 가능하나 프레임워크가 이미 제공하는 것을 재발명할 위험이 있다. **(b)의 존재 확인이 선행돼야 한다** — §검증해야 할 주장 참조.
|
||||
>
|
||||
> **범위 한정**: 위 seam 은 전부 `MongoTemplate` 경로 기준이다. 모듈 wiring 은 `@EnableMongoRepositories`(§1)이므로 **repository 파생 쿼리에 concern/preference 가 어떻게 붙는지는 본 표가 다루지 않는다**. 현재 repository·document 가 0개라 착수를 막지는 않으나, 첫 `@Document`/`MongoRepository` 를 추가하는 시점에 재확인해야 한다.
|
||||
>
|
||||
> 대비: write concern 은 `WriteConcernResolver`(`C4`~`C6`)라는 **연산별 결정 수단이 확인**된 반면, read concern 은 그 대응물이 확인되지 않았다. 따라서 D5 와 D6 은 같은 문장 구조("연산 단위로 명시")를 갖지만 **실현 확실성이 다르다**.
|
||||
|
||||
### 3. 실패 분류 — 기존 재사용 vs 신규 제안
|
||||
|
||||
> **Trace**: D15 · `internal-code-fact`(`Category.java:11-20`, `error-codes.yaml`)
|
||||
>
|
||||
> - **본 § 는 제안이다.** code 확정은 `feature-contract-registry-governance` 의 registry 변경 절차 소관이며 본 branch 가 단독으로 확정하지 않는다.
|
||||
|
||||
기존 재사용 (변경 없음):
|
||||
|
||||
| 대상 | 재사용 | owner |
|
||||
|---|---|---|
|
||||
| 실패 category 어휘 | `Category` enum 10종 (`VALIDATION`·`AUTH`·`AUTHZ`·`NOT_FOUND`·`CONFLICT`·`RATE_LIMIT`·`TRANSIENT_DEPENDENCY`·`PERMANENT_DEPENDENCY`·`DATA_INTEGRITY`·`INTERNAL`) | `shared-contract` |
|
||||
| 토폴로지 검증 실패 (D2·D8·D11) | `STARTUP_VALIDATION_FAILED` | `feature-migration-startup-contract` |
|
||||
| index migration 실패 (D13·D14) | `MIGRATION_FAILED` | `feature-migration-startup-contract` |
|
||||
| 비활성 capability 런타임 호출 | `ADAPTER_DISABLED` | `feature-integration-adapter-templates` |
|
||||
|
||||
신규 제안 (**아직 registry 에 없음** — 이름은 확정 아님):
|
||||
|
||||
**가장 흔한 3종 먼저** — 아래 3행은 정상 운영에서 가장 자주 만나는 실패다. `DB_*` 가 SQLState 기반이라 재사용할 수 없으므로(D15) Mongo 측 대응이 필요하다:
|
||||
|
||||
| 실패 | 제안 category | 근거 claim |
|
||||
|---|---|---|
|
||||
| 서버 선택 실패 (`serverSelectionTimeoutMS` 초과 — 서버 미기동·전원 unreachable·replica set 미구성) | `TRANSIENT_DEPENDENCY` | `MONGO-CONNSTR-C1`(기본 30,000ms 후 예외). PostgreSQL 대응은 `DB_UNAVAILABLE`(503·retryable, owner `feature-persistence-failure-baseline`)이나 **SQLState 기반이라 재사용 불가** |
|
||||
| 인증·권한 실패 (자격증명 오류, 최소권한 계정의 명령 거부) | `AUTH` / `INTERNAL` / `PERMANENT_DEPENDENCY` — **미정** (아래 `UNSUPPORTED_IMPL_DECISION` 참조) | 근거 claim 없음. 기존 `AUTH_*` 9종은 전부 **JWT 사용자 인증**용(owner `feature-authentication-authorization-contract`)이라 *서버-대-DB* 인증에 의미가 맞지 않는다. §구현 가이드 4 의 최소권한 검증(§검증해야 할 주장 — *토폴로지·FCV 질의를 최소권한 계정이 실행 가능한가*)과 쌍 |
|
||||
| connection pool 고갈 (`maxPoolSize` 소진 후 대기) | `TRANSIENT_DEPENDENCY` | `MONGO-CONNSTR-C4`(기본 100)·`C5`(min 0). Hikari 대응(`DB_UNAVAILABLE` + `hikaricp.connections.acquire{outcome=TIMEOUT}`)은 [[raw/branch-notes/feature-persistence-failure-baseline]]·[[raw/branch-notes/feature-database-connection-pool-contract]] 소유이며 **JDBC 전용** |
|
||||
|
||||
나머지 (드물지만 계약이 필요한 경로):
|
||||
|
||||
| 실패 | 제안 category | 근거 claim |
|
||||
|---|---|---|
|
||||
| write concern 미달·`wtimeout` 초과 | `TRANSIENT_DEPENDENCY` | `MONGO-WRITECONCERN-C6`·`C7` — write 는 취소되지 않으므로 **재시도는 멱등 연산에만** |
|
||||
| transaction 시간 한계 초과 abort | `CONFLICT` | `MONGO-TXN-PROD-C2` |
|
||||
| `TransactionTooLargeForCache` | `INTERNAL` | `MONGO-TXN-PROD-C4` |
|
||||
| change stream resume 불가 (oplog 롤오버) | `INTERNAL` | `MONGO-CHANGESTREAM-C7` |
|
||||
| unique index 위반 (duplicate key) | `CONFLICT` | **선례 정합** — `internal-code-fact`: `error-codes.yaml:301-306` `DB_UNIQUE_VIOLATION` = category `CONFLICT` · `http_status: 409` · `retryable: false`. Mongo duplicate key 도 **같은 의미**이므로 category 를 일치시킨다(code 이름만 신규). sibling [[raw/branch-notes/feature-transaction-concurrency-contract]] D5 의 "optimistic 충돌 → 409 non-retryable" 계열과도 정합 |
|
||||
|
||||
> **UNSUPPORTED_IMPL_DECISION — 인증 실패의 category**: 위 표의 "인증·권한 실패" 행은 **category 자체가 미정**이다. 후보 — (a) `AUTH`, (b) `INTERNAL`, (c) `PERMANENT_DEPENDENCY`. trade-off — 기존 `AUTH_*` 9종은 전부 *클라이언트가 제시한 JWT* 를 다루므로 (a)를 쓰면 "누구의 인증인가" 가 로그·대시보드에서 뒤섞인다. DB 자격증명 오류는 **클라이언트가 고칠 수 없고 배포 설정으로만 고쳐지므로** (b)/(c)가 의미상 가깝다. **어느 쪽도 외부 근거가 없고**, 기존 registry 에도 *서버-대-의존성 인증 실패* 선례가 없다.
|
||||
>
|
||||
> **UNSUPPORTED_IMPL_DECISION**: 위 신규 code 의 **이름·`retryable`·`http_status`**. trade-off — 기존 `DB_*` 접두사를 이어 쓰면(`DB_WRITE_CONCERN_TIMEOUT` 등) grep 일관성은 좋지만 그 접두사는 SQLState 매트릭스가 owner 인 `feature-persistence-failure-baseline` 소유라 **의미 확장 = 남의 registry 침범**이다. `MONGO_*` 접두사는 소유가 깨끗하지만 vendor 이름을 error code 에 박는 첫 사례가 된다. **소유 경계만 보면 `MONGO_*` 가 우세하나 결정 근거는 없다.**
|
||||
|
||||
### 4. 토폴로지 검증 게이트 (transaction · change stream 공통)
|
||||
|
||||
> **Trace**: D8(transaction) · D11(change stream) · D2(URI) — `MONGO-TXN-C2`~`C4` · `MONGO-TXN-PROD-C1` · `MONGO-CHANGESTREAM-C1`·`C2`
|
||||
|
||||
검증 항목과 대상 기능:
|
||||
|
||||
| 검증 | transaction (D8) | change stream (D11) | 근거 |
|
||||
|---|---|---|---|
|
||||
| standalone 아님 (replica set / sharded) | 필수 | 필수 | `MONGO-TXN-PROD-C1` · `MONGO-CHANGESTREAM-C1` |
|
||||
| primary WiredTiger | 필수 | 필수 | `MONGO-TXN-C3` · `MONGO-CHANGESTREAM-C2` |
|
||||
| FCV ≥ RS `4.0` / SC `4.2` | 필수 | (해당 없음) | `MONGO-TXN-C2` |
|
||||
| `writeConcernMajorityJournalDefault≠false` shard | 필수 (sharded) | (해당 없음) | `MONGO-TXN-C4` |
|
||||
| read concern majority 지원 여부 | (해당 없음) | **무관** | `MONGO-CHANGESTREAM-C3` |
|
||||
|
||||
- 검증 시점은 sibling [[raw/branch-notes/feature-capability-provider-selection-contract]] D6 을 **상속**한다 — context refresh 완료 전(`SmartInitializingSingleton`), `ApplicationRunner` 로 옮기지 않는다. 본 branch 는 시점을 재정의하지 않는다.
|
||||
- 검증 실패 시 `STARTUP_VALIDATION_FAILED` 로 기동 거부한다. **조용한 비활성 전환은 하지 않는다** — 활성화를 선언했는데 요건이 없으면 그것은 설정 오류다.
|
||||
|
||||
> **UNSUPPORTED_IMPL_DECISION**: 토폴로지·FCV·storage engine 을 **어떤 명령으로 질의할지**(`hello`/`buildInfo`/`getParameter` 등)와 **자격증명이 그 명령을 실행할 권한을 갖는지**. trade-off — 관리 명령은 권한이 없으면 실패하므로, 검증이 오히려 최소권한 계정에서 기동을 막을 수 있다. 근거 자료(위 4건) 중 어느 것도 *클라이언트가 이 요건을 런타임에 확인하는 방법*을 다루지 않는다. **검증 방법이 확정될 때까지 게이트는 "실패 시 거부" 정책만 확정이고 판정 수단은 미정이다.**
|
||||
|
||||
### 5. change stream checkpoint 와 재개 경로
|
||||
|
||||
> **Trace**: D11 · D12 — `MONGO-CHANGESTREAM-C4`~`C8`
|
||||
|
||||
재개 경로 결정표:
|
||||
|
||||
| 상황 | 사용 옵션 | 근거 |
|
||||
|---|---|---|
|
||||
| 저장된 resume token 있음 · invalidate 없었음 | `resumeAfter` | `C4` |
|
||||
| **invalidate event 이후 재개** | `startAfter` | `C5` — `resumeAfter` 는 invalidate 이후 재개 불가 |
|
||||
| token 없음 (최초 기동) | `startAtOperationTime` | `C6` — 과거 시점이면 oplog 시간 범위 안이어야 함 |
|
||||
| oplog 가 token 을 넘겨 롤오버됨 | **재개 불가** — 소비를 중단하고 경보. 복구 방법은 아래 참조 | `C7` |
|
||||
|
||||
> **OUT_OF_BRANCH_SCOPE — "재개 불가 이후의 복구"**: 저장된 token 이 무효가 된 뒤 *무엇을 해야 소비자가 정합 상태로 돌아오는가*(컬렉션 전량 재스캔 / 보상 이벤트 재발행 / 수동 개입)는 **소비자가 무엇을 하는 소비자인지에 종속**되며 skeleton 범위 밖의 도메인 결정이다. 본 branch 는 여기까지만 소유한다 — (1) 이 상태를 **감지**하고(`C7`), (2) 소비를 **중단**하고, (3) **경보**한다. 조용히 최신 시점부터 재개해 이벤트 구간을 건너뛰는 동작은 **금지**한다(유실을 은폐하므로). 실제 복구 절차의 **문서 산출물 owner 는 [[raw/branch-notes/feature-operational-runbook-contract]] D9**(실 runbook 본문 작성)다. change stream 소비자를 도입하는 branch 가 도메인 절차를 정의하겠지만 **그런 branch 는 현재 저장소에 없다**(소비자 도입 시 신설) — 지금 시점에 이 절차를 소유한 문서는 없으며, 그 사실 자체를 여기 명시해 다음 작업자가 "이미 있는데 못 찾는 것" 으로 오해하지 않게 한다.
|
||||
|
||||
cursor 종료 4조건(`C8`)은 전부 **정상 처리 경로를 가져야 한다**: 명시적 close(정상 종료) / invalidate(→ `startAfter` 재개) / 연결 종료·timeout(→ `resumeAfter` 재개) / sharded cluster 의 shard 제거(→ `resumeAfter` 재개 + 경보).
|
||||
|
||||
> **UNSUPPORTED_IMPL_DECISION**: checkpoint **저장 위치와 저장 시점**. 후보 — (a) 같은 Mongo 의 전용 컬렉션, (b) PostgreSQL(업무 store), (c) 별도 store. trade-off — (a)는 change stream 대상과 저장소가 같아 운영 단순하고 D8 트랜잭션 게이트를 통과하면 처리와 checkpoint 를 한 트랜잭션에 넣을 여지가 있으나, Mongo 가 죽으면 checkpoint 도 함께 잃는다. (b)는 이미 트랜잭션이 검증된 store 지만 두 DB 에 걸친 원자성은 불가능해 **at-least-once 가 확정**된다. **근거 자료 어느 것도 checkpoint 저장 전략을 다루지 않는다.** 저장 시점(처리 전 vs 후)에 따라 at-least-once ↔ at-most-once 가 갈리므로, 이 선택은 project note §11 의 durable 경로 정책과 함께 결정돼야 한다.
|
||||
|
||||
### 6. index manifest · migration runner · drift
|
||||
|
||||
> **Trace**: D13(자체 러너) · D14(lock) — `SD-MONGO-INDEX-C1`~`C3`,`C5` · `MONGOCK-C2`,`C3` · `LIQUIBASE-MONGO-PRO-C1`
|
||||
|
||||
확정된 것:
|
||||
|
||||
- `spring.data.mongodb.auto-index-creation` 을 켜지 않는다. 근거 `SD-MONGO-INDEX-C1`(3.0 부터 기본 OFF) + `C2`("undesired effects on collection lifecycle and performance"). **기본값이 이미 OFF 이므로 "켜지 않는다" 는 별도 조치 없이 성립**하며, 계약의 역할은 누군가 켜는 것을 막는 것이다.
|
||||
- 적용은 programmatic 경로를 쓴다 — `IndexResolver` + `IndexOperations` (`C3` 가 "(Recommended)" 로 표기, `C5` 가 "more control than annotations").
|
||||
- drift 감지는 실제 인덱스 목록과 manifest 를 대조한다.
|
||||
|
||||
대안 기각 (D13 선택 조건의 근거):
|
||||
|
||||
| 대안 | 기각 사유 | 근거 | 확신도 |
|
||||
|---|---|---|---|
|
||||
| Mongock | 신규 개발이 Flamingock 으로 이전, critical fix 만 유지 | `MONGOCK-C2` | 확정 (벤더 공지) |
|
||||
| Mongock (drift) | drift 기능이 **이 페이지에서 확인되지 않음** | `MONGOCK-C4` Does-not-prove | **`needs-confirmation`** — 부재의 증거 아님 |
|
||||
| Liquibase-mongodb | drift report 가 **Pro** 기능 | `LIQUIBASE-MONGO-PRO-C1`·`C2` | 확정 |
|
||||
| Liquibase (무료 티어) | 무료 extension 에 drift 가 **없다**는 부정 명제 | (직접 근거 없음) | **`needs-confirmation`** |
|
||||
|
||||
> **착수 순서 제약**: 위 표의 `needs-confirmation` 2행은 **본 절(§6)의 코드를 쓰기 전에** 닫아야 한다. 자체 러너는 §구현 가이드 최대 분량 절이고 전부 이 기각 논리에 종속되므로, 기각이 뒤집히면 작성한 코드를 되돌리게 된다. §검증해야 할 주장의 Mongock·Liquibase 자료 2건(Mongock v5 기능/lock 페이지 · Liquibase 무료 OSS extension 기능 목록)을 먼저 수집한다. 채택 근거(`SD-MONGO-INDEX-C1`~`C3`,`C5`)는 이미 충분하므로 **"auto-index-creation 을 쓰지 않는다" 부분은 지금 착수해도 안전**하다 — 되돌릴 위험이 있는 것은 *도구 대신 자체 러너를 만든다* 는 부분뿐이다.
|
||||
|
||||
> **UNSUPPORTED_IMPL_DECISION 1 — manifest 포맷과 drift 판정 범위.** 후보 — (a) `@Document` 애노테이션을 `IndexResolver` 로 읽어 manifest 를 코드에서 도출, (b) 별도 YAML manifest 파일, (c) 둘 병행. trade-off — (a)는 SSOT 가 하나지만 "인덱스 목록" 을 코드 밖에서 리뷰할 수 없고, (b)는 리뷰 가능하나 엔티티와 manifest 가 어긋날 수 있다. 또한 **drift 를 어디까지 볼 것인가**(키 순서만 vs `unique`/`partialFilterExpression`/`collation`/TTL `expireAfterSeconds` 까지)가 미정이며, `SD-MONGO-INDEX-C3` 의 Does-not-prove 가 "manifest·migration 이력·drift 비교 로직은 이 문서 범위 밖" 임을 명시한다. **어느 쪽도 외부 근거가 없다.**
|
||||
>
|
||||
> **UNSUPPORTED_IMPL_DECISION 2 — drift 발견 시 동작.** 후보 — (a) 기동 거부(`MIGRATION_FAILED`), (b) 경보만 남기고 기동, (c) 자동 교정. trade-off — (a)는 배포 중단 비용이 크고 운영자가 인덱스를 손으로 추가한 정당한 경우에도 막힌다. (c)는 프로덕션에서 예상치 못한 인덱스 빌드를 유발해 `SD-MONGO-INDEX-C2` 가 경고한 바로 그 "undesired effects" 를 재현한다. **(b)가 가장 보수적이나 결정 근거는 없다.**
|
||||
>
|
||||
> **UNSUPPORTED_IMPL_DECISION 3 — runner lock 메커니즘.** D14 는 lock 이 **필요하다**까지만 근거가 있다(`MONGOCK-C3` = 기성 도구도 DB 영속 pessimistic lock 을 씀). 후보 — (a) unique index 를 건 lock 컬렉션 + TTL 로 만료, (b) 배포 파이프라인 단일 Job 으로 분리(앱 밖), (c) 기존 `MigrationStartupRunner` 와 같은 프로세스에 묶기. trade-off — (a)는 D13 이 어차피 다뤄야 하는 unique/TTL index 를 재료로 쓰므로 신규 개념이 없으나 **TTL 만료 정밀도가 lock 안전성에 미치는 영향이 미조사**(§검증해야 할 주장 — *lock 컬렉션 TTL 만료 정밀도*)다. (b)는 sibling `feature-migration-startup-contract` 가 이미 `K8S-JOB-C1`/`C2` 로 근거를 확보한 패턴이지만 앱 readiness 게이트와 분리된다. **(a)가 기존 아키텍처와 가장 정합하나 결정 근거는 없다.**
|
||||
|
||||
### 6-1. oplog window 측정 (D12)
|
||||
|
||||
> **Trace**: D12 — `MONGO-CHANGESTREAM-C7`
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION — 측정 수단**: `C7` 은 "oplog 에 충분한 history 가 있어야 resume 이 된다" 는 **위험**만 증명하고, *남은 window 를 어떻게 재는가*는 다루지 않는다. 후보 — (a) `replSetGetStatus` 의 oplog 타임스탬프 범위, (b) `local.oplog.rs` 의 first/last entry 타임스탬프 차이, (c) `db.getReplicationInfo()` 의 `timeDiff`. trade-off — (a)·(c)는 관리 명령이라 §구현 가이드 4 와 **같은 최소권한 문제**를 공유하고(§검증해야 할 주장 — *토폴로지·FCV 질의를 최소권한 계정이 실행 가능한가*), (b)는 `local` 데이터베이스 직접 조회라 권한 요구가 다르고 `MONGO-READCONCERN-C12`(local DB 는 read concern 을 조용히 무시)가 적용되는 특수 영역이다. **어느 쪽도 외부 근거가 없다.**
|
||||
> - **UNSUPPORTED_IMPL_DECISION — 경보 임계**: D12 는 임계를 "남은 oplog 시간 < 소비자 최대 다운타임 허용치" 로 표현했으나 그 허용치를 정하는 주체가 본 branch 가 아니다. [[raw/branch-notes/feature-metrics-alerting-contract]] **D3** 가 "alert threshold 는 SLO/error budget 또는 documented operational default 에 연결 — **임의 수치 금지**" 를 강제하므로, 본 branch 는 수치를 제안하지 않고 **임계의 형태**(비율 기반 vs 절대 시간)만 남긴다.
|
||||
|
||||
| 항목 | 본 branch 가 고정하는 것 | 위임 |
|
||||
|---|---|---|
|
||||
| 무엇을 재는가 | 남은 oplog window(시간) 와 마지막 저장 checkpoint 의 시간 격차 | — |
|
||||
| 어떻게 재는가 | (미정 — 위 후보 3종) | — |
|
||||
| metric 이름 | (제안하지 않음) | [[raw/branch-notes/feature-metrics-alerting-contract]] D2 (Micrometer dot.case + unit suffix) |
|
||||
| 경보 임계 수치 | (제안하지 않음 — 임의 수치 금지) | [[raw/branch-notes/feature-metrics-alerting-contract]] D3 |
|
||||
| registry 등록 | 신규 제안만 | [[raw/branch-notes/feature-contract-registry-governance]] |
|
||||
|
||||
### 7. 위임 — 본 branch 가 정의하지 않는 것
|
||||
|
||||
> **Trace**: D7(read preference) · D17(outbox/inbox) — R3 정제 결과
|
||||
|
||||
| 관심사 | owner | 본 branch 가 남기는 것 |
|
||||
|---|---|---|
|
||||
| `ReadConsistency` 정책 → replica 라우팅 판정 | [[raw/branch-notes/feature-read-consistency-query-contract]] (`DEC-…-READ-CONSISTENCY-001@1`) | Mongo 측 **표현 수단**만 (mode 5종 · `maxStalenessSeconds`) — D7 |
|
||||
| outbox 행 모델 · dispatch 모드 · 순서 | [[raw/branch-notes/feature-outbox-dispatch-mode-contract]] | Mongo same-store **가능 조건**(D8 게이트 통과)만 — D17 |
|
||||
| idempotency owner token 프로토콜 | [[raw/branch-notes/feature-idempotency-ownership-protocol-contract]] | 동일 — D17 |
|
||||
| 활성화 property prefix 문자열 · descriptor 스키마 | [[raw/branch-notes/feature-capability-provider-selection-contract]] (D2·D3·D5·D6) | 축이 boolean 이라는 것만 — D1 |
|
||||
| env key 이름·수치·bounds 등록 | [[raw/branch-notes/feature-env-driven-runtime-configuration]] (D2·D7) | **명시 대상 키 집합**만 — D3·D4 |
|
||||
| secret 분류·회전 정책 | [[raw/branch-notes/feature-secrets-config-source-contract]] | URI 가 secret 이라는 신규 제안만 — D4 |
|
||||
| 운영 runbook 문서 | [[raw/branch-notes/feature-operational-runbook-contract]] | Mongo runbook 부재를 §Audit A4 로 신고만 — 작성은 그 branch 절차 |
|
||||
| error code 이름 확정 | [[raw/branch-notes/feature-contract-registry-governance]] | category 매핑 제안만 — D15 |
|
||||
| PostgreSQL/JPA 실패 매핑 · Hikari pool | [[raw/branch-notes/feature-persistence-failure-baseline]] · [[raw/branch-notes/feature-database-connection-pool-contract]] | 없음 (본 branch 범위 밖) |
|
||||
|
||||
`TransactionPort` 를 Mongo 로 구현할 것인지(현재 구현체는 JPA 1개)는 **본 branch 에서 확정하지 않는다** — 두 `PlatformTransactionManager` 공존은 application-core 의 포트 계약에 영향을 주므로 `feature-application-port-usecase-contract` 와의 조율이 선행돼야 한다. D8 은 *Mongo 트랜잭션을 언제 켜도 되는가*만 정의한다.
|
||||
|
||||
### 8. 쿼리·document 값 로그 유출 억제 (D18)
|
||||
|
||||
> **Trace**: D18 — `MONGO-JAVA-LOG-C1`~`C6` · governing doc §11 Persistence("SQL/parameter 로그 금지")
|
||||
>
|
||||
> 관계형 쪽 대응은 [[raw/branch-notes/feature-persistence-failure-baseline]] 이 소유하나 그 구현은 Hibernate `show-sql`(JDBC 전용)에 한정된다. Mongo 는 **다른 logger 계층**을 쓰므로 같은 금지를 별도로 집행해야 한다.
|
||||
|
||||
| logger | 무엇이 남는가 | 값 노출? | 운영 정책 |
|
||||
|---|---|---|---|
|
||||
| `org.mongodb.driver.protocol.command` | command 시작/성공 이벤트. `Command:` 필드에 **command document 전체**(예시에 `filter` 포함) | **예 — 차단 대상** | DEBUG 미만으로 고정 |
|
||||
| `org.mongodb.driver.protocol` (상위) | "Commands sent to and replies received" | 예 | 하위와 함께 고정 |
|
||||
| `org.mongodb.driver.connection` | 연결·connection pool | 아니오 (값 미포함) | **끄지 않는다** — pool 고갈 진단(§구현 가이드 3)에 필요 |
|
||||
|
||||
규범:
|
||||
|
||||
- 운영 프로파일에서 command logger 를 DEBUG 이상으로 올리는 설정은 **금지**한다. 이 금지가 없으면 모든 쿼리 filter 값이 평문으로 남는다(`C4`).
|
||||
- `LoggerSettings.maxDocumentLength` 는 **완화 수단이지 차단 수단이 아니다** — 기본 `1000`자(`C5`)를 줄여도 값의 앞부분은 남는다. 차단은 레벨로만 한다.
|
||||
- connection logger 는 값을 담지 않으므로(`C2`) 일괄 차단 대상에서 **분리**한다 — pool 고갈·연결 실패 진단 능력을 잃지 않기 위해.
|
||||
- 이 규칙은 Mongo 가 **비활성일 때도** 설정에 남아 있어야 한다(활성화 시점에 잊지 않도록).
|
||||
|
||||
**이 규칙이 덮지 않는 채널** (근거 자료가 Does-not-prove 로 명시한 범위 — "레벨만 내리면 다 막힌다" 고 주장하지 않는다):
|
||||
|
||||
| 채널 | 왜 안 덮이는가 |
|
||||
|---|---|
|
||||
| Command Monitoring API | logger 와 **별개 채널**이다. 애플리케이션이 command listener 를 등록하면 로그 레벨과 무관하게 command 내용을 받는다 — 등록 시 그쪽에서 별도 억제 필요 |
|
||||
| `org.mongodb.driver.operation` 등 나머지 driver logger | 수집 자료가 이름 목록만 제공하고 각각이 값을 담는지 **실측을 주지 않는다** |
|
||||
| `protocol` ↔ `protocol.command` 상속 관계 | 공식 목록에 `protocol.command` 가 `protocol` 의 자식으로 **명시돼 있지 않다**(추론일 뿐). 상위만 끄면 하위가 따라 꺼지는지는 미검증 → **두 이름을 모두 명시적으로 고정**한다 |
|
||||
| Spring Data `org.springframework.data.mongodb.core` logger | Spring Data 자체 로깅은 driver logger 와 별개다. 수집 자료가 다루지 않아 취급 미정 |
|
||||
|
||||
> **UNSUPPORTED_IMPL_DECISION — 집행 수단**. 후보 — (a) `logback-spring.xml` 등 로깅 설정에 logger 레벨을 못박기, (b) 코드에서 `LoggerSettings` 를 빌드해 주입, (c) 운영 프로파일 startup 검증에서 해당 logger 의 유효 레벨을 확인하고 위반 시 거부. trade-off — (a)는 가장 단순하나 설정 파일을 고치면 조용히 뚫린다. (c)는 D2·D8 의 startup 게이트와 같은 모양이라 정합적이지만 로깅 프레임워크의 유효 레벨을 런타임에 질의하는 방식이 프레임워크마다 다르다. **(a)+(c) 조합이 가장 견고하나 결정 근거는 없다.** 로깅 설정 파일의 소유자는 [[raw/branch-notes/feature-log-management-contract]] 이므로 (a) 채택 시 그 branch 와 조율이 필요하다.
|
||||
|
||||
## Audit & Findings
|
||||
|
||||
> `/branch-spec` 실행 중 ca-tmpl 코드·registry 대조에서 발견한 정합 문제. **본 branch 가 자동 수정하지 않는다** — 사용자·owner branch 의 결정 영역이므로 정합 권고만 남긴다.
|
||||
|
||||
| ID | 유형 | 발견 | 근거 | 권고 |
|
||||
|---|---|---|---|---|
|
||||
| A1 | `MONGO_URI_UNCONFIGURED` | `spring.data.mongodb.*` 가 `application.yml` 과 `src/.env`(126행) 어디에도 없다(grep 0건). 즉 `ca-skeleton.persistence-mongo.enabled=true` 로 바꿔도 **연결 대상이 정의되지 않은 상태**다. (플래그를 켰을 때 기본 URI 로 붙는지 Spring 이 먼저 실패시키는지는 `needs-confirmation` — §검증해야 할 주장 — *플래그를 켰을 때 기본 URI 로 붙는지*) | `internal-code-fact`(부재는 grep 으로 확정) | D2 로 해소. env-keys.yaml 등록이 선행돼야 `verifyEnvKeys` 가 이 부재를 잡는다 |
|
||||
| A2 | `ARCHUNIT_RULE_ABSENT` | `CleanArchitectureTest.java:823-828` 이 "그런 모듈이 생기면 rule 을 추가하라" 고 적었으나 모듈은 이미 존재(`modules.yaml:202-212`)하고 rule 은 미작성 | `internal-code-fact` | D16 으로 해소 |
|
||||
| A3 | `MISSING_INTERNAL_DESIGN_DOC` | 프로젝트 결정 `DEC-…-MONGO-BASELINE-001` 의 근거란이 "ca-tmpl platform 설계 §12.3" 을 가리키나, ca-tmpl `docs/superpowers/specs/` 에 tracked 된 파일은 `2026-07-20-harness-policy-engine-design.md` **하나뿐**이다. `docs/` 는 gitignore 가 아니라 정상 tracked(54 파일)이므로 부재가 확실하다 | `internal-code-fact` (`git ls-files docs/superpowers/`) | **본 branch 는 §12.3 내용을 인용하지 않았다** — 모든 D-row 는 공식 벤더 문서 또는 코드 사실에 근거한다. project note §6.1 의 근거란 정정은 project-note owner 소관 |
|
||||
| A4 | `REGISTRY_ROW_ABSENT` | `env-keys`·`metrics`·`error-codes`·`secrets-classification` 4개 registry 전부 mongo row 0건. `docs/runbooks/` 에도 mongo runbook 없음(`db-*.md` 5건은 전부 SQL) | `internal-code-fact` | D2·D3·D12·D15 가 신규 제안. 등록은 각 owner branch 절차 |
|
||||
| A5 | `README_SCOPE_DRIFT` | ca-tmpl `README.md:25` 는 `adapter:outbound:persistence-*` 가 "JPA/PostgreSQL·MongoDB 영속 구현과 매핑·**migration**" 을 담당한다고 적었으나, mongo 모듈의 `CLAUDE.md`·`README.md` 는 wiring-only 이며 migration 을 명시적으로 다루지 않는다 | `internal-code-fact` | D13·D14 착수 시 README 문구를 실제 범위에 맞춘다 (낮은 심각도) |
|
||||
| A7 | `OWNER_DOC_STALE` | [[raw/branch-notes/feature-log-management-contract]] 본문이 masking Layer 1 을 **미구현**으로 서술한다(`:190` "Layer 1~3 모두 미구현", `:274`, `:278`, `:334` "Layer 1 미구현(DRIFT-2)"). 그러나 코드는 구현돼 있고(`logback-spring.xml:45-46`,`:80-81` + `SecretMaskingMessageConverter`·`SecretMaskingJsonGeneratorDecorator`·`LogMaskingPatterns`) 같은 문서의 frontmatter `:22` `last_pass: 2026-06-14` 도 "Phase C2 전면 구현 완료" 로 갱신돼 있다 — **본문과 frontmatter 가 서로 어긋난 상태** | `internal-code-fact` | **본 branch 는 고치지 않는다**(Single-Owner). owner branch 가 본문을 정정해야 하며 `/sync` 로 그 branch 의 frontmatter↔본문 drift 를 별도 처리 권고. 본 branch 의 D18·§구현 가이드 8 은 **코드 사실**을 기준으로 작성했다 |
|
||||
| A6 | `MODULE_METADATA_ODD` | `modules.yaml:211` 의 mongo 모듈 `mutation_import` 가 `dev.caskeleton.adapter.inbound.web.controller.HealthcheckController` — 본 모듈 타입이 아니다. 모듈에 자체 public 타입이 사실상 없어서일 수 있다 | `internal-code-fact` | 모듈에 실제 타입이 생기면 재검토 (정보성) |
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
|
||||
**실패·엣지 경로** (각 경로의 기대 동작):
|
||||
|
||||
| 경로 | 기대 동작 | 근거 / 결정 |
|
||||
|---|---|---|
|
||||
| capability 활성인데 URI 미설정 | `STARTUP_VALIDATION_FAILED` 로 기동 거부. **기본 URI 로 조용히 연결하지 않는다** | D2 · §Audit A1 |
|
||||
| standalone 배포에서 transaction/change stream 활성 선언 | `STARTUP_VALIDATION_FAILED` 로 기동 거부. 조용한 비활성 전환 금지 | D8 · D11 · `MONGO-TXN-PROD-C1` · `MONGO-CHANGESTREAM-C1` |
|
||||
| `wtimeout` 초과 | write concern error 반환. **이미 적용된 write 는 되돌아가지 않는다** → 재시도는 멱등 연산에만 | D6 · `MONGO-WRITECONCERN-C6`·`C7` |
|
||||
| transaction 이 1분 초과 | periodic cleanup 이 abort. 배치성 작업은 분할 + 멱등 재시도로 설계 | D9 · `MONGO-TXN-PROD-C2` |
|
||||
| transaction 이 WiredTiger cache 초과 | `TransactionTooLargeForCache` 로 abort | D9 · `MONGO-TXN-PROD-C4` |
|
||||
| 단일 oplog entry 16MB 초과 | 설계 제약으로 취급 — 트랜잭션 범위를 줄인다 | D9 · `MONGO-TXN-PROD-C3` |
|
||||
| 일시적 네트워크 오류 · replica set election | 드라이버가 **1회** 재시도. 애플리케이션 재시도를 겹치지 않는다 | D10 · `MONGO-RETRYWRITE-C3` |
|
||||
| 지속적 네트워크 오류 | 드라이버 재시도로 해결되지 않음 → 실패로 전파 | D10 · `MONGO-RETRYWRITE-C3` |
|
||||
| `updateMany`/`deleteMany` 실패 | 드라이버가 재시도하지 않음 → 멱등 설계 + 명시적 재시도 필요 | D10 · `MONGO-RETRYWRITE-C5` |
|
||||
| 트랜잭션 내부 개별 write 실패 | 개별 재시도 없음 → 트랜잭션 전체 재시도 (조건은 D9-a, 미확정) | D10 · `MONGO-RETRYWRITE-C6` |
|
||||
| change stream invalidate (collection drop/rename) | cursor 종료 → `startAfter` 로 재개 | D11 · `MONGO-CHANGESTREAM-C5`·`C8` |
|
||||
| change stream 연결 종료·timeout | cursor 종료 → 저장된 token 으로 `resumeAfter` 재개 | D11 · `MONGO-CHANGESTREAM-C8` |
|
||||
| sharded cluster 에서 shard 제거 | cursor 종료 → `resumeAfter` 재개 + 경보 | D11 · `MONGO-CHANGESTREAM-C8` |
|
||||
| oplog 롤오버로 resume token 무효 | **재개 불가** → **소비 중단 + 경보**. 최신 시점부터 조용히 재개해 구간을 건너뛰는 동작은 **금지**(유실 은폐). 복구 절차 자체는 §구현 가이드 5 의 `OUT_OF_BRANCH_SCOPE`. 이 상태에 **도달하기 전** 경보하는 것이 D12 | D11 · D12 · `MONGO-CHANGESTREAM-C7` |
|
||||
| secondary 읽기가 오래된 데이터 반환 | 정상 동작 — stale 허용을 선언한 경로에서만 발생해야 함 | D7 · `MONGO-READPREF-C3` |
|
||||
| sharded collection 을 `available` 로 조회 | orphaned document 반환 → **금지** | D5 · `MONGO-READCONCERN-C3` |
|
||||
| `local` read concern 데이터의 rollback | 과반수 미기록 데이터는 롤백될 수 있음 → 정확성 경로에서 `local` 금지 | D5 · `MONGO-READCONCERN-C1` |
|
||||
| 기존 데이터가 unique index 제약을 위반 | index 생성 실패 → `MIGRATION_FAILED`. **기존 데이터 정리 전략은 본 branch 범위 밖** | D13 · §검증해야 할 주장 — *unique index 기존 데이터 정리 전략* |
|
||||
| **Mongo 서버 미기동·unreachable** | `serverSelectionTimeoutMS` 초과 후 예외 → `TRANSIENT_DEPENDENCY` 계열 신규 code. 기동 시점이면 D2 의 startup 거부와 구분한다(런타임 실패 ≠ 설정 오류) | §구현 가이드 3 · `MONGO-CONNSTR-C1` |
|
||||
| **자격증명 오류 / 최소권한 계정의 명령 거부** | 실패로 전파. **category 미확정**(`needs-confirmation`) — 기존 `AUTH_*` 는 JWT 사용자 인증용이라 의미가 맞지 않음 | §구현 가이드 3 · §검증해야 할 주장 — *토폴로지·FCV 질의를 최소권한 계정이 실행 가능한가* |
|
||||
| **connection pool 고갈** | `maxPoolSize` 소진 → 대기 후 실패, `TRANSIENT_DEPENDENCY` 계열. Hikari 의 `DB_UNAVAILABLE` 은 JDBC 전용이라 재사용 불가 | §구현 가이드 3 · `MONGO-CONNSTR-C4`·`C5` |
|
||||
| **쿼리 filter·document 값이 로그로 유출** | 드라이버·template logger 를 값이 새지 않는 레벨로 고정 (D18) | D18 · §구현 가이드 8 |
|
||||
| migration runner 동시 실행 | lock 으로 단일 실행 보장. 획득 실패 시 대기 후 실패 | D14 · `MONGOCK-C3` |
|
||||
| 트랜잭션 안에서 read concern `majority`/`snapshot` 을 쓰되 `w:"majority"` 로 커밋하지 않음 | **보증이 조용히 사라진다** → D5·D6 을 쌍으로 강제 | `MONGO-READCONCERN-C6`·`C10` |
|
||||
|
||||
**다른 계약 의존**:
|
||||
|
||||
- `WI-CA-SKELETON-OPERATIONAL-CONTRACT-060` = [[raw/branch-notes/feature-capability-provider-selection-contract]] — **D2**(활성화 축 단일화)·**D3**(단일 prefix + `APP_*` env registry)·**D6**(startup 검증 시점 = refresh 완료 전)·**D10**(startup 실패는 기존 registry code 재사용)에 의존. 그 branch 가 prefix 를 `app.*` 로 확정하면 본 branch 의 D1 키(`ca-skeleton.persistence-mongo.enabled`)가 rename 대상이 된다.
|
||||
- [[raw/branch-notes/feature-env-driven-runtime-configuration]] — **D2**(`APP_` 통일, registry = SSOT)·**D7**(`verifyEnvKeys` 3-way drift). 본 branch 의 D2·D3·D4 가 제안하는 키가 이 branch 절차로 등록돼야 검증이 성립한다.
|
||||
- [[raw/branch-notes/feature-migration-startup-contract]] — `STARTUP_VALIDATION_FAILED`·`MIGRATION_FAILED` code 의 owner. 본 branch 는 소비자다.
|
||||
- [[raw/branch-notes/feature-read-consistency-query-contract]] — `DEC-…-READ-CONSISTENCY-001@1` owner. D7 이 제공하는 Mongo 표현 수단을 그 branch 의 정책이 소비한다.
|
||||
- [[raw/branch-notes/feature-outbox-dispatch-mode-contract]] · [[raw/branch-notes/feature-idempotency-ownership-protocol-contract]] — D17 이 same-store 가능 조건만 넘기고 행 모델·프로토콜을 위임.
|
||||
- [[raw/branch-notes/feature-contract-registry-governance]] — D12(metric)·D15(error code) 신규 제안의 등록 절차 owner.
|
||||
- [[raw/branch-notes/feature-application-port-usecase-contract]] — `TransactionPort` 계약 owner. Mongo 트랜잭션 구현 여부는 이 계약과 조율 후 결정(§구현 가이드 7).
|
||||
- [[raw/branch-notes/feature-migration-startup-contract]] **D6** — "multi-instance 에서는 app startup runner 를 그대로 확장하지 않고 platform one-shot job 또는 migration lock 검증 필요". **본 branch 의 D14 는 이 결정을 상속한다** — Job 이냐 lock 이냐의 선택 자체는 그 branch 소유이고, 본 branch 는 *Mongo index manifest 러너에도 같은 규율이 적용된다*는 것만 선언한다. §구현 가이드 6 의 lock 메커니즘 후보 (b)(배포 파이프라인 단일 Job)가 그 branch 의 `K8S-JOB-C1`/`C2` 근거와 같은 선택지다.
|
||||
- [[raw/branch-notes/feature-background-job-async-contract]] **D3** — "scheduler/outbox publisher 는 single-instance 기본, multi-instance 시 DB advisory lock 또는 ShedLock 필수"(lock 전략 SSOT). D14 가 Mongo 자체 lock 컬렉션을 택하면 이 SSOT 와 **다른 메커니즘**이 되므로, §구현 가이드 6 의 후보 선택 시 그 정합을 먼저 확인해야 한다.
|
||||
- [[raw/branch-notes/feature-metrics-alerting-contract]] **D2**(metric naming = Micrometer dot.case + unit suffix 강제)·**D3**(alert threshold 는 SLO/error budget 또는 documented operational default 에 연결 — **임의 수치 금지**). D12 의 oplog window metric 이름·임계는 이 두 결정을 따라야 한다 — 본 branch 가 임의 수치를 제안할 수 없는 이유다.
|
||||
- [[raw/branch-notes/feature-log-management-contract]] **D1**(JSON log 기본 + **Logback masking converter = Layer 1 SSOT**)·**D4**(production logging stdout JSON default). **D18 의 집행 수단 후보 (a)(로깅 설정 파일에 logger 레벨 못박기)는 그 branch 소유 파일을 건드린다** — 채택 시 조율 필수. 그 masking 계층은 `actually-implemented` 다(`internal-code-fact`: `app-bootstrap/.../logging/SecretMaskingMessageConverter.java` + `SecretMaskingJsonGeneratorDecorator`, `logback-spring.xml:45-46`,`:81` 에 `conversionRule`/decorator 등록). **그럼에도 D18 이 필요한 이유**: 그 계층은 `LogMaskingPatterns` **카탈로그 기반 패턴 masking**(token/password/bearer 류 고정 패턴)이라 임의 업무 필드 값 — Mongo query `filter` 의 값 — 은 패턴에 걸리지 않는다. 즉 masking 이 있어도 `org.mongodb.driver.protocol.command` 의 DEBUG 출력(`MONGO-JAVA-LOG-C4`)은 그대로 남는다.
|
||||
- [[raw/branch-notes/feature-transaction-concurrency-contract]] **D5**(lock 충돌 분류 — optimistic 409 non-retryable / deadlock·serialization retryable-by-policy)·**D6**("duplicate command → idempotency branch key scope. **retryable write without idempotency forbidden**"). **D10 의 "재시도 경로는 멱등성을 스스로 보장한다" 는 D6 의 재진술이 아니라 그 정책의 Mongo 적용점이다** — 재시도·멱등 정책 자체의 owner 는 그 branch다. D9-a(트랜잭션 재시도 라벨)도 근거 확보 후 그 branch 로 이관하는 것이 정합적일 수 있다.
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장 / Claims To Verify
|
||||
|
||||
> 공식 문서가 증명한 것은 MongoDB 의 동작이지 **우리 배포에서의 동작**이 아니다. 아래는 구현 전/중/후에 실제로 확인해야 하는 주장이다.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 본 branch 가 소유할 관심사가 sibling branch 결정과 겹치지 않는다 | D-row 17개가 새로 생겼고 D7·D17 은 명시적으로 sibling 에 위임한다 | `/sync` 실행 — owner 중복·재진술 검출 | `needs-confirmation` |
|
||||
| **Mongock 에 index drift 감지 기능이 없다** | 근거는 "이 페이지에서 확인되지 않음" 이지 "없다" 가 아니다(`MONGOCK-C4` Does-not-prove). D13 의 기각 논리가 여기에 걸려 있다 | Mongock v5 문서 트리 전체(특히 lock·기능 목록 페이지) 확인 후 D13 기각 사유 확정 또는 철회 | `needs-confirmation` |
|
||||
| **Liquibase 무료 OSS MongoDB extension 에 drift 가 없다** | 확보한 자료는 "Pro 에 drift 가 있다" 만 증명한다. "무료엔 없다" 는 부정 명제는 미증명 | 무료 `liquibase-mongodb` extension 기능 목록 페이지를 별도 수집해 대조 | `needs-confirmation` |
|
||||
| lock 컬렉션의 TTL 만료 정밀도가 migration runner 의 단일 실행 보장에 충분하다 | §구현 가이드 6 의 후보 (a)가 TTL 만료에 의존하는데, MongoDB 의 TTL 백그라운드 스캔 주기를 이번 회차에서 공식 문서로 확인하지 않았다 | MongoDB TTL index 공식 문서 수집 → 만료 지연 상한을 확인하고 "이중 실행 윈도우" 허용 여부 판정 | `planned` |
|
||||
| 기존 데이터가 있는 컬렉션에 unique index 를 걸 때의 실패·정리 전략 | 실패 경로는 §엣지에 넣었으나 **기존 데이터 dedup 전략**(자동 정리 vs 수동 vs partial unique index 우회)은 어느 대안을 골라도 필요한 별도 결정이며 이번 조사 범위 밖 | MongoDB unique index 공식 문서 수집 후 별도 결정으로 추가 | `planned` |
|
||||
| 트랜잭션 재시도를 촉발하는 에러 라벨 집합 | D9-a 가 `UNSUPPORTED_DECISION` — `TransientTransactionError`/`UnknownTransactionCommitResult` 가 수집한 두 자료 어디에도 없다(self-grep 0 매치) | MongoDB Java driver 의 transactions 에러 처리 페이지 1건 수집 → D9-a 확정 | `planned` |
|
||||
| `transactionLifetimeLimitSeconds` 의 정확한 기본 정수값 | 원문은 "less than one minute" 서술만 제공한다. 60 이라고 단정하지 않았다 | MongoDB server parameters 페이지에서 해당 파라미터 기본값 확인 | `planned` |
|
||||
| `maxStalenessSeconds` 의 수치 하한 | read preference 자료가 메커니즘만 설명하고 하한을 명시하지 않는다(해당 raw 의 Usage Boundaries) | read preference staleness 전용 페이지 수집 | `planned` |
|
||||
| **Spring Data `MongoTemplate`/Query 로 read concern 을 연산 단위로 설정하는 API 수단** | `spring-data-mongodb-template-config-official` 문서에 "read concern"/"ReadConcern" 문자열이 0회 등장(self-grep 확인) — `WriteConcernResolver` 에 대응하는 read concern 결정 수단(예: `ReadConcernResolver` 류)이 이 페이지에 없다. D5(read concern 연산 단위 명시)의 Spring Data 측 적용 seam 이 미확보 상태 | MongoDB Java driver 의 `MongoCollection#withReadConcern()` 공식 문서 또는 Spring Data MongoDB `ClientSession`/세션 스코프 문서 1건 추가 수집 | `UNSUPPORTED_DECISION` |
|
||||
| Spring 이 노출하는 `spring.data.mongodb.*` property 가 §구현 가이드 2 의 키 집합을 전부 덮는다 | 드라이버 옵션과 Spring property 의 대응 범위를 확인하지 않았다. §구현 가이드 2 의 `UNSUPPORTED_IMPL_DECISION` 이 여기에 걸려 있다 | Spring Boot `MongoProperties` 코드/문서 확인 후 URI-쿼리 vs property 결정 | `planned` |
|
||||
| 토폴로지·FCV 질의 명령을 최소권한 계정이 실행할 수 있다 | §구현 가이드 4 의 게이트가 관리 명령에 의존하는데, 권한 부족 시 게이트 자체가 기동을 막는다 | 로컬 replica-set 컨테이너에 최소권한 사용자를 만들어 검증 명령 실행 | `planned` |
|
||||
| **oplog window 를 재는 수단** (`replSetGetStatus` / `local.oplog.rs` / `db.getReplicationInfo()` 중 무엇) | §구현 가이드 6-1 의 후보 3종이 전부 무근거다. 수집한 change stream 자료가 "측정·임계는 이 자료 범위 밖" 이라고 스스로 명시한다 | change stream Production Recommendations 또는 `replSetGetStatus`/`db.getReplicationInfo` 공식 페이지 1건 수집 → §6-1 의 `UNSUPPORTED_IMPL_DECISION` 확정. **D12 코드 작성 전에** 닫는다 | `planned` |
|
||||
| **레벨 고정 후에도 잔여 채널로 쿼리 값이 새지 않는다** | §구현 가이드 8 의 "덮지 않는 채널" 4종(Command Monitoring API · 나머지 driver logger · `protocol`↔`protocol.command` 상속 미확인 · Spring Data 자체 logger)이 미검증이다. "레벨만 내리면 다 막힌다" 는 주장을 하지 않았다 | 로컬 replica-set 에서 레벨 고정 후 실제 로그를 수집해 filter 값 유출 여부를 확인 (D18 통합 test) | `planned` |
|
||||
| **Mongo 서버-대-DB 인증 실패의 category** | §구현 가이드 3 의 후보 3종(`AUTH`/`INTERNAL`/`PERMANENT_DEPENDENCY`)에 근거가 없고 기존 registry 에 *서버-대-의존성 인증 실패* 선례가 없다 | registry owner([[raw/branch-notes/feature-contract-registry-governance]])와 category 의미 확인 후 확정 | `needs-confirmation` |
|
||||
| 위 계약이 실제 replica-set 에서 성립한다 | 현재 `docker-compose*.yml` 에 Mongo 서비스가 없고 Mongo Testcontainers test 도 없다 — 계약을 실행해 본 적이 없다 | replica-set 컨테이너 + 통합 test 추가 (완료 조건의 "concern·index manifest·replica-set 트랜잭션·change stream checkpoint test") | `planned` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
> `coverage-auditor` 2회차(2026-07-28) 결과. **Verdict: Covered** — Blocking 0 / Should-fix 0 / Advisory 3. 손으로 유지하지 않는다(매 `/coverage` 실행 시 재생성).
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|--------|------|-------|--------|------|
|
||||
| URI·timeout 3종·pool 2종·tls 명시 | covered-here | — | — | D2·D3·D4 |
|
||||
| read/write concern·read preference 명시 (`DEC-…-MONGO-BASELINE-001`) | covered-here | — | — | D5·D6·D7 · §구현 가이드 2-1 |
|
||||
| transaction replica-set/FCV/storage engine 게이트 | covered-here | — | — | D8·D9 · §구현 가이드 4 |
|
||||
| change stream replica-set 게이트 + resume token 영속화 | covered-here | — | — | D11 · §구현 가이드 5 |
|
||||
| oplog window 감시 | covered-here(측정 수단 `UNSUPPORTED_IMPL_DECISION`) + delegated(임계) | [[raw/branch-notes/feature-metrics-alerting-contract]] D2·D3 | OK | D12 · §구현 가이드 6-1 |
|
||||
| index manifest·auto-creation 금지·drift·migration runner | covered-here | — | — | D13 · §구현 가이드 6 |
|
||||
| migration runner 단일 실행 lock (§25 Multi-Instance Guardrail) | covered-here(메커니즘 `UNSUPPORTED_IMPL_DECISION`) | — | — | D14 |
|
||||
| retryable writes 위 애플리케이션 재시도 중첩 금지 | covered-here | — | — | D10 |
|
||||
| 트랜잭션 재시도 라벨 | covered-here(`UNSUPPORTED_DECISION`, 후속 dispatch 계획 명시) | — | — | D9-a · §검증해야 할 주장 |
|
||||
| **Mongo 쿼리·document 값 로그 유출 억제 (§11)** | covered-here | — | — | **D18 · §구현 가이드 8** (1회차 Blocking 해소) |
|
||||
| Data integrity / DB unavailable → 실패 category | covered-here(제안) | [[raw/branch-notes/feature-contract-registry-governance]] | OK | D15 · §구현 가이드 3 |
|
||||
| query timeout | covered-here(`socketTimeoutMS` 로 대체) | — | ⚪ Advisory | D3 — per-operation `maxTimeMS` 는 미언급. 소켓 상한이 무한대기는 막으므로 Blocking 아님 |
|
||||
| JPA system failure (governing §11 6항목 중 1) | out-of-scope | [[raw/branch-notes/feature-persistence-failure-baseline]] | — | Mongo 모듈은 JPA 미사용 — D16 이 오히려 JPA 의존을 금지 |
|
||||
| ArchUnit vendor-isolation rule | covered-here | — | — | D16 |
|
||||
| same-store Mongo outbox/inbox | covered-here(가능 조건만) + delegated(행모델·프로토콜) | [[raw/branch-notes/feature-outbox-dispatch-mode-contract]] · [[raw/branch-notes/feature-idempotency-ownership-protocol-contract]] | OK | D17 |
|
||||
| `ReadConsistency` → replica 라우팅 정책 | delegated | [[raw/branch-notes/feature-read-consistency-query-contract]] | OK | D7 · §구현 가이드 7 |
|
||||
| env key 등록·수치 | delegated | [[raw/branch-notes/feature-env-driven-runtime-configuration]] | OK | D3·D4 · §구현 가이드 2 |
|
||||
| secret 분류(URI) | delegated | [[raw/branch-notes/feature-secrets-config-source-contract]] | OK (1회차 Should-fix 해소) | D4 |
|
||||
| 운영 runbook | delegated(부재 신고만) | [[raw/branch-notes/feature-operational-runbook-contract]] | ⚪ Advisory | §Audit A4 — Mongo runbook 미작성, 위임 경로는 명확 |
|
||||
| `TransactionPort` Mongo 구현 여부 | out-of-scope(조율 대기) | [[raw/branch-notes/feature-application-port-usecase-contract]] | OK | §구현 가이드 7 |
|
||||
| 활성화 축(boolean vs provider) | covered-here(refines) | — | — | D1 |
|
||||
| §36.2 capability card 전체 | 미충족(**요구되지 않음**) | — | ⚪ Advisory | 목표 등급이 `R1` Local — §36.2 는 R2 이상 주장 시에만 요구. R2 승급 시 재요구 |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
해당 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
title: branch / feature-notification-durable-delivery-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-notification-durable-delivery-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||||
tags: [branch, ca-skeleton, notification, durability, template, receipt]
|
||||
created: 2026-07-28
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-077
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-077
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-READINESS-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-RESILIENCE-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-072]
|
||||
imports: []
|
||||
delegates: []
|
||||
accepts_delegations: []
|
||||
contract_packet: 1
|
||||
---
|
||||
|
||||
# branch: feature-notification-durable-delivery-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
> **스캐폴딩 상태** — 결정(D-row)·구현 가이드는 비어 있다. `/branch-spec feature-notification-durable-delivery-contract` 로 채운다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> 분해 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §4.2 — 전송·조회 (Tier X). 본 branch 는 project §8.0 `WI-CA-SKELETON-OPERATIONAL-CONTRACT-077` 의 실행 단위다.
|
||||
|
||||
형제 branch (같은 부모의 다른 자식 — 인접 영역):
|
||||
|
||||
- [[raw/branch-notes/feature-notification-provider-spi]]
|
||||
- [[raw/branch-notes/feature-outbox-dispatch-mode-contract]]
|
||||
- [[raw/branch-notes/feature-webhook-outbound-contract]]
|
||||
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
> project Work Item 에서 내려온 실행 계약의 snapshot. 여기에는 **pinned pointer + 1줄 요약 + branch 적용점**만 쓰고 상세를 복제하지 않는다.
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: durable/best-effort 분리·template 버전·receipt·dedupe 계약 test 가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-READINESS-001@1` | 모든 capability는 R0~R3 readiness 등급과 capability card를 가지며 R0 seam을 R2 provider로 표기하지 않는다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-RESILIENCE-001@1` | Resilience4j가 default이며 Spring Retry는 simple blocking retry에만 허용한다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다. 근거 없이 추측해 채우지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- `WI-CA-SKELETON-OPERATIONAL-CONTRACT-077` 의 완료 조건을 구현한다: durable/best-effort 분리·template 버전·receipt·dedupe 계약 test 가 통과한다
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- best-effort 와 durable 인터페이스의 명시적 분리
|
||||
- 논리 template ID·버전·locale 과 typed 파라미터
|
||||
- provider quota 와 bounded retry, 우선순위·fallback·fan-out 라우팅
|
||||
- dedupe 와 provider idempotency key
|
||||
- durable 모드의 outbox/message 경유
|
||||
- webhook 서명 검증·delivery receipt·bounce/suppression 처리
|
||||
- PII-safe 로그·큐 내용 암호화·보존
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- consent·preference·quiet hour 판단 — 업무 규칙이면 application/domain 소유
|
||||
- provider SPI 추상화와 라우팅 골격 — `feature-notification-provider-spi` 소유
|
||||
- 심층 명세 — 본 노트는 스캐폴딩. D-row 와 §구현 가이드는 `/branch-spec` 이 채운다.
|
||||
- project decision registry 변경 — owner 는 project-note
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 외부 근거 미등록. `/branch-spec feature-notification-durable-delivery-contract` 단계에서 verbatim 인용과 함께 `raw/official-docs/` 로 수집한 뒤 여기서 링크한다.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| (미등록) | 1차 수집 대상: (provider 공식 문서는 `/branch-spec` 에서 수집) · ca-tmpl platform 설계 §13.2 |
|
||||
|
||||
**프로젝트 내부 설계 참조 (등급 `internal-design-doc` — 공식 문서 아님, best practice 로 격상 금지):**
|
||||
|
||||
- ca-tmpl `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md`
|
||||
- llm-wiki `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md`
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [ ] `/branch-spec feature-notification-durable-delivery-contract` 로 D-row·§구현 가이드 작성 — 등급: `planned`
|
||||
- [ ] durable/best-effort 분리·template 버전·receipt·dedupe 계약 test 가 통과한다 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
project 결정 외 branch-local 결정은 아직 없음. `/branch-spec` 단계에서 기록한다.
|
||||
|
||||
<!-- section-id: decision-evidence -->
|
||||
## 결정-근거 매핑
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
`/branch-spec` 단계에서 source claim 기반으로 작성한다. 3-rule meta principle(R1 Reference 필수 / R2 UNSUPPORTED_IMPL_DECISION 명시 / R3 OUT_OF_BRANCH_SCOPE 정제) 적용.
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**: `/branch-spec` 단계에서 구체화한다.
|
||||
- **다른 계약 의존**: `WI-CA-SKELETON-OPERATIONAL-CONTRACT-072`([[raw/branch-notes/feature-outbox-dispatch-mode-contract]]) 의 계약에 의존
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 본 branch 가 소유할 관심사가 sibling branch 결정과 겹치지 않는다 | 스캐폴딩 시점에는 D-row 가 없어 경계가 문장으로만 존재 | `/branch-spec` 후 `/sync` 실행 — owner 중복 검출 | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
`/coverage` 실행 전.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
해당 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
title: branch / feature-objectstorage-streaming-lifecycle-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-objectstorage-streaming-lifecycle-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||||
tags: [branch, ca-skeleton, object-storage, s3, streaming, multipart]
|
||||
created: 2026-07-28
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-066
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-066
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-READINESS-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-060]
|
||||
imports: []
|
||||
delegates: []
|
||||
accepts_delegations: []
|
||||
contract_packet: 1
|
||||
---
|
||||
|
||||
# branch: feature-objectstorage-streaming-lifecycle-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
> **스캐폴딩 상태** — 결정(D-row)·구현 가이드는 비어 있다. `/branch-spec feature-objectstorage-streaming-lifecycle-contract` 로 채운다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> 분해 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §4.2 — 기술 런타임 (Tier T). 본 branch 는 project §8.0 `WI-CA-SKELETON-OPERATIONAL-CONTRACT-066` 의 실행 단위다.
|
||||
|
||||
형제 branch (같은 부모의 다른 자식 — 인접 영역):
|
||||
|
||||
- [[raw/branch-notes/feature-file-resource-handling-contract]]
|
||||
- [[raw/branch-notes/feature-data-retention-privacy-contract]]
|
||||
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
> project Work Item 에서 내려온 실행 계약의 snapshot. 여기에는 **pinned pointer + 1줄 요약 + branch 적용점**만 쓰고 상세를 복제하지 않는다.
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: streaming·multipart·presigned·checksum·orphan 회수 계약 test 가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-READINESS-001@1` | 모든 capability는 R0~R3 readiness 등급과 capability card를 가지며 R0 seam을 R2 provider로 표기하지 않는다 | `/branch-spec` 에서 적용 경계 작성 | [[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 책임을 분리한다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다. 근거 없이 추측해 채우지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- `WI-CA-SKELETON-OPERATIONAL-CONTRACT-066` 의 완료 조건을 구현한다: streaming·multipart·presigned·checksum·orphan 회수 계약 test 가 통과한다
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- streaming upload/download 과 range read (`byte[]` 전용 API 대체)
|
||||
- head/metadata 계약과 checksum 알고리즘·검증
|
||||
- version/ETag 전제조건 기반 조건부 create/update/delete
|
||||
- presigned 요청의 만료·content type·크기 상한
|
||||
- multipart start/upload/complete/abort 와 orphan 회수
|
||||
- server-side encryption·KMS, endpoint/region/credential chain 검증
|
||||
- staged finalization (stage → verify → commit → finalize) 과 보상
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- file server 로컬 파일 게시 — `feature-file-resource-handling-contract` 소유
|
||||
- 업로드 대상의 도메인 규칙
|
||||
- 심층 명세 — 본 노트는 스캐폴딩. D-row 와 §구현 가이드는 `/branch-spec` 이 채운다.
|
||||
- project decision registry 변경 — owner 는 project-note
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 외부 근거 미등록. `/branch-spec feature-objectstorage-streaming-lifecycle-contract` 단계에서 verbatim 인용과 함께 `raw/official-docs/` 로 수집한 뒤 여기서 링크한다.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| (미등록) | 1차 수집 대상: Amazon S3 공식 — object integrity · ca-tmpl platform 설계 §13.3 |
|
||||
|
||||
**프로젝트 내부 설계 참조 (등급 `internal-design-doc` — 공식 문서 아님, best practice 로 격상 금지):**
|
||||
|
||||
- ca-tmpl `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md`
|
||||
- llm-wiki `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md`
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [ ] `/branch-spec feature-objectstorage-streaming-lifecycle-contract` 로 D-row·§구현 가이드 작성 — 등급: `planned`
|
||||
- [ ] streaming·multipart·presigned·checksum·orphan 회수 계약 test 가 통과한다 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
project 결정 외 branch-local 결정은 아직 없음. `/branch-spec` 단계에서 기록한다.
|
||||
|
||||
<!-- section-id: decision-evidence -->
|
||||
## 결정-근거 매핑
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
`/branch-spec` 단계에서 source claim 기반으로 작성한다. 3-rule meta principle(R1 Reference 필수 / R2 UNSUPPORTED_IMPL_DECISION 명시 / R3 OUT_OF_BRANCH_SCOPE 정제) 적용.
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**: `/branch-spec` 단계에서 구체화한다.
|
||||
- **다른 계약 의존**: `WI-CA-SKELETON-OPERATIONAL-CONTRACT-060`([[raw/branch-notes/feature-capability-provider-selection-contract]]) 의 계약에 의존
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 본 branch 가 소유할 관심사가 sibling branch 결정과 겹치지 않는다 | 스캐폴딩 시점에는 D-row 가 없어 경계가 문장으로만 존재 | `/branch-spec` 후 `/sync` 실행 — owner 중복 검출 | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
`/coverage` 실행 전.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
해당 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -27,6 +27,12 @@ contract_packet_sha256: 17707c8f1f903e49e2466e6f22228fa014375942bc5e97f2098b8b3f
|
||||
> Layer: `raw/branch-notes/` — outbound HTTP adapter 실패 분류와 RestClient baseline을 정의합니다.
|
||||
> 구조 메모: 이 노트는 2026-05-21 생성(현 branch-note 템플릿 이전 포맷). 2026-06-10 `/branch-spec` 에서 템플릿 순서로 재정렬 + §구현 가이드·§엣지·실패·의존·§Audit & Findings·§관련 일일 노트 신설. 템플릿에 없는 pre-template 보조 섹션(Work Item Contract / Decisionized Work Items / 테스트 계약 / 외부 근거)은 삭제하지 않고 관련 템플릿 섹션 옆에 슬롯해 보존했다.
|
||||
|
||||
> [!note] 개정 예정 고지 (2026-07-28)
|
||||
> 본 branch 는 **owner 를 유지**하되 내용을 코드 사실에 맞춰 개정한다. 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §5.3.
|
||||
>
|
||||
> - **개정 사유**: `globalCallTimeout` 이 실행 중 호출을 취소하지 않고 다음 재시도 진입 여부만 판정하며, decorator 순서가 문서 서술과 다르다. total deadline 의 능동 취소와 순서 계약을 재판정한다.
|
||||
> - **참조 설계**: ca-tmpl `docs/superpowers/specs/2026-07-27-httpclient-production-capability-design.md` §3·§15·§17~§20 (등급 `internal-design-doc` — 공식 문서 아님)
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
title: branch / feature-outbox-dispatch-mode-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-outbox-dispatch-mode-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||||
tags: [branch, ca-skeleton, outbox, cdc, debezium, dispatch-mode]
|
||||
created: 2026-07-28
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-072
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-072
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-OUTBOX-DISPATCH-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-DELIVERY-SEMANTICS-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-063]
|
||||
imports: []
|
||||
delegates: []
|
||||
accepts_delegations: []
|
||||
contract_packet: 1
|
||||
---
|
||||
|
||||
# branch: feature-outbox-dispatch-mode-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
> **스캐폴딩 상태** — 결정(D-row)·구현 가이드는 비어 있다. `/branch-spec feature-outbox-dispatch-mode-contract` 로 채운다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> 분해 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §4.2 — 능력 계약 (Tier C). 본 branch 는 project §8.0 `WI-CA-SKELETON-OPERATIONAL-CONTRACT-072` 의 실행 단위다.
|
||||
|
||||
형제 branch (같은 부모의 다른 자식 — 인접 영역):
|
||||
|
||||
- [[raw/branch-notes/feature-domain-event-outbox-contract]]
|
||||
- [[raw/branch-notes/feature-background-job-async-contract]]
|
||||
- [[raw/branch-notes/feature-kafka-producer-runtime-contract]]
|
||||
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
> project Work Item 에서 내려온 실행 계약의 snapshot. 여기에는 **pinned pointer + 1줄 요약 + branch 적용점**만 쓰고 상세를 복제하지 않는다.
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: event/delivery 분리·모드 배타·CDC 라우팅·파티션 정리 조건 test 가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-OUTBOX-DISPATCH-001@1` | outbox는 불변 event 행과 polling 전용 delivery 행을 분리하고 dispatch 모드는 polling과 cdc가 배타이며 append는 업무 write와 같은 트랜잭션에 남는다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-DELIVERY-SEMANTICS-001@1` | end-to-end 메시징 보증은 at-least-once 전달과 멱등 consumer·inbox로 표현하고 DB와 broker를 걸친 exactly-once를 주장하지 않는다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다. 근거 없이 추측해 채우지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- `WI-CA-SKELETON-OPERATIONAL-CONTRACT-072` 의 완료 조건을 구현한다: event/delivery 분리·모드 배타·CDC 라우팅·파티션 정리 조건 test 가 통과한다
|
||||
- **주도권 이전 수신** — H6 (`feature-domain-event-outbox-contract` 의 행 상태 머신 D-row). 이전 절차는 `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §5.2 6단계를 따른다. 기존 branch 의 D-row 는 `/branch-spec` 으로 본 branch 결정이 확정된 뒤 포인터로 치환한다.
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- `outbox_event`(불변 envelope) 와 `outbox_delivery`(polling 전용 상태) 분리
|
||||
- `dispatch-mode: disabled | polling | cdc` 배타성과 전환 runbook
|
||||
- append 는 업무 write 와 같은 트랜잭션 유지 (Redis·Kafka 이동 금지)
|
||||
- polling — claim owner token, aggregate sequence 순서, broker ack 기한, 통합 재시도 예산, replay/requeue/skip 도구
|
||||
- CDC — Debezium Outbox Event Router 매핑, connector predicate·offset·snapshot·WAL/slot 운영, lag 감시
|
||||
- `outbox_event` 파티션 보존과 정리 가능 조건 (consumed 증명 + replay 보존 경과)
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- Kafka producer 설정 — #063 소유
|
||||
- scheduler 실행 기반 — `feature-background-job-async-contract` 소유
|
||||
- 심층 명세 — 본 노트는 스캐폴딩. D-row 와 §구현 가이드는 `/branch-spec` 이 채운다.
|
||||
- project decision registry 변경 — owner 는 project-note
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 외부 근거 미등록. `/branch-spec feature-outbox-dispatch-mode-contract` 단계에서 verbatim 인용과 함께 `raw/official-docs/` 로 수집한 뒤 여기서 링크한다.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| (미등록) | 1차 수집 대상: Debezium 공식 — Outbox Event Router · ca-tmpl platform 설계 §11.1·§11.2 |
|
||||
|
||||
**프로젝트 내부 설계 참조 (등급 `internal-design-doc` — 공식 문서 아님, best practice 로 격상 금지):**
|
||||
|
||||
- ca-tmpl `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md`
|
||||
- llm-wiki `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md`
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [ ] `/branch-spec feature-outbox-dispatch-mode-contract` 로 D-row·§구현 가이드 작성 — 등급: `planned`
|
||||
- [ ] event/delivery 분리·모드 배타·CDC 라우팅·파티션 정리 조건 test 가 통과한다 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
project 결정 외 branch-local 결정은 아직 없음. `/branch-spec` 단계에서 기록한다.
|
||||
|
||||
<!-- section-id: decision-evidence -->
|
||||
## 결정-근거 매핑
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
`/branch-spec` 단계에서 source claim 기반으로 작성한다. 3-rule meta principle(R1 Reference 필수 / R2 UNSUPPORTED_IMPL_DECISION 명시 / R3 OUT_OF_BRANCH_SCOPE 정제) 적용.
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**: `/branch-spec` 단계에서 구체화한다.
|
||||
- **다른 계약 의존**: `WI-CA-SKELETON-OPERATIONAL-CONTRACT-063`([[raw/branch-notes/feature-kafka-producer-runtime-contract]]) 의 계약에 의존
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 본 branch 가 소유할 관심사가 sibling branch 결정과 겹치지 않는다 | 스캐폴딩 시점에는 D-row 가 없어 경계가 문장으로만 존재 | `/branch-spec` 후 `/sync` 실행 — owner 중복 검출 | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
`/coverage` 실행 전.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
해당 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -26,6 +26,18 @@ contract_packet_sha256: f4c77ee0413caae0af0437e46ad05664e0f65e7b3c64923f318cb451
|
||||
|
||||
> Layer: `raw/branch-notes/` — rate limit, abuse protection, idempotency 기준을 정의합니다.
|
||||
|
||||
> [!important] 주도권 이전 고지 (2026-07-28)
|
||||
> 아래 관심사의 **owner 가 신규 branch 로 이동**했다. 근거·절차: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §5.
|
||||
>
|
||||
> | 이전 ID | 대상 D-row | 이전한 관심사 | 신규 owner |
|
||||
> |---|---|---|---|
|
||||
> | H1 | D2·D3·D6·D7·D8·D9 | idempotency 저장소·프로토콜·TTL·fingerprint | [[raw/branch-notes/feature-idempotency-ownership-protocol-contract]] |
|
||||
> | H2 | D4·D5 | rate limit 키 차원·분산 지원·알고리즘 | [[raw/branch-notes/feature-edge-rate-limit-policy-registry-contract]] |
|
||||
>
|
||||
> **본문은 아직 제거하지 않았다.** 신규 branch 는 현재 스캐폴딩 상태(D-row 없음)이므로, 지금 본문을 지우면 결정 근거가 소실된다. 위 D-row 는 신규 branch 의 `/branch-spec` 이 재판정을 마칠 때까지 **잠정 근거**로만 유효하며, 그 시점에 설계 §5.2 6단계에 따라 포인터(`superseded → [[<신규 branch>]] D<m>`)로 치환한다.
|
||||
>
|
||||
> 이 기간 중 새 결정을 본 branch 에 추가하지 않는다 — 신규 owner 에 작성한다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
title: branch / feature-read-consistency-query-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-read-consistency-query-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||||
tags: [branch, ca-skeleton, query, read-consistency, replica, cursor]
|
||||
created: 2026-07-28
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-076
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-076
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-READ-CONSISTENCY-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-TRANSACTION-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-060]
|
||||
imports: []
|
||||
delegates: []
|
||||
accepts_delegations: []
|
||||
contract_packet: 1
|
||||
---
|
||||
|
||||
# branch: feature-read-consistency-query-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
> **스캐폴딩 상태** — 결정(D-row)·구현 가이드는 비어 있다. `/branch-spec feature-read-consistency-query-contract` 로 채운다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> 분해 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §4.2 — 전송·조회 (Tier X). 본 branch 는 project §8.0 `WI-CA-SKELETON-OPERATIONAL-CONTRACT-076` 의 실행 단위다.
|
||||
|
||||
형제 branch (같은 부모의 다른 자식 — 인접 영역):
|
||||
|
||||
- [[raw/branch-notes/feature-application-query-bypass-contract]]
|
||||
- [[raw/branch-notes/feature-database-connection-pool-contract]]
|
||||
- [[raw/branch-notes/feature-mongo-runtime-baseline-contract]]
|
||||
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
> project Work Item 에서 내려온 실행 계약의 snapshot. 여기에는 **pinned pointer + 1줄 요약 + branch 적용점**만 쓰고 상세를 복제하지 않는다.
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: ReadConsistency·cursor·replica 라우팅·projection lag 계약 test 가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-READ-CONSISTENCY-001@1` | 조회는 ReadConsistency를 명시하고 replica 라우팅은 그 정책과 요청 문맥으로 결정하며 TransactionPort.inRead는 replica 사용을 뜻하지 않는다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-TRANSACTION-001@1` | application은 Spring transaction annotation 대신 TransactionPort 또는 TransactionalUseCaseRunner를 사용한다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다. 근거 없이 추측해 채우지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- `WI-CA-SKELETON-OPERATIONAL-CONTRACT-076` 의 완료 조건을 구현한다: ReadConsistency·cursor·replica 라우팅·projection lag 계약 test 가 통과한다
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- `ReadConsistency` — `STRONG` / `READ_YOUR_WRITES` / `BOUNDED_STALENESS` / `EVENTUAL`
|
||||
- opaque·서명·버전 cursor 와 페이지 상한, sort/filter allowlist
|
||||
- primary/replica 라우팅 판정 근거 (일관성 정책 + 요청 문맥)
|
||||
- `TransactionPort.inRead()` 가 replica 사용을 뜻하지 않음의 명문화
|
||||
- projection checkpoint 와 lag 노출
|
||||
- 조회 진행 단계 — same-store 집계 → projection → replica → 별도 read model → 전용 store
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- 범용 `QueryPort<Q,R>` 또는 범용 repository 도입
|
||||
- feature 별 query 형태 — 각 feature application 패키지 소유
|
||||
- 심층 명세 — 본 노트는 스캐폴딩. D-row 와 §구현 가이드는 `/branch-spec` 이 채운다.
|
||||
- project decision registry 변경 — owner 는 project-note
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 외부 근거 미등록. `/branch-spec feature-read-consistency-query-contract` 단계에서 verbatim 인용과 함께 `raw/official-docs/` 로 수집한 뒤 여기서 링크한다.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| (미등록) | 1차 수집 대상: (기존 `feature-application-query-bypass-contract` Sources 상속) · ca-tmpl platform 설계 §12.1 |
|
||||
|
||||
**프로젝트 내부 설계 참조 (등급 `internal-design-doc` — 공식 문서 아님, best practice 로 격상 금지):**
|
||||
|
||||
- ca-tmpl `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md`
|
||||
- llm-wiki `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md`
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [ ] `/branch-spec feature-read-consistency-query-contract` 로 D-row·§구현 가이드 작성 — 등급: `planned`
|
||||
- [ ] ReadConsistency·cursor·replica 라우팅·projection lag 계약 test 가 통과한다 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
project 결정 외 branch-local 결정은 아직 없음. `/branch-spec` 단계에서 기록한다.
|
||||
|
||||
<!-- section-id: decision-evidence -->
|
||||
## 결정-근거 매핑
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
`/branch-spec` 단계에서 source claim 기반으로 작성한다. 3-rule meta principle(R1 Reference 필수 / R2 UNSUPPORTED_IMPL_DECISION 명시 / R3 OUT_OF_BRANCH_SCOPE 정제) 적용.
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**: `/branch-spec` 단계에서 구체화한다.
|
||||
- **다른 계약 의존**: `WI-CA-SKELETON-OPERATIONAL-CONTRACT-060`([[raw/branch-notes/feature-capability-provider-selection-contract]]) 의 계약에 의존
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 본 branch 가 소유할 관심사가 sibling branch 결정과 겹치지 않는다 | 스캐폴딩 시점에는 D-row 가 없어 경계가 문장으로만 존재 | `/branch-spec` 후 `/sync` 실행 — owner 중복 검출 | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
`/coverage` 실행 전.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
해당 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -0,0 +1,467 @@
|
||||
---
|
||||
title: branch / feature-redis-atomic-program-catalog-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-redis-atomic-program-catalog-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||||
tags: [branch, ca-skeleton, redis, lua, atomicity, script-catalog]
|
||||
created: 2026-07-28
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-062
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-062
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-REDIS-PROGRAM-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-061]
|
||||
imports: []
|
||||
delegates: []
|
||||
accepts_delegations: []
|
||||
contract_packet: 1
|
||||
---
|
||||
|
||||
# branch: feature-redis-atomic-program-catalog-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
> **스캐폴딩 상태** — 결정(D-row)·구현 가이드는 비어 있다. `/branch-spec feature-redis-atomic-program-catalog-contract` 로 채운다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> 분해 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §4.2 — 기술 런타임 (Tier T). 본 branch 는 project §8.0 `WI-CA-SKELETON-OPERATIONAL-CONTRACT-062` 의 실행 단위다.
|
||||
|
||||
형제 branch (같은 부모의 다른 자식 — 인접 영역):
|
||||
|
||||
- [[raw/branch-notes/feature-redis-runtime-role-isolation-contract]]
|
||||
- [[raw/branch-notes/feature-edge-rate-limit-policy-registry-contract]]
|
||||
- [[raw/branch-notes/feature-fenced-lock-coordination-contract]]
|
||||
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
> project Work Item 에서 내려온 실행 계약의 snapshot. 여기에는 **pinned pointer + 1줄 요약 + branch 적용점**만 쓰고 상세를 복제하지 않는다.
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: program descriptor·golden vector·NOSCRIPT 재적재 test 와 unsafe recipe 경합 재현 test 가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-REDIS-PROGRAM-001@1` | 다단계 read-decide-write는 버전 있는 atomic program으로만 제공하고 각 helper는 unsafe recipe와 안전한 대체를 쌍으로 문서화하며 경합 test로 증명한다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 상세 근거와 선택 조건은 아래 결정-근거 매핑 표의 동일 D-row 가 소유한다. 여기에는 1줄 요약만 둔다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | atomic program 본문의 SSOT 는 `.lua` classpath resource 파일이고, 실행 메타데이터는 별도 descriptor 가 소유한다 | `refines DEC-…-REDIS-PROGRAM-001@1` | `spring-data-redis-scripting-execution.md#SDR-SCRIPT-C6`·`#SDR-SCRIPT-C5` | `proposed` |
|
||||
| D2 | EVALSHA 우선 + `NOSCRIPT` 를 받은 그때만 EVAL 로 1회 폴백. Spring Data Redis 에 위임하지 않고 Lettuce 위에 직접 구현한다 | `local` | **as-built** `RedisLuaProgramExecutor.execute()`, `redis-eval-scripting-atomicity.md#REDIS-EVAL-C3` | `actually-implemented` |
|
||||
| D3 | pipeline 경로에서는 EVALSHA 를 쓰지 않고 plain EVAL 로 되돌린다 | `local` | `redis-eval-scripting-atomicity.md#REDIS-EVAL-C4` | `proposed` |
|
||||
| D4 | bounded execution 은 descriptor 선언 + 빌드타임 정적 검사를 1차 강제로 두고, 서버 실행시간 설정·kill 런북은 상시 병행하는 사후 안전망으로 분리한다 | `refines DEC-…-REDIS-PROGRAM-001@1` | `redis-eval-scripting-atomicity.md#REDIS-EVAL-C1`·`#REDIS-EVAL-C6`, `redis-functions-library-deployment.md#REDIS-FN-C4` | `proposed` |
|
||||
| D5 | 모든 key 는 `KEYS` 로 선언하고, 미선언 key·프로그램 생성 key 접근을 program 작성 규칙으로 금지한다 | `local` | `redis-eval-scripting-atomicity.md#REDIS-EVAL-C5`, `redis-lua-api-determinism-replication.md#REDIS-LUAAPI-C5` | `proposed` |
|
||||
| D6 | 배포 모드 기본값은 EVAL script 이고 Redis Functions 는 3조건 동시 충족 시에만 opt-in | `local` | `redis-functions-library-deployment.md#REDIS-FN-C1`·`#REDIS-FN-C2`·`#REDIS-FN-C6`, `redis-eval-scripting-atomicity.md#REDIS-EVAL-C2` | `proposed` |
|
||||
| D7 | unsafe recipe ↔ 대체 쌍은 **읽은 값 기반 분기 유무**로 갈라 등재한다 (분기 없음 → `MULTI`/`EXEC`, 분기 있음 → atomic program) | `refines DEC-…-REDIS-PROGRAM-001@1` | `redis-incr-rate-limiter-pattern.md#REDIS-INCR-C2`·`#REDIS-INCR-C3`·`#REDIS-INCR-C4` | `proposed` |
|
||||
| D8 | typed facade 는 semantics-free — program 식별자·KEYS·ARGV·반환 타입만 다루고 능력 의미(rate limit·lock·idempotency)를 알지 않는다 | `local` | `spring-data-redis-scripting-execution.md#SDR-SCRIPT-C3`·`#SDR-SCRIPT-C4` + 소비 branch 4건의 out-of-scope 선언 | `proposed` |
|
||||
| D9 | 경합 증명은 2층 분리 — unsafe recipe 의 race 는 fake + 결정론적 인터리빙(focused), atomic 대체의 원자성은 실제 Redis 통합 test | `local` | `test-taxonomy-testcontainers-official.md#TC-OFFICIAL-C3`·`#TC-OFFICIAL-C5`, `redis-eval-scripting-atomicity.md#REDIS-EVAL-C1` | `proposed` |
|
||||
| D10 | program 실행 실패는 backend 실패와 program 결함으로 등급을 나눈다 (결함은 miss 로 위장 금지) | `local` (hub §11 매트릭스 consume) | `redis-lua-api-determinism-replication.md#REDIS-LUAAPI-C1`·`#REDIS-LUAAPI-C2` | `proposed` |
|
||||
| D11 | program 실행에 bounded cardinality metric 1개 + 실행당 trace span 1개를 짝지어 붙이고, key·ARGV 는 절대 tag/attribute 로 넣지 않는다 | `local` (`feature-metrics-alerting-contract` D2·D8 consume) | [[raw/branch-notes/feature-metrics-alerting-contract]] `D2`·`D8` + `metrics.yaml` 의 `lock.acquisition` 선례 | `proposed` |
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- `WI-CA-SKELETON-OPERATIONAL-CONTRACT-062` 의 완료 조건을 구현한다: program descriptor·golden vector·NOSCRIPT 재적재 test 와 unsafe recipe 경합 재현 test 가 통과한다
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- atomic program 카탈로그와 `ScriptDescriptor` 필수 필드
|
||||
- EVALSHA 실행 + NOSCRIPT 안전 재적재
|
||||
- bounded execution 규칙 (O(1)/유한, KEYS 선언, 무한 루프·대량 스캔 금지)
|
||||
- unsafe multi-command recipe ↔ atomic 대체의 쌍 문서화와 경합 test 의무
|
||||
- Redis Functions 배포 모드의 opt-in 조건
|
||||
- golden vector 와 typed facade
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- 능력별 상태 프로그램의 의미 — 각 능력 branch 소유
|
||||
- Redis 연결·키·코덱 — #061 소유
|
||||
- 심층 명세 — 본 노트는 스캐폴딩. D-row 와 §구현 가이드는 `/branch-spec` 이 채운다.
|
||||
- project decision registry 변경 — owner 는 project-note
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/redis-eval-scripting-atomicity]] | 다단계 read-decide-write 는 EVALSHA 로 실행하는 버전 있는 atomic program 으로만 제공하고, NOSCRIPT 시 안전 재적재하며, bounded execution(유한 실행) 규칙을 둔다 — 스크립트 원자성(블로킹 실행)·EVALSHA/NOSCRIPT 복구·KEYS 선언 규칙·SCRIPT KILL 근거 |
|
||||
| [[raw/official-docs/spring-data-redis-scripting-execution]] | ⚠️ **채택하지 않은 대안의 근거** — 초판은 이 문서를 D1·D2 의 기반으로 삼았으나 as-built 에 `spring-data-redis` 의존성이 없어 폐기했다. 현재는 "프레임워크에 위임했다면 어땠을지" 의 비교 기준(`#SDR-SCRIPT-C1` 의 evalsha→eval 자동 폴백)으로만 남는다. `#SDR-SCRIPT-C6`(script 를 classpath resource 로 두는 패턴)만 D1 에서 여전히 유효 |
|
||||
| [[raw/official-docs/redis-lua-api-determinism-replication]] | call/pcall 오류 전파 차이, typed error/status reply(`redis.error_reply`/`redis.status_reply`), effects replication(Redis 7.0 기준 유일 모드) + `redis.replicate_commands()` 폐기 이력, KEYS 선언 강제, sandbox 제약(전역 변수 금지·`require` 비활성화)의 공식 근거 |
|
||||
| [[raw/official-docs/redis-functions-library-deployment]] | Redis Functions(FUNCTION LOAD/LIST, Shebang 선언, `no-writes` flag, Redis 7.0 버전 요구, cluster 수동 배포)의 opt-in 조건을 무엇으로 삼을지의 공식 근거 |
|
||||
| [[raw/official-docs/redis-incr-rate-limiter-pattern]] | unsafe multi-command recipe (`GET`→판단→`INCR`→조건부 `EXPIRE`) 의 race condition 을 공식 문서가 명시 — 각 atomic program helper 가 unsafe recipe 와 안전한 atomic 대체(MULTI/EXEC 또는 Lua/EVAL)를 쌍으로 문서화해야 한다는 결정의 근거 |
|
||||
| [[raw/official-docs/redis-programmability-max-execution-time]] | bounded execution 규칙을 예방 계층(정적 검사)과 사후 대응 계층(서버 설정 + kill 런북)으로 분리해 상시 병행한다는 결정(D4)의 세부 근거 — 기본 5초 임계값·`busy-reply-threshold` 설정명, 시간 초과 시 자동 종료하지 않는 이유(atomicity 계약 보호), `BUSY` 에러 상태의 허용 명령(`SCRIPT KILL`/`FUNCTION KILL`/`SHUTDOWN NOSAVE`), 쓰기 수행 스크립트는 `SHUTDOWN NOSAVE` 만 허용됨을 공식 확인 |
|
||||
|
||||
| [[raw/official-docs/test-taxonomy-testcontainers-official]] (재사용 — owner 는 `feature-test-taxonomy-fixture-contract`) | D9 의 "atomic 원자성은 실제 Redis 로만 증명" — `#TC-OFFICIAL-C3`(mock/in-memory 없이 production 과 동일 type 서비스), `#TC-OFFICIAL-C5`(in-memory 는 production service 의 기능을 다 갖지 않음). ⚠️ 원문의 예시는 H2 이고 **Redis·Lua 를 직접 말하지 않는다** — "임베디드 Redis 는 Lua 를 온전히 못 돌린다" 는 이 claim 의 *유추*이지 인용이 아니다. D9 자체는 `#REDIS-EVAL-C1`+`#TC-OFFICIAL-C3` 로 닫힌다 |
|
||||
|
||||
**추가 수집 완료** (`busy-reply-threshold`/`BUSY`/`SHUTDOWN NOSAVE` bounded-execution 세부 설정값): `[[raw/official-docs/redis-programmability-max-execution-time]]` (`develop/programmability/` §Maximum execution time)로 닫혔다 — `REDIS-PROG-C1`~`C4`. **D4 의 Supporting Claims·Open Risk 와 §구현 가이드 3 의 (3)층 표, §엣지·실패·의존에 이미 반영 완료**(2026-07-28 동일 `/branch-spec` 실행). 구 파라미터명 `lua-time-limit` 과 `maxmemory` 상호작용은 이 자료에도 없어 미확인으로 남으며, 후자는 §Claims To Verify 에 등록했다.
|
||||
|
||||
**미수집 — 결정에 사용하지 않음**: LINE engineering 의 "Lua 채택 + EVALSHA 의도적 거부" 사례(§Audit `EVALSHA_COUNTEREXAMPLE`). D2 의 반례 후보이나 raw archive 전이므로 인용하지 않았다.
|
||||
|
||||
**프로젝트 내부 설계 참조 (등급 `internal-design-doc` — 공식 문서 아님, best practice 로 격상 금지):**
|
||||
|
||||
- ca-tmpl `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md`
|
||||
- llm-wiki `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md`
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [x] `/branch-spec` 로 D-row(D1~D11)·§구현 가이드(9절) 작성 — 등급: `documented-only`
|
||||
- [x] `.lua` 카탈로그 + descriptor + manifest 대조 test (D1) — 등급: `actually-implemented` (`RedisProgramCatalog`·`RedisProgramDescriptor`·`program-set.json`)
|
||||
- [x] EVALSHA + NOSCRIPT EVAL 폴백 (D2) — 등급: `actually-implemented` (`RedisLuaProgramExecutor` + test 2건)
|
||||
- [x] 런타임 admission — arity·바이트 상한·카탈로그 소유권 (D4 (i)층) — 등급: `actually-implemented` (`RedisLuaProgramExecutor.validate()`)
|
||||
- [x] typed facade 2층 구조 (D8) — 등급: `actually-implemented` (`RedisAtomicPrimitives` + `RedisProgramExecutor` seam)
|
||||
- [ ] **§1·§2·§6·§7 을 as-built 로 정정** — 초판 Spring Data Redis 전제 잔존 (§구현 가이드 preamble 표 참조) — 등급: `planned`
|
||||
- [ ] **`2026-07-26-redis-production-capability-design.md`(6,700줄) 정독** 후 `UNSUPPORTED_IMPL_DECISION` 라벨 재판정 — 등급: `planned`
|
||||
- [ ] **unsafe recipe 경합 재현 test** — WI-062 완료 조건 중 유일한 미구현분 (D9) — 등급: `planned`
|
||||
- [ ] bounded execution 빌드타임 정적 검사 + denylist 시드 (D4 (ii)층) — 등급: `planned`
|
||||
- [ ] unsafe recipe ↔ 대체 쌍 카탈로그 2행 등재 (D7) — 등급: `planned`
|
||||
- [ ] program 실패 error code + metric·span registry 등재 제안 (D10·D11) — 등급: `planned`
|
||||
- [ ] **선행 확인**: 배포 이미지의 실제 `redis_version` ≥ 7.2 — 등급: `needs-confirmation`
|
||||
- [ ] **harness 정정 요청 전달**: `/branch-spec` 명령 문서의 ca-tmpl ground truth 경로 (§Audit `STALE_CLONE_ROOT_CAUSE`) — 등급: `needs-confirmation`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- 2026-07-28 — **본 branch 는 greenfield 가 아니다.** foundation 이 이미 구현돼 있다(`RedisProgramCatalog`·`RedisProgramDescriptor`·`RedisProgramExecutor`·`RedisLuaProgramExecutor`·`RedisAtomicPrimitives` + `.lua` 3개 + `program-set.json` + test 7개). 착수는 "신규 작성" 이 아니라 **"as-built 확장"** 으로 계획할 것.
|
||||
- ⚠️ **초판의 3중 오판을 정정했다** — "코드 없음 / Redis 버전 미고정 / 설계문서 부재" 는 전부 낡은 clone(`workspace/ca-tmpl`)을 조회한 결과였다. 실제 ground truth 는 `desktop-server-git/clean-architecture-backend-template/` 다(§Audit `STALE_CLONE_ROOT_CAUSE`).
|
||||
- SDK 는 `io.lettuce:lettuce-core` 단독이며 `spring-data-redis` 는 없다. Redis 최소 버전은 `program-set.json` 의 `minimumRedisVersion: "7.2"` 로 고정돼 있다.
|
||||
- **다음 세션 우선 작업**: `docs/superpowers/specs/2026-07-26-redis-production-capability-design.md`(6,700줄) 정독. 이 문서가 golden vector 형식·BUSY 관측·Functions 호환·버전 pin 을 직접 다루므로, 현재 `UNSUPPORTED_IMPL_DECISION` 으로 남긴 항목 상당수가 근거를 얻을 가능성이 높다.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-07-28: **atomic program 본문은 `.lua` classpath resource, 메타데이터는 별도 descriptor** (D1) / 이유: Spring 공식 예시가 `ResourceScriptSource`+`ClassPathResource` 를 표준 패턴으로 제시하고, SHA1 이 본문에서 자동 도출돼 "변경 시 버전 변경" 을 별도 규약 없이 만족 / 검토한 대안: Java 인라인 문자열 상수(LINE 프로덕션 선례 있음, 다만 미archive), Redis Functions library 파일, 빌드타임 코드생성 / 근거: [[raw/official-docs/spring-data-redis-scripting-execution]]
|
||||
- 2026-07-28: **EVALSHA 우선 + `NOSCRIPT` 시에만 EVAL 1회 폴백을 Lettuce 위에 직접 구현** (D2, 등급 `actually-implemented`) / 이유: as-built 가 이미 이 형태이고 `spring-data-redis` 의존성이 없다 / 검토한 대안: Spring Data Redis `ScriptExecutor` 위임 — SDK 를 새로 들여야 하고 #061 이 raw Lettuce 로 기울어 채택하지 않음 / 위험: 공식 절차(`SCRIPT LOAD`+재시도)와 경로가 달라 캐시 재적재를 코드가 관측하지 않음 — §Claims To Verify 등록 / 근거: [[raw/official-docs/redis-eval-scripting-atomicity]] + as-built `RedisLuaProgramExecutor`
|
||||
- 2026-07-28: **bounded execution 은 예방(정적)과 사후 대응(서버 설정)을 분리해 둘 다 채택** (D4) / 이유: 쓰기를 수행한 program 은 `SCRIPT KILL` 로 죽일 수 없어(공식) 예방이 필수이고, 데이터 규모에서만 드러나는 초과는 정적 검사로 원천 재현 불가라 사후 층이 필수 / 검토한 대안: 서버 설정만, 리뷰 체크리스트만 / 근거: [[raw/official-docs/redis-eval-scripting-atomicity]]
|
||||
- 2026-07-28: **배포 모드 기본은 EVAL script, Functions 는 3조건 opt-in** (D6) / 이유: Functions 는 Redis 7.0+ 필요한데 대상 버전이 확정돼 있지 않다(§Audit `NO_VERSION_PIN`) / 검토한 대안: Functions 를 SSOT 로 채택 — SHA 기반 버전 식별이 성립하지 않아 보류 / 근거: [[raw/official-docs/redis-functions-library-deployment]]
|
||||
- 2026-07-28: **unsafe 쌍은 분기 유무로 분리 등재** (D7) / 이유: 공식이 분기 없는 recipe 에는 `MULTI`/`EXEC`, 분기 있는 recipe 에는 Lua 를 **서로 다르게** 권고한다. 한 항목으로 뭉치면 이 구분이 사라진다 / 근거: [[raw/official-docs/redis-incr-rate-limiter-pattern]]
|
||||
- 2026-07-28: **경합 증명을 focused / integration 2층으로 분리** (D9) / 이유: race 는 클라이언트 측 순서 문제라 fake+barrier 로 결정론적 재현이 가능하지만, 원자성은 서버가 보장하는 성질이라 실제 Redis 없이는 증명 불가 / 제약: focused 레벨 Testcontainers 금지는 [[raw/branch-notes/feature-test-taxonomy-fixture-contract]] 가 ArchUnit 으로 이미 강제 / 근거: [[raw/official-docs/redis-eval-scripting-atomicity]] · [[raw/official-docs/test-taxonomy-testcontainers-official]]
|
||||
|
||||
<!-- section-id: decision-evidence -->
|
||||
## Decision Evidence Map / 결정-근거 매핑
|
||||
|
||||
> 2026-07-28 `/branch-spec` 조사(Redis 공식 6건 + Spring Data Redis 1건 + 기존 Testcontainers raw 재사용)로 채웠다.
|
||||
> **본 branch 는 greenfield 가 아니다 — foundation 이 이미 구현돼 있다.** 실제 ground truth 는 `/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/` 이며(§Audit `GROUND_TRUTH_PATH_DRIFT`), `adapter/outbound/cache-redis` 에 `RedisProgramCatalog`·`RedisProgramDescriptor`·`RedisProgramExecutor`·`RedisLuaProgramExecutor`·`RedisProgramId` + `.lua` 3개 + `program-set.json` manifest + 전용 test 7개가 존재한다(등급 `actually-implemented`).
|
||||
> 따라서 아래 D-row 는 **as-built 를 서술**하거나(등급 표기) **as-built 를 확장**하는 결정이다. 초기 작성 시 `planned` 로 적었던 것을 코드 대조 후 정정했다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | atomic program 본문의 SSOT 는 `.lua` classpath resource, 식별·서명은 **닫힌 enum 카탈로그**(`RedisProgramId` → `RedisProgramDescriptor`)가 소유하고, 외부 노출은 `program-set.json` manifest 가 미러링한다 | 항상. D6 이 Functions 모드로 뒤집히면 SSOT 가 library 파일(`#!lua name=…`)로 이동한다 — `redis-functions-library-deployment.md#REDIS-FN-C3` | `raw/official-docs/spring-data-redis-scripting-execution.md#SDR-SCRIPT-C6`(script 본문을 classpath resource 로 두는 패턴) + **as-built**: `RedisProgramId`(3 program, `<name>-v1` + `redis/scripts/*.lua`), `RedisProgramCatalog.foundation()`, `redis/program-set.json` | `official-vendor-doc` + `actually-implemented` | as-built 는 **버전을 id 문자열(`-v1`)에 넣고 SHA-256 을 source digest 로 따로 보관**한다 — 내가 처음 적은 "SHA1 이 곧 버전" 은 as-built 와 다르다(SHA-1 은 EVALSHA 전송용으로만 쓰임). manifest 와 카탈로그의 drift 는 unit test 가 잡지만, `.lua` 와 descriptor 의 **arity** drift 는 실행 시점에만 드러난다 |
|
||||
| D2 | EVALSHA 우선 + `NOSCRIPT` 를 받은 **그때만** EVAL 로 1회 폴백. Spring Data Redis 에 위임하지 않고 Lettuce 위에 직접 구현한다 | 항상 — as-built 가 이미 이 형태다. Spring Data Redis 를 도입하면 `ScriptExecutor` 위임으로 대체 가능하나, #061 이 raw Lettuce 유지로 기운 상태라 전환 근거가 없다 | `raw/official-docs/redis-eval-scripting-atomicity.md#REDIS-EVAL-C3`(NOSCRIPT 복구 절차) + **as-built**: `RedisLuaProgramExecutor.execute()` 가 `commands.evalSha(...)` → `catch RedisNoScriptException` → `commands.eval(...)`. test `fallsBackToEvalOnlyWhenEvalShaReportsNoScript`·`doesNotEvalAgainWhenCachedScriptExecutes` | `official-vendor-doc` + `actually-implemented` | as-built 는 `SCRIPT LOAD` 를 쓰지 않고 **EVAL 직접 실행**으로 복구한다. `#REDIS-EVAL-C3` 이 서술한 "`SCRIPT LOAD` 후 EVALSHA 재시도" 와 다른 경로이며, 결과적으로 캐시 재적재 여부를 코드가 관측하지 않는다(EVAL 이 부수적으로 캐시에 넣음). 기능상 동등하나 공식이 명시한 절차는 아님 |
|
||||
| D3 | pipeline 경로에서는 EVALSHA 를 쓰지 않고 plain EVAL 로 되돌린다 | program 을 pipeline 안에서 실행할 때. 단건 실행이면 D2 의 EVALSHA 경로 | `raw/official-docs/redis-eval-scripting-atomicity.md#REDIS-EVAL-C4`(pipeline 안에서는 NOSCRIPT 가 반환돼도 handle 할 수 없으므로 plain EVAL 로 revert 해야 함) | `official-vendor-doc` | 근거의 구속력이 `should`(권고)이지 `MUST` 가 아니다(`#REDIS-EVAL-C4` 의 does-not-prove). 또 Spring 의 `ScriptExecutor` 가 pipeline 안에서 이 revert 를 자동으로 하는지는 `#SDR-SCRIPT-C1` 이 말하지 않는다 |
|
||||
| D4 | bounded execution 은 (i) descriptor 선언 + **런타임 admission**(as-built) + (ii) 빌드타임 정적 검사(신규)를 1차 강제로, (iii) 서버 실행시간 설정 + kill 런북을 상시 병행 안전망으로 **분리** | (i)은 arity·바이트 상한·카탈로그 소유권처럼 **호출 시점에 판정 가능한** 위반에 적용(as-built 가 이미 강제). (ii)는 `.lua` 텍스트의 정적 위반(비유한 명령 토큰·무한 루프 리터럴)에 적용 — **as-built 에 없음**. 운영 **데이터 규모**에서만 드러나는 초과는 (i)(ii)로 원천 재현 불가하므로 (iii)이 담당 — (iii)은 선택이 아니라 필수 병행 | `raw/official-docs/redis-eval-scripting-atomicity.md#REDIS-EVAL-C1`(실행 중 모든 서버 활동 블로킹), `#REDIS-EVAL-C6`(`SCRIPT KILL` 은 dataset 을 수정하지 **않은** 스크립트에만 가능), `raw/official-docs/redis-functions-library-deployment.md#REDIS-FN-C4`(Functions 도 동일 블로킹), `raw/official-docs/redis-programmability-max-execution-time.md#REDIS-PROG-C1`~`#REDIS-PROG-C4`(기본 5초 `busy-reply-threshold` · 자동 종료 안 함 · `BUSY` 허용 명령 · 쓰기 후엔 `SHUTDOWN NOSAVE` 뿐) | `official-vendor-doc` ×3 | **쓰기를 수행한 program 의 유일한 종료 수단이 `SHUTDOWN NOSAVE`(인스턴스 강제 정지)** 라는 것이 강제가 필요한 진짜 이유다(`#REDIS-PROG-C4`). 남은 미확인: 구 파라미터명 `lua-time-limit` 과의 관계, `maxmemory` 근접 시 script abort 동작 — 두 자료 모두에 없다 |
|
||||
| D5 | 모든 key 는 `KEYS` 입력 인자로 선언. 프로그램 생성 key·데이터 내용 기반 key 접근을 program 작성 규칙으로 금지 | 항상 — standalone·cluster 무관. cluster 에서는 추가로 같은 slot 으로 해싱되어야 multi-key 연산이 성립한다 | `raw/official-docs/redis-eval-scripting-atomicity.md#REDIS-EVAL-C5`, `raw/official-docs/redis-lua-api-determinism-replication.md#REDIS-LUAAPI-C5` (양쪽 모두 "should only access keys given as input arguments" / "should never access programmatically-generated names") | `official-vendor-doc` ×2 (독립 페이지 2곳) | 미선언 key 를 실제로 접근했을 때의 **거부 동작·에러 코드가 공식에 없다**(`#REDIS-EVAL-C5` does-not-prove) — 즉 Redis 가 막아주지 않으므로 우리가 강제하지 않으면 조용히 통과한다. hash slot 라우팅 자체는 #061 소유 |
|
||||
| D6 | 배포 모드 기본값은 **EVAL script**. Redis Functions 는 ① 대상 Redis 7.0+ 확정 ② NOSCRIPT 재적재 부담 제거가 실제 문제로 관측 ③ cluster 전 노드 수동 적재 운영을 수용 — 3조건 동시 충족 시에만 opt-in | 위 3조건 중 하나라도 미충족이면 EVAL script. 특히 **대상 Redis 버전이 확정되지 않은 현재 상태에서는 Functions 를 고를 수 없다** | `raw/official-docs/redis-functions-library-deployment.md#REDIS-FN-C1`(7.0+ 필요), `#REDIS-FN-C2`(Functions 는 AOF 영속+복제 / script 는 휘발성 캐시), `#REDIS-FN-C6`(cluster 전 노드 적재는 자동화되지 않음), `raw/official-docs/redis-eval-scripting-atomicity.md#REDIS-EVAL-C2`(캐시는 재시작·failover·`SCRIPT FLUSH` 시 소실) | `official-vendor-doc` | ca-tmpl 어디에도 **Redis 버전이 고정돼 있지 않다** — compose 에 redis 서비스 없음, SDK 의존성 없음(§Audit `NO_VERSION_PIN`). 조건 ①을 지금 판정할 수 없다. `#REDIS-FN-C2` 의 durability 가 `appendonly no` 배포에서도 성립하는지도 미서술 |
|
||||
| D7 | unsafe recipe ↔ 안전한 대체 쌍은 **읽은 값 기반 분기 유무**로 갈라 등재한다 | 분기 없음(고정 키 카운터 등) → `MULTI`/`EXEC` 로 충분하며 **program 을 만들지 않는다**. 분기 있음(`IF value == 1` 류) → `MULTI`/`EXEC` 로 불충분하므로 atomic program 필수 | `raw/official-docs/redis-incr-rate-limiter-pattern.md#REDIS-INCR-C4`(공식이 두 변형에 서로 다른 대체를 권고), `#REDIS-INCR-C2`(rate limiter 2 에 race condition 존재를 공식이 명시), `#REDIS-INCR-C3`(실패 모드 = `EXPIRE` 유실 → key leak) | `official-vendor-doc` | 이 분기 기준을 잃고 "INCR+EXPIRE" 를 한 항목으로 뭉치면 `#REDIS-INCR-C4` 의 구분이 사라진다. 또 공식은 key leak 의 **정량적 심각도**를 말하지 않는다(정성 서술만) |
|
||||
| D8 | typed facade 는 semantics-free — program 식별자·KEYS·ARGV·상태 문자열만 다루고 능력 의미를 알지 않는다. descriptor·catalog·executor 는 **package-private** 로 유지해 adapter 밖으로 새지 않는다 | 항상. 능력 의미(rate limit 판정·lock fencing·idempotency claim 결과)가 facade 에 새어들면 소비 branch 의 owner 경계를 침범한다 | **as-built**: `RedisProgramExecutor` javadoc — "Implementations may use Functions or EVALSHA, but application code must only depend on semantic ports and typed facades". 전 program 타입이 package-private. 반환은 `descriptor.statuses()` 에 속한 ASCII 상태 문자열 | `actually-implemented` | as-built 반환 타입은 `#SDR-SCRIPT-C4` 의 4종이 아니라 **상태 문자열 단일 형태**다(≤128 byte). 값을 함께 돌려줘야 하는 program(예: 남은 quota)이 생기면 이 형태로 부족하고, 그 확장 형식은 근거가 없다 |
|
||||
| D9 | 경합 증명을 2층으로 분리 — unsafe recipe 의 race 는 fake + 결정론적 인터리빙으로 focused test, atomic 대체의 원자성은 **실제 Redis** 통합 test | 원자성 주장은 서버가 보장하는 성질이므로 fake 로 증명 불가 → 실제 Redis 필수. 반대로 unsafe recipe 의 인터리빙은 클라이언트 측 순서 문제이므로 fake 로 결정론적 재현이 가능하고, 그래야 focused test 의 "실제 네트워크 금지" 규칙을 지킨다 | `raw/official-docs/redis-eval-scripting-atomicity.md#REDIS-EVAL-C1`(원자성은 서버 보장), `raw/official-docs/test-taxonomy-testcontainers-official.md#TC-OFFICIAL-C3`(mock/in-memory 없이 production 과 동일 type 서비스로 test), `#TC-OFFICIAL-C5`(in-memory 는 production service 의 기능을 다 갖지 않음) | `official-vendor-doc` + `official-reference` | **WI-062 완료 조건의 미충족분이 정확히 여기다.** as-built 는 NOSCRIPT 재적재 test 와 golden-vector 성격 test 를 이미 갖췄지만(`fallsBackToEvalOnlyWhenEvalShaReportsNoScript`·`loadsEveryFoundationProgramWithAnExactDigestAndBoundedSignature`), **unsafe recipe 경합 재현 test 는 모듈에 존재하지 않는다**(`CountDownLatch`/`Thread` grep 0건). 반복 횟수·스레드 수의 공식 근거도 없다 |
|
||||
| D11 | program 실행에 bounded cardinality metric 1개(`redis.program.executions.total`, tag = `program_id`+`outcome`)와 실행당 trace span 1개를 짝지어 붙이고, key·ARGV 는 tag/attribute 로 넣지 않는다 | 항상. `program_id` 를 tag 로 쓰는 것은 **카탈로그가 닫힌 enum 이라 cardinality 가 구조적으로 유한할 때만** 유효하다 — 런타임 등록으로 카탈로그가 열리면 이 tag 를 빼야 한다 | [[raw/branch-notes/feature-metrics-alerting-contract]] `D2`(Micrometer dot.case 명명)·`D8`(high-cardinality tag 금지 + bounded whitelist, Prometheus·Micrometer 공식 근거 보유) + `metrics.yaml` 의 `lock.acquisition` 선례("key 는 tag 로 넣지 않는다") + **as-built** 닫힌 `EnumMap` 카탈로그 | `project-registry-contract` (owner 는 metrics-alerting) + `actually-implemented`(카탈로그 유한성) | metric 이름·`outcome` 어휘·cardinality 상한은 공식 근거가 없다(§구현 가이드 9 에서 `UNSUPPORTED_IMPL_DECISION` 라벨). `metrics.yaml` 에 script/program row 가 **0건**이므로 전부 신규 제안이며 registry-governance 절차를 거쳐야 한다. **`BUSY` 상태는 서버 측 현상이라 이 클라이언트 metric 으로 직접 관측되지 않는다** — D4 (iii)층의 신호는 별도다 |
|
||||
| D10 | program 실행 실패를 backend 실패와 program 결함으로 등급 분리. 결함은 해당 연산을 fail closed 하고 **miss 로 위장하지 않는다** | backend 연결·timeout 실패 → 기존 `CACHE_UNAVAILABLE`(`TRANSIENT_DEPENDENCY`) 선례를 따름. program 결함(Lua 런타임 오류·반환 계약 위반) → fail closed. 이 경계는 hub §11 매트릭스의 "cache codec·프로그래밍 결함은 fail closed, miss 로 위장 금지" 와 동일 규칙 | `raw/official-docs/redis-lua-api-determinism-replication.md#REDIS-LUAAPI-C1`(`redis.call` 은 예외를 그대로 올려 스크립트 중단 / `redis.pcall` 은 절대 throw 하지 않고 error 테이블 반환), `#REDIS-LUAAPI-C2`(`redis.error_reply`/`redis.status_reply` 가 typed 반환의 공식 수단) | `official-vendor-doc` (등급 분리 규칙 자체는 hub §11 이 owner) | **신규 error code 가 필요하다** — `docs/registries/error-codes.yaml` 에 script/program 관련 code 가 하나도 없다(grep 확인). 신규 code 는 registry-governance 절차를 거쳐야 하고 owner 는 본 branch 다. `Category` enum 10종 밖의 값은 만들 수 없다(`shared/error/Category.java` 확인) |
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
> 2026-07-28 `/branch-spec` 조사로 in-scope detail 을 채웠다. 근거가 *원칙*만 지지하고 *detail* 은 지지하지 않는 cell 은 `UNSUPPORTED_IMPL_DECISION` + trade-off 한 줄로 표시했다(CLAUDE.md §15.5 R2). 다른 branch 결정 영역은 남기지 않았다(R3).
|
||||
>
|
||||
> ⚠️ **부분 재작성 상태 (다음 세션 최우선)** — §3·§8·§9 는 as-built 로 재작성했으나 **§1·§2·§6·§7 은 아직 초판의 Spring Data Redis 전제가 남아 있다.** 아래 as-built 사실과 충돌하는 서술이 그 4개 절에 있으므로, 착수 전 반드시 정정해야 한다:
|
||||
>
|
||||
> | as-built 사실 | 근거 | 충돌하는 절 |
|
||||
> |---|---|---|
|
||||
> | 리소스 경로는 `redis/scripts/*.lua` (`META-INF/scripts/` 아님) | `RedisProgramId` | §1 |
|
||||
> | 로딩은 `ClassLoader.getResourceAsStream` + `RedisProgramCatalog.foundation()` (`ResourceScriptSource`/`DefaultRedisScript` 아님) | `RedisProgramCatalog` | §1·§2 |
|
||||
> | descriptor 실제 필드 8개 = `id`·`sha256`·`scriptBytes`·`keyCount`·`argumentCount`·`maximumKeyBytes`·`maximumArgumentBytes`·`statuses` | `RedisProgramDescriptor` | §1 |
|
||||
> | manifest 에 `complexity: "O(1)"`·`timeoutCertainty` 가 이미 있다 — §3 (1)층의 `writes` 신설 전에 이 필드 재사용을 먼저 검토 | `program-set.json` | §3 |
|
||||
> | 실제 Redis lane 은 **Testcontainers 가 아니다** — `@Tag("redis-service")` + `./gradlew :adapter:outbound:cache-redis:redisServiceTest` + `-Dredis.test.host`/`-Dredis.test.port`. 모듈에 Testcontainers 참조 0건 | `build.gradle`, `LettuceRedisRuntimeServiceTest` | §6 |
|
||||
> | typed facade 가 이미 있다 — `RedisAtomicPrimitives` 의 `compareAndDelete`/`compareAndExpire`/`setIfAbsentWithTtl`, 반환은 program 별 typed enum. 즉 **2층 구조**(executor seam = 상태 문자열 / facade = typed enum) | `RedisAtomicPrimitives` | §7·D8 |
|
||||
>
|
||||
> 모듈 좌표(`actually-implemented`): id `adapter-outbound-cache-redis`, Gradle `:adapter:outbound:cache-redis`, package root `dev.caskeleton.adapter.outbound.cache`. **이 저장소는 harness-free 라 `.harness/project/modules.yaml` 이 없다** — 초판의 그 인용은 낡은 clone 기준이었다.
|
||||
|
||||
### 1. program 카탈로그와 `ScriptDescriptor`
|
||||
|
||||
> **Trace**: D1 ← `spring-data-redis-scripting-execution.md#SDR-SCRIPT-C6`(classpath resource 표준 패턴)·`#SDR-SCRIPT-C5`(단일 인스턴스로 SHA1 재계산 회피)
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: descriptor 를 **YAML registry 로 둘지 Java enum 으로 둘지**. `#SDR-SCRIPT-C6` 은 script *본문*을 classpath resource 로 두라고만 말하고 메타데이터의 소재를 말하지 않는다. trade-off: 이 repo 는 계약 값을 `docs/registries/*.yaml` 로 관리하는 관행이 이미 있어(error-codes·env-keys·metrics) YAML 을 택하면 검증 스타일을 재사용할 수 있다. 대신 `.lua` 파일과 YAML 행이 물리적으로 분리돼 drift 가 생기므로 §3 의 정적 검사가 두 쪽을 대조해야 한다.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: program id 명명 규칙. 공식 근거 없음. trade-off: 카탈로그가 능력 의미를 알면 안 되므로(D8) `rate-limit-token-bucket` 처럼 능력 이름을 쓰지 않고 `incr-with-ttl-if-absent` 처럼 **연산을 서술하는** 중립 이름을 쓴다.
|
||||
|
||||
| 항목 | planned 명세 | 근거 |
|
||||
|---|---|---|
|
||||
| script 본문 | `.lua` 파일 1개 = program 1개. `META-INF/scripts/` 아래 classpath resource | `#SDR-SCRIPT-C6` |
|
||||
| 로딩 | `ResourceScriptSource(new ClassPathResource(...))` → `RedisScript.of(source, <resultType>)` | `#SDR-SCRIPT-C6` |
|
||||
| 인스턴스 수명 | program 당 `DefaultRedisScript` 를 **컨텍스트에 단일 인스턴스**로 등록 — 매 실행 SHA1 재계산 금지 | `#SDR-SCRIPT-C5` |
|
||||
| descriptor 필수 필드 | `programId` · `resourcePath` · `keyCount` · `argSchema` · `resultType` · `writes(boolean)` · `ownerBranch` | `UNSUPPORTED_IMPL_DECISION` (필드 집합은 공식 근거 없음). `resultType` 은 `#SDR-SCRIPT-C4` 의 4종으로 제한, `writes` 는 §3 이 요구 |
|
||||
| 버전 식별 (**3축 분리**) | ① **계약 version** = program id 의 `-v1` 접미사 — 호환 불가 변경 시 신규 id 로 올린다(소비자가 pin 하는 축). ② **source digest** = SHA-256, manifest 와 카탈로그 대조용. ③ **실행 식별자** = SHA-1, EVALSHA 전송용으로만 쓰이며 계약 의미 없음 | **as-built** `RedisProgramId`(`compare-and-delete-v1` 등) · `RedisProgramDescriptor.sha256()` · `RedisLuaProgramExecutor.sha1()`. `#SDR-SCRIPT-C5` 는 SHA1 재계산 회피만 말하고 **버저닝 전략은 다루지 않는다**(그 문서의 does-not-prove) |
|
||||
| golden vector | program 당 `(KEYS, ARGV) → 기대 반환` 고정 벡터 ≥1. descriptor 의 `keyCount`·`resultType` 과 대조 | `UNSUPPORTED_IMPL_DECISION` (형식은 공식 근거 없음). trade-off: 벡터가 없으면 script 수정이 조용히 반환 계약을 깬다 |
|
||||
|
||||
### 2. 실행 경로 — 위임과 pipeline 예외
|
||||
|
||||
> **Trace**: D2 ← `#SDR-SCRIPT-C1`(evalsha 시도 → 미적재 시 eval 폴백)·`redis-eval-scripting-atomicity.md#REDIS-EVAL-C3`(NOSCRIPT 복구) / D3 ← `#REDIS-EVAL-C4`(pipeline 안에서는 NOSCRIPT 를 handle 못 함)
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 재적재 로직을 **직접 짜지 않는다**는 선택. 두 근거가 서로 다른 복구 경로를 말한다(Redis = `SCRIPT LOAD`+재시도 / Spring = `eval` 폴백). trade-off: 직접 구현하면 두 경로를 우리가 책임져야 하고, 위임하면 프레임워크 동작에 종속되지만 재적재 버그를 우리가 만들지 않는다. **어느 쪽이든 NOSCRIPT 재적재 test 는 실제 Redis 에서 `SCRIPT FLUSH` 후 재실행으로 증명해야 한다**(§6).
|
||||
|
||||
| 경로 | planned 동작 | 근거 |
|
||||
|---|---|---|
|
||||
| 단건 실행 | `execute(RedisScript, List<K> keys, Object... args)` — EVALSHA 우선은 프레임워크가 처리 | `#SDR-SCRIPT-C1`, `#SDR-SCRIPT-C3` |
|
||||
| 캐시 소실 | 재시작·failover·`SCRIPT FLUSH` 로 언제든 발생 가능. 애플리케이션이 복구 책임을 진다 | `#REDIS-EVAL-C2`, `#REDIS-EVAL-C3` |
|
||||
| pipeline | **EVALSHA 금지** — plain EVAL 로 되돌린다. pipeline 안에서는 NOSCRIPT 가 와도 처리할 수 없다 | `#REDIS-EVAL-C4` |
|
||||
| 반환 타입 | `Long`·`Boolean`·`List`·역직렬화 값 중 하나. `OK` 류 상태 반환은 `null` | `#SDR-SCRIPT-C4` |
|
||||
|
||||
### 3. bounded execution — 강제 3층
|
||||
|
||||
> **Trace**: D4 ← `#REDIS-EVAL-C1`(실행 중 서버 전면 블로킹)·`#REDIS-EVAL-C6`(`SCRIPT KILL` 은 쓰기 안 한 스크립트만) / D5 ← `#REDIS-EVAL-C5`·`redis-lua-api-determinism-replication.md#REDIS-LUAAPI-C5`(KEYS 선언) / sandbox ← `#REDIS-LUAAPI-C6`(전역 선언 차단)·`#REDIS-LUAAPI-C7`(`require` 비활성)
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 정적 검사를 **ArchUnit 이 아니라 일반 JUnit test 로** 구현. ArchUnit 의 검사 대상은 JVM 바이트코드이므로 `.lua` 텍스트는 그 DSL 대상이 아니다. trade-off: 같은 test 스위트 안에 두어 실행 위치는 통일하되, 규칙은 텍스트 검사로 짠다.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 금지 토큰 목록(denylist)의 구체 항목과 파서 수준(정규식 vs Lua lexer). 공식 문서는 원칙만 말한다. trade-off: 정규식은 문자열 조립(`'S'..'MEMBERS'`)으로 우회되므로 **완전한 강제가 아님을 문서에 명시**하고, 우회는 리뷰가 맡는다.
|
||||
|
||||
| 층 | 무엇을 잡는가 | 무엇을 못 잡는가 | 근거 |
|
||||
|---|---|---|---|
|
||||
| (1) descriptor 선언 | `keyCount`·`writes` 를 program 이 스스로 선언 — 선언 없이는 카탈로그 등재 불가 | 선언 자체가 거짓인 경우 | D1 |
|
||||
| (2) 빌드타임 정적 검사 | 하드코딩 key 리터럴, 비유한 명령 토큰, 무한 루프 리터럴, `KEYS[]` 미사용 | 문자열 조립 우회, **데이터 규모에 비례하는 사실상 무한** | `#REDIS-EVAL-C5`, `#REDIS-LUAAPI-C5` |
|
||||
| (3) 서버 설정 + kill 런북 | 운영 데이터 규모에서만 드러나는 실행 시간 초과 | 근본 원인(미선언 key·비유한 명령) 자체 — 예방이 아니라 사후 대응 | `#REDIS-EVAL-C6`, `redis-programmability-max-execution-time.md#REDIS-PROG-C1`~`#REDIS-PROG-C4` |
|
||||
|
||||
(3)층의 확정된 값 (전부 `official-vendor-doc`):
|
||||
|
||||
| 항목 | 값 | 근거 |
|
||||
|---|---|---|
|
||||
| 최대 실행 시간 기본값 | **5초**, 설정명 `busy-reply-threshold` (밀리초 단위, `redis.conf` 또는 `CONFIG SET`) | `#REDIS-PROG-C1` |
|
||||
| 임계 초과 시 | Redis 가 **자동 종료하지 않는다** — 종료하면 원자성 계약이 깨지므로 | `#REDIS-PROG-C2` |
|
||||
| 초과 후 서버 상태 | 다른 클라이언트에 `BUSY` 응답. 허용 명령은 `SCRIPT KILL` · `FUNCTION KILL` · `SHUTDOWN NOSAVE` 뿐 | `#REDIS-PROG-C3` |
|
||||
| 쓰기를 이미 수행한 경우 | **`SHUTDOWN NOSAVE` 만 가능** — 데이터셋을 디스크에 저장하지 않고 서버를 정지시킨다(사실상 강제 종료) | `#REDIS-PROG-C4`, `#REDIS-EVAL-C6` |
|
||||
|
||||
**(3)이 선택이 아니라 필수 병행인 이유**: 쓰기를 수행한 program 이 폭주하면 (1)(2)를 통과했더라도 죽일 수단이 `SHUTDOWN NOSAVE` — **인스턴스 강제 정지** 뿐이다(`#REDIS-PROG-C4`). 즉 bounded execution 위반의 최종 비용이 가용성 사고이므로 예방 층을 생략할 수 없다. 런북에 이 3단계(BUSY 관측 → 쓰기 여부 판별 → kill 또는 SHUTDOWN NOSAVE)를 명시한다.
|
||||
|
||||
program 작성 규칙 (카탈로그 등재 조건):
|
||||
|
||||
| 규칙 | 내용 | 근거 |
|
||||
|---|---|---|
|
||||
| KEYS 선언 | 접근하는 모든 key 를 `KEYS` 인자로 받는다. 프로그램 생성 key·데이터 내용 기반 key 접근 금지 | `#REDIS-EVAL-C5`, `#REDIS-LUAAPI-C5` |
|
||||
| 전역 금지 | 모든 선언은 `local`. 전역 변수·함수 선언은 샌드박스가 차단 | `#REDIS-LUAAPI-C6` |
|
||||
| 모듈 금지 | `require` 비활성 — 허용된 런타임 라이브러리만 사용 | `#REDIS-LUAAPI-C7` |
|
||||
| 복제 | effects replication 이 Redis 7.0 기준 유일 모드이며 정의상 결정론적이다. `redis.replicate_commands()` 를 호출하지 않는다 | `#REDIS-EVAL-C7`, `#REDIS-LUAAPI-C3`·`#REDIS-LUAAPI-C4` |
|
||||
| **오류 처리** | `redis.call` 을 쓰되 **오류가 날 상황을 미리 방어**한다 — 진입부에서 `#KEYS`/`#ARGV`/길이를 검사해 `INVALID`, `TYPE` 을 확인해 `WRONG_TYPE` 을 **상태 문자열로 반환**한다. `redis.pcall` 과 `redis.error_reply` 는 as-built 가 쓰지 않는다 | `#REDIS-LUAAPI-C1`(`call` 은 오류를 그대로 올려 스크립트 중단) + **as-built** `.lua` 3개 전부 이 패턴 |
|
||||
|
||||
**as-built 가 `pcall`/`error_reply` 대신 방어적 상태 반환을 택한 결과**: 예상 가능한 실패(잘못된 arity·타입 불일치·소유자 불일치)는 **오류가 아니라 계약된 상태값**으로 나오고, `redis.call` 이 실제로 오류를 올리는 경우는 *예상 못 한* 결함뿐이다. 즉 상태값 = 정상 흐름, 예외 = 결함이라는 경계가 script 안에서 이미 갈린다.
|
||||
|
||||
### 4. 배포 모드 — EVAL script 기본, Functions opt-in 게이트
|
||||
|
||||
> **Trace**: D6 ← `redis-functions-library-deployment.md#REDIS-FN-C1`(7.0+)·`#REDIS-FN-C2`(영속·복제 vs 휘발성 캐시)·`#REDIS-FN-C6`(cluster 전 노드 수동 적재)·`#REDIS-FN-C4`(Functions 도 동일 블로킹) / `#REDIS-EVAL-C2`(캐시 휘발)
|
||||
|
||||
| opt-in 조건 | 판정 | 근거 |
|
||||
|---|---|---|
|
||||
| ① 대상 Redis 7.0+ 확정 | **충족** — `program-set.json` 이 `minimumRedisVersion: "7.2"` 를 선언한다 | `#REDIS-FN-C1` + as-built manifest |
|
||||
| ② NOSCRIPT 재적재 부담이 실제 문제로 관측됨 | 미관측 (구현 전) | `#REDIS-FN-C2`, `#REDIS-EVAL-C2` |
|
||||
| ③ cluster 전 노드 수동 적재 운영을 수용 | 미결정 | `#REDIS-FN-C6` |
|
||||
|
||||
→ **3조건 모두 충족 전까지 EVAL script 가 기본값이다.** Functions 로 전환하면 SSOT 가 library 파일(`#!lua name=…`)로 이동하고(`#REDIS-FN-C3`) §1 의 SHA1 기반 버전 식별이 성립하지 않으므로, 전환은 D1 재검토를 동반한다.
|
||||
|
||||
### 5. unsafe recipe ↔ 안전한 대체 쌍 카탈로그
|
||||
|
||||
> **Trace**: D7 ← `redis-incr-rate-limiter-pattern.md#REDIS-INCR-C4`(공식이 두 변형에 서로 다른 대체를 권고)·`#REDIS-INCR-C2`(race 명시)·`#REDIS-INCR-C3`(EXPIRE 유실 → key leak)
|
||||
>
|
||||
> - 이 표의 **행 자체가 계약**이다. helper 를 추가할 때 unsafe 쌍과 경합 test 없이 등재할 수 없다.
|
||||
|
||||
| unsafe recipe | 분기 | 만드는 경합 | 안전한 대체 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 고정 타임스탬프 키 카운터 — `INCR` 후 `EXPIRE` | 없음 | `INCR` 은 성공했는데 `EXPIRE` 가 유실될 수 있음 | **`MULTI`/`EXEC` 로 충분 — program 을 만들지 않는다** | `#REDIS-INCR-C4`(a) |
|
||||
| 단일 카운터 — `GET` → 판단 → `INCR` → 조건부 `EXPIRE` | 있음(`IF value == 1`) | 공식이 "race condition" 을 명시. `EXPIRE` 미수행 시 key 가 TTL 없이 leak | **atomic program 필수** (Lua/EVAL) | `#REDIS-INCR-C2`·`#REDIS-INCR-C3`·`#REDIS-INCR-C4`(b) |
|
||||
|
||||
두 변형을 하나의 "INCR+EXPIRE" 항목으로 뭉치면 `#REDIS-INCR-C4` 의 구분이 사라진다 — **별개 행으로 유지한다.**
|
||||
|
||||
### 6. 테스트 계약 — 경합 증명 2층
|
||||
|
||||
> **Trace**: D9 ← `#REDIS-EVAL-C1`(원자성은 서버가 보장)·`test-taxonomy-testcontainers-official.md#TC-OFFICIAL-C3`(mock/in-memory 없이 실제 서비스)·`#TC-OFFICIAL-C5`(in-memory 는 기능 결여)
|
||||
>
|
||||
> - **선행 계약 (다른 branch owner, 재진술 아님)**: focused(unit/contract/architecture) 레벨의 Testcontainers 금지는 [[raw/branch-notes/feature-test-taxonomy-fixture-contract]] 가 소유하며 ca-tmpl 에 **ArchUnit 으로 실제 강제**돼 있다 — `TestTaxonomyArchitectureTest.contractLevelTestsHaveNoTestcontainersDependency` / `architectureLevelTestsHaveNoTestcontainersDependency` + 비공허성 증명 `banRuleFiresOnTestcontainersUsage` (등급 `actually-implemented`, `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TestTaxonomyArchitectureTest.java`).
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 반복 횟수·스레드 수. 어떤 공식 문서도 수치를 권고하지 않는다. trade-off: barrier 로 순서를 강제하면 확률적 반복에 의존하지 않으므로 반복 수를 작게 유지한다 — `flaky-quarantine.yaml` 의 14일 sunset 압력을 피하는 방향.
|
||||
|
||||
| 층 | 무엇을 증명 | 어떻게 | 왜 이 층인가 |
|
||||
|---|---|---|---|
|
||||
| focused (unit/contract) | unsafe recipe 의 race 가 **실제로 발생함** | fake `RedisClient` + latch/barrier 로 read 와 write 사이 인터리빙을 **결정론적으로 강제** | race 는 클라이언트 측 순서 문제라 서버 없이 재현 가능. 실제 네트워크 금지 규칙 준수 |
|
||||
| focused (unit/contract) | atomic 대체에 **재현 가능한 client-side window 가 없음** | 성공 경로에서 round-trip 이 정확히 1회임을 spy 로 assert | 구조적 회귀 방지 — 나중에 누가 보조 `GET` 을 끼워 2-round-trip 으로 되돌리는 것을 잡는다 |
|
||||
| integration | atomic 대체의 **실제 원자성** + NOSCRIPT 재적재 | 실제 Redis(Testcontainers) — 동시 호출 후 불변식 유지 확인, `SCRIPT FLUSH` 후 재실행 | 원자성은 서버가 보장하는 성질이라 fake 로 증명 불가(`#REDIS-EVAL-C1`). in-memory 대체는 기능 결여 위험(`#TC-OFFICIAL-C5`) |
|
||||
|
||||
내부 선례(등급 `actually-implemented`): latch 기반 결정론적 동시성 test 는 이 repo 에 이미 있다 — `src/adapter/outbound/persistence-jpa/.../lock/LockRegistryDistributedLockAdapterTest.java` (`CountDownLatch` 사용).
|
||||
|
||||
**환경 게이트**: Redis 를 요구하는 test 는 `@EnabledIfRedisCacheEnabled`(`APP_CACHE_REDIS_ENABLED=true` 일 때만 실행, 아니면 SKIPPED) 로 감싼다 — 등급 `actually-implemented`, `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfRedisCacheEnabled.java`.
|
||||
|
||||
### 7. typed facade 와 소비 경계
|
||||
|
||||
> **Trace**: D8 ← `#SDR-SCRIPT-C3`(execute 시그니처)·`#SDR-SCRIPT-C4`(반환 타입 제약) + 소비 branch 4건이 명시적으로 "Lua program 실행 기반 — #062 소유" 로 위임
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: facade 타입·메서드 명명. 공식 근거 없음. trade-off: 능력 이름이 들어가면 D8 이 깨지므로 연산 서술 이름만 쓴다.
|
||||
|
||||
| 규칙 | planned 동작 |
|
||||
|---|---|
|
||||
| 노출 | program 식별자 + KEYS + ARGV + 반환 타입. **그 이상 없음** |
|
||||
| 비노출 | rate limit 판정·lock fencing·idempotency claim 결과 등 능력 의미 — 각 소비 branch 소유 |
|
||||
| 반환 | `#SDR-SCRIPT-C4` 의 4종을 넘는 복합 구조가 필요하면 program 이 직렬화 형식을 정하고 facade 가 파싱. 그 형식 결정은 근거가 없다(`UNSUPPORTED_IMPL_DECISION`) |
|
||||
| 소비자 | [[raw/branch-notes/feature-edge-rate-limit-policy-registry-contract]] · [[raw/branch-notes/feature-idempotency-ownership-protocol-contract]] · [[raw/branch-notes/feature-fenced-lock-coordination-contract]] · [[raw/branch-notes/feature-cache-strategy-registry-contract]] |
|
||||
|
||||
### 8. 실패 등급과 error code
|
||||
|
||||
> **Trace**: D10 ← `#REDIS-LUAAPI-C1`(`call` 은 예외를 그대로 올려 중단 / `pcall` 은 error 테이블 반환)·`#REDIS-LUAAPI-C2`(`error_reply`/`status_reply` 가 typed 반환 수단). 등급 분리 규칙 자체의 owner 는 hub §11 매트릭스.
|
||||
>
|
||||
> - **신규 제안임을 명시** — `docs/registries/error-codes.yaml` 에 script/program 관련 code 가 **하나도 없다**(grep 확인). 아래는 기존 값이 아니라 제안이며 registry-governance 절차를 거쳐야 한다.
|
||||
|
||||
**등급 판별은 as-built 예외 타입으로 결정론적으로 갈린다** — 추론이 아니라 타입 매칭이다:
|
||||
|
||||
| as-built 신호 | 의미 | 등급 | 제안 category | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| `RedisCommandFailureException(kind=UNAVAILABLE)` | 연결 불가 | backend 실패 → 선언된 경우 degrade 가능 | `TRANSIENT_DEPENDENCY` | 기존 `CACHE_UNAVAILABLE`(503, retryable) 선례 |
|
||||
| `RedisCommandFailureException(kind=OVERLOADED)` | admission 거부(in-flight 상한) | backend 실패 | `TRANSIENT_DEPENDENCY` | `RedisCommandAdmission` — 단 이 상한의 owner 는 #061 |
|
||||
| `RedisProgramCompatibilityException` | 반환 상태가 `descriptor.statuses()` 밖 | **program 결함 → fail closed** | `INTERNAL` | hub §11 "codec·프로그래밍 결함은 fail closed" |
|
||||
| `IllegalArgumentException` (arity·바이트 상한·카탈로그 소유권) | 호출 계약 위반 | **program 결함 → fail closed** | `INTERNAL` | `RedisLuaProgramExecutor.validate()` |
|
||||
| 계약된 상태 문자열 (`INVALID`·`WRONG_TYPE`·`NOT_OWNER` 등) | **실패가 아니다** — 정상 반환 | 호출자가 의미를 해석 | — | §3 오류 처리 규칙 |
|
||||
|
||||
`RedisCommandFailureException.certainty()` 는 `NOT_APPLIED` / `INDETERMINATE` 를 구분한다 — **재시도 안전성 판단에 필요**하다. `INDETERMINATE`(적용 여부 불명)를 무조건 재시도하면 program 이 원자적이어도 **중복 적용**이 생길 수 있다. 이 축의 owner 는 #061(전송 계층)이며 본 branch 는 소비한다.
|
||||
|
||||
`Category` 는 `shared/error/Category.java` 의 **10종 밖 값을 만들 수 없다** (`VALIDATION`·`AUTH`·`AUTHZ`·`NOT_FOUND`·`CONFLICT`·`RATE_LIMIT`·`TRANSIENT_DEPENDENCY`·`PERMANENT_DEPENDENCY`·`DATA_INTEGRITY`·`INTERNAL`, 등급 `actually-implemented`).
|
||||
|
||||
`redis.error_reply` 는 as-built 가 쓰지 않는다 — §3 의 방어적 상태 반환이 그 역할을 대신한다. `#REDIS-LUAAPI-C2` 는 typed 오류 반환의 *공식 수단*을 증명할 뿐 as-built 가 그것을 쓴다는 근거가 아니다.
|
||||
|
||||
### 9. program 실행 관측 — metric + span
|
||||
|
||||
> **Trace**: D11 ← [[raw/branch-notes/feature-metrics-alerting-contract]] `D2`(Micrometer dot.case + unit suffix 명명)·`D8`(high-cardinality tag 금지 + bounded whitelist) — 그 branch 가 owner 이고 본 §는 소비자다. 선례: `metrics.yaml` 의 `lock.acquisition`(owner `feature-distributed-lock-contract`) — "key 는 tag 로 넣지 않는다(무한 cardinality)".
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: metric 이름과 `outcome` 어휘. 공식 근거 없음. trade-off: `lock.acquisition` 의 형태(counter + `outcome` 단일 bounded tag)를 그대로 따라 registry 스타일 일관성을 택했다. `program_id` 를 tag 로 **추가**한 것은 카탈로그가 닫힌 enum(현재 3개)이라 cardinality 가 구조적으로 유한하기 때문이다.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: cardinality 상한 수치. metrics-alerting `D8` 자체가 정량 상한(≤200/≤50)을 "공식 spec 없는 운영 가정" 으로 라벨했으므로 본 §도 같은 성격이다.
|
||||
|
||||
| 항목 | planned 명세 | 근거 |
|
||||
|---|---|---|
|
||||
| metric | `redis.program.executions.total` (counter, unit `total`) | metrics-alerting `D2` 명명 규약 |
|
||||
| tag `program_id` | 값 = `RedisProgramId.externalId()`. cardinality 는 **닫힌 enum 크기**로 bounded (현재 3, 상한 50 제안) | as-built 카탈로그가 `EnumMap` 닫힌 집합 |
|
||||
| tag `outcome` | `success` · `noscript_reload` · `defect` · `backend_failure` (4종 고정) | §8 의 예외 타입 매핑과 1:1 |
|
||||
| **금지 tag** | key·KEYS 값·ARGV 값·상태 문자열 원문 — 사용자 입력 유래이거나 무한 cardinality | metrics-alerting `D8`, `lock.acquisition` 선례 |
|
||||
| alert | `defect` rate 지속 > 0 이면 P2 — program 결함은 조용히 넘어가면 안 된다 | hub §11 fail-closed 원칙 |
|
||||
| trace span | program 실행 1회 = span 1개. attribute 는 `program_id`·`outcome` 만. **key·ARGV 를 span attribute 로 넣지 않는다** | hub §36.2 "bounded cardinality metric 과 trace span" |
|
||||
| `noscript_reload` 의 쓰임 | 이 값이 **꾸준히 0 이 아니면** 캐시가 반복 소실된다는 신호 — D2 의 EVAL 폴백이 정상 경로가 되어버린 상태를 드러낸다 | `#REDIS-EVAL-C2`(캐시 휘발성) |
|
||||
|
||||
**registry 등재**: 위 metric 은 `docs/registries/metrics.yaml` 에 **신규 제안** 이다(현재 script/program/redis 관련 row 0건 — grep 확인). `owner_branch: feature-redis-atomic-program-catalog-contract`, `compatibility_impact: additive`, `required_test: contract-verification:metrics-cardinality` 로 등재하며 절차는 [[raw/branch-notes/feature-contract-registry-governance]] 소관이다.
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**
|
||||
- **스크립트 캐시 소실** — 재시작·failover·`SCRIPT FLUSH` 로 언제든 발생. EVALSHA 가 `NOSCRIPT` 를 받는다 → 재적재 후 재시도(§구현 가이드 2). 애플리케이션 책임임이 공식 명시(`#REDIS-EVAL-C2`·`#REDIS-EVAL-C3`)
|
||||
- **pipeline 안의 NOSCRIPT** — 처리 불가. pipeline 경로는 애초에 plain EVAL 로 되돌린다(`#REDIS-EVAL-C4`, D3)
|
||||
- **폭주 program 이 쓰기를 이미 수행함** — `SCRIPT KILL` 로 죽일 수 없다(`#REDIS-EVAL-C6`). 5초(`busy-reply-threshold`) 초과 후 서버는 다른 클라이언트에 `BUSY` 로 응답하고, 이 상태에서 남은 수단은 **`SHUTDOWN NOSAVE` — 인스턴스 강제 정지뿐**이다(`#REDIS-PROG-C1`·`#REDIS-PROG-C3`·`#REDIS-PROG-C4`). 즉 이 엣지의 최종 비용은 가용성 사고다(§구현 가이드 3)
|
||||
- **미선언 key 접근** — Redis 가 막아주지 않는다(`#REDIS-EVAL-C5` 의 does-not-prove: 거부 동작·에러 코드가 공식에 없음). 우리가 정적 검사로 잡지 못하면 조용히 통과한다
|
||||
- **program 결함** — fail closed. cache miss 로 위장하지 않는다(hub §11, D10)
|
||||
- **descriptor ↔ `.lua` drift** — `keyCount`/`resultType` 은 수기 선언이라 본문 변경 시 자동 갱신되지 않는다. golden vector 가 이 drift 를 잡는 유일한 장치(§구현 가이드 1)
|
||||
- **경합 test 자체의 flakiness** — 확률적 재현에 의존하면 `flaky-quarantine.yaml` 의 14일 sunset 압력을 받는다. barrier 로 순서를 강제해 회피(§구현 가이드 6)
|
||||
- **다른 계약 의존** (대상 브랜치 + 그 Decision ID)
|
||||
- [[raw/branch-notes/feature-redis-runtime-role-isolation-contract]] (`WI-…-061`) — 그 branch 가 **이미 D1~D22 를 보유**하며(스캐폴딩 아님) 다음을 소유한다: `D5`(key builder)·`D6`(digest)·`D8`(key/payload/policy **3 version 축 분리**)·`D11`(envelope)·`D10`(`byte[]` codec) 은 **program descriptor 의 입력**이고, `D16`~`D18`(Lettuce ClientOptions·timeout·queue) 은 본 branch 의 실행 경로가 올라타는 전송 계층이다. `D2`(SDK 선택)가 raw Lettuce 로 확정된 것이 본 branch D1·D2 의 전제다
|
||||
- [[raw/branch-notes/feature-test-taxonomy-fixture-contract]] `D3`(focused 레벨 Testcontainers 금지, ArchUnit 강제)·`D4`(CI 시간 budget) — D9 의 2층 분리는 이 계약의 *결과*이지 본 branch 의 재량이 아니다(재진술 아님, 포인터만)
|
||||
- **위임을 선언한 소비 branch (2건, 확인됨)** — [[raw/branch-notes/feature-edge-rate-limit-policy-registry-contract]] (§제외 범위 "Lua program 실행 기반 — #062 소유") · [[raw/branch-notes/feature-fenced-lock-coordination-contract]] (§제외 범위 "Redis 연결·스크립트 기반 — #061·#062 소유")
|
||||
- **위임 미선언 소비 branch (2건)** — [[raw/branch-notes/feature-idempotency-ownership-protocol-contract]] · [[raw/branch-notes/feature-cache-strategy-registry-contract]] 는 §제외 범위에 program 위임 문구가 **없다**(grep 0건). hub §25 Owner Map 은 이 둘을 consumer 로 적지만 노트 쪽 선언이 비어 있어 **양방향 정합이 깨져 있다** — `/sync` 로 맞춰야 하며, 그 전까지 "4건 모두 위임" 이라고 주장할 수 없다
|
||||
- [[raw/branch-notes/feature-operational-error-observability-foundation]] — D10 의 신규 error code 는 `error-codes.yaml` 스키마와 `Category` enum(10종)을 따른다. 해당 branch 의 category enum owner D-row 를 pin 해야 하나 아직 번호 미확인(`Should-fix`)
|
||||
- [[raw/branch-notes/feature-capability-provider-selection-contract]] (`WI-…-060`, **선행 필수**) — D6 의 배포 모드(EVAL script ↔ Functions) 는 hub §25 기준 **capability 활성화·provider 선택**의 일종이며, 그 판정은 "typed descriptor 로만" 하고 bean 이름·classpath 존재를 근거로 삼지 않는다. 본 branch 는 3조건 opt-in *논리*만 소유하고, 그 스위치가 어떤 typed descriptor 로 노출되는지는 그 branch 소유다
|
||||
- [[raw/branch-notes/feature-metrics-alerting-contract]] `D2`(dot.case 명명)·`D8`(cardinality bounds·금지 tag) — D11 의 metric·span 은 이 계약의 소비자다. `D8` 이 바뀌면 §구현 가이드 9 의 tag 집합이 함께 바뀐다
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장 / Claims To Verify
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 배포 이미지의 실제 `redis_version` 이 7.2 이상이다 | `program-set.json` 이 `minimumRedisVersion: "7.2"` 를 **선언**하지만, 그것이 배포 환경에서 실제로 지켜지는지는 별개다. 미달이면 D6 의 조건 ①과 `#REDIS-EVAL-C7`(effects replication 유일 모드)이 함께 깨진다 | 배포 대상에서 `INFO server` 의 `redis_version` 확인 + startup 시 최소 버전 검증을 둘지 결정 | `needs-confirmation` |
|
||||
| as-built 의 EVAL 폴백이 Redis 공식 절차와 동등하다 | as-built 는 `NOSCRIPT` 시 **EVAL 직접 실행**으로 복구하고, `#REDIS-EVAL-C3` 은 "`SCRIPT LOAD` 후 EVALSHA 재시도" 를 서술한다 — 서로 다른 경로다. EVAL 이 캐시에 넣는 것은 부수효과이며 코드가 관측하지 않는다 | 실제 Redis lane 에서 `SCRIPT FLUSH` → 실행 → `SCRIPT EXISTS <sha>` 로 재적재 여부 확인 | `needs-confirmation` |
|
||||
| D3(pipeline plain EVAL)이 공허하지 않다 | as-built `RedisBinaryCommands` 는 `get`/`set`/`delete`/`evalSha`/`eval` 5개뿐이고 **pipeline 표면이 없다**. 즉 D3 은 현재 도달 불가능한 조건 위의 결정이다 | pipeline API 를 실제로 추가할지 결정 → 추가하지 않으면 D3 을 "향후 확장 시 제약" 으로 재분류 | `planned` |
|
||||
| 정적 검사가 bounded execution 위반을 실효적으로 잡는다 | 정규식 텍스트 검사는 문자열 조립(`'S'..'MEMBERS'`)으로 우회된다. "완전한 강제" 라는 인상만 주고 실제로는 부분 강제일 수 있다 | 의도적 우회 fixture 를 넣어 검사기가 놓치는지 확인(비공허성 증명). 놓치면 그 한계를 문서에 명시 | `planned` |
|
||||
| barrier 로 강제한 인터리빙이 매 실행 race 를 재현한다 | barrier 는 read/write 의 **논리적 순서**만 강제하고 서버가 명령을 수신하는 wire-order 까지 통제하지 못한다 | 동일 test 를 CI 에서 연속 반복 실행해 재현율 측정. 100% 가 아니면 quarantine 대상이 된다 | `planned` |
|
||||
| atomic 대체가 실제로 경합을 없앤다 | 원자성은 서버가 보장하는 성질이라 fake 로 증명 불가(`#REDIS-EVAL-C1`). in-memory 대체도 기능 결여 위험(`#TC-OFFICIAL-C5`) | 실제 Redis 통합 test — 동시 호출 후 불변식 유지 확인. 이는 통계적(위반 미관측) 증명이지 결정론적 증명이 아님을 명시 | `planned` |
|
||||
| golden vector 가 descriptor drift 를 잡는다 | `keyCount`·`resultType` 은 수기 선언이고 `.lua` 본문 변경 시 자동 갱신되지 않는다 | descriptor 는 그대로 두고 `.lua` 의 KEYS 사용 개수만 바꾼 fixture 로 test 가 실패하는지 확인 | `planned` |
|
||||
| 신규 error code 가 registry 절차를 통과한다 | `error-codes.yaml` 에 script/program code 가 하나도 없어 전부 신규 제안이다. `Category` 는 10종 밖으로 못 나간다 | registry-governance 절차 + `contract-verification` test 로 매핑 검증 | `planned` |
|
||||
| **`maxmemory` 초과 상태에서 program 이 abort 된다** | D4 는 bounded *시간*만 다루고 bounded *메모리*는 다루지 않는다. "maxmemory 초과 시 추가 메모리를 쓰는 첫 write 가 스크립트를 abort 시킨다"는 서술은 수집한 두 자료(`redis-programmability-max-execution-time`·`redis-key-eviction-maxmemory-policy`) 어디에도 없어 **인과관계가 미증명**이다 | 공식 근거를 별도 수집하거나 실제 Redis 에서 `maxmemory` 근접 상태로 write program 실행해 재현. 확인되면 D4 에 메모리 축을 추가하고, `maxmemory-policy` 값 선택은 #061(배포 설정 소유)로 위임 | `needs-confirmation` |
|
||||
| 본 branch 가 소유할 관심사가 sibling branch 결정과 겹치지 않는다 | 소비 branch 4건 중 **2건만** 위임을 선언했고(edge-rate-limit·fenced-lock), idempotency·cache-strategy 는 아직 scaffolding 이라 선언이 없다 | 그 2건 `/branch-spec` 후 `/sync` 실행 — owner 중복 검출 | `needs-confirmation` |
|
||||
|
||||
## Audit & Findings
|
||||
|
||||
> `/branch-spec` 2026-07-28 조사에서 발견한 **상위 문서의 사실 오류**. 대상은 hub / 설계 문서이므로 본 branch 는 정합 권고만 내고 자동 수정하지 않는다.
|
||||
|
||||
| Finding ID | 대상 | 현재 서술 | 조사 결과 | 권고 | 처리 |
|
||||
|---|---|---|---|---|---|
|
||||
| `GROUND_TRUTH_PATH_DRIFT` | `.claude/commands/branch-spec.md` §참조 + llm-wiki 다수 문서 | ca-tmpl ground truth 경로를 `/home/donghyeon/workspace/ca-tmpl/` 로 지정 | **그 경로는 낡은 clone 이다.** 실제 최신 저장소는 `/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/` 이며, `cache-redis` 가 47개 src 파일을 보유하고 `RedisProgramCatalog`·`RedisProgramDescriptor`·`RedisProgramExecutor`·`RedisLuaProgramExecutor`·`RedisProgramId`·`LettuceRedisRuntime`·`RedisKeyBuilder`·`RedisCacheEnvelopeCodec` + `.lua` 3개 + `program-set.json` 이 모두 존재한다. 낡은 clone 에는 14개뿐이라 "코드 없음" 으로 오판하기 쉽다 | 명령 문서의 ground truth 경로를 정정. 같은 사실을 [[raw/branch-notes/feature-redis-runtime-role-isolation-contract]] §Audit A1 과 설계 §13 D2 가 독립적으로 기록했다 | `open` — harness/사용자 소유 |
|
||||
| `SELF_CORRECTION` (본 세션) | 본 노트 초판의 `CODE_CLAIM_DRIFT` finding | 초판은 "4개 클래스가 어디에도 없고 commit `b3add01` 도 없으므로 hub §35 를 `[ ]`(미시작)로 강등하라" 고 권고했다 | **그 권고는 틀렸다.** 낡은 clone(`/home/donghyeon/workspace/ca-tmpl`)만 조회한 결과이며, 실제 저장소에는 4개 클래스가 모두 존재한다. hub §35 의 `[*]`(부분 구현) 표기가 **맞다**. 설계 §14.2 의 `actually-implemented` 등급도 유효하다(단 파일 수 "43개" 는 현재 47개로 증가) | hub·설계 문서에 대한 강등 권고를 **철회**한다. 남는 정정 대상은 파일 수 숫자와 commit 해시(`b3add01` 은 낡은 clone 기준 미존재)뿐이며 경미하다 | `partially-resolved` 2026-07-28 — D-row(D1·D2·D4·D8·D9·D10·D11)·§구현 가이드 3·8·9·§TODO·§결정 사항·§진행 중 메모·§Claims To Verify 는 as-built 로 재작성했다. **§구현 가이드 1·2·6·7 은 아직 초판 전제가 남아 있다** — §구현 가이드 preamble 의 충돌 표 참조 |
|
||||
| `SPRING_DATA_REDIS_PREMISE_RESOLVED` | 본 노트 초판 D1·D2 | 초판은 D1·D2 를 Spring Data Redis 의 `RedisScript`/`DefaultRedisScript`/`ScriptExecutor` 전제 위에 세웠다 | **as-built 는 `io.lettuce:lettuce-core` 단독이고 `spring-data-redis` 의존성이 없다**(`cache-redis/build.gradle` 확인). EVALSHA→NOSCRIPT→EVAL 폴백은 `RedisLuaProgramExecutor` 가 직접 구현한다 | D1·D2 를 as-built 기준으로 재작성했다. [[raw/branch-notes/feature-redis-runtime-role-isolation-contract]] §Audit A2 가 이 충돌을 `SPRING_DATA_REDIS_PREMISE_CONFLICT` 로 열어두고 본 노트를 가리키는데, **본 노트 쪽은 해소됐으므로 그 항목과 §구현 가이드 4 의 위험 서술이 낡았다** — 그 branch 소유라 여기서 고치지 않고 알림만 남긴다 | `resolved` (본 노트 측) / #061 측 갱신 필요 |
|
||||
| `NO_VERSION_PIN` **(철회)** | 본 branch D6 의 opt-in 조건 ① | 초판은 "ca-tmpl 에 Redis 버전이 고정된 곳이 없어 7.0+ 판정 불가" 라고 단정했다 | **틀렸다 — 낡은 clone 기준이었다.** 실제 저장소의 `redis/program-set.json:3` 에 `"minimumRedisVersion": "7.2"` 가 있다. 즉 D6 의 조건 ①(7.0+)은 **이미 충족**이고, `#REDIS-EVAL-C7`(effects replication 유일 모드, 7.0+ 전제)도 확정으로 승격된다 | D6 게이트를 ②③ 로 좁혔다. 남는 확인은 "배포 이미지의 실제 `redis_version` 이 7.2 이상인가" 라는 운영 확인뿐 | `resolved` 2026-07-28 |
|
||||
| `DANGLING_DESIGN_REF` **(철회)** | 본 노트 §근거의 "프로젝트 내부 설계 참조" | 초판은 "두 설계 문서가 부재하므로 인용 근거에서 제외" 라고 권고했다 | **틀렸다 — 낡은 clone 기준이었다.** 실제 저장소 `docs/superpowers/specs/` 에 `2026-07-26-production-capability-platform-design.md` 와 `2026-07-26-redis-production-capability-design.md`(as-built 를 낳은 6,700줄 설계)가 **둘 다 실재**한다 | 두 문서를 `internal-design-doc` 등급으로 §근거에 유지한다. **다만 본 노트의 현재 결정은 아직 이 문서들을 읽고 쓴 것이 아니다** — 다음 세션에서 정독해 §1 golden vector·§9 metric·§3 denylist·BUSY 관측의 `UNSUPPORTED_IMPL_DECISION` 라벨을 항목별로 재판정해야 한다 | `open` — 정독 미완 |
|
||||
| `STALE_CLONE_ROOT_CAUSE` | 본 세션의 조사 방법 | 위 `SELF_CORRECTION`·`NO_VERSION_PIN`·`DANGLING_DESIGN_REF` 3건이 모두 같은 원인에서 나왔다 | `/branch-spec` 명령 문서가 지정한 `/home/donghyeon/workspace/ca-tmpl` 를 ground truth 로 삼은 결과, **"코드 없음·버전 없음·설계문서 없음" 이라는 3중 오판**이 연쇄했다 | 명령 문서의 ground truth 경로를 정정하기 전까지 이 branch 계열의 조사는 반드시 `desktop-server-git/clean-architecture-backend-template/` 를 쓴다 | `open` — harness/사용자 소유 |
|
||||
| `EVALSHA_COUNTEREXAMPLE` | 본 branch D2 (EVALSHA 우선) | 완료 조건이 "NOSCRIPT 재적재 test" 를 요구 → EVALSHA 우선을 전제 | 조사 중 LINE engineering 이 Lua 를 쓰되 **EVALSHA 를 의도적으로 거부**한 사례가 확인됐다(노드·키 관리 비용 vs 대역폭 절감을 비교한 결과). 다만 **아직 raw 로 archive 하지 않아 근거로 인용하지 않았다** | 클러스터 노드 수·호출 빈도가 확정되면 EVALSHA 의 실익을 재평가. 필요 시 해당 블로그를 `raw/company-tech-blogs/` 로 수집 후 D2 재검토 | `open` — 근거 미수집, 결정 변경 없음 |
|
||||
|
||||
## Coverage / 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
> `coverage-auditor` 생성물 — 손으로 유지하지 않는다. 2026-07-28 loop 2/2 판정: **Covered** (Blocking 0 / Should-fix 0 / Advisory 3).
|
||||
> governing doc = [[raw/project-notes/ca-skeleton-operational-contract]] (§8.0 WI-062 · §25 Owner Map · §36.2 capability card · §11 실패 매트릭스). 코드 대조는 `desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/`.
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|--------|------|-------|--------|------|
|
||||
| WI-062 ① program descriptor | covered-here | — | — | D1 · `RedisProgramDescriptor.java` 실재 |
|
||||
| WI-062 ② golden vector | covered-here | — | — | D1 · `RedisProgramCatalogTest.loadsEveryFoundationProgramWithAnExactDigestAndBoundedSignature` |
|
||||
| WI-062 ③ NOSCRIPT 재적재 test | covered-here | — | — | D2 · `RedisLuaProgramExecutorTest.fallsBackToEvalOnlyWhenEvalShaReportsNoScript` |
|
||||
| WI-062 ④ unsafe recipe 경합 재현 test | covered-here (결정) / 코드 `planned` | — | — | D9 + §구현 가이드 6. 모듈에 latch/thread 코드 0건 — 노트의 "3 구현 / 1 미구현" 자기평가가 코드와 일치함이 검증됨 |
|
||||
| §36.2 bounded cardinality metric · trace span | covered-here | — | — | **D11** + §구현 가이드 9 (loop 1 의 🔴 Blocking 해소) |
|
||||
| §36.2 나머지 12항목 (provider ID·readiness·비용모델·rolling upgrade·runbook 등) | delegated | `feature-capability-provider-selection-contract` | — | hub §36 헤더가 그 branch 를 card owner 로 지정. 본 branch 는 Tier T(기술 런타임)이므로 card 전체를 채울 의무가 없다 |
|
||||
| §11 실패 정책 매트릭스 정합 | covered-here (consumer) | 규칙 owner: `feature-capability-provider-selection-contract` | — | D10 · `Category.java` 10종 enum 과 노트 열거가 100% 일치 확인 |
|
||||
| 배포 모드 typed descriptor 노출 | delegated + linked | `feature-capability-provider-selection-contract` | — | §엣지·실패·의존 |
|
||||
| cluster / hash-slot 라우팅 | delegated + linked | `feature-redis-runtime-role-isolation-contract` | — | §엣지·실패·의존 (#061 D5·D6·D8·D10·D11·D16~D18 pin) |
|
||||
| idempotency branch 의 위임 미선언 | delegated (consumer 쪽 미선언) | `feature-idempotency-ownership-protocol-contract` | ⚪ Advisory | 그 노트가 아직 scaffolding(D-row 0건). 본 branch 는 이미 gap 을 서술·귀속했으므로 `UNLINKED_DELEGATION` 아님 — 그쪽 `/branch-spec` 으로 해소 |
|
||||
| cache-strategy branch 의 위임 미선언 | delegated (consumer 쪽 미선언) | `feature-cache-strategy-registry-contract` | ⚪ Advisory | 위와 동일 |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
해당 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -0,0 +1,751 @@
|
||||
---
|
||||
title: branch / feature-redis-runtime-role-isolation-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-redis-runtime-role-isolation-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||||
tags: [branch, ca-skeleton, redis, runtime, role-isolation, codec]
|
||||
created: 2026-07-28
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-061
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-061
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-REDIS-ROLE-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-OPTIONAL-ADAPTER-001@1]
|
||||
refines: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-REDIS-ROLE-001@1]
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-060]
|
||||
imports: []
|
||||
delegates: []
|
||||
accepts_delegations: []
|
||||
contract_packet: 1
|
||||
---
|
||||
|
||||
# branch: feature-redis-runtime-role-isolation-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
> **스캐폴딩 상태** — 결정(D-row)·구현 가이드는 비어 있다. `/branch-spec feature-redis-runtime-role-isolation-contract` 로 채운다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> 분해 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §4.2 — 기술 런타임 (Tier T). 본 branch 는 project §8.0 `WI-CA-SKELETON-OPERATIONAL-CONTRACT-061` 의 실행 단위다.
|
||||
|
||||
형제 branch (같은 부모의 다른 자식 — 인접 영역):
|
||||
|
||||
- [[raw/branch-notes/feature-redis-atomic-program-catalog-contract]]
|
||||
- [[raw/branch-notes/feature-cache-strategy-registry-contract]]
|
||||
- [[raw/branch-notes/feature-cachestore-multi-backend-router]]
|
||||
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
> project Work Item 에서 내려온 실행 계약의 snapshot. 여기에는 **pinned pointer + 1줄 요약 + branch 적용점**만 쓰고 상세를 복제하지 않는다.
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: 3-role 연결·키·코덱·health 계약 test 와 incompatible co-location 거부 test 가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-REDIS-ROLE-001@1` | Redis는 cache·coordination·session 3개 role로 분리하고 key prefix나 DB number를 격리 수단으로 쓰지 않는다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-OPTIONAL-ADAPTER-001@1` | optional adapter stack은 축마다 구현체 하나를 고정하고 core stack과 분리된 matrix로 관리한다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 상세 근거와 선택 조건은 아래 `결정-근거 매핑` 절의 동일 D-row 가 소유한다. 여기에는 요약과 관계만 둔다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | role = 별도 물리 deployment. prefix·DB number 는 격리 수단이 아니다 | `refines DEC-CA-SKELETON-OPERATIONAL-CONTRACT-REDIS-ROLE-001@1` | `redis-key-eviction-maxmemory-policy.md#REDIS-EVICT-C1`, `redis-cluster-spec-hash-slots-tags.md#REDIS-CLUSTER-C4` | `proposed` |
|
||||
| D2 | capability 는 endpoint 가 아니라 role 을 참조하고, role 마다 runtime 을 분리한다 | `local` | `internal-design-doc` §10.3 · `internal-code-fact` | `proposed` |
|
||||
| D3 | incompatible co-location 5조합은 production startup 거부, local 만 명시 opt-out | `local` | `redis-key-eviction-maxmemory-policy.md#REDIS-EVICT-C2` | `proposed` |
|
||||
| D4 | `maxmemory`·eviction·persistence·backup 은 배포 설정이 소유하고 애플리케이션은 `CONFIG SET` 을 실행하지 않는다 | `local` | `redis-key-eviction-maxmemory-policy.md#REDIS-EVICT-C1`, `redis-acl-least-privilege.md#REDIS-ACL-C7` | `proposed` |
|
||||
| D5 | 모든 물리 key 는 단일 `RedisKeyBuilder` 가 만들고 bounds 위반은 fail-open 대상이 아니다 | `local` | `internal-code-fact` · `redis-cluster-spec-hash-slots-tags.md#REDIS-CLUSTER-C1` | `proposed` |
|
||||
| D6 | digest 는 식별자 성격별로 SHA-256 / HMAC-SHA-256 을 나눠 쓰고 length-prefixed canonical encoding 을 강제한다 | `local` | `internal-code-fact` · `internal-design-doc` §11.2 | `proposed` |
|
||||
| D7 | hash tag 는 원자 연산에 필요한 최소 key group 만 묶고 tenant 전체를 태그로 쓰지 않는다 | `local` | `redis-cluster-spec-hash-slots-tags.md#REDIS-CLUSTER-C2`, `#REDIS-CLUSTER-C3` | `proposed` |
|
||||
| D8 | key schema / payload schema / policy revision 3개 version 축을 분리한다 | `local` | `internal-design-doc` §11.4 · `internal-code-fact` | `proposed` |
|
||||
| D9 | 요청 경로에서 pattern delete 를 하지 않고 대량 무효화는 4개 선택지로만 한다 | `local` | `redis-acl-least-privilege.md#REDIS-ACL-C7` · `internal-design-doc` §11.6 | `proposed` |
|
||||
| D10 | Redis value 의 기본 타입은 `byte[]` 이며 codec 은 명시 등록한다 (reflection·JDK serialization 금지) | `local` | `internal-code-fact` · `internal-design-doc` §12.1 | `proposed` |
|
||||
| D11 | envelope 는 자기기술(self-describing)이고 hard expiry 를 TTL 과 별도로 싣는다 | `local` | `internal-code-fact` · `internal-design-doc` §12.2 | `proposed` |
|
||||
| D12 | schema 불일치·손상은 miss 와 다른 결과 종류로 분리하고 metric 을 나눈다 | `local` | `internal-code-fact` · project note §11 (owner [[raw/branch-notes/feature-capability-provider-selection-contract]]) | `proposed` |
|
||||
| D13 | region 마다 크기 상한 5종을 필수로 두고 oversize 처리는 role 별로 비대칭이다 | `local` | `internal-code-fact` · `internal-design-doc` §12.4 | `proposed` |
|
||||
| D14 | session·idempotency·rate 는 cache codec 을 재사용하지 않는다 | `local` | `internal-design-doc` §12.5 | `proposed` |
|
||||
| D15 | topology 는 standalone·sentinel·cluster 배타 sum type 이고 binding model 과 runtime model 을 분리한다 | `local` | `redis-cluster-spec-hash-slots-tags.md#REDIS-CLUSTER-C4` · `internal-design-doc` §32.2 | `proposed` |
|
||||
| D16 | timeout 은 7개 축으로 분리하고 production 은 모두 유한 상한을 요구한다 | `local` | `client-options-disconnected-behavior-queue-lettuce-official.md#LETTUCE-CO-C3` · `internal-design-doc` §31.6 | `proposed` |
|
||||
| D17 | 일반 command 는 shared connection, blocking·Pub/Sub·maintenance 는 전용 connection | `local` | `client-options-disconnected-behavior-queue-lettuce-official.md#LETTUCE-CO-C3` · `internal-design-doc` §31.5 | `proposed` |
|
||||
| D18 | disconnected 시 command 를 버퍼링하지 않고 즉시 거부하며 queue 는 유한하다 | `local` | `client-options-disconnected-behavior-queue-lettuce-official.md#LETTUCE-CO-C1`~`#LETTUCE-CO-C5` | `proposed` |
|
||||
| D19 | correctness role 은 primary read 전용, replica read 는 stale 허용 cache region 만 opt-in | `local` | `redis-replication-asynchronous-read-routing.md#REDIS-REPL-C1`~`#REDIS-REPL-C5` | `proposed` |
|
||||
| D20 | production 연결은 TLS 필수이고 plaintext·검증 완화는 startup 에서 거부한다 | `local` | `redis-tls-encryption-in-transit.md#REDIS-TLS-C1`~`#REDIS-TLS-C5` (hostname verification 은 `UNSUPPORTED_DECISION`) | `proposed` |
|
||||
| D21 | role 마다 별도 ACL user 를 두고 `reset` 기준 least privilege 로 부여한다 | `local` | `redis-acl-least-privilege.md#REDIS-ACL-C1`~`#REDIS-ACL-C7` | `proposed` |
|
||||
| D22 | `PING` 성공은 health 가 아니다 — liveness 는 Redis 비의존, readiness 는 required role 만 | `local` | `redis-replication-asynchronous-read-routing.md#REDIS-REPL-C2` · `internal-design-doc` §35.1~§35.3 | `proposed` |
|
||||
| D23 | 연결 credential(username·password·trust material)은 Redis leaf 소유 SPI 로 조달하고 rotation 은 role 단위로 무중단 수행한다 | `local` | `redis-acl-least-privilege.md#REDIS-ACL-C1` · `redis-tls-encryption-in-transit.md#REDIS-TLS-C3` · `internal-design-doc` §34.8·§34.9 | `proposed` |
|
||||
| D24 | client·topology 런타임 metric 을 bounded tag 로 노출하고 endpoint·key·tenant·token 을 tag 로 쓰지 않는다 | `local` | `client-options-disconnected-behavior-queue-lettuce-official.md#LETTUCE-CO-C3`·`#LETTUCE-CO-C4` · `redis-cluster-spec-hash-slots-tags.md#REDIS-CLUSTER-C5`·`#REDIS-CLUSTER-C6` · `internal-design-doc` §35.4·§35.10 | `proposed` |
|
||||
| D25 | role 마다 persistence·replication **기대치(claim)** 를 선언하고 "AOF + replica = 절대 유실 없음" 이라고 말하지 않는다 | `local` | `redis-replication-asynchronous-read-routing.md#REDIS-REPL-C3`·`#REDIS-REPL-C5` · `internal-design-doc` §28.7 | `proposed` |
|
||||
| D26 | Redis 런타임의 startup·graceful shutdown 을 **순서 불변식**으로 고정한다 | `local` | `internal-design-doc` §36.1·§36.2 · `internal-code-fact` | `proposed` |
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- `WI-CA-SKELETON-OPERATIONAL-CONTRACT-061` 의 완료 조건을 구현한다: 3-role 연결·키·코덱·health 계약 test 와 incompatible co-location 거부 test 가 통과한다
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- cache·coordination·session 3-role 분리와 role 별 실패 의미
|
||||
- role 별 연결·TLS·ACL·timeout·pool·topology refresh 설정
|
||||
- key model — namespace·digest·hash tag·version·크기 상한
|
||||
- payload envelope·codec·호환성·압축 임계
|
||||
- role 별 health 구성요소
|
||||
- `maxmemory`·eviction·persistence 의 소유 경계 (배포 설정 소유)
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- capability 별 **의미** 계약 — #067(cache 전략·TTL·stampede) · #068(rate-limit 알고리즘) · #069(session) · #070(idempotency) · #071(fenced lock) 소유. 본 branch 는 그들이 공유하는 **물리 형식**(role·연결·key·codec)만 소유한다
|
||||
- Lua/Function 카탈로그 · program descriptor · `NOSCRIPT` 재적재 — #062 소유
|
||||
- 활성화 SSOT · capability descriptor · readiness 등급 R0~R3 · capability card · 실패 정책 매트릭스 파생 · health **group** 구성 — #060 소유 (본 branch 는 소비자)
|
||||
- Pub/Sub · keyspace notification · Streams 의 사용 계약 — `internal-design-doc` §26 경계. 본 branch 는 전용 connection 필요성만 언급한다
|
||||
- multi-region / active-active Redis — R2 baseline 밖
|
||||
- 배포 인프라 자체(`maxmemory` 값·AOF 설정·Sentinel 구성·backup 정책) — IaC/managed service 소유 (D4). 본 branch 는 **소유 경계와 검증 방식**만 정한다
|
||||
- **네트워크 격리**(public 노출 금지·private endpoint/VPC·security group·Cluster bus 보호·egress allowlist) — D4 와 같은 성격의 배포 소유 항목이다. 애플리케이션 password 만으로 네트워크 노출을 정당화하지 않는다는 원칙만 여기 적고, 구성은 IaC 가 소유한다
|
||||
- **관리 작업 audit log**(ACL·credential rotation·function 배포·대량 무효화·session 전역 무효화) — R3 성격이며 [[raw/branch-notes/feature-operational-error-observability-foundation]] 의 structured log 계약과 묶여야 한다. 본 branch 는 D23 의 "secret 값을 log 에 남기지 않는다" 경계만 소유한다
|
||||
- project decision registry 변경 — owner 는 project-note
|
||||
- registry 스키마·owner 이관 절차 — [[raw/branch-notes/feature-contract-registry-governance]] 소유 (§Audit A4 는 권고만)
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/redis-tls-encryption-in-transit]] | production 에서 role 별 Redis 연결에 TLS(mutual TLS 포함)를 요구하고 `port 0`+`tls-port` 로 plaintext downgrade 를 startup 에서 거절하며, replication/cluster bus 채널은 `tls-replication`/`tls-cluster` 로 별도 활성화해야 한다는 결정의 근거 (단, hostname verification / trust-all 거절은 이 문서만으로 `UNSUPPORTED_DECISION` — 클라이언트 라이브러리 공식 문서 추가 필요) |
|
||||
| [[raw/official-docs/client-options-disconnected-behavior-queue-lettuce-official]] | Redis 연결 런타임에서 `autoReconnect` / `disconnectedBehavior=REJECT_COMMANDS` / 유한한 `requestQueueSize` / command replay 억제를 명시 설정해 Redis 장애가 애플리케이션 heap 고갈로 번지지 않게 한다는 결정의 근거 (LETTUCE-CO-C1~C5) |
|
||||
| [[raw/official-docs/redis-acl-least-privilege]] | role 마다 별도 named ACL user(cache-runtime / coordination-runtime / session-runtime)를 두고 `reset`(=`-@all` 포함) 상태에서 필요한 command category(`+@<category>`)·key pattern(`~<pattern>`)만 선택적으로 부여하는 least-privilege 결정의 공식 근거 |
|
||||
| [[raw/official-docs/redis-key-eviction-maxmemory-policy]] | `maxmemory-policy` 는 인스턴스 단위 설정이라는 것과 `noeviction`/`volatile-*` eviction 시맨틱 — cache(evictable) vs coordination/session(noeviction) role 분리 및 policy 를 배포 설정이 소유한다는 결정의 근거 (REDIS-EVICT-C1~C5) |
|
||||
| [[raw/official-docs/redis-cluster-spec-hash-slots-tags]] | key model 에서 hash tag `{...}` 로 원자 연산에 필요한 최소 key group 만 co-locate 하고, Cluster 는 database 0 만 사용하므로 DB number 를 격리 수단으로 쓸 수 없다는 결정의 근거 (16384 hash slot / CRC16 mod / hash tag 파싱 규칙 / multi-key same-slot 제약 / MOVED·ASK 리다이렉션 의미, REDIS-CLUSTER-C1~C6) |
|
||||
| [[raw/official-docs/redis-replication-asynchronous-read-routing]] | coordination/session/idempotency/lease/rate role 은 primary read 만 쓰고 stale 을 허용하는 cache region 만 replica read 를 opt-in 한다는 read routing 결정의 근거 — Redis replication 은 기본 asynchronous 이고 replica 는 stale data 를 반환할 수 있으며, `WAIT` 조차 acknowledged write 의 failover 유실을 완전히 막지 못한다는 공식 서술 (REDIS-REPL-C1~C5) |
|
||||
|
||||
**추가 수집 대상 (미등록 — 아래 3건은 현재 `UNSUPPORTED_DECISION` 의 직접 원인이다):**
|
||||
|
||||
1. **Lettuce / Spring Data Redis 의 SSL 옵션** — hostname verification. Redis 서버 TLS 문서에는 이 개념이 **0회** 등장한다(§Audit A8) → D20 의 해당 부분 승급용
|
||||
2. **Lettuce Cluster topology refresh** (periodic / adaptive 설정) — 이번 회차 fetch 에서 verbatim 미발견(§Audit A7) → D15 승급용
|
||||
3. **Lettuce `replayFilter` semantics** — paraphrase 만 확인되어 폐기(§Audit A7) → D18 의 replay 억제 메커니즘 근거용
|
||||
|
||||
**프로젝트 내부 설계 참조 (등급 `internal-design-doc` — 공식 문서 아님, best practice 로 격상 금지):**
|
||||
|
||||
- ca-tmpl `docs/superpowers/specs/2026-07-26-redis-production-capability-design.md` (6,771줄) — **본 branch 의 1차 설계 근거**. 인용한 절: §5(HARD invariants) · §6(대안 A~F) · §10(role·co-location·정책 소유) · §11(key model) · §12(payload·codec) · §27(topology) · §28.7(role 별 persistence baseline) · §29.3~§29.4(eviction·OOM) · §31(client·timeout·queue·replay) · §32(configuration·validation) · §34(TLS·ACL·secret) · §35.1~§35.3·§35.10(health·client metrics)
|
||||
- ca-tmpl `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md`
|
||||
- llm-wiki `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §4.2 #2 (본 branch 의 소유 관심사 정의) · §13(드리프트 정정)
|
||||
|
||||
> ⚠️ 위 ca-tmpl 문서의 실제 경로는 `/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/docs/superpowers/specs/` 다 — `/home/donghyeon/workspace/ca-tmpl/` 사본에는 **없다**(§Audit A1).
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [x] `/branch-spec` 로 D1~D26 · §구현 가이드 10절 작성 (2026-07-28, `/depth`+`/coverage` 루프 2회 반영) — 등급: `documented-only`
|
||||
- [ ] **선행**: [[raw/branch-notes/feature-capability-provider-selection-contract]] 완료 (활성화 SSOT·descriptor 확정 전 착수 금지) — 등급: `planned`
|
||||
- [ ] **사용자 확정 필요**: Spring Data Redis 도입 여부 (§Audit A2 · §구현 가이드 4 `UNSUPPORTED_IMPL_DECISION`) — 등급: `needs-confirmation`
|
||||
- [ ] role/deployment 설정 축 도입 + `env-keys.yaml` 신규 row 등록 (D1·D2) — 등급: `planned`
|
||||
- [ ] incompatible co-location 거부 5조합 startup 검증 + negative test (D3) — 등급: `planned`
|
||||
- [ ] key builder 미구현 검증 3종(hash tag 1개 · `{`/`}` 유입 차단 · kind allowlist) + builder 우회 금지 ArchUnit rule (D5) — 등급: `planned`
|
||||
- [ ] HMAC key rotation mode 4종 + `RedisKeyDigestMaterialProvider` SPI (D6) — 등급: `planned`
|
||||
- [ ] envelope 누락 필드 보강 — codec 식별자 · payload version 분리 · compressed flag · writtenAt · soft/hard expiry (D11, §Audit A3) — 등급: `planned`
|
||||
- [ ] schema/corrupt 축 metric 분리 + 손상 entry quarantine (D12) — 등급: `planned`
|
||||
- [ ] topology sum type(standalone/sentinel/cluster) + binding↔runtime model 분리 (D15) — 등급: `planned`
|
||||
- [ ] timeout 7축 분리 (shutdown 을 command timeout 에서 떼기 포함, D16) — 등급: `planned`
|
||||
- [ ] TLS 설정 축 + production plaintext 거부 (D20) — 등급: `planned`
|
||||
- [ ] role 별 ACL user + positive grant 확정 + `@admin` 미부여 negative test (D21) — 등급: `planned`
|
||||
- [ ] role health contributor + optional cache `DEGRADED` 표시 (D22) — 등급: `planned`
|
||||
- [ ] `RedisCredentialMaterialProvider` SPI + role 별 rotation coordinator (D23) — 등급: `planned`
|
||||
- [ ] client·topology metric 축 + `metrics.yaml` 행 등록 (D24, owner branch 경유) — 등급: `planned`
|
||||
- [ ] 서버 정책 거부(`OOM`/`NOPERM`)를 결과 모델에 매핑 — `LettuceRedisRuntime.execute()` 의 `RedisCommandExecutionException` 분기 (D12) — 등급: `planned`
|
||||
- [ ] role 별 durability baseline 선언 + startup·shutdown 순서 강제 test (D25·D26) — 등급: `planned`
|
||||
- [ ] **owner 충돌 해소**: health 판정 SSOT(§Audit A9) · secret rotation policy(§Audit A10) · metric 이름 규약(§Audit A11) · cache fail-open 정책 3자(§Claims To Verify) — `/sync` 로 대조 — 등급: `needs-confirmation`
|
||||
- [ ] **잔여 근거 수집 4건** (전부 `UNSUPPORTED_*` 의 직접 원인): Lettuce `SslOptions`/`RedisURI` TLS · Lettuce Cluster topology refresh · Lettuce `replayFilter` semantics · Lua 내부 명령의 ACL 적용 — 등급: `needs-confirmation`
|
||||
- [ ] `/depth` + `/coverage` 3회차 재실행 (본 세션은 루프 천장 2회로 종료) — 등급: `planned`
|
||||
- [ ] 완료 조건: 3-role 연결·키·코덱·health 계약 test 와 incompatible co-location 거부 test 가 통과한다 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
**as-built 현황 요약 (2026-07-28, `src/` grep 기준)** — 어디까지 실재하는지의 한 장 요약. 상세는 §구현 가이드.
|
||||
|
||||
| 영역 | 상태 |
|
||||
|---|---|
|
||||
| key model | ✅ canonical shape · SHA-256/HMAC digest · length-prefix 인코딩 · 길이·slug·hv 검증 (`actually-implemented`) |
|
||||
| envelope | ⚠️ magic·version·type·sourceRevision·payload·SHA-256 digest 만 (목표 필드 절반, §Audit A3) |
|
||||
| 결과 모델 | ✅ `Hit`/`Miss`/`NegativeHit`/`IncompatibleSchema`/`Unavailable` sealed 분리 (`actually-implemented`) |
|
||||
| connection | ⚠️ standalone 단일 connection · raw Lettuce · `REJECT_COMMANDS`+유한 queue+admission 은 구현됨 |
|
||||
| **role 개념** | ❌ **전혀 없음** — capability `"cache"`/hv `1`/kv `1`/kind `"entry"` 하드코딩 |
|
||||
| topology | ❌ standalone 전용. Sentinel·Cluster 없음 |
|
||||
| TLS · ACL | ❌ 0건. plaintext + password + default user |
|
||||
| health | ❌ Redis health contributor 0개 |
|
||||
| key rotation | ❌ `hv` 는 상수 `1`, secret 단일 값 |
|
||||
| 압축 | ❌ 미구현 |
|
||||
|
||||
즉 **`internal-design-doc` §0 의 자기 평가(standalone/string cache 는 R1, 나머지는 R0)와 코드가 일치**한다. 본 branch 의 D-row 26개 중 코드에 이미 반영된 것은 D5·D6(부분) · D10 · D12(결과 모델) · D13(일부) · D18 이고, 나머지는 전부 `planned` 다.
|
||||
|
||||
**실 Redis 검증 lane**: `./gradlew :adapter:outbound:cache-redis:redisServiceTest`(JUnit 태그 `redis-service`, 기본 `test` 에서는 제외). 이 lane 이 `locally-verified` 등급을 뒷받침할 유일한 장치다.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-07-28: D1~D22 를 `/branch-spec` 으로 작성. 각 결정의 근거·선택 조건·Open Risk 는 아래 `결정-근거 매핑` 표가 소유한다(여기서 재진술하지 않음).
|
||||
- 2026-07-28: 근거 수집 방침 — Redis **서버** 동작(eviction·cluster·replication·TLS·ACL)은 Redis 공식 문서 5건으로, **클라이언트** 동작(queue·replay·reconnect)은 Lettuce 공식 문서 1건으로 확보했다. 반면 role 분리·key model 의 *구조 설계*는 ca-tmpl `internal-design-doc` 이 유일한 근거이며 **공식 best practice 가 아니다** — 인용 시 "ca-tmpl 프로젝트 설계 결정" 으로 표기한다.
|
||||
- 2026-07-28: 검토했으나 채택하지 않은 대안(`internal-design-doc` §6) — (A) 현행 `RedisClient` seam 에 method 추가, (B) application 에 범용 `RedisPort` 노출, (C) capability 마다 즉시 leaf 분리, (E) Redisson API 중심, (F) Redis Functions 전용. 채택은 **(D) 단일 physical Redis leaf + capability 별 package·semantic port** 다. 이유: (B)는 HARD invariant 2·3(application 에 Redis command 노출 금지) 위반, (C)는 19-모듈 고정 registry 를 즉시 깨고, (E)는 project note §34 의 "Redisson 을 기본 lock provider 로 삼지 않는다" 와 충돌한다.
|
||||
|
||||
<!-- section-id: decision-evidence -->
|
||||
## Decision Evidence Map / 결정-근거 매핑
|
||||
|
||||
> `Evidence Strength` 어휘: `official-vendor-doc`(공식 벤더 문서) / `internal-design-doc`(ca-tmpl 설계 — **공식 아님, best practice 로 격상 금지**) / `internal-code-fact`(`src/` grep 으로 확인한 as-built) / `internal-policy`(project note 상속) / `UNSUPPORTED_DECISION`.
|
||||
> `internal-design-doc` 의 §번호는 ca-tmpl `docs/superpowers/specs/2026-07-26-redis-production-capability-design.md` 기준이다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | Redis role(`cache`/`coordination`/`session`)은 **별도 physical deployment**(managed database·cluster·instance)를 뜻한다. key prefix 나 Redis database number 로 role 을 나눴다고 말하지 않는다 | 3 role 중 둘 이상을 실제로 쓰는 모든 환경 → 별도 deployment. local·CI 단일 컨테이너 → D3 의 명시 opt-out 경로로만 공유하고 readiness 를 강등 | `REDIS-EVICT-C1`(`raw/official-docs/redis-key-eviction-maxmemory-policy.md` — `maxmemory-policy` 는 `maxmemory` 한도 도달 시 적용할 policy 를 고르는 **서버 configuration directive**), `REDIS-CLUSTER-C4`(`raw/official-docs/redis-cluster-spec-hash-slots-tags.md` — Cluster 는 database `0` 만 지원하고 `SELECT` 를 허용하지 않음) | **`official-vendor-doc`(전제) + 도출(결론)** — 두 공식 진술은 "policy 는 서버 단위 directive" 와 "Cluster 에 DB number 가 없다"까지만 말한다 | ⚠️ **"인스턴스당 `maxmemory-policy` 는 1개"는 원문이 시사할 뿐 명시하지 않는다** — `REDIS-EVICT-C1` 의 `Does not prove` 열이 "한 인스턴스 안에서 key 별/role 별로 다른 eviction policy 를 동시 적용할 수 있는지 여부 — 문서가 명시하지 않음"이라고 못박았다. D1·D3 은 `REDIS-EVICT-C1`+`C2` 에서 **도출한** 결론이며 단정 인용이 아니다. eviction·CPU·event loop·persistence fork·replication buffer·connection limit 이 인스턴스 단위라는 **전체 목록**도 `internal-design-doc` §10.2 다. deployment 수가 1→3 이 되면 인프라 비용·운영 표면이 늘어난다 — 비용 판단은 배포 소유자 몫 |
|
||||
| D2 | capability 는 endpoint 를 직접 알지 않고 **role binding** 을 참조한다. role 마다 connection factory·client resources·credential·metrics scope·lifecycle 을 분리하고 전역 `@Primary RedisConnectionFactory` 를 두지 않는다 | endpoint·credential·topology 가 같고 실패 정책이 compatible → runtime 공유 허용. 하나라도 다르면 분리 | `internal-design-doc` §10.3(`cache region -> cache role -> cache-main deployment` binding 표), §31.4(deployment ID 마다 client·event loop·factory·metrics 소유). `internal-code-fact`: as-built 는 role 개념이 없다 — `RedisCacheAdapterConfig.redisStringCacheRegion()` 이 capability 를 문자열 `"cache"` 로, hv/kv 를 `1`/`1` 로, kind 를 `"entry"` 로 **하드코딩** | **`internal-design-doc` + `internal-code-fact`** — 외부 공식 근거 없음(프레임워크 배선 정책) | 활성화 축·descriptor 모양은 본 branch 소유가 아니다 — [[raw/branch-notes/feature-capability-provider-selection-contract]] D2·D5 가 owner. 두 branch 가 같은 시기에 property 이름을 각자 정하면 배선이 갈라진다 |
|
||||
| D3 | 같은 physical deployment 에 다음이 bind 되면 **production startup 을 거부**한다: evictable cache + session / evictable cache + idempotency / evictable cache + fenced coordination / replica-read cache + primary-only correctness capability / 상호 모순되는 persistence·eviction attestation | production·staging → 무조건 거부. local profile → `allow-unsafe-colocation=true` 명시일 때만 공유 허용하고 readiness 를 R1 로 강등 | `REDIS-EVICT-C2`(`#REDIS-EVICT-C2` — `noeviction` 에서는 key 가 evict 되지 않고 새 데이터를 캐시하는 명령이 **에러를 반환**하며 read-only 명령은 정상 동작). 즉 한 인스턴스는 policy 를 하나만 가지므로 evictable 과 noeviction 요구가 공존할 수 없다. 조합 목록 자체는 `internal-design-doc` §10.4 | **`official-vendor-doc`(공존 불가의 원리) + `internal-design-doc`(5조합 목록)** | "readiness R1 강등"의 등급 어휘 owner 는 [[raw/branch-notes/feature-capability-provider-selection-contract]] D8 이고 그 D8 자체가 `UNSUPPORTED_DECISION` 이다. 등급 정의가 바뀌면 본 row 의 강등 표현도 따라가야 한다 |
|
||||
| D4 | `maxmemory`·eviction policy·AOF/RDB·replica·Sentinel/Cluster 구성·TLS 종단·backup 은 **배포(IaC/managed service)가 소유**한다. 애플리케이션은 `CONFIG SET` 을 실행하지 않고, 가능하면 read-only introspection 으로 effective policy 를 확인하되 확인 불가를 "원하는 보장이 있다"로 대체하지 않는다 | managed service 가 `CONFIG GET` 을 허용 → 기동 시 read-only 검증. 차단 → operator attestation + 외부 conformance job. 둘 다 없음 → 해당 role 의 guarantee 를 주장하지 않음 | `REDIS-EVICT-C1`(policy 는 서버 configuration directive), `REDIS-ACL-C7`(`raw/official-docs/redis-acl-least-privilege.md` — `@admin` 카테고리는 `CONFIG`·`DEBUG`·`SAVE`·`SHUTDOWN` 등을 포함하며 공식 문서가 "일반 애플리케이션은 쓸 일이 없다"고 서술) | **`official-vendor-doc`** — `CONFIG` 를 애플리케이션 권한에서 빼는 것은 공식 카테고리 서술과 정합 | attestation 형식(서명·발급 주체·만료)은 정해진 바 없다 — §구현 가이드 1 의 `UNSUPPORTED_IMPL_DECISION`. attestation 이 형식적으로 흐르면 D4 는 "확인했다고 적는 칸"이 된다 |
|
||||
| D5 | 모든 물리 key 는 **단일 `RedisKeyBuilder`** 로만 만든다. builder 는 전체 UTF-8 byte 길이·slug 문법·digest algorithm/version·hash tag 정확히 1개·user input 의 `{`/`}` 유입 차단·capability 별 kind allowlist 를 검증하고, **검증 실패는 backend outage 가 아니므로 fail-open 하지 않는다** | 모든 capability 의 모든 key → builder 경유. 예외 없음 (probe key 포함) | `internal-code-fact`: `RedisKeyBuilder.build()` 가 `ca:<app>:<env>:<capability>:<region>:hv<n>:kv<n>:{<slot>}:<digest>:<kind>` 를 생성하고 `maximumKeyBytes` 초과 시 `IllegalArgumentException`(`adapter/outbound/cache-redis/.../redis/key/RedisKeyBuilder.java`). `RedisKeyNamespace` 가 slug `[a-z][a-z0-9-]{0,62}`, hv/kv `1..9999`, maximumKeyBytes `1..4096` 강제. `REDIS-CLUSTER-C1`(key→slot 은 `HASH_SLOT = CRC16(key) mod 16384` 고정 공식) | **`internal-code-fact`(as-built) + `official-vendor-doc`(slot 공식)** | as-built 의 검증은 **길이·slug·hv 일치** 3개뿐이다. "hash tag 정확히 1개", "user input 의 `{`/`}` 차단", "capability 별 kind allowlist" 는 코드에 **없다** — `planned`. builder 밖에서 key 를 만드는 경로를 막는 정적 강제(ArchUnit)도 없다 |
|
||||
| D6 | digest 는 식별자 성격으로 나눈다: 이미 random opaque ID → SHA-256, 사용자·tenant·email·IP 처럼 사전 공격이 가능한 값 → versioned HMAC-SHA-256. 입력은 **length-prefixed canonical encoding** 후 해싱하고 단순 delimiter join 은 금지한다. key HMAC secret 은 payload 암호화 키와 분리하며 `hv<n>` 세그먼트가 HMAC key version 을 고정한다 | opaque·비민감 → `opaque()`. 민감·composite scope → `sensitive()`. rate-limit IP → trusted resolver 가 normalized binary address 를 만든 뒤 HMAC | `internal-code-fact`: `RedisKeyDigest.opaque()` = SHA-256, `.sensitive()` = HmacSHA256(≥32 byte secret), 둘 다 `canonicalComponents()` 의 `putInt(len) + bytes` length-prefix 인코딩을 통과(`redis/key/RedisKeyDigest.java`). `RedisRuntimeSettings.hmacSecret()` 이 Base64 디코드 후 32 byte 미만을 거부. `internal-design-doc` §11.2(rotation mode 4종) | **`internal-code-fact`(알고리즘·인코딩) + `internal-design-doc`(rotation)** | rotation(`dual-read-delete`/`cold-cutover`/conservative overlap/`fixed`)과 `RedisKeyDigestMaterialProvider` SPI 는 **코드에 없다** — `planned`. 현재는 `app.cache.redis.key-hmac-secret` 단일 값이고 version 축이 없어 secret 교체 = 전체 key 무효화다 |
|
||||
| D7 | `{slotTag}` 는 **같은 원자 연산에 필요한 최소 key group** 만 co-locate 한다(idempotency record + operation marker / sliding counter 의 current+previous bucket / lease owner + fencing counter / exact rate decision dedup record). tenant 전체를 hash tag 로 쓰지 않는다 | 원자 연산이 2개 이상 key 를 건드림 → 그 key 들만 같은 태그. 단일 key 연산 → 태그는 resource digest 의 bounded prefix 로 자연 분산 | `REDIS-CLUSTER-C2`("Then instead of hashing the key, only what is between the first occurrence of `{` and the following first occurrence of `}` is hashed."), `REDIS-CLUSTER-C3`(복잡한 multi-key 연산은 모든 key 가 같은 slot 일 때만 지원). `internal-code-fact`: `RedisKeyDigest.fromBytes()` 가 slotTag 를 digest hex 의 앞 8자로 고정 | **`official-vendor-doc`** — 태그 파싱 규칙과 same-slot 제약이 공식 진술 | as-built 는 slot tag 를 **항상 resource digest 앞 8자**로 만든다. 즉 "여러 key 를 의도적으로 같은 slot 에 모으는" D7 의 co-location 용법은 아직 표현할 수 없다 — 현재 코드는 분산만 된다(`planned`) |
|
||||
| D8 | **key schema version(`kv`)** / **payload schema version** / **policy revision** 을 서로 다른 축으로 분리한다. key version 은 물리 레이아웃을, payload version 은 같은 key 의 decode 호환성을, policy revision 은 state 해석(TTL·rate 파라미터)을 바꾼다. 정책이 바뀌었는데 기존 counter/token state 를 새 의미로 재사용하지 않는다 | rate-limit key → policy revision 을 key 에 **포함**한다. cache entry → policy revision 은 key 밖(정책 registry) | `internal-design-doc` §11.4. `internal-code-fact`: key 에 `hv`(HMAC key version)와 `kv`(key schema version) 2축이 이미 있고(`RedisKeyBuilder`), envelope 에 별도 `VERSION` 바이트가 있다(`RedisCacheEnvelopeCodec.VERSION = 1`) — 즉 key 축과 payload 축은 분리되어 있다 | **`internal-code-fact`(2축 분리) + `internal-design-doc`(3축 규범)** | **policy revision 축은 코드에 없다.** rate-limit·cache 정책은 아직 key 에도 envelope 에도 실리지 않으므로, 정책 변경 시 옛 state 재사용을 막는 장치가 현재 0이다. 이 축의 실사용자는 #067·#068 이라 본 branch 는 *자리*만 정한다 |
|
||||
| D9 | regular request 경로에서 **pattern delete 를 하지 않는다**. 대량 무효화는 (1) key schema/version bump, (2) region generation ID 교체, (3) known-key bounded batch, (4) operator maintenance 의 rate-limited `SCAN`+`UNLINK` 4가지로만 한다. generation key 가 evict 되어 `0` 으로 되돌아가 옛 namespace 를 부활시켜서는 안 되므로, missing 이면 새 random 128-bit generation 을 `SET NX` 로 초기화하고 loser 는 winner 값을 읽는다 | 전체 region 무효화 → (1) 또는 (2). 알려진 key 집합 → (3). 그 외 운영 정리 → (4), 요청 경로 밖 | `REDIS-ACL-C7`(`@admin`/dangerous 계열 command 를 애플리케이션이 쓰지 않는다는 공식 서술 — `KEYS`·`FLUSHALL` 계열 차단의 근거). 4개 선택지와 generation 프로토콜은 `internal-design-doc` §11.6 | **`official-vendor-doc`(위험 command 배제) + `internal-design-doc`(대체 4선택지)** | generation 프로토콜은 **코드에 없다**(`planned`). 또한 `SCAN`+`UNLINK` 를 도는 operator 도구가 없으면 (4)는 문서상의 선택지일 뿐이다. cache region 의 실제 무효화 사용자는 [[raw/branch-notes/feature-cache-strategy-registry-contract]] 라 그쪽 착수 전까지 검증되지 않는다 |
|
||||
| D10 | Redis runtime 의 기본 value 타입은 `byte[]` 다. application object 를 reflection 으로 자동 직렬화하지 않고 JDK native serialization 과 무제한 polymorphic/default typing 을 금지한다. codec 은 `CacheCodec` 같은 **adapter 내부 SPI** 로 명시 등록하며 application use case 는 그 타입을 보지 않는다 | 모든 Redis 저장 값 → `byte[]` + 명시 codec. 타입 안전이 필요한 지점 → semantic port 의 typed 시그니처로 해결하고 codec 은 adapter 안에 둔다 | `internal-code-fact`: `LettuceRedisRuntime` 이 `ByteArrayCodec.INSTANCE` 로 접속하고 `RedisBinaryCommands` 가 `byte[]` 만 노출. `CacheRegionPort<String,String>` 만 Spring composition 에 노출되고 `RedisStringCacheRegion`·`RedisCacheEnvelopeCodec` 은 package-private. `internal-design-doc` §12.1 | **`internal-code-fact`** — as-built 가 이미 이 형태 | as-built 의 codec 은 **String 전용 1개**이고 `CacheCodec<T>` 같은 일반 SPI 는 없다. 타입이 늘어나는 순간(#067·#069·#070) SPI 모양을 정해야 하며 그 API 는 아직 결정되지 않았다 |
|
||||
| D11 | cache envelope 는 자기기술이며 최소한 magic·envelope version·codec 식별자·payload version·flags(negative/compressed)·source revision·기록 시각·hard expiry·payload length·payload digest·payload 를 담는다. **Redis key TTL 은 hard expiry 이후의 물리적 정리**를 담당하고, **envelope 의 hard expiry 는 client 가 stale/expired 를 판정하고 clock·TTL drift 를 관측하는 방어선**이다 | 재생성 가능한 cache entry → 이 envelope. session·idempotency → D14 의 별도 스키마 | `internal-code-fact`: `RedisCacheEnvelopeCodec` 이 magic `0x43414348` + version + type(POSITIVE/NEGATIVE) + sourceRevision + payload + **SHA-256 digest 접미**를 인코딩하고 digest 불일치를 `CORRUPT_ENVELOPE` 로 판정. `internal-design-doc` §12.2(필드 목록) | **`internal-code-fact`(부분 구현) + `internal-design-doc`(목표 필드)** | **as-built 는 목표 필드의 절반이다** — codec 식별자·payload version(별도 축)·compressed flag·writtenAt·soft/hard expiry 가 **없다**. 따라서 현재는 TTL 만이 만료의 유일한 근거이고 clock drift 를 관측할 수단이 없다. §Audit `ENVELOPE_FIELD_GAP` 참조 |
|
||||
| D12 | schema 불일치와 손상은 miss 로 접지 않는다. writer 는 한 version 만 쓰고 reader 는 현재 N 과 migration window 의 N-1 을 읽는다. 알 수 없는 미래 version → `SCHEMA_MISMATCH`, decoder 예외·길이 오류·digest 불일치 → `CORRUPT`, 둘 다 miss 와 **다른 metric**. 손상 항목은 bounded owner-safe quarantine/evict 후 정책에 따라 source 를 조회한다. **프로그래밍 결함을 Redis unavailable 로 분류하지 않는다** | **category → policy 분기 (as-built)**: `FUTURE_VERSION` → `FAIL_FAST`(앞으로 나아갈 수 없음 — 새 writer 가 이미 배포됨), 그 외(`RETIRED_VERSION`·`CORRUPT_ENVELOPE`·`UNKNOWN_ENVELOPE`) → `QUARANTINE_AND_RELOAD`(`RedisStringCacheRegion.java:58-63`). **서버 정책 거부는 별도 축이다** — `noeviction` OOM 은 용량 사건이므로 `Unavailable(UNAVAILABLE, NOT_APPLIED)`(프로그래밍 결함 **아님**), `NOPERM`(ACL 미부여)은 배선 결함이므로 fail closed 이며 `IncompatibleSchema` 로 접지 않는다. rolling deploy 에서 old reader 가 new payload 를 못 읽음 → writer 전환 **전에** dual-readable codec 을 먼저 배포 | `internal-code-fact`: `CacheLookup` sealed 계층이 `Hit`/`Miss`/`NegativeHit`/`IncompatibleSchema`/`Unavailable` 을 이미 분리하고, `RedisCacheEnvelopeCodec.decode()` 가 `FUTURE_VERSION`/`RETIRED_VERSION`/`CORRUPT_ENVELOPE`/`UNKNOWN_ENVELOPE` 를 구분(`application-core/.../cache/CacheLookup.java`, `redis/RedisCacheEnvelopeCodec.java`). 정책 값은 project note §11 "Capability 별 실패 정책 매트릭스" 2행(cache backend 실패 = fail open / cache codec·프로그래밍 결함 = fail closed) — owner [[raw/branch-notes/feature-capability-provider-selection-contract]] D12 | **`internal-code-fact`(결과 모델) + `internal-policy`(정책 값 — 상속, 재진술 아님)** | 결과 종류는 코드에 있으나 **metric 분리는 없다**(`metrics.yaml` 의 cache row 2개는 `cache.gets.total`·`cache.invalidations.total` 뿐, schema/corrupt 축 없음). quarantine 동작도 미구현 — 현재는 `IncompatibleSchema` 를 돌려줄 뿐 손상 키를 지우지 않는다 |
|
||||
| D13 | region 마다 (1) 최대 encoded bytes, (2) 최대 decoded bytes, (3) 최대 collection element 수, (4) 최대 압축비, (5) encode/decode deadline 5종 상한을 **필수**로 둔다. 압축은 임계 이상에서만 opt-in 하고 decompression bomb 를 막기 위해 decoded size 와 비율을 먼저 제한하며, secret 과 공격자 제어 값을 같은 압축 컨텍스트에 섞지 않는다 | cache 의 oversize → cache write 만 `REJECTED_TOO_LARGE` 로 만들고 **source 결과 자체는 실패시키지 않는다**. session·idempotency response 의 oversize → 해당 capability 계약에 따라 fail closed | `internal-code-fact`: `RedisRuntimeSettings.maximumValueBytes`(1..16MiB), `maximumQueuedCommands`(1..4096), `maximumInFlightBytes`(≤256MiB) + `maximumRetainedCommandBytes` 교차 검증이 있고, `LettuceRedisRuntime.get()` 이 bounded Lua `GETRANGE` 로 wire bulk reply 를 상한+1 byte 로 잘라 `CA_VALUE_TOO_LARGE` 를 던진다. `internal-design-doc` §12.4 | **`internal-code-fact`(1·5의 일부) + `internal-design-doc`(5종 규범)** | **압축 자체가 미구현이므로 (4) 최대 압축비는 검증할 대상이 없다.** (2)(3)(5)도 코드에 없다 — decoded 상한은 encoded 상한으로 대신하고 있고 collection API 와 encode deadline 은 존재하지 않는다. oversize 의 role 별 비대칭은 session·idempotency branch 착수 전까지 미검증 |
|
||||
| D14 | cache codec 을 session·idempotency·rate/lease 에 그대로 재사용하지 않는다. session 은 allowlist 된 security/session attribute 스키마와 rolling 호환성을, idempotency 는 request fingerprint metadata 와 bounded response codec 을, rate/lease 는 고정 primitive 스키마를 쓰며 **임의 객체 직렬화를 두지 않는다** | 재생성 가능한 값 → cache codec. 인증 상태·중복 방지 상태·카운터 → 각 capability 전용 스키마 | `internal-design-doc` §12.5(규범 서술). **메커니즘 근거는 `internal-code-fact`**: as-built envelope 은 (a) `sourceRevision` 을 **필수**로 요구하고(`encode()` 가 blank·>128자·>512 byte 를 거부), (b) payload 를 **단일 String** 으로 전제하며(`Positive(String value, …)`), (c) `AuthoritativeAbsence` 기반 negative entry 개념을 내장한다(`RedisCacheEnvelopeCodec.java:27-39,106-119`). session attribute allowlist·idempotency request fingerprint·rate counter 는 이 세 전제 중 어느 것도 만족하지 않는다 | **`internal-code-fact`(재사용 불가의 메커니즘) + `internal-design-doc`(규범)** | 각 스키마의 실제 필드는 #069·#070·#071 이 정한다. 본 branch 는 "cache codec 재사용 금지"라는 **경계만** 소유하므로, 그 branch 들이 편의상 cache codec 을 재사용하면 이 경계를 강제할 정적 장치가 현재 없다 |
|
||||
| D15 | deployment 하나는 `standalone`·`sentinel`·`cluster` 중 **정확히 하나**를 고른다. host list 가 비었거나 두 topology field 가 동시에 설정되면 startup failure 다. Spring binder 가 sealed subtype 을 자동 판별한다고 가정하지 않고 **binding model(concrete `@ConfigurationProperties`)과 검증된 runtime model(immutable sealed)을 분리**해 factory 가 discriminator 와 exactly-one nested property 를 검증한다 | **topology 판별**: 단일 노드 용량으로 충분하고 HA 가 불필요(local·CI·외부 HA 를 제공하는 managed endpoint) → `standalone`. HA 는 필요하지만 key space·throughput 이 한 노드에 들어감 → `sentinel`. key space 또는 throughput 이 한 노드를 넘음 → `cluster`(이때 D7 의 hash tag co-location 이 **필수**가 되고 D19 의 replica opt-in 이 실제 선택지가 된다). 선택 이후의 귀결 — Cluster 는 database `0` 고정(`REDIS-CLUSTER-C4`), Sentinel 은 discovery 채널과 data-node 채널의 credential·trust material 을 **각각** 표현하고 production 에서 둘 다 검증. **`UNSUPPORTED_DECISION`(임계값)**: "한 노드에 들어간다"의 구체 임계(메모리·QPS)는 근거 없음 — workload 측정 후 확정 | `REDIS-CLUSTER-C4`(Cluster 는 database `0` 만 지원, `SELECT` 불가). `internal-design-doc` §27.1·§32.2. `internal-code-fact`: as-built 는 standalone 전용이다 — `LettuceRedisRuntime.redisUri()` 가 `RedisURI.Builder.redis(host, port)` 만 만든다 | **`official-vendor-doc`(Cluster DB 제약) + `internal-design-doc`(sum type 배선)** | Sentinel·Cluster 는 **코드에 전혀 없다**(`planned`). Cluster 진입 시 D7 의 hash tag co-location 이 실제로 필요해지므로 D7 의 미구현 부분과 함께 열린다. Lettuce 의 topology refresh(periodic/adaptive) 설정은 이번 회차 공식 근거 수집에 **실패**했다 — §Audit `LETTUCE_TOPOLOGY_REFRESH_UNSOURCED` |
|
||||
| D16 | timeout 을 DNS/connect · TLS handshake · pool acquire · command · capability overall deadline · blocking command · shutdown **7개 축으로 분리**하고 하나의 global timeout 으로 합치지 않는다. production 설정은 모두 유한 상한을 요구하며 `capability overall deadline ≤ caller deadline`, `lease wait + work budget < caller deadline`, `blocking timeout < connection lifecycle timeout` 관계를 startup 이 검증한다 | 정확한 기본값 → workload SLO 로 조정. 그러나 "무한/미설정" 은 어떤 축에서도 production 에서 허용하지 않는다 | `LETTUCE-CO-C3`(`raw/official-docs/client-options-disconnected-behavior-queue-lettuce-official.md` — `requestQueueSize` 기본값이 `Integer.MAX_VALUE` 이고 per-connection 이며 초과 시 `RedisException`). 축 분리 목록은 `internal-design-doc` §31.6. `internal-code-fact`: as-built 는 **command timeout 1축**뿐(`RedisRuntimeSettings.commandTimeout`, 기본 2s, ≤30s) | **`official-vendor-doc`(무한 기본값의 위험) + `internal-design-doc`(7축 분해)** | as-built 는 `shutdownTimeout` 을 `commandTimeout` 으로 **재사용**한다(`LettuceRedisRuntime` 필드). 즉 7축 중 실질 2축이 하나의 값에 묶여 있어, command timeout 을 낮추면 shutdown 이 함께 짧아진다. 나머지 5축은 `planned` |
|
||||
| D17 | 일반 non-blocking command 는 thread-safe shared native connection 을 쓴다. blocking `XREAD`·Pub/Sub·connection affinity 가 필요한 transaction·장기 maintenance·stateful command mode 는 **전용 connection/pool** 을 쓰며, blocking operation 이 일반 cache/rate connection 을 점유하지 못하게 한다. 실질 제어 축은 connection 개수가 아니라 **in-flight/queue bound** 다 | 일반 command → shared. 위 5종 → 전용. pool 크기 조정보다 in-flight 상한 조정을 먼저 한다 | `LETTUCE-CO-C3`(`requestQueueSize` 는 **per-connection** 단위), `LETTUCE-CO-C4`(Cluster 는 여러 커넥션을 쓰므로 전체 큐 상한이 `requestQueueSize * ((number of cluster nodes * 2) + 1)` 로 배수화). `internal-design-doc` §31.5 | **`official-vendor-doc`** — queue 가 connection 단위라는 점이 "connection 수 = heap 배수"의 직접 근거 | as-built 는 **단일 connection** 이고 blocking/Pub/Sub 사용처가 아직 없다(`planned`). Streams·Pub/Sub 은 본 branch 범위 밖(§26 경계)이므로 전용 connection 계약은 그 사용처가 생길 때 검증된다 |
|
||||
| D18 | reconnect 중 command 를 무제한 버퍼링하지 않는다. `disconnectedBehavior = REJECT_COMMANDS`, 유한한 `requestQueueSize`, driver-level pending replay 억제를 연결 생성 **전에** 고정하고, admission 포화는 `OVERLOADED` 로 즉시 거부한다. retry-safe 연산도 driver replay 가 아니라 capability wrapper 가 total deadline 안에서 새 invocation 으로 재시도한다 | `GET`/`TTL`·desired-absent delete → deadline 안 bounded retry. `INCR`/token consume·idempotency claim·lease acquire → operation dedup·owner token 없이 자동 replay 금지 | `LETTUCE-CO-C1`("As soon as a connection gets closed/reset without the intention to close it, the client will try to reconnect, activate the connection and re-issue any queued commands."), `LETTUCE-CO-C2`(REJECT_COMMANDS = 항상 즉시 거부), `LETTUCE-CO-C3`(기본 큐 무제한), `LETTUCE-CO-C5`(기본 at-least-once — "commands may be duplicated but not lost"). `internal-code-fact`: `LettuceRedisRuntime.clientOptions()` 가 `autoReconnect(true)`·`replayFilter(ignored -> true)`·`REJECT_COMMANDS`·유한 `requestQueueSize`·`TimeoutOptions` 를 명시 설정하고, `RedisCommandAdmission` 이 count+byte 이중 semaphore 로 즉시 거부 | **`official-vendor-doc` + `internal-code-fact`** — 본 branch 에서 **가장 근거가 강한 row** (공식 기본값 + as-built 반대 설정이 모두 확인됨) | `replayFilter` 의 semantics 는 **공식 문서 인용을 확보하지 못했다** — fetch 결과에 verbatim 이 없어 폐기됨(§Audit `LETTUCE_REPLAY_FILTER_UNSOURCED`). 즉 "replay 억제"의 *의도*는 코드에 있으나 *그 API 가 그렇게 동작한다*는 벤더 진술이 없다. Lettuce 버전 업 시 semantics 변경을 잡는 compatibility test 가 필요하다 |
|
||||
| D19 | coordination·session·idempotency·lease·rate 는 **primary read 전용**이다. cache 도 기본은 primary 이며 stale 을 명시적으로 허용한 region 만 replica read 를 opt-in 하고, 그 region descriptor 에 "무효화 직후 옛 replica 값을 읽을 수 있음"을 적는다 | replica read 는 latency·확장 옵션이지 read-your-write 보장이 아니다 → 정확성이 걸린 role 은 어떤 경우에도 opt-in 하지 않는다 | `REDIS-REPL-C1`("Redis uses asynchronous replication, with asynchronous replica-to-master acknowledges of the amount of data processed."), `REDIS-REPL-C2`(초기 동기화 중에도 옛 데이터셋으로 쿼리 처리 가능), `REDIS-REPL-C3`("...it is not possible to ensure the replica actually received a given write, so there is always a window for data loss."), `REDIS-REPL-C4`(replica 는 2.6 이후 기본 read-only), `REDIS-REPL-C5`(`WAIT` 조차 CP·strong consistency 를 주지 않으며 acknowledged write 가 failover 로 유실될 수 있음) | **`official-vendor-doc`** — 5개 claim 이 모두 벤더 진술이고 D19 의 금지 방향과 정확히 일치 | as-built 에 replica·read preference 개념이 **없다**(`planned`). 또한 REPL-C3·C5 는 primary-only 로도 유실 창이 남는다는 뜻이다 — D19 는 stale read 를 막을 뿐 **failover 유실을 막지 못한다**. 그 한계를 capability card 에 적는 책임은 [[raw/branch-notes/feature-capability-provider-selection-contract]] D9 |
|
||||
| D20 | production 의 role 별 Redis 연결은 TLS 를 켜고 신뢰 CA 를 명시하며 plaintext downgrade 를 금지한다. 서버 측은 `port 0` + `tls-port` 로 비-TLS 포트를 닫고, replication 은 `tls-replication`, Cluster bus 는 `tls-cluster` 로 **채널마다 따로** 켠다. `trust-all` 과 검증 완화는 local 전용이며 production startup 에서 거절한다 | local·CI → 완화 허용(명시 플래그). production·staging → 거절. Sentinel 사용 시 discovery 채널과 data-node 채널의 trust material 을 각각 검증 | `REDIS-TLS-C1`(TLS 는 Redis 6부터의 **컴파일 타임 옵션**이며 기본 활성이 아님), `REDIS-TLS-C2`("You may specify `port 0` to disable the non-TLS port completely."), `REDIS-TLS-C3`(기본 mutual TLS + 신뢰 루트 CA 검증, `tls-auth-clients no` 로 완화 가능), `REDIS-TLS-C4`(`tls-replication yes` 필요), `REDIS-TLS-C5`(`tls-cluster yes` 필요), `REDIS-TLS-C6`(TLS 는 처리량을 낮춤) | **`official-vendor-doc`** — 단, **hostname verification 부분만 `UNSUPPORTED_DECISION`** | **`UNSUPPORTED_DECISION`(부분)**: 이 공식 문서는 "hostname verification" 을 한 번도 언급하지 않는다(source 노트의 self-grep 0건). hostname 검증은 *클라이언트* 책임이며 Redis 서버 문서가 다룰 주제가 아니다 — Lettuce/Spring Data Redis 공식 문서를 추가 수집해야 승급한다. trade-off: 그때까지 "hostname verification 필수"는 **일반적 TLS 관행에 기댄 내부 정책**으로만 유지한다. 또한 as-built 에 SSL 설정이 **0건**이다(`grep withSsl\|verifyPeer` → 0) |
|
||||
| D21 | role 마다 별도 named ACL user(`cache-runtime`/`coordination-runtime`/`session-runtime`/`program-deployer`/`operator-readonly`)를 두고, `reset` 으로 무권한 상태에서 시작해 필요한 command category 와 key pattern 만 부여한다. default user 는 production 에서 비활성화한다. ACL key pattern 과 애플리케이션 key builder 는 **같은 versioned prefix registry** 에서 생성되어야 하며 hash tag·user input 으로 prefix 를 탈출할 수 없어야 한다 | Function provisioning 계정과 runtime 계정을 분리한다(`program-deployer` vs `*-runtime`) — 상세는 [[raw/branch-notes/feature-redis-atomic-program-catalog-contract]] 소유. ACL 카테고리는 Redis 버전업으로 확장될 수 있으므로 allowlist + **negative** integration test 를 쓴다 | `REDIS-ACL-C1`(ACL 은 연결 단위로 실행 가능 command 와 접근 가능 key 를 제한), `REDIS-ACL-C2`(`~<pattern>` glob key pattern, 복수 지정 가능), `REDIS-ACL-C3`("* There are no key patterns that the user can access." — 새 user 는 기본 무권한), `REDIS-ACL-C4`(`+@<category>`/`@all` 의미), `REDIS-ACL-C5`(`reset` 이 `-@all` 포함 초기화 수행), `REDIS-ACL-C7`(`@admin` 이 `CONFIG`·`DEBUG`·`SAVE`·`MONITOR`·`ACL`·`SHUTDOWN` 포함) | **`official-vendor-doc`** — 메커니즘 전부가 벤더 진술 | **과대해석 주의**: `REDIS-ACL-C6` 은 공식 문서가 보안(goal 1)과 운영 안전성(goal 2)을 **병렬 목적**으로 제시할 뿐, "ACL 은 보안 경계가 아니라 실수 방지용"이라는 이분법을 지지하지 **않는다**. 그렇게 인용하면 overclaim 이다. 또한 as-built 는 ACL username 을 쓰지 않는다 — `LettuceRedisRuntime.redisUri()` 가 `withPassword` 만 호출하므로 현재는 default user 접속이다(`planned`) |
|
||||
| D22 | `PING` 성공은 write 가능·올바른 primary·Cluster slot coverage·persistence 정상·noeviction 여유·필요한 Function version·serializer/key 호환·ACL 권한 중 **어느 것도 보장하지 않는다**. 따라서 health 는 capability 와 role 관점으로 구성한다. **liveness 는 Redis 에 의존하지 않는다**(Redis 장애로 pod 를 반복 재시작하면 connection storm 과 failover 를 악화시킨다). readiness 는 required role 의 connection/auth/TLS·topology/primary·최소 read/write 능력·program digest·최근 성공/오류 예산·queue 포화를 본다 | optional cache backend down → **ready 유지 + `DEGRADED` 표시**. required session·idempotency down → not ready. 사용하지 않는 role → health check 자체를 만들지 않는다 | `REDIS-REPL-C2`(replica 가 초기 동기화 중에도 옛 데이터셋으로 응답 가능 — 즉 응답한다고 최신이 아니다). 나머지 구성요소 목록은 `internal-design-doc` §35.1~§35.3 | **`internal-design-doc` + `official-vendor-doc`(응답≠최신 한 조각)** | ⚠️ **owner 충돌**: [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] 의 `Required vs Optional Dependency Matrix` 가 Redis 행을 **이미 확정**해 두었다 — `startup validation = ping`, `unavailable → degraded ready (cache-aside fallback)`. D22 는 그 행의 `ping` 을 **부정**한다. `degraded ready` 부분은 일치하므로 D22 는 그 행의 *startup validation 열만 refine* 하는 관계다 — §Audit `HEALTH_MATRIX_OWNER_CONFLICT` 에서 사용자 확정 대기. health **group 구성**(어떤 indicator 가 readiness group 에 들어가는가)의 owner 는 [[raw/branch-notes/feature-capability-provider-selection-contract]] D7(`readinessImpact`)이다. 본 branch 는 *role health contributor 가 무엇을 확인하는가*만 소유한다. 현재 코드에 Redis health contributor 는 **0개** |
|
||||
| D23 | 연결 credential(ACL username · password · trust material)은 **Redis leaf 소유 SPI** 로 조달하고, resolve 결과를 `Resolved`/`TemporarilyUnavailable`/`Expired`/`InvalidReference`/`PermissionDenied` 로 구분한다. secret 값·reference 전체·provider 예외 메시지를 log/metric 에 남기지 않는다. rotation 은 **role 단위**로 수행하며 listener 는 새 version 을 알릴 뿐 event thread 에서 client 를 바꾸지 않고, role runtime 의 직렬화된 rotation coordinator 가 새 factory 검증 → traffic switch → old connection drain 을 수행한다 | **재시작 창이 허용되면 → registry 현행 값(`restart-only` / `dual-read-restart-only`)을 따르고 8단계 대신 startup re-resolve + 연결 검증만 둔다.** 무중단이 필요하면 → 8단계 프로토콜(new credential/ACL 추가 → dual-valid overlap → 새 factory/session drain → 연결·명령 test → traffic switch → old connection drain → old credential revoke → stale client alert). 한 global connection 을 즉시 끊어 모든 role 이 동시에 outage 되지 않게 **role 별로** 수행한다. event 만 믿지 않고 만료 전 bounded periodic re-resolve 를 둔다 | `REDIS-ACL-C1`(ACL 이 연결 단위 권한 — username 이 연결 credential 의 일부), `REDIS-TLS-C3`(기본 mutual TLS 가 신뢰 루트 CA 검증 인증서를 요구 — trust material 이 credential 과 함께 조달돼야 하는 이유). SPI 형태·rotation 8단계는 `internal-design-doc` §34.8·§34.9 | **`official-vendor-doc`(조달 대상이 무엇인지) + `internal-design-doc`(SPI 모양·8단계)** | ⚠️ **registry 선점**: `docs/registries/secrets-classification.yaml:92-99` 이 `APP_CACHE_REDIS_KEY_HMAC_SECRET` 에 `rotation_policy: dual-read-restart-only` 를 **이미 선언**했고 `APP_CACHE_REDIS_PASSWORD` 는 `restart-only` 다 — 즉 registry 는 이미 "무중단 아님"을 못박았다. D23 의 무중단 rotation 은 그 행의 **변경을 요구**한다. registry owner 는 [[raw/branch-notes/feature-secrets-config-source-contract]] — §Audit `SECRET_ROTATION_POLICY_DRIFT`. 또한 as-built 에 SPI 가 **0건**이다(`grep CredentialMaterialProvider` → 0) |
|
||||
| D24 | client·topology 런타임 metric 을 노출하되 tag 는 `deployment`·`role`·`capability`·`operation`·`outcome`·`topology` 로 **bounded** 하고, endpoint·key·tenant·user·session·owner token 은 **tag 로 쓰지 않는다**. metric 행 등록은 `metrics.yaml` 의 change set 절차를 경유한다 | 애플리케이션이 노출 → client·capability 축(connect/reconnect · command timeout · queued/rejected · pool acquire/saturation · MOVED/ASK · topology refresh/failure/age · sentinel failover · connection age · TLS/auth failure · NOSCRIPT · function digest mismatch · BUSY). 운영이 수집 → server INFO 축(`used_memory`·`evicted_keys`·replication lag 등) — **애플리케이션이 server INFO 전체를 고cardinality metric 으로 무분별 export 하지 않는다** | `LETTUCE-CO-C3`(queue 초과 시 `RedisException` — rejected command 를 셀 수 있는 지점), `LETTUCE-CO-C4`(Cluster 전체 queue 상한이 노드 수에 배수 — saturation 을 노드 축으로 봐야 하는 근거), `REDIS-CLUSTER-C5`·`C6`(MOVED = 영구 재배치 / ASK = 일회성 — 두 리다이렉션을 **구분해** 세야 하는 근거). 목록·tag 규칙은 `internal-design-doc` §35.4·§35.10 | **`official-vendor-doc`(개별 계측 지점의 의미) + `internal-design-doc`(목록·tag 집합)** | `metrics.yaml` 행의 owner 는 [[raw/branch-notes/feature-metrics-alerting-contract]] 이고 cardinality bound 표도 그 branch 소유다 — 본 branch 는 **어떤 축을 측정할지** 만 정하고 행 등록은 그 branch 절차를 경유한다. 현재 registry 의 Redis 관련 행은 `cache.gets.total`·`cache.invalidations.total` **2개뿐**이라 위 축을 담을 자리가 0이다. metric 이름 자체는 `internal-design-doc` 의 제안형(`redis.capability.*`)이며 Micrometer dot.case + unit suffix 규약과의 정합은 미검증 |
|
||||
| D25 | role 마다 persistence·replication **기대치**를 선언한다 — `cache` = persistence optional / replica read optional / "재생성 가능, 유실 허용", `coordination` = 명시적 AOF·RPO / primary+replica / "저지연 상태이나 **유실은 여전히 가능**", `session` = 제품 RPO 에 맞는 AOF·HA / primary+replica / "세션 연속성은 best effort, 복구는 재인증". **"AOF + replica = 절대 유실 없음"은 금지 문구다** | 이 기대치는 IaC 가 무엇을 provisioning 해야 하는지의 **입력 스펙**이다 — 값 자체(어떤 RPO 인가)는 배포가 정하고(D4), **어떤 claim 을 만족해야 하는가**는 본 branch 가 정한다. 기대치를 만족하는지 확인 불가하면 D4 의 3번째 분기(guarantee 미주장)로 떨어진다 | `REDIS-REPL-C3`("...it is not possible to ensure the replica actually received a given write, so there is always a window for data loss."), `REDIS-REPL-C5`(`WAIT` 조차 CP·strong consistency 를 주지 않으며 acknowledged write 가 failover 로 유실될 수 있음) — **금지 문구의 직접 근거**. 3-row 기대치 표는 `internal-design-doc` §28.7 | **`official-vendor-doc`(금지 문구) + `internal-design-doc`(role 별 기대치 표)** | 기대치를 *선언*할 뿐 강제할 장치가 없다 — D4 의 attestation 형식이 `UNSUPPORTED_IMPL_DECISION` 이므로 "coordination role 이 실제로 AOF 인가"를 검증할 방법이 확정되지 않았다. 또한 `REDIS-REPL-C3`·`C5` 때문에 **어떤 설정으로도 유실 창은 남는다** — 이 한계를 capability card 에 적는 책임은 [[raw/branch-notes/feature-capability-provider-selection-contract]] D9 |
|
||||
| D26 | Redis 런타임의 startup 과 graceful shutdown 을 **순서 불변식**으로 고정한다. startup 은 config 검증 → secret 조달 → client resources → topology/connect/auth → program/schema → role attestation → provider binding → health/readiness → background 작업 순이며 **background task 를 connection 검증 전에 시작하지 않는다**. shutdown 은 readiness off → background 중단 → 신규 long operation 중단 → in-flight bounded wait → owner-safe lease release(best effort) → session save → Pub/Sub·stream listener stop → 전용 connection close → shared client resources close 순이며 **release 응답이 없다고 key 를 blind delete 하지 않는다** | 개별 검증(D3 co-location · D15 topology · D16 timeout 관계 · D20 TLS · D21 ACL · D22 health · D23 secret)이 *무엇을* 검사하는지는 각 D-row 가 정하고, 본 D26 은 *어떤 순서로* 실행되는지만 정한다. 두 축을 섞지 않는다 | `internal-design-doc` §36.1(startup 9단계)·§36.2(shutdown 9단계). `internal-code-fact`: as-built 는 `LettuceRedisRuntime.close()` 가 `connection.close()` → `client.shutdown(0, shutdownTimeout)` 2단계뿐이고 Spring `@Bean(destroyMethod = "close")` 에 위임한다 — background 작업·lease·session 이 없으므로 현재는 이 2단계로 충분하지만 D23·#069~#071 이 들어오면 부족하다 | **`internal-design-doc`** — 외부 공식 근거 없음(프레임워크 lifecycle 정책) | 앱 **전체**의 lifecycle phase 순서(web server 를 먼저 멈춘다 등)는 [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] 소유다 — D26 은 그 안에서 **Redis 런타임 내부** 순서만 정한다. 두 순서가 모순되면(예: readiness off 시점) 그 branch 가 우선한다. 순서를 강제할 test 는 없다 — 단계를 건너뛰어도 현재는 검출되지 않는다 |
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
> 3-rule meta principle 적용 — R1 각 sub-section 은 Decision ID + Claim ID reference, R2 근거 없는 detail 은 `UNSUPPORTED_IMPL_DECISION` + trade-off 한 줄, R3 본 branch 결정 범위 밖은 이관.
|
||||
>
|
||||
> 코드 위치는 ca-tmpl repo 기준 상대 경로다. **본 branch 는 계약 SSOT 이고 코드 작성은 ca-tmpl plan 이 소유한다** — 아래 클래스·경로 중 `src/` grep 으로 확인한 것만 `actually-implemented` 로 표기했고 나머지는 전부 `planned` 다.
|
||||
>
|
||||
> ⚠️ **ground truth 경로**: 실제 최신 ca-tmpl 은 `/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/` 다. `/home/donghyeon/workspace/ca-tmpl/` 은 2026-07-26 시점 사본이며 아래 Redis 클래스 대부분이 **없다** — §Audit `GROUND_TRUTH_PATH_DRIFT` 참조.
|
||||
|
||||
### 1. Role · deployment · binding 모델
|
||||
|
||||
> **Trace**: D1(role = physical deployment) · D2(role binding) · D3(co-location 거부) · D4(정책 소유 경계) · `REDIS-EVICT-C1`·`REDIS-EVICT-C2` · `REDIS-CLUSTER-C4` · `REDIS-ACL-C7`
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: **attestation 형식**(managed service 가 `CONFIG GET` 을 막았을 때 role policy 를 증명하는 문서의 서명 주체·형식·만료). 후보 — (a) 배포 저장소의 서명된 YAML, (b) provider 콘솔 스크린샷 + 승인자 기록, (c) 외부 conformance job 의 정기 리포트. trade-off — (a)는 검증 자동화가 쉽지만 실제 인스턴스 상태와 어긋나도 알 수 없고, (c)는 실측이지만 운영 인프라를 추가로 요구한다. **어느 쪽도 근거 없음** — 확정 전까지 attestation 부재는 "해당 role 의 guarantee 미주장"으로 처리한다(D4 의 3번째 분기).
|
||||
|
||||
| 항목 | 값 | 등급 |
|
||||
|---|---|---|
|
||||
| role 어휘 | `cache` · `coordination` · `session` (3 필수). rate-limit·stream 은 noisy-neighbor 발생 시 **추가** 분리 후보 | `planned` |
|
||||
| role → deployment | `roles.<role>.deployment = <deploymentId>` — 각 role 은 deployment 정확히 1개를 참조 | `planned` |
|
||||
| deployment 식별자 | 예시 `cache-main` / `coordination-main` / `session-main` (`internal-design-doc` §32.1 의 starter example — **production 이름 규약이 아님**) | `planned` |
|
||||
| role 기본 eviction 기대값 | `cache` = `allkeys-lfu`(또는 검증된 `allkeys-lru`), `coordination`·`session` = `noeviction` (`REDIS-EVICT-C2` 가 `noeviction` write 실패 시맨틱의 근거) | `planned` |
|
||||
| runtime 분리 단위 | deployment ID 마다 client · client resources/event loop · connection factory · credential/TLS material · metrics scope · lifecycle | `planned` |
|
||||
| 전역 `@Primary` factory | **금지** — role-qualified factory 만 허용. Boot 의 Redis auto-configuration 은 배제하거나 조건을 좁혀 unqualified `RedisConnectionFactory` 가 생기지 않게 한다 | `planned` |
|
||||
| as-built 격차 | role 개념이 **전혀 없다**. `RedisCacheAdapterConfig.redisStringCacheRegion()` 이 capability 를 `"cache"`, hv/kv 를 `1`/`1`, kind 를 `"entry"`, maximumKeyBytes 를 `512` 로 하드코딩 | `internal-code-fact`(부재·하드코딩 확인 — **구현됨이 아님**) |
|
||||
|
||||
**incompatible co-location 거부 규칙** (D3 — production startup fail):
|
||||
|
||||
| # | 거부 조합 | 왜 |
|
||||
|---|---|---|
|
||||
| 1 | evictable cache + session | 한 인스턴스는 `maxmemory-policy` 를 하나만 갖는다(`REDIS-EVICT-C1`). cache 가 eviction 을 유발하면 session 도 같은 정책 아래 놓인다 |
|
||||
| 2 | evictable cache + idempotency | 동일. 중복 방지 record 가 임의 축출되면 계약이 무너진다 |
|
||||
| 3 | evictable cache + fenced coordination | 동일. lease·fencing counter 축출은 정확성 손실 |
|
||||
| 4 | replica-read cache + primary-only correctness capability | `REDIS-REPL-C1`·`REDIS-REPL-C3` — replica 는 비동기이고 유실 창이 있다 |
|
||||
| 5 | 상호 모순된 persistence/eviction attestation | 같은 deployment 가 두 role 의 서로 다른 보장을 동시에 주장할 수 없다 |
|
||||
|
||||
local profile 은 `allow-unsafe-colocation=true` 를 **명시**할 때만 한 컨테이너를 공유하고 readiness 를 강등한다. 강등 등급의 어휘 owner 는 [[raw/branch-notes/feature-capability-provider-selection-contract]] D8 이다(재진술 아님, 포인터만).
|
||||
|
||||
**배포 소유 경계** (D4): 애플리케이션은 `CONFIG SET` 을 실행하지 않는다. `maxmemory` · eviction policy · AOF/RDB · replica · Sentinel/Cluster 구성 · TLS 종단 · backup 은 IaC/managed service 가 소유한다. `REDIS-ACL-C7` 이 `@admin`(= `CONFIG`·`DEBUG`·`SAVE`·`SHUTDOWN` 포함)을 "일반 애플리케이션은 쓸 일이 없다"로 서술하므로, D21 의 ACL 에서 `@admin` 을 부여하지 않는 것으로 이 경계를 **강제**한다.
|
||||
|
||||
### 2. Key model 계약
|
||||
|
||||
> **Trace**: D5(단일 builder·bounds) · D6(digest) · D7(hash tag) · D8(version 3축) · D9(대량 무효화) · `REDIS-CLUSTER-C1`·`C2`·`C3` · `REDIS-ACL-C7`
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: **`kind` allowlist 의 실제 값 집합**(`entry`/`bucket`/`record`/`owner` 외에 무엇을 허용할지). `internal-design-doc` §11.1 의 예시 4개는 *예시*이고 규범 목록이 아니다. trade-off — 좁게 잡으면 capability branch 마다 본 branch 를 고쳐야 하고, 넓게 잡으면 allowlist 가 검증 장치로서 의미를 잃는다. **capability branch(#067~#071)가 각자 kind 를 등록하는 개방형 registry** 가 절충안이나 근거는 없다.
|
||||
|
||||
canonical shape (`actually-implemented` — `adapter/outbound/cache-redis/.../redis/key/RedisKeyBuilder.java`):
|
||||
|
||||
```text
|
||||
ca:<app>:<env>:<capability>:<region>:hv<hashKeyVersion>:kv<keyVersion>:{<slotTag>}:<resourceDigest>:<kind>
|
||||
```
|
||||
|
||||
| 검증 항목 | as-built | 목표 |
|
||||
|---|---|---|
|
||||
| 전체 UTF-8 byte 길이 | ✅ `maximumKeyBytes` 초과 시 예외 (`1..4096`, cache 는 `512`) | 유지 |
|
||||
| slug 문법 | ✅ `[a-z][a-z0-9-]{0,62}` (app·env·capability·region·kind) | 유지 |
|
||||
| hv/kv 범위 | ✅ `1..9999`, namespace 와 digest 의 `hv` 일치 강제 | 유지 |
|
||||
| digest 형식 | ✅ slotTag `[0-9a-f]{8}`, resourceDigest `[0-9a-f]{64}` | 유지 |
|
||||
| hash tag 정확히 1개 | ❌ 없음 | `planned` |
|
||||
| user input 의 `{`/`}` 유입 차단 | ❌ 없음 (digest 를 거치므로 현재는 구조적으로 불가하나 **명시 검증은 없음**) | `planned` |
|
||||
| capability 별 kind allowlist | ❌ 없음 | `planned` (값 집합은 위 `UNSUPPORTED_IMPL_DECISION`) |
|
||||
| builder 우회 금지 정적 강제 | ❌ 없음 | `planned` — ArchUnit rule 후보 |
|
||||
|
||||
**bounds 위반은 fail-open 대상이 아니다** (D5). 잘못된 key 입력은 backend outage 가 아니라 프로그래밍 결함이며, project note §11 의 "cache codec·프로그래밍 결함 = fail closed" 행에 해당한다(owner [[raw/branch-notes/feature-capability-provider-selection-contract]]).
|
||||
|
||||
**digest 선택** (D6 — **함수는 `actually-implemented`, 선택 배선은 `planned`**, `redis/key/RedisKeyDigest.java`). ⚠️ production call site 는 현재 **항상 `sensitive()`** 다(`RedisStringCacheRegion.physicalKey()`); `opaque()` 는 테스트에서만 호출된다 — 즉 "식별자 성격에 따라 고른다"는 규칙 자체는 아직 배선되지 않았다:
|
||||
|
||||
| 입력 성격 | 함수 | 알고리즘 |
|
||||
|---|---|---|
|
||||
| 이미 random opaque ID, 노출 위험 낮음 | `RedisKeyDigest.opaque(hv, components)` | SHA-256 |
|
||||
| 사용자·tenant·email·IP 등 사전 공격 가능 | `RedisKeyDigest.sensitive(hv, secret, components)` | HMAC-SHA-256 (secret ≥ 32 byte) |
|
||||
| composite scope | 위 둘 중 성격에 맞는 쪽 | 공통 `canonicalComponents()` = `putInt(len) + bytes` **length-prefixed** 인코딩 후 해싱 |
|
||||
|
||||
단순 문자열 delimiter join 은 ambiguity 때문에 금지한다 — as-built 가 이미 length-prefix 로 이를 배제한다. `slotTag` 는 digest hex 의 앞 8자다.
|
||||
|
||||
**미구현 (`planned`)**: HMAC key rotation. `internal-design-doc` §11.2 는 `dual-read-delete`(재생성 가능 data 만) / `cold-cutover`(idempotency·lease·strict rate) / conservative overlap(rate) / `fixed`(unkeyed digest) 4 mode 와 `RedisKeyDigestMaterialProvider` SPI 를 규정하지만 코드에 **없다**. 현재는 `app.cache.redis.key-hmac-secret` 단일 값 + `hv` 하드코딩 `1` 이므로 **secret 교체 = 전체 key 무효화**다.
|
||||
|
||||
**version 3축** (D8):
|
||||
|
||||
| 축 | 위치 | 바꾸면 |
|
||||
|---|---|---|
|
||||
| key schema version | key 의 `kv<n>` (`actually-implemented`, 값은 `1` 고정) | 물리 레이아웃·namespace |
|
||||
| payload schema version | ⚠️ **envelope version byte 를 겸용 중** — codec 식별자와 payload version 이 분리되지 않았다. 별도 축은 `planned` (§구현 가이드 3 표 · §Audit A3) | 같은 key 의 decode 호환성 |
|
||||
| policy revision | **없음** (`planned`) | state 해석(TTL·rate 파라미터). rate-limit key 에는 **포함**한다 |
|
||||
|
||||
**대량 무효화** (D9) — 요청 경로에서 pattern delete 금지. 선택지는 (1) key schema/version bump, (2) region generation ID 교체, (3) known-key bounded batch, (4) operator maintenance 의 rate-limited `SCAN`+`UNLINK`. (2)의 generation key 가 evict 되면 `0` 으로 되돌아가 옛 namespace 를 부활시키므로, missing 이면 새 random 128-bit generation 을 `SET NX` 로 초기화하고 loser 는 winner 값을 읽는다. 전부 `planned`.
|
||||
|
||||
### 3. Payload · envelope · codec
|
||||
|
||||
> **Trace**: D10(byte[] 원칙) · D11(envelope) · D12(호환성·손상) · D13(크기·압축) · D14(codec 분리) · `internal-code-fact`
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: **`CacheCodec<T>` SPI 의 최종 시그니처**. `internal-design-doc` §12.1 이 `schemaId()`/`writeVersion()`/`encode()`/`decode(storedVersion, payload)` 4-method 형태를 제시하지만 이는 설계 문서의 제안이며 외부 근거가 아니다. trade-off — `decode` 가 `DecodeResult<T>` 를 돌려주면 D12 의 `SCHEMA_MISMATCH`/`CORRUPT` 구분을 codec 층에서 표현할 수 있으나 sealed 타입이 하나 더 늘고, 예외로 표현하면 타입은 단순하지만 결함과 outage 를 호출부가 다시 구분해야 한다. **as-built(`RedisCacheEnvelopeCodec.Decoded` sealed)는 전자에 가깝다** — 정합만 보면 전자가 우세하나 결정 근거는 없다.
|
||||
|
||||
envelope 필드 — 목표 vs as-built:
|
||||
|
||||
| 필드 | 목표(`internal-design-doc` §12.2) | as-built (`redis/RedisCacheEnvelopeCodec.java`) |
|
||||
|---|---|---|
|
||||
| magic | 필수 | ✅ `0x43414348` |
|
||||
| envelope version | 필수 | ✅ 1 byte, `VERSION = 1` |
|
||||
| codec 식별자 | 필수 | ❌ 없음 |
|
||||
| payload version | 필수(envelope version 과 별도 축) | ❌ envelope version 과 미분리 |
|
||||
| flags(negative/compressed) | 필수 | ⚠️ negative 는 **type byte** 로 표현, compressed flag 없음 |
|
||||
| source revision | optional | ✅ 1..128자 / ≤512 byte |
|
||||
| writtenAt | 필수 | ❌ 없음 |
|
||||
| soft expiry | optional | ❌ 없음 |
|
||||
| **hard expiry** | 필수 | ❌ 없음 — **현재 만료 판정의 유일한 근거가 Redis TTL 이고 clock/TTL drift 를 관측할 수단이 없다** |
|
||||
| payload length | 필수 | ✅ `int` |
|
||||
| payload digest | 필수 | ✅ SHA-256 접미, 불일치 시 `CORRUPT_ENVELOPE` |
|
||||
|
||||
**결과 모델** (D12 — `actually-implemented`, `application-core/.../cache/CacheLookup.java`): `Hit` / `Miss` / `NegativeHit` / `IncompatibleSchema(SchemaCategory)` / `Unavailable(reason, certainty)` sealed 계층이 이미 miss 와 outage 와 결함을 분리한다. `SchemaCategory` 는 `FUTURE_VERSION` / `RETIRED_VERSION` / `CORRUPT_ENVELOPE` / `UNKNOWN_ENVELOPE`.
|
||||
|
||||
**서버 정책 거부 축** (D12·D3·D4·D21) — 결과 타입이 3개(`CacheLookup` / `CacheRecordOutcome` / `CacheInvalidationOutcome`)이므로 각각에 매핑한다. as-built 는 이 축을 **결과 모델 밖으로 내보낸다**(`LettuceRedisRuntime.execute()` 가 `RedisCommandExecutionException` 을 그대로 rethrow):
|
||||
|
||||
| 서버 응답 | read (`lookup`) | write (`record`) | invalidate |
|
||||
|---|---|---|---|
|
||||
| `OOM command not allowed` (`noeviction` 한도 초과) — **용량 사건, 프로그래밍 결함 아님** | `Unavailable(UNAVAILABLE, NOT_APPLIED)` | `DEGRADED_UNAVAILABLE` | `DEGRADED_UNAVAILABLE` |
|
||||
| `NOPERM` (ACL 미부여) — **배선 결함, fail closed** | (아래 `UNSUPPORTED_IMPL_DECISION`) | (동일) | (동일) |
|
||||
|
||||
`REDIS-EVICT-C2` 가 OOM 이 **write 에서** 발생하고 read 는 정상 동작한다고 명시하므로, read 열은 실질적으로 Lua 경유 read(`EVAL`)가 write 로 분류될 때만 발생한다.
|
||||
|
||||
> **UNSUPPORTED_IMPL_DECISION**: **`NOPERM` 을 어떤 형태로 표현할지.** 세 enum(`CacheLookup` sealed / `CacheRecordOutcome` / `CacheInvalidationOutcome`) 어디에도 "배선 결함" 멤버가 없다 — 현재 값은 `RECORDED`·`NOT_RECORDED_CONDITION`·`NOT_RECORDED_PROVIDER_POLICY`·`DEGRADED_UNAVAILABLE`·`INDETERMINATE` 뿐이다. 후보 — (a) 신규 enum 멤버 추가, (b) `DEGRADED_UNAVAILABLE` 재사용, (c) 결과가 아니라 예외로 던져 상위에서 startup/배선 오류로 처리. trade-off — (b)는 타입 변경이 없지만 **용량 사건과 배선 결함을 합쳐** D12 의 대전제("결함을 outage 로 위장하지 않는다")를 스스로 위반하고, (a)는 정확하지만 `application-core` 의 provider-neutral enum 을 Redis 특성 때문에 넓히며, (c)는 fail-closed 의미와 맞지만 cache 경로에서 예외를 던지는 유일한 사례가 된다. **(a) 또는 (c) 가 D12 와 정합하나 결정 근거는 없다.**
|
||||
|
||||
**미구현 (`planned`)**: (a) schema/corrupt 축의 **별도 metric** — `metrics.yaml` 의 cache row 는 `cache.gets.total`·`cache.invalidations.total` 2개뿐이라 D12 가 요구하는 "miss 와 다른 metric"을 표현할 자리가 없다. (b) 손상 항목의 bounded owner-safe **quarantine/evict** — 현재는 `IncompatibleSchema` 를 돌려줄 뿐 키를 지우지 않는다. (c) N-1 dual-read 와 opportunistic rewrite.
|
||||
|
||||
**크기·압축** (D13):
|
||||
|
||||
| bound | as-built | 목표 |
|
||||
|---|---|---|
|
||||
| 최대 encoded bytes | ✅ `maximumValueBytes` (`1..16_777_216`, 기본 1 MiB) | region 단위로 분리 |
|
||||
| 최대 decoded bytes | ❌ encoded 상한으로 대용 | 별도 상한 |
|
||||
| 최대 collection element 수 | ❌ (collection API 부재) | 도입 시 필수 |
|
||||
| 최대 압축비 | ❌ (압축 미구현) | 압축 도입 시 **먼저** 설정 |
|
||||
| encode/decode deadline | ❌ | `planned` |
|
||||
| wire bulk reply 상한 | ✅ bounded Lua `GETRANGE` 로 상한+1 byte 에서 `CA_VALUE_TOO_LARGE` (`LettuceRedisRuntime.get()`) | 유지 |
|
||||
|
||||
oversize 처리는 **role 별 비대칭**이다 — cache write 는 `REJECTED_TOO_LARGE` 로 만들되 source 결과 자체는 실패시키지 않고, session·idempotency response 는 해당 capability 계약에 따라 fail closed 한다. 압축은 임계 이상에서만 opt-in 하고, decompression bomb 를 막기 위해 decoded size 와 비율을 **먼저** 제한하며, secret 과 공격자 제어 값을 같은 압축 컨텍스트에 섞지 않는다.
|
||||
|
||||
**codec 분리** (D14): session(allowlist 된 attribute 스키마 + rolling 호환성) · idempotency(request fingerprint + bounded response codec) · rate/lease(고정 primitive 스키마)는 cache codec 을 재사용하지 않는다. 각 스키마의 실제 필드는 #069·#070·#071·#068 이 정한다 — 본 §는 **경계만** 소유한다.
|
||||
|
||||
### 4. Connection runtime · topology
|
||||
|
||||
> **Trace**: D15(topology sum type) · D16(timeout 7축) · D17(connection 형태) · D18(queue·replay) · D19(read routing) · `LETTUCE-CO-C1`~`C5` · `REDIS-CLUSTER-C4` · `REDIS-REPL-C1`~`C5`
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: **Spring Data Redis 도입 여부 — 본 §의 최상위 분기점.** `internal-design-doc` §31.1·§31.3 은 `spring-data-redis` + `lettuce-core` 를 기본 provider 로 규정하지만, **as-built 는 `lettuce-core` 단독이다** (`cache-redis/build.gradle` 에 `spring-data-redis` 없음, 코드도 `io.lettuce.core.RedisClient` 직접 사용). trade-off — Spring Data Redis 를 넣으면 Sentinel/Cluster 설정·Spring Session 통합·`RedisScript` 추상이 따라오지만 auto-configuration 배제 작업(§31.3)이 늘고 unqualified `RedisConnectionFactory` 위험이 생긴다. raw Lettuce 유지는 표면이 작지만 Sentinel/Cluster·Session 을 직접 배선해야 한다.
|
||||
> **선택 조건(잠정)**: Sentinel/Cluster(D15) 또는 `redis-session` 모드(#069)를 R2 범위 **안**에 넣어야 하면 → Spring Data Redis. standalone-only 를 유지하고 session 을 JWT 로 고정하면 → raw Lettuce. 이 조건은 사용자 확정 전까지 *잠정*이며 근거는 없다.
|
||||
> **파급**: 이 하나가 D15(topology 배선) · D17(connection/pool) · §10(property 구조) · §5(클라이언트 TLS 배선) · [[raw/branch-notes/feature-redis-atomic-program-catalog-contract]] D1·D2(`RedisScript`/`ScriptExecutor` 전제)를 전부 가른다 — §Audit `SPRING_DATA_REDIS_PREMISE_CONFLICT`. **확정 전까지 §4·D15·D17 착수 금지.**
|
||||
|
||||
**topology** (D15): deployment 하나는 `standalone` · `sentinel` · `cluster` 중 정확히 하나. host list 가 비었거나 두 필드가 동시에 설정되면 startup failure. binding model(concrete `@ConfigurationProperties`)과 검증된 runtime model(immutable sealed)을 분리하고, factory 가 discriminator 와 exactly-one nested property 를 검증한다. Cluster 는 database `0` 고정(`REDIS-CLUSTER-C4`). as-built 는 **standalone 전용**(`LettuceRedisRuntime.redisUri()` = `RedisURI.Builder.redis(host, port)`) — Sentinel·Cluster 는 `planned`.
|
||||
|
||||
**timeout 7축** (D16):
|
||||
|
||||
| 축 | as-built | 목표 |
|
||||
|---|---|---|
|
||||
| DNS/connect | ❌ | 유한 |
|
||||
| TLS handshake | ❌ (TLS 자체 부재) | 유한 |
|
||||
| pool acquire | ❌ | 유한 |
|
||||
| command | ✅ `commandTimeout` (기본 2s, ≤30s) | 유지 |
|
||||
| capability overall deadline | ❌ | `≤ caller deadline` |
|
||||
| blocking command | ❌ | `< connection lifecycle timeout` |
|
||||
| shutdown | ⚠️ **`commandTimeout` 을 재사용** (`LettuceRedisRuntime.shutdownTimeout`) | 별도 축으로 분리 |
|
||||
|
||||
> **UNSUPPORTED_IMPL_DECISION**: **6개 신규 축의 기본값과 property 이름.** 근거 문서(`internal-design-doc` §31.6)가 "exact default 는 workload SLO 로 조정한다"고 명시하므로 값을 **주지 않는다**. trade-off — 시작값을 박아두면 구현자가 되묻지 않지만 SLO 를 재지 않은 숫자가 관성으로 굳고, 비워두면 정직하지만 착수 시 6번 되묻는다. **절충으로 "관계 불변식만 확정하고 값은 비운다"를 택한다** — startup assertion 이 `connect/command/acquire 각각 유한`, `capability overall deadline ≤ caller deadline`, `lease wait + work budget < caller deadline`, `blocking timeout < connection lifecycle timeout` 을 검증하고, 값 자체는 배포별 override 로 둔다. property 이름은 §7 의 신규 축과 함께 `env-keys.yaml` 등록 시 확정한다.
|
||||
|
||||
**connection 형태** (D17): 일반 non-blocking command 는 thread-safe shared native connection. blocking `XREAD` · Pub/Sub · connection affinity 가 필요한 transaction · 장기 maintenance · stateful command mode 는 전용 connection/pool 을 쓰고, blocking operation 이 일반 cache/rate connection 을 점유하지 못하게 한다. 실질 제어 축은 connection 개수가 아니라 in-flight/queue bound 다 — `LETTUCE-CO-C3` 이 `requestQueueSize` 가 **per-connection** 임을, `LETTUCE-CO-C4` 가 Cluster 전체 상한이 `requestQueueSize * ((node 수 * 2) + 1)` 로 배수화됨을 명시한다. as-built 는 단일 connection(`planned`).
|
||||
|
||||
**queue · replay** (D18 — 본 §에서 **가장 근거가 강하고 as-built 정합도 가장 높은** 항목):
|
||||
|
||||
| 옵션 | Lettuce 기본값 | as-built (`LettuceRedisRuntime.clientOptions()`) | 근거 |
|
||||
|---|---|---|---|
|
||||
| `autoReconnect` | `true` + 큐 command 재발행 | `true` | `LETTUCE-CO-C1` |
|
||||
| `disconnectedBehavior` | `DEFAULT`(autoReconnect 시 accept) | **`REJECT_COMMANDS`** | `LETTUCE-CO-C2` |
|
||||
| `requestQueueSize` | `Integer.MAX_VALUE` (사실상 무제한) | **유한** (`maximumQueuedCommands`, `1..4096`, 기본 8) | `LETTUCE-CO-C3` |
|
||||
| replay 억제 | 기본 at-least-once("commands may be duplicated but not lost") | `replayFilter(ignored -> true)` | `LETTUCE-CO-C5` (단, `replayFilter` semantics 자체는 **미인용** — §Audit) |
|
||||
| admission | (없음) | `RedisCommandAdmission` count+byte 이중 semaphore, 포화 시 `OVERLOADED`/`NOT_APPLIED` | `internal-code-fact` |
|
||||
|
||||
mutation certainty 는 `RedisCommandFailureException.Certainty` 로 표현한다 — timeout·connection 실패 시 **mutation 은 `INDETERMINATE`, read 는 `NOT_APPLIED`** (`actually-implemented`). retry-safe 연산도 driver replay 가 아니라 capability wrapper 가 total deadline 안에서 새 invocation 으로 재시도한다.
|
||||
|
||||
**read routing** (D19): coordination·session·idempotency·lease·rate = primary only. cache = 기본 primary, stale 허용 region 만 replica opt-in 하고 region descriptor 에 "무효화 직후 옛 replica 값을 읽을 수 있음"을 적는다. `planned` — as-built 에 read preference 개념 없음.
|
||||
|
||||
### 5. Security — TLS · ACL
|
||||
|
||||
> **Trace**: D20(TLS) · D21(ACL) · `REDIS-TLS-C1`~`C6` · `REDIS-ACL-C1`~`C7`
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: **클라이언트 TLS 활성화 메커니즘 전반.** 아래 표는 8행 중 7행이 **서버** config 이고, 클라이언트 축(TLS 스킴 선택 `rediss://` vs `RedisURI.Builder.withSsl()`, trust material 주입 형태 keystore/truststore vs PEM, mutual TLS 용 client certificate 배선, hostname verification 토글, **production plaintext 거부를 어디서 판정하는가**)은 **하나도 정해지지 않았다**. `REDIS-TLS-C3` 가 "TLS 활성 시 기본 mutual TLS" 를 말하므로 client certificate 배선은 선택이 아니라 필수인데 명세가 없다. trade-off — Lettuce `SslOptions` 를 직접 쓰면 의존성이 늘지 않지만 keystore 조립을 직접 해야 하고, Spring Data Redis 의 SSL 지원에 기대면 배선은 짧아지나 §구현 가이드 4 의 `UNSUPPORTED_IMPL_DECISION`(Spring Data Redis 도입 여부)에 종속된다. **source 노트 자신이 "클라이언트 대응 설정은 별도 official-doc 확보 필요"라고 명시했다** — Lettuce `SslOptions`/`RedisURI` 공식 문서 수집 전까지 §5 의 클라이언트 축 전체가 근거 없음(§Sources "추가 수집 대상" 1). hostname verification 은 그중 한 항목일 뿐이다.
|
||||
> **착수 조건**: 위 공식 문서 확보 전까지 **클라이언트 TLS 축 착수 금지**. 아래 서버 축 표(`port 0`·`tls-replication`·`tls-cluster`·이미지 TLS 빌드 확인)는 근거가 확보돼 있으므로 이 게이트와 무관하게 진행 가능하다.
|
||||
|
||||
**TLS** (D20) — production 요구:
|
||||
|
||||
| 항목 | 값 | 근거 |
|
||||
|---|---|---|
|
||||
| 서버 비-TLS 포트 | `port 0` 로 완전히 닫고 `tls-port` 만 | `REDIS-TLS-C2` |
|
||||
| 클라이언트 인증 | Redis 는 TLS 활성 시 **기본 mutual TLS** 이고 신뢰 루트 CA 로 검증된 인증서를 요구. `tls-auth-clients no` 완화는 production 금지 | `REDIS-TLS-C3` |
|
||||
| replication 채널 | `tls-replication yes` **별도** 필요 | `REDIS-TLS-C4` |
|
||||
| Cluster bus | `tls-cluster yes` **별도** 필요 | `REDIS-TLS-C5` |
|
||||
| 빌드 전제 | TLS 는 Redis 6+ 의 **컴파일 타임 옵션**이므로 이미지가 TLS 빌드인지 확인이 선행 | `REDIS-TLS-C1` |
|
||||
| 성능 | TLS 는 인스턴스 처리량을 낮춘다 — capacity 산정에 반영 | `REDIS-TLS-C6` |
|
||||
| Sentinel | discovery 채널과 data-node 채널의 credential·trust material 을 **각각** 표현하고 production 에서 둘 다 검증 | `internal-design-doc` §32.4 |
|
||||
| as-built | **SSL 설정 0건** (`grep withSsl\|verifyPeer\|useSsl` → 0). 현재 plaintext + password 만 | `internal-code-fact`(부재 확인 — **TLS 는 미구현**) |
|
||||
|
||||
**ACL** (D21):
|
||||
|
||||
| 사용자 | 용도 | 부여 방식 |
|
||||
|---|---|---|
|
||||
| `cache-runtime` | cache role 의 data command | `reset` 후 필요한 category·key pattern 만 (`REDIS-ACL-C5`) |
|
||||
| `coordination-runtime` | coordination role | 동일 |
|
||||
| `session-runtime` | session role | 동일 |
|
||||
| `program-deployer` | Function library load/list/delete — **release workflow 전용** (상세 owner 는 [[raw/branch-notes/feature-redis-atomic-program-catalog-contract]]) | 분리 계정 |
|
||||
| `operator-readonly` | 운영 조회 | 분리 계정 |
|
||||
|
||||
**positive grant 초안** (`internal-code-fact` 도출 — as-built 가 실제로 쓰는 명령):
|
||||
|
||||
| user | 필요한 command | 도출 근거 |
|
||||
|---|---|---|
|
||||
| `cache-runtime` | `SET` · `DEL` · `EVAL` · `EVALSHA` + bounded-get Lua 내부의 `GETRANGE`·`EXISTS` | `LettuceRedisRuntime` 이 호출하는 전부 — `commands.set`/`commands.del`/`commands.eval`/`commands.evalsha`, 그리고 `BOUNDED_GET_SCRIPT` 본문의 `redis.call('GETRANGE', …)`·`redis.call('EXISTS', …)`. **읽기는 `GET` 이 아니라 Lua 경유다** |
|
||||
| `coordination-runtime` · `session-runtime` | 미도출 — 해당 capability(#068~#071·#069) 미구현이라 호출 명령 집합이 아직 없다 | — |
|
||||
| `program-deployer` | `FUNCTION LOAD`/`LIST`/`DELETE` 또는 `SCRIPT LOAD` — 상세는 [[raw/branch-notes/feature-redis-atomic-program-catalog-contract]] 소유 | 위임 |
|
||||
| `operator-readonly` | 미도출 | — |
|
||||
|
||||
> **UNSUPPORTED_IMPL_DECISION**: **command 단위 grant vs category 단위 grant.** 위 표는 command 단위 도출이고, `REDIS-ACL-C4` 의 `+@<category>` 는 카테고리 단위다. trade-off — command 단위는 최소 권한이 정확하지만 코드가 새 명령을 쓰는 순간 조용히 `NOPERM` 이 나고(D12 의 서버 정책 거부 경로), category 단위는 안전 여유가 있지만 의도보다 넓다. **`REDIS-ACL-C7` 이 경고하는 `@admin` 회피는 어느 쪽이든 성립**하므로 tie-break 근거가 없다.
|
||||
>
|
||||
> **검증 필요**: Lua 안에서 실행되는 `GETRANGE`/`EXISTS` 가 **호출 user 의 ACL 을 따르는지**는 수집한 공식 문서로 확인되지 않았다 — §Claims To Verify 참조. 따르지 않는다면 `cache-runtime` 의 grant 집합이 달라진다.
|
||||
|
||||
- 새 user 는 기본적으로 **접근 가능한 key pattern 이 하나도 없다**(`REDIS-ACL-C3`) — 즉 allowlist 방식이 프레임워크 기본값과 정합한다.
|
||||
- `@admin`(= `CONFIG`·`DEBUG`·`SAVE`·`MONITOR`·`ACL`·`SHUTDOWN` 등)은 어떤 runtime 계정에도 부여하지 않는다(`REDIS-ACL-C7`) — 이것이 D4 의 "애플리케이션은 `CONFIG SET` 을 실행하지 않는다"를 **강제하는 실제 장치**다.
|
||||
- ACL key pattern(`~<pattern>`, `REDIS-ACL-C2`)과 애플리케이션 key builder 는 같은 versioned prefix registry 에서 생성되어야 하고, hash tag·user input 으로 prefix 를 탈출할 수 없어야 한다 — conformance test 필요(`planned`).
|
||||
- ACL category 는 Redis 버전업으로 확장될 수 있으므로 allowlist + **negative** integration test 를 쓴다.
|
||||
- default user 는 production 에서 비활성화한다. **as-built 는 ACL username 을 쓰지 않는다**(`redisUri()` 가 `withPassword` 만) — 현재 default user 접속(`planned`).
|
||||
|
||||
> **과대해석 금지**: `REDIS-ACL-C6` 은 공식 문서가 보안 목적과 운영 안전성 목적을 **병렬로** 제시한다는 사실만 지지한다. "ACL 은 보안 경계가 아니라 실수 방지용"이라는 이분법으로 인용하면 overclaim 이다.
|
||||
|
||||
### 6. Role health contributor
|
||||
|
||||
> **Trace**: D22 · `REDIS-REPL-C2` · `internal-design-doc` §35.1~§35.3
|
||||
>
|
||||
> - **R3 OUT_OF_BRANCH_SCOPE**: **어떤 indicator 가 readiness group 에 포함되는가**는 본 branch 가 정하지 않는다 — owner 는 [[raw/branch-notes/feature-capability-provider-selection-contract]] D7(`readinessImpact`)이다. 본 §는 *role health contributor 가 무엇을 확인하는가*만 소유한다.
|
||||
|
||||
`PING` 성공이 보장하지 **않는** 것: write 가능 · 올바른 primary · Cluster slot coverage · persistence 정상 · `noeviction` 여유 · 필요한 Function version · serializer/key 호환 · ACL command 권한. `REDIS-REPL-C2`(replica 가 초기 동기화 중에도 옛 데이터셋으로 응답 가능)가 "응답한다 ≠ 최신이다"의 공식 근거다.
|
||||
|
||||
| 계층 | Redis 의존 | 확인 항목 |
|
||||
|---|---|---|
|
||||
| liveness | **없음** — Redis 장애로 pod 를 반복 재시작하면 connection storm 과 failover 를 악화시킨다 | — |
|
||||
| readiness (required role) | 있음 | connection/auth/TLS · topology/primary · 최소 read/write 능력 · program digest · 최근 성공/오류 예산 · queue 포화 · role 고유 요구 |
|
||||
| optional cache | 있음(비차단) | **ready 유지 + `DEGRADED` 표시** |
|
||||
| 미사용 role | 없음 | health check 자체를 만들지 않는다 |
|
||||
|
||||
전부 `planned` — 현재 코드에 Redis health contributor 는 **0개**다.
|
||||
|
||||
### 7. Credential 조달 · rotation
|
||||
|
||||
> **Trace**: D23 · D20 · D21 · `REDIS-ACL-C1` · `REDIS-TLS-C3` · `internal-design-doc` §34.8·§34.9
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: **SPI 를 `internal-design-doc` §34.8 형태 그대로 채택할지.** 제안형은 `RedisCredentialMaterialProvider.resolve(SecretReference)` + `subscribe(reference, listener)` 2-method 이고 값 타입은 `VersionedRedisCredentialMaterial(version, expiresAt, username, password, trustMaterial)` 이다. trade-off — 그대로 채택하면 D6 의 `RedisKeyDigestMaterialProvider` 와 대칭이 맞아 두 SPI 를 같은 bridge 로 조립할 수 있으나, username·password·trustMaterial 을 한 record 에 묶으면 trust material 만 회전하는 경우에도 전체를 재조달해야 한다. 분리하면 유연하지만 rotation coordinator 가 두 축을 동기화해야 한다. **어느 쪽도 외부 근거 없음** — 설계 문서의 제안일 뿐이다.
|
||||
> **착수 조건**: 무중단 rotation 이 실제 요구사항인지 확인 전까지 **rotation coordinator 착수 금지**(§Claims To Verify). registry 는 이미 `restart-only` 를 선언했으므로 재시작 창이 허용되면 8단계가 통째로 불필요하다. **SPI 인터페이스 정의와 resolve 결과 5분류는 선행 가능** — 어느 분기에서도 필요하다.
|
||||
|
||||
| 항목 | 계약 | 등급 |
|
||||
|---|---|---|
|
||||
| 소유 위치 | **Redis leaf 가 SPI 와 immutable value 를 소유**한다. app-bootstrap 이 환경별 구현(Vault/file/Kubernetes/managed secret)을 조립하거나 generic secret capability 를 이 SPI 에 bridge 한다. **Redis leaf 가 bootstrap 이나 특정 secret vendor 에 역의존하지 않는다** | `planned` |
|
||||
| resolve 결과 | `Resolved` / `TemporarilyUnavailable` / `Expired` / `InvalidReference` / `PermissionDenied` 를 **구분**한다 — 일시 장애와 배선 결함을 합치지 않는다(D12 와 같은 원칙) | `planned` |
|
||||
| 값 수명 | version + expiry 를 갖고 사용 후 파기 가능한 byte/char 표현으로 전달 | `planned` |
|
||||
| 누설 금지 | secret 값 · reference 전체 · provider 예외 메시지를 log/metric 에 남기지 않는다. ⚠️ 현행 fail-open logger 가 raw exception message 를 기록하는 경로(`adapter/outbound/support/FailOpenDependencyLogger`)는 endpoint·credential 누설 가능성을 검토해 classified sanitized field 만 남기도록 바꾼다 | `planned` |
|
||||
| rotation 주체 | listener 는 새 version 을 **알릴 뿐** event thread 에서 client 를 바꾸지 않는다. role runtime 의 **직렬화된 rotation coordinator** 가 새 factory 검증 → traffic switch → old connection drain 을 수행 | `planned` |
|
||||
| rotation 8단계 | new credential/ACL 추가 → client dual-valid overlap → 새 factory/session drain → 새 credential 연결·명령 test → traffic switch → old connection drain → old credential revoke → stale client alert | `planned` |
|
||||
| role 격리 | 한 global connection 을 즉시 끊어 **모든 role 이 동시에 outage 되지 않도록** role 별로 수행 | `planned` |
|
||||
| 안전망 | event 만 믿지 않고 만료 전 **bounded periodic re-resolve** 를 둔다. subscription loss · 중복/순서 뒤바뀐 event · resolve timeout · 만료 material · 부분 role rotation 을 test | `planned` |
|
||||
|
||||
⚠️ **registry 와의 충돌**: `secrets-classification.yaml` 은 `APP_CACHE_REDIS_PASSWORD` 를 `rotation_policy: restart-only`, `APP_CACHE_REDIS_KEY_HMAC_SECRET` 을 `dual-read-restart-only` 로 **이미 선언**했다 — 둘 다 "무중단 아님"이다. 위 8단계 무중단 rotation 은 그 두 행의 **변경을 요구**하며, 그 registry 의 owner 는 [[raw/branch-notes/feature-secrets-config-source-contract]] 다. 본 branch 는 단독으로 바꾸지 않는다(§Audit `SECRET_ROTATION_POLICY_DRIFT`).
|
||||
|
||||
### 8. Client · topology metric
|
||||
|
||||
> **Trace**: D24 · D18 · D15 · `LETTUCE-CO-C3`·`C4` · `REDIS-CLUSTER-C5`·`C6` · `internal-design-doc` §35.4·§35.10
|
||||
>
|
||||
> - **R3 OUT_OF_BRANCH_SCOPE**: **metric 행 등록 메커니즘과 cardinality bound 표**는 [[raw/branch-notes/feature-metrics-alerting-contract]] 소유다(`metrics.yaml` 의 `Owner branch:` 헤더). 본 §는 *어떤 축을 측정할지* 만 정하고 행 등록은 그 branch 의 change set 절차를 경유한다.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: **metric 이름 규약.** `internal-design-doc` §35.4 의 제안형은 `redis.capability.operations` / `.duration` / `.inflight` / `.queue.depth` / `.timeouts` / `.indeterminate` 인데, `metrics.yaml` 헤더의 규약은 "Micrometer dot.case + unit suffix (`.seconds` | `.bytes` | `.total`)" 다 — 제안형 6개 중 suffix 규약을 만족하는 것은 하나도 없다. trade-off — 설계 문서 이름을 그대로 쓰면 문서 간 grep 이 쉽지만 registry 규약 위반이고, 규약에 맞추면(`redis.capability.operations.total` 등) 설계 문서와 이름이 갈린다. **규약 준수가 우세하나 결정 근거는 없다.**
|
||||
|
||||
측정 축 (전부 `planned` — 현재 `metrics.yaml` 의 Redis 관련 행은 `cache.gets.total`·`cache.invalidations.total` **2개뿐**):
|
||||
|
||||
| 축 | 항목 | 왜 필요한가 |
|
||||
|---|---|---|
|
||||
| connection | connect / reconnect · connection age · TLS·auth 실패 | D18 의 reconnect 동작과 D20·D21 의 실패를 구분 |
|
||||
| admission | queued / rejected command · pool acquire·saturation · in-flight | `LETTUCE-CO-C3`(queue 초과 시 `RedisException`)이 계측 지점. `LETTUCE-CO-C4` 때문에 Cluster 에서는 **노드 수 배수**로 봐야 한다 |
|
||||
| topology | `MOVED` / `ASK` **각각** · topology refresh / failure / age · sentinel failover | `REDIS-CLUSTER-C5`(MOVED = 영구 재배치)와 `REDIS-CLUSTER-C6`(ASK = 일회성)은 **의미가 다르므로 합쳐 세지 않는다** |
|
||||
| command | timeout · `INDETERMINATE` 발생률 | D18 의 certainty 분류가 실제로 얼마나 자주 불확정인지 |
|
||||
| program | `NOSCRIPT` · function digest mismatch · `BUSY`/slow program | 소비자는 #062 |
|
||||
| cache schema | schema mismatch · corrupt (D12 — **miss 와 별도**) | D12 가 요구하나 현재 담을 행이 없다 |
|
||||
|
||||
**tag 규칙**: `deployment` · `role` · `capability` · `operation` · `outcome` · `topology` 로 bounded. **endpoint · key · tenant · user · session · owner token 은 tag 가 아니다** — `metrics.yaml` 헤더의 전역 금지 태그(`user_id`·`request_id`·`raw_url`·`ip_address` 등)와 같은 취지다.
|
||||
|
||||
**server INFO 는 애플리케이션이 export 하지 않는다** — `used_memory` · `evicted_keys` · replication lag · Cluster state 등은 운영 모니터링(operator) 축이다. 애플리케이션이 server INFO 전체를 고cardinality metric 으로 무분별하게 내보내지 않는다.
|
||||
|
||||
### 9. Role durability baseline · startup·shutdown 순서
|
||||
|
||||
> **Trace**: D25(role 별 persistence·replication 기대치) · D26(순서 불변식) · D4(정책 소유 경계) · `REDIS-REPL-C3`·`C5` · `internal-design-doc` §28.7·§36.1·§36.2
|
||||
>
|
||||
> - **R3 OUT_OF_BRANCH_SCOPE**: 앱 **전체**의 lifecycle phase 순서와 liveness/readiness/startup 3-endpoint 분리는 [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] 소유다. 본 §는 Redis 런타임 **내부** 순서만 정한다.
|
||||
|
||||
**role 별 durability 기대치** (D25 — IaC 가 provisioning 해야 할 입력 스펙. 값은 배포가, claim 은 본 branch 가 소유):
|
||||
|
||||
| Role | Persistence | Replication | 선언 가능한 claim |
|
||||
|---|---|---|---|
|
||||
| `cache` | optional | optional / replica read 허용 | 재생성 가능, 유실 허용 |
|
||||
| `coordination` | 명시적 AOF · RPO | primary + replica | 저지연 상태. **유실은 여전히 가능** |
|
||||
| `session` | 제품 RPO 에 맞는 AOF · HA | primary + replica | 세션 연속성은 best effort, 복구는 재인증 |
|
||||
|
||||
> ⛔ **금지 문구**: "AOF + replica = 절대 유실 없음". `REDIS-REPL-C3`("...there is always a window for data loss")와 `REDIS-REPL-C5`(`WAIT` 도 CP 를 주지 않음)가 이를 직접 부정한다. capability card·문서·면접 답변 어디에서도 이 표현을 쓰지 않는다.
|
||||
|
||||
**startup 순서** (D26 — 전부 `planned`. as-built 는 `RedisCacheAdapterConfig` 의 bean 생성 순서에 암묵 의존):
|
||||
|
||||
1. typed config 검증 (D15 topology exactly-one · D16 timeout 관계 · D3 co-location)
|
||||
2. secret material 조달 (D23)
|
||||
3. client resources 생성
|
||||
4. topology / connect / auth (D20 TLS · D21 ACL)
|
||||
5. program / schema capability (#062)
|
||||
6. role attestation (D4 · D25)
|
||||
7. provider binding (#060)
|
||||
8. health / readiness 등록 (D22)
|
||||
9. background refresh · watchdog · consumer 시작
|
||||
|
||||
> **background task 를 connection 검증 전에 시작하지 않는다** — 4단계 이전에 9단계가 돌면 검증 실패한 연결로 백그라운드가 먼저 트래픽을 만든다.
|
||||
|
||||
**graceful shutdown 순서** (D26 — 전부 `planned`):
|
||||
|
||||
1. readiness off / 신규 트래픽 drain
|
||||
2. 신규 cache refresh · rate background 작업 중단
|
||||
3. 신규 lease · idempotency long operation 중단
|
||||
4. in-flight operation bounded wait
|
||||
5. owner-safe lease release (best effort)
|
||||
6. session save 완료
|
||||
7. Pub/Sub · stream listener stop
|
||||
8. 전용 connection / pool close
|
||||
9. shared client resources close
|
||||
|
||||
> **release 응답이 없다고 key 를 blind delete 하지 않는다** — 5단계의 실패는 timeout 이지 소유권 상실이 아니다(D18 의 `INDETERMINATE` 와 같은 원칙).
|
||||
>
|
||||
> as-built 는 `LettuceRedisRuntime.close()` 가 `connection.close()` → `client.shutdown(Duration.ZERO, shutdownTimeout)` **2단계뿐**이고 Spring `@Bean(destroyMethod = "close")` 에 위임한다. background 작업·lease·session 이 아직 없어 현재는 충분하지만, D23 과 #069~#071 이 들어오면 부족하다.
|
||||
|
||||
**금지 명령 · 크기 없는 collection 연산** (D9·D13 확장): regular runtime 에서 `KEYS`, unbounded `HGETALL`·`SMEMBERS`·`LRANGE 0 -1`·`ZRANGE`, `COUNT`/deadline 없는 `XREAD` 를 금지한다. **모든 collection operation 은 최대 결과 개수와 byte budget 을 갖는다.** `SCAN` 은 maintenance/admin 경로 전용이며 bounded `COUNT` · rate limit · cancellation/deadline 을 갖고, 결과는 중복·누락이 있을 수 있으며 mutation 중 정확한 스냅샷이 아니다 — request handler 의 wildcard 무효화에 쓰지 않는다. 현재 as-built 는 collection API 자체가 없어 위반 여지가 없다(`RedisBinaryCommands` 는 `get`/`set`/`delete`/`eval`/`evalSha` 5개뿐).
|
||||
|
||||
**TTL sentinel 해석** (D5·D11 확장, `planned`): expirable capability key 에서 `TTL = -1` 은 **corruption/policy 위반**(만료 없는 key 가 생긴 것)이고 `TTL = -2` 는 **absent** 다. `-1` 을 "영구 성공"으로 두지 않고 capability 별 repair/quarantine + alert 를 수행한다. `Duration` → millisecond 변환 시 overflow · zero truncation · negative · provider 최대 · policy 최대를 검증하며 **`Duration` 을 `int` millisecond 로 축소하지 않는다**.
|
||||
|
||||
**test 계약**: 일반 test taxonomy(unit/contract/integration 분리, focused 레벨 Testcontainers 금지)는 [[raw/branch-notes/feature-test-taxonomy-fixture-contract]] 소유이며 본 branch 는 그 계약의 소비자다. 본 branch 고유 물리층 test 는 key builder property test(hash tag 1개 불변식) · codec contract test(N/N-1 dual-read) · Sentinel/Cluster/TLS/ACL integration · `redisServiceTest` lane 의 no-silent-skip 이며, 형제 [[raw/branch-notes/feature-redis-atomic-program-catalog-contract]] 의 §구현 가이드 "테스트 계약" 패턴을 따른다.
|
||||
|
||||
### 10. Configuration 이행 (as-built → 목표)
|
||||
|
||||
> **Trace**: D2 · D15 · D16 · D20 · D21 · `internal-code-fact`
|
||||
>
|
||||
> - **R3 OUT_OF_BRANCH_SCOPE**: **legacy 키 병행 메커니즘**과 **활성화 축(provider/mode/boolean) 통일**은 [[raw/branch-notes/feature-capability-provider-selection-contract]] D2·D3·D14 가 owner 다. 본 §는 *Redis 런타임 property 의 형태*만 다루고 활성화 규약을 재정의하지 않는다.
|
||||
|
||||
as-built prefix 는 `app.cache.redis.*` 이고 **단일 standalone deployment 를 가정**한다. 목표는 `ca-skeleton.providers.redis.deployments.<id>` + `roles.<role>` 구조다(`internal-design-doc` §32.1).
|
||||
|
||||
as-built env key (전부 `actually-implemented`, `docs/registries/env-keys.yaml` 등록됨):
|
||||
|
||||
| env key | 기본값 | 비고 |
|
||||
|---|---|---|
|
||||
| `APP_CACHE_REDIS_ENABLED` | `false` | 활성화 게이트 |
|
||||
| `APP_CACHE_REDIS_CLIENT_MODE` | `managed` | `managed`(모듈 소유 Lettuce) \| `external`(프로젝트 제공 `RedisClient`) |
|
||||
| `APP_CACHE_REDIS_HOST` / `_PORT` | — / `6379` | managed 활성 시 host 누락은 **startup fail-fast** (`localhost` 로 숨기지 않음) |
|
||||
| `APP_CACHE_REDIS_PASSWORD` | — | `secrets-classification.yaml` 에 `secret`/`secret-manager` 등록 |
|
||||
| `APP_CACHE_REDIS_KEY_HMAC_SECRET` | — | Base64, 디코드 ≥32 byte |
|
||||
| `APP_CACHE_REDIS_COMMAND_TIMEOUT` | `2s` | ≤30s |
|
||||
| `APP_CACHE_REDIS_MAXIMUM_QUEUED_COMMANDS` | `8` | `1..4096` |
|
||||
| `APP_CACHE_REDIS_MAXIMUM_IN_FLIGHT_BYTES` | `16777216` | ≤256 MiB, `maximumValueBytes + 1024` 이상 |
|
||||
| `APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT` | `local` | key 의 `<env>` |
|
||||
| `APP_CACHE_REDIS_SEMANTIC_REGION` | `default` | key 의 `<region>` |
|
||||
| `APP_CACHE_REDIS_MAXIMUM_VALUE_BYTES` | `1048576` | `1..16 MiB` |
|
||||
|
||||
**신규 제안(registry 미등록)**: role/deployment 축(`roles.*`, `deployments.*`), TLS 축, ACL username 축, topology 축, timeout 5축, read preference. 이들은 `env-keys.yaml` 에 **행이 없으므로** 기존 값처럼 단정하지 않는다 — 도입 시 §32.7 의 한 change set(typed property + `application.yml` placeholder + env registry + `.env` example + binding/validation test + secret classification + 문서) 규율을 따른다.
|
||||
|
||||
> ⚠️ **registry owner drift**: `APP_CACHE_REDIS_CLIENT_MODE` 의 `owner_branch` 는 `redis-production-capability`, `KEY_HMAC_SECRET`·`MAXIMUM_QUEUED_COMMANDS`·`MAXIMUM_IN_FLIGHT_BYTES` 등은 `codex-phase-a-ci-recovery` 다 — **둘 다 llm-wiki 에 존재하지 않는 branch-note slug** 다. §Audit `ENV_KEY_OWNER_BRANCH_DRIFT` 참조.
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**
|
||||
- **disconnected 상태의 command** — `REJECT_COMMANDS` + 유한 queue 로 즉시 거부한다. 이걸 놓치면 Lettuce 기본값(`Integer.MAX_VALUE` queue, `LETTUCE-CO-C3`)이 Redis 장애를 애플리케이션 heap 고갈로 바꾼다(D18)
|
||||
- **reconnect 후 자동 replay** — 기본 동작이 at-least-once 라 "중복될 수 있다"(`LETTUCE-CO-C5`). `INCR`/token consume/idempotency claim/lease acquire 는 dedup·owner token 없이 자동 재전송되면 안 된다(D18)
|
||||
- **timeout·cancel 후의 mutation 결과 불명** — 이미 서버에서 실행됐을 수 있다. as-built 는 mutation 을 `INDETERMINATE`, read 를 `NOT_APPLIED` 로 분류한다(`RedisCommandFailureException.Certainty`). 이걸 `FAILED` 로 단정하면 상위 capability 가 잘못된 보상 동작을 한다
|
||||
- **admission 포화** — count 또는 byte semaphore 가 차면 `OVERLOADED`/`NOT_APPLIED`. cache 는 fail-open miss 로, correctness capability 는 fail closed 로 갈린다(정책 owner 는 project note §11)
|
||||
- **oversized 외부 value** — 다른 writer 가 상한을 넘는 값을 넣어둔 경우. bounded Lua `GETRANGE` 가 상한+1 byte 에서 `CA_VALUE_TOO_LARGE` 를 던지고 `IncompatibleSchema` 로 격리한다 — **miss 로 위장하지 않는다**(D12, `actually-implemented`)
|
||||
- **envelope digest 불일치·미래 version** — `CORRUPT_ENVELOPE` / `FUTURE_VERSION` 으로 분리. 프로그래밍 결함을 Redis unavailable 로 분류하지 않는다(D12). 다만 **손상 키 quarantine 과 별도 metric 은 미구현**이라 현재는 매 조회마다 같은 손상 키를 다시 읽는다
|
||||
- **서버 정책 거부 (`OOM command not allowed` · `NOPERM`)** — 본 branch 결정이 **새로 만들어내는** 실패 경로다(D3·D4 가 `noeviction` 을 요구하고 D21 이 ACL 을 켜기 때문). ⚠️ as-built 는 이 경로를 결과 모델 **밖으로 내보낸다** — `LettuceRedisRuntime.execute()` 가 `RedisCommandExecutionException` 을 그대로 rethrow 하고(`catch (RedisCommandExecutionException e) { throw e; }`), `RedisStringCacheRegion` 은 `RedisCommandFailureException` 만 잡으므로 OOM·NOPERM 이 `CacheLookup`·`CacheRecordOutcome` 어디에도 매핑되지 않고 호출부로 raw 전파된다. 계약(D12): **OOM → `Unavailable(UNAVAILABLE, NOT_APPLIED)`**(용량 사건, 프로그래밍 결함 아님) / **`NOPERM` → 배선 결함으로 fail closed**(`IncompatibleSchema` 로 접지 않는다). `execute()` 의 `RedisCommandExecutionException` 분기 신설이 `planned`
|
||||
- **`noeviction` role 의 OOM** — write 가 에러를 반환한다(`REDIS-EVICT-C2`). read 가 된다고 healthy 가 아니다 — session create/touch, idempotency claim/complete, lease acquire 가 각자 fail closed 로 갈린다
|
||||
- **resharding 중 `MOVED`/`ASK`** — `MOVED` 는 slot 이 **영구 재배치**됐다는 뜻이라 클라이언트가 topology 를 갱신해야 하고(`REDIS-CLUSTER-C5`), `ASK` 는 **그 한 번의 query 만** 지정 노드로 보내고 이후는 계속 이전 노드로 보내는 일회성 리다이렉션이다(`REDIS-CLUSTER-C6`). 둘을 같게 처리하면 topology 가 갱신되지 않거나 반대로 과잉 갱신된다. 처리 주체는 클라이언트 라이브러리이며 **그 동작의 벤더 근거는 미확보**(§Audit A7) — topology refresh 지연 중에는 리다이렉션이 지속된다
|
||||
- **evictable role 에 correctness 데이터가 섞임** — D3 의 co-location 거부가 유일한 방어선이다. 검증이 없으면 조용히 통과하고, 사고는 eviction 이 일어난 뒤에야 드러난다
|
||||
- **replica read 의 stale 값** — 무효화 직후에도 옛 값을 읽을 수 있다(`REDIS-REPL-C1`·`C2`). primary-only 로 바꿔도 **failover 유실 창은 남는다**(`REDIS-REPL-C3`·`C5`)
|
||||
- **Cluster 전환 시 slot 위반** — 원자 연산의 key 들이 다른 slot 이면 실패한다(`REDIS-CLUSTER-C3`). as-built 의 slotTag 는 digest 앞 8자라 **co-location 을 표현할 수 없다** — Cluster 진입 시점에 D7 미구현이 바로 문제가 된다
|
||||
- **HMAC secret 교체** — 현재 version 축이 없어 secret 을 바꾸면 전체 key 가 무효화된다(D6). rotation mode 도입 전까지 secret 교체는 cache 전면 miss 를 의미한다
|
||||
- **TLS 이미지 전제** — Redis TLS 는 컴파일 타임 옵션이다(`REDIS-TLS-C1`). 이미지가 TLS 빌드가 아니면 설정만으로는 켜지지 않는다
|
||||
- **다른 계약 의존** (대상 브랜치 + 그 Decision ID)
|
||||
- [[raw/branch-notes/feature-capability-provider-selection-contract]] (`WI-…-060`, **선행 필수**) — 활성화 SSOT(D2·D3), typed descriptor(D5), readiness 등급 R0~R3(D8), capability card(D9·D15), 실패 정책 매트릭스 파생(D12), `readinessImpact` → health group(D7), startup 실패 어휘(D10)를 그 branch 가 소유한다. 본 branch 의 D3(co-location 거부)·D22(health contributor)·§구현 가이드 10 은 그 계약의 **소비자**다. 그 branch 의 D8·D9 가 아직 `UNSUPPORTED_DECISION` 이므로 "readiness R1 강등" 표현은 확정값이 아니다
|
||||
- [[raw/branch-notes/feature-redis-atomic-program-catalog-contract]] (`WI-…-062`, **본 branch 완료 후 착수**) — key model·codec 이 program descriptor 의 입력이다(D5·D6·D8·D11). ⚠️ 그 branch 의 D1·D2 는 **Spring Data Redis 의 `RedisScript`/`ScriptExecutor` 를 전제**하지만 as-built 는 `lettuce-core` 단독이다 — §Audit `SPRING_DATA_REDIS_PREMISE_CONFLICT`. 본 branch 가 raw Lettuce 유지로 확정하면 그 D1·D2 는 재작성 대상이다
|
||||
- [[raw/branch-notes/feature-cache-strategy-registry-contract]] (`WI-…-067`) — TTL 4종·single-flight·SWR/stampede·전략 registry 는 그 branch 소유. 본 branch 는 **물리 형식**(key·envelope·크기 상한)만 소유하고 `maxmemory-policy` 의 배포 소유 경계(D4)를 제공한다
|
||||
- [[raw/branch-notes/feature-session-auth-mode-contract]] (`WI-…-069`) · [[raw/branch-notes/feature-idempotency-ownership-protocol-contract]] (`WI-…-070`) · [[raw/branch-notes/feature-fenced-lock-coordination-contract]] (`WI-…-071`) · [[raw/branch-notes/feature-edge-rate-limit-policy-registry-contract]] (`WI-…-068`) — 네 branch 모두 D1 의 role 배정(session → session role, 나머지 → coordination role)과 D14 의 "cache codec 재사용 금지"를 상속한다. 각 codec 의 **실제 필드**는 그 branch 들이 정한다
|
||||
- [[raw/branch-notes/feature-cachestore-multi-backend-router]] · [[raw/branch-notes/feature-cache-consistency-contract]] — 현행 `CacheStoreRouter`/`FailOpenCacheStore`/`app.cache.bindings.*` 의 owner. 본 branch 의 `CacheRegionPort` 경로와 **두 개의 cache 진입점이 공존**한다(`RedisCacheStore`(String) vs `RedisStringCacheRegion`(envelope)) — 정리 시점은 #067 의 주도권 이전과 묶인다
|
||||
- [[raw/branch-notes/feature-env-driven-runtime-configuration]] — `APP_*` env registry 가 SSOT 이고 `verifyEnvKeys` 가 3-way drift 를 검사한다. §구현 가이드 10 의 신규 축은 registry 등록이 **선행**돼야 한다
|
||||
- [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] **D10**(`Required vs Optional Dependency Matrix`) · **D9**(liveness/readiness/startup 의미 분리) · **D4**(graceful shutdown timeout sync) — `Required vs Optional Dependency Matrix` 를 그 branch 가 소유하고, 그 표의 Redis 행이 `startup validation = ping` · `unavailable → degraded ready (cache-aside fallback)` 로 **이미 확정**돼 있다. **D22 는 그 행의 `ping` 을 부정**하므로 startup validation 열을 refine 하는 관계다(`degraded ready` 는 일치). liveness/readiness/startup 3-endpoint 분리도 그 branch 소유 — §Audit `HEALTH_MATRIX_OWNER_CONFLICT`
|
||||
- [[raw/branch-notes/feature-secrets-config-source-contract]] **D3**(secret reload 기본값 = no runtime reload, rotation = restart validation) · **D4**(3단계 classification) · **D6**(무중단이 필요한 secret 은 dual-bind) — `secrets-classification.yaml` 의 owner. `APP_CACHE_REDIS_PASSWORD`(`restart-only`)·`APP_CACHE_REDIS_KEY_HMAC_SECRET`(`dual-read-restart-only`) 두 행이 이미 "무중단 아님"을 선언했다. **D23 의 8단계 무중단 rotation 과 D6 의 rotation mode 는 그 두 행의 변경을 요구**한다 — §Audit `SECRET_ROTATION_POLICY_DRIFT`
|
||||
- [[raw/branch-notes/feature-metrics-alerting-contract]] **D2**(Micrometer dot.case + unit suffix 명명 — D24 의 `redis.capability.*` 제안형이 이 규약을 만족하지 않는다) · **D8**(cardinality bounds — D24 의 tag 집합이 따라야 할 SSOT) · **D9**(registry timer 행 규약) — `metrics.yaml` 행과 cardinality bound 표의 owner. **D24 의 측정 축은 그 branch 의 change set 절차를 경유**해야 등록된다. 현재 Redis 관련 행은 2개뿐이라 D12·D24 가 요구하는 축을 담을 자리가 없다
|
||||
- [[raw/branch-notes/feature-integration-adapter-templates]] **D1**(optional adapter = disabled-default module) · **D2**(`@ConditionalOnProperty` 로 bean 등록 제어 — as-built `RedisCacheAdapterConfig` 가 이 패턴) · **D9**(required/optional 분류 owner 와 fail-open/closed 정책 owner 분리) — `APP_CACHE_REDIS_ENABLED` 의 `owner_branch` 이고 "Redis unavailable → cache-miss graceful degrade(fail-open)" 정책 행을 소유한다. §구현 가이드 9 의 prefix 재구조화가 그 branch 소유 키를 건드리며, cache fail-open 정책은 project note §11 · #060 D12 · 이 branch 3곳이 후보라 **단일 owner 확정이 필요**하다(§Claims To Verify)
|
||||
|
||||
## Audit & Findings
|
||||
|
||||
> `/branch-spec`(2026-07-28) 이 ca-tmpl 코드·registry·설계 문서를 대조하며 발견한 **drift**. 본 branch 가 단독으로 고칠 수 없는 항목은 권고만 남기고 owner 를 명시한다.
|
||||
|
||||
| # | 라벨 | 발견 | 근거 | 권고 owner |
|
||||
|---|---|---|---|---|
|
||||
| A1 | `GROUND_TRUTH_PATH_DRIFT` | `/branch-spec` 명령 문서와 llm-wiki 다수 문서가 ca-tmpl 을 `/home/donghyeon/workspace/ca-tmpl/` 로 가리키지만, **실제 최신 저장소는 `/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/`** 다. 전자에는 `LettuceRedisRuntime`·`RedisKeyBuilder`·`RedisProgramCatalog` 등 Redis 클래스가 **하나도 없고**(cache-redis 가 9개 파일 seam 상태), `2026-07-26-redis-production-capability-design.md` 자체도 없다 | `find`/`grep` 양쪽 실행 결과. 후자만 cache-redis 51개 파일(test 13) 보유 | 이미 `docs/superpowers/specs/2026-07-28-...-decomposition-design.md` §13 D2 가 같은 사실을 기록했으나 **명령 문서·`.claude/commands/branch-spec.md` 는 미갱신**. 사용자/harness 소유 |
|
||||
| A2 | `SPRING_DATA_REDIS_PREMISE_CONFLICT` | `internal-design-doc` §31.1·§31.3 과 project note §34 optional stack 표는 Redis client 를 "Lettuce (**Spring Data Redis**)" 로 규정하지만, **as-built 는 `io.lettuce:lettuce-core` 단독**이고 코드도 `io.lettuce.core.RedisClient` 를 직접 쓴다 | `src/adapter/outbound/cache-redis/build.gradle` — 의존성 4개 중 `spring-data-redis` **없음**. `LettuceRedisRuntime` 이 raw Lettuce API 사용 | **[[raw/branch-notes/feature-redis-atomic-program-catalog-contract]] D1·D2 가 이 전제 위에 서 있다** — 그 branch 의 §Claims To Verify 가 이미 "#061 이 raw 드라이버만 쓰기로 확정하면 D1·D2 가 무너진다"고 기록했다. 본 branch §구현 가이드 4 의 `UNSUPPORTED_IMPL_DECISION` 으로 남기고 사용자 확정 대기 |
|
||||
| A3 | `ENVELOPE_FIELD_GAP` | as-built envelope 는 목표 필드의 절반이다 — codec 식별자·payload version(별도 축)·compressed flag·writtenAt·soft expiry·**hard expiry** 가 없다 | `redis/RedisCacheEnvelopeCodec.java` 의 `CONTENT_HEADER_BYTES` 구성 vs `internal-design-doc` §12.2 | 본 branch(D11). 특히 hard expiry 부재로 **clock/TTL drift 를 관측할 수단이 현재 0** |
|
||||
| A4 | `ENV_KEY_OWNER_BRANCH_DRIFT` | Redis 런타임 env key 의 `owner_branch` 가 llm-wiki 에 **존재하지 않는 slug** 를 가리킨다 — `APP_CACHE_REDIS_CLIENT_MODE` → `redis-production-capability`, `KEY_HMAC_SECRET`·`MAXIMUM_QUEUED_COMMANDS`·`MAXIMUM_IN_FLIGHT_BYTES` → `codex-phase-a-ci-recovery`. 나머지 host/port/TTL 은 `feature-cache-consistency-contract` 소유 | `docs/registries/env-keys.yaml` L1245·L1295·L1319·L1331 | 본 branch 가 Redis **런타임** key 의 owner 로 이관 제안. 다만 registry 변경 절차는 [[raw/branch-notes/feature-contract-registry-governance]] 소관이므로 여기서 단독 변경하지 않는다 |
|
||||
| A5 | `CACHE_ENTRYPOINT_DUPLICATION` | cache 진입점이 **두 개** 공존한다 — `RedisCacheStore`(`CacheBackend`, plain String, `app.cache.bindings.*` 라우팅)와 `RedisStringCacheRegion`(`CacheRegionPort`, envelope·TTL·negative). 둘 다 `app.cache.redis.enabled=true` 에서 동시에 bean 이 된다 | `RedisCacheAdapterConfig` 가 `redisCacheBackend` 와 `redisStringCacheRegion` 을 **둘 다** 정의 | [[raw/branch-notes/feature-cache-strategy-registry-contract]] 의 주도권 이전(H3·H4)과 묶어 정리. 본 branch 는 물리 형식 owner 로서 **envelope 경로가 정본**임을 선언만 한다 |
|
||||
| A6 | `HUB_FILE_COUNT_STALE` | project note §F-CAP 는 `adapter/outbound/cache-redis` 를 "43개 파일(test 13)" 로 적었으나 실제(build 제외)는 **51개 파일, test 13** 이다 | `find src/adapter/outbound/cache-redis -type f -not -path '*/build/*' \| wc -l` → 51 | 저위험. project note owner 가 다음 갱신 시 정정 |
|
||||
| A7 | `LETTUCE_TOPOLOGY_REFRESH_UNSOURCED` / `LETTUCE_REPLAY_FILTER_UNSOURCED` | Lettuce 공식 페이지 수집에서 **(a) Cluster topology refresh(periodic/adaptive) 설정**과 **(b) `replayFilter` semantics** 의 verbatim 인용을 확보하지 못했다. as-built 는 `replayFilter(ignored -> true)` 를 쓰고 있으나 그 API 가 그렇게 동작한다는 벤더 진술이 없다 | `raw/official-docs/client-options-disconnected-behavior-queue-lettuce-official.md` 의 Usage Boundaries — 요청 항목 중 topology refresh 미발견, replay filter 는 paraphrase 만이라 폐기 | 추가 수집 필요(§Sources). 그때까지 D15 의 topology refresh 와 D18 의 replay 억제는 **메커니즘 근거 없음** |
|
||||
| A8 | `TLS_CLIENT_AXIS_UNSOURCED` (구 `TLS_HOSTNAME_VERIFICATION_UNSOURCED`) | Redis 서버 TLS 공식 문서에 "hostname verification" 이 **0회** 등장한다 (클라이언트 책임이라 서버 문서의 주제가 아님). 더 넓게는 **클라이언트 TLS 축 전체**(스킴 선택·trust material 주입 형태·client certificate 배선·plaintext 거부 판정 지점)에 근거가 없다 | `raw/official-docs/redis-tls-encryption-in-transit.md` 의 self-grep 부수 확인 + 그 노트의 "클라이언트 대응 설정은 별도 official-doc 확보 필요" 진술 | §구현 가이드 5 의 `UNSUPPORTED_IMPL_DECISION` + 착수 금지 게이트. Lettuce `SslOptions`/`RedisURI` 공식 문서 수집 시 승급 |
|
||||
| A9 | `HEALTH_MATRIX_OWNER_CONFLICT` | [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] D10 의 `Required vs Optional Dependency Matrix` 가 Redis 행을 `startup validation = ping` 으로 **이미 확정**했는데, 본 branch D22 는 "`PING` 성공은 health 가 아니다"로 그 열을 **부정**한다. `unavailable → degraded ready` 부분은 두 노트가 일치한다 | `raw/branch-notes/feature-runtime-health-lifecycle-contract.md` 의 해당 표 Redis 행 + 그 노트의 "dependency taxonomy 표의 owner 는 본 branch" 선언 | **사용자 확정 필요** — (a) 그 표의 Redis 행 startup validation 열을 본 branch 가 refine 하는 것으로 두고 그 노트에 역참조를 넣을지, (b) Redis 행 자체의 owner 를 본 branch 로 이관할지. 확정 전까지 D22 는 `refines` 관계로 표기 |
|
||||
| A10 | `SECRET_ROTATION_POLICY_DRIFT` | `secrets-classification.yaml` 이 `APP_CACHE_REDIS_PASSWORD` → `restart-only`, `APP_CACHE_REDIS_KEY_HMAC_SECRET` → `dual-read-restart-only` 로 **이미 선언**(둘 다 무중단 아님). 본 branch D23(8단계 무중단 rotation)·D6(rotation mode 4종)이 그 두 행의 변경을 요구한다. [[raw/branch-notes/feature-secrets-config-source-contract]] D3 도 "secret reload 기본값 = no runtime reload" 로 같은 방향이다 | ca-tmpl `docs/registries/secrets-classification.yaml` 의 두 행. HMAC 행의 `owner_branch` 는 `codex-phase-a-ci-recovery`(A4 와 같은 유형의 무효 slug) | registry owner 는 secrets branch. 본 branch 는 **요구만** 기록하고 단독 변경하지 않는다. D23 의 선택 조건이 "재시작 창 허용 시 registry 현행 값 유지" 분기를 이미 포함한다 |
|
||||
| A11 | `METRIC_NAMING_CONVENTION_CONFLICT` | `internal-design-doc` §35.4 의 metric 이름 제안형 6개(`redis.capability.operations`/`.duration`/`.inflight`/`.queue.depth`/`.timeouts`/`.indeterminate`) 중 [[raw/branch-notes/feature-metrics-alerting-contract]] D2 의 "dot.case + unit suffix(`.seconds`\|`.bytes`\|`.total`)" 규약을 만족하는 것이 **0개**다 | `metrics.yaml` 헤더의 Naming 주석 + 설계 문서 §35.4 목록 | D24 의 Open Risk 로 기록. 이름 확정은 metrics branch 와 합의 후 — 규약 준수(`redis.capability.operations.total` 등)가 우세하나 결정 근거는 없다 |
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장 / Claims To Verify
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 본 branch 가 소유할 관심사가 sibling branch 결정과 겹치지 않는다 | D-row 22개가 #060·#062·#067~#071 과 인접한다. 특히 D22(health contributor) vs #060 D7(`readinessImpact`), D3(강등) vs #060 D8(등급 어휘) | `/sync` 실행 — owner 중복·재진술 검출 | `needs-confirmation` |
|
||||
| Spring Data Redis 를 도입하지 않아도 Sentinel·Cluster·Spring Session 을 배선할 수 있다 | as-built 는 raw Lettuce 단독이고(A2) 설계 문서는 Spring Data Redis 를 전제한다. 두 경로의 비용 차이를 실측한 적이 없다 | Sentinel 최소 배선을 raw Lettuce 로 spike → 코드량·auto-config 배제 작업량 비교 | `needs-confirmation` |
|
||||
| `replayFilter(ignored -> true)` 가 실제로 pending replay 를 억제한다 | 벤더 verbatim 근거 미확보(A7). 현재 BOM 이 고른 Lettuce 버전의 semantics 를 코드가 가정하고 있다 | reconnect integration test — 연결 절단 중 mutation 을 큐에 넣고 재연결 후 **재전송되지 않음**을 확인. `redisServiceTest` lane(`redis-service` 태그) 활용 | `needs-confirmation` |
|
||||
| co-location 거부 5조합이 production startup 을 실제로 막는다 | 검증 로직이 **아직 없다**(D3 전부 `planned`). 규칙만 문서에 있으면 조용히 통과한다 | 5조합 각각에 대한 negative contract test + local `allow-unsafe-colocation` 경로의 readiness 강등 test | `planned` |
|
||||
| slot tag 가 원자 연산의 key group 을 실제로 같은 slot 에 모은다 | as-built 의 slotTag 는 digest 앞 8자라 **분산만 된다**(D7 미구현). Cluster 전환 시 `CROSSSLOT` 실패로 드러난다 | Cluster 통합 test — 같은 태그 성공 / 다른 slot multi-key 는 expected failure. key builder property test 로 "hash tag 정확히 1개" 불변식 검증 | `planned` |
|
||||
| HMAC secret 을 교체해도 서비스가 계속된다 | rotation mode·version 축이 없어 현재는 secret 교체 = 전체 key 무효화(D6) | rotation mode 도입 후 dual-read 창에서 old `hv` hit → new key refresh 를 확인하는 test | `planned` |
|
||||
| envelope 에 hard expiry 를 넣으면 clock drift 를 관측할 수 있다 | 현재 필드가 없어(A3) TTL 이 만료의 유일한 근거다. drift 가 실제로 얼마나 나는지 측정한 적 없다 | hard expiry 추가 후 `writtenAt` 대비 실제 TTL 잔량을 metric 으로 노출하고 real Redis lane 에서 관측 | `planned` |
|
||||
| 손상 entry 를 quarantine 하지 않아도 안전하다 | 현재 `IncompatibleSchema` 를 돌려줄 뿐 키를 지우지 않아 **매 조회마다 같은 손상 키를 다시 읽는다** | 손상 값 주입 후 반복 조회 → source 호출 횟수와 metric 을 확인. 허용 가능하면 문서화, 아니면 quarantine 구현 | `needs-confirmation` |
|
||||
| `@admin` 미부여만으로 `CONFIG SET` 경로가 실제로 막힌다 | ACL 이 아직 미적용(default user 접속)이고, ACL category 는 Redis 버전업으로 확장될 수 있다(D21) | negative integration test — runtime 계정으로 `CONFIG SET`·`FLUSHALL`·`KEYS` 시도가 거부되는지 | `planned` |
|
||||
| Lua 안에서 실행되는 `GETRANGE`/`EXISTS` 가 **호출 user 의 ACL** 을 따른다 | 수집한 ACL 공식 문서(`REDIS-ACL-C1`~`C7`)에 script 내부 명령의 ACL 적용 서술이 **없다**. 따르지 않으면 §구현 가이드 5 의 `cache-runtime` grant 집합이 달라진다 | Redis script/ACL 공식 문서 추가 수집 + `redisServiceTest` lane 에서 `+eval` 만 부여한 user 로 bounded-get 호출 → 성공/`NOPERM` 관측 | `needs-confirmation` |
|
||||
| Redis health 판정의 SSOT 가 한 곳이다 | [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] 의 dependency matrix Redis 행(`ping`)과 본 branch D22(`PING` ≠ health)가 **충돌**한다(§Audit A9). 두 노트가 각자 판정 방식을 정의하면 구현자가 어느 쪽을 따를지 알 수 없다 | `/sync` 로 두 노트 대조 + 사용자가 refine/이관 중 하나를 확정 | `needs-confirmation` |
|
||||
| cache fail-open 정책의 owner 가 한 곳이다 | 후보가 3곳이다 — project note §11 매트릭스, [[raw/branch-notes/feature-capability-provider-selection-contract]] D12(매트릭스 파생), [[raw/branch-notes/feature-integration-adapter-templates]](`APP_CACHE_REDIS_ENABLED` owner + "Redis unavailable → graceful degrade" 행) | `/sync` 로 3자 대조 → 단일 owner 확정 후 나머지는 포인터로 축약 | `needs-confirmation` |
|
||||
| 무중단 credential rotation 이 실제로 필요하다 | registry 는 이미 `restart-only`/`dual-read-restart-only` 로 "재시작 허용"을 선언했다(§Audit A10). 무중단이 요구사항인지 설계 문서의 이상론인지 확인되지 않았다 | 배포 방식(rolling deploy 주기·재시작 허용 창)을 사용자에게 확인 → 필요 없으면 D23 의 8단계를 registry 값에 맞춰 축소 | `needs-confirmation` |
|
||||
| `redis.capability.*` metric 이름이 registry 규약과 정합한다 | `metrics.yaml` 헤더는 "dot.case + unit suffix(`.seconds`\|`.bytes`\|`.total`)" 를 요구하는데 설계 문서 제안형 6개 중 만족하는 것이 **0개**다(D24) | [[raw/branch-notes/feature-metrics-alerting-contract]] 와 이름 규약 합의 후 `metrics.yaml` 행 등록 | `planned` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
> `/coverage` 2회차(2026-07-28) 반영. governing: [[raw/project-notes/ca-skeleton-operational-contract]] §25 Owner Map "Redis role·연결·키·코덱" 행 + 분해 설계 §4.2 #2. 2회차는 `internal-design-doc`(ca-tmpl redis 설계)의 replication·memory·time·lifecycle·test 절까지 대조 범위를 넓혔다.
|
||||
> 1회차 `missing` 2건(credential SPI · client/topology metric) → **D23·D24 로 편입**(2회차에서 "진짜 결정"으로 검증됨).
|
||||
> 2회차 `missing` 2건(role 별 persistence/replication baseline · startup·shutdown 순서) → **D25·D26 + §구현 가이드 9 로 편입**.
|
||||
> ⚠️ 이 표는 2회차 결과에 편입분을 반영한 것이며 **3회차 `/coverage` 로 재검증되지 않았다**(루프 천장 2회 — 다음 세션에서 재실행).
|
||||
|
||||
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
||||
|--------|------|-------|--------|------|
|
||||
| 3-role 분리 · role binding · co-location 거부 · 배포정책 소유경계 | covered-here | — | — | D1~D4 |
|
||||
| key model — canonical shape · digest · hash tag · version 3축 · bounds · 대량 무효화 | covered-here | — | — | D5~D9 |
|
||||
| payload — `byte[]` 원칙 · codec SPI · envelope · schema/손상 분리 · 크기·압축 · codec 분리 | covered-here | — | — | D10~D14 |
|
||||
| topology 배타 sum type | covered-here | — | — | D15 |
|
||||
| topology refresh (periodic/adaptive) | covered-here (근거 미확보) | — | ⚪ Advisory | D15 Open Risk + §Audit A7 |
|
||||
| timeout 7축 · connection 형태 · queue·replay | covered-here | — | — | D16~D18 |
|
||||
| read routing (primary only vs replica opt-in) | covered-here | — | — | D19 |
|
||||
| TLS (서버 축) | covered-here | — | — | D20 |
|
||||
| TLS (클라이언트 축 — 스킴·trust material·client cert·plaintext 거부 판정) | covered-here (근거 미확보) | — | ⚪ Advisory | §구현 가이드 5 `UNSUPPORTED_IMPL_DECISION` + §Audit A8 |
|
||||
| ACL least-privilege | covered-here | — | — | D21 |
|
||||
| role 별 health contributor | covered-here | — | — | D22 (⚠️ owner 충돌 §Audit A9) |
|
||||
| **연결 credential 조달 SPI · rotation 프로토콜** | covered-here | — | — | **D23 + §구현 가이드 7** (1회차 `missing` → 편입, 2회차 검증 완료) |
|
||||
| **client·topology 런타임 metric** | covered-here | — | — | **D24 + §구현 가이드 8** (1회차 `missing` → 편입, 2회차 검증 완료. ⚠️ 이름 규약 충돌 §Audit A11) |
|
||||
| **role 별 persistence·replication baseline** | covered-here | — | — | **D25 + §구현 가이드 9** (2회차 `missing` → 편입, **3회차 미검증**) |
|
||||
| **Redis 런타임 startup·graceful shutdown 순서** | covered-here | — | — | **D26 + §구현 가이드 9** (2회차 `missing` → 편입, **3회차 미검증**) |
|
||||
| 위험 collection 연산 일반 금지 (unbounded `HGETALL`·`SMEMBERS`·`LRANGE`·`ZRANGE`·`XREAD`) · `SCAN` 규율 | covered-here | — | — | §구현 가이드 9 (D9·D13 확장) |
|
||||
| TTL sentinel(`-1`/`-2`) 해석 · `Duration`→ms 변환 bound | covered-here | — | — | §구현 가이드 9 (D5·D11 확장) |
|
||||
| 일반 test taxonomy (unit/contract/integration 분리, Testcontainers 정책) | delegated | [[raw/branch-notes/feature-test-taxonomy-fixture-contract]] | OK | §구현 가이드 9 "test 계약" |
|
||||
| 네트워크 격리 (VPC·private endpoint·egress allowlist) | delegated | IaC/배포 소유 | OK | §범위 제외 (D4 와 동일 성격) |
|
||||
| 관리 작업 audit log | delegated | [[raw/branch-notes/feature-operational-error-observability-foundation]] | OK | §범위 제외 |
|
||||
| capability 별 **의미** 계약 (TTL·stampede·rate 알고리즘·session·idempotency·fenced lock) | delegated | #067 · #068 · #069 · #070 · #071 | OK | §범위 제외 + §엣지 의존 |
|
||||
| Lua/Function 카탈로그 · descriptor · `NOSCRIPT` 재적재 | delegated | [[raw/branch-notes/feature-redis-atomic-program-catalog-contract]] | OK | §범위 제외 (⚠️ §Audit A2 전제 충돌) |
|
||||
| 활성화 SSOT · descriptor · readiness 등급 · capability card · 실패정책 파생 | delegated | [[raw/branch-notes/feature-capability-provider-selection-contract]] | OK | §범위 제외 + `depends_on` |
|
||||
| cache 진입점 이원화 정리 | delegated | [[raw/branch-notes/feature-cache-strategy-registry-contract]] | OK | §Audit A5 |
|
||||
| registry 스키마 · owner 이관 절차 | delegated | [[raw/branch-notes/feature-contract-registry-governance]] | OK | §범위 제외 (§Audit A4·A10 은 권고만) |
|
||||
| metric 행 등록 메커니즘 · cardinality bound | delegated | [[raw/branch-notes/feature-metrics-alerting-contract]] | OK | §구현 가이드 8 R3 + §엣지 의존 |
|
||||
| secret 분류 · rotation policy registry | delegated | [[raw/branch-notes/feature-secrets-config-source-contract]] | OK | §Audit A10 + §엣지 의존 |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
해당 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -29,6 +29,17 @@ contract_packet_sha256: 4aeeee0f8f366a32a08f4e6c687e7bf7bab5de74f51c5ca05069b8cd
|
||||
|
||||
> Layer: `raw/branch-notes/` — JWT Resource Server 기준의 인증/인가 실패 운영 분류를 정의합니다.
|
||||
|
||||
> [!important] 주도권 이전 고지 (2026-07-28)
|
||||
> 아래 관심사의 **owner 가 신규 branch 로 이동**했다. 근거·절차: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §5.
|
||||
>
|
||||
> | 이전 ID | 대상 D-row | 이전한 관심사 | 신규 owner |
|
||||
> |---|---|---|---|
|
||||
> | H7 | session 관련 D-row | session·인증 모드 (JWT 검증 자체는 본 branch 유지) | [[raw/branch-notes/feature-session-auth-mode-contract]] |
|
||||
>
|
||||
> **본문은 아직 제거하지 않았다.** 신규 branch 는 현재 스캐폴딩 상태(D-row 없음)이므로, 지금 본문을 지우면 결정 근거가 소실된다. 위 D-row 는 신규 branch 의 `/branch-spec` 이 재판정을 마칠 때까지 **잠정 근거**로만 유효하며, 그 시점에 설계 §5.2 6단계에 따라 포인터(`superseded → [[<신규 branch>]] D<m>`)로 치환한다.
|
||||
>
|
||||
> 이 기간 중 새 결정을 본 branch 에 추가하지 않는다 — 신규 owner 에 작성한다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
title: branch / feature-session-auth-mode-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-session-auth-mode-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||||
tags: [branch, ca-skeleton, session, authentication, redis-session, multi-pod]
|
||||
created: 2026-07-28
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-069
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-069
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-SESSION-MODE-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-061]
|
||||
imports: []
|
||||
delegates: []
|
||||
accepts_delegations: []
|
||||
contract_packet: 1
|
||||
---
|
||||
|
||||
# branch: feature-session-auth-mode-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
> **스캐폴딩 상태** — 결정(D-row)·구현 가이드는 비어 있다. `/branch-spec feature-session-auth-mode-contract` 로 채운다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> 분해 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §4.2 — 능력 계약 (Tier C). 본 branch 는 project §8.0 `WI-CA-SKELETON-OPERATIONAL-CONTRACT-069` 의 실행 단위다.
|
||||
|
||||
형제 branch (같은 부모의 다른 자식 — 인접 영역):
|
||||
|
||||
- [[raw/branch-notes/feature-security-operational-baseline]]
|
||||
- [[raw/branch-notes/feature-authentication-authorization-contract]]
|
||||
- [[raw/branch-notes/feature-redis-runtime-role-isolation-contract]]
|
||||
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
> project Work Item 에서 내려온 실행 계약의 snapshot. 여기에는 **pinned pointer + 1줄 요약 + branch 적용점**만 쓰고 상세를 복제하지 않는다.
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: 배타 인증 모드·CSRF·쿠키·회전·다중 파드 세션 계약 test 가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-SESSION-MODE-001@1` | 인증 모드는 jwt와 redis-session이 배타이고 jwt가 default이며 redis-session은 전용 session role과 CSRF·쿠키·회전·다중 파드 계약을 동반한다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다. 근거 없이 추측해 채우지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- `WI-CA-SKELETON-OPERATIONAL-CONTRACT-069` 의 완료 조건을 구현한다: 배타 인증 모드·CSRF·쿠키·회전·다중 파드 세션 계약 test 가 통과한다
|
||||
- **주도권 이전 수신** — H7 (`feature-security-operational-baseline` 의 session 관련 D-row). 이전 절차는 `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §5.2 6단계를 따른다. 기존 branch 의 D-row 는 `/branch-spec` 으로 본 branch 결정이 확정된 뒤 포인터로 치환한다.
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- `jwt | redis-session` 배타 프로파일과 전환 조건
|
||||
- session 생성 정책·CSRF 활성 조건·쿠키 속성(Secure/HttpOnly/SameSite/path/domain/expiry)
|
||||
- session id 회전과 로그아웃 시 서버 세션 삭제
|
||||
- serializer 명시·버전·허용 타입 (JDK 직렬화 금지)
|
||||
- 다중 파드 read/touch/expiry/logout 계약과 rolling deploy 호환
|
||||
- session store 실패 시 fail closed 와 readiness 반영
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- 로그인 엔드포인트·신원 확인 흐름 — 제품 결정
|
||||
- JWT 검증 자체 — `feature-security-operational-baseline` 소유
|
||||
- principal 조회·동시 세션 제어는 필요할 때만 indexed repository 로 확장
|
||||
- 심층 명세 — 본 노트는 스캐폴딩. D-row 와 §구현 가이드는 `/branch-spec` 이 채운다.
|
||||
- project decision registry 변경 — owner 는 project-note
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 외부 근거 미등록. `/branch-spec feature-session-auth-mode-contract` 단계에서 verbatim 인용과 함께 `raw/official-docs/` 로 수집한 뒤 여기서 링크한다.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| (미등록) | 1차 수집 대상: Spring Session Redis 공식 API · ca-tmpl platform 설계 §8.6 |
|
||||
|
||||
**프로젝트 내부 설계 참조 (등급 `internal-design-doc` — 공식 문서 아님, best practice 로 격상 금지):**
|
||||
|
||||
- ca-tmpl `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md`
|
||||
- llm-wiki `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md`
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [ ] `/branch-spec feature-session-auth-mode-contract` 로 D-row·§구현 가이드 작성 — 등급: `planned`
|
||||
- [ ] 배타 인증 모드·CSRF·쿠키·회전·다중 파드 세션 계약 test 가 통과한다 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
project 결정 외 branch-local 결정은 아직 없음. `/branch-spec` 단계에서 기록한다.
|
||||
|
||||
<!-- section-id: decision-evidence -->
|
||||
## 결정-근거 매핑
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
`/branch-spec` 단계에서 source claim 기반으로 작성한다. 3-rule meta principle(R1 Reference 필수 / R2 UNSUPPORTED_IMPL_DECISION 명시 / R3 OUT_OF_BRANCH_SCOPE 정제) 적용.
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**: `/branch-spec` 단계에서 구체화한다.
|
||||
- **다른 계약 의존**: `WI-CA-SKELETON-OPERATIONAL-CONTRACT-061`([[raw/branch-notes/feature-redis-runtime-role-isolation-contract]]) 의 계약에 의존
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 본 branch 가 소유할 관심사가 sibling branch 결정과 겹치지 않는다 | 스캐폴딩 시점에는 D-row 가 없어 경계가 문장으로만 존재 | `/branch-spec` 후 `/sync` 실행 — owner 중복 검출 | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
`/coverage` 실행 전.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
해당 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
title: branch / feature-websocket-cluster-delivery-contract
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
branch: feature-websocket-cluster-delivery-contract
|
||||
parent_branch:
|
||||
related_projects: [ca-skeleton]
|
||||
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
|
||||
tags: [branch, ca-skeleton, websocket, stomp, backpressure, cluster]
|
||||
created: 2026-07-28
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-075
|
||||
kind: project-work-item
|
||||
project: ca-skeleton-operational-contract
|
||||
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-075
|
||||
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-READINESS-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-060, WI-CA-SKELETON-OPERATIONAL-CONTRACT-063]
|
||||
imports: []
|
||||
delegates: []
|
||||
accepts_delegations: []
|
||||
contract_packet: 1
|
||||
---
|
||||
|
||||
# branch: feature-websocket-cluster-delivery-contract
|
||||
|
||||
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
> **스캐폴딩 상태** — 결정(D-row)·구현 가이드는 비어 있다. `/branch-spec feature-websocket-cluster-delivery-contract` 로 채운다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
|
||||
|
||||
> 분해 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §4.2 — 전송·조회 (Tier X). 본 branch 는 project §8.0 `WI-CA-SKELETON-OPERATIONAL-CONTRACT-075` 의 실행 단위다.
|
||||
|
||||
형제 branch (같은 부모의 다른 자식 — 인접 영역):
|
||||
|
||||
- [[raw/branch-notes/feature-streaming-response-contract]]
|
||||
- [[raw/branch-notes/feature-graphql-production-baseline-contract]]
|
||||
- [[raw/branch-notes/feature-kafka-producer-runtime-contract]]
|
||||
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
> project Work Item 에서 내려온 실행 계약의 snapshot. 여기에는 **pinned pointer + 1줄 요약 + branch 적용점**만 쓰고 상세를 복제하지 않는다.
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: handshake 인증·destination authorization·bounded queue·cluster 전달 test 가 통과한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-READINESS-001@1` | 모든 capability는 R0~R3 readiness 등급과 capability card를 가지며 R0 seam을 R2 provider로 표기하지 않는다 | `/branch-spec` 에서 적용 경계 작성 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다. 근거 없이 추측해 채우지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- `WI-CA-SKELETON-OPERATIONAL-CONTRACT-075` 의 완료 조건을 구현한다: handshake 인증·destination authorization·bounded queue·cluster 전달 test 가 통과한다
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- 인증된 handshake 와 재인증·세션 만료 동작
|
||||
- destination 별 subscribe/send authorization
|
||||
- 신뢰 origin·payload/frame 한계, heartbeat·idle timeout
|
||||
- bounded inbound/outbound executor·queue·send time 과 명시적 disconnect/drop 정책
|
||||
- 손실이 문제되는 경우의 sequence/resume 계약
|
||||
- 다중 노드 전달 — broker relay 또는 durable 통합 이벤트 브리지
|
||||
- 임의 도메인 이벤트의 공개 destination 직렬화 금지
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- in-process simple broker 를 운영 등급으로 승격하는 것 — R1 로 고정
|
||||
- GraphQL subscription 의 스키마 표면 — #073 소유
|
||||
- 심층 명세 — 본 노트는 스캐폴딩. D-row 와 §구현 가이드는 `/branch-spec` 이 채운다.
|
||||
- project decision registry 변경 — owner 는 project-note
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 외부 근거 미등록. `/branch-spec feature-websocket-cluster-delivery-contract` 단계에서 verbatim 인용과 함께 `raw/official-docs/` 로 수집한 뒤 여기서 링크한다.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| (미등록) | 1차 수집 대상: Spring WebSocket 공식 — external broker relay · ca-tmpl platform 설계 §14.4 |
|
||||
|
||||
**프로젝트 내부 설계 참조 (등급 `internal-design-doc` — 공식 문서 아님, best practice 로 격상 금지):**
|
||||
|
||||
- ca-tmpl `docs/superpowers/specs/2026-07-26-production-capability-platform-design.md`
|
||||
- llm-wiki `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md`
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [ ] `/branch-spec feature-websocket-cluster-delivery-contract` 로 D-row·§구현 가이드 작성 — 등급: `planned`
|
||||
- [ ] handshake 인증·destination authorization·bounded queue·cluster 전달 test 가 통과한다 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
project 결정 외 branch-local 결정은 아직 없음. `/branch-spec` 단계에서 기록한다.
|
||||
|
||||
<!-- section-id: decision-evidence -->
|
||||
## 결정-근거 매핑
|
||||
|
||||
> `/branch-spec` 단계에서 작성한다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
`/branch-spec` 단계에서 source claim 기반으로 작성한다. 3-rule meta principle(R1 Reference 필수 / R2 UNSUPPORTED_IMPL_DECISION 명시 / R3 OUT_OF_BRANCH_SCOPE 정제) 적용.
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**: `/branch-spec` 단계에서 구체화한다.
|
||||
- **다른 계약 의존**: `WI-CA-SKELETON-OPERATIONAL-CONTRACT-060`([[raw/branch-notes/feature-capability-provider-selection-contract]]), `WI-CA-SKELETON-OPERATIONAL-CONTRACT-063`([[raw/branch-notes/feature-kafka-producer-runtime-contract]]) 의 계약에 의존
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 본 branch 가 소유할 관심사가 sibling branch 결정과 겹치지 않는다 | 스캐폴딩 시점에는 D-row 가 없어 경계가 문장으로만 존재 | `/branch-spec` 후 `/sync` 실행 — owner 중복 검출 | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
`/coverage` 실행 전.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
해당 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
Reference in New Issue
Block a user