LINE Android 애플리케이션 빌드에 R8 컴파일러 적용하기

시작하기 전에

안녕하세요. LINE+에서 LINE Android 앱을 개발하고 있는 차영호입니다. 이번 글에서는 LINE Android 앱에 Google의 새로운 Android 용 DEX 컴파일러인 R8을 적용하는 과정을 뒤돌아보려고 합니다.

참고로 저는 올해 초에 Droid Knight라는 행사에서 LINE Android 앱 패키지에서 발생했던 리소스 누락 현상의 원인 및 대처 방법에 대해 발표했습니다. 이 발표에서 공유했던 문제의 근본적인 원인은 Android 빌드 도구에 포함된 ZIP 파일 처리 라이브러리의 문제였습니다. 하지만 Google 측에서 이 문제를 즉시 해결할 수는 없다고 답변했습니다. 그래서 대안을 찾다 보니, ProGuard 최적화 도구 대신 R8 컴파일러를 도입하면 문제를 해결할 수 있다는 것을 알게 되어서 R8이 정식 출시되자마자 적용을 고려하게 되었습니다. 

9월에 열렸던 GDG Android Build Talk에서 R8의 여러 가지 호환성 이슈와 관련된 기술적인 문제에 대해서 공유했었는데요. 이번 글에서는 단편적인 기술적 문제보다는 R8을 도입하면서 어떠한 결정과 과정을 거쳤는지 뒤돌아 보는데 중점을 두려고 합니다. 

 

R8을 도입하기로 결정한 배경

아래 그림은 Android Studio 3.3 이전과 3.4 이후의 Android 앱을 빌드할 때 DEX(Dalvik Executable)가 생성되는 과정의 변경점을 나타냅니다.

용어설명
JAR(Java ARchive)Java 프로그램을 배포하는 형식입니다. Java 바이트 코드(bytecode)는 클래스 단위로 JAR 파일에 포함됩니다.
DEX(Dalvik Executable)Android에서 사용하는 Dalvik 바이트 코드를 저장하는 형식입니다. JAR와는 다르게 바이트 코드가 클래스 단위로 포함되지 않습니다. 포함할 수 있는 최대 메서드 개수(65,535)를 넘지 않도록 저장합니다.
ProGuard Java 프로그래밍 환경에서 Java 바이트 코드를 분석해, 사용하지 않는 부분을 제거하는 것과 같은 여러 가지 최적화 방식을 적용시켜주는 도구입니다. 또한 역공학으로 앱의 구동 방법을 파악하지 못하도록 난독화 작업도 수행해 줍니다. 올해(2019년) 초까지 Android 앱을 빌드할 때 사용했습니다.
ProGuard ruleProGuard가 Java 바이트 코드를 최적화할 때 참고하는 여러 가지 옵션이 포함된 파일입니다.
D8Java 바이트 코드를 DEX로 변환하는 컴파일러입니다.
R8D8에 ProGuard 기능을 추가했다고 생각하면 됩니다. 예전에는 ‘Java 컴파일러 → ProGuard → D8’로 이어지는 과정을 통해 DEX 파일을 생성했는데요. 올해 초부터는 ‘Java 컴파일러 → R8’의 과정으로 DEX 파일을 생성하기 때문에 빌드 단계가 줄었습니다.

앞에서 잠시 언급했습니다만, LINE Android는 작년 말에 앱을 릴리스 모드로 빌드할 때 앱 내부에 포함되어야 할 여러 가지 리소스(각종 프로퍼티 파일, 이미지 파일, 레이아웃 리소스 등)가 누락되는 현상이 나타나 앱을 출시할 수 없었던 상황이 발생했습니다. 간략하게 원인을 소개하자면, Android 앱을 빌드할 때 사용하는 Java 최적화 도구인 ProGuard의 결과물(Java class 및 여러 앱 리소스 등)에 65,535개 이상의 파일이 포함될 때 문제가 발생했습니다. 이는 Android Gradle Plugin에 포함된 ZIP 파일 처리 라이브러리인 apkzlib에서 리소스 항목을 처리하는 도중 ‘entry count overflow’가 발생하는 문제였고, 그 결과로 리소스 파일들이 최종 결과물인 apk 파일에 누락되는 현상이 나타났습니다. 다행히 원인을 밝혀냈고, 여러 가지 임시방편을 마련해서 당장은 문제없이 앱을 출시할 수 있었습니다.

하지만 장기적인 관점에서 임시 대응책으로 만족할 게 아니라 근본적으로 문제를 해결할 필요가 있었습니다. 저희는 여기서 문제의 원인이 65,535개를 넘는 파일의 개수라는 것에 주목했습니다. ProGuard에선 컴파일 결과로 수많은 Java class 파일이 생성되지만, R8에선 수십 개 가량의 DEX 바이트 코드 파일만 생성되므로 위 문제를 간단히 해결할 수 있습니다.

원래는 2019년 1월에 출시된 Android Studio 3.3에서 R8이 기본 컴파일러로 도입될 예정이었습니다만, 연기되어 2019년 4월에 출시된 Android Studio 3.4부터 R8이 기본 컴파일러로 도입되었습니다. 저희는 1월부터 R8을 도입하기 위해 작업을 시작했었는데요. 덕분에 몇 달간의 시간을 더 활용할 수 있는 기회를 얻었습니다.

 

R8을 적용하면서 겪은 문제점과 해결 방법

가장 먼저 R8을 적용했을 때 LINE Android에서 어떤 문제가 생기는지 확인해 보았습니다. Android Gradle Plugin 3.4 이전 버전을 사용할 때 R8을 사용하는 방법은 다음과 같습니다. 먼저 Android 프로젝트의 최상위 디렉터리에 gradle.properties 파일을 생성한 뒤, 아래 코드를 한 줄 추가하면, ProGuard와 D8을 이용하는 대신 R8을 이용해 DEX가 생성됩니다.

android.enableR8=true

앱을 컴파일해서 결과물인 apk가 생성되는 과정에서는 별다른 문제가 없었습니다. 하지만 앱을 휴대폰에 설치한 후 확인해 보니, 앱이 구동조차 되지 않았습니다. 구동이 되지 않는 원인을 파악하기 위해 R8이 생성한 DEX 파일들을 분석해야 했습니다. Android Studio에서는 APK Analyzer라는 도구를 제공하는데요. APK Analyzer에는 DEX 파일의 바이트 코드를 디컴파일(decompile)해주는 기능이 있습니다. 하지만, 마우스를 이용해 일일이 클래스와 필드를 찾아서 확인해야 하기 때문에 문제 원인을 파악하는데 시간이 너무 오래 걸릴 것 같았습니다. 다행히 Android SDK에 포함된 빌드 도구에서 dexdump라고 하는 커맨드 라인(command line)용 DEX 디컴파일러(decompiler)를 제공하고 있습니다. 또한 다양한 후처리를 지원하기 위해, 프로그래밍으로 처리하기 쉬운 XML 형식으로 DEX 디컴파일 결과를 출력해주는 기능도 제공하고 있습니다.

$ $ANDROID_SDK_ROOT/build-tools/29.0.3/dexdump      
dexdump: no file specified
Copyright (C) 2007 The Android Open Source Project
 
dexdump: [-c] [-d] [-f] [-h] [-i] [-l layout] [-m] [-t tempfile] dexfile...
 
 -c : verify checksum and exit
 -d : disassemble code sections
 -f : display summary information from file header
 -h : display file header details
 -i : ignore checksum failures
 -l : output layout, either 'plain' or 'xml'
 -m : dump register maps (and nothing else)
 -t : temp file name (defaults to /sdcard/dex-temp-*)
 
 
$ $ANDROID_SDK_ROOT/build-tools/29.0.1/dexdump -l xml app/build/outputs/apk/debug/app-debug.apk | xmllint --pretty 1 -
<?xml version="1.0"?>
<api>
  <package name="androidx.annotation">
    <class name="Keep" extends="java.lang.Object" abstract="true" static="false" final="false" visibility="public">
      <implements name="java.lang.annotation.Annotation"></implements>
    </class>
  </package>
  <package name="androidx.appcompat">
    <class name="R$attr" extends="java.lang.Object" abstract="false" static="false" final="true" visibility="public">
      <field name="actionBarDivider" type="int" transient="false" volatile="false" static="true" final="true" visibility="public"> </field>
      <field name="actionBarItemBackground" type="int" transient="false" volatile="false" static="true" final="true" visibility="public"> </field>
....

위와 같이 디컴파일 결과를 XML 형식으로 저장한 다음, 차이를 비교해주는 diff 도구를 이용하니 손쉽게 전후로 변경된 항목을 추적할 수 있었습니다. 확인해 본 결과 많은 메서드와 필드의 접근 제어자(access modifier)나 static, final 여부가 Java나 Kotlin 코드와 다르게 나오는 것을 확인할 수 있었습니다. 이러한 차이가 어떻게 발생했는지 확인하기 위해 R8에서 ProGuard 룰에 정의된 최적화 옵션을 어떻게 처리하고 있는지 살펴보았습니다.

아래 표는 R8(1.4 기준)에서 해석하는 ProGuard 룰의 최적화 옵션 목록입니다.

optionsupportedwarningignore
assumenosideeffectsY

dontobfuscateY

dontpreverify

Y
dontskipnonpubliclibraryclasses

Y
dontusemixedcaseclassnames

Y
dontwarnY

keepY

keepattributesY

keepclasseswithmembernamesY

keepclasseswithmembersY

keepclassmembernamesY

keepclassmembersY

keepnamesY

libraryjarsY

optimizationpasses

Y
optimizations

Y
renamesourcefileattributeY

repackageclassesY

verboseY

위 표와 같이, R8이 어떤 ProGuard 룰은 처리하고 어떤 룰은 무시하고 있다는 것을 알게 되었습니다. 처음에는 Google에서 R8을 제작할 때 ProGuard 룰의 처리 여부를 어떤 기준으로 결정했을지 짐작해보다가, Android Studio에서 새로 생성한 앱을 위해서 자동 생성한 ProGuard 룰과 비교해 보았는데, 여기서 중요한 차이점을 발견하였습니다. LINE Android 앱은 Android Gradle Plugin에서 제공하는 기본 ProGuard 룰(참고)을 사용하지 않았고, Eclipse ADT에서 제공하던 과거의 ProGuard 룰을 기반(참고)으로 문제가 생길 때마다 그때그때 새로운 룰을 추가해왔습니다. 즉, Eclipse ADT 개발 환경에서 Android Studio로 개발 환경을 이전할 때(참고) ProGuard 룰 마이그레이션에 대해서 충분히 고려하지 않았던 것입니다. 따라서 R8에서 이용하려는 여러 가지 최적화 옵션이 Google이 설계한 대로 제대로 적용되지 않고 있었습니다.

그래서 Android Gradle Plugin에서 제공하는 기본 ProGuard 룰을 적용하고(참고), 기존 룰 중에 불필요하거나 충돌이 발생하는 룰을 제거하는 ‘클린업(clean up)’ 작업을 수행했습니다. 이렇게 클린업을 수행한 후 dexdump를 이용해 분석해 본 결과, R8 도입 후 발견되었던 여러 가지 차이점이 대폭 줄어 들었고, 앱도 정상적으로 구동되는 것을 확인할 수 있었습니다. 더불어 기존과 비교해 앱을 구성하고 있는 클래스 개수가 눈에 띄게 줄어들었고(최대 2천 개 가량 감소), 앱의 크기도 5MB 정도 감소하였으며, 앱 빌드 시간도 15분가량 단축되었습니다. 이후에 R8로 변경한 것에 따른 이상 동작은 없는지 1달 가량 회귀(regression) 테스트를 수행하면서 일부 ProGuard와 R8의 호환성 문제를 발견하고 추가 수정을 진행했습니다(이 부분은 GDG Android Build Talk에서 발표하였던 세션에 포함되어 있습니다).

그리고 마침내 6월부터 R8이 적용된 LINE Android 앱이 성공적으로 배포되었습니다.

 

마치며

처음 R8 컴파일러를 도입한 후 앱이 구동조차 되지 않을 때는 매우 당황스러웠습니다. 컴파일할 때는 문제가 없었지만, 앱이 구동될 때 전혀 의도하지 않은 부분에서 크래시가 발생했고 그 대책을 마련하는 것은 거의 불가능해 보였습니다. 하지만 문제가 발생할 수 있는 원인들을 최대한 작은 단위로 분류하고 나눈 뒤, 관련 도구의 소스를 분석하면서 Google의 Android 빌드 도구가 어떠한 것을 목표로 삼았는지 유추한 결과, 중점적으로 살펴보아야 할 부분의 범위를 좁힐 수 있었습니다. 또한 LINE Android와 같이 시작한 지 어느덧 8년이 넘은 프로젝트에서는 레거시에 기반한 여러 가지 부분이 새로운 빌드 환경을 도입하는 데 장벽이 될 수 있다는 교훈도 얻을 수 있었습니다.

그래서 LINE Android 앱에서 사용하고 있는 각종 빌드 도구와 라이브러리를 되도록 최신 버전으로 미리미리 도입하여 Android 앱 개발 환경과 에코 시스템을 항상 최신 상태로 준비할 수 있도록 다음과 같은 노력을 진행하고 있습니다.

  • Android Gradle Plugin의 알파와 베타 버전을 미리 적용해 보고, 문제가 있는 경우 AOSP(Android opensource project) 이슈 트래커에 미리 공유하기
  • 앱에서 사용하고 있는 각종 오픈소스 라이브러리는 되도록이면 최신 버전을 적용하고, 최신 버전을 적용하지 못하는 이유가 있으면 그 이유를 추적하며 관리하기

이와 같은 노력으로 앞으로 더욱 안정적이고 효율적인 LINE Android 개발 환경을 갖추어 나가려고 합니다. 긴 글 읽어주셔서 감사합니다.

Related Post