결제창

분리 승인

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

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

분리 승인이란?

분리 승인은 인증과 승인을 분리하는 방식​​이에요. 일반 결제는 구매자 인증이 끝나면 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의 주문 상태를 조회해 결제 결과를 표시해요.

코드 예시

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

예외: 프론트엔드 승인

프론트엔드 승인 방식은 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