안드로이드 SDK 35 + Kotlin + R8

안드로이드 SDK 35 + Kotlin + R8
2025-07 난독화 R8 은 코틀린과 전혀 맞지 않는다.
💡
난독화 R8 은 Full mode 로 하던 하지 않던, 코틀린과의 조합은 문제를 일으킨다. Java 로 작성된 소스코드는 R8 난독화에서는 문제가 없다.

Android SDK 35 (API Level) 강제 업그레이드의 시기가 왔다

그럼 왜 업그레이드 하는 건데?

근거 : Meet Google Play's target API level requirement

최소 Target API 가 14 이상되어야, 그 이상버전의 최신 폰에서 너의 앱이 실행될 수 있다고 한다. 그 이하 13으로 만들어 놓으면 최신폰에서는 안될 것이다. 알겠지? 라는 의미다.

경고다, 난 이미 경고했다. 대가는 네가 치뤄야 한다.

Action by Aug 31 )
Update your target API level by August 31, 2025 to release updates to your app.

We've detected that your app is targeting an old version of Android. To provide users with a safe and secure experience, Google Play requires all apps to meet target API level requirements.

From August 31, 2025, if your target API level is not within 1 year of the latest Android release, you won't be able to update your app.

자바 언어로 된 코드와 코틀린으로 이루어진 코드로 된 2가지 형태의 비슷한 기능을 가진 앱을 모두 업그레이드하는 상황이었다.

자바는 별다른 문제 없이 API 버전 업그레이드와 R8 난독화가 이루어졌으나 코틀린은 난독화의 ㄴ 만 켜도 문제를 발생시켰다. 결국 난독화는 아예 포기하는 상황이 되었다.

중심이 되는 SDK 의 업그레이드는 주위 환경의 모든 변화를 야기한다. 그래서 유예 기간이 긴 것이다.

한 마디로 하면 복잡하거나 역사가 깊은 앱의 업그레이드는 지옥문이 펼쳐질 수 있다는 얘기이다.

SDK 의 업그레이드는 각종 플러그인, 컴파일러 그리고 연관된 라이브러리의 버전 업데이트가 동반된다. 여기서는 각종 플러그인과 컴파일러에 대해서 얘기한다.

업데이트의 준비

우선 SDK 35 에 적합한 Android Studio 로 변경해야 한다.
IDE 개발 툴은 계속 발전한다. 이미 배포되었던 IDE 의 버전들은 자연스럽게도 과거 그 당시의 SDK 에 맞추어서 개발되었으므로 사용할 수 없게 된다.

이번 버전을 위한 Android Studio 는 MeerKat ( Android Studio Meerkat Feature Drop | 2024.3.2 Patch 1 ) 이다. 여기서 특이한 점은 한글로 번역된 사이트에서 제공하는 버전보다 영문으로 번역된 사이트에서 제공하는 버전은 다를 수 있다

한국어로 번역된 페이지에서는 Meerkat 을 표현한다. 2025-07월
영문으로 된 페이지는 Narwhal 이다. 2025-07월

- Meerkat 과 Narwhal 이 다른 점을 간단하게 소개한 내용

Meerkat 은 2024년도 출시, Narwhal 은 2025년도 출시연도가 다르다.
다른 이유는 당연하게도? 한글 지원이 뒤 늦게 일어나기 때문에 한글로 편하게 개발하고 싶다면 Meerkat 을 사용하면 될 것이다. 굳이 더 얘기한다면 Meerkat 은 Jetpack Copose Live Edit Feature ( 간단하게 UI 라이브러리 ) 기능의 Stable 버전이고, Narwhal 은 Preview 버전이다. 선택은 그대의 몫이다.

왜 안정화 버전 ( Stable ) 을 사용하게 되는지 좋은 교훈을 얻게 되는 시간이다.

요즘 업그레이드는 굉장히 편하고 빠르게 이루어진다.
바로 안드로이드 업그레이드 어시스턴스 ( Android Upgrade Assistance ) 의 등장이다. 마법같은 가이드로 업그레이드를 편하게 끝낼 수 있다.
단 그날 운수가 터진 날이라면 더더욱 그럴 수 있다.

Android SDK 업그레이드 어시스턴트의 유혹

해당 가이드를 그대로 따라가지만, 믿지 말아야 할 것은 권장되는 버전이다. 말 그대로의 권장은 너의 상황은 "흠..." 잘 알 수도 모를 수도 있겠지만, 이렇게 되면 "잘 될 걸? 아마도 말이지" 의 의미로 받아들여야 한다.

어느덧 가이드를 따라 하다 보면 아래항목의 버전 변화가 나타날 것이다.
기준은 MeerKat 이다.
자세한 내용은 안드로이드 빌드 시스템 구성을 참고하자.

자바 소스로만 이루어진 프로젝트의 SDK Upgrade 변화 요소
  • CompileSdk : 35
  • minSdkVersion : 22 ( 이 앱이 동작하는 최소 버전은 22, Android 5.1 - 2015년 )
  • targetSdkVersion : 35
  • tools.build:gradle : 8.10.1 ( 정확히는 AGP 를 의미한다. Android Gradle Plugin , Android Studio 에서 gradle 를 이용하기 위한 도구, Gradle 은 안드로이드와 관계없는 외부의 빌드 도구다. )
  • gradle-wrapper : 실제로는 말이지 8.11.1 의 내용이다. gradle 버전과 같거나 호환되는 버전이어야 한다.

# File : app/build.gradle
  android {
      compileSdk  35
      defaultConfig {
          minSdkVersion 22
          targetSdkVersion 35
          # ...
      }
  }

# File : build.gradle
  dependencies {
    classpath 'com.android.tools.build:gradle:8.10.1'
  }

# File : gradle/wrapper/gradle-wrapper.properties
  distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
  

지금 우리의 목표는 targetSdk 35 이상이다. 그렇다면 compileSdk 는 36도 가능하다는 것이다.

그럼 이 버전 정보는 어디에서 왔는가?

우선 나의 앱이 지원해야 할 Android 버전은 어디서 찾아야 할까?
2025년도에도 지원해야 할 폰은 Android 5.1 (2015년도 등장) 부터라는 것은 명확해진다.
- 안드로이드 Api Levels

97.6% 안드로이드 사용자를 위한다면 Level 22, Android 5.1 이상 지원해야 한다.


안드로이드 공식 메뉴얼 사이트 에서 이것저것 살피다 보면
Gradle 로 빌드하기 위한 최소/최대 규칙을 찾을 수 있다.
- Android Developers > Develop > Gradle build guides

Android Gradle Plugin (AGP) 는 다음과 같은 도구들과의 버전을 맞추어야 한다.

여기서의 특징은 SDK Build Tools 가 35.0.0 을 의미한다. 반대로 얘기하면 Android SDK 35 버전으로 업그레이드 하고 싶다면 AGP 는 8.10.0 이상을 사용해야 한다는 뜻이 된다.

그럼 Gradle 은 8.11.1 라는데 이 의미는 Android Studio 프로젝트 구성 시 "당신이 알게 모르게" 친절하게 설치해준 외부 빌드 도구라고 보면 될 것이다.

Android 상의 빌드와 JDK 연관관계는 Gradle Build Guide 에 접속해보면 더 많은 것을 알 수 있다.

별거 없다. Java 없으면 못 산다
그래들이란 (Gradle) ?

- 빌드 도구다
- 빌드라는 행위는 굳이 표현한다면, 현실에서 집을 짓는 건축에 비교할 수 있다.
- 방문을 만드는 사람, 벽지를 바르는 사람, 보일러 배관을 설치하는 사람, 창문을 설치하는 사람 등이 있을 때, 이 사람들의 순서와 방법을 제시해 주는 사람, 즉 관리자라고 볼 수 있다. 하지만 이 관리자는 직접 벽지를 바를 수 없고, 배관도 설치할 수 없는 분야의 전문가는 아니다. 대신 보일러 -> 방문 -> 벽지 등의 순서를 만들고 이에 따라 직접 명령을 내려서 행동하게 만드는 "행위 유발자, 트리거"의 역할을 빌드라고 보면 이해할 수 있을 것이다.
- 이 빌드 도구에서 사용하는 언어가 따로 존재한다.
- 그 언어를 이용해서만 "어떠한 행위"를 "언제" 등등의 부가적인 요소를 제어하여 빌드결과를 만들어 낼 수 있다.

- 안드로이드는 이 빌드 도구를 이용하여 "앱 설치파일, AAB/APK file" 등을 생성하는 거이다. 물론 과거에는 Gradle 대신 Maven 이라는 빌드 도구를 이용해서 만들었다.

Maven 빌드도구는 아직도 Java 진영에서는 영원한 동반자다.

여기서 등장하는 JDK ( Java Development Kit ) 는 무엇일까?

Android Gradle Plugin (이하 AGP) 은 Java 프로그램이다. Gradle 도 AGP 도 Java 프로그램이기 때문에 원하는 JRE ( Java Runtime Environment : 자바가 실행되는 환경 , JDK 는 JRE 를 포함한다 ) 의 특정 버전을 요구하는 것이다.


사설이 길었다.


우리의 문제는 이제 시작이며, 결과는 참패다.


지금까지 내가 만난 코틀린은 실망감을 안겨주기에 충분했고, R8 과 만나면서 방점을 찍는다.

코틀린을 선택한 나 자신을 후회할 것이다.


코틀린 프로젝트의 SDK 35 Upgrade 는 ?


# File : app/build.gradle.kts
  android {
      compileSdk = 35
      defaultConfig {
          minSdk = 24
          targetSdk = 35
          # ...
      }
  }

# File : build.gradle.kts
  dependencies {
    // Compatibility with "com.android.application" (AGP)
    // Compatibility with "org.jetbrains.kotlin.android"
    // Compatibility with "com.google.dagger.hilt.android"
  
    // Android Gradle Plugin
    classpath("com.android.tools.build:gradle:8.7.2")
    // Kotlin Gradle Plugin
    classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.21")
  }

# File : gradle.properties
  # 2025-07-04 SDK35 upgrade, fullMode disable Kotlin Casting mode all file problem, 하지만 소용 없다. 난독화 레벨과 관계 없이 문제가 생긴다.
  android.enableR8.fullMode=false

# File : gradle/libs.versions.toml
  # AGP Android Gradle Plugin : 8.7.2
  # Kotlin Language : 2.0.0
  [versions]
  agp = "8.7.2"
  kotlin = "2.0.0."
  hiltVersion = "2.56.2"
  
# File : gradle/wrapper/gradle-wrapper.properties
  # Kotlin 연관되니 최소 Gradle version 이 8.12 를 요구한다.
  distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip
  

위의 버전은 Kotlin 버전에 필요한 AGP, D8, R8 버전 의 안드로이드 문서에 참고해서 조정해야 한다.

아 물론 여기에서 나오는 그대로 된다는 보장은 없다

실제 설정한 내용과 안드로이드에서 제시한 내용에 차이는 어느 정도 감안해야 한다.
코틀린 컴파일러 (Kotlin 버전) 은 문법의 변화도 있을 수 있기에, 함부로 바꿀 수 없다. 그런데 나중에 바꿔야 할 것이다. 당신이 원하지 않더라도 말이다.
개발 언어의 버전을 바꾸는 것은 경험한 사람이라면 악몽이라는 것을 알 것이다.

하지만 위의 정보로는 AGP 와 Gradle 버전을 맞추는데는 부족하다.

Gradle Plugin Document

위의 문서를 확인하면 좀 더 세부적으로 최소 (Minimum required gradle version) 을 확인할 수 있다. 또 조심해야 할 것은,

"그 버전 이상이면 전부 잘 될 것이다" 라는 이야기는 아니다.
그러니 최소 버전 부터 천천히 버전을 올려가면서 문제 없는 버전이 어느 대역인지 맞춰보는 것이 시간을 아끼는 일이다.


이제 최적화 그리고 난독화, 지옥의 시간이다.


난독화는 읽기 어렵게 소스코드를 바꿔나가는 행위다.
최적화는 소스코드의 기능을 유지하면서 좀 더 빠른 시간안에, 그리고 좀 더 적은 자원(메모리, CPU 등) 을 소모하도록 내용을 바꾸는 행위다.

안드로이드는 Pro-Guard 와 R8 을 이용하여 난독화를 진행한다.

간단하게 난독화의 예는 다음과 같다.
프로젝트에서 참조된 함수들의 이름을 별칭으로 간단하게 변경하는 것이다.

ex) kotlin.time.Duration -> X.a # ( 원래 이름 -> 치환된 별칭 )
kotlin.time.Duration(1) == X.a(1)

위와 같이 함수나 클래스, 인터페이스들을 겹치지 않게 치환환다.
그러면 APK 로 배포된 내용안에 들어 있는 class 등의 자바 소스파일을 text 파일로 변환 했을 때, X.a 라는 이름만 남아서 알아보기 힘들게 되는 것이다.

치환하기 이전의 내용은 mapping.txt 를 추가로 생산하여 제공하기 때문에 추적이 가능하다. 물론 일반 사용자들에게 제공되는 것이 아니다.

일반적인 경로 : /app/build/outputs/mapping/{build-type}/mapping.txt

mapping.txt 의 Java 함수 치환 내용, 디버깅을 위해 필요하다

위의 난독화 옵션을 켜준다.


# File : app/build.gradle.kts

android {
    buildTypes {
        getByName("release"){
            isMinifyEnabled = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro",
            )
        }
    }
}

Debug 모드가 아닌 APK 파일을 얻어서 파일을 폰에 직접 설치 후에 실행하면 결과를 확인해 볼 수 있다.

NullPointerException 이다. 원인이 아니라 결과라는 것에 집중해야 한다.
FATAL EXCEPTION: main
Process: com.yourapp, PID: 31893
java.lang.NullPointerException
	at N1.h.d(ConnectionPool.kt:21)
	at b2.j.run(R8$$SyntheticClass:593)
	at android.os.Handler.handleCallback(Handler.java:1000)
	at android.os.Handler.dispatchMessage(Handler.java:104)
	at android.os.Looper.loopOnce(Looper.java:242)
	at android.os.Looper.loop(Looper.java:362)
	at android.app.ActivityThread.main(ActivityThread.java:8448)
	at java.lang.reflect.Method.invoke(Native Method)
	at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:552)
	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:992)

여기서 N1.h.d, b2.j.run 이런 치환된 함수를 확인할 수 있다.
mapping.txt 를 이용해서 N1.h.d 를 확인해보자.

아래에서 언뜻 확인해보면,
N1.h -> okhttp3.ConnectionPool class 를 뜻한다.
N1.h.d -> 여러개의 함수가 보인다.

  • okhttp3 library 의 onResponse(...)
  • 여기서 com.yourapp package name 으로 된 작성한 코드의 함수도 보인다
    com.yourapp.retrofit.AppVersionCheck.checkAppType(com.yourapp.retrofit.data.VersionResponse,java.lang.String):80:80 -> d
okhttp3.Connection -> N1.g:
# {"id":"sourceFile","fileName":"Connection.kt"}
okhttp3.ConnectionPool -> N1.h:
# {"id":"sourceFile","fileName":"ConnectionPool.kt"}
    okhttp3.internal.connection.RealConnectionPool delegate -> a
      # {"id":"com.android.tools.r8.residualsignature","signature":"Ljava/lang/Object;"}
    # ...
    6:9:void 6:9:void com.yourapp.retrofit.AppVersionCheck$versionAsync$1.onResponse(retrofit2.Call,retrofit2.Response):64:64 -> d
      # {"id":"com.android.tools.r8.residualsignature","signature":"(Lb2/d;Lb2/D;)V"}
    10:17:void com.yourapp.retrofit.AppVersionCheck$versionAsync$1.onResponse(retrofit2.Call,retrofit2.Response):67:67 -> d
    18:20:void com.yourapp.retrofit.AppVersionCheck$versionAsync$1.onResponse(retrofit2.Call,retrofit2.Response):65:65 -> d
    21:21:int com.yourapp.retrofit.AppVersionCheck.checkAppType(com.yourapp.retrofit.data.VersionResponse,java.lang.String):80:80 -> d
    21:21:int com.yourapp.retrofit.AppVersionCheck.access$checkAppType(com.yourapp.retrofit.AppVersionCheck,com.yourapp.retrofit.data.VersionResponse,java.lang.String):21 -> d
    21:21:void com.yourapp.retrofit.AppVersionCheck$versionAsync$1.onResponse(retrofit2.Call,retrofit2.Response):65 -> d
    

결국 우리가 작성한 소스 코드의 함수를 찾지 못해서 NullPointerException 을 낸 것으로 보인다.

왜 그럴까?

일단 해당 함수의 특징은..

kotlin 으로 작성되었고, 찾지 못한 function


1. 코틀린으로 작성되어 있다.
2. private fun 이다.
3. retrofit (2가 아닌) 버전의 class 를 참조하고 있다.

일단은 답이 없어 보이므로 AI 에 물어본다면 어떤 답변을 얻을 것인가?

Copilot 은 R8 과 Kotlin 에서 발생할 수 있는 이 현상에 대해서 대답은 이렇게 했다.

A NullPointerException in Kotlin when using R8 (a code shrinker and obfuscator for Android) can occur due to various reasons, often related to incorrect optimizations, missing rules, or issues with reflection. Here are some potential causes and solutions:

1. Missing Proguard Rules

R8 uses Proguard rules to determine what to keep or optimize. If certain classes, methods, or fields are removed or obfuscated incorrectly, it can lead to runtime errors like NullPointerException.

Solution:

Add the necessary Proguard rules for the libraries or code causing the issue. For example:

코드 복사# Keep all classes and methods used by reflection
-keep class com.example.yourpackage.** { *; }

# Keep annotations
-keepattributes *Annotation*

# Keep specific library rules (e.g., Gson, Retrofit)
-keep class com.google.gson.** { *; }
-keep class retrofit2.** { *; }

2. Reflection or Dynamic Code Issues

If your app uses reflection or dynamic code (e.g., Gson, Retrofit, or custom annotations), R8 might strip out required classes or methods.

Solution:

Ensure that all classes used dynamically are preserved. For example:

코드 복사# Keep classes used by Gson serialization
-keep class com.example.models.** { *; }

# Keep Retrofit models and interfaces
-keep interface retrofit2.** { *; }
-keep class retrofit2.** { *; }

3. Debugging the Issue

If the cause of the NullPointerException is unclear, you can debug it by disabling R8 temporarily or enabling more detailed logs.

Solution:

  • Disable R8: Add the following to your gradle.properties file:코드 복사android.enableR8=false
    This will revert to Proguard for testing purposes.
  • Enable R8 Debugging Logs: Add this to your gradle.properties:코드 복사android.enableR8.fullMode=true
    This provides more detailed logs during the build process.

4. Check Kotlin-Specific Issues

Kotlin's null-safety features can sometimes conflict with R8 optimizations, especially if nullable types are incorrectly inferred or stripped.

Solution:

  • Ensure all nullable types are explicitly declared.
  • Use the @Keep annotation on critical classes or methods to prevent R8 from removing them:코드 복사@Keep
    data class MyDataClass(val id: String?)

5. Update Dependencies

Outdated libraries or plugins might not be fully compatible with the latest R8 optimizations.

Solution:

  • Update your Android Gradle Plugin and dependencies to the latest versions.
  • Ensure your Kotlin version is up-to-date.

If the issue persists, consider sharing the stack trace or more details about the context of the error for further assistance. You're doing great tackling this!

결국 답변은 아래와 같이 요약될 수 있다.

  • 못 찾는 너의 함수가 난독화 되지 않도록 해봐
    • 그럴거면 난독화를 하지 않지.
  • 코틀린 작성할 때, 조심했니?
    • 애초에 잘했어야지. 난독화를 켜고 개발을 시작했어야지.
  • 난독화 꺼 놓고 다시 해봐
    • 해봤어. 너 때문인거 같아.
  • 업데이트 해봐
    • 해결도 되긴 하겠지만, 경험상 "컴퓨터를 껐다 켜보세요" 보다 나은 방법은 아닌거 같아.
    • 윈도우의 최신 업데이트를 해보세요 라는 악몽이 떠오른다.
proguard-rules.pro 내용을 아무리 조작해도 결국에는 모든 패키지를 난독화하지 말았어야 했다.
전부 난독화를 제외 할거면, 뭐 하러 이 짓을 하는 거냐.

물론 인터넷에 찾아보면, retrofit2 , gson 등의 자체 패키지의 pro 룰을 통해 난독화를 제외해야 하는 룰들을 모두 넣어주면 될거라는 희망회로를 돌리지 마라.

라이브러리들 자체의 문제들이 아닌 "나의 코틀린"을 찾지 못하는 문제인 것이다.

💡
물론, 라이브러리들을 하나하나 추가할 때 마다
난독화에 의한 "이상 현상"을 조심해야 하는 것은 물론이다.

웃을 일이 아니다.

모든 라이브러리다.
1~2개가 아니라 수십 개에서 수백 개가 기본이다.

전부 실행해봐야 한다.

그렇다고 소스를 난독화 해보겠다고 전부 수정한다는 것에는 수지가 맞지 않다.

💡
이상한 점은
이와 비슷한 기능의 자바 소스코드에서는
이런 일들이 일어나지 않았다는 것이다.

물론 아래와 같이 R8 의 fullMode 를 Disable 해보면 될까라는 희망도 버리도록 하자.

# File : gradle.properties
android.enableR8.fullMode=false

일단 우리는 우리의 인건비를 소중히 하기 위해서, 난독화를 꺼야 했다.


R8 은 코틀린과의 관계가 정말 좋지 않은 것일까?

우린 정답을 모르기 때문에 추측하고 의심만 해 볼 수 있다.

안드로이드에서 제공하는 가장 기초적인 문서를 가지고 추정을 진행해보다가 안 되면 그냥 집에 가자.

Android 빌드 개요 > 도구 및 라이브러리 상호 종속 항목 > 빌드의 관계를 보면, Kotlin과의 Android 의 관계를 확인해 볼 수 있다.

KSP, Compose, Kotlin Compiler Plugin, Kotlin Compiler 이것들과 R8 의 음모인가?

R8 에 관련된 내용은 Enable app optimization 문서에 맛보기로만 기술되어 있어서 별다른 내용을 획득하지 못했다.

Kotlin Language 의 어떤 동작이 Java 와 달라 R8 이 문제를 일으키는지는 아직 미궁 속에 있다.