1. 핵심 요약
여러분은 이미 java.util.concurrent 패키지의 클래스들, 특히 ExecutorService에 조금 익숙할 것입니다. 이것은 일종의 “작업 관리자”입니다. 여러분이 작업을 보냄(예: submit())과 동시에, 언제 어떤 스레드로 실행할지 내부에서 알아서 결정합니다. 보통 내부적으로는 고정 크기의 스레드 풀을 사용해 자원을 절약하고, 작업마다 새 스레드를 만들지 않습니다.
하지만 가상 스레드에서는 모든 것이 달라집니다! 이제 각 작업마다 별도의 스레드를 할당하는 “사치”를 누릴 수 있으며, 그 때문에 JVM이 과부하로 “터질” 것을 걱정하지 않아도 됩니다.
새로운 방식: Executors.newVirtualThreadPerTaskExecutor()
Java 21에서는 각 작업을 별도의 가상 스레드로 실행하는 ExecutorService를 만드는 새로운 방법이 추가되었습니다:
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
중요한 차이점:
- 기존 스레드 풀(Executors.newFixedThreadPool, Executors.newCachedThreadPool)은 운영체제 스레드의 비용 때문에 동시에 실행 가능한 작업 수가 제한되었습니다.
- 새로운 가상 Executor는 거의 제한이 없습니다. 작업마다 가벼운 가상 스레드가 하나씩 할당됩니다.
간단한 예제
10개의 작업을 가상 Executor로 보내 봅시다:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualExecutorDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 10; i++) {
int taskId = i; // 람다에서 캡처할 변수
executor.submit(() -> {
System.out.println("Task " + taskId + " is running in thread: " +
Thread.currentThread());
});
}
executor.shutdown();
}
}
무슨 일이 발생하나요?
각 작업은 자신의 가상 스레드에서 실행되며, 다음과 같은 출력이 보일 것입니다:
Task 1 is running in thread: VirtualThread[#24]/runnable@ForkJoinPool-1-worker-1
...
2. 대규모 병렬성: 수천 개 작업 — 문제 없습니다!
가상 스레드의 위력을 체감하려면 ExecutorService에 10개가 아니라 100_000개의 작업을 보내 보세요. 고전적인 풀에서는 메모리가 급격히 소모되거나 심하게 느려졌을 겁니다. 가상 스레드는 다릅니다!
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualExecutorMassiveDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 100_000; i++) {
int taskId = i;
executor.submit(() -> {
// 예시로 1ms 동안 잠듭니다
try {
Thread.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// System.out.println("Task " + taskId + " done."); // 너무 많은 줄이 출력되므로 출력하지 않습니다!
});
}
executor.shutdown();
}
}
주의: 100_000줄을 콘솔에 출력하는 것은 좋지 않습니다. 콘솔이 가상 스레드보다 먼저 병목이 됩니다. 콘솔 출력은 하지 않거나, 처음 몇 개 작업만 출력하세요.
3. newVirtualThreadPerTaskExecutor는 어떻게 동작하나요
간단히 말해, 이 ExecutorService는 여러분이 보낸 각 작업마다 새로운 가상 스레드를 생성합니다. 고정 풀과 달리 작업 대기열과 동시에 실행되는 스레드 수에 대한 엄격한 제한이 없습니다(단, JVM과 하드웨어의 한계는 존재).
아키텍처 관점:
- 가상 스레드는 소수의 실제 OS 캐리어(carrier) 스레드 풀에 매핑됩니다.
- JVM이 어떤 가상 스레드를 언제 실행·일시중지·재개할지 스스로 결정합니다.
- 스레드가 블로킹되면(예: 파일 읽기나 네트워크 대기), JVM은 가상 스레드를 “동결”하고 캐리어 스레드를 다른 작업에 사용할 수 있게 합니다.
4. 예제: Future로 결과 처리
작업이 결과를 반환한다면, ExecutorService는 Future 타입의 객체를 반환합니다. 일반 스레드와 동일하게 동작합니다:
import java.util.concurrent.*;
public class VirtualExecutorWithResult {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Future<String> future = executor.submit(() -> {
Thread.sleep(500);
return "Hello from virtual thread!";
});
System.out.println("Result: " + future.get()); // 결과를 기다립니다
executor.shutdown();
}
}
익숙한 방식입니다: 값을 반환하는 작업을 보내고, get()으로 결과를 기다릴 수 있으며, 예외는 표준 방식으로 처리됩니다.
5. Executor를 올바르게 종료하는 방법
프로그램이 종료되지 않고 대기 상태로 남지 않도록 ExecutorService를 반드시 종료하세요(스레드가 “진짜”가 아닌 가상 스레드라 하더라도 마찬가지).
shutdown()과 awaitTermination
executor.shutdown(); // 더 이상 작업을 받지 않음
executor.awaitTermination(1, TimeUnit.MINUTES); // 모든 작업의 완료를 기다림(최대 1분)
왜 중요할까요?
shutdown()을 호출하지 않으면 가상 스레드가 계속 살아 있을 수 있고, main()이 끝난 뒤에도 프로그램이 종료되지 않을 수 있습니다. 초보자들이 자주 겪는 실수입니다.
6. 유용한 팁
비교: 가상 Executor vs 고전적인 스레드 풀
| 고전적인 풀(newFixedThreadPool) | 가상 Executor(newVirtualThreadPerTaskExecutor) | |
|---|---|---|
| 스레드 수 | 풀 크기로 제한됨 | 작업당 하나의 가상 스레드, 거의 무제한 |
| 대기열의 작업 | 예, 모든 스레드가 바쁠 때 | 보통 없음: 작업이 즉시 스레드를 획득 |
| 스레드 비용 | 높음(스택, OS 자원) | 매우 낮음(JVM 스케줄링) |
| 확장성 | 제한적 | 거의 제한 없음 |
| 적합한 용도 | CPU-bound 작업, 제한된 병렬성 | I/O-bound 작업, 대규모 병렬성 |
웹 서버와의 통합
최신 웹 서버(예: Tomcat, Jetty, Undertow)는 이미 가상 스레드를 지원하기 시작했습니다. 이는 각 HTTP 요청을 별도의 가상 스레드에서 처리하더라도 사용자 급증에 “숨이 막히지” 않는다는 뜻입니다.
장점: callback과 CompletableFuture를 사용하는 복잡한 비동기 구조를 설계할 필요가 없습니다. 코드는 더 단순해지고 — 익숙한 블로킹 코드를 그대로 써도 애플리케이션은 확장됩니다.
대규모 테스트와 부하 시뮬레이션
가상 스레드는 수천 명의 동시 사용자, 요청 또는 작업을 “시뮬레이션”해야 하는 테스트에 매우 적합합니다. 예를 들어, 서버로 10_000개의 병렬 요청을 보내되, 각 요청을 자체 가상 스레드에서 처리하는 테스트가 가능합니다.
파일과 네트워크 연결의 병렬 처리
애플리케이션이 많은 수의 파일이나 네트워크 연결을 다룬다면, 각 연결을 별도의 가상 스레드에서 처리하도록 구성할 수 있으며, 풀을 수동으로 관리하는 데 신경 쓰지 않아도 됩니다.
7. 가상 Executor 사용 시 흔한 실수
오류 1: shutdown() 호출을 잊음. Executor를 닫지 않으면 프로그램이 종료되지 않을 수 있습니다 — 가상 스레드가 여전히 새로운 작업을 기다릴 수 있기 때문입니다. 필요하다면 awaitTermination(...)도 함께 사용하세요.
오류 2: 무거운 계산에 가상 스레드를 사용함. 가상 스레드는 CPU를 100% 사용하는 작업을 빠르게 만들지 않습니다. CPU-bound 작업에는 고정 풀(Executors.newFixedThreadPool)을 사용하고, 크기를 신중하게 조정하세요.
오류 3: 작업 내부의 예외를 무시함. 작업에서 던진 예외는 메인 스레드로 전달되지 않습니다 — Future(get() 메서드)를 통해 처리하거나, 람다 내부에서 try/catch로 처리하세요.
오류 4: 오래된 문법/버전의 JDK와 혼동함. JDK 버전(Java 21+)이 올바른지, IDE가 가상 스레드를 지원하도록 설정되었는지 확인하세요. 사용해야 할 정확한 메서드는 Executors.newVirtualThreadPerTaskExecutor()입니다.
오류 5: 컨텍스트 전달에 ThreadLocal에 의존함. 가상 스레드는 자주 생성되고 소멸됩니다. ThreadLocal은 기대와 다르게 동작할 수 있습니다. 컨텍스트 전달에는 ScopedValue (Scoped Values; 자세한 내용은 다음 강의에서)를 사용하세요.
GO TO FULL VERSION