목차
목차도입Stripe → PortOne 정기결제클라이언트 기반 정기 결제 구현WebHook 기반 정기결제WebHook 기반 정기결제 (빌링키 발급)WebHook 기반 정기결제 (WebHook 로직)낙관적 업데이트 후 결제 검증결론
도입
saas-starter 라는 template을 아시나요?
깃허브에서 star 11k를 받은 saas-template 오픈소스입니다.
처음 봤을 때 굉장히 놀랐습니다. 환경변수 몇개만 입력하면 saas를 만들 수 있다는건 AI가 발전한 지금, AI를 활용해서 원하는 SaaS를 누구나 만들 수 있다고 생각했습니다.
하지만 한국 빌더에게는 이 saas-starter가 불편했습니다.
- 한국에서 지원하지 않는 Stripe 기반
- 영어만 존재
- Oauth 부재
이러한 문제들이 있다고 생각해 한국 빌더들이 사용할 saas-starter-ko를 만들고자 했습니다.
이때 저는 다음과 같은 기능들을 추가하고자 했습니다.
- Stripe → PortOne 결제 인프라
- 다크모드
- 한글/영어 Multi language
- 구글 & Microsoft OAuth
이 중. 이번 글에서는 Stripe → PortOne으로 migrate하는 과정 중 발생한 정기결제 기능에 대해 작성하겠습니다.
Stripe → PortOne 정기결제
Stripe은 Subscription 모드로 정기 결제를 지원하고 있습니다.
그래서 Stripe SDK에서 session create로 session url을 받아 해당 url에서 쉽게 정기 결제를 생성할 수 있습니다.
- 코드
- session.url

하지만 PortOne에는 subscription mode가 따로 존재하지 않고, 구독형 정기결제, 종량제 과금결제 등 고객사가 원하는 시점에 결제를 일으키기 위한 결제용 비밀 키인 BillingKey를 이용해서 결제, 예약결제 등을 구현해야 합니다.
클라이언트 기반 정기 결제 구현
정기결제를 구현하기 위해서는 주요한 3가지 PortOne API 호출이 있습니다.
- 빌링키 획득 (SDK)
- 빌링키로 결제
- 예약 결제 생성
이 3가지 API로 정기 결제 로직은 다음과 같습니다.

즉 빌링키 획득 SDK로 빌링키를 얻은 후, Server DB에 저장한 후 결제를 진행합니다.
결제 후 결제 정보를 담은 session을 생성합니다. 또한 Team Plan을 update 한 후 PortOne에 예약 결제를 생성합니다.
WebHook 기반 정기결제
하지만 클라이언트 기반 정기 결제 로직은 다음과 같은 문제가 있습니다.
- 다양한 결제 ( 정기 결제, 정기 결제 취소 ) 에 대응하기 어려움
- 스크립트 위/변조 시 검증 어려움
- 클라이언트에서만 결제 완료 확인 시 여러가지 이유(인터넷 연결 끊김, 브라우저 오류 등) 로 DB 동기화 누락 가능성
이러한 문제들을 해결하기 위해 Webhook API에서 다음과 같은 책임을 부여하고자 하였습니다.
- 결제 검증 로직
- 성공 시에만 Team Plan update, 예약결제 생성
먼저 WebHook에 대한 개념에 대해 알아보겠습니다. 공식 문서 상 설명은 다음과 같습니다.
Webhook 프로바이더는 해당 이벤트가 발행하면
HTTP POST
요청을 생성하여 callback URL(endpoint)로 이벤트 정보을 보냅니다.간단히 설명하자면 결제, 취소, 결제 실패 등 유저로부터 Payment 이벤트가 발생할 경우, 지정된 API 주소로 HTTP POST 요청합니다.
saas-starter에서는 다음 용도로 사용합니다.
webhook 에서 온 내용을 검증하고, subscription 상태가 변경되었을 때 handling 하기 위해 사용합니다.
WebHook 기반 정기결제 (빌링키 발급)
PortOne에서는 subscription mode 가 따로 존재하지 않기 때문에 이 부분에 대해서는 로직이 필요합니다. 해당 내용을 코드로 확인해보겠습니다.
- 빌링키 발급
먼저 PortOne SDK로 빌링키를 발급받은 후 해당 내용들을 추합하여 createPayMentsByBillingKeyAction server action을 실행시킵니다. (createPayMentsByBillingKeyAction에 대해서는 아래에서 설명)
- 클라이언트 빌링키 발급
- 빌링키 발급 로직
- 빌링키 저장
빌링키 발급에 문제가 없다면 Server DB에 빌링키를 저장합니다.
- 결제 Server Action (createPayMentsByBillingKeyAction)
해당 action에서는 팀을 확인한 후 빌링키와 priceId를 이용해서 결제할 금액에 대해 결제를 진행합니다.
- 결제 및 예약 결제 생성 (createCheckoutSubscription)
이때, 팀의 Trial 여부에 따라 process 함수가 달라집니다. process 진행 후 checkout API(redirectUrl)에서 session을 검증하고 dashboard로 redirect 합니다.
각 process 함수는 다음과 같습니다.
- Trial을 진행했을 경우 (processPayment)
- 진행하지 않았을 경우 (processTrialSubscription)
WebHook 기반 정기결제 (WebHook 로직)
먼저 Webhook으로부터 온 메세지들을 검증합니다.
다음 결제 시 지정해주었던 PaymentId를 통해 session을 가져와 결제 시 저장해두었던 결제 내역 (session)을 조회해 price, product, billingKey들을 가져옵니다.
이 값들을 가지고 저장된 결제된 내용과 PortOne에서 결제된 값 확인한 후, 다음과 같은 로직을 수행합니다.
- 예약 결제 & session 생성
- Team Plan 업데이트
결제 금액이 불일치 할 경우 위변조가 의심되므로 에러를 반환합니다.
- 에러 처리
또한 검증에 실패했을 경우 에러를 반환합니다.
해당 로직을 그림으로 나타내면 다음과 같습니다.

이렇게 구성하면 결제 검증, 성공 시에만 Team Plan이 업데이트 되고, 예약 결제가 생성되어 실패 시에는 Team Plan이 업데이트 되지 않습니다.
또한 클라이언트 결제 완료 후에 인터넷 연결 끊김, 브라우저 닫힘 등 다양한 문제로 Team Plan 업데이트가 진행되지 않아도 Webhook api 에서 동기화를 진행할 수 있습니다.
추후 다양한 결제 ( 정기 결제, 정기 결제 취소 )에 Webhook을 중심으로 비즈니스 로직을 수행할 수 있습니다.
하지만 이 방식에도 문제가 발생합니다.

그림과 같이 결제가 완료되어 dashboard로 redirect 된 후에도 Team Plan이 업데이트 되어있지 않아 사용자는 Webhook API를 처리하는 동안 이전 버전의 Plan으로 기다리고 있어야 합니다.
낙관적 업데이트 후 결제 검증
이 문제를 해결하기 위해 기존 saas-starter의 코드를 참조하였습니다.
saas-starter의 checkout api입니다.
해당 코드에선 checkout api가 실행되면 해당 Team Plan을 바로 업데이트 합니다.
이런 방식에 착안하여 saas-starter-ko에서도 checkout api에서 결제가 정상적으로 완료되었다는 가정 하에(낙관적 업데이트) Team Plan을 선제적으로 업데이트 합니다.
이렇게 되면 위의 DB 업데이트 텀은 발생하지 않습니다.
이때 Webhook API에서는 결제 내역을 검증합니다. PAID 시 기존 로직과 동일하지만, 만약 그 외에 상황에서는 Team Plan을 다시 canceled로 돌려 놓습니다.
로직을 그림으로 나타내면 다음과 같습니다.

결론
이렇게 WebHook 기반 결제 시스템, 그리고 낙관적 업데이트를 도입하여 다음과 같은 이점을 얻을 수 있었습니다.
- 결제 검증
- UX 관점에서 사용자가 결제 후 즉시 업그레이드된 Plan을 볼 수 있도록 낙관적 업데이트
- 안정성 향상: 결제 실패 시 WebHook을 통해 자동으로 Plan 상태를 원래대로 복구
이번 글에서는 Stripe의 Subscription 모델을 PortOne의 빌링키 기반 결제 시스템으로 마이그레이션하는 방법과 그 과정에서 발생한 문제점들을 해결하는 방법에 대해 다루었습니다. 다음 글에서는 다국어 지원 및 OAuth 통합에 대해 다루겠습니다.