--- 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 기준으로 재확인한다.