멜팅비의 개발 공부

[Android/Kotlin] 안드로이드 인앱 결제(구독상품) 구현 정리 본문

개발 공부/[Android 개발]

[Android/Kotlin] 안드로이드 인앱 결제(구독상품) 구현 정리

멜팅비 2021. 9. 15. 00:59
반응형

오늘은 안드로이드 InApp 결제를 구현하기 위해서 공부한 내용과 실습 내용을 정리하려고 한다.

이번에 새로 들어가는 프로젝트의 요구사항 중 하나로 구독형 상품 결제 기능이 포함되어 있어서 이에 대한 기술 검토를 위해

InApp 결제 구현을 접하게 되었는데, 생각보다 어렵지 않고 정리가 잘 된 블로그가 있어서 쉽게 기술 검토 할 수 있었다.


구글 플레이 콘솔에서 설정

1. 구글 플레이 콘솔에 앱 등록

 

2. 구독 상품 등록 및 내부 테스트 등록

 


1. BillingClient 라이브러리 추가

dependencies {
    // BillingClient Library
    implementation "com.android.billingclient:billing:4.0.0"

	// Coroutine
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3"
}

 

2. Manifest에 BILLING Permission 추가

  <uses-permission android:name="com.android.vending.BILLING" />

 

3. BillingManager 모듈 구현

/**
 * 결제 Callback 인터페이스
 */
interface BillingCallback {
    fun onBillingConnected() // BillingClient 연결 성공 시 호출
    fun onSuccess(purchase: Purchase) // 구매 성공 시 호출 Purchase : 구매정보
    fun onFailure(responseCode: Int) // 구매 실패 시 호출 errorCode : BillingResponseCode
}

class BillingManager(private val activity: Activity, private val callback: BillingCallback) {

    private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases ->
        if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
            for (purchase in purchases) {
                confirmPurchase(purchase)
            }
        } else {
            callback.onFailure(billingResult.responseCode)
        }
    }

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

    init {
        billingClient.startConnection(object : BillingClientStateListener {
            override fun onBillingServiceDisconnected() {
                Log.d("BiilingManager", "== BillingClient onBillingServiceDisconnected() called ==")
            }

            override fun onBillingSetupFinished(billingResult: BillingResult) {
                if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                    callback.onBillingConnected()
                } else {
                    callback.onFailure(billingResult.responseCode)
                }
            }
        })
    }

    /**
     * 콘솔에 등록한 상품 리스트를 가져온다.
     * @param sku 상품 ID String
     * @param billingType String IN_APP or SUBS
     * @param resultBlock 결과로 받을 상품정보들에 대한 처리
     */
    fun getSkuDetails(
        vararg sku: String,
        billingType: String,
        resultBlock: (List<SkuDetails>) -> Unit = {}
    ) {
        val params = SkuDetailsParams.newBuilder()
            .setSkusList(sku.asList())
            .setType(billingType)

        billingClient.querySkuDetailsAsync(params.build()) { _, list ->
            CoroutineScope(Dispatchers.Main).launch {
                resultBlock(list ?: emptyList())
            }
        }
    }

    /**
     * 구매 시도
     * @param skuDetail SkuDetails 구매 할 상품
     */
    fun purchaseSku(skuDetail: SkuDetails) {
        val flowParams = BillingFlowParams.newBuilder().apply {
            setSkuDetails(skuDetail)
        }.build()

        val responseCode = billingClient.launchBillingFlow(activity, flowParams).responseCode
        if (responseCode != BillingClient.BillingResponseCode.OK) {
            callback.onFailure(responseCode)
        }
    }

    /**
     * 구독 여부 확인
     * @param sku String 구매 확인 상품
     * @param resultBlock 구매 확인 상품에 대한 처리 return Purchase
     */
    fun checkSubscribed(sku: String, resultBlock: (Purchase?) -> Unit) {
        billingClient.queryPurchasesAsync(sku) { _, purchases ->
            CoroutineScope(Dispatchers.Main).launch {
                for (purchase in purchases) {
                    if (purchase.isAcknowledged && purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
                        return@launch resultBlock(purchase)
                    }
                }
                return@launch resultBlock(null)
            }
        }
    }

    /**
     * 구매 확인
     * @param purchase
     */
    fun confirmPurchase(purchase: Purchase) {
        if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED && !purchase.isAcknowledged) {
            // 구매를 완료 했지만 확인이 되지 않은 경우 확인 처리
            val ackPurchaseParams = AcknowledgePurchaseParams.newBuilder()
                .setPurchaseToken(purchase.purchaseToken)

            CoroutineScope(Dispatchers.IO).launch {
                billingClient.acknowledgePurchase(ackPurchaseParams.build()) {
                    CoroutineScope(Dispatchers.Main).launch {
                        if (it.responseCode == BillingClient.BillingResponseCode.OK) {
                            callback.onSuccess(purchase)
                        } else {
                            callback.onFailure(it.responseCode)
                        }
                    }
                }
            }
        }
    }

    /**
     * 구매 확인이 안 된 경우 다시 확인 할 수 있도록
     */
    fun onResume(type: String) {
        if (billingClient.isReady) {
            billingClient.queryPurchasesAsync(type) { _, purchases ->
                for (purchase in purchases) {
                    if (!purchase.isAcknowledged && purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
                        confirmPurchase(purchase)
                    }
                }
            }
        }
    }


}

 

4. Activity에서 결제 프로세스 구현

 

- activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:layout_marginStart="20dp"
        android:text="1개월 구독권 : "
        android:textColor="@color/black"
        android:textSize="20sp"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/tv_state"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="12dp"
        android:layout_marginTop="20dp"
        android:textSize="20sp"
        android:textColor="@color/purple_700"
        app:layout_constraintStart_toEndOf="@+id/textView"
        app:layout_constraintTop_toTopOf="parent" />

    <androidx.appcompat.widget.AppCompatButton
        android:id="@+id/btn_purchase"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginStart="20dp"
        android:layout_marginEnd="20dp"
        android:layout_marginBottom="20dp"
        android:background="#237C26"
        android:text="1개월 구독 결제하기"
        android:textColor="@color/white"
        android:textSize="18sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

 

- MainActivity.kt

class MainActivity : AppCompatActivity() {

    private lateinit var manager: BillingManager
    val subsItemID = "SUBS_ITEM"

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

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

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        manager = BillingManager(this, object : BillingCallback {
            override fun onBillingConnected() {
                manager.getSkuDetails(subsItemID, billingType = BillingClient.SkuType.SUBS) { list ->
                    mSkuDetails = list
                }

                manager.checkSubscribed(subsItemID) {
                    currentSubscription = it
                }
            }

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

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

        btn_purchase.setOnClickListener {
            mSkuDetails.find { it.sku == subsItemID }?.let { skuDetail ->
                manager.purchaseSku(skuDetail)
            } ?: also {
                Toast.makeText(this, "구매 가능 한 상품이 없습니다.", Toast.LENGTH_LONG).show()
            }
        }
    }

    private fun getSkuDetails() {
        var info = ""
        for (skuDetail in mSkuDetails) {
            info += "${skuDetail.title}, ${skuDetail.price} \n"
        }
        Toast.makeText(this, info, Toast.LENGTH_SHORT).show()
    }

    @SuppressLint("SetTextI18n")
    private fun updateSubscriptionState() {
        currentSubscription?.let {
            tv_state.text = "구독중: ${it.skus} "
        } ?: also {
            tv_state.text = "구독권이 없습니다."
        }
    }
}

 

SampleApp 결과

구독 결제를 하지 않은 경우
      결제 버튼 클릭 시
결제 완료

반응형
Comments