leo.dev
backend

구독 결제에서 믿을 수 없는 것들

PortOne v2 빌링키로 SWING의 구독 결제를 처음부터 붙였다. 자동 결제, 구독 상태 관리, 실패 처리, 복구까지. 만들면서 분명해진 건, 결제는 “성공 경로”를 짜는 일이 아니라 믿을 수 없는 것들을 가정하고 설계하는 일이라는 거였다.

Webhook은 두 번 온다

PortOne webhook은 재시도된다. 같은 결제 실패 이벤트가 두 번, 세 번 올 수 있다. 그대로 처리하면 결제 주기 이력 테이블에 FAILED 행이 중복으로 쌓인다.

그래서 결제 ID(portone_payment_id)에 UNIQUE를 걸고, 실패 핸들러는 그 ID 기준으로 멱등성을 체크한다. 이미 처리한 이벤트면 무시한다.

handleSubscriptionPaymentFailed (트랜잭션, portone_payment_id 멱등성 체크):
- 구독 status = PAST_DUE, scheduledPaymentId = null
- subscription_periods에 FAILED 행 1개 (중복이면 무시)

webhook은 “정확히 한 번”이 아니라 “최소 한 번”이다. 받는 쪽이 멱등해야 한다.

즉시 결제는 webhook을 믿지 않는다

결제 ID에 접두사를 붙여 종류를 나눴다.

접두사용도
sw-pay-즉시 결제 (업그레이드, PAST_DUE 복구)
sw-sch-예약 자동 결제

webhook 핸들러는 sw-sch-로 시작하는 것만 처리한다. 즉시 결제(sw-pay-)는 서비스 코드가 그 자리에서 동기적으로 DB를 다 쓰기 때문에, webhook이 같은 걸 또 처리하면 중복이다. 내가 이미 처리한 건 webhook이 알려줘도 무시한다. 결제 종류를 ID에 인코딩해 두면 핸들러가 한 줄로 갈라낸다.

카드는 실패한다

자동 결제는 언젠가 실패한다. 한도, 만료, 정지. 실패했다고 즉시 서비스를 끊으면 이탈한다. 그래서 상태기계에 PAST_DUE를 두고, 그동안은 서비스를 계속 쓰게 둔다.

active ── 예약 결제 실패 ──→ past_due
past_due ── 카드 재등록 + 결제 성공 ──→ active

구독 결제 상태기계 — active와 past_due, 복구 시 빌링키 선갱신

복구 흐름에서 비자명했던 건 빌링키를 결제 전에 먼저 갱신한다는 것이다.

retryPastDuePayment:
1. billingKeyId 먼저 갱신 ← 결제가 실패해도 다음 시도는 새 카드로
2. 즉시 결제 (sw-pay-)
3. 성공 시 트랜잭션: 결제 저장 + 구독 active + FAILED period → RECOVERED

순서가 반대면, 새 카드를 등록했는데 이번 결제가 또 실패할 경우 다음 재시도가 옛 카드를 본다. 결제(불확실)보다 카드 정보 갱신(확실)을 먼저 커밋해서, 실패가 반복돼도 항상 최신 카드로 시도하게 만든다.

이력은 덮어쓰지 않는다

구독 상태(active/past_due/cancelled)는 현재값 하나다. 그걸로는 “언제 얼마가 청구됐고 어떤 주기가 실패했는지”를 알 수 없다. 그래서 결제 주기마다 행을 쌓는 subscription_periods를 따로 뒀다.

period.status: active | failed | recovered

실패한 주기는 지우지 않고 failed로 남기고, 복구되면 그 행을 recovered로 바꾼다. 해지할 때 마지막 결제도 누락 없이 기록한다. 현재 상태와 이력을 분리하니, 결제 내역과 분쟁 대응이 데이터만으로 설명된다.

PG 호출과 DB 쓰기는 한 트랜잭션이 아니다

즉시 결제(업그레이드·PAST_DUE 복구)는 세 단계로 쪼개진다.

Phase 1 (트랜잭션): 구독 행 비관적 락 → 검증 → 필요한 값 수집
Phase 2 (트랜잭션 밖): PortOne 즉시 결제
Phase 3 (트랜잭션): 결제내역 + 구독상태 + 주기 이력 원자적 기록

PG 호출을 트랜잭션 안에 넣을 수 없다. 외부 HTTP는 느리고 실패하는데, 그 몇 초 동안 DB 커넥션과 행 잠금을 쥐고 있으면 안 된다. 그래서 결제는 트랜잭션 밖에서 하고, 결과만 트랜잭션 안에서 쓴다.

문제는 Phase 2와 3 사이다. PG에선 결제가 됐는데 Phase 3에서 DB 쓰기가 실패하면, 돈은 빠졌는데 구독은 그대로다. 이건 분산 트랜잭션의 근본 문제라 코드로 원천 봉쇄가 안 된다. 그래서 막는 대신 감지한다. Phase 3이 실패하면 [CRITICAL] 로그를 남기고 관리자에게 알림 메일을 보내 수동 보정하게 한다. 한계는 분명하다. 자동 복구가 아니라 사람이 개입한다.

결제 실패 후 고객사 어드민에게 보내는 안내(카드 재등록 유도)도 처음엔 fire-and-forget였다. 상태는 바뀌었는데 안내는 안 가는 어긋남을 없애려고, 지금은 past_due 전환과 한 트랜잭션으로 묶어 보내는 트랜잭셔널 아웃박스로 옮겼다.

그래서

결제 코드의 대부분은 성공했을 때가 아니라 두 번 와도, 실패해도, 중간에 끊겨도 일관성을 지키는 데 쓰인다. webhook은 멱등하게 받고, 내가 이미 한 건 외부 신호를 무시하고, 불확실한 작업(결제)보다 확실한 작업(카드 갱신·이력 기록)을 먼저 커밋한다. 외부 PG·webhook·카드를 못 믿는다고 전제하면, 설계가 자연스럽게 그 모양이 된다.

↑↓ 이동 열기esc 닫기