GraalVM AOT 컴파일 — 미리 컴파일하는 자바
AOT와 JIT의 근본적 차이, 폐쇄 세계 AOT가 포기하는 것과 얻는 것, 피크 처리량을 되찾는 PGO, JIT 추측적 최적화와의 관계, 제거된 jaotc와 Project Leyden의 방향, AOT가 실제로 유리한 상황을 정리합니다.
지난 글에서 native-image 도구가 폐쇄 세계 가정을 바탕으로 자바 바이트코드를 네이티브 실행 파일로 변환하는 과정을 살펴봤습니다. 이번 글에서는 그 근간이 되는 AOT(Ahead-Of-Time) 컴파일 을 좀 더 깊이 파고듭니다. AOT와 JIT가 근본적으로 무엇이 다른지, AOT가 무엇을 포기하고 무엇을 얻는지, 그리고 포기한 성능을 PGO로 얼마나 되찾을 수 있는지 살펴봅니다.
AOT와 JIT — 근본적 차이
컴파일은 “언제” 하느냐에 따라 크게 두 가지로 나뉩니다.
JIT(Just-In-Time) 는 런타임에 바이트코드를 기계어로 컴파일합니다. HotSpot JVM이 이 방식입니다. 처음에는 인터프리터로 바이트코드를 실행하면서 어느 메서드가 “핫(hot)“한지 프로파일을 수집하고, 일정 임계치를 넘으면 C1(클라이언트) 또는 C2(서버) 컴파일러로 기계어를 만들어 교체합니다.
AOT(Ahead-Of-Time) 는 실행 전 빌드 시점에 이미 기계어를 만들어 놓습니다. GraalVM Native Image가 이 방식입니다. 런타임에 컴파일 부담이 없으므로 처음부터 기계어로 실행되고, 추가적인 컴파일러 스레드나 코드 캐시도 필요 없습니다.
핵심 트레이드오프를 한 문장으로 정리하면 이렇습니다. AOT는 빠른 기동과 예측 가능한 성능을 얻는 대신 피크 처리량을 일부 포기하고, JIT는 높은 피크 처리량을 얻는 대신 워밍업 지연을 감수합니다.
AOT가 포기하는 것 — 추측적 최적화
JIT가 AOT보다 때로 더 빠른 피크 성능을 내는 이유는 추측적 최적화(speculative optimization) 덕분입니다. JIT는 런타임에 수집한 프로파일 정보를 기반으로 “아마도 이 가상 메서드는 항상 이 구현체를 호출하겠지”라고 가정하고 인라이닝을 공격적으로 합니다. 가정이 틀리면 역최적화(deoptimization) 를 통해 인터프리터로 돌아가 재컴파일합니다.
AOT는 이런 가정을 할 수 없습니다. 폐쇄 세계 내에서 정적으로 확인된 사실만 최적화에 쓸 수 있습니다. 결국 다형성이 많은 코드, 인터페이스를 통한 호출이 많은 코드에서는 JIT보다 성능이 낮게 나올 수 있습니다.
AOT가 얻는 것 — 기동 시간과 메모리
반대로 AOT가 명확히 이기는 영역이 있습니다.
기동 시간: JVM 기반 앱은 클래스 로딩, 바이트코드 검증, JIT 워밍업 등 수 초의 준비 시간이 필요합니다. Native Image 실행 파일은 이 모든 단계를 건너뛰고 바로 기계어를 실행하므로 수십 밀리초 만에 첫 요청을 처리할 수 있습니다.
메모리: JVM 자체(JIT 컴파일러 스레드, 코드 캐시, 클래스 메타데이터 등)가 상당한 메모리를 차지합니다. Native Image는 이 오버헤드가 없고, 사용하지 않는 클래스를 이미 제거했으므로 힙 사용량도 작습니다.
예측 가능성: 워밍업이 없으므로 첫 요청부터 성능이 안정적입니다. JIT 기반 시스템에서 캐노니컬 워밍업 시간 동안 지연이 튀는 현상이 없습니다.
PGO — 포기한 처리량 되찾기
AOT의 처리량 약점을 보완하는 것이 PGO(Profile-Guided Optimization) 입니다. “런타임 프로파일 없이 어떻게 최적화하느냐”는 AOT의 약점을 “미리 프로파일을 측정하고 그 결과를 빌드에 피드백하자”는 방식으로 해결합니다.
GraalVM Native Image의 PGO는 세 단계로 진행됩니다.
1단계 — 인스트루먼트 빌드: --pgo-instrument 플래그로 프로파일 수집 코드가 삽입된 이미지를 빌드합니다.
2단계 — 프로파일 수집: 인스트루먼트 이미지를 실제 또는 대표적인 워크로드로 실행합니다. 실행이 끝나면 default.iprof 파일이 생성됩니다.
3단계 — PGO 빌드: --pgo=default.iprof를 지정해 최종 이미지를 빌드합니다. 빌더는 프로파일 정보를 참고해 핫 경로를 적극적으로 인라이닝하고 최적화합니다.
# 1단계: 인스트루먼트 이미지 빌드
native-image --pgo-instrument -jar myapp.jar -o myapp-instr
# 2단계: 대표 워크로드 실행 (iprof 파일 생성)
./myapp-instr
# 3단계: 프로파일 기반 최적화 빌드
native-image --pgo=default.iprof -jar myapp.jar -o myapp-pgo
PGO를 적용하면 처리량이 최대 30~40% 향상되는 벤치마크도 보고됩니다. JIT만큼의 피크 성능에 도달하기는 어렵지만, 기동 속도와 메모리 이점을 유지하면서 처리량 격차를 상당히 줄일 수 있습니다.
jaotc의 역사와 제거
자바 표준 JDK에도 AOT 컴파일 시도가 있었습니다. JDK 9에서 jaotc라는 도구가 실험적으로 도입됐는데, 특정 클래스를 미리 컴파일한 네이티브 코드를 .so 라이브러리로 만들어 JVM이 로드하는 방식이었습니다. 그러나 JVM의 동적 특성과 충돌이 많았고, 관리 부담에 비해 효과가 제한적이라는 평가를 받아 JDK 17에서 제거됐습니다.
Project Leyden — JVM 수준 AOT의 새 방향
jaotc의 제거 이후 OpenJDK의 Project Leyden 이 표준 자바에서의 기동 시간 및 워밍업 개선을 이어받고 있습니다. Leyden은 Native Image처럼 JVM을 완전히 없애는 방향이 아니라, CDS(Class Data Sharing), AOT 메서드 컴파일, JIT 워밍업 상태 캡처 등을 JVM 수준에서 통합해 기존 자바 호환성을 유지하면서 기동을 빠르게 만드는 것이 목표입니다. JDK 24부터 일부 기능이 미리 보기로 포함되기 시작했습니다.
AOT가 실제로 유리한 상황
AOT와 JIT 중 어느 쪽이 더 나은지는 워크로드의 특성에 달려 있습니다.
AOT가 명확히 유리한 상황을 정리하면 다음과 같습니다.
- 서버리스 / FaaS: 함수가 짧게 실행되고 사라지는 환경에서 JVM 워밍업은 대부분의 실행 시간을 잡아먹습니다. 콜드 스타트를 밀리초 단위로 줄여야 하는 상황에서 Native Image가 압도적입니다.
- CLI 도구: 사용자가 명령을 입력할 때마다 새 프로세스가 뜨는 CLI 도구에서 수 초의 JVM 기동은 사용자 경험을 크게 해칩니다. GraalVM으로 빌드하면 C로 작성한 도구와 비슷한 반응 속도를 냅니다.
- 메모리 제약 컨테이너: 컨테이너 당 메모리 한도가 낮게 설정된 환경에서 JVM 오버헤드는 그 자체로 문제입니다. Native Image는 JVM 없이 훨씬 작은 메모리 풋프린트로 동작합니다.
반대로 장시간 실행되는 고트래픽 서버처럼 JIT 워밍업 이후 피크 처리량이 가장 중요한 환경에서는 전통적인 JVM이 여전히 유리할 수 있습니다. PGO를 적용한 Native Image로도 격차를 줄일 수는 있지만, JIT의 동적 피드백 루프를 완전히 대체하기는 어렵습니다.
정리
AOT는 빌드 시점에 프로그램을 분석하고 기계어를 미리 생성합니다. 워밍업 없는 즉시 기동과 낮은 메모리가 강점이지만, 런타임 프로파일 기반의 추측적 최적화가 없어 피크 처리량은 JIT에 뒤처질 수 있습니다. PGO는 이 약점을 부분적으로 해소합니다. jaotc는 제거됐고, 표준 JDK에서는 Project Leyden이 새로운 방향을 모색 중입니다. AOT가 진짜 빛나는 곳은 기동 속도와 메모리가 성능 지표의 핵심인 서버리스, CLI, 경량 컨테이너 환경입니다.
지난 글: GraalVM Native Image — 네이티브 실행 파일 만들기
다음 글: GraalVM 폴리글랏 — 하나의 런타임, 여러 언어
읽어주셔서 감사합니다. 😊