가상 스레드를 풀링하면 안 되는 이유
습관적으로 이렇게 쓰면 안 됩니다
플랫폼 스레드 시절부터 넘어온 개발자라면 다음 코드가 자연스러워 보일 겁니다.
// 이렇게 쓰지 마세요
ExecutorService virtualPool = Executors.newFixedThreadPool(
200, Thread.ofVirtual().factory()
);의도는 이해가 갑니다. "가상 스레드도 스레드니까, 풀로 관리하면 재사용 효율이 좋아지지 않을까"라는 생각입니다. 결론부터 말하면 이건 효율을 높이기는커녕 가상 스레드의 존재 이유를 없앱니다.
왜 안 되는가
스레드 풀은 애초에 "스레드를 만드는 비용이 비싸니 재사용하자"는 문제를 풀기 위한 도구입니다. 앞서 본 것처럼 플랫폼 스레드는 생성 비용이 실제로 큽니다 — 스택 메모리 예약, 커널 자료구조 할당. 이 비용을 매번 지불하지 않으려고 풀을 만들고 재사용하는 겁니다.
가상 스레드는 이 전제 자체가 다릅니다. 생성 비용이 마이크로초 이하로 작고, 스택도 필요한 만큼만 자랍니다. "비싸니까 재사용"이라는 전제가 성립하지 않는 자원을 굳이 풀에 가둬서 재사용하는 건, 얻는 것 없이 잃는 것만 만듭니다.
무엇을 잃느냐면, 고정 크기 풀은 최대 동시 실행 수를 인위적으로 제한합니다. newFixedThreadPool(200, ...) 을 가상 스레드로 만들면, 동시에 실행 가능한 가상 스레드가 200개로 묶여버립니다. 이건 정확히 플랫폼 스레드 시절의 병목을 그대로 재현하는 겁니다. 가상 스레드를 쓴 이유가 "수천, 수만 개를 동시에 실행하고 싶어서"였는데, 풀에 가두는 순간 그 목적이 사라집니다.
그럼 어떻게 써야 하는가
정답은 단순합니다. 필요할 때마다 새로 만들고, 끝나면 버립니다.
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Order order : orders) {
executor.submit(() -> processOrder(order));
}
} // try-with-resources 종료 시 제출된 작업이 모두 끝날 때까지 자동으로 대기newVirtualThreadPerTaskExecutor() 는 이름 그대로 작업(task)마다 새 가상 스레드를 하나씩 만듭니다. "풀"이라는 이름이 붙어 있지 않다는 점을 눈여겨보세요 — 이건 재사용 가능한 스레드 집합이 아니라, 매번 새로 만들고 버리는 걸 감싼 ExecutorService 어댑터일 뿐입니다.
ExecutorService 가 AutoCloseable 을 구현하게 된 것도 이 패턴을 위해서입니다. Java 19 이후 close() 는 새 작업 제출을 막고 제출된 작업이 모두 끝날 때까지 기다립니다. 위 코드처럼 try-with-resources 로 감싸면 별도의 shutdown(), awaitTermination() 호출이 필요 없습니다.
가비지 컬렉터를 믿어야 하는 이유
"수만 개를 만들고 버리면 GC 부담이 크지 않을까"라는 의문이 자연스럽게 듭니다. 실제로는 가상 스레드의 스택이 작고, 짧게 살고 죽는 객체는 대부분 Young Gen 에서 빠르게 회수됩니다. 현대 GC(G1, ZGC, Shenandoah)는 바로 이런 "짧게 살고 많이 생기는" 패턴을 처리하도록 최적화돼 있습니다. 벤치마크 결과들을 보면 스레드 풀을 유지 관리하는 비용(락 경합, 큐 관리)이 오히려 GC 비용보다 더 크게 잡히는 경우가 많습니다.
물론 이건 "무조건 믿어라"가 아니라 "실제로 측정해서 확인하라"는 뜻입니다. 트래픽 패턴이 특이하거나 가상 스레드 안에서 예외적으로 무거운 객체를 계속 할당하는 코드라면 얘기가 달라질 수 있습니다. 다만 기본 방향은 명확합니다 — 가상 스레드를 도입했다면 풀링 코드부터 걷어내는 게 순서입니다. 풀로 감싸 놓고 가상 스레드의 이점만 기대하는 건 앞뒤가 안 맞습니다.