프로그래밍/Android

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

Lou Park 2021. 3. 22. 00:38
Quick Links

강의 1 편 - 설정

강의 2편 - 인앱상품

강의 3편 - 구독상품

Github 예제 코드 

 

이번에는 정기결제 상품 인앱 결제를 구현해 보도록 하겠습니다.

어떤 앱을 만들게 될지 짧은 동영상으로 먼저 보시죠!

 

 

 

아래 기능들을 구현해볼겁니다.

- 정기결제 상품 정보 표시

- 정기결제 상태 확인

- 정기결제 업그레이드, 다운그레이드

- 정기결제 하기

 

구현에 앞서 참고 사항

라이브러리 구성과 같은 부분은 이전 인앱 결제하기 포스팅에서 따라하시고 오시면됩니다. 그 밖에 결제모듈에 대한 설명들도 모두 여기에서 하고있으니 이 포스팅을 보셨다는 가정하에 글을 쓰도록 하겠습니다.

 

 

화면 구성

화면쪽은 역시 빠르게 넘어가도록 하겠습니다.

activity_subscription.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_subscription"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:text="이용권 상태: "
        android:textSize="16sp"/>

    <Button
        android:id="@+id/btn_basic"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:text="Basic 1개월 이용권 구매"/>

    <Button
        android:id="@+id/btn_vip"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:text="Vip 1개월 이용권 구매"/>
</LinearLayout>

 

SubscriptionActivity.kt

class SubscriptionActivity : AppCompatActivity() {

    private lateinit var binding: ActivitySubscriptionBinding

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

}

 

 

 

 

결제모듈 만들기

코드의 재사용성을 위해 BillingModule을 만들어서 결제와 관련된 모든 로직을 처리하려고 합니다. 이전에 인앱 결제에서 BillingModule을 만든적이 있으니, 전체 코드를 보고 바뀐점만 따로 짚고 넘어가도록 하겠습니다.

 

BillingModule.kt의 전체 코드

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)
            }
        }
    }

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

    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.")
            }
        })
    }

    /**
     * 구매를 했지만 확인되지 않은 건에대해서 확인처리를 합니다.
     * @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)
                    }
                }
            }
        }
    }

    /**
     * 원하는 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())
                }
            }
        }
    }

    /**
     * 구매 시작하기
     * @param skuDetail 구매하고자하는 항목. querySkuDetail()을 통해 획득한 SkuDetail
     * @param oldPurchase 이미 구독중일때, 현재 구독 구매 정보를 전달
     */
    fun purchase(
        skuDetail: SkuDetails,
        oldPurchase: Purchase? = null
    ) {
        val flowParams = BillingFlowParams.newBuilder().apply {
            setSkuDetails(skuDetail)
            if (oldPurchase != null) {
                // # 구독을 위한 ProrationMode 문서: https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProrationMode
                setReplaceSkusProrationMode(IMMEDIATE_WITH_TIME_PRORATION)
                setOldSku(oldPurchase.sku, oldPurchase.purchaseToken)
            }
        }.build()

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

    /**
     * 구매 여부 체크, 소비성 구매가 아닌 항목에 한정.
     * @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)
        }
    }

    /**
     * 구독 여부 체크
     * @param sku
     * @return 구독하지 않았다면 null을 반환합니다.
     */
    fun checkSubscribed(resultBlock: (Purchase?) -> Unit) {
        billingClient.queryPurchases(BillingClient.SkuType.SUBS).purchasesList?.let { purchaseList ->
            for (purchase in purchaseList) {
                if (purchase.isPurchaseConfirmed()) {
                    return resultBlock(purchase)
                }
            }
            return resultBlock(null)
        }
    }

    /**
     * 구매 확인 처리
     * @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)
                        }
                    }
                }
            }
        }
    }

    // 구매 확인 검사 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)
    }
}

 

구독을 위해서 추가된 코드 (1) - 구매시 ProrationMode 설정

purchase() 메소드를 잘 보시면 oldPurchase라는 인자가 하나 더 생기고, 그 여부에 따라서 추가적인 세팅을 해주고 있습니다. ProrationMode(비례배분 모드)라는 단어부터 매우 생소한데요, 설명을 드리자면 이렇습니다.

다음 그림처럼 Basic 플랜을 이용중인 유저가 구독 도중에 다른 플랜으로 바꾸려고 하는 경우, 가격 책정, 시간 책정을 하는 방법이 정말 여러가지가 있습니다. 이 부분은 경영이나 기획쪽에서 관여해서 정책이 마련되어있을 수도 있겠죠. 이렇게 구독 플랜의 업그레이드와 다운그레이드때 어떤 정책을 취할지 결정할 수 있게 해주는 것이 setReplaceSkusProrationMode()이며, 그 정책이 ProrationMode입니다.

 

# Proration Mode의 종류

  • DEFERRED (4)
    현재 사용중인 요금제가 만료되면 교체가 적용됩니다.
    위의 예시에서 1월 31일 Basic 플랜이 만료되고, 그 다음달인 2월 1일부터는 Vip 요금이 청구되는 형식입니다.
  • IMMEDIATE_AND_CHARGE_PRORATED_PRICE (2)
    즉시 교체되고, 만료일은 그대로이며 남은 기간에대한 가격이 나눠져서 책정됩니다. 업그레이드시에만 사용할 수 있습니다.
    예시에서 1월 15일 즉시 교체되고, 한달에 20,000원인 요금제이지만 15일만 사용할 것이므로 10000만원만 내도 됩니다. 다운그레이드하면 유저가 돈을 오히려 돌려받아야 하기 때문에 적용될 수 없겠죠.
  • IMMEDIATE_WITHOUT_PRORATION (3)
    즉시 교체되고, 새 플랜에 대한 가격이 다음에 청구됩니다. 청구 주기는 동일합니다.
    이 부분이 DEFERRED와 헷갈릴 수 있는 부분인데, 이건 Basic을 구매했고 자시고 없고 바로 VIP로 변경되고, VIP 요금을 받을거다 같습니다. 전에 9,900원에 대한 아무런 배상이나 할인을 하지 않습니다.
  • IMMEDIATE_WITH_TIME_PRORATION(1)
    즉시 교체되고, 남은 시간은 일할 계산되어 적립됩니다. 현재 아무것도 적용하지 않을시 Default 동작입니다.
    예시에서는 Vip 요금제로 즉시 반영되며, 원래 1월 31일에 만료가 되었어야하지만, 그전에 9,900원을 냈으니 15일 더 연장이되는 식입니다. 정확히 이전 요금제에 비례한것인지는 저도 잘 모르겠군요!

 

/**
    * 구매 시작하기
    * @param skuDetail 구매하고자하는 항목. querySkuDetail()을 통해 획득한 SkuDetail
    * @param oldPurchase 이미 구독중일때, 현재 구독 구매 정보를 전달
    */
fun purchase(
    skuDetail: SkuDetails,
    oldPurchase: Purchase? = null
) {
    val flowParams = BillingFlowParams.newBuilder().apply {
        setSkuDetails(skuDetail)
        if (oldPurchase != null) {
            setReplaceSkusProrationMode(IMMEDIATE_WITH_TIME_PRORATION)
            setOldSku(oldPurchase.sku, oldPurchase.purchaseToken)
        }
    }.build()

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

 

 

저는 예시로 ImmediateWithTimeProration 옵션을 줬으나...저건 기본 값이기때문에 아무것도 주지 않는것이 더 현명하겠죠? 저 부분에 본인이 원하는 ProrationMode를 적용시키면 됩니다. 주의할점은 setOldSku부분입니다. ProrationMode는 이전에 어떤 Sku를 구매했느냐에 따라 달라지기 때문에 구매자가 현재 구독중인 플랜이있으면 그 플랜에 대한 정보를 받아서 넘겨주어야 합니다.

 

 

 

구독을 위해서 추가된 코드 (2) - 구독 여부 체크

이 부분은 이전에 인앱 결제때 구매여부 체크를 헀던 부분과 크게 다를바 없지만, Purchase를 직접 넘겨주는 방식이라는 점에서 차이가 있습니다. 앞서서 다른 플랜을 구매할때 현재 구독중인 정보가 필요하다고 말씀드렸는데요, 그걸 따로 저장 해 두기위해서 저렇게 구매정보 자체를 넘겨주면 편합니다.

/**
    * 구독 여부 체크
    * @param sku
    * @return 구독하지 않았다면 null을 반환합니다.
    */
fun checkSubscribed(resultBlock: (Purchase?) -> Unit) {
    billingClient.queryPurchases(BillingClient.SkuType.SUBS).purchasesList?.let { purchaseList ->
        for (purchase in purchaseList) {
            if (purchase.isPurchaseConfirmed()) {
                return resultBlock(purchase)
            }
        }
        return resultBlock(null)
    }
}

 

완성된 SubscriptionActivity.kt

class SubscriptionActivity : AppCompatActivity() {

    private lateinit var binding: ActivitySubscriptionBinding
    private lateinit var bm: BillingModule
    private var mSkuDetails = listOf<SkuDetails>()
        set(value) {
            field = value
            setSkuDetailsView()
        }

    private var currentSubscription: Purchase? = null
        set(value) {
            field = value
            updateSubscriptionState()
        }

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

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

                bm.checkSubscribed {
                    currentSubscription = it
                }
            }

            override fun onSuccess(purchase: Purchase) {
                currentSubscription = purchase
            }

            override fun onFailure(errorCode: Int) {
                Toast.makeText(
                    applicationContext,
                    "구매 도중 오류가 발생하였습니다. (${errorCode})",
                    Toast.LENGTH_SHORT).show()
            }

        })

        setClickListeners()
    }

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

            btnBasic.setOnClickListener {
                mSkuDetails.find { it.sku == Sku.SUB_BASIC }?.let { skuDetail ->
                    bm.purchase(skuDetail, currentSubscription)
                } ?: also {
                    Toast.makeText(this@SubscriptionActivity, "상품을 찾을 수 없습니다.", 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
    }

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

    private fun updateSubscriptionState() {
        currentSubscription?.let {
            binding.tvSubscription.text = "구독중: ${it.sku} | 자동갱신: ${it.isAutoRenewing}"
        } ?: also {
            binding.tvSubscription.text = "구독안함"
        }
    }
}

테스트 결제이므로 1개월은 5분 단위가 된다.

작동시켜보면 제대로 구독 결제가 됨을 알 수 있습니다.

사용자의 구독 주기와 다음 결제일을 알아내는 작업이나 영수증 검증을 위해서는 서버작업이 필요합니다. Google Developer Api를 통해서 할 수 있는 일인데, 이건 혹시나 요청하시는 분이 있다면 따로 다뤄보도록 하겠습니다.