주문 생성부터 결제, 검증, 웹훅 처리까지 전체 시퀀스를 이해해요.
개별 API 사용법은 주문서 요청, 결제 검증에서 확인해요. 이 문서는 전체 흐름을 한눈에 보여줘요.
주문 구현 전에 블로그 가이드를 봐요.
- 결제만 vs 커머스 스코프 — 주문 관리를 Bootpay 에 맡길지 판단
- 정산 흐름 설계 — 3단(OrderPre/Order/OrderPurchase) 배경
- 정산 주기 / 정산 대사
- 취소·환불 정책 — 취소 승인 루프·상계
자체 주문 시스템이 있고 결제만 붙이려면 결제 흐름을 참고해요. 결제 과정은 동일하고, 이 문서는 Bootpay가 주문·이행·배송까지 관리하는 커머스 API 흐름이에요.
전체 시퀀스
참여자: 고객 | 가맹점 프론트 | 가맹점 서버 | Bootpay | PG사
| 단계 | 흐름 | 유형 | 비고 |
|---|---|---|---|
| 1 | 고객 -> 가맹점 프론트: 상품 선택 | 실선 | |
| 2 | 가맹점 프론트 -> 가맹점 서버: 주문 생성 요청 | 실선 | |
| 3 | 가맹점 서버 -> 가맹점 프론트: order_id 반환 | 점선 | |
| 4 | 가맹점 프론트 -> Bootpay: 결제위젯/체크아웃 호출 | 실선 | |
| 5 | Bootpay -> PG사: 결제 요청 | 실선 | |
| 6 | PG사 -> Bootpay: 결제 결과 | 점선 | |
| 7 | Bootpay -> 가맹점 프론트: receipt_id 반환 | 점선 | |
| 8 | 가맹점 프론트 -> 가맹점 서버: receipt_id 전달 | 실선 | |
| 9 | 가맹점 서버 -> Bootpay: 결제 검증 (GET) | 실선 | |
| 10 | Bootpay -> 가맹점 서버: 검증 결과 | 점선 | |
| 11 | 가맹점 서버: DB 저장 + 상태 변경 | 실선 | DB 저장 + 상태 변경 |
| 12 | 가맹점 서버 -> 가맹점 프론트: 결과 반환 | 점선 | |
| 13 | 가맹점 프론트 -> 고객: 완료 페이지 | 점선 | |
| 14 | Bootpay -> 가맹점 서버: 웹훅 (order.done) | 실선 | |
| 15 | 가맹점 서버: 후처리 | 실선 | 후처리 (이메일 등) |
Bootpay 내부 주문 3단계
Bootpay는 내부적으로 주문을 3단계 모델로 관리해요. API 응답과 웹훅 데이터를 이해하는 데 도움이 돼요.
흐름 OrderPre / 결제 전 임시 -> Order / 결제 확정 : 결제 완료 Order / 결제 확정 -> OrderPurchase / 이행/배송 : 판매자별 분리
노드
| ID | 라벨 | 위치 | 타입 |
|---|---|---|---|
| A | OrderPre / 결제 전 임시 | col 0, row 0 | |
| B | Order / 결제 확정 | col 1, row 0 | success |
| C | OrderPurchase / 이행/배송 | col 2, row 0 |
- OrderPre: 결제위젯이 열리기 전 생성되는 임시 주문. 제한 시간(기본 31분) 내에 결제하지 않으면 자동 만료돼요.
- Order: 결제가 완료되면 OrderPre가 Order로 확정돼요. 이 시점부터
order_id,order_number가 발급돼요. - OrderPurchase: 판매자(셀러)별 이행 단위예요. 멀티셀러 쇼핑몰이면 하나의 Order에 여러 OrderPurchase가 생길 수 있어요.
Bootpay 주문 상태코드 참조
가맹점은 아래 상태코드를 직접 관리할 필요 없지만, API 응답의 status 필드가 이 값을 따라요.
| Bootpay 상태 | 코드 | 가맹점 매핑 예시 | 설명 |
|---|---|---|---|
| READY | 0 | pending |
주문 생성됨 (결제 대기) |
| DEPOSIT_WAIT | 1 | pending |
무통장입금 대기 |
| PAY_DONE | 2 | paid |
결제 완료 |
| BUY_CONFIRM | 8 | completed |
구매 확정 |
| CANCEL_DONE | 10 | refunded |
전체 취소 완료 |
| RETURN_DONE | 11 | returned |
반품 완료 |
| EXCHANGE_DONE | 12 | exchanged |
교환 완료 |
| CLOSED | -1 | expired |
미결제 만료 |
단계별 상세
1단계: 주문 예비 생성 (가맹점 서버)
결제 전에 가맹점 DB에 주문을 먼저 만들어요.
// POST /api/orders — 가맹점 자체 API
app.post('/api/orders', async (req, res) => {
const { user_id, products } = req.body
// 가격은 반드시 백엔드에서 계산 (프론트엔드 금액을 믿지 않음)
const totalPrice = await calculatePrice(products)
const order = await db.orders.create({
user_id,
total_amount: totalPrice,
status: 'pending', // 아직 결제 전
items: products
})
res.json({
order_id: order.id,
price: totalPrice,
order_name: formatOrderName(products)
})
})javascript프론트엔드에서 보낸 금액을 그대로 사용하면 금액 변조 위험이 있어요. 반드시 백엔드에서 상품 가격을 조회하여 계산해요.
2단계: 결제 요청 (프론트엔드)
서버에서 받은 정보로 결제위젯 또는 체크아웃을 호출해요.
// 결제위젯 방식
Bootpay.requestPayment({
client_key: 'APPLICATION_ID',
price: orderData.price, // 서버에서 받은 금액
order_name: orderData.order_name,
order_id: String(orderData.order_id), // 가맹점 주문 ID
pg: '나이스페이',
method: '카드',
user: { id: userId, username: userName }
})
.then(response => {
// 3단계: 서버로 검증 요청
verifyPayment(response.receipt_id, orderData.order_id)
})
.catch(error => {
console.error('결제 실패:', error)
})javascript3단계: 결제 검증 (가맹점 서버)
가장 중요한 단계예요. 프론트엔드 결과만 믿으면 안 돼요.
app.post('/api/orders/verify', async (req, res) => {
const { receipt_id, order_id } = req.body
// 가맹점 DB에서 주문 조회
const order = await db.orders.findByPk(order_id)
if (!order) return res.status(404).json({ error: '주문을 찾을 수 없음' })
// Bootpay에서 결제 정보 조회
const receipt = await Bootpay.receiptPayment(receipt_id)
// 검증: 상태 + 금액
if (receipt.status === 1 && receipt.price === order.total_amount) {
// ✅ 검증 성공 → DB 업데이트
await db.orders.update(
{
status: 'paid',
bootpay_receipt_id: receipt_id,
paid_at: new Date()
},
{ where: { id: order_id } }
)
res.json({ success: true })
} else {
// ❌ 검증 실패 → 결제 취소
await Bootpay.cancelPayment({
receipt_id,
cancel_price: receipt.price,
cancel_reason: '금액 위변조 의심'
})
res.json({ success: false, message: '검증 실패' })
}
})javascript4단계: 웹훅 수신 (비동기 후처리)
결제 검증과 별개로, Bootpay가 웹훅을 발송해요. 후처리 로직에 활용해요.
// 웹훅 수신 후 처리 (상세: /webhook/guide)
app.post('/webhooks/bootpay', async (req, res) => {
res.status(200).json({ success: true }) // 즉시 200 반환
const { event, data } = req.body
if (event === 'order.done') {
// 이메일 발송, 재고 차감, 포인트 적립 등
await sendOrderConfirmEmail(data.order_number)
await decrementStock(data.order_number)
}
})javascript주문 상태 관리
가맹점 DB의 주문 상태는 아래 흐름을 따른다:
상태
| ID | 라벨 | 위치 | 타입 |
|---|---|---|---|
| start | col 0, row 1 | initial | |
| pending | pending | col 1, row 1 | |
| paid | paid | col 2, row 1 | active |
| confirmed | confirmed | col 3, row 1 | |
| completed | completed | col 4, row 1 | active |
| refunded | refunded | col 3, row 2 | end |
| expired | expired | col 1, row 2 | end |
전이 -> pending pending -> paid paid -> confirmed confirmed -> completed paid -> refunded : 환불 pending -> expired : 결제 안 함
| 상태 | 설명 | 변경 시점 |
|---|---|---|
pending |
주문 생성됨, 결제 대기 | 주문 예비 생성 시 |
paid |
결제 완료, 검증 성공 | 서버 검증 성공 후 |
confirmed |
주문 확인 (출고 준비) | 가맹점이 수동 또는 자동으로 |
completed |
배송/서비스 완료 | 배송 완료 확인 후 |
refunded |
환불 완료 | 웹훅 order.cancelled 수신 시 |
expired |
미결제로 만료 | 일정 시간 후 자동 (정책에 따라) |
디지털 상품이면 paid → completed를 바로 처리해도 돼요. 물리적 배송이 있으면 confirmed(출고) 단계를 거쳐요.
취소/환불 흐름
주문 취소는 전체 취소와 부분 취소(금액 또는 상품 단위)로 나뉘어요.
전체 취소 vs 부분 취소
| 유형 | 설명 | 사용 시점 |
|---|---|---|
| 전체 취소 | 주문 전액 환불 | 주문 전체를 취소할 때 |
| 부분 취소 (금액) | 지정 금액만 환불 | 쿠폰 할인 차감 등 금액 조정 |
| 부분 취소 (상품) | 특정 상품만 취소 | 여러 상품 중 일부만 환불 |
취소 요청 → 승인 → 완료 흐름
참여자: 가맹점 서버 | Bootpay
| 단계 | 흐름 | 유형 | 비고 |
|---|---|---|---|
| 1 | 가맹점 서버 -> Bootpay: 취소 요청 (POST) | 실선 | |
| 2 | Bootpay: 취소 금액 계산 · 자동승인 확인 | 실선 | 취소 금액 계산 · 자동승인 확인 |
| 3 | Bootpay -> 가맹점 서버: 승인 결과 | 점선 | |
| 4 | Bootpay: PG사 환불 · 상태 변경 | 실선 | PG사 환불 · 상태 변경 |
| 5 | Bootpay -> 가맹점 서버: 웹훅 (order.cancelled) | 실선 | |
| 6 | 가맹점 서버: 후처리: 재고 복구 · 고객 안내 | 실선 | 재고 복구 · 서비스 비활성화 · 안내 이메일 |
취소 완료 웹훅 처리
// 웹훅 수신: order.cancelled
async function handleOrderCancelled(data) {
const { order_number, cancel_price, remain_price } = data
// 1. 주문 상태 업데이트
if (remain_price === 0) {
// 전체 취소
await db.orders.update(
{ status: 'refunded' },
{ where: { bootpay_order_number: order_number } }
)
} else {
// 부분 취소 — 잔액이 남아있으면 주문은 유지
await db.orders.update(
{ total_amount: remain_price, status: 'partially_refunded' },
{ where: { bootpay_order_number: order_number } }
)
}
// 2. 재고 복구 (전체 취소 시)
// 3. 환불 안내 이메일
}javascript부분 취소가 발생하면 주문 자체는 유효해요. status를 refunded로 바꾸면 안 되고, 잔여 금액을 기준으로 서비스를 유지해야 해요.
이행(Fulfillment) 흐름
물리적 배송이 있는 커머스에서는 결제 완료 후 이행 단계를 관리해야 해요.
이행 상태 흐름
흐름 ready / (발주대기) -> confirmed / (발주확인) confirmed / (발주확인) -> shipped / (배송중) shipped / (배송중) -> delivered / (배송완료)
노드
| ID | 라벨 | 위치 | 타입 |
|---|---|---|---|
| A | ready / (발주대기) | col 0, row 0 | |
| B | confirmed / (발주확인) | col 1, row 0 | |
| C | shipped / (배송중) | col 2, row 0 | process |
| D | delivered / (배송완료) | col 3, row 0 | success |
| 이행 상태 | Bootpay 코드 | 가맹점 액션 |
|---|---|---|
ready |
0~1 | 발주 접수. 상품 준비 시작 |
confirmed |
2 | 발주 확인 완료 |
shipped |
4 | 배송 시작. 운송장 번호 등록 |
delivered |
5 | 배송 완료. 구매 확정 대기 |
배송 정보 연동 패턴
// 발주 확인 + 배송 등록 (가맹점 서버에서)
async function shipOrder(orderNumber, trackingInfo) {
// Bootpay에 배송 정보 전달
await Bootpay.updateOrderPurchase(orderNumber, {
delivery_company_code: trackingInfo.company_code, // 택배사 코드
tracking_number: trackingInfo.tracking_number // 운송장 번호
})
// 가맹점 DB 업데이트
await db.order_fulfillments.update(
{
progress_status: 'shipped',
shipped_at: new Date(),
delivery_company: trackingInfo.company_name,
tracking_number: trackingInfo.tracking_number
},
{ where: { bootpay_order_number: orderNumber } }
)
}javascript배송이 없는 디지털 상품이라면 이행 단계를 건너뛰고, 결제 완료 시 즉시 서비스를 활성화하면 돼요.
흔한 실수
1검증 없이 프론트엔드 결과만 믿기
// ❌ 절대 하면 안 됨
Bootpay.requestPayment({ ... })
.then(response => {
// 프론트엔드 결과만으로 주문 완료 처리
db.orders.update({ status: 'paid' }) // 위험!
})javascript프론트엔드 응답은 위변조가 가능해요. 반드시 백엔드에서 결제 검증을 수행해요.
2웹훅에만 의존하기
// ❌ 위험 — 웹훅이 지연되면 고객이 결제했는데 서비스를 못 씀
// 웹훅만으로 주문 완료 처리javascript동기 검증(3단계)이 메인 흐름이고, 웹훅은 보조 수단(이메일, 재고 등 후처리)예요.
3가격을 프론트엔드에서 계산
// ❌ 위험 — 프론트엔드에서 할인 적용
const price = product.price * 0.8 // 20% 할인
Bootpay.requestPayment({ price }) // 변조 가능javascript가격 계산은 반드시 서버에서 수행하고, 결제 검증 시 서버의 가격과 비교해요.
