본문 바로가기

개발기술/설계|소프트웨어패턴

Saga 패턴

정의 :  분산 아키텍처에서 각 서비스의 로컬 트랜잭션을 순차적으로 실행하고, 실패 발생 시 반대되는 보상 트랜잭션을 실행하여 데이터의 최종 일관성을 확보하는 기법.

이유 : 분산 시스템 간 비동기 메시징 환경에서는 단일 트랜잭션 범위 내의 ACID(특히 원자성) 확보가 불가능

 

주문 프로세스 = 하나의 Saga

 

 

사가 흐름

  • [정상 흐름] Step 1: 주문 생성 ✅ Step 2: 결제 승인 ✅ Step 3: 재고 차감 ✅ Step 4: 배달 접수 ✅ → 완료!
  • [실패 흐름] Step 1: 주문 생성 ✅ Step 2: 결제 승인 ✅ Step 3: 재고 차감 ✅ Step 4: 배달 접수 💥 실패!
    • [보상 흐름] (역순으로 취소) Compensate 3: 재고 복구 ✅ Compensate 2: 결제 취소 ✅ Compensate 1: 주문 취소 ✅ → 원래 상태로 복구!

 

@Service
public class RobustOrderSaga {

    public OrderResponse execute(OrderRequest request) {
        // 1. Saga 인스턴스 생성 (상태 추적)
        SagaInstance saga = createSagaInstance(request);

        // 2. 주문 생성 (Idempotency)
        Order order = createOrderIdempotent(request, saga);

        // 3. 이벤트 발행 (Kafka - 메시지 유실 방지)
        kafka.send("order-created", new OrderCreatedEvent(
                sagaId: saga.getId(),
                order: order
        ));

        return OrderResponse.from(order);
    }

    @KafkaListener(topics = "order-created")
    public void handleOrderCreated(OrderCreatedEvent event) {
        SagaInstance saga = sagaRepository.findById(event.getSagaId());

        try {
            // 결제 (Idempotency + Saga State 업데이트)
            Payment payment = chargeWithTracking(saga, event.getOrder());

            kafka.send("payment-completed", ...);

        } catch (Exception e) {
            // 실패 이벤트 + 보상 시작
            saga.setStatus("COMPENSATING");
            sagaRepository.save(saga);

            kafka.send("payment-failed", ...);
        }
    }

    // 보상 트랜잭션 (재시도 + DLQ)
    @KafkaListener(topics = "payment-failed")
    public void compensate(PaymentFailedEvent event) {
        try {
            refundWithRetry(event.getPaymentId());

        } catch (MaxRetriesExceededException e) {
            // DLQ + 알림
            kafka.send("compensation-dlq", event);
            slackService.alert("긴급: 환불 실패 - " + event);
        }
    }
}

 

 

실패 처리 전략

 

  • CASE1  외부시스템 결제실패 처리 : 외부 시스템은 처리완료 확인을 위해서 동기처리를 하지만 실패시 동기적으로 try시에 blocking 시간이 길어져서 고객사용감 저하하여 fail event 생성으로 비동기적 처리
public Payment charge(Order order) {
    String idempotencyKey = "order-" + order.getId();

    // 즉시 1회 시도
    try {
        return paymentApi.charge(...);

    } catch (TimeoutException e) {
        // 타임아웃 → 비동기 큐에 넣고 즉시 반환
        kafka.send("payment-retry-queue", new PaymentRetryTask(
                orderId: order.getId(),
                idempotencyKey: idempotencyKey,
                retryCount: 0
        ));

        // 임시 상태로 주문 생성
        order.setStatus(OrderStatus.PAYMENT_PENDING);
        return Payment.pending();
    }
}

// 백그라운드 워커
@KafkaListener(topics = "payment-retry-queue")
public void retryPayment(PaymentRetryTask task) {
    try {
        Payment payment = paymentApi.charge(...);

        // 성공 → 주문 상태 업데이트
        Order order = orderRepository.findById(task.getOrderId());
        order.setStatus(OrderStatus.CONFIRMED);

        // 다음 단계 진행
        kafka.send("payment-completed", ...);

    } catch (TimeoutException e) {
        if (task.retryCount < 10) {
            // 다시 큐에 (1분 후)
            task.retryCount++;
            task.nextRetryAt = now() + 1분;
            kafka.send("payment-retry-queue", task);
        } else {
            // 10번 실패 → 수동 처리
            kafka.send("payment-dlq", task);
        }
    }
}

 

  • CASE2  : 외부 시스템은 처리완료 확인을 위해서 동기처리를 하지만 실패시 동기적으로 try시에 blocking 시간이 길어져서 고객사용감 저하하여 fail event 생성으로 비동기적 처리
if (retryCount >= 100) {
        // 관리자 알림
        slackService.alert("긴급! 환불 실패 100번: " + order);

// DLQ에 저장
    kafka.send("compensation-dlq", task);

// 관리자 대시보드에 표시
// → 사람이 결제사에 전화하거나
// → 고객에게 직접 계좌 이체
}
  • CASE3  : 실패처리시에 retry를 다회 처리하면 오랜기간 blocking으로 consum이 안되지, 전용 retry thread에서 처리하도록 분리
public void compensatePayment(Payment payment) {

    // 1단계: 즉시 3회 재시도 (빠르게)
    for (int i = 0; i < 3; i++) {
        try {
            paymentApi.refund(payment.getTransactionId());
            return;  // 성공!

        } catch (TimeoutException e) {
            if (i < 2) {
                Thread.sleep(1000);  // 1초 대기
            }
        }
    }

    // 2단계: 3번 실패 → 비동기 큐로 (느리지만 안전)
    kafka.send("refund-retry-queue", new RefundRetryTask(
            paymentId: payment.getId(),
            retryCount: 0,
            maxRetries: 100
    ));
}

@KafkaListener(topics = "refund-retry-queue")
public void retryRefund(RefundRetryTask task) {
    if (task.retryCount >= 100) {
        kafka.send("refund-dlq", task);
        return;
    }

    try {
        paymentApi.refund(task.getPaymentId());
        // 성공!

    } catch (Exception e) {
        // 지수 백오프로 다시 큐에
        task.retryCount++;
        task.nextRetryAt = now() + (task.retryCount * 1분);
        kafka.send("refund-retry-queue", task);
    }
}

 

Case4 : 위의 모든 과정을 실패한경우 Dead Letter Queue (DLQ)에 저장

Dead Letter Queue란?

"처리 실패한 메시지들을 모아두는 별도의 큐"

 

// 재시도 큐 Consumer
@KafkaListener(topics = "refund-retry-queue")
public void retryRefund(RefundRetryTask task) {

    // 100번 넘으면 DLQ로 이동
    if (task.retryCount >= 100) {
        kafka.send("refund-dlq", task);  // ← DLQ로 보냄
        return;
    }

    try {
        paymentApi.refund(task.getPaymentId());
        // 성공!

    } catch (Exception e) {
        // 재시도
        task.retryCount++;
        kafka.send("refund-retry-queue", task);
    }
}

// DLQ Consumer
@KafkaListener(topics = "refund-dlq")
public void handleDLQ(RefundRetryTask task) {
    // 1. DB에 저장
    FailedRefund failed = new FailedRefund(
            paymentId: task.getPaymentId(),
            retryCount: task.retryCount,
            lastError: task.getLastError(),
            failedAt: now()
    );
    failedRefundRepository.save(failed);

    // 2. 알림
    slackService.alert(
            "🚨 환불 실패 (DLQ): " + task.getPaymentId()
    );

    emailService.send(
            to: "ops@company.com",
            subject: "긴급: 수동 처리 필요",
            body: "환불 100번 실패: " + task
    );
}

 

 

데이터 정합성을 보장하는 방식

 

  • Transactional Outbox 패턴: 로컬 DB 업데이트와 메시지 발행을 하나의 로컬 트랜잭션으로 묶어, 메시지가 유실되지 않음을 보장합니다.
    • 분산 시스템에서는 "딱 한 번만 전송(Exactly-once)"하는 것은 기술적으로 매우 어렵고 비용이 많이 듭니다. 그래서 실무에서는 질문하신 것처럼 **"적어도 한 번은 전송(At-least-once delivery)"**되는 것을 보장하는 구조를 선택합니다.
    • 메시지 전송 중 서버 다운: 릴레이가 메시지를 보내다가 죽으면, status가 여전히 INIT으로 남아있습니다. 서버가 다시 살아나면 아웃박스를 보고 다시 보냅니다. (데이터 유실 방지)
    • 전송은 성공했는데 체크(Update) 실패: 메시지는 큐에 갔는데, DB에 '성공'이라고 표시하기 직전에 서버가 터지면? 나중에 릴레이가 똑같은 메시지를 또 보낼 수 있습니다.
    • 예시: 결제 서비스가 "주문번호 100번 결제해줘"라는 메시지를 두 번 받더라도,
    • DB에서 "이미 100번 주문에 대한 결제 기록이 있나?"를 먼저 확인하거나,
    • 주문번호를 유니크 키(Unique Key)로 설정하여 중복 결제가 일어나지 않도록 방어해야 합니다.받는 쪽의 숙제: 멱등성 (Idempotency)
    • 메시지가 최소 한 번은 오지만 두 번 올 수도 있기 때문에, 이 메시지를 받는 구독자(Consumer) 측에서는 반드시 멱등성 처리를 해야 합니다.
  • Retry(재시도) 로직: 일시적인 네트워크 오류라면 보상 트랜잭션을 바로 실행하기보다, 성공할 때까지 몇 번 더 시도하여 결과적으로 성공 상태를 만듭니다.

데이터 추적의어려움

  • 오케스트레이션(Saga Entity 위주): 중앙 관리자(오케스트레이터)가 Entity를 보며 지휘. (복잡한 비즈니스에 유리)

 

 

Saga Entity는 분산된 여러 서비스를 **하나의 큰 상태 머신(State Machine)**으로 관리하기 위한 장치입니다.

  • 코레오그래피(Outbox 위주): 중앙 관리자 없음. 서비스끼리 알아서 핑퐁. (단순한 구조에 유리)
  • 오케스트레이션(Saga Entity 위주): 중앙 관리자(오케스트레이터)가 Entity를 보며 지휘. (복잡한 비즈니스에 유리)

 

 

1. "상태 변경"만으로는 부족한 이유 (메시지 상세 내용의 부재)

Saga Entity는 보통 **'현재 어디쯤인가'**라는 상태 정보에 집중합니다. 반면 Outbox는 **'전달할 구체적인 데이터(Payload)'**에 집중합니다.

  • Saga Entity: "현재 상태는 PAYMENT_WAITING이야."
  • Outbox: "결제 서비스에 보낼 데이터는 { orderId: 100, amount: 50000, cardNum: '1234...' }야."

단순히 Saga Entity의 상태만 바꾼다면, 나중에 릴레이 프로세스가 "아, PAYMENT_WAITING 상태네? 결제 명령을 보내야지!"라고 할 때 어떤 데이터를 보내야 할지 다시 조회하거나 구성해야 하는 번거로움이 생깁니다.


2. Saga Entity를 Outbox처럼 쓰는 법 (통합 설계)

말씀하신 대로 Saga Entity가 Outbox 역할을 하게 하려면, Saga Entity 테이블 안에 '메시지 전송을 위한 정보'를 포함시키면 됩니다.

[통합된 Saga Entity 테이블 구조 예시] | ID | 상태 (State) | 전송 여부 (is_sent) | 페이로드 (Payload / 보낼 데이터) | | :--- | :--- | :--- | :--- | | 1 | PAYMENT_REQUESTED | false | { "orderId": 123, "price": 1000 ... } |

이렇게 설계하면 질문하신 시나리오가 완벽하게 성립합니다.

  1. 로컬 트랜잭션: 주문 서비스가 주문 저장 + Saga Entity(상태: REQUESTED, is_sent: false) 저장을 한 번에 처리.
  2. 릴레이: 별도 프로세스가 Saga Entity 테이블을 보고 is_sent가 false인 것을 찾아 MQ에 던짐.
  3. 체크: 전송 성공 후 is_sent를 true로 변경.

3. 왜 굳이 'Outbox'라는 용어를 따로 쓸까? (관심사의 분리)

실무에서 이 둘을 굳이 나누어 부르는 이유는 확장성 때문입니다.

  • Saga Entity: 비즈니스 로직의 **흐름(Flow)**을 관리합니다. (성공하면 다음 단계로, 실패하면 보상 트랜잭션으로)
  • Outbox: 어떤 메시지든 외부로 안전하게 나가는 통로 역할을 합니다. (주문 이벤트뿐만 아니라, 알림 서비스로 보낼 메시지, 로그 전송 등 모든 종류의 메시지)

만약 Saga Entity가 모든 메시지 전송 책임을 다 지게 되면, Saga와 상관없는 단순 알림 메시지 같은 것들도 Saga 로직을 타야 하는 구조적 비효율이 생길 수 있습니다.

 

 

정확한 우려입니다! 오케스트레이션 방식에서 가장 많이 지적받는 단점이 바로 **'중앙 집중화로 인한 단일 장애점(SPOF)'**과 '가용성 저하' 문제입니다.

하지만 Saga 패턴을 사용하는 근본적인 이유가 바로 **"주문 서비스가 죽어도 시스템 전체가 마비되는 것을 막기 위함"**입니다. 비동기 메시징(MQ)과 Outbox를 적절히 섞으면 주문 서비스가 죽었을 때 어떤 일이 벌어지는지 시뮬레이션해 볼게요.


1. 주문 서비스(오케스트레이터)가 죽었을 때 벌어지는 일

주문 서비스가 오케스트레이터를 겸하고 있을 때 서버가 다운되면 다음과 같은 상황이 됩니다.

  • 신규 주문: 당연히 불가능합니다. (이건 어느 구조나 마찬가지입니다.)
  • 기존 진행 중인 주문:
    • 결제 서비스: 이미 결제 요청 메시지를 받았다면, 주문 서비스가 죽어있든 말든 결제를 정상적으로 수행합니다.
    • 메시지 큐(MQ): 결제 서비스가 일을 마치고 "결제 완료!" 메시지를 던지면, MQ가 이 메시지를 안전하게 보관합니다. 주문 서비스가 죽어있으니 아무도 가져가지 않지만, 메시지는 사라지지 않습니다.
  • 복구 시: 주문 서비스가 다시 살아나는 순간, MQ에 쌓여있던 "결제 완료" 메시지들을 한꺼번에 읽어와서 Saga Entity 상태를 업데이트하고 다음 단계(배송 등)를 진행합니다.

즉, 서비스 간의 결합도가 '런타임' 수준에서는 분리되어 있는 것입니다. (실시간으로 서로 살아있어야만 일이 돌아가는 동기 방식 API 호출과는 다릅니다.)


2. "결합도"를 더 낮추는 방법: 전용 오케스트레이터 분리

질문하신 것처럼 주문 서비스와 Saga가 너무 끈끈하게 붙어 있는 게 걱정된다면, 앞서 말한 대로 오케스트레이터를 독립 서비스로 분리하는 것이 답입니다.

이렇게 분리하면 다음과 같은 장점이 생깁니다.

  1. 주문 서비스 장애 격리: 주문 서비스가 죽어도, 이미 생성된 Saga들은 독립된 오케스트레이터에 의해 결제-배송 프로세스를 계속 진행할 수 있습니다.
  2. 부하 분산: 주문 트래픽이 몰려도 오케스트레이터는 자신의 속도대로 메시지를 처리하며 전체 흐름을 완주합니다.

3. 코레오그래피(Choreography)라는 대안

만약 "나는 어떤 중앙 관리자도 믿지 못하겠다. 오케스트레이터 자체가 죽는 게 제일 걱정이다"라고 하신다면, 코레오그래피 방식으로 가야 합니다.

  • 구조: 오케스트레이터 없이 서비스들이 서로의 이벤트를 직접 구독합니다.
    • 주문 서비스: "주문됨" 이벤트 발행 -> 끝.
    • 결제 서비스: "주문됨" 구독 후 결제 -> "결제됨" 발행 -> 끝.
    • 배송 서비스: "결제됨" 구독 후 배송 시작.
  • 장점: 중앙 관리자가 없으니 특정 서비스가 죽어도 그 단계만 멈출 뿐, 다른 흐름의 관리는 분산됩니다. 진정한 의미의 탈중앙화입니다.

4. 요약: 죽는 게 아니라 '지연'되는 것입니다

Saga 패턴(Outbox + MQ)을 제대로 구현했다면, 특정 서비스가 죽는다고 해서 데이터가 꼬이거나 전체가 같이 죽지는 않습니다.

  • 동기 방식(API): 주문 서비스 죽음 = 결제 서비스 오류 응답 = 시스템 전체 에러 (같이 죽음)
  • Saga 방식(MQ): 주문 서비스 죽음 = 결제 서비스는 자기 일 함 = 메시지는 큐에 쌓임 = 주문 서비스 복구 시 재개 (지연 후 결국 성공)

결국 **"트랜잭션 없이 결과적 일관성"**을 확보한다는 것은, 장애 상황에서도 데이터가 유실되지 않고 언젠가는(Eventually) 비즈니스 로직이 완수되도록 만드는 설계의 힘입니다.

이 시점에서 고민이 되실 것 같아요. **"관리가 편하지만 중앙 집중적인 오케스트레이션"**이 나을까요, 아니면 **"복잡하지만 장애에 더 강한 코레오그래피"**가 나을까요? 현재 구상 중인 시스템에서 서비스가 몇 개 정도 연결될 예정인가요?