extra.separately_confirmed: true는 구매자 인증이 끝난 뒤 결제 승인 직전의 confirm 이벤트를 받기 위한 옵션이에요. 이 시점에 서버가 주문 금액·주문 상태·재고·쿠폰·적립금 같은 조건을 다시 확인하고, 문제가 없을 때만 결제를 승인해야 해요.
분리 승인이란?
분리 승인은 인증과 승인을 분리하는 방식이에요. 일반 결제는 구매자 인증이 끝나면 PG가 곧바로 승인까지 진행할 수 있지만, 분리 승인을 켜면 승인 직전에 confirm 이벤트로 제어권을 한 번 넘겨줘요.
이때 서버에서 확인할 수 있는 대표 조건은 아래와 같아요.
- DB에 저장된 주문 금액과 Bootpay 결제 금액이 일치하는지
- 주문 상태가 아직 결제 대기 상태인지
- 재고를 차감해도 되는지
- 쿠폰·적립금·프로모션 조건이 여전히 유효한지
- 이미 처리한
receipt_id가 아닌지
승인 호출은 두 가지 방식이 있어요. 운영에서는 백엔드가 Bootpay 승인 API를 호출하는 서버 승인을 권장해요. 서버 승인 API를 지원하지 않는 PG를 써야 한다면, 서버가 먼저 검증하고 프론트엔드가 Bootpay.confirm() 또는 SDK별 승인 함수를 호출하는 예외 흐름을 사용해요.
분리 승인 흐름
결제창 연동에는 두 가지 승인 흐름이 있어요. 운영 기준은 분리 승인을 먼저 검토하고, PG가 지원하지 않거나 단순 결제 흐름이면 자동 승인 후 서버 검증을 사용해요.
| 분리 승인 | 자동 승인 후 서버 검증 | |
|---|---|---|
| 설정 | extra.separately_confirmed: true |
기본값 |
| 승인 시점 | 백엔드가 승인 API를 호출할 때 | PG 결제창에서 자동 승인 |
| 프론트엔드 이벤트 | confirm |
done |
| 서버 역할 | 승인 전 주문·금액·재고 검증 후 승인 | 승인 후 receipt_id로 결제 상태·금액 검증 |
| 적합한 경우 | 재고·쿠폰·적립금처럼 승인 전 판단이 필요한 주문 | 단순 결제 또는 분리 승인을 지원하지 않는 PG |
분리 승인에서는 confirm이 결제 완료가 아니에요. 서버가 승인 API를 호출하고 주문을 확정한 뒤에야 결제 완료로 봐야 해요. 클라이언트와 웹훅이 둘 다 도착할 수 있으므로 receipt_id 기준으로 멱등 처리해야 해요.
서버 승인
백엔드에서 Bootpay 승인 API를 호출해 결제를 확정하는 패턴이에요. 재고·적립금·쿠폰 차감이 결제 승인과 같은 트랜잭션에 묶여야 하는 운영 서비스라면 이 방식을 권장해요.
confirm 이벤트 수신 및 서버 전달
(프론트엔드) 인증 완료 후 confirm을 받으면 receipt_id와 order_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@app.post('/api/confirm')
def confirm_payment():
data = request.get_json()
receipt_id = data['receipt_id']
order_id = data['order_id']
order = Order.get(order_id)
if not order or order.status != 'pending':
return jsonify({'message': '승인할 수 없는 주문'}), 400
if not check_inventory(order):
return jsonify({'message': '재고가 부족해요.'}), 400
confirmed = bootpay.confirm_payment(receipt_id)
if confirmed['status'] != 1 or confirmed['price'] != order.price:
return jsonify({'message': '결제 조회에 실패했어요.'}), 400
order.update(status='done', bootpay_receipt_id=receipt_id)
return jsonify({'success': True})python$data = json_decode(file_get_contents('php://input'), true);
$receiptId = $data['receipt_id'];
$orderId = $data['order_id'];
$order = Order::find($orderId);
if (!$order || $order->status !== 'pending') {
http_response_code(400);
echo json_encode(['message' => '승인할 수 없는 주문']);
exit;
}
if (!checkInventory($order)) {
http_response_code(400);
echo json_encode(['message' => '재고가 부족해요.']);
exit;
}
$confirmed = BootpayApi::confirmPayment($receiptId);
if ($confirmed['status'] !== 1 || $confirmed['price'] !== $order->price) {
http_response_code(400);
echo json_encode(['message' => '결제 조회에 실패했어요.']);
exit;
}
$order->update([
'status' => 'done',
'bootpay_receipt_id' => $receiptId,
]);
echo json_encode(['success' => true]);php@PostMapping("/api/confirm")
public ResponseEntity<?> confirmPayment(@RequestBody ConfirmRequest body) throws Exception {
var order = orderRepository.findById(body.orderId());
if (order.isEmpty() || !"pending".equals(order.get().getStatus())) {
return ResponseEntity.badRequest().body(Map.of("message", "승인할 수 없는 주문"));
}
if (!checkInventory(order.get())) {
return ResponseEntity.badRequest().body(Map.of("message", "재고가 부족해요."));
}
var confirmed = bootpay.confirm(body.receiptId());
int status = ((Number) confirmed.get("status")).intValue();
double price = ((Number) confirmed.get("price")).doubleValue();
if (status != 1 || price != order.get().getPrice()) {
return ResponseEntity.badRequest().body(Map.of("message", "결제 조회에 실패했어요."));
}
order.get().setStatus("done");
order.get().setBootpayReceiptId(body.receiptId());
orderRepository.save(order.get());
return ResponseEntity.ok(Map.of("success", true));
}javapost '/api/confirm' do
payload = JSON.parse(request.body.read)
receipt_id = payload['receipt_id']
order = Order.find(payload['order_id'])
halt 400, { message: '승인할 수 없는 주문' }.to_json unless order&.status == 'pending'
halt 400, { message: '재고가 부족해요.' }.to_json unless check_inventory(order)
confirmed = bootpay.confirm(receipt_id).data
if confirmed['status'] != 1 || confirmed['price'] != order.price
halt 400, { message: '결제 조회에 실패했어요.' }.to_json
end
order.update(status: 'done', bootpay_receipt_id: receipt_id)
{ success: true }.to_json
endrubyfunc confirmPayment(w http.ResponseWriter, r *http.Request) {
var body struct {
ReceiptID string `json:"receipt_id"`
OrderID string `json:"order_id"`
}
json.NewDecoder(r.Body).Decode(&body)
order, err := findOrder(body.OrderID)
if err != nil || order.Status != "pending" {
http.Error(w, "승인할 수 없는 주문", http.StatusBadRequest)
return
}
if !checkInventory(order) {
http.Error(w, "재고가 부족해요.", http.StatusBadRequest)
return
}
confirmed, err := api.ServerConfirm(body.ReceiptID)
if err != nil {
http.Error(w, "결제 조회에 실패했어요.", http.StatusBadRequest)
return
}
status := int(confirmed["status"].(float64))
price := confirmed["price"].(float64)
if status != 1 || price != order.Price {
http.Error(w, "결제 조회에 실패했어요.", http.StatusBadRequest)
return
}
markOrderDone(body.OrderID, body.ReceiptID)
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}go[HttpPost("/api/confirm")]
public async Task<IActionResult> ConfirmPayment([FromBody] ConfirmRequest body)
{
var order = await orderRepository.FindAsync(body.OrderId);
if (order == null || order.Status != "pending") {
return BadRequest(new { Message = "승인할 수 없는 주문" });
}
if (!CheckInventory(order)) {
return BadRequest(new { Message = "재고가 부족해요." });
}
var response = await bootpay.Confirm(body.ReceiptId);
var confirmed = JsonConvert.DeserializeObject<Dictionary<string, object>>(
await response.Content.ReadAsStringAsync()
);
if (Convert.ToInt32(confirmed["status"]) != 1 || Convert.ToDecimal(confirmed["price"]) != order.Price) {
return BadRequest(new { Message = "결제 조회에 실패했어요." });
}
order.Status = "done";
order.BootpayReceiptId = body.ReceiptId;
await orderRepository.SaveAsync(order);
return Ok(new { Success = true });
}csharp예외: 프론트엔드 승인
프론트엔드 승인 방식은 confirm을 받은 뒤 클라이언트에서 Bootpay.confirm()을 호출해 최종 승인하는 패턴이에요. 서버 승인 API를 지원하지 않는 PG를 써야 할 때 선택하는 예외 흐름으로 보면 돼요.
중요한 점은 클라이언트가 바로 Bootpay.confirm()을 호출하면 안 된다는 것이에요. 먼저 서버에 receipt_id와 order_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)
}javascriptapp.post('/api/payment/confirm-check', async (req, res) => {
const { receipt_id, order_id } = req.body
const order = await db.orders.findById(order_id)
if (!order || order.status !== 'pending') {
return res.json({ can_confirm: false, message: '승인할 수 없는 주문' })
}
if (!checkInventory(order)) {
return res.json({ can_confirm: false, message: '재고가 부족해요.' })
}
// 서버 승인 API를 호출하지 않아요.
// 여기서는 DB에 저장해 둔 주문 금액·상태·재고 기준으로 승인 가능 여부만 판단해요.
await db.paymentAttempts.create({
order_id,
bootpay_receipt_id: receipt_id,
status: 'CONFIRM_CHECKED'
})
return res.json({ can_confirm: true })
})
app.post('/api/payment/complete', async (req, res) => {
const { receipt_id, order_id } = req.body
// Bootpay.confirm() 이후에도 클라이언트 응답만으로 주문을 확정하면 안 돼요.
// 서버에서 receipt_id로 결제 정보를 다시 조회하고 DB 주문 금액·상태와 비교해야 해요.
const payment = await verifyPayment(receipt_id)
const order = await db.orders.findById(order_id)
if (!order || payment.status !== 1 || payment.price !== order.price) {
return res.status(400).json({ message: '결제 조회에 실패했어요.' })
}
await db.orders.update(
{ status: 'done', bootpay_receipt_id: receipt_id },
{ where: { id: order_id } }
)
return res.json({ success: true })
})javascript설정 방법
결제 요청 시 extra.separately_confirmed를 true로 설정해요. 이 옵션을 켜면 인증 완료 후 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
