JDK 19 — 동시성 혁명의 서막
Java 19는 2022년 9월에 출시된 비LTS 릴리스입니다. 기능 수 자체는 많지 않지만, Java 동시성 프로그래밍의 패러다임을 바꿀 기능들이 처음 등장한 역사적 버전입니다. Virtual Threads와 Structured Concurrency가 프리뷰/인큐베이터로 도입되었고, Record Patterns과 switch 패턴 매칭도 계속 발전했습니다.
| JEP | 기능 | 상태 |
|---|---|---|
| JEP 425 | Virtual Threads | 프리뷰 |
| JEP 428 | Structured Concurrency | 인큐베이터 |
| JEP 405 | Record Patterns | 프리뷰 |
| JEP 427 | Pattern Matching for switch (Third Preview) | 프리뷰 |
| JEP 424 | Foreign Function & Memory API | 프리뷰 |
| JEP 426 | Vector API (Fourth Incubator) | 인큐베이터 |
Virtual Threads — 경량 스레드의 첫 등장 (JEP 425)
Virtual Thread는 JVM이 관리하는 경량 스레드입니다. OS 스레드와 1:1로 매핑되는 기존 Platform Thread와 달리, 수백만 개의 Virtual Thread가 소수의 OS 스레드(Carrier Thread) 위에서 실행됩니다.
식당 비유로 설명하면, Platform Thread는 “손님마다 전담 웨이터 한 명”이고, Virtual Thread는 “소수의 웨이터가 주문 받고 → 주방 대기 중에 → 다른 손님 서빙”하는 방식입니다. 주방(I/O)에서 기다리는 동안 다른 일을 할 수 있어 전체 처리량이 올라갑니다.
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
public class VirtualThreadDemo {
public static void main(String[] args) throws Exception {
// Platform Thread 풀: 최대 200개 동시 실행
Instant start1 = Instant.now();
try (var executor = Executors.newFixedThreadPool(200)) {
IntStream.range(0, 10_000).forEach(i ->
executor.submit(() -> {
Thread.sleep(Duration.ofMillis(100));
return i;
})
);
}
Duration platformTime = Duration.between(start1, Instant.now());
// Virtual Thread: 10,000개 동시 실행
Instant start2 = Instant.now();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i ->
executor.submit(() -> {
Thread.sleep(Duration.ofMillis(100));
return i;
})
);
}
Duration virtualTime = Duration.between(start2, Instant.now());
System.out.println("Platform Thread (200 풀): " + platformTime.toMillis() + "ms");
System.out.println("Virtual Thread (10,000개): " + virtualTime.toMillis() + "ms");
// Platform Thread (200 풀): 약 5000ms (200개씩 50번 배치)
// Virtual Thread (10,000개): 약 200ms (거의 동시 실행)
}
}
Virtual Thread의 핵심 특성을 정리하면 다음과 같습니다.
| 항목 | Platform Thread | Virtual Thread |
|---|---|---|
| 생성 비용 | 높음 (~1MB 스택) | 낮음 (~1KB) |
| 동시 실행 수 | 수천 개 | 수백만 개 |
| 스케줄링 | OS 커널 | JVM ForkJoinPool |
| I/O 블로킹 시 | OS 스레드 점유 | Carrier Thread 양보 |
| 풀링 | 필수 | 불필요 (태스크당 생성) |
| 적합한 작업 | CPU 집약 | I/O 바운드 |
Virtual Thread는 코드를 “더 빠르게” 실행하지 않습니다. 목적은 I/O 대기 시간에 Carrier Thread를 다른 작업에 양보하여 전체 처리량을 극적으로 높이는 것입니다.
Structured Concurrency — 동시 작업의 구조화 (JEP 428)
기존 ExecutorService는 부모-자식 관계 없이 독립적으로 태스크를 실행합니다. 하나가 실패해도 나머지가 계속 돌아가고, 취소 전파가 수동이며, 디버깅 시 스레드 덤프에서 관계를 파악하기 어렵습니다.
Structured Concurrency는 동시 작업을 하나의 논리 단위로 묶어 생명 주기를 관리합니다. 자식 태스크 하나가 실패하면 나머지를 자동으로 취소하고, 부모 스코프가 끝나면 모든 자식이 완료됩니다.
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class StructuredConcurrencyDemo {
// 사용자 정보 조회 시뮬레이션
record UserProfile(String name, String email) {}
record OrderHistory(int orderCount, int totalAmount) {}
record UserDashboard(UserProfile profile, OrderHistory orders) {}
// 사용자 프로필 조회 (500ms 소요)
static UserProfile fetchProfile(int userId) throws InterruptedException {
Thread.sleep(500);
return new UserProfile("사용자" + userId, "user" + userId + "@example.com");
}
// 주문 내역 조회 (300ms 소요)
static OrderHistory fetchOrders(int userId) throws InterruptedException {
Thread.sleep(300);
return new OrderHistory(15, 1_250_000);
}
public static void main(String[] args) throws Exception {
int userId = 42;
// 기존 방식: ExecutorService로 병렬 조회
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<UserProfile> profileFuture = executor.submit(
() -> fetchProfile(userId)
);
Future<OrderHistory> ordersFuture = executor.submit(
() -> fetchOrders(userId)
);
// 두 결과를 조합 — 하나가 실패해도 다른 하나는 계속 실행됨
UserProfile profile = profileFuture.get();
OrderHistory orders = ordersFuture.get();
UserDashboard dashboard = new UserDashboard(profile, orders);
System.out.println("이름: " + dashboard.profile().name());
System.out.println("이메일: " + dashboard.profile().email());
System.out.println("주문 수: " + dashboard.orders().orderCount());
System.out.println("총 금액: " + dashboard.orders().totalAmount() + "원");
// 이름: 사용자42
// 이메일: user42@example.com
// 주문 수: 15
// 총 금액: 1250000원
}
// Structured Concurrency 방식은 StructuredTaskScope 사용 (인큐베이터)
// 하나가 실패하면 나머지 자동 취소, 스코프 종료 시 모든 태스크 완료 보장
// JDK 19에서는 jdk.incubator.concurrent 모듈 필요
System.out.println("\nStructured Concurrency → JDK 21에서 프리뷰로 발전");
}
}
Structured Concurrency의 핵심 이점은 다음과 같습니다.
- 실패 전파: 자식 태스크 실패 시 다른 자식을 자동 취소
- 취소 전파: 부모가 취소되면 모든 자식도 취소
- 관찰 가능성: 스레드 덤프에서 부모-자식 관계 확인 가능
- 리소스 누수 방지: 스코프 종료 시 모든 자식 태스크 완료 보장
이 기능은 JDK 21에서 프리뷰, JDK 24에서 최종 프리뷰 단계까지 발전합니다.
Record Patterns — 디스트럭처링의 시작 (JEP 405)
Record Patterns는 Record의 컴포넌트를 패턴 매칭으로 분해(destructure) 하는 기능입니다. JavaScript의 구조 분해 할당과 유사한 개념입니다.
import java.util.List;
public class RecordPatternDemo {
// 좌표를 나타내는 Record
record Point(int x, int y) {}
// 선분을 나타내는 Record (두 점으로 구성)
record Line(Point start, Point end) {}
// Record Pattern으로 컴포넌트 분해
static String describePoint(Object obj) {
// JDK 19 프리뷰: Record Pattern (instanceof)
if (obj instanceof Point(int x, int y)) {
// x, y를 바로 사용 — 별도 접근자 호출 불필요
return String.format("점(%d, %d) → 원점 거리: %.2f", x, y,
Math.sqrt(x * x + y * y));
}
return "Point가 아닙니다";
}
// 중첩 Record Pattern — 깊은 분해도 가능
static double lineLength(Object obj) {
if (obj instanceof Line(Point(int x1, int y1), Point(int x2, int y2))) {
// Line → Point → x, y까지 한 번에 분해
double dx = x2 - x1;
double dy = y2 - y1;
return Math.sqrt(dx * dx + dy * dy);
}
return -1;
}
public static void main(String[] args) {
Point p1 = new Point(3, 4);
Point p2 = new Point(6, 8);
Line line = new Line(p1, p2);
System.out.println(describePoint(p1));
// 점(3, 4) → 원점 거리: 5.00
System.out.printf("선분 길이: %.2f%n", lineLength(line));
// 선분 길이: 5.00
// 리스트와 함께 활용
List<Object> shapes = List.of(
new Point(0, 0),
new Point(1, 1),
new Line(new Point(0, 0), new Point(3, 4))
);
for (Object shape : shapes) {
if (shape instanceof Point(int x, int y)) {
System.out.printf(" 점: (%d, %d)%n", x, y);
} else if (shape instanceof Line(Point s, Point e)) {
System.out.printf(" 선: (%d,%d) → (%d,%d)%n",
s.x(), s.y(), e.x(), e.y());
}
}
// 점: (0, 0)
// 점: (1, 1)
// 선: (0,0) → (3,4)
}
}
Record Patterns의 진짜 위력은 Sealed Classes + switch 패턴 매칭과 결합할 때 발휘됩니다. JDK 21에서 정식화된 후, 모든 하위 타입을 빠짐없이 처리하는 exhaustive switch가 가능해집니다.
Pattern Matching for switch — 세 번째 프리뷰 (JEP 427)
JDK 17에서 처음 등장한 switch 패턴 매칭이 세 번째 프리뷰를 맞았습니다. when 키워드(가드 패턴)가 추가되어 조건부 분기가 더 자연스러워졌습니다.
import java.time.LocalDate;
public class SwitchGuardDemo {
// Sealed 인터페이스 + Record 조합
sealed interface Event permits Meeting, Deadline, Holiday {}
record Meeting(String title, int attendees) implements Event {}
record Deadline(String project, LocalDate dueDate) implements Event {}
record Holiday(String name) implements Event {}
static String describe(Event event) {
return switch (event) {
// when 키워드로 가드 조건 추가 (JDK 19)
case Meeting m when m.attendees() > 50
-> "대규모 회의: " + m.title() + " (" + m.attendees() + "명)";
case Meeting m
-> "회의: " + m.title() + " (" + m.attendees() + "명)";
case Deadline d when d.dueDate().isBefore(LocalDate.now())
-> "마감 초과: " + d.project();
case Deadline d
-> "마감 예정: " + d.project() + " (" + d.dueDate() + ")";
case Holiday h
-> "휴일: " + h.name();
};
// Sealed 인터페이스이므로 default 불필요 — 컴파일러가 모든 케이스 확인
}
public static void main(String[] args) {
Event[] events = {
new Meeting("전사 타운홀", 200),
new Meeting("팀 스탠드업", 8),
new Deadline("API v2", LocalDate.of(2026, 3, 1)),
new Deadline("리팩토링", LocalDate.of(2026, 12, 31)),
new Holiday("추석")
};
for (Event event : events) {
System.out.println(describe(event));
}
// 대규모 회의: 전사 타운홀 (200명)
// 회의: 팀 스탠드업 (8명)
// 마감 초과: API v2
// 마감 예정: 리팩토링 (2026-12-31)
// 휴일: 추석
}
}
when 키워드가 추가됨으로써, 같은 타입에 대해 세부 조건으로 분기할 수 있게 되었습니다. 이전 프리뷰의 && 방식보다 가독성이 크게 개선되었습니다.
Foreign Function & Memory API — 프리뷰 (JEP 424)
JNI(Java Native Interface)를 대체할 안전하고 현대적인 네이티브 코드 호출 API가 프리뷰로 승격되었습니다. C 라이브러리 함수를 Java에서 직접 호출하고, 네이티브 메모리를 안전하게 관리할 수 있습니다.
기존 JNI의 문제점(보일러플레이트, 메모리 안전성 부재, 플랫폼 의존적 빌드)을 해결하며, 이후 JDK 22에서 정식화됩니다.
I/O 바운드 처리량 혁신
Virtual Threads가 가져올 변화를 수치로 살펴보면 다음과 같습니다.
| 시나리오 | Platform Thread (200 풀) | Virtual Thread |
|---|---|---|
| 동시 HTTP 요청 처리 | ~200개 | ~10,000+개 |
| DB 쿼리 대기 중 다른 요청 처리 | 불가 (스레드 점유) | 가능 (자동 양보) |
| 메모리 사용 (10,000 스레드) | ~10GB | ~20MB |
| 코드 변경량 | 기존 코드 유지 | newFixedThreadPool → newVirtualThreadPerTaskExecutor |
핵심은 기존 블로킹 코드를 그대로 사용하면서 리액티브 수준의 처리량을 얻을 수 있다는 점입니다. WebFlux나 Kotlin Coroutines 같은 비동기 프레임워크를 도입하지 않아도, 전통적인 thread-per-request 모델에서 Virtual Thread만 교체하면 됩니다.
정리
JDK 19는 기능 수는 적지만, Java 역사에서 동시성 프로그래밍의 전환점이 되는 버전입니다.
- Virtual Threads (프리뷰): 수백만 개의 경량 스레드로 I/O 바운드 처리량 극대화. JDK 21에서 정식화
- Structured Concurrency (인큐베이터): 동시 작업의 생명 주기를 구조적으로 관리. 실패/취소 자동 전파
- Record Patterns (프리뷰): Record 컴포넌트를 패턴 매칭으로 분해. 중첩 분해도 가능
- switch 패턴 매칭 (3차 프리뷰):
when가드 조건 추가로 세밀한 분기 지원 - Foreign Function & Memory API (프리뷰): JNI를 대체하는 안전한 네이티브 호출 API
JDK 19의 기능들은 대부분 프리뷰/인큐베이터 상태이므로 프로덕션에서 바로 사용하기는 이릅니다. 하지만 이 버전에서 시작된 Virtual Threads와 Structured Concurrency는 JDK 21 LTS에서 정식화되어 Java 동시성 프로그래밍의 표준이 됩니다. JDK 19는 그 혁명의 시작점입니다.