feat: 공식 문서 근거자료, 브랜치 기능 문서 작성

This commit is contained in:
DongHyeonka
2026-07-29 18:05:17 +09:00
parent cfd84875bf
commit 58515ab0f3
251 changed files with 31470 additions and 109 deletions
@@ -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):
@@ -0,0 +1,86 @@
---
title: company-tech-blog / Very Good Security (VGS) — Solving Kafka Rebalancing Issues: A Case Study
source_type: company-tech-blog
url: https://www.verygoodsecurity.com/blog/posts/solving-kafka-rebalancing-issues-a-case-study
archive_url:
related_branches: [feature-kafka-consumer-inbox-contract]
related_projects: [ca-skeleton]
tags: [company-tech-blog, ca-skeleton, messaging, kafka]
created: 2026-07-28
---
# Very Good Security (VGS) — Solving Kafka Rebalancing Issues: A Case Study
> Layer: `raw/` — 외부 자료(대기업 기술 블로그)의 **원문 발췌·출처 기록**.
> 이 자료는 **회사 사례(company-case-study)** 다. 공식 Kafka best practice 로 취급하지 않는다 (CLAUDE.md §5). VGS 사례의 규모(100 consumers / 100 partitions / 3-broker 단일 클러스터, aiokafka Python 클라이언트)는 ca-skeleton 의 전제와 다를 수 있다 — `## Usage Boundaries` 참조.
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-kafka-consumer-inbox-contract]] | ca-skeleton consumer 가 `cooperative-sticky` 파티션 할당 전략과 `max.poll.*` 튜닝을 병행 채택할지의 **운영 사례 근거** — 잦은 rebalance 로 consumer 가 그룹에서 이탈하고 커밋이 실패하던 환경에서 두 조치를 적용한 결과 |
## 출처
- 원본 URL: https://www.verygoodsecurity.com/blog/posts/solving-kafka-rebalancing-issues-a-case-study
- 아카이브 URL: (미제공)
- 저자 / 조직: Ramin Ranjbar (Sr. Software Engineer), Jimit Patel (Staff Software Engineer) — Very Good Security (VGS)
- 발행일: 2025-06-11
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-kafka-consumer-inbox-contract` 브랜치가 검토 중인 "cooperative-sticky assignor + `max.poll.*` 튜닝" 병행 채택의 실제 운영 사례를 확보하기 위해 저장. 잦은 rebalance 로 인한 `CommitFailedError`/`RequestTimedOutError` 증상, 적용한 구체적 config 값, CooperativeSticky 전환이 "incremental rebalancing"으로 서술되는 방식, 그리고 처리량 개선 수치를 원문으로 고정한다.
## 핵심 인용
> [§Step 1: Diagnosing the Rebalancing Problem] "Consumers frequently dropped out with CommitFailedError and RequestTimedOutError on JoinGroupRequest_v2 to the group coordinator (broker 2)." (line 140)
> [§Fix 3: Increase max_poll_interval and Reduce max_poll_records] "Increased max_poll_interval_ms from 300,000ms to 600,000ms and reduced max_poll_records from 10 to 5. This allows more time for processing and reduces the batch size." (line 234)
> [§Step 3: Switching to Cooperative Sticky Assignor — Why use Cooperative Sticky?] "Incremental Rebalancing: Consumers only release partitions they no longer own, keeping others active." (line 250)
> [§Results (throughput)] "Before tuning the setting, due to rebalancing issues, we only serviced 13 req/second and the rate is volatile as depicted below:" [...] "After tuning the parameters and applying the above lessons, we have 10x the rate, to 135 req/second, and the load is sustained (i.e. not volatile)." (line 304, 306)
> [§Lessons Learned] "Use Cooperative Sticky for Large Groups: With 100 consumers, the Cooperative Sticky Assignor significantly reduces rebalancing overhead." (line 300)
## Claims Extracted
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| VGS-REBAL-C1 | 100-consumer aiokafka 기반 consumer group 에서 consumer 가 `CommitFailedError`/`RequestTimedOutError` 로 반복 이탈하고 group coordinator(broker 2)에 rebalancing 부하가 집중되는 증상이 관측되었다 | "Consumers frequently dropped out with CommitFailedError and RequestTimedOutError on JoinGroupRequest_v2 to the group coordinator (broker 2)." | `company-case-study` | VGS `app-worker-webhook` (aiokafka, Python, Kafka 2.8.1, 100 consumers / 100 partitions / 3 brokers) 환경의 관측 사실 | 이 증상이 ca-skeleton 의 (아직 미정인) consumer 규모·클라이언트(Java/Spring-Kafka 추정)에서도 동일하게 재현된다는 것은 증명하지 않음 — 이 원인(coordinator 부하 집중)을 Kafka 공식 문서가 설명한다는 근거도 이 인용 자체엔 없음 |
| VGS-REBAL-C2 | VGS 는 `max_poll_interval_ms` 를 300,000ms→600,000ms 로 늘리고 `max_poll_records` 를 10→5 로 줄여 poll 간 처리 시간 여유를 늘리고 batch 크기를 줄였다 | "Increased max_poll_interval_ms from 300,000ms to 600,000ms and reduced max_poll_records from 10 to 5. This allows more time for processing and reduces the batch size." | `company-case-study` | VGS 자체 워크로드(개별 메시지 처리 시간이 길어 poll 간격을 늘려야 했던 상황)에서의 구체 튜닝 값 | 이 정확한 수치(600,000ms / 5 records)가 ca-skeleton 의 어떤 워크로드에도 올바른 기본값이라는 것은 증명하지 않음 — 값은 poll 당 처리 시간에 의존적이며 이 문서는 산정 공식을 제공하지 않음 |
| VGS-REBAL-C3 | Cooperative Sticky Assignor 는 (round-robin Eager 방식과 달리) consumer 가 더 이상 소유하지 않는 파티션만 반납하고 나머지 파티션은 계속 active 상태로 유지하는 incremental rebalancing 을 제공한다 | "Incremental Rebalancing: Consumers only release partitions they no longer own, keeping others active." | `company-case-study` | Kafka 2.4.0+ / aiokafka 0.12.0+ 에서 사용 가능한 CooperativeStickyAssignor 의 일반 동작 서술 | 이 노트 안에는 Kafka 공식 문서(KIP-429 또는 Apache Kafka consumer configs reference) 인용이 없으므로, 이 메커니즘을 "공식 Kafka best practice"로 격상할 수 없다(CLAUDE.md §5) — 별도 official-doc 보강 필요 |
| VGS-REBAL-C4 | 설정 튜닝 + CooperativeSticky 전환 이후 `app-worker-webhook` 처리량이 불안정한 13 req/sec 에서 안정적인 135 req/sec(약 10배)으로 개선되었다 | "Before tuning the setting, due to rebalancing issues, we only serviced 13 req/second and the rate is volatile" [...] "After tuning the parameters and applying the above lessons, we have 10x the rate, to 135 req/second, and the load is sustained (i.e. not volatile)." | `company-case-study` | VGS 자체 관측 처리량(before/after) 비교 | CPU/메모리 사용량이 "relatively the same" 유지된 채 처리량만 늘었다는 것 외의 세부 비용(latency percentile 등)은 이 인용 범위 밖 — 다른 규모/클라이언트에서 동일 배율 개선이 재현된다는 것도 증명하지 않음 |
| VGS-REBAL-C5 | VGS 는 100 consumer 규모의 "large consumer group" 맥락에서 Cooperative Sticky 채택을 권고한다 | "Use Cooperative Sticky for Large Groups: With 100 consumers, the Cooperative Sticky Assignor significantly reduces rebalancing overhead." | `company-case-study` | 100 consumers / 100 partitions 규모 전제 | ca-skeleton 처럼 파티션·컨슈머 수가 훨씬 적은(수 개~수십 개) 소규모 배포에서도 동일한 효과 배율이 나타난다는 것은 증명하지 않음 — 이 사례의 개선은 대규모 coordinator 부하 집중 문제에서 기인하므로, 소규모에서는 문제 자체가 약할 수 있음 |
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `VGS-REBAL-C1`: 대규모(100 consumer) aiokafka consumer group 에서 잦은 rebalance 로 인한 `CommitFailedError`/`RequestTimedOutError` 증상과 coordinator 부하 집중이라는 VGS 의 관측 사실
- `VGS-REBAL-C2`: VGS 가 실제 적용한 `max_poll_interval_ms`/`max_poll_records` 구체 값
- `VGS-REBAL-C3`: CooperativeSticky 가 "일부 파티션만 반납"하는 incremental rebalancing 이라는 VGS 의 서술
- `VGS-REBAL-C4`: 이 두 조치를 병행 적용한 뒤 VGS 가 관측한 처리량 개선 수치(13→135 req/sec)
- `VGS-REBAL-C5`: VGS 가 이 조합을 "large consumer group"이라는 조건부로 권고한다는 것
- 이 자료가 증명하지 **않는** 것 (중요 — ca-skeleton 적용 전 반드시 확인):
- **"공식 Kafka best practice"라는 것.** 이 문서는 `company-case-study` 등급이며, Kafka 공식 문서(KIP-429, Apache Kafka consumer configs reference)의 보강 인용이 이 노트 안에 없다. `VGS-REBAL-C3`를 "Kafka 가 공식적으로 권장하는 방식"으로 서술하면 CLAUDE.md §5·§11 위반(공식 문서와 기술 블로그 혼동)이다.
- **규모 전제 불일치.** VGS 사례는 100 consumers / 100 partitions / 3-broker 단일 클러스터 기준이다. ca-skeleton 이 실제로 이 규모(수백 파티션/컨슈머)를 전제로 하는지는 이 자료로 증명되지 않는다 — 소규모 배포에서는 rebalance 부하 자체가 미미해 "10x 개선"이 재현되지 않을 수 있다.
- **클라이언트 불일치.** 이 사례는 Python `aiokafka` 클라이언트(snake_case 설정 키 `max_poll_interval_ms` 등) 기준이다. ca-skeleton 이 Java/Spring-Kafka 스택이라면 설정 키 표기(`max.poll.interval.ms` dot-notation)와 클라이언트 내부 구현(heartbeat thread 모델 등)이 다를 수 있어, 정확한 프로퍼티명·기본값은 별도 Apache Kafka 공식 consumer configs 문서로 재확인해야 한다.
- CooperativeStickyAssignor 의 "incremental rebalancing" 메커니즘 자체의 공식 사양(KIP-429) 세부는 이 문서에 없다 — 이 문서는 결과적 효과만 서술한다.
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- ca-skeleton 이 실제로 채택할 Kafka client(Spring Kafka 등)에서 `partition.assignment.strategy=CooperativeStickyAssignor` 사용 시의 공식 문서(Apache Kafka / Spring Kafka reference) 대조
- ca-skeleton 의 예상 파티션·컨슈머 규모가 이 사례의 "large group" 전제에 해당하는지 확인
- `max.poll.interval.ms`/`max.poll.records` 기본값 산정 근거(개별 메시지 처리 시간 profiling) — 이 문서는 산정 공식을 제공하지 않음
## 메모
- 인용 6개(quote4 는 elided 결합) 전부 Self-Grep 통과. fabrication 없음.
- WebFetch 1차 호출 결과는 도구가 모델로 재처리한 요약이라(WebFetch 는 fetch 후 별도 모델이 프롬프트를 적용해 응답을 생성) 문구가 원문과 미세하게 다를 위험이 있었다 (예: 1차 응답은 "available in Kafka 2.4.0+ and aiokafka 0.12.0+" 로 뭉뚱그렸으나, 원문은 "available in Kafka 2.4.0+ and aiokafka 0.12.0 for completed implementation" 이다). 이 차이를 발견한 뒤 `curl` 로 원본 HTML 을 직접 재수집(`vgs-raw.html`, HTTP 200)해 파이썬 `html.parser` 로 텍스트를 추출했고, 이 verbatim 텍스트를 self-grep 대조 기준으로 사용했다 — 인용은 모두 이 원본 대조본에서만 채택했다.
- 발행일은 페이지 본문에 "June 11, 2025" 로 명시되어 있어 `needs-confirmation` 없이 확정.
## 관련
- 인접 official-doc (consumer offset/commit 시맨틱): [[raw/official-docs/kafka-consumer-offset-commit-semantics-apache-javadoc]]
- 인접 official-doc (Spring Kafka listener container backpressure): [[raw/official-docs/spring-kafka-listener-container-pause-resume-backpressure]]
- 같은 branch 의 인접 company-tech-blog (retry topic + DLQ 사례): [[raw/company-tech-blogs/kafka-multi-tier-retry-topic-dlq-uber]]
- 이 자료를 인용한 wiki 요약: (생성 시 링크)
@@ -0,0 +1,82 @@
---
title: company-tech-blog / Uber — Building Reliable Reprocessing and Dead Letter Queues with Apache Kafka
source_type: company-tech-blog
url: https://www.uber.com/en-US/blog/reliable-reprocessing/
archive_url:
related_branches: [feature-kafka-consumer-inbox-contract]
related_projects: [ca-skeleton]
tags: [company-tech-blog, ca-skeleton, messaging, kafka, dead-letter-queue, retry-policy]
created: 2026-07-28
---
# Uber — Building Reliable Reprocessing and Dead Letter Queues with Apache Kafka
> Layer: `raw/` — 외부 자료(대기업 기술 블로그)의 **원문 발췌·출처 기록**.
> 이 자료는 **회사 사례(company-case-study)** 다. 공식 Kafka best practice 로 취급하지 않는다 (CLAUDE.md §5).
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-kafka-consumer-inbox-contract]] | ca-skeleton consumer 의 재시도 전략에서 "다단계 retry topic + DLQ" 대안이 실제 대규모 운영에서 어떤 전제 위에 성립하는지의 사례 근거 — 각 retry 단계가 처리 지연(backoff)을 강제하는 구조, 그리고 이 패턴이 "이벤트를 발생 순서 그대로 처리하지 않아도 되는 애플리케이션"을 전제로 한다는 명시적 인정(= ca-skeleton per-aggregate 순서 보장 계약과 충돌하는 지점) |
## 출처
- 원본 URL: https://www.uber.com/en-US/blog/reliable-reprocessing/
- 아카이브 URL: (미제공)
- 저자 / 조직: Uber Engineering (Insurance Engineering team)
- 발행일: 명시 없음 (WebFetch 결과에 발행 날짜 필드 없음 — `needs-confirmation`)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-kafka-consumer-inbox-contract` 브랜치가 검토 중인 "다단계 retry topic + DLQ" 대안의 실제 운영 사례를 확보하기 위해 저장. 특히 이 패턴이 성립하는 전제 — (1) 각 retry topic 단계가 지연(delay)을 강제하는 구조, (2) 파티션 밖에서는 순서를 보장하지 않아도 되는 애플리케이션이어야 한다는 것, (3) at-least-once 전제 하 idempotent consumer 필요 — 를 원문으로 고정한다. (2)는 ca-skeleton 의 per-aggregate FIFO 순서 보장 계약과 정면으로 충돌할 수 있는 지점이라 아래 `## Usage Boundaries`에 명시한다.
## 핵심 인용
> [§Delay Strategy] "Each subsequent level of retry consumers can enforce a processing delay, in other words, a timeout that increases as a message steps down through each retry topic." (line 27)
> [§Important Constraint] "Since Kafka only guarantees in-order processing within partitions and not across them, it must be acceptable for an application to handle events outside of the exact order in which they occur." (line 38)
> [§Important Constraint] "Additionally, consumers must implement idempotent operations due to at-least-once delivery semantics." (line 38, 같은 문단 두 번째 문장)
> [§Solution: Separate Queue Architecture] "Dead Letter Queue → end-of-line for persistent failures" (line 21, 원문 bullet: `**Dead Letter Queue** → end-of-line for persistent failures`)
> [§Solution: Separate Queue Architecture] "When a consumer fails to process a message, it publishes to the retry topic and commits the original offset, allowing batch processing to continue." (line 23)
## Claims Extracted
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| UBER-REPROC-C1 | 다단계 retry topic 구조에서 각 단계는 이전 단계보다 증가하는 처리 지연(timeout)을 강제할 수 있다 (leaky bucket 패턴) | "Each subsequent level of retry consumers can enforce a processing delay, in other words, a timeout that increases as a message steps down through each retry topic." | `company-case-study` | Uber Insurance Engineering 팀의 Kafka 기반 이벤트 재처리 아키텍처 설계 | Kafka 공식 문서가 이 backoff 구조를 권고한다는 것은 증명하지 않는다 (이 인용에는 Kafka 공식 문서 인용이 없음) — 몇 단계가 적정한지, 지연 값을 어떻게 산정하는지는 이 문서 범위 밖 |
| UBER-REPROC-C2 | Kafka 는 파티션 내부(within partitions)에서만 순서를 보장하고 파티션을 가로질러(across them)는 보장하지 않으므로, 이 재처리 아키텍처를 채택하려면 애플리케이션이 이벤트를 발생 순서 그대로 처리하지 않아도 되는 것을 받아들여야 한다 | "Since Kafka only guarantees in-order processing within partitions and not across them, it must be acceptable for an application to handle events outside of the exact order in which they occur." | `company-case-study` | retry topic + DLQ 로 메시지가 원본 topic → retry topic(들) → DLQ 로 이동하는 구조 전반 | **ca-skeleton 의 per-aggregate FIFO 순서 보장 계약이 이 패턴과 양립 가능한지는 증명하지 않는다** — 오히려 이 전제(순서 비보장 수용)가 ca-skeleton 계약과 정면으로 다를 수 있다는 것이 이 인용의 핵심 사용처. 이 페이지는 Kafka 공식 문서를 인용하지 않으므로 "파티션 내부만 순서 보장"이라는 Kafka 자체의 공식 동작도 이 문서만으로는 `official-standard`/`official-vendor-doc` 급으로 격상되지 않는다 (별도 공식 문서 보강 필요) |
| UBER-REPROC-C3 | 이 아키텍처에서는 at-least-once 전달 시맨틱을 전제하므로 consumer 가 idempotent 연산을 구현해야 한다 | "Additionally, consumers must implement idempotent operations due to at-least-once delivery semantics." | `company-case-study` | Uber 의 retry/DLQ consumer 설계 전제 | idempotency 를 **어떻게** 구현해야 하는지(메커니즘)는 말하지 않는다 — ca-skeleton 의 owner-token 기반 idempotency 프로토콜 (`feature-idempotency-ownership-protocol-contract` 소유) 과의 구체적 정합성은 이 인용만으로 증명되지 않는다 |
| UBER-REPROC-C4 | consumer 가 메시지 처리에 실패하면 retry topic 으로 publish 하고 원본 offset 을 즉시 commit 하여, 이후 배치 처리가 막히지 않고 계속 진행된다 | "When a consumer fails to process a message, it publishes to the retry topic and commits the original offset, allowing batch processing to continue." | `company-case-study` | Uber 의 실패 메시지 처리 흐름(원본 topic에서의 offset 커밋 시점) | ca-skeleton 의 manual-ack·bounded-concurrency 설계에서 동일한 "실패 시 즉시 원본 offset commit" 방식을 채택해야 한다는 것은 증명하지 않는다 — 이는 Uber 의 설계 선택이며 대안(예: 원본 offset 을 보류하고 재시도)과의 트레이드오프 비교는 이 문서에 없다 |
| UBER-REPROC-C5 | Dead Letter Queue 는 재시도 단계를 모두 소진한 지속적 실패 메시지의 최종 종착점(end-of-line)이다 | "Dead Letter Queue → end-of-line for persistent failures" | `company-case-study` | Uber 아키텍처에서 DLQ 의 역할 정의 | DLQ 재처리(replay) 절차·모니터링·알림 방식의 구체 사항은 이 인용 범위 밖 — ca-skeleton 의 "감사된 replay" 요구사항과의 구체적 정합성은 별도 확인 필요 |
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `UBER-REPROC-C1`: 다단계 retry topic 각 단계가 증가하는 지연을 강제할 수 있다는 Uber 의 설계 서술
- `UBER-REPROC-C2`: Uber 가 이 패턴을 채택하려면 "이벤트를 발생 순서대로 처리하지 않아도 되는 것"을 전제로 명시적으로 인정했다는 것
- `UBER-REPROC-C3`: at-least-once 전제 하 idempotent consumer 필요성에 대한 Uber 의 서술
- `UBER-REPROC-C4`: 실패 시 retry topic publish + 원본 offset commit 이라는 Uber 의 구체적 메커니즘
- `UBER-REPROC-C5`: DLQ 를 지속 실패 메시지의 최종 종착점으로 규정한다는 것
- 이 자료가 증명하지 **않는** 것 (중요 — ca-skeleton 적용 전 반드시 확인):
- **이 패턴이 "공식 Kafka best practice"라는 것.** 이 문서는 `company-case-study` 등급이며, Kafka 공식 문서(vendor-doc)의 보강 인용이 이 노트 안에 없다. `UBER-REPROC-C1`/`C2` 를 "Kafka 가 권장하는 방식"으로 서술하면 CLAUDE.md §5·§11 위반(공식 문서와 기술 블로그 혼동)이다.
- **ca-skeleton 의 per-aggregate FIFO 순서 보장 계약과의 양립 가능성.** `UBER-REPROC-C2` 는 오히려 이 패턴이 "순서 비보장을 받아들이는 애플리케이션"을 전제로 함을 명시한다 — ca-skeleton 이 per-aggregate 순서를 보장해야 하는 도메인이라면, retry topic 으로 메시지를 우회시키는 순간 해당 aggregate 의 이후 이벤트가 원본 partition 순서를 앞지를 수 있다는 위험을 이 인용이 시사한다. 이 노트는 그 위험을 **제기**할 뿐, ca-skeleton 에서 실제로 문제가 되는지/어떻게 완화하는지는 증명하지 않는다.
- idempotency 구현 메커니즘의 구체 사항 (owner token protocol 등) — `feature-idempotency-ownership-protocol-contract` 브랜치 소관.
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- ca-skeleton 이 실제로 per-aggregate FIFO 순서를 어느 범위까지 보장하기로 결정했는지 (`feature-kafka-consumer-inbox-contract` §결정-근거 매핑에서 확정 필요) — 이 근거와 대조해야 "다단계 retry topic" 채택 여부를 판단할 수 있다
- Kafka 자체의 "파티션 내부만 순서 보장" 공식 진술 보강 — 현재 vault 의 `raw/official-docs/kafka-message-delivery-semantics-design.md` 는 delivery semantics(at-least-once/exactly-once)만 다루고 ordering-within-partition 자체는 다루지 않는다. 별도 official-doc 수집 필요.
## 메모
- 이 노트의 인용 5개 전부 Self-Grep 통과 (아래 리포트 참조). fabrication 없음.
- Uber 블로그는 "leaky bucket pattern" 이라는 표현도 쓰지만 (본문: "This follows a leaky bucket pattern, preventing request spam.") 이는 별도 인용으로 뽑지 않았다 — Uber 의 해설(패턴 이름 붙이기)이지 구체적 수치·메커니즘 진술이 아니라서 5개 핵심 인용에서는 제외. 필요 시 추가 가능.
- 발행일이 WebFetch 결과에 없어 `needs-confirmation` 으로 남긴다. 원문 페이지에 날짜 메타데이터가 있는지는 브라우저 렌더링으로 재확인 필요할 수 있음.
## 관련
- 같은 도메인 인접 official-doc: [[raw/official-docs/kafka-message-delivery-semantics-design.md]] — at-least-once/idempotent producer 정의 (이 문서가 언급하는 "idempotent consumer 필요성"의 배경이 되는 delivery semantics)
- 이 자료를 인용한 wiki 요약: (생성 시 링크)
@@ -0,0 +1,80 @@
---
title: company-tech-blog / Spring for Apache Kafka — Beyond the Basics: Can Your Kafka Consumers Handle a Poison Pill?
source_type: company-tech-blog
url: https://www.confluent.io/blog/spring-kafka-can-your-kafka-consumers-handle-a-poison-pill/
archive_url:
related_branches: [feature-kafka-consumer-inbox-contract]
related_projects: [ca-skeleton]
tags: [company-tech-blog, ca-skeleton, messaging, kafka, dead-letter-queue]
created: 2026-07-28
---
# company-tech-blog / Spring for Apache Kafka — Beyond the Basics: Can Your Kafka Consumers Handle a Poison Pill?
> Layer: `raw/company-tech-blogs/` — 외부 자료 원문 발췌·출처 기록.
> **등급 caveat (필독)**: Confluent 는 Kafka 상용 벤더이고, 저자(Tim van Baarsen)는 ING Bank 소속 엔지니어로 Confluent 블로그에 기고한 것이다. 이 글은 "타사 프로덕션 장애 사례 보고"라기보다 **벤더 소속 블로그에 실린, 벤더 저자의 Spring Kafka 메커니즘 설명**에 가깝다. CLAUDE.md §5 에 따라 `company-tech-blog` 는 사례/관점으로만 취급하며, 이 글만으로 "공식 best practice"라고 격상하지 않는다. Spring Kafka 공식 레퍼런스(reference docs)의 corroboration 없이는 `official-vendor-doc` strength 를 부여하지 않는다.
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-kafka-consumer-inbox-contract]] | ca-skeleton consumer 가 역직렬화 실패를 재시도 무의미(non-retryable)로 분류하고 즉시 DLT(회수 경로)로 보내야 하는 근거 — poison pill 이 consumer offset 을 전진시키지 못한 채 무한 재시도 루프에 빠뜨리는 실패 메커니즘, 그리고 역직렬화 실패가 `poll()` 반환 *이전*에 발생해 리스너 레벨 예외 처리로는 잡을 수 없다는 사실 |
## 출처
- 원본 URL: https://www.confluent.io/blog/spring-kafka-can-your-kafka-consumers-handle-a-poison-pill/
- 아카이브 URL: (미제공)
- 저자 / 조직: Tim van Baarsen, Senior Software Engineer — ING Bank (Netherlands). Confluent 블로그(벤더 사이트)에 기고
- 발행일: 2020-06-30 (Jun 30, 2020, 페이지 표기 기준)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
ca-skeleton kafka consumer inbox 계약(`feature-kafka-consumer-inbox-contract`)이 "poison pill(역직렬화 실패)은 재시도 무의미 → 즉시 DLT" 로 분류하려는 결정의 근거로, poison pill 이 consumer 를 어떤 메커니즘으로 멈추게 하는지(offset 미전진 + 무한 재시도)와 그 실패가 Spring 리스너 레벨 예외 처리보다 이른 시점(`poll()` 반환 전)에 발생한다는 사실을 원문으로 확보하기 위함.
## 핵심 인용
> [§"What is a poison pill?"] "A poison pill (in the context of Kafka) is a record that has been produced to a Kafka topic and always fails when consumed, no matter how many times it is attempted." (line 23 in fetched text)
> [§"How can you survive a poison pill scenario?" — ErrorHandlingDeserializer 설명] "When a deserializer fails to deserialize a message, Spring has no way to handle the problem, because it occurs before the poll() returns." (line 113 in fetched text)
> [§"What can go wrong if I don't protect my application against poison pills?"] "The consumption of the topic partition is blocked because the consumer offset is not moving forward." (line 89 in fetched text)
> [§"What can go wrong if I don't protect my application against poison pills?"] "The consumer will try again and again (very rapidly) to deserialize the record but will never succeed." (line 90 in fetched text)
> [§"How can you survive a poison pill scenario?" — 요약] "the poison pill is handled and logged. The consumer offset moves forward so that the consumer can continue consuming the next record." (line 139 in fetched text)
## Claims Extracted
| Claim ID | Claim | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| CONF-POISON-C1 | poison pill 은 Kafka 토픽에 생성된 뒤 몇 번을 시도해도 항상 소비에 실패하는 레코드로 정의된다 | "A poison pill (in the context of Kafka) is a record that has been produced to a Kafka topic and always fails when consumed, no matter how many times it is attempted." | company-case-study | poison pill 용어 정의 일반. 역직렬화 실패를 포함한 "항상 실패하는 레코드" 개념 범위 | 이 정의가 Kafka 또는 Spring 공식 문서에 등재된 표준 용어라는 것 (벤더 블로그 저자의 서술) |
| CONF-POISON-C2 | 역직렬화 실패는 `poll()` 이 반환되기 이전에 발생하므로 Spring 은 리스너 레벨에서 이 문제를 처리할 방법이 없다 | "When a deserializer fails to deserialize a message, Spring has no way to handle the problem, because it occurs before the poll() returns." | company-case-study | ca-skeleton consumer 가 리스너(listener) 레벨 try/catch 나 애플리케이션 예외 핸들러만으로 역직렬화 실패를 잡을 수 없다는 아키텍처 제약의 근거 | 모든 Spring Kafka 버전에서 이 메커니즘이 동일하게 유지된다는 것 — 원문은 spring-kafka 2.5.0(2020) 기준 스택 트레이스를 인용하며, 이후 버전 변경 여부는 이 글만으로 확인 불가 |
| CONF-POISON-C3 | 처리되지 않은 poison pill 은 consumer offset 을 전진시키지 못해 해당 topic partition 의 consumption 을 정지시킨다 | "The consumption of the topic partition is blocked because the consumer offset is not moving forward." | company-case-study | poison pill 미처리 시 offset stuck 실패 메커니즘 일반 | auto-commit 과 manual ack 등 커밋 방식 차이에 따라 이 거동이 달라지는지 여부 — 원문은 커밋 방식별 차이를 별도로 분석하지 않음 |
| CONF-POISON-C4 | 처리되지 않은 poison pill 은 consumer 가 동일 레코드를 매우 빠르게 반복 재시도하며 결코 성공하지 못하는 상태를 만든다 | "The consumer will try again and again (very rapidly) to deserialize the record but will never succeed." | company-case-study | poison pill 미처리 시 무한 재시도 루프 실패 근거 | 재시도 사이 backoff/interval 이 존재하는지, 로그 볼륨 외의 구체적 리소스 소모 수치 — 원문은 "very rapidly" 로만 서술하고 수치를 제시하지 않음 |
| CONF-POISON-C5 | ErrorHandlingDeserializer 를 구성하면 poison pill 이 처리·로깅되고 consumer offset 이 전진해 다음 레코드 소비를 계속할 수 있다 | "the poison pill is handled and logged. The consumer offset moves forward so that the consumer can continue consuming the next record." | company-case-study | ca-skeleton 이 역직렬화 실패 레코드를 격리(DLT 등)하고 offset 을 커밋해야 한다는 결정의 방향성 근거 | ErrorHandlingDeserializer 가 ca-skeleton 이 채택할 구체적 DLT 라우팅 메커니즘(retry topic, replay 정책 등)의 유일한 구현 방법이라는 것 — 원문은 Spring Kafka 의 한 가지 구성 예시만 보여줌 |
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `CONF-POISON-C1`: poison pill 의 정의(항상 실패하는 레코드)
- `CONF-POISON-C2`: 역직렬화 실패가 `poll()` 반환 이전에 발생해 Spring 리스너 레벨 예외 처리로 잡을 수 없다는 메커니즘
- `CONF-POISON-C3`, `CONF-POISON-C4`: 처리되지 않은 poison pill 이 offset 미전진 + 무한 재시도로 이어지는 실패 사슬
- `CONF-POISON-C5`: ErrorHandlingDeserializer 적용 시 offset 이 전진해 poison pill 을 우회할 수 있다는 방향성
- 이 자료가 증명하지 않는 것:
- Kafka 또는 Spring 공식 문서가 "역직렬화 실패는 항상 DLT 로 보내야 한다"고 명시한다는 것 — 이 글은 company-tech-blog(벤더 사례)이며 공식 best practice 문서가 아니다 (CLAUDE.md §5)
- ca-skeleton 이 채택할 구체적 DLT 토픽 명명, retry 정책, replay 감사 메커니즘 — 이 글은 Spring Kafka 의 `ErrorHandlingDeserializer` 구성 예시 하나만 제시
- 최신 Spring Kafka 버전(2020년 이후)에서도 동일한 `poll()` 이전 실패 메커니즘이 유지된다는 것 — 버전별 재검증 필요
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- ca-skeleton 이 실제 사용할 Spring Kafka / spring-boot 버전에서 `ErrorHandlingDeserializer``DefaultErrorHandler`(또는 후속 API)의 현재 동작이 이 글의 서술과 동일한지 공식 Spring Kafka 레퍼런스로 재확인
- manual acknowledgement 모드에서 poison pill 발생 시 offset 커밋 시점이 이 글의 auto-commit 전제와 동일한지 확인 (branch 범위: manual ack 채택 예정)
## 메모
- 인용 1 해석 후보 (미검증): "poison pill" 은 Kafka 생태계에서 널리 쓰이는 은유적 표현으로 보이나, 이 글 자체가 그 유래를 규명하지는 않음 — RFC/공식 용어집 등재 여부는 별도 확인 필요.
- 추가로 봐야 할 동일 출처 페이지: Spring Kafka 공식 레퍼런스의 `ErrorHandlingDeserializer` / `DefaultErrorHandler` / `DeadLetterPublishingRecoverer` 섹션 (official-vendor-doc corroboration 후보).
## 관련
- 같은 주제 다른 official-doc / company-tech-blog: (아직 없음 — Spring Kafka 공식 레퍼런스 등록 시 여기 추가)
- 이 자료를 인용한 wiki 요약: `[[wiki/concepts/...]]` (생성 시)
@@ -0,0 +1,85 @@
---
title: AWS — Amazon S3 multipart upload limits
source_type: official-doc
url: https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html
archive_url:
related_branches: [feature-frontend-large-object-transfer-contract]
related_projects: [ca-skeleton-frontend]
tags: [frontend, transfer, upload, multipart, object-storage, aws-s3]
created: 2026-07-28
---
# AWS — Amazon S3 multipart upload limits
> Layer: `raw/` — 외부 자료의 **원문 발췌·출처 기록**.
## 활용 branch (필수, 최소 1개+)
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| `[[raw/branch-notes/feature-frontend-large-object-transfer-contract]]` | `TRANSFER_PART_SIZE_BYTES` 의 **하한 검증**이 필요한 이유(`D8`), part 수 상한이 최대 전송 크기를 결정한다는 사실 |
## 출처
- 원본 URL: https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html
- 보조 URL:
- 아카이브 URL:
- 저자 / 조직: Amazon Web Services (Amazon S3 User Guide)
- 발행일: (문서 지속 갱신)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
branch 가 part size default 를 8 MiB, 병렬도를 3 으로 적어 두었는데 **"측정값이 아니라 초기 default"** 라고만 밝혀 두었다. 문제는 part size 가 취향의 문제가 아니라 **vendor 가 강제하는 하한**이 있는 값이라는 점이다. 하한 아래로 설정하면 전송이 마지막 part 를 제외한 전 구간에서 거부된다. vendor 를 아직 고르지 않았더라도(`FE-Q-012`) 대표적인 구현 하나의 실제 수치를 확보해야 "하한 검증을 계약에 넣어야 한다" 를 근거 있게 말할 수 있다.
> ⚠️ **vendor 선택이 아니다.** 이 문서는 Amazon S3 의 제약을 증명할 뿐이며, 이 프로젝트가 S3 를 쓴다는 결정은 존재하지 않는다(`FE-Q-012` 미해소). 다른 vendor 의 수치는 다를 수 있다.
## 핵심 인용
> [§Amazon S3 multipart upload limits] "In general, when your object size reaches 100 MB, you should consider using multipart uploads instead of uploading the object in a single operation."
> [§Amazon S3 multipart upload limits] "There is no minimum size limit on the last part of your multipart upload."
> [§표 — Maximum object size] "48.8 TiB"
> [§표 — Maximum number of parts per upload] "10,000"
> [§표 — Part numbers] "1 to 10,000 (inclusive)"
> [§표 — Part size] "5 MiB to 5 GiB. There is no minimum size limit on the last part of your multipart upload."
> [§표 — Maximum number of parts returned for a list parts request] "1000"
## 추출된 주장
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| C1 | S3 multipart 의 part size 는 **5 MiB 이상 5 GiB 이하**여야 한다 | [§표 — Part size] "5 MiB to 5 GiB." | `official-reference` | Amazon S3 | 다른 object storage 의 하한이 같다는 것 |
| C2 | 마지막 part 에는 최소 크기 제한이 없다 | [§limits] "There is no minimum size limit on the last part of your multipart upload." | `official-reference` | Amazon S3 | 마지막 part 를 특별 취급하지 않아도 된다는 뜻 — 오히려 분할 로직이 이 예외를 알아야 한다 |
| C3 | upload 당 part 수는 최대 **10,000** 이며 part number 는 1~10,000 이다 | [§표] "Maximum number of parts per upload / 10,000" | `official-reference` | Amazon S3 | 10,000 을 넘는 분할이 다른 vendor 에서도 불가능하다는 것 |
| C4 | 단일 객체 최대 크기는 48.8 TiB 다 | [§표 — Maximum object size] "48.8 TiB" | `official-reference` | Amazon S3 | 브라우저가 그 크기를 다룰 수 있다는 것 |
| C5 | list parts 요청 1회가 돌려주는 part 는 최대 1000 개다 | [§표] "Maximum number of parts returned for a list parts request / 1000" | `official-reference` | S3 API 소비자 | 프론트가 list parts 를 직접 호출한다는 것 — presigned 방식에서는 backend 책임일 수 있다 |
| C6 | 100 MB 부터 multipart 사용을 고려하도록 권고한다 | [§limits] "when your object size reaches 100 MB, you should consider using multipart uploads" | `official-reference` | S3 사용자 일반 | 100 MB 미만에서 multipart 가 금지된다는 것 — 권고이지 제약이 아님 |
## 적용 경계
- 이 자료가 직접 증명하는 것:
- `C1`~`C5`: S3 의 part size 범위·part 수 상한·객체 크기 상한이라는 **하드 제약**의 존재와 실제 수치
- 이 자료가 증명하지 않는 것:
- 이 프로젝트가 S3 를 쓴다는 것 (`FE-Q-012` 미해소)
- part size 8 MiB 나 병렬도 3 이 **최적**이라는 것 — 이 문서는 성능이 아니라 허용 범위만 말한다
- 브라우저에서 10,000 part 를 병렬 관리하는 것이 현실적이라는 것
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- 실제 선택 vendor 의 part size 하한 (`FE-Q-012`) — 그 값이 `TRANSFER_PART_SIZE_BYTES` 검증의 기준이 된다
- part size × part 수 상한으로 계산되는 최대 전송 크기를 제품이 수용하는지
## 메모
- 인용 1 해석 후보 (미검증): `C1`+`C3` 을 곱하면 **고정 part size 가 최대 전송 크기를 결정**한다. 8 MiB × 10,000 ≈ 80 GB. 이 계산은 우리 도출이며 AWS 가 그렇게 적은 것이 아니다. 그 이상을 전송하려면 part size 를 키우거나 분할 전략을 바꿔야 한다.
- 인용 2 해석 후보 (미검증): `C1` 의 하한 때문에 `TRANSFER_PART_SIZE_BYTES` 는 자유값이 아니라 **검증 대상 계약값**이다. 8 MiB 는 S3 하한을 넘지만, 하한을 넘는다는 사실 자체를 부팅 시 검사하지 않으면 vendor 를 바꾸는 순간 조용히 깨진다.
- 추가로 봐야 할 동일 출처 페이지: `mpuoverview` (multipart upload 개요), presigned URL 만료 정책 문서
## 관련
- 같은 주제 다른 official-doc: `[[raw/official-docs/mdn-http-range-fetch-transfer]]`, `[[raw/official-docs/mdn-storage-quotas-eviction-persistence]]`
- 이 자료를 인용한 wiki 요약: 생성 전
@@ -0,0 +1,106 @@
---
title: official-doc / MongoDB Change Streams — Availability, Resume Tokens, Invalidate Events
source_type: official-doc
url: https://www.mongodb.com/docs/manual/changeStreams/
archive_url:
related_branches: [feature-mongo-runtime-baseline-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, persistence, mongodb, change-stream]
created: 2026-07-28
---
# MongoDB Change Streams — Availability, Resume Tokens, Invalidate Events
> Layer: `raw/` — 외부 자료(공식 문서 / 대기업 기술 블로그)의 **원문 발췌·출처 기록**.
> 본 템플릿은 `raw/official-docs/``raw/company-tech-blogs/` 두 폴더가 공유.
> 검증된 요약은 `/ingest``wiki/concepts/``source-summary-template` 형식으로 별도 작성. 원본은 raw에 영구 보관.
## source_type 허용값
- `official-doc` — MongoDB 공식 Database Manual (Server 8.3 current)
## 활용 branch (필수, 최소 1개+)
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-mongo-runtime-baseline-contract]] | "change stream 은 replica set / sharded cluster 요건 검증을 통과할 때만 활성화하고, resume token 을 checkpoint 저장소에 영속화하며, oplog window 를 감시한다" 결정의 근거 |
## 출처
- 원본 URL: https://www.mongodb.com/docs/manual/changeStreams/
- 아카이브 URL: (미등록)
- 저자 / 조직: MongoDB, Inc. (공식 Database Manual)
- 발행일: 확인 불가 (지속 갱신 레퍼런스 문서, 페이지 상단 버전 표기: Database Manual 8.3 (Current))
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-mongo-runtime-baseline-contract` 브랜치가 change stream 활성화 조건(배포 형태·storage engine·read concern majority 요건), resume token 기반 checkpoint 설계(resumeAfter/startAfter/startAtOperationTime), oplog window 부족 시 resume 실패, invalidate event 로 인한 stream 종료 조건을 결정하기 위한 1차 공식 근거.
## 핵심 인용
> [§Availability] "Change streams are available for replica sets and sharded clusters"
> [§Availability — Storage Engine] "The replica sets and sharded clusters must use the WiredTiger storage engine."
> [§Availability — Read Concern "majority" Enablement] "Change streams are available regardless of the "majority" read concern support; that is, read concern majority support can be either enabled (default) or disabled to use change streams."
> [§Resume a Change Stream — resumeAfter] "You can resume a change stream after a specific event by passing a resume token to resumeAfter when opening the cursor."
> [§Resume a Change Stream — startAfter] "Unlike resumeAfter , startAfter can resume notifications after an invalidate event by creating a new change stream."
> [§Open a Change Stream — startAtOperationTime] "You can specify a startAtOperationTime to open the cursor at a particular point in time. If the specified starting point is in the past, it must be in the time range of the oplog."
> [§resumeAfter for Change Streams / startAfter for Change Streams — Important note (oplog window)] "The oplog must have enough history to locate the operation associated with the token or the timestamp, if the timestamp is in the past."
> [§Open a Change Stream — cursor lifecycle] "The cursor is explicitly closed." / "An invalidate event occurs; for example, a collection drop or rename." / "The connection to the MongoDB deployment closes or times out." / "If the deployment is a sharded cluster, a shard removal may cause an open change stream cursor to close."
## Claims Extracted / 추출된 주장
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| MONGO-CHANGESTREAM-C1 | Change stream 은 replica set 또는 sharded cluster 배포에서만 사용 가능하며, standalone 배포는 지원하지 않는다 | [§Availability] "Change streams are available for replica sets and sharded clusters" | `official-vendor-doc` | 배포 형태 사전 검증 로직 (standalone 배포에서 change stream 활성화 시도를 거부해야 함) | ca-tmpl/ca-skeleton 의 실제 배포 토폴로지에서 이 검증이 어떻게 구현되어야 하는지는 증명하지 않음 |
| MONGO-CHANGESTREAM-C2 | Change stream 을 사용하는 replica set/sharded cluster 는 WiredTiger storage engine 을 사용해야 한다 | [§Availability — Storage Engine] "The replica sets and sharded clusters must use the WiredTiger storage engine." | `official-vendor-doc` | storage engine 사전 조건 검증 | 다른 storage engine(MMAPv1 등) 환경에서의 대체 동작은 다루지 않음 |
| MONGO-CHANGESTREAM-C3 | Change stream 사용에 `"majority"` read concern 지원 활성화 여부는 무관하다 (활성화/비활성화 모두 가능) | [§Read Concern "majority" Enablement] "Change streams are available regardless of the "majority" read concern support; that is, read concern majority support can be either enabled (default) or disabled to use change streams." | `official-vendor-doc` | change stream 자체의 가용 조건 판단 | change stream 이 반환하는 이벤트의 durability/일관성 보장 수준 자체를 설명하지 않음 — read concern majority 가 비활성화된 상태에서 change stream 이 반환하는 이벤트가 이후 rollback 될 수 있는지는 이 문장만으로 증명 불가 (별도 확인 필요) |
| MONGO-CHANGESTREAM-C4 | `resumeAfter` 는 resume token 을 지정해 특정 이벤트 이후부터 change stream 을 재개하는 옵션이다 | [§resumeAfter for Change Streams] "You can resume a change stream after a specific event by passing a resume token to resumeAfter when opening the cursor." | `official-vendor-doc` | checkpoint 저장소에서 읽은 resume token 으로 정상 재개하는 경로 설계 | invalidate event 이후에도 동작하는지는 이 claim 만으로 보장되지 않음 (C5 참조) |
| MONGO-CHANGESTREAM-C5 | `resumeAfter` 와 달리 `startAfter` 는 invalidate event 이후에도 새 change stream 을 시작해 알림을 재개할 수 있다 | [§startAfter for Change Streams] "Unlike resumeAfter , startAfter can resume notifications after an invalidate event by creating a new change stream." | `official-vendor-doc` | invalidate event(컬렉션 drop/rename 등) 발생 후 checkpoint 기반 재개 전략 분기 | `resumeAfter` 로 invalidate event 이후 재개를 시도했을 때의 정확한 에러 형태까지는 이 문장만으로 명시되지 않음 |
| MONGO-CHANGESTREAM-C6 | `startAtOperationTime` 은 특정 시점부터 커서를 여는 옵션이며, 과거 시점을 지정하면 그 시점이 oplog 의 시간 범위 안에 있어야 한다 | [§Open a Change Stream] "You can specify a startAtOperationTime to open the cursor at a particular point in time. If the specified starting point is in the past, it must be in the time range of the oplog." | `official-vendor-doc` | 최초 checkpoint 가 없는 상태에서 특정 시점부터 change stream 을 시작하는 경로 | timestamp 가 oplog 범위를 벗어났을 때의 정확한 실패 메시지/예외 타입은 명시하지 않음 |
| MONGO-CHANGESTREAM-C7 | resume token 또는 timestamp 로 지정된 operation 을 찾으려면 oplog 가 충분한 history 를 보유하고 있어야 하며, 그렇지 못하면 resume 이 불가능하다 (oplog window 요구) | [§resumeAfter/startAfter for Change Streams — Important] "The oplog must have enough history to locate the operation associated with the token or the timestamp, if the timestamp is in the past." | `official-vendor-doc` | oplog window 감시 알람 설계, checkpoint 지연이 oplog retention 을 초과하지 않도록 하는 운영 기준 근거 | 구체적인 oplog 크기 산정 공식이나 권장 retention 시간(초/일 단위)은 이 문장에 없음 — 별도 Production Recommendations 페이지 확인 필요 (`needs-confirmation`) |
| MONGO-CHANGESTREAM-C8 | change stream cursor 는 (a) 명시적 종료, (b) invalidate event(예: 컬렉션 drop 또는 rename) 발생, (c) MongoDB 배포와의 연결 종료/타임아웃, (d) sharded cluster 에서 shard 제거 중 하나가 발생하면 닫힌다 | [§Open a Change Stream] "The cursor is explicitly closed." / "An invalidate event occurs; for example, a collection drop or rename." / "The connection to the MongoDB deployment closes or times out." / "If the deployment is a sharded cluster, a shard removal may cause an open change stream cursor to close." | `official-vendor-doc` | invalidate event 발생 시 stream 종료를 감지하고 checkpoint 재개 전략(`startAfter`)으로 분기하는 에러 핸들링 설계 | 각 종료 사유를 애플리케이션 코드에서 어떻게 구분해 감지하는지(드라이버별 API)는 이 문장에 없음 |
### Strength 허용값
- `official-standard` — RFC, 표준 사양, 언어/프로토콜 표준
- `official-vendor-doc` — Spring, Keycloak, AWS, Google 등 공식 벤더 문서
- `official-reference` — 공식 reference/API 문서
- `company-case-study` — 대기업/실무 기술 블로그의 특정 사례
- `engineering-blog` — 개인/팀 블로그의 엔지니어링 해설
- `tutorial` — 튜토리얼/가이드. 일반화 금지
- `needs-confirmation` — 원문만으로는 적용 판단 불가
## Usage Boundaries / 적용 경계
- 이 자료가 직접 증명하는 것:
- `MONGO-CHANGESTREAM-C1`: change stream 은 replica set/sharded cluster 배포에서만 사용 가능하다 (standalone 불가)
- `MONGO-CHANGESTREAM-C2`: WiredTiger storage engine 이 필요하다
- `MONGO-CHANGESTREAM-C3`: read concern majority 지원 활성화 여부와 무관하게 change stream 사용 가능하다
- `MONGO-CHANGESTREAM-C4`~`C6`: resumeAfter/startAfter/startAtOperationTime 세 옵션의 역할과 각각의 적용 조건
- `MONGO-CHANGESTREAM-C7`: oplog 가 resume token/timestamp 에 해당하는 이벤트를 이미 롤오버(삭제)했다면 resume 이 실패한다 (oplog window 요구)
- `MONGO-CHANGESTREAM-C8`: invalidate event 를 포함한 4가지 조건에서 change stream cursor 가 닫힌다
- 이 자료가 증명하지 않는 것:
- 구체적인 oplog 크기 산정 공식이나 권장 retention 시간 (별도 Production Recommendations 페이지 영역)
- resume token 을 애플리케이션 checkpoint 저장소(DB 테이블 등)에 영속화하는 구체적 구현 패턴 — 이는 원문에 없는 ca-tmpl/ca-skeleton 측 설계 결정
- `resumeAfter` 실패 시 정확한 예외 타입/에러 코드 (드라이버별 상이할 수 있음)
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- ca-skeleton 이 실제로 배포할 MongoDB 토폴로지(replica set 최소 멤버 수, WiredTiger 기본 여부)가 이 요건을 만족하는지 로컬/dev 환경에서 검증 필요
- oplog window 감시 알람의 구체적 임계값(예: checkpoint 지연이 oplog 예상 소진 시간의 몇 %를 넘으면 경보)은 이 자료에 없으므로 별도 설계 필요
## 메모
- oplog window 요구(`MONGO-CHANGESTREAM-C7`)는 정성적 서술("must have enough history")만 있고 정량 기준이 없다. Production Recommendations 페이지(원문에서 "Back Limitations Next Production Recommendations" 로 다음 페이지 링크됨)를 별도 raw 자료로 추가 수집할 필요가 있는지는 `/branch-spec` 단계에서 판단.
- 추가로 봐야 할 동일 출처 페이지: `Production Recommendations`, `Change Stream Events` (invalidate 이벤트의 정확한 document 스키마)
## 관련
- 같은 주제 다른 official-doc: [[raw/official-docs/write-concern-mongodb-official]], [[raw/official-docs/read-preference-mongodb-official]] (동일 벤더, 인접 결정 영역)
- 이 자료를 인용한 wiki 요약: (아직 생성되지 않음)
@@ -0,0 +1,102 @@
---
title: official-doc / Lettuce ClientOptions — autoReconnect, disconnectedBehavior, requestQueueSize
source_type: official-doc
url: https://redis.github.io/lettuce/advanced-usage/client-options/
archive_url:
related_branches: [feature-redis-runtime-role-isolation-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, redis, runtime, connection-pool]
created: 2026-07-28
---
# Lettuce ClientOptions — autoReconnect, disconnectedBehavior, requestQueueSize
> Layer: `raw/` — 외부 자료(공식 문서 / 대기업 기술 블로그)의 **원문 발췌·출처 기록**.
> 본 템플릿은 `raw/official-docs/``raw/company-tech-blogs/` 두 폴더가 공유.
> 검증된 요약은 `/ingest``wiki/concepts/``source-summary-template` 형식으로 별도 작성. 원본은 raw에 영구 보관.
## source_type 허용값
- `official-doc` — 공식 레퍼런스 / 표준 / 사양 (Lettuce 프로젝트 공식 GitHub Pages 문서, `redis/lettuce` 리포의 공식 산출물)
## 활용 branch (필수, 최소 1개+)
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-redis-runtime-role-isolation-contract]] | Redis 연결 런타임에서 `autoReconnect` / `disconnectedBehavior=REJECT_COMMANDS` / 유한한 `requestQueueSize` 를 명시 설정해 Redis 장애가 애플리케이션 heap 고갈로 번지지 않게 한다는 결정의 근거 |
## 출처
- 원본 URL: https://redis.github.io/lettuce/advanced-usage/client-options/ (요청받은 `https://redis.github.io/lettuce/advanced-usage/` 는 404 — Lettuce 공식 사이트 내 `Advanced Usage → Client Options` 하위 페이지로 대체)
- 보조 URL (command replay / at-least-once 시맨틱): https://redis.github.io/lettuce/advanced-usage/command-execution-reliability/
- 아카이브 URL: (미수집 — 필요 시 사용자가 archive.org 스냅샷 지정)
- 저자 / 조직: Lettuce 프로젝트 (Redis 공식 Java 클라이언트, `redis/lettuce` GitHub org)
- 발행일: (페이지에 명시된 발행일 없음 — living reference doc)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
Redis 런타임 role-isolation 브랜치(`feature-redis-runtime-role-isolation-contract`)에서 "Redis 장애 시 애플리케이션 heap 이 커맨드 큐 적체로 고갈되지 않아야 한다"는 요구를 만족하려면 Lettuce `ClientOptions``autoReconnect`/`disconnectedBehavior`/`requestQueueSize` 기본값과 각 옵션의 의미를 정확히 알아야 한다. 이 문서는 그 결정(명시적 설정 필요성)의 1차 공식 근거다.
## 핵심 인용
> [Client Options] "Controls auto-reconnect behavior on connections. 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."
> [Client Options — Disconnected Behavior] "DEFAULT: Accept commands when auto-reconnect is enabled, reject commands when auto-reconnect is disabled." / "ACCEPT_COMMANDS: Accept commands in disconnected state." / "REJECT_COMMANDS: Reject commands in disconnected state."
> [Client Options — Request Queue Size] "Controls the per-connection request queue size. The command invocation will lead to a RedisException if the queue size is exceeded."
> [Client Options — Cluster] "Clustered operations use multiple connections. The resulting overall-queue limit is requestQueueSize * ((number of cluster nodes * 2) + 1)."
> [Command Execution Reliability] "commands may be duplicated but not lost" (at-least-once execution, via retry mechanisms during reconnection; commands are buffered in memory during failures)
## Claims Extracted
> 이 자료가 **직접 말하는 것만** claim 으로 분리한다. 내 프로젝트에 적용한 결론은 여기 쓰지 않는다.
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| LETTUCE-CO-C1 | `autoReconnect` 기본값은 true 이며, 의도치 않게 연결이 끊기면 클라이언트가 재연결·활성화 후 큐에 쌓인 command 를 재발행(re-issue)한다 | "Controls auto-reconnect behavior on connections. 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." | official-vendor-doc | Lettuce 클라이언트 기본 동작 (버전 명시 없음, 현재 reference 문서 기준) | 이 재발행이 모든 command 타입에 안전(idempotent)함을 증명하지 않음 — 중복 실행 가능성은 별도 검토 필요 |
| LETTUCE-CO-C2 | `disconnectedBehavior` 는 DEFAULT(autoReconnect 여부에 종속) / ACCEPT_COMMANDS(항상 버퍼링) / REJECT_COMMANDS(항상 즉시 거부) 3가지 값을 제공한다 | "DEFAULT: Accept commands when auto-reconnect is enabled, reject commands when auto-reconnect is disabled." / "REJECT_COMMANDS: Reject commands in disconnected state." | official-vendor-doc | Lettuce `ClientOptions.disconnectedBehavior` 설정 전반 | REJECT_COMMANDS 를 쓰는 것이 이 프로젝트에 "올바른 선택"이라고 증명하지 않음 — 이는 branch 의 trade-off 결정 |
| LETTUCE-CO-C3 | `requestQueueSize` 기본값은 `Integer.MAX_VALUE`(2147483647)이며, per-connection 단위이고 초과 시 `RedisException` 을 던진다 | "Controls the per-connection request queue size. The command invocation will lead to a RedisException if the queue size is exceeded." | official-vendor-doc | Lettuce `ClientOptions.requestQueueSize` 기본값(사실상 무제한에 가까운 상한) | 기본값이 실제로 OOM 을 유발한다는 실측치는 아님 — "사실상 무제한"이라는 설계상 위험만 보여줌, heap 고갈 실증은 이 문서 범위 밖 |
| LETTUCE-CO-C4 | Redis Cluster 모드에서는 여러 커넥션을 사용하므로 전체 큐 상한이 `requestQueueSize * ((number of cluster nodes * 2) + 1)` 공식으로 배수 증가한다 | "Clustered operations use multiple connections. The resulting overall-queue limit is requestQueueSize * ((number of cluster nodes * 2) + 1)." | official-vendor-doc | Lettuce cluster client 의 큐 상한 계산 | 이 프로젝트가 실제 cluster 배포인지, 이 공식이 모든 Lettuce 버전에서 동일한지는 이 문서만으로 확정 불가 |
| LETTUCE-CO-C5 | auto-reconnect 활성 상태의 기본 동작(at-least-once)은 재연결 시 buffered command 를 재시도하므로 "손실은 없지만 중복될 수 있다" | "commands may be duplicated but not lost" | official-vendor-doc | auto-reconnect 활성 상태의 command 재전송(replay) 시맨틱 | Lettuce 6.6+ 의 "replay filter" 기능 존재 여부·설정 방법은 fetch 결과에서 verbatim 인용으로 확인되지 않아 이 문서에서 claim 화하지 않음(§Usage Boundaries 참조) |
### Strength 허용값
- `official-standard` — RFC, 표준 사양, 언어/프로토콜 표준
- `official-vendor-doc` — Spring, Keycloak, AWS, Google 등 공식 벤더 문서
- `official-reference` — 공식 reference/API 문서
- `company-case-study` — 대기업/실무 기술 블로그의 특정 사례
- `engineering-blog` — 개인/팀 블로그의 엔지니어링 해설
- `tutorial` — 튜토리얼/가이드. 일반화 금지
- `needs-confirmation` — 원문만으로는 적용 판단 불가
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `LETTUCE-CO-C1`: `autoReconnect` 기본값(true)과 재연결 시 큐에 쌓인 command 재발행 동작
- `LETTUCE-CO-C2`: `disconnectedBehavior` 3개 값의 정의 (DEFAULT/ACCEPT_COMMANDS/REJECT_COMMANDS)
- `LETTUCE-CO-C3`: `requestQueueSize` 기본값이 `Integer.MAX_VALUE` 이고 per-connection 단위이며 초과 시 예외를 던진다는 사실
- `LETTUCE-CO-C4`: cluster 모드에서 전체 큐 상한이 노드 수에 비례해 배수 증가한다는 공식
- `LETTUCE-CO-C5`: auto-reconnect 활성 상태에서 command 가 손실 없이(다만 중복 가능하게) 재시도된다는 at-least-once 시맨틱
- 이 자료가 증명하지 않는 것:
- `requestQueueSize` 기본값(사실상 무제한)이 실제로 이 프로젝트에서 heap 고갈을 일으킨다는 실측 근거 — 이 문서는 설계상 위험(상한이 매우 큼)만 보여줄 뿐, 이 프로젝트의 부하·메모리 프로파일에서의 결과는 별도 검증 필요
- `disconnectedBehavior=REJECT_COMMANDS` 선택이 이 프로젝트에 "공식 best practice"라는 것 — 공식 문서는 옵션과 기본값만 설명하며 어떤 값을 골라야 하는지 권고하지 않음. 선택 자체는 branch 의 trade-off 결정
- Lettuce 6.6+ "command replay filter" 기능의 존재·설정 방법 — WebFetch 결과에서 이 부분은 verbatim 인용으로 확인되지 않아 claim 화하지 않았음 (fabrication 방지, §메모 참조)
- Cluster topology refresh (periodic/adaptive) 설정 — 요청받은 항목이었으나 `advanced-usage/client-options/``advanced-usage/command-execution-reliability/` 두 페이지 어디에도 verbatim 으로 존재하지 않아 이 문서에 포함하지 않음. 필요 시 별도 raw-source 조사 대상 (아래 §메모 참조)
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- ca-skeleton 의 실제 `LettuceClientConfiguration`/`ClientOptions` 빌더 설정에서 `autoReconnect(true)` + `disconnectedBehavior(REJECT_COMMANDS)` + 명시적 `requestQueueSize` 상한이 role(cache/coordination/session) 별로 실제 반영되는지 코드/테스트 검증
## 메모
> 나중에 wiki로 옮길 때 참고할 짧은 메모. 검증되지 않은 내 추론은 여기에 두지 말 것.
- WebFetch 처리 특성상 결과가 "요약 + 원문 발췌(따옴표)" 혼합 형태로 반환됨. 이 문서의 `## 핵심 인용`/`## Claims Extracted` 표에는 **따옴표로 감싸져 있고 self-grep 으로 확인된 문장만** 채택했다. "Lettuce 6.6+ replay filter" 서술은 fetch 결과에서 따옴표 없이(모델의 재서술로) 나타나 verbatim 확인 대상에서 제외 — claim 화하지 않음.
- Cluster topology refresh (periodic/adaptive refresh) 설정은 별도 페이지(추정: High-Availability and Sharding 섹션 하위)에 있을 가능성이 높음. 이 branch 에서 topology refresh 근거가 필요하면 별도 dispatch(URL: 해당 페이지 확정 후) 필요.
- 요청받은 원 URL `https://redis.github.io/lettuce/advanced-usage/` 는 404. 실제 유효 페이지는 `https://redis.github.io/lettuce/advanced-usage/client-options/` (frontmatter `url:` 에 반영).
## 관련
- 같은 주제 다른 official-doc: (아직 없음 — Redis Cluster topology refresh 전용 자료는 후속 수집 대상)
- 이 자료를 인용한 wiki 요약: `[[wiki/concepts/...]]` (생성 시)
@@ -3,7 +3,7 @@ title: "official-doc / Spring Boot — Externalized Configuration (Features Refe
source_type: official-doc
url: https://docs.spring.io/spring-boot/reference/features/external-config.html
archive_url:
related_branches: [feature-env-driven-runtime-configuration]
related_branches: [feature-env-driven-runtime-configuration, feature-capability-provider-selection-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, application, spring-boot, bean-validation, externalized-config, profile-activation]
created: 2026-06-05
@@ -19,6 +19,7 @@ created: 2026-06-05
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-env-driven-runtime-configuration]] | D4: Duration `30s`/`PT30S` 양쪽 허용 확인 (우리 규약이 `30s` 1택을 선택해도 됨을 Spring 공식 근거로 확인) / D6: `spring.profiles.active` 및 relaxed binding 규칙(`SPRING_PROFILES_ACTIVE` 도출 메커니즘) Spring Boot native 공식 근거 / D10: `@ConfigurationProperties + @Validated` JSR-303 startup validation fail-fast 공식 근거 |
| [[raw/branch-notes/feature-capability-provider-selection-contract]] | capability 활성화 property 를 단일 prefix 로 통일하는 결정의 근거 — relaxed binding 이 dash/capitalized 환경변수를 동일 property 로 취급하는 메커니즘, placeholder canonical form(kebab-case) 권장, `@ConfigurationProperties` prefix→bean 명명 규칙, type-safe binding 이점, OS 환경변수가 `application.yml`(config data)보다 나중에 평가되는(우선하는) PropertySource 순서 — 현재 `ca-skeleton.*`/`app.*` 이중 prefix 와 `APP_*` 환경변수 registry 통일 필요성의 공식 근거 |
## 출처 / Source
@@ -46,6 +47,22 @@ created: 2026-06-05
> [§features.external-config.typesafe-configuration-properties.validation, line 48974898] "Spring Boot attempts to validate `@ConfigurationProperties` classes whenever they are annotated with Spring's `@Validated` annotation. You can use JSR-303 `jakarta.validation` constraint annotations directly on your configuration class."
### 추가 인용 (2026-07-28, `feature-capability-provider-selection-contract` 근거 수집, 6개)
> 셀프그렙 검증 원본: `/tmp/source-fetch-20260728-155300.txt` (WebFetch 결과 저장, self-grep 통과)
> [§Relaxed Binding — 환경변수 특수문자 치환, self-grep L7] "If you use environment variables rather than system properties, most operating systems disallow period-separated key names, but you can use underscores instead (for example, `SPRING_CONFIG_NAME` instead of `spring.config.name`)."
> [§Relaxed Binding — 일반 규칙 + 예시, self-grep L11] "Spring Boot uses some relaxed rules for binding Environment properties to @ConfigurationProperties beans, so there does not need to be an exact match between the Environment property name and the bean property name. Common examples where this is useful include dash-separated environment properties (for example, `context-path` binds to `contextPath`), and capitalized environment properties (for example, `PORT` binds to `port`)."
> [§Placeholder canonical form 권장, self-grep L17] "You should always refer to property names in the placeholder using their canonical form (kebab-case using only lowercase letters). This will allow Spring Boot to use the same logic as it does when relaxed binding @ConfigurationProperties."
> [§@ConfigurationProperties bean 명명 규칙 (prefix), self-grep L29] "When the @ConfigurationProperties bean is registered using configuration property scanning or through @EnableConfigurationProperties, the bean has a conventional name: `<prefix>-<fqn>`, where `<prefix>` is the environment key prefix specified in the @ConfigurationProperties annotation and `<fqn>` is the fully qualified name of the bean."
> [§Type-safe Configuration Properties 이점, self-grep L33] "Using the @Value(\"${property}\") annotation to inject configuration properties can sometimes be cumbersome, especially if you are working with multiple properties or your data is hierarchical in nature. Spring Boot provides an alternative method of working with properties that lets strongly typed beans govern and validate the configuration of your application."
> [§PropertySource 순서 목록 (item 3, 5) + 순서 방향 확인 문장, self-grep L45/L47/L61] "3. Config data (such as `application.properties` files)" ... "5. OS environment variables" ... "As mentioned previously, command line properties always take precedence over file-based property sources."
## Claims Extracted / 추출된 주장
> 이 자료가 **직접 말하는 것만** claim 으로 분리한다. 내 프로젝트에 적용한 결론은 여기 쓰지 않는다.
@@ -57,6 +74,12 @@ created: 2026-06-05
| SPRING-EXTCONFIG-C3 | Spring Framework `DataSize` 프로퍼티는 `long`(기본 bytes)과 단순 suffix(`10MB`) 두 형식을 허용한다 | [§conversion.data-sizes, l.4733] "To specify a buffer size of 10 megabytes, `10` and `10MB` are equivalent." | `official-vendor-doc` | `@ConfigurationProperties` 바인딩 `DataSize` 필드 | `DataSize` 가 ISO-8601 형식을 지원하지 않음을 증명하지 않음 (Duration 과 달리 ISO-8601 언급 없음) |
| SPRING-EXTCONFIG-C4 | Spring Boot relaxed binding 은 프로퍼티 이름의 점(`.`)을 언더스코어(`_`)로, 대시(`-`)를 제거하고, 대문자로 변환하여 OS 환경 변수 이름에 매핑한다 | [§relaxed-binding.environment-variables, l.3966] "For example, the configuration property `spring.main.log-startup-info` would be an environment variable named `SPRING_MAIN_LOGSTARTUPINFO`." | `official-vendor-doc` | Spring Boot 환경 변수 바인딩 전체 (`systemEnvironment` property source 및 `-systemEnvironment` suffix 를 가진 추가 property source) | `SPRING_PROFILES_ACTIVE` 라는 이름이 문서에 명시적으로 나열되지는 않음 — 규칙 적용의 당연한 귀결 |
| SPRING-EXTCONFIG-C5 | Spring Boot 는 `@Validated` 애노테이션이 붙은 `@ConfigurationProperties` 클래스를 자동으로 검증하며, `jakarta.validation` JSR-303 제약 애노테이션을 필드에 직접 사용할 수 있다 | [§validation, l.48974898] "Spring Boot attempts to validate `@ConfigurationProperties` classes whenever they are annotated with Spring's `@Validated` annotation. You can use JSR-303 `jakarta.validation` constraint annotations directly on your configuration class." | `official-vendor-doc` | Spring Boot 의 `@ConfigurationProperties` + `@Validated` 조합 | 검증 실패 시 startup 이 fail-fast 로 중단된다는 명시적 문구는 이 문서에 없음 — Spring Bean 초기화 실패로 컨텍스트 로드 실패가 발생함은 Spring Framework 일반 동작 |
| SPRING-EXTCONFIG-C6 | Spring Boot relaxed binding 은 dash-separated 환경변수(`context-path``contextPath`)와 capitalized 환경변수(`PORT``port`)를 각각 bean property 이름으로 매핑한다 | [§Relaxed Binding, self-grep L11] "Common examples where this is useful include dash-separated environment properties (for example, `context-path` binds to `contextPath`), and capitalized environment properties (for example, `PORT` binds to `port`)." | `official-vendor-doc` | `@ConfigurationProperties` 바인딩 전체 (환경변수 소스) | 어떤 표기가 "정식" 표기인지는 말하지 않음 — canonical form 권장은 별도 claim(C8) |
| SPRING-EXTCONFIG-C7 | 대부분의 OS 는 마침표(`.`)로 구분된 키 이름을 환경변수로 쓸 수 없어서, Spring Boot 는 언더스코어(`_`) 치환을 허용한다(예: `spring.config.name``SPRING_CONFIG_NAME`) | [§Relaxed Binding — 환경변수 특수문자 치환, self-grep L7] "...most operating systems disallow period-separated key names, but you can use underscores instead (for example, `SPRING_CONFIG_NAME` instead of `spring.config.name`)." | `official-vendor-doc` | 환경변수로 property 를 지정하는 모든 경우 | 대문자화가 반드시 함께 요구되는지는 이 문장만으로는 규정하지 않음(대문자 규칙은 C6/C9 예시들이 뒷받침) |
| SPRING-EXTCONFIG-C8 | `${...}` placeholder 에서 property 이름을 참조할 때는 항상 canonical form(소문자 kebab-case)을 쓰는 것이 권장된다 — 그래야 relaxed binding 과 동일 로직이 적용된다 | [§Placeholder canonical form 권장, self-grep L17] "You should always refer to property names in the placeholder using their canonical form (kebab-case using only lowercase letters)." | `official-vendor-doc` | `${demo.item-price}` 류의 placeholder 참조 표기 | property 소스 파일(`application.yml`) 자체의 키 표기 관례까지 강제한다고 명시하진 않음 — placeholder 참조 표기에 대한 권장 |
| SPRING-EXTCONFIG-C9 | `@ConfigurationProperties` bean 이 configuration property scanning 또는 `@EnableConfigurationProperties` 로 등록되면, bean 이름은 `<prefix>-<fqn>` 관례를 따르며 `<prefix>` 는 애노테이션에 지정된 environment key prefix 다 | [§@ConfigurationProperties bean 명명 규칙, self-grep L29] "...the bean has a conventional name: `<prefix>-<fqn>`, where `<prefix>` is the environment key prefix specified in the @ConfigurationProperties annotation..." | `official-vendor-doc` | scanning 또는 `@EnableConfigurationProperties` 로 등록된 `@ConfigurationProperties` bean | 이 문장은 bean 이름 관례를 설명할 뿐, "단일 prefix 를 써야 한다"는 정책을 직접 규정하지 않음 — prefix 는 클래스마다 자유롭게 지정 가능하다는 사실만 확인됨 |
| SPRING-EXTCONFIG-C10 | `@Value("${property}")` 로 설정을 주입하는 방식은 다수 property 나 계층 구조 데이터를 다룰 때 번거로울 수 있으며, Spring Boot 는 strongly-typed bean 이 설정을 관리·검증하게 하는 대안(type-safe `@ConfigurationProperties`)을 제공한다 | [§Type-safe Configuration Properties 이점, self-grep L33] "Using the @Value(\"${property}\") annotation to inject configuration properties can sometimes be cumbersome... Spring Boot provides an alternative method of working with properties that lets strongly typed beans govern and validate the configuration of your application." | `official-vendor-doc` | `@Value` vs `@ConfigurationProperties` 선택 비교 | 특정 property 개수·계층 깊이 기준으로 `@ConfigurationProperties` 전환을 강제하지 않음 — "cumbersome 할 수 있다"는 정성적 진술 |
| SPRING-EXTCONFIG-C11 | 공식 문서는 PropertySource 를 15개 항목의 순서 목록으로 제공하며, Config data(`application.properties` 류 파일)는 3번째, OS 환경변수는 5번째로 나열된다. 별도로 커맨드라인 인자(11번째)가 파일 기반 소스보다 항상 우선한다고 명시하여, 목록에서 더 나중에 나열된 소스가 우선한다는 방향을 확인해준다 | [§PropertySource 순서, self-grep L45/L47/L61] "3. Config data (such as `application.properties` files)" ... "5. OS environment variables" ... "As mentioned previously, command line properties always take precedence over file-based property sources." | `official-vendor-doc` | Spring Boot 의 전체 PropertySource 우선순위 체계 (config data 파일 vs OS 환경변수 포함) | "OS 환경변수가 config data 파일보다 우선한다"는 문장이 이 발췌에 축자적으로 존재하지는 않음 — 목록 순서 + 커맨드라인/파일 우선순위 진술의 결합 추론. `SPRING_PROFILES_ACTIVE` 같은 특정 키의 실제 override 동작은 별도 통합 테스트 필요 |
## Usage Boundaries / 적용 경계
@@ -66,14 +89,23 @@ created: 2026-06-05
- `SPRING-EXTCONFIG-C3`: `DataSize``10MB` suffix 형식을 수용 (D4 DataSize 근거)
- `SPRING-EXTCONFIG-C4`: `spring.profiles.active` 는 relaxed binding 규칙에 의해 `SPRING_PROFILES_ACTIVE` 로 매핑됨 (D6 메커니즘 근거)
- `SPRING-EXTCONFIG-C5`: `@ConfigurationProperties + @Validated` 는 공식 Spring Boot API (D10 공식 근거)
- `SPRING-EXTCONFIG-C6`: relaxed binding 이 dash/capitalized 환경변수 표기를 property 이름으로 매핑 (capability-provider-selection 근거)
- `SPRING-EXTCONFIG-C7`: 마침표 구분 키를 언더스코어 환경변수로 대체 허용
- `SPRING-EXTCONFIG-C8`: placeholder 참조는 canonical form(kebab-case) 사용을 권장
- `SPRING-EXTCONFIG-C9`: `@ConfigurationProperties` bean 이름이 `<prefix>-<fqn>` 관례를 따름 — prefix 는 애노테이션에 클래스마다 지정하는 값
- `SPRING-EXTCONFIG-C10`: `@Value` 대비 type-safe `@ConfigurationProperties` 의 이점(다수/계층적 property 관리·검증)
- `SPRING-EXTCONFIG-C11`: PropertySource 순서 목록 + "커맨드라인이 파일 기반보다 우선" 진술로 뒷받침되는 우선순위 방향
- 이 자료가 증명하지 않는 것:
- `30s` 형식이 `PT30S` 보다 더 권장됨 (C1은 "동등하다"고만 말함 — 규약 선택은 팀 결정)
- `SPRING_PROFILES_ACTIVE``APP_PROFILE` 이 불일치할 때 startup 이 자동으로 fail-fast 되는 동작 (별도 `EnvironmentPostProcessor` 구현 필요)
- `@Validated` 실패가 반드시 startup 중단을 일으킨다는 명시 (Spring context 초기화 실패가 JVM exit 을 일으키는 것은 Spring Boot 런처 일반 동작이나 이 문서에 명시 없음)
- `SPRING_PROFILES_ACTIVE` 라는 정확한 환경 변수 이름이 문서에 명시적으로 나타남 (규칙 귀결)
- **"capability 활성화 property 를 단일 prefix 로 통일해야 한다"는 정책을 이 문서가 직접 규정하지 않음** — C9 는 prefix 가 클래스마다 자유롭게 지정 가능한 애노테이션 속성임을 보여줄 뿐, "prefix 를 하나로 통일하라"는 권고는 없음. 단일 prefix 결정은 `ca-skeleton.*`/`app.*` 이중 prefix 가 relaxed binding·canonical form 규칙 위에서 일관성을 해친다는 branch 자체의 판단이며, 이 raw 자료는 그 판단이 딛고 서는 메커니즘(관례상 규칙 자체는 일관 표기를 전제로 설계됨)만 뒷받침한다
- `SPRING_PROFILES_ACTIVE`/`APP_*` 같은 특정 키가 config data 보다 실제로 override 하는지의 런타임 동작 확인(C11 은 목록 순서 + 별도 진술의 결합 추론이며, 이 문서 발췌에 "환경변수가 config data 보다 우선한다"는 축자 문장은 없음)
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- `APP_PROFILE``SPRING_PROFILES_ACTIVE` 불일치 시 startup fail 동작 — `EnvironmentPostProcessor` 또는 `@PostConstruct` validator 구현 후 통합 테스트로 검증
- `@Validated` 실패 시 Spring Boot launcher 가 exit code 1 로 종료되는지 — contract test `StartupFailFastTest` 로 검증
- `ca-skeleton.*`/`app.*` 이중 prefix를 `APP_*` 단일 registry 로 통일할 때, relaxed binding 이 실제로 기존 `ca-skeleton.*` YAML 키와 새 `APP_*` 환경변수를 충돌 없이 매핑하는지 — 통합 테스트로 검증 필요
## 메모 / Notes
@@ -82,6 +114,12 @@ created: 2026-06-05
- D10 resolution: C5 는 `@Validated` API 지원의 공식 근거. fail-fast startup 동작은 Spring framework 컨텍스트 로드 실패 일반 동작으로 추가 raw 없이 합리적으로 추론 가능 — 단, 추론이므로 claim 에는 넣지 않음.
- 추가로 봐야 할 동일 출처 페이지: property precedence priority list (페이지 상단 §1), `@ConfigurationPropertiesScan`, constructor binding with `@DefaultValue`.
### 2026-07-28 추가 메모 (`feature-capability-provider-selection-contract` 근거 수집)
- capability 활성화 property 단일 prefix 통일 결정: relaxed binding(C6/C7)과 canonical form 권장(C8)은 "표기가 달라도 같은 property 로 매핑된다"는 사실을 확인해주지만, 반대로 이는 **현재 `ca-skeleton.*``app.*` 두 prefix 가 서로 다른 property 트리로 남아 relaxed binding 으로도 통합되지 않는다**는 점을 부각시킨다 — relaxed binding 은 표기 변형(대소문자/구분자)만 흡수하고, 서로 다른 prefix 자체를 통합하지는 않음. C9(prefix→bean 명명 규칙)는 prefix 가 클래스 단위로 자유롭게 지정된다는 사실만 보여주므로 "두 prefix 혼재가 Spring 표준 관행이 아니다"라는 직접 근거는 아니다 — 단일 prefix 채택은 이 문서가 아니라 팀의 일관성 정책 판단.
- **버전 드리프트 주의**: 2026-06-05 원 메모(D6 resolution)는 "OS env = 우선순위 10번째"라고 기록했으나, 2026-07-28 재확인한 동일 URL 발췌(Spring Boot 4.1.0 기준, javadoc 링크로 확인)에서는 OS 환경변수가 15개 항목 중 **5번째**, config data(`application.properties`)가 **3번째**로 나열됨. 항목 개수·순서가 문서 버전에 따라 달라졌을 가능성이 있음 — 두 메모 모두 "OS env 가 config data 파일보다 나중(=우선)"이라는 **상대적 방향**은 일치하나, 절대 순번은 재검증 시점의 문서 버전에 의존하므로 절대 순번을 claim 근거로 인용하지 말 것.
- 추가로 봐야 할 것: `ca-skeleton.*`/`app.*` 두 prefix 를 하나의 `APP_*` registry 로 합칠 때, 기존 YAML 계층 구조(`ca-skeleton.foo.bar`)를 `app.foo.bar` 로 옮기는 마이그레이션 경로가 relaxed binding 만으로 자동 해결되지 않음(수동 rename 필요) — 이 문서 범위 밖.
## Related / 관련
- 같은 주제 다른 official-doc:
@@ -0,0 +1,90 @@
---
title: GraphQL over HTTP (draft) — 상태 코드와 errors 처리
source_type: official-doc
url: https://graphql.github.io/graphql-over-http/draft/
archive_url:
related_branches: [feature-frontend-multi-protocol-api-transport-contract]
related_projects: [ca-skeleton-frontend]
tags: [frontend, graphql, http, api, protocol]
created: 2026-07-28
---
# GraphQL over HTTP (draft) — 상태 코드와 errors 처리
> Layer: `raw/` — 외부 자료의 **원문 발췌·출처 기록**.
> ⚠️ **draft 문서다.** 확정 표준이 아니며 내용이 바뀔 수 있다. 아래 claim 을 `official-reference` 로 쓰되 "draft" 라는 사실을 함께 인용해야 한다.
## 활용 branch (필수, 최소 1개+)
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| `[[raw/branch-notes/feature-frontend-multi-protocol-api-transport-contract]]` | GraphQL 성공 판정을 상태 코드가 아니라 본문으로 하는 근거(`D4`), `errors` 가 있는 응답을 실패로 정규화하는 것이 **규격과 다른 우리 정책**임을 밝히는 근거(`D3`) |
## 출처
- 원본 URL: https://graphql.github.io/graphql-over-http/draft/
- 보조 URL: https://spec.graphql.org/draft/#sec-Handling-Field-Errors
- 아카이브 URL:
- 저자 / 조직: GraphQL Foundation (GraphQL over HTTP working draft)
- 발행일: (draft, 지속 갱신)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
branch 가 "`200 OK` + `errors[]` 를 실패로 정규화한다" 고 적어 두었는데 두 가지를 확인해야 했다. (a) 그 응답이 정말 `200` 으로 오는가, (b) 규격이 그 상황을 어떻게 부르는가. 결과는 둘 다 예상과 달랐다. 데이터와 errors 가 함께 있으면 규격은 `294` 를 권고하고, 규격 자신은 그 상황을 **"successful execution"** 이라고 부른다. 즉 우리 결정은 규격을 따르는 것이 아니라 **의도적으로 다르게 가는 것**이며, 그 사실을 문서에 밝히지 않으면 다음 사람이 규격 준수로 오해한다.
## 핵심 인용
> [§6.4] "If the GraphQL response contains the data entry and it is not null, then the server MUST reply with a `2xx` status code."
> [§6.4] "If the GraphQL response contains the data entry and does not contain the errors entry, then the server SHOULD reply with a `200` status code."
> [§6.4] "If the GraphQL response contains both the data entry (even if it is null) and the errors entry, then the server SHOULD reply with a `294` status code."
> [§6.4] "If the GraphQL response does not contain the data entry then the server MUST reply with an appropriate `4xx` or `5xx` status code:"
> [§6.4] "Clients should process a response using the `application/graphql-response+json` media type as a well-formed GraphQL response independent of the HTTP status code."
> [§6.4 Note] "There are no circumstances where the GraphQL specification allows for a response having data as null without errors being present."
> [§6.4.1] "The GraphQL specification differentiates field errors from request errors and refers to the situation wherein a GraphQL field error occurs as a partial response; it still indicates successful execution."
> [§6.1] "If the `Accept` header does not indicate support for one of the server's preferred media types but does indicate support for `application/json` ... any response that produces a `2xx` status code should replace the `Content-Type` header with `Content-Type: application/json`."
## 추출된 주장
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| C1 | 데이터와 `errors` 가 함께 있는 응답에 규격이 권고하는 상태 코드는 `200` 이 아니라 **`294`** 다 | [§6.4] "then the server SHOULD reply with a `294` status code." | `official-reference` (draft) | `application/graphql-response+json` | 모든 서버가 `294` 를 보낸다는 것 — SHOULD 이며 draft 다 |
| C2 | data 가 있으면 상태 코드는 반드시 `2xx` 다 | [§6.4] "then the server MUST reply with a `2xx` status code." | `official-reference` (draft) | data 가 non-null 인 응답 | `2xx` 이면 성공이라는 것 |
| C3 | 클라이언트는 응답을 **HTTP 상태 코드와 무관하게** 잘 형성된 GraphQL 응답으로 처리해야 한다 | [§6.4] "independent of the HTTP status code" | `official-reference` (draft) | `application/graphql-response+json` 소비자 | 상태 코드를 아예 무시해도 된다는 것 |
| C4 | 규격은 field error 상황을 **partial response 이며 "successful execution"** 이라고 부른다 | [§6.4.1] "it still indicates successful execution." | `official-reference` (draft) | field error | 애플리케이션이 이를 성공으로 취급해야 한다는 것 — 규격의 명명일 뿐 |
| C5 | data 가 없으면 `4xx` 또는 `5xx` 여야 한다 | [§6.4] "MUST reply with an appropriate `4xx` or `5xx` status code" | `official-reference` (draft) | request error | 어떤 코드인지의 세부 |
| C6 | `data: null` 이면서 `errors` 가 없는 응답은 규격상 존재할 수 없다 | [§6.4 Note] "There are no circumstances where the GraphQL specification allows for a response having data as null without errors being present." | `official-reference` (draft) | 모든 GraphQL 응답 | 서버가 그 규칙을 실제로 지킨다는 것 |
| C7 | 구식 클라이언트가 `application/json` 만 받으면 `2xx` 응답의 Content-Type 이 `application/json` 으로 바뀐다 | [§6.1] "should replace the `Content-Type` header with `Content-Type: application/json`" | `official-reference` (draft) | legacy Accept 협상 | 그 경우에도 상태 코드가 같다는 것 |
## 적용 경계
- 이 자료가 직접 증명하는 것:
- `C1`~`C3`: GraphQL 성공 판정에 상태 코드만 쓰면 안 되며, `200` 만 검사하는 구현은 `294` 를 놓친다
- `C4`: 규격은 부분 응답을 "성공" 으로 분류한다
- `C5`·`C6`: data 유무가 request error 와 field error 를 가르는 축이다
- 이 자료가 증명하지 않는 것:
- `errors` 가 있는 응답을 **애플리케이션이 실패로 취급해야 한다**는 것 — 오히려 `C4` 는 반대 방향이다. 실패 취급은 우리 정책이다
- 서버가 `294` 를 실제로 보낸다는 것 (SHOULD + draft)
- GraphQL 클라이언트 라이브러리들의 기본 동작
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- 우리 backend 가 `application/graphql-response+json` 을 쓰는지, `294` 를 보내는지
- 부분 데이터를 버리는 정책이 제품에서 수용 가능한지
## 메모
- 인용 1 해석 후보 (미검증): `C1`+`C3` 때문에 adapter 의 성공 판정은 **상태 코드가 아니라 본문의 `data`·`errors` 구조**를 봐야 한다. "`200` 인지 확인" 하는 구현은 `294` 응답을 실패로 오분류하거나, `2xx` 만 보고 errors 를 놓친다.
- 인용 2 해석 후보 (미검증): `C4` 는 우리 `PARTIAL_RESULT_FAILURE` 결정이 **규격 이탈**임을 뜻한다. 이탈 자체는 문제가 아니지만, 문서에 "규격은 성공이라 부르지만 우리는 실패로 다룬다" 를 명시하지 않으면 근거를 오해하게 된다.
- 추가로 봐야 할 동일 출처 페이지: §5 (GET/POST 와 persisted document), §6.2 (media type 협상)
## 관련
- 같은 주제 다른 official-doc: `[[raw/official-docs/grpc-connect-status-codes-error-model]]`, `[[raw/official-docs/zod-runtime-schema-validation-official]]`
- 이 자료를 인용한 wiki 요약: 생성 전
@@ -0,0 +1,91 @@
---
title: gRPC status codes / Connect protocol — 오류 표현 방식
source_type: official-doc
url: https://github.com/grpc/grpc/blob/master/doc/statuscodes.md
archive_url:
related_branches: [feature-frontend-multi-protocol-api-transport-contract]
related_projects: [ca-skeleton-frontend]
tags: [frontend, grpc, grpc-web, connect, protocol, error-handling]
created: 2026-07-28
---
# gRPC status codes / Connect protocol — 오류 표현 방식
> Layer: `raw/` — 외부 자료의 **원문 발췌·출처 기록**.
## 활용 branch (필수, 최소 1개+)
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| `[[raw/branch-notes/feature-frontend-multi-protocol-api-transport-contract]]` | protocol 별 성공 판정을 분리하는 근거(`D2`), gRPC-Web 과 Connect 를 한 adapter 로 묶지 못하는 근거(`D7`), 재시도 가능 판정이 규격이 아니라 **우리 결정**이라는 근거(`D6`) |
## 출처
- 원본 URL: https://github.com/grpc/grpc/blob/master/doc/statuscodes.md
- 보조 URL: https://connectrpc.com/docs/protocol/
- 아카이브 URL:
- 저자 / 조직: gRPC Authors / Connect (Buf)
- 발행일: (지속 갱신)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
branch 가 `grpc-status` ↔ 정규화 kind 매핑표를 "명세 확인 후" 로 비워 두었다. 확인해 보니 매핑표에 필요한 것은 두 가지였고 성격이 정반대였다. (a) 코드 목록은 규격이 확정해 준다. (b) **어떤 코드가 재시도 가능한지는 규격이 정해 주지 않는다** — 명시적으로 애플리케이션에 떠넘긴다. 그리고 gRPC-Web 과 Connect 는 이름이 비슷한데 오류를 알리는 방식이 정반대여서, 하나의 adapter 로 묶으면 한쪽이 반드시 틀린다.
## 핵심 인용
> [§gRPC statuscodes — Status code 표] "OK 0 / CANCELLED 1 / UNKNOWN 2 / INVALID_ARGUMENT 3 / DEADLINE_EXCEEDED 4 / NOT_FOUND 5 / ALREADY_EXISTS 6 / PERMISSION_DENIED 7 / RESOURCE_EXHAUSTED 8 / FAILED_PRECONDITION 9 / ABORTED 10 / OUT_OF_RANGE 11 / UNIMPLEMENTED 12 / INTERNAL 13 / UNAVAILABLE 14 / DATA_LOSS 15 / UNAUTHENTICATED 16"
> [§gRPC statuscodes] "there is no fixed list of status codes on which it is appropriate to retry in all applications. As a result, individual applications must make their own determination as to which status codes should cause an RPC to be retried."
> [§gRPC statuscodes] RPC 는 "a `status` object composed of an integer `code` and a string `message`" 를 반환한다.
> [§Connect — Error and EndStreamResponse] "An `Error` is a code, an optional message, and an optional array of details."
> [§Connect — Error and EndStreamResponse] "The simplest form of `Error` contains just a code: `{\"code\": \"unavailable\"}`"
> [§Connect — Error Codes] "Connect represents categories of errors as codes, and each code maps to a specific HTTP status code."
> [§Connect — Unary-Response] "Errors are sent with a non-200 **HTTP-Status**."
> [§Connect — Summary] "The protocol doesn't use HTTP trailers at all, so it works with any networking infrastructure."
> [§Connect — Streaming-Request] "Note that this is not the same as the gRPC-Web protocol, which uses the _most_ significant bit to mark trailers."
## 추출된 주장
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| C1 | gRPC status code 는 0(OK)부터 16(UNAUTHENTICATED)까지 17개로 확정되어 있다 | [§표] "OK 0 ... UNAUTHENTICATED 16" | `official-reference` | gRPC 계열 전체 | 각 코드가 어떤 상황에 쓰이는지의 판단 기준 |
| C2 | **어떤 코드에서 재시도해야 하는지는 규격이 정하지 않으며 애플리케이션이 스스로 정한다** | [§statuscodes] "individual applications must make their own determination as to which status codes should cause an RPC to be retried." | `official-reference` | 재시도 정책 | 우리가 고른 재시도 집합이 옳다는 것 — 규격은 판단을 위임할 뿐이다 |
| C3 | gRPC 의 status 는 code + message 객체이며 transport 와 **별도로** 전달된다 | [§statuscodes] "a `status` object composed of an integer `code` and a string `message`" | `official-reference` | gRPC·gRPC-Web | HTTP 상태 코드와의 구체적 매핑 |
| C4 | Connect 의 오류는 `code`(문자열)와 선택적 message·details 로 구성된 JSON 이다 | [§Connect] "An `Error` is a code, an optional message, and an optional array of details." | `official-reference` | Connect unary | 문자열 code 가 gRPC 숫자 code 와 1:1 이라는 것 |
| C5 | Connect 는 오류를 **non-200 HTTP status 로 보낸다** | [§Unary-Response] "Errors are sent with a non-200 **HTTP-Status**." | `official-reference` | Connect unary | 스트리밍에서도 같다는 것 |
| C6 | Connect 는 HTTP trailer 를 전혀 쓰지 않는다 | [§Summary] "The protocol doesn't use HTTP trailers at all" | `official-reference` | Connect 전체 | gRPC-Web 도 그렇다는 것 — 오히려 반대다 |
| C7 | gRPC-Web 은 trailer 를 별도 표시로 구분하며 Connect 와 **다른 프로토콜**이다 | [§Streaming-Request] "this is not the same as the gRPC-Web protocol, which uses the _most_ significant bit to mark trailers." | `official-reference` | 두 프로토콜의 차이 | gRPC-Web 의 trailer 인코딩 세부 |
| C8 | Connect 는 각 code 를 특정 HTTP status 로 매핑한다 | [§Error Codes] "each code maps to a specific HTTP status code" | `official-reference` | Connect | 그 매핑이 gRPC-Web 에도 적용된다는 것 |
## 적용 경계
- 이 자료가 직접 증명하는 것:
- `C1`·`C3`: gRPC 코드 집합과 status 가 transport 와 분리되어 있다는 사실
- `C2`: **재시도 가능 판정은 규격이 위임한 애플리케이션 결정**이다
- `C4`~`C8`: Connect 와 gRPC-Web 의 오류 표현이 서로 다르다 — Connect 는 non-200 + JSON, gRPC-Web 은 200 + trailer
- 이 자료가 증명하지 않는 것:
- gRPC 코드 → 우리 error kind 매핑표의 구체 내용 — `C2` 가 명시적으로 우리에게 넘긴다
- gRPC-Web 의 trailer 파싱 세부 (별도 gRPC-Web 규격 필요)
- 어떤 프로토콜을 우리가 실제로 쓸지
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- gRPC-Web 규격 원문 (trailer 인코딩과 `grpc-status` 헤더 위치)
- Connect 의 code 문자열 ↔ gRPC 숫자 code 대응표
## 메모
- 인용 1 해석 후보 (미검증): `C5`+`C7` 은 "gRPC 계열" 이라는 묶음이 구현 관점에서 성립하지 않음을 보여준다. gRPC-Web 은 HTTP 200 이어도 실패일 수 있고, Connect 는 실패면 HTTP 가 200 이 아니다. 성공 판정 코드를 공유하면 한쪽이 반드시 틀린다.
- 인용 2 해석 후보 (미검증): `C2` 때문에 매핑표는 "규격 확인 후 채운다" 가 아니라 **"우리가 정하고 근거를 남긴다"** 가 맞다. 규격을 아무리 읽어도 재시도 여부는 나오지 않는다.
- 추가로 봐야 할 동일 출처 페이지: gRPC-Web 규격(`PROTOCOL-WEB.md`), Connect 의 code ↔ HTTP status 표 전체
## 관련
- 같은 주제 다른 official-doc: `[[raw/official-docs/graphql-over-http-draft-status-errors]]`
- 이 자료를 인용한 wiki 요약: 생성 전
@@ -0,0 +1,94 @@
---
title: official-doc / microservices.io — Idempotent Consumer Pattern (Chris Richardson)
source_type: official-doc
status: raw
confidence: medium
url: https://microservices.io/patterns/communication-style/idempotent-consumer.html
archive_url:
related_branches: [feature-kafka-consumer-inbox-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, messaging, kafka, idempotency]
created: 2026-07-28
last_reviewed: 2026-07-28
---
# Idempotent Consumer Pattern — microservices.io (Chris Richardson)
> Layer: `raw/official-docs/` — Chris Richardson 의 microservices.io 패턴 카탈로그 중 "Idempotent Consumer" 페이지 verbatim 발췌. at-least-once 재전달로 인한 consumer 중복 실행 문제와 processed-message-ID 기록 기반 해결책의 1차 인용 출처.
>
> **강도 주의**: microservices.io 는 Chris Richardson 개인의 패턴 카탈로그다. `raw/official-docs/` 에 두되 strength 는 `engineering-blog` (개인 패턴 카탈로그) 로 표기한다 — 벤더 공식 표준(official-standard / official-vendor-doc / official-reference)으로 격상 금지. sibling `[[raw/official-docs/microservices-io-transactional-outbox]]` 와 동일 등급 정책.
## Parent / 활용 branch (필수)
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-kafka-consumer-inbox-contract]] | ca-skeleton consumer 가 at-least-once 재전달의 중복 실행을 차단하기 위해 처리한 메시지 ID 를 기록하는 inbox(processed-message) 테이블을 비즈니스 write 와 동일 DB 트랜잭션에서 커밋하는 방식을 채택하는 근거, 그리고 "비즈니스 엔티티 자체의 유니크 제약으로 대체" 변형이 언제 성립하는지의 선택 조건 |
## 출처
- 원본 URL: https://microservices.io/patterns/communication-style/idempotent-consumer.html
- 아카이브 URL: (미등록)
- 저자 / 조직: Chris Richardson — microservices.io (personal pattern catalog). 별도 vendor 의 공식 문서 아님.
- 발행일: rolling docs (페이지에 "Copyright © 2026" 표기, 최초 작성 시점 명시 없음)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-kafka-consumer-inbox-contract` branch 가 inbox(PROCESSED_MESSAGE류) 테이블 기반 멱등 consumer 를 채택하는 근거이자, 처리한 메시지 ID 기록을 DB 트랜잭션 경계 안에서 수행해야 duplicate INSERT 가 유니크 제약으로 실패·rollback 되는 메커니즘의 1차 출처. 별도 테이블 vs 비즈니스 엔티티 내장이라는 두 변형 중 어느 쪽을 언제 쓰는지 판단할 근거로 보관한다.
## 핵심 인용
> [§Context] "One side-effect, however, is that the consumer can be invoked repeatedly for the same message."
> [§Solution] "Make a consumer idempotent by having it record the IDs of processed messages in the database."
> [§Solution] "After starting the database transaction, the message handler inserts the messages ID into the PROCESSED_MESSAGE table."
> [§Solution] "Since the (subscriberId, messageID) is the PROCESSED_MESSAGE tables primary key the INSERT will fail if the message has been already processed successfully."
> [§Solution] "The other option is for the consumer to store the IDs in the business entities that it creates or updates."
## Claims Extracted
| Claim ID | Claim | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| MSIO-IDEMPC-C1 | at-least-once 전달을 보장하는 메시지 브로커를 쓰면, 부작용으로 consumer 가 동일 메시지에 대해 반복 호출될 수 있다 | [§Context] "One side-effect, however, is that the consumer can be invoked repeatedly for the same message." | `engineering-blog` | at-least-once delivery 를 보장하는 모든 메시지 브로커(Kafka 포함) 사용 시 재전달 가능성 일반 | 재전달이 얼마나 자주 발생하는지 정량적 근거 없음; Kafka 고유의 rebalance/재시도·offset 커밋 메커니즘과의 상호작용은 별도 근거 필요 |
| MSIO-IDEMPC-C2 | 해법의 핵심은 consumer 가 처리한 메시지의 ID 를 DB 에 기록해 멱등성을 확보하는 것 | [§Solution] "Make a consumer idempotent by having it record the IDs of processed messages in the database." | `engineering-blog` | consumer 가 조회 가능한 저장소(RDB 등)에 접근 가능한 아키텍처 | 이 저장소가 반드시 별도 RDB 여야 한다는 뜻은 아님 — 다른 저장 매체(예: 분산 KV)의 적합성은 본문에서 다루지 않음 |
| MSIO-IDEMPC-C3 | 메커니즘: message handler 는 DB 트랜잭션을 시작한 뒤 그 트랜잭션 안에서 메시지 ID 를 PROCESSED_MESSAGE 테이블에 INSERT 한다 | [§Solution] "After starting the database transaction, the message handler inserts the messages ID into the PROCESSED_MESSAGE table." | `engineering-blog` | ID 기록을 DB 트랜잭션 경계 안에서 수행하는 구현 일반 | **미발견**: 이 문장 자체는 "비즈니스 엔티티 갱신도 같은 트랜잭션에 포함되어야 한다"고 명시적으로 말하지 않는다. 하나의 message handler 가 트랜잭션을 하나만 시작한다는 것에서 강하게 시사될 뿐, "동일 트랜잭션 요구"를 문장으로 직접 진술하지는 않음 — ca-skeleton 결정으로 넘어갈 때 이 gap 을 명시해야 함 |
| MSIO-IDEMPC-C4 | 중복 탐지 메커니즘은 (subscriberId, messageID) 복합 기본키(primary key) 이며, 이미 처리된 메시지를 다시 INSERT 하면 그 제약 위반으로 실패한다 | [§Solution] "Since the (subscriberId, messageID) is the PROCESSED_MESSAGE tables primary key the INSERT will fail if the message has been already processed successfully." | `engineering-blog` | RDB 의 기본키/유니크 제약을 dedup 메커니즘으로 쓰는 구현(PostgreSQL 포함) | 이 제약 기반 방식이 분산 락이나 애플리케이션 레벨 사전 조회보다 우월하다는 비교 평가는 없음; 유니크 제약이 없는 저장소(순수 NoSQL 등)에는 그대로 적용되지 않음 |
| MSIO-IDEMPC-C5 | 변형: 별도 PROCESSED_MESSAGES 테이블 대신, consumer 가 생성/갱신하는 비즈니스 엔티티 자체에 메시지 ID 를 저장하는 방식도 가능하다 | [§Solution] "The other option is for the consumer to store the IDs in the business entities that it creates or updates." | `engineering-blog` | consumer 가 처리마다 정확히 하나의 특정 business entity 를 생성/갱신하는 경우 (예: `AccountDebited``Account` 엔티티) | 이 변형을 **언제** 선택해야 하는지의 판단 기준(예: fan-out 메시지, 엔티티가 없는 처리, 여러 엔티티를 건드리는 처리)은 본문에 없음 — 선택 조건은 이 자료만으로 증명되지 않음 |
## Usage Boundaries
- **이 자료가 직접 증명하는 것**:
- `MSIO-IDEMPC-C1`: at-least-once 브로커의 재전달 부작용(중복 invocation) 정의
- `MSIO-IDEMPC-C2`: 해법의 뼈대 — 처리한 메시지 ID 를 DB 에 기록해 멱등성 확보
- `MSIO-IDEMPC-C3`: ID INSERT 가 message handler 의 DB 트랜잭션 안에서 일어난다는 것
- `MSIO-IDEMPC-C4`: (subscriberId, messageID) 복합 PK 유니크 제약이 중복 INSERT 를 실패시키는 구체 메커니즘
- `MSIO-IDEMPC-C5`: PROCESSED_MESSAGES 별도 테이블의 대안으로 비즈니스 엔티티 자체에 ID 저장이 가능하다는 것(옵션 존재 자체)
- **이 자료가 증명하지 않는 것**:
- 본 페이지가 **공식 vendor doc 이나 표준**이라는 점 — microservices.io 는 Chris Richardson 의 personal pattern catalog. 어떤 벤더의 공식 채택도 의미하지 않는다. strength 는 전부 `engineering-blog`.
- **"메시지 ID 기록과 비즈니스 데이터 갱신이 반드시 같은 트랜잭션이어야 한다"는 명시적 문장** — `C3`의 Does not prove 참고. 원문은 트랜잭션이 하나 시작된다는 것만 말하며, 비즈니스 엔티티 갱신이 그 안에 포함된다는 것은 패턴의 일반 관례로 추정될 뿐 이 페이지에서 직접 진술되지 않는다.
- PROCESSED_MESSAGES 별도 테이블 vs 비즈니스 엔티티 내장 중 **어느 쪽이 ca-skeleton 에 더 적합한지의 선택 기준**`C5`의 Does not prove 참고, 본문은 옵션 존재만 언급
- Kafka 특유의 consumer rebalance / `max.poll` / manual ack 커밋 시점과 이 패턴의 상호작용
- Eventuate 프레임워크의 실제 구현 코드 세부(이 페이지는 "implements this pattern"이라고만 언급, 코드는 미첨부)
- **내 프로젝트에 적용하려면 추가 확인이 필요한 것**:
- ca-skeleton 의 `InboxStorePort` 구현이 (subscriberId, messageID) 복합 PK 와 동등한 유니크 제약을 실제로 갖는지 코드 검증
- inbox insert 와 비즈니스 write 가 실제로 동일 `@Transactional` 경계 안에서 커밋되는지 — 이 raw 자료만으로는 "그래야 한다"는 관례적 근거이지 ca-skeleton 코드의 검증 결과가 아님
- "비즈니스 엔티티 자체에 ID 저장" 변형을 채택할지 여부는 ca-skeleton 의 메시지-엔티티 매핑이 1:1 인 케이스에 한해 별도로 결정해야 함
## 메모
- 본 페이지는 Context / Problem / Solution / See also 4개 섹션으로만 구성된 매우 간결한 패턴 카탈로그 페이지이며, 시퀀스 다이어그램 이미지(`/i/IdempotentConsumer/IdempotentConsumer.png`) 하나를 포함하지만 alt-text 나 대체 설명 텍스트는 없다.
- "See also" 에 언급된 `Eventuate framework` 와 "blog post about this pattern"(`/post/microservices/patterns/2020/10/16/idempotent-consumer.html`) 은 더 상세한 구현을 담고 있을 가능성이 있음 — 필요 시 별도 raw 로 추가 조사.
- 동일 저자의 [[raw/official-docs/microservices-io-transactional-outbox]] 와 짝을 이루는 패턴(producer 측 outbox ↔ consumer 측 idempotent consumer). ca-skeleton 의 producer/consumer 양쪽 계약을 함께 볼 때 두 문서를 같이 참조할 것.
## Related
- 같은 주제 다른 official-doc:
- [[raw/official-docs/microservices-io-transactional-outbox]] (동일 저자, producer 측 짝 패턴)
- [[raw/official-docs/event-sourcing-vs-outbox-microservices-io]]
- [[raw/official-docs/outbox-skip-locked-microservices-io]]
- 이 자료를 인용하는 branch:
- [[raw/branch-notes/feature-kafka-consumer-inbox-contract]]
- 이 자료를 인용한 wiki 요약: (미작성)
@@ -0,0 +1,104 @@
---
title: official-doc / MongoDB Java Sync Driver — Logging (Logger Names, Levels, maxDocumentLength)
source_type: official-doc
url: https://www.mongodb.com/docs/drivers/java/sync/current/logging-monitoring/logging/
archive_url:
related_branches: [feature-mongo-runtime-baseline-contract]
related_projects: []
tags: [official-doc, ca-skeleton, persistence, mongodb]
created: 2026-07-28
---
# official-doc / MongoDB Java Sync Driver — Logging (Logger Names, Levels, maxDocumentLength)
> Layer: `raw/` — 외부 자료(공식 문서 / 대기업 기술 블로그)의 **원문 발췌·출처 기록**.
> 본 템플릿은 `raw/official-docs/``raw/company-tech-blogs/` 두 폴더가 공유.
> 검증된 요약은 `/ingest``wiki/concepts/``source-summary-template` 형식으로 별도 작성. 원본은 raw에 영구 보관.
## source_type 허용값
`source_type: official-doc` — MongoDB Java Sync Driver 공식 문서 (Logging and Monitoring 섹션).
> 참고: 앞선 dispatch 는 `https://www.mongodb.com/docs/languages/java/sync-driver/current/logging-monitoring/logging/` 로 시도해 404 였다. 본 문서는 `https://www.mongodb.com/docs/drivers/java/sync/current/logging-monitoring/logging/` (200 OK 확인) 를 근거로 한다.
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-mongo-runtime-baseline-contract]] | MongoDB 쿼리 filter·document 값이 로그로 새지 않게 억제한다는 결정(branch 본문 §엣지·실패·의존 표의 "쿼리 filter·document 값이 로그로 유출" 행이 이미 `D18`·`§구현 가이드 8` 로 전방 참조 중 — 본 자료는 그 D18 을 세울 근거) — 어느 logger(`org.mongodb.driver.protocol`/`org.mongodb.driver.protocol.command`)가 command 내용을 어느 레벨(DEBUG)로 남기는지, 로그 레벨을 logger 단위로 어떻게 조정하는지, document 길이를 얼마나(기본 1000자) 제한할 수 있는지의 공식 근거 |
## 출처
- 원본 URL: https://www.mongodb.com/docs/drivers/java/sync/current/logging-monitoring/logging/
- 아카이브 URL: (미제공)
- 저자 / 조직: MongoDB, Inc. (공식 Java Sync 드라이버 문서, Logging and Monitoring 섹션)
- 발행일: (문서 자체에 명시 없음 — 드라이버 버전별 롤링 업데이트되는 레퍼런스 페이지. 본문 예시 코드는 driver-core 5.9 API 링크를 가리킴)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-mongo-runtime-baseline-contract` branch가 "MongoDB 쿼리 filter·document 값이 로그로 새지 않게 억제한다"를 결정하는 데 필요한 (1) 드라이버 logger 이름 계층과 각 logger 의 담당 범위, (2) command 로거가 DEBUG 레벨에서 실제로 command/reply 문서 전체를 기록한다는 사실, (3) SLF4J 바인딩 설정으로 logger 이름 단위 레벨 조정이 가능하다는 사실, (4) `maxDocumentLength()` 로 로그 메시지의 문자 길이를 제한할 수 있다는(그러나 필드 마스킹은 아니라는) 사실을 공식 근거로 보관.
## 핵심 인용
> [§Logger Names] "org.mongodb.driver.protocol : Commands sent to and replies received from MongoDB deployments"
> [§Logger Names] "org.mongodb.driver.connection : Connections and connection pools"
> [§Connection Settings — Example 로그 출력] "12:14:55.861 [main] DEBUG org.mongodb.driver.protocol.command - Command "find" started on database <database> using a connection with driver-generated ID 3 and server-generated ID 3 to <MongoDB hostname>. The request ID is 5. Command: {"find": "<collection>", "filter": {}, "limit": 1, "singleBatch": true, "$db": "<database>", "lsid": {"id": {"$binary": {"base64": "<_id>", "subType": "04"}}}, "$readPreference": {"mode": "primaryPreferred"}}"
> [§Connection Settings] "Sets the maximum document length, in characters, of a single log message" — Default: `1000`
> [§Logger Names — Example - Names] "This example shows how to change the log level for a specific driver logger. The example sets the root logger to OFF and the org.mongodb.driver.connection logger to INFO. This will cause the application to only log messages related to connecting to a MongoDB instance." 설정 예: `<logger name="org.mongodb.driver.connection" level="INFO" additivity="true"/>`
## Claims Extracted
> 이 자료가 **직접 말하는 것만** claim 으로 분리한다. 내 프로젝트에 적용한 결론은 여기 쓰지 않는다.
| Claim ID | Claim | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| MONGO-JAVA-LOG-C1 | 드라이버는 로깅 이벤트를 조직하기 위해 이름 있는 logger 계층을 정의하며, `org.mongodb.driver.protocol` 은 "MongoDB 배포로 보낸 명령과 그로부터 받은 응답"을 담당한다 | [§Logger Names] "org.mongodb.driver.protocol : Commands sent to and replies received from MongoDB deployments" | `official-vendor-doc` | command/reply 내용을 다루는 logger 가 어느 것인지 식별(값 노출 억제 대상 지정의 근거) | `org.mongodb.driver.protocol.command`(하위 logger)가 실제로 어떤 필드를 기록하는지는 C3·C4 가 별도로 증명 |
| MONGO-JAVA-LOG-C2 | `org.mongodb.driver.connection` logger 는 "연결과 connection pool"을 담당하며 command 내용과는 별개 관심사다 | [§Logger Names] "org.mongodb.driver.connection : Connections and connection pools" | `official-vendor-doc` | connection 관련 로거와 command 관련 로거가 서로 다른 이름 공간이라는 것 — 하나를 억제해도 다른 하나는 별도로 다뤄야 함을 보여주는 근거 | connection logger 를 억제하는 것만으로 command 값 노출이 막힌다는 것(별개 logger 계층이므로 이 자료가 그런 인과를 증명하지 않음) |
| MONGO-JAVA-LOG-C3 | 실제 로그 예시에서 `org.mongodb.driver.protocol.command` logger 는 DEBUG 레벨로 command 시작/성공 이벤트를 기록한다 | [§Connection Settings 예시 로그] "12:14:55.861 [main] DEBUG org.mongodb.driver.protocol.command - Command \"find\" started on database <database> using a connection with driver-generated ID 3" | `official-vendor-doc` | command 로거가 DEBUG 레벨에서 활성 관측 가능하다는 사실 확인 | 이 레벨이 모든 배포·버전에서 고정 불변이라는 것(레벨 자체는 사용자가 SLF4J 설정으로 조정 가능 — C5 참조) |
| MONGO-JAVA-LOG-C4 | 그 DEBUG 로그 라인의 `Command:` 필드에는 command document 전체(예시에서는 `find` command 의 `filter` 키 포함)가 직렬화되어 그대로 남는다 | [§Connection Settings 예시 로그] "Command: {\"find\": \"<collection>\", \"filter\": {}, \"limit\": 1, \"singleBatch\": true, \"$db\": \"<database>\"" | `official-vendor-doc` | "`org.mongodb.driver.protocol.command`를 DEBUG 이상으로 열면 쿼리 filter 값이 그대로 로그에 남는다"는 결정의 직접 근거 | 이 예시의 `filter` 값이 `{}`(빈 값)이라 실제 업무 데이터가 담긴 filter 가 노출된 사례 자체는 아님 — 다만 command document 전체가 직렬화되어 로그 문자열에 포함되는 **메커니즘**은 이 인용이 직접 보여줌. 필드 단위로 선택적 마스킹/redact 되는지는 이 인용만으로 증명 안 됨(C5 의 truncation 과는 별개 메커니즘) |
| MONGO-JAVA-LOG-C5 | `LoggerSettings.maxDocumentLength()` 로 단일 로그 메시지의 document 최대 길이(문자 수)를 설정할 수 있으며 기본값은 `1000`이다 | [§Connection Settings] "Sets the maximum document length, in characters, of a single log message" + "Default: 1000" | `official-vendor-doc` | 로그에 남는 document/command 내용의 길이를 제한하는 공식 제어 수단이 존재한다는 것, 그리고 그 기본값 | 이 길이 제한이 민감한 필드를 선택적으로 마스킹(redact)한다는 것 — 이 옵션은 문자 수 기준 **truncation**이지 필드 단위 마스킹 기능이 아니며, 원문에 필드 마스킹 옵션은 존재하지 않는다(부재 확인, self-grep) |
| MONGO-JAVA-LOG-C6 | 로그 레벨은 logger 이름 단위로 조정 가능하며, SLF4J 바인딩 프레임워크(logback/log4j2) 설정에서 root logger 를 `OFF` 로 두고 특정 driver logger 만 원하는 레벨로 올리는 방식으로 노출 범위를 좁힐 수 있다 | [§Logger Names — Example - Names] "This example shows how to change the log level for a specific driver logger. The example sets the root logger to OFF and the org.mongodb.driver.connection logger to INFO." + `<logger name="org.mongodb.driver.connection" level="INFO" additivity="true"/>` | `official-vendor-doc` | logger 이름 단위 레벨 억제가 공식적으로 지원되는 설정 방식이라는 것 — "어느 logger 를 어느 레벨로 묶어야 값이 안 새는가"를 SLF4J 설정으로 구현할 수 있다는 메커니즘 근거 | 이 예시는 `org.mongodb.driver.connection` 을 대상으로 시연했을 뿐 `org.mongodb.driver.protocol`/`org.mongodb.driver.protocol.command` 에 동일 패턴을 적용한 예시 로그를 직접 보여주지는 않음(단, 로거 계층 원리는 동일 — §메모 참조) |
### Strength 허용값
- `official-standard` — RFC, 표준 사양, 언어/프로토콜 표준
- `official-vendor-doc` — Spring, Keycloak, AWS, Google, MongoDB 등 공식 벤더 문서
- `official-reference` — 공식 reference/API 문서
- `company-case-study` — 대기업/실무 기술 블로그의 특정 사례
- `engineering-blog` — 개인/팀 블로그의 엔지니어링 해설
- `tutorial` — 튜토리얼/가이드. 일반화 금지
- `needs-confirmation` — 원문만으로는 적용 판단 불가
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `MONGO-JAVA-LOG-C1`~`C2`: 드라이버가 정의하는 9개 logger 이름(`org.mongodb.driver.authenticator`/`client`/`cluster`/`connection`/`connection.tls`/`operation`/`protocol`/`uri`/`management`) 각각의 담당 범위 — command/reply 내용은 `org.mongodb.driver.protocol`(하위 `org.mongodb.driver.protocol.command`) 소관, connection pool 은 `org.mongodb.driver.connection` 소관으로 서로 분리됨
- `MONGO-JAVA-LOG-C3`~`C4`: command 로거가 DEBUG 레벨에서 command document(filter 포함)를 그대로 직렬화해 로그에 남긴다는 것
- `MONGO-JAVA-LOG-C5`: `maxDocumentLength()`(기본 1000자)가 로그 메시지 길이를 제한하는 유일한 공식 옵션이며, 이것은 truncation 이지 필드 마스킹이 아니라는 것
- `MONGO-JAVA-LOG-C6`: SLF4J 바인딩 설정에서 root 를 OFF 로 두고 특정 driver logger 만 레벨을 올리는 방식이 공식 예시로 제공된다는 것
- 이 자료가 증명하지 않는 것:
- 필드 단위 마스킹/redact 기능의 존재 여부 — 원문에 그런 옵션이 전혀 등장하지 않는다(부재 확인이지 "없다"는 벤더의 명시적 부정 진술은 아님)
- `org.mongodb.driver.protocol`(부모)과 `org.mongodb.driver.protocol.command`(자식) 각각을 **개별적으로** OFF/INFO 로 나눠 조정했을 때의 정확한 상속 동작 예시 — 로거 계층 상속 원리("A logger inherits the properties of its ancestor logger")는 일반론으로 서술되나, protocol/protocol.command 조합에 대한 실측 예시 로그는 이 문서에 없음
- `org.mongodb.driver.protocol` logger 를 완전히 OFF 로 꺼도 command 관련 다른 경로(예: Command Monitoring 이벤트 리스너, `org.mongodb.driver.operation` 의 재시도 로그)로 filter 값이 별도로 새는지 여부 — 이 문서는 SLF4J 로거 채널만 다루고 Command Monitoring API(별도 기능)는 다루지 않음
- Reactive Streams/Async 드라이버 등 다른 MongoDB Java 드라이버 변형에서도 동일 logger 이름·동일 기본값이 적용되는지 — 이 문서는 Sync 드라이버 전용 페이지
- MongoDB 서버 측(`mongod`/`mongos`) 로그에 동일한 filter 값 노출 문제가 있는지 — 이 문서는 클라이언트(드라이버) 로깅만 다룸
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- ca-skeleton 이 실제로 SLF4J 바인딩(logback 등)을 어떤 레벨로 배포 환경별(dev/staging/prod)로 설정하는지 — 현재 mongo 모듈 설정 여부는 branch note §Audit 참조(연결 자체가 미설정 상태, `A1`)
- `org.mongodb.driver.protocol`(부모) 레벨을 낮추는 것과 `org.mongodb.driver.protocol.command`(자식)만 개별 지정하는 것 중 어느 쪽이 ca-skeleton 의 로깅 프레임워크(logback 등)에서 실제로 원하는 억제 범위를 만드는지 로컬 검증 필요
- `maxDocumentLength` 축소만으로 민감한 필드(예: 개인정보가 담긴 filter 값)의 앞부분 노출까지 막을 수 있는지 — truncation 은 길이만 제한하므로 앞쪽 N자 안에 민감값이 있으면 여전히 노출됨. 이 gap 은 D18 결정에서 "레벨 자체를 낮춘다"와 "길이만 줄인다"를 혼동하지 않도록 명시가 필요
## 메모
- 이 페이지는 React Server Component(Next.js) payload 형태로 서빙되며, WebFetch(AI 요약 도구)는 tab 선택형 UI(Logger 드롭다운) 뒤의 실제 본문을 가져오지 못했다(nav 구조만 반환) — `curl` 로 raw HTML 을 직접 받아 임베딩된 JSON payload 안의 텍스트를 수동 unescape 해서 검증했다. self-grep 은 이 unescape 된 텍스트 파일(`/tmp/source-fetch-1785227947.txt`) 기준.
- logger 이름 목록에 `org.mongodb.driver.protocol.command` 자체는 "Logger Names" 목록에 별도 항목으로 나열되지 않는다 — 목록에는 부모인 `org.mongodb.driver.protocol` 만 있고, `protocol.command` 는 예시 로그 출력에서만 등장한다. 즉 `protocol.command``protocol` 의 자식이라는 것은 문서 앞부분의 "logger 이름은 계층을 이룬다"는 일반 설명("grandparent.parent.child" 예시)과 실제 로그 라인의 이름 문자열로부터 **추론**한 것이며, "protocol.command 는 protocol 의 공식 자식 logger 목록에 있다"는 명시적 1:1 진술은 이 페이지에 없다 — 검증되지 않은 추론이므로 Claims Extracted 에는 포함하지 않았다.
- 추가로 봐야 할 동일 출처 페이지: `https://www.mongodb.com/docs/drivers/java/sync/current/logging-monitoring/monitoring/` (Command Monitoring API — SLF4J 로거와 별개 채널로 command 이벤트를 구독하는 수단일 가능성, D18 완결 시 확인 필요)
## 관련
- 같은 주제 다른 official-doc: [[raw/official-docs/mongodb-connection-string-options]] (연결 문자열 옵션 — 동일 branch 근거)
- 이 자료를 인용한 wiki 요약: `[[wiki/concepts/...]]` (생성 시)
@@ -0,0 +1,120 @@
---
title: official-doc / Apache Kafka — Client Security Configuration (SSL / SASL)
source_type: official-doc
url: https://kafka.apache.org/documentation/#security_configclients
archive_url:
related_branches: [feature-kafka-producer-runtime-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, security, messaging, kafka]
created: 2026-07-28
---
# official-doc / Apache Kafka — Client Security Configuration (SSL / SASL)
> Layer: `raw/` — 외부 자료(공식 문서)의 **원문 발췌·출처 기록**.
> 검증된 요약은 `/ingest``wiki/concepts/``source-summary-template` 형식으로 별도 작성. 원본은 raw에 영구 보관.
## source_type 허용값
`official-doc` — Apache Kafka 프로젝트가 직접 배포하는 공식 레퍼런스 문서.
## Parent / 활용 branch (필수, 최소 1개+)
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-kafka-producer-runtime-contract]] | ca-skeleton 의 Kafka producer 가 TLS/SASL 설정 키를 어떤 이름으로 갖는지, 그리고 어떤 값이 secret 분류 대상(keystore/truststore 비밀번호, SASL 자격증명)인지를 registry 등록 전에 공식 키 이름으로 확정한다 |
## 출처
- 원본 URL (사용자 요청): https://kafka.apache.org/documentation/#security_configclients
- **URL 리다이렉트 확인 사항**: 위 URL 은 client-side JS 리다이렉트 stub(`window.location.replace(...)`) 이다. `curl` 로 raw HTML 을 직접 받아 확인한 결과, 과거 단일 대형 페이지(`documentation.html`)의 "Configuring Kafka Clients" 앵커는 Kafka 4.3 문서 재구성(Hugo/Docsy 기반) 이후 다음 3개 세부 페이지로 분리되어 있다. 이 raw 문서의 인용은 모두 이 3개 페이지에서 발췌했다:
- https://kafka.apache.org/43/security/listener-configuration/ (`security.protocol` 값 목록)
- https://kafka.apache.org/43/security/encryption-and-authentication-using-ssl/ (SSL client `Configuring Kafka Clients` 섹션)
- https://kafka.apache.org/43/security/authentication-using-sasl/ (SASL client `Configuring Kafka Clients` 섹션 + mechanism 목록)
- 아카이브 URL: (미생성)
- 저자 / 조직: Apache Software Foundation — Apache Kafka 프로젝트
- 발행일: 각 페이지 하단 "Last modified May 22, 2026: MINOR: Add 4.3 docs and javadoc (#853) (dde051e342)" — Kafka 4.3 문서 빌드 기준
- 마지막 확인일: 2026-07-28
**Fetch 방법에 대한 투명성 메모**: `WebFetch` 툴을 원본 URL 과 `/43/documentation.html`(구조 추정) 두 형태로 3회 시도했으나, 매번 페이지 본문이 로드되기 전의 "Redirecting..." stub 만 반환됐다(JS 리다이렉트를 실행하지 않는 fetcher 한계). `curl` 로 raw HTML 을 직접 받아 리다이렉트 목적지(`t="43"`, Hugo 사이드바 nav)를 역추적해 위 3개 실제 문서 페이지를 특정했고, 각 페이지 HTML 을 태그 제거 후 평문으로 저장했다(`/tmp/source-fetch-20260728-170505.txt`, 3개 페이지 연결본). 아래 모든 인용은 이 평문에 대해 `grep -nF` 로 self-grep 검증했다(에이전트 최종 리포트 참조). Confluent fallback(`docs.confluent.io/platform/current/kafka/authentication_ssl.html`)은 사용하지 않았다 — Apache 공식 페이지 원문을 실제로 확보했기 때문.
## 왜 저장했는지
ca-skeleton Kafka producer 의 TLS/SASL 설정을 구현하기 전에, Kafka 공식 문서가 실제로 정의하는 설정 키 이름(`security.protocol`, `ssl.truststore.*`, `ssl.keystore.*`, `sasl.mechanism`, `sasl.jaas.config`)과 값 집합을 근거로 고정해 두기 위함. 특히 keystore/truststore 비밀번호와 SASL 자격증명이 어떤 키에 담기는지를 공식 문서로 확정해야 secret 분류(예: env-driven config registry 에서 마스킹 대상 지정)를 임의로 하지 않을 수 있다.
## 핵심 인용
> [§Listener Configuration] "Possible options (case-insensitive) for the security protocol are given below:
> PLAINTEXT
> SSL
> SASL_PLAINTEXT
> SASL_SSL
> The plaintext protocol provides no security and does not require any additional configuration."
> [§Encryption and Authentication using SSL → Configuring Kafka Clients] "If client authentication is not required in the broker, then the following is a minimal configuration example:
> security.protocol=SSL
> ssl.truststore.location=/var/private/ssl/client.truststore.jks
> ssl.truststore.password=test1234"
> [§Encryption and Authentication using SSL → Configuring Kafka Clients] "If client authentication is required, then a keystore must be created like in step 1 and the following must also be configured:
> ssl.keystore.location=/var/private/ssl/client.keystore.jks
> ssl.keystore.password=test1234
> ssl.key.password=test1234"
> [§Authentication using SASL/PLAIN → Configuring Kafka Clients] "The following is an example configuration for a client for the PLAIN mechanism:
> sasl.jaas.config=org.apache.kafka.common.security.plain.PlainLoginModule required \
> username="alice" \
> password="alice-secret";"
> [§Authentication using SASL/PLAIN → Use of SASL/PLAIN in production] "SASL/PLAIN should be used only with SSL as transport layer to ensure that clear passwords are not transmitted on the wire without encryption. The default implementation of SASL/PLAIN in Kafka specifies usernames and passwords in the JAAS configuration file as shown here. From Kafka version 2.0 onwards, you can avoid storing clear passwords on disk by configuring your own callback handlers that obtain username and password from an external source using the configuration options sasl.server.callback.handler.class and sasl.client.callback.handler.class."
## Claims Extracted
> 이 자료가 **직접 말하는 것만** claim 으로 분리한다. 내 프로젝트에 적용한 결론은 여기 쓰지 않는다.
| Claim ID | Claim | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| KAFKA-SEC-C1 | Kafka 의 `security.protocol` 은 정확히 4개 값(`PLAINTEXT`/`SSL`/`SASL_PLAINTEXT`/`SASL_SSL`) 중 하나이며, `PLAINTEXT` 는 보안을 제공하지 않는다 | [§Listener Configuration] "Possible options (case-insensitive) for the security protocol are given below: PLAINTEXT SSL SASL_PLAINTEXT SASL_SSL The plaintext protocol provides no security..." | `official-vendor-doc` | producer/consumer/broker 공통 `security.protocol` 값 선택 | Spring Boot `spring.kafka.*` 바인딩 프로퍼티 이름이 이와 동일하다는 것 — 이 문서는 native Kafka client config 이지 Spring Kafka autoconfiguration 문서가 아님 |
| KAFKA-SEC-C2 | client 인증(mTLS) 이 불필요한 경우 SSL client 최소 설정은 `security.protocol=SSL` + `ssl.truststore.location` + `ssl.truststore.password` 3개 키다 | [§SSL → Configuring Kafka Clients] "the following is a minimal configuration example: security.protocol=SSL ssl.truststore.location=... ssl.truststore.password=..." | `official-vendor-doc` | 단방향 TLS(서버 인증만) client 설정 | `ssl.truststore.password` 가 필수라는 것 — 문서 다른 곳(SSL 페이지)에서 "technically optional but highly recommended" 라고 명시. mTLS 여부는 별도 결정 |
| KAFKA-SEC-C3 | client 인증(mTLS) 이 필요한 경우 추가로 `ssl.keystore.location` + `ssl.keystore.password` + `ssl.key.password` 3개 키가 필요하다 | [§SSL → Configuring Kafka Clients] "If client authentication is required... ssl.keystore.location=... ssl.keystore.password=... ssl.key.password=..." | `official-vendor-doc` | mTLS(양방향 TLS) client 설정 | ca-skeleton 이 실제로 mTLS 를 요구하는지 — 이는 배포 환경/브랜치 결정이지 이 문서가 증명하는 것이 아님 |
| KAFKA-SEC-C4 | SASL/PLAIN client 설정은 `sasl.jaas.config` 값 문자열 안에 `username`/`password` 를 리터럴로 직접 기입한다 | [§SASL/PLAIN → Configuring Kafka Clients] "sasl.jaas.config=org.apache.kafka.common.security.plain.PlainLoginModule required \ username=\"alice\" \ password=\"alice-secret\";" | `official-vendor-doc` | SASL/PLAIN mechanism 의 client-side JAAS 설정 | 이것이 유일한 방법이라는 것 — 같은 문서가 바로 이어서(KAFKA-SEC-C6) callback handler 대안을 언급함 |
| KAFKA-SEC-C5 | Kafka 가 공식 지원하는 SASL mechanism 은 정확히 5개: `GSSAPI (Kerberos)`, `PLAIN`, `SCRAM-SHA-256`, `SCRAM-SHA-512`, `OAUTHBEARER` | [§SASL mechanisms] "Kafka supports the following SASL mechanisms: GSSAPI (Kerberos) PLAIN SCRAM-SHA-256 SCRAM-SHA-512 OAUTHBEARER" | `official-vendor-doc` | `sasl.mechanism` 값 선택 범위 | 각 mechanism 이 프로덕션에 동등하게 권장된다는 것 — 문서는 기본 OAUTHBEARER 구현이 non-production 전용이라고 별도 경고함(이 raw 문서 범위 밖 세부사항) |
| KAFKA-SEC-C6 | SASL/PLAIN 기본 구현은 자격증명을 JAAS 설정 파일/프로퍼티에 평문으로 저장하며, SSL 전송 없이는 자격증명이 평문으로 네트워크에 전송된다. Kafka 2.0+ 부터는 커스텀 callback handler 로 디스크 평문 저장을 피할 수 있다 | [§SASL/PLAIN → Use of SASL/PLAIN in production] "SASL/PLAIN should be used only with SSL as transport layer to ensure that clear passwords are not transmitted on the wire without encryption. The default implementation... specifies usernames and passwords in the JAAS configuration file..." | `official-vendor-doc` | `sasl.jaas.config` 값(및 그 안의 `username`/`password`)을 secret 으로 분류해야 하는 근거 | 구체적인 vault/secret-manager 연동 방법 — Kafka 문서는 "커스텀 callback handler 로 회피 가능"까지만 말하고 구현체를 권고하지 않음 |
### Strength 허용값
- `official-standard` — RFC, 표준 사양, 언어/프로토콜 표준
- `official-vendor-doc` — Spring, Keycloak, AWS, Google, Apache 등 공식 벤더/프로젝트 문서
- `official-reference` — 공식 reference/API 문서
- `company-case-study` — 대기업/실무 기술 블로그의 특정 사례
- `engineering-blog` — 개인/팀 블로그의 엔지니어링 해설
- `tutorial` — 튜토리얼/가이드. 일반화 금지
- `needs-confirmation` — 원문만으로는 적용 판단 불가
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `KAFKA-SEC-C1`: `security.protocol` 4개 값과 `PLAINTEXT` 의 무보안 의미
- `KAFKA-SEC-C2`/`KAFKA-SEC-C3`: SSL client 설정 키 이름 전체 목록(단방향/양방향 각각)
- `KAFKA-SEC-C4`/`KAFKA-SEC-C5`/`KAFKA-SEC-C6`: SASL client 설정 키 이름, 지원 mechanism 목록, 자격증명 평문 저장 위험
- 이 자료가 증명하지 않는 것:
- Spring Boot / Spring Kafka 의 `spring.kafka.*` 프로퍼티 바인딩 이름 (native Kafka client config 와 이름이 다를 수 있음 — 별도 Spring Kafka 공식 문서 확인 필요)
- ca-skeleton 이 mTLS 를 쓸지, 어떤 SASL mechanism 을 쓸지에 대한 결정 (branch-local 결정 사항)
- secret 값을 실제로 어떤 방식(Vault/K8s Secret/env)으로 주입할지 (Kafka 문서 범위 밖)
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- `feature-env-driven-runtime-configuration` 의 secret 분류 registry 스키마와 이 키 이름들의 실제 매핑
- Spring Kafka 사용 시 `spring.kafka.properties.*` prefix 를 통한 raw property 전달 방식(Spring Kafka 공식 문서로 별도 검증)
## 메모
> 나중에 wiki로 옮길 때 참고할 짧은 메모. 검증되지 않은 내 추론은 여기에 두지 말 것.
- WebFetch 툴이 이 URL(및 `/43/documentation.html` 추정 경로)에서 JS 리다이렉트 stub 만 반환해 3회 실패했다. `curl` 로 raw HTML 을 받아 리다이렉트 목적지를 역추적한 뒤 실제 3개 세부 페이지를 직접 curl 로 재수집했다 — 이 과정과 최종 텍스트는 `/tmp/source-fetch-20260728-170505.txt` (listener-configuration + encryption-and-authentication-using-ssl + authentication-using-sasl 3페이지 연결본)에 보존.
- 추가로 봐야 할 동일 출처 페이지: `https://kafka.apache.org/43/security/authorization-and-acls/` (ACL — 이 branch 범위 밖일 가능성 높음), Spring Kafka 공식 문서(`spring.kafka.*` property reference — 별도 raw 자료 필요)
## 관련
> 같은 주제의 다른 raw 자료, 또는 이 자료를 인용한 wiki 문서.
- 같은 벤더 다른 official-doc: `[[raw/official-docs/spring-kafka-sending-messages-kafkatemplate]]`
- 이 자료를 인용한 wiki 요약: (생성 시)
@@ -0,0 +1,106 @@
---
title: official-doc / Apache Kafka Consumer Configs — max.poll.interval.ms, max.poll.records, auto-commit, session/heartbeat timeouts, partition.assignment.strategy
source_type: official-doc
url: https://kafka.apache.org/41/generated/consumer_config.html
archive_url:
related_branches: [feature-kafka-consumer-inbox-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, messaging, kafka]
created: 2026-07-28
---
# Apache Kafka Consumer Configs — max.poll.interval.ms, max.poll.records, auto-commit, session/heartbeat timeouts, partition.assignment.strategy
> Layer: `raw/` — 외부 자료(공식 문서)의 **원문 발췌·출처 기록**.
> 문서 버전: Apache Kafka 4.1 (`kafka.apache.org/41/generated/consumer_config.html`, HTTP `last-modified: Fri, 12 Dec 2025 04:07:23 GMT`).
## source_type
`official-doc` — Apache Software Foundation 이 직접 호스팅하는 Apache Kafka 프로젝트 공식 consumer configuration reference (Gradle 빌드가 매 릴리스마다 자동 생성하는 `ConfigDef` 기반 페이지).
## URL Fetch 경위
- 요청된 URL 그대로 성공: `https://kafka.apache.org/41/generated/consumer_config.html``curl` 직접 요청 1회차에 HTTP `200`, 87,328 bytes 수신. 이 URL 은 (기존 `raw/official-docs/kafka-producer-configs-delivery-timeout.md``raw/official-docs/kafka-message-delivery-semantics-design.md` 가 겪은) `kafka.apache.org/documentation/#...` client-side JS redirect 셸이 아니라, 이미 버전 고정된 generated 정적 페이지라 redirect 문제가 없었다.
- 저장 원본: `curl` 로 받은 raw HTML 전체를 세션 scratchpad 에 저장(`kafka-consumer-config-raw.html`, 87,328 bytes). 이후 HTML 태그를 제거한 평문(paragraph 단위 `Description:`/`Type:`/`Default:`/`Valid Values:`/`Importance:` 추출)을 별도 파일(`kafka-consumer-config-fetch.txt`)로 만들어 self-grep 대상으로 삼았다.
- **이중 검증**: 아래 모든 인용은 (1) 평문 추출 파일에 대한 `grep -nF`, (2) 원본 raw HTML 파일에 대한 `grep -nF` 양쪽 모두 통과를 재확인했다(HTML 태그가 인용 문장 내부 단어를 끊지 않는 문장만 인용 대상으로 선정).
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-kafka-consumer-inbox-contract]] | ca-skeleton consumer 가 장시간 처리와 rebalance 안정성을 위해 조정하는 설정 노브(`max.poll.interval.ms`, `max.poll.records`, `enable.auto.commit`/`auto.commit.interval.ms`, `session.timeout.ms`/`heartbeat.interval.ms`, `partition.assignment.strategy`)의 공식 정의와 기본값 기준선 |
## 출처
- 원본 URL: https://kafka.apache.org/41/generated/consumer_config.html
- 아카이브 URL: (미제공)
- 저자 / 조직: Apache Software Foundation (Apache Kafka 프로젝트)
- 발행일: 명시 없음 (버전 고정 reference 페이지, HTTP `last-modified: Fri, 12 Dec 2025 04:07:23 GMT`)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-kafka-consumer-inbox-contract` 브랜치가 inbound leaf 의 rebalance·backpressure·auto-commit 계약을 설계할 때 임의 수치를 발명하지 않도록, 각 설정 노브의 공식 설명 전문과 기본값을 기준선으로 고정하기 위해 저장. 특히 `max.poll.interval.ms` 초과 시 그룹 이탈·rebalance 가 일어난다는 문장과 `partition.assignment.strategy` 기본값에 `CooperativeStickyAssignor` 가 이미 포함돼 있다는 사실이 본 branch 의 "bounded concurrency·pause/resume backpressure" 및 "rebalance 처리" 결정의 직접 근거가 된다.
## 핵심 인용
> [§max.poll.interval.ms] "The maximum delay between invocations of poll() when using consumer group management." [...] "If poll() is not called before expiration of this timeout, then the consumer is considered failed and the group will rebalance in order to reassign the partitions to another member." (Default: `300000` — 5 minutes)
> [§max.poll.records] "The maximum number of records returned in a single call to poll()." (Default: `500`)
> [§enable.auto.commit] "If true the consumer's offset will be periodically committed in the background." (Default: `true`)
> [§auto.commit.interval.ms] "The frequency in milliseconds that the consumer offsets are auto-committed to Kafka if enable.auto.commit is set to true." (Default: `5000` — 5 seconds)
> [§session.timeout.ms] "If no heartbeats are received by the broker before the expiration of this session timeout, then the broker will remove this client from the group and initiate a rebalance." (Default: `45000` — 45 seconds)
> [§heartbeat.interval.ms] "In that case, the value must be set lower than session.timeout.ms, but typically should be set no higher than 1/3 of that value." (Default: `3000` — 3 seconds)
> [§partition.assignment.strategy] "The default assignor is [RangeAssignor, CooperativeStickyAssignor], which will use the RangeAssignor by default," [...] "but allows upgrading to the CooperativeStickyAssignor with just a single rolling bounce that removes the RangeAssignor from the list." (Default: `class org.apache.kafka.clients.consumer.RangeAssignor,class org.apache.kafka.clients.consumer.CooperativeStickyAssignor`)
## Claims Extracted
| Claim ID | Claim | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| KAFKA-CONSCFG-C1 | `max.poll.interval.ms` 는 consumer group management 사용 시 `poll()` 호출 간 최대 허용 지연이며, 기본값은 300000ms(5분)이다. 이 시간 내에 `poll()` 이 호출되지 않으면 consumer 는 실패한 것으로 간주되어 그룹이 rebalance 되어 파티션이 다른 member 에게 재할당된다 | "The maximum delay between invocations of poll() when using consumer group management." ... "If poll() is not called before expiration of this timeout, then the consumer is considered failed and the group will rebalance in order to reassign the partitions to another member." | `official-vendor-doc` | 장시간 처리(long-running handler)가 있는 consumer 가 `max.poll.interval.ms` 를 초과하면 강제 rebalance 를 유발한다는 근거 — bounded concurrency·pause/resume backpressure 설계의 기본값 기준선 | ca-skeleton 워크로드의 실제 처리 시간이 300000ms 이내인지는 증명하지 않는다. `group.instance.id` 설정 시 즉시 재할당이 아니라 `session.timeout.ms`(또는 consumer 프로토콜의 `group.consumer.session.timeout.ms`) 만료 후 재할당된다는 예외 조건은 이 인용에는 없지만 원문에는 명시돼 있음(§메모 참고) |
| KAFKA-CONSCFG-C2 | `max.poll.records` 는 단일 `poll()` 호출에서 반환되는 최대 레코드 수이며 기본값은 500이다 | "The maximum number of records returned in a single call to poll()." | `official-vendor-doc` | consumer 가 한 번에 처리할 배치 크기 상한을 계약값으로 고정하는 근거 | 이 값이 fetch 자체의 크기(`fetch.max.bytes`/`max.partition.fetch.bytes`)에 영향을 준다는 뜻은 아님 — 원문은 "does not impact the underlying fetching behavior" 라고 명시(캐시된 레코드를 incremental 하게만 반환) |
| KAFKA-CONSCFG-C3 | `enable.auto.commit``true`(기본값)이면 consumer offset 이 백그라운드에서 주기적으로 커밋된다 | "If true the consumer's offset will be periodically committed in the background." | `official-vendor-doc` | manual acknowledgement 설계 시 기본값(`true`)을 명시적으로 `false` 로 override 해야 한다는 근거 | 커밋 주기의 정확한 타이밍(다음 `poll()` 호출 시점과의 관계 등)은 이 인용문 자체에는 없음 — `auto.commit.interval.ms`(C4)가 주기 값만 정의 |
| KAFKA-CONSCFG-C4 | `auto.commit.interval.ms``enable.auto.commit=true` 일 때 offset 이 자동 커밋되는 주기(밀리초)이며 기본값은 5000ms(5초)이다 | "The frequency in milliseconds that the consumer offsets are auto-committed to Kafka if enable.auto.commit is set to true." | `official-vendor-doc` | auto-commit 모드를 쓸 경우의 커밋 주기 기본값 기준선 | `enable.auto.commit=false`(manual ack 설계)에서는 이 설정 자체가 무의미해진다는 것 — 원문은 그 상호작용을 명시하지 않음 |
| KAFKA-CONSCFG-C5 | `session.timeout.ms` 는 group management 사용 시 client 장애를 탐지하는 타임아웃이며 기본값은 45000ms(45초)이다. 이 시간 내에 broker 가 heartbeat 을 받지 못하면 broker 가 해당 client 를 그룹에서 제거하고 rebalance 를 시작한다 | "If no heartbeats are received by the broker before the expiration of this session timeout, then the broker will remove this client from the group and initiate a rebalance." | `official-vendor-doc` | broker 측 장애 탐지 타임아웃의 기본값 기준선 — `max.poll.interval.ms`(C1, poll 미호출 탐지)와는 별개 메커니즘(heartbeat thread 기반)이라는 근거 | 값 범위가 broker 설정(`group.min.session.timeout.ms`/`group.max.session.timeout.ms`)에 종속된다는 제약과, `group.protocol=consumer` 사용 시 이 client 설정 자체가 무시되고 broker 설정(`group.consumer.session.timeout.ms`)이 대신 적용된다는 것은 인용문에는 없지만 원문 본문에 명시(§메모 참고) |
| KAFKA-CONSCFG-C6 | `heartbeat.interval.ms` 는 group management 사용 시 heartbeat 간격이며 기본값은 3000ms(3초)이다. `group.protocol=classic` 인 경우 이 값은 `session.timeout.ms` 보다 반드시 작아야 하고, 통상적으로 `session.timeout.ms` 의 1/3 이하로 설정하는 것이 권장된다 | "In that case, the value must be set lower than session.timeout.ms, but typically should be set no higher than 1/3 of that value." | `official-vendor-doc` | classic rebalance protocol 하에서 `heartbeat.interval.ms``session.timeout.ms`/3 비율 기준선(기본값 3000ms vs 45000ms 는 정확히 1/15 비율로 권장 상한보다 여유 있음) | `group.protocol=consumer` 로 설정 시 이 client 설정 자체가 지원되지 않고 broker 설정(`group.consumer.heartbeat.interval.ms`)이 heartbeat 간격을 통제한다는 것은 이 인용문에는 없지만 원문에 명시(§메모 참고) |
| KAFKA-CONSCFG-C7 | `partition.assignment.strategy` 기본값은 `[RangeAssignor, CooperativeStickyAssignor]` 이며, 이 기본 목록은 RangeAssignor 를 우선 사용하되 목록에서 RangeAssignor 를 제거하는 단 한 번의 rolling bounce 만으로 CooperativeStickyAssignor 로 업그레이드할 수 있게 해준다 | "The default assignor is [RangeAssignor, CooperativeStickyAssignor], which will use the RangeAssignor by default," ... "but allows upgrading to the CooperativeStickyAssignor with just a single rolling bounce that removes the RangeAssignor from the list." | `official-vendor-doc` | Kafka 4.1 기본값 자체가 이미 `CooperativeStickyAssignor` 로의 무중단 업그레이드 경로를 지원한다는 근거 — cooperative rebalancing 채택 여부를 branch 결정으로 명시할 때의 기준선 | 기본값이 RangeAssignor 를 "우선 사용"한다는 것이지, cooperative(비-eager) rebalancing 이 기본으로 **활성화**되어 있다는 뜻은 아님 — RangeAssignor 는 eager assignor 이며, 순수 cooperative 동작을 얻으려면 목록에서 RangeAssignor 를 제거하는 별도 rolling bounce 가 필요하다는 것이 원문의 요지 |
### Strength 참고
모두 `official-vendor-doc` — Apache Kafka 프로젝트(Apache Software Foundation) 가 직접 게시한 configuration reference. `official-standard`(RFC/표준)는 아니다.
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `KAFKA-CONSCFG-C1`: `max.poll.interval.ms` 정의·기본값(300000ms)과 초과 시 rebalance 유발
- `KAFKA-CONSCFG-C2`: `max.poll.records` 정의·기본값(500)과 fetch 동작과의 독립성
- `KAFKA-CONSCFG-C3`: `enable.auto.commit` 정의·기본값(`true`)
- `KAFKA-CONSCFG-C4`: `auto.commit.interval.ms` 정의·기본값(5000ms)
- `KAFKA-CONSCFG-C5`: `session.timeout.ms` 정의·기본값(45000ms)과 heartbeat 미수신 시 broker 주도 제거·rebalance
- `KAFKA-CONSCFG-C6`: `heartbeat.interval.ms` 정의·기본값(3000ms)과 `session.timeout.ms` 대비 권장 비율(≤1/3)
- `KAFKA-CONSCFG-C7`: `partition.assignment.strategy` 기본값([RangeAssignor, CooperativeStickyAssignor])과 CooperativeStickyAssignor 로의 단일 rolling-bounce 업그레이드 경로
- 이 자료가 증명하지 **않는** 것:
- ca-skeleton 실제 워크로드에서 이 기본값들(300000ms poll interval, 500 records/poll, 45000ms session timeout 등)이 목표 처리율·지연·rebalance 빈도를 만족한다는 것 — 벤치마크·측정 필요
- `group.protocol=consumer`(새 KIP-848 consumer group protocol) 사용 시 client-side `session.timeout.ms`/`heartbeat.interval.ms` 설정이 무시되고 broker 설정으로 대체된다는 세부 동작의 완전한 구현 검증 — 원문은 이 상호작용을 명시하지만, ca-skeleton 이 `group.protocol=classic`(기본값)을 유지하는지 여부는 별도 branch 결정 필요
- manual acknowledgement 구현 시 정확한 커밋 API 호출 시점(예: Spring Kafka `AckMode` 매핑) — 이 문서는 Kafka client 설정값만 제공, Spring Kafka wrapper 동작은 별도 자료 필요
- poison message·역직렬화 실패 분류, retry topic·DLT 설계 — 이 문서 범위 밖(별도 branch 결정 + 별도 자료)
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- ca-skeleton 이 `enable.auto.commit=false`(manual ack)로 override 할 것이 확실하므로, `auto.commit.interval.ms`(C4) 기본값은 실제로는 무관해질 수 있음 — branch `## 구현 가이드` 에서 명시적으로 정리 필요
- `max.poll.interval.ms`/`max.poll.records`/bounded concurrency 조합의 실제 계약값(ca-skeleton 이 채택할 숫자) — 이 문서는 기본값만 제공, 구체적 조정값은 branch-note에서 별도 결정 필요
- `partition.assignment.strategy` 를 기본값 그대로 둘지, 목록에서 `RangeAssignor` 를 제거해 순수 cooperative 로 고정할지는 별도 branch 결정 필요(이 문서는 두 옵션의 존재와 업그레이드 경로만 증명)
## 메모
- 원문에서 self-grep 은 통과했지만 위 Claims Extracted 표의 "Does not prove" 열에 요약 인용 없이 언급한 문장들(예: `group.instance.id` static member 예외, `group.protocol=consumer` 대체 동작)은 각각 raw HTML §max.poll.interval.ms, §session.timeout.ms, §heartbeat.interval.ms 문단 안에 그대로 존재함(`kafka-consumer-config-raw.html` line 284, 84, 63) — 별도 Claim 으로 분리하지 않고 인접 Claim 의 "Does not prove"에 원문 존재 사실만 남겼다. 필요 시 별도 Claim 으로 승격 가능.
- `partition.assignment.strategy``RangeAssignor`/`RoundRobinAssignor`/`StickyAssignor`/`CooperativeStickyAssignor` 4종 개별 설명 문장도 raw HTML 에 존재(self-grep 가능)하나, 본 문서에서는 기본값 조합(C7)만 인용 — 개별 assignor 알고리즘 비교가 필요해지면 별도 인용 추가.
- 저장 원본: 세션 scratchpad `kafka-consumer-config-raw.html`(원본 HTML, 87,328 bytes) + `kafka-consumer-config-fetch.txt`(평문 추출본, self-grep 대상).
## 관련
- 같은 branch 의 자매 관심사(Sources 미등록 — 별도 branch 소유): [[raw/branch-notes/feature-kafka-producer-runtime-contract]] 의 [[raw/official-docs/kafka-producer-configs-delivery-timeout]] — producer 측 delivery/retry/idempotence 설정
- 같은 topic 인접 관심사: [[raw/official-docs/kafka-message-delivery-semantics-design]] — at-least-once/exactly-once 정의, idempotent/transactional semantics
@@ -0,0 +1,93 @@
---
title: official-doc / Apache Kafka — KafkaConsumer Javadoc (offset commit semantics — auto vs manual, commitSync/commitAsync)
source_type: official-doc
url: https://kafka.apache.org/40/javadoc/org/apache/kafka/clients/consumer/KafkaConsumer.html
archive_url:
related_branches: [feature-kafka-consumer-inbox-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, messaging, kafka, idempotency]
created: 2026-07-28
---
# official-doc / Apache Kafka — KafkaConsumer Javadoc (offset commit semantics — auto vs manual, commitSync/commitAsync)
> Layer: `raw/` — 외부 자료(공식 문서)의 **원문 발췌·출처 기록**. 검증된 요약은 `/ingest``wiki/concepts/`에 별도 작성.
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-kafka-consumer-inbox-contract]] | ca-skeleton 의 Kafka consumer 가 "application use case 성공 + inbox/비즈니스 트랜잭션 커밋 이후에만 offset 을 커밋(ack)" 하는 계약을 채택하고 `enable.auto.commit` 자동 커밋을 기각하는 근거 — 자동 커밋의 at-least-once 전제조건(poll 이후 전량 소비 필요)과 수동 커밋의 중복 창(commit 직전 crash → 재소비) 메커니즘을 공식 문서 수준에서 확정한다 |
## 출처
- 원본 URL: https://kafka.apache.org/40/javadoc/org/apache/kafka/clients/consumer/KafkaConsumer.html
- 아카이브 URL: (미제공 — 사용자 입력 없음)
- 저자 / 조직: Apache Software Foundation — Apache Kafka 프로젝트 공식 Javadoc (`clients` 모듈, `org.apache.kafka.clients.consumer.KafkaConsumer`). URL 경로 `/40/` = Kafka 4.0 라인 Javadoc.
- 발행일: 명시 없음 (Kafka 4.0.x 라인 최신 Javadoc)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-kafka-consumer-inbox-contract` branch 가 "application 성공 이후에만 manual ack" 계약을 채택하고 `enable.auto.commit`(자동 커밋)을 기각하려면, 자동 커밋이 실제로 무엇을 보장/전제하는지와 수동 커밋에서도 남는 중복 창이 공식 API 문서 수준에서 확정되어야 한다. 이 문서는 `KafkaConsumer` Javadoc의 "Usage Examples" 절(Automatic Offset Committing / Manual Offset Control)과 `commitSync`/`commitAsync` 메서드 계약에서 그 근거를 직접 인용한다.
## 핵심 인용
> [line 번호는 `/tmp/source-fetch-20260728171542.txt` 기준 — HTML→텍스트 변환 후 줄 번호. 원문은 여러 물리 줄로 개행되어 있어(Javadoc 소스 주석의 원 줄바꿈 보존), 인용 안 문장이 줄바꿈을 가로지르는 경우 그 사실을 명시한다.]
1. [line 147-148, §Automatic Offset Committing] "Setting enable.auto.commit means that offsets are committed automatically with a frequency controlled by the config auto.commit.interval.ms."
2. [line 190-193, §Automatic Offset Committing, Note] "Using automatic offset commits can also give you "at-least-once" delivery, but the requirement is that you must consume all data returned from each call to poll(Duration) before any subsequent calls, or before closing the consumer. If you fail to do either of these, it is possible for the committed offset to get ahead of the consumed position, which results in missing records."
3. [line 156, §Manual Offset Control] "a message should not be considered as consumed until it is completed processing."
4. [line 185-189, §Manual Offset Control] "the process could fail in the interval after the insert into the database but before the commit (even though this would likely just be a few milliseconds, it is a possibility). In this case the process that took over consumption would consume from last committed offset and would repeat the insert of the last batch of data. Used in this way Kafka provides what is often called "at-least-once" delivery guarantees, as each record will likely be delivered one time but in failure cases could be duplicated."
5. [line 879-881, `commitSync()` 메서드 설명] "This is a synchronous commit and will block until either the commit succeeds, an unrecoverable error is encountered (in which case it is thrown to the caller), or the timeout specified by default.api.timeout.ms expires (in which case a TimeoutException is thrown to the caller)." — 그리고 [line 1069-1070, `commitAsync(OffsetCommitCallback)` 메서드 설명] "This is an asynchronous call and will not block. Any errors encountered are either passed to the callback (if provided) or discarded."
> 총 5개 topic, 6개 원문 문장(인용 5는 commitSync/commitAsync 대조를 위해 2개 문장을 함께 인용). 전부 self-grep 통과(아래 Self-Grep 결과 참조 — 물리 줄바꿈을 포함하는 인용은 `grep -Pzo`로 개행 포함 패턴 매칭해 검증).
## Claims Extracted
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| KAFKA-OFFSET-C1 | `enable.auto.commit=true` 는 offset 을 `auto.commit.interval.ms` 로 제어되는 주기로 자동 커밋한다 | [line 147-148] "Setting enable.auto.commit means that offsets are committed automatically with a frequency controlled by the config auto.commit.interval.ms." | official-reference | `enable.auto.commit=true` 채택 시 커밋 타이밍 메커니즘 일반 | 이 자동 커밋 방식이 안전(무손실)함을 증명하지 않는다 — 그 전제조건은 별도 `KAFKA-OFFSET-C2` |
| KAFKA-OFFSET-C2 | 자동 오프셋 커밋으로도 "at-least-once" 전달을 얻을 수 있지만, 전제조건은 매 `poll(Duration)` 호출이 반환한 데이터를 다음 poll 또는 close 이전에 **전부 소비**해야 한다는 것이며, 이를 어기면 committed offset 이 consumed position 보다 앞서가 레코드 유실이 발생할 수 있다 | [line 190-193] "Using automatic offset commits can also give you "at-least-once" delivery, but the requirement is that you must consume all data returned from each call to poll(Duration) before any subsequent calls, or before closing the consumer. If you fail to do either of these, it is possible for the committed offset to get ahead of the consumed position, which results in missing records." | official-reference | `enable.auto.commit=true` 채택을 기각하는 리스크 근거 — 비동기 처리/bounded concurrency/backpressure(pause-resume) 를 쓰는 handler 는 이 "poll 마다 전량 즉시 소비" 전제를 지키기 어렵다 | ca-skeleton 의 실제 handler 실행 모델이 이 전제조건을 위반하는지 여부는 별도 구현 사실 확인이 필요 — 이 문서는 일반 리스크 조건만 서술하고 특정 구현을 언급하지 않는다 |
| KAFKA-OFFSET-C3 | consumption 이 별도 processing logic 과 결합된 경우, 메시지는 그 처리가 완료되기 전까지 consumed 로 간주되면 안 된다 — 이것이 Manual Offset Control(수동 오프셋 제어, `enable.auto.commit=false`)을 쓰는 이유로 제시된다 | [line 156] "a message should not be considered as consumed until it is completed processing." | official-reference | manual commit 채택 근거 — "application/inbox 트랜잭션 커밋 이후에만 offset 을 ack" 하는 설계의 공식 문장 근거 | 정확히 어느 지점(같은 DB 트랜잭션 내부인지, 그 이후 별도 스텝인지)에서 commit 해야 하는지는 규정하지 않는다 — 그 구체 메커니즘은 project/branch 자체 결정(inbox 패턴) 소관 |
| KAFKA-OFFSET-C4 | 수동 커밋을 쓰더라도 "DB insert 이후, commit 이전" 구간에 프로세스가 실패하면, 이어받은 프로세스가 마지막 committed offset 부터 재소비하여 마지막 batch 의 insert 를 반복하게 되고, 이것이 Kafka 가 "at-least-once" 라 부르는 전달 보증이며 레코드가 중복될 수 있다 | [line 185-189] "the process could fail in the interval after the insert into the database but before the commit (even though this would likely just be a few milliseconds, it is a possibility). In this case the process that took over consumption would consume from last committed offset and would repeat the insert of the last batch of data. Used in this way Kafka provides what is often called "at-least-once" delivery guarantees, as each record will likely be delivered one time but in failure cases could be duplicated." | official-reference | 수동 커밋을 채택해도 exactly-once 가 아니라 at-least-once + 재소비로 인한 중복 가능성이 남는다는 근거 — 별도 멱등(inbox/idempotency) 계층이 필요하다는 project 결정의 정당화 | 그 중복을 제거하는 멱등 메커니즘(unique key, owner token, inbox 테이블 등) 자체는 규정하지 않는다 — 이는 `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-IDEMPOTENCY-OWNERSHIP-001` project 결정 소관이며 이 문서는 "중복이 가능하다"는 사실만 진술한다 |
| KAFKA-OFFSET-C5 | `commitSync()` 는 커밋 성공, 복구 불가능한 에러(호출자에게 throw), 또는 `default.api.timeout.ms` 만료(`TimeoutException` throw) 중 하나가 발생할 때까지 블로킹하는 동기 호출이다. 반대로 `commitAsync(OffsetCommitCallback)` 은 블로킹하지 않는 비동기 호출이며, 발생한 에러는 콜백으로 전달되거나(콜백 없으면) 버려진다 | [line 879-881] "This is a synchronous commit and will block until either the commit succeeds, an unrecoverable error is encountered (in which case it is thrown to the caller), or the timeout specified by default.api.timeout.ms expires (in which case a TimeoutException is thrown to the caller)." / [line 1069-1070] "This is an asynchronous call and will not block. Any errors encountered are either passed to the callback (if provided) or discarded." | official-reference | manual ack 구현 시 `commitSync`/`commitAsync` 중 선택 기준 — 블로킹 허용 여부와 에러 가시성(throw vs callback/discard)의 근거 | 어느 것을 ca-skeleton 이 실제로 채택해야 하는지는 규정하지 않는다(트레이드오프만 서술) — 그 선택은 branch-local 결정(§구현 가이드) 소관. `commitAsync` 에러가 "discard" 될 수 있다는 것은 콜백 미제공 시의 위험을 알려줄 뿐, ca-skeleton 이 콜백을 반드시 제공해야 한다는 규범적 문장은 아니다 |
### Strength 근거
모든 claim 은 `official-reference` — Apache Kafka 프로젝트가 배포하는 공식 API Javadoc(`kafka.apache.org/40/javadoc/...`)이며 RFC/표준 수준(`official-standard`)은 아니지만 벤더 자체의 공식 reference 문서다.
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `enable.auto.commit=true` 의 커밋 타이밍 메커니즘 — `KAFKA-OFFSET-C1`
- 자동 커밋으로 at-least-once 를 얻기 위한 전제조건과 그 위반 시 레코드 유실 리스크 — `KAFKA-OFFSET-C2`
- Manual Offset Control 채택 근거("처리 완료 전까지 consumed 간주 금지") — `KAFKA-OFFSET-C3`
- 수동 커밋에서도 남는 중복(재소비) 창의 메커니즘 — `KAFKA-OFFSET-C4`
- `commitSync`/`commitAsync` 의 블로킹·에러 전달 방식 대조 — `KAFKA-OFFSET-C5`
- 이 자료가 증명하지 **않는** 것 (중요 — branch 결정에 그대로 쓰면 안 됨):
- **rebalance·`max.poll.interval.ms`·`max.poll.records`·pause/resume backpressure 의 상세 계약.** 이 raw 문서는 offset commit semantics(§핵심 인용 5개)만 발췌했다 — 같은 Javadoc 페이지 안에 rebalance/liveness 관련 문장이 존재하는 것은 확인했으나(§핵심 인용에는 미포함), branch 범위의 "rebalance 처리, poison/역직렬화 실패 분류, retry topic/DLT" 결정에는 **이 raw 문서를 근거로 쓸 수 없다** — 별도 raw 문서(또는 이 문서의 후속 확장) 필요
- inbox 테이블 스키마·owner token 프로토콜 등 프로젝트 자체 멱등 메커니즘의 구체 설계 — 이 문서는 "중복이 가능하다"는 사실만 증명하고 그 해결책은 규정하지 않는다(`KAFKA-OFFSET-C4` 한계 참조)
- `commitSync(Map)`/`commitAsync(Map, callback)` 의 partition-단위 세밀 commit 이 rebalance 중 재시도 시 offset 파라미터를 어떻게 갱신해야 하는지의 상세 — 이 문서에서 해당 오버로드의 `RebalanceInProgressException` 설명은 확인했으나 이번 5개 인용에는 포함하지 않음(필요 시 추가 인용으로 보강)
- "어느 commit 방식이 ca-skeleton 에 더 적합한가"에 대한 규범적 권고 — 이 문서는 트레이드오프만 서술하고 prescriptive 하지 않음
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- ca-skeleton Kafka consumer adapter 가 실제로 `enable.auto.commit=false` + 명시적 commit 호출 지점(inbox 트랜잭션 커밋 이후)을 코드로 구현했는지 — 코드 레벨 검증 필요
- rebalance·backpressure·DLT 관련 결정에 쓸 별도 공식 근거(같은 Javadoc 페이지의 나머지 절 또는 `ConsumerRebalanceListener`/`ConsumerConfig` Javadoc) 추가 수집
## 메모
> 검증되지 않은 추론은 여기에만 둔다. wiki/concepts 로 옮길 때 재검토.
- WebFetch 도구의 1차 결과(요약 모델 처리본)는 "Automatic Offset Committing"/"Manual Offset Control" 문단을 paraphrase 했고 self-grep 검증이 불가능해 이 문서 작성에 **사용하지 않았다.** `curl` 로 raw HTML 을 받아 stdlib `re`/`html.unescape` 로 직접 텍스트를 추출(블록 태그 경계에서만 줄바꿈 삽입)한 뒤, 그 텍스트에서만 인용을 뽑았다 — `raw/official-docs/kafka-producer-javadoc-acks-idempotence.md` 작성 시 확립된 동일 절차.
- 원문 문단이 Javadoc 소스 주석의 원래 줄바꿈을 그대로 보존하고 있어(브라우저 렌더링과 달리 raw text 추출본은 문장 중간에 개행이 남음), 인용 2/4/5 는 물리적으로 2줄 이상에 걸쳐 있다. Self-Grep 은 `grep -Pzo`(null-delimited, 개행 포함 패턴)로 수행했다 — 아래 Self-Grep Verification 참조.
- 다음 작업 후보: 같은 `KafkaConsumer` Javadoc 페이지의 rebalance(`ConsumerRebalanceListener`)/backpressure(`pause`/`resume`) 절을 별도 인용 세트로 추가하거나 새 raw 문서로 분리해, branch 범위의 "rebalance·backpressure·DLT" 결정 근거를 보강해야 한다.
## 관련
- 같은 vendor(Apache Kafka) 다른 raw 문서: [[raw/official-docs/kafka-producer-javadoc-acks-idempotence]] — producer 측 idempotence/ordering/flush 계약 (다른 branch, 같은 Javadoc 패밀리)
- 같은 branch 인접 개념: [[raw/official-docs/kafka-message-delivery-semantics-design]] — at-least-once/exactly-once 정의, idempotent producer 의 broker-ID+sequence-number 중복 제거 (producer branch 근거지만 delivery semantics 용어 정의를 공유)
- 이 자료를 인용한 wiki 요약: 아직 없음 (생성 시 링크)
@@ -0,0 +1,88 @@
---
title: official-doc / KIP-429 — Kafka Consumer Incremental Cooperative Rebalance Protocol
source_type: official-doc
url: https://cwiki.apache.org/confluence/display/KAFKA/KIP-429%3A+Kafka+Consumer+Incremental+Rebalance+Protocol
archive_url:
related_branches: [feature-kafka-consumer-inbox-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, messaging, kafka]
created: 2026-07-28
---
# KIP-429 — Kafka Consumer Incremental Cooperative Rebalance Protocol
> Layer: `raw/` — 외부 자료(공식 문서)의 **원문 발췌·출처 기록**.
> 문서 종류: Apache Kafka 공식 Kafka Improvement Proposal (KIP), Apache Software Foundation Confluence wiki (cwiki.apache.org) 호스팅.
## URL Fetch 기록
- 최초 시도 URL(요청됨): `https://cwiki.apache.org/confluence/display/KAFKA/KIP-429%3A+Kafka+Consumer+Incremental+Rebalance+Protocol` — WebFetch 도구는 HTTP 200을 반환했으나, 그 출력은 소형 모델이 재구성한 **paraphrase 요약**(예: "KIP-429 introduces an incremental cooperative rebalancing protocol...")이었고 원문 바이트와 일치하지 않아 Self-Grep 검증 대상으로 사용할 수 없었다.
- 대체 조치: 동일 URL을 `curl` 로 직접 fetch(HTTP 200, 127,631 bytes)한 뒤, HTML 태그를 제거하는 결정론적 변환 스크립트로 순수 텍스트를 추출해 `/tmp/.../scratchpad/source-fetch-kip429.txt` (50,212 chars, 481 lines)에 저장. 이 파일을 Self-Grep 검증의 단일 기준으로 사용했다.
- 인코딩 이슈 재시도는 불필요했다 — 원 URL(`%3A` 인코딩 그대로)이 curl 직접 fetch에서 정상 동작함.
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-kafka-consumer-inbox-contract]] | ca-skeleton consumer 가 파티션 할당 전략으로 incremental cooperative rebalance(`cooperative-sticky`)를 채택할지의 근거 — eager 프로토콜은 rebalance 마다 소유한 모든 파티션을 revoke 하지만 cooperative 는 소유 파티션을 유지해 피해 범위를 줄이며, 그 결과 `onPartitionsRevoked` 가 호출되지 않을 수도 있어 revoke 시점 커밋(manual ack / inbox 트랜잭션 경계) 로직을 유일한 체크포인트로 설계하면 안 된다는 근거 |
## 출처
- 원본 URL: https://cwiki.apache.org/confluence/display/KAFKA/KIP-429%3A+Kafka+Consumer+Incremental+Rebalance+Protocol
- 아카이브 URL: (미제공)
- 저자 / 조직: Boyang Chen (작성, Confluence 표기), A. Sophie Blee-Goldman (최종 수정, 2021-05-11) — Apache Kafka 프로젝트, Apache Software Foundation
- 발행일: Confluence 표기에 최초 생성일은 없음 — "Created by Boyang Chen, last updated by A. Sophie Blee-Goldman on May 11, 2021" 만 명시
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-kafka-consumer-inbox-contract` 브랜치가 파티션 할당 전략(eager vs cooperative-sticky)을 결정하고 `ConsumerRebalanceListener` 콜백(특히 revoke 시점 커밋 로직) 설계를 재검토하기 위해서는, "cooperative 프로토콜에서 `onPartitionsRevoked` 가 호출되지 않을 수 있다"는 사실이 공식 문서로 뒷받침되어야 한다. 이 KIP 은 그 사실을 규정하는 원 사양 문서다.
## 핵심 인용
> [§Status, line 44] "Current state :" [...] "Accepted (2.4.0)" (원문은 콜론 뒤 non-breaking space + 일반 space 조합이라 정확한 원본 공백 문자와 자간을 그대로 재현하지 않고 elide 표기로 분리 인용함 — 두 fragment 모두 grep 검증됨)
> [§Public Interfaces — `ConsumerPartitionAssignor.RebalanceProtocol` javadoc, line 312] "The {@link RebalanceProtocol#EAGER} rebalance protocol requires a consumer to always revoke all its owned" [...] (line 313) "partitions before participating in a rebalance event. It therefore allows a complete reshuffling of the assignment."
> [§Public Interfaces — 동 javadoc, line 315] "{@link RebalanceProtocol#COOPERATIVE} rebalance protocol allows a consumer to retain its currently owned" [...] (line 316) "partitions before participating in a rebalance event. The assignor should not reassign any owned partitions"
> [§ConsumerRebalanceListener and ConsumerPartitionAssignor Semantics, line 210] "Listener#onPartitionsLost : if the member has missed a rebalance and fallen out of the group, this new callback will be invoked on the set of all owned partitions (unless empty). The member will then rejoin the group."
> [§ConsumerRebalanceListener and ConsumerPartitionAssignor Semantics, line 224] "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."
## Claims Extracted
| Claim ID | Claim | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| KIP429-C1 | KIP-429 는 Apache Kafka 프로젝트가 공식 채택(Accepted)한 제안이며 Kafka 2.4.0 에 반영되었다 | [§Status, line 44] "Current state :" [...] "Accepted (2.4.0)" | `official-vendor-doc` | Kafka client 2.4.0 이상에서 incremental cooperative rebalance protocol 이 공식 기능으로 존재한다는 것 | ca-skeleton 이 실제로 Kafka client 2.4.0 이상 버전을 사용한다는 것(별도 의존성 버전 확인 필요), Spring Kafka wrapper 가 이 기능을 그대로 노출한다는 것 |
| KIP429-C2 | EAGER rebalance protocol 은 consumer 가 rebalance event 에 참여하기 전 소유한 모든 파티션을 항상 revoke 하도록 요구하며, 그 결과 할당의 완전한 재구성(complete reshuffling)을 허용한다 | [§Public Interfaces, line 312-313] "The {@link RebalanceProtocol#EAGER} rebalance protocol requires a consumer to always revoke all its owned" [...] "partitions before participating in a rebalance event. It therefore allows a complete reshuffling of the assignment." | `official-vendor-doc` | Kafka consumer 의 (변경 전) 기본/전통적 rebalance protocol 동작(RangeAssignor·RoundRobinAssignor 등 EAGER 전용 assignor 사용 시) | EAGER 프로토콜이 ca-skeleton 환경에서 실측 downtime 을 유발한다는 것 — 이는 로컬/dev 측정으로 별도 검증 필요 |
| KIP429-C3 | COOPERATIVE rebalance protocol 은 consumer 가 rebalance event 참여 전 현재 소유한 파티션을 유지하도록 허용하며, assignor 는 소유 파티션을 즉시 재할당하지 않고 다음 rebalance 에서 revoke 가 필요함을 표시만 한다 | [§Public Interfaces, line 315-316] "{@link RebalanceProtocol#COOPERATIVE} rebalance protocol allows a consumer to retain its currently owned" [...] "partitions before participating in a rebalance event. The assignor should not reassign any owned partitions" | `official-vendor-doc` | `cooperative-sticky` assignor(Kafka 2.4+)를 사용하는 plain consumer | 모든 커스텀 assignor 가 cooperative 를 지원한다는 것 — 문서는 오히려 기존 StickyAssignor 는 Cooperative 를 지원하도록 만들어지지 않았다고 별도 서술한다(§메모 참조, 이 Claim 의 인용 범위 밖) |
| KIP429-C4 | cooperative protocol 에서 `onPartitionsLost` 콜백은 멤버가 rebalance 를 놓치고 그룹에서 이탈했을 때 소유하고 있던 모든 파티션 집합(비어있지 않은 경우)에 대해 호출되며, 이후 멤버는 그룹에 재가입한다 | [§ConsumerRebalanceListener and ConsumerPartitionAssignor Semantics, line 210] "Listener#onPartitionsLost : if the member has missed a rebalance and fallen out of the group, this new callback will be invoked on the set of all owned partitions (unless empty). The member will then rejoin the group." | `official-vendor-doc` | cooperative rebalance listener 구현 시 "멤버십 손실"(그룹 이탈) 경로의 파티션 정리 로직 설계 | `onPartitionsLost` 이후 `InboxStorePort` 의 커밋/롤백을 어떤 방식으로 처리해야 하는지 — 그 메커니즘 자체는 이 KIP 가 아니라 본 branch 의 별도 설계 결정 |
| KIP429-C5 | cooperative protocol 에서 `onPartitionsRevoked` 콜백은 rebalance 도중 전혀 호출되지 않을 수 있으며, rebalance 가 시작되었음을 알리는 신호로 신뢰해서는 안 된다 | [§ConsumerRebalanceListener and ConsumerPartitionAssignor Semantics, line 224] "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." | `official-vendor-doc` | revoke 시점 커밋/flush 로직을 rebalance 감지의 유일한 체크포인트로 설계하면 안 된다는 근거 — manual ack·inbox 커밋 시점을 `onPartitionsRevoked` 호출에만 의존하도록 설계해서는 안 됨 | `onPartitionsAssigned` 이 이 상황에서 ca-skeleton 의 안전한 대체 체크포인트로 항상 충분하다는 것 — 원문은 "always be called"만 말할 뿐, ca-skeleton `InboxStorePort` 트랜잭션 경계 설계 자체는 이 KIP 로 증명되지 않고 별도 구현·검증 필요 |
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `KIP429-C1`: KIP-429 가 공식 채택되어 Kafka 2.4.0 에 반영된 사실
- `KIP429-C2`: EAGER protocol 의 "항상 전체 revoke 후 재참여" 정의
- `KIP429-C3`: COOPERATIVE protocol 의 "소유 파티션 유지, 필요한 것만 다음 rebalance 에서 revoke" 정의
- `KIP429-C4`: `onPartitionsLost` 가 그룹 이탈(멤버십 손실) 시에만 호출된다는 것
- `KIP429-C5`: `onPartitionsRevoked` 가 cooperative protocol 에서 호출되지 않을 수 있다는 것 — rebalance 시작 신호로 신뢰 불가
- 이 자료가 증명하지 않는 것:
- ca-skeleton 이 실제로 `cooperative-sticky` assignor 를 설정했다는 것(구현 사실) — 이 문서는 프로토콜 사양일 뿐 프로젝트 구현 증거가 아님
- Spring Kafka 의 `ConcurrentKafkaListenerContainerFactory`/`ContainerProperties` 가 이 콜백 시맨틱을 그대로 노출하는지 — Spring Kafka 자체의 공식 문서로 별도 확인 필요
- `InboxStorePort` 트랜잭션 커밋을 어느 콜백(assigned/lost/revoked)에 바인딩해야 하는지의 **구현 방법** — 이 KIP 은 원칙(revoked 가 신뢰 불가능한 신호)만 규정하며 ca-skeleton 의 구체적 커밋 바인딩은 branch 의 별도 설계 결정
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- ca-skeleton 이 사용하는 Kafka client 실제 버전이 2.4.0 이상인지(빌드 의존성 확인)
- Spring Kafka `ConsumerRebalanceListener` 래퍼가 `onPartitionsLost` 를 KIP-429 원문 시맨틱 그대로 전달하는지(Spring Kafka 공식 문서 별도 인용 필요)
## 메모
- 확인됨(grep 검증, line 360, 이번 5개 인용에는 미포함): "The existing StickyAssignor was not made to support Cooperative to ensure users follow the smooth upgrade path outlined below..." — 기존 `StickyAssignor` 는 cooperative 미지원. ca-skeleton 이 `cooperative-sticky` 가 아닌 다른 sticky 계열 assignor 를 검토할 경우 이 문장을 별도 Claim 으로 승격해 인용할 것.
- 업그레이드 경로(2회 rolling bounce) 관련 문장도 line 369 부근에서 grep 확인됨 — ca-skeleton 은 신규 프로젝트라 rolling upgrade 시나리오 자체가 branch 결정 범위 밖일 가능성이 높음(운영 중인 기존 컨슈머 그룹의 무중단 업그레이드가 아니라면). 필요 시 별도 Claim 화.
- WebFetch 도구의 출력이 verbatim 이 아니라 paraphrase 라는 것을 이번에 직접 확인함 — 향후 동일 유형 작업에서는 curl 직접 fetch + 결정론적 HTML→text 변환을 기본 경로로 삼는 것이 안전하다.
## 관련
- 같은 branch 의 다른 official-doc: [[raw/official-docs/kafka-message-delivery-semantics-design.md]] — delivery semantics(at-least-once/exactly-once) 정의, 인접 관심사
- 이 자료를 인용한 wiki 요약: (생성 시 링크)
@@ -0,0 +1,100 @@
---
title: official-doc / Kafka Message Delivery Semantics — At-Least-Once, Idempotent Producer, Transactional Delivery Scope
source_type: official-doc
url: https://docs.confluent.io/kafka/design/delivery-semantics.html
archive_url:
related_branches: [feature-kafka-producer-runtime-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, messaging, kafka, idempotency]
created: 2026-07-28
---
# Kafka Message Delivery Semantics — At-Least-Once, Idempotent Producer, Transactional Delivery Scope
> Layer: `raw/` — 외부 자료(공식 문서)의 **원문 발췌·출처 기록**.
> 문서 버전: Confluent Documentation "Kafka Message Delivery Guarantees" (Apache Kafka Design 섹션) — 특정 개정일 미표기.
## source_type
`official-doc` — 이 페이지는 하단에 "This website includes content developed at the Apache Software Foundation under the terms of the Apache License v2" 고지를 명시한다. 즉 Confluent 문서 사이트가 호스팅하지만, 인용 대상 내용 자체는 Apache Kafka 프로젝트의 공식 semantics 설계 서술이다. **원 요청 URL은 접근 불가했다** — 아래 "URL Fetch 실패 기록" 참조.
## URL Fetch 실패 기록 (원 요청 URL)
- 요청된 URL: `https://kafka.apache.org/documentation/#semantics`
- 시도 1 (WebFetch, `#semantics` 앵커 포함): nav/redirect 셸만 반환, "Message Delivery Semantics" 섹션 본문 없음
- 시도 2 (WebFetch, 앵커 제거 `https://kafka.apache.org/documentation/`): 동일 — 네비게이션 메뉴·릴리스 목록·footer만 포함
- 시도 3 (`curl` 직접 fetch, `kafka.apache.org/documentation.html``/40/`, `/39/`, `/38/`, `/37/`, `/36/documentation.html` 버전별 순회): 전부 `<title>Documentation Redirect Legacy | Apache Kafka</title>` 셸 페이지(19,985 bytes 동일), "Message Delivery" 문자열 0건
- **판정**: 현재 `kafka.apache.org` 문서 사이트는 JS 렌더링 SPA로 전환되어 정적 fetch로는 본문에 도달 불가. 사용자가 사전 승인한 fallback 절차에 따라 `https://docs.confluent.io/kafka/design/delivery-semantics.html` 사용.
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-kafka-producer-runtime-contract]] | ca-skeleton 이 producer 측 보증을 "idempotent producer 로 파티션 단위 중복 제거된 at-least-once" 로 표현하고, **DB 와 broker 를 걸친 exactly-once 는 주장하지 않는다**는 상속 결정(`DEC-CA-SKELETON-OPERATIONAL-CONTRACT-DELIVERY-SEMANTICS-001@1`)의 경계를 공식 문장으로 고정한다 |
## 출처
- 원본 URL (요청됨, 접근 불가): https://kafka.apache.org/documentation/#semantics
- 실제 인용 URL (fallback, 사용됨): https://docs.confluent.io/kafka/design/delivery-semantics.html
- 아카이브 URL: (미제공)
- 저자 / 조직: Confluent, Inc. (문서 콘텐츠는 Apache Software Foundation 개발분 재게시, Apache License v2 고지)
- 발행일: 명시 없음 (footer: "Copyright © Confluent, Inc. 2014-", 개정일 미표기)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-kafka-producer-runtime-contract` 브랜치가 상속한 project 결정(`DEC-CA-SKELETON-OPERATIONAL-CONTRACT-DELIVERY-SEMANTICS-001@1`: "end-to-end 메시징 보증은 at-least-once 전달과 멱등 consumer·inbox로 표현하고 DB와 broker를 걸친 exactly-once를 주장하지 않는다")의 경계를 Kafka 공식 semantics 개념(at-least-once/exactly-once 정의, idempotent producer, transactional producer, exactly-once 적용 범위)으로 뒷받침하기 위함. **단, "그 보장이 파티션 단위"라는 정확한 스코프 표현은 본 페이지 어디에도 없다** — Self-Grep 결과 및 §메모 참조.
## 핵심 인용
> [§Semantic guarantees] "At least once: This means messages are delivered one or more times. If there is a system failure, messages are never lost, but they may be delivered more than once."
> [§Semantic guarantees] "Exactly once: This is the preferred behavior in that each message is delivered once and only once. Messages are never lost or read twice even if some part of the system fails."
> [§Producer delivery — idempotent 옵션] "The idempotent delivery option guarantees that resending a message will not result in duplicate entries in the log, and that log order is maintained. To achieve this, the broker assigns each producer an ID and deduplicates messages using a sequence number that is sent by the producer with every message."
> [§Producer delivery — Exactly once (transactional)] "Exactly once - Starting with version 0.11.0.0, producers can utilize transactional delivery. This means a producer can request acknowledgment that messages were received and successfully replicated, and if it resends a message, it resends with idempotency, meaning existing messages are overwritten rather than duplicated."
> [§Exactly once support] "Kafka supports exactly-once delivery in Kafka Streams and uses transactional producers and consumers to provide exactly-once delivery when transferring and processing data between Kafka topics." [...] "Otherwise, by default Kafka guarantees at-least-once delivery."
## Claims Extracted
| Claim ID | Claim | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| KAFKA-SEM-C1 | at-least-once 는 "메시지가 1회 이상 전달됨: 실패 시 유실되지 않지만 중복 전달될 수 있음" 으로 정의된다 | "At least once: This means messages are delivered one or more times. If there is a system failure, messages are never lost, but they may be delivered more than once." | `official-vendor-doc` | Kafka broker/producer/consumer 간 semantic guarantee 용어 정의 일반 | ca-skeleton 이 실제로 at-least-once 를 달성한다는 것(설정·구현 사실)은 증명하지 않는다 — 이는 용어 정의일 뿐 프로젝트 구현 증거가 아니다 |
| KAFKA-SEM-C2 | exactly-once 는 "각 메시지가 정확히 한 번만 전달됨: 시스템 일부가 실패해도 유실되거나 두 번 읽히지 않음" 으로 정의되며 "선호되는(preferred) 동작" 으로 서술된다 | "Exactly once: This is the preferred behavior in that each message is delivered once and only once. Messages are never lost or read twice even if some part of the system fails." | `official-vendor-doc` | exactly-once 라는 용어 자체의 정의 | Kafka 가 **모든** 상황에서 exactly-once 를 실제로 제공한다는 것은 증명하지 않는다 — 페이지 자체가 바로 다음 문장에서 "many systems claim to provide exactly once ... but this might not always what you think it is" 라고 경고하며, 적용 범위는 KAFKA-SEM-C5 로 별도 한정된다 |
| KAFKA-SEM-C3 | idempotent producer 옵션은 "메시지 재전송이 로그에 중복 항목을 만들지 않고, 로그 순서가 유지됨"을 보장하며, 이는 broker가 producer마다 ID를 부여하고 매 메시지에 동봉되는 sequence number로 중복 제거하는 방식으로 달성된다 | "The idempotent delivery option guarantees that resending a message will not result in duplicate entries in the log, and that log order is maintained. To achieve this, the broker assigns each producer an ID and deduplicates messages using a sequence number that is sent by the producer with every message." | `official-vendor-doc` | Kafka producer의 idempotent delivery(0.11.0.0+) 재시도 시 broker측 중복 제거 메커니즘 | **"파티션 단위(per-partition)" 라는 단어를 이 페이지는 전혀 쓰지 않는다.** "log" 라는 표현만 사용하며, Kafka의 로그가 topic-partition 단위로 존재한다는 사실은 이 문서 밖의 일반 지식으로 추론될 뿐 본문이 명시적으로 진술하지 않는다 — Self-Grep 실패 항목으로 아래 보고. 또한 producer 재시작(새 PID 발급) 시 이전 세션의 중복 제거 상태가 유지되는지도 이 문서는 언급하지 않는다 |
| KAFKA-SEM-C4 | 0.11.0.0부터 producer는 transactional delivery를 쓸 수 있으며, 메시지 수신·복제 완료 ack를 요청할 수 있고, 재전송 시 idempotency로 기존 메시지를 덮어써 중복시키지 않는다(더 높은 지연 대신 더 높은 내구성) | "Exactly once - Starting with version 0.11.0.0, producers can utilize transactional delivery. This means a producer can request acknowledgment that messages were received and successfully replicated, and if it resends a message, it resends with idempotency, meaning existing messages are overwritten rather than duplicated." | `official-vendor-doc` | Kafka 내부(브로커/토픽/파티션)에 대한 transactional producer 동작 | **DB 트랜잭션과의 원자성은 증명하지 않는다.** 이 인용은 Kafka 브로커에 대한 producer의 ack/재전송 동작만 서술하며, 외부 데이터베이스 write 와 Kafka publish 를 하나의 원자적 단위로 묶는다는 진술은 어디에도 없다 |
| KAFKA-SEM-C5 | Kafka Streams는 transactional producer/consumer를 이용해 "Kafka 토픽 간(between Kafka topics) 데이터를 옮기고 처리할 때" exactly-once를 제공하며, 그 외 consumer/producer 시스템은 Kafka Connect API의 자동 offset 관리를 써야 하고, 그렇지 않으면 Kafka는 기본적으로 at-least-once를 보장한다 | "Kafka supports exactly-once delivery in Kafka Streams and uses transactional producers and consumers to provide exactly-once delivery when transferring and processing data between Kafka topics." [...] "Otherwise, by default Kafka guarantees at-least-once delivery." | `official-vendor-doc` | Kafka 내장 exactly-once의 적용 범위(intra-Kafka topic-to-topic) vs 그 밖의 시스템 | **이 문장은 "DB와 broker를 걸친 exactly-once가 불가능하다"를 명시적으로 부정하지 않는다.** "between Kafka topics" 로 범위를 한정하고, 외부 시스템은 Connect API의 offset 관리에 위임한다고만 말할 뿐이며, 임의의 DB write 가 Kafka publish 와 원자적으로 결합될 수 있는지/없는지에 대한 직접 진술은 이 페이지에 없다 |
### Strength 참고
모두 `official-vendor-doc` — RFC/표준(`official-standard`)이 아니라 Kafka 프로젝트(Apache Software Foundation 개발분, Confluent 재게시)의 공식 설계 문서 서술이다.
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `KAFKA-SEM-C1`: at-least-once 의 정의(1회 이상 전달, 실패 시 무손실이나 중복 가능)
- `KAFKA-SEM-C2`: exactly-once 의 정의(정확히 1회, 무손실·무중복) 및 이 페이지가 이를 "선호되는 동작"으로 규정한다는 것
- `KAFKA-SEM-C3`: idempotent producer 가 broker-assigned ID + per-message sequence number 로 재전송 시 로그 중복을 막는다는 메커니즘
- `KAFKA-SEM-C4`: 0.11.0.0+ transactional producer 가 ack 요청 + idempotent 재전송(overwrite, not duplicate)을 제공한다는 것
- `KAFKA-SEM-C5`: Kafka 내장 exactly-once 의 명시적 적용 범위가 "Kafka 토픽 간"이며, 그 밖의 시스템은 Connect API offset 관리에 의존한다는 것, 기본값은 at-least-once 라는 것
- 이 자료가 증명하지 **않는** 것:
- **idempotent producer 보장이 "파티션 단위"라는 정확한 스코프 문구.** 이 페이지는 그 단어를 쓰지 않는다(`KAFKA-SEM-C3` 참조) — branch-note 나 project 결정문에서 "파티션 단위"라는 표현을 쓰려면 이 문서만으로는 근거가 부족하고, Kafka producer configs 공식 문서(`enable.idempotence` 항목, Javadoc)로 별도 보강이 필요하다
- Kafka transactional producer 가 DB write 와 원자적으로 결합된다는 것(`KAFKA-SEM-C4`, `KAFKA-SEM-C5`) — 이 문서 범위 안에서는 Kafka 내부(토픽/파티션) transactional write만 다루며, DB-broker 이중 쓰기(dual-write) 원자성에 대한 진술은 전혀 없다
- Kafka 가 모든 상황에서 exactly-once 를 보장한다는 일반 주장 — 페이지 스스로 "many systems claim to provide exactly once delivery semantics, but this might not always be what you think it is" 라고 경고하며 범위를 한정한다
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- "파티션 단위 중복 제거"의 정확한 공식 문구 — Kafka producer configs 문서(`enable.idempotence`) 또는 Javadoc 별도 인용 필요
- `acks=all` + idempotent producer 조합이 실제로 ca-skeleton 설정에 반영되는지 — 별도 구현 증거(코드/설정) 필요
- Kafka Connect API 의 offset 관리가 실제로 임의 외부 DB(비-Connect 커스텀 consumer)에도 원자성을 제공하는지 — 이 페이지는 Connect 커넥터 예시(HDFS)만 들며 일반화하지 않는다
## 메모
- Self-Grep 실패 보고: "파티션 단위" 관련 원문 문구를 이 페이지(Confluent fallback)에서 찾지 못했다. 추측으로 채우지 않고 `KAFKA-SEM-C3`의 "Does not prove"에 명시했다. 필요 시 Apache Kafka producer configs 공식 문서(`enable.idempotence`)를 별도 raw 문서로 수집해 이 gap 을 메워야 한다.
- 원 요청 URL(`kafka.apache.org/documentation/#semantics`)은 현재 정적 fetch로 접근 불가한 JS 렌더링 nav 셸이다 — 향후 재시도 시 브라우저 렌더링 기반 도구가 필요할 수 있다.
- 같은 branch 의 다른 raw 문서(`raw/official-docs/spring-kafka-sending-messages-kafkatemplate.md`)가 이미 "acks=all + enable.idempotence 설정 자체"를 다루는 Kafka producer configs 공식 문서 보강이 필요하다고 지적한 바 있다 — 동일 gap.
## 관련
- 같은 branch 의 다른 official-doc: [[raw/official-docs/spring-kafka-sending-messages-kafkatemplate.md]] — `KafkaTemplate.send()` 반환 타입/동기 대기 메커니즘 (같은 branch, 인접 관심사)
- 이 자료를 인용한 wiki 요약: (생성 시 링크)
@@ -0,0 +1,113 @@
---
title: official-doc / Apache Kafka Producer Configs — delivery.timeout.ms, retries, acks, enable.idempotence
source_type: official-doc
url: https://kafka.apache.org/documentation/#producerconfigs
archive_url:
related_branches: [feature-kafka-producer-runtime-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, messaging, kafka, idempotency]
created: 2026-07-28
---
# Apache Kafka Producer Configs — delivery.timeout.ms, retries, acks, enable.idempotence
> Layer: `raw/` — 외부 자료(공식 문서)의 **원문 발췌·출처 기록**.
> 문서 버전: Apache Kafka 4.3 (`kafka.apache.org/43/configuration/producer-configs/`, `last-modified: 2026-07-11`) — 아래 "URL Fetch 경위" 참조.
## source_type
`official-doc` — Apache Software Foundation 이 직접 호스팅하는 Apache Kafka 프로젝트 공식 configuration reference.
## URL Fetch 경위 (원 요청 URL은 client-side JS redirect 셸)
- 요청된 URL: `https://kafka.apache.org/documentation/#producerconfigs`
- 시도 1 (WebFetch): nav/release-dropdown/footer만 반환. "Redirecting..." 표시. producer config 본문 없음.
- 시도 2 (WebFetch, `documentation.html#producerconfigs`): 동일 실패.
- **원인 규명**: `curl` 로 원본 HTML 을 직접 받아보니 `<title>Documentation Redirect Legacy | Apache Kafka</title>` 셸이며, 내부 `<script>` 에 해시 앵커 → 실제 경로 매핑 테이블이 있다: `"#producerconfigs":"configuration/producer-configs/"`, 그리고 현재 버전 변수 `t="43"`. 즉 브라우저가 `window.location.replace("/43/configuration/producer-configs/")` 로 client-side redirect 하는 구조이며, 정적 WebFetch/curl 은 이 JS를 실행하지 못해 셸만 받는다.
- **판정**: fallback(Confluent 미러) 없이 **원 저작자(Apache Kafka) 사이트의 실제 목적지 페이지**를 직접 특정해 fetch 가능했다. 사용한 실제 URL: `https://kafka.apache.org/43/configuration/producer-configs/` (HTTP 200, 146,845 bytes). 이 페이지를 `curl` 로 받아 HTML 태그 제거 후 텍스트로 저장, 아래 모든 인용은 이 저장 텍스트에 대해 `grep -nF` 로 self-grep 검증했다.
- 저장 텍스트: `/tmp/claude-*/scratchpad/kafka-43-producer-configs.txt` (세션별 scratchpad, 1150 lines)
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-kafka-producer-runtime-contract]] | ca-skeleton 의 Kafka producer 가 재시도 예산을 `retries` 횟수가 아니라 bounded `delivery.timeout.ms` 로 표현하고, 압축·배치·요청 크기 상한을 계약값으로 고정한다 |
## 출처
- 원본 URL (요청됨, 앵커): https://kafka.apache.org/documentation/#producerconfigs
- 실제 인용 URL (client-side redirect 목적지, 버전 고정): https://kafka.apache.org/43/configuration/producer-configs/
- 아카이브 URL: (미제공)
- 저자 / 조직: Apache Software Foundation (Apache Kafka 프로젝트)
- 발행일: 명시 없음 (페이지 자체는 버전 문서, HTTP `last-modified: Sat, 11 Jul 2026 01:38:20 GMT`)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-kafka-producer-runtime-contract` 브랜치가 "재시도 예산을 `retries` 횟수가 아니라 bounded `delivery.timeout.ms` 로 표현"하는 결정과 "압축·배치·요청 크기 상한을 계약값으로 고정"하는 결정의 공식 기준값(기본값·제약 문장)을 확보하기 위해 저장. `acks`/`enable.idempotence`/`max.in.flight.requests.per.connection` 의 상호 제약도 idempotent producer 설정을 검증 계약 test 로 만들 때 그대로 근거가 된다.
## 핵심 인용
> [§delivery.timeout.ms] "An upper bound on the time to report success or failure after a call to send() returns." [...] "The value of this config should be greater than or equal to the sum of request.timeout.ms and linger.ms." (Default: `120000` — 2 minutes)
> [§retries] "Requests will be retried this many times until they succeed, fail with a non-transient error, or the delivery.timeout.ms expires." [...] "Users should generally prefer to leave this config unset and instead use delivery.timeout.ms to control retry behavior." (Default: `2147483647`)
> [§acks] "Note that enabling idempotence requires this config value to be 'all'." (Default: `all`)
> [§enable.idempotence] "Note that enabling idempotence requires max.in.flight.requests.per.connection to be less than or equal to 5 (with message ordering preserved for any allowable value), retries to be greater than 0, and acks must be 'all'." [...] "Idempotence is enabled by default if no conflicting configurations are set." (Default: `true`)
> [§max.in.flight.requests.per.connection] "Note that if this configuration is set to be greater than 1 and enable.idempotence is set to false, there is a risk of message reordering after a failed send due to retries (i.e., if retries are enabled); if retries are disabled or if enable.idempotence is set to true, ordering will be preserved." [...] "Additionally, enabling idempotence requires the value of this configuration to be less than or equal to 5, because broker only retains at most 5 batches for each producer." (Default: `5`)
> [§buffer.memory / §max.block.ms] "If records are sent faster than they can be delivered to the server the producer will block for max.block.ms after which it will fail with an exception." (buffer.memory Default: `33554432`, max.block.ms Default: `60000` — 1 minute)
> [§batch.size / §linger.ms] "This linger.ms setting defaults to 5, which means the producer will wait for 5ms or until the record batch is of batch.size (whichever happens first) before sending the record batch." (batch.size Default: `16384`, linger.ms Default: `5`)
> [§compression.type] "The default is none (i.e. no compression)." [...] "Compression is of full batches of data, so the efficacy of batching will also impact the compression ratio (more batching means better compression)."
> 참고 (self-grep 검증됨, 상기 인용 문맥 안): `max.request.size` Default: `1048576` — "The maximum size of a request in bytes." (§max.request.size)
## Claims Extracted
| Claim ID | Claim | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| KAFKA-PRODCFG-C1 | `delivery.timeout.ms``send()` 반환 후 성공/실패를 보고하기까지의 상한이며, 기본값은 120000(2분)이다. 이 값은 `request.timeout.ms` + `linger.ms` 의 합보다 크거나 같아야 한다는 제약이 있다 | "An upper bound on the time to report success or failure after a call to send() returns." ... "The value of this config should be greater than or equal to the sum of request.timeout.ms and linger.ms." | `official-vendor-doc` | `request.timeout.ms`·`linger.ms` 를 override 할 때 `delivery.timeout.ms` 를 재계산해야 한다는 설정 제약 일반 | ca-skeleton 워크로드에 120000ms 가 적절한 SLA 인지는 증명하지 않는다. 위반 시 어떤 예외가 던져지는지(타입)도 이 인용에는 없다 |
| KAFKA-PRODCFG-C2 | `retries` 는 사실상 무제한 기본값(2147483647)이며, 재시도 루프는 명시적 횟수가 아니라 `delivery.timeout.ms` 만료로 종료된다. 공식 문서는 `retries` 를 직접 조정하지 말고 `delivery.timeout.ms` 로 재시도 예산을 통제하라고 권고한다 | "Requests will be retried this many times until they succeed, fail with a non-transient error, or the delivery.timeout.ms expires." ... "Users should generally prefer to leave this config unset and instead use delivery.timeout.ms to control retry behavior." | `official-vendor-doc` | producer 재시도 예산을 카운트 기반이 아니라 시간 기반(`delivery.timeout.ms`)으로 설계하는 근거 | 단일 재시도 attempt 간 backoff 간격(`retry.backoff.ms`)이나, `delivery.timeout.ms` 만료 전 실제 몇 회 재시도가 일어나는지는 증명하지 않는다 |
| KAFKA-PRODCFG-C3 | `enable.idempotence` 가 활성화되려면 `max.in.flight.requests.per.connection` ≤ 5, `retries` > 0, `acks` = `'all'` 이어야 하고, 상충 설정이 없으면 idempotence 는 **기본값으로 활성화**된다(`enable.idempotence` Default: `true`, `acks` Default: `all`) | "Note that enabling idempotence requires max.in.flight.requests.per.connection to be less than or equal to 5 (with message ordering preserved for any allowable value), retries to be greater than 0, and acks must be 'all'." ... "Idempotence is enabled by default if no conflicting configurations are set." | `official-vendor-doc` | ca-skeleton 이 별도 override 없이 producer 기본 설정만 써도 idempotent producer 계약이 성립한다는 근거(세 조건의 기본값이 이미 요건을 만족: acks=all, retries=2147483647>0, max.in.flight=5) | acks=1 등으로 명시적으로 override 하면서 `enable.idempotence` 를 명시하지 않을 경우 idempotence 가 **조용히 비활성화**된다는 것 외의 상세 오류 처리(예외 메시지 등)는 증명하지 않는다 |
| KAFKA-PRODCFG-C4 | `max.in.flight.requests.per.connection` 기본값은 5이며, idempotence 활성 시 이 값은 5 이하로 제한된다(broker가 producer당 최대 5개 배치만 유지하기 때문). `enable.idempotence=false` 이면서 이 값이 1 초과이고 retries 가 활성화된 경우 메시지 순서가 바뀔 위험이 있고, retries 비활성 또는 idempotence 활성 시에는 순서가 보존된다 | "Note that if this configuration is set to be greater than 1 and enable.idempotence is set to false, there is a risk of message reordering after a failed send due to retries (i.e., if retries are enabled); if retries are disabled or if enable.idempotence is set to true, ordering will be preserved." ... "Additionally, enabling idempotence requires the value of this configuration to be less than or equal to 5, because broker only retains at most 5 batches for each producer." | `official-vendor-doc` | idempotent producer 하에서 in-flight 요청 수를 5(기본값)로 두어도 순서 보장이 유지된다는 근거 — branch 범위의 "key/partition 순서 보장 경계" 결정에 직접 적용 | 5보다 큰 값을 idempotence 활성 상태에서 설정했을 때 정확히 어떤 예외/오류가 발생하는지는 "previous batches may be removed on broker side" 이상으로 구체화하지 않는다 |
| KAFKA-PRODCFG-C5 | `buffer.memory`(기본값 33554432 byte)가 소진되면 producer 는 `max.block.ms`(기본값 60000ms, 1분) 동안 블로킹한 뒤 예외로 실패한다 — 즉 producer 의 블로킹은 무한이 아니라 두 설정값으로 상한이 걸린 계약이다 | "If records are sent faster than they can be delivered to the server the producer will block for max.block.ms after which it will fail with an exception." | `official-vendor-doc` | `buffer.memory` + `max.block.ms` 조합을 "bounded backpressure" 계약값으로 고정하는 근거 | 실패 시 던져지는 예외의 정확한 타입(`org.apache.kafka.common.errors.TimeoutException` 등)은 이 인용문 자체에는 명시되지 않는다 |
| KAFKA-PRODCFG-C6 | `batch.size`(기본값 16384 byte)는 파티션당 배치 크기의 상한이고, `linger.ms`(기본값 5ms, Kafka 4.0에서 0→5로 기본값 변경)는 그 상한에 도달하지 않았을 때 대기하는 시간 상한이다 — 배치 크기 도달 또는 linger 만료 중 먼저 오는 조건에 전송된다 | "This linger.ms setting defaults to 5, which means the producer will wait for 5ms or until the record batch is of batch.size (whichever happens first) before sending the record batch." | `official-vendor-doc` | `batch.size` + `linger.ms` 를 배치·지연 상한 계약값으로 고정하는 근거 | ca-skeleton 실제 topic/partition 개수·처리량에서 이 기본값 조합이 처리율/지연 목표를 만족하는지는 증명하지 않는다(벤치마크 필요) |
| KAFKA-PRODCFG-C7 | `compression.type` 기본값은 `none`(무압축)이며, 압축은 배치 단위로 이뤄지므로 배치 효율이 압축률에도 영향을 준다 | "The default is none (i.e. no compression)." ... "Compression is of full batches of data, so the efficacy of batching will also impact the compression ratio (more batching means better compression)." | `official-vendor-doc` | 압축을 계약값으로 고정할 때 `batch.size`/`linger.ms` 와 함께 고려해야 한다는 근거(배치가 작으면 압축 이득도 작음) | 어떤 `compression.type`(gzip/snappy/lz4/zstd) 이 ca-skeleton 워크로드에 최적인지는 증명하지 않는다 — 그 비교는 이 문서 범위 밖 |
### Strength 참고
모두 `official-vendor-doc` — Apache Kafka 프로젝트(Apache Software Foundation) 가 직접 게시한 configuration reference. `official-standard`(RFC/표준)는 아니다.
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `KAFKA-PRODCFG-C1`: `delivery.timeout.ms` 정의·기본값·제약(≥ request.timeout.ms + linger.ms)
- `KAFKA-PRODCFG-C2`: `retries` 기본값(사실상 무제한) + `delivery.timeout.ms` 가 재시도 예산을 지배한다는 권고
- `KAFKA-PRODCFG-C3`: idempotence 활성 요건(max.in.flight≤5, retries>0, acks=all) + 상충 없으면 기본 활성화
- `KAFKA-PRODCFG-C4`: `max.in.flight.requests.per.connection` 기본값(5)과 idempotence 하 상한(5)·순서 보존 조건
- `KAFKA-PRODCFG-C5`: `buffer.memory`/`max.block.ms` 조합이 bounded blocking 계약이라는 것
- `KAFKA-PRODCFG-C6`: `batch.size`/`linger.ms` 조합이 배치 상한·지연 상한 계약이라는 것 (Kafka 4.0 에서 linger.ms 기본값 0→5 변경 포함)
- `KAFKA-PRODCFG-C7`: `compression.type` 기본값(none)과 압축-배치 효율 관계
- 이 자료가 증명하지 **않는** 것:
- ca-skeleton 실제 워크로드에서 이 기본값들이 SLA(지연/처리율)를 만족한다는 것 — 벤치마크·측정 필요
- idempotent producer 의 중복 제거가 "파티션 단위"라는 정확한 스코프 문구 — 본 문서(`enable.idempotence`, `max.in.flight.requests.per.connection`, `retries` 항목)는 "a single partition"(§retries 문맥, ordering 논의)이라는 표현은 쓰지만, idempotent 중복 제거 메커니즘 자체를 "per-partition" 이라고 명시하지 않는다. `[[raw/official-docs/kafka-message-delivery-semantics-design.md]]` 가 이미 지적한 동일 gap이 이 문서로도 메워지지 않는다 — 필요 시 Kafka Javadoc(`KafkaProducer`) 별도 인용 필요
- `delivery.timeout.ms` 초과 시 던져지는 예외의 정확한 클래스명
- 여러 `compression.type` 값 간 성능 비교
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- ca-skeleton 이 실제로 `acks`/`enable.idempotence`/`max.in.flight.requests.per.connection` 을 기본값 그대로 두는지, 아니면 override 하는지 — 별도 구현 증거(코드/설정) 필요
- `delivery.timeout.ms`/`request.timeout.ms`/`linger.ms` 3개 값의 실제 계약값(ca-skeleton 이 채택할 숫자) — 이 문서는 기본값과 제약식만 제공, 구체적 조정값은 branch-note `## 구현 가이드` 에서 별도 결정 필요
## 메모
- 원 요청 URL(`kafka.apache.org/documentation/#producerconfigs`)은 client-side JS redirect 셸이라 WebFetch 로는 본문에 도달 불가했다. `curl` 로 셸 HTML 을 받아 내부 `<script>` 의 해시→경로 매핑 테이블(`t="43"`, `"#producerconfigs":"configuration/producer-configs/"`)을 읽어 실제 목적지(`/43/configuration/producer-configs/`)를 특정했다. 동일 branch 의 `[[raw/official-docs/kafka-message-delivery-semantics-design.md]]` 는 같은 문제(`#semantics` 앵커)를 만나 Confluent fallback 을 썼는데, 본 문서는 redirect 매핑을 직접 풀어 **Apache 원문**에 도달했다 — 더 강한 출처.
- HTML → 텍스트 변환은 직접 작성한 스크립트(태그 제거, `<td>`/`<th>`` | ` 로 표시)로 했다. Self-Grep 은 원본 HTML 문자열이 그대로 남아있는 문장 단위 인용에 대해서만 수행했고, 표 서식 기호(` | `) 자체는 인용에 포함하지 않았다.
- `retries` 필드의 "Enabling idempotence requires this config value to be greater than 0." 문장(§retries, self-grep 가능)은 KAFKA-PRODCFG-C3 에서 이미 `enable.idempotence` 항목 인용으로 커버되므로 중복 인용하지 않았다.
## 관련
- 같은 branch 의 다른 official-doc: [[raw/official-docs/kafka-message-delivery-semantics-design.md]] — at-least-once/exactly-once 정의, idempotent/transactional producer semantics (같은 branch, 인접 관심사, "파티션 단위" 표현 gap 공유)
- 같은 branch 의 다른 official-doc: [[raw/official-docs/spring-kafka-sending-messages-kafkatemplate.md]] — `KafkaTemplate.send()` 반환 타입/동기 대기 메커니즘
@@ -0,0 +1,109 @@
---
title: official-doc / Apache Kafka — KafkaProducer Javadoc (acks, idempotent producer, ordering, flush/close)
source_type: official-doc
url: https://kafka.apache.org/40/javadoc/org/apache/kafka/clients/producer/KafkaProducer.html
archive_url:
related_branches: [feature-kafka-producer-runtime-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, messaging, kafka, idempotency, ordering]
created: 2026-07-28
---
# official-doc / Apache Kafka — KafkaProducer Javadoc (acks, idempotent producer, ordering, flush/close)
> Layer: `raw/` — 외부 자료(공식 문서)의 **원문 발췌·출처 기록**. 검증된 요약은 `/ingest``wiki/concepts/`에 별도 작성.
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-kafka-producer-runtime-contract]] | ca-skeleton 의 Kafka producer 런타임 계약에서 `acks=all` + idempotent producer 를 기본값으로 못박고, 순서 보장 경계(파티션 단위)와 graceful flush/close 의 의미를 메커니즘 수준(L1+)으로 확정하는 근거. **단, in-flight 상한(`max.in.flight.requests.per.connection`)에 대한 근거는 이 URL 범위 밖 — 아래 Usage Boundaries 참조.** |
## 출처
- 원본 URL: https://kafka.apache.org/40/javadoc/org/apache/kafka/clients/producer/KafkaProducer.html
- 아카이브 URL: (미제공 — 사용자 입력 없음)
- 저자 / 조직: Apache Software Foundation — Apache Kafka 프로젝트 공식 Javadoc (`clients` 모듈, `org.apache.kafka.clients.producer.KafkaProducer`). 페이지 타이틀 `"KafkaProducer (kafka 4.0.2 API)"` 로 버전 확인.
- 발행일: 명시 없음 (Kafka 4.0.x 라인 최신 Javadoc — URL 경로 `/40/` = 4.0 branch)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-kafka-producer-runtime-contract` branch 가 `acks=all` + idempotent producer 를 기본값으로 못박으려면, acks 의 완료 판정 기준·idempotence 의 정확한 보장 범위와 전제조건·순서 보장 경계·flush/close 의 blocking semantics 가 공식 API 문서 수준에서 확정되어야 한다. 이 문서는 KafkaProducer 클래스 Javadoc 원문에서 그 근거를 직접 인용한다.
## 핵심 인용
> [source: `KafkaProducer` class-level Javadoc, 클래스 상단 설명 문단] (line 번호는 `/tmp/source-fetch-20260728-165959.txt` 기준 — HTML→텍스트 변환 후 줄 번호)
1. [line 34] "The acks config controls the criteria under which requests are considered complete." [...] "The default setting "all" will result in blocking on the full commit of the record, the slowest but most durable setting."
2. [line 40] "The idempotent producer strengthens Kafka's delivery semantics from at least once to exactly once delivery." [...] "In particular producer retries will no longer introduce duplicates."
3. [line 41] "From Kafka 3.0, the enable.idempotence configuration defaults to true." [...] "When enabling idempotence, retries config will default to Integer.MAX_VALUE and the acks config will default to all."
4. [line 42] "the producer can only guarantee idempotence for messages sent within a single session."
5. [line 257, `send(ProducerRecord, Callback)` 메서드 설명] "Callbacks for records being sent to the same partition are guaranteed to execute in order."
6. [line 261, `send(ProducerRecord, Callback)` 메서드 설명] "it is possible to continue sending after receiving an OutOfOrderSequenceException, but doing so can result in out of order delivery of pending messages." [...] "To ensure proper ordering, you should close the producer and create a new instance."
7. [line 279, `flush()` 메서드 설명] "Invoking this method makes all buffered records immediately available to send (even if linger.ms is greater than 0) and blocks on the completion of the requests associated with these records." — 그리고 "A request is considered completed when it is successfully acknowledged according to the acks configuration you have specified or else it results in an error."
8. [line 346, `close()` 메서드 설명] "Close this producer. This method blocks until all previously sent requests complete. This method is equivalent to close(Long.MAX_VALUE, TimeUnit.MILLISECONDS)."
9. [line 347, `close()` 메서드 설명] "If close() is called from Callback, a warning message will be logged and close(0, TimeUnit.MILLISECONDS) will be called instead."
10. [line 38, 클래스 상단 설명 문단] "The buffer.memory controls the total amount of memory available to the producer for buffering." [...] "When the buffer space is exhausted additional send calls will block. The threshold for time to block is determined by max.block.ms after which it throws a TimeoutException."
> 총 10개 인용 (요청된 5개 우선순위 topic 을 모두 커버하기 위해 3~5개 권장 범위를 초과 — 각 topic 이 "보장 내용 + 전제조건/한계"의 2개 문장을 요구했기 때문). 전부 self-grep 통과.
## Claims Extracted
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| KAFKA-PROD-JD-C1 | `acks=all`(기본값)은 요청 완료 판정 기준을 "레코드의 full commit 대기"로 설정하며, 가장 느리지만 가장 durable 한 설정이다 | [line 34] "The default setting "all" will result in blocking on the full commit of the record, the slowest but most durable setting." | official-reference | KafkaProducer 의 `acks` 설정값별 완료 판정 기준 일반 | 이 문서는 "full commit" 이 정확히 몇 개의 in-sync replica(ISR)를 의미하는지, `min.insync.replicas``acks=all` 의 관계를 설명하지 않는다(ISR 단어 자체가 이 페이지에 없음). 또한 "항상 acks=all 을 써야 한다"는 권고문도 아니다 — 트레이드오프(느림) 서술만 있음. |
| KAFKA-PROD-JD-C2 | idempotent producer 는 Kafka 의 전달 시맨틱을 at-least-once 에서 exactly-once 로 강화하며, producer 재시도가 더 이상 중복을 유발하지 않는다 | [line 40] "The idempotent producer strengthens Kafka's delivery semantics from at least once to exactly once delivery. In particular producer retries will no longer introduce duplicates." | official-reference | `enable.idempotence=true` 일 때 producer 재전송으로 인한 duplicate 제거 보장 | DB write 등 producer 바깥의 side effect 까지 포함한 end-to-end exactly-once 를 주장하지 않는다. 이 문서 자체가 이 범위를 명시하지 않으므로 확대 해석 금지 — project 결정(`DEC-CA-SKELETON-OPERATIONAL-CONTRACT-DELIVERY-SEMANTICS-001@1`, at-least-once + 멱등 consumer)과 병기해야 함. |
| KAFKA-PROD-JD-C3 | Kafka 3.0+ 부터 `enable.idempotence` 기본값이 `true`이며, idempotence 활성 시 `retries``Integer.MAX_VALUE`, `acks``all` 로 자동 기본 설정된다 | [line 41] "From Kafka 3.0, the enable.idempotence configuration defaults to true. When enabling idempotence, retries config will default to Integer.MAX_VALUE and the acks config will default to all." | official-reference | idempotent producer 활성 시 `acks`/`retries` 자동 강제값의 근거 | `max.in.flight.requests.per.connection` 상한과 idempotence 의 관계는 **이 URL(KafkaProducer 클래스 Javadoc)에 언급이 없다** — self-grep 결과 0건(negative control 확인됨). 순서 보장을 위한 in-flight 상한 수치는 별도 `ProducerConfig` 공식 문서로 보강 필요(`UNSUPPORTED_DECISION` 후보 — 아래 Usage Boundaries 참조). |
| KAFKA-PROD-JD-C4 | producer 는 단일 세션(session) 내에서 전송된 메시지에 대해서만 idempotence 를 보장한다 | [line 42] "the producer can only guarantee idempotence for messages sent within a single session." | official-reference | producer 재시작/재생성 시 idempotence 보장이 리셋됨을 아는 근거 | "세션"의 정확한 경계(예: producer epoch 변경 시점, transactional.id fencing 과의 관계)를 상세히 정의하지 않는다. |
| KAFKA-PROD-JD-C5 | 같은 파티션으로 전송되는 레코드의 콜백은 전송 순서대로 실행됨이 보장된다 | [line 257] "Callbacks for records being sent to the same partition are guaranteed to execute in order." | official-reference | 파티션 단위(partition-level) 순서 보장의 1차 근거 | 파티션을 넘나드는 전역 순서를 보장한다고 말하지 않는다. 또한 이 순서 보장이 `max.in.flight.requests.per.connection` 값에 어떻게 의존하는지는 이 페이지에 없다(negative control 확인됨). |
| KAFKA-PROD-JD-C6 | idempotence 활성 상태에서 `OutOfOrderSequenceException` 수신 후 재전송을 계속하면 pending 메시지의 out-of-order 전달이 발생할 수 있으며, 순서를 보장하려면 producer 를 close 하고 새 인스턴스를 만들어야 한다 | [line 261] "it is possible to continue sending after receiving an OutOfOrderSequenceException, but doing so can result in out of order delivery of pending messages. To ensure proper ordering, you should close the producer and create a new instance." | official-reference | idempotent producer 에서 순서가 깨질 수 있는 구체적 실패 경로와 복구 절차(재발급) | 이 실패가 `max.in.flight.requests.per.connection > 5` 조합에서만 발생하는지, 혹은 다른 조건에서도 발생하는지는 이 페이지가 설명하지 않는다. |
| KAFKA-PROD-JD-C7 | `flush()`는 buffer 에 있는 모든 레코드를 즉시 전송 가능 상태로 만들고, 그 요청들이 acks 설정에 따라 성공 ack 되거나 에러가 날 때까지 블로킹한다 | [line 279] "Invoking this method makes all buffered records immediately available to send (even if linger.ms is greater than 0) and blocks on the completion of the requests associated with these records." / "A request is considered completed when it is successfully acknowledged according to the acks configuration you have specified or else it results in an error." | official-reference | graceful flush 의 완료 판정 기준(acks 설정에 연동됨) | `flush()`에는 timeout 파라미터가 없다(무기한 대기)는 것 외의 취소/중단 메커니즘은 설명하지 않는다. send 콜백 안에서 호출하면 데드락이라는 점은 별도 문장(같은 메서드 doc 후반)에 있으나 본 claim 인용에는 포함하지 않음. |
| KAFKA-PROD-JD-C8 | `close()`는 기본적으로 이전에 전송된 모든 요청이 완료될 때까지 블로킹하며 `close(Long.MAX_VALUE, TimeUnit.MILLISECONDS)`와 동일하다. 콜백 내부에서 `close()`가 호출되면 sender thread 의 self-join 데드락을 피하기 위해 `close(0, TimeUnit.MILLISECONDS)`로 대체 실행된다 | [line 346] "Close this producer. This method blocks until all previously sent requests complete. This method is equivalent to close(Long.MAX_VALUE, TimeUnit.MILLISECONDS)." / [line 347] "If close() is called from Callback, a warning message will be logged and close(0, TimeUnit.MILLISECONDS) will be called instead." | official-reference | graceful shutdown 시 `close()` 기본 blocking 동작과 콜백 내 호출의 예외적 non-blocking 동작 | `close(Duration timeout)` 오버로드에서 "timeout 초과 시 unsent/unacknowledged record 를 즉시 fail 처리한다"는 별도 동작은 이 두 quote 에 포함되지 않는다(문서 내 다른 위치, 별도 claim 필요 시 추가 인용). |
| KAFKA-PROD-JD-C9 | `buffer.memory`가 소진되면 추가 `send()` 호출이 블로킹되며, 블로킹 대기 시간의 상한은 `max.block.ms`이고 이를 초과하면 `TimeoutException`이 발생한다 | [line 38] "The buffer.memory controls the total amount of memory available to the producer for buffering." / "When the buffer space is exhausted additional send calls will block. The threshold for time to block is determined by max.block.ms after which it throws a TimeoutException." | official-reference | producer buffer backpressure 메커니즘(client-side back-pressure)의 근거 | `max.block.ms` 초과 시 애플리케이션이 취해야 할 재시도/서킷브레이커 전략까지 규정하지 않는다 — 그건 애플리케이션 책임이라는 것도 명시하지 않는다(단순히 예외 발생만 서술). |
### Strength 근거
모든 claim 은 `official-reference` — Apache Kafka 프로젝트가 배포하는 공식 API Javadoc(`kafka.apache.org/40/javadoc/...`)이며 RFC/표준 수준(`official-standard`)은 아니지만 벤더 자체의 공식 reference 문서다.
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `acks=all`의 완료 판정 기준(full commit 대기, 가장 durable, 가장 느림) — `KAFKA-PROD-JD-C1`
- idempotent producer 의 exactly-once(중복 제거) 보장과 그 세션 스코프 한계 — `KAFKA-PROD-JD-C2`, `C4`
- idempotence 활성 시 `acks=all`/`retries=MAX_VALUE` 자동 기본값 — `KAFKA-PROD-JD-C3`
- 파티션 단위 콜백 순서 보장과 idempotence 하에서 순서가 깨질 수 있는 구체적 실패 경로(`OutOfOrderSequenceException`) — `C5`, `C6`
- `flush()`/`close()`의 기본 blocking 의미론 — `C7`, `C8`
- `buffer.memory`/`max.block.ms` backpressure 메커니즘 — `C9`
- 이 자료가 증명하지 **않는** 것 (중요 — branch 결정에 그대로 쓰면 안 됨):
- **`max.in.flight.requests.per.connection` 관련 어떤 문장도 이 URL 에 없다.** self-grep negative control로 확인(`grep -nF 'max.in.flight.requests.per.connection'` → 0 매치). 따라서 "idempotence 활성 시 in-flight 상한이 5로 제한되어 순서가 보장된다"는 흔히 알려진 사실은 **이 raw 문서만으로는 뒷받침되지 않는다.** branch 의 §구현 가이드에서 in-flight 상한 수치를 명시하려면 `ProducerConfig` 공식 문서(별도 URL, 예: `kafka.apache.org/.../ProducerConfig.html` 또는 Kafka 운영 가이드의 `max.in.flight.requests.per.connection` 설명)를 별도로 수집해 `UNSUPPORTED_DECISION` 라벨을 해소해야 한다.
- ISR(in-sync replica) 개수·`min.insync.replicas``acks=all`의 정확한 상호작용(이 페이지에 "in-sync"/"ISR" 단어 자체가 없음 — grep 확인, `min.insync.replicas`는 트랜잭션 섹션에서만 "should be set to 2"로 1회 등장하며 acks=all 의 일반 정의와 직접 연결되어 서술되지 않음)
- "항상 `acks=all`을 써야 한다"는 규범적 권고 — 이 문서는 트레이드오프만 서술하고 prescriptive 하지 않음
- DB write 와 Kafka publish 를 아우르는 end-to-end exactly-once
- 압축(compression)·배치 상한·TLS/SASL·스키마 호환성 등 branch 범위의 다른 항목 (이 URL 은 다루지 않음 — 별도 자료 필요)
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- ca-skeleton Kafka producer adapter 의 실제 `application.yml`/`ProducerConfig` 설정값이 이 문서의 기본값과 일치하는지 (`acks=all`, `enable.idempotence=true`) — 코드 레벨 검증 필요
- `max.in.flight.requests.per.connection` 상한값 — 위 한계 참조, 별도 공식 문서 수집 필요
## 메모
> 검증되지 않은 추론은 여기에만 둔다. wiki/concepts 로 옮길 때 재검토.
- HTML→텍스트 변환은 stdlib `html.parser` 로 직접 작성한 스크립트를 사용(환경에 `bs4`/`html2text`/`pandoc`/`lynx` 부재 확인). 블록 태그 경계에서만 줄바꿈을 넣고, 텍스트 노드 내부 개행은 공백으로 접어서 문장이 잘리지 않게 함 — self-grep 안정성을 위한 선택.
- 다음 작업 후보: `ProducerConfig` Javadoc 또는 Kafka 공식 "Configuration" 레퍼런스 페이지에서 `max.in.flight.requests.per.connection` 원문을 별도 raw 문서로 수집해 이 문서의 `KAFKA-PROD-JD-C3`/`C5`/`C6` 한계를 해소.
- WebFetch 도구의 1차 결과(요약 모델 처리본)는 이 문서 작성에 **사용하지 않음** — self-grep 검증이 불가능한 paraphrase 였기 때문에 폐기하고 `curl` raw HTML → 자체 텍스트 추출로 재작업함.
## 관련
- 같은 vault 내 관련 official-doc: 현재 없음(Kafka producer 관련 최초 official-doc). `raw/official-docs/spring-kafka-sending-messages-kafkatemplate.md` 는 Spring Kafka(벤더 다름, `KafkaTemplate` wrapper) — 겹치는 영역은 있으나 별개 자료.
- 추가 수집 후보: Kafka `ProducerConfig` 공식 문서(`max.in.flight.requests.per.connection` 상세), Kafka 공식 Design 문서(`acks`/ISR 상호작용 상세)
- 이 자료를 인용한 wiki 요약: 아직 없음 (생성 시 링크)
@@ -0,0 +1,98 @@
---
title: official-doc / Liquibase MongoDB Pro — Platform Support & Drift Report Scope
source_type: official-doc
url: https://docs.liquibase.com/pro/integration-guide/what-support-does-liquibase-mongodb-pro-have-with-mongodb-platforms
archive_url:
related_branches: [feature-mongo-runtime-baseline-contract]
related_projects: []
tags: [official-doc, ca-skeleton, persistence, mongodb, liquibase]
created: 2026-07-28
---
# official-doc / Liquibase MongoDB Pro — Platform Support & Drift Report Scope
> Layer: `raw/` — 외부 자료(공식 문서 / 대기업 기술 블로그)의 **원문 발췌·출처 기록**.
> 본 템플릿은 `raw/official-docs/``raw/company-tech-blogs/` 두 폴더가 공유.
> 검증된 요약은 `/ingest``wiki/concepts/``source-summary-template` 형식으로 별도 작성. 원본은 raw에 영구 보관.
## source_type 허용값
`source_type: official-doc` — Liquibase 공식 통합 가이드(Pro 문서 트리, `docs.liquibase.com/pro/...`).
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-mongo-runtime-baseline-contract]] | "MongoDB index drift 감지를 Liquibase 로 얻지 않고 자체 러너로 구현한다" 결정의 근거 — Liquibase 의 MongoDB drift report 접근이 **Liquibase MongoDB Pro extension**(유료) 기능 목록에 명시되어 있다는 사실 확보. 단, 이 페이지만으로 "무료(OSS) extension 에는 drift 가 전혀 없다"는 부정 명제까지 증명되지는 않음 — 아래 Usage Boundaries 참조 |
## 출처
- 원본 URL: https://docs.liquibase.com/pro/integration-guide/what-support-does-liquibase-mongodb-pro-have-with-mongodb-platforms
- 아카이브 URL: (미제공)
- 저자 / 조직: Liquibase (Datical/Liquibase Inc. 공식 문서)
- 발행일: (문서 자체에 명시 없음 — 버전별 롤링 업데이트되는 통합 가이드 페이지)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-mongo-runtime-baseline-contract` branch가 "MongoDB index drift 감지는 Liquibase 대신 자체 러너로 구현한다"를 결정하려 할 때, 그 근거인 "Liquibase 의 MongoDB drift report 는 Pro(유료) extension 기능"이라는 사실을 원문 그대로 확보하기 위해 보관.
## 핵심 인용
> [§Core Capabilities / Features] "Policy Checks, Secrets Management, Structured Logging, and MongoDB Pro extension-only commands and access to Drift reports."
> [§Reporting & Policy Checks / Reports] "The Drift report is the only report supported for use with the MongoDB Pro extension."
> [§Core Capabilities] "Liquibase MongoDB Pro enables use of Liquibase Pro features with MongoDB databases."
> [§Supported Platforms] "- MongoDB 5.x, 6.x, 7.x, 8.x
- MongoDB Atlas (versions 5, 6, 7, 8x)
- AWS DocumentDB (versions 4, 5)"
## Claims Extracted
> 이 자료가 **직접 말하는 것만** claim 으로 분리한다. 내 프로젝트에 적용한 결론은 여기 쓰지 않는다.
| Claim ID | Claim | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| LIQUIBASE-MONGO-PRO-C1 | Liquibase MongoDB **Pro** extension 의 기능 목록에 "access to Drift reports" 가 명시적으로 포함되어 있다 — 즉 drift report 접근은 Pro extension 기능 세트의 일부다 | [§Features] "Policy Checks, Secrets Management, Structured Logging, and MongoDB Pro extension-only commands and access to Drift reports." | `official-vendor-doc` | Liquibase Pro 구독 + MongoDB Pro extension 사용자가 drift report 에 접근 가능하다는 사실 | **무료(OSS) Liquibase MongoDB extension 에 drift report 기능이 전혀 없다는 것은 이 문장이 직접 말하지 않는다** — 이 문장은 "Pro 기능 목록에 drift 가 포함된다"는 긍정 명제만 서술하며, "free tier 에는 없다"는 부정 명제는 다루지 않음 |
| LIQUIBASE-MONGO-PRO-C2 | MongoDB Pro extension 을 사용할 때 지원되는 report 종류는 Drift report 하나뿐이다(다른 report 타입은 MongoDB Pro extension 범위에서 지원되지 않음) | [§Reports] "The Drift report is the only report supported for use with the MongoDB Pro extension." | `official-vendor-doc` | MongoDB Pro extension 범위 내 report 기능의 전체 목록(=drift 단독) | 이 문장은 "MongoDB Pro extension 안에서 report 타입이 drift 하나뿐"이라는 것만 말한다. **free/OSS extension 이 drift 자체를 지원하는지 여부는 이 문장의 주어(subject)가 아니므로 증명되지 않음** |
| LIQUIBASE-MONGO-PRO-C3 | "Liquibase MongoDB Pro" 는 Liquibase **Pro** 기능을 MongoDB 데이터베이스에서 쓸 수 있게 해주는 별도 유료 제품/extension 이다 (Pro 기능이 별도로 존재한다는 gating 구조를 전제) | [§Core Capabilities] "Liquibase MongoDB Pro enables use of Liquibase Pro features with MongoDB databases." | `official-vendor-doc` | "Liquibase MongoDB Pro" 라는 제품명 자체가 Pro 전용 기능 게이팅 구조를 전제한다는 프레이밍 근거 | 무료 extension 과 Pro extension 의 **기능 항목별 비교표**는 이 페이지에 없음 — 이 문장만으로 "drift 가 free tier 에서 빠진다"를 결론 내리는 것은 추론이며 원문 직접 서술이 아님 |
| LIQUIBASE-MONGO-PRO-C4 | Liquibase MongoDB Pro extension 이 지원하는 플랫폼/버전 범위는 MongoDB 5.x/6.x/7.x/8.x, MongoDB Atlas(5/6/7/8x), AWS DocumentDB(4/5) 이며, 최소 요구 버전은 Liquibase Pro 4.20.0+ 와 MongoDB Pro extension 1.0.0+ 이다 | [§Supported Platforms] "MongoDB 5.x, 6.x, 7.x, 8.x" / "MongoDB Atlas (versions 5, 6, 7, 8x)" / "AWS DocumentDB (versions 4, 5)"; [§Key Limitations] "Minimum version: Liquibase Pro 4.20.0+ with MongoDB Pro extension 1.0.0+" | `official-vendor-doc` | ca-skeleton 이 채택하려는 MongoDB 배포 버전이 이 지원 범위(5.x~8.x, Atlas, DocumentDB)에 해당하는지 확인하는 근거 | MongoDB 4.x 이하 self-hosted 버전, 또는 Atlas/DocumentDB 가 아닌 제3자 매니지드 MongoDB 호환 서비스에 대한 지원 여부는 이 페이지에 없음 |
### Strength 허용값
- `official-standard` — RFC, 표준 사양, 언어/프로토콜 표준
- `official-vendor-doc` — Spring, Keycloak, AWS, Google, Liquibase 등 공식 벤더 문서
- `official-reference` — 공식 reference/API 문서
- `company-case-study` — 대기업/실무 기술 블로그의 특정 사례
- `engineering-blog` — 개인/팀 블로그의 엔지니어링 해설
- `tutorial` — 튜토리얼/가이드. 일반화 금지
- `needs-confirmation` — 원문만으로는 적용 판단 불가
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `LIQUIBASE-MONGO-PRO-C1`: Liquibase MongoDB **Pro** extension 의 공식 기능 목록에 "drift report 접근"이 포함되어 있다
- `LIQUIBASE-MONGO-PRO-C2`: MongoDB Pro extension 범위에서 지원되는 report 타입은 drift 하나뿐이다
- `LIQUIBASE-MONGO-PRO-C3`: "Liquibase MongoDB Pro" 자체가 Pro 전용 기능을 여는 유료 제품/extension 이라는 프레이밍
- `LIQUIBASE-MONGO-PRO-C4`: 지원 플랫폼/버전 범위(MongoDB 5.x~8.x, Atlas, DocumentDB)와 최소 요구 버전(Liquibase Pro 4.20.0+, extension 1.0.0+)
- **이 자료가 증명하지 않는 것 (중요 — branch 결정에 직접 영향)**:
- **무료(OSS) Liquibase MongoDB extension 이 drift 감지를 전혀 지원하지 않는다는 명시적 부정 서술이 이 페이지에 없다.** 이 페이지는 "Liquibase MongoDB Pro" 전용 지원 매트릭스 문서이며, 그 대비군인 free/OSS MongoDB extension 의 기능 목록·제약을 별도로 서술하지 않는다. 따라서 "무료 티어로는 drift 요구사항을 충족하지 못한다"는 branch 결론은 이 페이지의 **직접 인용만으로는 완전히 닫히지 않는다** — free extension 쪽 공식 문서(또는 Liquibase pricing/edition 비교 페이지)의 별도 확인이 필요하다. 이 gap 은 branch-note 의 Decision Evidence Map 에 `needs-confirmation` 또는 별도 dispatch 필요 항목으로 명시해야 한다
- 무료(OSS)와 Pro extension 의 기능 항목별(feature-by-feature) 비교표
- drift report 의 구체적 산출물 형태(스키마 diff 포맷, CLI 명령 이름 등) — 이 페이지는 "지원 여부"만 서술하고 사용법은 다루지 않음
- Liquibase Pro 라이선스 가격/구매 조건
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- Liquibase 무료(OSS) MongoDB extension 공식 문서(또는 edition 비교 페이지)에서 drift report 가 명시적으로 빠져 있는지 별도 확인 (1 dispatch = 1 URL 원칙에 따라 본 자료에는 포함하지 않음)
- ca-skeleton 이 실제 채택할 MongoDB 배포 버전이 §Supported Platforms 범위(5.x~8.x/Atlas/DocumentDB) 안에 있는지 — Pro 를 도입할 경우에만 의미 있는 확인 항목
## 메모
- 사용자가 요청한 "(1)(2) 가 이 dispatch 의 핵심" 문장은 확보했다 — `C1`("access to Drift reports" 가 Pro 기능 목록에 포함) 과 `C2`("Drift report 가 MongoDB Pro extension 에서 지원되는 유일한 report") 둘 다 self-grep 통과.
- 다만 "무료 extension 에는 drift 가 없다"는 **부정 명제**는 이 페이지가 직접 말하지 않는다 — 추론을 사실로 승격시키지 않기 위해 Claims Extracted 의 `Does not prove` 칸과 Usage Boundaries 에 명시적으로 gap 을 남겼다. branch-note 작성 시 이 gap 을 근거 문장에서 "그러므로 무료 티어는 지원하지 않는다"처럼 단정하지 말 것.
- 추가로 봐야 할 동일 벤더 페이지: Liquibase MongoDB(무료/OSS) extension 공식 문서 또는 Liquibase edition/feature 비교 페이지 (별도 dispatch 필요).
## 관련
- 같은 프로젝트 내 다른 migration 관련 official-doc: `[[raw/official-docs/migration-liquibase-official-changelog-xml-yaml]]` (Liquibase changelog 형식 — RDBMS 중심, Pro/MongoDB 범위 아님)
- 이 자료를 인용한 wiki 요약: `[[wiki/concepts/...]]` (생성 시)
@@ -0,0 +1,100 @@
---
title: MDN — BroadcastChannel / Window storage event
source_type: official-doc
url: https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel
archive_url:
related_branches: [feature-frontend-cache-tier-cross-tab-invalidation-contract]
related_projects: [ca-skeleton-frontend]
tags: [frontend, cross-tab, broadcastchannel, web-storage, browser]
created: 2026-07-28
---
# MDN — BroadcastChannel / Window storage event
> Layer: `raw/` — 외부 자료의 **원문 발췌·출처 기록**.
## 활용 branch (필수, 최소 1개+)
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| `[[raw/branch-notes/feature-frontend-cache-tier-cross-tab-invalidation-contract]]` | 발신 컨텍스트 자기 수신 불가 처리(`D4`), **대체 경로를 두지 않는다는 결정**(`D5``#C4` 의 지원 범위와 `#C10``sessionStorage` 미도달), 메시지 봉투의 직렬화 제약(`D2`) |
## 출처
- 원본 URL: https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel
- 보조 URL: https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel/postMessage , https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event
- 아카이브 URL:
- 저자 / 조직: MDN Web Docs (Mozilla)
- 발행일: (문서 지속 갱신)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`FE-D028` 이 "BroadcastChannel 우선 + `storage` event fallback" 을 정했는데, 그 두 transport 의 **실제 전달 범위**를 확인하지 않은 채 adapter 를 설계하면 두 가지를 놓친다. (a) 두 transport 모두 **발신자에게는 이벤트가 가지 않는다** — 로컬 무효화를 채널 왕복에 의존하면 발신 탭만 stale 로 남는다. (b) `storage` event 는 `sessionStorage` 에서도 발생하지만 그 경우 **다른 탭에는 도달하지 않는다** — fallback backend 를 명시하지 않으면 조용히 동작하지 않는 fallback 이 된다. 추가로 BroadcastChannel 의 Baseline 상태가 fallback 유지 근거를 좌우한다.
## 핵심 인용
> [§BroadcastChannel] "The `BroadcastChannel` interface represents a named channel that any browsing context of a given origin can subscribe to."
> [§BroadcastChannel] "It allows communication between different documents (in different windows, tabs, frames or iframes) of the same origin."
> [§BroadcastChannel] "Messages are broadcasted via a `message` event fired at all `BroadcastChannel` objects listening to the channel, except the object that sent the message."
> [§BroadcastChannel — Note] "This feature is available in Web Workers."
> [§BroadcastChannel — Baseline] "**Baseline Widely available** — This feature is well established and works across many devices and browser versions. It's been available across browsers since March 2022."
> [§BroadcastChannel.close] "Closes the channel object, indicating it won't get any new messages, and allowing it to be, eventually, garbage collected."
> [§BroadcastChannel.postMessage — Parameters] "The data is serialized using the structured clone algorithm."
> [§BroadcastChannel.postMessage — Exceptions] "`DataCloneError` `DOMException` — Thrown if any part of the input data is not serializable."
> [§BroadcastChannel.postMessage — Exceptions] "`InvalidStateError` `DOMException` — Thrown if the `BroadcastChannel` has already been closed."
> [§Window: storage event] "The `storage` event of the `Window` interface fires when another document that shares the same storage area (either `localStorage` or `sessionStorage`) as the current window updates that storage area. The event is _not_ fired on the window that made the change."
> [§Window: storage event] "For `localStorage`, the event is fired in all other browsing contexts that are in the same origin as the initiating document. This includes other tabs with the same origin."
> [§Window: storage event] "For `sessionStorage`, the event is fired in all other browsing contexts that are in the same origin and the same top-level browsing context as the initiating document. This only includes embedded iframes, if any, in the same tab, and not other tabs."
> [§Window: storage event — Event properties] "The `key` attribute is `null` when the change is caused by the storage `clear()` method."
## 추출된 주장
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| C1 | BroadcastChannel 은 **같은 origin** 의 서로 다른 문서(창·탭·frame·iframe) 사이 통신 수단이다 | [§BroadcastChannel] "communication between different documents (in different windows, tabs, frames or iframes) of the same origin" | `official-reference` | 같은 origin 의 browsing context | cross-origin 또는 다른 프로필/브라우저 간 전달 |
| C2 | 메시지는 채널을 듣는 모든 BroadcastChannel **객체**에 전달되되 **보낸 객체만 제외**된다 | [§BroadcastChannel] "fired at all `BroadcastChannel` objects listening to the channel, except the object that sent the message" | `official-reference` | 모든 수신 객체 | 발신 *문서* 전체가 제외된다는 뜻은 아님 — 제외 단위는 **객체**이므로 같은 문서의 다른 채널 객체는 수신한다 |
| C3 | BroadcastChannel 은 Web Worker 에서도 사용할 수 있다 | [§Note] "This feature is available in Web Workers." | `official-reference` | worker 컨텍스트 | service worker 에서의 생명주기 보장 |
| C4 | BroadcastChannel 은 Baseline Widely available 이며 2022년 3월부터 브라우저 전반에서 제공된다 | [§Baseline] "It's been available across browsers since March 2022." | `official-reference` | Baseline 기준 브라우저 집합 | 이 프로젝트의 **대상 브라우저 매트릭스**가 그 집합 안이라는 뜻은 아님(`FE-Q-007` 미확정). private 모드·파티션 환경의 동작도 증명하지 않음 |
| C5 | `postMessage` 는 structured clone 으로 직렬화하며 직렬화 불가 데이터는 `DataCloneError` 를 던진다 | [§postMessage] "serialized using the structured clone algorithm" / "Thrown if any part of the input data is not serializable" | `official-reference` | 모든 BroadcastChannel 메시지 | 직렬화 가능하면 크기 제한이 없다는 뜻은 아님 |
| C6 | 닫힌 채널에 `postMessage` 하면 `InvalidStateError` 가 발생한다 | [§postMessage — Exceptions] "Thrown if the `BroadcastChannel` has already been closed." | `official-reference` | `close()` 이후의 발행 | unmount 시 close 를 강제해야 한다는 정책까지 증명하지는 않음 |
| C7 | `close()` 는 더 이상 메시지를 받지 않음을 표시하고 GC 를 허용한다 | [§close] "indicating it won't get any new messages, and allowing it to be, eventually, garbage collected" | `official-reference` | 채널 객체 수명 | close 를 빠뜨렸을 때의 누수 규모 |
| C8 | `storage` event 는 **변경을 만든 window 에서는 발생하지 않는다** | [§storage event] "The event is _not_ fired on the window that made the change." | `official-reference` | `localStorage`·`sessionStorage` 양쪽 | 다른 문서에서는 반드시 발생한다는 타이밍 보장 |
| C9 | `localStorage` 변경의 `storage` event 는 같은 origin 의 **다른 모든 탭**에 도달한다 | [§storage event] "For `localStorage`, the event is fired in all other browsing contexts that are in the same origin as the initiating document. This includes other tabs with the same origin." | `official-reference` | `localStorage` | 값이 동일할 때도 발생하는지 |
| C10 | `sessionStorage` 변경의 `storage` event 는 **같은 탭 안**(same top-level browsing context)에만 도달하고 다른 탭에는 도달하지 않는다 | [§storage event] "This only includes embedded iframes, if any, in the same tab, and not other tabs." | `official-reference` | `sessionStorage` | `sessionStorage` 를 캐시 tier 로 쓰는 것 자체가 잘못이라는 뜻은 아님 — 잘못은 이를 **탭 간 신호**로 쓰는 것 |
| C11 | `clear()` 로 인한 변경이면 `StorageEvent.key``null` 이다 | [§Event properties] "The `key` attribute is `null` when the change is caused by the storage `clear()` method." | `official-reference` | `storage` event 수신부 | `null` key 를 어떻게 처리해야 하는지의 정책 |
## 적용 경계
- 이 자료가 직접 증명하는 것:
- `C1`~`C7`: BroadcastChannel 의 전달 범위·자기 제외·직렬화·수명
- `C8`~`C11`: `storage` event 의 발신자 제외, backend 별 도달 범위, `clear()` 시 key 형태
- 이 자료가 증명하지 않는 것:
- `storage` event 가 **같은 값으로 덮어썼을 때도** 발생하는지 — MDN 이 이 페이지에서 말하지 않는다. 신호 key 를 매번 다른 값으로 써야 하는지는 실측 필요
- 두 transport 의 **전달 지연**이나 순서 보장 — 어느 쪽도 명시하지 않는다
- BroadcastChannel 이 사용 불가한 실제 환경의 목록 — `C4` 의 Baseline 은 그 반대 방향의 증거다
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- 대상 브라우저 매트릭스(`FE-Q-007`)에 BroadcastChannel 미지원 행이 실제로 있는지 — 없다면 `FE-D028``storage` event fallback 은 근거 없는 복잡도다
## 메모
- 인용 1 해석 후보 (미검증): `C2` 의 제외 단위가 *객체*라는 점 때문에, adapter 가 컨텍스트당 채널 객체를 1개만 유지하면 "발신 탭은 수신하지 않는다" 가 성립한다. 반대로 무심코 객체를 2개 만들면 자기 메시지를 자기가 받는 경로가 생긴다.
- 인용 2 해석 후보 (미검증): `C8`+`C2` 를 합치면 **두 transport 모두 발신자에게 echo 하지 않는다**. 따라서 로컬 무효화는 채널을 거치지 않고 직접 수행해야 한다는 설계 제약이 된다.
- 추가로 봐야 할 동일 출처 페이지: `Window.localStorage` 의 저장 한도, `StorageEvent` 인터페이스 자체
## 관련
- 같은 주제 다른 official-doc: `[[raw/official-docs/mdn-storage-quotas-eviction-persistence]]`, `[[raw/official-docs/tanstack-query-persistence-hydration-official]]`
- 이 자료를 인용한 wiki 요약: 생성 전
@@ -0,0 +1,103 @@
---
title: MDN — HTTP Range requests / Fetch 취소·스트림 소비
source_type: official-doc
url: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Range_requests
archive_url:
related_branches: [feature-frontend-large-object-transfer-contract]
related_projects: [ca-skeleton-frontend]
tags: [frontend, http, range, fetch, streaming, transfer]
created: 2026-07-28
---
# MDN — HTTP Range requests / Fetch 취소·스트림 소비
> Layer: `raw/` — 외부 자료의 **원문 발췌·출처 기록**.
## 활용 branch (필수, 최소 1개+)
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| `[[raw/branch-notes/feature-frontend-large-object-transfer-contract]]` | `StreamingDownloadPort` 의 재개 프로토콜(`D4`), part 재시도가 stream 재사용이 아니라 재slice 여야 하는 이유(`D6`), 취소의 error 매핑(`D7`) |
## 출처
- 원본 URL: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Range_requests
- 보조 URL: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
- 아카이브 URL:
- 저자 / 조직: MDN Web Docs (Mozilla)
- 발행일: (문서 지속 갱신)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
branch 의 `resumeStrategy: range` 와 "part 재시도 상한 2회" 가 **어떤 프로토콜 사실 위에 서 있는지** 확인하지 않은 상태였다. 특히 세 가지가 필요했다. (a) 재개가 서버 지원에 의존한다면 미지원을 어떻게 감지하는가, (b) 재개 중 원본이 바뀌면 무엇이 깨지는가, (c) 실패한 part 를 재시도할 때 이미 소비한 body 를 다시 쓸 수 있는가. (c) 는 재시도 구현이 조용히 빈 body 를 보내는 고전적 버그의 원인이다.
## 핵심 인용
> [§Checking if a server supports partial requests] "If an HTTP response includes the `Accept-Ranges` header with any value other than `none`, the server supports range requests."
> [§Checking if a server supports partial requests] "If responses omit the `Accept-Ranges` header, it indicates the server doesn't support partial requests."
> [§Requesting a specific range from a server] "If the server supports range requests, you can specify which part (or parts) of the document you want the server to return by including the `Range` header in a HTTP request."
> [§Single part ranges] "The `Content-Range` response header indicates where this partial message belongs within the full resource."
> [§Partial request responses] "A range request that is out of bounds will result in a `416` `Requested Range Not Satisfiable` status, meaning that none of the range values overlap the extent of the resource."
> [§Partial request responses] "If range requests are not supported, an `200` `OK` status is sent back and the entire response body is transmitted."
> [§Conditional range requests] "When resuming to request more parts of a resource, you need to guarantee that the stored resource has not been modified since the last fragment has been received."
> [§Conditional range requests] "The `If-Range` HTTP request header makes a range request conditional: if the condition is fulfilled, the range request will be issued and the server sends back a `206` `Partial Content` answer with the appropriate body. If the condition is not fulfilled, the full resource is sent back, with a `200` `OK` status."
> [§Using Fetch — Canceling a request] "To make a request cancelable, create an `AbortController`, and assign its `AbortSignal` to the request's `signal` property."
> [§Using Fetch — Canceling a request] "To cancel the request, call the controller's `abort()` method. The `fetch()` call will reject the promise with an `AbortError` exception."
> [§Using Fetch — Canceling a request] "If the request is aborted after the `fetch()` call has been fulfilled but before the response body has been read, then attempting to read the response body will reject with an `AbortError` exception."
> [§Using Fetch — Streaming the response body] "Request and response bodies are actually `ReadableStream` objects, and whenever you read them, you're streaming the content."
> [§Using Fetch — Locked and disturbed streams] "This means it's not possible to read the same response (or request) body more than once"
> [§Using Fetch — Locked and disturbed streams] "if any content has been read from the stream, then the stream is _disturbed_, and nothing else can read from the stream."
## 추출된 주장
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| C1 | 서버의 range 지원 여부는 `Accept-Ranges` 헤더로 판별하며 `none` 또는 헤더 부재는 미지원을 뜻한다 | [§Checking] "with any value other than `none`, the server supports range requests" / "If responses omit the `Accept-Ranges` header, it indicates the server doesn't support partial requests." | `official-reference` | 모든 HTTP 응답 | 특정 스토리지 vendor 가 이 헤더를 보내는지 |
| C2 | 부분 응답의 위치는 `Content-Range` 가 알려주며 상태 코드는 `206` 이다 | [§Single part ranges] "The `Content-Range` response header indicates where this partial message belongs within the full resource." | `official-reference` | `206` 응답 | 클라이언트가 요청한 범위와 서버가 준 범위가 항상 같다는 것 |
| C3 | 범위가 리소스 밖이면 `416 Requested Range Not Satisfiable` 이 온다 | [§Partial request responses] "will result in a `416` `Requested Range Not Satisfiable` status" | `official-reference` | 잘못된 재개 위치 | 416 이 재시도로 회복 가능한지 |
| C4 | range 를 지원하지 않으면 `200 OK` 와 **전체 본문**이 온다 — 조용히 성공한 것처럼 보인다 | [§Partial request responses] "an `200` `OK` status is sent back and the entire response body is transmitted." | `official-reference` | 재개 시도 | 200 응답을 받은 클라이언트가 자동으로 이를 알아챈다는 것 — 상태 코드를 검사해야만 안다 |
| C5 | 재개 시에는 마지막 조각 수신 이후 원본이 변경되지 않았음을 **보장해야 한다** | [§Conditional range requests] "you need to guarantee that the stored resource has not been modified since the last fragment has been received." | `official-reference` | 모든 이어받기 | 어떤 validator(ETag vs Last-Modified)를 써야 하는지 |
| C6 | `If-Range` 는 조건 충족 시 `206`, 불충족 시 `200` 과 전체 리소스를 돌려준다 | [§Conditional range requests] "If the condition is not fulfilled, the full resource is sent back, with a `200` `OK` status." | `official-reference` | 조건부 재개 | 불충족을 오류로 취급해야 하는지 — 프로토콜상 정상 응답이다 |
| C7 | `AbortController``signal` 로 요청을 취소하며 `fetch()``AbortError` 로 reject 된다 | [§Canceling a request] "The `fetch()` call will reject the promise with an `AbortError` exception." | `official-reference` | 모든 fetch 요청 | 이미 전송된 바이트가 서버에서 취소된다는 것 |
| C8 | 응답 수신 후 body 읽기 전에 취소하면 **body 읽기가** `AbortError` 로 reject 된다 | [§Canceling a request] "attempting to read the response body will reject with an `AbortError` exception." | `official-reference` | 취소 타이밍이 늦은 경우 | 두 경로의 error 를 구분해야 하는지 |
| C9 | request·response body 는 `ReadableStream` 이며 읽는 순간 스트리밍된다 | [§Streaming the response body] "Request and response bodies are actually `ReadableStream` objects" | `official-reference` | fetch body | 요청 body 로 스트림을 넘길 때의 추가 요구사항(`duplex` 등) — 이 페이지는 다루지 않음 |
| C10 | 같은 body 를 **두 번 읽을 수 없다**. 한 번 읽힌 스트림은 disturbed 상태가 되어 누구도 다시 읽지 못한다 | [§Locked and disturbed streams] "it's not possible to read the same response (or request) body more than once" / "the stream is _disturbed_, and nothing else can read from the stream." | `official-reference` | 모든 fetch body | 재시도가 불가능하다는 뜻은 아님 — **새 body 를 만들면** 가능하다 |
## 적용 경계
- 이 자료가 직접 증명하는 것:
- `C1`~`C6`: 이어받기의 지원 판별·위치 표현·실패 코드·조건부 재개
- `C7`·`C8`: 취소의 오류 표면
- `C9`·`C10`: body 스트림의 1회 소비 제약
- 이 자료가 증명하지 않는 것:
- **업로드** 스트리밍(요청 body 를 `ReadableStream` 으로 주는 경우)의 요구사항 — `duplex` 옵션과 HTTP/2 요구를 이 페이지는 언급하지 않는다
- part 재시도 횟수의 적정값 — 프로토콜은 횟수를 말하지 않는다
- 어떤 validator(`ETag`/`Last-Modified`)를 `If-Range` 에 써야 하는지
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- 선택할 object storage 가 `Accept-Ranges``If-Range` 를 실제로 지원하는지(`FE-Q-012`)
- 스트리밍 업로드가 필요한지, 필요하다면 `duplex` 지원 매트릭스
## 메모
- 인용 1 해석 후보 (미검증): `C4` 는 조용한 실패의 원천이다. 이어받기를 요청했는데 `200` 이 오면 클라이언트는 **처음부터 다시 받고 있는 중**인데도 "재개 성공" 으로 착각하기 쉽다. adapter 는 `206` 을 명시적으로 확인해야 한다.
- 인용 2 해석 후보 (미검증): `C10` 때문에 part 재시도는 **원본 `Blob` 을 다시 slice** 해야 한다. 첫 시도에서 만든 body 를 보관했다가 재사용하면 disturbed 스트림을 보내게 된다.
- 추가로 봐야 할 동일 출처 페이지: `Request.duplex`, `If-Range`, `Accept-Ranges`, `AbortSignal.timeout()`
## 관련
- 같은 주제 다른 official-doc: `[[raw/official-docs/aws-s3-multipart-upload-limits]]`, `[[raw/official-docs/mdn-referrer-policy]]`
- 이 자료를 인용한 wiki 요약: 생성 전
+79
View File
@@ -0,0 +1,79 @@
---
title: MDN — Referrer-Policy
source_type: official-doc
url: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy
archive_url:
related_branches: [feature-frontend-large-object-transfer-contract]
related_projects: [ca-skeleton-frontend]
tags: [frontend, http, security, referrer, transfer]
created: 2026-07-28
---
# MDN — Referrer-Policy
> Layer: `raw/` — 외부 자료의 **원문 발췌·출처 기록**.
## 활용 branch (필수, 최소 1개+)
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| `[[raw/branch-notes/feature-frontend-large-object-transfer-contract]]` | presigned URL 유출 경로 중 **`Referer` 는 브라우저 기본값이 이미 막고 있고 남은 위험은 우리 로그·telemetry** 라는 `D2` 의 초점 이동 |
## 출처
- 원본 URL: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy
- 보조 URL:
- 아카이브 URL:
- 저자 / 조직: MDN Web Docs (Mozilla)
- 발행일: (문서 지속 갱신)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
branch 의 `D2` 가 "presigned URL 은 telemetry·로그·`Referrer` 어디에도 남기지 않는다" 로 세 경로를 나란히 놓았다. 세 경로의 **위험 크기가 같은지** 확인이 필요했다. 결과적으로 `Referer` 경로는 2020년 11월 이후 브라우저 기본 정책이 cross-origin 에서 path·query 를 이미 제거하고 있어 우리가 추가로 할 일이 적고, 실질 위험은 우리가 직접 쓰는 로그·telemetry 에 몰려 있다는 것이 드러났다. 세 경로를 같은 무게로 다루면 정작 위험한 쪽의 fixture 가 얇아진다.
## 핵심 인용
> [§Directives — no-referrer] "The `Referer` header will be omitted: sent requests do not include any referrer information."
> [§Directives — same-origin] "Send the origin, path, and query string when performing a same-origin request. Don't send the `Referer` header for cross-origin requests."
> [§Directives — strict-origin-when-cross-origin] "Send the origin, path, and query string when performing a same-origin request. For cross-origin requests send the origin (only) when the protocol security level stays same (HTTPS→HTTPS). Don't send the `Referer` header to less secure destinations (HTTPS→HTTP)."
> [§Directives — strict-origin-when-cross-origin, Note] "This is the default policy if no policy is specified, or if the provided value is invalid"
> [§Directives — unsafe-url] "Send the origin, path, and query string when performing any request, regardless of security."
> [§Directives — unsafe-url, Warning] "This policy will leak potentially-private information from HTTPS resource URLs to insecure origins. Carefully consider the impact of this setting."
## 추출된 주장
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| C1 | 지정이 없거나 값이 잘못되면 기본 정책은 `strict-origin-when-cross-origin` 이다 | [§Note] "This is the default policy if no policy is specified, or if the provided value is invalid" | `official-reference` | 정책 미설정 문서 | 모든 브라우저가 이 기본값을 구현한다는 실측 |
| C2 | 그 기본값에서 **cross-origin 요청에는 origin 만** 가고 path·query 는 가지 않는다 | [§strict-origin-when-cross-origin] "For cross-origin requests send the origin (only) when the protocol security level stays same" | `official-reference` | cross-origin 요청 | same-origin 요청에서도 path 가 가려진다는 것 — same-origin 에는 path·query 가 그대로 간다 |
| C3 | `no-referrer``Referer` 를 완전히 생략한다 | [§no-referrer] "sent requests do not include any referrer information." | `official-reference` | 명시 설정 시 | 생략이 부작용 없이 안전하다는 것 (일부 서버는 referer 를 요구) |
| C4 | `unsafe-url` 은 보안 수준과 무관하게 origin·path·query 를 모두 보내며 MDN 은 이를 경고한다 | [§unsafe-url, Warning] "This policy will leak potentially-private information from HTTPS resource URLs to insecure origins." | `official-reference` | `unsafe-url` 설정 문서 | 기본값에서도 같은 유출이 일어난다는 것 — 오히려 반대다 |
| C5 | `same-origin` 정책은 cross-origin 요청에 `Referer` 를 아예 보내지 않는다 | [§same-origin] "Don't send the `Referer` header for cross-origin requests." | `official-reference` | 명시 설정 시 | 이 정책이 기본값이라는 것 |
## 적용 경계
- 이 자료가 직접 증명하는 것:
- `C1`+`C2`: **정책을 설정하지 않아도** cross-origin 으로 나가는 요청에는 path·query 가 붙지 않는다
- `C3`~`C5`: 더 강한 정책의 선택지와 `unsafe-url` 의 위험
- 이 자료가 증명하지 않는 것:
- presigned URL 이 `Referer` 로 유출될 수 있다는 것 — `Referer`**요청을 유발한 문서의 URL** 이지 요청 대상 URL 이 아니다. presigned URL 이 우리 페이지 주소에 들어가 있지 않다면 이 경로로는 새지 않는다
- 로그·telemetry·에러 객체를 통한 유출 — 이 문서의 범위가 아니며, 그쪽이 실제 위험이다
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- presigned URL 이 주소창·`history`·`document.referrer` 에 들어가는 코드 경로가 있는지 (있다면 그때부터 `Referer` 가 실제 유출 경로가 된다)
## 메모
- 인용 1 해석 후보 (미검증): `C1`+`C2``D2` 의 세 경로 중 `Referrer` 항목의 **우선순위를 낮춘다**. 브라우저 기본값이 이미 방어하므로, 이 branch 가 추가로 할 일은 "presigned URL 을 페이지 URL 에 넣지 않는다" 는 금지 규칙 하나다.
- 인용 2 해석 후보 (미검증): 반대로 **로그·telemetry 경로에는 어떤 브라우저 기본 방어도 없다.** fixture 밀도를 그쪽에 몰아야 한다.
- 추가로 봐야 할 동일 출처 페이지: `Referer` 헤더, `Request.referrerPolicy`
## 관련
- 같은 주제 다른 official-doc: `[[raw/official-docs/owasp-content-security-policy-cheat-sheet]]`, `[[raw/official-docs/mdn-http-range-fetch-transfer]]`
- 이 자료를 인용한 wiki 요약: 생성 전
@@ -0,0 +1,82 @@
---
title: official-doc / Mongock — Migration Tool, Multi-Instance Lock & Maintenance Status
source_type: official-doc
url: https://docs.mongock.io/
archive_url:
related_branches: [feature-mongo-runtime-baseline-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, persistence, mongodb, distributed-lock]
created: 2026-07-28
---
# Mongock — Migration Tool, Multi-Instance Lock & Maintenance Status
> Layer: `raw/` — 외부 자료(공식 문서 / 대기업 기술 블로그)의 **원문 발췌·출처 기록**.
> 본 템플릿은 `raw/official-docs/``raw/company-tech-blogs/` 두 폴더가 공유.
> 검증된 요약은 `/ingest``wiki/concepts/``source-summary-template` 형식으로 별도 작성. 원본은 raw에 영구 보관.
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-mongo-runtime-baseline-contract]] | "index manifest 적용 + drift 감지를 Mongock 이 아니라 Spring Data `IndexOperations` 기반 자체 러너로 구현한다" 결정의 양면 근거 — Mongock 을 선택하지 않는 근거(유지보수 상태: 신규 개발이 후속 프로젝트 Flamingock 으로 이동, critical bug fix/security update 만 지속)와, 선택했다면 얻었을 이점(멀티 인스턴스 동시 실행을 막는 DB 영속 pessimistic lock 내장)을 모두 verbatim 으로 확보 |
## 출처
- 원본 URL: https://docs.mongock.io/
- 아카이브 URL: (미제공)
- 저자 / 조직: Mongock (Flamingock 이 관리하는 OSS 프로젝트, Apache License 2.0)
- 발행일: 불명 (문서 사이트, 지속 갱신형 — 페이지 자체에 발행일 명시 없음)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-mongo-runtime-baseline-contract` branch 는 index manifest 적용 + drift 감지를 기성 migration 도구(Mongock) 대신 Spring Data `IndexOperations` 기반 자체 러너로 구현하기로 결정하려 한다. 이 결정은 "Mongock 이 EOL(신규 개발 중단, 후속 프로젝트 Flamingock 으로 이전)이라 채택하지 않는다"는 근거와, "Mongock 을 채택했다면 멀티 인스턴스 동시 실행 방지용 DB 영속 pessimistic lock 을 별도 구현 없이 얻었을 것이다"라는 trade-off 를 모두 인지한 상태에서 내려야 한다. 이 문서는 그 양면을 모두 verbatim 으로 뒷받침한다.
## 핵심 인용
> [Introduction] "Mongock is a Java based migration tool as part of your application code for Distributed environments. It allows developers to execute safer migrations by having ownership and control over data migrations during the Application deployment process as code and data changes are shipped together."
> [경고 배너, 페이지 최상단] "Mongock will continue receiving critical bug fixes and security updates only. All innovation is happening in Flamingock." (원문에서 "critical bug fixes and security updates only" 부분은 `<b>` 태그로 강조되어 있었음 — 볼드 마크업만 제거, 문구는 원문 그대로)
> [How it works → 3. The persistent layer] "As more than one instance of the client-service may be running simultaneusly [원문 그대로, typo 포함] in the environment, it will try to execute the same migration on startup. To prevent this, Mongock uses a pesimistic lock [원문 그대로, typo 포함] that is persisted in database."
> [How it works → 3. The persistent layer] "Mongock needs to track the ChangeUnits that have been executed, so the client-service doesn't execute them twice."
> [How it works → 2. Your migration changes] "Note: From version 5, ChangeLog annotation is deprecated (though remains for backwards compatibility). It's been replaced by @ChangeUnit."
## Claims Extracted
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| MONGOCK-C1 | Mongock 은 애플리케이션 코드에 통합되어 분산 환경에서 안전한 DB migration 을 실행하는 Java 기반 도구다 | "Mongock is a Java based migration tool as part of your application code for Distributed environments. [...] as code and data changes are shipped together." | `official-vendor-doc` | Mongock 의 정의·목적 범위(코드-DB 변경 동시 배포) | ca-skeleton 의 index manifest / drift 감지 요구사항과의 기능 적합성 |
| MONGOCK-C2 | Mongock 은 신규 기능 개발이 후속 프로젝트 Flamingock 으로 이전되었고, Mongock 자체는 critical bug fix 와 security update 만 계속 받는다 | "Mongock will continue receiving critical bug fixes and security updates only. All innovation is happening in Flamingock." | `official-vendor-doc` | 2026-07-28 확인 시점 기준 Mongock 의 유지보수 상태(사실상 maintenance-mode/EOL 방향) — "기성 도구 대신 자체 러너를 택한다" 결정의 반대 방향 근거 | 정확한 EOL 날짜, critical bug fix 지원이 얼마나 오래 지속될지, Flamingock 이 ca-skeleton 에 더 적합한지 여부 — 이 페이지는 판단하지 않음 |
| MONGOCK-C3 | 멀티 인스턴스 환경에서 동시 실행되는 여러 client-service 인스턴스가 같은 migration 을 중복 실행하지 않도록, Mongock 은 DB 에 영속되는 pessimistic lock 을 사용한다 | "As more than one instance of the client-service may be running simultaneusly in the environment, it will try to execute the same migration on startup. To prevent this, Mongock uses a pesimistic lock that is persisted in database." | `official-vendor-doc` | Mongock 을 채택했을 경우 얻는 이점(멀티 인스턴스 락 내장) — "Mongock 을 선택하지 않는다"는 결정에 대한 trade-off 인지 근거 | 이 pessimistic lock 의 timeout·lease·재시도 세부 메커니즘(이 페이지는 존재만 언급, 구현 detail 은 별도 `/v5/lock/` 섹션 — 미조사) — 자체 러너로 이 lock 을 어떻게 대체할지는 이 자료가 답하지 않음 |
| MONGOCK-C4 | Mongock 은 실행된 ChangeUnit(구 ChangeLog, v5 부터 `@ChangeUnit` 으로 대체, `@ChangeLog` 는 하위호환만 유지)을 DB 에 추적해 client-service 가 동일 migration 을 두 번 실행하지 않도록 한다 | "Mongock needs to track the ChangeUnits that have been executed, so the client-service doesn't execute them twice." / "From version 5, ChangeLog annotation is deprecated (though remains for backwards compatibility). It's been replaced by @ChangeUnit." | `official-vendor-doc` | Mongock 의 changelog/변경 이력 추적 메커니즘 존재 및 명명 변화(ChangeLog→ChangeUnit) | **선언한 index manifest 와 실제 DB 인덱스 상태를 대조하는 drift 감지 기능은 이 페이지(https://docs.mongock.io/ 홈)에서 언급되지 않음 — "확인되지 않음"이며, 이는 그런 기능이 Mongock 에 없다는 증거가 아니다(부재의 증거 아님, 별도 페이지 확인 필요)** |
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `MONGOCK-C1`: Mongock 의 정의(Java 기반, 코드 통합형, 분산 환경 대상 migration 도구)
- `MONGOCK-C2`: 2026-07-28 확인 시점 기준 Mongock 은 신규 개발이 중단되고 critical bug fix/security update 만 이어지는 유지보수 상태이며, 후속 프로젝트는 Flamingock
- `MONGOCK-C3`: Mongock 이 멀티 인스턴스 동시 실행을 막기 위해 DB 영속 pessimistic lock 을 사용한다는 사실 자체
- `MONGOCK-C4`: Mongock 이 실행된 ChangeUnit 을 DB 에 추적해 중복 실행을 막는다는 메커니즘 존재, `@ChangeLog``@ChangeUnit` 명명 변화(v5)
- 이 자료가 증명하지 않는 것:
- index manifest 선언값과 실제 DB 인덱스 상태를 비교하는 **drift 감지** 기능 — 이 페이지에서 확인되지 않음(부재 확인일 뿐 미지원 확정 아님)
- pessimistic lock 의 timeout/lease/재시도 구현 세부 — 별도 `/v5/lock/` 섹션 미조사
- Mongock 이 MongoDB 외 CosmosDB/DocumentDB/Couchbase/DynamoDB 등에서 각각 어떤 수준으로 동작하는지의 세부 비교
- "Mongock 대신 자체 러너를 만드는 것이 더 낫다"는 가치 판단 — 이 자료는 사실(유지보수 상태·락 메커니즘)만 제공, 선택은 branch 의 trade-off 판단 몫
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- ca-skeleton 의 index manifest·drift 감지 요구사항을 자체 `IndexOperations` 러너로 구현할 때, Mongock 의 pessimistic lock 이 제공하던 멀티 인스턴스 보호를 어떤 메커니즘(예: 별도 advisory lock, 배포 전략 등)으로 대체할지는 별도 branch-local 결정 필요
## 메모
- Q1(유지보수 상태) 문구는 원문 HTML 에서 `<b>` 태그로 감싸인 강조 표시였음 — 강조 마크업만 제거하고 문구는 그대로 옮김.
- Q3 의 "simultaneusly"와 "pesimistic"은 원문 사이트의 오탈자로, 그대로 보존함(교정하지 않음).
- 이 페이지는 Mongock v5 "How it works" 개요 페이지이며, lock 메커니즘·drift 감지 여부에 대한 상세는 사이드바의 `/v5/lock/`, `/v5/technical-overview` 등 하위 페이지에 있을 수 있음 — 미조사, 추후 필요 시 별도 dispatch.
- WebFetch 도구의 1차 결과는 소형 모델이 "원문 그대로"라 표시했음에도 실제로는 문장이 재구성(paraphrase)되어 있었음(예: "Existing deployments can migrate seamlessly" vs 실제 원문 "Existing Mongock deployments migrate automatically"). 이에 따라 `curl` 로 raw HTML 을 별도 확보해 tag 만 제거한 텍스트를 self-grep 대상으로 사용했고, 본 문서의 모든 인용은 그 raw HTML 대조본 기준.
## 관련
- 같은 주제 다른 official-doc: (Mongock lock/technical-overview 하위 페이지 미조사 — 필요 시 추가 dispatch)
- 이 자료를 인용한 wiki 요약: (아직 없음, `/ingest` 이후 생성 시 추가)
@@ -0,0 +1,99 @@
---
title: "official-doc / MongoDB — Connection String Options (timeout / pool / tls / retry)"
source_type: official-doc
url: https://www.mongodb.com/docs/manual/reference/connection-string-options/
archive_url:
status: raw
confidence: high
tags: [official-doc, ca-skeleton, persistence, mongodb, connection-pool]
related_projects: [ca-skeleton]
related_branches: [feature-mongo-runtime-baseline-contract]
created: 2026-07-28
last_reviewed: 2026-07-28
---
# official-doc / MongoDB — Connection String Options (timeout / pool / tls / retry)
> Layer: `raw/official-docs/` — MongoDB 공식 Manual reference (`connection-string-options`) 원문 발췌.
> `feature-mongo-runtime-baseline-contract` 의 "Mongo 연결의 timeout·pool·TLS 파라미터를 드라이버 기본값에 맡기지 않고 명시하고 env registry 로 노출한다" 결정 근거.
## Parent / 활용 branch (필수)
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-mongo-runtime-baseline-contract]] | Mongo 연결의 timeout(`serverSelectionTimeoutMS`/`connectTimeoutMS`/`socketTimeoutMS`)·pool(`maxPoolSize`/`minPoolSize`/`maxIdleTimeMS`)·TLS(`tls`)·retry(`retryWrites`/`retryReads`) 파라미터를 드라이버 기본값에 맡기지 않고 명시하고 env registry 로 노출한다 |
## 컨텍스트
ca-skeleton 은 MongoDB 를 optional adapter(문서 저장소 축)로 채택할 수 있다. 본 branch 는 연결 문자열의 timeout·pool·TLS·retry 파라미터를 드라이버 기본값에 맡길지, 명시적으로 설정하고 env registry 로 노출할지를 결정해야 한다. 이 판단에는 각 옵션이 실제로 무엇을 의미하는지, 그리고 드라이버/서버가 어떤 값을 기본값으로 쓰는지에 대한 공식 정의가 필요하다 — "기본값에 의존하지 않는다"는 결정을 정당화하려면 그 기본값이 무엇인지, 왜 운영 환경에 부적합할 수 있는지 먼저 알아야 한다.
## 출처 / Source
- 원본 URL: https://www.mongodb.com/docs/manual/reference/connection-string-options/
- 아카이브 URL: (미수집)
- 저자 / 조직: MongoDB, Inc.
- 발행일: MongoDB Manual (현재 버전)
- 마지막 확인일: 2026-07-28
## 핵심 인용 / Key quotes (verbatim)
> [§`serverSelectionTimeoutMS`] "Specifies how long (in milliseconds) to block for server selection before throwing an exception. _Default_: 30,000 milliseconds."
> [§`connectTimeoutMS`] "The time in milliseconds to attempt a connection before timing out. The default is 10,000 milliseconds, but specific drivers might have a different default."
> [§`socketTimeoutMS`] "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."
> [§`maxPoolSize`] "The maximum number of connections in the connection pool. The default value is `100`."
> [§`minPoolSize`] "The minimum number of connections in the connection pool. The default value is `0`."
> [§`maxIdleTimeMS`] "The maximum number of milliseconds that a connection can remain idle in the pool before being removed and closed." — "This option is not supported by all drivers."
> [§`tls`] "true`: Initiates the connection with TLS/SSL. Default for [SRV Connection Format.]" / "false`: Initiates the connection without TLS/SSL. Default for [Standard Connection String Format.]"
> [§`retryWrites` / `retryReads`] "Official MongoDB drivers default to `true`." (두 옵션 모두 동일 문장으로 기본값 명시)
## Claims Extracted / 추출된 주장
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| MONGO-CONNSTR-C1 | `serverSelectionTimeoutMS` 는 서버 선택을 위해 얼마나 오래 대기할지(밀리초)를 지정하며, 초과 시 예외를 던진다. 기본값은 30,000ms 이다 | [§`serverSelectionTimeoutMS`] "Specifies how long (in milliseconds) to block for server selection before throwing an exception. _Default_: 30,000 milliseconds." | `official-vendor-doc` | 공식 MongoDB 드라이버가 이 URI 옵션을 지원하는 배포 전반 | 특정 드라이버(예: Spring Data MongoDB 가 사용하는 Java driver) 가 이 기본값을 그대로 상속하는지는 개별 드라이버 문서 확인 필요 |
| MONGO-CONNSTR-C2 | `connectTimeoutMS` 는 연결 시도를 timeout 처리하기 전까지 걸리는 시간(밀리초)이며, 기본값은 10,000ms 이지만 드라이버마다 다를 수 있다 | [§`connectTimeoutMS`] "The time in milliseconds to attempt a connection before timing out. The default is 10,000 milliseconds, but specific drivers might have a different default." | `official-vendor-doc` | 연결 수립 단계의 timeout 일반 의미 | "특정 드라이버가 다른 기본값을 가질 수 있다"는 문장 자체가 그 드라이버가 무엇인지는 밝히지 않음 — 실제 채택 드라이버의 기본값은 별도 확인 필요 |
| MONGO-CONNSTR-C3 | `socketTimeoutMS` 는 소켓에서 송수신을 시도하는 시간(밀리초)이며, 기본값은 "no timeout"(무제한)이다 | [§`socketTimeoutMS`] "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." | `official-vendor-doc` | 연결 수립 이후 개별 read/write 소켓 작업의 timeout 일반 의미 | 드라이버가 실제로 이 "no timeout" 기본값을 그대로 두는지, 아니면 자체 기본값으로 override 하는지는 드라이버별 확인 필요 — 본 인용은 "different drivers might vary" 라고만 명시 |
| MONGO-CONNSTR-C4 | `maxPoolSize` 는 connection pool 의 최대 연결 수를 의미하며, 기본값은 `100` 이다 | [§`maxPoolSize`] "The maximum number of connections in the connection pool. The default value is `100`." | `official-vendor-doc` | connection pool 크기 설정 URI 옵션을 지원하는 드라이버 전반 | `100` 이 모든 배포·워크로드에 적정한 값이라는 것은 이 문장이 증명하지 않음 — 값 산정(sizing) 기준은 별도 자료 필요 |
| MONGO-CONNSTR-C5 | `minPoolSize` 는 connection pool 의 최소 연결 수를 의미하며, 기본값은 `0` 이다 | [§`minPoolSize`] "The minimum number of connections in the connection pool. The default value is `0`." | `official-vendor-doc` | connection pool 최소 유지 연결 수 개념 | 모든 드라이버가 이 옵션을 지원하는 것은 아님(자료 원문에 별도 명시) — 채택 드라이버의 지원 여부 확인 필요 |
| MONGO-CONNSTR-C6 | `maxIdleTimeMS` 는 연결이 idle 상태로 pool 에 남아있다가 제거·종료되기까지의 최대 시간(밀리초)을 의미한다. 원문에는 이 옵션의 **명시적 숫자 기본값이 기재되어 있지 않다**(재확인 fetch로 "No default value is explicitly stated" 확인) | [§`maxIdleTimeMS`] "The maximum number of milliseconds that a connection can remain idle in the pool before being removed and closed." | `official-vendor-doc` | idle connection 회수 개념 자체 | 이 옵션의 실제 기본 동작(예: 무제한 유지)이 무엇인지는 이 인용만으로 증명되지 않음 — "모든 드라이버가 지원하는 것은 아니다"라고만 명시되어 있어, 채택 드라이버 문서에서 실제 기본 동작 확인 필요 |
| MONGO-CONNSTR-C7 | `tls`(=`ssl`) 옵션의 기본값은 연결 문자열 형식에 따라 다르다 — SRV 형식(`mongodb+srv://`)에서는 `true` 가 기본, Standard 형식(`mongodb://`)에서는 `false` 가 기본이다 | [§`tls`] "true`: Initiates the connection with TLS/SSL. Default for [SRV Connection Format.]" / "false`: Initiates the connection without TLS/SSL. Default for [Standard Connection String Format.]" | `official-vendor-doc` | `mongodb://` 또는 `mongodb+srv://` 형식의 연결 문자열을 사용하는 모든 공식 드라이버 | TLS 미사용(Standard 형식 + `tls` 미지정) 상태에서 실제 운영 환경에 어떤 위험이 있는지는 이 문서가 규정하지 않음 — 위험 판단은 별도 보안 근거 필요 |
| MONGO-CONNSTR-C8 | `retryWrites`/`retryReads` 는 공식(official) MongoDB 드라이버에서 기본값이 `true` 이다(재시도 가능 write/read 가 기본 활성화) | [§`retryWrites`/`retryReads`] "Official MongoDB drivers default to `true`." | `official-vendor-doc` | "공식" MongoDB 드라이버(자체 명시)를 사용하는 연결 | 서드파티/커스텀 드라이버 또는 `mongosh` 는 이 기본값 범위 밖 — 원문에 "`mongosh` does not support retryable reads" 라고 별도 명시(이번 raw 에는 별도 claim 미등록, 참고용으로만 기재) |
## Usage Boundaries / 적용 경계
- **이 자료가 직접 증명하는 것**:
- `MONGO-CONNSTR-C1`: `serverSelectionTimeoutMS` 기본값 30,000ms
- `MONGO-CONNSTR-C2`: `connectTimeoutMS` 기본값 10,000ms(드라이버별 상이 가능)
- `MONGO-CONNSTR-C3`: `socketTimeoutMS` 기본값 "no timeout"(드라이버별 상이 가능)
- `MONGO-CONNSTR-C4`~`C5`: `maxPoolSize` 기본값 100, `minPoolSize` 기본값 0
- `MONGO-CONNSTR-C6`: `maxIdleTimeMS` 의미는 정의되지만 **명시적 숫자 기본값은 이 문서에 없음**
- `MONGO-CONNSTR-C7`: `tls` 기본값이 연결 문자열 형식(SRV vs Standard)에 따라 갈린다는 사실
- `MONGO-CONNSTR-C8`: 공식 드라이버 기준 `retryWrites`/`retryReads` 기본값 `true`
- **이 자료가 증명하지 않는 것**:
- ca-skeleton 이 실제로 채택할 특정 드라이버(예: Spring Data MongoDB 가 감싸는 MongoDB Java Driver)가 이 문서의 기본값을 정확히 그대로 상속하는지 — 원문 곳곳에 "specific drivers might have a different default" / "different drivers might vary" 라고 반복 명시되어 있어, 채택 드라이버 자체 문서 교차 확인이 필요함
- `maxPoolSize: 100`, `serverSelectionTimeoutMS: 30000` 등 기본값이 ca-skeleton 의 실제 트래픽·SLA 에 적정한지 — 이 문서는 정의와 기본값만 제공, sizing 판단 근거 아님
- `w`/`j`/`wtimeout` 등 write concern 관련 옵션 — 별도 자료 [[raw/official-docs/write-concern-mongodb-official]] 로 이미 수집됨, 본 자료 범위 밖
- read concern / change streams / replica-set 트랜잭션 요건 — 본 branch 완료 조건에 포함되나 이 자료는 다루지 않음(잔여 수집 대상)
- **ca-skeleton 프로젝트에 적용하려면 추가 확인이 필요한 것**:
- Spring Data MongoDB(또는 채택할 드라이버)가 이 URI 옵션들을 어떤 설정 property(예: `spring.data.mongodb.uri` vs 개별 property)로 노출하는지 — 별도 official-doc 필요
- env registry 에 노출할 실제 값(예: `serverSelectionTimeoutMS` 를 30,000 그대로 둘지 낮출지)은 이 자료가 아니라 branch 의 §구현 가이드에서 운영 요구사항 기반으로 결정 — 본 자료는 "기본값이 무엇이고 왜 명시가 필요한지"까지만 증명
## 메모 / Notes
- 최초 WebFetch 결과는 소형 모델이 각 옵션을 새 markdown 표(재구성)로 요약해 반환했다 — paraphrase 위험이 있어 폐기하고, "Do NOT summarize or reformat" 지시를 명시한 재요청으로 verbatim 인용을 다시 받았다. `maxIdleTimeMS` 의 명시적 기본값 유무는 3차 재요청으로 별도 확인("No default value is explicitly stated")했다.
- `tls`/`ssl` 인용은 원문의 markdown 링크 문법(`[SRV Connection Format.](url)`)이 포함되어 있어, self-grep 매칭을 위해 링크 텍스트까지만(`[SRV Connection Format.]`) 부분 문자열로 인용했다 — 원문 자체의 하이퍼링크 마크업이며 내용 왜곡 아님.
- 잔여 수집 대상(이번 raw 범위 밖): read concern 공식 정의, change streams resume token 공식 정의, replica-set 트랜잭션 요건 공식 문서.
## Related / 관련
- 같은 주제 다른 official-doc: [[raw/official-docs/write-concern-mongodb-official]] (write concern `w`/`j`/`wtimeout` — 본 자료와 상보적)
- 인용하는 branch: [[raw/branch-notes/feature-mongo-runtime-baseline-contract]]
- 인용하는 project: [[raw/project-notes/ca-skeleton-operational-contract]]
- 인용한 wiki 요약: (미작성)
@@ -0,0 +1,117 @@
---
title: "official-doc / MongoDB — Read Concern (Levels & Guarantees)"
source_type: official-doc
url: https://www.mongodb.com/docs/manual/reference/read-concern/
archive_url:
status: raw
confidence: high
tags: [official-doc, ca-skeleton, persistence, mongodb, consistent-read]
related_projects: [ca-skeleton]
related_branches: [feature-mongo-runtime-baseline-contract]
created: 2026-07-28
last_reviewed: 2026-07-28
---
# official-doc / MongoDB — Read Concern (Levels & Guarantees)
> Layer: `raw/official-docs/` — MongoDB 공식 Manual reference (`read-concern`) 원문 발췌.
> `feature-mongo-runtime-baseline-contract` 의 "MongoDB 조회는 read concern 을 명시하고 기본값에 의존하지 않는다" 결정 근거.
## Parent / 활용 branch (필수)
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-mongo-runtime-baseline-contract]] | MongoDB 조회는 read concern 을 명시하고 기본값에 의존하지 않는다 — `local`/`available`/`majority`/`linearizable`/`snapshot` 5개 level 이 각각 무엇을 보장/비보증하는지, `majority` 가 replica-set·multi-document transaction 과 맺는 관계, level 별 topology·operation 제약(예: `linearizable` 은 primary 전용, `snapshot` 은 트랜잭션 밖에서 `find`/`aggregate`/`distinct`(unsharded 전용)로만 제한)의 verbatim 근거 |
## 컨텍스트
ca-skeleton 은 MongoDB 를 optional adapter(문서 저장소 축)로 채택할 수 있다. 본 branch 는 조회(read) 시 read concern 을 애플리케이션/드라이버 레벨에서 **명시**할지, 서버 implicit default 에 의존할지를 결정해야 한다. 이 판단에는 (1) 5개 level 각각이 어떤 데이터를 반환하고 무엇을 보증/비보증하는지, (2) `majority` read concern 이 multi-document transaction 안에서 write concern 과 맺는 관계, (3) 각 level 이 어떤 topology(standalone/replica set/sharded cluster)·operation 에서만 쓸 수 있는지에 대한 공식 정의가 필요하다.
## 출처 / Source
- 원본 URL: https://www.mongodb.com/docs/manual/reference/read-concern/
- 아카이브 URL: (미수집)
- 저자 / 조직: MongoDB, Inc.
- 발행일: MongoDB Manual (현재 버전 — level별 상세 하위 페이지 `read-concern-local`/`read-concern-available`/`read-concern-majority`/`read-concern-linearizable`/`read-concern-snapshot` 를 링크하는 최신 manual 구조로 판단)
- 마지막 확인일: 2026-07-28
## 핵심 인용 / Key quotes (verbatim)
> [§read-concern-local / §read-concern-available — 두 level 이 동일 문장을 공유] "The query returns data from the instance with no guarantee that the data has been written to a majority of the replica set members. Data may be rolled back."
> [§read-concern-local] "Default for reads against the primary and secondaries."
> [§read-concern-available] "However, this comes at the expense of consistency as "available" read concern can return orphaned documents when reading from a sharded collection."
> [§read-concern-majority] "The query returns the data acknowledged by a majority of the replica set members. Returned documents are durable, even if a failure occurs."
> [§read-concern-majority — Requirements] "Replica sets must use the WiredTiger storage engine."
> [§read-concern-majority — Transactions] "For multi-document transactions, read concern "majority" provides its guarantees only if the transaction commits with write concern "majority"." [...] "Otherwise, "majority" provides no guarantees about data read in transactions."
> [§read-concern-linearizable] "You can only specify linearizable read concern for read operations on the primary."
> [§read-concern-linearizable] "Linearizable read concern guarantees only apply if read operations specify a query filter that uniquely identifies a single document."
> [§read-concern-snapshot] "A query with read concern "snapshot" returns majority-committed data as it appears across shards from a specific single point in time in the recent past."
> [§read-concern-snapshot — Transactions] "Read concern "snapshot" provides its guarantees only if the transaction commits with write concern "majority"."
> [§read-concern-snapshot — Supported Operations] "The following methods outside of multi-document transactions:" ... "distinct on unsharded collections only"
> [§Read Concern and the local Database] "The local database does not support read concerns. MongoDB silently ignores any configured read concern for operations on collections in the local database."
## Claims Extracted / 추출된 주장
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| MONGO-READCONCERN-C1 | `local` read concern 은 인스턴스가 가진 데이터를 반환하며, 그 데이터가 replica set 과반수에 기록되었다는 보장이 없고 rollback 될 수 있다 | [§read-concern-local] "The query returns data from the instance with no guarantee that the data has been written to a majority of the replica set members. Data may be rolled back." | `official-vendor-doc` | `local` read concern 을 사용하는 primary/secondary read 전반 | 어느 정도 stale 할 수 있는지(지연 시간)는 이 문장이 규정하지 않음 |
| MONGO-READCONCERN-C2 | `local` 은 primary·secondary 조회 모두에 대해 기본(default) read concern 이다 | [§read-concern-local] "Default for reads against the primary and secondaries." | `official-vendor-doc` | read concern 을 명시하지 않은 모든 조회(단, causally consistent session/transaction 등 다른 기본값이 적용되는 맥락은 제외) | 모든 driver/client 설정에서 예외 없이 항상 `local` 이 적용된다는 것까지는 이 한 문장만으로 보장하지 않음 — 세션/트랜잭션별 기본값은 별도 확인 필요 |
| MONGO-READCONCERN-C3 | `available` read concern 은 sharded collection 조회 시 orphaned document(잘못 라우팅되어 남아있는 document)를 반환할 수 있다 | [§read-concern-available] "However, this comes at the expense of consistency as "available" read concern can return orphaned documents when reading from a sharded collection." | `official-vendor-doc` | `available` read concern, sharded cluster topology | non-sharded replica set 에서도 동일한 orphaned document 위험이 있다는 뜻은 아님 — orphaned document 는 sharding 특유 현상 |
| MONGO-READCONCERN-C4 | `majority` read concern 은 replica set 과반수에 승인(acknowledge)된 데이터를 반환하며, 실패가 발생해도 반환된 문서는 durable 하다 | [§read-concern-majority] "The query returns the data acknowledged by a majority of the replica set members. Returned documents are durable, even if a failure occurs." | `official-vendor-doc` | `majority` read concern 을 사용하는 replica set 조회 전반 | 성능이 다른 level 과 항상 동일하다는 것을 이 문장 자체가 수치로 증명하지는 않음 |
| MONGO-READCONCERN-C5 | `majority` read concern 을 사용하려면 replica set 이 WiredTiger storage engine 을 써야 한다 | [§read-concern-majority — Requirements] "Replica sets must use the WiredTiger storage engine." | `official-vendor-doc` | `majority` read concern 채택 여부 판단 | 다른 storage engine 에서 정확히 어떤 에러/폴백이 발생하는지는 이 문장이 설명하지 않음 — "요구사항"만 명시 |
| MONGO-READCONCERN-C6 | multi-document transaction 안에서 `majority` read concern 은 트랜잭션이 write concern `"majority"` 로 커밋될 때만 보장을 제공하며, 그렇지 않으면 트랜잭션 내에서 읽은 데이터에 대해 어떤 보장도 제공하지 않는다 | [§read-concern-majority — Transactions] "For multi-document transactions, read concern "majority" provides its guarantees only if the transaction commits with write concern "majority". Otherwise, "majority" provides no guarantees about data read in transactions." | `official-vendor-doc` | multi-document transaction 내부에서 `majority` read concern 을 사용하는 모든 read | 트랜잭션 밖의 단일 read operation 에서의 `majority` 보장 여부는 별도(C4 가 이미 커버) — 이 claim 은 트랜잭션 맥락에 한정 |
| MONGO-READCONCERN-C7 | `linearizable` read concern 은 replica set 의 primary 멤버에서 수행하는 read operation 에만 지정할 수 있다 | [§read-concern-linearizable] "You can only specify linearizable read concern for read operations on the primary." | `official-vendor-doc` | `linearizable` read concern, replica set topology(primary 한정) | sharded cluster 에서 `linearizable` 사용 가능 여부를 직접 서술하지 않음 — primary member 제약만 명시 |
| MONGO-READCONCERN-C8 | `linearizable` read concern 의 보장은 read operation 이 단일 document 를 고유하게 식별하는 query filter 를 지정할 때만 적용된다 | [§read-concern-linearizable] "Linearizable read concern guarantees only apply if read operations specify a query filter that uniquely identifies a single document." | `official-vendor-doc` | `linearizable` 을 사용하는 쿼리의 filter 설계 | 다중 document 대상 쿼리 자체가 문법적으로 금지된다는 뜻은 아님 — "guarantee 가 적용되지 않는다"는 의미로, 실행 자체의 허용 여부와는 구분 |
| MONGO-READCONCERN-C9 | `snapshot` read concern 은 여러 shard 에 걸쳐 특정 단일 시점(recent past)의 majority-committed 데이터를 반환한다 | [§read-concern-snapshot] "A query with read concern "snapshot" returns majority-committed data as it appears across shards from a specific single point in time in the recent past." | `official-vendor-doc` | `snapshot` read concern, sharded cluster 포함 다중 shard 조회 | 이 보장이 자동으로 오는 것은 아니며 트랜잭션이 write concern `"majority"` 로 커밋되어야 함(별도 C10) |
| MONGO-READCONCERN-C10 | `snapshot` read concern 의 보장은 트랜잭션이 write concern `"majority"` 로 커밋될 때만 제공된다 | [§read-concern-snapshot — Transactions] "Read concern "snapshot" provides its guarantees only if the transaction commits with write concern "majority"." | `official-vendor-doc` | multi-document transaction 내 `snapshot` read concern | 트랜잭션 밖에서 사용하는 `snapshot`(find/aggregate/distinct)에도 "트랜잭션 커밋" 조건이 그대로 적용된다는 뜻은 아님 — 트랜잭션 밖 사용은 별도(C11) |
| MONGO-READCONCERN-C11 | multi-document transaction 밖에서 `snapshot` read concern 을 사용할 수 있는 operation 은 `find`, `aggregate`, 그리고 `distinct`(단, unsharded collection 에서만)로 제한된다 | [§read-concern-snapshot — Supported Operations] "The following methods outside of multi-document transactions:" ... "distinct on unsharded collections only" | `official-vendor-doc` | 트랜잭션 없이 단일 operation 에 `snapshot` read concern 을 지정하는 경우 | sharded collection 에서 트랜잭션 밖 `distinct` + `snapshot` 조합이 가능한지는 이 문장이 오히려 **불가능**하다고 증명함(허용이 아니라 제약 근거) — 트랜잭션 내부의 `distinct` 는 이 제약과 무관(트랜잭션-레벨이므로) |
| MONGO-READCONCERN-C12 | MongoDB 의 `local` 데이터베이스(리플리케이션 메타데이터 등 내부용 특수 DB)에 대한 operation 은 read concern 을 지원하지 않으며, 어떤 read concern 이 설정되어도 조용히 무시된다 | [§Read Concern and the local Database] "The local database does not support read concerns. MongoDB silently ignores any configured read concern for operations on collections in the local database." | `official-vendor-doc` | 이름이 `local` 인 특수 시스템 데이터베이스 상의 operation | 사용자 애플리케이션 데이터가 담긴 일반 database/collection 에서도 read concern 이 무시된다는 뜻이 아님 — 오직 시스템 `local` DB 에만 해당(read concern **level** `"local"` 과는 다른 개념이므로 혼동 주의) |
## Usage Boundaries / 적용 경계
- **이 자료가 직접 증명하는 것**:
- `MONGO-READCONCERN-C1`~`C2`: `local` 이 무엇을 (비)보증하는지, 그리고 read 의 implicit default 라는 사실
- `MONGO-READCONCERN-C3`: `available` 이 sharded collection 에서 orphaned document 를 반환할 수 있다는 위험
- `MONGO-READCONCERN-C4`~`C6`: `majority` 의 정의, WiredTiger 요건, multi-document transaction 내에서의 조건부 보장(write concern `"majority"` 커밋 필요)
- `MONGO-READCONCERN-C7`~`C8`: `linearizable` 이 primary 전용이고 단일 document uniquely-identifying filter 를 요구한다는 제약
- `MONGO-READCONCERN-C9`~`C11`: `snapshot` 의 point-in-time majority-committed 정의, 트랜잭션 write concern `"majority"` 조건, 트랜잭션 밖 사용 시 operation 제한(`find`/`aggregate`/`distinct`-unsharded-only)
- `MONGO-READCONCERN-C12`: 시스템 `local` 데이터베이스는 read concern 설정 자체를 무시함
- **이 자료가 증명하지 않는 것**:
- 각 level 의 실제 지연시간(latency) 수치 비교 — 상대적 순서(예: `linearizable` 이 가장 느림)는 원문에 언급되나 이번 raw 에는 verbatim 인용/self-grep 미포함(별도 확인 필요)
- 드라이버(Java Reactive Streams / Spring Data MongoDB 등)가 read concern 을 어떤 설정 키·API 로 노출하는지 — 이 자료는 서버 개념만 정의
- causally consistent session 에서의 read concern 조합 세부(예: `afterClusterTime` 자동 설정 메커니즘) — 이번 raw 수집 범위 밖(추가 확인 필요 항목으로 아래 메모에 기록)
- `readConcern` provenance(`clientSupplied`/`customDefault`/`implicitDefault`) 추적 메커니즘의 세부 동작 — 이번 raw 에는 verbatim 미수집
- **ca-skeleton 프로젝트에 적용하려면 추가 확인이 필요한 것**:
- ca-skeleton 이 채택할 MongoDB 배포(standalone / replica set / sharded cluster)에 따라 어떤 level 조합이 실제로 유효한지 재확인 — 특히 `linearizable`(primary 전용)·`snapshot`(트랜잭션 or 제한된 operation) 은 sharded cluster 여부에 민감
- `majority` 채택 시 실제 배포가 WiredTiger 를 쓰는지(현대 MongoDB 배포판은 기본값이나, 별도 storage engine 설정을 쓰지 않는지) 확인
- Spring Data MongoDB(또는 채택할 드라이버)가 `readConcern` 을 어떤 설정 키·API 로 노출하는지 — 별도 official-doc 필요 (`raw/official-docs/write-concern-mongodb-official.md` 와 동일한 드라이버 바인딩 gap)
## 메모 / Notes
- 본 fetch 는 WebFetch 소형 모델이 markdown 하이퍼링크 문법으로 원문을 재구성한 형태였다. 링크 문법이 문장 중간에 끼어들어 self-grep 리터럴 매칭이 여러 차례 깨졌다(예: "available" read concern can return orphaned documents 사이에 "orphaned documents" 를 감싸는 하이퍼링크가 삽입된 형태). 총 7회의 WebFetch 재요청으로 (1) 하이퍼링크 마크업 제거 + (2) level 이름을 감싸는 큰따옴표(`"majority"` 등, 문서 값 표기의 일부)는 보존하도록 명시적으로 지시해 최종 인용을 확보했다. 원문 페이지 자체의 HTML 은 curl 로 직접 대조하지 않음 — 다음 재검증 시 curl 직접 fetch 로 교차 확인 권장(`write-concern-mongodb-official.md` 메모와 동일한 한계).
- `local`/`available` 두 level 의 기본 정의 문장이 원문에서 **완전히 동일**하다(WebFetch 3회 반복 확인, 동일 문자열 재현). 두 level 의 실질적 차이는 이 공유 문장이 아니라 (1) `available` 만 causally consistent session/transaction 에서 사용 불가하고 (2) sharded collection 에서 orphaned document 를 반환할 수 있다는 추가 제약(C3)에 있다.
- 미수집(추가 fetch 필요): causally consistent session 에서 사용 가능한 level 목록 문장("local"/"majority"/"snapshot"), `readConcern` provenance 3종 값의 세부 동작, level별 상대적 성능 비교 문장(`linearizable` 이 가장 느림 — best practice 로 `maxTimeMS` 권장).
- 다음 fetch 후보: MongoDB 공식 `causal-consistency`(causally consistent session) 페이지, `transactions` 페이지(read concern 과 write concern 조합 표).
## Related / 관련
- 같은 주제 다른 official-doc:
- [[raw/official-docs/write-concern-mongodb-official]] — write concern (`w`/`j`/`wtimeout`) 공식 근거, 같은 branch 의 write 측 대응 문서
- [[raw/official-docs/change-streams-mongodb-official]] — change stream resume token/oplog window
- [[raw/official-docs/read-preference-mongodb-official]] — 조회를 어느 replica set 멤버로 라우팅할지(read concern 과는 별개 축)
- [[raw/official-docs/retryable-writes-mongodb-official]] — write 재시도 공식 근거
- [[raw/official-docs/mongodb-connection-string-options]] — connection string 옵션
- 인용하는 branch: [[raw/branch-notes/feature-mongo-runtime-baseline-contract]]
- 인용하는 project: [[raw/project-notes/ca-skeleton-operational-contract]]
- 인용한 wiki 요약: (미작성)
@@ -0,0 +1,100 @@
---
title: official-doc / MongoDB — Read Preference (Replica Set Read Routing)
source_type: official-doc
url: https://www.mongodb.com/docs/manual/core/read-preference/
archive_url:
related_branches: [feature-mongo-runtime-baseline-contract]
related_projects: []
tags: [official-doc, ca-skeleton, persistence, mongodb, read-preference]
created: 2026-07-28
---
# official-doc / MongoDB — Read Preference (Replica Set Read Routing)
> Layer: `raw/` — 외부 자료(공식 문서 / 대기업 기술 블로그)의 **원문 발췌·출처 기록**.
> 본 템플릿은 `raw/official-docs/``raw/company-tech-blogs/` 두 폴더가 공유.
> 검증된 요약은 `/ingest``wiki/concepts/``source-summary-template` 형식으로 별도 작성. 원본은 raw에 영구 보관.
## source_type 허용값
`source_type: official-doc` — MongoDB 공식 매뉴얼(레퍼런스) 문서.
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-mongo-runtime-baseline-contract]] | 조회의 replica 라우팅을 read preference mode로 명시하고, secondary 읽기는 stale read를 허용하는 경로에만 쓴다는 결정 — mode 5종 정의 + secondary 읽기 stale 경고 + maxStalenessSeconds 메커니즘 + 트랜잭션 내 read preference 제약의 근거 |
## 출처
- 원본 URL: https://www.mongodb.com/docs/manual/core/read-preference/
- 아카이브 URL: (미제공)
- 저자 / 조직: MongoDB, Inc. (공식 매뉴얼)
- 발행일: (문서 자체에 명시 없음 — 버전별 롤링 업데이트되는 레퍼런스 페이지)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-mongo-runtime-baseline-contract` branch가 "조회 replica 라우팅을 read preference로 명시하고, secondary 읽기는 stale read를 허용하는 경로에만 쓴다"를 결정하는 데 필요한 5종 mode 정의, stale 경고, maxStalenessSeconds 메커니즘, 트랜잭션 제약의 공식 근거로 보관.
## 핵심 인용
> [§Behavior] "All read preference modes except `primary` may return stale data because secondaries replicate operations from the primary in an asynchronous process. Ensure that your application can tolerate stale data if you choose to use a non-`primary` mode."
> [§Read Preference and Transactions] "Transactions that contain read operations must use read preference `primary`. All operations in a given transaction must route to the same member."
> [§Read Preference Modes (Detailed) — primary] "All read operations use only the current replica set primary. This is the default read mode. If the primary is unavailable, read operations produce an error or throw an exception."
> [§Read Preference Modes (Detailed) — secondary] "Operations read _only_ from the secondary members of the set. If no secondaries are available, then this read operation produces an error or exception."
> [§Read Preference Modes (Detailed) — secondary] "When the `secondary` read preference includes a maxStalenessSeconds value, the client estimates how stale each secondary is by comparing the secondary's last write to that of the primary. The client then directs the read operation to a secondary whose estimated lag is less than or equal to `maxStalenessSeconds`. If there is no primary, the client uses the secondary with the most recent write for the comparison."
## Claims Extracted
> 이 자료가 **직접 말하는 것만** claim 으로 분리한다. 내 프로젝트에 적용한 결론은 여기 쓰지 않는다.
| Claim ID | Claim | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| MONGO-READPREF-C1 | `primary` 모드가 기본값이며 primary에서만 읽는다. primary 사용 불가 시 에러/예외가 발생한다 | [§primary] "All read operations use only the current replica set primary. This is the default read mode. If the primary is unavailable, read operations produce an error or throw an exception." | `official-vendor-doc` | MongoDB 드라이버 전반의 기본 read routing 동작 | ca-skeleton이 실제로 이 기본값을 재정의하지 않았는지는 코드 확인 필요 |
| MONGO-READPREF-C2 | `secondary` 모드는 secondary에서만 읽으며, 가용한 secondary가 없으면 에러/예외가 발생한다 | [§secondary] "Operations read _only_ from the secondary members of the set. If no secondaries are available, then this read operation produces an error or exception." | `official-vendor-doc` | secondary 전용 조회 경로를 선택했을 때의 실패 모드 근거 | 특정 배포에서 이 실패가 얼마나 자주 발생하는지는 증명 안 함 |
| MONGO-READPREF-C3 | `primary`를 제외한 모든 read preference 모드는 secondary의 비동기 복제 지연 때문에 stale data를 반환할 수 있다. 애플리케이션이 stale data를 감내할 수 있어야 한다 | [§Behavior] "All read preference modes except `primary` may return stale data because secondaries replicate operations from the primary in an asynchronous process. Ensure that your application can tolerate stale data if you choose to use a non-`primary` mode." | `official-vendor-doc` | secondary/secondaryPreferred/nearest/primaryPreferred 조회 경로 전체에 stale read 가능성이 있다는 근거 | 구체적인 replication lag 수치(ms/sec)는 배포 환경에 따라 다르며 이 문서는 수치를 제시하지 않음 |
| MONGO-READPREF-C4 | `secondary` 모드에서 `maxStalenessSeconds`가 지정되면, 클라이언트는 secondary의 마지막 write와 primary의 마지막 write를 비교해 추정 지연이 임계값 이하인 secondary로만 라우팅한다 | [§secondary] "When the `secondary` read preference includes a maxStalenessSeconds value, the client estimates how stale each secondary is by comparing the secondary's last write to that of the primary. The client then directs the read operation to a secondary whose estimated lag is less than or equal to `maxStalenessSeconds`. If there is no primary, the client uses the secondary with the most recent write for the comparison." | `official-vendor-doc` | maxStalenessSeconds를 지정했을 때 secondary 선택 알고리즘의 근거 | `maxStalenessSeconds`의 허용 최소값(예: 90초 같은 하한)은 이 페이지에 없음 — `needs-confirmation`, 별도 페이지(`docs/manual/core/read-preference-staleness/`) 확인 필요 |
| MONGO-READPREF-C5 | read operation을 포함한 트랜잭션은 반드시 read preference `primary`를 사용해야 하고, 한 트랜잭션 내 모든 연산은 같은 멤버로 라우팅되어야 한다 | [§Read Preference and Transactions] "Transactions that contain read operations must use read preference `primary`. All operations in a given transaction must route to the same member." | `official-vendor-doc` | multi-document transaction 안에서는 read preference를 primary 이외로 설정할 수 없다는 제약의 근거 | 트랜잭션 밖의 단일 연산에는 이 제약이 적용되지 않음(5개 모드 모두 사용 가능) — 이 문서가 그 대비를 명시하지는 않음, 유추 |
| MONGO-READPREF-C6 | `primaryPreferred` 모드는 대부분 primary에서 읽되, failover 등으로 primary가 없으면 `maxStalenessSeconds`와 tag set을 만족하는 secondary에서 읽는다 | [§primaryPreferred] "In most situations, operations read from the primary member of the set. However, if the primary is unavailable, as is the case during failover situations, operations read from secondary members that satisfy the read preference's `maxStalenessSeconds` and tag set lists." | `official-vendor-doc` | failover 시 fallback 라우팅 동작 근거 | 여러 조건을 만족하는 secondary가 다수일 때 정확히 어느 secondary가 선택되는지 세부 알고리즘은 별도 문서(member selection) 확인 필요 |
| MONGO-READPREF-C7 | `secondaryPreferred` 모드는 보통 secondary에서 읽되, 유일한 멤버가 primary뿐이면 primary에서 읽는다 | [§secondaryPreferred] "Operations typically read data from secondary members of the replica set. If the replica set has only one single primary member and no other members, operations read data from the primary member." | `official-vendor-doc` | secondary 우선 + primary fallback 라우팅 동작 근거 | 조건을 만족하는 secondary가 없을 때(단일 primary 이외의 경우)의 세부 fallback 조건은 별도 상세 문단 확인 필요 |
| MONGO-READPREF-C8 | `nearest` 모드는 네트워크 지연이 낮은 멤버에서 읽으며, primary/secondary 역할을 구분하지 않는다 | [§nearest] "The driver reads from a member whose network latency falls within the acceptable latency window. Reads in the `nearest` mode do not consider whether a member is a primary or secondary when routing read operations: primaries and secondaries are treated equivalently." | `official-vendor-doc` | 지연 최소화 목적의 조회 라우팅 근거 | latency window의 정확한 계산식(`localThresholdMS` 등)은 이 인용 범위 밖 — 별도 문단 확인 필요 |
### Strength 허용값
- `official-standard` — RFC, 표준 사양, 언어/프로토콜 표준
- `official-vendor-doc` — Spring, Keycloak, AWS, Google, MongoDB 등 공식 벤더 문서
- `official-reference` — 공식 reference/API 문서
- `company-case-study` — 대기업/실무 기술 블로그의 특정 사례
- `engineering-blog` — 개인/팀 블로그의 엔지니어링 해설
- `tutorial` — 튜토리얼/가이드. 일반화 금지
- `needs-confirmation` — 원문만으로는 적용 판단 불가
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `MONGO-READPREF-C1`~`C3`, `C6`~`C8`: read preference 5종 모드의 공식 정의와 각 모드의 기본 라우팅/실패 동작
- `MONGO-READPREF-C3`: `primary`를 제외한 모든 모드가 stale data를 반환할 수 있다는 것 (branch의 "secondary 읽기는 stale read 허용 경로에만" 결정의 핵심 근거)
- `MONGO-READPREF-C4`: `maxStalenessSeconds`가 어떻게 secondary 선택에 반영되는지의 메커니즘
- `MONGO-READPREF-C5`: multi-document transaction 안에서 read preference가 `primary`로 고정되어야 한다는 제약
- 이 자료가 증명하지 않는 것:
- `maxStalenessSeconds`의 허용 **최소값**(예: 90초 같은 수치 하한) — 이 URL에는 해당 수치가 없음. 링크된 하위 페이지(`docs/manual/core/read-preference-staleness/`)에 있을 가능성이 있으나 별도 dispatch로 확인 필요 (1 dispatch = 1 URL 원칙에 따라 본 자료에 포함하지 않음)
- 특정 드라이버(Java, Node.js 등)의 실제 구현 세부사항 — 이 페이지는 드라이버 무관 개념 문서
- sharded cluster 전용 동작(`SystemOverloadedError`, `terminateSecondaryReadsOnOrphanCleanup`)이 ca-skeleton의 배포 토폴로지(단일 replica set 가정 여부)에 적용되는지 여부
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- ca-skeleton의 실제 MongoDB 배포가 replica set인지, sharded cluster인지 (전자에만 이 문서의 기본 가정이 그대로 적용됨)
- `maxStalenessSeconds`의 최소값 제약 — 별도 raw 자료(`read-preference-staleness`) dispatch 필요
## 메모
- `MONGO-READPREF-C4`의 "의미"는 확보했으나, 사용자가 명시적으로 요구한 "최소값 제약"(90초 등 구체적 하한)은 이 URL의 fetch 결과에 존재하지 않았다. 자체 grep으로도 확인되지 않음 — fabrication 방지를 위해 claim에 포함하지 않고 gap으로 남김.
- 추가로 봐야 할 동일 출처 페이지: `https://www.mongodb.com/docs/manual/core/read-preference-staleness/` (maxStalenessSeconds 최소값 제약 확인용), `https://www.mongodb.com/docs/manual/core/read-preference-tags/` (tag set 상세)
## 관련
- 같은 주제 다른 official-doc: (write concern 관련 자료가 taxonomy changelog에 `raw/official-docs/write-concern-mongodb-official.md`로 예고되어 있으나 2026-07-28 기준 아직 생성되지 않음 — 생성되면 상호 링크)
- 이 자료를 인용한 wiki 요약: `[[wiki/concepts/...]]` (생성 시)
@@ -0,0 +1,92 @@
---
title: "official-doc / Redis ACL — Access Control Lists (command·key 단위 최소 권한)"
source_type: official-doc
url: https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/
archive_url:
related_branches: [feature-redis-runtime-role-isolation-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, security, redis, least-privilege]
created: 2026-07-28
---
# Redis ACL — Access Control Lists (command·key 단위 최소 권한)
> Layer: `raw/` — 외부 자료(공식 문서)의 **원문 발췌·출처 기록**.
> Redis 공식 문서 — Operate Management Security ACL
## Parent / 활용 branch (필수)
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-redis-runtime-role-isolation-contract]] | Redis role(cache-runtime / coordination-runtime / session-runtime)마다 별도 named ACL user 를 두고, `reset` 으로 초기화된(=`-@all`) 상태에서 필요한 command category·key pattern 만 선택적으로 부여하는 least-privilege 결정의 공식 근거 |
## 출처 / Source
- 원본 URL: https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/
- 아카이브 URL: (미확보)
- 저자 / 조직: Redis (공식 문서)
- 발행일: (버전 미표기 페이지 — Redis 7.0+ 기능 포함 서술)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지 / Why archived
`feature-redis-runtime-role-isolation-contract` branch 가 role 별 별도 named ACL user + `reset -@all` 기반 least-privilege 부여를 결정하려면, ACL 이 실제로 command·key 단위로 권한을 제한하는 메커니즘인지, 새 user 가 기본적으로 무권한 상태로 생성되는지, `+@<category>`/`~<pattern>` 문법이 무엇을 의미하는지를 Redis 공식 문서로 확인해야 했음. 이 자료는 그 메커니즘 전체(정의·문법·기본값·reset 동작·카테고리 예시)를 공식 근거로 제공한다.
## 핵심 인용 / Key quotes (verbatim, 7개)
> [§(intro), self-grep L214] "The Redis ACL, short for Access Control List, is the feature that allows certain connections to be limited in terms of the commands that can be executed and the keys that can be accessed."
> [§ACL rules — key patterns, self-grep L104] "`~<pattern>`: Add a pattern of keys that can be mentioned as part of commands. For instance `~*` allows all the keys. The pattern is a glob-style pattern like the one of KEYS. It is possible to specify multiple patterns."
> [§Create and edit user ACLs — new user status, self-grep L170] "* There are no key patterns that the user can access."
> [§ACL rules — command categories, self-grep L96] "`+@<category>`: Add all the commands in such category to be called by the user, with valid categories being like @admin, @set, @sortedset, ... and so forth, see the full list by calling the ACL CAT command. The special category @all means all the commands, both the ones currently present in the server, and the ones that will be loaded in the future via modules."
> [§ACL rules — reset directive, self-grep L135] "`reset` Performs the following actions: resetpass, resetkeys, resetchannels, allchannels (if acl-pubsub-default is set), off, clearselectors, -@all. The user returns to the same state it had immediately after its creation."
> [§When ACLs are useful — goal 2, self-grep L41] "2. You want to improve operational safety, so that processes or humans accessing Redis are not allowed to damage the data or the configuration due to software errors or manual mistakes. For instance, there is no reason for a worker that fetches delayed jobs from Redis to be able to call the FLUSHALL command."
> [§Command categories — admin, self-grep L216] "* **admin** - Administrative commands. Normal applications will never need to use these. Includes REPLICAOF, CONFIG, DEBUG, SAVE, MONITOR, ACL, SHUTDOWN, etc."
## Claims Extracted / 추출된 주장
> 이 자료가 **직접 말하는 것만** claim 으로 분리한다. 내 프로젝트에 적용한 결론은 여기 쓰지 않는다.
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| REDIS-ACL-C1 | Redis ACL 은 연결(connection) 단위로 실행 가능한 command 와 접근 가능한 key 를 제한하는 기능이다 | [§intro] "...is the feature that allows certain connections to be limited in terms of the commands that can be executed and the keys that can be accessed." | `official-vendor-doc` | Redis 6 이상의 모든 ACL 사용 배포 | 네트워크 계층 격리(포트 분리, VPC/보안그룹)까지 ACL 이 대신한다는 뜻은 아님 — ACL 은 인증된 연결 내부의 command/key 권한만 제한 |
| REDIS-ACL-C2 | `~<pattern>` 규칙은 glob-style 패턴으로 접근 가능한 key 를 지정하며, 여러 패턴을 동시에 지정할 수 있다 | [§key patterns] "`~<pattern>`: Add a pattern of keys that can be mentioned as part of commands... It is possible to specify multiple patterns." | `official-vendor-doc` | `ACL SETUSER` 또는 `aclfile` 로 정의되는 모든 named user 의 key 접근 범위 | key pattern 만으로 read/write 를 구분하지 않음 — read-only 또는 write-only 제한이 필요하면 별도 `%R~<pattern>`/`%W~<pattern>` (Redis 7.0+) 을 써야 함 |
| REDIS-ACL-C3 | `ACL SETUSER <name>` 으로 새로 생성된 user 는 기본적으로 접근 가능한 key pattern 이 하나도 없다 | [§new user status] "* There are no key patterns that the user can access." | `official-vendor-doc` | `ACL SETUSER` 로 새로 생성되는 named user (예: `alice`) | 이미 존재하는 `default` user 에는 적용되지 않음 — `default` user 는 별도 설정 없이 `on nopass ~* &* +@all` (모든 key·모든 command 허용) 상태로 존재함 |
| REDIS-ACL-C4 | `+@<category>` 는 해당 카테고리에 속한 모든 command 를 user 에게 허용하고, `@all` 은 현재/향후 모듈 command 를 포함한 모든 command 를 의미하는 특수 카테고리다 | [§command categories rule] "`+@<category>`: Add all the commands in such category... The special category @all means all the commands..." | `official-vendor-doc` | Redis 6 이상 ACL rule DSL 전체 (`+@<category>`/`-@<category>` 조합) | 어떤 command 가 어느 카테고리에 속하는지 이 문장만으로는 규정하지 않음 — 카테고리 소속 목록은 `ACL CAT <category>` 런타임 조회 또는 개별 카테고리 설명(C7 등)으로 별도 확인 필요 |
| REDIS-ACL-C5 | `reset` 지시어는 resetpass, resetkeys, resetchannels, off, clearselectors, `-@all` 을 모두 수행해 user 를 생성 직후 상태로 되돌린다 | [§reset directive] "`reset` Performs the following actions: resetpass, resetkeys, resetchannels, allchannels (if acl-pubsub-default is set), off, clearselectors, -@all." | `official-vendor-doc` | `ACL SETUSER <user> reset ...` 호출 | `reset` 뒤에 별도로 `-@all` 을 다시 명시하는 것이 필수 문법이라는 뜻은 아님 — `reset` 자체가 이미 `-@all` 효과를 포함하므로, `reset -@all +get ...` 처럼 이어 쓰는 것은 명시적 표현이지 문법상 필수 절차가 아님 |
| REDIS-ACL-C6 | 공식 문서는 ACL 을 쓰는 이유로 (1) untrusted/trusted client 의 접근을 최소 권한으로 제한하는 "보안" 목적과 (2) 소프트웨어 오류·사람 실수로 데이터/설정이 손상되지 않도록 하는 "운영 안전성" 목적, 두 가지를 나란히 제시한다 | [§goal 2] "You want to improve operational safety, so that processes or humans accessing Redis are not allowed to damage the data or the configuration due to software errors or manual mistakes. For instance, there is no reason for a worker that fetches delayed jobs from Redis to be able to call the FLUSHALL command." | `official-vendor-doc` | worker/서비스 role 별 command 제한 근거 (예: cache-runtime 이 `FLUSHALL`/`CONFIG` 를 호출할 이유가 없다는 논리) | **ACL이 "보안 경계가 아니라 실수 방지 목적"이라고 이 문서가 말하지는 않는다** — 원문은 보안(goal 1)과 운영 안전성(goal 2)을 별개의 두 목적으로 병렬 제시할 뿐, 운영 안전성이 보안을 대체하거나 ACL이 보안 경계가 아니라고 단정하지 않음 |
| REDIS-ACL-C7 | `@admin` 카테고리는 `REPLICAOF`, `CONFIG`, `DEBUG`, `SAVE`, `MONITOR`, `ACL`, `SHUTDOWN` 등을 포함하며, 공식 문서는 "일반 애플리케이션은 이 command 들이 전혀 필요 없다"고 명시한다 | [§admin category] "**admin** - Administrative commands. Normal applications will never need to use these. Includes REPLICAOF, CONFIG, DEBUG, SAVE, MONITOR, ACL, SHUTDOWN, etc." | `official-vendor-doc` | 일반 애플리케이션 역할(캐시/좌표/세션 client)의 ACL 에서 `@admin` 을 제외하는 결정의 근거 | 목록이 "etc." 로 끝나 완전한 목록이 아님 — `@admin` 카테고리의 정확한 전체 command 목록은 런타임 `ACL CAT admin` 으로 별도 확인 필요 |
## Usage Boundaries / 적용 경계
- 이 자료가 직접 증명하는 것:
- `REDIS-ACL-C1`~`C2`: ACL 이 command·key pattern 단위로 권한을 제한하는 메커니즘 자체
- `REDIS-ACL-C3`: named user 를 새로 만들면 기본적으로 key 접근 권한이 0인 default-deny 상태로 시작 (role 별 named user 분리 설계의 안전한 출발점 근거)
- `REDIS-ACL-C4`~`C5`: `+@<category>`/`-@<category>`/`reset`(=`-@all` 포함) 문법 의미
- `REDIS-ACL-C6`: least-privilege 부여가 "운영 실수 방지"라는 공식적으로 인정된 목적 중 하나임 (단 유일한 목적은 아님 — 아래 참조)
- `REDIS-ACL-C7`: `@admin` 카테고리에 속하는 대표 command 예시와 "일반 애플리케이션엔 불필요"라는 공식 권고
- 이 자료가 증명하지 않는 것:
- **ACL이 "보안 경계가 아니라 실수 방지용"이라는 이분법** — 원문은 보안과 운영 안전성을 병렬적인 두 목적으로 제시하며, 어느 한쪽이 다른 쪽을 배제하거나 대체한다고 말하지 않음. branch 결정 근거에 이 이분법을 쓴다면 `REDIS-ACL-C6` 로 과대 해석한 것이 되므로 사용 금지
- `@dangerous` 카테고리의 정확한 전체 command 목록 (이 자료엔 `@admin` 만 verbatim 인용 확보 — `@dangerous``FLUSHALL`, `MIGRATE`, `RESTORE`, `SORT`, `KEYS` 등을 포함한다고 서술되어 있으나 이번 인용 세트엔 미포함, 별도 확인 필요)
- TLS·네트워크 계층 인증/암호화 (ACL 은 인증된 연결 내부의 command/key 권한만 다룸 — TLS·mTLS·network policy 는 별도 관심사)
- `%R~<pattern>`/`%W~<pattern>` (read/write 세분화 key permission, Redis 7.0+) 과 selector(`(...)`, Redis 7.0+) 의 실제 branch 적용 여부 — 이 문서는 문법을 설명할 뿐 branch 가 이를 채택해야 한다고 규정하지 않음
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- cache-runtime / coordination-runtime / session-runtime 각 role 이 실제로 필요로 하는 command 목록(예: cache-runtime 은 `@string`+`@hash`+`@keyspace` 일부만, coordination-runtime 은 Lua/Function 실행 관련 `@scripting` 포함 여부) — role 별 command 카탈로그는 이 문서 범위 밖, `feature-redis-atomic-program-catalog-contract` 등 sibling branch 산출물과 대조 필요
- `default` user 를 실제로 `off` 처리하거나 강한 password 로 잠그는 배포 설정 — 이 문서는 문법만 제공하며 배포 시 `default` user 처리 방침은 별도 결정 사항
- Redis 버전별 `%R`/`%W`/selector 기능 가용 여부 (Redis 7.0 이상 조건부 기능) — 실제 배포 Redis 버전 확인 필요
## 메모 / Notes
- 문서 자체는 Redis 버전을 페이지 상단에 명시하지 않고, 본문 곳곳에 "Redis 6.2 and later", "Redis 7.0 and later" 조건부 문구로 기능별 최소 버전을 표기하는 구조. 인용 시 버전 조건이 붙은 기능(`%R~`/`%W~`, selector, subcommand allow/block)은 branch 적용 전 배포 Redis 버전 확인 필요.
- `reset` 이 이미 `-@all` 을 포함한다는 사실(C5)은 branch 결정 문구의 "`reset -@all`" 표현이 강조적 병기(중복 명시)이지 필수 2단계 절차가 아님을 의미 — `/branch-spec` 단계에서 이 표현을 "reset (내부적으로 -@all 포함) 후 필요한 command·key 만 추가 부여"로 다듬을 것을 권고.
- C6 관련 — least-privilege 를 "보안이 아니라 실수 방지"로 단정하는 프레이밍은 이 공식 문서로 뒷받침되지 않으므로, branch-note 본문에 그렇게 쓰지 말 것 (Usage Boundaries 참조).
## Related / 관련
- 같은 주제 다른 official-doc: (아직 없음 — Redis TLS/mTLS 공식 문서, `%R`/`%W` key permission 세부 공식 문서는 추후 별도 수집 후보)
- 이 자료를 인용한 wiki 요약: (생성 시 추가)
@@ -0,0 +1,90 @@
---
title: official-doc / Redis Cluster Specification — Hash Slots, Hash Tags, MOVED/ASK
source_type: official-doc
url: https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/
archive_url:
related_branches: [feature-redis-runtime-role-isolation-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, caching, redis]
created: 2026-07-28
---
# official-doc / Redis Cluster Specification — Hash Slots, Hash Tags, MOVED/ASK
> Layer: `raw/` — 외부 자료(공식 문서 / 대기업 기술 블로그)의 **원문 발췌·출처 기록**.
> 본 문서는 `raw/official-docs/` 카테고리.
## source_type 허용값
`official-doc` — Redis 공식 레퍼런스 사양 문서 (`redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/`).
## Parent / 활용 branch (필수, 최소 1개+)
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-redis-runtime-role-isolation-contract]] | Redis key model 에서 hash tag `{...}` 로 원자 연산에 필요한 최소 key group 만 co-locate 하고, Cluster 는 database 0 만 사용하므로 DB number 를 격리 수단으로 쓸 수 없다는 결정의 근거 |
## 출처
- 원본 URL: https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/
- 아카이브 URL: (미제공)
- 저자 / 조직: Redis (공식 문서)
- 발행일: (문서에 명시된 발행일 없음 — "continuously synchronized with the actual implementation of Redis" 로 계속 갱신되는 living spec)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
Redis Cluster 는 key space 를 16384 hash slot 으로 나누고 CRC16(key) mod 16384 로 slot 을 결정하며, multi-key 연산은 모든 key 가 같은 slot 에 있어야 한다. hash tag `{...}` 는 이 제약을 우회해 co-location 을 강제하는 유일한 공식 메커니즘이다. 또한 Cluster 는 database 0 만 지원하고 `SELECT` 를 허용하지 않는다 — 이는 `feature-redis-runtime-role-isolation-contract` 가 "DB number 를 role 격리 수단으로 쓸 수 없고, key prefix + hash tag 로만 격리해야 한다"고 결정한 직접 근거다.
## 핵심 인용
> [§Key distribution model] "Each master node in a cluster handles a subset of the 16384 hash slots." (line 146)
> [§Key distribution model] "HASH_SLOT = CRC16(key) mod 16384" (line 156)
> [§Hash tags] "Then instead of hashing the key, only what is between the first occurrence of `{` and the following first occurrence of `}` is hashed." (line 195)
> [§Implemented subset] "all of the keys involved in the operation hash to the same slot." (line 36 — 전체 문장: "Commands performing complex multi-key operations like set unions and intersections are implemented for cases where all of the keys involved in the operation hash to the same slot.")
> [§Implemented subset] ``"We only support database `0`; the [`SELECT`](/commands/select) command is not allowed."`` (line 44)
> [§MOVED Redirection] "-MOVED 3999 127.0.0.1:6381" / "The error includes the hash slot of the key (3999) and the endpoint:port of the instance that can serve the query." (line 381, 383)
> [§ASK redirection] "If ASK redirection is received, send only the query that was redirected to the specified node but continue sending subsequent queries to the old node." (line 549)
## Claims Extracted
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| REDIS-CLUSTER-C1 | Redis Cluster 의 key space 는 16384 hash slot 으로 나뉘고, 각 master 는 그 부분집합을 담당하며, key → slot 매핑은 고정 공식 `HASH_SLOT = CRC16(key) mod 16384` 를 따른다(hash tag 예외 제외) | [§Key distribution model] "Each master node in a cluster handles a subset of the 16384 hash slots." / "HASH_SLOT = CRC16(key) mod 16384" | `official-vendor-doc` | Redis Cluster (OSS/Stack) 모드의 key routing 전반 | slot 수·매핑 공식이 애플리케이션에서 변경 가능하다는 것을 증명하지 않음 — 고정 알고리즘임 |
| REDIS-CLUSTER-C2 | key 안에 `{` 와 그 뒤 첫 `}` 사이에 1글자 이상 있으면, hash slot 계산 시 그 substring 만 해싱한다 (hash tag) | [§Hash tags] "Then instead of hashing the key, only what is between the first occurrence of `{` and the following first occurrence of `}` is hashed." | `official-vendor-doc` | 여러 key 를 같은 slot 에 강제 co-locate 시켜야 하는 모든 상황(트랜잭션, MSET, Lua, atomic 연산) | hash tag 내부에 어떤 문자열을 넣어야 하는지(네이밍 컨벤션)는 애플리케이션 책임 — 문서가 규정하지 않음 |
| REDIS-CLUSTER-C3 | set 합집합/교집합 같은 복잡한 multi-key 연산은 관련된 모든 key 가 같은 slot 으로 해싱되는 경우에만 구현되어 있다(지원된다) | [§Implemented subset] "all of the keys involved in the operation hash to the same slot." | `official-vendor-doc` | Redis Cluster 에서 multi-key 명령·Lua script·트랜잭션 설계 시 key 그룹핑 제약 | 어떤 명령이 "multi-key" 로 분류되는지 전체 목록까지 증명하지는 않음(본 인용 범위 밖) |
| REDIS-CLUSTER-C4 | Redis Cluster 는 standalone 과 달리 다중 database 를 지원하지 않으며 database `0` 만 사용하고 `SELECT` 명령은 허용되지 않는다 | [§Implemented subset] ``"We only support database `0`; the [`SELECT`](/commands/select) command is not allowed."`` | `official-vendor-doc` | Redis **Cluster 모드**에서 DB number 기반 격리 불가 판단 | standalone/non-cluster Redis 의 다중 DB 지원 여부는 이 claim 범위 밖(별개 진술) |
| REDIS-CLUSTER-C5 | MOVED 는 slot 이 영구적으로 다른 노드로 재배치됐다는 의미이며 `-MOVED <slot> <endpoint:port>` 형태로 응답하고, 클라이언트는 해당 endpoint 로 재요청해야 한다 | [§MOVED Redirection] "-MOVED 3999 127.0.0.1:6381" / "The error includes the hash slot of the key (3999) and the endpoint:port of the instance that can serve the query." | `official-vendor-doc` | Redis Cluster 클라이언트의 redirection 처리 로직 설계 | 구체적 클라이언트 라이브러리(Lettuce/Jedis 등)의 실제 구현 세부는 증명하지 않음 |
| REDIS-CLUSTER-C6 | ASK 는 MOVED 와 달리 "다음 한 번의 query 만" 지정된 노드로 보내고 이후 query 는 계속 이전 노드로 보내는 **일회성** 리다이렉션이다(슬롯 마이그레이션 중 사용) | [§ASK redirection] "If ASK redirection is received, send only the query that was redirected to the specified node but continue sending subsequent queries to the old node." | `official-vendor-doc` | resharding(슬롯 마이그레이션) 중 발생하는 일시적 리다이렉션 처리 설계 | 애플리케이션 레벨 재시도 정책 전체를 규정하지 않음 — ASK/ASKING 처리 의미만 규정 |
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `REDIS-CLUSTER-C1`: hash slot 총량(16384)과 고정 매핑 공식(CRC16 mod 16384)
- `REDIS-CLUSTER-C2`: hash tag `{...}` 문법의 정확한 파싱 규칙
- `REDIS-CLUSTER-C3`: multi-key 연산이 "같은 slot" 제약 하에서만 지원된다는 사실
- `REDIS-CLUSTER-C4`: Cluster 모드가 database 0 만 지원하고 `SELECT` 를 금지한다는 사실
- `REDIS-CLUSTER-C5`, `REDIS-CLUSTER-C6`: MOVED(영구) vs ASK(일회성) 리다이렉션의 의미 차이
- 이 자료가 증명하지 않는 것:
- key prefix + hash tag 조합을 role(cache/coordination/session) 격리 전략으로 채택하는 것이 "모범 사례"라는 것 — 이는 프로젝트 내부 설계 결정(branch-local decision)이며, 이 공식 문서는 "DB number 로는 격리 불가"라는 제약만 증명함
- 특정 클라이언트 라이브러리(Lettuce, Jedis, redis-py 등)가 MOVED/ASK 를 어떻게 캐싱·재시도하는지의 세부 구현
- Redis Stack의 모든 모듈(RedisJSON, RediSearch 등)이 Cluster 모드에서 동일하게 동작하는지 여부
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- ca-skeleton 이 실제로 사용할 Redis Cluster 설정(단일 노드 vs 실제 cluster)에서 hash tag 적용 대상 key 목록 확정 필요
- Spring Data Redis / Lettuce 클라이언트가 MOVED/ASK 를 투명하게 처리하는지 별도 공식 문서로 검증 필요 (`raw/official-docs/` 에 별도 등록 대상)
## 메모
- 인용 1 해석 후보 (미검증): key prefix(namespace) 는 co-location 을 보장하지 않으므로, role 별 격리는 prefix + hash tag 조합이 필요할 것으로 보임 — 이는 branch-local 결정이며 이 문서만으로 증명되지 않음.
- 추가로 봐야 할 동일 출처 페이지: Redis 공식 `CLUSTER SETSLOT`/`CLUSTER SLOTS`/`MIGRATE` 명령 레퍼런스 페이지(본 spec 문서가 링크만 걸고 상세 옵션은 별도 페이지에 위임함), maxmemory-policy/eviction 공식 문서(본 branch 범위의 "배포 설정 소유" 항목과 관련).
## 관련
- 같은 주제 다른 official-doc / company-tech-blog: (아직 없음 — Spring Data Redis / Lettuce 클러스터 클라이언트 문서 등록 후 링크 예정)
- 이 자료를 인용한 wiki 요약: (생성 시 링크)
@@ -0,0 +1,96 @@
---
title: "official-doc / Redis — Scripting with Lua (atomicity, script cache/EVALSHA/NOSCRIPT, KEYS/ARGV, SCRIPT KILL)"
source_type: official-doc
url: https://redis.io/docs/latest/develop/programmability/eval-intro/
archive_url:
related_branches: [feature-redis-atomic-program-catalog-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, persistence, redis]
created: 2026-07-28
---
# Redis — Scripting with Lua (atomicity, script cache/EVALSHA/NOSCRIPT, KEYS/ARGV, SCRIPT KILL)
> Layer: `raw/official-docs/` — Redis 공식 reference ("Scripting with Lua" / eval-intro) 원문 발췌.
> ca-skeleton `feature-redis-atomic-program-catalog-contract` 의 "다단계 read-decide-write 는 EVALSHA 기반 버전 있는 atomic program 으로만 제공" 결정 근거.
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-redis-atomic-program-catalog-contract]] | 다단계 read-decide-write 는 EVALSHA 로 실행하는 버전 있는 atomic program 으로만 제공하고, NOSCRIPT 시 안전 재적재하며, bounded execution(유한 실행) 규칙을 둔다 |
## 출처
- 원본 URL: https://redis.io/docs/latest/develop/programmability/eval-intro/
- 아카이브 URL: (미수집)
- 저자 / 조직: Redis (Redis Ltd. 공식 문서)
- 발행일: Redis 공식 reference, 최신 버전 anchor (7.0 관련 변경 다수 명시 — verbatim 인용 참조)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
Redis Lua 스크립트가 **원자적으로 실행**되고(다른 클라이언트 명령과 인터리빙되지 않음), 스크립트 캐시(SHA1)가 **휘발성**이라 `NOSCRIPT` 발생 시 재적재가 필요하며, 접근하는 key 는 반드시 `KEYS` 인자로 선언해야 한다는 공식 근거를 보존한다. `feature-redis-atomic-program-catalog-contract` 의 EVALSHA + 안전 재적재 + KEYS 선언 규칙 결정을 직접 뒷받침한다.
## 핵심 인용
> [§(intro, "Getting started" 이전)] "Redis guarantees the script's atomic execution. While executing the script, all server activities are blocked during its entire runtime. These semantics mean that all of the script's effects either have yet to happen or had already happened."
> (한글 요약: Redis 는 스크립트의 원자적 실행을 보장한다. 스크립트 실행 중에는 모든 서버 활동이 전체 실행 시간 동안 블로킹된다. 즉 스크립트의 효과는 "아직 발생 안 함" 아니면 "이미 발생함" 둘 중 하나다.)
> [§Getting started] "to ensure the correct execution of scripts, both in standalone and clustered deployments, all names of keys that a script accesses must be explicitly provided as input key arguments. The script should only access keys whose names are given as input arguments. Scripts should never access keys with programmatically-generated names or based on the contents of data structures stored in the database."
> (한글 요약: standalone/clustered 배포 모두에서 스크립트의 정확한 실행을 보장하려면, 스크립트가 접근하는 모든 key 이름은 반드시 입력 key 인자로 명시적으로 제공해야 한다. 스크립트는 입력 인자로 주어진 이름의 key 만 접근해야 하며, 프로그램적으로 생성된 이름이나 DB 저장 데이터 구조의 내용에 기반한 key 는 절대 접근해서는 안 된다.)
> [§Script cache → Cache volatility] "The Redis script cache is always volatile. It isn't considered as a part of the database and is not persisted. The cache may be cleared when the server restarts, during fail-over when a replica assumes the master role, or explicitly by SCRIPT FLUSH."
> (한글 요약: Redis 스크립트 캐시는 항상 휘발성이다. DB 의 일부로 취급되지 않으며 영속화되지 않는다. 서버 재시작, failover 시 replica 의 master 승격, 또는 명시적 SCRIPT FLUSH 호출 시 캐시가 지워질 수 있다.)
> [§Script cache → Cache volatility] "Applications that use scripts should always call EVALSHA to execute them. The server returns an error if the script's SHA1 digest is not in the cache." ... "(error) NOSCRIPT No matching script" ... "In this case, the application should first load it with SCRIPT LOAD and then call EVALSHA once more to run the cached script by its SHA1 sum."
> (한글 요약: 스크립트를 사용하는 애플리케이션은 항상 EVALSHA 로 실행해야 한다. SHA1 다이제스트가 캐시에 없으면 서버는 에러를 반환한다(`NOSCRIPT No matching script`). 이 경우 애플리케이션은 먼저 SCRIPT LOAD 로 적재한 뒤 SHA1 로 EVALSHA 를 다시 호출해야 한다.)
> [§Script cache → EVALSHA in the context of pipelining] "The commands in a pipelined request run in the order they are sent, but other clients' commands may be interleaved for execution between these. Because of that, the NOSCRIPT error can return from a pipelined request but can't be handled. Therefore, a client library's implementation should revert to using plain EVAL of parameterized in the context of a pipeline."
> (한글 요약: 파이프라인 요청 내 명령은 보낸 순서대로 실행되지만 다른 클라이언트의 명령이 그 사이에 인터리빙될 수 있다. 이 때문에 파이프라인에서 발생한 NOSCRIPT 에러는 처리(handle)될 수 없다. 따라서 클라이언트 라이브러리 구현은 파이프라인 맥락에서는 파라미터화된 일반 EVAL 로 되돌아가야 한다.)
> [§The SCRIPT command] "SCRIPT KILL: this command is the only way to interrupt a long-running script (a.k.a slow script), short of shutting down the server. A script is deemed as slow once its execution's duration exceeds the configured maximum execution time threshold. The SCRIPT KILL command can be used only with scripts that did not modify the dataset during their execution (since stopping a read-only script does not violate the scripting engine's guaranteed atomicity)."
> (한글 요약: SCRIPT KILL 은 서버를 종료하지 않고 장시간 실행 중인(slow) 스크립트를 중단시키는 유일한 방법이다. 스크립트는 설정된 최대 실행 시간 임계값을 초과하면 slow 로 간주된다. SCRIPT KILL 은 실행 중 dataset 을 수정하지 않은 스크립트에만 사용 가능하다 — read-only 스크립트를 중단하는 것은 스크립팅 엔진이 보장하는 원자성을 위반하지 않기 때문이다.)
> [§Script replication] "Effects replication: only the script's data-modifying commands are replicated. Replicas then run the commands without executing any scripts. While potentially lengthier in terms of network traffic, this replication mode is deterministic by definition and therefore doesn't require special consideration." ... "In Redis 5.0, effects replication became the default mode. As of Redis 7.0, verbatim replication is no longer supported."
> (한글 요약: Effects replication 은 스크립트의 데이터 변경 명령만 복제한다. Replica 는 스크립트를 실행하지 않고 그 명령만 실행한다. 네트워크 트래픽 면에서 더 길어질 수 있으나, 이 복제 모드는 정의상 결정론적이라 별도 조치가 필요 없다. Redis 5.0 부터 effects replication 이 기본값이 되었고, Redis 7.0 부터는 verbatim replication 이 더 이상 지원되지 않는다.)
## Claims Extracted / 추출된 주장
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| REDIS-EVAL-C1 | Redis 는 스크립트의 원자적 실행을 보장하며, 실행 중 모든 서버 활동이 블로킹되어 스크립트 효과는 "아직 발생 안 함" 또는 "이미 발생함" 둘 중 하나만 관측된다 | "Redis guarantees the script's atomic execution. While executing the script, all server activities are blocked during its entire runtime." | `official-vendor-doc` | 모든 EVAL/EVALSHA 스크립트 실행 (standalone) | 이 페이지는 스크립트 원자성을 MULTI/EXEC 트랜잭션과 명시적으로 대조·비교하는 서술을 포함하지 않는다 — 두 메커니즘의 이동성/차이는 본 인용 범위 밖 |
| REDIS-EVAL-C2 | 스크립트 캐시는 항상 휘발성이며 DB 의 일부로 영속화되지 않는다 — 서버 재시작, failover, 명시적 SCRIPT FLUSH 시 지워질 수 있다 | "The Redis script cache is always volatile. ... The cache may be cleared when the server restarts, during fail-over when a replica assumes the master role, or explicitly by SCRIPT FLUSH." | `official-vendor-doc` | 모든 Redis 배포(standalone/replica/cluster)의 스크립트 캐시 | 클러스터 각 노드별 캐시 상태가 서로 독립적으로 유실될 수 있는지의 세부 동작까지는 명시하지 않음 |
| REDIS-EVAL-C3 | 애플리케이션은 항상 EVALSHA 로 실행해야 하며, SHA1 다이제스트가 캐시에 없으면 서버는 `NOSCRIPT` 에러를 반환한다. 이 경우 클라이언트는 SCRIPT LOAD 로 재적재 후 EVALSHA 를 재시도해야 한다 | "Applications that use scripts should always call EVALSHA to execute them. The server returns an error if the script's SHA1 digest is not in the cache." / "(error) NOSCRIPT No matching script" / "the application should first load it with SCRIPT LOAD and then call EVALSHA once more" | `official-vendor-doc` | EVALSHA 실행 경로 + NOSCRIPT 복구 절차 일반 | 구체적인 client 라이브러리(예: Lettuce/Jedis)가 이 재적재를 자동화하는지는 "대부분의 클라이언트가 유틸리티 API 를 제공한다"고만 언급 — 특정 라이브러리 API 는 본 인용 범위 밖 |
| REDIS-EVAL-C4 | 파이프라인 컨텍스트에서는 다른 클라이언트 명령이 인터리빙될 수 있어 `NOSCRIPT` 에러가 발생해도 처리(handle)할 수 없으므로, 클라이언트 라이브러리는 파이프라인 안에서는 파라미터화된 일반 EVAL 로 되돌아가야(fallback) 한다 | "the NOSCRIPT error can return from a pipelined request but can't be handled. Therefore, a client library's implementation should revert to using plain EVAL of parameterized in the context of a pipeline." | `official-vendor-doc` | pipelined EVALSHA 실행 시나리오 | 이것이 client 라이브러리의 필수 구현 요건인지 권고인지의 구속력(should = 권고) 강도까지만 확인됨 — RFC 수준 MUST 아님 |
| REDIS-EVAL-C5 | standalone/clustered 배포 모두에서 정확한 스크립트 실행을 보장하려면, 스크립트가 접근하는 모든 key 이름은 `KEYS` 입력 인자로 명시적으로 제공되어야 하며, 프로그램적으로 생성되거나 DB 데이터 구조 내용에 기반한 key 는 절대 접근해서는 안 된다 | "all names of keys that a script accesses must be explicitly provided as input key arguments. The script should only access keys whose names are given as input arguments. Scripts should never access keys with programmatically-generated names or based on the contents of data structures stored in the database." | `official-vendor-doc` | 모든 EVAL/EVALSHA 스크립트의 key 접근 설계 (cluster 포함) | 미선언 key 를 실제로 접근했을 때의 구체적 에러 코드/거부 동작, 그리고 cluster hash-slot 라우팅 알고리즘 자체는 이 페이지에 명시되지 않음 |
| REDIS-EVAL-C6 | `SCRIPT KILL` 은 서버 종료 없이 장시간 실행 스크립트를 중단하는 유일한 방법이며, 설정된 최대 실행 시간 임계값 초과 시 slow 로 간주되고, dataset 을 수정하지 않은 스크립트에만 사용 가능하다(원자성 보장 위반 방지) | "SCRIPT KILL: this command is the only way to interrupt a long-running script ... A script is deemed as slow once its execution's duration exceeds the configured maximum execution time threshold. The SCRIPT KILL command can be used only with scripts that did not modify the dataset during their execution ..." | `official-vendor-doc` | 장시간 실행(slow) 스크립트의 중단 절차 일반 | `busy-reply-threshold`(구 `lua-time-limit`) 설정 이름, `BUSY` 에러 문자열, 쓰기를 수행한 스크립트에 대한 `SHUTDOWN NOSAVE` 강제 종료 조건은 **본 페이지에서 확인되지 않음** — "maximum execution time" 은 별도 개요 페이지(`develop/programmability/#maximum-execution-time`)로만 링크됨, 별도 fetch 필요 |
| REDIS-EVAL-C7 | Effects replication(Redis 5.0 부터 기본값, Redis 7.0 부터 유일 모드)은 스크립트의 데이터 변경 명령만 복제하고 replica 는 스크립트를 재실행하지 않으며, 이 방식은 정의상 결정론적이라 별도 조치가 불필요하다 | "Effects replication: only the script's data-modifying commands are replicated. Replicas then run the commands without executing any scripts. ... this replication mode is deterministic by definition ..." / "In Redis 5.0, effects replication became the default mode. As of Redis 7.0, verbatim replication is no longer supported." | `official-vendor-doc` | Redis 5.0+ (특히 7.0+) 의 스크립트 복제 모드 | Redis 7.0 미만 버전에서 verbatim replication 을 여전히 사용하는 배포의 결정론 제약(비결정적 명령 제한)은 별도 섹션("Scripts with deterministic writes")에 있으며, ca-skeleton 이 대상으로 하는 Redis 버전이 7.0+ 인지 여부는 본 자료가 증명하지 않음 |
## Usage Boundaries / 적용 경계
- **이 자료가 직접 증명하는 것**:
- `REDIS-EVAL-C1`: 스크립트 실행 중 서버 전체가 블로킹되어 원자적으로 실행됨
- `REDIS-EVAL-C2`, `C3`, `C4`: 스크립트 캐시의 휘발성 + EVALSHA/NOSCRIPT 복구 절차 (단일 요청 및 파이프라인 맥락 모두)
- `REDIS-EVAL-C5`: 스크립트가 접근하는 key 는 반드시 `KEYS` 인자로 선언해야 하며 미선언/동적 생성 key 접근 금지
- `REDIS-EVAL-C6`: `SCRIPT KILL` 은 dataset 미수정 + 실행시간 초과 스크립트에만 적용되는 중단 메커니즘
- `REDIS-EVAL-C7`: Effects replication 이 Redis 5.0+ 기본, 7.0+ 유일 모드이며 결정론적임
- **이 자료가 증명하지 않는 것**:
- `busy-reply-threshold`(구 `lua-time-limit`) 설정 항목, `BUSY` 에러 문자열, 쓰기 스크립트에 대한 `SHUTDOWN NOSAVE` 필요조건 — 이 페이지에는 등장하지 않음(다른 programmability 개요 페이지로만 링크)
- 스크립트 원자성과 `MULTI`/`EXEC` 트랜잭션의 명시적 비교·차이 서술
- Redis Cluster 의 실제 hash-slot 라우팅/정적 분석 알고리즘 detail
- Redis Functions(7.0+ 대안 프로그래밍 모델)의 세부 규칙 — 존재만 언급되고 상세는 별도 문서로 링크됨
- **내 프로젝트(ca-skeleton)에 적용하려면 추가 확인이 필요한 것**:
- "bounded execution(유한 실행)" 규칙의 구체적 서버 설정값(`busy-reply-threshold` 기본값/권장값, `BUSY` 에러 처리 흐름) — `https://redis.io/docs/latest/develop/programmability/#maximum-execution-time` 별도 fetch 필요
- unsafe multi-command recipe ↔ atomic 대체 쌍의 실제 Lua 스크립트 구현 detail은 이 자료 범위 밖 — `feature-redis-atomic-program-catalog-contract` 자체 설계 결정
- Redisson/Jedis/Lettuce 등 실제 사용 클라이언트 라이브러리가 EVALSHA→NOSCRIPT→SCRIPT LOAD 재시도를 자동화하는지 여부는 각 라이브러리 문서에서 별도 확인 필요
## 메모
- `busy-reply-threshold` / `BUSY` / `SHUTDOWN NOSAVE` 관련 인용은 이 페이지(`eval-intro`)에 없음을 self-grep 으로 확인(부재 확인, fabrication 아님) — bounded execution 완전한 근거를 위해 `develop/programmability/` 개요 페이지 추가 수집 필요.
- 인용 6 (`REDIS-EVAL-C7`)의 "결정론/복제" 서술은 branch 결정의 직접 근거는 아니지만, atomic program 이 실행 후 replica 에 어떻게 전파되는지에 대한 배경 지식으로 유용.
## Related / 관련
- 같은 주제 다른 official-doc: (아직 없음 — `develop/programmability/` 개요, `develop/programmability/lua-api` 후속 수집 후보)
- 이 자료를 인용한 wiki 요약: `[[wiki/concepts/...]]` (생성 시)
@@ -0,0 +1,104 @@
---
title: "official-doc / Redis Functions — Library Deployment (FUNCTION LOAD, Shebang, no-writes Flag, EVAL 비교)"
source_type: official-doc
url: https://redis.io/docs/latest/develop/programmability/functions-intro/
archive_url:
related_branches: [feature-redis-atomic-program-catalog-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, caching, redis]
created: 2026-07-28
---
# Redis Functions — Library Deployment (FUNCTION LOAD, Shebang, no-writes Flag, EVAL 비교)
> Layer: `raw/official-docs/` — Redis 공식 문서 ("Redis functions" / functions-intro) 원문 발췌.
> ca-skeleton `feature-redis-atomic-program-catalog-contract` 의 "atomic program 기본 배포 모드는 EVAL/EVALSHA script, Redis Functions 는 명시적 opt-in 조건을 만족할 때만" 결정의 근거.
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-redis-atomic-program-catalog-contract]] | atomic program 의 기본 배포 모드는 EVAL/EVALSHA script 이고, Redis Functions 는 명시적 opt-in 조건(Redis 7.0+ 버전 게이트, cluster 전체 노드 수동 배포 부담, `no-writes` 류 flag 명시적 선언)을 만족할 때만 채택한다는 결정의 공식 근거 |
## 출처
- 원본 URL: https://redis.io/docs/latest/develop/programmability/functions-intro/
- 아카이브 URL: (미수집)
- 저자 / 조직: Redis (Redis Ltd. 공식 문서)
- 발행일: 문서에 명시 없음
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
atomic program 카탈로그 branch 에서 EVAL/EVALSHA(기본) 배포 모드와 Redis Functions(opt-in) 배포 모드를 가르는 조건을 정할 때, 공식 문서가 말하는 두 모드의 실질적 차이(스크립트 캐시 vs persistence/replication, 버전 요구사항, library 배포·조회 명령, `no-writes` flag 의미, cluster 배포 오버헤드)를 그대로 근거로 삼기 위해 저장.
## 핵심 인용
> [§Redis functions (도입부)] "This feature, which became available in Redis 7, supersedes the use of [EVAL](https://redis.io/docs/latest/develop/programmability/eval-intro) in prior versions of Redis."
> [§Prologue] "To address these needs while avoiding breaking changes to already-established and well-liked ephemeral scripts, Redis v7.0 introduces Redis Functions."
> [§Prologue] "By design, Redis only caches the loaded scripts."
> [§Prologue] "That means that the script cache can become lost at any time, such as after calling [`SCRIPT FLUSH`](https://redis.io/docs/latest/commands/script-flush), after restarting the server, or when failing over to a replica."
> [§Prologue] "The application is responsible for reloading scripts during runtime if any are missing."
> [§What are Redis Functions?] "Functions are also persisted to the AOF file and replicated from master to replicas, so they are as durable as the data itself."
> [§What are Redis Functions?] "Like all other operations in Redis, the execution of a function is atomic."
> [§What are Redis Functions?] "Because running a function blocks the Redis server, functions are meant to finish executing quickly, so you should avoid using long-running functions."
> [§Loading libraries and functions] "the library payload must start with Shebang statement that provides a metadata about the library (like the engine to use and the library name)."
> [§Loading libraries and functions] "#!<engine name> name=<library name>"
> [§Loading libraries and functions] "Notice that the [`FUNCTION LOAD`](https://redis.io/docs/latest/commands/function-load) command returns the name of the loaded library, this name can later be used [`FUNCTION LIST`](https://redis.io/docs/latest/commands/function-list) and [`FUNCTION DELETE`](https://redis.io/docs/latest/commands/function-delete)."
> [§Functions in cluster] "As noted above, Redis automatically handles propagation of loaded functions to replicas."
> [§Functions in cluster] "In a Redis Cluster, it is also necessary to load functions to all cluster nodes. This is not handled automatically by Redis Cluster, and needs to be handled by the cluster administrator (like module loading, configuration setting, etc.)."
> [§Function flags] "By default, Redis assumes that all functions may perform arbitrary read or write operations. Function Flags make it possible to declare more specific function behavior at the time of registration."
> [§Function flags] "In these cases, you can add the `no-writes` flag to the function's registration, disable the safeguard and allow them to run."
> [§Function flags] "For the complete documentation flags, please refer to [Script flags](https://redis.io/docs/latest/develop/programmability/lua-api#script_flags)."
## Claims Extracted
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| REDIS-FN-C1 | Redis Functions 는 Redis 7(v7.0)에서 도입되었고, 이전 버전의 EVAL 사용을 대체(supersede)한다 | [§Redis functions] "became available in Redis 7, supersedes the use of EVAL ... in prior versions" / [§Prologue] "Redis v7.0 introduces Redis Functions" | `official-vendor-doc` | Functions 채택의 최소 버전 게이트(7.0+) 판단 | 6.x 이하에서 FUNCTION LOAD 호출 시 정확한 실패 모드(에러 메시지)는 미서술. ca-skeleton 실제 배포 Redis 버전이 7.0 이상인지는 이 자료로 확인 불가 — 별도 확인 필요 |
| REDIS-FN-C2 | EVAL/EVALSHA 스크립트는 서버가 "캐시"로만 취급해 SCRIPT FLUSH·재시작·failover 시 소실될 수 있고 재적재는 애플리케이션 책임인 반면, Functions 는 데이터베이스의 first-class 아티팩트로 AOF persistence + replication 을 통해 데이터와 동일하게 지속(durable)된다 | "By design, Redis only caches the loaded scripts." / "script cache can become lost at any time... application is responsible for reloading" / "Functions are also persisted to the AOF file and replicated..." | `official-vendor-doc` | EVAL 배포 시 NOSCRIPT 재적재가 왜 애플리케이션 책임인지의 공식 근거; Functions 채택 시 이 부담이 서버 측으로 이전됨을 뒷받침 | AOF 가 비활성화된 배포(`appendonly no`)에서도 이 durability 가 보장되는지는 조건부 서술 없음 — 별도 확인 필요 |
| REDIS-FN-C3 | Function library 는 `#!<engine name> name=<library name>` 형식 Shebang 선언으로 시작해야 하며 최소 1개 이상의 등록된 함수가 필요하다. `FUNCTION LOAD` 는 라이브러리를 적재하고 이름을 반환하며, 이후 `FUNCTION LIST`/`FUNCTION DELETE` 로 조회·삭제한다 | "library payload must start with Shebang statement..." / "#!<engine name> name=<library name>" / "FUNCTION LOAD command returns the name... used FUNCTION LIST and FUNCTION DELETE" | `official-vendor-doc` | library 이름·엔진 선언 규칙, 배포·조회 명령 카탈로그 구현 가이드 근거 | `FUNCTION DUMP` / `FUNCTION RESTORE` 명령은 이 문서(functions-intro)에 전혀 등장하지 않는다 — 별도 command reference 확인 없이 이 자료만으로 DUMP/RESTORE 워크플로우를 정당화할 수 없음 |
| REDIS-FN-C4 | 함수 실행은 원자적이며 실행되는 동안 서버의 모든 활동을 블로킹한다(트랜잭션과 유사한 시맨틱). 따라서 함수는 빠르게 끝나야 하고 long-running 함수는 피해야 한다 | "the execution of a function is atomic." / "Because running a function blocks the Redis server, functions are meant to finish executing quickly, so you should avoid using long-running functions." | `official-vendor-doc` | bounded-execution(유한 실행, 무한 루프·대량 스캔 금지) 요구의 공식 근거 — EVAL 과 동일한 블로킹 제약을 Functions 도 그대로 가진다는 근거 | "빠르게"의 구체적 시간 임계값(ms 단위)은 문서에 없음 — 별도 설정(예: 실행 시간 제한 설정) 문서 확인 필요 |
| REDIS-FN-C5 | 기본적으로 모든 함수는 read/write 모두 가능하다고 간주되어 read-only replica 에서 실행이 차단된다. 등록 시 `no-writes` flag 를 명시해야 이 안전장치가 해제되고 `FCALL_RO` 로 read-only replica 에서 실행 가능해진다. 이 문서는 `no-writes` 외 flag 의 완전한 목록은 별도 "Script flags" 문서를 참조하라고 안내한다 | "Redis assumes that all functions may perform arbitrary read or write operations..." / "you can add the `no-writes` flag..." / "For the complete documentation flags, please refer to Script flags." | `official-vendor-doc` | `no-writes` flag 의 의미와 등록 API 모양(named-args 변형) | `allow-oom` / `no-cluster` / `allow-stale` 등 다른 flag 의 의미는 이 문서에 등장하지 않는다 — 별도 "Lua API — Script flags" 페이지를 raw 로 등록 후에만 인용 가능. 이 claim 만으로 다른 flag 를 정의할 수 없음 |
| REDIS-FN-C6 | Redis Cluster 환경에서 함수는 replica 로는 자동 전파되지만, cluster 내 모든(마스터) 노드에 함수를 적재하는 것은 자동화되지 않으며 cluster 관리자가 별도로(module loading·설정 변경과 마찬가지로) 처리해야 한다 | "Redis automatically handles propagation of loaded functions to replicas." / "it is also necessary to load functions to all cluster nodes. This is not handled automatically by Redis Cluster, and needs to be handled by the cluster administrator" | `official-vendor-doc` | cluster 배포 시 Functions 채택에 따르는 운영 오버헤드 — opt-in 조건 판단 근거 중 하나 | EVAL/EVALSHA 스크립트가 cluster 각 노드에 배포될 때 상대적으로 더 쉬운지/어려운지는 이 문서에 비교 서술이 없음 — "Functions 가 EVAL 보다 cluster 배포가 더 번거롭다"는 상대적 결론은 이 자료만으로 내릴 수 없음 |
## Usage Boundaries
- 적용 가능한 범위:
- Redis Functions 와 EVAL/EVALSHA 의 지속성(persistence)·복제(replication) 차이를 branch 결정의 공식 근거로 사용
- Redis 7.0+ 버전 요구사항을 Functions opt-in 조건의 최소 버전 게이트로 사용
- library Shebang 선언 규칙(`#!<engine> name=<library>`), `FUNCTION LOAD`/`FUNCTION LIST`/`FUNCTION DELETE` 명령 의미를 구현 가이드에 직접 인용
- `no-writes` flag 의 정확한 의미와 등록 API 모양(named-args 변형)을 그대로 인용
- cluster 배포 시 수동 전파 필요성을 opt-in 조건(cluster 운영 오버헤드) 근거로 사용
- 적용 금지 (본 자료만으로 정당화 불가):
- `FUNCTION DUMP` / `FUNCTION RESTORE` 명령의 동작 — 이 문서에 없음. 별도 command reference 확인 후 별도 인용 필요
- `allow-oom` / `no-cluster` / `allow-stale``no-writes` 외 flag 의 의미 — 별도 "Lua API — Script flags" 문서 확인 필요
- "Functions 가 EVAL 보다 항상 낫다"는 식의 일반화된 평가 — 문서는 EVAL 의 한계와 Functions 의 지속성 이점을 서술하지만, Functions 도 "실행 중 서버 전체를 블로킹"하며 "빠르게 끝나야 한다"는 EVAL 과 동일한 제약을 그대로 가진다는 점도 함께 서술한다("항상 우월"이라는 표현은 원문에 없음)
- ca-skeleton 의 실제 Redis 배포 버전이 7.0 이상인지 여부 — 이 자료는 일반 버전 요구사항만 서술하며, 프로젝트 실제 환경 검증은 별도 확인 필요
## 메모
- 이 문서(functions-intro)는 flag 전체 목록을 다루지 않는다 — `allow-oom`/`no-cluster`/`allow-stale` 확인이 필요해지면 Lua API "Script flags" 페이지를 별도 `raw/official-docs/`로 등록해야 한다.
- `FUNCTION DUMP`/`FUNCTION RESTORE` 확인이 필요해지면 별도 command reference 페이지(`redis.io/docs/latest/commands/function-dump`, `function-restore`)를 별도 raw 문서로 등록해야 한다.
- 같은 branch 근거로 이미 등록된 `[[raw/official-docs/redis-eval-scripting-atomicity]]` (eval-intro) 와 짝을 이뤄, "EVAL 기본 vs Functions opt-in" 비교의 양쪽 절반을 구성한다.
## Related
- [[raw/official-docs/redis-eval-scripting-atomicity]] — 같은 branch 근거, EVAL/Lua 스크립팅 원자성·NOSCRIPT 재적재 쪽 공식 문서 (opt-in 비교의 EVAL 쪽 절반)
- 같은 주제 다른 official-doc 후보 (미등록): Redis "Lua API — Script flags" 페이지 (`no-writes` 외 전체 flag 목록), Redis "FUNCTION DUMP"/"FUNCTION RESTORE" command reference
- 이 자료를 인용한 wiki 요약: (아직 생성 안 됨)
@@ -0,0 +1,91 @@
---
title: "official-doc / Redis INCR — Atomicity & Rate Limiter Pattern Race Condition"
source_type: official-doc
url: https://redis.io/docs/latest/commands/incr/
archive_url:
related_branches: [feature-redis-atomic-program-catalog-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, caching, redis, rate-limit]
created: 2026-07-28
last_reviewed: 2026-07-28
status: raw
confidence: high
vendor: Redis
author: Redis (official docs)
published:
---
# Redis INCR — Atomicity & Rate Limiter Pattern Race Condition
> Layer: `raw/official-docs/` — Redis 공식 커맨드 레퍼런스(`INCR`) 원문 발췌·출처 기록.
> 검증된 요약은 `/ingest``wiki/concepts/``source-summary-template` 형식으로 별도 작성. 원본은 raw에 영구 보관.
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-redis-atomic-program-catalog-contract]] | 각 atomic program helper 는 흔한 unsafe multi-command recipe 와 그 안전한 atomic 대체를 쌍으로 문서화한다 — 대표 사례 `GET → 판단 → INCR → EXPIRE` 의 race condition 을 공식 문서가 명시적으로 서술한다는 근거 |
## 출처 / Source
- 원본 URL: https://redis.io/docs/latest/commands/incr/
- 아카이브 URL: (미제공)
- 저자 / 조직: Redis (공식 커맨드 레퍼런스)
- 발행일: (페이지에 명시 없음 — `since: 1.0.0` 커맨드 메타데이터만 존재)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지 / Why archived
`feature-redis-atomic-program-catalog-contract` branch 는 "다단계 read-decide-write recipe 는 항상 unsafe 버전과 atomic 대체를 쌍으로 문서화한다"는 프로젝트 결정(`DEC-CA-SKELETON-OPERATIONAL-CONTRACT-REDIS-PROGRAM-001`)을 구현해야 한다. Redis 공식 `INCR` 문서의 "Pattern: rate limiter" 절이 바로 이 다단계 recipe (`GET` → 판단 → `INCR` → 조건부 `EXPIRE`)의 race condition 을 공식적으로 서술하고, `MULTI`/`EXEC` 및 Lua(`EVAL`) 대체를 명시적으로 권고하므로 1차 근거로 확보한다.
## 핵심 인용 / Key quotes (verbatim)
> [§Details > Pattern: counter] "The counter pattern is the most obvious thing you can do with Redis atomic increment operations."
> (lines 508509 in fetched text)
> [§Details > Pattern: rate limiter > Pattern: rate limiter 1] "Note the used of `MULTI` and `EXEC` in order to make sure that we'll both increment and set the expire at every API call."
> (lines 565566 in fetched text)
> [§Details > Pattern: rate limiter > Pattern: rate limiter 2] "**In the above code there is a race condition**."
> (line 593 in fetched text)
> [§Details > Pattern: rate limiter > Pattern: rate limiter 2] "If for some reason the client performs the `INCR` command but does not perform the `EXPIRE` the key will be leaked until we'll see the same IP address again."
> (lines 594595 in fetched text)
> [§Details > Pattern: rate limiter > Pattern: rate limiter 2] "This can be easily fixed by turning the `INCR` with optional `EXPIRE` into a Lua script that is then sent using the `EVAL` command (only available since Redis version 2.6)."
> (lines 597599 in fetched text)
## Claims Extracted / 추출된 주장
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| REDIS-INCR-C1 | `INCR` 은 "atomic increment operation" 으로 서술된다 — 단일 `INCR` 호출 자체는 원자적이다 | [§Pattern: counter] "The counter pattern is the most obvious thing you can do with Redis atomic increment operations." | `official-vendor-doc` | 단일 `INCR` 커맨드 호출 자체의 원자성 (per-command atomicity) | 여러 명령(`GET`→판단→`INCR``EXPIRE`)을 조합한 시퀀스 *전체* 가 원자적이라는 것 — 오히려 본 문서가 뒤에서 이를 반박(REDIS-INCR-C2) |
| REDIS-INCR-C2 | "GET 으로 현재값 확인 → 조건부 `INCR` → 첫 증가 시에만 `EXPIRE`" 형태의 rate limiter 구현("Pattern: rate limiter 2")에 대해 공식 문서가 명시적으로 race condition 존재를 선언한다 | [§Pattern: rate limiter 2] "**In the above code there is a race condition**." | `official-vendor-doc` | `GET`/`INCR`/조건부 `EXPIRE` 다단계 recipe 에 race 가 실재한다는 사실 자체 | 다른 언어·클라이언트 구현에서도 항상 동일 race 가 발생한다는 일반화 — 문서는 이 특정 pseudocode 예시에 대해서만 명시함 |
| REDIS-INCR-C3 | 위 race 의 구체적 실패 모드는 "클라이언트가 `INCR` 은 수행했지만 `EXPIRE` 를 수행하지 못한 경우" 이며, 이 경우 같은 IP 를 다시 볼 때까지 key 가 TTL 없이 leak 된다 | [§Pattern: rate limiter 2] "If for some reason the client performs the `INCR` command but does not perform the `EXPIRE` the key will be leaked until we'll see the same IP address again." | `official-vendor-doc` | `INCR` 성공 후 `EXPIRE` 미실행(크래시·네트워크 단절 등) 시 key 누수 시나리오 | 이 leak 이 rate-limit *판정 정확성* 자체를 깨뜨린다는 것 — 문서는 메모리/키 누수로 서술하지, 카운트 오판으로 서술하지 않음 |
| REDIS-INCR-C4 | 공식 문서는 두 가지 다른 recipe 에 두 가지 다른 대체를 권고한다 — (a) 고정 타임스탬프 키 카운터("rate limiter 1")에는 `INCR`+`EXPIRE``MULTI`/`EXEC` 로 묶을 것을, (b) 조건부 분기가 있는 단일 카운터("rate limiter 2")에는 `INCR`+조건부 `EXPIRE` 를 Lua 스크립트로 옮겨 `EVAL` 로 실행할 것을 권고한다 | [§rate limiter 1] "Note the used of `MULTI` and `EXEC` in order to make sure that we'll both increment and set the expire at every API call." + [§rate limiter 2] "This can be easily fixed by turning the `INCR` with optional `EXPIRE` into a Lua script that is then sent using the `EVAL` command (only available since Redis version 2.6)." | `official-vendor-doc` | 두 recipe 각각에 맞는 공식 대체 메커니즘 선택 근거 | `MULTI`/`EXEC` 가 "rate limiter 2" 처럼 읽은 값을 기준으로 분기(`IF value == 1 THEN EXPIRE`)하는 recipe 의 race 도 없앤다는 것 — 문서는 그 recipe 의 fix 로 `MULTI`/`EXEC` 가 아니라 Lua 를 명시적으로 별도 제시함 (조건부 분기 recipe 와 무분기 recipe 는 다른 처방) |
## Usage Boundaries / 적용 경계
- 이 자료가 직접 증명하는 것:
- `REDIS-INCR-C1`: `INCR` 단일 커맨드 자체가 원자적이라는 공식 서술
- `REDIS-INCR-C2`: `GET`→판단→`INCR`→조건부 `EXPIRE` recipe 에 공식 문서가 인정하는 race condition 이 존재한다는 사실
- `REDIS-INCR-C3`: 그 race 의 구체적 실패 모드(EXPIRE 유실 시 key leak)
- `REDIS-INCR-C4`: recipe 종류에 따라 공식이 권고하는 두 가지 다른 대체 메커니즘(MULTI/EXEC vs Lua/EVAL)
- 이 자료가 증명하지 않는 것:
- `MULTI`/`EXEC` 가 조건부 분기(읽은 값에 따라 다음 커맨드를 결정)가 있는 recipe 의 race 도 없앤다는 것 — 문서는 이 경우 Lua 를 명시적으로 제시
- `EVAL`/Lua 스크립트가 모든 Redis 배포 모드(clustered, Redis Functions 등)에서 동일하게 동작한다는 것 — 버전 제약("only available since Redis version 2.6")만 명시
- key leak 이 실제 운영 환경에서 관측 가능한 심각도(메모리 규모·발생 빈도)를 가진다는 것 — 문서는 정성적 서술만 제공, 정량적 근거 없음
- ca-tmpl/ca-skeleton 의 `ScriptDescriptor`/EVALSHA/NOSCRIPT 재적재 같은 구체적 구현 메커니즘 — 이는 본 branch 의 로컬 결정 사항이지 이 공식 문서의 주장이 아님
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- `feature-redis-atomic-program-catalog-contract` 의 unsafe recipe 카탈로그에 이 `GET``INCR`→조건부 `EXPIRE` 패턴을 등재할 때, 대응 atomic 대체를 Lua 스크립트(EVALSHA)로 명시하고 MULTI/EXEC 로 오인하지 않도록 §구현 가이드에서 구분해야 함
- 경합 재현 test 시나리오는 REDIS-INCR-C2/C3 의 실패 모드(첫 INCR 성공 후 EXPIRE 실패)를 인위적으로 유발할 수 있는 fault-injection 지점 확인 필요
## 메모 / Notes
- 본 문서의 "Pattern: rate limiter" 절은 rate limiter 1(고정 타임스탬프 키, 분기 없음 → MULTI/EXEC 로 충분)과 rate limiter 2(단일 카운터, `IF value == 1` 분기 있음 → MULTI/EXEC 로 불충분, Lua 필요)를 **구분**해서 서술한다. branch 의 unsafe-recipe 카탈로그를 작성할 때 이 두 변형을 하나의 "INCR+EXPIRE" 항목으로 뭉뚱그리면 REDIS-INCR-C4 의 구분을 잃는다 — 별개 카탈로그 항목으로 분리 권장.
- 문서는 "Pattern: rate limiter 2" 의 세 번째 대안으로 Redis list(`RPUSH`/`RPUSHX`/`LLEN`) 기반 구현도 제시하며, 여기에도 `EXISTS` 확인과 `MULTI`/`EXEC` 사이의 race 가 있지만 "rare conditions 에서 API call 하나를 놓치는 정도"로 무해하다고 서술한다 — 이는 이번 branch 의 핵심 인용 범위(GET→INCR→EXPIRE) 밖이라 인용에는 포함하지 않았으나, "모든 race 가 치명적인 것은 아니다"라는 대비 사례로 §메모에만 남긴다(검증 안 된 해석이므로 claim 화하지 않음).
## Related / 관련
- 같은 프로젝트 내 Redis 원자성/락 관련 다른 official-doc: [[raw/official-docs/cache-redisson-rlock-vs-setnx]]
- 이 자료를 인용한 wiki 요약: (미생성 — `/ingest``wiki/concepts/` 예정)
@@ -0,0 +1,97 @@
---
title: official-doc / Redis — Key Eviction & maxmemory-policy
source_type: official-doc
url: https://redis.io/docs/latest/develop/reference/eviction/
archive_url:
related_branches: [feature-redis-runtime-role-isolation-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, caching, redis]
created: 2026-07-28
---
# official-doc / Redis — Key Eviction & maxmemory-policy
> Layer: `raw/` — 외부 자료(공식 문서 / 대기업 기술 블로그)의 **원문 발췌·출처 기록**.
> 본 템플릿은 `raw/official-docs/``raw/company-tech-blogs/` 두 폴더가 공유.
> 검증된 요약은 `/ingest``wiki/concepts/``source-summary-template` 형식으로 별도 작성. 원본은 raw에 영구 보관.
## source_type 허용값
`source_type: official-doc` — Redis 공식 레퍼런스 문서 (redis.io/docs, vendor-neutral 공식 사양은 아니지만 Redis 프로젝트가 직접 운영하는 1차 문서).
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-redis-runtime-role-isolation-contract]] | Redis role 을 cache(evictable) / coordination(noeviction) / session(noeviction) 으로 분리하고, `maxmemory-policy` 는 deployment(instance) 단위 설정이므로 애플리케이션이 아니라 배포 설정이 소유한다는 결정의 근거 |
## 출처
- 원본 URL: https://redis.io/docs/latest/develop/reference/eviction/
- 아카이브 URL: (미등록)
- 저자 / 조직: Redis (공식 문서, redis.io/docs)
- 발행일: (문서에 명시 없음 — 페이지 내 "Redis 8.6" 기준 최신 갱신 확인)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`maxmemory-policy` 가 Redis 서버(인스턴스) 단위의 설정 directive라는 것과, eviction policy 종류별 의미(특히 `noeviction`이 write 명령을 에러로 거부한다는 것)를 원문으로 확인해, 3-role(cache/coordination/session) 분리와 policy 소유권을 배포 설정으로 두는 결정의 근거로 삼는다.
## 핵심 인용
> [§Eviction policies] "Use the `maxmemory-policy` configuration directive to select the eviction policy you want to use when the limit set by `maxmemory` is reached." (source lines 8081)
> [§Eviction policies] "`noeviction`: Keys are not evicted but the server will return an error when you try to execute commands that cache new data. If your database uses replication then this condition only applies to the primary database. Note that commands that only read existing data still work as normal." (source lines 8588)
> [§Eviction policies] "`volatile-lru`: Evict the least recently used keys that have an associated expiration (TTL)." (source line 93)
> [§LFU eviction] "LFU is approximated like LRU: it uses a probabilistic counter, called a Morris counter to estimate the object access frequency using just a few bits per object, combined with a decay period so that the counter is reduced over time." (source line 243)
> [§Eviction policies] "Note also that if you make good use of key expiration, then you are less likely to run into the cache memory limit because keys will often expire before they need to be evicted." (source lines 120122)
## Claims Extracted
> 이 자료가 **직접 말하는 것만** claim 으로 분리한다. 내 프로젝트에 적용한 결론은 여기 쓰지 않는다.
| Claim ID | Claim | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| REDIS-EVICT-C1 | `maxmemory-policy``maxmemory` 한도 도달 시 적용할 eviction policy 를 선택하는 **configuration directive**다 — `redis.conf` 또는 `CONFIG SET` 으로 설정하는 서버(인스턴스) 단위 값이며, 애플리케이션 코드 API 가 아니다 | [§Eviction policies] "Use the `maxmemory-policy` configuration directive to select the eviction policy you want to use when the limit set by `maxmemory` is reached." | `official-vendor-doc` | 임의 Redis 인스턴스(단일 노드/replica 포함)에서의 eviction policy 설정 경로 | 한 인스턴스 안에서 key 별/role 별로 다른 eviction policy 를 동시 적용할 수 있는지 여부 — 문서가 명시하지 않음 (다만 "하나의 directive" 라는 서술 자체가 인스턴스당 정책이 1개임을 시사) |
| REDIS-EVICT-C2 | `noeviction` policy 에서는 key 가 evict 되지 않고, 대신 `maxmemory` 초과 시 **새 데이터를 캐시하는 명령이 에러를 반환**한다. read-only 명령은 정상 동작한다. replication 환경에서는 이 조건이 primary 에만 적용된다 | [§Eviction policies] "`noeviction`: Keys are not evicted but the server will return an error when you try to execute commands that cache new data. ... Note that commands that only read existing data still work as normal." | `official-vendor-doc` | `maxmemory-policy=noeviction` 이고 `maxmemory` 한도에 도달한 모든 Redis 배포 | 어떤 명령이 정확히 "새 데이터를 캐시하는 명령"에 해당하는지 명령어 단위 목록은 제공하지 않음. `maxmemory=0`(무제한)일 때는 이 조건 자체가 발동하지 않음(이 문서의 다른 절에서 별도 서술) |
| REDIS-EVICT-C3 | `volatile-lru` 는 TTL(만료 시각)이 설정된 key 들 중에서만 least-recently-used 기준으로 evict 한다 — `volatile-*` 계열 policy 는 TTL 이 있는 key 만 eviction 대상으로 삼는다 | [§Eviction policies] "`volatile-lru`: Evict the least recently used keys that have an associated expiration (TTL)." | `official-vendor-doc` | TTL 을 명시적으로 설정한 key 들이 존재하는 상황에서 `volatile-*` 계열 policy 를 쓸 때의 동작 | 이 branch 의 session/coordination role 이 실제로 TTL 을 세팅하는지는 이 문서로 증명되지 않음(프로젝트 구현 사실) |
| REDIS-EVICT-C4 | Redis 의 LFU eviction(및 이와 비교되는 LRU)은 **근사(approximated)** 알고리즘이다 — probabilistic counter(Morris counter)로 접근 빈도를 추정하고 decay 를 적용하는 방식이며, 정확한 전역 순서 계산이 아니다 | [§LFU eviction] "LFU is approximated like LRU: it uses a probabilistic counter, called a Morris counter to estimate the object access frequency using just a few bits per object, combined with a decay period so that the counter is reduced over time." | `official-vendor-doc` | `allkeys-lfu`/`volatile-lfu`(및 대응 LRU policy) 를 사용하는 모든 Redis 배포의 eviction 정확도 특성 | 특정 워크로드에서 hit/miss ratio 에 미치는 정량적 영향은 제공하지 않음. ca-skeleton cache role 의 실측 hit ratio 는 이 문서로 증명되지 않음 |
| REDIS-EVICT-C5 | key expiration(TTL 기반 자동 삭제)을 잘 활용하면 key 가 evict 되기 전에 먼저 만료되므로 memory 한도 초과 상황 자체가 줄어든다 — 즉 "만료(expire)"와 "축출(evict)"은 서로 다른, 별개의 삭제 경로다 | [§Eviction policies] "Note also that if you make good use of key expiration, then you are less likely to run into the cache memory limit because keys will often expire before they need to be evicted." | `official-vendor-doc` | TTL 기반 삭제(expiration)와 memory-pressure 기반 삭제(eviction)를 구분해야 하는 모든 Redis 사용 맥락 | `noeviction` 처럼 애초에 evict 를 하지 않는 policy 에서 TTL 이 필수인지는 이 문장만으로 증명되지 않음(별도로 C2 가 `noeviction` 자체의 동작을 규정) |
### Strength 허용값
- `official-standard` — RFC, 표준 사양, 언어/프로토콜 표준
- `official-vendor-doc` — Spring, Keycloak, AWS, Google, Redis 등 공식 벤더 문서
- `official-reference` — 공식 reference/API 문서
- `company-case-study` — 대기업/실무 기술 블로그의 특정 사례
- `engineering-blog` — 개인/팀 블로그의 엔지니어링 해설
- `tutorial` — 튜토리얼/가이드. 일반화 금지
- `needs-confirmation` — 원문만으로는 적용 판단 불가
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `REDIS-EVICT-C1`: `maxmemory-policy` 는 Redis **인스턴스(서버) 단위** 설정이다 — `redis.conf` 또는 `CONFIG SET` 으로 지정되는 configuration directive 이며 애플리케이션 코드 API 가 아니다. 하나의 directive 라는 서술은 한 인스턴스에 정책이 1개만 존재함을 시사한다.
- `REDIS-EVICT-C2`: `noeviction` 은 write 를 에러로 거부하고 read 는 계속 허용하는 "fail loud" 동작이다 — 데이터 유실보다 명시적 실패를 선호하는 role(coordination/session)에 적합한 근거가 된다.
- `REDIS-EVICT-C3` / `REDIS-EVICT-C5`: eviction(memory-pressure 기반)과 expiration(TTL 기반)은 서로 다른 삭제 메커니즘이며, `volatile-*` policy 는 TTL 있는 key 만 대상으로 삼는다.
- `REDIS-EVICT-C4`: LRU/LFU eviction 은 근사 알고리즘이라 완벽한 전역 순서를 보장하지 않는다 — cache role 설계 시 "정확한 LRU"를 전제하면 안 된다는 근거.
- 이 자료가 증명하지 않는 것:
- Redis 의 **기본(default) `maxmemory-policy` 값**이 무엇인지 이 페이지는 명시하지 않는다(다른 절이 `maxmemory` 기본값 0 만 언급).
- "cache = evictable, coordination/session = noeviction" 이라는 **3-role 매핑 자체**는 이 문서의 용어가 아니다 — Redis 문서는 "cache" 사용 사례를 일반적으로 언급할 뿐, ca-skeleton 의 role 분류 체계(cache/coordination/session)를 알지 못한다. 이 매핑은 branch 의 결정이며, 본 자료는 그 결정을 뒷받침하는 일반 eviction 시맨틱만 제공한다.
- 한 Redis 인스턴스 안에서 key/role 별로 다른 `maxmemory-policy` 를 동시 적용할 수 없다는 것을 **명시적으로 금지**한다고 말하지는 않는다 — `maxmemory-policy` 가 단일 directive 라는 서술에서 **유추**할 뿐이며, 이는 오히려 "다른 정책이 필요한 role 은 별도 Redis 배포로 분리해야 한다"는 branch 결정과 정합적이다.
- `maxmemory`/persistence(RDB·AOF) 소유 경계에 대한 심층 규정은 없다 — replication/persistence buffer 가 `maxmemory` 계산에서 제외된다는 부수적 언급만 있고, 이 문서는 eviction 에 집중한다.
- ca-skeleton 의 실제 Redis 배포(단일 인스턴스 vs 복수 인스턴스, docker-compose/Helm 설정 파일 소유권)가 어떻게 구성되어야 하는지는 이 문서로 확인되지 않는다 — `/branch-spec` 단계에서 구현 결정으로 별도 확정 필요.
## 메모
- 원문에 명시된 evict/expire 구분과 "maxmemory-policy = 인스턴스 단위 directive" 서술은, coordination/session role 이 cache role 과 같은 Redis 인스턴스에 co-locate 될 수 없다는 branch 결정(incompatible co-location 거부)의 방향성과 일치한다 — 단, 이 연결 자체는 branch 의 해석이며 본 문서가 직접 말하는 것은 아니다.
- `allkeys-lru` / `allkeys-lfu` / `allkeys-random` / `volatile-ttl` 등 나머지 policy 종류의 정확한 정의도 같은 페이지 §Eviction policies 목록에 있음 — 필요 시 추가 인용 가능(현재는 5개로 제한).
- Redis 8.6 부터 추가된 LRM(Least Recently Modified) policy 도 같은 페이지에 있으나, 이 branch 결정과 직접 관련 없어 인용에서 제외.
## 관련
- 같은 주제 다른 official-doc: (아직 없음 — Spring Data Redis / Lettuce 커넥션 설정 관련 공식 문서는 `/branch-spec` 단계에서 추가 수집 예정)
- 이 자료를 인용한 wiki 요약: `[[wiki/concepts/...]]` (생성 시)
@@ -0,0 +1,98 @@
---
title: official-doc / Redis Lua API Reference — call vs pcall, typed errors, effects replication, sandbox
source_type: official-doc
url: https://redis.io/docs/latest/develop/programmability/lua-api/
archive_url:
related_branches: [feature-redis-atomic-program-catalog-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, runtime, redis]
created: 2026-07-28
---
# official-doc / Redis Lua API Reference — call vs pcall, typed errors, effects replication, sandbox
> Layer: `raw/` — 외부 자료(공식 문서)의 **원문 발췌·출처 기록**.
> 검증된 요약은 `/ingest``wiki/concepts/`에 별도 작성. 원본은 raw에 영구 보관.
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-redis-atomic-program-catalog-contract]] | atomic program 은 bounded·결정론적이어야 하며, 오류는 typed 로 반환하고, 복제 의미(effects replication)를 이해한 상태에서 작성해야 한다 — 그 제약을 카탈로그 규칙으로 고정한다. |
## 출처
- 원본 URL: https://redis.io/docs/latest/develop/programmability/lua-api/
- 참고: dispatch 에서 지정한 `https://redis.io/docs/latest/develop/interact/programmability/lua-api/` 는 WebFetch 시 `404 Not Found` — 위 대체 URL 로 fetch 성공(본 문서의 `url:` 은 실제 fetch 성공한 URL).
- 아카이브 URL: (미제공)
- 저자 / 조직: Redis, Inc. (공식 레퍼런스 문서 — "Redis Lua API reference")
- 발행일: 문서 자체에 발행일 명시 없음 (버전 미고정 "latest" reference 페이지, 각 API 항목에 `Since version:` / `Until version:` 개별 명시)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-redis-atomic-program-catalog-contract` 는 EVALSHA 기반 atomic program 이 (1) bounded/deterministic 하고 (2) 오류를 typed 로 반환하며 (3) replication 의미(verbatim vs effects)를 이해한 상태로 작성되어야 한다는 카탈로그 규칙을 정의하려 한다. 이 문서는 그 세 축(오류 처리, typed reply, replication 모드)의 1차 공식 근거다.
## 핵심 인용
> [§redis.call] "If and when `redis.call()` triggers a runtime exception, the raw exception is raised back to the user as an error, automatically." (line 127)
> [§redis.pcall] "The `redis.pcall()` function behaves exactly like [`redis.call()`](#redis.call), except that it: * Always returns a reply. * Never throws a runtime exception, and returns in its stead a [`redis.error_reply`](#redis.error_reply) in case that a runtime exception is thrown by the server." (line 149)
> [§redis.error_reply] "The helper accepts a single string argument and returns a Lua table with the _err_ field set to that string." (line 177)
> [§redis.status_reply] "The Lua API represents status replies as tables with a single field, _ok_, set with a simple status string." (line 211)
> [§redis.set_repl] "As of Redis version 7.0, script replication is no longer supported, and the only replication mode available is script effects replication." (line 278)
> [§redis.replicate_commands] "* Until version: 7.0.0" ... "The default, and only script replication mode supported, is script effects' replication." (lines 318, 327)
> [§The KEYS global variable] "The script **should only** access keys whose names are given as input arguments. Scripts **should never** access keys with programmatically-generated names or based on the contents of data structures stored in the database." (lines 8788)
> [§Sandbox context > Global variables and functions] "The sandboxed Lua execution context blocks the declaration of global variables and functions." (line 17)
> [§Sandbox context > Imported Lua modules] "The sandboxed execution context prevents the loading modules by disabling Lua's [`require` function](https://www.lua.org/pil/8.1.html)." (line 65)
> [§redis.setresp] "The default protocol version is _2_, but it can be switched to version _3_." (line 257)
## Claims Extracted
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| REDIS-LUAAPI-C1 | `redis.call()` propagates a runtime exception as a raw error automatically (script aborts); `redis.pcall()` never throws — it always returns a reply, converting the exception into a `redis.error_reply` table instead | [§redis.call] "the raw exception is raised back to the user as an error, automatically." / [§redis.pcall] "Never throws a runtime exception, and returns in its stead a `redis.error_reply`..." | official-reference | EVAL/EVALSHA 기반 ephemeral script 및 Redis Functions 모두 (문서에 "Available in functions: yes" 명시) | 상위 클라이언트 라이브러리(예: Spring Data Redis `RedisScript`)가 이 예외를 어떻게 재래핑하는지는 증명 안 함 |
| REDIS-LUAAPI-C2 | `redis.error_reply(x)` / `redis.status_reply(x)` 는 각각 `{err=...}` / `{ok=...}` Lua table 을 반환하는 공식 helper — 스크립트가 typed error/status 를 반환하는 공식 수단 | [§redis.error_reply] "returns a Lua table with the _err_ field set to that string." / [§redis.status_reply] "tables with a single field, _ok_, set with a simple status string." | official-reference | 모든 EVAL/EVALSHA 스크립트 및 함수의 반환값 설계 | 어떤 error code(첫 단어 convention)를 카탈로그가 강제해야 하는지는 규정 안 함 — 문서는 "convention"일 뿐 "not mandatory"라고 명시 |
| REDIS-LUAAPI-C3 | Redis 7.0 부터 verbatim script replication 이 완전히 제거되고, 오직 effects replication(쓰기 명령의 결과만 복제) 만 지원됨 | [§redis.set_repl] "As of Redis version 7.0, script replication is no longer supported, and the only replication mode available is script effects replication." | official-reference | Redis 7.0+ 배포 환경의 replication 동작 이해 | 6.x 이하에서의 verbatim replication 동작 세부(스크립트 소스 자체가 replica로 전송되는 방식)까지는 이 인용만으로 재구성 불가 — 별도 §redis.set_repl 상단 문단 확인 필요 |
| REDIS-LUAAPI-C4 | `redis.replicate_commands()``3.2.0`~`7.0.0` 구간에만 존재한 API(verbatim→effects 전환 함수)였고, 7.0 이후에는 effects replication 이 기본값이자 유일한 모드가 되어 이 함수 호출이 불필요해짐 | [§redis.replicate_commands] "* Until version: 7.0.0" / "The default, and only script replication mode supported, is script effects' replication." | official-reference | Redis 버전별(7.0 이전 vs 이후) 카탈로그 program 작성 시 `redis.replicate_commands()` 호출 필요 여부 판단 | 비결정적 명령(RANDOMKEY, TIME, SPOP 등)을 program 에서 사용해도 되는지에 대한 명시적 규칙은 **이 페이지에 없음** — 별도 확인 필요 (아래 Usage Boundaries 참고) |
| REDIS-LUAAPI-C5 | atomic program 이 접근하는 key 이름은 반드시 입력 인자(KEYS)로 명시되어야 하며, 프로그램적으로 생성되거나 데이터 내용에 기반한 key 이름 접근은 금지된다 | [§The KEYS global variable] "The script **should only** access keys whose names are given as input arguments. Scripts **should never** access keys with programmatically-generated names..." | official-reference | ScriptDescriptor 의 필수 필드 설계(KEYS 선언 강제) 근거 | 이 규칙은 key 접근 방식에 대한 것이며, program 의 시간/공간 복잡도(O(1)/bounded)를 직접 규정하지 않음 — bounded execution 규칙 자체는 별도 근거 필요 |
| REDIS-LUAAPI-C6 | Lua 샌드박스는 전역 변수/함수 선언과 미정의 전역 변수 접근을 차단한다 — 모든 선언은 `local` 이어야 한다 | [§Sandbox context > Global variables and functions] "The sandboxed Lua execution context blocks the declaration of global variables and functions." | official-reference | 카탈로그 program 작성 코딩 규칙(전역 변수 금지) | 문서 자체가 "sandbox 우회는 실수로는 어렵지만 불가능하지 않다"고 명시 — 우회 시도에 대한 강제력의 절대성까지는 증명 안 함 |
| REDIS-LUAAPI-C7 | Lua 샌드박스는 `require` 를 비활성화해 임의 외부 모듈 로드를 금지하며, 문서가 나열한 runtime 라이브러리(string/table/math/os 일부/struct/cjson/cmsgpack/bit)만 사용 가능하다 | [§Sandbox context > Imported Lua modules] "prevents the loading modules by disabling Lua's `require` function." | official-reference | 카탈로그 program 이 의존할 수 있는 라이브러리 화이트리스트 근거 | Redis Functions 배포 모드에서 라이브러리 제약이 ephemeral script 와 동일한지는 이 인용만으로 확정 안 됨(문서는 이 제약이 sandbox 공통 서술이라고만 함) |
| REDIS-LUAAPI-C8 | `redis.setresp(x)``redis.call()`/`redis.pcall()` 응답의 RESP 버전을 전환할 수 있으며 기본값은 RESP2, 옵트인으로 RESP3 전환 가능 | [§redis.setresp] "The default protocol version is _2_, but it can be switched to version _3_." | official-reference | typed facade 가 `redis.call`/`redis.pcall` 반환값을 파싱할 때 어떤 RESP 버전을 가정해야 하는지 근거 | RESP3 개별 타입(map/set/double/big_number/verbatim_string) 각각의 Lua 변환 세부 규칙까지는 이 인용에 포함하지 않음 — 필요 시 원문 "RESP3 to Lua type conversion" 섹션 추가 확인 |
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `REDIS-LUAAPI-C1`, `C2`: call/pcall 오류 전파 차이 + typed error/status reply 공식 API
- `REDIS-LUAAPI-C3`, `C4`: Redis 7.0 기준 effects replication 이 유일한 모드이고 `redis.replicate_commands()` 는 과거(3.2.0~7.0.0) 호환 함수였다는 사실
- `REDIS-LUAAPI-C5`: KEYS 는 반드시 입력 인자로 선언되어야 한다는 규칙
- `REDIS-LUAAPI-C6`, `C7`: 전역 변수 금지 + `require` 비활성화(라이브러리 화이트리스트)
- `REDIS-LUAAPI-C8`: `redis.setresp()` 기본값/전환 가능 여부
- 이 자료가 증명하지 않는 것:
- **비결정적 명령(RANDOMKEY, TIME, SPOP 등) 사용 시의 규칙** — 이 페이지(`lua-api`)에는 명시적 서술이 없다. `eval-intro` 페이지(`redis.io/docs/latest/develop/programmability/eval-intro`)에서 다룰 가능성이 높으나 본 fetch 범위 밖이다. 카탈로그 문서에서 "비결정적 명령을 program 에서 금지/제한한다"는 규칙을 세우려면 **`UNSUPPORTED_DECISION`** 으로 표시하거나 `eval-intro` 를 별도 raw-source 로 추가 수집해야 한다.
- Redis Functions(`FUNCTION LOAD`) 전용 API(`redis.register_function`, script flags `no-writes`/`allow-oom`/`no-cluster` 등)의 세부 동작 — 본 문서에 존재하나 이번 인용 셋에 포함하지 않았다. Functions 배포 모드를 카탈로그 규칙에 넣으려면 별도 claim 추출 필요.
- RESP3 개별 타입 변환 세부 규칙(map/set/double/big_number/verbatim_string) — 원문에 존재하나 인용 미포함.
- Redis 6.x 이하에서 `redis.replicate_commands()` 를 호출하지 않았을 때의 구체적 오류/폴백 동작.
- 내 프로젝트(ca-skeleton)에 적용하려면 추가 확인이 필요한 것:
- 사용 중인 Redis 서버/클라이언트 버전이 7.0 이상인지 확인 — 이상이면 `redis.replicate_commands()` 호출은 불필요(no-op)하고 effects replication 이 기본.
- typed facade 구현 시 `redis.setresp()` 미호출 상태(RESP2 기본)를 가정해도 되는지 실제 배포 Redis 버전과 클라이언트 설정으로 검증.
## 메모
> 검증되지 않은 추론은 여기까지만. 인용 섹션에는 넣지 않았다.
- 비결정적 명령 규칙 공백(RANDOMKEY/TIME/SPOP)은 `eval-intro` 페이지를 별도 `wiki-source-summarizer` dispatch 로 수집해야 branch-note의 "bounded/deterministic" 결정이 완전히 근거를 갖춘다 — 현재는 부분 근거만 확보된 상태.
- 이 문서 하나로 branch 의 "복제 의미를 이해한 상태에서 작성" 요구는 effects-replication 전환(C3/C4) 근거는 있으나, "왜 그 요구가 필요한가"(replica/AOF 불일치 위험)는 `redis.set_repl` 섹션 상단의 warning 문단(이번 인용에 미포함)에 더 있다 — 필요 시 추가 인용 가능.
## Related
- 같은 branch 의 sibling 근거 (아직 미등록): eval-intro (bounded execution, blocking, 비결정적 명령) — `/branch-spec feature-redis-atomic-program-catalog-contract` 진행 시 추가 수집 권고.
- 이 자료를 인용한 wiki 요약: 아직 없음 (raw 단계).
@@ -0,0 +1,86 @@
---
title: "official-doc / Redis Programmability — Maximum Execution Time (busy-reply-threshold, BUSY, SHUTDOWN NOSAVE)"
source_type: official-doc
url: https://redis.io/docs/latest/develop/programmability/
archive_url:
related_branches: [feature-redis-atomic-program-catalog-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, runtime, redis]
created: 2026-07-28
---
# Redis Programmability — Maximum Execution Time (busy-reply-threshold, BUSY, SHUTDOWN NOSAVE)
> Layer: `raw/official-docs/` — Redis 공식 개요 페이지(`develop/programmability/`, §Maximum execution time) 원문 발췌.
> 자매 문서 `[[raw/official-docs/redis-eval-scripting-atomicity]]``REDIS-EVAL-C6` `Does not prove` 가 명시한 공백(`busy-reply-threshold`/구 `lua-time-limit` 설정 이름, `BUSY` 에러, 쓰기 스크립트의 `SHUTDOWN NOSAVE` 강제 종료 조건)을 닫기 위한 dispatch.
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-redis-atomic-program-catalog-contract]] | atomic program 에 bounded execution(유한 실행) 규칙을 두고, 실행 시간 초과의 사후 대응(서버 설정 + 런북)을 예방 계층과 분리해 상시 병행한다 |
## 출처
- 원본 URL: https://redis.io/docs/latest/develop/programmability/
- 아카이브 URL: (미수집)
- 저자 / 조직: Redis (Redis Ltd. 공식 문서)
- 발행일: Redis 공식 reference, 최신 버전 anchor (본문에 Redis 7.0/2.6.0/5.0 버전 변경 이력 명시)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-redis-atomic-program-catalog-contract` 의 bounded execution 규칙은 "예방"(KEYS 선언·O(1)/유한 루프 설계)과 "사후 대응"(서버가 실제로 시간 초과된 스크립트를 어떻게 처리하는가)이 분리된 계약이다. 자매 문서 `redis-eval-scripting-atomicity.md``SCRIPT KILL` 이 "장시간 실행 스크립트를 중단하는 유일한 방법"이라고만 말하고 임계값 설정 이름·`BUSY` 상태·쓰기 스크립트의 예외를 확인하지 않았다(`REDIS-EVAL-C6` Does not prove). 이 문서는 정확히 그 공백 — 기본 임계값·설정 파라미터명·타임아웃 도달 후 서버 상태 전이(BUSY)·명령별 허용 여부 — 을 원문으로 닫는다.
## 핵심 인용
> [§Maximum execution time] "Scripts are subject to a maximum execution time (set by default to five seconds)."
> [§Maximum execution time] "The configuration parameter affecting max execution time is called busy-reply-threshold."
> [§Maximum execution time] "When a script reaches the timeout threshold, it isn't terminated by Redis automatically."
>
> [§Maximum execution time] "Doing so would violate the contract between Redis and the scripting engine that ensures that scripts are atomic."
> [§Maximum execution time] "It starts accepting commands again from other clients but will reply with a BUSY error to all the clients sending normal commands. The only commands allowed in this state are SCRIPT KILL, FUNCTION KILL, and SHUTDOWN NOSAVE."
> [§Maximum execution time] "If the script had already performed even a single write operation, the only command allowed is SHUTDOWN NOSAVE that stops the server without saving the current data set on disk (basically, the server is aborted)."
## Claims Extracted / 추출된 주장
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| REDIS-PROG-C1 | 스크립트(및 함수)는 기본 5초의 최대 실행 시간 제한을 가지며, 이 값을 제어하는 설정 파라미터 이름은 `busy-reply-threshold`이다(밀리초 단위로 `redis.conf` 또는 `CONFIG SET`으로 변경 가능) | "Scripts are subject to a maximum execution time (set by default to five seconds)." / "The configuration parameter affecting max execution time is called busy-reply-threshold." | `official-vendor-doc` | 모든 EVAL/EVALSHA/FCALL 스크립트·함수 실행의 기본 timeout 설정 | 구 파라미터 이름 `lua-time-limit` 은 본 페이지에 등장하지 않음 — 이름 변경 이력(구명→신명)은 이 자료만으로 증명되지 않음, 별도 changelog/config 문서 확인 필요 |
| REDIS-PROG-C2 | 스크립트가 임계값을 초과해도 Redis 는 자동으로 스크립트를 강제 종료하지 않는다 — 그렇게 하면 스크립트가 원자적이라는 Redis-스크립팅 엔진 간 계약을 위반하게 되기 때문이다 | "When a script reaches the timeout threshold, it isn't terminated by Redis automatically." / "Doing so would violate the contract between Redis and the scripting engine that ensures that scripts are atomic." | `official-vendor-doc` | 시간 초과된 모든 스크립트/함수 실행 일반 | half-written 상태를 실제로 어떤 내부 메커니즘(예: 인터프리터 훅 지점)으로 방지하는지 구현 detail 은 제공하지 않음 |
| REDIS-PROG-C3 | 임계값 초과 후 서버는 다른 클라이언트의 일반 명령에 `BUSY` 에러로 응답하며, 이 상태에서 허용되는 명령은 `SCRIPT KILL`, `FUNCTION KILL`, `SHUTDOWN NOSAVE` 뿐이다. 그중 `SCRIPT KILL`/`FUNCTION KILL` 은 아직 쓰기를 하지 않은(read-only) 스크립트에만 사용 가능하다 | "It starts accepting commands again from other clients but will reply with a BUSY error to all the clients sending normal commands. The only commands allowed in this state are SCRIPT KILL, FUNCTION KILL, and SHUTDOWN NOSAVE." | `official-vendor-doc` | timeout 이후 BUSY 상태의 서버에 대한 클라이언트 명령 허용 목록 | `maxmemory` 초과와 이 BUSY 상태의 상호작용은 본 페이지에서 확인되지 않음(§Usage Boundaries 참조) |
| REDIS-PROG-C4 | 스크립트가 이미 쓰기 연산을 한 번이라도 수행했다면, 그 시점부터 허용되는 명령은 `SHUTDOWN NOSAVE` 뿐이며 이는 현재 데이터셋을 디스크에 저장하지 않고 서버를 정지시킨다(사실상 서버 강제 종료) | "If the script had already performed even a single write operation, the only command allowed is SHUTDOWN NOSAVE that stops the server without saving the current data set on disk (basically, the server is aborted)." | `official-vendor-doc` | 쓰기를 이미 수행한 시간 초과 스크립트/함수에 대한 유일한 복구 경로 | `SHUTDOWN NOSAVE` 이후 재시작 절차, replica/cluster 환경에서의 failover 영향은 본 페이지에 없음 |
## Usage Boundaries / 적용 경계
- **이 자료가 직접 증명하는 것**:
- `REDIS-PROG-C1`: 기본 5초 실행 시간 제한 + 설정 파라미터명 `busy-reply-threshold`
- `REDIS-PROG-C2`: 시간 초과 시 Redis 가 스크립트를 자동 종료하지 않음(원자성 계약 보호 목적)
- `REDIS-PROG-C3`: 시간 초과 후 `BUSY` 에러 상태 + 허용 명령(`SCRIPT KILL`/`FUNCTION KILL`/`SHUTDOWN NOSAVE`) + read-only 스크립트만 `SCRIPT KILL`/`FUNCTION KILL` 로 중단 가능
- `REDIS-PROG-C4`: 쓰기를 수행한 스크립트는 `SHUTDOWN NOSAVE` 만 허용(서버 강제 종료)
- **이 자료가 증명하지 않는 것**:
- **구 파라미터 이름 `lua-time-limit`** — 본 페이지에 전혀 등장하지 않는다(self-grep 부재 확인, fabrication 아님). `busy-reply-threshold` 가 그 후신이라는 이름 변경 이력은 별도 Redis changelog/config 문서 확인 필요.
- **`maxmemory` 와의 상호작용** — "maxmemory 초과 상태에서 추가 메모리를 쓰는 첫 write 명령이 스크립트를 abort 시킨다"는 서술은 본 페이지에 없다(self-grep 부재 확인). `maxmemory-policy` 자체의 일반 의미(예: `noeviction` 이 write 를 에러로 거부)는 `[[raw/official-docs/redis-key-eviction-maxmemory-policy]]` 가 별도로 다루지만, 그 문서 역시 "스크립트 abort" 를 스크립트 맥락에서 명시하지는 않는다 — 두 자료를 합쳐도 "maxmemory 초과 → 스크립트 abort" 인과관계 자체는 증명되지 않는다.
- `SHUTDOWN NOSAVE` 이후의 클러스터/replica 복구 절차, Sentinel/failover 와의 상호작용
- Redis Functions(FUNCTION) 고유의 timeout 규칙 차이 — 이 페이지는 스크립트와 함수를 동일 취급하는 서술("SCRIPT KILL, FUNCTION KILL")만 제공, 세부 차이는 없음
- **내 프로젝트(ca-skeleton)에 적용하려면 추가 확인이 필요한 것**:
- 운영 환경에서 `busy-reply-threshold` 를 기본 5초에서 조정할지 여부와 그 근거(레이턴시 SLO 대비)
- `BUSY` 상태 발생 시 알림/런북 설계 — 이 페이지는 서버 동작만 규정하고 운영 대응 절차는 제공하지 않음
- `maxmemory` 초과와 스크립트 실행의 실제 상호작용은 별도 실험/공식 자료로 검증 필요(현재 미확인 상태로 branch 결정에 반영하지 않을 것)
## 메모
- 이번 dispatch 로 자매 문서 `redis-eval-scripting-atomicity.md``REDIS-EVAL-C6` Does not prove 공백 중 `busy-reply-threshold`/`BUSY`/`SHUTDOWN NOSAVE` 3개는 닫혔다. `lua-time-limit`(구명)과 `maxmemory` 상호작용 2개는 이 페이지에도 없어 여전히 미확인 — 필요하면 별도 config 레퍼런스 또는 릴리스 노트 fetch 후보.
- 추가로 봐야 할 동일 출처 페이지: Redis `redis.conf` 공식 주석(파라미터 changelog), maxmemory 공식 문서의 스크립트 상호작용 여부 재확인.
## Related / 관련
- [[raw/official-docs/redis-eval-scripting-atomicity]] — 이 문서가 닫는 공백을 남긴 자매 문서(`REDIS-EVAL-C6`)
- [[raw/official-docs/redis-key-eviction-maxmemory-policy]] — `maxmemory-policy` 일반 의미(스크립트 맥락은 아님)
- [[raw/official-docs/redis-lua-api-determinism-replication]] — 같은 programmability 문서군, sandbox/replication 세부
- [[raw/official-docs/redis-functions-library-deployment]] — Redis Functions(FUNCTION LOAD 등) 배포 조건
- 이 자료를 인용한 wiki 요약: `[[wiki/concepts/...]]` (생성 시)
@@ -0,0 +1,85 @@
---
title: "official-doc / Redis Replication — Asynchronous Propagation, Stale Reads, WAIT Semantics"
source_type: official-doc
url: https://redis.io/docs/latest/operate/oss_and_stack/management/replication/
archive_url:
related_branches: [feature-redis-runtime-role-isolation-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, persistence, redis, replication]
created: 2026-07-28
---
# official-doc / Redis Replication — Asynchronous Propagation, Stale Reads, WAIT Semantics
> Layer: `raw/` — 외부 자료(공식 문서)의 **원문 발췌·출처 기록**.
> 본 문서는 Redis 공식 replication 문서 원문 발췌. 검증된 요약은 `/ingest``wiki/concepts/`에 별도 작성.
## source_type 허용값
- `official-doc` — Redis 공식 레퍼런스 (redis.io/docs)
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-redis-runtime-role-isolation-contract]] | coordination/session/idempotency/lease/rate role 은 primary read 만 쓰고 stale 을 허용하는 cache region 만 replica read 를 opt-in 한다는 read routing 결정의 근거 |
## 출처
- 원본 URL: https://redis.io/docs/latest/operate/oss_and_stack/management/replication/
- 아카이브 URL: (미등록)
- 저자 / 조직: Redis (공식 문서, redis.io/docs)
- 발행일: (문서 내 명시 없음 — "latest" 채널 문서, 버전 미고정)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
Redis 는 기본적으로 비동기(asynchronous) replication 을 쓰고, replica 는 stale 데이터를 반환할 수 있으며, `WAIT` 명령조차 strong consistency(CP)를 보장하지 않는다는 공식 근거. coordination/session/idempotency/lease/rate 처럼 정확성이 중요한 role 은 replica read 를 쓰면 안 되고 primary read 만 써야 한다는 role-isolation 결정을 뒷받침한다.
## 핵심 인용
> [§Important facts about Redis replication] "Redis uses asynchronous replication, with asynchronous replica-to-master acknowledges of the amount of data processed."
> [§Important facts about Redis replication] "While the replica is performing the initial synchronization, it can handle queries using the old version of the dataset, assuming you configured Redis to do so in redis.conf."
> [§Allow writes only with N attached replicas] "However, because Redis uses asynchronous replication it is not possible to ensure the replica actually received a given write, so there is always a window for data loss."
> [§Read-only replica] "Since Redis 2.6, replicas support a read-only mode that is enabled by default."
> [intro, before §Important facts — WAIT 명령 관련, 원문은 `WAIT``/commands/wait` 로 하이퍼링크 처리함] "However `WAIT` is only able to ensure there are the specified number of acknowledged copies in the other Redis instances, it does not turn a set of Redis instances into a CP system with strong consistency: acknowledged writes can still be lost during a failover, depending on the exact configuration of the Redis persistence."
## Claims Extracted / 추출된 주장
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| REDIS-REPL-C1 | Redis 는 기본적으로 비동기 replication 을 쓰고, replica 는 master 에게 처리한 데이터 양을 비동기적으로 ack 한다 (master 는 매 command 마다 replica 응답을 기다리지 않음) | [§Important facts] "Redis uses asynchronous replication, with asynchronous replica-to-master acknowledges of the amount of data processed." | `official-vendor-doc` | Redis OSS/Stack 기본 replication 설정 전반 (master-replica) | Redis Cluster·Sentinel 이 이 기본 동작 위에 추가하는 failover 세부 동작까지 증명하지 않음(문서가 "이 문서는 기본 replication 특성만 다룬다"고 명시) |
| REDIS-REPL-C2 | replica 는 초기 동기화(initial synchronization) 중에도 설정에 따라 이전 버전의 데이터셋으로 쿼리를 처리할 수 있다 — 즉 replica read 는 stale 데이터를 반환할 수 있는 경로가 존재한다 | [§Important facts] "While the replica is performing the initial synchronization, it can handle queries using the old version of the dataset, assuming you configured Redis to do so in redis.conf." | `official-vendor-doc` | initial full sync 구간의 replica read 동작 | 정상 steady-state(sync 이후) replication lag 에 의한 stale read 크기(초/밀리초 단위)는 이 문장만으로는 정량화되지 않음 — 별도 latency 측정 필요 |
| REDIS-REPL-C3 | 비동기 replication 특성상 특정 write 가 replica 에 실제로 도달했는지 보장할 수 없고, 따라서 항상 data loss 가능 구간(window)이 존재한다 | [§Allow writes only with N attached replicas] "However, because Redis uses asynchronous replication it is not possible to ensure the replica actually received a given write, so there is always a window for data loss." | `official-vendor-doc` | master 장애/failover 시 최근 write 유실 가능성 일반 (min-replicas-to-write 설정 여부와 무관하게 근본 원인) | `min-replicas-to-write` 를 설정하면 이 window 가 "완전히 사라진다"는 뜻은 아님 — 문서는 이 설정도 "best effort" 라고 명시(별도 인용은 미등록, 같은 섹션 서술) |
| REDIS-REPL-C4 | Redis 2.6 이후 replica 는 기본적으로 read-only 모드다 | [§Read-only replica] "Since Redis 2.6, replicas support a read-only mode that is enabled by default." | `official-vendor-doc` | `replica-read-only` 설정을 명시적으로 바꾸지 않은 모든 Redis 2.6+ replica | writable replica 로 전환 가능하다는 사실 자체가 그것이 권장된다는 뜻은 아님(문서는 반대로 "writable replica 는 권장하지 않는다"고 별도 서술) |
| REDIS-REPL-C5 | `WAIT` 명령은 지정한 수의 replica 에 acknowledge 된 복사본이 있음을 보장할 뿐, Redis 인스턴스 집합을 strong consistency 를 갖는 CP 시스템으로 만들지 않는다 — ack 된 write 도 failover 중 유실될 수 있다 | [intro] "it does not turn a set of Redis instances into a CP system with strong consistency: acknowledged writes can still be lost during a failover, depending on the exact configuration of the Redis persistence." | `official-vendor-doc` | `WAIT` 를 사용하는 준-동기(quasi-synchronous) write 패턴 전반 | `WAIT` 가 무의미하다는 뜻은 아님 — 문서는 이어서 "WAIT dramatically reduces the probability of losing a write" 라고 명시. 즉 "손실 확률을 낮추지만 0으로 만들지 않는다"가 정확한 범위 |
### Strength 허용값 참고
전부 `official-vendor-doc` — Redis 공식 문서(redis.io/docs)이며 벤더가 직접 발행. `company-case-study` 아님(회사 사례가 아니라 제품 자체의 공식 동작 명세).
## Usage Boundaries / 적용 경계
- 이 자료가 직접 증명하는 것:
- `REDIS-REPL-C1`~`C5`: Redis 기본 replication 이 비동기이고, replica read 는 stale 을 반환할 수 있으며, `WAIT` 조차 acknowledged write 의 failover 유실을 완전히 막지 못한다는 것. 이는 "정확성이 중요한 role(coordination/session/idempotency/lease/rate) 은 replica read 를 쓰면 안 된다"는 branch 결정의 **일반 원리 근거**로 충분하다.
- 이 자료가 증명하지 않는 것:
- ca-skeleton 이 실제로 어떤 Redis 배포 토폴로지(managed service, Sentinel, Cluster 등)를 쓰는지 — 이 문서는 "basic replication" 만 다루고 Cluster/Sentinel 세부는 별도 문서로 위임한다고 명시.
- stale read 의 정량적 크기(수 ms vs 수 초) — 배포·네트워크 환경에 의존, 이 문서만으로 SLA 수치를 도출할 수 없음.
- cache region 에 replica read 를 opt-in 했을 때 실제 애플리케이션 정확성에 문제가 없다는 것 — 그건 ca-skeleton 이 cache region 을 "stale 허용" 으로 설계했다는 branch-local 결정이지, 이 문서가 그 설계를 승인하는 것이 아님.
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- ca-skeleton 이 사용할 실제 Redis 배포(managed / self-hosted, Sentinel 유무)에서 replication lag 실측치
- `replica-read-only`, `min-replicas-to-write`, `min-replicas-max-lag` 등 세부 설정값이 branch 구현 가이드에 실제로 반영되는지
## 메모
- REDIS-REPL-C3 는 `min-replicas-to-write` best-effort 서술과 함께 읽으면 "설정을 켜도 window 를 줄일 뿐 제거하지 못한다"는 뉘앙스가 더 명확해짐 — 필요하면 해당 서술도 별도 인용으로 추가 가능(미검증 상태로 남겨둠).
- 추가로 봐야 할 동일 출처 페이지: Redis Sentinel 문서(failover 세부), Redis Cluster 문서(샤딩된 환경에서의 replication) — 이 문서는 두 문서로 위임하고 있음.
## Related
- 같은 주제 다른 official-doc: `[[raw/official-docs/redis-lua-api-determinism-replication]]` (Lua effects replication — 다른 주제, replication 메커니즘의 다른 단면)
- 이 자료를 인용한 wiki 요약: (생성 시 추가)
@@ -0,0 +1,81 @@
---
title: official-doc / Redis — TLS (Encryption in Transit)
source_type: official-doc
url: https://redis.io/docs/latest/operate/oss_and_stack/management/security/encryption/
archive_url:
related_branches: [feature-redis-runtime-role-isolation-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, security, redis, mtls]
created: 2026-07-28
---
# official-doc / Redis — TLS (Encryption in Transit)
> Layer: `raw/` — 외부 자료(공식 문서 / 대기업 기술 블로그)의 **원문 발췌·출처 기록**.
> 본 템플릿은 `raw/official-docs/``raw/company-tech-blogs/` 두 폴더가 공유.
> 검증된 요약은 `/ingest``wiki/concepts/``source-summary-template` 형식으로 별도 작성. 원본은 raw에 영구 보관.
## source_type 허용값
- `official-doc` — Redis 공식 레퍼런스 (redis.io/docs, oss_and_stack).
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-redis-runtime-role-isolation-contract]] | production 에서 role 별 Redis 연결에 TLS(mutual TLS 포함) + 명시적 trust bundle(CA cert)을 요구하고, `port 0` + `tls-port` 로 plaintext downgrade 를 startup 에서 원천 차단하며, replication/cluster bus 채널도 별도 TLS 플래그(`tls-replication`/`tls-cluster`)로 각각 켜야 한다는 결정의 근거 |
## 출처
- 원본 URL: https://redis.io/docs/latest/operate/oss_and_stack/management/security/encryption/
- 아카이브 URL: (미등록)
- 저자 / 조직: Redis (공식 문서, redis.io)
- 발행일: (페이지에 명시 없음 — redis.io docs 는 버전별 업데이트, 게시일 미표기)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
Redis 서버 자체가 TLS 를 컴파일 타임 옵션으로 지원하며 기본적으로 켜져 있지 않다는 것, `port 0`/`tls-port` 조합으로 plaintext 를 완전히 차단하는 방법, mutual TLS 가 (TLS 활성화 시) 기본 동작이라는 것, 그리고 replication/cluster 채널은 client-facing `tls-port` 와 별개로 각각 켜야 한다는 것을 검증하기 위해 보관. `feature-redis-runtime-role-isolation-contract` 의 role 별 TLS 강제 결정의 1차 근거.
## 핵심 인용
> [§Getting Started] "SSL/TLS is supported by Redis starting with version 6 as an optional feature" [...] "that needs to be enabled at compile time."
> [§TLS listening port] "You may specify `port 0` to disable the non-TLS port completely."
> [§Client certificate authentication] "By default, Redis uses mutual TLS and requires clients to authenticate with a" [...] "valid certificate (authenticated against trusted root CAs specified by" [...] "`ca-cert-file` or `ca-cert-dir`)." [...] "You may use `tls-auth-clients no` to disable client authentication."
> [§Replication] "On the replica server side, it is necessary to specify `tls-replication yes` to" [...] "use TLS for outgoing connections to the master."
> [§Cluster] "When Redis Cluster is used, use `tls-cluster yes` in order to enable TLS for the" [...] "cluster bus and cross-node connections."
> [§Performance considerations] "TLS adds a layer to the communication stack with overheads due to writing/reading to/from an SSL connection, encryption/decryption and integrity checks." [...] "using TLS results in a decrease of the achievable throughput per Redis instance"
## Claims Extracted
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| REDIS-TLS-C1 | Redis 의 SSL/TLS 지원은 버전 6부터 시작된 컴파일 타임(`BUILD_TLS=yes`) 옵션 기능이며 기본 활성 상태가 아니다 | [§Getting Started] "SSL/TLS is supported by Redis starting with version 6 as an optional feature that needs to be enabled at compile time." | `official-vendor-doc` | Redis OSS/Stack 서버 바이너리가 TLS 를 지원하려면 별도 빌드 플래그가 필요하다는 사실 | 특정 배포판(managed Redis, Docker 공식 이미지 등)이 기본적으로 TLS 지원 빌드인지 여부는 증명하지 않음 — 배포 환경별 확인 필요 |
| REDIS-TLS-C2 | `port 0` 로 non-TLS 포트를 완전히 끄고 `tls-port` 만 열어 plaintext 연결을 원천 차단할 수 있다 | [§TLS listening port] "You may specify `port 0` to disable the non-TLS port completely." | `official-vendor-doc` | production 에서 plaintext downgrade 를 startup 설정으로 차단하는 방법의 근거 | `tls-port` 만으로 인증서 검증 강도(hostname verification 등)까지 보장한다는 뜻은 아님 — 이 문서는 hostname verification 을 언급하지 않음 |
| REDIS-TLS-C3 | TLS 활성화 시 Redis 는 기본적으로 mutual TLS 를 사용하며 신뢰 루트 CA 로 검증된 유효 클라이언트 인증서를 요구하고, 이 요구는 `tls-auth-clients no` 로 명시적으로 꺼야만 해제된다 | [§Client certificate authentication] "By default, Redis uses mutual TLS and requires clients to authenticate with a valid certificate (authenticated against trusted root CAs specified by `ca-cert-file` or `ca-cert-dir`)." + "You may use `tls-auth-clients no` to disable client authentication." | `official-vendor-doc` | 서버가 클라이언트 인증서를 요구하는 기본값(opt-in 아니라 opt-out)이라는 것, trust-all 이 기본이 아니라는 것 | **hostname verification(서버 인증서의 CN/SAN이 접속 hostname과 일치하는지 검증)은 이 문서에 전혀 언급되지 않음** — mutual TLS(클라이언트 인증) 과 hostname verification(서버 인증 강화) 은 별개 개념이며, 후자는 클라이언트 라이브러리(Lettuce/Jedis 등) 설정에서 별도로 검증 필요. `UNSUPPORTED_DECISION` — 이 문서만으로 hostname verification 요구를 정당화할 수 없음 |
| REDIS-TLS-C4 | replication 링크는 client-facing `tls-port`/`tls-auth-clients` 설정을 그대로 상속하지만, replica 가 master 로의 outbound 연결에 TLS 를 쓰려면 replica 측에서 별도로 `tls-replication yes` 를 명시해야 한다 | [§Replication] "On the replica server side, it is necessary to specify `tls-replication yes` to use TLS for outgoing connections to the master." | `official-vendor-doc` | replication 채널이 client 채널과 별개의 명시적 opt-in 플래그를 요구한다는 근거 | 이 프로젝트의 role(cache/coordination/session)별 replication 토폴로지가 실제로 존재하는지, Spring Data Redis 클라이언트 설정에서 이 서버 플래그가 어떻게 매핑되는지는 증명하지 않음 |
| REDIS-TLS-C5 | Redis Cluster 사용 시 `tls-cluster yes` 를 명시해야 cluster bus 와 cross-node 연결에 TLS 가 적용된다 | [§Cluster] "When Redis Cluster is used, use `tls-cluster yes` in order to enable TLS for the cluster bus and cross-node connections." | `official-vendor-doc` | cluster bus 채널이 client-facing TLS 설정과 별개의 명시적 opt-in 플래그를 요구한다는 근거 | 이 프로젝트가 Redis Cluster 토폴로지를 실제로 쓰는지(vs standalone/Sentinel) 는 증명하지 않음 — 이 branch 의 role 분리 결정과 무관하게 배포 토폴로지 별개 확인 필요 |
| REDIS-TLS-C6 | TLS 는 SSL 연결에 대한 write/read, 암복호화, 무결성 검사 오버헤드로 인해 Redis 인스턴스의 처리 가능 처리량(throughput)을 감소시킨다 | [§Performance considerations] "TLS adds a layer to the communication stack with overheads due to writing/reading to/from an SSL connection, encryption/decryption and integrity checks. Consequently, using TLS results in a decrease of the achievable throughput per Redis instance" | `official-vendor-doc` | production 에서 TLS 적용이 성능 trade-off 를 수반한다는 사실 인정의 근거 | 구체적 처리량 감소 수치(%, latency 등)는 이 페이지에 없음 — 문서는 GitHub 논의(#7595) 로 위임, 수치는 별도 벤치마크 필요 |
## Usage Boundaries
- **적용 가능**: production Redis 연결에 대해 (1) `port 0` + `tls-port` 로 plaintext 리스너를 완전히 제거하고, (2) TLS 활성화 시 mutual TLS(클라이언트 인증서)가 기본이며 `tls-auth-clients no` 로만 해제 가능하다는 사실, (3) replication(`tls-replication yes`)과 cluster bus(`tls-cluster yes`)는 client-facing TLS 와 독립적으로 각각 켜야 한다는 사실, (4) TLS 가 처리량 저하 비용을 수반한다는 사실 — 이 4가지는 이 문서만으로 정당화 가능.
- **적용 불가 / UNSUPPORTED_DECISION**: **"hostname verification"을 요구한다는 branch 결정은 이 문서만으로 정당화되지 않는다.** 이 페이지는 mutual TLS(클라이언트→서버 인증서 인증)와 CA trust bundle(`tls-ca-cert-file`)만 다루며, "서버 인증서의 hostname/SNI/CN 매칭"에 대한 서술이 전혀 없다(§본문 self-grep 결과 `hostname`, `verify` 키워드 0건). hostname verification 은 통상 클라이언트 라이브러리(Lettuce `SslOptions.verifyPeer` 등) 레벨 설정이므로, branch 가 이 요구를 유지하려면 별도의 클라이언트 라이브러리 공식 문서를 추가 근거로 확보해야 한다.
- **trust-all 거절**: `tls-auth-clients` 는 서버가 클라이언트를 신뢰할지에 대한 스위치일 뿐, 클라이언트가 서버를 무조건 신뢰(trust-all)하지 않도록 막는 것은 클라이언트 측 설정 영역이라 이 문서 범위 밖.
- **이 프로젝트에 적용하려면 추가 확인 필요**: Spring Data Redis / Lettuce 클라이언트가 이 서버 플래그들(`tls-port`, `tls-auth-clients`, `tls-replication`, `tls-cluster`)에 대응하는 자체 설정(`RedisStandaloneConfiguration`, `SslOptions`, hostname verification mode 등)이 실제로 무엇인지는 별도 official-doc(Spring Data Redis / Lettuce) 확보 필요.
## 메모
- 이 문서는 Redis **서버(redis-server)** 측 TLS 설정만 다룬다. 클라이언트(애플리케이션) 측 hostname verification·trust store 구성은 다른 공식 문서(Lettuce/Jedis)로 별도 보강 필요 — branch 결정의 "hostname verification" 부분은 현재 `UNSUPPORTED_DECISION` 상태.
- `tls-replication`/`tls-cluster` 는 client 채널(`tls-port`)과 독립적인 opt-in 이므로, role 별(cache/coordination/session) 배포에서 replication 이나 cluster 를 쓰는 role 이 있다면 각각 별도로 켜야 한다는 점을 구현 가이드에 명시할 근거로 쓸 수 있음.
- 추가로 봐야 할 동일 출처 페이지: Redis ACL 공식 문서(role 별 ACL 격리 근거), Lettuce/Jedis SSL 옵션 공식 문서(hostname verification 근거).
## 관련
- 같은 주제 다른 official-doc: (아직 없음 — Lettuce/Jedis SSL 옵션 공식 문서 추가 수집 후보)
- 이 자료를 인용한 wiki 요약: `[[wiki/concepts/...]]` (생성 시)
@@ -0,0 +1,102 @@
---
title: "official-doc / MongoDB — Retryable Writes (Once-Only Retry Semantics)"
source_type: official-doc
url: https://www.mongodb.com/docs/manual/core/retryable-writes/
archive_url:
status: raw
confidence: high
tags: [official-doc, ca-skeleton, persistence, mongodb, write-concern]
related_projects: [ca-skeleton]
related_branches: [feature-mongo-runtime-baseline-contract]
created: 2026-07-28
last_reviewed: 2026-07-28
---
# official-doc / MongoDB — Retryable Writes (Once-Only Retry Semantics)
> Layer: `raw/official-docs/` — MongoDB 공식 Manual reference (`retryable-writes`) 원문 발췌.
> `feature-mongo-runtime-baseline-contract` 의 "Mongo write 재시도 의미를 명시한다 — 드라이버의 retryable writes 가 무엇을 보장하고 무엇을 보장하지 않는지를 계약에 적고, 애플리케이션 재시도와 겹치지 않게 한다" 결정 근거.
## Parent / 활용 branch (필수)
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-mongo-runtime-baseline-contract]] | Mongo write 재시도 의미를 명시한다 — 드라이버의 retryable writes 가 무엇을 보장하고 무엇을 보장하지 않는지를 계약에 적고, 애플리케이션 재시도와 겹치지 않게 한다 — 기본 활성 여부, 재시도 1회 한정 서술, 재시도 가능/불가능 연산의 정확한 목록, 배포 형태 요건의 verbatim 근거 |
## 컨텍스트
ca-skeleton 이 MongoDB 를 optional adapter 로 채택할 경우, 드라이버가 기본 제공하는 retryable writes 가 애플리케이션 재시도 로직과 겹치면 이중 재시도·의미 혼동이 생길 수 있다. 이 판단에는 (1) retryable writes 가 기본으로 켜져 있는지, (2) 재시도가 정확히 몇 번 수행되는지(무한 재시도가 아님을 확인), (3) 어떤 write 연산이 재시도 대상이고 어떤 연산(특히 multi-document update/delete, unacknowledged write concern)이 대상 밖인지, (4) 어떤 배포 형태(standalone 은 제외)에서만 지원되는지에 대한 공식 정의가 필요하다.
## 출처 / Source
- 원본 URL: https://www.mongodb.com/docs/manual/core/retryable-writes/
- 아카이브 URL: (미수집)
- 저자 / 조직: MongoDB, Inc.
- 발행일: MongoDB Manual (현재 버전 — 본문에 "Starting in MongoDB 6.1" 문구 포함되어 있어 6.1 이후 반영분까지 포함된 최신 manual 로 판단)
- 마지막 확인일: 2026-07-28
## 핵심 인용 / Key quotes (verbatim)
> [§Compatibility — Deployment Topologies] "A **replica set** or **sharded cluster**. Not supported on **standalone instances**."
> [§Enabling Retryable Writes] "**Drivers compatible with MongoDB 4.2 and higher enable Retryable Writes by default**."
> [§Retryable Write Behaviors — Retry Behavior] "**By default, MongoDB retries writes once**. One retry attempts to address transient network errors and replica set elections, but not persistent network errors."
> [§Retryable Write Operations] "MongoDB retries the following operations if they have acknowledged write concern:"
> [§Retryable Write Operations — Non-retryable operations] "Writes with Write Concern `0` (unacknowledged)"
> [§Retryable Write Operations — Non-retryable operations] "Multi-document write operations like `updateMany()` and `deleteMany()`"
> [§Multi-Document Transactions] "**Transaction commit and abort operations are retryable**. Drivers retry these operations once on error, even if `retryWrites` is `false`."
> [§Multi-Document Transactions] "**Writes inside a transaction are not individually retryable**, regardless of the value of `retryWrites`."
> [§Error Handling] "Starting in MongoDB 6.1, if both the first and second attempt of a retryable write fail without a single write being performed, MongoDB returns an error with the `NoWritesPerformed` label."
## Claims Extracted / 추출된 주장
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| MONGO-RETRYWRITE-C1 | Retryable writes 는 standalone instance 에서는 지원되지 않고, replica set 또는 sharded cluster 배포에서만 동작한다 | [§Compatibility] "A **replica set** or **sharded cluster**. Not supported on **standalone instances**." | `official-vendor-doc` | ca-skeleton 이 MongoDB 를 replica set/sharded cluster 로 배포할 때만 retryable writes 를 전제할 수 있음 | standalone 단일 인스턴스로 운영 중인 환경에서 retryable writes 관련 동작을 기대할 수 있다는 것 — 이 문장이 명시적으로 부정함 |
| MONGO-RETRYWRITE-C2 | MongoDB 4.2 이상과 호환되는 드라이버는 retryable writes 를 기본으로 활성화한다 | [§Enabling Retryable Writes] "**Drivers compatible with MongoDB 4.2 and higher enable Retryable Writes by default**." | `official-vendor-doc` | 4.2+ 호환 드라이버를 사용하는 애플리케이션 — 별도 `retryWrites=true` 설정 없이도 활성 상태 | 4.2 미만 호환 드라이버의 기본값(이 경우 `retryWrites=true` 를 명시해야 함) — 이 문장은 4.2+ 케이스만 증명 |
| MONGO-RETRYWRITE-C3 | MongoDB 는 기본적으로 write 를 정확히 한 번만 재시도하며, 이 1회 재시도는 일시적(transient) 네트워크 오류와 replica set election 상황을 커버할 뿐 지속적(persistent) 네트워크 오류는 커버하지 않는다 | [§Retry Behavior] "**By default, MongoDB retries writes once**. One retry attempts to address transient network errors and replica set elections, but not persistent network errors." | `official-vendor-doc` | `timeoutMS` 를 별도 설정하지 않은 기본 동작. 애플리케이션 레벨 재시도 설계 시 "드라이버가 이미 1회 재시도한다"는 전제로 중복 재시도 설계를 피해야 함 | `timeoutMS` 를 설정했을 때는 문서가 별도로 "성공/재시도불가오류/타임아웃 중 하나까지 여러 번 재시도할 수 있다"고 규정하므로, 이 문장은 그 case 를 다루지 않음 |
| MONGO-RETRYWRITE-C4 | MongoDB 는 acknowledged write concern 을 가진 특정 연산(insert, single-document update/delete, findAndModify 계열, single-document 로만 구성된 bulkWrite)만 재시도 대상으로 삼는다 | [§Retryable Write Operations] "MongoDB retries the following operations if they have acknowledged write concern:" | `official-vendor-doc` | 위 나열된 단일 문서 단위 연산들. write concern 이 acknowledged(즉 `w != 0`)일 때만 성립 | 나열되지 않은 연산(예: multi-document 연산)이 재시도된다는 것 — 아래 C5 가 명시적으로 부정 |
| MONGO-RETRYWRITE-C5 | Write Concern `0`(unacknowledged) 인 write 와 `updateMany()`/`deleteMany()` 같은 multi-document write 연산은 재시도되지 않는다 | [§Non-retryable operations] "Writes with Write Concern `0` (unacknowledged)" / "Multi-document write operations like `updateMany()` and `deleteMany()`" | `official-vendor-doc` | 애플리케이션이 `w:0` 로 fire-and-forget write 를 하거나 multi-document update/delete 를 쓸 때, 드라이버 재시도에 의존할 수 없다는 계약 | 애플리케이션이 이런 연산에 대해 직접 idempotent 재시도 로직을 구현해야 하는지 여부의 구체적 방법 — 이 문장은 "드라이버가 안 해준다"는 사실만 증명 |
| MONGO-RETRYWRITE-C6 | 트랜잭션의 commit/abort 연산 자체는 `retryWrites` 값과 무관하게 오류 시 1회 재시도되지만, 트랜잭션 내부의 개별 write 는 `retryWrites` 값과 무관하게 개별적으로 재시도되지 않는다 | [§Multi-Document Transactions] "**Transaction commit and abort operations are retryable**. Drivers retry these operations once on error, even if `retryWrites` is `false`." / "**Writes inside a transaction are not individually retryable**, regardless of the value of `retryWrites`." | `official-vendor-doc` | 멀티 도큐먼트 트랜잭션을 쓰는 write 흐름 — 트랜잭션 경계(commit/abort) 재시도와 트랜잭션 내부 개별 write 재시도를 구분해서 설계해야 함 | 트랜잭션 자체가 실패했을 때 애플리케이션이 트랜잭션 전체를 재시도해야 하는지 여부의 구체적 정책 — 이 문장은 "드라이버가 commit/abort 만 1회 재시도한다"는 사실만 증명 |
| MONGO-RETRYWRITE-C7 (보조) | MongoDB 6.1 부터, retryable write 의 1차·2차 시도가 모두 실패하고 write 가 하나도 수행되지 않았다면 `NoWritesPerformed` 라벨이 붙은 에러를 반환한다 | [§Error Handling] "Starting in MongoDB 6.1, if both the first and second attempt of a retryable write fail without a single write being performed, MongoDB returns an error with the `NoWritesPerformed` label." | `official-vendor-doc` | MongoDB 6.1+ 배포에서 `insertMany()` 등 batch 연산의 부분 실패 여부를 구분해야 하는 에러 핸들링 로직 | 6.1 미만 버전에서의 동일 상황 에러 형태 — 이 문장은 6.1+ 에만 적용됨을 명시 |
## Usage Boundaries / 적용 경계
- **이 자료가 직접 증명하는 것**:
- `MONGO-RETRYWRITE-C1`: 배포 형태 요건 — replica set/sharded cluster 필수, standalone 불가
- `MONGO-RETRYWRITE-C2`: MongoDB 4.2+ 호환 드라이버는 retryable writes 기본 활성
- `MONGO-RETRYWRITE-C3`: 기본 재시도는 **정확히 1회**, transient 오류/election 만 커버
- `MONGO-RETRYWRITE-C4`: 재시도 대상 연산 조건 — acknowledged write concern + 단일 문서 단위 연산
- `MONGO-RETRYWRITE-C5`: 재시도 불가 연산 — unacknowledged(`w:0`) write, multi-document update/delete
- `MONGO-RETRYWRITE-C6`: 트랜잭션 commit/abort 는 재시도되지만 트랜잭션 내부 개별 write 는 재시도되지 않음
- `MONGO-RETRYWRITE-C7`: 6.1+ 에서 완전 실패 시 `NoWritesPerformed` 에러 라벨
- **이 자료가 증명하지 않는 것**:
- `timeoutMS` 설정 시 정확히 몇 회까지 재시도되는지의 구체적 상한 — 문서는 "성공/재시도불가오류/타임아웃 중 하나까지" 라고만 규정, 횟수 자체는 명시하지 않음
- Spring Data MongoDB(또는 채택할 드라이버)가 `retryWrites` 옵션을 어떤 설정 키/property 로 노출하는지 — 이 자료는 서버·프로토콜 레벨 계약만 정의
- 애플리케이션 레벨 재시도(예: idempotency key 기반 재시도)와 드라이버 레벨 retryable writes 를 어떻게 조합해야 하는지에 대한 설계 지침 — 이 자료는 드라이버가 "무엇을 하는지"만 말하고 "애플리케이션이 무엇을 더 해야 하는지"는 말하지 않음
- `local` 데이터베이스로의 write 에 대한 retryable writes 비활성화 권고의 정확한 근거 문장(이번 raw 에는 verbatim 인용/self-grep 미포함 — 별도 확인 필요)
- **ca-skeleton 프로젝트에 적용하려면 추가 확인이 필요한 것**:
- ca-skeleton 이 채택할 MongoDB 드라이버(Java/Reactive Streams 또는 Spring Data MongoDB)가 실제로 4.2+ 호환인지, 그래서 별도 `retryWrites=true` 설정 없이 기본 활성인지 확인 필요
- branch 의 §구현 가이드에서 애플리케이션 레벨 재시도(예: idempotent write 재시도)를 드라이버의 1회 재시도와 어떻게 겹치지 않게 설계할지는 이 자료가 아니라 branch 자체 결정 사항
## 메모 / Notes
- WebFetch 1차 시도는 원문 링크를 markdown `[text](url)` 문법으로 감싼 형태로 반환해 self-grep 인용에 마크다운 아티팩트가 섞일 위험이 있었다. 재요청 시 "링크를 순수 텍스트로" 명시해 재fetch, 브래킷 없는 깨끗한 인용을 확보했다.
- G2 재검증 1차 시도에서 4개 인용("A replica set or sharded cluster...", "Drivers compatible with MongoDB 4.2...", "By default, MongoDB retries writes once...", "Transaction commit and abort operations..."/"Writes inside a transaction...")이 `grep -nF` 불일치로 확인됨 — WebFetch 결과의 `**볼드**` 강조 마커를 인용에서 임의로 제거했기 때문. 원문 그대로 `**` 를 보존하도록 정정하고 재검증 통과.
- `local` 데이터베이스 write 에 대한 retryable writes 비활성화 권고, `timeoutMS` 재시도 상한의 정확한 조건 문장은 이번 라운드에서 verbatim 미수집 — 필요 시 재수집.
- MongoDB 드라이버(Java/Reactive)의 `retryWrites` 구체 설정 API 는 별도 official-doc 후보.
## Related / 관련
- 같은 주제 다른 official-doc: [[raw/official-docs/write-concern-mongodb-official]] (write concern 정의 — 본 문서의 "acknowledged write concern" 조건과 직결)
- 같은 주제 다른 official-doc: [[raw/official-docs/spring-data-mongodb-index-management-official]] (같은 branch cluster 의 다른 MongoDB 공식 근거)
- 인용하는 branch: [[raw/branch-notes/feature-mongo-runtime-baseline-contract]]
- 인용하는 project: [[raw/project-notes/ca-skeleton-operational-contract]]
- 인용한 wiki 요약: (미작성)
@@ -7,9 +7,9 @@ status: raw
confidence: high
tags: [ca-skeleton, runtime, health, lifecycle, spring-boot, actuator]
related_projects: [ca-skeleton-operational-contract]
related_branches: [feature-runtime-health-lifecycle-contract, feature-management-actuator-security-contract]
related_branches: [feature-runtime-health-lifecycle-contract, feature-management-actuator-security-contract, feature-capability-provider-selection-contract]
created: 2026-05-22
last_reviewed: 2026-05-27
last_reviewed: 2026-07-28
---
# Spring Boot Actuator — Health Endpoint Groups (Liveness / Readiness / Startup)
@@ -23,6 +23,7 @@ last_reviewed: 2026-05-27
|---|---|
| [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] | actuator `/actuator/health/liveness` + `/actuator/health/readiness` group 채택 + readiness 에 외부 dependency 포함 정책 결정 |
| [[raw/branch-notes/feature-management-actuator-security-contract]] | health endpoint 의 prod 노출 + group 별 detail 노출 정책 결정 |
| [[raw/branch-notes/feature-capability-provider-selection-contract]] | 활성화된 provider 만 readiness 에 반영한다 — optional cache 실패는 pod 를 unready 로 만들지 않고, session/idempotency/필수 lock 실패는 unready 로 만든다. 판정 주체는 capability descriptor 이지 bean 존재가 아니다 (StatusAggregator 기반 aggregation + health group include/exclude 메커니즘 근거) |
## 컨텍스트
@@ -57,6 +58,18 @@ ca-tmpl `feature-runtime-health-lifecycle-contract` 는 actuator 의 **liveness
> needs-confirmation: 이전 raw 본문에서 인용된 "Custom `HealthIndicator` beans can be assigned to groups using `management.endpoint.health.group.<name>.include`. By default, the readiness group includes the `readinessState` indicator only — application liveness and readiness must NOT depend on external systems by Spring Boot's default model." 문장은 2026-05-27 WebFetch 결과에서 **단일 문장 verbatim 으로 확인 불가**. `management.endpoint.health.group.<name>.include` property 자체는 reference 의 다른 위치에 존재하나, "must NOT depend on external systems" 라는 정책 문장의 verbatim 출처는 별도 fetch 필요. 따라서 본 raw 의 직접 증명 범위에서 제외.
**2026-07-28 추가 수집 (`feature-capability-provider-selection-contract` 근거, curl 직접 fetch → self-grep 검증 완료, 위 SB-HEALTH-C8 gap 부분 해소):**
> [§actuator.endpoints.health.groups] "To create a health indicator group, you can use the `management.endpoint.health.group.<name>` property and specify a list of health indicator IDs to include or exclude."
> [§actuator.endpoints.health] "By default, the final system health is derived by a `StatusAggregator`, which sorts the statuses from each `HealthIndicator` based on an ordered list of statuses."
> [§actuator.endpoints.health.groups] "By default, groups inherit the same `StatusAggregator` and `HttpCodeStatusMapper` settings as the system health."
> [§actuator.endpoints.kubernetes-probes.external-state] "By default, Spring Boot does not add other health indicators to these groups."
> [§actuator.endpoints.kubernetes-probes.external-state] "Other external systems might not be essential to the application (the application could have circuit breakers and fallbacks), in which case they definitely should not be included."
## Claims Extracted / 추출된 주장
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
@@ -68,7 +81,11 @@ ca-tmpl `feature-runtime-health-lifecycle-contract` 는 actuator 의 **liveness
| SB-HEALTH-C5 | `AvailabilityChangeEvent.publish(eventPublisher, ex, LivenessState.BROKEN)` 패턴으로 application code 가 명시적으로 state 전이 publish 가능 | [§features.spring-application.application-availability] "We can also update the state of the application, when the application breaks and cannot recover: `AvailabilityChangeEvent.publish(this.eventPublisher, ex, LivenessState.BROKEN);`" | `official-vendor-doc` | LivenessState / ReadinessState 전이 시점 명시 제어 | exception handler 외 다른 위치 (예: scheduled task) 에서의 published pattern 은 본 인용 범위 밖 |
| SB-HEALTH-C6 | 애플리케이션 instance 의 readiness 가 unready 이면 Kubernetes 는 해당 instance 로 traffic routing 안 함 | [§actuator.endpoints.kubernetes-probes.external-state] "If the readiness state of an application instance is unready, Kubernetes does not route traffic to that instance." | `official-vendor-doc` | Kubernetes 환경의 Spring Boot Actuator readiness group | "ready → unready 전이" 의 정확한 propagation 지연 (kubelet probe period × failureThreshold) 은 K8s probe 측 변수 — 별도 |
| SB-HEALTH-C7 | health group 은 `CompositeHealthContributor` 를 include / exclude 가능 | [§actuator.endpoints.health.groups] "A health group can also include/exclude a `CompositeHealthContributor`." | `official-vendor-doc` | health group 구성 시 | 외부 dependency 를 readiness 에 포함시키는 권장 / 비권장 정책은 본 인용 범위 밖 (needs-confirmation 참조) |
| SB-HEALTH-C8 | "default readiness group 은 외부 의존성을 포함하지 않는다 / 포함해서는 안 된다" 라는 **단일 문장 verbatim** 은 본 capture 에서 확보 못 함 | (부재 자체가 claim) | `needs-confirmation` | readiness group default 멤버 정책 | 외부 의존성을 readiness 에 추가해도 안전하다는 뜻도 아님 — Spring Boot reference 의 별도 섹션 fetch 필요 |
| SB-HEALTH-C8 | "default readiness group 은 외부 의존성을 포함하지 않는다 / 포함해서는 안 된다" 라는 **단일 문장 verbatim** 은 본 capture 에서 확보 못 함 | (부재 자체가 claim) | `needs-confirmation` | readiness group default 멤버 정책 | 외부 의존성을 readiness 에 추가해도 안전하다는 뜻도 아님 — Spring Boot reference 의 별도 섹션 fetch 필요**2026-07-28: C11/C12 로 부분 해소 (아래)** |
| SB-HEALTH-C9 | health indicator group 은 `management.endpoint.health.group.<name>` property 로 health indicator ID 목록을 include/exclude 지정해 생성한다 | [§actuator.endpoints.health.groups] "To create a health indicator group, you can use the `management.endpoint.health.group.<name>` property and specify a list of health indicator IDs to include or exclude." | `official-vendor-doc` | 모든 custom health group (liveness/readiness 포함) 생성 메커니즘 | liveness/readiness 그룹이 startup 시 자동으로 이 매커니즘을 거쳐 구성된다는 순서·시점까지는 본 인용 범위 밖 |
| SB-HEALTH-C10 | 전체 system health(및 각 group)는 기본적으로 `StatusAggregator` 가 각 `HealthIndicator` 의 status 를 정렬된 순서 목록 기준으로 정렬하여 도출한다 — 첫 항목이 overall status | [§actuator.endpoints.health] "By default, the final system health is derived by a `StatusAggregator`, which sorts the statuses from each `HealthIndicator` based on an ordered list of statuses." | `official-vendor-doc` | global `/actuator/health` 및 모든 health group 의 aggregation 규칙 | 개별 capability 의 실패가 어떤 Status(DOWN/OUT_OF_SERVICE 등) 로 매핑되어야 하는지는 본 인용 범위 밖 — application 코드 책임 |
| SB-HEALTH-C11 | health group 은 기본적으로 system 전체와 동일한 `StatusAggregator`/`HttpCodeStatusMapper` 설정을 상속한다 — group 마다 별도 override 가능 | [§actuator.endpoints.health.groups] "By default, groups inherit the same `StatusAggregator` and `HttpCodeStatusMapper` settings as the system health." | `official-vendor-doc` | readiness/liveness group 포함 모든 custom group 의 aggregation 상속 규칙 | readiness group 이 실제로 override 설정을 갖는지 여부는 이 인용만으로 알 수 없음 — 프로젝트별 설정 확인 필요 |
| SB-HEALTH-C12 | readiness/liveness health group 은 기본적으로 다른 health indicator 를 추가하지 않으며, 외부 시스템을 readiness 에 포함할지는 "필수(essential)한가"를 기준으로 애플리케이션 개발자가 판단해야 한다 — non-essential(예: fallback 가능한 optional cache)은 포함하지 말아야 함 | [§actuator.endpoints.kubernetes-probes.external-state] "By default, Spring Boot does not add other health indicators to these groups." + "Other external systems might not be essential to the application (the application could have circuit breakers and fallbacks), in which case they definitely should not be included." | `official-vendor-doc` | readiness probe 에 캐시·비필수 외부 시스템을 포함할지 판단하는 일반 기준 | "필수/비필수"를 자동으로 구분해주는 Spring Boot 메커니즘은 없음 — capability descriptor 로 판정하는 것은 ca-skeleton 의 설계 결정(본 branch), 공식 문서가 강제하는 구현 방식은 아님 |
## Usage Boundaries / 적용 경계
@@ -77,15 +94,21 @@ ca-tmpl `feature-runtime-health-lifecycle-contract` 는 actuator 의 **liveness
- `SB-HEALTH-C2` ~ `C5`: LivenessState / ReadinessState 의 정의와 ApplicationAvailability + AvailabilityChangeEvent.publish 모델
- `SB-HEALTH-C6`: Kubernetes 가 unready 인스턴스로 traffic routing 안 함
- `SB-HEALTH-C7`: health group 의 include / exclude 메커니즘 존재
- `SB-HEALTH-C9`: health group 생성은 `management.endpoint.health.group.<name>` property 로 이뤄짐
- `SB-HEALTH-C10`: system health(및 group)의 aggregation 은 기본적으로 `StatusAggregator` 가 정렬된 상태 목록 기준으로 도출
- `SB-HEALTH-C11`: health group 은 기본적으로 system 과 동일한 `StatusAggregator`/`HttpCodeStatusMapper` 를 상속
- `SB-HEALTH-C12`: readiness/liveness group 은 기본적으로 다른 indicator 를 추가하지 않으며, non-essential 외부 시스템은 포함하지 말아야 한다는 판단 기준이 명시됨
- **이 자료가 증명하지 않는 것**:
- `SB-HEALTH-C8`: default readiness group 멤버 (readinessState 만 포함 vs 외부 의존성 포함 정책) verbatim
- `SB-HEALTH-C8`: default readiness group 멤버 (readinessState 만 포함 vs 외부 의존성 포함 정책) verbatim — **2026-07-28: `SB-HEALTH-C12` 로 정책 판단 기준("essential 한가")은 확보. 단 "capability descriptor 로 판정한다"는 구현 방식 자체는 여전히 ca-skeleton 의 설계 결정이며 본 자료가 증명하지 않음.**
- startup probe 를 Spring Boot 가 dedicated group 으로 제공하는지 (현재 인용 범위: liveness + readiness 만 명시)
- HealthIndicator 의 per-indicator timeout 제어 메커니즘 (endpoint-level vs indicator-level)
- graceful shutdown 시 readiness 가 자동 DOWN 으로 전환되는 mechanism 의 verbatim 출처 (Application Availability 페이지 본문에는 명시 부재 — 2026-05-27 fetch 결과)
- "판정 주체는 capability descriptor 이지 bean 존재가 아니다" — 이는 Spring Boot 문서가 아닌 ca-skeleton 자체 설계 결정. 본 자료는 group aggregation·include/exclude 메커니즘과 "essential 여부로 판단하라"는 일반 기준만 증명
- **내 프로젝트 (ca-tmpl) 에 적용하려면 추가 확인이 필요한 것**:
- ca-tmpl 이 readiness group 에 DB / outbox broker 를 포함시키려면 `management.endpoint.health.group.readiness.include=readinessState,db,...` 명시 설정 필요 — 이 property 의 verbatim 출처 별도 fetch
- ca-tmpl 의 startup endpoint (`/actuator/health/startup`) 가 manually 구성된 health group 인지, 아니면 별도 endpoint 인지 (Spring Boot 가 dedicated group 제공 여부 미확정)
- graceful shutdown ↔ readiness DOWN 자동 전환의 공식 메커니즘 (Application Availability 또는 별도 graceful-shutdown reference 페이지)
- `feature-capability-provider-selection-contract`: capability descriptor 의 readiness 기여 여부를 실제로 `management.endpoint.health.group.readiness.include/exclude` 로 배선하는 구체 구현(어떤 capability 를 이 property 목록에 넣을지)은 이 자료가 아니라 branch 의 §구현 가이드에서 결정 — 본 자료는 "그렇게 include/exclude 할 수 있는 메커니즘이 존재한다"까지만 증명
## ca-tmpl 함의 (내 프로젝트 해석)
@@ -101,8 +124,8 @@ ca-tmpl `feature-runtime-health-lifecycle-contract` 는 actuator 의 **liveness
## 메모 / Notes
- 2026-05-27 재검증: Application Availability 페이지 verbatim 확보. Endpoints 페이지의 Kubernetes Probes 섹션 verbatim 확보. 단 "default readiness group 멤버 + 외부 의존성 정책" 단일 문장 verbatim 미확보 → `SB-HEALTH-C8` 로 분리.
- 2026-07-28 재수집 (`feature-capability-provider-selection-contract` 근거): WebFetch 의 소형 모델 요약이 paraphrase 되어 verbatim 요건을 충족하지 못해, `curl` 로 원문 HTML 을 직접 fetch(HTTP 200, 235KB) 후 Python `html.parser` 로 텍스트만 추출해 self-grep 대조. `management.endpoint.health.group.<name>` property, `StatusAggregator` aggregation, group 의 aggregator 상속, "essential 하지 않은 외부 시스템은 포함하지 말 것" 판단 기준의 verbatim 4개 신규 확보 (`SB-HEALTH-C9`~`C12`). `SB-HEALTH-C8` 의 "포함 여부는 개발자 판단" 부분은 해소되었으나, "capability descriptor 로 그 판단을 자동화한다"는 부분은 여전히 ca-skeleton 자체 설계(본 자료가 증명 안 함).
- 다음 fetch 후보:
- `https://docs.spring.io/spring-boot/reference/actuator/endpoints.html#actuator.endpoints.health.groups` (group include property verbatim)
- `https://docs.spring.io/spring-boot/reference/features/graceful-shutdown.html` (readiness 자동 DOWN 전이)
## Related / 관련
@@ -114,6 +137,7 @@ ca-tmpl `feature-runtime-health-lifecycle-contract` 는 actuator 의 **liveness
- 인용하는 branch:
- [[raw/branch-notes/feature-runtime-health-lifecycle-contract]]
- [[raw/branch-notes/feature-management-actuator-security-contract]]
- [[raw/branch-notes/feature-capability-provider-selection-contract]]
- 인용하는 project:
- [[raw/project-notes/ca-skeleton-operational-contract]]
- 인용한 wiki 요약: (미작성)
@@ -0,0 +1,80 @@
---
title: official-doc / Spring Boot — Application Availability (Liveness/Readiness) & SpringApplication Lifecycle
source_type: official-doc
url: https://docs.spring.io/spring-boot/reference/features/spring-application.html
archive_url:
related_branches: [feature-capability-provider-selection-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, runtime, spring-boot]
created: 2026-07-28
---
# official-doc / Spring Boot — Application Availability (Liveness/Readiness) & SpringApplication Lifecycle
> Layer: `raw/` — 외부 자료(공식 문서)의 **원문 발췌·출처 기록**.
> 검증된 요약은 `/ingest``wiki/concepts/``source-summary-template` 형식으로 별도 작성. 원본은 raw에 영구 보관.
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-capability-provider-selection-contract]] | capability 토폴로지 검증은 startup 에서 fail-fast 로 수행하고, 검증 실패는 컨텍스트 기동을 거부한다 — Spring Boot 자체의 애플리케이션 이벤트 순서(`ApplicationStartedEvent` → runner 실행, `ApplicationFailedEvent` on startup exception)가 근거. 또한 application availability state (`LivenessState`/`ReadinessState`)는 컨텍스트 refresh 완료 **이후**에만 변경되는 런타임 신호이며, startup 검증의 대체물이 아니라는 결정의 근거. |
## 출처
- 원본 URL: https://docs.spring.io/spring-boot/reference/features/spring-application.html
- 아카이브 URL: (미제공)
- 저자 / 조직: VMware / Spring Boot 프로젝트 (공식 레퍼런스)
- 발행일: 확인 안 됨 (WebFetch 결과 상단에 "SpringApplication :: Spring Boot 4.1.0" 표시 — 문서가 Spring Boot 4.1.0 레퍼런스 시점 버전임을 시사)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-capability-provider-selection-contract` branch 는 capability 토폴로지 검증을 "startup 시 fail-fast"로 둘지, 아니면 Spring Boot 의 `ApplicationAvailability` (readiness/liveness) 신호로 대체할지를 결정해야 한다. 본 문서는 Spring Boot 공식 레퍼런스가 (1) Liveness/Readiness state 를 컨텍스트 refresh 완료 이후의 런타임 신호로 정의하고, (2) `ApplicationRunner`/`CommandLineRunner` 도 "애플리케이션이 이미 시작된 후" 실행되며, (3) startup 중 예외는 별도의 `ApplicationFailedEvent` 경로로 분리된다는 것을 직접 진술하는지 확인하기 위해 발췌했다.
## 핵심 인용
> [§Application Availability — Liveness State] "Indicates whether the application's internal state allows it to work correctly. A broken Liveness state signals that the application cannot recover and should be restarted by infrastructure (e.g., Kubernetes)."
> [§Application Availability — Readiness State] "Indicates whether the application is ready to handle traffic. A failing Readiness state tells the platform not to route traffic to the application, typically during startup or when the application is too busy."
> [§Application Events and Listeners — 이벤트 순서 목록 6번] "**AvailabilityChangeEvent** (LivenessState.CORRECT) Application is live"
> [§ApplicationRunner and CommandLineRunner] "If you need to run specific code once `SpringApplication` has started, implement `ApplicationRunner` or `CommandLineRunner`:"
> [§Application Events and Listeners — 이벤트 순서 목록 9번] "**ApplicationFailedEvent** If there's an exception on startup"
## Claims Extracted
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| SB-AVAIL-C1 | Liveness State 는 애플리케이션 내부 상태가 정상 동작 가능한지를 나타내며, broken 상태는 "복구 불가 → 인프라가 재시작해야 함"을 의미하는 런타임 헬스 신호다. | [§Application Availability] "Indicates whether the application's internal state allows it to work correctly. A broken Liveness state signals that the application cannot recover and should be restarted by infrastructure (e.g., Kubernetes)." | `official-vendor-doc` | LivenessState 의 의미론 — "복구 불가능해진 이미 살아있는 프로세스"를 인프라가 재시작하도록 신호하는 용도 | LivenessState/ReadinessState 가 startup-time capability 토폴로지 정합성 검증에 적합한 메커니즘이라거나 Spring Boot 가 그 용도로 권장한다는 것 — 문서는 이 용도를 언급하지 않음 |
| SB-AVAIL-C2 | Readiness State 는 트래픽 처리 준비 여부를 나타내며, failing 상태는 "아직 트래픽 라우팅하지 말라"는 뜻이고 전형적으로 startup 도중이나 과부하 시 사용된다. | [§Application Availability] "Indicates whether the application is ready to handle traffic. A failing Readiness state tells the platform not to route traffic to the application, typically during startup or when the application is too busy." | `official-vendor-doc` | Readiness = "아직 트래픽 라우팅하지 마라"는 신호이며, 문서 스스로 startup 도중 상태로 명시 — 즉 컨텍스트는 이미 살아있고 프로세스도 떠 있는 상태에서의 트래픽 게이팅 | Readiness=REFUSING_TRAFFIC 설정이 컨텍스트 기동 자체를 거부(refresh 실패/프로세스 종료)하는 것과 동등하다는 것 — 트래픽만 안 보낼 뿐 컨텍스트는 이미 완성되어 떠 있음 |
| SB-AVAIL-C3 | 애플리케이션 이벤트 순서에서 `AvailabilityChangeEvent(LivenessState.CORRECT)` 는 6번째 이벤트로, `ApplicationStartedEvent`(5번, "컨텍스트 refresh 이후, runner 이전") 다음에 발생한다. | [§Application Events and Listeners] "**AvailabilityChangeEvent** (LivenessState.CORRECT) Application is live" (목록 6번, 5번 항목 "**ApplicationStartedEvent** After context refresh, before runners" 뒤) | `official-vendor-doc` | Liveness 신호가 컨텍스트 refresh 완료 **이후**에만 발생한다는 시점 근거 — refresh 도중의 bean 초기화 실패는 이 이벤트 이전 단계에서 이미 별도 경로(ApplicationFailedEvent)로 분기됨 | 문서가 "capability 토폴로지 검증"이라는 개념 자체를 언급하거나 그 검증을 refresh 단계에 두라고 권고한다는 것 — 이는 이벤트 순서로부터의 추론이지 원문의 명시적 진술이 아님 |
| SB-AVAIL-C4 | `ApplicationRunner`/`CommandLineRunner` 는 "`SpringApplication` 이 이미 시작된 후(once ... has started)" 실행하도록 설계된 확장점이다. | [§ApplicationRunner and CommandLineRunner] "If you need to run specific code once `SpringApplication` has started, implement `ApplicationRunner` or `CommandLineRunner`:" | `official-vendor-doc` | Runner 실행 시점이 컨텍스트 refresh 완료 이후(모든 싱글턴 빈이 이미 완전히 wiring된 시점)라는 것 — capability 토폴로지 검증을 Runner 에 두면 "이미 활성화된 컨텍스트"를 사후 점검하는 것이 됨 | Runner 가 예외를 던졌을 때 프로세스가 종료되는지 여부 — 이 문서 발췌에는 명시 없음 (Spring 일반 지식이지 이 페이지의 verbatim 진술 아님) |
| SB-AVAIL-C5 | 애플리케이션 이벤트 순서에서 startup 중 예외는 정상 경로(Started→Live→Ready→AcceptingTraffic)와 분리된 `ApplicationFailedEvent` 로 별도 처리된다. | [§Application Events and Listeners] "**ApplicationFailedEvent** If there's an exception on startup" (목록 9번) | `official-vendor-doc` | Spring Boot 자체의 라이프사이클 모델이 startup 중 예외를 "정상 가용성 상태 진행과는 별개의 실패 경로"로 취급한다는 것 — fail-fast 의 이벤트 모델적 근거 | 이 이벤트가 정확히 어떤 종료 코드/JVM exit 동작으로 이어지는지에 대한 구체 메커니즘 — 이 발췌 페이지는 `ExitCodeGenerator` 인터페이스만 별도로 언급할 뿐 `ApplicationFailedEvent` 와의 직접 연결을 verbatim 으로 진술하지 않음 |
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `SB-AVAIL-C1`/`SB-AVAIL-C2`: LivenessState/ReadinessState 는 "이미 살아있는 프로세스"에 대한 런타임 헬스·트래픽 게이팅 신호이며, 정의상 컨텍스트가 이미 완성된 이후에 의미를 갖는다.
- `SB-AVAIL-C3`/`SB-AVAIL-C4`: 이벤트 순서상 Liveness 신호 변경과 Runner 실행은 모두 `ApplicationStartedEvent`("After context refresh, before runners") **이후**에 위치한다 — 즉 컨텍스트 refresh 가 이미 성공적으로 끝난 다음의 일이다.
- `SB-AVAIL-C5`: startup 중 예외는 정상 가용성 상태 전이와 분리된 별도 실패 이벤트(`ApplicationFailedEvent`)로 취급된다.
- 이 자료가 증명하지 않는 것:
- "capability 토폴로지 검증"이라는 ca-skeleton 고유 개념 자체 — Spring Boot 문서는 이 용어를 전혀 언급하지 않는다. 위 5개 claim 은 모두 **일반적인 SpringApplication 라이프사이클 이벤트 순서**에 대한 진술이며, 이로부터 "그러므로 capability 토폴로지 검증은 refresh 단계에서 fail-fast 로 해야 한다"는 결론은 **branch 저자의 추론**이지 원문의 직접 진술이 아니다.
- Runner 가 예외를 던질 때 프로세스 종료/exit code 동작의 구체 메커니즘.
- Readiness=REFUSING_TRAFFIC 이 컨텍스트 기동 실패와 "동등"하다는 주장 — 오히려 원문은 정반대(Readiness 는 컨텍스트가 이미 뜬 상태의 트래픽 게이팅)를 보여준다.
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- ca-skeleton 의 실제 컨텍스트 초기화 훅(`@PostConstruct`, `SmartInitializingSingleton`, `ApplicationListener<ContextRefreshedEvent>` 등 refresh 단계 내 실행 지점 중 정확히 어디에 capability 토폴로지 검증을 배치할지)은 이 문서에 없다 — 별도 Spring Framework `ApplicationContext` refresh 문서 또는 실제 구현으로 검증 필요.
- `ApplicationFailedEvent` 발생 시 Spring Boot 가 `SpringApplication.run()` 호출자에게 예외를 어떻게 전파하는지(예: `run()` 이 예외를 던지는지)는 본 발췌에 없음.
## 메모
- 인용 해석 후보 (미검증, branch 저자 추론 — 이 문서가 직접 말한 것 아님): "이벤트 순서(ApplicationStartedEvent → runner → AvailabilityChangeEvent)가 보여주는 건 Liveness/Readiness 와 Runner 모두 **컨텍스트가 이미 완성된 이후**라는 사실이므로, capability 토폴로지처럼 '컨텍스트가 완성되기 전에 막아야 하는' 검증은 이 두 메커니즘이 아니라 컨텍스트 refresh 도중(예: bean 초기화 또는 `BeanFactoryPostProcessor`/`SmartInitializingSingleton`)에 두어야 한다"는 결론은 이 문서의 이벤트 순서 사실에서 **추론**한 것이지, 문서가 "capability 검증을 여기에 두라"고 명시적으로 권고한 것이 아니다. wiki/concepts 로 승격 시 이 구분을 명확히 유지할 것.
- 추가로 봐야 할 동일 출처 페이지: Spring Framework 공식 문서의 `ApplicationContext.refresh()` 생명주기(`BeanFactoryPostProcessor`, `SmartInitializingSingleton`, `ContextRefreshedEvent`) — 이 페이지(SpringApplication)는 Spring Boot 레벨 이벤트만 다루고, refresh 내부의 세부 콜백 순서는 다루지 않는다.
- WebFetch 도구 특성상 원본 HTML 을 AI 모델이 마크다운으로 정리한 결과다. 원문 그대로 보존을 프롬프트로 요청했고 self-grep 은 이 정리된 텍스트 기준으로 통과했으나, Spring 공식 페이지의 byte-for-byte HTML 과 100% 동일하다는 보장은 없다 — 재검증 시 페이지를 직접 열어 대조 권장.
## 관련
- 같은 주제 다른 official-doc: (아직 없음 — Spring Framework `ApplicationContext` 생명주기 공식 문서 추가 수집 후보)
- 이 자료를 인용한 wiki 요약: `[[wiki/concepts/...]]` (생성 시)
@@ -0,0 +1,87 @@
---
title: official-doc / Spring Boot Condition Annotations — @ConditionalOnProperty, matchIfMissing, Evaluation Order
source_type: official-doc
status: raw
confidence: high
url: https://docs.spring.io/spring-boot/reference/features/developing-auto-configuration.html
archive_url:
related_branches: [feature-capability-provider-selection-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, architecture, spring-boot]
created: 2026-07-28
last_reviewed: 2026-07-28
---
# Spring Boot Condition Annotations — @ConditionalOnProperty / matchIfMissing / Evaluation Order
> Layer: `raw/official-docs/` — Spring Boot 4.1.0 reference "Creating Your Own Auto-configuration" 발췌. `feature-capability-provider-selection-contract` 브랜치의 "`matchIfMissing=true` 금지" 결정과 "조건 평가 시점의 한계"를 뒷받침하는 근거.
## Parent / 활용 branch (필수)
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-capability-provider-selection-contract]] | production capability 활성화를 `@ConditionalOnProperty` 로 게이트할 때 `matchIfMissing=true` 를 금지하는 결정, 그리고 조건 평가 시점/순서 의존성의 한계 근거 |
## 출처
- 원본 URL: https://docs.spring.io/spring-boot/reference/features/developing-auto-configuration.html
- 아카이브 URL: (미수집)
- 저자 / 조직: Spring Team (spring-projects / Broadcom)
- 발행일: 미상 — living reference 문서. 페이지 내 버전 배지 기준 **Spring Boot 4.1.0** (Stable)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-capability-provider-selection-contract` 는 production capability 활성화 게이트에 `@ConditionalOnProperty` 를 쓰되 `matchIfMissing=true` 를 금지한다 (속성 미설정 = 비활성 유지). 이 raw 자료는 (1) `@ConditionalOnProperty` 의 공식 정의와 `matchIfMissing` 이 실제로 존재하는 옵션 attribute 라는 근거, (2) `@Conditional` 계열 annotation 전반의 평가 시점·순서 의존성 한계(원문은 이를 근거로 bean 존재 기반 조건을 auto-configuration class 로 한정 권고)를 보존한다. 후자는 project 가 이미 내린 "활성화는 bean 이름이 아니라 typed descriptor 기반 provider 선택으로만" 결정과도 연결된다.
## 핵심 인용
> [§Understanding Auto-configured Beans] "Additional @Conditional annotations are used to constrain when the auto-configuration should apply. Usually, auto-configuration classes use @ConditionalOnClass and @ConditionalOnMissingBean annotations."
> [§Property Conditions] "The @ConditionalOnProperty annotation lets configuration be included based on a Spring Environment property. Use the prefix and name attributes to specify the property that should be checked. By default, any property that exists and is not equal to false is matched."
> [§Property Conditions] "There is also a dedicated @ConditionalOnBooleanProperty annotation specifically made for boolean properties. With both annotations you can also create more advanced checks by using the havingValue and matchIfMissing attributes."
> [§Bean Conditions] "You need to be very careful about the order in which bean definitions are added, as these conditions are evaluated based on what has been processed so far."
> [§Bean Conditions] "For this reason, we recommend using only @ConditionalOnBean and @ConditionalOnMissingBean annotations on auto-configuration classes (since these are guaranteed to load after any user-defined bean definitions have been added)."
## Claims Extracted / 추출된 주장
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| SB-COND-C1 | `@ConditionalOnProperty` 는 Spring `Environment` 의 property 존재 여부·값을 기준으로 configuration 포함 여부를 결정하며, `prefix`/`name` 으로 지정한 property 가 **존재하고 값이 `"false"` 가 아니면** 기본적으로 match 된다 | [§Property Conditions] "The @ConditionalOnProperty annotation... By default, any property that exists and is not equal to false is matched." | `official-vendor-doc` | `@ConditionalOnProperty` 의 기본(=`havingValue`/`matchIfMissing` 미지정) 매칭 규칙 | property 가 **부재**할 때의 기본 동작(=matchIfMissing 미지정 시 기본값)은 이 문장이 다루지 않음 — 이 문장은 "존재 시" 규칙만 정의 |
| SB-COND-C2 | `matchIfMissing``havingValue` 와 함께 "more advanced checks" 를 만들 때 쓰는 실존하는 attribute 다 | [§Property Conditions] "With both annotations you can also create more advanced checks by using the havingValue and matchIfMissing attributes." | `official-vendor-doc` | `matchIfMissing` 이라는 옵션이 Spring Boot 공식 API 에 실재한다는 사실 | **이 페이지는 `matchIfMissing` 의 기본값이나 매칭 semantics(속성 부재 시 true/false 중 무엇으로 처리되는지)를 설명하지 않는다** — self-grep 결과 이 페이지 전체에서 `matchIfMissing` 단어는 이 한 문장에서만 등장(1회). 따라서 이 자료만으로 "matchIfMissing=true 를 금지해야 한다"는 명제를 공식 권고로 증명할 수 없음 — 이는 project/branch 의 자체 설계 판단이다 |
| SB-COND-C3 | `@Conditional` 계열 annotation(특히 `@ConditionalOnBean`/`@ConditionalOnMissingBean`) 은 "지금까지 처리된 것"을 기준으로 평가되므로 bean definition 이 추가되는 **순서**에 매우 주의해야 한다 | [§Bean Conditions] "You need to be very careful about the order in which bean definitions are added, as these conditions are evaluated based on what has been processed so far." | `official-vendor-doc` | bean 존재/부재 기반 조건(`@ConditionalOnBean`/`@ConditionalOnMissingBean`)의 순서 의존성 일반 | 이 경고는 원문에서 "Bean Conditions" 섹션에 위치 — "Property Conditions" 섹션(`@ConditionalOnProperty`)에는 동일한 순서 의존 경고 문장이 없다. Property 기반 조건에도 동일한 순서 취약성이 있다고 이 인용만으로 단정할 수 없음 |
| SB-COND-C4 | 순서 의존 위험 때문에 공식 문서는 `@ConditionalOnBean`/`@ConditionalOnMissingBean`**auto-configuration 클래스에서만** 사용하라고 권고한다 — auto-configuration 클래스는 사용자 정의 bean definition 이 모두 추가된 후에 로드되는 것이 보장되기 때문 | [§Bean Conditions] "For this reason, we recommend using only @ConditionalOnBean and @ConditionalOnMissingBean annotations on auto-configuration classes (since these are guaranteed to load after any user-defined bean definitions have been added)." | `official-vendor-doc` | bean 존재/이름 기반 판정의 순서 취약성 근거 — "bean 이름 기반 판정 제거" 결정을 뒷받침하는 데 사용 가능 | `@ConditionalOnProperty` 같은 property 기반 조건에도 이 권고가 확장 적용된다는 뜻은 아님(범위는 명시적으로 bean 존재 기반 조건) |
| SB-COND-C5 | auto-configuration class 는 `@AutoConfiguration`(= meta-annotated `@Configuration`) + 추가 `@Conditional` 로 적용 조건을 제한하며, 보통은 `@ConditionalOnClass` + `@ConditionalOnMissingBean` 조합을 쓴다 | [§Understanding Auto-configured Beans] "Additional @Conditional annotations are used to constrain when the auto-configuration should apply. Usually, auto-configuration classes use @ConditionalOnClass and @ConditionalOnMissingBean annotations." | `official-vendor-doc` | 이 문서가 다루는 condition annotation 의 전형적 사용 범위(=auto-configuration class 의 적용 여부 제약) | `@ConditionalOnProperty` 가 이 "usual" 조합에 포함된다는 뜻은 아님(원문이 명시한 usual 조합은 OnClass+OnMissingBean) — capability provider 선택처럼 property 기반 게이트를 쓰는 것이 이 문서의 표준 패턴이라는 주장의 근거로는 쓸 수 없음 |
## Usage Boundaries / 적용 경계
- **이 자료가 직접 증명하는 것**:
- `SB-COND-C1`: `@ConditionalOnProperty` 의 "존재 + 값≠false" 기본 매칭 규칙
- `SB-COND-C2`: `matchIfMissing` 이 실존하는 공식 attribute 라는 사실 (semantics 는 아님)
- `SB-COND-C3`: bean 존재 기반 조건은 bean definition 추가 순서에 의존하는 평가 시점 한계가 있음
- `SB-COND-C4`: 그 한계 때문에 `@ConditionalOnBean`/`@ConditionalOnMissingBean` 은 auto-configuration class 사용으로 한정 권고됨
- `SB-COND-C5`: condition annotation 의 usual 조합은 `@ConditionalOnClass`+`@ConditionalOnMissingBean`
- **이 자료가 증명하지 않는 것**:
- `matchIfMissing` 의 기본값(true/false) 자체 — 이 reference 페이지는 명시하지 않음(별도로 Javadoc 확인 필요, 본 URL 범위 밖)
- "`matchIfMissing=true` 를 금지해야 한다"는 명제가 Spring 공식 best practice 라는 것 — 이 자료는 그런 권고 문장을 포함하지 않는다. 금지 결정은 project/branch 의 안전-기본값(fail-closed) 설계 판단으로 별도 명시해야 함
- Bean 조건의 순서 의존 경고가 Property 조건(`@ConditionalOnProperty`)에도 문자 그대로 적용된다는 것 — 원문 절 구분상 별개 섹션
- **내 프로젝트(ca-skeleton) 에 적용하려면 추가 확인이 필요한 것**:
- `matchIfMissing` 의 실제 기본값과, 값을 명시하지 않았을 때(=옵션 자체를 안 쓸 때) capability descriptor 활성화가 어떻게 평가되는지 — Javadoc(`org.springframework.boot.autoconfigure.condition.ConditionalOnProperty`) 별도 조사 필요
- property 기반 조건이 실제로 순서 독립적인지(=Environment 기반이라 bean 등록 순서와 무관한지)를 이 문서만으로 확정할 수 없음 — 필요하면 Spring 소스 또는 별도 실험으로 검증
## 메모
> 검증되지 않은 내 해석. 인용 아님.
- 이 페이지가 `matchIfMissing` semantics를 설명하지 않는다는 사실 자체가 중요한 발견 — branch 결정문에 "공식 문서가 matchIfMissing=false 를 기본값으로 명시한다"처럼 쓰면 과장. 정확히는 "matchIfMissing 은 옵션으로 실재하지만 그 기본값·의미는 이 reference 페이지 밖(Javadoc)에서 확인해야 한다"로 표현할 것.
- `SB-COND-C3`/`SB-COND-C4` (bean 조건의 순서 의존성)는 matchIfMissing 자체보다는, project 가 이미 정한 "bean 이름 기반 판정 금지 → typed descriptor 기반 provider 선택" 결정을 보강하는 배경 근거로 더 적합해 보임.
- 동일 URL을 이미 인용한 `[[raw/official-docs/adapter-spring-boot-autoconfig-custom-starter]]` (ca-tmpl 프로젝트, `AutoConfiguration.imports`/custom starter naming 위주)와는 발췌 절이 다름 — 이 문서는 Property/Bean Conditions 절에 집중.
## 관련
- 같은 URL, 다른 발췌·다른 프로젝트: `[[raw/official-docs/adapter-spring-boot-autoconfig-custom-starter]]` (ca-tmpl, `AutoConfiguration.imports` + custom starter naming + `@ConditionalOnBooleanProperty` 위주)
- 인접 주제: `[[raw/official-docs/archunit-conditional-on-property-3-layer-pattern]]` (`@ConditionalOnProperty` 기반 adapter on/off 의 ArchUnit 정적 검사 한계)
- 이 자료를 인용한 wiki 요약: (생성 시 추가)
@@ -0,0 +1,84 @@
---
title: official-doc / Spring Boot Javadoc — ConditionalOnProperty (matchIfMissing default, havingValue, name, prefix)
source_type: official-doc
status: raw
confidence: high
url: https://docs.spring.io/spring-boot/api/java/org/springframework/boot/autoconfigure/condition/ConditionalOnProperty.html
archive_url:
related_branches: [feature-capability-provider-selection-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, architecture, spring-boot]
created: 2026-07-28
last_reviewed: 2026-07-28
---
# Spring Boot Javadoc — ConditionalOnProperty (matchIfMissing default, havingValue, name, prefix)
> Layer: `raw/official-docs/` — Spring Boot 4.1.0 **Javadoc API** 페이지(`org.springframework.boot.autoconfigure.condition.ConditionalOnProperty`) 발췌. 기존 `[[raw/official-docs/spring-boot-condition-annotations]]` (reference 문서, `matchIfMissing` 을 1회만 언급하고 semantics 를 설명하지 않음 — `SB-COND-C2`)가 남긴 공백을 메우기 위해 별도 수집.
## Parent / 활용 branch (필수)
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-capability-provider-selection-contract]] | D4("production capability 에 `matchIfMissing = true` 금지")의 **메커니즘** 근거 승급 — `matchIfMissing` 의 선언된 기본값이 `false` 이고, property 가 `Environment` 에 전혀 없을 때 기본적으로 조건이 match 되지 않는다는 사실이 공식 Javadoc 에 명시되어 있는지 확인 |
## 출처
- 원본 URL: https://docs.spring.io/spring-boot/api/java/org/springframework/boot/autoconfigure/condition/ConditionalOnProperty.html
- 아카이브 URL: (미수집)
- 저자 / 조직: Spring Team — Maciej Walkowiak, Stephane Nicoll, Phillip Webb (Javadoc `@author` 태그 명시) / Broadcom
- 발행일: 미상 — living Javadoc. 페이지 타이틀 기준 **Spring Boot 4.1.0 API**, `@since 1.1.0`
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`[[raw/branch-notes/feature-capability-provider-selection-contract]]` 의 D4("production capability 에 `matchIfMissing = true` 를 쓰지 않는다")는 기존에 `UNSUPPORTED_DECISION` 으로 라벨돼 있었다 — 이미 수집한 reference 문서(`spring-boot-condition-annotations.md`)는 `matchIfMissing` 을 36,781자 중 1회만 언급하고 기본값·semantics 를 설명하지 않기 때문이다(`SB-COND-C2` 의 Does not prove 참조). 이 Javadoc 페이지는 그 annotation element 자체의 공식 API 문서이며, `matchIfMissing` 의 선언된 기본값과 "property 부재 시 기본 동작"을 직접 서술한다. 이 자료로 D4 의 **메커니즘 축**(공식 문서가 증명하는 것)과 **정책 축**(내부 결정)을 분리할 수 있는지 확인하는 것이 수집 목적이다.
## 핵심 인용
> [class-level description] "If the property is not contained in the Environment at all, the matchIfMissing() attribute is consulted. By default missing attributes do not match."
> [Element Details § matchIfMissing] "Specify if the condition should match if the property is not set. Defaults to false." — `Default: false`
> [Optional Element Summary / Element Details § havingValue] "The string representation of the expected value for the properties. If not specified, the property must not be equal to false."
> [Element Details § prefix] "A prefix that should be applied to each property. The prefix automatically ends with a dot if not specified. A valid prefix is defined by one or more words separated with dots (e.g. "acme.system.feature")."
> [Element Details § name] "The name of the properties to test. If a prefix has been defined, it is applied to compute the full key of each property. For instance if the prefix is app.config and one value is my-value, the full key would be app.config.my-value"
## Claims Extracted / 추출된 주장
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| SB-COND-JD-C1 | `matchIfMissing` attribute 의 **선언된 기본값은 `false`** 다 | [Element Details § matchIfMissing] "Specify if the condition should match if the property is not set. Defaults to false." (+ `Default: false`) | `official-reference` | `@ConditionalOnProperty(matchIfMissing=...)` 를 명시적으로 지정하지 않았을 때 annotation element 자체의 기본값 | `matchIfMissing=true` 를 명시적으로 설정하는 것이 안티패턴이거나 금지해야 한다는 정책적 권고 — 이 문서는 그런 진술을 포함하지 않는다 |
| SB-COND-JD-C2 | property 가 `Environment`**전혀 존재하지 않을 때**, `matchIfMissing` 이 참조되며 **기본적으로(=미지정 시) 조건은 match 되지 않는다** — "property 미설정 = 조건 불일치"가 annotation class-level description 이 직접 서술하는 기본 동작이다 | [class-level description] "If the property is not contained in the Environment at all, the matchIfMissing() attribute is consulted. By default missing attributes do not match." | `official-reference` | property 완전 부재 상황에서 `@ConditionalOnProperty` 의 기본(=matchIfMissing 미지정) 동작. D4 의 **메커니즘** 축 근거 | 특정 capability(예: gRPC 서버, filesystem object storage)가 코드에서 이미 `matchIfMissing=true`**명시적으로 override** 한 경우의 실제 동작까지 바꾸지 않는다 — 그 6곳의 실제 설정 여부는 `internal-code-fact` 로 개별 확인해야 한다 |
| SB-COND-JD-C3 | `havingValue` 의 기본값은 빈 문자열(`""`)이며, 명시하지 않으면 property 값이 `"false"` 와 같지 않아야 match 된다 | [Element Details § havingValue] "The string representation of the expected value for the properties. If not specified, the property must not be equal to false." | `official-reference` | `havingValue` 미지정 시 매칭 규칙 (class-level description 의 "존재+값≠false" 규칙과 동일 규칙을 attribute 레벨에서 재확인) | `havingValue` 를 특정 문자열로 지정했을 때 세부 매칭 표(원문에 있으나 이 claim 의 인용 범위 밖) |
| SB-COND-JD-C4 | `prefix` 는 각 property 에 적용되는 접두어이며, 명시하지 않으면 자동으로 점(`.`)으로 끝나고, 점으로 구분된 하나 이상의 단어로 정의된 유효 prefix 를 갖는다(예: `"acme.system.feature"`) | [Element Details § prefix] "A prefix that should be applied to each property. The prefix automatically ends with a dot if not specified. A valid prefix is defined by one or more words separated with dots (e.g. "acme.system.feature")." | `official-reference` | `@ConditionalOnProperty(prefix=...)` 의 문법·기본 동작 | prefix 를 생략했을 때(기본값 `""`) 전체 키가 어떻게 계산되는지의 구체 규칙 자체(그건 `name` 설명이 규정, SB-COND-JD-C5) |
| SB-COND-JD-C5 | `name` 은 검사할 property 이름이며, `prefix` 가 정의돼 있으면 각 property 의 full key 계산에 적용된다(예: `prefix=app.config`, value=`my-value` → full key `app.config.my-value`) | [Element Details § name] "The name of the properties to test. If a prefix has been defined, it is applied to compute the full key of each property. For instance if the prefix is app.config and one value is my-value, the full key would be app.config.my-value" | `official-reference` | `name`/`prefix` 조합의 full key 계산 규칙 | dashed notation 강제(`my-long-property`)를 어기면 실제 binding 이 실패하는지 여부 — 원문은 "use" 를 권고할 뿐 강제 실패를 서술하지 않음 |
## Usage Boundaries / 적용 경계
- **이 자료가 직접 증명하는 것**:
- `SB-COND-JD-C1`: `matchIfMissing` 의 선언된 기본값은 `false`
- `SB-COND-JD-C2`: property 가 `Environment` 에 전혀 없을 때 (matchIfMissing 미지정 시) 기본적으로 조건은 match 되지 않는다
- `SB-COND-JD-C3`~`C5`: `havingValue`/`prefix`/`name` 각 attribute 의 정의와 기본값
- **이 자료가 증명하지 않는 것**:
- "production capability 에 `matchIfMissing=true` 를 쓰지 않는다"는 정책이 Spring 공식 best practice 라는 것 — Javadoc 은 mechanism(기본값·매칭 규칙)만 서술하고, 이 attribute 를 언제 쓰거나 쓰지 말아야 하는지에 대한 권고 문장을 포함하지 않는다
- `@Conditional` 계열 조건의 평가 시점·bean definition 순서 의존성 — 그 범위는 sibling `[[raw/official-docs/spring-boot-condition-annotations]]``SB-COND-C3`/`C4` 가 다룬다
- `@ConditionalOnProperty``@ConditionalOnBooleanProperty` 의 세부 차이 — "See Also" 링크만 존재하고 본문 비교 서술은 없음
- **내 프로젝트(ca-skeleton) 에 적용하려면 추가 확인이 필요한 것**:
- D4 Open Risk 로 남아 있는 6곳(`GrpcServerConfig.java:18-22`, `ObjectStorageConfig.java:32-37` 등)이 실제로 `matchIfMissing=true` 를 명시했는지, 그것이 §Audit A5 판정대로 "속성 부재로 활성화되는 운영 표면"인지는 `internal-code-fact` 로 개별 확인해야 한다 — 이 Javadoc 은 그 판정에 필요한 **기본값 규칙**만 제공한다
## 메모
> 검증되지 않은 내 해석. 인용 아님.
- D4 는 이 수집으로 **2축 승급** 가능: (1) 메커니즘 — "`matchIfMissing` 기본값은 `false`, property 부재 시 기본 동작은 조건 불일치"는 이제 `official-reference`(Javadoc) 근거를 갖는다(`SB-COND-JD-C1`/`C2`). (2) 정책 — "그래서 production capability 에는 이 attribute 를 명시적으로 `true` 로 켜지 않는다"는 여전히 `internal-policy`다. Javadoc 은 attribute 를 어떻게 쓰라는 권고를 하지 않기 때문. 두 축을 하나의 `official-vendor-doc`/`official-reference` 라벨로 뭉치지 말 것.
- 역설적으로 이 발견은 D4 의 위험 서술을 더 날카롭게 만든다: 기본값(`matchIfMissing` 미지정)이 이미 "property 부재 = 비활성"이므로, ca-tmpl 6곳이 실제로 위험한 이유는 "matchIfMissing 을 안 써서"가 아니라 "**명시적으로 override 해서**"다. §Audit A5(`GrpcServerConfig`/`ObjectStorageConfig`)가 실제로 `matchIfMissing=true` 를 썼는지 코드에서 재확인 필요 — 이 raw 문서만으로는 그 코드 사실을 증명하지 못한다.
- self-grep 결과 이 Javadoc 페이지 전체에서 `matchIfMissing`(식별자)은 6회 등장 — 목차(1) + class description(2) + Optional Element Summary(1) + Element Details 제목/시그니처(2)이며, 그중 semantics 를 서술하는 문장은 class description 1곳 + Element Details 1곳 총 2곳뿐이다.
## 관련
- 같은 annotation, 다른 문서 계층(reference guide, `matchIfMissing` 존재만 언급): `[[raw/official-docs/spring-boot-condition-annotations]]`
- 인접 주제 (`@ConditionalOnProperty` 기반 adapter on/off 의 ArchUnit 정적 검사 한계): `[[raw/official-docs/archunit-conditional-on-property-3-layer-pattern]]`
- 이 자료를 인용한 wiki 요약: (생성 시 추가)
@@ -0,0 +1,91 @@
---
title: "official-doc / Spring Data MongoDB — Index Creation (Automatic vs Programmatic)"
source_type: official-doc
url: https://docs.spring.io/spring-data/mongodb/reference/mongodb/mapping/mapping-index-management.html
archive_url:
related_branches: [feature-mongo-runtime-baseline-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, persistence, spring-data, mongodb]
created: 2026-07-28
---
# Spring Data MongoDB — Index Creation (Automatic vs Programmatic)
> Layer: `raw/` — 외부 자료(공식 문서)의 **원문 발췌·출처 기록**.
> Spring Data MongoDB Reference — Mapping Index Creation
## Parent / 활용 branch (필수)
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-mongo-runtime-baseline-contract]] | "index 는 애플리케이션 automatic index creation 에 맡기지 않고 선언적 manifest + migration runner 로 관리하며 drift 를 감지한다" 결정의 근거 후보 — automatic index creation 기본값이 버전 3.0부터 OFF 라는 것과, 명시적(programmatic) index 생성이 "Recommended" 로 표기되어 있음을 공식 문서로 확인. 단 "manifest"/"migration runner"/"drift 감지" 라는 구체 메커니즘 자체는 이 문서가 규정하지 않음 (아래 Usage Boundaries 참조) — branch-note 작성 시 `UNSUPPORTED_IMPL_DECISION` 라벨 필요 여부 검토 대상 |
## 출처 / Source
- 원본 URL: https://docs.spring.io/spring-data/mongodb/reference/mongodb/mapping/mapping-index-management.html
- 아카이브 URL: (미확보)
- 저자 / 조직: Spring Data Team (Broadcom / VMware)
- 발행일: Spring Data MongoDB Reference (페이지 자체에 버전 배지 없음 — "since version 3.0" 문구로 최소 3.0 이상 대상 문서임만 확인됨)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지 / Why archived
`feature-mongo-runtime-baseline-contract` branch 의 index 관리 결정("automatic index creation 에 맡기지 않고 선언적 manifest + migration runner + drift 감지")이 아직 외부 근거 없이 스캐폴딩된 상태였음. Spring Data MongoDB 공식 문서에서 (1) automatic index creation 기본값이 버전 3.0부터 OFF 라는 것, (2) 이를 켰을 때의 위험 고지("undesired effects on collection lifecycle and performance") 및 "Production consideration" 경고, (3) `@Indexed`/`@CompoundIndex` 선언 방식과 `IndexResolver`/`IndexOperations` 프로그래매틱 방식이 모두 존재한다는 것을 확인하기 위해 아카이브.
## 핵심 인용 / Key quotes (verbatim, 6개)
> 셀프그렙 검증 원본: `/tmp/claude-1000/-home-donghyeon-workspace-ai-tool-llm-wiki/1f67282d-364b-47cb-ba22-616233fae6ea/scratchpad/source-fetch-20260728-165953.txt` (WebFetch 결과 저장, self-grep 통과)
> [§Overview / Default Behavior, self-grep L8] "**Automatic index creation is turned OFF by default as of version 3.0**"
> [§Overview, self-grep L5] "Spring Data MongoDB can automatically create indexes for entity types annotated with `@Document`. However, **index creation must be explicitly enabled** since version 3.0 to prevent undesired effects on collection lifecycle and performance."
> [§Programmatic Index Creation (Recommended), self-grep L29] "Spring Data recommends **explicit index creation for application-based control**. Use `IndexResolver` with `IndexOperations` on application startup, triggered by `ContextRefreshedEvent`:"
> [§Index Annotations / Compound Indexes (code), self-grep L90] "@CompoundIndex(name = "age_idx", def = "{'lastName': 1, 'age': -1}")"
> [§Important Notes, self-grep L182] "**Production consideration**: Explicit index creation provides better control than automatic creation"
> [§Important Notes, self-grep L183] "Index creation via `IndexOperations` offers more control than annotations"
## Claims Extracted / 추출된 주장
> 이 자료가 **직접 말하는 것만** claim 으로 분리한다. 내 프로젝트에 적용한 결론은 여기 쓰지 않는다.
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| SD-MONGO-INDEX-C1 | Spring Data MongoDB 는 버전 3.0부터 automatic index creation 이 기본적으로 꺼져 있다(OFF by default) | [§Default Behavior, l.8] "**Automatic index creation is turned OFF by default as of version 3.0**" | `official-vendor-doc` | Spring Data MongoDB 3.0 이상의 `@Document` 엔티티 | 3.0 이전 버전의 정확한 기본값(암묵적으로 ON 이었음을 시사하나 이 문서가 3.0 이전 동작을 직접 재확인하지는 않음), 그리고 우리 프로젝트가 실제 3.0+ 를 쓰는지 여부 |
| SD-MONGO-INDEX-C2 | automatic index creation 은 "collection lifecycle 과 performance 에 대한 원치 않는 영향(undesired effects)"을 막기 위해 명시적으로 활성화되어야 한다 | [§Overview, l.5] "However, **index creation must be explicitly enabled** since version 3.0 to prevent undesired effects on collection lifecycle and performance." | `official-vendor-doc` | automatic index creation 활성화 여부를 결정할 때의 공식 위험 고지 | "프로덕션에서 금지" 또는 "권장하지 않음" 이라는 명시적 문구는 없음 — "undesired effects" 라는 일반적 경고만 있고, 구체적으로 무엇이 위험한지(예: 대용량 컬렉션에서 인덱스 빌드가 쓰기를 블록하는지 등)는 이 페이지에 설명되지 않음 |
| SD-MONGO-INDEX-C3 | "Programmatic Index Creation" 섹션은 "(Recommended)" 로 표기되어 있으며, 애플리케이션 기반 제어를 위한 명시적 index 생성을 권장한다. 구체적으로 `IndexResolver``IndexOperations``ContextRefreshedEvent` 시점에 사용하는 패턴을 제시한다 | [§Programmatic Index Creation (Recommended), l.29] "Spring Data recommends **explicit index creation for application-based control**. Use `IndexResolver` with `IndexOperations` on application startup, triggered by `ContextRefreshedEvent`:" | `official-vendor-doc` | 애플리케이션 시작 시점에 index 를 프로그래매틱하게 생성/보장하려는 설계 | "manifest 파일"이나 "migration runner"라는 개념·도구를 이 문서가 규정하지 않는다 — `ContextRefreshedEvent` 리스너에서 `resolver.resolveIndexFor(...).forEach(indexOps::ensureIndex)` 를 호출하는 것이 문서가 보여주는 유일한 패턴이며, 별도 migration 이력 관리나 drift 감지는 이 문서 범위 밖 |
| SD-MONGO-INDEX-C4 | Spring Data MongoDB 는 `@Indexed`, `@CompoundIndex`/`@CompoundIndexes`, `@HashIndexed`, `@WildcardIndexed`, `@TextIndexed` 같은 매핑 애노테이션을 통한 선언적 index 정의 방식을 제공한다 | [§Index Annotations / Compound Indexes, l.90] `@CompoundIndex(name = "age_idx", def = "{'lastName': 1, 'age': -1}")` | `official-vendor-doc` | `@Document` 엔티티 클래스/필드에 선언하는 index 정의 | 애노테이션 방식이 deprecated 되었거나 사용을 피해야 한다는 것 — 오히려 `IndexResolver` 가 이 애노테이션들을 읽어 인덱스를 계산하는 상호보완 관계 (C5 참조) |
| SD-MONGO-INDEX-C5 | "Important Notes" 섹션은 explicit index creation 이 automatic creation 보다 더 나은 제어를 제공한다는 production 관련 권고와, `IndexOperations`(프로그래매틱)가 annotation(선언적) 보다 더 많은 제어력을 제공한다는 비교를 명시한다 | [§Important Notes, l.182183] "**Production consideration**: Explicit index creation provides better control than automatic creation" / "Index creation via `IndexOperations` offers more control than annotations" | `official-vendor-doc` | explicit(programmatic) index 생성 전략을 선택하는 결정의 공식 근거 | "annotation 기반 선언 방식을 쓰지 말라"는 것은 아님 — 프로그래매틱 방식이 "더 많은 제어력"을 준다는 상대적 비교일 뿐, annotation 방식이 프로덕션에 부적합하다고 말하지 않음 |
## Usage Boundaries / 적용 경계
- 이 자료가 직접 증명하는 것:
- `SD-MONGO-INDEX-C1`: automatic index creation 기본값은 버전 3.0부터 OFF (auto-index-creation 에 맡기지 않는다는 결정의 "이미 기본이 꺼져 있다"는 전제 근거)
- `SD-MONGO-INDEX-C2`: 자동 생성을 켤 경우 collection lifecycle/performance 에 원치 않는 영향이 있을 수 있다는 공식 위험 고지
- `SD-MONGO-INDEX-C3`: 명시적/프로그래매틱 index 생성이 "Recommended" 섹션 표제로 제시됨 (`IndexResolver` + `IndexOperations` + `ContextRefreshedEvent` 패턴)
- `SD-MONGO-INDEX-C4`: `@Indexed`/`@CompoundIndex` 등 선언적 index 애노테이션의 존재와 문법
- `SD-MONGO-INDEX-C5`: explicit(programmatic) 방식이 automatic 방식 및 annotation-only 방식보다 "더 많은 제어력"을 제공한다는 공식 비교
- 이 자료가 증명하지 않는 것:
- **"manifest" 라는 개념** — 이 문서는 index 정의를 코드(애노테이션) 또는 `IndexResolver`/`IndexOperations` 호출로 표현하는 두 가지만 보여줄 뿐, 별도의 선언적 "manifest 파일"(YAML/JSON 등 외부 스펙 문서)이라는 아키텍처 패턴을 언급하거나 권장하지 않는다. manifest 도입은 branch 의 설계 결정이며 이 문서로 정당화되지 않는다
- **"migration runner"** — Flyway/Liquibase 류의 버전 관리형 migration 도구·이력 테이블 개념은 이 문서에 없다. 문서가 보여주는 것은 `ContextRefreshedEvent` 시점에 `ensureIndex` 를 매번 재실행하는 idempotent-보장 패턴뿐이며, "migration" 이력 추적은 범위 밖
- **"drift 감지"** — 실제 컬렉션 인덱스와 코드/manifest 상 정의된 인덱스 간의 불일치를 탐지하는 메커니즘은 이 문서에 없다. `IndexOperations`/`IndexResolver` 는 인덱스를 "생성/보장(ensure)"하는 API 이지 "비교/보고(diff)"하는 API 라고 문서가 명시하지 않는다
- "자동 인덱스 생성이 프로덕션에서 금지된다" 또는 "권장되지 않는다"는 명시적 문구 — C2/C5 는 "위험 고지"와 "더 나은 제어"라는 상대적 표현만 있을 뿐, 절대적 금지 표현은 없다
- replica-set 트랜잭션이나 change stream 과 index 관리의 상호작용 — 이 페이지는 index 생성 메커니즘만 다루며, 이 branch 의 다른 범위(트랜잭션·change stream replica-set 요건)는 별도 근거 필요
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- "선언적 manifest" 를 실제로 어떤 형식(코드 상 애노테이션 집합을 manifest 로 간주할지, 별도 YAML 스펙을 만들지)으로 정의할지는 branch 의 별도 결정이며, 이 문서만으로는 근거가 부족함 — `UNSUPPORTED_IMPL_DECISION` 라벨 + trade-off 명시 필요
- "migration runner" 의 구체 구현(예: 자체 `IndexOperations` 래퍼가 있는 컬렉션에 인덱스 적용 이력을 별도 컬렉션에 기록할지)도 이 문서 범위 밖이므로 별도 설계·근거 필요
- drift 감지를 위한 실제 비교 로직(`listIndexes()` 커맨드 등 MongoDB 드라이버 API 조사 필요)은 별도 raw 자료로 보강해야 함
## 메모 / Notes
- 이 문서는 "automatic index creation 을 쓰지 말라"는 근거로는 충분하지만(C1+C2), branch 가 결정하려는 "manifest + migration runner + drift 감지"라는 구체 아키텍처는 이 문서의 범위를 넘어선다. `/branch-spec` 단계에서 D-row 를 작성할 때, 이 raw 자료는 "자동 생성 대신 명시적 생성" 부분만 뒷받침하고 나머지(manifest 형식·migration 이력·drift 비교)는 `UNSUPPORTED_IMPL_DECISION` 으로 라벨링하거나 별도 근거(MongoDB 드라이버 `listIndexes` 공식 문서, Flyway-Mongo 유사 도구 사례 등)를 추가 수집해야 한다.
- `IndexResolver`/`IndexOperations` 조합이 애노테이션 메타데이터를 읽어 인덱스를 계산하는 구조이므로, "manifest" 를 이 애노테이션 집합 자체로 간주하는 설계도 가능해 보이지만 이는 내 해석이며 문서가 그렇게 명명하지는 않는다 (검증 안 된 추론 — wiki 단계에서 별도 확인 필요).
- 추가로 봐야 할 동일 출처 페이지: Spring Data MongoDB Reference 의 read preference / read concern / write concern 페이지, replica-set 트랜잭션 요건 페이지, change stream resume token 페이지 (같은 branch 의 다른 결정 근거).
## Related / 관련
- 같은 주제 다른 official-doc: (아직 없음 — MongoDB read/write concern, change streams 공식 문서는 이 branch 를 위해 별도 수집 필요)
- 이 자료를 인용한 wiki 요약: (생성 시 추가)
@@ -0,0 +1,102 @@
---
title: "official-doc / Spring Data MongoDB — Template Configuration (WriteConcern, WriteConcernResolver, ReadPreference)"
source_type: official-doc
url: https://docs.spring.io/spring-data/mongodb/reference/mongodb/template-config.html
archive_url:
related_branches: [feature-mongo-runtime-baseline-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, persistence, spring-data, mongodb, write-concern]
created: 2026-07-28
---
# official-doc / Spring Data MongoDB — Template Configuration (WriteConcern, WriteConcernResolver, ReadPreference)
> Layer: `raw/` — 외부 자료(공식 문서)의 **원문 발췌·출처 기록**.
> Spring Data MongoDB Reference — MongoDB Support Configuration
## source_type 허용값
`source_type: official-doc` — Spring Data MongoDB 공식 레퍼런스 문서 (Broadcom / VMware, Spring Data Team).
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-mongo-runtime-baseline-contract]] | D5(read concern 연산 단위 명시)·D6(write concern 명시)·D7(read preference 명시) 결정에서 **Spring Data MongoDB 가 그 값을 어디에 어떻게 붙이는지(적용 seam)** 를 확인. `MongoTemplate` 수준 `WriteConcern` 기본값 + `WriteConcernResolver` 연산별 전략 인터페이스 + `ReadPreference` 가 template 의 설정 가능한 property 목록에 있다는 것을 근거로 확보. **단, read concern 설정 수단은 이 문서에 부재** — D5 의 Spring Data 측 적용 seam 은 이 자료로 닫히지 않는다(아래 Usage Boundaries 참조) |
## 출처
- 원본 URL: https://docs.spring.io/spring-data/mongodb/reference/mongodb/template-config.html
- 아카이브 URL: (미제공)
- 저자 / 조직: Spring Data Team (Broadcom / VMware)
- 발행일: Spring Data MongoDB Reference 버전 5.1.0 (페이지 내 버전 배지 기준, 날짜 미표기 롤링 레퍼런스)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-mongo-runtime-baseline-contract` branch 가 read/write concern·read preference 를 "명시" 하기로 결정했으나, `MongoTemplate` API 어디에 그 값을 붙이는지(적용 seam)의 근거가 없어 구현자가 되묻게 되는 gap 이 있었다. 이 문서는 `MongoTemplate` 수준 기본 `WriteConcern`, 연산 단위 결정을 위한 `WriteConcernResolver`, `ReadPreference` 가 template 의 설정 가능한 property 라는 것을 공식 문서로 확인하기 위해 보관. read concern 은 이 문서에 **전혀 등장하지 않아** 그 부재 자체가 중요한 기록이다.
## 핵심 인용
> 셀프그렙 검증 원본: `/tmp/source-fetch-mongo-template.txt` (curl 직접 fetch — WebFetch 1차 결과가 실제 원문에 없는 메서드명을 생성한 사실이 발견되어, HTML 원본을 직접 curl 로 가져와 텍스트 추출한 뒤 재검증함. 아래 메모 참조)
> [§Configuration, l.266] "Other optional properties that you might like to set when creating a MongoTemplate / ReactiveMongoTemplate are the default WriteResultCheckingPolicy, WriteConcern, ReadPreference and others listed below."
> [§Default Read Preference, l.270] "The default read preference applied to read operations if no other preference was defined via the Query."
> [§Default WriteConcern, l.278] "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 for write operations. If the WriteConcern property is not set, it defaults to the one set in the MongoDB driver's DB or Collection setting."
> [§WriteConcernResolver, l.282] "For more advanced cases where you want to set different WriteConcern values on a per-operation basis (for remove, update, insert, and save operations), a strategy interface called WriteConcernResolver can be configured on MongoTemplate."
> [§WriteConcernResolver (interface), l.284-285] "public interface WriteConcernResolver {\n WriteConcern resolve(MongoAction action);\n}"
> [§WriteConcernResolver, l.289] "MongoAction contains the collection name being written to, the java.lang.Class of the POJO, the converted Document, the operation (REMOVE, UPDATE, INSERT, INSERT_LIST, or SAVE), and a few other pieces of contextual information."
## Claims Extracted
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| SD-MONGO-TEMPLATE-C1 | `MongoTemplate`/`ReactiveMongoTemplate` 생성 시 설정 가능한 선택적 property 목록에 `WriteResultCheckingPolicy`·`WriteConcern`·`ReadPreference` 가 포함된다 | [l.266] "Other optional properties that you might like to set when creating a MongoTemplate / ReactiveMongoTemplate are the default WriteResultCheckingPolicy, WriteConcern, ReadPreference and others listed below." | `official-vendor-doc` | `ReadPreference` 를 template 레벨에서 명시할 수 있는 수단이 존재한다는 것(D7 의 "표현 수단 실재" 근거) | 실제 setter 메서드 이름(예: `setReadPreference`)이나 시그니처는 이 페이지 텍스트에 없다(self-grep 0 매치) — 목록에 있다는 사실만 확인되고 API 형태는 확인되지 않음 |
| SD-MONGO-TEMPLATE-C2 | "기본 read preference"란 Query 로 별도 preference 가 지정되지 않았을 때 read 연산에 적용되는 값이다 | [l.270] "The default read preference applied to read operations if no other preference was defined via the Query." | `official-vendor-doc` | template 레벨 기본값과 Query 레벨 override 의 계층 관계(Query 가 우선) — D7 의 "연산 단위 표현 수단이 있는가" 판단에 사용 | read concern 에 대한 동일한 계층 서술은 이 페이지에 없다 — 이 문서 전체에 "read concern"/"ReadConcern" 문자열이 0회 등장(self-grep 확인). read concern 을 연산 단위로 명시할 template/query 수준 수단이 있는지는 이 자료로 증명되지 않는다 |
| SD-MONGO-TEMPLATE-C3 | `MongoTemplate``WriteConcern``MongoClient` 같은 드라이버 상위 레벨에서 지정되지 않았을 때만 적용되며, 미설정 시 MongoDB 드라이버의 DB/Collection 설정값으로 폴백한다 | [l.278] "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 for write operations. If the WriteConcern property is not set, it defaults to the one set in the MongoDB driver's DB or Collection setting." | `official-vendor-doc` | template 레벨 write concern 설정과 driver(MongoClient) 레벨 설정의 우선순위 — D6 "write concern 을 명시" 결정의 적용 seam 근거 | 정확한 setter 메서드 이름(`setWriteConcern`)은 이 페이지 텍스트에 없다(self-grep 0 매치) — "you can set the ... property" 라는 prose 표현만 있다. 이 default WriteConcern 은 template 전체의 기본값이지 연산 단위 값이 아니다 |
| SD-MONGO-TEMPLATE-C4 | 연산 단위(remove/update/insert/save)로 다른 `WriteConcern` 값을 적용하려면 `WriteConcernResolver` 전략 인터페이스를 `MongoTemplate` 에 구성할 수 있다 | [l.282] "For more advanced cases where you want to set different WriteConcern values on a per-operation basis (for remove, update, insert, and save operations), a strategy interface called WriteConcernResolver can be configured on MongoTemplate." | `official-vendor-doc` | **D6 이 찾던 "연산 단위 write concern 결정 수단"의 실제 답** — write concern 한정으로 존재가 확인됨 | `WriteConcernResolver``WriteConcern` 전용이다. 동일한 전략 인터페이스가 `ReadConcern` 이나 `ReadPreference` 에도 존재하는지는 이 페이지가 증명하지 않는다(`ReadConcernResolver`/`ReadPreferenceResolver` 류의 언급 없음, self-grep 0 매치) |
| SD-MONGO-TEMPLATE-C5 | `WriteConcernResolver` 인터페이스 시그니처는 `WriteConcern resolve(MongoAction action)` 이다 | [l.284-285] "public interface WriteConcernResolver {\n WriteConcern resolve(MongoAction action);\n}" | `official-reference` | 구현 시 준수해야 하는 정확한 인터페이스 계약 | 없음 — API 시그니처 자체의 직접 인용 |
| SD-MONGO-TEMPLATE-C6 | `MongoAction` 은 쓰기 대상 컬렉션명, POJO 의 `java.lang.Class`, 변환된 `Document`, 연산 종류(REMOVE/UPDATE/INSERT/INSERT_LIST/SAVE), 기타 컨텍스트 정보를 담는다 | [l.289] "MongoAction contains the collection name being written to, the java.lang.Class of the POJO, the converted Document, the operation (REMOVE, UPDATE, INSERT, INSERT_LIST, or SAVE), and a few other pieces of contextual information." | `official-reference` | `WriteConcernResolver.resolve()` 구현체가 결정에 사용할 수 있는 컨텍스트 정보의 범위 | 이 컨텍스트에 read concern/read preference 관련 필드가 포함되는지는 언급되지 않는다 — `MongoAction` 은 write 연산 전용 컨텍스트로 보이며, 이 페이지는 read 연산에 대한 동등한 연산 단위 결정 메커니즘을 제공하지 않는다 |
### Strength 허용값
- `official-standard` — RFC, 표준 사양, 언어/프로토콜 표준
- `official-vendor-doc` — Spring, Keycloak, AWS, Google, MongoDB 등 공식 벤더 문서
- `official-reference` — 공식 reference/API 문서
- `company-case-study` — 대기업/실무 기술 블로그의 특정 사례
- `engineering-blog` — 개인/팀 블로그의 엔지니어링 해설
- `tutorial` — 튜토리얼/가이드. 일반화 금지
- `needs-confirmation` — 원문만으로는 적용 판단 불가
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `SD-MONGO-TEMPLATE-C1`: `ReadPreference``MongoTemplate` 의 설정 가능한 property 목록에 포함된다는 것
- `SD-MONGO-TEMPLATE-C2`: template 기본 read preference 와 Query 레벨 override 의 계층 관계(Query 우선)
- `SD-MONGO-TEMPLATE-C3`: template `WriteConcern` 과 driver(`MongoClient`) 레벨 설정의 우선순위·폴백 관계
- `SD-MONGO-TEMPLATE-C4`·`C5`·`C6`: `WriteConcernResolver` 를 통한 **연산 단위 write concern 결정 수단**의 존재와 인터페이스 계약, 사용 가능한 컨텍스트
- 이 자료가 증명하지 않는 것 (중요 — read concern 부재):
- **read concern 을 `MongoTemplate`/Query 단위로 설정하는 수단** — 이 페이지 전체(`/tmp/source-fetch-mongo-template.txt`, curl 원본 텍스트)에 "read concern"/"ReadConcern" 문자열이 **0회** 등장한다(self-grep 확인). 즉 D5(read concern 연산 단위 명시) 결정의 Spring Data 측 적용 seam 은 **이 자료로 근거를 확보할 수 없다** — branch-note 에는 `UNSUPPORTED_DECISION` 후보로 승계해야 한다
- `setWriteConcern`/`setReadPreference` 라는 정확한 Java 메서드 시그니처 — 이 페이지는 "you can set the ... property" 라는 prose 로만 설명하고 실제 setter 메서드 이름/코드를 보여주지 않는다(self-grep 0 매치)
- `WriteConcernResolver` 와 동등한 연산별 read concern/read preference resolver — 이 페이지에 존재하지 않는다
- Query 객체를 통한 read preference override 의 구체적 API — 이 페이지는 "Query Read Preference" 섹션(`template-query-operations.html#mongo.query.read-preference`)으로 링크만 하고 본문에서 API 형태를 보여주지 않는다
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- read concern 적용 seam은 별도 raw 자료(예: MongoDB Java 드라이버 `MongoCollection.withReadConcern()` 또는 Spring Data MongoDB 의 `ClientSession`/`SessionScoped` 관련 공식 문서)로 보강 필요 — 1 dispatch = 1 URL 원칙에 따라 이번 회차에는 포함하지 않음
- Query 레벨 read preference override 의 정확한 API(`Query#withReadPreference` 등 존재 여부)는 `template-query-operations.html#mongo.query.read-preference` 별도 확인 필요
- `WriteConcernResolver` 를 실제로 어디에 등록/구성(`@Bean` 설정 예시 등)하는지는 이 페이지에 없음 — `template.setWriteConcernResolver(...)` 형태로 붙이는지 여부는 미확인
## 메모
- **WebFetch 1차 결과 fabrication 발견**: 최초 `WebFetch` 호출(원문 그대로 추출 프롬프트)이 반환한 텍스트는 `template.setWriteConcern(WriteConcern.ACKNOWLEDGED);`, `template.setReadPreference(ReadPreference.secondary());` 같은 구체적 메서드 호출 코드를 포함했다. 그러나 이 페이지를 `curl` 로 직접 fetch 해 HTML 을 텍스트로 변환한 원본(`/tmp/source-fetch-mongo-template.txt`)에는 `setWriteConcern`/`setReadPreference` 문자열이 **0회** 등장한다. 즉 WebFetch 의 1차 요약(소형 모델 후처리)이 실제로 페이지에 없는 API 호출 예시를 생성(fabrication)한 것으로 판단해 폐기했고, 본 문서의 모든 인용·Claim 은 curl 원본 재검증을 거친 것만 사용했다. 이 자료 수집 과정 자체가 "WebFetch 출력을 그대로 원문으로 신뢰하면 안 된다"는 사례 기록으로도 남긴다.
- D5(read concern) 는 이 문서로 닫히지 않는다 — branch-note 작성 시 이 gap 을 `UNSUPPORTED_DECISION` 으로 명시할 것.
- D6(write concern 연산 단위 명시)와 D7(read preference 명시)의 Spring Data 측 seam 은 이 문서로 부분적으로 닫힌다 — write concern 은 `WriteConcernResolver` 로 완전히 닫히고, read preference 는 "template property 로 설정 가능하다"는 존재 확인까지만 닫히고 정확한 API 형태는 미확인.
## Related
- 같은 주제 다른 official-doc: [[raw/official-docs/write-concern-mongodb-official]] (MongoDB 서버 측 write concern 의미) · [[raw/official-docs/read-concern-mongodb-official]] (MongoDB 서버 측 read concern 의미 — 단, Spring Data 적용 seam 은 별도) · [[raw/official-docs/read-preference-mongodb-official]] (MongoDB 서버 측 read preference 5종 모드) · [[raw/official-docs/spring-data-mongodb-index-management-official]] (같은 Spring Data MongoDB Reference 문서군)
- 이 자료를 인용한 wiki 요약: (생성 시 추가)
@@ -0,0 +1,87 @@
---
title: "official-doc / Spring Data Redis — Scripting execution (RedisScript, ScriptExecutor, EVALSHA/NOSCRIPT fallback)"
source_type: official-doc
url: https://docs.spring.io/spring-data/redis/reference/redis/scripting.html
archive_url:
related_branches: [feature-redis-atomic-program-catalog-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, persistence, redis, spring-data]
created: 2026-07-28
---
# Spring Data Redis — Scripting execution (RedisScript, ScriptExecutor, EVALSHA/NOSCRIPT fallback)
> Layer: `raw/official-docs/` — Spring Data Redis 공식 reference ("Scripting" 페이지, 버전 4.1.0) 원문 발췌.
> ca-skeleton `feature-redis-atomic-program-catalog-contract` 의 "EVALSHA 우선 실행 + NOSCRIPT 재적재를 직접 구현할지, 프레임워크(Spring Data Redis) 기존 script executor 에 위임할지" 결정 근거.
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-redis-atomic-program-catalog-contract]] | EVALSHA 우선 실행 + NOSCRIPT 재적재를 직접 구현하지 않고 Spring Data Redis 의 기본 `ScriptExecutor` 에 위임하며, `RedisScript`/`DefaultRedisScript` 위에 typed facade 를 얹는 API 설계의 근거 |
## 출처
- 원본 URL: https://docs.spring.io/spring-data/redis/reference/redis/scripting.html
- 아카이브 URL: (미수집)
- 저자 / 조직: Spring (VMware/Broadcom) — Spring Data Redis 공식 reference
- 발행일: 명시된 발행일 없음(living reference doc). fetch 시점 페이지 메타데이터 기준 버전 `4.1.0` (`data-version="4.1.0"`, GitHub 소스 `spring-projects/spring-data-redis/blob/4.1.0/...`) — Spring Boot 4.0 세대와 정렬되는 최신판
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-redis-atomic-program-catalog-contract` 는 다단계 read-decide-write 를 버전 있는 atomic Lua program 으로 제공해야 하고, EVALSHA 실행 + NOSCRIPT 시 안전 재적재가 그 핵심 메커니즘이다. Redis 자체 공식 문서(`raw/official-docs/redis-eval-scripting-atomicity.md`)는 "애플리케이션이 EVALSHA 를 호출하고 NOSCRIPT 시 SCRIPT LOAD 후 재시도해야 한다"는 프로토콜 수준 규약만 증명하고, 어떤 client 라이브러리가 이를 자동화하는지는 범위 밖이라고 명시한다. 본 자료는 그 공백을 메운다 — Spring Data Redis 의 기본 `ScriptExecutor` 가 이 EVALSHA→NOSCRIPT→EVAL 재시도를 실제로 자동 처리한다는 프레임워크 공식 근거이며, 따라서 branch 가 직접 구현할지 위임할지를 결정하는 데 직접 쓰인다.
## 핵심 인용
> [§Scripting] "Scripts can be run by calling the execute methods of RedisTemplate and ReactiveRedisTemplate. Both use a configurable ScriptExecutor (or ReactiveScriptExecutor) to run the provided script. By default, the ScriptExecutor (or ReactiveScriptExecutor) takes care of serializing the provided keys and arguments and deserializing the script result. This is done through the key and value serializers of the template. There is an additional overload that lets you pass custom serializers for the script arguments and the result."
> [§Scripting] "The default ScriptExecutor optimizes performance by retrieving the SHA1 of the script and attempting first to run evalsha, falling back to eval if the script is not yet present in the Redis script cache."
> [§Scripting] "The preceding code configures a RedisScript pointing to a file called checkandset.lua, which is expected to return a boolean value. The script resultType should be one of Long, Boolean, List, or a deserialized value type. It can also be null if the script returns a throw-away status (specifically, OK)."
> [§Scripting, tip admonition] "It is ideal to configure a single instance of DefaultRedisScript in your application context to avoid re-calculation of the script's SHA1 on every script run."
> [§Scripting, 코드 예시] "ScriptSource scriptSource = new ResourceScriptSource(new ClassPathResource("META-INF/scripts/checkandset.lua")); return RedisScript.of(scriptSource, Boolean.class);"
> [§Scripting, 코드 예시] "return redisOperations.execute(script, List.of("key"), expectedValue, newValue);"
## Claims Extracted
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| SDR-SCRIPT-C1 | Spring Data Redis 의 기본 `ScriptExecutor`(`RedisTemplate`/`ReactiveRedisTemplate.execute(...)` 경로)는 스크립트의 SHA1 을 조회해 먼저 `evalsha` 실행을 시도하고, 스크립트가 Redis 스크립트 캐시에 아직 없으면 `eval` 로 폴백한다 — 즉 EVALSHA 우선 + NOSCRIPT 시 재적재를 프레임워크가 자동 처리한다 | "The default ScriptExecutor optimizes performance by retrieving the SHA1 of the script and attempting first to run evalsha, falling back to eval if the script is not yet present in the Redis script cache." | `official-vendor-doc` | `RedisTemplate`/`ReactiveRedisTemplate.execute(RedisScript, keys, args...)` 경로의 기본 `ScriptExecutor`/`ReactiveScriptExecutor` 구현 | 어떤 예외/에러 타입으로 NOSCRIPT 를 감지하는지, fallback 후 서버 캐시에 재적재되는지, 커스텀 `ScriptExecutor` 구현체의 동작, cluster/샤딩 환경에서의 동작은 증명하지 않음 |
| SDR-SCRIPT-C2 | `RedisTemplate`/`ReactiveRedisTemplate``execute` 메서드를 통해 설정 가능한 `ScriptExecutor`/`ReactiveScriptExecutor` 에 실행을 위임하며, 기본적으로 이 executor 가 전달된 key/인자를 직렬화하고 스크립트 결과를 역직렬화한다(템플릿의 key/value serializer 사용), 커스텀 직렬화기를 넘기는 오버로드도 존재한다 | "Scripts can be run by calling the execute methods of RedisTemplate and ReactiveRedisTemplate. [...] There is an additional overload that lets you pass custom serializers for the script arguments and the result." | `official-vendor-doc` | typed facade 설계 시 `execute(script, keys, args...)` 시그니처와 직렬화 책임 분담 근거 | 정확한 Java 오버로드 전체 목록, `ScriptOutputType` 열거값과의 매핑 관계는 증명하지 않음(이 페이지에 `ScriptOutputType` 자체가 등장하지 않음) |
| SDR-SCRIPT-C3 | 예시 코드는 `execute``RedisScript`, `List<K>` 형태의 KEYS, 그리고 나머지 가변인자 형태의 ARGV 를 받는 형태임을 보여준다 | "return redisOperations.execute(script, List.of("key"), expectedValue, newValue);" | `official-vendor-doc` | keys/args 전달 방식(List + varargs) 설계 근거 | 이 예시 1건이 모든 오버로드(커스텀 직렬화 포함 포함)를 대표한다고 증명하지 않음 |
| SDR-SCRIPT-C4 | `RedisScript``resultType``Long`, `Boolean`, `List`, 또는 역직렬화된 값 타입 중 하나여야 하며, 스크립트가 `OK` 같은 throw-away 상태를 반환하면 `null` 도 허용된다 | "The script resultType should be one of Long, Boolean, List, or a deserialized value type. It can also be null if the script returns a throw-away status (specifically, OK)." | `official-vendor-doc` | golden vector / typed facade 반환 타입 계약 설계 | `ScriptOutputType` enum 자체의 존재나 값 목록, 역직렬화 실패 시 예외 타입은 이 페이지에서 확인되지 않음 |
| SDR-SCRIPT-C5 | `DefaultRedisScript` 는 애플리케이션 컨텍스트에 단일 인스턴스로 구성해 매 실행마다 스크립트 SHA1 을 재계산하지 않는 것이 권장(ideal)된다 | "It is ideal to configure a single instance of DefaultRedisScript in your application context to avoid re-calculation of the script's SHA1 on every script run." | `official-vendor-doc` | 카탈로그의 `ScriptDescriptor`/`RedisScript` bean 을 싱글턴으로 구성해야 하는 근거 | thread-safety 를 명시적으로 보장한다고는 말하지 않음(싱글턴 권장이 암묵적으로 시사할 뿐), 런타임 카탈로그 재적재/버저닝 전략은 다루지 않음 |
| SDR-SCRIPT-C6 | 스크립트 본문을 classpath resource 로 두고 `ResourceScriptSource(new ClassPathResource(...))` 를 통해 `RedisScript.of(scriptSource, Boolean.class)` 로 구성하는 것이 공식 예시의 표준 패턴이다 | "ScriptSource scriptSource = new ResourceScriptSource(new ClassPathResource(\"META-INF/scripts/checkandset.lua\")); return RedisScript.of(scriptSource, Boolean.class);" | `official-vendor-doc` | 프로그램 카탈로그의 `.lua` 파일을 classpath resource 로 두고 `ResourceScriptSource` 로 로드하는 설계의 공식 근거 | 이것이 유일하게 지원되는 `ScriptSource` 구현인지, 특정 classpath 경로(`META-INF/scripts/`)가 강제 규약인지는 증명하지 않음 — 예시 1건일 뿐 |
## Usage Boundaries
- **이 자료가 직접 증명하는 것**:
- `SDR-SCRIPT-C1`: Spring Data Redis 의 기본 `ScriptExecutor` 가 EVALSHA 우선 + NOSCRIPT 시 EVAL 폴백을 자동 처리 — branch 가 이 재적재 로직을 직접 구현할 필요가 없다는 결정의 직접 근거
- `SDR-SCRIPT-C2`, `C3`: `execute(script, keys, args...)` 시그니처와 직렬화 책임 분담 — typed facade 가 감쌀 API 표면
- `SDR-SCRIPT-C4`: `RedisScript` 반환 타입 제약 — golden vector 설계 시 참고
- `SDR-SCRIPT-C5`: `DefaultRedisScript` 싱글턴 구성 권장 — 카탈로그 bean 설계 근거
- `SDR-SCRIPT-C6`: `ResourceScriptSource` classpath resource 패턴 — 카탈로그 파일 배치 근거
- **이 자료가 증명하지 않는 것**:
- `ScriptOutputType`, `ScriptingException` 등 구체적 결과 타입 enum·예외 타입 — 이 페이지(`redis/scripting.html`)에는 **등장하지 않는다**(self-grep 으로 부재 확인, fabrication 아님). Javadoc(`org.springframework.data.redis.core.script` 패키지) 또는 별도 fetch 필요
- Redis Functions(FCALL) 배포 모드 지원 여부 — 이 페이지는 `eval`/`evalsha` 기반 `RedisScript` 경로만 다룬다
- Redis Cluster 환경에서 각 노드별 스크립트 캐시가 독립적으로 유실될 수 있는 문제와 Spring Data Redis 의 대응 방식
- fallback(`eval`) 실행 후 서버 스크립트 캐시에 실제로 재적재되어 이후 `evalsha` 가 다시 성공하는지의 명시적 서술(문맥상 당연히 그렇다고 추정되나 이 문장이 직접 말하지는 않음)
- **내 프로젝트(ca-skeleton)에 적용하려면 추가 확인이 필요한 것**:
- `ScriptOutputType`/`ScriptingException` 관련 Javadoc 확인 (typed facade 의 예외 처리 계층 설계에 필요)
- `DefaultRedisScript` 가 실제로 thread-safe 한지 Javadoc/소스 확인 (싱글턴 bean 으로 구성 시 동시성 안전성 전제)
- unsafe multi-command recipe ↔ atomic 대체 쌍의 실제 Lua 구현 detail 은 이 자료 범위 밖 — branch 자체 설계 결정
## 메모
- 이 페이지는 Redis 서버 프로토콜 수준의 EVALSHA/NOSCRIPT 규약(`raw/official-docs/redis-eval-scripting-atomicity.md`)이 남긴 공백 — "어떤 client 라이브러리가 EVALSHA→NOSCRIPT→재적재를 자동화하는가" — 을 Spring Data Redis 프레임워크 계층에서 메운다. 두 문서를 함께 인용하면 "프로토콜 규약 + 프레임워크 자동화" 근거 사슬이 완성된다.
- `ScriptOutputType`/`ScriptingException` 부재는 fabrication 방지를 위해 의도적으로 quote 하지 않았음 — 필요 시 별도 raw-source 로 Javadoc 페이지 수집 권장.
- fetch 시점 페이지 버전은 `4.1.0`(Spring Boot 4.0 세대). 향후 버전업 시 재확인 필요.
## Related
- 같은 주제 다른 official-doc: [[raw/official-docs/redis-eval-scripting-atomicity]] — Redis 서버 자체의 EVALSHA/NOSCRIPT/KEYS 선언 규약 (프로토콜 수준, 본 문서는 프레임워크 수준)
- 이 자료를 인용한 wiki 요약: `[[wiki/concepts/...]]` (생성 시)
@@ -0,0 +1,105 @@
---
title: official-doc / Spring for Apache Kafka — Message Listener Containers (AckMode & Concurrency)
source_type: official-doc
url: https://docs.spring.io/spring-kafka/reference/kafka/receiving-messages/message-listener-container.html
archive_url:
related_branches: [feature-kafka-consumer-inbox-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, messaging, kafka]
created: 2026-07-28
---
# official-doc / Spring for Apache Kafka — Message Listener Containers (AckMode & Concurrency)
> Layer: `raw/` — 외부 자료(공식 문서)의 **원문 발췌·출처 기록**.
> 검증된 요약은 `/ingest``wiki/concepts/`에 별도 작성. 원본은 raw에 영구 보관.
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-kafka-consumer-inbox-contract]] | manual acknowledgement 메커니즘(D3 — "application use case 성공 + DB 트랜잭션 커밋 이후에만 ack")을 `ContainerProperties.AckMode` 층에서 무엇으로 표현하는지의 근거. `AckMode.MANUAL`/`MANUAL_IMMEDIATE` 정의·`Acknowledgment` 호출 제약(`nack()` 스레드 제약, `acknowledge(index)` 리스너 스레드 제약)·`ConcurrentMessageListenerContainer``concurrency` 대 파티션 수 하향 조정 규칙을 확인 |
## 출처
- 원본 URL: https://docs.spring.io/spring-kafka/reference/kafka/receiving-messages/message-listener-container.html
- 아카이브 URL: (미제공)
- 저자 / 조직: Spring team (Broadcom / VMware Tanzu — Spring for Apache Kafka reference)
- 문서 버전: Spring for Apache Kafka reference **4.1.0** (페이지 상단 breadcrumb "Spring for Apache Kafka 4.1.0" 확인). 페이지 자체의 별도 발행일 표기는 없고 각 기능 옆에 도입 버전만 표기됨(예: AckMode 관련 `enable.auto.commit` 강제 false 는 2.3부터, `acknowledge(index)` 부분 배치 커밋은 3.0.10부터).
- 발행일: 명시 없음 (버전 이력만 본문에 표기)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
ca-skeleton kafka consumer inbox 브랜치(`feature-kafka-consumer-inbox-contract`)의 D3(수동 ack, DB 커밋 이후에만 offset 커밋) 결정을 Spring Kafka 컨테이너 API 레벨의 `AckMode` 값과 `Acknowledgment` 인터페이스 계약으로 정당화하기 위함. 아울러 `ConcurrentMessageListenerContainer``concurrency` 가 파티션 수를 넘길 때 어떻게 처리되는지(D4/D6 의 "파티션당 컨슈머 1개" 전제와 인접)도 이 페이지에서 확인.
**요청받은 5개 확인 대상 중 이 URL 페이지에 실제로 있는 것은 2개(AckMode 열거·MANUAL/MANUAL_IMMEDIATE 정의, concurrency 하향 조정)뿐이다.** 나머지 3개(① `Acknowledgment` 를 어느 스레드에서 호출해야 하는지의 "calling consumer thread ... otherwise queued" 계열 문장, ② ack 순서 제약 "must be acknowledged in order ... does not maintain state for each record" 계열 문장, ③ `asyncAcks`/out-of-order ack 의 pause·중복 전달 trade-off 문장)은 **self-grep 결과 이 페이지에 없음을 확인**했다(아래 ## 메모 참조). 1 dispatch = 1 URL 원칙에 따라 다른 페이지 내용을 끌어와 대신 인용하지 않았다.
## 핵심 인용
> 원문 그대로. 이 페이지는 "Message Listener Containers" 단일 페이지이며 그 안에 "Committing Offsets" 소제목이 있다. 하위 번호 섹션이 없어 위치는 fetched text 의 line 번호로 표기한다 (self-grep 참조).
> [Committing Offsets] "MANUAL: The message listener is responsible to acknowledge() the Acknowledgment." (line 447)
> [Committing Offsets] "MANUAL_IMMEDIATE: Commit the offset immediately when the Acknowledgment.acknowledge() method is called by the listener." (line 450)
> [Committing Offsets] "MANUAL and MANUAL_IMMEDIATE require the listener to be an AcknowledgingMessageListener or a BatchAcknowledgingMessageListener." (line 454)
> [Committing Offsets] "The default AckMode is BATCH." (line 429)
> [Committing Offsets] "nack() can only be called on the consumer thread that invokes your listener." (line 480)
> [Committing Offsets — partial batch commit] "The method must be called on the listener thread" (line 507, `acknowledge(index)` 제약 목록 중 하나)
> [Using ConcurrentMessageListenerContainer] "If the concurrency is greater than the number of TopicPartitions, the concurrency is adjusted down such that each container gets one partition." (line 412)
## Claims Extracted
| Claim ID | Claim | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| SPRK-ACKMODE-C1 | `AckMode.MANUAL` 은 리스너가 `Acknowledgment.acknowledge()` 를 호출할 책임을 지며, 그 이후엔 `BATCH` 와 동일한 커밋 시맨틱(poll() 이 반환한 레코드 전체 처리 후 커밋)이 적용된다 | "MANUAL: The message listener is responsible to acknowledge() the Acknowledgment." + (같은 항목의 다음 문장, self-grep 대상은 아니나 문맥) "After that, the same semantics as BATCH are applied." | `official-vendor-doc` | ca-skeleton D3 의 "use case 성공 이후에만 ack" 를 표현할 `AckMode` 값이 `MANUAL` 임을 확인 | acknowledge() 를 **언제** 호출해야 하는지(DB 커밋 성공 이후)는 애플리케이션 책임이지 프레임워크가 강제하지 않음 — 순서를 지키는 코드는 리뷰/계약 테스트로 별도 보장해야 함(branch note 의 §검증해야 할 주장 이미 인지) |
| SPRK-ACKMODE-C2 | `AckMode.MANUAL_IMMEDIATE``Acknowledgment.acknowledge()` 호출 즉시 offset 을 커밋한다 — `MANUAL` 의 배치형 커밋과 달리 호출마다 즉시 커밋 | "MANUAL_IMMEDIATE: Commit the offset immediately when the Acknowledgment.acknowledge() method is called by the listener." | `official-vendor-doc` | `MANUAL` vs `MANUAL_IMMEDIATE` 선택 — ca-skeleton D3 는 "커밋 시점"만 결정했고 이 자료는 두 모드가 커밋 빈도(배치 대 즉시)에서 갈린다는 것을 보여줄 뿐, 어느 쪽이 D3 의 의도에 더 맞는지는 이 자료만으로 결정 불가 | 두 모드의 처리량/지연 trade-off (벤치마크 없음). `acknowledge(index)` 부분 배치 커밋(SPRK-ACKMODE-C6)은 `MANUAL_IMMEDIATE` 를 요구한다는 점에서 이 claim 과 연결되나 별도 claim으로 분리 |
| SPRK-ACKMODE-C3 | `MANUAL`/`MANUAL_IMMEDIATE` 를 쓰려면 리스너가 `AcknowledgingMessageListener` 또는 `BatchAcknowledgingMessageListener` 를 구현해야 한다 | "MANUAL and MANUAL_IMMEDIATE require the listener to be an AcknowledgingMessageListener or a BatchAcknowledgingMessageListener." | `official-vendor-doc` | ca-skeleton consumer 구현 시 리스너 인터페이스 선택 제약 — `Acknowledgment` 를 받으려면 이 인터페이스 중 하나를 구현해야 함(구체 클래스/메서드 명명 자체는 이 자료 범위 밖, §구현 가이드 1 의 `UNSUPPORTED_IMPL_DECISION` 대상) | 어느 인터페이스(단건 vs 배치)를 골라야 하는지는 D4 의 처리 단위 결정에 종속 — 이 자료는 존재 요건만 말함 |
| SPRK-ACKMODE-C4 | AckMode 를 명시하지 않으면 기본값은 `BATCH` 이지 `MANUAL` 이 아니다 | "The default AckMode is BATCH." | `official-vendor-doc` | ca-skeleton 이 D3 의 수동 ack 계약을 실제로 적용하려면 `AckMode`**명시적으로 `MANUAL` (또는 `MANUAL_IMMEDIATE`) 로 설정해야 함** — 설정을 빠뜨리면 조용히 `BATCH`(자동 그룹 커밋)로 동작 | `BATCH` 자체가 언제 위험한지(자동 커밋과 유사한 문제)는 이 자료가 직접 논하지 않음 — 이미 수집한 `kafka-consumer-offset-commit-semantics-apache-javadoc` 의 자동 커밋 위험 논의가 별도 근거 |
| SPRK-ACKMODE-C5 | `nack()`(2.3 부터 추가된 `Acknowledgment` 메서드)은 **리스너를 호출한 그 consumer 스레드에서만** 호출할 수 있다 | "nack() can only be called on the consumer thread that invokes your listener." | `official-vendor-doc` | `Acknowledgment` 인터페이스의 적어도 한 메서드(`nack`)가 리스너/consumer 스레드에 강하게 묶여 있다는 것을 확인 — "별도 워커 스레드에서 ack 관련 호출을 해도 되는가"라는 branch 의 질문에 **`nack()` 에 한해서는 "아니오"** 라고 명시적으로 답함 | **`acknowledge()` 자체(단건 리스너, `MANUAL` 모드)에 동일한 스레드 제약이 있다고 이 문장만으로 단정할 수 없다** — 이 페이지가 `acknowledge()` 에 대해 그런 문장을 쓰는 곳은 SPRK-ACKMODE-C6(부분 배치 커밋 변형)뿐이며, 그건 다른 API(`acknowledge(index)`, 배치 리스너, `MANUAL_IMMEDIATE` 한정)다. 사용자가 원한 "calling consumer thread ... otherwise queued" 문장은 이 페이지에 없음(미발견, 아래 Usage Boundaries) |
| SPRK-ACKMODE-C6 | 3.0.10 부터 추가된 배치 리스너의 부분 배치 커밋(`acknowledge(index)`)은 `AckMode.MANUAL_IMMEDIATE` 를 요구하고, **"리스너 스레드에서 호출되어야 한다"** 는 제약을 명시적으로 건다(그 외에도 List 소비 요건, index 범위·단조 증가 요건이 있으며 위반 시 `IllegalArgumentException`/`IllegalStateException`) | "The method must be called on the listener thread" (같은 목록의 다른 제약: "AckMode.MANUAL_IMMEDIATE is required") | `official-vendor-doc` | `acknowledge(index)` 부분 배치 커밋을 쓸 경우의 스레드 제약 — 이 자료가 제공하는 **가장 근접한** "Acknowledgment 관련 호출은 리스너/consumer 스레드에서" 근거 | 단건 레코드 리스너의 일반 `acknowledge()` 호출(`MANUAL` 모드, 배치 아님)에도 동일 제약이 명시돼 있는지는 **이 페이지에서 확인 불가** — 그 문장은 별도 페이지("Manually Committing Offsets")에 있을 가능성이 있으나 본 dispatch 범위 밖 |
| SPRK-ACKMODE-C7 | `ConcurrentMessageListenerContainer``concurrency` 가 배정 가능한 `TopicPartition` 수보다 크면, 각 delegate 컨테이너가 파티션 하나씩만 갖도록 concurrency 가 **하향 조정**된다 | "If the concurrency is greater than the number of TopicPartitions, the concurrency is adjusted down such that each container gets one partition." | `official-vendor-doc` | ca-skeleton 의 concurrency 설정 상한 근거 — 파티션 수를 넘는 concurrency 값은 무의미(초과분 컨테이너가 유휴 상태로 남지 않고 애초에 만들어지지 않음). D4/D6 이 전제하는 "파티션 하나는 컨슈머 그룹 안에서 한 컨테이너만 소비"라는 가정과 **정합**되는 Spring 프레임워크 레벨 동작 | 이 문장은 **Spring 컨테이너의 파티션 분배 동작**을 말할 뿐, Kafka 프로토콜 레벨에서 "파티션 하나는 그룹 내 정확히 한 consumer 가 소비한다"는 것 자체의 공식 Kafka 문서 근거(branch `SOURCE_GAP-1`, `needs-confirmation`)를 대신하지 않는다 — Spring 이 이렇게 동작하는 것은 그 Kafka 레벨 보장을 **전제로 구현**했을 뿐, 이 문서가 Kafka 프로토콜 자체를 규정하는 문서는 아님 |
### Strength 참고
모든 claim 은 `official-vendor-doc` (Spring 공식 reference — RFC/IETF 표준은 아니므로 `official-standard` 로 격상하지 않음).
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `SPRK-ACKMODE-C1`~`C4`: `AckMode` 값의 열거와 커밋 시점 정의, 기본값이 `BATCH` 라는 것, `MANUAL`/`MANUAL_IMMEDIATE` 사용 시 리스너 인터페이스 요건
- `SPRK-ACKMODE-C5`: `nack()` 은 consumer/리스너 스레드에서만 호출 가능
- `SPRK-ACKMODE-C6`: `acknowledge(index)`(부분 배치 커밋, `MANUAL_IMMEDIATE` 한정)는 리스너 스레드에서 호출돼야 한다는 명시 제약
- `SPRK-ACKMODE-C7`: `concurrency` > 파티션 수일 때 하향 조정
- 이 자료가 증명하지 않는 것 (branch 가 요청했으나 이 URL 페이지에서 **미발견** — self-grep 결과 0건):
- `Acknowledgment.acknowledge()`(단건 레코드 리스너, `MANUAL` 모드) 를 **어느 스레드에서 호출해야 하는지**에 대한 일반 규칙 — 사용자가 지정한 "commit will be performed immediately if the Acknowledgment is acknowledged on the calling consumer thread; otherwise, the acks will be queued" 계열 문장은 `grep -nF`로 "calling consumer thread"/"queued" 검색 시 이 페이지에서 0건. `nack()`(C5)과 `acknowledge(index)`(C6)에 대해서만 스레드 제약이 명시돼 있고, 일반 `acknowledge()` 에 대한 동일 문장은 이 페이지 범위 밖(다른 페이지 "Manually Committing Offsets" 가능성 — 본 dispatch 는 여기서 멈춤, 1 dispatch = 1 URL)
- ack **순서** 제약 — "acknowledgments must be acknowledged in order, because Kafka does not maintain state for each record, only a committed offset for each group/partition" 계열 문장 — `grep -nF`로 "does not maintain state"/"in order" 검색 시 미발견(단, "Out of Order Commits" 라는 기능명 자체는 이 페이지에 1회 등장 — line 482, 483: `nack()` 은 Out of Order Commits 사용 시 허용 안 됨. 이 기능의 정의 문장 자체는 이 페이지에 없음)
- `asyncAcks`/out-of-order ack 채택 시 consumer 가 pause 되고 중복 전달 가능성이 커진다는 trade-off 문장 — `grep -ni "asyncAck"` 결과 0건
- 파티션 단위 pause/resume API — 이미 별도 raw 문서([[raw/official-docs/spring-kafka-listener-container-pause-resume-backpressure]] `SPRK-PAUSE-C5`)에서 동일하게 미발견 확인됨
- `commitSync`/`commitAsync` 중 어느 것을 쓸지의 권고 (이 페이지는 `syncCommits` 컨테이너 프로퍼티가 그 둘을 스위칭한다는 것만 언급하고 권고는 안 함 — 이미 branch D3 의 Open Risk 로 기록됨)
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- `Acknowledgment.acknowledge()` 의 일반적 스레드 제약(있다면)을 확인하려면 "Manually Committing Offsets" 페이지(`Message Listeners` 섹션의 sibling 페이지, 이 문서 최하단 nav 에서 확인됨)를 별도 `wiki-source-summarizer` dispatch 로 조사해야 한다
- ack 순서 제약과 `asyncAcks` trade-off 도 마찬가지로 별도 페이지 조사 필요 — 이 페이지에 없다고 해서 Spring Kafka 문서 전체에 없다는 뜻은 아님
## 메모
> 나중에 wiki로 옮길 때 참고할 짧은 메모. 검증되지 않은 내 추론은 여기에 두지 말 것.
- self-grep 실행 결과 (fetched text `/tmp/.../scratchpad/source-fetch-20260728-193000.txt`, 658줄):
- `calling consumer thread` → 0건
- `queued` → 0건
- `does not maintain state` → 0건
- `asyncAck` (대소문자 무관) → 0건
- `Out of Order Commits` → 1건 (line 482, 기능명만 등장 — 정의 문장 없음)
- 이 페이지 자체가 "Committing Offsets" 절 안에서 `AckMode` 7종(RECORD/BATCH/TIME/COUNT/COUNT_TIME/MANUAL/MANUAL_IMMEDIATE)을 모두 정의하지만, 이 raw 문서에는 branch 의 관심사(MANUAL/MANUAL_IMMEDIATE 대비, 스레드, concurrency)에 직접 관련된 것만 발췌했다. RECORD/BATCH/TIME/COUNT/COUNT_TIME 의 정의 자체도 필요해지면 이 문서에 claim 추가만으로 확장 가능(재-fetch 불필요, 같은 텍스트 파일에 이미 있음).
- 추가로 봐야 할 동일 출처 페이지: 사이트 nav 에서 확인된 "Manually Committing Offsets" (이 페이지 바로 다음 sibling, `Message Listeners` 섹션 하위) — `Acknowledgment.acknowledge()` 스레드 규칙과 ack 순서 제약이 있을 가능성이 높음. 별도 URL, 별도 dispatch 필요.
## 관련
- 같은 주제 다른 official-doc: `[[raw/official-docs/spring-kafka-listener-container-pause-resume-backpressure]]` (같은 "Message Listener Containers" 상위 카테고리, pause/resume 근거), `[[raw/official-docs/kafka-consumer-offset-commit-semantics-apache-javadoc]]` (Kafka 레벨 offset commit 시맨틱), `[[raw/official-docs/kafka-incremental-cooperative-rebalance-kip429]]` (concurrency/rebalance 인접 주제)
- 이 자료를 인용한 wiki 요약: `[[wiki/concepts/...]]` (생성 시)
@@ -0,0 +1,101 @@
---
title: official-doc / Spring for Apache Kafka — DefaultErrorHandler Fatal Exceptions, BackOff Thread Suspension, and Dead Letter Topic Defaults
source_type: official-doc
url: https://docs.spring.io/spring-kafka/reference/kafka/annotation-error-handling.html
archive_url:
related_branches: [feature-kafka-consumer-inbox-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, messaging, kafka, dead-letter-queue, exponential-backoff]
created: 2026-07-28
---
# Spring for Apache Kafka — DefaultErrorHandler Fatal Exceptions, BackOff Thread Suspension, and Dead Letter Topic Defaults
> Layer: `raw/` — 외부 자료(공식 문서)의 **원문 발췌·출처 기록**.
> 문서 버전: Spring for Apache Kafka **4.1.0** reference ("Stable" 채널) — "Using Spring for Apache Kafka" §"Handling Exceptions".
## source_type
`official-doc` — Spring for Apache Kafka 공식 reference 문서 (docs.spring.io, Broadcom/VMware Tanzu 가 관리하는 Spring 프로젝트).
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-kafka-consumer-inbox-contract]] | (1) poison/역직렬화 예외 분류 시 `DefaultErrorHandler` 기본 fatal 예외 6종을 non-retryable 기준으로 삼을 근거, (2) blocking retry 의 backoff 대기가 consumer 스레드를 정지시키므로 `max.poll.interval.ms` 를 넘는 지연에는 `ContainerPausingBackOffHandler` 가 필요하다는 메커니즘 근거, (3) DLT 기본 명명(`<originalTopic>-dlt`)·파티션 요건과 recoverer 가 producer(`KafkaTemplate`/`KafkaOperations`)를 요구한다는 근거, (4) recoverer 를 명시 구성하지 않으면 재시도 소진 후 기본 동작이 "로그만"이라는 근거 |
## 출처
- 원본 URL: https://docs.spring.io/spring-kafka/reference/kafka/annotation-error-handling.html
- 아카이브 URL: (미제공)
- 저자 / 조직: Spring team (Broadcom / VMware Tanzu — Spring for Apache Kafka 프로젝트)
- 발행일: 명시 없음 (버전 4.1.0 reference, "Stable" 채널 — 문서 자체에 발행 연월일 필드 없음)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-kafka-consumer-inbox-contract` 브랜치가 poison exception 분류·blocking retry backoff·DLT 명명·기본 recoverer 동작을 결정할 때 필요한 **공식 기본값 사실**을 확보하기 위함. 어떤 예외를 "재시도 무의미"로 볼지, backoff 를 얼마로 잡을지, DLT 토픽명을 어떻게 override 할지는 이 문서가 아니라 branch 의 별도 결정이며, 이 문서는 그 결정이 마주치는 **프레임워크 기본 동작의 경계**만 증명한다.
## 핵심 인용
> [§DefaultErrorHandler — Skipping Retries for Specific Exceptions] "The DefaultErrorHandler considers certain exceptions to be fatal, and retries are skipped for such exceptions; the recoverer is invoked on the first failure. The exceptions that are considered fatal, by default, are:
>
> DeserializationException
> MessageConversionException
> ConversionException
> MethodArgumentResolutionException
> NoSuchMethodException
> ClassCastException
>
> since these exceptions are unlikely to be resolved on a retried delivery." (원문 lines 435450, 열거형 리스트이므로 elide 미적용 — 6개 예외명 자체가 claim 의 핵심 내용이라 축약하지 않고 원문 순서대로 보존)
> [§Back Off Handlers] "Error handlers such as the DefaultErrorHandler use a BackOff to determine how long to wait before retrying a delivery. Starting with version 2.9, you can configure a custom BackOffHandler. The default handler simply suspends the thread until the back off time passes (or the container is stopped). The framework also provides the ContainerPausingBackOffHandler which pauses the listener container until the back off time passes and then resumes the container. This is useful when the delays are longer than the max.poll.interval.ms consumer property." (원문 lines 344348)
> [§Publishing Dead-letter Records] "The recoverer requires a KafkaTemplate<Object, Object>, which is used to send the record." (원문 line 1043)
> [§Publishing Dead-letter Records] "By default, the dead-letter record is sent to a topic named <originalTopic>-dlt (the original topic name suffixed with -dlt) and to the same partition as the original record. Therefore, when you use the default resolver, the dead-letter topic must have at least as many partitions as the original topic." (원문 lines 10461047)
> [§DefaultErrorHandler] "The default recoverer logs the failed record after retries are exhausted. You can use a custom recoverer, or one provided by the framework such as the DeadLetterPublishingRecoverer." (원문 line 411; 같은 subsection line 362 "By default, after ten failures, the failed record is logged (at the ERROR level)." 도 동일 사실을 뒷받침)
## Claims Extracted
| Claim ID | Claim | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| SPRK-ERRH-C1 | `DefaultErrorHandler` 는 6종 예외(`DeserializationException`, `MessageConversionException`, `ConversionException`, `MethodArgumentResolutionException`, `NoSuchMethodException`, `ClassCastException`)를 기본으로 fatal 로 분류해 재시도를 건너뛰고 첫 실패에서 바로 recoverer 를 호출한다 | "The DefaultErrorHandler considers certain exceptions to be fatal, and retries are skipped for such exceptions; the recoverer is invoked on the first failure." + 6종 나열 + "since these exceptions are unlikely to be resolved on a retried delivery." (lines 435450) | `official-vendor-doc` | Spring for Apache Kafka 4.1.0 `DefaultErrorHandler`(및 `DefaultAfterRollbackProcessor`, 동일 목록이 line 908923 에 재등장)의 기본 classification 맵 | 이 6종이 ca-skeleton 이 실제로 채택해야 할 poison-exception 전체 목록이라는 것은 증명하지 않는다 — 프레임워크는 `addNotRetryableExceptions()`/`setClassifications()` 로 추가·교체를 명시적으로 지원하므로, 프로젝트별 poison 기준은 별도 branch 결정 |
| SPRK-ERRH-C2 | `DefaultErrorHandler` 의 기본 `BackOffHandler` 는 backoff 시간이 지날 때까지 **consumer 스레드를 정지(suspend)**시키며, 이 지연이 `max.poll.interval.ms` 보다 길 때를 위해 프레임워크가 별도로 `ContainerPausingBackOffHandler`(container 자체를 pause 후 resume)를 제공한다 | "The default handler simply suspends the thread until the back off time passes (or the container is stopped)." + "The framework also provides the ContainerPausingBackOffHandler ... This is useful when the delays are longer than the max.poll.interval.ms consumer property." (lines 346348) | `official-vendor-doc` | Spring for Apache Kafka 4.1.0 `DefaultErrorHandler`/`BackOffHandler` 기본 동작과 `max.poll.interval.ms` 초과 우려가 있는 backoff 설정 | 이 인용은 "지연이 `max.poll.interval.ms` 를 넘으면 반드시 rebalance 가 발생한다"는 인과 관계를 **명시적으로 진술하지 않는다** — 그 인과는 Kafka consumer group 프로토콜(broker 측 rebalance 로직) 일반 지식이며 본 페이지의 claim 범위 밖. 본 페이지가 직접 증명하는 것은 "기본 handler 는 스레드를 정지시킨다"와 "그래서 `ContainerPausingBackOffHandler` 가 존재한다"까지다 |
| SPRK-ERRH-C3 | 기본 리졸버 사용 시 dead-letter 레코드는 `<originalTopic>-dlt`(원본 토픽명 + `-dlt` suffix) 토픽의 **원본과 동일한 partition** 으로 전송되며, 따라서 DLT 토픽은 원본 토픽 이상의 partition 수를 가져야 한다 | "By default, the dead-letter record is sent to a topic named <originalTopic>-dlt (the original topic name suffixed with -dlt) and to the same partition as the original record." + "the dead-letter topic must have at least as many partitions as the original topic." (lines 10461047) | `official-vendor-doc` | `DeadLetterPublishingRecoverer` 의 기본(커스텀 리졸버 미지정) destination 계산 | 커스텀 `BiFunction<ConsumerRecord, Exception, TopicPartition>` 리졸버를 지정하면 이 명명 규칙이 적용되지 않는다는 것도 같은 문서가 별도로 보여준다(예: line 10551063, line 1159 `"topic-dlt"` 커스텀 예시) — 즉 이 claim 은 **기본값**에 한정되며 ca-skeleton 이 커스텀 리졸버를 쓸지는 별도 결정 |
| SPRK-ERRH-C4 | `DeadLetterPublishingRecoverer` 는 레코드를 보내기 위해 producer 참조(`KafkaTemplate<Object, Object>`, 또는 `KafkaOperations<K, V>` 타입 파라미터를 받는 오버로드)를 요구한다 — producer 없이는 구성할 수 없다 | "The recoverer requires a KafkaTemplate<Object, Object>, which is used to send the record." (line 1043) + 코드 예시 "public ErrorHandler eh(KafkaOperations<String, String> template) {" (line 1147) + "Map<Class<?>, KafkaOperations<?, ?>> templates = new LinkedHashMap<>();" (line 1113, 멀티 템플릿 오버로드) | `official-vendor-doc` | `DeadLetterPublishingRecoverer` 의 생성자 요구사항 (단일 템플릿 및 멀티 템플릿 오버로드 모두) | 본문 서술(line 1043)은 정확히 `KafkaTemplate<Object, Object>` 라는 단어를 쓰고, "KafkaOperations" 라는 단어 자체는 코드 예시(line 1113, 1147)에서만 등장한다 — "recoverer 가 정확히 `KafkaOperations` 인터페이스 타입으로 문서화되어 있다"는 문장은 본문 prose 인용만으로는 과잉 해석이며, 코드 예시 조합으로 뒷받침한 것임을 구분할 것 (자세한 것은 `## 메모` 참조) |
| SPRK-ERRH-C5 | recoverer 를 명시적으로 구성하지 않으면, `DefaultErrorHandler` 는 재시도가 소진된 뒤 기본적으로 **실패 레코드를 로그만 남기고**(기본값 10회 실패 후 ERROR 레벨) DLT 등으로 보내지 않는다 | "The default recoverer logs the failed record after retries are exhausted. You can use a custom recoverer, or one provided by the framework such as the DeadLetterPublishingRecoverer." (line 411) + "By default, after ten failures, the failed record is logged (at the ERROR level)." (line 362) | `official-vendor-doc` | `DefaultErrorHandler` 에 커스텀 recoverer/`BackOff` 를 지정하지 않은 기본 구성 | "10회 실패" 라는 수치는 기본 `FixedBackOff` 설정에서 파생되는 값이며, `BackOff`(재시도 횟수·지연)를 다른 값으로 구성하면 실패까지 걸리는 시도 횟수도 달라진다 — "항상 10회" 라는 뜻은 아니다 |
### Strength 참고
모두 `official-vendor-doc` — Spring for Apache Kafka 는 Spring 프로젝트 공식 reference 문서이며 RFC/표준(`official-standard`)은 아니다.
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `SPRK-ERRH-C1`: `DefaultErrorHandler` 기본 fatal 예외 6종과 "재시도 skip + 첫 실패에 recoverer 호출" 동작
- `SPRK-ERRH-C2`: 기본 `BackOffHandler` 가 consumer 스레드를 정지시킨다는 것과, 그로 인해 `max.poll.interval.ms` 초과 우려가 있을 때 `ContainerPausingBackOffHandler` 가 제공된다는 것
- `SPRK-ERRH-C3`: 기본 리졸버의 DLT 명명(`<originalTopic>-dlt`) + 동일 partition 전송 + partition 수 요건
- `SPRK-ERRH-C4`: `DeadLetterPublishingRecoverer` 가 producer(`KafkaTemplate`/`KafkaOperations` 계열) 없이는 구성 불가하다는 것
- `SPRK-ERRH-C5`: recoverer 미구성 시 기본 동작이 "로그만"이라는 것
- 이 자료가 증명하지 **않는** 것:
- **backoff 지연이 `max.poll.interval.ms` 를 넘으면 반드시 rebalance 가 발생한다는 인과 자체.** 본 페이지는 "스레드 정지" 사실과 "그래서 `ContainerPausingBackOffHandler` 가 유용하다"는 권고만 진술한다 — rebalance 트리거 메커니즘은 Kafka consumer group 프로토콜의 별도 공식 문서로 보강 필요(`SPRK-ERRH-C2` Does not prove 참조).
- ca-skeleton 이 실제로 채택해야 할 poison-exception 전체 목록 — 6종은 Spring 기본값일 뿐, 확장/교체가 명시적으로 지원된다.
- "recoverer 가 `KafkaOperations` 인터페이스로 문서화되어 있다"는 prose 수준 주장 — 본문 prose 는 `KafkaTemplate<Object, Object>` 로 서술하고, `KafkaOperations` 는 코드 예시에서만 파라미터 타입으로 등장한다.
- DLT 파티션 수·명명 override 시의 동작 — 커스텀 리졸버를 쓰면 `<originalTopic>-dlt` 규칙이 적용되지 않는다는 것도 같은 문서가 별도 예시로 보여준다.
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- ca-skeleton 이 실제로 설정할 `max.poll.interval.ms` 값과 backoff 지연 상한이 서로 충돌하지 않는지 — Kafka consumer 공식 설정 문서(`max.poll.interval.ms`, `max.poll.records`)로 별도 확인
- poison exception 목록에 ca-skeleton 도메인 역직렬화/스키마 예외를 추가할지 여부 — branch 자체 결정
- DLT 파티션 수를 원본과 동일하게 프로비저닝할지, 토픽 생성 자동화(Kafka Streams/Admin API)로 강제할지 — 별도 인프라 결정
## 메모
- `KafkaOperations` vs `KafkaTemplate`: 본문 prose 는 `KafkaTemplate<Object, Object>` 를 요구한다고 명시하고, `KafkaOperations` 라는 단어는 이 페이지의 코드 예시(멀티 템플릿 맵, 커스텀 destination resolver 예시)에서만 파라미터 타입으로 등장한다. `KafkaTemplate``KafkaOperations` 인터페이스의 구현체라는 것은 Spring Kafka 일반 지식이지만, **이 페이지 자체가 그 상속 관계를 설명하는 문장은 없다** — 다음 문서 작업자는 이 구분을 유지할 것 (branch 의 D-row 에서 "producer 타입은 `KafkaTemplate` 구현체(또는 `KafkaOperations` 파라미터로 받는 오버로드)" 로 정확히 표현 권장).
- 추가로 봐야 할 동일 출처 페이지: Kafka consumer 공식 문서의 `max.poll.interval.ms`/`max.poll.records` 설정 — `SPRK-ERRH-C2` 의 "얼마나 길어야 rebalance 위험인지" 를 별도로 보강해야 함. Non-Blocking Retries(`@RetryableTopic`) 페이지도 blocking retry 대안 비교 시 참고.
## 관련
- 같은 주제 다른 official-doc: [[raw/official-docs/kafka-message-delivery-semantics-design]], [[raw/official-docs/spring-kafka-sending-messages-kafkatemplate]] — 같은 Spring for Apache Kafka 4.1.0 reference 의 인접 주제(전달 시맨틱, producer 전송 결과)
- 추가 확보 필요 (아직 raw 에 없음): Kafka consumer 공식 문서의 `max.poll.interval.ms`/`max.poll.records` 설정 페이지 — `SPRK-ERRH-C2` 보강용
- 이 자료를 인용한 wiki 요약: (생성 시 링크)
@@ -0,0 +1,88 @@
---
title: official-doc / Spring for Apache Kafka — ErrorHandlingDeserializer (Poison Record Handling)
source_type: official-doc
url: https://docs.spring.io/spring-kafka/reference/kafka/serdes.html
archive_url:
related_branches: [feature-kafka-consumer-inbox-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, messaging, kafka, dead-letter-queue]
created: 2026-07-28
---
# official-doc / Spring for Apache Kafka — ErrorHandlingDeserializer (Poison Record Handling)
> Layer: `raw/` — 외부 자료(공식 문서)의 **원문 발췌·출처 기록**.
> 검증된 요약은 `/ingest``wiki/concepts/`에 별도 작성. 원본은 raw에 영구 보관.
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-kafka-consumer-inbox-contract]] | ca-skeleton consumer 가 역직렬화 실패(poison message)를 "리스너 호출 이전 단계에서 감지해 error handler/DLT 경로로 회수"하는 방식을 채택하는 근거 — `ErrorHandlingDeserializer` 가 실패 시 null 값 + `DeserializationException` 헤더(원인 + raw bytes)를 실어 보내고, 컨테이너가 리스너 대신 error handler 를 호출한다는 공식 메커니즘 |
## 출처
- 원본 URL: https://docs.spring.io/spring-kafka/reference/kafka/serdes.html
- 아카이브 URL: (미제공)
- 저자 / 조직: Spring team (Broadcom / VMware Tanzu — Spring for Apache Kafka reference)
- 문서 버전: Spring for Apache Kafka reference **4.1.0** (페이지 상단 breadcrumb "Spring for Apache Kafka 4.1.0" 및 페이지 메타 `content="4.1.0"` 확인). `ErrorHandlingDeserializer` 절 본문 자체에 버전 도입 표기는 없으나, `Validator` 추가 기능은 "Starting with version 3.1"로 명시됨
- 발행일: 페이지 자체 발행일 표기 없음 (기능별 "Starting with version N.N" 문구만 본문에 존재)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
ca-skeleton kafka consumer inbox 브랜치(`feature-kafka-consumer-inbox-contract`)의 "rebalance·`max.poll` 처리, poison/역직렬화 실패 분류" 범위에서, poison message 를 리스너 도달 이전 단계(deserializer 레벨)에서 걸러 error handler/DLT 로 회수하는 설계를 공식 메커니즘으로 정당화하기 위함. `ErrorHandlingDeserializer` 가 무엇을 반환하고 컨테이너가 어떻게 반응하는지의 원문 계약을 근거로 남긴다.
## 핵심 인용
> 원문 그대로. 이 페이지는 단일 섹션("Using ErrorHandlingDeserializer")이며 하위 번호 섹션이 없어, 위치는 fetched text(`/tmp/source-fetch-20260728-171842.txt`)의 line 번호로 표기한다 (self-grep 참조).
> [Using ErrorHandlingDeserializer] "When a deserializer fails to deserialize a message, Spring has no way to handle the problem, because it occurs before the poll() returns." (line 578)
> [Using ErrorHandlingDeserializer] "If the delegate fails to deserialize the record content, the ErrorHandlingDeserializer returns a null value and a DeserializationException in a header that contains the cause and the raw bytes." (line 581)
> [Using ErrorHandlingDeserializer] "When you use a record-level MessageListener, if the ConsumerRecord contains a DeserializationException header for either the key or value, the container's ErrorHandler is called with the failed ConsumerRecord." "The record is not passed to the listener." (line 582583)
> [Using ErrorHandlingDeserializer] "You can use the DefaultKafkaConsumerFactory constructor that takes key and value Deserializer objects and wire in appropriate ErrorHandlingDeserializer instances that you have configured with the proper delegates. Alternatively, you can use consumer configuration properties (which are used by the ErrorHandlingDeserializer) to instantiate the delegates. The property names are ErrorHandlingDeserializer.KEY_DESERIALIZER_CLASS and ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS." (line 593595)
## Claims Extracted
| Claim ID | Claim | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| SPRK-EHD-C1 | 표준 Kafka `Deserializer``poll()` 반환 이전에 발생하는 역직렬화 실패를 리스너 레벨에서 처리할 방법이 없고, 이 문제를 해결하기 위해 `ErrorHandlingDeserializer` 가 도입되었다 | "When a deserializer fails to deserialize a message, Spring has no way to handle the problem, because it occurs before the poll() returns." + "To solve this problem, the ErrorHandlingDeserializer has been introduced." | `official-vendor-doc` | poison message 문제의 근본 원인(왜 일반 deserializer 로는 리스너 레벨 에러 처리가 불가능한지)과 `ErrorHandlingDeserializer` 도입 동기 | `ErrorHandlingDeserializer` 외 다른 해결책(예: try-catch 를 감싼 custom deserializer)의 존재·우열 비교 |
| SPRK-EHD-C2 | 위임(delegate) deserializer 가 레코드 내용 역직렬화에 실패하면, `ErrorHandlingDeserializer``null` 값과, cause + raw bytes 를 담은 `DeserializationException` 헤더를 반환한다 | "If the delegate fails to deserialize the record content, the ErrorHandlingDeserializer returns a null value and a DeserializationException in a header that contains the cause and the raw bytes." | `official-vendor-doc` | `ErrorHandlingDeserializer` 가 key 또는 value deserializer 로 설정된 경우의 실패 시 반환 값·헤더 계약 | batch listener 컨테이너에서 이 헤더가 동일하게 자동 노출/처리되는지 (문서 후반 별도 절 "Batch Listener Error Handling" 에서 수동 검사 코드로 별도 처리됨 — 자동 아님) |
| SPRK-EHD-C3 | record-level `MessageListener` 사용 시, `ConsumerRecord``DeserializationException` 헤더(key 또는 value)가 있으면 컨테이너의 `ErrorHandler` 가 실패한 `ConsumerRecord` 와 함께 호출되고, 그 레코드는 리스너로 전달되지 않는다 | "When you use a record-level MessageListener, if the ConsumerRecord contains a DeserializationException header for either the key or value, the container's ErrorHandler is called with the failed ConsumerRecord." + "The record is not passed to the listener." | `official-vendor-doc` | record-level(단일 레코드) `@KafkaListener` 의 poison record 라우팅 경로 — 리스너 도달 이전에 error handler 로 우회된다는 계약. 본 branch 의 "리스너 호출 이전 단계에서 감지해 error handler/DLT 경로로 회수" 결정의 직접 근거 | DLT 로의 실제 라우팅(예: `DefaultErrorHandler` + `DeadLetterPublishingRecoverer` 조합)은 이 페이지에 명시되지 않음 — 별도 자료("Handling Exceptions" 페이지) 확인 필요. batch listener 의 라우팅 경로도 별도(문서 후반 절 참조) |
| SPRK-EHD-C4 | 위임 deserializer 는 `DefaultKafkaConsumerFactory` 생성자로 직접 wiring 하거나, `ErrorHandlingDeserializer.KEY_DESERIALIZER_CLASS` / `ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS` 컨슈머 설정 프로퍼티로 지정할 수 있다 | "You can use the DefaultKafkaConsumerFactory constructor that takes key and value Deserializer objects and wire in appropriate ErrorHandlingDeserializer instances that you have configured with the proper delegates. Alternatively, you can use consumer configuration properties (which are used by the ErrorHandlingDeserializer) to instantiate the delegates. The property names are ErrorHandlingDeserializer.KEY_DESERIALIZER_CLASS and ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS." | `official-vendor-doc` | `ErrorHandlingDeserializer` 에 실제 위임 deserializer(예: `JsonDeserializer`)를 지정하는 두 가지 설정 방법(생성자 vs 프로퍼티) | Spring Boot `spring.kafka.*` 프로퍼티에서 이 설정이 어떤 정확한 키로 자동 매핑되는지(이 페이지는 raw `ConsumerConfig` 프로퍼티 예시만 제공) |
### Strength 참고
모든 claim 은 `official-vendor-doc` (Spring 공식 reference — RFC/IETF 표준이 아니므로 `official-standard` 로 격상하지 않음)이다.
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `SPRK-EHD-C1`: 일반 deserializer 로는 poll() 이전 실패를 리스너 레벨에서 처리 불가능하다는 문제와 `ErrorHandlingDeserializer` 도입 동기
- `SPRK-EHD-C2`: 위임 deserializer 실패 시 null + `DeserializationException` 헤더 반환 계약
- `SPRK-EHD-C3`: record-level 리스너에서 실패 레코드가 리스너 대신 컨테이너 `ErrorHandler` 로 라우팅된다는 계약 — **리스너 호출 이전 감지** 결정의 직접 근거
- `SPRK-EHD-C4`: delegate deserializer 를 지정하는 두 가지 설정 방법(생성자 wiring / 컨슈머 프로퍼티)
- 이 자료가 증명하지 않는 것:
- DLT(Dead Letter Topic)로의 실제 라우팅 구현(`DefaultErrorHandler` + `DeadLetterPublishingRecoverer` 조합의 상세 동작) — 이 페이지에는 등장하지 않음
- batch listener 컨테이너에서의 동일 계약(별도 수동 헤더 검사 코드 필요 — 문서 후반 "Batch Listener Error Handling" 절에서 별도로 다룸, 본 인용 범위 밖)
- 이 메커니즘이 ca-skeleton 의 handler/schema/version allowlist 설계와 어떻게 결합되어야 하는지
- retry topic 또는 지연 재시도 정책과의 상호작용(이 페이지 미언급 — Non-Blocking Retries 페이지 별도 확인 필요)
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- DLT 라우팅과 감사된 replay 를 위한 실제 error handler 구성(`DefaultErrorHandler`/`DeadLetterPublishingRecoverer`)은 "Handling Exceptions" 공식 페이지를 별도 `wiki-source-summarizer` dispatch 로 조사한 뒤 확정
- batch listener 를 채택할 경우 poison record 검출 코드를 리스너 본문에 직접 작성해야 한다는 것(자동 아님)을 `/branch-spec` 결정에 반영할지 여부
## 메모
> 나중에 wiki로 옮길 때 참고할 짧은 메모. 검증되지 않은 내 추론은 여기에 두지 말 것.
- 사용자가 최초 요청한 인용 문구는 backtick(code font) 표기를 포함했으나, 실제 원문 HTML 은 `<code>` 태그로 감싼 것이지 별도 마크다운 backtick 문자가 존재하지 않는다. 본 raw 문서의 verbatim 인용은 backtick 을 제거한 원문 텍스트 그대로이며, 의미는 사용자 요청과 100% 일치함 (self-grep 확인 완료).
- WebFetch 도구의 1차 결과는 AI 요약 모델을 거쳐 문장이 재구성되어("delegates to a real deserializer and returns...") 있었다 — verbatim 요구사항에 부적합해 폐기하고, `curl` 로 원본 HTML 을 직접 받아 태그만 제거한 텍스트로 self-grep 을 재실행했다.
- 추가로 봐야 할 동일 출처 페이지: "Handling Exceptions" (DLT/DefaultErrorHandler/DeadLetterPublishingRecoverer 실제 구성) — 별도 URL, 별도 dispatch 필요.
## 관련
- 같은 주제 다른 official-doc: `[[raw/official-docs/spring-kafka-listener-container-pause-resume-backpressure]]`, `[[raw/official-docs/spring-kafka-non-blocking-retry-topic-ordering-loss]]`
- 이 자료를 인용한 wiki 요약: `[[wiki/concepts/...]]` (생성 시)
@@ -0,0 +1,92 @@
---
title: official-doc / Spring for Apache Kafka — Pausing and Resuming Listener Containers
source_type: official-doc
url: https://docs.spring.io/spring-kafka/reference/kafka/pause-resume.html
archive_url:
related_branches: [feature-kafka-consumer-inbox-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, messaging, kafka, bounded-queue]
created: 2026-07-28
---
# official-doc / Spring for Apache Kafka — Pausing and Resuming Listener Containers
> Layer: `raw/` — 외부 자료(공식 문서)의 **원문 발췌·출처 기록**.
> 검증된 요약은 `/ingest``wiki/concepts/`에 별도 작성. 원본은 raw에 영구 보관.
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-kafka-consumer-inbox-contract]] | bounded queue 포화 시 consumer 를 컨슈머 그룹에서 이탈시키지 않고 소비만 멈추는 backpressure 메커니즘으로 컨테이너 `pause()`/`resume()` 을 채택하는 근거 — pause 중에도 `poll()` 이 계속 호출되어 rebalance 를 회피한다는 공식 동작과 pause/resume 반영 시점(poll 경계 vs `pauseImmediate`) |
## 출처
- 원본 URL: https://docs.spring.io/spring-kafka/reference/kafka/pause-resume.html
- 아카이브 URL: (미제공)
- 저자 / 조직: Spring team (Broadcom / VMware Tanzu — Spring for Apache Kafka reference)
- 문서 버전: Spring for Apache Kafka reference **4.1.0** (페이지 상단 breadcrumb "Spring for Apache Kafka 4.1.0" 확인. 본문에 인용된 기능 자체는 2.1.3 / 2.1.5 / 2.9 에서 도입되었고 4.1.0 문서에도 동일 내용으로 유지됨 — 페이지 자체의 별도 발행일 표기는 없음)
- 발행일: 명시 없음 (버전 이력만 본문에 표기)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
ca-skeleton kafka consumer inbox 브랜치(`feature-kafka-consumer-inbox-contract`)의 bounded queue backpressure 설계에서 "큐 포화 시 소비를 멈추되 그룹에서 이탈하지 않는다"는 요구를, 컨테이너 `pause()`/`resume()``poll()` 을 계속 호출해 rebalance 를 회피한다는 공식 메커니즘으로 정당화하기 위함. 아울러 pause/resume 이 언제 반영되는지(poll 경계 vs `pauseImmediate`)를 근거로 남긴다.
## 핵심 인용
> 원문 그대로. 이 페이지는 단일 섹션("Pausing and Resuming Listener Containers")이며 하위 번호 섹션이 없어, 위치는 fetched text 의 line 번호로 표기한다 (self-grep 참조).
> [Pausing and Resuming Listener Containers] "A pause() takes effect just before the next poll(); a resume() takes effect just after the current poll() returns." (line 263)
> [Pausing and Resuming Listener Containers] "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." (line 264)
> [Pausing and Resuming Listener Containers] "Starting with version 2.9, a new container property pauseImmediate, when set to true, causes the pause to take effect after the current record is processed." (line 273)
> [Pausing and Resuming Listener Containers] "By default, the pause takes effect when all the records from the previous poll have been processed." (line 274)
> [Pausing and Resuming Listener Containers — Checking Pause Status] "isConsumerPaused() returns true if all Consumer instances have actually paused." (line 269)
## Claims Extracted / 추출된 주장
| Claim ID | Claim | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| SPRK-PAUSE-C1 | 컨테이너 `pause()`/`resume()` 은 즉시 적용되지 않고 poll() 경계에서 반영된다 — pause 는 다음 poll() 직전에, resume 은 현재 poll() 반환 직후에 적용 | "A pause() takes effect just before the next poll(); a resume() takes effect just after the current poll() returns." | `official-vendor-doc` | 기본(non-`pauseImmediate`) listener container 의 pause/resume 타이밍 | `pauseImmediate=true` 일 때의 타이밍(→ C3), 파티션 단위 pause API 의 타이밍(본 URL 에 없음 → C5) |
| SPRK-PAUSE-C2 | 컨테이너가 paused 상태여도 `poll()` 호출은 계속되어, group management 사용 시 rebalance 를 회피하지만 레코드는 가져오지 않는다 | "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." | `official-vendor-doc` | consumer group management(자동 파티션 할당)를 사용하는 컨테이너의 pause 상태 | manual partition assignment(그룹 관리 미사용) 시 동작 여부, `max.poll.interval.ms` 초과 안전성(본문 미언급) |
| SPRK-PAUSE-C3 | 2.9 버전부터 `pauseImmediate=true` 로 설정하면 pause 가 현재 레코드 처리 직후 적용되고, 기본값(`false`)은 이전 poll 의 모든 레코드 처리가 끝난 뒤 적용된다 | "Starting with version 2.9, a new container property pauseImmediate, when set to true, causes the pause to take effect after the current record is processed." + "By default, the pause takes effect when all the records from the previous poll have been processed." | `official-vendor-doc` | `pauseImmediate` 옵션의 존재와 기본값 대비 동작 차이 | 어느 설정이 ca-skeleton 백프레셔 반응 속도 요구에 적합한지는 이 문서만으로 결정 불가 — branch 설계 판단 별도 필요 |
| SPRK-PAUSE-C4 | `isPauseRequested()` 는 pause 호출 여부만 알려주고, `isConsumerPaused()` 라야 모든 Consumer 인스턴스가 실제로 정지했는지 확인 가능 | "isConsumerPaused() returns true if all Consumer instances have actually paused." | `official-vendor-doc` | pause 완료 여부를 헬스체크/모니터링 로직으로 확인하는 용도 | 요청부터 실제 정지까지 걸리는 시간 상한(본문 미언급) |
| SPRK-PAUSE-C5 | 파티션 단위 pause/resume API(`pausePartition`/`resumePartition`)는 **본 URL(container-level pause-resume 페이지)에 등장하지 않는다** — self-grep 결과 0건. 사이트 내비게이션에 "Pausing and Resuming Partitions on Listener Containers" 라는 별도 페이지명만 확인됨(별도 URL, 본 dispatch 범위 밖) | 미발견 — `grep -nF 'pausePartition'` / `grep -nF 'resumePartition'` 모두 0건 (fetched text 전체). 내비게이션 텍스트에 "Pausing and Resuming Partitions on Listener Containers" 링크명만 존재 | `needs-confirmation` | 없음 — 이 claim 은 "본 URL 범위 안에 파티션 단위 API 근거가 없다"는 부재 확인용 | 파티션 단위 API 의 실제 존재·동작(별도 URL "Pausing and Resuming Partitions on Listener Containers" 를 별도 dispatch 로 조사해야 확인 가능) |
| SPRK-PAUSE-C6 | 컨테이너 `pause()`/`resume()` API 자체는 2.1.3 버전에 추가되었다 | "Version 2.1.3 added pause() and resume() methods to listener containers." | `official-vendor-doc` | 컨테이너 레벨 pause/resume API 의 도입 시점과 안정성(4.1.0 문서에도 동일 유지) | 파티션 레벨 API 의 도입 시점(별도 페이지, 미확인) |
### Strength 참고
모든 claim 은 `official-vendor-doc` (Spring 공식 reference — RFC/IETF 표준은 아니므로 `official-standard` 로 격상하지 않음) 또는 미발견 확인용 `needs-confirmation` (C5) 이다.
## Usage Boundaries / 적용 경계
- 이 자료가 직접 증명하는 것:
- `SPRK-PAUSE-C1`: 컨테이너 pause/resume 이 poll() 경계에서 반영되는 타이밍
- `SPRK-PAUSE-C2`: paused 상태에서도 poll() 이 계속되어 group management 사용 시 rebalance 를 회피
- `SPRK-PAUSE-C3`: `pauseImmediate` 옵션 유무에 따른 반영 시점 차이
- `SPRK-PAUSE-C4`: pause 요청 여부와 실제 정지 여부를 구분하는 API 존재
- `SPRK-PAUSE-C6`: 컨테이너 pause/resume API 도입 버전
- 이 자료가 증명하지 않는 것:
- 파티션 단위 pause/resume(`pausePartition`/`resumePartition`) 의 존재·동작 (`SPRK-PAUSE-C5` — 별도 페이지, 본 URL 범위 밖)
- manual partition assignment(그룹 관리 미사용) 환경에서의 rebalance 회피 여부
- `max.poll.interval.ms` 초과 위험이나 consumer heartbeat 동작과의 상호작용 (본문 미언급)
- manual ack 모드(`MANUAL`/`MANUAL_IMMEDIATE`)와 pause/resume 의 상호작용 (본문 미언급)
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- ca-skeleton bounded queue 구현에서 `pauseImmediate` 를 켤지 기본값을 쓸지의 trade-off (branch-note 에서 `UNSUPPORTED_IMPL_DECISION` 또는 명시적 사용자 trade-off 로 결정)
- 파티션 단위 pause API 가 필요한지 여부는 "Pausing and Resuming Partitions on Listener Containers" 페이지를 별도 `wiki-source-summarizer` dispatch 로 조사한 뒤 확정
## 메모
> 나중에 wiki로 옮길 때 참고할 짧은 메모. 검증되지 않은 내 추론은 여기에 두지 말 것.
- 이 페이지는 Spring for Apache Kafka reference 4.1.0 버전 기준 렌더링(내비게이션 breadcrumb 확인). 기능 자체(2.1.3/2.1.5/2.9 도입)는 오래 안정화된 기능이며 4.1.0 문서에도 동일 문구로 유지.
- 추가로 봐야 할 동일 출처 페이지: 사이트 내비게이션에서 확인된 "Pausing and Resuming Partitions on Listener Containers" (파티션 단위 pause/resume API — `pausePartition`/`resumePartition` 등) — 별도 URL, 별도 dispatch 필요.
## 관련
- 같은 주제 다른 official-doc: `[[raw/official-docs/spring-kafka-sending-messages-kafkatemplate]]`
- 이 자료를 인용한 wiki 요약: `[[wiki/concepts/...]]` (생성 시)
@@ -0,0 +1,80 @@
---
title: official-doc / Spring for Apache Kafka — Non-Blocking Retry Topic Pattern (Ordering Loss)
source_type: official-doc
url: https://docs.spring.io/spring-kafka/reference/retrytopic/how-the-pattern-works.html
archive_url:
related_branches: [feature-kafka-consumer-inbox-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, messaging, kafka, retry-policy, dead-letter-queue]
created: 2026-07-28
---
# official-doc / Spring for Apache Kafka — Non-Blocking Retry Topic Pattern (Ordering Loss)
> Layer: `raw/` — 외부 자료(공식 문서)의 **원문 발췌·출처 기록**.
> 문서 버전: Spring for Apache Kafka reference **4.1.0** (페이지 상단 버전 표기 기준, 2026-07-28 확인).
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-kafka-consumer-inbox-contract]] | ca-skeleton consumer 재시도 전략 선택 — non-blocking retry topic 체인이 파티션 정체를 피하는 대신 Kafka 의 순서 보장을 잃는다는 공식 자인이, per-aggregate(파티션 키) 순서 보장을 유지해야 하는 계약에서 이 대안을 언제 배제/채택할지의 선택 조건 근거 |
## 출처
- 원본 URL: https://docs.spring.io/spring-kafka/reference/retrytopic/how-the-pattern-works.html
- 아카이브 URL: (미제공)
- 저자 / 조직: Spring team (Broadcom / VMware Tanzu — Spring for Apache Kafka reference)
- 발행일: (reference 페이지 자체에 발행일 미표기 — 버전 4.1.0 기준 문서)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
ca-skeleton 의 `adapter:inbound:messaging-kafka` consumer 가 non-blocking retry topic 체인(retry topic + DLT)을 재시도 전략으로 채택할지 결정하려면, 이 패턴이 **Kafka 의 순서 보장을 잃는다**는 공식 자인을 근거로 per-aggregate 순서 보장 요구가 있는 leaf 에서 배제 조건을 판단해야 한다. 이 raw 문서는 그 배제/채택 판단의 1차 근거다.
## 핵심 인용
> [§How the Pattern Works] "If message processing fails, the message is forwarded to a retry topic with a back off timestamp."
> [§How the Pattern Works] "The retry topic consumer then checks the timestamp and if its not due it pauses the consumption for that topics partition."
> [§How the Pattern Works] "If the message processing fails again the message will be forwarded to the next retry topic, and the pattern is repeated until a successful processing occurs, or the attempts are exhausted, and the message is sent to the Dead Letter Topic (if configured)."
> [§How the Pattern Works] "To illustrate, if you have a "main-topic" topic, and want to set up non-blocking retry with an exponential backoff of 1000ms with a multiplier of 2 and 4 max attempts, it will create the main-topic-retry-1000, main-topic-retry-2000, main-topic-retry-4000 and main-topic-dlt topics and configure the respective consumers."
> [§How the Pattern Works] "By using this strategy you lose Kafkas ordering guarantees for that topic."
> 참고: 원문은 straight apostrophe(`'`)가 아니라 curly apostrophe(U+2019, ``)를 사용한다 (`its`, `topics`, `Kafkas`). 위 인용은 원문 문자 그대로(U+2019) 보존했으며, self-grep 검증도 동일 문자로 수행했다 — 아래 검증 로그 참조.
## Claims Extracted
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| SPRK-RETRYTOPIC-C1 | non-blocking retry topic 패턴은 처리 실패 메시지를 back-off timestamp 와 함께 별도 재시도 토픽으로 포워딩하고, 재시도 토픽 컨슈머는 그 timestamp 가 도래하기 전까지 **해당 토픽 파티션의 소비 자체를 일시 중지**한다 | [§How the Pattern Works] "If message processing fails, the message is forwarded to a retry topic with a back off timestamp." + "The retry topic consumer then checks the timestamp and if its not due it pauses the consumption for that topics partition." | `official-vendor-doc` | Spring for Apache Kafka 의 `@RetryableTopic`/`RetryTopicConfiguration` non-blocking retry 기능 (reference 4.1.0) | 이 메커니즘이 raw Kafka client(Spring 미사용) 컨슈머에도 그대로 적용된다는 것 |
| SPRK-RETRYTOPIC-C2 | 재시도가 반복 실패하면 다음 재시도 토픽으로 계속 포워딩되다가, 시도 횟수 소진 시(그리고 DLT 가 설정된 경우) Dead Letter Topic 으로 전송된다 | [§How the Pattern Works] "If the message processing fails again the message will be forwarded to the next retry topic, and the pattern is repeated until a successful processing occurs, or the attempts are exhausted, and the message is sent to the Dead Letter Topic (if configured)." | `official-vendor-doc` | 동일 non-blocking retry 기능의 종료 조건 | DLT 전송이 항상 강제된다는 것 — 원문의 "(if configured)"는 DLT 가 선택 설정임을 명시 |
| SPRK-RETRYTOPIC-C3 | 이 전략(non-blocking retry topic 체인)을 사용하면 **해당 토픽의 Kafka 순서 보장을 잃는다** — 공식 문서가 직접 자인하는 trade-off | [§How the Pattern Works] "By using this strategy you lose Kafkas ordering guarantees for that topic." | `official-vendor-doc` | non-blocking retry topic 전략을 채택하는 임의 토픽 — per-aggregate(파티션 키) 순서 보장이 필요한 leaf 에서 이 전략을 배제/채택할지 판단하는 결정적 근거 | blocking retry 가 전반적으로 우월하다는 것은 아님 — 순서 손실의 정량적 영향(예: 얼마나 자주, 어느 범위까지)은 명시하지 않으며, 파티션 키 설계로 순서 손실을 완화할 수 있는지도 이 문장만으로는 증명 못 함 |
| SPRK-RETRYTOPIC-C4 | 예시 설정(1000ms 백오프, multiplier 2, 최대 4회 시도)에서 프레임워크는 `<topic>-retry-<backoffMs>` 형태의 재시도 토픽들과 `<topic>-dlt` 토픽을 **자동 생성**하고 리스너까지 구성한다 | [§How the Pattern Works] "it will create the main-topic-retry-1000, main-topic-retry-2000, main-topic-retry-4000 and main-topic-dlt topics and configure the respective consumers." | `official-vendor-doc` | 기본(커스터마이징 없는) 토픽 명명 및 자동 생성 동작의 예시 | 이 예시가 보여주는 것은 특정 백오프 설정(1000ms×2, 4회)에서의 네이밍이며, `-retry`/`-dlt` 단독 접미사가 모든 설정에서 고정된 기본값이라는 일반 규칙은 별도 "Topic Naming" reference 페이지 소관 — 이 URL(1 dispatch = 1 URL 원칙) 로는 확인하지 않음, **미발견** |
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `SPRK-RETRYTOPIC-C1`, `SPRK-RETRYTOPIC-C2`: non-blocking retry topic 패턴의 동작 메커니즘(timestamp 기반 pause/resume, 시도 소진 시 DLT)
- `SPRK-RETRYTOPIC-C3`: 이 전략이 해당 토픽의 Kafka 순서 보장을 깨뜨린다는 공식 자인 — per-aggregate 순서 보장이 필요한 consumer 설계에서 이 대안을 배제할지 판단하는 결정적 근거
- `SPRK-RETRYTOPIC-C4`: 특정 백오프 설정 예시에서의 토픽 자동 생성/명명 동작
- 이 자료가 증명하지 않는 것:
- blocking retry(동일 파티션 내 backoff) 전략이 순서를 보존하면서도 처리량/지연 측면에서 더 우월하다는 것 — 이 문서는 trade-off 의 한쪽(순서 손실)만 자인할 뿐 대안 비교를 제공하지 않음
- `-retry`/`-dlt` 접미사가 모든 backoff 설정에서 고정된 기본값이라는 일반 규칙 (Topic Naming 별도 페이지 소관, 이 dispatch 범위 밖)
- AckMode·`seekAfterError` 관련 문장(원문에 존재하나 본 raw 문서는 ca-skeleton 결정 근거로 요청된 순서 손실/패턴 동작/명명에 집중해 별도 Claim 화하지 않음 — 필요 시 후속 raw 문서로 분리)
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- ca-skeleton `adapter:inbound:messaging-kafka` 의 실제 파티션 키 설계(예: aggregate id 기반)가 이 순서 손실 trade-off 를 어느 leaf 에서 감수 가능한지/불가능한지 branch-local 결정(D-row)으로 명시 필요
- 이 전략을 채택할 경우 `InboxStorePort` 멱등 계약과의 상호작용(재시도 토픽에서 재처리되는 메시지의 멱등 판정) 검증 필요 — sibling branch `feature-idempotency-ownership-protocol-contract` 소관
## 메모
- 인용 해석 후보(미검증): 순서 손실은 "해당 토픽" 단위로 발생하므로, 만약 재시도 토픽을 원본과 동일 파티션 키로 구성한다면 같은 key 의 메시지끼리는 재시도 토픽 내에서 여전히 순서가 섞일 수 있음 — 이 문서만으로는 파티션 키 유지 여부에 따른 완화 가능성을 확인할 수 없음 (검증 필요)
- 추가로 봐야 할 동일 출처 페이지: `retrytopic/topic-naming.html`(기본 접미사 커스터마이징 규칙), `retrytopic/features.html`(추가 옵션), `retrytopic/dlt-strategies.html`(DLT 처리 전략) — 각각 별도 dispatch 로 수집
## Related
- 같은 주제 다른 official-doc / company-tech-blog: (아직 없음 — Kafka 공식 consumer configs·rebalance 문서는 `feature-kafka-consumer-inbox-contract` 의 "추가 수집 필요" 항목으로 남아 있음)
- 이 자료를 인용한 wiki 요약: (생성 시 링크)
@@ -0,0 +1,91 @@
---
title: official-doc / Spring for Apache Kafka — Pausing and Resuming Partitions on Listener Containers
source_type: official-doc
url: https://docs.spring.io/spring-kafka/reference/kafka/pause-resume-partitions.html
archive_url:
related_branches: [feature-kafka-consumer-inbox-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, messaging, kafka, bounded-queue]
created: 2026-07-28
---
# official-doc / Spring for Apache Kafka — Pausing and Resuming Partitions on Listener Containers
> Layer: `raw/` — 외부 자료(공식 문서)의 **원문 발췌·출처 기록**.
> 검증된 요약은 `/ingest``wiki/concepts/`에 별도 작성. 원본은 raw에 영구 보관.
> 자매 문서(컨테이너 레벨 pause/resume): [[raw/official-docs/spring-kafka-listener-container-pause-resume-backpressure]] — 이 문서는 그 문서와 별도 URL("Pausing and Resuming **Partitions** on Listener Containers")을 다루며, 인접 페이지 중복 수집이 아니다.
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-kafka-consumer-inbox-contract]] | D4("순서 단위는 파티션 — 파티션별 독립 bounded queue")와 D5("포화 시 pause")를 **파티션 단위**로 구현할 수 있는 근거 — 컨테이너 전체가 아니라 특정 `TopicPartition` 만 pause/resume 하는 공식 API(`pausePartition`/`resumePartition`)의 존재·이름·반영 시점을 확인. 아울러 **rebalance 로 파티션이 재배정될 때 pause 상태가 어떻게 되는지는 이 문서도 다루지 않는다는 부재를 확인**(SPRK-PAUSEPART-C5) — 브랜치가 이미 §구현 가이드 3(`onPartitionsAssigned` 행)에서 "재배정된 파티션의 pause 상태를 반드시 초기화(resume)"라고 적어 둔 계약의 근거가 이 문서로도 보강되지 않는다는 뜻 |
## 출처
- 원본 URL: https://docs.spring.io/spring-kafka/reference/kafka/pause-resume-partitions.html
- 아카이브 URL: (미제공)
- 저자 / 조직: Spring team (Broadcom / VMware Tanzu — Spring for Apache Kafka reference)
- 문서 버전: Spring for Apache Kafka reference **4.1.0** (페이지 breadcrumb "Spring for Apache Kafka 4.1.0" 확인). 본문에 인용된 기능 자체는 버전 **2.7** 도입이며 4.1.0 문서에도 동일 문구로 유지됨. 페이지 자체의 별도 발행일 표기는 없음
- 발행일: 명시 없음 (버전 이력만 본문에 표기)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
ca-skeleton kafka consumer inbox 브랜치(`feature-kafka-consumer-inbox-contract`)가 D4("파티션별 독립 bounded queue")와 D5("포화 시 pause") 조합을 실제로 **그 파티션만** pause 하는 형태로 구현하려면 컨테이너 전체 pause 가 아니라 파티션 단위 pause API 가 필요하다. 이 문서가 그 API(`pausePartition`/`resumePartition`)의 존재·시그니처·반영 시점·상태 조회 API 를 공식으로 확인해 준다. 동시에, 브랜치가 이미 세운 "재배정 시 pause 상태 초기화" 계약의 근거를 이 문서에서 찾을 수 있는지 확인하는 것이 이 조사의 두 번째 목적이었다 — 결과는 "부재"(아래 SPRK-PAUSEPART-C5).
## 핵심 인용
> 원문 그대로. 이 페이지는 단일 섹션("Pausing and Resuming Partitions on Listener Containers")이며 하위 번호 섹션이 없어, 위치는 fetched plain-text 의 line 번호로 표기한다 (self-grep 참조). 전체 본문은 이 5개 문장이 전부다(두 개 `<p>` 블록, 나머지는 페이지 네비게이션).
> [Pausing and Resuming Partitions on Listener Containers] "Since version 2.7 you can pause and resume the consumption of specific partitions assigned to that consumer by using the pausePartition(TopicPartition topicPartition) and resumePartition(TopicPartition topicPartition) methods in the listener containers." (line 142)
> [Pausing and Resuming Partitions on Listener Containers] "The pausing and resuming take place respectively before and after the poll() similar to the pause() and resume() methods." (line 143)
> [Pausing and Resuming Partitions on Listener Containers] "The isPartitionPauseRequested() method returns true if pause for that partition has been requested." (line 144)
> [Pausing and Resuming Partitions on Listener Containers] "The isPartitionPaused() method returns true if that partition has effectively been paused." (line 145)
> [Pausing and Resuming Partitions on Listener Containers] "Also since version 2.7 ConsumerPartitionPausedEvent and ConsumerPartitionResumedEvent instances are published with the container as the source property and the TopicPartition instance." (line 146)
## Claims Extracted / 추출된 주장
| Claim ID | Claim | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| SPRK-PAUSEPART-C1 | Spring Kafka listener container 는 버전 2.7부터 **파티션 단위** pause/resume API 를 제공한다 — `pausePartition(TopicPartition topicPartition)` / `resumePartition(TopicPartition topicPartition)` | "Since version 2.7 you can pause and resume the consumption of specific partitions assigned to that consumer by using the pausePartition(TopicPartition topicPartition) and resumePartition(TopicPartition topicPartition) methods in the listener containers." | `official-vendor-doc` | 2.7 이상 Spring Kafka listener container 에서 특정 `TopicPartition` 만 대상으로 pause/resume 하는 API 존재 | 이 API 가 파티션 단위 bounded queue(D4) 와 어떻게 배선되어야 하는지(스켈레톤 구현 세부) — 문서는 API 존재만 말함 |
| SPRK-PAUSEPART-C2 | 파티션 단위 pause/resume 도 컨테이너 레벨과 동일하게 poll() 경계에서 반영된다 — pause 는 poll() 이전, resume 은 poll() 이후 | "The pausing and resuming take place respectively before and after the poll() similar to the pause() and resume() methods." | `official-vendor-doc` | 파티션 단위 pause/resume 의 타이밍이 컨테이너 레벨(`SPRK-PAUSE-C1`, [[raw/official-docs/spring-kafka-listener-container-pause-resume-backpressure]])과 동형이라는 것 | `pauseImmediate` 옵션이 파티션 단위 pause 에도 동일 적용되는지는 본문에 명시 없음(컨테이너 레벨 문서에만 있음) |
| SPRK-PAUSEPART-C3 | 파티션 pause **요청** 여부(`isPartitionPauseRequested()`)와 **실제 정지** 여부(`isPartitionPaused()`)는 별개 API 로 구분된다 | "The isPartitionPauseRequested() method returns true if pause for that partition has been requested." + "The isPartitionPaused() method returns true if that partition has effectively been paused." | `official-vendor-doc` | 파티션 단위 pause 요청과 실제 정지 완료를 구분해 확인하는 용도 — 컨테이너 레벨의 `isPauseRequested()`/`isConsumerPaused()` 쌍(`SPRK-PAUSE-C4`)과 동형 패턴 | 요청부터 실제 정지까지 걸리는 시간 상한(본문 미언급) |
| SPRK-PAUSEPART-C4 | 파티션 단위 pause/resume 발생 시 `ConsumerPartitionPausedEvent`/`ConsumerPartitionResumedEvent` 가 컨테이너를 `source`, 해당 `TopicPartition` 을 이벤트 데이터로 발행한다 (버전 2.7~) | "Also since version 2.7 ConsumerPartitionPausedEvent and ConsumerPartitionResumedEvent instances are published with the container as the source property and the TopicPartition instance." | `official-vendor-doc` | 파티션 pause/resume 을 애플리케이션 이벤트 리스너로 관측하는 용도(모니터링·로깅) | 이벤트 발행이 동기인지 비동기인지, 리스너 예외 발생 시 pause 자체가 롤백되는지(본문 미언급) |
| SPRK-PAUSEPART-C5 | **rebalance·파티션 재배정 시 파티션 단위 pause 상태가 어떻게 되는지는 본 문서에 언급이 없다** — self-grep 결과 "rebalance"/"reassign"/"revoke"/"onPartitionsAssigned"/"onPartitionsRevoked" 어휘가 본문(2개 단락)에 전혀 등장하지 않는다 | 미발견 — `grep -ni "rebalan\|reassign\|revoke\|assign"` 를 fetched plain-text 전체(215줄)에 실행한 결과, 실제 본문(142~146줄) 범위에는 0건. 페이지 좌측 네비게이션의 "Rebalancing Listeners"/"Enforcing Consumer Rebalance"(84~85줄)는 **다른 페이지로의 링크 텍스트**일 뿐 본 페이지 본문이 아님 | `needs-confirmation` | 이 claim 은 "본 URL 범위 안에 rebalance-시 파티션 pause 상태 근거가 없다"는 부재 확인용이며, 그 자체로는 아무것도 증명하지 않음 | 파티션이 재배정될 때 이전 pause 상태가 유지되는지 초기화되는지의 실제 동작(별도 소스 필요 — Spring Kafka 소스 코드, `ConsumerRebalanceListener` 관련 공식 페이지, 또는 실제 통합 테스트로 확인) |
### Strength 참고
모든 claim 은 `official-vendor-doc` (Spring 공식 reference) 또는 미발견 확인용 `needs-confirmation`(C5) 이다. company-tech-blog 근거는 이 문서에 없다.
## Usage Boundaries / 적용 경계
- 이 자료가 직접 증명하는 것:
- `SPRK-PAUSEPART-C1`: 파티션 단위 pause/resume API(`pausePartition`/`resumePartition`)가 2.7부터 listener container 에 존재
- `SPRK-PAUSEPART-C2`: 파티션 단위 pause/resume 도 poll() 경계에서 반영
- `SPRK-PAUSEPART-C3`: pause 요청과 실제 정지를 구분하는 API(`isPartitionPauseRequested()`/`isPartitionPaused()`) 존재
- `SPRK-PAUSEPART-C4`: 파티션 pause/resume 이벤트(`ConsumerPartitionPausedEvent`/`ConsumerPartitionResumedEvent`) 발행
- 이 자료가 증명하지 않는 것:
- **rebalance·파티션 재배정 시 파티션 단위 pause 상태의 운명(유지되는가 초기화되는가)**`SPRK-PAUSEPART-C5`. 이 문서(파티션 레벨)와 자매 문서(컨테이너 레벨, `[[raw/official-docs/spring-kafka-listener-container-pause-resume-backpressure]]`)를 모두 확인했으나 **양쪽 다 이 주제를 다루지 않는다**
- manual partition assignment(그룹 관리 미사용) 환경에서 파티션 단위 pause API 의 동작 여부(본문 미언급)
- `pauseImmediate` 옵션이 파티션 단위 pause 에도 적용되는지(본문에 없음 — 컨테이너 레벨 문서에만 존재)
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- ca-skeleton 의 `feature-kafka-consumer-inbox-contract` §구현 가이드 3 의 `onPartitionsAssigned` 행이 "새로 배정된 파티션의 pause 상태를 반드시 초기화(resume)"를 `KIP429-C3` + `SPRK-PAUSE-C1` 근거로 명시하고 있는데, **이 두 claim 모두 그 구체적 동작(재배정 시 pause 상태 리셋)을 직접 말하지 않는다** (`KIP429-C3` 은 COOPERATIVE 가 소유 파티션을 유지한다는 것만, `SPRK-PAUSE-C1` 은 컨테이너 레벨 poll 경계 타이밍만 말함). 본 문서로도 이 gap 이 메워지지 않았으므로, 그 branch-note 행은 **`UNSUPPORTED_IMPL_DECISION` 재라벨 후보**다 — 재라벨 여부와 trade-off 명시는 branch 소유자(=`/branch-spec` 재실행 또는 사용자)가 판단할 사항이며, 본 raw 문서는 근거 부재 사실만 보고한다.
- 실제 동작 확인이 필요하면 Spring Kafka 소스 코드(`KafkaMessageListenerContainer``pausedPartitions` 필드가 `onPartitionsRevoked`/`onPartitionsAssigned` 콜백에서 어떻게 갱신되는지) 또는 별도 통합 테스트로 검증해야 함 — 이는 이 raw 문서의 범위 밖(공식 reference 문서에는 없음).
## 메모
> 나중에 wiki로 옮길 때 참고할 짧은 메모. 검증되지 않은 내 추론은 여기에 두지 말 것.
- 이 페이지 본문은 단 두 개 `<p>` 블록, 5개 문장이 전부다(원본 HTML 확인 — 557~565번째 줄 범위, admonition/note 박스 없음). Spring 문서치고 이례적으로 짧은 페이지.
- 컨테이너 레벨 자매 문서([[raw/official-docs/spring-kafka-listener-container-pause-resume-backpressure]])와 함께, "Spring Kafka 공식 문서가 rebalance 시 pause 상태 처리를 명시하지 않는다"는 것이 **두 페이지 모두**에서 확인됨 — 우연이 아니라 이 문서군이 다루는 범위 밖일 가능성이 높음(다른 페이지, 예: "Rebalancing Listeners"/`ConsumerAwareRebalanceListener` 관련 페이지를 살펴봐야 할 수 있음 — 별도 dispatch 대상, 이번 범위 아님).
- 추가로 봐야 할 동일 출처 페이지: "Rebalancing Listeners" (사이트 네비게이션에서 확인, line 84) — `ConsumerRebalanceListener`/`ConsumerAwareRebalanceListener` 관련 공식 API 가 partition pause 상태와 상호작용하는지 확인하려면 이 페이지를 별도 dispatch 로 조사해야 함.
## Related
- 같은 주제 다른 official-doc: [[raw/official-docs/spring-kafka-listener-container-pause-resume-backpressure]] (컨테이너 레벨 pause/resume — 자매 문서), [[raw/official-docs/kafka-incremental-cooperative-rebalance-kip429]] (KIP-429 cooperative rebalance)
- 이 자료를 인용한 wiki 요약: `[[wiki/concepts/...]]` (생성 시)
@@ -0,0 +1,95 @@
---
title: official-doc / Spring for Apache Kafka — KafkaTemplate Send Result (SendResult, get(), flush())
source_type: official-doc
url: https://docs.spring.io/spring-kafka/reference/kafka/sending-messages.html
archive_url:
related_branches: [feature-kafka-producer-runtime-contract]
related_projects: [ca-skeleton]
tags: [official-doc, ca-skeleton, messaging, kafka]
created: 2026-07-28
---
# Spring for Apache Kafka — KafkaTemplate Send Result (SendResult, get(), flush())
> Layer: `raw/` — 외부 자료(공식 문서)의 **원문 발췌·출처 기록**.
> 문서 버전: Spring for Apache Kafka **4.1.0** reference — "Sending Messages" §"Using KafkaTemplate".
## source_type
`official-doc` — Spring for Apache Kafka 공식 reference 문서 (docs.spring.io).
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-kafka-producer-runtime-contract]] | ca-skeleton 의 messaging 포트가 `void send` 대신 acknowledgement-aware 한 전송 결과(broker 가 확인한 topic/partition/offset)를 반환해야 하는지, 그 결과 타입을 Spring/Kafka SDK 타입(`SendResult`)으로 노출할지 프로젝트 자체 값 타입으로 감쌀지 판단할 **메커니즘 근거**(반환 타입, 구성 property, 동기 대기 방법, flush 시맨틱, 실패 전달 방식) |
## 출처
- 원본 URL: https://docs.spring.io/spring-kafka/reference/kafka/sending-messages.html
- 아카이브 URL: (미제공)
- 저자 / 조직: Spring team (Broadcom / VMware Tanzu — Spring for Apache Kafka 프로젝트)
- 발행일: 명시 없음 (버전 4.1.0 reference, "Stable" 채널)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-kafka-producer-runtime-contract` 브랜치가 messaging 포트의 `send()` 반환 타입을 정할 때 필요한 **메커니즘 사실**(반환 타입이 무엇인지, 무엇을 담는지, 어떻게 동기 대기하는지, flush/실패가 어떻게 전달되는지)을 공식 문서에서 확보하기 위함. 단, "포트가 SDK 타입을 그대로 반환해도 되는가"라는 **아키텍처 판단 자체는 이 문서가 증명하지 않는다** — 이는 ca-skeleton 의 framework-neutral 포트 원칙(별도 canonical 근거)과 대조해서 branch 가 별도로 결정해야 한다.
## 핵심 인용
> [§Using KafkaTemplate — Overview, 코드 시그니처] "CompletableFuture<SendResult<K, V>> send(ProducerRecord<K, V> record);"
> [§Using KafkaTemplate, 서술문] "Notice that the send methods return a CompletableFuture<SendResult>."
> [§Using KafkaTemplate] "SendResult has two properties, a ProducerRecord and RecordMetadata."
> [§Using KafkaTemplate] "The Throwable can be cast to a KafkaProducerException; its producerRecord property contains the failed record."
> [§Using KafkaTemplate] "If you wish to block the sending thread to await the result, you can invoke the futures get() method; using the method with a timeout is recommended."
> [§Using KafkaTemplate] "If you have set a linger.ms, you may wish to invoke flush() before waiting or, for convenience, the template has a constructor with an autoFlush parameter that causes the template to flush() on each send."
> [§Using KafkaTemplate] "Flushing is only needed if you have set the linger.ms producer property and want to immediately send a partial batch."
## Claims Extracted
| Claim ID | Claim | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| SPRING-KAFKA-SEND-C1 | `KafkaTemplate``send`/`sendDefault` 전 오버로드는 `CompletableFuture<SendResult<K, V>>` 를 반환한다 (`void` 반환이 아니다) | "CompletableFuture<SendResult<K, V>> send(ProducerRecord<K, V> record);" / "Notice that the send methods return a CompletableFuture<SendResult>." | `official-vendor-doc` | Spring for Apache Kafka 4.1.0 `KafkaTemplate.send()`/`sendDefault()` 전 오버로드 | future 가 **정확히 언제** 완료되는지(broker acks 시점, in-flight retry 종료 시점 등)는 본문이 명시적으로 진술하지 않는다 — "broker 가 확인한 결과"라는 timing 주장은 이 인용만으로 증명되지 않으며, Kafka producer 자체의 `acks` 설정 공식 문서로 별도 보강 필요 |
| SPRING-KAFKA-SEND-C2 | `SendResult<K,V>``ProducerRecord``RecordMetadata` 두 property 로 구성된다 | "SendResult has two properties, a ProducerRecord and RecordMetadata." | `official-vendor-doc` | `SendResult<K,V>` 타입의 구조 | 이 문장 자체는 `RecordMetadata` 가 partition/offset 필드를 갖는다고 명시하지 않는다(본문은 "See the Kafka API documentation for information about those objects" 로 위임). 또한 "포트가 이 SDK 타입을 그대로 반환해도 된다"는 아키텍처 판단은 증명하지 않는다 |
| SPRING-KAFKA-SEND-C3 | send 결과를 동기적으로 기다리려면 반환된 future 의 `get()` 을 호출하며, timeout 있는 오버로드 사용이 권장된다 | "If you wish to block the sending thread to await the result, you can invoke the futures get() method; using the method with a timeout is recommended." | `official-vendor-doc` | `CompletableFuture<SendResult<K,V>>` 를 동기적으로 대기하는 호출 패턴 일반 | 이 문장은 조건부("if you wish to block")이며, blocking 이 기본 권장 패턴이라는 뜻은 아니다 — 같은 섹션은 `whenComplete` 비동기 콜백을 먼저 예시로 제시한다. 포트가 반드시 blocking 방식을 채택해야 한다는 근거는 아니다 |
| SPRING-KAFKA-SEND-C4 | `linger.ms` 를 설정한 경우 대기 전 `flush()` 를 명시 호출하거나 `autoFlush` 생성자 파라미터로 매 send 마다 자동 flush 할 수 있으며, flush 는 partial batch 를 즉시 보내고 싶을 때만 필요하다 | "If you have set a linger.ms, you may wish to invoke flush() before waiting or, for convenience, the template has a constructor with an autoFlush parameter that causes the template to flush() on each send." / "Flushing is only needed if you have set the linger.ms producer property and want to immediately send a partial batch." | `official-vendor-doc` | `KafkaTemplate``flush()` 메서드와 `autoFlush` 생성자 파라미터 | flush 호출이 다른 스레드(동일 producer 공유)에 지연을 유발할 수 있다는 성능 경고는 다른 섹션("Using DefaultKafkaProducerFactory")의 별개 진술이며 이 claim 범위 밖이다. graceful shutdown 시 flush 순서를 어떻게 강제할지는 이 문서가 규정하지 않는다 |
| SPRING-KAFKA-SEND-C5 | send future 가 예외로 완료되면 그 `Throwable``KafkaProducerException` 으로 캐스팅 가능하고, 그 `producerRecord` property 가 실패한 레코드를 담는다 | "The Throwable can be cast to a KafkaProducerException; its producerRecord property contains the failed record." | `official-vendor-doc` | `whenComplete` 콜백의 `ex` 인자, 또는 동기 `get()` 호출 시 `ExecutionException.getCause()` 로 관찰되는 실패 케이스 | 어떤 하부 원인(네트워크 오류, 브로커 거부, 직렬화 실패 등)들이 `KafkaProducerException` 으로 래핑되는지에 대한 전체 목록은 제공하지 않는다 |
### Strength 참고
모두 `official-vendor-doc` — Spring for Apache Kafka 는 Spring 프로젝트 공식 reference 문서이며 RFC/표준(`official-standard`)은 아니다.
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `SPRING-KAFKA-SEND-C1`: `send()`/`sendDefault()` 의 반환 타입이 `CompletableFuture<SendResult<K, V>>` 라는 것(즉 fire-and-forget `void` 가 아니라는 것)
- `SPRING-KAFKA-SEND-C2`: `SendResult``ProducerRecord` + `RecordMetadata` 를 담는다는 구조적 사실
- `SPRING-KAFKA-SEND-C3`: 동기 대기 방법이 `future.get()`(timeout 권장)이라는 것
- `SPRING-KAFKA-SEND-C4`: `flush()`/`autoFlush` 의 존재와 사용 조건(`linger.ms` 설정 시 partial batch 즉시 전송)
- `SPRING-KAFKA-SEND-C5`: 실패 시 예외가 `KafkaProducerException` + `producerRecord` 로 전달된다는 것
- 이 자료가 증명하지 **않는** 것:
- **"messaging 포트가 SDK 타입(`SendResult`, `RecordMetadata`)을 그대로 반환해도 되는가" 라는 아키텍처 판단.** 이 문서는 Spring Kafka 라이브러리 사용법을 설명할 뿐, ca-skeleton 의 framework-neutral 포트 원칙과의 정합성은 다루지 않는다. `SendResult` 를 그대로 포트 반환 타입으로 노출할지, 프로젝트 자체 값 타입(예: `topic`/`partition`/`offset` 만 담는 record)으로 감쌀지는 branch 가 별도 canonical 근거(포트 설계 원칙 문서)로 결정해야 한다.
- future 가 "broker ack 시점"에 정확히 완료된다는 timing 보장 — 본문은 이를 명시적으로 진술하지 않는다(§핵심 인용/C1 참조). 이 timing 주장은 Kafka producer 의 `acks` 설정 공식 문서로 별도 검증 필요.
- `RecordMetadata` 가 정확히 어떤 필드(partition, offset, timestamp 등)를 갖는지 — 본문은 "Kafka API documentation" 을 참조하라고 위임할 뿐 필드를 나열하지 않는다.
- blocking(`get()`)이 권장되는 기본 패턴이라는 것 — 문서는 조건부("if you wish to block")로만 서술하며 비동기 콜백을 먼저 제시한다.
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- ca-skeleton 의 messaging 포트 인터페이스 원칙(SDK 타입 노출 금지 여부)을 규정하는 canonical 문서와 대조
- `RecordMetadata` 의 실제 필드 목록 — Kafka client 공식 Javadoc/문서로 별도 인용 필요
- `acks=all` 등 delivery semantics 설정이 이 future 의 완료 시점에 미치는 영향 — Kafka producer configs 공식 문서로 별도 인용 필요
## 메모
- 이 문서는 "무엇을 반환하는가/무엇을 담는가/어떻게 대기하는가"라는 **메커니즘** 근거이며, "무엇을 반환해야 하는가"라는 **설계 결정**의 근거가 아니다. branch-note 의 D-row 에서 이 구분을 유지할 것.
- 추가로 봐야 할 동일 출처 페이지: Kafka producer configs 공식 문서(`acks`, `enable.idempotence`, `delivery.timeout.ms`) — 같은 branch 의 다른 결정(acks=all + idempotent producer 설정)의 근거로 별도 raw 문서 필요.
## 관련
- 같은 주제 다른 official-doc: (아직 없음 — Kafka producer configs 공식 문서 추가 시 링크)
- 이 자료를 인용한 wiki 요약: (생성 시 링크)
@@ -0,0 +1,91 @@
---
title: TanStack Query — persistQueryClient / dehydrate·hydrate
source_type: official-doc
url: https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient
archive_url:
related_branches: [feature-frontend-cache-tier-cross-tab-invalidation-contract]
related_projects: [ca-skeleton-frontend]
tags: [frontend, server-state, cache, persistence, tanstack-query]
created: 2026-07-28
---
# TanStack Query — persistQueryClient / dehydrate·hydrate
> Layer: `raw/` — 외부 자료의 **원문 발췌·출처 기록**.
## 활용 branch (필수, 최소 1개+)
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| `[[raw/branch-notes/feature-frontend-cache-tier-cross-tab-invalidation-contract]]` | version 불일치 시 **부분 복원 없이 전량 폐기**(`D1`), 복원과 렌더의 순서(`D6`), 영속 대상 선별과 직렬화 책임(`D7`) |
## 출처
- 원본 URL: https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient
- 보조 URL: https://tanstack.com/query/latest/docs/framework/react/reference/hydration
- 아카이브 URL:
- 저자 / 조직: TanStack (TanStack Query 공식 문서)
- 발행일: (문서 지속 갱신, v5 기준)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
이 repo 의 기존 TanStack 발췌([[raw/official-docs/tanstack-query-server-state-official]])는 server state 원칙만 담고 있어 **캐시 영속**을 다루지 않는다. `CachePersistencePort` 를 설계하면서 세 가지를 확인해야 했다. (a) 라이브러리가 버전 불일치 캐시를 부분 복원하는지 폐기하는지, (b) 복원이 렌더와 경합하는지, (c) 어떤 query 가 기본적으로 영속 대상인지. 세 가지 모두 공식 문서가 직접 답한다.
## 핵심 인용
> [§persistQueryClient — buster] "If the cache that is found does not also have that buster string, it will be discarded."
> [§persistQueryClient — maxAge] "The max-allowed age of the cache in milliseconds. If a persisted cache is found that is older than this time, it will be **silently** discarded (defaults to 24 hours)"
> [§persistQueryClient — How it works] "If data is found to be any of the following: 1. expired (see `maxAge`) 2. busted (see `buster`)... the persister `removeClient()` is called and the cache is immediately discarded."
> [§persistQueryClient] "`createSyncStoragePersister` and `createAsyncStoragePersister` throttle this action to happen at most every 1 second to save on potentially expensive writes."
> [§persistQueryClient — PersistQueryClientProvider] "However, restoring is asynchronous, because all persisters are async by nature, which means that if you render your App while you are restoring, you might get into race conditions if a query mounts and fetches at the same time."
> [§hydration — dehydrate] "It only includes currently successful queries by default."
> [§hydration — dehydrate Options] "`shouldDehydrateQuery` — Whether to dehydrate queries." / "Defaults to only including successful queries"
> [§hydration — dehydrate Returns] "This result is not in serialized form, you need to do that yourself if desired"
> [§hydration — hydrate Limitations] "If the queries you're trying to hydrate already exist in the queryCache, `hydrate` will only overwrite them if the data is newer than the data present in the cache."
## 추출된 주장
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| C1 | 저장된 캐시의 buster 문자열이 일치하지 않으면 그 캐시는 **폐기**된다 | [§buster] "If the cache that is found does not also have that buster string, it will be discarded." | `official-reference` | `persistQueryClient` 복원 경로 | buster 를 **무엇으로 구성**해야 하는지 — 문자열 내용은 소비자가 정한다 |
| C2 | buster 불일치·만료 시 `removeClient()` 가 호출되고 캐시는 즉시 폐기된다 — 부분 복원 경로가 없다 | [§How it works] "the persister `removeClient()` is called and the cache is immediately discarded" | `official-reference` | 복원 실패 처리 | 폐기가 사용자에게 고지되어야 한다는 뜻은 아님 |
| C3 | `maxAge` 기본값은 24시간이며 초과분은 **조용히** 폐기된다 | [§maxAge] "it will be **silently** discarded (defaults to 24 hours)" | `official-reference` | 라이브러리 기본값 | 24시간이 우리 계약의 TTL 이라는 뜻은 아님 — 우리 값은 `FE-REG-STORAGE` 가 정한다 |
| C4 | 번들 제공 persister 는 write 를 **최대 1초에 1회**로 throttle 한다 | [§persistQueryClient] "throttle this action to happen at most every 1 second to save on potentially expensive writes" | `official-reference` | `createSyncStoragePersister`·`createAsyncStoragePersister` | 우리 custom adapter 의 적정 간격이 1초라는 뜻은 아님 — 번들 구현의 선택값이다 |
| C5 | 복원은 비동기이며, 복원 중 렌더하면 query mount·fetch 와 **경합**이 발생할 수 있다 | [§PersistQueryClientProvider] "if you render your App while you are restoring, you might get into race conditions if a query mounts and fetches at the same time" | `official-reference` | 모든 async persister | 경합이 항상 눈에 보이는 오류로 나타난다는 뜻은 아님 |
| C6 | `dehydrate` 는 기본적으로 **성공한 query 만** 포함한다 | [§dehydrate] "It only includes currently successful queries by default." | `official-reference` | 영속 대상 선별 | error/pending query 를 영속하면 안 된다는 금지 규칙까지는 아님 — 기본값일 뿐 |
| C7 | `dehydrate` 결과는 직렬화된 형태가 아니며 직렬화는 소비자 책임이다 | [§dehydrate Returns] "This result is not in serialized form, you need to do that yourself if desired" | `official-reference` | 영속 adapter | 어떤 직렬화 형식을 써야 하는지 |
| C8 | `hydrate` 는 이미 캐시에 있는 query 를 **더 새로운 데이터일 때만** 덮어쓴다 | [§hydrate Limitations] "`hydrate` will only overwrite them if the data is newer than the data present in the cache" | `official-reference` | 복원 시 병합 | "더 새롭다"의 판정 기준이 무엇인지 |
## 적용 경계
- 이 자료가 직접 증명하는 것:
- `C1`·`C2`: 버전 불일치 캐시의 처리는 **부분 복원이 아니라 전량 폐기**가 라이브러리의 기존 동작이다
- `C5`: 복원과 렌더를 겹치면 경합이 생기므로 gating 이 필요하다
- `C6`~`C8`: 영속 대상 기본 선별, 직렬화 책임 위치, 복원 시 병합 규칙
- 이 자료가 증명하지 않는 것:
- 파티션 키를 `releaseId`·`configSchemaVersion`·`apiContractVersion` **세 값으로** 구성해야 한다는 것 — `C1` 은 buster 가 문자열이라는 것만 말한다. 구성은 project decision 이다
- `CachePersistencePort``QueryCachePort` 와 분리해야 한다는 것 — 문서는 단일 `queryClient` 를 전제한다
- 우리 adapter 의 throttle 값 (`C4` 는 번들 persister 의 선택값)
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- `C3` 의 24시간 기본값과 `FE-REG-STORAGE``QUERY_CACHE_SNAPSHOT` TTL 이 충돌하지 않는지
- `C8` 의 "newer" 판정이 우리 파티션 폐기 규칙보다 먼저 적용되는지 (폐기가 우선이면 무관)
## 메모
- 인용 1 해석 후보 (미검증): `C1`+`C2` 는 branch 의 `D1`("불일치 시 복원하지 않고 폐기, 부분 복원 금지")이 **자체 발명이 아니라 채택한 라이브러리의 기존 동작과 같은 방향**임을 보여준다. 다만 세 값 tuple 구성은 여전히 project-local 이다.
- 인용 2 해석 후보 (미검증): `C5` 때문에 `CachePersistencePort.restore()` 는 bootstrap 단계에서 await 되어야 하고, 복원 실패는 부팅을 막지 않고 메모리 캐시로 진행해야 한다(§8.2 `CACHE_PERSISTENCE_FAILURE` 와 정합).
- 추가로 봐야 할 동일 출처 페이지: `createAsyncStoragePersister`, `broadcastQueryClient` experimental plugin
## 관련
- 같은 주제 다른 official-doc: `[[raw/official-docs/tanstack-query-server-state-official]]`, `[[raw/official-docs/mdn-broadcastchannel-storage-event]]`
- 이 자료를 인용한 wiki 요약: 생성 전
@@ -0,0 +1,117 @@
---
title: official-doc / MongoDB — Multi-Document Transactions (Replica Set / Sharded Cluster Requirements, Concern Defaults)
source_type: official-doc
url: https://www.mongodb.com/docs/manual/core/transactions/
archive_url:
related_branches: [feature-mongo-runtime-baseline-contract]
related_projects: []
tags: [official-doc, ca-skeleton, persistence, mongodb, transaction]
created: 2026-07-28
---
# official-doc / MongoDB — Multi-Document Transactions (Replica Set / Sharded Cluster Requirements, Concern Defaults)
> Layer: `raw/` — 외부 자료(공식 문서 / 대기업 기술 블로그)의 **원문 발췌·출처 기록**.
> 본 템플릿은 `raw/official-docs/``raw/company-tech-blogs/` 두 폴더가 공유.
> 검증된 요약은 `/ingest``wiki/concepts/``source-summary-template` 형식으로 별도 작성. 원본은 raw에 영구 보관.
## source_type 허용값
`source_type: official-doc` — MongoDB 공식 매뉴얼(레퍼런스) 문서.
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-mongo-runtime-baseline-contract]] | (1) multi-document transaction 은 replica-set/sharded 요건 검증을 통과할 때만 활성화한다 — FCV 최소버전(Replica Set 4.0 / Sharded Cluster 4.2), WiredTiger storage engine 요건, `writeConcernMajorityJournalDefault: false` 샤드 배제 근거. (2) transaction 은 시간·크기 한계를 가지므로 무제한 사용을 전제하지 않는다 — "runtime limit" 이 공식적으로 별도 usage consideration 으로 취급된다는 근거(단, 구체 수치는 이 URL 범위 밖, 아래 Usage Boundaries 참고) |
## 출처
- 원본 URL: https://www.mongodb.com/docs/manual/core/transactions/
- 아카이브 URL: (미제공)
- 저자 / 조직: MongoDB, Inc. (공식 매뉴얼)
- 발행일: (문서 자체에 명시 없음 — 버전별 롤링 업데이트되는 레퍼런스 페이지. 2026-07-28 fetch 시점 기준 "Starting in MongoDB 8.1" 문구 포함 확인)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-mongo-runtime-baseline-contract` branch가 "multi-document transaction 은 replica-set/sharded 요건 검증을 통과할 때만 활성화하고 standalone 에서는 사용하지 않는다"와 "transaction 은 시간·크기 한계를 가지므로 무제한 사용을 전제하지 않는다"를 결정하는 데 필요한 FCV/storage-engine 배포 요건, read/write concern 기본값 결정 체인, runtime limit 서술의 공식 근거로 보관.
## 핵심 인용
> [§Transactions and Atomicity — intro] "In MongoDB, an operation on a single document is atomic." [...] "multi-document transactions are not necessary for many practical use cases."
> [§Feature Compatibility Version (FCV)] "To use transactions, the featureCompatibilityVersion for all members of the deployment must be at least:" — 표: Replica Set → `4.0`, Sharded Cluster → `4.2`
> [§Storage Engines] "Transactions are supported on replica sets and sharded clusters where:" [...] "the secondary members use either the WiredTiger storage engine or the in-memory storage engines."
> [§Shard Configuration Restriction] "You cannot run transactions on a sharded cluster that has a shard with writeConcernMajorityJournalDefault set to false, such as a shard with a voting member that uses the in-memory storage engine."
> [§Transactions and Read Concern — MongoDB Defaults] "By default, the client-level read concern is "local" for reads on the primary."
> [§Transactions and Write Concern — MongoDB Defaults] "If the transaction-level write concern and the session-level write concern are unset, the transaction-level write concern defaults to the client-level write concern of:" — `w: "majority"` (MongoDB 5.0+) 또는 `w: 1` (이전 버전)
> [§Transactions and Write Concern — Tip] "Don't explicitly set the write concern for the individual write operations inside a transaction. Setting write concerns for the individual write operations inside a transaction returns an error."
> [§Transactions and Sessions] "If a session ends and it has an open transaction, the transaction aborts."
> [§Transactions and Atomicity — Important] "For additional transactions usage considerations (such as runtime limit and oplog size limit), see also Production Considerations."
> [§Transactions and Write Concern — Note] "Regardless of the write concern specified for the transaction, the driver applies w: "majority" as the write concern when retrying commitTransaction."
## Claims Extracted
> 이 자료가 **직접 말하는 것만** claim 으로 분리한다. 내 프로젝트에 적용한 결론은 여기 쓰지 않는다.
| Claim ID | Claim | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| MONGO-TXN-C1 | 단일 document 에 대한 연산은 원자적이며, 이 때문에 많은 실무 사용 사례에서 multi-document transaction 이 필수가 아니다 | [§Transactions and Atomicity] "In MongoDB, an operation on a single document is atomic." [...] "multi-document transactions are not necessary for many practical use cases." | `official-vendor-doc` | 임베디드 document/array 로 관계를 표현할 수 있는 일반적인 스키마 설계 | 특정 도메인(예: 여러 collection 에 걸친 정산·재고 이동)이 transaction 없이 처리 가능한지는 이 문장이 증명하지 않음 — 스키마별 개별 판단 필요 |
| MONGO-TXN-C2 | Transaction 을 사용하려면 배포의 모든 멤버가 최소 FCV 를 만족해야 한다 — Replica Set 은 `4.0` 이상, Sharded Cluster 는 `4.2` 이상 | [§Feature Compatibility Version (FCV)] "To use transactions, the featureCompatibilityVersion for all members of the deployment must be at least:" (표: Replica Set `4.0` / Sharded Cluster `4.2`) | `official-vendor-doc` | replica-set 또는 sharded-cluster 로 배포된 MongoDB 전반 | standalone(단일 `mongod`, replica set 아님) 배포에서 transaction 시도 시 정확히 어떤 에러/거부 메시지가 발생하는지는 이 문장 자체가 규정하지 않음 — FCV 표가 애초에 standalone 을 대상에서 제외하고 있을 뿐 |
| MONGO-TXN-C3 | Transaction 은 replica set 과 sharded cluster 에서 지원되며, primary 는 WiredTiger storage engine 을 사용해야 하고 secondary 는 WiredTiger 또는 in-memory storage engine 을 사용해야 한다 | [§Storage Engines] "Transactions are supported on replica sets and sharded clusters where:" [...] "the secondary members use either the WiredTiger storage engine or the in-memory storage engines." | `official-vendor-doc` | replica-set/sharded-cluster 배포의 storage engine 조합 검증 | standalone 인스턴스에 대한 명시적 언급은 없음 — "replica sets and sharded clusters where" 로 지원 범위를 positively 한정할 뿐, standalone 을 명시적으로 거부한다고 말하지는 않음 |
| MONGO-TXN-C4 | `writeConcernMajorityJournalDefault``false` 로 설정된 shard(예: in-memory storage engine 을 쓰는 voting member 를 가진 shard)가 있는 sharded cluster 에서는 transaction 을 실행할 수 없다 | [§Shard Configuration Restriction] "You cannot run transactions on a sharded cluster that has a shard with writeConcernMajorityJournalDefault set to false, such as a shard with a voting member that uses the in-memory storage engine." | `official-vendor-doc` | sharded cluster 배포에서 shard 별 `writeConcernMajorityJournalDefault` 설정 검증 | replica set(비-sharded) 배포에 동일 제약이 적용되는지는 이 문장이 명시하지 않음 — 문장 자체가 sharded cluster 로 범위를 한정함 |
| MONGO-TXN-C5 | Transaction 의 read concern 은 transaction-level → session-level → client-level 순으로 기본값이 결정되며, client-level read concern 의 기본값은 primary 읽기 기준 `"local"` 이다 | [§Transactions and Read Concern] "By default, the client-level read concern is "local" for reads on the primary." | `official-vendor-doc` | read concern 을 명시하지 않고 transaction 을 시작하는 모든 경우 | `"majority"`/`"snapshot"` 을 명시적으로 설정했을 때의 동작까지는 이 한 문장이 다루지 않음(별도 절에서 각각 정의됨, 이번 raw 에는 claim 화하지 않음) |
| MONGO-TXN-C6 | Transaction 의 write concern 은 transaction-level → session-level → client-level 순으로 기본값이 결정되며, client-level write concern 기본값은 MongoDB 5.0 이상에서 `w: "majority"`, 그 이전 버전에서는 `w: 1` 이다 | [§Transactions and Write Concern] "If the transaction-level write concern and the session-level write concern are unset, the transaction-level write concern defaults to the client-level write concern of:" (`w: "majority"` MongoDB 5.0+ / `w: 1` 이전) | `official-vendor-doc` | write concern 을 명시하지 않고 transaction 을 커밋하는 모든 경우(버전에 따라 분기) | arbiter 를 포함한 배포에서 이 기본값이 달라지는 정확한 조건은 이 문장 자체에 없음 — 링크된 "Implicit Default Write Concern" 별도 페이지 확인 필요(`write-concern-mongodb-official.md` C8 의 동일 gap 참고) |
| MONGO-TXN-C7 | Transaction 내부의 개별 write 연산에는 명시적 write concern 을 설정할 수 없으며, 설정 시 에러가 발생한다 — write concern 은 반드시 transaction/session/client 레벨에서만 설정한다 | [§Transactions and Write Concern] "Don't explicitly set the write concern for the individual write operations inside a transaction. Setting write concerns for the individual write operations inside a transaction returns an error." | `official-vendor-doc` | transaction 내부에서 실행되는 모든 write 연산의 write concern 설정 위치 제약 | 이 제약을 어겼을 때 반환되는 정확한 에러 코드/메시지 문자열은 이 문장이 제공하지 않음 |
| MONGO-TXN-C8 | Transaction 은 session 에 연결되며, session 이 열린 transaction 을 가진 채 종료되면 그 transaction 은 abort 된다 | [§Transactions and Sessions] "If a session ends and it has an open transaction, the transaction aborts." | `official-vendor-doc` | 세션 종료(연결 끊김, 명시적 session 종료 등)로 인한 transaction abort 조건 | 이것은 **세션 종료로 인한 abort** 이며, 실행 시간(runtime) 초과로 인한 abort(`transactionLifetimeLimitSeconds` 등)와는 별개의 트리거다 — 시간 초과 abort 의 구체 파라미터/기본값/동작은 이 URL 에 없음(C9 참고, Usage Boundaries 에 gap 명시) |
| MONGO-TXN-C9 | MongoDB 공식 문서는 transaction 의 "runtime limit" 과 "oplog size limit" 을 별도의 usage consideration 으로 명시적으로 인정하며, 상세는 Production Considerations 페이지로 위임한다 | [§Transactions and Atomicity] "For additional transactions usage considerations (such as runtime limit and oplog size limit), see also Production Considerations." | `needs-confirmation` | transaction 이 "무제한 사용을 전제하지 않는다"는 원칙적 근거(런타임 한계가 공식적으로 존재한다는 사실 자체) | **구체적인 시간 한계 값(예: `transactionLifetimeLimitSeconds` 기본값 60초)과 초과 시 정확한 abort 동작은 이 URL 에 없다** — 별도 페이지(`Production Considerations for Transactions`, https://www.mongodb.com/docs/manual/core/transactions-production-consideration/)의 fetch 가 필요하며, 1 dispatch = 1 URL 원칙에 따라 이번 raw 에는 포함하지 않음 |
| MONGO-TXN-C10 | Transaction 커밋에 어떤 write concern 이 지정되었든 관계없이, driver 가 `commitTransaction` 을 재시도할 때는 항상 `w: "majority"` write concern 을 적용한다 | [§Transactions and Write Concern] "Regardless of the write concern specified for the transaction, the driver applies w: "majority" as the write concern when retrying commitTransaction." | `needs-confirmation` | driver 가 `commitTransaction` 명령 자체를 재시도하는 경우의 write concern 승격 규칙 | **일반적인 TransientTransactionError 라벨 감지 → 전체 transaction 재시도 루프 패턴**은 이 문장이 다루지 않는다 — 그 패턴은 드라이버별 에러 처리 가이드(별도 페이지)의 범위이며 이 URL 에는 없음 |
### Strength 허용값
- `official-standard` — RFC, 표준 사양, 언어/프로토콜 표준
- `official-vendor-doc` — Spring, Keycloak, AWS, Google, MongoDB 등 공식 벤더 문서
- `official-reference` — 공식 reference/API 문서
- `company-case-study` — 대기업/실무 기술 블로그의 특정 사례
- `engineering-blog` — 개인/팀 블로그의 엔지니어링 해설
- `tutorial` — 튜토리얼/가이드. 일반화 금지
- `needs-confirmation` — 원문만으로는 적용 판단 불가
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `MONGO-TXN-C1`: 단일 document 연산은 이미 원자적이라 다수 실무 사용 사례에서 transaction 이 필수가 아니라는 공식 서술
- `MONGO-TXN-C2`~`C4`: transaction 활성화의 배포 형태 요건 — FCV 최소버전(Replica Set 4.0 / Sharded Cluster 4.2), storage engine 조합(primary WiredTiger 필수), `writeConcernMajorityJournalDefault: false` shard 배제
- `MONGO-TXN-C5`~`C7`: read concern(기본 `"local"`)·write concern(기본 `w:"majority"` 또는 `w:1`, 버전 의존) 기본값 결정 체인과 "개별 write op 에 명시적 write concern 금지" 제약
- `MONGO-TXN-C8`: session 종료로 인한 transaction abort — 시간 초과와는 별개의 abort 트리거이지만 "transaction 이 무한정 열려있지 않는다"는 원칙의 부분 근거
- `MONGO-TXN-C9`: "runtime limit" 이 공식 문서에서 별도 usage consideration 으로 인정된다는 사실 — 단 구체 수치는 없음
- `MONGO-TXN-C10`: `commitTransaction` 재시도 시 write concern 승격 규칙 — 단 일반 transient error 재시도 패턴 전체는 아님
- 이 자료가 증명하지 않는 것:
- **`transactionLifetimeLimitSeconds` 의 정확한 기본값과, 시간 한계 초과 시의 구체적 abort 동작·에러 코드** — 이 URL 은 "runtime limit" 을 언급만 하고 상세를 `Production Considerations for Transactions` 페이지로 위임한다. 그 페이지는 이번 dispatch 에 포함되지 않음(1 dispatch = 1 URL)
- **TransientTransactionError / UnknownTransactionCommitResult 라벨 기반의 일반 재시도 루프 패턴** — 이 URL 은 `commitTransaction` 재시도 시 write concern 규칙 하나만 언급하며, 드라이버별 에러 처리 가이드에 있는 전체 재시도 패턴은 다루지 않음
- standalone(비-replica-set) `mongod` 인스턴스에서 transaction 시도 시 정확한 거부 메커니즘(기동 시 거부 vs 런타임 에러) — 이 URL 은 지원 범위를 replica-set/sharded-cluster 로 positively 서술할 뿐, standalone 명시적 거부 문구는 없음
- 특정 드라이버(Java Reactive Streams, Spring Data MongoDB 등)가 transaction 을 어떤 API 로 노출하는지 — 이 자료는 서버/개념 문서
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- `transactionLifetimeLimitSeconds` 기본값과 초과 시 동작 — 별도 raw 자료(`transactions-production-consideration-mongodb-official` 가칭) dispatch 필요
- ca-skeleton 이 실제로 채택할 MongoDB 배포가 replica set 인지 sharded cluster 인지(양쪽 FCV 요건이 다름)
- Spring Data MongoDB(또는 채택할 드라이버)가 이 기본값들을 어떤 설정 키로 노출하는지
## 메모
- 사용자가 명시적으로 요구한 "transactionLifetimeLimitSeconds 같은 실행 시간 한계와 초과 시 abort 동작" 및 "transient transaction error / retry 권고"는 이 URL(`/docs/manual/core/transactions/`)의 fetch 결과에 상세 수치·패턴으로 존재하지 않았다. 대신 이 페이지가 "runtime limit" 을 별도 considerations 로 인정하고 Production Considerations 페이지로 위임한다는 사실(C9)과, `commitTransaction` 재시도 시 write concern 규칙(C10)만 verbatim 으로 확보했다. Self-Grep 통과분만 claim 화했고, 구체 수치는 fabrication 방지를 위해 claim 에 포함하지 않았다.
- WebFetch 로 1차 처리한 결과가 원문 표(FCV 표 등)를 산문으로 재구성하면서 일부 수치 매핑이 모호해질 위험이 있어, curl 로 원본 HTML 을 직접 받아 self-grep 검증했다(WebFetch 결과는 참고용으로만 사용, 최종 인용은 curl 원문 기준).
- 추가로 봐야 할 동일 출처 페이지: `https://www.mongodb.com/docs/manual/core/transactions-production-consideration/` (runtime limit 구체 수치, `transactionLifetimeLimitSeconds`), 드라이버별 transient transaction error 처리 가이드(예: `https://www.mongodb.com/docs/drivers/node/current/fundamentals/transactions/` 등 언어별 페이지)
## 관련
- 같은 주제 다른 official-doc: [[raw/official-docs/write-concern-mongodb-official]] (write concern `w`/`j`/`wtimeout` 상세), [[raw/official-docs/read-preference-mongodb-official]] (read preference 5종 모드 및 트랜잭션 내 `primary` 고정 제약)
- 이 자료를 인용한 wiki 요약: `[[wiki/concepts/...]]` (생성 시)
@@ -0,0 +1,100 @@
---
title: official-doc / MongoDB — Production Considerations for Transactions (Runtime & Oplog Size Limits)
source_type: official-doc
url: https://www.mongodb.com/docs/manual/core/transactions-production-consideration/
archive_url:
related_branches: [feature-mongo-runtime-baseline-contract]
related_projects: []
tags: [official-doc, ca-skeleton, persistence, mongodb, transaction]
created: 2026-07-28
---
# official-doc / MongoDB — Production Considerations for Transactions (Runtime & Oplog Size Limits)
> Layer: `raw/` — 외부 자료(공식 문서 / 대기업 기술 블로그)의 **원문 발췌·출처 기록**.
> 본 템플릿은 `raw/official-docs/``raw/company-tech-blogs/` 두 폴더가 공유.
> 검증된 요약은 `/ingest``wiki/concepts/``source-summary-template` 형식으로 별도 작성. 원본은 raw에 영구 보관.
## source_type 허용값
`source_type: official-doc` — MongoDB 공식 매뉴얼(레퍼런스) 문서. 형제 자료 [[raw/official-docs/transactions-mongodb-official]]의 "Production Considerations 페이지 참조" gap 을 닫기 위해 그 페이지 자체를 dispatch.
## Parent / 활용 branch
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-mongo-runtime-baseline-contract]] | (부분) multi-document transaction 은 시간·크기 한계를 가지므로 무제한 사용을 전제하지 않고, 한계 초과 시 동작을 계약에 명시한다 — runtime limit("less than one minute" 기본값 + `transactionLifetimeLimitSeconds` 초과 시 periodic cleanup 에 의한 abort)과 oplog entry 크기 한계(16MB, transaction 전체가 아닌 entry 단위) 근거. **주의**: 이 URL 은 "transient transaction error 재시도 책임을 애플리케이션 계약으로 못박는다" 결정은 정당화하지 못한다 — `TransientTransactionError`/`UnknownTransactionCommitResult` 라벨이 이 URL 본문에 없음(Self-Grep 0 매치, 아래 Usage Boundaries 참조). 이 결정은 별도 URL(드라이버별 에러 처리 가이드) dispatch 필요 |
## 출처
- 원본 URL: https://www.mongodb.com/docs/manual/core/transactions-production-consideration/
- 아카이브 URL: (미제공)
- 저자 / 조직: MongoDB, Inc. (공식 매뉴얼)
- 발행일: (문서 자체에 명시 없음 — 버전별 롤링 업데이트되는 레퍼런스 페이지. 2026-07-28 fetch 시점 기준 breadcrumb 상 "Database Manual 8.3 (Current)" 확인)
- 마지막 확인일: 2026-07-28
## 왜 저장했는지
`feature-mongo-runtime-baseline-contract` branch 가 "transaction 은 시간·크기 한계를 가지므로 무제한 사용을 전제하지 않는다"를 구체 수치(runtime limit 기본값, oplog entry 크기 제한)로 뒷받침하기 위해 보관. 형제 자료 [[raw/official-docs/transactions-mongodb-official]]의 Claim `MONGO-TXN-C9`가 "구체 수치는 이 URL 범위 밖"이라며 위임한 페이지가 바로 이 문서.
## 핵심 인용
> [§Availability] "MongoDB standalone deployments do not support transactions. To use transactions, your deployment must be a multiple node replica set."
> [§Runtime Limit] "By default, a transaction must have a runtime of less than one minute. You can modify this limit using transactionLifetimeLimitSeconds for the mongod instances." [...] "Transactions that exceeds this limit are considered expired and will be aborted by a periodic cleanup process."
> [§Oplog Size Limit] "MongoDB creates as many oplog entries as necessary to the encapsulate all write operations in a transaction, instead of a single entry for all write operations in the transaction." [...] "each oplog entry still must be within the BSON document size limit of 16MB."
> [§WiredTiger Cache] "When you encounter an error during individual operation in the transaction, abort and retry the transaction."
> [§WiredTiger Cache — Note] "If a transaction is too large to ever fit in the WiredTiger cache, the transaction aborts and returns a TransactionTooLargeForCache error."
## Claims Extracted
| Claim ID | Claim | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| MONGO-TXN-PROD-C1 | MongoDB standalone 배포는 transaction 을 지원하지 않으며, transaction 을 쓰려면 multiple-node replica set 이어야 한다 | [§Availability] "MongoDB standalone deployments do not support transactions. To use transactions, your deployment must be a multiple node replica set." | `official-vendor-doc` | 모든 MongoDB 배포 형태(standalone vs replica set/sharded cluster) 판단 | sharded cluster 고유 추가 제약(별도 "Production Considerations (Sharded Clusters)" 페이지로 위임됨)까지는 다루지 않음 |
| MONGO-TXN-PROD-C2 | Transaction 은 기본적으로 1분(one minute) 미만의 runtime 을 가져야 하며, 이 한계는 `transactionLifetimeLimitSeconds` 파라미터(sharded cluster 는 모든 shard replica set member 에 동일 적용)로 조정 가능하고, 한계를 초과한 transaction 은 expired 로 간주되어 periodic cleanup 프로세스에 의해 abort 된다 | [§Runtime Limit] "By default, a transaction must have a runtime of less than one minute. You can modify this limit using transactionLifetimeLimitSeconds for the mongod instances." [...] "Transactions that exceeds this limit are considered expired and will be aborted by a periodic cleanup process." | `official-vendor-doc` | replica-set/sharded-cluster 배포의 `mongod` 인스턴스 런타임 한계 | **`transactionLifetimeLimitSeconds` 의 정확한 기본 정수값(예: 60)은 이 문장에 없다** — 원문은 "less than one minute" 이라고만 서술하며 정수 초 표기는 없음. 정확한 기본 정수값은 이 URL 범위 밖(파라미터 레퍼런스 페이지 확인 필요, 아래 Usage Boundaries 참조) |
| MONGO-TXN-PROD-C3 | Transaction 이 여러 write 연산을 포함해도 단일 oplog entry 가 아니라 필요한 만큼의 여러 oplog entry 로 기록되며, 이로써 이전에 있던 "transaction 전체 16MB 크기 제한"은 제거되지만, 개별 oplog entry 각각은 여전히 16MB BSON document 크기 제한을 지켜야 한다 | [§Oplog Size Limit] "MongoDB creates as many oplog entries as necessary to the encapsulate all write operations in a transaction, instead of a single entry for all write operations in the transaction." [...] "each oplog entry still must be within the BSON document size limit of 16MB." | `official-vendor-doc` | transaction 내 여러 write 연산의 oplog 기록 방식과 크기 한계 | transaction 이 포함할 수 있는 write 연산의 "개수" 자체에 대한 상한은 이 문장이 규정하지 않음 — 다루는 것은 개별 oplog entry 의 BSON 크기 한계뿐 |
| MONGO-TXN-PROD-C4 | Transaction 이 WiredTiger cache 에 담기기에 너무 크면 `TransactionTooLargeForCache` 에러와 함께 abort 된다 | [§WiredTiger Cache — Note] "If a transaction is too large to ever fit in the WiredTiger cache, the transaction aborts and returns a TransactionTooLargeForCache error." | `official-vendor-doc` | WiredTiger storage engine 을 사용하는 배포에서 대용량 transaction 실행 시 실패 모드 | 이 에러가 발생하는 정확한 크기 임계값(캐시 크기 대비 비율 등)은 이 문장에 없음 |
| MONGO-TXN-PROD-C5 | WiredTiger cache pressure 를 막기 위해, transaction 내 개별 연산에서 에러를 만나면 그 transaction 을 abort 하고 재시도(retry)하라고 권고한다 | [§WiredTiger Cache] "When you encounter an error during individual operation in the transaction, abort and retry the transaction." | `official-vendor-doc` | WiredTiger cache pressure 완화 목적의 일반적 "에러 시 abort+retry" 운영 권고 | **이것은 `TransientTransactionError`/`UnknownTransactionCommitResult` 에러 라벨 기반의 표준 재시도 프로토콜이 아니다.** 이 URL 본문에는 두 라벨 모두 존재하지 않는다(Self-Grep 확인, 0 매치) — 이 문장은 "왜 abort+retry 를 하는가"(cache pressure 완화)를 말할 뿐, 애플리케이션이 라벨을 어떻게 감지해 전체 transaction 을 재시도해야 하는지의 프로토콜은 다루지 않음. 그 프로토콜은 드라이버별 에러 처리 가이드(별도 URL)의 범위 |
### Strength 허용값
- `official-standard` — RFC, 표준 사양, 언어/프로토콜 표준
- `official-vendor-doc` — Spring, Keycloak, AWS, Google, MongoDB 등 공식 벤더 문서
- `official-reference` — 공식 reference/API 문서
- `company-case-study` — 대기업/실무 기술 블로그의 특정 사례
- `engineering-blog` — 개인/팀 블로그의 엔지니어링 해설
- `tutorial` — 튜토리얼/가이드. 일반화 금지
- `needs-confirmation` — 원문만으로는 적용 판단 불가
## Usage Boundaries
- 이 자료가 직접 증명하는 것:
- `MONGO-TXN-PROD-C1`: standalone 배포는 transaction 미지원, multi-node replica set 필요
- `MONGO-TXN-PROD-C2`: transaction runtime 기본 한계는 "1분 미만"이며 `transactionLifetimeLimitSeconds` 로 조정, 초과 시 periodic cleanup 이 abort
- `MONGO-TXN-PROD-C3`: oplog entry 는 transaction 당 여러 개로 기록되어 transaction 전체 16MB 제한은 없지만, entry 단위 16MB BSON 제한은 여전히 존재
- `MONGO-TXN-PROD-C4`: cache 초과 시 `TransactionTooLargeForCache` 에러로 abort
- `MONGO-TXN-PROD-C5`: 개별 연산 에러 시 abort+retry 하라는 cache-pressure 완화 목적의 일반 권고
- 이 자료가 증명하지 않는 것 (사용자가 명시적으로 요청했으나 이 URL 에 부재 — Self-Grep 확인, 0 매치, 절대 추정하지 않음):
- **`transactionLifetimeLimitSeconds` 의 정확한 기본 정수값(초)**. 원문은 "less than one minute" 이라고만 서술한다. "60초"라는 숫자는 이 URL 어디에도 없다 — 별도 파라미터 레퍼런스 페이지(`/docs/manual/reference/parameters/#mongodb-parameter-param.transactionLifetimeLimitSeconds`) 확인 필요
- **`TransientTransactionError` 라벨** — 이 URL 본문에 전혀 등장하지 않는다(grep 0 매치)
- **`UnknownTransactionCommitResult` 라벨** — 이 URL 본문에 전혀 등장하지 않는다(grep 0 매치)
- **"transaction 을 짧게 유지하라"(작은 문서 수/짧은 실행 시간) 는 명시적 권고 문장** — 이 URL 에 그런 문장은 없다(grep 0 매치, "keep...short"/"small number of documents" 류 문구 부재). 가장 근접한 내용은 WiredTiger Cache 섹션의 "에러 시 abort+retry" 권고(`MONGO-TXN-PROD-C5`)뿐이며, 이는 "짧게 유지하라"는 사전 설계 권고가 아니라 "에러 발생 후 사후 대응" 권고다
- 내 프로젝트에 적용하려면 추가 확인이 필요한 것:
- `transactionLifetimeLimitSeconds` 기본 정수값 — 파라미터 레퍼런스 페이지 별도 dispatch
- `TransientTransactionError`/`UnknownTransactionCommitResult` 라벨과 애플리케이션 재시도 계약 — 드라이버별 에러 처리 가이드(예: MongoDB Manual "Transactions and Sessions" 또는 언어별 driver 문서) 별도 dispatch 필요
- "transaction 을 짧게 유지하라"는 실무 권고 자체가 필요하면 별도 출처(공식 문서 다른 페이지 또는 기술 블로그) 확보 필요 — 이 URL 은 근거가 아님
## 메모
- 사용자가 요청한 5개 항목 중 (1) runtime limit 기본값 서술과 (2) oplog entry 16MB 한계는 이 URL 에서 verbatim 확보했다. 단 (1)의 정확한 초 단위 숫자는 원문에 없어 "less than one minute"로만 기록했다.
- (3) `TransientTransactionError`, (4) `UnknownTransactionCommitResult`, (5) "짧게 유지하라" 권고는 이 URL 본문에 **부재**함을 self-grep(0 매치)으로 확인했다. 추정하지 않고 Claims Extracted 에서 제외했으며 Usage Boundaries 에 gap 으로 명시했다.
- WebFetch 1차 결과가 표/목록을 산문으로 재구성하며 "TransactionTooLargeForCache" 등 일부 세부를 누락했고 TransientTransactionError 관련 문구를 임의로 언급하지 않았음(다행히 fabrication 없음)을 확인 후, curl 로 원본 HTML 을 직접 받아 self-grep 검증했다(WebFetch 결과는 참고용, 최종 인용은 curl 원문 기준) — 형제 raw 문서와 동일한 방법론.
- 추가로 봐야 할 동일 출처 페이지: `https://www.mongodb.com/docs/manual/reference/parameters/#mongodb-parameter-param.transactionLifetimeLimitSeconds` (정확한 기본 정수값), 드라이버별 transient transaction error 처리 가이드(예: `https://www.mongodb.com/docs/manual/core/transactions-in-applications/` 또는 언어별 driver 문서 — `TransientTransactionError`/`UnknownTransactionCommitResult` 라벨 확인용), `https://www.mongodb.com/docs/manual/core/transactions-production-consideration-sharded-clusters/` (sharded cluster 전용 추가 고려사항)
## 관련
- 같은 주제 다른 official-doc: [[raw/official-docs/transactions-mongodb-official]] (multi-document transaction 배포 요건·concern 기본값 — 이 문서의 gap `MONGO-TXN-C9`/`C10`을 이 자료가 부분적으로 닫음), [[raw/official-docs/write-concern-mongodb-official]], [[raw/official-docs/read-preference-mongodb-official]]
- 이 자료를 인용한 wiki 요약: `[[wiki/concepts/...]]` (생성 시)
@@ -0,0 +1,99 @@
---
title: "official-doc / MongoDB — Write Concern (w / j / wtimeout)"
source_type: official-doc
url: https://www.mongodb.com/docs/manual/reference/write-concern/
archive_url:
status: raw
confidence: high
tags: [official-doc, ca-skeleton, persistence, mongodb, write-concern]
related_projects: [ca-skeleton]
related_branches: [feature-mongo-runtime-baseline-contract]
created: 2026-07-28
last_reviewed: 2026-07-28
---
# official-doc / MongoDB — Write Concern (w / j / wtimeout)
> Layer: `raw/official-docs/` — MongoDB 공식 Manual reference (`write-concern`) 원문 발췌.
> `feature-mongo-runtime-baseline-contract` 의 "MongoDB write 는 write concern 을 명시하고 기본값에 의존하지 않는다" + "journaling(j) / wtimeout 을 명시한다" 결정 근거.
## Parent / 활용 branch (필수)
| Branch | 이 자료가 정당화하는 결정 |
|---|---|
| [[raw/branch-notes/feature-mongo-runtime-baseline-contract]] | MongoDB write 는 write concern 을 명시하고 기본값(`w: "majority"` 여부)에 의존하지 않는다 + journaling(`j`) / `wtimeout` 을 명시한다 — `w`/`j`/`wtimeout` 옵션 의미, 서버 implicit default, `wtimeout` 초과 시 write 가 rollback 되지 않는다는 실패 의미의 verbatim 근거 |
## 컨텍스트
ca-skeleton 은 MongoDB 를 optional adapter(문서 저장소 축)로 채택할 수 있다. 본 branch 는 write concern 을 애플리케이션/드라이버 레벨에서 **명시**할지, 서버 implicit default 에 의존할지를 결정해야 한다. 이 판단에는 (1) `w` 값의 의미(숫자 vs `"majority"`), (2) `j` 가 무엇을 보장하는지, (3) `wtimeout` 초과 시 실제로 무슨 일이 일어나는지(=write 취소 여부), (4) 서버가 실제로 어떤 값을 implicit default 로 쓰는지에 대한 공식 정의가 필요하다.
## 출처 / Source
- 원본 URL: https://www.mongodb.com/docs/manual/reference/write-concern/
- 아카이브 URL: (미수집)
- 저자 / 조직: MongoDB, Inc.
- 발행일: MongoDB Manual (현재 버전 — 본문에 "Starting in MongoDB 8.0" 문구 포함되어 있어 8.0 이후 반영분 포함된 최신 manual 로 판단)
- 마지막 확인일: 2026-07-28
## 핵심 인용 / Key quotes (verbatim)
> [§Write Concern Definition] "Write concern describes the level of acknowledgment requested from MongoDB for write operations"
> [§`w` Option (Acknowledgment Count)] "requests acknowledgment that the write operation has propagated to a specified number of `mongod` instances or to `mongod` instances with specified tags."
> [§`w` Values — `"majority"` row] "calculated majority of data-bearing voting members have durably written the change to their local oplog"
> [§`j` Option (Journal Durability)] "requests acknowledgment from MongoDB that the write operation has been written to the on-disk journal."
> [§`wtimeout` Option (Time Limit)] "`wtimeout` specifies a time limit, in milliseconds, for a write operation to propagate to enough members to achieve the write concern after the operation succeeds on the primary."
> [§`wtimeout` Option — Key behaviors] "MongoDB returns a write concern error after the specified limit, even if the required write concern will eventually succeed"
> [§`wtimeout` Option — Key behaviors] "When these write operations return, MongoDB does not undo successful data modifications performed before the write concern exceeded the `wtimeout` time limit"
> [§Implicit Default Write Concern] "The implicit default write concern is `{ w: "majority" }`"
## Claims Extracted / 추출된 주장
| Claim ID | Claim (이 자료가 직접 말하는 것) | Evidence quote | Strength | Applies to | Does not prove |
|---|---|---|---|---|---|
| MONGO-WRITECONCERN-C1 | Write concern 은 MongoDB 가 write 작업에 대해 요청하는 acknowledgment(확인 응답) 수준을 기술하는 개념이다 | [§Write Concern Definition] "Write concern describes the level of acknowledgment requested from MongoDB for write operations" | `official-vendor-doc` | standalone `mongod` / replica set / sharded cluster 전반의 write 요청 | 특정 드라이버(Java/Node 등)가 write concern 을 설정하는 구체 API 형태 — 이 인용은 서버 개념 정의만 다룸 |
| MONGO-WRITECONCERN-C2 | `w` 옵션은 write 가 지정된 개수의 `mongod` 인스턴스(또는 지정 태그를 만족하는 인스턴스)로 전파되었음을 확인해달라는 요청이다 | [§`w` Option] "requests acknowledgment that the write operation has propagated to a specified number of `mongod` instances or to `mongod` instances with specified tags." | `official-vendor-doc` | `w: <number>` 형태 값의 일반 의미 | 각 멤버가 durable write 로 카운트되는 순서·우선순위(예: arbiter 제외 여부)의 세부 규칙 — 본 인용 범위 밖 |
| MONGO-WRITECONCERN-C3 | `w: "majority"` 는 데이터를 보유한 투표권 멤버들의 계산된 과반수가 자신의 local oplog 에 변경사항을 durable 하게 기록했음을 확인하는 것을 요청한다 | [§`w` Values] "calculated majority of data-bearing voting members have durably written the change to their local oplog" | `official-vendor-doc` | `w: "majority"` write concern 을 사용하는 모든 replica set 배포 | journal(`j`) 까지 포함해서 확인하는지는 이 문장 자체엔 없음 — `writeConcernMajorityJournalDefault` 설정에 따라 별도 결정됨 (C8 참고) |
| MONGO-WRITECONCERN-C4 | `j` 옵션은 write 작업이 on-disk journal 에 기록되었음을 MongoDB 로부터 확인받는 것을 요청한다 | [§`j` Option] "requests acknowledgment from MongoDB that the write operation has been written to the on-disk journal." | `official-vendor-doc` | `j: true` 를 명시한 write 요청 전반 | journal 없이 write 가 유실될 수 있는 구체적 장애 시나리오(OS crash 등) 자체는 이 문장이 다루지 않음 |
| MONGO-WRITECONCERN-C5 | `wtimeout` 은 write 작업이 primary 에서 성공한 뒤 write concern 을 달성할 만큼 충분한 멤버로 전파되는 데 허용되는 시간 제한(밀리초)을 지정한다 | [§`wtimeout` Option] "`wtimeout` specifies a time limit, in milliseconds, for a write operation to propagate to enough members to achieve the write concern after the operation succeeds on the primary." | `official-vendor-doc` | `wtimeout` 이 명시된 모든 write 요청 | `w<=1` 일 때는 `wtimeout` 이 적용되지 않는다는 것 — 본 인용은 그 예외 조건 자체를 포함하지 않음(별도 bullet) |
| MONGO-WRITECONCERN-C6 | `wtimeout` 시간 제한 초과 시, 요청한 write concern 이 결국 충족될 것이었다 해도 MongoDB 는 write concern error 를 반환한다 | [§`wtimeout` Option — Key behaviors] "MongoDB returns a write concern error after the specified limit, even if the required write concern will eventually succeed" | `official-vendor-doc` | `wtimeout` 을 명시한 write 요청의 클라이언트 응답 성격 | 이 에러를 받은 애플리케이션이 재시도해야 하는지, 재시도 시 idempotent 하게 처리되는지는 이 문장이 규정하지 않음 — 애플리케이션 레벨 결정 |
| MONGO-WRITECONCERN-C7 | `wtimeout` 이 초과되어 write 작업이 반환되더라도, MongoDB 는 write concern 이 초과되기 전에 이미 적용된 성공적인 data modification 을 되돌리지(undo) 않는다 | [§`wtimeout` Option — Key behaviors] "When these write operations return, MongoDB does not undo successful data modifications performed before the write concern exceeded the `wtimeout` time limit" | `official-vendor-doc` | `wtimeout` 을 명시한 모든 write 작업의 실패 모드 — **`wtimeout` 은 write 자체를 취소하는 옵션이 아니라, 확인 응답 대기의 시간 제한일 뿐임을 증명** | 이 write 가 다른 replica 로 반드시 복제된다는 durability 보장 자체까지는 증명하지 않음 — primary 에 적용된 변경을 rollback 하지 않는다는 것만 증명 |
| MONGO-WRITECONCERN-C8 | 대부분의 MongoDB 배포에서 implicit(암묵적) default write concern 은 `{ w: "majority" }` 이다 | [§Implicit Default Write Concern] "The implicit default write concern is `{ w: "majority" }`" | `official-vendor-doc` | arbiter 가 없거나, arbiter 가 있어도 non-arbiter 수가 투표 노드 과반수를 초과하는 replica set 배포 | arbiter 를 포함한 특정 topology(예: non-arbiter ≤ majority(voting nodes))에서는 default 가 `{ w: 1 }` 로 낮아지는 예외 공식이 별도로 존재함 — 이 문장 자체는 그 예외 공식을 포함하지 않음(별도 코드 블록, 이번 raw 에 verbatim self-grep 미시도) |
## Usage Boundaries / 적용 경계
- **이 자료가 직접 증명하는 것**:
- `MONGO-WRITECONCERN-C1`: write concern 의 일반 정의
- `MONGO-WRITECONCERN-C2`~`C3`: `w` 옵션 값의 의미(숫자 propagation count / `"majority"` = 과반수 durable oplog write)
- `MONGO-WRITECONCERN-C4`: `j` 옵션 = on-disk journal 기록 확인
- `MONGO-WRITECONCERN-C5`: `wtimeout` = 시간 제한(ms) 정의
- `MONGO-WRITECONCERN-C6`~`C7`: `wtimeout` 초과 시 write concern error 를 반환하지만, 이미 적용된 write 를 rollback/undo 하지 않는다는 실패 모드
- `MONGO-WRITECONCERN-C8`: 대부분 배포의 implicit default 는 `{ w: "majority" }`
- **이 자료가 증명하지 않는 것**:
- arbiter 가 있는 replica set 에서 default 가 `{ w: 1 }` 로 낮아지는 정확한 공식 조건(코드 블록 형태 — 이번 raw 에서 verbatim 인용/self-grep 미포함, 별도 확인 필요)
- MongoDB Atlas 등 managed 서비스가 self-managed 배포와 동일한 implicit default 를 쓰는지 여부
- 특정 드라이버(Java Reactive Streams / Spring Data MongoDB 등)가 write concern 을 어떤 설정 키·API 로 노출하는지 — 이 자료는 서버 개념만 정의, 드라이버 바인딩은 별도 자료 필요
- 트랜잭션 레벨 write concern 설정 방식(문서 본문에 "멀티 도큐먼트 트랜잭션은 개별 operation 이 아니라 트랜잭션 레벨에서 write concern 을 설정해야 한다"는 별도 주의사항이 있으나, 이번 raw 에는 별도 claim 으로 등록하지 않음 — 아래 메모 참고)
- **ca-skeleton 프로젝트에 적용하려면 추가 확인이 필요한 것**:
- ca-skeleton 이 실제로 채택할 replica set 구성(arbiter 유무)에 따라 서버 implicit default 가 `{ w: "majority" }` 인지 `{ w: 1 }` 인지 확인 필요 — `C8` 의 예외 공식 verbatim 별도 수집
- Spring Data MongoDB(또는 채택할 드라이버)의 `WriteConcern` 설정 API/property 이름 — 별도 official-doc 필요
- `j`/`wtimeout` 을 애플리케이션에서 명시할 구체 값(예: `wtimeout` 몇 ms) 은 이 자료가 아니라 branch 의 §구현 가이드에서 결정 — 본 자료는 "명시하지 않으면 무엇에 의존하게 되는지"와 "초과 시 무슨 일이 일어나는지"까지만 증명
## 메모 / Notes
- 이번 WebFetch 결과는 소형 모델이 원문을 markdown(볼드 `**`) 으로 재구성한 형태였다. Self-Grep 시 `**` 강조 마커가 인용 중간에 끼어 있는 경우 grep -F 매칭이 깨져(`w` 옵션 정의 인용 1차 시도 실패), 볼드 구간 경계를 벗어나지 않는 부분 문자열로 재선정해 재검증 통과시켰다. 원문 페이지 자체의 실제 HTML 은 이번에 별도 curl 로 대조하지 않음 — 다음 재검증 시 curl 직접 fetch 로 교차 확인 권장.
- 미수집: 멀티 도큐먼트 트랜잭션의 write concern 설정 위치(transaction-level vs operation-level) 관련 문장. `wtimeout: 0` 이 "wtimeout 옵션 없는 것과 동일"이라는 문장도 이번엔 별도 claim 화하지 않음(2차 우선순위) — 필요 시 재수집.
- 다음 fetch 후보: MongoDB 공식 `journaling` 페이지(journal 미기록 시 실제 유실 시나리오), `replica-set-election` 페이지(arbiter 예외 공식의 정확한 조건).
## Related / 관련
- 같은 주제 다른 official-doc: 없음 (vault 내 최초 MongoDB 공식 문서)
- 인용하는 branch: [[raw/branch-notes/feature-mongo-runtime-baseline-contract]]
- 인용하는 project: [[raw/project-notes/ca-skeleton-operational-contract]]
- 인용한 wiki 요약: (미작성)
@@ -429,8 +429,8 @@ Out of scope:
| `FE-D024` | dependency lock, secret scan, vulnerability scan, license inventory, dependency review를 merge/release gate로 분리 (lock 은 `FE-GATE-001`, 나머지 4개는 `FE-GATE-013`) | `accepted-documented-only` | `feature-frontend-build-bundle-supply-chain-contract` | `FE-OC-018`, `FE-OC-019`, `FE-OC-020` | supply-chain scope 최소값 | organization security policy가 더 강한 gate 지정 | — |
| `FE-D025` | sample slice는 제거 가능한 contract fixture이며 product import를 금지 | `accepted-documented-only` | `feature-sample-feature-slice-contract-fixture` | `FE-OC-024` | backend skeleton의 sample-fixture 운영 원칙을 frontend에 적용 | fixture 없이 동일 gate coverage를 증명 | — |
| `FE-D026` | 신규 runtime capability 6종은 `FE-REG-CAPABILITY` flag로 default OFF이며 활성화는 owner·gate·runbook을 동반한다 | `conditional-default` | `feature-frontend-env-runtime-config-contract` | `FE-OC-004`, `FE-OC-022`, `FE-OC-027`, `FE-OC-028`, `FE-OC-029`, `FE-OC-030`, `FE-OC-031`, `FE-OC-032` | project-local default, 외부 source claim 아님 | 특정 capability가 제품 필수가 되어 상시 활성이 요구됨 | — |
| `FE-D027` | 로컬 바이너리 backend는 IndexedDB를 default로 하고 OPFS는 대용량 순차 write에 opt-in, Cache Storage는 service worker 호스팅 response cache 전용다 | `conditional-default` | `feature-frontend-binary-file-io-store-contract` | `FE-OC-013`, `FE-OC-027` | project-local default, 외부 source claim 아님 | OPFS 브라우저 지원 또는 quota 정책이 바뀜 | — |
| `FE-D028` | 탭 간 무효화는 BroadcastChannel 우선에 `storage` event fallback을 쓰고 leader election 없이 무효화 key만 전파한다 | `conditional-default` | `feature-frontend-cache-tier-cross-tab-invalidation-contract` | `FE-OC-012`, `FE-OC-028` | project-local default, 외부 source claim 아님; 값 전파는 PII·stale 표면을 넓힘 | 다중 탭 실시간 협업이 제품 요구가 됨 | — |
| `FE-D027` | 로컬 바이너리 backend는 IndexedDB를 default로 하고 OPFS는 대용량 순차 write에 opt-in, Cache Storage는 정책상 service worker 호스팅 response cache 전용으로 제한한다 | `conditional-default` | `feature-frontend-binary-file-io-store-contract` | `FE-OC-013`, `FE-OC-027` | backend 선택은 project-local default. Cache Storage 제한은 플랫폼 제약이 아니라 release 단위 파티션 일관성을 위한 정책이다 — [[raw/official-docs/mdn-object-url-cache-storage]] `#C3` | OPFS 브라우저 지원 또는 quota 정책이 바뀜 | — |
| `FE-D028` | 탭 간 무효화는 BroadcastChannel만 쓰고 별도 fallback 경로를 두지 않으며 leader election 없이 무효화 key만 전파한다. 채널을 쓸 수 없으면 탭 내 무효화만 수행한다 | `conditional-default` | `feature-frontend-cache-tier-cross-tab-invalidation-contract` | `FE-OC-012`, `FE-OC-028` | BroadcastChannel은 2022년 3월부터 모든 주요 브라우저에서 동작한다 — [[raw/official-docs/mdn-broadcastchannel-storage-event]] `#C4`. 값 전파 금지는 PII·stale 표면을 넓히지 않기 위한 project decision | 지원 대상에 BroadcastChannel 미동작 브라우저가 실제로 포함되거나, 다중 탭 실시간 협업이 제품 요구가 됨 | — |
| `FE-D029` | presigned URL 획득은 shared client를 경유하고 실제 byte 전송은 session credential을 첨부하지 않는 transfer adapter가 수행한다 | `accepted-documented-only` | `feature-frontend-large-object-transfer-contract` | `FE-OC-006`, `FE-OC-019`, `FE-OC-029` | credential 유출 방지 invariant, project decision | 스토리지가 same-origin proxy만 제공 | — |
| `FE-D030` | 재개 가능 전송은 part size·병렬도·part 재시도 상한을 registry로 고정하고 part 상태를 `BlobStorePort`에 보존한다 | `conditional-default` | `feature-frontend-large-object-transfer-contract` | `FE-OC-029` | project-local default, 외부 source claim 아님 | 스토리지 제공자가 다른 multipart 제약을 요구 | — |
| `FE-D031` | transport default는 REST이고 GraphQL·gRPC-Web·Connect-Web은 `FE-REG-API`의 protocol 필드로 opt-in하며 미지원 환경은 REST gateway로 fallback한다 | `conditional-default` | `feature-frontend-multi-protocol-api-transport-contract` | `FE-OC-006`, `FE-OC-007`, `FE-OC-030` | project-local default, 외부 source claim 아님 | backend가 단일 비-REST protocol만 제공 | — |
@@ -440,7 +440,7 @@ Out of scope:
| `FE-D035` | background sync 재생은 `idempotency: keyed` operation만 허용한다 (`FE-D016` 상속) | `accepted-documented-only` | `feature-frontend-background-execution-worker-contract` | `FE-OC-009`, `FE-OC-032` | mutation 자동 retry 결정의 중복 write 방지 invariant를 지연 재생에 확장 | mutation이 naturally idempotent임이 schema로 증명됨 | — |
| `FE-D036` | Web Worker 작업은 structured-clone 또는 Transferable로만 통신하고 timeout과 terminate를 계약하며 worker 안에서 application port를 재구현하지 않는다 | `accepted-documented-only` | `feature-frontend-background-execution-worker-contract` | `FE-OC-002`, `FE-OC-032` | worker 안 로직 중복이 layer 경계를 우회하는 것을 차단, project decision | SharedArrayBuffer 기반 병렬 처리가 요구됨 | — |
`FE-D026`~`FE-D036``Evidence / rationale`에 적힌 `project-local default, 외부 source claim 아님``FE-D001`이 쓰는 표기와 같다. 대응 raw 근거 문서가 아직 없다는 사실을 감추지 않고 명시하는 장치이며, 수집 계획은 `FE-Q-011`이 소유한다.
`FE-D026`~`FE-D036` 중 아직 raw 근거가 없는 행`Evidence / rationale`에 적힌 `project-local default, 외부 source claim 아님``FE-D001`이 쓰는 표기와 같다. 대응 raw 근거 문서가 아직 없다는 사실을 감추지 않고 명시하는 장치이며, 수집 계획은 `FE-Q-011`이 소유한다. `FE-D027`은 2026-07-28 조사로 근거 일부를 확보해 이 표기에서 빠졌다 — 경위는 §6.1 개정 기록.
### 3.3 Decision change protocol
@@ -486,8 +486,8 @@ Out of scope:
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-SUPPLY-CHAIN-001` | 1 | `supply-chain` | dependency lock, secret scan, vulnerability scan, license inventory, dependency review를 merge/release gate로 분리한다 | `accepted-documented-only` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | §3.2 `FE-D024` |
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-SAMPLE-FIXTURE-001` | 1 | `sample-fixture` | sample slice는 제거 가능한 contract fixture이며 product import를 금지한다 | `accepted-documented-only` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | §3.2 `FE-D025` |
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CAPABILITY-001` | 1 | `capability` | 신규 runtime capability 6종은 FE-REG-CAPABILITY flag로 default OFF이며 활성화는 owner·gate·runbook을 동반한다 | `conditional-default` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | §3.2 `FE-D026` |
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-BINARY-STORE-001` | 1 | `binary-store` | 로컬 바이너리 backend는 IndexedDB를 default로 하고 OPFS는 대용량 순차 write에 opt-in, Cache Storage는 service worker 호스팅 response cache 전용다 | `conditional-default` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | §3.2 `FE-D027` |
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CROSS-TAB-001` | 1 | `cross-tab` | 탭 간 무효화는 BroadcastChannel 우선에 storage event fallback을 쓰고 leader election 없이 무효화 key만 전파한다 | `conditional-default` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | §3.2 `FE-D028` |
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-BINARY-STORE-001` | 1 | `binary-store` | 로컬 바이너리 backend는 IndexedDB를 default로 하고 OPFS는 대용량 순차 write에 opt-in, Cache Storage는 정책상 service worker 호스팅 response cache 전용으로 제한한다 | `conditional-default` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | §3.2 `FE-D027`; [[raw/official-docs/mdn-object-url-cache-storage]] `#C3` |
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CROSS-TAB-001` | 2 | `cross-tab` | 탭 간 무효화는 BroadcastChannel만 쓰고 별도 fallback 경로를 두지 않으며 leader election 없이 무효화 key만 전파한다 | `conditional-default` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | §3.2 `FE-D028`; [[raw/official-docs/mdn-broadcastchannel-storage-event]] `#C4` |
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-TRANSFER-CREDENTIAL-001` | 1 | `transfer-credential` | presigned URL 획득은 shared client를 경유하고 실제 byte 전송은 session credential을 첨부하지 않는 transfer adapter가 수행한다 | `accepted-documented-only` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | §3.2 `FE-D029` |
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-RESUMABLE-TRANSFER-001` | 1 | `resumable-transfer` | 재개 가능 전송은 part size·병렬도·part 재시도 상한을 registry로 고정하고 part 상태를 BlobStorePort에 보존한다 | `conditional-default` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | §3.2 `FE-D030` |
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-PROTOCOL-001` | 1 | `protocol` | transport default는 REST이고 GraphQL·gRPC-Web·Connect-Web은 FE-REG-API의 protocol 필드로 opt-in하며 미지원 환경은 REST gateway로 fallback한다 | `conditional-default` | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] | §3.2 `FE-D031` |
@@ -502,6 +502,9 @@ Out of scope:
> - 2026-07-21 · `DEC-...-SUPPLY-CHAIN-001` · `compatibility_impact: additive` · revision 유지(1). Decision Summary 에 `dependency review` 를 추가했다. 이는 새 결정이 아니라 **불완전한 요약의 정정**이다 — `FE-OC-018` 과 §13.1 이 처음부터 dependency review 를 요구했고 §3.2 `FE-D024` 도 이를 포함하는데 이 registry 행만 4개 control 로 적혀 있었다. 기존 4개 control 의 동작은 바뀌지 않고, gate 정의(§15.1 `FE-GATE-013`)도 이미 dependency-review fixture 를 포함한 채 revision 1 이므로 같은 판정을 적용한다.
> - 2026-07-28 · `DEC-...-OFFLINE-CACHE-001` · `compatibility_impact: behavior-change` · revision 1→2. `FE-D034``FE-D019` 를 supersede 하면서 service worker 를 "전면 off" 에서 "precaching off + 역할별 capability opt-in" 으로 바꿨다. WebPush·Background Sync·Cache Storage 가 service worker 없이는 동작하지 않기 때문이다. behavior-change 이므로 §3.3 4단계에 따라 `FE-GATE-032`(SW update UX + rollback 시 SW 되돌림)와 `FE-RB-007` 이 migration·rollback evidence 를 담당한다. 이 결정을 pin 한 [[raw/branch-notes/feature-frontend-release-cache-rollback-contract]] 는 `@2` 로 갱신했다.
> - 2026-07-28 · `DEC-...-REGISTRY-001` · `compatibility_impact: additive` · revision 유지(1). 9번째 registry `FE-REG-CAPABILITY` 를 추가했다. 기존 8개 registry 의 owner·schema·동작은 바뀌지 않으므로 `DEC-...-SUPPLY-CHAIN-001` 선례와 같은 판정을 적용한다. 다만 Summary 문자열이 바뀌므로 이를 복사해 둔 소비 branch 4곳([[raw/branch-notes/feature-frontend-storage-registry-contract]]·[[raw/branch-notes/feature-frontend-observability-logging-trace-contract]]·[[raw/branch-notes/feature-frontend-contract-registry-governance]]·[[raw/branch-notes/feature-frontend-contract-compatibility-governance]])의 상속 표를 함께 갱신했다.
> - 2026-07-28 · `DEC-...-CROSS-TAB-001` · `compatibility_impact: behavior-change` · revision 1→2. `storage` event fallback 경로를 **삭제**했다. 삭제 근거는 두 가지다. (1) [[raw/official-docs/mdn-broadcastchannel-storage-event]] `#C4` 가 BroadcastChannel 을 2022년 3월부터 Baseline Widely available 로 기록하는데, fallback 이 필요한 브라우저가 지원 대상에 실제로 있다는 근거는 조사에서 나오지 않았다. (2) `#C10` 대로 `sessionStorage``storage` event 는 다른 탭에 도달하지 않아 원래 서술("`storage` event fallback")이 동작하지 않는 구현을 허용하고 있었다. 쓰이지 않는 경로를 남기면 저장 공간 예산·자기 수신 제외·연속 동일값 문제를 위한 장치를 계속 유지해야 하고, 평소 실행되지 않아 테스트로도 검증되지 않는다. 필요해지는 시점(지원 대상에 미동작 브라우저가 들어올 때)에 다시 넣는다. behavior-change 이므로 `FE-GATE-028` 의 fixture 목록에서 fallback 항목을 제거하고 `CROSS_TAB_CHANNEL_UNAVAILABLE` 의 진입 조건을 좁혔다. 이 결정을 pin 한 [[raw/branch-notes/feature-frontend-cache-tier-cross-tab-invalidation-contract]] 는 `@2` 로 갱신했다.
> - 2026-07-28 · `DEC-...-BINARY-STORE-001` · `compatibility_impact: none` · revision 유지(1). Summary 의 "Cache Storage는 service worker 호스팅 response cache 전용이다" 를 "정책상 ... 전용으로 제한한다" 로 바꿨다. 이는 새 결정도 완화도 아니라 **틀린 근거의 정정**이다 — 원문은 플랫폼 제약을 서술하는 것처럼 읽혔으나 [[raw/official-docs/mdn-object-url-cache-storage]] `#C3` 이 "you're not limited to only using it with service workers" 이고 `Window.caches` 로 window scope 에서도 접근 가능하다고 적는다. 제한 자체는 유지되므로 어떤 adapter 의 허용 동작도 바뀌지 않고, 바뀐 것은 그 제한이 **우리가 고른 것**임을 드러낸 점이다. Summary 문자열이 바뀌므로 이를 pin 한 [[raw/branch-notes/feature-frontend-binary-file-io-store-contract]] 의 상속 표를 함께 갱신했다. 발견 경위는 같은 노트 §Audit & Findings `CACHE_STORAGE_CONSTRAINT_DRIFT`.
> - 2026-07-28 · §5.5 `FE-REG-STORAGE.evictionOrder` 필드 Rule · `compatibility_impact: none` · 대응 registry 결정 `DEC-...-REGISTRY-001` revision 유지(1). 필드 설명에 **주체**(애플리케이션 주도)와 **비적용 범위**(브라우저 eviction)를 명시했다. 어떤 행의 `evictionOrder` 값도 바뀌지 않았고 §8.2 `BLOB_STORE_QUOTA_EXCEEDED` 복구 경로도 그대로다. 정정 근거는 [[raw/official-docs/mdn-storage-quotas-eviction-persistence]] `#C1`(origin 전량 삭제)·`#C3`(LRU 는 origin 단위)이며, 원문은 브라우저가 이 순서를 존중한다는 오독을 허용했다. Decision Summary 를 건드리지 않으므로 소비 branch 상속 표 전파는 불필요하다. 발견 경위는 [[raw/branch-notes/feature-frontend-binary-file-io-store-contract]] §Audit & Findings `EVICTION_SCOPE_DRIFT`.
> - 2026-07-28 · `DEC-...-CAPABILITY-001` 외 10건 신규 등록. 근거 raw 문서가 아직 없으므로 §3.2 대응 행의 `Evidence / rationale``project-local default, 외부 source claim 아님` 을 명시했고, 수집 계획은 `FE-Q-011` 이 소유한다.
> - Summary 셀은 소비 branch 의 상속 표와 **문자열이 정확히 일치해야 한다**(`wiki_consistency_check.py``CONFLICTS_WITH_PROJECT_DECISION`). 분류·근거 같은 메타는 이 기록에 적고 Summary 에 섞지 않는다. 조사 앞에 공백을 넣지 않는다 — `…release token을 8개 registry로` 형태가 기준이다.
@@ -533,7 +536,7 @@ Out of scope:
| `adapters/file` | 파일 선택·저장 dialog, object URL 생성/해제 | application port, File System Access / input element | 파일 내용 해석, 도메인 검증 | `planned` |
| `adapters/blob-store` | IndexedDB·OPFS·Cache Storage 백엔드, quota 매핑, eviction | application port, browser storage API | 저장 대상의 의미, 도메인 정책 | `planned` |
| `adapters/cache-persistence` | 캐시 직렬화, version partition, 복원 거부 | application port, 자체 영속 백엔드 | 캐시 정책 결정(`QueryCachePort` 소유) | `planned` |
| `adapters/cross-tab` | BroadcastChannel·`storage` event 전송, 메시지 봉투 | application port, browser globals | 무효화 대상 결정 | `planned` |
| `adapters/cross-tab` | BroadcastChannel 전송, 메시지 봉투 | application port, browser globals | 무효화 대상 결정 | `planned` |
| `adapters/transfer` | part 분할·병렬·재시도·무결성·진행 보고, credential-less 전송 | application port, fetch, Streams | presign 발급, 업로드 대상 도메인 규칙 | `planned` |
| `adapters/protocol` | GraphQL·gRPC-Web·Connect-Web codec 과 status 정규화 | application output port, fetch | operation 정의, use-case policy | `planned` |
| `adapters/realtime` | 연결 수명주기, 재연결 backoff, resume cursor, 프레임 디코드, 구독 해제 | application port, EventSource·WebSocket·fetch | 이벤트의 도메인 의미, 상태 병합 정책 | `planned` |
@@ -842,7 +845,7 @@ capability flag 6개의 기본값이 전부 `false`인 것은 `FE-D026`의 defau
| `migration` | conditional | previous version을 읽으면 migration 또는 discard |
| `quotaFallback` | yes | memory/no-persist/feature-disable 중 하나. correctness에 영향을 주는 값은 `없음`으로 선언 |
| `payloadClass` | yes | `structured`(기본) 또는 `binary`. `binary``sensitive-forbidden` classification과 조합할 수 없음 |
| `evictionOrder` | yes | quota 압박 시 제거 순서(정수, 낮을수록 먼저). 제거 불가 값은 `null` |
| `evictionOrder` | yes | **애플리케이션 주도** 정리 순서(정수, 낮을수록 먼저). 제거 불가 값은 `null`. 브라우저 eviction 에는 적용되지 않는다 |
Initial planned rows:
@@ -859,6 +862,8 @@ Initial planned rows:
`UPLOAD_PART_STATE``quotaFallback``없음`이고 `evictionOrder``null`인 것은 §9.4의 "correctness에 영향을 주는 값은 storage fallback을 임의 적용하지 않는다"를 이 행에 적용한 것이다. part 상태가 조용히 memory로 넘어가면 새로고침 한 번에 이어받기가 불가능해지고, 사용자는 그 사실을 전송이 실패한 뒤에야 알게 된다.
`evictionOrder`가 지배하는 범위는 **애플리케이션이 quota 압박을 감지하고 스스로 정리할 때의 순서**뿐이다. 브라우저가 주도하는 eviction은 이 순서를 보지 않는다 — [[raw/official-docs/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 단위로 동작한다고 적는다. 즉 이 필드는 §8.2의 `BLOB_STORE_QUOTA_EXCEEDED` 복구 경로에 주는 입력일 뿐이고, `evictionOrder`가 높다고 해서 어떤 행도 브라우저 eviction 에서 보호받지 못한다. 브라우저 eviction 에 대한 유일한 방어는 `navigator.storage.persist()` 허가이며(`#C4`), 그 허가는 브라우저 재량이라 요청이 성공한다고 가정할 수 없다(`#C5`). Safari는 cross-site tracking prevention이 켜져 있으면 7일간 사용자 상호작용이 없는 origin의 script 생성 데이터를 삭제한다(`#C7`).
### 5.6 Error registry minimum schema
| Field | Required | Rule |
@@ -1394,7 +1399,7 @@ Raw response body, token, authorization header, full URL/query, stack, storage v
| IndexedDB/OPFS/Cache Storage 접근 불가 또는 security error | `BLOB_STORE_UNAVAILABLE` | no | registry `quotaFallback`; `없음`이면 terminal | 기능 저하 고지, 무음 처리 금지 | backend type + reason enum |
| 로컬 바이너리 quota 초과 | `BLOB_STORE_QUOTA_EXCEEDED` | no | `evictionOrder` 순 제거 후 재시도, `null` 행은 제거 금지 | 저장 실패 고지 + 정리 action | quota bucket, 값 금지 |
| 캐시 직렬화·영속·복원 실패 | `CACHE_PERSISTENCE_FAILURE` | no | 메모리 캐시만 사용 | 무음, 필요 시 stale 표시 | phase + tier only |
| BroadcastChannel`storage` event가 모두 불가 | `CROSS_TAB_CHANNEL_UNAVAILABLE` | no | 탭 내 무효화만 수행 | 무음, 다중 탭 stale 가능성 고지 가능 | reason enum only |
| BroadcastChannel 사용 불가 | `CROSS_TAB_CHANNEL_UNAVAILABLE` | no | 탭 내 무효화만 수행 | 무음, 다중 탭 stale 가능성 고지 가능 | reason enum only |
| presigned URL 만료/거부 | `PRESIGN_EXPIRED` | presign 재획득 후 1회 | 재획득 성공 시 같은 위치에서 재개 | 자동 재개, 재획득도 실패하면 retry action | operation ID only, URL 금지 |
| upload part 재시도 상한 소진 | `UPLOAD_PART_FAILED` | no (part 내부 재시도는 최대 2회) | part 상태 보존 후 일시정지 | 재개 action, 진행률 유지 | part index bucket + attempts |
| 체크섬 또는 크기 불일치 | `TRANSFER_INTEGRITY_MISMATCH` | no | 해당 part 폐기 후 재전송 1회, 재실패면 terminal | 무결성 실패 고지 + 처음부터 다시 action | size bucket only |
@@ -1897,7 +1902,7 @@ Script 이름을 바꾸는 것은 허용되지만 acceptance gate와 artifact ma
| `FE-GATE-025` | `FE-RB-005` drill | production promotion | `FE-OC-025` | — | blocking release defect | rollback decision, escalation, recovery assertions pass | `FE-RB-005` record | `FAIL_UNVERIFIED` |
| `FE-GATE-026` | lab performance | release | `FE-OC-021` | `FE-NFR-003`, `FE-NFR-004`, `FE-NFR-005` | recorded runner/throttling/cache context + named interactions | every lab threshold passes and report contains reproducibility metadata | lab performance report | `FAIL_UNVERIFIED` |
| `FE-GATE-027` | binary I-O & local store | merge | `FE-OC-013`, `FE-OC-027` | — | picker 취소·거부, quota 초과 fallback, OPFS 순차 write, Cache Storage 버전 파티션, object URL 해제 | 모든 fixture가 기대 kind로 처리되고 object URL 누수 0 | binary I-O report | `FAIL_UNVERIFIED` |
| `FE-GATE-028` | cache tier & cross-tab | merge | `FE-OC-012`, `FE-OC-028` | — | 영속 캐시 version 파티션, 탭 A mutation → 탭 B 무효화, BroadcastChannel 부재 fallback | 불일치 version 캐시는 복원되지 않고 탭 간 무효화가 도달 | cache tier report | `FAIL_UNVERIFIED` |
| `FE-GATE-028` | cache tier & cross-tab | merge | `FE-OC-012`, `FE-OC-028` | — | 영속 캐시 version 파티션, 탭 A mutation → 탭 B 무효화, BroadcastChannel 부재 시 탭 내 무효화만 | 불일치 version 캐시는 복원되지 않고 탭 간 무효화가 도달 | cache tier report | `FAIL_UNVERIFIED` |
| `FE-GATE-029` | large object transfer | merge + release | `FE-OC-019`, `FE-OC-029` | `FE-NFR-018` | presign 만료, part 재시도 상한, stream 중단 후 재개, 무결성 불일치, credential 첨부 negative | 모든 fixture 통과 + 전송 요청에 session credential 0건 | transfer report | `FAIL_UNVERIFIED` |
| `FE-GATE-030` | multi-protocol transport | merge | `FE-OC-006`, `FE-OC-007`, `FE-OC-030` | — | GraphQL `200 + errors[]`, gRPC status ↔ HTTP status, codec decode 실패, gateway fallback | 모든 protocol 실패가 기대 kind로 정규화 | protocol mapping report | `FAIL_UNVERIFIED` |
| `FE-GATE-031` | realtime lifecycle | merge + release | `FE-OC-011`, `FE-OC-031` | `FE-NFR-016`, `FE-NFR-017` | 결정론 fake clock backoff, resume gap 감지, unmount 후 열린 연결(`SUBSCRIPTION_LEAKED` fixture), 이벤트 스키마 거부, `FE-RB-006` drill | backoff가 cap을 넘지 않고 unmount 후 열린 연결 0, 미검증 이벤트 0건 도달 | realtime lifecycle report | `FAIL_UNVERIFIED` |
@@ -2511,7 +2516,7 @@ Repository identity와 implementation/test/CI/deploy evidence 또는 blocking ga
| `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-026` | `feature-frontend-operational-runbook-contract` | 5개 drill의 trigger·window·escalation·evidence assertion이 검증된다 | `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-DEPLOYMENT-001@1`, `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-TELEMETRY-001@1` | `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-024`, `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-005`, `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-012`, `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-004` | `planned` |
| `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-027` | `feature-frontend-ci-quality-gates-contract` | blocking gate가 분리되고 dependency graph와 artifact retention이 검증된다 | `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-TEST-STACK-001@1`, `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-SUPPLY-CHAIN-001@1`, `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-DEPLOYMENT-001@1` | `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-017`, `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-020`, `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-024`, `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-025`, `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-026` | `planned` |
| `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-028` | `feature-frontend-binary-file-io-store-contract` | picker·다운로드·object URL 해제·quota·OPFS·Cache Storage fixture가 통과하고 binary I-O report가 생성된다 | `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CAPABILITY-001@1`, `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-BINARY-STORE-001@1`, `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-REGISTRY-001@1` | `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-002`, `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-011` | `planned` |
| `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-029` | `feature-frontend-cache-tier-cross-tab-invalidation-contract` | version 파티션·탭 간 무효화·채널 부재 fallback fixture가 통과한다 | `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` | `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-010`, `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-011` | `planned` |
| `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-029` | `feature-frontend-cache-tier-cross-tab-invalidation-contract` | version 파티션·탭 간 무효화·채널 부재 시 탭 내 무효화 fixture가 통과한다 | `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` | `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-010`, `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-011` | `planned` |
| `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-030` | `feature-frontend-large-object-transfer-contract` | presign 만료·part 재시도·무결성·credential 경계 fixture가 통과한다 | `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CAPABILITY-001@1`, `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-TRANSFER-CREDENTIAL-001@1`, `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-RESUMABLE-TRANSFER-001@1` | `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-005`, `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-028` | `planned` |
| `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-031` | `feature-frontend-multi-protocol-api-transport-contract` | protocol별 성공/실패 정규화와 gateway fallback fixture가 통과한다 | `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CAPABILITY-001@1`, `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-PROTOCOL-001@1`, `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-VALIDATION-001@1` | `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-005`, `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-006`, `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-007` | `planned` |
| `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-032` | `feature-frontend-realtime-subscription-lifecycle-contract` | backoff·resume·구독 해제·이벤트 검증 fixture와 `FE-RB-006` drill이 통과한다 | `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-CAPABILITY-001@1`, `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-REALTIME-TRANSPORT-001@1`, `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-REALTIME-LIFECYCLE-001@1` | `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-006`, `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-007`, `WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-013` | `planned` |
@@ -2553,7 +2558,7 @@ Branch는 project-wide contract를 상세 implementation-ready spec으로 내린
| `feature-frontend-operational-runbook-contract` | `FE-OC-025` | `FE-OC-004`, `FE-OC-006`, `FE-OC-014`, `FE-OC-016`, `FE-OC-017` | five drills with trigger/window/escalation/evidence assertions | P3 | `feature-frontend-release-cache-rollback-contract`, `feature-api-client-response-envelope-contract`, `feature-frontend-observability-logging-trace-contract`, `feature-frontend-env-runtime-config-contract` |
| `feature-frontend-ci-quality-gates-contract` | — | `FE-OC-020`, `FE-OC-021`, `FE-OC-022`, `FE-OC-023`, `FE-OC-024`, `FE-OC-025` | separate blocking gates, dependency graph, artifact retention | P3 | `feature-frontend-test-taxonomy-contract`, `feature-frontend-build-bundle-supply-chain-contract`, `feature-frontend-release-cache-rollback-contract`, `feature-web-vitals-performance-budget-contract`, `feature-frontend-operational-runbook-contract` |
| `feature-frontend-binary-file-io-store-contract` | `FE-OC-027` | `FE-OC-013`, `FE-OC-022`, `FE-OC-029` | picker/다운로드/object URL 해제/quota/OPFS/Cache Storage fixtures + binary I-O report | P4 | `feature-frontend-storage-registry-contract`, `feature-frontend-clean-architecture-layering-contract` |
| `feature-frontend-cache-tier-cross-tab-invalidation-contract` | `FE-OC-028` | `FE-OC-012`, `FE-OC-013`, `FE-OC-023` | version 파티션 + 탭 간 무효화 + 채널 부재 fallback fixtures | P4 | `feature-server-state-caching-contract`, `feature-frontend-storage-registry-contract` |
| `feature-frontend-cache-tier-cross-tab-invalidation-contract` | `FE-OC-028` | `FE-OC-012`, `FE-OC-013`, `FE-OC-023` | version 파티션 + 탭 간 무효화 + 채널 부재 시 탭 내 무효화 fixtures | P4 | `feature-server-state-caching-contract`, `feature-frontend-storage-registry-contract` |
| `feature-frontend-large-object-transfer-contract` | `FE-OC-029` | `FE-OC-006`, `FE-OC-019`, `FE-OC-027` | presign 만료/part 재시도/무결성/credential 경계 fixtures | P4 | `feature-api-client-response-envelope-contract`, `feature-frontend-binary-file-io-store-contract` |
| `feature-frontend-multi-protocol-api-transport-contract` | `FE-OC-030` | `FE-OC-006`, `FE-OC-007`, `FE-OC-008` | protocol별 성공/실패 정규화 + gateway fallback fixtures | P4 | `feature-api-client-response-envelope-contract`, `feature-runtime-schema-validation-contract`, `feature-frontend-error-classification-boundary-contract` |
| `feature-frontend-realtime-subscription-lifecycle-contract` | `FE-OC-031` | `FE-OC-007`, `FE-OC-008`, `FE-OC-011`, `FE-OC-025` | backoff/resume/구독 해제/이벤트 검증 fixtures + `FE-RB-006` drill | P4 | `feature-runtime-schema-validation-contract`, `feature-frontend-error-classification-boundary-contract`, `feature-async-ui-state-contract` |
@@ -42,13 +42,35 @@ semantic_surface_exclusions:
도메인/비즈니스 로직은 제거합니다. 대신 운영 실패 분류, 경계 validation, mapper, structured response, structured logging, distributed tracing, env-driven configuration, repository access permission, adapter failure contract, API schema, transaction/concurrency, runtime lifecycle, sample domain fixture, domain onboarding, use case/port contract, domain modeling guardrails, business rule validation, domain event/outbox, metrics/alerting, secret/config source, management endpoint security, tenant policy, file/resource handling, cache consistency, background job/async, API compatibility, CI quality gate, build/release supply chain, container runtime, operational runbook, data retention/privacy, developer experience 기준을 기본 제공해야 합니다.
### 1.1 Production Capability Platform (2026-07-28 추가)
위 목표에 **운영 능력(capability)의 개방** 을 더합니다. 계약만 있고 seam 만 있는 adapter 는 실제 운영에서 쓸 수 없습니다. 그렇다고 모든 기술을 core 에 탑재하면 안 쓰는 프로젝트가 의존성·기동시간·보안 표면을 떠안습니다.
```text
available by default, inactive by default
```
- 개발자가 capability 와 provider 를 **typed 설정으로 선택**하고, composition root 가 선택된 토폴로지와 보증을 **startup 에 검증**한다.
- application 코드는 framework-free semantic port 에만 의존한다.
- 선택된 adapter 는 실제 client·안전한 기본값·health·metric·실패 의미·재사용 가능한 contract test 를 **함께** 제공한다.
- **선택되지 않은 capability 는 연결·백그라운드 워커·스키마·암묵적 런타임 동작을 만들지 않는다.**
- 하나의 전략이 모든 도메인에 맞다고 가정하지 않는다. rate limit 알고리즘, cache 전략, outbox dispatch 모드, lock 보증 등급은 **교체 가능한 축**으로 둔다.
- 자주 쓰는 연산은 기본 제공하되, **그 연산이 만드는 함정(비원자 check-then-act 등)을 문서와 test 가 함께 드러낸다.** 상세는 §36 capability card 의 "unsafe recipe ↔ 안전한 대체" 항목.
이 확장의 분해 설계(신규 branch 18개 · 주도권 이전 8건 · 기존 owner 개정 3건)는 `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` 가 SSOT 입니다.
<!-- section-id: implementation-boundaries -->
## 2. 하지 않는 것
- `ProblemDetail` 사용 안 함. 자체 structured envelope 응답을 사용.
- 특정 비즈니스 도메인 예외를 기본 제공하지 않음.
- 단, skeleton 계약 검증을 위한 sample domain fixture는 둠. 이 sample은 비즈니스 기능이 아니라 contract 검증 도구임.
- Kafka/Redis/Slack/Google Email을 기본 dependency로 무겁게 탑재하지 않음.
- Kafka/Redis/MongoDB/Slack/Google Email을 **core 기본 dependency로 탑재하지 않음**. optional adapter 모듈로 제공하고 기본 비활성(§1.1). **2026-07-28 개정** — 이전 표기 "무겁게 탑재하지 않음" 은 "구현하지 않는다" 로 읽혀 seam 수준 정체의 근거가 되었다. 모듈 내부는 운영 등급으로 완결하되 선택되지 않으면 런타임에 존재하지 않는다.
- **R0 seam 을 R2 provider 로 표기하지 않음.** 문서·startup 진단·capability card 모두에 적용(§36).
- capability 의 **use case·domain model·business rule 을 제공하지 않음.** port 와 adapter 계약까지만 정의하고 그 port 를 호출하는 use case 는 적용 프로젝트가 작성.
- capability 의 **성능 수치·개선 서사를 만들지 않음.** capacity 입력(키 크기·연산율·보존기간·재시도 증폭)만 계약.
- DB 와 broker 를 걸친 **exactly-once**, Redis lock 의 **strong correctness** 를 코드·문서 어디에도 주장하지 않음.
- 특정 vendor(관리형 Redis/Kafka/Mongo 제공자, CDN, push 서비스, KMS)를 선택하지 않음.
- raw exception, SQL, token, request/response body를 클라이언트 응답이나 기본 로그에 노출하지 않음.
- `wiki/interview/`, `wiki/portfolio/`, `wiki/blog/`로 직접 파생하지 않음. 먼저 `wiki/projects/` canonical 문서로 승급.
@@ -210,7 +232,7 @@ retryable 기본값:
- read timeout
- DNS temporary failure
- message publish temporary failure
- cache unavailable when degradation is allowed
- cache unavailable when degradation is allowed**단, 이 행은 §11 실패 정책 매트릭스가 세분화한다**: backend 실패만 retryable/degradable 이고 codec·프로그래밍 결함은 non-retryable(해당 연산 fail closed)
non-retryable 기본값:
@@ -372,11 +394,27 @@ use case capability:
- missing token, malformed token, expired token, invalid signature, issuer mismatch, audience mismatch, claim mapping failure 분리.
- token/PII 로그 금지.
### Optional Adapters
### Capability 별 실패 정책 매트릭스
- Kafka: publish/consume/deserialization/retry/DLQ/idempotency/correlationId 기준.
- Redis: cache miss는 장애 아님. unavailable은 degrade 가능 여부로 분류.
- Slack/Email: notification failure가 core use case를 막을지 명시.
> **2026-07-28 개정** — 이전 `### Optional Adapters` 3줄(Kafka / Redis / Slack·Email)을 대체한다. 전역 fail-open 도 전역 fail-closed 도 없다. 실패 정책은 **capability 단위**이며, 같은 client 를 공유해도 정책은 분리된다(예: cache Redis 와 coordination Redis).
> owner: [[raw/branch-notes/feature-capability-provider-selection-contract]] · 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §9
| Capability | 기본 실패 정책 | 근거 |
|---|---|---|
| optional cache (backend 실패) | fail open — source 로 fallback, degraded 결과와 metric 노출 | 캐시 부재는 정확성 손실이 아니다 |
| cache codec·프로그래밍 결함 | fail closed (해당 연산) — 손상 항목 축출/격리, miss 로 위장 금지 | 결함을 miss 로 숨기면 영구 오염 |
| security session store | fail closed — 인증 상태를 지어내지 않는다 | 인증은 추측 대상이 아니다 |
| keyed mutation idempotency | fail closed | 중복 write 방지가 목적 |
| strict rate limit (남용·비용 경계) | fail closed 또는 의도적으로 제한된 local emergency limiter | 무제한 통과는 경계의 부재 |
| availability 지향 rate limit | 명시적 local emergency fallback, 조용한 무제한 통과 금지 | 저하는 허용, 은폐는 불가 |
| efficiency lock | 선언된 use case 정책에 따라 실패 또는 계속 | 중복 *작업* 방지가 목적 |
| fenced correctness lock | 획득·lease 상실 시 보호 작업 중단 | 중복 *결과* 차단이 목적 |
| outbox append | 업무 트랜잭션 롤백 | append 는 업무 write 와 원자 |
| outbox dispatcher 장애 | write 는 계속 축적, lag·backlog 알림 | 전달 지연은 업무 차단 사유가 아니다 |
| best-effort notification·message | 명시적 fail open | 이름과 계약에 best-effort 명시 |
| durable notification·message | outbox/inbox + 재시도 + 종단 실패 경로 | 유실 불가 경로 |
Kafka 의 publish/consume/deserialization/retry/DLQ/idempotency/correlationId 기준은 [[raw/branch-notes/feature-kafka-producer-runtime-contract]] 와 [[raw/branch-notes/feature-kafka-consumer-inbox-contract]] 가 소유한다.
## 12. Test Contract
@@ -1032,6 +1070,36 @@ promotion failure:
| `WI-CA-SKELETON-OPERATIONAL-CONTRACT-057` | `feature-sample-portfolio-public-access` | sample public endpoint allowlist와 authenticated endpoint negative test가 명시된다 | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1`, `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-FRAMEWORK-001@2`, `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-TEST-001@1` | `WI-CA-SKELETON-OPERATIONAL-CONTRACT-048`, `WI-CA-SKELETON-OPERATIONAL-CONTRACT-021` | `planned` |
| `WI-CA-SKELETON-OPERATIONAL-CONTRACT-058` | `feature-startup-failure-log-suppression` | suppressible startup failure 조건과 retained actionable error test가 명시된다 | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-BOOTSTRAP-001@1`, `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-ERROR-ENVELOPE-001@1` | `WI-CA-SKELETON-OPERATIONAL-CONTRACT-001`, `WI-CA-SKELETON-OPERATIONAL-CONTRACT-017` | `planned` |
| `WI-CA-SKELETON-OPERATIONAL-CONTRACT-059` | `feature-static-analysis-quality-contract` | static analysis 도구·threshold·CI failure mapping과 fixture가 명시된다 | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-BUILD-001@1`, `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-TEST-001@1` | `WI-CA-SKELETON-OPERATIONAL-CONTRACT-028`, `WI-CA-SKELETON-OPERATIONAL-CONTRACT-029`, `WI-CA-SKELETON-OPERATIONAL-CONTRACT-018` | `planned` |
| `WI-CA-SKELETON-OPERATIONAL-CONTRACT-060` | `feature-capability-provider-selection-contract` | capability descriptor·provider 선택·readiness 등급 검증 test가 통과하고 bean 이름 기반 판정이 제거된다 | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-SELECTION-001@1`, `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-READINESS-001@1` | - | `planned` |
| `WI-CA-SKELETON-OPERATIONAL-CONTRACT-061` | `feature-redis-runtime-role-isolation-contract` | 3-role 연결·키·코덱·health 계약 test와 incompatible co-location 거부 test가 통과한다 | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-REDIS-ROLE-001@1`, `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-OPTIONAL-ADAPTER-001@1` | `WI-CA-SKELETON-OPERATIONAL-CONTRACT-060` | `planned` |
| `WI-CA-SKELETON-OPERATIONAL-CONTRACT-062` | `feature-redis-atomic-program-catalog-contract` | program descriptor·golden vector·NOSCRIPT 재적재 test와 unsafe recipe 경합 재현 test가 통과한다 | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-REDIS-PROGRAM-001@1` | `WI-CA-SKELETON-OPERATIONAL-CONTRACT-061` | `planned` |
| `WI-CA-SKELETON-OPERATIONAL-CONTRACT-063` | `feature-kafka-producer-runtime-contract` | acknowledgement·delivery timeout·순서·보안 설정 계약 test가 통과한다 | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-DELIVERY-SEMANTICS-001@1`, `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-EVENT-BROKER-001@1` | `WI-CA-SKELETON-OPERATIONAL-CONTRACT-060` | `planned` |
| `WI-CA-SKELETON-OPERATIONAL-CONTRACT-064` | `feature-kafka-consumer-inbox-contract` | inbound leaf 등록·manual ack·rebalance·DLT·inbox 멱등 계약 test가 통과한다 | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-DELIVERY-SEMANTICS-001@1`, `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-IDEMPOTENCY-OWNERSHIP-001@1` | `WI-CA-SKELETON-OPERATIONAL-CONTRACT-063`, `WI-CA-SKELETON-OPERATIONAL-CONTRACT-070` | `planned` |
| `WI-CA-SKELETON-OPERATIONAL-CONTRACT-065` | `feature-mongo-runtime-baseline-contract` | concern·index manifest·replica-set 트랜잭션·change stream checkpoint test가 통과한다 | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MONGO-BASELINE-001@1`, `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-OPTIONAL-ADAPTER-001@1` | `WI-CA-SKELETON-OPERATIONAL-CONTRACT-060` | `planned` |
| `WI-CA-SKELETON-OPERATIONAL-CONTRACT-066` | `feature-objectstorage-streaming-lifecycle-contract` | streaming·multipart·presigned·checksum·orphan 회수 계약 test가 통과한다 | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-READINESS-001@1`, `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1` | `WI-CA-SKELETON-OPERATIONAL-CONTRACT-060` | `planned` |
| `WI-CA-SKELETON-OPERATIONAL-CONTRACT-067` | `feature-cache-strategy-registry-contract` | 전략 registry·TTL 4종·single-flight·실패 등급 분리 test가 통과한다 | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CACHE-STRATEGY-001@1`, `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CACHE-FAILURE-001@1` | `WI-CA-SKELETON-OPERATIONAL-CONTRACT-061` | `planned` |
| `WI-CA-SKELETON-OPERATIONAL-CONTRACT-068` | `feature-edge-rate-limit-policy-registry-contract` | 알고리즘 6종 교체·policy registry·decision 기반 Retry-After·fallback test가 통과한다 | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-RATE-LIMIT-ALGORITHM-001@1`, `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-RATE-LIMIT-001@1` | `WI-CA-SKELETON-OPERATIONAL-CONTRACT-062` | `planned` |
| `WI-CA-SKELETON-OPERATIONAL-CONTRACT-069` | `feature-session-auth-mode-contract` | 배타 인증 모드·CSRF·쿠키·회전·다중 파드 세션 계약 test가 통과한다 | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-SESSION-MODE-001@1` | `WI-CA-SKELETON-OPERATIONAL-CONTRACT-061` | `planned` |
| `WI-CA-SKELETON-OPERATIONAL-CONTRACT-070` | `feature-idempotency-ownership-protocol-contract` | owner token 프로토콜·lease 분리·보증 등급별 provider 공통 계약 test가 통과한다 | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-IDEMPOTENCY-OWNERSHIP-001@1`, `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-IDEMPOTENCY-001@1` | `WI-CA-SKELETON-OPERATIONAL-CONTRACT-060` | `planned` |
| `WI-CA-SKELETON-OPERATIONAL-CONTRACT-071` | `feature-fenced-lock-coordination-contract` | fencing token 단조성·high watermark 거부·lease-lost 신호 test가 통과한다 | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-LOCK-FENCING-001@1`, `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-SCHEDULER-LOCK-001@1` | `WI-CA-SKELETON-OPERATIONAL-CONTRACT-060` | `planned` |
| `WI-CA-SKELETON-OPERATIONAL-CONTRACT-072` | `feature-outbox-dispatch-mode-contract` | event/delivery 분리·모드 배타·CDC 라우팅·파티션 정리 조건 test가 통과한다 | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-OUTBOX-DISPATCH-001@1`, `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-DELIVERY-SEMANTICS-001@1` | `WI-CA-SKELETON-OPERATIONAL-CONTRACT-063` | `planned` |
| `WI-CA-SKELETON-OPERATIONAL-CONTRACT-073` | `feature-graphql-production-baseline-contract` | 깊이·복잡도·persisted query·field authorization·스키마 gate test가 통과한다 | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-READINESS-001@1`, `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-API-VERSIONING-001@1` | `WI-CA-SKELETON-OPERATIONAL-CONTRACT-060` | `planned` |
| `WI-CA-SKELETON-OPERATIONAL-CONTRACT-074` | `feature-grpc-production-baseline-contract` | proto 호환·TLS/mTLS·deadline·크기 한계·drain test가 통과한다 | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-READINESS-001@1`, `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-OPTIONAL-ADAPTER-001@1` | `WI-CA-SKELETON-OPERATIONAL-CONTRACT-060` | `planned` |
| `WI-CA-SKELETON-OPERATIONAL-CONTRACT-075` | `feature-websocket-cluster-delivery-contract` | handshake 인증·destination authorization·bounded queue·cluster 전달 test가 통과한다 | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-READINESS-001@1` | `WI-CA-SKELETON-OPERATIONAL-CONTRACT-060`, `WI-CA-SKELETON-OPERATIONAL-CONTRACT-063` | `planned` |
| `WI-CA-SKELETON-OPERATIONAL-CONTRACT-076` | `feature-read-consistency-query-contract` | ReadConsistency·cursor·replica 라우팅·projection lag 계약 test가 통과한다 | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-READ-CONSISTENCY-001@1`, `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-TRANSACTION-001@1` | `WI-CA-SKELETON-OPERATIONAL-CONTRACT-060` | `planned` |
| `WI-CA-SKELETON-OPERATIONAL-CONTRACT-077` | `feature-notification-durable-delivery-contract` | durable/best-effort 분리·template 버전·receipt·dedupe 계약 test가 통과한다 | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-READINESS-001@1`, `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-RESILIENCE-001@1` | `WI-CA-SKELETON-OPERATIONAL-CONTRACT-072` | `planned` |
> **주도권 이전 (2026-07-28)** — 아래 기존 Work Item 은 관심사 일부 또는 전부를 신규 branch 로 이전했다. 이전 절차와 대상 D-row 는 `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §5 가 SSOT.
>
> | 기존 WI | branch | 이전한 관심사 | 신규 owner |
> |---|---|---|---|
> | `WI-…-016` | `feature-rate-limit-idempotency-contract` | idempotency 저장소·프로토콜(H1), rate limit 키·분산·알고리즘(H2) | `WI-…-070`, `WI-…-068` |
> | `WI-…-024` | `feature-cache-consistency-contract` | cache 전략 — stampede·TTL·직렬화·negative·cluster(H3) | `WI-…-067` |
> | `WI-…-049` | `feature-cachestore-multi-backend-router` | cache 실패 등급 — fail-open 경계(H4) | `WI-…-067`, `WI-…-061` |
> | `WI-…-052` | `feature-distributed-lock-contract` | correctness lock·fencing(H5). efficiency mutex 는 유지 | `WI-…-071` |
> | `WI-…-038` | `feature-domain-event-outbox-contract` | outbox 행 모델·dispatch 전략(H6) | `WI-…-072` |
> | `WI-…-008` | `feature-security-operational-baseline` | session·인증 모드(H7). JWT 검증은 유지 | `WI-…-069` |
> | `WI-…-004` | `feature-env-driven-runtime-configuration` | multi-instance 활성화 **판정 메커니즘**(H8) — [[raw/branch-notes/feature-env-driven-runtime-configuration]] D8 의 bean presence 검사 부분. flag·env registry row 는 계속 env-driven 소유 | `WI-…-060` |
## 24. Branch 실행 계획
@@ -1107,6 +1175,38 @@ TODO는 작업 목록이 아니라 미완성 계약입니다. 각 TODO는 branch
- [[raw/branch-notes/feature-streaming-response-contract]] (SSE / WebSocket / long-polling / chunked 지원 여부 1차 결정)
- [[raw/branch-notes/feature-resource-identifier-contract]] (D1~D19 결정 박힘 — ULID 26-char Crockford base32 + PostgreSQL `uuid` native + sample-portfolio `WorkLogId = "01ARZ3NDEKTSV4RRFFQ69G5FAV"` fixture, project §34 Stack Commitment 정합)
**Production Capability 확장 (2026-07-28 신설 — `WI-…-060`~`077`).** 착수 순서는 Tier 의존을 따른다: 1차 플랫폼 → 2차 기술 런타임 → 3차 능력 계약 → 4차 전송·조회. 1차 완료 전 2차 착수 금지(설계 §12).
1차 — 플랫폼:
- [[raw/branch-notes/feature-capability-provider-selection-contract]]
2차 — 기술 런타임:
- [[raw/branch-notes/feature-redis-runtime-role-isolation-contract]]
- [[raw/branch-notes/feature-redis-atomic-program-catalog-contract]] (`redis-runtime-role-isolation` 선행 필수)
- [[raw/branch-notes/feature-kafka-producer-runtime-contract]]
- [[raw/branch-notes/feature-kafka-consumer-inbox-contract]]
- [[raw/branch-notes/feature-mongo-runtime-baseline-contract]]
- [[raw/branch-notes/feature-objectstorage-streaming-lifecycle-contract]]
3차 — 능력 계약 (착수 시 설계 §5.2 주도권 이전 6단계 동반):
- [[raw/branch-notes/feature-cache-strategy-registry-contract]]
- [[raw/branch-notes/feature-edge-rate-limit-policy-registry-contract]]
- [[raw/branch-notes/feature-session-auth-mode-contract]]
- [[raw/branch-notes/feature-idempotency-ownership-protocol-contract]]
- [[raw/branch-notes/feature-fenced-lock-coordination-contract]]
- [[raw/branch-notes/feature-outbox-dispatch-mode-contract]]
4차 — 전송·조회:
- [[raw/branch-notes/feature-graphql-production-baseline-contract]]
- [[raw/branch-notes/feature-grpc-production-baseline-contract]]
- [[raw/branch-notes/feature-websocket-cluster-delivery-contract]]
- [[raw/branch-notes/feature-read-consistency-query-contract]]
- [[raw/branch-notes/feature-notification-durable-delivery-contract]]
## 25. Default Decisions
> **Legacy reference (v1).** 아래 default와 owner map은 세부 rationale·branch-local owner 설명을 보존한다. project-wide 결정의 현재 owner와 상속 기준은 `## 6.1 Project Decision Registry / 안정 결정 레지스트리`다. branch-owned D-row는 project registry로 복제하지 않는다.
@@ -1134,6 +1234,7 @@ TODO는 작업 목록이 아니라 미완성 계약입니다. 각 TODO는 branch
| rate-limit key | authenticated는 principal 기준, unauthenticated는 IP + normalized route 기준. tenant 활성화 시 tenant를 prefix로 추가 | `feature-rate-limit-idempotency-contract` | tenant/user/API key/IP 기준이 branch마다 다름 |
| bootstrap command | `./gradlew bootstrap` 기본. 없는 경우 `./gradlew test` + `docker compose up` wrapper로 제공 | `feature-developer-experience-contract` | 신규 팀이 첫 실행 명령을 문서에서 판정할 수 없음 |
| Testcontainers policy | persistence/outbound integration test부터 강제, unit/architecture/contract test는 Testcontainers 금지 | `feature-test-taxonomy-fixture-contract` | contract test가 컨테이너 의존으로 느려지거나 CI 실패 원인을 흐림 |
| capability 활성화 | production capability는 모듈로 제공하되 기본 비활성. 활성화는 typed descriptor 기반 provider 선택으로만 (`provider: disabled\|<id>` / `dispatch-mode` / leaf `enabled`) | `feature-capability-provider-selection-contract` | classpath 존재·`matchIfMissing`·local provider 기본값으로 capability가 활성화되거나, bean 이름 존재만으로 보증을 주장 |
### SSOT Owner Map
@@ -1157,6 +1258,32 @@ TODO는 작업 목록이 아니라 미완성 계약입니다. 각 TODO는 branch
| **HTTP method 지원/실패 분류** (D12 405/Allow, D13 HEAD/OPTIONS) | [[raw/branch-notes/feature-api-contract-baseline]] D12/D13 | (no counterpart — leaf) | 405 응답 + `Allow` MUST, HEAD MUST 자동 mirror |
| **PATCH content type + mapper** | [[raw/branch-notes/feature-boundary-validation-mapping-contract]] B2 (ArchUnit `no_merge_patch_json_media_type_string` enforced) | api-contract-baseline D14 (정정 — content type 정책만 consume) | RFC 7396 (merge-patch+json) / RFC 6902 (json-patch+json) 모두 *미채택*`application/json` only · absent/null/value 3-상태 wrapper |
| **Conditional request** (ETag / If-Match / If-None-Match / 304 / 412) | [[raw/branch-notes/feature-api-contract-baseline]] D15 | sample-portfolio fixture (WorkLogVersion 이 ETag derivation source) | DB optimistic lock 과 HTTP 412 가 동일 conflict 의 두 표현 |
#### Production Capability 영역 (2026-07-28 신설 — 설계 §4.2 / §5)
> **주도권 이전 표기**: `이전 ← <기존 branch>` 는 그 관심사의 owner 가 신규 branch 로 **이동했음**을 뜻한다. 기존 branch 는 consumer 로 남고, 해당 D-row 는 `/branch-spec` 으로 신규 결정이 확정된 뒤 포인터로 치환된다(설계 §5.2).
| contract area | single SSOT owner | consumers/producers | rule |
| --- | --- | --- | --- |
| capability 활성화 · provider 선택 · readiness 등급 | `feature-capability-provider-selection-contract` | 모든 capability branch, env-runtime-config, runtime-health, integration-adapter-templates | 활성화 판정은 typed descriptor 로만. bean 이름·classpath 존재는 근거가 아님. **이전 ← [[raw/branch-notes/feature-env-driven-runtime-configuration]] 의 판정 메커니즘(H8)** — flag 자체와 env registry row 는 env-driven 유지 |
| Redis role·연결·키·코덱 | `feature-redis-runtime-role-isolation-contract` | cache-strategy, rate-limit, session, idempotency, fenced-lock | 물리 형식은 여기서만 결정. 능력 branch 는 의미만 소유 |
| Redis atomic program (Lua/Function) 카탈로그 | `feature-redis-atomic-program-catalog-contract` | rate-limit, idempotency, fenced-lock, cache-strategy | 다단계 read-decide-write 는 program 경유. unsafe recipe ↔ 대체 쌍 문서화 의무 |
| cache 전략·TTL·stampede | `feature-cache-strategy-registry-contract` | cache-consistency, cachestore-router, redis-runtime | **이전 ← [[raw/branch-notes/feature-cache-consistency-contract]] D3~D9 (H3)** |
| cache 실패 등급 (fail-open 경계) | `feature-cache-strategy-registry-contract` | cachestore-router, redis-runtime | **이전 ← [[raw/branch-notes/feature-cachestore-multi-backend-router]] D1~D3 (H4)** — codec 결함을 miss 로 위장 금지 |
| rate limit 알고리즘·policy registry·키 차원 | `feature-edge-rate-limit-policy-registry-contract` | rate-limit-idempotency, api-contract-baseline, web adapter | **이전 ← [[raw/branch-notes/feature-rate-limit-idempotency-contract]] D4·D5 (H2)** |
| idempotency 저장소·owner token 프로토콜 | `feature-idempotency-ownership-protocol-contract` | rate-limit-idempotency, transaction-concurrency, kafka-consumer-inbox | **이전 ← [[raw/branch-notes/feature-rate-limit-idempotency-contract]] D2·D3·D6·D7·D8·D9 (H1)** |
| correctness lock · fencing token | `feature-fenced-lock-coordination-contract` | distributed-lock, background-job-async | **이전 ← [[raw/branch-notes/feature-distributed-lock-contract]] (H5 — 대상 D-row 는 §25 Owner Map 참조)**. efficiency mutex 는 distributed-lock 이 계속 소유 |
| outbox 행 모델 · dispatch 모드 (polling/cdc) | `feature-outbox-dispatch-mode-contract` | domain-event-outbox, background-job-async, kafka-producer | **이전 ← `feature-domain-event-outbox-contract` 행 상태 머신 D-row (H6)** |
| session · 인증 모드 (jwt/redis-session) | `feature-session-auth-mode-contract` | security-operational-baseline, authn-authz, redis-runtime | **이전 ← `feature-security-operational-baseline` session D-row (H7)**. JWT 검증 자체는 security baseline 유지 |
| Kafka producer 전달 계약 | `feature-kafka-producer-runtime-contract` | messaging-multibroker-router, outbox-dispatch | broker ack·순서·보안은 여기서만 |
| Kafka consumer · inbox | `feature-kafka-consumer-inbox-contract` | background-job-async, idempotency-ownership | 신규 inbound leaf 소유. 모듈 registry migration 동반 |
| MongoDB 런타임 baseline | `feature-mongo-runtime-baseline-contract` | persistence-failure-baseline, read-consistency-query | PostgreSQL 계약과 분리 |
| object storage 스트리밍·수명주기 | `feature-objectstorage-streaming-lifecycle-contract` | file-resource-handling, data-retention-privacy | `byte[]` 전용 API 대체 |
| GraphQL 운영 baseline | `feature-graphql-production-baseline-contract` | api-contract-baseline, application-query-bypass | subscription 전송은 websocket branch 위임 |
| gRPC 운영 baseline | `feature-grpc-production-baseline-contract` | streaming-response, security-operational-baseline | proto 호환·deadline·크기 한계 |
| WebSocket cluster 전달 | `feature-websocket-cluster-delivery-contract` | streaming-response, graphql, kafka-producer | in-process simple broker 는 R1 고정 |
| 조회 일관성 · replica 라우팅 | `feature-read-consistency-query-contract` | application-query-bypass, database-connection-pool, mongo-runtime | 범용 QueryPort 도입 금지 |
| notification durable 전달 | `feature-notification-durable-delivery-contract` | notification-provider-spi, outbox-dispatch, webhook-outbound | provider SPI·라우팅 골격은 notification-provider-spi 유지 |
| **Response cache policy + Vary header** | [[raw/branch-notes/feature-api-contract-baseline]] D16 (HTTP header 정책) | `feature-cache-consistency-contract` (cache layer 구현 SSOT) | default `Cache-Control: no-store`, Vary 의무 |
| **Long-running operation (LRO)** | [[raw/branch-notes/feature-api-contract-baseline]] D17 (polling-only) | webhook callback 패턴은 별도 `feature-webhook-outbound-contract` | 202 + `Location: /v1/operations/{id}` + polling endpoint |
| **Pagination index base + size cap** | [[raw/branch-notes/feature-api-contract-baseline]] D18 | (no counterpart — leaf) | `page` 0-indexed, `size` default 20 / max 100 |
@@ -1228,6 +1355,21 @@ TODO는 작업 목록이 아니라 미완성 계약입니다. 각 TODO는 branch
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-TEST-001` | 1 | `stack-test` | test framework는 JUnit 5다 | `active` | [[raw/project-notes/ca-skeleton-operational-contract]] | §34 Stack Matrix `Test framework` |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-ARCHTEST-001` | 1 | `stack-archtest` | architecture test는 archunit-junit5 1.3.0을 사용한다 | `active` | [[raw/project-notes/ca-skeleton-operational-contract]] | §34 Stack Matrix `Architecture test` |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-RANDOM-001` | 1 | `stack-random` | ULID·idempotency key·token 생성의 random source는 SecureRandom이다 | `active` | [[raw/project-notes/ca-skeleton-operational-contract]] | §34 Stack Matrix `Random source` |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-SELECTION-001` | 1 | `capability-selection` | production capability는 available by default이고 inactive by default이며 활성화는 typed descriptor 기반 provider 선택으로만 이뤄진다 | `active` | [[raw/project-notes/ca-skeleton-operational-contract]] | §36 Capability Readiness Model + `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §6 |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-READINESS-001` | 1 | `capability-readiness` | 모든 capability는 R0~R3 readiness 등급과 capability card를 가지며 R0 seam을 R2 provider로 표기하지 않는다 | `active` | [[raw/project-notes/ca-skeleton-operational-contract]] | §36 Capability Readiness Model |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-REDIS-ROLE-001` | 1 | `redis-role` | Redis는 cache·coordination·session 3개 role로 분리하고 key prefix나 DB number를 격리 수단으로 쓰지 않는다 | `active` | [[raw/project-notes/ca-skeleton-operational-contract]] | 설계 §4.2 #2 · ca-tmpl redis 심층 설계 §10 |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-REDIS-PROGRAM-001` | 1 | `redis-program` | 다단계 read-decide-write는 버전 있는 atomic program으로만 제공하고 각 helper는 unsafe recipe와 안전한 대체를 쌍으로 문서화하며 경합 test로 증명한다 | `active` | [[raw/project-notes/ca-skeleton-operational-contract]] | 설계 §4.2 #3 · ca-tmpl redis 심층 설계 §13·§14 |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CACHE-STRATEGY-001` | 1 | `cache-strategy` | cache 기본 전략은 cache-aside와 after-commit 무효화이고 SWR·refresh-ahead·L1L2·분산 stampede 억제는 opt-in이며 maxmemory-policy는 배포 설정이 소유한다 | `conditional-default` | [[raw/project-notes/ca-skeleton-operational-contract]] | 설계 §4.2 #8 · ca-tmpl platform 설계 §8.4 |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CACHE-FAILURE-001` | 1 | `cache-failure` | optional cache의 backend 실패는 fail open이고 codec·프로그래밍 결함은 해당 연산을 fail closed로 처리하며 cache miss로 위장하지 않는다 | `active` | [[raw/project-notes/ca-skeleton-operational-contract]] | §11 실패 정책 매트릭스 |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-RATE-LIMIT-ALGORITHM-001` | 1 | `rate-limit-algorithm` | rate limit은 policyId 단위로 알고리즘을 선택하고 fixed window·sliding counter·token bucket을 기본 제공하며 sliding log·GCRA·leaky bucket은 opt-in이다 | `conditional-default` | [[raw/project-notes/ca-skeleton-operational-contract]] | 설계 §4.2 #9 · ca-tmpl platform 설계 §8.5 |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-SESSION-MODE-001` | 1 | `session-mode` | 인증 모드는 jwt와 redis-session이 배타이고 jwt가 default이며 redis-session은 전용 session role과 CSRF·쿠키·회전·다중 파드 계약을 동반한다 | `conditional-default` | [[raw/project-notes/ca-skeleton-operational-contract]] | 설계 §4.2 #10 · ca-tmpl platform 설계 §8.6 |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-IDEMPOTENCY-OWNERSHIP-001` | 1 | `idempotency-ownership` | idempotency는 owner token 기반 claim·renew·complete·release 프로토콜을 쓰고 실행 lease와 replay TTL을 분리하며 보증 등급을 명시한다 | `active` | [[raw/project-notes/ca-skeleton-operational-contract]] | 설계 §4.2 #11 · ca-tmpl platform 설계 §9 |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-LOCK-FENCING-001` | 1 | `lock-fencing` | DistributedLockPort는 efficiency mutex로 고정하고 correctness가 필요한 경로는 별도 FencedLockPort와 보호 자원의 high watermark 거부로만 보장한다 | `active` | [[raw/project-notes/ca-skeleton-operational-contract]] | 설계 §4.2 #12 · ca-tmpl platform 설계 §10 |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-OUTBOX-DISPATCH-001` | 1 | `outbox-dispatch` | outbox는 불변 event 행과 polling 전용 delivery 행을 분리하고 dispatch 모드는 polling과 cdc가 배타이며 append는 업무 write와 같은 트랜잭션에 남는다 | `active` | [[raw/project-notes/ca-skeleton-operational-contract]] | 설계 §4.2 #13 · ca-tmpl platform 설계 §11.1·§11.2 |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-DELIVERY-SEMANTICS-001` | 1 | `delivery-semantics` | end-to-end 메시징 보증은 at-least-once 전달과 멱등 consumer·inbox로 표현하고 DB와 broker를 걸친 exactly-once를 주장하지 않는다 | `active` | [[raw/project-notes/ca-skeleton-operational-contract]] | ca-tmpl platform 설계 §11.4 |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MONGO-BASELINE-001` | 1 | `mongo-baseline` | MongoDB는 read preference·read concern·write concern을 명시하고 트랜잭션과 change stream은 replica-set 요건 검증을 통과할 때만 활성화한다 | `conditional-default` | [[raw/project-notes/ca-skeleton-operational-contract]] | 설계 §4.2 #6 · ca-tmpl platform 설계 §12.3 |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-READ-CONSISTENCY-001` | 1 | `read-consistency` | 조회는 ReadConsistency를 명시하고 replica 라우팅은 그 정책과 요청 문맥으로 결정하며 TransactionPort.inRead는 replica 사용을 뜻하지 않는다 | `active` | [[raw/project-notes/ca-skeleton-operational-contract]] | 설계 §4.2 #17 · ca-tmpl platform 설계 §12.1 |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-OPTIONAL-ADAPTER-001` | 1 | `stack-optional-adapter` | optional adapter stack은 축마다 구현체 하나를 고정하고 core stack과 분리된 matrix로 관리한다 | `active` | [[raw/project-notes/ca-skeleton-operational-contract]] | §34 Optional Adapter Stack Matrix |
| area | single-instance default | multi-instance activation requirement |
| --- | --- | --- |
@@ -1539,6 +1681,22 @@ Release-blocking 5 category에 대한 runbook stub 5종 작성.
본 contract의 6개 핵심 결정에 대해 외부 source(공식 문서·RFC·대기업 기술블로그·GitHub repo)를 조사하여 `raw/official-docs/``raw/company-tech-blogs/`에 raw **54개 파일**로 저장. 각 raw 파일은 owning branch-note와 양방향 wikilink로 연결됨. 비교 분석은 추후 `wiki/concepts/` 합성 단계(Phase E)에서 6개 concept 문서로 정리.
### 29.0 프로젝트 내부 설계 참조 (2026-07-28 추가 — 등급 `internal-design-doc`)
> **공식 문서가 아니다.** ca-tmpl repo 가 소유한 프로젝트 내부 설계이며, 인용 시 "ca-tmpl 프로젝트 설계 결정" 으로 표기한다. 외부 best practice 로 격상하거나 면접·블로그에서 표준으로 인용하지 않는다(CLAUDE.md §5·§11).
> 각 문서의 **외부 공식 근거는 아직 `raw/` 로 수집되지 않았다**. Production Capability branch 의 `/branch-spec` 단계에서 verbatim 인용과 함께 수집한다.
| 문서 (ca-tmpl `docs/superpowers/specs/`) | 줄 수 | 대응 llm-wiki branch |
|---|---:|---|
| `2026-07-26-production-capability-platform-design.md` | 1,346 | `feature-capability-provider-selection-contract` 외 17개 전체 |
| `2026-07-26-redis-production-capability-design.md` | 6,771 | `feature-redis-runtime-role-isolation-contract`, `feature-redis-atomic-program-catalog-contract` + Redis 를 provider 로 쓰는 능력 branch 5개 |
| `2026-07-27-httpclient-production-capability-design.md` | 7,030 | `feature-outbound-http-client-baseline` (개정) |
| `2026-07-26-fileserver-production-capability-design.md` | 2,467 | `feature-file-resource-handling-contract` (개정) |
llm-wiki 측 분해 설계: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` — 신규 branch 18개·주도권 이전 8건·hub 델타의 SSOT.
**`/branch-spec` 단계 1차 수집 대상 (외부 공식 문서):** Redis(eval-intro·key eviction·distributed locks·rate-limiter) / Spring Data Redis scripting / Spring Session Redis / Debezium Outbox Event Router / Kafka(design·producer configs) / MongoDB(read·write concern·change streams) / Amazon S3 object integrity / GraphQL Java limits / gRPC(deadlines·retry) / Spring WebSocket broker relay.
### Topic 1 — Architecture Layout
- **ca-tmpl 결정**: Gradle multi-module Clean Architecture / Hexagonal boundary (`domain-core`, `application-core`, `adapter-*`, `shared-contract`, `app-bootstrap`, `sample-portfolio`)
@@ -2425,7 +2583,9 @@ sequenceDiagram
- [[raw/branch-notes/feature-build-release-supply-chain-contract]]
- [[raw/branch-notes/feature-business-rule-validation-contract]]
- [[raw/branch-notes/feature-cache-consistency-contract]]
- [[raw/branch-notes/feature-cache-strategy-registry-contract]]
- [[raw/branch-notes/feature-cachestore-multi-backend-router]]
- [[raw/branch-notes/feature-capability-provider-selection-contract]]
- [[raw/branch-notes/feature-ci-quality-gates-contract]]
- [[raw/branch-notes/feature-container-runtime-contract]]
- [[raw/branch-notes/feature-contract-registry-governance]]
@@ -2439,22 +2599,36 @@ sequenceDiagram
- [[raw/branch-notes/feature-domain-event-outbox-contract]]
- [[raw/branch-notes/feature-domain-feature-onboarding-contract]]
- [[raw/branch-notes/feature-domain-modeling-guardrails]]
- [[raw/branch-notes/feature-edge-rate-limit-policy-registry-contract]]
- [[raw/branch-notes/feature-env-driven-runtime-configuration]]
- [[raw/branch-notes/feature-fenced-lock-coordination-contract]]
- [[raw/branch-notes/feature-file-resource-handling-contract]]
- [[raw/branch-notes/feature-graphql-production-baseline-contract]]
- [[raw/branch-notes/feature-grpc-production-baseline-contract]]
- [[raw/branch-notes/feature-idempotency-ownership-protocol-contract]]
- [[raw/branch-notes/feature-implementation-readiness-scorecard]]
- [[raw/branch-notes/feature-integration-adapter-templates]]
- [[raw/branch-notes/feature-kafka-consumer-inbox-contract]]
- [[raw/branch-notes/feature-kafka-producer-runtime-contract]]
- [[raw/branch-notes/feature-log-management-contract]]
- [[raw/branch-notes/feature-management-actuator-security-contract]]
- [[raw/branch-notes/feature-messaging-multibroker-router]]
- [[raw/branch-notes/feature-metrics-alerting-contract]]
- [[raw/branch-notes/feature-migration-startup-contract]]
- [[raw/branch-notes/feature-mongo-runtime-baseline-contract]]
- [[raw/branch-notes/feature-notification-durable-delivery-contract]]
- [[raw/branch-notes/feature-notification-provider-spi]]
- [[raw/branch-notes/feature-objectstorage-streaming-lifecycle-contract]]
- [[raw/branch-notes/feature-operational-error-observability-foundation]]
- [[raw/branch-notes/feature-operational-runbook-contract]]
- [[raw/branch-notes/feature-outbound-http-client-baseline]]
- [[raw/branch-notes/feature-outbox-dispatch-mode-contract]]
- [[raw/branch-notes/feature-persistence-auditing-contract]]
- [[raw/branch-notes/feature-persistence-failure-baseline]]
- [[raw/branch-notes/feature-rate-limit-idempotency-contract]]
- [[raw/branch-notes/feature-read-consistency-query-contract]]
- [[raw/branch-notes/feature-redis-atomic-program-catalog-contract]]
- [[raw/branch-notes/feature-redis-runtime-role-isolation-contract]]
- [[raw/branch-notes/feature-repository-access-permission-contract]]
- [[raw/branch-notes/feature-resource-identifier-contract]]
- [[raw/branch-notes/feature-runtime-context-propagation-contract]]
@@ -2465,6 +2639,7 @@ sequenceDiagram
- [[raw/branch-notes/feature-schema-serialization-contract]]
- [[raw/branch-notes/feature-secrets-config-source-contract]]
- [[raw/branch-notes/feature-security-operational-baseline]]
- [[raw/branch-notes/feature-session-auth-mode-contract]]
- [[raw/branch-notes/feature-skeleton-package-blueprint-contract]]
- [[raw/branch-notes/feature-startup-failure-log-suppression]]
- [[raw/branch-notes/feature-static-analysis-quality-contract]]
@@ -2473,6 +2648,7 @@ sequenceDiagram
- [[raw/branch-notes/feature-test-taxonomy-fixture-contract]]
- [[raw/branch-notes/feature-transaction-concurrency-contract]]
- [[raw/branch-notes/feature-webhook-outbound-contract]]
- [[raw/branch-notes/feature-websocket-cluster-delivery-contract]]
<!-- GENERATED: branches:end -->
> generated reverse view는 child branch의 v2 contract migration 후 채운다. 아래 수기 목록은 그 전까지 legacy navigation으로 보존한다.
@@ -2526,7 +2702,28 @@ sequenceDiagram
- [[raw/branch-notes/feature-implementation-readiness-scorecard]] — 15-area binary gate
- [[raw/branch-notes/feature-distributed-lock-contract]] — `distributedLockProvider` bean 계약 (JdbcLockRegistry default + tx commit 정합)
(총 44개 — `raw/branch-notes/feature-*.md` glob 으로 확인 가능)
**Production Capability 확장 (2026-07-28 신설 — 설계 §4.2)**
- [[raw/branch-notes/feature-capability-provider-selection-contract]] — capability descriptor + provider 선택 SSOT + readiness R0~R3
- [[raw/branch-notes/feature-redis-runtime-role-isolation-contract]] — Redis cache/coordination/session 3-role + 키·코덱
- [[raw/branch-notes/feature-redis-atomic-program-catalog-contract]] — Lua/Function 카탈로그 + unsafe recipe ↔ atomic 대체
- [[raw/branch-notes/feature-kafka-producer-runtime-contract]] — acks=all + idempotent producer + delivery timeout
- [[raw/branch-notes/feature-kafka-consumer-inbox-contract]] — inbound leaf + manual ack + DLT + InboxStorePort
- [[raw/branch-notes/feature-mongo-runtime-baseline-contract]] — read/write concern + index manifest + change stream
- [[raw/branch-notes/feature-objectstorage-streaming-lifecycle-contract]] — streaming/multipart/presigned + staged finalization
- [[raw/branch-notes/feature-cache-strategy-registry-contract]] — 전략 registry + TTL 4종 + 실패 등급 분리
- [[raw/branch-notes/feature-edge-rate-limit-policy-registry-contract]] — policyId registry + 알고리즘 6종 교체
- [[raw/branch-notes/feature-session-auth-mode-contract]] — jwt | redis-session 배타 프로파일 + 다중 파드
- [[raw/branch-notes/feature-idempotency-ownership-protocol-contract]] — owner token claim/renew/complete/release
- [[raw/branch-notes/feature-fenced-lock-coordination-contract]] — FencedLockPort + fencing high watermark
- [[raw/branch-notes/feature-outbox-dispatch-mode-contract]] — 불변 event + delivery 분리 + polling|cdc 배타
- [[raw/branch-notes/feature-graphql-production-baseline-contract]] — depth/complexity + persisted query + DataLoader
- [[raw/branch-notes/feature-grpc-production-baseline-contract]] — proto 호환 + mTLS + deadline + backpressure
- [[raw/branch-notes/feature-websocket-cluster-delivery-contract]] — handshake 인증 + bounded queue + broker relay
- [[raw/branch-notes/feature-read-consistency-query-contract]] — ReadConsistency + cursor + replica 라우팅
- [[raw/branch-notes/feature-notification-durable-delivery-contract]] — durable/best-effort 분리 + template 버전 + receipt
(수기 목록 총 62개 — 기존 44 + Production Capability 18. 위 GENERATED 목록은 77개이며 `raw/branch-notes/feature-*.md` glob 으로 확인 가능)
### 31.2 근거 자료
@@ -2673,7 +2870,7 @@ sequenceDiagram
- `[ ]` Webhook outbound (signature / replay / retry / observability) — `feature-webhook-outbound-contract` (D=0, scaffolding)
- `[ ]` Streaming response (SSE / WebSocket / long-poll / chunked — 1차 결정: 지원 여부) — `feature-streaming-response-contract` (D=0, scaffolding)
- `[~]` API versioning + Sunset / Deprecation (`/v1` URI prefix → 향후 `/v2`) — `feature-api-compatibility-deprecation-contract` (D=8, sl=in-progress)
- `[~]` Outbound HTTP resilience (Resilience4j circuit breaker / retry / timeout) — `feature-outbound-http-client-baseline` (D=11, sl=in-progress)
- `[*]` Outbound HTTP resilience (Resilience4j circuit breaker / retry / timeout) — `feature-outbound-http-client-baseline` (D=11, sl=in-progress). **2026-07-28 상태 정정**: `[~]``[*]` — ca-tmpl `adapter/outbound/httpclient` 38개 파일(test 15) 존재. 단 `globalCallTimeout` 이 실행 중 호출을 취소하지 않고 decorator 순서가 문서와 다름 → **본 branch 개정 대상**(설계 §5.3)
- `[~]` Error envelope + 코드 registry (`error-codes.yaml` SSOT) — `feature-operational-error-observability-foundation` (D=12, sl=in-progress)
- `[~]` Idempotency-Key + Rate limit (HTTP header / TTL / fingerprint) — `feature-rate-limit-idempotency-contract` (D=10, sl=in-progress)
- `[~]` Contract verification test suite (OpenAPI drift detection) — `feature-contract-verification-test-suite` (D=9, sl=in-progress)
@@ -2689,9 +2886,9 @@ sequenceDiagram
- `[~]` Migration runner readiness gate (Flyway startup) — `feature-migration-startup-contract` (D=8, sl=in-progress)
- `[~]` Multi-tenancy isolation (tenant context + DB scope) — `feature-tenant-context-policy` (D=10, sl=in-progress)
- `[~]` Data retention / Privacy / GDPR (DSR / PII / 감사 log) — `feature-data-retention-privacy-contract` (D=12, sl=in-progress)
- `[~]` File / Resource handling (upload / download / S3) — `feature-file-resource-handling-contract` (D=12, sl=in-progress)
- `[*]` File / Resource handling (upload / download / S3) — `feature-file-resource-handling-contract` (D=12, sl=in-progress). **2026-07-28 상태 정정**: `[~]``[*]` — ca-tmpl `adapter/outbound/fileserver` 16개 파일(test 5) 존재. 전체 파일 버퍼링·비원자 덮어쓰기는 **본 branch 개정 대상**(설계 §5.3). object storage 스트리밍은 [[raw/branch-notes/feature-objectstorage-streaming-lifecycle-contract]] 로 분리
- `[~]` Domain event + Transactional Outbox — `feature-domain-event-outbox-contract` (D=10, sl=in-progress)
- `[~]` Cache consistency (Redis adapter + invalidation) — `feature-cache-consistency-contract` (D=9, sl=in-progress)
- `[*]` Cache consistency (Redis adapter + invalidation) — `feature-cache-consistency-contract` (D=9, sl=in-progress). **2026-07-28 상태 정정**: `[~]``[*]` — ca-tmpl `adapter/outbound/cache-redis` 43개 파일(test 13) 존재. 전략·실패 등급 주도권은 [[raw/branch-notes/feature-cache-strategy-registry-contract]] 로 이전(H3)
- `(없음)` Persistence auditing (CreatedBy / UpdatedBy 도메인 오염 차단) — **신규 branch 권고: `feature-persistence-auditing-contract`**
- `(없음)` DB connection pool 운영 안정성 (HikariCP pool size / timeout / leak detection / slow query) — **신규 branch 권고 (priority #4): `feature-database-connection-pool-contract`**
- `(없음)` Backup / restore / DR (PITR / schema rollback policy / restore drill) — F 미래 후보 (사용자 항목 #6)
@@ -2735,6 +2932,40 @@ sequenceDiagram
- `(없음)` Template instantiation contract (group / artifact / basePackage / root package rename / README 치환 / sample-off 적용 검증) — **신규 branch 권고 (priority #1): `feature-template-instantiation-contract`** — developer-experience + sample-removal-adoption 와 인접하나 *clone 후 검증 절차* 가 독립 row 로 약함
- `(없음)` AuthN / AuthZ product API baseline (JWT / OAuth2 resource server / RBAC / ABAC / permission matrix / endpoint authorization annotation) — **신규 branch 권고 (priority #5): `feature-authentication-authorization-contract`**`feature-security-operational-baseline` 와 별개 (그 branch 는 CORS / SecureRandom / header suppression 중심, 본 branch 는 *product API 인증/인가*)
### F-CAP. Production Capability 영역 (2026-07-28 신설 — 18 branch)
> 설계 SSOT: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md`. 착수 순서는 §24 의 4차수를 따른다. 각 row 는 `WI-…-060`~`077` 과 1:1.
**Tier P — 플랫폼 (나머지 17개의 선행)**
- `[ ]` Capability descriptor · provider 선택 · readiness R0~R3 · capability card — `feature-capability-provider-selection-contract` (D=0, scaffolding). **선행 필수** — bean 이름 기반 multi-instance 판정(`StartupSafetyValidator`) 교체 포함
**Tier T — 기술 런타임**
- `[*]` Redis role isolation · 연결 · key model · codec — `feature-redis-runtime-role-isolation-contract` (D=0, scaffolding). 코드 일부 존재: key builder/namespace/digest, envelope codec, Lettuce runtime 골격
- `[*]` Redis atomic program 카탈로그 (Lua/Function) — `feature-redis-atomic-program-catalog-contract` (D=0, scaffolding). 코드 일부 존재: `RedisLuaProgramExecutor`·`RedisProgramCatalog`·`RedisProgramDescriptor`·`RedisAtomicPrimitives`
- `[ ]` Kafka producer 런타임 (acks=all · idempotent · delivery timeout) — `feature-kafka-producer-runtime-contract` (D=0, scaffolding)
- `[ ]` Kafka consumer + inbox (신규 inbound leaf · DLT · backpressure) — `feature-kafka-consumer-inbox-contract` (D=0, scaffolding). **모듈 19→20 registry migration 동반**
- `[ ]` MongoDB 런타임 baseline (concern · index manifest · change stream) — `feature-mongo-runtime-baseline-contract` (D=0, scaffolding). hub 최초 등록 (기존 row 자체가 부재했음)
- `[ ]` Object storage 스트리밍·수명주기 (multipart · presigned · checksum) — `feature-objectstorage-streaming-lifecycle-contract` (D=0, scaffolding). `byte[]` 전용 `ObjectStoragePort` 대체
**Tier C — 능력 계약 (착수 시 주도권 이전 동반)**
- `[ ]` Cache 전략 registry (TTL 4종 · single-flight · SWR/stampede) — `feature-cache-strategy-registry-contract` (D=0, scaffolding). 이전 ← cache-consistency(H3) · cachestore-router(H4)
- `[ ]` Edge rate limit policy registry (알고리즘 6종 교체) — `feature-edge-rate-limit-policy-registry-contract` (D=0, scaffolding). 이전 ← rate-limit-idempotency(H2). 현재 코드는 `FIXED_WINDOW` 단일 · in-process
- `[ ]` Session · 인증 모드 (`jwt | redis-session`) — `feature-session-auth-mode-contract` (D=0, scaffolding). 이전 ← security-operational-baseline(H7). hub 최초 등록 (기존 row 자체가 부재했음)
- `[ ]` Idempotency owner token 프로토콜 — `feature-idempotency-ownership-protocol-contract` (D=0, scaffolding). 이전 ← rate-limit-idempotency(H1). 현재 port 에 owner token 없음
- `[ ]` Fenced lock · coordination primitive — `feature-fenced-lock-coordination-contract` (D=0, scaffolding). 이전 ← [[raw/branch-notes/feature-distributed-lock-contract]] (H5 — 대상 D-row 는 §25 Owner Map 참조)
- `[ ]` Outbox dispatch mode (불변 event + polling|cdc) — `feature-outbox-dispatch-mode-contract` (D=0, scaffolding). 이전 ← domain-event-outbox(H6). 현재 가변 status row 는 Debezium Outbox Router 와 비호환
**Tier X — 전송·조회**
- `[ ]` GraphQL 운영 baseline (depth/complexity · persisted · DataLoader) — `feature-graphql-production-baseline-contract` (D=0, scaffolding). hub 최초 등록 (기존 row 자체가 부재했음)
- `[ ]` gRPC 운영 baseline (proto 호환 · mTLS · deadline · backpressure) — `feature-grpc-production-baseline-contract` (D=0, scaffolding). hub 최초 등록 (기존 row 자체가 부재했음)
- `[ ]` WebSocket cluster 전달 (handshake 인증 · bounded queue · broker relay) — `feature-websocket-cluster-delivery-contract` (D=0, scaffolding). hub 최초 등록 (기존 row 자체가 부재했음). in-process simple broker 는 R1 고정
- `[ ]` 조회 일관성 · replica 라우팅 (`ReadConsistency` · cursor · lag) — `feature-read-consistency-query-contract` (D=0, scaffolding)
- `[ ]` Notification durable 전달 (template 버전 · receipt · dedupe) — `feature-notification-durable-delivery-contract` (D=0, scaffolding)
### E. 신규 branch 권고 (우선순위 9개)
사용자 18항 + 9 보강 분석 결과의 통합 우선순위. 박을 시점은 본 branch 가 *현재 결정에 영향* 을 주거나 *코드 작성 중 막힐* 때.
@@ -2778,12 +3009,12 @@ sequenceDiagram
### 현재 우선순위 (진행 가능 순서)
**현재 분포 요약** (46 ca-skeleton branches + 9 신규 권고 + 11 미래 후보):
**현재 분포 요약** (2026-07-28 갱신 — **64** ca-skeleton branches = 기존 46 + Production Capability 18 · + 8 신규 권고 · + 11 미래 후보):
- `[x]` 3개 (locally-verified 이상): `feature-skeleton-package-blueprint-contract`, `feature-architecture-enforcement-rules`, `feature-application-port-usecase-contract`
- `[*]` 4개 (partial implementation): `feature-boundary-validation-mapping-contract`, `feature-domain-feature-onboarding-contract`, `feature-sample-removal-adoption-contract`, `feature-implementation-readiness-scorecard`
- `[~]` 37개 (결정 박힘, 코드 없음): 대부분 — D-row 평균 9~12개
- `[ ]` 2개 (scaffolding only): `feature-webhook-outbound-contract`, `feature-streaming-response-contract`
- `[*]` 9개 (partial implementation): `feature-boundary-validation-mapping-contract`, `feature-domain-feature-onboarding-contract`, `feature-sample-removal-adoption-contract`, `feature-implementation-readiness-scorecard`, **+ 2026-07-28 상태 정정 3개** `feature-cache-consistency-contract`·`feature-outbound-http-client-baseline`·`feature-file-resource-handling-contract`, **+ 신규 2개** `feature-redis-runtime-role-isolation-contract`·`feature-redis-atomic-program-catalog-contract`
- `[~]` 34개 (결정 박힘, 코드 없음): D-row 평균 9~12개 (이전 37개 중 3개가 `[*]` 로 승급)
- `[ ]` 18개 (scaffolding only): `feature-webhook-outbound-contract`, `feature-streaming-response-contract` + **Production Capability 16개** (§F-CAP 중 Redis foundation 2개 제외 전부)
- `(없음)` 8개 (신규 branch 권고, E 영역): template-instantiation / static-analysis / dependency-vulnerability / database-connection-pool / authn-authz / runtime-context / persistence-auditing / distributed-lock — (query-bypass 는 2026-06-05 scaffold+구현 완료로 제외, `[x]` locally-verified 로 승급)
- `(없음)` 11개 (미래 후보, F 영역): backup-restore / performance-load / docs-publishing / local-dev-data / clock-injection / i18n / money-decimal / search / notification / saga / soft-delete
@@ -2854,6 +3085,34 @@ ca-skeleton 은 *단일 stack 커밋* 을 채택합니다. 다중 DB / 다중
| Architecture test | archunit-junit5 | 1.3.0 | D17 ArchUnit rule suite (resource-identifier branch + boundary branch) |
| Random source | `java.security.SecureRandom` | Java 21 | ULID generator + idempotency key + token generation 의 의무 random source |
### Optional Adapter Stack Matrix (2026-07-28 신설)
> owner 결정: `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-OPTIONAL-ADAPTER-001@1`. **core Stack Matrix 는 그대로다** — 아래는 §1.1 capability 가 활성화됐을 때만 classpath 와 런타임에 등장하는 축이다. "축마다 구현체 하나" 라는 위 단일 stack 정신을 optional 축에도 동일 적용한다.
>
> `상태` 열: **존재** = 이미 의존성이 pin 되어 있음 / `도입 예정` = capability branch 착수 시 추가 / `opt-in 설정만` = 설정 골격만 있고 운영 계약 없음.
| Capability 축 | 선택 | 버전 | 상태 | 비고 |
|---|---|---|---|---|
| Redis client | Lettuce (Spring Data Redis) | Spring Boot BOM transitive | 도입 예정 | `LettuceRedisRuntime` 골격은 존재하나 SDK 의존성 추가는 후속. owner `feature-redis-runtime-role-isolation-contract` |
| Session store | Spring Session Data Redis | Spring Boot BOM transitive | 도입 예정 | `redis-session` 모드 활성 시에만. owner `feature-session-auth-mode-contract` |
| Kafka client | spring-kafka | Spring Boot BOM transitive | 도입 예정 | producer 먼저, consumer 는 신규 inbound leaf |
| CDC | Debezium + Kafka Connect | 배포 자산 | 도입 예정 | **애플리케이션 의존성 아님.** connector 는 외부 배포·통합 test 자산 |
| MongoDB | Spring Data MongoDB | Spring Boot BOM transitive | opt-in 설정만 | replica-set 요건 검증 필요 |
| Object storage | AWS SDK v2 | `2.30.0` (`ext.awsSdkVersion`) | **존재** | Boot BOM 미관리 → 모듈 scope BOM import |
| gRPC | io.grpc + protobuf | `1.68.1` (`ext.grpcVersion`) | **존재** | Boot BOM 미관리 → 모듈 scope BOM import |
| GraphQL | Spring for GraphQL | Spring Boot BOM transitive | 최소 구현 존재 | production gate(depth/complexity/persisted) 미도입 |
| Resilience | Resilience4j | — | **존재** | core 결정 `RESILIENCE-001` 유지 |
| 통합 test | Testcontainers (+ Toxiproxy) | test scope | 부분 존재 | `TESTCONTAINERS-001` 정책 상속 |
**Out of Stack (optional 축) — 명시적 거부:**
- **lock/coordination**: Redisson 을 lock provider *기본* 으로 삼지 않는다. Spring Integration `LockRegistry` 계열(JDBC/Redis)이 기본이며 Redisson 은 대안. ZooKeeper / etcd / Consul 은 범위 밖.
- **messaging**: RabbitMQ / Pulsar / ActiveMQ 는 범위 밖. Redis Streams 는 Kafka 대체재가 아니라 후속 소규모 provider 후보.
- **cache**: Ehcache / Hazelcast / Infinispan 은 범위 밖. L1 local cache 는 Caffeine.
- **search**: Elasticsearch / OpenSearch 는 범위 밖 (§35 F 영역 유지).
optional 축의 변경도 아래 "Stack 변경 절차" 4단계를 그대로 따른다.
### Cross-Branch 상속 패턴
각 branch 의 §결정 사항 / §Decision Evidence Map / §구현 가이드 가 stack 관련 결정 시 본 § 를 *reference* 만 하고 *재선언하지 않음*. 예시:
@@ -2888,3 +3147,63 @@ ca-skeleton 은 *단일 stack 커밋* 을 채택합니다. 다중 DB / 다중
- **Test framework**: TestNG / Spock — JUnit 5 단일
도메인이 위 alternative 를 요구할 경우 본 § 를 갱신 (cascade) 또는 별도 project-fork.
## 36. Capability Readiness Model (2026-07-28 신설)
> owner: [[raw/branch-notes/feature-capability-provider-selection-contract]] · 결정: `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-READINESS-001@1`, `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-CAPABILITY-SELECTION-001@1`
> 설계 SSOT: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §6
> 절 번호는 §35 뒤에 **덧붙였다** — 중간 삽입은 기존 문서·branch-note 의 절 참조를 어긋나게 한다.
### 36.1 Readiness 4단계
§1.1 의 "available by default, inactive by default" 를 판정 가능한 등급으로 만든다. seam 이 있다는 사실은 capability 가 운영 가능하다는 뜻이 아니다.
| 등급 | 의미 | 필요한 증거 | CLAUDE.md §6 등급 대응 |
|---|---|---|---|
| `R0` Contract | 타입·seam·placeholder 만 존재 | unit test + architecture boundary test | `planned` 또는 `documented-only` |
| `R1` Local | 단일 로컬 프로세스/로컬 서비스에서 동작 | 집중 통합 test + 한계 문서화 | `actually-implemented` (partial) |
| `R2` Production baseline | 실제 provider, 안전한 설정, 실패 의미, health, metric, 보안, graceful lifecycle | provider 계약 test + 실서비스 통합 + 동시성/실패 test + runbook | `locally-verified` |
| `R3` Scale/HA | cluster·failover·rolling upgrade 검증 | topology test + 호환 매트릭스 + 복구/용량 runbook | `prod-verified` |
**금지 (§2 에 등록):** R0 seam 을 R2 provider 로 표기하는 것. 문서·startup 진단·capability card 모두에 적용된다.
### 36.2 Capability card 필수 항목
R2 이상을 주장하는 capability 는 owner branch-note 에 아래를 갖춘 card 를 보유한다.
```text
owner 모듈 · semantic port
provider ID 와 readiness 등급
보증(guarantee)과 명시적 비보증(non-guarantee)
기본 실패 정책과 허용되는 override (§11 실패 정책 매트릭스와 일치)
필요한 토폴로지 · 영속/축출 정책
설정 키와 secret
liveness/readiness 영향
bounded cardinality metric 과 trace span
적합/부적합 지침 · 비용 모델 · 자원 한계
흔한 unsafe recipe · 그것이 만드는 경합/실패 · 안전한 대체
집중/통합/실패 test 명령
rolling upgrade · 복구 노트
runbook 링크
```
**"흔한 unsafe recipe ↔ 안전한 대체" 항목이 §1.1 의 교육적 요구를 계약으로 고정하는 장치다.** 예: `GET → 판단 → INCR → EXPIRE` 는 개별 명령이 직렬화되더라도 클라이언트 사이 인터리빙이 가능하므로 원자적이지 않다 — card 는 이 recipe 와 그 대체(단일 atomic program)를 쌍으로 제시하고, provider 는 그 경합을 재현하는 test 를 통과해야 card 에 보증을 쓸 수 있다.
승급 게이트: §26 Universal Acceptance Gate 10문항 + §23 promotion contract 6필드에 더해, capability branch 는 (1) 보증과 비보증이 **둘 다** 작성되어 있을 것, (2) 주장하는 동시성·실패 의미를 증명하는 **경합 test 이름**이 명시되어 있을 것.
### 36.3 활성화 SSOT
```text
provider: disabled | <provider-id> # 선택 가능한 capability
dispatch-mode: disabled | polling | cdc # 모드 축이 있는 capability (outbox)
enabled: true | false # provider·mode 축이 없는 leaf 에만
```
규범:
- `matchIfMissing=true`, classpath 존재, local provider 기본값이 production capability 를 활성화해서는 **안 된다**.
- 미사용 capability 는 연결·백그라운드 워커·스키마·암묵적 런타임 동작을 만들지 **않는다**.
- legacy flag 를 한시적으로 유지하는 경우, SSOT 와의 불일치는 **startup 오류**다.
- 활성화·안전 판정은 **bean 이름이 아니라 typed descriptor** 로 한다. 현재 ca-tmpl `StartupSafetyValidator` 는 bean 이름 문자열 목록(`distributedLockProvider`·`cacheStampedeProtection`·`outboxLeaderElection`·`distributedRateLimiter`·`migrationStartupRunner` 5개)으로 multi-instance 안전을 판정하므로, 그 이름의 아무 bean 이나 존재하면 안전으로 보고된다 — `feature-capability-provider-selection-contract` 의 교체 대상.
capability descriptor 최소 필드: `capabilityId`, `providerId`, readiness 등급, guarantee class, failure mode, multi-instance 지원, 필요한 backing role, readiness 영향, 구현 버전.