123 KiB
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 |
|
|
|
2026-07-28 | in-progress | BR-CA-SKELETON-OPERATIONAL-CONTRACT-063 | project-work-item | ca-skeleton-operational-contract | WI-CA-SKELETON-OPERATIONAL-CONTRACT-063 |
|
|
1 |
branch: feature-kafka-producer-runtime-contract
Layer:
raw/branch-notes/— 단일 브랜치의 TODO·결정·진행 기록. 머지/종료 후 verified 결과는/ingest로wiki/projects/에 추출. 원본은 raw에 영구 보관.status_label:in-progress|review|merged|abandoned2026-07-28/branch-spec1회차로 D-row 14개·§구현 가이드 10절·§Audit & Findings 10건을 채웠고, 같은 날 depth·coverage 게이트 1회차의 Blocking 5건을 보강했다(§진행 중 메모 게이트 이력). 코드 착수 전이며 아래 detail 은 코드로 확인한 것만actually-implemented로 표기했다.
부모 (필수)
- 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.0WI-CA-SKELETON-OPERATIONAL-CONTRACT-063의 실행 단위다.
형제 branch (같은 부모의 다른 자식 — 인접 영역):
- raw/branch-notes/feature-messaging-multibroker-router —
MessageBrokerSPI·중앙 조립·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_DISABLEDowner - raw/branch-notes/feature-env-driven-runtime-configuration —
env-keys.yamlregistry 의 enforced SSOT owner (APP_prefix 통일 D2 ·verifyEnvKeys3-way drift D7). 본 branch 가 신규 등록할 13키가 그 계약의 적용을 받는다
브랜치 계약 패킷
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-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 |
선언한 예외
| 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=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-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-receipttest 를 재정의 — 등급: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 /depth3회차 재판정 — 2회차 Blocking 3건 보강분(§7 half ② 불채택 · §7-1 19키 3열표 · §9failurePolicy키 재작성)은 아직 게이트를 통과하지 않았다./branch-spec루프 천장 2회 도달로 이번 세션은 종료 — 등급:planned- 후속 registry 과제 2건 — (1)
KafkaSenderjavadoc 의 "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의 registrylog_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 이 담당" 이 틀렸다 —verifyEnvKeyscheck 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+KafkaSenderjavadoc 정정 의무).
- 2회차 depth 가 잡은 3건(전부 보강 완료, 재판정 전): (1) §7-1 이 "
- 게이트 이력 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+ 게이트된@ImportAutoConfiguration2-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 유예 스텁.
- depth Blocking 1 → A8 이 틀렸다.
- 2026-07-28
/branch-spec1회차. 외부 공식 문서 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 check1254 pass)한MessageBroker/OutboundMessage를 건드린다. 협의 없이 진행하면 Single-Owner 를 깬다(§Audit A9). - 두 번째 발견: Kafka 3.0+ 기본값이 이미 idempotence 요건 3종을 충족하므로, 이 branch 의 설정 계약이 하는 일은 값을 정하는 것이 아니라 보증을 깨는 override 를 거부하는 것이다.
- ca-tmpl 의 Kafka 는 현재 R0 seam 이다 —
KafkaSenderjavadoc 이 "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-contractD5(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.yamlL724·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 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)에 종속돼 지금 확정할 수 없다 |
구현 가이드
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("Thevoidreturn 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 settingallwill 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-closedOutboxMessagePublishAdapter(outbox/OutboxMessagePublishAdapter.java:32-48)만 결과를 소비한다. 이렇게 해야 sibling multibroker D4(두 실패 계약의 분리 보존)를 깨지 않는다. - 동기 대기가 필요하면 timeout 있는 형태를 쓴다(
SPRING-KAFKA-SEND-C3). 실패는KafkaProducerException으로 오고 실패한 record 를 담고 있으므로(SPRING-KAFKA-SEND-C5) §8 의 분류 입력으로 쓸 수 있다.
OUT_OF_BRANCH_SCOPE:
MessageBrokerSPI 자체의 소유권은 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.OutboxMessagePublishAdapterTestjavadoc(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) 문자열 전체를 하나의secretrow 로, (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") + siblingPROFILE_MISMATCH선례. 로컬 dev 는 허용. - SASL/PLAIN 을 쓴다면
SASL_SSL과만 조합한다 —KAFKA-SEC-C6이 "should be used only with SSL as transport layer" 라고 직접 진술한다.SASL_PLAINTEXT+PLAIN조합은 거부. - 모든
secretrow 는 sibling 규약을 따른다:prod_default: null, dev sentinel__LOCAL_DEV_prefix, maskingfull(진짜 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):
- web server 정지 — 신규 요청 차단, 기존 요청만 완료 (
SB-GS-C2·C3). 이미 Spring Boot 기본 동작이다. - outbox relay 스케줄러 정지 — 새 batch claim 중단. owner 는 raw/branch-notes/feature-background-job-async-contract 이며 본 branch 는 순서 요구만 건다.
- producer
flush()— buffer 의 남은 record 를 전송하고acks기준 완료까지 대기 (KAFKA-PROD-JD-C7).linger.ms기본이 5ms 이므로 partial batch 가 실재한다 (KAFKA-PRODCFG-C6,SPRING-KAFKA-SEND-C4). - 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 이름을 이미 명시한다(ownerfeature-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-IdHTTP 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 가 왜 둘 다 필요한지("ImportAutoConfigurationis an explicit import thatspring.autoconfigure.excludedoes 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@ConditionalOnPropertygate. Left alone it would start a Mongo client (and a mongo health contributor, turning/actuator/healthDOWN) on every boot" 라고 기록한다. - 채택 패턴 (
planned) — Mongo 선례의 half ①만 채택하고 half ②는 채택하지 않는다.app-bootstrap의application.ymlspring.autoconfigure.exclude에 Kafka auto-configuration FQN 을 올려 classpath 를 무력화하되(①),@ImportAutoConfiguration으로 되살리지는 않고(②-불채택)KafkaAdapterConfig(이미app.messaging.broker=kafka로 게이트됨)가 게이트된@Bean으로 producer 를 직접 조립한다. - Mongo 선례와의 의도적 divergence (2회차 depth 게이트 지적 반영): Mongo 는 half ② 를 채택했고, 그 결과 연결 배선을 벤더 네임스페이스에 맡겼다 —
MongoPersistenceSettings.java:10-13javadoc 이 "The Mongo connection URI is intentionally NOT modelled here — it is read from Spring's own standardspring.data.mongodb.uri(owned by Spring Boot'sMongoProperties), 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패키지에 둔다. 근거 —MessagingConfigjavadoc(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(verifyEnvKeys3-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의verifyEnvKeyscheck B 가 "Every.envkey must be referenced by someapplication.ymlplaceholder" 를 강제하므로("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> propertiespassthrough 한 칸을 두고 임의 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_idheader 전파가 그 전제를 미리 만든다.
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 가 무엇을 선언하는가 만 채운다.
엣지·실패·의존
-
실패·엣지 경로
- broker 미선택 상태에서 publish 호출 →
AdapterDisabledException("messaging")즉시 실패. 조용한 no-op 아님 (actually-implemented:core/DisabledMessagePublisher.java:15). app.messaging.broker=kafka인데KafkaSenderbean 이 없음 →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 측 사후 관찰로만 발견된다.
- broker 미선택 상태에서 publish 호출 →
-
다른 계약 의존
- raw/branch-notes/feature-messaging-multibroker-router D2(통합
MessageBrokerSPI)·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(verifyEnvKeys3-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 = envelopeidempotencyKey+eventType복합)이 소유. 또한 그 branch D2 가 본 branch D2 의 축 — seam 유지 + 스켈레톤 spring-kafka 기본 구현 + broker 미선택 시 auto-config 비활성 — 에 정렬해 있으므로 두 D2 는 함께 승인해야 한다.)
- raw/branch-notes/feature-messaging-multibroker-router 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 | "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 계열의 기존 prefixadapter-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 |
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-auditor2회차 판정 =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 수집)
- 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):