Files
llm-wiki/vault/40-publish/blog-topics/skip-locked-outbox-per-aggregate-fifo-gate-2026-06-11.md
T

91 lines
5.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 기준으로 재확인한다.