[Android][Kotlin] ProGuard/R8

D.O·2023년 10월 21일
3

R8 공부 계기

이전 글에서 Debug 모드에서 앱 테스트를 하여 버벅임 현상을 겪으면서 대충 공부하고 넘어갔던 R8에 대해서 좀 자세히 공부하고 넘어가기로 했다.

앱을 최대한 작게 만들려면 출시 빌드에 축소를 사용 설정하여 미사용 코드와 리소스를 삭제해야한다.

R8은 Google에서 개발한 Android 앱의 난독화, 축소, 및 최적화 도구이다

R8 이전에는 ProGuard를 사용하였다. 즉 R8은 Proguard의 후속 버전인 Android의 새로운 코드 축소 및 최적화 도구이다.

ProGuard?

ProGuard는 앱을 빌드할 때, Java 컴파일러로 생성된 Java 바이트코드를 DEX 변환 도구인 'dx' 또는 'D8'을 사용하여 DEX로 변환하였다

여기서 말하는 Dex란 무엇일까?

DEX

DEX(Dalvik Executable)는 Android 운영 체제에서 실행되는 바이트 코드 형식을 말한다.

Android Kotlin 코드가 어떻게 실행 Flow를 알아보자

  1. Kotlin 소스 코드는 Kotlin 컴파일러(kotlinc)에 의해 JVM 바이트 코드로 컴파일

    Kotlin이 Java와 호환될 수 있게 설계되었기 때문에, 컴파일 결과로 나오는 바이트 코드는 Java 바이트 코드와 동일한 형식

  2. proguard 또는 R8과 같은 도구들은 JVM 바이트 코드를 최적화

    이 부분에서 Proguard를 사용하나 R8을 사용하나에서 성능차이가 상당하다고 한다 이 부분에서는 아래에서 살펴보자

  3. 최적화된 JVM 바이트 코드는 dex 도구를 통해 DEX 형식으로 변환

    이 DEX 형식은 Android 운영 체제에서 실행

  4. DEX 파일 Runtime Execution (런타임 실행)

65K 메서드 제한 문제

DEX 파일은 최대 65,536개의 메서드만 포함할 수 있습니다. 이 제한은 DEX 파일의 구조 및 포맷에서 비롯된 것으로, 16비트로 메서드를 참조하기 때문에 생기는 제한입니다.

Android 앱의 크기가 커짐에 따라 사용되는 메서드의 수도 증가하게 되었습니다. 특히 여러 외부 라이브러리를 사용할 때, 65,536개의 메서드 제한에 빠르게 도달할 수 있습니다. 이 문제는 "65K 문제" 또는 "65K 메서드 제한 문제"라고 불립니다.

멀티 DEX를 사용하면, 앱 내에서 여러 DEX 파일을 포함하여 이 제한을 우회할 수 있습니다. 그러나 멀티 DEX 사용 시, 앱 시작 시 여러 DEX 파일을 로드해야 하므로 시작 시간이 늘어날 수 있습니다.

과거에는 이 문제가 큰 불편사항이었지만, Android 5.0 이후 도입된 ART (Android Runtime) 덕분에 상황이 크게 개선되었습니다. ART는 Ahead-of-Time (AOT) 컴파일을 지원하면서 앱 설치 시 바이트코드를 기계어로 미리 번역하는 등의 향상된 처리 방식 덕분에 멀티 DEX로 인한 성능 저하가 줄어들었습니다. 그렇지만, 멀티 DEX가 앱 시작 성능과 앱 크기에 여전히 영향을 줄 수 있습니다.

따라서 불필요한 라이브러리를 제거하거나 ProGuard/R8과 같은 도구를 사용하여 코드를 최적화가 필요하다.

Proguar와 R8의 차이점을 구체적으로 설명하자면

ProGuard Flow

  1. Java 또는 Kotlin 코드가 JVM 바이트코드로 컴파일
  2. ProGuard는 이 바이트코드를 최적화하고 난독화
  3. 별도의 도구 (dx 또는 D8)를 사용하여 JVM 바이트코드를 Android의 실행 형식인 DEX로 변환

R8 Flow

  1. Java 또는 Kotlin 코드가 JVM 바이트코드로 컴파일
  2. R8을 통해 JVM 바이트코드를 최적화하면서 동시에 DEX로 변환하는 두 단계를 한 번에 수행

R8은 여러 단계를 한 번에 처리한다는 것은 잘 이해되지 않을 수 있지만, 중요한 점은 컴파일 과정에서 일반적으로 사용되는 '중간 표현' 단계를 생략함으로써 속도 측면에서 효율성을 높인다는 것

이 부분 사실 잘 이해가 안간다. 컴파일에 대한 공부를 좀 해야할 것 같다.

또한 R8은 경량화 성능이 더 좋은데 그 이유는 R8은 Android 런타임과 프레임워크에 대해 맞춤 기반으로 설계되었기 때문에, 플랫폼 특화된 최적화를 수행하기 때문이라고 한다.

사실 원리는 공부해도 잘 모르겠는데 R8이 어쨌든 Android에서는 Proguard에 비해 어떤 부분에서 보나 우세하다고 이해하였다.

필요하면 더 공부해야겠다.

R8

그래서 이러저러한 이유로 더 좋은 R8을 Android Gradle 플러그인 3.4.0 이상에서 기본적으로 사용되며, 그 주요 특성은 아래와 같다.

  1. 난독화 (Obfuscation): 앱의 코드를 더 어렵게 만들어 역공학을 통한 코드 해석을 어렵게 합니다. 클래스, 메서드, 변수의 이름을 짧고 무의미한 것으로 변경하여 코드의 가독성을 줄입니다.
  2. 코드 축소 (Shrinking): 사용되지 않는 코드를 자동으로 제거하여 앱의 크기를 줄입니다. 예를 들어, 라이브러리를 포함시켰지만 실제로 앱에서 사용되지 않는 클래스나 메서드가 있다면, 이들은 제거됩니다.
  3. 최적화 (Optimization): 코드를 재구조화하여 실행 시간을 줄이고 앱의 성능을 향상시킵니다.
  4. DEX 바이트코드 생성: 바이트코드를 직접 Android DEX 형식으로 변환합니다. 이는 R8이 더 빠른 컴파일 타임을 제공하며, 중간 단계를 건너뛸 수 있게 합니다.
  5. ProGuard 규칙 지원: R8은 ProGuard와 호환되는 규칙을 사용하여, 개발자가 기존의 ProGuard 설정 파일을 그대로 사용할 수 있도록 합니다.

즉 R8을 사용함으로써, 리버스 엔지니어링에 대한 보호를 강화하고, 앱의 전체 크기를 줄이며, 앱의 성능을 향상시킬 수 있다는 것이다.

난독화 ,코드 축소, 최적화

난독화(Obfuscation)코드 축소 (Shrinking), 최적화 (Optimization)에 대해 간단히 알아보자

**1. 난독화 (Obfuscation)**

코드의 가독성을 감소시켜 역공학으로부터의 보호를 강화

긴 클래스 이름을 짧게 바꾸기 때문에 DEX 파일의 용량이 줄어드는 효과

예시)

원본 코드:

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setupSplashScreen()
        setupUiStateObserver()
        enableEdgeToEdge()
        setupContent()
    }

private fun setupSplashScreen() {
        val splashScreen = installSplashScreen()
        splashScreen.setKeepOnScreenCondition {
            when (uiState) {
                MainActivityUiState.Loading -> true
                is MainActivityUiState.Success -> false
            }
        }
    }

...

난독화 후: *예시로 한 것이다 실제로는 다르게 난독화될 수 있다.

override fun a(b: Bundle?) {
    super.a(b)
    b()
    c()
    d()
    e()
}

private fun b() {
    val f = g()
    f.h {
        when (i) {
            j.k -> true
            is j.l -> false
        }
    }
}

2. 코드 축소 (Shrinking)

사용되지 않는 코드를 제거하여 앱의 크기를 줄인다.

예시)

앱에서 'Glide' 이미지 로딩 라이브러리를 사용하는데, GIF 이미지 로딩 기능은 사용하지 않는다면, 이 기능과 관련된 코드는 제거

'Glide' 라이브러리에는 다양한 이미지 포맷을 지원하는 모듈들이 포함되어 있지만, 앱에서 사용하지 않는 특정 모듈(예: GIF)에 대한 코드는 코드 축소 과정에서 제거

3. 최적화 (Optimization)

코드의 실행 속도를 빠르게 하기 위해 코드 구조를 재구성
Inline 처리 (Inlining)
기타 등등 최적...

예시)

메서드 호출의 오버헤드를 줄이기 위해 작은 메서드의 본문을 호출 위치에 직접 삽입

원본 코드 :

fun add(a: Int, b: Int): Int {
    return a + b
}

fun main() {
    val result = add(5, 3)
    print(result)
}

최적화 후 :

fun main() {
    val result = 5 + 3
    print(result)
}

이 외에도 많은 최적화가 진행되는 것 같은데 자세하게 궁금하다면 따로 공부하자
이러한 최적화 기법이 적용되면 누가봐도 코드 분석이 어려워 보인다.
그래서 일반적으로 디버그 모드에서는 이러한 최적화를 진행하지 않는다.

적용

Mineme프로젝트에 Debug APK와 Relase APK를 비교해보았다.

Build > Analyze APK… 선택

이전에는 debug버전에서는 dex파일이 20개가 넘었는데 release버전에서는 dex 파일이 확실히 줄었다.

R8에 의해 최적화된 dex 파일을 확인하면 클래스명, 메서드명이 변형된 것을 알 수 있습니다. 심지어 패키지명조차도 인식하기 어려울 정도로 변경되었습니다.

두 버전을 비교했을 때 최종적으로 16.8M 사이즈의 크기가 줄었다.

또한 앱의 성능은 크게 향상되었으며, 이는 프로파일링 도구를 사용하지 않고도 눈으로 확인할 수 있을 정도

이 글에 보였듯 HWUI 렌더링 프로파일을 사용하여 실시간 GPU 렌더링 성능을 그래프로 비교했을 때, Release 버전이 확연히 우수한 성능을 보였다.

물론, Debug 모드에서만 활성화되는 로깅과 관련된 오버헤드도 있겠지만, 이러한 성능 향상은 최적화 작업의 결과로 보인다.

R8 적용시 주의할 점!!

제가 이전에 Benchmark 적용편 Bug 부분에서 언급했듯이 R8 또는 ProGuard 같은 난독화 도구를 사용할 때 Protobuf와 같은 특정 라이브러리나 프레임워크에서 사용되는 클래스와 필드 이름을 변경하면 런타임에서 예상치 못한 동작이나 버그가 발생할 수 있다고 하였다.

Protobuf는 런타임에서 특정 필드 이름에 의존하기 때문에 난독화 도구가 이를 변경하면 메시지 직렬화 및 역직렬화 과정에서 오류가 발생할 수 있습니다.

따라서 이러한 문제를 예방하기 위해 R8 또는 ProGuard 설정에서 특정 클래스나 필드를 제외하는 것이 필요하다

Retrofit에 대해서 많은 사람들이 비슷한 문제를 겪었는데

Retrofit은 HTTP API를 자바 인터페이스로 변환하는데 사용되는 라이브러리인데 이때 해당 인터페이스는 Retrofit에 의해 동적으로 구현되며, API 호출은 해당 인터페이스의 메서드로 표현

Retrofit의 인터페이스나 클래스가 난독화되면, 런타임에서 해당 인터페이스나 클래스를 찾거나 사용하는 데 문제가 발생할 수 있다.
예를 들어 설명하면

다음과 같은 Retrofit 인터페이스가 있다고 했을 때

난독화 도구가 이 인터페이스의 이름이나 메서드의 이름을 변경한다면, Retrofit은 런타임에서 해당 인터페이스getDetailStory 메서드를 찾을 수 없게 됩니다.

따라서 proguard-rules에 -keep 등을 사용하여 Retrofit 인터페이스와 클래스를 보존함으로써 이러한 문제를 방지할 수 있다.

마무리

ProGuard와 R8등 최적화 도구의 개념을 알아보았고, R8의 핵심 특성 및 주의사항을 살펴보았다

이런 최적화 작업은 사용자에게 앱을 배포하기 전에 반드시 수행되어야 하는 핵심 과정이라고 생각이된다.

개발 단계에서는 디버깅 목적으로 이러한 최적화를 일반적으로 생략한다

이제는 실제 기기에서 디버그 APK 성능을 테스트하는 이러 실수는 안할 것 같다. 😅

profile
Android Developer

0개의 댓글