Flutter 앱에 인앱 구매 추가

Flutter 앱에 인앱 구매 추가

이 Codelab 정보

subject최종 업데이트: 1월 23, 2025
account_circle작성자: Rene Floor, Bodhi Mulders, Jop Middelkamp, Miguel Beltran

1. 소개

Flutter 앱에 인앱 구매를 추가하려면 App Store 및 Play 스토어를 올바르게 설정하고, 구매를 인증하고, 구독 혜택과 같은 필요한 권한을 부여해야 합니다.

이 Codelab에서는 앱에 세 가지 유형의 인앱 구매를 추가하고 (제공됨) Firebase와 함께 Dart 백엔드를 사용하여 이러한 구매를 확인합니다. 제공된 앱인 Dash Clicker에는 Dash 마스코트를 통화로 사용하는 게임이 포함되어 있습니다. 다음과 같은 구매 옵션을 추가합니다.

  1. 한 번에 2,000개의 Dash를 구매할 수 있는 반복 구매 옵션입니다.
  2. 기존 스타일의 대시를 최신 스타일의 대시로 업그레이드하는 일회성 구매입니다.
  3. 자동 생성된 클릭수를 두 배로 늘리는 구독입니다.

첫 번째 구매 옵션은 사용자에게 2,000개의 Dash를 직접 제공합니다. 사용자는 직접 구매할 수 있으며 여러 번 구매할 수 있습니다. 이는 직접 소비되고 여러 번 소비될 수 있으므로 소모성이라고 합니다.

두 번째 옵션은 Dash를 더 멋진 Dash로 업그레이드합니다. 한 번만 구매하면 영구적으로 사용할 수 있습니다. 이러한 구매는 앱에서 사용할 수 없지만 영구적으로 유효하므로 비소모성이라고 합니다.

세 번째이자 마지막 구매 옵션은 정기 결제입니다. 정기 결제가 활성 상태인 동안에는 사용자가 더 빨리 대시를 받을 수 있지만 정기 결제 요금을 더 이상 지불하지 않으면 혜택도 사라집니다.

백엔드 서비스 (개발자도 제공)는 Dart 앱으로 실행되며 구매가 이루어졌는지 확인하고 Firestore를 사용하여 저장합니다. Firestore는 이 프로세스를 더 쉽게 하는 데 사용되지만 프로덕션 앱에서는 어떤 유형의 백엔드 서비스든 사용할 수 있습니다.

300123416ebc8dc1.png 7145d0fffe6ea741.png 646317a79be08214.png

빌드할 항목

  • 소모성 구매 및 정기 결제를 지원하도록 앱을 확장합니다.
  • 또한 Dart 백엔드 앱을 확장하여 구매한 항목을 확인하고 저장합니다.

학습 내용

  • 구매 가능한 제품으로 App Store 및 Play 스토어를 구성하는 방법
  • 스토어와 통신하여 구매를 확인하고 Firestore에 저장하는 방법
  • 앱에서 구매를 관리하는 방법

필요한 항목

  • Android 스튜디오 4.1 이상
  • Xcode 12 이상(iOS 개발용)
  • Flutter SDK

2. 개발 환경 설정

이 Codelab을 시작하려면 코드를 다운로드하고 iOS의 번들 식별자와 Android의 패키지 이름을 변경합니다.

코드 다운로드

명령줄에서 GitHub 저장소를 클론하려면 다음 명령어를 사용합니다.

git clone https://github.com/flutter/codelabs.git flutter-codelabs

또는 GitHub의 CLI 도구가 설치되어 있으면 다음 명령어를 사용합니다.

gh repo clone flutter/codelabs flutter-codelabs

샘플 코드는 Codelab 모음의 코드가 포함된 flutter-codelabs 디렉터리에 클론됩니다. 이 Codelab의 코드는 flutter-codelabs/in_app_purchases에 있습니다.

flutter-codelabs/in_app_purchases 아래의 디렉터리 구조에는 각 이름이 지정된 단계의 끝에 있어야 하는 위치의 일련의 스냅샷이 포함되어 있습니다. 시작 코드는 0단계에 있으므로 일치하는 파일을 찾는 방법은 다음과 같이 간단합니다.

cd flutter-codelabs/in_app_purchases/step_00

앞으로 건너뛰거나 한 단계를 완료한 후의 결과를 보려면 관심 있는 단계의 이름을 따서 지정된 디렉터리를 살펴보세요. 마지막 단계의 코드는 complete 폴더에 있습니다.

시작 프로젝트 설정

원하는 IDE에서 step_00의 시작 프로젝트를 엽니다. 스크린샷을 위해 Android 스튜디오를 사용했지만 Visual Studio Code도 좋은 옵션입니다. 두 편집기 중 하나를 사용하여 최신 Dart 및 Flutter 플러그인이 설치되어 있는지 확인합니다.

만들려는 앱은 App Store 및 Play 스토어와 통신하여 어떤 제품을 어떤 가격에 구매할 수 있는지 확인해야 합니다. 모든 앱은 고유한 ID로 식별됩니다. iOS App Store의 경우 번들 식별자라고 하며 Android Play 스토어의 경우 애플리케이션 ID라고 합니다. 이러한 식별자는 일반적으로 역방향 도메인 이름 표기법을 사용하여 만들어집니다. 예를 들어 flutter.dev용 인앱 구매 앱을 만들 때는 dev.flutter.inapppurchase를 사용합니다. 앱의 식별자를 생각해 보세요. 이제 프로젝트 설정에서 이를 설정할 것입니다.

먼저 iOS의 번들 식별자를 설정합니다.

Android 스튜디오에서 프로젝트를 연 상태로 iOS 폴더를 마우스 오른쪽 버튼으로 클릭하고 Flutter를 클릭한 다음 Xcode 앱에서 모듈을 엽니다.

942772eb9a73bfaa.png

Xcode의 폴더 구조에서 Runner 프로젝트가 맨 위에 있고 Flutter, Runner, Products 타겟이 Runner 프로젝트 아래에 있습니다. Runner를 더블클릭하여 프로젝트 설정을 수정한 다음 서명 및 기능을 클릭합니다. 필드에서 방금 선택한 번들 식별자를 입력하여 팀을 설정합니다.

812f919d965c649a.jpeg

이제 Xcode를 닫고 Android 스튜디오로 돌아가 Android 구성을 완료할 수 있습니다. 이렇게 하려면 android/app, 아래의 build.gradle 파일을 열고 applicationId (아래 스크린샷의 37번 줄)를 iOS 번들 식별자와 동일한 애플리케이션 ID로 변경합니다. iOS 스토어와 Android 스토어의 ID가 동일할 필요는 없지만 동일하게 유지하면 오류가 발생할 가능성이 줄어들므로 이 Codelab에서는 동일한 식별자를 사용합니다.

5c4733ac560ae8c2.png

3. 플러그인 설치

이 Codelab에서는 in_app_purchase 플러그인을 설치합니다.

pubspec에 종속 항목 추가

pubspec의 종속 항목에 in_app_purchase를 추가하여 pubspec에 in_app_purchase를 추가합니다.

$ cd app
$ flutter pub add in_app_purchase dev:in_app_purchase_platform_interface

pubspec.yaml을 열고 dependencies 아래에 in_app_purchase가, dev_dependencies 아래에 in_app_purchase_platform_interface가 항목으로 표시되는지 확인합니다.

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  cloud_firestore: ^5.5.1
  cupertino_icons: ^1.0.8
  firebase_auth: ^5.3.4
  firebase_core: ^3.8.1
  google_sign_in: ^6.2.2
  http: ^1.2.2
  intl: ^0.20.1
  provider: ^6.1.2
  in_app_purchase: ^3.2.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^4.0.0
  in_app_purchase_platform_interface: ^1.4.0

pub get을 클릭하여 패키지를 다운로드하거나 명령줄에서 flutter pub get을 실행합니다.

4. App Store 설정

iOS에서 인앱 구매를 설정하고 테스트하려면 App Store에서 새 앱을 만들고 구매 가능한 제품을 만들어야 합니다. 검토를 위해 게시하거나 앱을 Apple에 전송할 필요가 없습니다. 이 작업을 하려면 개발자 계정이 필요합니다. 계정이 없는 경우 Apple 개발자 프로그램에 등록하세요.

인앱 구매를 사용하려면 App Store Connect에서 유료 앱에 대한 활성 계약도 있어야 합니다. https://appstoreconnect.apple.com/으로 이동하여 계약, 세금, 은행을 클릭합니다.

11db9fca823e7608.png

여기에서 무료 및 유료 앱에 대한 계약을 확인할 수 있습니다. 무료 앱의 상태는 활성 상태여야 하며 유료 앱의 상태는 신규 상태여야 합니다. 약관을 확인하고 동의한 후 필요한 정보를 모두 입력하세요.

74c73197472c9aec.png

모든 설정이 올바르게 되면 유료 앱의 상태가 활성 상태로 표시됩니다. 활성 상태의 계약이 없으면 인앱 구매를 사용해 볼 수 없으므로 이 부분이 매우 중요합니다.

4a100bbb8cafdbbf.jpeg

앱 ID 등록

Apple 개발자 포털에서 새 식별자를 만듭니다.

55d7e592d9a3fc7b.png

앱 ID 선택

13f125598b72ca77.png

앱 선택

41ac4c13404e2526.png

설명을 입력하고 번들 ID를 XCode에서 이전에 설정한 것과 동일한 값으로 설정합니다.

9d2c940ad80deeef.png

새 앱 ID를 만드는 방법에 관한 자세한 내용은 개발자 계정 도움말을 참고하세요 .

새 앱 만들기

App Store Connect에서 고유한 번들 식별자로 새 앱을 만듭니다.

10509b17fbf031bd.png

5b7c0bb684ef52c7.png

새 앱을 만들고 계약을 관리하는 방법에 관한 자세한 내용은 App Store Connect 도움말을 참고하세요.

인앱 구매를 테스트하려면 샌드박스 테스트 사용자가 필요합니다. 이 테스트 사용자는 iTunes에 연결되어서는 안 됩니다. 인앱 구매를 테스트하는 데만 사용됩니다. 이미 Apple 계정에 사용 중인 이메일 주소는 사용할 수 없습니다. 사용자 및 액세스샌드박스 아래에 있는 테스터로 이동하여 새 샌드박스 계정을 만들거나 기존 샌드박스 Apple ID를 관리합니다.

3ca2b26d4e391a4c.jpeg

이제 설정 > App Store > 샌드박스 계정으로 이동하여 iPhone에서 샌드박스 사용자를 설정할 수 있습니다.

d99e0b89673867cd.jpeg e1621bcaeb33d3c5.jpeg

인앱 구매 구성하기

이제 구매 가능한 세 가지 항목을 구성합니다.

  • dash_consumable_2k: 여러 번 구매할 수 있는 소비성 구매로, 구매할 때마다 사용자에게 2,000개의 Dash (인앱 통화)가 부여됩니다.
  • dash_upgrade_3d: 한 번만 구매할 수 있으며 사용자에게 클릭할 수 있는 외관상 다른 대시를 제공하는 비소비성 '업그레이드' 구매입니다.
  • dash_subscription_doubler: 구독 기간 동안 사용자에게 클릭당 두 배의 대시를 부여하는 구독입니다.

d156b2f5bac43ca8.png

인앱 구매 > 관리로 이동합니다.

지정된 ID로 인앱 구매를 만듭니다.

  1. dash_consumable_2k소모성으로 설정합니다.

dash_consumable_2k를 제품 ID로 사용합니다. 참조 이름은 App Store Connect에서만 사용됩니다. dash consumable 2k로 설정하고 구매의 현지화를 추가하기만 하면 됩니다. 2000 dashes fly out을 설명으로 사용하여 구매 Spring is in the air를 호출합니다.

ec1701834fd8527.png

  1. dash_upgrade_3d비소비성으로 설정합니다.

dash_upgrade_3d를 제품 ID로 사용합니다. 참조 이름을 dash upgrade 3d로 설정하고 구매의 현지화를 추가합니다. Brings your dash back to the future을 설명으로 사용하여 구매 3D Dash를 호출합니다.

6765d4b711764c30.png

  1. dash_subscription_doubler자동 갱신 정기 결제로 설정합니다.

구독 흐름은 약간 다릅니다. 먼저 참조 이름과 제품 ID를 설정해야 합니다.

6d29e08dae26a0c4.png

다음으로 구독 그룹을 만들어야 합니다. 여러 정기 결제가 동일한 그룹에 속하는 경우 사용자는 한 번에 하나의 정기 결제만 구독할 수 있지만 이러한 정기 결제 간에 쉽게 업그레이드하거나 다운그레이드할 수 있습니다. 이 그룹을 subscriptions라고 부르세요.

5bd0da17a85ac076.png

그런 다음 구독 기간과 현지화를 입력합니다. 이 구독의 이름을 Jet Engine로 지정하고 설명을 Doubles your clicks로 지정합니다. 저장을 클릭합니다.

bd1b1d82eeee4cb3.png

저장 버튼을 클릭한 후 정기 결제 가격을 추가합니다. 원하는 가격을 선택합니다.

d0bf39680ef0aa2e.png

이제 구매 목록에 세 건의 구매 내역이 표시됩니다.

99d5c4b446e8fecf.png

5. Play 스토어 설정

App Store와 마찬가지로 Play 스토어에도 개발자 계정이 필요합니다. 아직 계정이 없는 경우 계정을 등록하세요.

새 앱 만들기

Google Play Console에서 새 앱을 만듭니다.

  1. Play Console을 엽니다.
  2. 모든 앱 > 앱 만들기를 선택합니다.
  3. 기본 언어를 선택하고 앱 제목을 추가합니다. Google Play에 표시하려는 앱 이름을 입력하세요. 이름은 나중에 변경할 수 있습니다.
  4. 애플리케이션이 게임이라고 지정합니다. 나중에 변경할 수 있습니다.
  5. 애플리케이션이 무료인지 유료인지 지정합니다.
  6. Play 스토어 사용자가 이 애플리케이션과 관련해 연락할 수 있는 이메일 주소를 추가합니다.
  7. 콘텐츠 가이드라인 및 미국 수출법 선언을 작성합니다.
  8. 앱 만들기를 선택합니다.

앱이 생성되면 대시보드로 이동하여 앱 설정 섹션의 모든 작업을 완료합니다. 여기에서 콘텐츠 등급, 스크린샷 등 앱에 관한 몇 가지 정보를 제공합니다. 13845badcf9bc1db.png

애플리케이션 서명하기

인앱 구매를 테스트하려면 Google Play에 업로드된 빌드가 하나 이상 있어야 합니다.

이를 위해서는 출시 빌드가 디버그 키가 아닌 다른 것으로 서명되어야 합니다.

키 저장소 만들기

기존 키 저장소가 있는 경우 다음 단계로 건너뜁니다. 그렇지 않은 경우 명령줄에서 다음을 실행하여 하나 만듭니다.

Mac/Linux에서는 다음 명령어를 사용합니다.

keytool -genkey -v -keystore ~/key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias key

Windows의 경우 다음 명령어를 사용합니다.

keytool -genkey -v -keystore c:\Users\USER_NAME\key.jks -storetype JKS -keyalg RSA -keysize 2048 -validity 10000 -alias key

이 명령어는 key.jks 파일을 홈 디렉터리에 저장합니다. 파일을 다른 위치에 저장하려면 -keystore 매개변수에 전달하는 인수를 변경합니다. 계속 유지

keystore

비공개 파일로 저장하고 공개 소스 제어에 체크인하지 마세요.

앱에서 키 저장소 참조

키 저장소 참조가 포함된 <your app dir>/android/key.properties이라는 파일을 만듭니다.

storePassword=<password from previous step>
keyPassword
=<password from previous step>
keyAlias
=key
storeFile
=<location of the key store file, such as /Users/<user name>/key.jks>

Gradle에서 서명 구성

<your app dir>/android/app/build.gradle 파일을 수정하여 앱의 서명을 구성합니다.

속성 파일의 키 저장소 정보를 android 블록 앞에 추가합니다.

   def keystoreProperties = new Properties()
   def keystorePropertiesFile = rootProject.file('key.properties')
   if (keystorePropertiesFile.exists()) {
       keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
   }

   android {
         // omitted
   }

key.properties 파일을 keystoreProperties 객체로 로드합니다.

buildTypes 블록 앞에 다음 코드를 추가합니다.

   buildTypes {
       release
{
           
// TODO: Add your own signing config for the release build.
           
// Signing with the debug keys for now,
           
// so `flutter run --release` works.
           signingConfig signingConfigs
.debug
       
}
   
}

서명 구성 정보로 모듈의 build.gradle 파일에서 signingConfigs 블록을 구성합니다.

   signingConfigs {
       release
{
           keyAlias keystoreProperties
['keyAlias']
           keyPassword keystoreProperties
['keyPassword']
           storeFile keystoreProperties
['storeFile'] ? file(keystoreProperties['storeFile']) : null
           storePassword keystoreProperties
['storePassword']
       
}
   
}
   buildTypes
{
       release
{
           signingConfig signingConfigs
.release
       
}
   
}

이제 앱의 출시 빌드가 자동으로 서명됩니다.

앱 서명에 관한 자세한 내용은 developer.android.com앱 서명을 참고하세요.

첫 빌드 업로드

앱이 서명되도록 구성되면 다음을 실행하여 애플리케이션을 빌드할 수 있습니다.

flutter build appbundle

이 명령어는 기본적으로 출시 빌드를 생성하며 출력은 <your app dir>/build/app/outputs/bundle/release/에서 확인할 수 있습니다.

Google Play Console의 대시보드에서 출시 > 테스트 > 비공개 테스트로 이동하여 새 비공개 테스트 버전을 만듭니다.

이 Codelab에서는 Google에서 앱에 서명하도록 하므로 Play 앱 서명에서 계속을 눌러 선택합니다.

ba98446d9c5c40e0.png

그런 다음 빌드 명령어로 생성된 app-release.aab App Bundle을 업로드합니다.

저장을 클릭한 다음 버전 검토를 클릭합니다.

마지막으로 내부 테스트로 출시 시작을 클릭하여 내부 테스트 버전을 활성화합니다.

테스트 사용자 설정하기

인앱 구매를 테스트하려면 테스터의 Google 계정을 다음 두 위치에 Google Play Console에 추가해야 합니다.

  1. 특정 테스트 트랙 (내부 테스트)
  2. 라이선스 테스터로서

먼저 테스터를 내부 테스트 트랙에 추가합니다. 출시 > 테스트 > 내부 테스트로 돌아가서 테스터 탭을 클릭합니다.

a0d0394e85128f84.png

이메일 목록 만들기를 클릭하여 새 이메일 목록을 만듭니다. 목록에 이름을 지정하고 인앱 구매 테스트에 액세스해야 하는 Google 계정의 이메일 주소를 추가합니다.

그런 다음 목록의 체크박스를 선택하고 변경사항 저장을 클릭합니다.

그런 다음 라이선스 테스터를 추가합니다.

  1. Google Play Console의 모든 앱 뷰로 돌아갑니다.
  2. 설정 > 라이선스 테스트로 이동합니다.
  3. 인앱 구매를 테스트해야 하는 테스터의 동일한 이메일 주소를 추가합니다.
  4. 라이선스 응답RESPOND_NORMALLY로 설정합니다.
  5. 변경사항 저장을 클릭합니다.

a1a0f9d3e55ea8da.png

인앱 구매 구성하기

이제 앱 내에서 구매할 수 있는 상품을 구성합니다.

App Store와 마찬가지로 다음 세 가지 구매를 정의해야 합니다.

  • dash_consumable_2k: 여러 번 구매할 수 있는 소비성 구매로, 구매할 때마다 사용자에게 2,000개의 Dash (인앱 통화)가 부여됩니다.
  • dash_upgrade_3d: 한 번만 구매할 수 있는 비소비성 '업그레이드' 구매로, 사용자에게 클릭할 수 있는 외관상 다른 대시를 제공합니다.
  • dash_subscription_doubler: 구독 기간 동안 사용자에게 클릭당 두 배의 대시를 부여하는 구독입니다.

먼저 소모성 및 비소모성 항목을 추가합니다.

  1. Google Play Console로 이동하여 애플리케이션을 선택합니다.
  2. 수익 창출 > 제품 > 인앱 상품으로 이동합니다.
  3. 제품 만들기를 클릭합니다.c8d66e32f57dee21.png
  4. 제품에 대한 모든 필수 정보를 입력합니다. 제품 ID가 사용하려는 ID와 정확히 일치하는지 확인합니다.
  5. 저장을 클릭합니다.
  6. 활성화를 클릭합니다.
  7. 비소비성 '업그레이드' 구매에 대해 이 절차를 반복합니다.

다음으로 구독을 추가합니다.

  1. Google Play Console로 이동하여 애플리케이션을 선택합니다.
  2. 수익 창출 > 제품 > 구독으로 이동합니다.
  3. 구독 만들기를 클릭합니다.32a6a9eefdb71dd0.png
  4. 정기 결제에 필요한 모든 정보를 입력합니다. 제품 ID가 사용하려는 ID와 정확하게 일치하는지 확인합니다.
  5. 저장을 클릭합니다.

이제 Play Console에서 구매 항목이 설정됩니다.

6. Firebase 설정

이 Codelab에서는 백엔드 서비스를 사용하여 사용자의 구매를 확인하고 추적합니다.

백엔드 서비스를 사용하면 다음과 같은 여러 이점이 있습니다.

  • 거래를 안전하게 확인할 수 있습니다.
  • 앱 스토어의 결제 이벤트에 반응할 수 있습니다.
  • 데이터베이스에서 구매를 추적할 수 있습니다.
  • 사용자는 시스템 시계를 되감아 앱이 프리미엄 기능을 제공하도록 속일 수 없습니다.

백엔드 서비스를 설정하는 방법에는 여러 가지가 있지만, 여기서는 Google 자체 Firebase를 사용하여 Cloud 함수와 Firestore를 사용해 설정합니다.

백엔드 작성은 이 Codelab의 범위에 해당하지 않으므로 시작 코드에는 이미 기본 구매를 처리하는 Firebase 프로젝트가 포함되어 있어 시작할 수 있습니다.

Firebase 플러그인도 시작 앱에 포함되어 있습니다.

이제 나만의 Firebase 프로젝트를 만들고 Firebase용 앱과 백엔드를 모두 구성한 후 백엔드를 배포하면 됩니다.

Firebase 프로젝트 만들기

Firebase Console로 이동하여 새 Firebase 프로젝트를 만듭니다. 이 예에서는 프로젝트 이름을 Dash Clicker로 지정합니다.

백엔드 앱에서는 구매를 특정 사용자에게 연결하므로 인증이 필요합니다. 이를 위해 Google 로그인을 사용하는 Firebase의 인증 모듈을 활용합니다.

  1. Firebase 대시보드에서 인증으로 이동하여 필요한 경우 사용 설정합니다.
  2. 로그인 방법 탭으로 이동하여 Google 로그인 제공업체를 사용 설정합니다.

7babb48832fbef29.png

Firebase의 Firestore 데이터베이스도 사용하므로 이 데이터베이스도 사용 설정합니다.

e20553e0de5ac331.png

다음과 같이 Cloud Firestore 규칙을 설정합니다.

rules_version = '2';
service cloud
.firestore {
  match
/databases/{database}/documents {
    match
/purchases/{purchaseId} {
      allow read
: if request.auth != null && request.auth.uid == resource.data.userId
   
}
 
}
}

Flutter용 Firebase 설정

Flutter 앱에 Firebase를 설치하는 권장 방법은 FlutterFire CLI를 사용하는 것입니다. 설정 페이지에 설명된 안내를 따릅니다.

flutterfire configure를 실행할 때는 이전 단계에서 방금 만든 프로젝트를 선택합니다.

$ flutterfire configure

i
Found 5 Firebase projects.                                                                                                  
? Select a Firebase project to configure your Flutter application with                                                      
in-app-purchases-1234 (in-app-purchases-1234)                                                                        
  other-flutter-codelab-1 (other-flutter-codelab-1)                                                                          
  other-flutter-codelab-2 (other-flutter-codelab-2)                                                                      
  other-flutter-codelab-3 (other-flutter-codelab-3)                                                                          
  other-flutter-codelab-4 (other-flutter-codelab-4)                                                                                                                                                              
  <create a new project>  

그런 다음 두 플랫폼을 선택하여 iOSAndroid를 사용 설정합니다.

? Which platforms should your configuration support (use arrow keys & space to select)?                                      
android                                                                                                                    
ios                                                                                                                        
  macos                                                                                                                      
  web                                                                                                                          

firebase_options.dart 재정의에 관한 메시지가 표시되면 예를 선택합니다.

? Generated FirebaseOptions file lib/firebase_options.dart already exists, do you want to override it? (y/n)  yes                                                                                                                         

Android용 Firebase 설정: 추가 단계

Firebase 대시보드에서 프로젝트 개요로 이동하여 설정을 선택하고 일반 탭을 선택합니다.

내 앱까지 아래로 스크롤하고 dashclicker (android) 앱을 선택합니다.

b22d46a759c0c834.png

디버그 모드에서 Google 로그인을 허용하려면 디버그 인증서의 SHA-1 해시 지문을 제공해야 합니다.

디버그 서명 인증서 해시 가져오기

Flutter 앱 프로젝트의 루트에서 디렉터리를 android/ 폴더로 변경한 다음 서명 보고서를 생성합니다.

cd android
./gradlew :app:signingReport

서명 키 목록이 대량으로 표시됩니다. 디버그 인증서의 해시를 찾으므로 VariantConfig 속성이 debug로 설정된 인증서를 찾습니다. 키 저장소는 .android/debug.keystore 아래의 홈 폴더에 있을 가능성이 큽니다.

> Task :app:signingReport
Variant: debug
Config: debug
Store: /<USER_HOME_FOLDER>/.android/debug.keystore
Alias: AndroidDebugKey
MD5
: XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX
SHA1
: XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX
SHA
-256: XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX
Valid until: Tuesday, January 19, 2038

SHA-1 해시를 복사하고 앱 제출 모달 대화상자의 마지막 입력란을 작성합니다.

iOS용 Firebase 설정: 추가 단계

Xcode를 사용하여 ios/Runnder.xcworkspace을 엽니다. 또는 원하는 IDE를 사용합니다.

VSCode에서 ios/ 폴더를 마우스 오른쪽 버튼으로 클릭한 다음 open in xcode를 클릭합니다.

Android 스튜디오에서 ios/ 폴더를 마우스 오른쪽 버튼으로 클릭한 다음 flutter를 클릭하고 open iOS module in Xcode 옵션을 클릭합니다.

iOS에서 Google 로그인을 허용하려면 빌드 plist 파일에 CFBundleURLTypes 구성 옵션을 추가합니다. 자세한 내용은 google_sign_in 패키지 문서를 참고하세요. 이 경우 파일은 ios/Runner/Info-Debug.plistios/Runner/Info-Release.plist입니다.

키-값 쌍이 이미 추가되었지만 값을 대체해야 합니다.

  1. <string>..</string> 요소로 둘러싸이지 않은 REVERSED_CLIENT_ID 값을 GoogleService-Info.plist 파일에서 가져옵니다.
  2. CFBundleURLTypes 키 아래의 ios/Runner/Info-Debug.plistios/Runner/Info-Release.plist 파일 모두에서 값을 바꿉니다.
<key>CFBundleURLTypes</key>
<array>
   
<dict>
       
<key>CFBundleTypeRole</key>
       
<string>Editor</string>
       
<key>CFBundleURLSchemes</key>
       
<array>
           
<!-- TODO Replace this value: -->
           
<!-- Copied from GoogleService-Info.plist key REVERSED_CLIENT_ID -->
           
<string>com.googleusercontent.apps.REDACTED</string>
       
</array>
   
</dict>
</array>

이제 Firebase 설정이 완료되었습니다.

7. 구매 업데이트 리슨

이 Codelab의 이 부분에서는 제품을 구매할 수 있도록 앱을 준비합니다. 이 프로세스에는 앱이 시작된 후 구매 업데이트 및 오류를 리슨하는 것이 포함됩니다.

구매 업데이트 리슨

main.dart,에서 두 페이지가 포함된 BottomNavigationBar가 있는 Scaffold가 있는 위젯 MyHomePage를 찾습니다. 이 페이지에서는 DashCounter, DashUpgrades,, DashPurchases의 세 가지 Provider도 만듭니다. DashCounter는 현재 대시 수를 추적하고 자동으로 증분합니다. DashUpgrades는 Dashes로 구매할 수 있는 업그레이드를 관리합니다. 이 Codelab에서는 DashPurchases에 중점을 둡니다.

기본적으로 제공업체의 객체는 해당 객체가 처음 요청될 때 정의됩니다. 이 객체는 앱이 시작될 때 구매 업데이트를 직접 리슨하므로 lazy: false를 사용하여 이 객체에서 지연 로드를 사용 중지합니다.

lib/main.dart

ChangeNotifierProvider<DashPurchases>(
  create
: (context) => DashPurchases(
    context
.read<DashCounter>(),
 
),
  lazy
: false,                                             // Add this line
),

InAppPurchaseConnection 인스턴스도 필요합니다. 하지만 앱을 테스트 가능한 상태로 유지하려면 연결을 모의 처리할 방법이 필요합니다. 이렇게 하려면 테스트에서 재정의할 수 있는 인스턴스 메서드를 만들고 main.dart에 추가합니다.

lib/main.dart

// Gives the option to override in tests.
class IAPConnection {
  static InAppPurchase? _instance;
  static set instance(InAppPurchase value) {
    _instance = value;
  }

  static InAppPurchase get instance {
    _instance ??= InAppPurchase.instance;
    return _instance!;
  }
}

테스트가 계속 작동하도록 하려면 테스트를 약간 업데이트해야 합니다.

test/widget_test.dart

import 'package:dashclicker/main.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:in_app_purchase/in_app_purchase.dart';     // Add this import
import 'package:in_app_purchase_platform_interface/src/in_app_purchase_platform_addition.dart'; // And this import

void main() {
 
testWidgets('App starts', (tester) async {
   
IAPConnection.instance = TestIAPConnection();          // Add this line
   
await tester.pumpWidget(const MyApp());
   
expect(find.text('Tim Sneath'), findsOneWidget);
 
});
}

class TestIAPConnection implements InAppPurchase {         // Add from here
 
@override
 
Future<bool> buyConsumable(
     
{required PurchaseParam purchaseParam, bool autoConsume = true}) {
   
return Future.value(false);
 
}

 
@override
 
Future<bool> buyNonConsumable({required PurchaseParam purchaseParam}) {
   
return Future.value(false);
 
}

 
@override
 
Future<void> completePurchase(PurchaseDetails purchase) {
   
return Future.value();
 
}

 
@override
 
Future<bool> isAvailable() {
   
return Future.value(false);
 
}

 
@override
 
Future<ProductDetailsResponse> queryProductDetails(Set<String> identifiers) {
   
return Future.value(ProductDetailsResponse(
     
productDetails: [],
     
notFoundIDs: [],
   
));
 
}

 
@override
 
T getPlatformAddition<T extends InAppPurchasePlatformAddition?>() {
   
// TODO: implement getPlatformAddition
   
throw UnimplementedError();
 
}

 
@override
 
Stream<List<PurchaseDetails>> get purchaseStream =>
     
Stream.value(<PurchaseDetails>[]);

 
@override
 
Future<void> restorePurchases({String? applicationUserName}) {
   
// TODO: implement restorePurchases
   
throw UnimplementedError();
 
}

 
@override
 
Future<String> countryCode() {
   
// TODO: implement countryCode
   
throw UnimplementedError();
 
}
}                                                          // To here.

lib/logic/dash_purchases.dart에서 DashPurchases ChangeNotifier 코드로 이동합니다. 현재 구매한 대시보드에 추가할 수 있는 대시보드는 DashCounter뿐입니다.

스트림 구독 속성 _subscription (StreamSubscription<List<PurchaseDetails>> _subscription; 유형), IAPConnection.instance,, 가져오기를 추가합니다. 결과 코드는 다음과 같습니다.

lib/logic/dash_purchases.dart

import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:in_app_purchase/in_app_purchase.dart';     // Add this import

import '../main.dart';                                     // And this import
import '../model/purchasable_product.dart';
import '../model/store_state.dart';
import 'dash_counter.dart';

class DashPurchases extends ChangeNotifier {
 
DashCounter counter;
 
StoreState storeState = StoreState.available;
 
late StreamSubscription<List<PurchaseDetails>> _subscription;  // Add this line
 
List<PurchasableProduct> products = [
   
PurchasableProduct(
     
'Spring is in the air',
     
'Many dashes flying out from their nests',
     
'\$0.99',
   
),
   
PurchasableProduct(
     
'Jet engine',
     
'Doubles you clicks per second for a day',
     
'\$1.99',
   
),
 
];

 
bool get beautifiedDash => false;

 
final iapConnection = IAPConnection.instance;            // And this line

 
DashPurchases(this.counter);

 
Future<void> buy(PurchasableProduct product) async {
   
product.status = ProductStatus.pending;
   
notifyListeners();
   
await Future<void>.delayed(const Duration(seconds: 5));
   
product.status = ProductStatus.purchased;
   
notifyListeners();
   
await Future<void>.delayed(const Duration(seconds: 5));
   
product.status = ProductStatus.purchasable;
   
notifyListeners();
 
}
}

_subscription가 생성자에서 초기화되므로 late 키워드가 _subscription에 추가됩니다. 이 프로젝트는 기본적으로 null 허용 여부가 'false'로 설정되어 있습니다 (NNBD). 즉, null 허용 여부가 선언되지 않은 속성에는 null이 아닌 값이 있어야 합니다. late 한정자를 사용하면 이 값 정의가 지연될 수 있습니다.

생성자에서 purchaseUpdated 스트림을 가져와 스트림 수신 대기를 시작합니다. dispose() 메서드에서 스트림 구독을 취소합니다.

lib/logic/dash_purchases.dart

class DashPurchases extends ChangeNotifier {
  DashCounter counter;
  StoreState storeState = StoreState.notAvailable;         // Modify this line
  late StreamSubscription<List<PurchaseDetails>> _subscription;
  List<PurchasableProduct> products = [
    PurchasableProduct(
      'Spring is in the air',
      'Many dashes flying out from their nests',
      '\$0.99',
    ),
    PurchasableProduct(
      'Jet engine',
      'Doubles you clicks per second for a day',
      '\$1.99',
    ),
  ];

  bool get beautifiedDash => false;

  final iapConnection = IAPConnection.instance;

  DashPurchases(this.counter) {                            // Add from here
    final purchaseUpdated = iapConnection.purchaseStream;
    _subscription = purchaseUpdated.listen(
      _onPurchaseUpdate,
      onDone: _updateStreamOnDone,
      onError: _updateStreamOnError,
    );
  }

  @override
  void dispose() {
    _subscription.cancel();
    super.dispose();
  }                                                        // To here.

  Future<void> buy(PurchasableProduct product) async {
    product.status = ProductStatus.pending;
    notifyListeners();
    await Future<void>.delayed(const Duration(seconds: 5));
    product.status = ProductStatus.purchased;
    notifyListeners();
    await Future<void>.delayed(const Duration(seconds: 5));
    product.status = ProductStatus.purchasable;
    notifyListeners();
  }
                                                           // Add from here
  void _onPurchaseUpdate(List<PurchaseDetails> purchaseDetailsList) {
    // Handle purchases here
  }

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

  void _updateStreamOnError(dynamic error) {
    //Handle error here
  }                                                        // To here.
}

이제 앱이 구매 업데이트를 수신하므로 다음 섹션에서 구매를 진행합니다.

계속하기 전에 'flutter test"'로 테스트를 실행하여 모든 것이 올바르게 설정되었는지 확인합니다.

$ flutter test

00:01 +1: All tests passed!                                                                                  

8. 구매

이 Codelab의 이 부분에서는 현재 존재하는 모의 제품을 구매 가능한 실제 제품으로 대체합니다. 이러한 제품은 스토어에서 로드되고 목록에 표시되며 제품을 탭하면 구매됩니다.

PurchasableProduct 조정

PurchasableProduct: 모의 제품을 표시합니다. purchasable_product.dartPurchasableProduct 클래스를 다음 코드로 바꿔 실제 콘텐츠를 표시하도록 업데이트합니다.

lib/model/purchasable_product.dart

import 'package:in_app_purchase/in_app_purchase.dart';

enum ProductStatus {
 
purchasable,
 
purchased,
 
pending,
}

class PurchasableProduct {
 
String get id => productDetails.id;
 
String get title => productDetails.title;
 
String get description => productDetails.description;
 
String get price => productDetails.price;
 
ProductStatus status;
 
ProductDetails productDetails;

 
PurchasableProduct(this.productDetails) : status = ProductStatus.purchasable;
}

dash_purchases.dart,에서 더미 구매를 삭제하고 빈 목록 List<PurchasableProduct> products = [];으로 바꿉니다.

구매 가능한 항목 로드

사용자에게 구매 기능을 제공하려면 스토어에서 구매 항목을 로드합니다. 먼저 스토어를 사용할 수 있는지 확인합니다. 스토어를 사용할 수 없는 경우 storeStatenotAvailable로 설정하면 사용자에게 오류 메시지가 표시됩니다.

lib/logic/dash_purchases.dart

  Future<void> loadPurchases() async {
    final available = await iapConnection.isAvailable();
    if (!available) {
      storeState = StoreState.notAvailable;
      notifyListeners();
      return;
    }
  }

스토어를 사용할 수 있게 되면 구매할 수 있는 항목을 로드합니다. 이전 Firebase 설정에 따라 storeKeyConsumable, storeKeySubscription,, storeKeyUpgrade가 표시됩니다. 예상 구매 항목을 사용할 수 없는 경우 이 정보를 콘솔에 출력합니다. 이 정보를 백엔드 서비스로 전송하는 것도 좋습니다.

await iapConnection.queryProductDetails(ids) 메서드는 찾을 수 없는 ID와 구매 가능한 제품을 모두 반환합니다. 응답의 productDetails를 사용하여 UI를 업데이트하고 StoreStateavailable로 설정합니다.

lib/logic/dash_purchases.dart

import '../constants.dart';

 
Future<void> loadPurchases() async {
   
final available = await iapConnection.isAvailable();
   
if (!available) {
     
storeState = StoreState.notAvailable;
     
notifyListeners();
     
return;
   
}
   
const ids = <String>{
     
storeKeyConsumable,
     
storeKeySubscription,
     
storeKeyUpgrade,
   
};
   
final response = await iapConnection.queryProductDetails(ids);
   
products = response.productDetails.map((e) => PurchasableProduct(e)).toList();
   
storeState = StoreState.available;
   
notifyListeners();
 
}

생성자에서 loadPurchases() 함수를 호출합니다.

lib/logic/dash_purchases.dart

  DashPurchases(this.counter) {
    final purchaseUpdated = iapConnection.purchaseStream;
    _subscription = purchaseUpdated.listen(
      _onPurchaseUpdate,
      onDone: _updateStreamOnDone,
      onError: _updateStreamOnError,
    );
    loadPurchases();
  }

마지막으로 storeState 필드의 값을 StoreState.available에서 StoreState.loading:로 변경합니다.

lib/logic/dash_purchases.dart

StoreState storeState = StoreState.loading;

구매 가능한 제품 표시

purchase_page.dart 파일을 예로 들어 보겠습니다. PurchasePage 위젯은 StoreState에 따라 _PurchasesLoading, _PurchaseList, 또는 _PurchasesNotAvailable,을 표시합니다. 또한 위젯에는 다음 단계에서 사용되는 사용자의 이전 구매 내역이 표시됩니다.

_PurchaseList 위젯은 구매 가능한 제품 목록을 표시하고 DashPurchases 객체에 구매 요청을 전송합니다.

lib/pages/purchase_page.dart

class _PurchaseList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var purchases = context.watch<DashPurchases>();
    var products = purchases.products;
    return Column(
      children: products
          .map((product) => _PurchaseWidget(
              product: product,
              onPressed: () {
                purchases.buy(product);
              }))
          .toList(),
    );
  }
}

올바르게 구성된 경우 Android 및 iOS 스토어에서 사용 가능한 제품을 볼 수 있습니다. 각 콘솔에 구매 항목을 입력하면 구매 항목을 사용할 수 있기까지 다소 시간이 걸릴 수 있습니다.

ca1a9f97c21e552d.png

dash_purchases.dart로 돌아가서 제품을 구매하는 함수를 구현합니다. 소모품과 비소모품을 구분하기만 하면 됩니다. 업그레이드 및 정기 결제 제품은 소모품이 아닙니다.

lib/logic/dash_purchases.dart

  Future<void> buy(PurchasableProduct product) async {
    final purchaseParam = PurchaseParam(productDetails: product.productDetails);
    switch (product.id) {
      case storeKeyConsumable:
        await iapConnection.buyConsumable(purchaseParam: purchaseParam);
      case storeKeySubscription:
      case storeKeyUpgrade:
        await iapConnection.buyNonConsumable(purchaseParam: purchaseParam);
      default:
        throw ArgumentError.value(
            product.productDetails, '${product.id} is not a known product');
    }
  }

계속하기 전에 _beautifiedDashUpgrade 변수를 만들고 이를 참조하도록 beautifiedDash getter를 업데이트합니다.

lib/logic/dash_purchases.dart

  bool get beautifiedDash => _beautifiedDashUpgrade;
 
bool _beautifiedDashUpgrade = false;

_onPurchaseUpdate 메서드는 구매 업데이트를 수신하고 구매 페이지에 표시되는 제품의 상태를 업데이트한 후 구매를 카운터 로직에 적용합니다. 구매를 처리한 후 completePurchase를 호출하여 스토어에 구매가 올바르게 처리되었음을 알리는 것이 중요합니다.

lib/logic/dash_purchases.dart

  Future<void> _onPurchaseUpdate(
      List<PurchaseDetails> purchaseDetailsList) async {
    for (var purchaseDetails in purchaseDetailsList) {
      await _handlePurchase(purchaseDetails);
    }
    notifyListeners();
  }

  Future<void> _handlePurchase(PurchaseDetails purchaseDetails) async {
    if (purchaseDetails.status == PurchaseStatus.purchased) {
      switch (purchaseDetails.productID) {
        case storeKeySubscription:
          counter.applyPaidMultiplier();
        case storeKeyConsumable:
          counter.addBoughtDashes(2000);
        case storeKeyUpgrade:
          _beautifiedDashUpgrade = true;
      }
    }

    if (purchaseDetails.pendingCompletePurchase) {
      await iapConnection.completePurchase(purchaseDetails);
    }
  }

9. 백엔드 설정

구매 추적 및 확인으로 이동하기 전에 이를 지원하는 Dart 백엔드를 설정하세요.

이 섹션에서는 dart-backend/ 폴더를 루트로 하여 작업합니다.

다음 도구가 설치되어 있는지 확인합니다.

기본 프로젝트 개요

이 프로젝트의 일부는 이 Codelab의 범위에 해당하지 않으므로 시작 코드에 포함되어 있습니다. 시작하기 전에 시작 코드에 이미 있는 내용을 검토하여 구조를 어떻게 구성할지 파악하는 것이 좋습니다.

이 백엔드 코드는 컴퓨터에서 로컬로 실행할 수 있으며, 이를 사용하기 위해 배포할 필요가 없습니다. 하지만 개발 기기 (Android 또는 iPhone)에서 서버가 실행될 머신에 연결할 수 있어야 합니다. 그러려면 두 기기가 동일한 네트워크에 있어야 하며 컴퓨터의 IP 주소를 알아야 합니다.

다음 명령어를 사용하여 서버를 실행해 봅니다.

$ dart ./bin/server.dart

Serving at http://0.0.0.0:8080

Dart 백엔드는 shelfshelf_router를 사용하여 API 엔드포인트를 제공합니다. 기본적으로 서버는 경로를 제공하지 않습니다. 나중에 구매 인증 절차를 처리할 경로를 만듭니다.

시작 코드에 이미 포함된 부분 중 하나는 lib/iap_repository.dartIapRepository입니다. Firestore 또는 일반적인 데이터베이스와 상호작용하는 방법을 배우는 것은 이 Codelab과 관련이 없으므로 시작 코드에는 Firestore에서 구매 항목을 만들거나 업데이트하는 함수와 이러한 구매 항목의 모든 클래스가 포함되어 있습니다.

Firebase 액세스 설정

Firebase Firestore에 액세스하려면 서비스 계정 액세스 키가 필요합니다. Firebase 프로젝트 설정을 열고 서비스 계정 섹션으로 이동한 다음 새 비공개 키 생성을 선택하여 비공개 키를 생성합니다.

27590fc77ae94ad4.png

다운로드한 JSON 파일을 assets/ 폴더에 복사하고 이름을 service-account-firebase.json으로 바꿉니다.

Google Play 액세스 설정하기

Play 스토어에 액세스하여 구매를 확인하려면 이러한 권한이 있는 서비스 계정을 생성하고 JSON 사용자 인증 정보를 다운로드해야 합니다.

  1. Google Play Console로 이동하여 모든 앱 페이지에서 시작합니다.
  2. 설정 > API 액세스로 이동합니다. 317fdfb54921f50e.png Google Play Console에서 기존 프로젝트를 만들거나 연결하도록 요청하는 경우 먼저 프로젝트를 만든 다음 이 페이지로 돌아오세요.
  3. 서비스 계정을 정의할 수 있는 섹션을 찾아 새 서비스 계정 만들기를 클릭합니다.1e70d3f8d794bebb.png
  4. 팝업 대화상자에서 Google Cloud Platform 링크를 클릭합니다. 7c9536336dd9e9b4.png
  5. 프로젝트를 선택합니다. 표시되지 않으면 오른쪽 상단의 계정 드롭다운 목록에서 올바른 Google 계정에 로그인했는지 확인하세요. 3fb3a25bad803063.png
  6. 프로젝트를 선택한 후 상단 메뉴 바에서 + 서비스 계정 만들기를 클릭합니다. 62fe4c3f8644acd8.png
  7. 서비스 계정의 이름을 지정하고 필요한 경우 용도를 기억할 수 있도록 설명을 제공한 다음 다음 단계로 이동합니다. 8a92d5d6a3dff48c.png
  8. 서비스 계정에 편집자 역할을 할당합니다. 6052b7753667ed1a.png
  9. 마법사를 완료하고 개발자 콘솔 내의 API 액세스 페이지로 돌아가 서비스 계정 새로고침을 클릭합니다. 목록에 새로 만든 계정이 표시됩니다. 5895a7db8b4c7659.png
  10. 새 서비스 계정의 액세스 권한 부여를 클릭합니다.
  11. 다음 페이지에서 재무 데이터 블록까지 아래로 스크롤합니다. 재무 데이터, 주문, 취소 설문조사 응답 보기주문 및 정기 결제 관리를 모두 선택합니다. 75b22d0201cf67e.png
  12. 사용자 초대를 클릭합니다. 70ea0b1288c62a59.png
  13. 이제 계정이 설정되었으므로 사용자 인증 정보를 생성하기만 하면 됩니다. Cloud 콘솔로 돌아가 서비스 계정 목록에서 서비스 계정을 찾은 다음 세 개의 점 아이콘을 클릭하고 키 관리를 선택합니다. 853ee186b0e9954e.png
  14. 새 JSON 키를 만들고 다운로드합니다. 2a33a55803f5299c.png cb4bf48ebac0364e.png
  15. 다운로드한 파일의 이름을 service-account-google-play.json,로 바꾸고 assets/ 디렉터리로 이동합니다.

lib/constants.dart,를 열고 androidPackageId 값을 Android 앱에 선택한 패키지 ID로 바꾸는 작업도 하나 더 남아 있습니다.

Apple App Store 액세스 설정하기

구매를 확인하기 위해 앱 스토어에 액세스하려면 공유 비밀을 설정해야 합니다.

  1. App Store Connect를 엽니다.
  2. 내 앱으로 이동하여 앱을 선택합니다.
  3. 사이드바 탐색에서 인앱 구매 > 관리로 이동합니다.
  4. 목록의 오른쪽 상단에서 앱별 공유 비밀을 클릭합니다.
  5. 새 보안 비밀을 생성하고 복사합니다.
  6. lib/constants.dart,를 열고 appStoreSharedSecret 값을 방금 생성한 공유 보안 비밀로 바꿉니다.

d8b8042470aaeff.png

b72f4565750e2f40.png

상수 구성 파일

계속하기 전에 lib/constants.dart 파일에 다음 상수가 구성되어 있는지 확인합니다.

  • androidPackageId: Android에서 사용되는 패키지 ID입니다(예: com.example.dashclicker).
  • appStoreSharedSecret: App Store Connect에 액세스하여 구매 확인을 실행하기 위한 공유 비밀입니다.
  • bundleId: iOS에서 사용되는 번들 ID입니다(예: com.example.dashclicker).

나머지 상수는 당분간 무시해도 됩니다.

10. 구매 확인

구매 확인을 위한 일반적인 흐름은 iOS와 Android에서 비슷합니다.

두 스토어 모두 구매 시 애플리케이션이 토큰을 수신합니다.

이 토큰은 앱에서 백엔드 서비스로 전송되며, 백엔드 서비스는 제공된 토큰을 사용하여 각 스토어의 서버에서 구매를 확인합니다.

그러면 백엔드 서비스는 구매 항목을 저장하고 애플리케이션에 구매가 유효한지 여부를 응답할 수 있습니다.

백엔드 서비스가 사용자 기기에서 실행되는 애플리케이션이 아닌 스토어에서 유효성 검사를 실행하도록 하면 사용자가 시스템 시계를 되감아 프리미엄 기능에 액세스하는 것을 방지할 수 있습니다.

Flutter 측 설정

인증을 설정합니다.

구매 내역을 백엔드 서비스로 전송할 예정이므로 구매하는 동안 사용자가 인증되었는지 확인해야 합니다. 대부분의 인증 로직은 이미 시작 프로젝트에 추가되어 있으므로 사용자가 아직 로그인하지 않은 경우 PurchasePage에 로그인 버튼이 표시되는지 확인하기만 하면 됩니다. PurchasePage의 빌드 메서드 시작 부분에 다음 코드를 추가합니다.

lib/pages/purchase_page.dart

import '../logic/firebase_notifier.dart';
import '../model/firebase_state.dart';
import 'login_page.dart';

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

 
@override
 
Widget build(BuildContext context) {
   
var firebaseNotifier = context.watch<FirebaseNotifier>();
   
if (firebaseNotifier.state == FirebaseState.loading) {
     
return _PurchasesLoading();
   
} else if (firebaseNotifier.state == FirebaseState.notAvailable) {
     
return _PurchasesNotAvailable();
   
}

   
if (!firebaseNotifier.loggedIn) {
     
return const LoginPage();
   
}
   
// omitted

앱에서 확인 엔드포인트 호출

앱에서 http post 호출을 사용하여 Dart 백엔드에서 /verifypurchase 엔드포인트를 호출하는 _verifyPurchase(PurchaseDetails purchaseDetails) 함수를 만듭니다.

선택한 스토어 (Play 스토어의 경우 google_play, App Store의 경우 app_store), serverVerificationData, productID를 전송합니다. 서버는 구매가 확인되었는지 여부를 나타내는 상태 코드를 반환합니다.

앱 상수에서 서버 IP를 로컬 머신 IP 주소로 구성합니다.

lib/logic/dash_purchases.dart

  FirebaseNotifier firebaseNotifier;

 
DashPurchases(this.counter, this.firebaseNotifier) {
   
// omitted
 
}

main.dart:에서 DashPurchases를 만들 때 firebaseNotifier를 추가합니다.

lib/main.dart

        ChangeNotifierProvider<DashPurchases>(
          create
: (context) => DashPurchases(
            context
.read<DashCounter>(),
            context
.read<FirebaseNotifier>(),
         
),
          lazy
: false,
       
),

사용자 ID를 구매 확인 함수에 전달할 수 있도록 FirebaseNotifier에 사용자의 getter를 추가합니다.

lib/logic/firebase_notifier.dart

  User? get user => FirebaseAuth.instance.currentUser;

_verifyPurchase 함수를 DashPurchases 클래스에 추가합니다. 이 async 함수는 구매가 확인되었는지 여부를 나타내는 불리언을 반환합니다.

lib/logic/dash_purchases.dart

  Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) async {
    final url = Uri.parse('http://$serverIp:8080/verifypurchase');
    const headers = {
      'Content-type': 'application/json',
      'Accept': 'application/json',
    };
    final response = await http.post(
      url,
      body: jsonEncode({
        'source': purchaseDetails.verificationData.source,
        'productId': purchaseDetails.productID,
        'verificationData':
            purchaseDetails.verificationData.serverVerificationData,
        'userId': firebaseNotifier.user?.uid,
      }),
      headers: headers,
    );
    if (response.statusCode == 200) {
      return true;
    } else {
      return false;
    }
  }

구매를 적용하기 직전에 _handlePurchase에서 _verifyPurchase 함수를 호출합니다. 구매가 확인된 경우에만 구매를 적용해야 합니다. 프로덕션 앱에서는 이를 추가로 지정하여 예를 들어 스토어를 일시적으로 사용할 수 없는 경우 무료 체험판 구독을 적용할 수 있습니다. 하지만 이 예에서는 간단하게 유지하고 구매가 확인된 경우에만 구매를 적용합니다.

lib/logic/dash_purchases.dart

  Future<void> _onPurchaseUpdate(
      List<PurchaseDetails> purchaseDetailsList) async {
    for (var purchaseDetails in purchaseDetailsList) {
      await _handlePurchase(purchaseDetails);
    }
    notifyListeners();
  }

  Future<void> _handlePurchase(PurchaseDetails purchaseDetails) async {
    if (purchaseDetails.status == PurchaseStatus.purchased) {
      // Send to server
      var validPurchase = await _verifyPurchase(purchaseDetails);

      if (validPurchase) {
        // Apply changes locally
        switch (purchaseDetails.productID) {
          case storeKeySubscription:
            counter.applyPaidMultiplier();
          case storeKeyConsumable:
            counter.addBoughtDashes(1000);
        }
      }
    }

    if (purchaseDetails.pendingCompletePurchase) {
      await iapConnection.completePurchase(purchaseDetails);
    }
  }

이제 앱에서 구매를 확인할 준비가 되었습니다.

백엔드 서비스 설정

그런 다음 백엔드에서 구매를 확인하기 위한 Cloud 함수를 설정합니다.

구매 핸들러 빌드

두 매장의 인증 흐름이 거의 동일하므로 각 매장에 대해 별도의 구현이 포함된 추상 PurchaseHandler 클래스를 설정합니다.

be50c207c5a2a519.png

먼저 lib/ 폴더에 purchase_handler.dart 파일을 추가합니다. 여기에서 구독과 비구독이라는 두 가지 종류의 구매를 확인하는 두 가지 추상 메서드가 있는 추상 PurchaseHandler 클래스를 정의합니다.

lib/purchase_handler.dart

import 'products.dart';

/// Generic purchase handler,
/// must be implemented for Google Play and Apple Store
abstract class PurchaseHandler {

 
/// Verify if non-subscription purchase (aka consumable) is valid
 
/// and update the database
 
Future<bool> handleNonSubscription({
   
required String userId,
   
required ProductData productData,
   
required String token,
 
});

 
/// Verify if subscription purchase (aka non-consumable) is valid
 
/// and update the database
 
Future<bool> handleSubscription({
   
required String userId,
   
required ProductData productData,
   
required String token,
 
});
}

보시다시피 각 메서드에는 세 개의 매개변수가 필요합니다.

  • userId: 로그인한 사용자의 ID로, 구매를 사용자에게 연결할 수 있습니다.
  • productData: 제품에 대한 데이터입니다. 잠시 후 이를 정의할 것입니다.
  • token: 스토어에서 사용자에게 제공한 토큰입니다.

또한 이러한 구매 핸들러를 더 쉽게 사용할 수 있도록 정기 결제 및 비정기 결제 모두에 사용할 수 있는 verifyPurchase() 메서드를 추가합니다.

lib/purchase_handler.dart

  /// Verify if purchase is valid and update the database
  Future<bool> verifyPurchase({
    required String userId,
    required ProductData productData,
    required String token,
  }) async {
    switch (productData.type) {
      case ProductType.subscription:
        return handleSubscription(
          userId: userId,
          productData: productData,
          token: token,
        );
      case ProductType.nonSubscription:
        return handleNonSubscription(
          userId: userId,
          productData: productData,
          token: token,
        );
    }
  }

이제 두 경우 모두 verifyPurchase를 호출할 수 있지만 구현은 별도로 유지할 수 있습니다.

ProductData 클래스에는 구매 가능한 다양한 제품에 관한 기본 정보(제품 ID(SKU라고도 함) 및 ProductType 포함)가 포함되어 있습니다.

lib/products.dart

class ProductData {
  final String productId;
  final ProductType type;

  const ProductData(this.productId, this.type);
}

ProductType은 정기 결제 또는 비정기 결제일 수 있습니다.

lib/products.dart

enum ProductType {
  subscription,
  nonSubscription,
}

마지막으로 제품 목록은 동일한 파일에서 맵으로 정의됩니다.

lib/products.dart

const productDataMap = {
  'dash_consumable_2k': ProductData(
    'dash_consumable_2k',
    ProductType.nonSubscription,
  ),
  'dash_upgrade_3d': ProductData(
    'dash_upgrade_3d',
    ProductType.nonSubscription,
  ),
  'dash_subscription_doubler': ProductData(
    'dash_subscription_doubler',
    ProductType.subscription,
  ),
};

그런 다음 Google Play 스토어 및 Apple App Store의 자리표시자 구현을 정의합니다. Google Play에서 시작해 보세요.

lib/google_play_purchase_handler.dart를 만들고 방금 작성한 PurchaseHandler를 확장하는 클래스를 추가합니다.

lib/google_play_purchase_handler.dart

import 'dart:async';

import 'package:googleapis/androidpublisher/v3.dart' as ap;

import 'constants.dart';
import 'iap_repository.dart';
import 'products.dart';
import 'purchase_handler.dart';

class GooglePlayPurchaseHandler extends PurchaseHandler {
 
final ap.AndroidPublisherApi androidPublisher;
 
final IapRepository iapRepository;

 
GooglePlayPurchaseHandler(
   
this.androidPublisher,
   
this.iapRepository,
 
);

 
@override
 
Future<bool> handleNonSubscription({
   
required String? userId,
   
required ProductData productData,
   
required String token,
 
}) async {
   
return true;
 
}

 
@override
 
Future<bool> handleSubscription({
   
required String? userId,
   
required ProductData productData,
   
required String token,
 
}) async {
   
return true;
 
}
}

지금은 핸들러 메서드에 true를 반환합니다. 나중에 살펴보겠습니다.

알 수 있듯이 생성자는 IapRepository의 인스턴스를 사용합니다. 구매 핸들러는 이 인스턴스를 사용하여 나중에 Firestore에 구매 정보를 저장합니다. Google Play와 통신하려면 제공된 AndroidPublisherApi를 사용합니다.

그런 다음 앱 스토어 핸들러에도 동일한 작업을 실행합니다. lib/app_store_purchase_handler.dart를 만들고 PurchaseHandler를 다시 확장하는 클래스를 추가합니다.

lib/app_store_purchase_handler.dart

import 'dart:async';

import 'package:app_store_server_sdk/app_store_server_sdk.dart';

import 'constants.dart';
import 'iap_repository.dart';
import 'products.dart';
import 'purchase_handler.dart';

class AppStorePurchaseHandler extends PurchaseHandler {
 
final IapRepository iapRepository;

 
AppStorePurchaseHandler(
   
this.iapRepository,
 
);

 
@override
 
Future<bool> handleNonSubscription({
   
required String userId,
   
required ProductData productData,
   
required String token,
 
}) {
   
return true;
 
}

 
@override
 
Future<bool> handleSubscription({
   
required String userId,
   
required ProductData productData,
   
required String token,
 
}) {
   
return true;
 
}
}

좋습니다. 이제 구매 핸들러가 2개가 됩니다. 다음으로 Purchase Verification API 엔드포인트를 만들어 보겠습니다.

구매 핸들러 사용

bin/server.dart를 열고 shelf_route를 사용하여 API 엔드포인트를 만듭니다.

bin/server.dart

Future<void> main() async {
  final router = Router();

  final purchaseHandlers = await _createPurchaseHandlers();

  router.post('/verifypurchase', (Request request) async {
    final dynamic payload = json.decode(await request.readAsString());

    final (:userId, :source, :productData, :token) = getPurchaseData(payload);

    final result = await purchaseHandlers[source]!.verifyPurchase(
      userId: userId,
      productData: productData,
      token: token,
    );

    if (result) {
      return Response.ok('all good!');
    } else {
      return Response.internalServerError();
    }
  });

  await serveHandler(router.call);
}

({
  String userId,
  String source,
  ProductData productData,
  String token,
}) getPurchaseData(dynamic payload) {
  if (payload
      case {
        'userId': String userId,
        'source': String source,
        'productId': String productId,
        'verificationData': String token,
      }) {
    return (
      userId: userId,
      source: source,
      productData: productDataMap[productId]!,
      token: token,
    );
  } else {
    throw const FormatException('Unexpected JSON');
  }
}

위의 코드는 다음을 실행합니다.

  1. 이전에 만든 앱에서 호출할 POST 엔드포인트를 정의합니다.
  2. JSON 페이로드를 디코딩하고 다음 정보를 추출합니다.
  3. userId: 현재 로그인한 사용자 ID
  4. source: 사용된 스토어입니다(app_store 또는 google_play).
  5. productData: 이전에 만든 productDataMap에서 가져옵니다.
  6. token: 스토어로 전송할 인증 데이터를 포함합니다.
  7. 소스에 따라 GooglePlayPurchaseHandler 또는 AppStorePurchaseHandlerverifyPurchase 메서드를 호출합니다.
  8. 인증에 성공하면 메서드는 클라이언트에 Response.ok를 반환합니다.
  9. 인증에 실패하면 메서드는 클라이언트에 Response.internalServerError를 반환합니다.

API 엔드포인트를 만든 후에는 두 가지 구매 핸들러를 구성해야 합니다. 이렇게 하려면 이전 단계에서 가져온 서비스 계정 키를 로드하고 Android 게시자 API 및 Firebase Firestore API를 비롯한 다양한 서비스에 대한 액세스를 구성해야 합니다. 그런 다음 서로 다른 종속 항목으로 두 개의 구매 핸들러를 만듭니다.

bin/server.dart

Future<Map<String, PurchaseHandler>> _createPurchaseHandlers() async {
 
// Configure Android Publisher API access
 
final serviceAccountGooglePlay =
     
File('assets/service-account-google-play.json').readAsStringSync();
 
final clientCredentialsGooglePlay =
      auth
.ServiceAccountCredentials.fromJson(serviceAccountGooglePlay);
 
final clientGooglePlay =
      await auth
.clientViaServiceAccount(clientCredentialsGooglePlay, [
    ap
.AndroidPublisherApi.androidpublisherScope,
 
]);
 
final androidPublisher = ap.AndroidPublisherApi(clientGooglePlay);

 
// Configure Firestore API access
 
final serviceAccountFirebase =
     
File('assets/service-account-firebase.json').readAsStringSync();
 
final clientCredentialsFirebase =
      auth
.ServiceAccountCredentials.fromJson(serviceAccountFirebase);
 
final clientFirebase =
      await auth
.clientViaServiceAccount(clientCredentialsFirebase, [
    fs
.FirestoreApi.cloudPlatformScope,
 
]);
 
final firestoreApi = fs.FirestoreApi(clientFirebase);
 
final dynamic json = jsonDecode(serviceAccountFirebase);
 
final projectId = json['project_id'] as String;
 
final iapRepository = IapRepository(firestoreApi, projectId);

 
return {
   
'google_play': GooglePlayPurchaseHandler(
      androidPublisher
,
      iapRepository
,
   
),
   
'app_store': AppStorePurchaseHandler(
      iapRepository
,
   
),
 
};
}

Android 구매 확인: 구매 핸들러 구현

그런 다음 Google Play 구매 핸들러를 계속 구현합니다.

Google은 이미 구매를 확인하는 데 필요한 API와 상호작용하는 Dart 패키지를 제공하고 있습니다. server.dart 파일에서 초기화한 후 이제 GooglePlayPurchaseHandler 클래스에서 사용합니다.

정기 결제 유형이 아닌 구매의 핸들러를 구현합니다.

lib/google_play_purchase_handler.dart

  @override
  Future<bool> handleNonSubscription({
    required String? userId,
    required ProductData productData,
    required String token,
  }) async {
    print(
      'GooglePlayPurchaseHandler.handleNonSubscription'
      '($userId, ${productData.productId}, ${token.substring(0, 5)}...)',
    );

    try {
      // Verify purchase with Google
      final response = await androidPublisher.purchases.products.get(
        androidPackageId,
        productData.productId,
        token,
      );

      print('Purchases response: ${response.toJson()}');

      // Make sure an order id exists
      if (response.orderId == null) {
        print('Could not handle purchase without order id');
        return false;
      }
      final orderId = response.orderId!;

      final purchaseData = NonSubscriptionPurchase(
        purchaseDate: DateTime.fromMillisecondsSinceEpoch(
          int.parse(response.purchaseTimeMillis ?? '0'),
        ),
        orderId: orderId,
        productId: productData.productId,
        status: _nonSubscriptionStatusFrom(response.purchaseState),
        userId: userId,
        iapSource: IAPSource.googleplay,
      );

      // Update the database
      if (userId != null) {
        // If we know the userId,
        // update the existing purchase or create it if it does not exist.
        await iapRepository.createOrUpdatePurchase(purchaseData);
      } else {
        // If we do not know the user id, a previous entry must already
        // exist, and thus we'll only update it.
        await iapRepository.updatePurchase(purchaseData);
      }
      return true;
    } on ap.DetailedApiRequestError catch (e) {
      print(
        'Error on handle NonSubscription: $e\n'
        'JSON: ${e.jsonResponse}',
      );
    } catch (e) {
      print('Error on handle NonSubscription: $e\n');
    }
    return false;
  }

구독 구매 핸들러도 비슷한 방식으로 업데이트할 수 있습니다.

lib/google_play_purchase_handler.dart

  /// Handle subscription purchases.
  ///
  /// Retrieves the purchase status from Google Play and updates
  /// the Firestore Database accordingly.
  @override
  Future<bool> handleSubscription({
    required String? userId,
    required ProductData productData,
    required String token,
  }) async {
    print(
      'GooglePlayPurchaseHandler.handleSubscription'
      '($userId, ${productData.productId}, ${token.substring(0, 5)}...)',
    );

    try {
      // Verify purchase with Google
      final response = await androidPublisher.purchases.subscriptions.get(
        androidPackageId,
        productData.productId,
        token,
      );

      print('Subscription response: ${response.toJson()}');

      // Make sure an order id exists
      if (response.orderId == null) {
        print('Could not handle purchase without order id');
        return false;
      }
      final orderId = extractOrderId(response.orderId!);

      final purchaseData = SubscriptionPurchase(
        purchaseDate: DateTime.fromMillisecondsSinceEpoch(
          int.parse(response.startTimeMillis ?? '0'),
        ),
        orderId: orderId,
        productId: productData.productId,
        status: _subscriptionStatusFrom(response.paymentState),
        userId: userId,
        iapSource: IAPSource.googleplay,
        expiryDate: DateTime.fromMillisecondsSinceEpoch(
          int.parse(response.expiryTimeMillis ?? '0'),
        ),
      );

      // Update the database
      if (userId != null) {
        // If we know the userId,
        // update the existing purchase or create it if it does not exist.
        await iapRepository.createOrUpdatePurchase(purchaseData);
      } else {
        // If we do not know the user id, a previous entry must already
        // exist, and thus we'll only update it.
        await iapRepository.updatePurchase(purchaseData);
      }
      return true;
    } on ap.DetailedApiRequestError catch (e) {
      print(
        'Error on handle Subscription: $e\n'
        'JSON: ${e.jsonResponse}',
      );
    } catch (e) {
      print('Error on handle Subscription: $e\n');
    }
    return false;
  }
}

주문 ID 파싱을 용이하게 하는 다음 메서드와 구매 상태를 파싱하는 두 가지 메서드를 추가합니다.

lib/google_play_purchase_handler.dart

NonSubscriptionStatus _nonSubscriptionStatusFrom(int? state) {
  return switch (state) {
    0 => NonSubscriptionStatus.completed,
    2 => NonSubscriptionStatus.pending,
    _ => NonSubscriptionStatus.cancelled,
 
};
}

SubscriptionStatus _subscriptionStatusFrom(int? state) {
  return switch (state) {
    // Payment pending
    0 => SubscriptionStatus.pending,
    // Payment received
    1 => SubscriptionStatus.active,
    // Free trial
    2 => SubscriptionStatus.active,
    // Pending deferred upgrade/downgrade
    3 => SubscriptionStatus.pending,
    // Expired or cancelled
    _ => SubscriptionStatus.expired,
 
};
}

/// If a subscription suffix is present (..#) extract the orderId.
String extractOrderId(String orderId) {
 
final orderIdSplit = orderId.split('..');
 
if (orderIdSplit.isNotEmpty) {
    orderId
= orderIdSplit[0];
 
}
 
return orderId;
}

이제 Google Play 구매가 확인되고 데이터베이스에 저장됩니다.

다음으로 iOS의 App Store 구매로 이동합니다.

iOS 구매 확인: 구매 핸들러 구현

App Store에서 구매를 확인하는 경우 이 과정을 더 쉽게 만들어주는 서드 파티 Dart 패키지인 app_store_server_sdk가 있습니다.

먼저 ITunesApi 인스턴스를 만듭니다. 샌드박스 구성을 사용하고 로깅을 사용 설정하여 오류 디버깅을 용이하게 합니다.

lib/app_store_purchase_handler.dart

  final _iTunesAPI = ITunesApi(
    ITunesHttpClient(
      ITunesEnvironment.sandbox(),
      loggingEnabled: true,
    ),
  );

이제 Google Play API와 달리 App Store에서는 정기 결제와 비정기 결제 모두에 동일한 API 엔드포인트를 사용합니다. 즉, 두 핸들러에 동일한 로직을 사용할 수 있습니다. 동일한 구현을 호출하도록 두 개의 클래스를 병합합니다.

lib/app_store_purchase_handler.dart

  @override
  Future<bool> handleNonSubscription({
    required String userId,
    required ProductData productData,
    required String token,
  }) {
    return handleValidation(userId: userId, token: token);
  }

  @override
  Future<bool> handleSubscription({
    required String userId,
    required ProductData productData,
    required String token,
  }) {
    return handleValidation(userId: userId, token: token);
  }

  /// Handle purchase validation.
  Future<bool> handleValidation({
    required String userId,
    required String token,
  }) async {
   //..
  }

이제 handleValidation를 구현합니다.

lib/app_store_purchase_handler.dart

  /// Handle purchase validation.
  Future<bool> handleValidation({
    required String userId,
    required String token,
  }) async {
    print('AppStorePurchaseHandler.handleValidation');
    final response = await _iTunesAPI.verifyReceipt(
      password: appStoreSharedSecret,
      receiptData: token,
    );
    print('response: $response');
    if (response.status == 0) {
      final receipts = response.latestReceiptInfo ?? [];
      for (final receipt in receipts) {
        final product = productDataMap[receipt.productId];
        if (product == null) {
          print('Error: Unknown product: ${receipt.productId}');
          continue;
        }
        switch (product.type) {
          case ProductType.nonSubscription:
            await iapRepository.createOrUpdatePurchase(NonSubscriptionPurchase(
              userId: userId,
              productId: receipt.productId ?? '',
              iapSource: IAPSource.appstore,
              orderId: receipt.originalTransactionId ?? '',
              purchaseDate: DateTime.fromMillisecondsSinceEpoch(
                  int.parse(receipt.originalPurchaseDateMs ?? '0')),
              type: product.type,
              status: NonSubscriptionStatus.completed,
            ));
            break;
          case ProductType.subscription:
            await iapRepository.createOrUpdatePurchase(SubscriptionPurchase(
              userId: userId,
              productId: receipt.productId ?? '',
              iapSource: IAPSource.appstore,
              orderId: receipt.originalTransactionId ?? '',
              purchaseDate: DateTime.fromMillisecondsSinceEpoch(
                  int.parse(receipt.originalPurchaseDateMs ?? '0')),
              type: product.type,
              expiryDate: DateTime.fromMillisecondsSinceEpoch(
                  int.parse(receipt.expiresDateMs ?? '0')),
              status: SubscriptionStatus.active,
            ));
            break;
        }
      }
      return true;
    } else {
      print('Error: Status: ${response.status}');
      return false;
    }
  }

이제 App Store 구매가 인증되고 데이터베이스에 저장됩니다.

백엔드 실행

이제 dart bin/server.dart를 실행하여 /verifypurchase 엔드포인트를 제공할 수 있습니다.

$ dart bin/server.dart 
Serving at http://0.0.0.0:8080

11. 구매 내역 확인하기

사용자의 구매를 추적하는 권장 방법은 백엔드 서비스에서 추적하는 것입니다. 백엔드가 스토어의 이벤트에 응답할 수 있으므로 캐싱으로 인해 오래된 정보가 표시될 가능성이 줄어들고 조작될 가능성도 줄어듭니다.

먼저 빌드한 Dart 백엔드를 사용하여 백엔드에서 스토어 이벤트 처리를 설정합니다.

백엔드에서 매장 이벤트 처리

스토어는 구독이 갱신될 때와 같이 발생하는 모든 결제 이벤트를 백엔드에 알릴 수 있습니다. 백엔드에서 이러한 이벤트를 처리하여 데이터베이스의 구매를 최신 상태로 유지할 수 있습니다. 이 섹션에서는 Google Play 스토어와 Apple App Store 모두에 대해 설정합니다.

Google Play 결제 이벤트 처리

Google Play는 Cloud Pub/Sub 주제를 통해 결제 이벤트를 제공합니다. 이는 기본적으로 메시지를 게시하고 소비할 수 있는 메시지 대기열입니다.

이 기능은 Google Play에만 해당하는 기능이므로 GooglePlayPurchaseHandler에 이 기능을 포함합니다.

먼저 lib/google_play_purchase_handler.dart를 열고 PubsubApi 가져오기를 추가합니다.

lib/google_play_purchase_handler.dart

import 'package:googleapis/pubsub/v1.dart' as pubsub;

그런 다음 PubsubApiGooglePlayPurchaseHandler에 전달하고 클래스 생성자를 수정하여 다음과 같이 Timer를 만듭니다.

lib/google_play_purchase_handler.dart

class GooglePlayPurchaseHandler extends PurchaseHandler {
  final ap.AndroidPublisherApi androidPublisher;
  final IapRepository iapRepository;
  final pubsub.PubsubApi pubsubApi; // new

  GooglePlayPurchaseHandler(
    this.androidPublisher,
    this.iapRepository,
    this.pubsubApi, // new
  ) {
    // Poll messages from Pub/Sub every 10 seconds
    Timer.periodic(Duration(seconds: 10), (_) {
      _pullMessageFromPubSub();
    });
  }

Timer는 10초마다 _pullMessageFromSubSub 메서드를 호출하도록 구성됩니다. 원하는 대로 시간 설정을 조정할 수 있습니다.

그런 다음 _pullMessageFromSubSub를 만듭니다.

lib/google_play_purchase_handler.dart

  /// Process messages from Google Play
  /// Called every 10 seconds
  Future<void> _pullMessageFromPubSub() async {
    print('Polling Google Play messages');
    final request = pubsub.PullRequest(
      maxMessages: 1000,
    );
    final topicName =
        'projects/$googlePlayProjectName/subscriptions/$googlePlayPubsubBillingTopic-sub';
    final pullResponse = await pubsubApi.projects.subscriptions.pull(
      request,
      topicName,
    );
    final messages = pullResponse.receivedMessages ?? [];
    for (final message in messages) {
      final data64 = message.message?.data;
      if (data64 != null) {
        await _processMessage(data64, message.ackId);
      }
    }
  }

  Future<void> _processMessage(String data64, String? ackId) async {
    final dataRaw = utf8.decode(base64Decode(data64));
    print('Received data: $dataRaw');
    final dynamic data = jsonDecode(dataRaw);
    if (data['testNotification'] != null) {
      print('Skip test messages');
      if (ackId != null) {
        await _ackMessage(ackId);
      }
      return;
    }
    final dynamic subscriptionNotification = data['subscriptionNotification'];
    final dynamic oneTimeProductNotification =
        data['oneTimeProductNotification'];
    if (subscriptionNotification != null) {
      print('Processing Subscription');
      final subscriptionId =
          subscriptionNotification['subscriptionId'] as String;
      final purchaseToken = subscriptionNotification['purchaseToken'] as String;
      final productData = productDataMap[subscriptionId]!;
      final result = await handleSubscription(
        userId: null,
        productData: productData,
        token: purchaseToken,
      );
      if (result && ackId != null) {
        await _ackMessage(ackId);
      }
    } else if (oneTimeProductNotification != null) {
      print('Processing NonSubscription');
      final sku = oneTimeProductNotification['sku'] as String;
      final purchaseToken =
          oneTimeProductNotification['purchaseToken'] as String;
      final productData = productDataMap[sku]!;
      final result = await handleNonSubscription(
        userId: null,
        productData: productData,
        token: purchaseToken,
      );
      if (result && ackId != null) {
        await _ackMessage(ackId);
      }
    } else {
      print('invalid data');
    }
  }

  /// ACK Messages from Pub/Sub
  Future<void> _ackMessage(String id) async {
    print('ACK Message');
    final request = pubsub.AcknowledgeRequest(
      ackIds: [id],
    );
    final subscriptionName =
        'projects/$googlePlayProjectName/subscriptions/$googlePlayPubsubBillingTopic-sub';
    await pubsubApi.projects.subscriptions.acknowledge(
      request,
      subscriptionName,
    );
  }

방금 추가한 코드는 10초마다 Google Cloud의 Pub/Sub 주제와 통신하여 새 메시지를 요청합니다. 그런 다음 _processMessage 메서드에서 각 메시지를 처리합니다.

이 메서드는 수신 메시지를 디코딩하고 구독 및 비구독 등 각 구매에 관한 업데이트된 정보를 가져와 필요한 경우 기존 handleSubscription 또는 handleNonSubscription를 호출합니다.

각 메시지는 _askMessage 메서드로 확인해야 합니다.

그런 다음 server.dart 파일에 필요한 종속 항목을 추가합니다. 사용자 인증 정보 구성에 PubsubApi.cloudPlatformScope를 추가합니다.

bin/server.dart

 final clientGooglePlay =
      await auth
.clientViaServiceAccount(clientCredentialsGooglePlay, [
    ap
.AndroidPublisherApi.androidpublisherScope,
    pubsub
.PubsubApi.cloudPlatformScope, // new
 
]);

그런 다음 PubsubApi 인스턴스를 만듭니다.

bin/server.dart

  final pubsubApi = pubsub.PubsubApi(clientGooglePlay);

마지막으로 GooglePlayPurchaseHandler 생성자에 전달합니다.

bin/server.dart

  return {
   
'google_play': GooglePlayPurchaseHandler(
      androidPublisher
,
      iapRepository
,
      pubsubApi
, // new
   
),
   
'app_store': AppStorePurchaseHandler(
      iapRepository
,
   
),
 
};

Google Play 설정

pub/sub 주제에서 결제 이벤트를 소비하는 코드를 작성했지만 pub/sub 주제를 만들지 않았으며 결제 이벤트를 게시하지도 않았습니다. 이제 설정할 차례입니다.

먼저 pub/sub 주제를 만듭니다.

  1. Google Cloud 콘솔에서 Cloud Pub/Sub 페이지로 이동합니다.
  2. Firebase 프로젝트에 있는지 확인하고 + Create Topic(주제 만들기)을 클릭합니다. d5ebf6897a0a8bf5.png
  3. 새 주제에 constants.ts에서 GOOGLE_PLAY_PUBSUB_BILLING_TOPIC에 설정된 값과 동일한 이름을 지정합니다. 이 경우 이름을 play_billing로 지정합니다. 다른 항목을 선택하는 경우 constants.ts를 업데이트해야 합니다. 주제를 만듭니다. 20d690fc543c4212.png
  4. Pub/Sub 주제 목록에서 방금 만든 주제의 세로 점 3개를 클릭하고 권한 보기를 클릭합니다. ea03308190609fb.png
  5. 오른쪽 사이드바에서 주 구성원 추가를 선택합니다.
  6. 여기에서 google-play-developer-notifications@system.gserviceaccount.com를 추가하고 Pub/Sub 게시자 역할을 부여합니다. 55631ec0549215bc.png
  7. 권한 변경사항을 저장합니다.
  8. 방금 만든 주제의 주제 이름을 복사합니다.
  9. Play Console을 다시 열고 모든 앱 목록에서 앱을 선택합니다.
  10. 아래로 스크롤하여 수익 창출 > 수익 창출 설정으로 이동합니다.
  11. 전체 주제를 작성하고 변경사항을 저장합니다. 7e5e875dc6ce5d54.png

이제 모든 Google Play 결제 이벤트가 주제에 게시됩니다.

App Store 결제 이벤트 처리

그런 다음 App Store 결제 이벤트에 대해 동일한 작업을 실행합니다. 앱 스토어 구매에서 업데이트 처리를 구현하는 데는 두 가지 효과적인 방법이 있습니다. 하나는 개발자가 Apple에 제공하고 Apple에서 서버와 통신하는 데 사용하는 Webhook을 구현하는 것입니다. 두 번째 방법은 이 Codelab에서 다루는 방법으로, App Store Server API에 연결하고 정기 결제 정보를 수동으로 가져오는 것입니다.

이 Codelab에서는 두 번째 솔루션에 중점을 두고 있습니다. 웹훅을 구현하려면 서버를 인터넷에 노출해야 하기 때문입니다.

프로덕션 환경에서는 두 가지 모두를 사용하는 것이 이상적입니다. App Store에서 이벤트를 가져오는 웹훅과 이벤트를 놓쳤거나 구독 상태를 다시 확인해야 하는 경우의 서버 API

먼저 lib/app_store_purchase_handler.dart를 열고 AppStoreServerAPI 종속 항목을 추가합니다.

lib/app_store_purchase_handler.dart

final AppStoreServerAPI appStoreServerAPI;

AppStorePurchaseHandler(
 
this.iapRepository,
 
this.appStoreServerAPI, // new
)

_pullStatus 메서드를 호출하는 타이머를 추가하도록 생성자를 수정합니다. 이 타이머는 10초마다 _pullStatus 메서드를 호출합니다. 이 타이머 시간은 필요에 따라 조정할 수 있습니다.

lib/app_store_purchase_handler.dart

  AppStorePurchaseHandler(
    this.iapRepository,
    this.appStoreServerAPI,
  ) {
    // Poll Subscription status every 10 seconds.
    Timer.periodic(Duration(seconds: 10), (_) {
      _pullStatus();
    });
  }

그런 다음 다음과 같이 _pullStatus 메서드를 만듭니다.

lib/app_store_purchase_handler.dart

  Future<void> _pullStatus() async {
    print('Polling App Store');
    final purchases = await iapRepository.getPurchases();
    // filter for App Store subscriptions
    final appStoreSubscriptions = purchases.where((element) =>
        element.type == ProductType.subscription &&
        element.iapSource == IAPSource.appstore);
    for (final purchase in appStoreSubscriptions) {
      final status =
          await appStoreServerAPI.getAllSubscriptionStatuses(purchase.orderId);
      // Obtain all subscriptions for the order id.
      for (final subscription in status.data) {
        // Last transaction contains the subscription status.
        for (final transaction in subscription.lastTransactions) {
          final expirationDate = DateTime.fromMillisecondsSinceEpoch(
              transaction.transactionInfo.expiresDate ?? 0);
          // Check if subscription has expired.
          final isExpired = expirationDate.isBefore(DateTime.now());
          print('Expiration Date: $expirationDate - isExpired: $isExpired');
          // Update the subscription status with the new expiration date and status.
          await iapRepository.updatePurchase(SubscriptionPurchase(
            userId: null,
            productId: transaction.transactionInfo.productId,
            iapSource: IAPSource.appstore,
            orderId: transaction.originalTransactionId,
            purchaseDate: DateTime.fromMillisecondsSinceEpoch(
                transaction.transactionInfo.originalPurchaseDate),
            type: ProductType.subscription,
            expiryDate: expirationDate,
            status: isExpired
                ? SubscriptionStatus.expired
                : SubscriptionStatus.active,
          ));
        }
      }
    }
  }

이 메서드는 다음과 같이 작동합니다.

  1. IapRepository를 사용하여 Firestore에서 활성 상태인 정기 결제 목록을 가져옵니다.
  2. 주문마다 App Store Server API에 정기 결제 상태를 요청합니다.
  3. 해당 정기 결제 구매의 마지막 거래를 가져옵니다.
  4. 만료일을 확인합니다.
  5. Firestore에서 구독 상태를 업데이트합니다. 만료된 경우 만료된 것으로 표시됩니다.

마지막으로 App Store Server API 액세스를 구성하는 데 필요한 모든 코드를 추가합니다.

bin/server.dart

  // add from here
  final subscriptionKeyAppStore =
      File('assets/SubscriptionKey.p8').readAsStringSync();

  // Configure Apple Store API access
  var appStoreEnvironment = AppStoreEnvironment.sandbox(
    bundleId: bundleId,
    issuerId: appStoreIssuerId,
    keyId: appStoreKeyId,
    privateKey: subscriptionKeyAppStore,
  );

  // Stored token for Apple Store API access, if available
  final file = File('assets/appstore.token');
  String? appStoreToken;
  if (file.existsSync() && file.lengthSync() > 0) {
    appStoreToken = file.readAsStringSync();
  }

  final appStoreServerAPI = AppStoreServerAPI(
    AppStoreServerHttpClient(
      appStoreEnvironment,
      jwt: appStoreToken,
      jwtTokenUpdatedCallback: (token) {
        file.writeAsStringSync(token);
      },
    ),
  );
  // to here


  return {
    'google_play': GooglePlayPurchaseHandler(
      androidPublisher,
      iapRepository,
      pubsubApi,
    ),
    'app_store': AppStorePurchaseHandler(
      iapRepository,
      appStoreServerAPI, // new
    ),
  };

App Store 설정

다음으로 App Store를 설정합니다.

  1. App Store Connect에 로그인하고 사용자 및 액세스를 선택합니다.
  2. 키 유형 > 인앱 구매로 이동합니다.
  3. '더하기' 아이콘을 탭하여 새 위치를 추가합니다.
  4. 이름을 지정합니다(예: 'Codelab 키').
  5. 키가 포함된 p8 파일을 다운로드합니다.
  6. 애셋 폴더에 SubscriptionKey.p8라는 이름으로 복사합니다.
  7. 새로 만든 키에서 키 ID를 복사하여 lib/constants.dart 파일에서 appStoreKeyId 상수로 설정합니다.
  8. 키 목록 상단에 있는 Issuer ID를 복사하고 lib/constants.dart 파일에서 appStoreIssuerId 상수로 설정합니다.

9540ea9ada3da151.png

기기에서 구매 추적하기

클라이언트를 보호하기가 어렵기 때문에 구매를 추적하는 가장 안전한 방법은 서버 측에서 하는 것입니다. 하지만 앱이 정기 결제 상태 정보를 기반으로 작업할 수 있도록 정보를 클라이언트로 다시 가져오는 방법이 있어야 합니다. Firestore에 구매 항목을 저장하면 데이터를 클라이언트에 쉽게 동기화하고 자동으로 업데이트된 상태로 유지할 수 있습니다.

List<PastPurchase> purchases에 있는 사용자의 모든 구매 데이터가 포함된 Firestore 저장소인 IAPRepo를 이미 앱에 포함했습니다. 저장소에는 만료되지 않은 상태의 productId storeKeySubscription가 포함된 구매가 있는 경우 true인 hasActiveSubscription,도 포함되어 있습니다. 사용자가 로그인하지 않은 경우 목록은 비어 있습니다.

lib/repo/iap_repo.dart

  void updatePurchases() {
    _purchaseSubscription?.cancel();
    var user = _user;
    if (user == null) {
      purchases = [];
      hasActiveSubscription = false;
      hasUpgrade = false;
      return;
    }
    var purchaseStream = _firestore
        .collection('purchases')
        .where('userId', isEqualTo: user.uid)
        .snapshots();
    _purchaseSubscription = purchaseStream.listen((snapshot) {
      purchases = snapshot.docs.map((DocumentSnapshot document) {
        var data = document.data();
        return PastPurchase.fromJson(data);
      }).toList();

      hasActiveSubscription = purchases.any((element) =>
          element.productId == storeKeySubscription &&
          element.status != Status.expired);

      hasUpgrade = purchases.any(
        (element) => element.productId == storeKeyUpgrade,
      );

      notifyListeners();
    });
  }

모든 구매 로직은 DashPurchases 클래스에 있으며 여기에서 정기 결제를 적용하거나 삭제해야 합니다. 따라서 iapRepo를 클래스의 속성으로 추가하고 생성자에 iapRepo를 할당합니다. 그런 다음 생성자에 리스너를 직접 추가하고 dispose() 메서드에서 리스너를 삭제합니다. 처음에는 리스너가 빈 함수일 수 있습니다. IAPRepoChangeNotifier이고 Firestore의 구매 항목이 변경될 때마다 notifyListeners()를 호출하므로 구매한 제품이 변경될 때마다 purchasesUpdate() 메서드가 항상 호출됩니다.

lib/logic/dash_purchases.dart

  IAPRepo iapRepo;

  DashPurchases(this.counter, this.firebaseNotifier, this.iapRepo) {
    final purchaseUpdated = iapConnection.purchaseStream;
    _subscription = purchaseUpdated.listen(
      _onPurchaseUpdate,
      onDone: _updateStreamOnDone,
      onError: _updateStreamOnError,
    );
    iapRepo.addListener(purchasesUpdate);
    loadPurchases();
  }

  @override
  void dispose() {
    _subscription.cancel();
    iapRepo.removeListener(purchasesUpdate);
    super.dispose();
  }

  void purchasesUpdate() {
    //TODO manage updates
  }

그런 다음 main.dart.의 생성자에 IAPRepo를 제공합니다. 저장소는 이미 Provider에 생성되어 있으므로 context.read를 사용하여 가져올 수 있습니다.

lib/main.dart

        ChangeNotifierProvider<DashPurchases>(
          create
: (context) => DashPurchases(
            context
.read<DashCounter>(),
            context
.read<FirebaseNotifier>(),
            context
.read<IAPRepo>(),
         
),
          lazy
: false,
       
),

다음으로 purchaseUpdate() 함수의 코드를 작성합니다. dash_counter.dart,에서 applyPaidMultiplierremovePaidMultiplier 메서드는 배수를 각각 10 또는 1로 설정하므로 정기 결제가 이미 적용되었는지 확인할 필요가 없습니다. 구독 상태가 변경되면 구매 페이지에 이미 활성화되었다고 표시할 수 있도록 구매 가능한 제품의 상태도 업데이트합니다. 업그레이드 구매 여부에 따라 _beautifiedDashUpgrade 속성을 설정합니다.

lib/logic/dash_purchases.dart

  void purchasesUpdate() {
    var subscriptions = <PurchasableProduct>[];
    var upgrades = <PurchasableProduct>[];
    // Get a list of purchasable products for the subscription and upgrade.
    // This should be 1 per type.
    if (products.isNotEmpty) {
      subscriptions = products
          .where((element) => element.productDetails.id == storeKeySubscription)
          .toList();
      upgrades = products
          .where((element) => element.productDetails.id == storeKeyUpgrade)
          .toList();
    }

    // Set the subscription in the counter logic and show/hide purchased on the
    // purchases page.
    if (iapRepo.hasActiveSubscription) {
      counter.applyPaidMultiplier();
      for (var element in subscriptions) {
        _updateStatus(element, ProductStatus.purchased);
      }
    } else {
      counter.removePaidMultiplier();
      for (var element in subscriptions) {
        _updateStatus(element, ProductStatus.purchasable);
      }
    }

    // Set the Dash beautifier and show/hide purchased on
    // the purchases page.
    if (iapRepo.hasUpgrade != _beautifiedDashUpgrade) {
      _beautifiedDashUpgrade = iapRepo.hasUpgrade;
      for (var element in upgrades) {
        _updateStatus(
            element,
            _beautifiedDashUpgrade
                ? ProductStatus.purchased
                : ProductStatus.purchasable);
      }
      notifyListeners();
    }
  }

  void _updateStatus(PurchasableProduct product, ProductStatus status) {
    if (product.status != ProductStatus.purchased) {
      product.status = ProductStatus.purchased;
      notifyListeners();
    }
  }

이제 백엔드 서비스에서 정기 결제 및 업그레이드 상태가 항상 최신 상태로 유지되고 앱과 동기화됩니다. 앱은 그에 따라 작동하여 Dash 클릭 게임에 정기 결제 및 업그레이드 기능을 적용합니다.

12. 완료

수고하셨습니다. Codelab을 완료했습니다. 이 Codelab의 완료된 코드는 android_studio_folder.pngcomplete 폴더에서 확인할 수 있습니다.

자세한 내용은 다른 Flutter Codelab을 참고하세요.