Virtual Thread

신예찬·2025년 10월 30일

소프트웨어 개발을 하다보면 컴퓨팅 자원이 얼마나 소중한지 깨닫게 되는 경우가 매우 많다. 현대의 서버 애플리케이션은 정말 많은 요청을 처리해야하는 경우가 많고 이 요청들을 처리하기 위해서 자바 진영에서는 Thread라는 자원을 사용해 요청을 처리할 수 있었다. 이번 포스팅에서 기존 Thread를 경량화한 Virtual Thread에 대해 살펴보고 언제 쓰면 좋을지 고민해보자.

Request Per Thread를 해결해보자

기존의 자바 서버 애플리케이션은 Tomcat 기반으로 구성되어왔다. Tomcat은 클라이언트의 요청이 들어올 때마다 해당 요청을 처리하기 위한 단위로 Thread를 할당한다. 하지만 이 Thread는 생성 비용이 크고, 운영체제 레벨의 스레드이기 때문에 한정된 자원만 사용할 수 있다.

그래서 Tomcat은 매 요청마다 새 스레드를 만들지 않고, 미리 일정 수의 스레드를 만들어 Thread Pool로 관리한다. 그러나 동시에 처리 가능한 요청 수는 이 풀의 크기에 의해 제한된다. 풀의 크기보다 더 많은 요청이 들어오면 일부 요청은 대기하거나, 심한 경우 타임아웃이 발생한다.

이를 해결하기 위해 더 적은 스레드로 많은 요청을 효율적으로 처리하는 비동기 논블로킹(Non-Blocking I/O) 모델이 등장했다. 자바 진영에서는 이를 Reactive Programming으로 발전시켜 RxJava, Project Reactor, Spring WebFlux 같은 기술들이 등장했다. 자원을 점유하지 않고 Non-Blocking I/O를 통해 훨씬 많은 요청을 동시에 처리할 수 있게 한다.

하지만 Reactive 패러다임은 몇가지 불편한 요소들이 존재한다.

  • 코드의 실행 순서가 명확하지 않아 디버깅이 어렵다.
  • 기존의 동기식 코드 구조와 달라 러닝 커브가 높다.
  • StackTrace 추적이 복잡하고, 예외 전파가 직관적이지 않다.
  • IDE의 디버거나 로깅 시스템이 Reactive 흐름을 완전히 지원하지 못한다.

결국 Reactive Programming은 고성능 환경에서는 탁월했지만, 모든 개발자나 모든 프로젝트에 쉽게 적용하기에는 부담스러운 기술이었다.

그래서 자바 진영에서는 기존 패러다임을 지키면서 자원 효율을 높이기 위한 노력들이 계속되었고, Project Loom에서 개발자가 익숙한 동기 코드 스타일을 유지하면서도 비동기 성능을 제공하기 위한 시도가 이후 Java 21에 정식 스펙으로 도입되며 Virtual Thread로 발전하게 되었다.

Virtual Thread

Virtual Thread(후술할때는 VThread로 축약하겠다)의 기본 컨셉은 다음과 같다.

  • 자원을 생성하고 관리하는 영역을 기존의 OS에서 JVM으로 이동한다.
  • Thread 대신 사용되는 VThread는 실제 Thread와 호환된다.
  • VThread는 JVM의 Carrier Thread에 의해 스케줄링 된다.
  • 스케줄링 되기 위한 작업들은 WorkSteelingQueue에 적재되고 Carrier Thread는 여기서 처리할 작업을 가져와 작업을 수행한다.
  • I/O, wait등 작업을 수행하려 하면 VThread는 대기 상태로 전환되고 WorkSteelingQueue에서 제거된다. 후에 다시 호출될때 ForkJoinPool에 들어와 처리를 마저 할 수 있게된다.

작업의 흐름을 보면 알겠지만 I/O bound의 작업과 같이 대기에 비용이 큰 작업들을 처리하려 할 때는 해당 작업을 나중에 실행시키기 위해 넘긴다. 과거에는 연산 속도가 그리 빠르지 않아 I/O 처리가 꽤나 합리적이였는지는 모르겠지만 현대의 컴퓨팅 능력은 I/O 처리를 위한 대기시간이 더 많은 비용을 차지한다. 기존의 Thread Per Request 모델에서는 이러한 대기시간이 길다는 문제는 곧 자원 점유 시간이 늘어난다는 말과 같다. 그래서 VThread는 이런 문제를 해결하기 위해 Continuation이라는 개념을 도입한다.

Continuation

Continuation은 실행 중인 호출 스택의 상태(로컬 변수, 리턴 지점, 중첩 호출 등)를 통째로 캡처해 두었다가, 나중에 정확히 그 지점부터 다시 이어서 실행할 수 있게 해주는 재개 가능한 실행 단위다.

앞서 VThread가 I/O 같은 블로킹 작업을 수행할 때 Work-Stealing Queue에서 제거된다고 했었다.
이후 작업이 다시 재개되려면 "어디서부터 이어서 실행할지"를 알아야 하는데, 이때 자바는 Continuation 객체를 만들어 재개 지점(resume point) 을 기록한다.

이 객체는 JVM의 힙(Heap) 영역에 저장되며, 이는 일반적인 메서드 호출 스택이 스택(Stack) 메모리에 존재하는 것과는 대조적이다. 즉, 일시 중단된 실행 컨텍스트(스택 프레임)는 잠시 힙으로 옮겨져 있다가, 재개 시점에 다시 스택으로 복원되어 이어서 실행된다.

코틀린의 Coroutine을 사용해본 개발자라면, Continuation이 매우 비슷한 개념이라는 걸 직관적으로 느낄 수 있을 것이다. 다만 코루틴은 "함수 단위로 정의된 비동기 흐름"을 다루는 언어 수준의 개념이고, 자바의 Continuation은 함수의 실행 상태 자체를 JVM 레벨에서 저장·복원하는 좀 더 저수준의 런타임 메커니즘이라는 점이 다르다.

Continuation의 상태

Continuation은 크게 세개의 상태를 가진다

  • 실행(Resume)
    Continuation이 캐리어 스레드에 마운트(Mount) 되어 실제 코드를 수행하는 단계다.
    이 시점에는 스택 프레임이 스택 메모리에 존재하며, CPU를 점유한 상태로 코드가 정상 실행된다.

  • 중단(Yield)
    실행 중 I/O, 대기(wait), sleep 등의 블로킹 지점이 발생하면 Continuation은 yield()를 호출하며 현재의 실행 상태를 캡처한다. 호출 스택은 힙 영역으로 옮겨지고, Continuation은 일시 중단(Suspended) 상태로 전환된다. 그 즉시 캐리어 스레드는 반환되어 다른 Continuation의 실행을 맡을 수 있다.

  • 재개(Resume)
    대기하던 I/O가 완료되거나 신호가 오면, 스케줄러는 해당 Continuation을 다시 선택한다. Continuation은 힙에 저장해둔 스택 프레임을 복원해 중단되었던 정확한 지점부터 다시 실행된다. 개발자 입장에서는 블로킹 후 이어 실행된 것처럼 보이지만, 실제로는 JVM이 내부에서 언마운트 -> 리마운트를 수행한다.

그리고 이 Continuation은 하나의 ForkJoinPool에 의해 스케줄링 된다. 스케줄러가 static인 모습을 확인할 수 있다.

VirtualThread가 실행되면 내부적으로 runContinuation을 호출해 Continuation을 실행하게 된다.

만약 작업이 중단되어야 하는 상황에서는 park를 호출한다.

그럼 Continuation을 대기 상태로 바꾸어 재개되지 못하고, 다시 mount하여 작업을 재개할 수 있도록 finally 구문에서 처리된다.

Virtual Thread의 장점과 주의점

Virtual Thread의 가장 큰 장점은 기존의 동기식 코드 스타일을 유지하면서도 Reactive 수준의 동시성 성능을 얻을 수 있다는 점이다. 즉, 개발자는 기존의 익숙한 코드를 그대로 사용하면서도, 내부적으로는 수천·수만 개의 요청을 동시에 처리할 수 있다.

다만 절대적으로 우수하다고 할수는 없는거같다. 기본적으로 모든 처리가 아닌 일부 비동기 처리가 가능한 작업들의 down time을 활용해 성능을 높힌것을 인지해야한다. 즉, CPU bound 작업이 기존의 Platform Thread를 상회하는 성능을 낼 수 없다는 것이다. 오히려 Continuation을 관리하는 측면에서 속도가 떨어지기도 하기 때문에 꼭 위에서 언급한 Yield 가능한 blocking 처리 위주의 작업에서 유리하다는 것을 인지해야한다.

뿐만 아니라 VThread로 요청 처리를 위한 자원이 늘어난것이 절대적으로 좋지는 않다는것도 알아야한다. 애플리케이션이 DBCP의 한계를 넘는 요청을 받을 수 있다고 하더라도 어차피 DBCP가 받아주지를 않는다.

객체 형태로 운영되는 문제도 일부 존재한다. VThread는 자원을 객체 형태로 가지고 있다보니 ThreadLocal에 너무 많은 데이터를 가지고 있는 경우에는 큰 문제로 작용할 수 있다. 아주 무거운 객체를 ThreadLocal이 가지고 있을때 사용자가 많은 요청을 하면 감당할 수 없는 메모리 수요가 발생하기에 이점을 유의해야한다.

마지막으로 Pinning 현상도 큰 문제가 된다. Pinning 현상은 VThread가 원래 의도대로 unmount 되어야 하는 시점에 그렇게 하지 못하고 Carrier Thread를 계속 점유한 채 멈춰 있는 현상을 말한다. 주로 JVM이 스택 상태를 안전하게 떼어낼 수 없을 때 발생한다. synchronized 영역이나 native 영역을 다룰때 발생하는 문제다. 다만 JDK 24에서 이를 해결하기 위한 방법을 적용했다고 하니 기회가 되면 자세히 확인해보려 한다.

Continuations: The magic behind virtual threads in Java by Balkrishna Rawool @ Spring I/O 2024

스프링캠프 2025 [Track 2] 4. Virtual Thread 어디까지 보고 오셨어요? (박성훈, 정승주)

Continuation and Virtual Threads in Java: A New Concurrency Paradigm
Java의 미래, Virtual Thread

0개의 댓글