🛠️ 개발자 매뉴얼

21. 에러 테스트 (QA 결제 실패 재현)

이 문서는 결제 실패·거절 케이스를 STG/dev에서 직접 재현하는 방법을 설명합니다. 토스 테스트 환경은 성공은 쉽게 재현되지만, 실패는 TossPayments-Test-Code 헤더를 결제 서버가 토스 호출에 붙여야만 재현됩니다. 이 장은 그 헤더를 요청에 실어 보내는 방법(pass-through)과, 샘플 서비스의 에러 테스트 화면 사용법을 다룹니다.

🍎

쉽게 말하면, "카드 거절·한도 초과 같은 실패를 일부러 일으켜서, 내 서비스가 그 실패 응답을 화면·상태·안내로 제대로 처리하는지" 확인하는 기능입니다.

함께 보기: 13. 서비스 API — QA 에러 재현 헤더 · 19. 샘플 서비스 · 15. 구독 기능 · 16. 단건결제 기능


21.1 동작 원리 — 헤더 pass-through

호출자가 결제 API 요청에 재현할 토스 에러코드를 헤더로 실으면, 결제 서버가 그 값을 토스 호출에 그대로 중계합니다. 요청마다 헤더만 바꾸면 다양한 실패를 즉석에서 재현할 수 있습니다.

POST /api/v1/payments/confirm
TossPayments-Test-Code: REJECT_CARD_COMPANY

헤더가 없으면 평소와 동일하게 정상 승인됩니다. 상태를 저장하지 않으므로(요청 단위) 여러 QA가 서로 다른 코드를 동시에 테스트해도 섞이지 않습니다.

21.2 적용 대상 API (confirm + charge)

같은 헤더가 결제창 단건 승인구독 결제(저장 카드 청구, charge) 양쪽에 적용됩니다.

API 결제 유형 주입 지점
POST /api/v1/payments/confirm 단건(결제창) 승인 confirm
POST /api/v1/subscriptions 구독 생성 첫 결제 charge
POST /api/v1/subscriptions/{uid}/pay 구독 수동결제 charge

게이트 함수는 app/api/deps.pyresolve_test_error_code 하나로 세 라우트가 공유합니다.

21.3 안전 게이트 (3중)

운영에서 악용되지 않도록 아래를 모두 통과해야 헤더가 토스로 중계됩니다.

  1. 비운영 전용environment != "prod". 운영에선 헤더를 읽지도 전달하지도 않습니다.
  2. 기능 플래그TEST_ERROR_INJECTION_ENABLED=true(기본 false). .env.stg(또는 dev)에서 직접 켜야만 동작합니다(opt-in). 설정은 12. 설치·배포의 환경변수 표 참고.
  3. test 키 전용 — 해당 서비스의 토스 시크릿이 test_로 시작하는 테스트 키일 때만 헤더를 붙입니다(구현: app/toss/client.py — test 키가 아니면 헤더를 전달하지 않음). 라이브 키는 토스도 헤더를 무시하므로 실질 3중 안전입니다.

또한 헤더 형식(대문자·숫자·언더스코어 1~64자, 전체 일치)만 검증하고 통과시킵니다 — 재현 가능 여부는 토스가 판정(유효하지 않으면 INVALID_TEST_CODE 400)하므로 코드 목록을 서버에 하드코딩하지 않습니다. HMAC 서명은 임의 헤더를 서명 대상에 포함하지 않으므로, 이 헤더를 더해도 서비스 인증은 깨지지 않습니다.

21.4 코드에 따라 서비스가 받는 응답 (중요)

주입한 코드의 HTTP 상태에 따라 서비스가 받는 결과가 다릅니다. 이는 결제 서버의 이중결제 방지 처리(보안 R-1) 때문입니다.

주입 코드(예) 토스 HTTP 서비스가 받는 것 결제 상태 용도
REJECT_CARD_COMPANY, REJECT_CARD_PAYMENT, INVALID_CARD_*, EXCEED_MAX_ONE_DAY_AMOUNT, NOT_FOUND_PAYMENT_SESSION 4xx 확정 실패 (토스 code/message 전달) FAILED ✅ 카드 거절 화면 검증
FAILED_CARD_COMPANY_RESPONSE 500 결과 불명 PAYMENT_UNRESOLVED(503) PENDING 유지 결과불명 대응 시나리오
💬

정리: 깔끔한 "카드 거절 화면"을 검증하려면 4xx 코드를 쓰세요. FAILED_CARD_COMPANY_RESPONSE(500)는 실패가 아니라 "결과 불명(재조회 필요) → 결제 PENDING 유지"로 처리되어, 정산 스윕이 나중에 확정합니다.

21.5 실패 시 데이터 처리

  • 단건(confirm) — 4xx면 결제가 FAILED로 기록됩니다. 결제 내역에 실패로 남습니다.
  • 구독 생성(charge) — 4xx면 구독은 EXPIRED, 결제는 FAILED로 남아 구독 결제 목록에 실패가 노출됩니다. EXPIRED는 '열린 구독' 판정에서 제외되므로 재구독을 막지 않습니다(자세한 전이는 15. 구독 기능 참고). 카드는 보존됩니다.
  • 어느 경우든 500(결과불명)은 실패로 단정하지 않고 PENDING을 유지합니다.

21.6 샘플 서비스 「에러 테스트」 화면 — /pay/error-test

샘플 서비스(19. 샘플 서비스)의 상단 메뉴 에러 테스트에서 눌러보며 확인할 수 있습니다.

localhost:8001/pay/error-test
결제 실패 재현 (QA)
재현할 토스 에러코드와 결제 유형을 고르면 결제서버가 토스 호출에 TossPayments-Test-Code 헤더를 실어 그 실패를 응답합니다.
재현할 에러
한도초과·잔액부족 — REJECT_CARD_PAYMENT (402/4xx)
결제 유형
일반결제 (단건 · 결제창)
에러 재현 결제 진행
일반 결제로
▲ 에러 테스트 화면 — 에러코드·결제 유형 선택 후 진행. 화면 하단에는 「🔍 개발자 노트」·「🧩 바로 적용 가이드」 패널이 있다.
  1. 재현할 에러결제 유형(일반결제 단건 / 구독 생성)을 드롭다운에서 고릅니다. 구독 생성은 요금제도 함께 고릅니다.
  2. 일반결제(단건) — 결제창(prepare → 결제창 → confirm) 흐름을 탑니다. 선택 코드를 oo_testcode_{toss_order_id} 키로 세션에 저장했다가 successUrl 콜백(oneoff_success_view)에서 confirm 헤더로 전달합니다. 결제창에서는 정상 테스트 카드로 인증하고, 실패는 승인(confirm) 단계에서 주입됩니다. 상품명은 "에러테스트상품", 금액 5,000원 고정입니다.
  3. 구독 생성 — 저장된 카드로 즉시 청구(charge)라 결제창이 없습니다. create_subscription에 헤더를 실어 결과를 바로 표시합니다(카드 등록 + 기존 구독 없음이 전제).
  4. 결과는 16. 단건결제·15. 구독의 실패 처리 흐름을 그대로 따릅니다. 단건 FAILED·구독 FAILED는 결제 내역(/history) 에 상태 배지로 표시됩니다. 단건 에러 테스트는 실패로 끝나도 상품명이 결제 내역에 "에러테스트상품"으로 보이도록, 샘플이 prepare 시점에 로컬 기록(OneOffRecord)을 미리 남깁니다.

구현 위치: sample_service/shop/views.py(oneoff_error_test_view), sample_service/shop/payment_client.py(confirm_one_off_payment_window·create_subscriptiontest_error_code).

21.7 드롭다운에 넣은 대표 에러코드

코드 의미 결과
REJECT_CARD_COMPANY 승인 거절(카드사) 확정 실패(4xx)
REJECT_CARD_PAYMENT 한도초과·잔액부족 확정 실패(4xx)
INVALID_CARD_LOST_OR_STOLEN 분실·도난 카드 확정 실패(4xx)
INVALID_STOPPED_CARD 정지된 카드 확정 실패(4xx)
INVALID_CARD_EXPIRATION 유효기간 오류 확정 실패(4xx)
INVALID_CARD_PASSWORD 비밀번호 오류 확정 실패(4xx)
EXCEED_MAX_ONE_DAY_AMOUNT 일일 한도 초과 확정 실패(4xx)
NOT_FOUND_PAYMENT_SESSION 결제 세션 만료 확정 실패(4xx)
FAILED_CARD_COMPANY_RESPONSE 카드사 시스템 오류 503 결과불명·PENDING

전체 코드 목록은 토스 에러코드 문서를 참고하세요 — 형식(대문자·숫자·언더스코어)만 맞으면 그대로 중계되고, 미지원 코드는 토스가 INVALID_TEST_CODE로 응답합니다.