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 가이드 | 아키텍처 | 공통 라이선스