자바 함수형 마스터 노트 시리즈 6편 (마지막). Project Loom의 결실 가상 스레드(Virtual Threads), 플랫폼 스레드와의 결정적 차이, 캐리어 스레드와 mount/unmount 동작, Record Patterns 정식, Switch 패턴 매칭 정식까지 — 자바 동시성의 패러다임 전환 + 시리즈 마무리.
이 글은 자바 함수형 마스터 노트 시리즈의 마지막 여섯 번째 편입니다. 5편(Modern Java)에서 Java 9~17의 신기능을 정리했다면, 이번엔 2023년 LTS Java 21의 핵심 변화 — Virtual Thread.
Project Loom 프로젝트가 7년간 다듬어 온 결실. 자바 동시성 처리의 패러다임 전환. 한 JVM에서 수백만 동시 처리 가능. 함께 정식이 된 Record Patterns·Switch 패턴 매칭까지 묶어 시리즈를 마무리합니다.
처음 가상 스레드가 어렵게 느껴지는 이유
처음 강의를 들을 때 가상 스레드 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, **"기존 스레드와 뭐가 다른지"**가 막연합니다. 같은 Thread 타입인데 왜 따로 부르나? 둘째, "OS 스레드·캐리어·플랫폼·가상" 4단어가 한 번에 쏟아져서. 누가 누구 위에 올라가는지 머리에 안 들어옵니다.
해결법은 한 가지예요. **"호텔 직원 비유"**로 묶는 것. 플랫폼 스레드 = 호텔 정직원(비싸고 한정), 가상 스레드 = 임시직(저렴하고 무제한), 캐리어 스레드 = 정직원이 임시직 작업을 운반. 이 그림이 잡히면 모든 동작이 따라옵니다.
왜 가상 스레드인가 — 기존 스레드의 한계
자바의 전통 스레드 = OS 스레드 1:1 매핑 (플랫폼 스레드).
스레드 1개 = OS 커널 스레드 1개
스레드 1개 = ~2MB 메모리 (스택)
스레드 1개 = OS 컨텍스트 스위칭 비용
문제 — 수만 동시 요청 처리에 한계:
1만 동시 요청 = 1만 스레드 = 20GB 메모리
컨텍스트 스위칭 폭발
해결책으로 등장한 게 비동기 프로그래밍 (CompletableFuture·Reactive). 그런데 코드가 어렵고 디버깅 지옥. "콜백 지옥".
여기서 정말 중요한 시험 함정 — 가상 스레드는 비동기 코드를 동기 스타일로 쓰게 함. 코드 쉬움 + 비동기 성능. 패러다임 전환의 핵심.
가상 스레드 — Project Loom의 결실
JVM이 관리하는 경량 스레드. OS 스레드와 분리.
플랫폼 스레드 (Platform Thread):
OS 스레드와 1:1 — 무겁고 한정 (수천 개)
가상 스레드 (Virtual Thread):
JVM 관리 — 가볍고 거의 무제한 (수백만 개)
메모리 비용 비교
| 종류 | 메모리 | 1GB로 만들 수 있는 수 |
|---|---|---|
| 플랫폼 스레드 | ~2MB | ~500개 |
| 가상 스레드 | ~수 KB | ~수십만 개 |
여기서 시험 함정이 하나 있어요. 가상 스레드는 OS 스레드 X. JVM 안에서 자체 관리. 그래서 무한히 만들 수 있음.
가상 스레드 만들기
1. 직접 생성
Thread vt = Thread.ofVirtual().start(() -> {
System.out.println("Hello from virtual thread");
});
vt.join();
// 또는
Thread vt2 = Thread.startVirtualThread(() -> { ... });
2. ExecutorService
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10000; i++) {
executor.submit(() -> {
// 각 작업마다 새 가상 스레드
Thread.sleep(1000);
return "Done";
});
}
}
여기서 정말 중요한 시험 함정 — newVirtualThreadPerTaskExecutor는 풀 X. 각 작업마다 새 가상 스레드 생성. 가상 스레드는 풀링 비효율 — 만들고 버리는 게 더 쌈.
3. ThreadFactory
ThreadFactory factory = Thread.ofVirtual().factory();
ExecutorService executor = Executors.newThreadPerTaskExecutor(factory);
Carrier Thread — 가상 스레드의 운반자
가상 스레드가 실제 실행될 때 플랫폼 스레드를 빌려 씀. 그게 캐리어 스레드.
가상 스레드 (수백만 개)
↓ mount
캐리어 스레드 (CPU 코어 수만큼, 보통 ForkJoinPool)
↓ 실행
OS 스레드
Mount / Unmount
Thread vt = Thread.ofVirtual().start(() -> {
System.out.println("Step 1");
Thread.sleep(1000); // ← 여기서 unmount!
System.out.println("Step 2"); // ← 다시 mount
});
블로킹 작업(I/O·sleep) 시 가상 스레드가 캐리어에서 떨어져 나옴 (unmount). 캐리어는 다른 가상 스레드를 받음. 작업 완료 후 가상 스레드가 다시 캐리어에 mount.
여기서 정말 중요한 시험 함정 — 가상 스레드의 핵심 = I/O 대기 시간 활용. 1만 가상 스레드가 모두 I/O 대기여도, 실제 캐리어 스레드는 거의 노는 상태. CPU 효율 극대화.
호텔 비유로 정리
- 플랫폼 스레드 = 호텔 정직원. 월급 비싸고 채용 한정 (수백 명).
- 가상 스레드 = 임시 알바. 저렴, 일이 있을 때만 부름 (수백만 명).
- 캐리어 스레드 = 정직원 중 알바 일감 처리 담당. 알바가 손님 응대하다 손님이 화장실 가면(unmount) 정직원은 다른 알바 일감 받음.
이 그림이 머리에 잡히면 모든 동작 자연스럽습니다.
가상 스레드의 동기 코드 미학
비동기 vs 가상 스레드 비교:
// 비동기 (CompletableFuture) — 콜백 지옥
CompletableFuture<String> a = httpClient.fetch("url1");
a.thenCompose(r -> httpClient.fetch("url2"))
.thenAccept(System.out::println);
// 가상 스레드 — 동기 코드 그대로
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> {
String r1 = httpClient.fetch("url1"); // 블로킹
String r2 = httpClient.fetch("url2"); // 블로킹
System.out.println(r2);
});
}
블로킹 같지만, 가상 스레드는 unmount되며 캐리어를 다른 작업에 양보. 비동기 효율 + 동기 코드 단순함.
가상 스레드 적합·부적합
적합
- HTTP 서버 (수만 동시 연결)
- 데이터베이스 풀 (대기 많음)
- 외부 API 호출 (네트워크 대기)
- 마이크로서비스 통신
부적합
- CPU 집약 작업 (계산 위주) — 캐리어 스레드만 점유, 효과 X
- synchronized 블록 — pinning (캐리어에 고정, unmount X)
- JNI 콜 — pinning
- ThreadLocal 다량 사용 — 가상 스레드는 ThreadLocal 비효율
여기서 시험 함정이 하나 있어요. synchronized → ReentrantLock 권장. synchronized 안에선 unmount 안 됨 (pinning). ReentrantLock은 OK. 가상 스레드 사용 시 락 코드 점검 필수.
Record Patterns (Java 21 정식)
Record를 패턴으로 분해.
record Point(int x, int y) {}
// Java 16 — instanceof 패턴 (필드 직접 접근)
if (obj instanceof Point p) {
System.out.println(p.x() + ", " + p.y());
}
// Java 21 — Record Patterns로 분해
if (obj instanceof Point(int x, int y)) {
System.out.println(x + ", " + y);
}
중첩 Record
record Pair(Point left, Point right) {}
if (obj instanceof Pair(Point(int x1, int y1), Point(int x2, int y2))) {
// 한 번에 분해
System.out.println(x1 + y2);
}
여기서 정말 중요한 시험 함정 — Record Patterns + Switch 패턴 = 강력한 도메인 모델링. 함수형 언어의 ADT(Algebraic Data Type) 매칭과 비슷.
Switch 패턴 매칭 (Java 21 정식)
Type Pattern
Object obj = ...;
String desc = switch (obj) {
case Integer i -> "Int: " + i;
case String s -> "Str: " + s;
case null -> "Null";
default -> "Unknown";
};
null 케이스 명시 가능 (Java 21). 이전엔 별도 if 분기 필요.
Guarded Pattern
String describe(Object obj) {
return switch (obj) {
case Integer i when i > 0 -> "Positive int";
case Integer i when i < 0 -> "Negative int";
case Integer i -> "Zero";
case String s when s.isEmpty() -> "Empty string";
case String s -> "Non-empty string";
default -> "Other";
};
}
when 절로 가드 조건. 강력한 분기.
Sealed + Switch = exhaustive
5편에서 다룬 sealed와 결합:
sealed interface Shape permits Circle, Square, Triangle {}
record Circle(double r) implements Shape {}
record Square(double s) implements Shape {}
record Triangle(double a, double b, double c) implements Shape {}
double area(Shape shape) {
return switch (shape) {
case Circle(double r) -> Math.PI * r * r;
case Square(double s) -> s * s;
case Triangle(double a, double b, double c) -> {
double p = (a + b + c) / 2;
yield Math.sqrt(p * (p-a) * (p-b) * (p-c));
}
// default 없음 — sealed로 컴파일러가 모든 케이스 안다
};
}
여기서 시험 함정이 하나 있어요. Sealed 인터페이스에 새 자식 추가 시 Switch가 컴파일 에러. 안전망 역할. 누락 케이스 컴파일 시점 발견.
Structured Concurrency (Preview, Java 21)
여러 가상 스레드를 하나의 단위로 관리.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<String> user = scope.fork(() -> fetchUser(id));
Subtask<String> orders = scope.fork(() -> fetchOrders(id));
scope.join(); // 모두 끝까지 대기
scope.throwIfFailed(); // 하나라도 실패 시 예외
String result = user.get() + orders.get();
}
장점:
- 작업 흐름 명확 (트리 구조)
- 한 작업 실패 시 나머지 자동 취소
- 디버깅·로깅 단순화
시리즈 마무리 — 6편 종합
1편부터 6편까지의 흐름:
| 편 | 주제 | 한 줄 |
|---|---|---|
| 1 | 기초 | JVM·OOP·Pass-by-Value·컬렉션 — 함수형 토대 |
| 2 | 람다 | 익명 클래스 변환·메서드 참조·effectively final |
| 3 | 함수형 인터페이스 | 12종을 4 패턴으로 묶어 정리 |
| 4 | Stream API | 파이프라인·중간/최종/Collectors/Optional/병렬 |
| 5 | Modern Java | var·Record·Sealed·텍스트 블록·Switch 표현식 |
| 6 | Java 21 | Virtual Thread·Record Patterns·Switch 패턴 |
자바 8(2014) 람다·Stream에서 시작된 함수형 변환은 Java 21(2023)에 이르러 함수형 + 데이터 + 동시성 세 축이 모두 자리 잡았습니다.
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 6편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- 플랫폼 스레드 = OS 스레드 1:1, 무거움 (~2MB)
- 가상 스레드 = JVM 관리, 가벼움 (~수 KB), 거의 무제한
- 가상 스레드 = OS 스레드 X
- Carrier Thread = 가상 스레드를 운반하는 플랫폼 스레드 (ForkJoinPool)
- Mount / Unmount = I/O 대기 시 캐리어 양보
- 가상 스레드 핵심 = I/O 대기 시간 활용
- 생성 —
Thread.ofVirtual().start()/Executors.newVirtualThreadPerTaskExecutor - 풀링 X — 만들고 버리는 게 효율적
- 적합 — HTTP·DB·외부 API·마이크로서비스
- 부적합 — CPU 집약 / synchronized (pinning) / JNI / ThreadLocal 다량
- synchronized → ReentrantLock 권장
- Record Patterns (Java 21 정식) —
obj instanceof Point(int x, int y) - 중첩 Record 한 번에 분해
- Switch 패턴 매칭 (Java 21 정식)
- Type Pattern —
case Integer i -> - null 케이스 명시 가능 (Java 21+)
- Guarded Pattern —
case X x when condition - Sealed + Switch = exhaustive (default 불필요, 누락 시 컴파일 에러)
- Structured Concurrency (Preview) — 여러 가상 스레드 단위 관리
- StructuredTaskScope — fork·join·throwIfFailed
- 한 작업 실패 시 나머지 자동 취소
- 자바 8 람다(2014) → Java 21 가상 스레드(2023) = 9년 함수형 + 동시성 진화
시리즈 다른 편 (시리즈 마지막)
- 1편 — JVM·OOP·컬렉션 기초
- 2편 — 람다 표현식
- 3편 — 함수형 인터페이스
- 4편 — Stream API
- 5편 — Modern Java (9~17)
- 6편 — Java 21 가상 스레드 (현재 글)
공식 문서: JEP 444 — Virtual Threads / JEP 440 — Record Patterns / JEP 441 — Pattern Matching for switch 에서 더 깊이.
자바 함수형 마스터 시리즈는 여기서 마무리. 1편부터 6편까지의 흐름이 머리에 남으면 자바 8 이후 9년의 진화를 한 번에 통찰할 수 있는 토대가 됩니다.