서버 연동

Next.js 쇼핑몰 BFF

Next.js에서 Bootpay Node.js SDK로 상품·고객·장바구니·주문을 연결하는 서버 경계

Next.js 화면이 자사 /api/*를 호출하고, Route Handler가 @bootpay/backend-js로 Commerce v1 API를 호출해요. 프로젝트 secret과 고객 세션은 서버가 관리해요. 상품 탐색·상세·회원가입·로그인·마이페이지·견적·주문 준비를 연결할 수 있고, 완전한 자체 체크아웃(배송지·혜택·결제수단·결과)에는 아래에 정리한 v1 보완이 필요해요.

이 가이드는 2026-09-14 확인한 SDK 2.13.1과 로컬 v1 구현을 기준으로 해요. 운영에서는 상점·상품·상세·상품 검증·비회원 미리보기 응답을 확인했으며, 주문 생성·결제·회원 변경을 모두 실거래로 검증했다는 의미는 아니에요.

호출 경계

sequenceDiagram
  participant Browser as Next.js 화면
  participant BFF as Next.js Route Handler
  participant SDK as Bootpay Node.js SDK
  participant API as Commerce v1
  Browser->>BFF: GET /api/products
  BFF->>SDK: product.list(filters)
  SDK->>API: GET /v1/products + Basic
  API-->>BFF: {list, count}
  BFF-->>Browser: 표시용 상품 필드
  Browser->>BFF: 로그인 후 회원 작업
  BFF->>SDK: 서버 쿠키의 고객 JWT
  SDK->>API: Basic + Bootpay-User-JWT

여기서 /api/products 등은 가맹점이 Next.js에 구현하는 URL​​이에요. Bootpay가 제공하는 URL은 /v1/...이며 두 경로를 혼동하지 않아요.

환경변수와 SDK

.env.local에 서버 전용 값을 넣고 버전관리에서 제외해요.

BOOTPAY_COMMERCE_CLIENT_KEY=your-commerce-client-key
BOOTPAY_COMMERCE_SECRET_KEY=your-commerce-secret-key
npm install @bootpay/backend-js@2.13.1 server-onlybash
// src/lib/commerce.ts
import 'server-only'
import { BootpayCommerce } from '@bootpay/backend-js'

export function getCommerce() {
  const client_key = process.env.BOOTPAY_COMMERCE_CLIENT_KEY
  const secret_key = process.env.BOOTPAY_COMMERCE_SECRET_KEY
  if (!client_key || !secret_key) throw new Error('Commerce credentials are missing')
  return new BootpayCommerce({ client_key, secret_key, mode: 'production' })
}typescript

키를 NEXT_PUBLIC_ 변수, React props, HTML, 브라우저 로그에 넣지 않아요. Node.js SDK를 쓰는 Route Handler는 Node 런타임으로 실행해요. getAccessToken()은 필요하지 않아요. 실제 인증 계약을 참고해요.

상품 목록 Route Handler 예제

// src/app/api/products/route.ts
import { NextResponse } from 'next/server'
import { getCommerce } from '@/lib/commerce'

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

type JsonObject = Record<string, unknown>
function object(value: unknown): value is JsonObject {
  return typeof value === 'object' && value !== null && !Array.isArray(value)
}

export async function GET(request: Request) {
  const query = new URL(request.url).searchParams
  const page = Number(query.get('page') ?? 1)
  const keyword = (query.get('keyword') ?? '').trim()
  if (!Number.isSafeInteger(page) || page < 1 || keyword.length > 100) {
    return NextResponse.json({ error: 'INVALID_QUERY' }, { status: 400 })
  }
  try {
    const raw: unknown = await getCommerce().product.list({ page, limit: 20, keyword })
    if (!object(raw) || !Array.isArray(raw.list) || typeof raw.count !== 'number') {
      return NextResponse.json({ error: 'INVALID_UPSTREAM_RESPONSE' }, { status: 502 })
    }
    const items = raw.list.filter(object).map((product) => ({
      id: String(product.product_id ?? product._id ?? ''),
      name: typeof product.name === 'string' ? product.name : '',
      displayPrice: typeof product.display_price === 'number' ? product.display_price : null,
      image: Array.isArray(product.images) && typeof product.images[0] === 'string'
        ? product.images[0] : null,
      statusSale: product.status_sale === true,
      useStock: product.use_stock === true,
      stock: typeof product.stock === 'number' ? product.stock : null
    })).filter((product) => product.id.length > 0)
    return NextResponse.json({ items, count: raw.count }, {
      headers: { 'Cache-Control': 'no-store' }
    })
  } catch {
    return NextResponse.json({ error: 'COMMERCE_UNAVAILABLE' }, { status: 502 })
  }
}typescript

SDK는 실제 본문을 반환해요. 위 코드에서 .data.list로 한 번 더 접근하지 않아요. 상품 ID는 운영 응답의 product_id를 사용하고, 이전 serializer의 _id를 허용한다면 경계에서 정규화해요. displayPrice는 표시 가격이므로 결제 금액에는 검증/견적을 사용해요.

위 예제의 잘못된 page 에 대한 400은 가맹점 BFF의 입력 정책​​이에요. v1 목록 API 는 잘못된 page 를 1로, 잘못된 limit 를 20으로 보정하고 limit 최대값을 100으로 제한해요. 엔드포인트별 차이는 페이지네이션을 봐요.

화면별 연결표

Next.js 기능 SDK/HTTP 검증할 응답과 제약
상점 정보 commerce.store.getStore() → GET /v1/store { mall, seller }; 공개 표시/주문 정책 필드만 반환
상품 목록 product.list() → GET /v1/products { list, count }; 검색 keyword, 정렬 sort
상품 상세 product.productDetail(id) → GET /v1/products/:id project_id, product_id, options; 프로젝트와 표시 필드 검증
카테고리 category.list() → GET /v1/categories 배열 트리; 노출 상태 필터
가입 정책 commerce.get('users/signup-policy') 약관 목록·수집 필드·policy_version; 가입 화면을 이걸로 그려요
쇼핑몰 회원가입 commerce.post('users/signup', {...}) 약관·주소·중복·재가입 제한을 거친 신규 가입. 가입 후 별도 로그인
고객 이관 user.join({ login_pw, ... }) → POST /v1/users/join 기존 회원 서버 동기화. 가입 정책을 거치지 않아요
로그인 user.userLogin({ login_id, password }) { token }; HttpOnly 쿠키에 저장
로그인 상태 user.userSession(jwt) 고객 객체 또는 null
회원 장바구니·찜 generic get/post/put/delete 고객 JWT 또는 엔드포인트가 허용하는 user_id로 회원 지정. 다른 상점 상품 차단. 찜 to_cart는 회원별 Idempotency-Key 지원
마이페이지 users/me, users/me/password, users/me/withdrawal 본인 전용 권한 + 고객 JWT. 본인 프로필
비밀번호 찾기 users/password-recovery/* 로그아웃 상태. 등록된 연락처로만 발송
회원 주소록 users/address CRUD JWT면 본인 고정, user_id면 서버 권한
상품 검증·견적 cart.orderPreview({ cart_items }) 등 상품·배송 견적. 주소/쿠폰/적립금 최종 계약과 구분
주문서 생성 commerce.post('orders/prepare', body, config) URL 모드 { order_number, url }; 고객 JWT 헤더 + use_auto_login: true이면 API가 세션 경유 /b URL 반환
주문 완료 검증 commerce.order.detail(orderNumber) 구매자·금액·주문/영수증 상태 검증 후 표시
배송 추적 commerce.get('orders/ORDER_NUMBER/purchases') 발주 배열. 배송 조회

고객 세션과 변경 요청

고객 JWT는 서버에서 읽은 세션 쿠키로부터 가져와 각 요청의 Bootpay-User-JWT 헤더에 넣어요. 공용 SDK 인스턴스의 사용자 상태를 요청마다 바꾸면 동시 요청 간에 고객이 섞일 수 있어요. setToken(jwt)는 고객 헤더를 설정하지 않아요.

로그인/회원가입 요청의 필드를 허용목록으로 구성해요. 브라우저가 user_id, is_group_admin, extra.skip_password를 임의 지정하게 만들지 않아요. 변경 BFF는 동일 출처 검사, CSRF 방어, 요청 크기/수량 제한을 적용해요. POST /v1/orders/prepare 재시도에는 같은 구매 시도와 같은 본문에 같은 Idempotency-Key 를 써요. 같은 키로 입력을 바꾸면 409 IDEMPOTENCY_REQUEST_MISMATCH, 이전 처리가 진행 중이면 409 IDEMPOTENCY_REQUEST_IN_PROGRESS 와 Retry-After 가 와요. 이 API 의 기록 기간은 7일이며, 프로젝트 공통 멱등 키의 24시간 규칙과 달라요. 다른 변경 API 에는 각 엔드포인트의 멱등 지원 여부를 확인해요. 멱등 키

상품/옵션 ID가 실제 상점의 것인지, 주문이 현재 고객 소유인지 BFF에서도 확인해요. 서버 주문 상세에는 구매자 개인정보나 서버 전용 결제 키가 포함될 수 있으므로 원문 전체를 브라우저에 반환하지 않아요.

회원 장바구니에서 호스팅 주문서로 이동

자사 화면의 로그인 쿠키는 shop.bootpay.co.kr에 전달되지 않아요. BFF가 고객 JWT와 use_auto_login: true, response_type: 'url'을 준비 API에 보내면 API가 기존 /b/{client_key} 세션 경유 URL을 반환해요. 브라우저가 응답 url로 이동하면 호스팅이 세션 쿠키를 설정한 뒤 주문서로 이동해요. Next.js와 MCP가 암호화나 경유 URL 생성을 구현할 필요는 없어요.

먼저 현재 고객의 JWT를 준비해요.

  • POST /v1/users/login으로 이미 로그인했다면 BFF가 이번 요청의 서버 세션에서 JWT를 읽어요. 같은 고객의 유효한 토큰을 주문마다 재발급할 필요는 없어요.
  • 자사 인증으로만 로그인했다면 서버가 검증한 사용자와 Bootpay 고객의 매핑으로 POST /v1/users/login/token을 호출해요. 브라우저가 보낸 임의 user_id로 발급하지 않아요.
  • JWT가 없거나 세션 조회 본문이 null이면 회원 주문 준비를 중단하고 로그인을 요청해요. use_auto_login 플래그가 비회원 주문에 회원을 만들어 주지는 않아요.

아래 서버 모듈은 앞의 getCommerce()로 새 구매 시도의 주문서​​를 만들어요. Route Handler가 서버 세션에서 가져온 customerJwt와 서버에서 상품·옵션·수량·금액을 검증한 order를 전달해요. 브라우저 요청 본문을 그대로 order에 넘기지 않아요. 기존 구매 시도에 주문서가 있으면 아래의 재사용·만료 확인을 먼저 수행해요.

// src/lib/hosted-checkout.ts
import 'server-only'
import { getCommerce } from '@/lib/commerce'

type ValidatedOrder = {
  products: Array<{
    product_id: string
    quantity: number
    option?: { product_option_id: string }
  }>
  price: number
}

export async function prepareMemberCheckout(
  customerJwt: string,
  order: ValidatedOrder
) {
  if (!customerJwt) throw new Error('LOGIN_REQUIRED')
  const commerce = getCommerce()
  const session: unknown = await commerce.user.userSession(customerJwt)
  if (
    typeof session !== 'object' || session === null ||
    !('user_id' in session) ||
    typeof session.user_id !== 'string' || !session.user_id
  ) {
    throw new Error('LOGIN_REQUIRED')
  }

  const prepared: unknown = await commerce.post('orders/prepare', {
    products: order.products,
    price: order.price,
    uct: 1,
    response_type: 'url',
    use_auto_login: true,
    redirect_url: 'https://shop.example.com/checkout/result'
  }, {
    headers: { 'Bootpay-User-JWT': customerJwt }
  })

  if (
    typeof prepared !== 'object' || prepared === null ||
    !('order_number' in prepared) || !('url' in prepared) ||
    typeof prepared.order_number !== 'string' || !prepared.order_number ||
    typeof prepared.url !== 'string'
  ) {
    throw new Error('INVALID_CHECKOUT_RESPONSE')
  }
  const url = new URL(prepared.url)
  const trustedHost = url.hostname === 'shop.bootpay.co.kr' ||
    /^[a-z0-9-]+\.bootpay\.shop$/.test(url.hostname)
  if (
    url.protocol !== 'https:' || !trustedHost ||
    url.port || url.username || url.password
  ) {
    throw new Error('INVALID_CHECKOUT_HOST')
  }
  const redirect = url.searchParams.get('redirect_url')
  if (
    !/^\/b\/[^/]+$/.test(url.pathname) ||
    !url.searchParams.get('__s') || !redirect
  ) {
    throw new Error('AUTO_LOGIN_URL_REQUIRED')
  }
  const destination = new URL(redirect, url.origin)
  if (
    destination.origin !== url.origin ||
    destination.username || destination.password ||
    !/^\/mall\/[^/]+\/order\/[^/]+$/.test(destination.pathname)
  ) {
    throw new Error('INVALID_CHECKOUT_DESTINATION')
  }
  // 검증에만 URL을 사용하고, __s를 포함한 API 원문 URL을 그대로 반환해요.
  return { order_number: prepared.order_number, url: prepared.url }
}typescript

요청 본문의 redirect_url은 실제 자사 완료 페이지로 바꿔요. 응답 /b 쿼리의 동명 필드는 호스팅이 세션 설정 후 이동할 주문 경로이므로 역할이 달라요. 다른 Bootpay 결제 호스트를 사용하는 계약이면 서버의 허용 호스트 목록을 그 계약에 맞춰 설정해요. 회원 유형은 개인 uct: 1 예시이며 기업/부서 주문은 해당 회원 그룹의 유형을 사용해요. Route Handler는 주문번호를 현재 구매자·구매 시도와 연결해 저장한 뒤 원문 URL을 반환하고, 브라우저는 그대로 이동해요.

최상위 token은 { order_number, url } 응답에 추가되지 않아요. API가 검증한 고객 세션을 URL의 __s에 암호화해 담으므로 URL 자체는 세션 자격증명으로 취급해요. JWT·__s·원문 URL을 로그에 남기거나, 가맹점이 JWT를 쿼리에 덧붙이거나 URL을 재암호화·재조합하지 않아요. setToken(jwt)도 요청별 Bootpay-User-JWT 헤더를 대신하지 않아요.

헤더와 플래그를 보냈는데도 로그인이 풀려 보일 때

먼저 실제 오류 코드와 주문 상태를 확인해요. ORDER_NOT_PAYMENT_READY나 order_expired는 세션 복원만으로 해결되지 않아요. 반대로 결제 대기 중인 유효한 회원 주문에서도 API가 직접 주문 URL을 반환하면 /b의 세션 설정이 빠질 수 있어요. 위 예제의 AUTO_LOGIN_URL_REQUIRED는 그 계약 누락을 탐지하는 가맹점 오류이며 클라이언트가 URL을 보정하라는 뜻이 아니에요.

2026-09-14 운영에서는 직접 URL 반환이 관찰됐고, 기존 /b 경로를 통한 진단에서는 쿠키 설정과 주문 소유자 세션 연결이 확인됐어요. API가 /b를 반환하도록 하는 수정은 로컬 반영과 운영 배포를 구분해야 해요. 소스 근거와 순서별 진단을 사용해요.

자동 로그인 URL은 API가 기존 범용 /b 경로로 만들어요. 청구서 전용 /i/{clientKey}/{invoiceId}/session은 Invoice BSON ID를 사용하므로 일반 주문번호를 넣을 수 없어요. /order/.../session도 만들지 않아요. 범용 경유와 청구서 전용 경유의 구분을 참고해요.

기존 주문서 URL 재사용과 만료 후 재시도

구매 시도 ID로 주문번호·URL을 저장하는 BFF는 저장된 URL을 즉시 반환하지 않아요. 현재 고객의 구매 시도 기록을 찾은 뒤 SDK commerce.order.detail(orderNumber)로 주문 상태를 다시 조회해요. 조회 응답의 주문번호도 저장된 주문번호와 같은지 검증해요.

서버가 확인한 상태 BFF와 화면의 처리
현재 결제 가능한 주문 기존 구매 시도와 URL을 재사용
status: 'order_expired' 기존 URL 반환 중단. 가맹점 BFF 오류(예: CHECKOUT_EXPIRED, HTTP 409)로 "새 주문서로 다시 진행" 선택 제공
결제 완료 또는 승인/결제 처리 중 기존 주문 결과를 확인. 자동 새 주문 생성 금지
응답 불일치·알 수 없는 상태·조회 실패 상태 확인 실패로 처리하고 기존 URL 재전송과 자동 새 주문 생성 중단

CHECKOUT_EXPIRED는 이 예시의 가맹점 BFF 오류 이름이며 Bootpay의 새 API 오류 코드가 아니에요. 고객이 명시적으로 만료된 주문을 새로 진행하기로 선택했을 때만 새 구매 시도 ID와 새 Idempotency-Key 를 만들고, 상품·재고·금액·고객 세션을 다시 검증한 뒤 prepareMemberCheckout()을 호출해요. 원래 키로 재시도하면 견적 만료 후에도 처음 주문 준비 응답이 돌아와요. 모든 오류에서 구매 시도 ID를 바꾸면 이전 결제가 처리 중일 때 중복 주문을 만들 수 있어요.

receipt_status: 'receipt_ready'만 보고 결제 가능한 주문이라고 판단하지 않아요. 주문 status가 order_expired이면 만료된 주문이에요. 만료와 세션 문제의 구분을 참고해요.

API 보완 전 동작

  • 회원 장바구니·찜은 고객 JWT와 함께 호출해요. 비회원 흐름을 유지한다면 브라우저에 장바구니를 저장하고 상품 검증 API로 금액/가용성을 다시 확인해요. 이를 서버 동기화 성공으로 표시하지 않아요.
  • use_non_member_order: false이면 장바구니 미리보기가 성공해도 주문은 로그인 후 진행해요.
  • 주소록은 조회·생성·수정·삭제가 모두 열려 있어요(배송주소). 기본 배송지는 is_default로 내려와요.
  • 쿠폰·적립금·도서산간 배송비는 현재 v1 견적에서 입력을 읽지 않으므로 적용된 것처럼 표시하지 않아요.
  • 주문 준비 URL로 결제 화면에 연결하고, 완전한 자체 체크아웃은 배송지/혜택/결제수단/결과 API를 보완한 뒤 연결해요.
  • /v1/order/confirm은 결제 승인, 배송 후 구매확정은 별도 계약이에요.

현재 API와 보완 제안을 구분한 상세 계약은 장바구니, 주소·쿠폰, 주문 준비, 결과에 있어요. 회원 흐름은 쇼핑몰 회원가입, 본인 프로필, 비밀번호, 회원 탈퇴에 있어요.

검증 범위

검증은 SDK 요청 경로·헤더, 원문 응답 DTO, 잘못된 상품/수량 입력, 비로그인 회원 API 차단, 경유 URL과 목적지 검사, 주문 소유권 검사부터 시작해요. 회원 주문에서는 유효 JWT + /b 반환, 만료 JWT, JWT 없음 + true일 때 주문 생성 전 거부, false의 기존 직접 URL, 비URL 메타데이터 응답을 각각 확인해요. 호스팅 도메인에 쿠키가 없는 브라우저에서 응답 URL을 그대로 따라간 뒤 세션 쿠키·주문 소유자 일치·후속 회원 정보 요청을 확인해야 인계 성공이에요.

운영 조회와 실제 결제 검증을 분리하고, 주문 준비/승인/취소를 테스트 성공을 위해 임의 호출하지 않아요. 화면에서는 API 오류와 정상 빈 목록을 구분해요.

공식 근거: SDK 기본 전송·인증, 고객 모듈, 상품 모듈.