들어가며
2023년 9월에 출시된 Java 21은 언어의 장기 지원(LTS) 버전으로, 여러 중요한 문법적 개선과 새로운 기능을 정식 도입했습니다. 이 중 일부는 미리보기(Preview) 단계를 거쳐 완전히 표준화된 기능들입니다. 본 글에서는 Java 21에서 영구적으로 도입된 핵심 기능들을 중심으로 상세히 다룹니다.
레코드 패턴 (Record Patterns) [JEP 440]
레코드 패턴을 활용하면 레코드(Record) 타입의 데이터를 훨씬 더 직관적이고 간편하게 분해(Destructuring)할 수 있습니다. 레코드는 불변 데이터를 캡슐화하는 데 주로 사용되며, 내부의 각 필드는 컴포넌트(Component)라고 부릅니다.
다음과 같이 Employee와 Department 레코드가 정의되어 있다고 가정해 봅시다.
record Department(String deptName, String location, String code) {}
record Employee(String empName, int yearsOfService, Department department) {}
Employee emp = new Employee("Alice", 5, new Department("Engineering", "Seoul", "ENG-01"));
Java 16에서는 패턴 매칭을 통해 인스턴스 타입을 확인한 후, 각 접근자(Accessor) 메서드를 호출하여 값을 추출해야 했습니다.
if (emp instanceof Employee e) {
String name = e.empName();
int years = e.yearsOfService();
Department dept = e.department();
System.out.println("Name: " + name + ", Years: " + years);
System.out.println("Dept: " + dept.deptName() + ", Loc: " + dept.location());
}
하지만 Java 21의 레코드 패턴을 사용하면 다음과 같이 한 번에 내부 값을 바인딩할 수 있습니다.
if (emp instanceof Employee(String n, int y, Department(String dName, String loc, String c))) {
System.out.println("Name: " + n + ", Years: " + y);
System.out.println("Dept: " + dName + ", Loc: " + loc);
}
이처럼 불필요한 지역 변수 선언 없이 패턴 내부에서 직접 변수를 정의하여 값을 추출할 수 있으며, 중첩된 레코드 패턴(Nested Record Patterns)도 완벽하게 지원합니다.
Switch 문을 위한 패턴 매칭 [JEP 441]
Java 21부터 switch 문의 case 레이블은 기존 상수뿐만 아니라 타입 패턴과 null 값까지 처리할 수 있게 되었습니다.
public static void handleEntity(Object target) {
switch (target) {
case null -> System.out.println("Target is null");
case Employee e -> System.out.println("Employee: " + e.empName());
case String s -> System.out.println("String value: " + s);
case Integer i -> System.out.println("Integer value: " + i);
default -> System.out.println("Unknown type");
}
}
이제 null 처리를 위해 별도의 조건문을 작성할 필요가 없어졌으며, 다양한 타입에 대한 분기 처리가 매우 간결해졌습니다.
중첩 레코드 패턴과 가드(Guarded) 레이블
switch 문에서도 중첩 레코드 패턴을 사용할 수 있으며, when 키워드를 활용한 가드 절(Guard clause)을 통해 추가적인 조건 검사를 수행할 수 있습니다.
public static void evaluateEmployee(Employee emp) {
switch (emp) {
case null -> System.out.println("No employee data");
case Employee(String name, int years, Department dept)
when years < 3 -> {
System.out.println(name + " is a junior member.");
}
case Employee(String name, int years, Department dept)
when name != null && name.length() > 5 -> {
System.out.println(name + " has a long name.");
}
default -> System.out.println("Standard employee profile");
}
}
가드 절은 패턴 매칭이 성공한 후, 특정 불리언 조건을 만족할 때만 해당 케이스 블록이 실행되도록 제한하는 역할을 합니다.
열거형(Enum) 상수 매칭 강화
서로 다른 열거형 타입이 섞여 들어오는 상황에서도 switch 문이 유연하게 대응합니다.
enum Status { ACTIVE, INACTIVE, PENDING }
enum Priority { HIGH, MEDIUM, LOW }
public static void checkState(Object obj) {
switch (obj) {
case Status.ACTIVE -> System.out.println("Currently active");
case Status s -> System.out.println("Other status: " + s);
case Priority p -> System.out.println("Priority level: " + p);
default -> System.out.println("Not an enum");
}
}
시퀀스 컬렉션 (Sequenced Collections) [JEP 431]
기존 Java 컬렉션 프레임워크에는 순서가 있는 컬렉션(List, TreeSet 등)이 존재했음에도 불구하고, 첫 번째나 마지막 요소에 접근하거나 역순으로 순회하는 통일된 인터페이스가 부족했습니다. JEP 431은 이를 해결하기 위해 SequencedCollection, SequencedSet, SequencedMap 인터페이스를 도입했습니다.
SequencedCollection은 다음과 같은 핵심 메서드를 제공합니다.
interface SequencedCollection<E> extends Collection<E> {
void addFirst(E e);
void addLast(E e);
E getFirst();
E getLast();
E removeFirst();
E removeLast();
SequencedCollection<E> reversed();
}
ArrayList를 활용한 예시는 다음과 같습니다.
List<String> fruits = new ArrayList<>();
fruits.add("Apple");
fruits.add("Banana");
fruits.addFirst("Mango");
fruits.addLast("Orange");
for (String fruit : fruits) {
System.out.print(fruit + " "); // Mango Apple Banana Orange
}
System.out.println();
List<String> reversedFruits = fruits.reversed();
for (String fruit : reversedFruits) {
System.out.print(fruit + " "); // Orange Banana Apple Mango
}
TreeSet과 같이 정렬 기준에 의해 요소의 위치가 결정되는 컬렉션에서는 addFirst나 addLast 호출 시 UnsupportedOperationException이 발생합니다. 하지만 reversed() 메서드를 통한 역순 뷰 생성은 정상적으로 동작합니다.
SequencedMap 역시 putFirst, putLast, reversed 등의 메서드를 제공하여 LinkedHashMap과 같은 순서 유지 맵에서 유용하게 활용할 수 있습니다.
가상 스레드 (Virtual Threads) [JEP 444]
플랫폼 스레드 vs 가상 스레드
기존의 플랫폼 스레드(Platform Thread)는 OS 스레드와 1:1로 매핑되며, 생성 및 컨텍스트 전환에 상당한 시스템 자원을 소모합니다. 반면, 가상 스레드는 JVM에 의해 관리되는 경량 스레드로, OS 스레드와 강하게 바인딩되지 않습니다. 가상 스레드가 I/O 대기 등으로 차단(Block)되면, JVM은 해당 OS 스레드를 다른 가상 스레드에 할당하여 자원을 효율적으로 사용합니다.
적용 시나리오 및 성능 비교
가상 스레드는 CPU 집약적인 작업보다는 네트워크 요청, 데이터베이스 조회 등 I/O 집약적인 동시성 작업에 최적화되어 있습니다. 다음 코드는 10만 개의 I/O 대기 작업을 플랫폼 스레드와 가상 스레드로 각각 처리할 때의 성능 차이를 보여줍니다.
import java.time.Instant;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ThreadBenchmark {
private static final int TASK_COUNT = 100_000;
private static final int TIMEOUT_SEC = 120;
public static void runPlatformThreads() throws InterruptedException {
System.out.println("Platform Threads Start");
Instant start = Instant.now();
try (ExecutorService executor = Executors.newCachedThreadPool()) {
for (int i = 0; i < TASK_COUNT; i++) {
executor.execute(() -> {
try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
}
executor.shutdown();
if (!executor.awaitTermination(TIMEOUT_SEC, TimeUnit.SECONDS)) executor.shutdownNow();
}
System.out.println("Platform Threads Duration: " + (Instant.now().toEpochMilli() - start.toEpochMilli()) + " ms");
}
public static void runVirtualThreads() throws InterruptedException {
System.out.println("Virtual Threads Start");
Instant start = Instant.now();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < TASK_COUNT; i++) {
executor.execute(() -> {
try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
}
executor.shutdown();
if (!executor.awaitTermination(TIMEOUT_SEC, TimeUnit.SECONDS)) executor.shutdownNow();
}
System.out.println("Virtual Threads Duration: " + (Instant.now().toEpochMilli() - start.toEpochMilli()) + " ms");
}
}
테스트 결과, 가상 스레드가 플랫폼 스레드 대비 압도적으로 짧은 시간 내에 모든 작업을 완료하는 것을 확인할 수 있습니다.
가상 스레드 생성 방식
Executors.newVirtualThreadPerTaskExecutor() 외에도 Thread.ofVirtual() 빌더를 사용하여 다양한 방식으로 가상 스레드를 생성할 수 있습니다.
// 1. 생성 후 수동 시작
Thread vt1 = Thread.ofVirtual().name("manual-vt").unstarted(() -> {
System.out.println("Running: " + Thread.currentThread().getName());
});
vt1.start();
vt1.join();
// 2. 생성과 동시에 시작
Thread vt2 = Thread.ofVirtual().name("auto-vt").start(() -> {
System.out.println("Running: " + Thread.currentThread().getName());
});
vt2.join();
// 3. 스레드 팩토리 활용
ThreadFactory factory = Thread.ofVirtual().factory();
Thread vt3 = factory.newThread(() -> {
System.out.println("Running: " + Thread.currentThread().getName());
});
vt3.start();
vt3.join();
스케줄링 및 활용 가이드
가상 스레드는 M:N 스케줄링 모델을 따르며, 다수의 가상 스레드가 소수의 OS 스레드(Carrier Thread) 위에서 실행됩니다. 개발자가 직접 스레드 풀을 관리하거나 스레드를 풀링(Pooling)할 필요가 없으며, HTTP 요청이나 DB 트랜잭션 처리와 같이 수명이 짧고 I/O 대기가 빈번한 작업마다 새로운 가상 스레드를 할당하는 '스레드 당 작업(Thread-per-task)' 모델을 채택하는 것이 가장 효과적입니다.
기타 주요 업데이트
- 세대별 ZGC (Generational ZGC) [JEP 439]: 가비지 컬렉터인 ZGC에 세대별 개념을 도입하여 애플리케이션의 지연 시간을 줄이고 전반적인 처리량을 크게 향상시켰습니다.
- 키 캡슐화 메커니즘 API (KEM) [JEP 452]: 공개 키 암호화를 사용하여 대칭 키를 안전하게 보호하기 위한 표준 키 캡슐화 메커니즘(Key Encapsulation Mechanism) API를 도입하여 보안 기능을 강화했습니다.