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에서 다시 발행
도메인 서비스생성 · 수정 · 삭제같은 트랜잭션MySQL비즈니스 데이터 변경commit 또는 rollbackOutbox 이벤트 저장미전송 이벤트 보존커밋 후 발행Message Relay즉시 발행 · 재시도성공 확인Kafka
비즈니스 데이터와 이벤트를 함께 저장하고, Kafka 전송 성공을 확인한 뒤 Outbox 삭제

핵심 코드

@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가 다시 발행