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

123 KiB
Raw Permalink Blame History

title, source_type, status, branch, parent_branch, related_projects, governing_docs, tags, created, target_merge, status_label, id, kind, project, work_item, inherits, refines, overrides, depends_on, imports, delegates, accepts_delegations, contract_packet
title source_type status branch parent_branch related_projects governing_docs tags created target_merge status_label id kind project work_item inherits refines overrides depends_on imports delegates accepts_delegations contract_packet
branch / feature-kafka-producer-runtime-contract branch-note raw feature-kafka-producer-runtime-contract
ca-skeleton
raw/project-notes/ca-skeleton-operational-contract
branch
ca-skeleton
kafka
producer
messaging
delivery-semantics
2026-07-28 in-progress BR-CA-SKELETON-OPERATIONAL-CONTRACT-063 project-work-item ca-skeleton-operational-contract WI-CA-SKELETON-OPERATIONAL-CONTRACT-063
DEC-CA-SKELETON-OPERATIONAL-CONTRACT-DELIVERY-SEMANTICS-001@1
DEC-CA-SKELETON-OPERATIONAL-CONTRACT-EVENT-BROKER-001@1
WI-CA-SKELETON-OPERATIONAL-CONTRACT-060
1

branch: feature-kafka-producer-runtime-contract

Layer: raw/branch-notes/ — 단일 브랜치의 TODO·결정·진행 기록. 머지/종료 후 verified 결과는 /ingestwiki/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 로 표기했다.

부모 (필수)

분해 근거: 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 (같은 부모의 다른 자식 — 인접 영역):

브랜치 계약 패킷

project Work Item 에서 내려온 실행 계약의 snapshot. 여기에는 pinned pointer + 1줄 요약 + branch 적용점만 쓰고 상세를 복제하지 않는다.

  • 생성 시 프로젝트 개정: 1
  • 패킷 스키마: contract_packet: 1
  • 완료 조건: acknowledgement·delivery timeout·순서·보안 설정 계약 test 가 통과한다

상속한 프로젝트 결정

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

브랜치 지역 결정

상세 근거와 선택 조건은 아래 결정-근거 매핑 표의 동일 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-siblingraw/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

선언한 예외

Override ID Overrides Reason Approval Status

없음 — 상속한 두 project 결정을 좁히기만 하고(D1·D2·D12 의 refines) 뒤집지 않는다.

가져온 프로젝트 계약

Ref Owner 요약 Branch 적용

목표

  • WI-CA-SKELETON-OPERATIONAL-CONTRACT-063 의 완료 조건을 구현한다: acknowledgement·delivery timeout·순서·보안 설정 계약 test 가 통과한다

  • 이슈:

  • PR:

범위

포함 범위

  • 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=trueacks=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-C1C4, SPVJ-C1C5) — 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

  • /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_idheaders.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.yamlmessage-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 의 failurePolicyowner 스키마를 위반했다(키를 실패 종류가 아니라 연산명 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-C3max.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)

Decision Evidence Map / 결정-근거 매핑

Supporting Claimsraw/...#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-receiptR1 판정에 필수로 둘 것인가" 가 판정 기준이다 — 필수면 (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.msTimeoutException). 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.yamlsecret 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 OutboundMessageheaders 를 추가하고, mdc-keys.yamlpropagation: [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-Idmdc_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_idenvelope_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.yamlOUTBOX_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 = trueKAFKA-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)에 종속돼 지금 확정할 수 없다

구현 가이드

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:19void 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:34new 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-bootstrapapplication.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:messagingkafka 패키지에 둔다. 근거 — 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 기본 구현이 들어오면 R0R1. 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.gradleverifyEnvKeys 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:520brokers: ${APP_MESSAGING_KAFKA_BROKERS:}.
  • placeholder 는 두 파일에 있다app-bootstrap(:520)과 sample-portfolio(:228)가 같은 블록을 각각 바인딩한다. verifyEnvKeysapp-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_BROKERSbootstrap.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-implementedcore/DisabledMessagePublisher.java:15AdapterDisabledException("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·outcomemessaging.producer.send.duration.
  • cardinality 는 태그 선택으로 유한하게 묶는다broker(활성 broker 1개) × outcome(성공/실패 2값)이므로 상한이 구조적으로 작다. topic 을 태그로 쓰지 않는다: topic 은 eventType 에서 오므로(§3) 도메인이 늘수록 무한히 증가한다. 이는 기존 outbox.publisher.published.totalevent_typecardinality_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-implementedkafka/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-unavailabledurable-retry(outbox 경로) / broker-unavailablefail-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. trueKAFKA-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 가 무엇을 선언하는가 만 채운다.

엣지·실패·의존

  • 실패·엣지 경로

    • 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.yamlerror_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 공존 규칙

검증해야 할 주장 / 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에서 파생된 자료)

외부 근거 (2026-07-28 수집)

관련 일일 노트

해당 없음.

완료 후 정리

  • PR 링크:
  • 리뷰 메모:
  • 머지 결과 / 배포 환경:
  • wiki 추출 대상 (verified만, wiki/projects/로만 추출):
  • 추출하지 않을 항목 (planned / documented-only / abandoned):