이전 글
2026.02.19 - [안드로이드] - 안드로이드 컴파일 과정 완전정리 ② - 코틀린 컴파일러란?
안드로이드 컴파일 과정 완전정리 ② - 코틀린 컴파일러란?
이전 글2026.02.19 - [안드로이드] - 안드로이드 컴파일 과정 완전정리 ① - Kotlin, D8/R8, DEX, ART 등 Android 변환(.dex) -> 설치/최적화 -> ART 해석/JIT/AOT -> CPU 기계어 실행으로 동작한다.코틀린 소스가 컴파
develop-with-kkomaeng.tistory.com
이전 글에서 Kotlin 컴파일러가 .kt를 .class로 만든다고 했다.
이번 글은 그 .class가 의미하는 “JVM”이 뭔지, 그리고 JVM 관점에서 .class가 어떻게 실행되는지(= JVM이 이해하는 명령 집합/실행 모델)를 안드로이드 개발자 관점으로 끝까지 파고든다.
중요한 전제부터 박자.
- 안드로이드 런타임(ART)은 JVM이 아니다.
- 하지만 Kotlin/Javac는 (JVM 타깃일 때) .class를 만들고,
- 안드로이드 빌드에서는 보통 이 .class가 D8/R8를 거쳐 .dex로 변환되어 ART에서 실행된다.
즉, 안드로이드 앱이 “JVM 위에서 그대로 돈다”는 말은 사실이 아니다.
그럼에도 JVM을 알아야 하는 이유는, .class라는 중간 산출물이 JVM 규격(클래스 파일 + 바이트코드)으로 정의되어 있고, 안드로이드 툴체인도 그걸 기반으로 변환/최적화를 하기 때문이다.
1) .class는 JVM용 바이트코드다
.class 파일은 “CPU 기계어”가 아니다.
.class 안에는 JVM이 이해하는 **바이트코드(bytecode)**가 들어 있다.
예를 들어:
이 함수가 “정수 더하기”로 컴파일되면 바이트코드는 대략 이런 형태가 된다.
- iload (int 로드)
- iadd (int add)
- ireturn (int return)
여기서 “대략”이라고 한 이유는, 실제 명령은 메서드가 정적(static)인지, 인스턴스 메서드인지, 지역 변수 슬롯 배치가 어떤지에 따라 iload_0, iload_1처럼 변형이 생기기 때문이다. 하지만 핵심은 동일하다.
JVM은 스택 기반(stack-based) 가상 머신이라서, 값을 스택에 올리고(pop/push) 연산한다.
▶ 더보기 — 바이트코드(bytecode)란?
CPU 기계어는 특정 CPU(ARM/x86)가 직접 실행한다.
바이트코드는 CPU가 직접 실행하지 않는다.
바이트코드는 가상 머신(VM)이 읽고 실행하거나, 실행 직전에 기계어로 바꿔서 실행한다.
JVM 바이트코드는 “JVM 규격이 정의한 명령 집합”이다.
2) JVM이란: “프로그램”이 아니라 “명세 + 구현”이다
JVM이라는 단어는 보통 두 의미가 섞여 쓰인다.
- JVM 명세(specification)
- “클래스 파일 포맷은 이렇게 생겼다”
- “바이트코드 명령은 이런 의미다”
- “스택 프레임/힙/클래스 로딩은 이런 모델이다”
- JVM 구현(implementation)
- 실제로 돌아가는 프로그램(대표적으로 HotSpot JVM 같은 구현체)
안드로이드 개발 맥락에서 중요한 건 1)이다.
.class는 “어떤 구현체”가 아니라, JVM 명세가 정의한 규격을 따른 파일이기 때문이다.
▶ 더보기 — 명세(spec)란?
명세는 “이 기능이 어떤 규칙으로 동작해야 하는지”를 글로 정의한 문서다.
구현은 “그 규칙대로 실제 프로그램을 만든 것”이다.
예: HTTP는 명세가 있고, 크롬/사파리는 그걸 구현한 프로그램이다.
JVM도 비슷하게, JVM 명세가 있고 여러 JVM 구현이 존재한다.
3) .class 파일 내부: “그냥 바이트코드 덩어리”가 아니다
.class는 크게 아래 정보를 가진다.
- 클래스 이름, 부모 클래스, 인터페이스
- 필드 목록, 메서드 목록
- 각 메서드의 바이트코드(Code)
- 상수 풀(Constant Pool)
- 예외 테이블(try/catch)
- 디버그 정보(라인 번호 등, 옵션에 따라)
- (Kotlin의 경우) @kotlin.Metadata 같은 부가 메타데이터
여기서 **상수 풀(Constant Pool)**은 JVM 세계의 핵심 중 핵심이다.
바이트코드가 “메서드 호출” 같은 걸 할 때, 문자열로 직접 “java/lang/String”을 들고 있는 게 아니라, 상수 풀 인덱스를 통해 심볼을 참조한다.
▶ 더보기 — 상수 풀(Constant Pool)이란?
.class 내부에 있는 “참조 테이블”이다.
- 클래스 이름
- 메서드/필드 심볼(이름+시그니처)
- 문자열 리터럴
- 숫자 상수
- 타입 정보
이런 것들을 모아두고, 바이트코드는 “테이블의 n번 항목”을 가리키는 방식으로 동작한다.
그래서 .class는 텍스트 기반이 아니라 “구조화된 바이너리 포맷”이다.
4) JVM 실행 모델: 스택 머신 + 프레임 + 로컬 변수 슬롯
JVM 바이트코드는 레지스터 기반이 아니라 스택 기반이다.
메서드가 실행되면 JVM은 그 메서드에 대해 **스택 프레임(stack frame)**을 만든다.
프레임 안에는 보통 이런 것들이 있다.
- 로컬 변수 영역(Local Variables)
- 오퍼랜드 스택(Operand Stack)
- 현재 실행 위치(Program Counter에 해당하는 정보)
- (구현체에 따라) 추가 메타 정보
sum(a, b) 같은 함수라면:
- a, b는 로컬 변수 슬롯에 들어간다
- iload로 값을 스택에 push
- iadd가 스택에서 두 값을 pop해서 더한 뒤 결과를 push
- ireturn이 결과를 반환
▶ 더보기 — 오퍼랜드 스택(Operand Stack)이란?
“연산할 값들을 올려두는 스택”이다.
예: a + b
- a push
- b push
- add: 두 값을 pop해서 더하고 결과 push
이 모델 때문에 JVM 바이트코드는 iadd처럼 “레지스터 이름이 없는 연산”이 가능하다.
(레지스터 머신이라면 보통 add r1, r2, r3 같은 형태가 된다.)
▶ 더보기 — 로컬 변수 슬롯(local variable slot)이란?
JVM 메서드는 지역 변수들을 “번호가 붙은 칸”에 저장한다.
- 0번 슬롯: (인스턴스 메서드면) this가 보통 들어간다
- 그 다음 슬롯들: 파라미터/지역 변수
정적(static) 메서드인지 인스턴스 메서드인지에 따라 슬롯 배치가 달라질 수 있다.
그래서 바이트코드 예시는 “대략”이라고 표현하는 게 정확하다.
5) JVM의 “클래스 로딩”은 단순한 파일 읽기가 아니다
JVM에서 .class는 실행 전에 보통 다음 과정을 거친다.
- Loading(로딩)
- 바이트를 읽어서 “클래스 데이터 구조”로 만든다
- Linking(링킹)
- Verification(검증)
- Preparation(준비)
- Resolution(해석/해결)
- Initialization(초기화)
- 정적 초기화, <clinit> 실행 등
여기서 핵심은 2) Linking이다.
- Verification(바이트코드 검증): 이 바이트코드가 JVM 규칙을 위반하지 않는지 확인
(예: 스택 언더플로우, 잘못된 타입으로 연산, 점프 대상 불일치 등) - Preparation: 정적 필드 메모리 할당/기본값 설정 같은 준비
- Resolution: 상수 풀에 있는 심볼 참조를 실제 대상(메서드/필드/클래스)로 연결
이 과정을 통해 JVM은 “실행 가능한 안전한 형태”로 클래스를 만든다.
▶ 더보기 — 바이트코드 검증(verification)이 왜 필요하나?
JVM은 원래 “바이너리 형태의 코드”를 실행한다.
이게 임의로 조작된 바이트코드라면 보안/안정성이 깨질 수 있다.
검증은 “이 바이트코드가 JVM 규칙을 지키는지”를 실행 전에 확인해서
런타임이 터지는 것과 별개로, 아예 로딩 단계에서 거부할 수 있게 한다.
6) JVM이 바이트코드를 실행하는 방식: 인터프리터 vs JIT
JVM은 보통 두 가지 실행 전략을 섞어 쓴다.
- 인터프리터(interpreter): 바이트코드를 한 명령씩 해석해서 바로 실행
- 시작은 빠르지만 장기 성능은 상대적으로 불리할 수 있다
- JIT(Just-In-Time 컴파일): 자주 실행되는 코드를 런타임에 기계어로 컴파일
- 워밍업(초기 구간) 비용이 들지만, 반복 실행 구간 성능이 좋아진다
sum 같은 작은 함수도, 핫(hot)해지면 JIT 최적화 대상이 될 수 있다.
다만 “반드시 된다”라고 단정할 수는 없다. JIT 적용 여부는 JVM 구현과 실행 상황(프로파일링 결과)에 따라 달라진다.
▶ 더보기 — JIT가 “프로파일 기반”이라는 말의 뜻
JIT는 보통 “실행 통계를 모아서” 최적화한다.
- 이 메서드가 얼마나 자주 호출됐는지
- 어떤 분기(if)가 주로 선택되는지
- 어떤 타입이 주로 들어오는지
이런 정보를 근거로 “이 코드는 앞으로도 자주 실행될 것 같다”라고 판단하면
그때 기계어로 컴파일하거나 더 강한 최적화를 적용한다.
7) JVM 메모리 구조: 스택/힙/메타데이터 영역
JVM을 내부 관점에서 보면 “메모리 구획을 어떻게 나눠 쓰는가”가 크다.
- 스레드 스택(Thread Stack)
- 각 스레드마다 존재
- 메서드 호출마다 스택 프레임이 쌓인다
- 지역 변수/오퍼랜드 스택 같은 “실행 중 임시 값”이 주로 여기에 들어간다
- 힙(Heap)
- 모든 스레드가 공유
- new로 만든 객체/배열이 주로 여기에 들어간다
- 클래스 메타데이터 영역
- 로딩된 클래스의 구조 정보(필드/메서드 등)를 저장하는 영역
- 정확한 구현/이름/배치는 JVM 구현체마다 다를 수 있다(그래서 여기서는 “있다” 수준으로만 다룬다)
▶ 더보기 — GC(가비지 컬렉션)란?
힙에 있는 객체는 free()로 직접 해제하지 않는다.
대신 더 이상 참조되지 않는 객체를 JVM이 찾아서 치운다. 이게 GC다.
- 장점: 메모리 해제 실수를 줄인다
- 단점: 특정 타이밍에 “정리 비용”이 들어가며, 구현에 따라 일시 정지가 발생할 수 있다
GC 알고리즘 종류/동작은 JVM 구현체와 설정에 따라 달라진다.
그래서 “GC는 무조건 멈춘다/안 멈춘다” 같은 단정은 위험하다.
8) 안드로이드 관점 연결: “그럼 JVM을 왜 이렇게까지 보나?”
정리하면, Kotlin 컴파일러가 만든 .class는 “JVM 규격의 결과물”이다.
안드로이드 앱은 보통 ART에서 돌아가지만, 빌드 체인은 이 흐름을 탄다.
- .kt
- Kotlin 컴파일러
- .class (JVM 바이트코드 + 메타데이터)
- (안드로이드 툴체인) .dex 변환/최적화
- ART에서 실행
그래서 .class와 JVM 실행 모델을 이해하면 이런 걸 명확히 볼 수 있다.
- “이 코드가 컴파일되면 어떤 형태의 호출/분기로 떨어질지”
- “inline, 코루틴, default parameter 같은 문법이 왜 코드 크기/디버깅 난이도에 영향을 주는지”
- “바이트코드 레벨에서 무엇이 ‘비싸고/싼지’의 직관”
9) 이 글에서 나오는 단어 미니 설명(필수)
▶ 더보기 — JVM(자바 가상 머신)이란?
JVM은 “JVM 바이트코드를 실행하는 가상 머신”이고, 동시에 그 동작을 정의하는 명세를 포함하는 말이다.
실제 실행 프로그램(구현체)과 규칙 문서(명세)를 구분해서 이해해야 혼동이 없다.
▶ 더보기 — 인터프리터란?
명령을 “번역해서 실행”하는 방식이다.
바이트코드를 기계어로 미리 다 바꾸지 않고, 실행 시점에 한 줄씩 해석한다.
▶ 더보기 — 스택 프레임이란?
함수(메서드) 호출 1회에 대응하는 실행 컨테이너다.
파라미터/지역 변수/연산 스택/리턴 주소 같은 실행 정보를 묶어 들고 있다.
'안드로이드' 카테고리의 다른 글
| 안드로이드 컴파일 과정 완전정리 ⑤ - DEX 란? (1) | 2026.02.22 |
|---|---|
| 안드로이드 컴파일 과정 완전정리 ④ - D8/R8 란? (0) | 2026.02.21 |
| 안드로이드 컴파일 과정 완전정리 ② - 코틀린 컴파일러란? (0) | 2026.02.19 |
| 안드로이드 컴파일 과정 완전정리 ① - Kotlin, D8/R8, DEX, ART 등 (0) | 2026.02.19 |
| [코틀린 Kotlin 스터디] 코틀린 기초 시작하기 (0) | 2022.01.08 |
댓글