최근 이벤트 기반 아키텍처(EDA)와 Kafka를 공부하면서 가장 자주 등장하는 패턴 중 하나가 Transactional Outbox 패턴이었다.
특히 핀테크 도메인에서는 결제, 정산, 송금 같은 데이터가 이벤트로 확산될 때 정합성이 굉장히 중요하기 때문에 이 패턴을 이해해둘 필요가 있다고 느꼈다.
DB 저장과 Kafka 발행은 왜 문제가 될까?
보통 주문 생성이나 결제 승인 같은 상황에서
- DB에 데이터를 저장하고
- Kafka로 이벤트를 발행해서
- 다른 서비스들이 해당 이벤트를 처리하는 구조를 많이 사용한다.
간단히 보면 이런 코드가 될 수 있다.
@Transactional public void pay() {
paymentRepository.save(payment);
kafkaProducer.send("payment-approved", event);
}
하지만 여기서 문제가 발생할 수 있다.
케이스 1. DB는 저장됐는데 Kafka 발행이 실패하면?
- 결제 정보는 DB에 저장되었지만
- 이벤트는 Kafka로 전달되지 못한다
결과적으로 다른 서비스(정산/알림)가 결제 완료를 인지하지 못한다.
→ 이벤트 유실(Event Loss)
케이스 2. Kafka는 발행됐는데 DB 트랜잭션이 롤백되면?
- 이벤트는 전파됐지만
- 실제 결제 데이터는 DB에 존재하지 않는다
→ 잘못된 이벤트 발행(Phantom Event)
즉, 핵심 문제는 DB 트랜잭션과 Kafka 발행을 하나의 원자적 작업으로 묶기 어렵다 는 점이었다.
Transactional Outbox 패턴의 핵심 아이디어
Transactional Outbox 패턴은 Kafka로 바로 이벤트를 보내지 않고 “보낼 이벤트”를 DB에 먼저 기록해두는 방식이다.
즉 서비스 로직에서는
- 비즈니스 데이터 저장
- Outbox 테이블에 이벤트 저장
까지만 수행한다.
@Transactional public void pay() {
paymentRepository.save(payment);
outboxRepository.save( new OutboxEvent("PaymentApproved", payload));
}
이렇게 하면 DB 커밋이 성공했다면
Outbox에도 반드시 이벤트가 남게 된다.
Kafka 발행은 누가 담당할까?
이후 Kafka 발행은 서비스 로직이 아니라 별도의 Outbox Publisher가 담당한다.
Publisher는 주기적으로 Outbox 테이블을 읽고
- status = PENDING 인 이벤트를 조회
- Kafka로 발행
- 성공 시 status = SENT로 변경
public void publish() {
List<Event> events = outboxRepository.findPending();
for (Event e : events) {
kafkaProducer.send(e.payload()); e.markSent();
}
}
Kafka 장애가 발생하더라도
- 이벤트는 Outbox에 남아있고
- Kafka가 복구되면 다시 발행할 수 있다.
따라서 “유실”이 아니라 “지연”이 된다.
Consumer는 그대로인데, 멱등성이 중요해진다
Outbox 패턴에서 Publisher는 재시도를 수행할 수 있기 때문에 Kafka 메시지는 중복 발행될 가능성이 있다.
그래서 Consumer에서는 Inbox 테이블을 두고 이미 처리한 이벤트인지 확인하는 방식이 흔하다.
if (inboxRepository.exists(eventId)) { return; // 중복 이벤트 skip }
processBusinessLogic(); inboxRepository.save(eventId);
정리하면
- Outbox는 Producer의 이벤트 유실 방지
- Inbox는 Consumer의 중복 처리 방지
역할을 가진다.