프로그래밍/Android

[안드로이드] V3 구글 인앱 결제 쉽게 구현하기 2021 - 인앱 상품편

Lou Park 2021. 3. 21. 12:28
Quick Links

강의 1 편 - 설정

강의 2편 - 인앱상품

강의 3편 - 구독상품

Github 예제 코드 

 

이제 인앱 상품 결제를 구현해 보겠습니다.

정확히 어떤 앱이 만들어질지 직접 동영상으로 확인 해 보세요!

 

 

1회성 구매 파트가 이번 포스팅 파트입니다.

- 광고제거, 크리스탈 충전 상품정보를 받아와서 화면에 표시

- 광고제거 구매, 구매여부 체크

- 크리스탈 구매, 크리스탈 충전

 

구현에 앞서 참고사항

예제 코드들은 Kotlin으로 되어있으며, 비동기 처리에 Coroutine을 사용하기도 합니다. 하지만 Coroutine을 사용하지 않고 콜백형식으로 구현하는 방법도 간단히 설명드릴 예정입니다. (Kotlin과 Java의 전환이 어려우시다면 제 예전 글을 참조하셔서 Kotlin을 빠르게 배워보세요!)

 

라이브러리 설정하기

app/build.gradle

buildFeatures.viewBinding은 Kotlin ViewBinding을 이용하기 위함입니다. 사용하지 않으시다면 지워주셔도 됩니다 :)

android {
    ...
    buildFeatures {
        viewBinding true
    }
}

dependencies {
    ...
    // Billing
    def billing_version = "3.0.0"
    implementation "com.android.billingclient:billing:$billing_version"
    // (선택사항) Kotlin을 사용하신다면 추가 해 주세요 
    implementation "com.android.billingclient:billing-ktx:$billing_version"
    // (선택사항) Kotlin Coroutine을 사용하신다면 추가 해 주세요 - Life Cycle
    def lifecycle_version = '2.2.0'
    implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
}

AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.geumson.purchase">
    ...
    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="com.android.vending.BILLING"/>
    ...
</manifest>

 

 

화면 디자인 준비하기

화면 디자인 파트는 중요한 부분은 없으니 빠르게 넘어가겠습니다.

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:gravity="center"
    tools:context=".MainActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="결제하자"
        android:textSize="20sp"/>


    <Button
        android:id="@+id/btn_one_time"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:text="1회성 구매 - 광고제거, 크리스탈 충전"/>

    <Button
        android:id="@+id/btn_subscription"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:text="정기결제"/>

</LinearLayout>

activity_one_time.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="20dp">

    <TextView
        android:id="@+id/tv_sku"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="설명"
        android:textColor="@color/teal_200"
        android:textSize="16sp"/>

    <TextView
        android:id="@+id/tv_remove_ads"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="40dp"
        android:text="광고제거 여부: X"
        android:textSize="16sp"/>

    <Button
        android:id="@+id/btn_purchase_remove_ads"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:text="광고제거 구매"/>

    <TextView
        android:id="@+id/tv_crystal"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="30dp"
        android:text="보유 크리스탈: 0"
        android:textSize="16sp"/>

    <Button
        android:id="@+id/btn_purchase_crystal"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:text="1,000 크리스탈 구매"/>
</LinearLayout>

 

MainActivity.kt

btnOneTime을 누르면 인앱 상품 구매를 하는 화면으로 넘어가도록 할 것입니다. btnSubscription은 다음 포스팅에서 다룰 것이므로 주석처리 해 주셔도 됩니다 :)

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)


        with (binding) {
            btnOneTime.setOnClickListener {
                val intent = Intent(this@MainActivity, OneTimeActivity::class.java)
                startActivity(intent)
            }
            btnSubscription.setOnClickListener {
                val intent = Intent(this@MainActivity, SubscriptionActivity::class.java)
                startActivity(intent)
            }
        }
    }
}

 

 

결제 모듈 만들기

OneTimeActivity에서 하고자 하는 일은 다음과 같습니다.

구글에서는 인앱 결제를 위한 라이브러리로 BillingClient라는걸 제공합니다. 이것도 충분히 편안..할수있는 라이브러리입니다만 기억해야할 것이 많기 때문에 더욱 편하게 쓰기 위해서 우리는 BillingModule이라는 Wrapper 클래스를 만들어서 언제든지 재사용할 수 있도록 할겁니다.

  • tv_sku: 구매가능한 상품에 대한 정보를 표시합니다.
  • tv_remove_ads: 광고제거 상품 구매여부를 표시합니다.
  • tv_crystal: 보유 크리스탈 수를 표시합니다.
  • btn_purchase_remove_ads: 광고제거 상품을 구매합니다.
  • btn_purchase_crystal: 크리스탈 1,000개를 충전합니다.

OneTimeActivity.kt의 화면 모습

BillingModule

Coroutine을 사용하지 않는다면 lifeCycleScope를 받을 필요는 없습니다.

저는 Callback이라는 인터페이스를 하나 만들었는데요, 각 메소드에 대한 설명은 다음과 같습니다.

 

  • onBillingModuleIsReady(): BillingClient가 연결에 성공하여 모듈을 사용할 준비가 되었을때 알리기 위함입니다. 이것이 호출되기 전에는 아무런 기능을 사용할 수 없습니다.
  • onSuccess(Purchase): 구매가 성공했을때 호출됩니다. Purchase는 구매한 정보입니다.
  • onFailure(Int): 구매가 실패했을때 호출됩니다. BillingResponseCode가 넘겨지게 됩니다.
class BillingModule(
    private val activity: Activity,
    private val lifeCycleScope: LifecycleCoroutineScope,
    private val callback: Callback
) {
    interface Callback {
        fun onBillingModulesIsReady()
        fun onSuccess(purchase: Purchase)
        fun onFailure(errorCode: Int)
    }
}

자 그럼, 제일 처음 BillingClient를 초기화해보겠습니다.

구매가 이루어지면 PurchaseUpdatedListener에서 콜백을 수신 받습니다. 주석에 적힌대로 구매가 완료되면 구매 확인(acknowledge) 처리를 해주어야합니다. 그렇지 않으면 환불되니 확실히 확인처리를 해 주어야겠죠?!

 

class BillingModule(
    private val activity: Activity,
    private val lifeCycleScope: LifecycleCoroutineScope,
    private val callback: Callback
) {
    ...

    // 구매관련 업데이트 수신
    private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases ->
        when {
            billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null -> {
                // 제대로 구매 완료, 구매 확인 처리를 해야합니다. 3일 이내 구매확인하지 않으면 자동으로 환불됩니다.
                for (purchase in purchases) {
                    confirmPurchase(purchase)
                }
            }
            else -> {
                // 구매 실패
                callback.onFailure(billingResult.responseCode)
            }
        }
    }

    private var billingClient: BillingClient = BillingClient.newBuilder(activity)
        .setListener(purchasesUpdatedListener)
        .enablePendingPurchases()
        .build()


    private fun confirmPurchase(purchase: Purchase) {
        ...
    }
}

BillingClient가 초기화 되었고, 이제 GooglePlay와 연결해야합니다. 연결 코드는 다음과 같습니다.

onBillingSetupFinished가 호출되고, ResponseOK로 떨어지면 그때부터 저희가 만든 callback으로 사용 가능하다고 알려주게됩니다. 이 시점 이후부터 상품정보를 불러오든, 구매를 하든 가능하게 되는겁니다.

 

class BillingModule(
    private val activity: Activity,
    private val lifeCycleScope: LifecycleCoroutineScope,
    private val callback: Callback
) {
    init {
        billingClient.startConnection(object: BillingClientStateListener {
            override fun onBillingSetupFinished(billingResult: BillingResult) {
                if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                    // 여기서부터 billingClient 활성화 됨
                    callback.onBillingModulesIsReady()
                } else {
                    callback.onFailure(billingResult.responseCode)
                }
            }

            override fun onBillingServiceDisconnected() {
                 // GooglePlay와 연결이 끊어졌을때 재시도하는 로직이 들어갈 수 있음.
                Log.e("BillingModule", "Disconnected.")
            }
        })
    }
}

 

상품정보 불러오기

별건 하지 않았지만 어쨌든 이제는  APK를 만들어서, 구글 플레이 내부 테스트에 배포 후 인앱 상품등록 후 진행하셔야합니다. 그렇지 않을경우 상품정보가 뜨지 않는 오류가 생길 수 있습니다.

class BillingModule(
    private val activity: Activity,
    private val lifeCycleScope: LifecycleCoroutineScope,
    private val callback: Callback
) {
   /**
     * 원하는 sku id를 가지고있는 상품 정보를 가져옵니다.
     * @param sku sku 목록
     * @param resultBlock sku 상품정보 콜백
     */
    fun querySkuDetail(
        type: String = BillingClient.SkuType.INAPP,
        vararg sku: String,
        resultBlock: (List<SkuDetails>) -> Unit = {}
    ) {
        SkuDetailsParams.newBuilder().apply {
            // 인앱, 정기결제 유형중에서 고름. (SkuType.INAPP, SkuType.SUBS)
            setSkusList(sku.asList()).setType(type)
            // 비동기적으로 상품정보를 가져옵니다.
            lifeCycleScope.launch(Dispatchers.IO) {
                val skuDetailResult = billingClient.querySkuDetails(build())
                withContext(Dispatchers.Main) {
                    resultBlock(skuDetailResult.skuDetailsList ?: emptyList())
                }
            }
        }
    }
}

원하는 상품정보를 불러오기 위해서는 querySkuDetails를 호출 해 주어야합니다. 상품 형태는 SkuType.INAPPSkuType.SUBS가 있는데요, 이름에서 알 수 있듯 전자가 인앱결제 상품, 후자가 구독 상품에 대한 내용입니다.

 

* Coroutine을 사용하지 않는 분들을 위해서 Async 버전도 적습니다. billingClinet의 모든 메소드명 뒤에 Async를 붙이면 (응답정보, 반환값) 형태로 콜백을 받을 수 있습니다. 아래와 같이 바꿔서 이용하시면 됩니다.

billingClient.querySkuDetailsAsync(build()) { billingResult, skuDetailsList ->
   resultBlock(skuDetailsList ?: emptyList())
}

받아온 SkuDetails에는 이름, 설명, 가격정보, Sku 코드 등 결제를 위한 다양한 정보들이 있습니다.

이쯤에서 OneTimeActivity를 구현해보며 상품정보를 표시해보겠습니다.

 

 

OneTimeActivity.kt

앞서 만든 BillingModule을 생성하고, onBillingModuleIsReady()에서 상품정보들을 불러와서 저장합니다. 이 화면에서는 광고 제거(Sku.REMOVE_ADS)와 크리스탈 구매 상품(Sku.BUY_1000)을 조회하고 싶으니 인자들을 저렇게 넘겨줍니다.

SkuDetails의 주요 메소드를 살펴보면 다음과 같습니다.

 

  • getPrice: 화폐기호를 포함한 포맷이 지정된 가격을 반환합니다. - "W8,000", "$49.99"
  • getPriceAmountMicros: 백만분의 일(micro) 단위의 가격을 반환합니다. Long 형식이며 $7.99짜리 상품은 7990000의 값을 가집니다.
  • getPriceCurrencyCode: ISO 4217 화폐 코드를 반환합니다 - "USD", "KRW"
  • getSku: 고유 아이디인 SKU 코드를 반환합니다. - "remove_ads", "buy_1000"
  • getTitle: 상품의 제목을 반환합니다. 하지만 "제목 (앱명)" 형식입니다. - "광고제거 구매 (플레이오 - 필수 게임 리워드 앱)"
  • getDescription: 상품 설명을 반환합니다.
class OneTimeActivity : AppCompatActivity() {

    private lateinit var binding: ActivityOneTimeBinding
    private lateinit var bm: BillingModule

    private var mSkuDetails = listOf<SkuDetails>()
        set(value) {
            field = value
            setSkuDetailsView()
        }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityOneTimeBinding.inflate(layoutInflater)
        setContentView(binding.root)

        bm = BillingModule(this, lifecycleScope, object: Callback {
            override fun onBillingModulesIsReady() {
                bm.querySkuDetail(BillingClient.SkuType.INAPP, Sku.REMOVE_ADS, Sku.BUY_1000) { skuDetails ->
                    mSkuDetails = skuDetails
                }
            }

            override fun onSuccess(purchase: Purchase) {

            }

            override fun onFailure(errorCode: Int) {
                when (errorCode) {
                    BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> {
                        Toast.makeText(this@OneTimeActivity, "이미 구입한 상품입니다.", Toast.LENGTH_LONG).show()
                    }
                    BillingClient.BillingResponseCode.USER_CANCELED -> {
                        Toast.makeText(this@OneTimeActivity, "구매를 취소하셨습니다.", Toast.LENGTH_LONG).show()
                    }
                    else -> {
                        Toast.makeText(this@OneTimeActivity, "error: $errorCode", Toast.LENGTH_LONG).show()
                    }
                }
            }
        })
    }


    private fun setSkuDetailsView() {
        val builder = StringBuilder()
        for (skuDetail in mSkuDetails) {
            builder.append("<${skuDetail.title}>\n")
            builder.append(skuDetail.price)
            builder.append("\n======================\n\n")
        }
        binding.tvSku.text = builder
    }
}
object Sku {
    const val REMOVE_ADS = "remove_ads"
    const val BUY_1000 = "buy_1000"
    const val SUB_BASIC = "sub_basic"
    const val SUB_VIP = "sub_vip"
}

앱을 실행해 보면 이제 상품정보가 뜨게 됩니다.

 

상품 구매

상품 구매 파트로 넘어가보겠습니다. 

 

BillingModule.kt

purchase()라는 메소드를 호출하고 SkuDetails를 넘겨주게되면 해당 상품에 대한 구매절차를 시작합니다. 자세한 내용은 주석에 있으니 코드를 천천히 따라가 보세요.

class BillingModule(
    private val activity: Activity,
    private val lifeCycleScope: LifecycleCoroutineScope,
    private val callback: Callback
) {
    ...
    /**
     * 구매 시작하기
     * @param skuDetail 구매하고자하는 항목. querySkuDetail()을 통해 획득한 SkuDetail
     */
    fun purchase(
        skuDetail: SkuDetails
    ) {
        val flowParams = BillingFlowParams.newBuilder().apply {
            setSkuDetails(skuDetail)
        }.build()

        // 구매 절차를 시작, OK라면 제대로 된것입니다.
        val responseCode = billingClient.launchBillingFlow(activity, flowParams).responseCode
        if (responseCode != BillingClient.BillingResponseCode.OK) {
            callback.onFailure(responseCode)
        }
        // 이후 부터는 purchasesUpdatedListener를 거치게 됩니다.
    }
    ...
}

 

소비(consume)와 구매 확인하기 (acknowledge)

크리스탈 1,000개 구매처럼 사용자가 반복적으로 구매할 수 있는 상품에 대해서는 소비(consume)처리를 해 주어야 재 구매가 가능합니다. 그렇지 않으면 계정당 1회 구매 밖에 되지 않겠죠?

 

사용자가 결제를 완료 했는데 (purchaseState == PurchaseState.PURCHASED) 구매 확인이 되지 않았다(!purchase.isAcknowledged)의 경우에도 자동 환불이 되는걸 막기 위해 구매 확인 절차가 필요합니다.

 

이 두 케이스를 확실하게 잡기 위해서 confirmPurchase로 구매 확인처리를 하는 메소드를 만들어보았습니다.

BillingClient.consumePurchase()가 소비, BillingClient.acknowledgePurchase()가 확인을 하는 부분입니다.

class BillingModule(
    private val activity: Activity,
    private val lifeCycleScope: LifecycleCoroutineScope,
    private val callback: Callback
) {
    ...
    // '소비'되어야 하는 sku 들을 적어줍니다.
    private val consumableSkus = setOf(Sku.BUY_1000)
    
    // 구매관련 업데이트 수신
    private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases ->
        when {
            billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null -> {
                // 제대로 구매 완료, 구매 확인 처리를 해야합니다. 3일 이내 구매확인하지 않으면 자동으로 환불됩니다.
                for (purchase in purchases) {
                    confirmPurchase(purchase)
                }
            }
            else -> {
                // 구매 실패
                callback.onFailure(billingResult.responseCode)
            }
        }
    }

    /**
     * 구매 확인 처리
     * @param purchase 확인처리할 아이템의 구매정보
     */
    private fun confirmPurchase(purchase: Purchase) {
        when {
            consumableSkus.contains(purchase.sku) -> {
                // 소비성 구매는 consume을 해주어야합니다.
                val consumeParams = ConsumeParams.newBuilder()
                    .setPurchaseToken(purchase.purchaseToken)
                    .build()

                lifeCycleScope.launch(Dispatchers.IO) {
                    val result = billingClient.consumePurchase(consumeParams)
                    withContext(Dispatchers.Main) {
                        if (result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                            callback.onSuccess(purchase)
                        }
                    }
                }
            }
            purchase.purchaseState == Purchase.PurchaseState.PURCHASED && !purchase.isAcknowledged -> {
                // 구매는 완료되었으나 확인이 되어있지 않다면 구매 확인 처리를 합니다.
                val ackPurchaseParams = AcknowledgePurchaseParams.newBuilder()
                    .setPurchaseToken(purchase.purchaseToken)
                lifeCycleScope.launch(Dispatchers.IO) {
                    val result = billingClient.acknowledgePurchase(ackPurchaseParams.build())
                    withContext(Dispatchers.Main) {
                        if (result.responseCode == BillingClient.BillingResponseCode.OK) {
                            callback.onSuccess(purchase)
                        } else {
                            callback.onFailure(result.responseCode)
                        }
                    }
                }
            }
        }
    }
    ...
}

사실 사용자가 구매할때 네트워크 오류나...갑자기 폰이 부서지거나..기타 등등의 이유로 구매확인을 놓칠 수도 있습니다. 이럴 경우를 대비해 Activity의 onResume()시 놓친 구매건이있으면 확인을 해주는 코드도 추가합니다. 

class BillingModule(...) {
    ...
    /**
     * 구매를 했지만 확인되지 않은 건에대해서 확인처리를 합니다.
     * @param type BillingClient.SkuType.INAPP 또는 BillingClient.SkuType.SUBS
     */
    fun onResume(type: String) {
        if (billingClient.isReady) {
            billingClient.queryPurchases(type).purchasesList?.let { purchaseList ->
                for (purchase in purchaseList) {
                    if (!purchase.isAcknowledged && purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
                        confirmPurchase(purchase)
                    }
                }
            }
        }
    }
    ...
}

 

OneTimeActivity.kt

다시 화면으로 돌아와서, 만든 구매 코드를 적용시켜 보겠습니다.

class OneTimeActivity : AppCompatActivity() {

    private lateinit var binding: ActivityOneTimeBinding
    private lateinit var bm: BillingModule

    private val storage: AppStorage by lazy {
        AppStorage(this)
    }

    private var mSkuDetails = listOf<SkuDetails>()
        set(value) {
            field = value
            setSkuDetailsView()
        }


    // 이전에 광고 제거 구매 여부
    private var isPurchasedRemoveAds = false
        set(value) {
            field = value
            updateRemoveAdsView()
        }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityOneTimeBinding.inflate(layoutInflater)
        setContentView(binding.root)

        bm = BillingModule(this, lifecycleScope, object: Callback {
            override fun onBillingModulesIsReady() {
                bm.querySkuDetail(BillingClient.SkuType.INAPP, Sku.REMOVE_ADS, Sku.BUY_1000) { skuDetails ->
                    mSkuDetails = skuDetails
                }
            }

            override fun onSuccess(purchase: Purchase) {
                when (purchase.sku) {
                    Sku.REMOVE_ADS -> {
                        isPurchasedRemoveAds = true
                    }
                    Sku.BUY_1000 -> {
                        // 크리스탈 1000개를 충전합니다.
                        val currentCrystal = storage.getInt(PREF_KEY_CRYSTAL)
                        storage.put(PREF_KEY_CRYSTAL, currentCrystal + 1000)
                        updateCrystalView()
                    }
                }
            }

            override fun onFailure(errorCode: Int) {
                when (errorCode) {
                    BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> {
                        Toast.makeText(this@OneTimeActivity, "이미 구입한 상품입니다.", Toast.LENGTH_LONG).show()
                    }
                    BillingClient.BillingResponseCode.USER_CANCELED -> {
                        Toast.makeText(this@OneTimeActivity, "구매를 취소하셨습니다.", Toast.LENGTH_LONG).show()
                    }
                    else -> {
                        Toast.makeText(this@OneTimeActivity, "error: $errorCode", Toast.LENGTH_LONG).show()
                    }
                }
            }
        })

        updateCrystalView()
        setClickListeners()
    }

    private fun setClickListeners() {
        with (binding) {
            // 광고 제거 구매 버튼 클릭
            btnPurchaseRemoveAds.setOnClickListener {
                mSkuDetails.find { it.sku == Sku.REMOVE_ADS }?.let { skuDetail ->
                    bm.purchase(skuDetail)
                } ?: also {
                    Toast.makeText(this@OneTimeActivity, "상품을 찾을 수 없습니다.", Toast.LENGTH_LONG).show()
                }
            }

            btnPurchaseCrystal.setOnClickListener {
                mSkuDetails.find { it.sku == Sku.BUY_1000 }?.let { skuDetail ->
                    bm.purchase(skuDetail)
                } ?: also {
                    Toast.makeText(this@OneTimeActivity, "상품을 찾을 수 없습니다.", Toast.LENGTH_LONG).show()
                }
            }
        }
    }

    private fun setSkuDetailsView() {
        val builder = StringBuilder()
        for (skuDetail in mSkuDetails) {
            builder.append("<${skuDetail.title}>\n")
            builder.append(skuDetail.price)
            builder.append("\n======================\n\n")
        }
        binding.tvSku.text = builder
    }

    private fun updateRemoveAdsView() {
        binding.tvRemoveAds.text = "광고 제거 여부: ${if (isPurchasedRemoveAds) "O" else "X"}"
    }

    private fun updateCrystalView() {
        binding.tvCrystal.text = "보유 크리스탈: ${storage.getInt(PREF_KEY_CRYSTAL)}"
    }

    override fun onResume() {
        super.onResume()
        bm.onResume(BillingClient.SkuType.INAPP)
    }

    companion object {
        private const val PREF_KEY_CRYSTAL = "crystal"
    }
}

 

크리스탈 저장에 이용되는 SharedPreference Util 클래스인 AppStorage의 내용은 다음과 같습니다.

class AppStorage(context: Context) {

    private var pref: SharedPreferences = context.getSharedPreferences("storage", Context.MODE_PRIVATE)

    fun put(key: String?, value: Int) {
        val editor = pref.edit()
        editor.putInt(key, value)
        editor.apply()
    }

    fun getInt(key: String?): Int {
        return pref.getInt(key, 0)
    }
}

 

이제 정상적으로 구매도 되고, 충전도 될것입니다. 하지만 아직 문제가 남았죠!

광고제거 구매를하고 화면을 나갔다오면 구매를 안한걸로 표시가 됩니다. 구매여부 확인도 마저 구현해보도록 하겠습니다.

 

구매여부 확인

BillingModule.kt

매번 검사 코드를 넣는게 귀찮아서 isPurchaseConfirmed()라는 Extension Function을 만들어서 구현한 모습입니다. 사용자의 구매정보를 가져와서 해당 sku가 정상적으로 구매 완료된 상태라면 true를 반환, 기록이 없다면 false를 반환합니다.

class BillingModule(
    ...
) {
    /**
     * 구매 여부 체크, 소비성 구매가 아닌 항목에 한정.
     * @param sku
     */
    fun checkPurchased(
        sku: String,
        resultBlock: (purchased: Boolean) -> Unit
    ) {
        billingClient.queryPurchases(BillingClient.SkuType.INAPP).purchasesList?.let { purchaseList ->
            for (purchase in purchaseList) {
                if (purchase.sku == sku && purchase.isPurchaseConfirmed()) {
                    return resultBlock(true)
                }
            }
            return resultBlock(false)
        }
    }

    // 구매 확인 검사 Extension
    private fun Purchase.isPurchaseConfirmed(): Boolean {
        return this.isAcknowledged && this.purchaseState == Purchase.PurchaseState.PURCHASED
    }

    interface Callback {
        fun onBillingModulesIsReady()
        fun onSuccess(purchase: Purchase)
        fun onFailure(errorCode: Int)
    }
}

 

OneTimeActivity.kt

화면에서는 BillingModule이 초기화된 직후 불러와주면 제대로 적용됩니다. 구매여부 확인은 비 소비성 상품인 광고제거에만 필요하므로 그렇게 구현하였습니다.

class OneTimeActivity : AppCompatActivity() {
    ...

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityOneTimeBinding.inflate(layoutInflater)
        setContentView(binding.root)

        bm = BillingModule(this, lifecycleScope, object: Callback {
            override fun onBillingModulesIsReady() {
                bm.querySkuDetail(BillingClient.SkuType.INAPP, Sku.REMOVE_ADS, Sku.BUY_1000) { skuDetails ->
                    mSkuDetails = skuDetails
                }

                bm.checkPurchased(Sku.REMOVE_ADS) {
                    isPurchasedRemoveAds = it
                }
            }
        }
    }
}

 

인앱 구매 완성!

잘 따라오셨다면 무리없이 결제가 동작할 것입니다. 잘 안된다면 오류 코드에 따라 다음과 같은 문제가 있을 수 있습니다.

  • BillingResponseCode.DEVELOPER_ERROR(5): 전달한 인자 값이 잘못되었습니다. SkuType을 넣어야하는데 Sku를 전달한다거나 등의 문제가 있을 수 있습니다.
  • BillingResponseCode.ITEM_ALREADY_OWNED(7): 소비성 아이템 구매시 이런 오류가 뜬다면 consume이 제대로 되지 않았다는 겁니다.
  • 요청하신 항목은 구매할 수 없습니다: 라이선스 테스터와 내부 테스터로 등록된 구글 계정을 제외한 모든 계정을 휴대폰에서 제거 후 다시 시도 해 보세요.