|

Java virtual threads란 무엇인가: 기존 Thread와 Executor 코드가 어떻게 달라질까

Java virtual threads란 무엇인가: 기존 Thread와 Executor 코드가 어떻게 달라질까
virtual threads는 코드를 비동기 콜백으로 바꾸지 않고도 많은 blocking 작업을 더 작게 다룰 수 있게 해준다

Java virtual threads는 Java 21 이후 서버 동시성 이야기를 할 때 빠지지 않는 주제입니다. 이름만 보면 기존 Thread의 더 빠른 버전처럼 보이지만, 핵심은 속도가 아니라 blocking I/O가 많은 작업을 더 많은 수의 가벼운 thread로 다룰 수 있게 하는 것입니다.

이 글에서는 virtual thread가 기존 platform thread와 무엇이 다른지, ExecutorService 코드는 어떻게 바뀌는지, 언제 쓰면 좋고 언제 조심해야 하는지 정리하겠습니다. Java 기본 API 선택 기준은 자바 Optional 글처럼 ‘문법이 생겼으니 무조건 쓴다’가 아니라 역할부터 나눠 봐야 합니다.

Java virtual threads 핵심 판단 기준 카드 이미지
virtual threads는 더 빠른 thread가 아니라 I/O 대기 많은 작업을 더 큰 규모로 다루기 위한 가벼운 thread다

Java virtual threads란 무엇인가

Oracle Java 21 문서는 platform thread를 OS thread의 얇은 wrapper로 설명합니다. 기존 Java Thread는 실제 운영체제 thread와 강하게 연결되어 있고, thread 수가 많아질수록 OS thread라는 제한에 부딪히기 쉽습니다.

반면 virtual thread도 java.lang.Thread의 인스턴스이지만 특정 OS thread에 고정되지 않습니다. virtual thread가 blocking I/O를 만나면 Java runtime이 해당 virtual thread를 잠시 멈추고, carrier OS thread는 다른 virtual thread를 실행할 수 있습니다.

  • platform thread: OS thread와 강하게 연결된 기존 thread
  • virtual thread: Java runtime이 많은 수를 가볍게 관리하는 thread
  • carrier thread: virtual thread를 실제로 실행하는 OS thread
  • 목표: latency를 낮추기보다 높은 동시성 규모를 더 단순한 코드로 다루는 것

왜 virtual threads가 나왔을까

JEP 444는 virtual threads의 목표를 thread-per-request 스타일의 서버 애플리케이션이 더 잘 확장되도록 하는 데 둡니다. 웹 서버는 요청마다 DB, 외부 API, 파일, 네트워크 응답을 기다리는 일이 많습니다.

기존 platform thread만으로 요청마다 thread를 붙이면 코드는 단순하지만 thread 수가 병목이 됩니다. 반대로 callback이나 reactive 스타일로 바꾸면 thread는 아낄 수 있지만 코드 흐름이 복잡해질 수 있습니다. virtual threads는 이 사이에서 단순한 blocking 코드 스타일을 유지하면서 더 많은 동시 요청을 다루려는 선택지입니다.


기존 Thread 코드와 어떻게 다를까

가장 작은 예시는 Thread.ofVirtual()입니다. 기존 Thread처럼 Runnable을 넘기지만 virtual thread로 실행됩니다.

Thread thread = Thread.ofVirtual().start(() -> {
    System.out.println("Hello from virtual thread");
});

thread.join();

하지만 실무에서는 thread를 직접 만들기보다 ExecutorService를 통해 작업 제출 방식을 통일하는 편이 더 자연스럽습니다.

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<String> result = executor.submit(() -> {
        return callExternalApi();
    });

    System.out.println(result.get());
}

Executors.newVirtualThreadPerTaskExecutor()는 task마다 새 virtual thread를 만들어 실행합니다. 기존 fixed thread pool처럼 thread 개수를 미리 작게 제한해 재사용하는 사고와 다릅니다.


virtual threads가 특히 잘 맞는 작업

  • 요청마다 DB query, HTTP 호출, 파일 I/O처럼 기다림이 많은 서버 작업
  • 기존 blocking 라이브러리를 많이 쓰는 애플리케이션
  • callback/reactive 스타일로 바꾸기에는 코드 복잡도가 큰 서비스
  • 요청 단위 stack trace와 디버깅 흐름을 유지하고 싶은 서버 코드

중요한 기준은 I/O 대기입니다. virtual threads는 CPU 계산을 더 빠르게 끝내는 도구가 아닙니다. Oracle 문서도 virtual threads는 faster threads가 아니며, speed보다 scale을 제공한다고 설명합니다.


virtual threads가 잘 맞지 않는 경우

CPU를 오래 붙잡는 계산 작업은 virtual threads로 바꾼다고 빨라지지 않습니다. 예를 들어 큰 이미지 처리, 암호화 연산, 대규모 숫자 계산처럼 CPU가 계속 바쁜 작업은 병렬 처리 전략이나 별도 작업 큐를 검토해야 합니다.

// virtual thread로 실행해도 CPU 계산 자체가 빨라지는 것은 아니다.
long sum = 0;
for (long i = 0; i < 1_000_000_000L; i++) {
    sum += i;
}

또 하나 조심할 부분은 ThreadLocal입니다. virtual thread를 매우 많이 만들 수 있기 때문에 ThreadLocal에 큰 객체를 담거나 무거운 컨텍스트를 계속 붙이는 패턴은 비용이 커질 수 있습니다.


실무 적용 전에 확인할 체크리스트

  1. 현재 병목이 CPU인지 I/O 대기인지 먼저 확인한다
  2. 사용 중인 JDBC, HTTP client, framework가 virtual thread와 잘 맞는지 확인한다
  3. thread pool 크기로 동시성을 제한하던 기존 사고를 그대로 옮기지 않는다
  4. DB connection pool, 외부 API rate limit 같은 실제 병목도 함께 본다
  5. ThreadLocal 사용량과 request context 전달 방식을 점검한다
  6. 부하 테스트로 throughput, latency, memory, connection 사용량을 함께 본다

virtual threads를 도입해도 DB connection pool이 20개라면 동시에 처리할 수 있는 DB 작업은 결국 그 제한을 받습니다. thread를 많이 만들 수 있다는 것과 외부 자원을 무제한으로 쓸 수 있다는 것은 다른 이야기입니다.


마무리

Java virtual threads는 기존 Thread를 대체하는 마법이 아닙니다. blocking I/O가 많은 서버 작업에서 thread-per-request 스타일을 더 단순하게 유지하면서 높은 동시성 규모를 다루기 위한 도구입니다.

정리하면, virtual threads는 ‘더 빠른 thread’가 아니라 ‘더 많이 만들 수 있는 가벼운 thread’에 가깝습니다. CPU 작업보다 I/O 대기가 많고, 기존 blocking 코드 스타일을 유지하고 싶을 때 가장 먼저 검토할 만합니다.

함께보면 좋은 글