
주문과 결제는 사용자에게 하나의 과정처럼 보입니다.
상품을 선택하고 결제 버튼을 누르면 재고와 포인트가 반영되고, 결제 완료 알림과 판매자 정산 데이터까지 이어져야 합니다. 환불할 때는 반대 방향으로 금액과 상태를 다시 맞춰야 합니다.
하지만 서버 입장에서 이 과정을 하나의 트랜잭션으로 처리하기는 어렵습니다. 결제 승인에는 외부 PG사가 참여하고, 승인 이후에는 포인트·재고·알림·정산처럼 책임과 실패 특성이 서로 다른 작업이 이어지기 때문입니다.
PocketPay에서는 이 문제를 해결하기 위해 주문, 결제 승인, 결제 완료 후처리, 대사와 정산, 환불의 책임과 트랜잭션을 분리했습니다. 이번 글에서는 세부 코드보다 각 단계의 비즈니스 의미와 전체 처리 흐름을 정리합니다.

주문만 저장하고 나중에 재고를 확인하면 사용자는 주문번호를 받았지만 실제로는 구매할 수 없는 상황을 마주할 수 있습니다. 특히 마지막 상품을 여러 사용자가 동시에 주문하면 주문 수가 판매 가능한 수량을 넘어갈 수 있습니다.
따라서 주문 생성 과정에서는 서버가 상품 가격을 조회하고, 상품별 분산 락을 획득한 뒤 결제 전에 필요한 수량을 예약 재고로 확보합니다.

| 재고 상태 | 비즈니스 의미 |
|---|---|
| 판매 가능 | 다른 사용자가 주문할 수 있는 수량 |
| 예약 | 주문은 생성됐지만 결제가 끝나지 않은 수량 |
| 판매 완료 | 결제가 완료돼 실제 판매로 확정된 수량 |
현재 구현에서는 재고 예약과 주문·주문 상품 저장을 같은 데이터베이스 트랜잭션에서 처리합니다. 주문 저장이 실패하면 재고 예약도 함께 롤백되고, 분산 락은 트랜잭션 종료 후 해제됩니다.
결제를 시작하지 않은 주문은 Batch가 일정 시간이 지난 뒤 만료시키고 예약 재고를 해제합니다. 즉 재고 예약은 무기한 차감이 아니라, 결제가 끝날 때까지 판매 가능 수량을 확보하는 임시 상태입니다.
주문 요청마다 멱등키를 사용합니다. Redis가 같은 키의 동시 실행을 먼저 차단하고 데이터베이스의 유니크 제약이 마지막 방어선으로 동작합니다.
현재 주문 API는 이전 성공 응답을 반환하기보다 중복 요청을 거절합니다. 모든 API가 같은 멱등성 정책을 사용하는 것은 아닙니다.
같은 요청은 여러 번 도착할 수 있지만, 비즈니스 결과는 한 번만 만들어져야 합니다.
재고만 예약하고 포인트는 잔액만 확인하면, 같은 사용자가 여러 결제에서 같은 포인트를 동시에 사용할 수 있습니다. PocketPay는 결제를 IN_PROGRESS로 바꾸는 트랜잭션 안에서 사용할 포인트도 함께 예약합니다.
사용 가능 포인트 = 전체 잔액 - 예약 포인트
포인트 잔액 행을 잠근 상태에서 사용 가능 포인트를 확인하고 결제별 예약 내역을 생성합니다.
| 결제 결과 | 포인트 예약 | 처리 |
|---|---|---|
| 승인 성공 | RESERVED → USED | 잔액 차감과 사용 원장 생성 |
| 명확한 승인 실패 | RESERVED → RELEASED | 예약 해제 |
| 결과 미확정 | RESERVED 유지 | 대사 결과까지 다른 결제의 사용 차단 |
포인트 전액 결제는 PG사를 호출하지 않지만, 동일한 예약과 확정 상태 전이를 거쳐 내부에서 완료합니다.
가장 단순한 결제 구현은 하나의 트랜잭션 안에서 결제 데이터를 만들고 PG사를 호출한 뒤 주문을 완료하는 방식입니다.

문제는 PG사의 응답 시간을 우리 서버가 통제할 수 없다는 점입니다. 외부 응답이 늦어지면 트랜잭션과 데이터베이스 연결, 잠금이 함께 길어집니다.
그래서 결제 승인을 세 구간으로 나눴습니다.
PAYMENT_PENDING, 결제를 IN_PROGRESS로 바꾸며 포인트를 예약합니다.
목적은 PG 장애를 없애는 것이 아니라 외부 시스템이 느려져도 우리 데이터베이스 트랜잭션이 함께 길어지는 범위를 줄이는 것입니다.
PG 승인 요청 전 다음 내용을 내부에서 먼저 검증합니다.
주문 총액 - 사용 포인트와 PG 승인 금액이 일치하는가?
결제 멱등성은 주문보다 적극적입니다. 성공 응답을 Redis에 저장해 같은 멱등키가 다시 들어오면 PG 승인을 새로 만들지 않고 기존 결과를 반환합니다. 첫 요청이 처리 중이면 제한 시간 동안 결과를 기다리며, DB 유니크 제약도 중복 생성을 방어합니다.
PG 승인 요청 중 타임아웃이 발생했다고 실제 결제가 실패한 것은 아닙니다. 서버는 응답을 받지 못했지만 PG에서는 승인이 끝났을 수 있습니다. 이 결제를 실패로 단정하고 다시 결제하면 중복 승인으로 이어질 수 있습니다.
따라서 결과를 즉시 확정할 수 없는 결제는 TIMEOUT_UNKNOWN, 즉 결제 결과 확인 필요 상태로 관리합니다.

이때 주문은 PAYMENT_PENDING, 사용 포인트는 예약 상태에 둡니다. 주문을 다시 열거나 포인트를 해제하면 재결제와 중복 사용을 허용할 수 있기 때문입니다.
TIMEOUT_UNKNOWN은 오류 코드가 아니라 외부 시스템과 내부 데이터가 다를 수 있음을 표현한 비즈니스 상태입니다. Batch가 PG 거래를 다시 조회해 승인을 확인하면 결제와 주문을 완료 상태로 보정하고 포인트 사용 확정과 완료 작업을 이어갑니다.
PG 승인은 성공했지만 내부 완료 상태 저장이 실패한 경우에도 중요 알림을 영속적으로 남기고 대사 대상으로 전환합니다.
결제 완료 트랜잭션에서는 다음 상태를 함께 확정합니다.
DONE으로 바꿉니다.PAID로 바꿉니다.포인트 사용은 결제 금액 구성 자체이므로 결제·주문 완료와 같은 핵심 트랜잭션에 둡니다. 반면 다음 작업은 완료 후 독립적으로 시도합니다.

하나가 실패해도 다음 작업은 계속 실행합니다. 알림 실패 때문에 이미 승인된 결제를 취소하지 않습니다. 실패 이력을 남기고 포인트 적립과 재고 확정은 영역별 복구 Batch가 다시 처리합니다.
현재 Core는 정산 원본을 결제 후처리에서 직접 생성하지 않습니다. Batch가 완료된 결제 중 아직 정산되지 않은 건을 조회해 정산 데이터를 만듭니다. 정산 지연이나 실패가 고객의 결제 응답에 영향을 주지 않도록 책임을 분리한 것입니다.
| 복구 작업 | 중복 방지 기준 | 완료 기준 |
|---|---|---|
| 포인트 적립 | 같은 주문의 적립 원장 | 적립 내역이 한 번만 반영됨 |
| 재고 확정 | 현재 예약 재고 상태 | 예약 수량이 판매 완료로 전환됨 |
| 정산 생성 | 결제별 정산 존재 여부 | 정산 원본이 한 번만 생성됨 |
영역별 Batch는 각 데이터에 맞는 멱등 조건을 사용합니다. 여러 인스턴스가 같은 실패 건을 처리하지 않도록 대상을 조건부로 선점하고, 중단된 건은 일정 시간 후 다시 가져옵니다. 알림 실패는 금액·재고 정합성을 바꾸지 않아 현재 자동 복구 대상이 아닌 운영 확인 대상으로 남습니다.
환불은 결제 승인을 단순히 거꾸로 실행하는 작업이 아닙니다. 환불 금액은 클라이언트 값을 신뢰하지 않고 주문 당시 단가와 요청 수량으로 계산합니다.
환불 요청 금액 = 주문 당시 단가 × 환불 수량
동시 환불 합계가 가능 금액을 넘지 않도록 결제 행을 비관적 락으로 읽은 뒤 refundable_amount를 차감합니다.

금액이 남으면 PARTIAL_CANCELED, 환불 가능 금액이 0원이 되면 CANCELED 상태가 됩니다. 각 요청은 별도 환불 이력으로 저장됩니다.
다만 현재 구현에는 운영 전 보완할 부분이 있습니다.
현재 구조는 동시 초과 환불을 막는 기반은 갖췄지만, PG 취소 결과와 포인트까지 포함한 완전한 환불 정합성은 개선 과제입니다.

[주문]
STOCK_RESERVED ─ 결제 시작 → PAYMENT_PENDING ─ 승인 완료 → PAID
└─ 결제 기한 초과 → EXPIRED + 예약 재고 해제
[결제]
READY → IN_PROGRESS
├─ 승인 성공 → DONE
├─ 명확한 실패 → FAILED
└─ 결과 불명확 → TIMEOUT_UNKNOWN ─ 대사 성공 → DONE
[포인트 예약]
RESERVED
├─ 결제 성공 → USED
├─ 명확한 실패 → RELEASED
└─ 결과 불명확 → RESERVED 유지
[환불 후 결제]
DONE ─ 일부 환불 → PARTIAL_CANCELED ─ 전액 환불 → CANCELED
상태는 성공 여부뿐 아니라 시스템이 무엇을 기다리고 운영 또는 Batch가 무엇을 확인해야 하는지를 표현합니다.

재고 예약 + 주문 저장
↓
결제 검증 + 포인트 예약 + IN_PROGRESS 저장
↓ 트랜잭션 종료
PG 승인 요청
├─ 성공 → 포인트 사용 확정 + 결제 DONE + 주문 PAID
│ ↓
│ 포인트 적립·재고 확정·알림 → Batch 정산 생성
├─ 명확한 실패 → 결제 FAILED + 포인트 예약 해제
└─ 결과 불명확 → TIMEOUT_UNKNOWN + 포인트 예약 유지 → PG 거래 대사
주문과 결제, 환불을 구현하면서 가장 중요하게 본 것은 모든 작업을 한 번에 성공시키는 것이 아니었습니다.
TIMEOUT_UNKNOWN으로 관리했습니다.이 구조가 장애 자체를 없애주지는 않습니다. 대신 하나의 실패가 전체 결제로 번지는 범위를 줄이고, 즉시 확정할 수 없는 결과를 상태와 이력으로 남겨 나중에 다시 맞출 수 있게 합니다.