--- title: ca-tmpl - Transactional Outbox 결정 (SKIP LOCKED polling) source_type: project status: verified confidence: high tags: [ca-tmpl, outbox, event-driven, actually-implemented, locally-verified] related_projects: [ca-tmpl] last_reviewed: 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-core`의 `OutboxAppendPort`, `OutboxStorePort`, `OutboxEvent`, `OutboxEventStatus`, `PublishPendingOutboxEventsUseCase`, `OutboxBackoffPolicy`. - `adapter-persistence-rdbms`의 `OutboxEventEntity`, `OutboxStoreAdapter`, `OutboxReaper`, `OutboxClaimRepository`, `OutboxEventJpaRepository`. - `adapter-persistence-postgresql`의 `PostgreSqlOutboxClaimRepository`와 `V3__outbox_event.sql`. claim query는 `FOR UPDATE SKIP LOCKED`를 사용한다. - `adapter-outbound`의 `OutboxMessagePublishAdapter`, `DisabledOutboxMessagePublisher`, `OutboxEnvelopeJson`. - `app-bootstrap`의 `OutboxConfig`, `OutboxSettings`, `OutboxRelayScheduler`, `OutboxMetrics`, `OutboxLeaderElectionToken`. - `sample-portfolio`의 `CreateWorkLogOutboxTest`와 `WorkLogReservedIntegrationEvent*` 계열이 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-11]] 는 `FOR 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를 분리한다. ## 관련 개념 - [[wiki/concepts/transactional-outbox-pattern]] ## Sources - [[raw/project-notes/ca-skeleton-operational-contract]] — §11 Adapter Failure / §29 Topic 3 (outbox 결정 canonical map) - [[raw/branch-notes/feature-domain-event-outbox-contract]] — outbox publisher SSOT, row status, claim transaction, at-least-once + dedupe 합의 - [[raw/branch-notes/feature-background-job-async-contract]] — outbox publisher가 공유하는 retry/DLQ vocabulary(exp backoff with jitter, max 3, DLQ exhausted) SSOT - [[raw/blog-topics/skip-locked-outbox-per-aggregate-fifo-gate-2026-06-11]] — SKIP LOCKED vs per-aggregate FIFO gate 블로그 글감 raw seed. ## Cluster / 묶음 - [[wiki/blog/ca-tmpl-transactional-outbox-pattern-2026-07-02]]