티스토리 뷰
목차
- 왜 virtual thread를 many-to-many 모델이라고 부르는가?
- ForkJoinPool이 내부적으로 어떻게 활용되는가?
- 왜 virtual thread는 기존 플랫폼 스레드보다 메모리 덜 차지하는가?
- 가상스레드를 어느정도로 생성했을 때 위험하며, 어떻게 방지할 수 있는가?
- virtual thread pool을 설정하지 않는 게 좋은 이유?
- 가상스레드 활용 시 carrier thread(platform thread) 설정은 어떻게 할 것인가?
- 기존 platform thread는 왜 pool로 설정하였는가?
- virtual thread가 생성 되어지는 위치?
- virtual thread가 실행 되어지며 갖는 transaction, context는 어떻게 저장 되어져서 다른 carrier thread에서 실행 되어져도 데이터 일관성을 지킬 수 있는가?
- virtual thread 와 spring 과의 연동 되어지는 내부 과정?
- 실제로 spring 프레임워크와 연동 하였을 때 이슈가 없는가?
- 기존 thread와 구조가 달라졌는데 Spring도 업데이트되었는지 혹은 반영을 위한 업데이트가 필요 없었는지?
- virtual thread 사용시에 synchronized를 사용하면 이슈가 발생 하는 이유?
- 다른 주요 라이브러리 및 프레임워크에서 이슈가 발생할 수 있는 예시는 어떤게 있나?
- virtual thread 가 성능을 높이기 위한 기술인가?
- continuation이란 무엇이며 기존 플랫폼 스레드의 스택 구조와의 차이는 어떠한가?
- jdk24 부터는 pin 이슈가 어떻게 해결되었으며 완벽히 pin 이슈가 해결된 것인가?
1. 왜 virtual thread를 many-to-many 모델이라고 부르는가?
- 수많은 virtual thread(사용자 수준)는 한정된 개수의 carrier thread(커널 수준)에 매핑된다. virtual thread는 blocking 등으로 인해 멈추면, 해당 carrier thread를 풀어서 다른 virtual thread가 재빨리 실행될 수 있다.
- 이 과정에서 virtual thread가 필요에 따라 여러 carrier thread 위에 실행될 수 있다는 점, 그리고 각 carrier thread는 여러 virtual thread를 번갈아 mounting/unmounting하면서 실행한다는 점에서 many-to-many 모델로 분류된다.
2. ForkJoinPool이 내부적으로 어떻게 활용되는가?
- 핵심 역할: JVM은 가상스레드의 실행을 스케줄링하기 위해 내부적으로 ForkJoinPool(일종의 work-stealing pool)을 사용한다.
- 이 풀은 carrier(플랫폼) 스레드의 집합을 관리하며, 각 carrier 스레드는 자신의 work-queue를 갖고 있고 virtual-thread의 runContinuation(실행 단위)을 큐로 푸시/팝해서 실행한다.
- 즉 ForkJoinPool은 carrier 스레드 pool 이자 스케줄러(work steal queue 기반) 역할.
- 동작 과정
- virtual thread의 실제 실행은 runContinuation 같은 Runnable이 carrier의 work-queue에 들어가고, ForkJoinPool worker(=carrier) 가 이걸 가져가 실행.
- 블로킹(park) 시에는 해당 virtual thread의 상태(continuation, stack frame 등)를 힙에 보관하고 carrier는 다른 작업을 수행.
- work-stealing 덕분에 carrier 사이에서 부하 균형을 맞춘다.
3. 왜 virtual thread는 기존 플랫폼 스레드보다 메모리 덜 차지하는가?
- OS 자원 미보유: 플랫폼 스레드는 OS 스레드당 고정된 네이티브 스택(수백 KB ~ 수 MB)을 갖는다. 가상스레드는 OS 네이티브 스택을 영구히 할당하지 않으므로(필요할 때만 carrier가 제공) 네이티브 스택 비용이 없다.
- 경량 객체화: VirtualThread는 JVM 내부 객체(힙에 할당되는 Thread 오브젝트 + continuation/광역 상태)로 관리되며, 전통적 플랫폼 스레드보다 per-thread 네이티브 오버헤드가 매우 작다. (따라서 수십만 ~ 백만 단위로도 메모리 사용량 급증이 덜함)
4. 가상스레드를 어느정도로 생성했을 때 위험하며, 어떻게 방지할 수 있는가?
- 가상 스레드 생성을 제한하는 이유가 메모리를 많이 차지하는 이유라면 잘못된 판단이라고 한다. 메모리 때문에 제한하기에는 수백만 개의 가상스레드를 무리 없이 생성하도록 만들어졌기 때문에 이슈가 생길 정도로 많이 생성되는 상황이 흔치 않을 것이다.
- 주요 포인트는, 수 많은 가상 스레드가 제한 없이 만들어지면서 다음 경우가 발생하는 경우이다.
- 서버에서 외부로의 네트워크 요청이 급격히 늘어나는 경우
- DB/Cache/MQ 등의 미들웨어에 요청이 급격히 많아지는 경우
- 서버 메모리 내의 공유 자원에 대한 접근이 많아지며 대기 시간이 길어지는 경우
- 위 문제들에 대해 세마포어를 활용해 접근 가능한 스레드 개수를 제한하는 것을 권장한다고 한다.
- 다만 다음 상황 발생 시 유의깊게 모니터링 필요
- jdk.VirtualThreadSubmitFailed 이벤트 발생(스케줄 실패).
- jdk.VirtualThreadPinned 이벤트가 잦음(pin 이슈로 carrier 고갈).
- GC 횟수/지연이 급증.
- 시스템 전반(파일 디스크립터, DB 커넥션 등) 리소스 포화.
5. virtual thread pool을 설정하지 않는 게 좋은 이유?
- 기본 권장 패턴: 짧은 작업(블로킹 I/O 처리가 주된 요청)에는 newVirtualThreadPerTaskExecutor()(task-per-virtual-thread 방식)처럼 매번 새 가상스레드 생성하는 게 단순하고 안전(구성 복잡성 낮음).
- 이유:
- virtual thread 자체가 저비용이므로 재사용/풀링으로 인한 복잡성(동기화, ThreadLocal 누수, 상태 공유 등)을 피할 수 있음.
6. 가상스레드 활용 시 carrier thread(platform thread) 설정은 어떻게 할 것인가?
- 가상스레드의 carrier thread 풀은 특수한 상황(리소스 제한/튜닝 목적)이 아니라면 직접 커스텀하지 않아도 되며, JVM default 설정에 맡길 것을 공식적으로 권장한다.
- carrier thread(플랫폼 스레드, 실제 OS 쓰레드) 수는 기본적으로 CPU 코어 수에 맞춰 자동으로 결정된다. 추가로 시스템 프로퍼티(jdk.virtualThreadScheduler.parallelism, jdk.virtualThreadScheduler.maxPoolSize)로 직접 설정 가능하다. 필요에 따라 컨테이너 환경(Kubernetes 등)에서는 visible CPU count나 관련 프로퍼티 옵션을 튜닝 가능하다.
7. 기존 platform thread는 왜 pool로 설정하였는가?
- 전통적으로 OS 스레드는 생성·종료 비용이 크고, 시스템 리소스(네이티브 스택 등)를 사용하므로 풀을 만들어 재사용하여 생성 비용과 컨텍스트 스위칭 비용을 줄여왔다.
- 즉 플랫폼 스레드에 대한 풀은 전통적 방식(스레드 풀 패턴)의 자연스러운 결과 → virtual thread 도입으로 풀 없이 스레드-per-task를 더 쉽게 쓸 수 있게 된 차이.
8. virtual thread가 생성 되어지는 위치?
- virtual thread의 생성과 관리는 JVM 내부에서 일어난다.
- 스레드 생성 API(Thread.ofVirtual().start 등)나 Executors.newVirtualThreadPerTaskExecutor()를 사용할 때, 이들은 힙 메모리에 스택 chunk를 두고, 실행이 필요할 때 JVM이 메인 carrier thread 풀(ForkJoinPool의 worker 등)에 virtual thread의 스택을 올려 실행시킨다.
- 실제 OS에서는 carrier thread 만이 리소스를 점유한다. virtual thread 인스턴스의 스택은 필요 없을 때 힙에 저장되고, 실행될 때 carrier thread와 맞물려 동작한다.
9. virtual thread가 실행 되어지며 갖는 transaction, context는 어떻게 저장 되어져서 다른 carrier thread에서 실행 되어져도 데이터 일관성을 지킬 수 있는가?
- ThreadLocal / InheritableThreadLocal:
- 가상스레드도 ThreadLocal을 지원한다. ThreadLocal 값은 virtual thread 객체 내부(힙)에서 유지되므로 carrier가 바뀌어도 값은 유지된다(플랫폼 스레드와 달리 값이 carrier가 아닌 virtual thread 오브젝트에 속함).
- Transaction / Context:
- 트랜잭션 컨텍스트(예: JDBC/트랜잭션 스코프)는 보통 쓰레드-바운디드(resource per thread)로 구현된다. virtual thread는 thread-bound 컨텍스트를 유지하므로, 동일 virtual thread가 carrier 사이를 옮겨도 VM 레벨의 ThreadLocal 기반 컨텍스트는 계속 유지된다.
- 다만 외부 리소스(예: DB 커넥션 풀의 한정된 커넥션) 은 virtual thread가 무작정 늘어나면 고갈 가능 → 트랜잭션 경계 관리는 semaphore 혹은 connection pool 으로 제어해야 함.
10. virtual thread 와 spring 과의 연동 되어지는 내부 과정?
- Spring Boot 3.2 이상에선 spring.threads.virtual.enabled=true 같은 설정을 활용하면, Spring이 제공하는 내부 TaskExecutor(기존 ThreadPoolTaskExecutor 대신)를 virtual thread 기반 Executor로 자동 교체한다. 이는 Tomcat Executor, Servlet request, @Async, @Scheduled 작업 등 Spring이 관리하는 주요 비동기 작업에 다 적용된다.
11. 실제로 spring 프레임워크와 연동 하였을 때 이슈가 없는가?
- synchronized, native method 내 블로킹 등 특정 동기화/네이티브 호출, JNI 등은 virtual thread를 carrier thread에 pinned 상태로 만들어버린다. 이렇게 되면 carrier thread가 해제되지 않고, virtual thread의 장점이 사라진다.
- 수많은 가상스레드가 제한 없이 생성되면 배압조절 기능이 없다는 점을 유의해서 활용해야 한다.
12. 기존 thread와 구조가 달라졌는데 Spring도 업데이트되었는지 혹은 반영을 위한 업데이트가 필요 없었는지?
- Spring은 가상스레드를 고려한 업데이트를 일부 했음(문서·가이드·옵션). 하지만 프레임워크가 대대적으로 내부 동작을 바꿀 필요는 적음 — virtual thread는 Thread API 유지 목표라 호환성이 좋음.
- 다만 다음 영역에서 업데이트/주의 필요:
- Embedded server integration: executor 주입(virtual thread executor 사용) 관련 구성.
- Observability/metrics: 대량의 가상스레드에 적합한 모니터링/스레드 덤프 포맷 등 보완.
- Spring Security / Transaction / AOP 등: ThreadLocal 기반 assumption이 있으면 패턴 점검 필요.
13. virtual thread 사용시에 synchronized를 사용하면 이슈가 발생 하는 이유?
- 전통적으로 synchronized는 JVM 모니터를 사용하고, 모니터에 의해 스레드가 blocking 되면 실제 플랫폼 스레드가 그 모니터를 획득한 상태로 남는다. 가상스레드가 synchronized 내부에서 blocking 되면 가상스레드가 carrier에 pin 되어 carrier를 놓지 못하는 상황이 발생 → carrier 풀 소진 → 전체 가상스레드가 스케줄되지 못하는 문제.
- 참고) JVM 옵션으로 pinning 감지 가능
- java -Djdk.tracePinnedThreads=full YourApp
14. jdk24 부터는 pin 이슈가 어떻게 해결되었으며 완벽히 pin 이슈가 해결된 것인가?
- synchornized 에서의 pin 이슈는 해결되었다고 한다. synchronized로 인한 Object.wait() 시 blocking 될 때와 같이 carrier thread에서 unmount 되는 과정을 거치도록 수정되었다.
- JEP-491 JDK24로 '거의 대부분'의 사례는 해결되었지만, 실제 운영 환경에서는 아직도 몇몇 특수 케이스(네이티브 라이브러리, 일부 에이전트, class-init/모니터 대기 복합 케이스)에서 pin-like 문제가 보고된다(버그 리포트 존재). 따라서 무조건 안전하다고 단정하긴 이르다. 실무에서는 JDK 버전·사용 라이브러리·에이전트 조합으로 철저한 테스트가 필요하다.
- jdk24에서 여전히 이슈가 발생한다는 보고: https://bugs.openjdk.org/browse/JDK-8355036
15. 주요 라이브러리 및 프레임워크에서 pin 이슈가 발생할 수 있는 예시는 어떤게 있나?
- MySQL Connector/J 8.0.32 이하
- 오래된 JDBC 드라이버에 여러 사례가 있으며 최신 버전 업데이트 지원으로 해결되고 있음
- HttpComponents 4.x: 5.x로 업그레이드 필요 / 참조: https://blog.igooo.org/120
16. virtual thread 가 성능을 높이기 위한 기술인가?
- 목적은 throughput 과 단순성: 블로킹 I/O가 많은 서버에서 코드 변경(reactive 변환) 없이 동시성 처리량을 크게 늘리고 프로그래밍 모델을 단순화하는 게 목표이다.
17. continuation이란 무엇이며 기존 플랫폼 스레드의 스택 구조와의 차이는 어떠한가?
- Continuation: 실행의 재개 가능한 위치를 나타내는 개념(함수의 실행을 멈추고 저장한 상태). Project Loom의 구현에서는 virtual thread의 실행 스택(로컬 변수, 프레임 등)을 힙 쪽의 continuation 형태로 저장/복원해서 스택에 묶이지 않는 실행 단위를 만든다.
- 기존 플랫폼 스레드:
- 네이티브 OS 스택에 모든 호출 스택과 로컬 변수가 저장되어 있고, 스레드가 blocking 되면 OS가 해당 스레드를 block 상태로 관리(스택은 네이티브에 고정).
- 차이:
- virtual thread는 blocking 시(park) 스택 내용을 JVM이 캡처(continuation) 하여 힙으로 옮기고 carrier를 해제 → 다른 virtual thread에 같은 플랫폼 스레드를 할당 가능.
- 결과적으로 스택이 네이티브에 고정되지 않으므로 수십만 스레드의 논리적 컨텍스트를 메모리-효율적으로 유지할 수 있음.