Published on

Saga 패턴 보상 트랜잭션 설계·디버깅 8단계

Authors

분산 트랜잭션이 필요한 순간마다 2PC를 떠올리지만, 마이크로서비스 환경에서는 락/가용성/복잡도 때문에 현실적으로 어렵습니다. 그래서 많이 선택하는 해법이 Saga 패턴이고, Saga의 성패는 결국 보상 트랜잭션(Compensation) 을 얼마나 “되돌릴 수 있게” 설계하느냐에 달려 있습니다.

이 글은 단순 개념 소개가 아니라, 운영에서 실제로 터지는 문제(중복 실행, 부분 실패, 타임아웃, 순서 꼬임, 재시도 폭주)를 기준으로 보상 트랜잭션 설계와 디버깅을 8단계 체크리스트로 정리합니다.


0. 전제: 이 글에서 말하는 Saga의 형태

Saga는 크게 두 가지가 있습니다.

  • Choreography: 서비스들이 이벤트를 발행/구독하며 다음 단계를 진행
  • Orchestration: 오케스트레이터가 각 서비스 호출을 지휘하고 상태를 관리

보상 트랜잭션 디버깅은 Orchestration이 상대적으로 단순하지만, Choreography도 결국 같은 원칙(상태 추적, 멱등성, 재시도 제어, 관측성)이 필요합니다.

예시 시나리오는 전형적인 주문 흐름으로 가정합니다.

  1. Order 생성
  2. Payment 승인
  3. Inventory 예약
  4. Shipping 생성

실패 시 역순으로 보상합니다.


1단계: “무엇을 되돌릴지”를 상태 기계로 먼저 고정

보상 설계의 가장 흔한 실패는, 도메인 액션을 “그냥 취소 API 하나”로 뭉개는 것입니다. 실제로는 단계별로 되돌릴 수 있는 범위가 다르고, 어떤 단계는 되돌리기보다 상쇄(정정) 가 맞습니다.

체크리스트

  • 각 단계의 성공 상태를 명확히 정의했는가
  • 각 단계의 보상 가능 여부(완전 취소, 부분 취소, 정정, 불가)를 분류했는가
  • “보상은 역순” 원칙이 항상 성립하는가(외부 시스템 포함)

예시: Saga 상태 모델

아래처럼 Saga 인스턴스 상태를 명시적으로 저장해야 디버깅이 쉬워집니다.

-- saga_instance: 오케스트레이터가 관리하는 사가 실행 기록
create table saga_instance (
  saga_id varchar(64) primary key,
  saga_type varchar(64) not null,
  status varchar(32) not null, -- RUNNING, COMPENSATING, SUCCEEDED, FAILED
  current_step int not null,
  created_at timestamp not null,
  updated_at timestamp not null
);

-- saga_step: 각 스텝의 실행/보상 결과
create table saga_step (
  saga_id varchar(64) not null,
  step int not null,
  action varchar(64) not null,
  status varchar(32) not null, -- PENDING, DONE, COMPENSATED, FAILED
  idempotency_key varchar(128) not null,
  last_error text,
  updated_at timestamp not null,
  primary key (saga_id, step)
);

핵심은 “성공했으니 다음”이 아니라, 어떤 스텝이 DONE인지가 기록으로 남아야 한다는 점입니다.


2단계: 보상 트랜잭션을 “멱등”하게 만들기

운영에서 보상은 거의 반드시 중복 호출됩니다. 이유는 단순합니다.

  • 네트워크 타임아웃으로 호출자는 실패로 인식하지만 서버는 성공했을 수 있음
  • 메시지 브로커의 at-least-once 전달
  • 오케스트레이터 재시작 후 재처리

따라서 보상 API는 “한 번만 호출된다”는 가정이 절대 성립하지 않습니다.

멱등성 구현 패턴

  • idempotency_key를 요청에 포함하고, 처리 결과를 저장
  • 상태 전이를 compare-and-set으로 제한
-- idempotency 테이블
create table idempotency_record (
  key varchar(128) primary key,
  status varchar(32) not null, -- IN_PROGRESS, SUCCEEDED, FAILED
  response_hash varchar(64),
  updated_at timestamp not null
);
// TypeScript 예시: 멱등 처리 스케치
async function handleCompensation(req: { key: string; orderId: string }) {
  const rec = await db.idempotency.find(req.key);
  if (rec?.status === 'SUCCEEDED') return;

  // 없으면 생성, 있으면 IN_PROGRESS로 CAS
  await db.idempotency.upsertInProgress(req.key);

  try {
    await cancelPayment(req.orderId);
    await db.idempotency.markSucceeded(req.key);
  } catch (e) {
    await db.idempotency.markFailed(req.key, String(e));
    throw e;
  }
}

멱등성이 없으면 “보상 재시도”가 “추가 취소/추가 환불/재고 음수”로 이어집니다.


3단계: 보상의 의미를 “정확한 도메인 동작”으로 정의

보상은 단순히 반대 연산이 아닙니다.

  • 결제 승인 보상은 cancel일 수도 있고, 승인 이후 캡처가 된 경우 refund일 수도 있음
  • 재고 예약 보상은 release지만, 이미 피킹이 시작되면 adjust가 될 수 있음

설계 팁

  • 보상 API 이름을 cancel로 뭉치지 말고, 도메인 상태에 맞춰 분리
  • 보상 요청에는 “왜 보상인지” 컨텍스트를 포함
{
  "sagaId": "S-20240224-0001",
  "reason": "ORDER_FAILED_AFTER_PAYMENT",
  "idempotencyKey": "S-20240224-0001:PAYMENT:COMPENSATE",
  "payment": {
    "paymentId": "P-7788",
    "mode": "CANCEL_OR_REFUND"
  }
}

이렇게 해두면 장애 분석 시 “어떤 정책으로 보상했는지”가 로그만으로도 남습니다.


4단계: 타임아웃·재시도·서킷브레이커를 “보상 관점”에서 재설계

보상 트랜잭션은 정상 트래픽보다 더 불리한 조건에서 호출됩니다.

  • 이미 장애가 발생한 상황에서 실행됨
  • 연쇄 실패로 외부 의존성이 흔들리는 중
  • 동시 보상 요청이 폭주할 수 있음

따라서 보상 호출의 타임아웃/재시도는 “평소 정책”을 그대로 쓰면 위험합니다.

실전 권장

  • 보상 호출은 짧은 타임아웃 + 제한된 재시도
  • 재시도는 지수 백오프 + 지터
  • 실패 시 즉시 사람에게 알리는 대신, 보상 큐에 적재하고 비동기 재처리

gRPC 기반이라면 타임아웃 설계가 특히 중요합니다. 운영에서 자주 보는 증상이 DEADLINE_EXCEEDED이고, 원인 파악 프레임워크가 필요합니다. 관련해서는 이 글도 함께 보면 좋습니다.


5단계: Outbox/Inbox로 “발행과 저장”을 원자화

Choreography든 Orchestration이든, 결국 이벤트/커맨드를 외부로 내보냅니다. 이때 가장 치명적인 버그가 아래 패턴입니다.

  • DB에는 상태 저장 성공
  • 메시지 발행 실패
  • 결과적으로 다음 스텝이 영원히 실행되지 않음(유령 사가)

해결: Transactional Outbox

create table outbox (
  id bigint primary key auto_increment,
  aggregate_id varchar(64) not null,
  topic varchar(128) not null,
  payload json not null,
  status varchar(16) not null, -- NEW, SENT, FAILED
  created_at timestamp not null
);

업데이트 트랜잭션 안에서 saga_step 갱신과 outbox insert를 함께 처리하고, 별도 퍼블리셔가 outbox를 읽어 발행합니다.

이 구조가 없으면 “보상 이벤트가 발행되지 않아 보상이 안 되는” 유형의 장애를 재현하기도 어렵습니다.


6단계: 동시성 충돌과 데드락을 “사가 단위”로 격리

보상은 대개 역순으로 여러 리소스를 만지며, 동시에 여러 사가가 같은 자원(재고, 쿠폰, 포인트)을 건드립니다. 이때 DB 레벨에서는 락 경합과 데드락이 빈번해집니다.

체크리스트

  • 동일 리소스 업데이트 순서를 서비스 전반에서 통일했는가
  • 보상 처리 시에도 동일한 락 순서를 지키는가
  • SELECT ... FOR UPDATE 범위가 과도하지 않은가

MySQL InnoDB를 쓴다면 데드락 로그를 읽고 “어떤 쿼 조합이 락 사이클을 만들었는지”를 추적하는 습관이 중요합니다.

보상 트랜잭션 디버깅에서 데드락을 놓치면, 증상은 단순히 “보상 재시도만 계속됨”으로 보이고 실제 원인은 DB 락일 수 있습니다.


7단계: 관측성 3종 세트로 “어디서 멈췄는지”를 즉시 찾기

보상 디버깅의 목표는 하나입니다.

  • “사가 sagaId가 지금 어느 스텝에서, 어떤 이유로, 몇 번 재시도하다가 멈췄는가”를 1분 안에 답하기

필수 로그 필드

  • sagaId
  • step
  • action 또는 compensationAction
  • idempotencyKey
  • attempt
  • traceId
{
  "level": "ERROR",
  "sagaId": "S-20240224-0001",
  "step": 2,
  "phase": "COMPENSATE",
  "action": "PAYMENT_CANCEL",
  "attempt": 3,
  "idempotencyKey": "S-20240224-0001:PAYMENT:COMPENSATE",
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
  "error": "DEADLINE_EXCEEDED"
}

메트릭 권장

  • saga_running_total, saga_failed_total, saga_compensating_total
  • 스텝별 latency 히스토그램
  • 보상 재시도 횟수 분포

트레이싱

  • 오케스트레이터 span 아래에 각 서비스 호출 span을 붙여 “보상 체인”을 한 화면에서 확인

8단계: 운영 디버깅 런북을 “재현 가능”하게 만든다

보상 장애는 재현이 어렵습니다. 그래서 런북은 “추측”이 아니라, 데이터로 좁혀가는 절차여야 합니다.

런북 예시(요약)

  1. saga_instance에서 sagaId 조회, statuscurrent_step 확인
  2. saga_step에서 DONE/FAILED 스텝과 last_error 확인
  3. 동일 idempotency_key로 중복 요청 여부 확인
  4. 타임아웃이면 의존 서비스의 지연/에러율 확인
  5. DB 락이면 데드락 로그 또는 락 대기 확인
  6. 메시지 기반이면 outboxNEW가 쌓였는지 확인
  7. 재시도 폭주면 워커 동시성/백오프 설정 확인
  8. 마지막으로 “수동 정정”이 필요한지 판단(자동 보상이 불가능한 케이스 분리)

디스크/메모리 같은 인프라 병목도 함께 본다

보상 워커가 느려지는 원인이 애플리케이션 로직이 아니라, 노드 자원 고갈인 경우도 많습니다. 예를 들어 디스크가 꽉 차 로그/DB/큐가 연쇄적으로 느려질 수 있고, 컨테이너가 OOMKilled로 재시작되며 사가가 반복될 수 있습니다.


코드 예제: 간단한 Orchestrator + 보상 실행 흐름

아래는 “스텝 실행 중 실패하면 역순 보상”의 핵심 구조만 담은 예시입니다. 실제 운영에서는 outbox, 분산락, 워커 큐 등을 붙이지만, 뼈대는 동일합니다.

type Step = {
  name: string;
  run: () => Promise<void>;
  compensate: () => Promise<void>;
};

export async function runSaga(sagaId: string, steps: Step[]) {
  const done: number[] = [];

  try {
    for (let i = 0; i < steps.length; i++) {
      await markStepPending(sagaId, i, steps[i].name);
      await steps[i].run();
      await markStepDone(sagaId, i);
      done.push(i);
    }
    await markSagaSucceeded(sagaId);
  } catch (e) {
    await markSagaCompensating(sagaId, String(e));

    // 역순 보상
    for (let j = done.length - 1; j >= 0; j--) {
      const i = done[j];
      try {
        await steps[i].compensate();
        await markStepCompensated(sagaId, i);
      } catch (ce) {
        await markStepFailed(sagaId, i, `COMPENSATION_FAILED: ${String(ce)}`);
        // 여기서 즉시 중단할지, 다음 보상을 계속할지는 정책 결정 사항
      }
    }

    await markSagaFailed(sagaId, String(e));
    throw e;
  }
}

이 코드가 운영 수준이 되려면 다음이 추가되어야 합니다.

  • compensate() 멱등성(2단계)
  • 보상 실패 시 워커 큐에 적재하여 재시도(4단계)
  • 상태/이벤트 저장과 발행 원자화(outbox)(5단계)
  • 트레이스/로그/메트릭(7단계)

마무리: 보상은 “취소 버튼”이 아니라 “운영 가능한 되돌리기”

Saga 패턴은 분산 트랜잭션의 은탄환이 아닙니다. 대신, 잘 설계된 보상 트랜잭션은 장애가 나도 시스템을 일관된 방향으로 수렴하게 만들어줍니다.

8단계를 다시 요약하면 다음과 같습니다.

  1. 상태 기계로 되돌릴 범위를 고정
  2. 보상 멱등성 확보
  3. 도메인 의미에 맞는 보상 동작 정의
  4. 타임아웃/재시도/서킷브레이커를 보상 관점에서 설계
  5. outbox/inbox로 저장과 발행 원자화
  6. 동시성/데드락을 사가 단위로 격리
  7. 관측성(로그/메트릭/트레이싱) 표준화
  8. 런북으로 재현 가능한 디버깅 절차 확립

이 8단계를 기준으로 기존 사가 흐름을 점검하면, “가끔 터지는 보상 장애”가 아니라 “예상 가능한 운영 이벤트”로 바뀌기 시작합니다.