가상 스레드
가상 스레드 (Virtual Threads)
서버 애플리케이션은 동시에 아주 많은 요청을 처리해야 하는데, 요청마다 운영체제의 플랫폼 스레드(platform thread)를 하나씩 쓰다 보면 메모리와 CPU 낭비가 커집니다. 자바는 이 문제를 풀기 위해 가상 스레드(virtual thread)를 도입했어요. 가상 스레드는 수많은 가상 스레드를 소수의 플랫폼 스레드 위에서 돌려서, 블로킹(blocking)을 값싸게 만들고 콜백 없이 익숙한 동기식 코드를 그대로 쓸 수 있게 해 줍니다.
출처: Virtual Threads
본문
왜 가상 스레드인가? (Why Virtual Threads?)
자바 1.0이 1995년에 출시되었을 때, 그 API에는 약 100개의 클래스가 있었고 그중에는 java.lang.Thread도 있었습니다. 자바는 동시성 프로그래밍을 직접 지원한 최초의 주류 프로그래밍 언어였습니다.
자바 1.2부터는 각 자바 스레드가 기반 운영체제가 제공하는 플랫폼 스레드 위에서 실행됩니다. (자바 1.1까지는 일부 플랫폼에서 모든 자바 스레드가 단일 플랫폼 스레드로 실행되기도 했습니다.)
플랫폼 스레드는 무시할 수 없는 비용이 듭니다. 시작하는 데 수천 개의 CPU 명령이 필요하고, 몇 메가바이트의 메모리를 소비합니다. 서버 애플리케이션은 동시에 너무 많은 요청을 처리하기 때문에, 각 요청을 별도의 플랫폼 스레드에서 실행하는 것은 현실적으로 불가능해집니다. 전형적인 서버 애플리케이션에서 이 요청들은 많은 시간을 블로킹(blocking)하며 보냅니다 — 즉 데이터베이스나 다른 서비스의 결과를 기다리는 상태입니다.
처리량(throughput)을 높이는 고전적인 해결책은 논블로킹(non-blocking) API입니다. 프로그래머는 결과를 기다리는 대신, 결과가 준비되었을 때 호출될 메서드, 그리고 실패할 경우 호출될 다른 메서드를 지정합니다. 콜백(callback)이 점점 더 깊이 중첩되면서 이 방식은 금방 불편해집니다.
JEP 425는 자바 19에서 가상 스레드를 도입했습니다. 많은 가상 스레드가 하나의 플랫폼 스레드 위에서 실행됩니다. 가상 스레드가 블로킹되면 언마운트(unmounted)되고, 그 플랫폼 스레드는 다른 가상 스레드를 실행합니다. ("가상 스레드"라는 이름은 실제 RAM에 매핑되는 가상 메모리(virtual memory)를 연상시키려는 의도입니다.) 가상 스레드는 자바 20(JEP 436)에서 프리뷰(preview) 기능이 되었고, 자바 21에서 정식(final)이 되었습니다.
가상 스레드에서는 블로킹이 값쌉니다. 결과가 즉시 준비되지 않으면 가상 스레드 안에서 그냥 블로킹하면 됩니다. 콜백의 파이프라인 대신 분기(branch), 반복(loop), try 블록과 같은 익숙한 프로그래밍 구조를 사용하면 됩니다.
가상 스레드는 동시에 실행되는 작업 수가 많고, 그 작업들이 대부분 네트워크 I/O에서 블로킹되는 경우에 유용합니다. CPU 집약적인 작업에는 이득이 없습니다. 그런 작업에는 병렬 스트림(parallel streams)이나 재귀적 fork-join 작업(recursive fork-join tasks)을 고려해 보세요.
가상 스레드 만들기 (Creating Virtual Threads)
팩토리 메서드 Executors.newVirtualThreadPerTaskExecutor()는 각 작업을 별도의 가상 스레드에서 실행하는 ExecutorService를 반환합니다. 예를 들어:
import java.util.concurrent.*;
public class VirtualThreadDemo {
public static void main(String[] args) {
final int NTASKS = 100;
ExecutorService service = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < NTASKS; i++) {
service.submit(() -> {
long id = Thread.currentThread().threadId();
LockSupport.parkNanos(1_000_000_000);
IO.println(id);
});
}
service.close();
}
}
참고로 이 코드는 Thread.sleep() 대신 LockSupport.parkNanos()를 사용했는데, 그 덕분에 골치 아픈 InterruptedException을 잡지 않아도 됩니다.
혹시 더 낮은 수준의 API를 쓰면서 스레드 팩토리(thread factory)를 요구받는 상황일 수도 있습니다. 가상 스레드용 팩토리를 얻으려면 새 Thread.Builder 인터페이스를 사용하면 됩니다:
Thread.Builder builder = Thread.ofVirtual().name("request-", 1);
ThreadFactory factory = builder.factory();
이제 factory.newThread(myRunnable)을 호출하면 (아직 시작하지 않은) 새 가상 스레드가 만들어집니다. name() 메서드는 빌더가 스레드 이름을 request-1, request-2처럼 붙이도록 구성합니다.
빌더로 단일 가상 스레드를 만들 수도 있습니다:
Thread t = builder.unstarted(myRunnable);
또는 스레드를 바로 시작하고 싶다면:
Thread t = builder.started(myRunnable);
마지막으로, 빠른 데모를 위한 편의 메서드도 있습니다:
Thread t = Thread.startVirtualThread(myRunnable);
참고로 결과를 반환하는 작업(즉 Callable)에서 동작하는 것은 맨 처음의 executor service 방식뿐입니다.
스레드 API 변경 (Thread API Changes)
다양한 API로 여러 실험을 거친 끝에, 자바 가상 스레드의 설계자들은 익숙한 Thread API를 그대로 재사용하기로 결정했습니다. 가상 스레드는 Thread의 인스턴스입니다. 취소(cancellation)도 플랫폼 스레드와 마찬가지로 Thread.interrupt()를 호출해 동작합니다. 언제나 그렇듯, 스레드 코드는 인터럽트 플래그를 확인하거나, 그런 확인을 해 주는 메서드를 호출해야 합니다. (대부분의 블로킹 메서드가 그런 확인을 수행합니다.)
몇 가지 차이점이 있습니다. 특히 모든 가상 스레드는:
- 단일 스레드 그룹(thread group)에 속합니다.
NORM_PRIORITY우선순위를 갖습니다.- 데몬 스레드(daemon thread)입니다.
다른 스레드 그룹으로 가상 스레드를 만드는 API는 없습니다. 가상 스레드에 setPriority나 setDaemon을 호출해도 아무 효과가 없습니다.
정적 메서드 Thread.getAllStackTraces()는 모든 플랫폼 스레드의 스택 트레이스(strace trace) 맵을 반환합니다. 가상 스레드는 포함되지 않습니다.
새 인스턴스 메서드 Thread.isVirtual()는 그 스레드가 가상 스레드인지 알려 줍니다.
가상 스레드가 어떤 플랫폼 스레드 위에서 실행되고 있는지 알아낼 방법은 없다는 점에 유의하세요.
자바 19에는 가상 스레드와는 무관한 Thread API 변경도 몇 가지 있습니다:
Thread.join()메서드에Duration을 매개변수로 받는 오버로드가 추가되었습니다:Thread.join(Duration).Thread.sleep()도 마찬가지입니다.Thread.sleep(Duration)메서드가 추가되었습니다.final이 아닌Thread.getId()메서드는 누군가가 스레드 ID가 아닌 다른 값을 반환하도록 오버라이드할 수 있기 때문에 deprecated 되었습니다.final메서드인Thread.threadId()를 대신 호출하세요.
참고로 Thread.suspend()와 Thread.resume()은 JDK 23에서 제거되었고, Thread.stop()은 JDK 26에서 제거되었습니다. 이 메서드들은 자바 1.2부터 deprecated였고, 자바 18부터는 제거 예정(deprecated for removal)으로 표시되었습니다.
작업 결과 가져오기 (Capturing Task Results)
여러 동시 작업의 결과를 결합하고 싶은 경우가 자주 있습니다:
Future<T1> f1 = service.submit(callable1);
Future<T2> f2 = service.submit(callable2);
result = combine(f1.get(), f2.get());
가상 스레드 이전에는 블로킹하는 get() 호출이 마음에 걸렸을지도 모릅니다. 하지만 이제 블로킹은 값쌉니다. 더 구체적인 예를 든 샘플 프로그램이 여기 있습니다:
import java.util.concurrent.*;
import java.net.*;
import java.net.http.*;
public class VirtualThreadDemo {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService service = Executors.newVirtualThreadPerTaskExecutor();
Future<String> f1 = service.submit(() -> get("https://horstmann.com/random/adjective"));
Future<String> f2 = service.submit(() -> get("https://horstmann.com/random/noun"));
String result = f1.get() + " " + f2.get();
IO.println(result);
service.close();
}
private static HttpClient client = HttpClient.newHttpClient();
public static String get(String url) {
try {
var request = HttpRequest.newBuilder().uri(new URI(url)).GET().build();
return client.send(request, HttpResponse.BodyHandlers.ofString()).body();
} catch (Exception ex) {
var rex = new RuntimeException();
rex.initCause(ex);
throw rex;
}
}
}
결과 타입이 같은 작업 목록이 있다면 ExecutorService.invokeAll() 메서드를 사용한 뒤 각 Future에 대해 Future.get()을 호출할 수 있습니다:
List<Callable<T>> callables = ...;
List<T> results = new ArrayList<>();
for (Future<T> f : service.invokeAll(callables))
results.add(f.get());
역시 더 구체적인 샘플 프로그램입니다:
import java.util.*;
import java.util.concurrent.*;
import java.net.*;
import java.net.http.*;
public class VirtualThreadDemo {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService service = Executors.newVirtualThreadPerTaskExecutor();
List<Callable<String>> callables = new ArrayList<>();
final int ADJECTIVES = 4;
for (int i = 1; i <= ADJECTIVES; i++)
callables.add(() -> get("https://horstmann.com/random/adjective"));
callables.add(() -> get("https://horstmann.com/random/noun"));
List<String> results = new ArrayList<>();
for (Future<String> f : service.invokeAll(callables))
results.add(f.get());
IO.println(String.join(" ", results));
service.close();
}
private static HttpClient client = HttpClient.newHttpClient();
public static String get(String url) {
try {
var request = HttpRequest.newBuilder().uri(new URI(url)).GET().build();
return client.send(request, HttpResponse.BodyHandlers.ofString()).body();
} catch (Exception ex) {
var rex = new RuntimeException();
rex.initCause(ex);
throw rex;
}
}
}
속도 제한 (Rate Limiting)
가상 스레드는 플랫폼 스레드보다 훨씬 많은 동시 작업을 처리할 수 있기 때문에 애플리케이션 처리량을 향상시킵니다. 하지만 그만큼 작업들이 호출하는 서비스에 부담을 줄 수 있습니다. 예를 들어, 웹 서비스가 엄청나게 많은 동시 요청을 견디지 못할 수도 있습니다.
플랫폼 스레드에서는 (조금 투박하지만) 쉬운 조정 요소가 그 작업들을 위한 스레드 풀(thread pool)의 크기였습니다. 하지만 가상 스레드를 풀링해서는 안 됩니다. 가상 스레드에 작업을 스케줄링하고, 그것을 또 플랫폼 스레드에 스케줄링하는 것은 분명히 비효율적입니다. 그리고 이득이 뭘까요? 가상 스레드 수를 서비스가 견디는 소수의 동시 요청 수로 제한하려는 것인가요? 그러면 애초에 왜 가상 스레드를 쓰는 걸까요?
가상 스레드에서는 제한된 자원에 대한 접근을 제어하는 다른 메커니즘을 사용해야 합니다. 동시 작업 수에 대한 전체적인 제한 대신, 각 자원을 적절한 방식으로 보호하세요. 데이터베이스 연결의 경우 연결 풀(connection pool)이 이미 올바른 일을 해 줄 것입니다. 웹 서비스에 접근할 때는 여러분이 자신의 서비스를 잘 알고 있으니 적절한 속도 제한을 제공하면 됩니다.
예를 들어, 저의 개인 웹사이트에서는 임의 항목을 생성하는 데모 서비스를 제공합니다. 같은 IP 주소에서 한 순간에 많은 요청이 들어오면 호스팅 회사가 그 IP 주소를 블랙리스트에 올립니다.
다음 샘플 프로그램은 소수의 동시 요청만 허용하는 간단한 세마포어(semaphore)로 속도 제한을 하는 모습을 보여 줍니다. 최대치를 초과하면 Semaphore.acquire() 메서드가 블로킹되는데, 그건 괜찮습니다. 가상 스레드에서는 블로킹이 값싸기 때문입니다.
import java.util.*;
import java.util.concurrent.*;
import java.net.*;
import java.net.http.*;
public class RateLimitDemo {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService service = Executors.newVirtualThreadPerTaskExecutor();
List<Future<String>> futures = new ArrayList<>();
final int TASKS = 250;
for (int i = 1; i <= TASKS; i++)
futures.add(service.submit(() -> get("https://horstmann.com/random/word")));
for (Future<String> f : futures)
IO.print(f.get() + " ");
IO.println();
service.close();
}
private static HttpClient client = HttpClient.newHttpClient();
private static final Semaphore SEMAPHORE = new Semaphore(20);
public static String get(String url) {
try {
var request = HttpRequest.newBuilder().uri(new URI(url)).GET().build();
SEMAPHORE.acquire();
try {
Thread.sleep(100);
return client.send(request, HttpResponse.BodyHandlers.ofString()).body();
} finally {
SEMAPHORE.release();
}
} catch (Exception ex) {
ex.printStackTrace();
var rex = new RuntimeException();
rex.initCause(ex);
throw rex;
}
}
}
핀닝 (Pinning)
가상 스레드 스케줄러는 가상 스레드를 캐리어 스레드(carrier thread) 위에 마운트합니다. 기본적으로 캐리어 스레드는 CPU 코어 수만큼 있습니다. 그 개수는 jdk.virtualThreadScheduler.parallelism VM 옵션으로 조정할 수 있습니다.
가상 스레드가 블로킹 연산을 실행하면, 자신의 캐리어 스레드에서 언마운트되어야 하고, 그 캐리어 스레드는 다른 가상 스레드를 실행할 수 있습니다. 하지만 이런 언마운트가 불가능한 상황이 있습니다. 어떤 상황에서는 가상 스레드 스케줄러가 캐리어 스레드를 하나 더 시작해서 보완합니다. 예를 들어 JDK 21에서는 많은 파일 I/O 연산과 Object.wait() 호출에서 그렇게 합니다. 캐리어 스레드의 최대 개수는 jdk.virtualThreadScheduler.maxPoolSize VM 옵션으로 제어할 수 있습니다.
스레드는 다음 두 상황 중 하나에서 핀닝(pinned)되었다고 합니다:
synchronized메서드나 블록을 실행할 때 (JDK 21, 22, 23에서 해당)- 네이티브 메서드(native method)나 외부 함수(foreign function)를 호출할 때
핀닝 자체는 나쁜 것이 아닙니다. 하지만 핀된 스레드가 블로킹되면 언마운트될 수 없습니다. 캐리어 스레드가 블로킹되고, 자바 21에서는 추가 캐리어 스레드가 시작되지 않습니다. 그렇게 되면 가상 스레드를 실행할 수 있는 캐리어 스레드가 줄어듭니다.
synchronized를 인메모리 연산의 경쟁 조건(race condition)을 피하는 데 사용한다면 핀닝은 무해합니다. 하지만 블로킹 호출이 있다면 synchronized를 ReentrantLock으로 바꾸는 것이 가장 좋습니다. 물론 이는 여러분이 소스 코드를 제어할 수 있을 때만 선택할 수 있는 옵션입니다. 다시 말하지만, 이 문제는 JDK 24 이상에서 해결되었습니다.
핀된 스레드가 블로킹되었는지 알아보려면 다음 옵션 중 하나로 JVM을 시작하세요.
-Djdk.tracePinnedThreads=short
-Djdk.tracePinnedThreads=full
핀된 스레드가 블로킹될 때를 보여 주는 스택 트레이스를 얻을 수 있습니다:
...
org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49) <== monitors:1
...
참고로 핀닝 위치(pinning location)당 경고는 한 번만 출력됩니다!
또는 Java Flight Recorder로 기록한 다음, 선호하는 mission control 뷰어로 보고, VirtualThreadPinned와 VirtualThreadSubmitFailed 이벤트를 찾아보세요.
다음 샘플 프로그램은 JDK 24 이하에서 핀닝이 동작하는 모습을 보여 줍니다. synchronized 메서드 안에서 잠드는 여러 가상 스레드를 시작해서 캐리어 스레드를 블로킹합니다. 그리고 아무 작업도 하지 않는 가상 스레드 몇 개를 추가합니다. 하지만 캐리어 스레드 풀이 완전히 고갈되어 있어서 그들은 스케줄링될 수 없습니다. 이 문제는 다음과 같은 경우에 사라진다는 점에 유의하세요:
ReentrantLock을 사용할 때- 가상 스레드를 사용하지 않을 때
- JDK 24 이상에서 코드를 실행할 때
import java.util.concurrent.*;
import java.util.concurrent.locks.*;
public class PinningDemo {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService service =
Executors.newVirtualThreadPerTaskExecutor();
// Executors.newCachedThreadPool();
final int TASKS = 20;
long start = System.nanoTime();
for (int i = 1; i <= TASKS; i++) {
service.submit(() -> block());
// service.submit(() -> rblock());
}
for (int i = 1; i <= TASKS; i++) {
service.submit(() -> noblock());
}
service.close();
long end = System.nanoTime();
System.out.printf("%.2f%n", (end - start) * 1E-9);
}
public static synchronized void block() {
IO.println("Entering block " + Thread.currentThread());
LockSupport.parkNanos(1_000_000_000);
IO.println("Exiting block " + Thread.currentThread());
}
private static Lock lock = new ReentrantLock();
public static void rblock() {
lock.lock();
try {
IO.println("Entering rblock " + Thread.currentThread());
LockSupport.parkNanos(1_000_000_000);
IO.println("Exiting rblock " + Thread.currentThread());
} finally {
lock.unlock();
}
}
public static void noblock() {
IO.println("Entering noblock " + Thread.currentThread());
LockSupport.parkNanos(1_000_000_000);
IO.println("Exiting noblock " + Thread.currentThread());
}
}
스레드 로컬 변수 (Thread Locals)
스레드 로컬 변수(thread-local variable)는 ThreadLocal 클래스의 인스턴스로, 그 get()과 set() 메서드가 현재 스레드에 따라 달라지는 값에 접근합니다. 왜 전역 변수나 지역 변수 대신 이런 것을 쓰고 싶을까요? 고전적인 사용처는 SimpleDateFormat처럼 스레드에 안전하지 않은(thread safe) 서비스나, 난수 생성기처럼 경합(contention)이 발생하기 쉬운 것입니다. 스레드별 인스턴스는 락으로 보호되는 전역 인스턴스보다 더 나은 성능을 낼 수 있습니다.
스레드 로컬 변수의 또 다른 흔한 용도는 각 작업에 맞게 올바르게 구성된 데이터베이스 연결과 같은 "암시적(implicit)" 컨텍스트를 제공하는 것입니다. 컨텍스트를 메서드에서 메서드로 전달하는 대신, 작업 코드는 데이터베이스에 접근해야 할 때마다 스레드 로컬 변수를 그냥 읽기만 합니다.
가상 스레드로 전환할 때 스레드 로컬 변수는 문제가 될 수 있습니다. 스레드 풀 안의 스레드보다 가상 스레드가 훨씬 많을 가능성이 있고, 그러면 스레드 로컬 인스턴스도 훨씬 많아집니다. 이런 상황에서는 공유 전략을 다시 생각해야 합니다.
애플리케이션에서 스레드 로컬 변수의 사용처를 찾으려면 jdk.traceVirtualThreadLocals VM 플래그로 실행하세요. 가상 스레드가 스레드 로컬 변수를 변경할 때 스택 트레이스를 얻을 수 있습니다.
결론 (Conclusion)
- 대부분 네트워크 I/O에서 블로킹되는 작업이 많을 때 가상 스레드로 처리량을 높이세요.
- 가장 큰 이점은 콜백 없이 익숙한 "동기(synchronous)" 프로그래밍 스타일을 쓸 수 있다는 점입니다.
- 가상 스레드를 풀링하지 말고, 속도 제한에는 다른 메커니즘을 사용하세요.
- 핀닝이 있는지 확인하고, 필요하면 완화하세요.
- 가상 스레드에서 스레드 로컬 변수를 최소화하세요.