Skip to main content

Flutter 인앱 결제 가이드

작성일: 2026-04-06 Last Updated: 2026-04-06 대상: Flutter Mobile App (iOS / Android) 공통 참조: ../common/api-integration.md (API 연동), ../common/licensing.md (라이선스 관리)


목차​

  1. 인앱 결제 아키텍처
  2. RevenueCat SDK 통합
  3. 구독 모델 설계
  4. StoreKit 2 (iOS) + Google Play Billing (Android)
  5. 서버 사이드 영수증 검증 (Laravel)
  6. 구독 상태 관리
  7. 가격 테스트 / A/B 테스트
  8. 환불 처리
  9. 크로스 플랫폼 구독 동기화
  10. Introductory / Promotional Offers
  11. 체크리스트

1. 인앱 결제 아키텍처​

전체 흐름​

Flutter 앱
│
├── RevenueCat SDK (권장)
│ │
│ ├──→ App Store (StoreKit 2) ──→ Apple 서버
│ ├──→ Google Play Billing ──→ Google 서버
│ └──→ RevenueCat 서버 ──→ Webhook → Laravel 서버
│
└── 또는 in_app_purchase (직접 구현)
│
├──→ App Store / Google Play
└──→ 영수증 → Laravel 서버 → Apple/Google API 검증

RevenueCat vs 직접 구현​

항목RevenueCat직접 구현 (in_app_purchase)
구현 복잡도낮음높음
크로스 플랫폼 통합자동수동 구현
영수증 검증서버 사이드 자동직접 구현
구독 상태 추적자동 (Webhook)직접 구현
A/B 테스트내장 (Experiments)직접 구현
분석 대시보드내장직접 구현
비용MTR $2,500 이하 무료 -> 매출 1%무료
초기 스타트업강력 권장대규모 팀에 적합

권장: 초기 프로젝트는 RevenueCat. MTR이 $10,000+ 이상이고 엔지니어 리소스 충분 시 직접 구현 검토.


2. RevenueCat SDK 통합​

설치​

# pubspec.yaml
dependencies:
purchases_flutter: ^8.0.0

초기화​

// core/services/purchase_service.dart

import 'package:purchases_flutter/purchases_flutter.dart';

class PurchaseService {
static const _apiKeyIos = 'appl_xxxxxxxxxxxxx';
static const _apiKeyAndroid = 'goog_xxxxxxxxxxxxx';

/// 앱 시작 시 초기화
Future<void> initialize() async {
await Purchases.setLogLevel(LogLevel.debug); // 개발 중에만

final configuration = PurchasesConfiguration(
Platform.isIOS ? _apiKeyIos : _apiKeyAndroid,
);

await Purchases.configure(configuration);
}

/// 사용자 로그인 (서버 user_id와 연결)
Future<void> login(String userId) async {
await Purchases.logIn(userId);
}

/// 로그아웃
Future<void> logout() async {
await Purchases.logOut();
}

/// 현재 제공 가능한 상품 조회
Future<List<Offering>> getOfferings() async {
final offerings = await Purchases.getOfferings();
if (offerings.current == null) return [];
return [offerings.current!];
}

/// 구매 실행
Future<CustomerInfo> purchase(Package package) async {
final result = await Purchases.purchasePackage(package);
return result.customerInfo;
}

/// 구매 복원
Future<CustomerInfo> restorePurchases() async {
return await Purchases.restorePurchases();
}

/// 현재 구독 상태 확인
Future<bool> isProUser() async {
final customerInfo = await Purchases.getCustomerInfo();
return customerInfo.entitlements.active.containsKey('pro');
}

/// 구독 정보 스트림
Stream<CustomerInfo> get customerInfoStream {
return Purchases.customerInfoStream;
}
}

Riverpod Provider 연동​

// features/subscription/presentation/providers/subscription_provider.dart

@Riverpod(keepAlive: true)
class SubscriptionNotifier extends _$SubscriptionNotifier {
@override
Future<SubscriptionState> build() async {
// RevenueCat 고객 정보 리스닝
final purchaseService = ref.watch(purchaseServiceProvider);
purchaseService.customerInfoStream.listen((info) {
state = AsyncData(_mapCustomerInfo(info));
});

final info = await Purchases.getCustomerInfo();
return _mapCustomerInfo(info);
}

SubscriptionState _mapCustomerInfo(CustomerInfo info) {
final proEntitlement = info.entitlements.active['pro'];
return SubscriptionState(
isActive: proEntitlement != null,
plan: proEntitlement?.productIdentifier,
expiresAt: proEntitlement?.expirationDate != null
? DateTime.parse(proEntitlement!.expirationDate!)
: null,
willRenew: proEntitlement?.willRenew ?? false,
);
}

Future<void> purchase(Package package) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final info = await ref.read(purchaseServiceProvider).purchase(package);
return _mapCustomerInfo(info);
});
}

Future<void> restore() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final info = await ref.read(purchaseServiceProvider).restorePurchases();
return _mapCustomerInfo(info);
});
}
}

@freezed
class SubscriptionState with _$SubscriptionState {
const factory SubscriptionState({
required bool isActive,
String? plan,
DateTime? expiresAt,
required bool willRenew,
}) = _SubscriptionState;
}

페이월 UI​

// features/subscription/presentation/pages/paywall_page.dart

class PaywallPage extends ConsumerWidget {
const PaywallPage({super.key});

@override
Widget build(BuildContext context, WidgetRef ref) {
final offerings = ref.watch(offeringsProvider);

return Scaffold(
appBar: AppBar(title: const Text('Pro 업그레이드')),
body: offerings.when(
data: (packages) => Column(
children: [
// 혜택 설명
const _BenefitsSection(),

// 가격 옵션
Expanded(
child: ListView.builder(
itemCount: packages.length,
itemBuilder: (context, index) {
final package = packages[index];
return _PackageCard(
package: package,
onTap: () => _handlePurchase(ref, package),
);
},
),
),

// 복원 버튼
TextButton(
onPressed: () => ref.read(subscriptionProvider.notifier).restore(),
child: const Text('이전 구매 복원'),
),

// 법적 링크
const _LegalLinks(), // 이용약관, 개인정보 처리방침
],
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('오류: $e')),
),
);
}

Future<void> _handlePurchase(WidgetRef ref, Package package) async {
try {
await ref.read(subscriptionProvider.notifier).purchase(package);
// 성공 -> 자동으로 상태 업데이트 (스트림)
} on PurchasesErrorCode catch (e) {
if (e == PurchasesErrorCode.purchaseCancelledError) {
// 사용자 취소 -> 무시
return;
}
// 에러 표시
}
}
}

3. 구독 모델 설계​

플랜 구조​

플랜가격 (월)가격 (연)대상
Free$0-개인 무료 사용
Pro$9.99$79.99 (33% 할인)개인/소규모 팀
Enterprise$29.99$239.99대규모 조직

기능 매트릭스 (Entitlement)​

기능FreeProEnterprise
기본 기능OOO
프로젝트 수3개무제한무제한
팀 멤버1명10명무제한
파일 저장소100MB10GB100GB
고급 분석XOO
우선 지원XXO
API 접근XOO
SSOXXO

RevenueCat Entitlement 설정​

RevenueCat Dashboard:
├── Entitlements:
│ ├── "pro" -> Pro + Enterprise 공통 기능
│ └── "enterprise" -> Enterprise 전용 기능
│
├── Products:
│ ├── "pro_monthly" ($9.99/월) -> entitlement: "pro"
│ ├── "pro_yearly" ($79.99/년) -> entitlement: "pro"
│ ├── "ent_monthly" ($29.99/월) -> entitlements: "pro", "enterprise"
│ └── "ent_yearly" ($239.99/년) -> entitlements: "pro", "enterprise"
│
└── Offerings:
└── "default":
├── Package: "Monthly" -> pro_monthly
├── Package: "Yearly" -> pro_yearly (recommended)
└── ... (Enterprise는 별도 offering 또는 같은 offering 내)

앱에서 Entitlement 확인​

// 기능 접근 제어
@riverpod
bool hasProAccess(HasProAccessRef ref) {
final subscription = ref.watch(subscriptionProvider);
return subscription.valueOrNull?.isActive ?? false;
}

// UI에서 사용
class FeatureWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasPro = ref.watch(hasProAccessProvider);

if (!hasPro) {
return LockedFeatureCard(
onUpgrade: () => context.push('/paywall'),
);
}

return const ProFeatureContent();
}
}

4. StoreKit 2 (iOS) + Google Play Billing (Android)​

직접 구현 시 (in_app_purchase 패키지)​

# pubspec.yaml
dependencies:
in_app_purchase: ^3.2.0
in_app_purchase_storekit: ^0.3.0 # iOS 전용 기능
in_app_purchase_android: ^0.3.0 # Android 전용 기능

기본 구현​

// core/services/iap_service.dart

import 'package:in_app_purchase/in_app_purchase.dart';
import 'dart:async';

class IAPService {
final InAppPurchase _iap = InAppPurchase.instance;
late StreamSubscription<List<PurchaseDetails>> _subscription;

static const _productIds = {
'pro_monthly',
'pro_yearly',
'ent_monthly',
'ent_yearly',
};

/// 초기화
Future<void> initialize() async {
final available = await _iap.isAvailable();
if (!available) {
throw const PurchaseException('스토어 이용 불가');
}

// 구매 스트림 리스닝
_subscription = _iap.purchaseStream.listen(
_handlePurchaseUpdate,
onDone: () => _subscription.cancel(),
onError: (error) => _handlePurchaseError(error),
);

// 미완료 구매 복원 (앱 시작 시)
await _iap.restorePurchases();
}

/// 상품 정보 조회
Future<List<ProductDetails>> getProducts() async {
final response = await _iap.queryProductDetails(_productIds);

if (response.notFoundIDs.isNotEmpty) {
debugPrint('상품 미발견: ${response.notFoundIDs}');
}

return response.productDetails;
}

/// 구매 실행
Future<void> purchase(ProductDetails product) async {
final purchaseParam = PurchaseParam(productDetails: product);
await _iap.buyNonConsumable(purchaseParam: purchaseParam);
// 결과는 purchaseStream으로 전달됨
}

/// 구매 결과 처리
Future<void> _handlePurchaseUpdate(List<PurchaseDetails> purchases) async {
for (final purchase in purchases) {
switch (purchase.status) {
case PurchaseStatus.purchased:
case PurchaseStatus.restored:
// 서버에 영수증 검증 요청
final verified = await _verifyPurchase(purchase);
if (verified) {
await _iap.completePurchase(purchase);
}
break;

case PurchaseStatus.error:
_handlePurchaseError(purchase.error!);
await _iap.completePurchase(purchase);
break;

case PurchaseStatus.pending:
// 결제 대기 중 (예: 가족 승인, 느린 결제)
break;

case PurchaseStatus.canceled:
break;
}
}
}

/// 서버 사이드 영수증 검증
Future<bool> _verifyPurchase(PurchaseDetails purchase) async {
final response = await apiClient.post('/subscriptions/verify', data: {
'receipt': purchase.verificationData.serverVerificationData,
'source': purchase.verificationData.source, // 'app_store' | 'google_play'
'product_id': purchase.productID,
'purchase_id': purchase.purchaseID,
});
return response.data['verified'] == true;
}

void dispose() {
_subscription.cancel();
}
}

StoreKit 2 전용 기능 (iOS)​

// iOS 전용: 구독 관리 페이지 열기
import 'package:in_app_purchase_storekit/in_app_purchase_storekit.dart';

Future<void> openSubscriptionManagement() async {
if (Platform.isIOS) {
// iOS 15+ StoreKit 2: 앱 내 구독 관리 시트
final storeKitPlatform = InAppPurchasePlatform.instance
as InAppPurchaseStoreKitPlatform;
await storeKitPlatform.showPriceConsentIfNeeded();
} else {
// Android: Play Store 구독 관리 페이지로 이동
final url = 'https://play.google.com/store/account/subscriptions'
'?package=com.mycompany.myapp';
await launchUrl(Uri.parse(url));
}
}

5. 서버 사이드 영수증 검증 (Laravel)​

패키지 설치​

# RevenueCat Webhook 사용 시 별도 검증 불필요
# 직접 구현 시:
composer require imdhemy/laravel-purchases

RevenueCat Webhook (권장)​

// routes/api.php
Route::post('/webhooks/revenuecat', [RevenueCatWebhookController::class, 'handle'])
->middleware('verify.revenuecat.signature');

// app/Http/Controllers/Api/RevenueCatWebhookController.php

class RevenueCatWebhookController extends Controller
{
public function handle(Request $request): JsonResponse
{
$event = $request->input('event');
$appUserId = $event['app_user_id'];
$type = $event['type'];

match ($type) {
'INITIAL_PURCHASE' => $this->handleInitialPurchase($event),
'RENEWAL' => $this->handleRenewal($event),
'CANCELLATION' => $this->handleCancellation($event),
'BILLING_ISSUE' => $this->handleBillingIssue($event),
'SUBSCRIBER_ALIAS' => $this->handleAlias($event),
'EXPIRATION' => $this->handleExpiration($event),
'PRODUCT_CHANGE' => $this->handleProductChange($event),
default => null,
};

return response()->json(['status' => 'ok']);
}

private function handleInitialPurchase(array $event): void
{
$user = User::where('id', $event['app_user_id'])->first();
if (!$user) return;

$user->subscription()->updateOrCreate(
['user_id' => $user->id],
[
'plan' => $this->mapProductToPlan($event['product_id']),
'status' => 'active',
'store' => $event['store'], // 'APP_STORE' | 'PLAY_STORE'
'product_id' => $event['product_id'],
'expires_at' => Carbon::parse($event['expiration_at_ms'] / 1000),
'original_purchase_date' => Carbon::parse($event['purchased_at_ms'] / 1000),
],
);
}

private function handleExpiration(array $event): void
{
$user = User::find($event['app_user_id']);
if (!$user) return;

$user->subscription()->update([
'status' => 'expired',
'expires_at' => now(),
]);
}

private function handleBillingIssue(array $event): void
{
$user = User::find($event['app_user_id']);
if (!$user) return;

$user->subscription()->update([
'status' => 'grace_period',
]);

// 사용자에게 결제 수단 업데이트 알림
SendPushNotification::dispatch(
userId: $user->id,
title: '결제 문제 발생',
body: '구독 갱신에 문제가 발생했습니다. 결제 수단을 확인해주세요.',
data: ['route' => '/settings/subscription', 'channel' => 'system'],
);
}
}

직접 검증 시 (RevenueCat 미사용)​

// app/Services/ReceiptVerificationService.php

class ReceiptVerificationService
{
/**
* Apple App Store 영수증 검증 (App Store Server API v2)
*/
public function verifyApple(string $transactionId): VerificationResult
{
// App Store Server API v2 (JWT 기반)
$response = Http::withToken($this->generateAppleJwt())
->get("https://api.storekit.itunes.apple.com/inApps/v1/transactions/{$transactionId}");

if ($response->failed()) {
return VerificationResult::failed('Apple 검증 실패');
}

$payload = $this->decodeJWSPayload($response->body());
return VerificationResult::success($payload);
}

/**
* Google Play 영수증 검증
*/
public function verifyGoogle(string $productId, string $purchaseToken): VerificationResult
{
// Google Play Developer API v3
$response = Http::withToken($this->getGoogleAccessToken())
->get(
"https://androidpublisher.googleapis.com/androidpublisher/v3"
. "/applications/{$this->packageName}"
. "/purchases/subscriptions/{$productId}/tokens/{$purchaseToken}"
);

if ($response->failed()) {
return VerificationResult::failed('Google 검증 실패');
}

return VerificationResult::success($response->json());
}
}

6. 구독 상태 관리​

구독 상태 다이어그램​

                    ┌─────────┐
│ None │ (미구독)
└────┬────┘
│ 구매
┌────v────┐
┌───→│ Active │←──────────────────┐
│ └────┬────┘ │
│ │ │
│ 결제 실패 갱신 성공 │
│ │ │
│ ┌────v────────┐ │
│ │ Grace Period│────────────────→┘
│ └────┬────────┘
│ │ 유예 기간 만료
│ ┌────v────────┐
│ │ On Hold │ (일시중지, Android만)
│ └────┬────────┘
│ │ 시간 초과
│ ┌────v────┐
│ │ Expired │
│ └────┬────┘
│ │ 재구독
└─────────┘

상태별 앱 동작​

상태코드프리미엄 기능UI 안내
ActiveactiveO정상 표시
Grace Periodgrace_periodO"결제 수단 업데이트 필요" 배너
On Holdon_holdX"구독 일시중지" + 재활성화 안내
ExpiredexpiredX"구독 만료" + 재구독 유도
PausedpausedX"구독 일시정지" (사용자 주도)
CancelledcancelledO (만료일까지)"다음 갱신 취소됨"

DB 스키마​

// database/migrations/xxxx_create_subscriptions_table.php

Schema::create('subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('plan'); // free, pro, enterprise
$table->enum('status', [
'active', 'grace_period', 'on_hold',
'expired', 'paused', 'cancelled',
])->default('active');
$table->enum('store', ['app_store', 'play_store', 'web'])->nullable();
$table->string('product_id')->nullable(); // StoreKit/Play 상품 ID
$table->string('rc_customer_id')->nullable(); // RevenueCat 고객 ID
$table->timestamp('expires_at')->nullable();
$table->timestamp('original_purchase_date')->nullable();
$table->timestamp('cancelled_at')->nullable();
$table->timestamps();

$table->index('user_id');
$table->index('status');
});

7. 가격 테스트 / A/B 테스트​

RevenueCat Experiments​

RevenueCat Dashboard > Experiments

실험 A: "Default Paywall"
├── 변형 A (컨트롤): 월 $9.99, 연 $79.99
└── 변형 B (테스트): 월 $12.99, 연 $89.99

측정 지표:
├── Trial Conversion Rate
├── Revenue per Customer
└── Churn Rate

앱에서 실험 적용​

// RevenueCat는 Offering을 통해 자동으로 실험 적용
final offerings = await Purchases.getOfferings();
final currentOffering = offerings.current; // 실험 그룹에 따라 다른 가격 반환

// 별도 코드 변경 없이 RevenueCat 대시보드에서 관리

Apple / Google 자체 가격 테스트​

플랫폼기능설명
ApplePrice TestingApp Store Connect > Subscription Prices > Price Testing
GooglePrice ExperimentsPlay Console > Monetize > Price experiments

8. 환불 처리​

플랫폼별 환불 정책​

플랫폼환불 경로개발자 통보
AppleApple 고객 지원 (reportaproblem.apple.com)App Store Server Notification (REFUND)
GooglePlay Store 48시간 내 자동 환불 또는 고객 지원RTDN (Real-time Developer Notification)

환불 Webhook 처리​

// RevenueCat Webhook
private function handleCancellation(array $event): void
{
$user = User::find($event['app_user_id']);
if (!$user) return;

$cancellationReason = $event['cancel_reason'] ?? 'unknown';

// 환불인 경우
if (in_array($cancellationReason, ['CUSTOMER_SUPPORT', 'REFUND'])) {
$user->subscription()->update([
'status' => 'expired',
'cancelled_at' => now(),
]);

// 프리미엄 기능 즉시 비활성화
$user->update(['plan' => 'free']);

// 환불 기록
RefundLog::create([
'user_id' => $user->id,
'product_id' => $event['product_id'],
'reason' => $cancellationReason,
'amount' => $event['price'] ?? 0,
'store' => $event['store'],
]);
}
}

환불 남용 방지​

전략구현
환불 횟수 추적refund_logs 테이블에서 사용자별 환불 횟수 집계
반복 환불자 제한3회 이상 환불 시 자동 플래그, 관리자 검토
콘텐츠 접근 기록환불 전 사용 기록이 있으면 어필 근거

9. 크로스 플랫폼 구독 동기화​

동기화 시나리오​

사용자가 iOS에서 구독 -> Android에서도 Pro 기능 사용 가능해야 함
사용자가 웹에서 구독 -> iOS/Android에서도 Pro 기능 사용 가능해야 함

아키텍처​

iOS 앱  ──→ RevenueCat ──→ Webhook ──→ Laravel DB
Android ──→ RevenueCat ──→ Webhook ──→ Laravel DB
Web ──→ Stripe ──────────────────→ Laravel DB

↓
subscriptions 테이블
(source: app_store | play_store | web)
↓
API: GET /api/v1/me/subscription
↓
모든 클라이언트에서 구독 상태 확인

서버 구독 상태 API​

// app/Http/Controllers/Api/SubscriptionController.php

class SubscriptionController extends Controller
{
public function show(Request $request): JsonResponse
{
$subscription = $request->user()->subscription;

return response()->json([
'data' => [
'plan' => $subscription?->plan ?? 'free',
'status' => $subscription?->status ?? 'none',
'expires_at' => $subscription?->expires_at?->toISOString(),
'store' => $subscription?->store,
'features' => $this->getFeatures($subscription?->plan ?? 'free'),
],
]);
}

private function getFeatures(string $plan): array
{
return match ($plan) {
'enterprise' => [
'max_projects' => -1,
'max_members' => -1,
'storage_mb' => 102400,
'analytics' => true,
'api_access' => true,
'sso' => true,
'priority_support' => true,
],
'pro' => [
'max_projects' => -1,
'max_members' => 10,
'storage_mb' => 10240,
'analytics' => true,
'api_access' => true,
'sso' => false,
'priority_support' => false,
],
default => [
'max_projects' => 3,
'max_members' => 1,
'storage_mb' => 100,
'analytics' => false,
'api_access' => false,
'sso' => false,
'priority_support' => false,
],
};
}
}

Flutter에서 구독 상태 확인 (서버 기준)​

// 서버 API 기반 구독 상태 (크로스 플랫폼 통합)
@riverpod
Future<SubscriptionInfo> serverSubscription(ServerSubscriptionRef ref) async {
final repo = ref.watch(subscriptionRepositoryProvider);
return repo.getSubscription(); // GET /api/v1/me/subscription
}

웹 결제 (Stripe) + 앱 동기화​

시나리오처리 방법
웹에서 Stripe 구독Laravel에서 subscription 테이블 업데이트
앱에서 상태 확인서버 API로 구독 상태 조회
앱에서 IAP 구독RevenueCat Webhook -> Laravel 업데이트
중복 구독 방지서버에서 활성 구독 존재 시 앱 IAP 구매 버튼 비활성화

주의: Apple/Google은 자사 스토어 IAP 수수료를 부과함. 웹에서만 구독 가능하게 하면 수수료 회피로 간주되어 심사 거부 가능. 앱 내에서도 IAP 경로를 제공해야 함.


10. Introductory / Promotional Offers​

Apple Introductory Offers (자동 적용)​

Offer 유형설명적용 대상
Free Trial무료 체험구독 그룹 최초 구독자
Pay As You Go할인가 반복구독 그룹 최초 구독자
Pay Up Front할인 일시불구독 그룹 최초 구독자

Apple Promotional Offers (서버 서명 필요)​

// iOS 프로모셔널 오퍼 (이탈 방지, 윈백용)
// 서버에서 서명 생성 필요

// 1. 서버: 프로모셔널 오퍼 서명 생성
// POST /api/v1/subscriptions/promotional-offer-signature
// -> { "signature": "...", "nonce": "...", "timestamp": ..., "key_id": "..." }

// 2. 클라이언트: 서명으로 할인 구매
import 'package:in_app_purchase_storekit/store_kit_wrappers.dart';

final discount = SKPaymentDiscount(
identifier: 'promo_50_off',
keyIdentifier: signatureData['key_id'],
nonce: signatureData['nonce'],
signature: signatureData['signature'],
timestamp: signatureData['timestamp'],
);

Google Play Offers (Base Plan / Offer)​

Play Console > Monetize > Products > Subscriptions

Subscription: pro_subscription
├── Base Plan: monthly
│ ├── Offer: free-trial (7일 무료)
│ └── Offer: introductory (첫 3개월 50% 할인)
└── Base Plan: yearly
└── Offer: yearly-discount (첫해 20% 할인)

RevenueCat에서 Offer 관리​

// RevenueCat Offering에서 자동으로 적격 Offer 적용
final offering = offerings.current;
for (final package in offering!.availablePackages) {
final product = package.storeProduct;

// 무료 체험 여부 확인
final introOffer = product.introductoryPrice;
if (introOffer != null) {
print('무료 체험: ${introOffer.periodUnit} ${introOffer.periodNumberOfUnits}');
}
}

11. 체크리스트​

인앱 결제 구현 체크리스트​

#카테고리항목필수
1설정RevenueCat 프로젝트 생성 + API 키O
2설정App Store Connect IAP 상품 등록O
3설정Google Play Console 상품 등록O
4설정RevenueCat에 App Store/Play Store 연결O
5설정Entitlement + Offering 구성O
6클라이언트RevenueCat SDK 초기화O
7클라이언트사용자 로그인 연동 (Purchases.logIn)O
8클라이언트페이월 UI 구현O
9클라이언트구매 흐름 구현 + 에러 처리O
10클라이언트구매 복원 기능O (Apple 필수)
11클라이언트구독 상태에 따른 기능 잠금/해제O
12서버RevenueCat Webhook 엔드포인트O
13서버subscriptions 테이블 + 상태 관리O
14서버구독 상태 API (GET /me/subscription)O
15서버결제 실패 알림 발송 (Grace Period)권장
16동기화크로스 플랫폼 구독 동기화 (웹 + 앱)웹 결제 시
17동기화중복 구독 방지 로직O
18테스트Sandbox/테스트 환경 구매 테스트O
19테스트구독 갱신/만료/취소 시나리오 테스트O
20테스트구매 복원 테스트O
21법률이용약관 + 개인정보 처리방침 링크 (페이월)O
22법률자동 갱신 안내 문구 (Apple 필수)O
23분석RevenueCat 대시보드 모니터링 설정권장
24환불환불 Webhook 처리O

Sandbox 테스트 가이드​

플랫폼테스트 방법갱신 주기 (테스트)
iOSSandbox 테스터 계정 (App Store Connect)주간 -> 3분, 월간 -> 5분
Android라이선스 테스터 (Play Console)5분마다 갱신

Related: App Store 가이드 | Play Store 가이드 | 아키텍처 | 공통 라이선스