Post

Java Virtual Threads: Project Loom이 바꾸는 동시성 프로그래밍의 미래

Java 21의 Project Loom Virtual Threads가 어떻게 전통적인 스레딩 모델의 한계를 극복하고, 수백만 개의 동시 작업을 단순한 코드로 처리할 수 있게 하는지 심층 분석합니다.

Java Virtual Threads: Project Loom이 바꾸는 동시성 프로그래밍의 미래

3줄 요약

  • Java 21의 Virtual Threads는 수백만 개의 동시 작업을 기존 플랫폼 스레드 대비 1/20의 메모리로 처리할 수 있습니다.
  • M:N 스레딩 모델을 통해 블로킹 코드의 단순함과 리액티브 프로그래밍의 확장성을 동시에 확보합니다.
  • I/O 바운드 워크로드에서 탁월한 성능을 보이며, Spring Boot, Quarkus 등 주요 프레임워크에서 이미 통합 지원을 시작했습니다.

📌 주요 내용

Virtual Threads란 무엇인가

수십 년간 Java 개발자들은 불편한 트레이드오프에 직면해 왔습니다. 유지보수하기 쉽지만 확장성이 떨어지는 단순한 블로킹 코드를 작성하거나, 뛰어난 확장성을 제공하지만 디버깅과 이해가 어려운 복잡한 비동기 프레임워크를 채택하는 것이었습니다.

Java 21에서 정식 출시된 Project Loom의 Virtual Threads는 이러한 이분법을 근본적으로 변화시킵니다. Virtual Threads는 Java 스레드와 운영체제 스레드 간의 1:1 관계를 분리하여, 최소한의 오버헤드로 수백만 개의 동시 작업을 생성할 수 있게 합니다. 동시에 Java 개발자들에게 익숙한 명령형 프로그래밍 모델을 그대로 유지합니다.

이는 Java 5의 제네릭 도입 이후 Java 생태계에서 가장 중요한 혁신 중 하나로 평가받고 있습니다.

전통적인 스레딩 모델의 한계

플랫폼 스레드의 제약사항

Java의 전통적인 스레딩 모델, 즉 플랫폼 스레드는 각 java.lang.Thread 인스턴스가 운영체제 스레드 하나와 직접 대응하는 1:1 매핑 방식으로 동작합니다. 이 단순한 구조는 여러 근본적인 제약을 가져옵니다.

각 플랫폼 스레드는 스택을 위해 상당한 메모리 블록(일반적으로 스레드당 1-2MB)을 할당해야 합니다. Oracle 문서에 따르면 이 고정 할당은 스레드 스택을 동적으로 크기 조정할 수 없기 때문에 필요하며, 대부분의 스레드가 이 한계에 도달하지 않더라도 최대 가능한 호출 깊이를 수용해야 합니다.

현대 서버는 일반적으로 성능 저하나 리소스 고갈을 겪기 전에 수천 개의 플랫폼 스레드만 지원할 수 있습니다. 50ms 응답 시간으로 10,000개의 동시 요청을 처리하는 웹 애플리케이션의 경우, 이론적으로 10,000개의 활성 스레드를 동시에 유지해야 하는데, 이는 플랫폼 스레드로는 불가능합니다.

리액티브 프로그래밍의 등장

플랫폼 스레드의 한계는 Project Reactor, RxJava, Spring WebFlux 같은 리액티브 프로그래밍 프레임워크의 탄생을 가져왔습니다. 이러한 프레임워크는 논블로킹 I/O와 이벤트 주도 아키텍처를 통해 소수의 스레드로 수천 개의 동시 작업을 효율적으로 처리합니다.

하지만 리액티브 프로그래밍은 코드 실행에 대한 근본적인 사고방식의 전환을 요구합니다. 비동기 특성은 콜백 체인, 조합 연산자, 이벤트 루프를 통해 복잡성을 증가시킵니다. 스택 트레이스가 단편화되어 해석하기 어려워지고, 디버깅은 훨씬 더 어려워지며, 리액티브 파이프라인을 통한 데이터 흐름을 이해하는 인지적 오버헤드가 크게 증가합니다.

Virtual Threads의 내부 동작 원리

M:N 스레딩 모델

Virtual Threads는 Java의 동시성 추상화와 기본 운영체제 간의 관계를 근본적으로 재구상합니다. 1:1 대응을 유지하는 대신, Virtual Threads는 M:N 모델을 구현합니다. 즉, 많은 수(M)의 가상 스레드가 소수(N)의 운영체제 스레드(캐리어 스레드라고 함)에 멀티플렉싱됩니다.

핵심 혁신은 가상 스레드가 캐리어 스레드와 상호작용하는 방식에 있습니다. 가상 스레드가 CPU에서 코드를 실행해야 할 때 JVM은 사용 가능한 캐리어 스레드에 이를 “마운트”합니다. I/O 호출, 락 획득, 명시적 슬립 명령과 같은 블로킹 작업 중에는 가상 스레드가 캐리어 스레드에서 “언마운트”되고, 해당 캐리어 스레드는 다른 가상 스레드를 실행하는 데 사용될 수 있습니다.

1
2
3
4
5
6
7
8
9
[Virtual Thread 1] ──┐
[Virtual Thread 2] ──┼─→ [Carrier Thread 1] ──→ [OS Thread 1]
[Virtual Thread 3] ──┤
                      │
[Virtual Thread 4] ──┐│
[Virtual Thread 5] ──┼┼─→ [Carrier Thread 2] ──→ [OS Thread 2]
[Virtual Thread 6] ──┘│
                       │
[Virtual Thread N] ────┘──→ [Carrier Thread N] ──→ [OS Thread N]

힙 기반 스택 저장소

플랫폼 스레드가 운영체제가 할당한 메모리에 호출 스택을 저장하는 것과 달리, 가상 스레드는 Java 힙에 스택 프레임을 저장합니다. 이 아키텍처 결정은 여러 가지 이점을 제공합니다.

가상 스레드 스택은 최소한의 메모리(일반적으로 수백 바이트)로 시작하여 필요에 따라 동적으로 증가합니다. 또한 가비지 컬렉션 중에 축소될 수 있어 메모리 사용이 더욱 효율적입니다.

가상 스레드 스택이 힙에 있기 때문에 표준 가비지 컬렉션의 대상이 됩니다. 가상 스레드가 완료되면 OS 수준의 리소스 해제 없이 스택 메모리가 자동으로 회수됩니다.

내부적으로 가상 스레드는 컨티뉴에이션(continuation)을 사용합니다. 이는 계산을 일시 중단했다가 나중에 재개할 수 있는 프로그래밍 구조입니다. 가상 스레드가 블록되면 JVM은 컨티뉴에이션(실행 상태의 스냅샷)을 캡처하여 힙에 저장합니다. 블로킹 작업이 완료되면 컨티뉴에이션이 복원되고 실행이 재개됩니다.

리액티브 프로그래밍과의 비교

철학적 차이

Virtual Threads와 리액티브 프로그래밍은 동일한 문제를 해결하기 위한 서로 다른 철학을 나타냅니다. 바로 시스템 리소스를 고갈시키지 않으면서 대규모 동시성을 처리하는 방법입니다.

리액티브 프레임워크는 아키텍처 수준에서 블로킹을 제거합니다. 데이터를 비동기 스트림으로 처리하며, 연산자가 이러한 스트림을 변환하고 조합합니다. 데이터베이스 쿼리는 스레드를 블록하지 않습니다. 대신 결과가 도착하면 실행되는 콜백을 등록합니다.

Virtual Threads는 정반대의 접근 방식을 취합니다. 블로킹 작업을 수용하되 블로킹 비용을 저렴하게 만듭니다. 가상 스레드가 I/O 작업 중에 블록되면 단순히 캐리어 스레드에서 언마운트됩니다. 캐리어 스레드는 즉시 다른 작업에 사용할 수 있게 되어, 논블로킹 API를 요구하지 않으면서도 리액티브 시스템과 유사한 활용도를 달성합니다.

성능 특성

DZone의 벤치마크 분석 연구에 따르면 미묘한 성능 특성이 드러납니다.

I/O 바운드 워크로드의 경우, I/O 대기 시간이 지배적인 작업(데이터베이스 쿼리, HTTP 요청, 파일 작업)에서 가상 스레드는 뛰어난 성능을 보여줍니다. 현실적인 네트워크 지연을 시뮬레이션한 벤치마크에서 가상 스레드는 훨씬 더 단순한 코드를 유지하면서도 리액티브 성능에 근접하거나 일치했습니다.

CPU 바운드 워크로드의 경우, 계산 집약적인 작업에서 가상 스레드는 플랫폼 스레드에 비해 본질적인 이점을 보이지 않습니다. 벤치마크에 따르면 짧은 기간의 CPU 집약적 작업은 컨티뉴에이션 관리 오버헤드로 인해 가상 스레드에서 약간 더 나쁜 성능을 보일 수 있습니다.

동시성 확장 측면에서 가상 스레드는 요청 동시성이 높을 때(수천에서 수백만 개의 동시 작업) 탁월한 성능을 보입니다. InfoQ 사례 연구에 따르면 2,000개 미만의 동시 요청에서는 일반적인 엔터프라이즈 워크로드에서 가상 스레드와 플랫폼 스레드 간의 성능 차이가 미미했습니다.

프레임워크에 미치는 영향

Spring Framework 통합

Spring Boot 3.x는 여러 통합 지점을 통해 가상 스레드를 수용했습니다. 개발자는 간단한 설정을 통해 Tomcat의 스레드 풀에 가상 스레드를 활성화할 수 있습니다.

1
spring.threads.virtual.enabled=true

Spring 팀의 공식 블로그 게시물은 기회와 도전 과제를 모두 인정합니다. synchronized 블록과 ThreadLocal 사용을 중심으로 한 수년간의 최적화는 캐리어 스레드 피닝을 피하기 위해 재검토되어야 합니다. 그러나 Spring의 아키텍처는 일반적으로 I/O 중에 장시간 유지되는 락을 피하므로 가상 스레드 채택에 유리한 위치에 있습니다.

흥미롭게도 Spring WebFlux와 가상 스레드는 상호 배타적이지 않습니다. 애플리케이션은 특정 고처리량 파이프라인에 리액티브 스트림을 사용하면서 더 단순한 요청 처리에는 가상 스레드를 활용할 수 있습니다.

Quarkus: 네이티브 컴파일 리더

Quarkus는 가상 스레드를 리액티브 코어와 원활하게 통합했습니다. 프레임워크의 @RunOnVirtualThread 어노테이션을 사용하면 채택이 매우 간단해집니다.

1
2
3
4
5
6
7
@GET
@Path("/blocking")
@RunOnVirtualThread
public String handleRequest() {
    // 이 블로킹 호출은 플랫폼 스레드를 묶지 않습니다
    return database.fetchData();
}

Quarkus의 빌드 타임 최적화와 GraalVM을 통한 네이티브 컴파일에 대한 강조는 가상 스레드와 흥미로운 시너지를 만듭니다. Quarkus의 리액티브 데이터베이스 드라이버와 가상 스레드를 결합하면 최적의 성능을 달성합니다. 리액티브 드라이버는 논블로킹 I/O를 보장하고 가상 스레드는 코드 단순성을 유지합니다.

확장성 향상 및 리소스 활용

메모리 풋프린트 감소

가상 스레드는 플랫폼 스레드 대비 약 20:1의 메모리 효율성 비율을 보여줍니다. 플랫폼 스레드가 스레드당 약 2MB를 사용하는 반면, 가상 스레드는 약 100KB만 사용합니다.

이러한 20:1 메모리 효율성 비율은 가능한 것을 근본적으로 변화시킵니다. 이전에 신중한 스레드 풀 튜닝이 필요했던 애플리케이션이 이제 대규모로 더 단순한 “요청당 스레드” 모델을 채택할 수 있습니다.

처리량 분석

여러 소스의 벤치마크 데이터는 확장성 패턴을 보여줍니다:

  • 낮은 동시성(< 1,000 요청): 가상 스레드는 최소한의 이점을 보이며, 때로는 컨티뉴에이션 관리로 인한 약간의 오버헤드도 있습니다.
  • 중간 동시성(1,000 ~ 10,000 요청): 가상 스레드는 특히 10ms 이상의 지연이 있는 I/O 바운드 작업에서 명확한 이점을 보이기 시작합니다.
  • 높은 동시성(> 10,000 요청): 가상 스레드는 플랫폼 스레드 풀을 크게 능가하며, 플랫폼 스레드가 성능 저하를 겪는 동안에도 낮은 지연과 높은 처리량을 유지합니다.

가상 스레드는 리소스 한계에 대한 사고방식을 변화시킵니다. 전통적인 플랫폼 스레드 애플리케이션은 종종 “스레드 고갈”에 직면합니다. 가상 스레드는 병목 지점을 다른 곳으로 이동시킵니다:

  • 연결 풀: 데이터베이스 연결 제한이 새로운 제약이 됩니다.
  • 외부 서비스 속도 제한: 병목이 다운스트림 종속성으로 이동합니다.
  • CPU 포화: 계산 집약적 작업의 경우 CPU 활용률이 100%에 도달합니다.

마이그레이션 전략

점진적 채택

가상 스레드의 장점은 API 호환성에 있습니다. 플랫폼 스레드와 동일한 Thread 인터페이스를 구현하여 점진적 마이그레이션을 가능하게 합니다.

1단계: 비동기 작업: 비동기 태스크 실행자를 교체하는 것으로 시작합니다.

1
2
3
4
5
6
7
8
// 이전
@Async
public void processTask() { ... }

// 이후
@Async
@RunOnVirtualThread  // Quarkus
public void processTask() { ... }

2단계: 요청 처리: 웹 서버 스레드 풀에 가상 스레드를 활성화합니다. Spring Boot에서는 코드 수정 없이 설정 변경만 필요합니다.

3단계: 백그라운드 처리: 예약된 작업과 배치 처리를 가상 스레드 실행자로 마이그레이션합니다.

4단계: 완전 채택: 확신이 생기면 애플리케이션 전체의 블로킹 I/O 작업을 전환합니다.

일반적인 함정 회피

마이그레이션에는 여러 소스에서 문서화된 도전 과제가 있습니다:

피닝 문제(Java 21): 가상 스레드가 synchronized 블록이나 네이티브 메서드를 실행할 때 캐리어 스레드에 “고정”되어 주요 이점을 잃습니다. Java 21은 이 동작을 보이지만, Java 24+에서는 개선된 JVM 계측을 통해 해결됩니다.

ThreadLocal 과다 사용: 수백만 개의 가상 스레드가 동시에 존재할 가능성이 있으므로 ThreadLocal 변수는 상당한 메모리를 소비할 수 있습니다. 새로운 ScopedValue API가 더 효율적인 대안을 제공합니다.

CPU 집약적 작업: 가상 스레드는 계산 바운드 작업에 이점을 제공하지 않습니다. 이러한 작업은 계속해서 플랫폼 스레드나 사용 가능한 CPU 코어에 맞게 크기가 조정된 전용 스레드 풀을 사용해야 합니다.

👨‍💻 개발자에게 미치는 영향

개발 패러다임의 변화

Virtual Threads는 Java 개발자들에게 수십 년 만의 가장 중요한 패러다임 변화를 가져옵니다. 더 이상 확장성과 코드 단순성 사이에서 선택할 필요가 없습니다. I/O 바운드 애플리케이션을 개발하는 팀은 이제 익숙한 블로킹 코드 스타일을 유지하면서도 이전에는 리액티브 프레임워크가 필요했던 확장성 수준을 달성할 수 있습니다.

실무 적용 가이드라인

현실적인 기대치를 설정하는 것이 중요합니다. Virtual Threads는 만능 성능 해결책이 아닙니다. 2,000개 미만의 동시 요청에서는 차이가 종종 미미하며, 2,000~10,000개 요청 사이에서 개선이 측정 가능하지만 적당합니다. 10,000개 요청을 넘어서면 I/O 바운드 작업에서 극적인 개선을 보입니다.

실제 가치 제안은 항상 원시 성능이 아닙니다. 극적으로 단순한 코드, 더 쉬운 디버깅, 자연스러운 예외 처리로 리액티브에 가까운 성능을 달성하는 것입니다.

프로덕션 준비 상태

Java 21에서 Virtual Threads는 I/O 바운드 애플리케이션에 대해 프로덕션 준비가 되어 있습니다. 그러나 성공하려면 특성을 이해하고, 대표적인 환경에서 철저히 테스트하고, 각 특정 사용 사례에 적합한 도구(가상 스레드, 리액티브 프로그래밍 또는 플랫폼 스레드)를 선택해야 합니다.

Java 24+에서 초기 피닝 제한이 해결되면서, Virtual Threads는 Go와 Kotlin 같이 오랫동안 경량 동시성 프리미티브를 제공해온 언어들과 Java를 경쟁력 있게 만듭니다. 이는 종착점이 아니라 새로운 시작입니다. Java의 강점을 보존하면서 역사적인 동시성 제한을 해결하는 더 단순하고 확장 가능한 Java 애플리케이션을 위한 기반입니다.

원문 기사 보기

This post is licensed under CC BY 4.0 by the author.