기술 블로그/MiriMiri

Outbox 패턴 vs. @TransactionalEventListener

개발자가 될 사람 2024. 6. 20. 11:23

MSA 환경에서 분산 트랜잭션 방식으로 구현 시 서비스 간 이벤트 발행이 필수이다.

각 서비스에서는 별도의 트랜잭션으로 로직을 처리하고 이벤트를 발행하게 되는데 이때 트랜잭션이 커밋 또는 롤백된 경우에만 이벤트가 발행되도록 보장해야 한다.

 

이 글에서는 Outbox 패턴과 Spring의 @TransactionalEventListener 방식을 고민한 끝에 @TransactionalEventListener를 사용하여 문제를 해결한 사례를 공유하고자 한다.

 

Outbox 패턴 vs. @TransactionalEventListener


MSA 환경에서는 서비스 간 데이터 일관성을 유지하기 위해 분산 트랜잭션 처리가 필요하다. 이때 트랜잭션 커밋 또는 롤백 시에만 카프카 이벤트가 발행되도록 보장해야 한다.

Outbox 패턴

Outbox 패턴은 데이터베이스의 Outbox 테이블에 이벤트를 저장하고, 트랜잭션 커밋 시 이를 카프카로 전송하는 방식이다.

 

장점

  • 데이터 일관성 보장: 트랜잭션 커밋 시에만 이벤트가 발행되므로 데이터 일관성이 보장된다.
  • 재전송 가능: Outbox 테이블에 이벤트가 저장되므로, 이벤트 발행 실패 시 재전송할 수 있다.

단점

  • 복잡성: Outbox 테이블 관리와 이벤트 전송 로직 구현이 필요하여 복잡도가 높다.

 

@TransactionalEventListener

Spring에서 제공하는 @TransactionalEventListener 어노테이션은 트랜잭션 커밋 또는 롤백 시에만 이벤트를 발행할 수 있다. 이 방식은 별도의 Outbox 테이블 없이 간단하게 구현할 수 있다는 장점과 트랜잭션 커밋 즉시 이벤트를 발행할 수 있다는 장점이 있다.

 

장점

  • 단순성: 별도의 Outbox 테이블 없이 간단하게 구현할 수 있다.
  • 즉시성: 트랜잭션 커밋 시 즉시 이벤트가 발행된다.

 

단점

  • 재전송 불가능: 이벤트 발행 실패 시 재전송할 수 없어 신뢰성이 저하될 수 있다.

 

@TransactionalEventListener 활용


단순하면서도 트랜잭션 커밋 시 즉시 이벤트가 발행 가능하다는 이유로 @TransactionalEventListener 방식을 선택했다.

이벤트 발행 리스너 구현

@Slf4j
@Component
public class TransactionEventListener {

    private final KafkaSender kafkaSender;

    public TransactionEventListener(KafkaSender kafkaSender) {
        this.kafkaSender = kafkaSender;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onOrderSuccess(ProcessOrderEvent event) {
        try {
            OrderRequestEventDto orderRequestEventDto = event.getOrderRequestEventDto();
            log.info("traceId={}, 카프카 결제 요청 이벤트 발행", orderRequestEventDto.getTraceId());
            kafkaSender.sendPaymentRequestEvent(KafkaVO.PAYMENT_REQUEST_TOPIC,
                    PaymentRequestEventDto.fromOrderRequest(orderRequestEventDto, event.getOrderId()));
        } catch (Exception e) {
            log.error("결제 요청 이벤트 발행 실패, 재시도 필요", e);
        }
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onOrderStatusToCanceledSuccess(CancelOrderEvent event) {
        try {
            log.info("카프카 주문 취소 이벤트 발행");
            kafkaSender.sendCancelOrderEvent(KafkaVO.CANCEL_ORDER_TOPIC,
                    new CancelOrderEventDto(event.getOrderId(), event.getGoodsId(), event.getQuantity()));
        } catch (Exception e) {
            log.error("주문 취소 이벤트 발행 실패, 재시도 필요", e);
        }
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onReturnSuccess(ReturnEvent event) {
        try {
            log.info("카프카 반품 완료 이벤트 발행");
            kafkaSender.sendRollbackRequestEvent(KafkaVO.STOCK_ROLLBACK_TOPIC,
                    new StockRollbackEventDto(event.getGoodsId(), event.getQuantity(), null));
        } catch (Exception e) {
            log.error("반품 완료 이벤트 발행 실패, 재시도 필요", e);
        }
    }
}

 

트랜잭션 커밋/롤백 시 이벤트 발행/취소

  • 트랜잭션 커밋 시: @TransactionalEventListener가 적용된 메서드가 호출되어 이벤트가 발행된다.
  • 트랜잭션 롤백 시: @TransactionalEventListener가 적용된 메서드가 호출되지 않아 이벤트가 발행되지 않는다.

이처럼 @TransactionalEventListener를 사용하면 별도의 Outbox 테이블 관리 없이도 트랜잭션 커밋/롤백 시에만 이벤트가 발행/취소되도록 구현할 수 있다.

@TransactionalEventListener 신뢰성 저하 문제

이처럼 @TransactionalEventListener 방식은 단순성과 즉시성이 장점이지만, 위에서 언급했듯이 이벤트 발행 실패 시 재전송할 수 없어 신뢰성이 저하된다는 단점이 있다.

 


@TransactionalEventListener의 신뢰성 문제와 Kafka를 활용한 해결 방안


@TransactionalEventListener 방식은 구현이 간단하고 트랜잭션 결과에 따라 이벤트를 처리할 수 있다는 장점이 있지만, 이벤트 발행 실패 시 재발행 메커니즘이 없다는 단점이 있다.

하지만 Kafka를 이벤트 스트리밍 플랫폼으로 사용하여 다음과 같이 신뢰성을 보장하기에 괜찮을 것이라 판단했다.

1. 이벤트 발행 후 Kafka에 저장
트랜잭션이 커밋되면 @TransactionalEventListener가 즉시 이벤트를 발행하고, 이 이벤트를 Kafka 토픽에 저장한다. Kafka는 이벤트를 복제하여 브로커 장애 시에도 데이터를 안전하게 보관한다.

2. Kafka의 오프셋 관리 활용
Kafka 컨슈머는 오프셋을 관리하여 이벤트를 중복 없이 정확히 한 번씩 처리한다. 이를 통해 이벤트 처리의 일관성을 유지할 수 있다.

3. 장애 복구 시 이벤트 재처리
이벤트 발행 후 소비 과정에서 장애가 발생하면, Kafka에 저장된 이벤트는 그대로 유지된다. 장애가 복구되면 Kafka 컨슈머는 마지막으로 처리된 오프셋 이후의 이벤트를 다시 소비하여 이벤트 손실 없이 신뢰성을 보장할 수 있다.

이와 같은 방식으로 @TransactionalEventListener의 신뢰성 문제를 Kafka의 특성을 활용하여 해결할 수 있다. 이를 통해 트랜잭션 커밋 또는 롤백 시 즉시 이벤트를 발행하면서도 데이터 손실 없이 신뢰성을 유지할 수 있다.

 

이외에도 로그를 통해 원인을 파악하고 Kafka 메트릭에 대해 Prometheus나 Grafana와 같은 모니터링 툴을 사용해 문제에 대응할 수 있을 것으로 보인다.

또한 Kafka의 분산 아키텍처 특성을 살려 카프카 클러스터 내의 브로커 수를 늘려 고가용성부하 분산을 통해 신뢰성 저하 문제를 해결할 수 있을 것으로 보인다.

 


결론


MSA 환경에서 분산 트랜잭션을 처리할 때 Outbox 패턴과 @TransactionalEventListener 방식을 고민한 끝에, 단순성과 즉시성의 이유로 @TransactionalEventListener를 활용하여 문제를 해결할 수 있었다. 이를 통해 별도의 Outbox 테이블 관리 없이도 트랜잭션 커밋/롤백 시에만 이벤트가 발행/취소되도록 구현할 수 있었다.