JDK 27은 2026년 9월 15일 GA를 앞두고 이미 Release Candidate 단계에 들어갔다. 기능 세트는 동결됐고 9개 JEP가 확정됐다. 그런데 이번 릴리스에서 실무자가 진짜로 신경 써야 할 부분은 JEP 목록이 아니다. 코드를 한 줄도 고치지 않아도 기본 동작이 바뀌는 항목 두 개(G1 기본화, Compact Object Headers 기본 적용)와, 릴리스 노트 뒤쪽에 조용히 적혀 있는 제거 항목들이다. -Xverify:none이 박힌 기동 스크립트를 아직 굴리고 있다면 JDK 27에서는 JVM이 아예 뜨지 않는다. 이번 포스팅에서는 JDK 27의 9개 JEP를 공식 JEP 문서와 릴리스 노트 원문 기준으로 정리하고, 버전을 올릴 때 실제로 깨지는 지점을 마이그레이션 체크리스트 형태로 짚는다.
JDK 27은 결국 무엇이 달라진 릴리스인가
JDK 27에는 9개 JEP가 들어간다. 사용자 코드 수정 없이 성능·보안 기본값이 바뀌는 것이 4개(G1 기본 GC, Compact Object Headers 기본화, 양자내성 TLS, JFR 데이터 마스킹), 나머지 5개는 프리뷰 4개와 인큐베이터 1개라 각각 --enable-preview나 --add-modules 없이는 쓸 수 없다. 새 문법을 배우는 릴리스가 아니라 운영 기본값이 바뀌는 릴리스라고 보면 된다.
OpenJDK JDK 27 프로젝트 페이지에 확정된 목록은 다음과 같다.
| JEP | 이름 | 상태 | 코드 수정 필요 |
|---|---|---|---|
| 523 | Make G1 the Default Garbage Collector in All Environments | Final | 없음 (기본값 변경) |
| 527 | Post-Quantum Hybrid Key Exchange for TLS 1.3 | Final | 없음 (기본값 변경) |
| 531 | Lazy Constants | 3차 프리뷰 | --enable-preview |
| 532 | Primitive Types in Patterns, instanceof, and switch | 5차 프리뷰 | --enable-preview |
| 533 | Structured Concurrency | 7차 프리뷰 | --enable-preview |
| 534 | Compact Object Headers by Default | Final | 없음 (기본값 변경) |
| 536 | JFR In-Process Data Redaction | Final | 없음 (기본값 변경) |
| 537 | Vector API | 12차 인큐베이터 | --add-modules |
| 538 | PEM Encodings of Cryptographic Objects | 3차 프리뷰 | --enable-preview |
릴리스 일정은 Rampdown Phase One이 2026년 6월 4일, Rampdown Phase Two가 7월 16일, Release Candidate 빌드가 8월 20일, GA가 2026년 9월 15일이다. 지금 시점에는 이미 기능이 동결되어 있으므로 목록이 더 늘어날 일은 없다. 미리 받아서 테스트하기에 딱 좋은 시기다.
눈여겨볼 점은 Final로 들어온 4개가 전부 “가만히 있어도 동작이 바뀌는” 종류라는 것이다. Structured Concurrency는 일곱 번째, 원시 타입 패턴 매칭은 다섯 번째 프리뷰다. 프리뷰 횟수만 봐도 JDK 27은 언어 자체의 변화보다 런타임 기본값 정리에 무게가 실린 릴리스라는 게 드러난다.
JDK 27은 LTS인가 — 올릴지 말지부터 정하기
JDK 27은 LTS가 아니다. 현재 LTS는 JDK 25(2025년 9월)이고, 다음 LTS는 JDK 29(2027년 9월 예정)다. Oracle Java SE 지원 로드맵 기준으로 27은 다음 릴리스인 JDK 28이 나오는 2027년 3월까지 약 6개월간만 업데이트를 받는다. 프로덕션 표준 런타임을 27로 갈아탈 이유는 없다.
| 버전 | GA | LTS 여부 | 실무에서의 위치 |
|---|---|---|---|
| JDK 21 | 2023-09 | LTS | 아직 다수 서비스의 운영 기준선 |
| JDK 25 | 2025-09 | LTS | 현행 LTS, 이관 목표 |
| JDK 26 | 2026-03 | 비-LTS | 6개월 릴리스 |
| JDK 27 | 2026-09-15 | 비-LTS | 검증·선행 테스트용 |
| JDK 28 | 2027-03 | 비-LTS | JEP 540 Simple JSON API 등 예정 |
| JDK 29 | 2027-09 | LTS 예정 | 다음 이관 목표 |
그렇다면 JDK 27을 왜 봐야 하냐면, 비-LTS 릴리스가 다음 LTS의 예고편이기 때문이다. 27에서 기본값이 된 항목은 특별한 사정이 없는 한 29에도 그대로 실린다. 지금 27로 CI를 한 번 돌려 두면 2027년 9월에 JDK 29로 올릴 때 마주칠 문제의 상당수를 1년 앞서 발견할 수 있다. 특히 이번에 제거된 JVM 옵션들은 되돌릴 방법이 없으므로 미리 걷어내는 편이 안전하다.
현실적인 선택은 운영 런타임을 25에 두고, CI 파이프라인에 JDK 27 매트릭스 잡을 하나 추가해 빌드와 테스트만 통과시키는 것이다. 이 블로그의 Java 25 주요 변경 사항 정리와 JDK 21에서 JDK 25로 성능 업데이트를 먼저 처리한 팀이라면 27 검증은 크게 부담스럽지 않다.
객체 헤더에서 4바이트를 덜어내면 생기는 일 — JEP 534

JEP 534는 Compact Object Headers를 HotSpot의 기본 객체 헤더 레이아웃으로 만든다. 64비트 아키텍처에서 헤더가 96비트에서 64비트로 줄어든다. JDK 24에서 실험적으로 들어오고 JDK 25에서 정식 기능이 된 뒤, 이번에 플래그 없이 켜지는 것이다. 힙 사용량과 GC 횟수가 함께 줄어드는 변화다.
JEP 문서에 인용된 실측치가 꽤 구체적이다. SPECjbb2015에서 힙 공간을 22% 적게 쓰고 CPU 시간은 8% 적게 썼다. 다른 설정에서는 G1과 Parallel 양쪽 모두 GC 횟수가 15% 줄었고, 고도로 병렬화된 JSON 파서 벤치마크는 실행 시간이 10% 짧아졌다.
이 정도 변화를 기본값으로 밀어붙일 수 있었던 근거도 문서에 적혀 있다. Amazon이 수백 개 서비스를 JDK 21·17 백포트로 프로덕션에서 이미 굴려왔고, SAP는 자사 OpenJDK 포크인 SapMachine에서 진작에 기본값으로 전환했다. 실험실 벤치마크만 놓고 결정한 게 아니라는 뜻이다.
# JDK 25까지는 옵트인이었다
java -XX:+UseCompactObjectHeaders -jar app.jar
# JDK 27부터는 플래그가 필요 없다. 되돌리려면 명시적으로 꺼야 한다
java -XX:-UseCompactObjectHeaders -jar app.jarShellScript위쪽은 JDK 25 기준의 사용법이고, 아래쪽이 JDK 27에서 문제가 생겼을 때의 탈출구다. 그런데 JDK 27 릴리스 노트는 -XX:-UseCompactObjectHeaders 플래그 자체를 향후 릴리스에서 deprecate 후 제거할 예정이라고 못 박았다. 탈출구가 영구적이지 않다는 이야기다. 끄는 것으로 덮지 말고 왜 느려졌는지를 찾는 편이 낫다.
한 가지 함정이 있다. JOL(Java Object Layout)로 헤더 크기를 확인할 때 구버전 JOL을 쓰면 정렬 갭을 잘못 계산해 실제 16바이트인 객체를 24바이트로 보고하는 경우가 있다. 헤더 크기가 8로 찍히고 첫 필드 오프셋이 8에서 시작하면 제대로 적용된 것이다. 또 하나, 모든 객체가 4바이트씩 줄어드는 건 아니다. 객체 크기는 8바이트 경계로 정렬되므로, 헤더가 4바이트 줄어도 정렬 패딩이 그 차이를 흡수해 총 크기가 그대로인 클래스가 나온다. 필드가 적은 작은 객체를 대량으로 만드는 코드일수록 효과가 크고, 필드가 이미 많은 엔티티 클래스는 체감이 적다.
레이아웃이 어떻게 바뀌는지 비트 단위로 궁금하다면 이 블로그의 Java 25 Compact Object Header 정리에서 다뤘다. JDK 27에서 달라진 건 레이아웃이 아니라 “이제 아무도 켜지 않아도 켜져 있다”는 사실 하나다.
Serial GC가 기본이던 자리를 G1이 가져갔다 — JEP 523

JEP 523으로 GC를 지정하지 않으면 JVM은 언제나 G1을 고른다. 이전까지는 CPU가 2개 이상이고 물리 메모리가 1792MB 이상인 “서버급” 환경에서만 G1이었고, 그보다 작으면 Serial GC가 선택됐다. JDK 27부터 이 분기 자체가 사라진다. 작은 컨테이너에서 돌던 애플리케이션의 GC가 조용히 바뀐다는 뜻이다.
문제는 그 1792MB라는 숫자를 아무도 기억하지 못한다는 데 있다. 메모리 리밋을 512MB로 잡은 쿠버네티스 파드나 사이드카 컨테이너는 그동안 아무도 모르는 사이에 Serial GC로 돌고 있었다. GC 튜닝 문서에는 G1 기준 옵션이 적혀 있는데 실제 런타임은 Serial이었던 상황도 드물지 않다. JDK 27로 올리는 순간 이런 곳이 전부 G1로 바뀐다. 좋은 방향이긴 한데, 바뀐다는 사실 자체를 모르고 있다가 GC 로그 형식이 달라진 걸 보고 당황하는 쪽이 더 곤란하다.
# 현재 어떤 GC로 돌고 있는지 확인 (JDK 버전 무관)
java -XX:+PrintFlagsFinal -version | grep -E "UseG1GC|UseSerialGC|UseParallelGC"
# JDK 27에서 예전처럼 Serial GC를 쓰고 싶다면 명시적으로 지정
java -XX:+UseSerialGC -jar app.jarShellScriptPrintFlagsFinal 쪽은 지금 쓰는 JDK가 어떤 GC를 고르는지 그 자리에서 찍어보는 방법이다. 반드시 컨테이너 안에서 돌려야 한다. 노트북에서 실행하면 당연히 G1이 나오는데 정작 운영 파드에서는 Serial인 경우가 있다. 여기서 예상과 다른 값이 나오는 순간이 대개 문제의 시작이다. -XX:+UseSerialGC 쪽은 JDK 27로 올린 뒤 성능 회귀가 관찰될 때의 대응이다. JEP는 사용자가 명시한 GC 선택을 절대 덮어쓰지 않는다고 못 박았으므로 이 방법은 계속 유효하다.
G1 팀이 왜 지금 전환했는지도 근거가 있다. JEP 문서는 JEP 522의 동기화 감소 작업으로 G1의 최대 처리량이 Serial에 근접했고, 최대 지연 시간은 원래부터 G1이 나았으며, 최근 릴리스에서 G1의 네이티브 메모리 사용량도 Serial 수준으로 내려왔다고 설명한다. G1 유지보수를 맡고 있는 Thomas Schatzl은 JDK 27 GC 변경 사항 정리에서, 알기 어려운 환경 조건에 따라 GC를 갈아 끼우는 방식이 이점보다 부담이 컸다는 취지로 배경을 설명했다.
여기에 딸려오는 기본값 변경이 하나 더 있다. G1의 -XX:MinHeapFreeRatio와 -XX:MaxHeapFreeRatio 기본값이 40과 70에서 0과 100으로 바뀌었다(JDK-8238686). 사실상 이 비율에 따른 힙 리사이징이 꺼진 것이다. 기존 기본값은 Full GC 직후 힙을 늘리거나 줄였다가, GC CPU 사용률 기반의 다른 휴리스틱이 곧바로 그 변경을 되돌리는 식으로 서로 싸우는 문제가 있었다. System.gc()를 자주 부르는 애플리케이션에서 특히 두드러졌다. 이 두 옵션을 명시적으로 지정한 애플리케이션은 기존 동작이 유지되므로, 튜닝값을 박아둔 팀이라면 오히려 영향이 없다.
TLS 핸드셰이크가 조용히 양자내성으로 갈아탄다 — JEP 527
JEP 527은 TLS 1.3에 양자내성 하이브리드 키 교환을 넣는다. javax.net.ssl API를 쓰는 코드는 한 줄도 고치지 않고 혜택을 받는다. JDK의 TLS 1.3 클라이언트가 기본 named group 목록 맨 앞에 X25519MLKEM768을 놓기 때문이다. 서버가 지원하면 하이브리드로, 아니면 기존 방식으로 자동 폴백한다.
하이브리드라는 이름 그대로 기존 ECDHE와 양자내성 ML-KEM을 결합한다. 둘 중 하나만 깨지지 않으면 안전하다는 설계다. 지금 대규모 양자컴퓨터가 없는데도 서두르는 이유는 “harvest now, decrypt later” 때문이다. 공격자가 오늘 암호화된 트래픽을 통째로 저장해 두었다가 나중에 양자컴퓨터가 나오면 복호화하는 시나리오다. 오늘 오간 트래픽의 기밀성이 미래 시점에 무너진다.
JDK 27이 추가하는 스킴은 세 가지다.
| 스킴 | 조합 | 기본 활성화 |
|---|---|---|
X25519MLKEM768 | X25519 + ML-KEM-768 | 예 (최우선) |
SecP256r1MLKEM768 | secp256r1 + ML-KEM-768 | 아니오 |
SecP384r1MLKEM1024 | secp384r1 + ML-KEM-1024 | 아니오 |
JEP 527이 명시한 기본 목록은 X25519MLKEM768, x25519, secp256r1, secp384r1, secp521r1, x448, ffdhe2048, ffdhe3072, ffdhe4096 순이다. 배포판에 따라 뒤쪽에 ffdhe6144 같은 그룹이 더 붙어 있을 수 있으니, 실제 목록은 -Djava.security.debug=all이나 핸드셰이크 로그로 확인하는 편이 정확하다. 클라이언트가 실제로 key share를 실어 보내는 건 이 중 앞의 두 개, X25519MLKEM768과 x25519뿐이다. X25519MLKEM768이 가장 빠른 하이브리드 그룹이고 대부분의 TLS 클라이언트가 이미 기본으로 켜고 있다는 게 JEP의 설명이다.
import java.io.IOException;
import java.security.NoSuchAlgorithmException;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSocket;
public class NamedGroupPinning {
// 특정 키 교환 스킴만 제안하도록 소켓을 구성한다 (JEP 527 문서 예시 기반)
static SSLSocket openPinnedSocket()
throws NoSuchAlgorithmException, IOException {
SSLSocket tlsSock = (SSLSocket) SSLContext.getDefault()
.getSocketFactory().createSocket();
SSLParameters params = tlsSock.getSSLParameters();
// 하이브리드 2종과 전통 방식 2종만 제안하도록 제한한다
params.setNamedGroups(new String[] {
"SecP256r1MLKEM768", "X25519MLKEM768", "secp256r1", "x25519"
});
tlsSock.setSSLParameters(params);
return tlsSock;
}
}JavaSSLParameters::setNamedGroups로 제안할 키 교환 스킴을 직접 통제하는 코드다. SSLContext.getDefault()가 NoSuchAlgorithmException을, createSocket()이 IOException을 던지므로 둘 다 선언해 줘야 컴파일된다. 실무에서 이걸 쓰게 되는 상황은 대개 반대 방향이다. 오래된 TLS 종단 장비나 미들박스가 낯선 named group이 담긴 ClientHello를 보고 핸드셰이크를 끊어버리는 경우다. 이때는 코드를 고치는 대신 시스템 프로퍼티 -Djdk.tls.namedGroups=x25519,secp256r1로 전역 목록을 되돌리는 편이 빠르다.
한 가지 더 알아둘 점은 하이브리드 키 교환이 ClientHello 크기를 키운다는 것이다. ML-KEM-768 키 공유가 붙으면서 첫 패킷이 1KB를 넘어간다. 핸드셰이크 패킷 크기에 가정을 걸어둔 중간 장비가 있다면 여기서 걸릴 수 있다. 다행히 릴리스 노트에는 RFC 8879 zlib 인증서 압축 지원이 함께 들어왔고 이것도 기본 활성화라고 적혀 있다. 문제가 생기면 -Djdk.tls.client.disableExtensions=compress_certificate로 끌 수 있다.
JFR 덤프에 박혀 있던 비밀번호가 사라진다 — JEP 536

JEP 536은 JFR이 커맨드라인 인자, 환경 변수, 시스템 프로퍼티의 초깃값을 프로세스 밖으로 나가기 전에 마스킹하도록 바꾼다. 별도 설정 없이 기본으로 동작한다. 그동안 JFR 덤프 파일에는 -Djavax.net.ssl.keyStorePassword 값이나 ACCESS_TOKEN 환경 변수가 평문 그대로 들어 있었다.
이게 왜 위험했는지는 JFR 파일이 어디로 가는지를 생각해 보면 명확하다. 장애 분석하겠다고 슬랙에 올리고, 벤더 지원 케이스에 첨부하고, 사내 스토리지에 아카이빙한다. 그 파일 안에 DB 비밀번호가 들어 있었다는 이야기다. 구체적으로 jdk.InitialEnvironmentVariable, jdk.InitialSystemProperty, jdk.JVMInformation 세 이벤트가 범인이었다.
JDK 27은 아래 기본 필터로 키-값 쌍을 마스킹한다. 매칭은 대소문자를 구분하지 않고 glob 패턴(*, ?)을 쓴다.
*api*key*
*auth*
*client*secret*
*credential*
*jaas*config*
*passphrase*
*passwd*
*password*
*private*key*
*pwd*
*secret*
*token*Plaintext이 목록은 JEP 536 원문에 명시된 redact-key 기본 필터다. DB_PASSWORD, API_KEY_PROD, spring.datasource.password 같은 이름은 아무 설정 없이도 [REDACTED]로 바뀐다. 문제는 반대쪽이다. dbpw처럼 목록에 안 걸리는 이름을 쓰고 있다면 여전히 평문으로 남는다. 사내 환경 변수 명명 규칙을 한 번 훑어볼 만한 대목이다.
# 사내 규칙에 맞는 필터 추가 (+ 를 붙이면 기본 필터에 더한다)
java -XX:FlightRecorderOptions:'redact-key=+*confidential*;*internal*token*' \
-XX:StartFlightRecording:filename=dump.jfr -jar app.jar
# 인자와 그 다음 인자를 함께 가리기 (--password SECRET 형태 대응)
java -XX:FlightRecorderOptions:'redact-argument=--password *' -jar app.jar
# 필터 목록이 길면 파일로 분리한다
java '-XX:FlightRecorderOptions:redact-argument=@args.txt,redact-key=@keys.txt' -jar app.jar
# 마스킹이 실제로 걸렸는지 확인
jfr print --events InitialSystemProperty,JVMInformation,InitialEnvironmentVariable dump.jfrShellScript여기서 제일 중요한 문자는 +다. 이걸 붙이면 기본 필터를 유지한 채 사내 규칙이 얹히고, 빼면 기본 목록 열두 줄을 통째로 날려버린다. --password *처럼 뒤에 공백과 별표를 붙인 형태는 옵션 이름과 그 다음 인자를 한 쌍으로 묶어 가린다. 값 자체를 필터에 적으면 필터 파일 안에 비밀이 남는 모순이 생기니 이렇게 설계됐다. 필터가 길어지면 @파일명으로 빼두면 되는데, 배포마다 기동 옵션을 건드리지 않고 필터만 갱신할 수 있어 운영 쪽에서 편하다. 그리고 jfr print는 생략하지 않는 게 좋다. 마스킹은 걸렸다고 믿는 순간 안 걸려 있다.
필터가 왜 안 걸리는지 디버깅해야 한다면 -Xlog:jfr+redact=debug를 붙인다. 어떤 인자와 키가 마스킹 대상으로 판정됐는지 로그로 보여준다. 참고로 필터 파일을 읽지 못하면 JVM은 경고가 아니라 에러 메시지를 내고 종료하므로, 배포 스크립트에서 파일 경로를 잘못 잡으면 기동 실패로 이어진다.
여기에 릴리스 노트가 별도로 언급한 변경이 하나 더 있다. jdk.SystemProcess 이벤트가 이제 커맨드라인 인자를 아예 담지 않는다. 같은 머신에서 도는 다른 프로세스의 정보가 새는 걸 막기 위해서다. 프로세스 ID와 이름은 그대로 남는다.
Structured Concurrency 7차 프리뷰 — 이번엔 컴파일이 깨진다
JEP 533의 Structured Concurrency는 일곱 번째 프리뷰다. 문제는 이번 변경이 소스 호환성을 깬다는 점이다. JDK 26에서 쓰던 awaitAll()이 사라졌고, onTimeout()은 timeout()으로 이름이 바뀌었고, Joiner에 세 번째 타입 파라미터가 붙었다. 프리뷰 API를 실제로 쓰고 있었다면 JDK 27에서 다시 고쳐야 한다.
JDK 27에서 달라진 항목을 JEP 원문 기준으로 옮기면 다음과 같다.
| 변경 | 내용 |
|---|---|
| 타입 파라미터 추가 | StructuredTaskScope와 Joiner에 join()이 던질 예외 타입 R_X가 추가됐다 |
| 예외 타입 변경 | allSuccessfulOrThrow(), anySuccessfulOrThrow(), awaitAllSuccessfulOrThrow()가 FailedException 대신 ExecutionException을 던진다 |
| 메서드 제거 | Joiner.awaitAll()이 제거됐다 |
| 메서드 대체 | onTimeout()이 timeout()으로 바뀌었다. 타임아웃으로 취소되면 CancelledByTimeoutException을 cause로 하는 예외가 던져진다 |
| 팩터리 추가 | 설정을 UnaryOperator로 넘기는 새 open 메서드가 생겼다 |
import java.util.concurrent.ExecutionException;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
// JDK 27 기준. --enable-preview 필요
public class OrderPageLoader {
record ProductPage(String details, int price, int stock) {}
ProductPage load(long productId) throws ExecutionException, InterruptedException {
// try-with-resources 블록이 하위 작업들의 수명 경계가 된다
try (var scope = StructuredTaskScope.open()) {
Subtask<String> details = scope.fork(() -> fetchDetails(productId));
Subtask<Integer> price = scope.fork(() -> fetchPrice(productId));
Subtask<Integer> stock = scope.fork(() -> fetchStock(productId));
// 하나라도 실패하면 나머지는 자동으로 인터럽트된다
scope.join();
return new ProductPage(details.get(), price.get(), stock.get());
}
}
private String fetchDetails(long id) { return "product-" + id; }
private int fetchPrice(long id) { return 19_900; }
private int fetchStock(long id) { return 42; }
}Java이 코드의 핵심은 세 하위 작업의 수명이 try 블록 밖으로 절대 새어나가지 않는다는 것이다. fetchPrice가 예외를 던지면 아직 실행 중인 fetchDetails와 fetchStock이 인터럽트되고, load()를 호출한 스레드가 인터럽트되면 세 작업 모두 스코프를 빠져나갈 때 취소된다. ExecutorService와 Future 조합에서 발생하던 스레드 누수가 구조적으로 불가능해진다. 여기서 join()이 던지는 예외가 JDK 26의 FailedException에서 ExecutionException으로 바뀌었다는 점이 마이그레이션 포인트다.
타임아웃까지 걸고 싶다면 JDK 27에서 추가된 설정 기반 open을 쓴다.
import java.time.Duration;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.StructuredTaskScope;
// JDK 27 기준. --enable-preview 필요
public class CheckoutLoader {
record Checkout(String cart, String profile) {}
Checkout load(long userId) throws ExecutionException, InterruptedException {
// 2초 타임아웃과 스코프 이름을 함께 지정한다
try (var scope = StructuredTaskScope.open(
cfg -> cfg.withTimeout(Duration.ofSeconds(2)).withName("checkout"))) {
var cart = scope.fork(() -> fetchCart(userId));
var profile = scope.fork(() -> fetchProfile(userId));
scope.join();
return new Checkout(cart.get(), profile.get());
}
}
private String fetchCart(long userId) { return "cart-" + userId; }
private String fetchProfile(long userId) { return "profile-" + userId; }
}JavawithName으로 붙인 이름은 스레드 덤프에서 그대로 보인다. 스코프 하위에 어떤 가상 스레드들이 매달려 있는지가 계층으로 드러나므로, 장애 상황에서 “지금 이 요청이 무엇을 기다리고 있는가”를 답하기 훨씬 쉬워진다. 타임아웃으로 취소되면 CancelledByTimeoutException이 cause로 붙어 온다는 점을 예외 처리 코드에 반영해야 한다.
Structured Concurrency 자체가 처음이라면 이 블로그의 Structured Concurrency 소개와 Java Virtual Threads 실전 패턴에서 기본 개념을 먼저 정리했다. 다만 그 글들은 JDK 25 API 기준이라, JDK 27에서는 위 표의 변경을 반영해야 컴파일된다.
Lazy Constants와 나머지 프리뷰는 뭐가 달라졌나
나머지 프리뷰 세 개는 변화 폭이 작다. JEP 531 Lazy Constants는 Set.ofLazy()가 추가되고 저수준 메서드 두 개가 빠졌다. JEP 532 원시 타입 패턴 매칭은 변경 없이 다섯 번째 프리뷰로 재등장했다. JEP 537 Vector API는 열두 번째 인큐베이터로, Valhalla의 Value Objects를 기다리는 중이다.
Lazy Constants는 final 필드의 “생성 시점에 무조건 초기화” 제약과 가변 필드의 “언제든 바뀔 수 있어 JIT가 상수 폴딩을 못 함” 사이의 빈틈을 메우는 API다. JDK 25에서 StableValue라는 이름으로 처음 나왔다가 JDK 26에서 LazyConstant로 개명됐고, JDK 27에서는 isInitialized()와 orElse()가 제거됐다. JEP는 이 두 메서드가 API 설계 의도와 어긋나게 쓰일 수 있어서 뺐다고 설명한다.
import java.lang.System.Logger;
import java.lang.System.Logger.Level;
import java.util.Set;
// JDK 27 기준. --enable-preview 필요
public class OrderController {
// 첫 접근 시점에 한 번만 초기화된다. 멀티스레드에서도 한 번이 보장된다
private final LazyConstant<Logger> logger =
LazyConstant.of(() -> System.getLogger(OrderController.class.getName()));
// JDK 27에서 새로 추가된 lazy Set. 두 번째 인자는 Predicate다
private static final Set<String> ENABLED_FEATURES =
Set.ofLazy(Set.of("dark-mode", "beta-export", "ai-assistant"),
OrderController::isFeatureEnabled);
void submitOrder(String orderId) {
logger.get().log(Level.INFO, "order started: " + orderId);
// contains 호출 시점에 isFeatureEnabled("beta-export")가 딱 한 번 평가된다
if (ENABLED_FEATURES.contains("beta-export")) {
exportOrder(orderId);
}
}
private static boolean isFeatureEnabled(String key) { return true; }
private void exportOrder(String orderId) { }
}JavaLazyConstant.of에 넘긴 람다는 get()이 처음 호출될 때 실행되고 그 결과가 영구히 고정된다. 직접 null 체크로 지연 초기화를 구현할 때와 달리, JVM이 이 값을 진짜 상수로 취급해 final 필드와 동일한 상수 폴딩 최적화를 적용한다. Set.ofLazy는 후보 원소 집합을 미리 받아두고 contains가 불릴 때 해당 원소에 대해서만 판정 함수를 실행한다. 피처 플래그처럼 후보는 정해져 있지만 평가 비용이 있는 경우에 맞는 구조다.
원시 타입 패턴 매칭은 JDK 26의 4차 프리뷰에서 unconditional exactness 정의와 switch dominance 검사를 다듬은 뒤, 이번에는 손대지 않고 그대로 재프리뷰됐다. 변경이 없다는 건 설계가 안정 궤도에 들었다는 신호로 읽을 수 있다. switch의 default 절을 case int i로 바꿔 값을 그대로 받아 쓰는 패턴이 대표적인 활용이다. 이 블로그의 Java Pattern Matching 완벽 가이드에서 다룬 내용이 JDK 27에서도 그대로 유효하다.
JEP 538 PEM Encodings는 3차 프리뷰다. PEMDecoder와 PEMEncoder로 -----BEGIN PRIVATE KEY----- 같은 PEM 텍스트와 PrivateKey·X509Certificate 객체를 오간다. 지금까지 헤더 문자열을 replace로 걷어내고 Base64 디코딩한 뒤 KeyFactory를 거치던 20줄짜리 보일러플레이트가 몇 줄로 줄어든다.
진짜 지뢰는 JEP가 아니라 조용히 제거된 옵션들
JDK 27에서 실제로 서비스를 멈추게 할 가능성이 가장 높은 건 9개 JEP가 아니다. 릴리스 노트의 “Removed Features and Options” 항목이다. 그중 -noverify와 -Xverify:none은 오래된 기동 스크립트에 아직 남아 있는 경우가 많은데, JDK 27에서는 인식되지 않는 옵션으로 처리되어 JVM이 아예 기동하지 않는다.
| 제거·변경 항목 | 영향 | 대응 |
|---|---|---|
-noverify, -Xverify:none | JVM 기동 실패 | 대체 옵션 없음. 걷어내야 한다 |
-noclassgc | JVM 기동 실패 | -Xnoclassgc로 교체 |
-verifyremote | JVM 기동 실패 | -Xverify:remote로 교체 |
| JVMCI 전면 제거 | -XX:+UseGraalJIT 등 사용 불가 | 스톡 OpenJDK에 Graal JIT를 얹던 구성 재검토 |
HttpServer 경로 매칭 변경 | 라우팅 동작 변화 | 아래 설명 참고 |
| ML-KEM·ML-DSA 개인키 인코딩 기본값 변경 | 구버전 JDK가 키를 못 읽음 | jdk.mlkem.pkcs8.encoding=expandedKey |
ThreadPoolExecutor.finalize() 제거 | 컴파일 실패 가능 | finalize() 오버라이드 제거 |
java.locale.useOldISOCodes 제거 | 경고 후 무시 | iw/ji/in → he/yi/id |
VFORK 런치 메커니즘 제거 | 프로세스 생성 실패 | 기본값 POSIX_SPAWN 또는 FORK |
| JNDI/LDAP 팩터리 프로퍼티 기본값 제거 | LDAP 연동 동작 변화 | 애플리케이션이 직접 설정 |
-Xverify:none은 JDK 13에서 deprecate됐지만 그 뒤로도 경고만 내고 계속 동작했다. 그래서 “경고 뜨는데 잘 돌아가니까” 상태로 방치된 스크립트가 많다. JDK 27은 그 유예를 끝낸다. 지금 사내 배포 스크립트와 Dockerfile을 grep -rn "Xverify\|noverify" 한 번 돌려보는 것을 권한다. 검증을 끄면 기동이 조금 빨라지긴 하지만, 애초에 클래스 검증을 끄는 건 신뢰할 수 없는 바이트코드를 그대로 실행하겠다는 뜻이라 대체 옵션이 제공되지 않는다.
JVMCI 제거(JDK-8382582)는 10년 넘게 실험 상태였던 JVM Compiler Interface를 걷어낸다. HotSpot에서 JVMCI 코드가 빠지고 jdk.internal.vm.ci, jdk.graal.compiler, jdk.graal.compiler.management 모듈이 사라지며, 이름에 JVMCI가 들어간 모든 플래그와 -XX:+UseGraalJIT이 제거된다. 스톡 OpenJDK에 외부 Graal JIT를 붙여 쓰던 구성은 JDK 27에서 성립하지 않는다. GraalVM 배포판을 쓰는 경우는 배포판 자체의 지원 버전을 별도로 확인해야 한다.
com.sun.net.httpserver.HttpServer의 변경은 성격이 다르다. 기동은 되는데 라우팅 결과가 달라진다. 기존에는 문자열 접두사 매칭이라 컨텍스트 경로 /foo가 요청 경로 /foobar에도 걸렸다. JDK 27부터는 경로 세그먼트 단위로 매칭하므로 /foo, /foo/, /foo/bar는 걸리지만 /foobar는 걸리지 않는다. 예전 동작이 필요하면 -Dsun.net.httpserver.pathMatcher 시스템 프로퍼티로 되돌릴 수 있지만, 이 되돌리기 수단도 향후 제거될 수 있다고 명시돼 있다.
ML-KEM·ML-DSA 키 인코딩 변경은 하이브리드 배포 환경에서 특히 조심해야 한다. JDK 24에서 이 알고리즘이 들어올 때는 expandedKey 형식이었는데, JDK 27은 기본값을 seed로 바꿨다. 결과적으로 JDK 27이 생성한 개인키를 구버전 JDK가 읽지 못한다. 반대 방향은 괜찮다. 구버전이 만든 키를 JDK 27이 읽는 데는 문제가 없다. 롤링 배포 중에 27과 25가 섞여 도는 구간이 있다면 jdk.mlkem.pkcs8.encoding 또는 jdk.mldsa.pkcs8.encoding을 expandedKey로 고정해 두는 게 안전하다.
나머지도 짚어둔다. ThreadPoolExecutor를 상속해 finalize()를 오버라이드하고 super.finalize()를 부르는 코드는 이제 Object.finalize()를 호출하게 되는데, 이쪽은 throws Throwable을 선언하므로 컴파일이 깨질 수 있다. 리눅스에서 -Djdk.lang.Process.launchMechanism=VFORK를 쓰던 곳은 기본값인 POSIX_SPAWN이나 FORK로 옮겨야 한다. 윈도우에서 TimeZone.getDefault()가 인도 지역에서 Asia/Calcutta 대신 Asia/Kolkata를 반환하도록 바뀐 것도, 타임존 ID를 문자열로 비교하는 코드가 있다면 영향을 받는다.
JDK 27을 지금 깔아서 우리 앱에 물려보기

JDK 27은 GA 전이라도 jdk.java.net/27에서 Early-Access 빌드를 받을 수 있고, SDKMAN을 쓰면 기존 JDK를 건드리지 않고 셸 세션 단위로만 27을 붙여볼 수 있다. 검증 목적이라면 이 방식이 가장 안전하다. 시스템 기본 JDK는 그대로 두고 터미널 하나에서만 27로 실험하면 된다.
# 설치 가능한 27 빌드 목록 확인
sdk list java | grep -E '\| *27'
# GA 전이라 식별자에 ea가 붙는다. 실제 값은 위 목록에서 확인해 그대로 쓴다
sdk install java 27.ea.15-open
# 현재 셸에서만 27로 전환 (기본 JDK는 유지된다)
sdk use java 27.ea.15-open
java -versionShellScript식별자를 외워서 쓰지 말고 반드시 목록에서 복사해야 한다. GA 전에는 27.ea.15-open처럼 EA 빌드 번호가 붙고, 2026년 9월 15일 이후에는 27-open, 27.0.1-tem 같은 정식 식별자로 바뀐다. sdk use는 sdk default와 달리 현재 셸 세션에만 적용된다. 터미널을 새로 열면 원래 기본 JDK로 돌아오므로, 실수로 개발 환경 전체를 27로 바꿔놓을 걱정이 없다. SDKMAN 자체 사용법은 이 블로그의 SDKMAN 설치부터 실전 활용까지에 정리해 두었다.
버전을 바꿨으면 애플리케이션을 띄우기 전에 런타임이 실제로 무엇을 고르는지부터 찍어보는 게 순서다. 아래는 의존성 없이 단일 파일로 돌아가는 진단 코드다. JDK 21 이상이면 어느 버전에서든 실행되므로, 25에서 한 번 27에서 한 번 돌려 출력을 비교하면 이번 릴리스에서 무엇이 바뀌었는지 그대로 드러난다.
import com.sun.management.HotSpotDiagnosticMXBean;
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
public class JdkRuntimeReport {
public static void main(String[] args) {
System.out.println("=== JDK 런타임 리포트 ===");
System.out.println("버전 : " + Runtime.version());
System.out.println("벤더 : " + System.getProperty("java.vendor"));
System.out.println("아키텍처 : " + System.getProperty("os.arch"));
System.out.println("가용 프로세서 : " + Runtime.getRuntime().availableProcessors());
System.out.printf("최대 힙 : %.0f MB%n",
Runtime.getRuntime().maxMemory() / (1024.0 * 1024.0));
// JEP 523 확인용. JDK 27부터는 작은 컨테이너에서도 G1이 나와야 한다
System.out.println("\n-- 선택된 GC --");
for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
System.out.println(" " + gc.getName());
}
System.out.println("\n-- JDK 27에서 기본값이 바뀌는 VM 옵션 --");
HotSpotDiagnosticMXBean diagnostic =
ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class);
printOption(diagnostic, "UseG1GC");
printOption(diagnostic, "UseSerialGC");
printOption(diagnostic, "UseCompactObjectHeaders"); // JEP 534
printOption(diagnostic, "MinHeapFreeRatio"); // 40 -> 0
printOption(diagnostic, "MaxHeapFreeRatio"); // 70 -> 100
}
private static void printOption(HotSpotDiagnosticMXBean diagnostic, String name) {
try {
var option = diagnostic.getVMOption(name);
System.out.printf(" %-24s = %-8s (%s)%n",
option.getName(), option.getValue(), option.getOrigin());
} catch (IllegalArgumentException e) {
// 해당 JDK 버전에 존재하지 않는 옵션이면 여기로 떨어진다
System.out.printf(" %-24s = (이 JDK에는 없는 옵션)%n", name);
}
}
}JavaJDK 11에서 돌려본 출력은 다음과 같다.
=== JDK 런타임 리포트 ===
버전 : 11.0.22+7
가용 프로세서 : 10
최대 힙 : 4096 MB
-- 선택된 GC --
G1 Young Generation
G1 Old Generation
-- JDK 27에서 기본값이 바뀌는 VM 옵션 --
UseG1GC = true (ERGONOMIC)
UseSerialGC = false (DEFAULT)
UseCompactObjectHeaders = (이 JDK에는 없는 옵션)
MinHeapFreeRatio = 40 (DEFAULT)
MaxHeapFreeRatio = 70 (DEFAULT)PlaintextUseCompactObjectHeaders가 “없는 옵션”으로 흡수되는 게 보인다. 이 옵션은 JDK 24부터 생겼으므로 그 이전 버전에서는 조회 자체가 실패한다. MinHeapFreeRatio와 MaxHeapFreeRatio가 40과 70으로 찍히는 것도 확인된다. JDK 27에서 같은 명령을 돌리면 이 두 줄이 0과 100으로 바뀌어 있어야 한다.
이 클래스는 HotSpotDiagnosticMXBean으로 JVM이 실제로 확정한 옵션 값과 그 출처를 함께 출력한다. 출처는 DEFAULT, ERGONOMIC, VM_CREATION 등으로 찍히는데, 커맨드라인으로 넘긴 플래그는 VM_CREATION으로 나온다. 이 값이 중요한 이유는, UseG1GC가 true로 찍혀도 그게 기본값이라 그런 건지 누군가 기동 옵션에 박아둔 건지를 구분해야 하기 때문이다. 존재하지 않는 옵션을 조회하면 IllegalArgumentException이 나므로 버전별 차이를 그대로 흡수하도록 예외를 잡아뒀다.
이왕이면 -XX:+UseSerialGC를 붙여 한 번 더 돌려보는 편이 좋다. JDK 26 이하의 작은 컨테이너에서 벌어지던 상황을 지금 이 자리에서 그대로 재현할 수 있다.

두 번째 실행에서 GC 이름이 Copy와 MarkSweepCompact로 바뀐 게 보인다. 이게 지금까지 작은 컨테이너에서 조용히 돌아가던 Serial GC의 실제 이름이다. UseSerialGC의 출처가 DEFAULT가 아니라 VM_CREATION으로 찍히는 것도 확인된다. 커맨드라인에서 내가 직접 넣었기 때문이다. JDK 27에서는 이 플래그를 빼도 첫 번째 실행처럼 G1이 나온다.
컨테이너에서 실행할 때는 반드시 운영과 동일한 메모리 리밋을 걸고 돌려야 한다. JEP 523의 영향은 메모리가 1792MB 미만인 환경에서만 드러나기 때문에, 넉넉한 개발 머신에서 실행하면 25든 27이든 똑같이 G1이 나와서 아무 차이도 못 본다.
# 단일 파일 실행. 컴파일 단계가 필요 없다
java JdkRuntimeReport.java
# 기준선. 현행 LTS에서 메모리 512MB로 제한해 돌린다
docker run --rm -m 512m -v "$PWD":/app -w /app eclipse-temurin:25 \
java JdkRuntimeReport.java
# 비교 대상. GA 전에는 EA 빌드를 직접 마운트해서 쓴다
docker run --rm -m 512m -v "$PWD":/app -v "$HOME/.sdkman/candidates/java/current":/jdk \
-w /app eclipse-temurin:25 /jdk/bin/java JdkRuntimeReport.javaShellScript맨 위는 JDK 11부터 지원되는 단일 파일 소스 실행이라 javac 없이 바로 돌아간다. 진짜 검증은 아래 두 개의 도커 명령이다. 메모리를 512MB로 묶어 예전이라면 Serial GC가 선택됐을 조건을 재현한다. eclipse-temurin:27 태그는 GA 이후에나 생기므로, 그 전에는 세 번째처럼 SDKMAN으로 받아둔 EA 빌드 디렉토리를 컨테이너에 마운트해 그 안의 java를 직접 실행하는 편이 확실하다. GA가 지나면 eclipse-temurin:27로 바꾸면 된다.
두 출력에서 “선택된 GC” 줄만 비교하면 된다. JDK 25 쪽에서 Copy와 MarkSweepCompact가 나오다가 27 쪽에서 G1으로 시작하는 이름들로 바뀌어 있으면, JEP 523이 그 컨테이너에 실제로 영향을 준다는 뜻이다.
FAQ
JDK 27은 LTS인가요?
아니다. JDK 27은 비-LTS 릴리스로, 다음 릴리스인 JDK 28이 나오는 2027년 3월까지 약 6개월간만 업데이트를 받는다. 현재 LTS는 2025년 9월에 나온 JDK 25이고, 다음 LTS는 2027년 9월 예정인 JDK 29다. 운영 표준 런타임은 25에 두고, JDK 27은 CI에서 선행 검증하는 용도로 쓰는 편이 현실적이다.
JDK 27로 올리면 코드를 고쳐야 하나요?
대부분은 고칠 필요가 없다. Final로 들어온 4개 JEP는 전부 기본값 변경이라 소스 수정을 요구하지 않는다. 다만 두 가지는 확인해야 한다. 첫째, 기동 스크립트에 -noverify, -Xverify:none, -noclassgc, -verifyremote가 있으면 JVM이 기동하지 못한다. 둘째, Structured Concurrency 프리뷰 API를 쓰고 있었다면 awaitAll() 제거와 onTimeout() → timeout() 변경 때문에 컴파일이 깨진다.
G1이 기본이 되면 작은 컨테이너에서 메모리를 더 쓰지 않나요?
JEP 523은 성능 지표가 유의미하게 나빠지지 않는 것을 명시적 목표로 잡았고, 최근 릴리스에서 G1의 네이티브 메모리 사용량을 Serial 수준까지 낮췄다고 밝히고 있다. 다만 모든 워크로드에 해당하는 이야기는 아니다. 실제로 회귀가 관찰되면 -XX:+UseSerialGC로 명시 지정하면 되고, 사용자가 직접 지정한 GC는 JVM이 절대 덮어쓰지 않는다.
Compact Object Headers를 켰는데 힙이 별로 안 줄어듭니다
객체 크기가 8바이트 경계로 정렬되기 때문이다. 헤더가 12바이트에서 8바이트로 줄어도 정렬 패딩이 그 4바이트를 흡수하면 총 크기는 그대로다. 필드가 적은 작은 객체를 대량으로 생성하는 코드에서 효과가 크고, 필드가 많은 엔티티 클래스는 체감이 적다. JOL로 확인할 때는 최신 버전을 쓰고, 헤더 크기가 8로 찍히고 첫 필드 오프셋이 8이면 정상 적용된 것이다.
JFR 마스킹이 우리 회사 시크릿 이름에는 안 걸립니다
기본 필터는 *password*, *secret*, *token*, *api*key* 같은 일반적인 패턴만 커버한다. dbpw처럼 여기 안 걸리는 이름을 쓰면 여전히 평문으로 남는다. -XX:FlightRecorderOptions:'redact-key=+*사내패턴*' 형태로 기본 필터에 추가하면 되고, +를 빼면 기본 목록을 통째로 대체해 버리니 주의한다. 적용 여부는 jfr print로 [REDACTED]가 찍히는지 확인한다.
마치며
지금까지 JDK 27의 9개 JEP와 마이그레이션 시 확인할 항목을 정리해 보았다.
개인적으로 이번 릴리스를 보면서 가장 손이 갔던 건 JEP 목록이 아니라 릴리스 노트의 제거 항목 쪽이었다. 예전에 인수인계받은 배치 서버 기동 스크립트에 -Xverify:none이 박혀 있던 걸 발견한 적이 있다. JDK 8 시절 누군가 기동 시간을 줄이겠다고 넣어놓은 것이 몇 번의 버전업을 거치면서도 그대로 살아남아 있었다. 경고 로그는 꼬박꼬박 찍히고 있었는데, 아무도 그 줄을 읽지 않았을 뿐이다. JDK 27은 그런 옵션들에 대한 유예를 끝낸다.
그래서 JDK 27은 당장 올려야 할 버전이라기보다, 지금 한 번 돌려봐야 할 버전에 가깝다. CI 매트릭스에 27 잡을 하나 추가해 두면 빌드가 깨지든 테스트가 실패하든 신호가 온다. 그 신호는 2027년 9월에 JDK 29로 올릴 때 마주칠 문제를 1년 앞당겨 보여주는 셈이다. 위에 올려둔 JdkRuntimeReport를 운영과 같은 메모리 리밋으로 한 번 돌려보는 것부터 시작해도 충분하다. 출력 두 개를 나란히 놓고 보면 이번 릴리스가 우리 서비스에 무엇을 하는지가 꽤 분명해진다.
