한 줄 정의
핵심 메시지
JVM 사양은 자바 바이트코드를 인터프리터 방식으로 실행하도록 정의하지만, 인터프리터만으로는 머신 코드 직접 실행에 비해 성능이 떨어집니다.
그래서 핫스팟은 인터프리터 + JIT 컴파일러라는 이중 구조를 채택합니다. 평소에는 바이트코드를 해석하면서 실행 통계를 모으고, 자주 호출되는 메서드(핫스팟)만 골라 네이티브 코드로 컴파일합니다.
이 접근의 진짜 무기는 프로파일 기반 최적화(PGO) 입니다. 런타임에 수집한 실제 실행 패턴을 근거로 최적화하기 때문에, AOT(사전 컴파일)보다 더 공격적인 추측 기반 최적화가 가능합니다. 대신 그 대가로 워밍업 시간과 결정적이지 않은 성능을 감수해야 합니다.
쉽게 말하면
JVM은 두 가지 일을 합니다. 메모리 관리(GC) 와 코드 실행입니다. 4-5장에서 GC를 깊이 다뤘다면, 이번 장은 두 번째 축인 “어떻게 자바 코드가 실제로 CPU 위에서 돌아가는가?”의 이야기입니다.
비유하자면 인터프리터는 통역사, JIT 컴파일러는 번역서입니다. 처음 만난 말은 통역사가 한 줄씩 옮겨주는 게 빠르지만(시작은 빠름), 같은 문장이 반복해서 나오면 아예 번역서를 인쇄해두는 편이 효율적입니다(반복 호출은 컴파일된 코드가 빠름).
핫스팟이 영리한 부분은 모든 코드를 번역서로 만들지 않는다는 점입니다. 책 전체 중 자주 읽히는 챕터만 인쇄하고, 어쩌다 한 번 읽히는 페이지는 통역사에게 맡깁니다. 그리고 어떤 페이지가 자주 읽히는지는 실제로 책을 읽어보지 않으면 알 수 없습니다 — 이것이 프로파일 기반 최적화의 본질입니다.
왜 중요한가?
백엔드 엔지니어에게 JIT는 보통 “그냥 알아서 잘 돌아가는 마법”입니다. 그러나 다음 상황에서는 이 마법의 내부를 알아야 합니다.
워밍업 문제
쿠버네티스에 새 Pod이 뜨고 트래픽을 받기 시작한 직후, 응답 시간이 평소보다 2~3배 느린 현상은 거의 모든 자바 백엔드에서 관찰됩니다. JIT가 아직 핫스팟을 식별하고 컴파일할 시간이 없었기 때문입니다. 이 특성을 모르면 슬로우 스타트(slow start) 전략, readiness probe 지연, 트래픽 분산 비율을 잘못 설정해 장애로 이어집니다.
코드 캐시 고갈
JIT로 컴파일된 코드는 코드 캐시라는 고정 크기 영역에 저장됩니다. 자바 8 기본값은 240MB이고, 이를 초과하면 더 이상 컴파일이 불가능해져 인터프리터로만 실행됩니다. 큰 모놀리식 애플리케이션이나 동적으로 클래스를 많이 로드하는 서비스(예: 핫 리로딩 프레임워크, 멀티 테넌트 서비스)에서는 이 문제가 종종 발생합니다.
클라우드 네이티브 자바의 부상
전통적인 동적 VM 모드는 수 분~수 시간 단위로 떠 있는 서버 를 가정합니다. 그러나 람다·서버리스·짧은 컨테이너 수명 환경에서는 워밍업 비용이 전체 실행 시간의 대부분을 차지하는 역설이 발생합니다. 이 때문에 GraalVM 네이티브 이미지, Project Leyden 같은 사전 컴파일 기술이 다시 주목받습니다.
핵심 내용
6.1 전통적인 자바 애플리케이션 수명 주기
시작 단계 (Startup)
java HelloWorld 한 줄 뒤에서 일어나는 일은 의외로 복잡합니다.
sequenceDiagram participant Shell as 셸 participant Java as $JAVA_HOME/bin/java participant VM as JVM 프로세스 participant Cls as 클래스 로더 Shell->>Java: java HelloWorld 실행 Java->>VM: 단일 프로세스로 시작 VM->>VM: 명령줄 플래그 분석<br/>(-Xmx, GC 옵션 등) VM->>VM: 시스템 조사<br/>(CPU 코어, 메모리, 명령 집합) VM->>VM: C 힙에서 Xmx만큼 메모리 예약 VM->>VM: 메타공간(Metaspace) 초기화 VM->>VM: JNI_CreateJavaVM으로 가상 머신 생성 VM->>VM: GC 스레드/JIT 컴파일 스레드 시작 VM->>Cls: 부트스트래핑 클래스 로드 및 초기화 Cls->>Cls: static {} (clinit) 실행 Cls->>VM: 엔트리포인트 클래스 로드 VM->>VM: main() 실행
여기서 백엔드 관점의 중요한 포인트가 두 가지 있습니다.
첫째, JVM은 자기 자신을 컨테이너 환경에 맞게 자동 조정하려고 합니다. CPU 코어 수에 따라 GC 스레드 수가 정해지고, 메모리 한도에 따라 힙 크기가 결정됩니다. 컨테이너 안에서 이 자동 탐지가 잘못 동작하면 GC 스레드 수 폭증, OOM 등 비정상 거동이 발생합니다.
둘째, 메타공간(Metaspace)은 자바 8에서 PermGen을 대체한 영역으로, 클래스 메타데이터를 저장합니다. 동적 클래스 로딩이 많은 서비스에서는 이 영역이 무제한 증가해 시스템 메모리를 잠식할 수 있어, -XX:MaxMetaspaceSize로 상한을 두는 것이 안전합니다.
안정 상태 (Steady State)
시작 단계가 끝나면 애플리케이션은 다음과 같은 안정 상태로 수렴합니다.
- 애플리케이션에 필요한 클래스가 거의 모두 로드됨
- 자주 호출되는 메서드가 JIT 컴파일러를 통해 네이티브 코드로 변환됨
- 객체 할당 패턴이 정형화되어 GC 패턴이 예측 가능해짐
다만 “안정 상태 = 정적 상태”는 아닙니다. 비최적화(deoptimization)와 재최적화(reoptimization)는 안정 상태에서도 발생하며, 드물게 실행되는 코드 경로가 처음 호출되거나 추측 최적화의 가정이 깨지면 트리거됩니다.
이단계 클래스 로딩
스프링 같은 의존성 주입 프레임워크를 쓰는 애플리케이션은 다른 양상을 보입니다.
- 1단계: 프레임워크 핵심 클래스가 먼저 로드됩니다.
- 2단계: 프레임워크가 애플리케이션 설정을 분석해 필요한 빈 그래프를 결정하고, 그제서야 애플리케이션 코드와 종속성을 로드합니다.
이 패턴은 콜드 스타트가 길어지는 근본 원인입니다. 스프링 부트 애플리케이션이 처음 트래픽을 받기까지 수십 초~수 분이 걸리는 이유는 단순히 클래스 파일을 디스크에서 읽는 비용이 아니라, 이 2단계 분석 과정 자체가 무겁기 때문입니다.
동적 VM 모드
전통적인 자바 애플리케이션 수명 주기를 커뮤니티에서는 동적 VM 모드(dynamic VM mode)라고 부릅니다. 시작 → 안정 상태로의 전환을 전제하며, 안정 상태가 충분히 길게 유지된다는 가정 위에서 모든 최적화가 작동합니다.
이 가정이 깨지는 환경(서버리스, 짧은 수명 컨테이너)에서는 같은 JVM이 부적합해지고, 이것이 GraalVM 네이티브 이미지나 Leyden 같은 새로운 운영 모델이 등장한 배경입니다.
6.2 바이트코드 해석 개요
JVM 인터프리터는 스택 머신(stack machine)입니다. 물리 CPU와 달리 계산용 임시 저장소로 레지스터를 쓰지 않고, 평가 스택(evaluation stack) 위에서 연산을 수행합니다.
JVM의 세 가지 데이터 저장 영역
| 영역 | 범위 | 용도 |
|---|---|---|
| 평가 스택 | 메서드별 | 연산 중인 임시 값 |
| 로컬 변수 | 메서드별 | 메서드 내 변수, 매개변수 |
| 객체 힙 | 메서드 간 / 스레드 간 공유 | 객체 인스턴스 |
스택 머신의 동작 예시
if (x < 3 + 1)을 평가하는 과정을 추적하면 스택 머신의 본질이 드러납니다.
1. x를 스택에 로드 스택: [x]
2. 정수 상수 3을 스택에 로드 스택: [x, 3]
3. 정수 상수 1을 스택에 로드 스택: [x, 3, 1]
4. add 실행 (3 + 1 = 4) 스택: [x, 4]
5. x와 4를 비교 후 분기 스택: []
각 명령어는 스택 맨 위 값(들)을 소비하고 결과를 다시 스택에 푸시하는 단순한 규칙으로 동작합니다. CPU 레지스터 모델보다 표현은 조금 더 길어지지만, 하드웨어 독립성을 얻습니다.
6.2.1 JVM 바이트코드의 설계 원칙
1바이트 연산코드
JVM의 모든 연산 코드는 1바이트(0~255 범위)로 표현되므로 바이트코드(bytecode)라는 이름이 붙었습니다. 자바 23 기준 약 200개의 연산 코드가 사용 중입니다.
타입을 고려한 명령어
iadd(int 덧셈)와 dadd(double 덧셈)는 별도 명령어입니다. 스택 머신은 타입을 자체적으로 추론하지 않고, 명령어가 타입을 명시합니다. 같은 개념의 작업을 위해 명령어가 여러 개씩 필요한 이유입니다.
단축 형태
aload_0은 “현재 객체(this)를 스택에 올린다”는 의미로, 매우 자주 사용되므로 매개변수 없이 1바이트로 표현됩니다. 클래스 파일 크기를 줄이기 위한 설계로, 14.4kbps 모뎀 시대의 흔적이 남아 있습니다.
빅 엔디언
JVM 바이트코드는 빅 엔디언 방식이며, 리틀 엔디언 하드웨어(x86)에서는 변환 비용이 발생합니다.
invokedynamic
자바 1.0 이후 추가된 유일한 새 바이트코드 연산 코드입니다. 자바 7에 도입되어 자바와 동적 언어(JRuby) 지원이 목적이었으나, 자바 8 람다에서 본격 활용되며 자바 언어의 핵심 부분이 되었습니다. 반대로 jsr, ret은 폐기되었습니다.
바이트코드 범주
핵심 범주는 다음 5가지로 정리됩니다.
load / store 범주
| 명령어 | 의미 |
|---|---|
load, store | 로컬 변수 ↔ 스택 |
ldc | 상수 풀에서 스택으로 로드 (문자열, 클래스 리터럴 등) |
const | 작고 일반적인 상수(0, -1 등)를 스택에 로드 |
pop, dup | 스택 조작 |
getfield, putfield | 객체 필드 ↔ 스택 |
getstatic, putstatic | 정적 필드 ↔ 스택 |
ldc와 const의 차이를 명확히 이해해야 합니다. ldc는 클래스 상수 풀에서 임의의 상수를 가져오는 범용 명령이고, const는 iconst_m1, aconst_null처럼 극히 제한된 상수만 1바이트로 표현하는 단축 형식입니다.
산술 연산 범주
add, sub, div, mul, neg, rem, (cast) 등 기본 타입에만 적용되는 순수 스택 연산으로, 매개변수를 받지 않습니다.
흐름 제어 범주
| 명령어 | 의미 |
|---|---|
if 계열 | 조건이 참일 때 분기 |
goto | 무조건 분기 |
return | 메서드 종료, 스택 상단 값을 호출자에게 반환 |
tableswitch, lookupswitch | switch 문 구현 |
if 계열은 매우 다양한 변형이 존재합니다. if_icmpge(if-integer-compare-greater-or-equal) 같은 식으로 비교 타입과 비교 종류에 따라 별도 명령어가 존재합니다.
메서드 호출 범주
자바 가상 머신에서 가장 중요한 범주입니다. 기계어의 단일 call 연산에 해당하는 것이 JVM에는 존재하지 않으며, 호출 종류에 따라 명령어가 분리되어 있습니다.
| 명령어 | 호출 종류 |
|---|---|
invokevirtual | 가상 디스패치 (일반 인스턴스 메서드) |
invokespecial | 특별 디스패치 (생성자, private, super 호출) |
invokeinterface | 인터페이스 메서드 |
invokestatic | 정적 메서드 (수신 객체 없음) |
invokedynamic | 동적 메서드 조회 (람다, 제이루비 등) |
호출 지점(call site)이란 호출자(caller) 안에서 피호출자(callee)가 호출되는 위치입니다. 비정적 호출의 경우 항상 수신 객체(receiver object)가 존재하고, 그 런타임 타입을 수신 타입(receiver type)이라고 부릅니다. 이 구분은 7장에서 다룰 인라인 캐시(inline cache) 최적화의 기반입니다.
플랫폼 연산 코드 범주
| 명령어 | 의미 |
|---|---|
new | 객체 공간 할당 |
newarray, anewarray | 배열 공간 할당 |
arraylength | 배열 길이 |
monitorenter, monitorexit | synchronized 블록의 모니터 잠금/해제 |
6.2.2 세이프포인트와 바이트코드 경계
스택 머신의 또 다른 미덕은 세이프포인트 처리에 자연스럽다는 점입니다. 5장에서 봤듯 세이프포인트는 모든 애플리케이션 스레드가 공유 힙을 수정하지 않는 일관된 상태에 도달하는 지점입니다.
바이트코드 간이 순간은 다음 조건을 만족합니다.
- 평가 스택이 명확한 상태
- 어떤 명령어도 부분적으로 실행 중이지 않음
- JVM 인터프리터 코드(사용자 코드 아님)가 실행 중
따라서 바이트코드 사이가 세이프포인트의 가장 단순한 사례입니다. JIT 컴파일된 메서드는 좀 더 복잡하지만, 동일한 장벽 개념이 컴파일러가 생성한 기계 코드에 삽입됩니다.
6.2.3 핫스팟의 템플릿 인터프리터
단순한 while-switch 인터프리터와 달리, 핫스팟은 템플릿 인터프리터(template interpreter)를 사용해 실행될 때마다 인터프리터를 동적으로 생성합니다. 어셈블리 언어와 네이티브 스택 프레임 구조를 직접 활용해 해석 모드에서도 빠른 실행을 추구합니다.
비공개 바이트코드
핫스팟은 JVM 사양에 없는 자체 바이트코드를 사용합니다. 가장 흥미로운 예가 final 키워드 변경 호환성입니다.
public class A {
public final void fMethod() { /* ... */ }
}
public class CallA {
public void otherMethod(A obj) {
obj.fMethod();
}
}final 메서드 호출을 invokespecial로 컴파일하면, A에서 final을 제거하고 서브클래스 B에서 오버라이드한 순간 잘못된 메서드가 호출됩니다. 이는 리스코프 치환 원칙(LSP)을 위반합니다.
JLS 13.4.17은 이 호환성 문제를 막기 위해 final 메서드 호출도 invokevirtual로 컴파일하도록 정의합니다. 핫스팟은 final이라는 사실을 알기 때문에, 전용 비공개 바이트코드로 정적 바인딩 최적화를 적용합니다 — 사양 호환성과 성능을 동시에 챙기는 우회로입니다.
또 다른 예로, Object 생성자에서 반환되는 시점을 표시하는 비공개 바이트코드가 있습니다. 종료 작업(finalization) 서브시스템에 등록해야 할 정확한 위치를 표시하기 위함이며, 바이트코드 재작성 도구에 대한 방어책 역할도 합니다.
6.3 핫스팟에서의 JIT 컴파일
JIT의 핵심 가치는 단순히 “네이티브 코드라 빠르다”가 아니라 프로파일 기반 최적화(Profile-Guided Optimization, PGO)입니다.
6.3.1 왜 동적 PGO인가?
자바 개발자라면 한 번쯤 이런 의문을 가집니다.
- “컴파일된 코드를 디스크에 저장해서, 애플리케이션을 다시 시작할 때 재사용하면 안 될까?”
- “최적화된 컴파일 작업을 애플리케이션 실행 시마다 반복하는 것은 낭비가 아닌가?”
답은 금융 업계의 비농업 고용 지표(NFP) 비유에서 나옵니다. NFP 발표일은 한 달에 한 번이고, 그날은 거래 시스템에 매우 특이한 트래픽 패턴이 발생합니다. 일반적인 다른 날과 완전히 다른 코드 경로가 핫해집니다.
만약 이전 날에 계산한 최적화를 NFP 발표일에 재사용한다면, 그 최적화는 새로 계산한 것만큼 효과적이지 않을 것입니다. 컴파일된 코드가 가정한 분기 확률과 실제 분기 확률이 다르기 때문에, 잘못된 추측 최적화 → 비최적화 → 재최적화의 비용이 오히려 더 큽니다.
이런 이유로 핫스팟은 프로파일링 정보를 저장하지 않고, JVM 종료 시 폐기합니다. 매 실행마다 처음부터 다시 생성됩니다. 이것이 동적 VM 모드의 본질이며, 같은 애플리케이션을 동일한 입력으로 실행해도 성능이 다르게 나오는 근본 원인입니다.
사전 컴파일 vs PGO
GraalVM·Leyden 같은 프로젝트는 “사전 컴파일을 선택할 것인가, PGO를 사용할 것인가?”라는 새로운 질문을 던집니다. 사전 컴파일은 워밍업 비용을 없애지만 동적 최적화를 포기합니다. 워크로드 특성(콜드 스타트가 빈번한가, 안정 상태가 긴가)에 따라 답이 달라집니다.
6.3.2 JIT 컴파일의 흐름
핫스팟의 JIT 컴파일은 메서드 단위로 작동하며, 가상 함수 테이블(vtable)을 통해 컴파일된 코드와 인터프리터 코드를 전환합니다.
graph LR Interp[인터프리터<br/>실행 중] -->|호출 카운터 임계 초과| Queue[컴파일 큐] Queue --> CT[컴파일러 스레드<br/>백그라운드] CT -->|네이티브 코드 생성| CC[코드 캐시] CC -->|vtable 포인터 업데이트<br/>= 포인터 스위즐링| Native[다음 호출부터<br/>네이티브 실행] Interp -.기존 호출은 끝까지.-> Interp
포인터 스위즐링
가상 함수 테이블 포인터를 갱신해 새 호출을 컴파일된 코드로 보내는 과정을 포인터 스위즐링(pointer swizzling)이라고 부릅니다. 이미 실행 중인 스레드는 현재 작업을 해석 모드로 끝낸 뒤, 다음 호출부터 컴파일된 형태를 사용합니다.
OSR (On-Stack Replacement)
스택 내 교체(OSR)는 메서드 자체가 자주 호출되지는 않지만 그 안의 반복문이 독립적인 메서드였다면 컴파일 대상이 되었을 경우 를 처리합니다. 거대한 main 메서드 안의 무거운 루프가 대표 사례입니다.
6.3.3 C1과 C2, 계층적 컴파일
핫스팟에는 JIT 컴파일러가 두 개 들어 있습니다.
| 컴파일러 | 별칭 | 특성 |
|---|---|---|
| C1 | 클라이언트 컴파일러 | 단순, 빠른 컴파일, 가벼운 최적화 |
| C2 | 서버 컴파일러 | 복잡, 느린 컴파일, 깊은 최적화 |
과거에는 애플리케이션 유형에 따라 둘 중 하나를 선택했지만, 자바 6 이후 계층적 컴파일(tiered compilation)이 도입되어 둘을 함께 쓰는 것이 기본값이 되었습니다.
5단계 컴파일 모델
advancedThresholdPolicy.hpp를 보면 실제로는 다섯 단계로 나뉩니다.
| 단계 | 역할 |
|---|---|
| 0단계 | 인터프리터 |
| 1단계 | C1 전체 최적화 (프로파일링 없음) |
| 2단계 | C1 + 호출/백엣지 카운터 |
| 3단계 | C1 + 전체 프로파일링 |
| 4단계 | C2 |
컴파일 경로
모든 메서드가 모든 단계를 거치는 것은 아닙니다.
| 패스웨이 | 설명 |
|---|---|
| 0 → 3 → 4 | 표준 경로 (인터프리터 → C1 프로파일링 → C2) |
| 0 → 2 → 3 → 4 | C2가 바쁠 때 빠른 C1으로 우회 |
| 0 → 3 → 1 | 트리비얼 메서드 (C2가 의미 없다고 판단해 종료) |
| 0 → 4 | 계층적 컴파일 없이 바로 C2 |
성능 엔지니어가 PrintCompilation 출력을 해석할 때 같은 메서드가 여러 번 컴파일되는 이유가 바로 이 다단계 구조 때문입니다.
단일 정적 할당 (SSA)
C1과 C2 모두 단일 정적 할당(single static assignment) 기법을 사용합니다. 프로그램의 모든 변수를 final로 선언한 것처럼 보이게 재작성하는 방식으로, 데이터 흐름 분석과 최적화에 유리합니다.
6.3.4 코드 캐시
JIT로 컴파일된 코드는 코드 캐시(code cache)에 저장됩니다. 이 영역에는 인터프리터의 일부 네이티브 코드도 포함됩니다.
코드 캐시의 특성
코드 캐시는 시작 시 고정 크기로 설정되며 동적 확장이 불가능합니다. 가득 차면 더 이상 JIT 컴파일이 불가능해지고, 새 메서드는 인터프리터로만 실행됩니다.
자바 8 리눅스 x86-64 기본값:
240MB 계층적 컴파일 활성화 (-XX:+TieredCompilation, 기본값)
48MB 계층적 컴파일 비활성화
조정은 -XX:ReservedCodeCacheSize=<n> 옵션으로 가능합니다.
단편화와 세분화된 코드 캐시
C1으로 컴파일된 중간 단계 코드가 C2 코드로 대체되며 제거되는 과정이 반복되면 단편화(fragmentation)가 발생합니다. 압축 단계가 없는 GC와 동일한 문제입니다.
자바 9에서 JEP 197로 도입된 세분화된 코드 캐시(segmented code cache)는 이 문제를 해결합니다.
| 영역 | 옵션 | 용도 |
|---|---|---|
| 비메서드 코드 힙 | -XX:NonMethodCodeHeapSize | 인터프리터 등 메서드가 아닌 컴파일 코드 |
| 프로파일 코드 힙 | -XX:ProfiledCodeHeapSize | C1 등 짧은 수명의 가볍게 최적화된 코드 |
| 비프로파일 코드 힙 | -XX:NonProfiledCodeHeapSize | C2 등 긴 수명의 완전 최적화 코드 |
수명 주기가 비슷한 코드끼리 묶으면 단편화가 줄고, 코드 지역성(locality)이 좋아져 CPU 캐시 효율이 향상됩니다.
크기 조정은 신중히
코드 캐시 크기는 고정되어 있어, 애플리케이션과 충분히 테스트하지 않고 조정하면 예상치 못한 문제가 발생합니다. 비메서드 영역을 너무 작게 설정하면 인터프리터 코드 자체가 들어가지 못해 JVM이 시작되지 않을 수 있습니다.
스위퍼
네이티브 코드가 제거되면 해당 블록은 자유 리스트에 추가됩니다. 스위퍼(sweeper) 프로세스가 이 블록들을 재활용합니다. 다음 상황에서 네이티브 코드가 제거됩니다.
- 비최적화 (추측 최적화의 가정이 깨짐)
- 다른 컴파일된 버전으로 대체 (계층적 컴파일에서 C1 → C2 승격)
- 해당 메서드를 포함하는 클래스가 언로드됨
6.3.5 JIT 컴파일 로깅
성능 엔지니어가 가장 먼저 켜는 옵션은 다음 두 가지입니다.
-XX:+PrintCompilation
50 1 3 java.lang.Object::<init> (1 bytes)
55 2 3 java.lang.String::hashCode (60 bytes)
59 12 3 java.lang.module.ModuleDescriptor$Exports::<init> (20 bytes)
59 1 3 java.lang.Object::<init> (1 bytes) made not entrant
각 컬럼의 의미는 다음과 같습니다.
| 컬럼 | 의미 |
|---|---|
| 첫 번째 | 컴파일 시점 (밀리초, JVM 시작 이후) |
| 두 번째 | 컴파일 ID (몇 번째 컴파일인가) |
| 세 번째 | 단계 (3 = C1 프로파일링, 4 = C2 등) |
| 네 번째 | 메서드 시그니처와 크기 |
추가 플래그도 자주 등장합니다.
| 플래그 | 의미 |
|---|---|
n | 네이티브 메서드 |
s | 동기화 메서드 |
! | 예외 처리기 포함 |
% | OSR로 컴파일됨 |
made not entrant | 더 나은 버전으로 대체되어 이제 호출되지 않음 |
같은 메서드가 단계 3에서 컴파일된 후 단계 4에서 다시 등장하고, 단계 3 버전이 made not entrant로 표시되는 패턴이 계층적 컴파일의 정상 동작입니다.
-XX:+LogCompilation
-XX:+UnlockDiagnosticVMOptions와 함께 사용하며, 컴파일 결정 과정을 XML로 상세 기록합니다. 수백 MB에 달하는 파일이 생성되므로 JITWatch 같은 도구로 해석하는 것이 일반적입니다.
6.3.6 간단한 JIT 튜닝 체크리스트
JIT 컴파일 튜닝의 기본 원칙은 “컴파일이 필요한 메서드가 모두 필요한 리소스를 제공받아야 한다” 입니다.
PrintCompilation플래그를 켜고 애플리케이션 실행- 컴파일된 메서드 로그 수집
ReservedCodeCacheSize옵션으로 코드 캐시 크기 증가- 애플리케이션 재실행
- 확장된 캐시로 컴파일된 메서드 수 변화 확인
판단 기준 두 가지를 함께 봐야 합니다.
- 캐시 크기를 늘리면 컴파일된 메서드 수가 유의미하게 증가하는가? — 증가하지 않는다면 코드 캐시는 부족하지 않은 것입니다.
- 주요 트랜잭션 경로의 메서드가 모두 컴파일되고 있는가? — 핫 경로 메서드가 컴파일 로그에 없다면 그 원인을 찾는 것이 다음 단계입니다.
PGO의 비결정성
컴파일된 메서드 목록은 실행마다 약간씩 달라집니다. PGO의 동적 특성 때문에 발생하는 자연스러운 현상이며 크게 신경 쓸 필요는 없습니다. 그러나 성능 측정 시에는 워밍업을 충분히 거친 후 측정해야 한다는 의미이기도 합니다.
6.4 자바 프로그램 실행의 진화
자바 애플리케이션의 전형적인 수명 주기는 다음과 같습니다.
graph LR Boot[부트스트랩 자바<br/>가상 머신 프로세스] --> Load[앱 로드] Load --> Prep[준비 과정] Prep --> Steady[안정 상태] Steady --> Shutdown[셧다운]
이 모델은 JIT가 도입된 이후 자바 커뮤니티의 표준 사고 모델로 자리 잡았습니다. 다만 한 가지 한계가 있습니다 — 안정 상태(자바 가상 머신 워밍업 상태)에 도달하기까지 실행 속도가 느리다는 점입니다. 이 전환 시간은 애플리케이션 시작 후 수십 초까지 늘어날 수 있습니다.
장시간 실행되는 애플리케이션에서는 큰 문제가 되지 않습니다. JIT 컴파일된 코드의 이점이 초기 생성 비용을 훨씬 능가하기 때문입니다. 하지만 클라우드 네이티브 환경에서는 프로세스가 훨씬 짧은 시간 동안만 실행되는 경우가 많고, 그 결과 다음과 같은 질문이 제기됩니다.
- 자바의 시작 시간과 JIT 컴파일 비용을 감안할 때, 이 비용이 실제로 가치 있는 상황은 무엇인가?
- 자바 애플리케이션의 시작 속도를 높이고 이러한 비용을 줄이기 위해 무엇을 할 수 있을까?
- 많은 클라우드 네이티브 워크로드에서 실제로 필요하지 않은 동적 기능을 지원하기 위해 과도한 리소스(특히 메모리)를 사용하는 것은 아닌가?
이 질문에 답하기 위해 먼저 AOT 컴파일을 살펴보고, 이어서 이러한 질문을 염두에 두고 설계된 현대적인 프레임워크인 쿼커스(Quarkus)를 살펴봅니다.
6.4.1 AOT 컴파일
AOT(Ahead-of-Time) 컴파일은 C나 C++ 경험이 있다면 익숙한 개념입니다. 컴파일러가 사람이 읽기 쉬운 소스를 받아 바로 실행 가능한 기계어 코드로 변환하는 과정입니다.
단 한 번의 최적화 기회
AOT 컴파일은 소스 코드를 미리 컴파일하면서 단 한 번의 최적화 기회만 제공합니다.
플랫폼 일반화의 비용
보통은 실행하려는 플랫폼과 프로세서 아키텍처에 맞춘 실행 파일을 생성합니다. 이렇게 타게팅된 바이너리는 특정 프로세서 기능을 활용하여 프로그램의 속도를 높일 수 있습니다.
그러나 대부분의 경우 실행 파일은 실행될 플랫폼에 대한 구체적인 정보를 모른 채 생성됩니다. 이 때문에 AOT 컴파일은 보수적인 선택을 강요받습니다. 특정 기능을 사용할 수 있다는 가정이 틀릴 경우, 바이너리가 아예 실행되지 않을 수 있기 때문입니다. 결과적으로 AOT 컴파일된 바이너리는 CPU의 모든 성능을 활용하지 못해 성능 향상의 기회를 놓치는 경우가 많습니다.
핫스팟 JIT의 우위
핫스팟의 고급 JIT 컴파일러에는 이러한 제한이 없습니다. 실행 시 CPU를 검사하여 사용 가능한 명령어를 확인하고, 이를 기반으로 자바 가상 머신은 프로세서 고유의 최적화(컴파일러 고유 기능, intrinsics)를 활성화해 JIT 컴파일러를 실제 실행 환경에 맞게 조정합니다. 이렇게 하면 자바 애플리케이션이 코드를 재컴파일하지 않고도 자바 가상 머신 업그레이드만으로 성능 향상을 얻을 수 있습니다.
동적 기능과 AOT의 충돌
전통적으로 자바 애플리케이션은 AOT 컴파일되지 않았습니다. 가장 큰 이유는 자바 가상 머신이 실제로 매우 동적인 실행 환경이라는 점입니다. 이는 자바가 정적 언어라는 개발자들의 일반적인 인식과 대조됩니다.
특히 리플렉션(reflection)은 자바에서 광범위하게 사용되는 동적 메커니즘으로, 일반적인 프레임워크와 개발 도구(디버거, 코드 브라우저)가 이 메커니즘에 의존합니다. 기본적으로 자바 애플리케이션은 컴파일 시점에 이름이 알려지지 않은 클래스와 메서드를 로드하고 호출하기 위해 리플렉션을 사용할 수 있습니다. 이는 매우 강력한 기능으로 개방적이고 동적인 시스템을 구현할 수 있게 해 주지만, AOT 컴파일과 같이 더 캡슐화된 시스템과는 충돌할 수 있습니다.
따라서 자바를 위한 AOT 스키마는 결국 리플렉션이나 기타 동적 기술이 자바 생태계 전반에 걸쳐 있다는 사실을 정면으로 다뤄야 합니다.
메커니즘이 아니라 결과가 목표
마지막으로 중요한 관점이 있습니다. AOT 컴파일은 하나의 메커니즘일 뿐이고, 애플리케이션 소유자와 SRE가 실제로 관심을 가지는 것은 애플리케이션의 결과(즉, 성능)입니다. 기술자들은 둘을 자주 혼동하는데, 특히 AOT 컴파일이 지적인 도전 과제를 제공할 때 더욱 그렇습니다. 클라우드 네이티브 배포에 중점을 두고 AOT 컴파일을 활용할 수 있지만 반드시 필요로 하지는 않는 쿼커스를 살펴보면 이 구분이 명확해집니다.
6.4.2 쿼커스
쿼커스(Quarkus)는 레드햇과 커뮤니티가 공동 개발한 자바 프레임워크로, 클라우드 네이티브 환경(마이크로서비스 또는 서버리스 애플리케이션)을 위해 설계되었습니다. 빠른 시작 속도와 개발자 생산성을 높이는 데 최적화되어 있습니다.
쿼커스는 스스로를 “OpenJDK 핫스팟 또는 그랄VM에 최적화된 쿠버네티스 네이티브 자바 스택”이라고 소개합니다. Jakarta EE 또는 MicroProfile API 같은 표준을 구현하고 OpenTelemetry 같은 신기술 표준도 지원합니다.
빌드 단계의 도입
쿼커스의 주요 특징 중 하나는 자바 애플리케이션의 수명 주기에 빌드 단계(build phase)를 추가한 점입니다. 이 단계의 목적은 일반적으로 애플리케이션 시작 시점에 수행되는 작업의 상당 부분을 미리 처리하여, 계산 작업을 실행 시점에서 컴파일 시점으로 옮기는 것입니다.
예를 들어 쿼커스는 의존성 주입을 위해 ArC라는 CDI 기반 라이브러리를 사용하는데, ArC는 쿼커스 애플리케이션의 수명 주기에 맞춰 설계되었습니다. 또한 클래스패스 어노테이션 스캐닝 같은 작업을 실행 시점에서 빌드 시점으로 이동(이른바 “왼쪽으로 이동”, shift-left)시킵니다. 이를 위해 모든 의존성을 빌드 시점에 선언해야 하며, 쿼커스는 이를 지원하기 위해 Jandex라는 인덱싱 기능을 제공합니다. 그리고 Gizmo라는 바이트코드 생성 라이브러리를 활용해 리플렉션 사용을 줄이거나 제거합니다.
컨테이너 환경과의 정합성
이러한 접근은 클라우드 네이티브 환경에서 매우 적합합니다. 애플리케이션은 주로 컨테이너로 배포되며, 컨테이너는 실행 시점에 새로운 의존성을 추가하지 않기 때문에 불변 상태를 유지합니다. 게다가 이처럼 정적인 코드를 생성하면 C2 JIT가 더 효율적으로 최적화를 수행할 수 있어 성능 향상에 기여합니다.
두 가지 실행 모드
프로덕션 환경에서 쿼커스는 두 가지 모드로 실행될 수 있습니다.
| 모드 | 기반 | 특징 |
|---|---|---|
| 동적 가상 머신 모드 | 핫스팟 자바 가상 머신 | 전통적인 방식, JIT 컴파일 활용 |
| 네이티브 모드 | 그랄VM 네이티브 이미지 컴파일러 | AOT 기능 사용 |
graph LR App[앱 빌드] --> Container[컨테이너 빌드] Container --> JVMMode[자바 가상 머신 모드로 배포<br/>핫스팟 JIT] Container -.AOT.-> Native[네이티브 모드로 배포<br/>그랄VM 네이티브 이미지]
많은 개발자가 쿼커스 애플리케이션의 빠른 시작 속도를 위해 네이티브 모드가 반드시 필요하다고 생각합니다. 하지만 실제로는 시작 시점에 필요한 많은 계산 작업(예: 객체 의존성 그래프 구축)을 빌드 시점으로 옮길 수 있습니다.
실제 시작 속도 비교
REST 서비스를 시작하고 요청에 응답하는 데 걸리는 시간을 비교한 데이터입니다.
| 스택 | 시작 시간 |
|---|---|
| 전통적인 스택 (자바 + JIT) | 약 4.3초 |
| 쿼커스 + JIT | 0.943초 |
| 쿼커스 + 네이티브 컴파일 | 0.016초 |
쿼커스는 가능한 모든 작업을 빌드 시점으로 이동시키는 접근을 통해, 동적 가상 머신 모드에서도 전통적인 자바 시스템보다 훨씬 빠르게 시작됩니다. 네이티브 모드는 추가적인 성능 향상을 제공할 수 있지만, 그 정도는 애플리케이션의 특성에 따라 다릅니다. 많은 팀이 쿼커스의 동적 가상 머신 모드에서 충분히 빠른 시작 속도와 뛰어난 성능을 경험하며, 네이티브 모드를 위해 복잡성을 추가할 필요성을 느끼지 않습니다.
개발자 경험
쿼커스가 강조하는 또 다른 축은 개발자 경험입니다. 예컨대 로컬 애플리케이션의 경우 ./mvnw quarkus:dev 명령만 실행하면 라이브 리로드와 백그라운드 컴파일 기능이 함께 시작됩니다. 자바 파일이나 리소스 파일(구성 파일 포함)을 수정하고 브라우저를 새로 고침하면 변경 내용이 자동으로 적용됩니다.
쿼커스 프레임워크는 명령형과 반응형 두 가지 스타일의 애플리케이션 개발을 모두 지원합니다. 그래서 스프링 부트(Spring Boot) 같은 명령형 모델을 사용하던 팀도 쉽게 쿼커스로 전환할 수 있습니다. 애플리케이션에 높은 확장성이 필요하면, 쿼커스는 반응형(논블로킹) 프로그래밍을 사용할 수 있는 기능을 제공합니다.
6.4.3 그랄VM
그랄VM(GraalVM)은 “자바 또는 기타 자바 가상 머신 언어로 작성된 애플리케이션의 실행 성능을 가속화하기 위해 설계된 고성능 JDK”로 소개됩니다.
오라클은 그랄VM을 자사의 연구 부서에서 개발한 뒤 제품으로 출시했으며, 현재 두 가지 배포판을 제공합니다.
| 배포판 | 라이선스 |
|---|---|
| 오라클 그랄VM 커뮤니티 에디션(CE) | 오픈 소스 |
| 오라클 그랄VM 엔터프라이즈 에디션(EE) | 유료 라이선스 필요 |
트러플과 네이티브 이미지
그랄VM에는 자바로 작성된 트러플(Truffle)이라는 언어 구현 프레임워크가 포함되어 있습니다. 트러플은 다양한 프로그래밍 언어의 인터프리터를 생성하며, 이 인터프리터는 그랄VM 위에서 실행됩니다.
트러플도 흥미로운 기술이지만, 더 주목할 점은 네이티브 이미지(native image) 기능입니다. 이는 그랄 컴파일러(JIT와 AOT 컴파일러로 모두 작동 가능)를 통해 구현된 그랄VM의 주요 기능입니다. 그랄VM의 네이티브 이미지는 지속적으로 발전해 왔으며, 현재 많은 팀이 자바 애플리케이션의 빠른 시작 속도를 위해 이를 활용하고 있습니다.
리플렉션 처리의 까다로움
다만 한 가지 중요한 주의사항이 있습니다. 네이티브 이미지를 빌드하려면 리플렉션으로 접근되는 프로그램 요소를 빌드 시점에 알아야 합니다. 그랄VM은 리플렉션 API 호출을 감지하는 정적 분석으로 이를 자동으로 잡으려고 하지만, 분석이 실패하거나 리플렉션 경로가 너무 복잡하면 빌드 스크립트에서 실행시 리플렉션으로 접근될 요소를 수동으로 지정해야 합니다. 이 작업이 그랄VM을 직접 사용할 때 가장 큰 진입 장벽입니다.
쿼커스와 그랄VM의 결합
쿼커스는 그랄VM의 네이티브 이미지 기능을 활용해 네이티브 실행 파일을 생성할 수 있습니다. 많은 개발자와 팀은 쿼커스를 통해 네이티브 모드를 사용하는 것이, 단독으로 그랄VM을 사용하는 것보다 더 쉽다고 느낍니다. 가장 큰 이유는 쿼커스가 이미 라이브러리를 네이티브 모드에서 작동하도록 조정해 두었기 때문입니다. 프레임워크 없이 그랄VM을 직접 사용해 프로덕션 품질의 빌드를 만드는 것은 까다로울 수 있습니다.
맨드릴
레드햇은 맨드릴(Mandrel)이라는 그랄VM CE의 하위 분포판 사용을 권장합니다. 맨드릴은 쿼커스 애플리케이션과 함께 사용하도록 최적화되어 있어, 쿼커스 + 네이티브 이미지 조합에서 안정적인 빌드를 만들 수 있습니다.
6.5 정리
자바 가상 머신의 초기 코드 실행 환경은 바이트코드 인터프리터입니다. 이를 제대로 이해하기 위해 바이트코드의 기본 개념과 JIT 컴파일의 기본 이론을 살펴보았습니다.
대부분의 성능 최적화 작업에서는 인터프리터의 동작보다 JIT 컴파일된 코드의 동작이 훨씬 더 중요합니다. 많은 애플리케이션에서는 이 장에서 다룬 코드 캐시 튜닝만으로도 충분할 수 있지만, 성능에 특히 민감한 애플리케이션은 JIT 동작을 더 깊이 분석해야 할 수도 있습니다.
또한 클라우드 네이티브 환경에서의 실행 방식, 특히 클라우드에 배포된 자바 애플리케이션의 수명 주기와 관련된 개념(AOT 컴파일·쿼커스·그랄VM)도 살펴보았습니다. AOT 컴파일은 워밍업 비용이 큰 문제로 작용하는 환경에 답을 주지만, 그 자체가 목적이 아니라 결과(성능)를 얻기 위한 메커니즘 중 하나라는 관점을 잊지 말아야 합니다.
비교 / 트레이드오프
인터프리터 vs JIT 컴파일
| 항목 | 인터프리터 | JIT 컴파일 |
|---|---|---|
| 시작 비용 | 0 | 컴파일 시간 + 프로파일링 비용 |
| 실행 속도 | 느림 | 빠름 (네이티브 코드) |
| 메모리 | 적음 | 코드 캐시 필요 |
| 최적화 깊이 | 없음 | 깊음 (PGO 기반) |
| 결정성 | 결정적 | 비결정적 |
AOT vs JIT
| 항목 | AOT (GraalVM, Leyden) | JIT (전통 핫스팟) |
|---|---|---|
| 콜드 스타트 | 빠름 | 느림 |
| 안정 상태 성능 | 보통 | 빠름 (PGO 효과) |
| 메모리 사용량 | 낮음 | 높음 |
| 배포 크기 | 작음 (필요한 코드만) | 큼 (JVM 포함) |
| 동적 기능 | 제한적 (리플렉션 등) | 풍부함 |
| 적합한 환경 | 서버리스, 짧은 수명 컨테이너 | 장기 실행 서버 |
C1 vs C2
| 항목 | C1 | C2 |
|---|---|---|
| 컴파일 속도 | 빠름 | 느림 |
| 최적화 수준 | 가벼움 | 깊음 |
| 사용 시점 | 워밍업 초중반 | 안정 상태 |
| 리소스 비용 | 낮음 | 높음 |
내 생각
JVM은 “데이터 기반 최적화”의 원조
JIT 컴파일러가 하는 일은 결국 “실제 트래픽 패턴을 데이터로 모아서, 거기에 맞게 코드를 다시 만든다” 입니다. 이는 현대적인 데이터 기반 시스템 설계와 본질적으로 같습니다. 통계를 모으는 비용, 잘못된 추측의 비용, 재최적화의 비용을 어떻게 균형 잡을 것인가 — 이 질문은 캐싱 전략, 인덱스 추천 엔진, A/B 테스트 플랫폼 설계에서도 동일하게 등장합니다.
워밍업의 비용은 SRE 문제
JIT의 동작 원리를 알면 다음과 같은 SRE 결정의 근거가 명확해집니다.
- 블루-그린 배포에서 readiness probe 대기 시간: 트래픽을 즉시 받으면 워밍업되지 않은 인스턴스가 SLA를 깹니다. JVM 시작 후 일정 시간(예: 30~60초) 동안 합성 트래픽을 흘려 워밍업한 뒤 배포 완료를 선언하는 것이 안전합니다.
- 카나리 배포에서 점진적 트래픽 비율 증가: 1% → 5% → 25% → 100%의 램프업 패턴은 사용자 보호뿐 아니라 JIT 워밍업 시간 확보 측면에서도 의미가 있습니다.
- 재시작 후 응답 시간 알림 임계값: 재시작 직후 응답 시간은 평소보다 높게 나오는 것이 정상이므로, 알림에 “워밍업 그레이스 기간”이 필요합니다.
코드 캐시 모니터링은 누락하기 쉽다
힙 사용량, GC 시간은 모두 모니터링하지만 코드 캐시 사용률 을 추적하는 팀은 드뭅니다. 자바 에이전트가 많이 붙거나(APM, 보안, 디버거), 동적 클래스 생성이 많은 환경(스크립팅, AOP, 프록시 다수)에서는 코드 캐시 고갈이 조용히 성능을 깎아먹습니다. java.lang.management.MemoryPoolMXBean으로 노출되므로 JMX/Prometheus 메트릭에 추가할 가치가 있습니다.
사전 컴파일은 만능이 아니다
서버리스가 부상하면서 GraalVM 네이티브 이미지가 자주 언급되지만, PGO를 포기하는 것은 분명한 트레이드오프 입니다. 장기 실행 서비스에서는 핫스팟 JIT가 만들어내는 PGO 최적화 코드가 사전 컴파일된 코드보다 더 빠를 가능성이 높습니다. “어떤 워크로드에서 안정 상태가 충분히 긴가?”를 측정하고 답해야 합니다.
더 알아볼 것
- JITWatch로 LogCompilation 출력 분석해 보기
-
jcmd <pid> Compiler.codecache명령으로 실제 코드 캐시 사용량 확인 - GraalVM 네이티브 이미지로 같은 애플리케이션을 빌드해 콜드 스타트/안정 상태 성능 비교
- 프로덕션 환경에서 PrintCompilation 출력 일부를 캡처해 핫 메서드 식별
- OSR이 트리거되는 거대한 루프 패턴을 의도적으로 만들어
%플래그 관찰 -
-XX:+UseEpsilonGC -XX:+AlwaysPreTouch로 GC와 메모리 변수를 통제한 마이크로벤치마크에서 JIT 효과 분리 측정
관련 개념
- Ch03 자바 가상 머신 개요 — 전체 아키텍처 맥락
- Ch04 가비지 컬렉션 이해하기 — 같은 “동적 런타임”의 두 축
- Ch05 고급 가비지 컬렉션 — 세이프포인트 개념 공유
- 프로파일 기반 최적화
- JIT 컴파일러
- GraalVM
출처
자바 최적화 2판 (오라일리), Ben Evans 외 저, Chapter 6: Code Execution on the JVM