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로 응답합니다.