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_duepast_due ── 카드 재등록 + 결제 성공 ──→ active복구 흐름에서 비자명했던 건 빌링키를 결제 전에 먼저 갱신한다는 것이다.
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·카드를 못 믿는다고 전제하면, 설계가 자연스럽게 그 모양이 된다.