Java 가상 스레드 — 빠른 가이드
I’m ready to translate the article for you, but I’ll need the full text you’d like translated. Could you please paste the content (excluding the source line you already provided) here? Once I have it, I’ll translate it into Korean while preserving all formatting, markdown, and code blocks.
01 · 가상 스레드란 무엇인가
Project Loom 이전에는 Java에 스레드가 하나뿐이었습니다: 플랫폼 스레드 (OS 커널 스레드와 1:1 매핑).
Project Loom은 두 번째, 경량 스레드 유형으로 가상 스레드를 도입합니다.
- 플랫폼 스레드 – 운영 체제에 의해 스케줄링되며, 커널 스레드와 1:1 매핑됩니다.
- 가상 스레드 – JVM이 사용자 모드에서 스케줄링하며, M:N 방식으로 커널 스레드에 매핑됩니다.
두 종류 모두 java.lang.Thread 로 표현됩니다.
주요 특성
- 플랫폼(운영 체제) 스레드에 비해 매우 가볍습니다.
- 수백만 개의 가상 스레드를 안전하게 생성할 수 있습니다.
- 개발자가 단순한 블로킹 스타일 코드를 작성하면서도 높은 확장성을 유지할 수 있게 합니다.
사고 모델
| 가상 스레드 | 캐리어(OS) 스레드 |
|---|---|
| JVM이 관리하는 경량 스레드 (~1 KB) | 실제 운영 체제 스레드 |
| 수백만 개를 생성할 수 있음 | 작고 고정된 풀 (≈ CPU 코어 수) |
| 블로킹이 저렴하고 안전함 | 진정으로 희소한 자원 |
블로킹 비교
OS 스레드 블록
RestTemplate blocks an OS thread
- Thread is idle during I/O
- Under load → thread exhaustion
가상 스레드 블록
- JVM suspends the virtual thread
- Carrier thread is released immediately
- Scales safely under high concurrency
02 · Enable in Spring Boot
One property. No code changes.
# application.yml
server:
servlet:
threads:
virtual-threads-enabled: true
Requirements
- Java 21+ (final, not preview)
- Spring Boot 3.2+
What Changes
- 각 HTTP 요청이 새로운 가상 스레드에서 실행됩니다.
- 컨트롤러, 서비스,
RestTemplate등은 그대로 유지됩니다.
What virtual-threads-enabled: true Actually Does
Tomcat의 전체 서블릿 스레드 풀을 요청당 가상 스레드 실행기로 교체합니다. 들어오는 모든 HTTP 요청은 즉시 새로운 가상 스레드에 할당됩니다. 고정된 풀 크기가 없으며—Tomcat은 제한을 두지 않으며, JVM이 스레드를 관리합니다.
Consequences
- 전체 요청 라이프사이클(
DispatcherServlet진입부터 응답 전송까지)이 가상 스레드에서 실행됩니다. - 해당 체인 내의 모든 블로킹 호출(
RestTemplate, JDBC,Thread.sleep())은 이미 가상 스레드에서 실행되고 있기 때문에 비용이 적습니다.
The Manual Offload Approach
Tomcat의 기본 OS 스레드 풀은 accept와 dispatch를 처리한 뒤, 작업을 가상 스레드 실행기(예: CompletableFuture.supplyAsync)에 명시적으로 넘깁니다. 요청을 받아들인 OS 스레드는 즉시 해제됩니다.
Spring MVC는 반환된 CompletableFuture를 처리하는 방법을 알고 있습니다:
- 서블릿 처리를 일시 중단합니다.
- Future가 완료되면 재개합니다.
Key point: Spring MVC는 Future를 기다리는 동안 서블릿 스레드를 블로킹하지 않으며, 내부적으로 콜백을 등록하고 스레드를 즉시 해제합니다.
서비스와 클라이언트 레이어는 두 접근 방식 모두에서 변경되지 않습니다.
03 · Spring Boot에서 가상 스레드 도입 전략
Context
기존 Spring Boot 마이크로서비스:
- Spring MVC(서블릿 스택)로 들어오는 HTTP 요청을 처리합니다.
- 차단형 클라이언트(
RestTemplate, JDBC)를 사용해 여러 하위 서비스에 호출합니다.
Constraints
- 현재 구현 수정하거나 재작성할 수 없습니다.
- 코드베이스에
synchronized블록/메서드와ThreadLocal(SecurityContext, MDC, request attributes)에 대한 의존도가 높습니다. - 서비스는 하위 서비스 간 I/O‑중심 집계 작업을 수행합니다.
- 부하가 걸릴 때 스레드 차단으로 인해 확장성 문제가 발생합니다.
Goal: 기존 동작을 깨뜨리거나 미묘한 런타임 위험을 도입하지 않으면서 Java Virtual Threads를 활용해 동시성 및 확장성을 개선합니다.
Spring Boot에서는 두 가지 접근 방법을 제공합니다:
- 프로퍼티 기반 가상 스레드(
spring.threads.virtual.enabled=true). CompletableFuture를 이용한 수동 오프로드를 가상 스레드 실행기에 전달.
Option 1: Property‑Based Virtual Threads (Global Enablement)
Description
다음 설정을 활성화하면
spring.threads.virtual.enabled=true
Tomcat의 서블릿 스레드 풀을 요청당 가상 스레드 실행기로 교체합니다.
각 HTTP 요청은:
- 새로운 가상 스레드에 할당됩니다.
- 필터 → 컨트롤러 → 서비스 → 응답 전체가 해당 가상 스레드에서 실행됩니다.
- 차단 호출(
RestTemplate, JDBC,Thread.sleep)을 저비용으로 수행합니다.
Benefits
- 코드 변경이 전혀 필요 없음.
- 애플리케이션 전체에 일관된 동작 제공.
- 차단 I/O에 대한 자동 확장성 확보.
Risks and Limitations
Pinning Risk
synchronized 블록은 carrier thread를 고정(pinning)합니다. 고정은 눈에 보이지 않으며 전역적이어서, 동시 접근이 캐리어 스레드 풀을 고갈시킬 수 있습니다.
[Virtual Thread] → synchronized block ← carrier pinned → blocking I/O
동시 요청 N개가 고정 구역에 들어가면 N개의 캐리어 스레드가 필요합니다. CPU 수에 비례하는 캐리어만 존재하므로 애플리케이션이 정체될 수 있습니다.
ThreadLocal Assumptions Break
- 가상 스레드는 짧게 살아가며 요청 간 재사용되지 않습니다.
ThreadLocal데이터는 단일 요청을 넘어선 지속성을 갖지 않습니다.
스레드 재사용을 전제로 ThreadLocal에 데이터를 캐시하는 코드는 오작동할 수 있습니다.
Lack of Control
- 격리 경계가 없으므로 특정 엔드포인트나 코드 경로만 제외할 수 없습니다.
- 문제 해결을 위해서는 전역 설정을 바꾸어야 하며, 이는 전체 애플리케이션 재시작을 요구할 수 있습니다.
Option 2: Manual Offloading to a Virtual‑Thread Executor
Description
Tomcat의 OS 스레드 풀은 accept/dispatch 용도로 유지하고, 차단 작업을 가상 스레드 실행기로 명시적으로 오프로드합니다. 예:
CompletableFuture.supplyAsync(() -> {
// blocking I/O, JDBC, RestTemplate, etc.
}, Executors.newVirtualThreadPerTaskExecutor());
Spring MVC는 반환된 CompletableFuture를 이미 처리할 수 있습니다:
- 서블릿 처리를 일시 중단(suspend)합니다.
- future가 완료되면 콜백을 등록해 처리를 재개합니다.
Benefits
- 세밀한 제어 – 실제로 차단이 필요한 부분만 오프로드합니다.
synchronized나ThreadLocal에 의존하는 중요한 섹션은 OS 스레드에서 그대로 실행되어 고정 및 ThreadLocal 문제를 회피할 수 있습니다.
Drawbacks
- 차단이 발생하는 경계마다 코드 변경이 필요합니다.
- 프로퍼티 기반 접근보다 침투도가 높으며, 모든 차단 호출을 감사하고 래핑해야 합니다.
채택 경로 선택
| 기준 | 속성 기반 (전역) | 수동 오프로드 |
|---|---|---|
| 필요한 코드 변경 | 없음 | 예 (블로킹 호출을 래핑) |
| 가상 스레드에서 실행되는 코드 제어 | 없음 (전체 요청) | 선택적 |
기존 synchronized / ThreadLocal 사용에 미치는 영향 | 잠재적으로 위험 (핀 고정, ThreadLocal 상태 손실) | 핵심 구역을 OS 스레드에 유지 가능 |
| 운영 단순성 | 매우 간단 (단일 속성) | 복잡함 (executor 관리) |
| 캐리어 스레드 고갈 위험 | 높음 (전역 핀 고정) | 낮음 (오프로드된 부분만) |
추천
- 코드베이스에
synchronized블록이 최소이고ThreadLocal상태에 크게 의존하지 않는 경우, 속성 기반 접근 방식이 확장성으로 가는 가장 빠른 경로를 제공합니다. - 애플리케이션이
synchronized구역, ThreadLocal 기반 보안 컨텍스트, 또는 기타 스레드 친화성 패턴을 많이 사용하는 경우, 수동 오프로드 전략이 더 안전합니다. 이를 통해 가상 스레드 사용을 실제 블로킹 I/O 경로에만 제한할 수 있습니다.
빠른 참고
# Enable virtual threads globally (Spring Boot 3.2+)
server:
servlet:
threads:
virtual-threads-enabled: true
// Manual offload example
Executor vtExecutor = Executors.newVirtualThreadPerTaskExecutor();
CompletableFuture<String> result = CompletableFuture.supplyAsync(() -> {
// Blocking call (e.g., RestTemplate, JDBC)
return restTemplate.getForObject(url, String.class);
}, vtExecutor);
UI‑Res 리라이팅: synchronized 및 ThreadLocal‑종속 코드
옵션 2: 가상 스레드에 수동 오프로드 (선택적 채택)
설명
- Tomcat은 기본 OS‑thread 서블릿 풀을 계속 사용합니다.
- 기존 코드는 OS 스레드에서 그대로 실행됩니다.
- I/O‑집중 로직은 다음과 같이 명시적으로 오프로드합니다
CompletableFuture.supplyAsync(task, virtualThreadExecutor);
- Spring MVC는
CompletableFuture반환 타입을 기본적으로 지원하여 요청 처리를 일시 중단하고 서블릿 스레드를 해제한 뒤, Future가 완료되면 다시 재개합니다.
ThreadLocal 고려 사항
오프로드는 하드 스레드 경계를 생성합니다. 다음과 같은 컨텍스트는
SecurityContext- MDC 추적 데이터
자동으로 전파되지 않으며 수동으로 캡처하고 복원해야 합니다. 이 경계는 명시적이며 제어 가능합니다.
장점
- 기존 가정(
synchronized,ThreadLocal)을 유지합니다. - 레거시 코드에서 캐리어‑스레드 고정(pinning)을 방지합니다.
- 가상 스레드를 필요한 경우에만 선택적으로 사용할 수 있습니다.
- 점진적인 마이그레이션이 가능합니다.
- OS‑스레드와 가상‑스레드 실행 간 명확한 격리를 제공합니다.
단점
- 약간 더 많은 보일러플레이트 코드가 필요합니다.
- 명시적인 컨텍스트 전파가 요구됩니다.
- 가상 스레드 사용을 의식적으로 적용해야 합니다.
결과
| 긍정적 | 부정적 |
|---|---|
| I/O‑집중 엔드포인트에 대한 확장성 향상 | 컨텍스트 전파를 위한 추가 보일러플레이트 |
| 기존 synchronized 코드를 리팩터링할 필요 없음 | 오프로드 경계를 유지하기 위한 규율 필요 |
| 예측 가능한 런타임 동작 | |
| 명확한 마이그레이션 경로 |
최종 판단
속성 기반 가상 스레드 접근 방식은 이미 가상 스레드에 친화적인 코드베이스에만 적합합니다.
이 시스템에서는 수동 오프로드가 가장 안전하고 효과적인 전략이며, 가상 스레드의 이점을 제공하면서도 정확성과 운영 안정성을 유지합니다.
04 · 함정 (프로덕션 전 읽기)
synchronized가 캐리어 스레드를 고정시킴
문제
- 가상 스레드가 캐리어에 붙어버림.
9개의 동시 요청 +8개의 캐리어 → 교착 상태.
해결: ReentrantLock 사용
// Pins carrier
public synchronized Product fetch(String id) {
return restTemplate.getForObject("/p/{id}", Product.class, id);
}
// Safe
private final ReentrantLock lock = new ReentrantLock();
public Product fetch(String id) {
lock.lock();
try {
return restTemplate.getForObject("/p/{id}", Product.class, id);
} finally {
lock.unlock();
}
}
수동 오프로드 시 ThreadLocal 컨텍스트 손실
무엇이 깨지는가
- MDC
SecurityContextRequestAttributes
해결: 컨텍스트 캡처 및 복원
Map<String, String> mdc = MDC.getCopyOfContextMap();
SecurityContext sec = SecurityContextHolder.getContext();
return CompletableFuture.supplyAsync(() -> {
if (mdc != null) MDC.setContextMap(mdc);
SecurityContextHolder.setContext(sec);
try {
return service.doWork();
} finally {
MDC.clear();
SecurityContextHolder.clearContext();
}
}, ioExecutor);
💡 이 문제는 전역적으로 virtual-threads-enabled: true를 사용할 때 존재하지 않습니다.
풀링된 Executor 사용 시 ThreadLocal 누수
문제
- Executor를 고정 풀로 교체하면 요청 간에 ThreadLocal이 누수됩니다.
해결: 올바른 Executor 강제 적용
@Bean
public Executor ioExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
네이티브(JNI) 호출이 조용히 고정됨
예시
- 일부 JDBC 드라이버
- 암호화 라이브러리
피해를 제한
private static final ExecutorService NATIVE_POOL =
Executors.newFixedThreadPool(10);
public Future<String> callNative(String input) {
return NATIVE_POOL.submit(() -> nativeLib.process(input));
}
핀 로깅 활성화 (개발 전용)
-XX:+PrintVirtualThreadPinning
MVC와 WebFlux를 함께 사용
두 스타터가 모두 존재하면 Spring은 MVC를 선택하고 경고를 표시하지 않습니다.
규칙
- 가상 스레드 →
spring-boot-starter-web유지. starter-webflux제거.
가상 스레드에서 CPU‑집약 작업
안티패턴
- 무거운 연산
- 이미지 처리
- 암호화 루프
올바른 분리
// I/O work
CompletableFuture.supplyAsync(
() -> restTemplate.getForObject(...),
Executors.newVirtualThreadPerTaskExecutor());
// CPU work
CompletableFuture.supplyAsync(
() -> heavyComputation(data),
ForkJoinPool.commonPool());
최종 요약
Virtual Threads는 재작성할 수 없는 차단형 I/O‑집중 Spring Boot 서비스에 가장 적합한 선택입니다.
이들은 반응형 복잡성 없이 확장성, 단순성 및 프로덕션 안전성을 제공합니다.