41 lines
3.9 KiB
Markdown
41 lines
3.9 KiB
Markdown
---
|
||
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 트랜잭션 밖에서 수행.
|