DEVELOPMENT NOTE / Modu Square
Transactional Outbox Pattern 구현
문제
데이터 저장과 Kafka 이벤트 발행은 서로 다른 시스템에서 실행돼 하나의 트랜잭션으로 묶을 수 없음.
- DB commit 후 Kafka 발행 실패: 데이터는 저장됐지만 다른 서비스가 변경 사실을 받지 못함
- Kafka 발행 후 DB commit 실패: 존재하지 않는 데이터의 이벤트가 먼저 전달됨
기술 선택 이유
-
Kafka 직접 발행 — 제외
- MySQL commit과 Kafka 전송 사이의 실패 구간에서 이벤트 유실이나 데이터 불일치 가능
-
Two-Phase Commit — 제외
- 참여 시스템의 commit·rollback을 조정할 수 있지만 MySQL·Kafka 통합 제약과 처리 지연, coordinator 운영 부담 발생
-
CDC 기반 Outbox Relay — 제외
- polling은 없앨 수 있지만 Debezium·Kafka Connect의 실행 상태와 전송 지연, 장애 복구를 별도로 관리해야 함
-
Transactional Outbox — 채택
- 비즈니스 데이터와 이벤트를 같은 MySQL 트랜잭션에 저장
- Kafka 장애를 서비스 요청과 분리하고 미전송 이벤트는 DB에서 다시 발행
핵심 코드
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void createOutbox(OutboxEvent event) {
outboxRepository.save(event.getOutbox());
}
@Async("messageRelayPublishEventExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void publishEvent(OutboxEvent event) {
publishEvent(event.getOutbox());
}
private void publishEvent(Outbox outbox) {
try {
messageRelayKafkaTemplate.send(
outbox.getEventType().getTopic(),
outbox.getPayload()
).get(1, TimeUnit.SECONDS);
outboxRepository.delete(outbox);
} catch (Exception e) {
// polling relay가 다시 발행할 수 있도록 Outbox 유지
}
}
- BEFORE_COMMIT : 비즈니스 데이터와 Outbox를 함께 저장하고 실패 시 모두 rollback
- AFTER_COMMIT : Kafka 발행을 시작하고 전송 성공을 확인한 뒤 Outbox 삭제
- 남은 이벤트는 polling relay가 다시 발행