91 lines
5.6 KiB
Markdown
91 lines
5.6 KiB
Markdown
---
|
||
title: blog-topic / skip-locked-outbox-per-aggregate-fifo-gate-2026-06-11
|
||
source_type: blog-topic
|
||
status: raw
|
||
related_branches: [feature-domain-event-outbox-contract]
|
||
related_projects: [ca-tmpl]
|
||
tags: [blog-topic, ca-tmpl, outbox, skip-locked, fifo, postgresql, testcontainers, clean-architecture]
|
||
created: 2026-06-11
|
||
status_label: ready-for-canonical
|
||
target_audience: backend-engineer
|
||
inspiration_url:
|
||
archive_url:
|
||
---
|
||
|
||
# blog-topic: skip-locked-outbox-per-aggregate-fifo-gate-2026-06-11
|
||
|
||
> Layer: `raw/blog-topics/` — 채용공고가 아닌 작업·학습·트러블슈팅에서 나온 블로그 글감 원석. canonical 정제 전 raw 후보이며, `wiki/blog/` 직접 생성 근거가 아니다.
|
||
|
||
## Parent / 부모
|
||
|
||
- [[raw/branch-notes/feature-domain-event-outbox-contract]] — outbox relay 구현 중 SKIP LOCKED 와 per-aggregate FIFO 의 충돌을 claim query 의 `NOT EXISTS` 게이트로 해소한 경험 단독 추출.
|
||
|
||
## 트리거 / Trigger
|
||
|
||
- 트리거 유형: `branch-work`
|
||
- `FOR UPDATE SKIP LOCKED` 폴링 outbox 는 멀티 인스턴스 claim 경합을 우아하게 풀지만, PostgreSQL/MySQL 공식 문서가 명시하듯 **순서를 깬다(inconsistent view)**. "per-aggregate FIFO 보장" 계약과 정면 충돌 — 1차 구현이 실제로 게이트를 빠뜨려 리뷰에서 잡혔고(head FAILED 인데 tail 이 먼저 발행되는 경로), 수정 과정 자체가 글감.
|
||
|
||
## 글감 코어 / Core idea
|
||
|
||
- **문제**: SKIP LOCKED 는 "락 못 잡으면 건너뛴다" — 같은 aggregate 의 이벤트 e1, e2 가 서로 다른 publisher 에 분산 claim 되거나, e1 이 FAILED(backoff 대기) 인 동안 e2 가 먼저 나가면 consumer 가 순서 역전을 본다.
|
||
- **해결**: claim query 에 상관 서브쿼리 게이트 —
|
||
`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')`.
|
||
배치에는 aggregate 당 head 1건만 들어오고, head 가 비-PUBLISHED(FAILED/IN_FLIGHT/**DEAD 포함**)인 동안 후행은 구조적으로 claim 불가. READ_COMMITTED 스냅숏을 읽는 게이트라 보수적(차단 우위)으로 동작.
|
||
- **트레이드오프 (strict FIFO)**: DEAD 가 후행을 영구 차단 → poison event 1건이 aggregate 스트림을 멈춘다. 자동 우회 대신 runbook 수동 처분(재발행 `PENDING` 리셋 vs skip `PUBLISHED` 마킹 — 이벤트 갭 승인 필요)으로 설계. backlog 증가는 `outbox.pending.size` P2 alert 가 감지.
|
||
- **검증**: Testcontainers PG 계약 테스트 3종 — ① 2개 Spring context × 1000 rows 동시 claim, 합계 1000·중복 0 (SKIP LOCKED 단일 claim), ② head FAILED/DEAD 시 tail 차단·head PUBLISHED 후 해제 (FIFO 게이트), ③ `next_attempt_at` 을 visibility timeout 으로 재사용한 IN_FLIGHT orphan 재claim.
|
||
- **부가 발견**: 공유 HikariDataSource 를 두 context 에 등록하면 첫 close 가 풀을 닫는다(`setDestroyMethodName("")` 필요) — [[raw/errors/testcontainers-two-context-shared-datasource-close-2026-06-11]].
|
||
|
||
## 왜 의미 있나 / Why it matters
|
||
|
||
- 국내외 outbox 글 대부분이 "SKIP LOCKED 로 폴링하면 된다"에서 멈춘다. **ordering 계약과의 충돌**과 그 해소(쿼리 레벨 게이트 + strict FIFO 의 운영 비용 명문화 + 계약 테스트로 고정)까지 다루는 글은 드물다.
|
||
- fail-open publisher(use case 직발행)와 fail-closed publisher(outbox relay)가 한 코드베이스에 공존해야 하는 이유도 곁들일 수 있는 실전 소재.
|
||
|
||
## 글감 / Topic seed
|
||
|
||
- 한 문장 요지: `FOR UPDATE SKIP LOCKED`는 claim 경합을 줄이지만 per-aggregate FIFO 보장과 충돌할 수 있어 head gate가 필요하다.
|
||
- 예상 제목 후보:
|
||
- SKIP LOCKED outbox에서 순서를 지키는 방법
|
||
- per-aggregate FIFO를 깨지 않는 outbox claim query
|
||
|
||
## 핵심 주장 후보 / Claim candidates
|
||
|
||
- 사실 후보:
|
||
- `SKIP LOCKED`는 잠긴 row를 skip하므로 동일 aggregate의 tail이 먼저 claim될 수 있다.
|
||
- `NOT EXISTS` head gate는 앞선 미발행 row가 있을 때 tail claim을 막는 방식이다.
|
||
- 의견/해석 후보:
|
||
- strict FIFO는 poison event가 aggregate stream을 멈추는 운영 비용을 동반한다.
|
||
|
||
## Outline seed
|
||
|
||
1. SKIP LOCKED가 해결하는 문제와 새로 만드는 ordering 문제를 분리한다.
|
||
2. head gate query로 per-aggregate FIFO를 보강한다.
|
||
3. DEAD row가 tail을 막는 strict FIFO의 운영 비용과 runbook 필요성을 설명한다.
|
||
|
||
## Canonical 전환 후보 / Canonical extraction candidates
|
||
|
||
- `wiki/projects/ca-tmpl/transactional-outbox-pattern.md` 후보:
|
||
- SKIP LOCKED vs per-aggregate FIFO gate 글감.
|
||
- 필요한 추가 검증:
|
||
- branch-note/code 기준 실제 Testcontainers 검증 여부.
|
||
|
||
## Sources / 근거 후보
|
||
|
||
- [[raw/branch-notes/feature-domain-event-outbox-contract]]
|
||
- [[raw/errors/testcontainers-two-context-shared-datasource-close-2026-06-11]]
|
||
|
||
## 미해결 / Unknown
|
||
|
||
- 아직 확인해야 할 사실: 현재 canonical의 구현 없음 기록과 raw seed의 검증 주장 간 차이.
|
||
- 과장하면 안 되는 부분: SKIP LOCKED가 ordering을 자동 보장한다고 쓰지 않는다.
|
||
|
||
## Related / 관련
|
||
|
||
- 관련 branch: [[raw/branch-notes/feature-domain-event-outbox-contract]]
|
||
- 관련 error: [[raw/errors/testcontainers-two-context-shared-datasource-close-2026-06-11]]
|
||
|
||
## Decision / 처리 결정
|
||
|
||
- 액션: `promote-to-canonical`
|
||
- 이유: `wiki/projects/ca-tmpl/transactional-outbox-pattern.md` 에 SKIP LOCKED와 per-aggregate FIFO gate 글감으로 반영했다.
|
||
- 다음 단계: target canonical이 아직 `draft` 이므로 `blogify` 전 outbox 구현·Testcontainers 검증 여부를 branch-note/code 기준으로 재확인한다.
|