Files
llm-wiki/raw/branch-notes/feature-kafka-producer-runtime-contract.md

624 lines
123 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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):