Published on

Java/Spring 없이 SAGA 보상 트랜잭션 설계하기

Authors

서로 다른 마이크로서비스가 각자 데이터베이스를 소유하는 구조에서는, 단일 ACID 트랜잭션으로 모든 변경을 묶을 수 없습니다. 이때 가장 현실적인 접근이 SAGA(사가)이며, 실패 시 이전 단계들을 되돌리는 보상 트랜잭션(compensating transaction)이 핵심입니다.

이 글은 Java/Spring(예: Spring Cloud, Spring StateMachine, Spring Kafka 등) 없이도 SAGA를 설계·구현할 수 있도록, 언어/런타임 중립적인 원칙과 데이터 모델, 메시징 패턴, 운영 관점의 안전장치를 정리합니다. 예시는 Node.js, Python, Go 등 어디서든 적용 가능한 형태로 제시합니다.

SAGA에서 “보상”이 어려운 이유

보상 트랜잭션은 단순히 rollback이 아닙니다. 분산 환경에서는 다음의 문제가 동시에 발생합니다.

  • 부분 성공: A는 성공했는데 B에서 실패
  • 시간 지연: B가 늦게 성공/실패 이벤트를 발행
  • 중복 전달: 메시지 브로커가 at-least-once로 동일 이벤트를 재전달
  • 순서 뒤바뀜: 네트워크/파티션으로 이벤트가 역순 도착
  • 보상도 실패: 보상 API 호출 자체가 실패하거나 재시도 중 중복 실행

따라서 설계 목표는 “완벽한 원자성”이 아니라, 최종 일관성(eventual consistency) 하에서 누락 없이, 중복에도 안전하게, 관측 가능하게 보상을 수행하는 것입니다.

오케스트레이션 vs 코레오그래피: 프레임워크 없이 고르는 기준

SAGA는 크게 두 방식이 있습니다.

오케스트레이션(Orchestration)

중앙의 오케스트레이터가 상태 머신처럼 단계별로 명령(command)을 보내고, 결과(event)를 받아 다음 단계를 진행합니다.

  • 장점: 흐름이 명확, 디버깅/관측 쉬움, 보상 순서 제어 용이
  • 단점: 오케스트레이터가 단일 논리 허브가 됨(가용성/복잡도)

코레오그래피(Choreography)

각 서비스가 이벤트를 구독하고 다음 이벤트를 발행하며, 중앙 제어 없이 흐름이 이어집니다.

  • 장점: 결합도 낮음, 중앙 컴포넌트 부담 감소
  • 단점: 흐름 추적 어려움, 보상 설계 난이도 상승(특히 순서/경쟁 조건)

프레임워크 없이 시작한다면, 운영 난이도까지 고려해 오케스트레이션을 먼저 추천합니다. 코레오그래피는 조직/도메인이 매우 성숙하고 이벤트 계약이 안정적일 때 유리합니다.

도메인 모델링: “취소”가 아니라 “상태 전이”로 설계

보상 트랜잭션은 보통 다음 중 하나입니다.

  • 취소(Cancel): 결제 취소, 예약 취소
  • 반대 동작(Reverse): 재고 차감의 반대는 재고 복구
  • 새로운 조정(Adjust): 환불이 부분 환불이거나, 재고가 이미 소진되어 대체 재고로 전환

중요한 포인트는 보상이 “이전 상태로 완벽히 복구”가 아니라, 비즈니스적으로 허용 가능한 상태 전이라는 점입니다.

예: 주문 SAGA라면 주문 상태는 PENDINGCONFIRMED가 아니라,

  • PENDING
  • PAYMENT_AUTHORIZED
  • INVENTORY_RESERVED
  • SHIPPING_REQUESTED
  • COMPLETED
  • COMPENSATING
  • CANCELLED
  • FAILED

처럼 “보상 중”과 “실패”를 명시적으로 상태로 둬야 운영이 가능합니다.

메시징 기본기: 커맨드/이벤트, 상관관계, 멱등성

Java/Spring 없이 구현할 때도 메시지 계약은 동일하게 가져가야 합니다.

필수 메타데이터

모든 커맨드/이벤트에 아래 필드를 넣는 것을 권장합니다.

  • message_id: 메시지 멱등 처리 키(전역 유니크)
  • saga_id: 사가 인스턴스 식별자
  • correlation_id: 요청-응답/흐름 추적용
  • causation_id: 이 메시지를 유발한 직전 메시지의 message_id
  • step: 단계명(예: reserve_inventory)
  • timestamp: 생성 시각

이 메타데이터가 있어야 중복/역순/재시도 상황에서 “무슨 일이 있었는지”를 재구성할 수 있습니다.

메시지 예시(JSON)

아래 예시는 인라인 코드가 아니라 코드 블록이므로 그대로 사용해도 됩니다.

{
  "message_id": "01J1Q9Z8W2Y1G9Q1Y9W3ZJ7K3A",
  "saga_id": "saga_order_9f2c1c0b",
  "correlation_id": "req_7c1d0d",
  "causation_id": "01J1Q9Z7...",
  "type": "ReserveInventoryCommand",
  "step": "inventory.reserve",
  "payload": {
    "order_id": "o_123",
    "items": [{"sku": "A-1", "qty": 2}]
  },
  "timestamp": "2026-02-26T10:00:00Z"
}

핵심 패턴 1: Outbox로 “DB 반영과 이벤트 발행”을 묶기

분산 트랜잭션이 불가능한 상황에서 가장 흔한 장애는 이것입니다.

  1. DB에는 상태가 저장됨
  2. 이벤트 발행은 실패함
  3. 다운스트림은 영원히 그 변경을 모름

이를 막는 대표 해법이 Transactional Outbox입니다.

Outbox 테이블 스키마 예시(PostgreSQL)

CREATE TABLE outbox (
  id              uuid PRIMARY KEY,
  aggregate_type  text NOT NULL,
  aggregate_id    text NOT NULL,
  message_type    text NOT NULL,
  payload         jsonb NOT NULL,
  headers         jsonb NOT NULL,
  created_at      timestamptz NOT NULL DEFAULT now(),
  published_at    timestamptz,
  publish_attempt int NOT NULL DEFAULT 0
);

CREATE INDEX outbox_unpublished_idx
ON outbox (created_at)
WHERE published_at IS NULL;

서비스 로직(언어 중립 의사코드)

BEGIN;
  UPDATE orders SET status = 'PAYMENT_AUTHORIZED' WHERE id = :order_id;
  INSERT INTO outbox(id, aggregate_type, aggregate_id, message_type, payload, headers)
  VALUES(:uuid, 'order', :order_id, 'PaymentAuthorizedEvent', :payload, :headers);
COMMIT;

# 별도 퍼블리셔가 outbox를 폴링/CDC로 읽어 브로커에 발행

이렇게 하면 DB 커밋이 성공한 변경은 반드시 outbox에 남고, 브로커 발행은 재시도로 결국 따라잡을 수 있습니다.

핵심 패턴 2: “보상도 멱등”하게 만들기

보상은 재시도/중복 실행이 흔합니다. 따라서 보상 핸들러는 반드시 멱등이어야 합니다.

멱등성 구현 전략

  • Idempotency Key 저장: message_id 또는 saga_id + step을 처리 로그 테이블에 기록
  • 상태 기반 처리: 이미 취소된 결제면 200 OK로 종료
  • 유니크 제약: 같은 보상 요청이 중복 삽입되지 않도록 DB 제약으로 차단

예: 결제 취소 처리 테이블

CREATE TABLE payment_compensations (
  id uuid PRIMARY KEY,
  saga_id text NOT NULL,
  order_id text NOT NULL,
  message_id text NOT NULL,
  status text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (message_id)
);

핸들러는 다음과 같이 동작합니다.

  • message_id가 이미 존재하면 “이미 처리됨”으로 종료
  • 존재하지 않으면 레코드 생성 후 실제 PG사 취소 호출
  • 성공/실패 상태를 업데이트

이 주제는 실전에서 누락·중복이 가장 치명적이므로, Kafka 기반 사례를 기준으로 더 깊게 보고 싶다면 아래 글도 함께 참고하세요.

오케스트레이터 설계: 상태 머신을 DB로 만든다

Spring 없이 오케스트레이션을 구현하려면, 결국 “사가 인스턴스 상태”를 저장하고 이벤트에 따라 전이시키는 컴포넌트가 필요합니다.

최소 테이블 설계

CREATE TABLE saga_instances (
  saga_id text PRIMARY KEY,
  saga_type text NOT NULL,
  status text NOT NULL,
  current_step text,
  context jsonb NOT NULL,
  version int NOT NULL DEFAULT 0,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE saga_step_log (
  id uuid PRIMARY KEY,
  saga_id text NOT NULL,
  step text NOT NULL,
  direction text NOT NULL,  -- forward | compensate
  message_id text NOT NULL,
  result text NOT NULL,     -- success | failure
  created_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (message_id)
);
  • context에는 주문 금액, 예약된 재고 토큰, 결제 승인 번호 등 “보상에 필요한 데이터”를 저장합니다.
  • version은 낙관적 락(optimistic locking) 용도로 사용해 중복 이벤트로 인한 경합을 줄입니다.

이벤트 처리 루프(예: Node.js 스타일 의사코드)

async function onEvent(evt) {
  // 1) 중복 이벤트 방지
  if (await alreadyProcessed(evt.message_id)) return;

  // 2) saga 로드 + 버전 체크
  const saga = await loadSaga(evt.saga_id);

  // 3) 상태 전이
  const transition = decideTransition(saga, evt);

  // 4) DB에 saga 상태/로그 기록 + outbox에 다음 커맨드 적재를 한 트랜잭션으로
  await db.tx(async (t) => {
    await t.insert('saga_step_log', { /* ... */ });
    await t.update('saga_instances', { /* status, step, context, version+1 */ });
    await t.insert('outbox', { /* NextCommand or CompensationCommand */ });
  });
}

여기서 중요한 점은 “다음 커맨드 발행”도 outbox에 넣어 사가 상태 업데이트와 함께 커밋하는 것입니다. 그래야 오케스트레이터가 죽어도 흐름이 끊기지 않습니다.

보상 순서 설계: 역순 보상이 기본, 예외를 문서화

일반적으로 보상은 진행의 역순으로 합니다.

  1. 결제 승인
  2. 재고 예약
  3. 배송 요청

이라면 실패 시 보상은

  • 배송 취소
  • 재고 예약 해제
  • 결제 취소

순서가 됩니다.

다만 예외가 있습니다.

  • 결제 취소가 늦게 반영되면 고객 경험/정산 리스크가 커서 결제 취소를 먼저 수행해야 하는 경우
  • 재고가 외부 WMS에 반영되어 취소 비용이 크면, “취소” 대신 “재고 조정 이벤트”로 대체해야 하는 경우

이런 예외는 코드로 숨기지 말고, 사가 정의서에 “보상 순서와 이유”를 남겨야 운영에서 사고를 줄입니다.

타임아웃과 재시도: 무한 재시도 대신 ‘데드레터+수동 개입’

보상까지 포함한 SAGA는 외부 시스템(결제/배송/파트너 API)에 의존하므로, 네트워크 타임아웃이 필연입니다.

재시도 정책은 다음을 권장합니다.

  • 지수 백오프 + 지터: 동시 재시도로 인한 폭주 방지
  • 최대 시도 횟수: 예: 10회 또는 24시간
  • DLQ(Dead Letter Queue): 한계를 넘으면 운영자가 재처리/보정
  • 서킷 브레이커: 외부 장애 시 빠르게 실패시키고 큐 적체를 줄임

타임아웃/게이트웨이 계열 장애를 운영에서 자주 만난다면, 네트워크 계층에서 어떤 증상이 나오는지 아래 글이 도움이 됩니다.

관측 가능성(Observability): 추적 가능한 correlation_id가 반이다

SAGA는 “정상일 때”보다 “깨졌을 때”가 중요합니다. 다음을 최소로 갖추세요.

  • 모든 로그에 saga_id, correlation_id, message_id, step 포함
  • 사가 인스턴스 조회 API: GET /admin/sagas/{saga_id}
  • 상태별 카운터: COMPLETED, COMPENSATING, FAILED, DLQ
  • 단계별 지연 시간: step별 처리 시간/대기 시간

분산 추적(예: OpenTelemetry)을 붙일 때도 correlation_id를 trace/span에 연결하면, “어디서 멈췄는지”를 빠르게 찾을 수 있습니다.

구현 예시: 주문 생성 SAGA(결제-재고) 미니 설계

여기서는 가장 단순한 2단계 사가를 예로 듭니다.

목표

  • 결제 승인 후 재고 예약
  • 재고 예약 실패 시 결제 취소(보상)

단계 정의

  1. AuthorizePaymentCommandPaymentAuthorizedEvent 또는 PaymentAuthorizationFailedEvent
  2. ReserveInventoryCommandInventoryReservedEvent 또는 InventoryReservationFailedEvent

실패 시 보상:

  • CancelPaymentCommand

오케스트레이터 전이 규칙(의사코드)

state: START
  on OrderCreatedEvent -> send AuthorizePaymentCommand

state: WAIT_PAYMENT
  on PaymentAuthorizedEvent -> send ReserveInventoryCommand
  on PaymentAuthorizationFailedEvent -> mark FAILED

state: WAIT_INVENTORY
  on InventoryReservedEvent -> mark COMPLETED
  on InventoryReservationFailedEvent -> send CancelPaymentCommand, mark COMPENSATING

state: COMPENSATING
  on PaymentCancelledEvent -> mark CANCELLED
  on PaymentCancelFailedEvent -> mark FAILED (manual intervention)

보상에 필요한 데이터는 언제 저장하나

결제 승인 이벤트에는 보통 payment_auth_id 같은 토큰이 옵니다. 이 값이 없으면 취소를 못 합니다.

따라서 PaymentAuthorizedEvent를 받는 순간:

  • saga_instances.context.payment_auth_id에 저장
  • 다음 커맨드(ReserveInventoryCommand)를 outbox에 적재

이 두 작업을 하나의 DB 트랜잭션으로 처리해야 합니다.

흔한 함정 6가지

  1. 보상 API가 “취소 불가”를 반환: 이미 캡처/정산 단계로 넘어간 결제는 취소 대신 환불로 전환해야 함
  2. 보상 순서가 잘못됨: 외부 시스템의 비즈니스 제약을 무시하고 역순 보상만 고집
  3. 중복 이벤트로 보상이 두 번 실행: message_id 멱등 처리 누락
  4. 사가 상태 업데이트와 커맨드 발행이 분리됨: outbox 없이 구현하다가 유실
  5. 사가 컨텍스트에 보상 데이터가 없음: 승인 토큰/예약 토큰을 저장하지 않아 보상 불가
  6. 무한 재시도로 큐가 적체: DLQ/수동 개입 경로가 없어 장애가 장기화

프레임워크 없이도 “안전한 SAGA”의 체크리스트

  • 사가 인스턴스 상태와 step 로그가 DB에 남는가
  • 커맨드/이벤트에 message_id, saga_id, correlation_id, causation_id가 있는가
  • 소비자는 message_id 기준 멱등 처리하는가
  • 상태 업데이트와 메시지 발행이 outbox로 묶였는가
  • 보상 트랜잭션도 멱등이며 재시도 가능하게 설계했는가
  • 타임아웃/재시도/DLQ/수동처리 런북이 있는가
  • 사가 단위로 관측(로그/메트릭/트레이싱)이 가능한가

마무리

Java/Spring이 없다고 SAGA가 어려워지는 것은 “코드 작성”보다 “안전장치의 부재” 때문입니다. 프레임워크가 제공하던 상태 관리, 중복 방지, 재시도, 트랜잭션 경계(outbox)를 직접 구현해야 하고, 그 경계가 조금만 흐트러져도 보상 누락/중복 같은 치명적인 장애로 이어집니다.

반대로 말하면, 이 글에서 정리한 것처럼 outbox, 멱등성 키, 사가 상태 테이블, DLQ, 관측 가능성을 언어 중립적으로 갖추면 Node/Python/Go/Rust 어떤 스택에서도 충분히 견고한 보상 트랜잭션을 만들 수 있습니다.