결제창

분리 승인

인증 완료 후 서버에서 한 번 더 확인하고 승인해요.

extra.separately_confirmed: true는 구매자 인증이 끝난 뒤 결제 승인 직전의 confirm 이벤트를 받기 위한 옵션​​이에요. 이 시점에 서버가 주문 금액·주문 상태·재고·쿠폰·적립금 같은 조건을 다시 확인하고, 문제가 없을 때만 결제를 승인해야 해요.

영상으로 보기

서버승인, 클라이언트 승인, 웹훅 이해하기 · 6분 35초

분리 승인이란?

분리 승인은 인증과 승인을 분리하는 방식​​이에요. 일반 결제는 구매자 인증이 끝나면 PG가 곧바로 승인까지 진행할 수 있지만, 분리 승인을 켜면 승인 직전에 confirm 이벤트로 제어권을 한 번 넘겨줘요.

이때 서버에서 확인할 수 있는 대표 조건은 아래와 같아요.

  • DB에 저장된 주문 금액과 Bootpay 결제 금액이 일치하는지
  • 주문 상태가 아직 결제 대기 상태인지
  • 재고를 차감해도 되는지
  • 쿠폰·적립금·프로모션 조건이 여전히 유효한지
  • 이미 처리한 receipt_id가 아닌지

승인 호출은 두 가지 방식이 있어요. 운영에서는 백엔드가 Bootpay 승인 API를 호출하는 서버 승인​​을 권장해요. 서버 승인 API를 지원하지 않는 PG를 써야 한다면, 서버가 먼저 검증하고 프론트엔드가 Bootpay.confirm() 또는 SDK별 승인 함수를 호출하는 예외 흐름을 사용해요.

분리 승인을 지원하지 않는 PG

페이앱·페이레터·키움페이는 현재 분리 승인을 기술적으로 지원하지 않아요. 이런 PG를 쓸 때는 separately_confirmed: false로 두고 결제 조회 API로 승인 결과를 한 번 더 조회해 확정하거나, 검증에서 문제를 발견하면 결제 취소 API로 즉시 환불해요.

분리 승인 흐름

결제창 연동에는 두 가지 승인 흐름이 있어요. 운영 기준은 분리 승인을 먼저 검토하고, PG가 지원하지 않거나 단순 결제 흐름이면 자동 승인 후 서버 검증을 사용해요.

분리 승인 자동 승인 후 서버 검증
설정 extra.separately_confirmed: true 기본값
승인 시점 백엔드가 승인 API를 호출할 때 PG 결제창에서 자동 승인
프론트엔드 이벤트 confirm done
서버 역할 승인 전 주문·금액·재고 검증 후 승인 승인 후 receipt_id로 결제 상태·금액 검증
적합한 경우 재고·쿠폰·적립금처럼 승인 전 판단이 필요한 주문 단순 결제 또는 분리 승인을 지원하지 않는 PG

분리 승인에서는 confirm이 결제 완료가 아니에요. 서버가 승인 API를 호출하고 주문을 확정한 뒤에야 결제 완료로 봐야 해요. 클라이언트와 웹훅이 둘 다 도착할 수 있으므로 receipt_id 기준으로 멱등 처리해야 해요.

서버 승인

백엔드에서 Bootpay 승인 API를 호출해 결제를 확정하는 패턴이에요. 재고·적립금·쿠폰 차감이 결제 승인과 같은 트랜잭션에 묶여야 하는 운영 서비스라면 이 방식을 권장해요.

confirm 이벤트 수신 및 서버 전달

(프론트엔드) 인증 완료 후 confirm을 받으면 receipt_idorder_id를 서버로 전달해요. 이 단계는 결제 완료가 아니에요.

서버 검증

(서버) DB에 저장된 주문 금액·주문 상태·재고·쿠폰·적립금 조건을 확인해요. 승인해도 되는 주문인지 먼저 판단해야 해요.

서버 승인 수행

(서버) 검증을 통과한 경우에만 Bootpay 승인 API를 호출하고, 승인 결과를 DB에 반영해요.

결제 결과 표시

(프론트엔드) 서버 승인 방식에서는 클라이언트의 done이 호출되지 않아요. 결과 페이지에서 서버 DB의 주문 상태를 조회해 결제 결과를 표시해요.

서버 승인만 허용하려면 `extra.confirm_only_rest_api`

결제 요청 시 extra.confirm_only_rest_api: true를 함께 보내면 REST API(Secret Key) 로 요청한 승인만 허용​​돼요. 클라이언트 키로 승인을 시도하면 RC_CONFIRM_ONLY_REST_API 오류가 나면서 차단돼요. 서버 승인을 표준으로 삼는다면 켜 두는 편이 안전해요. 기본값은 false예요.

코드 예시

const orderId = 'order_' + Date.now()

try {
    const response = await Bootpay.requestPayment({
        client_key: '[ Client Key ]',
        price: 50000,
        order_name: '나이키 운동화 외 2건',
        order_id: orderId,
        extra: { separately_confirmed: true }
    })

    if (response.event === 'confirm') {
        // confirm = 인증 완료, 승인 직전 상태예요.
        // 서버가 주문 금액·상태·재고를 확인하고 승인 API를 호출해야 해요.
        await requestBackendServerConfirm(response)
        location.href = `/orders/${response.order_id}/result`
    }
} catch (e) {
    if (e.event === 'cancel') keepOrderUnpaid(e)
    if (e.event === 'error') showPaymentError(e.message)
}

const requestBackendServerConfirm = async (response) => {
    const { receipt_id, order_id } = response
    return axios.post('/api/confirm', { receipt_id, order_id })
}javascript

프론트엔드에서 receipt_id를 서버로 전송하면, 서버가 주문 조건을 검증하고 Bootpay 승인 API를 호출해요. 서버는 승인 API를 호출하기 전에 DB에 저장해 둔 주문 금액·주문 상태·재고 조건을 먼저 확인해야 해요.

app.post('/api/confirm', async (req, res) => {
    const { receipt_id, order_id } = req.body
    const order = await db.orders.findByPk(order_id)

    if (!order || order.status !== 'pending') {
        return res.status(400).json({ message: '승인할 수 없는 주문' })
    }

    if (!checkInventory(order)) {
        return res.status(400).json({ message: '재고가 부족해요.' })
    }

    const confirmed = await Bootpay.confirmPayment(receipt_id)
    if (confirmed.status !== 1 || confirmed.price !== order.price) {
        return res.status(400).json({ message: '결제 조회에 실패했어요.' })
    }

    await db.orders.update(
        { status: 'done', bootpay_receipt_id: receipt_id },
        { where: { id: order_id } }
    )

    res.json({ success: true })
})javascript

승인 API 응답 예시

아래 JSON은 가맹점의 /api/confirm 응답이 아니라, 서버 SDK의 confirmPayment(receipt_id)가 반환하는 Bootpay 승인 결과예요. 승인 직후 이 값의 status, price, order_id를 내부 주문과 비교하고 결제수단 상세 정보가 필요하면 card_data 같은 객체를 저장해요.

{
  "receipt_id": "6244f60c1fc19202e42e8c4e",
  "order_id": "order_20260428_001",
  "price": 50000,
  "tax_free": 0,
  "cancelled_price": 0,
  "cancelled_tax_free": 0,
  "order_name": "나이키 운동화 외 2건",
  "company_name": "부트페이",
  "gateway_url": "https://gw.bootpay.co.kr",
  "metadata": {
    "order_source": "web"
  },
  "sandbox": true,
  "pg": "kcp",
  "method": "카드",
  "method_symbol": "card",
  "method_origin": "카드",
  "method_origin_symbol": "card",
  "currency": "KRW",
  "status": 1,
  "status_locale": "결제완료",
  "receipt_url": "https://receipt.bootpay.co.kr/receipt/6244f60c1fc19202e42e8c4e",
  "purchased_at": "2026-04-28T09:30:29+09:00",
  "requested_at": "2026-04-28T09:30:04+09:00",
  "card_data": {
    "tid": "20260428123456789012",
    "card_approve_no": "12345678",
    "card_no": "1234-****-****-5678",
    "card_quota": "00",
    "card_company_code": "08",
    "card_company": "하나카드",
    "card_interest": "0",
    "receipt_url": "https://receipt.pg.example/card/20260428123456789012"
  }
}json
필드 승인 후 확인할 내용
status 1이면 승인 완료예요
price 내부 주문의 최종 결제 금액과 일치해야 해요
order_id 승인 요청을 시작한 내부 주문번호와 일치해야 해요
receipt_id 주문에 저장하고 중복 승인·웹훅 멱등 처리 키로 사용해요
method_origin 구매자가 처음 선택한 상위 결제수단​​이에요 (예: 카카오페이). 반면 method는 그 안에서 실제 승인된 결제수단​​이에요 (예: 카드)
card_data.card_approve_no 카드 승인번호예요
card_data.card_company 카드사명이에요
card_data.card_no PG 정책에 따라 마스킹된 카드번호예요

카드가 아닌 결제수단은 card_data 대신 bank_data, vbank_data, phone_data 등이 내려올 수 있어요. 각 객체의 상세 구조는 결제 조회의 결제수단별 상세 객체를 참고해요.

PG별 응답 차이

결제수단 상세 객체 안의 일부 필드는 PG사에 따라 생략될 수 있어요. 카드번호를 포함한 민감값은 마스킹된 응답만 저장하고, 전체 카드번호 복원을 전제로 처리하면 안 돼요.

예외: 프론트엔드 승인

프론트엔드 승인 방식은 confirm을 받은 뒤 클라이언트에서 Bootpay.confirm()을 호출해 최종 승인하는 패턴이에요. 서버 승인 API를 지원하지 않는 PG를 써야 할 때 선택하는 예외 흐름으로 보면 돼요.

중요한 점은 클라이언트가 바로 Bootpay.confirm()을 호출하면 안 된다는 것​​이에요. 먼저 서버에 receipt_idorder_id를 보내 주문 금액·상태·재고를 검증하고, 서버가 승인 가능하다고 응답한 경우에만 클라이언트에서 Bootpay.confirm()을 호출해야 해요.

confirm 이벤트 수신

(프론트엔드) 인증 결과로 confirm을 받아요. 아직 결제 완료가 아니라 승인 직전 상태예요.

서버 사전 검증

(서버) DB에 저장된 주문 금액·주문 상태·재고 조건을 확인하고 승인 가능 여부만 응답해요. 이 단계에서는 Bootpay 승인 API를 호출하지 않아요.

클라이언트 승인 요청

(프론트엔드) 서버가 승인 가능하다고 응답한 경우에만 Bootpay.confirm()을 호출해 결제 승인 요청을 해요.

서버 결제 조회

(서버) Bootpay.confirm() 이후에도 클라이언트 응답만으로 주문을 확정하면 안 돼요. 서버에서 receipt_id로 결제 정보를 다시 조회하고, DB 주문 금액·상태와 비교한 뒤 주문을 확정해야 해요.

웹훅은 클라이언트 승인 흐름에서 특히 중요해요

웹훅은 confirm 단계에서 오는 이벤트가 아니에요. Bootpay.confirm() 또는 SDK별 승인 함수로 결제가 완료된 뒤, Bootpay가 최종 결제 결과를 가맹점 백엔드로 전달할 때 사용해요. 구매자가 승인 직후 브라우저나 앱을 닫으면 /api/payment/complete 요청이 누락될 수 있으므로, 클라이언트 승인 흐름에서는 웹훅도 함께 운영하고 receipt_id 기준으로 멱등 처리해야 해요.

코드 예시

import { Bootpay } from '@bootpay/client-js'

try {
    const orderId = 'order_' + Date.now()

    const response = await Bootpay.requestPayment({
        client_key: '[ Client Key ]',
        price: 50000,
        order_name: '나이키 운동화 외 2건',
        order_id: orderId,
        pg: 'nicepay',
        method: 'card',
        extra: {
            separately_confirmed: true,
            open_type: 'popup'
        }
    })

    if (response.event === 'confirm') {
        // 1. 인증 완료 후 confirm을 받아요. 아직 결제 승인 전이에요.
        const checked = await fetch('/api/payment/confirm-check', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                receipt_id: response.receipt_id,
                order_id: orderId
            })
        }).then((res) => res.json())

        if (!checked.can_confirm) {
            throw new Error(checked.message || '승인할 수 없는 주문이에요.')
        }

        // 2. 서버가 승인 가능하다고 응답한 경우에만 클라이언트에서 결제 승인 요청을 해요.
        const confirmed = await Bootpay.confirm()

        if (confirmed.event === 'done') {
            // 3. 승인 완료 후에도 클라이언트 응답만으로 주문을 확정하면 안 돼요.
            // 서버에서 receipt_id로 결제 상태·금액을 다시 조회하고 주문을 확정해야 해요.
            await fetch('/api/payment/complete', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({
                    receipt_id: confirmed.receipt_id,
                    order_id: orderId
                })
            })
        }
    }
} catch (data) {
    if (data.event === 'cancel') keepOrderUnpaid(data)
    if (data.event === 'error') showPaymentError(data.message)
}javascript

설정 방법

결제 요청 시 extra.separately_confirmedtrue로 설정해요. 이 옵션을 켜면 인증 완료 후 confirm 이벤트를 받을 수 있어요.

const response = await Bootpay.requestPayment({
    client_key: "[ Client Key ]",
    price: 1000,
    order_name: "테스트 상품",
    order_id: "test_order_001",
    extra: {
        separately_confirmed: true  // 인증 완료 후 confirm 이벤트 수신
    }
})javascript