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

97 KiB
Raw Blame History

title, source_type, status, branch, parent_branch, related_projects, governing_docs, tags, created, target_merge, status_label, id, kind, project, work_item, inherits, refines, overrides, depends_on, contract_packet, contract_packet_sha256
title source_type status branch parent_branch related_projects governing_docs tags created target_merge status_label id kind project work_item inherits refines overrides depends_on contract_packet contract_packet_sha256
branch / feature-domain-event-outbox-contract branch-note raw feature-domain-event-outbox-contract
ca-skeleton
wiki/projects/ca-tmpl/transactional-outbox-pattern
branch
ca-skeleton
domain-event
outbox
messaging
2026-05-22 in-progress BR-CA-SKELETON-OPERATIONAL-CONTRACT-038 project-work-item ca-skeleton-operational-contract WI-CA-SKELETON-OPERATIONAL-CONTRACT-038
DEC-CA-SKELETON-OPERATIONAL-CONTRACT-EVENT-BROKER-001@1
DEC-CA-SKELETON-OPERATIONAL-CONTRACT-SCHEDULER-LOCK-001@1
1 07cf1cd12d434bd2863971a2449e16cd77d46aa46eefc8be1f42f5eb171ca28d

branch: feature-domain-event-outbox-contract

Layer: raw/branch-notes/ — domain event, integration event, outbox, message publish 실패 기준을 정의합니다.

부모 (필수)

ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (§<관련 섹션>) 의 결정/근거/금지 사항을 정제한다.

형제 branch (계약 의존 — §엣지·실패·의존 참조):

브랜치 계약 패킷

  • 생성 시 프로젝트 개정: 1
  • 패킷 스키마: contract_packet: 1
  • 완료 조건: broker-agnostic outbox와 duplicate execution test가 통과한다

상속한 프로젝트 결정

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

브랜치 지역 결정

기존 branch-local 결정은 아래 ## Decision Evidence Map / 결정-근거 매핑의 D-row가 소유하며 이 packet에서 복제하지 않는다.

Decision ID Decision Relation Supporting Claims Status

선언한 예외

Override ID Overrides Reason Approval Status

목표

실제 도메인이 들어오면 이벤트 발행 요구가 빠르게 생깁니다. domain event가 Kafka/Redis/HTTP 같은 transport detail을 알거나 transaction과 publish가 분리되어 유실되면 skeleton의 운영 계약이 깨집니다.

  • 이슈:
  • PR:

범위

포함 범위

  • 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 참조.

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_seconds1.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 protectedpublic (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): CreateWorkLogUseCaseOutboxAppendPort + 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 + WorkLogAuthorizationContractTestCreateWorkLogUseCase 생성자 변경에 맞게 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) OutboxLeaderElectionTokenStartupSafetyValidator.REQUIRED_MULTI_INSTANCE_BEANS["outboxLeaderElection"] bean 충족용 마커 클래스. (3) OutboxMetricsObjectProvider<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.ymlca-skeleton.outbox 섹션 6개 리터럴 기본값 추가(신규 env key 0개 — I11 준수). app-bootstrap/build.gradlemicrometer-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 으로 등록하지 않음 — OutboxConfigoutboxRelayScheduler @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) OutboundHttpSettingsRetry/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-C1C3 — 2026-06-11 self-grep 검증 수집) + engineering-blog (MSIO Richardson — personal pattern catalog) + needs-confirmation (DUAL-WRITE-C1C3 — 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=trueoutboxLeaderElection bean 필수 (부재 시 기동 실패) raw/official-docs/skip-locked-postgres-docs.md#SK-PG-C2 (multiple consumer 시나리오에 SKIP LOCKED 적합). ca-tmpl 구현: StartupSafetyValidatorAPP_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_LETTEROperationalError enum 에 추가됨 (actually-implemented, locally-verified./gradlew :shared-contract:test PASS + ./gradlew :app-bootstrap:test --tests '*ErrorCodeRegistryMappingTest' PASS). OUTBOX_DEAD_LETTERINTERNAL 이지만 retryable=falseinternal_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.sqldb/migrationdb/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 성공 PENDINGIN_FLIGHT outbox.pending.size{status}
publish 성공 IN_FLIGHTPUBLISHED outbox.publisher.published.total{outcome=PUBLISHED}, outbox.publisher.lag
broker 일시 실패 IN_FLIGHTFAILED, 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) 소진 FAILEDDEAD 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):
    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: appendnextAttemptAt = 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 (§테스트 계약).
  • 다른 계약 의존:

테스트 계약

  • 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
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.sqlactually-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.yamlrunbook://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) 완료 — 파생 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-12OutboundHttpSettings 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 PT1HPT10M (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 PublishPendingOutboxEventsUseCaseTestmark_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/writethrows Exception). RedisCacheStoretry/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.

면접 준비 (이 작업에서 나올 수 있는 면접 질문)

Blog topics (이 작업에서 나온 글감)

관련 일일 노트

  • (없음 — 2026-06-11 /branch-spec 정비. 작업 재개 시 해당 일일 노트 링크)

완료 후 wiki 추출 대상

완료 후 정리

머지/종료 시점에 채움.

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