이전 글
2026.02.19 - [안드로이드] - 안드로이드 컴파일 과정 완전정리 ⑤ 부록 - DEX 는 알겠고 APK 는요?
안드로이드 컴파일 과정 완전정리 ⑥ - ART란?
이전 글에서 DEX는 Android용 바이트코드 포맷이라고 정리했다.
이제 질문은 하나다.
그 DEX를 실제로 누가 실행시키는가?
정답은 **ART(Android Runtime)**다.
하지만 “ART가 실행한다”는 말은 정확하지 않다.
실제로 실행하는 것은 항상 CPU다.
ART는 그 CPU 실행을 가능하게 만드는 런타임 + 컴파일 시스템이다.
0) 한 문장 정의 (정확한 버전)
ART는 CPU 위에서 동작하는 소프트웨어이며,
DEX 바이트코드를 인터프리터 또는 JIT/AOT 컴파일을 통해
CPU가 실행 가능한 기계어로 연결해주는 실행 환경이다.
이 정의를 CPU 관점으로 다시 풀면 이렇게 된다.
- CPU는 ARM64 같은 **기계어(machine code)**만 실행한다.
- DEX는 CPU가 이해하지 못하는 **바이트코드(bytecode)**다.
- ART가 중간에서
- 해석(interpreter)하거나
- 기계어로 변환(JIT/AOT)해서
- CPU 실행 경로로 올려준다.
즉 ART는 “실행 주체”가 아니라
**DEX → 네이티브 코드로 이어지는 브리지(bridge)**다.
▶ 더보기 — “바이트코드”와 “기계어” 차이
- 기계어: CPU가 직접 이해하는 명령어. ARM64 CPU면 ARM64 기계어만 실행한다.
- 바이트코드: 가상머신(ART/JVM 같은 런타임)이 이해하는 명령어. CPU는 직접 실행 못 한다.
1) ART의 구성: Runtime + Compiler
ART는 크게 **Runtime(실행 시스템)**과 Compiler(코드 생성 시스템) 두 덩어리로 이해하면 된다. “런타임인데 왜 컴파일러가 있냐?”가 첫 혼란 포인트인데, Android는 기기 안에서(온디바이스) 컴파일이 실제로 일어나는 구조라서 둘이 같이 묶여 있다.
1-1) Runtime (libart.so 등)
앱 프로세스가 시작되면 런타임 라이브러리(대표적으로 libart.so)가 로드되고, 그 코드(기계어)가 CPU에서 실행된다. Runtime이 맡는 건 “앱이 돌기 위해 필요한 운영체제급 기능”에 가깝다.
Runtime이 담당하는 것(핵심만):
- DEX 로딩: APK 안의 classes.dex 또는 분할 DEX들을 열어 “실행 가능한 형태로 읽는” 작업
- 클래스 로딩/링킹(linking): com.example.Foo 같은 클래스 정의를 찾아 메모리상의 클래스 구조로 만들고, 심볼(이름) 참조를 실제 대상(메서드/필드)로 연결
- 인터프리터 실행: DEX opcode를 하나씩 읽고 수행(아래 2장에서 더 깊게)
- JIT 관리: 언제 어떤 메서드를 컴파일할지 결정하고, 컴파일 결과를 실행 경로에 연결
- GC(가비지 컬렉션): 힙 메모리에서 더 이상 쓰지 않는 객체를 회수
- 스레드/동기화: Java/Kotlin 스레드 모델을 OS 스레드와 연결하고 모니터 락 같은 동기화 제공
- 예외 처리: try/catch, 스택 언와인딩(unwind) 같은 제어 흐름 제공
▶ 더보기 — “링킹(linking)”이란
코드가 SomeClass.someMethod()를 호출할 때, 바이트코드에는 보통 “이름/시그니처” 형태로 참조가 들어 있다.
링킹은 이 참조를 “실제 메모리상의 메서드 구현”으로 연결(resolve)하는 과정이다.
이게 끝나야 런타임이 호출을 빠르게 처리할 수 있다.
1-2) Compiler: dex2oat / JIT 컴파일러
Compiler는 “DEX를 네이티브 코드로 만들 수 있는 장치”다. 두 종류로 나뉜다.
- AOT 컴파일러(dex2oat): 실행 전에 미리 컴파일
- JIT 컴파일러: 실행 중에 컴파일
여기서부터가 “ART가 단순 인터프리터가 아니다”의 핵심이다.
▶ 더보기 — AOT / JIT 가장 구체적인 정의
- AOT(Ahead-Of-Time): 실행 전에 DEX(또는 그 일부)를 네이티브 기계어로 컴파일해서 저장해 두는 방식.
→ 장점: 앱 실행 시 바로 네이티브로 갈 수 있어 초기 성능이 안정적일 수 있음
→ 단점: 설치/백그라운드 작업 비용, 저장공간 증가 가능 - JIT(Just-In-Time): 실행 중에 “자주 실행되는 코드(핫 코드)”만 골라 네이티브로 컴파일하는 방식.
→ 장점: 실제 사용 패턴에 맞게 필요한 부분만 최적화, 저장공간 절약 가능
→ 단점: 처음 실행 때는 인터프리터 경로 비중이 있어 워밍업(warm-up) 시간이 생길 수 있음
2) 왜 ART가 필요한가? (DEX는 CPU가 모른다)
DEX는 바이트코드다.
CPU는 ARM64 명령어 집합만 이해한다.
예:
DEX:
add-int v0, v1, v2
ARM64(예시):
add w0, w1, w2
CPU는 두 번째만 실행 가능하다.
그래서 ART는 두 전략을 가진다.
- 인터프리터 실행: “DEX를 그대로 두고” 해석해서 실행
- 네이티브 컴파일 실행: “DEX를 기계어로 바꿔서” CPU가 직접 실행
▶ 더보기 — 인터프리터가 “CPU 명령으로 수행한다”는 말의 정확한 의미
인터프리터 자체가 이미 기계어로 된 프로그램이다.
CPU는 인터프리터 프로그램의 기계어를 실행하고, 그 인터프리터가 DEX opcode를 읽어 덧셈/분기/호출을 수행한다.
즉 CPU는 끝까지 기계어만 실행한다.
3) 설치 시점 최적화: AOT (dex2oat)
dex2oat는 “기기 안에서 돌아가는 AOT 컴파일러”다.
빌드 머신에서 이미 .dex가 만들어졌는데도 굳이 기기에서 다시 컴파일하는 이유는, 최종 타깃이 ‘해당 기기 CPU/런타임 조건’이기 때문이다.
AOSP 문서 흐름을 개념적으로 정리하면:
- 입력: DEX
- 처리: dex2oat가 기기 ISA(예: ARM64)에 맞게 컴파일
- 출력: 기기용 실행 산출물(여러 파일일 수 있음)
이 과정은:
- 앱 설치 시점
- 또는 백그라운드 dexopt 작업 중
에 일어날 수 있다.
3-1) 내부적으로 무슨 일이 벌어지나? (컴파일러 관점으로 쪼개기)
AOT 컴파일러의 내부는 전형적인 “컴파일 파이프라인”을 따른다. 구현 디테일은 버전에 따라 달라질 수 있지만, 개념적으로는 아래 단계를 밟는다.
- DEX 파싱(parsing)
- DEX의 메서드 코드, 상수 풀(constant pool), 타입/필드/메서드 테이블을 읽는다.
- “이 메서드의 바이트코드가 어디에 저장돼 있는지”를 찾아낸다.
- 검증(verification)
- 바이트코드가 규칙을 어겼는지 체크한다(예: 타입 규칙, 스택/레지스터 사용 규칙 등).
- 이 단계가 엄격하면, 잘못된 DEX를 런타임에서 실행하기 전에 잡아낼 수 있다.
- IR(중간 표현) 구성
- 바이트코드를 컴파일하기 쉬운 내부 표현으로 바꾼다.
- 여기서부터 “최적화”가 가능해진다(예: 상수 전파, 불필요 연산 제거).
- 최적화(optimization)
- 간단한 예: x = 1 + 2는 미리 x = 3으로 바꿀 수 있다.
- 조건 분기 단순화, 중복 계산 제거 같은 변환이 여기서 들어갈 수 있다.
- 코드 생성(codegen)
- IR을 ARM64 같은 기계어로 내린다.
- 레지스터 할당(register allocation), 호출 규약(calling convention) 적용 같은 작업이 여기 포함된다.
- 산출물 저장(artifact emission)
- 생성된 코드 및 런타임에 필요한 메타데이터를 파일로 저장한다(OAT/VDEX 등은 다음 장).
▶ 더보기 — IR(중간 표현)이 왜 필요하나
바이트코드는 “실행”에는 적합하지만 “최적화”에는 불편한 형태가 많다.
컴파일러는 보통 IR로 바꿔야 분석/최적화가 쉬워진다.
3-2) “항상 즉시 컴파일되나?”
아니다.
정책은 기기/버전/설정에 따라 달라질 수 있다. 그래서 글에서는:
설치 시점 또는 백그라운드에서 컴파일될 수 있다
라고 쓰는 게 안전하다.
▶ 더보기 — dexopt가 뭔가
“DEX 최적화 작업”을 통칭하는 말로 자주 쓰인다.
실제로는 dex2oat AOT 컴파일, 프로파일 기반 컴파일, 기타 준비 작업이 포함될 수 있다(정책/구현에 따라).
4) 산출물: OAT / VDEX는 무엇인가
독자가 가장 많이 헷갈리는 포인트가 여기다.
AOSP는 이렇게 명시한다.
dex2oat는 하나 이상의 compilation artifact를 만든다.
파일 개수/확장자/이름은 릴리즈마다 바뀔 수 있다.
즉 “확장자 이름” 자체는 본질이 아니다.
본질은 왜 이런 파일이 필요한가다.
4-1) 가장 안전한 역할 정의
- OAT 계열(개념적으로): “컴파일된 코드(네이티브 코드) + 실행에 필요한 메타데이터”를 담는 컨테이너로 쓰인다.
- VDEX 계열(개념적으로): “DEX 자체/검증 정보/런타임 보조 정보” 같은 부가 데이터를 담는 쪽으로 쓰일 수 있다.
여기서 “정확히 어떤 바이트 레이아웃이냐”는 버전별로 달라질 수 있으니, 블로그에서는 역할 중심이 안전하다.
4-2) 코드가 보여주는 것
dex2oat 코드에서는:
- verifier dependencies 기록
- quickening info 기록
이 동작이 존재한다.
이 말은:
단순 네이티브 코드만 생성하는 게 아니라
런타임 최적화를 위한 부가 정보를 저장한다는 뜻이다.
▶ 더보기 — verifier dependencies(검증 의존성)
“검증 결과가 특정 클래스/메서드 상태에 의존한다” 같은 정보를 담을 수 있다.
목적은 보통 “나중에 재검증/재컴파일할 때 불필요한 일을 줄이거나, 안전성을 유지”하는 쪽으로 이해하면 된다.
(세부 포맷은 버전별로 달라질 수 있으니 역할 중심으로 설명하는 게 안전하다.)
▶ 더보기 — quickening
바이트코드 실행을 더 빠르게 만들기 위해
런타임에서 사용할 수 있도록 준비된 사전 처리 정보.
(정확히 어떤 변환을 하느냐는 버전/구현에 따라 달라질 수 있음)
5) JIT + 프로파일: 실행 중 최적화
ART는 AOT만 사용하지 않는다.
여기서 다시 CPU 관점으로 정리한다.
- 인터프리터는 “DEX opcode를 읽고 분기”를 매번 반복한다.
- 이 반복의 핵심 오버헤드가 dispatch 비용이다.
- 자주 실행되는 경로(핫 코드)는 이 비용이 누적되므로, 네이티브로 바꾸면 큰 이득이 나온다.
- 그래서 JIT가 필요하다.
5-1) 프로파일링이 왜 필요한가?
예시:
fun renderFrame() {
physicsStep()
draw()
}
renderFrame은 초당 수십 번 호출된다.
settingsScreen은 거의 호출되지 않는다.
모든 코드를 똑같이 컴파일하면:
- 저장공간이 불필요하게 늘거나
- 설치/업데이트 비용이 커지거나
- 실제로 안 쓰는 코드까지 최적화하느라 낭비가 생긴다
그래서 **프로파일링(실행 통계 수집)**이 필요하다.
▶ 더보기 — 프로파일링이 수집하는 데이터 예시(개념)
- 메서드 호출 빈도
- 루프 반복 빈도
- 어떤 분기(if/else)가 실제로 자주 타는지
- 어떤 호출 대상이 자주 오는지(다형성 호출 패턴)
(정확히 무엇을 어느 형식으로 저장하는지는 구현/버전에 따라 달라질 수 있으므로 “대표 예시”로만 이해하면 된다.)
5-2) 내부적으로 무슨 일이 벌어지나? (JIT 파이프라인)
JIT는 보통 이런 흐름을 가진다.
- 인터프리터로 시작
- 앱 첫 실행이나 아직 컴파일되지 않은 경로는 인터프리터가 담당한다.
- 카운터/프로파일 누적
- 메서드 진입 카운터, 루프 백엣지(back-edge) 카운터 같은 형태로 “핫함”을 측정하는 방식이 일반적이다(개념).
- 임계값(threshold) 도달
- “이 메서드/루프는 충분히 자주 돈다” 판단이 서면 JIT 컴파일 트리거.
- JIT 컴파일
- 바이트코드 → IR → 최적화 → 코드 생성
- AOT와 유사한 파이프라인을 “런타임 중”에 수행한다.
- 이후 네이티브 코드 경로 사용
- 이후 호출은 컴파일된 코드로 갈 수 있다.
- 흔히 “엔트리포인트가 바뀐다”라고 말하지만, 블로그에서는 이렇게 표현하는 게 안전하다:
- 동일 메서드 호출이 이후에는 인터프리터 경로가 아니라 컴파일된 코드 경로로 들어갈 수 있다.
▶ 더보기 — dispatch 비용(다시, 더 정확히)
인터프리터가 매 opcode마다 “다음 opcode 읽기 → 분기 → 구현 수행”을 반복할 때 드는 오버헤드.
네이티브 코드로 가면 이 “분기 루프”를 제거할 수 있다.
6) AOT와 JIT의 관계
AOT는 “미리 준비하는 전략”
JIT는 “실제 사용 패턴에 맞춰 조정하는 전략”
둘은 경쟁 관계가 아니라 보완 관계다.
- AOT: 기본 성능/일관성 확보에 유리할 수 있음
- JIT: 실제로 많이 쓰는 경로를 더 날카롭게 최적화 가능
여기서 중요한 결론:
ART는 단일 실행 방식이 아니라, 상황에 따라 경로를 바꾼다.
▶ 더보기 — “워밍업(warm-up)”이란
JIT 기반 시스템에서 “처음엔 느리다가, 반복 실행 후 빨라지는” 구간이 생길 수 있다.
그 이유는 핫 코드가 아직 컴파일되지 않았고, 프로파일이 충분히 쌓이지 않았기 때문이다.
7) 내부 구조를 CPU 관점으로 다시 정리
DEX
↓
(인터프리터 또는 JIT/AOT)
↓
네이티브 기계어
↓
CPU
여기서 핵심은:
ART는 CPU 위에서 동작하는 소프트웨어다.
CPU는 끝까지 기계어만 실행한다.
이 문장을 오해 없이 더 직설적으로 풀면:
- CPU는 ART(기계어 프로그램)를 실행한다.
- ART는 DEX를 처리해 기계어를 만들거나, 이미 만든 기계어로 연결한다.
- CPU는 그 기계어를 실행한다.
▶ 더보기 — “엔트리포인트”란
메서드를 호출할 때 “실제로 점프하는 시작 주소”를 말한다(개념).
처음엔 인터프리터 진입점일 수 있고, JIT/AOT 후엔 컴파일된 코드 진입점일 수 있다.
(정확한 내부 구조체/필드명은 버전에 따라 달라질 수 있으니 개념으로 이해하면 된다.)
8) 개발자가 ART를 이해해야 하는 이유
- **“왜 첫 실행이 느릴 수 있나”**를 설명 가능
- 인터프리터 비중이 높은 워밍업 구간이 있을 수 있다.
- **“왜 동일 앱인데 기기마다 체감이 다를 수 있나”**를 설명 가능
- AOT/JIT 정책, 백그라운드 최적화 타이밍, 저장공간/열/전력 정책 등이 다를 수 있다(기기/OEM 차).
- **“DEX는 완성본이 아니다”**라는 감각 형성
- .dex는 “최종 실행물”이 아니라 “ART의 입력”이다.
최종 요약
ART는 단순 인터프리터가 아니다.
단순 컴파일러도 아니다.
DEX를 CPU 실행 경로로 연결하는 하이브리드 런타임/컴파일 시스템이다.
그리고 다시 한 번 정의를 반복한다.
ART는 CPU 위에서 동작하는 소프트웨어이며,
DEX 바이트코드를 인터프리터 또는 JIT/AOT 컴파일을 통해
CPU가 실행 가능한 기계어로 연결해주는 실행 환경이다.
'안드로이드' 카테고리의 다른 글
| 안드로이드 컴파일 과정 완전정리 ⑦ 마지막 편 - ART에서 CPU로 (0) | 2026.02.26 |
|---|---|
| 안드로이드 컴파일 과정 완전정리 ⑥ 부록 - 앱 프로세스는 어떻게 시작되나? (0) | 2026.02.25 |
| 안드로이드 컴파일 과정 완전정리 ⑤ 부록 - DEX 는 알겠고 APK 는요? (0) | 2026.02.23 |
| 안드로이드 컴파일 과정 완전정리 ⑤ - DEX 란? (1) | 2026.02.22 |
| 안드로이드 컴파일 과정 완전정리 ④ - D8/R8 란? (0) | 2026.02.21 |
댓글