Files
llm-wiki/raw/interviews/transactional-outbox-skip-locked-implementation-2026-06-11.md

41 lines
3.9 KiB
Markdown
Raw Permalink 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: interview / transactional-outbox-skip-locked-implementation-2026-06-11
source_type: interview-prep
status: raw
related_branches: [feature-domain-event-outbox-contract]
related_projects: [ca-skeleton]
tags: [interview, ca-skeleton, outbox, skip-locked, messaging, concurrency, clean-architecture]
created: 2026-06-11
---
# interview: transactional outbox (SKIP LOCKED polling) 구현
> Layer: `raw/interviews/` — feature-domain-event-outbox-contract 실구현(Phase C2)에서 정직하게 나올 수 있는 질문.
## Parent / 부모
- [[raw/branch-notes/feature-domain-event-outbox-contract]]
## 예상 질문 / Q&A
- **Q. 왜 outbox 인가? 그냥 트랜잭션 커밋 후 publish 하면 안 되나?**
A. dual-write 문제. DB 커밋과 broker publish 는 원자적으로 묶을 수 없다(2PC 비현실적). 커밋 후 publish 전에 프로세스가 죽으면 이벤트 유실. outbox 는 상태 변경과 같은 트랜잭션으로 outbox row 를 insert 하고(if-and-only-if commit), 별도 relay 가 폴링해 발행한다. ca-tmpl 에서는 `OutboxAppendPort.append` 를 use case 의 `tx.inWrite` 안에서 호출하고, rollback 시 row 가 없음을 계약 테스트(`OutboxAppendTransactionalContractTest`)로 고정했다.
- **Q. 멀티 인스턴스에서 같은 이벤트를 두 publisher 가 잡지 않는 보장은?**
A. 별도 leader election 인프라(Redis/ZooKeeper) 없이 DB row-level claim: `SELECT ... FOR UPDATE SKIP LOCKED`. 락을 못 잡는 row 는 대기 없이 skip 되므로 두 인스턴스가 서로 다른 row 를 가져간다. 계약 테스트로 2개 Spring context 가 1000 row 를 나눠 발행해 합계 1000·중복 0 을 단언했다. `StartupSafetyValidator` 는 multi-instance 모드에서 `outboxLeaderElection` bean 존재를 기동 시 강제한다.
- **Q. per-aggregate 순서(FIFO)는 어떻게 보장하나? SKIP LOCKED 는 순서를 깨지 않나?**
A. 깬다(공식 문서가 inconsistent view 명시). 그래서 claim query 에 게이트를 넣었다: `NOT EXISTS (같은 aggregate 의 더 이른 occurred_at row 가 PUBLISHED 가 아닌 상태)` — 배치에는 aggregate 당 head 1건만 들어온다. head 가 FAILED/IN_FLIGHT/DEAD 인 동안 후행은 차단(strict FIFO). 트레이드오프: poison event 1건이 그 aggregate 스트림을 멈춤 → DEAD runbook 의 수동 처분(재발행 또는 skip)으로 해제. global ordering 은 보장하지 않는다고 명시.
- **Q. publisher 가 claim 후 죽으면(IN_FLIGHT orphan)?**
A. 별도 컬럼 없이 `next_attempt_at` 을 visibility timeout 으로 재사용: claim 시 `IN_FLIGHT + next_attempt_at = now + PT5M`. 만료된 IN_FLIGHT 는 재claim 가능. publish 직후 상태 갱신 전 crash 면 재발행되므로 at-least-once — consumer 의 idempotencyKey dedupe 가 흡수(계약 테스트: 동일 key 5회 전달 → 1회 처리).
- **Q. 발행 실패 분류는?**
A. 일시 실패 → `OUTBOX_PUBLISH_FAILED`(TRANSIENT_DEPENDENCY) + FAILED + 지수 backoff(30s × 2^(n-1) + full jitter), max attempts 3 소진 → `OUTBOX_DEAD_LETTER`(INTERNAL) + DEAD + runbook. 분류 코드·메트릭(`outbox.publisher.published.total{outcome}`, `outbox.pending.size{status}`, `outbox.publisher.lag`)은 registry 기존 값 재사용.
- **Q. 기존 KafkaMessagePublisher 가 fail-open(실패 삼킴)인데 relay 가 그걸 쓰면?**
A. 못 쓴다 — relay 는 실패를 봐야 FAILED/DEAD 상태머신을 돌린다. 그래서 fail-closed 전용 포트(`OutboxMessagePublishPort`)를 application-core 에 정의하고 adapter-outbound 에서 `KafkaSender` seam 에 직결해 예외를 전파시켰다. fail-open 경로는 "use case 직발행 + outbox 가 durability 담당" 시나리오용이라 공존이 맞다.
- **Q. claim 트랜잭션 isolation 은?**
A. `READ_COMMITTED` 명시 pin (vendor default 금지 — MySQL InnoDB 는 REPEATABLE READ). claim 은 짧고 단일 배치 단위라 SERIALIZABLE 불필요. publish 는 claim 트랜잭션 밖에서 수행.