결제 요청부터 승인, 검증, 웹훅 처리까지 전체 시퀀스를 이해해요.
개별 API 사용법은 결제 요청, 결제 결과 수신, 결제 조회에서 확인해요. 이 문서는 전체 흐름을 한눈에 보여줘요.
이 문서는 "어떻게 구현하는가" 에 집중해요. "무엇을/왜" 는 블로그에서 다뤄요.
- 결제 완료 처리 설계 — 결제 조회·정합성 정책
- 결제 체크아웃 설계 — 체크아웃 UI·에러 흐름
- 결제 상태 흐름 — 가맹점 주문 상태와 Bootpay 상태 매핑
인증 vs 승인 — 핵심 구분
PG 결제는 보통 인증과 승인 구간을 나눠서 보면 이해하기 쉬워요. PG·결제수단마다 화면과 이벤트 이름은 다르지만, 운영에서는 “아직 돈이 빠지지 않은 승인 전 상태인지”와 “승인이 끝난 결제 완료 상태인지”를 구분해야 CS가 꼬이지 않아요.
| 인증 | 승인 | |
|---|---|---|
| 하는 일 | 본인확인, 결제수단 준비 | PG에 결제 확정 → 금액 차감 |
| 카드 결제 | 카드사 앱에서 인증 → 결제창으로 복귀 | 결제창에서 "결제하기" 버튼 → 카드사 승인 |
| 카카오페이 | 카카오페이 앱에서 비밀번호 입력, 잔액 부족 시 충전 | 결제 확정 → 카카오페이 잔액 차감 |
| 계좌이체 | 은행 앱에서 본인 인증 | 이체 실행 → 출금 |
| 여기서 중단되면? | 보통 결제 승인은 완료되지 않음 (아래 주의사항 참고) | 결제 완료 상태가 되고 receipt_id로 조회 가능 |
카카오페이로 결제할 때 잔액이 부족하면, 인증 단계에서 지정 계좌에서 카카오페이로 충전이 일어나요. 이후 구매자가 결제를 취소하거나 네트워크 이슈로 승인까지 가지 못하면, 카카오페이 잔액은 충전되었지만 실제 결제(승인)는 되지 않은 상태예요.
이 경우 구매자에게 "카카오페이 충전은 인증 과정에서 발생한 것이며, 결제 승인은 완료되지 않았다"고 안내해요. 충전된 카카오페이 잔액은 다음 결제에 사용할 수 있어요.
두 가지 승인 방식
| 서버 승인 (추천) | 클라이언트 승인 | |
|---|---|---|
| 설정 | extra.separately_confirmed: true |
기본값 |
| 승인 주체 | 가맹점 서버 | SDK 자동 |
| 장점 | 승인 전 서버 검증·재고·쿠폰 판단 가능 | 구현이 단순 |
| 주의점 | 프론트엔드에 onDone 안 옴 |
결과 이벤트 누락에 대비해 웹훅도 함께 운영 |
서버 승인은 운영 정합성 측면에서 권장하지만, 일부 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 -> 가맹점 서버: 웹훅 (done) | 실선 |
핵심 포인트
- 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);csharp3단계: 결과 표시 (프론트엔드)
결과 페이지에서 서버에 주문 상태를 조회해야 해요.
// 결과 페이지에서 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 -> 가맹점 서버: 웹훅 (done) | 실선 |
핵심 포인트
- SDK가 자동 승인 — 별도 설정 없이 PG 결제가 완료되면 SDK가 바로 승인해요
done이후 서버 검증 필요 — 프론트엔드에서 받은receipt_id를 백엔드로 전달하여 결제 조회을 수행해야 해요- 이벤트 누락에 대비 — 네트워크 문제, 앱 종료 등으로
done처리가 누락될 수 있으므로 웹훅을 함께 운영해야 해요
웹훅으로 이벤트 누락 보완
[정상] 프론트엔드 onDone → 백엔드 검증 → DB 저장
[누락] 부트페이 웹훅 (done) → 서버 검증 → DB 저장클라이언트 승인 방식은 승인과 검증 사이에 시간차가 있어, 그 사이에 재고가 변동될 수 있어요. 재고·쿠폰 정합성이 중요하다면 서버 승인 방식을 우선 검토해야 해요.
