본문 바로가기
Android

[Android] 구글 인앱 결제

by 준그래머 2021. 3. 20.
반응형

개인 프로젝트를 진행하면서 식권 구매 페이지에 결제 시스템을 넣어야 해서 전에 한번 해본 적이 있는 구글 인 앱 결제로 진행하였고 블로그에 결제 관련 로직을 정리해보려 한다.

 

일단 구글 인 앱 결제를 이용하기 위해선 google play console에서 개발자 계정을 만든 뒤 앱을 업로드하는 게 필요하다. 나 같은 경우는 알파 버전에 앱을 올리고 인앱 상품을 만들어 테스트 한 뒤 프로덕션으로 올리는 방식으로 진행하였다. 아무튼 이 과정은 생략하고 코드 쪽에 대한 설명으로 포스팅하려 한다.

 

먼저 BillingHelper.kt을 구현할 것이다. 이 파일은 인앱 결제를 위한 클라이언트 객체를 만들어주기 위한 클래스다. 

import android.content.Context
import com.android.billingclient.api.*

class BillingHelper(val context: Context, val listener: PurchasesUpdatedListener){

    companion object{
        @Volatile
        private var instance: BillingClient? = null

        @Synchronized
        fun getInstance(context: Context, listener: PurchasesUpdatedListener) = instance ?: setUpBillingClient(context, listener).also { instance = it }

        private fun setUpBillingClient(context: Context, listener: PurchasesUpdatedListener): BillingClient {
            return BillingClient.newBuilder(context)
                .enablePendingPurchases()
                .setListener(listener)
                .build()
        }
    }
}

자세히 보면 BillingHelper 생성자 객체를 만들어주는 게 아니라 BillingClient 객체를 만들어서 리턴해주는 것을 볼 수 있다.

 

이제 이 객체를 사용할 프래그먼트를 만들도록 하겠다.

fragment_meal_ticket.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:id="@+id/frameLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/white"
    tools:context=".ui.frag.mealticket.MealTicketFrag">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rv_meal_ticket_list"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:nestedScrollingEnabled="false"
        android:orientation="vertical"
        android:overScrollMode="never"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:itemCount="4"
        tools:listitem="@layout/layout_meal_ticket_item" />

</androidx.constraintlayout.widget.ConstraintLayout>

android:overScrollMode="never"를 통해 위아래 맨 끝의 RecyclerView 효과를 제거했다.

 

리사이클러뷰에 들어갈 아이템 xml

layout_meal_ticket_item.xml

<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView 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:id="@+id/cv_meal_ticket_item_parent"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:theme="@style/Theme.MaterialComponents.Light"
    android:layout_margin="8dp"
    android:background="@color/white">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:padding="16dp">

        <TextView
            android:id="@+id/tv_meal_ticket_title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textColor="#000"
            android:textSize="20sp"
            android:text="Title"
            />

        <TextView
            android:id="@+id/tv_meal_ticket_price"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp"
            android:text="price"
            android:textAppearance="?attr/textAppearanceBody2"
            android:textColor="?android:attr/textColorSecondary"/>

        <TextView
            android:id="@+id/tv_meal_ticket_description"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:text="description"
            android:textAppearance="?attr/textAppearanceBody2"
            android:textColor="?android:attr/textColorSecondary"/>

    </LinearLayout>

</com.google.android.material.card.MaterialCardView>

 

MealTicketFrag.kt

 

class MealTicketFrag : BaseFrag(), PurchasesUpdatedListener {

    private val binding: FragmentMealTicketBinding by lazy { FragmentMealTicketBinding.inflate(layoutInflater) }

    private lateinit var billingClient: BillingClient
    private lateinit var listener: ConsumeResponseListener

    private var ticketType = "" // 구매한 식권의 타입을 지정해줄 변수
    private var ticketCnt = 0 // 구매한 식권의 수량을 지정해줄 변수

    private val accountViewModel: AccountViewModel by viewModel()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setupBillingClient()
    }

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        setAdapter()

        setObserver()
    }

    private fun setAdapter(){
        showLog("setAdapter")
        // 리사이클러뷰의 객체를 클릭한 경우
        val adapter = MealTicketAdapter { item ->
            if(sessionHelper.isLogin()){
                purchaseProduct(item)
            }
            else {
                (requireActivity() as MainAct).showSignInNotificationDialog { result ->
                    if(result) purchaseProduct(item)
                    else showMsg(getString(R.string.fail_update_session))
                }
            }
        }

        binding.rvMealTicketList.adapter = adapter

        showLog("isReady: ${billingClient.isReady}")

        if (billingClient.isReady) {
            // play console에 생성된 특정 상품 리스트들을 가져온 뒤 객체를 생성
            val skuDetailsParams = SkuDetailsParams.newBuilder()
                .setSkusList(listOf("ticket_1", "ticket_2", "ticket_3", "ticket_4"))
                .setType(BillingClient.SkuType.INAPP)
                .build()

            // 비동기로 가져온 상품 리스트들을 어떻게 처리할지 구현
            billingClient.querySkuDetailsAsync(skuDetailsParams, object : SkuDetailsResponseListener {
                    override fun onSkuDetailsResponse(billingResult: BillingResult, list: MutableList<SkuDetails>?) {
                        if (billingResult.responseCode == BillingClient.BillingResponseCode.OK){
                            showLog(list.toString(), "querySkuDetailsAsync")

                            // 아답터에 상품 리스트들을 전달해서 업데이트 시켜준다.
                            adapter.submitList(list)
                        }
                        else
                            showLog("Error Code: ${billingResult.responseCode}")
                    }

                })
        }
    }

    private fun setObserver(){
        // 유저 식권 보유량 업데이트 상태를 받아오는 곳
        accountViewModel.updateUserTicket.observe(viewLifecycleOwner, Observer {
            val resource = it ?: return@Observer
            when(resource.status){
                Status.SUCCESS ->{
                    val data = resource.data!!
                    when(data.status){
                        "200" -> showMsg(data.msg.toString())
                        "202" -> accountViewModel.updateToken(sessionHelper.tokenVo!!)
                        else -> showMsg(data.msg.toString())
                    }
                }
                Status.ERROR -> {
                    showMsg(resource.message.toString())
                }
                Status.ERROR_INT -> {
                    showMsg(getString(resource.intMsg!!))
                }
                else -> return@Observer
            }
            // 토큰 만료 상태가 아닌 경우만 식권 상태를 리셋 해줌
            // 토큰 만료 상태의 경우 토큰을 재발행하는 요청을 한 후에
            // 자동으로 다시 식권을 업데이트 하는 요청이 필요하다.
            if(resource.data?.status != "202"){
                ticketCnt = 0
                ticketType = ""
            }

            accountViewModel.updateUserTicket.postValue(null)
        })

        // 유저의 갱신된 토큰(access_token, 때에 따라선 refresh_token도 갱신)
        accountViewModel.updateToken.observe(viewLifecycleOwner, Observer {
            val resource = it ?: return@Observer

            when(resource.status){
                Status.SUCCESS-> {
                    // 토큰이 갱신된 상태이므로 다시 유저 식권 수량을 업데이트 요청
                    accountViewModel.updateUserTicket(ticketType, ticketCnt)
                }
                Status.ERROR ->{
                    showMsg.showShortToastMsg(resource.message.toString())
                }
                Status.ERROR_INT->{
                    showMsg.showShortToastMsg(getString(resource.intMsg!!))
                }
                else -> return@Observer
            }
        })
    }

    private fun setupBillingClient() {

        // ConsumeResponseListener를 통해 구매에 상태값을 가져와 이후 처리를 수행할 부분
        listener = ConsumeResponseListener { billingResult: BillingResult, str: String ->
            if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                showLog("구매 성공!!")
                accountViewModel.updateUserTicket(ticketType, ticketCnt)
            }
            else {
                showLog("구매 실패!!")
            }
        }

        // 전역변수로 선언된 BillingClient 객체를 BillingHelper를 통해 생성하는 과정
        billingClient = BillingHelper.getInstance(requireContext(), this)

        // billingClient.startConnection()을 통해 google play console에 접속을 시도
        // 이 구문은 fragment 실행과 상관 없이 앱이 실행되면 바로 실행된다.
        billingClient.startConnection(object : BillingClientStateListener {
            override fun onBillingSetupFinished(billingResult: BillingResult) {
                if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                    showLog("연결 성공!!")
                    
                    val purchases: MutableList<Purchase>? = billingClient.queryPurchases(BillingClient.SkuType.INAPP).purchasesList
                    showLog(purchases.toString(), "purchases")

                    // 구매를 진행할 경우 수행할 함수
                    handleItemAlreadyPurchase(purchases)
                } else {
                    showLog("Error Code: ${billingResult.responseCode}, 연결에 실패했습니다.")
                }
            }

            override fun onBillingServiceDisconnected() {
                showLog("연결이 끊김")
            }
        })
    }

    private fun handleItemAlreadyPurchase(purchases: MutableList<Purchase>?) {
        // purchases가 null이 아닌 경우
        // 각 sku에 따라 처리하는데, 식권의 타입과 식권의 수량을 정의해준다.
        if (purchases != null) {
            for (purchase in purchases) {
                when (purchase.sku) {
                    "ticket_1" -> {
                        showLog("ticket_1")
                        ticketType = "user_ticket_5000"
                        ticketCnt = 5
                    }
                    "ticket_2" -> {
                        showLog("ticket_2")
                        ticketType = "user_ticket_4000"
                        ticketCnt = 5
                    }
                    "ticket_3" -> {
                        showLog("ticket_3")
                        ticketType = "user_ticket_3500"
                        ticketCnt = 5
                    }
                    "ticket_4" -> {
                        showLog("ticket_4")
                        ticketType = "user_ticket_2000"
                        ticketCnt = 5
                    }
                }
                // 비동기로 식권 구매로직 상태를 받아서 필요한 기능을 수행할 수 있게 돕는다.
                val consumeParams = ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build()
                billingClient.consumeAsync(consumeParams, listener)
            }
        }
    }

    // 식권 구매을 진행하는 메서드
    private fun purchaseProduct(item: SkuDetails){

        // 구매할 상품 객체를 생성
        val billingFlowParams = BillingFlowParams.newBuilder()
            .setSkuDetails(item)
            .build()

        // 구매 로직 실행
        when(billingClient.launchBillingFlow(requireActivity(), billingFlowParams).responseCode){
            BillingClient.BillingResponseCode.BILLING_UNAVAILABLE -> {
                showMsg("BILLING UNAVAILABLE")
                showLog("BILLING UNAVAILABLE", "purchaseProduct")
            }

            BillingClient.BillingResponseCode.DEVELOPER_ERROR -> {
                showMsg("DEVELOPER ERROR")
                showLog("DEVELOPER ERROR", "purchaseProduct")
            }

            BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED -> {
                showMsg("FEATURE NOT SUPPORTED")
                showLog("FEATURE NOT SUPPORTED", "purchaseProduct")
            }

            BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> {
                showMsg("ITEM ALREADY OWNED")
                showLog("ITEM ALREADY OWNED", "purchaseProduct")
            }

            BillingClient.BillingResponseCode.SERVICE_DISCONNECTED -> {
                showMsg("SERVICE DISCONNECTED")
                showLog("SERVICE DISCONNECTED", "purchaseProduct")
            }

            BillingClient.BillingResponseCode.SERVICE_TIMEOUT -> {
                showMsg("SERVICE TIMEOUT")
                showLog("SERVICE TIMEOUT", "purchaseProduct")
            }

            BillingClient.BillingResponseCode.ITEM_UNAVAILABLE -> {
                showMsg("ITEM UNAVAILABLE")
                showLog("ITEM UNAVAILABLE", "purchaseProduct")
            }
        }
    }

    // 구매 상태를 업데이트 해주는 코드
    override fun onPurchasesUpdated(billingResult: BillingResult, list: MutableList<Purchase>?) {
        // 상품 구매에 성공한 경우
        if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && list != null){
            handleItemAlreadyPurchase(list)
        }
        // 유저가 상품 구매를 취소한 경우
        else if(billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED){
            showMsg(getString(R.string.cancel_buying_product))
        }
        // 상품 구매시 에러가 뜬 경우
        else{
            showMsg( "Error: ${billingResult.responseCode}")
        }
    }

}

코드를 하나하나 정리 하려 했으나... 너무 글이 길어질거 같아 주석으로 각 코드들에 대해 설명하였다.

 

 

결과 영상

 

 

 

반응형