Published on

Kafka Saga 보상 트랜잭션 설계 7가지 함정

Authors

마이크로서비스에서 분산 트랜잭션을 다루는 대표 패턴이 Saga입니다. Kafka를 이벤트 버스로 쓰면 결합도는 낮아지지만, 보상 트랜잭션(Compensation)은 오히려 더 어려워집니다. 이유는 간단합니다. 보상은 DB 롤백이 아니라, 이미 외부로 퍼진 사실(이벤트/상태)을 또 다른 사실로 상쇄하는 비즈니스 행위이기 때문입니다.

특히 Kafka는 at-least-once 소비가 기본이고, 재시도/리밸런스/중복 전달이 흔합니다. 보상 설계가 허술하면 “취소했는데 다시 확정됨”, “재고가 마이너스”, “환불이 두 번 나감” 같은 사고가 현실이 됩니다.

이 글은 Kafka Saga에서 보상 트랜잭션 설계 시 자주 빠지는 7가지 함정과, 실전에서 통하는 회피 전략을 코드 중심으로 정리합니다. 멱등/Outbox/중복 처리의 기본은 아래 글과 함께 보면 이해가 더 빨라집니다.


전제: Kafka Saga에서 “보상”의 의미

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

  • Choreography Saga: 서비스들이 이벤트를 구독하고 다음 이벤트를 발행하며 흐름이 진행됨
  • Orchestration Saga: 오케스트레이터가 각 서비스에 커맨드를 보내고 결과를 수집

Kafka를 쓰는 경우 Choreography가 흔하지만, 보상은 흐름이 복잡해질수록 사실상 “오케스트레이션에 가까운 상태 관리”가 필요해집니다. 최소한 다음이 필요합니다.

  • Saga 인스턴스 식별자(예: sagaId)
  • 단계별 상태(예: RESERVED, CAPTURED, COMPENSATED)
  • 이벤트 중복 처리(멱등)
  • 재시도/지연/순서 뒤바뀜에 대한 내성

아래 함정들은 대부분 “보상은 단순히 반대 작업을 하면 된다”라는 오해에서 시작됩니다.


함정 1) 보상을 “반대 연산”으로만 생각한다

예: 결제 승인에 대한 보상을 “승인 취소”로만 정의하는 경우.

문제는 승인 이후에 이미 다음 단계가 진행됐을 수 있다는 점입니다.

  • 결제 승인 후 매출 전표가 생성됨
  • 재고 차감 후 출고가 진행됨
  • 포인트 적립이 이미 완료됨

이때 “반대 연산”은 불가능하거나(취소 불가) 의미가 달라집니다(부분 환불, 위약금, 재입고 불가 등).

회피 전략

  • 보상은 기술적 롤백이 아니라 업무적으로 일관된 상태 전이로 정의
  • 각 단계마다 “보상 가능 조건”과 “보상 결과 상태”를 명시

예시 상태 모델(간단화):

  • PAYMENT_AUTHORIZED → 보상: PAYMENT_VOIDED
  • PAYMENT_CAPTURED → 보상: PAYMENT_REFUNDED
  • SHIPMENT_DISPATCHED → 보상: RETURN_REQUESTED (즉시 롤백 불가)

코드 예시(상태 머신 형태):

type PaymentState =
  | 'INIT'
  | 'AUTHORIZED'
  | 'VOIDED'
  | 'CAPTURED'
  | 'REFUND_REQUESTED'
  | 'REFUNDED'
  | 'FAILED';

function compensatePayment(state: PaymentState): PaymentState {
  switch (state) {
    case 'AUTHORIZED':
      return 'VOIDED';
    case 'CAPTURED':
      return 'REFUND_REQUESTED';
    default:
      return state; // 보상 불가/불필요는 그대로
  }
}

핵심은 “CAPTURED의 보상은 VOID가 아니라 REFUND”처럼, 단계에 따라 보상 행위가 달라진다는 점입니다.


함정 2) 보상 이벤트가 “원 이벤트보다 늦게 오면” 끝이라고 가정한다

Kafka에서는 순서가 보장되지 않습니다. 같은 파티션 키를 잘 잡으면 순서성이 생기지만, 현실에서는 다음이 흔합니다.

  • 파티션 키가 서비스마다 다름
  • 토픽이 다름
  • 재처리로 과거 이벤트가 늦게 재등장

예: OrderConfirmed를 처리한 뒤 OrderCancelled가 늦게 도착했는데, 이미 배송까지 진행됨.

회피 전략

  • 이벤트에 sagaId, step, occurredAt, version을 넣고 상태 버전 기반으로 수용 여부 결정
  • “현재 상태에서 허용되는 전이만 적용”하는 방식으로 방어

간단한 전이 검증 예시:

type OrderState = 'CREATED' | 'CONFIRMED' | 'CANCELLED' | 'SHIPPED';

const allowed: Record<OrderState, OrderState[]> = {
  CREATED: ['CONFIRMED', 'CANCELLED'],
  CONFIRMED: ['SHIPPED', 'CANCELLED'],
  SHIPPED: [],
  CANCELLED: [],
};

function applyTransition(current: OrderState, next: OrderState): OrderState {
  if (!allowed[current].includes(next)) return current;
  return next;
}

이렇게 하면 늦게 온 취소가 와도 SHIPPED 상태에서는 무시되거나, 별도의 “반품 프로세스”로 분기할 수 있습니다.


함정 3) “보상도 멱등이면 되겠지”라고만 생각한다

보상은 멱등이어야 하지만, 멱등만으로는 부족합니다. 이유는 보상과 정상 흐름이 경합하기 때문입니다.

예:

  • 정상 흐름: ReserveInventory 성공 후 ShipOrder 진행
  • 보상 흐름: ReleaseInventory 실행

동시에 처리되면 재고가 꼬입니다. 멱등은 “같은 요청을 여러 번”에 대한 방어이고, 경합은 “서로 다른 요청의 순서/동시성” 문제입니다.

회피 전략

  • 동일 리소스(재고, 결제, 주문)에 대해 단일 writer를 만들거나
  • 상태 레코드에 낙관적 락(버전) 을 두고 CAS 업데이트
  • 또는 sagaId 단위로 직렬화(파티션 키를 sagaId로 고정)

낙관적 락 예시(의사 코드):

UPDATE inventory_reservation
SET status = 'RELEASED', version = version + 1
WHERE reservation_id = :id
  AND status = 'RESERVED'
  AND version = :expectedVersion;

업데이트 건수가 0이면 이미 다른 경로에서 처리된 것이므로, 이벤트를 재시도하더라도 안전해집니다.


함정 4) 보상 트리거를 “실패 이벤트 하나”로 단순화한다

많은 팀이 “어느 단계에서 실패하면 보상 이벤트를 발행한다”로 끝냅니다. 하지만 현실의 실패는 다양합니다.

  • 외부 API 타임아웃(실패인지 모름)
  • 부분 성공(결제는 됐는데 주문 저장 실패)
  • 소비자 크래시로 인한 처리 중단(커밋 전/후)

즉, 실패는 이진값이 아니라 불확실성을 포함합니다. 이 불확실성을 무시하면 보상이 과도하게 발생하거나(이중 환불), 보상이 누락됩니다.

회피 전략

  • 실패를 FAILED 하나로 두지 말고 최소한
    • RETRYING
    • UNKNOWN (결과 확인 필요)
    • FAILED_FINAL 로 나눠 운영
  • “확인 쿼리(조회) 후 보상” 패턴 도입

예: 결제 승인 요청이 타임아웃이면 곧바로 환불하지 말고, 결제사에 승인 상태 조회 후 다음을 결정합니다.

async function handlePaymentTimeout(cmdId: string) {
  const status = await paymentGateway.query(cmdId);
  if (status === 'AUTHORIZED') {
    // 다음 단계로 재진입 또는 정상 이벤트 발행
  } else if (status === 'NOT_FOUND') {
    // 재시도 또는 실패 확정
  } else {
    // UNKNOWN: 운영 개입/지연 재조회
  }
}

함정 5) Outbox 없이 “DB 저장 후 Kafka 발행”을 분리한다

보상 설계에서 가장 치명적인 구멍 중 하나입니다.

  • DB에는 “취소됨”으로 저장됐는데 Kafka 이벤트 발행 실패
  • Kafka에는 “취소됨” 이벤트가 나갔는데 DB 저장 실패

이러면 어떤 서비스는 취소로 알고, 어떤 서비스는 확정으로 압니다. 보상은 결국 이 불일치를 더 크게 증폭시킵니다.

회피 전략

  • Outbox 테이블을 두고 DB 트랜잭션 안에서 이벤트를 기록한 뒤, 별도 퍼블리셔가 Kafka로 발행
  • 컨슈머는 멱등키로 중복 처리

Outbox 스키마 예시:

CREATE TABLE outbox (
  id BIGSERIAL PRIMARY KEY,
  aggregate_id TEXT NOT NULL,
  event_type TEXT NOT NULL,
  payload JSONB NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  published_at TIMESTAMPTZ
);

퍼블리셔(의사 코드):

async function publishOutboxBatch() {
  const rows = await db.query(
    `SELECT id, event_type, payload FROM outbox
     WHERE published_at IS NULL
     ORDER BY id
     LIMIT 100`
  );

  for (const r of rows) {
    await kafkaProducer.send({
      topic: 'domain-events',
      messages: [{ key: r.payload.sagaId, value: JSON.stringify(r.payload) }],
    });

    await db.query(`UPDATE outbox SET published_at = now() WHERE id = $1`, [r.id]);
  }
}

Outbox와 멱등 처리의 실전 포인트는 아래 글에서 더 깊게 다룹니다.


함정 6) 보상은 “항상 즉시 실행”해야 한다고 믿는다

보상은 빨리 할수록 좋을 때가 많지만, 즉시 실행이 오히려 위험한 경우도 흔합니다.

  • 결제 승인 직후 취소는 가능하지만, 결제사 정산/캡처 타이밍에 따라 실패 가능
  • 재고는 즉시 풀면 좋지만, 물류 시스템은 이미 피킹을 시작했을 수 있음
  • 쿠폰/포인트는 이미 사용자에게 노출되어 사용됐을 수 있음

즉시 보상은 “기술적으로는 가능”해 보여도, 비즈니스적으로는 더 큰 부작용을 만듭니다.

회피 전략

  • 보상을 두 단계로 나눔
    • COMPENSATION_REQUESTED (요청)
    • COMPENSATED (완료)
  • 지연 보상(Delay) 또는 배치 보상도 고려
  • 외부 시스템과는 “취소/환불 요청”을 비동기로 만들고, 최종 확정 이벤트를 기다림

Kafka 지연 토픽 전략(간단):

  • compensation.requested
  • compensation.retry.5m
  • compensation.retry.1h

재시도 시에는 같은 compensationId를 유지해 멱등을 보장합니다.


함정 7) 관측 가능성(Observability)을 로그로만 해결한다

보상은 정상 흐름보다 훨씬 “드물고”, 재현이 어렵고, 운영 영향이 큽니다. 그런데도 많은 시스템이

  • 로그만 남기고
  • Saga 단위 추적이 안 되고
  • 어떤 주문이 어떤 단계에서 왜 보상됐는지 한눈에 안 보입니다

이 상태에서는 장애 시 “재처리”가 가장 위험한 선택지가 됩니다. 무엇이 이미 실행됐는지 모르기 때문입니다.

회피 전략

  • Saga 인스턴스 테이블(또는 상태 저장소)을 두고 단계별 타임라인을 저장
  • 메트릭을 “보상 요청 수/성공 수/실패 수/재시도 횟수”로 분리
  • 트레이싱에 sagaId를 태그로 강제

상태 테이블 예시:

CREATE TABLE saga_instance (
  saga_id TEXT PRIMARY KEY,
  type TEXT NOT NULL,
  state TEXT NOT NULL,
  step TEXT,
  version INT NOT NULL DEFAULT 0,
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE saga_step_log (
  id BIGSERIAL PRIMARY KEY,
  saga_id TEXT NOT NULL,
  step TEXT NOT NULL,
  event_type TEXT NOT NULL,
  status TEXT NOT NULL,
  detail JSONB,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

이렇게 하면 “보상 재실행”을 해야 할 때도 saga_step_log를 보고 안전하게 판단할 수 있습니다.


Kafka Saga 보상 설계 체크리스트

아래 항목 중 하나라도 “나중에”로 미루면 운영에서 대가를 치르기 쉽습니다.

  1. 보상은 반대 연산이 아니라 업무 상태 전이로 정의했는가
  2. 이벤트에 sagaId와 단계/버전 정보를 넣고, 허용 전이만 적용하는가
  3. 멱등뿐 아니라 경합(정상 vs 보상) 을 제어하는가
  4. 타임아웃/불확실성을 UNKNOWN 등으로 모델링하고, 조회 후 결정하는가
  5. Outbox로 “상태 변경”과 “이벤트 발행”을 원자적으로 다루는가
  6. 보상을 REQUESTEDDONE으로 나누고, 지연/재시도 전략이 있는가
  7. Saga 단위 관측(테이블/메트릭/트레이싱)이 가능해, 재처리 판단이 가능한가

마무리: 보상은 기능이 아니라 운영 설계다

Kafka 기반 Saga에서 보상 트랜잭션은 코드 몇 줄로 끝나는 기능이 아닙니다. 중복, 순서 뒤바뀜, 불확실한 실패, 외부 시스템의 비가역성을 전제로 “안전한 상태 기계”를 설계하는 일입니다.

특히 Outbox와 멱등키는 보상 설계의 바닥 공사에 가깝습니다. 이 기반이 없으면 어떤 보상 로직도 결국 데이터 불일치를 키웁니다. 관련된 실전 패턴은 아래 글을 함께 참고하세요.