MSA 환경에서 주문·결제 플로우 설계하기

이상훈·2026년 3월 24일

Project

목록 보기
17/17

현재 진행 중인 baro-farm 프로젝트에서 MSA 환경에서 주문·결제 프로세스를 구축하게 되었다. MSA 환경에서는 주문, 결제, 재고와 같은 여러 서비스가 협력해야 하기 때문에 단일 트랜잭션으로 처리하기 어려운 분산 트랜잭션 문제가 발생한다. 이번 글에서는 주문·결제 플로우를 설계하고 구현하는 과정에서 어떤 아키텍처적 고민을 했고, 이를 어떻게 해결했는지 정리해보려 한다.

1. Saga를 활용한 분산 트랜잭션 처리

이전에 작성했던 블로그 분산 환경에서 트랜잭션 처리하기에서 설명했듯이, MSA 환경에서는 여러 서비스에 걸친 작업을 하나의 ACID 트랜잭션으로 묶기 어렵다. 따라서 본 프로젝트에서는 확장성과 가용성을 고려하여 Saga 패턴을 기반으로 분산 트랜잭션을 처리하도록 설계했다. 그중에서도 중앙에서 전체 흐름을 제어하는 오케스트레이터를 두는 방식이 아니라, 각 서비스가 이벤트를 기반으로 다음 작업을 수행하는 Choreography 기반 Saga 패턴을 적용했다.

1.1 주문 요청 API

재고 예약 방식 vs 재고 즉시 차감

재고 관리 전략으로 재고 예약 방식을 선택했다. 즉시 차감 방식은 주문 생성 시 재고를 바로 감소시키기 때문에 결제 실패 시 이미 변경된 재고 상태를 복구하는 보상 트랜잭션이 필요하다. MSA 환경에서는 이벤트 유실이나 통신 장애로 인해 복구가 실패할 경우 실제로 판매되지 않은 재고가 차감된 상태로 남을 수 있다. 반면 재고 예약 방식은 결제 완료 전까지 재고를 임시 예약 상태로 관리하고 결제 성공 시에만 실제 차감을 수행하기 때문에 실패 시 예약 해제만으로 처리할 수 있어 Saga 기반 분산 시스템에서 더 안정적인 흐름을 만들 수 있다.

또한 비즈니스 관점에서도 재고 상태를 예약과 판매로 명확히 구분할 수 있어 운영 및 관리가 용이하다.


Feign 기반 동기 통신

주문 생성과 재고 예약 단계에서는 Kafka 기반의 비동기 이벤트 대신 Feign을 통한 동기 통신을 사용했다.

만약 주문 생성 이후 재고 예약을 이벤트 기반으로 처리한다면, 재고 예약이 실패했음에도 불구하고 주문이 이미 생성된 상태로 남아 결제 단계까지 진행되는 문제가 발생할 수 있다.

이를 방지하기 위해 주문 서비스가 재고 서비스에 Feign을 통해 동기적으로 재고 예약 요청을 수행하도록 설계했다.
재고 예약이 성공한 경우에만 주문 상태를 AWAITING_PAYMENT으로 변경하고 결제 프로세스를 진행하도록 하여, 재고 확보가 보장된 주문만 결제 단계로 넘어가도록 했다.

또한 서비스 간 동기 호출에서 발생할 수 있는 네트워크 장애나 일시적인 서버 오류에 대응하기 위해 다음과 같은 타임아웃 및 재시도 정책을 적용했다.

  • connectionTimeout = 2초 : 2초 이내에 TCP 커넥션을 맺지 못하면 요청을 실패로 처리
  • readTimeout = 3초 : 커넥션은 연결되었지만 3초 내에 응답이 오지 않으면 실패 처리
  • exponential backoff 기반 재시도(최대 3회) : 500ms를 시작으로 재시도 간격을 점진적으로 늘리며 동일 요청을 재시도
  • 에러 코드 기반 재시도 정책
    • 4xx (도메인/요청 오류) → 재시도 없이 즉시 실패 처리
    • 5xx (서버/인프라 오류) → 재시도 대상
      이러한 설정을 통해 일시적인 네트워크 장애나 서버 오류에 대해서는 자동 재시도를 수행하면서도, 비즈니스적으로 확정된 실패에 대해서는 불필요한 재시도를 방지하도록 설계했다.

이러한 설정을 통해 일시적인 네트워크 장애나 서버 오류에 대해서는 자동 재시도를 수행하면서도, 비즈니스적으로 확정된 실패에 대해서는 불필요한 재시도를 방지할 수 있도록 했다.

또한 재시도 과정에서 동일 요청이 중복 전달될 수 있기 때문에, 서버 측에서는 주문 ID(orderId) 기반의 멱등성을 보장하여 동일 요청이 여러 번 처리되지 않도록 설계했다.


보상 트랜잭션 처리

주문 처리 과정에서 예외가 발생할 경우 보상 트랜잭션을 수행하도록 설계했다. 먼저 주문 상태를 FAILED로 변경한 뒤, 재고 서비스에 재고 예약 취소 요청을 동기 호출(Feign) 방식으로 수행한다.

그러나 네트워크 단절이나 인벤토리 서비스 장애와 같은 문제로 재고 취소 API 호출이 실패할 경우, 주문은 FAILED 상태이지만 재고 예약은 여전히 유지되는 불완전한 상태가 발생할 수 있다.

이 문제를 해결하기 위해 보상 트랜잭션 수행 중 예외가 발생하면 해당 orderId에 대한 레코드를 CompensationRegistry 테이블에 저장하도록 설계했다. 이 테이블은 보상 처리가 완료되지 않은 작업을 관리하는 용도로 사용되며, 배치 또는 스케줄러가 PENDING 상태의 레코드를 주기적으로 조회하여 보상 작업을 재시도하도록 구성했다.

이를 통해 일시적인 장애로 인해 보상 트랜잭션이 실패하더라도 최종적으로 주문 상태와 재고 상태의 정합성을 보장할 수 있도록 했다.


1.2 결제 API

Kafka 기반 saga

결제 이후 흐름은 Payment → Inventory → Order → Settlement 순으로 Kafka 이벤트를 통해 전파되도록 설계했다. Payment 서비스가 Toss와 동기 통신으로 결제 승인을 완료하면 payment-confirmed 이벤트를 발행한다. Inventory 서비스는 해당 이벤트를 구독하여 재고 차감을 확정하고 inventory-confirmed 이벤트를 발행한다. 이후 Order 서비스는 주문 상태를 CONFIRMED로 변경하고 order-confirmed 이벤트를 발행하며, Settlement 서비스는 이를 구독해 정산 데이터를 생성하도록 구성했다.


보상 트랜잭션

Toss 결제 승인 실패 시에는 즉시 예외를 반환하여 흐름을 중단한다. 반면 결제 승인 이후 재고 차감 확정에 실패한 경우에는 이미 외부 결제가 완료된 상태이므로 Payment 서비스가 Toss 결제 취소(환불) API를 호출하는 보상 트랜잭션을 수행하도록 설계했다. 이를 통해 결제 상태와 재고 상태 간 불일치를 방지하고 최종 정합성(Eventual Consistency) 을 보장했다.


1.3 주문취소 API

Kafka 기반 saga

주문 취소 흐름은 Order → Payment → Inventory → Order → Settlement 순으로 Kafka 이벤트를 통해 전파되도록 설계했다. Order 서비스가 주문 취소 요청을 처리하고 order-cancel-requested 이벤트를 발행하면, Payment 서비스가 이를 구독하여 Toss 결제 취소 API를 동기 호출한다. 결제 취소가 완료되면 payment-canceled 이벤트를 발행하고, Inventory 서비스는 이를 구독해 기존에 차감되었던 재고를 복원한 뒤 inventory-canceled 이벤트를 발행한다. 이후 Order 서비스는 주문 상태를 CANCELED로 변경하고 order-canceled 이벤트를 발행하며, Settlement 서비스는 이를 구독해 정산 데이터를 생성하도록 구성했다.


보상 트랜잭션

주문 취소 과정에서 결제 취소가 실패할 경우에는 이미 결제가 완료된 상태이므로 예외를 반환하고 취소 흐름을 중단하도록 했다. 반면 재고 복원 단계에서 실패하는 경우에는 이미 결제 취소가 완료된 상태이기 때문에 다시 결제를 수행하는 것은 고객 입장에서 납득하기 어려운 상황이 된다. 따라서 이 경우에는 결제를 다시 복구하지 않고 재고 복원 작업을 재시도하거나 DLQ(Dead Letter Queue) 및 운영 복구를 통해 해결하도록 설계했다.


2. 카프카 메시지 전달 보장 수준 설정

현재 주문·결제 플로우는 단일 Kafka 브로커에서 payment-confirmed, inventory-confirmed, inventory-canceled 같은 SAGA 이벤트를 주고받는다. 이때 가장 먼저 결정해야 했던 것은 메시지를 어떤 수준으로 보장할 것인가였다. Kafka의 전달 보장 수준은 크게 세 가지다.

  • At-most-once : 메시지를 한 번만 전송하고, 장애가 발생해도 재전송하지 않는다. 설정은 단순하지만 프로듀서나 컨슈머 장애가 발생하면 메시지가 유실될 수 있다. 주문·결제 시스템에서 payment-confirmedinventory-confirmed 이벤트가 사라지면 결제는 완료됐지만 주문 상태가 갱신되지 않는 심각한 정합성 문제가 발생할 수 있다. 따라서 이 방식은 선택하지 않았다.

  • Exactly-once : 메시지를 정확히 한 번 처리하도록 보장하는 방식이다. Kafka의 Exactly-once는 Kafka 내부 파이프라인(read → process → write)에서는 강력한 처리 보장을 제공한다. 그러나 주문·결제 시스템처럼 DB 상태 변경과 Kafka 이벤트 발행이 함께 이루어지는 구조에서는 Dual Write 문제가 발생하며, 이를 Kafka의 Exactly-once만으로 end-to-end 보장하기는 어렵다. 따라서 본 프로젝트에서는 메시지 유실 가능성을 최소화하기 위해 At-least-once 전달 전략을 사용하고, 중복 이벤트는 멱등성(Idempotency) 처리로 해결하도록 설계했다. 또한 DB와 메시지 발행 간 정합성을 보장하기 위해 트랜잭셔널 아웃박스(Transactional Outbox) 패턴을 적용했다.

  • At-least-once : 메시지가 최소 한 번 이상 전달되는 것을 보장한다. 네트워크나 컨슈머 장애 상황에서는 중복 이벤트가 발생할 수 있다. 하지만 메시지 유실 가능성을 크게 줄일 수 있고, 중복 문제는 애플리케이션 레벨의 멱등성(idempotency) 으로 해결할 수 있다.

이 프로젝트에서는 At-least-once 전달 보장과 멱등성 처리 전략을 함께 사용했다. 메시지 유실을 방지하기 위해 At-least-once 전략을 택했으며 중복 이벤트는 orderId 기반 상태 전이로 안전하게 처리했다. 예를 들어 이미 CONFIRMED 상태인 주문에 payment-confirmed 이벤트가 다시 들어오면 추가 처리를 하지 않고 무시하도록 설계해 멱등성을 보장했다.


2.1 프로듀서측 설정

주요 프로듀서 설정은 다음과 같다.

  • acks=all : 단일 브로커라 내구성만 놓고 보면 acks=1과 큰 차이는 없지만, enable.idempotence=true를 사용하려면 acks=all이 요구 조건이고, 추후 브로커를 2~3대로 확장했을 때도 그대로 “리더 + ISR에 기록된 뒤에만 성공”으로 동작하게 하기 위해 확장성을 고려해 acks=all로 설정했다.

  • retries=3 : 네트워크 일시 장애나 브로커의 일시적 불안정 상황에서 자동 재시도를 수행한다. 이때 동일 레코드가 여러 번 전송될 수 있으므로 반드시 enable.idempotence=true와 함께 사용해야 한다. 재시도 횟수를 3으로 크게 잡지 않은 이유는 이 서비스가 Outbox 패턴을 사용하기 때문이다. 메시지의 궁극적인 내구성은 Kafka가 아니라 애플리케이션 DB의 Outbox 테이블이 보장하며, 프로듀서가 3회 재시도 후 실패하더라도 Outbox 레코드는 DB에 그대로 남는다. 이후 Outbox 소비자가 해당 레코드를 다시 읽어 Kafka로 재전송하므로, 프로듀서 레벨에서 재시도 횟수를 과도하게 늘릴 필요가 없다.

  • enable.idempotence=true : enable.idempotence=true는 프로듀서 재시도 과정에서 동일 메시지가 Kafka 파티션에 중복 append 되는 것을 방지한다. Kafka는 Producer ID(PID)와 sequence number를 사용해 중복 메시지를 필터링한다.


2.2 컨슈머측 설정

컨슈머는 Spring Kafka + @KafkaListener + @RetryableTopic 조합을 사용하며, 핵심 설정과 패턴은 다음과 같다.

  • spring container-managed commit : spring 기본 컨테이너 커밋 방식을 사용하며, 카프카 별도의 자동 커밋, 수동 커밋은 사용하지 않는다.

  • @RetryableTopic(attempts = "3", backoff = @Backoff(...)) : 일시적인 장애(네트워크, DB 일시 오류 등) 발생 시 최대 3회까지 재시도하고, 그래도 실패하면 해당 메시지를 DLT 토픽으로 격리한다. 이를 통해 계속 깨지는 독성 메시지가 메인 컨슈머의 처리를 가로막는 상황을 방지하고, DLT 토픽은 별도 모니터링/운영 플로우에서 후처리한다.

  • 상태 전이 기반 멱등성 : orderId를 키로 삼고 OrderStatus, PaymentStatus, InventoryReservationStatus를 엄격하게 관리해, 이미 CANCELED, FAILED, REFUNDED, CONFIRMED 등의 상태에 도달한 이후에 들어오는 동일/중복 이벤트는 그대로 무시한다. 덕분에 at-least-once 모드에서 동일 이벤트가 여러 번 소비되더라도, 실제 비즈니스 결과는 “한 번만 처리된 것”과 동일하게 유지된다.


2.3 이벤트 순서 보장

Kafka는 기본적으로 파티션 내부에서만 메시지 순서를 보장한다. 따라서 서로 다른 파티션에 기록된 이벤트 간에는 순서가 뒤바뀔 수 있다.

주문·결제 시스템에서는 동일 주문에 대해 payment-confirmed → inventory-confirmed → order-confirmed 와 같은 이벤트 흐름의 순서가 중요하다. 만약 이 이벤트들이 서로 다른 파티션에 기록되면, 소비 시점에서 순서가 뒤바뀌어 잘못된 상태 전이가 발생할 수 있다.

이를 방지하기 위해 본 프로젝트에서는 Kafka 메시지 키를 orderId로 설정했다. Kafka는 동일한 메시지 키를 가진 레코드를 항상 동일 파티션으로 라우팅하기 때문에, 같은 주문에 대한 이벤트는 하나의 파티션에서 처리된다.

이를 통해 동일 주문에 대한 이벤트 순서를 안정적으로 보장할 수 있도록 설계했다.


3. 트랜잭셔널 아웃박스 패턴

주문·결제 프로세스의 기본 구조는 payment-service에서 결제 데이터를 생성하고 Kafka로 payment-confirmed 이벤트를 발행하면 inventory-service가 이를 소비해 재고 예약/확정을 처리한다.

문제는 이 두 단계가 서로 다른 시스템에 쓰기를 한다는 점이다.

  • DB 트랜잭션: payment 테이블에 결제 행 추가
  • Kafka 전송: payment-confirmed 메시지 발행

둘 사이에 네트워크 장애나 애플리케이션 예외가 끼어들면, DB에는 결제 성공으로 남았는데, Kafka 이벤트는 발행되지 않은 상태가 충분히 발생할 수 있다. 이게 바로 Dual Write 문제다.

이 문제를 해결하기 위해서는 DB 변경과 이벤트 발행 사이의 정합성을 보장할 수 있는 구조가 필요했다. 프로젝트에서는 먼저 @TransactionalEventListener(AFTER_COMMIT) 기반 이벤트 발행 방식을 고려했다.


3.1 AFTER_COMMIT 방식과 비교

AFTER_COMMIT 방식은 payment 테이블에 결제 데이터를 저장한 뒤, DB 트랜잭션이 성공적으로 커밋되면 이벤트 리스너가 Kafka로 payment-confirmed 이벤트를 발행하는 구조다.

@Transactional
public void confirmPayment() {
    paymentRepository.save(payment);

    eventPublisher.publishEvent(
        new PaymentConfirmedEvent(...)
    );
}

@TransactionalEventListener(
    phase = TransactionPhase.AFTER_COMMIT
)
public void handle(PaymentConfirmedEvent event) {
    kafkaTemplate.send(...);
}

이 방식은 DB 트랜잭션이 롤백되면 이벤트도 함께 발행되지 않으며, 비즈니스 로직과 이벤트 발행 로직을 자연스럽게 분리할 수 있다는 장점이 있다. 또한 구현이 단순하고 별도의 Outbox 테이블이 필요하지 않다는 점에서 처음에는 충분히 합리적인 구조처럼 보였다.

여기에 Kafka 전송 실패 시 재시도(Retry)를 수행하고, 일정 횟수 이상 실패한 메시지는 DLQ(Dead Letter Queue)로 보내는 방식까지 적용하면 안정적인 구조를 만들 수 있을 것이라 생각했다.

하지만 실제로는 중요한 한계가 존재했다. AFTER_COMMIT 방식은 “트랜잭션 커밋 이후 이벤트 실행”은 보장하지만, “이벤트가 반드시 발행됨”까지는 보장하지 못한다는 점이다.

예를 들어 다음과 같은 상황이 발생할 수 있다.

  1. payment 테이블 COMMIT 성공
  2. AFTER_COMMIT 리스너 실행 직전 애플리케이션 서버 다운 또는 Kafka 브로커 장애 또는 네트워크 단절 발생

이 경우 DB에는 결제 성공 상태가 정상적으로 저장되지만, Kafka 이벤트는 발행되지 않은 상태가 된다. 즉, 결제 데이터와 이벤트 데이터 사이의 정합성이 깨질 수 있다.

문제는 AFTER_COMMIT 이벤트 자체가 JVM 메모리 기반이라는 점이다. 트랜잭션 커밋 이후 애플리케이션 프로세스가 종료되면, 재시도 대상 이벤트 정보도 함께 사라질 수 있다. 따라서 Retry와 DLQ를 구성하더라도 프로세스 장애 상황에서는 이벤트 유실 가능성을 완전히 제거할 수 없었다. 결국 프로젝트에서는 “이벤트 발행 요청 자체를 영속화해야 한다”고 판단했다. 이를 위해 트랜잭셔널 아웃박스 패턴을 적용했다.


3.2 Outbox 설계와 PaymentService

결제 서비스에서는 payment_outbox_event 테이블을 관리하는 PaymentOutboxEvent 엔티티를 두고, 결제 확정 시 아래처럼 도메인 상태 + Outbox 레코드를 한 번에 커밋한다.

@Transactional
public ResponseDto<TossPaymentConfirmInfo> confirmPayment(UUID userId, TossPaymentConfirmCommand command                                                                         
  ) {                                                                                                            
      // 1. PG 결제 승인                                                                                         
      TossPaymentResponse tossPayment = tossPaymentClient.confirm(command);                                      
                                                                                                                 
      // 2. 결제 엔티티 생성 및 저장                                                                             
      Payment payment = Payment.of(userId, tossPayment, ORDER_PAYMENT);                                          
      Payment saved = paymentRepository.save(payment);                                                           
                                                                                                                 
      // 3. payment-confirmed 이벤트를 Outbox에 기록                                                             
      PaymentConfirmedEvent event = new PaymentConfirmedEvent(                                                   
          saved.getOrderId(),                                                                                    
          saved.getAmount()                                                                                      
      );                                                                                                         
                                                                                                                 
      try {                                                                                                      
          String payload = objectMapper.writeValueAsString(event);                                               
                                                                                                                 
          PaymentOutboxEvent outbox = PaymentOutboxEvent.pending(                                                
              "PAYMENT",                                                                                         
              saved.getId().toString(),         // aggregateId: paymentId                                        
              "payment-confirmed",              // topic                                                         
              saved.getOrderId().toString(),    // correlationId: orderId                                        
              payload                           // JSON payload                                                  
          );                                                                                                     
          paymentOutboxEventRepository.save(outbox);
      } catch (JsonProcessingException e) {                                                                      
          throw new CustomException(PaymentErrorCode.OUTBOX_SERIALIZATION_FAILED);                               
      }                                                                                                          
                                                                                                                 
      // 4. 클라이언트 응답                                                                                      
      return ResponseDto.ok(TossPaymentConfirmInfo.from(saved));                                                 
  }

PaymentOutboxEvent 엔티티는 Outbox 테이블의 스키마를 캡슐화한다.

@Entity                            
@Table(                                                                                                        
      name = "payment_outbox_event",                  
      indexes = @Index(name = "idx_outbox_status_created_at", columnList = "status, created_at")
)
public class PaymentOutboxEvent extends BaseEntity {
      
      @Id                                                                                                      
      private UUID id;                                                                                           
                                                                                                                 
      @Column(name = "aggregate_type", nullable = false, length = 50)                                            
      private String aggregateType;   // "PAYMENT"                                                               
                                                                                                                 
      @Column(name = "aggregate_id", nullable = false, length = 100)                                             
      private String aggregateId;     // paymentId                                                               
                                                                                                                 
      @Column(name = "topic", nullable = false, length = 100)                                                    
      private String topic;           // ex) "payment-confirmed"                                                 
                                                                                                                 
      @Column(name = "correlation_id", nullable = false, length = 100)                                           
      private String correlationId;   // 보통 orderId                                                            
                                                                                                                 
      @Lob                                                                                                       
      @Column(name = "payload", nullable = false)
      private String payload;         // JSON                                                                    
                                                                                                                 
      @Enumerated(EnumType.STRING)                                                                               
      @Column(name = "status", nullable = false, length = 20)                                                    
      private PaymentOutboxStatus status;                                                                        
                                                                                                                 
      // 생성 시 기본 status = PENDING                                                                           
      private PaymentOutboxEvent(String aggregateType,                                                           
                                 String aggregateId,                                                             
                                 String topic,                                                                   
                                 String correlationId,                                                           
                                 String payload) {                                                               
          this.id = UUID.randomUUID();                                                                           
          this.aggregateType = aggregateType;                                                                    
          this.aggregateId = aggregateId;                                                                        
          this.topic = topic;                                                                                    
          this.correlationId = correlationId;                                                                    
          this.payload = payload;                                                                                
          this.status = PaymentOutboxStatus.PENDING;                                                             
      }                                                                                                          
                                                                                                                 
      public static PaymentOutboxEvent pending(                                                                  
          String aggregateType,                                                                                  
          String aggregateId,                                                                                    
          String topic,                                                                                          
          String correlationId,                                                                                  
          String payload                                                                                         
      ) {                                                                                                        
          return new PaymentOutboxEvent(aggregateType, aggregateId, topic, correlationId, payload);              
      }                                                                                                          
                                                                                                                 
      public void markSent()   { this.status = PaymentOutboxStatus.SENT; }                                       
      public void markFailed() { this.status = PaymentOutboxStatus.FAILED; }                                     
  }

이렇게 하면 “결제 저장 + Outbox 레코드 INSERT”는 하나의 DB 트랜잭션으로 보장된다. 이제 남은 문제는 Outbox → Kafka 구간을 어떻게 구현할지다.


3.3 @Scheduled 폴링

각 서비스 내부에 @Scheduled 작업을 두고, 주기적으로 Outbox 테이블을 폴링하는 구조다.

결제 서비스 기준 흐름은 다음과 같다.

  1. 스케줄러가 status = PENDING 인 레코드를 created_at 순서로 N개씩 조회
  2. Outbox 레코드를 Kafka로 전송 (topic 컬럼을 그대로 사용)
  3. 성공 시 markSent(), 실패 시 markFailed() + 재시도
  4. 일정 횟수 이상 실패한 레코드는 알람/운영 대상

실제 코드는 대략 아래와 같았다.

@Component                                                                                                     
  @RequiredArgsConstructor                                                                                       
  @Slf4j                                                                                                         
  public class PaymentOutboxPublisher {
                                                                                                                 
      private final PaymentOutboxEventJpaRepository outboxRepository;                                            
      private final KafkaTemplate<String, String> kafkaTemplate;                                                 
                                                                                                                 
      @Scheduled(fixedDelay = 2000L)                                                                             
      @Transactional                                                                                             
      public void publishOutboxEvents() {                                                                        
          List<PaymentOutboxEvent> events =                                                                      
              outboxRepository.findTop100ByStatusOrderByCreatedAtAsc(PaymentOutboxStatus.PENDING);               
                                                                                                                 
          for (PaymentOutboxEvent event : events) {                                                              
              try {                                                                                              
                  kafkaTemplate.send(                                                                            
                      event.getTopic(),         // ex) "payment-confirmed"                                       
                      event.getCorrelationId(), // key: orderId                                                  
                      event.getPayload()        // value: JSON                                                   
                  );                                                                                             
                  event.markSent();                                                                              
              } catch (Exception e) {                                                                            
                  log.error("Outbox publish 실패 id={}, topic={}", event.getId(), event.getTopic(), e);          
                  event.markFailed();
              }                                                                                                  
          }                                                                                                      
      }
  }

이 방식의 장점은 명확하다.

  • 추가 인프라 없이 애플리케이션 코드만으로 완결 가능하다.
  • Outbox 상태(PENDING/SENT/FAILED)를 기준으로 운영·모니터링 하기 쉽다
  • 트랜잭션 경계가 모두 애플리케이션 내부에 있어 디버깅이 간편하다

하지만 시스템이 커질수록 단점도 눈에 띄었다.

  • 주문/결제/재고 서비스마다 유사한 스케줄러·폴링·재시도 로직을 반복 구현해야 한다.
  • 폴링 주기, 배치 사이즈, 락 전략 등을 도메인별로 따로 튜닝해야 해서 운영 포인트가 늘어난다.
  • 피크 타임에 Outbox 레코드가 빠르게 쌓이면, 스케줄러가 처리량을 따라가지 못할 수 있다.

물론 다른 대안으로 Kafka Connect 기반 CDC(Change Data Capture) 방식도 존재한다. 하지만 별도의 Kafka Connect 클러스터와 Debezium 같은 CDC 인프라를 운영해야 하고, 운영 복잡도 또한 함께 증가한다는 부담이 있었다.

profile
Problem Solving과 기술적 의사결정을 중요시합니다.

0개의 댓글