자바(Java)는 1995년에 처음 공개된 이후 오랜 시간 사랑받아 온 프로그래밍 언어입니다. 그러나 기술이 빠르게 발전하면서, 자바는 태생적인 한계를 지니고 있다는 평가를 받기도 합니다. 이러한 이유로 일부 개발자들은 Go나 Kotlin과 같은 최신 언어로 전환하기도 했습니다.
그럼에도 자바는 시대에 뒤처지지 않기 위해 끊임없이 발전을 거듭하고 있습니다. 최근 릴리스된 JDK 21은 이를 보여주는 대표적인 사례로, 많은 개발자가 주목하던 가상 스레드(Virtual Thread)가 공식적으로 추가되었습니다.
오늘은 가상 스레드가 등장하기 전 자바가 가지고 있던 한계와, 이를 극복하기 위해 어떠한 노력들이 이어져 왔는지 알아보겠습니다.
먼저 이번 주제와 관련있는 스레드에 대해 간단하게 짚고 넘어가겠습니다.
하드웨어 스레드(물리적 스레드)는 중앙처리장치(CPU)에서 동시에 실행되는 독립적인 명령어 스트림입니다. 각각의 하드웨어 스레드는 프로세서 내의 별도의 실행 경로를 가지며, 이것은 다수의 스레드가 동시에 다양한 명령을 처리할 수 있도록 합니다.
CPU는 다수의 코어를 가질 수 있습니다. 그리고 1코어 = 1스레드가 일반적이었으나 기술 발전으로 이미지와 같이 1코어 = 2스레드인 cpu모델도 존재합니다. 이는 가상 하드웨어 스레드라 하며 하나의 물리적인 코어가 여러 가상 스레드를 실행하는데, 이는 소프트웨어적으로 구현됩니다. 가상화 기술을 사용하여 단일 코어에서 여러 물리적 스레드를 실행하게 만듭니다.
소프트웨어 스레드(논리적 스레드)는 프로세스 내에서 실행되는 하나의 실행 흐름으로, 프로세스 내에서 독립적으로 실행될 수 있는 가장 작은 단위입니다.
논리적 스레드는 물리적 스레드보다 개수가 많을 수 있으나 병렬적으로 실행될 수 있는 스레드는 물리적 스레드 개수만큼입니다. 사이에 스케쥴러가 스케쥴링을 수행하여 사람 눈에는 마치 수많은 프로그램이 동시에 실행되는 것처럼 보이게 합니다. (동시성과 병렬성)
소프트웨어 스레드에는 커널 레벨 스레드
와 유저 레벨 스레드
로 구분할 수 있습니다.
OS thread, 네이티브 스레드라고도 합니다. 커널 레벨 스레드는 OS Kernel에 의해 생성되고 관리되는 스레드를 의미합니다.
유저 레벨 스레드는 OS Thread 개념을 프로그래밍 레벨에서 추상화 한 것으로, 프로그래밍 언어에서 제공한 라이브러리를 통해 생성한 스레드를 의미합니다.
프로그램 연산을 최종적으로는 물리적 스레드가 해주어야 프로그램이 동작할텐데요. 이를 위해 유저 레벨 스레드
는 결국 운영체제가 관리하는 커널 스레드
와 매핑되어 동작해야합니다.
Many-to-One model은 하나의 Kernel Thread가 다수의 User Thread를 처리하는 구조입니다.
이 구조는 커널 스레드가 블로킹 상태에 빠진 경우 관련 유저스레드가 모두 일을 하지 못하는 단점이 존재합니다.
One-to-One model은 처리해야 할 User Thread 한 개당 Kernel Thread를 대응시켜 작업을 진행하는 구조입니다.
이 구조는 커널 스레드가 과도하게 생성될 수 있으며 부하가 심한 상태에서 커널 스레드가 블로킹에 빠진 경우 스케쥴러에 의해 컨텍스트 스위칭 하는 과정에서 오버헤드가 심하게 발생할 수 있습니다.
Many-to-Many model은 그림과 같이 다수의 User Thread를 다수의 Kernel Thread가 처리하는 구조입니다.
이 구조에서는 다수의 커널 스레드로 다수의 유저 스레드를 제어하기 위한 별도의 스케쥴러가 필요합니다.
Reactor 패턴
을 사용하지 않는 정통적인 자바는 OS와 유저 레벨 스레드를 1대1 매핑하여 사용, 관리합니다. (one to one 방식)
따라서 스케쥴러도 OS의 구현체에 의존합니다.
Green Thread는 Many to One 모델로 설계된 쓰레드입니다. (다수의 유저스레드를 하나의 커널스레드와 매핑)
CPU Core가 하나인 환경일 때 설계된 쓰레드이다보니 아무리 Green Thread가 많아도 Native Thread는 단 한 개 뿐이 만들어지지 않습니다.
Native Thread가 하나라는 건 cpu가 아무리 많아도 코어를 하나 밖에 사용하지 못한다는 뜻입니다. 그래서 멀티 코어 환경에서는 장점을 전혀 살리지 못해 현재는 사용되지 않습니다.
JDK 1.3부터는 유저 스레드 생성 시 커널 스레드와 1대1 방식으로 매핑됩니다.
자바에서 스레드를 생성할 때 docs를 한번 보겠습니다.
새로운 플랫폼 스레드
를 생성한다고 나와있는데요, 자바에서는 OS스레드를 Wrapping하였다고 하여 이를 플랫폼 스레드라고 정의합니다.
때문에 스레드의 구현 방식은 OS 종속적이고 스케쥴링 정책 또한 OS와 동일합니다.
OS스레드는 생성, 관리비용이 비쌉니다. 따라서 플랫폼 스레드를 효율적으로 관리하기 위해 우리는 주로 스레드풀
을 두고 스레드를 다룹니다.
Reactor 패턴
이나 가상스레드를 사용하지 않는 정통적인 서블릿 기반의 스프링MVC는 thread per request model
로, 유저 스레드와 커널 스레드가 One To One 관계이니 하나의 요청 당 하나의 커널 스레드를 생성하여 멀티스레드로 처리하는 구조입니다.
OS 스레드는 생성, 관리비용이 비싸니 스프링에서는 스레드 풀
(default 스레드 개수: 200개)을 두는데요.
자바 스레드가 로직을 타다가 네트워크 I/O, DB I/O, File I/O 등을 하는 과정에서 블로킹IO를 만나면 어떻게 될까요.
스레드 로직을 수행하다가(task) 네트워크 Blocking I/O를 만났다고 가정해봅니다.
001스레드는 블로킹 IO를 만나면 스레드가block
되고 작업이 완료될 때까지 waiting
상태로 전환됩니다. 이것은 001스레드가 I/O 작업을 완료되기 전에는 다른 작업을 수행하지 못하게 하는 것을 의미합니다.
Task2에 대해 네트워크I/O가 끝나면 001스레드는 runnable
상태로 변경됩니다. 이어서 CPU를 할당받아 task3를 수행합니다. (Running
)
시스템 부하가 낮은 상태에서는 이런 일련의 과정이 큰 문제가 되지 않지만 시스템 부하가 높은 상태에서는 스레드 상태가 waiting -> runnable -> running -> waiting.., 즉 컨텍스트 스위칭이 되고 스레드의 데이터가 계속해서 로딩하는 과정에서 발생하는 오버헤드가 큰 부담이 될 수 있습니다.
컨텍스트 스위칭을 하는 동안에는 cpu는 일을 하지 않고 대기하는데 시스템의 부하가 큰 상태에서는 CPU의 대기하는 시간이 점점 길어지고, 이는 시스템의 성능 저하로 이어지기 때문입니다.
스레드풀의 사이즈가 200이고 인스턴스의 물리적인 cpu 코어(실제 일을 수행하는 주체)가 8개라고 해봅니다.
시스템이 과부화된 상태에서 생성된 200개의 스레드는 8개의 코어를 두고 경합을 벌이게 되고, 경합 과정에서 컨텍스트 스위칭이 자주 발생하게 됩니다. 컨텍스트 스위칭이 자주 발생할수록 cpu가 일하지 않고 대기하는 전체 대기 시간은 점점 길어지게 되고, 성능저하는 그만큼 심해집니다.
많은 개발자들이 최근 자바를 버리고 go언어와 코틀린 등 다른 언어로 넘어갔는데요 왜일까요, 다양한 이유가 있겠지만 그 중 하나인 코루틴을 알아보겠습니다.
코루틴은 서브 루틴을 일시 정지하고 재개할 수 있는 구성 요소를 말합니다. 쉽게 말해 필요에 따라 일시 정지할 수 있는 함수를 의미합니다.
코루틴을 사용하여 I/O 처리를 극대화할 수 있는데, 이는 단순히 대기하는 작업을 기다리는 동안 다른 작업을 먼저 처리함으로써 CPU의 유휴 시간(Idle time)을 최소화 할 수 있기 때문입니다.
OS 스레드를 멀티스레드로 처리해 동시성을 높이는 자바는 부하가 심한 상태에서 컨텍스트 스위칭을 수행하는 유휴시간 동안 CPU가 일하지 못하는데, 코루틴을 지원하는 다른 언어들은 이러한 비용을 아낌으로써 성능을 극대화 할 수 있습니다.
C++
: C++20에 stackless coroutine이 추가Rust
: 2018 에디션에서 코루틴 관련 문법과 트레이트를 지원한다. 다만, 실행자를 별도로 구현하거나 라이브러리를 사용해야 한다.Go
자바스크립트
: ES6이 제정되기 이전에는 AJAX, 콜백을 사용해 비동기를 구현했다. ES6부터 Promise라는 객체가 등장하였으며, async, await 키워드로 가독성이 더 좋아졌다.PHP
: 5.5부터 지원된다.C#
: 2.0부터 지원된다.코틀린
: 1.3부터 지원된다.파이썬
: Asyncio라는 기본 내장 라이브러리를 통해 3.5부터 지원된다.루아
: 언어의 여덟가지 기본 type 중 하나로 코루틴이 제공된다코루틴을 사용하려면 go
, async, await
, suspand
등 별도의 키워드를 사용해주어야 합니다. 이런 구조에서는 중첩 코루틴이 쉽게 나타날 수 있으며 코드의 제어 흐름을 이해하기 어려워지고 관리가 복잡해질 수 있습니다. (근데 아래에서 설명하는 리액티브 프로그래밍보다는 훨씬 쉽습니다.)
리액티브 프로그래밍은 선언형 프로그래밍의 일종으로 데이터 흐름(dataflow)과 변화 전파에 반응하여 처리하는데 중점을 둔 프로그래밍 패러다임(programming paradigm)입니다. 자세한 내용은 이전 블로그 글을 참고해주세요. 리액티브 프로그래밍이란
자바는 상대적으로 적은 수의 스레드로 점점 더 많은 요청을 처리하는 데 도움이 되는 동시성 모델이 필요했습니다.
리액티브 프로그래밍은 프로그램 흐름을 일련의 동기 작업에서 비동기 이벤트 스트림으로 변환하여 스레드가 차단되지 않도록 합니다.
The reactive-stack web framework, Spring WebFlux, was added later in version 5.0. It is fully non-blocking, supports Reactive Streams back pressure, and runs on such servers as Netty, Undertow, and Servlet containers.
spring webflux
는 리액티브 스택 웹프레임워크입니다. 위에서 설명한 리액티브 프로그래밍을 하여 스프링 애플리케이션 개발을 할 수 있도록 지원합니다.
thread per request 모델인 스프링MVC와 달리 스프링 웹플럭스는 이벤트 루프 모델을 채택합니다.
스프링 웹플럭스는 최소 4개의 워커 스레드를 생성하는데 CPU 코어가 4개 이상이면 그 개수만큼 워커 스레드를 생성합니다.
이벤트 루프는 서버를 위한 반응형 비동기 프로그래밍 모델 중 하나입니다.
이벤트 루프는 단일 스레드에서 지속적으로 실행되지만 사용 가능한 코어 수만큼 이벤트 루프를 가질 수 있습니다.
이벤트 루프는 단일 스레드에서 계속 실행되며 클라이언트 요청이나 네트워크IO, DB IO등을 모두 이벤트로 처리하기 때문에 이벤트 발생 시 해당 이벤트에 대한 콜백을 등록함과 동시에 다음 이벤트 처리로 넘어갑니다.
이벤트 루프 스레딩 플랫폼은 Netty
, Apache Tomcat
, Undertow
등 다양합니다. 이중에서 스프링 웹플럭스 스타터는 이벤트 루프 플랫폼으로 Netty
를 기본적으로 내장해 사용합니다.
리액티브 프로그래밍의 단점은 명확합니다.
잘못사용하면 오히려 성능이 저하되고 익숙하지 않고 새로우며 어렵습니다. 라이브러리도 잘 알아보고 써야합니다. 블로킹IO로 동작하는 라이브러리라면 쓸 수 없습니다.. 또한 시스템이 복잡하면 복잡해질수록 유지보수는 어려워지는 구조가 되었습니다. 기존 명령형 프로그래밍에 익숙한 개발자들은 "이럴거면 차라리 다른 언어 쓰지"라며 다른 언어로 많이 넘어갔습니다.
Project Loom
은 OpenJDK 커뮤니티에서 시작한 Java에서 처리량이 높고 가벼운 동시성 모델을 지원하기 위한 프로젝트입니다.
자바는 OS 스레드로 멀티스레드를 처리하는 구조이기 때문에 오늘날의 다양한 동시성 요구 사항을 충족하지 못하였고, 커널 스레드와 독립적인 경량 동시성 구성이 필요하다는 니즈가 생겼습니다. 또한 스케쥴러도 운영체제의 구현체에 의존했었는데요.
Project Loom은 이러한 Java 언어가 가진 한계를 벗어나기 위해 OS 대신 Java 런타임(JVM)에 의존하는 유저 레벨 스레드를 통해 이 문제를 해결할 것을 제안합니다. 이를 위해 기존의 스레드(Platform Thread)보다 더 가볍고 효율적인 경량 스레드(Fiber)를 추가하는 목표를 가지고 있습니다.
최근 Fiber 라는 새로운 클래스가 Thread 클래스 와 함께 라이브러리에 도입되었습니다.
Fiber는 JVM 인스턴스로 생성 및 관리되기 때문에 수백만 개의 Fiber가 생성될 수 있으며, 메모리 사용량 측면에서 커널 스레드보다 훨씬 가볍기 때문에 컨텍스트 스위칭 비용이 월등하게 적습니다.
커널 스레드의 경우 연속과 스케줄러는 OS에 의해 관리되며, 플랫폼 스레드는 커널 스레드의 의해 구현되기 때문에 OS에 의존하게 되는데요.
Fiber에도 동일하게 일시 중지되고 재개되는 연속과 이를 스케줄링하는 스케줄러가 존재하는데, 플랫폼 스레드랑은 다르게 이들 모두 JVM에 의해 관리가 됩니다.
처음에는 Fiber 라고하는 별도의 기능으로 개발되었으나 최종적으로는 기존 스레드 문법과 호환될 수 있는 형태로 발전했고, 우리는 이를 Virtual Thread
라고 부르기로 하였습니다.
Virtual Thread
도 플랫폼 스레드 위에서 동작합니다. Virtual Thread
를 참조하는 플랫폼 스레드를 우리는 Carrier Thread
라고 합니다.
Virtual Thread
는 N:1로 Carrier Thread
와 맵핑이 되고, Carrier Thread
는 1:1로 OS Thread
와 맵핑되는 구조입니다. 이때 OS 스레드와 매핑되는 스레드를 Carrer Thread
라고 하며 OS 스레드와 가상 스레드를 스케쥴링해주는 스케쥴러가 JVM에 존재합니다.
가상 스레드는 자원 회수하는 형태가 기존 플랫폼 스레드와 다릅니다. 플랫폼 스레드와 다르게 잡아먹는 리소스가 매우 작기 때문에 일반적으로 스레드풀을 두지 않으며 쓰고 버린다는 의미로 리소스 회수를 임의로 하지 않고 GC에게 위임합니다.
Virtual Thread는 코루틴 모델과 다르게 별도의 키워드를 사용하지 않아 중첩 코루틴이 나타남에 따른 단점이 들어나지 않습니다.
Virtual Thread
는 N:1로 Carrier Thread
(플랫폼 스레드)와 맵핑이 되고, Carrier Thread
는 1:1로 OS Thread
와 맵핑되는 구조라고 설명했습니다.
우리는 환경변수를 주입하여 이 캐리어 스레드의 수를 지정할 수 있습니다.
가상 스레드를 스케줄링하기 위해 사용할 수 있는 플랫폼 스레드의 수입니다. 기본값은 사용 가능한 프로세서의 수입니다.
스케줄러에 사용할 수 있는 플랫폼 스레드의 최대 수입니다. 기본값은 256입니다.
자세한 내용은 오라클 링크를 참조해주세요.
가상 스레드는 Blocking IO
를 만나면 Virtual Thread
간 컨텍스트 스위칭이 일어납니다.
컨텍스트 스위칭이 발생하면 Carrier Thread
입장에서 Virtual Thread
가 들고있던 정보들(로컬 변수, 쓰레드 로컬 변수, 콜스택 정보 등등)을 힙메모리에 올리고 다른 Virtual Thread
가 Carrier Thread
를 점유하는 과정을 거치게 됩니다.
이는 OS 스레드가 컨텍스트 스위칭이 발생한 것이 아니기 때문에 컨텍스트 스위칭 비용이 굉장히 저렴합니다.
virtual thread는 os 스레드와 비교해서 생성 비용이 매우 작습니다. 생성비용이 작기 때문에 스레드 풀을 만드는 행위 자체가 낭비가 될 수 있습니다. 필요할 때마다 생성하고 GC(Garbage Collector)에 의해 소멸되도록 방치해버리시는게 좋습니다.
기존 값비싼 OS 스레드를 효율적으로 공유하여 사용하기 위해 구성했던 스레드풀의 로직의 경우 세마포어 등으로 대체하는게 좋습니다.
ThreadLocal을 사용한다면 메모리 사용량에 유의해야합니다.
기존 자바에서 플랫폼 스레드를 다룰 땐 스레드풀을 두고 플랫폼 스레드를 사용하였기 때문에 ThreadLocal 사이즈 또한 제한적이였습니다.
하지만 가상 스레드의 경우 필요한 만큼 수백 수천 수만개 생성될 수 있고 Heap 영역을 사용하기 때문에 ThreadLocal 남발시 그만큼 메모리 사용량이 늘어납니다.
syncronized
를 사용하면 Virtual Thread
와 연결된 캐리어 스레드가 블로킹될 수 있으니 사용에 주의해야 합니다. (이런경우 pinning
이라고 부릅니다.)
이런 경우 ReentrantLock
를 사용하여 pinning을 방지할 수 있습니다.
기존 플랫폼 스레드는 OS 스레드 생성 비용과 개수 한계로 인해 스레드풀을 두었습니다.
스레드풀은 생성 비용 측면에서도 사용되었지만 자체적으로 스레드 추가 생성을 막아 Throttle
역할도 수행해주었는데요.
하지만 Virtual Thread는 이러한 스레드풀을 두지 않기 때문에 메모리 등 리소스만 잘 받아준다면 무한정 생성될 수 있으며 이로 인해 Overwhelming
현상이 발생할 수 있습니다.
예를들어 요청마다 리소스를 생성하는 구조에서는 virtual thread당 리소스 생성으로 인해 의도치 않은 동작이 일어날 수도 있고, 별도 커넥션풀 없이 redis나 RDB 같은 외부 데이터베이스를 사용한다고 했을 때 가상 스레드마다 커넥션을 매번 새로 연결해 사용할 경우 DB의 맥스 커넥션 개수가 넘어 자원을 얻지 못한 가상 스레드는 커넥션을 기다리다가 타임아웃이 뜨는 경우가 있을 것 같은데요.
디비의 경우 커넥션풀을 두면 되겠지만 그 외에는 세마포어로 한정된 자원을 여러 Virtual Thread
간 동기화 하는 방법으로도 해결할 수 있습니다. (https://www.youtube.com/watch?v=Ecoq2PVVaSc)
IO 작업 없이 CPU 작업만 수행하는 경우(컨텍스트 스위칭이 잘 안일어나는 배치성 작업 등등) 플랫폼 스레드만 사용하는 경우보다 성능이 떨어질 수 있습니다. 가상 스레드는 JVM에서의 컨텍스트 스위칭 오버헤드가 존재하기 때문입니다.
Oracle의 개발자이자 Project Loom의 주요 개발자인 브라이언 게츠(Brian Goetz)는 영상에서 다음과 같이 이야기하였습니다.
"Loom이 Reactive Programming을 죽일 것이라고 생각한다.
Reactive Programming은 과도기적 기술이었으며 우리가 가진 문제에 대한 반작용에 불과했다는 것을 머지 않은 미래에 모두가 깨달을 것이라고 생각한다.
만약 그 문제가 사라지게 된다면 Reactive는 우리가 원하던 해결책이 아니라는 것이 분명하게 드러날 거다."
리액티브 프로그래밍
이 등장하고 이를 기반으로 하는 Spring Webflux가 처음 나왔을 때 이들이 기존에 자바의 태생적 한계를 모두 해결해주고 SpringMVC는 한계에 사로잡혀 잊혀져 갈 것만 같았습니다.
하지만 리액티브 프로그래밍은 어렵고 익숙하지 않았으며 디버깅도 힘들고 기능이 추가, 변경됨에 따라 유지보수도 점점 어려워짐을 느꼈습니다.
Virtual Thread의 등장으로 자바 수명이 20년은 늘어나지 않았을까요.
https://www.youtube.com/watch?v=I0zMm6wIbRI&t=519s
https://www.baeldung.com/spring-webflux-concurrency
https://www.baeldung.com/openjdk-project-loom
https://jaeyeong951.medium.com/virtual-thread-synchronized-x-6b19aaa09af1