이벤트를 업무 데이터와 같은 DB, 같은 트랜잭션으로 기록한 뒤, 별도 프로세스가 읽어서 발행하는 패턴. dual-write 문제의 표준 해법이다.
구조
다이어그램 로딩 중…
outbox 테이블
CREATE TABLE outbox (
id BIGINT PRIMARY KEY,
aggregate_id VARCHAR(64), -- 어떤 주문에 대한 것인지 (파티션 키로 사용)
event_type VARCHAR(64),
payload JSON,
created_at TIMESTAMP,
published_at TIMESTAMP NULL -- NULL이면 아직 안 보냄
);
저장 시점
@Transactional
public void createOrder(Order order) {
orderRepository.save(order); // 업무 데이터
outboxRepository.save(toEvent(order)); // 이벤트 — 같은 트랜잭션
} // 커밋 → 둘 다 남거나, 둘 다 없거나
하나의 트랜잭션이므로 "주문은 있는데 이벤트는 없다"가 원리적으로 불가능하다.
릴레이 — 두 가지 방식
① 폴링
SELECT * FROM outbox WHERE published_at IS NULL ORDER BY id LIMIT 100;
간단하지만 주기적 쿼리 부담이 있고, 여러 인스턴스가 돌면 중복 발행을 막아야 한다
(FOR UPDATE SKIP LOCKED 활용).
② CDC (Change Data Capture) Debezium 같은 도구가 DB의 트랜잭션 로그(MySQL binlog, PostgreSQL WAL)를 읽어 outbox 삽입을 감지해 발행한다. 폴링 부담이 없고 지연이 짧다.
남는 특성 — At-Least-Once
발행에 성공하고 published_at을 기록하기 직전에 죽으면 다시 발행된다.
그래서 소비자는 여전히 멱등해야 한다. Outbox가 해결한 것은 유실이지 중복이 아니다.
운영에서 챙길 것
- 발행 지연 모니터링 —
created_at과 현재 시각의 차이가 벌어지면 릴레이 장애 - 테이블 정리 — 발행된 행이 무한히 쌓이므로 주기적으로 삭제하거나 파티셔닝
- 순서 — 같은
aggregate_id는 순서대로 발행해야 한다. id 순서대로 읽고, 파티션 키로aggregate_id를 쓰면 소비 측 순서까지 보장된다