Published on

MSA 사가(Saga) 패턴 구현으로 중복결제 방지하기

Authors

모놀리식에서는 데이터베이스 트랜잭션 하나로 결제 흐름을 묶을 수 있지만, MSA에서는 주문, 결제, 재고, 쿠폰, 포인트 같은 서비스가 네트워크로 분리되면서 부분 성공중복 실행이 기본값이 됩니다. 특히 결제는 사용자가 버튼을 여러 번 누르거나, 클라이언트가 타임아웃 후 재시도하거나, 메시지 브로커가 at-least-once로 이벤트를 재전달하는 순간 중복결제 위험이 급격히 커집니다.

이 글에서는 사가(Saga) 패턴을 중복결제 방지 관점에서 구현하는 방법을 다룹니다. 핵심은 사가만으로는 부족하고, 멱등성(idempotency)중복 제거(deduplication), 상태 머신, 보상 트랜잭션을 함께 설계해야 한다는 점입니다.

관련해서 사가의 보상 및 중복처리 자체를 더 깊게 보고 싶다면 아래 글도 함께 참고하면 좋습니다.

중복결제가 발생하는 전형적인 시나리오

중복결제는 대개 아래 조합에서 터집니다.

  1. 클라이언트 재시도: 결제 승인 API가 3초 안에 응답을 못 하면 앱이 재시도한다.
  2. 게이트웨이/프록시 재시도: API Gateway, Service Mesh가 502/504에 대해 재시도한다.
  3. 메시지 재전달: 결제 요청 이벤트가 브로커에서 중복 전달된다(at-least-once).
  4. 사가 재시작: 오케스트레이터가 장애로 재기동되며 같은 스텝을 다시 수행한다.
  5. 외부 PG 연동의 불확실성: PG 승인 요청이 타임아웃되었는데 실제로는 승인됐을 수 있다(가장 위험).

중요한 포인트는 중복 호출 자체는 분산 시스템에서 자연스럽고, 이를 0으로 만들기보다 중복 호출되어도 결과가 1번만 반영되게 만들어야 한다는 점입니다.

목표 정의: “중복결제 방지”의 기술적 요구사항

중복결제를 막는다는 말은 구체적으로 아래를 만족해야 합니다.

  • 동일 주문에 대해 승인(Authorize/Capture)이 1회만 성공해야 한다.
  • 승인 요청이 중복으로 들어와도 항상 같은 결제 결과를 반환해야 한다(멱등 응답).
  • 결제는 성공했는데 주문이 실패한 경우 등 부분 성공에 대해 **보상 또는 정정(Refund/Void)**이 가능해야 한다.
  • 장애/재시도/재전달이 있어도 사가가 **정확히 한 번처럼 보이는 효과(effectively-once)**를 내야 한다.

이를 위해 보통 아래 4가지를 조합합니다.

  1. 멱등성 키(Idempotency Key)
  2. 사가 인스턴스 키(Workflow Key) + 상태 머신
  3. Outbox/Inbox 패턴(이벤트 중복 제거)
  4. 보상 트랜잭션(Refund/Void) 설계

사가 선택: 오케스트레이션 vs 코레오그래피

중복결제 방지 관점에서는 오케스트레이션이 유리한 경우가 많습니다.

  • 오케스트레이터가 사가 상태를 중앙에서 관리하므로, 중복 스텝 실행을 상태 기반으로 차단하기 쉽습니다.
  • 결제처럼 순서조건이 중요한 흐름(주문 생성 후 결제 승인, 승인 후 재고 확정 등)에 강합니다.

코레오그래피(이벤트 기반 자율 협업)도 가능하지만, 중복 이벤트와 순서 뒤집힘을 각 서비스가 모두 방어해야 하므로 초기 구현 난이도가 올라갑니다.

이 글에서는 오케스트레이션 기반 예시로 설명합니다.

설계의 핵심: 결제는 “요청”이 아니라 “명령 + 키”로 다뤄라

결제 요청을 단순히 POST /pay로 보내면 중복을 막기 어렵습니다. 대신 다음 2개의 키를 명확히 분리합니다.

  • orderId: 비즈니스 대상(무엇을 결제하나)
  • idempotencyKey: 동일한 사용자 의도의 재시도 식별자(같은 의도인가)

권장 규칙:

  • idempotencyKey는 클라이언트가 생성(예: UUID)하고, 동일 결제 시도에 대해 재시도 시 같은 키를 재사용
  • 서버는 idempotencyKey 단위로 결과를 저장하고, 이후 동일 키 요청에는 저장된 결과를 그대로 반환

추가로, 사가 오케스트레이터에서는 sagaId(워크플로 인스턴스 ID)를 따로 두고, orderIdidempotencyKey를 매핑해 추적합니다.

데이터 모델: 상태 머신이 중복 실행을 막는다

결제 사가의 최소 상태 예시는 아래처럼 잡을 수 있습니다.

  • STARTED
  • ORDER_CREATED
  • PAYMENT_AUTHORIZED
  • INVENTORY_RESERVED
  • COMPLETED
  • FAILED
  • COMPENSATING
  • COMPENSATED

중복결제 방지의 포인트는 PAYMENT_AUTHORIZED가 이미 찍힌 사가에 대해 결제 승인 스텝을 다시 실행하지 않는 것입니다.

아래는 PostgreSQL 기준의 간단한 테이블 예시입니다.

-- 사가 인스턴스
create table payment_saga (
  saga_id uuid primary key,
  order_id uuid not null,
  idempotency_key text not null,
  state text not null,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  unique (order_id),
  unique (idempotency_key)
);

-- 결제 멱등성 결과 저장소
create table payment_idempotency (
  idempotency_key text primary key,
  order_id uuid not null,
  status text not null, -- IN_PROGRESS | SUCCEEDED | FAILED
  pg_payment_id text null,
  response_json jsonb null,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

-- 이벤트 인박스(중복 이벤트 처리)
create table inbox_events (
  event_id text primary key,
  consumer text not null,
  received_at timestamptz not null default now()
);

unique (order_id)를 둘지 여부는 정책에 따라 다릅니다.

  • orderId당 결제는 1회만 가능: unique (order_id)가 강력한 안전장치
  • 분할 결제/추가 결제 가능: orderId 대신 paymentAttemptId 같은 별도 키가 필요

흐름 예시: 주문 생성부터 결제 승인까지

1) 클라이언트 요청

클라이언트는 결제 버튼 클릭 시 idempotencyKey를 생성하고, 네트워크 오류로 재시도해도 같은 키를 사용합니다.

curl -X POST https://api.example.com/payments/checkout \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: 2f6c2c7e-0df6-4f4b-9f9a-8e5a0c0e3a10' \
  -d '{
    "orderId": "b2a6f2a1-9d2c-4f2e-9c7b-7c1f6a5f9d11",
    "amount": 19900,
    "currency": "KRW",
    "paymentMethodToken": "tok_xxx"
  }'

Idempotency-Key 헤더가 없다면 서버가 생성해도 되지만, 그 경우 클라이언트 재시도에서 동일 키를 보장하기 어렵습니다.

2) 오케스트레이터의 멱등 처리(중복 요청 차단)

오케스트레이터는 요청을 받자마자 payment_idempotency에 먼저 기록해 IN_PROGRESS를 선점합니다.

  • 이미 SUCCEEDED면 저장된 response_json을 그대로 반환
  • 이미 IN_PROGRESS409 또는 202로 처리(정책 선택)

아래는 TypeScript(예: NestJS/Express) 스타일의 의사 코드입니다.

type IdemStatus = "IN_PROGRESS" | "SUCCEEDED" | "FAILED";

type IdemRow = {
  idempotencyKey: string;
  orderId: string;
  status: IdemStatus;
  pgPaymentId?: string | null;
  responseJson?: unknown | null;
};

async function checkout(req: {
  orderId: string;
  amount: number;
  currency: string;
  paymentMethodToken: string;
  idempotencyKey: string;
}) {
  // 1) 멱등성 선점
  const inserted = await db.tryInsertIdempotency({
    idempotencyKey: req.idempotencyKey,
    orderId: req.orderId,
    status: "IN_PROGRESS",
  });

  if (!inserted.ok) {
    const row: IdemRow = await db.getIdempotency(req.idempotencyKey);

    if (row.status === "SUCCEEDED") {
      return row.responseJson; // 동일 응답 반환
    }

    if (row.status === "IN_PROGRESS") {
      // 이미 처리 중: 클라이언트는 폴링하거나 동일 키로 재시도
      throw new HttpError(202, "Payment is processing");
    }

    // FAILED면 재시도 허용 정책에 따라 처리
    throw new HttpError(409, "Previous attempt failed");
  }

  // 2) 사가 생성(또는 재개)
  const saga = await db.createSaga({
    orderId: req.orderId,
    idempotencyKey: req.idempotencyKey,
    state: "STARTED",
  });

  // 3) 결제 승인 스텝 실행
  const pg = await paymentService.authorize({
    orderId: req.orderId,
    amount: req.amount,
    currency: req.currency,
    paymentMethodToken: req.paymentMethodToken,
    idempotencyKey: req.idempotencyKey,
  });

  await db.updateSagaState(saga.sagaId, "PAYMENT_AUTHORIZED");

  // 4) 성공 결과 저장(멱등 응답)
  const response = { orderId: req.orderId, pgPaymentId: pg.pgPaymentId, status: "AUTHORIZED" };
  await db.markIdempotencySucceeded(req.idempotencyKey, pg.pgPaymentId, response);

  return response;
}

여기서 중요한 점은 멱등성 저장사가 상태가 분리되어 있다는 것입니다.

  • 멱등성 저장: 동일 요청에 대해 동일 응답을 보장
  • 사가 상태: 워크플로의 단계별 중복 실행을 방지

결제 서비스(또는 PG 연동)의 멱등성: 내부 키와 외부 키를 모두 잡아라

오케스트레이터에서 멱등 처리를 해도, 결제 서비스가 장애로 인해 같은 승인 요청을 2번 받을 수 있습니다. 따라서 결제 서비스도 자체 멱등성을 가져야 합니다.

권장 키 구성:

  • 내부 paymentAttemptId(서버 생성) 또는 idempotencyKey를 결제 레코드의 유니크 키로 사용
  • PG가 idempotency key 기능을 제공하면 반드시 활용(예: merchantUid, orderNo 같은 상점 주문번호)

PG가 멱등을 제공하지 않는다면, 최소한 결제 서비스는 다음을 해야 합니다.

  • 같은 idempotencyKey로 승인 요청이 오면 DB에서 기존 pgPaymentId를 찾아 반환
  • 승인 요청이 타임아웃되면 승인 조회 API로 최종 상태를 확인한 뒤 사가에 반영

결제 서비스 테이블 예시:

create table payments (
  payment_id uuid primary key,
  order_id uuid not null,
  idempotency_key text not null,
  pg_payment_id text null,
  status text not null, -- AUTHORIZING | AUTHORIZED | CAPTURED | VOIDED | REFUNDED | FAILED
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  unique (idempotency_key)
);

Outbox/Inbox로 이벤트 중복을 “정상”으로 만들기

사가가 이벤트 기반으로 다음 스텝을 진행한다면, 중복 이벤트는 피할 수 없습니다. 이때는 Inbox로 소비 측 중복을 제거합니다.

예: 결제 서비스가 PaymentAuthorized 이벤트를 발행하고, 오케스트레이터가 이를 소비해 다음 단계로 진행

  • 동일 eventId를 가진 이벤트가 다시 오면 무시
  • 이벤트 처리와 inbox_events 기록을 같은 DB 트랜잭션으로 묶기

의사 코드:

async function onPaymentAuthorized(event: { eventId: string; orderId: string; pgPaymentId: string }) {
  await db.transaction(async (tx) => {
    const first = await tx.tryInsertInbox({
      eventId: event.eventId,
      consumer: "saga-orchestrator",
    });

    if (!first.ok) return; // 중복 이벤트

    const saga = await tx.getSagaByOrderId(event.orderId);
    if (saga.state === "PAYMENT_AUTHORIZED" || saga.state === "COMPLETED") return;

    await tx.updateSagaState(saga.sagaId, "PAYMENT_AUTHORIZED");

    // 다음 스텝: 재고 예약 등
    await tx.enqueueOutbox({
      type: "ReserveInventory",
      payload: { orderId: event.orderId },
    });
  });
}

이 구조를 쓰면 브로커가 같은 이벤트를 2번 보내도, 소비자는 1번만 반영합니다.

“타임아웃 후 재시도”가 가장 위험하다: 승인 결과 조회로 수렴시켜라

중복결제 사고의 최빈 케이스는 아래입니다.

  • 오케스트레이터가 PG 승인 요청을 보냄
  • 네트워크 타임아웃 발생
  • 오케스트레이터가 재시도
  • 실제로는 첫 번째 요청이 승인되었고, 두 번째도 승인되어 2번 결제

해법은 타임아웃을 실패로 취급하지 말고 불확실(UNKNOWN) 상태로 모델링하는 것입니다.

  • AUTHORIZING 상태로 저장
  • 타임아웃 시 즉시 재시도하지 말고 결제 조회(GetPaymentStatus)를 먼저 수행
  • 조회에서도 불확실하면 지수 백오프 폴링, 최종적으로 운영자 확인 큐로 보내기

이때 재시도 폭주를 막는 패턴(큐잉, 백오프)은 외부 API에도 동일하게 적용됩니다. 재시도 설계 감각은 아래 글의 패턴도 참고할 만합니다.

보상 트랜잭션: 중복결제 방지의 마지막 안전망

아무리 멱등을 잘해도, 현실에서는 운영 이슈나 데이터 정합성 문제로 결제 성공 + 주문 실패 같은 케이스가 남습니다. 이때 사가의 보상 트랜잭션이 필요합니다.

대표 보상 시나리오:

  • 결제 승인 성공(AUTHORIZED) 후 재고 예약 실패
    • 보상: 승인 취소(VOID) 또는 환불(REFUND)

보상 설계 체크리스트:

  • 보상도 멱등해야 함: 같은 paymentId에 대해 VOID가 2번 호출되어도 1번만 반영
  • 보상의 가능 시간창: 카드사/PG 정책상 승인 취소 가능 시간이 제한될 수 있음
  • 부분 환불 가능성: 이미 캡처(CAPTURED)되었으면 VOID가 아니라 REFUND로 전환

보상 의사 코드:

async function compensatePayment(orderId: string, reason: string) {
  const payment = await db.getPaymentByOrderId(orderId);

  if (!payment) return;
  if (payment.status === "VOIDED" || payment.status === "REFUNDED") return; // 멱등

  if (payment.status === "AUTHORIZED") {
    await paymentGateway.void({ pgPaymentId: payment.pgPaymentId!, reason });
    await db.updatePaymentStatus(payment.paymentId, "VOIDED");
    return;
  }

  if (payment.status === "CAPTURED") {
    await paymentGateway.refund({ pgPaymentId: payment.pgPaymentId!, amount: payment.amount, reason });
    await db.updatePaymentStatus(payment.paymentId, "REFUNDED");
  }
}

동시성 제어: 같은 주문에 대한 “경쟁 결제”를 막는 법

사용자가 결제 버튼을 두 번 누르거나, 웹과 앱에서 동시에 결제 시도하는 경우 idempotencyKey가 서로 다를 수 있습니다. 이때는 orderId 단위의 동시성 제어가 필요합니다.

실무에서 많이 쓰는 선택지:

  1. DB 유니크 제약: orderId에 대한 결제 시도 레코드를 유니크로 잡아 2번째 시도를 실패 처리
  2. 분산 락: Redis 기반 락으로 orderId를 잠그고 결제 진행
  3. 상태 기반 거절: 주문 서비스에서 PAYMENT_PENDING 상태면 새 결제 시도를 거절

가장 단단한 건 1번(유니크 제약) + 3번(상태 머신) 조합입니다. 락은 운영 복잡도를 올리므로 꼭 필요한 경우에만 권장합니다.

예: 결제 시도 테이블을 만들고 order_id 유니크로 경쟁 결제를 차단

create table payment_attempts (
  attempt_id uuid primary key,
  order_id uuid not null,
  idempotency_key text not null,
  status text not null,
  created_at timestamptz not null default now(),
  unique (order_id)
);

운영 관점 체크리스트: 로그, 추적, 알람

중복결제는 “코드로만” 막는 문제가 아니라 관측 가능성까지 포함한 운영 문제입니다.

  • 모든 결제 요청에 idempotencyKey, orderId, sagaId를 로그에 포함
  • 분산 추적에서 결제 승인 호출 span에 동일 키 태깅
  • IN_PROGRESS가 일정 시간 이상 지속되는 레코드 알람(타임아웃/유실 탐지)
  • 보상 실패(VOID/REFUND 실패) DLQ 및 운영자 재처리 플로우

특히 gRPC를 쓰는 환경이라면 데드라인 전파 누락이 상위 서비스의 재시도를 유발해 중복 호출을 증가시킬 수 있습니다. 네트워크/타임아웃 튜닝은 “중복결제 방지”의 간접 요인입니다.

정리: 사가만으로는 부족하고, 멱등성이 답이다

사가 패턴은 분산 트랜잭션을 단계적으로 관리하고 보상할 수 있게 해주지만, 중복결제 방지의 본질은 중복 실행을 허용하되 결과를 1번만 반영하는 멱등성에 있습니다.

실전에서의 권장 조합은 다음과 같습니다.

  • 오케스트레이터: idempotencyKey 선점 저장 + 사가 상태 머신으로 스텝 중복 실행 차단
  • 결제 서비스: idempotencyKey 유니크 + 타임아웃을 UNKNOWN으로 처리하고 조회로 수렴
  • 이벤트 기반: Inbox/Outbox로 중복 이벤트를 정상 처리
  • 보상 트랜잭션: VOID/REFUND를 멱등하게 구현하고 실패 시 운영 재처리 경로 확보

이 4가지를 갖추면, 재시도와 장애가 일상인 MSA 환경에서도 중복결제를 “구조적으로” 줄일 수 있습니다.