---
title: "Android 인앱 구매, 2부: Google Play 결제 라이브러리 (Google Play Billing Library)로 구매 처리하기"
description: "Android 상에서 인앱 구매를 구현하는 방법에 대한 시리즈의 두 번째 사용 지침서입니다. 구매를 처리하고 구독 변경을 처리하는 방법"
url: "https://adapty.io/ko/blog/android-in-app-purchases-google-play-billing-library-part-2/"
language: "ko"
slug: "android-in-app-purchases-google-play-billing-library-part-2"
category: "adapty-updates"
date: "2021-03-24T00:00:00.000Z"
date_modified: "2021-03-24T00:00:00.000Z"
author: "Vlad Guriev"
---

# Android 인앱 구매, 2부: Google Play 결제 라이브러리 (Google Play Billing Library)로 구매 처리하기

이전기사에서 결제 라이브러리와 함께 작동하는 래퍼클래스를 생성했습니다.

```kotlin
import android.content.Contextnimport com.android.billingclient.api.*nclass BillingClientWrapper(context: Context) : PurchasesUpdatedListener {
   interface OnQueryProductsListener {
       fun onSuccess(products: Listu0026lt;SkuDetailsu0026gt;)
       fun onFailure(error: Error)
   }
   class Error(val responseCode: Int, val debugMessage: String)
   private val billingClient = BillingClient
       .newBuilder(context)
       .enablePendingPurchases()
       .setListener(this)
       .build()
   fun queryProducts(listener: OnQueryProductsListener) {
       val skusList = listOf("premium_sub_month", "premium_sub_year", "some_inapp")
       queryProductsForType(
           skusList,
           BillingClient.SkuType.SUBS
       ) { billingResult, skuDetailsList -u003e
           if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
               val products = skuDetailsList ?: mutableListOf()
               queryProductsForType(
                   skusList,
                   BillingClient.SkuType.INAPP
               ) { billingResult, skuDetailsList -u003e
                   if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                       products.addAll(skuDetailsList ?: listOf())
                       listener.onSuccess(products)
                   } else {
                       listener.onFailure(
                           Error(billingResult.responseCode, billingResult.debugMessage)
                       )
                   }
               }
           } else {
               listener.onFailure(
                   Error(billingResult.responseCode, billingResult.debugMessage)
               )
           }
       }
   }
   private fun queryProductsForType(
       skusList: Listu0026lt;Stringu0026gt;,
       @BillingClient.SkuType type: String,
       listener: SkuDetailsResponseListener
   ) {
       onConnected {
           billingClient.querySkuDetailsAsync(
               SkuDetailsParams.newBuilder().setSkusList(skusList).setType(type).build(),
               listener
           )
       }
   }
   private fun onConnected(block: () -u003e Unit) {
       billingClient.startConnection(object : BillingClientStateListener {
           override fun onBillingSetupFinished(billingResult: BillingResult) {
               block()
           }
           override fun onBillingServiceDisconnected() {
               // Try to restart the connection on the next request to
               // Google Play by calling the startConnection() method.
           }
       })
   }
   override fun onPurchasesUpdated(billingResult: BillingResult, purchaseList: MutableListu0026lt;Purchaseu0026gt;?) {
       // here come callbacks about new purchases
   }
}
```

구매구현(purchaseimplementation)으로진행하여 클래스를 개선해 보겠습니다.

## 구독화면 디자인하기

인앱구매 기능이 있는 모든 앱 (app)에는페이월 (paywall)화면이있습니다.[Google정책](https://support.google.com/googleplay/android-developer/answer/9900533)이이러한 화면에 반드시 있어야 하는 최소한의 요소와지침 텍스트를 정의합니다.요약하자면다음과 같습니다.페이월화면에서 구독 조건,비용및 기간을 명시하고 앱을 사용하기 위해 구독이 필요한지여부를 구체적으로 지정해야 합니다.또한사용자가 조건을 검토하기 위해 추가 작업을 수행하도록강요해서는 안 됩니다.

여기서는단순화된 페이월 화면을 예로 사용합니다.

![android sample paywall](https://adapty.io/wp-content/uploads/2023/03/611e0b607bcbad683e723934_image20255-3.png)
*Android샘플페이월*

페이월화면에는 다음 요소가 있습니다.

- 제목.
- 구매 절차를 시작하도록 설정된 버튼입니다. 이 버튼은 사용자에게 제목 및 비용과 같은 구독 옵션의 일반 세부 정보에 대해 안내하며, 비용의 경우 현지 통화로 표시됩니다.
- 지침 텍스트.
- 이전에 구매한 항목을 복원하는 버튼입니다. 이 요소는 구독 또는 비소모성 구매 기능이 있는 모든 앱의 필수 요소입니다.

코드를수정하여 제품 세부 정보 표시

이예제에는 4가지제품이 있습니다.

- 자동 갱신 구독 2개 ("premium_sub_month" 및 "premium_sub_year"),
- 두 번 구매할 수 없는 제품 또는 비소모성 제품 ("unlock_feature"),
- 여러 번 구매할 수 있는 상품 또는 소모성 제품 ("coin_pack_large").

예제를단순화하기 위해,이전기사에서 만든 BillingClientWrapper와함께 삽입할 액티비티와 고정된 수의 구매 버튼이 있는레이아웃을 사용하겠습니다.

```kotlin
class PaywallActivity: BaseActivity() {nn   @Injectn   lateinit var billingClientWrapper: BillingClientWrappernn   override fun onCreate(savedInstanceState: Bundle?) {n       super.onCreate(savedInstanceState)n       setContentView(R.layout.activity_paywall)nn       displayProducts() //to be declared belown   }n}
```

편의를위해,키가제품의 [sku](https://developer.android.com/reference/com/android/billingclient/api/SkuDetails#getSku%28))인맵을 추가하고,값은화면의 해당 버튼입니다.

```kotlin
private val purchaseButtonsMap: Mapu0026lt;String, Buttonu0026gt; by lazy(LazyThreadSafetyMode.NONE) {
   mapOf(
       "premium_sub_month" to monthlySubButton,
       "premium_sub_year" to yearlySubButton,
       "coin_pack_large" to coinPackLargeButton,
       "unlock_feature" to unlockFeatureButton,
   )
}
```

UI에제품을 표시하는 메소드를 선언합니다.그것은[이전](https://adapty.io/blog/android-in-app-subscriptions-part-1/)사용지침서에서 소개했던 논리를 기반으로 할 것입니다.

```kotlin
private fun displayProducts() {
   billingClientWrapper.queryProducts(object : BillingClientWrapper.OnQueryProductsListener {
       override fun onSuccess(products: Listu0026lt;SkuDetailsu003eu0026gt;) {
           products.forEach { product -u003e
               purchaseButtonsMap[product.sku]?.apply {
                   text = "${product.descriptio
} for ${product.price}"
                   setOnClickListener {
                       billingClientWrapper.purchase(this@PaywallActivity, product) //will be declared below
                   }
               }
           }
       }n
       override fun onFailure(error: BillingClientWrapper.Error) {
           //handle error
       }
   })
}
```

product.price는계정의 현지 통화를 명시하는 문자열로 형식이 미리지정되어 있습니다.추가서식이 필요하지 않습니다.[SkuDetails](https://developer.android.com/reference/com/android/billingclient/api/SkuDetails)클래스객체의 다른 모든 속성도 완전히 현지화되어 나타납니다.

## 구매절차 시작

구매를처리하려면 앱의 메인 스레드에서 [launchBillingFlow()](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#launchBillingFlow%28android.app.Activity,%20com.android.billingclient.api.BillingFlowParams))메소드를불러와야 합니다.

이를위해 BillingClientWrapper에purchase()메소드를추가합니다.

```kotlin
fun purchase(activity: Activity, product: SkuDetails) {n   onConnected {n       activity.runOnUiThread {n           billingClient.launchBillingFlow(n               activity,n               BillingFlowParams.newBuilder().setSkuDetails(product).build()n           )n       }n   }n}
```

launchBillingFlow()메소드에는콜백이 없습니다.응답은onPurchasesUpdated()메소드로반환됩니다.[이전기사](https://adapty.io/blog/android-in-app-subscriptions-part-1/)에서메소드를 선언하고 나중을 위해 저장한 것을 기억하시나요?이제그것을 필요로 합니다.

onPurchasesUpdated()메소드는사용자와 구매 대화 상자 간의 상호 작용 결과가 있을때마다 호출됩니다.성공적인구매일 수도 있고 사용자가 대화 상자를 닫아서 발생한구매 취소일 수도 있는데,이경우 [BillingResponseCode.*USER_CANCELED*](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponseCode#USER_CANCELED) 코드를받게 됩니다.또는다른 가능한 오류 메시지일 수 있습니다.

이전기사의 OnQueryProductsListener인터페이스와유사한 방식으로,BillingClientWrapper 클래스에서OnPurchaseListener인터페이스를선언하겠습니다.해당인터페이스를 통해 구매 ([Purchase](https://developer.android.com/reference/com/android/billingclient/api/Purchase)클래스객체)또는[이전가이드](https://adapty.io/blog/android-in-app-subscriptions/)에서선언한 오류 메시지 중 하나를 받게 됩니다.다음시간에는 구매가 성공하더라도 Purchase클래스객체가 무효로 될 수 있는 경우에 대해 다룰 것입니다.

```kotlin
interface OnPurchaseListener {n   fun onPurchaseSuccess(purchase: Purchase?)n   fun onPurchaseFailure(error: Error)n}nnvar onPurchaseListener: OnPurchaseListener? = null
```

다음으로PaywallActivity내에서이를 구현합니다.

```kotlin
class PaywallActivity: BaseActivity(), BillingClientWrapper.OnPurchaseListener {n
   @Inject
   lateinit var billingClientWrapper: BillingClientWrappern
   private val purchaseButtonsMap: Mapu0026lt;String, Buttonu0026gt; by lazy(LazyThreadSafetyMode.NONE) {
       mapOf(
           "premium_sub_month" to monthlySubButton,
           "premium_sub_year" to yearlySubButton,
           "coin_pack_large" to coinPackLargeButton,
           "unlock_feature" to unlockFeatureButton,
       )
   }n
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_paywall)n
       billingClientWrapper.onPurchaseListener = thisn
       displayProducts()
   }n
   override fun onPurchaseSuccess(purchase: Purchase?) {
       //handle successful purchase
   }n
   override fun onPurchaseFailure(error: BillingClientWrapper.Error) {
       //handle error or user cancelation
   }n
   private fun displayProducts() {
       billingClientWrapper.queryProducts(object : BillingClientWrapper.OnQueryProductsListener {
           override fun onSuccess(products: Listu0026lt;SkuDetailsu0026gt;) {
               products.forEach { product -u003e
                   purchaseButtonsMap[product.sku]?.apply {
                       text = "${product.descriptio
} for ${product.price}"
                       setOnClickListener {
                           billingClientWrapper.purchase(this@PaywallActivity, product)
                       }
                   }
               }
           }n
           override fun onFailure(error: BillingClientWrapper.Error) {
               //handle error
           }
       })
   }
}
```

onPurchaseUpdated()에몇 가지 논리를 추가해 봅시다.

```kotlin
override fun onPurchasesUpdated(n   billingResult: BillingResult,n   purchaseList: MutableListu0026lt;Purchaseu0026gt;?n) {n   when (billingResult.responseCode) {n       BillingClient.BillingResponseCode.OK -u003e {n           if (purchaseList == null) {n               //to be discussed in the next articlen               onPurchaseListener?.onPurchaseSuccess(null)n               returnn           }nn           purchaseList.forEach(::processPurchase) //to be declared belown       }n       else -u003e {n           //error occured or user canceledn           onPurchaseListener?.onPurchaseFailure(n               BillingClientWrapper.Error(n                   billingResult.responseCode,n                   billingResult.debugMessagen               )n           )n       }n   }n}
```

만일purchaseList가비어 있지 않으면,먼저purchaseState이[PurchaseState.PURCHASED](https://developer.android.com/google/play/billing/integrate#pending)와동일한 아이템을 골라내야 하는데,[보류중](https://developer.android.com/google/play/billing/integrate#pending)인구매도 있기 때문입니다.이러한경우,유저플로우는 여기에서 끝납니다.[문서](https://developer.android.com/google/play/billing/integrate#process)에따르면,이때에는 우리 서버에서 구매를 인증해야 합니다.이시리즈의 후속 기사에서 이 점에 대해 다룰 것입니다.서버인증이 완료되면 콘텐츠를 전달하고 Google에알려야 합니다.후자대로하지 않으면,구매는3일후에 자동으로 환불됩니다.이정책이 GooglePlay에만적용된다는 점은 매우 흥미롭습니다.iOS는유사한 정책을 부과하지 않습니다.사용자에게콘텐츠 전달을 승인하는 방법에는 두 가지가 있습니다.

- 클라이언트 측에서 [acknowledgePurchase()](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#acknowledgepurchase) 를 통하기,
- 백엔드 측에서 [Product.Purchases.Acknowledge](https://developers.google.com/android-publisher/api-ref/purchases/products/acknowledge)/[Purchases.Subscriptions.Acknowledge](https://developers.google.com/android-publisher/api-ref/purchases/subscriptions/acknowledge) 를 통하기.

소모성제품을 다루는 경우,이대신 [consumeAsync()](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#consumeasync)메소드를불러와야 합니다.그것은내부 구매를 승인하는 동시에,제품을다시 구매할 수 있도록 합니다.이는결제 라이브러리에서만 수행할 수 있습니다.어떤이유로인가,Google Play 개발자API는백엔드 측에서 이 작업을 수행할 수 있는 방법을 제공하지않습니다.Google Play와달리 AppStore와AppGallery는각각 AppStore Connect와AppGalleryConnect를통해 제품을 소모품으로 구성한다는 점이 흥미롭습니다.그러나이러한 AppGallery제품도명시적인 방식으로 소비되어야 합니다.

자체백엔드를 실행 중인지 여부를 보고하는 두 가지 버전의processPurchase()메소드뿐만아니라,승인및 소비 메소드를 작성해 보겠습니다.

```kotlin
private fun acknowledgePurchase(n   purchase: Purchase,n   callback: AcknowledgePurchaseResponseListenern) {n   onConnected {n       billingClient.acknowledgePurchase(n           AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchase.purchaseToken)n               .build(),n           callback::onAcknowledgePurchaseResponsen       )n   }n}nnprivate fun consumePurchase(purchase: Purchase, callback: ConsumeResponseListener) {n   onConnected {n       billingClient.consumeAsync(n           ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build()n       ) { billingResult, purchaseToken -u003en           callback.onConsumeResponse(billingResult, purchaseToken)n       }n   }n}
```

서버인증 불포함:

```kotlin
private fun processPurchase(purchase: Purchase) {
   if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
       onPurchaseListener?.onPurchaseSuccess(purchase)n
       if (purchase.skus.firstOrNull() == "coin_pack_large") {
           //consuming our only consumable product
           consumePurchase(purchase) { billingResult, purchaseToken -u003e
               if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                   //implement retry logic or try to consume again in onResume()
               }
           }
       } else if (!purchase.isAcknowledged) {
           acknowledgePurchase(purchase) { billingResult -u003e
               if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                   //implement retry logic or try to acknowledge again in onResume()
               }
           }
       }
   }
}
```

서버인증 포함:

```kotlin
private fun processPurchase(purchase: Purchase) {
   if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
       api.verifyPurchase(purchase.purchaseToken) { error -u003e
           if (error != null) {
               onPurchaseListener?.onPurchaseSuccess(purchase)n
               if (purchase.skus.firstOrNull() == "coin_pack_large") {
                   //consuming our only consumable product
                   billingClient.consumeAsync(
                       ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken)
                           .build()
                   ) { billingResult, purchaseToken -u003e
                       if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                           //implement retry logic or try to consume again in onResume()
                       }
                   }
               }
           } else {
               //handle verification error
           }
       }
   }
}
```

다음기사에서는 구매에 대한 서버 인증에 대해 더 자세히다룰 것입니다.

물론,클라이언트측의 두 번째 예에서 승인을 구현할 수도 있습니다.그러나백엔드에서 무언가를 할 수 있는 경우라면,그렇게해야 합니다.승인또는 소비 프로세스 중 하나에서 오류가 발생하면,이를무시해서는 안 됩니다.구매가[PurchaseState.*PURCHASED*](https://developer.android.com/reference/com/android/billingclient/api/Purchase.PurchaseState#PURCHASED)상태를수신한 후 3일이내에 이들 중 아무 것도 실행되지 않은 경우,취소및 환불됩니다.따라서백엔드에서 수행이 불가능하고 여러 번 시도한 후에도오류가 계속 발생하는 경우에는,신뢰할수 있는 차선책이 있습니다.사용자의현재 구매를 [onStart()또는onResume()](https://developer.android.com/reference/androidx/lifecycle/Lifecycle.Event)과같은 수명 주기 메소드를 통해 가져와야 하고,사용자가인터넷에 연결된 상태에서 3일이내에 앱을 실행하기를 바라며 계속 시도합니다.:)

따라서BillingClientWrapper클래스의현재 버전은 다음과 같습니다.

```kotlin
import android.app.Activitynimport android.content.Contextnimport com.android.billingclient.api.*nnclass BillingClientWrapper(context: Context, private val api: Api) : PurchasesUpdatedListener {n
   interface OnQueryProductsListener {
       fun onSuccess(products: Listu0026lt;SkuDetailsu0026gt;)
       fun onFailure(error: Error)
   }n
   interface OnPurchaseListener {
       fun onPurchaseSuccess(purchase: Purchase?)
       fun onPurchaseFailure(error: Error)
   }n
   var onPurchaseListener: OnPurchaseListener? = nulln
   class Error(val responseCode: Int, val debugMessage: String)n
   private val billingClient = BillingClient
       .newBuilder(context)
       .enablePendingPurchases()
       .setListener(this)
       .build()n
   fun purchase(activity: Activity, product: SkuDetails) {
       onConnected {
           activity.runOnUiThread {
               billingClient.launchBillingFlow(
                   activity,
                   BillingFlowParams.newBuilder().setSkuDetails(product).build()
               )
           }
       }
   }n
   fun queryProducts(listener: OnQueryProductsListener) {
       val skusList = listOf("premium_sub_month", "premium_sub_year", "coin_pack_large", "unlock_feature")n
       queryProductsForType(
           skusList,
           BillingClient.SkuType.SUBS
       ) { billingResult, skuDetailsList -u003e
           if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
               val products = skuDetailsList ?: mutableListOf()
               queryProductsForType(
                   skusList,
                   BillingClient.SkuType.INAPP
               ) { billingResult, skuDetailsList -u003e
                   if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                       products.addAll(skuDetailsList ?: listOf())
                       listener.onSuccess(products)
                   } else {
                       listener.onFailure(
                           Error(billingResult.responseCode, billingResult.debugMessage)
                       )
                   }
               }
           } else {
               listener.onFailure(
                   Error(billingResult.responseCode, billingResult.debugMessage)
               )
           }
       }
   }n
   private fun queryProductsForType(
       skusList: Listu0026lt;Stringu0026gt;,
       @BillingClient.SkuType type: String,
       listener: SkuDetailsResponseListener
   ) {
       onConnected {
           billingClient.querySkuDetailsAsync(
               SkuDetailsParams.newBuilder().setSkusList(skusList).setType(type).build(),
               listener
           )
       }
   }n
   private fun onConnected(block: () -u003e Unit) {
       billingClient.startConnection(object : BillingClientStateListener {
           override fun onBillingSetupFinished(billingResult: BillingResult) {
               block()
           }n
           override fun onBillingServiceDisconnected() {
               // Try to restart the connection on the next request to
               // Google Play by calling the startConnection() method.
           }
       })
   }n
   override fun onPurchasesUpdated(
       billingResult: BillingResult,
       purchaseList: MutableListu0026lt;Purchaseu0026gt;?
   ) {
       when (billingResult.responseCode) {
           BillingClient.BillingResponseCode.OK -u003e {
               if (purchaseList == null) {
                   //to be discussed in the next article
                   onPurchaseListener?.onPurchaseSuccess(null)
                   return
               }n
               purchaseList.forEach(::processPurchase)
           }
           else -u003e {
               //error occured or user canceled
               onPurchaseListener?.onPurchaseFailure(
                   BillingClientWrapper.Error(
                       billingResult.responseCode,
                       billingResult.debugMessage
                   )
               )
           }
       }
   }n
   private fun processPurchase(purchase: Purchase) {
       if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
           api.verifyPurchase(purchase.purchaseToken) { error -u003e
               if (error != null) {
                   onPurchaseListener?.onPurchaseSuccess(purchase)n
                   if (purchase.skus.firstOrNull() == "coin_pack_large") {
                       //consuming our only consumable product
                       billingClient.consumeAsync(
                           ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken)
                               .build()
                       ) { billingResult, purchaseToken -u003e
                           if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                               //implement retry logic or try to consume again in onResume()
                           }
                       }
                   }
               } else {
                   //handle verification error
               }
           }
       }
   }n
   private fun acknowledgePurchase(
       purchase: Purchase,
       callback: AcknowledgePurchaseResponseListener
   ) {
       onConnected {
           billingClient.acknowledgePurchase(
               AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchase.purchaseToken)
                   .build(),
               callback::onAcknowledgePurchaseResponse
           )
       }
   }n
   private fun consumePurchase(purchase: Purchase, callback: ConsumeResponseListener) {
       onConnected {
           billingClient.consumeAsync(
               ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build()
           ) { billingResult, purchaseToken -u003e
               callback.onConsumeResponse(billingResult, purchaseToken)
           }
       }
   }
}
```

사용자가이미 구매했는지 여부에 관계없이,모든제품에 대해 버튼이 활성화된 이유가 궁금할 수 있습니다.또는두 구독을 모두 구입하면 어떻게 되는지에 대해 질문이있을 수 있습니다.첫번째 구독이 두 번째 구독을 대체합니까,아니면둘 다 유지됩니까?다음기사에서 모두 답해드립니다.:)
