Virtual Thread란 무엇이고 언제 사용하면 좋을까?

Yukicow·2024년 3월 21일
2

Java 21부터 도입된 Virtual Thread에 대해 들어본 적이 있을 것이다.

다들 한 번씩 공부해 두면 좋다는 얘기를 많이 하였기에 기회가 될 때 개념 정도는 알아 두기 위해 글을 작성한다.

어떤 문제점을 해결하기 위해 나왔고 언제 사용할 수 있을까?





Java의 Thread 모델

자바에서는 동시성 문제를 해결하기 위해 Thread를 생성하여 처리한다.

나와 같은 자바 웹 개발자라면 Spring Framework를 한 번쯤은 다뤄 보았을 것이고, 이러한 스프링이 톰캣 서버를 사용하기 때문에, 하나의 요청을 처리하기 위해 하나의 Thread를 생성한다는(thread per request) 개념 정도는 알고 있을 것이다.

그렇기 때문에 동시 요청이 많다면 스레드의 수 역시 증가하는 형태이다.

하지만 Java의 스레드는 실제 운영 체제 스레드 하나와 매핑되는 형태(Platform Thread)로 동작하기 때문에, 하나의 스레드가 가지는 스택의 크기와 리소스 양은 매우 크다.

조금 쉽게 생각해 보면, OS가 최소 프로그램 하나를 돌리기 위해 생성하는 스레드를 Java는 내부 스레드 하나를 사용하는 데에 만들고 있다고 생각하면 된다.

( 물론 내부적으로 적절한 만큼의 메모리를 할당하겠지만, 그럼에도 OS 수준에서 사용될 메모리이기 때문에 적지 않다는 것이다. )


하여튼 자바의 스레드가 위와 같은 형태를 띄기 때문에 발생하는 문제점이 있다.

바로 너무 많은 스레드를 생성할 경우 효율성이 조금 떨어질 수 있다는 것이다.


왜 효율이 나빠?

효율이 나쁜 이유는 간단하다. Java의 스레드는 운영체제에 의해 스케쥴링되기 때문이다.

스레드들은 작업을 수행하다 보면 I/O 작업 등으로 인해 유휴 상태에 빠지는 경우가 있는데, 이러한 유휴 상태가 되면 CPU를 필요로 하는 다른 스레드를 동작시키기 위해 컨텍스트 스위칭이 발생한다.

운영체제의 기본 내용이다. CPU를 효율적으로 처리하기 위해 기다리는 시간에 다른 스레드를 동작시키는 것이다. ( 스레드든 프로세스든 )

여기서 효율이 나쁜 이유 두 가지를 발견할 수 있다.

1. Thread가 제한적이다.

운영체제는 스레드 생성, 유지 비용이 비싸기 때문에 효율이 좋지 않다.

같은 하드웨어 자원으로 운영체제 수준의 스레드만을 생성해 낸다면 많은 양의 스레드를 만들기는 힘들 것이다.

그렇기 때문에 자바에서 요청을 처리하기 위해 아무리 많은 스레드를 생성하려고 해도 운영체제에서 스레드 생성이 더 이상 불가능 하면 거기서 끝이다.

예를 들어 1000개의 스레드 생성이 한계인 서버가 있다면, 단순 계산으로 1000개의 요청까지만 처리가 가능하다는 것이다.

( 물론 실제로 그렇진 않을 것이다. )

이렇게 생성된 스레드들은 I/O가 발생하면 대기한다. 만약 요청을 처리하다가 대기 상태가 되어 컨텍스트 스위칭을 하려고 봤더니 모든 스레드가 전부 I/O 작업을 수행중이라고 가정해 보자.

아니 설령 모든 스레드가 그렇지 않다고 하더라도, CPU 작업이 필요한 스레드가 몇 개 없어서 전부 처리하고 나니 할 일이 없어졌다고 생각해 보자.

그럼 CPU는 아무것도 하지 않는 상태가 될 것이다.

아니 스레드가 1000개인데 어떻게 그럴 수가 있음ㅋ

컴퓨터를 너무 간과하면 안 된다. 컴퓨터는 매우매우 빠르다. 그리고 상황에 따라 다를 수도 있다는 것을 감안해야 한다.

I/O가 매우 잦은 요청들이 많이 발생하는 서버일 수도 있다. 그렇다고 한다면 충분히 위와 같은 상황이 발생할 수도 있고, 이는 CPU를 효율적으로 사용한 것이 아니게 된다.

운영체제가 컨텍스트 스위칭을 통해 병행 처리를 하더라도 CPU의 효율성 문제를 완전히 해결할 수는 없다.

결국 컨텍스트 스위칭이라는 것도 유휴 시간 동안 작업할 것이 있을 때를 위한 것이지 어차피 일이 없다면 의미가 없다.

CPU를 최대한으로 활용하기 위한 방법 중 하나일 뿐이고, 병행 처리를 한다고 해서 CPU가 100% 사용되고 있다고 말할 수 없다는 것이다.

충분히 찔끔찔끔 노는 순간이 발생할 수 있다. ( 업무 시간에 담배피러 나가는 당신처럼 말이다. ㅈㅅ)

백번 천번 만번 두리번 두리번 양보해서 운영체제가 무한한 스레드를 제공한다고 가정해 보자.

그럼 모든 요청마다 스레드를 생성할 수 있고, 처리해야 할 요청이 매우매우매우 많아졌기 때문에 아무리 CPU가 쉬려고 해도 쉬는 시간을 만들어 내기는 쉽지 않을 것이다.

하지만 이러한 방법은 운영체제에서 지원하지도 않을 뿐더러, 실제 컴퓨터의 메모리나 CPU의 자원이 한정적이기 때문에 불가능하다.

이 문제를 해결하기 위해 scale-up 또는 scale-out을 통해 자원을 늘리고 받을 수 있는 요청 수를 늘리는 방법을 사용하는 것이다.

하지만 이것은 매우 1차원적인 해결 방법이고 무엇 보다 돈이 든다.

내가 가게를 운영하는데, 잘만 가르치면 알바생 혼자서도 처리할 수 있는 일을 굳이 한 명을 더 고용해서 일을 시키는 것과 같다.


2. 컨텍스트 스위칭 비용이 비싸다.

아주 이상적으로 무한한 스레드를 생성할 수 있게 되었고 자원도 무한하다고 해 보자.

그럼에도 효율 자체만 놓고보면 썩 좋은 편은 아니다. 왜냐하면 Java의 스레드는 OS에 의해 스케쥴링되기 때문에, 하나의 스레드에서 다른 스레드로 컨텍스트 스위칭을 하기 위해서는 OS레벨에서 동작해야 한다.

아무리 무한으로 스레드 생성이 가능하다고 해도 컨텍스트 스위칭 비용이 비싸면 효율이 조금 더 나쁠 것이다.

이왕 쓰는 거 효율을 더 좋게 쓴다면 좋을 것이다. 우리는 적은 비용으로 극한의 효율을 내야 하는 개발자이기 때문이다.




Virtual Thread란

위에서도 보았듯이, 자바의 기존 스레드 모델은 I/O가 빈번하면서도 동시에 처리할 양이 많은 프로그램을 구현하기에는 크게 효율적이지 못 하다.

이러한 한계점을 극복하기 위해 나온 것이 Virtual Thread이다.

Virtual Thread란 OS가 아닌 JVM 위에서 스케쥴링되는 경량화 스레드를 말한다.

현재 주어진 자원 내에서, 스레드를 최대한 많이 생성하면서도 효율적으로 컨텍스트 스위칭 비용을 줄일 수 있다.




구조

Virtual Thread가 도입 된 후로, Platform Thread Pool은 스케줄러에 의해 관리된다. 기본 스케줄러는 ForkJoinPool을 사용하며, Virtual Thread의 작업 분배를 담당한다.

Virtual Thread가 가지는 데이터들

  • carrierThread : 실제로 작업을 수행시키는 platform thread이다. carrierThread는 workQueue를 가지고 있다.

  • scheduler : ForkJoinPool에 해당한다.

  • runContinuation : Virtual Thread의 실제 작업 내용(Runnable)에 해당한다.

동작원리

  1. 실행될 Virtual Thread의 작업인 runContinuation을 carrierThread의 workQueue에 push한다.

  2. workQueue에 있는 runContinuation들은 forkJoinPool에 의해 work stealing 방식으로 carrierThread에 의해 처리된다.
    ( work stealing이란 특정 작업을 병렬로 처리할 스레드를 담는 thread pool을 만들고, 수행해야 할 작업을 잘게 쪼개어 각 쓰레드의 작업큐에 담아 한 스레드의 작업큐가 비게 되면 다른 쓰레드에서 task를 훔쳐 수행한 뒤 합치는 방식의 알고리즘이다. Java7에 도입되었다고 한다. )

  3. 처리되던 runContinuation들은 I/O, 또는 Sleep으로 인해 interrupt가 발생하거나 작업이 완료되면, work queue에서 pop되어 park과정에 의해 다시 힙 메모리로 되돌아갑니다.


간단하게 요약하면 Virtual Thread는 Platform Thread에 의해 실행되는 형태이고, 이 때, Virtual Thread가 블록킹 상태에 빠지면 Platform Thread는 해당 Virtual Thread가 아닌 다른 Virtual Thread를 수행한다는 것이다.

이 때, Virtual Thread는 Platform Thread와 따로 연관 관계를 갖고 있지 않기 때문에, Platform Thread는 아무 Virtual Thread를 수행할 수 있다.

마치 내부적으로 운영체제에서 사용하는 스케줄러 같은 것을 만들어서 기존 스레드 모델의 약점을 보완한 느낌이다.

Virtual Thread는 JVM위에서 논리적으로 생성되는 스레드이기 때문에 무한으로 생성이 가능하고, 원래의 Platform Thread였다면 I/O가 발생하였을 때, 컨텍스트 스위칭 비용이 컸겠지만, 내부적으로 Platform Thread가 처리해야 할 Virtaul Thread를 변경시키기만 하면 되기 때문에 엄청나게 비용이 적어졌다.

위에서 말한 CPU가 쉬는 시간을 줄이기 위해서 처리할 내용이 많아야 하고, 컨텍스트 스위칭 비용은 적어야 한다는 두 가지 토끼를 한 번에 잡은 형태이다.

예를 들어, Virtual Thread를 통해 요청을 받으면, 요청을 무한으로 받을 수 있기 때문에 CPU가 할 일이 없어서 쉬는 일은 줄어들 것이고, 컨텍스트 스위칭이 내부적으로(투명하게) 이루어지기 때문에 비용도 훨씬 싸질 것이다.



그럼 Virtual Thread가 무조건 좋은거 아닌가..?

라고 생각할 수 있지만 그렇지 않다.

계속 강조하고 있지만 Virtual Thread를 사용하면 I/O를 통한 블로킹이 발생했을 때에 적은 컨텍스트 스위칭 비용을 통해 다른 스레드의 일 처리가 가능하다는 점이 장점이었다.

그럼 I/O가 발생하지 않고 CPU의 연산이 많이 필요한(CPU intensive) 요청에서는 이러한 Virtual Thread가 굳이 필요할까..?

이런 상황이라면 오히려 Virtual Thread를 생성하고 동작시키기 위한 오버헤드로 성능이 저하될 수 있다.

Virtual Thread는 Platform Thread 보다 경량화된 스레드이기 때문에 CPU Bound 작업에서는 Platform Thread가 성능상 우위를 보인다.

또, 요청이 많지 않아서 블로킹이 발생한 시간 동안 다른 요청을 처리할 만큼의 스레드가 부족한 것이 아니라면..?

오히려 Virtual Thread를 도입하기 위해 코드가 복잡해지거나, 러닝 커브가 발생한는 문제가 발생할 수도 있다.

즉, Virtual Thread는 잦은 I/O로 인해 발생하는 CPU 사용 효율이 낮아지는 상황에서 적합한 것이지, 사용한다고 무조건 성능 향상이 일어나는 것이 아니다.




Virtual Thread 사용 시 주의 사항

Virtual Thread를 올바르게 사용하기 위해서는 몇 가지 주의할 점이 있다.

1. 스레드 풀 사용 금지

Virtual Thread는 라이플 사이클 동안 하나의 작업만 수행하도록 설계되어 있어 미리 여러 개를 만들어 놓고 돌려 사용하면 안 된다.

그리고 개발자는 Virtual Thread의 라이프 사이클을 신경 쓸 필요도 없다. 언제 Platform Thread에 의해 사용되고 없어지고 그런건 알 필요 없고 그냥 쓰기만 하면 된다.

Virtual Thread가 필요할 때에는 매번 새롭게 만들어 주어야 하고, 요청 수를 제한하기 위해 스레드 풀링을 사용해야 한다면, 세마포어 등을 사용하는 게 좋다.

예를 들면, Virtual Thread는 제한이 없기 때문에 많이 만들 수 있지만, 내부적으로 DB를 사용한다면 DB 커넥션이 부족한 상황이 발생할 수 있다.

이럴 경우 Virtual Thread의 수를 제한할 필요가 있는데 Thread pool로 해결하려 하면 안 된다.

2. 스레드 로컬의 사용을 조심

가상 스레드 별로 값비싼 리소스를 생성하면 성능이 크게 저하될 수 있다. 때문에 ThreadLocal 대신 많은 가상 스레드에서 효율적으로 공유할 수 있는 캐싱 전략을 사용하는 것이 좋다.

참고로 carreierThread의 Thread Loacal을 Virtual Thread는 사용할 수 없고 그 반대도 그렇다.

3. 가상 스레드가 캐리어에 고정(Pinning)되어 언마운트(unpark)할 수 없는 경우.





정리

Virtual Thread에 대해 정리해 볼 수 있는 시간을 가졌다.

언제 사용하면 좋고, 어떤 이점이 있는지 이해할 수 있었다.

Virtual Thread는 blocking되는 스레드를 내부적으로 자체 컨텍스트 스위칭(?)하여, 마치 Webflux처럼 동작한다.

Webflux같은 성능을 내면서도, Spring MVC에 익숙한 우리 개발자들에게 러닝 커브를 줄여 준다.

아직 나온지 얼마 안 된 기술인 만큼 점점 버전이 올라가다 보면 더 좋은 기능으로 급부상할 수 있지 않을까 싶다.

불쌍한 나같은 사람을 위해 Webflux 같은 기술 대신 충분히 사용 가능한 기술로 성장해 주길 바란다.
( 이미 상황에 따라서 여러 비슷한 모델들의 성능을 넘어선 모습을 보여주고 있는 듯 하다. )

profile
자료를 찾다 보면 사소한 부분에서 궁금한 부분이 생기도 한다. 똑같은 복붙식 블로그 때문에 시간만 낭비되고 시원하게 해결하지 못 하는 경우가 많았다. 그런 부분들까지 세세하게 고민하고 함께 해결해 나가고자 글을 작성한다. 혼자서 작성하는 블로그가 아닌 함께 만들어 가는 블로그이다. ( 지식 공유를 환영합니다. )

0개의 댓글