144 lines
10 KiB
Markdown
144 lines
10 KiB
Markdown
---
|
|
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 / 묶음
|
|
|
|
<!-- GENERATED: derived-blogs:start -->
|
|
- [[wiki/blog/ca-tmpl-transactional-outbox-pattern-2026-07-02]]
|
|
<!-- GENERATED: derived-blogs:end -->
|