BillingResult 응답 코드 처리

Play 결제 라이브러리 호출이 작업을 트리거하면 라이브러리는 BillingResult 응답을 반환하여 개발자에게 결과를 알립니다. 예를 들어 queryProductDetailsAsync를 사용하여 사용자에게 제공되는 혜택을 가져오는 경우 응답 코드는 OK 코드를 포함하고 올바른 ProductDetails 객체를 제공하거나, ProductDetails 객체를 제공할 수 없는 이유를 나타내는 다른 응답을 포함합니다.

모든 응답 코드가 오류인 것은 아닙니다. BillingResponseCode 참고 페이지에서는 이 가이드에서 설명하는 각 응답에 관한 자세한 설명을 제공합니다. 오류를 나타내지 않는 응답 코드의 예는 다음과 같습니다.

응답 코드가 실제로 오류를 나타내는 경우, 일시적인 조건으로 인해 오류가 발생했을 때는 복구가 가능하기도 합니다. Play 결제 라이브러리 메서드를 호출했을 때 복구 가능한 조건을 나타내는 BillingResponseCode 값이 반환된다면 호출을 재시도해야 합니다. 다른 경우에는 조건이 일시적인 것으로 간주되지 않으므로 재시도가 권장되지 않습니다.

일시적인 오류의 경우 사용자가 세션을 진행 중일 때 오류가 발생했는지(예: 사용자가 구매 흐름을 진행하는 중에 오류가 발생한 경우) 아니면 백그라운드에서 오류가 발생했는지(예: onResume 중에 사용자의 기존 구매를 쿼리한 경우)에 따라 서로 다른 재시도 전략이 요구됩니다. 아래의 재시도 전략 섹션에서는 이러한 다양한 전략을 안내하며, 재시도 가능한 BillingResult 응답 섹션에서는 각 응답 코드에 가장 효과적인 전략을 추천합니다.

일부 오류 응답에는 응답 코드 외에도 디버깅 및 로깅 목적의 메시지가 포함되어 있습니다.

재시도 전략

간단한 재시도

사용자가 세션을 진행 중인 상황에서는 오류가 사용자 경험을 최대한 방해하지 않도록 간단한 재시도 전략을 구현하는 것이 좋습니다. 이 경우에는 최대 재시도 횟수를 종료 조건으로 사용하는 간단한 재시도 전략을 사용할 것을 권장합니다.

다음 예는 BillingClient 연결을 설정할 때 오류를 처리하는 간단한 재시도 전략을 보여줍니다.

class BillingClientWrapper(context: Context) : PurchasesUpdatedListener {
  // Initialize the BillingClient.
  private val billingClient = BillingClient.newBuilder(context)
    .setListener(this)
    .enablePendingPurchases()
    .build()

  // Establish a connection to Google Play.
  fun startBillingConnection() {
    billingClient.startConnection(object : BillingClientStateListener {
      override fun onBillingSetupFinished(billingResult: BillingResult) {
        if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
          Log.d(TAG, "Billing response OK")
          // The BillingClient is ready. You can now query Products Purchases.
        } else {
          Log.e(TAG, billingResult.debugMessage)
          retryBillingServiceConnection()
        }
      }

      override fun onBillingServiceDisconnected() {
        Log.e(TAG, "GBPL Service disconnected")
        retryBillingServiceConnection()
      }
    })
  }

  // Billing connection retry logic. This is a simple max retry pattern
  private fun retryBillingServiceConnection() {
    val maxTries = 3
    var tries = 1
    var isConnectionEstablished = false
    do {
      try {
        billingClient.startConnection(object : BillingClientStateListener {
          override fun onBillingSetupFinished(billingResult: BillingResult) {
            if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
              isConnectionEstablished = true
              Log.d(TAG, "Billing connection retry succeeded.")
            } else {
              Log.e(
                TAG,
                "Billing connection retry failed: ${billingResult.debugMessage}"
              )
            }
          }
        })
      } catch (e: Exception) {
        e.message?.let { Log.e(TAG, it) }
        tries++
      }
    } while (tries <= maxTries && !isConnectionEstablished)
  }
  ...
}

지수 백오프 재시도

백그라운드에서 이루어지며 사용자가 세션을 진행하는 동안 사용자 경험에 영향을 주지 않는 Play 결제 라이브러리 작업에는 지수 백오프를 사용하는 것이 좋습니다.

예를 들어 새 구매를 확인하는 작업은 백그라운드에서 이루어질 수 있고 오류가 발생한 경우에도 확인이 실시간으로 이루어질 필요가 없으므로 지수 백오프를 구현하는 것이 적절합니다.

private fun acknowledge(purchaseToken: String): BillingResult {
  val params = AcknowledgePurchaseParams.newBuilder()
    .setPurchaseToken(purchaseToken)
    .build()
  var ackResult = BillingResult()
  billingClient.acknowledgePurchase(params) { billingResult ->
    ackResult = billingResult
  }
  return ackResult
}

suspend fun acknowledgePurchase(purchaseToken: String) {

  val retryDelayMs = 2000L
  val retryFactor = 2
  val maxTries = 3

  withContext(Dispatchers.IO) {
    acknowledge(purchaseToken)
  }

  AcknowledgePurchaseResponseListener { acknowledgePurchaseResult ->
    val playBillingResponseCode =
    PlayBillingResponseCode(acknowledgePurchaseResult.responseCode)
    when (playBillingResponseCode) {
      BillingClient.BillingResponseCode.OK -> {
        Log.i(TAG, "Acknowledgement was successful")
      }
      BillingClient.BillingResponseCode.ITEM_NOT_OWNED -> {
        // This is possibly related to a stale Play cache.
        // Querying purchases again.
        Log.d(TAG, "Acknowledgement failed with ITEM_NOT_OWNED")
        billingClient.queryPurchasesAsync(
          QueryPurchasesParams.newBuilder()
            .setProductType(BillingClient.ProductType.SUBS)
            .build()
        )
        { billingResult, purchaseList ->
          when (billingResult.responseCode) {
            BillingClient.BillingResponseCode.OK -> {
              purchaseList.forEach { purchase ->
                acknowledge(purchase.purchaseToken)
              }
            }
          }
        }
      }
      in setOf(
         BillingClient.BillingResponseCode.ERROR,
         BillingClient.BillingResponseCode.SERVICE_DISCONNECTED,
         BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE,
       ) -> {
        Log.d(
          TAG,
          "Acknowledgement failed, but can be retried --
          Response Code: ${acknowledgePurchaseResult.responseCode} --
          Debug Message: ${acknowledgePurchaseResult.debugMessage}"
        )
        runBlocking {
          exponentialRetry(
            maxTries = maxTries,
            initialDelay = retryDelayMs,
            retryFactor = retryFactor
          ) { acknowledge(purchaseToken) }
        }
      }
      in setOf(
         BillingClient.BillingResponseCode.BILLING_UNAVAILABLE,
         BillingClient.BillingResponseCode.DEVELOPER_ERROR,
         BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED,
       ) -> {
        Log.e(
          TAG,
          "Acknowledgement failed and cannot be retried --
          Response Code: ${acknowledgePurchaseResult.responseCode} --
          Debug Message: ${acknowledgePurchaseResult.debugMessage}"
        )
        throw Exception("Failed to acknowledge the purchase!")
      }
    }
  }
}

private suspend fun <T> exponentialRetry(
  maxTries: Int = Int.MAX_VALUE,
  initialDelay: Long = Long.MAX_VALUE,
  retryFactor: Int = Int.MAX_VALUE,
  block: suspend () -> T
): T? {
  var currentDelay = initialDelay
  var retryAttempt = 1
  do {
    runCatching {
      delay(currentDelay)
      block()
    }
      .onSuccess {
        Log.d(TAG, "Retry succeeded")
        return@onSuccess;
      }
      .onFailure { throwable ->
        Log.e(
          TAG,
          "Retry Failed -- Cause: ${throwable.cause} -- Message: ${throwable.message}"
        )
      }
    currentDelay *= retryFactor
    retryAttempt++
  } while (retryAttempt < maxTries)

  return block() // last attempt
}

재시도 가능한 BillingResult 응답

NETWORK_ERROR(오류 코드 12)

문제

이 오류는 기기와 Play 시스템 간의 네트워크 연결에 문제가 있음을 나타냅니다.

가능한 해결 방법

오류를 해결하려면 이 오류를 트리거한 작업이 무엇인지에 따라 간단한 재시도와 지수 백오프 중 하나를 사용하세요.

SERVICE_TIMEOUT(오류 코드 -3)

문제

이 오류는 Google Play에서 응답하기 전에 요청이 최대 제한 시간에 도달했음을 나타냅니다. 이 상황은 예를 들어 Play 결제 라이브러리 호출에서 요청한 작업의 실행이 지연되어 발생할 수 있습니다.

가능한 해결 방법

이 오류는 보통 일시적인 문제입니다. 오류를 반환한 작업이 무엇인지에 따라 간단한 전략과 지수 백오프 전략 중 하나를 사용하여 요청을 재시도하세요.

아래의 SERVICE_DISCONNECTED와 달리 Google Play 결제 서비스 연결이 끊어지지 않으며 개발자는 원래 시도한 Play 결제 라이브러리 작업만 재시도하면 됩니다.

SERVICE_DISCONNECTED(오류 코드 -1)

문제

이 치명적인 오류는 BillingClient를 통한 클라이언트 앱과 Google Play 스토어 서비스의 연결이 끊어졌음을 나타냅니다.

가능한 해결 방법

이 오류를 최대한 방지하려면 Play 결제 라이브러리를 호출하기 전에 항상 BillingClient.isReady()를 호출하여 Google Play 서비스 연결을 확인하세요.

SERVICE_DISCONNECTED 상태에서 복구를 시도하려면 클라이언트 앱이 BillingClient.startConnection을 사용하여 연결을 다시 설정해야 합니다.

SERVICE_TIMEOUT과 마찬가지로, 이 오류를 트리거한 작업이 무엇인지에 따라 간단한 재시도와 지수 백오프 중 하나를 사용하세요.

SERVICE_UNAVAILABLE(오류 코드 2)

중요사항:

Google Play 결제 라이브러리 6.0.0부터는 더 이상 네트워크 문제에 대해 SERVICE_UNAVAILABLE이 반환되지 않습니다. 결제 서비스를 사용할 수 없는 경우와 지원 중단된 SERVICE_TIMEOUT 케이스 시나리오에서만 반환됩니다.

문제

이 일시적인 오류는 현재 Google Play 결제 서비스를 사용할 수 없음을 나타냅니다. 이 오류는 대부분의 경우 클라이언트 기기와 Google Play 결제 서비스 사이에 네트워크 연결 문제가 있음을 의미합니다.

가능한 해결 방법

이 오류는 보통 일시적인 문제입니다. 오류를 반환한 작업이 무엇인지에 따라 간단한 전략과 지수 백오프 전략 중 하나를 사용하여 요청을 재시도하세요.

SERVICE_DISCONNECTED와 달리 Google Play 결제 서비스 연결이 끊어지지 않으며 개발자는 원래 시도한 작업만 재시도하면 됩니다.

BILLING_UNAVAILABLE(오류 코드 3)

문제

이 오류는 구매 프로세스 중에 사용자 결제 오류가 발생했음을 나타냅니다. 이 상황이 발생할 수 있는 경우는 다음과 같습니다.

  • 사용자 기기의 Play 스토어 앱이 최신 버전이 아닙니다.
  • 사용자가 지원되지 않는 국가에 있습니다.
  • 사용자가 기업 사용자이며, 기업 관리자가 사용자의 구매 활동을 사용 중지했습니다.
  • Google Play가 사용자의 결제 수단에 청구할 수 없습니다. 예를 들어 사용자의 신용카드가 만료되었을 수 있습니다.

가능한 해결 방법

이 경우에는 자동 재시도가 도움이 되지 않습니다. 단, 사용자가 문제의 원인이 된 조건을 해결한다면 수동 재시도가 도움이 될 수 있습니다. 예를 들어 사용자가 Play 스토어 버전을 지원되는 버전으로 업데이트한다면 처음에 시도한 작업을 수동으로 재시도하는 것이 해결 방법이 될 수 있습니다.

사용자가 세션을 진행하고 있지 않을 때 이 오류가 발생했다면 재시도가 적절하지 않을 수 있습니다. 구매 흐름의 결과로 BILLING_UNAVAILABLE 오류가 발생했다면 사용자가 구매 프로세스 중에 Google Play로부터 피드백을 받았으며 이에 따라 무엇이 문제인지 알고 있을 가능성이 큽니다. 이 경우 사용자가 문제를 해결한 후 수동 재시도 옵션을 사용할 수 있도록 무언가 잘못되었다고 안내하는 오류 메시지를 표시하고 '다시 시도' 버튼을 제공할 수 있습니다.

ERROR(오류 코드 6)

문제

Google Play 자체의 내부 문제를 나타내는 치명적인 오류입니다.

가능한 해결 방법

ERROR로 이어지는 Google Play의 내부적인 문제는 일시적일 때도 있으며, 문제 완화를 위해 지수 백오프 방식의 재시도를 구현할 수 있습니다. 사용자가 세션을 진행 중이라면 간단한 재시도를 사용하는 것이 좋습니다.

ITEM_ALREADY_OWNED

문제

이 응답은 Google Play 사용자가 구매를 시도 중인 정기 결제 또는 일회성 구매 제품을 이미 소유하고 있음을 나타냅니다. 이 오류는 오래된 Google Play 캐시로 인해 발생한 경우를 제외하면 대부분의 경우 일시적인 오류가 아닙니다.

가능한 해결 방법

원인이 캐시 문제가 아닌 경우에 이 오류가 발생하지 않도록 하려면 사용자가 이미 소유하고 있는 제품을 표시하지 마세요. 구매 가능한 제품을 표시할 때는 사용자의 사용 권한을 확인한 후 이에 따라 사용자가 구매할 수 있는 항목을 필터링하세요. 캐시 문제로 인해 클라이언트 앱이 이 오류를 수신한 경우에는 Google Play 캐시가 Play 백엔드의 최신 데이터로 업데이트되도록 트리거됩니다. 이 경우에는 오류가 발생한 후에 재시도하면 일시적인 오류가 해결됩니다. ITEM_ALREADY_OWNED 오류가 표시된 후 BillingClient.queryPurchasesAsync()를 호출하여 사용자가 제품을 획득했는지 확인하고, 사용자가 제품을 획득하지 않았다면 간단한 재시도 로직을 구현하여 구매를 재시도합니다.

ITEM_NOT_OWNED

문제

이 구매 응답은 Google Play 사용자가 교체, 확인 또는 소비하려는 정기 결제 또는 일회성 구매 제품을 사용자가 소유하지 않고 있음을 나타냅니다. 이 오류는 오래된 Google Play 캐시로 인해 발생한 경우를 제외하면 대부분의 경우 일시적인 오류가 아닙니다.

가능한 해결 방법

캐시 문제로 인해 이 오류를 수신한 경우에는 Google Play 캐시가 Play 백엔드의 최신 데이터로 업데이트되도록 트리거됩니다. 이 경우에는 오류가 발생한 후에 간단한 재시도 전략을 사용하면 일시적인 오류가 해결됩니다. ITEM_NOT_OWNED 오류가 표시된 후 BillingClient.queryPurchasesAsync()를 호출하여 사용자가 제품을 획득했는지 확인하고, 사용자가 제품을 획득하지 않았다면 간단한 재시도 로직을 구현하여 구매를 재시도합니다.

재시도 불가능한 BillingResult 응답

다음과 같은 오류가 발생한 경우 재시도 로직을 사용하여 복구할 수 없습니다.

FEATURE_NOT_SUPPORTED

문제

재시도 불가능한 이 오류는 사용자의 기기에서 Play 스토어 버전이 오래되어 Google Play 결제 기능이 지원되지 않음을 나타냅니다.

예를 들어 일부 사용자의 기기에서 인앱 메시지를 지원하지 않을 수 있습니다.

가능한 완화 조치

Play 결제 라이브러리를 호출하기 전에 BillingClient.isFeatureSupported()를 사용하여 기능 지원 여부를 확인하세요.

when {
  billingClient.isReady -> {
    if (billingClient.isFeatureSupported(BillingClient.FeatureType.IN_APP_MESSAGING)) {
       // use feature
    }
  }
}

USER_CANCELED

문제

사용자가 결제 흐름 UI 외부를 클릭했습니다.

가능한 해결 방법

이는 정보 제공 목적으로만 제공되며 단계적 기능 저하를 구현할 수 있습니다.

ITEM_UNAVAILABLE

문제

이 사용자는 Google Play 결제 정기 결제 또는 일회성 구매 제품을 구매할 수 없습니다.

가능한 완화 조치

앱이 권장된 대로 queryProductDetailsAsync를 통해 제품 세부정보를 새로고침하는지 확인합니다. 필요한 경우 Play Console 구성에서 제품 카탈로그가 변경되는 빈도를 고려하여 추가 새로고침을 구현합니다. queryProductDetailsAsync를 통해 올바른 정보를 반환하는 제품만 Google Play 결제에서 판매합니다. 제품 적합성 구성에 불일치가 있는지 확인합니다. 예를 들어 사용자가 구매하려는 지역이 아닌 다른 지역에서만 사용할 수 있는 제품을 쿼리하고 있을 수 있습니다. 사용자에게 판매하려면 상품이 활성 상태이고, 앱이 게시된 상태여야 하며, 사용자의 국가에서 앱을 사용할 수 있어야 합니다.

테스트 중에 제품 구성의 모든 항목이 올바르지만 사용자에게 이 오류가 표시되는 경우가 있을 수 있습니다. 이는 Google 서버에서 제품 세부정보가 전파되는 데 지연이 발생했기 때문일 수 있습니다. 나중에 다시 시도하세요.

DEVELOPER_ERROR

문제

API를 올바르게 사용하고 있지 않음을 나타내는 치명적인 오류입니다. 예를 들어 BillingClient.launchBillingFlow에 잘못된 매개변수를 제공하면 이 오류가 발생할 수 있습니다.

가능한 해결 방법

여러 Play 결제 라이브러리 호출을 올바르게 사용하고 있는지 확인합니다. 또한 디버그 메시지에서 오류에 관한 자세한 내용을 확인하세요.