JVM 관점에서 이해하는 JIT와 AOT 컴파일
JVM 관점에서 이해하는 JIT와 AOT 컴파일: HotSpot부터 GraalVM Native Image까지
Java를 공부하다 보면 다음과 같은 설명을 자주 접한다.
Java는 소스 코드를 바이트코드로 컴파일하고, JVM이 바이트코드를 실행한다.
문제는 이 한 줄을 외운다고 해서 실제 궁금증이 풀리지 않는다는 것이다. JVM은 바이트코드를 인터프리터로 돌리는 걸까, 아니면 컴파일해서 돌리는 걸까? JIT 컴파일러는 정확히 언제 끼어드는 걸까? 애초에 javac도 실행 전에 컴파일하는 건데, 이것도 AOT 아닌가? GraalVM은 또 뭐가 다르길래 따로 언급되는 걸까? Native Image는 JVM 없이 도는 건가?
나도 처음에는 “JIT는 실행 중 컴파일, AOT는 실행 전 컴파일”이라고 단순 암기하고 넘어갔는데, GraalVM 관련 자료를 읽다가 완전히 막혀버렸다. Graal이 JIT라는 얘기도 있고 AOT라는 얘기도 나오는데, 둘 다 맞는 말이었다. 결국 이 차이를 제대로 이해하려면 Java 코드가 JVM에서 실행되는 전 과정을 한 번 훑어야 한다는 결론에 도달했다. 이 글은 그 과정을 정리한 것이다.
1. javac가 하는 일과 하지 않는 일
Java 프로그램을 실행하는 흐름은 대략 이렇다: javac가 소스 코드를 바이트코드로 바꾸고, JVM이 그 바이트코드를 클래스로 로딩해서, 최종적으로 인터프리터나 JIT 컴파일러를 거쳐 CPU가 실행할 수 있는 기계어가 만들어진다.
javac Main.java
java Main
여기서 javac가 만드는 Main.class에는 CPU가 바로 실행할 수 있는 기계어가 들어있지 않다. JVM 명령어 집합인 바이트코드와 클래스 메타데이터가 들어있을 뿐이다. 그러니까 컴파일은 사실 두 단계로 나눠서 봐야 한다.
- Java 소스 코드 → JVM 바이트코드 (
javac가 담당) - JVM 바이트코드 → 네이티브 기계어 (JIT 또는 AOT가 담당)
넓게 보면 javac도 실행 전에 일어나는 컴파일이니 AOT라고 부를 수야 있다. 하지만 실제로 JVM 성능이나 GraalVM 얘기를 할 때 “JIT 대 AOT”라고 하면 보통 두 번째 단계, 그러니까 바이트코드를 네이티브 코드로 바꾸는 시점을 두고 하는 말이다. JIT는 이걸 실행 중에 하고, AOT는 실행 전에 끝낸다.
2. 바이트코드가 들어오면 무슨 일이 일어나나
java Main을 실행한다고 해서 JVM이 .class 파일 첫 줄부터 곧장 실행하는 건 아니다. 클래스 로더가 클래스 정의를 읽고(Loading), 바이트코드와 타입이 유효한지 검증하고 심볼릭 참조를 실제 참조로 연결하고(Linking), 필요한 시점에 초기화(Initialization)를 수행한다. JVM 명세는 이 세 단계를 명확히 구분해서 정의하고 있다.
이 과정을 통과한 바이트코드가 비로소 실행 엔진에서 실행되는데, 여기서 재미있는 사실이 하나 있다. 바이트코드를 어떻게 실행할지는 JVM 명세가 강제하지 않는다. 인터프리트해도 되고, 곧바로 네이티브 코드로 컴파일해도 되고, 둘을 섞어도 된다. HotSpot은 이 중 인터프리터와 JIT 컴파일러를 함께 쓰는 방식을 택했다.
3. 왜 처음부터 다 컴파일해버리지 않을까
이게 사실 가장 자연스러운 질문이다. 다음 코드를 보자.
public void start() {
loadConfiguration();
initializeServer();
}
public int process(int value) {
return value * 2;
}
loadConfiguration()과 initializeServer()는 앱 시작할 때 딱 한 번 불린다. 반면 process()는 데이터 처리 도중 수억 번 불릴 수도 있다. 만약 JVM이 시작하자마자 모든 메서드를 최고 수준으로 최적화한다면, 시작 시간은 늘어지고 거의 안 쓰이는 코드에까지 컴파일 비용을 쏟아붓게 된다. 게다가 어떤 코드가 진짜 중요한지도 모르는 상태에서 최적화를 해야 한다.
그래서 HotSpot은 처음엔 인터프리터나 낮은 최적화 수준으로 돌리면서 메서드 호출 횟수, 루프 반복 횟수, 분기 비율, 호출 지점에 실제로 들어오는 객체 타입, 예외 발생 빈도 같은 정보를 모은다. 이걸 런타임 프로파일이라고 부른다. 특정 코드가 충분히 자주 실행된다 싶으면 그때 “hot code”로 찍어서 JIT 컴파일 대상으로 넘긴다.
4. C1, C2, 그리고 Tiered Compilation
HotSpot에는 대표적으로 두 종류의 JIT 컴파일러가 있다. C1은 컴파일 자체를 빠르게 끝내서 인터프리터보다 나은 성능을 빨리 확보하는 데 초점을 둔다. C2는 시간을 더 들이는 대신 훨씬 공격적인 최적화를 한다.
실제로는 이보다 세분화되어 있어서 다음과 같은 레벨을 오간다.
- Level 0: Interpreter
- Level 1: C1, 프로파일링 없이 컴파일
- Level 2: C1, 제한적인 프로파일링
- Level 3: C1, 전체 프로파일링
- Level 4: C2, 고수준 최적화
모든 메서드가 반드시 0→1→2→3→4 순서로 흘러가는 건 아니고, 실행 빈도나 컴파일 큐 상태, JVM 옵션에 따라 경로가 갈린다. 이 구조를 Tiered Compilation(계층형 컴파일)이라 부른다.
이게 필요한 이유는 결국 상충하는 두 목표 — “빠르게 시작하고 싶다”와 “오래 도는 코드는 최대한 최적화하고 싶다” — 를 동시에 만족시켜야 하기 때문이다. 처음부터 C2만 쓰면 성능은 좋지만 시작이 느려지고, C1만 쓰면 시작은 빠르지만 장시간 서버 애플리케이션에서 얻을 수 있는 최고 성능이 제한된다. 그래서 가볍게 시작한 다음, 실제로 중요하다고 판명 난 코드에만 비용을 더 투자하는 쪽을 택한 것이다.
5. JIT가 런타임 정보로 하는 일들
JIT의 핵심은 단순히 “실행 중에 기계어를 만든다”는 사실 자체가 아니라, 실제로 프로그램이 어떻게 돌아가는지 관찰한 뒤 그에 맞는 코드를 만들 수 있다는 데 있다. 여기서부터가 재밌는 부분이다.
5.1 인라이닝
int square(int value) {
return value * value;
}
int calculate(int value) {
return square(value) + 1;
}
JIT는 square()의 내용을 호출 지점에 그냥 박아넣을 수 있다.
int calculate(int value) {
return value * value + 1;
}
메서드 호출 비용만 없애는 게 아니라, 메서드 경계가 사라지면서 상수 전파나 불필요한 분기 제거 같은 추가 최적화의 문이 열린다는 게 진짜 이득이다.
5.2 타입을 추측해서 최적화하기
interface Operator {
int execute(int value);
}
class AddOperator implements Operator {
public int execute(int value) {
return value + 1;
}
}
int process(Operator operator, int value) {
return operator.execute(value);
}
정적으로만 보면 operator에 뭐가 들어올지 알 수 없다. AddOperator일 수도, MultiplyOperator일 수도 있다. 그런데 실행을 관찰해보니 이 호출 지점에 항상 AddOperator만 들어온다면? JIT는 이 정보를 근거로 “여긴 사실상 AddOperator만 온다”고 추측하고, 그 메서드를 아예 인라이닝해버린다. 이걸 추측적 최적화(speculative optimization)라고 부른다.
5.3 추측이 틀리면?
문제는 나중에 MultiplyOperator가 등장하는 경우다.
Operator operator = new MultiplyOperator();
이때 JVM이 잘못된 결과를 낼 리는 없다. 대신 기존 최적화가 전제했던 가정이 깨졌으니, 그 최적화 코드를 버리고 더 일반적인 실행 상태로 되돌아간다. 이걸 Deoptimization(Deopt)이라 부른다. JIT는 공격적으로 추측하지만, 추측이 틀렸을 때 안전하게 원상 복구할 수 있는 메타데이터도 같이 들고 다닌다.
5.4 아예 객체를 만들지 않는 최적화
int calculate() {
Point point = new Point(10, 20);
return point.x() + point.y();
}
point는 메서드 밖으로 나가지 않는다. JIT는 이런 걸 분석할 수 있고(Escape Analysis), 객체가 밖으로 탈출하지 않는다고 판단하면 필드 값을 개별 스칼라로 취급해버린다(Scalar Replacement). 개념적으로는 이렇게 바뀌는 셈이다.
int calculate() {
int x = 10;
int y = 20;
return x + y;
}
여기서 흔히 오해하는 게 “Escape Analysis를 하면 객체가 스택에 할당된다”는 건데, 정확히는 좀 다르다. 중요한 건 객체를 스택이든 어디든 “다른 곳에 할당”하는 게 아니라, 애초에 객체 자체를 만들지 않는다는 점이다. 처음 이 개념을 봤을 때 나는 “그럼 스택 할당이랑 뭐가 다른 거지” 하고 한참 헷갈렸는데, 힙 할당이 아예 사라진다는 걸 이해하고 나서야 감이 왔다.
6. 도는 중인 루프도 컴파일할 수 있다 — OSR
for (long i = 0; i < 10_000_000_000L; i++) {
process(i);
}
이 루프가 인터프리터로 실행되기 시작했다고 하자. 메서드가 끝난 다음 호출부터 JIT 코드를 쓴다면, 지금 돌고 있는 이 거대한 루프는 계속 인터프리터로 버텨야 한다. HotSpot은 이걸 그냥 놔두지 않고 OSR(On-Stack Replacement)을 쓴다. 루프 반복 횟수가 임계값을 넘으면 그 자리에서 JIT 컴파일을 하고, 현재 실행 중인 스택 프레임을 최적화된 코드로 통째로 바꿔치기한다. 메서드가 끝나기를 기다릴 필요가 없다는 뜻이다. 장시간 데이터 처리를 하는 엔진에서는 이게 꽤 중요하게 작동한다.
7. Warm-up이 생기는 이유
JIT 기반 JVM은 시작하자마자 최고 성능이 나오지 않는다. 클래스 로딩, 바이트코드 검증, 인터프리터 실행, 프로파일 수집, C1 컴파일, C2 컴파일, 코드 설치, 때로는 Deopt와 재컴파일까지 — 이 모든 게 초반에 몰려 있기 때문이다. 그래서 성능은 시작 직후 낮다가 점점 올라가서 어느 시점에 안정화되는(Steady state) 곡선을 그린다.
Java 벤치마크에서 메서드를 한 번만 실행하고 시간을 재면 왜 부정확한지도 여기서 설명된다. 첫 실행은 인터프리터, 클래스 로딩, JIT 컴파일 비용을 다 뒤집어쓰지만, 그다음부터는 이미 최적화된 코드를 쓰기 때문이다.
8. 그럼 AOT는 정확히 뭘 하는 건가
AOT는 Ahead-Of-Time Compilation의 약자로, JVM 관점에서는 바이트코드를 실행 전에 네이티브 기계어로 바꿔놓는 걸 말한다. 대표적인 게 GraalVM Native Image다.
javac HelloWorld.java
native-image HelloWorld
./helloworld
native-image는 .class나 JAR에 든 바이트코드를 입력받아서, 특정 OS·CPU 아키텍처에서 바로 실행 가능한 네이티브 바이너리를 만들어낸다. 일반적인 HotSpot 실행이 “JAR + JVM이 실행 중 인터프리터·JIT를 쓰는” 구조라면, Native Image는 “미리 컴파일된 실행 파일을 그냥 실행하는” 구조다.
여기서 헷갈리기 쉬운 게 있는데, “JVM이 완전히 사라진다”고 말하면 부정확하다. 정확히는 HotSpot JVM 위에서 바이트코드를 동적으로 실행하는 대신, 필요한 Java 런타임 구성요소를 포함한 별도의 네이티브 실행 환경으로 배포하는 것에 가깝다.
9. GraalVM은 AOT 컴파일러인가 — 절반만 맞는 말
GraalVM을 그냥 “AOT 컴파일러”라고 부르면 절반만 맞다. GraalVM에는 구분해야 할 두 가지가 섞여 있다.
Graal JIT Compiler는 Java로 구현된 동적 JIT 컴파일러다. JVMCI라는 인터페이스로 HotSpot과 연결되고, 실행 중에 바이트코드를 네이티브 코드로 컴파일한다. 개념적으로 기존 HotSpot의 C2 자리에 Graal이 들어간다고 보면 된다.
일반 HotSpot: Interpreter → C1 → C2
GraalVM 실행: Interpreter → C1 → Graal JIT Compiler
이렇게 실행할 때는 여전히 java -jar application.jar로 돌아가는 평범한 JVM 프로세스다. 클래스 로딩, 런타임 프로파일링, Warm-up, Deoptimization, JVM GC까지 다 그대로 있다. 즉 Graal JIT Compiler는 JIT 방식이다.
반면 GraalVM Native Image는 native-image -jar application.jar로 바이너리를 만드는 AOT 방식이다. 같은 GraalVM이라는 이름 아래 있지만, 하나는 JVM 내부의 동적 컴파일러이고 다른 하나는 빌드 시점 네이티브 컴파일 기술이다. 이 둘을 섞어서 이해하면 GraalVM 관련 글을 읽을 때마다 헷갈리게 된다. 나도 처음엔 “그래서 GraalVM 쓰면 무조건 빨라지는 건가” 하고 착각했었는데, 어떤 실행 경로를 타느냐에 따라 완전히 다른 얘기였다.
10. Native Image의 Closed-World Assumption
JIT 기반 JVM은 실행 중에도 새 클래스를 로딩할 수 있다.
Class<?> clazz = Class.forName(className);
Object object = clazz.getDeclaredConstructor().newInstance();
className이 설정 파일이나 네트워크 입력으로 결정되어도, HotSpot은 클래스패스에 그게 있으면 런타임에 그냥 로딩해버린다. 하지만 AOT 컴파일러는 실행하기 전에 어떤 코드가 필요할지 결정해야 하는 입장이라 사정이 다르다. Native Image는 이걸 Closed-World Assumption으로 해결한다 — 실행 파일에 필요한 모든 코드는 빌드 시점에 알려져 있어야 한다는 전제다.
native-image는 정적 분석으로 애플리케이션 진입점에서 도달 가능한 클래스·메서드·필드·리소스를 찾아내고, 도달 가능한 것만 실행 파일에 남긴다. 문제는 Reflection이나 동적 프록시처럼 호출 관계가 코드에 드러나지 않는 경우다.
Class.forName(config.getClassName());
정적 분석기는 config.getClassName()이 런타임에 뭘 반환할지 알 도리가 없다. Native Image 공식 문서도 Reflection, JNI, Dynamic Proxy, 클래스패스 리소스 같은 동적 기능은 정적 분석만으로 다 예측할 수 없으니, 필요하면 Reachability Metadata를 따로 제공하라고 안내한다. 접근할 클래스와 메서드, 생성해야 할 동적 프록시, 포함해야 할 리소스, 직렬화 대상 클래스, JNI 연결 정보 같은 걸 빌드 도구에 알려주는 식이다.
Reflection과 런타임 클래스 로딩을 많이 쓰는 프레임워크일수록 Native Image 대응 작업이 늘어나는 이유가 여기 있다. 최근엔 Spring Boot, Micronaut, Quarkus를 비롯한 여러 라이브러리가 이런 메타데이터를 자동 생성하거나 공식으로 제공하긴 하지만, 결국 실제 애플리케이션 경로가 네이티브 빌드에 제대로 포함됐는지는 직접 테스트해봐야 안다.
11. 실행 시점 작업을 빌드 시점으로 옮기기
Native Image의 특징 중 하나는 일부 작업을 런타임이 아니라 빌드 시점에 미리 처리해버릴 수 있다는 것이다. 대표적으로 클래스 초기화 시점을 build-time과 run-time 중에 고를 수 있다.
class Configuration {
static final Map<String, String> VALUES = loadConfiguration();
}
이 초기화를 빌드 시점에 해버리면 결과가 Native Image의 이미지 힙에 그대로 박히고, 런타임에 다시 파싱할 필요가 없어진다. 다만 빌드 환경에만 있는 값이나 보안 정보가 이미지에 섞여 들어갈 위험도 있어서 무조건 빌드 시 초기화하는 게 능사는 아니다. 그래서 Native Image는 안전하다고 확신할 수 있는 경우를 제외하면 기본적으로 애플리케이션 클래스를 런타임에 초기화하고, 필요할 때만 명시적으로 빌드 시점 초기화를 지정하게 되어 있다.
이게 AOT의 본질을 잘 보여준다. JIT가 “실행하면서 정보를 모아 최적화”한다면, Native Image는 “빌드 단계에서 더 많은 분석과 작업을 미리 끝내서 실행 시점 비용을 줄이는” 쪽이다.
12. 성능은 어느 쪽이 나은가
이걸 “누가 더 빠른가”로 물으면 답하기 어렵다. 적어도 시작 성능(Startup)과 안정화 이후 성능(Steady-state)을 나눠서 봐야 한다.
JIT 기반 JVM은 시작할 때 JVM 초기화, 클래스 로딩, 인터프리터 실행, 프로파일 수집, JIT 컴파일, 코드 캐시 구성 같은 비용을 다 지불한다. 대신 실제 타입 분포, 실제 분기 확률, 실제 hot method 같은 걸 관찰해서 지금 이 워크로드에 맞춘 최적화를 할 수 있다. 장시간 도는 서버나 데이터 엔진이라면 이 Warm-up 비용을 충분히 회수하고도 남는다.
Native Image는 실행할 기계어를 미리 만들어두기 때문에 JIT 컴파일과 Warm-up 과정 자체가 없다. 시작 속도와 초기 메모리 사용량에서 확실히 유리하다. 대신 실행 중 워크로드를 보고 다시 컴파일하는 게 불가능하다. 빌드할 때 예상 못 한 타입이나 분기 패턴이 운영 환경에서 나오면, JIT처럼 거기 맞춰 재최적화할 방법이 없다.
이걸 보완하려고 AOT 쪽에서도 PGO(Profile-Guided Optimization)를 쓸 수 있다. 대략 이런 흐름이다.
- 프로파일 수집용 Native Image를 먼저 만든다
- 대표 워크로드를 돌려서 프로파일을 수집한다
- 그 프로파일을 반영해서 최적화된 Native Image를 다시 빌드한다
다만 이 프로파일은 빌드 전에 수집한 대표 워크로드 기준이라, 실행 중 계속 프로파일을 갱신하는 JIT와는 성격이 다르다. GraalVM 문서도 JIT의 강점으로 “런타임 동작을 관찰할 수 있다”는 점을 꼽고, Native Image에서는 PGO로 이 격차를 부분적으로 메운다고 설명한다.
간단히 표로 정리하면 이렇다.
| 항목 | HotSpot·Graal JIT | GraalVM Native Image |
|---|---|---|
| 네이티브 코드 생성 시점 | 실행 중 | 빌드 중 |
| 실행 형태 | JAR + JVM | 플랫폼별 실행 파일 |
| 시작 속도 | Warm-up 비용 존재 | 대체로 빠름 |
| 런타임 프로파일 | 지속 수집 | 기본적으로 없음 (PGO로 보완) |
| 재컴파일·Deopt | 가능 | 실행 중엔 불가능 |
| 동적 클래스 로딩 | 자연스럽게 지원 | Closed-World 제약 |
| 빌드 시간 | 짧음 | 정적 분석 때문에 길어질 수 있음 |
| 적합한 대상 | 장시간 서버·데이터 엔진 | CLI·서버리스·짧은 프로세스 |
물론 이건 일반적인 경향일 뿐, Native Image가 항상 느리거나 HotSpot이 항상 처리량이 높은 것도 아니다. 실행 시간, 사용 라이브러리, 객체 할당 패턴, GC 설정, I/O 비중, Reflection 사용량, PGO 적용 여부 같은 걸 다 따져봐야 하고, 결국은 실제 애플리케이션으로 직접 측정해보는 수밖에 없다.
13. 그래서 뭘 골라야 하나
장시간 실행되는 API 서버, 데이터베이스, 메시지 브로커, 스트리밍 애플리케이션, 동적 플러그인 시스템 — 이런 건 JIT 쪽에 잘 맞는다. Warm-up 시간을 충분히 확보할 수 있고 같은 코드 경로를 계속 반복 실행하기 때문에, 추측적 최적화가 효과를 제대로 발휘한다. 예를 들어 데이터 처리 엔진에서 특정 Connector나 Serializer 조합이 오래 반복되면, JIT가 그 타입을 관찰해서 인라이닝하는 식으로 이득을 본다.
반대로 CLI 도구, 서버리스 함수, 짧게 도는 배치, 자주 뜨고 죽는 컨테이너라면 얘기가 다르다. 실행 시간이 200ms짜리 CLI를 생각해보면, JIT가 프로파일을 모아서 C2나 Graal 컴파일을 끝내기도 전에 프로세스가 종료돼버린다. 이런 경우엔 런타임 적응 능력보다 빠른 시작, 작은 메모리, JVM 설치 없이 배포 가능한 실행 파일 쪽이 훨씬 중요하다.
Apache SeaTunnel 같은 데이터 엔진을 예로 들어보면 이 구분이 더 명확해진다. 엔진 본체는 장시간 실행되고, 데이터 처리 루프가 반복되고, 동일한 Connector·Operator가 계속 호출되고, 사용자 플러그인을 동적으로 로딩한다 — 전형적으로 JIT에 맞는 특성이다. 엔진 전체를 Native Image로 빌드하려면 Plugin ClassLoader, ServiceLoader/SPI, Reflection, 사용자 정의 Connector, 런타임에 결정되는 직렬화 구현체까지 전부 Closed-World Assumption에 맞춰 메타데이터를 제공해야 하는데, 이건 복잡도가 상당히 올라간다. 반면 설정 검증 CLI나 마이그레이션 도구, 단발성 진단 도구처럼 짧게 실행되는 주변 도구는 Native Image로 만들 만하다. 엔진과 주변 CLI가 같은 컴파일 전략을 쓸 필요는 없다는 뜻이다.
한 가지 오해하기 쉬운 부분을 짚고 넘어가면, AOT로 컴파일한다고 GC가 사라지는 건 아니다. JIT/AOT는 “기계어를 언제 만드는가”의 문제고, GC는 “객체 메모리를 어떻게 회수하는가”의 문제라 서로 다른 축이다. Native Image로 만든 실행 파일도 Java 객체를 계속 생성하기 때문에 그걸 관리할 런타임과 GC가 필요하다. C나 C++처럼 개발자가 직접 free를 호출해야 하는 방식으로 바뀌는 게 아니다.
마무리
정리하면 JIT는 실제 실행을 관찰한 뒤 최적화하고, AOT는 실행이 시작되기 전에 가능한 한 많은 결정을 미리 내려놓는다. GraalVM은 이 둘을 다 제공한다 — Graal JIT Compiler는 HotSpot 안에서 런타임 프로파일을 활용하는 동적 컴파일러고, Native Image는 바이트코드를 실행 전에 네이티브 바이너리로 만드는 AOT 도구다.
그러니 “JIT냐 GraalVM이냐”는 질문 자체가 성립하지 않는다. 정확히는 “GraalVM의 JIT 방식으로 돌릴 것이냐, Native Image로 AOT 바이너리를 만들 것이냐”를 물어야 한다. 장시간 실행되고 동적 기능이 많은 서버·데이터 엔진이라면 JIT가 자연스러운 선택이고, 빠른 시작과 작은 런타임이 중요한 CLI·서버리스·짧은 배치라면 Native Image가 더 맞는다. 둘은 한쪽이 다른 쪽을 완전히 대체하는 관계가 아니라, 시작 성능·최고 처리량·런타임 적응성·빌드 복잡도·동적 기능 지원 사이에서 서로 다른 트레이드오프를 제공하는 관계다.