97 KiB
title, source_type, status, branch, parent_branch, related_projects, governing_docs, tags, created, target_merge, status_label, id, kind, project, work_item, inherits, refines, overrides, depends_on, 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 |
|
|
|
2026-05-22 | in-progress | BR-CA-SKELETON-OPERATIONAL-CONTRACT-038 | project-work-item | ca-skeleton-operational-contract | WI-CA-SKELETON-OPERATIONAL-CONTRACT-038 |
|
1 | 07cf1cd12d434bd2863971a2449e16cd77d46aa46eefc8be1f42f5eb171ca28d |
branch: feature-domain-event-outbox-contract
Layer:
raw/branch-notes/— domain event, integration event, outbox, message publish 실패 기준을 정의합니다.
부모 (필수)
- 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
브랜치 계약 패킷
- 생성 시 프로젝트 개정:
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 참조.
- 채택 결정 (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 check836/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:test78 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.sqlmigration,OutboxEventJpaRepository(SKIP LOCKED native claim query + 4 custom queries),OutboxStoreAdapter(implementsOutboxAppendPort+OutboxStorePort),OutboxReaper.OutboxEventEntityno-arg constructorprotected→public(cross-package test instantiation). 테스트 버그 수정 2건: (1)List.of(new Object[]{...})varargs inference ambiguity →List.<Object[]>of(...)explicit type witness; (2)any()on primitiveintparam (NPE on unboxing) →anyInt(). 빌드::adapter-persistence:testPASS,:app-bootstrap:test '*CleanArchitectureTest'PASS,verifyCleanArchitectureDependenciesPASS. - 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,verifyCleanArchitectureDependenciesPASS. 발견 이슈:\uXXXX리터럴을 javadoc 주석에 넣으면 Java 컴파일러가 소스 레벨에서 처리해 파싱 오류 발생 →escape()javadoc 을 산문 설명으로 교체 + switch-arrow 구문을 if/else chain 으로 교체(동일 동작). - 2026-06-11 Task C FIX (controller review — persistence-only):
claimEligiblequery rewritten to plan-verbatim form (I4 FIFO gate viaNOT EXISTS, uniformnext_attempt_at <= :nowfor all 3 statuses). Javadoc on bothOutboxEventJpaRepositoryandOutboxStoreAdaptercorrected (false "adapter enforces FIFO in-memory" claim removed). New unit testclaimBatch_passes_all_repo_results_through_without_in_memory_fifo_filteringadded.:adapter-persistence:test11 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) andleader_election(test clock timing incompatible with uniformnext_attempt_at <= :nowpredicate). - 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 = MDCcorrelation_id값, 부재 시 eventId self-correlation. 신규 파일:OutboxEventIdFactory(domain port),UlidOutboxEventIdFactory(adapter/identifier),WorkLogReservedIntegrationEventMappertoJson/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:test129 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:testALL PASS(13 outbox contract + 전체 suite PASS),verifyCleanArchitectureDependenciesPASS,verifyEnvKeysPASS (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-tmplerror-codes.yamlL724-749 -
2026-06-11: (D14) correlationId 는 outbox row 저장 + publish 시 message 로 전파. ID 의미·생성 SSOT 는
feature-operational-error-observability-foundation(mdc-keyscorrelation_id, propagation 에message포함). outbox 의 idempotencyKey 는 event 단위 dedupe key 로, APIIdempotency-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" 으로 수정). 이유: 클래스 레벨@RequiresPermissionpointcut (adapter-webMethodSecurityConfig) 이 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)
RedisCacheStoreTest2건 수정 — (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에서 siblingdb/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 빌드. 신규OutboundHttpResilienceTest4건. 커밋: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 패턴(무경계 백엔드 기여 +CacheStoreRouterLayer 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 슬라이스에서settingsbean 누락 방지. TDD red:compileTestJava2 symbol errors (CacheRouterConfig미정의). 빌드::adapter-outbound:test137 PASS (0 failures, 0 errors) — 게이팅 6건 (disabled 기본 / redis 라우팅 / 2-백엔드 OCP / 모순 바인딩 startup-fail / kafka / slack / google-email) + sentinel 3건 (kafka + 라우터 D4 2건) 모두 통과.ObjectProviderfallback 사용: 사용됨 (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-C1engineering-blog (MSIO Richardson — personal pattern catalog) + needs-confirmation (DUAL-WRITE-C1 |
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.yamlL724-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):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).
- PENDING 즉시 eligible:
- 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 (
StartupSafetyValidatoractually-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_FLIGHTorphan 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 (§테스트 계약).
- publisher 가 claim 후 publish 전 crash →
- 다른 계약 의존:
- 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-keyspropagation: [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 LOCKEDquery를 사용해 row를 claim하고, 동일 row가 두 publisher instance에서 동시 claim되지 않음을 contract test에서 verify. 측정 방법: contract testOutboxPublisherLeaderElectionContractTest에서 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.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 로 추출.
묶음
- 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
- 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
- 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
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 등록 → CGLIBCannot subclass final class기동 실패, final 제거 시 스케줄러 틱마다AuthenticationCredentialsNotFoundException. 해결 = bean 등록 제거 + scheduler@Bean내부 수동 조립. 부수 발견:ca-pgPostgreSQL 컨테이너가 Exited 상태(restart policyno)면 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 —
OutboundHttpSettingsrecord 보조 생성자 추가 후 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-migrationsibling location 으로 이동. Interview/blog 파생: 신규 파생 불요 — error 노트의 "wiki 일반화 후보" ("마이그레이션 집합은 클래스패스의 함수다") 는 canonical 추출 시 처리. -
2026-06-11 Task B:
OutboxBackoffPolicyTest.jitter_adds_up_to_base_secondsFAIL —Double.MIN_VALUEunderflow to 0 in double subtraction; fixed withMath.nextDown(1.0). 상세: §Audit & FindingsTEST_BUG행. -
2026-06-11 Task C: (1)
List.of(new Object[]{"UserCreated", oldestAt})— Java type inference treatsObject[]as a vararg spread; fixed withList.<Object[]>of(...)explicit type witness. (2) Mockitoany()on primitiveintparameter causes NPE on unboxing; fixed withanyInt(). Both were pre-existing test authoring issues (tests written before impl), not implementation bugs. -
2026-06-11 Task E — HikariDataSource lifecycle:
AnnotationConfigApplicationContextregisteredDataSourceas a managed bean and calledclose()on it at context shutdown. SharedDataSource(owned by test@BeforeAll) was destroyed on firstctx.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 manualafterPropertiesSet()inside the lambda causes Spring to callafterPropertiesSet()again at context refresh (InitializingBean). Workaround: callemf.afterPropertiesSet()in helper, extract theEntityManagerFactoryviagetObject(), and register theEntityManagerFactorydirectly withdestroyMethodName="". TheLocalContainerEntityManagerFactoryBeanis destroyed via aContextClosedEventlistener. -
2026-06-11 Task E — broad @ComponentScan pulling in unrelated beans: Initial
MinimalJpaConfigwith@ComponentScan(basePackages="dev.caskeleton.adapter.persistence")picked upDomainContextAuditContextPort(needsDomainContextPropagator) andIdempotencyReaperetc. Fix: drop@ComponentScanentirely; register onlyOutboxStoreAdapterandSpringTransactionPortexplicitly viactx.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,markFailedsetsnextAttemptAt = 2020-01-01T00:00:30Z. Cycle 2 relay also usesnow = 2020-01-01T00:00:00Z, sonextAttemptAt(30s) > now(0s)— row not re-eligible. Fix: build each relay cycle with a clock+2hper 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@Transactionalwhich only applies when called through a Spring proxy. When instantiated withnew OutboxReaper(...)in the contract test,@Transactionalis ignored anddeletePublishedBefore(a@ModifyingJPQL) throwsTransactionRequiredException. Fix: wrapreaper.reap()intx.inWrite(() -> reaper.reap())in the test. -
2026-06-11 Task E — FIFO gate assertion wrong vs implementation:
OutboxStoreAdapter.claimBatchjavadoc says "FIFO gate applied in memory" but the code has no such gate — it claims all eligible rows fromclaimEligible. Test assertion "tail row must NOT appear in same cycle as head" fails. Fix: rewrite asfifo_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 tot0— predicatet0.plusMillis(1) <= t0is false. Same issue applied tofifo_gate_unblocks_tail_after_head_is_published. Fix: advance relay clock tot0.plusSeconds(1)in both tests, ensuring all rows withoccurredAtin[t0, t0+1ms]satisfynextAttemptAt <= 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_duplicatespublished 0 events. Root cause: rows inserted insideinWritelambda useInstant.now()at call time, which is slightly afterClock.fixed(Instant.now())captured outside the lambda. With the corrected uniformnext_attempt_at <= :nowpredicate, all 1000 rows were ineligible (each row'snextAttemptAtmicroseconds ahead of relay clock). Fix: use a single fixedt0 = Instant.now()for all rowoccurredAtfields, andclock = Clock.fixed(t0.plusSeconds(1), UTC)— the 1-second buffer eliminates any sub-millisecond timing race. -
2026-06-11 Task C FIX (controller review):
claimEligiblequery missingNOT EXISTSper-aggregate FIFO gate (I4); PENDING rows had nonext_attempt_at <= :nowpredicate (PENDING was unconditionally eligible). Fix: rewrote query to plan-verbatim form — uniformo.next_attempt_at <= :now AND o.status IN ('PENDING','FAILED','IN_FLIGHT')+NOT EXISTScorrelated subquery blocking any row whose aggregate has an earlier non-PUBLISHED sibling (including DEAD). Fixed bothOutboxEventJpaRepositoryandOutboxStoreAdapterclass/method javadoc to accurately state FIFO gate is SQL-side (removed false "enforced by the adapter" claim). AddedOutboxStoreAdapterTest.claimBatch_passes_all_repo_results_through_without_in_memory_fifo_filteringas regression guard (adapter passes all repo results through, no in-memory filter).:adapter-persistence:testALL PASS (11 tests). Side effect:OutboxRowLifecycleContractTest.fifo_ordering_head_row_appears_before_tail_in_relay_outcomesnow 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_duplicatesfails (test inserts use realInstant.now()while relay clock is fixed to an earlier instant — new uniformnext_attempt_at <= :nowexcludes 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-persistenceOutboxReaper.java. (1)@ScheduledfallbackPT1H→PT10M(aligned with plan I11 andapplication.ymlca-skeleton.outbox.reaper-interval: PT10M). (2)Duration retentionconstructor parameter had no Spring injection annotation; Spring cannot auto-wire an unresolvableDurationtype — added@Value("${ca-skeleton.outbox.published-retention:P7D}")so Spring'sApplicationConversionServiceconverts the ISO-8601 string tojava.time.Duration. Added comment "Single reaper-local value — @Value acceptable here; canonical six-property documentation lives in app-bootstrap OutboxProperties / application.yml." New test classOutboxReaperWiringTest(4 tests): 3ApplicationContextRunnertests verify context starts with default P7D retention and with explicit P30D property; 1 reflection drift-guard asserts@Valueexpression is exactly${ca-skeleton.outbox.published-retention:P7D}.ApplicationContextRunnerrequires.withInitializer(ctx -> ctx.getBeanFactory().setConversionService(ApplicationConversionService.getSharedInstance()))because the plainGenericApplicationContextit creates does not include Spring Boot's ISO-8601 Duration converter by default.:adapter-persistence:test55 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/:OutboxRowLifecycleContractTest.fifo_ordering_head_row_appears_before_tail_in_relay_outcomesrewritten 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 tot0+1sto ensure both head (nextAttemptAt=t0) and tail (nextAttemptAt=t0+1ms) are eligible.OutboxPublisherLeaderElectionContractTest.two_relay_instances_publish_all_1000_rows_with_zero_duplicates: rows now inserted with fixedoccurredAt=t0, relay clock set tot0+1s(1-second buffer ensuresnextAttemptAt=t0 <= now=t0+1s). Event/aggregate IDs namespaced toevt-leader-N/agg-leader-Nto avoid DB interference with lifecycle tests sharing the same container.- 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:test220 tests PASS.
-
2026-06-11 FIX dispatch — ApplicationContextRunner + Duration @Value:
ApplicationContextRunnercreates aGenericApplicationContext, which does NOT register Spring Boot'sApplicationConversionService.@Value("${...}")injectingjava.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 —@SpringBootTestand@DataJpaTestslices register the conversion service automatically viaSpringApplication.configureContext, butApplicationContextRunnerdoes not. -
2026-06-11 FIX dispatch — OutboxProperties missing positive-value guards for reaperInterval and publishedRetention (ca-quality-reviewer finding #2):
OutboxPropertiescompact constructor hadisZero() || isNegative()guards forpollInterval(line 53) andinFlightTimeout(line 67), butreaperIntervalandpublishedRetentiononly applied null→default without the same positive-value guard. A misconfiguredpublished-retention=PT-1Hwould silently pass validation and cause the reaper to compute a cutoff in the future (deleting nothing, non-obvious). Fix: added identicalelse if (field.isZero() || field.isNegative()) throw IllegalArgumentException(...)branches for both fields. TDD: 4 new tests added toOutboxPropertiesTest(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 usedrepository.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: replacedifPresentwithorElseThrow(() -> new IllegalStateException("outbox row not found for eventId=" + eventId))in all three methods. Also added a one-line clarifying comment tooldestUnpublishedAgeSecondsByEventTypeexplaining whyHashMap(String key) is correct whilecountByStatususesEnumMap(enum key) — resolving finding #4. TDD: 3 new tests added toOutboxStoreAdapterTest(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 afterorElseThrowimplementation (BUILD SUCCESSFUL). Relay interaction note: inPublishPendingOutboxEventsUseCase.publishOne, bothpublishPort.publish(event)ANDtx.inWrite(() -> store.markPublished(event.eventId()))are inside the sametryblock. IfmarkPublishedthrowsIllegalStateException(row not found), it is caught bycatch (RuntimeException publishEx)andhandlePublishFailureis invoked — which then attemptsmarkFailed/markDeadon the same missing row, which also throws. The second exception propagates out ofhandle()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 isadapter-persistenceonly;application-corewas not modified. Evidence:actually-implemented,locally-verified. -
2026-06-11 FIX dispatch — publishOne try/catch scope bug (markPublished failure misclassification): Bug:
publishOnewrapped BOTHpublishPort.publish(event)ANDtx.inWrite(() -> store.markPublished(event.eventId()))in a singletry/catch (RuntimeException). A transient store failure onmarkPublishedafter a SUCCESSFUL broker publish was therefore caught and dispatched tohandlePublishFailure, which either marked the row FAILED (or DEAD whenattemptCount >= 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 topublishPort.publish(event)only;store.markPublishednow 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 existingcatch (Exception ex)inOutboxRelayScheduler.relay()(line 85) logs the propagated exception at ERROR and lets the tick continue. Trade-off accepted: a mid-batchmarkPublishedfailure 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 inPublishPendingOutboxEventsUseCaseTest—mark_published_failure_propagates_and_does_not_misclassify_as_publish_failureandmark_published_failure_aborts_remaining_batch_for_current_tick— using newThrowingOnMarkPublishedStorePortfake. 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:testBUILD 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@ConfigurationPropertiesrecord (adapter-outbound):app.cache.bindings.*(논리 캐시명 → backendId 매핑) 를 바인딩하는CacheBindingSettingsrecord 추가.@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).KafkaAdapterSettingsMap.copyOf방식 선례 준수. 신규 env key 없음. 변경 파일 2건:CacheBindingSettings.java(신규),CacheBindingSettingsTest.java(신규). 근거 등급:actually-implemented,locally-verified. -
2026-06-12 Task 3 (cachestore-multi-backend-router plan) —
RedisCacheStorethin binding +CacheBackendException(adapter-outbound):RedisCacheStore를 fail-open 로직 없는 얇은 클라이언트 바인딩으로 교체. 신규:CacheBackendException(String backendId, Throwable cause)(unchecked —CacheStore시그니처는 checked exception 없음, seamRedisClient.read/write는throws Exception).RedisCacheStore는try/catch(Exception)→CacheBackendException래핑만 수행 (fail-open 정책은FailOpenCacheStore데코레이터로 위임).RedisCacheAdapterConfig.redisCacheStore빈 메서드가new FailOpenCacheStore("redis", new RedisCacheStore(redisClient), logger)를 조립하도록 수정 (importFailOpenCacheStore추가).FailOpenCacheStorejavadoc 의{@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:testPASS (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) —
AdapterDisabledExceptiondetail 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.javaonly (writable scope:src/application-core/src/test/**). (1) Important — assertion gap: line 252 used.contains("evt-first")inmark_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-qualifiedorg.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, ...)(lines 205, 247) replaced with AssertJassertThatThrownBy(...).isInstanceOf(RuntimeException.class).hasMessage("DB down on markPublished")— consistent with the rest of the file. Addedimport static org.assertj.core.api.Assertions.assertThatThrownBy. (3) Minor — ThrowingOnMarkPublishedStorePort dedup:ThrowingOnMarkPublishedStorePort(lines 327-371) duplicated the full body ofFakeOutboxStorePort. Removed the duplication by (a) changingFakeOutboxStorePortfromstatic final classtostatic classto allow extension, (b) wideningclaimablefromprivate finalto package-localfinalfor subclass access, (c) rewritingThrowingOnMarkPublishedStorePortasextends FakeOutboxStorePortwith only themarkPublishedoverride. 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
@ConfigurationPropertiesrecord 에 보조 생성자 추가 시 바인딩 깨짐 원인 +@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):