PG 결제 연동 삽질 모음 — AICopia에서 이니시스·카카오페이·네이버페이와 싸운 기록

· @martin

PG 연동은 "SDK 붙이고 콜백 받으면 끝"이 아니었다. 쇼핑몰 솔루션 AICopia에 PG 6종(토스페이먼츠·KG이니시스·나이스페이·카카오페이·네이버페이·페이코)을 붙이면서, 2026년 8월 7일부터 12일까지 엿새 동안 결제 관련 커밋만 30개 가까이 쌓였다. 대부분 결제 로직이 아니라 그 바깥 — CSP, iframe, 쿠키, CSS, 에러 메시지 — 에서 터졌다.

구조는 단순하다. PG마다 PGInterface를 구현한 어댑터(InicisAdapter, KakaoPayAdapter, NaverPayAdapter …)가 결제 파라미터를 만들고, 주문서(checkout.php)의 launchPG()가 pg 값으로 분기해 각 SDK를 호출한다. 승인은 PaymentController::callback()에서 서버사이드로 재검증한다. CodeIgniter 4 + PHP 8.5, 대부분의 디버깅은 Claude Code와 브라우저 콘솔을 오가며 했다.

아래는 그 엿새의 기록이다. 같은 길을 가는 분들이 반나절씩이라도 아끼길 바라며.

1. 모든 PG의 첫 번째 적은 CSP였다

결제창이 안 뜨면 PG부터 의심하게 되는데, 우리 경우 첫 범인은 우리 서버가 보내는 Content-Security-Policy였다. SecurityHeadersFilter가 default-src 'self' 기반의 빡빡한 CSP를 쓰고 있었고, 결제 SDK들은 그 틈새마다 걸렸다.

토스페이먼츠 하나만 해도 네 군데에서 막혔다.

디렉티브

막힌 것

증상

script-src

js.tosspayments.com

SDK 로드 실패, window.TossPayments undefined

connect-src

apigw(-sandbox).tosspayments.com

결제창 열기 전 XHR 차단 → NETWORK_ERROR

frame-src

payment-gateway(-sandbox).tosspayments.com

결제창이 iframe이라 default-src로 폴백돼 차단

form-action

stdpay.inicis.com, pay.nicepay.co.kr

이니시스·나이스페이의 form POST 차단

원칙은 두 가지로 정했다. 와일드카드 금지(*.inicis.com 대신 호스트를 하나씩), 그리고 실측. SDK를 실제로 띄우고 콘솔의 CSP 위반 로그를 보며 필요한 호스트만 추가했다. 디렉티브별 호스트와 와일드카드 금지는 단위 테스트로 묶어 회귀를 막았다.

하나 반전이 있었다. 처음엔 "최소 범위" 원칙으로 토스의 텔레메트리 호스트(log·event.tosspayments.com)를 뺐는데, SDK가 재시도하면서 주문서 콘솔에 CSP 오류를 끝없이 쌓았다. 진짜 결제 오류가 그 소음에 묻혔다. 결제와 무관해도 콘솔을 더럽히는 호스트는 열어 두는 게 디버깅에 낫다. 부트스트랩·네이버페이 CSS 소스맵을 connect-src에 넣은 것도 같은 이유다.

참고로 PHP curl로 부르는 서버사이드 REST API(api.tosspayments.com 등)와 location.href로 최상위 이동하는 카카오페이·페이코는 CSP 대상이 아니다. 열 필요가 없다.

2. 이니시스 — 한 꺼풀 벗기면 또 한 꺼풀

이니시스는 문제가 가장 많았다. 하나를 고치면 그다음 단계에서 새 문제가 드러나는 양파 구조였다. 시간 순서대로 적는다.

① 결제창이 아예 안 열린다 — SDK 파일에 POST를 하고 있었다

launchPG()가 동적으로 만든 form을 결제 엔드포인트가 아니라 SDK 자바스크립트 파일 URL(INIStdPay.js)로 POST하고 있었다. 이니시스·나이스페이 모두 폼 전송이 아니라 "SDK 로드 → 함수 호출" 방식이다.

  • 이니시스: INIStdPay.js 로드 → 파라미터 form의 id를 INIStdPay.pay('SendPayForm_id')에 넘김
  • 나이스페이: /v1/js/ 로드 → AUTHNICE.requestPay({...})
  • SDK는 필요한 시점에 1회만 주입하는 loadScript() 헬퍼로 처리(주문서가 PG SDK 6개를 미리 다 받지 않도록)

어댑터 파라미터도 규격 미달이었다. version·currency·gopaymethod·acceptmethod·buyertel·buyeremail, 그리고 returnUrl·closeUrl. 특히 returnUrl이 없으면 인증 후 돌아올 곳이 없어 결제가 그냥 끊긴다. 한글 상품명·구매자명이 깨지지 않게 form의 acceptCharset을 UTF-8로 고정한 것도 이때다.

② 화면은 멀쩡한데 아무것도 클릭이 안 된다 — 부트스트랩5와의 충돌

결제 버튼을 누르면 화면엔 변화가 없는데 클릭만 먹통이 됐다. 원인은 CSS였다. INIStdPay.css는 부트스트랩3 방식(.fade.in → opacity:1)을 기대하는데 AICopia는 부트스트랩5(.fade.show)를 쓴다. 부트스트랩5의 전역 .fade 규칙이 이니시스가 주입한 레이어에 그대로 적용돼 opacity:0으로 고정됐고, display와 pointer-events는 살아 있으니 보이지 않는 채로 클릭만 막는 레이어가 남은 것이다.

/* 이니시스 레이어에 한정해 부트스트랩5의 .fade를 되돌린다 */
.inipay_modal.fade.in,
.inipay_modal-backdrop.fade.in {
    opacity: 1 !important;
}

③ 결제를 끝내도 화면이 안 뜬다 — frame-ancestors 'none'

이니시스·나이스페이는 결제창을 팝업이 아니라 주문서 위 레이어(iframe)로 띄우고, returnUrl·closeUrl도 그 iframe 안에서 로드한다. 그런데 보안 필터가 모든 응답에 frame-ancestors 'none'을 보내고 있어서, 결제 레이어가 우리 페이지를 부르는 순간 "copia.aivance.kr에서 연결을 거부했습니다"가 떴다. frame-src 목록과 대칭으로 두 PG 도메인만 frame-ancestors에 열었다.

④ 결제는 됐는데 "잘못된 접근입니다" — SameSite=Lax 세션

화면은 뜨게 됐는데 이번엔 콜백에서 로그인 정보가 사라졌다. 세션 쿠키 ci_session이 SameSite=Lax라서 크로스사이트 iframe 서브요청에는 실리지 않는다. 결과적으로 PaymentController::callback()이 userId를 못 읽어 성공 경로에서도 루트로 튕겼다. 화면 문제가 아니라 결제 확정 자체가 안 될 수 있는 심각한 버그였다.

SameSite를 None으로 푸는 대신 FrameBridge를 만들었다. 요청이 Sec-Fetch-Dest: iframe이면 실제 처리 대신 최상위 창을 같은 URL로 보내는 탈출 페이지를 응답한다. 탈출 후 재요청은 최상위 이동이라 세션이 정상으로 실린다.

// FrameBridge — iframe으로 로드된 요청인지 판별
return $request->getHeaderLine('Sec-Fetch-Dest') === 'iframe';
<script>
(top !== self ? top : self).location.href = <?= json_encode($targetUrl, JSON_THROW_ON_ERROR) ?>;
</script>

⑤ 결제창이 실패하면 주문서가 통째로 굳는다 — 닫기 버튼 없는 모달

INIStdPay는 결제창을 #inicisModalDiv(backdrop: 'static') 안의 iframe으로 띄운다. 그런데 SDK의 INIModal_init이 닫기 버튼이 든 modal-header를 만들고도 modal에 append를 안 한다. 그래서 결제창이 정상 진입하지 못하면(MID 오류 등) 부모 페이지엔 오버레이를 걷을 방법이 아예 없고, 주문서가 클릭 불가 상태로 굳는다.

  • 어댑터: MID·사인키가 비면 결제 파라미터 대신 error를 반환해 pay() 호출 자체를 막는다(SDK의 개발자용 alert 대신 원인이 보이게).
  • 주문서: 오버레이가 뜨면 최상단에 "결제 취소" 버튼을 붙인다. 누르면 viewOff() → 모달·딤배경·파라미터 폼 제거 → SDK가 부모 document에 건 contextmenu/selectstart/dragstart 차단까지 해제한다. SDK의 viewOffTriger는 contextmenu를 안 풀어서 직접 풀어야 했다.

실제 SDK + 테스트 MID(INIpayTest)로 먹통을 재현하고, 탈출 버튼이 오버레이를 걷어내는 것까지 브라우저에서 확인했다.

⑥ 테스트 결제창만 죽는다 — stgstdpay 도메인

이니시스는 테스트 결제창을 stgstdpay.inicis.com으로 따로 운영한다. CSP에 운영 도메인만 있으면 테스트 환경에서 SDK 로드부터 막혀 INIStdPay가 undefined가 된다. 반면 정적 리소스 CDN은 운영과 공유한다(stgstdux는 NXDOMAIN). 그래서 stgstdpay만 추가하고 stdux.inicis.com은 하나로 유지했다.

덤으로 내 주석이 틀렸다는 것도 알았다. "결제창은 window.open 팝업이라 frame-src 대상이 아니다"라고 써 뒀는데, payViewType을 지정하지 않으면 overlay 모드라 결제창은 iframe이다. 결제창 안에서 뜨는 팝업(약관·개인정보 동의 보기 등)의 팝업 차단 해제 페이지 allowPopupIframe.jsp도 같은 stdpay 도메인에서 iframe으로 돈다. 즉 frame-src에서 stdpay가 빠지면 결제창도, 그 안의 팝업도 같이 죽는다.

3. 카카오페이 — "지원하지 않는 PG입니다"는 거짓말이었다

카카오페이 버튼을 누르면 "지원하지 않는 PG입니다"가 떴다. 분명 지원하는데. 이 하나의 오류 메시지를 걷어내는 데 커밋 다섯 개가 들었다. 매번 진짜 원인을 가리고 있던 껍데기를 한 겹씩 벗긴 과정이다.

  1. 오탐의 정체 — pg 키 누락. KakaoPayAdapter::buildPaymentParams()가 결제 준비(ready()) 실패 시 ['error' => ...]만 반환하고 'pg' 키를 빠뜨렸다. launchPG()는 p.pg로 분기하니 카카오페이 전용 에러 처리로 못 가고 맨 끝의 "지원하지 않는 PG" 폴백으로 떨어졌다. 에러 응답에도 pg를 넣도록 다른 어댑터 관례에 맞췄다.
  2. 뭉뚱그린 문구 — "결제 준비 실패". 오탐을 고치자 이번엔 원인 없는 실패 문구가 나왔다. next_redirect_pc_url 유무만으로 성공/실패를 가르면서 카카오 API가 준 msg를 버리고 있었다. 시크릿 키가 비면 네트워크를 타기 전에 ".env의 KAKAOPAY_SECRET_KEY가 설정되지 않았습니다"를 바로 반환하고, 키가 있는데 실패하면 API의 msg를 그대로 노출하게 했다.
  3. 배포 후에도 같은 문구 — curl 실패와 API 실패를 구분 못 함. request()가 curl_exec 실패(DNS·SSL·타임아웃)와 "msg 없는 실패 응답"을 둘 다 빈 배열로 뭉갰다. curl 실패는 curl_error()를, HTTP 4xx/5xx인데 msg가 없으면 상태코드를, JSON이 아니면 파싱 실패를 각각 msg에 담았다. 덤으로 PHP 8.5에서 deprecated된 curl_close()도 걷어냈다.
  4. 403 → 400 — 진짜 문제는 파라미터였다. 운영 키로는 403(권한부여 미승인), 테스트 CID(TC0ONETIME)로 바꾸니 400이 떴다. 카카오 API는 인증/권한(401/403)을 파라미터 검증(400)보다 먼저 하기 때문에, 403을 넘겨야 비로소 진짜 문제가 보인다. 그런데 400 응답엔 msg 키가 없었다. 결국 msg가 없으면 원본 응답 바디를 최대 300자까지 그대로 붙여 보여주게 했다. 응답 해석부는 interpretResponse(int $status, string $rawBody)로 분리해 네트워크 없이 단위 테스트할 수 있게 만들었다.
  5. 타임아웃. 같은 김에 보니 다른 PG 어댑터 5종 모두 CURLOPT_TIMEOUT 없이 curl을 호출하고 있었다. PG가 응답을 미루면 PHP-FPM 워커가 무기한 묶인다. 프로젝트 규칙(외부 API 타임아웃 필수, 기본 5초)대로 전부 5초를 걸었다.

교훈은 하나다. 에러 메시지는 원인을 가리지 말고 드러내야 한다. "결제 준비 실패" 같은 친절한 문구가 디버깅 시간을 몇 배로 늘렸다. 운영 화면용 문구와 로그·개발용 원인은 분리하되, 원인 자체는 절대 버리지 말 것.

4. 네이버페이 — 공식 가이드보다 브라우저 콘솔을 믿어라

네이버페이는 출발부터 웃겼다. 주문서의 naverpay 분기가 실제 SDK 호출 없이 alert()만 띄우는 미구현 스텁으로 남아 있었다. 네이버페이 Window SDK(Naver.Pay)를 로드해 어댑터 파라미터로 결제창을 여는 것부터 다시 붙였다. 그다음부터는 오류 메시지 세 개를 차례로 넘었다.

증상

원인

조치

"유효하지 않은 가맹점"

Naver.Pay.create()에 mode를 안 넘기면 운영으로 간주 → 샌드박스 clientId 거부

NAVERPAY_MODE 설정 추가, payType 명시

(다음 단계에서 터질 예정)

REST API URL에 파트너 ID 대신 "naverpay-partner" 문자열 하드코딩

NAVERPAY_PARTNER_ID 추가, mode에 따라 dev.apis / apis.naver.com 전환

결제창이 스타일 없이 깨짐

development 모드는 운영과 다른 전용 도메인 사용

style-src·frame-src에 샌드박스 도메인 추가

"일시적 오류가 발생했습니다"

결제창 iframe이 frame-src에 막혀 about:blank로 남음 → postMessage 핸드셰이크 실패

실측 도메인 test-nsp.pay.naver.com 추가

마지막 줄이 이 섹션의 핵심이다. 처음엔 네이버페이 공식 가이드의 "결제 서비스 도메인" 표를 근거로 test-pay.naver.com·test-m.pay.naver.com을 frame-src에 넣었다. 그런데 오류는 그대로였다. 브라우저 콘솔로 실제 iframe 주소를 확인해 보니, 이 흐름에서 열리는 결제창은 가이드에 적힌 도메인이 아니라 SDK와 같은 test-nsp.pay.naver.com이었다.

iframe이 막히면 origin이 null인 about:blank로 남고, SDK의 postMessage 핸드셰이크가 실패한다. 그 결과가 "일시적 오류"라는, 아무 정보도 없는 메시지다. CSP 문제를 이렇게 포장해서 보여주니 한참을 헤맸다.

또 하나 배운 것. 카카오 우편번호처럼 네이버페이도 window.open('')으로 빈 팝업을 띄운 뒤 그 안에 iframe을 꽂는다. 이 빈 팝업은 about:blank(동일 출처)라 부모 페이지의 CSP를 그대로 상속한다. "팝업이니까 frame-src는 상관없겠지"는 틀렸다.

가이드 도메인은 모바일 등 다른 진입 경로에서 쓰일 수 있어 예방적으로 남겨 두었다. 다만 "가이드에 있으니 됐다"가 아니라 "콘솔에서 본 것만 확실하다"로 생각을 바꿨다.

5. 신용카드 결제창 닫힘 — 취소는 실패가 아니다

결제창이 다 열리고 나서 마지막까지 붙잡고 있던 주제다. 사용자가 결제창의 X를 누르는 건 아주 흔한 행동인데, 우리 쇼핑몰은 그걸 장애처럼 다뤘다.

증상 1 — 닫기만 했는데 "오류가 발생했습니다", 그리고 버튼이 잠긴다

결제창을 닫으면 PG SDK가 requestPayment()를 reject한다. 주문서는 이걸 일반 오류로 받아 경고창을 띄웠다. 게다가 결제 버튼 복구 코드가 catch 안에만 있어서, 중간에 return하는 경로(키 설정 오류 안내 등)에선 버튼이 "처리 중..." 상태로 잠긴 채 남았다.

  • 취소 코드(PAY_PROCESS_CANCELED·PAY_PROCESS_ABORTED·USER_CANCEL)는 경고창 없이 주문서로 되돌린다.
  • 그 외 오류는 SDK 메시지를 그대로 보여준다.
  • 버튼 복구를 finally로 옮겨 모든 종료 경로에서 재시도할 수 있게 한다.

증상 2 — 닫으면 "결제에 실패했습니다" 화면

다른 쇼핑몰은 결제창을 닫으면 그냥 주문서로 돌아간다. 우리는 closeUrl이 order/fail을 가리키고 있었다. 문제는 PG마다 "닫았다"는 신호가 제각각이라는 것이다.

PG

닫힘 신호

처리

이니시스

전용 closeUrl 호출

closeUrl을 /order(주문서)로 변경

카카오페이·페이코

전용 취소 URL

어댑터가 만드는 취소 URL을 주문서로

네이버페이

성공·취소 모두 같은 returnUrl, resultCode로만 구분

resultCode === Success && paymentId 존재일 때만 성공, 나머지는 전부 주문서로

토스페이먼츠

실패·취소 모두 failUrl, code로 구분

취소성 code면 주문서, 한도 초과 등 진짜 실패만 실패 화면

나이스페이

fnError 콜백만, 페이지 이동 없음

이미 주문서 위라 수정 불필요

장바구니는 결제 확정(confirmPaid) 전까지 비우지 않으므로 주문서로 돌려보내도 상품이 그대로 남는다. 진짜 실패(카드 승인 거절, 금액 불일치)는 여전히 callback()이 실패 화면으로 안내한다.

네이버페이는 처음에 resultCode === 'Fail' 정확 일치일 때만 취소로 봤는데, 대소문자 차이나 Cancel류 값, resultCode 없음이 전부 실패 화면으로 샜다. 조건을 뒤집어 "성공 조건을 다 만족할 때만 성공"으로 바꿨다. paymentId가 없으면 어차피 승인 요청을 보낼 수 없으니 나머지를 취소로 묶어도 잃는 게 없다.

증상 3 — 열었다 닫을 때마다 주문내역과 포인트 내역이 더러워진다

결제창을 열기 전에 주문 레코드를 만들고 쿠폰·포인트를 선점하다 보니, 닫을 때마다 "결제대기" 주문이 주문내역에 쌓였다. 그래서 결제 전 단계를 order_attempts 테이블로 분리했다. 주문내역(orders)엔 결제 확정 건만 남고, 결제 멱등성과 쿠폰 이중사용 방어는 attempt로 옮겼다. 주문번호 채번은 order_attempts뿐 아니라 orders까지 확인한다. 그러지 않으면 결제 후 전환 시점에 UNIQUE 위반으로 "결제는 됐는데 주문이 없는" 상태가 될 수 있다.

후속으로 따라온 버그들도 있었다.

  • 만료 쿠폰 부활: 결제 실패 시 쿠폰을 무조건 issued로 복원해 이미 만료된 쿠폰이 되살아났다. expires_at을 보고 지났으면 expired로 복원한다.
  • 쿠폰 24시간 잠김: 브라우저 종료·취소 URL 없는 PG의 선점 회수는 만료 배치가 유일한 경로인데, 크론이 하루 1회로 바뀌어 있었다. 5분으로 되돌렸다.
  • 포인트 허수: 결제창을 열었다 닫으면 포인트 선점(use)과 환급(refund)이 한 쌍씩 쌓인다. 잔액은 그대로인데 내역만 두 줄씩 도배된다. 판정 기준을 "실패했는가"가 아니라 **"주문이 남았는가"**로 두고, 주문이 되지 못한 시도의 로그만 숨겼다. 한쪽만 숨기면 근거 없는 환급 한 줄이 남아 잔액과 내역 합계가 어긋나기 때문이다.

6. 정리 — 다음 PG 붙일 때 볼 체크리스트

엿새를 돌아보면 결제 로직 자체에서 난 버그는 거의 없었다. 전부 결제창이 우리 페이지 안의 낯선 손님이라는 사실을 놓친 데서 왔다. 남의 스크립트가 남의 iframe을 띄우고, 그 iframe 안에서 다시 우리 페이지를 부른다. 그 경계마다 보안 설정, 쿠키, CSS가 끼어든다.

다음 PG를 붙일 때 이 순서로 확인한다.

  1. 결제창이 팝업인가, overlay iframe인가? window.open('') 팝업이어도 부모 CSP를 상속한다.
  2. CSP는 script·style·img·connect·frame·form-action을 디렉티브별로 실측해서 연다. 운영·테스트 도메인이 다른지 확인한다(이니시스 stgstdpay, 네이버 test-nsp).
  3. returnUrl·closeUrl이 iframe 안에서 로드되는가? 그렇다면 frame-ancestors와 SameSite 쿠키 문제가 따라온다.
  4. PG가 주입하는 CSS가 내 CSS 프레임워크와 충돌하지 않는가(부트스트랩 3 vs 5).
  5. SDK가 실패했을 때 사용자가 빠져나갈 길이 있는가? 없으면 만들어 준다.
  6. 에러 응답에도 분기 키(pg)가 있는가? 원인 msg·curl_error·원본 바디를 버리지 않는가?
  7. 모든 외부 호출에 타임아웃(5초)이 있는가?
  8. "닫음"과 "진짜 실패"를 PG별 신호로 구분하는가? 버튼 복구는 finally에 있는가?
  9. 결제 전 단계 데이터(주문 시도, 쿠폰·포인트 선점)가 확정 데이터를 오염시키지 않는가?

가이드 문서는 출발점일 뿐이다. 결국 결제창을 실제로 띄우고 콘솔을 보는 것만이 확실했다.