결제위젯은 주문서 페이지 안에 결제수단 선택 UI를 먼저 렌더링하고, 구매자가 선택을 마치면 주문하기 버튼으로 결제를 요청하는 방식이에요. 쿠폰, 적립금, 결제수단별 프로모션처럼 주문서 로직이 복잡할수록 PG 결제창만 띄우는 방식보다 결제위젯이 관리하기 쉬워요.
이 문서의 목표는 화면에 위젯을 띄우는 것에서 끝나지 않아요. 독자가 아래 흐름을 따라 주문서에서 결제 완료를 안전하게 확정하는 지점까지 도달하도록 구성했어요.
| 독자 상황 | 먼저 볼 내용 | 완성 기준 |
|---|---|---|
| 처음 붙이는 개발자 | 첫 결제까지 붙이기 | 위젯 렌더링 → 결제 버튼 활성화 → 결제 요청 → 인증 결과 수신 → 결과 확인까지 연결 |
| 운영 주문서를 만드는 개발자 | 주문서 전체 흐름으로 합치기 | 쿠폰·적립금 변경, 인증 결과 수신, 결제 승인 요청, 결과 페이지 이동까지 연결 |
| 특정 SDK 이슈를 확인하는 개발자 | 이벤트·금액 업데이트·플랫폼별 상세 | SDK별 콜백, 높이 조정, 재로드/종료 처리를 확인 |
전체 구조보다 최소 E2E 코드가 먼저 필요하면 빠른 매뉴얼의 결제위젯을 먼저 봐요. 이 페이지는 전체 플랫폼(Android/iOS/Flutter/RN)과 운영 옵션까지 확인하는 상세 문서예요.

0연동 전에 준비할 것
이 문서는 주문서 화면에 단건결제위젯을 렌더링하고, 결제 요청부터 서버 조회·주문 확정까지 연결하는 연동 가이드예요. 관리자에서 해야 하는 위젯 생성·결제수단·디자인 설정은 위젯 생성에서 먼저 끝내요.
여기서 “준비한다”는 말은 Bootpay 관리자에서 발급·설정할 값과 가맹점 백엔드에서 직접 구현할 API를 나눠 둔다는 뜻이에요.
| 구분 | 준비 항목 | 누가 준비하나 | 확인 위치 |
|---|---|---|---|
| Bootpay 설정 | PG·결제수단 활성화 | Bootpay 관리자에서 설정 | 결제 설정, 위젯 생성 |
| Bootpay 설정 | client_key |
Bootpay 관리자에서 발급 | 연동키 발급 |
| Bootpay 설정 | widget_key |
Bootpay 관리자에서 위젯 생성 시 발급 | 위젯 생성 |
| 가맹점 서버 | 주문 준비 API | 가맹점 백엔드에서 구현 | 아래 주문서 전체 흐름 예제의 /api/payment/prepare |
| 가맹점 서버 | 결제 조회 API | 가맹점 백엔드에서 Bootpay 조회 API를 호출하도록 구현 | 결제 조회 |
| 가맹점 서버 | 서버 승인 API | 서버 승인 모드를 쓸 때 가맹점 백엔드에서 구현 | 분리 승인 |
결제수단 노출, PG 우선순위, 약관, 색상, 레이아웃은 관리자에서 조정해요. 이 문서에서는 SDK 렌더링, 이벤트 처리, 결제 요청, 인증 결과, 승인 요청, 결과 수신만 다뤄요.
1첫 결제까지 붙이기
이 구간은 결제위젯을 주문서에 붙이는 최소 여정이에요. 먼저 SDK를 준비하고, 위젯을 렌더링한 뒤, 결제수단 선택과 약관 동의가 끝났을 때만 결제 버튼을 활성화해요. 이후 흐름은 결제 요청 → 인증 결과 수신 → 결제 승인 요청 → 결제 결과 수신 순서로 나눠 봐요.
1-1. SDK 설치
PG 결제창 연동 → SDK 설치를 먼저 완료해요. Client Key가 아직 없다면 연동키 발급도 함께 끝내요.
1-2. 위젯 렌더링
주문서 페이지에 결제위젯을 렌더링해요. 이 단계의 목적은 결제를 바로 요청하는 것이 아니라, 구매자가 결제수단을 선택하고 약관 동의 상태를 만들 수 있게 하는 거예요.
import { BootpayWidget } from '@bootpay/client-js'
function updateButtonState(data) {
document.getElementById('pay-button').disabled =
!(data?.completed && data?.term_passed)
}
document.addEventListener('bootpay-widget-ready', () => {
console.log('위젯 렌더링 완료')
})
document.addEventListener('bootpay-widget-change-payment', (e) => {
console.log('선택된 결제수단:', e.detail.method)
updateButtonState(e.detail)
})
document.addEventListener('bootpay-widget-change-terms', (e) => {
updateButtonState(e.detail)
})
BootpayWidget.render('#bootpay-widget', {
client_key: '[ Client Key ]', // Client Key
widget_key: 'default-widget', // 관리자에서 만든 단건결제 위젯 키
price: 1000, // 결제 금액
order_name: '테스트 상품', // 주문명
order_id: 'ORD-' + Date.now(), // 주문번호
sandbox: true, // 샌드박스 모드
use_terms: true // 약관 동의 UI 표시
})javascript<div id="bootpay-widget"></div>
<button id="pay-button" disabled>결제하기</button>
<script src="https://js.bootpay.co.kr/bootpay-5.3.0.min.js"></script>
<script>
function updateButtonState(data) {
document.getElementById('pay-button').disabled =
!(data?.completed && data?.term_passed)
}
document.addEventListener('bootpay-widget-ready', function() {
console.log('결제위젯 준비 완료')
})
document.addEventListener('bootpay-widget-change-payment', function(e) {
console.log('결제수단 변경:', e.detail)
updateButtonState(e.detail)
})
document.addEventListener('bootpay-widget-change-terms', function(e) {
updateButtonState(e.detail)
})
BootpayWidget.render('#bootpay-widget', {
client_key: '[ Client Key ]', // Client Key
widget_key: 'default-widget', // 관리자에서 만든 단건결제 위젯 키
price: 1000, // 결제 금액
order_name: '테스트 상품', // 주문명
order_id: 'ORD-' + Date.now(), // 주문번호
sandbox: true, // 샌드박스 모드
use_terms: true // 약관 동의 UI 표시
})
</script>html// STEP 1. WidgetController 설정
controller = BootpayWidgetController()
.bind(this, supportFragmentManager)
.setOnChangePayment { data -> payload.mergeWidgetData(data) }
.setOnChangeAgreeTerm { data -> payload.mergeWidgetData(data) }
.setOnDone { data -> Log.d("Widget", "결제 완료: $data") }
.setOnError { data -> Log.e("Widget", "결제 오류: $data") }
.setOnCancel { data -> Log.d("Widget", "결제 취소: $data") }
.setOnClose { finish() }
// STEP 2. Payload 설정
payload = Payload().apply {
clientKey = "[ Client Key ]" // Client Key
orderName = "테스트 상품" // 주문명
orderId = "ORD-" + System.currentTimeMillis()
price = 1000.0 // 결제 금액
widgetKey = "default-widget" // 관리자에서 만든 단건결제 위젯 키
widgetSandbox = true // 샌드박스 모드
widgetUseTerms = true // 약관 동의 UI 표시
extra = BootExtra().apply { appScheme = "yourAppScheme" }
}
// STEP 3. 위젯 렌더링
webViewContainer = findViewById(R.id.webViewContainer)
BootpayWidget.bindViewUpdate(this, supportFragmentManager, webViewContainer)
webViewContainer.post {
BootpayWidget.renderWidget(this, payload, controller)
}kotlin// STEP 1. WidgetController 설정
widgetController = BootpayWidgetController()
widgetController.closeAction = .none // 직접 처리 (권장)
widgetController.onChangePayment = { [weak self] data in
self?.payload.mergeWidgetData(data)
self?.updatePayButtonState()
}
widgetController.onChangeAgreeTerm = { [weak self] data in
self?.payload.mergeWidgetData(data)
self?.updatePayButtonState()
}
widgetController.onDone = { data in print("결제 완료: \(data)") }
widgetController.onError = { data in print("결제 오류: \(data)") }
widgetController.onCancel = { data in print("결제 취소: \(data)") }
// STEP 2. Payload 설정
payload = Payload()
payload.clientKey = "[ Client Key ]" // Client Key
payload.orderName = "테스트 상품" // 주문명
payload.orderId = "ORD-\(Int(Date().timeIntervalSince1970 * 1000))"
payload.price = 1000 // 결제 금액
payload.widgetKey = "default-widget" // 관리자에서 만든 단건결제 위젯 키
payload.widgetSandbox = true // 샌드박스 모드
payload.widgetUseTerms = true // 약관 동의 UI 표시
payload.extra = BootExtra()
payload.extra?.appScheme = "yourAppScheme"
// STEP 3. 위젯 렌더링
widgetView = BootpayWidgetView()
widgetView.translatesAutoresizingMaskIntoConstraints = false
widgetView.controller = widgetController
view.addSubview(widgetView)
// ... Auto Layout constraints 설정
widgetView.payload = payload
widgetView.startWidget()swiftPayload _payload = Payload();
BootpayWidgetController _controller = BootpayWidgetController();
double _widgetHeight = 516.0;
// STEP 1. Payload 설정
void initPayload() {
_payload.clientKey = '[ Client Key ]'; // Client Key
_payload.price = 1000; // 결제 금액
_payload.orderName = '테스트 상품'; // 주문명
_payload.orderId = 'ORD-${DateTime.now().millisecondsSinceEpoch}';
_payload.widgetKey = 'default-widget'; // 관리자에서 만든 단건결제 위젯 키
_payload.widgetSandbox = true; // 샌드박스 모드
_payload.widgetUseTerms = true; // 약관 동의 UI 표시
_payload.extra = Extra()..appScheme = 'yourAppScheme';
}
// STEP 2. Controller 콜백 설정
void initController() {
_controller.onWidgetResize = (height) {
setState(() { _widgetHeight = height; });
};
_controller.onWidgetChangePayment = (widgetData) {
setState(() { _payload.mergeWidgetData(widgetData); });
};
_controller.onWidgetChangeAgreeTerm = (widgetData) {
setState(() { _payload.mergeWidgetData(widgetData); });
};
}
// STEP 3. 위젯 렌더링
@override
Widget build(BuildContext context) {
return SizedBox(
height: _widgetHeight,
child: BootpayWidget(
payload: _payload,
controller: _controller,
),
);
}dart// Flutter Web — web/index.html에 JS SDK 스크립트 필수
// 코드 구조는 Flutter 모바일과 동일하나, clientKey만 설정
Payload _payload = Payload();
BootpayWidgetController _controller = BootpayWidgetController();
double _widgetHeight = 516.0;
void initPayload() {
_payload.clientKey = '[ Client Key ]'; // Client Key
_payload.price = 1000; // 결제 금액
_payload.orderName = '테스트 상품'; // 주문명
_payload.orderId = 'ORD-${DateTime.now().millisecondsSinceEpoch}';
_payload.widgetKey = 'default-widget'; // 관리자에서 만든 단건결제 위젯 키
_payload.widgetSandbox = true; // 샌드박스 모드
_payload.widgetUseTerms = true; // 약관 동의 UI 표시
}
void initController() {
_controller.onWidgetResize = (height) {
setState(() { _widgetHeight = height; });
};
_controller.onWidgetChangePayment = (widgetData) {
setState(() { _payload.mergeWidgetData(widgetData); });
};
_controller.onWidgetChangeAgreeTerm = (widgetData) {
setState(() { _payload.mergeWidgetData(widgetData); });
};
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: _widgetHeight,
child: BootpayWidget(
payload: _payload,
controller: _controller,
),
);
}dartimport { BootpayWidget, WidgetPayload, WidgetData } from 'react-native-bootpay-api';
const bootpayWidget = useRef<BootpayWidget>(null);
const [widgetHeight, setWidgetHeight] = useState(200);
const [widgetData, setWidgetData] = useState<WidgetData | null>(null);
const [widgetTop, setWidgetTop] = useState(0);
const widgetPlaceholderRef = useRef<View>(null);
// STEP 1. WidgetPayload 설정
const getWidgetPayload = (): WidgetPayload => ({
widget_key: 'default-widget', // 관리자에서 만든 단건결제 위젯 키
widget_sandbox: true, // 샌드박스 모드
widget_use_terms: true, // 약관 동의 UI 표시
order_name: '테스트 상품', // 주문명
price: 1000, // 결제 금액
order_id: `order_${Date.now()}`, // 테스트용. 실제로는 서버에서 생성한 주문번호 사용
extra: { app_scheme: 'yourAppScheme' },
});
// STEP 2. 콜백 설정 — onWidgetReady에서 renderWidget 호출 필수
const onWidgetReady = () => {
bootpayWidget.current?.renderWidget(getWidgetPayload());
};
const onWidgetResize = (height: number) => setWidgetHeight(height);
const onWidgetChangePayment = (data: WidgetData | null) => setWidgetData(data);
const onWidgetChangeTerms = (data: WidgetData | null) => setWidgetData(data);
// STEP 3. 위젯 렌더링
<View ref={widgetPlaceholderRef} style={{ minHeight: widgetHeight }}
onLayout={() => setTimeout(measureWidgetPosition, 100)} />
<BootpayWidget ref={bootpayWidget}
client_key={'[ Client Key ]'}
height={widgetHeight} widgetTop={widgetTop}
onWidgetReady={onWidgetReady}
onWidgetResize={onWidgetResize}
onWidgetChangePayment={onWidgetChangePayment}
onWidgetChangeTerms={onWidgetChangeTerms}
onDone={(data) => console.log('완료', data)}
onError={(data) => console.log('에러', data)}
onCancel={(data) => console.log('취소', data)}
onClose={() => console.log('닫힘')}
onConfirm={(data) => { bootpayWidget.current?.transactionConfirm(); return true; }}
/>tsx1-3. 결제 요청
구매자가 결제수단 선택과 약관 동의를 마치면 결제를 요청해요. 이 단계는 구매자 인증을 시작하는 단계이지, 아직 결제 승인이나 주문 확정이 아니에요. 운영 코드에서는 버튼 클릭 직전에 서버에서 주문번호와 최종 금액을 확정한 뒤 요청하는 흐름을 권장해요.
document.getElementById('pay-button').onclick = async () => {
try {
const response = await BootpayWidget.requestPayment({
order_name: '테스트 상품',
order_id: 'order_' + Date.now(), // 테스트용. 실제로는 서버에서 생성한 주문번호 사용
extra: {
open_type: 'iframe'
}
})
// 결제 결과 수신 — 서버에서 결제 조회 필요
if (response.event === 'done') {
console.log('결제 완료:', response.data.receipt_id)
}
} catch (error) {
// 결제 실패 또는 취소
console.error('결제 실패:', error)
}
}javascript// 결제 버튼 클릭 시
fun goPayment() {
if (!payload.widgetIsCompleted) {
// 결제수단 선택과 약관동의가 완료되지 않음
return
}
// controller에 설정된 콜백(onConfirm, onDone, onError 등)으로 인증 결과와 결제 결과를 수신
controller.requestPayment(payload)
}
// 결제 버튼 활성화 상태 관리
fun updatePayButtonState() {
runOnUiThread {
payButton.isEnabled = payload.widgetIsCompleted
}
}kotlin// 결제 버튼 클릭 시
@objc func requestPayment() {
guard payload.widgetIsCompleted else {
// 결제수단 선택과 약관동의가 완료되지 않음
return
}
// controller에 설정된 콜백(onConfirm, onDone, onError 등)으로 인증 결과와 결제 결과를 수신
widgetController.requestPayment(payload: payload)
}
// 결제 버튼 활성화 상태 관리
func updatePayButtonState() {
DispatchQueue.main.async { [weak self] in
self?.payButton.isEnabled = self?.payload.widgetIsCompleted ?? false
}
}swift// 결제 버튼 클릭 시
void goPayment() {
if (!_payload.widgetIsCompleted) {
// 결제수단 선택과 약관동의가 완료되지 않음
return;
}
// controller를 통해 결제 요청
_controller.requestPayment(
context: context,
payload: _payload,
onDone: (data) {
debugPrint('[Widget] Done: $data');
// 결제 결과 수신 — 서버에서 검증 필요
},
onError: (data) {
debugPrint('[Widget] Error: $data');
// 에러 시 위젯 재로드하여 재시도 가능
Future.delayed(Duration(milliseconds: 500), () {
_controller.reloadWidget();
});
},
onCancel: (data) {
debugPrint('[Widget] Cancel: $data');
_controller.reloadWidget();
},
onClose: () {
Bootpay().dismiss(context);
},
onConfirm: (data) => true,
);
}dart// Flutter Web — 결제 요청 시 redirect 방식 권장
void goPayment() {
if (!_payload.widgetIsCompleted) return;
_controller.requestPayment(
context: context,
payload: _payload,
onDone: (data) {
// redirect 방식에서는 호출되지 않을 수 있음
debugPrint('[Widget] Done: $data');
},
onError: (data) {
debugPrint('[Widget] Error: $data');
Future.delayed(Duration(milliseconds: 500), () {
_controller.reloadWidget();
});
},
onCancel: (data) {
debugPrint('[Widget] Cancel: $data');
_controller.reloadWidget();
},
onClose: () {},
onConfirm: (data) => true,
);
}dart// 결제 버튼 활성화 조건
const isCompleted =
(widgetData?.term_passed ?? false) && (widgetData?.completed ?? false);
// 결제 버튼 클릭 시
const goPayment = () => {
if (!isCompleted) {
Alert.alert('알림', '결제수단 선택과 약관동의를 완료해 주세요.');
return;
}
// ref를 통해 결제 요청 — 콜백은 BootpayWidget props로 수신
bootpayWidget.current?.requestPayment();
};tsx1-4. 인증 결과 수신
운영 주문서는 서버 승인 모드를 우선 검토해요. extra.separately_confirmed: true로 설정하면 구매자 인증이 완료된 뒤 confirm 이벤트가 발생해요. 이 시점은 인증 결과를 받은 상태이지 결제 완료가 아니에요. receipt_id를 서버로 넘기고, 다음 단계에서 결제 승인 요청을 처리해야 해요. 서버 승인 모드에서는 클라이언트의 onDone이 호출되지 않아요.
document.getElementById('pay-button').onclick = async () => {
try {
const response = await BootpayWidget.requestPayment({
order_name: '테스트 상품',
order_id: 'order_' + Date.now(), // 테스트용. 실제로는 서버에서 생성한 주문번호 사용
extra: {
open_type: 'iframe',
separately_confirmed: true // 서버 승인 모드
}
})
// 인증 결과 수신 — confirm 이벤트
if (response.event === 'confirm') {
const { receipt_id, order_id } = response
// 서버로 receipt_id를 보내 승인 가능 여부와 승인 요청을 처리
await fetch('/api/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ receipt_id, order_id })
})
// 결제 결과 수신은 결과 페이지에서 서버 DB 조회로 확인
location.href = `/order/result?order_id=${order_id}`
}
} catch (error) {
console.error('결제 실패:', error)
}
}javascript// 결제 버튼 클릭 시
fun goPayment() {
if (!payload.widgetIsCompleted) return
// 서버 승인 모드 설정
payload.extra = BootExtra().apply {
appScheme = "yourAppScheme"
separatelyConfirmed = true
}
controller.requestPayment(payload)
}
// controller 설정 시 — onConfirm에서 서버로 전송 후 false 반환
// (STEP 2 위젯 렌더링의 controller 설정에 추가)
controller = BootpayWidgetController()
// ... 기존 콜백
.setOnConfirm { data ->
sendConfirmToServer(data) // 인증 결과를 서버로 전달하고 결제 승인 요청 처리
false // 클라이언트 자동 승인 방지
}
.setOnDone { data ->
// 서버 승인 방식에서는 호출되지 않음
}kotlin// 결제 버튼 클릭 시
@objc func requestPayment() {
guard payload.widgetIsCompleted else { return }
// 서버 승인 모드 설정
payload.extra = BootExtra()
payload.extra?.appScheme = "yourAppScheme"
payload.extra?.separatelyConfirmed = true
widgetController.requestPayment(payload: payload)
}
// controller 설정 시 — onConfirm에서 서버로 전송 후 false 반환
// (STEP 2 위젯 렌더링의 controller 설정에 추가)
widgetController.onConfirm = { data in
sendConfirmToServer(data) // 인증 결과를 서버로 전달하고 결제 승인 요청 처리
return false // 클라이언트 자동 승인 방지
}
widgetController.onDone = { data in
// 서버 승인 방식에서는 호출되지 않음
}swiftvoid goPayment() {
if (!_payload.widgetIsCompleted) return;
// 서버 승인 모드 설정
_payload.extra = Extra()
..appScheme = 'yourAppScheme'
..separatelyConfirmed = true;
_controller.requestPayment(
context: context,
payload: _payload,
onConfirm: (data) {
sendConfirmToServer(data); // 인증 결과를 서버로 전달하고 결제 승인 요청 처리
return false; // 클라이언트 자동 승인 방지
},
onDone: (data) {
// 서버 승인 방식에서는 호출되지 않음
},
onError: (data) {
debugPrint('[Widget] Error: $data');
Future.delayed(Duration(milliseconds: 500), () {
_controller.reloadWidget();
});
},
onCancel: (data) {
debugPrint('[Widget] Cancel: $data');
_controller.reloadWidget();
},
onClose: () {
Bootpay().dismiss(context);
},
);
}dartvoid goPayment() {
if (!_payload.widgetIsCompleted) return;
// 서버 승인 + redirect 방식
_payload.extra = Extra()
..separatelyConfirmed = true
..openType = 'redirect'
..redirectUrl = 'https://yoursite.com/order/result';
_controller.requestPayment(
context: context,
payload: _payload,
onConfirm: (data) {
sendConfirmToServer(data);
return false;
},
onCancel: (data) {
debugPrint('[Widget] Cancel: $data');
_controller.reloadWidget();
},
onError: (data) {
debugPrint('[Widget] Error: $data');
},
onClose: () {},
);
}dartconst isCompleted =
(widgetData?.term_passed ?? false) && (widgetData?.completed ?? false);
const goPayment = () => {
if (!isCompleted) {
Alert.alert('알림', '결제수단 선택과 약관동의를 완료해 주세요.');
return;
}
// WidgetPayload에 서버 승인 모드 설정 (getWidgetPayload에서)
// extra: { separately_confirmed: true, app_scheme: 'yourAppScheme' }
bootpayWidget.current?.requestPayment();
};
// BootpayWidget의 onConfirm 콜백
const onConfirm = useCallback((data: unknown) => {
sendConfirmToServer(data); // 인증 결과를 서버로 전달하고 결제 승인 요청 처리
// transactionConfirm()을 호출하지 않음 → 클라이언트 자동 승인 방지
return false;
}, []);tsxextra.separately_confirmed: true설정 → 인증 완료 후confirm이벤트 수신confirm은 결제 완료가 아니라 승인 직전 상태onConfirm에서receipt_id를 서버로 전달하고, 서버에서 승인 가능 여부를 검증- 서버 승인 가능 PG라면 서버가 Bootpay 승인 API를 호출
- 서버 승인 방식에서는
onDone이 호출되지 않음 → 프론트엔드는 결과 페이지에서 서버 DB를 조회
1-5. 결제 승인 요청
인증 결과(confirm) 다음 단계는 결제 승인 요청이에요. 서버 승인 모드에서는 onConfirm에서 받은 receipt_id를 서버로 보내고, 서버가 주문 금액·상태·재고를 확인한 뒤 Bootpay 승인 API를 호출해야 해요. 서버 승인을 지원하지 않는 PG라면 서버에서 주문 금액·상태를 먼저 확인한 뒤 각 플랫폼의 클라이언트 승인 함수를 호출하는 방식으로 처리해요.
- 서버 승인 지원 PG —
onConfirm에서receipt_id를 서버로 전달하고, 서버에서 승인 API를 호출해요. - 서버 승인 미지원 PG — 서버에서 주문 금액·상태를 먼저 검증하고, 통과한 경우에만 각 플랫폼 탭의 클라이언트 승인 함수를 호출해요.
1-6. 결제 결과 수신
결제 승인 요청 이후에는 성공 또는 실패 결과를 수신해요. 프론트엔드 응답만으로 주문을 완료하면 안 돼요. 항상 서버에서 receipt_id로 결제 상태와 금액을 한 번 더 확인한 뒤 주문을 확정해야 해요.
- 클라이언트 자동 승인 모드 —
onDone수신 후 서버에서 결제 조회 API를 호출해요. - 서버 승인 모드(
separately_confirmed) —onDone이 호출되지 않아요. 프론트엔드는 결과 페이지에서 서버 DB를 조회해 승인 결과를 표시해요. - 실패/취소 —
onError또는onCancel을 받으면 주문은 미결제 상태로 유지하고, 위젯을 재로드해 재시도할 수 있게 해요.
결제위젯 결과 데이터 예시
결제위젯에는 두 종류의 데이터가 있어요. WidgetData는 결제수단 선택·약관 동의 같은 주문서 UI 상태이고, onConfirm·onDone·onIssued·onError에서 받는 데이터는 결제 진행 결과예요.
{
"event": "done",
"receipt_id": "6244f60c1fc19202e42e8c4e",
"order_id": "order_20260428_001",
"price": 1000,
"status": 1,
"method": "card",
"method_symbol": "card",
"pg": "kcp"
}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,
"vbank_data": {
"bank_name": "국민은행",
"account": "1234567890",
"account_holder": "부트페이",
"expired_at": "2026-04-29T23:59:59+09:00"
}
}json{
"event": "error",
"error_code": "RC_REQUEST_FAILED",
"message": "결제 요청이 실패했다.",
"receipt_id": "6244f60c1fc19202e42e8c4e"
}json{
"event": "cancel",
"message": "사용자가 결제를 취소했습니다.",
"receipt_id": "6244f60c1fc19202e42e8c4e"
}jsonconfirm은 승인 전 인증 결과이고, done은 결제 완료 이벤트예요. 가상계좌 issued는 입금 완료가 아니라 계좌 발급 완료예요.
주문 확정에 필요한 전체 결제 JSON은 이벤트 데이터가 아니라 서버의 결제 조회 응답 예시를 기준으로 봐요. 결제창과 결제위젯 모두 같은 receipt_id 조회 응답을 사용해요.
2주문서 전체 흐름으로 합치기
앞에서는 렌더링·결제 요청·인증 결과·승인 요청·결과 수신을 조각별로 봤어요. 이제 실제 주문서처럼 적립금 적용, 금액 업데이트, 서버 준비 API, 결제 요청을 한 흐름으로 합쳐요. 주문서 코드를 승인 방식별로 두 벌 만들 필요는 없어요. 서버 승인 모드를 쓰면 아래 예제의 confirm 처리 위치에 앞에서 본 서버 승인 로직만 넣으면 돼요.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<title>주문서 - 내 쇼핑몰</title>
<script src="https://js.bootpay.co.kr/bootpay-5.3.0.min.js"></script>
</head>
<body>
<h2>주문 결제</h2>
<!-- 적립금 사용 -->
<div class="mileage-section">
<label>
<input type="checkbox" id="use-mileage" />
보유 적립금 3,000원 사용
</label>
</div>
<!-- 결제위젯 영역 -->
<div id="payment-widget-area"></div>
<!-- 결제 버튼 -->
<button id="checkout-btn" disabled>39,000원 결제하기</button>
<script>
var orderInfo = {
basePrice: 39000,
mileageDiscount: 0
}
function updateButtonState(data) {
document.getElementById('checkout-btn').disabled =
!(data && data.completed && data.term_passed)
}
document.addEventListener('bootpay-widget-ready', function() {
console.log('결제위젯 준비 완료')
})
document.addEventListener('bootpay-widget-change-payment', function(e) {
console.log('결제수단 변경:', e.detail)
updateButtonState(e.detail)
})
document.addEventListener('bootpay-widget-change-terms', function(e) {
updateButtonState(e.detail)
})
// STEP 1. 위젯 렌더링
BootpayWidget.render('#payment-widget-area', {
client_key: '[ Client Key ]',
price: orderInfo.basePrice,
sandbox: true,
use_terms: true
})
// STEP 2. 적립금 적용 시 금액 업데이트
document.getElementById('use-mileage').addEventListener('change', function(e) {
orderInfo.mileageDiscount = e.target.checked ? 3000 : 0
var finalPrice = orderInfo.basePrice - orderInfo.mileageDiscount
BootpayWidget.update({ price: finalPrice })
document.getElementById('checkout-btn').textContent =
finalPrice.toLocaleString() + '원 결제하기'
})
// STEP 3. 결제 요청
document.getElementById('checkout-btn').addEventListener('click', async function() {
try {
var response = await fetch('/api/payment/prepare', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
order_name: '프리미엄 무선 이어폰',
mileage_amount: orderInfo.mileageDiscount,
payment_method: BootpayWidget.currentPaymentParameters()
})
})
var orderData = await response.json()
await BootpayWidget.requestPayment({
client_key: '[ Client Key ]',
pg: orderData.pg,
method: orderData.method,
order_id: orderData.order_id,
order_name: orderData.order_name,
price: orderData.price,
redirect_url: window.location.origin + '/order/result'
})
} catch (err) {
alert('결제 실패: ' + err.message)
}
})
</script>
</body>
</html>html<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<title>주문서 - 내 쇼핑몰</title>
</head>
<body>
<h2>주문 결제</h2>
<!-- 적립금 사용 -->
<div class="mileage-section">
<label>
<input type="checkbox" id="use-mileage" />
보유 적립금 3,000원 사용
</label>
</div>
<!-- 결제위젯 영역 -->
<div id="payment-widget-area"></div>
<!-- 결제 버튼 -->
<button id="checkout-btn" disabled>39,000원 결제하기</button>
<script type="module">
import { BootpayWidget } from '@bootpay/client-js'
const orderInfo = {
basePrice: 39000,
mileageDiscount: 0
}
const updateButtonState = (data) => {
document.getElementById('checkout-btn').disabled =
!(data?.completed && data?.term_passed)
}
document.addEventListener('bootpay-widget-ready', () => {
console.log('결제위젯 준비 완료')
})
document.addEventListener('bootpay-widget-change-payment', (e) => {
console.log('결제수단 변경:', e.detail)
updateButtonState(e.detail)
})
document.addEventListener('bootpay-widget-change-terms', (e) => {
updateButtonState(e.detail)
})
// STEP 1. 위젯 렌더링
BootpayWidget.render('#payment-widget-area', {
client_key: '[ Client Key ]',
price: orderInfo.basePrice,
sandbox: true,
use_terms: true
})
// STEP 2. 적립금 적용 시 금액 업데이트
document.getElementById('use-mileage').addEventListener('change', (e) => {
orderInfo.mileageDiscount = e.target.checked ? 3000 : 0
const finalPrice = orderInfo.basePrice - orderInfo.mileageDiscount
BootpayWidget.update({ price: finalPrice })
document.getElementById('checkout-btn').textContent =
finalPrice.toLocaleString() + '원 결제하기'
})
// STEP 3. 결제 요청
document.getElementById('checkout-btn').addEventListener('click', async () => {
try {
const response = await fetch('/api/payment/prepare', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
order_name: '프리미엄 무선 이어폰',
mileage_amount: orderInfo.mileageDiscount,
payment_method: BootpayWidget.currentPaymentParameters()
})
})
const orderData = await response.json()
await BootpayWidget.requestPayment({
client_key: '[ Client Key ]',
pg: orderData.pg,
method: orderData.method,
order_id: orderData.order_id,
order_name: orderData.order_name,
price: orderData.price,
redirect_url: window.location.origin + '/order/result'
})
} catch (err) {
alert('결제 실패: ' + err.message)
}
})
</script>
</body>
</html>html// WidgetActivity.kt
class WidgetActivity : AppCompatActivity() {
private lateinit var webViewContainer: FrameLayout
private lateinit var payButton: Button
private lateinit var payload: Payload
private lateinit var controller: BootpayWidgetController
private var widgetHeight = 516.0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_widget)
webViewContainer = findViewById(R.id.webViewContainer)
payButton = findViewById(R.id.payButton)
initPayload()
initController()
bindWidgetView()
webViewContainer.post { renderWidget() }
}
// STEP 1. Payload 설정
private fun initPayload() {
payload = Payload().apply {
clientKey = "[ Client Key ]"
orderName = "프리미엄 무선 이어폰"
orderId = "ORD-" + System.currentTimeMillis()
price = 39000.0
widgetKey = "default-widget"
widgetSandbox = true
widgetUseTerms = true
extra = BootExtra().apply {
appScheme = "yourAppScheme" // 앱투앱 결제 복귀용
}
}
}
// STEP 2. WidgetController 설정
private fun initController() {
controller = BootpayWidgetController()
.bind(this, supportFragmentManager)
.setCloseAction(WidgetCloseAction.NONE) // 직접 처리 (권장)
.setOnReady { Log.d("Widget", "위젯 준비 완료") }
// 위젯 높이 변경 — dp를 px로 변환하여 적용
.setOnResize { height ->
widgetHeight = height
val density = resources.displayMetrics.density
val heightPx = (height * density).toInt()
runOnUiThread {
webViewContainer.layoutParams =
webViewContainer.layoutParams.apply { this.height = heightPx }
}
}
// 결제수단 변경 시 Payload에 반영
.setOnChangePayment { data ->
payload.mergeWidgetData(data)
updatePayButtonState()
}
// 약관동의 변경 시 Payload에 반영
.setOnChangeAgreeTerm { data ->
payload.mergeWidgetData(data)
updatePayButtonState()
}
// 결제 완료
.setOnDone { data ->
Log.d("Widget", "결제 완료: $data")
BootpayWidget.collapseAndFinish(this)
runOnUiThread { finish() }
}
// 결제 에러 — 위젯 재로드하여 재시도 가능
.setOnError { data ->
Log.e("Widget", "결제 오류: $data")
BootpayWidget.collapseAndReload(this)
}
// 결제 취소 — 위젯 재로드
.setOnCancel { data ->
Log.d("Widget", "결제 취소: $data")
BootpayWidget.collapseAndReload(this)
}
// 결제 확인 (서버 조회 후 진행 여부 결정)
.setOnConfirm { true }
.setOnClose {
Log.d("Widget", "닫기")
BootpayWidget.closeDialog(this)
}
}
// STEP 3. 위젯 렌더링
private fun bindWidgetView() {
BootpayWidget.bindViewUpdate(this, supportFragmentManager, webViewContainer)
}
private fun renderWidget() {
BootpayWidget.renderWidget(this, payload, controller)
}
// STEP 4. 결제 요청
fun goPayment(v: View) {
if (!payload.widgetIsCompleted) {
AlertDialog.Builder(this)
.setTitle("알림")
.setMessage("결제수단 선택과 약관동의를 완료해 주세요.")
.setPositiveButton("확인", null)
.show()
return
}
controller.requestPayment(payload)
}
private fun updatePayButtonState() {
runOnUiThread {
payButton.isEnabled = payload.widgetIsCompleted
}
}
// 뒤로가기 — 전체화면이면 축소, 아니면 종료
override fun onBackPressed() {
val webView = BootpayWidget.getView(this, supportFragmentManager)
if (webView?.isExpanded == true) {
BootpayWidget.collapseAndReload(this)
} else {
super.onBackPressed()
}
}
override fun onDestroy() {
super.onDestroy()
BootpayWidget.destroy()
}
}kotlin// WidgetViewController.swift
import UIKit
import Bootpay
class WidgetViewController: UIViewController {
var widgetView: BootpayWidgetView!
var widgetController: BootpayWidgetController!
var payButton: UIButton!
var widgetHeightConstraint: NSLayoutConstraint!
var payload: Payload!
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
title = "결제하기"
setupPayload()
setupUI()
setupWidgetController()
startWidget()
}
// STEP 1. Payload 설정
func setupPayload() {
payload = Payload()
payload.clientKey = "[ Client Key ]"
payload.orderName = "프리미엄 무선 이어폰"
payload.orderId = "ORD-\(Int(Date().timeIntervalSince1970 * 1000))"
payload.price = 39000
payload.widgetKey = "default-widget"
payload.widgetSandbox = true
payload.widgetUseTerms = true
payload.user = BootUser()
payload.user?.username = "홍길동"
payload.user?.phone = "01012341234"
payload.extra = BootExtra()
payload.extra?.appScheme = "yourAppScheme"
}
// STEP 2. UI 구성
func setupUI() {
let scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(scrollView)
let contentView = UIView()
contentView.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(contentView)
widgetView = BootpayWidgetView()
widgetView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(widgetView)
payButton = UIButton(type: .system)
payButton.setTitle("39,000원 결제하기", for: .normal)
payButton.backgroundColor = .systemGray
payButton.setTitleColor(.white, for: .normal)
payButton.layer.cornerRadius = 10
payButton.isEnabled = false
payButton.translatesAutoresizingMaskIntoConstraints = false
payButton.addTarget(self, action: #selector(requestPayment), for: .touchUpInside)
view.addSubview(payButton)
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: payButton.topAnchor, constant: -10),
contentView.topAnchor.constraint(equalTo: scrollView.topAnchor),
contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
widgetView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20),
widgetView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
widgetView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
widgetView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -20),
payButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
payButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
payButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -10),
payButton.heightAnchor.constraint(equalToConstant: 56)
])
widgetHeightConstraint = widgetView.heightAnchor.constraint(equalToConstant: 516)
widgetHeightConstraint.isActive = true
}
// STEP 3. WidgetController 설정
func setupWidgetController() {
widgetController = BootpayWidgetController()
widgetController.closeAction = .none // 직접 처리 (권장)
widgetController.onReady = {
print("[Widget] Ready")
}
// 위젯 높이 변경 — Auto Layout constraint 업데이트
widgetController.onResize = { [weak self] height in
guard let self = self else { return }
DispatchQueue.main.async {
self.widgetHeightConstraint.constant = height
UIView.animate(withDuration: 0.3) {
self.view.layoutIfNeeded()
}
}
}
// 결제수단 변경 시 Payload에 반영
widgetController.onChangePayment = { [weak self] data in
self?.payload.mergeWidgetData(data)
self?.updatePayButtonState()
}
// 약관동의 변경 시 Payload에 반영
widgetController.onChangeAgreeTerm = { [weak self] data in
self?.payload.mergeWidgetData(data)
self?.updatePayButtonState()
}
// 결제 완료
widgetController.onDone = { [weak self] data in
print("[Widget] Done: \(data)")
// 서버 조회 후 결과 페이지로 이동
}
// 결제 에러 — 위젯 재로드하여 재시도 가능
widgetController.onError = { [weak self] data in
print("[Widget] Error: \(data)")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self?.widgetView.reloadWidget()
}
}
// 결제 취소
widgetController.onCancel = { [weak self] data in
print("[Widget] Cancel: \(data)")
self?.navigationController?.popViewController(animated: true)
}
// 결제 확인 (서버 조회 후 진행 여부 결정)
widgetController.onConfirm = { data in
return true
}
widgetController.onClose = { [weak self] in
self?.navigationController?.popViewController(animated: true)
}
// 위젯 뷰에 컨트롤러 연결
widgetView.controller = widgetController
}
// STEP 4. 위젯 시작
func startWidget() {
widgetView.payload = payload
widgetView.startWidget()
}
// STEP 5. 결제 요청
@objc func requestPayment() {
guard payload.widgetIsCompleted else { return }
widgetController.requestPayment(payload: payload)
}
func updatePayButtonState() {
let isCompleted = payload.widgetIsCompleted
DispatchQueue.main.async { [weak self] in
self?.payButton.isEnabled = isCompleted
self?.payButton.backgroundColor = isCompleted ? .systemBlue : .systemGray
}
}
}swift// widget_page.dart
import 'package:bootpay/bootpay.dart';
import 'package:bootpay/model/extra.dart';
import 'package:bootpay/model/payload.dart';
import 'package:bootpay/model/user.dart';
import 'package:bootpay/widget/bootpay_widget.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class WidgetPage extends StatefulWidget {
@override
State<WidgetPage> createState() => _WidgetPageState();
}
class _WidgetPageState extends State<WidgetPage> {
Payload _payload = Payload();
BootpayWidgetController _controller = BootpayWidgetController();
double _widgetHeight = 516.0;
@override
void initState() {
super.initState();
_initPayload();
_initController();
}
// STEP 1. Payload 설정
void _initPayload() {
_payload = Payload();
_payload.clientKey = '[ Client Key ]';
_payload.price = 39000;
_payload.orderName = '프리미엄 무선 이어폰';
_payload.orderId = '${DateTime.now().millisecondsSinceEpoch}';
_payload.widgetKey = 'default-widget';
_payload.widgetSandbox = true;
_payload.widgetUseTerms = true;
_payload.user = User();
_payload.user?.username = '홍길동';
_payload.user?.phone = '01012341234';
_payload.extra = Extra();
_payload.extra?.appScheme = 'yourAppScheme';
}
// STEP 2. Controller 콜백 설정
void _initController() {
_controller.onWidgetReady = () {
debugPrint('[Widget] Ready');
};
// 위젯 높이 변경 — SizedBox 높이 업데이트
_controller.onWidgetResize = (height) {
if (_widgetHeight == height) return;
setState(() { _widgetHeight = height; });
};
// 결제수단 변경 시 Payload에 반영
_controller.onWidgetChangePayment = (widgetData) {
setState(() { _payload.mergeWidgetData(widgetData); });
};
// 약관동의 변경 시 Payload에 반영
_controller.onWidgetChangeAgreeTerm = (widgetData) {
setState(() { _payload.mergeWidgetData(widgetData); });
};
}
// STEP 3. 결제 요청
void _goPayment() {
if (!_payload.widgetIsCompleted) return;
_controller.requestPayment(
context: context,
payload: _payload,
onDone: (String data) {
debugPrint('[Widget] Done: $data');
// 결제 결과 수신 — 서버에서 검증 필요
},
onError: (String data) {
debugPrint('[Widget] Error: $data');
// 에러 시 위젯 재로드하여 재시도 가능
Future.delayed(Duration(milliseconds: 500), () {
_controller.reloadWidget();
});
},
onCancel: (String data) {
debugPrint('[Widget] Cancel: $data');
Future.delayed(Duration(milliseconds: 500), () {
_controller.reloadWidget();
});
},
onClose: () {
if (!kIsWeb) Bootpay().dismiss(context);
},
onConfirm: (String data) => true,
);
}
String _formatPrice(double price) {
return '${NumberFormat('#,###', 'ko_KR').format(price.toInt())}원';
}
@override
Widget build(BuildContext context) {
final isCompleted = _payload.widgetIsCompleted;
return Scaffold(
appBar: AppBar(title: Text('결제하기')),
body: SafeArea(
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
// 상품 정보
Padding(
padding: EdgeInsets.all(16),
child: Text(_formatPrice(39000),
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
),
// 위젯 렌더링
SizedBox(
height: _widgetHeight,
child: BootpayWidget(
payload: _payload,
controller: _controller,
),
),
],
),
),
),
// 결제 버튼
Padding(
padding: EdgeInsets.all(16),
child: Material(
color: isCompleted ? Colors.blueAccent : Colors.grey,
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: isCompleted ? _goPayment : null,
borderRadius: BorderRadius.circular(10),
child: Container(
height: 56,
child: Center(
child: Text(
'${_formatPrice(39000)} 결제하기',
style: TextStyle(color: Colors.white, fontSize: 18,
fontWeight: FontWeight.w600),
),
),
),
),
),
),
],
),
),
);
}
}dart// Flutter Web 전체 예제 — 모바일과 구조 동일, clientKey만 사용
// web/index.html <head>에 JS SDK 스크립트 추가 필수:
// <script src="https://js.bootpay.co.kr/bootpay-5.3.0.min.js"></script>
//
// 코드는 Flutter(모바일) 탭과 동일하다.
// 차이점:
// - clientKey만 설정 (플랫폼별 ID 불필요)
// - 결제 요청 시 redirect 방식도 사용 가능
// - kIsWeb으로 플랫폼 분기 가능:
// if (kIsWeb) {
// _payload.extra?.openType = 'redirect';
// _payload.extra?.redirectUrl = 'https://yoursite.com/order/result';
// }
//
// 위의 Flutter 탭 코드를 그대로 사용하되,
// _initPayload()에서 clientKey만 설정하면 된다.dart// WidgetPaymentScreen.tsx
import React, { useRef, useState, useCallback } from 'react';
import {
StyleSheet, View, Text, TouchableOpacity,
ScrollView, SafeAreaView, Alert,
} from 'react-native';
import {
BootpayWidget, WidgetPayload, WidgetData,
} from 'react-native-bootpay-api';
const CLIENT_KEY = '[ Client Key ]';
export function WidgetPaymentScreen() {
const bootpayWidget = useRef<BootpayWidget>(null);
const widgetPlaceholderRef = useRef<View>(null);
const [widgetHeight, setWidgetHeight] = useState(200);
const [widgetData, setWidgetData] = useState<WidgetData | null>(null);
const [widgetTop, setWidgetTop] = useState(0);
// STEP 1. WidgetPayload 생성
const getWidgetPayload = useCallback((): WidgetPayload => ({
widget_key: 'default-widget',
widget_sandbox: true,
widget_use_terms: true,
order_name: '프리미엄 무선 이어폰',
price: 39000,
order_id: `order_${Date.now()}`, // 테스트용. 실제로는 서버에서 생성한 주문번호 사용
extra: {
app_scheme: 'yourAppScheme',
separately_confirmed: true,
display_success_result: false,
display_error_result: false,
},
}), []);
// 위젯 위치 측정 (position: absolute 기반)
const measureWidgetPosition = useCallback(() => {
widgetPlaceholderRef.current?.measureInWindow((x, y) => {
if (y > 0) setWidgetTop(y);
});
}, []);
// STEP 2. 위젯 콜백
const onWidgetReady = useCallback(() => {
bootpayWidget.current?.renderWidget(getWidgetPayload());
}, [getWidgetPayload]);
const onWidgetResize = useCallback((height: number) => {
setWidgetHeight(height);
}, []);
const onWidgetChangePayment = useCallback((data: WidgetData | null) => {
setWidgetData(data);
}, []);
const onWidgetChangeTerms = useCallback((data: WidgetData | null) => {
setWidgetData(data);
}, []);
// STEP 3. 결제 콜백
const onConfirm = useCallback((data: unknown) => {
bootpayWidget.current?.transactionConfirm();
return true;
}, []);
const onDone = useCallback((data: unknown) => {
console.log('[Widget] Done:', data);
// 서버 조회 후 결과 화면으로 이동
}, []);
const onError = useCallback((data: unknown) => {
console.log('[Widget] Error:', data);
setTimeout(() => bootpayWidget.current?.reloadWidget(), 500);
}, []);
const onCancel = useCallback((data: unknown) => {
console.log('[Widget] Cancel:', data);
setTimeout(() => bootpayWidget.current?.reloadWidget(), 500);
}, []);
// STEP 4. 결제 요청
const isCompleted =
(widgetData?.term_passed ?? false) && (widgetData?.completed ?? false);
const goPayment = useCallback(() => {
if (!isCompleted) {
Alert.alert('알림', '결제수단 선택과 약관동의를 완료해 주세요.');
return;
}
bootpayWidget.current?.requestPayment();
}, [isCompleted]);
return (
<SafeAreaView style={styles.container}>
<ScrollView>
<View style={styles.productSection}>
<Text style={styles.productName}>프리미엄 무선 이어폰</Text>
<Text style={styles.productPrice}>39,000원</Text>
</View>
{/* 위젯 placeholder (위치 측정용) */}
<View ref={widgetPlaceholderRef}
style={{ minHeight: widgetHeight }}
onLayout={() => setTimeout(measureWidgetPosition, 100)} />
</ScrollView>
{/* 결제 버튼 */}
<TouchableOpacity
style={[styles.payButton, !isCompleted && styles.disabled]}
onPress={goPayment} disabled={!isCompleted}>
<Text style={styles.payButtonText}>39,000원 결제하기</Text>
</TouchableOpacity>
{/* BootpayWidget */}
<BootpayWidget ref={bootpayWidget}
client_key={CLIENT_KEY}
height={widgetHeight} widgetTop={widgetTop}
onWidgetReady={onWidgetReady}
onWidgetResize={onWidgetResize}
onWidgetChangePayment={onWidgetChangePayment}
onWidgetChangeTerms={onWidgetChangeTerms}
onConfirm={onConfirm} onDone={onDone}
onError={onError} onCancel={onCancel}
onIssued={(data) => console.log('Issued:', data)}
onClose={() => console.log('Close')} />
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' },
productSection: { padding: 16 },
productName: { fontWeight: '600', fontSize: 16 },
productPrice: { color: '#3182f6', fontWeight: '600', fontSize: 24, marginTop: 8 },
payButton: { backgroundColor: '#3182f6', borderRadius: 10, height: 56,
justifyContent: 'center', alignItems: 'center', margin: 16 },
disabled: { backgroundColor: '#ccc' },
payButtonText: { color: '#fff', fontSize: 18, fontWeight: '600' },
});tsx위 주문서 코드를 한 벌 더 만들 필요는 없어요. 위 예제의 confirm 처리 위치에서 receipt_id를 서버로 보내고, 서버가 주문 금액·상태·재고를 확인한 뒤 Bootpay 승인 API를 호출하게 연결하면 돼요. 서버 승인 API 예제는 분리 승인 페이지를 참고해요.
3이벤트로 주문 상태 연결하기
주문서 UI는 이벤트를 기준으로 움직여요. 위젯이 준비되면 결제 버튼을 열고, 결제수단·약관 상태가 바뀌면 Payload를 갱신하며, 결제 완료·오류·취소 이벤트는 서버 조회 또는 재시도 흐름으로 연결해요.
const payButton = document.getElementById('pay-button')
function updateButtonState(data) {
// completed + term_passed가 모두 true일 때만 결제 버튼을 활성화해요.
payButton.disabled = !(data?.completed && data?.term_passed)
}
document.addEventListener('bootpay-widget-ready', () => {
// 위젯 SDK 준비 완료
console.log('위젯이 준비되었다')
})
document.addEventListener('bootpay-widget-resize', (e) => {
// 위젯 높이 변경 → 컨테이너 높이 갱신
document.getElementById('bootpay-widget').style.height = `${e.detail.height}px`
})
document.addEventListener('bootpay-widget-change-payment', (e) => {
// 구매자가 결제수단 변경
updateButtonState(e.detail)
})
document.addEventListener('bootpay-widget-change-terms', (e) => {
// 약관 동의 상태 변경
updateButtonState(e.detail)
})
BootpayWidget.render('#bootpay-widget', {
// ... 기본 옵션
widget_key: 'default-widget'
})javascriptcontroller = BootpayWidgetController()
.bind(this, supportFragmentManager)
.setOnChangePayment { data ->
// 구매자가 결제수단 변경
payload.mergeWidgetData(data)
updatePayButtonState()
}
.setOnChangeAgreeTerm { data ->
// 약관 동의 상태 변경
payload.mergeWidgetData(data)
updatePayButtonState()
}
.setOnReady { data ->
// 위젯 준비 완료
Log.d("Widget", "준비 완료: $data")
}
.setOnDone { data ->
// 결제 완료 → receipt_id 서버 조회
Log.d("Widget", "결제 완료: $data")
}
.setOnError { data ->
// 결제 오류
Log.e("Widget", "결제 오류: $data")
}
.setOnCancel { data ->
// 결제 취소
Log.d("Widget", "결제 취소: $data")
}
.setOnClose {
// 결제창 닫힘
finish()
}kotlinwidgetController = BootpayWidgetController()
widgetController.closeAction = .none
widgetController.onChangePayment = { [weak self] data in
// 구매자가 결제수단 변경
self?.payload.mergeWidgetData(data)
self?.updatePayButtonState()
}
widgetController.onChangeAgreeTerm = { [weak self] data in
// 약관 동의 상태 변경
self?.payload.mergeWidgetData(data)
self?.updatePayButtonState()
}
widgetController.onReady = { data in
// 위젯 준비 완료
print("준비 완료: \(data)")
}
widgetController.onDone = { data in
// 결제 완료 → receipt_id 서버 조회
print("결제 완료: \(data)")
}
widgetController.onError = { data in
// 결제 오류
print("결제 오류: \(data)")
}
widgetController.onCancel = { data in
// 결제 취소
print("결제 취소: \(data)")
}swiftvoid initController() {
_controller.onWidgetResize = (height) {
// 위젯 높이 변경 → UI 갱신
setState(() { _widgetHeight = height; });
};
_controller.onWidgetChangePayment = (widgetData) {
// 구매자가 결제수단 변경
setState(() { _payload.mergeWidgetData(widgetData); });
};
_controller.onWidgetChangeAgreeTerm = (widgetData) {
// 약관 동의 상태 변경
setState(() { _payload.mergeWidgetData(widgetData); });
};
}
// 결제 요청 시 콜백 설정
_controller.requestPayment(
context: context,
payload: _payload,
onDone: (data) => debugPrint('결제 완료: $data'),
onError: (data) => debugPrint('결제 오류: $data'),
onCancel: (data) => debugPrint('결제 취소: $data'),
onClose: () => Bootpay().dismiss(context),
onConfirm: (data) => true,
);dart// Flutter Web — web/index.html에 JS SDK 스크립트 필수
// 콜백 구조는 Flutter(모바일)와 동일해요. kIsWeb 분기로 close 동작만 조정해요.
void initController() {
_controller.onWidgetResize = (height) {
setState(() { _widgetHeight = height; });
};
_controller.onWidgetChangePayment = (widgetData) {
setState(() { _payload.mergeWidgetData(widgetData); });
};
_controller.onWidgetChangeAgreeTerm = (widgetData) {
setState(() { _payload.mergeWidgetData(widgetData); });
};
}
_controller.requestPayment(
context: context,
payload: _payload,
onDone: (data) => debugPrint('결제 완료: $data'),
onError: (data) => debugPrint('결제 오류: $data'),
onCancel: (data) => debugPrint('결제 취소: $data'),
onClose: () {
if (!kIsWeb) Bootpay().dismiss(context); // Web은 dismiss 불필요
},
onConfirm: (data) => true,
);dart<BootpayWidget ref={bootpayWidget}
client_key={'[ Client Key ]'}
height={widgetHeight}
onWidgetReady={() => {
// 위젯 렌더링 완료 → renderWidget 호출 필수
bootpayWidget.current?.renderWidget(getWidgetPayload());
}}
onWidgetResize={(height) => {
// 위젯 높이 변경
setWidgetHeight(height);
}}
onWidgetChangePayment={(data) => {
// 구매자가 결제수단 변경
setWidgetData(data);
}}
onWidgetChangeTerms={(data) => {
// 약관 동의 상태 변경
setWidgetData(data);
}}
onDone={(data) => console.log('결제 완료', data)}
onError={(data) => console.log('결제 오류', data)}
onCancel={(data) => console.log('결제 취소', data)}
onClose={() => console.log('결제창 닫힘')}
/>tsx4쿠폰·적립금으로 금액이 바뀔 때
주문서에서 쿠폰이나 적립금을 적용하면 결제 금액과 할부 옵션이 바뀌어요. 이때 주문서 금액만 바꾸지 말고 위젯에도 같은 금액을 반영해야 해요.
// 적립금 적용 시 금액 업데이트
const finalPrice = basePrice - mileageDiscount
BootpayWidget.update({
price: finalPrice,
tax_free: 0,
extra: {
card_quota: [0, 2, 3] // 할부 옵션
}
})javascript// 공개 SDK에는 별도 updateWidget 메서드가 없다.
// Payload 금액 변경 후 위젯을 다시 렌더링해요.
payload.price = finalPrice
BootpayWidget.renderWidget(this, payload, controller)kotlin// Payload 금액 변경 후 위젯 갱신
payload.price = finalPrice
widgetController.update(payload: payload, refresh: false)swift// Payload 금액 변경 후 위젯 갱신
setState(() {
_payload.price = finalPrice;
});
_controller.update(payload: _payload, refresh: false);dart// Flutter(모바일)와 동일한 Controller API 사용
setState(() {
_payload.price = finalPrice;
});
_controller.update(payload: _payload, refresh: false);dart// WidgetPayload의 price를 변경한 뒤 위젯 갱신
const updatedPayload = { ...getWidgetPayload(), price: finalPrice };
bootpayWidget.current?.updateWidget(updatedPayload);tsx5플랫폼별 상세 확인
앞의 공통 흐름을 구현하다가 SDK별 세부 동작이 필요할 때만 이 섹션을 확인해요. 아래 플랫폼을 선택하면 해당 플랫폼의 상세 본문만 표시돼요. 다른 플랫폼 본문은 숨겨져 페이지 스크롤 길이에 영향을 주지 않아요.
플랫폼별 상세 코드는 공통적으로 위젯 삽입 → 콜백에서 결제수단·약관 상태 병합 → 결제 버튼 활성화 → 결제 후 재로드/종료 처리 순서로 봐요.
Web JS 상세 API
주문서 전체 구현은 위의 전체 연동 예제 selector를 기준으로 잡고, 아래는 Web에서 필요한 API만 가져다 써요.
const widgetEl = document.querySelector('#bootpay-widget')
// Web JS는 위젯 상태를 DOM 이벤트로 받아요.
widgetEl.addEventListener('bootpay-widget-ready', () => {
console.log('위젯 준비 완료')
})
widgetEl.addEventListener('bootpay-widget-resize', (e) => {
console.log('위젯 크기 변경:', e.detail.height)
})javascript// 금액 변경
BootpayWidget.update({
price: 2000,
tax_free: 0,
extra: {
card_quota: [0, 2, 3]
}
})
// 렌더링 없이 데이터만 업데이트
BootpayWidget.update({ price: 3000 }, false)javascriptconst params = BootpayWidget.currentPaymentParameters()
// {
// pg: 'danal',
// method: 'card',
// wallet_id: null,
// terms: [...],
// widget_key: 'default-widget',
// widget_sandbox: true,
// extra: { card_quota: [0, 2, 3] }
// }javascript// 카드 결제 선택
BootpayWidget.selectPayment({ payment_type: 'card' })
// 특정 간편결제 선택
BootpayWidget.selectPayment({
payment_type: 'easy',
method_alias: 'kakaopay' // naverpay, payco, samsungpay 등
})javascriptconst terms = BootpayWidget.currentTermsCondition()
// [
// { pk: 'term1', title: '이용약관', agree: true },
// { pk: 'term2', title: '개인정보 처리방침', agree: false }
// ]
const allAgreed = terms.every(term => term.agree)javascriptif (BootpayWidget.isRendered()) {
BootpayWidget.requestPayment({
order_id: 'order_' + Date.now(), // 테스트용. 실제로는 서버에서 생성한 주문번호 사용
order_name: '테스트 결제'
})
}
function navigateToOtherPage() {
if (BootpayWidget.isRendered()) {
BootpayWidget.destroy()
}
}javascript| API | 설명 |
|---|---|
BootpayWidget.render(selector, options) |
위젯 렌더링 |
BootpayWidget.requestPayment(options) |
결제 요청 |
BootpayWidget.update(options) |
위젯 정보 업데이트 (금액 변경 등) |
BootpayWidget.destroy() |
위젯 제거 |
BootpayWidget.isRendered() |
위젯 렌더링 상태 확인 |
BootpayWidget.currentPaymentParameters() |
현재 선택된 결제 정보 조회 |
BootpayWidget.currentTermsCondition() |
현재 약관 동의 상태 조회 |
BootpayWidget.selectPayment(options) |
결제수단 프로그래밍 방식 선택 |
Android 위젯 상세
Android 위젯은 WebView 기반으로 결제 UI를 표시하며, 결제 시 전체화면으로 확장돼요. BootpayWidgetController로 위젯 상태를 관리해요.
| 콜백 | 설명 | 파라미터 |
|---|---|---|
setOnReady |
위젯 준비 완료 | 없음 |
setOnResize |
위젯 높이 변경 | height: Double (dp 단위) |
setOnChangePayment |
결제수단 변경 | data: WidgetData |
setOnChangeAgreeTerm |
약관동의 변경 | data: WidgetData |
setOnDone |
결제 완료 | data: String (JSON) |
setOnError |
결제 에러 | data: String (JSON) |
setOnCancel |
결제 취소 | data: String (JSON) |
setOnConfirm |
결제 확인 (검증) | data: String → Boolean 반환 |
setOnIssued |
가상계좌 발급 | data: String (JSON) |
setOnClose |
위젯 닫기 | 없음 |
onChangePayment과 onChangeAgreeTerm 콜백에서 payload.mergeWidgetData(data)를 호출하면, 위젯에서 선택한 결제수단/약관동의 상태가 Payload에 자동 반영돼요. payload.widgetIsCompleted가 true이면 결제수단 선택과 약관동의가 모두 완료된 상태이에요.
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<FrameLayout
android:id="@+id/webViewContainer"
android:layout_width="match_parent"
android:layout_height="516dp" />
</ScrollView>
<Button
android:id="@+id/payButton"
android:layout_width="match_parent"
android:layout_height="56dp"
android:enabled="false"
android:text="결제하기"
android:onClick="goPayment" />
</LinearLayout>xmlcontroller = BootpayWidgetController()
.setCloseAction(WidgetCloseAction.NONE) // 직접 처리 (권장)kotlin// 결제 완료 시 — 축소 후 결과 페이지로 이동
.setOnDone { data ->
BootpayWidget.collapseAndFinish(this)
runOnUiThread { finish() }
}
// 결제 에러/취소 시 — 축소 후 위젯 재로드 (재시도 가능)
.setOnError { data ->
BootpayWidget.collapseAndReload(this)
}kotlinoverride fun onBackPressed() {
val webView = BootpayWidget.getView(this, supportFragmentManager)
if (webView?.isExpanded == true) {
BootpayWidget.collapseAndReload(this)
} else {
super.onBackPressed()
}
}kotlinpayload.extra = BootExtra().apply {
appScheme = "yourAppScheme"
displaySuccessResult = true
displayErrorResult = true
}kotlin<activity
android:name=".WidgetActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="yourAppScheme" />
</intent-filter>
</activity>xml| 항목 | 핵심 |
|---|---|
WidgetCloseAction.NONE |
위젯 닫기 이후 화면 이동을 앱 코드에서 직접 처리할 때 권장 |
collapseAndReload(activity) |
취소·에러 후 축소하고 위젯을 재로드 |
collapseAndFinish(activity) |
결제 완료 후 축소하고 Activity 종료 |
displaySuccessResult / displayErrorResult |
false가 기본값. 앱에서 결과 화면을 직접 구현할 때 권장 |
- 위젯 높이:
onResize콜백의 높이 값은 dp 단위이에요.density를 곱하여 px로 변환 후 적용해요. - 결제 버튼:
payload.widgetIsCompleted가true일 때만 결제 버튼을 활성화해요. - 앱 스킴:
extra.appScheme과 AndroidManifest.xml의 scheme이 일치해야 해요. - 메모리 관리:
onDestroy()에서BootpayWidget.destroy()를 호출해요. - 뒤로가기:
onBackPressed()에서 전체화면 상태를 확인 후 적절히 처리해요.
iOS 위젯 상세
iOS 위젯은 BootpayWidgetView(UIView 서브클래스)를 화면에 삽입하고, BootpayWidgetController로 이벤트를 관리해요.
| 클래스 | 역할 |
|---|---|
BootpayWidgetView |
결제위젯을 표시하는 UIView |
BootpayWidgetController |
위젯 상태 관리 및 이벤트 콜백 처리 |
Payload |
결제 정보 데이터 |
| 콜백 | 설명 | 파라미터 |
|---|---|---|
onReady |
위젯 준비 완료 | 없음 |
onResize |
위젯 높이 변경 | height: CGFloat |
onChangePayment |
결제수단 변경 | data: WidgetData |
onChangeAgreeTerm |
약관동의 변경 | data: WidgetData |
onDone |
클라이언트 자동 승인 시 결제 완료 | data: [String: Any] |
onError |
결제 에러 | data: [String: Any] |
onCancel |
결제 취소 | data: [String: Any] |
onConfirm |
결제 확인 (검증) | data: [String: Any] → Bool 반환 |
onIssued |
가상계좌 발급 | data: [String: Any] |
onClose |
위젯 닫기 | 없음 |
// 1. WidgetView 생성 및 화면에 추가
widgetView = BootpayWidgetView()
view.addSubview(widgetView)
// 2. Controller 생성 및 콜백 설정
widgetController = BootpayWidgetController()
widgetController.onChangePayment = { data in ... }
// 3. Controller를 View에 연결
widgetView.controller = widgetController
// 4. Payload 설정 후 위젯 시작
widgetView.payload = payload
widgetView.startWidget()swift// NavigationController 사용 시
widgetController.closeAction = .popViewController
// Modal 사용 시
widgetController.closeAction = .dismissViewController
// 직접 처리 (권장)
widgetController.closeAction = .none
widgetController.onClose = { [weak self] in
self?.navigationController?.popToRootViewController(animated: true)
}swiftwidgetHeightConstraint = widgetView.heightAnchor.constraint(equalToConstant: 516)
widgetHeightConstraint.isActive = true
widgetController.onResize = { [weak self] height in
DispatchQueue.main.async {
self?.widgetHeightConstraint.constant = height
self?.view.layoutIfNeeded()
}
}swift// 기본값: 앱에서 직접 결과 화면 구현 (권장)
payload.extra = BootExtra()
payload.extra?.appScheme = "yourAppScheme"
// 빠른 연동: 웹뷰에서 결과 화면 표시
payload.extra?.displaySuccessResult = true
payload.extra?.displayErrorResult = trueswiftwidgetController.onError = { [weak self] data in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self?.widgetView.reloadWidget()
}
}swiftclass WidgetData {
var pg: String?
var method: String?
var termPassed: Bool
var completed: Bool
var cardQuota: String?
var methodSymbol: String?
var easyPay: String?
}swiftonChangePayment과 onChangeAgreeTerm 콜백에서 payload.mergeWidgetData(data)를 호출하면, 위젯에서 선택한 결제수단/약관동의 상태가 Payload에 자동 반영돼요. payload.widgetIsCompleted가 true이면 결제수단 선택과 약관동의가 모두 완료된 상태이에요.
- 위젯 높이:
onResize콜백에서widgetHeightConstraint를 업데이트해야 해요. - 결제 버튼:
payload.widgetIsCompleted가true일 때만 결제 버튼을 활성화해요. - 앱 스킴:
payload.extra?.appScheme을 설정하고 Info.plist에 URL Scheme을 등록해요. - 메모리 관리: 클로저에서
[weak self]를 사용하여 순환 참조를 방지해요.
Flutter 위젯 상세
Flutter 위젯은 BootpayWidget StatefulWidget을 화면에 삽입하고, BootpayWidgetController로 이벤트와 결제를 관리해요. 위젯 전용 BootpayWidgetWebView를 내부적으로 사용해요.
| 클래스 | 역할 |
|---|---|
BootpayWidget |
결제위젯을 표시하는 StatefulWidget |
BootpayWidgetController |
위젯 상태 관리, 결제 요청, 이벤트 콜백 |
Payload |
결제 정보 데이터 |
| 콜백 | 설명 | 파라미터 |
|---|---|---|
onWidgetReady |
위젯 준비 완료 | 없음 |
onWidgetResize |
위젯 높이 변경 | height: double |
onWidgetChangePayment |
결제수단 변경 | data: WidgetData |
onWidgetChangeAgreeTerm |
약관동의 변경 | data: WidgetData |
onDone |
클라이언트 자동 승인 시 결제 완료 | data: String (JSON) |
onError |
결제 에러 | data: String (JSON) |
onCancel |
결제 취소 | data: String (JSON) |
onConfirm |
결제 확인 (검증) | data: String → bool 반환 |
onIssued |
가상계좌 발급 | data: String (JSON) |
onClose |
위젯 닫기 | 없음 |
double _widgetHeight = 516.0;
_controller.onWidgetResize = (height) {
if (_widgetHeight == height) return;
setState(() { _widgetHeight = height; });
};
SizedBox(
height: _widgetHeight,
child: BootpayWidget(
payload: _payload,
controller: _controller,
),
)dart_controller.reloadWidget();dart// 기본값: 앱에서 직접 결과 화면 구현 (권장)
_payload.extra = Extra()..appScheme = 'yourAppScheme';
// 빠른 연동: 웹뷰에서 결과 화면 표시
_payload.extra = Extra()
..appScheme = 'yourAppScheme'
..displaySuccessResult = true
..displayErrorResult = true;dartclass WidgetData {
String? pg;
String? method;
String? walletId;
List<WidgetTerm>? selectTerms;
String? currency;
bool? termPassed;
bool? completed;
WidgetExtra? extra;
String? methodSymbol;
String? easyPay;
String? cardQuota;
}dart<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="yourAppScheme" />
</intent-filter>
</activity>xml<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>yourAppScheme</string>
</array>
</dict>
</array>xmlonWidgetChangePayment과 onWidgetChangeAgreeTerm 콜백에서 _payload.mergeWidgetData(widgetData)를 호출하면, 위젯에서 선택한 결제수단/약관동의 상태가 Payload에 자동 반영돼요. _payload.widgetIsCompleted가 true이면 결제수단 선택과 약관동의가 모두 완료된 상태이에요.
- 위젯 높이:
onWidgetResize콜백에서setState로SizedBox높이를 업데이트해야 해요. - 결제 버튼:
_payload.widgetIsCompleted가true일 때만 결제 버튼을 활성화해요. - ScrollView: 위젯 높이가 동적으로 변경되므로
SingleChildScrollView를 사용해요. - dismiss:
onClose콜백에서Bootpay().dismiss(context)를 호출해요. 웹에서는kIsWeb체크 후 skip해요. - 앱 스킴: Android
AndroidManifest.xml과 iOSInfo.plist에 모두 등록해야 해요.
React Native 위젯 상세
React Native 위젯은 BootpayWidget 컴포넌트와 ref를 통한 명령형 API로 동작해요. 다른 SDK와 달리 onWidgetReady 콜백에서 renderWidget()을 명시적으로 호출해야 해요.
| 메서드 | 설명 | 호출 시점 |
|---|---|---|
renderWidget(payload) |
위젯 초기 렌더링 | onWidgetReady 콜백 내에서 |
updateWidget(payload, refresh?) |
위젯 데이터 업데이트 | 금액/상품 변경 시 |
requestPayment() |
결제 요청 (전체화면 전환) | 결제 버튼 클릭 시 |
transactionConfirm() |
프론트엔드 승인 확정 | onConfirm 콜백 내에서. 서버 승인에서는 호출하지 않음 |
reloadWidget() |
위젯 재로드 | 결제 완료/에러/취소 후 |
| 콜백 | 설명 | 파라미터 |
|---|---|---|
onWidgetReady |
위젯 준비 완료 | 없음 |
onWidgetResize |
위젯 높이 변경 | height: number |
onWidgetChangePayment |
결제수단 변경 | data: WidgetData | null |
onWidgetChangeTerms |
약관동의 변경 | data: WidgetData | null |
onDone |
클라이언트 자동 승인 시 결제 완료 | data: unknown (JSON) |
onError |
결제 에러 | data: unknown (JSON) |
onCancel |
결제 취소 | data: unknown (JSON) |
onConfirm |
결제 승인 확인 | data: unknown → boolean 반환 |
onIssued |
가상계좌 발급 | data: unknown (JSON) |
onClose |
결제창 닫힘 | 없음 |
위젯 렌더링 흐름은 마운트 → onWidgetReady → renderWidget(payload) → 결제수단/약관 변경 → 버튼 활성화 → requestPayment() 순서이에요.
const widgetPlaceholderRef = useRef<View>(null);
const [widgetTop, setWidgetTop] = useState(0);
const measureWidgetPosition = useCallback(() => {
widgetPlaceholderRef.current?.measureInWindow((x, y) => {
if (y > 0) setWidgetTop(y);
});
}, []);
<View ref={widgetPlaceholderRef}
style={{ minHeight: widgetHeight }}
onLayout={() => setTimeout(measureWidgetPosition, 100)} />
<BootpayWidget widgetTop={widgetTop} height={widgetHeight} ... />tsxconst [widgetData, setWidgetData] = useState<WidgetData | null>(null);
const isCompleted =
(widgetData?.term_passed ?? false) && (widgetData?.completed ?? false);
<TouchableOpacity
onPress={goPayment}
disabled={!isCompleted}
style={[styles.payButton, !isCompleted && styles.disabled]}>
<Text>결제하기</Text>
</TouchableOpacity>tsxconst updatePrice = (newPrice: number) => {
const payload = getWidgetPayload();
payload.price = newPrice;
bootpayWidget.current?.updateWidget(payload);
};
// 전체 새로고침
bootpayWidget.current?.updateWidget(payload, true);
// 에러/취소 후 위젯 재로드
setTimeout(() => bootpayWidget.current?.reloadWidget(), 500);tsxinterface WidgetData {
pg?: string;
method?: string;
wallet_id?: string;
select_terms?: WidgetTerm[];
currency?: string;
term_passed?: boolean;
completed?: boolean;
extra?: WidgetExtra;
method_symbol?: string;
easy_pay?: string;
card_quota?: string;
}typescript<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="yourAppScheme" />
</intent-filter>
</activity>xml<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>yourAppScheme</string>
</array>
</dict>
</array>xml| 옵션 | false (기본값, 권장) |
true |
|---|---|---|
display_success_result |
클라이언트 자동 승인 기준: 즉시 onDone 호출 → 앱에서 결과 처리 |
웹뷰 결과 화면 → 닫기 클릭 → onClose |
display_error_result |
즉시 onError 호출 → 위젯 재로드 가능 |
웹뷰 에러 화면 → 닫기 클릭 → onClose |
onWidgetReady: 위젯 준비 후renderWidget(payload)을 반드시 호출해야 해요.widgetTop:position: absolute기반이므로 placeholder의measureInWindow로 Y 좌표를 측정해요.- 결제 버튼:
widgetData?.completed && widgetData?.term_passed가 모두true일 때 활성화해요. - 서버 승인:
extra.separately_confirmed: true사용 시onConfirm에서receipt_id를 서버로 전달해요. 이 방식에서는onDone이 호출되지 않으며,transactionConfirm()도 클라이언트에서 호출하지 않아요. - 앱 스킴: Android
AndroidManifest.xml과 iOSInfo.plist에 모두 등록해야 해요. - iOS 스와이프 백: 전체화면 결제 모드에서 좌측 스와이프 지원을 위해
react-native-gesture-handler가 필요해요.
6다음 단계
연동이 끝났다면 운영 설정과 확장 기능을 이어서 확인해요.
- 위젯 생성 — 결제수단, PG 가중치, 약관, 디자인, 브랜드페이 노출 설정
- 브랜드페이 연동 가이드 — 한 번 등록한 지불수단으로 재결제를 쉽게 하는 흐름
- 정기결제위젯 연동 — 구독 결제용 위젯과 빌링키 등록 흐름
- 결제위젯 도입 전에 비교할 기준 — PG 결제창과 위젯 방식의 차이와 선택 조건
