본문 바로가기

CS/JVM

[JVM] Project Leyden과 AOT Cache - Java가 콜드 스타트 문제를 해결하는 방법

JVM 인터널 시리즈
1편에서 CDS와 AppCDS가 클래스 로딩 결과를 공유 아카이브에 저장하고 mmap으로 재사용하는 방법을 살펴봤습니다.
그런데 CDS가 캐싱하는 범위는 Loading과 Verification까지였고, Linking의 마지막 단계인 Resolution은 여전히 런타임에 수행됐습니다.

이번 글은 그 나머지를 채우는 Project Leyden과 AOT Cache를 다룹니다.

 


1. CDS의 한계: 시작 지연의 두 가지 얼굴

1편을 마치며 남긴 질문이 있었습니다. CDS와 AppCDS가 Spring Boot 애플리케이션의 시작 시간을 40~60% 줄여줬는데, 나머지 40~60%는 어디서 오는 걸까요?

 

Spring Boot 애플리케이션의 기동을 단계별로 보면 이렇습니다.

① Loading + Verification  → CDS/AppCDS가 캐싱
② Resolution              → 런타임에 수행 (CDS 범위 밖)
③ 프레임워크 초기화       → Spring 컨텍스트, 빈 생성, DB 커넥션 풀 연결
④ JIT 웜업               → 핫 메서드 탐색 → 네이티브 코드 컴파일

CDS는 ①만 담당합니다. 그리고 콜드 스타트 문제는 사실 두 가지 문제입니다.

 

시작 지연(Startup Latency): 프로세스 시작부터 첫 요청 처리까지 걸리는 시간. ①②③이 주요 원인입니다.

웜업 지연(Warmup Latency): 첫 요청 처리부터 피크 처리량 도달까지 걸리는 시간. ④가 원인입니다. JIT가 메서드를 충분히 관찰하기 전까지는 인터프리터 모드로 실행되어 처음 수백~수천 건의 요청 성능이 낮습니다.

 

CDS는 이 두 가지 문제 중 시작 지연의 일부만 해결합니다. Project Leyden은 이 두 가지를 체계적으로 공략합니다.

 


2. Project Leyden이란?

Project Leyden은 Java의 콜드 스타트 문제를 하위 호환성을 유지하면서 해결하기 위한 OpenJDK 프로젝트입니다. 2020년에 시작되어 Java 24부터 첫 결과물을 출시하기 시작했습니다.

 

GraalVM Native Image와 자주 비교되는데, 접근 방식이 근본적으로 다릅니다.

항목 GraalVM Native Image Project Leyden
핵심 아이디어 AOT 컴파일로 JVM 자체를 제거 JVM은 유지하되, 반복 작업을 미리 수행
동적 클래스 로딩 제한됨 (클로즈드 월드 가정) 완전 지원
리플렉션 설정 파일 필요 코드 변경 없음
런타임 JIT 없음 있음 (피크 처리량 도달 가능)
시작 시간 80~95% 단축 40~67% (현재)
코드 변경 일부 필요 없음

Leyden의 핵심 아이디어는 "특정 범주의 작업을 프로덕션 런타임이 아닌 훈련 실행(training run) 으로 미리 수행한다"입니다. 훈련 실행에서 관찰한 결과를 캐시 파일에 저장하고, 이후 프로덕션 실행에서 재사용합니다.

 

Leyden은 단일 JEP가 아니라 Java 24부터 순차적으로 출시되는 JEP 시리즈로 구성됩니다.

 


3. JEP 483: AOT Class Loading & Linking (Java 24)

CDS의 직접적인 후계자입니다. CDS가 Loading + Verification 결과를 캐싱했다면, JEP 483은 Resolution(링킹)까지 포함해 캐싱합니다.

 

JVM 시작 시 캐시에서 꺼낸 클래스들은 이미 완전히 로드되고 링크된 상태입니다. 재파싱·재검증·재링크 없이 즉시 사용할 수 있습니다.

 

Spring PetClinic 기준으로 약 21,000개의 클래스가 이미 링크된 상태로 제공되어 시작 시간이 42% 단축됩니다. (JDK 23 대비 4.486초 → 2.604초)

 

AOT Cache 생성 절차 (JDK 24)

JDK 24에서는 3개의 명령으로 처리합니다.

# 1단계: 훈련 실행 — 어떤 클래스가 어떻게 로드되는지 기록
java -XX:AOTMode=record \
     -XX:AOTConfiguration=app.aotconf \
     -jar app.jar

# 훈련 실행에서는 실제 운영 시나리오를 최대한 재현합니다.
# Spring Boot라면 주요 엔드포인트를 몇 차례 호출한 뒤 종료합니다.

# 2단계: 기록을 바탕으로 AOT 캐시 파일 생성
# (앱을 실행하는 게 아니라 캐시만 생성하고 종료)
java -XX:AOTMode=create \
     -XX:AOTConfiguration=app.aotconf \
     -XX:AOTCache=app.aot

# 3단계: AOT 캐시 적용해서 실행
java -XX:AOTCache=app.aot \
     -jar app.jar

 

훈련 실행에서 주의할 점

훈련 실행은 "어떤 클래스를 캐시에 넣을지 결정하는" 단계입니다. 여기서 중요한 트레이드오프가 있습니다.

 

적게 실행하면 → 실제 프로덕션에서 쓰일 클래스가 캐시에서 빠져 효과 감소

많이 실행하면 → 테스트용 클래스까지 캐시에 포함되어 파일 크기 비대화, 불필요한 클래스를 로드하는 오버헤드 발생

 

일반적으로 주요 API 엔드포인트를 한 번씩 호출하고 종료하는 것이 균형점입니다. Spring Boot라면 -Dspring.context.exit=onRefresh로 컨텍스트 초기화 직후 자동 종료시키는 것도 좋은 방법입니다.

: JEP 483은 훈련 전용 main 클래스를 애플리케이션에 별도로 두는 방식도 권장합니다. com.example.AppTrainer와 같이 프로덕션 main 클래스를 내부적으로 호출하면서 주요 코드 경로를 재현하고, 로컬 네트워크 설정이나 모의 DB를 활용해 종료하는 구성입니다. 이미 통합 테스트용 main 클래스가 있다면 그것이 좋은 후보가 됩니다.

 

캐시에서 제외되는 클래스

모든 클래스가 AOT 캐시에 들어가는 건 아닙니다. 캐시 생성 로그에 다음과 같은 메시지가 나타납니다.

Skipping ...$$Lambda+0x...: Hidden class
Skipping net/bytebuddy/...: Old class has been linked
Cannot aot-resolve Lambda proxy because org.slf4j.Logger is excluded
Skipping jdk/internal/event/Event: JFR event class

람다 내부 클래스(Hidden class), 레거시 라이브러리 클래스, JFR 이벤트 클래스 등은 AOT 캐시 대상에서 제외됩니다. 이는 1편에서 살펴본 CDS의 제외 원인과 동일합니다.

 

커스텀 클래스 로더 제약

JEP 483은 현재 표준 JDK 클래스 로더로 로드된 클래스만 캐싱합니다. Quarkus fast-jar, OSGi처럼 커스텀 클래스 로더를 사용하는 프레임워크에서 로드된 클래스는 AOT 캐시 혜택을 받지 못합니다. Quarkus가 이를 우회하기 위해 aot-jar 패키징을 별도로 도입한 이유가 이 때문입니다.

 


4. JEP 514: AOT 커맨드라인 에르고노믹스 (Java 25)

JDK 24의 3개 명령을 2개 명령으로 단순화합니다. 기능 추가가 아닌 개발자 경험(DX) 개선 JEP입니다.

핵심은 -XX:AOTCacheOutput 옵션 하나입니다.

# JDK 25: 훈련 실행과 캐시 생성을 한 번에
java -XX:AOTCacheOutput=app.aot \
     -jar app.jar

# 실행 (동일)
java -XX:AOTCache=app.aot \
     -jar app.jar

내부적으로는 JDK 24의 record → create 두 단계를 서브프로세스로 순차 실행하고, 임시 .aotconf 파일을 자동으로 생성하고 삭제합니다. 사용자 입장에서는 명령어 하나지만, JVM이 내부적으로 두 번의 서브 호출을 처리합니다.

JDK_AOT_VM_OPTIONS 환경변수

JDK 25와 함께 새 환경변수가 도입됩니다.

# 캐시 생성 서브프로세스에만 추가 옵션 전달
export JDK_AOT_VM_OPTIONS="-XX:+SomeCreationOnlyFlag"

java -XX:AOTCacheOutput=app.aot -jar app.jar

기존 JAVA_TOOL_OPTIONS은 모든 JVM 호출에 적용됩니다. JDK_AOT_VM_OPTIONS는 캐시 생성 서브프로세스에만 적용되어, 훈련 실행과 캐시 생성에 서로 다른 JVM 옵션이 필요한 경우에 유용합니다.

 

3개 명령 워크플로가 여전히 필요한 경우

단순화된 1명령 워크플로가 항상 더 편한 건 아닙니다. 다음 상황에서는 JDK 24의 명시적 명령이 더 적합합니다.

 

훈련 환경과 캐시 생성 환경을 분리하고 싶을 때: 훈련 실행은 프로덕션과 동일한 소형 인스턴스에서, 캐시 생성은 CPU·메모리 여유가 있는 별도 머신에서 수행할 수 있습니다. Leyden의 AOT 최적화가 복잡해질수록 이 분리의 이점이 커질 수 있습니다.

 

메모리 제약 환경: -XX:AOTCacheOutput은 훈련과 캐시 생성을 순차적으로 실행하므로 힙 메모리가 2배 필요합니다. -Xmx4g를 지정하면 실제로는 8GB가 필요합니다.

 


5. JEP 515: AOT 메서드 프로파일링 (Java 25)

JEP 483과 514가 시작 지연을 공략했다면, JEP 515는 웜업 지연을 공략합니다.

 

JIT 컴파일러는 메서드를 네이티브 코드로 컴파일하기 전에 충분한 실행 횟수를 관찰해야 합니다. 이 관찰 시간 동안 JVM은 인터프리터 모드로 실행됩니다. 처음 수백~수천 건의 요청이 느린 이유입니다.

 

JEP 515는 훈련 실행 중 어떤 메서드가 얼마나 자주 호출되는지 프로파일 데이터를 AOT 캐시에 저장합니다. 프로덕션 실행 시 JIT 컴파일러는 처음부터 이 데이터를 읽어 자체 관찰 시간 없이 즉시 핫 메서드를 컴파일합니다.

 

중요한 점은, 캐시된 프로파일이 프로덕션 실행 중 추가 프로파일링을 막지 않는다는 것입니다. 프로덕션에서 실제 동작이 훈련 때와 달라지면 JVM은 계속해서 실시간 프로파일링과 최적화를 병행합니다. 캐시된 AOT 프로파일은 JIT가 더 빠르고 정확하게 시작할 수 있도록 초기 방향을 잡아주는 역할입니다.

 

JEP 원문 기준, Stream API를 사용하는 소형 프로그램(HelloStreamWarmup)에서도 프로파일 캐싱만으로 웜업 시간이 19% 단축됩니다. (90ms → 73ms) 프로파일 데이터가 캐시에 추가하는 용량은 약 250KB로 부담이 크지 않습니다. 더 복잡한 장수명 서비스일수록 p99 레이턴시와 스케일아웃 초기 성능에서 이 효과가 두드러집니다.

 


6. JEP 516: 모든 GC에서의 AOT 객체 캐싱 (Java 26)

JEP 483~515에는 숨겨진 제약이 있었습니다. ZGC에서 객체 캐싱이 동작하지 않았습니다.

 

기존 AOT 캐시는 G1, Parallel, Serial 같은 GC와 비트 단위로 호환되는 형식으로 객체를 저장했습니다. JVM이 이 객체들을 힙 메모리에 직접 mmap으로 매핑할 수 있었기 때문입니다. 그런데 ZGC는 컬러드 포인터(colored pointers) 방식을 사용합니다. 객체 참조 포인터 안에 GC 메타데이터를 직접 인코딩하는 방식인데, 이것이 기존 캐시 포맷과 호환되지 않았습니다. 결과적으로 AOT 캐시와 ZGC는 함께 사용할 수 없었습니다.

 

JEP 516은 이 문제를 GC에 종속되지 않는 스트리밍 포맷을 선택적으로 추가하는 방식으로 해결합니다. 기존 GC-specific 포맷(G1 등에서의 mmap 방식)은 그대로 유지하면서, ZGC를 위한 새로운 GC-agnostic 포맷을 병행 지원합니다.

 

새 포맷에서는 객체 참조를 메모리 주소 대신 논리적 인덱스로 저장합니다. JVM 시작 시 백그라운드 스레드가 캐시에서 객체를 하나씩 읽어 들이며(스트리밍), 각 GC가 자신의 규칙에 따라 메모리에 배치합니다. 여분의 CPU 코어가 있으면 이 백그라운드 작업이 시작 속도를 늦추지 않습니다.

 

추가로 JDK에 기본 AOT 캐시가 동봉됩니다. JDK 12에서 기본 CDS 아카이브가 배포본에 포함된 것처럼, JDK 26부터는 커스텀 훈련 실행 없이도 기본 AOT 캐시의 혜택을 받을 수 있습니다. 이 기본 캐시는 GC-agnostic 포맷으로 제공되므로 ZGC를 포함한 모든 GC에서 작동합니다.

 


7. Spring Boot에서 AOT Cache 적용하기

JDK 24 이상이라면 AOT Cache를 Docker 이미지에 포함시키는 방식으로 적용합니다.

# 1스테이지: 빌드
FROM bellsoft/liberica-runtime-container:jdk-24-cds-musl as builder
WORKDIR /home/app
COPY . /home/app
RUN ./mvnw -Dmaven.test.skip=true clean package

# 2스테이지: 레이어 추출
FROM bellsoft/liberica-runtime-container:jdk-24-cds-slim-musl as optimizer
WORKDIR /app
COPY --from=builder /home/app/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted

# 3스테이지: 최종 이미지 + AOT 캐시 생성
FROM bellsoft/liberica-runtime-container:jdk-24-cds-slim-musl
WORKDIR /app

COPY --from=optimizer /app/extracted/dependencies/ ./
COPY --from=optimizer /app/extracted/spring-boot-loader/ ./
COPY --from=optimizer /app/extracted/snapshot-dependencies/ ./
COPY --from=optimizer /app/extracted/application/ ./

# 훈련 실행: AOT 설정 기록
RUN java -Dspring.aot.enabled=true \
         -XX:AOTMode=record \
         -XX:AOTConfiguration=app.aotconf \
         -Dspring.context.exit=onRefresh \
         -jar /app/app.jar

# AOT 캐시 생성
RUN java -Dspring.aot.enabled=true \
         -XX:AOTMode=create \
         -XX:AOTConfiguration=app.aotconf \
         -XX:AOTCache=app.aot \
         -jar /app/app.jar

# 실행
ENTRYPOINT ["java", "-Dspring.aot.enabled=true", \
            "-XX:AOTCache=app.aot", \
            "-jar", "/app/app.jar"]

표준 컨테이너(5.3초) 대비 AOT Cache + Spring AOT 컨테이너는 약 67% 빠르게 시작합니다. 1편의 AppCDS + Spring AOT(60%)보다 7%p 더 개선됩니다.

 

JDK 25에서는 더 간단하게

JDK 25(JEP 514)를 사용한다면 훈련과 캐시 생성 두 RUN 명령어를 하나로 줄일 수 있습니다.

# 훈련 실행 + 캐시 생성 통합
RUN java -Dspring.aot.enabled=true \
         -XX:AOTCacheOutput=app.aot \
         -Dspring.context.exit=onRefresh \
         -jar /app/app.jar

ENTRYPOINT ["java", "-Dspring.aot.enabled=true", \
            "-XX:AOTCache=app.aot", \
            "-jar", "/app/app.jar"]

 


8. Leyden vs GraalVM Native Image

Leyden이 성숙해질수록 자주 받게 될 질문입니다. 둘은 대체 관계가 아니라 서로 다른 배포 프로파일을 위한 도구입니다.

항목 GraalVM Native Image Project Leyden (현재)
시작 시간 80~95% 단축 40~67% 단축
웜업 시간 없음 (AOT 컴파일) JEP 515로 단축 가능
피크 처리량 JIT 없어 낮을 수 있음 JIT 동작으로 최대 도달
동적 클래스 로딩 제한적 제한 없음
리플렉션 설정 파일 필요 코드 변경 없음
컨테이너 이미지 크기 매우 작음 JDK + 캐시 파일 (40~200MB)
빌드 시간 수 분 (무거움) 훈련 실행 1회 (가벼움)
표준 OpenJDK 포함 별도 배포판 OpenJDK 내장

GraalVM이 더 적합한 경우: CLI 도구, 단명(short-lived) 함수, 극단적으로 낮은 메모리 요구, 컨테이너 이미지 크기가 중요한 환경

 

Leyden이 더 적합한 경우: 복잡한 동적 특성(리플렉션, 동적 프록시)을 가진 장수명 서비스, 피크 처리량이 중요한 환경, 코드 변경 없이 적용해야 하는 경우

 


9. 성능 수치 전체 비교

기술 방법 시작 시간 단축
기본 CDS (Kubernetes 2코어/256MB) JDK 기본 아카이브만 7%
AppCDS (Kubernetes 2코어/256MB) AppCDS 47%
AppCDS + Spring AOT (로컬) AppCDS + AOT 54%
AppCDS + Spring AOT (Docker) AppCDS + AOT 60%
AOT Cache + Spring AOT (Docker) JEP 483 67%
GraalVM Native Image AOT 컴파일 ~90%+

Kubernetes 행은 Quarkus 앱 기반 Red Hat 측정값, 그 외 행은 Spring Petclinic 기반 BellSoft 측정값으로 측정 환경이 다릅니다.

 


10. 정리

1편에서 CDS와 AppCDS가 클래스 로딩 결과를 캐싱해 시작 시간을 40~60% 줄여줬습니다. 2편에서 살펴본 Project Leyden은 그 다음 단계입니다.

 

JEP 483(Java 24)은 Resolution(링킹)까지 캐싱 범위를 확장해 시작 시간을 추가로 단축합니다. JEP 514(Java 25)는 워크플로를 단순화해 사용성을 높입니다. JEP 515(Java 25)는 처음으로 웜업 지연을 공략하며, JEP 516(Java 26)은 ZGC 지원과 기본 AOT 캐시 내장으로 마무리됩니다.

 

CDS에서 AOT Cache로의 전환은 단순한 기능 확장이 아닙니다. "클래스 파일을 파싱한 결과 재사용"에서 "완전히 링크된 클래스를 즉시 제공"으로, 다시 "JIT 웜업 데이터까지 미리 준비"로 확장되는 흐름입니다. JVM이 시작 시 해야 할 일을 점점 더 빌드 타임으로 앞당기는 방향으로, Leyden의 로드맵이 계속됩니다.


참고 자료