결제 해지예약 오결제 — 근본 수정 리포트
cancel_at_period_end(해지 예약) 구독이 워커 복구·재시도 경로에서 잘못 청구되던 버그의 근본 원인 분석 · 2단계 수정 · 전수 검증
요약
오결제 금액
₩198,000
필터 누락 청구 경로
5개
실제 피해 고객
1명
머지된 PR
4건
결론: 모든 청구 경로가 거치는 단일 함수에 가드를 추가해 오결제를 전수 차단하고(1단계), 해지 자동취소 로직의 상태 커버리지를 비종료 상태 전체로 확대해 stranded 갭까지 제거(2단계). beta 전수 감사 결과 추가 피해자·잔존 이상 0건.
1. 무슨 일이 있었나
고객 BUSEOT은 5/21에 정상적으로 해지를 예약(cancel_at_period_end=true + 사유 설문)했으나, 6/16에 ₩198,000이 재청구됐다(미환불). 6/14 기간 종료 시 취소됐어야 할 구독이 정기갱신처럼 결제됐다.
| 시각 | 이벤트 |
|---|---|
| 5/21 09:51 | 해지 예약 (cancel_at_period_end=true) |
| 6/14 | 결제 기간 종료 → 취소 처리 대상이 됐어야 함 |
| 6/16 00:00 | 정기결제 ₩198,000 청구 (recurring) — 버그 발현 |
| 현재 | active 유지 · 기간 7/14 · 미환불 |
2. 근본 원인
구독을 청구·갱신 대상으로 잡는 워커 경로 중 정상 결제·해지 실행 2개만 cancel_at_period_end를 존중했고, 나중에 추가된 복구/재시도 경로 5개가 이 플래그를 무시했다. 게다가 워커 실행 순서상 해지 실행(마지막)보다 청구가 먼저 일어나 race에서 이겼다.
| 워커 경로 | cancel 필터 | 청구? |
|---|---|---|
| processExpiredTrials (트라이얼 만료) | 없음 | 첫 결제 |
| processExpiredGracePeriods (grace 만료) | 없음 | 청구 |
| processStrandedActiveSubscriptions (잔류 복구) ★ | 없음 | BUSEOT 발현 |
| processPendingRetries (Dunning 재시도) | 없음 | 재청구 |
| 워커 수동 트리거 | 없음 | 강제 청구 |
| getSubscriptionsDueForPayment (정상 갱신) | 있음 | 정상 |
| processScheduledCancellations (해지 실행) | 있음 | 취소 |
설계 아이러니: "기간 끝났는데 처리 누락된 구독을 살리는 안전망"이, 정상 쿼리가 의도적으로 건너뛴 해지예약 구독을 "방치됨"으로 오판해 되살려 청구했다.
3. 수정 — 2단계 심층 방어
1단계: 단일 가드 #8833 alpha#8834 beta
5개 청구 경로가 모두 거치는 단일 choke point processSubscriptionPayment() 진입부에 가드 추가:
if (subscription.cancelAtPeriodEnd) {
logger.warn(... "scheduled for cancellation")
throw new PaymentProcessingError(..., "SUBSCRIPTION_SCHEDULED_FOR_CANCELLATION", false)
}
- 5개 경로 + 미래 신규 호출까지 자동 차단 (SSOT)
non-retryable→ Dunning 무한 재시도 방지- 정상 갱신 경로는 SELECT에서 이미 제외 → 회귀 없음
2단계: 해지 자동취소 상태 커버리지 확대 #8835 alpha#8836 beta
가드가 청구는 막지만, 해지예약 구독이 unpaid(결제 실패)나 trialing에 빠지면 자동취소에서 누락되는 잔존 갭이 있었다. processScheduledCancellations의 상태 필터를 변경:
- or(eq(status,"active"), eq(status,"past_due"))
+ notInArray(status, ["canceled","incomplete_expired","expired"])
- 종료 상태만 제외 → trialing·active·incomplete·past_due·unpaid·paused 전부 커버
- 향후 추가될 비종료 상태도 자동 포함 (방어적·SSOT)
- 해지예약 + 기간종료 구독은 결제 상태 무관하게 취소 보장
4. 전수 검증 (beta = 프로덕션)
| 점검 항목 | 결과 |
|---|---|
| cancel 예약 후 renewed(오결제 패턴) | BUSEOT 1건뿐 — 추가 피해자 0 |
| 기간종료 미해소(stranded) 구독 | 0건 |
| 워크스페이스당 중복 primary 구독 | 0건 |
| 해지(canceled) 이후 발생한 결제 | 0건 |
| pending payment_retries 적체 | 0건 |
| canceled인데 cancel 플래그 잔존 | 0건 |
모든 시나리오(해지예약 / 결제실패·Dunning / 카드없음 / trial / past_due / unpaid)에서 오결제 차단 확인. Dunning은 non-retryable로 무한루프 없음. 정상 결제·실패·카드없음 흐름 영향 없음. 백엔드 규칙(logger·AppError·SSOT) 준수.
5. 남은 운영 조치 (코드와 별개)
가드는 미래 청구만 막는다. BUSEOT 잔여 처리 필요:
- 6/16 ₩198,000 환불 (실제 송금 — 승인 후 실행)
- 즉시 해지 (status → canceled)
- billing_key 비활성화로 추가 청구 차단