Java의 미래, Virtual Thread

Dec.12.2023 김태헌

Backend

안녕하세요! 회원프로덕트팀 서버개발자 김태헌입니다.
이번 글에서는 팀에서 진행했던 Virtual Thread 스터디를 바탕으로 Java의 경량스레드 모델인 Virtual Thread를 소개하고자 합니다.

Virtual Thread를 스터디 주제로 선정하게 된 일화가 있습니다.

쇄국정책을 펼치시는 두 어르신

보시다시피 저희 팀엔 외세의 침략을 막는 흥선대원군 두 분이 계셔서, 모든 프로젝트는 Java로 만들어져 있었습니다. Java만을 고집하던 중 2021년에 사용자 인증 게이트웨이 시스템을 개발하게 되었는데, I/O가 많이 발생하고 병목이 큰 장애로 전파될 수 있는 지점이어서 Kotlin의 coroutine을 활용하자는 의견이 나왔습니다. 그 당시엔 Virtual Thread가 JDK 정식 feature가 아니어서 coroutine이 유일한 선택지였습니다.

의견을 수렴하여 게이트웨이 시스템을 Kotlin으로 개발하게 되었지만, 가끔 Kotlin 프로젝트를 보면 생소한 문법과 형식 때문에 낯설고, 머리가 지끈지끈 아파왔습니다. 얼마 지나지 않아 코루틴을 대체할 수 있는 Virtual Thread(Project Loom)가 JDK19 얼리 액세스 feature로 포함되었다는 소식을 듣자마자 팀 스터디를 진행하게 되었습니다.

경량 스레드 모델들

Go의 goroutine을 아신다면, 경량 스레드 모델도 들어보셨을 겁니다. 기존 언어의 스레드 모델보다 더 작은 단위로 실행 단위를 나눠 컨텍스트 스위칭 비용과 Blocking 타임을 낮추는 개념입니다.

JVM 진영에는 대표적으로 2017년, Kotlin 1.1에 처음 도입된 Kotlin의 coroutine이 존재했습니다.

2023년 9월에 정식 출시된 JDK21에 경량 스레드 모델 Virtual Thread가 정식 Feature로 포함되면서, 드디어 Java에서도 경량 스레드 모델을 다룰 수 있게 되었습니다.

그렇다면 Virtual Thread에 대해 알아보겠습니다!


Virtual Thread란?


기존 Java의 스레드 모델은 Native Thread로, Java의 유저 스레드를 만들면 Java Native Interface(JNI)를 통해 커널 영역을 호출하여 OS가 커널 스레드를 생성하고 매핑하여 작업을 수행하는 형태였습니다.


Java의 스레드는 I/O, interrupt, sleep과 같은 상황에 block/waiting 상태가 되는데, 이때 다른 스레드가 커널 스레드를 점유하여 작업을 수행하는 것을 ‘컨텍스트 스위치’라고 합니다.

이러한 스레드 모델은 기존 프로세스 모델을 잘게 쪼개 프로세스 내의 공통된 부분은 공유하면서, 작은 여러 실행단위를 번갈아 가면서 수행할 수 있도록 만들었습니다. 스레드는 프로세스의 공통영역을 제외하고 만들어지기 때문에, 프로세스에 비해 크기가 작아서 생성 비용이 적고, 컨텍스트 스위칭 비용이 저렴했기 때문에 주목받아 왔습니다.

그러나, 요청량이 급격하게 증가하는 서버 환경에서는 갈수록 더 많은 스레드 수를 요구하게 되었습니다. 스레드의 사이즈가 프로세스에 비해 작다고 해도, 스레드 1개당 1MB 사이즈라고 가정하면, 4GB 메모리 환경에서도 많아야 4,000개의 스레드를 가질 수 있습니다. 이처럼 메모리가 제한된 환경에서는 생성할 수 있는 스레드 수에 한계가 있었고, 스레드가 많아지면서 컨텍스트 스위칭 비용도 기하급수적으로 늘어나게 되었습니다.

이런 한계를 겪던 서버는 더 많은 요청 처리량과 컨텍스트 스위칭 비용을 줄여야 했는데, 이를 위해 나타난 스레드 모델이 경량 스레드 모델인 Virtual Thread입니다.

Virtual Thread는 기존 Java의 스레드 모델과 달리, 플랫폼 스레드와 가상 스레드로 나뉩니다. 플랫폼 스레드 위에서 여러 Virtual Thread가 번갈아 가며 실행되는 형태로 동작합니다. 마치 커널 스레드와 유저 스레드가 매핑되는 형태랑 비슷합니다.

여기서 가장 큰 특징은 Virtual Thread는 컨텍스트 스위칭 비용이 저렴하다는 것입니다.

Thread Virtual Thread
Stack 사이즈 ~2MB ~10KB
생성시간 ~1ms ~1µs
컨텍스트 스위칭 ~100µs ~10µs

Thread는 기본적으로 최대 2MB의 스택 메모리 사이즈를 가지기 때문에, 컨텍스트 스위칭 시 메모리 이동량이 큽니다. 또한 생성을 위해선 커널과 통신하여 스케줄링해야 하므로, 시스템 콜을 이용하기 때문에 생성 비용도 적지 않습니다.

하지만 Virtual Thread는 JVM에 의해 생성되기 때문에 시스템 콜과 같은 커널 영역의 호출이 적고, 메모리 크기가 일반 스레드의 1%에 불과합니다. 따라서 Thread에 비해 컨텍스트 스위칭 비용이 적습니다.

Virtual Thread 톺아보기

Virtual Thread가 Thread 모델보다 성능이 좋은 이유를 구조와 동작 흐름을 통해 알아보겠습니다.

Virtual Thread의 구조

우선 Platform Thread의 기본 스케줄러는 ForkJoinPool을 사용하는데요. 스케줄러는 platform thread pool을 관리하고, Virtual Thread의 작업 분배 역할을 합니다.

디버거를 통해 런타임의 Virtual Thread를 살펴보면

  • VirtualThread는 carrierThread를 가지고 있습니다. 실제로 작업을 수행시키는 platform thread를 의미합니다. carrierThreadworkQueue를 가지고 있습니다.
  • VirtualThread는 scheduler라는 ForkJoinPool을 가지고 있습니다. carrier thread의 pool 역할을 하고, 가상 스레드의 작업 스케줄링을 담당합니다.
  • VirtualThread는 runContinuation 이라는 Virtual Thread의 실제 작업 내용(Runnable)을 가지고 있습니다.

이걸 바탕으로 Virtual Thread의 동작 원리도 알아보겠습니다.

Virtual Thread의 동작 원리

  1. 실행될 virtual thread의 작업인 runContinuation을 carrier thread의 workQueue에 push 합니다.
  2. Work queue에 있는 runContinuation들은 forkJoinPool에 의해 work stealing 방식으로 carrier thread에 의해 처리됩니다.
  3. 처리되던 runContinuation들은 I/O, Sleep으로 인한 interrupt나 작업 완료 시, work queue에서 pop되어 park과정에 의해 다시 힙 메모리로 되돌아갑니다.

기존의 스레드 모델에서 virtual thread의 park, unpark 동작을 통해 virtual thread의 컨텍스트 스위칭을 하는 형태로 동작한다는 것을 알 수 있습니다. 그렇다면 이런 park/unpark가 실제로 어떻게 동작하는지 확인해 보겠습니다.

Virtual Thread의 park/unpark

Virtual Thread의 unpark

위의 VirtualThread.unpark() 메서드를 살펴보면, unpark 될 수 있는 Virtual Thread 중 submitRunContinuation() 메서드를 통해 scheduler를 통해 runContinuation을 execute하는 것을 확인하실 수 있습니다. 이렇게 execute된 runContinuation은 carrirer thread의 work queue에 스케줄링 됩니다.

기존 스레드 모델과 비교

unpark메서드. 왼쪽 JDK17, 오른쪽 JDK21

기존의 스레드 모델에서의 park/unpark 개념은 LockSupport.class를 통해 네이티브 메서드로 제공하던 기능이었습니다. Reactive 환경에서 컨텍스트 스위칭이 발생하는 상황처럼, thread를 대기시키고 다른 thread를 수행해야 하는 상황을 떠올리시면 됩니다.
JDK21에서부터는 LockSupport에 Virtual Thread 판단 로직을 추가하여, 현재 스레드가 Virtual Thread인 경우 virtual Thread의 park/unpark 되도록 하여 기존 Thread모델과 완벽하게 호환되면서, Virtual Thread 기반 컨텍스트 스위칭이 가능하도록 하였습니다.

그렇다면 I/O가 발생했을 때 어떻게 경량 스레드가 park가 되는지도 알아보겠습니다.

NIOSocketImpl의 park 메서드. 왼쪽 JDK17, 오른쪽 JDK21

기존 Reactive 방식에서는, Socket 통신 시 park 메서드 내에서 Net.poll()을 통해 커널영역에 스레드 park를 요청함으로써, 컨텍스트 스위칭을 수행하였습니다.

JDK 21에서는 NIOSocketImpl.park()메서드에 virtual thread 판단 로직을 추가하여, 현재 스레드가 virtual Thread인 경우 Poller.poll()을 통해 내부적으로 Virtual Thread의 park를 수행하여 virtual thread 컨텍스트 스위칭을 가능하게 하였습니다.

마지막으로 Thread가 Sleep 상태에서도 가상스레드 park가 수행되는지 살펴보겠습니다.

Thread.sleep 메서드. 왼쪽 JDK17, 오른쪽 JDK21

Thread.sleep() 메서드를 살펴보면, 마찬가지로 virtual thread 분기가 생겨서, virtual thread인 경우 virtual thread 기반 park가 됩니다.

이번 Virtual Thread 기능은 기존 Thread 모델에서 Native 기반으로 동작하던 park/unpark 로직에 대해 Virtual Thread 분기를 추가해, 기존 Thread 방식에서 특별한 코드 수정 없이 Virtual Thread기반의 컨텍스트 스위칭을 가능하게 하였습니다.

스레드 모델 간 비교

성능테스트, 스레드 모델의 특성, 사용성 등을 고려해 개인적으로 생각했던 장단점들을 비교해 보았습니다.

성능테스트는 Ngrinder를 활용했습니다. Virtual thread의 효과를 극대화하기 위해(약을 잘 팔기 위해) 최대한 극한의 상황을 가정했습니다. 테스트하기 위해 애플리케이션 스펙을 최소 사양으로 두고, 256MB의 힙 사이즈를 사용하도록 설정하였습니다. 테스트는 300ms를 sleep하는 API를 3번 호출하는 Request I/O Bound 작업, 0~300000000까지 합을 3번 계산하는 CPU Bound 작업으로 진행하였습니다.

실제 서비스 상황과는 다소 차이가 있다는 점 참고하여 가볍게 봐주시면 좋을 것 같습니다.

Thread vs Virtual Thread

public String ioBound() {
        requestSleep().block(); //Thread.sleep(300) API 호출
        requestSleep().block();
        requestSleep().block();

    return "ok";
}

public Integer cpuBound() {
        IntStream.range(0, 300000000).reduce(0, Integer::sum);
        IntStream.range(0, 300000000).reduce(0, Integer::sum);
        return IntStream.range(0, 300000000).reduce(0, Integer::sum);
}

I/O Bound 작업에서 Virtual Thread의 성능은 Thread 모델에 비해 약 51% 이상 향상되었습니다. 실제로는 더 많은 성능차이가 있으나, 테스트 과정에서 이슈가 발생하였습니다.

우선 적절한 vuser 수를 설정하기 위해 테스트를 해보았는데, Ngrinder의 동시 요청 수를 계속해서 늘리다 보니 vuser(가상 사용자 수)가 250이 넘어가는 시점부터 Thread 모델에서는 서버가 죽고 응답을 정상적으로 주지 못하는 상황이 발생하였습니다. 반면에 virtual thread를 사용하는 서버에서는 동일한 vuser수에도 장애 없이 정상 처리할 수 있었습니다.

vuser를 일반 thread모델이 소화 가능한 낮은 숫자로 설정했기 때문에, 더 드라마틱한 차이가 발생하지는 않았습니다. 이 테스트를 통해 제한된 성능에서 가상스레드 모델이 더 높은 처리량과 더 빠른 처리속도를 보여준다는걸 확인할 수 있었습니다.

반면 CPU Bound 작업에서는 일반 스레드 모델이 성능상 우위를 보였는데요. 경량 스레드가 결국 플랫폼 스레드 위에서 동작하기 때문에, CPU Bound 작업과 같은 Virtual Thread가 Switching 되지 않는 경우에는 Platform Thread 사용 비용뿐만 아니라 Virtual Thread 생성 및 스케줄링 비용까지 포함되어 성능 낭비가 발생되기 때문입니다. 이를 두고 Java에서는 이렇게 말했습니다.

It is more expensive to run a task in a virtual thread than running it in a platform thread.

Virtual Thread vs Kotlin Coroutine

fun ioBound(): String? {
    return CoroutineScope(Dispatchers.IO).async {
        requestSleep().awaitFirstOrNull() // api call
        requestSleep().awaitFirstOrNull()
        requestSleep().awaitFirstOrNull()
    }.await();
}

코루틴 모델은 앞서 테스트한 스레드 모델보다 더 많은 처리량을 가지기 때문에, 이전 테스트의 vuser의 4배인 510으로 두고 I/O bound 요청 테스트를 진행하였습니다. 성능테스트 결과 Virtual Thread의 성능이 Kotlin coroutine에 비해 37% 좋은 성능을 보였습니다.

컴파일러가 코루틴을 만드는 방식

Kotlin Coroutine은 virtual thread이 JDK 자체적으로 지원하는 것과는 다르게 Kotlin 컴파일러의 마법으로 가능합니다. 함수를 suspend로 선언하게 되면, 해당 메서드 블럭은 경량스레드와 유사하게 동작하게 됩니다. 간단하게 coroutine이 동작하는 원리를 살펴보자면,

  1. Suspend 함수를 Contiuation과 지역변수를 가진 클래스로 만듭니다.
  2. 첫번째 그림처럼 suspend 메서드 내에 호출하고있는 suspend 함수가 2개의 지점이 있다면, suspend 함수 호출 부분을 기점으로 suspend point로 지정합니다.
  3. 각각의 suspend point를 기준으로 label(L0, L1, L2)을 나눠 switch(when)문으로 finite state machine처럼 코드를 Generate 합니다.
  4. fetchUser(), fetchProfile()과 같은 park/unpark가 필요한 I/O 발생지점과 같은 부분은 위에서 보이는 .await()와 같은 Kotlin 확장함수를 통해 park/unpark를 가능하게 합니다.

Virtual Thread는 기존의 Thread 방식을 완전히 대체하기 때문에, TaskExecutor를 교체하여 어플리케이션 전체에 적용할 수 있습니다. 반면 Coroutine은 메서드 단위로 원하는 곳에만 경량스레드를 적용할 수 있다는 장점이 있습니다.

그리고 코루틴은 JDK21 이전의 버전에서도 경량스레드를 적용할 수 있다는 장점을 가지고 있습니다. 이는 JDK의 최신버전을 바로 적용하기 어려운 상황에서 최선의 선택이 될 수 있습니다.

그러나 Coroutine은 suspend 진입 전 플로우는 경량스레드가 아닌 일반 Thread로 처리되고, I/O Block 이나 sleep 같은 Thread park/unpark(컨텍스트 스위칭)가 필요한 순간마다 Kotlin이 만들어놓은 suspend 확장함수를 사용해야 하므로 프로덕션 코드에 변경이 필요합니다.

그리고 Reactive Streams 패러다임과 마찬가지로, suspend function들은 역시 전염성이 있어서 suspend가 전파될 수 있다는 차이점이 있습니다. 이걸 함수의 색 문제 라고도 이야기하는데요, 빨간색의 함수는 빨간색 함수만 호출 가능하기 때문에 계속해서 함수의 색을 고려해야 되는 문제입니다. 마찬가지로 suspend function도 전파를 막으려면 특정 지점에서 runBlocking을 사용하거나 suspend Controller로 만들어야 하는데, 이는 응답을 reactive 응답으로 전환하게 됩니다.

마지막으로 Kotlin 러닝커브가 존재한다는것도 하나의 고려해볼만한 포인트가 될 것 같습니다. Kotlin을 모르는 Java 개발자라면, 러닝커브의 부담을 버리고 Virtual Thread를 적용하는게 정신건강에 이로울것 같네요.

Virtual Thread vs Reactive Programming

public Mono<String> ioBound() {
      return requestSleep()
          .flatMap(it -> requestSleep())
          .flatMap(it -> requestSleep())
}

리액티브 스레드모델 또한, vuser를 앞서 테스트한것의 4배인 510으로 두고 I/O bound 요청을 통해 진행하였습니다. 성능테스트 결과 Virtual Thread의 성능이 Reactive에 비해 111% 좋은 성능을 보였습니다.


Spring의 Reactive 프로그래밍 모델인 WebFlux는 Netty의 event loop 기반으로 동작합니다. Event loop가 중심에서 모든 요청을 처리하고, 요청 처리 구간을 callback으로 등록해놓고 worker 스레드 풀이 작업들을 처리하는 형태입니다. Worker 스레드가 작업을 처리하는 과정에서 I/O를 마주치게 되면 작업이 park 되면서 컨텍스트 스위칭이 발생합니다.

동기 방식과 리액티브 방식의 코드 차이

위 코드를 보면 동기로 짜여있던 코드는 직관적이었던 반면 reactive 프로그래밍으로 짜인 코드는 다소 파편화 되어 있다는 생각이 드시지 않으신가요? if문이나 try/catch구문들이 모두 메서드 단위로 분리되어 있기 때문이죠. 이는 Java의 기본적인 syntax를 활용하기 어렵게 하여, 코드의 흐름을 이해하기 어렵게 만들 수 있습니다.

또한 reactive 프로그래밍은 함수의 색 문제를 가지고 있어, park/unpark 사용되는 부분마다 Reactive가 적용되어야 하고, 이는 플로우 전체에서 reactive streams를 사용해야하는 문제점이 존재합니다.

마지막으로 컨텍스트 스위칭시 실제 스레드를 switch 하기 때문에, 경량스레드 switch에 비해 성능 낭비가 존재하고, 스레드의 컨텍스트를 상실하기 때문에 스택 트레이스가 유실된다는 단점도 존재합니다. 이는 디버그를 어렵게 만들 수 있습니다.

Virtual Thread는 JDK 자체적으로 개선되어서, 기존 Thread 모델과 완전 호환되기 때문에 기존 코드 그대로 사용할 수 있고, 전염성도 존재하지 않으며 스레드 컨텍스트를 온전히 유지할 수 있기 때문에 리액티브 패러다임의 러닝커브가 부담스러우신 분들에게는 좋은 해결책이 될 수 있습니다.

여기까지 Virtual Thread을 다른 스레드 모델과 비교하여 개인적으로 생각한 장단점을 제시해드렸습니다. 다만 이건 저의 주관적인 시각에 불과하고, Virtual thread의 장점만을 서술하려고 노력했습니다. 독자분들이 Virtual Thread에 대해 스스로 판단해주시고 댓글로 많은 의견을 주신다면 감사하겠습니다. 🙂

그렇다면 Virtual Thread의 주의사항으로 넘어가도록 하겠습니다.

주의사항

여기까지 Virtual Thread에 대해 알아보았는데요. 끝으로 Virtual Thread의 주의사항을 알려드리고 글을 마무리하려고 합니다.

  • No pooling
    • Virtual Thread는 값싼 일회용품이라고 보시면 됩니다. 생성비용이 작기 때문에 스레드 풀을 만드는 행위 자체가 낭비가 될 수 있습니다. 필요할 때마다 생성하고 GC(Garbage Collector)에 의해 소멸되도록 방치해버리시는게 좋습니다. (실생활의 일회용품엔 GC가 없으니 방치하지 말아주세요😊)
  • CPU bound 작업엔 비효율
    • 앞선 테스트에서 봤듯이 IO 작업 없이 CPU 작업만 수행하는것은, 플랫폼 스레드만 사용하는것보다 성능이 떨어집니다. 컨텍스트 스위칭이 빈번하지 않은 환경이라면, 기존 스레드모델을 사용하시는것이 이득입니다.
  • Pinned issue
    • Virtual thread 내에서 synchronizedparallelStream 혹은 네이티브 메서드를 쓰면 virtual thread가 carrier thread에 park 될 수 없는 상태가 되어버립니다. 이를 Pinned(고정된) 상태라고 하는데요, 이는 예상한 virtual thread의 성능저하를 유발할 수 있습니다. 써드파티 앱이나, synchronized 과정 중 수백 밀리초가 걸리는 연산이 존재하는 경우엔, ReentrantLock으로 대체할 수 있을지 고려해보셔야합니다.
  • Thread local
    • Virtual Thread는 수시로 생성되고 소멸되며 스위칭됩니다. 백만개의 스레드를 운용할 수 있도록 설계되었기 때문에, 항상 크기를 작게 유지하시는게 좋습니다. 다이어트를 하고 다시 살이 찌는걸 요요현상이라고 하잖아요? 가상 스레드의 thread local 사이즈가 커질수록, 요요현상이 강하게 온다고 생각해주세요!

지금까지 Virtual Thread에 대해 알아보았습니다! 아직은 낯선 기술이지만, 조만간 Java의 주력 기술중 하나가 될 것이라 생각합니다. 여러분들의 Java 환경에서도 적절하게 적용해보시는건 어떨까요?