-
자바 가상 스레드란?Java 2025. 1. 12. 22:50
1. 가상 스레드란?
가상 스레드는 JDK 19(프로젝트 Loom)에서 도입된 새로운 형태의 스레드입니다. 기존의 플랫폼 스레드와 달리, JVM 내부에서 매우 가볍게 생성되고 스케줄링됩니다. 플랫폼 스레드와 1:N으로 매핑되어 블로킹 상황에서도 플랫폼 스레드가 효율적으로 재사용될 수 있도록 지원합니다.
2. 가상 스레드를 왜 사용해야 하는가?
웹 서버를 운영 할 때 비동기/병렬 처리를 위해 추가 플랫폼 스레드를 할당하곤 합니다.
가상 스레드의 장점을 설명하기 전에 기존에는 플랫폼 스레드가 어떻게 생성되고 관리됐는지 이해할 필요가 있습니다.
플랫폼 스레드를 생성할 때는 보통 플랫폼 스레드 기반 Executor을 이용하여 생성하게 됩니다.
이 방식은 톰캣에서 생성되는 스레드처럼 JNI(Java Native Interface)를 이용하여 OS 스레드에 접근해서 스레드를 생성하게 됩니다.
이 스레드들은 OS kernel 스레드와 1:1 매핑 되고 OS Scheduler의 영향을 받게 됩니다.
그런데 OS 에 접근해서 스레드를 생성하고 OS Scheduler에 의존하는건 비용이 많이 드는 일입니다.
그러니 플랫폼 스레드 위에서 가상의 스레드 개념을 만들어서 마치 스레드가 여러개인것 처럼 동작하게 하면 어떨까? 라는 발상에서 나온게 아래와 같은 가상 스레드 개념입니다.
플랫폼 스레드에 할당 받은 자원을 쪼개서 여러개의 가상 스레드를 운영하게 되는 구조입니다.
[Platform Thread에서 Virtual Thread가 생성됩니다.]
간단한 발상이지만 이러한 방식으로 아래와 같은 이점들을 가져올 수 있습니다.
스레드 생성 비용 절약: 플랫폼 스레드 기반 Executor을 이용해 스레드를 만들게 되면 OS에 직접 접근하여 생성하는 비용이 발생합니다.
기존 스레드 모델에서도 생성 비용을 최적화 하기 위해 스레드 풀 개념을 사용해서 최초 생성 이후에는 비용이 거의 소모되지 않지만 이런 방식은 최초에 스레드 풀에 만들어진 소수의 스레드만 운영할 수 있다는 단점이 있습니다.
가상 스레드를 이미 할당 받은 플렛폼 스레드 위에서 스레드를 생성한다면 JVM 메모리 위에서 가상의 스레드가 생성되는 개념이기 때문에 생성 비용이 거의 없으며
스택 메모리와 같은 자원도 플랫폼 스레드는 보통 1mb정도 고정 크기를 갖는 반면 가상 스레드는 kb단위의 동적인 크기를 갖기 때문에 스레드를 수십만개 만들어도 부담이 거의 없습니다.
스레드 관리, 컨텍스트 스위칭 비용 절약: 생성과 마찬가지로 가상 스레드 간의 문맥 전환이 JVM 내부에서 일어나므로, OS 커널 단에서 일어나는 컨텍스트 스위칭보다 훨씬 비용이 저렴합니다.
스레드 대기 비용 절약: 기존에는 IO 등이 발생할 때 가뜩이나 비싼 플랫폼 스레드가 응답을 대기하고 있는 상황이 빈번했습니다. 하지만 가상 스레드를 사용한다면 JVM위에서 동작하기 때문에 read/write/socket 등의 IO를 JVM에서 감지하여 만약 대기 작업이 생긴다면 가상 스레드가 대기하는동안은 잠시 자원을 회수하도록(Park) 스케쥴링하는 동작도 가능합니다.
(park 상태는 스레드가 임시로 중단되어 실행되지 않는 상태를 의미합니다.
이 상태에서 스레드는 CPU를 점유하지 않으며 다시 재개될 때까지 대기합니다.)
간단한 도입: 기존 플렛폼 스레드를 생성하는 코드를 Executors.newVirtualThreadPerTaskExecutor() 로 바꾸고 조금만 수정해주면 도입이 가능합니다. 이에 관해서는 뒤에서 자세히 다루겠습니다.
나열한 사항들 만으로도 가상 스레드를 사용하는것에 충분한 이점이 있을것 같습니다.
3. 가상 스레드 동작 방식
위에서 가상 스레드의 장점을 설명하며 동작 방식을 간접적으로 언급했지만
가상 스레드의 [생성/스케쥴링/IO Blocking 처리]에 대해 좀 더 상세히 살펴보겠습니다.
3-1. 생성
플랫폼 스레드 기반 Executor를 이용해 스레드를 생성하면 OS에 접근해 스레드를 생성하는 비용이 발생합니다.
이에 비해 플랫폼 스레드 위에서 가상 스레드를 생성하면 JVM 메모리 위에서 가상의 스레드가 만들어지는 개념이기 때문에 생성 비용이 거의 들지 않습니다.또한 가상 스레드는 스택 메모리와 같은 자원을 동적으로 필요한 만큼 사용하므로 수십만 개의 스레드를 생성해도 성능 부담이 거의 없습니다.
3-2. 스케줄링
기존의 플랫폼 스레드는 운영체제 커널의 스케줄러에 의해 관리됩니다.
그렇다면 플랫폼 스레드 위에서 만들어진 가상 스레드는 누가 관리할까요?가상 스레드는 JVM 위에서 생성되므로, JVM이 모든 생명 주기를 관리할 수 있습니다.
그래서 가상 스레드는 JVM의 내부 스케줄러에 의해 스케줄링됩니다.
JVM 스케줄러는 소수의 플랫폼 스레드에서 관리됩니다. (분산 설계로 인해 반드시 가상 스레드를 생성한 플랫폼 스레드가 스케줄러를 관리하지는 않는다고 합니다.)이러한 구조 덕분에 가상 스레드의 컨텍스트 스위칭은 OS 커널의 간섭 없이 JVM 메모리 상에서 이루어지며
실제 스레드에 컨텍스트 스위칭이 일어나는게 아니어서 cpu 캐시 미스 매치 같은 문제도 일어나지 않기에
비용이 기존 플랫폼 스레드보다 훨씬 적습니다.3. IO Blocking 처리
JVM 스케줄러는 가상 스레드가 네트워크, 파일 IO 등 블로킹 호출을 만나면 자동으로 다음과 같은 단계를 통해 효율적으로 처리합니다
- 가상 스레드의 상태 변경
JVM은 해당 가상 스레드를 플랫폼 스레드에서 분리시키고 park 상태로 전환합니다.
이후 해당 가상 스레드는 스케줄링 큐에 등록됩니다. - 플랫폼 스레드의 작업 전환
플랫폼 스레드는 블로킹된 가상 스레드 대신 스케줄링 큐에서 다음 실행 가능한 가상 스레드를 선택해 실행합니다.
이를 통해 플랫폼 스레드의 자원 낭비를 최소화합니다. - IO 작업 완료 후 복귀
블로킹 작업이 끝나고 IO 응답이 돌아오면 가상 스레드는 다시 실행 큐에 등록됩니다.
이후 JVM 스케줄러에 의해 다시 실행됩니다.
처음에 위같은 설명을 접했을 때 스레드에 실행할 코드를 읽고 아 블로킹 될 것 같다 피해가야지 하는 인공지능이 달려있는것도 아니고 이게 어떻게 가능하지? 라고 생각했지만
가상 스레드는 JVM 위에서 관리 되기 때문에 JVM에서 IO 관련된 코드가 실행되면 이를 감지하고 스레드를 park 시킬 수 있다고 합니다.
4. 가상 스레드 도입 가이드/ 주의사항
아래는 JVM측에서 제시한 가상 스레드 도입 가이드입니다.
앞서 기술한 가상 스레드의 특성들을 이해한다면 아래 사항들을 자연스래 준수하게 될 것입니다.
4-1. 기존 코드 스타일을 유지하라
가상 스레드는 기존의 Thread API와 완전히 호환되므로, 기존 동기적으로 동작하는 코드 스타일을 변경하지 않고 사용할 수 있습니다.
더욱 효율적인 작업을 위해 Non-blocking 스타일로 코드를 바꾸는것은 의미 없는 작업이라고 합니다.
가상 스레드는 거의 무한히 생성 가능하고 blocking하는 비용은 매우 적기 때문에 (아래 설명에는 생략 되었지만 blocking시 park되고 다른 가상 스레드가 실행되기 때문에)
플랫폼 스레드처럼 blocking을 주의할 필요가 없기 때문입니다.
4-2. 가상 스레드는 Pool로 관리하지 마라
위에서도 계속 얘기했지만 플랫폼 스레드에 비하면 가상 스레드는 생성 비용이 없는 수준입니다.
때문에 가상 스래드를 필요할 때 마다 항상 생성/소멸 시키는것이 올바른 개발 방법이라고 말합니다.
기존에 스레드 풀로 스레드를 관리하고 있었다면, task 단위로 가상 스레드를 할당 하도록 변경 할 것을 강력히 권고합니다.
4-3 동시성 제한을 위해서는 Semaphore을 사용하라
동시에 최대 10개만 요청을 보낼 수 있어서
ExecutorService es = Executors.newFixedThreadPool(10);
이런 방식으로 스레드 풀 스레드 개수를 10개로 제한해서 동시 요청 개수를 조절하고 있는 경우가 있을것입니다.
이런 경우에는 아래와 같이 Semaphore을 이용해서 동시 요청 개수를 제한하는것을 제안합니다.
4-4 스레드 로컬에 재사용 가능한 무거운 캐시를 등록하지 마라
스레드 로컬 변수를 사용해 고비용 객체(생성 비용이 크고 메모리를 많이 사용하며, 상태를 가지는 객체)를 캐싱하는 패턴은 가상 스레드와 근본적으로 충돌합니다.
전통적인 방식에서는 플랫폼 스레드가 풀링될 경우, 최초에 스레드가 생성 될 때 스레드 로컬 객체가 할당되고
스레드 풀 내에서 스레드가 재사용되기 때문에 스레드 로컬 변수를 사용한 캐싱은 효과적입니다.
하지만 가상 스레드는 풀링되지 않으며, 작업마다 고유한 가상 스레드가 생성/소멸 됩니다
때문에 매번 스레드 로컬 객체가 재생성 되어야 합니다.
이는 생성 비용을 최소화 해야 하는 가상 스레드의 특성 상 큰 부담이 됩니다.
InheritableThreadLocal은 사용 불가능합니다.
이는 JVM이 가상 스레드 생성 비용을 최소화 하기 위해 InheritableThreadLocal의 자식 스레드로의 복사 작업을 기본적으로 비활성화하기 때문입니다.
스레드 로컬의 대안으로 가상 스레드 내의 데이터 공유를 용이하게 해주는 ScopedValues를 사용 가능합니다.
(WebFlux의 context와 비슷한 용도)
5. 가상 스레드 + 톰캣 구조의 한계
가상 스레드는 로직의 비동기 처리에 많은 이점을 가져다 주지만 기존 톰캣 웹 서버 자체를 비동기로 동작하게 하지는 못합니다.
이로 인해 동기적인 객체를 반환해야 하는 경우 어쩔 수 없이 블로킹이 발생하고 플랫폼 스레드가 대기하게 됩니다.
예를 들어 CompetableFuture에서 get() 을 호출한다면 블로킹이 발생할 수 밖에 없습니다.
또한 항상 하나의 요청에 대해서 하나의 플랫폼 스레드는 할당되어야 하기 때문에 무수히 많은 요청이 들어왔을때 플랫폼 스레드의 개수가 부족하게 되는 상황에 대해서는 대처하기가 힘듭니다.
사실 가상 스레드의 한계점이라기 보다는 톰캣의 요청 동기 처리 방식의 한계라고 할 수 있겠습니다만
웹 환경에서 가상 스레드가 보통 톰캣과 같이 사용되기 때문에 위처럼 서술하겠습니다.
그렇다면 가상 스레드 + 톰캣 환경에서 해결하기 어려운 블로킹 발생 / 플랫폼 스레드 개수 부족 문제를 어떻게 해결할 수 있을까요?
자바/스프링 비동기 처리에 대해 찾아보다 보면 가상 스레드 외에도 Netty / WebFlux에 대해 접해본적이 있으실 것입니다.
Netty는 이벤트 루프 구조를 활용한 비동기 웹 서버로 CPU 코어 수만큼 이벤트 루프 스레드를 생성하고 이를 통해 모든 요청을 처리합니다.
[요청이 들어올 때 / 요청이 읽기 준비 완료됐을 때 / 요청이 쓰기 가능할 때] 각각 이벤트를 이벤트 루프에 등록하고 이벤트 루프 스레드가 계속해서 처리하는 구조입니다.
이러한 방식은 요청이 들어올 때마다 스레드를 새로 할당할 필요가 없으며, 작업이 준비될 때마다 이벤트 큐에 쌓아두기만 하면 됩니다.
자원만 충분하다면 무한한 요청 처리도 가능해집니다.WebFlux는 Reactive 프로그래밍의 구현체로 Spring과 Netty를 아주 쉽게 통합할 수 있도록 해주고 비동기 프로그래밍 관련하여 많은 기능을 제공합니다.
Netty / WebFlux에 관한 내용은 다른 포스트에서 더 자세히 다뤄보도록 하겠습니다.
6. 결론
비동기 / 병렬 로직 처리에 플랫폼 스레드를 사용하고 있다면 가상 스레드는 훌륭한 대안이 될 수 있다고 생각합니다.
기본 동작 방식을 이해하고 있다면 위에 도입방법 / 주의 사항을 참고하여
기존 플랫폼 스레드 풀을 Executors.newVirtualThreadPerTaskExecutor()를 갈아끼우기만 하면 됩니다.
스프링 웹 서버 자체를 비 동기적으로 운영하고 싶다면 Netty / WebFlux도 공부해보는것이 도움이 될 수 있을것 같습니다.
참고 자료
- 가상 스레드의 상태 변경