작은 금액은 결제가 잘 되는데 금액이 커지면 인증창이 안 뜬다면 이 문서예요.
이 증상인지 먼저 확인해요
아래가 모두 맞으면 이 문서의 상황이에요.
- 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')javascriptfalse 가 나오면 이 문서의 상황이에요.
왜 생기나요
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 가 가장 안정적이에요. 자세한 비교는 창 모드 문서를 참고하세요.
바꾸면 무엇이 달라지나요
여기가 핵심이에요. 창 모드를 바꾸면 결제 결과를 받는 위치가 달라져요. 코드를 그대로 두고 옵션만 바꾸면 결제는 되는데 주문이 확정되지 않는 상태가 돼요.
| iFrame (기존) | Popup | Redirect | |
|---|---|---|---|
| 결과 받는 곳 | 프론트엔드 콜백 | 프론트엔드 콜백 | redirect_url 서버 라우트 |
| 결제 중 원래 페이지 | 유지 | 유지 | 떠나요 |
| 화면 상태(장바구니·폼) | 유지 | 유지 | 사라져요 |
| 추가 구현 | - | 거의 없음 | 서버 라우트 필요 |
Popup 으로 바꿀 때 (PC)
결과 처리 코드는 그대로 두면 돼요. confirm · done · cancel · error 이벤트 흐름이 iFrame 과 같아요. 다만 두 가지를 확인해요.
- 구매자 클릭이 한 번 더 들어가요. 팝업 차단 정책 때문에 "결제 계속하기" 확인 단계가 자동으로 붙어요. 결제 완료율에 영향이 없는지 한 번 보세요.
- 결제 요청은 반드시 클릭 핸들러 안에서 호출해요. 버튼 클릭과
requestPayment()사이에await(서버로 주문 생성 요청 등)를 두면 브라우저가 팝업을 차단해요.
// ❌ 팝업이 차단돼요 — await 뒤에 호출하면 클릭과의 연결이 끊겨요
async function onClick() {
const order = await createOrder()
await Bootpay.requestPayment({ /* ... */ })
}
// ✅ 주문 생성을 먼저 끝내두고, 클릭 시점에는 바로 호출해요
async function onClick() {
await Bootpay.requestPayment({ order_id: preparedOrderId, /* ... */ })
}javascriptRedirect 로 바꿀 때 (모바일)
페이지가 결제창으로 완전히 이동했다가 돌아와요. 그래서 아래를 반드시 준비해야 해요.
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는 팝업 콜백, 모바일은 서버 라우트예요. 한쪽만 만들면 그 경로로 들어온 구매자는 결제가 되고도 주문이 확정되지 않아요.
어느 경로에서든 결제 결과를 놓치지 않으려면 웹훅을 함께 사용하세요. 구매자가 결과 페이지 도착 전에 창을 닫아도 서버가 결과를 받을 수 있어요.
구매자에게 안내할 문구
창 모드를 바꾸면 권한 허용 팝업이 정상적으로 뜨므로, 구매자는 허용을 누르면 돼요. 이미 거부한 구매자에게는 아래처럼 안내해요.
- 주소창 왼쪽 자물쇠 아이콘 클릭
- 사이트 설정 선택
- 크롬 145 이상은 기기의 앱, 크롬 142~144는 로컬 네트워크 액세스 항목을 허용 으로 변경
- 결제 페이지 새로고침 후 다시 시도
급하게 결제를 마쳐야 하는 구매자에게는 Microsoft Edge 사용을 안내할 수 있어요.
점검 순서
- 100만원 이하는 되고 초과만 안 되는지 확인해요 — 맞으면 이 문서의 상황이에요
- 결제창 프레임에서
document.featurePolicy.allowsFeature('loopback-network')가false인지 확인해요 - 보안 헤더는 건드리지 않아요 — 이 증상은 헤더로 해결되지 않아요
extra.open_type을 PCpopup· 모바일redirect로 바꿔요redirect를 쓴다면redirect_url서버 라우트를 먼저 만들어요- 테스트 결제로 양쪽 경로 모두 주문이 확정되는지 확인해요