이 문서는 빌링키 발급부터 저장, 첫 결제, 회차 청구까지 빠르게 이어보는 매뉴얼이에요. 빌링키 저장 설계·조회·삭제·청구 API 세부 옵션은 자동결제 상세 문서에서 확인해요.
자동결제는 빌링키로 원하는 시점에 결제 승인을 요청하는 방식이에요. 고객이 결제창에 카유생비(카드번호, 유효기간, 생년월일, 비밀번호 앞 2자리)를 입력하면 PG/카드사가 카드 정보를 확인하고 빌링키를 발급해요. 가맹점 서버는 카드 정보를 직접 저장할 수 없기 때문에, 실제 카드 정보 대신 결제에 사용할 수 있는 암호화된 식별값을 저장해야 해요. 이 값이 빌링키예요.
빌링키 발급 방식은 두 가지예요. 보통은 프론트엔드에서 PG 결제창을 띄워 고객이 직접 입력하는 방식을 써요. 일부 PG에서는 백엔드 REST API로 카드 정보를 전달해 직접 발급하는 방식도 지원해요.
어느 방식이든 최종적으로는 백엔드가 billing_key를 저장해야 해요. 이후 서버는 저장된 빌링키로 Bootpay API에 결제 승인을 요청해야 해요. 빌링키가 유효하면 청구 금액과 요청 주기는 가맹점 정책에 맞게 정할 수 있어요.
데모 영상
빌링키를 저장한 뒤 자동결제를 운영하는 방식은 두 가지예요. 차이는 누가 회차 실행 시점을 들고 있느냐예요.
- A. 서버 cron으로 매번 빌링키 결제 요청 (이 빠른 매뉴얼) — 매 회차마다 서버 스케줄러가 Bootpay 승인 API를 호출해야 해요. 회차 주기 변경·일할 청구·재시도 정책을 자유롭게 바꿔야 할 때 적합.
- B. 예약결제를 회차마다 반복 등록 (예약결제) — 결제일마다 예약을 걸어두면 PG사가 시간 맞춰 자체 실행해요. 자체 스케줄러 없이 굴리고 싶을 때 적합.
구독 시 금액 변경, 취소가 빈번히 발생하면 A, 결제일이 고정된 멤버십·구독이면 B를 권장해요.
회차 관리·조정·해지까지 Bootpay에 맡기려면 커머스 구독 가이드로 가요. 이 빠른 매뉴얼은 "회차 로직을 서비스가 직접 짜요"는 전제예요.
연동 흐름
빌링키 발급은 A 또는 B 중 하나를 선택해요. 그 뒤로는 서버가 스케줄러·청구·재시도·해지를 모두 직접 처리해야 해요. 예약결제와 달리 PG사가 회차 결제를 자동 실행하지 않아요.
가맹점 측 DB 스키마(빌링키 + 회차 관리 테이블·인덱스·status 전이)는 데이터 저장 설계에서 다뤄요. 아래 코드는 그 스키마를 사용한다고 가정해요.
구현 순서
빌링키 발급 A — PG 결제창에서 발급 (프론트엔드)
권장 방식이에요. 프론트엔드에서 Bootpay SDK로 PG 결제창을 띄우고, 고객이 결제창에 카유생비를 입력하면 빌링키 발급이 진행돼요. 발급 완료 후에는 receipt_id를 백엔드로 보내고, 서버에서 빌링키를 조회해 저장해야 해요.
async function registerCard() {
const response = await Bootpay.requestSubscription({
application_id: 'YOUR_CLIENT_KEY',
pg: '나이스페이',
method: '카드자동',
subscription_id: 'sub_' + Date.now(),
price: 0, // 빌링키만 발급
order_name: '정기결제 카드 등록',
user: { username: '홍길동', phone: '01012345678' },
extra: { subscribe_test_payment: true },
})
if (response.event === 'done') {
await fetch('/api/recurring/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
receipt_id: response.receipt_id,
plan: 'monthly',
}),
})
}
}javascriptlet payload = Payload()
payload.applicationId = BootpayConfig.applicationId
payload.pg = "나이스페이"
payload.method = "카드자동"
payload.subscriptionId = String(Date().timeIntervalSince1970)
payload.price = 0
payload.orderName = "정기결제 카드 등록"
payload.extra = BootExtra()
payload.extra?.subscribeTestPayment = true
Bootpay.requestSubscription(viewController: self, payload: payload)
.onDone { data in
// receipt_id를 백엔드 /api/recurring/register로 전송 (plan 포함)
self.registerRecurring(receiptId: data["receipt_id"] as? String ?? "", plan: "monthly")
}
.onError { _ in }
.onCancel { _ in }swiftval extra = BootExtra().setSubscribeTestPayment(true)
val payload = Payload()
.setApplicationId(BootpayConfig.applicationId)
.setOrderName("정기결제 카드 등록")
.setPg("나이스페이")
.setMethod("카드자동")
.setSubscriptionId("sub_${System.currentTimeMillis()}")
.setPrice(0.0)
.setExtra(extra)
Bootpay.init(this).setPayload(payload)
.setEventListener(object : BootpayEventListener {
override fun onDone(data: String) {
// data의 receipt_id + plan을 /api/recurring/register로 전송
registerRecurring(data, "monthly")
}
override fun onError(data: String) {}
override fun onCancel(data: String) {}
override fun onClose() { Bootpay.removePaymentWindow() }
override fun onIssued(data: String) {}
override fun onConfirm(data: String) = true
}).requestSubscription()kotlinfinal payload = Payload()
..webApplicationId = BootpayEnvConfig.webApplicationId
..androidApplicationId = BootpayEnvConfig.androidApplicationId
..iosApplicationId = BootpayEnvConfig.iosApplicationId
..pg = '나이스페이'
..method = '카드자동'
..subscriptionId = DateTime.now().millisecondsSinceEpoch.toString()
..price = 0
..orderName = '정기결제 카드 등록'
..extra = (Extra()..subscribeTestPayment = 1);
Bootpay().requestSubscription(
context: context,
payload: payload,
onDone: (data) {
// receipt_id를 백엔드 /api/recurring/register로 전송 (plan 포함)
registerRecurring(data, 'monthly');
},
onError: (data) => debugPrint('error: $data'),
onCancel: (data) => debugPrint('cancel: $data'),
onClose: () {},
);dartconst payload = {
pg: '나이스페이',
method: '카드자동',
order_name: '정기결제 카드 등록',
subscription_id: `sub_${Date.now()}`,
price: 0,
}
const user = { username: '홍길동', phone: '01012345678' }
const extra = { subscribe_test_payment: true }
bootpay.current?.requestSubscription(payload, [], user, extra)
function onDone(data) {
// receipt_id를 백엔드 /api/recurring/register로 전송 (plan 포함)
registerRecurring(data.receipt_id, 'monthly')
}javascript상세 문서: 빌링키 발급
빌링키 발급 B — REST API로 직접 발급 (백엔드, 선택)
일부 PG는 백엔드 REST API로 빌링키를 직접 발급할 수 있어요. 이 방식은 가맹점 화면에서 카드 정보를 입력받아 서버로 전달하고, 서버가 Bootpay API에 빌링키 발급을 요청해요.
나이스페이먼츠, 페이앱, 웰컴페이먼츠, 토스페이먼츠, 키움페이
카드번호·유효기간·생년월일·비밀번호 앞 2자리를 가맹점 시스템이 직접 취급하므로, 지원 PG 여부와 보안 책임을 먼저 확인해야 해요. 가능하면 A 방식처럼 PG 결제창에서 입력받는 흐름을 우선 검토해요.
app.post('/api/billing-key/issue', auth, async (req, res) => {
const { card_no, card_pw, card_identity_no, card_expire_year, card_expire_month } = req.body
const info = await Bootpay.requestSubscribeBillingKey({
pg: '나이스페이',
subscription_id: `sub_${req.user.id}_${Date.now()}`,
order_name: '정기결제 카드 등록',
card_no,
card_pw,
card_identity_no,
card_expire_year,
card_expire_month,
})
await db.billingKeys.create({
user_id: req.user.id,
billing_key: info.billing_key,
card_name: info.card_name,
card_last4: info.card_no?.slice(-4),
status: 'active'
})
res.json({ success: true })
})javascript상세 문서: 백엔드에서 발급하기
빌링키 저장 + 첫 결제 (백엔드)
A 방식은 프론트엔드에서 받은 receipt_id로 빌링키를 조회한 뒤 저장해야 해요. B 방식은 발급 API 응답에 billing_key가 들어오므로 조회 단계 없이 저장할 수 있어요.
아래 예시는 A 방식 기준이에요. lookupSubscribeBillingKey로 발급된 빌링키를 조회하고, requestSubscribePayment로 첫 결제를 실행해요.
const PLANS = {
monthly: { name: '월간 구독', price: 9900, interval_days: 30 },
yearly: { name: '연간 구독', price: 99000, interval_days: 365 }
}
app.post('/api/recurring/register', auth, async (req, res) => {
const { receipt_id, plan } = req.body
const planInfo = PLANS[plan]
if (!planInfo) return res.status(400).json({ error: '잘못된 플랜' })
// 빌링키 조회·저장
const info = await Bootpay.lookupSubscribeBillingKey(receipt_id)
await db.billingKeys.create({
user_id: req.user.id,
billing_key: info.billing_key,
card_name: info.card_name,
card_last4: info.card_no.slice(-4)
})
// 첫 결제 실행
const payment = await Bootpay.requestSubscribePayment({
billing_key: info.billing_key,
price: planInfo.price,
order_name: planInfo.name,
order_id: `recurring_${req.user.id}_${Date.now()}`
})
if (payment.status === 1) {
// 다음 결제일 등록
const nextDate = new Date()
nextDate.setDate(nextDate.getDate() + planInfo.interval_days)
await db.recurringPayments.create({
user_id: req.user.id,
plan,
amount: planInfo.price,
next_payment_at: nextDate,
receipt_id: payment.receipt_id,
status: 'active'
})
res.json({ success: true, next_payment_at: nextDate })
} else {
res.json({ success: false, error: '첫 결제 실패' })
}
})javascript# 1) 빌링키 조회
info = bootpay.lookup_subscribe_billing_key(receipt_id)
# info['billing_key'] / info['card_name'] / info['card_no']를 DB에 저장
# 2) 첫 결제 실행
payment = bootpay.request_subscribe_payment(
billing_key=info['billing_key'],
price=plan_info['price'],
order_name=plan_info['name'],
order_id=f'recurring_{user_id}_{int(time.time())}',
)
# payment['status'] == 1이면 성공 → 다음 결제일 등록python// 1) 빌링키 조회
$info = BootpayApi::lookupSubscribeBillingKey($receiptId);
// $info['billing_key'] / $info['card_name'] / $info['card_no']를 DB에 저장
// 2) 첫 결제 실행
$payment = BootpayApi::requestSubscribePayment([
'billing_key' => $info['billing_key'],
'price' => $planInfo['price'],
'order_name' => $planInfo['name'],
'order_id' => 'recurring_' . $userId . '_' . time(),
]);
// $payment['status'] == 1이면 성공 → 다음 결제일 등록php// 1) 빌링키 조회
var info = bootpay.lookupBillingKey(receiptId);
// info.get("billing_key") / info.get("card_name") / info.get("card_no")를 DB에 저장
// 2) 첫 결제 실행
SubscribePayload payload = new SubscribePayload();
payload.billingKey = (String) info.get("billing_key");
payload.price = planInfo.price;
payload.orderName = planInfo.name;
payload.orderId = "recurring_" + userId + "_" + System.currentTimeMillis();
var payment = bootpay.requestSubscribe(payload);
// payment.get("status") == 1이면 성공 → 다음 결제일 등록java# 1) 빌링키 조회
info = bootpay.request(method: :get, uri: "subscribe/billing_key/#{receipt_id}").data
# info['billing_key'] / info['card_name'] / info['card_no']를 DB에 저장
# 2) 첫 결제 실행
payment = bootpay.request(
uri: 'subscribe/payment',
payload: {
billing_key: info['billing_key'],
price: plan_info[:price],
order_name: plan_info[:name],
order_id: "recurring_#{user_id}_#{Time.now.to_i}"
}
).data
# payment['status'] == 1이면 성공 → 다음 결제일 등록ruby// 1) 빌링키 조회
info, err := api.LookupBillingKey(receiptID)
if err != nil {
log.Fatal(err)
}
billingKey, _ := info["billing_key"].(string)
// info["billing_key"] / info["card_name"] / info["card_no"]를 DB에 저장
// 2) 첫 결제 실행
payment, err := api.RequestSubscribe(bootpay.SubscribePayload{
BillingKey: billingKey,
Price: planInfo.Price,
OrderName: planInfo.Name,
OrderId: fmt.Sprintf("recurring_%d_%d", userID, time.Now().Unix()),
})
status := int(payment["status"].(float64))
// status == 1이면 성공 → 다음 결제일 등록go// 1) 빌링키 조회
var infoResponse = await bootpay.LookupBillingKey(receiptId);
var infoJson = await infoResponse.Content.ReadAsStringAsync();
// infoJson에서 data.billing_key / card_name / card_no를 추출해 DB에 저장
var billingKey = "[ parsed billing_key ]";
// 2) 첫 결제 실행
var paymentResponse = await bootpay.RequestSubscribe(new SubscribePayload
{
billingKey = billingKey,
price = planInfo.Price,
orderName = planInfo.Name,
orderId = $"recurring_{userId}_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}",
});
var paymentJson = await paymentResponse.Content.ReadAsStringAsync();
// paymentJson의 status가 1이면 성공 → 다음 결제일 등록csharp상세 문서: 빌링키 조회 · 빌링키 결제 요청 — 7개 언어 예제를 확인해요.
회차 자동 결제 — 스케줄러 (백엔드)
매일 실행되는 스케줄러(cron)로 결제일이 된 건을 처리해야 해요. 스케줄러·큐·재시도·이메일·일시정지 정책은 각 백엔드 프레임워크의 방식대로 구현하고, 스케줄러 안에서는 저장된 billing_key로 결제 요청 API를 호출해야 해요.
const payment = await Bootpay.requestSubscribePayment({
billing_key: recurring.billing_key,
price: recurring.amount,
order_name: recurring.order_name,
order_id: `recurring_${recurring.user_id}_${Date.now()}`
})
if (payment.status === 1) {
// receipt_id 저장, 다음 결제일 갱신
await markRecurringPaid(recurring, payment.receipt_id)
} else {
await handlePaymentFailure(recurring)
}javascriptpayment = bootpay.request_subscribe_payment(
billing_key=recurring['billing_key'],
price=recurring['amount'],
order_name=recurring['order_name'],
order_id=f"recurring_{recurring['user_id']}_{int(time.time())}",
)
if payment['status'] == 1:
# receipt_id 저장, 다음 결제일 갱신
mark_recurring_paid(recurring, payment['receipt_id'])
else:
handle_payment_failure(recurring)python$payment = BootpayApi::requestSubscribePayment([
'billing_key' => $recurring['billing_key'],
'price' => $recurring['amount'],
'order_name' => $recurring['order_name'],
'order_id' => 'recurring_' . $recurring['user_id'] . '_' . time(),
]);
if ($payment['status'] === 1) {
// receipt_id 저장, 다음 결제일 갱신
markRecurringPaid($recurring, $payment['receipt_id']);
} else {
handlePaymentFailure($recurring);
}phpSubscribePayload payload = new SubscribePayload();
payload.billingKey = recurring.getBillingKey();
payload.price = recurring.getAmount();
payload.orderName = recurring.getOrderName();
payload.orderId = "recurring_" + recurring.getUserId() + "_" + System.currentTimeMillis();
var payment = bootpay.requestSubscribe(payload);
if ((int) payment.get("status") == 1) {
// receipt_id 저장, 다음 결제일 갱신
markRecurringPaid(recurring, (String) payment.get("receipt_id"));
} else {
handlePaymentFailure(recurring);
}javapayment = bootpay.request(
uri: 'subscribe/payment',
payload: {
billing_key: recurring[:billing_key],
price: recurring[:amount],
order_name: recurring[:order_name],
order_id: "recurring_#{recurring[:user_id]}_#{Time.now.to_i}"
}
).data
if payment['status'] == 1
# receipt_id 저장, 다음 결제일 갱신
mark_recurring_paid(recurring, payment['receipt_id'])
else
handle_payment_failure(recurring)
endrubypayment, err := api.RequestSubscribe(bootpay.SubscribePayload{
BillingKey: recurring.BillingKey,
Price: recurring.Amount,
OrderName: recurring.OrderName,
OrderId: fmt.Sprintf("recurring_%d_%d", recurring.UserID, time.Now().Unix()),
})
if err != nil {
handlePaymentFailure(recurring)
return
}
status := int(payment["status"].(float64))
if status == 1 {
// receipt_id 저장, 다음 결제일 갱신
receiptID, _ := payment["receipt_id"].(string)
markRecurringPaid(recurring, receiptID)
} else {
handlePaymentFailure(recurring)
}govar paymentResponse = await bootpay.RequestSubscribe(new SubscribePayload
{
billingKey = recurring.BillingKey,
price = recurring.Amount,
orderName = recurring.OrderName,
orderId = $"recurring_{recurring.UserId}_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}",
});
var paymentJson = await paymentResponse.Content.ReadAsStringAsync();
// paymentJson의 status와 receipt_id를 확인해 성공/실패 처리를 나눠요.
// status == 1이면 receipt_id를 저장하고 다음 결제일을 갱신해요.csharp스케줄러는 서버 스택에서 쓰는 표준 도구를 붙이면 돼요. Python은 APScheduler나 Celery beat, Java는 Spring @Scheduled나 Quartz, Ruby는 whenever나 Sidekiq, Go는 robfig/cron, .NET은 IHostedService를 사용할 수 있어요.
이 cron 코드를 굴리기 싫다면, 같은 빌링키로 결제일마다 예약결제를 등록해 PG사가 실행하게 할 수도 있어요. → 예약결제
구독 해지 (백엔드)
app.post('/api/recurring/cancel', auth, async (req, res) => {
const recurring = await db.recurringPayments.findOne({
where: { user_id: req.user.id, status: ['active', 'paused'] }
})
if (!recurring) return res.status(404).json({ error: '활성 구독 없음' })
// 구독 상태 변경 (현재 주기 끝까지는 서비스 유지 가능)
await recurring.update({ status: 'cancelled' })
// 빌링키 삭제 (선택 — 재구독 가능하게 하려면 유지)
// await Bootpay.destroyBillingKey(billingKey.billing_key)
res.json({ success: true, message: '구독이 해지되었습니다' })
})javascript상세 문서: 빌링키 삭제
마이페이지 — 구독 조회 (백엔드)
app.get('/api/my/recurring', auth, async (req, res) => {
const recurring = await db.recurringPayments.findOne({
where: { user_id: req.user.id, status: ['active', 'paused'] }
})
if (!recurring) return res.json({ active: false })
const billingKey = await db.billingKeys.findOne({
where: { user_id: req.user.id, status: 'active' }
})
res.json({
active: true,
plan: recurring.plan,
amount: recurring.amount,
status: recurring.status,
next_payment_at: recurring.next_payment_at,
card: billingKey ? {
name: billingKey.card_name,
last4: billingKey.card_last4
} : null
})
})javascript다음 단계
- 빌링키 저장 설계·조회·삭제·청구 API를 자세히 보려면 → 자동결제 상세 문서
- 원하는 시점에만 결제를 실행하려면 → 예약결제
더 읽을거리
- 구독 상품 유형별 차이 — 구독 상품 유형별 운영 정책
- 결제 실패 재시도 정책 — 언제·몇 번·얼마나 늦게
