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 |
|
|
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_COMMITTEDisolation +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종 대안과 채택 사유:
- SKIP LOCKED polling — 채택. RDB만으로 운영 가능, Kafka Connect 인프라 불요, lag 수 초 허용 범위.
- Debezium CDC — 보류. WAL 기반으로 lag은 짧지만 Kafka Connect 클러스터·connector·slot 운영 인력 부재.
- Kafka Connect Outbox SMT (Debezium event router) — 보류. Debezium 도입 자체가 보류되므로 동반 제외.
- Dual-write (직접 publish) — 명시적 anti-pattern. 채택 안 함(outbox 채택의 negative reference).
- Event sourcing — 미채택. 전달 정합성이 아닌 도메인 모델링 결정이므로 ca-tmpl 범위 밖.
- Spring
@TransactionalEventListener— 미채택. JVM in-process 한정이라 외부 broker 발행에는 부적합. in-process side effect 용도로만 사용 가능. - 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 LOCKEDsemantics — 잠긴 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를 분리한다.
관련 개념
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.