| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
- Spring
- 트랜잭션
- request
- 자바
- Spring Security
- RestControllerAdvice
- response
- aop
- aspect
- Filter
- git
- proxy pattern
- spring boot
- OOP
- 스프링
- 객체지향프로그래밍
- java
- MYSQL
- Redis
- 인터셉터
- exception
- 스프링 시큐리티
- mybatis
- 스프링부트
- network
- 관점지향프로그래밍
- SQL
- 디자인패턴
- http
- Interceptor
- Today
- Total
장쫄깃 기술블로그
[Java] Virtual Thread 정리 (JDK 21 → JDK 25) 본문

들어가며
Java 21에서 Virtual Thread가 정식으로 도입된 이후, JDK 24/25를 거치면서 실전에서 쓰기 훨씬 편한 형태로 발전했다.
이 글에서는 Virtual Thread가 왜 등장했는지, 어떻게 쓰는지, 그리고 JDK 25에서 달라진 점까지 정리한다. MySQL을 쓰는 환경에서 신경 써야 할 부분도 같이 다룬다.
Virtual Thread란
한 줄 정의: OS thread를 작업 하나가 평생 독점하지 않도록, JVM이 훨씬 가볍게 스케줄링하는 thread이다.
목적은 Java의 익숙한 thread-per-request 스타일을 유지하면서도 높은 동시성을 얻는 것이다.
왜 필요한가
기존 Platform Thread 기반 서버는 이렇게 동작했다.
- 요청 1개 = thread 1개
- I/O 대기 중에도 OS thread를 점유
- thread pool 크기에 따라 처리량이 제한됨
Virtual Thread는 blocking I/O에서 unmount되어 carrier thread(platform thread)를 반납한다. 개발자는 동기 코드처럼 작성하고, 런타임이 뒤에서 더 효율적으로 운영한다.
JDK는 Virtual Thread 스케줄러로 FIFO 모드의 work-stealing ForkJoinPool 을 사용하며, 기본 parallelism은 사용 가능한 프로세서 수이다.
JDK 21 기본 사용법
1. 가장 단순한 형태
Thread vt = Thread.startVirtualThread(() -> {
System.out.println("running in: " + Thread.currentThread());
});
vt.join();
2. 실무 기본형: task마다 Virtual Thread 하나
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
int requestId = i;
futures.add(executor.submit(() -> callExternalApi(requestId)));
}
for (Future<String> future : futures) {
System.out.println(future.get());
}
}
핵심은 newVirtualThreadPerTaskExecutor()이다. 이름 그대로 task마다 Virtual Thread를 새로 주는 발상이다. pool에서 worker를 재사용하는 기존 방식과 다르다.
OpenJDK는 Virtual Thread가 cheap and plentiful하므로 pooling하면 안 된다고 명시한다.
3. JDBC / MySQL과 함께 쓰기
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (long i = 1; i <= 1000; i++) {
long userId = i;
executor.submit(() -> {
String name = loadUserName(userId); // 동기 JDBC 코드
System.out.println(userId + " -> " + name);
});
}
}
동기 JDBC 코드를 유지하면서도 높은 동시성으로 운영할 수 있다는 점이 장점이다.
JDK 21에서 주의해야 할 점
① Virtual Thread는 "많이 만들 수 있다"는 것이지, 자원이 무한한 건 아니다
예를 들어 MySQL connection pool이 30개인데 Virtual Thread를 10만 개 만들어도 동시에 DB에 들어갈 수 있는 connection 수는 30개다. 결국 병목은 DB pool, DB 서버, 네트워크에 있다.
잘못된 접근: 동시성 제한 = fixed thread pool 30개
Virtual Thread 시대의 접근: thread는 task마다 만들고, DB pool/외부 API 슬롯 같은 희소 자원만 별도로 제한
private static final Semaphore DB_SLOTS = new Semaphore(30);
static String query(long id) {
try {
DB_SLOTS.acquire();
try {
// JDBC 쿼리
return "user-" + id;
} finally {
DB_SLOTS.release();
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
thread pool 대신 Semaphore 같은 동기화 도구가 Virtual Thread 친화적인 방식이다.
② CPU-bound 작업에는 효과가 제한적이다
Virtual Thread는 속도(speed)를 올리는 기능이 아니라 규모(scale)를 높이는 기능이다.
| 작업 유형 | Virtual Thread 효과 |
| I/O bound | 크다 |
| CPU bound | 제한적 |
OpenJDK도 "virtual threads are not faster threads"라고 설명한다.
③ synchronized pinning 문제 (JDK 21 한정)
JDK 21 기준으로 아래 두 경우에 Virtual Thread가 pin될 수 있다.
- synchronized block/method 안에 있을 때
- native method 또는 foreign function 안에 있을 때
pinning 상태에서 I/O나 blocking이 발생하면 carrier OS thread까지 같이 묶여 scalability를 해친다.
// JDK 21에서 나쁜 예
public String load() {
synchronized (lock) {
Thread.sleep(1000); // I/O 대기 중 carrier 점유
return "ok";
}
}
JDK 21에서는 이런 경우 ReentrantLock으로 교체하거나 lock 범위를 좁히는 것이 권장됐다.
④ ThreadLocal 남용 주의
ThreadLocal에 큰 객체를 넣으면 메모리 비용이 커진다.
// 조심해야 할 예
private static final ThreadLocal<byte[]> BUF =
ThreadLocal.withInitial(() -> new byte[1024 * 1024]); // 1MB
Virtual Thread가 많아지면 이런 구조는 바로 부담이 된다.
JDK 25에서 달라진 것들
① synchronized pinning이 거의 제거됨 (JEP 491)
JDK 24에 도입된 JEP 491은 JDK 25에도 포함된다. 이 JEP의 목표는 synchronized 안에서 block되더라도 Virtual Thread가 carrier를 반납할 수 있게 하는 것이다. "nearly all cases"의 pinning을 제거한다고 명시되어 있다.
| 버전 | synchronized + I/O |
| JDK 21 | carrier 점유 위험 |
| JDK 25 | 대부분의 pinning 제거됨 |
덕분에 무조건 synchronized를 ReentrantLock으로 교체하는 마이그레이션이 덜 필요해졌다. 다만 lock 범위를 좁히고, lock 안에서 장기 blocking을 피하는 것은 여전히 좋은 습관이다.
아직 남는 주의 대상:
- native method / FFM 호출 후 Java로 돌아와 block하는 경우
- class loading / class initialization 관련 일부 케이스
즉, JNI·native codec·foreign function·일부 프레임워크 초기화 지점은 여전히 관찰 대상이다.
② StructuredTaskScope + Joiner
"Virtual Thread를 많이 만드는 것" 다음 단계다. 요청 하나가 여러 하위 작업으로 fan-out 될 때, executor/future로 흩뿌리면 취소·실패·join 정책이 지저분해진다.
StructuredTaskScope는 subtask를 각자 thread에서 fork하고, scope 단위로 join하며, 실패/취소를 구조적으로 묶는다. JDK 25에서는 Joiner 기반으로 API가 정리되었다.
예제 1: 둘 다 성공해야 하는 fan-out
static String handle(long userId) throws InterruptedException {
try (var scope = StructuredTaskScope.open(Joiner.<String>allSuccessfulOrThrow())) {
var profile = scope.fork(() -> loadProfile(userId));
var orders = scope.fork(() -> loadOrders(userId));
scope.join(); // 하나라도 실패하면 예외 전파
return profile.get() + " / " + orders.get();
}
}
예제 2: 가장 먼저 성공한 결과 하나만 쓰기
static <T> T firstSuccess(List<Callable<T>> tasks) throws InterruptedException {
try (var scope = StructuredTaskScope.open(Joiner.<T>anySuccessfulResultOrThrow())) {
tasks.forEach(scope::fork);
return scope.join();
}
}
예제 3: timeout 함께 주기
try (var scope = StructuredTaskScope.open(
Joiner.<String>allSuccessfulOrThrow(),
cfg -> cfg.withTimeout(Duration.ofSeconds(2)))) {
tasks.forEach(scope::fork);
return scope.join().map(StructuredTaskScope.Subtask::get).toList();
}
preview 기능이라 --enable-preview가 필요하다.
javac --release 25 --enable-preview Main.java
java --enable-preview Main
③ ScopedValue (JDK 25 정식)
ThreadLocal의 문제를 해결하는 대안이다.
한 줄 정의: 멀리 있는 하위 호출까지 전달해야 하는 읽기 전용 컨텍스트를, ThreadLocal보다 더 안전하고 더 싸게 전달하기 위한 기능이다.
ThreadLocal의 문제점:
- remove() 누락 시 오염/누수 위험
- 값의 생명주기가 느슨함
- Virtual Thread가 많아질수록 공간 비용 증가
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
static void handle(String requestId) {
ScopedValue.where(REQUEST_ID, requestId).run(() -> {
queryDb();
callApi();
});
}
static void queryDb() {
System.out.println("requestId = " + REQUEST_ID.get());
}
블록이 끝나면 binding도 끝난다. 컨텍스트의 수명이 코드 구조에 드러나는 것이 가장 큰 장점이다.
잘 맞는 용도: requestId, tenantId, tracing context, 로그인 사용자 정보 등 읽기 전용 request context
안 맞는 용도: mutable shared state, 비즈니스 핵심 입력값
MySQL 사용 시 특히 신경 쓸 것
Connector/J 버전이 중요하다
MySQL JDBC 드라이버가 synchronized block 안에서 DB 서버를 호출하는 구조라서, JDK 21 환경에서는 carrier thread pinning이 발생할 수 있었다.
MySQL Connector/J 9.0.0 release notes에는 공식적으로 다음 내용이 적혀 있다.
- Connector/J 코드의 synchronized blocks를 ReentrantLock으로 교체
- Virtual Thread가 I/O 대기 중 carrier thread를 unmount할 수 있게 됨
- Connector/J가 virtual-thread friendly해졌다고 명시
| 환경 | 상태 |
| JDK 21 + 구버전 Connector/J | pinning 리스크 큼 |
| JDK 25 + 구버전 Connector/J | JEP 491 덕분에 많이 개선됨 |
| JDK 25 + Connector/J 9.x | JVM + driver 양쪽 개선, 가장 안전한 조합 |
MySQL Developer Guide는 현재 Connector/J 9.6을 production 권장으로 안내한다. JDK 25로 간다면 driver도 9.x 최신으로 같이 올리는 것을 권장한다.
Virtual Thread가 해결해 주지 않는 것들
Virtual Thread는 애플리케이션 thread 모델을 좋게 만드는 것이지, DB 자원 제약을 없애주지는 않는다.
여전히 신경 써야 하는 것들:
- HikariCP 등 connection pool 크기
- MySQL 서버의 max connections
- transaction hold time
- slow query / row lock / deadlock
- 대량 fan-out query로 인한 DB 부하 폭증
운영/디버깅 팁
JDK 21
-Djdk.tracePinnedThreads=full
pinned stack trace를 출력할 수 있다.
JDK 25
JEP 491 이후 jdk.tracePinnedThreads는 더 이상 필요하지 않다. 대신 JFR의 jdk.VirtualThreadPinned 이벤트로 남은 pinning 상황을 진단한다.
thread dump를 통해 scope hierarchy도 확인할 수 있다.
jcmd <pid> Thread.dump_to_file -format=json threads.json
정리하며
| 항목 | 핵심 |
| Virtual Thread pooling | 하지 말 것. task마다 새로 만들고, 동시성 제한은 Semaphore로 |
| CPU-bound | 효과 제한적. throughput 향상 목적이지 CPU 속도 향상이 아님 |
| synchronized pinning | JDK 25에서 대부분 제거. native/JNI/FFM은 여전히 주의 |
| MySQL driver | Connector/J 9.x 이상 사용 권장 |
| ThreadLocal | Virtual Thread 수가 많을수록 부담. 읽기 전용 context는 ScopedValue 검토 |
| fan-out/join | StructuredTaskScope로 훨씬 깔끔하게 구조화 가능 (preview) |
가장 현실적인 추천 조합은 JDK 25 + Connector/J 9.6 이다. request context는 ScopedValue, fan-out orchestration은 StructuredTaskScope를 검토해보자.
참조
'Programming Language > Java' 카테고리의 다른 글
| [Java] predicate, consumer, supplier, function 이해하기 (함수형 인터페이스) (4) | 2025.06.22 |
|---|---|
| [Java] printStackTrace()를 사용하면 안되는 이유 (2) | 2024.12.20 |
| [Java]객체지향 생활 체조 9가지 원칙 (feat. 소트웍스 앤솔로지) (0) | 2024.10.27 |
| [Java] static method만 있는 유틸리티 클래스에 private 생성자를 사용해야 하는 이유 (0) | 2024.08.02 |
| [Java] Record란? (0) | 2024.07.26 |
