이 글에서 다루는 내용

이 글에서는 Flutter 앱에 티어 2개 × 월·연 주기 조합의 구독 상품 4개를 도입하기 위해 in_app_purchasepurchases_flutter(RevenueCat)를 비교한 결과, 수수료와 가입 구조를 확인한 뒤, 클라이언트 측 entitlement 체크와 서버측 webhook을 어떻게 역할 분담해 Supabase와 연동할지 설계한 과정을 정리합니다.

상품 구조

베이직·프리미엄 두 티어 각각에 월간·연간 구독을 만들어 총 4개 상품을 운영합니다.

Product ID 상품
basic_monthly 베이직 월간
basic_yearly 베이직 연간
premium_monthly 프리미엄 월간
premium_yearly 프리미엄 연간

RevenueCat에서는 basic·premium 두 개의 entitlement를 만들고 각 entitlement에 월·연 상품을 연결합니다. 이렇게 하면 월·연 어느 쪽을 구매했든 entitlement 하나로 권한 판정이 끝납니다.

in_app_purchase vs RevenueCat

직접 구현 시 필요한 작업이 꽤 많습니다.

  1. 서버측 영수증 검증 (Google Play Developer API)
  2. 사용자별 구독 상태 DB
  3. Real-time Developer Notifications 수신(Pub/Sub)
  4. grace period·hold 처리
  5. 플랜 전환 시 proration 처리
  6. iOS StoreKit 별도 구현

RevenueCat은 1~6을 모두 대행하고 Flutter 쪽 코드는 아래 수준으로 단순해집니다.

await Purchases.purchasePackage(package);

final info = await Purchases.getCustomerInfo();
final isPremium = info.entitlements.active.containsKey('premium');

4개 상품 구성은 직접 구현 시 가장 까다로운 구간이므로 RevenueCat을 선택했습니다.

수수료 구조

RevenueCat은 결제 중간에 끼어들지 않습니다. Google이 수수료를 떼고 개발자 계좌로 입금하는 흐름은 그대로이며, RevenueCat은 월 매출(MTR)이 $2,500을 초과하는 구간에 1%를 별도 청구서로 부과하는 SaaS 구독 모델입니다. 초기 매출 구간에서는 사실상 무료로 운영 가능합니다.

클라이언트 측과 서버측 이벤트 수신

"구독 상태 변화를 어디서 받느냐"는 두 경로로 나뉩니다.

클라이언트 측 — SDK 리스너

Purchases.addCustomerInfoUpdateListener((customerInfo) {
  final isPremium = customerInfo.entitlements.active.containsKey('premium');
  setState(() => _isPremium = isPremium);
});

RevenueCat SDK가 서버와 통신해 상태 변화를 앱으로 push합니다. webhook 없이도 UI 실시간 업데이트가 가능합니다.

서버측 — webhook

Supabase DB에 구독 상태를 저장하거나 RLS로 서버측 권한 제어를 하려면 webhook이 필수입니다. RevenueCat은 webhook을 송신하는 쪽이며, 받는 엔드포인트는 개발자가 만들어야 합니다.

Supabase Edge Function으로 작성한 예시.

// supabase/functions/revenuecat-webhook/index.ts
import { createClient } from 'jsr:@supabase/supabase-js@2'

Deno.serve(async (req) => {
  const authHeader = req.headers.get('Authorization')
  if (authHeader !== `Bearer ${Deno.env.get('REVENUECAT_WEBHOOK_SECRET')}`) {
    return new Response('Unauthorized', { status: 401 })
  }

  const payload = await req.json()
  const event = payload.event

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
  )

  await supabase.from('profiles').update({
    premium_entitlement: event.entitlement_ids?.[0] ?? null,
    premium_expires_at: event.expiration_at_ms
      ? new Date(event.expiration_at_ms).toISOString()
      : null,
  }).eq('id', event.app_user_id)

  return new Response('OK', { status: 200 })
})

배포 후 URL을 RevenueCat 대시보드 → Project Settings → Integrations → Webhooks에 등록합니다.

이 프로젝트의 설계 결론

클라이언트 측 체크만 있으면 UX용으로는 충분하지만, Supabase에 저장된 데이터(단어장·스캔 이력 등) 접근을 프리미엄 한정으로 제어하려면 RLS가 참조할 서버측 상태가 필요합니다. 따라서 클라이언트·서버 둘 다 구현하는 업계 표준 아키텍처를 따릅니다.

Google Play → RevenueCat → [클라이언트 SDK 리스너] → 앱 UI
                        ↘ [webhook] → Supabase Edge Function → profiles 테이블

pubspec과 Gradle 정리

dependencies:
  purchases_flutter: ^8.0.0

purchases_flutter가 최신 Billing Library를 전이 의존성으로 포함하므로, 이전에 경고 회피용으로 추가했던 android/app/build.gradle.ktscom.android.billingclient:billing 명시와 resolutionStrategy.force 블록은 삭제합니다. 중복·다운그레이드 리스크를 제거하기 위함입니다.

구현 순서

  1. 내부 테스트 출시 성공 (결제 기능 없는 초기 버전)
  2. RevenueCat 가입 및 Play Console 서비스 계정 연동
  3. Play Console에서 구독 상품 4개 등록
  4. RevenueCat 대시보드에서 entitlement·offering 설정
  5. 앱에 purchases_flutter 초기화와 클라이언트 entitlement 체크 구현
  6. Supabase Edge Function으로 webhook 엔드포인트 작성
  7. RLS 정책에 premium_expires_at > now() 조건 추가
  8. 내부 테스터로 실제 구매·취소·갱신 시나리오 테스트