- 기존 프로젝트 내에 인앱 결제 관련 로직이 너무 노후화되어 있었고, 함수도 분산되어 있어서 관리하기가 난해했다.
- 최근에 모듈을 최신화하면서 주요 함수를 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이 존재한다면 끊어주고 새로 연결한다.
- 구글 플레이 서비스를 사용할 수 있는 디바이스인지 검사를 진행한다.
[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를 사용해야 한다.
- 인앱 결제 로직이 단순하지만은 않지만 모듈 활용 로직을 정리만 제대로 해둔다면 빠르게 이해할 수 있다.