본문 바로가기
Java

Java 8에서 Java 25로 올릴 때 확인할 것

aappsroot·2026년 9월 21일·조회 4

Java 8 지원 종료 이야기가 나올 때마다 "다음 분기에 하자"며 미뤄 둔 업그레이드 프로젝트가 어느 조직에나 하나쯤 있다. Tomcat 위에서 몇 년째 조용히 돌아가는 서비스라면 더 그렇다. 이번에는 11, 17, 21을 거치지 않고 Java 25로 한 번에 넘어가려는 경우를 기준으로, 제가 점검할 때 실제로 보는 순서를 적어 둔다.

Java 8 애플리케이션을 Java 25로 올리면 컴파일 에러보다 먼저 네 곳에서 문제가 생긴다. 기동 스크립트의 JVM 옵션, 모듈 캡슐화와 제거된 API, 서드파티 라이브러리와 에이전트의 호환성, 그리고 GC, 인코딩, 로케일, TLS 같은 런타임 기본값이다. 이 순서대로 점검하면 대부분의 장애를 운영 반영 전에 잡을 수 있다.

1. 개요

대상 버전과 확인 환경은 아래와 같다. 본문의 실행 결과는 모두 이 두 JDK에서 직접 확인한 것이다. 버전 번호는 받는 시점의 배포 상태에 따라 다를 수 있다.

  • 출발: Eclipse Temurin JDK 8u504 (java.version = 1.8.0_504)
  • 도착: Eclipse Temurin JDK 25.0.4.1 (java.version = 25.0.4.1)
  • 기준 문서: OpenJDK JEP 문서, Apache Tomcat, Spring Boot, Gradle 공식 문서

Java 8과 25 사이에는 LTS가 네 개(11, 17, 21, 25) 있다. 17과 25만 비교한 내용과 배포판별 지원 기간은 예전에 정리한 Java 17과 Java 25의 차이에 있으니, 여기서는 Java 8에서 출발할 때만 생기는 문제에 집중한다.

Java 9에서 들어온 모듈 시스템(JPMS, JEP 261)을 먼저 짚고 가자. JDK 자체를 java.base, java.sql 같은 모듈로 쪼개고, 모듈이 밖으로 공개(export)하지 않은 패키지는 외부 코드가 접근하지 못하게 막는 구조다. Java 8에서는 sun.miscjava.lang 내부 필드를 리플렉션으로 마음대로 건드릴 수 있었지만, 이 장치 때문에 Java 25에서는 그런 코드가 실행 시점에 실패한다. 아래에서 다루는 문제 상당수가 여기서 나온다.

아래에서는 전략 결정, JVM 옵션, 모듈 캡슐화, 사라진 API, 런타임 기본값, 라이브러리와 빌드 도구 순서로 차례로 살펴본다.

2. 먼저 정할 전략

2-1. 런타임을 먼저 올리고 언어는 나중에

JDK 8로 컴파일한 클래스는 JDK 25에서 그대로 돈다. 클래스 파일 버전으로 확인해 보겠습니다.

$ /opt/jdk8/bin/javac Hello.java
$ javap -v Hello.class | grep major
  major version: 52

$ /opt/jdk25/bin/java Hello
Hello

$ /opt/jdk25/bin/javac Hello.java
$ javap -v Hello.class | grep major
  major version: 69

JDK 8의 javac는 52, JDK 25의 javac는 69를 만든다. 하위 버전 클래스는 상위 JVM에서 실행되므로, 소스와 빌드는 그대로 두고 운영 JVM만 25로 바꿔 띄워 보는 것이 첫 단계로 가장 싸다. 이 단계에서 3장부터 7장의 문제가 대부분 드러난다.

2-2. --release 8은 아직 되지만 경고가 붙는다

빌드 JDK를 25로 바꾸면서 산출물은 Java 8 호환으로 유지하고 싶다면 --release 8을 쓴다. 동작은 하지만 곧 없어질 옵션이라는 경고가 나온다.

$ /opt/jdk25/bin/javac --release 8 Hello.java
warning: [options] source value 8 is obsolete and will be removed in a future release
warning: [options] target value 8 is obsolete and will be removed in a future release
warning: [options] To suppress warnings about obsolete options, use -Xlint:-options.
3 warnings

$ /opt/jdk25/bin/javac --release 7 Hello.java
error: release version 7 not supported

-source 8 -target 8 조합을 쓰던 빌드라면 "bootstrap class path is not set in conjunction with -source 8" 경고와 함께 --release 8을 쓰라는 안내가 나온다. -source/-target은 Java 8에 없는 API를 써도 잡아내지 못하고, --release는 해당 버전의 공개 API만 허용해 컴파일 에러로 막아 준다. Maven이라면 maven.compiler.release 속성으로 적으면 됩니다.

<properties>
  <maven.compiler.release>8</maven.compiler.release>
</properties>

--release 8은 운영 JVM을 8과 25로 섞어 두는 전환 기간에만 쓰고, 전 서버가 25로 넘어가면 바로 25(또는 최소 17)로 올리는 것을 권한다. 7은 이미 지원 대상에서 빠졌고 8도 deprecated 상태여서, 장기적으로 유지할 설정으로 보기는 어렵다.

2-3. 단계 업그레이드보다 한 번에 25로

11, 17, 21을 하나씩 밟으면 회귀 테스트를 네 번 돌려야 한다. 제 판단으로는 JDK는 바로 25로 가고, 프레임워크 전환만 별도 단계로 나누는 쪽이 비용이 적다. javax에서 jakarta로 바뀌는 네임스페이스 전환(7장)은 코드 전체를 건드리므로 JDK 교체와 같은 릴리스에 섞지 않는다.

3. JVM 옵션 정리

막상 JDK만 바꿔서 Tomcat을 띄워 보면 가장 먼저 걸리는 곳이 setenv.sh다. 몇 년 전 튜닝 글을 보고 넣은 CMS나 PermGen 옵션이 그대로 남아 있는 경우가 많다.

3-1. 옵션별 결과

각 옵션을 JDK 25.0.4.1에 단독으로 지정해 기동한 결과다.

옵션JDK 25 결과조치
-XX:+UseConcMarkSweepGC, -XX:+CMSClassUnloadingEnabled, -XX:+UseParNewGC기동 실패 (Unrecognized VM option)삭제. GC는 G1 기본 또는 명시
-XX:PermSize, -XX:MaxPermSize기동 실패삭제. 필요하면 -XX:MaxMetaspaceSize
-XX:+PrintGCDateStamps, -XX:+PrintGCTimeStamps기동 실패-Xlog 데코레이터로 대체
-XX:+UseBiasedLocking, -XX:+AggressiveOpts기동 실패삭제
-Xbootclasspath/p:기동 실패 (전용 메시지)패치 대상 라이브러리 교체, 또는 --patch-module 검토
-Djava.ext.dirs, -Djava.endorsed.dirs기동 실패 (전용 메시지)JAR를 classpath로 옮김
-XX:+PrintGC, -XX:+PrintGCDetails, -Xloggc:경고 후 기동-Xlog:gc*로 변경
--illegal-access=permit경고 후 무시삭제. 필요한 곳만 --add-opens
-XX:+UseG1GC, -XX:+UseStringDeduplication, -XX:+UseCompressedOops, -XX:-UseAdaptiveSizePolicy, -XX:+DisableExplicitGC, -XX:+HeapDumpOnOutOfMemoryError정상 기동유지 가능

3-2. CMS는 무시되지 않는다

흔한 오해가 "없어진 GC 옵션은 무시되고 기본 GC로 뜬다"는 것이다. CMS는 JEP 363으로 JDK 14에서 제거됐다. 제거 직후에는 경고를 남기고 기본 GC로 기동했지만, 이후 옵션 자체가 만료되어 JDK 25에서는 아예 JVM이 뜨지 않는다.

$ /opt/jdk25/bin/java -XX:+UseConcMarkSweepGC -version
Unrecognized VM option 'UseConcMarkSweepGC'
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.

$ /opt/jdk25/bin/java -Xbootclasspath/p:x.jar -version
-Xbootclasspath/p is no longer a supported option.
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.

$ /opt/jdk25/bin/java -Djava.ext.dirs=/tmp -version
-Djava.ext.dirs=/tmp is not supported.  Use -classpath instead.
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.

Tomcat이라면 catalina.sh start는 성공처럼 보이고 catalina.out에만 위 메시지가 남는다. systemd 유닛도 Type이나 기동 방식(start인지 run인지)에 따라 active로 보였다가 뒤늦게 failed로 바뀔 수 있다. 배포 직후 헬스체크가 실패하면 catalina.out 첫 줄부터 확인한다.

3-3. 경고만 나오는 옵션

$ /opt/jdk25/bin/java -XX:+PrintGCDetails -version
[warning][gc] -XX:+PrintGCDetails is deprecated. Will use -Xlog:gc* instead.

$ /opt/jdk25/bin/java -Xloggc:gc.log -version
[warning][gc] -Xloggc is deprecated. Will use -Xlog:gc:gc.log instead.

$ /opt/jdk25/bin/java --illegal-access=permit -version
OpenJDK 64-Bit Server VM warning: Ignoring option --illegal-access=permit; support was removed in 17.0

뜨기는 하지만 로그 형식이 바뀌어 GC 로그를 파싱하던 모니터링 스크립트가 깨질 수 있다. 이번 기회에 통합 로깅(JEP 271, JDK 9) 형식으로 옮긴다.

3-4. setenv.sh 변경 전/후

변경 전 (Java 8 시절 전형적인 구성):

CATALINA_OPTS="-Xms2g -Xmx2g \
  -XX:PermSize=256m -XX:MaxPermSize=512m \
  -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:+CMSClassUnloadingEnabled \
  -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:$CATALINA_BASE/logs/gc.log \
  -XX:+HeapDumpOnOutOfMemoryError"

변경 후:

CATALINA_OPTS="-Xms2g -Xmx2g \
  -XX:+UseG1GC \
  -Xlog:gc*:file=$CATALINA_BASE/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=20m \
  -XX:+HeapDumpOnOutOfMemoryError"

-Xlog 문법은 -Xlog:무엇:출력:데코레이터:출력옵션 순서다. time, uptime이 예전 DateStamps와 TimeStamps 역할을 하고, filecountfilesize로 JVM이 직접 로테이션한다.

3-5. 옵션 전수 점검

옵션은 setenv.sh에만 있지 않다. systemd 유닛, Dockerfile, 배포 스크립트까지 한 번에 훑는다.

$ grep -rnE "ConcMarkSweep|CMS|ParNew|PermSize|PrintGC|Xloggc|BiasedLocking|AggressiveOpts|bootclasspath/p|ext\.dirs|endorsed\.dirs|illegal-access" \
    $CATALINA_BASE/bin /etc/systemd/system /etc/default Dockerfile* 2>/dev/null

걸린 줄은 3-1 표대로 정리한다. 정리 후에는 java $CATALINA_OPTS -version으로 기동 여부만 먼저 확인해 두면 배포 단계에서 헤매지 않는다.

4. 모듈 캡슐화

4-1. 리플렉션 접근이 막힌다

모듈 시스템은 JDK 9부터 있었지만, 한동안은 --illegal-access 옵션으로 경고만 내고 허용했다. JDK 16에서 기본값이 강한 캡슐화로 바뀌었고(JEP 396), JDK 17에서 옵션 자체가 제거됐다(JEP 403). Java 8에서 바로 넘어오면 이 유예 기간 없이 곧바로 막힌 상태를 만난다.

// Reflect.java
Field f = String.class.getDeclaredField("value");
f.setAccessible(true);
System.out.println("reflect ok");
$ /opt/jdk8/bin/java Reflect
reflect ok

$ /opt/jdk25/bin/java Reflect
Exception in thread "main" java.lang.reflect.InaccessibleObjectException: Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module @...

$ /opt/jdk25/bin/java --add-opens java.base/java.lang=ALL-UNNAMED Reflect
reflect ok

--add-opens는 특정 패키지를 리플렉션에 열어 주는 옵션이다. 급한 불을 끄는 용도로는 쓸 수 있지만, 이 예외는 대부분 직렬화, 프록시, 캐시 라이브러리 안에서 난다. 스택 트레이스에서 호출한 라이브러리를 찾아 업그레이드하는 것이 근본 해결이다. --add-opens를 계속 늘려 가는 방식은 권하지 않는다.

4-2. 시스템 클래스로더는 URLClassLoader가 아니다

플러그인 로딩 코드에서 자주 보던 패턴이다.

URLClassLoader cl = (URLClassLoader) ClassLoader.getSystemClassLoader();
$ /opt/jdk25/bin/java LoaderCast
Exception in thread "main" java.lang.ClassCastException: class jdk.internal.loader.ClassLoaders$AppClassLoader cannot be cast to class java.net.URLClassLoader ...

JDK 8에서는 성공하던 캐스팅이다. 런타임에 JAR를 추가해야 한다면 new URLClassLoader(urls, parent)로 별도 로더를 만드는 방식으로 바꾼다.

4-3. jdeps로 미리 찾기

실행해 보기 전에 JDK 내부 API 의존을 찾아 주는 도구가 jdeps다. 애플리케이션 JAR와 WEB-INF/lib를 모두 넣어 돌린다.

$ /opt/jdk25/bin/jdeps --jdk-internals Old.class
Old.class -> jdk.unsupported
   Old                                -> sun.misc.Unsafe                JDK internal API (jdk.unsupported)

$ /opt/jdk25/bin/jdeps --jdk-internals WEB-INF/lib/*.jar

출력에 나온 라이브러리가 곧 업그레이드 목록이다. 자체 코드에서 나온 항목은 직접 고친다. 다만 jdeps는 정적 분석 도구라 문자열이나 리플렉션으로 내부 API를 호출하는 경우까지는 찾지 못한다. 결과가 비어 있어도 내부 API 의존이 전혀 없다고 단정하지 말고, 실제 기동과 테스트에서 나오는 예외로 한 번 더 확인한다.

5. 사라진 API와 대체

5-1. JDK에서 빠진 패키지

JDK 11에서 Java EE와 CORBA 모듈이 제거됐고(JEP 320), JDK 15에서 Nashorn JavaScript 엔진이 제거됐다(JEP 372). 아래 클래스는 JDK 8에서는 로드되고 JDK 25에서는 ClassNotFoundException이 난다.

$ /opt/jdk25/bin/java LoadCheck
javax.xml.bind.JAXBContext                           -> ClassNotFoundException
javax.annotation.PostConstruct                       -> ClassNotFoundException
sun.misc.BASE64Encoder                               -> ClassNotFoundException
jdk.nashorn.api.scripting.NashornScriptEngineFactory -> ClassNotFoundException

컴파일 단계에서 잡히면 다행이지만, 설정 파일이나 리플렉션으로 이름만 참조하는 경우엔 해당 기능을 호출하는 순간에야 터진다.

5-2. 대체 의존성

사라진 것javax 패키지 유지jakarta로 전환
JAXB (javax.xml.bind)javax.xml.bind:jaxb-api:2.3.1 + org.glassfish.jaxb:jaxb-runtime:2.3.xjakarta.xml.bind:jakarta.xml.bind-api 4.0.x + jaxb-runtime 4.0.x
javax.annotation (@PostConstruct 등)jakarta.annotation:jakarta.annotation-api:1.3.5 (패키지는 javax.annotation 그대로)jakarta.annotation-api 2.x 이상
sun.misc.BASE64Encoder/Decoderjava.util.Base64 (Java 8에도 있으므로 지금 바로 교체 가능)
Nashornorg.openjdk.nashorn:nashorn-core:15.7 또는 사용처 제거

JAXB는 Tomcat 9와 Spring Boot 2.x를 유지하는 동안 javax 쪽을, 7장의 jakarta 전환을 할 때 jakarta 쪽을 고른다. javax 계열 조합(jaxb-api:2.3.1 + jaxb-runtime:2.3.1)과 jakarta.xml.bind-api:2.3.3 + jaxb-runtime:2.3.9 조합 모두, 간단한 클래스를 marshal/unmarshal하는 예제가 JDK 25.0.4.1에서 정상 동작하는 것을 확인했다. 두 계열을 섞어 넣으면 클래스 충돌이 날 수 있으니 한쪽으로 맞춘다.

5-3. Nashorn은 정말 쓰는지부터

JDK 8은 JavaScript 엔진을 기본으로 품고 있었다. JDK 25에서는 엔진이 하나도 없다.

// new ScriptEngineManager().getEngineFactories().size()
JDK 8  : 1
JDK 25 : 0

이때 getEngineByName("javascript")는 예외 없이 null을 돌려주므로, 나중에 엉뚱한 곳에서 NPE로 나타날 수 있다. 스크립트 엔진은 오래전 룰 엔진 대용으로 넣어 둔 코드 몇 곳에서만 쓰이는 사례가 있다. nashorn-core를 추가하기 전에 사용처를 검색해 걷어낼 수 있는지 먼저 본다.

5-4. SecurityManager와 Thread.stop()

SecurityManager는 JDK 24에서 영구 비활성화됐다(JEP 486). 설치를 시도하면 바로 예외가 난다.

$ /opt/jdk25/bin/java SmTest
Exception in thread "main" java.lang.UnsupportedOperationException: Setting a Security Manager is not supported

Tomcat을 catalina.sh start -security로 띄우던 환경이라면 이 옵션을 빼고 격리 방식을 다시 설계해야 한다. Thread.stop()도 JDK 8에서는 대상 스레드에 ThreadDeath를 던졌지만 JDK 25에서는 호출 자체가 UnsupportedOperationException이다. 인터럽트 기반 종료로 바꾼다.

6. 런타임 기본값 변화

코드 한 줄 바꾸지 않았는데 동작이 달라지는 부분이다. 테스트는 통과하는데 운영에서 이상한 증상이 나오면 여기부터 의심한다.

6-1. 기본 GC

JDK 8의 기본 GC는 Parallel이고, JDK 9부터 G1이 기본이다(JEP 248). 그런데 CPU가 적은 환경에서는 JVM이 Serial GC를 고를 수 있다.

$ /opt/jdk25/bin/java -XX:+PrintFlagsFinal -version | grep -E " Use(Serial|Parallel|G1|Z|Shenandoah)GC "
     bool UseG1GC                                  = true                                      {product} {ergonomic}
     bool UseParallelGC                            = false                                     {product} {default}
     bool UseSerialGC                              = false                                     {product} {default}
     ...

$ /opt/jdk25/bin/java -XX:ActiveProcessorCount=1 -XX:+PrintFlagsFinal -version | grep -E " Use(Serial|G1)GC "
     bool UseG1GC                                  = false                                     {product} {default}
     bool UseSerialGC                              = true                                      {product} {ergonomic}

2 CPU, 1.9GB 서버에서는 G1이, CPU를 1개로 제한하면 Serial이 선택됐다. CPU만이 아니라 메모리 크기도 영향을 준다. 테스트한 환경(Temurin 25.0.4.1, Linux, CPU 2개)에서 cgroup 메모리 제한만 바꿔 보면 1800M에서는 G1, 1700M에서는 Serial이 선택됐다. 이 경계값은 JVM 버전과 실행 환경에 따라 달라질 수 있으므로 고정된 임계값으로 보면 안 된다. CPU나 메모리를 작게 잡은 컨테이너에서는 Serial로 내려갈 수 있다는 정도로 받아들이고, 실제 운영 조건에서 선택된 GC를 직접 확인한다. JDK 25는 UseContainerSupport가 기본으로 켜져 있어 컨테이너 CPU 제한을 그대로 반영한다. 환경에 따라 GC가 바뀌는 것을 원하지 않는다면 -XX:+UseG1GC처럼 GC를 명시한다. 기존에 Parallel 처리량에 맞춰 튜닝한 서비스라면 GC 로그를 새로 받아 비교한다.

6-2. 기본 문자셋 UTF-8

JDK 18부터 기본 문자셋은 OS 로케일과 무관하게 UTF-8이다(JEP 400). JDK 8에서는 OS 설정을 따랐기 때문에 한국어 Windows에서는 MS949가 기본이었다.

$ /opt/jdk25/bin/java -XshowSettings:properties -version 2>&1 | grep encoding
    file.encoding = UTF-8
    native.encoding = UTF-8
    ...

인코딩 인자 없이 new FileReader(file), getBytes(), new String(bytes)를 쓰던 코드의 동작이 여기서 바뀐다. 개발 PC(Windows)와 서버(Linux)의 차이가 사라지는 좋은 변화지만, 읽는 파일이 EUC-KR이나 MS949로 저장돼 있다면 한글이 깨진다. 배치가 읽는 외부 연계 파일이나 레거시 설정 파일이 자주 발견되는 원인 중 하나다.

해결은 코드에서 StandardCharsetsCharset.forName("MS949")로 명시하는 것이다. 당장 고칠 수 없다면 -Dfile.encoding=COMPAT를 줄 수 있다. JEP 400 원문 기준으로 이 값은 JDK 17 이전처럼 OS와 로케일을 보고 기본 문자셋을 고르게 하며, JDK 25.0.4.1에서도 동작한다. 테스트한 Temurin/Linux 환경에서 LC_ALL=C로 띄웠더니 file.encodingnative.encoding이 모두 ANSI_X3.4-1968, 기본 문자셋은 US-ASCII로 나왔다. 반대로 COMPATUTF-8 외의 값은 JEP 400이 지원 대상으로 규정하지 않는다. 실제로 -Dfile.encoding=EUC-KR은 적용됐지만 -Dfile.encoding=MS949를 주었을 때 기본 문자셋은 UTF-8로 남았다. 원하는 인코딩은 이 옵션이 아니라 코드에서 명시하는 것이 안전하다. 어디까지나 임시 조치로 두고, grep -rn "getBytes()\|new FileReader\|new InputStreamReader"로 사용처를 찾아 정리한다.

6-3. 로케일 데이터 CLDR

JDK 9부터 날짜와 숫자 서식의 기본 데이터가 유니코드 CLDR로 바뀌었다(JEP 252). 같은 코드가 다른 문자열을 만든다.

// DateFormat.getDateInstance(DateFormat.LONG, Locale.KOREA).format(date)
JDK 8  : 2026년 9월 21일 (월)
JDK 25 : 2026년 9월 21일

// DateFormat.getTimeInstance(DateFormat.SHORT, Locale.US)
JDK 25 : 3:30 PM      (3:30과 PM 사이는 U+202F)

// NumberFormat.getInstance(Locale.FRANCE).format(1234567.89)
JDK 8  : 천 단위 구분 U+00A0
JDK 25 : 천 단위 구분 U+202F

U+202F는 눈에 보이지 않는 좁은 공백이라 화면으로는 차이를 찾기 어렵다. 포맷한 문자열을 equals로 비교하는 단위 테스트, 화면 문자열을 다시 파싱하는 코드, 외부 시스템에 문자열로 날짜를 넘기는 연계에서 문제가 난다. 저장이나 연계에 쓰는 문자열은 DateTimeFormatter.ofPattern("yyyy-MM-dd")처럼 패턴을 고정한다.

예전 로케일 데이터로 돌리는 -Djava.locale.providers=COMPAT는 JDK 25에서 기대할 수 없다. JEP 252 문서에 따르면 JDK 23부터 레거시 로케일 데이터가 JDK에서 빠져 JRECOMPAT을 지정해도 효과가 없다. Java 8에서 올라오는 경우 우회 옵션 없이 코드를 고쳐야 한다.

6-4. TLS 기본 정책

JDK 25의 기본 활성 프로토콜과 차단 목록을 확인해 보겠습니다.

// SSLContext.getDefault().getDefaultSSLParameters().getProtocols()
[TLSv1.3, TLSv1.2]

// Security.getProperty("jdk.tls.disabledAlgorithms")
SSLv3, TLSv1, TLSv1.1, DTLSv1.0, RC4, DES, MD5withRSA, DH keySize < 1024, EC keySize < 224, 3DES_EDE_CBC, anon, NULL, ECDH, TLS_RSA_*, rsa_pkcs1_sha1 usage HandshakeSignature, ecdsa_sha1 usage HandshakeSignature, dsa_sha1 usage HandshakeSignature

TLS 1.0과 1.1, 그리고 TLS_RSA_* 계열(RSA 키 교환 스위트)이 막혀 있다. 애플리케이션이 클라이언트로 붙는 오래된 사내 시스템, 결제 대행 연동, 구형 DB 드라이버 연결, 구형 로드밸런서가 TLS 1.2 ECDHE를 지원하지 않으면 핸드셰이크가 실패한다. 이때 흔히 보는 메시지는 javax.net.ssl.SSLHandshakeException: No appropriate protocol (protocol is disabled or cipher suites are inappropriate)Received fatal alert: handshake_failure다.

원인 확인은 -Djavax.net.debug=ssl:handshake를 붙여 어떤 프로토콜과 스위트로 협상하다 끊기는지 본다. 대응은 상대 서버를 업그레이드하는 것이 원칙이다. java.securityjdk.tls.disabledAlgorithms를 고쳐 풀 수는 있지만 알려진 취약 프로토콜을 다시 여는 것이므로, 연동 대상과 해제 기한을 문서로 남기는 조건에서만 쓴다.

6-5. 기본 KeyStore 형식

JDK 9부터 기본 keystore 형식이 JKS에서 PKCS12로 바뀌었다(JEP 229). JDK 25에서 keystore.type=pkcs12로 확인된다. 기존 JKS 파일은 형식을 자동 감지해 읽으므로 Tomcat Connector의 .jks 인증서는 그대로 동작한다. 달라지는 것은 새로 keytool -genkeypair로 만든 파일이 PKCS12라는 점이고, 스크립트에 -storetype JKS를 가정한 부분이 있으면 확인한다.

7. 라이브러리, 프레임워크, 빌드 도구, 에이전트

7-1. Tomcat 버전 선택

Tomcat 공식 whichversion 페이지 기준 요구 사항이다.

TomcatServlet최소 Java네임스페이스
9.0.x4.08javax
10.1.x6.011jakarta
11.0.x6.117jakarta

공식 문서는 각 Tomcat 버전이 요구 사항을 만족하는 안정 Java 릴리스에서 지원된다고 적고 있다. 그래서 Tomcat 9 최신 패치 + javax 코드 그대로 + JDK 25라는 중간 단계를 검토해 볼 수 있다. JDK 교체와 jakarta 전환을 분리할 수 있다는 점이 크다. 위 표대로 9.0.x는 Java 8 이상을 지원 범위로 명시하고 있어 JDK 25도 그 범위에 들어간다. 패치 버전 릴리스 노트는 참고용으로 함께 본다.

Tomcat 10.1 이상으로 가면 javax.servletjakarta.servlet으로 바뀐다. Tomcat 프로젝트가 제공하는 Migration Tool for Jakarta EE로 WAR의 패키지명을 변환해 먼저 돌려 볼 수 있지만, 소스 전환은 결국 따로 해야 한다.

7-2. Spring Boot

Spring Boot 4.1.1 공식 System Requirements는 "최소 Java 17, Java 26까지 호환"이며 Spring Framework 7.0.9 이상, 임베디드 컨테이너로 Tomcat 11.0.x(Servlet 6.1)나 Jetty 12.1.x를 요구한다. Java 8에서 돌던 Spring Boot 2.x는 javax 기반이므로, 최신 Boot로 올리려면 javax에서 jakarta로의 전환이 반드시 끼어든다.

Boot 2.x 그대로 JDK 25에서 돌려도 되는지, 3.x의 어느 버전부터 25를 지원하는지는 이 글에서 단정하지 않는다. 쓰고 있는 Boot 버전의 공식 System Requirements 페이지에서 호환 Java 범위를 확인하고, 범위 밖이면 Boot 업그레이드를 JDK 교체보다 앞선 별도 단계로 잡는다.

7-3. 빌드 도구

  • Gradle: 공식 호환표에서 Java 25 실행 지원은 9.1.0부터다. 8.14.x는 Java 24까지다. Gradle 데몬을 JDK 25로 돌리려면 wrapper부터 9.1.0 이상으로 올린다.
  • Maven: maven.compiler.release로 대상 버전을 지정하고, compiler, surefire, failsafe, jacoco 같은 플러그인이 클래스 파일 버전 69를 처리하는 릴리스인지 확인한다.

7-4. 바이트코드를 다루는 라이브러리

ASM, ByteBuddy, CGLIB, Javassist는 클래스 파일을 직접 읽고 만든다. 이들을 내장한 Spring, Hibernate, Mockito, Jacoco, Lombok 등은 새 클래스 파일 버전을 이해하는 릴리스가 아니면 Unsupported class file major version 69 류의 에러를 낸다. 처음엔 JDK 8로 빌드한 산출물(52)이라 괜찮다가, 빌드를 --release 25로 바꾸는 순간 터질 수 있다. 각 프로젝트의 릴리스 노트나 호환표에서 JDK 25 지원 버전을 찾아 올린다.

7-5. APM 에이전트와 프로파일러

APM 에이전트는 JVM 내부에 깊이 붙기 때문에 JDK 버전 지원 목록이 따로 있다. -javaagent로 붙이는 에이전트는 벤더의 지원 JDK 목록에 25가 있는지 먼저 본다.

실행 중인 JVM에 에이전트를 나중에 붙이는 동적 로딩은 JDK 21부터 경고가 나온다(JEP 451). JEP는 향후 릴리스에서 이를 기본 금지하겠다고 예고한다. 정적 -javaagent는 영향이 없고, 동적 attach를 쓰는 프로파일러나 Mockito의 inline mock 등이 해당될 수 있다. 의도한 사용이면 -XX:+EnableDynamicAgentLoading으로 명시한다.

JNI 네이티브 라이브러리를 쓰는 경우 JDK 24부터 경고가 나올 수 있다(JEP 472). --enable-native-access=ALL-UNNAMED로 허용을 명시해 둔다.

8. 컴파일 경고가 붙는 API

당장 깨지지는 않지만 다음 LTS에서 깨질 후보들이다. -Xlint로 한 번 뽑아 둔다.

$ /opt/jdk25/bin/javac --release 25 -Xlint:deprecation,removal Legacy.java
Legacy.java:...: warning: [removal] finalize() in Object has been deprecated and marked for removal
Legacy.java:...: warning: [deprecation] Integer(int) in Integer has been deprecated
Legacy.java:...: warning: [deprecation] URL(String) in URL has been deprecated
Legacy.java:...: warning: [deprecation] Locale(String,String) in Locale has been deprecated
Legacy.java:...: warning: [deprecation] getId() in Thread has been deprecated
Legacy.java:...: warning: [deprecation] exec(String) in Runtime has been deprecated
경고 API대체
finalize() (JEP 421, 제거 예정)try-with-resources, java.lang.ref.Cleaner
new Integer(int)Integer.valueOf(int)
new URL(String)URI.create(s).toURL()
new Locale(String, String)Locale.of(String, String)
Thread.getId()Thread.threadId()
Runtime.exec(String)exec(String[]) 또는 ProcessBuilder

Locale.ofthreadId()는 Java 8에 없으므로 --release 8을 유지하는 동안에는 쓸 수 없다. 이 정리는 릴리스 대상을 올린 뒤에 한다.

sun.misc.Unsafe의 메모리 접근 메서드는 JDK 23에서 제거 예정으로 지정됐고(JEP 471), JDK 24부터 처음 호출 시 경고를 낸다(JEP 498). 동작은 정상이다.

$ /opt/jdk25/bin/java Old
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::allocateMemory has been called by Old (file:/...)
WARNING: Please consider reporting this to the maintainers of class Old
WARNING: sun.misc.Unsafe::allocateMemory will be removed in a future release
42

Netty, Kryo, 일부 캐시 라이브러리가 내부에서 Unsafe를 쓴다. catalina.out에 이 경고가 찍히면 "called by" 뒤의 클래스로 라이브러리를 특정해 업그레이드 목록에 올린다.

9. 작업 순서 체크리스트

  1. 현재 산출물(JDK 8 빌드)과 JVM 옵션을 그대로 둔 채 jdeps --jdk-internals로 내부 API 의존을 뽑는다.
  2. 3-5의 grep으로 기동 스크립트, systemd 유닛, Dockerfile의 옵션을 정리하고 java ... -version으로 기동만 확인한다.
  3. 스테이징에서 JDK 8 빌드 산출물을 JDK 25로 띄운다. catalina.out에서 InaccessibleObjectException, ClassNotFoundException, WARNING:을 검색한다.
  4. 제거된 API(JAXB, javax.annotation, Nashorn, BASE64Encoder)의 대체 의존성을 넣는다.
  5. GC를 명시하고, 인코딩 없는 I/O, 날짜와 숫자 문자열 비교, TLS 연동 대상을 점검한다.
  6. 빌드 JDK를 25로 바꾸고 --release 8로 빌드한다. Gradle 9.1.0 이상, Maven 플러그인, APM 에이전트 버전을 맞춘다.
  7. 운영 전환이 끝나면 --release를 올리고 바이트코드 라이브러리 버전을 따라 올린다.
  8. jakarta 전환(Tomcat 10.1 이상, Spring Boot 최신)은 별도 릴리스로 진행한다.

10. 올려서 얻는 것

언어 쪽에서는 Java 8 이후 추가된 var, 텍스트 블록(15), record(16), sealed 클래스(17), switch 패턴 매칭(21)을 쓸 수 있다. JDK 25에서는 Module Import Declarations(JEP 511), Compact Source Files and Instance Main Methods(JEP 512), Flexible Constructor Bodies(JEP 513), Scoped Values(JEP 506), Key Derivation Function API(JEP 510)가 정식 기능이 됐다. Structured Concurrency(JEP 505)는 아직 5차 프리뷰다.

동시성에서는 JDK 21의 가상 스레드가 가장 크다. JDK 24에서 synchronized 블록 안의 핀닝 문제도 해소됐다(JEP 491). 동시성 API는 JDK 25 StructuredTaskScope 글에서 따로 다뤘다.

런타임 쪽에는 Compact Object Headers(JEP 519)가 있다. 객체 헤더를 줄여 힙 사용량을 낮추는 기능인데 기본값은 꺼져 있어서(UseCompactObjectHeaders=false) -XX:+UseCompactObjectHeaders로 켜야 한다. Generational Shenandoah(JEP 521), AOT 관련 개선(JEP 514, 515)도 있다.

얼마나 빨라지는지는 애플리케이션마다 다르다. 업그레이드 전후로 같은 부하를 걸어 처리량, 지연, GC 일시 정지 시간을 재측정해 비교한다. 동시 접속 처리 관점의 측정 방법은 JDK 17 C10K 글을 참고할 수 있다.

11. 마무리

Java 8에서 25로 가는 작업에서 소스 수정은 생각보다 적다. 실제 시간은 기동 옵션, 캡슐화된 내부 API, 라이브러리 버전 맞추기, 그리고 인코딩과 로케일과 TLS 같은 보이지 않는 기본값 확인에 들어간다. JDK 8로 빌드한 산출물을 JDK 25에 먼저 띄워 문제를 모으고, 빌드 JDK와 jakarta 전환은 그 다음 단계로 분리하는 것이 가장 덜 위험한 순서다.

자주 묻는 질문

Java 8로 컴파일한 WAR를 재빌드 없이 Java 25에서 실행할 수 있나?

실행할 수 있다. JDK 8 javac가 만드는 클래스 파일(major 52)은 JDK 25에서 그대로 로드된다. 다만 JVM 옵션, 내부 API 접근, 제거된 Java EE 모듈 때문에 기동이나 특정 기능 호출에서 실패할 수 있으니 스테이징에서 먼저 띄워 catalina.out을 확인해야 한다.

-XX:+UseConcMarkSweepGC를 남겨 두면 기본 GC로 대신 뜨나?

아니다. CMS는 JDK 14에서 제거됐고 JDK 25에서는 'Unrecognized VM option' 에러와 함께 JVM이 기동하지 않는다. PermSize, MaxPermSize, PrintGCDateStamps, UseBiasedLocking도 마찬가지로 기동 실패하므로 모두 삭제해야 한다.

Java 25로 올리려면 javax를 jakarta로 반드시 바꿔야 하나?

JDK 자체는 javax 코드를 막지 않는다. Tomcat 9.0.x는 javax 네임스페이스를 유지하고 Java 8 이상을 요구하므로, 공식 지원 범위를 확인한 뒤 JDK만 먼저 25로 올리는 중간 단계를 검토할 수 있다. 최신 Spring Boot나 Tomcat 10.1 이상으로 가려면 jakarta 전환이 필요하다.

-Djava.locale.providers=COMPAT로 Java 8 날짜 형식을 되돌릴 수 있나?

JDK 25에서는 안 된다. JEP 252 문서에 따르면 JDK 23부터 레거시 로케일 데이터가 JDK에서 빠져 COMPAT을 지정해도 효과가 없다. 저장이나 연계에 쓰는 날짜 문자열은 DateTimeFormatter 패턴을 고정해서 만들어야 한다.

Java 25에서 오래된 사내 시스템과 SSL 연결이 실패하는 이유는?

JDK 25는 TLS 1.0과 1.1, RSA 키 교환 스위트(TLS_RSA_*)를 jdk.tls.disabledAlgorithms로 막는다. 상대가 TLS 1.2 ECDHE를 지원하지 않으면 SSLHandshakeException이 난다. -Djavax.net.debug=ssl:handshake로 협상 과정을 보고, 상대 서버를 업그레이드하는 것이 원칙이다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.