JDK 27 정리: Compact Object Headers와 G1이 기본값이 됐다

JDK 27은 2026년 9월 15일에 GA 됐습니다. LTS가 아니라서 프로덕션을 올릴 버전은 아닌데, 그래도 이번 릴리스는 한 번 뜯어볼 이유가 있었습니다. 지난 Java 25 LTS 정리에서 제가 “기본값이 될 계획은 없다”고 쓴 Compact Object Headers가 27에서 기본값이 됐거든요. 그 문장은 그 글에서 고쳤고, 여기서는 왜 바뀌었는지와 실제로 뭐가 달라지는지를 씁니다.

릴리스 후보 빌드(27+35)를 받아 Temurin 25.0.4와 나란히 돌려봤습니다. 결론은 이렇습니다. 27의 기본값 변화 두 가지(G1, Compact Object Headers)는 25에서 플래그로 미리 가져갈 수 있고, 그중 하나는 여러분 워크로드에서 효과가 0일 수도 있습니다. 그리고 올리자마자 JVM이 안 뜨는 옵션이 하나 있습니다.

JEP 9개, 정식은 넷

25가 JEP 18개였던 것과 비교하면 조용한 릴리스입니다. 다시 찾아볼 일이 있을 테니 표로 둡니다.

JEP 기능 상태
523 Make G1 the Default GC in All Environments 정식
534 Compact Object Headers by Default 정식
527 Post-Quantum Hybrid Key Exchange for TLS 1.3 정식
536 JFR In-Process Data Redaction 정식
531 Lazy Constants 3차 프리뷰
532 Primitive Types in Patterns, instanceof, and switch 5차 프리뷰
533 Structured Concurrency 7차 프리뷰
538 PEM Encodings of Cryptographic Objects 3차 프리뷰
537 Vector API 12차 인큐베이터

정식 넷은 전부 “코드를 안 고쳐도 동작이 바뀌는” 종류입니다. 새 API를 배우는 릴리스가 아니라 기본값을 확인하는 릴리스라고 보면 됩니다.

프리뷰 쪽은 짧게만 짚겠습니다. Structured Concurrency는 일곱 번째 프리뷰인데 이번에도 API가 바뀌었습니다. StructuredTaskScope에 예외 타입 파라미터가 하나 늘었고, awaitAll()이 사라지고 onTimeout()이 timeout()으로 교체됐습니다. 25에서 “아직 쓰지 말 것”으로 분류한 판단은 그대로입니다. Lazy Constants는 25의 Stable Values가 26에서 이름을 바꾼 것이고, 이번에 Set.ofLazy(...)가 추가되면서 isInitialized()와 orElse()가 빠졌습니다. 프리뷰 API 위에 코드를 올려두면 이렇게 반년마다 깨집니다.

G1이 어디서나 기본값 — 소형 컨테이너에서 GC가 바뀐다

JDK 9부터 G1이 기본이었지만 조건이 있었습니다. CPU가 1개거나 메모리가 1792MB 미만이면 JVM이 “서버급 머신이 아니다”라고 판단해 Serial GC를 골랐습니다. JEP 523은 이 분기를 없앱니다. 앞으로는 지정하지 않으면 무조건 G1입니다.

그럼 어디서 체감되느냐. CPU 1개짜리 컨테이너입니다. Docker에 --cpus=1 --memory=1g를 걸고 두 버전을 띄워봤습니다.

$ docker run --cpus=1 --memory=1g eclipse-temurin:25-jdk java GcInfo.java
JDK 25 cpus=1 maxHeap=247MB GC=[Copy, MarkSweepCompact]        ← Serial

$ docker run --cpus=1 --memory=1g ... /jdk27/bin/java GcInfo.java
JDK 27 cpus=1 maxHeap=256MB GC=[G1 Young Generation, G1 Concurrent GC, G1 Old Generation]

같은 컨테이너 스펙에서 GC가 바뀝니다. 사이드카나 배치 워커처럼 작게 띄우는 JVM이 있다면, 27부터는 아무 설정 없이 G1로 돌게 됩니다. 이게 나쁜 소식은 아닙니다. JEP 문서는 G1이 이제 모든 힙 크기에서 Serial과 경쟁할 만하고 네이티브 메모리 오버헤드도 따라잡았다고 설명합니다.

정말 그런지 궁금해서 27에서 CPU 1개, -Xmx256m으로 빈 프로그램을 띄우고 Native Memory Tracking으로 커밋된 메모리를 비교했습니다. G1이 약 37.9MB, Serial이 약 34.4MB였습니다. GC 자체가 쓰는 메모리는 G1 1.4MB, Serial 0.07MB로 차이가 있지만 전체로 보면 3.5MB 정도입니다. 컨테이너 메모리 한도가 빡빡한 경우가 아니면 신경 쓸 수준은 아니더군요. 그래도 Serial을 유지하고 싶으면 이제는 명시해야 합니다.

-XX:+UseSerialGC

한 가지 더, 릴리스 노트에 조용히 적힌 변경이 있습니다. G1에서 MinHeapFreeRatio/MaxHeapFreeRatio 기본값이 40/70에서 0/100으로 바뀌었습니다. 27의 -XX:+PrintFlagsFinal 출력에서 확인했습니다. 릴리스 노트는 이 두 비율에 의한 힙 크기 조절이 사실상 꺼진 것이라고 설명합니다. 이 두 값을 직접 튜닝해 두었다면 27에서는 전제가 달라졌다는 점만 알아두세요.

Compact Object Headers 기본값 — 그런데 절감이 0인 객체가 많다

이 항목을 제일 오래 봤습니다. 25 글에서 JEP 519의 비목표 문구를 근거로 “옵트인으로 남을 가능성이 있다”고 썼는데, 반년 만에 JEP 534가 나와 기본값이 됐습니다. JEP는 Amazon이 수백 개 서비스를 이 설정으로 운영 중이고 SAP는 자사 배포판에서 이미 기본값으로 쓴다는 점을 근거로 듭니다. 예측이 틀린 건 틀린 거라 정정합니다.

동작은 단순합니다. 객체 헤더가 96비트에서 64비트로 줍니다. 25에서는 -XX:+UseCompactObjectHeaders로 켜야 했고, 27에서는 기본값이라 끄고 싶으면 -XX:-UseCompactObjectHeaders를 씁니다. 옛 레이아웃을 제거하는 것은 이번 목표가 아니라고 JEP에 적혀 있으니 되돌릴 길은 남아 있습니다.

그런데 JEP가 말하는 “힙 22% 절감”(SPECjbb2015)이 내 객체에도 해당되는지가 궁금했습니다. 그래서 모양이 다른 객체를 500만 개씩 만들고 GC 후 힙 증가량을 개수로 나눠 봤습니다. Temurin 25.0.4 기본값, 25에 플래그를 켠 것, 27 기본값 세 가지입니다.

객체 모양 25 기본 25 + 플래그 27 기본
new Object() 16 B 8 B 8 B
필드 int 1개 16 B 16 B 16 B
필드 int 2개 24 B 16 B 16 B
필드 long 1개 24 B 16 B 16 B
참조 필드 1개 16 B 16 B 16 B
참조 2개 + int 1개 (연결 리스트 노드) 24 B 24 B 24 B
Integer 16 B 16 B 16 B
new int[0] 16 B 16 B 16 B

예상보다 절감이 없는 행이 많습니다. 이유는 8바이트 정렬입니다. 헤더가 12바이트에서 8바이트로 줄어도, 객체 전체 크기가 8의 배수로 올림되기 때문에 원래 크기가 딱 16이나 24에 걸쳐 있던 객체는 그대로입니다. Integer가 대표적입니다. 12 + 4 = 16이었는데 8 + 4 = 12가 되어도 다시 16으로 올림됩니다. 반대로 빈 객체는 절반, long 하나나 int 두 개짜리는 3분의 1이 줄어듭니다.

그러니 이 기능의 효과는 “내 힙을 차지하는 객체의 모양”에 달려 있습니다. 도메인 객체에 long id와 int 몇 개가 들어 있다면 꽤 줄고, HashMap의 노드처럼 참조 위주면 덜 줄어듭니다. 25에서 플래그를 켜고 힙 사용량을 재보면 27에서 얻을 값과 같으니, 미리 측정할 수 있습니다.

덤으로 하나. 27에서는 -XX:-UseCompressedClassPointers가 무시됩니다. 이 옵션이 시작 스크립트에 남아 있어도 경고만 찍고 뜨긴 뜹니다. JVM이 항상 클래스 포인터를 압축하게 됐기 때문입니다.

TLS가 양자내성 키 교환을 먼저 제안한다

JEP 527은 TLS 1.3 키 교환에 하이브리드 방식 세 가지를 추가합니다. 그중 X25519MLKEM768 하나만 기본으로 켜지고 기본 목록의 맨 앞에 놓입니다. 나머지 둘(SecP256r1MLKEM768, SecP384r1MLKEM1024)은 직접 설정해야 합니다.

기본 목록을 두 버전에서 찍어보면 차이가 바로 보입니다.

JDK 25.0.4: x25519, secp256r1, secp384r1, secp521r1, x448, ffdhe2048, ffdhe3072, ffdhe4096, ffdhe6144, ffdhe8192
JDK 27:     X25519MLKEM768, x25519, secp256r1, secp384r1, secp521r1, x448, ffdhe2048, ffdhe3072, ffdhe4096

앞에 하이브리드가 추가됐고 뒤에서 ffdhe6144, ffdhe8192가 빠졌습니다. 실제로 협상되는지 보려고 -Djavax.net.debug=ssl:handshake를 켜고 cloudflare.com에 붙여봤습니다. ClientHello의 key_share에 X25519MLKEM768과 x25519가 실리고, ServerHello가 X25519MLKEM768을 골랐습니다. 코드 한 줄 안 바꿨는데 양자내성 키 교환으로 연결된 겁니다.

주의할 점은 ClientHello 크기입니다. ML-KEM-768 공개키가 들어가면서 첫 메시지가 1KB 넘게 커집니다. 사내 프록시나 오래된 TLS 종단이 이걸 제대로 다루지 못하면 핸드셰이크가 이유 없이 실패하는 것처럼 보일 수 있습니다. 그럴 때는 시스템 프로퍼티로 그룹 목록을 되돌릴 수 있습니다.

-Djdk.tls.namedGroups=x25519,secp256r1,secp384r1

프로그램 안에서는 SSLParameters.setNamedGroups()로 같은 일을 합니다. 27로 올리기 전에 사내 TLS 종단이 하이브리드 그룹을 만나면 어떻게 반응하는지 한 번은 확인해 두는 편이 낫겠습니다.

JFR가 비밀번호를 가린다 — 그리고 인자가 안 보여서 헤맬 수 있다

JEP 536은 JFR 녹화 파일에 들어가는 명령행 인자, 환경 변수, 시스템 프로퍼티에서 민감한 값을 [REDACTED]로 바꿉니다. 지원 티켓에 .jfr을 첨부했다가 DB 비밀번호를 같이 보내는 사고를 막으려는 겁니다.

실제로 어떻게 보이는지 -Ddb.password=hunter2 -Dapi.token=sk-live-123 -Dapp.mode=prod를 붙여 녹화한 뒤 jfr print로 열어봤습니다.

# JDK 25.0.4  (jdk.InitialSystemProperty)
key = "db.password"   value = "hunter2"
key = "api.token"     value = "sk-live-123"

# JDK 27
key = "db.password"   value = "[REDACTED]"
key = "api.token"     value = "[REDACTED]"
key = "app.mode"      value = "prod"

# JDK 27  (jdk.JVMInformation.jvmArguments)
-XX:StartFlightRecording:... [REDACTED] [REDACTED] -Dapp.mode=prod

설정 없이도 password, token, secret, credential 같은 키 이름을 알아서 가립니다. app.mode는 그대로 남았습니다. 명령행 인자 쪽도 -Ddb.password=... 인자 자체가 통째로 가려졌습니다.

반대로 생각하면, 27부터 JFR로 “이 프로세스가 무슨 옵션으로 떴는지” 확인하려다 [REDACTED]만 보고 헤맬 수 있습니다. 필터를 추가하려면 -XX:FlightRecorderOptions:redact-key=confidential처럼 쓰고, 예전처럼 전부 보고 싶으면 redact-key=none을 씁니다. 여기에 jdk.SystemProcess 이벤트가 다른 프로세스의 명령행 인자를 더 이상 기록하지 않는 변경도 함께 들어갔습니다.

올리자마자 깨지는 것

릴리스 노트의 제거 목록을 하나씩 실행해 봤습니다. 가장 먼저 걸릴 만한 것이 이겁니다.

$ java -Xverify:none -version
Unrecognized verification option: -Xverify:none
Error: Could not create the Java Virtual Machine.

-noverify도 같습니다. 25까지는 “13에서 deprecated, 곧 제거”라는 경고만 찍고 떴는데 27에서는 JVM이 시작되지 않습니다. 기동 시간을 줄이려고 오래전에 넣어둔 시작 스크립트, Dockerfile의 JAVA_OPTS, IDE 실행 구성에 이 옵션이 남아 있는 경우가 꽤 있습니다. 27 위에서 처음 띄울 때 JVM이 뜨지도 않으면 이것부터 의심하세요.

그 밖에 제거된 것들입니다.

  • 실험적 JVMCI와 -XX:+UseGraalJIT가 완전히 빠졌습니다. jdk.internal.vm.ci 모듈도 없습니다.
  • 프로세스 실행 방식 중 VFORK가 제거됐습니다. jdk.lang.Process.launchMechanism=VFORK를 쓰고 있었다면 POSIX_SPAWN이나 FORK로 바꿔야 합니다.
  • ThreadPoolExecutor.finalize()가 제거됐습니다. 9에서 deprecated 됐던 것입니다.
  • java.locale.useOldISOCodes 프로퍼티가 없어졌습니다. 히브리어·이디시어·인도네시아어 코드가 he, yi, id로 고정됩니다.
  • TimeZone이 옛 ID 대신 현재 IANA ID를 돌려줍니다. 예를 들어 Asia/Calcutta가 아니라 Asia/Kolkata입니다. 타임존 문자열을 비교하는 코드가 있으면 확인이 필요합니다.
  • 내장 HttpServer의 컨텍스트 경로 매칭이 문자열 접두어에서 경로 접두어로 바뀌었습니다. /api가 /api2에 매칭되던 동작이 사라집니다.

Spring Boot를 쓰신다면 하나 더. 이 글을 쓰는 시점의 Spring Boot 4.1.1 시스템 요구사항은 “Java 17 이상, Java 26까지 호환”이라고 적혀 있습니다. 27은 아직 공식 문구 밖입니다. 실제로는 돌아갈 가능성이 높지만, 문제가 생겼을 때 지원 범위 안이냐 밖이냐는 다른 얘기입니다.

그래서 25 LTS에서 27로 올릴 이유가 있나

프로덕션은 아니라고 봅니다. 27은 비-LTS라 다음 릴리스인 28이 나오는 2027년 3월까지만 업데이트가 나오고, 다음 LTS는 2027년 9월의 29입니다. 6개월짜리 버전에 운영 부담을 얹을 이유가 없습니다.

대신 27이 보여준 방향을 25에서 미리 가져가는 건 할 만합니다.

  1. -XX:+UseCompactObjectHeaders를 스테이징에 켜고 힙을 잰다. 27에서 기본값이 됐다는 건 이 설정이 충분히 검증됐다는 뜻이고, 위 표처럼 효과는 객체 모양에 따라 0에서 50%까지 갈립니다. 재보지 않고는 모릅니다.
  2. CPU 1개짜리 컨테이너가 있으면 -XX:+UseG1GC를 명시해 27의 기본 동작을 미리 경험해 본다. 문제가 없으면 27로 갈 때 신경 쓸 일이 하나 준다.
  3. 사내 TLS 종단(프록시, 로드밸런서, 구형 어플라이언스)이 하이브리드 키 교환 ClientHello를 받아들이는지 27 클라이언트로 한 번 붙여본다.
  4. 시작 스크립트에서 -Xverify:none, -noverify, -XX:-UseCompressedClassPointers를 지운다. 25에서도 필요 없는 옵션입니다.
  5. CI 매트릭스에 27을 추가한다. 프로덕션이 아니라 “다음 LTS인 29에서 깨질 것을 2년 먼저 보는” 용도입니다.

가상 스레드를 쓰고 있다면 27에서 바뀌는 건 없습니다. pinning 관련 변화는 JDK 24의 JEP 491에서 끝났고, Structured Concurrency는 여전히 프리뷰입니다.

자주 묻는 질문

Q. JDK 27은 LTS인가요? A. 아닙니다. Oracle의 2년 주기 LTS 정책에 따라 21(2023년), 25(2025년) 다음 LTS는 29(2027년 9월)입니다. 27은 28이 나오는 2027년 3월까지 6개월간 업데이트됩니다.

Q. Java 25에서 Compact Object Headers를 켜면 27과 같은 효과를 얻나요? A. 같습니다. 위 표에서 “25 + 플래그” 열과 “27 기본” 열이 전부 일치했습니다. 25에서는 -XX:+UseCompactObjectHeaders가 정식 제품 옵션이라 다른 플래그 없이 켤 수 있습니다. 단, 24 이하에서는 실험 옵션이라 -XX:+UnlockExperimentalVMOptions가 먼저 필요합니다.

Q. G1이 기본값이 되면 작은 컨테이너의 메모리 사용량이 늘어나나요? A. 조금 늘어납니다. CPU 1개, 최대 힙 256MB 조건에서 빈 프로그램의 커밋 메모리가 Serial 대비 약 3.5MB 많았습니다. 메모리 한도가 수백 MB 단위라면 무시할 수준이고, 그래도 아끼고 싶다면 -XX:+UseSerialGC를 명시하면 됩니다.


이 글의 실측은 OpenJDK 27 릴리스 후보 빌드 27+35(2026년 8월 20일)와 Eclipse Temurin 25.0.4를 macOS(Apple Silicon)와 Docker(linux/arm64)에서 2026년 9월 13일에 돌린 결과입니다. 9월 15일에 공개된 GA 빌드도 27+35로, 실측에 쓴 빌드와 같습니다(2026년 9월 17일 확인). 객체 크기는 500만 개 할당 후 GC를 강제하고 힙 증가량을 나눈 근사값이며, 소수점은 반올림했습니다.

참고 자료

“JDK 27 정리: Compact Object Headers와 G1이 기본값이 됐다”에 대한 2개의 생각

  1. 핑백: 가상 스레드를 켰는데 왜 빨라지지 않는가: JEP 491 이후 이동한 병목 - loghubs

  2. 핑백: Java 25 LTS 실무 정리: 지금 쓸 것과 아직 아닌 것 - loghubs

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤