Saga 패턴으로 분산 트랜잭션 보상 처리하기

“MSA에서도 @Transactional로 한 번에 묶으면 되지 않나요?” 분산 환경을 처음 다루는 분들이 가장 흔히 하는 오해입니다. 결론부터 말하면, 안 됩니다. 주문·결제·재고가 각각 다른 서비스와 다른 데이터베이스에 있다면, 하나의 트랜잭션으로 이들을 묶을 방법은 사실상 없습니다. 이 문제를 현실적으로 푸는 방법이 Saga 패턴입니다.

Saga는 여러 서비스에 걸친 하나의 비즈니스 흐름을, 각 서비스의 로컬 트랜잭션을 이어 붙인 연쇄로 다룹니다. 그리고 중간에 실패하면 앞서 성공한 단계들을 되돌리는 보상 트랜잭션으로 정합성을 맞춥니다.

이 글에서는 분산 트랜잭션이 왜 로컬 트랜잭션처럼 되지 않는지, Saga가 “롤백”이 아니라 “보상”으로 문제를 푸는 방식이 무엇인지, 그리고 코레오그래피와 오케스트레이션이라는 두 조율 방식의 차이와 보상 설계 원칙까지 정리합니다.


분산 트랜잭션은 왜 로컬 트랜잭션처럼 안 되나

단일 데이터베이스 안에서는 @Transactional 하나로 여러 작업을 원자적으로 묶을 수 있습니다. 전부 성공하거나 전부 롤백되므로 중간 상태를 걱정할 필요가 없습니다. 하지만 서비스가 쪼개지고 데이터베이스가 나뉘는 순간, 이 편안함은 사라집니다.

각 서비스의 트랜잭션은 자기 데이터베이스 안에서만 유효합니다. 결제 서비스가 커밋한 트랜잭션을, 재고 서비스의 실패 때문에 되돌릴 방법이 언어 차원에는 없습니다. 이론적으로 여러 DB를 한꺼번에 묶는 분산 트랜잭션(2PC)이 있긴 하지만, 느리고 복잡하며 한 참여자가 멈추면 전체가 잠기는 위험 때문에 실무에서는 대부분 피합니다.

그래서 Saga는 발상을 바꿉니다. 완벽한 원자성을 포기하고, 대신 최종 일관성을 받아들이는 것입니다. 각 단계는 자기 트랜잭션으로 즉시 커밋하되, 흐름이 실패하면 이미 커밋된 단계를 논리적으로 되돌려 전체를 일관된 상태로 되돌립니다. 이런 이벤트 기반 흐름 설계의 큰 그림은 MSA 서비스 간 호출 해결 방법 비교 가이드에서도 다뤘습니다.


Saga의 핵심: 롤백이 아니라 보상이다

Saga에서 가장 중요한 개념은 보상 트랜잭션(compensating transaction)입니다. 이미 커밋된 트랜잭션은 데이터베이스 롤백으로 되돌릴 수 없습니다. 대신 그 효과를 상쇄하는 역연산을 새로 실행합니다.

예를 들어 결제가 이미 승인됐다면, 이걸 “롤백”할 수는 없습니다. 대신 환불이라는 새로운 트랜잭션으로 상쇄합니다. 재고를 차감했다면 다시 복원하고, 주문을 생성했다면 취소 상태로 바꿉니다. 즉 보상은 “없던 일로 만드는 것”이 아니라 “반대 방향의 실제 작업”입니다.

// 결제 단계의 보상: 취소가 아니라 역연산인 '환불'
public void compensatePayment(PaymentContext ctx) {
    if (refundRepository.existsByPaymentId(ctx.paymentId())) {
        return; // 이미 환불됨 → 멱등하게 무시
    }
    paymentClient.refund(ctx.paymentId(), ctx.amount());
}

이 차이가 중요한 이유는, 보상이 실제 부수효과를 남기기 때문입니다. 고객은 결제됐다가 환불된 내역을 보게 됩니다. 그래서 Saga를 설계할 때는 “이 단계를 어떻게 되돌릴 것인가”를 처음부터 함께 설계해야 합니다.


조율 방식 두 가지: 코레오그래피 vs 오케스트레이션

Saga의 단계들을 어떻게 이어 붙이고 실패를 조율할지는 크게 두 방식으로 나뉩니다.

구분코레오그래피오케스트레이션
흐름 제어중앙 조율자 없음, 이벤트로 전파중앙 오케스트레이터가 명령
결합도낮음 (서비스가 이벤트만 알면 됨)오케스트레이터에 흐름이 집중
흐름 파악여러 서비스에 흩어져 추적 어려움한 곳에 명시적
적합한 경우단계가 적고 단순한 Saga단계가 많고 복잡한 Saga
주의점순환 의존·디버깅 난이도오케스트레이터 비대화

코레오그래피는 중앙 지휘자가 없습니다. 각 서비스가 이벤트를 발행하고, 다음 서비스가 그 이벤트를 구독해 자기 일을 한 뒤 또 다른 이벤트를 냅니다. 결합도가 낮고 단순한 흐름에 적합하지만, 전체 흐름이 이벤트 사이에 흩어져 있어 규모가 커지면 추적과 디버깅이 어려워집니다.

오케스트레이션은 중앙 오케스트레이터가 각 서비스에 “결제해라”, “재고 차감해라” 명령을 보내고, 응답에 따라 다음 단계로 갈지 보상할지를 결정합니다. 흐름이 한 곳에 명시적으로 모여 있어 복잡한 Saga에 유리하지만, 오케스트레이터에 로직이 몰려 비대해질 수 있습니다. 어느 쪽이든 이벤트나 명령을 실어 나를 브로커가 필요하며, 선택 기준은 Kafka vs RabbitMQ, 무엇을 언제 선택할까에서 정리한 바 있습니다.


주문 Saga 예시로 보는 실패와 보상

구체적인 흐름으로 감을 잡아보겠습니다. 주문 Saga는 보통 이렇게 흘러갑니다. 주문 생성 → 결제 승인 → 재고 차감 → 배송 시작. 각 단계는 서로 다른 서비스의 로컬 트랜잭션입니다.

만약 재고 차감 단계에서 “품절”로 실패하면, 앞서 성공한 단계들을 역순으로 보상해야 합니다. 결제를 환불하고, 주문을 취소 상태로 바꾸는 것입니다. 오케스트레이션 방식이라면 이 로직이 한곳에 모입니다.

재고 차감 실패 시 결제·주문을 역순으로 보상.
// 오케스트레이터 (이해를 위한 단순화 버전 — 실제로는 비동기·이벤트 기반)
public void createOrderSaga(OrderCommand cmd) {
    Deque<SagaStep> completed = new ArrayDeque<>();
    try {
        orderService.create(cmd);     completed.push(orderStep);
        paymentService.charge(cmd);   completed.push(paymentStep);
        stockService.decrease(cmd);   completed.push(stockStep);
        shippingService.start(cmd);
    } catch (Exception e) {
        // 완료된 단계를 역순으로 꺼내 보상한다
        while (!completed.isEmpty()) {
            completed.pop().compensate(cmd); // 환불, 재고 복원, 주문 취소
        }
    }
}

실제 운영 Saga는 이렇게 하나의 동기 메서드로 돌지 않습니다. 각 단계가 이벤트/명령으로 오가는 비동기 흐름이고, 어느 단계까지 진행됐는지 상태를 별도로 기록해 재시작에도 견디게 만듭니다. 위 코드는 “성공한 단계를 역순으로 보상한다”는 핵심 구조만 드러낸 것입니다.


보상 트랜잭션을 설계할 때의 원칙

보상은 정상 로직보다 더 신중하게 설계해야 합니다. 실패 상황에서 실행되는 데다, 그 보상마저 실패하거나 중복될 수 있기 때문입니다.

첫째, 보상은 멱등해야 합니다. 네트워크 재시도로 같은 보상이 두 번 올 수 있으므로, 이미 환불된 결제를 또 환불하지 않도록 방어해야 합니다. 이런 중복 방지·동시성 제어에는 Redis 분산락 구현하기의 접근이 도움이 됩니다.

둘째, 되돌릴 수 없는 단계는 뒤로 미룹니다. 알림 이메일 발송처럼 취소가 불가능한 작업은 Saga의 앞쪽에 두면 보상할 방법이 없습니다. 이런 비가역 작업은 흐름의 마지막, 즉 더 이상 실패하지 않을 지점 이후에 배치하는 것이 안전합니다.

셋째, 중간 상태가 외부에 노출된다는 점을 감안합니다. Saga는 즉시 일관성이 아니라 최종 일관성이므로, 결제는 됐는데 아직 배송 준비가 안 된 짧은 순간이 존재합니다. 이 중간 상태를 사용자에게 어떻게 보여줄지(“결제 완료, 준비 중” 같은 상태값)를 함께 설계해야 합니다.


Saga의 한계와 Outbox·멱등성의 역할

Saga가 만능은 아닙니다. 가장 큰 약점은 격리성의 부재입니다. 전통적 트랜잭션이 제공하던 격리가 없으므로, 커밋되지 않아야 할 중간 상태가 다른 요청에 읽힐 수 있습니다. 이를 완화하려면 상태 플래그로 “처리 중”을 표시하는 시맨틱 락 같은 보완 장치가 필요합니다.

또 하나, Saga의 각 단계는 “로컬 DB 변경”과 “다음 단계로 넘길 이벤트 발행”을 함께 해야 하는데, 이 둘의 원자성을 보장하지 못하면 Saga 자체가 흔들립니다. 앞서 다룬 Outbox 패턴이 바로 이 지점을 받쳐줍니다. 각 단계에서 데이터 변경과 이벤트 적재를 같은 트랜잭션으로 묶어, 이벤트 유실 없이 다음 단계로 넘어가게 해주기 때문입니다. 그리고 이벤트가 최소 한 번 이상 전달되는 특성상, 각 단계와 보상은 모두 멱등하게 구현돼 있어야 합니다. 결국 Saga·Outbox·멱등성은 분산 트랜잭션을 지탱하는 삼각대처럼 함께 움직입니다.


다시 짚어보면

Saga 패턴의 핵심은 “완벽한 원자성을 포기하는 대신, 실패를 우아하게 되돌릴 방법을 설계에 포함하는 것”입니다. 롤백이 아니라 보상이라는 관점 전환이 그 출발점입니다. 단계가 단순하면 코레오그래피로 가볍게, 흐름이 복잡하면 오케스트레이션으로 명시적으로 조율하되, 보상은 반드시 멱등하게 만들고 비가역 작업은 뒤로 미뤄야 합니다.

핵심 요약

  • 분산 트랜잭션은 2PC 대신 Saga로 — 로컬 트랜잭션의 연쇄 + 실패 시 보상
  • 조율 방식은 코레오그래피(이벤트 전파)와 오케스트레이션(중앙 명령) 중 복잡도로 선택
  • 보상은 멱등하게, 비가역 단계는 마지막에, 이벤트 신뢰성은 Outbox로 받친다

댓글 남기기