--- id: android-billingclient-iap title: Android Billing Client — IAP / Subscription category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [android, billing, iap, subscription, vibe-coding] tech_stack: { language: "Kotlin / com.android.billingclient", applicable_to: ["Android"] } applied_in: [] aliases: [Google Play Billing, Purchase, acknowledgePurchase, server-side validation] --- # Android Billing Client (IAP) > Google Play 결제. **acknowledge / consume 안 하면 사용자 환불**. 서버 검증 (Real-Time Developer Notifications + Subscription API) 필수. ## 📖 핵심 개념 - ProductDetails: Google Play Console 등록 상품. - Purchase: 구매 결과. acknowledged / consumed 상태. - Acknowledge: 3일 안 안 하면 자동 환불. - Server: Pub/Sub RTDN 으로 갱신 / 환불 / 만료 통지. ## 💻 코드 패턴 ### 의존성 ```kotlin implementation("com.android.billingclient:billing-ktx:7.0.0") ``` ### Setup ```kotlin class BillingManager(ctx: Context) : PurchasesUpdatedListener { private val client = BillingClient.newBuilder(ctx) .setListener(this) .enablePendingPurchases() .build() suspend fun startConnection(): BillingResult = suspendCancellableCoroutine { cont -> client.startConnection(object : BillingClientStateListener { override fun onBillingSetupFinished(result: BillingResult) { cont.resume(result) } override fun onBillingServiceDisconnected() { /* retry later */ } }) } suspend fun queryProducts(ids: List): List { val params = QueryProductDetailsParams.newBuilder() .setProductList(ids.map { QueryProductDetailsParams.Product.newBuilder() .setProductId(it) .setProductType(BillingClient.ProductType.SUBS) .build() }) .build() return client.queryProductDetails(params).productDetailsList ?: emptyList() } fun launchPurchase(activity: Activity, product: ProductDetails) { val flow = BillingFlowParams.newBuilder() .setProductDetailsParamsList(listOf( BillingFlowParams.ProductDetailsParams.newBuilder() .setProductDetails(product) .setOfferToken(product.subscriptionOfferDetails!!.first().offerToken) .build() )) .setObfuscatedAccountId(currentUserId) // server matching .build() client.launchBillingFlow(activity, flow) } override fun onPurchasesUpdated(result: BillingResult, purchases: List?) { if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { for (p in purchases) handlePurchase(p) } } private fun handlePurchase(purchase: Purchase) { if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) return // 1) 서버 검증 scope.launch { val verified = api.verify(purchase.purchaseToken, purchase.products.first(), currentUserId) if (!verified) return@launch // 2) acknowledge (subscription) or consume (consumable) if (!purchase.isAcknowledged) { val params = AcknowledgePurchaseParams.newBuilder() .setPurchaseToken(purchase.purchaseToken) .build() client.acknowledgePurchase(params) {} } } } } ``` ### 서버 검증 (Subscription API) ```ts // Backend import { google } from 'googleapis'; const auth = new google.auth.GoogleAuth({ keyFile: serviceAccountPath, scopes: ['https://www.googleapis.com/auth/androidpublisher'] }); const pub = google.androidpublisher({ version: 'v3', auth }); const r = await pub.purchases.subscriptionsv2.get({ packageName: 'com.example.app', token: purchaseToken, }); if (r.data.subscriptionState === 'SUBSCRIPTION_STATE_ACTIVE') { await grantEntitlement(userId, productId); } ``` ### RTDN (Real-Time Developer Notifications) - Google Cloud Pub/Sub 구독. - 갱신 / 환불 / 만료 / 환경 변경 notify. - 서버가 사용자 entitlement 갱신. ## 🤔 의사결정 기준 | 상품 | type | |---|---| | 1회 영구 (광고 제거) | INAPP | | 사용 후 소진 (코인) | INAPP + consume | | 월/년 구독 | SUBS + offer | | 무료 trial | SUBS + introductoryPrice offer | ## ❌ 안티패턴 - **acknowledge 안 함**: 3일 후 자동 환불. - **client-side validation 만**: 우회 가능. 서버 + signature 검증. - **consume vs acknowledge 헷갈림**: consumable 만 consume, 그 외 acknowledge. - **obfuscatedAccountId 미설정**: 결제 ↔ 사용자 매핑 어려움. - **RTDN 없음**: 환불 / 만료 모름. - **client 가 entitlement 결정**: 서버가 진실. 클라는 표시. - **동시에 여러 구독 가능**: 의도와 다른 구독 활성. SkuDetails 하나만 active. ## 🤖 LLM 활용 힌트 - BillingClient 7+ + obfuscatedAccountId + 서버 검증 + RTDN 4종 세트. - acknowledge / consume 분리. ## 🔗 관련 문서 - [[iOS_StoreKit_2_Patterns]] - [[Web_JWT_Patterns]]