"DB 저장"과 "이벤트 발행"을 원자적으로 만드는 패턴.
무엇이 문제인가 — 이중 쓰기
@Transactional
void order() {
repo.save(order); // ① DB
kafkaTemplate.send("orders", event); // ② 브로커
}
- ① 은 성공, ②는 실패 → 주문은 있는데 알림·정산이 안 간다
- ② 는 성공, ①은 롤백 → 없는 주문의 이벤트가 나갔다
서로 다른 두 시스템에 쓰기 때문에 한 트랜잭션으로 묶을 수 없다 (2PC 는 앞서 본 이유로 피한다)
"결제는 됐는데 알림이 안 갔다" 의 정체다.
해법 — 같은 DB에 쓴다
@Transactional
void order() {
repo.save(order); // 같은 DB
outboxRepo.save(new Outbox( // 같은 트랜잭션
"Order", order.getId(), toJson(event)));
} // 둘 다 커밋되거나 둘 다 롤백된다 — 원자성 확보
CREATE TABLE outbox (
id BIGINT PRIMARY KEY,
aggregate_type VARCHAR(50),
aggregate_id VARCHAR(50),
payload JSONB,
created_at TIMESTAMPTZ DEFAULT now(),
published_at TIMESTAMPTZ -- NULL 이면 아직 미발행
);
그다음 누가 발행하나
-
① 폴링 릴레이
- published_at IS NULL 인 행을 주기적으로 읽어 발행하고 표시한다
- ✅ 단순하다 ❌ 폴링 지연 · DB 부하
-
② CDC (Debezium 등)
- DB 의 변경 로그(WAL/binlog)를 읽어 outbox 삽입을 감지해 발행한다
- ✅ 지연이 적고 DB 부하가 없다 ❌ 인프라가 하나 는다
여전히 중복은 생긴다
발행 후 published_at 을 표시하기 직전에 죽으면
- 재시작 후 같은 이벤트를 또 발행한다
즉 Outbox 로도 at-least-once 다
- 소비자가 반드시 멱등해야 한다
"Outbox + 멱등 컨슈머" 가 분산 정합의 골드 스탠더드로 불리는 이유
커밋 후 발행만으로는 왜 부족한가
@TransactionalEventListener(phase = AFTER_COMMIT)
void publish(OrderPlaced e) { kafkaTemplate.send(...); }
커밋 직후·발행 직전에 인스턴스가 죽으면 이벤트가 영영 사라진다
- 유실을 감당할 수 있으면 이걸로 충분하다 (가볍다)
- 절대 잃으면 안 되면 Outbox 로 간다
선택 기준은 "유실을 감당할 수 있는가" 하나다.
면접 함정
- ❌ "Outbox를 쓰면 중복이 없다" → 유실을 막을 뿐 중복은 남는다.
- ❌ "트랜잭션 안에서 발행하면 된다" → 브로커는 롤백되지 않는다. 오히려 최악이다.
폴링 릴레이 구현
@Scheduled(fixedDelay = 500)
@Transactional
void relay() {
// SKIP LOCKED 로 여러 인스턴스가 서로 다른 행을 집어 간다
List<Outbox> batch = outboxRepo.findUnpublished(100); // FOR UPDATE SKIP LOCKED
for (Outbox o : batch) {
kafkaTemplate.send(o.topic(), o.aggregateId(), o.payload());
o.markPublished();
}
}
-- 리포지토리 쿼리
SELECT * FROM outbox WHERE published_at IS NULL
ORDER BY id LIMIT 100
FOR UPDATE SKIP LOCKED; -- 워커가 여러 대여도 경합 없이 나눠 갖는다
SKIP LOCKED 가 핵심이다. 없으면 인스턴스들이 같은 행을 두고 줄을 서서 사실상 한 대만 일하게 된다.
outbox 테이블을 비워야 한다
-- 발행된 행을 방치하면 계속 커지고 인덱스도 무거워진다
DELETE FROM outbox WHERE published_at < now() - INTERVAL '7 days';
-- 또는 파티셔닝해서 오래된 파티션을 통째로 DROP