524 lines
97 KiB
Markdown
524 lines
97 KiB
Markdown
---
|
||
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 실패 기준을 정의합니다.
|
||
|
||
<!-- 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):
|