대기 중인 결제가 중복 처리되던 버그 수정
목차
결제 플랫폼에서 pending 상태의 주문이 새로운 checkout으로 덮어씌워지면서 같은 결제를 두 번 처리하려던 경합 조건을 발견하고 수정했다.
문제: Stale-Pending 상태의 탈취 레이스
결제 흐름은 보통 이렇게 진행된다. 사용자가 주문을 생성하면 checkout이 pending 상태로 저장되고, 외부 결제 시스템(여기서는 Polar)의 웹훅을 기다린다. 그런데 여러 시나리오에서 이 pending이 "썩은" 상태로 남는다. 네트워크 지연으로 웹훅이 늦게 도착하거나, 사용자가 결제를 취소했는데 시스템이 모르거나, 타임아웃이 발생했거나.
이 상황에서 사용자가 다시 checkout을 시도하면 새로운 checkout이 생성된다. 그런데 여기서 문제가 터진다. 예전 pending 상태로 떨어져 있던 이전 checkout의 웹훅이 지연돼서 갑자기 도착하는 바람에, 아직 진행 중인 새로운 주문과 겹쳐 버리는 것. 두 개의 비동기 이벤트가 같은 상태를 두고 다투는 경합 조건(race condition)이 발생한다.
결과는? 같은 상품에 대해 결제 확인이 두 번 처리되거나, 한쪽 결제는 시스템 기록엔 남지만 고객 계정엔 반영되지 않는 orphan payment(고아 결제)가 생긴다.
해결책: Webhook 기반 매칭과 Hold Window
이 문제를 세 가지 방향으로 해결했다.
첫째, checkout_id 기반 웹훅 매칭이다. 이전엔 아마 주문 ID나 사용자 ID로 웹훅을 매칭했을 텐데, 같은 사용자가 여러 번 checkout하면 구분이 어려워진다. checkout_id는 각 결제 시도마다 고유하므로, 웹훅도 정확히 그 결제 시도에만 매핑된다.
둘째, 65분의 hold window다. Polar의 checkout expiry(외부 시스템의 결제 유효 기간)를 조사하니 약 60분이었다. 그래서 우리 시스템에선 65분을 hold 윈도우로 잡았다. 이 시간 동안은 새로운 checkout을 생성하지 않고 기존 pending을 기다린다. 60분 이내에 웹훅이 도착하지 않으면, 외부 시스템에서도 그 결제는 만료된 것이므로 우리가 강제 취소해도 괜찮다.
checkout 생성 (t=0)
↓
pending 상태로 저장
↓
[0~65분: hold window — 새 checkout X, 웹훅 대기]
↓
[65분 경과: 웹훅 안 온 건 만료된 거 → 자동 취소]
또는
[웹훅 도착: 결제 완료 또는 실패]
셋째, orphan payment 로깅과 자동 환불이다. 만약 이 모든 보호막을 뚫고 고아 결제가 생기면, 그걸 명시적으로 로깅해 두고 환불 처리 큐에 넣는다. 대시보드에서 이 로그를 모니터링하면 누락된 결제를 빠르게 찾아낼 수 있다.
파일별 역할
| 파일 | 역할 |
|---|---|
src/app/api/checkout/route.ts |
checkout 생성 로직 — hold window 체크, 새 결제 생성 조건 추가 |
src/app/api/polar/webhook/route.ts |
웹훅 핸들러 — checkout_id 매칭, 고아 결제 감지 |
src/app/api/second/[epoch]/route.ts |
타임아웃 처리 — 65분 경과 후 자동 취소 |
tsconfig.tsbuildinfo |
타입스크립트 캐시 재생성 |
팀 입장에서의 회고
이 버그는 "간헐적으로 드물게 터지는" 성격이라 재현이 어려웠다. 그래서 처음엔 "사용자가 실수한 건 아닐까"라며 방치할 뻔했다. 하지만 로그를 자세히 들여다보니 패턴이 명확했다. 동시성 문제는 '문제가 보일 때까진 터지지 않는다'는 특성 때문에 위험하다. 하나하나는 확률적이고 드물지만, 일일 checkout이 수천 건이면 필연적으로 발생한다.
결제 관련 버그는 우선순위가 높을 수밖에 없다. "어떤 고객이 결제했는데 기록이 없네요"는 비즈니스 신뢰도를 깎는 가장 빠른 방법이기 때문이다. 그래서 이 작업을 다른 기능 개발보다 앞당겨 처리했다.
또 하나 배운 점은 외부 시스템의 동작을 확실히 이해해야 한다는 것. 우리 hold window가 Polar expiry보다 짧으면 의미가 없었다. PG 문서를 읽고 실제 문의까지 하며 정확한 값을 확인하는 과정이 없었으면 이 해결책도 미완성이었을 것이다.
다음
🛒 이 글과 어울리는 추천 상품
*위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.
댓글 0
첫 댓글 달아줘.