JDK 21이 특별한 이유
2023년 9월 출시된 JDK 21은 JDK 17 이후 4년 만의 LTS(Long-Term Support) 릴리스입니다. JDK 14부터 축적된 프리뷰 기능들이 한꺼번에 정식 출시되면서, 총 15개의 JEP이 포함되었습니다. Virtual Threads, Record Patterns, Pattern Matching for switch가 모두 정식으로 확정된 이번 릴리스는 JDK 8 이후 가장 큰 변화라 해도 과언이 아닙니다.
이 글에서는 JDK 21의 핵심 기능을 카테고리별로 정리합니다.
Virtual Threads (정식, JEP 444)
JVM이 관리하는 경량 스레드로, I/O 바운드 작업의 처리량을 극적으로 늘려줍니다. Executors.newVirtualThreadPerTaskExecutor()로 태스크당 하나씩 생성하고, synchronized 대신 ReentrantLock을 사용하는 것이 핵심 패턴입니다.
Virtual Threads의 상세한 사용법, 성능 비교, 주의사항(Pinning, ThreadLocal)은 Virtual Threads 완벽 가이드 포스트에서 다루고 있습니다.
Record Patterns (정식, JEP 440)
instanceof와 switch에서 레코드 타입의 필드를 바로 추출하는 구조 분해 문법입니다. JDK 19에서 첫 프리뷰, JDK 20에서 두 번째 프리뷰를 거쳐 드디어 정식 출시되었습니다.
public class RecordPatternFinal {
sealed interface JsonValue permits JsonString, JsonNumber, JsonArray {}
record JsonString(String value) implements JsonValue {}
record JsonNumber(double value) implements JsonValue {}
record JsonArray(java.util.List<JsonValue> elements) implements JsonValue {}
// Record Pattern으로 JSON 값 변환
static String toDisplay(JsonValue json) {
return switch (json) {
case JsonString(var s) -> "\"" + s + "\"";
case JsonNumber(var n) -> n % 1 == 0
? String.valueOf((long) n) // 정수면 소수점 제거
: String.valueOf(n);
case JsonArray(var elements) -> {
var items = elements.stream()
.map(RecordPatternFinal::toDisplay)
.toList();
yield items.toString();
}
};
}
public static void main(String[] args) {
// 구조 분해로 깔끔한 데이터 처리
JsonValue name = new JsonString("Java");
JsonValue version = new JsonNumber(21.0);
JsonValue tags = new JsonArray(java.util.List.of(
new JsonString("LTS"),
new JsonString("virtual-threads")
));
System.out.println(toDisplay(name)); // 출력: "Java"
System.out.println(toDisplay(version)); // 출력: 21
System.out.println(toDisplay(tags)); // 출력: ["LTS", "virtual-threads"]
}
}
sealed interface + Record Pattern + switch 조합은 JDK 21의 대표적인 패턴입니다. 모든 하위 타입을 컴파일러가 검증하므로 default 분기 없이도 안전하며, 새로운 타입을 추가하면 처리하지 않은 switch에서 컴파일 에러가 발생합니다.
Pattern Matching for switch (정식, JEP 441)
JDK 17의 instanceof 패턴 매칭에 이어, switch 문에서도 타입 패턴과 when 가드를 사용할 수 있게 되었습니다. null 처리가 switch 내부에서 가능해진 것도 주목할 점입니다.
import java.util.List;
public class SwitchPatternFinal {
// null을 포함한 switch 패턴 매칭
static String classify(Object obj) {
return switch (obj) {
case null -> "null 값";
case Integer i when i < 0 -> "음수: " + i;
case Integer i -> "양수 또는 0: " + i;
case String s when s.isBlank() -> "빈 문자열";
case String s -> "문자열: " + s;
case List<?> list when list.isEmpty() -> "빈 리스트";
case List<?> list -> "리스트 (크기: " + list.size() + ")";
default -> "기타: " + obj.getClass().getSimpleName();
};
}
public static void main(String[] args) {
// 다양한 타입 테스트
Object[] testCases = {
null, -42, 100, "", "Java 21",
List.of(), List.of("a", "b", "c")
};
for (Object obj : testCases) {
System.out.println(classify(obj));
}
// 출력:
// null 값
// 음수: -42
// 양수 또는 0: 100
// 빈 문자열
// 문자열: Java 21
// 빈 리스트
// 리스트 (크기: 3)
}
}
기존에는 switch에 null을 전달하면 NullPointerException이 발생했습니다. JDK 21부터는 case null로 명시적으로 처리할 수 있어, null 체크를 switch 외부에서 별도로 작성할 필요가 없습니다.
Sequenced Collections (JEP 431)
Java 컬렉션 프레임워크에 순서 개념을 도입한 새로운 인터페이스입니다. List는 인덱스 기반, LinkedHashSet은 삽입 순서 기반, SortedSet은 정렬 순서 기반으로 각각 “순서”가 있지만, 이들을 통합하는 인터페이스가 없었습니다.
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.SequencedCollection;
import java.util.SequencedMap;
public class SequencedCollectionExample {
public static void main(String[] args) {
// SequencedCollection: 첫 번째/마지막 요소 접근 통합
SequencedCollection<String> languages = new ArrayList<>(
List.of("Java", "Python", "Go", "Rust")
);
System.out.println("첫 번째: " + languages.getFirst()); // Java
System.out.println("마지막: " + languages.getLast()); // Rust
// addFirst/addLast로 양쪽 끝에 추가
languages.addFirst("Kotlin");
languages.addLast("Swift");
System.out.println("추가 후: " + languages);
// 출력: 추가 후: [Kotlin, Java, Python, Go, Rust, Swift]
// reversed()로 역순 뷰 생성 (새 컬렉션이 아닌 뷰)
SequencedCollection<String> reversed = languages.reversed();
System.out.println("역순: " + reversed);
// 출력: 역순: [Swift, Rust, Go, Python, Java, Kotlin]
// SequencedMap: 순서가 있는 맵
SequencedMap<String, Integer> versions = new LinkedHashMap<>();
versions.put("Java", 21);
versions.put("Python", 3);
versions.put("Go", 1);
System.out.println("첫 번째 엔트리: " + versions.firstEntry()); // Java=21
System.out.println("마지막 엔트리: " + versions.lastEntry()); // Go=1
// pollFirstEntry/pollLastEntry로 제거하면서 가져오기
var removed = versions.pollLastEntry();
System.out.println("제거된 엔트리: " + removed);
// 출력: 제거된 엔트리: Go=1
// LinkedHashSet도 SequencedCollection
var techStack = new LinkedHashSet<>(List.of("Spring", "Docker", "Kubernetes"));
System.out.println("첫 번째 기술: " + techStack.getFirst()); // Spring
}
}
SequencedCollection은 기존 컬렉션 클래스들의 상위에 추가된 인터페이스이므로, ArrayList, LinkedList, LinkedHashSet 등이 이미 이를 구현합니다. reversed()는 원본 컬렉션의 뷰(view) 를 반환하므로 새로운 컬렉션을 생성하지 않아 메모리 효율적입니다.
String Templates (Preview, JEP 430)
문자열 보간(interpolation) 기능의 프리뷰입니다. STR 프로세서를 사용하여 문자열 내에 직접 표현식을 삽입할 수 있습니다.
public class StringTemplateExample {
// 실행 시 --enable-preview 필요
public static void main(String[] args) {
String name = "Java";
int version = 21;
// STR 템플릿 프로세서 (프리뷰)
String message = STR."\{name} \{version} has been released!";
System.out.println(message);
// 출력: Java 21 has been released!
// 표현식 사용 가능
int x = 10, y = 20;
String calc = STR."\{x} + \{y} = \{x + y}";
System.out.println(calc);
// 출력: 10 + 20 = 30
// 여러 줄 템플릿
String html = STR."""
<html>
<body>
<h1>\{name} \{version}</h1>
<p>Features: \{version - 17} versions since last LTS</p>
</body>
</html>
""";
System.out.println(html);
}
}
참고로 String Templates는 이후 JDK 22에서 재검토를 거치며 API가 변경될 예정이므로, 프로덕션 코드에서는 아직 사용하지 않는 것이 좋습니다.
Unnamed Patterns and Variables (Preview, JEP 443)
사용하지 않는 변수나 패턴에 _(언더스코어)를 사용하여 의도를 명확히 표현하는 기능입니다.
import java.util.LinkedList;
import java.util.Queue;
public class UnnamedPreviewExample {
sealed interface Result permits Success, Failure {}
record Success(String data) implements Result {}
record Failure(int code, String message) implements Result {}
public static void main(String[] args) {
// 사용하지 않는 변수에 _ 사용 (프리뷰)
Queue<Result> results = new LinkedList<>();
results.add(new Success("OK"));
results.add(new Failure(404, "Not Found"));
results.add(new Success("Done"));
int successCount = 0;
for (Result r : results) {
// code 필드는 사용하지 않으므로 _로 표시
switch (r) {
case Success(var data) ->
System.out.println("성공: " + data);
case Failure(var _, var message) ->
System.out.println("실패: " + message);
}
}
// 출력:
// 성공: OK
// 실패: Not Found
// 성공: Done
}
}
성능 개선
Generational ZGC (JEP 439)
ZGC에 세대별 수집(Generational Collection) 개념이 도입되었습니다. Young Generation과 Old Generation을 분리하여 수집함으로써, 특히 수명이 짧은 객체가 많은 워크로드에서 GC 효율이 크게 향상됩니다.
활성화 방법은 다음과 같습니다.
java -XX:+UseZGC -XX:+ZGenerational -jar app.jar
기존 ZGC 대비 할당 속도가 향상되고, 힙 메모리 사용량이 감소하며, GC 일시정지 시간은 기존과 동일하게 밀리초 이하를 유지합니다.
Key Encapsulation Mechanism API (JEP 452)
공개키 암호화에서 대칭키를 안전하게 교환하기 위한 KEM(Key Encapsulation Mechanism) API가 추가되었습니다. 양자 컴퓨팅 시대를 대비한 암호화 표준으로, 향후 양자 내성 알고리즘 지원의 기반이 됩니다.
정리
JDK 17 LTS에서 JDK 21 LTS로 마이그레이션할 때 가장 큰 변화를 정리합니다.
| 카테고리 | 기능 | 실무 영향도 |
|---|---|---|
| 동시성 | Virtual Threads | 높음 — I/O 바운드 서비스 처리량 대폭 향상 |
| 언어 | Record Patterns + switch 패턴 매칭 | 높음 — 타입별 분기 코드 간결화 |
| 컬렉션 | Sequenced Collections | 중간 — 첫 번째/마지막 요소 접근 통합 |
| GC | Generational ZGC | 중간 — 대규모 힙에서 GC 효율 개선 |
| 보안 | KEM API | 낮음 — 향후 양자 내성 암호화 기반 |
| 언어 | String Templates (Preview) | 참고 — 정식 출시 전 API 변경 가능 |
JDK 17 → 21 마이그레이션 시 체크포인트는 다음과 같습니다.
synchronized→ReentrantLock: Virtual Threads 활용 시 Pinning 방지ThreadLocal→ScopedValue: Virtual Thread 환경에서 메모리 절약- 다중
if-instanceof→switch패턴 매칭: 코드 간결화 - 컬렉션 첫 번째/마지막 접근 →
getFirst()/getLast(): 통합 API 활용 - Gradle/Maven 빌드 도구: JDK 21 호환 버전으로 업그레이드
JDK 21은 향후 최소 8년간(2031년까지) 보안 업데이트가 제공되는 LTS 릴리스입니다. JDK 8이나 JDK 11에 머물러 있는 프로젝트라면, 이번 기회에 JDK 21로의 전환을 적극 검토하는 것을 권장합니다.