최근 이벤트 기반 아키텍처(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의 중복 처리 방지

역할을 가진다.

+ Recent posts