2026년 10월 28일 수요일
미단재

AI 토스페이먼츠 테스트 키 연동에서 막힌 4곳: CSP, orderId, 키 종류, CI

작성자 웅파 작성일 2026-10-27 23:34:34 조회수 3

요약: AICopia에 토스페이먼츠 결제창을 테스트 키로 붙이면서 CSP 차단, orderId 규격 위반(400), 결제위젯 키 혼용, .env에 의존한 테스트 실패를 차례로 만났다. 모두 증상만으로는 원인이 보이지 않아, 원인 확인 방법과 어댑터에서 미리 막는 방식을 정리한다.

계약 전이라도 테스트 키만 있으면 결제창 호출부터 승인까지 붙여 볼 수 있다. 실제로 AICopia(CodeIgniter 4 쇼핑몰)에서 토스를 다른 PG(이니시스, 나이스페이, 카카오페이 등)와 같은 어댑터 구조로 붙였다. 그 과정에서 막힌 곳은 결제 로직이 아니라 결제창이 뜨기 전 단계였다.

결제창이 뜨기까지 걸린 4개 관문: SDK 로드, 파라미터 검증, 결제창 iframe, 승인

어댑터 구조

PG마다 PGInterface 구현체를 두고, 컨트롤러는 어댑터에서 받은 파라미터로 결제창을 연다.

  • buildPaymentParams(): 결제창에 넘길 값(orderId, amount, successUrl 등)을 만든다.
  • confirm($paymentKey, $expectedAmount): 서버가 계산한 금액으로 승인 API(/v1/payments/confirm)를 호출한다.
  • cancel(): 취소 API 호출.

승인 금액은 쿼리스트링이 아니라 서버가 넘기는 expectedAmount를 쓴다. 외부 API 호출에는 CURLOPT_TIMEOUT 5초를 걸었다(5개 어댑터 공통).

1. SDK가 로드되지 않았다: CSP

결제가 시작조차 되지 않았고 window.TossPayments가 정의되지 않았다. 원인은 CSP였다. 주문서가 불러오는 js.tosspayments.com이 script-src에 없었다. 브라우저로 재현해 보니 차단이 네 군데에서 따로 일어났다.

지시어 허용한 호스트 막히면
script-src js.tosspayments.com SDK 로드 자체가 막힘
connect-src apigw(-sandbox).tosspayments.com 결제창 전 파라미터 조회 XHR 실패, NETWORK_ERROR
frame-src payment-gateway(-sandbox).tosspayments.com 결제창이 iframe이라 default-src 'self'로 폴백되어 차단
connect-src log·event.tosspayments.com 결제는 되지만 SDK가 재시도하며 콘솔에 오류가 쌓임

두 가지를 기억해 둘 만하다.

  • 테스트 키를 쓰면 호스트 이름에 -sandbox가 붙은 도메인으로 간다. 라이브만 허용해 두면 테스트에서 막히고, 반대도 마찬가지라 둘 다 명시했다.
  • 토스 결제창은 팝업이 아니라 iframe이다. frame-src를 따로 지정하지 않으면 막힌다.

서버에서 호출하는 api.tosspayments.com은 PHP curl이라 CSP 대상이 아니므로 추가하지 않았다. 와일드카드 없이 필요한 호스트만 개별로 적고, 디렉티브별 호스트와 와일드카드 금지를 검사하는 단위 테스트를 함께 두었다.

개발자도구를 열면 부트스트랩 소스맵(cdn.jsdelivr.net) fetch도 connect-src에 걸려 콘솔이 CSP 메시지로 뒤덮였다. 기능에는 영향이 없지만 진짜 결제 오류를 가리므로 허용했다.

2. orderId가 규격 위반이라 400

CSP를 풀자 이번에는 결제창이 열리기 전에 400(INVALID_ORDER_ID)이 났다. 토스 SDK는 결제창을 띄우기 전에 apigw로 파라미터를 검증받는데, 어댑터가 orderId에 DB PK(예: 12)를 그대로 넘기고 있었다. 토스 규격은 영문·숫자·-·_, 6자 이상 64자 이하라 초기 주문처럼 PK가 짧으면 걸린다.

다른 PG 어댑터는 이미 주문번호를 쓰고 있어서 토스만 문제였다. 수정은 세 가지였다.

  // 주문번호(ORD-YYYYMMDD-NNNNN, 18자)는 규격을 만족한다. 'orderId' => (string) $order['order_number'],   // 콜백은 주문 시도를 DB PK로 조회하므로 PK는 별도 파라미터로 넘긴다. // 토스가 successUrl에 덧붙이는 orderId와 이름이 겹치지 않게 attempt_id를 쓴다. 'successUrl' => base_url('payment/callback/toss?attempt_id=' . $order['id']), 'failUrl' => base_url('order/fail/' . $order['order_number']),  
  • 승인용 orderId는 결제창에 넘긴 값과 완전히 같아야 한다. 컨트롤러가 어댑터가 만든 값을 그대로 세션에 보관해, 두 값이 어긋날 여지를 없앴다.
  • successUrl과 failUrl은 이니시스·나이스페이와 같은 방식으로 어댑터가 만든다.
  • 토스가 successUrl 뒤에 orderId를 덧붙이므로, 우리 쿼리 파라미터 이름이 order_id처럼 비슷하면 헷갈린다. 처음에는 order_id였다가 PG 콜백을 시도(attempt) 기반으로 바꿀 때 attempt_id로 정리했다.

3. 키 종류: 결제창은 ck/sk, 결제위젯은 gck/gsk

  결제창·브랜드페이(API 개별 연동) → test_ck_ / live_ck_ , test_sk_ / live_sk_ 결제위젯 → test_gck_ / live_gck_, test_gsk_ / live_gsk_  

프로젝트는 결제창 SDK(requestPayment)를 쓰는데 .env의 클라이언트 키에 결제위젯 키가 들어가 있었다. 결과는 결제창 전 검증 단계의 400뿐이고 브라우저 콘솔에는 원인이 남지 않았다.

더 나쁜 경우는 시크릿 키다. gsk를 쓰면 결제창은 정상으로 열리고 결제도 진행되지만 승인(confirm)이 INVALID_API_KEY로 실패한다. 고객은 결제했는데 주문만 미완료로 남는다.

결제창 키와 결제위젯 키의 접두사 차이, 잘못 넣었을 때의 증상

그래서 결제창을 열기 전에 어댑터가 키를 검사하고, 문제가 있으면 파라미터 대신 읽을 수 있는 메시지를 돌려준다.

  private function validateKeys(): ?string { if ($this->clientKey === '') { return '토스페이먼츠 클라이언트 키가 설정되지 않았습니다. (.env 의 TOSS_CLIENT_KEY)'; } // (시크릿 키 빈 값 검사 생략) if ($this->isWidgetKey($this->clientKey) || $this->isWidgetKey($this->secretKey)) { return '결제위젯용(gck/gsk) 키입니다. 결제창 연동에는 test_ck_ / test_sk_ 키가 필요합니다.'; } return null; }   /** 결제위젯 키는 test/live 뒤에 `g`가 붙는다. */ private function isWidgetKey(string $key): bool { return (bool) preg_match('/^(?:test|live)_g(?:ck|sk)_/', $key); }  

화면 쪽은 error가 오면 alert로 그대로 보여 주고 결제창을 열지 않는다. 키 오류로 orderId가 없을 때 세션 저장이 터지지 않도록 isset 가드도 넣었다.

4. 내 .env에서만 통과하는 테스트

위 키 검사를 넣고 나니 금액 테스트 2건이 CI에서만 실패했다(Undefined array key amount). 키가 비면 어댑터가 ['pg', 'error']만 돌려주는데, 테스트가 로컬 .env의 실제 키가 있어야만 통과하는 구조였다.

Factories::injectMock으로 결제창용 형식의 더미 키를 주입해 .env 유무와 무관하게 같은 결과가 나오게 했다. 키를 비운 상태에서 composer test:parallel로 CI 조건을 재현해 확인했다.

  $config = new PG(); $config->tossClientKey = 'test_ck_...'; // 형식만 맞춘 더미 $config->tossSecretKey = 'test_sk_...'; Factories::injectMock('config', 'PG', $config);  

테스트에서 쓰는 키는 형식만 맞춘 더미 값이다. 실제 테스트 키도 .env로만 다루고 저장소에 넣지 않는다.

배운 점

  • 결제창이 뜨기 전 실패는 증상이 400이나 CSP 위반뿐이라 원인을 찾기 어렵다. 어댑터에서 키 종류와 orderId 규격을 미리 검사해 사람이 읽을 메시지로 바꿔 두면 시간이 크게 줄어든다.
  • 테스트 키로 가면 -sandbox 호스트를 쓴다. CSP는 테스트와 라이브 호스트를 모두 허용해야 한다. 결제창은 iframe이라 frame-src도 필요하다.
  • 결제창에 넘긴 orderId와 승인에 쓰는 orderId는 같은 값을 한 곳에서 만들어 재사용한다.
  • 시크릿 키 오류는 결제 후에 드러나 고객 피해로 이어지므로, 열기 전에 막는 것이 가장 싸다.
  • 외부 설정(키)에 따라 결과가 달라지는 테스트는 로컬에서만 통과한다. 주입해서 고정한다.

댓글 0개