결제 API가 네트워크 재시도 때문에 두 번 청구된 사고
문제 발생
결제 요청이 네트워크 지연으로 응답을 못 받은 클라이언트가 자동으로 같은 요청을 재시도했는데, 사실 첫 번째 요청은 서버에서 정상 처리되어 이미 결제가 완료된 상태였습니다. 결과적으로 같은 결제가 두 번 청구됐습니다.
원인 분석
네트워크는 신뢰할 수 없습니다 — 요청은 성공했는데 응답만 유실되는 경우가 실제로 발생합니다. 클라이언트 입장에서는 "응답을 못 받았다"는 사실만 알 뿐, 서버가 요청을 처리했는지 못했는지 구분할 방법이 없습니다. 그래서 재시도하는 것 자체는 합리적인 대응이지만, 서버가 "같은 요청을 두 번 받으면 두 번 처리"하는 구조라면 이 재시도가 중복 처리로 이어집니다.
POST 요청은 기본적으로 멱등(idempotent)하지 않습니다 — 같은 요청을 여러 번 보내면 매번 새로운 리소스(결제, 주문 등)를 만드는 것이 기본 동작입니다. GET/PUT/DELETE는 설계상 멱등하지만, "결제 생성"처럼 본질적으로 POST여야 하는 작업은 이 보장이 없습니다.
해결 방안
멱등성 키(Idempotency Key) 패턴을 씁니다. 클라이언트가 요청을 보낼 때 그 요청을 식별하는 고유한 키(보통 UUID)를 함께 보내고, 서버는 이 키로 "이미 처리한 요청인지"를 판단합니다.
POST /payments
Idempotency-Key: 5f3b1c2e-...
Content-Type: application/json
{ "amount": 10000, "orderId": "order-123" }서버 쪽 처리 흐름:
- 요청이 오면 먼저 이
Idempotency-Key로 이미 처리된 기록이 있는지 조회합니다. - 기록이 있다면 실제 결제 로직을 다시 실행하지 않고, 저장해둔 이전 응답을 그대로 반환합니다.
- 기록이 없다면 결제를 처리하고, 그 결과를 이 키와 함께 저장한 뒤 응답합니다.
이 조회-후-저장 과정 자체도 동시에 같은 키로 요청이 두 번 들어오면 경쟁 상태가 될 수 있으므로, DB의 유니크 제약(키에 unique index)으로 동시 삽입 중 하나만 성공하도록 강제하는 것이 안전합니다 — 애플리케이션 레벨의 조회만으로는 두 요청이 동시에 통과할 수 있습니다.
클라이언트 쪽에서는 재시도할 때 같은 멱등성 키를 재사용해야 이 메커니즘이 의미가 있습니다 — 재시도마다 새 키를 생성하면 서버 입장에서는 매번 새 요청으로 보입니다.
공식 문서
- Stripe Docs: Idempotent Requests — 실제 결제 API가 이 패턴을 어떻게 구현하는지 참고할 수 있는 대표적인 공개 문서입니다.
- IETF Draft: The Idempotency-Key HTTP Header Field
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.