참고

결제 흐름

인증과 승인 구간을 나눠 운영 판단 실수를 줄여봐요.

결제 요청부터 승인, 검증, 웹훅 처리까지 전체 시퀀스를 이해해요.

개별 API 사용법은 결제 요청, 결제 결과 수신, 결제 조회에서 확인해요. 이 문서는 전체 흐름을 한눈에 보여줘요.

개념·정책 배경 (먼저 정해야 할 것)

이 문서는 "어떻게 구현하는가" 에 집중해요. "무엇을/왜" 는 블로그에서 다뤄요.

인증 vs 승인 — 핵심 구분

PG 결제는 보통 인증​​과 승인 구간을 나눠서 보면 이해하기 쉬워요. PG·결제수단마다 화면과 이벤트 이름은 다르지만, 운영에서는 “아직 돈이 빠지지 않은 승인 전 상태인지”와 “승인이 끝난 결제 완료 상태인지”를 구분해야 CS가 꼬이지 않아요.

인증 승인
하는 일 본인확인, 결제수단 준비 PG에 결제 확정 → 금액 차감
카드 결제 카드사 앱에서 인증 → 결제창으로 복귀 결제창에서 "결제하기" 버튼 → 카드사 승인
카카오페이 카카오페이 앱에서 비밀번호 입력, 잔액 부족 시 충전 결제 확정 → 카카오페이 잔액 차감
계좌이체 은행 앱에서 본인 인증 이체 실행 → 출금
여기서 중단되면? 보통 결제 승인은 완료되지 않음 (아래 주의사항 참고) 결제 완료 상태가 되고 receipt_id로 조회 가능

분리 승인(extra.separately_confirmed: true)을 쓰면 결제 상태는 4(결제진행중) → 2(입금/승인대기) → 1(결제완료) 순서로 전이돼요. 2 에서 멈춰 있다면 아직 서버 승인 API가 호출되지 않은 상태예요.

"결제했는데 취소됐어요" — 인증과 승인의 혼동

카카오페이로 결제할 때 잔액이 부족하면, 인증 단계에서 지정 계좌에서 카카오페이로 충전이 일어나요. 이후 구매자가 결제를 취소하거나 네트워크 이슈로 승인까지 가지 못하면​, 카카오페이 잔액은 충전되었지만 실제 결제(승인)는 되지 않은 상태예요.

이 경우 구매자에게 "카카오페이 충전은 인증 과정에서 발생한 것이며, 결제 승인은 완료되지 않았다"고 안내해요. 충전된 카카오페이 잔액은 다음 결제에 사용할 수 있어요.

두 가지 승인 방식

서버 승인 (추천) 클라이언트 승인
설정 extra.separately_confirmed: true 기본값
승인 주체 가맹점 서버 SDK 자동
장점 승인 전 서버 검증·재고·쿠폰 판단 가능 구현이 단순
주의점 프론트엔드에 onDone 안 옴 결과 이벤트 누락에 대비해 웹훅도 함께 운영
클라이언트 승인을 아예 막고 싶다면

extra.confirm_only_rest_api: true 를 함께 보내면 REST 키가 아닌 클라이언트에서의 승인 호출이 차단돼요. 차단된 요청은 RC_CONFIRM_ONLY_REST_API 로 떨어지므로, 승인은 반드시 서버 REST API 로만 이뤄져요.

서버 승인은 운영 정합성 측면에서 권장하지만, 일부 PG·결제수단은 서버 승인 API를 지원하지 않을 수 있어요. 그 경우에는 클라이언트에서 승인한 뒤 서버에서 receipt_id로 조회·검증하는 흐름을 사용해요.

서버 승인 흐름 (권장)

참여자: 고객 | 가맹점 프론트 | 가맹점 서버 | Bootpay | PG사

단계 흐름 유형 비고
1 고객 -> 가맹점 프론트: 결제 요청 실선
2 가맹점 프론트 -> Bootpay: requestPayment() separately_confirmed: true 실선
3 Bootpay -> PG사: 결제 요청 실선
4 PG사 -> Bootpay: 결제 결과 점선
5 Bootpay -> 가맹점 프론트: onConfirm 이벤트 점선
6 가맹점 프론트 -> 가맹점 서버: receipt_id 전달 실선
7 가맹점 서버: 재고 확인 + 비즈니스 로직 실선 재고 확인 + 비즈니스 로직
8 가맹점 서버 -> Bootpay: 승인 API 호출 실선
9 Bootpay -> 가맹점 서버: 승인 완료 점선
10 가맹점 서버: DB 저장 실선 DB 저장
11 가맹점 프론트 -> 고객: 결과 페이지 (서버 DB 조회) 점선
12 Bootpay -> 가맹점 서버: 웹훅 (PAYMENT_COMPLETED) 실선

핵심 포인트

  1. PG 승인 전에 서버 로직 실행onConfirm 시점에서 재고 확인, 쿠폰 차감 등을 처리한 뒤 승인 API를 호출해야 해요
  2. 클라이언트에 onDone 없음 — 서버가 승인하므로 클라이언트는 결제 결과를 직접 받지 못해요. 결과 페이지에서 서버 DB를 polling해야 해요
  3. 정합성 관리가 쉬움 — 재고 차감과 결제 승인을 서버 흐름 안에서 묶어 처리하므로, 클라이언트 승인 방식보다 주문 정합성을 맞추기 쉬워요

단계별 상세

1단계: 결제 요청 (프론트엔드)

extra.separately_confirmed: true로 결제창을 열어요.

const response = await Bootpay.requestPayment({
    client_key: '[ Client Key ]',
    price: 1000,
    order_name: '상품명',
    order_id: 'order_' + Date.now(), // 테스트용. 실제로는 서버에서 생성한 주문번호 사용
    extra: {
        separately_confirmed: true
    }
})javascript

2단계: 서버 승인 (가맹점 서버)

onConfirm에서 받은 receipt_id를 서버로 전달해야 해요. 서버는 비즈니스 로직을 처리한 뒤 Bootpay 승인 API를 호출해야 해요.

const confirmed = await Bootpay.confirmPayment(receipt_id)

await db.orders.update({
    status: 'done',
    bootpay_receipt_id: receipt_id,
    paid_amount: confirmed.price
}, { where: { id: order_id } })javascript

승인 API 호출에서 자주 만나는 응답이에요.

error_code HTTP 의미와 처리
RC_NOT_CONFIRM_READY 400 승인 대기(2) 상태가 아니에요. 결제 상태를 조회해 실제 흐름을 확인해요
RC_CONFIRM_ALREADY_COMPLETE 201 승인 직후 10초 이내의 재요청이에요. 성공으로 간주​​하고 결제 조회로 마무리해요
RC_TIMEOUT 400 결제 유효시간이 지났어요. 결제를 다시 요청해야 해요
RC_COMPLETED 400 이미 결제 진행이 완료된 건이에요. 주문 상태를 확인해요
RC_CONFIRM_NOT_PERMIT 400 결제 요청과 승인 요청의 인증 키가 같은 프로젝트가 아니에요

전체 코드 목록은 오류 코드에서 확인해요.

3단계: 결과 표시 (프론트엔드)

결과 페이지에서 서버에 주문 상태를 조회해야 해요.

// 결과 페이지에서 polling
const checkResult = async (orderId) => {
    const res = await fetch(`/api/orders/${orderId}/status`)
    const data = await res.json()

    if (data.status === 'done') {
        // 결제 완료 표시
    } else {
        // 아직 처리 중 — 재조회
        setTimeout(() => checkResult(orderId), 1000)
    }
}javascript

클라이언트 승인 흐름

참여자: 고객 | 가맹점 프론트 | 가맹점 서버 | Bootpay | PG사

단계 흐름 유형 비고
1 고객 -> 가맹점 프론트: 결제 요청 실선
2 가맹점 프론트 -> Bootpay: requestPayment() 실선
3 Bootpay -> PG사: 결제 요청 실선
4 PG사 -> Bootpay: 결제 결과 점선
5 Bootpay: 자동 승인 실선 자동 승인
6 Bootpay -> 가맹점 프론트: onDone 이벤트 점선
7 가맹점 프론트 -> 가맹점 서버: receipt_id 전달 실선
8 가맹점 서버 -> Bootpay: 결제 조회 (GET) 실선
9 Bootpay -> 가맹점 서버: 검증 결과 점선
10 가맹점 서버: DB 저장 실선 DB 저장
11 가맹점 서버 -> 가맹점 프론트: 결과 반환 점선
12 가맹점 프론트 -> 고객: 완료 페이지 점선
13 Bootpay -> 가맹점 서버: 웹훅 (PAYMENT_COMPLETED) 실선

핵심 포인트

  1. SDK가 자동 승인 — 별도 설정 없이 PG 결제가 완료되면 SDK가 바로 승인해요
  2. done 이후 서버 검증 필요 — 프론트엔드에서 받은 receipt_id를 백엔드로 전달하여 결제 조회을 수행해야 해요
  3. 이벤트 누락에 대비 — 네트워크 문제, 앱 종료 등으로 done 처리가 누락될 수 있으므로 웹훅을 함께 운영해야 해요

웹훅으로 이벤트 누락 보완

[정상]  프론트엔드 onDone → 백엔드 검증 → DB 저장
[누락]  부트페이 웹훅(PAYMENT_COMPLETED) → 서버 검증 → DB 저장

웹훅 본문에는 결제 조회 응답과 같은 필드에 더해 아래 값이 들어와요.

필드 설명
webhook_type 어떤 이벤트로 발송된 웹훅인지 구분해요
application_id 결제가 일어난 프로젝트의 앱키 ID
billing_key 빌링키로 진행된 결제인 경우에만 포함돼요

webhook_type 값은 다음과 같아요.

발송 시점
PAYMENT_COMPLETED 결제 완료
PAYMENT_CANCELLED 전액 취소 (남은 금액 0원)
PAYMENT_PARTIAL_CANCELLED 부분 취소 (남은 금액 있음)
PAYMENT_VIRTUAL_ACCOUNT_ISSUED 가상계좌 발급 완료
AUTHENTICATION_COMPLETED 본인인증 완료
PAYMENT_EXPIRED 결제 유효시간 만료
ESCROW_STATUS_CHANGED 에스크로 상태 변경
PAYMENT_CONFIRM_FAILED 승인 실패 (오류 웹훅)
PAYMENT_CANCEL_FAILED 취소 실패 (오류 웹훅)
PAYMENT_REQUEST_FAILED 결제 요청 실패 (오류 웹훅)
웹훅은 URL 설정이 있어야 발송돼요
  • 성공 웹훅​​도 결제 요청의 extra.feedback_url 또는 관리자 > 개발자 설정에 등록한 Webhook URL 중 하나가 있어야 발송돼요. 둘 다 없으면 아무것도 오지 않아요.
  • 오류 웹훅​(PAYMENT_CONFIRM_FAILED, PAYMENT_CANCEL_FAILED, PAYMENT_REQUEST_FAILED)은 기본적으로 꺼져 있어요​. 결제 요청에 extra.enable_error_webhook: true 를 넣거나 프로젝트 설정에서 오류 웹훅을 켜야 받을 수 있어요.
  • PAYMENT_EXPIRED 도 기본은 꺼져 있어요. extra.common_event_webhook: true 를 넣어야 발송돼요.
  • webhook_type 은 SDK/API 버전 2.5 이상에서만 내려와요.

클라이언트 승인 방식은 승인과 검증 사이에 시간차가 있어, 그 사이에 재고가 변동될 수 있어요. 재고·쿠폰 정합성이 중요하다면 서버 승인 방식을 우선 검토해야 해요.