Java 26에 포함된 10개의 JEP를 한눈에 살펴보고, 새 기능과 재프리뷰(Preview) 기능, 사용 중단(Deprecated) 항목까지 핵심 변화와 사용 예를 간단히 소개합니다.
Java 26이 출시되었습니다! 6개월 전 우리는 Java 25를 마음에 품었고, 이제 또 한 번 새로운 Java 기능을 한가득 맞이할 시간입니다. 이번에는 이전 몇몇 릴리스에 비해 기능 묶음이 조금 더 작습니다. 이는 딱 한 가지를 의미합니다. 이번 릴리스의 초점은 곧 공개될 커다란 무언가™️를 위한 탄탄한 기반을 마련하는 데 있었다는 것이죠! 제 바람은 Project Valhalla의 첫 JEP들이 올해 후반에 발표되는 것입니다. 그리고 Java 26의 몇몇 변경 사항은 첫 번째 Valhalla 기능들을 위한 적절한 준비 단계처럼 느껴지기 때문에 그 기대는 더 커집니다(특히 JEP 500과 529이 그렇습니다).
미래 계획이 무엇이든 간에, 이 글은 이번 릴리스에 추가된 모든 것을 다루며 각 기능을 간단히 소개합니다. 해당되는 곳에서는 Java 25와의 차이점을 강조하고 몇 가지 전형적인 사용 사례도 제공하니, 이 글을 읽고 나면 바로 이 기능들을 사용하기에 충분히 준비될 것입니다.

Photo by Rodolfo Quirós, from Pexels
먼저 Java 26에 포함된 JEP들의 개요를 살펴보겠습니다. 아래 표에는 프리뷰 상태, 소속 프로젝트, 추가되는 기능의 유형, 그리고 Java 25 이후 변경된 사항이 담겨 있습니다.
| JEP | Title | Status | Project | Feature Type | Changes since previous Java version |
|---|---|---|---|---|---|
| 500 | Prepare to Make Final Mean Final | Core Libs | Deprecation | Warnings | |
| 504 | Remove the Applet API | Client Libs | Deprecation | Deprecation | |
| 516 | Ahead-of-Time Object Caching with Any GC | HotSpot | Performance | New feature | |
| 517 | HTTP/3 for the HTTP Client API | Core Libs | Extension | New feature | |
| 522 | G1 GC: Improve Throughput by Reducing Synchronization | HotSpot | Performance | New feature | |
| 524 | PEM Encodings of Cryptographic Objects | Second Preview | Security Libs | Security | Minor |
| 525 | Structured Concurrency | Sixth Preview | Loom | Concurrency | Minor |
| 526 | Lazy Constants | Second Preview | Core Libs | New API | Major |
| 529 | Vector API | Eleventh Incubator | Panama | New API | None |
| 530 | Primitive Types in Patterns, instanceof, and switch | Fourth Preview | Amber | Language | Minor |
이제 Java 26에 완전히 새로운 기능을 추가하는 JEP들부터 시작해 보겠습니다.
Java 26은 HotSpot에 두 가지 새 기능을 도입합니다:
HotSpot JVM은 Oracle이 개발하는 런타임 엔진입니다. Java 바이트코드를 호스트 운영체제의 프로세서 아키텍처에 맞는 머신 코드로 변환합니다.
웹 서버나 실시간 시스템처럼 빠른 응답 시간이 필요한 애플리케이션에서 중요한 지표 중 하나는 테일 레이턴시(요청이 처리되는 데 걸리는 시간)입니다. 테일 레이턴시는 가비지 컬렉션 중단(gc pause) 때문에 생기기도 하고, 아직 충분히 워밍업되지 않은 새 JVM 인스턴스로 요청이 들어갔을 때 생기기도 합니다. 첫 번째 원인은 Z Garbage Collector(ZGC) 같은 저지연 가비지 컬렉터를 사용해 완화할 수 있고, 두 번째 원인은 ahead-of-time cache를 사용해 완화할 수 있습니다. 이는 JVM 인스턴스의 더 빠른 기동을 가능하게 합니다.
Java 24에서는 이 ahead-of-time 캐시가 도입되어, 초기 훈련 실행(training run) 결과로 클래스들을 읽고 파싱하고 로드하고 링크한 뒤 메모리에 저장했습니다. 이후 애플리케이션을 다시 실행할 때 이를 재사용해 시작 시간을 줄일 수 있었죠. 하지만 당시 캐시 사용에는 제약이 있었습니다. 캐시된 Java 객체가 GC별 형식으로 저장되어 ZGC 같은 다른 가비지 컬렉터와 호환되지 않았기 때문입니다. JEP 516은 Java 객체를 GC에 독립적인 형식(GC-agnostic format)으로 캐싱함으로써, ahead-of-time 캐시를 ZGC(그리고 그 밖의 어떤 가비지 컬렉터에도)에서 사용할 수 있도록 지원을 확장합니다.
각 가비지 컬렉터는 메모리에 객체를 배치하는 정책이 다르므로, 캐시된 객체의 메모리 주소는 가비지 컬렉터가 바뀌면 유효하지 않습니다. 이 문제를 해결하기 위해 JEP 516은 캐시 형식을 변경하여 메모리 주소를 논리 인덱스(logical indices)로 대체합니다. 캐시를 로드할 때 이 논리 인덱스는 메모리에 스트리밍(streaming) 되어 다시 메모리 주소로 변환되며, 이 과정에서 캐시된 객체가 실제로 메모리에 구체화(materialize)됩니다.
JVM은 훈련 실행에서 ZGC 또는 -XX:-UseCompressedOops 커맨드라인 옵션을 사용했거나, 힙이 32GB보다 컸다면 자동으로 스트리밍 가능한 GC-독립 형식으로 객체를 캐싱합니다. 반대로 훈련 실행에서 -XX:+UseCompressedOops(‘+’가 있다는 점에 주의) 옵션을 사용했다면 이전의 GC-특정 형식으로 객체를 캐싱합니다. 이는 훈련 환경이 32GB 미만의 힙을 사용했고 ZGC를 사용하지 않았음을 의미합니다.
훈련 환경과 무관하게 새 GC-독립 캐시 형식을 강제로 사용하고 싶다면 -XX:+AOTStreamableObjects 커맨드라인 옵션을 지정하면 됩니다.
ZGC 전용 캐시를 만드는 대안이 채택되지 않은 이유는, 각 가비지 컬렉터마다 별도의 캐시를 유지·관리해야 했기 때문입니다. 또한 ZGC 전용 캐시의 유일한 이점은 단일 코어 머신에서의 약간 더 나은 성능 정도였을 것입니다. 하지만 고도로 동시적인 ZGC는 단일 코어 머신이 아니라 멀티 코어 머신에서 잘 동작하도록 설계되었기 때문에, 현실적으로 이 이점은 미미하며 여러 캐시를 유지하는 비용을 정당화하지 못합니다.
이 기능에 대한 자세한 내용은 JEP 516을 참고하세요.
_G1 GC_는 Java의 기본 가비지 컬렉터로 Java 9부터 사용되고 있습니다. G1은 큰 힙을 사용하는 애플리케이션에서 높은 성능과 짧은 중단 시간을 제공하도록 설계되었고, 레이턴시와 처리량 사이의 균형을 목표로 합니다. 이 균형을 달성하기 위해 G1은 애플리케이션과 동시에(concurrently) 작업을 수행하므로, 애플리케이션 스레드는 GC 스레드와 CPU를 공유하게 됩니다. 이 상황은 스레드 동기화를 필요로 하는데, 불행히도 이는 처리량을 떨어뜨리고 레이턴시를 늘립니다.
JEP 522는 애플리케이션 스레드와 GC 스레드 사이에 필요한 동기화의 양을 줄여 처리량과 레이턴시를 모두 개선하는 것을 제안합니다.
G1이 메모리를 회수할 때, 힙 내의 살아있는 객체(live objects)는 새 메모리 영역으로 복사되고, 원래 위치에 남은 공간은 해제됩니다. 해당 객체에 대한 참조는 새 위치를 가리키도록 갱신되어야 합니다. 이런 참조를 찾기 위해 매번 힙 전체를 스캔하지 않도록, G1은 카드 테이블(card table) 이라는 데이터 구조를 유지하며, 객체 참조가 필드에 저장될 때마다 이를 갱신합니다. 이러한 갱신은 쓰기 장벽(write barriers) 이라고 불리는 코드 조각에 의해 수행되며, G1은 JIT 컴파일러와 협력해 애플리케이션 코드에 이를 주입합니다.
카드 테이블을 스캔하는 작업은 효율적이며 보통 GC 중단 시간 안에 들어옵니다. 하지만 객체가 매우 빈번하게 할당되는 환경에서는 카드 테이블이 지나치게 커져 G1의 중단 시간 목표 내에 스캔이 끝나지 않을 수 있습니다. 이를 피하기 위해 G1은 별도의 옵티마이저 스레드로 백그라운드에서 카드 테이블을 최적화합니다. 이 방식이 동작하려면 카드 테이블의 갱신이 스레드 안전해야 하며, 현재는 옵티마이저 스레드와 애플리케이션 스레드를 동기화함으로써 이를 달성합니다. 이는 더 복잡하고 느린 쓰기 장벽 코드로 이어진다고 볼 수 있습니다.
JEP 522는 옵티마이저 스레드와 애플리케이션 스레드가 더 이상 간섭하지 않도록 두 번째 카드 테이블 을 도입할 것을 제안합니다. 애플리케이션 스레드의 쓰기 장벽은 동기화 없이 첫 번째 카드 테이블을 갱신하고, 옵티마이저 스레드는 처음에는 비어 있는 두 번째 카드 테이블을 갱신합니다.
G1이 중단 동안 현재 카드 테이블을 스캔하면 중단 시간 목표를 초과할 가능성이 크다고 판단하면, 두 카드 테이블을 원자적으로 교체합니다. 그러면 애플리케이션 스레드는 이제 비어 있는 테이블(이전의 “두 번째” 테이블)에 계속 기록하고, 전용 옵티마이저 스레드는 이전에 채워져 있던 테이블(이전의 “첫 번째” 테이블)을 추가 동기화 없이 처리합니다. G1은 활성 카드 테이블에 필요한 작업량이 원하는 한도 내에 머물도록 필요 시 이런 스와핑을 반복합니다.
이 접근 방식은 애플리케이션 스레드와 옵티마이저 스레드 사이의 동기화 양을 줄입니다. 객체 참조 필드를 많이 변경하는 애플리케이션에서는 5~15%의 처리량 향상을 기대할 수 있습니다. 또한 쓰기 장벽 코드가 훨씬 단순해질 수 있으므로, 객체 참조 필드를 많이 변경하지 않는 애플리케이션에서도 x64 아키텍처에서 최대 5%까지의 추가 처리량 향상이 관측되었습니다.
두 카드 테이블은 크기가 동일하며, 각각 같은 양의 추가 네이티브 메모리를 사용합니다. 둘을 합치면 Java 힙의 약 0.2% 정도를 차지하며, 이는 힙 1GB당 약 2MB의 네이티브 메모리에 해당합니다. 이 정도의 적은 오버헤드는 큰 성능 향상에 비하면 충분히 가치가 있습니다. 특히 Java 20 이전에는 G1이 지금의 두 번째 카드 테이블이 필요로 하는 메모리의 8배가 넘는 메모리를 필요로 했다는 점을 고려하면 더욱 그렇습니다.
이 기능에 대한 자세한 내용은 JEP 522을 참고하세요.
Java 26은 Core Libs에 속하는 단 하나의 새 기능을 도입합니다:
Java 11부터 Java 플랫폼에는 현대적인 HTTP 클라이언트 API가 포함되어 있습니다. 이는 HTTP/1.1과 HTTP/2를 지원하며, 향후 버전도 지원할 수 있도록 설계되었습니다. 현재 형태의 API는 기본적으로 HTTP/2를 가정하지만, 대상 서버가 더 새로운 HTTP 버전을 지원하지 않으면 HTTP/1.1로 되돌아갈 수 있습니다.
아래 코드 예제는 이 API의 사용 편의성과 프로토콜 비종속성을 보여줍니다:
import java.net.http.*;
...
var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder(URI.create("https://hanno.codes")).GET().build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
assert response.statusCode() == 200;
String htmlText = response.body();
assert htmlText.contains("Java");
보시다시피, 이 코드 예제에서는 어떤 HTTP 버전도 지정하지 않았습니다. API는 기본적으로 HTTP/2를 가정합니다.
HTTP/3는 2022년에 IETF에 의해 표준화되었으며, TCP 위에서 동작하는 QUIC 전송 계층 프로토콜을 사용합니다. HTTP/3 프로토콜을 사용하는 애플리케이션은 멀티플렉싱, 더 빠른 핸드셰이크, 네트워크 혼잡 문제 회피, 더 신뢰할 수 있는 전송 등 다양한 이점을 얻을 수 있습니다. 대부분의 웹 브라우저는 이미 HTTP/3를 지원하고 있으며, 현재 전체 웹사이트의 약 3분의 1이 그 기능의 이점을 누리고 있습니다. 따라서 HTTP 클라이언트 API에서 이를 지원하기 시작하기에 좋은 시점으로 보이며, JEP 517이 정확히 그 제안을 합니다.
Java 26에서 HTTP 클라이언트 API는 HttpClient 또는 HttpRequest 인스턴스를 HTTP_3 버전으로 구성해 HTTP/3를 명시적으로 선택(opt-in)하도록 요구합니다. 예를 들어:
// for reuse with multiple requests
var http3Client = HttpClient.newBuilder().version(HttpClient.Version.HTTP_3).build();
// just for a single request
var http3Request = HttpRequest.newBuilder(URI.create("https://hanno.codes"))
.version(HttpClient.Version.HTTP_3)
.GET().build();
HTTP/3를 선택했으면(요청 자체에서든 클라이언트에서든) 평소처럼 요청을 전송하면 됩니다. 대상 서버가 HTTP/3를 지원하지 않으면 요청은 자동으로, 그리고 투명하게 HTTP/2로(필요하다면 HTTP/1.1로) 롤백됩니다.
HTTP 클라이언트 API는 대상 서버가 HTTP/3를 지원하는지 확실히 알 수 없습니다. 또한 기존 HTTP/1.1 및 HTTP/2 연결은 HTTP/3로 업그레이드할 수 없습니다. HTTP/1.1과 HTTP/2는 TCP 위에 구축되어 있지만, HTTP/3의 QUIC은 UDP 데이터그램 기반이기 때문입니다. 따라서 API는 프로토콜 버전을 협상하는 방법이 필요했고, 이를 위해 네 가지 접근 방식을 갖추었습니다:
먼저 HTTP/3를 시도하고 타임아웃 시 폴백 – HTTP/3로 요청을 시작하고, 합리적인 타임아웃 내에 연결이 수립되지 않으면 자동으로 HTTP/2 또는 HTTP/1.1로 다운그레이드합니다. (선호 버전이 HTTP\_3로 설정된 HttpRequest에 해당)
HTTP/3와 이전 프로토콜을 경합(race) – HTTP/3 연결과 HTTP/2 또는 HTTP/1.1 연결을 동시에 열고 먼저 성공하는 쪽을 사용합니다. ( HttpClient는 HTTP\_3를 선호하지만 HttpRequest는 선호 버전을 지정하지 않을 때 발생)
HTTP/2 또는 1.1로 시작하고 발견 시 전환 – 첫 요청을 HTTP/2 또는 HTTP/1.1로 보냅니다. 서버 응답이 HTTP/3 사용 가능을 나타내면 이후 모든 요청을 HTTP/3로 전환합니다. ( H3\_DISCOVERY 옵션에 Http3DiscoveryMode.ALT\_SVC를 설정하고, 클라이언트 또는 요청 중 최소 하나가 HTTP\_3를 선호할 때 트리거)
HTTP/3만 강제 – 모든 요청을 오직 HTTP/3로만 전송합니다. 서버가 HTTP/3로 응답할 수 없으면 실패로 처리하고 이전 프로토콜로 폴백하지 않습니다. ( H3\_DISCOVERY 옵션에 Http3DiscoveryMode.HTTP\_3\_URI\_ONLY를 설정하고, 클라이언트 또는 요청 중 최소 하나가 HTTP\_3를 선호할 때 활성화)
네 가지 방법은 각각 단점이 있습니다:
HTTP/3는 이전 버전들만큼 널리 배포되어 있지 않기 때문에, 단 하나의 접근 방식이 모든 상황에서 통할 수 없습니다. 이것이 현재 시점에서 HTTP/3를 기본 프로토콜 버전으로 만들 수 없는 주된 이유이기도 합니다. 다만 향후 HTTP/3 채택이 더 널리 확산되면 바뀔 수도 있습니다.
이 기능에 대한 자세한 내용은 JEP 517을 참고하세요.
이제는 이미 익숙할 수도 있는 몇 가지 기능을 살펴볼 차례입니다. 이 기능들은 이전 Java 버전에서 도입되었고, Java 26에서 재프리뷰되었으며, 대부분의 경우 Java 25에 비해 변경은 사소합니다.
Java 맥락에서는 공개키, 개인키, 인증서 같은 암호화 객체를 쉽게 생성하고 배포할 수 있습니다. 하지만 Java 바깥 세계에서는 사실상의 표준이 Privacy-Enhanced Mail(PEM) 형식입니다. PEM으로 인코딩된 암호화 객체 예시를 하나 보겠습니다:
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEi/kRGOL7wCPTN4KJ2ppeSt5UYB6u
cPjjuKDtFTXbguOIFDdZ65O/8HTUqS/sVzRF+dg7H3/tkQ/36KdtuADbwQ==
-----END PUBLIC KEY-----
현재 Java 플랫폼에는 PEM 형식의 텍스트를 디코딩/인코딩하는 사용하기 쉬운 API가 포함되어 있지 않기 때문에, PEM으로 인코딩된 키를 디코딩하는 작업은 원본 PEM 텍스트를 신중하게 파싱해야 하는 번거로운 일이 될 수 있습니다. 이 점을 더 분명히 하자면, 개인 키를 암호화/복호화하는 작업은 현재 코드가 10여 줄을 넘게 필요합니다.
이 문제를 해결하기 위해 JEP 524는 객체를 PEM 형식으로 인코딩할 수 있는 API를 도입합니다. 이는 Base64와 암호화 객체 사이의 다리 역할을 합니다. java.security 패키지에 새 인터페이스 하나와 새 클래스 세 개가 포함됩니다:
DEREncodable인스턴스를 Distinguished Encoding Rules(DER) 형식의 바이트 배열로 변환하는 것을 지원하는 모든 암호화 객체를 묶는 sealed 인터페이스입니다.PEMEncoderDEREncodable 객체를 PEM 텍스트로 인코딩하기 위한 메서드를 선언하는 클래스입니다.PEMDecoderPEM 텍스트를 DEREncodable 객체로 디코딩하기 위한 메서드를 선언하는 클래스입니다.PEMDEREncodable을 구현하는 record로, 어떤 종류의 PEM 데이터든 담을 수 있습니다. 현재 Java 표현이 없는 암호화 객체를 결과로 하는 PEM 텍스트의 인코딩/디코딩을 가능하게 합니다.
아래 코드 예제는 API의 전형적인 사용법을 보여줍니다:
PrivateKey privateKey = ...;
PublicKey publicKey = ...;
// let's encode a cryptographic object!
PEMEncoder pemEncoder = PEMEncoder.of();
// this returns PEM text in a byte array
byte[] privateKeyPem = pemEncoder.encode(privateKey);
// this returns PEM text in a String
String keyPairPem = pemEncoder.encodeToString(new KeyPair(privateKey, publicKey));
// this returns encrypted PEM text
String password = "java-first-java-always";
String pem = pemEncoder.withEncryption(password).encodeToString(privateKey);
// let's decode a cryptographic object!
PEMDecoder pemDecoder = PEMDecoder.of();
// this returns a DEREncodable, so we need to pattern-match
switch (pemDecoder.decode(pem)) {
case PublicKey publicKey -> ...;
case PrivateKey privateKey -> ...;
default -> throw new IllegalArgumentException("Unsupported cryptographic object");
}
// alternatively, if you know the type of the encoded cryptographic object in advance:
PrivateKey key = pemDecoder.decode(pem, PrivateKey.class);
// this decodes an encrypted cryptographic object
PrivateKey decryptedkey = pemDecoder.withDecryption(password).decode(pem, PrivateKey.class);
이 JEP는 프리뷰 단계이므로, 기능을 사용해 보려면 커맨드라인에 --enable-preview 플래그를 추가해야 합니다.
Java 25에 비해 API에 몇 가지 사소한 변경이 있었습니다:
PEMRecord가 PEM으로 이름이 변경되었고, 디코딩된 Base64 콘텐츠를 반환하는 decode() 메서드가 포함되었습니다.PEMEncoder 및 PEMDecoder 클래스가 이제 KeyPair 및 PKCS8EncodedKeySpec 객체의 암호화/복호화를 지원합니다.EncryptedPrivateKeyInfo 클래스에도 몇 가지 변경이 있었습니다:
encryptKey 메서드들이 encrypt로 이름이 바뀌었고, 이제 PrivateKey 대신 DEREncodable 객체를 받습니다. 이를 통해 KeyPair와 PKCS8EncodedKeySpec 객체의 암호화가 가능해집니다.PublicKey를 포함한 PKCS#8 인코딩 텍스트를 복호화하는 getKeyPair 메서드가 포함되었습니다.getKey 메서드가 던지는 예외가, 인근의 getKeySpec 메서드가 던지는 예외와 정렬(aligned)되었습니다.이 기능에 대한 자세한 내용은 JEP 524를 참고하세요.
Java의 동시성 접근은 전통적으로 비구조적(unstructured) 이었습니다. 즉, 작업들이 서로 독립적으로 실행됩니다. 계층, 스코프, 혹은 다른 구조가 없기 때문에 오류나 취소 의도를 전달하기가 어렵습니다. 이를 설명하기 위해, 레스토랑에서 벌어지는 코드 예제를 보겠습니다:
이 코드 예제들은 제 컨퍼런스 발표 “Java’s Concurrency Journey Continues! Exploring Structured Concurrency and Scoped Values”에서 가져왔습니다.
public class MultiWaiterRestaurant implements Restaurant {
@Override
public MultiCourseMeal announceMenu() throws ExecutionException, InterruptedException {
Waiter grover = new Waiter("Grover");
Waiter zoe = new Waiter("Zoe");
Waiter rosita = new Waiter("Rosita");
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Course> starter = executor.submit(() -> grover.announceCourse(CourseType.STARTER));
Future<Course> main = executor.submit(() -> zoe.announceCourse(CourseType.MAIN));
Future<Course> dessert = executor.submit(() -> rosita.announceCourse(CourseType.DESSERT));
return new MultiCourseMeal(starter.get(), main.get(), dessert.get());
}
}
}
Waiter 클래스의 announceCourse(..) 메서드는 코스에 필요한 재료가 품절일 수 있기 때문에 때때로 OutOfStockException으로 실패한다는 점에 주의하세요. 이는 몇 가지 문제를 야기할 수 있습니다:
zoe.announceCourse(CourseType.MAIN) 실행이 오래 걸리는 동안 grover.announceCourse(CourseType.STARTER)가 중간에 실패한다면, announceMenu(..) 메서드는 합리적으로는 취소해야 할 메인 코스 안내를 main.get()에서 블로킹하며 불필요하게 기다리게 됩니다.zoe.announceCourse(CourseType.MAIN)에서 예외가 발생하면 main.get()은 이를 던지겠지만, grover.announceCourse(CourseType.STARTER)는 자신의 스레드에서 계속 실행되어 스레드 누수(thread leakage)가 발생합니다.announceMenu(..)를 실행하는 스레드가 인터럽트되더라도, 이 인터럽트는 하위 작업으로 전파되지 않습니다. 즉, announceCourse(..) 호출을 수행 중인 모든 스레드가 누수되어 announceMenu()가 실패한 뒤에도 계속 실행됩니다.결국 문제는, 우리의 프로그램이 논리적으로는 작업-하위작업 관계로 구조화되어 있지만, 그 관계가 오직 개발자의 머릿속에만 존재한다는 점입니다. 우리 모두는 순차적인 이야기처럼 읽히는 구조화된 코드를 선호할지 모르지만, 이 예제는 그 기준을 충족하지 못합니다.
반면, 단일 스레드 코드의 실행은 항상 작업과 하위 작업의 계층을 강제합니다. 이는 아래의 레스토랑 예제의 단일 스레드 버전에서 확인할 수 있습니다:
public class SingleWaiterRestaurant implements Restaurant {
@Override
public MultiCourseMeal announceMenu() throws OutOfStockException {
Waiter elmo = new Waiter("Elmo");
Course starter = elmo.announceCourse(CourseType.STARTER);
Course main = elmo.announceCourse(CourseType.MAIN);
Course dessert = elmo.announceCourse(CourseType.DESSERT);
return new MultiCourseMeal(starter, main, dessert);
}
}
여기서는 이전에 있던 문제들이 전혀 없습니다. 웨이터 Elmo는 정확한 순서로 코스를 안내하고, 하위 작업 하나가 실패하면 나머지 작업은 아예 시작조차 되지 않습니다. 그리고 모든 일이 같은 스레드에서 실행되므로 스레드 누수 위험도 없습니다.
이 예제들을 통해, 단일 스레드 코드처럼 작업과 하위 작업의 계층을 강제할 수 있다면 동시성 프로그래밍이 훨씬 쉬워지고 직관적이 될 것임이 분명해졌습니다.
구조적 동시성 접근에서는 스레드가 명확한 계층 구조를 가지며, 각자의 스코프와 명확한 진입/종료 지점을 가집니다. 구조적 동시성은 함수 호출과 유사하게 스레드를 계층적으로 배치하여 부모-자식 관계의 트리를 형성합니다. 실행 스코프는 모든 자식 스레드가 완료될 때까지 유지되며, 코드 구조와 일치합니다.
이제 예제를 구조적·동시적으로 작성한 버전을 보겠습니다:
public class StructuredConcurrencyRestaurant implements Restaurant {
@Override
public MultiCourseMeal announceMenu() throws ExecutionException, InterruptedException {
Waiter grover = new Waiter("Grover");
Waiter zoe = new Waiter("Zoe");
Waiter rosita = new Waiter("Rosita");
try (var scope = StructuredTaskScope.open()) {
Supplier<Course> starter = scope.fork(() -> grover.announceCourse(CourseType.STARTER));
Supplier<Course> main = scope.fork(() -> zoe.announceCourse(CourseType.MAIN));
Supplier<Course> dessert = scope.fork(() -> rosita.announceCourse(CourseType.DESSERT));
scope.join(); // 1
return new MultiCourseMeal(starter.get(), main.get(), dessert.get()); // 2
}
}
}
스코프의 목적은 스레드들을 함께 묶는 것입니다. 1에서 우리는 모든 스레드의 작업이 끝날 때까지 (join) 기다립니다. 스레드 중 하나가 인터럽트되면 InterruptedException이 던져집니다. 또한 생성된 스레드 중 하나에서 예외가 발생하면 여기서 RuntimeException이 던져질 수도 있습니다. 2에 도달하면 모든 것이 잘 되었음을 확신할 수 있고, 결과를 가져와 처리할 수 있습니다.
사실 이전 코드와의 가장 큰 차이는 새 scope 안에서 스레드를 생성(fork)한다는 점입니다. 이제 세 스레드의 수명이 이 스코프 안에 갇혀 있으며, 이 스코프는 try-with-resources 구문의 본문과 일치한다는 것을 확신할 수 있습니다.
또한 단락(short-circuiting) 동작 을 얻게 됩니다. announceCourse(..) 하위 작업 중 하나가 실패하면, 아직 완료되지 않은 다른 작업들은 취소됩니다. 그리고 취소 전파(cancellation propagation) 도 얻게 됩니다. announceMenu()를 실행하는 스레드가 scope.join() 호출 전 또는 호출 중에 인터럽트되면, 스레드가 스코프를 벗어날 때 모든 하위 작업이 자동으로 취소됩니다.
스코프를 제공해 준 팩토리 메서드(StructuredTaskScope.open())는 기본적으로 실패 시 종료(shutdown-on-failure) 정책을 구현합니다. 즉, 작업 중 하나가 실패하면 스코프 안의 나머지 작업들을 취소합니다. 반대로 성공 시 종료(shutdown-on-success) 정책도 제공되는데, 이는 작업 중 하나가 성공하면 나머지 작업들을 취소합니다. 성공한 결과를 이미 얻었을 때 불필요한 작업을 피하는 데 사용할 수 있습니다.
성공 시 종료 정책은 StructuredTaskScope.open() 메서드의 오버로드 중 Joiner를 파라미터로 받는 버전을 호출해 사용할 수 있습니다. 예시는 다음과 같습니다:
record DrinkOrder(Guest guest, Drink drink) {}
public class StructuredConcurrencyBar implements Bar {
@Override
public DrinkOrder determineDrinkOrder(Guest guest) throws InterruptedException, ExecutionException {
Waiter zoe = new Waiter("Zoe");
Waiter elmo = new Waiter("Elmo");
try (var scope = StructuredTaskScope.open(Joiner.<DrinkOrder>anySuccessfulOrThrow())) {
scope.fork(() -> zoe.getDrinkOrder(guest, BEER, WINE, JUICE));
scope.fork(() -> elmo.getDrinkOrder(guest, COFFEE, TEA, COCKTAIL, DISTILLED));
return scope.join(); // 1
}
}
}
이 예제에서 웨이터는 손님 선호도와 바의 음료 공급 상황에 따라 유효한 DrinkOrder 객체를 얻는 역할을 합니다. Waiter.getDrinkOrder(Guest guest, DrinkCategory... categories) 메서드에서는 전달받은 음료 카테고리에 속한 모든 음료를 나열하기 시작합니다. 손님이 마음에 드는 것을 들으면 응답하고, 웨이터는 주문을 생성합니다. 이때 getDrinkOrder(..)는 DrinkOrder 객체를 반환하고 스코프는 종료됩니다. 즉, 완료되지 않은 하위 작업(예: Elmo가 아직 여러 종류의 차를 나열 중인 작업)은 취소됩니다. 1의 join() 메서드는 유효한 DrinkOrder 객체를 반환하거나, 하위 작업 중 하나가 실패했다면 RuntimeException을 던집니다.
지금까지 두 가지 종료 정책의 예제를 봤지만, StructuredTaskScope.Joiner 인터페이스의 정적 팩토리 메서드로 기본 제공되는 정책이 네 가지 더 있습니다. 예를 들어 Joiner.allSuccessfulOrThrow()는 모든 하위 작업이 성공적으로 완료될 때까지 스코프를 유지하고, 어떤 하위 작업이든 실패하면 스코프를 취소합니다. 그리고 Joiner.awaitAll()은 성공 여부와 관계없이 모든 하위 작업이 완료될 때까지 기다립니다. 또한 Joiner 인터페이스를 구현해 사용자 정의 종료 정책을 만드는 것도 가능합니다. 그러면 스코프가 언제 종료될지와 어떤 결과를 수집할지에 대한 완전한 제어권을 가질 수 있습니다.
Java 25에 비해 API에 몇 가지 사소한 변경이 있었습니다:
Joiner 인터페이스의 새 메서드 onTimeout()은 타임아웃이 만료될 때 구현체가 결과를 반환할 수 있게 합니다.Joiner::allSuccessfulOrThrow()는 이제 하위 작업의 스트림이 아니라 결과 리스트를 반환합니다.Joiner::anySuccessfulResultOrThrow()는 약간 더 단순한 anySuccessfulOrThrow()로 이름이 변경되었습니다.Joiner와 기본 설정을 변경하는 Function을 받던 정적 open 메서드가, 이제 Joiner와 UnaryOperator를 받습니다.이 JEP는 프리뷰 단계이므로, 기능을 사용해 보려면 커맨드라인에 --enable-preview 플래그를 추가해야 합니다.
더 알아보고 싶다면 이 기능의 현재 상태에 대한 상세 내용을 JEP 525에서 확인할 수 있습니다.
불변(immutable) 객체는 단 하나의 상태만 가질 수 있고 여러 스레드에서 자유롭게 공유할 수 있기 때문에, 가변(mutable) 객체보다 훨씬 덜 복잡한 개념입니다. 현재 Java에서 불변성을 달성하기 위한 주요 도구는 final 필드입니다. 하지만 final 필드는 두 가지 단점이 있어, 현실 세계의 많은 애플리케이션에서 잠재력을 제한합니다:
final 필드의 초기화 순서는 필드가 선언된 텍스트 순서로 결정되며, 이를 바꿀 수 없습니다.기타 매장 도메인의 아래 코드 예제에서 불변성 사용을 고려해 봅시다:
class OrderController {
private final Logger logger = Logger.create(OrderController.class);
void submitOrder(User user, List<Guitar> guitar) {
logger.info("Ordering new guitars...");
// ...
logger.info("New guitars have been ordered, let's get to work!");
}
}
OrderController 인스턴스가 생성될 때마다 logger 필드는 즉시 초기화되며, 이는 OrderController 생성 자체를 느리게 만들 수 있습니다. 그리고 애플리케이션의 다른 곳에서도 logger 필드가 이런 식으로 즉시 초기화되고 있을지 모릅니다:
class GuitarStore {
static final OrderController ORDERS = new OrderController();
static final GuitarRepository GUITARS = new GuitarRepository();
static final ManufacturerService MANUFACTURERS = new ManufacturerService();
}
이 모든 초기화 작업 때문에 애플리케이션 시작이 느려집니다. 그리고 최악인 점은, 그 작업이 필요 없을 수도 있다는 것입니다! 사용자가 새 기타를 주문할 생각 없이 매장을 둘러보기만 한다면 OrderController는 호출되지 않을 것이고, 우리는 logger 필드를 쓸데없이 초기화한 셈이 됩니다.
현재 가능한 유일한 대안은 가변성 기반 접근으로 돌아가, 복잡한 객체 초기화를 가능한 한 늦추는 것입니다:
class OrderController {
private Logger logger;
Logger getLogger() {
if (logger == null) {
logger = Logger.create(OrderController.class);
}
return logger;
}
void submitOrder(User user, List<Guitar> guitar) {
getLogger().info("Ordering new guitars...");
// ...
getLogger().info("New guitars have been ordered, let's get to work!");
}
}
이는 시작 시간을 개선하지만, 자체적인 단점도 있습니다:
logger 필드에 대한 모든 접근이 getLogger 메서드를 거쳐야 하는데, 이 관례를 따르지 않는 코드는 NullPointerException을 만날 위험이 있습니다.submitOrder 메서드가 동시에 호출될 때 로거 객체가 여러 개 생성될 수 있습니다.logger 필드에 대한 상수 폴딩(constant-folding) 최적화가 더 이상 불가능합니다. JVM이 초기 업데이트 이후 값이 바뀌지 않는다고 신뢰할 수 없기 때문입니다.우리가 원하는 것은 양쪽 세계의 장점을 모두 가진 해결책입니다:
즉, 우리는 지연된 불변성(defer immutability) 을 원하며, Java 런타임에서 이를 1급(first-class)으로 지원하길 원합니다.
JEP 526은 lazy constants 라는 형태로 그 1급 지원을 도입합니다. lazy constant는 단일 데이터 값을 담는 LazyConstant 타입의 객체입니다. 내용이 처음으로 조회되기 전에 언젠가 초기화되어야 하며, 이후에는 불변입니다.
OrderController 클래스의 로거를 lazy constant로 바꿔봅시다:
class OrderController {
private final LazyConstant<Logger> logger = LazyConstant.of(() -> Logger.create(OrderController.class));
void submitOrder(User user, List<Guitar> guitar) {
logger.get().info("Ordering new guitars...");
// ...
logger.get().info("New guitars have been ordered, let's get to work!");
}
}
초기에는 lazy constant가 초기화되지 않은 상태입니다. get() 메서드로 처음 접근할 때 of() 팩토리 메서드에 전달했던 람다 표현식을 호출하여 초기화합니다. 이미 초기화되어 있다면 get 메서드는 내용을 그대로 반환합니다. 따라서 get 메서드는 제공된 람다 표현식이 오직 한 번만 평가되도록 보장합니다(동시 호출 상황에서도 마찬가지입니다).
lazy constant의 성질을 보면, 이는 final 필드와 non-final 필드 사이의 공백을 메워줍니다:
| Update count | Update location | Constant folding? | Concurrent updates? | |
|---|---|---|---|---|
final field | 1 | Constructor or static initializer | Yes | No |
LazyConstant | [0, 1] | Computing function | Yes, after update | Yes, by winner |
non-final field | [0, ∞] | Anywhere | No | Yes |
lazy constant는 로거에만 국한되지 않습니다. OrderController 컴포넌트 자체와 관련 컴포넌트도 lazy constant로 저장할 수 있습니다:
class GuitarStore {
static final LazyConstant<OrderController> ORDERS = LazyConstant.of(OrderController::new);
static final LazyConstant<GuitarRepository> GUITARS = LazyConstant.of(GuitarRepository::new);
static final LazyConstant<ManufacturerService> MANUFACTURERS = LazyConstant.of(ManufacturerService::new);
public static OrderController orders() {
return ORDERS.get();
}
public static GuitarRepository guitars() {
return GUITARS.get();
}
public static ManufacturerService manufacturers() {
return MANUFACTURERS.get();
}
}
애플리케이션 시작 시간은 더 이상 OrderController 같은 컴포넌트를 미리 초기화하지 않기 때문에 개선됩니다. 대신 각 컴포넌트는 해당 lazy constant의 get 메서드를 통해 필요할 때(on demand) 초기화됩니다. 각 컴포넌트 역시 로거 같은 하위 컴포넌트도 같은 방식으로 필요할 때 초기화합니다.
내부적으로 JVM은 final로 선언된 lazy constant의 내용을 상수로 취급하여 상수 폴딩 최적화가 가능하도록 합니다.
여러 개의 lazy constant를 추적하고 싶다면(예: 객체 풀을 유지하는 경우) lazy list 를 사용할 수 있습니다:
class GuitarStore {
static final int POOL_SIZE = 10;
static final List<OrderController> ORDERS = List.ofLazy(POOL_SIZE, _ -> new OrderController());
public static OrderController orders() {
long index = Thread.currentThread().threadId() % POOL_SIZE;
return ORDERS.get((int) index);
}
}
여기서 ORDERS는 더 이상 lazy constant가 아니라 lazy list이며, 각 원소는 lazy constant에 저장됩니다. 내용에 접근하려면 클라이언트는 인덱스를 전달해 ORDERS.get(...)을 호출합니다. 해당 인덱스에 대한 첫 호출은 인덱스를 무시하는 람다 함수를 실행하여 OrderController() 생성자를 호출합니다. 같은 인덱스로 ORDERS.get(...)을 다시 호출하면 원소의 내용이 즉시 반환됩니다.
또는, 키를 생성 시점에 알고 있고 값은 lazy constant에 저장되며, 생성 시점에 제공된 계산 함수에 의해 필요할 때 초기화되는 lazy map 으로도 이 문제를 해결할 수 있습니다:
class GuitarStore {
static final Map<String, OrderController> ORDERS = Map.ofLazy(Set.of("Customers", "Internal", "Testing"), _ -> new OrderController());
public static OrderController orders() {
return ORDERS.get(Thread.currentThread().getName());
}
}
이 예제에서 OrderController 인스턴스는 스레드 식별자에서 계산한 정수 인덱스가 아니라 스레드 이름(이 경우 “Customers”, “Internal”, “Testing”)에 연관됩니다. lazy map은 lazy list보다 더 표현력 있는 접근 관용구를 제공하지만, 그 외에는 동일한 이점을 가집니다.
이전에는 ‘stable values’로 알려졌던 기능이, 의도된 상위 수준의 사용 사례를 더 잘 담기 위해 ‘lazy constants’로 이름이 변경되었습니다.
그 밖의 변경도 비슷한 목적을 갖습니다. 예를 들어:
orElseSet, setOrThrow, trySet을 제거하고, 값 계산 함수를 받는 팩토리 메서드만 남겼습니다.StableValue.list)와 map(StableValue.map)의 팩토리 메서드를 각각 List와 Map 인터페이스로 옮겨, 발견 가능성을 높였습니다.LazyConstant.get() 메서드에 통합했습니다.function 및 intFunction 팩토리 메서드를 제거했습니다.null을 허용하지 않도록 했습니다.더 알아보고 싶다면 이 기능의 현재 상태에 대한 상세 내용을 JEP 526에서 확인할 수 있습니다.
Vector API는 런타임에 최적의 벡터 명령으로 확실하게 컴파일되는 벡터 계산을 표현할 수 있게 해줍니다. 이는 지원되는 CPU 아키텍처(x64 및 AArch64)에서 동일한 스칼라 계산에 비해 성능이 크게 향상됨을 의미합니다.
벡터 계산(vector computation) 이란 임의 길이를 가진 하나 이상의 1차원 행렬에 대한 수학적 연산입니다. 벡터는 동적 길이를 가진 배열이라고 생각하면 됩니다. 또한 배열처럼 인덱스로 상수 시간에 요소에 접근할 수 있습니다.
과거에는 Java 프로그래머가 이런 계산을 작성하려면 어셈블리 코드 수준에서만 가능했습니다. 하지만 현대 CPU가 고급 SIMD(Single Instruction, Multiple Data) 기능을 지원하면서, SIMD 명령과 병렬로 동작하는 여러 레인(lanes)이 가져오는 성능 이득을 활용하는 것이 더 중요해졌습니다. Vector API는 그 가능성을 Java 프로그래머에게 더 가까이 가져다줍니다.
아래는 (JEP에서 가져온) 배열 요소들에 대한 간단한 스칼라 계산과, Vector API를 사용한 동등한 계산을 비교하는 코드 예제입니다:
void scalarComputation(float[] a, float[] b, float[] c) {
for (int i = 0; i < a.length; i++) {
c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f;
}
}
static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_PREFERRED;
void vectorComputation(float[] a, float[] b, float[] c) {
int i = 0;
int upperBound = SPECIES.loopBound(a.length);
for (; i < upperBound; i += SPECIES.length()) {
// FloatVector va, vb, vc;
var va = FloatVector.fromArray(SPECIES, a, i);
var vb = FloatVector.fromArray(SPECIES, b, i);
var vc = va.mul(va)
.add(vb.mul(vb))
.neg();
vc.intoArray(c, i);
}
for (; i < a.length; i++) {
c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f;
}
}
Java 개발자 관점에서 이는 스칼라 계산을 표현하는 또 다른 방식일 뿐입니다. 다소 장황하게 보일 수는 있지만, 반대로 엄청난 성능 향상을 가져올 수 있습니다.
Vector API는 Java에서 매우 빠르게 동작하는 복잡한 벡터 알고리즘(예: 벡터화된 hashCode 구현이나 특수화된 배열 비교)을 작성하는 방법을 제공합니다. 머신 러닝, 선형대수, 암호화, 텍스트 처리, 금융, 그리고 JDK 내부 코드 등 수많은 도메인이 이로부터 이득을 볼 수 있습니다.
Java 25의 열 번째 인큐베이터 버전에 비해 변경되거나 추가된 사항은 없습니다.
Vector API는 Project Valhalla의 필요한 기능들이 프리뷰 기능으로 제공될 때까지 인큐베이션 상태를 유지할 것입니다. 그 시점이 오면 Vector API는 이를 사용하도록 조정되고, 인큐베이션에서 프리뷰로 승격될 것입니다.
이 기능에 대한 자세한 내용은 JEP 529를 참고하세요.
Java 23부터 패턴 매칭은 모든 패턴 컨텍스트, 그리고 instanceof 및 switch 구문에서 기본 타입을 지원합니다. 이 기능은 세 번 연속 프리뷰 상태였고, Java 26에서 네 번째로 프리뷰됩니다. 먼저 Java 22와의 차이점을 살펴본 뒤, 네 번째 프리뷰에서의 변경을 강조하겠습니다.
Java 22의 switch를 위한 패턴 매칭 버전은 기본 타입을 지정하는 타입 패턴을 지원하지 않았습니다. Java 23에서는 switch에서 기본 타입 패턴 지원이 추가되어 아래 코드 예제가:
switch (reverb.roomSize()) {
case 1 -> "Toilet";
case 2 -> "Bedroom";
case 30 -> "Classroom";
default -> "Unsupported value: " + reverb.roomSize();
}
…다음과 같이 작성될 수 있게 되었습니다:
switch (reverb.roomSize()) {
case 1 -> "Toilet";
case 2 -> "Bedroom";
case 30 -> "Classroom";
case int i -> "Unsupported int value: " + i;
}
또한 가드(guard)가 매치된 값을 검사할 수 있도록 해줍니다:
switch (reverb.roomSize()) {
case 1 -> "Toilet";
case 2 -> "Bedroom";
case 30 -> "Classroom";
case int i when i > 100 && i < 1000 -> "Cinema";
case int i when i > 5000 -> "Stadium";
case int i -> "Unsupported int value: " + i;
}
레코드 패턴은 기본 타입에 대해 제한적인 지원을 갖고 있었습니다. 레코드 패턴은 레코드를 개별 컴포넌트로 분해하지만, 그 중 하나가 기본 타입이라면 레코드 패턴은 타입을 정확히 지정해야 합니다. 이를 설명하기 위해 아래 예제를 보겠습니다:
record Tuner(double pitchInHz) implements Effect {}
var tuner = new Tuner(440); // int argument is widened to double
// Attempt 1: record pattern match on int argument
if (tuner instanceof Tuner(int p)) {} // doesn't compile!
// Attempt 2: record pattern match on double argument
if (tuner instanceof Tuner(double p)) {
int pitch = p; // doesn't compile! needs a cast to int
}
// Attempt 3: record pattern match on double argument, cast to int
if (tuner instanceof Tuner(double p)) {
int pitch = (int) p;
}
다르게 말하면, Java 컴파일러는 제공된 int를 double로 확장(widen)하지만, 다시 int로 축소(narrow)해 주지는 않습니다. 이 제한은 축소 변환이 데이터 손실로 이어질 수 있기 때문에 존재합니다. 런타임의 double 값이 int의 범위를 넘거나, int가 수용할 수 있는 정밀도보다 더 높은 정밀도를 가질 수 있기 때문입니다. 하지만 패턴 매칭의 중요한 장점 중 하나는, 매치가 되지 않도록 함으로써 유효하지 않은 값을 자동으로 거부할 수 있다는 점입니다. Tuner의 double 컴포넌트가 int로 안전하게 되돌리기에는 너무 크거나 너무 정밀하다면, instanceof Tuner(int p)는 단순히 false를 반환하여 프로그램이 다른 분기에서 큰 double 컴포넌트를 처리할 수 있게 해줍니다.
이는 참조 타입 패턴에서 현재 패턴 매칭이 동작하는 방식과 유사합니다. 예를 들어:
record SingleEffect(Effect effect) {}
var singleEffect = new SingleEffect(...);
if (singleEffect instanceof SingleEffect(Delay d)) {
// ...
} else if (singleEffect instanceof SingleEffect(Reverb r)) {
// ...
} else {
// ...
}
여기서 instanceof는 SingleEffect가 Delay 또는 Reverb 컴포넌트를 갖는지 매칭을 시도할 수 있으며, 패턴이 매치되면 자동으로 축소(narrow)됩니다.
요약하자면, 이 JEP는 기본 타입 패턴이 참조 타입 패턴만큼 매끄럽게 동작하도록 하여, 대응하는 레코드 컴포넌트가 int가 아닌 다른 수치형 기본 타입이라도 Tuner(int p)를 허용하는 것을 제안합니다.
Java 22 버전의 instanceof를 위한 패턴 매칭은 기본 타입 패턴을 지원하지 않았지만, 이 기능은 instanceof의 목적(값이 주어진 타입으로 안전하게 변환될 수 있는지 테스트하는 것)과 완벽히 맞아떨어집니다. 기본 타입을 안전하게 변환하기 위해 Java 개발자들은 정보 손실을 막기 위한 손실 캐스트(losssy casts)와 범위 검사(range checks)를 다뤄야 했습니다:
int roomSize = reverb.roomSize();
if (roomSize >= -128 && roomSize < 127) {
byte r = (byte) roomSize;
// now it's safe to use r
}
이 JEP는 이러한 구문을 기본 타입에 대해 동작하는 간단한 instanceof 검사로 대체할 수 있는 가능성을 제안합니다. 해당 예제를 이 기능을 사용하도록 다시 써보면:
int roomSize = reverb.roomSize();
if (roomSize instanceof byte r) {
// now it's safe to use r
}
패턴 roomSize instanceof byte r는 roomSize가 byte에 들어갈 때만 매치되므로, 캐스트와 범위 검사가 필요 없어집니다.
instanceof 키워드는 원래 참조 타입만 받을 수 있었고, Java 16부터는 타입 패턴도 받을 수 있게 되었습니다. 하지만 instanceof가 기본 타입도 받을 수 있다면 더 자연스러울 것입니다. 이 경우 instanceof는 변환이 안전한지 확인하지만, 실제로 변환을 수행하지는 않습니다:
if (roomSize instanceof byte) { // check if value of roomSize fits in a byte
... (byte) roomSize ... // yes, it fits! but cast is required
}
이 JEP는 이 구문을 지원할 것을 제안하며, 이는 instanceof 검사에서 타입 패턴을 사용하도록 바꾸거나 그 반대로 바꾸는 일을 더 쉽게 합니다.
Java 22 버전의 switch 문/표현식은 byte, short, char, int 값을 지원했습니다. 이 JEP는 나머지 기본 타입들인 boolean, float, double, long에 대한 지원도 추가할 것을 제안합니다. boolean 값에 대한 switch는 삼항 연산자(?:)의 좋은 대안이 될 수 있는데, 분기에서 표현식뿐 아니라 문장(statements)도 담을 수 있기 때문입니다.
String guitaristResponse = switch (guitar.isInTune()) {
case true -> "Ready to play a song.";
case false -> {
log.warn("Guitar is out of tune!");
yield "Let's take five!";
}
}
Java 25에 비해 사소한 변경이 하나 있었습니다. switch 구문에서 지배성(dominance) 검사가 더 엄격해졌습니다. 어떤 패턴이 다른 패턴이 매치하는 모든 값을 매치한다면, 그 패턴은 다른 패턴을 지배(dominate) 한다고 합니다. 지배성의 정의는 원래 참조 타입에만 적용되었는데, 이 JEP는 그 정의를 확장하여 기본 타입도 포함합니다. 그 결과 예를 들어 타입 패턴 long q는 이제 타입 패턴 int i를 지배하는 것으로 간주됩니다.
이 JEP는 프리뷰 단계이므로, 기능을 사용해 보려면 커맨드라인에 --enable-preview 플래그를 추가해야 합니다.
이 기능에 대한 자세한 내용은 JEP 530를 참고하세요.
Java 26은 몇몇 오래된 기능도 사용 중단 처리합니다. 안정성과 명확성을 개선하기 위한 이 노력에 어떤 것들이 포함되었는지 살펴봅시다.
Java의 final 필드는 불변 상태를 나타냅니다. 생성자나 클래스 초기화 블록에서 한 번 할당되면 final 필드는 다시 할당할 수 없습니다. 이 동작은 정합성(correctness)을 추론하는 데 중요하고, 성능 측면에서도 중요합니다. 클래스의 동작에 대한 제약이 많을수록 JVM은 더 많은 최적화(예: 상수 폴딩)를 적용할 수 있습니다. 또한 final 필드에서 기대할 수 있는 불변성은 멀티스레드 코드에서의 객체 안전 초기화(safe initialization)에서도 중요한 역할을 합니다.
불행히도 final 필드는 재할당될 수 없다는 기대는 사실이 아닙니다. 여러 API가 프로그램의 어떤 코드에서든 언제든 final 필드를 재할당할 수 있게 하여, 정합성에 대한 모든 추론을 무너뜨리고 중요한 최적화를 무효화합니다. 그중 가장 악명 높은 것이 Field.setAccessible과 Field.set 메서드를 통한 심층 리플렉션 API(deep reflection API) 입니다. 이 메서드들은 예를 들어 다음처럼 final 필드를 마음대로 변경할 수 있게 합니다:
// A normal class with a final field
static class Guitar {
final int numberOfStrings;
Guitar() {
numberOfStrings = 6;
}
}
void main() throws ReflectiveOperationException {
// 1. Perform deep reflection over the final field in Guitar
java.lang.reflect.Field numberOfStrings = Guitar.class.getDeclaredField("numberOfStrings");
numberOfStrings.setAccessible(true); // Make Guitar's final field mutable
// 2. Create an instance of Guitar
Guitar guitar = new Guitar();
IO.println(guitar.numberOfStrings); // Prints 6
// 3. Mutate the final field in the object
numberOfStrings.set(guitar, 12);
IO.println(guitar.numberOfStrings); // Prints 12
numberOfStrings.set(guitar, 4);
IO.println(guitar.numberOfStrings); // Prints 4
}
이 예제는 실제로 final 필드가 non-final 필드만큼 가변적일 수 있음을 보여줍니다.
그렇다면 왜 처음부터 이런 가능성이 추가되었을까요? 답은 (짐작하셨겠지만!) 직렬화(serialization) 와 관련이 있습니다. 직렬화 라이브러리는 역직렬화 중 객체를 초기화할 때 final 필드를 변경할 수 있어야 합니다. 문제는, 올바른 이유로 final 필드를 변경하는 코드는 상대적으로 적지만, 그런 API가 존재한다는 사실만으로도 어떤 final 필드의 값도 신뢰할 수 없게 된다는 점입니다. 돌이켜 보면 이 기능을 제공한 것은 무결성(integrity)을 희생하는 좋지 않은 선택이었습니다.
최근에 추가된 hidden classes나 records 같은 기능은 final 필드 변경을 허용하지 않으며, 이제 이 동작을 일반 클래스에도 확장할 때가 되었습니다.
JEP 500은 심층 리플렉션을 사용해 final 필드를 변경하려고 할 때 경고를 발생시키는 것을 제안합니다. 이러한 경고는 final 필드 변경을 제한함으로써 기본적으로 무결성을 보장하는(즉, Java 프로그램을 더 안전하고 잠재적으로 더 빠르게 만드는) 미래 릴리스를 대비해 개발자들을 준비시킬 것입니다. 이 규칙에 대한 예외는 하나 남습니다. 바로 역직렬화 중 final 필드를 변경해야 하는 직렬화 라이브러리이며, 이를 위해 제한된 목적의 API가 지원될 것입니다.
이러한 final 필드 제한 의 효과는 시간이 지나며 강화될 것입니다. 경고를 내는 대신, 미래의 JDK 릴리스에서는 기본적으로 Java 코드가 심층 리플렉션을 사용해 final 필드를 변경하려 할 때 예외를 던지게 될 것입니다.
애플리케이션 개발자는 커맨드라인에서 final 필드 변경을 명시적으로 허용(opt-in)함으로써 이러한 경고와 예외를 피할 수 있습니다. 이를 위해 --enable-final-field-mutation 커맨드라인 옵션을 지정하고, 모듈 이름 목록을 쉼표로 구분해 전달합니다:
$ java --enable-final-field-mutation=module1,module2
환경 변수 설정, JAR의 매니페스트에 추가, jlink로 커스텀 런타임을 구성하는 등의 추가 기법도 가능합니다. 자세한 내용은 JEP 500을 참고하세요.
JDK 26에서는 final 필드에 대한 Field::set 규칙이 바뀝니다. 필드는 다음 조건을 모두 만족할 때만 변경됩니다:
f.setAccessible(true)가 이미 성공했으며,마지막 두 조건이 새롭게 추가된 조건입니다. 따라서:
어떤 모듈에 final 필드 변경이 활성화되어 있지 않다면, 심층 리플렉션을 통해 final 필드를 변경하려는 시도는 IllegalAccessException을 던집니다(JVM이 --illegal-final-field-mutation으로 시작된 경우는 예외). f.setAccessible(true)는 성공할 수 있지만, f.set(...)은 불법입니다.
final 필드 변경이 활성화되어 있어도, 필드의 패키지가 그 모듈에 open 되어 있지 않다면 동일한 예외가 던져집니다. 이는 예를 들어 open 패키지를 가진 모듈 A가 f.setAccessible(true)를 호출한 뒤 그 Field를 모듈 B에 전달하는 경우 발생할 수 있습니다. 모듈 B는 변경이 활성화되어 있지만 패키지에 접근할 수 없다면, 모듈 B의 f.set(...)은 불법입니다.
미래 JDK 릴리스에서 final 필드 제한이 더 강화되면, 직렬화 라이브러리는 심층 리플렉션에 자동으로 의존할 수 없게 됩니다. 사용자가 커맨드라인 플래그로 final 필드 변경을 켜도록 요구하는 대신, 라이브러리 유지보수자는 이를 위해 의도된 sun.reflect.ReflectionFactory API를 사용해야 합니다. 이 API는 직렬화 라이브러리가 JDK가 생성한 특수 코드에 대한 메서드 핸들을 얻을 수 있게 하며, 그 코드는 final로 선언된 인스턴스 필드라도 직접 써서 객체를 초기화할 수 있습니다. 이렇게 생성된 코드는 JDK 내장 직렬화 메커니즘과 동일한 능력을 제공하여, 라이브러리 모듈에 대해 final 필드 변경을 활성화할 필요를 없앱니다.
sun.reflect.ReflectionFactory는java.io.Serializable을 구현한 클래스를 역직렬화할 때만 동작한다는 점에 유의하세요.
일부 의존성 주입(dependency injection), 테스트, 모킹(mocking) 라이브러리는 심층 리플렉션을 사용해 객체를 조작하며, final 필드까지 바꾸기도 합니다. 해당 유지보수자는 커맨드라인 스위치로 final 필드 변경을 활성화하는 것을 오직 폴백으로만 취급해야 합니다. 가능하다면 final 또는 private 필드를 변경할 필요가 없는 설계를 채택해야 합니다. 예를 들어 대부분의 DI 프레임워크는 이미 final 필드 주입을 금지하며, 대신 생성자 주입을 권장합니다.
이 기능에 대한 자세한 내용은 JEP 500을 참고하세요.
Java 플랫폼이 1990년대 후반과 2000년대 초반에 유명해졌을 때, 주요 촉매 중 하나는 Java 애플릿과 Applet API였습니다. Java 애플릿은 웹 페이지에 삽입되어 웹 브라우저에서 실행될 수 있는 작은 Java 프로그램으로, 개발자가 대화형 웹 애플리케이션을 만들 수 있게 했습니다. 게임, 애니메이션, 기타 대화형 콘텐츠 등에서 널리 쓰였습니다. Java 프로그래머가 아니었던 사람들조차 브라우저에서 애플릿 때문에 ‘Java’라는 이름을 알고 있었습니다! (요즘 아이들이 Minecraft라는 게임 때문에 Java의 존재를 알게 되는 것과 비슷한 방식으로 말이죠.)

하지만 시간이 흐르면서 보안 문제와 JavaScript, HTML5 같은 대체 기술의 부상으로 인해 Java 애플릿은 인기가 줄어들었습니다. 그 결과 많은 브라우저 벤더가 애플릿 지원을 제거했습니다. 이런 이유로 Applet API는 Java 9에서 사용 중단(deprecated)되었고, Java 17에서 제거 예정(deprecated for removal)으로 표시되었으며, 이러한 흐름의 연장선에서 Java 26에서 완전히 제거됩니다. 게다가 신뢰할 수 없는 코드를 샌드박싱하여 애플릿을 실행하는 데 필요한 기반이었던 Security Manager가 Java 24에서 영구적으로 비활성화되면서, Applet API를 마침내 종료(sunset)해야 할 또 하나의 이유가 생겼습니다.
다음 요소들이 제거됩니다:
다음으로 구성된 java.applet 패키지 전체:
java.applet.Appletjava.applet.AppletContextjava.applet.AppletStubjava.applet.AudioClip다음 추가 클래스들:
java.beans.AppletInitializerjavax.swing.JApplet위 클래스 및 인터페이스를 참조하는 남아 있는 API 요소 전부(다음을 포함한 메서드 및 필드):
java.beans.Beansjavax.swing.RepaintManager현재 형태의 Applet API는 대부분 사용 불가능에 가깝기 때문에, API를 제거해도 사용자 애플리케이션에 실질적인 위험은 없습니다. Applet API를 여전히 사용하는 애플리케이션은 오래된 릴리스에 남거나, AWT API나 오디오 재생을 위한 javax.sound.SoundClip 클래스 같은 다른 API로 마이그레이션할 것입니다.
이 제거에 대한 자세한 내용은 JEP 504를 참고하세요.
이로써 Java 26에 포함된 10개의 JEP에 대한 논의를 마칩니다. 하지만 새로워진 것은 이것만이 아닙니다. 이번 릴리스에는 다양한 성능, 안정성, 보안 업데이트를 포함해 더 많은 업데이트가 포함되었습니다. 한 가지 확실한 점은, 이번 Java 버전은 올해 후반에 더 많은 추가를 맞이할 준비가 되어 있다는 것입니다. 그러니 무엇을 기다리고 계신가요? 이제 이 따끈따끈한 새 Java 릴리스를 직접 사용해 볼 시간입니다!