🧵 Virtual Threads 실전 활용법 — 진짜 운영 환경에서 쓰는 법
앞 글에서 던진 떡밥, 오늘 회수합니다.
"Virtual Threads가 게임 체인저"라고 했는데, 그래서 어떻게 써야 잘 쓰는 건지 코드와 함께 정리해드립니다.
Virtual Threads(가상 스레드)는 Java 21에서 정식 출시된 후 Java 24의 JEP 491로 가장 큰 약점이었던 pinning 문제까지 해결되면서, 2026년 현재 운영 환경 도입의 모든 장벽이 사라진 상태입니다.
이 글은 "개념 설명"이 아니라 "실전 코드 + 함정 회피" 중심입니다. Spring Boot 통합, HikariCP 이슈, ThreadLocal 트랩까지 모두 다룹니다.
✅ TL;DR — 5줄 요약
- Spring Boot 3.2+에서는
spring.threads.virtual.enabled=true한 줄이면 끝 - Virtual Thread는 풀링 금지 — 매 작업마다 새로 만드는 게 정석
- 동시성 제한은 풀 대신
Semaphore로 - Java 21~23에서
synchronized는 pinning 발생 → Java 24/25부터 해결됨 - CPU-bound 작업은 여전히 Platform Thread가 유리
🎯 왜 가상 스레드인가? (1분 복습)
기존 방식의 한계
전통적인 Java 스레드는 OS 스레드와 1:1 매핑됩니다. 스레드 하나당 약 1MB의 스택 메모리를 잡아먹어요. 그래서:
- 동시 요청 1만 개? → 메모리 폭발 💥
- 해결책으로 나온 게 WebFlux 같은 리액티브 프로그래밍
- 하지만 코드 스타일이 완전히 달라서 학습 비용 ↑
가상 스레드의 해법
Platform Thread (기존): 1 스레드 = 1 OS 스레드 = 1MB 메모리
Virtual Thread (신규): 수백만 개 가능, 메모리 거의 안 씀
JVM이 자체 스케줄러로 가상 스레드를 carrier(플랫폼) 스레드에 mount/unmount 하면서 관리합니다. "동기 코드처럼 짜되, 리액티브처럼 확장된다"가 핵심이에요.
🛠️ 활용법 1 — 순수 Java로 사용하기
가장 기본적인 방법
// 방법 1: Thread.ofVirtual() 빌더
Thread vt = Thread.ofVirtual()
.name("my-task")
.start(() -> {
System.out.println("가상 스레드 실행 중!");
});
vt.join();
// 방법 2: 한 줄로 시작
Thread.startVirtualThread(() -> {
System.out.println("간단 버전");
});
실무에서 쓰는 건 이 패턴 ⭐
// ExecutorService를 가상 스레드로 — 가장 많이 쓰는 패턴
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = IntStream.range(0, 10_000)
.mapToObj(i -> executor.submit(() -> {
// 각 작업이 별도의 가상 스레드에서 실행됨
return callExternalApi(i);
}))
.toList();
for (var future : futures) {
System.out.println(future.get());
}
}
// try-with-resources 종료 시 모든 작업 완료 대기
⚠️ 중요:
newVirtualThreadPerTaskExecutor()는 작업마다 새 가상 스레드를 생성합니다. 이게 정상이에요. 풀링하지 마세요!
🌱 활용법 2 — Spring Boot 3.2+에서 사용하기
단 한 줄로 활성화
# application.properties
spring.threads.virtual.enabled=true
# application.yml
spring:
threads:
virtual:
enabled: true
이 한 줄이면 Spring Boot가 알아서 다음 영역에 가상 스레드를 적용합니다.
- Tomcat/Jetty 웹 요청 처리
@Async메서드 실행@Scheduled작업- WebClient 비동기 요청
- 기타 내부 TaskExecutor
적용 확인하기
@RestController
@RequestMapping("/thread")
public class ThreadCheckController {
@GetMapping("/info")
public String getThreadInfo() {
Thread current = Thread.currentThread();
return String.format(
"Thread: %s, Virtual: %s",
current.getName(),
current.isVirtual()
);
}
}
호출 결과:
Thread: VirtualThread[#171]/runnable@ForkJoinPool-1-worker-4, Virtual: true
isVirtual()이 true로 나오면 성공!
환경 요구사항
| 항목 | 최소 사양 | 권장 |
|---|---|---|
| Java | 21 | 24 이상 (JEP 491 적용) |
| Spring Boot | 3.2 | 3.3+ |
| Spring Framework | 6.1 | 6.2+ |
⚙️ 활용법 3 — 세밀한 제어가 필요할 때
전체 적용이 아니라 특정 영역에만 가상 스레드를 쓰고 싶다면:
@Configuration
public class VirtualThreadConfig {
// Tomcat 요청 처리만 가상 스레드로
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerCustomizer() {
return protocolHandler -> protocolHandler.setExecutor(
Executors.newVirtualThreadPerTaskExecutor()
);
}
// @Async 메서드만 가상 스레드로
@Bean
public AsyncTaskExecutor asyncTaskExecutor() {
return new TaskExecutorAdapter(
Executors.newVirtualThreadPerTaskExecutor()
);
}
// 특정 비즈니스 로직 전용
@Bean
public ExecutorService emailSenderExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
조건부 활성화도 가능합니다.
@Bean
@ConditionalOnProperty(value = "app.thread-mode", havingValue = "virtual")
public AsyncTaskExecutor virtualThreadExecutor() {
return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}
⚠️ 함정 1 — Virtual Thread는 절대 풀링하지 마세요
가장 많이 하는 실수입니다.
// ❌ 잘못된 예 — 가상 스레드의 의미를 완전히 무시
ExecutorService bad = Executors.newFixedThreadPool(20,
Thread.ofVirtual().factory());
// ✅ 올바른 예 — 매 작업마다 새 가상 스레드 생성
ExecutorService good = Executors.newVirtualThreadPerTaskExecutor();
가상 스레드는 생성/소멸이 거의 무료입니다. 풀링은 플랫폼 스레드의 비싼 비용을 아끼려는 패턴이지, 가상 스레드에 적용하면 오히려 손해예요.
그럼 동시성 제한은 어떻게?
다운스트림 서비스 보호를 위해 동시 호출을 제한하고 싶다면 Semaphore를 쓰세요.
public class RateLimitedService {
// 외부 API 동시 호출을 20개로 제한
private final Semaphore semaphore = new Semaphore(20);
public String callExternalApi(String url) throws InterruptedException {
semaphore.acquire();
try {
return httpClient.send(url);
} finally {
semaphore.release();
}
}
}
// 사용 시 — 가상 스레드는 마음껏 만들어도 됨
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> service.callExternalApi("..."));
}
}
⚠️ 함정 2 — Pinning 이슈 (Java 21~23에서 발생)
Pinning이 뭔가요?
가상 스레드가 carrier 스레드에 달라붙어서 떨어지지 않는 현상입니다. 이렇게 되면 carrier가 다른 가상 스레드를 처리하지 못해 사실상 풀링 모델로 퇴화해요.
Java 21~23에서의 원인
// ❌ Java 21~23에서 pinning 발생
public synchronized void cachedFetch(String key) { // synchronized
var data = database.query(key); // 블로킹 I/O
cache.put(key, data);
}
synchronized 블록 안에서 블로킹 I/O가 일어나면 가상 스레드가 carrier에 pinning됩니다.
Java 24/25에서 해결됨 (JEP 491) 🎉
Java 24부터 JEP 491: Synchronize Virtual Threads without Pinning이 적용되어 synchronized로 인한 pinning이 거의 사라졌습니다. JVM이 모니터를 virtual thread 단위로 추적하도록 변경됐어요.
그래서 2026년 현재의 권장사항은:
- Java 24+ 사용 가능 →
synchronized그대로 사용해도 OK - Java 21~23 사용 → 핫 패스의
synchronized는ReentrantLock으로 교체 권장
// Java 21~23용 — pinning 회피 패턴
public class SafeCache {
private final ReentrantLock lock = new ReentrantLock();
public Data fetch(String key) {
lock.lock();
try {
return database.query(key); // pinning 안 일어남
} finally {
lock.unlock();
}
}
}
아직 pinning이 발생하는 케이스
JEP 491 이후에도 다음 경우엔 여전히 pinning이 발생합니다.
- 네이티브 메서드 호출 중 블로킹
- Foreign Function & Memory API 사용 중 블로킹
- 클래스 초기화 중 블로킹
JFR 이벤트 jdk.VirtualThreadPinned로 모니터링 가능합니다.
⚠️ 함정 3 — HikariCP & ThreadLocal 트랩
Spring Boot에서 가상 스레드를 켰는데 DB 연결이 자꾸 타임아웃난다면 의심할 곳이 여기입니다.
문제 상황
HikariCP는 ThreadLocal로 connection을 캐싱하는데, 가상 스레드는 매번 새로 만들어지므로 캐시 히트율이 0에 수렴합니다. 결과적으로:
HikariPool-1 - Connection is not available, request timed out after 30000ms
해결 방법
1. 커넥션 풀 사이즈 증설
spring.datasource.hikari.maximum-pool-size=50
spring.datasource.hikari.connection-timeout=30000
2. 동시 DB 호출에 Semaphore 적용
@Service
public class UserService {
private final Semaphore dbSemaphore = new Semaphore(50); // pool size에 맞춤
private final UserRepository repository;
public User findById(Long id) throws InterruptedException {
dbSemaphore.acquire();
try {
return repository.findById(id).orElseThrow();
} finally {
dbSemaphore.release();
}
}
}
3. JDBC 드라이버 최신 버전 사용
MySQL Connector/J 9.0+, PostgreSQL JDBC 최신 버전은 내부 synchronized를 ReentrantLock으로 교체해 가상 스레드 친화적으로 개선됐습니다.
<!-- pom.xml -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>9.1.0</version> <!-- 9.0+ 권장 -->
</dependency>
⚠️ 함정 4 — ThreadLocal 의존 코드 주의
ThreadLocal을 캐시처럼 쓰는 코드는 가상 스레드에서 무용지물입니다. 매 요청마다 새 스레드라 캐시 히트가 안 일어나요.
// ❌ 가상 스레드에서 의미 없음
private static final ThreadLocal<DateFormat> dateFormat =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
대안: ScopedValue (Java 21 Preview, Java 25 정식)
Java 25에서 정식화된 ScopedValue를 쓰면 가상 스레드에서도 효율적으로 컨텍스트를 전달할 수 있습니다.
// Java 25+
public class RequestContext {
public static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
public void handleRequest(String userId, Runnable task) {
ScopedValue.where(USER_ID, userId).run(task);
}
public void someMethod() {
String currentUser = USER_ID.get(); // 어디서든 접근
// ...
}
}
🚫 가상 스레드를 쓰면 안 되는 경우
만능이 아닙니다. 다음 상황에서는 여전히 Platform Thread가 유리해요.
1. CPU-bound 작업
// ❌ 이런 건 가상 스레드 의미 없음
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> {
// 행렬 곱셈, 이미지 처리, 암호화 등
return computePrime(1_000_000); // 순수 CPU 연산
});
}
// ✅ CPU-bound는 ForkJoinPool 또는 코어 수만큼의 플랫폼 스레드
ExecutorService cpuPool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);
가상 스레드의 강점은 블로킹 I/O 처리입니다. CPU 작업은 어차피 코어 수만큼만 동시 실행되니 이득이 없어요.
2. 짧고 동기적인 작업만 하는 경우
레이턴시가 1ms 이하의 매우 짧은 작업이라면 컨텍스트 스위칭 오버헤드가 더 클 수 있습니다.
3. 기존 시스템이 잘 돌아가는 경우
리액티브 코드(WebFlux)가 이미 잘 동작 중이고 성능 이슈가 없다면, 굳이 다 갈아엎을 필요는 없습니다.
📊 모니터링 & 디버깅
Pinning 감지
# Java 21~23: 시스템 프로퍼티
-Djdk.tracePinnedThreads=full
# Java 24+: JFR 이벤트로
java -XX:StartFlightRecording=filename=app.jfr,settings=profile MyApp
가상 스레드 사용 여부 확인
@Component
public class ThreadMonitor {
@EventListener(ApplicationReadyEvent.class)
public void logThreadInfo() {
log.info("Available processors: {}",
Runtime.getRuntime().availableProcessors());
log.info("Default Virtual Thread Scheduler parallelism: {}",
System.getProperty("jdk.virtualThreadScheduler.parallelism"));
}
}
Spring Boot Actuator 활용
management.endpoints.web.exposure.include=health,metrics,threaddump
/actuator/threaddump에서 가상 스레드와 플랫폼 스레드를 모두 확인할 수 있습니다.
🚀 실전 마이그레이션 체크리스트
기존 Spring Boot 프로젝트에 가상 스레드를 적용하려면 이 순서로 진행하세요.
- Java 24 이상으로 업그레이드 (또는 최소 Java 21)
- Spring Boot 3.2 이상으로 업그레이드
- JDBC 드라이버를 가상 스레드 친화 버전으로 업데이트 (MySQL 9.0+, pgjdbc 최신)
- HikariCP
maximum-pool-size재검토 (필요 시 증설) -
ThreadLocal사용 코드 점검 — 캐시 용도라면 ScopedValue 또는 다른 방식으로 교체 - 핫 패스의
synchronized블록에서 블로킹 호출 여부 점검 (Java 21~23인 경우) - 스테이징 환경에서 부하 테스트 후 프로덕션 적용
-
application.properties에spring.threads.virtual.enabled=true추가 - 프로덕션 적용 후 JFR/Actuator로 pinning 모니터링
✍️ 결론
Virtual Threads는 더 이상 "신기술 한번 써보자" 단계가 아닙니다. JEP 491로 마지막 장벽까지 사라진 2026년 현재, 다음과 같은 워크로드라면 도입을 진지하게 고민할 시점이에요.
- 외부 API를 많이 호출하는 백엔드
- 데이터베이스 I/O가 주요 병목인 애플리케이션
- 동시 접속자가 많은 웹 서비스
- 마이크로서비스 간 통신이 빈번한 시스템
리액티브 프로그래밍의 학습 비용 없이도 수만 동시 요청을 처리하는 백엔드를 만들 수 있다는 것, 이게 가상 스레드의 진짜 가치입니다.
📌 핵심 요약 (TL;DR)
🔹 활성화: Spring Boot 3.2+에서
spring.threads.virtual.enabled=true한 줄
🔹 풀링 금지:newVirtualThreadPerTaskExecutor()사용, 동시성 제한은Semaphore
🔹 Pinning: Java 24+에서 JEP 491로 거의 해결됨, Java 21~23은ReentrantLock권장
🔹 HikariCP: pool size 재조정 + 최신 JDBC 드라이버 사용
🔹 CPU-bound 작업: 여전히 플랫폼 스레드가 정답
👉 다음 글 예고
다음 편에서는 Java 25에서 정식 출시된 Structured Concurrency 활용법을 다룰게요. 가상 스레드와 함께 쓰면 동시 작업의 생명주기를 깔끔하게 관리할 수 있습니다.
도움이 되셨다면 공감 + 댓글 부탁드려요 😊