- Published on
MSA 사가(Saga) 패턴 구현으로 중복결제 방지하기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
모놀리식에서는 데이터베이스 트랜잭션 하나로 결제 흐름을 묶을 수 있지만, MSA에서는 주문, 결제, 재고, 쿠폰, 포인트 같은 서비스가 네트워크로 분리되면서 부분 성공과 중복 실행이 기본값이 됩니다. 특히 결제는 사용자가 버튼을 여러 번 누르거나, 클라이언트가 타임아웃 후 재시도하거나, 메시지 브로커가 at-least-once로 이벤트를 재전달하는 순간 중복결제 위험이 급격히 커집니다.
이 글에서는 사가(Saga) 패턴을 중복결제 방지 관점에서 구현하는 방법을 다룹니다. 핵심은 사가만으로는 부족하고, 멱등성(idempotency)과 중복 제거(deduplication), 상태 머신, 보상 트랜잭션을 함께 설계해야 한다는 점입니다.
관련해서 사가의 보상 및 중복처리 자체를 더 깊게 보고 싶다면 아래 글도 함께 참고하면 좋습니다.
- Saga 패턴 보상트랜잭션 설계와 중복처리 실전
- (네트워크 타임아웃/데드라인이 재시도를 유발하는 현실적인 원인) gRPC MSA 데드라인 전파 누락 진단·해결
중복결제가 발생하는 전형적인 시나리오
중복결제는 대개 아래 조합에서 터집니다.
- 클라이언트 재시도: 결제 승인 API가 3초 안에 응답을 못 하면 앱이 재시도한다.
- 게이트웨이/프록시 재시도: API Gateway, Service Mesh가 502/504에 대해 재시도한다.
- 메시지 재전달: 결제 요청 이벤트가 브로커에서 중복 전달된다(
at-least-once). - 사가 재시작: 오케스트레이터가 장애로 재기동되며 같은 스텝을 다시 수행한다.
- 외부 PG 연동의 불확실성: PG 승인 요청이 타임아웃되었는데 실제로는 승인됐을 수 있다(가장 위험).
중요한 포인트는 중복 호출 자체는 분산 시스템에서 자연스럽고, 이를 0으로 만들기보다 중복 호출되어도 결과가 1번만 반영되게 만들어야 한다는 점입니다.
목표 정의: “중복결제 방지”의 기술적 요구사항
중복결제를 막는다는 말은 구체적으로 아래를 만족해야 합니다.
- 동일 주문에 대해 승인(Authorize/Capture)이 1회만 성공해야 한다.
- 승인 요청이 중복으로 들어와도 항상 같은 결제 결과를 반환해야 한다(멱등 응답).
- 결제는 성공했는데 주문이 실패한 경우 등
부분 성공에 대해 **보상 또는 정정(Refund/Void)**이 가능해야 한다. - 장애/재시도/재전달이 있어도 사가가 **정확히 한 번처럼 보이는 효과(effectively-once)**를 내야 한다.
이를 위해 보통 아래 4가지를 조합합니다.
- 멱등성 키(Idempotency Key)
- 사가 인스턴스 키(Workflow Key) + 상태 머신
- Outbox/Inbox 패턴(이벤트 중복 제거)
- 보상 트랜잭션(Refund/Void) 설계
사가 선택: 오케스트레이션 vs 코레오그래피
중복결제 방지 관점에서는 오케스트레이션이 유리한 경우가 많습니다.
- 오케스트레이터가
사가 상태를 중앙에서 관리하므로, 중복 스텝 실행을 상태 기반으로 차단하기 쉽습니다. - 결제처럼
순서와조건이 중요한 흐름(주문 생성 후 결제 승인, 승인 후 재고 확정 등)에 강합니다.
코레오그래피(이벤트 기반 자율 협업)도 가능하지만, 중복 이벤트와 순서 뒤집힘을 각 서비스가 모두 방어해야 하므로 초기 구현 난이도가 올라갑니다.
이 글에서는 오케스트레이션 기반 예시로 설명합니다.
설계의 핵심: 결제는 “요청”이 아니라 “명령 + 키”로 다뤄라
결제 요청을 단순히 POST /pay로 보내면 중복을 막기 어렵습니다. 대신 다음 2개의 키를 명확히 분리합니다.
orderId: 비즈니스 대상(무엇을 결제하나)idempotencyKey: 동일한 사용자 의도의 재시도 식별자(같은 의도인가)
권장 규칙:
idempotencyKey는 클라이언트가 생성(예: UUID)하고, 동일 결제 시도에 대해 재시도 시 같은 키를 재사용- 서버는
idempotencyKey단위로 결과를 저장하고, 이후 동일 키 요청에는 저장된 결과를 그대로 반환
추가로, 사가 오케스트레이터에서는 sagaId(워크플로 인스턴스 ID)를 따로 두고, orderId와 idempotencyKey를 매핑해 추적합니다.
데이터 모델: 상태 머신이 중복 실행을 막는다
결제 사가의 최소 상태 예시는 아래처럼 잡을 수 있습니다.
STARTEDORDER_CREATEDPAYMENT_AUTHORIZEDINVENTORY_RESERVEDCOMPLETEDFAILEDCOMPENSATINGCOMPENSATED
중복결제 방지의 포인트는 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_PROGRESS면409또는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 단위의 동시성 제어가 필요합니다.
실무에서 많이 쓰는 선택지:
- DB 유니크 제약:
orderId에 대한 결제 시도 레코드를 유니크로 잡아 2번째 시도를 실패 처리 - 분산 락: Redis 기반 락으로
orderId를 잠그고 결제 진행 - 상태 기반 거절: 주문 서비스에서
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 환경에서도 중복결제를 “구조적으로” 줄일 수 있습니다.