[CS] 결제 시스템 - 전체 Flow

Bronze_Yun·2026년 9월 2일

PocketPay

목록 보기
4/4
post-thumbnail

하나의 결제를 여러 트랜잭션으로 나눈 이유

주문과 결제는 사용자에게 하나의 과정처럼 보입니다.

상품을 선택하고 결제 버튼을 누르면 재고와 포인트가 반영되고, 결제 완료 알림과 판매자 정산 데이터까지 이어져야 합니다. 환불할 때는 반대 방향으로 금액과 상태를 다시 맞춰야 합니다.

하지만 서버 입장에서 이 과정을 하나의 트랜잭션으로 처리하기는 어렵습니다. 결제 승인에는 외부 PG사가 참여하고, 승인 이후에는 포인트·재고·알림·정산처럼 책임과 실패 특성이 서로 다른 작업이 이어지기 때문입니다.

  • PG사 응답이 늦으면 데이터베이스 트랜잭션도 계속 기다려야 할까?
  • 결제는 승인됐는데 포인트 적립이 실패하면 결제까지 실패시켜야 할까?
  • 서버가 응답을 받지 못했다면 실제 결제 결과는 성공일까, 실패일까?
  • 같은 주문이나 환불 요청이 두 번 들어오면 어떻게 막아야 할까?

PocketPay에서는 이 문제를 해결하기 위해 주문, 결제 승인, 결제 완료 후처리, 대사와 정산, 환불의 책임과 트랜잭션을 분리했습니다. 이번 글에서는 세부 코드보다 각 단계의 비즈니스 의미와 전체 처리 흐름을 정리합니다.


주문을 생성할 때 재고부터 예약한 이유

주문만 저장하고 나중에 재고를 확인하면 사용자는 주문번호를 받았지만 실제로는 구매할 수 없는 상황을 마주할 수 있습니다. 특히 마지막 상품을 여러 사용자가 동시에 주문하면 주문 수가 판매 가능한 수량을 넘어갈 수 있습니다.

따라서 주문 생성 과정에서는 서버가 상품 가격을 조회하고, 상품별 분산 락을 획득한 뒤 결제 전에 필요한 수량을 예약 재고로 확보합니다.

재고 상태비즈니스 의미
판매 가능다른 사용자가 주문할 수 있는 수량
예약주문은 생성됐지만 결제가 끝나지 않은 수량
판매 완료결제가 완료돼 실제 판매로 확정된 수량

현재 구현에서는 재고 예약과 주문·주문 상품 저장을 같은 데이터베이스 트랜잭션에서 처리합니다. 주문 저장이 실패하면 재고 예약도 함께 롤백되고, 분산 락은 트랜잭션 종료 후 해제됩니다.

결제를 시작하지 않은 주문은 Batch가 일정 시간이 지난 뒤 만료시키고 예약 재고를 해제합니다. 즉 재고 예약은 무기한 차감이 아니라, 결제가 끝날 때까지 판매 가능 수량을 확보하는 임시 상태입니다.

중복 주문은 요청 고유 키로 막았다

주문 요청마다 멱등키를 사용합니다. Redis가 같은 키의 동시 실행을 먼저 차단하고 데이터베이스의 유니크 제약이 마지막 방어선으로 동작합니다.

현재 주문 API는 이전 성공 응답을 반환하기보다 중복 요청을 거절합니다. 모든 API가 같은 멱등성 정책을 사용하는 것은 아닙니다.

같은 요청은 여러 번 도착할 수 있지만, 비즈니스 결과는 한 번만 만들어져야 합니다.


결제할 포인트도 승인 전에 예약한다

재고만 예약하고 포인트는 잔액만 확인하면, 같은 사용자가 여러 결제에서 같은 포인트를 동시에 사용할 수 있습니다. PocketPay는 결제를 IN_PROGRESS로 바꾸는 트랜잭션 안에서 사용할 포인트도 함께 예약합니다.

사용 가능 포인트 = 전체 잔액 - 예약 포인트

포인트 잔액 행을 잠근 상태에서 사용 가능 포인트를 확인하고 결제별 예약 내역을 생성합니다.

결제 결과포인트 예약처리
승인 성공RESERVED → USED잔액 차감과 사용 원장 생성
명확한 승인 실패RESERVED → RELEASED예약 해제
결과 미확정RESERVED 유지대사 결과까지 다른 결제의 사용 차단

포인트 전액 결제는 PG사를 호출하지 않지만, 동일한 예약과 확정 상태 전이를 거쳐 내부에서 완료합니다.


외부 PG 호출과 DB 트랜잭션을 분리한 이유

가장 단순한 결제 구현은 하나의 트랜잭션 안에서 결제 데이터를 만들고 PG사를 호출한 뒤 주문을 완료하는 방식입니다.

문제는 PG사의 응답 시간을 우리 서버가 통제할 수 없다는 점입니다. 외부 응답이 늦어지면 트랜잭션과 데이터베이스 연결, 잠금이 함께 길어집니다.

그래서 결제 승인을 세 구간으로 나눴습니다.

  1. 짧은 트랜잭션에서 주문과 금액을 검증하고, 주문을 PAYMENT_PENDING, 결제를 IN_PROGRESS로 바꾸며 포인트를 예약합니다.
  2. 트랜잭션 밖에서 PG사에 승인을 요청합니다.
  3. PG 응답에 따라 별도의 짧은 트랜잭션에서 완료, 실패 또는 결과 확인 필요 상태를 저장합니다.

목적은 PG 장애를 없애는 것이 아니라 외부 시스템이 느려져도 우리 데이터베이스 트랜잭션이 함께 길어지는 범위를 줄이는 것입니다.


결제 요청에서 먼저 확인하는 것

PG 승인 요청 전 다음 내용을 내부에서 먼저 검증합니다.

  • 요청한 사용자가 실제 주문자인가?
  • 사용할 포인트가 0 이상이고 주문 금액 이하인가?
  • 사용 가능 포인트가 요청 금액 이상인가?
  • 주문 총액 - 사용 포인트와 PG 승인 금액이 일치하는가?
  • 같은 멱등키로 이미 처리된 결제가 있는가?
  • 해당 주문에 이미 진행 중인 결제가 있는가?

결제 멱등성은 주문보다 적극적입니다. 성공 응답을 Redis에 저장해 같은 멱등키가 다시 들어오면 PG 승인을 새로 만들지 않고 기존 결과를 반환합니다. 첫 요청이 처리 중이면 제한 시간 동안 결과를 기다리며, DB 유니크 제약도 중복 생성을 방어합니다.


타임아웃을 바로 실패로 처리하지 않은 이유

PG 승인 요청 중 타임아웃이 발생했다고 실제 결제가 실패한 것은 아닙니다. 서버는 응답을 받지 못했지만 PG에서는 승인이 끝났을 수 있습니다. 이 결제를 실패로 단정하고 다시 결제하면 중복 승인으로 이어질 수 있습니다.

따라서 결과를 즉시 확정할 수 없는 결제는 TIMEOUT_UNKNOWN, 즉 결제 결과 확인 필요 상태로 관리합니다.

이때 주문은 PAYMENT_PENDING, 사용 포인트는 예약 상태에 둡니다. 주문을 다시 열거나 포인트를 해제하면 재결제와 중복 사용을 허용할 수 있기 때문입니다.

TIMEOUT_UNKNOWN은 오류 코드가 아니라 외부 시스템과 내부 데이터가 다를 수 있음을 표현한 비즈니스 상태입니다. Batch가 PG 거래를 다시 조회해 승인을 확인하면 결제와 주문을 완료 상태로 보정하고 포인트 사용 확정과 완료 작업을 이어갑니다.

PG 승인은 성공했지만 내부 완료 상태 저장이 실패한 경우에도 중요 알림을 영속적으로 남기고 대사 대상으로 전환합니다.


결제가 성공해도 모든 후처리가 성공하는 것은 아니다

결제 완료 트랜잭션에서는 다음 상태를 함께 확정합니다.

  1. 예약 포인트를 실제 사용으로 확정하고 사용 원장을 생성합니다.
  2. 결제를 DONE으로 바꿉니다.
  3. 주문을 PAID로 바꿉니다.

포인트 사용은 결제 금액 구성 자체이므로 결제·주문 완료와 같은 핵심 트랜잭션에 둡니다. 반면 다음 작업은 완료 후 독립적으로 시도합니다.

  1. PG 결제 금액 기준 구매 포인트 적립
  2. 예약 재고를 판매 완료로 확정
  3. 결제 완료 알림 발송

하나가 실패해도 다음 작업은 계속 실행합니다. 알림 실패 때문에 이미 승인된 결제를 취소하지 않습니다. 실패 이력을 남기고 포인트 적립과 재고 확정은 영역별 복구 Batch가 다시 처리합니다.

정산은 결제 응답 경로에서 제외했다

현재 Core는 정산 원본을 결제 후처리에서 직접 생성하지 않습니다. Batch가 완료된 결제 중 아직 정산되지 않은 건을 조회해 정산 데이터를 만듭니다. 정산 지연이나 실패가 고객의 결제 응답에 영향을 주지 않도록 책임을 분리한 것입니다.

복구 작업중복 방지 기준완료 기준
포인트 적립같은 주문의 적립 원장적립 내역이 한 번만 반영됨
재고 확정현재 예약 재고 상태예약 수량이 판매 완료로 전환됨
정산 생성결제별 정산 존재 여부정산 원본이 한 번만 생성됨

영역별 Batch는 각 데이터에 맞는 멱등 조건을 사용합니다. 여러 인스턴스가 같은 실패 건을 처리하지 않도록 대상을 조건부로 선점하고, 중단된 건은 일정 시간 후 다시 가져옵니다. 알림 실패는 금액·재고 정합성을 바꾸지 않아 현재 자동 복구 대상이 아닌 운영 확인 대상으로 남습니다.


환불은 결제를 반대로 실행하는 과정일까?

환불은 결제 승인을 단순히 거꾸로 실행하는 작업이 아닙니다. 환불 금액은 클라이언트 값을 신뢰하지 않고 주문 당시 단가와 요청 수량으로 계산합니다.

환불 요청 금액 = 주문 당시 단가 × 환불 수량

동시 환불 합계가 가능 금액을 넘지 않도록 결제 행을 비관적 락으로 읽은 뒤 refundable_amount를 차감합니다.

금액이 남으면 PARTIAL_CANCELED, 환불 가능 금액이 0원이 되면 CANCELED 상태가 됩니다. 각 요청은 별도 환불 이력으로 저장됩니다.

다만 현재 구현에는 운영 전 보완할 부분이 있습니다.

  • PG 취소가 best-effort 방식이어서 외부 취소 실패에도 내부 완료가 진행됩니다.
  • 포인트 결제분의 환급 정책과 복원 흐름이 아직 완성되지 않았습니다.
  • 누적 상품 수량보다 남은 PG 환불 가능 금액을 중심으로 제한합니다.
  • PG 취소 결과 미확정 상태, 거래 재조회, 자동 재시도와 대사가 필요합니다.

현재 구조는 동시 초과 환불을 막는 기반은 갖췄지만, 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 거래 대사

마무리

주문과 결제, 환불을 구현하면서 가장 중요하게 본 것은 모든 작업을 한 번에 성공시키는 것이 아니었습니다.

  • 주문과 예약 재고는 같은 트랜잭션에서 확정했습니다.
  • 결제 시작 시 사용할 포인트도 먼저 예약해 동시 사용을 막았습니다.
  • 외부 PG 호출은 내부 DB 트랜잭션과 분리했습니다.
  • 알 수 없는 결제는 실패로 단정하지 않고 TIMEOUT_UNKNOWN으로 관리했습니다.
  • 포인트 사용은 결제 완료와 묶고, 적립·재고·알림은 독립 후처리로 분리했습니다.
  • 정산은 결제 응답 경로에서 제거하고 Batch가 완료 결제를 기준으로 생성하게 했습니다.
  • 환불은 남은 환불 가능 금액으로 동시 초과 환불을 막되, PG 취소 대사와 포인트 환급은 개선 과제로 남겼습니다.

이 구조가 장애 자체를 없애주지는 않습니다. 대신 하나의 실패가 전체 결제로 번지는 범위를 줄이고, 즉시 확정할 수 없는 결과를 상태와 이력으로 남겨 나중에 다시 맞출 수 있게 합니다.

profile
정답이 꼭 정답이 아니다.

0개의 댓글