Published on

MSA Saga 보상트랜잭션 설계 - NATS JetStream

Authors

마이크로서비스 아키텍처(MSA)에서 주문, 결제, 재고, 배송 같은 도메인이 분리되면 전통적인 2PC 기반 분산 트랜잭션은 현실적으로 운영 난이도와 성능 비용이 큽니다. 그래서 대부분은 Saga 패턴으로 일관성을 맞춥니다. 다만 Saga는 "실패했을 때 무엇을 되돌릴 것인가"가 설계의 절반 이상입니다. 즉, 보상트랜잭션(Compensation)이 제대로 정의되지 않으면 재시도, 중복 메시지, 부분 성공 같은 현실의 장애에서 데이터가 쉽게 오염됩니다.

이 글에서는 NATS JetStream을 이벤트 버스로 사용할 때 Saga 보상트랜잭션을 어떻게 설계하고, 어떤 메시징/소비자 설정으로 운영 안전성을 확보하는지 정리합니다. 예시는 주문 생성 흐름을 기준으로 설명합니다.

Saga에서 보상트랜잭션이 어려운 이유

Saga는 "일련의 로컬 트랜잭션"의 조합입니다. 각 서비스는 자기 DB에서만 원자성을 보장하고, 전체 흐름은 메시지로 이어집니다. 여기서 난점은 다음과 같습니다.

  • 부분 성공이 정상 상태: 결제 성공 후 재고 실패처럼 중간 단계에서 멈출 수 있습니다.
  • 재시도와 중복이 기본값: 네트워크 타임아웃, 소비자 재시작, 브로커 재전송으로 동일 이벤트가 여러 번 도착합니다.
  • 보상은 "역연산"이 아님: 결제 취소는 결제 생성의 역연산처럼 보이지만, 실제로는 취소 수수료/부분취소/취소 불가 상태가 존재합니다.
  • 시간이 흐른 뒤의 보상: 배송이 시작된 뒤에는 결제 취소가 아니라 반품 프로세스로 바뀝니다.

따라서 보상트랜잭션은 단순히 "undo" 함수가 아니라, 도메인 규칙을 가진 별도의 상태 전이로 모델링해야 합니다.

NATS JetStream을 Saga에 쓰는 이유

NATS Core는 초저지연 pub/sub에 강하지만, Saga는 기본적으로 "유실되면 안 되는 이벤트"가 많습니다. JetStream은 다음 요소로 Saga에 적합합니다.

  • Stream(내구성 로그): 메시지를 저장하고 재생 가능
  • Consumer(소비자 상태): Ack 기반 처리, 재전송, 백오프/재시도 설계 가능
  • Durable Consumer: 서비스 재시작에도 오프셋(처리 위치) 유지
  • Subject 기반 라우팅: orders.*, payments.* 같은 토픽 설계가 단순
  • At-least-once 전달: 중복은 발생하지만 유실 가능성을 줄임

중요한 전제는 JetStream도 기본적으로 exactly-once가 아니라 at-least-once라는 점입니다. 즉, Saga 설계는 중복을 흡수하도록(멱등성) 만들어야 합니다.

오케스트레이션 vs 코레오그래피: JetStream 관점

Saga 구현은 크게 두 가지입니다.

오케스트레이션(중앙 조정자)

  • Saga Orchestrator 서비스가 단계별로 커맨드를 발행하고 결과 이벤트를 기다립니다.
  • 장점: 흐름이 명시적이고 디버깅이 쉽습니다.
  • 단점: 오케스트레이터가 복잡해지고 단일 장애 지점이 될 수 있습니다.

JetStream에서는 오케스트레이터가 commands.*를 발행하고, 각 서비스의 events.*를 구독해 상태 머신을 전개합니다.

코레오그래피(이벤트 기반 자율)

  • 각 서비스가 이벤트를 보고 다음 이벤트를 발행합니다.
  • 장점: 결합도가 낮고 확장성이 좋습니다.
  • 단점: 전체 흐름 추적이 어렵고, 보상 규칙이 분산되어 복잡해질 수 있습니다.

JetStream에서는 orders.created를 결제가 구독하고 payments.authorized를 발행, 재고가 이를 구독하는 식입니다.

현업에서는 핵심 플로우는 오케스트레이션, 주변 확장 플로우는 코레오그래피로 섞는 경우가 많습니다.

보상트랜잭션 설계 원칙 7가지

1) 각 단계는 "보상 가능 상태"를 명시한다

예: 결제는 AUTHORIZED(승인)까지만 먼저 만들고, 최종 확정은 CAPTURED에서 수행합니다. 그러면 재고 실패 시 VOID(승인 취소)로 보상할 수 있습니다.

2) 보상은 "최소 손실"이 아니라 "도메인 규칙"이다

결제 취소가 불가능한 시점이 있으면, 보상은 환불 프로세스로 전환됩니다. 즉, 보상 이벤트도 상태 머신의 일부입니다.

3) 멱등성 키를 표준화한다

  • saga_id(전체 흐름)
  • step_id 또는 command_id(단계별 커맨드)
  • idempotency_key(서비스별 중복 방지)

DB에 유니크 제약으로 막는 것이 가장 강합니다.

4) "커맨드"와 "이벤트"를 구분한다

  • 커맨드: 특정 서비스에게 "해라"라고 요청
  • 이벤트: "일어났다"라는 사실

JetStream subject도 분리하는 편이 운영에 유리합니다.

  • 예: cmd.payments.authorize, evt.payments.authorized

5) 타임아웃은 보상 설계의 일부다

결제 승인 응답이 늦으면 재고를 잡아놓고 기다릴지, 타임아웃 후 보상(예약 해제)할지 결정해야 합니다.

분산 환경에서 타임아웃은 자주 발생합니다. gRPC를 섞어 쓴다면 데드라인 설계도 같이 봐야 합니다. 관련해서는 Go gRPC 데드라인 초과 원인 7가지와 해결도 참고할 만합니다.

6) 보상도 실패한다

보상 커맨드가 실패하면 재시도/대기열/수동 개입을 고려해야 합니다. "보상 실패"는 더 이상 자동으로 해결되지 않을 수 있으니, 운영 관점에서 별도 상태(COMPENSATION_PENDING, MANUAL_REVIEW)를 두는 게 안전합니다.

7) 관측 가능성(Tracing) 없이 Saga는 운영 불가

  • 모든 메시지에 trace_id, saga_id를 넣고 로그/메트릭에 남깁니다.
  • 이벤트 저장소(Outbox)나 Saga 상태 테이블이 있으면 디버깅이 급격히 쉬워집니다.

JetStream 스트림/서브젝트 설계

권장 subject 네이밍

  • 커맨드: cmd.{service}.{action}
  • 이벤트: evt.{service}.{event}
  • 보상 커맨드: cmd.{service}.compensate.{action} 또는 cmd.{service}.{action}.cancel

예시

  • cmd.payments.authorize
  • evt.payments.authorized
  • cmd.payments.void
  • evt.payments.voided

스트림 분리 전략

  • SAGA_COMMANDS 스트림: cmd.*
  • SAGA_EVENTS 스트림: evt.*

분리하면 보존 기간, 복제, 소비자 정책을 다르게 가져갈 수 있습니다. 예를 들어 이벤트는 7일 보존, 커맨드는 1일 보존처럼 운영 최적화가 가능합니다.

JetStream 소비자 설정: Ack, 재전송, DLQ

JetStream은 소비자가 Ack를 보내지 않으면 AckWait 이후 재전송합니다. 이때 Saga는 중복이 자연스럽게 생기므로 멱등성이 필수입니다.

추천 포인트

  • AckPolicyexplicit
  • AckWait는 처리 시간의 상한보다 약간 길게
  • MaxDeliver를 설정해 무한 재전송을 막고, 초과 시 별도 subject로 라우팅(DLQ 유사 패턴)

JetStream 자체에 전통적 의미의 DLQ가 "기본 기능"으로 딱 떨어지게 있는 것은 아니므로, 보통은 다음 패턴을 씁니다.

  • MaxDeliver 초과 메시지를 별도 스트림으로 퍼블리시하는 애플리케이션 로직
  • 또는 NAK와 지수 백오프를 조합하고, 특정 조건에서 term 처리 후 수동 큐로 이동

코드 예제 1: 스트림 생성(NATS CLI)

운영에서 가장 먼저 해야 할 일은 스트림을 명확히 정의하는 것입니다.

# Commands 스트림
nats stream add SAGA_COMMANDS \
  --subjects "cmd.*" \
  --storage file \
  --retention limits \
  --max-age 24h \
  --replicas 3

# Events 스트림
nats stream add SAGA_EVENTS \
  --subjects "evt.*" \
  --storage file \
  --retention limits \
  --max-age 168h \
  --replicas 3

포인트

  • file 스토리지는 노드 재시작에도 내구성을 제공합니다.
  • replicas는 JetStream 클러스터에서 내결함성에 직결됩니다.

코드 예제 2: 결제 서비스 소비자(Go) - 멱등성과 Ack

결제 서비스가 cmd.payments.authorize를 받아 처리하고 evt.payments.authorized 또는 evt.payments.failed를 발행한다고 가정합니다.

아래 예시는 핵심만 담은 형태입니다.

package main

import (
  "context"
  "database/sql"
  "encoding/json"
  "log"
  "time"

  "github.com/nats-io/nats.go"
)

type AuthorizeCmd struct {
  SagaID          string `json:"saga_id"`
  CommandID       string `json:"command_id"`
  OrderID         string `json:"order_id"`
  Amount          int64  `json:"amount"`
  Currency        string `json:"currency"`
  IdempotencyKey  string `json:"idempotency_key"`
}

type PaymentAuthorizedEvt struct {
  SagaID    string `json:"saga_id"`
  OrderID   string `json:"order_id"`
  PaymentID string `json:"payment_id"`
}

type PaymentFailedEvt struct {
  SagaID  string `json:"saga_id"`
  OrderID string `json:"order_id"`
  Reason  string `json:"reason"`
}

func main() {
  nc, _ := nats.Connect(nats.DefaultURL)
  js, _ := nc.JetStream()

  // Durable consumer로 재시작에도 처리 위치 유지
  sub, err := js.PullSubscribe(
    "cmd.payments.authorize",
    "payments-authorize-worker",
    nats.BindStream("SAGA_COMMANDS"),
  )
  if err != nil {
    log.Fatal(err)
  }

  db := mustOpenDB()
  ctx := context.Background()

  for {
    msgs, err := sub.Fetch(10, nats.MaxWait(2*time.Second))
    if err != nil {
      continue
    }

    for _, msg := range msgs {
      var cmd AuthorizeCmd
      if err := json.Unmarshal(msg.Data, &cmd); err != nil {
        // 포맷 오류는 재시도해도 의미 없으니 Ack 후 별도 로깅/격리
        _ = msg.Ack()
        continue
      }

      // 멱등성: idempotency_key를 유니크로 저장했다고 가정
      // 이미 처리된 커맨드면 동일 결과를 재발행하거나 Ack만 수행
      already, paymentID := isAlreadyAuthorized(ctx, db, cmd.IdempotencyKey)
      if already {
        evt := PaymentAuthorizedEvt{SagaID: cmd.SagaID, OrderID: cmd.OrderID, PaymentID: paymentID}
        publishJSON(js, "evt.payments.authorized", evt)
        _ = msg.Ack()
        continue
      }

      // 로컬 트랜잭션: 승인 레코드 생성
      paymentID, err := authorizePayment(ctx, db, cmd)
      if err != nil {
        publishJSON(js, "evt.payments.failed", PaymentFailedEvt{SagaID: cmd.SagaID, OrderID: cmd.OrderID, Reason: err.Error()})
        _ = msg.Ack()
        continue
      }

      publishJSON(js, "evt.payments.authorized", PaymentAuthorizedEvt{SagaID: cmd.SagaID, OrderID: cmd.OrderID, PaymentID: paymentID})
      _ = msg.Ack()
    }
  }
}

func publishJSON(js nats.JetStreamContext, subject string, v any) {
  b, _ := json.Marshal(v)
  _, _ = js.Publish(subject, b)
}

func mustOpenDB() *sql.DB { return &sql.DB{} }

func isAlreadyAuthorized(ctx context.Context, db *sql.DB, key string) (bool, string) {
  return false, ""
}

func authorizePayment(ctx context.Context, db *sql.DB, cmd AuthorizeCmd) (string, error) {
  return "pay_123", nil
}

핵심

  • 중복 메시지는 "예외"가 아니라 "정상"입니다.
  • 멱등성은 메시지 브로커가 아니라 서비스의 DB 제약과 상태 머신으로 확보합니다.

코드 예제 3: 오케스트레이터 상태 머신과 보상 발행

오케스트레이터는 주문 생성 Saga를 다음처럼 진행한다고 가정합니다.

  1. 결제 승인 요청
  2. 재고 예약 요청
  3. 모두 성공하면 주문 확정
  4. 중간 실패 시 이미 성공한 단계에 대해 보상 커맨드 발행

보상 규칙 예시

  • 재고 예약 실패 시 결제 void
  • 결제 승인 실패 시 재고 보상 없음
// TypeScript 의사코드

type SagaState =
  | "START"
  | "PAYMENT_AUTH_REQUESTED"
  | "PAYMENT_AUTHORIZED"
  | "INVENTORY_RESERVED"
  | "COMPLETED"
  | "COMPENSATING"
  | "FAILED";

interface SagaRow {
  sagaId: string;
  orderId: string;
  state: SagaState;
  paymentId?: string;
  inventoryReservationId?: string;
}

async function onOrderCreated(evt: any) {
  const saga = await loadSaga(evt.saga_id);
  if (saga.state !== "START") return;

  await updateSaga(saga.sagaId, { state: "PAYMENT_AUTH_REQUESTED" });

  await publish("cmd.payments.authorize", {
    saga_id: saga.sagaId,
    command_id: `cmd_${Date.now()}`,
    order_id: saga.orderId,
    amount: evt.amount,
    currency: "KRW",
    idempotency_key: `pay_auth_${saga.orderId}`,
  });
}

async function onPaymentAuthorized(evt: any) {
  const saga = await loadSaga(evt.saga_id);
  if (saga.state !== "PAYMENT_AUTH_REQUESTED") return;

  await updateSaga(saga.sagaId, {
    state: "PAYMENT_AUTHORIZED",
    paymentId: evt.payment_id,
  });

  await publish("cmd.inventory.reserve", {
    saga_id: saga.sagaId,
    command_id: `cmd_${Date.now()}`,
    order_id: saga.orderId,
    idempotency_key: `inv_res_${saga.orderId}`,
  });
}

async function onInventoryReserveFailed(evt: any) {
  const saga = await loadSaga(evt.saga_id);
  if (saga.state !== "PAYMENT_AUTHORIZED") return;

  await updateSaga(saga.sagaId, { state: "COMPENSATING" });

  // 결제 승인 취소(보상)
  await publish("cmd.payments.void", {
    saga_id: saga.sagaId,
    command_id: `cmd_${Date.now()}`,
    payment_id: saga.paymentId,
    idempotency_key: `pay_void_${saga.orderId}`,
  });

  await updateSaga(saga.sagaId, { state: "FAILED" });
}

포인트

  • 오케스트레이터도 멱등해야 합니다. 동일 이벤트가 재도착해도 상태 전이가 중복 실행되지 않도록 state 체크가 필요합니다.
  • 보상 커맨드도 멱등성 키를 갖습니다.

Outbox 패턴과 JetStream: "DB 커밋 후 발행"의 빈틈 메우기

Saga에서 자주 터지는 버그는 다음 순서에서 발생합니다.

  1. DB에 상태 저장
  2. 이벤트 발행

여기서 1은 성공, 2는 실패하면 "DB는 바뀌었는데 이벤트가 없다"가 됩니다. 반대로 2가 먼저 나가면 "이벤트는 있는데 DB 상태가 없다"가 됩니다.

이를 줄이는 대표 해법이 Outbox 패턴입니다.

  • 서비스 DB에 outbox 테이블을 두고 로컬 트랜잭션으로 "업데이트 + outbox insert"를 함께 커밋
  • 별도 퍼블리셔가 outbox를 읽어 JetStream에 발행
  • 발행 성공 시 outbox를 마킹/삭제

이 패턴은 트랜잭션 경계를 명확히 해주며, 재시도에도 강합니다.

DB 운영 이슈(락, bloat 등)가 outbox에 영향을 주면 이벤트 발행이 지연될 수 있으니, PostgreSQL을 쓴다면 PostgreSQL VACUUM 안 끝남? bloat·락 7단계 진단 같은 운영 관점 점검도 같이 가져가는 게 좋습니다.

재시도 전략: 즉시 재시도 vs 지연 재시도

JetStream은 Ack를 못 받으면 재전송하므로 "즉시 재시도" 성격이 강합니다. 하지만 외부 결제사 장애처럼 몇 분 단위로 회복되는 실패에는 즉시 재시도가 오히려 시스템을 더 압박합니다.

권장 조합

  • 애플리케이션 레벨에서 NAK와 지연(백오프) 처리
  • MaxDeliver 초과 시 격리 스트림으로 이동 후 수동 처리
  • 보상 단계도 동일한 정책 적용

또한 커맨드 처리 시간이 길다면 AckWait를 충분히 주거나, 처리 중간에 in-progress 성격의 하트비트를 설계하는 것도 고려합니다(구현 방식은 클라이언트 라이브러리/패턴에 따라 다름).

메시지 스키마와 버전 관리

Saga는 여러 서비스가 같은 이벤트를 공유하므로 스키마 관리가 중요합니다.

  • 이벤트에 schema_version을 넣고 하위 호환을 유지
  • 필드 추가는 optional로
  • 필드 삭제는 충분한 유예 기간 후

이벤트 계약이 깨지면 보상 로직이 오작동할 수 있으니, CI에서 계약 테스트를 자동화하는 것도 좋습니다. 모노레포라면 GitHub Actions 재사용 워크플로우로 모노레포 CI 통합처럼 파이프라인을 정리해두면 운영 비용이 줄어듭니다.

운영 체크리스트

  • Stream 보존 기간: 장애 시 재처리 가능한 기간으로 설정(너무 짧으면 복구 불가)
  • Consumer lag 모니터링: 특정 서비스가 밀리면 Saga 전체가 지연
  • 중복 처리율: 멱등성 키 충돌/재전송 증가를 메트릭으로 감지
  • 보상 실패 큐: 수동 개입 대상이 어디에 쌓이는지 명확히
  • 트레이싱 필수: saga_id로 end-to-end 추적

마무리: JetStream으로 "현실적인" Saga를 만들려면

NATS JetStream은 Saga의 기반이 되는 내구성 메시징을 제공하지만, 보상트랜잭션의 정답을 대신 만들어주진 않습니다. 결국 핵심은 다음 3가지입니다.

  1. 각 서비스의 로컬 상태 머신을 보상 가능하게 설계
  2. at-least-once를 전제로 멱등성과 재시도를 내재화
  3. Outbox, 관측 가능성, 격리 큐로 운영 복원력을 확보

이 3가지를 갖추면, JetStream은 가볍고 빠른 이벤트 버스로서 Saga를 안정적으로 굴릴 수 있는 좋은 선택지가 됩니다.