[PonsLink] 결제 연동과 웹훅 안정성 회고 — 다중 PG와 멱등성
결제는 "성공 페이지를 보여주면 끝나는 일"이 아니다. PonsLink는 Polar를 활성 공급자로 두고, PayPal·LemonSqueezy·Stripe 웹훅 경로를 함께 유지한다. 공급자가 늘수록 문제는 기능이 아니라 중복·지연·재시도다.
현재 구성 (실측)
PAYMENT_PROVIDER=polar— 체크아웃·구독의 주 경로- PayPal sandbox (
PAYPAL_ENV=sandbox) - LemonSqueezy / Stripe — 이벤트 원장 테이블과 웹훅 핸들러 유지
- 관련 테이블:
subscriptions,drop_passes,stripe_events,paypal_events,lemonsqueezy_events,idempotency_keys,webhook_deliveries
구독 컬럼은 공급자별 식별자를 나란히 둔다 (stripe_subscription_id, paypal_subscription_id, lemonsqueezy_subscription_id …). 각각 UNIQUE. "어느 공급자로 샀든 한 구독 행"을 만들기 위한 타협이다.
웹훅 처리 순서 — 절대 바꾸지 않는 것
- 시그니처 검증 — secret 없는 바디는 버린다.
- 이벤트 원장 INSERT — 공급자 이벤트 id를 PK로. 이미 있으면 no-op 200.
- 멱등성 키 확인 —
idempotency_keys로 API/재시도 단위 중복을 죽인다. - 상태 전이 — subscriptions / drop_passes / entitlements.
- 실패 시 데드레터 —
webhook_deliveries+ admin 재시도.
이 순서를 뒤집으면 "두 번 업그레이드" 또는 "돈은 나갔는데 플랜은 free"가 나온다. 프론트의 return URL(/lemonsqueezy/return, /paypal/return, Polar success URL)은 UX일 뿐 권한 근거가 아니다.
드롭 패스 — 구독이 아닌 1회 공유권
drop_passes는 상담 파일·자료 전달용 1회권이다. max_total_bytes, max_file_bytes, retention_seconds, remaining_uses를 고정의 조건으로 들고, 사용 시 cloud_shares.drop_pass_id로 연결된다. 구독 없이도 유료 전달이 가능하게 하려는 제품 결정이다.
크론이 메우는 구멍
check-trials— 체험 종료evaluate-dunning— 결제 실패 후속monthly-usage-reset/usage-rollup/report-overage— 사용량 청구reconcile-payments— 공급자 상태와 로컬 원장 대사cleanup-idempotency-records— 키 만료 정리
웹훅만 믿으면 공급자 장애 하루에 운영이 멈춘다. 크론은 부끄러운 폴백이 아니라 설계의 일부다.
어드민 면
잠긴 요청, 결제 대기, 이메일 실패, 리스크·남용 큐가 /admin/operations/queues 아래에 있다. 결제 장애는 고객 지원 티켓이 아니라 운영 큐 항목이어야 한다. 감사 로그(admin_audit_logs)에 actor/ip/ua/request_id를 남긴다.
교훈
- 공급자 SDK보다 이벤트 원장 스키마를 먼저 설계한다.
- 성공 UI와 권한 부여를 분리한다.
- 멱등성은 미들웨어 한 줄이 아니라 테이블이다.
- 다중 PG는 "지원 배지"가 아니라 운영 복잡도다. 활성 공급자를 env로 고정(
PAYMENT_PROVIDER)해 경로를 하나로 수렴시킨다. - 대사(reconcile) 없는 웹훅은 언젠가 잔고와 싸운다.
결제는 신뢰의 인프라다. 기능 목록에 로고를 늘리는 일보다, 한 번의 웹훅이 두 번 적용되지 않게 만드는 일이 먼저다.