🛠️ 개발자 매뉴얼

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.py의 resolve_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_subscription의 test_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로 응답합니다.

21.8 서비스별 에러 테스트 모드 — 모든 API 강제 에러 (요청 qa/서비스별_에러재현)

21.1~21.7의 토스 에러 재현이 "결제 승인 단계의 실패"를 재현한다면, 에러 테스트 모드는 그보다 넓게 — 토글을 켠 서비스의 모든 외부 API 호출(구독 조회·생성, 결제 조회, 요금제 목록 등 전부)이 강제로 에러를 반환합니다. 연동 서비스가 "결제 서버가 아플 때"의 자기 동작(재시도·안내 화면·폴백)을 검증하는 용도입니다.

사용 방법

  1. 켜기: 어드민 → 서비스 관리 → 해당 서비스 상세 → "에러 테스트 모드 (QA)" 카드에서 체크 후 저장. 켜져 있는 동안 카드에 빨간 "ON" 배지가 표시됩니다.
  2. 재현할 에러 지정: API 요청 헤더 x-test-error-code에 에러 코드를 실으면 해당 에러로 응답합니다. - 자체 카탈로그 코드(§13.8과 동일): NOT_FOUND(404), CONFLICT(409), RATE_LIMITED(429), SERVER_DISABLED(503), PAYMENT_UNRESOLVED(503), VALIDATION_ERROR(422), UNAUTHORIZED(401), FORBIDDEN(403), INTERNAL_ERROR(500) 등 - 목록에 없는 코드(토스 오류코드 등, 예: INSUFFICIENT_FUNDS)는 402로 재현 — 실서비스의 402 토스 코드 패스스루 관례와 동일 - 헤더 미지정 시 기본 TEST_MODE_ERROR(400)
  3. 응답 형태: 에러 봉투에 test_mode: true 표식이 포함되어 실제 장애와 구분됩니다.
{ "error": { "code": "NOT_FOUND", "message": "[TEST MODE] 에러 테스트 모드가 켜져 있어 강제 발생한 오류입니다(NOT_FOUND). 해제는 어드민 > 서비스 관리에서.", "test_mode": true } }

안전 게이트

  • 운영(prod) 환경에서는 토글이 켜져 있어도 무시됩니다 — 실 트래픽 보호 (app/api/deps.py resolve_test_mode_error)
  • 인증(HMAC·nonce·레이트리밋)을 전부 통과한 뒤 에러를 발생시키므로, 서명 오류 등 인증 실패 테스트는 실제 인증 동작 그대로 검증됩니다
  • 토글 전환은 감사 로그(service.error_test_toggled)에 남습니다
  • 다른 서비스에는 영향이 없습니다(서비스별 격리)
⚠️

주의: 켜 두는 동안 그 서비스의 실제 기능이 전부 멈춘 것과 같으므로, QA가 끝나면 반드시 꺼 주세요. 켜짐 상태는 서비스 상세 카드의 빨간 배지로 확인할 수 있습니다.

샘플 서비스 검증 화면 — 메뉴 「서비스 에러테스트」 (/pay/error-mode-test)

sample_service 상단 메뉴가 두 QA 화면으로 나뉩니다: 「TOSS 에러테스트」(/pay/error-test — 21.6, 결제 승인 단계 실패 재현)와 「서비스 에러테스트」(/pay/error-mode-test — 이 절의 기능 검증).

「서비스 에러테스트」에서 호출할 조회 API(구독 조회·결제 정보·요금제 목록·결제 내역·카드 조회)와 재현할 에러 코드를 정의된 목록(드롭다운)에서 골라 호출하면(직접 입력 없음 — 코드 의미는 화면의 §13.8 오류 코드 링크로 확인):

  • 호출 요청 정보(JSON — method·URL·헤더, API 키·서명은 마스킹)와 실제 반환된 응답 JSON 원문을 가공 없이 그대로 표시합니다 (연동 개발자가 무엇을 보내면 무엇이 오는지 한 화면에서 확인)
  • 결제서버가 테스트 모드인지 아닌지 확인 메시지를 배너로 판정합니다 — ✔ 초록 "테스트 모드에서 발생한 에러"(error.test_mode: true 확인) / ⚠ 빨강 "표식 없음 — 실제 에러일 수 있음" / ℹ 노랑 "정상 응답 — 테스트 모드 아님(토글 OFF 또는 운영)"

또한 샘플의 서비스 선택 화면(/services)이 서비스별로 테스트 모드 상태를 배지로 표시합니다: 🧪 에러 테스트 모드 ON(빨강) / 정상(초록) / 상태 확인 불가(회색 — 결제서버 미기동·인증 오류). 판정에는 해당 서비스의 자격증명이 필요하므로 키가 저장된 서비스만 표시되며, 가벼운 인증 GET 프로브(3초 타임아웃) + 서비스별 10초 캐시로 동작합니다(shop/views.py _error_mode_state).

따라 하기 — 화면과 함께 보는 검증 절차

1) 결제서버 어드민에서 켜기 — 서비스 관리 → 대상 서비스 상세 → "에러 테스트 모드 (QA)" 카드에서 체크 후 저장. 켜지면 카드에 빨간 ON 배지가 표시됩니다.

어드민 — 에러 테스트 모드 카드(ON)
▲ 어드민 서비스 상세 — 에러 테스트 모드 카드. ON 상태면 "모든 API가 에러를 반환 중" 배지가 붙는다

2) 샘플에서 상태 확인 — 샘플 서비스의 서비스 화면(/services)에서, 키가 저장된 서비스 카드에 🧪 에러 테스트 모드 ON 배지가 보이면 준비 완료입니다.

샘플 — 서비스 카드의 에러 테스트 모드 ON 배지
▲ 샘플 서비스 선택 화면 — 해당 서비스에만 🧪 ON 배지가 표시된다(서비스별 격리)

3) 서비스 에러테스트 화면에서 재현 — 상단 메뉴 서비스 에러테스트(/pay/error-mode-test) → 호출할 API와 재현할 에러 코드를 고르고 [API 호출해서 에러 확인]. 결과 카드에서 ① 판정 배너(✔ 테스트 모드에서 발생한 에러) ② 호출 요청 JSON(x-test-error-code 헤더 포함, 키·서명 마스킹) ③ 실제 응답 JSON 원문(test_mode: true)을 확인합니다.

샘플 — 서비스 에러테스트 화면(NOT_FOUND 재현 결과)
▲ NOT_FOUND(404) 재현 — 초록 판정 배너 + 호출 요청/실제 응답 JSON. 응답의 test_mode: true로 실제 장애와 구분된다

4) 일반 화면에서도 확인 — 카드 보관함(/card) 등 일반 화면에 들어가면 조회가 강제 에러가 되면서, 어떤 요청이 어떤 에러를 가져왔는지 상세 패널이 표시됩니다. 연동 서비스가 "결제서버가 아플 때" 화면을 어떻게 처리하는지 이 상태로 검증하면 됩니다.

샘플 — 카드 보관함의 에러 상세 패널
▲ 카드 보관함 — 요청·HTTP·code·message와 🧪 "에러 테스트 모드로 강제 발생" 배지

5) 검증이 끝나면 반드시 끄기 — 어드민에서 체크 해제 후 저장. 켜 둔 동안 그 서비스의 실제 기능이 전부 에러입니다.

일반 화면의 에러 표시: 샘플의 주요 화면 전체 — 카드 보관함(/card)·요금제(/plans)·내 구독(/my)·결제 내역(/history)·결제 결과(구독 생성/단건 결제) — 에서 결제서버 에러가 나면 어떤 요청이 어떤 에러를 가져왔는지(method·경로·HTTP·code·message)를 공용 상세 패널(shop/_api_error.html)로 표시하고, 취소/재개/수동결제/요금제변경 등 액션 실패 메시지에도 같은 요청 정보가 포함됩니다. 에러 테스트 모드로 강제 발생한 에러면 "🧪 에러 테스트 모드로 강제 발생 — 실제 장애 아님" 배지가 붙어 실제 장애와 구분됩니다(PaymentAPIError가 요청 정보를 함께 전달 — shop/payment_client.py).

구현 참고: sample_service/shop/views.py error_mode_test_view, payment_client.probe_error_mode(4xx여도 예외 없이 원문 반환).