tech

Spring Boot에서 Virtual Thread 활용

현대 백엔드 시스템은 높은 동시성과 자원 효율성을 요구한다. 사용자 트래픽의 증가와 복잡해지는 API 호출, 외부 시스템과의 빈번한 통신 속에서 Java 백엔드는 주로 두 가지 처리 모델에 의존해왔다.

  • Spring MVC (Platform Thread 기반): 개발 편의성이 높지만, 동시 요청 증가 시 스레드 블로킹으로 인한 성능 한계가 명확하다.
  • Spring WebFlux (논블로킹 리액티브): 높은 동시성과 자원 효율성을 제공하지만, 리액티브 프로그래밍 모델의 복잡성과 가파른 학습 곡선, 디버깅의 어려움이라는 단점이 존재한다.

이러한 상황에서 Java 21에 포함된 Virtual Thread는 새로운 가능성을 제시한다. 기존의 익숙한 동기식 프로그래밍 모델(Spring MVC)을 유지하면서도 WebFlux 수준의 동시성 처리 능력을 확보할 수 있는 현실적인 대안으로 부상했다.

기존 WebFlux 사용에서 Virtual Thread로 전환 검토

기존 팀에서는 WebFlux 기반의 API 서버를 운영해왔다. 논블로킹 구조를 통해 높은 처리량을 확보하고 자원 사용률을 최적화했지만, 운영 과정에서 다음과 같은 실질적인 어려움에 직면했다.

  • 리액티브 체인의 복잡성: flatMap, map, subscribe 등으로 이어지는 코드 구성은 가독성을 저해하고 유지보수를 어렵게 했다.
  • 디버깅 및 오류 추적의 난해함: 비동기 실행 흐름과 분리된 스택 트레이스로 인해 문제 발생 시 원인 파악에 시간이 소요됐다.
  • 팀 내 학습 비용 증가: 리액티브 패러다임에 대한 이해도가 낮은 구성원의 경우 적응에 시간이 필요했다.
  • 기존 동기 라이브러리와의 통합 문제: 외부 라이브러리나 레거시 시스템과의 연동 시 구현 복잡성이 발생했다.

이러한 요인을 고려했을 때, 성능적 이점에도 불구하고 개발 생산성과 유지보수 비용 측면에서 부담이 컸다. 따라서 익숙한 동기적 코드 구조의 장점을 살리면서 높은 동시성을 확보할 수 있는 Virtual Thread로의 전환을 적극적으로 검토하게 됐다.

Spring Boot MVC 애플리케이션은 Platform Thread 기반으로 동작한다. 하나의 요청(Request)에 하나의 스레드를 할당하는 thread-per-request 모델을 따른다.

[요청 A] → Thread-1
[요청 B] → Thread-2
[요청 C] → Thread-3

Platform Thread 모델의 한계

이 구조는 직관적이지만 다음과 같은 구조적 한계를 내포합니다.

  • 블로킹(Blocking)으로 인한 자원 비효율: 요청 처리 중 데이터베이스 I/O, 외부 API 호출 등 대기 시간이 발생하는 작업(Blocking I/O) 시, 해당 스레드는 작업이 완료될 때까지 다른 일을 하지 못하고 대기 상태로 자원을 점유한다. 이는 시스템 리소스의 비효율적인 사용으로 이어진다.
  • 제한된 스레드 풀: 웹 서버(예: Tomcat)의 스레드 풀 크기는 제한적이다. 동시 요청 수가 스레드 풀의 용량을 초과하면, 요청은 대기 큐에 쌓이거나 거부될 수 있다.
  • 스레드 증가의 비용: 스레드 풀 크기를 늘리는 것은 해결책이 될 수 있지만, Platform Thread는 생성 및 컨텍스트 스위칭 비용이 상대적으로 높고 메모리 사용량이 크기 때문에 무한정 늘릴 수 없다. 그래서 일정 수준 이상에서는 오히려 성능 저하를 유발할 수 있다.

3.1 Java 스레드 모델의 변화


그림 1. Virtual Thread의 동작 방식

기존의 Platform Thread는 운영체제(OS) 커널 레벨에서 관리되며, 생성과 컨텍스트 스위칭에 상당한 비용이 소요된다.

반면, Virtual Thread는 JVM(Java Virtual Machine) 이 직접 관리하는 경량 스레드다. 이들은 실제 작업 수행 시에만 운영체제가 관리하는 Platform Thread 위에서 실행되며, 이때 Virtual Thread를 실행하는 Platform Thread를 Carrier Thread라고 한다.

또한 I/O 대기 등으로 인해 블로킹 상황이 발생하면, 해당 Virtual Thread는 즉시 Carrier Thread에서 분리되고, 이를 통해 다른 Virtual Thread가 빠르게 Carrier Thread를 사용할 수 있도록 한다.

3.2 핵심 차이점

구분Platform ThreadVirtual Thread
관리 주체OSJVM
생성 비용높음낮음
컨텍스트 스위칭상대적으로 무거움상대적으로 가벼움
스레드 매핑 방식OS 스레드와 1:1 매칭다수가 소수의 캐리어 스레드 공유

3.3 응답 속도가 아닌 처리량을 개선

Virtual Thread는 단일 요청의 처리 시간을 단축시키는 기술이 아니다. 대신, 동일한 하드웨어 자원으로 훨씬 더 많은 수의 요청을 동시에 처리할 수 있도록 시스템의 처리량(Throughput)을 극대화하는 데 목적이 있다.

특히 I/O Bound 작업(데이터베이스 접근, 외부 API 호출, 파일 입출력 등 연산 시간보다 대기 시간이 긴 작업)에서 그 효과가 두드러진다. Virtual Thread는 I/O 대기 중에 스레드를 낭비하지 않고 다른 작업을 처리할 수 있게 함으로써 시스템 전체의 효율을 높인다.

반면, CPU Bound 작업(복잡한 수학 연산, 암호화, 대규모 데이터 처리 등 CPU 연산이 주가 되는 작업)에서는 Virtual Thread의 이점이 거의 없다. 이러한 작업은 스레드 수보다는 가용한 CPU 코어 성능이 중요하며, 경우에 따라 Platform Thread가 더 적합할 수 있다.

Spring Boot 3.2 이상, Java 21 환경에서는 application.properties 또는 application.yml 파일에서 다음 설정으로 Virtual Thread를 활성화할 수 있다.

# application.properties
spring.threads.virtual.enabled=true

# application.yml
spring:
  threads:
    virtual:
      enabled: true

이 설정을 적용하면, Spring MVC의 요청 처리 스레드가 Virtual Thread로 동작하게 된다. 기존 컨트롤러나 서비스 로직의 변경 없이 적용 가능하다.

5.1 Virtual Thread Pinning

Virtual Thread는 블로킹 상황에서 Carrier Thread로부터 분리(unmount) 되어야 그 경량성의 이점을 살릴 수 있다. 그러나 특정 조건 하에서는 Virtual Thread가 Carrier Thread에 고정(pinned) 되어 분리되지 못하는 현상이 발생한다.

Pinning이 발생하면 해당 Virtual Thread는 블로킹되는 동안에도 Carrier Thread를 계속 점유하게 되어, Virtual Thread의 핵심 장점인 높은 동시성 처리 능력을 저해하고 Platform Thread와 유사하게 동작하게 된다. 주요 Pinning 발생 조건은 다음과 같다.

  • synchronized 블록 내부에서 블로킹 작업(예: I/O)을 수행할 때
  • 네이티브 메서드(JNI)를 호출하거나 foreign 함수를 실행할 때

5.2 synchronized 키워드의 문제점

synchronized 블록/메서드는 해당 코드 구간을 실행하는 동안 스레드를 Carrier Thread에 고정시키는 특성이 있다. 만약 이 블록 내에서 I/O 작업 등으로 스레드가 블로킹되면, Virtual Thread는 Carrier Thread를 반납하지 못하고 대기하게 된다.

이는 Virtual Thread가 I/O 대기 중에도 Carrier Thread 자원을 점유하게 만든다. 결과적으로 다른 Virtual Thread가 사용할 수 있는 Carrier Thread 수가 줄어들어 시스템 전체의 처리량이 제한된다. Virtual Thread 도입의 핵심 목적인 동시성 확장 효과가 크게 반감될 수 있다.

synchronized로 인한 Pinning 문제를 회피하기 위해 ReentrantLock 사용이 권장된다.

synchronized (lockObject) {
    // 로직 
}


private final Lock lock = new ReentrantLock();
lock.lock();
try {
    // 로직
} finally {
    lock.unlock(); // finally 블록에서 반드시 unlock() 호출
}

ReentrantLock은 내부적으로 Virtual Thread의 언마운트(unmount) 메커니즘과 호환되도록 설계되어 있어, 락(Lock)을 보유한 상태에서 스레드가 블로킹되더라도 Pinning을 유발하지 않는다.

5.3 라이브러리 검토 필요

그림 2. Virtual Thread 적용 고려사항 © mongodb

외부 라이브러리가 synchronized 기반이면 성능 저하가 발생할 수 있다. Virtual Thread 친화적인 구현(예: ReentrantLock 기반)을 사용하는 라이브러리를 선택하는 것이 좋다.

Virtual Thread는 기존 Spring MVC 코드 변경을 최소화하면서 동시 처리 성능을 크게 향상시킬 수 있는 실용적인 대안이다. WebFlux의 코드 복잡성, 학습 곡선, 디버깅 어려움 등의 문제를 해소하면서 높은 처리량을 확보할 수 있다.

그러나 모든 상황에 적용 가능한 해결책은 아니다.

  • CPU Bound 작업에는 효과가 제한적이다.
  • synchronized, Native 메서드로 인한 Pinning 가능성이 있다.
  • 외부 라이브러리 호환성 검토가 필요하다.
  • 과도한 동시성은 자원 고갈을 유발할 수 있다.

Virtual Thread는 개별 속도를 높이기 위한 기술이 아닌, 전체 처리량을 개선하는 기술임을 명확히 이해해야 한다. 기술 선택은 항상 시스템의 특성과 목적, 제약 조건을 종합적으로 고려해 이루어져야 한다.

권도엽 기자

Virtual Thread를 적용하면서 익숙한 코드 구조를 유지한 채 성능 개선을 경험할 수 있어 흥미로웠다. WebFlux에서 느꼈던 코드 복잡성과 부담이 확실히 줄어든 점이 인상 깊었다.


TOP