hyunsu blog

홈동향

JDK 28 AOT 코드 컴파일과 값 객체, Spring 서버에서 달라지는 범위

목차 · 8
  1. 01AOT 캐시는 JDK 24 부터 릴리스마다 한 칸씩 채워졌다
  2. 02기계어는 기본으로 실리고, 조건이 어긋나면 그 부분만 버린다
  3. 03Spring Boot 는 명령이 그대로이고, 학습 실행이 효과를 정한다
  4. 04값 객체는 == 를 값 비교로 바꾸고, 메모리 이득은 좁은 조건에서만 난다
  5. 05프리뷰를 켜면 정체성에 기댄 코드가 깨진다
  6. 06한국 Java·Spring 팀이라면 운영은 JDK 25 에 두고 EA 로 숫자부터 잰다
  7. 07지금 할 일은 기준값을 재고 학습 실행과 환경을 맞추는 것이다
  8. 08자주 묻는 질문

JDK 28 의 AOT 캐시는 클래스와 메서드 프로파일에 더해, 학습 실행 동안 C1·C2 컴파일러가 만든 기계어까지 담는다. 덕분에 JEP 544 측정에서 프레임워크 기반 앱의 기동 시간이 캐시 없이 띄울 때보다 65~80% 줄어, 기계어 없이 클래스와 프로파일만 담은 캐시(50~70%)보다 한 걸음 더 나갔다. 같은 릴리스의 값 객체(JEP 401)는 --enable-preview 를 켜야 쓰는 프리뷰라서, Spring 서버에서는 2027년 3월 정식 출시 전까지 EA 빌드로 시험해 보는 데 그친다.

01 AOT 캐시는 JDK 24 부터 릴리스마다 한 칸씩 채워졌다

HotSpot 은 띄울 때마다 같은 일을 처음부터 되풀이한다. 클래스를 읽어 링크하고, 인터프리터로 돌리며 프로파일을 모으고, 자주 도는 메서드를 C1·C2 컴파일러로 바꾼 뒤에야 최고 성능에 닿는다. OpenJDK 의 Project Leyden 은 이 과정을 학습 실행(training run, 운영 전에 앱을 한 번 돌려 관찰 결과를 남기는 실행)으로 앞당겨 파일에 저장하는 길을 골랐다. 그 파일이 AOT 캐시이고, 담기는 것이 릴리스마다 늘어 왔다.

JDKJEP캐시에 더해진 것
24483미리 읽고 링크한 클래스
25514-XX:AOTCacheOutput 하나로 학습과 캐시 생성을 한 번에
25515학습 중 모은 메서드 프로파일
28544프로파일을 바탕으로 C1·C2 가 만든 기계어

Spring 은 이미 이 흐름 위에 올라와 있다. Spring Boot 4.1 문서는 Java 25 이상이면 AOT 캐시를, 그보다 낮으면 그 전신인 CDS(Class Data Sharing)를 쓰라고 안내한다. JEP 544 도 새 절차를 만들지 않고 기존 캐시 워크플로를 넓히는 것을 목표로 적었다. 그래서 JDK 28 에서 바뀌는 것은 명령이 아니라, 같은 명령이 만든 파일 안에 실리는 내용이다.

02 기계어는 기본으로 실리고, 조건이 어긋나면 그 부분만 버린다

JDK 28 에서 -XX:AOTCacheOutput=app.aot 로 학습 실행을 돌리면 HotSpot 이 뜨거운 메서드의 기계어를 기본으로 만들어 캐시에 넣는다. 운영에서는 -XX:AOTCache=app.aot 만 주면 그 코드를 곧바로 올린다. 추가 옵션은 없다. 끄고 싶을 때만 -XX:+UnlockDiagnosticVMOptions -XX:-AOTCodeCaching 을 주는데, 학습 실행에 주면 생성을, 운영 실행에 주면 로딩을 막는다.

AOT 코드만으로 도는 모드는 없다. 미리 만든 코드와 JIT 코드는 같은 C1·C2 가 만들었기 때문에 섞여 돈다. 운영 부하가 학습 때와 달라 코드가 역최적화되면 평소처럼 인터프리터와 JIT 로 돌아간다. 모든 코드를 미리 굳히는 정적 컴파일과 달리, 실행 중에 다시 최적화할 여지를 남겨 둔 설계다.

JEP 544 가 밝힌 측정은 아래와 같다. 다섯 개 프레임워크 앱은 마이크로서비스 환경을 흉내 내려고 두 코어 리눅스 x64 에서 돌렸다. 코어가 적으면 JIT 컴파일러가 앱과 CPU 를 다투느라 기동이 늘어지기 때문이다.

측정(JDK 28 EA)캐시만(기계어 없음)캐시 + 기계어
프레임워크 앱 5개 기동 시간약 50~70% 감소약 65~80% 감소
javac 벤치마크 첫 반복약 30% 개선약 75% 개선

javac 벤치마크는 소스 파일 50개를 20번 되풀이해 컴파일한다. 기계어를 실은 캐시에서는 네 번째 반복쯤에 이미 정상 상태에 가까운 속도가 나왔다.

대신 기계어는 실행 환경에 묶인다. 클래스와 프로파일만 담던 때보다 맞춰야 할 조건이 늘었다.

  • CPU 아키텍처와 기능 집합: AVX-512 가 있는 x64 에서 만든 코드는 그 기능이 없는 x64 에서 쓰이지 않는다.
  • GC: 기계어 안에 GC 마다 다른 읽기·쓰기 배리어가 들어 있어 학습과 운영의 GC 가 같아야 한다.
  • JDK 와 플랫폼: JEP 483 때부터 이어진 일관성 조건이 그대로 걸린다.
  • 지원 아키텍처: 기계어 캐시는 x64 와 AArch64 에서만 만든다.

조건이 어긋나면 HotSpot 은 경고를 남기고 기계어만 건너뛴다. 캐시 안의 클래스와 프로파일은 그대로 쓰므로 앱은 정상으로 돌고, 빨라지는 폭만 줄어든다. 치르는 값도 있다. 캐시 파일이 눈에 띄게 커질 수 있고, 미리 만든 코드는 아직 초기화되지 않았을 수 있는 클래스의 static final 값을 상수로 박아 넣지 못해 실행 때 읽는다.

03 Spring Boot 는 명령이 그대로이고, 학습 실행이 효과를 정한다

Spring Boot 4.1 문서의 절차는 세 단계다. 실행 가능한 jar 를 풀고, 컨텍스트가 뜨자마자 끝나는 학습 실행으로 캐시를 만든 뒤, 그 캐시로 운영 실행을 띄운다. JDK 28 에서도 이 명령은 그대로다. 같은 학습 실행이 기계어까지 실을 뿐이다.

bash
# 1) 실행 가능한 jar 를 푼다(캐시는 풀린 형태에서만 효과가 있다)
java -Djarmode=tools -jar my-app.jar extract --destination application
cd application

# 2) 학습 실행: ApplicationContext refresh 직후 끝내며 app.aot 를 만든다
java -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh -jar my-app.jar

# 3) 운영 실행: 같은 JDK, 같은 클래스패스로 캐시를 쓴다
java -XX:AOTCache=app.aot -jar my-app.jar

여기서 눈여겨볼 값은 spring.context.exit=onRefresh 다. 지연 초기화가 아닌 싱글턴을 모두 만든 시점, 생명주기가 시작되기 전에 프로세스를 끝낸다. 요청을 한 건도 처리하지 않는 학습 실행인 셈이다. Spring Framework 문서도 이 방식은 기동만 최적화하므로, 실행 성능까지 얻으려면 운영과 비슷한 작업을 처리한 뒤 캐시를 만들라고 권한다.

JEP 544 는 학습 중 뜨거웠던 메서드만 기계어로 만든다. 이 점을 겹쳐 보면 onRefresh 캐시는 기동 시간을 줄여 주고, 컨트롤러·직렬화 같은 요청 경로의 워밍업은 운영 중 JIT 의 몫으로 남긴다. 학습 실행을 실제에 가깝게 만들면 다른 문제가 따라온다. Spring Boot 문서는 학습 실행이 앱을 실제로 띄우기 때문에 생명주기 전에 데이터베이스 같은 원격 서비스에 붙을 수 있다고 경고하고, 학습 전용 설정으로 그런 연결을 막으라고 안내한다.

Cloud Native Buildpacks 로 이미지를 만든다면 BP_JVM_AOTCACHE_ENABLED=true 하나로 학습 실행부터 캐시 탑재까지 맡길 수 있다(Java 25 이상). 학습 실행에만 JVM 옵션을 따로 주려면 TRAINING_RUN_JAVA_TOOL_OPTIONS 를 쓴다. 이때 학습 실행은 이미지를 굽는 빌드 기계에서 돈다는 점을 기억해야 한다. JDK 28 의 기계어는 CPU 기능 집합에 묶이므로, CI 러너와 운영 노드의 CPU 세대가 다르면 기계어가 통째로 버려질 수 있다.

Spring Framework 문서는 이 밖에도 조건을 든다. 캐시를 만든 JVM 과 똑같은 JVM 을 쓰고, 클래스패스는 디렉터리나 와일드카드가 아닌 jar 목록으로 같은 순서를 지키며, jar 의 타임스탬프를 보존해야 한다. 실제로 캐시를 쓰는지는 로그로 확인한다.

bash
# 클래스가 캐시에서 올라오면 "source: shared objects file" 이 찍힌다
java -XX:AOTCache=app.aot -Xlog:class+load:file=aot-cache.log -jar my-app.jar

# JDK 28: AOT 코드 로딩과 JIT 컴파일을 함께 출력한다
java -XX:AOTCache=app.aot -XX:+PrintCompilation -jar my-app.jar

# 비교용: 기계어 없이 클래스·프로파일만 담은 캐시를 만든다
java -XX:+UnlockDiagnosticVMOptions -XX:-AOTCodeCaching \
  -XX:AOTCacheOutput=app-nocode.aot -Dspring.context.exit=onRefresh -jar my-app.jar

04 값 객체는 == 를 값 비교로 바꾸고, 메모리 이득은 좁은 조건에서만 난다

값 객체는 Project Valhalla 가 오래 준비해 온 기능으로, 정체성(identity)이 없고 필드 값으로만 구별되는 불변 객체다. 클래스 앞에 value 를 붙이면 클래스와 필드가 암묵적으로 final 이 되고, 레코드에도 value record 로 붙일 수 있다. 값 객체끼리의 == 는 같은 값 클래스이고 필드가 같으면 참이다. 논리적 동등성은 여전히 equals 로 본다.

text
value record Point(int x, int y) {}

Objects.hasIdentity(new Point(1, 2));   // false

Integer a = 1996, b = 1996;
a == b;   // 프리뷰 끔: false(Integer 캐시 범위 밖) / 프리뷰 켬: true
bash
javac --release 28 --enable-preview Main.java
java --enable-preview Main

Spring 서버에 먼저 닿는 쪽은 직접 만드는 값 클래스보다 JDK 클래스다. 프리뷰를 켜면 JDK 의 30개 클래스가 값 클래스로 바뀐다. Integer·Long 같은 래퍼, Optional, LocalDate·LocalDateTime·Instant·Duration 같은 java.time 타입이 여기에 들고, String 은 빠진다. 프리뷰를 끄면 이 클래스들은 JDK 27 과 똑같이 동작한다.

메모리를 줄이는 장치는 둘이다. 평탄화(flattening)는 필드나 배열 칸에 참조 대신 값을 직접 넣고, 스칼라화(scalarization)는 JIT 가 컴파일한 코드에서 값 객체를 힙에 만들지 않고 지역 변수처럼 다룬다. 다만 JEP 401 은 특정 메모리 배치를 약속하지 않고, 측정 수치도 내놓지 않았다. Valhalla 프로젝트 문서가 밝힌 조건도 좁다.

  • 평탄화된 값은 원자적으로 읽고 쓰며 null 표시까지 품어야 해서, 필드 데이터가 64비트 이상인 값 객체는 보통 평탄화되지 않는다.
  • 연·월·일 48비트인 LocalDate 는 null 표시를 더해도 64비트 한 칸에 들어가지만, 규칙대로라면 64비트 필드 하나인 Long·Double 은 대상에서 벗어난다.
  • 제네릭 API 안의 변수처럼 여러 구체 타입을 담는 자리는 평탄화되지 않고, 스칼라화도 대개 일어나지 않는다.
  • 스칼라화는 C2 컴파일 뒤에만 일어나므로, 워밍업 동안은 평소처럼 힙에 할당한다.
  • 프리뷰 없이 컴파일된 예전 클래스 파일은 최적화가 덜 되므로, 값 클래스를 참조하는 클래스를 모두 --enable-preview 로 다시 컴파일해야 제 성능이 난다.

이 조건을 Spring 서버에 대 보면 이득이 날 자리는 좁다. JPA 엔티티처럼 필드를 바꾸는 객체는 값 클래스가 될 수 없고, 요청·응답 DTO 는 대부분 List<…>·Map<…> 같은 제네릭 컬렉션과 프레임워크 코드를 거친다. 효과를 기대할 곳은 구체 타입 배열이나 필드에 작은 값을 대량으로 쌓는 핫 경로 정도다. 같은 릴리스의 JEP 539(JVM 의 엄격한 필드 초기화, 프리뷰)는 필드가 기본값 0·null 로 읽히기 전에 반드시 초기화되게 하는 JVM 쪽 장치로, 클래스 파일을 내보내는 컴파일러를 위한 것이며 JEP 401 과 짝을 이룬다.

05 프리뷰를 켜면 정체성에 기댄 코드가 깨진다

Java 16 부터 값 기반 클래스에 synchronized 를 거는 코드에는 경고가 붙어 왔다. JDK 28 프리뷰에서는 그 경고가 오류로 바뀐다. 기존 코드에서 부딪힐 만한 경우를 모으면 다음과 같다.

기존 코드프리뷰를 켠 JDK 28
Integer·LocalDate 타입 변수에 synchronized컴파일 오류
Object 로 받은 값 객체에 synchronized실행 중 IdentityException
값 객체에 wait·notifyIllegalMonitorStateException
WeakHashMap 키나 java.lang.ref 참조로 값 객체 사용IdentityException
Integer 를 == 로 비교값이 같으면 범위와 상관없이 true
레코드가 아닌 값 클래스 직렬화writeReplace·readResolve 가 없으면 InvalidClassException
레코드 생성자에서 필드를 다 넣기 전에 this 사용컴파일 오류

마지막 줄은 값 레코드만이 아니라, 프리뷰를 켠 상태의 모든 레코드 생성자에 걸린다. JEP 는 이런 코드가 드물고 대개 버그라고 본다. JDK 28 의 javac 는 값 클래스를 이런 API 와 함께 쓰면 identity 경고를, 실행 중 직렬화에 실패할 값 클래스에는 serial 경고를 낸다.

06 한국 Java·Spring 팀이라면 운영은 JDK 25 에 두고 EA 로 숫자부터 잰다

기계어 캐시를 가장 크게 체감할 곳은 파드를 자주 늘리고 줄이는 Kubernetes 위 Spring 서비스, 그리고 롤링 배포 시간이 기동 시간에 묶인 팀이다. JEP 측정을 두 코어에서 한 이유처럼, CPU 한도를 작게 건 컨테이너일수록 JIT 와 앱이 CPU 를 다툰다. 반대로 한 번 띄우면 몇 주씩 도는 서버라면 기동 몇 초가 운영을 바꾸지 않으니 서두를 이유가 적다.

시점은 지원 주기가 정한다. JDK 28 은 LTS 가 아니고, InfoWorld 에 따르면 Oracle 지원 기간이 6개월이다. JDK 25 는 대부분 벤더가 LTS 로 지원하는 릴리스다. 그러니 운영 업그레이드 계획은 25 를 기준으로 세우고, 28 은 EA 로 효과만 확인해 두는 편이 맞다.

아직 17·21 위에 있다면 순서가 다르다. Spring Boot 의 AOT 캐시 지원이 Java 25 부터라, 기계어 캐시를 논하기 전에 25 로 올리는 것이 먼저다. 기계어를 뺀 캐시, 곧 JDK 25 에서도 만들 수 있는 구성만으로도 JEP 544 측정에서 기동이 50~70% 줄었다.

값 객체는 운영 코드에 넣을 기능이 아니다. 프리뷰로 컴파일한 클래스 파일은 실행할 때도 --enable-preview 가 있어야 돈다. 지금 할 일은 위 표의 코드를 미리 찾아 고치는 것이다. 래퍼 타입에 락을 거는 코드나 Integer 를 == 로 비교하는 코드는 프리뷰와 상관없이 이미 버그 후보다.

시험 환경도 하나 챙길 것이 있다. 최신 EA 는 2026년 10월 15일에 나온 빌드 20 이다. 내려받을 수 있는 것은 리눅스 x64·AArch64, macOS AArch64, Windows x64 용뿐이라, 인텔 맥을 쓰는 개발자는 리눅스 컨테이너나 CI 에서 시험해야 한다.

07 지금 할 일은 기준값을 재고 학습 실행과 환경을 맞추는 것이다

  1. JDK 25 에서 Spring Boot 문서 절차로 AOT 캐시를 만들고, 캐시 없는 기동 시간과 비교한 기준값을 남긴다.
  2. 같은 jar 를 JDK 28 EA 로 다시 학습시켜, 기계어를 담은 캐시와 -XX:-AOTCodeCaching 캐시의 기동 시간·첫 요청 지연을 나란히 잰다.
  3. onRefresh 학습과 운영과 비슷한 요청을 흘린 학습을 따로 만들어 워밍업 차이를 보되, 외부 연결은 학습 전용 설정으로 막는다.
  4. 학습과 운영의 GC 옵션을 똑같이 명시하고, 빌드 기계와 운영 노드의 CPU 차이로 기계어가 버려졌다는 경고가 없는지 로그로 확인한다.
  5. 캐시 파일이 커진 만큼 이미지 크기와 배포 시간이 얼마나 늘었는지도 함께 기록한다.
  6. 별도 브랜치에서 --enable-preview 로 컴파일해 깨지는 코드 목록을 만들고, 메모리 효과는 JMH 나 JDK Mission Control 로 직접 잰다.

08 자주 묻는 질문

JDK 25 로도 AOT 캐시를 쓸 수 있나

예. Spring Boot 4.1 은 Java 25 이상에서 AOT 캐시를 지원하고, 25 의 캐시에는 클래스와 메서드 프로파일이 담긴다. 기계어가 더해지는 것은 JDK 28 부터다.

캐시를 만든 기계와 CPU 가 다르면 앱이 뜨지 않나

아니오. HotSpot 은 경고를 남기고 기계어만 건너뛴 뒤 클래스와 프로파일은 그대로 쓴다. 기동이 덜 빨라질 뿐 동작은 같다.

프리뷰를 켜면 Spring 앱 메모리가 바로 줄어드나

아니오. 평탄화는 구체 값 클래스 타입에 64비트 미만 데이터일 때 주로 일어나고, 제네릭 컬렉션 안의 값은 평탄화되지 않는다. JEP 401 도 측정 수치를 내놓지 않았다.