Indie Hackers

Stripe and Google Play both write one row in my database. Here's what I learned

Stripe와 Google Play가 구독 테이블 한 행을 함께 쓸 때 배운 점

MotionLab은 Stripe와 Google Play의 구독 정보를 Supabase의 사용자별 한 행에 통합합니다. 작성자는 웹훅 순서가 뒤바뀌는 상황과 스토어별 상태 차이를 처리한 방법, 결제 완료 화면과 열 이름에서 얻은 교훈을 정리합니다.

에디터 노트

사용자당 한 행은 앱의 접근 판정을 단순하게 만들지만, 두 스토어에서 동시에 결제하거나 한쪽 상태가 다른 쪽 갱신을 덮어쓰면 그 단순성이 깨집니다. 작성자가 설명한 구조는 스토어별 상태를 한 행에 정규화하는 방식이며, 댓글에서 제안한 스토어별 결제 기록과 entitlement 분리는 충돌과 상태 변경을 다루는 다른 설계입니다. source 열은 결제 관리 화면을 올바르게 보여주지만 동시 구독을 보존하는 문제까지 해결하지는 않습니다. 웹훅에서 현재 상태를 다시 읽는 방식은 순서가 뒤바뀐 이벤트에 대한 구체적인 대응입니다. 반면 Play의 유예 기간은 active 또는 trialing만으로 접근을 정한다는 설명과 맞지 않을 수 있고, 원문은 이 상태를 어떻게 처리하는지 밝히지 않습니다. 댓글의 원본 상태 저장, 주기적 대조, Play 알림 처리는 운영 보완책으로 제안됐지만 작성자의 구현에 이미 포함됐다고 볼 근거는 없습니다.

AI 요약

MotionLab은 웹에서는 Stripe Checkout으로, Android에서는 Google Play Billing으로 Pro 구독을 판매합니다. 두 결제 경로는 Supabase의 subscriptions 테이블에서 사용자마다 한 행을 갱신합니다. 앱은 이 행을 읽고 status가 active 또는 trialing인지 확인해 Pro 접근을 결정합니다.

웹훅에서 현재 상태 다시 읽기

Stripe 웹훅 이벤트는 순서가 뒤바뀌어 도착할 수 있습니다. 작성자는 invoice나 checkout 완료 이벤트가 들어오면 이벤트 안에 담긴 구독 상태를 그대로 저장하지 않고 Stripe API에서 현재 구독을 다시 조회합니다. API 호출이 한 번 늘지만, 늦게 도착하거나 중복된 이벤트가 이전 상태를 덮어쓰는 문제를 막습니다. 사용자 ID는 Checkout Session 메타데이터뿐 아니라 생성된 구독에도 기록합니다. 갱신이나 취소처럼 이후에 발생하는 이벤트에서도 어느 사용자인지 확인하기 위해서입니다.

초기 웹훅 수정 가운데 일부는 로직 오류가 아니라 Stripe API 필드 변경에서 비롯됐습니다. 결제 기간 종료일이 subscription에서 해당 항목으로 이동했고, invoice의 subscription 참조 필드도 바뀌었습니다. 값이 조용히 undefined가 되는 경우를 겪은 뒤에는 결제 기능이 갑자기 깨질 때 코드부터 고치지 않고 변경 기록을 확인한다고 설명합니다.

두 스토어의 상태를 한 어휘로 변환

Google Play는 Stripe와 다른 구독 상태를 사용합니다. Play 구독은 취소해도 만료일까지 접근 권한이 남을 수 있어, 작성자는 이를 active 상태와 기간 종료 시 취소되는 값으로 저장합니다. Stripe의 예약 취소와 같은 모양으로 맞춘 셈입니다. 계정 보류와 일시중지 상태는 past_due로 변환해 Pro 접근을 허용하지 않습니다. 스토어별 상태를 데이터 입력 시점에 공통 표현으로 바꾸면 화면은 어느 스토어에서 결제했는지 신경 쓰지 않아도 됩니다.

다만 실제 구매 경로를 알아야 결제 관리 화면을 올바르게 보여줄 수 있다는 의견이 댓글에 나옵니다. Play 구독자에게 Stripe 결제 버튼을 보여주면 중복 결제가 생길 수 있고, Stripe 고객에게 Play에서 취소하라고 안내해도 맞지 않습니다. 원문 작성자는 처음부터 provider 열을 뒀다면 좋았을 것이라고 덧붙입니다. 현재는 Play 구매 토큰이 stripe_subscription_id라는 열에 들어가는 등 기존 명명 체계가 혼란을 부릅니다.

결제 완료와 웹훅 사이의 시간차

Stripe 결제 직후 브라우저가 성공 페이지로 돌아오는 시점이 웹훅의 데이터 저장보다 빠를 수 있습니다. 홈 화면은 active 상태가 확인될 때까지 2초 간격으로 몇 차례 조회하고, 상태를 찾으면 멈춥니다. 작성자는 복잡한 동기화 장치를 만드는 대신 이 짧은 재시도를 택했습니다.

댓글에서는 이와 별도로 웹훅 중복과 누락에 대비한 방안도 제안합니다. 한 댓글 작성자는 원본 스토어 상태를 정규화한 값 옆에 보관하자고 했고, 다른 작성자는 Stripe와 Play를 주기적으로 재조회해 DB와 대조하는 야간 조정 작업을 언급했습니다. Play의 구매 검증과 승인 기한, 실시간 알림을 처리해야 한다는 지적도 있습니다.

Indie Hackers 반응

  • @theboris7 — 앱에서 읽기만 하고 사용자별 한 행을 두는 구조가 맞습니다. 두 스토어를 함께 쓸 때 다음으로 문제가 되는 경우는 Play로 이미 Pro를 구독한 사용자가 웹에 로그인해 Stripe 업그레이드 버튼을 보는 상황입니다. 일부 사용자는 두 번 결제하고, 환불은 서로 다른 두 시스템에서 처리해야 합니다. 행에 source 열을 저장하고 화면에서 사용하세요. source가 play라면 웹에는 결제 버튼 대신 Google Play에서 구독을 관리하라고 보여주고, 앱에서도 반대로 처리하면 됩니다. 취소 안내도 실제 결제 경로와 맞출 수 있습니다. Play 구독자에게 한 번도 써본 적 없는 Stripe 포털에서 취소하라고 하면 지원 문의가 생깁니다.
  • @lukehoang — 고객 유입 쪽에서도 같은 질문이 있습니다. 사용자별 한 행에 고객이 어디서 왔는지도 기록하나요? 웹과 Play에서 모두 결제를 받기 시작하면 유입 경로가 가장 먼저 사라지곤 합니다. 어디에 돈을 쓸지 정할 때 필요한 정보입니다.
  • @shipplays — 놓치기 쉬운 Play 상태로 유예 기간이 있습니다. 갱신 결제에 실패해도 유예 기간에는 구독이 유지되며 접근 권한을 계속 줘야 합니다. active 또는 trialing만 확인하면 접근을 끊게 됩니다. Play의 유예 기간과 계정 보류를 각각 어떻게 매핑하나요? 둘을 Stripe의 past_due와 같은 상태로 합치나요?
  • @harries — 이벤트 스냅샷을 믿지 말라는 지점은 보통보다 더 강조할 만합니다. 늦게 온 취소 이벤트가 여전히 결제 중인 사용자를 무료 상태로 되돌리는 경우를 봤고, 해결책은 말씀하신 대로 현재 상태를 다시 조회해 저장하는 것이었습니다. Play 쪽에서는 구매 토큰을 서버에서 검증하고 승인 기한도 처리해야 합니다. 승인하지 않으면 Play가 자동 환불합니다. 정규화한 행 옆에 원본 제공자 페이로드를 저장하는 것도 좋습니다. 고객이 결제 상태에 이의를 제기할 때 Stripe나 Play가 실제로 보낸 내용을 확인하면 여러 지원 문의를 해결할 수 있었습니다. 여러 스토어를 하나의 상태 체계로 맞추는 방식은 좋습니다. 제가 본 다중 스토어 앱 가운데 이를 건너뛴 곳은 결국 진실의 출처가 두 개가 되고, 결제했는데 Pro가 아니라는 문의가 쌓였습니다.
  • @ShaneCollins — 저희는 다른 방식을 씁니다. 다만 구독이 아니라 일회성 구매입니다. Apple, Google Play, Stripe 구매를 모두 같은 거래 객체로 바꾸고, 스토어 거래 ID를 키로 단일 테이블에 저장합니다. 같은 구매가 웹훅과 성공 페이지에서 중복으로 오거나 동시에 두 요청이 들어와도 고유 키가 한 번만 처리되도록 합니다. 두 가지 문제가 있었습니다. Stripe Checkout Session ID는 66자라 64자 열에 들어가지 않아 PaymentIntent ID를 키로 씁니다. 환불 이벤트에도 이 ID가 들어옵니다. Play 검증에서는 사용자가 실제로 낸 금액을 반환하지 않아 Play 매출은 추정치입니다. 5번 내용은 저희와 같습니다. 웹훅을 기다리지 않고 성공 페이지에서 세션을 직접 조회합니다. 나중에 구독과 일회성 구매를 함께 넣는다면 사용자당 한 행을 유지할 건가요?
  • @takehomecalc — 구독은 사용자당 한 행으로 두고, 제공자별 키를 쓰는 별도 entitlements 테이블을 둡니다. 구독 행은 결제가 정상인지 답하고, entitlements는 사용자가 실제로 무엇을 쓸 수 있는지 답합니다. Play의 보류와 일시중지는 권한 기간에는 잘 맞지만 Stripe식 단일 상태와는 맞지 않습니다. 성공 페이지를 힌트로 쓰고 웹훅을 기준으로 삼는 방식에도 동의합니다. 홈 페이지에서 잠깐 폴링하는 편이 복잡한 기능을 만드는 것보다 훨씬 저렴합니다.
  • @nebosa — 쓰기 전에 현재 상태를 다시 조회하면 순서가 뒤바뀐 이벤트를 가장 깔끔하게 처리합니다. 웹훅을 페이로드를 믿는 대신 '무언가 바뀌었으니 현재 상태를 확인하라'는 신호로 바꾸는 셈입니다. 4번에는 원본 스토어 상태도 정규화한 상태 옆에 보관하라고 덧붙이고 싶습니다. 예를 들어 원래 Play 상태를 active 또는 trialing 상태와 함께 저장하면 사용자가 취소했다고 문의할 때 로그를 뒤지지 않고 스토어가 보낸 내용을 확인할 수 있습니다.
  • @omasn — 저희도 스토어별 한 행을 씁니다. Google Play와 App Store를 함께 운영하며 더한 점은 이렇습니다. 스토어 알림인 RTDN과 App Store Server Notifications는 신호로만 취급합니다. 처리기가 쓰기 전에 스토어 API에서 현재 상태를 다시 조회하므로 순서가 뒤바뀌거나 중복된 알림은 문제가 되지 않습니다. 클라이언트 영수증 검증, 알림 처리, 야간 재동기화는 모두 먼저 Postgres의 사용자별 advisory lock을 획득합니다. 그렇지 않으면 클라이언트 영수증과 RTDN이 같은 순간에 들어와 경쟁 상태가 생길 수 있습니다.
  • @feech — 구매 기록은 스토어별로 두고, 둘을 바탕으로 Pro 접근을 결정하겠습니다. Stripe 업데이트가 여전히 결제 중인 Play 구매를 지우면 안 된다는 것이 DannieDan의 사례에서 드러납니다. 제공자별 원래 상태를 남기면 복구 규칙을 바꿀 때 화면을 전부 다시 손대지 않아도 됩니다. 저는 고객이 앱에서 쓸 기능을 제어하는 .NET/TypeScript 라이브러리 Subscrio를 만듭니다. 구독을 바탕으로 접근 권한을 정하고 Stripe를 연동합니다. Play 구매는 여전히 직접 검증하고 매핑해야 합니다. 결제 기록과 Pro 판정을 분리한다면 저도 이런 구분을 쓰겠습니다.
  • @DannieDan — 같은 사용자가 두 스토어에서 모두 결제하기 전까지는 사용자당 한 행으로 버틸 수 있습니다. Stripe가 현재 상태를 저장하는 처리기가 아직 결제 중인 Play 구매를 덮어쓸 수 있습니다. 스토어별로 한 행을 두고 어느 한쪽이라도 active면 Pro를 주면 앱에서는 같은 방식으로 접근을 확인하면서 두 처리기가 서로의 값을 덮어쓰지 않습니다.
  • @Regulus — 특히 이벤트 스냅샷을 믿지 말라는 2번이 유용합니다. 저희 경험으로는 Stripe와 Play의 진실을 다시 조회해 subscriptions 테이블과 대조하는 야간 조정 작업도 필요합니다. 웹훅은 언젠가 지연되거나 중복되거나 누락됩니다. 조정 작업이 있으면 결제했는데 Pro를 쓸 수 없어 화난 고객이 생기기 전에 로그에서 발견해 고칠 수 있습니다. Regulus에서도 특정 제공자의 이벤트만 믿지 않고 하나의 기준이 되는 결제 기록을 둡니다. 6번도 과소평가하기 쉽습니다. 매핑을 기억할 때 열 이름을 바꾸는 편이 두 번째 스토어나 팀원이 생긴 뒤에 바꾸는 것보다 훨씬 저렴합니다. Play 엔드포인트에서 멱등성 키를 처리하나요, 아니면 사용자 ID 조회로 중복을 막나요?
  • @indie977 — 4번은 가장 면밀히 확인할 지점입니다. 취소했지만 만료일까지 활성 상태인 Play 구독이나 구매 검증 뒤에 발생한 환불이 있으면, 행은 active로 남고 이를 지울 계기가 없을 수 있습니다. Play의 실시간 개발자 알림도 받아 취소를 처리하나요, 아니면 다른 사건이 조회를 일으킬 때만 행을 바꾸나요?

원문: Indie Hackers / 번역·요약: Trawling