이 문서는 결제위젯을 주문서에 빠르게 붙이기 위한 매뉴얼이에요. 위젯 생성·디자인·운영 옵션은 위젯 생성과 단건결제위젯 연동에서 더 자세히 다뤄요.
아래 순서대로 진행하면 주문서 화면에서 결제위젯을 렌더링하고 서버에서 결제 결과를 확인하는 기본 흐름을 만들 수 있어요. 관리자는 위젯에 노출할 결제수단과 스타일을 설정하고, 사용자는 주문서에 렌더링된 위젯 안에서 결제수단을 선택해요. 백엔드는 주문 확인과 상태 저장을 담당해요.
데모 영상
결제창 vs 결제위젯
| 결제창 | 결제위젯 | |
|---|---|---|
| 결제수단 선택 | 코드에서 pg, method 지정 |
사용자가 위젯에서 선택 |
| UI | PG사 결제창만 | 결제수단 선택 UI + 약관 동의 포함 |
| 결제수단 관리 | 코드 수정 필요 | 관리자에서 노출 수단 활성화/비활성화 및 스타일 설정 |
| 추천 | 결제수단이 1~2개로 고정 | 여러 결제수단을 유연하게 관리 |
완성 후 모습
위젯은 관리자가 활성화한 결제수단을 주문서 화면에 보여주고, 사용자는 그 안에서 결제수단 선택과 약관 동의를 진행해요. 결제 완료 후 receipt_id를 받은 뒤부터는 백엔드가 결제 정보를 검증하고 주문을 확정해야 해요.
이 빠른 매뉴얼의 기본 예제는 클라이언트 자동 승인 후 done으로 receipt_id를 받는 흐름이에요. 운영에서 extra.separately_confirmed: true를 쓰면 done 대신 confirm이 호출되고, 클라이언트의 onDone은 호출되지 않아요. 이때는 confirm에서 receipt_id를 서버로 보내고 서버가 승인 API를 호출해야 해요.
사전 준비
결제 설정에서 사용할 PG사와 결제수단을 먼저 활성화해요. 그다음 위젯 설정에서 사용할 위젯을 만들고, 생성된 widget_key를 연동 코드에 넣어요.
구현 순서
SDK 설치 (프론트엔드)
플랫폼별 표준 패키지 매니저로 설치해요.
<!-- Bootpay JS SDK (CDN) -->
<script src="https://js.bootpay.co.kr/bootpay-5.3.0.min.js"></script>
<!-- 또는 npm -->
<!-- npm install @bootpay/client-js -->html// Xcode -> File -> Add Package Dependencies
// Package URL: https://github.com/bootpay/ios_swift.git
// Dependency Rule: Up to Next Major
//
// 또는 Package.swift에 직접 추가:
dependencies: [
.package(url: "https://github.com/bootpay/ios_swift.git", from: "5.1.1")
]swift// app/build.gradle (Module: app)
dependencies {
implementation 'io.github.bootpay:android:5.1.1'
}flutter pub add bootpay
# pubspec.yaml의 dependencies에 bootpay: ^5.1.2가 추가된다bashnpm install react-native-bootpay-api
# iOS는 pod install까지 실행
cd ios && pod installbash위젯 렌더링 (프론트엔드)
사용 중인 SDK 탭에서 widget_key를 어디에 넘기는지 확인해요.
<!-- 위젯이 렌더링될 영역 -->
<div id="widget"></div>
<button id="pay-btn" disabled onclick="requestPayment()">결제하기</button>
<script>
let widgetData = null
const payButton = document.getElementById('pay-btn')
function updateButtonState(data) {
widgetData = data
// completed + term_passed 가 모두 true 일 때만 결제 버튼을 활성화한다.
payButton.disabled = !(data?.completed && data?.term_passed)
}
document.addEventListener('bootpay-widget-ready', () => {
// 위젯 SDK가 준비된 시점이다.
console.log('위젯 준비 완료')
})
document.addEventListener('bootpay-widget-change-payment', (e) => {
// 결제수단 변경 이벤트. e.detail 안에 pg, method, easy_pay, card_quota 등이 들어온다.
updateButtonState(e.detail)
})
document.addEventListener('bootpay-widget-change-terms', (e) => {
// 약관 동의 변경 이벤트. e.detail.term_passed 로 필수 약관 통과 여부를 확인한다.
updateButtonState(e.detail)
})
document.addEventListener('bootpay-widget-resize', (e) => {
// 위젯 높이가 바뀌면 컨테이너 높이도 같이 맞춘다.
document.getElementById('widget').style.height = `${e.detail.height}px`
})
// JavaScript는 BootpayWidget.render(...)에 widget_key를 넘겨요.
BootpayWidget.render('#widget', {
client_key: 'YOUR_CLIENT_KEY',
price: 1000,
widget_key: 'default-widget',
sandbox: true,
use_terms: true
})
</script>htmllet payload = Payload()
// iOS는 Payload 객체에 widgetKey를 채워요.
payload.clientKey = "YOUR_CLIENT_KEY"
payload.price = 1000
payload.orderName = "테스트 상품"
payload.orderId = String(Int(Date().timeIntervalSince1970 * 1000))
payload.widgetKey = "default-widget"
payload.widgetSandbox = true
payload.widgetUseTerms = true
let widgetController = BootpayWidgetController()
widgetController.onResize = { height in
// 위젯 높이 변경 이벤트. AutoLayout 높이 제약을 같이 갱신한다.
widgetHeightConstraint.constant = height
}
widgetController.onChangePayment = { data in
// 결제수단 변경 이벤트. 선택된 pg/method 정보를 payload에 병합한다.
payload.mergeWidgetData(data)
}
widgetController.onChangeAgreeTerm = { data in
// 약관 변경 이벤트. payload.widgetIsCompleted 판정에 필요한 상태를 병합한다.
payload.mergeWidgetData(data)
}
let widgetView = BootpayWidgetView()
widgetView.controller = widgetController
widgetView.payload = payload
widgetView.startWidget()swiftPayload payload = new Payload();
// Android는 Payload setter로 widgetKey를 채워요.
payload.setClientKey("YOUR_CLIENT_KEY")
.setOrderName("테스트 상품")
.setOrderId("order_" + System.currentTimeMillis())
.setPrice(1000d)
.setWidgetKey("default-widget")
.setWidgetSandbox(true)
.setWidgetUseTerms(true);
BootpayWidget.bindViewUpdate(this, getSupportFragmentManager(), webViewContainer);
BootpayWidget.renderWidget(this, payload, new BootpayWidgetEventListener() {
@Override
public void onWidgetResize(double height) {
// 위젯 높이 변경 이벤트. 컨테이너 높이를 같이 갱신한다.
}
@Override
public void onWidgetReady() {
// 위젯 준비 완료 이벤트.
}
@Override
public void onWidgetChangePayment(WidgetData data) {
// 결제수단 변경 이벤트. 선택된 pg/method 정보를 payload에 병합한다.
payload.mergeWidgetData(data);
}
@Override
public void onWidgetChangeAgreeTerm(WidgetData data) {
// 약관 변경 이벤트. payload.getWidgetIsCompleted() 판정에 필요한 상태를 병합한다.
payload.mergeWidgetData(data);
}
@Override
public void needReloadWidget() {
// 에러·취소 후 위젯 재렌더링이 필요할 때 호출된다.
}
});java// Flutter는 Payload 객체에 widgetKey를 채워요.
final payload = Payload()
..webApplicationId = BootpayEnvConfig.webApplicationId
..androidApplicationId = BootpayEnvConfig.androidApplicationId
..iosApplicationId = BootpayEnvConfig.iosApplicationId
..price = 1000
..orderName = '테스트 상품'
..orderId = DateTime.now().millisecondsSinceEpoch.toString()
..widgetKey = 'default-widget'
..widgetSandbox = true
..widgetUseTerms = true;
final controller = BootpayWidgetController()
// 위젯 높이 변경 이벤트. SizedBox 등 위젯 컨테이너 높이를 같이 갱신한다.
..onWidgetResize = (height) => setState(() => widgetHeight = height)
// 결제수단 변경 이벤트. 선택된 pg/method 정보를 payload에 병합한다.
..onWidgetChangePayment = (data) => setState(() => payload.mergeWidgetData(data))
// 약관 변경 이벤트. payload.widgetIsCompleted 판정에 필요한 상태를 병합한다.
..onWidgetChangeAgreeTerm = (data) => setState(() => payload.mergeWidgetData(data));
return BootpayWidget(payload: payload, controller: controller);dartconst bootpayWidget = useRef(null)
const payload = {
// React Native는 WidgetPayload에 widget_key를 채워요.
widget_key: 'default-widget',
widget_sandbox: true,
widget_use_terms: true,
order_name: '테스트 상품',
order_id: `order_${Date.now()}`,
price: 1000,
}
const onWidgetReady = () => {
// RN은 위젯 준비 완료 후 renderWidget(payload)를 명시적으로 호출한다.
bootpayWidget.current?.renderWidget(payload)
}
const onWidgetResize = (height) => {
// 위젯 높이 변경 이벤트. placeholder 높이를 같이 갱신한다.
setWidgetHeight(height)
}
const onWidgetChangePayment = (data) => {
// 결제수단 변경 이벤트. data.completed 값으로 결제 가능 여부를 확인한다.
setWidgetData(data)
}
const onWidgetChangeTerms = (data) => {
// 약관 변경 이벤트. data.term_passed 값으로 필수 약관 통과 여부를 확인한다.
setWidgetData(data)
}
<BootpayWidget
ref={bootpayWidget}
client_key={CLIENT_KEY}
onWidgetReady={onWidgetReady}
onWidgetResize={onWidgetResize}
onWidgetChangePayment={onWidgetChangePayment}
onWidgetChangeTerms={onWidgetChangeTerms}
/>javascript상세 문서: 위젯 렌더링 옵션
위젯의 결제수단이나 약관 상태가 바뀌면 아래와 비슷한 데이터가 이벤트로 전달돼요.
{
"pg": "nicepay",
"method": "card",
"method_symbol": "card",
"easy_pay": null,
"card_quota": "0",
"currency": "KRW",
"completed": true,
"term_passed": true,
"select_terms": [
{
"pk": "terms-privacy",
"title": "개인정보 수집 및 이용 동의",
"agree": true,
"term_type": 1
}
],
"extra": {
"card_quota": 0
}
}json결제 요청 (프론트엔드)
사용자가 위젯에서 결제수단 선택과 약관 동의를 완료하면, 각 SDK의 위젯 결제 요청 API를 호출해야 해요. 완료 이벤트에서 받은 receipt_id는 백엔드로 보내 결제 조회를 진행해야 해요.
완료 이벤트 데이터는 상태별로 아래처럼 들어와요. 결제창과 결제위젯의 최종 조회 응답은 같으므로, 서버에서는 결제 조회 응답 예시의 필드를 기준으로 주문을 확정해요.
{
"event": "done",
"receipt_id": "6244f60c1fc19202e42e8c4e",
"order_id": "order_20260428_001",
"price": 1000,
"status": 1,
"method": "card",
"method_symbol": "card"
}json{
"event": "confirm",
"receipt_id": "6244f60c1fc19202e42e8c4e",
"order_id": "order_20260428_001",
"price": 1000,
"method": "card"
}json{
"event": "issued",
"receipt_id": "6244f60c1fc19202e42e8c4e",
"order_id": "order_20260428_001",
"method": "vbank",
"status": 5
}json{
"event": "error",
"error_code": "RC_REQUEST_FAILED",
"message": "결제 요청이 실패했다."
}jsonasync function requestPayment() {
const response = await BootpayWidget.requestPayment({
order_name: '테스트 상품',
order_id: 'order_' + Date.now(),
user: { username: '홍길동', phone: '01012345678' },
extra: { open_type: 'iframe' }, // iframe, popup, redirect
})
if (response.event === 'done') {
// 결제 완료 이벤트. receipt_id를 백엔드로 보내 결제 조회를 진행한다.
await verifyPayment(response.receipt_id)
}
if (response.event === 'issued') {
// 가상계좌 발급 이벤트. 입금 완료는 웹훅으로 최종 확정한다.
}
if (response.event === 'cancel' || response.event === 'error') {
// 구매자 취소 또는 결제 실패 이벤트. 주문은 미결제 상태로 유지한다.
}
}
async function verifyPayment(receiptId) {
const res = await fetch('/api/server/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ receipt_id: receiptId }),
})
const { success } = await res.json()
alert(success ? '결제 완료!' : '결제 조회 실패')
}javascriptguard payload.widgetIsCompleted else { return }
widgetController.onDone = { data in
// 결제 완료 이벤트. receipt_id를 백엔드로 보내 결제 조회를 진행한다.
self.verifyPayment(receiptId: data["receipt_id"] as? String ?? "")
}
widgetController.onIssued = { data in
// 가상계좌 발급 이벤트. 입금 완료는 웹훅으로 최종 확정한다.
}
widgetController.onError = { data in
// 결제 실패 이벤트. 실패 사유를 기록하고 위젯 재로드 여부를 결정한다.
}
widgetController.onCancel = { data in
// 구매자 취소 이벤트. 주문은 미결제 상태로 유지한다.
}
widgetController.requestPayment(payload: payload)swiftif (!payload.getWidgetIsCompleted()) return;
BootpayWidget.requestPayment(
this,
getSupportFragmentManager(),
payload,
new BootpayEventListener() {
@Override
public void onDone(String data) {
// 결제 완료 이벤트. receipt_id를 백엔드로 보내 결제 조회를 진행한다.
verifyPayment(data);
}
@Override public void onIssued(String data) {
// 가상계좌 발급 이벤트. 입금 완료는 웹훅으로 최종 확정한다.
}
@Override public void onCancel(String data) {
// 구매자 취소 이벤트. 주문은 미결제 상태로 유지한다.
}
@Override public void onError(String data) {
// 결제 실패 이벤트. 실패 사유를 기록하고 위젯 재로드 여부를 결정한다.
}
@Override public void onClose() {
// 결제창 닫힘 이벤트. UI를 정리한다.
BootpayWidget.removePaymentWindow();
}
@Override public boolean onConfirm(String data) {
// 승인 전 확인 이벤트. 필요하면 서버 사전 검증 후 true를 반환한다.
return true;
}
}
);java// 위젯 컨트롤러로 위젯 결과를 그대로 결제 요청에 사용
controller.requestPaymentDirect(
payload: payload,
onDone: (data) {
// 결제 완료 이벤트. receipt_id를 백엔드 결제 조회 API로 전송한다.
verifyPayment(data);
},
// 결제 실패 이벤트. 실패 사유를 기록하고 위젯 재로드 여부를 결정한다.
onError: (data) => debugPrint('error: $data'),
// 구매자 취소 이벤트. 주문은 미결제 상태로 유지한다.
onCancel: (data) => debugPrint('cancel: $data'),
// 결제창 닫힘 이벤트. UI를 정리한다.
onClose: () {},
// 승인 전 확인 이벤트. 필요하면 서버 사전 검증 후 true를 반환한다.
onConfirm: (data) => true,
// 가상계좌 발급 이벤트. 입금 완료는 웹훅으로 최종 확정한다.
onIssued: (data) => debugPrint('issued: $data'),
);dartconst isCompleted = widgetData?.term_passed && widgetData?.completed
function requestPayment() {
if (!isCompleted) return
bootpayWidget.current?.requestPayment()
}
function onConfirm(data) {
// 승인 전 확인 이벤트. 필요하면 서버 사전 검증 후 승인한다.
return true
}
function onDone(data) {
// 결제 완료 이벤트. receipt_id를 백엔드로 보내 결제 조회를 진행한다.
verifyPayment(data.receipt_id)
}
function onIssued(data) {
// 가상계좌 발급 이벤트. 입금 완료는 웹훅으로 최종 확정한다.
savePendingVbankOrder(data)
}
function onError(data) {
// 결제 실패 이벤트. 실패 사유를 기록하고 위젯 재로드 여부를 결정한다.
logPaymentError(data)
}
function onCancel(data) {
// 구매자 취소 이벤트. 주문은 미결제 상태로 유지한다.
keepOrderUnpaid(data)
}
<BootpayWidget
ref={bootpayWidget}
onConfirm={onConfirm}
onDone={onDone}
onIssued={onIssued}
onError={onError}
onCancel={onCancel}
onClose={resetPaymentUi}
/>javascript위젯에서는 pg, method를 코드에 하드코딩하지 않아요. 관리자가 활성화한 결제수단이 위젯에 표시되고, 사용자가 선택한 결과를 그대로 결제 요청 페이로드에 합쳐요.
상세 문서: 결제위젯 가이드
서버 SDK 설치 (백엔드)
PG 결제창 연동 → SDK 설치의 서버 SDK 단계를 먼저 완료해요.
결제 조회 (백엔드)
결제창과 동일한 SDK 호출이에요. 서버에서는 조회한 결제 정보의 status가 결제 완료 상태인지 확인하고, DB에 저장해 둔 price, 주문 상태 등 기준값이 Bootpay 응답값과 일치하는지도 확인해요. 응답 필드는 결제 조회 응답 예시의 JSON과 같아요.
app.post('/api/server/verify', async (req, res) => {
const { receipt_id } = req.body
const receipt = await Bootpay.receiptPayment(receipt_id)
if (receipt.status === 1 && receipt.price === 1000) {
await db.payments.create({
receipt_id,
price: receipt.price,
method: receipt.method_symbol,
paid_at: receipt.purchased_at,
})
res.json({ success: true })
} else {
await Bootpay.cancelPayment({
receipt_id,
cancel_price: receipt.price,
cancel_message: '검증 실패',
})
res.json({ success: false })
}
})javascript# Flask·Django 핸들러 안에서
receipt = bootpay.receipt_payment(receipt_id)
if receipt['status'] == 1 and receipt['price'] == 1000:
# DB 저장
pass
else:
bootpay.cancel_payment(
receipt_id=receipt_id,
cancel_price=receipt['price'],
cancel_message='검증 실패',
)python// Laravel·Slim 컨트롤러 안에서
$receipt = BootpayApi::receiptPayment($receipt_id);
if ($receipt['status'] === 1 && $receipt['price'] === 1000) {
// DB 저장
} else {
BootpayApi::cancelPayment([
'receipt_id' => $receipt_id,
'cancel_price' => $receipt['price'],
'cancel_message' => '검증 실패',
]);
}php// Spring 컨트롤러 안에서
var receipt = bootpay.getReceipt(receiptId);
if (((Number) receipt.get("status")).intValue() == 1 && ((Number) receipt.get("price")).intValue() == 1000) {
// DB 저장
} else {
Cancel cancel = new Cancel();
cancel.receiptId = receiptId;
cancel.cancelPrice = ((Number) receipt.get("price")).doubleValue();
cancel.cancelUsername = "시스템";
cancel.cancelMessage = "검증 실패";
bootpay.receiptCancel(cancel);
}java# Rails 컨트롤러 안에서
receipt = bootpay.verify(receipt_id).data
if receipt['status'] == 1 && receipt['price'] == 1000
# DB 저장
else
bootpay.cancel_payment(
receipt_id: receipt_id,
cancel_price: receipt['price'],
cancel_message: '검증 실패',
)
endruby// net/http·Gin 핸들러 안에서
receipt, err := api.GetReceipt(receiptID)
if err != nil { return }
status := int(receipt["status"].(float64))
price := receipt["price"].(float64)
if status == 1 && price == 1000 {
// DB 저장
} else {
_, _ = api.ReceiptCancel(bootpay.CancelData{
ReceiptId: receiptID, CancelPrice: price, CancelUsername: "시스템", CancelMessage: "검증 실패",
})
}go// ASP.NET Core 컨트롤러 안에서
var response = await bootpay.GetReceipt(receiptId);
var body = await response.Content.ReadAsStringAsync();
var receipt = JsonConvert.DeserializeObject<Dictionary<string, object>>(body);
var status = Convert.ToInt32(receipt["status"]);
var price = Convert.ToDouble(receipt["price"]);
if (status == 1 && price == 1000) {
// DB 저장
} else {
await bootpay.ReceiptCancel(new Cancel {
receiptId = receiptId, cancelPrice = price, cancelUsername = "시스템", cancelMessage = "검증 실패",
});
}csharp상세 문서: 결제 조회
