결제창

로컬 네트워크 권한(ISP·페이북)

보안 헤더로는 해결되지 않아요. 창 모드를 바꿔야 해요.

작은 금액은 결제가 잘 되는데 금액이 커지면 인증창이 안 뜬다면 이 문서예요.

이 증상인지 먼저 확인해요

아래가 모두 맞으면 이 문서의 상황이에요.

  • 100만원 이하​​는 정상 결제돼요
  • 100만원 초과​​에서만 인증서 창이 뜨지 않고 멈춰요
  • KB국민 · BC · 카카오뱅크 카드(ISP·페이북)에서 발생해요
  • 크롬에서 발생하고, 엣지나 다른 브라우저에서는 되기도 해요

브라우저 개발자도구(F12) → Console 에 아래와 비슷한 줄이 있어요.

Access to XMLHttpRequest at 'https://127.0.0.1:<포트>/' from origin 'https://<PG도메인>'
has been blocked by CORS policy: Permission was denied for this request to access
the `loopback` address space.
확실하게 판별하려면

개발자도구 Console 좌측 상단의 프레임 선택을 결제창 프레임​​으로 바꾸고 아래를 실행해요.

document.featurePolicy.allowsFeature('loopback-network')javascript

false 가 나오면 이 문서의 상황이에요.

왜 생기나요

100만원을 넘는 카드결제는 카드사가 인증서 단계​​를 추가로 요구해요. 이 단계는 구매자 PC에 설치된 카드사 보안 프로그램을 불러야 하는데, 브라우저는 그 프로그램을 https://127.0.0.1:<포트> 주소로 호출해요.

크롬 142(2025년 10월)부터 로컬 네트워크 액세스(Local Network Access) 정책이 적용되면서, 웹사이트가 이 주소로 접근하려면 구매자의 권한 허용이 필요해졌어요.

문제는 결제창을 iFrame 으로 열었을 때예요. 이 권한은 다른 도메인의 iFrame 에 자동으로 내려가지 않아요(기본값이 self). 그래서 권한을 묻는 팝업조차 뜨지 않고 조용히 거부​​돼요. 구매자 입장에서는 "인증창이 안 뜬다" 로만 보여요.

100만원 이하에서 멀쩡한 이유는 그 구간에는 인증서 단계가 없어서 로컬 프로그램을 부를 일이 아예 없기 때문이에요.

보안 헤더로는 해결되지 않아요

여기서 시간을 가장 많이 낭비해요

"우리 웹서버(Vercel·Next.js·nginx) 헤더가 조여서 그렇다" 고 판단하기 쉽지만 아니에요. 헤더를 고치는 방향으로는 해결되지 않아요.

크롬 145 기준으로 실제 측정한 결과예요.

상황 로컬 네트워크 접근
최상위 페이지 · 팝업 허용 (헤더와 무관)
다른 도메인 iFrame 거부
다른 도메인 iFrame + 서버에 Permissions-Policy: loopback-network=* 추가 거부
보안 헤더가 아예 없는 사이트의 iFrame 거부

정리하면 이래요.

  • Permissions-Policy 를 추가해도 iFrame 안은 그대로 거부돼요. 상위 페이지의 권한이 다른 도메인 iFrame 으로 내려가려면 iFrame 태그 쪽 설정이 함께 필요한데, 그건 결제 SDK 영역이라 가맹점이 손댈 수 없어요.
  • CSP 와는 무관해요. CSP 는 다른 도메인 iFrame 안으로 상속되지 않아요. 결제창 안에서 일어나는 통신은 PG 사이트의 정책을 따라요.
  • 보안 헤더를 한 번도 설정한 적 없는 사이트에서도 똑같이 거부돼요. 브라우저 기본 동작이라서 그래요.
  • 오히려 loopback-network=(self) 처럼 잘못 적으면 없던 제약이 새로 생겨요.

해결 — 창 모드를 바꿔요

결제창이 최상위 창​​으로 뜨면 권한이 정상적으로 적용되고, 구매자에게 허용 여부를 묻는 팝업도 정상적으로 나타나요.

import { Bootpay } from '@bootpay/client-js'

const isMobile = /Mobile|Android|iP(hone|od|ad)/i.test(navigator.userAgent)

await Bootpay.requestPayment({
    client_key: '[ Client Key ]',
    price: 1180000,
    order_name: '주문명',
    order_id: orderId,
    extra: {
        // PC는 팝업, 모바일은 리다이렉트
        open_type: isMobile ? 'redirect' : 'popup',
        // redirect일 때 필수예요
        redirect_url: 'https://내도메인/order/result'
    }
})javascript
왜 모바일은 redirect 인가요

모바일에는 팝업 개념이 없고 카카오톡·인스타그램 같은 인앱 브라우저에서 팝업이 막혀요. 모바일은 redirect 가 가장 안정적이에요. 자세한 비교는 창 모드 문서를 참고하세요.

바꾸면 무엇이 달라지나요

여기가 핵심이에요. 창 모드를 바꾸면 결제 결과를 받는 위치가 달라져요. 코드를 그대로 두고 옵션만 바꾸면 결제는 되는데 주문이 확정되지 않는 상태가 돼요.

iFrame (기존) Popup Redirect
결과 받는 곳 프론트엔드 콜백 프론트엔드 콜백 redirect_url 서버 라우트
결제 중 원래 페이지 유지 유지 떠나요
화면 상태(장바구니·폼) 유지 유지 사라져요
추가 구현 - 거의 없음 서버 라우트 필요

결과 처리 코드는 그대로 두면 돼요. confirm · done · cancel · error 이벤트 흐름이 iFrame 과 같아요. 다만 두 가지를 확인해요.

  1. 구매자 클릭이 한 번 더 들어가요. 팝업 차단 정책 때문에 "결제 계속하기" 확인 단계가 자동으로 붙어요. 결제 완료율에 영향이 없는지 한 번 보세요.
  2. 결제 요청은 반드시 클릭 핸들러 안에서 호출해요. 버튼 클릭과 requestPayment() 사이에 await(서버로 주문 생성 요청 등)를 두면 브라우저가 팝업을 차단해요.
// ❌ 팝업이 차단돼요 — await 뒤에 호출하면 클릭과의 연결이 끊겨요
async function onClick() {
    const order = await createOrder()
    await Bootpay.requestPayment({ /* ... */ })
}

// ✅ 주문 생성을 먼저 끝내두고, 클릭 시점에는 바로 호출해요
async function onClick() {
    await Bootpay.requestPayment({ order_id: preparedOrderId, /* ... */ })
}javascript

Redirect 로 바꿀 때 (모바일)

페이지가 결제창으로 완전히 이동했다가 돌아와요. 그래서 아래를 반드시 준비해야 해요.

이걸 안 하면 결제는 되는데 주문이 확정되지 않아요

redirect 는 프론트엔드 콜백이 호출되지 않아요. 서버 라우트를 만들지 않으면 결제 승인과 주문 확정이 아무 데서도 일어나지 않아요.

① 주문을 결제 요청 전에 서버에 저장해요

페이지를 떠나면 화면 메모리에 있던 값(장바구니, 배송지 폼, 쿠폰 선택)이 전부 사라져요. order_id 와 결제 금액, 주문 내용을 결제창을 띄우기 전에 서버 DB에 저장해 두세요. 돌아왔을 때 order_id 로 다시 찾을 수 있어야 해요.

② redirect_url 은 서버가 처리하는 주소여야 해요

Next.js 라면 Route Handler 나 API Route 여야 하고, 클라이언트 컴포넌트로는 안 돼요. 승인 API 호출에 시크릿 키가 필요해서 브라우저에서 처리할 수 없어요.

③ 쿼리로 넘어오는 값은 요약본이에요

event · receipt_id · order_id · status · message 가 쿼리 파라미터로 들어와요. 상세 결제 정보가 아니에요. receipt_id 로 결제 조회 API를 다시 호출해서 실제 금액과 상태를 확인해야 해요.

④ 승인은 서버에서 해요

event=confirm 은 "구매자 인증이 끝났고 승인 직전" 이라는 뜻이에요. 아직 결제가 완료된 게 아니에요. 서버에서 DB 주문 금액과 대조한 뒤 승인 API를 호출해야 결제가 완료돼요. 자세한 내용은 분리 승인 문서를 참고하세요.

import { NextRequest, NextResponse } from 'next/server'

export async function GET(req: NextRequest) {
    const q = req.nextUrl.searchParams
    const event = q.get('event')
    const receiptId = q.get('receipt_id')
    const orderId = q.get('order_id')

    // 취소·실패는 주문을 미결제로 두고 안내 페이지로 보내요
    if (event !== 'confirm' && event !== 'done') {
        return NextResponse.redirect(new URL(`/orders/${orderId}/failed`, req.url))
    }

    // 이미 처리한 결제면 다시 승인하지 않아요 (뒤로가기·새로고침 대비)
    const order = await findOrder(orderId)
    if (order.status === 'paid') {
        return NextResponse.redirect(new URL(`/orders/${orderId}/complete`, req.url))
    }

    // 쿼리 값을 믿지 말고 receipt_id로 다시 조회해요
    const payment = await bootpay.receiptPayment(receiptId)
    if (payment.price !== order.price) {
        await bootpay.cancelPayment({ receipt_id: receiptId, cancel_username: 'system' })
        return NextResponse.redirect(new URL(`/orders/${orderId}/failed`, req.url))
    }

    if (event === 'confirm') {
        await bootpay.confirmPayment(receiptId)   // 서버 승인
    }

    await markOrderPaid(orderId, receiptId)
    return NextResponse.redirect(new URL(`/orders/${orderId}/complete`, req.url))
}typescript

⑤ 프론트엔드 try/catch 는 이동 전 상황만 잡아요

requestPayment() 의 catch 로는 결제창으로 이동하기 전에 발생한 취소·오류만 들어와요. 결제 결과는 여기로 오지 않아요.

⑥ 세션 쿠키가 SameSite=Strict 면 로그인이 풀려 보여요

결제창에서 돌아오는 것은 다른 사이트에서 넘어오는 이동이라, Strict 로 설정한 쿠키는 전송되지 않아요. 결과 페이지에서 비로그인 상태로 보이면 이걸 의심하세요. 세션 쿠키는 SameSite=Lax 로 두세요.

⑦ 새로고침·뒤로가기에 대비해요

구매자가 결과 페이지에서 새로고침하면 같은 receipt_id 로 라우트가 다시 실행돼요. 위 예시의 order.status === 'paid' 검사처럼 같은 주문을 두 번 승인하지 않도록 막아 두세요.

⑧ redirect_url 을 빠뜨리면 결제창이 아예 안 떠요

open_type: 'redirect' 인데 redirect_url 이 없으면 RC_REDIRECT_URL_INVALID 오류가 발생해요.

PC와 모바일을 다르게 쓴다면

양쪽 결과 처리를 모두 구현해야 해요. PC는 팝업 콜백, 모바일은 서버 라우트예요. 한쪽만 만들면 그 경로로 들어온 구매자는 결제가 되고도 주문이 확정되지 않아요.

웹훅을 함께 켜두면 안전해요

어느 경로에서든 결제 결과를 놓치지 않으려면 웹훅을 함께 사용하세요. 구매자가 결과 페이지 도착 전에 창을 닫아도 서버가 결과를 받을 수 있어요.

구매자에게 안내할 문구

창 모드를 바꾸면 권한 허용 팝업이 정상적으로 뜨므로, 구매자는 허용​​을 누르면 돼요. 이미 거부한 구매자에게는 아래처럼 안내해요.

  1. 주소창 왼쪽 자물쇠 아이콘 클릭
  2. 사이트 설정 선택
  3. 크롬 145 이상은 기기의 앱​, 크롬 142~144는 로컬 네트워크 액세스 항목을 허용 으로 변경
  4. 결제 페이지 새로고침 후 다시 시도

급하게 결제를 마쳐야 하는 구매자에게는 Microsoft Edge 사용을 안내할 수 있어요.

점검 순서

  1. 100만원 이하는 되고 초과만 안 되는지 확인해요 — 맞으면 이 문서의 상황이에요
  2. 결제창 프레임에서 document.featurePolicy.allowsFeature('loopback-network') 가 false 인지 확인해요
  3. 보안 헤더는 건드리지 않아요 — 이 증상은 헤더로 해결되지 않아요
  4. extra.open_type 을 PC popup · 모바일 redirect 로 바꿔요
  5. redirect 를 쓴다면 redirect_url 서버 라우트를 먼저 만들어요
  6. 테스트 결제로 양쪽 경로 모두 주문이 확정되는지 확인해요