인벤토리 아이템 예약·결제·확정 Saga 설계: 부분 실패를 복구하는 멀티플레이 거래 백엔드
멀티플레이 게임의 아이템 거래에서 예약·결제·확정이 일부 실패할 때 데이터 정합성을 지키는 Saga 패턴을 설명합니다. PostgreSQL, Outbox, 멱등성, 보상 트랜잭션으로 중복 지급과 아이템 유실을 복구하는 서버 설계를 다룹니다.

핵심 요약
인벤토리 아이템 거래는 인벤토리 서버, 결제 서버, 주문 서버가 서로 다른 로컬 트랜잭션을 사용하므로 하나의 ACID 트랜잭션으로 묶기 어렵습니다. 거래를 RESERVED → PAYMENT_AUTHORIZED → CONFIRMED → CAPTURED → COMPLETED 상태로 관리하고 각 단계에 멱등성 키와 보상 트랜잭션을 적용하면 부분 실패를 복구하도록 설계할 수 있습니다.
핵심은 아이템을 먼저 예약해 중복 판매를 막고 결제는 승인과 캡처로 나누는 것입니다. 확정이나 지급이 실패하면 예약 해제, 결제 취소 또는 환불을 실행하며 Outbox 이벤트와 주기적인 보정 작업으로 누락된 후속 처리를 다시 수행합니다.
왜 인벤토리 예약·결제·확정은 하나의 트랜잭션으로 처리할 수 없는가?
단일 데이터베이스 안에서 주문과 인벤토리를 함께 변경한다면 일반적인 DB 트랜잭션으로 처리할 수 있습니다. 하지만 실제 멀티플레이 게임에서는 다음과 같이 책임이 분리되는 경우가 많습니다.
| 영역 | 책임 | 실패 예시 |
|---|---|---|
| 인벤토리 서버 | 보유 수량, 예약 수량, 소유권 변경 | 재고 부족, DB 타임아웃 |
| 주문 서버 | 거래 상태와 주문 번호 관리 | 메시지 유실, 중복 요청 |
| 결제 서버 | 결제 승인·캡처·취소 | 승인 거절, 외부 결제 지연 |
| 지급 서버 | 구매 아이템을 플레이어에게 지급 | 소비자 장애, 중복 지급 |
2PC는 참여 시스템이 해당 프로토콜을 지원해야 하며, 여러 서비스 경계와 외부 결제 제공자를 모두 묶기 어려울 수 있습니다. 준비 단계 동안 리소스를 점유하는 비용도 고려해야 합니다. 이 예시는 그런 환경에서 로컬 트랜잭션과 보상 흐름을 조합하는 Saga 설계를 택합니다.
Saga는 전체 거래를 여러 개의 로컬 트랜잭션으로 나누고 각 단계가 실패했을 때 이전 단계의 효과를 되돌리는 보상 트랜잭션을 실행하는 패턴입니다. 네트워크 재시도는 피할 수 없으므로 목표는 정확히 한 번 실행하는 것이 아니라 같은 명령을 여러 번 받아도 결과가 한 번만 반영되는 멱등성입니다.
Saga 상태 머신은 어떻게 설계할까?
먼저 거래 상태와 각 상태에서 허용되는 전이를 명확하게 정의해야 합니다. 상태를 문자열 몇 개로만 관리하면 재시도와 보상 작업이 서로 충돌하기 쉽습니다.
| 상태 | 의미 | 다음 상태 |
|---|---|---|
PENDING | 거래 생성 후 처리 대기 | RESERVED, CANCELLED |
RESERVED | 아이템 수량을 확보함 | PAYMENT_AUTHORIZED, CANCELLED |
PAYMENT_AUTHORIZED | 결제 수단의 금액을 승인함 | CONFIRMED, CANCELLED |
CONFIRMED | 예약을 커밋하고 수신자 지급 작업을 기록함 | CAPTURED, COMPENSATING |
CAPTURED | 결제 금액을 최종 캡처함 | COMPLETED, COMPENSATING |
COMPLETED | 지급과 회계 기록까지 완료함 | 종료 |
COMPENSATING | 실패한 선행 작업을 보상 중임 | CANCELLED, REFUND_REQUIRED |
CANCELLED | 예약 해제와 취소가 완료됨 | 종료 |
REFUND_REQUIRED | 자동 환불 실패로 운영자 또는 재처리가 필요함 | CANCELLED |
예시는 Kotlin 2.0과 JDK 21을 기준으로 작성했습니다.
enum class TradeState {
PENDING,
RESERVED,
PAYMENT_AUTHORIZED,
CONFIRMED,
CAPTURED,
COMPLETED,
COMPENSATING,
CANCELLED,
REFUND_REQUIRED
}
상태 전이는 조건부 UPDATE로 원자적으로 수행합니다. 애플리케이션에서 현재 상태를 읽고 나중에 저장하는 방식은 두 개의 워커가 동시에 같은 거래를 처리할 때 잘못된 전이를 허용할 수 있습니다.
UPDATE trade
SET state = 'PAYMENT_AUTHORIZED',
version = version + 1,
updated_at = now()
WHERE trade_id = :trade_id
AND state = 'RESERVED'
AND version = :version;
영향받은 행이 1개가 아니면 다른 워커가 먼저 처리했거나 상태가 바뀐 것입니다. 이 경우 오류로 처리하기보다 현재 상태를 다시 읽고 이미 완료된 단계인지 확인해야 합니다.
아이템 예약은 어떻게 중복 판매를 막는가?
인벤토리에는 보유 수량과 예약 수량을 분리해 저장합니다. 판매 가능한 수량은 quantity - reserved_quantity로 계산합니다. 예약 수량의 조건부 갱신과 item_reservation 이력 삽입은 같은 DB 트랜잭션에서 처리해야 합니다. 이미 같은 trade_id의 예약이 있으면 수량을 다시 늘리지 말고 기존 결과를 반환하거나 검증합니다.
UPDATE inventory_balance
SET reserved_quantity = reserved_quantity + :amount,
updated_at = now()
WHERE user_id = :user_id
AND item_id = :item_id
AND quantity - reserved_quantity >= :amount;
이 조건부 갱신만으로는 같은 요청의 재시도에 대한 멱등성이 보장되지 않습니다. 트랜잭션 안에서 예약 이력의 유일 키를 먼저 확인·기록하고 수량을 갱신해야 합니다. 쿼리 영향 행이 0개면 재고 부족 또는 대상 행 부재를 구분해 처리합니다.
CREATE TABLE item_reservation (
reservation_id UUID PRIMARY KEY,
trade_id UUID NOT NULL UNIQUE,
user_id BIGINT NOT NULL,
item_id BIGINT NOT NULL,
amount INTEGER NOT NULL CHECK (amount > 0),
state VARCHAR(32) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
예약 만료 시각은 서버가 정합니다. 클라이언트가 보낸 만료 시간을 그대로 믿으면 예약을 영구적으로 점유하거나 시간 조작으로 경쟁 조건을 만들 수 있습니다. 만료 워커는 RESERVED 상태이면서 expires_at < now()인 예약만 해제합니다.

결제 승인과 결제 캡처를 왜 분리하는가?
결제를 한 번에 캡처하면 결제 성공 후 인벤토리 확정이 실패했을 때 환불이 필요합니다. 결제 제공자가 승인과 최종 캡처를 분리 지원한다면 다음 순서를 고려할 수 있습니다.
- 인벤토리 아이템을 예약합니다.
- 주문 금액을 서버에서 계산하고 결제 승인을 요청합니다.
- 예약을 커밋하고 수신자 지급 의도를 내구성 있게 기록합니다. 지급 워커는 거래가
CAPTURED에 도달하기 전에는 아이템을 지급하지 않습니다. - 결제를 캡처하고 지급 후 원장에 기록합니다.
결제 제공자가 멱등성 키를 지원한다면 trade_id 또는 trade_id:payment_attempt 같은 키를 사용합니다. 재시도 응답과 키의 유효 기간·범위는 제공자별 규약을 확인하고, 로컬 거래 처리 이력도 별도로 저장합니다.
승인된 금액은 클라이언트가 전달한 가격이 아니라 서버의 상품 카탈로그와 주문 스냅샷으로 계산합니다. 상품 가격이 거래 중 바뀌어도 기존 주문의 금액과 아이템 목록은 변하지 않아야 합니다.
Saga 오케스트레이터는 어떻게 구현할까?
중앙 오케스트레이터는 거래 상태를 읽고 다음 명령을 발행합니다. 각 서비스는 명령을 여러 번 받아도 한 번만 효과를 반영해야 합니다.
fun advance(trade: Trade) {
when (trade.state) {
TradeState.PENDING -> inventory.reserve(
tradeId = trade.id,
idempotencyKey = "${trade.id}:reserve"
)
TradeState.RESERVED -> payment.authorize(
tradeId = trade.id,
amount = trade.amount,
idempotencyKey = "${trade.id}:authorize"
)
TradeState.PAYMENT_AUTHORIZED -> inventory.confirm(
tradeId = trade.id,
idempotencyKey = "${trade.id}:confirm"
)
TradeState.CONFIRMED -> payment.capture(
tradeId = trade.id,
idempotencyKey = "${trade.id}:capture"
)
TradeState.CAPTURED -> delivery.grant(
tradeId = trade.id,
idempotencyKey = "${trade.id}:grant"
)
else -> Unit
}
}
각 명령의 결과는 동기 응답에만 의존하지 않고 이벤트로도 저장합니다. 주문 상태 변경과 Outbox 이벤트 삽입은 같은 PostgreSQL 로컬 트랜잭션에서 처리해야 합니다.
CREATE TABLE outbox_event (
event_id UUID PRIMARY KEY,
aggregate_id UUID NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Outbox 퍼블리셔는 published_at IS NULL인 이벤트를 일정 개수씩 읽어 메시지 브로커에 발행하고 성공 후 발행 시각을 기록합니다. 발행 직후 프로세스가 종료되면 같은 이벤트가 다시 발행될 수 있으므로 소비자도 event_id를 처리 이력 테이블의 유일 키로 저장해야 합니다.
부분 실패가 발생하면 어떤 보상 트랜잭션을 실행하는가?
보상은 실패 지점과 이미 외부에 반영된 효과를 기준으로 선택합니다.
| 실패 지점 | 이미 완료된 작업 | 보상 작업 |
|---|---|---|
| 예약 실패 | 없음 | 거래 취소 |
| 결제 승인 실패 | 아이템 예약 | 예약 해제 |
| 아이템 확정 실패 | 아이템 예약, 결제 승인 | 예약 해제 후 결제 승인 취소 |
| 결제 캡처 실패 | 아이템 확정 | 캡처 재시도, 계속 실패하면 보상 검토 |
| 지급 실패 | 결제 캡처, 아이템 확정 | 지급 재시도 또는 환불 정책 실행 |
보상 작업도 멱등적이어야 합니다. 예를 들어 예약 해제는 현재 예약 상태가 ACTIVE일 때만 수량을 감소시키고 곧바로 RELEASED로 바꿉니다. 이미 RELEASED라면 성공으로 간주합니다.
결제 캡처 이후에는 환불과 지급 중복이 생기지 않도록 거래 상태와 지급 원장을 대조한 뒤 재시도 또는 환불을 선택합니다. 지급 서비스는 trade_id를 멱등 키로 사용해 동일 아이템이 중복으로 지급되지 않게 설계합니다.
재시도와 보정 작업은 어떻게 안정적으로 운영할까?
재시도 대상은 일시적인 네트워크 오류와 영구적인 비즈니스 오류를 구분해야 합니다.
- 타임아웃, HTTP 502, 메시지 브로커 연결 오류는 재시도합니다.
- 재고 부족, 결제 수단 거절, 상품 판매 종료는 즉시 실패 처리합니다.
- 재시도 간격은 지수 백오프와 지터를 사용합니다. 예를 들어
min(30초, 2^시도횟수 × 500밀리초) + 0~250밀리초로 설정할 수 있습니다. - 최대 재시도 횟수를 넘기면
REFUND_REQUIRED또는 운영자 검토 큐로 보냅니다.
주기적인 보정 작업은 다음 조건을 조회합니다.
PAYMENT_AUTHORIZED상태인데 결제 승인 시각이 오래된 거래CONFIRMED상태인데 결제 캡처가 없는 거래CAPTURED상태인데 지급 원장에 없는 거래- 만료 시각이 지났지만 아직 해제되지 않은 예약
- Outbox 이벤트가 장시간 발행되지 않은 거래
보정 작업은 정상 경로와 같은 멱등 명령을 호출해야 합니다. 별도의 직접 SQL 수정으로 상태를 맞추면 감사 추적과 재처리 규칙이 깨질 수 있습니다.
보안과 동시성에서 놓치기 쉬운 부분은 무엇인가?
거래 API는 클라이언트의 요청을 신뢰하지 않는 전제로 설계해야 합니다.
user_id, 상품 가격, 지급 수량은 인증 토큰과 서버 상품 카탈로그에서 결정합니다.- 요청마다 서버가 발급한 거래 번호와 멱등성 키를 검증합니다.
- 결제 웹훅은 서명 검증, 타임스탬프 허용 범위, 이벤트 ID 중복 검사를 적용합니다.
- 거래 상태 전이는 낙관적 잠금의
version또는 데이터베이스 행 잠금으로 보호합니다. - 동일한 아이템을 구매하는 요청은 사용자별 거래 제한과 요청 속도 제한을 적용합니다.
- 감사 로그에는 거래 번호, 사용자 ID, 상태 전이, 명령 ID, 외부 결제 ID를 남기되 카드 원문이나 민감한 결제 정보를 저장하지 않습니다.
특히 결제 성공 웹훅을 받은 뒤 클라이언트가 보낸 confirm 요청을 그대로 신뢰하면 안 됩니다. 결제 서버가 반환한 외부 거래 ID와 주문의 금액, 통화, 상품 스냅샷이 일치하는지 서버에서 다시 확인해야 합니다.
전체 처리 흐름은 어떻게 보이는가?
flowchart LR
A[거래 생성] --> B[아이템 예약]
B --> C[결제 승인]
C --> D[아이템 확정]
D --> E[결제 캡처]
E --> F[아이템 지급]
C -- 승인 실패 --> X[예약 해제]
D -- 확정 실패 --> Y[예약 해제 후 승인 취소]
E -- 캡처 실패 --> Z[캡처 재시도]
F -- 지급 실패 --> W[지급 재시도 또는 환불 검토]
운영 지표는 성공률만으로 충분하지 않습니다. 상태별 체류 시간, 보상 횟수, 예약 만료율, 중복 명령 비율, REFUND_REQUIRED 건수, Outbox 지연 시간, 거래별 최종 일관성 회복 시간을 함께 수집해야 장애가 데이터 손실로 이어지기 전에 발견할 수 있습니다.
자주 묻는 질문(FAQ)
Saga는 결제를 정확히 한 번만 실행하게 보장하는가?
아닙니다. 네트워크 재시도로 같은 명령이 여러 번 전달될 수 있습니다. 결제 제공자의 멱등성 키와 서비스별 처리 이력의 유일 제약으로 결과가 한 번만 반영되도록 설계합니다.
아이템 확정과 결제 캡처 중 무엇을 먼저 해야 하는가?
가능하면 결제 승인 후 아이템 확정, 마지막에 결제 캡처 순서를 권장합니다. 캡처가 먼저라면 아이템 확정 실패 시 환불이 필요하므로 환불 API의 멱등성, 지연, 실패까지 별도로 처리해야 합니다.
Outbox만 사용하면 이벤트 유실이 사라지는가?
Outbox는 DB 상태 변경과 이벤트 기록 사이의 유실을 줄이지만 중복 발행을 없애지는 않습니다. 퍼블리셔 재시도와 소비자의 event_id 중복 제거를 함께 구현해야 합니다.
정리
인벤토리 아이템 거래에서 중요한 것은 모든 시스템을 하나의 거대한 트랜잭션으로 묶는 것이 아닙니다. 예약, 결제 승인, 아이템 확정, 결제 캡처, 지급을 독립적인 로컬 트랜잭션으로 나누고 상태 전이, 멱등성, 보상, Outbox, 보정 작업을 함께 설계하는 것이 핵심입니다.
이 구조를 사용하면 네트워크 장애나 외부 결제 지연이 발생해도 아이템 중복 판매와 중복 지급을 막으면서 거래를 재개하거나 안전하게 취소할 수 있습니다. 최종적으로는 정상 성공률보다 실패 후에도 일관성을 회복하는 운영 능력이 거래 시스템의 신뢰성을 결정합니다.



