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

Bronze_Yun·2026년 9월 2일

PocketPay

목록 보기
4/4
post-thumbnail

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

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

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

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

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

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


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

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

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

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

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

실제 동시성 제어의 마지막 방어선은 분산 락이 아니라 재고 행 자체에 거는 DB 행 락(SELECT ... FOR UPDATE)입니다. 분산 락은 대기시간을 넘기면 빠르게 실패를 돌려주는 역할이고, 상품 수량이 실제로 꼬이지 않는 이유는 이 DB 행 락이 트랜잭션이 끝날 때까지 유지되기 때문입니다.

결제를 시작하지 않은 주문은 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 유니크 제약도 중복 생성을 방어합니다.

다만 대기 중 원 요청이 결과 확인 필요 상태로 끝나면 캐시가 남지 않아 대기자는 자체 타임아웃을 받을 수 있습니다. 이런 경우까지 포함해 결제 상태를 언제든 다시 확인할 수 있도록 결제 상태 조회 API(GET /api/payments/{orderNumber})도 함께 제공합니다.


PG가 거절해도 원인은 다르다

PG가 거절 응답을 줘도 HTTP 상태코드만으로는 원인을 판단할 수 없습니다. 카드사 거절처럼 고객 쪽 문제도 400번대로 오지만, PG 내부 오류나 잘못된 요청 같은 시스템 쪽 문제도 똑같이 400번대로 내려오기 때문입니다.

그래서 상태코드 대신 응답 바디의 실제 에러 코드를 봅니다. 카드 거절, 한도 초과, 비밀번호 오류처럼 알려진 고객 귀책 코드 목록과 대조해서, 고객 귀책이면 재시도 없이 바로 실패 처리하고, 그 외(시스템 문제거나 처음 보는 코드)는 안전한 쪽으로 판단해 즉시 재조회 경로로 보냅니다.

모르는 코드를 섣불리 고객 귀책으로 판단해 성공했을 수도 있는 결제를 임의로 실패시키는 것보다, 재조회를 한 번 더 하는 쪽이 안전합니다.


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

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

그래서 이런 경우엔 곧바로 실패 처리하지 않고 그 자리에서 PG 거래를 한 번 더 즉시 조회합니다. 대부분은 이 재조회로 바로 완료 또는 실패가 확정됩니다. 그래도 결과가 불명확하거나 재조회 자체가 실패하면, 그때 비로소 TIMEOUT_UNKNOWN, 즉 결제 결과 확인 필요 상태로 관리합니다.

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

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

PG 승인은 성공했지만 내부 완료 상태 저장이 실패한 경우엔, 결제를 TIMEOUT_UNKNOWN으로 남겨 대사 대상으로 전환하는 동시에 그 사실을 Slack과 어드민 대시보드로 바로 알립니다. 이 알림은 DB에 기록하거나 재시도하지 않는 best-effort입니다 — 알림이 실패해도 "완료 상태 저장 실패"라는 사실 자체는 이미 TIMEOUT_UNKNOWN으로 남아 Batch가 복구하므로, 알림 전송만을 위한 별도의 재시도 인프라는 두지 않았습니다.


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

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

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

포인트 사용 확정과 재고 확정은 둘 다 "이 결제가 실제로 무엇을 판 것인가"를 정의하는 핵심 상태라 결제·주문 완료와 같은 트랜잭션에 둡니다. 이 중 하나라도 실패하면 전체가 롤백돼 부분 완료 상태가 남지 않습니다.

반면 다음 작업은 "돈의 유효성"과 무관한 부가 작업이라 커밋 이후 독립적으로 시도합니다.

  1. PG 결제 금액 기준 구매 포인트 적립(캐시백)
  2. 결제 완료 알림 발송(Slack, 어드민 대시보드, 실시간 이벤트)

하나가 실패해도 다음 작업은 계속 실행합니다. 부가 작업의 실패가 이미 확정된 결제를 되돌리지 않습니다. 포인트 적립이 실패하면 실패 이력을 남기고 Batch가 재시도하며, 알림은 실패해도 재시도하지 않는 best-effort로 둡니다 — 실패 사실 자체가 이미 다른 경로로 기록되기 때문입니다.

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

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

복구 작업담당 Batch Job중복 방지 기준완료 기준
결제 결과 대사paymentTimeoutReconciliationJobTIMEOUT_UNKNOWN 조건부 확정결제가 완료 또는 실패로 확정됨
포인트 적립 재시도pointEarnRetryJob같은 적립 로그의 조건부 선점적립 내역이 정확히 한 번만 반영됨
환불 PG 취소 재확인refundReconciliationJobPG 취소 확인 플래그PG 취소가 확인되거나 로컬 완료가 마무리됨
정산 생성 / 재계산settlementCreationJob결제별 정산 존재 여부 + 환불 반영 여부정산 금액이 최신 환불 상태와 일치함

영역별 Batch는 각 데이터에 맞는 멱등 조건을 사용합니다. 여러 인스턴스가 같은 실패 건을 처리하지 않도록 대상을 조건부로 선점하고, 중단된 건은 일정 시간 후 다시 가져옵니다. 알림 실패는 금액·재고 정합성을 바꾸지 않고, 그 실패가 통지하려는 사실 자체는 이미 다른 경로(TIMEOUT_UNKNOWN, 실패 로그 등)로 기록되기 때문에 별도의 자동 재시도 대상으로 두지 않았습니다.


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

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

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

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

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

PG 취소는 결제 승인과 마찬가지로 best-effort입니다. 취소 호출이 실패하면 그 자리에서 PG 거래를 즉시 재조회해 취소 여부를 확인하고, 그래도 확인이 안 되면 로컬 환불 처리는 그대로 완료하되 그 사실만 별도로 남겨 Batch가 나중에 다시 확인합니다. 로컬 환불 완료 자체는 PG 취소 확인 여부와 무관하게 끝나므로, 사용자에게는 지연 없이 환불이 처리됩니다.

환불도 주문·결제와 동일하게 클라이언트가 발급한 멱등키로 중복 요청을 막습니다. 다만 같은 키라도 어떤 주문에 대한 것인지가 중요하기 때문에, 캐시 네임스페이스와 DB 유니크 제약 모두 회원+주문 단위로 범위를 좁혔습니다.

캐시백(구매 포인트 적립)도 환불 비율만큼 회수합니다. 아직 지급 전이면 지급 예정액만 줄이고, 이미 지급됐으면 잔액에서 바로 차감해 이중 지급·이중 회수를 막습니다.

현재 구조는 동시 초과 환불 방지, 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 트랜잭션과 분리했습니다.
  • PG 거절은 HTTP 상태코드가 아니라 실제 에러 코드로 고객 귀책 여부를 판단했습니다.
  • 알 수 없는 결제는 실패로 단정하지 않고, 먼저 즉시 재조회로 확정을 시도한 뒤 그래도 안 되면 TIMEOUT_UNKNOWN으로 관리했습니다.
  • 포인트 사용과 재고 확정은 결제 완료와 묶고, 적립·알림은 독립 후처리로 분리했습니다.
  • 정산은 결제 응답 경로에서 제거하고 Batch가 완료 결제를 기준으로 생성·재계산하게 했습니다.
  • 환불은 주문·결제와 동일한 방식의 멱등키로 중복을 막고, 남은 환불 가능 금액으로 동시 초과 환불을 막았습니다. PG 취소도 결제 승인과 같은 패턴(즉시 재조회 + Batch 재확인)으로 확인합니다.
  • 실패해도 데이터에 영향이 없는 알림은 DB 기록이나 재시도 없이 best-effort로 남겼습니다 — 그 실패 사실 자체는 이미 다른 경로로 기록되기 때문입니다.

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

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

0개의 댓글