Development/Android

[Android] 구글 인앱 결제 처리 관련 코드를 Manager로 정리

SeungYong.Lee 2025. 3. 18. 16:28
반응형

- 기존 프로젝트 내에 인앱 결제 관련 로직이 너무 노후화되어 있었고, 함수도 분산되어 있어서 관리하기가 난해했다.

 

- 최근에 모듈을 최신화하면서 주요 함수를 BillingManager라는 싱글톤 object로 정리했는데, 그 내용을 기록해 본다.

결제 모듈 초기화

object BillingManager {

    private var billingClient: BillingClient? = null

    /**
     * 결제 모듈 초기화 완료 여부
     */
    fun isReady(): Boolean {
        val client = this.billingClient ?: return false
        return client.isReady
    }

- 결제 모듈 초기화 완료 여부를 확인하는 코드이다. 클라이언트 객체가 초기화되지 않았다면 하위 구매 기능을 활성화하지 않도록 구성했다.

 

- 이 Boolean 함수를 체크하기 전에 Activity에 진입한 최초에 결제 모듈의 초기화가 선행적으로 이루어져야 한다.

/*
BILLING_RESPONSE_RESULT_ITEM_UNAVAILABLE 4 요청한 제품을 구매할 수 없습니다.
BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED 7 항목을 이미 소유하고 있기 때문에 구매할 수 없습니다.
BILLING_UNAVAILABLE 3 구글 플레이 스토어 사용 불가 상태
 */
private val purchaseUpdatedListener = PurchasesUpdatedListener { billingResult, purchases ->
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
    when (billingResult.responseCode) {
       //TODO: 결제 예외 처리 진행
    }
} else {
    purchases?.first()?.let { onProductPurchase(it) }
}

- 위와 같은 결제 처리 Listener가 구성되고 이 동작 내용을 기반으로 결제 모듈을 아래처럼 초기화한다.

private fun billingClientInit() {
    BillingManager.startBillingClient(purchaseUpdatedListener) { result ->
        if (result.responseCode != BillingClient.BillingResponseCode.OK) return@startBillingClient
        //TODO : 결제 모듈 초기화 완료 이후 동작
    }
}
.
.
.
fun startBillingClient(listener: PurchasesUpdatedListener, onStarted: (BillingResult) -> Unit) {
        if (!isEnableGooglePlayService(AppCore.context)) return
        billingClient?.endConnection()
        billingClient = BillingClient.newBuilder(AppCore.context)
            .setListener(listener)
            .enablePendingPurchases()
            .build()
        billingClient?.startConnection(object : BillingClientStateListener {
            override fun onBillingServiceDisconnected() {}
            override fun onBillingSetupFinished(result: BillingResult) {
                CoroutineScope(Dispatchers.Main).launch { onStarted.invoke(result) }
            }
        })
    }

- 기존 Connection이 존재한다면 끊어주고 새로 연결한다.

 

- 구글 플레이 서비스를 사용할 수 있는 디바이스인지 검사를 진행한다.

https://yongdragon9819.tistory.com/entry/Android-%EA%B5%AC%EA%B8%80-%ED%94%8C%EB%A0%88%EC%9D%B4-%EC%8A%A4%ED%86%A0%EC%96%B4-%EC%82%AC%EC%9A%A9-%EA%B0%80%EB%8A%A5-%EC%97%AC%EB%B6%80-%ED%8C%90%EB%8B%A8%ED%95%98%EA%B8%B0-How-to-check-if-user-has-Google-Play-Services-enabled

 

[Android] 구글 플레이 스토어 사용 가능 여부 판단하기 - How to check if user has Google Play Services enabled

중국에서는 구글 플레이 스토어가 지원되지 않는다. 중국에서 발매된 안드로이드 디바이스는 구글 플레이 스토어를 사용할 수 없습니다. 이유는 구글이 중국 정부의 검열 문제 때문에 아예 철

yongdragon9819.tistory.com

 

- 모듈 활성화 및 연결이 완료되면 필요한 이후 동작을 구성한다.

구매 가능 상품 조회

- 그 이후 동작으로서는 보통 모듈 활성화 및 연결이 완료되면 구매 가능한 상품 목록을 가져와서 사용자에게 UI로 표시한다.

fun getPurchaseListDetails(
    idList: List<String>,
    productType: String,
    onResult: (List<ProductDetails>) -> Unit
) {
    val client = billingClient ?: run {
        onResult.invoke(emptyList())
        return
    }
    if (!client.isReady) {
        onResult.invoke(emptyList())
        return
    }

    val productList = mutableListOf<QueryProductDetailsParams.Product>()
    idList.forEach {
        productList.add(
            QueryProductDetailsParams.Product.newBuilder()
                .setProductId(it)
                .setProductType(productType)
                .build()
        )
    }

    val params = QueryProductDetailsParams.newBuilder()
    params.setProductList(productList)

    client.queryProductDetailsAsync(params.build()) { result, productDetailsList ->
        if (result.responseCode != BillingClient.BillingResponseCode.OK || productDetailsList.isEmpty()) {
            //TODO: 응답 실패 시 처리
            return@queryProductDetailsAsync
        }
        mainAsync { onResult(productDetailsList) /* 응답 성공 시 처리 */ }
    }
}

 

- 위 함수는 개발자가 구글 클라우드 앱 관리에서 지정한 상품 구매 목록을 수신받는 함수다.

 

- 인자로 idList를 받는데, 개발자가 클라우드에서 지정한 상품의 고유 id이다. 개발 시에 이 값을 정확히 알고, 프로젝트에도 등록해주어야 한다.

 

- 다른 인자로 productType을 받는데, 일회성 구매 항목에 대한 데이터를 수신받을 건지, 구독형 구매 항목에 대한 데이터를 수신받을 건지 결정한다. 인앱 모듈 라이브러리 자체에 존재하는 부분이다.

@Retention(RetentionPolicy.SOURCE)
public @interface ProductType {
    @NonNull
    String INAPP = "inapp";
    @NonNull
    String SUBS = "subs";
}

 

- queryProductDetailsAsync()를 통해 상품 목록을 조회하고 onResult에 반환한다.

 

- onResult에서는 다음과 같이 상품 목록에 대한 value를 가져올 수 있다.

val monthLyPrice = monthly?.subscriptionOfferDetails?.first()?.pricingPhases?.pricingPhaseList?.first()?.formattedPrice

결제 프로세스 진입

fun startPurchase(product: ProductDetails, activity: Activity) {
    val offerToken = if (product.productType == BillingClient.ProductType.INAPP) "" else
        product.subscriptionOfferDetails?.first()?.offerToken ?: return

    val productDetailsParamsList = if (product.productType == BillingClient.ProductType.INAPP)
        listOf(BillingFlowParams.ProductDetailsParams.newBuilder()
            .setProductDetails(product)
            .build()
    ) else listOf(BillingFlowParams.ProductDetailsParams.newBuilder()
            .setProductDetails(product)
            .setOfferToken(offerToken)
            .build()
    )
    val billingFlowParams = BillingFlowParams.newBuilder()
        .setProductDetailsParamsList(productDetailsParamsList)
        .build()
    billingClient?.launchBillingFlow(activity, billingFlowParams)
}

- 상품 타입. 구독형인지 일회성인지에 따라 서로 다르게 상품 DetailParams를 가져온다.

 

- 구독형의 경우에는 offerToken이 필요하다. 이유는 구독 상품은 여러 가지 가격 모델(Offer)을 가질 수 있기 때문이다.
즉, 구독 상품이 여러 개의 가격 옵션을 제공할 수 있어서, 어떤 옵션을 선택할지 지정해 줘야 한다.

구매 처리

- 일회소비성 구매 처리

fun consumePurchase(purchaseToken: String) {
    val consumeParams = ConsumeParams.newBuilder().setPurchaseToken(purchaseToken).build()
    billingClient?.consumeAsync(consumeParams, object : ConsumeResponseListener {
        override fun onConsumeResponse(result: BillingResult, p1: String) {
            if (result.responseCode != BillingClient.BillingResponseCode.OK) return
            CoroutineScope(Dispatchers.Main).launch {
                billingClient?.consumePurchase(consumeParams)
            }
        }
    })
}

- 구매 괴정에서 얻은 Purchase 객체의 purchaseToken을 통해 클라이언트에 구매 완료에 대해 알려준다.

 

- ConsumeParams를 사용해야 하는 것에 유의한다.

 

- 구독형 구매 처리

fun acknowledgePurchase(purchaseToken: String) {
    val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder()
        .setPurchaseToken(purchaseToken).build()

    billingClient?.acknowledgePurchase(
        acknowledgePurchaseParams
    ) { result ->
        if (result.responseCode == BillingClient.BillingResponseCode.OK) {
            CoroutineScope(Dispatchers.Main).launch {
                billingClient?.acknowledgePurchase(acknowledgePurchaseParams)
            }
        }
    }
}

- 똑같이 구매 괴정에서 얻은 Purchase 객체의 purchaseToken을 통해 클라이언트에 구매 완료에 대해 알려준다.

 

- 하지만 AcknowlegePurchaseParams를 사용해야 한다.

 

- 인앱 결제 로직이 단순하지만은 않지만 모듈 활용 로직을 정리만 제대로 해둔다면 빠르게 이해할 수 있다.

반응형