Published on

MSA 사가(Saga) 중복처리·보상트랜잭션 설계 실전

Authors

서론

MSA에서 사가(Saga)는 “분산 트랜잭션을 피하면서도 비즈니스 일관성을 확보”하기 위한 대표 패턴입니다. 하지만 운영 단계에서 실제로 장애를 만드는 지점은 사가 자체보다 중복처리(duplicate execution)보상트랜잭션(compensation) 입니다. 네트워크 타임아웃, 메시지 브로커 재전송, 컨슈머 리밸런싱, 오케스트레이터 재시작 등으로 같은 단계가 2번 이상 실행되는 것은 정상이며(“at-least-once delivery”), 이를 전제로 설계하지 않으면 결제 중복, 재고 마이너스, 주문 상태 꼬임이 발생합니다.

이 글은 다음을 목표로 합니다.

  • 사가에서 중복이 생기는 원인과 “어디에서” 막아야 하는지
  • 단계별 멱등성(idempotency) 설계: 커맨드/이벤트/DB 업데이트
  • 보상트랜잭션을 “되돌리기”가 아니라 “정상 상태로 수렴시키기”로 설계하는 법
  • 운영 관점(타임아웃, 재시도, DLQ, 관측성) 체크리스트

사가에서 중복처리가 발생하는 대표 시나리오

사가가 중복 실행되는 이유는 대부분 “성공했는데 성공했다고 못 들었다” 또는 “처리 중인데 다시 시도했다”입니다.

1) 요청-응답 타임아웃

오케스트레이터가 결제 서비스에 요청을 보냈고 결제는 성공했지만, 응답이 타임아웃으로 끊겼다면 오케스트레이터는 재시도합니다. 이때 결제 서비스가 멱등하지 않으면 중복 결제가 됩니다.

2) 메시지 브로커의 at-least-once 전달

Kafka/RabbitMQ/SQS 등에서 컨슈머가 처리 후 ack 전에 죽으면 메시지가 재전달됩니다. 컨슈머는 같은 이벤트를 다시 처리하게 됩니다.

3) 오케스트레이터/컨슈머 재시작 및 리밸런싱

프로세스가 재시작되면 “마지막으로 어디까지 했는지”를 정확히 모르면 이미 실행한 단계를 다시 실행합니다.

4) 동시성(중복 요청) 자체

사용자가 결제 버튼을 두 번 누르거나, API 게이트웨이/클라이언트 재시도 정책 때문에 동일 주문 요청이 동시에 들어옵니다.

결론적으로, 사가에서는 중복은 버그가 아니라 전제조건입니다. 따라서 설계의 핵심은 “중복을 막는다”가 아니라 중복이 와도 결과가 1번 실행한 것과 동일하도록 만드는 것입니다.

중복 방지의 3계층: API/커맨드, 이벤트, 상태 저장

중복처리는 한 군데에서만 해결되지 않습니다. 실전에서는 아래 3계층을 함께 둬야 사고 확률이 급격히 줄어듭니다.

  1. 요청(커맨드) 멱등성 키: 동일 요청을 한 번만 접수
  2. 이벤트 중복 제거: 동일 이벤트를 한 번만 소비
  3. 상태 전이(사가 단계) 멱등성: 이미 끝난 단계는 다시 실행하지 않음

1) 커맨드 멱등성: Idempotency-Key + 유니크 제약

가장 단순하고 강력한 방법은 클라이언트/오케스트레이터가 Idempotency-Key를 보내고, 서버는 해당 키로 결과를 캐시/저장하여 재요청에 같은 결과를 반환하는 것입니다.

  • 키 생성 기준: userId + businessKey(orderId) + action(createOrder)
  • 저장 위치: 주문 서비스 DB(권장) 또는 Redis(보조)
  • 강제 장치: DB의 UNIQUE 인덱스

예시: PostgreSQL로 멱등성 테이블 구성

CREATE TABLE idempotency_keys (
  idempotency_key TEXT PRIMARY KEY,
  request_hash TEXT NOT NULL,
  response_body JSONB,
  status TEXT NOT NULL, -- IN_PROGRESS, COMPLETED, FAILED
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- 동일 키에 다른 payload가 들어오면 공격/버그 가능성이 있으니 해시로 검증

예시: 서비스 코드(의사코드)

def handle_create_order(req):
    key = req.headers["Idempotency-Key"]
    payload_hash = sha256(req.body)

    row = db.get("SELECT * FROM idempotency_keys WHERE idempotency_key=%s", key)
    if row and row.request_hash != payload_hash:
        raise Http409("Idempotency-Key reuse with different payload")

    if row and row.status == "COMPLETED":
        return row.response_body

    # 없거나 IN_PROGRESS면 선점 시도
    inserted = db.execute("""
      INSERT INTO idempotency_keys(idempotency_key, request_hash, status)
      VALUES (%s, %s, 'IN_PROGRESS')
      ON CONFLICT DO NOTHING
    """, key, payload_hash)

    if not inserted:
        # 다른 워커가 처리 중: 폴링/즉시 202 반환 등 정책 선택
        return Http202({"message": "processing"})

    try:
        result = create_order_tx(req)
        db.execute("UPDATE idempotency_keys SET status='COMPLETED', response_body=%s WHERE idempotency_key=%s",
                   json(result), key)
        return result
    except Exception:
        db.execute("UPDATE idempotency_keys SET status='FAILED' WHERE idempotency_key=%s", key)
        raise

이 방식의 장점은 “중복 요청”을 가장 앞단에서 정리한다는 점입니다. 단, 사가 단계별 중복은 여전히 남습니다.

2) 이벤트 중복 제거: consumer-side dedup (Inbox)

이벤트는 브로커 특성상 중복 전달될 수 있으므로, 컨슈머는 이벤트 ID를 저장하고 한 번만 처리해야 합니다. 이를 흔히 Inbox 패턴이라 부릅니다.

예시: inbox 테이블

CREATE TABLE inbox_events (
  consumer_name TEXT NOT NULL,
  event_id TEXT NOT NULL,
  received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (consumer_name, event_id)
);

예시: 이벤트 처리 트랜잭션

BEGIN;

-- 1) 이벤트 선처리(중복이면 여기서 끝)
INSERT INTO inbox_events(consumer_name, event_id)
VALUES ('payment-consumer', :event_id)
ON CONFLICT DO NOTHING;

-- 영향을 받은 row가 0이면 이미 처리한 이벤트
-- 2) 비즈니스 로직 적용
-- 예: 주문 상태 업데이트, 사가 로그 기록 등

COMMIT;

핵심은 inbox insert와 비즈니스 업데이트를 같은 DB 트랜잭션으로 묶는 것입니다. 그래야 “중복 체크는 했는데 업데이트 전에 죽음” 같은 반쪽 처리가 줄어듭니다.

3) 사가 단계 멱등성: Saga log + 상태 전이 규칙

사가의 각 단계는 “한 번만 실행”이 아니라 “여러 번 실행될 수 있음”을 전제로 해야 합니다.

  • 오케스트레이터: saga_instance 테이블에 현재 단계, 각 단계의 실행 상태(STARTED/SUCCEEDED/COMPENSATED) 저장
  • 스텝 실행 전에 상태 확인: 이미 SUCCEEDED면 스킵
  • 상태 업데이트는 낙관적 락(version) 또는 조건부 업데이트로 경합 제어

예시: saga 인스턴스 테이블

CREATE TABLE saga_instance (
  saga_id TEXT PRIMARY KEY,
  saga_type TEXT NOT NULL,
  status TEXT NOT NULL, -- RUNNING, COMPLETED, COMPENSATING, FAILED
  current_step INT NOT NULL,
  version INT NOT NULL DEFAULT 0,
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE saga_step (
  saga_id TEXT NOT NULL,
  step_no INT NOT NULL,
  status TEXT NOT NULL, -- STARTED, SUCCEEDED, COMPENSATED, FAILED
  last_error TEXT,
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (saga_id, step_no)
);

예시: 조건부 상태 전이

UPDATE saga_step
SET status='SUCCEEDED', updated_at=now()
WHERE saga_id=:saga_id AND step_no=:step_no AND status IN ('STARTED');

-- rowcount=0이면 이미 성공/보상/실패 처리된 단계이므로 멱등하게 종료

Outbox + Inbox: “정확히 한 번처럼 보이게” 만드는 기본기

사가에서 이벤트 발행과 DB 변경이 분리되면 다음 문제가 생깁니다.

  • DB는 커밋됐는데 이벤트 발행 실패 → 다운스트림이 영영 모름
  • 이벤트는 발행됐는데 DB 롤백 → 거짓 이벤트

이를 막는 정석이 Outbox 패턴입니다.

Outbox 패턴 요약

  • 비즈니스 변경(예: 주문 생성)과 함께 outbox 테이블에 이벤트를 같은 트랜잭션으로 기록
  • 별도 퍼블리셔가 outbox를 읽어 브로커로 발행 후 published_at 표시

예시: outbox 테이블

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

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

예시: 주문 생성 + outbox 기록 (단일 트랜잭션)

BEGIN;

INSERT INTO orders(order_id, user_id, status)
VALUES (:order_id, :user_id, 'CREATED');

INSERT INTO outbox(aggregate_type, aggregate_id, event_type, event_id, payload)
VALUES ('Order', :order_id, 'OrderCreated', :event_id, :payload);

COMMIT;

Outbox/Inbox를 함께 쓰면 “전달은 중복될 수 있지만 처리 결과는 한 번”에 수렴합니다.

보상트랜잭션 설계: ‘되돌리기’가 아닌 ‘수렴’

보상은 종종 “Undo”로 오해됩니다. 하지만 현실에서는 외부 결제, 쿠폰, 포인트, 배송 등은 완벽한 되돌리기가 불가능합니다. 따라서 보상 설계의 목표는 다음입니다.

  • 비즈니스적으로 허용 가능한 최종 상태로 수렴
  • 중복 보상/역보상에도 안전(멱등)
  • 실패 시 재시도 가능(재진입)

보상은 가능한 한 ‘반대 작업’이 아니라 ‘정정 작업’으로

예를 들어 결제 승인 단계가 실패했을 때 보상은 “결제 취소”입니다. 하지만 취소 API도 실패/지연/중복이 발생합니다.

  • 결제 취소 요청에도 idempotency key(취소 키) 를 사용
  • 결제 서비스는 payment_id 기준으로 “이미 취소됨”을 반환 가능해야 함

예시: 결제 보상 API 멱등성

POST /payments/{paymentId}/cancel
Idempotency-Key: cancel-{sagaId}-{stepNo}

{ "reason": "ORDER_FAILED" }

보상 순서: 역순 실행 + 단계별 상태 기록

사가 단계가 A(재고예약) → B(결제승인) → C(배송요청)이라면, C 실패 시 보상은 B 취소 → A 해제 순으로 역순이 일반적입니다.

오케스트레이터는 보상도 “한 번만”이 아니라 “여러 번 시도”될 수 있으므로 saga_step.status=COMPENSATED 같은 기록이 필수입니다.

보상 실패를 ‘사가 실패’로 즉시 결론내지 말기

보상이 실패하면 시스템은 더 위험해집니다(예: 결제는 승인됐는데 주문은 실패). 이때 필요한 것이 보상 재시도 + 운영 개입 가능성입니다.

  • 보상 재시도: 지수 백오프, 최대 횟수
  • 일정 횟수 초과: DLQ/티켓/알람으로 전환
  • 수동 처리 도구(Backoffice)에서 현재 사가 상태와 외부 상태를 함께 보여줘야 함

오케스트레이션 vs 코레오그래피: 중복/보상 관점에서의 선택

  • 오케스트레이션(중앙 오케스트레이터): 사가 상태를 한 곳에서 관리하기 쉬워 중복/보상 통제가 명확함. 대신 오케스트레이터가 SPOF가 되지 않도록 저장소 기반 상태관리 및 수평확장이 필요.
  • 코레오그래피(이벤트 기반 자율 반응): 결합도가 낮지만, “누가 전체 흐름을 알고 보상을 시작/종료하는지”가 모호해져 운영 난이도가 올라감. 중복 제거는 각 서비스가 더 철저히 해야 함.

실무에서 “중복과 보상”이 가장 큰 비용이라면, 초기에는 오케스트레이션이 안정적입니다.

타임아웃·재시도·서킷브레이커: 중복을 ‘통제된 중복’으로

중복은 재시도 정책에서 증폭됩니다. 특히 gRPC/HTTP 타임아웃이 짧고 재시도가 공격적이면 동일 단계가 폭발적으로 중복 실행됩니다.

  • 타임아웃: p95/p99 기반으로 설정(너무 짧으면 중복 증가)
  • 재시도: 멱등 요청만 자동 재시도 허용(POST라도 Idempotency-Key가 있으면 가능)
  • 백오프: 지수 백오프 + 지터(jitter)
  • 서킷브레이커: 하류 장애 시 재시도 폭주 차단

운영에서 gRPC 타임아웃/재시도로 문제가 커지는 사례는 흔합니다. 관련 진단/해결 흐름은 EKS에서 gRPC DEADLINE_EXCEEDED 폭증 해결 글의 관점이 사가 단계 호출 튜닝에도 그대로 적용됩니다.

데이터베이스 관점: 커넥션 폭주와 잠금 경합이 사가를 망친다

사가가 길어지고 재시도가 늘면, 오케스트레이터/컨슈머/퍼블리셔가 DB 커넥션을 급격히 잡아먹습니다. 특히 outbox/inbox/saga_log 테이블이 추가되면서 트래픽이 더 늘어납니다.

  • outbox 퍼블리셔 폴링 주기/배치 크기 조절
  • inbox/outbox 인덱스 최적화
  • 커넥션 풀 상한 설정
  • 필요 시 RDS Proxy/pgBouncer로 폭주 완충

DB 커넥션이 고갈되면 사가 상태 기록 자체가 실패하면서 “중복 재시도 → 더 많은 커넥션 → 더 큰 장애”로 악순환이 생깁니다. 이 주제는 Aurora PostgreSQL remaining connection slots are reserved로 서비스가 멈출 때 RDS Proxy와 pgBouncer와 max_connections 튜닝으로 커넥션 폭주를 영구 차단하는 실전 체크리스트에서 제시한 체크리스트를 그대로 참고할 수 있습니다.

실전 설계 체크리스트

아래 항목을 “모두” 만족시키면 사가 운영 사고의 대부분이 사라집니다.

중복처리(멱등성)

  • 각 외부 호출/커맨드에 Idempotency-Key가 있다
  • 결제/환불/쿠폰/포인트 같은 금전성 작업은 반드시 멱등하게 설계했다
  • 이벤트 컨슈머는 inbox로 중복 제거한다
  • 사가 단계는 saga_step 상태로 재진입 가능하다

Outbox/이벤트 발행

  • 비즈니스 DB 변경과 이벤트 기록이 동일 트랜잭션이다(outbox)
  • outbox 퍼블리셔는 중복 발행되어도 event_id로 컨슈머에서 안전하다

보상트랜잭션

  • 보상도 멱등하며, 실패 시 재시도/운영 개입 경로가 있다
  • “완전한 undo”가 불가능한 리소스는 정정/수렴 모델로 설계했다

운영/관측성

  • saga_id가 모든 로그/트레이스/이벤트에 포함된다
  • 각 단계의 처리 시간, 재시도 횟수, 보상 횟수를 메트릭으로 본다
  • 타임아웃/재시도 정책이 서비스별로 합리적이다

결론

MSA에서 사가를 도입할 때의 핵심 난제는 “분산 트랜잭션을 어떻게 구현하나”가 아니라, 중복이 일상적으로 발생하는 환경에서 어떻게 안전하게 수렴시키나입니다.

  • 커맨드 멱등성(Idempotency-Key + UNIQUE)
  • 이벤트 중복 제거(Inbox)
  • 원자적 이벤트 발행(Outbox)
  • 단계별 상태 기록(Saga log)
  • 보상은 되돌리기보다 수렴, 그리고 보상도 멱등

이 다섯 가지를 기본기로 깔면, 사가는 ‘이론’에서 ‘운영 가능한 시스템’으로 바뀝니다.