참고

주문 흐름

생성부터 취소·환불까지 주문 상태 전이를 시퀀스로 고정해요.

개별 API 사용법은 주문서 요청, 결제 검증에서 확인해요. 이 문서는 전체 흐름을 한눈에 보여줘요.

개념·정책 배경 (먼저 정해야 할 것)

주문 구현 전에 블로그 가이드를 봐요.

결제만 연동하려면

자체 주문 시스템이 있고 결제만 붙이려면 결제 흐름을 참고해요. 결제 과정은 동일하고, 이 문서는 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: 결제위젯이 열리기 전 생성되는 임시 주문. 이때 order_number 가 발급돼요. 제한 시간(기본 30분, 몰 설정으로 변경 가능) 내에 결제하지 않으면 자동 만료돼요.
  • Order: 결제가 완료되면 OrderPre가 Order로 확정돼요. order_number 는 그대로 이어지고, 주문 문서 ID(order_id)는 이때 새로 생겨요.
  • OrderPurchase: 판매자(셀러)별 이행 단위예요. 멀티셀러 쇼핑몰이면 하나의 Order에 여러 OrderPurchase가 생길 수 있어요.

Bootpay 주문 상태코드 참조 (status)

가맹점은 아래 상태코드를 직접 관리할 필요 없지만, API 응답의 status 필드가 이 값을 따라요. 응답에는 키(문자열) 가 내려와요 — "status": "payment_completed" 처럼요.

값 키 가맹점 매핑 예시 설명
0 order_pending pending 주문 생성됨 (결제 대기)
1 payment_pending pending 결제 진행 중 (무통장입금 대기 포함)
2 payment_completed paid 결제 완료
8 purchase_confirmed completed 구매 확정
10 cancellation_completed refunded 전체 취소 완료
11 return_completed returned 반품 완료
12 exchange_completed exchanged 교환 완료
-1 order_expired expired 미결제 만료

전체 값은 커머스 Enum에 있어요.

단계별 상세

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)
})javascript

3단계: 결제 검증 (가맹점 서버)

가장 중요한 단계​​예요. 프론트엔드 결과만 믿으면 안 돼요.

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: '검증 실패' })
    }
})javascript

4단계: 웹훅 수신 (비동기 후처리)

결제 검증과 별개로, Bootpay가 웹훅을 발송해요. 후처리 로직에 활용해요.

// 웹훅 수신 후 처리 (상세: /webhook/guide)
app.post('/webhooks/bootpay', async (req, res) => {
    res.status(200).json({ success: true })  // 즉시 200 반환

    const { webhook_type, payload: data } = req.body
    if (webhook_type === '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 : 결제 안 함

아래는 가맹점 DB 의 orders.status 예시예요 — Bootpay 응답의 status 와는 다른 값 체계입니다.

가맹점 orders.status 설명 변경 시점
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 (전체 취소) · order.partial_cancelled (부분 취소)
async function handleOrderCancelled(webhookType, data) {
    const { order_number, price, cancelled_price } = data

    // 1. 주문 상태 업데이트
    if (webhookType === 'order.cancelled') {
        // 전체 취소
        await db.orders.update(
            { status: 'refunded' },
            { where: { bootpay_order_number: order_number } }
        )
    } else {
        // 부분 취소 — 잔액(price - cancelled_price)이 남아 있으면 주문은 유지
        await db.orders.update(
            { total_amount: price - cancelled_price, status: 'partially_refunded' },
            { where: { bootpay_order_number: order_number } }
        )
    }

    // 2. 재고 복구 (전체 취소 시)
    // 3. 환불 안내 이메일
}javascript
알려진 문제 — 부분 취소 웹훅에 payload 가 없어요

order.partial_cancelled 는 지금 payload 없이 와요. 받으면 주문 상세를 다시 조회해 price·cancelled_price 를 읽어요.

부분 취소 시 주의

부분 취소가 발생하면 주문 자체는 유효해요. 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 order_purchases[].status 가맹점 액션
ready 0(주문 전) ~ 1(발주 대기) 발주 접수. 상품 준비 시작
confirmed 2 (발주 확인) 발주 확인 완료
preparing 3 (상품 준비중) 상품 준비
shipped 4 (발송 완료) 배송 시작. 운송장 번호 등록
delivered 5 (배송 완료) 배송 완료. 구매 확정 대기
발주 `status` 는 숫자예요

주문 status 와 달리 발주의 order_purchases[].status 는 enum 문자열로 변환되지 않고 숫자 그대로 내려와요. 같은 배열의 progress_status 는 발송처리로 갱신되지 않으니 이행 판단에 쓰지 않아요. 가맹점 스키마의 progress_status(위 표 왼쪽 열)와도 값 체계가 달라요.

배송 정보 연동 패턴

배송은 발주 단위​​로 움직여요. 운송장을 발송처리로 넣으면 그때부터 배송 추적이 자동으로 붙고, 택배사가 알려주는 단계는 주문 상세의 order_purchases[]로 읽어요.

// 발송처리 (가맹점 서버에서)
async function shipOrder(orderNumber, purchaseNumber, trackingInfo) {
    await fetch(`https://api.bootapi.com/v1/orders/${ orderNumber }/purchases`, {
        method:  'PUT',
        headers: { Authorization: basicAuth, 'Content-Type': 'application/json' },
        body:    JSON.stringify({
            purchases: [
                {
                    order_purchase_number: purchaseNumber,
                    status:                4,                          // 발송 완료
                    delivery_company_code: trackingInfo.company_code,  // 택배사 코드
                    tracking_number:       trackingInfo.tracking_number // 운송장 번호
                }
            ]
        })
    })
}

// 배송 상태 동기화 (가맹점 서버에서)
async function syncShipping(orderNumber) {
    const order = await commerce.order.detail(orderNumber)

    for (const purchase of order.order_purchases) {
        await db.order_fulfillments.update(
            {
                progress_status:  purchase.status,                 // 4 발송 완료 · 5 배송 완료
                delivery_company: purchase.delivery_company_code,  // 택배사 코드
                tracking_number:  purchase.tracking_number,        // 운송장 번호
                tracking_status:  purchase.d_ts                    // 배송추적 상태
            },
            { where: { bootpay_order_purchase_number: purchase.order_purchase_number } }
        )
    }
}javascript

tracking_histories[]에는 집화·배송 완료 같은 추적 단계가 최신 순으로 쌓여요. 값은 커머스 Enum의 발주 상태·배송추적 상태 표에서 확인하세요.

배송 완료는 안 눌러도 돼요

택배사가 배송 완료를 알려주면 발주 상태도 따라 넘어가요. 발송처리까지만 해두면 나머지는 자동이에요.

디지털 상품

배송이 없는 디지털 상품이라면 이행 단계를 건너뛰고, 결제 완료 시 즉시 서비스를 활성화하면 돼요.

흔한 실수

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

가격 계산은 반드시 서버에서 수행하고, 결제 검증 시 서버의 가격과 비교해요.