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중)
운영에서 악용되지 않도록 아래를 모두 통과해야 헤더가 토스로 중계됩니다.
- 비운영 전용 —
environment != "prod". 운영에선 헤더를 읽지도 전달하지도 않습니다. - 기능 플래그 —
TEST_ERROR_INJECTION_ENABLED=true(기본false)..env.stg(또는 dev)에서 직접 켜야만 동작합니다(opt-in). 설정은 12. 설치·배포의 환경변수 표 참고. - 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. 샘플 서비스)의 상단 메뉴 에러 테스트에서 눌러보며 확인할 수 있습니다.
- 재현할 에러와 결제 유형(일반결제 단건 / 구독 생성)을 드롭다운에서 고릅니다. 구독 생성은 요금제도 함께 고릅니다.
- 일반결제(단건) — 결제창(prepare → 결제창 → confirm) 흐름을 탑니다. 선택 코드를
oo_testcode_{toss_order_id}키로 세션에 저장했다가 successUrl 콜백(oneoff_success_view)에서 confirm 헤더로 전달합니다. 결제창에서는 정상 테스트 카드로 인증하고, 실패는 승인(confirm) 단계에서 주입됩니다. 상품명은 "에러테스트상품", 금액 5,000원 고정입니다. - 구독 생성 — 저장된 카드로 즉시 청구(charge)라 결제창이 없습니다.
create_subscription에 헤더를 실어 결과를 바로 표시합니다(카드 등록 + 기존 구독 없음이 전제). - 결과는 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 호출(구독 조회·생성, 결제 조회, 요금제 목록 등 전부)이 강제로 에러를 반환합니다. 연동 서비스가 "결제 서버가 아플 때"의 자기 동작(재시도·안내 화면·폴백)을 검증하는 용도입니다.
사용 방법
- 켜기: 어드민 → 서비스 관리 → 해당 서비스 상세 → "에러 테스트 모드 (QA)" 카드에서 체크 후 저장. 켜져 있는 동안 카드에 빨간 "ON" 배지가 표시됩니다.
- 재현할 에러 지정: 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) - 응답 형태: 에러 봉투에
test_mode: true표식이 포함되어 실제 장애와 구분됩니다.
{ "error": { "code": "NOT_FOUND", "message": "[TEST MODE] 에러 테스트 모드가 켜져 있어 강제 발생한 오류입니다(NOT_FOUND). 해제는 어드민 > 서비스 관리에서.", "test_mode": true } }
안전 게이트
- 운영(prod) 환경에서는 토글이 켜져 있어도 무시됩니다 — 실 트래픽 보호 (
app/api/deps.pyresolve_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 배지가 표시됩니다.
2) 샘플에서 상태 확인 — 샘플 서비스의 서비스 화면(/services)에서, 키가 저장된 서비스 카드에 🧪 에러 테스트 모드 ON 배지가 보이면 준비 완료입니다.
3) 서비스 에러테스트 화면에서 재현 — 상단 메뉴 서비스 에러테스트(/pay/error-mode-test) → 호출할 API와 재현할 에러 코드를 고르고 [API 호출해서 에러 확인]. 결과 카드에서 ① 판정 배너(✔ 테스트 모드에서 발생한 에러) ② 호출 요청 JSON(x-test-error-code 헤더 포함, 키·서명 마스킹) ③ 실제 응답 JSON 원문(test_mode: true)을 확인합니다.
4) 일반 화면에서도 확인 — 카드 보관함(/card) 등 일반 화면에 들어가면 조회가 강제 에러가 되면서, 어떤 요청이 어떤 에러를 가져왔는지 상세 패널이 표시됩니다. 연동 서비스가 "결제서버가 아플 때" 화면을 어떻게 처리하는지 이 상태로 검증하면 됩니다.
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여도 예외 없이 원문 반환).