결제 승인 실패·재시도 가드레일 체크리스트 2026: decline·3DS·timeout 구분
결제 승인 실패·재시도 가드레일 체크리스트 2026: decline·3DS·timeout 구분
checkout에서 결제 버튼을 누른 뒤 실패 화면이 보였다고 해서 모든 거래가 같은 상태인 것은 아닙니다. 발급사가 최종 거절한 거래, 고객의 3D Secure 인증을 기다리는 거래, 통신이 끊겨 결과를 모르는 거래, 공급사가 일시 오류로 분류한 거래는 다음 행동이 서로 다릅니다.
이 구분 없이 payment_failed 하나로 합치면 두 가지 문제가 생깁니다. 실제로는 승인된 거래를 다시 제출해 중복 결제가 생길 수 있고, 재시도하면 안 되는 hard decline을 다른 경로로 반복 제출할 수 있습니다. 반대로 고객 인증만 기다리면 되는 거래를 시스템 오류로 처리해 정상 구매를 막을 수도 있습니다.
이 글의 산출물은 재시도 횟수를 늘리는 요령이 아닙니다. 원문 응답 보존 → 내부 상태 정규화 → 고객 조치 또는 최종 상태 조회 → 정책이 허용한 제한적 후속 시도 → ledger 기록 → stop으로 이어지는 단건 checkout 운영 기준입니다.
기능과 문서는 2026년 8월 1일 확인 기준입니다. 실제 응답 코드와 허용되는 후속 동작은 사용 중인 processor, acquirer, 결제수단, 국가, 계정 설정과 계약에 따라 달라질 수 있습니다. 이 글은 일반적인 결제 운영 체크리스트이며 법률·세무·회계·PCI DSS 준수 조언이 아닙니다. 특정 구현이 승인율 상승, 비용 절감, 중복 결제 방지 또는 규정 준수를 보장하지 않습니다.
먼저 범위를 분리합니다
결제 운영 글은 서로 연결되지만 이번 체크리스트가 답하는 질문은 좁습니다.
| 별도 문서 | 중심 질문 | 이번 글에서 제외하는 범위 |
|---|---|---|
| 결제 라우팅 A/B 테스트 체크리스트 | 정상 거래군을 두 경로에 배정해 성과를 어떻게 비교할 것인가 | A/B 배정, 승인율 비교, 라우팅 승자 결정 |
| B2B SaaS 결제 공급사 장애 대응 체크리스트 | provider outage와 webhook 지연에서 어떻게 복구할 것인가 | 공급사 장애 복구, 대량 backlog, 사고 지휘 |
| 결제 공급사 failover·multi-gateway 준비 체크리스트 | 대체 gateway로 전환할 기반이 준비됐는가 | multi-gateway 전환 설계, token portability, routing |
| B2B SaaS 결제 실패·dunning 자동화 체크리스트 | 구독 invoice 실패 뒤 CRM·알림·회수 흐름을 어떻게 연결할 것인가 | subscription 갱신, dunning 캠페인, CRM·고객 알림 |
| 이 글 | 단건 checkout 한 건의 승인 시도가 지금 어떤 상태이며 다음 안전한 동작은 무엇인가 | 라우팅 성과, 구독 회수, 공급사 장애 복구 |
따라서 본문에서 말하는 retry는 매출을 높이기 위한 반복 제출이 아닙니다. 같은 주문의 현재 결과를 확인하고, 공급사의 공식 상태와 내부 정책이 허용하는 경우에만 한 명의 owner가 통제하는 후속 시도를 뜻합니다.
먼저 결론: 실패 화면보다 payment의 최종 상태를 봅니다
운영 흐름은 다음 순서로 고정합니다.
[checkout 요청과 내부 order/payment ID 생성]
↓
[processor 원문 응답·HTTP 상태·request ID 보존]
↓
[normalized family와 next_action 결정]
┌────────────┼────────────┬────────────┐
↓ ↓ ↓ ↓
terminal customer_action unknown transient
↓ ↓ ↓ ↓
종료 인증·결제수단 안내 최종상태 조회 정책 허용 확인
└────────────┴────────────┴────────────┘
↓
[단일 owner가 evidence ledger 갱신]
↓
[완료, 제한 재시도 또는 stop]
화면에 실패를 표시했는지보다 중요한 것은 다음 세 질문입니다.
- processor가 거래를 최종 승인·거절했는가, 아니면 결과가 아직 미확정인가?
- 다음 행동의 주체가 고객인가, 서버인가, 운영 담당자인가?
- 새 요청을 보내기 전에 기존 payment의 최종 상태를 조회했는가?
이 세 질문에 답할 수 없으면 자동 재시도를 시작하지 않습니다.
1. 원문 code를 덮어쓰지 않고 정규화 계층을 추가합니다
서로 다른 processor가 같은 이름과 의미의 상태를 돌려준다고 가정하면 안 됩니다. Stripe code를 공통 표준처럼 다른 공급사 응답에 덮어씌우는 방식도 피합니다.
Stripe decline code 문서는 Stripe 거래에서 decline 의미와 후속 조치를 확인할 공식 자료입니다. Adyen refusal reasons 문서와 Adyen raw acquirer responses 문서는 Adyen의 표준 사유와 원시 acquirer 응답을 구분할 때 확인할 자료입니다.
내부 event에는 최소한 다음 필드를 둡니다.
| 필드 | 기록 목적 |
|---|---|
order_id |
고객 주문과 모든 결제 시도를 묶는 내부 기준 |
payment_id |
현재 payment 객체의 내부 식별자 |
attempt_id |
한 번의 제출과 응답을 구분 |
provider |
Stripe, Adyen 등 응답 주체 |
provider_payment_id |
공급사 대시보드·API 조회에 쓰는 ID |
raw_status |
공급사가 반환한 상태 원문 |
raw_code |
decline, refusal, error code 원문 |
raw_message_safe |
민감정보를 제거한 운영용 원문 요약 |
http_status |
API 전송 계층의 상태 |
provider_request_id |
공급사 지원 문의와 추적에 쓰는 request ID |
normalized_family |
내부 공통 분기인 terminal, customer_action, unknown, transient |
mapping_version |
어떤 변환표로 정규화했는지 재현 |
next_action |
조회, 고객 안내, 제한 재시도, 수동 검토, 종료 |
observed_at |
응답을 관찰한 시각 |
원문과 정규화 결과를 함께 저장해야 mapping 규칙이 바뀌어도 과거 사건을 다시 해석할 수 있습니다. raw_code를 내부 문구로 바꾼 뒤 원문을 버리면 공급사 문서와 대조하기 어렵습니다.
2. 상태 family는 네 갈래로만 시작합니다
처음부터 수십 개 상태를 만들기보다 다음 네 family로 분기하고, 세부 사유는 별도 필드로 둡니다.
| normalized family | 의미 | 기본 next action | 금지할 동작 |
|---|---|---|---|
terminal |
최종 승인, 최종 취소 또는 비재시도 거절처럼 현재 시도가 끝남 | 주문 완료, 결제수단 변경 안내 또는 종료 | hard decline을 다른 processor로 우회 제출 |
customer_action |
고객 인증이나 결제수단 확인이 필요 | 공식 인증 화면·안전한 checkout으로 연결 | 백그라운드에서 고객 대신 인증 완료 처리 |
unknown |
timeout, 연결 종료 등으로 승인 여부를 확정할 수 없음 | 기존 payment의 최종 상태 조회 | 조회 전에 새 payment 생성 또는 동시 제출 |
transient |
공급사 공식 지침과 내부 정책상 일시 오류 후보 | 제한 재시도 적격성 평가 | 고정 횟수·간격으로 무조건 반복 |
terminal은 성공만 뜻하지 않습니다. 현재 시도에 대해 더 제출하면 안 되는 최종 거절도 포함합니다. 반대로 HTTP 오류가 있었다는 이유만으로 transient로 두면 안 됩니다. 서버가 응답을 받지 못했어도 processor에서는 승인됐을 수 있으므로 먼저 unknown으로 취급하는 편이 안전합니다.
3. hard decline은 라우팅 우회 신호가 아닙니다
hard decline 또는 비재시도 거절로 분류된 거래는 같은 자격 증명으로 다시 제출하지 않습니다. 승인율을 높이기 위해 다른 processor에 즉시 넘기는 것도 이번 체크리스트의 허용 동작이 아닙니다.
| 확인 항목 | 운영 질문 |
|---|---|
| code 출처 | processor가 공식적으로 제공한 원문 code인가 |
| mapping 버전 | 현재 merchant account와 결제수단에 맞는 변환표인가 |
| 고객 조치 | 새 결제수단 또는 발급사 확인이 필요한가 |
| 종료 상태 | checkout 화면과 order 상태가 같은 결론을 보여주는가 |
| 기록 | 누가 어떤 근거로 retry 불가를 결정했는가 |
고객에게는 내부 fraud 점수나 상세 위험 판단을 노출하지 않고, 실제로 취할 수 있는 다음 행동만 안내합니다. 예를 들어 다른 결제수단을 선택하거나 카드 발급사에 확인하도록 안내할 수 있지만, 거절을 우회하는 구체적인 제출 방법을 제시하지 않습니다.
4. 3D Secure는 실패가 아니라 customer action일 수 있습니다
3D Secure 흐름은 인증 필요, 인증 진행, 완료, 실패, 취소를 나눠야 합니다. Stripe 3D Secure 문서와 Adyen 3D Secure 문서에서 각 공급사의 현재 흐름을 확인할 수 있습니다.
| 단계 | 저장할 값 | 화면·서버의 역할 |
|---|---|---|
| action 필요 | payment ID, action type, 만료 기준 | 공식 인증 화면으로 고객을 연결 |
| action 진행 | checkout session, return URL correlation | 중복 버튼과 새 payment 생성을 막음 |
| return 수신 | return 결과, 관찰 시각 | 브라우저 값만 믿지 않고 서버 상태 조회 |
| 최종 성공 | provider payment status, amount, currency | order 완료를 한 번만 처리 |
| 최종 실패·취소 | 원문 code, customer action 결과 | 안전한 재진입 또는 결제수단 변경 안내 |
고객이 인증 창을 닫았다고 곧바로 hard decline으로 바꾸지 않습니다. 반대로 return URL에 도착했다는 사실만으로 결제 성공을 선언하지도 않습니다. 서버에서 동일한 provider payment ID의 최종 상태를 확인한 뒤 주문 상태를 바꿉니다.
5. timeout은 새 결제 요청보다 최종 상태 조회가 먼저입니다
timeout과 연결 종료는 실패 확인이 아니라 결과 미확정일 수 있습니다. 요청이 processor에 도착한 뒤 응답만 유실됐을 가능성이 있기 때문입니다.
Stripe 오류 처리 문서는 오류 유형과 처리 방향을 확인할 공식 출처이고, Stripe PaymentIntent lifecycle 문서는 하나의 payment가 여러 상태를 거칠 수 있음을 보여줍니다.
unknown 상태에서는 아래 순서를 지킵니다.
order_id,payment_id,attempt_id, provider request ID를 잠급니다.- 동일 주문에 새 payment를 만들거나 다른 processor 요청을 동시에 보내지 않습니다.
- 기존 provider payment ID 또는 안전한 조회 키로 현재 상태를 조회합니다.
- webhook을 사용한다면 같은 payment ID의 도착·처리 여부를 함께 확인합니다.
- 조회 결과가 성공이면 주문 완료를 멱등하게 처리합니다.
- 최종 거절이면 terminal 또는 customer action으로 이동합니다.
- 계속 미확정이면 새 제출 대신 수동 검토·고객 대기 안내·stop rule로 넘깁니다.
조회 자체가 실패한다고 해서 결제가 실패한 것은 아닙니다. last_lookup_error, last_lookup_at, lookup_owner를 ledger에 남겨 다음 worker가 같은 주문을 다시 제출하지 않도록 합니다.
6. idempotency key는 중복 결제 면허가 아닙니다
Stripe idempotent requests 문서는 같은 작업의 안전한 재전송을 설계할 때 확인할 공식 자료입니다. 그러나 idempotency key를 썼다는 이유만으로 어떤 요청이든 반복해도 된다는 뜻은 아닙니다.
안전한 key 설계에는 다음 조건이 필요합니다.
| 조건 | 체크할 내용 |
|---|---|
| 작업 범위 | key가 어떤 endpoint와 논리 작업을 대표하는가 |
| payload 일치 | 같은 key에 amount, currency, customer가 달라지지 않는가 |
| 보존 기간 | 공급사가 key 결과를 얼마나 보존하는지 현재 문서로 확인했는가 |
| 내부 uniqueness | 주문·payment·attempt 관계가 중복 생성되지 않는가 |
| 후속 처리 | 같은 성공 응답을 다시 받아도 fulfillment가 한 번만 실행되는가 |
| provider 경계 | 한 공급사의 key를 다른 공급사의 중복 방지 근거로 오해하지 않는가 |
다른 processor에 같은 문자열 key를 보냈다고 두 공급사 사이의 중복 제출이 막히지는 않습니다. multi-processor 중복 방지는 내부 order lock, 단일 owner, 기존 payment 조회와 ledger로 통제해야 합니다.
7. retry 적격성은 횟수보다 근거로 판단합니다
이 글에서는 “몇 초 뒤 몇 회” 같은 고정값을 제안하지 않습니다. 적절한 횟수와 간격은 processor의 현재 공식 지침, 결제수단 규칙, merchant 설정, 위험 정책과 고객 경험 기준에 따라 달라지기 때문입니다.
retry 후보가 되려면 다음 조건이 모두 확인돼야 합니다.
- 기존 payment가 성공 또는 처리 중이 아님을 조회로 확인했다.
- hard decline, 고객 인증 필요, 결제수단 변경 필요 상태가 아니다.
- processor의 현재 공식 문서나 계정 설정에서 재시도 가능한 상황이다.
- 같은 주문에 active attempt가 하나뿐이다.
- amount, currency, customer, order와 멱등 범위가 변하지 않았다.
- retry owner와 stop 조건이 ledger에 기록됐다.
- 고객이 결제 버튼을 다시 누르는 흐름과 서버 retry가 충돌하지 않는다.
한 항목이라도 확인할 수 없으면 retry_allowed=false로 두고 조회 또는 수동 검토로 보냅니다. 재시도 가능성을 true로 추정하는 것보다 불명확한 이유를 기록하는 편이 안전합니다.
8. evidence ledger가 단일 owner를 보장해야 합니다
서버 worker, checkout 화면, webhook consumer, 고객지원 도구가 각자 retry를 시작하면 동시 제출을 막기 어렵습니다. 결제 한 건에는 하나의 ledger와 하나의 retry owner만 둡니다.
payment_attempt_ledger
order_id
payment_id
attempt_id
provider / provider_payment_id
raw_status / raw_code / provider_request_id
normalized_family / mapping_version
action_owner / action_started_at / action_lease_until
lookup_status / last_lookup_at
retry_eligible / retry_basis
customer_action_type / customer_action_status
final_status / finalized_at
stop_reason
action_lease_until은 특정 고정 시간으로 추천하는 값이 아니라 동시 worker를 막기 위한 소유권 필드입니다. 실제 만료·갱신 기준은 처리 시스템의 장애 복구 방식과 processor 조회 가능성을 반영해 정합니다.
ledger의 append event에는 다음 질문에 답할 증거가 있어야 합니다.
- 어떤 원문 응답을 어떤 mapping 버전으로 분류했는가?
- 새 요청 전에 기존 상태를 언제, 어떤 ID로 조회했는가?
- 고객에게 어떤 action을 표시했는가?
- retry를 허용하거나 중단한 공식·내부 근거는 무엇인가?
- 최종 주문 완료를 누가 한 번만 실행했는가?
9. stop rule은 성공뿐 아니라 불확실성도 종료합니다
자동화는 계속 시도하는 장치가 아니라 멈출 시점을 명확히 하는 장치입니다.
| stop reason | 의미 | 다음 운영 |
|---|---|---|
final_success |
승인·완료 상태 확인 | 주문 완료, 추가 제출 금지 |
final_decline |
비재시도 거절 확인 | 결제수단 변경 등 고객 action 안내 |
customer_action_open |
인증·고객 조치 대기 | 백그라운드 retry 금지 |
state_unknown |
조회 후에도 결과 미확정 | 수동 검토 또는 안전한 대기 안내 |
policy_not_eligible |
공식·내부 정책상 retry 불가 | 종료 및 근거 기록 |
owner_conflict |
다른 worker·화면이 처리 중 | 신규 제출 금지, lock 조정 |
payload_changed |
amount·currency·customer 등이 달라짐 | 기존 시도와 분리해 새 checkout 검토 |
manual_hold |
지원·risk·운영 검토 필요 | 사람의 승인 전 자동화 중지 |
stop 이후에는 버튼 재클릭, webhook 재전송, queue 재처리가 새 결제 제출로 이어지지 않아야 합니다. 성공한 주문의 fulfillment, 이메일, 재고 처리도 동일한 order completion key로 중복 실행을 막습니다.
10. customer action 문구는 상태와 일치시킵니다
고객 화면에는 processor 원문을 그대로 노출하거나 모든 문제를 “카드 오류”로 표시하지 않습니다. 내부 상태와 고객이 실제로 할 수 있는 행동을 연결합니다.
| 내부 상태 | 고객 안내 목적 | 피할 문구·동작 |
|---|---|---|
| 인증 필요 | 공식 인증 절차 계속하기 | 결제가 완전히 거절됐다고 단정 |
| 결제수단 확인 필요 | 다른 결제수단 선택 또는 발급사 확인 | 내부 fraud 판단 상세 노출 |
| 결과 확인 중 | 새 제출을 막고 현재 거래 확인 | 반복 클릭 유도 |
| 최종 거절 | 다른 결제수단 선택 또는 발급사 확인 | 자동 교차 processor 우회 |
| 일시 오류 후보 | 잠시 뒤 안전한 재진입 가능 여부 안내 | 구체 성공 가능성 보장 |
고객이 문의했을 때 지원팀도 같은 order_id와 customer-safe 상태를 볼 수 있어야 합니다. 다만 로그에 카드번호, CVC, 인증 데이터 같은 민감정보를 저장하거나 지원 화면에 노출해서는 안 됩니다.
11. 구현 전 테스트는 정상 성공보다 경계 상태를 먼저 만듭니다
테스트 환경과 공급사 공식 테스트 수단을 사용해 다음 시나리오를 확인합니다.
| 테스트 | 통과 기준 |
|---|---|
| hard decline | retry 불가로 종료되고 고객 action만 제시됨 |
| 3DS action 필요 | 동일 payment를 유지하고 인증 완료 뒤 서버 상태를 조회함 |
| 고객 인증 취소 | 최종 상태 확인 전 성공·hard decline으로 단정하지 않음 |
| request timeout | 새 payment 생성 없이 기존 payment 조회로 이동함 |
| 늦은 성공 응답 | 주문 완료와 fulfillment가 한 번만 실행됨 |
| duplicate webhook | 같은 event·payment가 ledger를 중복 전이시키지 않음 |
| 버튼 연속 클릭 | 같은 주문의 active attempt가 하나만 유지됨 |
| worker 동시 실행 | action owner lock을 얻은 한 worker만 후속 동작함 |
| mapping 변경 | 원문 code와 mapping version으로 과거 분류를 재현할 수 있음 |
| 수동 hold | 자동 queue가 stop 상태를 존중함 |
장애 전체를 재현하거나 routing 성능을 비교하는 테스트는 이번 글의 범위가 아닙니다. provider outage 대응과 multi-gateway 전환은 앞의 별도 문서에서 다룹니다.
운영 대시보드는 승인율보다 미확정·중복 위험을 먼저 보여줍니다
이 체크리스트의 목적은 승인율을 높였다고 주장하는 것이 아닙니다. 다음 운영 위험이 보이는지 확인하는 것입니다.
| 지표 | 보는 이유 |
|---|---|
unknown_open_count |
결과를 모른 채 방치된 payment 수 |
unknown_age_bucket |
최종 상태 조회가 지연되는 구간 |
owner_conflict_count |
동시 worker·고객 재진입 충돌 |
duplicate_submit_blocked_count |
가드가 막은 중복 제출 후보 |
customer_action_open_count |
인증·결제수단 조치 대기 |
hard_decline_retry_blocked_count |
비재시도 거절 우회 차단 |
mapping_unknown_code_count |
아직 정규화 규칙이 없는 원문 code |
manual_hold_count |
사람이 확인해야 하는 거래 |
final_state_without_evidence_count |
원문·조회 근거 없이 종료된 거래 |
수치 변화만으로 효과를 보장하지 않습니다. processor 정책 변경, 고객군, 결제수단, 지역, checkout 변경과 공급사 장애 여부를 함께 확인해야 합니다.
운영 적용 전 마지막 체크리스트
- [ ] 이 기준은 단건 checkout 승인 실패에만 적용하고, A/B 실험·subscription dunning·provider outage는 별도 절차로 분리한다.
- [ ] processor의 raw status, raw code, request ID와 내부 normalized family를 함께 저장한다.
- [ ] terminal, customer action, unknown, transient를 구분한다.
- [ ] hard decline을 다른 processor로 자동 우회하지 않는다.
- [ ] 3DS return만 보고 성공을 선언하지 않고 서버에서 기존 payment 상태를 조회한다.
- [ ] timeout 뒤 새 payment를 만들기 전에 기존 payment의 최종 상태를 조회한다.
- [ ] 서로 다른 processor에 동시 제출하지 않는다.
- [ ] idempotency key와 내부 order lock·단일 owner를 함께 사용한다.
- [ ] 임의의 retry 횟수·간격을 코드에 일반 규칙처럼 고정하지 않는다.
- [ ] retry 근거, owner, 조회 기록과 stop reason이 evidence ledger에 남는다.
- [ ] 성공·거절·고객 조치·미확정·정책 불가 상태에서 자동화를 멈춘다.
- [ ] 승인율 상승, 비용 절감, 중복 결제 방지 또는 규정 준수를 보장하지 않는다.
공식 출처
- Stripe – Declines
- Stripe – Error handling
- Stripe – 3D Secure
- Stripe – PaymentIntent lifecycle
- Stripe – Idempotent requests
- Adyen – Refusal reasons
- Adyen – Raw acquirer responses
- Adyen – 3D Secure
결제 승인 실패를 안전하게 처리하려면 실패 문구보다 현재 payment의 증거를 먼저 봐야 합니다. 원문 code를 보존하고, 고객 조치와 결과 미확정을 분리하며, 기존 상태 조회와 단일 owner ledger를 거친 뒤에만 제한된 후속 시도를 허용해야 합니다. 가장 중요한 가드레일은 더 많이 재시도하는 규칙이 아니라 모르는 거래를 다시 제출하지 않고, 끝난 거래에서 멈추는 규칙입니다.