JVM 컴파일 패러다임의 진화: JIT, AOT, GraalVM, Leyden까지
클라우드 네이티브 시대가 자바 컴파일러를 다시 설계하게 된 이유
범위가 크긴하지만.. 하나의 흐름으로 정리하는게 더 기억하기 좋을 거 같아서 묶어 정리해보려고 한다. 각각의 컴파일러 구조와 작동방식, 채택한 최적화 방법도 살펴보고 어떠한 이유로 패러다임이 점점 변해가는지 정리해보고자 한다.
1. HotSpot JVM의 JIT(Just-in-Time) 컴파일
자바는 흔히 컴파일러와 인터프리터를 같이 사용하는 언어라고 알려져 있다. 이는 플랫폼 독립성과 고성능이라는 두 가지 상반된 목표를 달성하기 위해 만들어진 구조다. 이러한 구조 아래, 오랜 시간동안 실행되는 애플리케이션에서 JVM은 어떻게 높은 성능을 달성하는 것일까? 이는 런타임에 코드를 최적화하는 JIT 컴파일 모델에서 답을 찾을 수 있다.
JIT 컴파일러 작동 방식
- 처음에는 인터프리터 방식으로 코드를 한 줄씩 실행합니다.
- 실행하면서 자주 호출되는 '뜨거운(Hot)' 코드를 감지합니다.
- 이 '핫스팟(HotSpot)'을 JIT 컴파일러가 최적화된 네이티브 코드로 컴파일합니다.
- 이후부터 해당 코드는 인터프리터를 거치지 않고 컴파일된 네이티브 코드로 매우 빠르게 실행됩니다.
인터프리터와 "웜업"
자바의 플랫폼 독립성은 인터프리터 덕분에 가능한 것이다. 모든 자바 바이트코드(.class)는 초기에 인터프리터에 의해 한 줄씩 해석되어 실행된다. 이 방식은 특정 CPU 아키텍처에 종속되지 않는 완벽한 이식성을 보장한다. 하지만 네이티브 코드에 비해 상당한 성능 저하를 감수해야 한다.
이러한 성능 격차는 웜업 기간이라는 현상을 발생시킨다. 애플리케이션이 시작된 후, JIT 컴파일러가 성능에 결정 적인 코드 경로(핫스팟)을 식별하고 최적화할 때까지 애플리케이션의 처리량은 최적화 되지 못한다. 이 웜업 단계는 장시간 실행되는 서버 애플리케이션에서는 용납 가능하지만, 수 밀리초 내에 시작하고 종료되어야 하는 단기 실행 애플리케이션이나 서버리스 환경에서는 치명적인 단점이 되기도 하다. 이러한 JIT 모델의 고유한 한계는 AOT(Ahead of Time) 기술이 주목받게 되는 핵심적인 동기가 되었다.
계층적 컴파일: 다단계 최적화 전략
자바 컴파일러는 크게 C1 컴파일러, C2 컴파일러, 인터프리터로 구분 가능하다.
- 인터프리터 :
.class파일의 바이트코드를 한 줄씩 해석해서 즉시 실행 - C1 컴파일러(클라이언트 컴파일러) : 인터프리터가 자주 실행하는(Hot) 메서드를 간단하고 빠르게 네이티브 코드로 컴파일
- C2 컴파일러(서버 컴파일러) : 매우 자주 실행되는 메서드를 고도의 최적화를 적용하여 네이티브 코드로 컴파일
기본적으로, C1 컴파일러는 단순하게 네이티브 코드로 컴파일 시켜 빠르지만, C2 컴파일러에는 추가적인 최적화가 들어감. 그래서 컴파일 속도는 느림. 이 최적화에 대해서도 밑에서 더 설명
빠른 시작 시간과 높은 최고 성능이라는 두 마리 토끼를 잡기 위해, HotSpot JVM은 자바 8부터 기본적으로 활성화된 계층적 컴파일(-XX:+TieredCompilation) 모델을 채택하였다. 이 모델은 코드 실행을 0부터 4까지 총 다섯 개의 레벨로 정의하며, 인터프리터 실행에서 고도로 최적화된 네이티브 코드로 점진적으로 전환된다.
- 레벨 0, 인터프리터 실행 코드 (Interpreted Code): 모든 메서드의 시작점. 이 단계에서 JVM은 메서드 호출 빈도나 루프 실행 횟수와 같은 프로파일링 정보를 수집하여 어떤 코드가 최적화 대상인지 식별한다.
- 레벨 1, 단순 C1 컴파일 코드 (Simple C1 Compiled Code): C1 컴파일러가 프로파일링 도구 없이 코드를 네이티브 코드로 컴파일. 이 레벨은 매우 간단하여 프로파일링이나 C2 컴파일의 추가 오버헤드가 성능 향상에 기여하지 않을 것으로 판단되는 메서드에 사용한다.
- 레벨 2, 제한된 C1 컴파일 코드 (Limited C1 Compiled Code) : C2 컴파일러의 작업 큐가 가득 찼을 때 임시로 사용되는 단계. C1 컴파일러는 호출 및 백-엣지(back-edge) 카운터와 같은 가벼운 프로파일링 정보만 수집하며 코드를 컴파일. 이는 C2의 과부하 상황에서도 최소한의 성능 향상을 보장하기 위한 중간 단계.
- 레벨 3, 전체 C1 컴파일 코드 (Full C1 Compiled Code): C1 컴파일러가 전체 프로파일링 도구를 사용하여 코드를 네이티브 코드로 컴파일. 이 단계에서 수집된 상세한 프로파일링 데이터는 레벨 4에서 C2 컴파일러가 수행할 공격적인 최적화의 기반이 됨
- 레벨 4, C2 컴파일 코드 (C2 Compiled Code): C2 컴파일러가 레벨 3에서 수집된 풍부한 프로파일링 데이터를 활용하여 가장 공격적이고 시간이 많이 소요되는 최적화를 수행. 최고 성능의 네이티브 코드가 생성된다. 이 단계는 컴파일 과정 중 가장 많은 CPU 자원을 소모하게 된다.
컴파일러: C1 (클라이언트) 및 C2 (서버)
계층적 컴파일의 핵심에는 목적이 다른 두가지의 JIT 컴파일러가 존재한다.
C1 컴파일러 (클라이언트 컴파일러)
- 빠른 시작 시간과 낮은 컴파일 지연 시간에 최적화
- C1은 복잡한 최적화를 생략하는 대신, 코드를 신속하게 네이티브 코드로 변환하여 애플리케이션이 빠르게 응답할 수 있도록 한다.
- 계층적 컴파일에서는 레벨 1, 2, 3의 컴파일을 담당
C2 컴파일러 (서버 컴파일러)
- 최대 처리량과 최고 성능에 최적화
- C1보다 더 많은 시간, 메모리, CPU를 소모하지만, 광범위한 프로파일링 데이터와 정교한 최적화 알고리즘을 통해 월등히 뛰어난 품질의 네이티브 코드를 생성
- 레벨 4 컴파일을 전담
JVM은 기본적으로 가용한 CPU 코어 수에 따라 C1 및 C2 컴파일러 스레드의 수를 동적으로 계산한다. 예를 들어, CPU가 1개일 때에는 각각 1개의 스레드를, 64개일 때에는 C1 4개, C2 8개의 스레드를 사용하는 식이다.. 이 컴파일러 스레드들이 종종 높은 CPU 사용률의 원인이 되기도 하는데, 이는 -XX:CICompilerCount와 같은 JVM 옵션으로 제어 가능하다.
https://blog.ycrash.io/jvm-c1-c2-compiler-thread-high-cpu-consumption/↗
프로파일링: 뜨거운 코드 식별하기
JVM은 "핫스팟", 자주 실행되는 코드 영역 식별을 위해 카운터를 사용한다.
- 호출 카운터 : 메서드가 호출된 횟수
- 백-엣지 카운터(Back-Edge Counters) : 루프가 한 번 반복되거나 뒤로 분기할 때마다 증가한다.
이 카운터들이 미리 정의된 임계값 (-XX:CompileThreshold=10000)에 도달하면, 해당 메서드는 컴파일 큐에 추가되어 C1 또는 C2 컴파일러에 의해 최적화 된다. 일반적으로 인터프리터(레벨 0)에서 C1(레벨 3)로 전환되는 임계값은 약 200회정도이고, C1(레벨 3)에서 C2(레벨 4)로 전환 되는 임계값은 5000회 이상으로 차이가 많이 난다. 이는 C2의 최적화가 정말 비싸기 때문에, 성능에 큰 영향을 미치는 코드에만 적용되도록 보장하기 위함이다.
코드 캐시
JIT 컴파일러가 생성한 네이티브 코드는 JVM 메모리의 특정 영역인 코드 캐시(Code Cache)에 저장된다. 자바 9이상부터 이 캐시는 수명이 다른 코드를 효율적으로 관리하기 위해 세분화 되었다. 비-메서드 코드(non-method code), 프로파일링된 코드(C1 결과물), 비-프로파일링된 코드(C2 결과물)를 위한 별도의 힙으로 나뉘어 관리한다. 코드 캐시의 기본 크기는 240MB 이며, 이 또한 -XX:ReservedCodeCacheSize 옵션을 통해 조절 가능하다.
JIT 컴파일러의 최적화
C2 컴파일러가 최대 효율을 위해 코드를 재작성하는 최적화 방법들이다.
메서드 인라이닝: 관문 최적화
메서드 인라이닝(Method Inlining)은 가장 중요한 JIT 최적화 기법 중 하나로, 자주 호출되는 메서드의 본문을 호출자(caller)의 코드에 직접 복사해 넣는 기술이다. 이를 통해 메서드 호출 자체에 수반되는 오버헤드(스택 프레임 설정, 메모리 주소 점프 등)를 완전히 제거할 수 있기 때문이다.
인라이닝은 단순히 호출 비용을 줄이는 것을 넘어, **"관문 최적화(gateway optimization)"**로 불린다. 관련 코드를 한곳에 모음으로써, 메서드 경계를 넘어서는 불가능했던 탈출 분석(Escape Analysis)이나 상수 폴딩(Constant Folding)과 같은 강력한 후속 최적화들을 가능하게 하기 때문이다.
HotSpot JVM은 인라이닝할 메서드의 바이트코드 크기가 기본적으로 35바이트 미만이어야 한다는 등의 휴리스틱을 사용하지만, 프로파일링을 통해 메서드의 상당 부분이 실행되지 않는 죽은 코드(dead code)임이 밝혀지면 더 큰 메서드라도 실행되는 "살아있는" 부분만 지능적으로 인라이닝할 수 있다.
탈출 분석
탈출 분석(Escape Analysis, EA)은 특정 객체의 생명주기가 해당 객체가 생성된 메서드나 스레드 내로 국한되는지("탈출하지 않음"), 아니면 다른 곳에서도 접근될 수 있는지("탈출함")를 판단하는 컴파일 시점의 분석 기법이다. 객체는 메서드에서 반환되거나, 정적 필드 또는 인스턴스 필드에 할당되거나, 분석이 불가능한 다른 메서드로 전달될 때 "탈출"한 것으로 간주하게 된다.
컴파일러가 객체가 탈출하지 않는다고 100% 확신할 수 없으면 탈출한다고 가정한다. 이 지점에서 앞선 인라이닝이 큰 역할을 하는데, 인라이닝을 통해 호출된 메서드의 내부 로직이 노출되면, 컴파일러는 객체가 실제로는 외부로 유출되지 않음을 증명할 기회를 얻게 되기 때문이다. HotSpot의 탈출 분석 구현은 흐름에 무관(flow-insensitive)하므로, 제어 흐름 경로 중 단 하나라도 객체가 탈출하면 해당 메서드 전체에서 탈출하는 것으로 간주된다.
스칼라 대체
탈출 분석을 통해 어떤 객체가 메서드를 탈출하지 않는다고 증명되면, JIT 컴파일러는 스칼라 대체(Scalar Replacement) 라는 강력한 최적화를 수행하게 된다. 이 최적화는 객체를 그 구성 요소인 원시 타입 필드(스칼라)들로 분해하고, 이 필드들을 마치 지역 변수처럼 취급하는 것이다.
스칼라 대체는 해당 객체에 대한 힙 할당을 원천적으로 제거하므로, 가비지 컬렉터(GC)의 부담을 극적으로 줄여준다. 흔히 탈출 분석이 "스택 할당"을 가능하게 한다고 알려져 있지만, 이는 개념적으로 유사할 뿐 정확한 설명이 아니다. HotSpot JVM은 실제로 객체를 스택 메모리로 옮기지 않는다. 대신 스칼라 대체를 통해 객체라는 연속된 메모리 블록 자체가 존재하지 않게 만들고, 그 필드들만이 남아 CPU 레지스터에 직접 저장되는 경우가 많다.
더 설명하자면, 결론적으론, HotSpot JVM은 진짜 스택 프레임 안에 객체를 배치하지 않는다
왜냐하면 자바 객체는 내부적으로 헤더(mark word, klass pointer 등)를 갖는데, 스택 프레임에 이걸 넣으면 GC/모니터 락/리플렉션 동작에 복잡성이 생긴다. 그리고, JIT 최적화가 적용된 메서드가 인라이닝되면, 객체가 아예 “연속된 메모리 블록” 형태로 존재하지 않게 최적화 가능하기 때문이다.
그래서 HotSpot은 스칼라 대체로,
- 객체를 각 필드 단위로 쪼개서 (int, double, reference 등)
- 지역 변수나 CPU 레지스터에 직접 배치
- 결과적으로 힙에도, 스택에도 “객체 전체”가 없음 -> GC가 관리할 대상이 사라짐
class Point { int x, y; } public int sum() { Point p = new Point(); // new지만 탈출 안 함 p.x = 1; p.y = 2; return p.x + p.y; }
이와 같은 코드가 JIT + 탈출 분석 + 스칼라 대체가 되면,
public int sum() { int x = 1; int y = 2; return x + y; }
걍 Point 라는 객체 자체가 사라짐. 그리고, x와 y만 남음
이렇게 되면 힙 할당도 0, GC에도 부담 X
지역 변수 저장이 스택에 된다고 생각할 수도 있지만, **자바의 "지역 변수"**라는 말은 JVM 바이트코드 상의 Local Variable Table을 의미한다. 이건 논리적인 슬롯 개념이지, 물리적으로 항상 스택 메모리에 존재한다는 뜻이 아니다. JIT 컴파일이 되면, 이 "지역 변수 슬롯"은 실제로
- CPU 레지스터에 매핑
- 필요 시 스택 프레임(네이티브 스택) 일부에 저장
의 상태가 되기 때문..
JVM 인터프리터 모드에서는 지역 변수가 JVM 스택 프레임(운영체제 스택과는 다른 JVM 내부 구조)에 위치시키는 게 맞다. JIT 컴파일 모드에서는 HotSpot의 레지스터 할당 최적화가 개입하기 때문에, 자주 쓰이는 변수는 CPU 레지스터에 덜 쓰이는 변수나 레지스터가 부족한 경우만 네이티브 스택에 저장한다. (여기서 네이티브 스택이라 함은, OS가 스레드 생성 시 **프로그램 스택(실행 스택)**으로 할당해 주는, 진짜 물리적 스택 메모리 공간)
결론적으로, 이 때문에 "지역 변수 -> 스택"이라는 단정은 인터프리터 시절에는 맞지만, JIT이 개입하면 반은 맞고 반은 틀린 말이 되긴하다..
https://www.javaadvent.com/2015/12/jit-compiler-inlining-escape-analysis.html↗
잠금 제거 및 병합
잠금 제거
탈출 분석을 통해 객체가 스레드-로컬(thread-local), 즉 생성된 스레드를 벗어나지 않는다고 판단되면, 해당 객체에 대한 모든 동기화 블록(synchronized)은 불필요하다고 증명할 수 있게 된다. 이때 JIT 컴파일러는 잠금을 획득하고 해제하는 작업을 완전히 제거하여 상당한 성능 오버헤드를 제거 시킨다.
단일 메서드 내에서 StringBuffer나 Vector를 사용하는 경우가 대표적인 예로, 이들 클래스의 내부적인 동기화는 불필요하게 되는 것이다.
잠금 병합
동일한 잠금 객체를 사용하는 인접한 synchronized 블록들을 하나의 더 큰 임계 영역(critical section)으로 병합합니다. 이를 통해 잠금을 획득하고 해제하는 빈도를 줄여 오버헤드를 감소시킨다.
이 최적화 기법들 말고도 루프 펼치기, 단형/이형/다형 디스패치, 내장 함수 최적화 등등이 있지만.. 우선은 이쯤에서 줄이겠다.
JIT 최적화는 독립적인 기법들의 집합이 아닌, 서로 깊게 연관된 분석과 변환 시스템이다. 이 시스템의 장점은 정적 컴파일러가 갖지 못하는 런타임 데이터를 기반으로 추측에 기반한 최적화를 수행할 수 있다는 것이다. 이는 런타임 데이터를 활용하여 초기 최적화(인라이닝)를 정당화하고, 이를 통해 점점 더 강력한 코드 변환을 연쇄적으로 가능하게 하는 시스템.. 최종적으로는 이론적인 가능성이 아닌 실제 관찰된 애플리케이션 동작에 맞춰진 네이티브 코드를 생성함으로써, 뛰어난 성능을 낼 수 있게 되는 것이다.
2. Ahead-of-Time (AOT) 컴파일과 GraalVM
현대 클라우드 네이티브 아키텍처의 요구에 의해 촉발된 컴파일 변화와 새로운 패러다임의 선두주자인 GraalVM에 대해 알아보자.
클라우드 네이티브 자바를 위한 AOT의 필요성
장시간 안정적으로 실행되는 서버 애플리케이션에 최적화된 JIT 모델은 마이크로서비스나 서버리스 모델에선 단점이 드러난다.
- 느린 시작 시간/콜드 스타트 : "웜업" 기간은 수 밀리초 내에 시작해야 하는 함수형 서비스에서는 용납될 수 없는.. 지연이다
- 높은 메모리 점유율 : JVM 자체와 JIT 컴파일러, 관련 데이터 구조, 코드 캐시 등은 상당한 메모리를 소모하며, 이는 사용한 만큼 비용을 지불하는 클라우드 환경에서 직접적인 비용 증가로 이어진다.
AOT 컴파일은 컴파일 및 최적화 작업을 런타임에서 빌드 타임으로 옮김으로써 이러한 문제들을 해결하고자 한다. 그 결과, 즉시 시작되고 훨씬 적은 메모리를 사용하는 독립 실행형(self-contained) 실행 파일을 생성할 수 있게 된다.
GraalVM의 아키텍처 개요
GraalVM은 단순히 AOT 컴파일러가 아니라, 여러 모드로 작동할 수 있는 고성능 JDK이다.
Graal 컴파일러
자바로 작성된 현대적이고 고도로 최적화된 컴파일러이다. 이 컴파일러는 표준 HotSpot JVM 내에서 C2 JIT 컴파일러를 대체하는 역할로 사용될 수 있으며, 더 진보된 최적화 기법 덕분에 장기 실행 애플리케이션에서 종종 더 높은 최고 성능을 제공한다. 실제로 트위터도 성능 향상을 위해 GraalVM을 JIT 모드로 사용한다.
네이티브 이미지 생성
GraalVM의 AOT 컴파일 기능이다. Graal 컴파일러를 사용하여 자바 바이트코드를 독립 실행형 네이티브 실행 파일로 변환한다.
SubstrateVM
이는 완전한 형태의 가상머신이 아니라, 네이티브 이미지를 위한 기본 프레임워크이자 런타임이다. SubstrateVM은 가비지 컬렉션, 스레드 스케줄링, 시그널 핸들링 등 JVM이 일반적으로 제공하는 필수 서비스를 최소한의 경량화된 형태로 제공하며, 이 구성 요소들이 최종 실행 파일에 함께 번들링된다. SubstrateVM은 빌드 시점(구성 요소 제공)과 런타임 시점(실행) 모두에 필요하다.
GraalVM 네이티브 이미지
native-image 도구는 AOT 프로세스의 핵심이다. 이는 애플리케이션의 main 메서드에서 시작하여 정적힌 "닫힌 세계" 분석을 수행하여 실행 중 도달 가능한 모든 클래스, 메서드, 필드들을 결정한다.
이 과정에서 도달 불가능한 코드들은 공격적으로 제거(트리 셰이킹..)되어 최종 바이너리 크기를 줄인다. 도달 가능한 코드는 Graal 컴파일러에 의해 플랫폼 별 실행파일로 AOT 컴파일 된다. 이 실행 파일에는 애플리케이션 코드, 의존성, 필수 JDK 라이브러리, SubstrateVM 등 모두 포함된다. 최종 결과물은 외부 JVM 의존성이 전혀 없는 독립적인 바이너리이다.
GraalVM은 단일한 AOT 컴파일러가 아니라, JIT + 네이티브 이미지를 모두 갖춘 다재다능한 플랫폼이다. 그 기저에 있는 혁신은 두 가지 맥락 모두에서 활용될 수 있는 Graal 컴파일러이다.
Graal 컴파일러는 최초에, Oracle Labs에서 만들었는데, 초기 연구 목표는 노후화된 C2 컴파일러를 대체하기 위해 자바로 더 나은 JIT 컴파일러(Graal 컴파일러)를 작성하는 것이었다. 이는 전통적인 JVM 장기 실행을 위한 것이었다.
그러나 클라우드 네이티브 아키텍처의 부상으로 더 발전된 JIT 컴파일러 조차 해결할 수 없는 새로운 문제 영역(시작 시간/메모리)을 만들어 냈다. 이로 인해, Graal 컴파일러를 AOT 맥락에 맞게 재활용하는 아이디어가 나왔고, 이는 네이티브 이미지 유틸리티의 탄생으로 이어진 것이다. 하지만 AOT 컴파일된 자바 애플리케이션은 여전히 가비지 컬렉션과 같은 런타임 서비스가 필요하다. 이 필요성이 바로 SubstrateVM의 탄생을 야기한 것이다. SubstrateVM은 Graal에 의해 컴파일될 수 있는 자바로 작성된 최소한의 런타임 구성 요소(GC, 스케줄러) 집합이다.
따라서 GraalVM의 아키텍처는 하나의 핵심 기술(컴파일러)로 두 가지 다른 문제를 해결하려는 시도의 결과물이다. Graal 컴파일러가 엔진이다. HotSpot JVM과 함께 사용될 때 JIT 컴파일러가 되고, SubstrateVM 및 native-image 도구와 함께 사용될 때 AOT 컴파일러가 되는 것이다.
3. GraalVM 네이티브 이미지의 제약 조건 탐색
그럼 네이티브 이미지에는 아무런 단점이 없는 완벽한 기술일까? 현실은 그렇지 않다...
"닫힌 세계 가정"과 도달 가능성 분석
네이티브 이미지의 핵심 원칙은 **닫힌 세계 가정(closed-world assumption)**이다. 이는 런타임에 실행될 모든 코드가 빌드 시점에 알려져 있고 분석 가능해야 한다는 것을 의미한다. 이는 그럼, 런타임에 새로운 코드를 로드하거나 생성할 수 없다는 사실로 귀결된다.
이 가정은 native-image 도구가 **도달 가능성 분석(reachability analysis)**을 수행할 수 있게 해준다. 이는 애플리케이션의 진입점(예: main 메서드)에서부터 호출 그래프를 정적으로 순회하여 접근 가능한 모든 클래스, 메서드, 필드를 식별하는 하게되는데, 도달 가능하다고 판단되지 않은 모든 것은 최종 실행 파일에서 제거된다. 이 가정은 본질적으로 자바의 동적인 특성과 상충된다.
동적 기능 처리
정적 도달 가능성 분석은 동적인 방식으로 접근되는 코드를 탐지하지 못하는 경우가 많다. 이를 제대로 구성하지 않으면 런타임에 ClassNotFoundException, NoSuchMethodError 등의 오류가 발생할 수 있다. 예를 들자면, 다음과 같은 기술이 주요 문제다.
- 리플렉션 (Reflection) : 이름으로 클래스, 메서드, 필드에 접근하는 것.
Class.forName("com.Foo")은 문자열 인수가 컴파일 시점 상수가 아닌 한 정적 분석에 보이지 않는다. - 동적 프록시 (Dynamic Proxies) :
java.lang.reflect.Proxy를 통한 런타임 프록시 클래스 생성은 구현할 인터페이스 목록을 빌드 시점에 알아야 한다. - JNI (Java Native Interface) : 네이티브 C/C++ 코드에서 이름으로 접근하는 자바 코드는 분석에 보이지 않는다.
- 리소스 (Resources) : 클래스패스에서 파일을 로드하는 것(getResourceAsStream 등)은 최종 실행 파일에 전통적인 클래스패스가 없으므로 빌드 시점에 선언되어야 한다.
- 직렬화 (Serialization) : 클래스 메타데이터가 필요하며, 이 또한 구성 파일에 명시해야 한다.
해결: 정적 분석, 구성, 그리고 자동화
- 휴리스틱 정적 분석 :
native-image빌더는 일부 동적 호출을 자동으로 탐지하려고 시도한다. 예를 들어,Proxy.newProxyInstance호출 시 인터페이스 배열이 컴파일 시점 상수이면 이를 분석할 수 있다. - 수동 구성 : 정적 분석이 실패하는 경우, 개발자는 JSON 형식의 구성 파일(
reflect-config.json,proxy-config.json,resource-config.json)을 통해 명시적으로 메타데이터를 제공해야 한다. - 추적 에이전트 : 구성 작업을 자동화하기 위해 GraalVM은 자바 에이전트(
-agentlib:native-image-agent)를 제공한다. 표준 JVM에서 이 에이전트를 연결하여 애플리케이션을 실행하면, 에이전트가 테스트 실행 중에 발생하는 모든 동적 호출(리플렉션, 프록시 등)을 관찰하고 필요한 JSON 구성 파일을 자동으로 생성해 준다.
힙 스냅샷 프로세스: 빌드 시점 초기화
네이티브 이미지의 핵심 최적화 중 하나는 클래스 초기화 블록(static 블록)을 빌드 시점에 실행하는 기능이다. 이 빌드 시점 초기화 과정에서 생성된 객체들은 초기 객체 그래프를 형성한다. 이 그래프는 "힙 스냅샷(heap snapshot)"으로 직렬화되어 실행 파일의 데이터 섹션에 직접 포함된다..(예를 들자면, .svm_heap 섹션)
런타임 시, 운영 체제는 이 섹션을 프로세스의 주소 공간에 메모리 매핑한다. 이 미리 초기화 된 힙은 즉시 사용 가능하며, 애플리케이션이 부분 적으로 "웜업" 된 상태에서 시작되므로 시작 시간을 극적으로 단축시킨다.
* 힙 스냅샷과 GC의 관계
추가적으로, 빌드 시점 초기화로 만들어진 이 힙 스냅샷 객체들은 애플리케이션 시작괃 동시에 이미 메모리에 존재하는 상태이다. GC 입장에서 이들은 특이 케이스다.
- 불변(immutable)간주 : 대부분의 스냅샷 객체는 읽기 전용이라 GC 루트(내부 객체를 참조하는 시작점!)로만 취급되고, 수집 대상이 아니다
- 별도 메모리 매핑 : OS가
.svm_heap섹션을 실행 파일에서 매핑하게 되므로 GC가 이 영역을 읽기 전용 데이터 처럼 취급한다 - 세대 구분의 의미 축소 : 일반 JVM의 GC는 새로 생성된 객체를 Young Gen에 두고 오래 살아 남은 객체를 Old gen으로 옮긴다. 스냅샷 객체는 시작부터 장수할 Old Gen 객체처럼 동작하므로 GC 힙 내 세대 이동 없이 바로 Old Gen에 존재하는 효과가 있다
- 스냅샷 크기가 메모리 베이스라인이 됨 : 스냅샷 자체가 런타임 메모리 사용량의 하한선을 만든다. 작은 앱이면 부팅 직후 메모리 사용량이 눈에 띄게 줄지만, 스냅샷이 비대하면 "시작은 빠른데 기본 메모리 소비가 높음"이라는 부작용이 있기도 함.
- GC 최적화 효과 : 초기화된 객체들을 Young Gen GC가 건드리지 않으므로, 애플리케이션 초기 구간에서 GC 빈도가 현저히 줄어든다.
이러한 점들로 하여금, 이게 단순한 빠른 시작 기술이 아니라, GC 동작 모델을 바꾸는 요소이기도 하다는 걸 알 수 있다.
GraalVM 설계자들이 발표한 학술 논문에 따르면, 이 기술의 기저에는 포인트-투 분석(points-to analysis)과 힙 스냅샷의 반복적 적용이라는 새로운 접근 방식이 있다.
분석을 통해 스냅샷에 포함될 도달 가능한 객체를 식별하고, 스냅샷을 생성하는 과정에서 다시 새로운 코드가 분석 대상이 될 수 있다. 이 과정은 고정점(fixed point)에 도달할 때까지 반복된다.
자바의 동적 기능이 제기하는 이 문제는 단순한 불편함이 아닌, 추적 에이전트의 개발을 직접적으로 촉발시켰다. 이 도구는 실제 JVM에서 동적 동작을 관찰하고 이를 AOT 컴파일러가 필요로 하는 정적 구성으로 변환함으로써 그 간극을 메우기 위해 특별히 설계된 것이다.
동시에 Quarkus, Micronaut과 같은 프레임워크 개발자들도 동일한 문제를 인식했다. 이들의 해결책은 더 근본적이었는데, 런타임의 동적성을 완전히 피하도록 프레임워크를 재설계하는 것이었다.. 이는 의존성 주입 그래프, 구성 파싱, 프록시 생성이 컴파일 시점에 해결되는 빌드 시점 처리라는 패러다임으로 이어졌다. 이러한 아키텍처 변화는 런타임 리플렉션의 필요성 자체를 대부분 제거하여 프레임워크가 본질적으로 닫힌 세계 가정과 호환되도록 만들었다.
따라서 추적 에이전트와 같은 도구부터 Quarkus와 같은 프레임워크의 아키텍처 원칙에 이르기까지, 현대 네이티브 자바 생태계는 AOT의 닫힌 세계 요구 사항과 자바 플랫폼의 열린 세계 유산 사이의 핵심적인 충돌에 대한 직접적이고 다각적인 대응으로 볼 수 있다.
4. JIT vs. AOT 성능 프로파일
두 컴파일 모델 간 트레이드 오프를 살펴보자.
시작시간 및 메모리 점유율: AOT
네이티브 이미지는 JVM 기반 애플리케이션보다 수십에서 수백 배 더 빠르게 시작된다. 이는 코드가 이미 컴파일되어 있고, 힙 스냅샷을 통해 애플리케이션 힙이 부분적으로 초기화 된 상태에서 시작하기 때문이다. 이점이 서버리스 및 마이크로서비스 환경에서 AOT를 채탱하는 주된 동력이다.
또한, 네이티브 이미지는 훨씬 적은 메모리를 소비한다. 이는 런타임에 JIT 컴파일러, 관련 데이터 구조, 인터프리터 오버헤드가 없고 죽은 코드들을 모두 제거 했기 때문이다.
https://dev.to/yanev/turbocharge-java-microservices-with-quarkus-and-graalvm-native-image-2cb4↗
최고 처리량 및 지연 시간: JIT
장시간 실행되는 CPU 집약적인 워크로드의 경우에선, JIT 컴파일된 코드가 종종 더 높은 최고 처리량을 달성한다. 이는 JIT 컴파일러가 AOT 컴파일러의 정적 예측보다 훨씬 정확한 실제 런타임 프로파일링 데이터에 기반하여 추측 최적화를 수행할 수 있기 때문이다.
JIT의 웜업 기간은 높은 초기 지연 시간을 유발한다. 반면 AOT는 시작부터 낮고 예측 가능한 지연 시간을 보인다. 그러나 장기 실행의 경우, 웜업이 끝난 이후의 JIT의 고급 GC 옵션과 런타임 최적화가 더 낮은 안정 상태의 지연 시간을 제공할 수 있다.
프로파일 기반 최적화 - PGO
프로파일 기반 최적화(Profile-Guided Optimization, PGO)는 AOT의 빠른 시작 시간의 이점을 유지하면서 JIT 수준의 최고 성능을 달성하기 위해 런타임 프로파일 정보를 AOT 컴파일러에 제공하는 기술이다. 이는 GraalVM Enterprise Edition의 주요 기능이기도 하다.
작업 흐름
--pgo-instrument옵션을 사용하여 "계측된(instrumented)" 네이티브 실행 파일을 빌드- 이 계측된 바이너리를 대표적인 워크로드로 실행하여 프로파일 파일(
.iprof)을 생성 --pgo=...옵션으로 이 프로파일 파일을 컴파일러에 전달하여 네이티브 실행 파일을 다시 빌드- 컴파일러는 이 데이터를 사용하여 인라이닝, 분기 예측 등 더 나은 최적화 결정을 내리게 되는 구조
PGO는 실제로 처리량 격차를 크게 줄인다. 벤치마크에 따르면, PGO로 최적화된 네이티브 이미지는 HotSpot C2 JIT 컴파일러와 동등하거나 때로는 능가하는 성능을 보인다..
https://www.oracle.com/cn/a/ocom/docs/graalvm_enterprise_community_comparison_2021.pdf↗
실제로 Enterprise Edition (EE) 에서는 고급 최적화, 네이티브 이미지용 G1GC, PGO를 제공하여 EE 네이티브 이미지는 JIT 성능과 매우 경쟁력 있거나 때로는 더 우수할 수 있다. 커뮤니티 에디션은 여전히 최고 처리량 면에선 JIT에 밀릴 여지가 있다.
성능의 핵심적인 차이는 정보의 가용성에서 나온다. JIT는 동적인 런타임 정보를 보유하고 AOT는 정적인 빌드 타임 정보를 가진다. 이러한 정보적 우위때문에 JIT가 장기 실행 작업에서 최고 처리량에 유리하다. 왜냐하면, 실제 동작을 관찰하고 이를 바탕으로 추측하여 최적화를 하기 때문에.
반대로, AOT의 장점은 모든 비싼 작업을 빌드 타임에 수행하는 데에서 나온다. 이는 런타임 프로세스가 가볍고 이미 최적화된 상태에서 시작하기 때문에 거의 즉각적인 시작 시간과 낮은 메모리 점유율을 직접적으로 야기한다. PGO는 이 두 모델의 합성을 의미한다.
5. 다른 자바 생태계
이러한 기조에 응답하는 다른 진영들은 어떤 대안을 꺼내고 있을까?
IBM OpenJ9의 공유 클래스 캐시(SCC)
대안적인 JVM 구현인 OpenJ9 는 공유 클래스 캐시(Shared Class Cache, SCC)와 AOT 기능을 통해 시작 시간을 개선하는 다른 접근 방식을 제공한다.
메커니즘
- GraalVM의 완전한 정적 컴파일과 달리, OpenJ9의 AOT는 캐싱 메커니즘
- 초기 실행 중 JIT 컴파일러의 결과물(AOT 컴파일된 코드), 클래스데이터, 프로파일링 정보가 공유 메모리 영역(SCC)에 저장된다. 이후 시작 시 JVM은 이 캐시를 매핑하여 미리 컴파일된 코드와 데이터를 직접 로드함으로써 인터프리테이션과 초기 JIT 컴파일 작업을 상당 부분 생략하여 시작 속도를 높인다
이 방법은 여전히 완전한 JVM 위에서 동작한다. 이는 독립 실행형 파일이 아니다. GraalVM 네이티브 이미지처럼 실행 모델을 근본적으로 바꾸는 게 아니라 웜업을 가속하는 기술이다.
OpenJDK의 프로젝트 Leyden
프로젝트 Leyden은 표준 JVM 내에서 자바 프로그램의 시작 시간, 최고 성능 도달 시간, 그리고 메모리 점유율을 개선하는 것을 목표로 하는 공식 OpenJDK 프로젝트이다. 이 프로젝트는 JVM에 "정적성의 스펙트럼(spectrum of staticness)"을 도입하여, 개발자가 자바의 동적 특성과 정적 사전 계산의 성능 이점 사이에서 트레이드오프를 선택할 수 있도록 하는 것을 목표로하고 있다.
Ahead-of-Time 클래스 로딩 및 링킹(JEP 483)
JDK 24에서 나온 Leyden의 첫 번째 주요 결과물이다. 이는 기존의 애플리케이션 클래스 데이터 공유(AppCDS) 기능을 확장한다. AppCDS가 파싱된 클래스 데이터를 캐시하는 반면 이 새로 나온 JEP 483은 클래스를 완전히 로드되고 링크된 상태로 저장한다. 이는 더 많은 런타임에서 훈련 실행으로 옮겨 시작 시간을 더욱 단축시킨다.
향후, JEP들은 AOT 메서드 프로파일링과 궁극적으로 AOT 코드 컴파일을 다룰 예정이라고 한다.
https://softwaremill.com/inside-jdk-24-understanding-ahead-of-time-class-loading-and-linking/↗
그렇다고, Leyden이 GraalVM 네이티브 이미지를 대체하는 것은 아니다. 이는 서로 상호 보완적이다.
Layden은 표준 JVM을 개선하여 완전한 동적 기능을 유지하며 AOT와 유사한 시작 시간 개선을 제공하는 것을 목표로 한다. Leyden 최적화로 실행되는 애플리케이션은 여전히 JVM 위의 표준 자바 애플리케이션이다.
반면 GraalVM은 닫힌 세계 제약을 수용하고, JVM의 일부 동적성을 포기하는 대신 완전히 정적인 독립 실행형 네이티브 파일을 옵션으로 제공하는 것이다.
| 접근 방식 | Graal VM 네이티브 이미지 | OpenJ9 AOT + SCC | OpenJDK 프로젝트 Leyden |
|---|---|---|---|
| 메커니즘 | 완전한 정적 컴파일(AOT) | JIT 결과물 캐싱 | 점진적 AOT (클래스로딩/링킹/컴파일) |
| 결과물 | 독립 실행형 네이티브 바이너리 | 표준 JAR + 공유 캐시 파일 | 표준 JAR + AOT 캐시 파일 |
| JVM 의존성 | 없음(SubstrateVM 내장) | 완전한 OpenJ9 JVM 필요 | 완전한 HotSpot JVM 필요 |
| 동적 기능 지원 | 제한(닫힌 세카이..) | 완전 지원 | 완전 지원(동적성 유지 목표) |
| 주요 사용 사례 | 서버리스, CLI, 마이크로서비스 | 기존 애플리케이션의 빠른 시작 | 표준 JVM의 점진적 성능 향상 |
이러한 분기는 갈등이 아닌 정교함의 표시같다. 생태계가 다양한 요구에 맞는 맞춤형 솔루션을 만들고 있음을 보여주기 때문이다.
최근 공부하면서, 그리고 바로 얼마전에 JDK 25가 출시되면서, 흩어진 지식들을 모아 정리하고 싶었기 때문에 작성했다. 더불어 최근에는 JVM 쪽을 살펴보고 있었기 때문에, 관심이 더 많아졌기 때문이다.. 물론 뜯어서 개념별로 설명하면 더 매끄럽고 읽기 쉬웠겠지만.. 개인적으론 그런 기술 블로그나 공식 정보 등 파편적 지식을 모아 단권처럼 정리하고 싶었다. 개인적 선호..
오해를 사지 않을 정확한 정보 전달을 위해 많이 찾아보긴 했는데, 그럼에도 불구하고 오류가 있다면.. 말씀주시면 감사하겠습니다.