사가패턴을 통해 데이터 정합성을 보장하기

잘못된 설계가 만드는 오차

운영 환경에서 잘못된 설계 탓에 어긋난 데이터는 원인을 추적하기가 쉽지 않다. 실제 사용자가 서비스를 쓰고 여러 시스템이 맞물려 돌아가다 보니 버그를 재현하기 어렵고, 문제 자체도 불규칙하고 간헐적으로 나타나기 때문이다.

그렇다면 이렇게 어긋난 데이터를 막는 것은 불가능할까? 그렇지 않다. 로그를 심어 사후에 추적하는 방법도 있지만, 더 근본적인 해결은 설계 단계에서부터 트랜잭션의 경계와 예외 발생 시 로직의 흐름을 명확히 정의하는 데서 출발한다.

실제 주문 로직을 통해 알아보기

@Transactional
public PaymentResponse requestPayment(Long memberId, Long orderId) {
    Order order = orderRepository.findByIdAndMemberId(orderId, memberId)
            .orElseThrow(() -> new BusinessException(ErrorCode.ORDER_NOT_FOUND));

    if (order.getStatus() != OrderStatus.PENDING) {
        throw new BusinessException(ErrorCode.INVALID_ORDER_STATUS, "결제 대기 상태의 주문만 결제할 수 있습니다.");
    }

    if (paymentRepository.findByOrderIdAndStatusNot(orderId, PaymentStatus.FAILED).isPresent()) {
        throw new BusinessException(ErrorCode.DUPLICATE_REQUEST, "이미 결제가 진행된 주문입니다.");
    }

    Map<String, Object> paymentResult;
    try {
        paymentResult = externalPaymentClient.requestPayment(order.getOrderNumber(), order.getTotalAmount());
    } catch (BusinessException e) {
        for (var item : order.getItems()) {
            Product product = productRepository.findByIdWithLock(item.getProduct().getId())
                    .orElseThrow(() -> new BusinessException(ErrorCode.PRODUCT_NOT_FOUND));
            product.restoreStock(item.getQuantity());
        }

        Payment failedPayment = Payment.builder()
                .order(order)
                .paymentKey("FAILED-" + java.util.UUID.randomUUID().toString().substring(0, 8))
                .amount(order.getTotalAmount())
                .build();
        failedPayment.fail();
        paymentRepository.save(failedPayment);
        order.changeStatus(OrderStatus.CANCELLED);

        applicationEventPublisher.publishEvent(OrderCancelledEvent.builder()
                .data(OrderCancelledEvent.OrderCancelledData.builder()
                        .orderId(order.getId())
                        .orderNumber(order.getOrderNumber())
                        .reason("결제 실패로 인한 자동 취소")
                        .refundAmount(java.math.BigDecimal.ZERO)
                        .build())
                .build());

        throw e;
    }

    String paymentKey = paymentResult.get("paymentKey").toString();

    Payment payment = Payment.builder()
            .order(order)
            .paymentKey(paymentKey)
            .amount(order.getTotalAmount())
            .build();
    payment.approve(LocalDateTime.now(clock));
    paymentRepository.save(payment);

    order.changeStatus(OrderStatus.PAID);

    OrderPaidEvent event = OrderPaidEvent.builder()
            .data(OrderPaidEvent.OrderPaidData.builder()
                    .orderId(order.getId())
                    .orderNumber(order.getOrderNumber())
                    .paymentKey(paymentKey)
                    .amount(order.getTotalAmount())
                    .paidAt(payment.getPaidAt())
                    .build())
            .build();
    applicationEventPublisher.publishEvent(event);

    return PaymentResponse.from(payment);
}

이 코드의 문제는 트랜잭션 경계에 있다. 핵심은 catch 블록이다. 결제 실패 시 재고를 복원하고, 실패 기록을 남기고, 주문을 취소 상태로 바꾸고, 취소 이벤트까지 발행하는 보상 로직을 작성해두었다. 그러나 마지막 throw e가 같은 트랜잭션 안에서 예외를 다시 던지면서, 방금 수행한 보상 네 줄이 전부 롤백된다. 결국 재고는 복원되지 않고 주문은 PENDING 상태에 영구히 남는다. 보상하려고 짠 코드가 보상을 무효화하는 셈이다.

여기에 더해, 외부 결제 API 호출이 트랜잭션 경계 안에 있다는 점도 문제다. 외부 시스템은 우리 DB가 롤백된다고 함께 되돌아가지 않으므로, 결제는 성공했는데 로컬 트랜잭션은 롤백되는 순간 "돈은 빠져나갔지만 주문에는 반영되지 않은" 상태가 만들어진다.

트랜잭션 경계 개선하기

트랜잭션 경계는 단순히 throw를 빼는 식으로 손대기보다는, 각 메서드의 책임을 잘게 나누고 상위의 오케스트레이션 레이어가 전체 흐름을 조율하도록 하는 편이 유지보수에 유리하다.

@Service
@RequiredArgsConstructor
public class PaymentOrchestrator {

    private final PaymentService paymentService;
    private final PaymentCompensationService paymentCompensationService;
    private final ExternalPaymentClient externalPaymentClient;

    public PaymentResponse requestPayment(Long memberId, Long orderId) {
        // 1단계: 검증 — 짧은 읽기 전용 트랜잭션
        PaymentTarget target = paymentService.validatePaymentTarget(memberId, orderId);

        // 2단계: 외부 결제 호출 — DB 트랜잭션 밖.
        // 외부 I/O 동안 DB 커넥션과 락을 점유하지 않는다(기존에는 호출 내내 트랜잭션이 열려 있었다).
        Map<String, Object> paymentResult;
        try {
            paymentResult = externalPaymentClient.requestPayment(target.orderNumber(), target.totalAmount());
        } catch (BusinessException e) {
            // 3a단계: 보상 트랜잭션 — 별도 빈의 독립 트랜잭션에서 커밋된다.
            // 보상이 "커밋된 뒤에" 예외를 다시 던지므로, 기존처럼 재throw가 보상을 롤백시키지 않는다.
            paymentCompensationService.compensatePaymentFailure(orderId, "결제 실패로 인한 자동 취소");
            throw e;
        }

        // 3b단계: 승인 트랜잭션 — 외부 호출 동안 주문 상태가 바뀌었을 수 있으므로 내부에서 재검증한다.
        return paymentService.approvePayment(orderId, paymentResult.get("paymentKey").toString());
    }
}
@Service
@RequiredArgsConstructor
public class PaymentService {

    private final PaymentRepository paymentRepository;
    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;
    private final ExternalPaymentClient externalPaymentClient;
    private final ApplicationEventPublisher applicationEventPublisher;
    private final Clock clock;

    /**
     * 결제 가능 여부 검증 — 짧은 읽기 전용 트랜잭션.
     * 여기서 통과해도 외부 호출 동안 상태가 바뀔 수 있으므로(TOCTOU),
     * 최종 검증은 approvePayment의 상태 전이 가드가 다시 수행한다.
     */
    @Transactional(readOnly = true)
    public PaymentTarget validatePaymentTarget(Long memberId, Long orderId) {
        Order order = orderRepository.findByIdAndMemberId(orderId, memberId)
                .orElseThrow(() -> new BusinessException(ErrorCode.ORDER_NOT_FOUND));

        if (order.getStatus() != OrderStatus.PENDING) {
            throw new BusinessException(ErrorCode.INVALID_ORDER_STATUS, "결제 대기 상태의 주문만 결제할 수 있습니다.");
        }

        if (paymentRepository.findByOrderIdAndStatusNot(orderId, PaymentStatus.FAILED).isPresent()) {
            throw new BusinessException(ErrorCode.DUPLICATE_REQUEST, "이미 결제가 진행된 주문입니다.");
        }

        return new PaymentTarget(order.getId(), order.getOrderNumber(), order.getTotalAmount());
    }

    /**
     * 결제 승인 반영 — 독립 로컬 트랜잭션.
     * changeStatus(PAID)가 상태 머신 가드 역할을 한다: 외부 호출 동안 주문이
     * 취소됐다면(PENDING이 아니면) 여기서 예외가 나고 승인은 기록되지 않는다.
     * 이때 외부 결제는 이미 승인된 상태로 남으므로, 다음 단계의 사가에서는
     * "외부 결제 취소"라는 보상 트랜잭션이 추가로 필요하다.
     */
    @Transactional
    public PaymentResponse approvePayment(Long orderId, String paymentKey) {
        Order order = orderRepository.findByIdForUpdate(orderId)
                .orElseThrow(() -> new BusinessException(ErrorCode.ORDER_NOT_FOUND));

        // 상태 머신 가드: PENDING이 아니면(예: 외부 호출 동안 취소됨) 여기서 예외 발생
        order.changeStatus(OrderStatus.PAID);

        Payment payment = Payment.builder()
                .order(order)
                .paymentKey(paymentKey)
                .amount(order.getTotalAmount())
                .build();
        payment.approve(LocalDateTime.now(clock));
        paymentRepository.save(payment);

        OrderPaidEvent event = OrderPaidEvent.builder()
                .data(OrderPaidEvent.OrderPaidData.builder()
                        .orderId(order.getId())
                        .orderNumber(order.getOrderNumber())
                        .paymentKey(paymentKey)
                        .amount(order.getTotalAmount())
                        .paidAt(payment.getPaidAt())
                        .build())
                .build();
        applicationEventPublisher.publishEvent(event);

        return PaymentResponse.from(payment);
    }

    @Transactional
    public PaymentCancelResponse cancelPayment(Long memberId, String paymentKey, String reason) {
        Payment payment = paymentRepository.findByPaymentKey(paymentKey)
                .orElseThrow(() -> new BusinessException(ErrorCode.PAYMENT_NOT_FOUND));

        Order order = orderRepository.findByIdForUpdate(payment.getOrder().getId())
                .orElseThrow(() -> new BusinessException(ErrorCode.ORDER_NOT_FOUND));

        if (!order.getMember().getId().equals(memberId)) {
            throw new BusinessException(ErrorCode.ORDER_NOT_FOUND);
        }

        if (payment.getStatus() != PaymentStatus.APPROVED) {
            throw new BusinessException(ErrorCode.INVALID_ORDER_STATUS, "승인된 결제만 취소할 수 있습니다.");
        }

        order.changeStatus(OrderStatus.REFUND_REQUESTED);
        externalPaymentClient.cancelPayment(paymentKey);
        payment.cancel();
        order.changeStatus(OrderStatus.REFUNDED);

        for (var item : order.getItems()) {
            int remaining = item.getActiveQuantity();
            if (remaining <= 0) continue;
            Product product = productRepository.findByIdWithLock(item.getProduct().getId())
                    .orElseThrow(() -> new BusinessException(ErrorCode.PRODUCT_NOT_FOUND));
            product.restoreStock(remaining);
            item.cancelQuantity(remaining);
        }

        return PaymentCancelResponse.builder()
                .paymentKey(paymentKey)
                .status(payment.getStatus().name())
                .cancelledAt(LocalDateTime.now(clock))
                .build();
    }
}
@Slf4j
@Service
@RequiredArgsConstructor
public class PaymentCompensationService {

    private final OrderRepository orderRepository;
    private final PaymentRepository paymentRepository;
    private final ProductRepository productRepository;
    private final ApplicationEventPublisher applicationEventPublisher;

    /**
     * 결제 실패 보상: 재고 복원 → 실패 결제 기록 → 주문 취소 → 취소 이벤트 발행.
     *
     * 호출자(PaymentOrchestrator)에 트랜잭션이 없으므로 plain @Transactional로도
     * 항상 새 트랜잭션이 열린다. 이미 트랜잭션이 열린 컨텍스트에서 호출될 가능성이
     * 생기면 propagation = REQUIRES_NEW를 명시해 독립 커밋을 보장해야 한다.
     */
    @Transactional
    public void compensatePaymentFailure(Long orderId, String reason) {
        Order order = orderRepository.findByIdForUpdate(orderId)
                .orElseThrow(() -> new BusinessException(ErrorCode.ORDER_NOT_FOUND));

        // 멱등 가드: 이미 보상됐거나(CANCELLED) 다른 흐름이 선점했다면(PAID 등) 재실행하지 않는다.
        // 보상이 중복 실행되면 재고가 두 번 복원되는 정합성 위반이 생긴다.
        if (order.getStatus() != OrderStatus.PENDING) {
            log.info("결제 실패 보상 스킵 - orderId: {}, status: {}", orderId, order.getStatus());
            return;
        }

        for (var item : order.getItems()) {
            Product product = productRepository.findByIdWithLock(item.getProduct().getId())
                    .orElseThrow(() -> new BusinessException(ErrorCode.PRODUCT_NOT_FOUND));
            product.restoreStock(item.getQuantity());
        }

        Payment failedPayment = Payment.builder()
                .order(order)
                .paymentKey("FAILED-" + UUID.randomUUID().toString().substring(0, 8))
                .amount(order.getTotalAmount())
                .build();
        failedPayment.fail();
        paymentRepository.save(failedPayment);

        order.changeStatus(OrderStatus.CANCELLED);

        // 이 트랜잭션은 예외 없이 정상 커밋되므로 AFTER_COMMIT 리스너가 실제로 발행한다.
        // (기존 코드는 같은 트랜잭션에서 재throw → 전체 롤백 → 보상·이벤트 모두 무위)
        applicationEventPublisher.publishEvent(OrderCancelledEvent.builder()
                .data(OrderCancelledEvent.OrderCancelledData.builder()
                        .orderId(order.getId())
                        .orderNumber(order.getOrderNumber())
                        .reason(reason)
                        .refundAmount(BigDecimal.ZERO)
                        .build())
                .build());
    }
}

이렇게 코드를 분리해 각 트랜잭션의 경계를 명확히 하면, 단계별 처리가 독립적인 트랜잭션으로 커밋되어 보상이 본래 의도대로 동작한다. 또한 각 단계가 실패하면 그 지점에서 예외가 드러나므로 에러 트레이스를 따라 디버깅하기도 수월하다.

다만 이렇게 트랜잭션을 쪼갠 대가로, 단계와 단계 사이에는 정합성이 일시적으로 깨질 수 있는 구간이 생긴다. 가령 외부 결제는 이미 승인됐는데 로컬 승인 트랜잭션이 실패하면, "결제는 됐지만 주문에 반영되지 않은" 상태가 남는다. 이 빈틈을 메우는 것이 다음 절에서 다룰 보상 트랜잭션, 곧 사가 패턴이다.

사가 패턴을 설계해보기

사가 패턴은 크게 두가지가 있다. 오케스트레이션 방식과 코레오그래피 방식이다. 오케스트레이션은 중앙의 오케스트레이터가 전체 흐름을 지휘하는 방식이다. 각 참여 서비스는 서로를 알 필요 없이 오케스트레이터의 지시에 따라 동작하므로, 흐름 제어 로직이 한곳에 모여 구현과 추적이 쉽다. 다만 오케스트레이터가 모든 참여자를 알아야 해서 중앙으로의 의존이 생기고, 흐름이 복잡해질수록 오케스트레이터가 비대해질 수 있다.

두번째는 코레오그래피 방식인데 이 방식은 오케스트레이션 방식과 다르게 이벤트를 기반으로 상호작용하는 형태로 구현되어 서로 간 결합도가 낮다. 예를 들어 정상 흐름에서는 주문 생성 → (주문 생성됨 이벤트) → 결제 → (결제 완료 이벤트) → 배송처럼 각 단계가 앞 단계의 이벤트를 듣고 이어진다. 실패 시에는 반대 방향으로 결제 실패 → (취소 이벤트) → 재고 복원 → 환불처럼 보상 이벤트가 연쇄된다.

코레오그래피 사가 구현하기

앞 절에서 결제 흐름을 PaymentOrchestrator로 분리하면서 하나의 긴 트랜잭션을 단계별 독립 트랜잭션으로 쪼갰다. 덕분에 외부 결제 I/O가 진행되는 동안 DB 커넥션과 락을 붙잡고 있지 않아도 되니 주문 처리를 더 늘리기 좋아졌고, 결제가 실패하더라도 보상이 끝날 때까지 기다리지 않고 바로 응답하므로 사용자 입장에서도 더 빠른 결과를 받는다.

하지만 트랜잭션을 쪼갠 데에는 대가가 따른다. 각 단계 사이에 데이터 정합성이 일시적으로 깨지는 구간이 생긴 것이다. 결제는 FAILED로 커밋됐는데 재고는 아직 차감된 채 남아 있는 순간이 있고, 같은 이벤트가 두 번 전달되면 보상이 두 번 실행되어 재고가 실제보다 늘어날 수도 있다. 이 빈틈을 메우는 것이 사가다.

1. 현재의 발행·소비 골격

지금 이벤트는 트랜잭션이 커밋된 뒤에 발행된다. PaymentService.recordPaymentFailure가 실패 결제(FAILED)를 커밋하면서 ApplicationEventPublisher로 도메인 이벤트를 던지면, AFTER_COMMIT 단계의 리스너가 그것을 받아 브로커로 내보낸다.

// OrderEventListener — AFTER_COMMIT에서 브로커 발행으로 릴레이
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handlePaymentFailed(PaymentFailedEvent event) {
    orderEventPublisher.publishPaymentFailed(event);
}
// RabbitMQEventPublisher — 실제 발행 지점
public void publishPaymentFailed(PaymentFailedEvent event) {
    try {
        rabbitTemplate.convertAndSend(RabbitMQConfig.ORDER_EXCHANGE, RabbitMQConfig.PAYMENT_FAILED_KEY, event);
        log.info("Published PaymentFailedEvent: orderId={}", event.getData().getOrderId());
    } catch (Exception e) {
        // AFTER_COMMIT 직접 발행이라 브로커 장애 시 조용히 유실된다(측정 포인트).
        log.error("Failed to publish PaymentFailedEvent: orderId={}", event.getData().getOrderId(), e);
    }
}

발행된 payment.failed는 exchange에서 도메인별 큐 2개로 팬아웃된다. 한쪽은 재고 복원을 맡는 PaymentFailedStockHandler, 다른 한쪽은 주문 취소를 맡는 PaymentFailedOrderHandler다. 두 핸들러는 서로를 모른 채, 각자 독립된 트랜잭션·재시도·DLQ 단위로 같은 이벤트에 반응한다. 누가 시켜서가 아니라 스스로 구독해 처리한다는 것, 이것이 코레오그래피다.

발행을 AFTER_COMMIT에 거는 이유는 트랜잭션이 롤백되면 이벤트도 나가면 안 되기 때문이다. 이것이 첫 절의 버그, 즉 같은 트랜잭션에서 재throw하는 바람에 보상과 이벤트가 모두 함께 롤백되던 문제를 구조적으로 피하는 방법이다.

그런데 여기에 또 다른 함정이 있다. 커밋은 이미 끝났는데 convertAndSend가 실패하면 — 브로커가 다운됐거나 네트워크가 순간 끊기면 — 그 이벤트는 그냥 증발한다. 위 코드의 catch 블록이 로그만 남기고 삼켜버리기 때문이다.

2. 전제 ① — 신뢰성 있는 발행 (현재는 일부러 비워둔 자리)

먼저 솔직하게 짚고 가자. 사가를 아무리 잘 구현해도 이벤트 유실 자체는 줄지 않는다. 유실은 발행 경로의 문제이지 사가의 문제가 아니기 때문이다. 그래서 사가는 발행 신뢰성 보강과 한 세트로 가야 한다.

가장 가벼운 보강은 RabbitMQ의 publisher confirm을 켜는 것이다. 브로커가 메시지를 받았는지 확인(ack)받고, 못 받았으면(nack) 다시 발행한다. 라우팅 자체가 실패한 경우는 mandatory로 잡는다.

spring:
  rabbitmq:
    publisher-confirm-type: correlated   # 비동기 confirm
    publisher-returns: true              # 라우팅 실패 반환
    template:
      mandatory: true

이것만으로도 "브로커는 살아 있는데 메시지를 놓치는" 경우는 막힌다. 하지만 커밋과 발행 사이에 프로세스가 죽으면 여전히 유실된다. AFTER_COMMIT은 트랜잭션 바깥이라, 커밋은 끝났는데 발행 코드에 도달하기 전에 인스턴스가 내려가면 그 이벤트는 영영 나가지 않는다.

이 마지막 빈틈까지 막는 근본적인 해법이 Outbox 패턴이다. 이벤트를 브로커로 바로 보내는 대신, 결제를 FAILED로 기록하는 그 트랜잭션 안에서 outbox 테이블에 함께 INSERT한다. 둘이 같은 트랜잭션이라 함께 커밋되거나 함께 롤백되므로, "커밋은 됐는데 이벤트가 없는" 상태가 원천적으로 생기지 않는다.

3. 전제 ② — 멱등한 소비

메시지 브로커는 보통 at-least-once로 전달한다. 즉 같은 이벤트가 두 번 이상 도착할 수 있다. 재시도든 재배달이든 컨슈머 재기동이든, 중복은 언제든 일어난다. 멱등 처리가 없으면 보상이 두 번 실행되어 재고가 실제보다 많아지는 정합성 위반이 생긴다.

그래서 두 보상 핸들러 모두에 방어를 두 겹으로 깔았다.

첫 번째 겹은 명시적인 멱등 키다. 이벤트마다 고유한 event_id를 부여하고, (event_id, handler)에 UNIQUE 제약을 건 processed_events 테이블에 선점 INSERT를 시도한다. 핸들러별로 키를 잡는 이유는, 같은 이벤트라도 재고 핸들러와 주문 핸들러가 각각 한 번씩은 처리해야 하기 때문이다.

// PaymentFailedOrderHandler.cancelOrder — 비즈니스 트랜잭션 안에서 선점
if (!idempotentEventProcessor.tryProcess(event.getEventId(), HANDLER_NAME)) {
    log.info("주문 취소 스킵(중복 이벤트) - ...");
    return;
}
// IdempotentEventProcessor — MANDATORY: 반드시 핸들러의 트랜잭션 안에서 호출된다
@Transactional(propagation = Propagation.MANDATORY)
public boolean tryProcess(String eventId, String handler) {
    if (processedEventRepository.existsByEventIdAndHandler(eventId, handler)) {
        return false;
    }
    processedEventRepository.save(/* eventId, handler, processedAt */);
    return true;
}

여기에는 설계 의도가 두 가지 담겨 있다. 하나는 전파 속성을 MANDATORY로 둔 것이다. 처리 기록과 비즈니스 부작용이 반드시 같은 트랜잭션에 묶이도록 강제해서, "기록은 됐는데 처리가 안 됨"이나 그 반대가 생기지 않게 한다. 다른 하나는 exists 체크를 어디까지나 빠른 경로로만 본 것이다. 동시에 들어온 두 트랜잭션이 둘 다 체크를 통과하더라도, 커밋 시점에 UNIQUE 제약 위반으로 늦은 쪽 트랜잭션 전체가 롤백되므로 부작용은 결국 한 번만 적용된다.

두 번째 겹은 도메인 상태 가드다. 멱등 키는 "같은 이벤트가 다시 온 경우"만 막는다. 사용자 취소 같은 다른 흐름과 겹치는 중복은 도메인의 상태가 막아야 한다.

// 주문 쪽: PENDING이 아니면(이미 취소됐거나 다른 흐름이 선점) 전이하지 않는다
if (order.getStatus() != OrderStatus.PENDING) {
    log.info("주문 취소 스킵(상태 가드) - orderId: {}, status: {}", ...);
    return;
}
// 재고 쪽: 이미 복원된 수량은 다시 복원하지 않고,
// 복원분은 cancelledQuantity로 마킹해 이중 복원을 구조적으로 차단한다
int remaining = item.getActiveQuantity();
if (remaining <= 0) continue;
product.restoreStock(remaining);
item.cancelQuantity(remaining);

findByIdForUpdate로 비관적 락을 잡고 상태를 확인하므로, 두 보상이 동시에 들어와도 한쪽이 먼저 커밋하면 다른 쪽은 가드에 걸려 스킵된다.

한 가지 주의할 점이 있다. 관찰용으로 두는 consumed_events 테이블은 멱등 키로 쓸 수 없다. 이 테이블은 재전달된 중복까지 그대로 쌓아 중복률을 측정하는 것이 목적이라, event_id에 일부러 UNIQUE 제약을 걸지 않았다. 멱등을 책임지는 것은 UNIQUE 제약이 있는 processed_events이고, 둘은 역할이 전혀 다르다. 하나는 방어를 위한 자물쇠이고, 하나는 그저 무슨 일이 있었는지 비추는 거울이다.

4. 순서는 보장되지 않는다

요약: 사가는 순서를 맞춰주지 않는다. 순서가 뒤바뀌어도 사고가 안 나게 막아줄 뿐이다.

리스너 동시성이 1보다 크고(concurrency: 2, max-concurrency: 8) 큐가 여러 개면, 이벤트는 발행 순서대로 도착하지 않는다. 장애가 없어도 그렇다.

여기서 두 문제를 구분하자. 순서 보장은 파티션 키로 같은 엔티티를 같은 컨슈머에 몰아주는, 코레오그래피 바깥의 일이다. 사가가 맡는 건 순서가 꼬여도 부작용이 안 생기게 막는 것이다.

방법은 상태 머신이다. 이미 CANCELLED된 주문에 취소 이벤트가 늦게 도착해도, 허용 안 되는 전이를 거부하면 끝이다.

 
// Order.changeStatus — 전이 가드
public void changeStatus(OrderStatus nextStatus) {
    this.status.validateTransitionTo(nextStatus);  // 허용되지 않는 전이면 BusinessException
    this.status = nextStatus;
}

// OrderStatus — 허용 전이를 명시한 상태 머신
PENDING, Set.of(PAID, CANCELLED),
PAID,    Set.of(PREPARING, REFUND_REQUESTED, CANCELLED, PARTIALLY_CANCELLED),
...

5. 흐름이 흩어져 멈춤(stuck)을 잡기 어렵다

요약: "처리 실패"는 DLQ로 잡는다. "트리거 유실"은 아무도 못 잡으니 스위퍼가 필요하다.

코레오그래피는 흐름이 한곳에 모여 있지 않다. 어딘가 멈춰 있어도 알아챌 주체가 없다. 멈춤은 두 가지다.

① 이벤트는 도착했는데 처리가 실패한 경우. DLQ로 격리했다. 큐가 도메인별로 나뉘어 있어 재고 보상이 DLQ로 가도 주문 보상은 멀쩡히 돈다. 대신 재고는 복원됐는데 주문은 PENDING인, 보상의 부분 실패가 생길 수 있다.

② 트리거 이벤트 자체가 유실된 경우. 이쪽이 더 고약하다. DLQ에도 흔적이 없어 주문이 PENDING에 영원히 남는데 아무도 모른다. 발행 신뢰성(2절)을 보강해도 100%는 아니라, PENDING에 오래 머문 주문을 주기적으로 훑어 보상을 다시 트리거하는 스위퍼가 마지막 안전망으로 필요하다. 아직 코드엔 없는 다음 과제다.

// 후속 과제 스케치 — 아직 코드에 없다
@Scheduled(fixedDelay = 60_000)
public void sweep() {
    LocalDateTime deadline = LocalDateTime.now().minusMinutes(5);
    for (Long orderId : orderRepository.findStuckPendingOrderIds(deadline)) {
        paymentService.recordPaymentFailure(orderId, "타임아웃으로 인한 자동 취소");
    }
}

스위퍼는 보상 로직을 직접 부르면 안 된다. 사가 트리거를 다시 쏘는 방식이어야 멱등 가드가 이벤트 체인을 타고 일관되게 동작한다. 그런데 recordPaymentFailure엔 "이미 결제 행이 있으면 발행 안 함" 가드가 있어서, 기록은 됐는데 발행만 유실된 케이스는 이 경로로 못 잡는다. 그래서 이 가드를 우회하는 재발행 경로가 함께 필요하다 — Outbox와 스위퍼가 한 세트인 이유다.

6. 사가는 은탄환이 아니다

요약: 사가는 유실·stuck은 못 줄인다. 정합성(중복 보상)만 잡는다. 숫자로도 그렇다.

같은 강도의 장애를 주입하는 카오스 테스트를 Before/After로 돌려 비교했다. 무엇을 지표로 보느냐가 핵심이다.

  • 유실률 — 사가는 발행 경로를 안 건드리니 안 움직인다. Before/After가 같게 나오는 게 정상이고, 그게 "사가와 발행 신뢰성은 별개"라는 증거다.
  • 정합성 — 사가가 실제로 잡는 곳. 중복 보상으로 인한 invariant 위반은 이번 구현이 직접 책임진다. (stuck은 트리거 유실분만큼 스위퍼의 몫으로 남는다.)

측정 조건은 양쪽 똑같다. k6로 VUS 30·60초 부하, 시작 20초에 RabbitMQ를 10초간 내렸다 올렸다. Before는 멱등 2중 방어(processed_events 키 + activeQuantity/cancelQuantity 가드)를 꺼둔 무방비 빌드, After는 현재 코드다.

지표Before (멱등 OFF)After (멱등 ON)
이벤트 유실률 18.6% 17.0% — 변화 없음
재고 invariant 위반 15건 (재고 +15) 0건 (재고 +0)
stuck 주문 수 23건 21건 — 변화 없음

표가 말하는 건 하나다. 유실과 stuck은 그대로, 재고 invariant 위반만 15 → 0으로 떨어졌다. 멱등 2중 방어가 유일하게 움직인 지표다.

(보조 지표, After 기준: 순서 꼬임 93.6%로 구조적이라 줄지 않음. E2E p99 약 28초, lag 회복 16초 — 장애 중 큐 적체의 후행 효과로 Before도 동급.)

정리

분류무엇으로 해결했나
멱등한 소비 (전제) 멱등 키 + 상태 가드 2중 방어 ✅ 완료
신뢰성 있는 발행 (전제) confirms → Outbox 경로만 확정, 측정 위해 비워둠
순서 (한계) 상태 머신 가드로 부작용 차단
부분 실패 (한계) 도메인별 DLQ로 격리
트리거 유실 stuck (한계) 타임아웃 스위퍼 — 후속 과제

마지막으로 솔직한 전제. 이 앱은 모놀리스에 단일 DB라, 원래는 한 트랜잭션으로 끝낼 수 있다. 그런데도 트랜잭션을 쪼갠 건 분산 트랜잭션 상황을 재현해 정합성 패턴을 검증하기 위해서였다. 사가는 그 분리의 대가를 치르는 방법이지, 분리가 필요 없는 곳에 꺼내 쓸 은탄환이 아니다.

부록 — 흐름 다이어그램