Java Virtual Thread로 동시성 코드 바꾸기 | JDK 21 마이그레이션 가이드
이 글의 핵심
가상 스레드로 바꾸기만 하면 처리량이 오를 것 같지만, synchronized 블록에서 캐리어 스레드가 고정되거나 커넥션 풀보다 많은 요청이 몰려 DB가 먼저 버티지 못하는 일이 생깁니다. 블로킹 I/O와 CPU 바운드 워크로드에서의 차이, StructuredTaskScope와 ScopedValue 활용, ThreadLocal 누수까지 짚어 마이그레이션 판단 기준을 세웁니다.
들어가며
전통적으로 Java 서버는 요청당 스레드 모델을 쓰면 OS 스레드 한계에 부딪혔으며, 스레드 풀 + 작은 스레드 + 논블로킹으로 우회해 왔습니다. JDK 21부터 가상 스레드(Virtual Thread)는 “블로킹 호출을 쓰되, 플랫폼 스레드를 거의 먹지 않는” 방향을 열어 줍니다.
Java 가상 스레드(virtual thread)를 도입할지 판단할 때는 워크로드가 I/O 대기인지, 네이티브 블로킹이 있는지부터 봅니다.
이 글은 가상 스레드가 무엇인지, 기존 ExecutorService 풀과 무엇이 다른지, 코드를 옮길 때 어디를 점검해야 하는지를 실무 관점에서 정리합니다. 스레드 기본기는 Java 멀티스레드, I/O는 I/O 가이드와 연결됩니다.
플랫폼 스레드와 가상 스레드의 차이
플랫폼 스레드 vs 가상 스레드
| 항목 | 플랫폼 스레드 | 가상 스레드 |
|---|---|---|
| 매핑 | OS 스레드 1:1 | JVM이 M:N 스케줄링 |
| 생성 비용 | 높음 (OS 스레드 생성·스택 예약) | 낮음 (힙 객체 할당 수준) |
| 메모리 | 스레드당 스택 예약 ~1MB (기본 -Xss) | 필요한 만큼 힙에서 늘어나는 스택 (보통 수백 B~수 KB) |
| 최대 개수 | 수천 개 (OS 제한) | 수백만 개 가능 |
| 블로킹 시 | OS 스레드 점유 | 캐리어 스레드 양도 |
| CPU 바운드 | 코어 수만큼 효율적 | 이득 없음 |
| I/O 바운드 | 스레드 풀 필요 | 풀 없이도 효율적 |
동작 원리
┌─────────────────────────────────────────┐
│ 가상 스레드 (수백만 개) │
│ VT1 VT2 VT3 VT4 VT5 ... │
└────┬────┬────┬────┬────┬────────────────┘
│ │ │ │ │
└────┴────┴────┴────┘
│ │
┌────▼─────────▼────┐
│ 캐리어 스레드 풀 │
│ (플랫폼 스레드) │
│ CT1 CT2 CT3 │
└────────────────────┘
│
┌────▼────┐
│ OS 스레드│
└─────────┘
핵심 메커니즘:
- 가상 스레드가 블로킹 I/O 호출 (예:
socket.read()) - JVM이 가상 스레드를 언마운트 (캐리어 스레드에서 분리)
- 캐리어 스레드는 다른 가상 스레드 실행
- I/O 완료 시 가상 스레드를 다시 마운트
이 동작이 가능한 이유는 JDK 21에서 java.net 소켓, java.nio 채널, Thread.sleep, java.util.concurrent의 락과 큐 같은 블로킹 API들이 내부적으로 “가상 스레드라면 멈추지 말고 양보한다”는 경로를 갖도록 다시 구현되었기 때문입니다. 언마운트될 때 가상 스레드의 호출 스택은 힙으로 복사되고, 다시 마운트될 때 (다른 캐리어일 수도 있는) 스레드 위에 복원됩니다. 그래서 애플리케이션 코드는 바꿀 필요가 없지만, JDK가 모르는 방식으로 막히는 코드에서는 이 마법이 통하지 않습니다. JNI 안의 블로킹, synchronized 안의 블로킹(JDK 21~23), 그리고 대부분의 OS에서 비동기 인터페이스가 없는 파일 I/O가 그 예입니다. 파일 I/O의 경우 JVM은 가상 스레드를 캐리어에 붙잡아 둔 채 캐리어 풀에 임시 스레드를 추가하는 방식(보상)으로 버티므로 동작은 하지만, 파일 I/O가 몰리면 캐리어 스레드 수가 일시적으로 늘어납니다.
언제 사용하면 좋은가?
✅ 적합한 경우:
- 블로킹 I/O가 많은 워크로드 (HTTP 요청, DB 쿼리, 파일 I/O)
- 요청당 스레드 모델 (서블릿, 동기 REST 클라이언트)
- 동시 연결 수가 많은 서버 (수만 개 이상) ❌ 부적합한 경우:
- CPU 집약적 작업 (암호화, 이미지 처리, 복잡한 계산)
- 이미 논블로킹 모델 (Netty, Reactor, Vert.x)
- 네이티브 블로킹이 많은 경우 (JNI 호출)
Executor 교체부터 Spring Boot 설정까지
기본: newVirtualThreadPerTaskExecutor()
import java.util.concurrent.*;
import java.time.Duration;
public class BasicVirtualThreadExample {
public static void main(String[] args) throws Exception {
// 가상 스레드 실행기 생성
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// 10,000개의 작업 제출 (플랫폼 스레드로는 부담)
for (int i = 0; i < 10_000; i++) {
int id = i;
executor.submit(() -> handleRequest(id));
}
} // try-with-resources로 자동 종료 대기
System.out.println("All tasks completed");
}
static void handleRequest(int id) {
try {
// 블로킹 I/O 시뮬레이션
Thread.sleep(Duration.ofMillis(100));
System.out.println("Request " + id + " completed by " +
Thread.currentThread());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
출력 예시:
Request 0 completed by VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1
Request 1 completed by VirtualThread[#22]/runnable@ForkJoinPool-1-worker-2
...
직접 생성: Thread.ofVirtual()
public class DirectVirtualThreadExample {
public static void main(String[] args) throws InterruptedException {
// 방법 1: 빌더 패턴
Thread vt1 = Thread.ofVirtual()
.name("worker-", 0) // 자동 번호 부여
.start(() -> {
System.out.println("Virtual thread: " + Thread.currentThread());
});
// 방법 2: 팩토리
ThreadFactory factory = Thread.ofVirtual().factory();
Thread vt2 = factory.newThread(() -> {
System.out.println("From factory: " + Thread.currentThread());
});
vt2.start();
vt1.join();
vt2.join();
}
}
기존 스레드 풀 마이그레이션
Before: 고정 스레드 풀
public class BeforeVirtualThread {
private final ExecutorService executor =
Executors.newFixedThreadPool(200); // 플랫폼 스레드 200개
public void processRequests(List<Request> requests) {
List<Future<Response>> futures = new ArrayList<>();
for (Request req : requests) {
Future<Response> future = executor.submit(() -> {
// 블로킹 I/O
return callExternalAPI(req);
});
futures.add(future);
}
// 결과 수집
for (Future<Response> future : futures) {
try {
Response resp = future.get();
// 처리
} catch (Exception e) {
e.printStackTrace();
}
}
}
Response callExternalAPI(Request req) {
// HTTP 호출 (블로킹)
try {
Thread.sleep(100); // 시뮬레이션
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return new Response();
}
}
After: 가상 스레드
public class AfterVirtualThread {
// 가상 스레드 실행기 (풀 크기 제한 없음)
private final ExecutorService executor =
Executors.newVirtualThreadPerTaskExecutor();
public void processRequests(List<Request> requests) {
List<Future<Response>> futures = new ArrayList<>();
for (Request req : requests) {
Future<Response> future = executor.submit(() -> {
// 블로킹 I/O 그대로 사용 가능
return callExternalAPI(req);
});
futures.add(future);
}
// 결과 수집 (동일)
for (Future<Response> future : futures) {
try {
Response resp = future.get();
// 처리
} catch (Exception e) {
e.printStackTrace();
}
}
}
Response callExternalAPI(Request req) {
// HTTP 호출 (블로킹) - 가상 스레드가 자동으로 캐리어 양도
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return new Response();
}
}
변경 사항:
newFixedThreadPool(200)→newVirtualThreadPerTaskExecutor()- 풀 크기 튜닝 불필요
- 블로킹 코드 그대로 사용
여기서 가장 흔한 실수는 가상 스레드를 풀링하려는 것입니다. Executors.newFixedThreadPool(200, Thread.ofVirtual().factory())처럼 가상 스레드 200개짜리 고정 풀을 만들면 코드는 돌아가지만 의미가 없습니다. 가상 스레드는 만드는 비용이 거의 없어서 작업마다 새로 만들고 버리는 것이 설계 의도이고, 풀은 원래 “비싼 스레드를 재사용하기 위한” 장치였기 때문입니다. 풀 크기가 사실상 해 오던 또 다른 역할, 즉 동시 실행 수 제한이 필요하다면 스레드 수가 아니라 Semaphore로 제한 대상 자원 앞에서 직접 제한하는 것이 권장 방식입니다(뒤의 DB 커넥션 고갈 절 참고).
newFixedThreadPool(200)이 조용히 하던 일이 하나 더 있습니다. 작업이 200개를 넘으면 나머지를 큐에 대기시켜 자연스러운 백프레셔를 만들었습니다. newVirtualThreadPerTaskExecutor()에는 이 큐가 없어서 들어온 작업이 전부 즉시 실행을 시작합니다. 외부 API에 초당 호출 한도가 있거나 대상 서버가 작다면, 전환 직후 그쪽에서 429나 타임아웃이 쏟아지는 형태로 문제가 드러납니다. 전환 전에 “이 풀의 크기가 누구를 보호하고 있었는가”를 한 번씩 확인해 두는 것이 좋습니다.
Spring Boot 3.2+ 통합
application.properties
# Tomcat에서 가상 스레드 사용
spring.threads.virtual.enabled=true
이 속성 하나로 Tomcat(또는 Jetty)의 요청 처리 스레드뿐 아니라 @Async용 TaskExecutor, @Scheduled의 스케줄러, 일부 메시징 리스너 컨테이너까지 가상 스레드를 쓰도록 바뀝니다. 영향 범위가 생각보다 넓다는 뜻입니다. 전환 뒤에는 server.tomcat.threads.max가 더 이상 동시 요청 수의 상한 역할을 하지 않으므로, 그 값으로 사실상 DB 부하를 제한하고 있던 서비스라면 다른 제한 장치를 먼저 마련해야 합니다. 또 JDK 21 미만에서 이 속성을 켜면 아무 효과가 없다는 점도 확인할 부분입니다.
수동 설정 (Spring Boot 3.2 이전)
import org.springframework.boot.web.embedded.tomcat.TomcatProtocolHandlerCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.Executors;
@Configuration
public class VirtualThreadConfig {
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
}
컨트롤러 예시
@RestController
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
// 블로킹 JDBC 호출 - 가상 스레드가 자동 처리
return userService.findById(id);
}
@GetMapping("/users/{id}/orders")
public List<Order> getUserOrders(@PathVariable Long id) {
// 여러 블로킹 호출
User user = userService.findById(id);
List<Order> orders = orderService.findByUserId(user.getId());
// 외부 API 호출 (블로킹)
for (Order order : orders) {
order.setShippingStatus(shippingService.getStatus(order.getId()));
}
return orders;
}
}
병렬 처리: CompletableFuture와 함께
import java.util.concurrent.*;
import java.util.List;
import java.util.stream.Collectors;
public class ParallelProcessing {
private final ExecutorService executor =
Executors.newVirtualThreadPerTaskExecutor();
public List<Result> processInParallel(List<Task> tasks) {
// CompletableFuture로 병렬 실행
List<CompletableFuture<Result>> futures = tasks.stream()
.map(task -> CompletableFuture.supplyAsync(
() -> processTask(task),
executor
))
.collect(Collectors.toList());
// 모든 작업 완료 대기
CompletableFuture<Void> allOf =
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
allOf.join();
// 결과 수집
return futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());
}
Result processTask(Task task) {
// 블로킹 I/O
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return new Result();
}
}
StructuredTaskScope·ScopedValue·PINNED
StructuredTaskScope (JDK 21 프리뷰)
자식 작업을 구조화된 스코프로 묶어 취소·타임아웃을 일관되게 관리합니다.
먼저 짚어 둘 점은 이 절의 StructuredTaskScope와 다음 절의 ScopedValue가 JDK 21에서 프리뷰 API라는 것입니다. 컴파일과 실행 모두 --enable-preview 플래그가 필요하고, 없으면 StructuredTaskScope is a preview API and is disabled by default 같은 컴파일 에러가 납니다. 프리뷰 API는 버전마다 바뀔 수 있어서 실제로 StructuredTaskScope는 이후 버전에서 new StructuredTaskScope.ShutdownOnFailure() 대신 StructuredTaskScope.open(Joiner...) 형태의 정적 팩터리로 API가 재설계되었습니다. ScopedValue는 JDK 25에서 정식 API가 되었습니다. 운영 코드에 넣을 때는 사용하는 JDK 버전의 API 문서를 기준으로 하고, 프리뷰 API를 쓰는 모듈은 JDK 업그레이드 때 수정이 필요하다는 점을 계획에 넣어 두세요. 아래 예제는 JDK 21 프리뷰 API 기준입니다.
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
public class StructuredConcurrencyExample {
record UserData(String name, List<Order> orders, Address address) {}
public UserData fetchUserData(Long userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// 병렬로 3개 API 호출
Subtask<String> nameTask = scope.fork(() -> fetchUserName(userId));
Subtask<List<Order>> ordersTask = scope.fork(() -> fetchOrders(userId));
Subtask<Address> addressTask = scope.fork(() -> fetchAddress(userId));
// 모든 작업 완료 대기 (하나라도 실패하면 나머지 취소)
scope.join();
scope.throwIfFailed();
// 결과 조합
return new UserData(
nameTask.get(),
ordersTask.get(),
addressTask.get()
);
}
}
String fetchUserName(Long userId) throws InterruptedException {
Thread.sleep(50);
return "User-" + userId;
}
List<Order> fetchOrders(Long userId) throws InterruptedException {
Thread.sleep(100);
return List.of(new Order(), new Order());
}
Address fetchAddress(Long userId) throws InterruptedException {
Thread.sleep(80);
return new Address();
}
}
장점:
- 자식 작업 중 하나라도 실패하면 자동으로 나머지 취소
- 부모 스코프가 끝나면 모든 자식 작업 정리
- 타임아웃 설정 가능
ScopedValue (ThreadLocal 대체)
가상 스레드는 수가 많아 ThreadLocal 남용이 메모리 문제로 이어집니다. ScopedValue는 불변 값을 스코프 내에서만 공유합니다.
Before: ThreadLocal
public class ThreadLocalExample {
private static final ThreadLocal<String> USER_CONTEXT = new ThreadLocal<>();
public void handleRequest(String userId) {
USER_CONTEXT.set(userId);
try {
processRequest();
} finally {
USER_CONTEXT.remove(); // 누수 방지 필수
}
}
void processRequest() {
String userId = USER_CONTEXT.get();
System.out.println("Processing for user: " + userId);
}
}
문제점:
- 가상 스레드 수백만 개 × ThreadLocal = 메모리 누수 위험
remove()누락 시 메모리 누수
After: ScopedValue
import java.util.concurrent.StructuredTaskScope;
public class ScopedValueExample {
private static final ScopedValue<String> USER_CONTEXT = ScopedValue.newInstance();
public void handleRequest(String userId) throws Exception {
// 스코프 내에서만 값 바인딩
ScopedValue.where(USER_CONTEXT, userId)
.run(() -> {
processRequest();
});
}
void processRequest() {
String userId = USER_CONTEXT.get();
System.out.println("Processing for user: " + userId);
}
}
장점:
- 스코프 종료 시 자동 정리
- 불변 값으로 안전
- 메모리 효율적
ThreadLocal의 문제를 정확히 말하면 “가상 스레드에서 누수된다”기보다 전제가 바뀐다는 것입니다. 가상 스레드는 요청마다 새로 만들어지고 끝나면 버려지므로, 스레드가 끝날 때 그 ThreadLocal 값도 함께 회수되어 오히려 풀 재사용 때 생기던 “이전 요청의 값이 남는” 누수는 줄어듭니다. 대신 스레드 풀 재사용을 전제로 한 캐시형 ThreadLocal(스레드마다 SimpleDateFormat이나 큰 버퍼를 하나씩 만들어 재사용하는 패턴)이 문제가 됩니다. 플랫폼 스레드 200개일 때는 객체도 200개였지만, 동시 가상 스레드가 10만 개면 객체도 10만 개가 만들어지고 재사용은 한 번도 일어나지 않습니다. 이런 용도는 DateTimeFormatter처럼 스레드 안전한 불변 객체로 바꾸거나 명시적인 객체 풀로 옮기는 편이 맞습니다. ScopedValue는 요청 컨텍스트(사용자 ID, 트랜잭션 ID)처럼 “위에서 정해서 아래로 읽기만 하는” 값에 맞는 도구이고, 값이 StructuredTaskScope로 만든 자식 스레드에 자동으로 상속된다는 장점도 있습니다.
PINNED 상태 디버깅
가상 스레드가 캐리어 스레드에 고정되면 이점이 사라집니다.
PINNED 발생 원인
- synchronized 블록 내 블로킹 (JDK 21~23)
- 네이티브 메서드 호출
1번은 JDK 버전에 따라 결론이 달라집니다. JDK 21~23에서는 synchronized의 모니터가 OS 스레드에 묶여 구현되어 있어서, 그 안에서 블로킹하면 가상 스레드가 캐리어에서 내려오지 못합니다. 캐리어 수는 기본적으로 CPU 코어 수이므로, 8코어 서버에서 동시에 8개 요청만 synchronized 안에서 DB를 기다려도 나머지 모든 가상 스레드가 멈춥니다. 전환 직후 “부하가 조금만 올라가면 서버 전체가 얼어붙는다”는 증상의 대표적인 원인이며, 오래된 JDBC 드라이버나 커넥션 풀 라이브러리 내부의 synchronized에서 흔히 발생했습니다. JDK 24(JEP 491)부터는 synchronized가 캐리어를 붙잡지 않도록 재구현되어 이 문제의 대부분이 사라졌고, 남는 고정 원인은 네이티브 호출 정도입니다. JDK 21 LTS에 머물러야 한다면 아래처럼 ReentrantLock으로 바꾸거나 라이브러리를 가상 스레드 대응 버전으로 올리는 것이 해법입니다.
예제: synchronized 문제
// 나쁜 예: synchronized 블록 내 블로킹
public class PinnedExample {
private final Object lock = new Object();
public void badMethod() {
synchronized (lock) {
// 블로킹 I/O → PINNED 상태!
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}
// 좋은 예: ReentrantLock 사용
import java.util.concurrent.locks.ReentrantLock;
public class UnpinnedExample {
private final ReentrantLock lock = new ReentrantLock();
public void goodMethod() {
lock.lock();
try {
// 블로킹 I/O → 캐리어 양도 가능
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}
}
JFR로 PINNED 이벤트 확인
# JFR 프로파일링 실행
java -XX:+UnlockDiagnosticVMOptions \
-XX:+DebugNonSafepoints \
-XX:StartFlightRecording=filename=recording.jfr \
-jar app.jar
# JFR 파일 분석
jfr print --events jdk.VirtualThreadPinned recording.jfr
출력 예시:
jdk.VirtualThreadPinned {
startTime = 12:34:56.789
duration = 105 ms
carrierThread = "ForkJoinPool-1-worker-1"
pinnedReason = "synchronized"
}
위 출력은 형태를 보여 주기 위한 예시이며, 이벤트에 담기는 필드는 JDK 버전에 따라 다릅니다(JDK 21의 이벤트는 주로 지속 시간과 스택 트레이스를 담고, 고정 이유 같은 필드는 이후 버전에서 보강되었습니다). 실제로 가장 쓸모 있는 정보는 스택 트레이스로, 어느 라이브러리의 어느 synchronized 블록에서 고정됐는지 알려 줍니다. jdk.VirtualThreadPinned 이벤트는 기본 설정에서 20ms 이상 고정된 경우만 기록하므로, 짧은 고정이 아주 잦은 경우는 놓칠 수 있다는 점도 알아 두세요.
실전 마이그레이션: JDBC 예제
Before: HikariCP + 고정 풀
@Configuration
public class BeforeConfig {
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost/mydb");
config.setUsername("user");
config.setPassword("pass");
config.setMaximumPoolSize(50); // 플랫폼 스레드 풀 크기에 맞춤
return new HikariDataSource(config);
}
@Bean
public ExecutorService executor() {
return Executors.newFixedThreadPool(200); // 요청 처리 풀
}
}
After: 가상 스레드 + 커넥션 풀 조정
@Configuration
public class AfterConfig {
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost/mydb");
config.setUsername("user");
config.setPassword("pass");
config.setMaximumPoolSize(200); // 가상 스레드 증가에 맞춰 풀 확대
config.setConnectionTimeout(5000); // 타임아웃 설정 중요
return new HikariDataSource(config);
}
@Bean
public ExecutorService executor() {
return Executors.newVirtualThreadPerTaskExecutor(); // 풀 크기 제한 없음
}
}
주의사항:
- 가상 스레드가 동시 요청 수를 폭증시킬 수 있음
- DB 커넥션 풀 크기를 함께 조정
- 쿼리 타임아웃, 서킷 브레이커 필수
커넥션 풀을 50에서 200으로 늘리는 것이 항상 답은 아닙니다. 커넥션 하나는 DB 서버 쪽에서도 프로세스나 스레드, 메모리를 차지하고, DB가 실제로 동시에 처리할 수 있는 쿼리 수는 DB 서버의 코어 수와 디스크에 묶여 있습니다. 풀을 무작정 키우면 DB 안에서 락 경합과 컨텍스트 스위칭이 늘어 오히려 전체 처리량이 떨어질 수 있고, 애플리케이션 인스턴스가 여러 대라면 풀 크기 × 인스턴스 수가 DB의 max_connections를 넘어 연결 자체가 거부됩니다. 가상 스레드 환경에서는 오히려 커넥션 풀이 자연스러운 동시성 제한 장치가 됩니다. 가상 스레드 10만 개가 커넥션 50개를 기다리는 것은 대기 비용이 작아 괜찮은 상태이고, 필요한 것은 connectionTimeout을 적절히 짧게 두어 과부하 때 빨리 실패하게 하는 것입니다. 풀 대기 시간이 길어지면 HikariCP는 Connection is not available, request timed out after 5000ms 예외를 던지므로, 이 예외 비율을 전환 후 핵심 지표로 지켜보면 됩니다.
블로킹 I/O와 CPU 바운드에서의 이론적 차이
블로킹 I/O 워크로드 (이론 계산)
가정:
- 10,000개 작업
- 각 작업: 100ms 블로킹 대기 (CPU는 거의 쓰지 않음)
- 외부 자원(DB·API)의 동시 처리 한도는 없다고 가정
| 방식 | 동시 실행 수 | 총 실행 시간 (이론값) |
|---|---|---|
| 고정 풀 (200) | 200 | 10,000 ÷ 200 × 100ms ≈ 5초 |
| 가상 스레드 | 10,000 | ≈ 100ms + 스케줄링 오버헤드 |
해석:
- 차이는 “가상 스레드가 빠르다”가 아니라 동시에 기다릴 수 있는 작업 수에서 나옵니다. 고정 풀은 200개씩 50번 줄을 서고, 가상 스레드는 10,000개가 한꺼번에 기다립니다.
- 이 계산은 대기 시간이 외부 자원에 의해 늘어나지 않는다는 가정 위에 있습니다. 실제로는 10,000개 요청이 한꺼번에 DB로 가면 DB 쪽 대기열이 길어져 이론값에 한참 못 미칩니다.
- 메모리 차이도 스레드 스택 예약 방식의 차이에서 오며, 실제 수치는 각 작업이 호출 스택을 얼마나 깊게 쓰는지에 따라 달라지므로 직접 측정해야 합니다.
CPU 바운드 워크로드 (이론 계산)
가정:
- 10,000개 작업, 각 작업 10ms CPU 계산, 8코어
| 방식 | 동시 실행 수 | 총 실행 시간 (이론값) | CPU 사용률 |
|---|---|---|---|
| 고정 풀 (8) | 8 | 10,000 × 10ms ÷ 8 ≈ 12.5초 | 100% |
| 가상 스레드 | 캐리어 8개 위에서 실행 | ≈ 12.5초 | 100% |
해석:
- CPU 바운드는 이득 없음
- 오히려 컨텍스트 스위칭 오버헤드 가능
- 코어 수만큼 풀 사용이 효율적 CPU 바운드에서 가상 스레드가 불리할 수 있는 이유가 하나 더 있습니다. 가상 스레드 스케줄러는 시간 분할(time slicing) 선점을 하지 않으므로, 블로킹 없이 오래 계산하는 가상 스레드는 캐리어를 놓지 않습니다. 계산 작업이 캐리어를 모두 차지하면 그 사이 I/O가 끝나 깨어난 다른 가상 스레드들이 실행 기회를 얻지 못해, 짧은 요청의 지연 시간이 들쭉날쭉해집니다. CPU 작업을 요청 처리와 같은 가상 스레드에서 오래 돌리지 말고 별도의 플랫폼 스레드 풀로 넘기는 것이 안전한 이유입니다.
요약:
- I/O 대기가 많은 워크로드에서 극적인 성능 향상
- CPU 바운드는 효과 없음
- 외부 시스템 한도 (DB 커넥션, API 레이트 리밋)가 새로운 병목
게이트웨이·배치·소켓 서버에 적용하기
사례 1: 마이크로서비스 API 게이트웨이
시나리오: 업스트림 서비스 5개 호출 후 결과 조합
Before: 순차 호출
public class SequentialGateway {
public AggregatedResponse handle(Request req) {
// 순차 호출: 총 500ms
UserInfo user = userService.getUser(req.getUserId()); // 100ms
List<Order> orders = orderService.getOrders(req.getUserId()); // 150ms
Inventory inventory = inventoryService.check(req.getItemId()); // 80ms
Pricing pricing = pricingService.calculate(req.getItemId()); // 120ms
Shipping shipping = shippingService.estimate(req.getZipCode()); // 50ms
return new AggregatedResponse(user, orders, inventory, pricing, shipping);
}
}
After: 가상 스레드 병렬 호출
public class ParallelGateway {
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
public AggregatedResponse handle(Request req) throws Exception {
// 병렬 호출: 총 150ms (가장 느린 것 기준)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var userTask = scope.fork(() -> userService.getUser(req.getUserId()));
var ordersTask = scope.fork(() -> orderService.getOrders(req.getUserId()));
var inventoryTask = scope.fork(() -> inventoryService.check(req.getItemId()));
var pricingTask = scope.fork(() -> pricingService.calculate(req.getItemId()));
var shippingTask = scope.fork(() -> shippingService.estimate(req.getZipCode()));
scope.join();
scope.throwIfFailed();
return new AggregatedResponse(
userTask.get(),
ordersTask.get(),
inventoryTask.get(),
pricingTask.get(),
shippingTask.get()
);
}
}
}
개선 효과:
- 레이턴시: 호출 시간의 합(약 500ms)에서 가장 느린 호출(약 150ms) 수준으로 줄어듦
- 업스트림 하나가 실패하면
ShutdownOnFailure가 나머지 호출을 취소하므로, 실패한 요청이 다른 호출이 끝날 때까지 자원을 잡고 있지 않음
이 개선은 사실 가상 스레드가 없어도 CompletableFuture와 스레드 풀로 얻을 수 있었습니다. 가상 스레드가 바꾸는 것은 동시 요청이 많을 때의 비용입니다. 요청 하나가 업스트림 호출 5개를 띄우면, 동시 요청 1,000개일 때 5,000개의 블로킹 작업이 동시에 대기합니다. 플랫폼 스레드 풀로는 이 수를 감당하기 어려워 풀 크기와 큐를 조심스럽게 조율해야 했지만, 가상 스레드에서는 대기 비용이 작아 이 조율이 대부분 필요 없어집니다. 대신 업스트림 서비스가 받는 동시 요청 수도 그만큼 늘어난다는 점을 함께 계산해야 합니다.
사례 2: 배치 처리 시스템
시나리오: 100만 개 레코드 처리 (각 레코드당 DB 조회 + 외부 API 호출)
Before: 고정 풀
public class BatchProcessor {
private final ExecutorService executor = Executors.newFixedThreadPool(50);
public void processBatch(List<Record> records) {
List<Future<Void>> futures = new ArrayList<>();
for (Record record : records) {
Future<Void> future = executor.submit(() -> {
processRecord(record);
return null;
});
futures.add(future);
}
// 모든 작업 완료 대기
for (Future<Void> future : futures) {
try {
future.get();
} catch (Exception e) {
e.printStackTrace();
}
}
}
void processRecord(Record record) {
// DB 조회 (블로킹)
Data data = database.query(record.getId());
// 외부 API 호출 (블로킹)
Result result = externalAPI.process(data);
// DB 업데이트 (블로킹)
database.update(record.getId(), result);
}
}
문제점:
- 풀 크기 50 → 100만 개 처리에 오래 걸림
- 풀 크기 증가 → 메모리 부족
After: 가상 스레드
public class VirtualThreadBatchProcessor {
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
public void processBatch(List<Record> records) {
List<Future<Void>> futures = new ArrayList<>();
for (Record record : records) {
Future<Void> future = executor.submit(() -> {
processRecord(record);
return null;
});
futures.add(future);
}
// 모든 작업 완료 대기
for (Future<Void> future : futures) {
try {
future.get();
} catch (Exception e) {
e.printStackTrace();
}
}
}
void processRecord(Record record) {
// 동일한 블로킹 코드 - 가상 스레드가 자동 처리
Data data = database.query(record.getId());
Result result = externalAPI.process(data);
database.update(record.getId(), result);
}
}
개선 효과:
- 처리 시간: 수 시간 → 수 분
- 메모리: 안정적
- DB 커넥션 풀을 병목으로 조정 필요
사례 3: 연결당 스레드 소켓 서버
시나리오: 10만 개 동시 TCP 연결 (아래 예제는 WebSocket 프로토콜 처리 없이 받은 바이트를 그대로 돌려주는 에코 서버입니다)
Before: 플랫폼 스레드
// 연결당 스레드 생성 불가 (메모리 부족)
// → Netty 등 논블로킹 프레임워크 필요
After: 가상 스레드
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.Executors;
public class WebSocketServer {
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
public void start(int port) throws Exception {
try (ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("Server started on port " + port);
while (true) {
Socket clientSocket = serverSocket.accept();
// 연결당 가상 스레드 생성
executor.submit(() -> handleClient(clientSocket));
}
}
}
void handleClient(Socket socket) {
try (socket;
var in = socket.getInputStream();
var out = socket.getOutputStream()) {
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
// 블로킹 읽기/쓰기 - 가상 스레드가 자동 처리
out.write(buffer, 0, bytesRead);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
개선 효과:
- 10만 개 연결 처리 가능
- 블로킹 코드 그대로 사용
- 논블로킹 프레임워크 불필요
전환 후 자주 만나는 문제
문제 1: “성능이 기대만큼 안 나온다”
원인 1: CPU 바운드 워크로드
// 가상 스레드가 도움 안 됨
public void cpuIntensiveTask() {
for (int i = 0; i < 1_000_000; i++) {
// 복잡한 계산
Math.sqrt(i);
}
}
해결: CPU 작업은 ForkJoinPool 사용
import java.util.concurrent.ForkJoinPool;
public class CpuTaskProcessor {
private final ForkJoinPool forkJoinPool = ForkJoinPool.commonPool();
public void processCpuTasks(List<Task> tasks) {
tasks.parallelStream()
.forEach(this::cpuIntensiveTask);
}
}
원인 2: PINNED 상태
확인 방법:
# JVM 플래그 추가 (JDK 21~23, JDK 24에서 제거됨)
-Djdk.tracePinnedThreads=full
# 또는 JFR
java -XX:StartFlightRecording=settings=profile,filename=recording.jfr -jar app.jar
해결:
// synchronized → ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
public void method() {
lock.lock();
try {
// 블로킹 I/O
} finally {
lock.unlock();
}
}
문제 2: “ThreadLocal이 누수된다”
원인
// 가상 스레드 수백만 개 × ThreadLocal
private static final ThreadLocal<HeavyObject> CONTEXT = new ThreadLocal<>();
public void handle() {
CONTEXT.set(new HeavyObject()); // 누수 위험
// ...
}
해결 1: ScopedValue 사용
private static final ScopedValue<HeavyObject> CONTEXT = ScopedValue.newInstance();
public void handle() {
HeavyObject obj = new HeavyObject();
ScopedValue.where(CONTEXT, obj).run(() -> {
// 스코프 종료 시 자동 정리
process();
});
}
해결 2: ThreadLocal 명시적 정리
private static final ThreadLocal<HeavyObject> CONTEXT = new ThreadLocal<>();
public void handle() {
try {
CONTEXT.set(new HeavyObject());
process();
} finally {
CONTEXT.remove(); // 필수
}
}
문제 3: “DB 커넥션이 고갈된다”
원인
// 가상 스레드 10만 개 → DB 커넥션 풀 50개 → 대기
해결 1: 커넥션 풀 크기 조정 (DB가 감당할 수 있는 범위 안에서)
config.setMaximumPoolSize(100); // DB 서버 코어 수·max_connections 기준으로 결정
config.setConnectionTimeout(5000); // 풀이 막히면 5초 안에 빨리 실패
앞 절에서 설명했듯 풀을 수백 개로 키우는 것은 DB 쪽 한도에 막히기 쉬우므로, 풀 크기는 DB 기준으로 정하고 대기 시간 초과를 빨리 드러내는 쪽이 안전합니다.
해결 2: 세마포어로 동시 요청 제한
import java.util.concurrent.Semaphore;
public class RateLimitedService {
private final Semaphore semaphore = new Semaphore(100); // 동시 100개 제한
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
public void processRequest(Request req) throws Exception {
semaphore.acquire();
try {
executor.submit(() -> {
try {
// DB 호출
database.query(req);
} finally {
semaphore.release();
}
});
} catch (Exception e) {
semaphore.release();
throw e;
}
}
}
이 예제는 호출하는 쪽에서 acquire()로 대기하므로 제출 자체가 느려지는 백프레셔 역할을 합니다. 요청 처리 스레드가 이미 가상 스레드라면 더 단순하게, 제한하려는 자원을 쓰는 코드 바로 앞뒤에서 acquire()/release()를 하면 됩니다. 가상 스레드는 세마포어에서 기다리는 동안 캐리어를 양보하므로 대기 비용이 거의 없고, 이것이 “스레드 수로 제한하던 것을 자원 앞에서 제한한다”는 가상 스레드 시대의 기본 패턴입니다. 대기가 무한정 길어지지 않도록 tryAcquire(timeout, unit)으로 시간 제한을 두는 것도 잊지 마세요.
해결 3: 서킷 브레이커
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
public class ResilientService {
private final CircuitBreaker circuitBreaker = CircuitBreaker.of(
"database",
CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofSeconds(30))
.build()
);
public Result query(Request req) {
return circuitBreaker.executeSupplier(() -> database.query(req));
}
}
문제 4: “스레드 덤프에 가상 스레드가 안 보인다 / 너무 크다”
원인
# jstack은 플랫폼 스레드만 보여 주고 가상 스레드는 나오지 않음
jstack <pid> > dump.txt
# 가상 스레드까지 포함한 덤프 (JDK 21+), 수가 많으면 파일이 매우 커짐
jcmd <pid> Thread.dump_to_file -format=json threads.json
기존에는 “요청이 멈췄다”면 jstack으로 어느 스레드가 어디서 막혔는지 보는 것이 첫 단계였는데, 가상 스레드로 옮기면 이 방법이 통하지 않습니다. jstack에는 캐리어 스레드(ForkJoinPool-1-worker-N)만 보이고, 막혀 있는 요청들은 보이지 않기 때문입니다. jcmd Thread.dump_to_file의 JSON 형식은 가상 스레드를 실행기·스코프별로 묶어 보여 주므로, 수만 개가 있어도 도구로 필터링해 분석할 수 있습니다. 전환 전에 운영 런북의 덤프 명령부터 바꿔 두는 것을 권합니다.
해결: 샘플링 도구 사용
# async-profiler (가상 스레드 지원)
./profiler.sh -e cpu -d 30 -f flamegraph.html <pid>
# JFR (가상 스레드 이벤트 필터링)
jfr print --events jdk.ExecutionSample recording.jfr
마무리
가상 스레드는 “블로킹 코드를 그대로 두되 OS 스레드를 아끼자”는 현대적인 선택지입니다. 풀 튜닝 중심 사고를 외부 자원 한도·PINNED·ThreadLocal 관점으로 바꾸면 마이그레이션이 수월합니다.
마이그레이션 체크리스트
- 워크로드 확인
- ✅ I/O 대기가 많은가? → 가상 스레드 적합
- ❌ CPU 집약적인가? → ForkJoinPool 유지
- 코드 점검
- ✅
synchronized블록 내 블로킹? → JDK 21~23이면ReentrantLock으로 변경 (JDK 24+는 대부분 불필요) - ✅ ThreadLocal 사용? →
ScopedValue검토 - ✅ 네이티브 메서드 호출? → PINNED 이벤트 모니터링
- ✅
- 외부 자원 조정
- ✅ DB 커넥션 풀 크기를 DB가 감당할 수 있는 범위에서 조정하고, 동시 사용량은 세마포어 등으로 제한
- ✅ HTTP 클라이언트 타임아웃 설정
- ✅ 서킷 브레이커 도입
- 모니터링
- ✅ JFR로 PINNED 이벤트 확인
- ✅ 처리량·레이턴시 측정
- ✅ 메모리 사용량 추적
다음 단계
- 스레드 기본기: Java 멀티스레드
- Spring 통합: Spring 시리즈
- I/O 최적화: I/O 가이드 가상 스레드는 JDK 21의 가장 큰 변화 중 하나이고, 기존 블로킹 코드를 크게 바꾸지 않고도 동시에 기다릴 수 있는 작업 수를 늘려 줍니다. 다만 늘어난 동시성은 그대로 DB와 외부 API로 흘러가므로, 스레드 수 대신 외부 자원의 동시 사용량을 제한하는 설계로 관점을 옮기는 것이 전환의 핵심입니다.
자주 묻는 질문 (FAQ)
Q. 가상 스레드로 바꾸면 ThreadLocal은 그대로 써도 되나요?
A. 동작은 하지만 주의가 필요합니다. 가상 스레드는 요청마다 새로 만들어 수가 매우 많아질 수 있어서, 스레드마다 큰 객체를 ThreadLocal에 담는 기존 패턴은 메모리 사용량을 크게 늘립니다. 요청 컨텍스트처럼 범위 안에서 읽기만 하는 값이라면 ScopedValue로 바꾸는 것을 검토하고, 스레드 풀 재사용을 전제로 만든 캐시성 ThreadLocal은 다른 캐시 구조로 옮기는 것이 좋습니다.