624 lines
123 KiB
Markdown
624 lines
123 KiB
Markdown
---
|
||
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):
|