안드로이드, 어디까지 아세요 [1] - Build process

APK bundling, Proguard, R8, D8, Desugaring …

MJ Studio
MJ Studio

--

안드로이드, 어디까지 아세요 시리즈는 안드로이드 개발 과정에서 특수한 요구 사항, 고급 기술이 필요한 상황일 때를 대비하거나 안드로이드란 기술에 대한 전반적인 이해를 목적으로 작성되는 포스팅들입니다.

안드로이드 앱 개발자라면 Android Studio를 사용하여 안드로이드 앱을 빌드하고 디바이스에 설치를 하여 실행하거나 스토어에 출시를 해 본 경험이 있을 것입니다.

Android Studio가 채택한 Gradle이라는 툴체인 프레임워크는 빠르게 진화하는 Android Gradle Plugin(AGP)를 사용하여 빌드 과정을 내부적으로 처리를 해주고 개발자들에게 깊은 수준의 빌드 과정 이해를 강요하지 않습니다.

// Android Gradle Plugin in build.gradle(project level)
classpath(
"com.android.tools.build:gradle:4.1.0")

그러나 개발자가 이 과정을 한꺼풀만 벗겨 조금만 더 이해를 한다면, build.gradle 의 설정들이 무엇을 의미하는 것인지에 대한 이해, 우리의 APK(AAB)를 더 가볍고 견고하게 만드는 방법, 한 번에 여러개의 앱 flavor(유료 버전 or 무료 버전)를 어떻게 빌드할 수 있는 방법들을 터득할 수 있을 것입니다.

Build process overview

다음 사진은 안드로이드 빌드과정을 간략화한 그래프입니다.

https://developer.android.com/studio/build

빌드가 되기 전엔 Android Studio에서 우리가 직접 작성한 소스 코드(Java, Kotlin), 리소스 파일들(layout, drawable), 그리고 AIDL(Service IPC definition)파일들 3자 라이브러리들의 상기한 소스코드, 리소스들과 함께 있는 상황입니다.

상기한 소스코드나 리소스들이 javac(자바 컴파일러, 자바 바이트 코드(.class) 를 만들어 줌), kotlinc(코틀린 컴파일러), AAPT2(Android Asset Packging Tool), R8, D8 혹은 Proguard 같은 컴파일러들에 의해 변환되어 최종적으로 DEX(.dex) 파일로 변환이 됩니다. 각자가 하는 일은 다음 섹션에서부터 자세히 알아볼 것입니다.

이 과정을 좀더 세분화한 그래프는 다음과 같습니다.

  • 리소스 ✅: AAPT(Android Asset Packging Tool)에 의해 처리된 리소스 파일들은 해당 리소스 파일을 참조할 수 있는 ID가 부여되어 R.java라는 클래스안에 담기게 됩니다. 이것이 우리가 안드로이드 개발을 할때 소스 코드에서 R이라는 클래스에서 여러 리소스들을 참조할 수 있는 이유입니다. 컴파일된 실제 리소스들은 나중에 apkbuilder 툴에 의해 APK로 합쳐질 때 다시 사용됩니다.
  • 소스 코드 ✅: 우리의 자바나 코틀린의 소스 코드들은 각각 javac와 kotlinc라는 컴파일러를 이용해 자바 바이트코드(.class)로 변환됩니다. 우리의 코드가 아니더라도 다른 라이브러리들의 자바나 코틀린의 소스들은 JAR이나 AAR파일 안에서 .class로 이미 변환이 되어 있는 상태일 것입니다.

언급한 것 이외에도 빌드 과정에서 여러가지 많은 리소스들이 함께 사용되지만(NDK도 사용하는 경우도 있고), 대표적인 것들만 살펴보았습니다. 이제 우리가 집중적으로 살펴볼 것은 .class 파일들이 .dex 파일로 변환되는 과정입니다. 이는 우리의 최종 APK(AAB) 결과물의 Size(Shirnking, Optimizing)나 난독화(Obfuscating) 수준을 결정하는 중요한 과정입니다.

코드, 리소스 최적화, 난독화

코드와 리소스를 최적화및 난독화한다는 것은 무엇일까요? 보통 다음과 같은 것들이 있습니다. 이 항목들은 안드로이드 공식 문서의 내용을 가져왔습니다. 이는 현재 AGP(4.1.0)가 R8컴파일러를 사용해서 설정들을 켜놓았을 때 실행될 수 있는 작업들입니다.

  • Code shrinking (or tree-shaking): 우리의 소스 코드나 라이브러리의 코드에서 실제로 사용되지 않는 클래스들, 필드들, 메소드들, 속성들을 제거합니다. 만약 우리가 더하기와 빼기가 가능한 계산기 클래스를 만들고 앱에서는 더하기만 사용한다면 이 과정에서 빼기라는 메소드는 우리의 최종 빌드에 포함되지 않습니다. 사용되지 않는 코드들을 없앰으로써 최종적으로 APK의 크기가 줄어들며 multidex를 사용하지 않을 때 최소 64k의 참조 제한이 걸려있는 것도 방지할 수 있습니다.
  • Resource shrinking: 사용되지 않는 리소스들을 제거합니다. 우리가 3개의 drawable을 프로젝트에 포함시켜놓고 그 중에 1개만 포함한다면 나머지 2개는 최종 APK파일에 포함될 필요가 없기 때문에 제거합니다. 이는 Code shrinking이 진행된 후에 진행되는 것이 유리합니다. 왜냐하면 Code shrinking이 끝난 다음에야 우리는 정확히 어떤 리소스들이 참조되며 사용될 지를 정확히 판단할 수 있기 때문입니다.
  • Obfuscation(난독화): 우리가 Calculator 라는 클래스가 있으면 난독화 과정을 통해 이를 C(임의의 이름)라는 이름의 클래스로 최종 파일에서 변경됩니다. 이는 코드의 보안을 증진시키고 소스코드의 길이를 줄임으로써 APK크기 개선에도 도움이 됩니다. 하지만 이 난독화 과정을 거치고 나면 앱이 난독화가 된 후에 Crash가 나거나 Exception stack trace를 분석할 때 우리가 알아볼 수 없게 됩니다. 그러기 때문에 Obfuscation tool들은 난독화를 수행할 때 매핑 파일을 생성해줍니다. 예를 들어 Calculator라는 클래스가 C라고 변경이 된 사실을 매핑파일에 저장해두고 나중에 stack trace를 분석할 때 이 파일을 이용해 실제로 우리가 짠 코드의 어느 부분에서 에러가 난 것인지 판단을 할 수 있게 해주는 것입니다. R8 컴파일러는 mapping.txt 라는 매핑 파일을 생성합니다.
androidx.appcompat.app.ActionBarDrawerToggle$DelegateProvider -> a.a.a.b:
androidx.appcompat.app.AlertController -> androidx.appcompat.app.AlertController:
android.content.Context mContext -> a
int mListItemLayout -> O
int mViewSpacingRight -> l
android.widget.Button mButtonNeutral -> w
int mMultiChoiceItemLayout -> M
boolean mShowTitle -> P
int mViewSpacingLeft -> j
int mButtonPanelSideLayout -> K
  • Optimization(최적화): 코드를 분석해서 최종 APK파일의 크기를 줄이는 최적화를 진행할 수 있습니다. 예를 들어, R8 컴파일러는 절대 수행될 수 없는 else 문을 스스로 제거해서 최적화를 진행해주기도 합니다.

Proguard

https://imstudio.medium.com/android-journey-proguard-d8-r8-what-are-they-e8f2bfe079a7

Proguard는 AGP 3.3까지 사용되었던 상기한 작업들을 도와주는 툴이였습니다. 현재 글을 쓰고 있는 시점에서 AGP는 4.1.0이 안정화된 버전입니다. 보통 Android Studio 버전은 AGP의 버전을 따라갑니다. AGP 3.4부터는 원래 Proguard를 사용하여 진행되던 작업들이 R8을 기본적으로 사용하여 진행되기 시작했습니다.

Proguard는 여러가지 일을 도와주지만 흔히 proguard-rules.proconsumer-rules.pro 파일등을 이용해서 어떤 코드를 난독화하고 난독화하지 않을 것인지 명시해줄 수 있습니다. 이러한 설정들은 라이브러리 제작자들이 미리 명시해두었을 수도 있고, 라이브러리를 설치하는 가이드에서 이런 것들을 포함하라고 복붙할 수 있는 코드를 명시해놓았을 수도 있습니다. 이 두 차이점은 이 글에서 확인하실 수 있습니다. 다음은 Proguard rule 문법에 대한 글입니다. R8 컴파일러도 난독화 과정에서 Proguard rule 문법을 사용하여 우리가 난독화 과정을 커스터마이징 할 수 있습니다.

R8 & D8

R8은 미리 설명했지만, 코드 최적화 이외에도 .class.dex파일로 바꿔주는 컴파일러도 포함하고 있는 녀석입니다.

https://proandroiddev.com/android-default-proguard-rules-guide-20058ba7a486

R8과 D8은 포함관계지만 R8이 등장하기 전에는 다음과 같이 D8이 독립적으로 .class(자바 바이트 코드)를 .dex(DEX)코드로 변경해주는 역할을 했습니다.

https://developer.android.com/studio/releases/gradle-plugin

Desugaring with D8

Sugaring은 무엇을 의미할까요? 무언가 달콤하게 해준다는 뜻일 것입니다. 저희가 쓰는 언어들에서는 흔히 syntax sugar라고 불리는 똑같은 동작을 하는 문법을 더 간편하게 쓸 수 있게 해주는 문법들도 있습니다.

Android빌드 과정에서의 Desugaring은 그 반대입니다. 이는 주로 Java API와 관련된 내용입니다. Java가 발전하며 계속해서 Java의 core에는 새로운 API나 문법들이 추가가 되어왔습니다. 대표적으로 Java 8java.time API를 예시로 들겠습니다.

기존에 우리가 안드로이드 앱을 개발할 때 날짜나 시간을 표현해주려면 어떤 클래스를 사용했나요? 3자 라이브러리를 사용할 수도 있겠지만, 주로 java.util.Datejava.util.Calendar를 사용했을 것입니다. 이러한 legacy한 유틸리티 클래스들은 단점들이 있습니다. 다음과 같은 표를 보겠습니다.

https://medium.com/nanogiants/handling-dates-on-android-1fccccde9d54

쓰레드 안전성이 보장도 안되고 API도 잘 구성이 되어있지 않고 무엇보다 java.util.Date 는 그저 시간에 대해서 나타낸 것이기 때문에 Timezone 등에 대한 지원이 복잡하고 직관적이지 않습니다.

이런것들을 보완해서 만들어진 것이 바로 Java 8의 java.time 패키지의 API 들입니다. 이 내부엔 LocalDateTime , LocalDate , ZonedDateTime 과 같은 유틸리티들입니다. 이런것들을 제 코드에서 직접 사용해보도록 하겠습니다.

바로 Lint 에러가 발생합니다. 이 API는 최소 안드로이드 API 26 버전부터 사용될 수 있으니 사용될 수 없다는 에러입니다. 우리가 MinimumSdkVersion이 26보다 낮은 안드로이드 프로젝트에서 이 API를 사용해주고 싶다면 어떻게 해야 할까요?

바로 D8 컴파일러가 컴파일 과정에서 Desugaring을 수행하게 하면 됩니다. 우리가 사실은 사용할 수 없는데 java.time 을 사용할 수 있게 해주는 것을 Sugaring 이라고 하고 컴파일 과정에서 D8이 그걸 다시 API 26 보다 낮은 기기에서 해석할 수 있는 .dex 코드로 변환시켜주는 과정을 Desugaring 이라고 합니다.

Gradle 스크립트에선 다음과 같이 설정할 수 있습니다.

android {
defaultConfig {
// Required when setting minSdkVersion to 20 or lower
multiDexEnabled true
}

compileOptions {
// Flag to enable support for the new language APIs
coreLibraryDesugaringEnabled true
// Sets Java compatibility to Java 8
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}

dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.9'
}

그대로 설정해 주었더니 이제 에러가 발생하지 않습니다!

Dex 그 이후

빌드 과정에서 DEX 파일이 만들어지는 과정까지 살펴보았다면 남은 단계는 많지 않습니다.

http://tools.android.com/tech-docs/new-build-system/build-workflow

그 이후엔 그래프에서 보이듯이 apkbilder에 의해 만들어진 DEX 파일과 컴파일된 리소스 파일들, 그리고 RenderScript, NDK 등 c/c++과 관련있는 .so(dynamic linked native libs) 파일들이 합쳐져 APK가 만들어지게 되며 그 과정에서 KeyStore를 이용한 서명도 발생합니다.

결론

여기까지 현재 AGP 버전인 4.1.0 에서 APK나 AAB(Dynamic app bundling)를 만들기 위해 진행되는 빌드 과정을 간략하게 살펴보았습니다. 살펴보지 않은 과정들도 많지만 이정도면 뼈대에 대해서는 충분한 내용을 담았다고 생각합니다.

언급한 일련의 과정들은 리눅스기반의 운영체제에서 ART(Android runtime) or Dalvik VM 을 사용하여 구성되어 있는 안드로이드라는 운영체제에서 설치할 수 있는 파일을 만드는 것이지 바로 안드로이드 운영체제에서 실행가능한 파일을 만드는 것이 아니란 것도 주의하셔야 합니다.

안드로이드, 어디까지 아세요 시리즈의 포스팅이 마무리 되었네요. 다음 글로 찾아뵙겠습니다. 아마 NDK쪽을 다루지 않을까 싶네요.

읽어주셔서 감사합니다 🙌

--

--