Published on

MSA에서 Saga 보상 트랜잭션이 꼬일 때

Authors

모놀리스를 MSA로 분리하면 가장 먼저 깨지는 감각이 있습니다. "트랜잭션은 원자적으로 끝난다"라는 믿음입니다. 로컬 DB 트랜잭션은 여전히 원자적이지만, 서비스 경계를 넘는 순간부터는 네트워크, 큐, 재시도, 타임아웃, 중복 이벤트, 순서 뒤바뀜이 기본값이 됩니다. 그 결과 Saga를 도입해도 보상 트랜잭션이 기대처럼 동작하지 않고, 오히려 상태가 더 꼬이는 일이 생깁니다.

이 글은 "MSA에서 Saga 보상 트랜잭션이 꼬일 때" 실제로 어떤 형태로 망가지는지, 왜 그런지, 그리고 어떤 설계로 복구 가능하게 만들지(중요: 완벽한 방지는 불가능) 정리합니다.

참고로 MSA 전환 시 DB 트랜잭션이 깨지는 패턴 자체가 궁금하다면 모놀리스를 MSA로 쪼갤 때 DB 트랜잭션 깨지는 5가지 패턴도 같이 보시면 배경 이해가 빨라집니다.

Saga 보상이 "꼬였다"의 대표 증상

현장에서 자주 보는 증상은 아래 5가지로 수렴합니다.

1) 보상이 중복 실행된다

  • 결제 취소가 두 번 호출되어 "이미 취소됨" 오류가 나거나
  • 재고 복원이 두 번 되어 재고가 증가해버리거나
  • 쿠폰 복구가 두 번되어 쿠폰이 부활하는 등

대부분 메시지 브로커의 at-least-once 전달, HTTP 재시도, 컨슈머 리밸런싱, 워커 크래시 후 재처리로 발생합니다.

2) 보상이 역순으로 실행된다

예를 들어 정상 순서는 다음이라고 합시다.

  1. 재고 예약
  2. 결제 승인
  3. 주문 확정

실패 시 보상은 보통 역순을 기대합니다.

  1. 주문 확정 취소
  2. 결제 취소
  3. 재고 예약 해제

그런데 이벤트 지연이나 파티션 분리, 서로 다른 토픽, 서로 다른 컨슈머 그룹 때문에 결제 취소가 먼저 실행되고, 그 후 주문 확정 취소가 와서 "이미 결제 취소됨" 같은 비정상 흐름이 만들어집니다.

3) 보상이 부분 실패하고 멈춘다

  • 결제 취소는 성공했는데 재고 복원은 실패
  • 재고 복원은 성공했는데 쿠폰 복구는 실패

그리고 이 상태가 운영자가 알아차릴 때까지 방치됩니다. Saga는 "일련의 보상"이지 "자동 복구 보장"이 아닙니다.

4) 정상 플로우와 보상 플로우가 경합한다

가장 골치 아픈 케이스입니다.

  • 주문 생성 요청이 늦게 도착해 결제가 먼저 성공해버림
  • 타임아웃으로 오케스트레이터는 실패로 판단하고 보상을 발행
  • 동시에 늦게 도착한 정상 응답이 성공 처리되어 "성공"과 "실패"가 동시에 진행

5) 관측상 성공인데 실제로는 실패(또는 반대)

  • 오케스트레이터 DB에는 COMPLETED인데 실제로 결제는 취소됨
  • 오케스트레이터 DB에는 COMPENSATED인데 실제로 재고는 여전히 예약 상태

이건 대개 "오케스트레이터 상태 업데이트"와 "메시지 발행"이 원자적으로 묶이지 않아서 생깁니다.

왜 꼬이나: 보상 트랜잭션이 가진 구조적 한계

Saga는 분산 트랜잭션의 대체재지만, 아래 전제 위에서만 "예측 가능"해집니다.

  • 메시지는 중복 전달될 수 있다(at-least-once)
  • 메시지 순서는 보장되지 않는다(특히 멀티 토픽, 멀티 파티션)
  • 각 서비스는 독립적으로 실패하고 재시작한다
  • 네트워크 타임아웃은 "실패"가 아니라 "모름"이다
  • 보상은 진짜 롤백이 아니라 "반대 동작"이며, 항상 가능하지 않다

즉, 보상 트랜잭션이 꼬이는 건 구현 실수만의 문제가 아니라 "분산 시스템의 정상 동작"에 가깝습니다. 그래서 해결책도 "꼬이지 않게"가 아니라 "꼬여도 복구 가능하게"로 가야 합니다.

안정화 설계 1: 모든 커맨드와 보상을 idempotent로 만들기

보상 중복 실행을 막는 가장 기본은 "멱등성"입니다.

  • 결제 취소 요청이 2번 와도 1번만 처리
  • 재고 복원 요청이 3번 와도 최종 재고는 1번 복원된 값

핵심은 요청 단위의 idempotencyKey와 처리 결과 저장입니다.

예시: 결제 취소 API 멱등 처리(의사 코드)

// cancelPayment.ts
// 주의: 본문에 `<` `>` 를 쓰지 않기 위해 제네릭 표기는 피합니다.

type CancelRequest = {
  paymentId: string;
  idempotencyKey: string;
  reason: string;
};

export async function cancelPayment(req: CancelRequest) {
  // 1) 멱등 키로 이미 처리했는지 확인
  const prev = await db.idempotency.findUnique({
    where: { key: req.idempotencyKey }
  });

  if (prev) {
    return { status: "OK", result: prev.resultJson };
  }

  // 2) 실제 취소 로직(외부 PG 호출 등)
  const payment = await db.payment.findUnique({ where: { id: req.paymentId } });
  if (!payment) {
    // 실패도 멱등 테이블에 기록해두면 재시도 폭풍을 줄일 수 있음
    await db.idempotency.create({
      data: { key: req.idempotencyKey, resultJson: JSON.stringify({ notFound: true }) }
    });
    return { status: "NOT_FOUND" };
  }

  if (payment.status === "CANCELED") {
    await db.idempotency.create({
      data: { key: req.idempotencyKey, resultJson: JSON.stringify({ alreadyCanceled: true }) }
    });
    return { status: "OK", alreadyCanceled: true };
  }

  // 외부 시스템 취소 요청 (타임아웃/재시도 설계 필요)
  await pg.cancel({ transactionId: payment.pgTransactionId });

  await db.payment.update({
    where: { id: req.paymentId },
    data: { status: "CANCELED" }
  });

  await db.idempotency.create({
    data: { key: req.idempotencyKey, resultJson: JSON.stringify({ canceled: true }) }
  });

  return { status: "OK", canceled: true };
}

멱등성은 "보상"뿐 아니라 "정상 커맨드"에도 동일하게 적용해야 합니다. 정상 커맨드가 중복 처리되면, 보상이 아무리 잘 되어도 결과가 틀어집니다.

안정화 설계 2: Saga를 상태머신으로 만들고 전이를 단방향으로 제한

"보상 역순" 문제는 결국 상태 전이가 느슨해서 생깁니다. 해결은 상태머신입니다.

  • 주문 Saga 상태: STARTEDINVENTORY_RESERVEDPAYMENT_CAPTUREDCOMPLETED
  • 실패 시 상태: COMPENSATINGCOMPENSATED

중요한 규칙은 다음입니다.

  1. 상태 전이는 항상 단방향
  2. 특정 상태에서만 특정 커맨드를 발행
  3. 이벤트가 늦게 와도 "현재 상태"로 무시하거나 보정

예시: 오케스트레이터 상태 전이(의사 코드)

// sagaOrchestrator.ts

type SagaState =
  | "STARTED"
  | "INVENTORY_RESERVED"
  | "PAYMENT_CAPTURED"
  | "COMPLETED"
  | "COMPENSATING"
  | "COMPENSATED";

function canApply(current: SagaState, eventType: string) {
  const allowed: Record<string, SagaState[]> = {
    InventoryReserved: ["STARTED"],
    PaymentCaptured: ["INVENTORY_RESERVED"],
    Completed: ["PAYMENT_CAPTURED"],
    CompensationDone: ["COMPENSATING"],
  };

  return (allowed[eventType] || []).includes(current);
}

export async function onEvent(sagaId: string, eventType: string, payload: any) {
  const saga = await db.saga.findUnique({ where: { id: sagaId } });
  if (!saga) return;

  if (!canApply(saga.state as SagaState, eventType)) {
    // 늦게 온 이벤트는 무시하거나, 별도 보정 큐로 보냄
    await db.sagaEventIgnored.create({
      data: { sagaId, eventType, payloadJson: JSON.stringify(payload) }
    });
    return;
  }

  // 전이 수행
  if (eventType === "InventoryReserved") {
    await db.saga.update({ where: { id: sagaId }, data: { state: "INVENTORY_RESERVED" } });
    await enqueueCommand("CapturePayment", { sagaId });
  }

  if (eventType === "PaymentCaptured") {
    await db.saga.update({ where: { id: sagaId }, data: { state: "PAYMENT_CAPTURED" } });
    await enqueueCommand("CompleteOrder", { sagaId });
  }
}

이렇게 하면 "역순 이벤트"가 와도 상태 전이가 막히고, 무시된 이벤트를 별도 테이블에 남겨 운영자가 추적할 수 있습니다.

안정화 설계 3: Outbox로 상태 업데이트와 이벤트 발행을 원자화

"관측상 성공인데 실제로는 실패"의 대표 원인은 다음 경쟁 조건입니다.

  • DB 트랜잭션 커밋 성공
  • 이벤트 발행 실패(프로세스 크래시, 네트워크 오류)

또는 반대로

  • 이벤트는 발행됨
  • DB 커밋 전에 크래시

해결은 Outbox 패턴입니다.

  • 서비스는 자신의 DB 트랜잭션 안에서
    • 도메인 상태 변경
    • outbox 테이블에 발행할 이벤트 저장
  • 별도 릴레이 프로세스가 outbox를 읽어 브로커로 발행

예시: 주문 서비스 Outbox 스키마와 발행(예시 SQL)

-- outbox 테이블
create table outbox (
  id varchar(64) primary key,
  aggregate_id varchar(64) not null,
  event_type varchar(128) not null,
  payload_json text not null,
  created_at timestamp not null,
  published_at timestamp null
);

-- 주문 확정과 이벤트 저장을 같은 트랜잭션으로
-- (DB 문법은 엔진별로 다를 수 있음)
begin;

update orders
set status = 'COMPLETED'
where id = 'order_123';

insert into outbox (id, aggregate_id, event_type, payload_json, created_at)
values ('evt_789', 'order_123', 'OrderCompleted', '{"orderId":"order_123"}', now());

commit;

릴레이 워커는 published_at이 비어있는 레코드를 읽어 발행 후 마킹합니다. 이때도 중복 발행 가능성을 고려해 "브로커 키" 또는 "컨슈머 멱등"으로 방어합니다.

안정화 설계 4: 보상은 "반대 동작"이 아니라 "목표 상태로 수렴"하게

보상 로직을 "undo"처럼 만들면 실패합니다. 분산 환경에서 undo는 종종 불가능합니다.

예시)

  • 결제는 부분 승인 상태가 있을 수 있음
  • 재고는 이미 다른 주문이 소비했을 수 있음
  • 쿠폰은 이미 다른 곳에서 사용 처리되었을 수 있음

그래서 보상은 다음 철학이 더 안전합니다.

  • "이전 상태로 되돌리기"가 아니라
  • "최종적으로 일관된 목표 상태로 수렴시키기"

예를 들어 재고 보상은 +1 같은 델타 업데이트보다

  • reservationId를 기준으로 예약 레코드를 RELEASED로 만들고
  • 가용 재고는 예약 레코드 합으로 계산하거나
  • 예약 레코드 상태를 기반으로 정산

같은 방식이 더 복구 친화적입니다.

안정화 설계 5: 타임아웃과 데드라인 전파를 강제해 "모름" 구간을 줄이기

보상이 꼬이는 계기 중 하나가 "타임아웃 불일치"입니다.

  • A 서비스는 2초 타임아웃
  • B 서비스는 10초 동안 처리
  • A는 실패로 판단하고 보상 시작
  • B는 결국 성공 처리하고 이벤트 발행

이러면 정상 플로우와 보상이 경합합니다.

해결은

  • 데드라인을 요청 체인 전체에 전파
  • 데드라인이 임박하면 다운스트림이 빠르게 중단
  • "늦게 성공"을 줄이기

gRPC를 쓰고 있다면 데드라인 전파 누락이 특히 흔합니다. 관련 진단/해결은 gRPC MSA 데드라인 전파 누락 진단·해결에서 더 깊게 다뤘습니다.

예시: HTTP에서 데드라인 헤더 전파(의사 코드)

// deadline.ts

export function computeDeadlineMs(nowMs: number, budgetMs: number) {
  return nowMs + budgetMs;
}

export function remainingMs(deadlineMs: number, nowMs: number) {
  return Math.max(0, deadlineMs - nowMs);
}

// client.ts
export async function callDownstream(url: string, deadlineMs: number) {
  const timeoutMs = remainingMs(deadlineMs, Date.now());
  if (timeoutMs === 0) throw new Error("deadline exceeded before call");

  const controller = new AbortController();
  const t = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const res = await fetch(url, {
      method: "POST",
      headers: {
        "x-deadline-ms": String(deadlineMs)
      },
      signal: controller.signal
    });
    return res;
  } finally {
    clearTimeout(t);
  }
}

다운스트림은 x-deadline-ms를 읽어 내부 처리(외부 API 호출, DB 락 대기 등)도 같은 예산으로 제한해야 합니다.

안정화 설계 6: 보상 실패는 "재시도"와 "사람 개입"을 제품 기능으로 만들기

보상은 실패합니다. 중요한 건 실패했을 때의 시스템 행동입니다.

권장 패턴은 다음입니다.

  • 보상 커맨드마다 재시도 정책을 다르게
    • 네트워크 오류는 지수 백오프
    • 비즈니스 충돌은 즉시 중단하고 수동 처리 큐로
  • 재시도 횟수 초과 시 NEEDS_MANUAL_REVIEW 상태로 전이
  • 운영 도구에서 sagaId 단위로
    • 현재 상태
    • 마지막 이벤트
    • 실패 원인
    • 재시도 버튼(또는 보상 강제 실행)

"보상 실패를 숨기지 말고 노출"해야 운영이 가능합니다.

디버깅 체크리스트: 꼬였을 때 어디부터 볼까

  1. 상관관계 ID: sagaId, orderId, reservationId, paymentId가 모든 로그와 이벤트에 포함되는가
  2. 멱등 키: 커맨드와 보상 모두 idempotencyKey가 있는가
  3. Outbox 누락: DB에는 상태가 바뀌었는데 이벤트가 없는 케이스가 있는가
  4. 컨슈머 중복 처리: 같은 메시지 ID를 여러 번 처리했는가
  5. 순서 보장 가정: 서로 다른 토픽 또는 파티션에서 순서를 기대하고 있지 않은가
  6. 타임아웃 불일치: 업스트림 타임아웃이 다운스트림 처리 시간보다 짧지 않은가

결론: "완벽한 보상"이 아니라 "복구 가능한 Saga"가 목표

MSA에서 Saga 보상 트랜잭션이 꼬이는 이유는 대개 "분산 환경에서 당연히 일어나는 일"을 코드가 감당하지 못해서입니다.

정리하면 안정화의 핵심은 아래 4가지입니다.

  • 커맨드와 보상 모두 멱등 처리
  • 오케스트레이터를 상태머신으로 만들고 역순 이벤트를 무시 또는 격리
  • Outbox로 상태 변경과 이벤트 발행을 원자화
  • 데드라인 전파로 정상 플로우와 보상 플로우의 경합을 줄이기

그리고 마지막으로, 보상 실패를 "예외"가 아니라 "제품의 한 흐름"으로 설계해야 운영 가능한 MSA가 됩니다.