결제 요청부터 승인, 검증, 웹훅 처리까지 전체 시퀀스를 이해해요.
개별 API 사용법은 결제 요청, 결제 결과 수신, 결제 조회에서 확인해요. 이 문서는 전체 흐름을 한눈에 보여줘요.
이 문서는 "어떻게 구현하는가" 에 집중해요. "무엇을/왜" 는 블로그에서 다뤄요.
- 결제 완료 처리 설계 — 결제 조회·정합성 정책
- 결제 체크아웃 설계 — 체크아웃 UI·에러 흐름
- 결제 상태 흐름 — 가맹점 주문 상태와 Bootpay 상태 매핑
인증 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) |
실선 |
핵심 포인트
- PG 승인 전에 서버 로직 실행 —
onConfirm시점에서 재고 확인, 쿠폰 차감 등을 처리한 뒤 승인 API를 호출해야 해요 - 클라이언트에
onDone없음 — 서버가 승인하므로 클라이언트는 결제 결과를 직접 받지 못해요. 결과 페이지에서 서버 DB를 polling해야 해요 - 정합성 관리가 쉬움 — 재고 차감과 결제 승인을 서버 흐름 안에서 묶어 처리하므로, 클라이언트 승인 방식보다 주문 정합성을 맞추기 쉬워요
단계별 상세
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
}
})javascript2단계: 서버 승인 (가맹점 서버)
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 } })javascriptconfirmed = bootpay.confirm_payment(receipt_id)
order.update(
status='done',
bootpay_receipt_id=receipt_id,
paid_amount=confirmed['price'],
)python$confirmed = BootpayApi::confirmPayment($receiptId);
$order->update([
'status' => 'done',
'bootpay_receipt_id' => $receiptId,
'paid_amount' => $confirmed['price'],
]);phpvar confirmed = bootpay.confirm(receiptId);
order.setStatus("done");
order.setBootpayReceiptId(receiptId);
order.setPaidAmount(((Number) confirmed.get("price")).doubleValue());
orderRepository.save(order);javaconfirmed = bootpay.confirm(receipt_id).data
order.update(
status: 'done',
bootpay_receipt_id: receipt_id,
paid_amount: confirmed['price']
)rubyconfirmed, err := api.ServerConfirm(receiptId)
if err != nil {
return err
}
markOrderDone(orderId, receiptId, confirmed["price"])govar response = await bootpay.Confirm(receiptId);
var confirmed = JsonConvert.DeserializeObject<Dictionary<string, object>>(
await response.Content.ReadAsStringAsync()
);
order.Status = "done";
order.BootpayReceiptId = receiptId;
order.PaidAmount = Convert.ToDecimal(confirmed["price"]);
await orderRepository.SaveAsync(order);csharp승인 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) |
실선 |
핵심 포인트
- SDK가 자동 승인 — 별도 설정 없이 PG 결제가 완료되면 SDK가 바로 승인해요
done이후 서버 검증 필요 — 프론트엔드에서 받은receipt_id를 백엔드로 전달하여 결제 조회을 수행해야 해요- 이벤트 누락에 대비 — 네트워크 문제, 앱 종료 등으로
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 |
결제 요청 실패 (오류 웹훅) |
- 성공 웹훅도 결제 요청의
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 이상에서만 내려와요.
클라이언트 승인 방식은 승인과 검증 사이에 시간차가 있어, 그 사이에 재고가 변동될 수 있어요. 재고·쿠폰 정합성이 중요하다면 서버 승인 방식을 우선 검토해야 해요.
