Flutter 인앱 결제 및 페이월 개요

0:02:00

RevenueCat Flutter SDK Codelab에 오신 것을 환영합니다!

목표: 이 Codelab에서는 RevenueCat Flutter SDK를 새 Flutter 애플리케이션에 연동하여 상품 Offering을 가져오고 사전 구축된 네이티브 페이월 UI를 표시하는 방법을 배웁니다.

구축할 내용:

로딩 인디케이터를 표시하고 RevenueCat에서 구성된 "Offering"을 가져온 다음 PaywallView를 표시하는 간단한 단일 화면 앱입니다.

사전 요구사항

시작하기 전에 다음이 설정되어 있어야 합니다:

  1. Flutter SDK: Flutter 개발 환경이 준비되어 있어야 합니다.
  2. RevenueCat 계정: revenuecat.com에서 무료 계정이 필요합니다.
  3. App Store / Play Store 구성:
App Store Connect 또는 Google Play Console에서 생성된 인앱 상품(구독 또는 일회성 구매). Google Play 구성에 대해 자세히 알아보려면 Codelab1: RevenueCat Google Play 연동을 확인하세요. 이 상품이 RevenueCat 대시보드 내의 EntitlementOffering과 연결되어 있어야 합니다. 이것이 가장 중요한 단계입니다. "Current" Offering이 없으면 페이월이 표시되지 않습니다.
  1. 실제 기기 또는 구성된 에뮬레이터: 인앱 결제 테스트용입니다.

이 Codelab을 완료하면, RevenueCat의 Flutter SDK를 사용하여 Flutter 앱에서 인앱 결제를 성공적으로 구현하고 동적 페이월을 표시할 수 있습니다.

overview

RevenueCat SDK 가져오기

0:05:00

인앱 결제를 구현하기 전에, 먼저 기존 프로젝트 또는 새 프로젝트에 RevenueCat SDK를 가져와야 합니다. 시작하려면 pubspec.yaml 파일에 다음 의존성을 추가하세요:

GitHub에서 최신 릴리스 버전을 확인할 수 있습니다.

yaml
dependencies:
  purchases_flutter: 9.2.3
  purchases_ui_flutter: 9.2.3

아래 이미지와 같이 해당 의존성을 추가했다면 "Pub get" 버튼을 클릭하면 필요한 의존성이 자동으로 다운로드됩니다.

dependencies

이제 RevenueCat SDK를 초기화해 보겠습니다. main.dart 파일에서 수행합니다.

  1. lib/main.dart를 열고 전체 내용을 다음 코드로 교체합니다.
  2. API 키를 붙여넣으세요 _androidApiKey_iosApiKey 상수에 붙여넣습니다.
dart
// lib/main.dart

import 'package:flutter/material.dart';
import 'package:purchases_flutter/purchases_flutter.dart';
import 'dart:io' show Platform;

// --- 여기에 REVENUECAT 키를 붙여넣으세요 ---
const String _androidApiKey = "goog_YOUR_KEY_HERE";
const String _iosApiKey = "appl_YOUR_KEY_HERE";

void main() async {
  // 비동기 플러그인 호출 전에 Flutter 위젯이 초기화되었는지 확인
  WidgetsFlutterBinding.ensureInitialized();

  // 앱을 실행하기 전에 RevenueCat SDK 초기화
  await _initializeRevenueCat();

  runApp(const MyApp());
}

Future<void> _initializeRevenueCat() async {
  // 개발용 디버그 로그 활성화
  await Purchases.setLogLevel(LogLevel.debug);

  PurchasesConfiguration? configuration;
  if (Platform.isAndroid) {
    configuration = PurchasesConfiguration(_androidApiKey);
  } else if (Platform.isIOS) {
    configuration = PurchasesConfiguration(_iosApiKey);
  } else {
    print("RevenueCat이 이 플랫폼에 대해 구성되지 않았습니다.");
    return;
  }

  try {
    await Purchases.configure(configuration);
    print("RevenueCat이 성공적으로 구성되었습니다!");
  } catch (e) {
    print("RevenueCat 구성 오류: $e");
  }
}

// ... 나머지 코드는 다음 단계에서 작성합니다 ...

async main 함수를 만들었습니다. 이를 통해 _initializeRevenueCat 함수를 await하여 UI가 빌드되기 전에 RevenueCat SDK가 구성되고 준비되었음을 보장합니다. 이것이 시작부터 사용 가능해야 하는 플러그인을 초기화하는 올바른 방법입니다.

이것으로 구현의 50%를 완료했습니다!

Entitlement 검증하기

0:03:00

이제 사용자 Entitlement를 검증하는 방법을 알아보겠습니다.

앞서 언급했듯이, Entitlement는 사용자가 구매 후 잠금 해제하는 접근 수준 또는 기능을 나타냅니다. 이를 통해 광고 배너를 표시할지 또는 프리미엄 접근 권한을 부여할지 등을 결정할 수 있습니다.

아래 코드 스니펫을 사용하여 사용자가 활성 Entitlement를 가지고 있는지 쉽게 확인할 수 있습니다:

dart
const ENTITLEMENT_IDENTIFIER = ".."; // RevenueCat 대시보드에서 특정 entitlement identifier를 가져오세요
final customerInfo = await Purchases.getCustomerInfo();
final isEntitled = customerInfo.entitlements.active[ENTITLEMENT_IDENTIFIER]?.isActive;

사용자가 특정 Entitlement를 가지고 있는지 확인한 후, 앱의 비즈니스 모델에 따라 진행 방법을 결정할 수 있습니다.

예를 들어, 앱이 광고 지원 방식이라면 AdMob 배너를 표시하거나 숨길 수 있습니다. 또는 사용자가 고급 기능이나 콘텐츠를 잠금 해제할 수 있도록 페이월이나 구매 다이얼로그를 표시할 수도 있습니다.

다음은 해당 로직을 구현하는 예시입니다:

dart
if (isEntitled == true) {
// 사용자가 이 entitlement에 접근 권한이 있으면 배너를 표시할 필요가 없습니다.
} else {
// 여기에 배너 UI를 표시하거나 페이월을 표시합니다
..
}

인앱 결제 구현하기

0:04:00

이제 광고 없는 경험을 제공하기 위한 인앱 결제를 구현해 보겠습니다. 시작하려면 먼저 RevenueCat 대시보드에서 관련 상품 정보를 가져와야 합니다. 이 상품 데이터는 사용자에게 구매 옵션을 제시하는 데 사용됩니다.

아래 예시와 같이 Purchases.sharedInstance.awaitGetProducts()를 호출하여 사용 가능한 상품을 가져올 수 있습니다:

dart
// RevenueCat 서버에서 상품 정보를 가져옵니다
final List<StoreProduct> products = await Purchases.getProducts(['paywall_tester.subs']);

// 인앱 결제를 진행합니다
final purchaseResult = await Purchases.purchaseStoreProduct(products.first);

paywall_tester.subs:weekly, paywall_tester.subs:monthly, paywall_tester.subs:yearly와 같이 여러 상품 변형을 제공하는 경우, productIds 필드에 기본 상품 식별자paywall_tester.subs를 사용하여 상품 검색을 단순화할 수 있습니다. 이렇게 하면 RevenueCat이 모든 관련 상품 변형을 목록으로 가져와 페이월 UI에서 동적으로 표시할 수 있습니다.

상품 데이터를 가져온 후, Purchases.sharedInstance.awaitPurchase(product)를 호출하여 인앱 결제 흐름을 시작할 수 있습니다. 이렇게 하면 자동으로 Google Play 결제 다이얼로그가 트리거되어 사용자가 앱 내에서 거래를 완료할 수 있습니다.

이것으로 단 몇 줄의 코드만으로 완전한 기능을 갖춘 인앱 결제 흐름을 통합했습니다. 영수증, 스토어 API 또는 구매 검증을 수동으로 처리하는 복잡함을 다룰 필요가 없습니다.

전체 예시 코드는 다음과 같습니다:

dart
import 'package:flutter/services.dart';
import 'package:purchases_flutter/purchases_flutter.dart';

/// ID로 특정 상품을 가져와 구매 흐름을 시작합니다.
///
/// 이 함수는 상품을 찾을 수 없거나, 사용자가 구매를 취소하거나,
/// 다른 스토어 오류와 같은 잠재적 오류를 처리합니다.
Future<void> purchaseProduct() async {
  // 1. 가져올 상품 식별자를 정의합니다.
  const String productId = 'paywall_tester.subs';

  try {
    // 2. RevenueCat에서 StoreProduct를 가져옵니다
    print('상품 가져오는 중...');
    final List<StoreProduct> products = await Purchases.getProducts([productId]);

    // 3. 상품 목록이 비어 있지 않은지 확인합니다
    if (products.isEmpty) {
      print('오류: 상품을 찾을 수 없습니다. ID와 RevenueCat 설정을 확인하세요.');
      // 선택적으로 사용자에게 오류 메시지를 표시합니다
      return;
    }

    final StoreProduct productToPurchase = products.first;
    print('상품 찾음: ${productToPurchase.title}. 구매 시작 중...');

    // 4. 구매 흐름을 시작합니다
    final PurchaseResult purchaseResult = await Purchases.purchaseStoreProduct(productToPurchase);

    // 5. entitlement를 확인하여 구매가 성공했는지 확인합니다
    // "your_premium_entitlement"를 RevenueCat의 실제 entitlement identifier로 교체하세요
    if (purchaseResult.customerInfo.entitlements.all["your_premium_entitlement"]?.isActive ?? false) {
      print('구매 성공! 사용자가 이제 프리미엄 접근 권한을 갖게 되었습니다.');
      // 프리미엄 콘텐츠에 대한 접근 권한을 부여합니다
    } else {
      print('구매가 완료되었지만 entitlement가 활성화되지 않았습니다.');
    }
  } on PlatformException catch (e) {
    // 6. 잠재적 오류를 처리합니다
    final PurchasesErrorCode error = PurchasesErrorHelper.getErrorCode(e);
    if (error == PurchasesErrorCode.purchaseCancelledError) {
      print('사용자가 구매를 취소했습니다.');
    } else {
      print('구매 실패 오류: ${e.message}');
    }
  } catch (e) {
    print('예상치 못한 오류가 발생했습니다: $e');
  }
}

페이월 구현하기

0:07:00

이제 Dart에서 Paywall을 구현할 차례입니다.

1단계: 앱 셸 만들기

이제 기본 Flutter 앱 구조를 추가해 보겠습니다. 여기에는 MyApp 위젯과 최종적으로 페이월을 보유할 stateful MyHomePage 위젯이 포함됩니다.

lib/main.dart 파일에 다음 코드를 추가하세요:

dart
// lib/main.dart (계속)

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'RevenueCat Paywall Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'RevenueCat Paywall Demo'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});
  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

// ... State 클래스는 다음 단계에서 작성합니다 ...

2단계: Offering 가져오기 및 상태 관리

이것이 핵심 로직입니다. _MyHomePageState에서 RevenueCat으로부터 Offering을 가져오고 로딩 및 오류 상태를 관리합니다.

// ... State 클래스는... 주석을 아래의 전체 _MyHomePageState 클래스로 교체하세요:

dart
// lib/main.dart (계속)

class _MyHomePageState extends State<MyHomePage> {
  // 상태 변수
  Offering? _offering;
  bool _isLoading = true;
  String? _errorMessage;

  @override
  void initState() {
    super.initState();
    // 페이지가 로드되자마자 offering을 가져옵니다
    _loadOfferings();
  }

  Future<void> _loadOfferings() async {
    // setState를 호출하기 전에 위젯이 여전히 마운트되어 있는지 확인해야 합니다
    if (!mounted) return;

    setState(() {
      _isLoading = true;
      _errorMessage = null; // 이전 오류를 지웁니다
    });

    try {
      // RevenueCat에서 현재 offering을 가져옵니다
      final offerings = await Purchases.getOfferings();

      // 비동기 호출 후 위젯이 여전히 마운트되어 있는지 확인합니다
      if (!mounted) return;

      setState(() {
        _offering = offerings.current;
        _isLoading = false;
        if (_offering == null) {
          _errorMessage = "현재 offering을 찾을 수 없습니다. RevenueCat 대시보드를 확인하세요.";
        }
      });
    } catch (e) {
      if (!mounted) return;
      setState(() {
        _isLoading = false;
        _errorMessage = "offering 로드 실패: ${e.toString()}";
      });
    }
  }

  // build 메서드는 마지막 단계에서 작성합니다...
  @override
  Widget build(BuildContext context) {
    // 지금은 플레이스홀더입니다
    return Scaffold(
      appBar: AppBar(title: Text(widget.title)),
      body: const Center(child: Text("거의 다 됐습니다!")),
    );
  }
}
방금 무엇을 했나요?
  1. 스피너를 표시하기 위한 _isLoading, 오류를 표시하기 위한 _errorMessage, 페이월의 데이터를 보유하기 위한 _offering이라는 세 가지 상태 변수를 만들었습니다.
  2. initState에서 데이터 가져오기 프로세스를 즉시 시작하기 위해 _loadOfferings()를 호출했습니다.
  3. _loadOfferings 메서드는 Purchases.getOfferings() 호출을 try...catch 블록으로 감쌉니다. 로더를 표시/숨기고 가져온 offering 또는 오류 메시지를 저장하도록 상태를 업데이트합니다.

3단계: PaywallView 표시하기

마지막으로, 상태 변수에 따라 올바른 UI를 조건부로 표시하도록 build 메서드를 업데이트해 보겠습니다. Offering이 성공적으로 로드되면 PaywallView를 표시합니다.

_MyHomePageStatebuild 메서드를 이 완전한 버전으로 교체하세요:

dart
// lib/main.dart (_MyHomePageState 내)

@override
Widget build(BuildContext context) {
  Widget content;

  // offering을 가져오는 동안 로더를 표시합니다
  if (_isLoading) {
    content = const Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        CircularProgressIndicator(),
        SizedBox(height: 20),
        Text("페이월 로딩 중..."),
      ],
    );
  }
  // 문제가 발생하면 오류 메시지를 표시합니다
  else if (_errorMessage != null) {
    content = Padding(
      padding: const EdgeInsets.all(16.0),
      child: Text(
        _errorMessage!,
        textAlign: TextAlign.center,
        style: const TextStyle(color: Colors.red, fontSize: 16),
      ),
    );
  }
  // offering이 로드되면 PaywallView를 표시합니다
  else if (_offering != null) {
    content = PaywallView(
      offering: _offering!,
      onDismiss: () => Navigator.pop(context),
      onPurchaseCompleted: (customerInfo) {
        print("entitlement에 대한 구매가 완료되었습니다: ${customerInfo.entitlements.active.keys.first}");
        // 여기서 닫거나 다른 곳으로 이동할 수 있습니다
      },
      onPurchaseError: (error) {
        print("구매 오류: ${error.message}");
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text("구매 실패: ${error.message}")),
        );
      },
      onRestoreCompleted: (customerInfo) {
        print("복원 완료. 활성 entitlement: ${customerInfo.entitlements.active.length}");
        if (customerInfo.entitlements.active.isNotEmpty) {
           Navigator.pop(context);
        }
      },
    );
  }
  // 가능성이 낮은 경우를 위한 대비책
  else {
    content = const Text("사용 가능한 offering이 없습니다.");
  }

  return Scaffold(
    appBar: AppBar(
      backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      title: Text(widget.title),
    ),
    body: Center(
      child: content,
    ),
  );
}

완료되었습니다!

이제 기기나 에뮬레이터에서 애플리케이션을 실행하세요.

sh
flutter run
다음과 같이 표시되어야 합니다:
  1. 스피너와 함께 "페이월 로딩 중..." 메시지가 표시됩니다.
  2. 그 다음 아래 이미지와 같이 완전히 작동하는 네이티브 페이월 UI가 표시됩니다.
result

문제 해결

페이월이 표시되지 않으면 디버그 콘솔을 확인하고 다음과 같은 일반적인 문제를 검토하세요: "현재 offering을 찾을 수 없습니다": 가장 일반적인 오류입니다. RevenueCat 대시보드에 "current" offering이 없다는 의미입니다. Offerings로 이동하여 offering을 선택하고 "current"로 표시되어 있는지 확인하세요. 잘못된 API 키: 테스트 중인 플랫폼에 대한 올바른 public API 키를 복사했는지 다시 확인하세요. 상품이 구성되지 않음: 스토어 콘솔의 인앱 상품이 올바르게 설정되어 있고 RevenueCat에서 entitlement 및 offering과 연결되어 있는지 확인하세요. Sandbox/테스트 사용자 문제: 샌드박스 테스터 계정(iOS)으로 로그인했거나 이메일이 라이선스 테스터로 추가되었는지(Android) 확인하세요.

구성 완료입니다! 이제 사용자가 필요한 Entitlement를 가지고 있지 않을 때마다 Paywall Editor에서 구성한 것과 동일한 디자인으로 페이월을 표시할 수 있습니다.

Codelab: RevenueCat Google Play 연동 (페이월 생성)에서 이미 보셨듯이, 페이월 시스템은 서버 기반 UI로 구축되어 있습니다. 이는 앱 업데이트를 푸시하거나 리뷰 프로세스를 거치지 않고도 대시보드에서 직접 페이월의 콘텐츠와 디자인을 동적으로 업데이트할 수 있다는 것을 의미합니다.

마무리

이 Codelab에서는 RevenueCat의 Flutter SDK를 연동하고, 인앱 결제를 구현하고, Flutter에서 페이월을 구축하는 방법을 배웠습니다. 이제 앱을 출시하고 더 많은 수익을 올릴 시간입니다!

아래 리소스를 통해 RevenueCat SDK 사용에 대해 더 자세히 알아볼 수 있습니다:

  • 제품 튜토리얼: RevenueCat을 시작하고 최대한 활용하는 데 도움이 되는 비디오 튜토리얼입니다.