<PonsLink/>
작업
글
주제
시리즈
카테고리
연락
⌘K 검색
About
코드와 제품으로

코드와 제품으로

연결되는 세계

연결되는 세계

PonsLink는 만남 뒤 요청을 세션으로 잇고, PonsWarp는 서버 보관 없이 파일을 직접 전송합니다. 이 블로그는 기능 소개보다 설계 판단과 운영 증거를 남깁니다.

연락처

[email protected]github.com/DeclanJeon

운영

콘텐츠는 WordPress headless CMS, 공개 UI는 Next.js입니다.

초안 미리보기
© 2026 PonsLink Blog|Next.js · WordPress REST · Framer Motion
Local-first · Backup-before-deploy

[PonsLink] 결제 연동과 웹훅 안정성 회고 — 다중 PG와 멱등성

Declan Jeon·2026년 8월 7일

  • 개발 회고

결제는 &quot;성공 페이지를 보여주면 끝나는 일&quot;이 아니다. PonsLink는 Polar를 활성 공급자로 두고, PayPal·LemonSqueezy·Stripe 웹훅 경로를 함께 유지한다. 공급자가 늘수록 문제는 기능이 아니라 중복·지연·재시도다. 현재 구성 (실측) &#8211; `PAYMENT_PROVIDER=polar` — 체크아웃·구독의 주 경로 &#8211; PayPal sandbox (`PAYPAL_ENV=sandbox`) &#8211; LemonSqueezy / Stripe — 이벤트 원장 테이블과 웹훅 핸들러 유지 &#8211; 관련 테이블: `subscriptions`, `drop_passes`, `stripe_events`, `paypal_events`, `lemonsqueezy_events`, &#8230; Read more

결제는 "성공 페이지를 보여주면 끝나는 일"이 아니다. 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. "어느 공급자로 샀든 한 구독 행"을 만들기 위한 타협이다.

웹훅 처리 순서 — 절대 바꾸지 않는 것

1. **시그니처 검증** — secret 없는 바디는 버린다.

2. **이벤트 원장 INSERT** — 공급자 이벤트 id를 PK로. 이미 있으면 no-op 200.

3. **멱등성 키 확인** — `idempotency_keys`로 API/재시도 단위 중복을 죽인다.

4. **상태 전이** — subscriptions / drop_passes / entitlements.

5. **실패 시 데드레터** — `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를 남긴다.

교훈

1. 공급자 SDK보다 **이벤트 원장 스키마**를 먼저 설계한다.

2. 성공 UI와 권한 부여를 분리한다.

3. 멱등성은 미들웨어 한 줄이 아니라 테이블이다.

4. 다중 PG는 "지원 배지"가 아니라 운영 복잡도다. 활성 공급자를 env로 고정(`PAYMENT_PROVIDER`)해 경로를 하나로 수렴시킨다.

5. 대사(reconcile) 없는 웹훅은 언젠가 잔고와 싸운다.

결제는 신뢰의 인프라다. 기능 목록에 로고를 늘리는 일보다, 한 번의 웹훅이 두 번 적용되지 않게 만드는 일이 먼저다.

Related

관련 운영 노트

같은 주제에서 이어서 읽기 좋은 글입니다.

  • [Blueprint L3] Pons 포트폴리오 — 의존성 0, 단일 index.html이 오래 가는 이유

    의존성 0, 단일 index.html 41KB. 디자인 토큰 SSOT와 13개 제품 쇼케이스로 만든 정적 포트폴리오가 오래 가는 이유.

  • [Blueprint L2] PonsLink Control Tower — Threads에 dry-run으로 쏘는 이유

    Python Threads 컨트롤타워. cardnews 재사용→생성 폴백, n8n 패치, dry-run 기본 게이트로 안전하게 쏘는 자동발행.

  • [Blueprint L1] PonsLink P2P Room — WebRTC 화상회의를 BFF로 감싼 이유

    React SPA와 Express BFF가 Socket.IO로 시그널링하고, 미디어는 mesh로 가는 2티어. TURN 그랜트와 presence lease로 룸을 만든 화상회의.

댓글

아직 댓글이 없습니다.

현재 이 글의 댓글은 닫혀 있습니다.