
'한 달째 일시적인 오류'라는 리뷰: 상품을 기다리지 않던 구독 상세 화면, 16명 중 1명만 결제를 시작했다
구독 상세 화면이 상품 정보를 요청한 바로 다음 줄에서 목록이 비었다고 판정해, 상품이 캐시돼 있지 않은 사용자를 메인으로 돌려보내고 있었습니다.
한글 띄어쓰기 앱의 구독 상세 화면에 90일 동안 16명이 들어왔고, 결제를 시작한 사람은 1명이었습니다. 2026년 9월 26일 GA4에서 이 퍼널을 본 것은 Play 리뷰 하나 때문이었습니다. "고급 구독을 하려 하면 일시적인 오류… 한 달째"라는 내용이고 유용 표가 18개였습니다. 같은 날 커밋 9290f91로 화면이 상품을 최대 8초 기다리게 고쳤고, 10월 1일 1.6.025 배포 뒤 10월 17일 중간 판정에서는 상세 화면에 온 13명 중 4명(31%)이 결제를 시작했습니다.
근거는 한글 띄어쓰기 저장소의 git 기록(9290f91, 023dd3c, a839c87, 6eb49a1과 2023~2024년 커밋), CHANGELOG 09-26·09-30 절, 관측 기록 WATCH.md, 2026-09-26~30 작업 세션 기록입니다. 사용자 수는 GA4 사용자 기준입니다.
목차 (11)
- GA4 90일 퍼널: 구독 화면 45명, 상세 화면 16명, 결제 시작 1명
- 빈 목록을 바로 오류로 판정한 함수
- 2024년의 '응답이 나뉘어 온다'는 판단은 for 루프가 만든 증상이었습니다
- AI가 쓴 첫 테스트 5개 중 3개는 Mockito에서 실패했습니다
- S21에서 메인으로 돌아간 원인은 기기의 Play 결제 연결이었습니다
- '배포 준비 완료' 다음 날 S23 시험에서 결제 결함 7개가 더 나왔습니다
- 즉시 판정, 무한 대기, 8초 대기 중에서 고른 것
- 13명 중 4명은 중간값이고, 기기 쪽 결제 불가는 남습니다
- 내 구독 화면 점검 순서
- 10월 31일 +30일 판정에서 볼 것
- 자주 묻는 질문
GA4 90일 퍼널: 구독 화면 45명, 상세 화면 16명, 결제 시작 1명
2026년 9월 26일에 한글 띄어쓰기 앱의 구독 이벤트를 사용자 기준으로 90일 치 뽑았습니다. 구독 안내 화면에 45명, 요금제를 고르는 상세 화면에 16명, 결제 시작에 1명이었습니다. 상세 화면까지 온 16명 가운데 15명(94%)은 결제를 시작하지 않았습니다. 상세 화면 진입 이벤트는 고급(premium)이 18건, 기본(basic)이 2건이었고, 1.6.016~1.6.024 사이 7개 버전에 걸쳐 나왔습니다.
리뷰가 말한 "일시적인 오류"는 앱의 공용 문구 일시적인 오류가 발생되었습니다. 다시 시도해주세요.입니다.

빈 목록을 바로 오류로 판정한 함수
상세 화면의 addSubsItems()는 상품 정보 흐름을 lifecycleScope.launch 안에서 firstOrNull()로 기다리게 걸어 두고, 같은 함수 아래에서 목록 크기를 바로 검사했습니다. 수정 전 코드를 줄여 옮기면 이렇습니다.
getProductDetails(repo.premiumProductDetails) // 안에서 launch { flow.firstOrNull() }
if (recyclerAdapterSubscription!!.mItemList!!.size <= 0) {
Toast.makeText(..., R.string.string_common_error_retry, ...).show()
startActivity(메인 화면)
}
상품이 결제 관리자의 StateFlow에 이미 들어 있으면 firstOrNull()이 그 자리에서 값을 내 목록이 채워졌고, 아직 없으면 코루틴은 기다리는 동안 함수는 다음 줄로 넘어가 빈 목록을 봤습니다. 상품이 미리 캐시돼 있지 않은 사용자는 전부 토스트를 보고 메인으로 돌아갔습니다. 크기 검사 줄은 2024년 1월 4일 커밋에서, firstOrNull()은 2024년 10월 3일 커밋에서 들어왔습니다.
9290f91은 이 판정을 기다림으로 고쳤습니다. 이제 화면은 진행 다이얼로그를 띄운 채 withTimeoutOrNull(8_000) 안에서 상품을 기다리고, 8초가 지나도 안 오면 그때 토스트를 띄우고 돌아갑니다. 코드 주석에 적힌 근거는 "결제 연결부터 상품 조회까지 보통 1~2초, 그 몇 배"입니다. 8초는 지연 분포를 재서 정한 값이 아닙니다.
2024년의 '응답이 나뉘어 온다'는 판단은 for 루프가 만든 증상이었습니다
결제 관리자 BillingClientManager는 Play가 돌려준 상품을 맵에 담을 때 if(newMap.size >= 2), 즉 basic과 premium이 한 응답에 함께 왔을 때만 저장했습니다. 이 줄을 넣은 2024년 10월 4일 커밋의 메시지는 "간헐적으로 product details response 가 따로 혹은 같이 입력됨", "basic, premium 한번에 입력될때만 저장"입니다.
2023년 12월 30일부터 queryProductDetails()는 상품 목록을 만드는 for 루프 안에서 반복마다 queryProductDetailsAsync를 불렀습니다. 상품이 두 개라 basic 하나짜리 요청과 둘짜리 요청이 차례로 나갔고, 응답도 1개짜리와 2개짜리로 왔습니다. 2024년 수정은 1개짜리를 버려 증상을 가렸고, 그 대가로 Play가 상품을 하나만 돌려주는 경우(한 상품의 base plan이 꺼져 있을 때 등)에는 맵이 끝까지 비게 만들었습니다.
9290f91은 조회를 한 번만 하고, 온 상품은 온 대로 기존 맵에 합치게 고쳤습니다. Play Billing Library 9.1.0의 QueryProductDetailsResult는 돌려주지 못한 상품을 unfetchedProductList로 따로 주는데, 이것을 로그로 남겨 Play Console 쪽 문제와 앱 문제를 가를 수 있게 했습니다.
AI가 쓴 첫 테스트 5개 중 3개는 Mockito에서 실패했습니다
수정은 AI 코딩 에이전트(Claude Code)와 함께 했고, 고치기 전에 실패하는 테스트부터 썼습니다. Robolectric 화면 테스트 3개(안 왔을 때 기다림, 나중에 오면 목록 표시, 끝내 안 오면 오류)와 결제 관리자 테스트 2개(한 상품만 온 응답, 나뉘어 온 응답 합치기)입니다. 첫 실행에서 5개가 모두 실패했지만 그중 3개는 버그를 재현한 실패가 아니었습니다.
java.lang.NullPointerException: Cannot invoke "org.mockito.plugins.MockMaker$TypeMockability.mockable()" because "typeMockability" is null
에이전트가 ProductDetails를 Mockito 목으로 만들었는데, 이 클래스는 final이고 이 프로젝트 테스트 클래스패스에는 mockito-android가 섞여 있어 final 목을 만들지 못했습니다. 그래서 목을 버렸습니다. ProductDetails의 생성자는 JSON 문자열을 받는 패키지 전용 생성자라서, 테스트 코드에 같은 패키지 이름 com.android.billingclient.api로 TestProductDetails를 두고 Play가 내려 주는 모양의 JSON(productId, type, subscriptionOfferDetails, pricingPhases)으로 진짜 객체를 만들었습니다. 다시 돌리자 5개가 모두 의도한 이유로 실패했습니다. 화면 테스트는 "상품이 아직 안 왔을 뿐인데 메인으로 내보냈다"로, 합치기 테스트는 기대값 basic·premium에 실제값 빈 집합으로 실패했고, 수정 뒤 통과했습니다. 그때 쓴 도우미의 축약본입니다(상품 ID와 가격은 자리 표시로 대신했습니다).
package com.android.billingclient.api // 테스트 소스. 생성자가 패키지 전용이라 같은 패키지에 둔다
fun subscriptionMonthly(productId: String, basePlanId: String): ProductDetails {
val phase = JSONObject().put("priceAmountMicros", 1_000_000_000).put("priceCurrencyCode", "KRW")
.put("formattedPrice", "1,000원").put("billingPeriod", "P1M").put("recurrenceMode", 1)
val offer = JSONObject().put("basePlanId", basePlanId).put("offerIdToken", "token-$basePlanId")
.put("offerTags", JSONArray()).put("pricingPhases", JSONArray().put(phase))
val json = JSONObject().put("productId", productId).put("type", "subs")
.put("title", productId).put("name", productId).put("description", "test")
.put("subscriptionOfferDetails", JSONArray().put(offer))
return ProductDetails(json.toString()) // 목이 아니라 진짜 객체
}
// 사용: subscriptionMonthly("premium", "premium-monthly")
S21에서 메인으로 돌아간 원인은 기기의 Play 결제 연결이었습니다
9월 28일 Galaxy S21에서 구독 상세 화면을 열자, 진행 표시가 돌다가 화면이 닫히고 메인으로 돌아갔습니다. 최근 이틀 사이 변경부터 의심해서, 그 변경 전 코드(9월 26일 저녁, 9290f91은 포함)로 릴리스 빌드를 따로 만들어 같은 폰에 설치했는데 똑같이 8초 뒤 실패했습니다. 결제 연결이 남긴 오류는 In-app billing API version 3 is not supported였고, 이 폰의 Google Play 서비스는 2024년 12월판이었습니다.
원인은 기기였어도 앱 결함 두 개가 함께 드러났습니다. 결제 연결 설정이 실패로 끝나면 끊김 콜백이 오지 않아 아무도 다시 연결하지 않았고, 앱을 다시 켜기 전까지 구독 화면은 매번 8초를 기다린 뒤 원인과 상관없는 "다시 시도해주세요"를 띄웠습니다. 같은 날 023dd3c에서 결제 관리자가 연결 상태(연결 중, 연결됨, 사용 불가와 응답 코드)를 내보내게 했습니다. 023dd3c 뒤 1.6.025에 들어간 대기 부분은 이렇습니다.
repo.ensureConnected() // 앞서 실패한 연결은 여기서 다시 건다
popupDialog.showDialog(POPUP_TYPE.PROGRESS, true)
lifecycleScope.launch {
val outcome: Any? = try {
withTimeoutOrNull(PRODUCT_LOAD_TIMEOUT_MS) { // 8_000L
merge(source, repo.connectionState.filterIsInstance<BillingConnectionState.Unavailable>())
.firstOrNull() // 상품 도착과 연결 실패 중 먼저 오는 쪽
}
} finally { popupDialog.stopDialog() }
// Unavailable: (BILLING/SERVICE_UNAVAILABLE이면) Play 확인 안내 + subscription_unavailable / null(8초 초과): 재시도 문구 / 그 밖: 요금제 목록
}
BILLING_UNAVAILABLE이면 8초를 채우지 않고 Play 스토어를 확인하라는 안내를 띄우고 GA4에 subscription_unavailable을 보냅니다.
'배포 준비 완료' 다음 날 S23 시험에서 결제 결함 7개가 더 나왔습니다
9월 29일 1.6.025는 배포 준비 완료로 기록됐습니다(그날 검토 과정은 에이전트 84개가 돈 릴리스 전 검토에 있습니다). 다음 날 아침 운영자가 Galaxy S23에서 구독을 직접 눌러 보고 이대로는 배포할 수 없다고 해서, S21 실기기 점검과 에이전트 교차 검증을 다시 돌렸고, 1.6.024 이전부터 있던 결제 결함 7개가 나왔습니다.
- 결제 창이 바로 실패하면 대기 표시가 남고, 뒤에서 도는 스레드가 CPU 한 코어를 계속 씀
- 결제 연결 완료 콜백의 OK가 구매 결과와 같은 전역 값에 쓰여 "구독 완료"로 읽힘
- 결제 대기(PENDING)를 성공으로 처리
- 앱으로 돌아올 때마다 BillingClient를 새로 만들어 누수가 생김. 구매 목록 조회가 끝나기 전의 빈 목록을 보고 Classic 화면이 구독자를 무료로 저장
- Chat 화면은 구매 뒤 혜택을 반영하지 않음
- 구매 목록을 모르는 채 결제하면 기존 구독자가 두 번째 구독을 살 수 있음
- 화면의 onDestroy가 앱 전체가 쓰는 결제 연결을 끊음
a839c87은 구매 결과를 연결 상태와 다른 흐름으로 받고, 구매 목록은 성공한 조회에만 채워지는 신호로 읽어 "조회 성공이고 활성 구독이 없을 때"만 무료로 내리게 했습니다. 6eb49a1은 결제 직전에 구매 목록을 새로 묻고, 모르면 결제 창을 띄우지 않게 했습니다. 그날 수정 전체로 단위 테스트는 250개에서 292개가 됐습니다.
S23 로그는 보지 못했지만, 같은 시험을 S21에서 해 보니 원인은 시험 방식이었습니다. Play에 올리지 않은 사이드로드 릴리스 빌드는 Play가 "이 버전은 Google Play 결제를 사용할 수 없습니다"를 띄우고, 패키지 이름 끝에 .debug가 붙는 디버그 빌드는 Play에 등록된 상품이 없습니다. 실결제는 내부 테스트 트랙에 올리고 라이선스 테스터 계정으로 Play에서 받아 설치해야만 끝까지 볼 수 있어서, 이 확인을 배포 절차에 한 단계로 넣었습니다(b88e9bf).

즉시 판정, 무한 대기, 8초 대기 중에서 고른 것
예전 방식인 즉시 판정은 화면을 연 순간 목록이 비어 있다는 사실을 오류로 본 것이라 버렸습니다. 반대쪽 끝인 무한 대기는 수정 전 코드에 이미 반쯤 있었습니다. firstOrNull()은 상품이 끝내 오지 않으면 돌아오지 않으니, 크기 검사 줄만 지웠다면 사용자는 빈 화면을 계속 봤을 겁니다. 배포 30일 뒤에도 상세에서 결제 시작이 0~10%면 상품 조회 자체가 실패하는 것으로 보고 결제 연결 재시도로 간다는 규칙도 9월 26일에 적어 두었습니다.
비동기로 오는 상품 정보를 화면을 연 직후에 판정하지 마십시오. 기다릴 상한을 정하고, 결제 연결 실패처럼 원인을 아는 실패는 기다리지 말고 바로 알리십시오. 같은 규칙이 지금 이 앱 저장소의 에이전트 지침(CLAUDE.md) 구독 절에 들어 있습니다.
13명 중 4명은 중간값이고, 기기 쪽 결제 불가는 남습니다
9월 26일에 정한 판정 규칙의 나머지 절반은 "배포 30일 뒤 상세에서 결제 시작이 30%를 넘으면 원인 확정"이었습니다. 10월 17일 중간값 31%는 이 선을 넘었고 결제 성공도 2명 생겼지만, 상세 화면 13명짜리 표본입니다. 10월 18일 기록에서는 Play 사전 출시 로봇이 1.6.030의 구독 화면 수치를 부풀린 것이 확인됐는데, 이 13명에 로봇이 섞였는지는 기록에 없습니다(GA4에서 로봇을 거른 방법은 GA4의 '어제 설치 19건'은 사실 8건이었다에 있습니다).
같은 날 subscription_unavailable은 상세 화면 13명 중 2명(15%)이었습니다. 오래된 Play 서비스나 로그인 문제처럼 기기 쪽에서 결제를 못 하는 사용자는 수정 뒤에도 남고, 앱이 할 수 있는 일은 정확한 안내까지입니다. TestProductDetails는 Play Billing Library 9.1.0에서만 써 봤고, Robolectric 테스트는 실결제와 Play 서명을 보지 못합니다.
내 구독 화면 점검 순서
- GA4에서 구독 안내, 상세, 결제 시작 이벤트를 사용자 기준으로 나란히 봅니다. 상세에서 결제 시작이 한 자릿수 비율이면 상세 화면 코드부터 엽니다.
- 상품 흐름을
launch로 걸어 둔 함수 안에서, 같은 함수 아래에 목록 크기를 검사하는 줄이 있는지 찾습니다. queryProductDetailsAsync가 루프 안에 있는지, 응답 처리부가 "모든 상품이 와야" 저장하는지 봅니다. 온 상품은 합치고unfetchedProductList는 로그에 기록합니다.- 상세 화면은 진행 표시와 함께 상한을 두고 기다리고, 결제 연결이
BILLING_UNAVAILABLE이나SERVICE_UNAVAILABLE로 끝나면 바로 Play 업데이트 안내로 끝냅니다. - JSON으로
ProductDetails를 만드는 테스트 도우미를 두고, 화면 3경우와 관리자 2경우를 먼저 실패시킨 뒤 고칩니다. - 배포 전에 판정 기준(몇 % 넘으면 원인 확정, 얼마 아래면 다른 원인)을 날짜와 함께 적습니다.
10월 31일 +30일 판정에서 볼 것
최종 판정일은 1.6.025 배포 30일째인 10월 31일입니다. 상세 화면 사용자에 사전 출시 로봇이 섞였는지 확인한 뒤에도 결제 시작 비율이 30% 위에 남는지, subscription_unavailable이 5%를 계속 넘어 Play 스토어로 바로 가는 버튼이 필요한지 봅니다. 배포 절차에 넣은 내부 테스트 트랙 실결제 확인을 1.6.025 배포 전에 실제로 마쳤는지는 기록에서 확인하지 못했습니다.
자주 묻는 질문
구독 화면에서 "일시적인 오류"가 계속 나는데 Play 쪽 장애인가요?
앱 코드일 가능성부터 보십시오. 한글 띄어쓰기 앱은 상품 정보가 오기 전에 목록이 비었다고 판정해, 캐시가 없는 사용자를 전부 돌려보냈습니다. 다시 시도해도 같으면 일시적인 장애가 아닙니다.
ProductDetails를 Mockito로 목으로 만들 수 없을 때는 어떻게 테스트하나요?
테스트 소스에 com.android.billingclient.api 패키지를 만들고, 패키지 전용 생성자에 Play가 내려 주는 모양의 JSON을 넣어 진짜 객체를 만듭니다. Play Billing Library 9.1.0에서 이 방식으로 테스트 5개를 돌렸습니다.
사이드로드한 릴리스 APK에서 "Google Play 결제를 사용할 수 없습니다"가 뜨면 결함인가요?
아닙니다. Play에 올리지 않은 빌드나 디버그 패키지에서는 결제가 안 되는 것이 정상입니다. 실결제는 내부 테스트 트랙에 올리고 라이선스 테스터 계정으로 Play에서 설치해 확인합니다.
이 글은 SugarMount가 직접 측정한 값으로 쓰고, 초안 작성에 AI 코딩 에이전트를 썼습니다.
다른 AX 노트
- GA4의 '어제 설치 19건'은 사실 8건이었다: 테스트 기기와 Play 사전 출시·심사 기기 걸러 내기
- 20% 단계적 출시가 100%로 나갔다: Play Publisher API의 completed는 단계 출시로 되돌릴 수 없다
- Android assets를 미리 gzip한 실측: APK 이득은 0, .pack.gz는 .pack이 되어 FileNotFoundException
- SKT 판매 삼성 폰에서 market://을 열면 스토어 선택 창이 뜬다: Play 패키지부터 지정하기
- addOnCompleteListener(this)가 onStop에서 지운 리스너 때문에 스플래시에 멈춘 버그
- 화살표 퍼즐 1,100판을 만든 역구성 생성기와 G2 게이트