Flutter 앱에 정기구독 도입하기 (RevenueCat vs in_app_purchase)
이 글에서 다루는 내용
이 글에서는 Flutter 앱에 티어 2개 × 월·연 주기 조합의 구독 상품 4개를 도입하기 위해 in_app_purchase와 purchases_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
직접 구현 시 필요한 작업이 꽤 많습니다.
- 서버측 영수증 검증 (Google Play Developer API)
- 사용자별 구독 상태 DB
- Real-time Developer Notifications 수신(Pub/Sub)
- grace period·hold 처리
- 플랜 전환 시 proration 처리
- 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.kts의 com.android.billingclient:billing 명시와 resolutionStrategy.force 블록은 삭제합니다. 중복·다운그레이드 리스크를 제거하기 위함입니다.
구현 순서
- 내부 테스트 출시 성공 (결제 기능 없는 초기 버전)
- RevenueCat 가입 및 Play Console 서비스 계정 연동
- Play Console에서 구독 상품 4개 등록
- RevenueCat 대시보드에서 entitlement·offering 설정
- 앱에
purchases_flutter초기화와 클라이언트 entitlement 체크 구현 - Supabase Edge Function으로 webhook 엔드포인트 작성
- RLS 정책에
premium_expires_at > now()조건 추가 - 내부 테스터로 실제 구매·취소·갱신 시나리오 테스트