서버 연동

웹훅 처리 가이드

이벤트별 후속 처리 기준을 웹훅 수신 순서에 맞춰 정리해요.

"웹훅 받으면 뭘 해야 해?" — 이벤트별로 가맹점이 수행해야 하는 비즈니스 로직을 정리해요.

빠르게 시작하려면?

웹훅 빠른 매뉴얼 — 최소 코드로 시작해요. 이 페이지는 전체 이벤트와 고급 패턴(멱등성, DB 설계)을 다루는 Full Reference예요.

웹훅 설정에서 URL 등록과 기본 코드를 확인해요. 이 문서는 설정 이후​, 각 이벤트를 받았을 때 실제로 무엇을 해야 하는지​​에 집중해요.

웹훅 응답 필수 조건 — 이 두 가지를 모두 충족해야 "처리 완료"로 인식돼요
# 조건 예시
1 HTTP 상태 코드 200 반환 res.status(200)
2 JSON 응답 본문에 { "success": true } 포함 res.json({ success: true })

두 조건 중 하나라도 빠지면 Bootpay는 웹훅이 실패한 것으로 판단하고 재시도​​해요.

결제 웹훅

상태별 처리

status 설명 가맹점이 할 일 중요도
1 결제 완료 주문 상태 업데이트, 서비스 활성화 필수
20 결제 취소 재고 복구, 환불 처리 필수
2 가상계좌 발급 (입금 대기) 입금 안내 표시 권장

결제 웹훅 처리 코드

app.post('/webhook/bootpay', async (req, res) => {
    const { receipt_id, status, price, order_id } = req.body

    // 1. 결제 조회 API로 검증 (위변조 방지)
    const receipt = await Bootpay.getReceipt(receipt_id)

    if (receipt.status !== status || receipt.price !== price) {
        return res.status(200).json({ success: true })
    }

    // 2. 상태별 처리
    switch (status) {
        case 1: // 결제 완료
            await db.orders.update({
                bootpay_receipt_id: receipt_id,
                amount: price,
                method: receipt.method,
                status: 'paid',
                paid_at: new Date()
            }, { where: { order_id } })

            // 재고 차감
            const order = await db.orders.findOne({ where: { order_id } })
            for (const item of order.items) {
                await db.products.decrement('stock', {
                    by: item.quantity, where: { id: item.product_id }
                })
            }
            break

        case 20: // 결제 취소
            await db.orders.update(
                { status: 'refunded' },
                { where: { bootpay_receipt_id: receipt_id } }
            )
            // 재고 복구
            const refundOrder = await db.orders.findOne({
                where: { bootpay_receipt_id: receipt_id }
            })
            for (const item of refundOrder.items) {
                await db.products.increment('stock', {
                    by: item.quantity, where: { id: item.product_id }
                })
            }
            break

        case 2: // 가상계좌 발급 (입금 대기)
            await db.orders.update(
                { status: 'pending', bootpay_receipt_id: receipt_id },
                { where: { order_id } }
            )
            break
    }

    res.status(200).json({ success: true })
})javascript
반드시 결제 조회 API로 검증해요

결제 웹훅의 receipt_id를 사용해 Bootpay 서버에서 직접 영수증을 조회하여, 금액(price)과 상태(status)가 일치하는지 확인해야 해요.

가상계좌 흐름

가상계좌는 웹훅이 2번 온다: 발급 시(status: 2) → 입금 완료 시(status: 1). 두 이벤트를 모두 처리해야 해요.

커머스 웹훅

이벤트별 가맹점 액션

주문 이벤트

이벤트 발생 시점 가맹점이 할 일 중요도
order.done 주문 결제 완료 재고 차감, 서비스 활성화, 확인 이메일 필수
order.cancelled 주문 취소 완료 재고 복구, 서비스 비활성화, 환불 안내 필수
order.partial_cancelled 부분 취소 해당 상품만 재고 복구 권장
order.payment_pending 지연 결제 대기 입금 안내 표시 권장
order.payment_error 결제 실패 에러 로그 기록 선택

구독 이벤트

이벤트 발생 시점 가맹점이 할 일 중요도
subscription.requested_done 구독 신청 접수 관리자에게 승인 요청 알림 선택
subscription.approved 구독 승인 서비스 활성화 (service_active = true) 필수
subscription.renewed 회차 결제 성공 서비스 기간 연장, 다음 결제일 업데이트 필수
subscription.modified 구독 내용 변경 변경 내용 반영 (금액, 상품) 선택
subscription.paused 구독 일시정지 서비스 일시 제한 or 유지 (정책에 따라) 권장
subscription.resumed 구독 재개 서비스 재활성화 권장
subscription.terminated 구독 해지 (고객) 서비스 비활성화 (service_active = false) 필수
subscription.admin_terminated 구독 해지 (관리자) 서비스 비활성화 + 고객 안내 필수

Bootpay 내부 이벤트 매핑 참조

웹훅 이벤트명은 Bootpay 내부 WebhookType 상수에서 변환돼요. 디버깅이나 로그 분석 시 참고해요.

주문 이벤트

내부 WebhookType 코드 이벤트명 설명
ORDER_DONE 202 order.done 주문 결제 완료
ORDER_CONFIRM_ERROR 203 order.payment_error 결제 실패
ORDER_CANCELLED 204 order.cancelled 주문 전체 취소
ORDER_TRANSACTION_PENDING 206 order.payment_pending 지연 결제 대기 (무통장 등)
ORDER_PARTIAL_CANCELLED 207 order.partial_cancelled 부분 취소

구독 이벤트

내부 WebhookType 코드 이벤트명 설명
SUBSCRIPTION_REQUESTED_DONE 100 subscription.requested_done 구독 신청 접수
SUBSCRIPTION_APPROVED 101 subscription.approved 구독 승인
SUBSCRIPTION_TRIAL_STARTED 103 subscription.trial_started 체험 시작
SUBSCRIPTION_RENEWED 150 subscription.renewed 회차 결제 성공 (갱신)
SUBSCRIPTION_MODIFIED 120 subscription.modified 구독 내용 변경
SUBSCRIPTION_PAUSED 130 subscription.paused 구독 일시정지
SUBSCRIPTION_RESUMED 131 subscription.resumed 구독 재개
SUBSCRIPTION_TERMINATED 140 subscription.terminated 구독 해지 (고객)
SUBSCRIPTION_ADMIN_TERMINATED 141 subscription.admin_terminated 구독 해지 (관리자)
SUBSCRIPTION_EXPIRED 142 subscription.expired 약정 만료

커머스 웹훅 코드 패턴

패턴 1: 200 먼저, 처리는 비동기

웹훅은 30초 이내 응답해야 해요. 비즈니스 로직은 비동기로 처리해요.

app.post('/webhooks/bootpay', async (req, res) => {
    const { idempotency_key, event, data } = req.body

    // 1. 중복 체크
    const exists = await db.webhookLogs.findOne({ where: { idempotency_key } })
    if (exists) return res.status(200).json({ success: true })

    // 2. 웹훅 로그 저장
    await db.webhookLogs.create({ idempotency_key, event, data: JSON.stringify(data), status: 'received' })

    // 3. 즉시 200 반환
    res.status(200).json({ success: true })

    // 4. 비동기로 처리
    processWebhook(event, data, idempotency_key).catch(err => {
        console.error('웹훅 처리 실패:', err)
    })
})

async function processWebhook(event, data, key) {
    switch (event) {
        case 'order.done': await handleOrderDone(data); break
        case 'order.cancelled': await handleOrderCancelled(data); break
        case 'subscription.approved': await handleSubscriptionApproved(data); break
        case 'subscription.renewed': await handleSubscriptionRenewed(data); break
        case 'subscription.terminated':
        case 'subscription.admin_terminated':
            await handleSubscriptionTerminated(data); break
        case 'order.partial_cancelled':
            await handleOrderPartialCancelled(data); break
    }
    await db.webhookLogs.update({ status: 'processed' }, { where: { idempotency_key: key } })
}javascript

패턴 2: 이벤트별 처리 함수

// order.done — 주문 결제 완료
async function handleOrderDone(data) {
    const { order_id, order_number, price } = data
    await db.orders.update(
        { status: 'paid', bootpay_order_id: order_id, paid_at: new Date() },
        { where: { bootpay_order_number: order_number } }
    )
    // 재고 차감
    const order = await db.orders.findOne({ where: { bootpay_order_number: order_number } })
    for (const item of order.items) {
        await db.products.decrement('stock', { by: item.quantity, where: { id: item.product_id } })
    }
    await sendEmail(order.user_id, 'order_confirmed', { order_number, price })
}

// order.cancelled — 주문 취소
async function handleOrderCancelled(data) {
    const { order_number } = data
    await db.orders.update({ status: 'cancelled' }, { where: { bootpay_order_number: order_number } })
    const order = await db.orders.findOne({ where: { bootpay_order_number: order_number } })
    for (const item of order.items) {
        await db.products.increment('stock', { by: item.quantity, where: { id: item.product_id } })
    }
}

// subscription.approved — 구독 승인
async function handleSubscriptionApproved(data) {
    await db.subscriptions.update(
        { service_active: true, status: 'active' },
        { where: { bootpay_subscription_id: data.order_subscription_id } }
    )
}

// subscription.terminated — 구독 해지
async function handleSubscriptionTerminated(data) {
    await db.subscriptions.update(
        { service_active: false, status: 'terminated' },
        { where: { bootpay_subscription_id: data.order_subscription_id } }
    )
}

// subscription.renewed — 구독 갱신 (회차 결제 성공)
async function handleSubscriptionRenewed(data) {
    await db.subscriptions.update(
        { current_period_end: new Date(data.next_billing_at), status: 'active', service_active: true },
        { where: { bootpay_subscription_id: data.order_subscription_id } }
    )
}javascript
subscription.renewed 활용

subscription.renewed는 매 회차 결제가 성공할 때마다 발송돼요. next_billing_at 값으로 가맹점 DB의 current_period_end를 갱신하면, 서비스 접근 제어를 정확하게 관리할 수 있어요.

패턴 3: 환불 이벤트 처리 (전체/부분)

order.cancelledorder.partial_cancelled는 처리 로직이 달라요.

// order.cancelled — 전체 취소/환불
async function handleOrderCancelled(data) {
    const { order_number } = data
    await db.orders.update({ status: 'refunded' }, { where: { bootpay_order_number: order_number } })
    // 전체 재고 복구
}

// order.partial_cancelled — 부분 취소/환불
async function handleOrderPartialCancelled(data) {
    const { order_number, cancel_price, remain_price } = data
    await db.orders.update(
        { total_amount: remain_price, status: 'partially_refunded' },
        { where: { bootpay_order_number: order_number } }
    )
    // 부분 취소된 상품만 재고 복구
}javascript
전체 취소 vs 부분 취소 구분
  • order.cancelled → 전체 환불. 주문 무효화.
  • order.partial_cancelled → 일부 환불. 주문은 유효​​하며 잔액이 남아있어요.

웹훅 DB 테이블 (멱등성 보장)

웹훅 로그 테이블을 만들어 idempotency_key 기반으로 중복을 방지해요.

CREATE TABLE webhook_logs (
    id              BIGINT PRIMARY KEY AUTO_INCREMENT,
    idempotency_key VARCHAR(100) NOT NULL UNIQUE,
    event           VARCHAR(50) NOT NULL,
    data            TEXT,
    status          VARCHAR(20) DEFAULT 'received',  -- received → processed → failed
    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,

    INDEX idx_key (idempotency_key),
    INDEX idx_event (event)
);sql

디버깅: 웹훅이 안 올 때

# 확인 사항 해결 방법
PROCESS_DUPLICATED 이미 처리된 요청이에요 HTTP는 지원하지 않음. SSL 인증서 확인
2 관리자에서 이벤트를 선택했는가? 관리자 → 웹훅 설정에서 필요한 이벤트 체크
3 서버가 200을 반환하는가? 다른 상태 코드면 재시도로 처리됨
4 방화벽이 차단하지 않는가? Bootpay IP 대역 허용
5 로컬 개발 환경인가? ngrok 등 터널링 도구 사용
6 테스트 웹훅을 발송해봤는가? 웹훅 설정에서 테스트 웹훅 발송 기능 사용
7 샌드박스 모드인가? 샌드박스에서도 웹훅은 정상 발송됨

로컬 개발 시 웹훅 받기

로컬 개발 환경에서는 외부에서 접근할 수 없으므로 터널링 도구를 사용해요.

# ngrok 사용
ngrok http 3000
# → https://abc123.ngrok.io 주소를 웹훅 URL로 등록

# 또는 localtunnel
npx localtunnel --port 3000bash

터널링 URL을 Bootpay 관리자 웹훅 설정에 등록하면 로컬에서도 웹훅을 수신할 수 있어요.