앱 15개가 전부 업데이트 대상이 됐다? — Google Play가 하루아침에 올린 두 기준(결제 8+, target 36)
앱 하나 버그가 아니라 Google Play 기준선이 2026-08-31 자로 올라가면서 갖고 있는 앱 15개가 한꺼번에 업데이트 대상이 됐다. 인앱결제 라이브러리 8.0.0+, target API 36(Android 16) — 결제 7.1.1→9.1.0에서 실제로 깨진 API, compileSdk/AGP 버전 체인, 16KB 정렬, Scaffold 바깥 전체화면의 edge-to-edge 인셋까지 규칙찾기·티츄온라인·링고루프 실제 커밋으로 정리.
TL;DR
- 어느 날 앱 하나가 아니라 갖고 있는 앱 15개가 전부 대응 대상이 됐다. 개별 버그가 아니라 Google Play 정책이 올라간 것 — 데드라인은 2026-08-31.
- 두 가지를 같이 요구한다.
- 인앱결제 라이브러리 8.0.0 이상 (Play Billing Library) — 구버전 지원 종료.
- target API level 36 이상 (Android 16).
- 결제는 버전만 올리면 컴파일이 깨진다. 규칙찾기(catchtherole)에서 실제로 걸린 건
queryProductDetailsAsync콜백의 2번째 인자가List<ProductDetails>→QueryProductDetailsResult로 바뀐 것 하나. 7.1.1 → 9.1.0으로 올렸다(요구 하한은 8.0.0, 권장 9.x). - target 36은 숫자 하나가 아니라
compileSdk 36→ AGP 8.9.1까지 끌어올리는 일이고, 올리면서 16KB 페이지 정렬과 edge-to-edge 인셋을 같이 확인해야 했다. - edge-to-edge는
enableEdgeToEdge()로 이미 돼 있었는데,Scaffold바깥에서 그려지는 전체화면(캠페인·타임어택)만 인셋을 못 받아 버튼이 상태바와 겹쳤다. 이건 실기기에서만 잡혔다.
왜 전 앱이 한날에 막혔나
앱 하나만 손보는 게 아니라 규칙찾기, 티츄온라인, 성인의 수학, 듀오아레나… 15개를 한꺼번에 굴리다 보니, Play 정책 데드라인이 오면 정책을 안 맞춘 앱이 전부 같은 날 막힌다. 코드가 잘못된 게 아니라 바깥 기준선이 올라간 것뿐이라, 원인 파악보다 “어디까지 같이 올려야 하나”를 정하고 같은 절차를 앱마다 반복하는 게 일의 대부분이다.
이번에 2026-08-31 자로 걸린 건 두 개다.
- Play Billing Library 8.0.0+ — 결제 붙은 앱 전부.
- target API level 36(Android 16)+ — 결제 여부와 무관하게 전부.
이 글은 그중 규칙찾기(네이티브 Android, Kotlin + Jetpack Compose) 를 주된 축으로, 티츄온라인·링고루프(Flutter) 를 대조로 든다. 앱마다 스택(네이티브/Flutter/서드파티 결제 SDK)이 달라도 결국 툴체인 버전을 위로 정렬하는 것이 공통이라, 하나를 제대로 훑어두면 나머지는 같은 체크리스트로 돈다. (구독 앱 특유의 무료체험 offer 함정은 이번 정책과 직접 인과가 없어 별도 글로 뺐다.)
이슈 1 — Play Billing Library 8+ (7.1.1 → 9.1.0)
무엇이 요구되는가
Google은 Play Billing Library를 버전별로 일정 기간만 지원하고, 기간이 지나면 그 버전으로 만든 앱의 업데이트 제출을 막는다. 이번 하한선이 8.0.0. 그래서 결제가 붙은 앱은 8.0.0 이상으로 올려야 업데이트를 낼 수 있다. 굳이 8.0.0에 딱 맞추기보다 권장 안정 버전인 9.x(9.1.0) 로 올렸다.
// Android/app/build.gradle.kts
dependencies {
// 인앱결제 (광고 제거) — Play 정책: 2026-08-31부터 8.0.0+ 필수(9.x 권장)
implementation("com.android.billingclient:billing-ktx:9.1.0")
}
Flutter 앱이면
in_app_purchase(내부in_app_purchase_android)가, RevenueCat 같은 서드파티 SDK를 쓰면 그 SDK가 Play Billing을 래핑한다. 이 경우 플러그인/SDK 버전만 올리면 내부 Billing이 8+로 따라 올라가고, 아래의 컴파일 에러도 대개 라이브러리가 대신 흡수한다. 실제로 티츄온라인(Flutter)은in_app_purchase 3.3.0/in_app_purchase_android 0.5.2로 올리자 플러그인이billing:8.0.0을 번들했고(0.4.x는 7.x),flutter analyze가 클린 — Dart 쪽 IAP 코드는 손댈 게 없었다.다만 플러그인 버전이 다시 SDK 버전을 끌어올릴 수 있다.
in_app_purchase_android 0.5.x는 Dart ≥ 3.12를 요구해서, 결국 Flutter 3.44.7(Dart 3.12.2) 까지 함께 올려야 했다. 네이티브의compileSdk → AGP사슬과 똑같이, Flutter에도 플러그인 → Dart/Flutter SDK 사슬이 있다.
버전만 올리면 컴파일이 깨진다
Billing 8.0.0은 오래 deprecated였던 API를 실제로 삭제·변경한 메이저 버전이라 숫자만 올리면 빌드가 안 된다. 규칙찾기에서 실제로 걸린 건 하나 였다 — queryProductDetailsAsync의 콜백 2번째 인자 타입 변경. 8.0.0부터 List<ProductDetails>가 아니라 QueryProductDetailsResult가 넘어온다.
// before (7.1.1)
client.queryProductDetailsAsync(params) { result, list ->
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
list.forEach { productDetails[it.productId] = it }
// ...
}
}
// after (8.0.0+) — 콜백 2번째 인자가 QueryProductDetailsResult 로 바뀜
client.queryProductDetailsAsync(params) { result, queryResult ->
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
queryResult.productDetailsList.forEach { productDetails[it.productId] = it }
// ...
}
}
핵심은 list → queryResult.productDetailsList. 리스트를 한 겹 감싼 결과 객체로 바뀐 것뿐이라 대응은 짧다.
규칙찾기는 일회성 상품(광고 제거·힌트) 만 팔아서 이 한 줄로 끝났다. 네이티브에서 구독을 팔면 8에서 더 정리할 게 있다 — 대표적으로
ProrationMode제거(→ReplacementMode). 출발 버전(6→8인지 7→8인지)에 따라 걸리는 API가 다르니, 공식 마이그레이션 가이드 에서 자기 버전 기준으로 확인. 단, Flutter는 이 네이티브 API 변경을 플러그인이 흡수한다 — 구독을 파는 링고루프도 플러그인 bump만으로 Dart 코드 변경 0이었다(티츄와 동일). 다만 구독엔 결제 라이브러리 버전과 무관한 별도 함정(무료체험 offer 선택) 이 있는데, 이번 정책과 인과가 없어 따로 정리했다.
라이브러리 손대는 김에 같이 정리한 것
결제 코드를 여는 김에, 서버 영수증 검증(verifyOnServer)의 커넥션 누수도 정리했다. disconnect()를 finally로 옮기고, 응답/에러 스트림을 드레인해서 예외 시 keep-alive가 망가지는 걸 막았다.
try {
OutputStreamWriter(conn.outputStream).use { it.write(body) }
val code = conn.responseCode
(if (code in 200..299) conn.inputStream else conn.errorStream)?.use { it.readBytes() }
} finally {
conn.disconnect() // 예외가 나도 반드시 닫는다
}
정책 대응은 아니지만, 어차피 열어야 하는 파일이면 근처의 오래된 문제도 같이 닫고 나오는 편이 다음 번 삽질을 줄인다.
이슈 2 — target API level 36 (Android 16)
targetSdk 숫자 하나가 아니다
targetSdk 36을 쓰려면 **compileSdk 36**이 필요하고, compileSdk 36은 최신 AGP를 요구한다. 그래서 실제 diff는 이렇게 사슬로 올라간다.
// Android/build.gradle.kts — AGP 8.7.3 → 8.9.1 (compileSdk 36 최소 요구, Gradle 8.11.1 호환 유지)
plugins {
id("com.android.application") version "8.9.1" apply false
}
// Android/app/build.gradle.kts
android {
compileSdk = 36 // 35 → 36
defaultConfig {
minSdk = 24
targetSdk = 36 // 35 → 36
versionCode = 11 // 10 → 11
versionName = "1.0.7"
}
}
즉 실제로 만지는 순서는 AGP(→ 필요하면 Gradle 배포판) → compileSdk → targetSdk. Flutter 앱이면 여기에 Flutter SDK 버전 → 플러그인들이 그 조합을 지원하는지까지 사슬이 더 길어진다. 규칙찾기는 AGP 8.9.1 / Gradle 8.11.1 조합으로 bundleRelease가 통과했다.
확인할 것 ①: 16KB 페이지 정렬
Android 15+ 기기는 16KB 메모리 페이지를 쓸 수 있고, Play는 Android 15+ target 앱이 16KB 페이지에서 동작하길 요구한다. 순수 Kotlin/Java 코드만 있으면 대개 무관하지만, 네이티브 .so(광고·결제·보안 SDK, 이미지/영상 라이브러리 등) 가 섞여 있으면 16KB 정렬로 빌드된 버전이 필요하다.
규칙찾기는 AAB 안의 네이티브 라이브러리 4개 ABI 전부 16KB(0x4000) 정렬을 이미 충족했다(광고 SDK가 최신이라 통과). 확인은 .so의 LOAD 세그먼트 정렬을 보면 된다.
# AAB/APK 안 .so 들의 정렬 확인 — 0x4000(=16384) 이어야 안전
find . -name "*.so" -exec sh -c \
'echo "== $1"; llvm-readelf -l "$1" | grep -A1 LOAD | grep -i align' _ {} \;
정렬이 안 맞으면 대부분 문제 SDK를 16KB 지원 버전으로 올리는 것이 답이다. 내 코드가 아니라 의존성이 원인인 경우가 많아, 여기서도 결국 “버전 올리기”로 귀결된다.
확인할 것 ②: edge-to-edge 인셋 (실기기에서만 잡힌 것)
target SDK 35(Android 15)부터 edge-to-edge가 강제된다(36도 유지). 앱이 상태바/내비게이션바 뒤까지 전체 화면으로 그려지면서, 시스템이 넣어주던 여백이 사라진다. 규칙찾기는 enableEdgeToEdge() + Scaffold의 systemBars 인셋으로 이미 대응돼 있는 줄 알았다. 그런데 실기기(Android 16, 3버튼 내비)에서 보니:
- 상단: 캠페인/타임어택 화면의 닫기(X)·힌트·계산기 버튼이 상태바 시계·배터리와 겹침
- 하단: 배너 광고가 내비게이션 바 아래로 깔려 오클릭 위험
원인은 화면 구조였다. 탭 화면들은 RootScreen의 Scaffold가 주는 padding으로 시스템 바를 피하는데, 캠페인 세션·타임어택은 fullScreen 조기 return 경로로 Scaffold 바깥에서 렌더링돼 인셋을 전혀 못 받고 있었다. 그래서 전체화면 전용 배경 컴포저블을 만들어 콘텐츠에만 safeDrawing 인셋을 먹였다.
/**
* 전체화면(캠페인 세션 / 타임어택) 전용 배경.
* 탭 화면은 Scaffold 의 padding 으로 시스템 바를 피하지만,
* 이 두 화면은 Scaffold 바깥에서 렌더링되므로 인셋을 직접 적용해야 한다.
* 그라데이션 배경은 화면 끝까지, 콘텐츠만 안쪽으로 민다.
*/
@Composable
fun FullScreenBackground(content: @Composable () -> Unit) {
ScreenBackground { // 그라데이션은 화면 끝까지
Box(
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing) // 콘텐츠만 안쪽으로
) {
content()
}
}
}
포인트 두 가지.
- 배경은 끝까지, 콘텐츠만 민다. 그라데이션까지 안으로 밀면 시스템 바 옆에 빈 띠가 생긴다. 배경은
fillMaxSize로 두고windowInsetsPadding은 안쪽 콘텐츠에만 준다. - 이중 패딩 주의. 탭 화면이 쓰는
ScreenBackground는 그대로 뒀다. 거기까지 인셋을 또 주면Scaffoldpadding과 겹쳐 위아래가 과하게 밀린다.
사실 이 인셋 버그는 target 35/36 양쪽에서 재현되던 기존 문제였다. API 36 전환이 직접 만든 건 아니지만, 실기기 점검을 하는 김에 잡혔다. edge-to-edge는 “빌드가 통과했다”로 끝나는 게 아니라 실기기에서 위아래를 눈으로 봐야 안다는 게 교훈.
마이그레이션 순서 (체크리스트)
전 앱을 같은 절차로 훑을 수 있게 순서를 고정해두면 편하다.
- 결제 라이브러리부터 8+로. 네이티브는
billing-ktx:9.1.0, Flutter/서드파티는 해당 버전(플러그인이 다시 Dart/Flutter SDK를 끌어올릴 수 있음). 컴파일 에러(규칙찾기는queryProductDetailsAsync콜백 타입) 정리. - AGP → compileSdk → targetSdk 36. 규칙찾기 기준 AGP 8.9.1 / Gradle 8.11.1 / compileSdk·targetSdk 36.
bundleRelease통과 확인. - 16KB 정렬 확인.
.so가 있으면0x4000정렬 검사 → 걸리는 SDK 버전 업. - edge-to-edge를 실기기에서 확인. 특히
Scaffold바깥에서 그려지는 전체화면 라우트가 인셋을 받는지 위아래를 눈으로 점검. - 실기기 결제 플로우 테스트. 라이브러리 메이저 업이라 구매·복원(·구독 변경) 을 라이선스 테스터로 한 바퀴 돌린다.
- 내부 테스트 트랙에 먼저 올려 Play Console이 정책 통과로 인식하는지 확인 후 프로덕션.
정리
핵심은 두 가지다.
- 이번 장애는 코드 버그가 아니라 Play의 기준선이 2026-08-31 자로 올라간 것이다. 그래서 앱마다 원인을 파는 게 아니라 같은 버전 정렬 절차를 전 앱에 반복 적용하는 일이 된다.
- 겉보기엔 “결제 8+“와 “target 36” 두 줄이지만, 실제로는 결제 라이브러리 삭제 API 대응과 compileSdk → AGP 사슬을 올려야 하고, 그 과정에서 16KB 정렬과 edge-to-edge 인셋이 딸려 나온다. 마지막 둘은 빌드 성공이 아니라 실기기 확인으로만 잡힌다.
매년 이맘때 target SDK가 한 칸씩 올라가는 건 정해진 일이라, “버전 정렬 체크리스트”를 앱 공통 문서로 하나 두고 해마다 숫자만 갱신하는 게 결국 제일 싸게 먹힌다. 이번 글이 그 문서의 초안인 셈이다.