Files
llm-wiki/vault/30-knowledge/projects/ca-tmpl/transactional-outbox-pattern.md
T

10 KiB

title, source_type, status, confidence, tags, related_projects, last_reviewed
title source_type status confidence tags related_projects last_reviewed
ca-tmpl - Transactional Outbox 결정 (SKIP LOCKED polling) project verified high
ca-tmpl
outbox
event-driven
actually-implemented
locally-verified
ca-tmpl
2026-07-02

ca-tmpl - Transactional Outbox 결정 (SKIP LOCKED polling)

Layer: wiki/projects/ — 내 프로젝트(ca-tmpl) 사실. 일반 패턴 정의는 wiki/concepts/transactional-outbox-pattern 참조.

프로젝트 컨텍스트

ca-tmpl은 Clean Architecture 기반 백엔드 skeleton 템플릿입니다. 도메인 변경과 외부 이벤트 발행의 정합성 요구에서 dual-write를 회피하기 위해 outbox table + SKIP LOCKED polling 방식을 채택한다는 운영 계약을 문서화한 상태입니다.

진척 상황:

  • C2 구현 + 로컬 검증 완료: outbox row schema, append/store port, SKIP LOCKED claim repository, relay use case, scheduler, metrics, reaper, disabled publisher, sample event append path가 코드화되어 있다. 2026-07-02 ./gradlew check 통과로 로컬 검증했다.

본 문서는 그 결정 자체와 검토한 대안, 그리고 "지금 시점에 말할 수 있는 범위"를 분리해 둡니다.

실제 구현 내용 (actually-implemented)

  • application-coreOutboxAppendPort, OutboxStorePort, OutboxEvent, OutboxEventStatus, PublishPendingOutboxEventsUseCase, OutboxBackoffPolicy.
  • adapter-persistence-rdbmsOutboxEventEntity, OutboxStoreAdapter, OutboxReaper, OutboxClaimRepository, OutboxEventJpaRepository.
  • adapter-persistence-postgresqlPostgreSqlOutboxClaimRepositoryV3__outbox_event.sql. claim query는 FOR UPDATE SKIP LOCKED를 사용한다.
  • adapter-outboundOutboxMessagePublishAdapter, DisabledOutboxMessagePublisher, OutboxEnvelopeJson.
  • app-bootstrapOutboxConfig, OutboxSettings, OutboxRelayScheduler, OutboxMetrics, OutboxLeaderElectionToken.
  • sample-portfolioCreateWorkLogOutboxTestWorkLogReservedIntegrationEvent* 계열이 sample domain event → integration event/outbox append path를 검증한다.

로컬/dev 검증 (locally-verified)

  • ./gradlew check 통과(2026-07-02, BUILD SUCCESSFUL, 114 tasks).
  • PublishPendingOutboxEventsUseCaseTest, OutboxBackoffPolicyTest, NewOutboxEventTest가 application relay logic을 검증한다.
  • OutboxStoreAdapterTest, OutboxReaperTest, OutboxReaperWiringTest가 RDBMS adapter와 cleanup wiring을 검증한다.
  • OutboxRowLifecycleContractTest, OutboxPublisherLeaderElectionContractTest, OutboxAppendTransactionalContractTest가 PostgreSQL Testcontainers 기반으로 row lifecycle, SKIP LOCKED multi-relay claim, transactional append를 검증한다.
  • OutboxStatusRegistryContractTest, EventPayloadPiiContractTest, OutboxMessagePublishAdapterTest가 registry/status, payload safety, publish adapter를 검증한다.

운영 검증 (prod-verified)

없음. ca-tmpl은 skeleton 템플릿이며 운영 인스턴스가 존재하지 않음.

문서/계획만 존재 (documented-only / planned)

다음 항목들은 모두 canonical operational contract(§11, §29 Topic 3) 및 branch-notes에 합의된 문서/설계 수준입니다. 구현 사실 아님.

Outbox row schema (implemented)

  • id, aggregate_type, aggregate_id, event_type, payload, headers, status, attempts, next_attempt_at, created_at, published_at, last_error 컬럼 어휘 합의.
  • row status: PENDING → IN_FLIGHT → PUBLISHED 정상 경로, 실패 시 FAILED → DEAD(DLQ).
  • per-aggregate FIFO 순서 보존을 목표로 함.

Publisher state machine (implemented)

  • claim transaction: READ_COMMITTED isolation + SELECT ... FOR UPDATE SKIP LOCKED LIMIT n.
  • multi-instance publisher 운영 시 row 단위 lock으로 중복 claim 방지.
  • publish 성공 → PUBLISHED로 update + commit.
  • publish 실패 → attempts++, next_attempt_at 갱신(backoff with jitter), FAILED로 회귀.
  • attempts >= max(=3) 도달 시 DEAD로 전이 후 DLQ 대상.

Retry / DLQ vocabulary (partially implemented)

  • exponential backoff with jitter, 최대 3회 retry, 그 이후 DEAD → DLQ.
  • DLQ 상태와 runbook은 존재하지만, 운영 재처리 도구/대시보드는 없다.

대안 검토 (decided, not implemented)

ca-tmpl이 outbox 구현 방식을 결정하면서 검토한 7종 대안과 채택 사유:

  1. SKIP LOCKED polling — 채택. RDB만으로 운영 가능, Kafka Connect 인프라 불요, lag 수 초 허용 범위.
  2. Debezium CDC — 보류. WAL 기반으로 lag은 짧지만 Kafka Connect 클러스터·connector·slot 운영 인력 부재.
  3. Kafka Connect Outbox SMT (Debezium event router) — 보류. Debezium 도입 자체가 보류되므로 동반 제외.
  4. Dual-write (직접 publish) — 명시적 anti-pattern. 채택 안 함(outbox 채택의 negative reference).
  5. Event sourcing — 미채택. 전달 정합성이 아닌 도메인 모델링 결정이므로 ca-tmpl 범위 밖.
  6. Spring @TransactionalEventListener — 미채택. JVM in-process 한정이라 외부 broker 발행에는 부적합. in-process side effect 용도로만 사용 가능.
  7. Netflix DBLog 류 자체 CDC — 미채택. 베이스라인 인프라 투자 규모가 ca-tmpl 범위를 초과.

Migration trigger (planned)

  • 다음 가정이 깨지면 Debezium CDC로 마이그레이션 검토:
    • publish lag SLO 위반(수 초 허용을 깨는 sub-second 요구가 생김), 또는
    • polling 쿼리로 DB load가 포화되는 신호 발생.
  • 현 시점에는 가정이 유지된다고만 말할 수 있음. 도입 시점/일정 약속 없음.

면접에서 말할 수 있는 범위

자신 있게 답할 수 있는 질문

  • dual-write가 왜 위험한가 — DB commit과 broker publish 사이의 프로세스/네트워크 실패가 정합성을 깨는 시나리오를 설명할 수 있음.
  • FOR UPDATE SKIP LOCKED semantics — 잠긴 row를 차단 없이 skip하여 multi-instance publisher 간 claim 경합을 해소하는 원리, 잠금 범위가 row 단위 + 트랜잭션 종료 시 해제임을 설명할 수 있음.
  • outbox cleanup 정책의 필요성 — archived row를 TTL/파티션 회전으로 정리하지 않으면 인덱스 비대·vacuum 비용 증가가 발생하는 이유.
  • at-least-once + idempotent consumer — outbox + 비동기 publish가 exactly-once가 아니라는 점과, consumer가 eventId/idempotencyKey로 dedupe해야 정합성이 닫힌다는 점.

적당히 답할 수 있는 질문

  • Debezium CDC migration trigger — 어떤 가정(lag SLO, DB load)이 깨질 때 전환을 정당화하는지 설명 가능. 단, 실제 운영 경험은 없음.
  • outbox row status 머신 — 어휘는 합의되어 있으나 직접 구현하지는 않았음을 전제로 설명.

답하면 안 되는 질문 (모른다고 해야 함)

  • "outbox를 직접 구현했는가" → 구현했다. 단 로컬/Testcontainers 검증까지이며 운영 배포 검증은 없다.
  • "polling lag을 측정해 본 수치는?" → 측정값 없음. relay 동작 검증은 있지만 부하/lag 수치 단정 금지.
  • "DLQ 운영 / 재처리 경험" → 어휘는 정의했지만 실제 DLQ를 운영해 본 적 없음.
  • "production에서 outbox로 인한 인시던트 처리 경험" → 운영 인스턴스 자체가 없음.

과장 금지 지점

ca-tmpl을 설명할 때 사실보다 부풀려지기 쉬운 표현:

  • "outbox = exactly-once delivery" → 틀림. 정확한 표현은 at-least-once delivery + idempotent consumer. ca-tmpl 운영 계약도 at-least-once 전제.
  • "Debezium도 검토했고 곧 도입 예정" → 틀림. Debezium은 검토 결과 migration trigger만 정의된 상태이며 도입 일정·작업 없음. "lag 가정이 깨질 때만 전환을 검토한다"가 정확.
  • "outbox 패턴을 운영에서 검증했다" → 틀림. 구현과 로컬/Testcontainers 검증은 있으나 운영 배포·측정은 없다.
  • "SKIP LOCKED로 모든 동시성 문제를 막았다" → 틀림. SKIP LOCKED는 claim 단계 row 경합만 해소. publish 후 commit 실패로 인한 재발행은 별개 문제이며 consumer dedupe가 해결.
  • "event sourcing도 비교 검토했고 도입할 수 있었다" → 과장. event sourcing은 도메인 재설계 결정이며 ca-tmpl 범위 밖. "비교군으로만 언급"이 정확.
  • DLQ / 재처리 경험을 가진 것처럼 말하기 → 어휘 합의만 있고 운영 경험 없음.

Blog-topic ingest: outbox ordering gate (2026-07-02)

raw/blog-topics/skip-locked-outbox-per-aggregate-fifo-gate-2026-06-11FOR UPDATE SKIP LOCKED claim이 row 경합은 줄이지만 per-aggregate FIFO와 충돌할 수 있다는 점, 그리고 NOT EXISTS head gate로 tail 선발행을 막는 설계를 블로그로 풀기 위한 raw seed다.

  • canonical 반영 범위: SKIP LOCKED polling 결정 문서에 ordering gate와 strict FIFO trade-off 글감을 연결했다.
  • blogify 전 조건: 충족. 이 문서는 2026-07-02 기준 코드와 ./gradlew check로 검증됨.
  • 블로그 전 과장 방지: SKIP LOCKED가 순서 보존까지 해결한다고 쓰지 않고, claim 경합 해소와 ordering gate를 분리한다.

관련 개념

Sources

Cluster / 묶음