0. 설계 경계
설계하고자 하는 분산 트랜잭션 서비스는 글로벌 한정판 스니커즈 Drop 커머스다.
- 정오 오픈 시점 예상 동시 접속자 수는 100만 명이다.
- 판매 재고는 1,000족뿐이다.
- 선착순 공정성을 보장해야 한다.
- 1족도 초과 판매되면 안 된다.
- 주문서 진입 후 5분 안에 결제가 끝나지 않으면 재고를 돌려줘야 한다.
- 사용자는 최대 10분 안에 성공 또는 실패 피드백을 받아야 한다.
- 대기열이 100만 명을 그대로 주문 서버로 보내지 않고, 입장 순서와 트래픽을 제어한다고 본다. 즉, 주문 API에는 대기열을 통과한 사용자만 들어온다.
- 재고 서비스가 초과 판매를 막기 위해 재고를 먼저 선점한다고 본다. 내부 구현은 Redis 분산 락이든 DB 조건부 업데이트든 가능하지만, Part 3에서는 재고 서비스가 다음 명령을 원자적으로 처리할 수 있다고 가정한다.
ReserveInventory(orderId, skuId, ttl=5m)
ConfirmReservation(orderId)
ReleaseReservation(orderId)
이번 설계의 관심사는 그 다음이다.
주문 생성
→ 재고 예약
→ 외부 PG 결제 승인 요청
→ 재고 확정
→ 주문 성공 처리
이 흐름은 Order, Inventory, Payment, 외부 PG까지 걸쳐 있다. 모두 다른 서비스이고, 각자 다른 DB를 가진다. 여기서 "한 번에 모두 성공하거나 모두 실패하는 것처럼 보이게 만드는 것"이 이번 설계의 목표다.
1. 먼저 실패 시나리오부터 생각했다
처음에는 "Saga 패턴을 쓰면 되지 않을까?" 정도로 생각했다. 그런데 그렇게 시작하면 글이 너무 추상적이 된다.
그래서 먼저 뭐가 터질 수 있는지부터 적어봤다.
시나리오 1. 주문은 만들어졌는데 결제 요청 이벤트가 유실된다
주문 서비스가 Order DB에 주문을 저장했다. 그런데 Kafka에 PaymentApproveRequested 이벤트를 보내기 직전에 서버가 죽었다.
그럼 DB에는 주문이 있는데, 결제 서비스는 이 주문을 모른다. 사용자는 주문을 눌렀지만 결제는 시작되지 않는다. 이런 주문은 운영에서 가장 곤란하다. 실패도 아니고 성공도 아닌 상태로 남기 때문이다.
이 문제는 단순 재시도로 해결되지 않는다. 서버가 죽으면 재시도 로직도 같이 죽는다.
시나리오 2. PG에서는 결제가 승인됐는데 우리 서버는 Timeout을 받는다
Payment Service가 PG에 승인 요청을 보냈다. PG 내부에서는 결제가 승인됐다. 그런데 네트워크 문제로 응답이 유실됐다.
우리 서버 입장에서는 Timeout이다. 하지만 이걸 실패로 처리하면 안 된다. 실제로는 돈이 빠져나갔을 수 있기 때문이다.
시나리오 3. 사용자가 결제 버튼을 여러 번 누른다
한정판 구매 화면에서 사용자가 불안해서 결제 버튼을 여러 번 누를 수 있다. 브라우저 재시도, 앱 재시도, HTTP client retry, Kafka 재전달까지 생각하면 같은 요청은 여러 번 들어온다고 보는 게 맞다.
시나리오 4. 결제는 성공했는데 재고 확정이 실패한다
PG 결제는 성공했다. 그런데 Inventory Service가 일시 장애라 ConfirmReservation이 실패했다.
이때 바로 환불하면 안 된다. 이미 5분 TTL 안에 예약된 재고가 살아 있다면, 재고 확정을 재시도해서 주문 성공으로 처리시키는 게 맞다.
다만 예약이 이미 만료되어 다른 사용자에게 넘어갔다면, 그 결제는 취소 또는 환불해야 한다.
정리하면 이번 설계의 핵심은 "정상 흐름"이 아니라 "애매한 중간 상태를 어떻게 끝까지 책임질 것인가"다.
2. 왜 2PC를 선택하지 않았나
분산 트랜잭션을 들으면 가장 먼저 2PC가 떠오른다.
2PC는 Coordinator가 각 서비스에 먼저 준비 가능한지 묻고, 모두 가능하다고 하면 한 번에 커밋을 지시하는 방식이다.
1. Prepare
2. Commit or Rollback
이론적으로는 원자성이 강하다. 하지만 이번 도메인에는 맞지 않는다고 판단했다.
첫째, 외부 PG는 2PC에 참여하지 않는다
Order DB, Inventory DB는 우리가 통제할 수 있다. 하지만 PG사는 우리 트랜잭션 참여자가 아니다. "prepare만 해두고 기다려줘", "이제 commit 해줘", "rollback 해줘" 같은 식으로 지시할 수 없다. 결제 승인 이후 취소는 가능하지만, 그건 트랜잭션 롤백이 아니라 별도의 보상 트랜잭션이다. 이 지점에서 이미 2PC는 핵심 문제를 해결하지 못한다.
둘째, 느린 외부 요청이 내부 트랜잭션을 붙잡는다
2PC나 그와 비슷한 동기식 설계를 택하면, Order DB와 Inventory DB가 결제 응답을 기다리는 동안 리소스를 잡고 있을 가능성이 커진다.
그런데 이 시스템의 핵심 주문/결제 API 목표 응답 시간은 200ms 이하다. PG API가 1초만 느려져도 내부 트랜잭션, DB 커넥션, 애플리케이션 스레드가 같이 붙잡힌다. 100만 명이 몰리는 Drop 상황에서 이런 구조는 장애를 전파한다.
셋째, MSA의 독립성을 깨뜨린다
MSA에서는 각 서비스가 자기 데이터에 대한 로컬 트랜잭션을 짧게 끝내야 한다. 2PC는 여러 서비스의 커밋 결정을 하나의 Coordinator에 강하게 묶는다. 한 서비스의 지연이 전체 지연이 되고, 한 서비스의 장애가 전체 장애가 된다.
이번 설계에서는 실패를 보상하며 최종적으로 올바른 상태에 도달하는 구조가 더 적합하다고 봤다.
3. Saga 중에서도 Orchestration을 선택한 이유
Saga는 하나의 큰 트랜잭션을 여러 로컬 트랜잭션으로 나누고, 실패하면 보상 트랜잭션을 실행하는 방식이다.
Saga에는 두 가지 방식이 있다.
- Choreography: 각 서비스가 이벤트를 보고 다음 행동을 스스로 결정한다.
- Orchestration: 중앙 Orchestrator가 전체 진행 상태와 보상 순서를 관리한다.
나는 Orchestration을 선택했다.
왜 Choreography가 아니라 Orchestration인가
Choreography는 단순한 이벤트 흐름에는 잘 맞는다.
OrderCreated
→ InventoryReserved
→ PaymentApproved
→ OrderCompleted
각 서비스가 이벤트를 구독하고 다음 이벤트를 발행하면 된다. 중앙 제어자가 없어 결합도가 낮아 보인다.
하지만 이번 주문-결제 흐름에서는 애매한 상태가 많다.
- 결제 Timeout은 실패가 아니라
UNKNOWN일 수 있다. - 5분 결제 제한 시간이 있다.
- 결제 승인 후 재고 확정 실패 시 재시도와 환불 중 하나를 선택해야 한다.
- 늦은 PG Webhook이 도착할 수 있다.
- 사용자는 10분 이내에 최종 상태를 알아야 한다.
이런 조건이 있으면 "이 주문이 지금 정확히 어느 단계인가"를 한 곳에서 추적하는 편이 낫다.
그래서 OrderSagaOrchestrator를 둔다. 이 컴포넌트는 주문별 Saga 상태를 관리하고, 다음 명령을 내리고, 보상 트랜잭션을 실행한다.
각 서비스는 자기 역할만 한다.
- Order Service: 주문 상태와 Saga 진행 상태 관리
- Inventory Service: 예약, 확정, 해제 로컬 트랜잭션 처리
- Payment Service: PG 승인 요청, 상태 조회, 취소/환불 처리
4. 전체 구조
User → [Waiting Queue] → [API Gateway] → [Order Service / OrderSagaOrchestrator]
│
┌──────────────────────┼──────────────────────┐
↓ ↓ ↓
[Order DB] [Kafka] [Client Polling/SSE]
/ \
[Inventory Service] [Payment Service]
│ │ │ │
[Inv DB] [Redis] [Pay DB] [External PG]
흐름을 한 문장으로 말하면 이렇다.
대기열이 구매 기회를 제한하고, 재고 서비스가 5분 동안 재고를 예약하고, 주문 Orchestrator가 결제와 재고 확정을 끝까지 추적한다.
여기서 가장 중요한 하게 생각한 것은 외부 PG 호출은 절대 내부 DB 트랜잭션 안에서 하지 않는다는 것이다.
5. 재고는 먼저 예약하고, 결제 후 확정한다
결제 전에 재고를 전혀 잡아두지 않으면 문제가 생긴다. 사용자가 결제창에서 카드 정보를 입력하는 동안 다른 사용자들이 같은 재고를 가져갈 수 있다.
반대로 주문서 진입 시점에 바로 판매 확정으로 차감해버리면, 결제 실패나 이탈 때 재고 복구가 복잡해진다.
그래서 중간 상태가 필요하다.
AVAILABLE → RESERVED → SOLD
RESERVED → RELEASED
주문서에 들어온 사용자는 재고를 5분 동안 점유한다. 이 5분 안에 결제에 성공하면 SOLD로 확정한다. 결제 실패, 결제 포기, 만료가 발생하면 RELEASED로 되돌린다.
- 사용자는 결제하는 동안 구매 기회를 보장받는다.
- 시스템은 결제 실패 시 재고를 다음 대기자에게 넘길 수 있다.
- 초과 판매는 Inventory Service의 예약 원자성으로 막는다.
6. 정상 흐름
User OrderSagaOrchestrator Inventory Service Payment Service External PG
│ │ │ │ │
│─ POST /orders ────▶│ │ │ │
│ │─ Local TX: Order(CREATED) │ │
│ │─ ReserveInventory ───▶│ │ │
│ │ │─ Local TX: RESERVED │
│ │◀─ InventoryReserved ──│ │ │
│ │─ Local TX: Order(WAITING_PAYMENT) │ │
│◀─ orderId, expiresAt ─│ │ │ │
│ │ │ │ │
│─ POST /payments/confirm ▶│ │ │ │
│ │─ Local TX: Order(PAYMENT_IN_PROGRESS) + Outbox │
│◀─ 202 Accepted ───│ │ │ │
│ │─ PaymentApproveRequested ────────────────▶│ │
│ │ │ │─ Approve() ──▶│
│ │ │ │◀─ Approved ───│
│ │ │ │─ Local TX: APPROVED + Outbox
│ │◀─ PaymentApproved ────────────────────────│ │
│ │─ ConfirmReservation ─▶│ │ │
│ │ │─ Local TX: RESERVED → SOLD │
│ │◀─ InventoryConfirmed ─│ │ │
│ │─ Local TX: Order(PAID) │ │
│◀─ SSE: success ───│ │ │ │
여기서 POST /payments/confirm은 결제 완료를 기다리지 않는다. 결제 승인 요청을 접수하고 202 Accepted를 반환한다.
왜냐하면 PG 호출은 느릴 수 있기 때문이다. PG가 느려졌다고 해서 주문 API의 스레드와 DB 트랜잭션이 같이 느려지면 안 된다.
사용자는 이후 주문 상태 조회나 SSE를 통해 최종 결과를 받는다.
GET /orders/{orderId}
{
"orderId": "ord_123",
"status": "PAYMENT_IN_PROGRESS",
"message": "결제 승인 처리 중입니다."
}
최종적으로는 다음 중 하나로 끝난다.
- 성공:
PAID,COMPLETED - 실패:
CANCELLED,EXPIRED,PAYMENT_FAILED,REFUNDED
7. 외부 PG 호출을 내부 트랜잭션에서 분리한다
이번 설계에서 가장 경계한 코드는 이런 형태다.
@Transactional
fun confirmPayment(orderId: String) {
orderRepository.updateStatus(orderId, PAYMENT_IN_PROGRESS)
val result = pgClient.approve(orderId) // ← 위험
paymentRepository.save(result)
inventoryClient.confirm(orderId)
}
pgClient.approve()가 3초 동안 응답하지 않으면 DB 트랜잭션도 3초 동안 열린다. PG Timeout은 내부 DB 커넥션, row lock, 애플리케이션 스레드를 같이 잡아둔다.그래서 다음처럼 나눈다.
Local TX: Order status update + outbox insert ← 짧게 끝냄
No TX: External PG HTTP call ← 트랜잭션 밖
Local TX: Payment result update + outbox insert ← 짧게 끝냄
- Order DB에 결제 진행 상태를 저장한다.
- 같은 트랜잭션에서 Outbox에
PaymentApproveRequested를 저장한다. - 트랜잭션을 커밋한다.
- Outbox Relay가 Kafka로 이벤트를 발행한다.
- Payment Service Consumer가 PG를 호출한다.
- PG 결과를 Payment DB에 저장하고 다시 이벤트를 발행한다.
이렇게 하면 PG가 느려져도 Order DB 트랜잭션은 영향을 받지 않는다.
8. PG 승인 Timeout은 실패가 아니라 UNKNOWN이다
가장 중요하게 다룬 케이스다.
Payment Service → PG 승인 요청
PG 내부 승인 성공
PG → Payment Service 응답 유실
Payment Service: Timeout 발생
이때 우리 서버가 받은 건 Timeout이다. 하지만 PG에서는 결제가 승인됐을 수 있다.
PAYMENT_FAILED로 처리하면 안 된다. 재고를 풀어버리고 주문을 취소했는데, 실제로는 고객 카드에서 돈이 빠져나간 상태가 될 수 있다.이 케이스는 반드시 PAYMENT_UNKNOWN으로 두고, 다음 흐름으로 처리한다.

이 흐름에서 핵심은 세 가지다.
- Timeout은 실패가 아니라 UNKNOWN이다.
- 승인 요청에는 반드시 idempotencyKey를 붙인다.
- PG 상태 조회 API로 실제 승인 여부를 확인한 뒤 다음 단계로 간다.
9. 5분 제한 시간이 지나면 어떻게 할까
비즈니스 요구사항은 명확하다. 주문서 진입 후 5분 이내에 결제가 완료되지 않으면 재고를 환원한다.
그런데 Payment가 UNKNOWN이면 고민이 생긴다. 나는 이 정책을 선택했다.
- 5분 안에 PG 조회로
APPROVED가 확인되면 주문 성공 처리한다. - 5분이 지났고 승인 내역이 확인되지 않으면 주문은
EXPIRED처리하고 재고를 해제한다. - 이후 늦게 승인 사실이 확인되면 상품 제공이 아니라 결제 취소/환불로 보상한다.
"5분은 결제 완료 시간이 아니라 구매 권리의 유효 시간"이다. 5분이 지난 뒤 결제가 확인되었다고 해서 이미 해제된 재고를 다시 뺏어오면 안 된다. 그 재고는 다음 대기자에게 넘어갔을 수 있다.

이 정책은 구매 전환율만 보면 아쉬울 수 있다. 하지만 이 도메인에서는 초과 판매 방지가 더 중요하다. 1,000족 한정 판매에서 이미 해제된 재고를 다시 성공 처리하면 다른 사용자의 선착순 기회를 침해할 수 있다.
10. 상태 모델
Orchestration Saga를 쓰려면 상태가 명확해야 한다. 상태가 흐릿하면 장애 복구 시 "이 주문을 다시 진행해야 하는지, 취소해야 하는지, 환불해야 하는지" 판단할 수 없다.
Order 상태
CREATED
→ WAITING_PAYMENT
→ PAYMENT_IN_PROGRESS
→ PAID
→ COMPLETED
WAITING_PAYMENT → EXPIRED
PAYMENT_IN_PROGRESS → PAYMENT_FAILED → CANCELLED
PAYMENT_IN_PROGRESS → PAYMENT_UNKNOWN → PAID or EXPIRED
EXPIRED + LatePaymentApproved → REFUND_REQUESTED
Payment 상태
READY
→ APPROVAL_REQUESTED
→ APPROVED
APPROVAL_REQUESTED → FAILED
APPROVAL_REQUESTED → UNKNOWN
UNKNOWN → APPROVED / FAILED / REFUND_REQUIRED
APPROVED → CANCEL_REQUESTED → CANCELLED or REFUNDED
Inventory Reservation 상태
RESERVED → SOLD
RESERVED → RELEASED
RESERVED → EXPIRED
상태를 이렇게 쪼개는 이유는 운영 복구 때문이다. 장애가 난 뒤 재처리 Job이 돌 때도 상태만 보고 다음 행동을 결정할 수 있어야 한다.
11. 보상 트랜잭션 정리
| 실패 상황 | 처리 |
|---|---|
| 재고 예약 실패 | 주문 취소, 결제 요청 안 함 |
| 결제 명시적 실패 | 재고 예약 해제, 주문 취소 |
| 결제 Timeout | Payment UNKNOWN, PG 상태 조회 |
| Timeout 후 5분 내 승인 확인 | 재고 확정, 주문 성공 |
| Timeout 후 5분 내 승인 미확인 | 재고 해제, 주문 만료 |
| 주문 만료 후 늦은 승인 확인 | 결제 취소/환불 |
| 결제 승인 후 재고 확정 실패 | 재고 예약이 살아 있으면 재시도 |
| 재고 예약 만료 후 결제 승인 확인 | 결제 취소/환불 |
결제 승인 후 재고 확정 실패는 바로 환불하면 안 된다. 재고 예약이 아직 살아 있으면 재시도해서 성공으로 수렴시키는 게 맞다.
12. 멱등성
Kafka는 같은 메시지를 두 번 전달할 수 있다. PG Webhook도 중복으로 올 수 있다. 사용자의 결제 요청도 중복으로 들어올 수 있다.
그래서 이 설계에서는 "같은 요청이 여러 번 들어온다"를 기본값으로 둔다.
-- 중복 방지
orders:
UNIQUE(drop_id, user_id)
inventory_reservations:
UNIQUE(order_id)
payment_attempts:
UNIQUE(order_id, attempt_no)
UNIQUE(idempotency_key)
inbox_events:
UNIQUE(event_id)
outbox_events:
PRIMARY KEY(event_id)
PG 승인 요청에도 idempotency key를 붙인다.
idempotencyKey = payment:{orderId}:{attemptNo}
상태 전이도 조건부로 처리한다.
UPDATE inventory_reservation
SET status = 'SOLD'
WHERE order_id = :orderId
AND status = 'RESERVED'; ← 이미 SOLD면 UPDATE 안 됨
이미 SOLD인 상태에서 같은 요청이 한 번 더 와도 결과는 달라지지 않아야 한다. 이것이 Saga를 운영 가능한 구조로 만드는 핵심이다.
13. 사용자에게는 어떻게 보여줄까
핵심 주문/결제 API는 평균 200ms 이하를 목표로 한다. 따라서 결제 승인과 재고 확정까지 한 API에서 모두 기다리면 안 된다. API 응답은 접수와 완료를 분리한다.
주문서 진입
POST /orders
→ 200 OK
{
"orderId": "ord_123",
"status": "WAITING_PAYMENT",
"expiresAt": "2026-04-26T12:05:00+09:00"
}
결제 승인 요청
POST /orders/{orderId}/payments/confirm
→ 202 Accepted
{
"orderId": "ord_123",
"status": "PAYMENT_IN_PROGRESS",
"message": "결제 승인 처리 중입니다."
}
상태 조회 (Polling / SSE)
GET /orders/{orderId}
→ 200 OK
{
"orderId": "ord_123",
"status": "PAID",
"paymentStatus": "APPROVED",
"inventoryStatus": "SOLD"
}
사용자는 중간에 PAYMENT_IN_PROGRESS 또는 PAYMENT_UNKNOWN을 볼 수 있다. 다만 10분 안에는 성공 또는 실패 계열의 최종 상태로 안내한다.
만약 PG 확인이 계속 지연된다면, 구매는 실패로 확정하고 이후 결제 승인 사실이 확인될 경우 자동 환불한다.
14. 최종적으로 내가 선택한 설계
- 대기열을 통과한 사용자만 주문서에 진입한다.
- 주문 생성 시 Inventory Service가 재고를 5분간
RESERVED처리한다. - 결제 승인 요청 API는 PG 응답을 기다리지 않고 202로 반환한다.
- PG 호출은 Payment Service가 비동기로 수행한다.
- PG Timeout은
FAILED가 아니라UNKNOWN으로 저장한다. UNKNOWN은 PG 상태 조회와 Webhook으로 확정한다.- 5분 안에 승인 확인 시 주문 성공, 확인 실패 시 주문 만료와 재고 해제.
- 만료 이후 늦은 승인 확인은 주문 성공이 아니라 환불로 보상한다.
- 모든 이벤트와 명령은 Outbox, Inbox, Idempotency Key로 중복과 유실을 방어한다.
이 설계는 즉시 일관성을 포기한다. 대신 최종 일관성을 선택한다.
하지만 이 도메인에서 더 중요한 조건은 지킨다.
- 초과 판매를 막는다.
- 외부 PG 장애가 내부 트랜잭션을 붙잡지 않는다.
- 결제 Timeout을 섣불리 실패로 처리하지 않는다.
- 주문은 최종적으로 성공 또는 실패로 수렴한다.
- 사용자는 제한 시간 안에 결과를 받는다.