ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Java Virtual Threads: 개념, 등장 배경
    백엔드 : 서버공부/Spring 2025. 10. 29. 20:19
    728x90

     
     
    공부 끝나고 현대카드 언더스테이지쪽에서 찍은 사진인데, 요즘 하늘이 참 맛집이다. 
     
    오늘은 미루고 미뤄왔던 자바 Virtual Thread(가상 스레드)에 대해 정리하려한다. 이것도 잘알아서 정리가 아니라 요즘 공부중인 주제라 기록하지않으면 잊어버릴까봐 정리하는 것이다 하하하하..
     

    1. Virtual Thread란 무엇인가?

    Virtual Thread(가상 스레드)는 한 마디로 JVM이 관리하는 초경량 스레드를 뜻한다.
    전통적인 Java의 스레드(Thread 클래스 인스턴스)는 실제 운영체제의 커널 스레드(OS 스레드)에 1:1로 연결되어 동작했는데, 가상 스레드는 이러한 플랫폼 스레드(OS 기반 스레드) 위에서 동작하는 사용자 모드 스레드다. 즉, 여러 개의 가상 스레드가 소수의 OS 스레드에 M:N 방식으로 매핑되어 실행된다.

    가상 스레드는 OS 스레드에 묶여 있지 않으므로(non-blocking), 실행 중에 대기나 블로킹 상태로 들어가더라도 해당 OS 스레드를 점유하지 않고 다른 가상 스레드로 전환될 수 있다.
    반대로 말하면, 플랫폼 스레드(기존의 Java 스레드)는 하나의 OS 스레드를 전적으로 독점하지만, 가상 스레드는 필요한 순간에만 OS 스레드를 차지했다가 일이 끝나면 즉시 반환한다. 이러한 구조 덕분에 한정된 수의 OS 스레드아주 많은 수의 가상 스레드를 운용할 수 있게 된다.
    JVM 스케줄러는 가상 스레드의 실행을 관리하여, 각 가상 스레드가 CPU 코어 위에서 적절히 번갈아 가며 수행되도록 해준다.
    기본적으로 JDK는 가상 스레드를 위해 ForkJoinPool 기반의 스케줄러를 사용하며, 이 풀에는 사용 가능한 프로세서 수와 동일한 수의 OS 스레드(캐리어 스레드)가 병렬 작업을 처리하도록 구성된다.
    가상 스레드의 스케줄링은 협력적(cooperative) 성격을 띠는데, JVM이 가상 스레드 내부에서 발생하는 블로킹 이벤트를 감지하여 스레드를 자동으로 일시 정지하고 (예를 들어 Thread.sleep()이나 I/O 대기 등) 그 동안 해당 OS 스레드를 다른 작업에 활용한다.
    이렇게 런타임이 블로킹 지점을 감지하여 가상 스레드를 잠시 중단(suspend)하고 재개(resume)하는 과정을 통해, 수많은 작업을 소수의 OS 스레드가 효율적으로 교대로 처리할 수 있다.
    가상 스레드는 특정 OS 스레드에 붙잡혀 있지 않기 때문에, 스레드 로컬 변수, 호출 스택, 예외 상태 등의 문맥(context)은 JVM이 가상 스레드 단위로 관리하며 필요할 때 어느 캐리어 스레드에서든 이어서 실행할 수 있다. 요약하면, 가상 스레드JVM이 제공하는 경량 스레드로서, OS 커널이 아닌 JVM 레벨에서 스케줄링되며, 여러 개가 하나의 OS 스레드를 공유하면서 동작하는 새로운 개념의 스레드이다.

    2. 왜 가상 스레드가 등장했는가?

    가상 스레드의 등장은 기존 자바 스레드 모델의 구조적 한계를 극복하기 위한 필연적인 움직임이었다. 전통적인 Java의 플랫폼 스레드는 곧 OS 스레드였기에, 다음과 같은 문제점들이 누적되어 왔다.

    • 높은 메모리 오버헤드: 자바 플랫폼 스레드는 생성 시 운영체제가 할당하는 고정 크기 스택 메모리를 갖는다. 기본적으로 스레드 하나당 수백 KB에서 수 MB 정도의 스택이 예약되는데, 예를 들어 1MB의 스택을 갖는 스레드 1만 개를 띄우면 그 자체로 약 10GB의 메모리가 필요하다. 이처럼 스레드마다 큰 메모리를 차지하다 보니, 수천 개 이상 스레드를 생성하는 것에는 현실적인 한계가 있었다. 메모리가 넉넉한 환경이라도, OS는 최악의 경우를 대비해 각 스레드에 충분한 자원을 할당하므로 결과적으로 한 JVM당 수천 개 수준 이상으로 스레드를 늘리기 어려웠다.
    • 컨텍스트 스위칭 비용: OS 스레드가 늘어날수록 CPU는 여러 스레드를 번갈아 실행하기 위해 컨텍스트 스위칭(Context Switching)을 빈번히 수행해야 한다. OS 차원에서 레지스터, 스택, 메모리 맵 등을 교체하는 이 작업은 스레드 수가 많아질수록 기하급수적으로 비용이 증가하며 CPU 자원을 소모한다. 실제로 수만 개 수준의 스레드가 경쟁하면 CPU 시간의 대부분(때론 80% 이상)이 컨텍스트 스위칭에 소비되어 버린다는 지적도 있다. 결국 수많은 OS 스레드를 돌리는 것은 메모리뿐 아니라 CPU 측면에서도 비효율적이었다.
    • 1:1 스레드 모델의 병렬성 한계: Java는 오랫동안 스레드 당 하나의 OS 스레드를 할당하는 1:1 모델을 사용해왔다. 이 접근은 구현이 단순하고 OS의 스케줄러를 그대로 활용한다는 장점이 있지만, **응용 프로그램의 동시성 단위(예: 동시에 처리해야 할 요청 수)**를 OS 스레드 수만큼으로 제한하는 효과를 낳았다. 예컨대 웹 서버에서 요청 당 스레드(thread-per-request) 구조를 취할 때, 활성 사용자나 요청 수가 수만, 수십만으로 증가하면 그만큼의 OS 스레드를 만들 수 없어 병렬 처리에 한계가 온다. 스레드 풀(thread pool)을 사용해 재활용하더라도 총 스레드 수 자체의 제약은 변함이 없다. 하드웨어는 더 많은 동시 처리를 감당할 수 있는데, OS 스레드 수 제한 때문에 애플리케이션 처리량이 하드웨어 잠재력보다 훨씬 낮은 수준에서 제한되는 문제가 있었다.

    위와 같은 이유로, 과거 자바 생태계에서는 동시성의 확대를 위해 어쩔 수 없이 다른 우회적인 방법들이 동원되었다. 대표적인 것이 비동기(async) 프로그래밍 모델의 유행이다. 스레드를 무턱대고 늘릴 수 없으니, 하나의 스레드가 I/O 등으로 대기할 때 다른 작업에 활용될 수 있도록 논리적인 작업 분할과 콜백을 사용하는 것이다.
    예를 들어 요청 처리 로직을 여러 단계의 콜백(또는 CompletableFuture나 Reactive 스트림 등)으로 쪼개어, I/O 호출 시 즉시 스레드를 반환하고 나중에 결과가 도착하면 다음 콜백을 실행하는 방식이다.
    이러한 스레드 공유 모델은 OS 스레드 부족으로 인한 처리량 제한은 피했지만, 개발자에게 복잡한 비동기 코드 작성을 강요했다.
    콜백 지옥으로 불리는 복잡한 흐름 제어, 예외 전파의 어려움, 디버깅과 프로파일링의 불편함 등은 모두 이로부터 비롯된 부작용이다. 스택 트레이스가 분절되어 의미를 잃고, 디버거로 논리 흐름을 추적하기 어려우며, 오류가 발생해도 어디서 문제가 생겼는지 추적하기가 힘들었다.
    결국 기존 쓰레드 모델의 한계로 인해, 자바 개발자들은 직관적인 동기식 코드높은 동시성 사이에서 트레이드오프를 감수해야만 했다. 단순하고 이해하기 쉬운 추상화(스레드)를 버리고 복잡한 비동기 기법을 쓰게 된 것은, 순전히 런타임 성능상의 제약 때문이었다.
    이러한 배경에서 Project Loom이 시작되었다. Oracle의 엔지니어 Ron Pressler를 주축으로 2017년경 착수된 Project Loom은 “쉽고 가벼운 고성능 동시성”을 Java에 도입하기 위한 연구 프로젝트로, 전술한 문제들을 해결할 경량 스레드(fiber) 개념을 제안했다. 초기 구현 단계에서 Fiber라고 불리던 가상 스레드는 JDK 19(2022년)에 JEP 425으로 처음 공개되었고, JDK 20에서 개선된 JEP 436)를 거쳐, 마침내 **JDK 21(2023년)**에서 정식 기능(JEP 444)으로 채택되었다.
    흥미롭게도, Java 초창기에도 그린 스레드(green thread)라고 불리는 유저 모드 스레드가 있었으나 당시에는 M:1 모델(여러 Java 스레드가 단일 OS 스레드에 묶임)로 동작하여 멀티코어 활용이 어렵고 성능상 이점이 제한적이었다. 결국 자바는 오래 동안 1:1의 플랫폼 스레드만 제공해 왔는데, 현대적인 요구(수십만~수백만 동시 처리)를 만족하기 위해 M:N 모델의 가상 스레드를 부활시킨 셈이다. 정리하면, 가상 스레드는 기존 자바 스레드 모델의 메모리·성능 한계를 타개하고 동기식 프로그래밍의 단순함과 높은 동시성을 양립시키기 위해 등장한 혁신이라 할 수 있다.

    3. 기대되는 효과와 이점

    가상 스레드의 도입으로 Java 개발자와 시스템에겐 여러 가지 긍정적 효과가 기대된다. 주요 이점들을 하나씩 살펴보면 다음과 같다:

    • 메모리 오버헤드의 감소: 가상 스레드는 필요한 만큼만 스택을 사용하도록 설계되어 스레드 당 메모리 부담이 크게 줄어든다. 전통적인 OS 스레드는 기본 스택이 수백 KB~수 MB로 고정되지만, 가상 스레드의 스택은 힙(heap)에 chunk 단위로 동적으로 할당되며 프로그램 실행에 따라 성장 및 축소한다. 이는 마치 가상 메모리가 실제 사용하는 만큼만 물리 메모리를 매핑하는 것과 유사한 개념이다. 그 결과, 평균적인 가상 스레드의 스택 크기는 수 KB 수준에 불과할 수 있다 (예를 들어 약 1% 정도 크기). 메타데이터까지 포함해도 가상 스레드 하나당 메모리 소비는 몇백 바이트~수KB 수준으로 알려져 있어, 수만~수백만 개의 스레드를 만들더라도 메모리 압박이 기존보다 현저히 적다. 이러한 경량화 덕분에 하나의 JVM에서 수백만 개의 동시 스레드를 생성하는 것도 이론적으로 가능해진다.
    • 동시성 확장성과 높은 처리량: 가상 스레드는 개당 비용이 낮아 필요한 만큼 마음껏 생성할 수 있으므로, 애플리케이션의 동시 처리 가능 작업 수를 하드웨어 한계 수준까지 끌어올릴 수 있다. 더 이상 “스레드가 모자라 처리량이 제한되는” 상황이 발생하지 않으므로, 스레드 당 요청 모델의 서버도 요청 수만큼 스레드를 할당해 병렬성을 극대화할 수 있다. 실제로 가상 스레드를 이용하면 1만 개 수준이었던 동시 처리 작업을 수십만~수백만 개까지 늘려 하드웨어 자원의 활용도를 최적에 가깝게 높일 수 있다는 보고가 있다. 중요한 점은, 가상 스레드 자체가 작업을 더 빠르게 만들어주는 것은 아니지만 압도적인 동시성 증가를 통해 총체적 처리량(throughput)을 향상시킨다는 것이다. 즉 단일 작업의 지연 시간이 줄어들지는 않더라도, 동일한 시간에 훨씬 더 많은 작업을 병렬로 처리할 수 있게 되어 결과적으로 시스템 전반의 응답률과 처리량이 향상된다.
    • 컨텍스트 스위칭 부담 완화: 앞서 언급했듯 OS 수준에서 수많은 스레드를 관리하면 컨텍스트 스위칭으로 인한 CPU 부하가 컸지만, 가상 스레드는 JVM 레벨 스케줄링을 통해 이러한 오버헤드를 줄여준다. 가상 스레드 사이의 문맥 전환은 커널 모드 전환 없이 JVM이 사용자 모드에서 수행하므로 비용이 훨씬 가볍다. 특히 I/O 대기, sleep() 같은 블로킹 지점에서 자동으로 스레드를 양보하기 때문에, 불필요하게 실행되지 않을 가상 스레드들이 CPU 시간을 소모하지 않는다. 실제 성능 지표로 보면, 전통적인 스레드의 컨텍스트 스위치 비용이 예를 들어 100µs 수준이라면 가상 스레드는 10µs 정도로 10배 이상 효율적이라는 실험 결과도 있다. 스레드 생성 시간 역시 수 ms에서 수 µs 단위로 줄어들어 수천 배 빨라질 수 있다. 요컨대, 가상 스레드 수가 많아져도 CPU 자원을 좀먹는 문맥 교환 오버헤드 증가폭이 매우 완만하여, 높은 동시성 환경에서 보다 안정적인 성능을 기대할 수 있다.
    • 블로킹 코드의 재활용과 간결한 동기식 코드: 가상 스레드의 가장 큰 선물은 기존의 블로킹 API들을 그대로 활용하면서도 높은 동시성을 얻을 수 있다는 점이다. 더 이상 효율을 위해 억지로 콜백이나 Future를 사용해 코드를 비동기로 쪼갤 필요 없이, 그냥 평범한 함수 호출과 루프, try-catch로 구성된 동기식 코드를 작성하면 된다. 가상 스레드 내에서 Socket으로 데이터를 읽거나, DB 쿼리를 실행하거나, Thread.sleep()으로 대기해도 해당 가상 스레드만 잠들고 OS 스레드는 다른 작업을 처리하기 때문에 시스템 전체의 병행성에는 문제가 없다. 개발자는 마치 무한히 많은 스레드 자원이 있는 것처럼 직관적인 코드를 작성하면 되고, Runtime이 알아서 비동기 I/O로 처리한 뒤 필요한 시점에 스레드를 재개해준다. 이로써 오랫동안 자바 생태계의 난제였던 “간결한 코드 vs. 높은 성능”의 딜레마가 상당 부분 해결될 것으로 기대된다. 또한 기존에 별도로 제공되던 동기/비동기 이중 API의 혼란도 줄어들 수 있다. 예를 들어, 과거에는 동기 방식의 JDBC와 비동기 DB 클라이언트를 선택 사이에서 고민하거나, HTTP 호출 시 blocking용 API와 async 용 API 중 하나를 골라야 했다. 이제는 동기 API 하나만으로도 충분히 높은 성능을 낼 수 있으므로 라이브러리 설계 또한 단순해질 수 있다. 라이브러리 개발자들도 더 이상 두 가지 버전의 API를 병행 개발하지 않고도 성능과 단순함을 모두 제공할 수 있게 된다.
    • 기존 비동기 패러다임 대비 유지보수성 향상: 가상 스레드는 사실상 “쓰레드 퍼 요청” 모델을 부활시킨 것이므로, 개발자 입장에서는 동시성 프로그래밍의 추상화가 스레드라는 익숙한 단위로 돌아온다. 따라서 새로운 개념을 학습하기보다, 지금까지의 쓰레드 프로그래밍 경험을 그대로 활용하면서 동시성을 개선할 수 있다. 게다가 스레드 단위로 스택 트레이스, 디버깅, 모니터링이 가능하므로, 문제 발생 시 추적이 쉽고 진단 도구(JStack, Debugger 등)도 정상적으로 동작한다. 이는 콜백 지향 비동기 코드에서 흔히 겪었던 어려움이 해소되는 부분이다. 예를 들어, 가상 스레드를 사용하면 예외가 발생했을 때도 직관적인 스택 추적이 가능하고, 각 요청(작업)의 처리 흐름을 한 스레드의 연속적인 실행으로 간주할 수 있으므로 코드의 이해와 유지보수성이 극적으로 높아진다. 결과적으로 가상 스레드는 반응형 프로그래밍의 많은 이점(높은 동시성, 자원 효율성)을 기존 명령형 프로그래밍 스타일로 누릴 수 있게 해주는 기술 혁신이라고 요약할 수 있다.

    마무리

    Java Virtual Thread는 현대 서버가 요구하는 높은 동시성개발 생산성을 동시에 추구한 결과물이다. 기존 OS 스레드 모델의 제약을 넘어, 개발자는 직관적인 동기식 코드로도 높은 요청 처리량을 기대할 수 있으며, JVM은 내부적으로 스레드 스케줄링을 최적화하여 자원 효율을 확보한다. 이는 수십 년 전 등장한 멀티스레딩 추상화를 21세기의 스케일에 맞게 재해석한 시도라 할 수 있다.
     
    그러나 모든 상황에서 “무한에 가까운 스레드”를 제공하는 것은 아니다.
    sychronized 블록이나 네이티브 호출에서 발생하는 Pinning 문제, CPU 바운드 작업에서의 기대 이하 성능, 라이브러리 호환성 등의 현실적 제약도 여전히 존재한다.
    또한 수용 가능한 동시성이 커진 만큼, DB·네트워크·외부 시스템이 병목이 될 위험도 함께 증가한다.
    그럼에도 불구하고, Virtual Thread는 Java의 동시성 프로그래밍 패러다임을 보다 단순한 방향으로 되돌릴 수 있는 강력한 기반을 제공한다. 잘 설계된 범위 내에서 활용된다면, Java 개발자들은 복잡한 비동기 패턴에서 벗어나 명령형 코드의 가독성을 유지하면서도 대규모 동시성을 실현할 수 있을 것이다.
     
     
     
     
     

    다음에는 개인적으로 실험도 해보고.. 좀더 수치화된 글을 써보겠다..! ( 지금 코시 3치전보는데 한화야 좀 이겨랴 제발 )

Designed by Tistory.