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

3.9 KiB
Raw Permalink Blame History

title, source_type, status, related_branches, related_projects, tags, created
title source_type status related_branches related_projects tags created
interview / transactional-outbox-skip-locked-implementation-2026-06-11 interview-prep raw
feature-domain-event-outbox-contract
ca-skeleton
interview
ca-skeleton
outbox
skip-locked
messaging
concurrency
clean-architecture
2026-06-11

interview: transactional outbox (SKIP LOCKED polling) 구현

Layer: raw/interviews/ — feature-domain-event-outbox-contract 실구현(Phase C2)에서 정직하게 나올 수 있는 질문.

Parent / 부모

예상 질문 / 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 트랜잭션 밖에서 수행.