Android 컴파일 과정과 Baseline Profile을 통한 성능 개선

개발Android컴파일러ARTBaselineProfileR8

2026년 10월 6일

개요

요즘 면접을 준비하기 위해 Codex와 모의 실무 면접을 계속하고 있다. 진행하면서 모르는 것들을 정말 많이 마주하고 있는데, 그 중 빠른 시일 내 정리가 필요하다고 판단한 주제 중 하나가 Android의 컴파일 과정과 Baseline Profile에 관한 내용이었다.

이 얘기를 하려면 먼저 Baseline Profile에 대해 언급해야 한다. Baseline Profile은 개발자가 사전에 사용자가 자주 사용할 것으로 예상되거나 부하가 걸릴 만한 부분을 등록하면, 컴파일러가 이 부분을 꽤 강하게 최적화해주는 기능을 말한다. 그런데 이 기능이 어떻게 최적화를 하고 왜 성능이 개선되는지에 대해 설명하기 위해서는 반드시 Android 컴파일 과정에 대해 알아야만 했던 것이다.

따라서 이 게시물에서도, 먼저 Android의 컴파일 과정에 대해 간략히 설명 후, Baseline Profile이 어떻게 Android의 컴파일 과정 위에서 앱의 성능을 개선하는지를 이어 설명하도록 하겠다.

Android의 컴파일 과정

개요

Android는 기본적으로 Kotlin(Java)로 개발된다. 특히 Kotlin이 Android 공식 언어가 되면서, Java와의 상호 운용이 가능하다는 점이 주요 장점으로 언급되곤 한다. 다만, 엄밀히 말하면 실제 컴파일이 Java 바이트코드로 이루어지는 건 아니다. Android에서는 Android Runtime(ART, 구 Dalvik VM)에서 실행되는 DEX(Dalvik Executable) 바이트코드로 컴파일된다.

JVM과 Dalvik VM(DVM)의 차이

왜 JVM을 타겟한 .class가 아니라 DVM를 타겟한 .dex로 컴파일할까? 여러 이유가 있지만 주된 이유는 다음과 같다: Android에서는 DVM의 성능이 더 우위에 있다.

JVM의 스택 기반 컴파일러

JVM은 스택 기반이다. 여기서 스택 기반이란 보통 컴퓨터공학과라면 자료구조 시간에 스택 사용의 대표적 사례로 배우는 후위 표기법(postfix notation) 및 그것의 연산 처리를 의미하는 거 맞다.

스택이라는 자료구조 자체가 매우 간단하기도 하고, 스택을 쓰기 때문에 단순하게 "피연산자는 무조건 스택 TOP에 위치한다"고 가정할 수 있게 되어 컴파일러 구현이 매우 간단해지는 것이 JVM의 장점이다.

DVM의 가상 레지스터 기반 컴파일러

반면, DVM은 가상 레지스터 기반이다. 가상의 레지스터가 있다고 가정하고 스택 대신 가상 레지스터를 연산에 사용하는 방식이다.

단점부터 말하면 가상 레지스터에 대한 최적화가 필요해진다. 왜냐하면 레지스터에 어떤 값이 언제 올라와서 언제 내려가는지 전체 생명주기를 파악하고, 레지스터에 값이 없는 타이밍을 최대한 활용하며 공간을 절약해야 효율적인 컴파일이 가능하기 때문이다. 또한, 명령어가 좀 길어진다. 스택 방식은 그냥 "스택에 넣어라", "스택에서 빼라"만 지시하면 되는 것에 비해, 가상 레지스터를 전제한다면 "어느 레지스터와 어느 레지스터의 값을 어떻게 연산한 것을 어떤 레지스터에 저장해라"고 지시해야 한다. 딱 봐도 명령어 길이 차이가 느껴지는 부분이다.

그러나 장점이 꽤나 명확한데 전체 명령어 실행 수가 스택 방식에 비해 꽤 줄어든다. 다시 말해, CPU가 새로운 명령어를 페칭 및 실행하기 위해 필요한 오버헤드도 그만큼 줄어든다는 뜻이다.

비교

그럼 JVM와 DVM에서 두 정수 a와 b를 더해서 r1이라는 저장 공간에 쓰는 명령을 지시했을 때 실제 명령어와 호출되는 명령어 수를 계산해보자. 다음을 보라:

JVM명령어 번호DVM
PUSH a1add-int a, b, r1
PUSH b2-
OPERAND +3-
STORE r14-

DVM에서는 명령어가 조금 길긴 하지만 단 1개 명령으로 모든 게 끝난다. 그러나 JVM에서는 4번의 명령이나 필요하다. 기기 성능이 충분하다면 JVM이나 DVM나 큰 상관이 없겠으나 초기 모바일 기기의 CPU 성능은 데스크탑용에 비해 그렇게 좋지 않았다는 건 모두가 알 것이고 지금도 보급형 기기의 경우 꽤나 불만족스러운 성능을 내는 기기가 많다. 그렇기에 저사양 기기를 고려해야 하는 Android로서는 스택 프레임을 사용하는 JVM을 택할 수 없었던 것이다.

정리

Android는 저사양 모바일 기기를 고려하여 JVM 대신 레지스터 기반의 Dalvik VM(DVM)과 그 실행 파일 포맷인 DEX 바이트코드 .dex를 도입했다. 현대 Android는 DVM 대신 ART를 런타임으로 사용하고 있지만, 바이트코드 규격은 하위 호환성을 위해 여전히 .dex를 사용하고 있어 DVM의 가상 레지스터 아키텍처로 인한 이점은 여전히 유효하다.

컴파일 과정

이제 JVM 대신 ART(구 DVM)를 쓰는 이유에 대해 알아봤으니, 다음으로는 Android에서 소스 코드가 어떻게 DEX 바이트코드로 컴파일이 진행되는지 그 과정을 뜯어보자.

코드 작성

먼저 개발자는 Kotlin과 Java로 Android 앱 코드를 작성한다. 이 과정에서는 개발자로서는 딱히 신경을 쓸 부분이 없다.

JVM 바이트코드 컴파일

개발자가 컴파일을 시작하면, 컴파일러는 가장 먼저 Kotlin과 Java 소스 코드를 JVM 바이트코드 .class로 컴파일한다. 그리고 이 결과물을 Android 전용 컴파일러인 D8/R8로 바로 보낸다.

DEX 바이트코드 변환

이전 과정에서 넘겨받은 JVM 바이트코드 .class 파일들은 다음 2가지 컴파일러 중 하나에 의해 DEX 바이트코드 .dex 파일로 변환된다:

컴파일러역할구분
D8 컴파일러JVM 바이트코드(.class)를 DEX 바이트코드(.dex)로 변환난독화가 필요 없는 경우 (디버그 빌드)
R8 컴파일러D8 + 코드 축소, 난독화, 최적화, 디슈가링난독화 및 코드 최적화가 필요한 경우 (릴리즈 빌드)

📖 R8의 역할에 대한 추가 설명

이름의미
코드 축소실제로 도달할 수 없는(unreachable) 미사용 소스 코드 등을 제거하는 과정
난독화ProGuard로 알려진 그것으로 리버스 엔지니어링 시 코드 추적이 어렵도록 재작성하는 과정
최적화메소드를 인라인으로 옮기는 등 일반적으로 알려진 최적화 과정
디슈가링구형 Android에서 지원되지 않는 문법적 편의 기능(sugar)을 하위 호환 바이트코드로 변환하는 과정

기본적으로 R8 = D8 + 여러 유틸리티 기능으로 설명할 수 있다. 둘 다 실제로 같은 패키지인 com.android.tools.r8에 위치하고 있다. 다만 D8은 순수하게 코드를 컴파일하는 기능만 가지고 있기 때문에 보안 및 최적화가 필요한 릴리즈용에는 부적합하다. 반대로 R8은 최적화, 난독화 등 컴파일에는 필수적이지 않은 부가 기능을 가지고 있어 디버깅 및 개발 시 빠르게 컴파일할 목적으로는 부적합하다. 그래서 D8은 디버그용, R8은 릴리즈용으로 사용하는 게 일반적이다.

정리하면, Java/Kotlin 컴파일러가 .kt 파일과 .java 파일을 .class 파일로 컴파일하면, D8/R8 중 필요한 컴파일러가 상황에 맞게 동작하며 .class 파일을 전부 DEX 바이트코드 .dex로 변환한다고 요약이 가능하겠다.

실행 과정

컴파일이 진행됐으니 이제 Android 기기에서 실행이 가능해졌다. 아까 위에서 말했지만 Android에서 실행되는 바이너리는 크게 2가지로, 각각 앱에 탑재되는 DEX 바이트코드 .dex와 앱 설치 과정에서 컴파일되는 기계어 .oat이다. 이 두 바이너리 간 실행 방식에 어떤 차이가 있는지 살펴보자.

DEX 바이트코드: JIT 컴파일러와 인터프리터

이 파트는 바이트코드 .dex의 케이스이다.

.dex는 바이트코드이기 때문에, Android Runtime(ART)에 포함된 인터프리터가 코드를 즉시 해석하고 바로 실행한다. 그리고 후술하겠지만 필요에 따라 자주 사용하는 핫 코드는 JIT 컴파일러로 컴파일하여 코드 캐시에 올려둔다. 그래서 선 컴파일에 필요한 시간이 필요하지 않다. 당연하지만, 미리 컴파일을 안 하는 만큼 실행 시간에 코드를 해석해야 하기 때문에 앱 성능에 일부 악영향을 미친다. 어쩔 수 없는 부분이다.

OAT 기계어: AOT 컴파일러

바이트코드의 단점을 보완해주는 방식이자 기계어 .oat의 케이스이다.

.oat로 컴파일된 바이너리는 별도의 추가 컴파일 절차 없이 즉시 Android 하드웨어 위에서 실행 가능하다. 그렇기 때문에, 그 어떤 추가 과정도 필요하지 않으며 그냥 실행하면 끝이다. 미리 컴파일을 해야 하긴 하지만 실행 시간에 확실한 이점을 주는 만큼 감당할 만한 부분이다.

두 방식의 관계

두 방식은 서로 완전히 반대 위치에 있다. DEX 바이트코드는 앱 설치 시간을 깔끔하고 빠르게 넘길 수 있게 해주는 대신, 실행 시간에 코드를 컴파일해야 해서 앱 성능에 악영향을 준다. 반대로 OAT 기계어는 미리 컴파일을 하는 만큼 앱 설치 시간을 추가로 요구하지만, 그만큼 실행 시간에는 확실한 성능을 보장한다.

현대 ART는 두 타입의 바이너리를 모두 취할 수 있도록 설계되어 있다. 그렇기에 둘을 적절히 같이 사용하면 앱 설치 시간을 적당히 유지하면서 실행 시간을 개선하는 이상적인 방향이 가능해진다. 그리고 이어 설명할 Baseline Profile은 이 두 컴파일러의 상호 보완 때문에 가능한 방식이라는 점도 미리 언급하도록 하겠다.

Baseline Profile

Baseline Profile은 쉽게 말하면 "사용자가 자주 쓰는 기능을 예측하여 기계어로 미리 컴파일해두는 방식"이다.

예를 들어, 시작 화면? 모든 사용자가 본다. 앱의 핵심 기능? 대부분의 사용자가 사용할 것이다. 그런데 이렇게 사용자가 자주 찾을 것으로 예상되는 기능이 느리다면? 재앙이다. 그래서 이런 부분을 개발자가 미리 지정하여 컴파일러에게 "여기는 사용자들이 많이 사용할 것으로 예상되는 플로우니까 DEX 바이트코드 말고 바로 기계어로 컴파일해줘"라고 부탁하는 것이다.

프로파일링

이 지점에서 프로파일링 기법에 대한 소개가 필요하다.

위에서 ART는 AOT와 JIT의 상호 보완으로 구성된다고 설명했다. 빠진 내용이 하나 있는데, 현대 ART는 사용자가 실행 시간에 자주 호출되는 메소드와 클래스 정보를 프로파일 .prof에 기록해두고, 기기가 유휴 상태이거나 충전 중일 때 해당 프로파일에 따라 자주 호출되는 핫 코드를 기계어 .oat로 미리 컴파일하여 성능을 개선한다. 즉, 간단히 요약하면 다음과 같은 식이 성립하는 것이다:

현대 ART = AOT(Ahead-of-time) + JIT(Just-in-time) + Profiling

다만 이 프로파일링 기법은 한계가 있는데, 사용자가 주로 사용하는 기능에 대한 통계를 수집하는 데 시간이 필요하기 때문에 최초 설치 시에는 여전히 버벅임(jank)이 있을 수밖에 없다. 그럼 이 첫 사용 시의 성능 개선은 어떻게 해야 할까? 그 질문에 대한 답이 바로 Baseline Profile이다.

적용 과정

Baseline Profile은 위에서 말했듯이 개발자가 자주 사용할 것으로 예측되는 부분을 기계어로 컴파일하도록 지정해두는 것이다. 다음 과정에 따라 동작한다. (기준 AGP 8.2 이상이며, 자세한 코드는 생략했으니 궁금하다면 실 적용 시 에이전트와 함께 파악해보기 바란다.)

Baseline Profile Generator 모듈 생성

Android Studio 상단 메뉴에서 새 모듈을 생성하는 창을 열고, Baseline Profile Generator를 선택 후, 타겟 모듈을 앱 모듈로 지정한다.

시나리오 작성

생성된 모듈 내부의 BaselineProfileGenerator.kt 파일을 열어, 컴파일해 두고 싶은 주요 사용자 경로(시작, 핵심 기능 진입 등)를 정의한다.

프로파일 생성 실행

런타임 프로파일을 기기에서 수집해야 하기 때문에 컴파일 권한이 허용된 기기 환경이 필요하다. 보통 에뮬레이터를 사용하면 문제가 없다고 한다. 에뮬레이터에서 다음 명령어를 실행한다: ./gradlew :app:generateBaselineProfile. 그러면 테스트가 기기에서 앱을 실행하면서 지정한 사용자 경로를 기록하고, 이를 앱 모듈의 src/main/baselineProfiles/baseline-prof.txt 위치로 자동으로 복사한다.

릴리즈 빌드

여기부터는 추가 작업이 필요 없다. 릴리즈 빌드에 자동으로 baseline-prof.txt가 포함되고, 사용자가 앱을 설치할 때 기기 내 ART의 컴파일러가 지정된 경로에 따라 AOT 컴파일을 완료한다.

Google Play Cloud Profile

기기 내 프로파일링과 Baseline Profile의 보완책이 하나 더 있다. Google Play Cloud Profile이다.

기기 내 프로파일링이 단일 사용자에 대한 통계라면, Google Play Cloud Profile은 앱 사용자 전체에 대한 통계이다. 따라서 전체 사용자에 대한 데이터를 통해 더 정밀하고 정확한 최적화가 가능하다. 다만 이 역시 배포 후 데이터 누적에 시간이 필요하고, 업데이트가 잦으면 효과를 보기 어렵다는 단점이 있기는 하다.

정리

오늘 정리한 내용은 다음과 같다:

  • Android에서 Kotlin과 Java 컴파일은 JVM이 아닌 ART(Android Runtime, 과거에는 DVM이었음)을 전제로 진행된다.
  • Android의 컴파일 방식은 바이트코드(JIT 컴파일러) 와 기계어(AOT 컴파일러)의 2가지이다.
  • Android 앱에는 사용자가 자주 사용하는 경로(path) 를 추적하여 최적화를 하는 프로파일링 기법이 적용된다.
  • 통계 누적이 필요한 프로파일링 기법을 보완할 수 있는 Baseline Profile 기법도 존재한다.

참고 자료

전체 게시글

1 / 4