프로그래밍 PROGRAMMING/자바 JAVA AND FRAMEWORKS

[JAVA/SPRING] Spring Boot Virtual Threads로 처리량 높이기 어떻게 설정하고 언제 써야 할까

매운할라피뇨 2026. 3. 2. 15:41
반응형

Spring Boot Virtual Threads

개요

기존 Java 서버 애플리케이션은 요청 하나에 OS 스레드 하나를 할당하는 Thread-per-Request 모델을 사용합니다. 이 방식은 동시 요청이 수백 개를 넘어서면 스레드 생성·전환 비용이 급격히 늘어나고, 결국 스레드 풀 고갈로 이어지는 경향이 있습니다.

Java 21에서 정식 출시된 Virtual Threads(가상 스레드, Project Loom)는 이 문제를 JVM 레벨에서 해결합니다. Spring Boot 3.2부터는 단 한 줄의 설정만으로 내장 서버(Tomcat·Jetty)와 @Async, 스케줄러까지 가상 스레드로 전환할 수 있습니다. 이 글에서는 Spring Boot 3.2+ 프로젝트에 Virtual Threads를 적용하는 방법과, 실제 프로젝트에서 주의해야 할 함정을 단계별로 설명합니다.


Virtual Threads란 무엇인가

Virtual Thread는 JVM이 관리하는 경량 스레드입니다. OS 스레드(Platform Thread) 위에서 동작하지만, 블로킹 I/O가 발생하는 순간 JVM이 해당 가상 스레드를 자동으로 일시 중단(suspend)하고 다른 작업을 같은 OS 스레드에서 처리합니다. OS 스레드 수천 개가 아니라 수백만 개의 가상 스레드를 동시에 유지할 수 있는 이유입니다.

생성 비용 수 MB 스택 할당 수 KB 수준
블로킹 시 OS 스레드 점유 OS 스레드 반환
최대 수 수백~수천 수백만 이상 가능
적합 I/O 유형 CPU 집약 I/O 집약

Virtual Threads는 CPU 바운드 작업보다 I/O 바운드 작업(DB 조회, 외부 API 호출 등)에서 효과가 큽니다.


Spring Boot 3.2에서 Virtual Threads 활성화하기

1단계: 의존성과 Java 버전 확인

build.gradle 또는 pom.xml에서 Spring Boot 3.2+, Java 21 이상을 사용 중인지 확인합니다.

// build.gradle
plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.4'
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21) // Java 21 필수
    }
}

2단계: 설정 파일 한 줄 추가

application.yml에 아래 옵션을 추가하면 Tomcat이 요청마다 가상 스레드를 사용합니다.

# application.yml
spring:
  threads:
    virtual:
      enabled: true  # Tomcat·Jetty·@Async·TaskScheduler 모두 적용

이 옵션 하나로 내장 Tomcat의 Executor가 Executors.newVirtualThreadPerTaskExecutor()로 교체되며, @AsyncTaskExecutorTaskScheduler도 함께 전환됩니다. 별도의 Bean 등록 없이 기존 코드를 그대로 유지할 수 있습니다.


코드로 확인하는 Virtual Threads 동작

아래 예제는 가상 스레드 적용 전·후 스레드 이름 차이를 로그로 확인하는 간단한 컨트롤러입니다.
변경전 — 일반 Platform Thread 사용 시

@RestController
@RequestMapping("/api")
public class ThreadDemoController {

    @GetMapping("/thread-info")
    public String getThreadInfo() throws InterruptedException {
        Thread current = Thread.currentThread();
        // 블로킹 I/O 시뮬레이션
        Thread.sleep(100);
        return "name=%s, virtual=%b".formatted(
            current.getName(), current.isVirtual()
        );
    }
}
// 실행 결과 (Virtual Threads 비활성화)
name=http-nio-8080-exec-3, virtual=false

변경후spring.threads.virtual.enabled=true 적용 후

// 코드 변경 없이 설정만 추가
// application.yml: spring.threads.virtual.enabled: true
// 실행 결과 (Virtual Threads 활성화)
name=tomcat-handler-0, virtual=true

isVirtual()true를 반환하면 정상적으로 가상 스레드에서 실행 중임을 뜻합니다. 코드 한 줄 바꾸지 않았지만 스레드 모델이 완전히 교체되었습니다.


운영 환경에서 주의할 사항

Pinning 현상 — synchronized 블록과의 충돌

가상 스레드가 synchronized 블록이나 메서드 안에서 블로킹될 경우, JVM은 해당 가상 스레드를 일시 중단하지 못하고 OS 스레드를 계속 점유합니다. 이를 Thread Pinning(핀닝)이라 합니다.

// 핀닝 발생 예시 — synchronized 내부 블로킹
public synchronized String fetchData() throws Exception {
    // synchronized + 블로킹 조합 → OS 스레드 점유 유지
    return externalClient.call(); // ← 위험
}

// 권장 패턴 — ReentrantLock으로 교체
private final ReentrantLock lock = new ReentrantLock();

public String fetchData() throws Exception {
    lock.lock();
    try {
        return externalClient.call(); // 가상 스레드가 정상 일시 중단 가능
    } finally {
        lock.unlock();
    }
}

JVM 플래그 -Djdk.tracePinnedThreads=full을 추가하면 핀닝 발생 지점을 콘솔에서 바로 확인할 수 있습니다.

DB 커넥션 풀 크기 재검토

가상 스레드 덕분에 동시 요청이 급증할 수 있습니다. HikariCP 커넥션 풀 기본값(10개)이 병목이 될 수 있으므로, 실제 프로젝트에서는 DB 커넥션 풀 크기를 함께 조정해야 합니다.

spring:
  datasource:
    hikari:
      maximum-pool-size: 50  # 가상 스레드 환경에 맞게 증가

맺음말

Spring Boot Virtual Threads는 I/O 바운드 서비스에서 스레드 풀 튜닝 부담 없이 처리량을 높일 수 있는 현실적인 방법입니다. 설정 한 줄로 기존 코드를 그대로 유지하면서 가상 스레드를 도입할 수 있다는 점이 가장 큰 장점입니다.

다만 synchronized 기반 라이브러리(구버전 JDBC 드라이버, 일부 레거시 라이브러리 등)를 사용하는 환경이라면 핀닝 현상을 먼저 점검해야 합니다. CPU 집약적 배치 작업보다는 REST API, 외부 서비스 연동, 다수의 DB 조회가 병렬로 발생하는 시나리오에서 효과가 두드러집니다.

다음 단계로는 Spring WebFlux와 Virtual Threads의 차이점 비교, Micrometer로 가상 스레드 성능 모니터링하기 등을 살펴보면 더 깊이 있는 이해를 얻을 수 있습니다.

참고

반응형