운영 중인 WAS를 LTS에서 LTS로 건너뛰는 일은 몇 년에 한 번 돌아온다. 제가 현장에서 Java 8에서 11로, 다시 17과 21로 올릴 때마다 가장 신경 쓰는 건 성능 수치보다 '예전에 넣어둔 JVM 옵션이 새 버전에서 그대로 뜨느냐'였다. JDK 25가 LTS로 나오면서 다시 같은 상황을 마주했고, 이번엔 힙을 줄여 준다는 컴팩트 객체 헤더가 추가로 걸려 있어 정리해 둔다.
결론부터 말하자면, JDK 21에서 25로 올리는 일 자체는 바이트코드 수준에서 큰 단절이 없어 재컴파일 없이 대부분 실행된다. 다만 기동 옵션 두 축에서 막힌다. 하나는 JDK 24에서 이미 사라진 비세대 ZGC와 Security Manager 관련 옵션이고, 다른 하나는 새로 쓰려는 -XX:+UseCompactObjectHeaders다. 전자는 서비스가 아예 안 뜨게 만들 수 있고, 후자는 기본값이 꺼짐이라 명시적으로 켜야 효과가 난다.
객체 헤더가 무엇이고 왜 줄이나
HotSpot JVM은 모든 객체 앞에 메타데이터를 붙인다. 이걸 객체 헤더라고 부르는데, 식별 해시와 락 상태를 담는 mark word, 그리고 그 객체가 어느 클래스인지 가리키는 class word로 이루어진다. 64비트 JVM에서 이 헤더는 보통 128비트, 즉 16바이트를 차지했다.
문제는 작은 객체가 많은 워크로드다. 필드가 몇 개뿐인 객체라도 헤더 16바이트가 항상 따라붙으니, 힙의 상당 부분을 헤더가 먹는다. 컴팩트 객체 헤더는 이 헤더를 64비트, 8바이트로 줄인다. class 정보를 mark word 안에 압축해 넣는 방식이다.
JEP 519는 이 기능을 JDK 24의 실험 단계(JEP 450)에서 정식 제품 기능으로 승격한 것이다. JEP 문서는 SPECjbb2015 기준으로 한 구성에서 힙을 약 22%, CPU 시간을 약 8% 줄였고 G1과 Parallel에서 GC 횟수가 약 15% 감소했다고 보고한다. 실제 절감폭은 객체 크기 분포에 따라 달라지므로, 작은 객체가 많은 서비스일수록 이득이 크다.
JDK 25 설치와 버전 확인
먼저 새 런타임을 깔고 버전을 확인한다. 배포판은 Temurin, Oracle, Corretto 어느 쪽이든 GA 빌드면 된다.
$ java -version openjdk version "25" 2025-09-16 OpenJDK Runtime Environment Temurin-25+36 (build 25+36) OpenJDK 64-Bit Server VM Temurin-25+36 (build 25+36, mixed mode, sharing)
빌드 번호(25+36)는 배포판과 패치 수준에 따라 다를 수 있다. 중요한 건 64-Bit Server VM이라는 점이다. 컴팩트 객체 헤더는 64비트 JVM에서만 동작하고, JDK 25부터는 32비트 x86 포트 자체가 JEP 503으로 제거됐다.
컴팩트 객체 헤더 켜고 확인하기
JDK 24에서는 -XX:+UnlockExperimentalVMOptions를 함께 줘야 했지만, JDK 25에서는 그 잠금 해제 플래그가 필요 없다. 바로 켠다.
$ java -XX:+UseCompactObjectHeaders -jar app.jar
기본값은 꺼짐이다. 켜져 있는지 확인하려면 실행 중인 프로세스에 jcmd를 쓰거나 플래그를 덤프한다.
$ jcmd <pid> VM.flags | tr ' ' '\n' | grep CompactObjectHeaders -XX:+UseCompactObjectHeaders
헤더 크기가 실제로 줄었는지 눈으로 보려면 JOL(Java Object Layout)로 바닥 객체의 인스턴스 크기를 찍어 본다. 기본 설정에서 java.lang.Object는 16바이트지만, 컴팩트 헤더를 켜면 8바이트로 내려간다.
$ java -XX:+UseCompactObjectHeaders -jar jol-cli.jar internals java.lang.Object # ... (생략) java.lang.Object object internals: OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) ... Instance size: 8 bytes
저는 프로덕션에 바로 넣기보다, 먼저 스테이징에서 같은 트래픽을 흘려 보고 GC 로그로 old gen 점유와 GC 주기를 비교한다. -Xlog:gc*:file=gc.log:time,uptime를 붙여 두고 Used heap after GC가 얼마나 내려가는지 본다. 작은 객체가 적은 서비스라면 차이가 미미할 수 있으니, 효과를 수치로 확인한 뒤 유지 여부를 정한다.
JDK 24에서 이미 제거된 옵션이 발목을 잡는다
21에서 25로 건너뛰면 23과 24에서 생긴 제거가 한꺼번에 몰려온다. 가장 흔한 사고가 ZGC 옵션이다. JDK 23에서 세대별 ZGC가 기본이 되면서 비세대 모드가 폐기됐고, 24에서 완전히 제거됐다.
$ java -XX:+UseZGC -XX:-ZGenerational -version [0.001s][info][gc] Ignoring option ZGenerational; support was removed in 24.0
이 경우는 무시만 하고 뜨긴 뜬다. 하지만 아래 두 개는 JVM이 기동조차 못 한다.
$ java -Xfuture -version Unrecognized option: -Xfuture Error: Could not create the Java Virtual Machine. Error: A fatal exception has occurred. Program will exit.
-Xfuture는 -Xverify:all로 대체됐고, -Xnoagent도 똑같이 인식 불가로 바뀌었다. 예전 기동 스크립트에 관성으로 남아 있던 옵션들이라, 서버가 안 떠서 당황하기 쉽다. 저는 올리기 전에 JAVA_OPTS와 CATALINA_OPTS, 그리고 systemd 유닛 파일을 전부 열어 다음 항목을 먼저 지운다.
-XX:-ZGenerational: ZGC는 세대별 모드만 남았다. 옵션 자체를 제거한다.-Xfuture,-Xnoagent: 인식 불가로 기동 실패. 제거하거나-Xverify:all로 교체한다.-Xdebug,-noclassgc: 폐기 경고가 뜬다. 지금 정리해 둔다.- Graal JIT 컴파일러(
-XX:+UseJVMCICompiler계열): 실험용 Graal JIT이 JDK 25에서 제거됐다. - 진단 플래그
-XX:GCLockerRetryAllocationCount: 더 필요 없어져 제거됐다.
Security Manager를 켜는 옵션은 기동을 막는다
오래된 WAS 기동 스크립트에서 특히 조심할 부분이다. Security Manager는 JEP 411로 폐기 수순을 밟아 왔는데, JDK 25에서는 활성화 자체가 막혔다. -Djava.security.manager=allow로 뜨려 하면 main에 닿기도 전에 VM 초기화에서 멈춘다.
$ java -Djava.security.manager=allow -jar app.jar Error occurred during initialization of VM A command line option has attempted to allow or enable the Security Manager. Enabling a Security Manager is not supported.
유효한 값은 -Djava.security.manager=disallow 뿐이다. 애플리케이션 코드에서 System.setSecurityManager()를 호출하던 레거시가 있다면 그 부분을 걷어내야 한다. 더불어 -Djava.locale.providers=COMPAT도 이제 무시되므로, 날짜나 숫자 포맷을 이 설정에 기대고 있었다면 CLDR 기준으로 출력이 달라질 수 있다. 로케일 포맷이 민감한 서비스라면 업그레이드 후 반드시 눈으로 확인한다.
Tomcat과 Spring Boot 호환성 점검
런타임을 올렸으면 그 위에 얹힌 WAS와 프레임워크가 JDK 25를 지원하는지 봐야 한다. Tomcat부터 보면, Tomcat 11.0.x는 Java 17 이상을 요구하므로 JDK 25에서 그대로 돈다. 가상 스레드 같은 기능은 Java 21 이상이면 쓸 수 있으니 25에서도 문제없다. Tomcat 10.1.x는 Java 11 이상이 기준이라 역시 실행된다.
Tomcat에서 컴팩트 헤더나 GC 옵션을 줄 자리는 bin/setenv.sh의 CATALINA_OPTS다.
# $CATALINA_BASE/bin/setenv.sh export CATALINA_OPTS="$CATALINA_OPTS -XX:+UseG1GC -XX:+UseCompactObjectHeaders"
Spring Boot는 버전 선택이 중요하다. Spring Boot 4.0은 Java 25를 1차 지원 대상으로 올리면서 Java 17 호환은 유지한다. 2025년 11월에 나온 4.0, 2026년 6월에 나온 4.1 모두 Spring Framework 7 기반이고 Java 17을 최저선으로 둔다. 그래서 JDK 25를 정식으로 받으려면 Spring Boot 4.0 이상으로 함께 올리는 걸 권한다.
기존 Spring Boot 3.x 애플리케이션은 Java 17이 최저선이라 JDK 25에서 기동은 되지만, 25에 대한 공식 인증 범위 밖이다. 당장 프레임워크를 못 올리는 상황이면 JDK 25 런타임에서 실행하되, 라이브러리 호환 문제가 보이면 프레임워크 업그레이드를 미루지 말라는 쪽이다. 바이트코드 조작을 많이 하는 바이트 버디, ASM, cglib 계열 의존성은 새 클래스 파일 버전을 못 읽어 UnsupportedClassVersionError나 내부 예외를 던지기 쉬우니, 의존성 버전부터 최신으로 맞춘다.
업그레이드 순서
제가 운영 서버에서 밟는 순서는 이렇다.
- 기동 스크립트와 systemd 유닛에서 제거된 옵션(ZGenerational, -Xfuture, -Xnoagent, Security Manager allow)을 먼저 걷어낸다.
- JDK 25를 설치하고 기존 옵션 그대로 스테이징에서 기동해 본다. 여기서 안 뜨면 남은 레거시 옵션을 찾는다.
- Tomcat, Spring Boot, 바이트코드 조작 라이브러리 버전을 호환 범위로 맞춘다.
- 스테이징에서
-XX:+UseCompactObjectHeaders를 켜고 GC 로그로 힙 점유를 비교한다. - 효과가 확인되면 프로덕션에 반영하고, 컴팩트 헤더는 롤백이 쉽도록 옵션 한 줄로만 관리한다.
컴팩트 객체 헤더는 옵션 하나를 빼면 즉시 원래 레이아웃으로 돌아오니, 도입과 철회의 위험이 낮은 편이다. 반면 제거된 기동 옵션은 롤백 대상이 아니라 정리 대상이다. LTS 건너뛰기에서 서비스가 안 뜨는 사고는 거의 이쪽에서 난다.