Files
llm-wiki/raw/branch-notes/feature-domain-event-outbox-contract.md

535 lines
98 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: branch / feature-domain-event-outbox-contract
source_type: branch-note
status: raw
branch: feature-domain-event-outbox-contract
parent_branch:
related_projects: [ca-skeleton]
governing_docs: [wiki/projects/ca-tmpl/transactional-outbox-pattern]
tags: [branch, ca-skeleton, domain-event, outbox, messaging]
created: 2026-05-22
target_merge:
status_label: in-progress
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-038
kind: project-work-item
project: ca-skeleton-operational-contract
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-038
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-EVENT-BROKER-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-SCHEDULER-LOCK-001@1]
refines: []
overrides: []
depends_on: []
contract_packet: 1
contract_packet_sha256: 07cf1cd12d434bd2863971a2449e16cd77d46aa46eefc8be1f42f5eb171ca28d
---
# branch: feature-domain-event-outbox-contract
> Layer: `raw/branch-notes/` — domain event, integration event, outbox, message publish 실패 기준을 정의합니다.
> [!important] 주도권 이전 고지 (2026-07-28)
> 아래 관심사의 **owner 가 신규 branch 로 이동**했다. 근거·절차: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §5.
>
> | 이전 ID | 대상 D-row | 이전한 관심사 | 신규 owner |
> |---|---|---|---|
> | H6 | 행 상태 머신 관련 D-row | outbox 행 모델·dispatch 전략 (polling|cdc) | [[raw/branch-notes/feature-outbox-dispatch-mode-contract]] |
>
> **본문은 아직 제거하지 않았다.** 신규 branch 는 현재 스캐폴딩 상태(D-row 없음)이므로, 지금 본문을 지우면 결정 근거가 소실된다. 위 D-row 는 신규 branch 의 `/branch-spec` 이 재판정을 마칠 때까지 **잠정 근거**로만 유효하며, 그 시점에 설계 §5.2 6단계에 따라 포인터(`superseded → [[<신규 branch>]] D<m>`)로 치환한다.
>
> 이 기간 중 새 결정을 본 branch 에 추가하지 않는다 — 신규 owner 에 작성한다.
<!-- section-id: branch-parent -->
## 부모 (필수)
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (§<관련 섹션>) 의 결정/근거/금지 사항을 정제한다.
형제 branch (계약 의존 — §엣지·실패·의존 참조):
- [[raw/branch-notes/feature-background-job-async-contract]] — retry/DLQ vocabulary SSOT
- [[raw/branch-notes/feature-transaction-concurrency-contract]] — isolation level 결정 (D3)
- [[raw/branch-notes/feature-rate-limit-idempotency-contract]] — API-측 Idempotency-Key SSOT
<!-- GENERATED: branch-contract:start -->
<!-- section-id: branch-contract-packet -->
## 브랜치 계약 패킷
- **생성 시 프로젝트 개정**: `1`
- **패킷 스키마**: `contract_packet: 1`
- **완료 조건**: broker-agnostic outbox와 duplicate execution test가 통과한다
<!-- section-id: inherited-project-decisions -->
### 상속한 프로젝트 결정
| Decision Ref | Project Summary | Branch Application | Source |
|---|---|---|---|
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-EVENT-BROKER-001@1` | core는 broker-agnostic outbox를 제공하고 Kafka는 optional adapter로 둔다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-SCHEDULER-LOCK-001@1` | single-instance가 default이며 multi-instance scheduler/outbox는 DB advisory lock을 사용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
<!-- section-id: branch-local-decisions -->
### 브랜치 지역 결정
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
| Decision ID | Decision | Relation | Supporting Claims | Status |
|---|---|---|---|---|
<!-- section-id: declared-overrides -->
### 선언한 예외
| Override ID | Overrides | Reason | Approval | Status |
|---|---|---|---|---|
<!-- GENERATED: branch-contract:end -->
<!-- section-id: branch-goal -->
## 목표
실제 도메인이 들어오면 이벤트 발행 요구가 빠르게 생깁니다. domain event가 Kafka/Redis/HTTP 같은 transport detail을 알거나 transaction과 publish가 분리되어 유실되면 skeleton의 운영 계약이 깨집니다.
- 이슈:
- PR:
<!-- section-id: branch-scope -->
## 범위
### 포함 범위
- domain event와 integration event 분리.
- outbox 도입 기준.
- event payload 안전 기준.
- publish 실패 분류.
- retry/DLQ/runbook 기준.
- correlationId/idempotency key propagation.
### 제외 범위
- Kafka dependency 기본 탑재.
- 특정 broker schema registry 구현.
- event sourcing 강제.
## 근거 (필수, 최소 1개+)
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
| Source | 정당화하는 결정 |
|---|---|
| [[raw/official-docs/outbox-skip-locked-microservices-io]] | Chris Richardson 원형 |
| [[raw/official-docs/skip-locked-postgres-docs]] | Postgres SKIP LOCKED 원리 |
| [[raw/company-tech-blogs/outbox-woowahan-techblog-pattern]] | 우아한형제들 polling 사례 |
| [[raw/official-docs/outbox-debezium-official-docs]] | [[raw/company-tech-blogs/outbox-wix-engineering-debezium]] |
| [[raw/company-tech-blogs/outbox-wix-engineering-debezium]] | 대안 1 (Debezium CDC) 의 운영 사례 비교 근거 (company-case-study) |
| [[raw/company-tech-blogs/outbox-confluent-kafka-connect-smt]] | 대안 2 (Kafka Connect outbox SMT) 비교 근거 (company-case-study) |
| [[raw/official-docs/spring-transactional-event-listener]] | 대안 3 (in-process only) 비교 근거 — TX-EVT-C1~C5 (official-vendor-doc, 2026-06-11 grep 확인) |
| [[raw/official-docs/event-sourcing-vs-outbox-microservices-io]] | 대안 4 (event sourcing 전환) 비교 근거 |
| [[raw/company-tech-blogs/outbox-netflix-domain-events-cdc]] | 대안 5 (자체 CDC) 비교 근거 (company-case-study) |
| [[raw/official-docs/dual-write-antipattern-microservices-io]] | outbox 도입 근거 — D2 (DUAL-WRITE-C1~C3, 2026-06-11 grep 확인) |
| [[raw/official-docs/microservices-io-transactional-outbox]] | Chris Richardson outbox 패턴 카탈로그 (engineering-blog) — dual-write 문제 정의 + OUTBOX 테이블 + 별도 message relay 해법 + if-and-only-if commit 보장 |
| [[raw/official-docs/domain-event-fowler-eaa]] | D1 — domain event 의 정의(Fowler EAA Dev) — "도메인 사실의 기억" 포착이 본질이며 input source 에 무관한 second layer 구조 설명 (transport-independence 의 해석 근거, engineering-blog strength) |
| [[raw/official-docs/transactional-outbox-aws-prescriptive-guidance]] | D2 official-vendor-doc corroborate — dual-write 문제 + 동일 transaction outbox insert + at-least-once delivery + consumer idempotency + polling vs CDC relay 옵션 (OUTBOX-AWS-C1~C6) |
| [[raw/official-docs/skip-locked-mysql-docs]] | D4 MySQL 측 일반화 — MySQL 8.0+ SKIP LOCKED 공식 시맨틱 (SK-MYSQL-C1/C2) 이 PostgreSQL SK-PG-C1/C2 와 동등함을 MySQL 공식 문서로 보강 |
| [[raw/official-docs/cloudevents-spec-required-attributes]] | D12 — ca-tmpl event envelope required-field 결정을 CloudEvents 표준(REQUIRED: id/source/specversion/type, OPTIONAL: time/subject, extension: correlationId/idempotencyKey) 과 대조하기 위한 표준 근거 |
| [[raw/official-docs/arch-hexagonal-cockburn]] | D11 보조 — adapter 가 port API 를 device signal 로 양방향 변환한다는 원형 (HEX-COCKBURN-ORIG-C4) — domain→integration event mapper 의 위치 근거 (engineering-blog) |
## 외부 근거 / 대안 조사 (2026-05-22 — Topic 3)
본 branch의 SKIP LOCKED polling outbox 결정에 대한 외부 source. 6종 대안 비교는 (예정) `wiki/concepts/transactional-outbox-pattern.md` 참조.
- **채택 결정 (DB polling + FOR UPDATE SKIP LOCKED)**:
- [[raw/official-docs/outbox-skip-locked-microservices-io]] — Chris Richardson 원형
- [[raw/official-docs/skip-locked-postgres-docs]] — Postgres SKIP LOCKED 원리
- [[raw/company-tech-blogs/outbox-woowahan-techblog-pattern]] — 우아한형제들 polling 사례
- **검토한 대안**:
- **대안 1: Debezium CDC** — [[raw/official-docs/outbox-debezium-official-docs]], [[raw/company-tech-blogs/outbox-wix-engineering-debezium]]
- **대안 2: Kafka Connect outbox SMT** — [[raw/company-tech-blogs/outbox-confluent-kafka-connect-smt]]
- **대안 3: Spring @TransactionalEventListener** (in-process only) — [[raw/official-docs/spring-transactional-event-listener]]
- **대안 4: Event sourcing 전환** — [[raw/official-docs/event-sourcing-vs-outbox-microservices-io]]
- **대안 5: Netflix DBLog 급 자체 CDC** — [[raw/company-tech-blogs/outbox-netflix-domain-events-cdc]]
- **Negative reference (금지)**: [[raw/official-docs/dual-write-antipattern-microservices-io]] — outbox 도입 근거
- **비교 핵심**: polling lag vs CDC 인프라 비용이 결정 축. ca-tmpl 가정 = lag 수 초 허용 + Kafka Connect 운영 인력 부재 + DB가 SSOT. 가정 깨지면 Debezium migration. event sourcing은 "대안"이라기보다 도메인 모델 교체.
- **2026-06-11 보강 (자동조사)**: D2 official-vendor-doc corroborate — [[raw/official-docs/transactional-outbox-aws-prescriptive-guidance]] (AWS Prescriptive Guidance, polling publisher 와 CDC 를 모두 relay 옵션으로 공식 기술). D4 MySQL 일반화 — [[raw/official-docs/skip-locked-mysql-docs]]. D1 정의 근거 — [[raw/official-docs/domain-event-fowler-eaa]]. D12 표준 대조 — [[raw/official-docs/cloudevents-spec-required-attributes]].
## TODO
> TODO drained — 결정은 아래 표/결정 사항 참조.
## Work Item Contract
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
| field | required | rule |
| --- | --- | --- |
| Decision | yes | 구현자가 선택해야 하는 기본값 |
| Allowed | yes | 허용되는 예외와 조건 |
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
## 진행 중 메모
- **2026-06-11 Phase C2 구현 완료 (controller 최종 요약)**: 플랜 `ca-tmpl docs/superpowers/plans/2026-06-11-domain-event-outbox-contract-plan.md` 의 Task A~G 전부 구현. 리뷰 체인: ca-architect-sentinel PASS×3 (blocking 0) → ca-spec-reviewer 37/37 MET (req#14 OutboxReaper wiring 은 FIX 후 on-disk 재확인; pre-commit 워크플로우라 절차상 blocked 표기) → ca-quality-reviewer PASS (Important 2건 FIX 완료: mark* silent-swallow → orElseThrow, OutboxProperties 양수 가드). 최종 `./gradlew check` 836/836 PASS (Testcontainers PG 계약 테스트 12건 실제 실행 확인). 커밋은 사용자가 직접 수행 예정. 잔여 minor(샘플 mapper escape 방식 javadoc 주석, WorkLogUseCasesTest UTC_CLOCK, 테스트 support listener 관용구)는 후속 정리 후보로만 기록. runbook 2건(`outbox-publish-failed`/`outbox-dead-letter`) 작성 — D15 충족.
- 2026-06-11 `/branch-spec` 실행: ca-tmpl ground truth 감사 (domain event 계약은 actually-implemented, outbox 인프라는 전부 부재 = planned), 자동조사 4건 (Fowler / AWS / MySQL / CloudEvents raw 수집), Debezium 인용 재검증 (QUOTE_DRIFT — §Audit & Findings), 신규 결정 D11~D15 추가, 템플릿 순서 재배치.
- 2026-06-11 Task B 완료 (application-core outbox contract): `application-core` 에 outbox 포트 계약 및 relay use case 구현. 실제 구현 파일 9개 + 테스트 3개. 빌드: `:application-core:test` 78 PASS, `:app-bootstrap:test '*CleanArchitectureTest'` PASS. 발견 버그: `OutboxBackoffPolicyTest.jitter_adds_up_to_base_seconds``1.0 - Double.MIN_VALUE` 이 double 연산에서 정확히 `1.0` 으로 underflow 해서 jitter 가 30초 boundary 에 정확히 닿아 `isLessThan(30)` 실패. `Math.nextDown(1.0)` 으로 수정.
- 2026-06-11 Task C 완료 (adapter-persistence outbox): `V3__outbox_event.sql` migration, `OutboxEventJpaRepository` (SKIP LOCKED native claim query + 4 custom queries), `OutboxStoreAdapter` (implements `OutboxAppendPort` + `OutboxStorePort`), `OutboxReaper`. `OutboxEventEntity` no-arg constructor `protected``public` (cross-package test instantiation). 테스트 버그 수정 2건: (1) `List.of(new Object[]{...})` varargs inference ambiguity → `List.<Object[]>of(...)` explicit type witness; (2) `any()` on primitive `int` param (NPE on unboxing) → `anyInt()`. 빌드: `:adapter-persistence:test` PASS, `:app-bootstrap:test '*CleanArchitectureTest'` PASS, `verifyCleanArchitectureDependencies` PASS.
- 2026-06-11 Task D 완료 (adapter-outbound outbox): 신규 패키지 `dev.caskeleton.adapter.outbound.messaging.outbox` 에 4개 파일 추가. `OutboxEnvelopeJson` (D12 envelope 직렬화 — 의존성 없는 수기 JSON, escape 메서드 RFC 8259 §7 준수, payload raw 삽입). `KafkaOutboxMessagePublishAdapter implements OutboxMessagePublishPort` (KafkaSender seam 직결, fail-closed — 실패 시 OutboundDependencyLogger.logFailure 후 예외 전파, I8; topic=eventType/key=aggregateId, I9; javadoc 에 KafkaMessagePublisher fail-open 과의 대비 명시). `DisabledOutboxMessagePublishAdapter` (AdapterDisabledException("kafka") throw, Layer 3 sentinel). `OutboxPublishAdapterConfig` (app.messaging.kafka.enabled 게이트, matchIfMissing=true 비활성화 기본, KafkaAdapterConfig 선례). TDD red 증거: compileTestJava 24 symbol errors (production 타입 부재). 빌드: `:adapter-outbound:test` (신규 14 테스트 포함) PASS, `:app-bootstrap:test '*CleanArchitectureTest'` PASS, `verifyCleanArchitectureDependencies` PASS. 발견 이슈: `\uXXXX` 리터럴을 javadoc 주석에 넣으면 Java 컴파일러가 소스 레벨에서 처리해 파싱 오류 발생 → `escape()` javadoc 을 산문 설명으로 교체 + switch-arrow 구문을 if/else chain 으로 교체(동일 동작).
- 2026-06-11 Task C FIX (controller review — persistence-only): `claimEligible` query rewritten to plan-verbatim form (I4 FIFO gate via `NOT EXISTS`, uniform `next_attempt_at <= :now` for all 3 statuses). Javadoc on both `OutboxEventJpaRepository` and `OutboxStoreAdapter` corrected (false "adapter enforces FIFO in-memory" claim removed). New unit test `claimBatch_passes_all_repo_results_through_without_in_memory_fifo_filtering` added. `:adapter-persistence:test` 11 PASS. Two app-bootstrap contract tests now fail as expected-to-change (follow-up dispatch owns them): `fifo_ordering` (gate blocks tail in same batch — old test assumed both rows claimed in one cycle) and `leader_election` (test clock timing incompatible with uniform `next_attempt_at <= :now` predicate).
- 2026-06-11 Task F 완료 (sample-portfolio outbox wiring demo): `CreateWorkLogUseCase``OutboxAppendPort` + `OutboxEventIdFactory` + `Clock` 주입 추가. `WorkLog.create()` 후 같은 `tx.inWrite` 블록 안에서 `WorkLogReserved` 도메인 이벤트 생성 → `WorkLogReservedIntegrationEventMapper.toIntegrationEvent` (D11) → `toJson` (수기 JSON, RFC 8259 §7 escape) → `OutboxAppendPort.append` (D2). eventId = `OutboxEventIdFactory.newEventId()` (ULID), idempotencyKey = eventId (I12). correlationId = MDC `correlation_id` 값, 부재 시 eventId self-correlation. 신규 파일: `OutboxEventIdFactory` (domain port), `UlidOutboxEventIdFactory` (adapter/identifier), `WorkLogReservedIntegrationEventMapper` toJson/escape 추가, `OutboxEventIdFactory` 주입 추가. 신규 테스트: `CreateWorkLogOutboxTest` (7개 — tx-내 append 증명 + envelope 필드 검증), `WorkLogReservedIntegrationEventMapperJsonTest` (7개 — JSON shape/escape/PII), `WorkLogReservedConsumerDedupeContractTest` (4개 — D7 consumer dedupe 계약). 기존 테스트 업데이트: `WorkLogUseCasesTest` + `WorkLogAuthorizationContractTest``CreateWorkLogUseCase` 생성자 변경에 맞게 no-op stub 추가. TDD red 증거: `compileTestJava` 가 기존 3-arg 생성자 불일치로 8 errors. 빌드: `:sample-portfolio:test` 129 PASS, 0 failures. `:app-bootstrap:test '*CleanArchitectureTest'` PASS, `:app-bootstrap:test '*EventPayloadPiiContractTest'` PASS. ArchUnit 검증: `no_uuid_random_in_controller` — UlidCreator 는 `adapter/identifier/UlidOutboxEventIdFactory` 에만 있고 application layer 에 없음(확인). `OutboxAppendPort` 구현체는 `adapter-persistence` 소속 — `externalOutboundAllowed` 불필요(확인).
- 2026-06-11 Task E 완료 (app-bootstrap outbox wiring + contract tests): `dev.caskeleton.bootstrap.outbox` 패키지 신설. (1) `OutboxProperties``@ConfigurationProperties(prefix="ca-skeleton.outbox")` 6-field constructor-bound record, compact constructor로 null→default + positive validation. (2) `OutboxLeaderElectionToken``StartupSafetyValidator.REQUIRED_MULTI_INSTANCE_BEANS["outboxLeaderElection"]` bean 충족용 마커 클래스. (3) `OutboxMetrics``ObjectProvider<MeterRegistry>` no-op pattern; `outbox.publisher.published.total`(Counter) / `outbox.pending.size`(MultiGauge per status) / `outbox.publisher.lag`(MultiGauge per eventType, seconds) 3종. (4) `OutboxRelayScheduler``@ConditionalOnProperty(relay-enabled, matchIfMissing=true)` + `@Scheduled(fixedDelayString=...)` + 예외 전면 catch(스케줄러 스레드 사망 방지). (5) `OutboxConfig``@Bean publishPendingOutboxEventsUseCase` (manual wiring + OutboxBackoffPolicy), `@Bean outboxLeaderElection`, `@Bean outboxMetrics`. `application.yml``ca-skeleton.outbox` 섹션 6개 리터럴 기본값 추가(신규 env key 0개 — I11 준수). `app-bootstrap/build.gradle``micrometer-core` + testcontainers 4종 추가. 컨트랙트 테스트 5종: `OutboxPropertiesTest`(green 13), `EventPayloadPiiContractTest`(red+green ArchUnit PII 검사), `OutboxStatusRegistryContractTest`(gitignored registries 부재 시 skip), `OutboxPublisherLeaderElectionContractTest`(1000row×2ctx SKIP LOCKED 중복 0 검증), `OutboxRowLifecycleContractTest`(happy path / FAILED / DEAD / FIFO ordering / orphan reclaim / reaper). `OutboxAppendTransactionalContractTest`(rollback→row absent / commit→row present). 발견한 구현 상태: `OutboxStoreAdapter.claimBatch` 에 per-aggregate FIFO gate 코드 부재(javadoc 은 "in-memory gate" 언급하나 실제 구현 없음) — FIFO ordering test 를 "동일 aggregate 두 row 의 occurred_at ASC 순서 보장" 으로 재작성(FIFO gate blocking 아님). `OutboxReaper.reap()` `@Transactional` 은 Spring proxy 통해서만 작동 — 수동 `new` 생성 시 `tx.inWrite(() -> reaper.reap())` 래핑 필요(계약 테스트에서 적용). 3-retry DEAD 테스트: 고정 과거 시계(2020년) 는 backoff nextAttemptAt = 2020년+30s 를 생성해 다음 사이클이 eligible 안 됨 → 각 사이클을 +2h 시계로 빌드. 빌드: `:app-bootstrap:test` ALL PASS(13 outbox contract + 전체 suite PASS), `verifyCleanArchitectureDependencies` PASS, `verifyEnvKeys` PASS (81 env keys, 73 required, 0 new).
## 결정 사항
- 2026-05-22: domain event는 transport detail을 모름.
- 2026-05-22: transaction과 외부 publish의 원자성이 필요하면 outbox를 기본 기준으로 둠.
- 2026-05-22: broker는 Kafka를 강제하지 않음. core는 broker-agnostic outbox만 제공하고 Kafka는 optional integration adapter.
- 2026-05-22: retry/DLQ vocabulary의 SSOT는 `feature-background-job-async-contract`, 이 branch는 outbox publisher consumer.
- 2026-05-22: outbox publisher는 single-instance 기본. multi-instance 활성화 시 publisher ownership lock과 idempotent publish proof가 필요.
- 2026-05-22: outbox publisher leadership = DB row-level SKIP LOCKED claim (PostgreSQL FOR UPDATE SKIP LOCKED / MySQL 8.0+ SKIP LOCKED). DB advisory lock은 SKIP LOCKED 미지원 vendor의 fallback.
- 2026-05-22: outbox row status enum = PENDING / IN_FLIGHT / PUBLISHED / FAILED / DEAD.
- 2026-05-22: event ordering guarantee = per-aggregate FIFO (aggregateId 기준 sequence). global ordering은 보장하지 않음.
- 2026-05-22: consumer-side contract = at-least-once delivery. consumer는 idempotencyKey 기반 dedupe 의무.
- 2026-05-22: outbox publisher claim transaction = `READ_COMMITTED` + `FOR UPDATE SKIP LOCKED`. claim transaction은 짧고 단일 row 단위이므로 SERIALIZABLE 불필요.
- 2026-06-11: (D11) domain event → integration event 변환은 application boundary 의 명시적 mapper 에서 수행. domain event 는 domain 타입만 담고, integration event 는 primitive 로 flatten. / 근거: ca-tmpl `WorkLogReservedIntegrationEvent` + `Mapper` (actually-implemented), [[raw/official-docs/arch-hexagonal-cockburn]]
- 2026-06-11: (D12) event envelope required fields = `eventId`, `occurredAt`, `aggregateId`, `eventType`, `correlationId`, `idempotencyKey` — CloudEvents REQUIRED 4속성(id/source/specversion/type) 과 대조해 strict superset 로 유지. correlationId/idempotencyKey 는 CloudEvents extension attribute 위상. / 근거: [[raw/official-docs/cloudevents-spec-required-attributes]]
- 2026-06-11: (D13) publish 실패 분류 = 일시 실패 → `OUTBOX_PUBLISH_FAILED` (TRANSIENT_DEPENDENCY, retryable) + status FAILED + backoff 재시도, max attempts 소진 → status DEAD + `OUTBOX_DEAD_LETTER` (INTERNAL, non-retryable). registry 기존 값 재사용 (신규 제안 아님). / 근거: ca-tmpl `error-codes.yaml` L724-749
- 2026-06-11: (D14) correlationId 는 outbox row 저장 + publish 시 message 로 전파. ID 의미·생성 SSOT 는 `feature-operational-error-observability-foundation` (mdc-keys `correlation_id`, propagation 에 `message` 포함). outbox 의 idempotencyKey 는 event 단위 dedupe key 로, API `Idempotency-Key` (rate-limit-idempotency D2 소유) 와 별개 scope.
- 2026-06-11: (D15) outbox 전용 runbook 2건 (`runbook://outbox/publish-failed`, `runbook://outbox/dead-letter` — error-codes.yaml 에 링크 선언 완료, 파일 부재) 은 outbox 구현 branch 머지 전 `docs/runbooks/` 작성 의무.
- 2026-06-12: (D16) `PublishPendingOutboxEventsUseCase` 는 Spring context bean 으로 등록하지 않음 — `OutboxConfig``outboxRelayScheduler` `@Bean` 내부에서 수동 조립 (Task E 의 "수동 @Bean" 을 "수동 조립, non-bean" 으로 수정). 이유: 클래스 레벨 `@RequiresPermission` pointcut (adapter-web `MethodSecurityConfig`) 이 bean 을 CGLIB 프록시 (final 클래스 → 기동 실패) + 비인증 스케줄러 스레드에서 fail-closed 거부 (relay 전멸). / 근거: [[raw/errors/method-security-class-pointcut-final-usecase-bean-2026-06-12]] (`locally-verified`)
- 2026-06-12: (Task 3 품질리뷰 FIX) `RedisCacheStoreTest` 2건 수정 — (a) `put_wraps_a_checked_client_failure_into_CacheBackendException``.hasMessageContaining("redis")` 단언 추가 (get 예외 테스트 동등성 확보), (b) `get_propagates_empty_on_a_miss` 신규 테스트 추가 (cache-miss 경로 검증 gap 해소). `:adapter-outbound:test '*RedisCacheStoreTest*'` 5 tests PASS. 프로덕션 코드 무변경.
- 2026-06-12: (D17) sample-portfolio 의 `V2__work_log.sql` 을 기본 `db/migration` 에서 sibling `db/sample-migration` 으로 이동 — fixture 마이그레이션은 production 의 기본 Flyway location/버전 네임스페이스를 공유하지 않는다. V3(본 branch) 적용으로 history 에 V2 구멍이 생기자 launcher 별 클래스패스 차이(Gradle 런타임 V2 비가시 vs IDE/test 클래스패스 V2 가시)로 Flyway 검증이 양방향 모두 실패. / 근거: [[raw/errors/flyway-launcher-divergent-migration-set-shared-dev-db-2026-06-12]] (`locally-verified` — Flyway 11.7.2 4-시나리오 실측)
- 2026-06-12: (outbound-http-resilience-config Tasks 1+2) `OutboundHttpSettings``Retry`/`CircuitBreaker` 중첩 record 추가 (코어 8종 튜닝 노브 외부화). 기본값은 기존 `maxAttempts=3 / 100ms×2.0 / Resilience4j ofDefaults()` 정확히 보존. 보조 6-arg 생성자로 호출부 무변경. 발견 이슈: record 에 보조 생성자 추가 시 Spring Boot constructor-binding 자동 감지 무효화 → `No default constructor found`. 해결: 8-arg canonical compact constructor 에 `@ConstructorBinding` (Spring Boot 3.x 다중 생성자 record 표준). `OutboundHttpResilience.retryFor`/`circuitBreakerFor` 가 하드코딩 대신 settings 값으로 config 빌드. 신규 `OutboundHttpResilienceTest` 4건. 커밋: `d702572` (OutboundHttpSettings nested record) + `2613561` (resilience settings-driven config). (`actually-implemented`, `locally-verified`)
- 2026-06-12: (Task 6 — CacheStore multi-backend router 조립 전환) `CacheRouterConfig` 신규 생성 + `RedisCacheAdapterConfig` 전체 교체 + `DisabledCacheStore` 삭제. sentinel 패턴(per-backend disabled bean)을 router 패턴(무경계 백엔드 기여 + `CacheStoreRouter` Layer 3 fail-fast)으로 전환. `ObjectProvider<Map<String,CacheStore>>` 로 zero-backend 허용 (required map injection 은 L262 위반 — Spring 4.3+ 이름별 맵 주입이 빈 0개 컨텍스트에서 missing-bean 예외를 내므로 `ObjectProvider`로 감싸 `getIfAvailable(Map::of)` 사용). `CacheRouterConfig.@EnableConfigurationProperties(CacheBindingSettings.class)``CaSkeletonApplication.@ConfigurationPropertiesScan` 은 runner 테스트에서 활성화되지 않아 runner 슬라이스에서 `settings` bean 누락 방지. TDD red: `compileTestJava` 2 symbol errors (`CacheRouterConfig` 미정의). 빌드: `:adapter-outbound:test` 137 PASS (0 failures, 0 errors) — 게이팅 6건 (disabled 기본 / redis 라우팅 / 2-백엔드 OCP / 모순 바인딩 startup-fail / kafka / slack / google-email) + sentinel 3건 (kafka + 라우터 D4 2건) 모두 통과. `ObjectProvider` fallback 사용: 사용됨 (zero-backend + N-backend 컨텍스트 모두 통과 확인). (`actually-implemented`, `locally-verified`)
## 판정 기준
| 구분 | 기준 |
| --- | --- |
| Decision | domain event와 integration event를 분리 |
| Allowed | 외부 발행 없는 내부 event는 outbox 생략 |
| Forbidden | domain event에 Kafka topic, HTTP endpoint, Slack channel 같은 transport detail 포함 |
| Required fields | eventId, occurredAt, aggregateId, eventType, correlationId, idempotencyKey |
| Failure condition | publish 실패가 retry/DLQ/log/runbook 기준 없이 삼켜지면 실패 |
## Outbox Defaults
| item | default |
| --- | --- |
| storage | DB outbox table with `eventId`, `aggregateId`, `eventType`, `payload`, `occurredAt`, `status`, `attemptCount`, `nextAttemptAt`, `correlationId`, `idempotencyKey` |
| publisher | single app process publisher |
| broker | none required in core |
| DLQ | background-job branch owner |
| multi-instance | requires ownership lock + duplicate publish idempotency |
## Decisionized Work Items
| item | Decision | Allowed | Forbidden | Required test |
| --- | --- | --- | --- | --- |
| leadership | DB row-level SKIP LOCKED claim (PostgreSQL FOR UPDATE SKIP LOCKED / MySQL 8.0+ SKIP LOCKED) | advisory lock fallback (SKIP LOCKED 미지원 vendor) | Redis/Zookeeper 등 외부 coordination service 의존 | multi-instance에서 동일 outbox row가 한 publisher에게만 claim됨을 verify |
| row status | PENDING / IN_FLIGHT / PUBLISHED / FAILED / DEAD enum | — | undocumented status 사용 | status enum contract test |
| ordering | per-aggregate FIFO (aggregateId sequence) | aggregate별 독립 publisher | global ordering 보장 주장 | aggregate FIFO test |
| consumer delivery | at-least-once + idempotencyKey dedupe | — | exactly-once 주장, dedupe 없는 consumer | consumer dedupe test |
## 결정-근거 매핑
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. company-tech-blog 는 `company-case-study` 라벨 (official best practice 단정 금지).
| Decision ID | Decision (요약) | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|---|---|---|---|---|---|
| D1 | domain event 는 transport detail (Kafka topic, HTTP endpoint, Slack channel) 을 모름 | 항상 (skeleton 불변식 — 내부 in-process 소비 전용 event 도 동일). 대안 없음, 위반은 Forbidden | `raw/official-docs/domain-event-fowler-eaa.md#DOMAIN-EVT-FOWLER-C1` (domain event = 도메인 사실의 기억), `#DOMAIN-EVT-FOWLER-C2` (application state 변경 포착 + Audit Log 저장 목적), `#DOMAIN-EVT-FOWLER-C3` (second layer ignorant of input source). **주의**: C1~C3 는 정의 설명이며 "transport detail 포함 금지" prescriptive claim 을 Fowler 가 직접 말하지는 않음 — transport-independence 는 해석. ca-tmpl 구현: `@DomainEvent` annotation (domain-core) + ArchUnit `domain_events_are_transport_free`/`domain_events_are_records` (`actually-implemented`, 2026-06-11 코드 확인) | `engineering-blog` (Fowler EAA Dev — personal pattern catalog, draft 상태 명시) + `actually-implemented` (ca-tmpl 계약 코드) | Eric Evans DDD 또는 Vaughn Vernon IDDD 의 domain event 정의 raw 별도 수집 필요 (official 강도 격상 조건) |
| D2 | transaction 과 외부 publish 의 원자성이 필요하면 outbox 를 기본 기준 (dual-write 금지) | DB 상태 변경 + 외부 발행이 한 use case 에 공존할 때 outbox. 외부 발행 없는 내부 event 는 outbox 생략 (§판정 기준 Allowed). lag 수 초 허용 불가 또는 Kafka Connect 운영 인력 확보 시 → 대안 1 (Debezium CDC) migration | `raw/official-docs/transactional-outbox-aws-prescriptive-guidance.md#OUTBOX-AWS-C1` (dual-write 문제 정의), `#OUTBOX-AWS-C2` (DB update + event notification 원자성 요구), `#OUTBOX-AWS-C3` (동일 transaction outbox insert + 실패 시 전체 rollback), `raw/official-docs/dual-write-antipattern-microservices-io.md#DUAL-WRITE-C1` (DB+broker distributed transaction not viable), `#DUAL-WRITE-C2` (2PC 없는 순차 쓰기의 inconsistency), `#DUAL-WRITE-C3` (process crash 시 inconsistent state), `raw/official-docs/microservices-io-transactional-outbox.md#MSIO-OUTBOX-C1`~`C3`, `#MSIO-OUTBOX-C5`, `#MSIO-OUTBOX-C7` (dual-write 문제 + 동일 트랜잭션 저장 + if-and-only-if commit 발행), `raw/official-docs/outbox-debezium-official-docs.md#OUTBOX-DBZ-C2` (CDC 가 polling 비용 회피 — 대안 비교 축) | `official-vendor-doc` (OUTBOX-AWS-C1~C3 — 2026-06-11 self-grep 검증 수집) + `engineering-blog` (MSIO Richardson — personal pattern catalog) + `needs-confirmation` (DUAL-WRITE-C1~C3 — verbatim 재확인 전, OUTBOX-DBZ — §Audit QUOTE_DRIFT) | OUTBOX-DBZ-C1~C4 는 2026-06-11 재검증 결과 현행 페이지·2019 블로그 어디에도 verbatim 부재 (paraphrase 판정 — §Audit & Findings). 실질 내용은 corroborate 됨. AWS 수집으로 official-vendor-doc 격상 완료 (기존 Open Risk 해소) |
| D3 | broker 는 Kafka 를 강제하지 않음. core 는 broker-agnostic outbox 만 제공하고 Kafka 는 optional integration adapter | skeleton 기본. Kafka 운영이 확정된 배포는 `APP_MESSAGING_KAFKA_ENABLED=true` 로 adapter 활성화 (env key owner: feature-integration-adapter-templates) | `raw/official-docs/outbox-debezium-official-docs.md#OUTBOX-DBZ-C1` (Outbox Event Router SMT 가 Kafka 전제 — ca-tmpl 이 이 의존성을 거부). ca-tmpl 구현: `MessagePublisher` port + `OutboundMessage(topic,key,payload)` (broker-중립) + `KafkaMessagePublisher`/`KafkaAdapterConfig` `@ConditionalOnProperty(app.messaging.kafka.enabled)` (`actually-implemented`, 2026-06-11 코드 확인) | `actually-implemented` (port/adapter 분리 코드) + `needs-confirmation` (OUTBOX-DBZ-C1 — §Audit QUOTE_DRIFT) | Kafka 외 broker (RabbitMQ / NATS / SQS) 의 outbox 적용 사례 raw 미수집 — broker-agnostic 가능성 일반화는 외부 근거 부족 |
| D4 | outbox publisher leadership = DB row-level SKIP LOCKED claim (PostgreSQL FOR UPDATE SKIP LOCKED / MySQL 8.0+ SKIP LOCKED). DB advisory lock 은 SKIP LOCKED 미지원 vendor fallback | 대상 DB 가 PostgreSQL 또는 MySQL 8.0+ 일 때 기본. SKIP LOCKED 미지원 vendor → advisory lock fallback. Redis/Zookeeper 등 외부 coordination 은 Forbidden (§Decisionized Work Items) | `raw/official-docs/skip-locked-postgres-docs.md#SK-PG-C1` (SKIP LOCKED 의 정확한 동작: 즉시 lock 못 잡는 row skip), `#SK-PG-C2` (queue-like table multiple consumer lock contention 회피 — Postgres 공식이 명시한 적용 영역), `raw/official-docs/skip-locked-mysql-docs.md#SK-MYSQL-C1` (MySQL: locked row 를 result set 에서 제거, 대기 없음), `#SK-MYSQL-C2` (inconsistent view 경고 + queue-like table use case — PostgreSQL 과 동등 wording, 2026-06-11 수집) | `official-vendor-doc` (SK-PG-C1/C2 + SK-MYSQL-C1/C2 — 양 vendor 공식 문서 확보) | `#SK-PG-C3` (FOR UPDATE / FOR NO KEY UPDATE 와 SKIP LOCKED 결합 가능) 은 `needs-confirmation` — user 수집본 wording 이 2026-05-27 페이지에서 동일 문장 미발견. advisory lock fallback 메커니즘은 cited raw 에 verbatim 없음 → `UNSUPPORTED_IMPL_DECISION` (trade-off: SKIP LOCKED 미지원 vendor 는 skeleton 1차 지원 대상 아님 — fallback 은 방향만 명시) |
| D5 | outbox row status enum = PENDING / IN_FLIGHT / PUBLISHED / FAILED / DEAD | N/A (단일 enum 고정 — 변형 금지, undocumented status 는 Forbidden) | (UNSUPPORTED_DECISION — cited raw 중 status enum 표준 verbatim 없음. ca-tmpl 내부 결정. trade-off: 외부 표준이 없는 영역이므로 registry 를 SSOT 로 고정하는 것이 최선) registry 정합: `metrics.yaml` `outbox.pending.size` 의 status tag 5종과 일치 + `OUTBOX_PUBLISH_FAILED`/`OUTBOX_DEAD_LETTER` 코드와 FAILED/DEAD 대응 (2026-06-11 확인, drift 없음) | `internal-policy` + `internal-contract-registry` (registry 와 정합 확인) | status enum 명세는 ca-tmpl 내부 결정 — 외부 권위 근거 미수집 (AWS Prescriptive Guidance 도 status column 구체 enum 은 prescribe 안 함) |
| D6 | event ordering guarantee = per-aggregate FIFO (aggregateId 기준 sequence). global ordering 보장 안 함 | 기본. strict/global ordering 요구가 생기면 → partition key + 단일 publisher 또는 CDC 전환 검토 (운영 해석) | `raw/official-docs/skip-locked-postgres-docs.md#SK-PG-C2` ("inconsistent view" 명시 — global ordering 보장 안 됨), Usage Boundaries: "순서 보장 — SKIP LOCKED 는 순서를 보장하지 않으며 inconsistent view 라는 명시적 경고", `raw/official-docs/skip-locked-mysql-docs.md#SK-MYSQL-C2` (MySQL 동일 경고) | `official-vendor-doc` (global ordering 비-보장만 명시) | per-aggregate FIFO 자체는 ca-tmpl 내부 결정 — Postgres 공식이 prescribe 안 함. FIFO 강제 메커니즘은 §구현 가이드 4 의 `UNSUPPORTED_IMPL_DECISION` |
| D7 | consumer-side contract = at-least-once delivery. consumer 는 idempotencyKey 기반 dedupe 의무 | 항상 (at-least-once 는 polling outbox 의 구조적 결과). exactly-once 요구 → 본 패턴으로 불충족, exactly-once 주장은 Forbidden | `raw/official-docs/transactional-outbox-aws-prescriptive-guidance.md#OUTBOX-AWS-C5` (duplicate messages 가능 — consumer idempotent 권고, processed message tracking), `raw/official-docs/outbox-debezium-official-docs.md#OUTBOX-DBZ-C4` (at-least-once delivery + consumer idempotency 필수 — paraphrase, §Audit), `raw/official-docs/skip-locked-postgres-docs.md` Usage Boundaries: "처리 중 worker 크래시 시 row 재선택 가능 → at-least-once", `raw/official-docs/microservices-io-transactional-outbox.md#MSIO-OUTBOX-C7` Usage Boundary (relay 재발행 가능 — consumer 측 idempotency 필요) | `official-vendor-doc` (OUTBOX-AWS-C5 + SKIP LOCKED 시맨틱) + `engineering-blog` (MSIO) + `needs-confirmation` (OUTBOX-DBZ-C4 — §Audit QUOTE_DRIFT) | consumer 측 dedupe 메커니즘 (idempotency key TTL / scope / storage) 은 cited raw 범위 밖 — consumer 구현 branch 결정 영역 (§엣지·실패·의존) |
| D8 | outbox publisher 는 single-instance 기본. multi-instance 활성화 시 publisher ownership lock + idempotent publish proof 필요 | single-instance 기본. `APP_MULTI_INSTANCE_ENABLED=true``outboxLeaderElection` bean 필수 (부재 시 기동 실패) | `raw/official-docs/skip-locked-postgres-docs.md#SK-PG-C2` (multiple consumer 시나리오에 SKIP LOCKED 적합). ca-tmpl 구현: `StartupSafetyValidator``APP_MULTI_INSTANCE_ENABLED=true` 일 때 `outboxLeaderElection` bean 요구 (검증 로직 `actually-implemented`, bean 자체는 미정의 = `planned`, 2026-06-11 코드 확인) | `official-vendor-doc` + `actually-implemented` (기동 검증측) | "publisher ownership lock" 의 구체 메커니즘은 ca-tmpl 내부 결정 — SKIP LOCKED 자체로 ownership 보장 (single-claim) |
| D9 | outbox publisher claim transaction = `READ_COMMITTED` + `FOR UPDATE SKIP LOCKED`. claim 은 짧고 단일 row 단위이므로 SERIALIZABLE 불필요 | claim query 한정. write-heavy use case 본체의 isolation 은 [[raw/branch-notes/feature-transaction-concurrency-contract]] D3 의 명시 선언 규칙 따름 | `raw/official-docs/skip-locked-postgres-docs.md#SK-PG-C1` (SKIP LOCKED 의 즉시-skip 동작 — short transaction 적합), [[raw/branch-notes/feature-transaction-concurrency-contract]] D3 (isolation level default = `READ_COMMITTED` 명시 pin — 2026-06-11 cross-reference 실존 확인) | `official-vendor-doc` (SKIP LOCKED 동작) + `internal-cross-reference` (isolation 결정은 transaction-concurrency D3 위임, 검증 완료) | MySQL InnoDB 기본 isolation 은 REPEATABLE READ — claim query 에 READ_COMMITTED 명시 pin 필요 (transaction-concurrency §Audit DRIFT-2 와 동일 주의) |
| D10 | retry/DLQ vocabulary 의 SSOT 는 `feature-background-job-async-contract`, 본 branch 는 outbox publisher consumer | N/A (위임 — 재정의 금지) | (cross-reference — [[raw/branch-notes/feature-background-job-async-contract]] D4: exponential backoff with jitter, max attempts 3, DLQ after exhausted — 2026-06-11 위임 대상 실존 확인) | `internal-cross-reference` | background-job D4 변경 시 본 branch 의 D13 status 전이 (FAILED→DEAD 시점) 가 연동 변경됨 — 비차단 전파 알림 대상 |
| D11 | domain event → integration event 변환은 application boundary 의 명시적 mapper 에서 수행 (domain event 는 domain 타입만, integration event 는 primitive flatten) | 외부 발행이 필요한 domain event 만 integration event 로 변환. 내부 in-process 소비 전용 event 는 변환 생략 | ca-tmpl 구현: `WorkLogReserved` (domain record) → `WorkLogReservedIntegrationEvent` (String/primitive record) + `WorkLogReservedIntegrationEventMapper` (sample-portfolio application/event — `actually-implemented`, 2026-06-11 코드 확인), `raw/official-docs/arch-hexagonal-cockburn.md#HEX-COCKBURN-ORIG-C4` (adapter 가 port API 를 device signal 로 양방향 변환), `raw/official-docs/domain-event-fowler-eaa.md#DOMAIN-EVT-FOWLER-C4` (immutable source data vs mutable processing data 분리) | `actually-implemented` (sample 코드) + `engineering-blog` (Cockburn/Fowler — 원칙 수준) | mapper 의 명명 규칙 (`<DomainEvent>IntegrationEvent` + `<...>Mapper`) 은 sample 1건에서 귀납 — 계약 명문화는 `UNSUPPORTED_IMPL_DECISION` (trade-off: sample 패턴 답습이 신규 규칙 발명보다 안전) |
| D12 | event envelope required fields = `eventId`, `occurredAt`, `aggregateId`, `eventType`, `correlationId`, `idempotencyKey` | 모든 integration event envelope 에 적용. CloudEvents 호환 전송이 필요해지면 §구현 가이드 2 의 속성 매핑 사용 | `raw/official-docs/cloudevents-spec-required-attributes.md#CLOUDEVT-C1` (REQUIRED = id/source/specversion/type 4개), `#CLOUDEVT-C2` (source+id 가 event 고유성 — consumer 는 동일 source+id 를 duplicate 로 간주 가능), `#CLOUDEVT-C3` (time 은 OPTIONAL — ca-tmpl 은 occurredAt 을 required 로 강화), `#CLOUDEVT-C4` (correlationId/idempotencyKey 는 core 밖 — extension attribute 로만 가능), `#CLOUDEVT-C5` (subject ≈ aggregateId 위상) | `official-vendor-doc` (CNCF 표준 spec 대조) + `internal-policy` (correlationId/idempotencyKey required 화는 ca-tmpl 강화 결정 — trade-off: 운영 추적성과 dedupe 를 위해 표준보다 엄격하게) | CloudEvents 전송 채택 시 attribute 명명 제약 (`[a-z][a-z0-9]*``correlationid`/`idempotencykey` 소문자 강제) 반영 필요. specversion/source 대응 필드 부재는 CloudEvents 호환 전송 시 보강 필요 |
| D13 | publish 실패 분류 = 일시 실패 → `OUTBOX_PUBLISH_FAILED` (TRANSIENT_DEPENDENCY, retryable=true, retry_after 30s) + FAILED + backoff 재시도 / max attempts 소진 → DEAD + `OUTBOX_DEAD_LETTER` (INTERNAL, retryable=false). **Scope: publish failures (broker) only.** Status-update failures (markPublished/markFailed/markDead throwing) are NOT publish failures — they propagate out of handle() to the scheduler catch; row stays IN_FLIGHT and is recovered via orphan visibility-timeout reclaim (2026-06-11 fix dispatch). | publish 예외 발생 시 항상 이 분류. 재시도 가능 여부 판단이 모호한 예외는 TRANSIENT 로 분류 후 attempts 소진에 위임 | ca-tmpl `docs/registries/error-codes.yaml` L724-749 (`OUTBOX_PUBLISH_FAILED` category TRANSIENT_DEPENDENCY / `OUTBOX_DEAD_LETTER` category INTERNAL — registry 기존 값 재사용, owner_branch 본 branch), [[raw/branch-notes/feature-background-job-async-contract]] D4 (max attempts 3: `SPRING-RETRY-C1` `official-vendor-doc` 확인됨; DLQ after exhausted: UNSUPPORTED — Spring Retry README 미언급, 외부 reference 필요 — vocabulary 위임), `raw/official-docs/transactional-outbox-aws-prescriptive-guidance.md#OUTBOX-AWS-C5` (duplicate/실패 처리 공식 권고) | `internal-contract-registry` (registry SSOT 값) + `internal-cross-reference` (backoff vocab — max attempts `official-vendor-doc`, DLQ `unsupported`) + `official-vendor-doc` (AWS) | **2026-06-11 Task A 완료**: `OUTBOX_PUBLISH_FAILED`/`OUTBOX_DEAD_LETTER``OperationalError` enum 에 추가됨 (`actually-implemented`, `locally-verified``./gradlew :shared-contract:test` PASS + `./gradlew :app-bootstrap:test --tests '*ErrorCodeRegistryMappingTest'` PASS). `OUTBOX_DEAD_LETTER``INTERNAL` 이지만 `retryable=false``internal_category_codes_are_retryable` 테스트의 exclusion 목록에 추가됨 (동일 패턴: `INTERNAL_AUTH_MISCONFIGURATION`, `ADAPTER_DISABLED`). category 는 코드 enum `shared/error/Category.java` 의 TRANSIENT_DEPENDENCY/INTERNAL 와 정합. runbook 링크 2건은 파일 부재 → D15. DLQ after exhausted 외부 reference 미수집 — background-job D4 잔여 UNSUPPORTED |
| D14 | correlationId 는 outbox row 저장 + publish 시 message 전파. idempotencyKey 는 event 단위 dedupe key (API `Idempotency-Key` 와 별개 scope) | N/A (저장+전파 항상). ID 의미·생성 규칙이 바뀌면 owner branch 가 전파 | ca-tmpl `docs/registries/mdc-keys.yaml` `correlation_id` (propagation: `[http, async, message]` — message 경계 전파가 registry 에 이미 선언, owner: feature-operational-error-observability-foundation), `headers.yaml` `X-Correlation-Id` (동일 owner). API Idempotency-Key 는 [[raw/branch-notes/feature-rate-limit-idempotency-contract]] D2 소유 (producer-side 4-tuple scope) — outbox idempotencyKey 와 무관함을 명시 | `internal-contract-registry` + `internal-cross-reference` (의미 SSOT 는 foundation branch — reference-only) | consumer 측 dedupe storage/TTL 은 본 branch 범위 밖 (consumer 구현 영역). correlationId 의 broker message header 명명은 `UNSUPPORTED_IMPL_DECISION` (trade-off: 채택 broker 별 header 규약이 달라 구현 시 결정) |
| D15 | outbox runbook 2건 (`runbook://outbox/publish-failed`, `runbook://outbox/dead-letter`) 은 outbox 구현 branch 머지 전 `docs/runbooks/` 작성 의무 | outbox 구현 착수 시점에 작성 (현재 `planned`) | ca-tmpl `error-codes.yaml` 의 두 코드가 runbook_link 를 이미 선언 — 파일은 `docs/runbooks/` 에 부재 (2026-06-11 확인 — 기존 runbook 5종에 outbox 없음) | `internal-contract-registry` (링크 선언) | runbook 본문 구조 (증상/진단/완화) 는 [[raw/branch-notes/feature-operational-runbook-contract]] 계약 따름 — 본 branch 는 작성 의무만 정의 |
| D16 | relay use case (`PublishPendingOutboxEventsUseCase`) 는 context bean 으로 등록하지 않고 `OutboxConfig.outboxRelayScheduler` `@Bean` 내부에서 수동 조립. `public final class` 유지. `outbox:relay` 권한 집행은 convention (런타임 미집행) | 클래스 레벨 `@RequiresPermission` pointcut 이 활성인 컨텍스트에서 스케줄러/배치 전용 use case 일 때. 대안: 스케줄러에 시스템 principal SecurityContext 를 세우고 role registry 에 `outbox:relay` 매핑 → 런타임 집행이 실제로 필요해지면 (보안 설계 확장 — 리뷰 체인 결정) | [[raw/errors/method-security-class-pointcut-final-usecase-bean-2026-06-12]] — bean 등록 시 CGLIB `Cannot subclass final class` 기동 실패 + final 제거 후 매 틱 `AuthenticationCredentialsNotFoundException` 재현·해소 기록. ca-tmpl 구현: `OutboxConfig`/`OutboxRelayScheduler`/use case Javadoc 제약 명시 (`actually-implemented`) | `locally-verified` (bootRun 3회 + healthcheck 200 + relay 3틱 ERROR 0 + `:application-core:test`·`:app-bootstrap:test` 224/224·ArchUnit 48 rules green) + `internal-policy` (UNSUPPORTED_DECISION — 외부 raw claim 없음. trade-off: 선언적 권한은 D4 ArchUnit 충족용이며 스케줄러 경로 런타임 집행 포기) | 권한 미집행 상태가 영구화될 위험 — 시스템 principal 설계 채택 여부를 리뷰 체인에서 명시 결정 필요. 풀 컨텍스트 smoke 테스트 부재로 동류 배선 결함은 bootRun 에서만 검출됨 (개선 후보) |
| D17 | fixture 마이그레이션은 production 의 기본 Flyway location 을 공유하지 않는다 — `V2__work_log.sql``db/migration``db/sample-migration` (sibling, 기본 스캔 비대상) 으로 이동. 활성화는 `spring.flyway.locations` 에 location 명시 추가로 opt-in; 로컬 dev 의 sample 스키마는 ddl-auto=update 담당 | launcher 별 클래스패스 차이(테스트 전용 의존 모듈)가 존재하고 공유 long-lived DB 를 쓸 때 항상. 대안들: (a) outOfOrder 보정 — FLYWAY-C5 (`out-of-order: false` pinned) 위반 + 반대 방향(applied-not-resolved) 재실패 실측으로 기각, (b) app-bootstrap 의 sample runtime 의존 추가 — `production_code_does_not_depend_on_sample_portfolio` ArchUnit/모듈 매트릭스 위반으로 기각, (c) DB 리셋 — 클래스패스 비대칭이 남아 재발하므로 기각 | [[raw/errors/flyway-launcher-divergent-migration-set-shared-dev-db-2026-06-12]] — Flyway 11.7.2 스크래치 DB 4-시나리오 실측 (resolved-not-applied / applied-not-resolved 양방향 fatal 확인). V2 소비자 전수 조사 (샘플 테스트 mock-only, OutboxContainerTestSupport 는 outbox 테이블만, locations 미지정, compose init 없음) (`actually-implemented`) | `locally-verified` (이동 후 bootRun 3.324s + healthcheck 200 + `:sample-portfolio:test` 129/129 + `:app-bootstrap:test` 224/224) + `internal-policy` (UNSUPPORTED_DECISION — location 분리 규칙 자체의 외부 권위 raw 미수집. trade-off: Flyway 재귀 스캔 특성상 sibling location 이 유일한 안전 격리) | IDE 가 이전 빌드 산출물의 V2 사본을 캐시하면 1회 더 실패 가능 (Java 프로젝트 reload 필요). fork 프로젝트가 sample 을 런타임에 켤 때 location 추가를 잊으면 work_log 스키마 부재 — V2 헤더에 명시했으나 기동 가드는 없음 |
## 구현 가이드
> *결정 (Decisions)* 이 "*무엇*" 이라면 본 §는 "*어디에 어떻게*" 의 사전 명세. 모든 cell 은 Decision ID + Supporting Claim 의 도출 (R1). 근거 없는 detail 은 `UNSUPPORTED_IMPL_DECISION` (R2). 본 branch 범위 밖 detail 은 두지 않음 (R3).
### 1. 모듈·클래스 배치 (domain event 분리 계약)
> **Trace**: D1 (`DOMAIN-EVT-FOWLER-C1~C3`) + D3 + D11 (`HEX-COCKBURN-ORIG-C4`) — ca-tmpl 코드 2026-06-11 grep 확인.
>
> - **UNSUPPORTED_IMPL_DECISION**: outbox poller 의 모듈 배치 — DB claim (adapter-persistence 영역) 과 broker publish (adapter-outbound 영역) 를 한 컴포넌트가 수행해야 하므로 adapter 간 의존이 생김. 근거 raw 없음. trade-off: app-bootstrap 조립(wiring)으로 두 adapter 를 묶는 방향이 layer 규칙 (`app-bootstrap -> adapter-*`) 과 정합하나, 최종 배치는 구현 branch 에서 결정.
| 항목 | 위치 (module / path) | 증거 등급 | Trace |
|---|---|---|---|
| `@DomainEvent` marker annotation (record 강제 + transport-free) | `domain-core` `dev/caskeleton/domain/stereotype/DomainEvent.java` | `actually-implemented` | D1 |
| domain event 예시 (`WorkLogReserved` — domain 타입만) | `sample-portfolio` `domain/worklog/WorkLogReserved.java` | `actually-implemented` | D1, D11 |
| integration event + mapper (`WorkLogReservedIntegrationEvent` + `Mapper` — primitive flatten + `toJson` hand-rolled JSON serialisation + `EVENT_TYPE="worklog.reserved"`) | `sample-portfolio` `application/event/` | `actually-implemented`, `locally-verified` | D11 |
| `OutboxEventIdFactory` domain port + `UlidOutboxEventIdFactory` adapter (ULID-backed, 동일 `UlidCreator.getMonotonicUlid()` 메커니즘, application layer UlidCreator 차단 준수) | `sample-portfolio` `domain/worklog/` + `adapter/identifier/` | `actually-implemented`, `locally-verified` | I12, D2 |
| `CreateWorkLogUseCase` outbox wiring (D2 same-tx append: `repository.save` + `OutboxAppendPort.append` 동일 `tx.inWrite` 내, D11 mapper, correlationId MDC fallback to eventId self-correlation, I12 idempotencyKey=eventId) | `sample-portfolio` `application/worklog/CreateWorkLogUseCase.java` | `actually-implemented`, `locally-verified` | D2, D11, I12 |
| consumer dedupe contract test `WorkLogReservedConsumerDedupeContractTest` (동일 idempotencyKey 5회 전달 → 처리 1회) | `sample-portfolio` `test/.../application/event/` | `actually-implemented`, `locally-verified` | D7 |
| transport-free 강제 (ArchUnit `domain_events_are_records` / `domain_events_are_transport_free` + violation fixtures: Kafka/SpringHttp/JaxRs/NonRecord) | `app-bootstrap` `architecture/CleanArchitectureTest.java` | `actually-implemented` | D1 |
| `MessagePublisher` port + `OutboundMessage(topic, key, payload)` (broker-중립) | `adapter-outbound` `messaging/` | `actually-implemented` | D3 |
| `KafkaMessagePublisher` (fail-open: publish 실패 log+correlationId, 미전파) + `KafkaAdapterConfig` `@ConditionalOnProperty("app.messaging.kafka.enabled")` | `adapter-outbound` `messaging/kafka/` | `actually-implemented` | D3, D14 |
| `NewOutboxEvent`, `OutboxEvent`, `OutboxEventStatus`, `OutboxAppendPort`, `OutboxStorePort`, `OutboxMessagePublishPort`, `OutboxBackoffPolicy`, `OutboxRelayResult`, `PublishPendingOutboxEventsCommand` (value objects + outbound ports + relay contracts) | `application-core` `dev/caskeleton/application/outbox/` | `actually-implemented`, `locally-verified` | D2, D4, D5, D6, D7, D10, D12, D13 |
| `PublishPendingOutboxEventsUseCase` (relay use case — claim short tx, publish outside tx, FAILED/DEAD state machine) | `application-core` `dev/caskeleton/application/outbox/` | `actually-implemented`, `locally-verified` | D4, D6, D8, D9, D13 |
| `OutboxEventEntity` (JPA entity, no AuditableEntity — infra record D6), `OutboxEventJpaRepository` (SKIP LOCKED native claim query + deletePublishedBefore + countGroupedByStatus + findOldestUnpublishedOccurredAtByEventType), `OutboxStoreAdapter` (OutboxAppendPort + OutboxStorePort — no @Transactional, caller owns TX), `OutboxReaper` (@Scheduled(fixedDelayString="${ca-skeleton.outbox.reaper-interval:PT10M}") + @Value("${ca-skeleton.outbox.published-retention:P7D}") Duration retention — FIX dispatch: PT1H→PT10M + @Value added), `V3__outbox_event.sql` migration (5 indexes incl. partial ix_outbox_event_eligible, ix_outbox_event_published_occurred) | `actually-implemented`, `locally-verified` | D2, D4, D5, D6 |
| `outboxLeaderElection` bean (이름은 `StartupSafetyValidator` 가 요구 — bean 정의 부재) | `app-bootstrap` `runtime/StartupSafetyValidator.java` (검증측만 존재) | 검증 로직 `actually-implemented` / bean `planned` | D8 |
### 2. Outbox row schema — CloudEvents 대조
> **Trace**: D5 (registry 정합) + D12 (`CLOUDEVT-C1~C5`) + §Outbox Defaults 의 column 목록.
>
> - **UNSUPPORTED_IMPL_DECISION**: (1) 컬럼 DB 타입·인덱스 설계 (예: `(status, next_attempt_at)` 복합 인덱스) — 근거 raw 없음, trade-off: claim query 의 WHERE 절 형태(§4)에서 자연 도출되나 실측 전 확정 금지. (2) PUBLISHED row 의 TTL archive/delete 정책 — polling 채택안은 즉시 DELETE (Debezium 모델) 불가, 보존 기간은 운영 결정.
| ca-tmpl column | CloudEvents 대응 | 비고 |
|---|---|---|
| `eventId` | `id` (REQUIRED) | source+id 가 고유성 단위 (`CLOUDEVT-C2`) — consumer 는 동일 id 를 duplicate 로 간주 가능 |
| `eventType` | `type` (REQUIRED) | |
| `occurredAt` | `time` (OPTIONAL) | ca-tmpl 은 required 로 강화 (D12 internal-policy) |
| `aggregateId` | `subject` (OPTIONAL, `CLOUDEVT-C5`) | per-aggregate FIFO (D6) 의 ordering key 겸용 |
| `correlationId`, `idempotencyKey` | extension attribute (`CLOUDEVT-C4`) | CloudEvents 전송 시 `correlationid`/`idempotencykey` 소문자 제약 |
| `payload` | `data` | 직렬화 정책은 [[raw/branch-notes/feature-schema-serialization-contract]] 소유 (reference-only). PII/token/raw body 금지는 [[raw/branch-notes/feature-data-retention-privacy-contract]] allowlist 따름 |
| `status`, `attemptCount`, `nextAttemptAt` | (해당 없음 — outbox 저장 컬럼) | status enum 은 D5, 전이는 §3 |
### 3. Publish 실패 분류 → registry 매핑 (publisher state machine)
> **Trace**: D13 (`error-codes.yaml` L724-749 verbatim) + D10 (background-job D4 backoff vocab) + D5 + D15 + `OUTBOX-AWS-C5`. 계약 값 전부 registry 기존 값 재사용 — 신규 제안 없음.
| 시나리오 | status 전이 | error code (registry) | metric (registry) |
|---|---|---|---|
| claim 성공 | `PENDING``IN_FLIGHT` | — | `outbox.pending.size{status}` |
| publish 성공 | `IN_FLIGHT``PUBLISHED` | — | `outbox.publisher.published.total{outcome=PUBLISHED}`, `outbox.publisher.lag` |
| broker 일시 실패 | `IN_FLIGHT``FAILED`, `nextAttemptAt` = exponential backoff with jitter (background-job D4) | `OUTBOX_PUBLISH_FAILED` (TRANSIENT_DEPENDENCY, retryable=true, retry_after_seconds 30, log ERROR) | `outcome=FAILED` — alert P2: FAILED rate > 1% for 10m |
| max attempts (3, background-job D4) 소진 | `FAILED``DEAD` | `OUTBOX_DEAD_LETTER` (INTERNAL, retryable=false, log ERROR, runbook://outbox/dead-letter — D15) | `outcome=DEAD` |
| publisher lag 누적 | — | — | `outbox.publisher.lag` alert P2 > 60s for 10m / P1 > 300s for 5m (registry verbatim) |
### 4. Claim query 명세
> **Trace**: D4 (`SK-PG-C1/C2`, `SK-MYSQL-C1/C2`) + D6 + D9 (transaction-concurrency D3 cross-ref).
>
> - **UNSUPPORTED_IMPL_DECISION**: (1) per-aggregate FIFO 강제 메커니즘 — SKIP LOCKED 는 순서를 깨므로 (SK-PG-C2/SK-MYSQL-C2 inconsistent view), aggregate 단위 claim 직렬화 또는 sequence gating 이 필요하나 cited raw 가 prescribe 안 함. trade-off: 동일 aggregateId 의 선행 미발행 row 존재 시 후행 skip 방식이 단순하나 구현 검증 전 확정 금지. (2) batch size (LIMIT n) — 근거 없음, 운영 측정 후 결정.
- query 형태 (`actually-implemented`, 2026-06-11 Task C FIX):
```sql
SELECT * FROM outbox_event o
WHERE o.next_attempt_at <= :now
AND o.status IN ('PENDING', 'FAILED', 'IN_FLIGHT')
AND NOT EXISTS (
SELECT 1 FROM outbox_event p
WHERE p.aggregate_id = o.aggregate_id
AND p.occurred_at < o.occurred_at
AND p.status <> 'PUBLISHED'
)
ORDER BY o.occurred_at ASC
LIMIT :limit
FOR UPDATE SKIP LOCKED
```
- PENDING 즉시 eligible: `append` 가 `nextAttemptAt = occurredAt` 으로 설정 → `next_attempt_at <= now` 항상 참 (발행 시점 이후).
- DEAD 포함한 모든 non-PUBLISHED earlier sibling 이 후행을 블로킹 (strict FIFO). DEAD head 의 unblocking = runbook 수동 조치 (`UPDATE ... SET status='PUBLISHED'`).
- `NOT EXISTS` 서브쿼리 행들은 잠기지 않음 (READ_COMMITTED snapshot) — 보수적으로 블로킹 (conservative, never permissive).
- isolation: `READ_COMMITTED` 명시 pin (D9). **주의**: MySQL InnoDB 기본은 REPEATABLE READ — 묵시 default 사용 금지 (transaction-concurrency D3 Forbidden 동일).
- claim transaction 은 짧게 (claim 만) — publish 는 claim transaction 밖에서 수행 후 status 갱신 (IN_FLIGHT orphan 처리는 §엣지·실패·의존).
### 5. 기동·환경 계약
> **Trace**: D8 (`StartupSafetyValidator` actually-implemented) + D3. env key 는 전부 타 branch 소유 — 값 재사용만, 본 branch 는 신규 env key 없음.
| env key (registry) | owner branch | 본 branch 의 consume 방식 |
|---|---|---|
| `APP_MULTI_INSTANCE_ENABLED` (default false) | feature-env-driven-runtime-configuration | true 시 `outboxLeaderElection` bean 필수 — 부재 시 `REQUIRED_ADAPTER_DISABLED` 기동 실패 (검증 `actually-implemented`) |
| `APP_MESSAGING_KAFKA_ENABLED` / `APP_MESSAGING_KAFKA_BROKERS` | feature-integration-adapter-templates | Kafka adapter 활성화 시에만 `KafkaMessagePublisher` 바인딩, 아니면 `DisabledMessagePublisher` (`actually-implemented`) |
## 엣지·실패·의존
- **실패·엣지 경로**:
- publisher 가 claim 후 publish 전 crash → `IN_FLIGHT` orphan row. 기대 동작: visibility timeout 성격의 재선택 기준 필요 — `UNSUPPORTED_IMPL_DECISION` (timeout 값 근거 없음, 구현 시 결정). at-least-once 이므로 재발행 중복은 D7 의 consumer dedupe 가 흡수.
- publish 성공 후 status 갱신 전 crash → 동일 event 재발행 (at-least-once 의 구조적 원인 — `OUTBOX-AWS-C5`, SK-PG Usage Boundaries).
- broker 장기 다운 → FAILED 누적 + `outbox.pending.size` 증가 → P2 alert (§구현 가이드 3). DEAD 전이 후엔 runbook (D15) 수동 개입.
- poison event (직렬화 불가 / payload 계약 위반) → 재시도 무의미 — TRANSIENT 분류 후 attempts 소진 → DEAD (D13 선택 조건).
- 동일 aggregate 의 이벤트가 서로 다른 publisher 에 분산 claim → per-aggregate FIFO 위반 위험 (§구현 가이드 4 의 UNSUPPORTED_IMPL_DECISION — 구현 검증 필수).
- event payload 에 PII/token 혼입 → 테스트 계약 위반으로 build fail (§테스트 계약).
- **다른 계약 의존**:
- [[raw/branch-notes/feature-background-job-async-contract]] D4 — backoff/max attempts/DLQ vocabulary consume (D10, D13). D4 변경 시 본 branch FAILED→DEAD 전이 시점 연동 변경.
- [[raw/branch-notes/feature-transaction-concurrency-contract]] D3 — claim transaction isolation (D9). READ_COMMITTED pin 규칙 변경 시 claim query 명세 영향.
- [[raw/branch-notes/feature-rate-limit-idempotency-contract]] D2 — API Idempotency-Key 와 outbox idempotencyKey 의 scope 구분 (D14). 혼동 시 dedupe 의미 충돌.
- [[raw/branch-notes/feature-operational-error-observability-foundation]] — `correlation_id` 의미·생성 SSOT (D14). mdc-keys `propagation: [http, async, message]` 의 message 경계가 본 branch 의 전파 의무.
- [[raw/branch-notes/feature-data-retention-privacy-contract]] — payload PII allowlist (reference-only). [[raw/branch-notes/feature-schema-serialization-contract]] — payload 직렬화 정책 (reference-only).
- feature-env-driven-runtime-configuration / feature-integration-adapter-templates — env key 소유 (§구현 가이드 5).
- [[raw/branch-notes/feature-operational-runbook-contract]] — runbook 본문 구조 계약 (D15).
## 테스트 계약
- domain package가 messaging client/type을 import하면 실패.
- outbox required use case에서 DB commit 후 event publish 유실 가능성이 있으면 실패.
- event payload에 PII/token/raw body가 포함되면 실패.
- Kafka topic/broker detail이 domain event에 들어가면 실패.
- multi-instance publisher lock claim consistency: env `APP_MULTI_INSTANCE_ENABLED=true`이면 outbox publisher가 `FOR UPDATE SKIP LOCKED` query를 사용해 row를 claim하고, 동일 row가 두 publisher instance에서 동시 claim되지 않음을 contract test에서 verify. 측정 방법: contract test `OutboxPublisherLeaderElectionContractTest`에서 2개 Spring context를 띄우고 동일 outbox row 1000개에 대해 publish 시 각 instance의 publish 횟수 합 = row 수 (중복 0) verify.
## 검증해야 할 주장
> 공식 문서 인용이 결정의 근거이지만, ca-tmpl 자체 구현에서의 동작은 별도 검증이 필요한 주장.
| Claim | Why uncertain | How to verify | Status |
|---|---|---|---|
| Debezium Outbox SMT 인용 4건 (OUTBOX-DBZ-C1~C4) 의 verbatim 재확인 | 2026-05-27 debezium.io WebFetch HTTP 403 차단 (UA 차단 추정) — 1차/버전핀/블로그 모두 403. **2026-06-11 갱신**: curl(browser UA) 로 stable 문서 + 2019 블로그 모두 HTTP 200 수신했으나 **4건 인용문이 양쪽 어디에도 verbatim 부재** — paraphrase 판정 (§Audit & Findings QUOTE_DRIFT). 실질 내용은 다른 문장으로 corroborate 됨 (aggregatetype 기반 topic routing / "at least once" semantics / log tailing + DELETE entry) | `outbox-debezium-official-docs.md` 의 인용 4건을 현행 페이지의 실제 문장으로 재인용 (raw 문서 측 수정 — 본 branch 범위 밖) | `needs-confirmation` (격상 금지 확정) |
| `#SK-PG-C3` (FOR UPDATE / FOR NO KEY UPDATE 와 SKIP LOCKED 결합) 의 동일 wording 재확보 | 2026-05-27 페이지에서 user 수집 wording 미발견 — 페이지 구조상 lock_strength 4종 SKIP LOCKED 결합 가능 추정, 별도 인용 재정리 필요 | `https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE` 의 현행 wording 으로 SK-PG-C3 verbatim 재인용 | `needs-confirmation` |
| MySQL 8.0+ SKIP LOCKED 시맨틱이 PostgreSQL `#SK-PG-C1`/`#SK-PG-C2` 와 동등 (D4 의 vendor 일반화) | cited raw 는 PostgreSQL 한정 — MySQL 8.0+ 동등성 별도 보장 필요 | MySQL 8.0+ Reference Manual SKIP LOCKED 섹션 raw 수집 후 PostgreSQL 과 시맨틱 대조 — **2026-06-11 해소**: [[raw/official-docs/skip-locked-mysql-docs]] `SK-MYSQL-C1/C2` 수집·self-grep 검증, "inconsistent view"/queue-like table wording 이 PostgreSQL 과 실질 동일 확인 | `verified` (2026-06-11) |
| outbox row 가 2 publisher instance 에서 동시 claim 되지 않음 (D4/D8 contract test: `OutboxPublisherLeaderElectionContractTest`) | `#SK-PG-C1` 은 SKIP LOCKED 동작만 보장 — ca-tmpl publisher 구현의 race condition 별도 검증. outbox 인프라 자체가 미구현 (2026-06-11 src grep — 코드 부재) | 2개 Spring context + 동일 outbox row 1000개 publish 후 각 instance 발행 횟수 합 = 1000 (중복 0) 단언 | `verified` (2026-06-11 Task E — `OutboxPublisherLeaderElectionContractTest` PASS, 1000 rows × 2 ctx, duplicates=0) |
| outbox row 즉시 DELETE 가능 (Debezium OUTBOX-DBZ-C3 의 transaction log capture 가정) 이 SKIP LOCKED polling 채택안 (ca-tmpl) 에서는 적용 안 됨 | OUTBOX-DBZ-C3 은 CDC 전제 — polling 채택안에서는 row 보존 + status 전이가 필요 | row lifecycle test: PENDING → IN_FLIGHT → PUBLISHED 후 TTL 기반 archive/delete 정책 단언 | `verified` (2026-06-11 Task E — `OutboxRowLifecycleContractTest.reaper_deletes_published_rows_older_than_retention` PASS, `pending_row_transitions_to_published_on_successful_relay` PASS) |
| dual-write antipattern raw 의 claim ID 가 D2 의 "outbox 도입 근거" 와 일치 | `dual-write-antipattern-microservices-io.md` 의 claim ID 본 세션 grep 미수행 — **2026-06-11 해소**: `DUAL-WRITE-C1~C3` grep 확인 (distributed tx not viable / 2PC 없는 inconsistency / crash 시 inconsistent state), D2 Supporting Claims 에 연결 완료. 단 해당 raw 의 strength 칼럼은 `needs-confirmation` (verbatim 재확인 전) | (해소 — D2 행 참조) | `verified` (claim ID 연결, 2026-06-11) |
| domain event 가 transport detail (Kafka topic, HTTP endpoint, Slack channel) 을 import 하지 않음 (D1 contract test) | (구) UNSUPPORTED_DECISION — **2026-06-11 갱신**: ca-tmpl 에 ArchUnit rule `domain_events_are_transport_free` + violation fixtures (Kafka/SpringHttp/JaxRs) 가 이미 존재 — `actually-implemented` (코드 grep 확인) | `app-bootstrap` `CleanArchitectureTest` 실행 green 확인 (로컬 검증 시 `locally-verified` 격상) | `actually-implemented` |
| event payload 에 PII/token/raw body 포함 검사 | cited raw 는 payload safety prescribe 안 함 — PII allowlist 는 data-retention-privacy branch 소유, 본 branch 는 검사 의무만 정의 | ArchUnit + 정규식 기반 test: payload class field 중 `email`, `password`, `token`, `Authorization` 패턴 detect 시 fail | `verified` (2026-06-11 Task E — `EventPayloadPiiContractTest` red+green PASS; PII pattern `(?i)(email|password|token|authorization|secret|rawbody)`) |
| consumer-side idempotency dedupe 메커니즘이 at-least-once 시나리오에서 실제로 중복 차단 (D7 contract test) | OUTBOX-DBZ-C4 verbatim 재확인 보류 + dedupe 구현은 consumer 측 | consumer integration test: 동일 idempotencyKey event 5회 전송 → DB 처리 row 1개 단언 | `planned` |
| Spring `@TransactionalEventListener` (대안 3 in-process only) 의 시맨틱 verbatim | cited raw `spring-transactional-event-listener` 의 claim ID 본 세션 grep 미수행 — **2026-06-11 해소**: `TX-EVT-C1~C5` grep 확인 (`official-vendor-doc` strength — AFTER_COMMIT default, no-transaction 시 미호출 + fallbackExecution). 대안 3 이 "publish 유실 가능" (AFTER_COMMIT 후 process crash 시 재발행 메커니즘 없음 — TX-EVT-C4 의 transaction 부재 시 미호출과 결합) 으로 outbox 미채택 근거 보강 | (해소 — §외부 근거 대안 3 참조) | `verified` (claim ID 연결, 2026-06-11) |
| 우아한형제들 / Wix / Confluent / Netflix outbox 사례 (company-tech-blog) 가 ca-tmpl 환경 가정 (lag 수 초 허용 + Kafka Connect 운영 인력 부재 + DB SSOT) 과 일치 | company-case-study 4종은 각 조직의 사례 — official best practice 아님. ca-tmpl 환경 적합성 별도 검증 | 각 사례의 운영 컨텍스트 (traffic, SLA, infra) 와 ca-tmpl 가정 비교 표 작성 | `planned` |
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
> 2026-06-11 coverage-auditor 생성 (verdict: Covered, Blocking 0). governing doc: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]].
| 관심사 | 상태 | owner | 심각도 | 근거 |
|--------|------|-------|--------|------|
| domain event / integration event 분리 | covered-here | — | — | D1, D11 (구현 가이드 §1 — `actually-implemented`) |
| outbox 도입 기준 (dual-write 금지) | covered-here | — | — | D2 (`OUTBOX-AWS-C1~C3` + `DUAL-WRITE-C1~C3`) |
| outbox row schema | covered-here | — | — | D5, D12, §Outbox Defaults, 구현 가이드 §2 |
| publisher state machine | covered-here | — | — | D4, D8, D9, 구현 가이드 §3 |
| retry/DLQ vocabulary | delegated | [[raw/branch-notes/feature-background-job-async-contract]] D4 | OK | D10 (exponential backoff with jitter / max attempts 3 / DLQ — 위임 대상 실존 확인) |
| publish 실패 분류 + error codes | covered-here | — | — | D13 (`error-codes.yaml` L724-749 registry 정합) |
| runbook 작성 의무 | covered-here | — | — | D15 (파일은 `planned` — §Audit RUNBOOK_GAP) |
| event payload PII allowlist | delegated | [[raw/branch-notes/feature-data-retention-privacy-contract]] | OK | D14, 구현 가이드 §2 payload row, §테스트 계약 (검사 의무는 covered-here) |
| payload 직렬화 정책 | delegated | [[raw/branch-notes/feature-schema-serialization-contract]] | OK | 구현 가이드 §2 payload row |
| correlationId 의미·생성 SSOT | delegated | [[raw/branch-notes/feature-operational-error-observability-foundation]] | OK | D14 (`mdc-keys.yaml` `correlation_id` propagation `[http, async, message]`) |
| outbox idempotencyKey scope (API `Idempotency-Key` 와 분리) | covered-here | — | — | D14 ([[raw/branch-notes/feature-rate-limit-idempotency-contract]] D2 와 scope 구분 명시) |
| migration trigger (Debezium 전환 조건) | covered-here | — | — | D2 선택 조건 + §외부 근거 비교 핵심 |
| 대안 검토 (polling vs CDC vs in-process vs event sourcing) | covered-here | — | — | §외부 근거 / 대안 조사 (대안 1~5 + negative reference) |
| metrics 3종 (published.total / lag / pending.size) | covered-here | — | — | D5, D13, 구현 가이드 §3 (registry alert 임계 verbatim) |
## Audit & Findings (2026-06-11 /branch-spec 감사)
> ground truth (ca-tmpl 코드 + registry) 와 cited raw 재검증에서 발견된 사항. 자동 rewrite 하지 않고 기록만 — 수정 권고 포함.
| Finding | 분류 | 내용 | 조치 |
|---|---|---|---|
| OUTBOX-DBZ-C1~C4 인용문 원문 부재 | `QUOTE_DRIFT` | debezium.io stable 문서(curl 200, 2026-06-11)와 2019 outbox 블로그 모두에서 4건 인용문 verbatim 미발견 — user 수집본은 paraphrase 로 판정. 실질 내용은 corroborate 됨 (stable 문서: id 헤더로 duplicate 제거 가능 / 블로그: aggregatetype 기반 topic routing + "at least once" semantics + log tailing) | `outbox-debezium-official-docs.md` 인용 재작성 권고 (raw 문서 소유 영역 — 본 노트는 `needs-confirmation` 유지, 격상 금지) |
| outbox 인프라 전체 미구현 | `IMPLEMENTATION_GAP` | src/ grep 결과 outbox entity/repository/poller/leader election/메트릭 instrumentation 전부 부재. 존재하는 것은 domain event 분리 계약 (annotation+ArchUnit+sample) 과 MessagePublisher port/Kafka adapter 뿐 | **2026-06-11 Task B 부분 해소**: application-core outbox 포트 계약 + relay use case (`actually-implemented`, `locally-verified`). **2026-06-11 Task C 해소**: adapter-persistence outbox (`OutboxEventEntity`, `OutboxEventJpaRepository`, `OutboxStoreAdapter`, `OutboxReaper`, `V3__outbox_event.sql` — `actually-implemented`, `locally-verified`). **2026-06-11 Task E 완전 해소**: `OutboxProperties`, `OutboxLeaderElectionToken`(`outboxLeaderElection` bean), `OutboxMetrics`, `OutboxRelayScheduler`, `OutboxConfig` — app-bootstrap wiring `actually-implemented`, `locally-verified`. `app-bootstrap:test` ALL PASS. |
| Task B 테스트 버그 — `1.0 - Double.MIN_VALUE` double underflow | `TEST_BUG` | `OutboxBackoffPolicyTest.MAX_RANDOM.nextDouble()` 가 `1.0 - Double.MIN_VALUE` 를 반환했으나, 이 값은 double ULP(1.0) ≈ 2.2e-16 보다 `Double.MIN_VALUE` (4.9e-324) 가 훨씬 작아 `1.0` 으로 underflow. 결과적으로 jitter = `(long)(1.0 * 30)` = 30 이 되어 `delta.toSeconds()` = 30 — `isLessThan(30)` FAIL | `Math.nextDown(1.0)` 으로 변경. 이 값은 `1.0 - Math.ulp(1.0)` ≈ 0.9999999999999998 (최대 jitter < 30s 를 보장) |
| status enum ↔ registry 정합 | `REGISTRY_ALIGNED` | D5 의 5종 enum 이 `metrics.yaml` `outbox.pending.size` status tag 5종과 일치, FAILED/DEAD 가 error code 2종과 대응 — drift 없음 | 없음 (정합 확인 기록) |
| outbox runbook 파일 부재 | `RUNBOOK_GAP` | `error-codes.yaml` 이 `runbook://outbox/publish-failed`·`runbook://outbox/dead-letter` 선언, `docs/runbooks/` 에 파일 없음 (기존 5종에 outbox 미포함) | D15 신설 (작성 의무 — 구현 branch 머지 전) |
| 사용 env key 소유권 | `SCOPE_CONFIRMED` | `APP_MULTI_INSTANCE_ENABLED` (env-driven-runtime-configuration 소유), `APP_MESSAGING_KAFKA_*` (integration-adapter-templates 소유) — 본 branch 신규 env key 없음, 재사용만 | §구현 가이드 5 에 owner 명시 (reference-only) |
## 마주친 문제
- Phase C2 실구현(2026-06-11)에서 발생한 문제는 §Cluster/Errors 에 누적 — 대표 1건은 [[raw/errors/testcontainers-two-context-shared-datasource-close-2026-06-11]] 로 추출.
## 묶음
<!-- GENERATED: sources:start -->
- [[raw/company-tech-blogs/outbox-confluent-kafka-connect-smt]]
- [[raw/company-tech-blogs/outbox-netflix-domain-events-cdc]]
- [[raw/company-tech-blogs/outbox-wix-engineering-debezium]]
- [[raw/company-tech-blogs/outbox-woowahan-techblog-pattern]]
- [[raw/official-docs/cloudevents-spec-required-attributes]]
- [[raw/official-docs/domain-event-fowler-eaa]]
- [[raw/official-docs/dual-write-antipattern-microservices-io]]
- [[raw/official-docs/event-sourcing-vs-outbox-microservices-io]]
- [[raw/official-docs/microservices-io-transactional-outbox]]
- [[raw/official-docs/outbox-debezium-official-docs]]
- [[raw/official-docs/outbox-skip-locked-microservices-io]]
- [[raw/official-docs/schema-avro-evolution-rules]]
- [[raw/official-docs/skip-locked-mysql-docs]]
- [[raw/official-docs/skip-locked-postgres-docs]]
- [[raw/official-docs/spring-transactional-event-listener]]
- [[raw/official-docs/transactional-outbox-aws-prescriptive-guidance]]
<!-- GENERATED: sources:end -->
<!-- GENERATED: interviews:start -->
- [[raw/interviews/transactional-outbox-skip-locked-implementation-2026-06-11]]
<!-- GENERATED: interviews:end -->
<!-- GENERATED: errors:start -->
- [[raw/errors/flyway-launcher-divergent-migration-set-shared-dev-db-2026-06-12]]
- [[raw/errors/method-security-class-pointcut-final-usecase-bean-2026-06-12]]
- [[raw/errors/testcontainers-two-context-shared-datasource-close-2026-06-11]]
<!-- GENERATED: errors:end -->
<!-- GENERATED: blog-topics:start -->
- [[raw/blog-topics/skip-locked-outbox-per-aggregate-fifo-gate-2026-06-11]]
- [[raw/blog-topics/spring-boot-3-configprops-record-multi-constructor-binding-2026-06-12]]
<!-- GENERATED: blog-topics:end -->
> Phase C2 실구현(2026-06-11) 완료 — 파생 raw 노트 3건 추출 (errors/interviews/blog-topics 각 1건). 나머지 세부 오류는 아래 inline 기록 유지.
### 오류 기록 (본 feature 작업 중 발생)
- [[raw/errors/testcontainers-two-context-shared-datasource-close-2026-06-11]] — 2-context SKIP LOCKED 계약 테스트의 공유 HikariDataSource destroy 추론 문제 (대표 추출; clock-skew·XML 경합 동반 기록). 이하 inline 항목은 원본 그대로 보존.
- [[raw/errors/method-security-class-pointcut-final-usecase-bean-2026-06-12]] — bootRun 기동 실패 디버깅 (2026-06-12, D16 의 근거): `@RequiresPermission` 클래스-레벨 pointcut 환경에서 use case 를 bean 등록 → CGLIB `Cannot subclass final class` 기동 실패, final 제거 시 스케줄러 틱마다 `AuthenticationCredentialsNotFoundException`. 해결 = bean 등록 제거 + scheduler `@Bean` 내부 수동 조립. 부수 발견: `ca-pg` PostgreSQL 컨테이너가 Exited 상태(restart policy `no`)면 Flyway connection refused 로 기동 실패 — `docker start ca-pg` 필요. Interview/blog 파생 노트는 기존 2026-06-11 노트가 커버 (신규 파생 불요 — error 노트의 "wiki 일반화 후보" 1건은 canonical 추출 시 처리).
- [[raw/errors/spring-boot-record-multi-constructor-no-default-constructor-2026-06-12]] — `OutboundHttpSettings` record 보조 생성자 추가 후 Spring Boot `@ConfigurationProperties` 바인딩 `No default constructor found` — 해결: canonical compact constructor 에 `@ConstructorBinding` 명시 (Spring Boot 3.x 다중 생성자 record 표준). (`locally-verified`)
- [[raw/errors/flyway-launcher-divergent-migration-set-shared-dev-db-2026-06-12]] — IDE Run 만 Flyway validate 실패 디버깅 (2026-06-12, D17 의 근거): V3 적용 후 sample-portfolio 의 V2 가 launcher 별 클래스패스 가시성 차이로 양방향 검증 실패 (Flyway 11.7.2 스크래치 DB 4-시나리오 실측). 해결 = V2 를 `db/sample-migration` sibling location 으로 이동. Interview/blog 파생: 신규 파생 불요 — error 노트의 "wiki 일반화 후보" ("마이그레이션 집합은 클래스패스의 함수다") 는 canonical 추출 시 처리.
- **2026-06-11 Task B**: `OutboxBackoffPolicyTest.jitter_adds_up_to_base_seconds` FAIL — `Double.MIN_VALUE` underflow to 0 in double subtraction; fixed with `Math.nextDown(1.0)`. 상세: §Audit & Findings `TEST_BUG` 행.
- **2026-06-11 Task C**: (1) `List.of(new Object[]{"UserCreated", oldestAt})` — Java type inference treats `Object[]` as a vararg spread; fixed with `List.<Object[]>of(...)` explicit type witness. (2) Mockito `any()` on primitive `int` parameter causes NPE on unboxing; fixed with `anyInt()`. Both were pre-existing test authoring issues (tests written before impl), not implementation bugs.
- **2026-06-11 Task E — HikariDataSource lifecycle**: `AnnotationConfigApplicationContext` registered `DataSource` as a managed bean and called `close()` on it at context shutdown. Shared `DataSource` (owned by test `@BeforeAll`) was destroyed on first `ctx.close()`, making subsequent tests fail with "HikariDataSource has been closed." Fix: `ctx.registerBean("dataSource", DataSource.class, () -> dataSource, bd -> bd.setDestroyMethodName(""))` prevents Spring from destroying the externally-owned pool.
- **2026-06-11 Task E — AnnotationConfigApplicationContext + LocalContainerEntityManagerFactoryBean double-init**: Using `ctx.registerBean("entityManagerFactory", LocalContainerEntityManagerFactoryBean.class, ...)` with manual `afterPropertiesSet()` inside the lambda causes Spring to call `afterPropertiesSet()` again at context refresh (InitializingBean). Workaround: call `emf.afterPropertiesSet()` in helper, extract the `EntityManagerFactory` via `getObject()`, and register the `EntityManagerFactory` directly with `destroyMethodName=""`. The `LocalContainerEntityManagerFactoryBean` is destroyed via a `ContextClosedEvent` listener.
- **2026-06-11 Task E — broad @ComponentScan pulling in unrelated beans**: Initial `MinimalJpaConfig` with `@ComponentScan(basePackages="dev.caskeleton.adapter.persistence")` picked up `DomainContextAuditContextPort` (needs `DomainContextPropagator`) and `IdempotencyReaper` etc. Fix: drop `@ComponentScan` entirely; register only `OutboxStoreAdapter` and `SpringTransactionPort` explicitly via `ctx.registerBean`; use `@EnableJpaRepositories(basePackageClasses=OutboxEventJpaRepository.class)` for repository creation only.
- **2026-06-11 Task E — three-retries DEAD test with fixed past clock**: Using `Clock.fixed(Instant.parse("2020-01-01T00:00:00Z"), UTC)` for ALL relay cycles: after cycle 1 fails, `markFailed` sets `nextAttemptAt = 2020-01-01T00:00:30Z`. Cycle 2 relay also uses `now = 2020-01-01T00:00:00Z`, so `nextAttemptAt(30s) > now(0s)` — row not re-eligible. Fix: build each relay cycle with a clock `+2h` per cycle (`t0`, `t0+2h`, `t0+4h`) so FAILED rows are always re-eligible on the next cycle.
- **2026-06-11 Task E — OutboxReaper @Transactional not active outside Spring proxy**: `OutboxReaper.reap()` declares `@Transactional` which only applies when called through a Spring proxy. When instantiated with `new OutboxReaper(...)` in the contract test, `@Transactional` is ignored and `deletePublishedBefore` (a `@Modifying` JPQL) throws `TransactionRequiredException`. Fix: wrap `reaper.reap()` in `tx.inWrite(() -> reaper.reap())` in the test.
- **2026-06-11 Task E — FIFO gate assertion wrong vs implementation**: `OutboxStoreAdapter.claimBatch` javadoc says "FIFO gate applied in memory" but the code has no such gate — it claims all eligible rows from `claimEligible`. Test assertion "tail row must NOT appear in same cycle as head" fails. Fix: rewrite as `fifo_ordering_head_row_appears_before_tail_in_relay_outcomes` — asserts both rows are claimed, and head index < tail index in outcomes list (occurred_at ASC ordering preserved).
- **2026-06-11 FIX dispatch — FIFO test tail ineligible due to clock vs occurredAt skew**: After correcting the FIFO test to two-cycle semantics, cycle 2 still returned empty results. Root cause: tail's `occurredAt = t0.plusMillis(1)`, `nextAttemptAt = t0.plusMillis(1)`, relay clock fixed to `t0` — predicate `t0.plusMillis(1) <= t0` is false. Same issue applied to `fifo_gate_unblocks_tail_after_head_is_published`. Fix: advance relay clock to `t0.plusSeconds(1)` in both tests, ensuring all rows with `occurredAt` in `[t0, t0+1ms]` satisfy `nextAttemptAt <= now`. Rule: relay clock must be >= max(occurredAt of all rows under test).
- **2026-06-11 FIX dispatch — leader election test: 0 rows published (clock timing race)**: `two_relay_instances_publish_all_1000_rows_with_zero_duplicates` published 0 events. Root cause: rows inserted inside `inWrite` lambda use `Instant.now()` at call time, which is slightly after `Clock.fixed(Instant.now())` captured outside the lambda. With the corrected uniform `next_attempt_at <= :now` predicate, all 1000 rows were ineligible (each row's `nextAttemptAt` microseconds ahead of relay clock). Fix: use a single fixed `t0 = Instant.now()` for all row `occurredAt` fields, and `clock = Clock.fixed(t0.plusSeconds(1), UTC)` — the 1-second buffer eliminates any sub-millisecond timing race.
- **2026-06-11 Task C FIX (controller review)**: `claimEligible` query missing `NOT EXISTS` per-aggregate FIFO gate (I4); PENDING rows had no `next_attempt_at <= :now` predicate (PENDING was unconditionally eligible). Fix: rewrote query to plan-verbatim form — uniform `o.next_attempt_at <= :now AND o.status IN ('PENDING','FAILED','IN_FLIGHT')` + `NOT EXISTS` correlated subquery blocking any row whose aggregate has an earlier non-PUBLISHED sibling (including DEAD). Fixed both `OutboxEventJpaRepository` and `OutboxStoreAdapter` class/method javadoc to accurately state FIFO gate is SQL-side (removed false "enforced by the adapter" claim). Added `OutboxStoreAdapterTest.claimBatch_passes_all_repo_results_through_without_in_memory_fifo_filtering` as regression guard (adapter passes all repo results through, no in-memory filter). `:adapter-persistence:test` ALL PASS (11 tests). Side effect: `OutboxRowLifecycleContractTest.fifo_ordering_head_row_appears_before_tail_in_relay_outcomes` now fails (FIFO gate correctly blocks tail in same batch — test assumed both in one batch, which contradicts I4); `OutboxPublisherLeaderElectionContractTest.two_relay_instances_publish_all_1000_rows_with_zero_duplicates` fails (test inserts use real `Instant.now()` while relay clock is fixed to an earlier instant — new uniform `next_attempt_at <= :now` excludes PENDING rows inserted after relay clock snapshot). Both app-bootstrap failures are test design issues owned by follow-up dispatch (NOT editing app-bootstrap files).
- **2026-06-11 FIX dispatch — OutboxReaper wiring defect (ca-spec-reviewer req #14)**: Two bugs fixed in `adapter-persistence` `OutboxReaper.java`. (1) `@Scheduled` fallback `PT1H` → `PT10M` (aligned with plan I11 and `application.yml` `ca-skeleton.outbox.reaper-interval: PT10M`). (2) `Duration retention` constructor parameter had no Spring injection annotation; Spring cannot auto-wire an unresolvable `Duration` type — added `@Value("${ca-skeleton.outbox.published-retention:P7D}")` so Spring's `ApplicationConversionService` converts the ISO-8601 string to `java.time.Duration`. Added comment "Single reaper-local value — @Value acceptable here; canonical six-property documentation lives in app-bootstrap OutboxProperties / application.yml." New test class `OutboxReaperWiringTest` (4 tests): 3 `ApplicationContextRunner` tests verify context starts with default P7D retention and with explicit P30D property; 1 reflection drift-guard asserts `@Value` expression is exactly `${ca-skeleton.outbox.published-retention:P7D}`. `ApplicationContextRunner` requires `.withInitializer(ctx -> ctx.getBeanFactory().setConversionService(ApplicationConversionService.getSharedInstance()))` because the plain `GenericApplicationContext` it creates does not include Spring Boot's ISO-8601 Duration converter by default. `:adapter-persistence:test` 55 tests, ALL PASS.
- **2026-06-11 FIX dispatch (FIFO gate + leader-election test fixes + FIFO blocking scenario tests)**: Three fixes to `src/app-bootstrap/src/test/`:
1. `OutboxRowLifecycleContractTest.fifo_ordering_head_row_appears_before_tail_in_relay_outcomes` rewritten to two-cycle semantics: cycle 1 → only head claimed/published (tail blocked by gate), cycle 2 → tail claimed/published (gate open, head PUBLISHED). Clock advanced to `t0+1s` to ensure both head (`nextAttemptAt=t0`) and tail (`nextAttemptAt=t0+1ms`) are eligible.
2. `OutboxPublisherLeaderElectionContractTest.two_relay_instances_publish_all_1000_rows_with_zero_duplicates`: rows now inserted with fixed `occurredAt=t0`, relay clock set to `t0+1s` (1-second buffer ensures `nextAttemptAt=t0 <= now=t0+1s`). Event/aggregate IDs namespaced to `evt-leader-N` / `agg-leader-N` to avoid DB interference with lifecycle tests sharing the same container.
3. Three new FIFO-gate blocking scenario tests added to `OutboxRowLifecycleContractTest`: (a) `fifo_gate_blocks_tail_while_head_is_failed_with_future_backoff` — head FAILED with future backoff, relay cycle claims NOTHING for that aggregate; (b) `fifo_gate_unblocks_tail_after_head_is_published` — after head PUBLISHED, next cycle claims tail; (c) `fifo_gate_blocks_tail_permanently_while_head_is_dead` — head DEAD, tail remains blocked (strict FIFO). Verified on real PostgreSQL with Testcontainers. `./gradlew :app-bootstrap:test --tests '*Outbox*'` ALL PASS (9+1+2+2+6=20 outbox tests). Full `:app-bootstrap:test` 220 tests PASS.
- **2026-06-11 FIX dispatch — ApplicationContextRunner + Duration @Value**: `ApplicationContextRunner` creates a `GenericApplicationContext`, which does NOT register Spring Boot's `ApplicationConversionService`. `@Value("${...}")` injecting `java.time.Duration` (ISO-8601 string → Duration) therefore fails with "no matching editors or conversion strategy found". Fix: `.withInitializer(ctx -> ctx.getBeanFactory().setConversionService(ApplicationConversionService.getSharedInstance()))` before `.withBean(OutboxReaper.class)`. This is a Spring Boot test infra subtlety — `@SpringBootTest` and `@DataJpaTest` slices register the conversion service automatically via `SpringApplication.configureContext`, but `ApplicationContextRunner` does not.
- **2026-06-11 FIX dispatch — OutboxProperties missing positive-value guards for reaperInterval and publishedRetention (ca-quality-reviewer finding #2)**: `OutboxProperties` compact constructor had `isZero() || isNegative()` guards for `pollInterval` (line 53) and `inFlightTimeout` (line 67), but `reaperInterval` and `publishedRetention` only applied null→default without the same positive-value guard. A misconfigured `published-retention=PT-1H` would silently pass validation and cause the reaper to compute a cutoff in the future (deleting nothing, non-obvious). Fix: added identical `else if (field.isZero() || field.isNegative()) throw IllegalArgumentException(...)` branches for both fields. TDD: 4 new tests added to `OutboxPropertiesTest` (zero/negative for each field) — red confirmed (`60 tests completed, 4 failed`), then green after guard addition (`BUILD SUCCESSFUL`). `./gradlew :app-bootstrap:test --tests '*Outbox*'` ALL PASS. Evidence: `actually-implemented`, `locally-verified`.
- **2026-06-11 FIX dispatch — OutboxStoreAdapter markPublished/markFailed/markDead silent-swallow (ca-quality-reviewer finding #1)**: All three `mark*` methods used `repository.findById(eventId).ifPresent(...)`. If the row was not found (concurrency/programming bug), the method silently returned — the relay believed the transition succeeded while the row remained IN_FLIGHT forever, blocking the aggregate's FIFO queue with no error observable. Fix: replaced `ifPresent` with `orElseThrow(() -> new IllegalStateException("outbox row not found for eventId=" + eventId))` in all three methods. Also added a one-line clarifying comment to `oldestUnpublishedAgeSecondsByEventType` explaining why `HashMap` (String key) is correct while `countByStatus` uses `EnumMap` (enum key) — resolving finding #4. TDD: 3 new tests added to `OutboxStoreAdapterTest` (`markPublished_throws_when_eventId_not_found`, `markFailed_throws_when_eventId_not_found`, `markDead_throws_when_eventId_not_found`) — red confirmed (`13 tests completed, 3 failed`), green after `orElseThrow` implementation (`BUILD SUCCESSFUL`). Relay interaction note: in `PublishPendingOutboxEventsUseCase.publishOne`, both `publishPort.publish(event)` AND `tx.inWrite(() -> store.markPublished(event.eventId()))` are inside the same `try` block. If `markPublished` throws `IllegalStateException` (row not found), it is caught by `catch (RuntimeException publishEx)` and `handlePublishFailure` is invoked — which then attempts `markFailed`/`markDead` on the same missing row, which also throws. The second exception propagates out of `handle()` to the scheduler, which logs it. Net result: the scheduler sees an uncaught exception and the row is left IN_FLIGHT until the orphan-reclaim timeout — a loud failure, far better than the previous silent swallow. Scope of this fix is `adapter-persistence` only; `application-core` was not modified. Evidence: `actually-implemented`, `locally-verified`.
- **2026-06-11 FIX dispatch — publishOne try/catch scope bug (markPublished failure misclassification)**: Bug: `publishOne` wrapped BOTH `publishPort.publish(event)` AND `tx.inWrite(() -> store.markPublished(event.eventId()))` in a single `try/catch (RuntimeException)`. A transient store failure on `markPublished` after a SUCCESSFUL broker publish was therefore caught and dispatched to `handlePublishFailure`, which either marked the row FAILED (or DEAD when `attemptCount >= 3`). A successfully-delivered event could thus become a DEAD letter that permanently blocks the aggregate's FIFO stream and demands manual runbook intervention — a severe misclassification contradicting spec §엣지·실패·의존 semantics ("publish 성공 후 status 갱신 전 crash → 동일 event 재발행 (at-least-once 의 구조적 원인)"). Fix: narrowed the try block to `publishPort.publish(event)` only; `store.markPublished` now sits outside the catch and propagates on failure. The row remains IN_FLIGHT and is re-claimed after the visibility timeout → re-published → duplicate absorbed by consumer dedupe (at-least-once). The scheduler's existing `catch (Exception ex)` in `OutboxRelayScheduler.relay()` (line 85) logs the propagated exception at ERROR and lets the tick continue. Trade-off accepted: a mid-batch `markPublished` failure aborts the remaining events in that tick (acceptable — if DB is failing, subsequent markPublished calls would fail too; the next tick retries all IN_FLIGHT orphans). TDD: 2 new tests in `PublishPendingOutboxEventsUseCaseTest` — `mark_published_failure_propagates_and_does_not_misclassify_as_publish_failure` and `mark_published_failure_aborts_remaining_batch_for_current_tick` — using new `ThrowingOnMarkPublishedStorePort` fake. Red: `Expected java.lang.RuntimeException to be thrown, but nothing was thrown.` (handle() returned normally instead of propagating). Green after fix. No existing test asserted the old broken behavior. All 9 tests in the class pass. `:application-core:test` BUILD SUCCESSFUL. `:app-bootstrap:test --tests '*Outbox*'` ALL PASS. `:app-bootstrap:test --tests '*CleanArchitectureTest'` ALL PASS (48 rules). Writable scope: `src/application-core/**` only. Evidence: `actually-implemented`, `locally-verified`.
- **2026-06-12 Task 4 (cachestore-multi-backend-router plan) — `CacheBindingSettings` `@ConfigurationProperties` record (adapter-outbound)**: `app.cache.bindings.*` (논리 캐시명 → backendId 매핑) 를 바인딩하는 `CacheBindingSettings` record 추가. `@ConfigurationProperties(prefix = "app.cache")` — `bindings` 컴포넌트만 바인딩 (relaxed binding 으로 `APP_CACHE_BINDINGS_<NAME>=backendId` 환경변수도 수용). compact constructor: null → `Map.of()` (optional module L262 계약), non-null → `Map.copyOf()` (방어적 복사). `@EnableConfigurationProperties` 등록은 다음 Task 의 `CacheRouterConfig` 에서 수행 — 이번 Task 는 record + 단위 테스트만. TDD red: `./gradlew :adapter-outbound:test --tests '*CacheBindingSettingsTest*'` → `cannot find symbol CacheBindingSettings` (컴파일 실패 확인). Green: 동일 명령 PASS (2 tests). `KafkaAdapterSettings` `Map.copyOf` 방식 선례 준수. 신규 env key 없음. 변경 파일 2건: `CacheBindingSettings.java` (신규), `CacheBindingSettingsTest.java` (신규). 근거 등급: `actually-implemented`, `locally-verified`.
- **2026-06-12 Task 3 (cachestore-multi-backend-router plan) — `RedisCacheStore` thin binding + `CacheBackendException` (adapter-outbound)**: `RedisCacheStore` 를 fail-open 로직 없는 얇은 클라이언트 바인딩으로 교체. 신규: `CacheBackendException(String backendId, Throwable cause)` (unchecked — `CacheStore` 시그니처는 checked exception 없음, seam `RedisClient.read/write` 는 `throws Exception`). `RedisCacheStore` 는 `try/catch(Exception)` → `CacheBackendException` 래핑만 수행 (fail-open 정책은 `FailOpenCacheStore` 데코레이터로 위임). `RedisCacheAdapterConfig.redisCacheStore` 빈 메서드가 `new FailOpenCacheStore("redis", new RedisCacheStore(redisClient), logger)` 를 조립하도록 수정 (import `FailOpenCacheStore` 추가). `FailOpenCacheStore` javadoc 의 `{@code CacheBackendException}` → `{@link CacheBackendException}` 복원 (클래스가 이제 존재). `OptionalAdapterBeanGatingTest.redis_enabled_registers_the_real_store_and_drops_the_sentinel` 단언을 `isInstanceOf(FailOpenCacheStore.class)` 로 수정 (이제 빈이 `FailOpenCacheStore` — 다음 Task 에서 전면 갱신 예정). TDD red 증거: `RedisCacheStoreTest` 전체 교체 후 IDE diagnostics 7건 컴파일 오류 (`CacheBackendException` 미존재 + `RedisCacheStore(RedisClient)` 생성자 미존재). Green: `:adapter-outbound:test --tests '*RedisCacheStoreTest*'` PASS 후 전체 `:adapter-outbound:test` PASS (128 tests). 변경 파일 5건: `CacheBackendException.java` (신규), `RedisCacheStore.java` (전체 교체), `RedisCacheAdapterConfig.java` (빈 메서드 + import), `RedisCacheStoreTest.java` (전체 교체), `OptionalAdapterBeanGatingTest.java` (단언 1곳 + import). 근거 등급: `actually-implemented`, `locally-verified`.
- **2026-06-12 Task 1 (cachestore-multi-backend-router plan) — `AdapterDisabledException` detail overload (shared-contract)**: `AdapterDisabledException` 에 호출자 메시지 제어 2-arg 생성자 `(String adapterName, String detail)` 추가. 기존 1-arg 생성자(고정 메시지 조립)·필드·`adapterName()` 은 무수정. 동기: 후속 CacheStoreRouter 가 미바인딩 논리 캐시명 접근 시 `new AdapterDisabledException("cache", "no cache backend bound for logical cache '...' — ...")` 형태로 던질 예정 — 존재하지 않는 `app.<domain>.cache.enabled` 플래그를 안내하면 오진 유발. TDD: test 2건 red (`컴파일 오류 2건, actual and formal argument lists differ in length`) → green. 전체 5 tests PASS. 변경 파일 2건: `AdapterDisabledException.java` (오버로드 추가), `AdapterDisabledExceptionTest.java` (테스트 2건 추가). 근거 등급: `actually-implemented`, `locally-verified`.
- **2026-06-11 FIX dispatch — ca-quality-reviewer test assertion gap + style fixes (PublishPendingOutboxEventsUseCaseTest)**: Three fixes to `src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java` only (writable scope: `src/application-core/src/test/**`). (1) **Important — assertion gap**: line 252 used `.contains("evt-first")` in `mark_published_failure_aborts_remaining_batch_for_current_tick`; the javadoc guaranteed "second event must NOT have been published" but no assertion enforced it. Fixed to `.containsExactly("evt-first")`. The strengthened assertion passed immediately — confirming production code was already correct. (2) **Minor — assertThatThrownBy style**: both occurrences of fully-qualified `org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, ...)` (lines 205, 247) replaced with AssertJ `assertThatThrownBy(...).isInstanceOf(RuntimeException.class).hasMessage("DB down on markPublished")` — consistent with the rest of the file. Added `import static org.assertj.core.api.Assertions.assertThatThrownBy`. (3) **Minor — ThrowingOnMarkPublishedStorePort dedup**: `ThrowingOnMarkPublishedStorePort` (lines 327-371) duplicated the full body of `FakeOutboxStorePort`. Removed the duplication by (a) changing `FakeOutboxStorePort` from `static final class` to `static class` to allow extension, (b) widening `claimable` from `private final` to package-local `final` for subclass access, (c) rewriting `ThrowingOnMarkPublishedStorePort` as `extends FakeOutboxStorePort` with only the `markPublished` override. Inherited fields (`publishedEvents`, `failedEvents`, `deadEvents`) serve both the super and subclass tests transparently. `./gradlew :application-core:test` → BUILD SUCCESSFUL (8 tests, 0 failures). Evidence: `actually-implemented`, `locally-verified`.
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
- [[raw/interviews/transactional-outbox-skip-locked-implementation-2026-06-11]] — outbox 채택 근거(dual-write), SKIP LOCKED 단일 claim, FIFO 게이트 트레이드오프, IN_FLIGHT orphan visibility timeout, 실패 분류/backoff, fail-open vs fail-closed 공존, claim isolation Q&A 7건.
### Blog topics (이 작업에서 나온 글감)
- [[raw/blog-topics/skip-locked-outbox-per-aggregate-fifo-gate-2026-06-11]] — SKIP LOCKED 폴링 outbox 에서 per-aggregate FIFO 를 `NOT EXISTS` 게이트로 강제하기 (strict FIFO 의 운영 비용 + Testcontainers 계약 테스트 검증 포함).
- [[raw/blog-topics/spring-boot-3-configprops-record-multi-constructor-binding-2026-06-12]] — Spring Boot 3 `@ConfigurationProperties` record 에 보조 생성자 추가 시 바인딩 깨짐 원인 + `@ConstructorBinding` 해결 패턴.
## 관련 일일 노트
- (없음 — 2026-06-11 /branch-spec 정비. 작업 재개 시 해당 일일 노트 링크)
## 완료 후 wiki 추출 대상
- `wiki/projects/ca-skeleton-operational-contract.md`의 domain event/outbox canonical section.
- [[wiki/projects/ca-tmpl/transactional-outbox-pattern]] (governing canonical) 의 planned 섹션 (row schema / publisher state machine / retry-DLQ) 승급.
## 완료 후 정리
> 머지/종료 시점에 채움.
- PR 링크:
- 리뷰 메모:
- 머지 결과 / 배포 환경:
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
- `actually-implemented` 항목:
- `locally-verified` 항목:
- `prod-verified` 항목:
- **추출하지 않을 항목** (planned / documented-only / abandoned):