Java Virtual Thread가 뭐임?-Spring

spring에서 Virtual Thread를 사용하는 방법 정리

spring.thread.virtual.enabled면 설정이 끝나요
이거하나면 끝

Intro

지난 글에서 모두 다루려 했지만, 내용이 길어질 것 같아 Spring(Boot) 관련 부분은 따로 떼어 정리했어요.
아마 Virtual Thread를 직접 다루기보단, Spring을 통해 사용할 일이 많을 거예요.

이번 글에서는 Spring에서 Virtual Thread를 적용하는 방법과 주의할 점들을 정리할게요.


적용법

Spring Boot 3.2버전부터 Virtual Thread를 공식 지원하며, Java 21 이상을 사용한다면 간단하게 MVC에 통합할 수 있어요.
Spring Boot답게, 아래 설정 한 줄이면 컨트롤러, @Async, @Scheduled, Kafka/RabbitMQ 리스너, WebFlux 블로킹 실행, Redis... 대부분의 컴포넌트가 Virtual Thread로 전환돼요.

spring.threads.virtual.enabled: true

Request-handling

이 상태에서 간단한 요청을 보내면, 실제 톰캣이 Virtual Thread를 사용하는 걸 알 수 있어요!!
thread = name=tomcat-handler-0,virtual=true
한 가지 주의할 점은, server.tomcat.threads.max등의 설정은 Virtual Thread가 활성화된 경우에는 무시되고, 요청마다 새로운 Virtual thread가 생성돼요.

Background Task

위에서 언급했듯이, spring.threads.virtual.enabled: true만으로 Task Thread도 전부 Virtual Thread로 변경돼요.

@Async 는 기본적으로 taskExecutor 이름의 SimpleAsyncTaskExecutor 빈을 사용해요(활성화되지 않았다면 ThreadPoolTaskExecutor).
Spring Boot는 이를 자동 등록해주지만, 수동 등록도 가능하죠.

//빌더는 yml의 설정을 가짐
@Bean(name="taskExecutor")
public SimpleAsyncTaskExecutor taskExecutor(SimpleAsyncTaskExecutorBuilder builder) {
    return builder
        .threadNamePrefix("overriding-thread-name-")
        ...
//직접 생성도 가능
public SimpleAsyncTaskExecutor taskExecutor(){
    new SimpleAsyncTaskExecutorBuilder()
        .threadNamePrefix()
        ...
}

마찬가지로, @Scheduled에 기본으로 사용되는 Executor는 이름이 simpleAsyncTaskSchedulerSimpleAsyncTaskScheduler 타입이에요.

@Bean
public SimpleAsyncTaskScheduler simpleAsyncTaskScheduler(SimpleAsyncTaskSchedulerBuilder builder) {
    return builder.build();
}

Background Task - Named Executor

또, 직접 Executor 이름을 지어서, 특정 TASK에 사용할 수 있어요. 이렇게 하면 spring.threads.virtual 과 별개로, 작업마다 Virtual Thread, Platform Thread를 사용하도록 커스텀이 가능해요:

@Bean(name = "custom-executor")
public Executor PlatformThreadExecutor() {
    return new ThreadPoolTaskExecutorBuilder()
            ...
    }
//////
@Async("custom-executor")
public void asyncTask(){...}

성능 테스트는 망했어요.

Virtual Thread는 요청마다 새로운 스레드를 생성하기 때문에, "한정된 자원으로 얼마나 퍼포먼스를 뽑아낼 수 있는가"로 초점을 맞췄어요.

먼저, 순수 Java 코드로 Virtual Thread와 Platform Thread의 성능을 비교했는데,
결과는 예상대로 꽤 뻔했어요:

  • IO-bound 작업에서는 Virtual Thread가 훨씬 효율적이에요.
    대기 시간이 길고, 동시에 처리해야 할 작업이 많을수록 성능 차이가 점점 벌어져요.
  • CPU-bound 작업은 Virtual Thread를 사용할 이유가 없어요.
    오히려 Platform Thread보다 느려요.

Spring 테스트

제가 본 대부분의 포스팅에선 Spring 테스트를 Thread.sleep()으로 수행하고 있는데, 이건 ​실제 상황과는 거리가 너무 멀잖아요?

그래서, Thread.sleep()대신, DB SELECT 쿼리(pg_sleep(0.2)), 로 테스트하기로 했어요.

제 컴퓨터는 맥 m1 pro 14인치 기본형이고, cpu 8/8, 메모리 1G 에요.도커로 cpu 2, 메모리 2G짜리 amazoncorretto:21-alpine
컨테이너를 만들고, V_THREAD 환경변수만 다르게 넣어서 테스트했어요.
export let options = {
stages:
[
{ duration: '30s', target: 50 },
{ duration: '30s', target: 200 },
{ duration: '60s', target: 400 }, //
{ duration: '60s', target: 600 }, // 폭발 지점
{ duration: '30s', target: 200 },
{ duration: '30s', target: 0 },
],

당연히 Virtual Thread가 훨씬 잘 버틸 거라고 예상했죠.
그런데, VU 400근처부터 요청이 전부 에러로 떨어지기 시작했어요.

컨테이너 로그를 보니까, 3초를 넘겨서 Hikari 커넥션 풀 타임아웃 에러가 나고 있더라고요.쿼리는 고작 0.2초밖에 안 걸리는데, 왜 3초가 넘지?

요청이 많아 커넥션 풀에 커넥션이 부족하면, 들어온 요청이 바로 실행되지 않고 남는 커넥션이 생길 때까지 풀 내부 대기 큐에 쌓이게 돼요.
이때 Hikari 커넥션 풀은 기본적으로 3초의 대기시간을 설정해두는데, 3초가 넘어도 커넥션을 잡지 못하면 오류를 반환하죠.

이후에 커넥션 풀을 조정 후 다시 테스트했지만, 여전히 병목은 여기서 발생했어요.
왜 DB가 가장 비싼 자원이라고 하는지 이제 알겠어요.


Final

Spring(Boot)답게, Virtual Thread로의 전환은 정말 간단해요.

하지만,

  • Spring 애플리케이션의 병목은 요청 처리 스레드가 아닌 DB 커넥션이나 외부 API 처럼 다른 자원에서 많이 발생하고,
  • CPU를 사용하는 작업에서는 오히려 별 차이가 없으며,
  • (이 글에서는 다루지 않았지만) 일부 라이브러리에서는 Pinned 현상이 발생할 수도 있어요.

정리하면, Virtual Thread 도입은 쉽지만, 실제로 도입하기 위해선 다양한 고려사항이 존재해요.

TMI

공식 문서에서는 경험상 동시에 10,000개 이상의 Virtual Thread가 사용되지 않으면 별 이점이 없다고 언급하고 있어요:
As a rule of thumb, if your application never has 10,000 virtual threads or more, it is unlikely to benefit from virtual threads. Either it experiences too light a load to need better throughput, or you have not represented sufficiently many tasks to virtual threads.

3-POINT

  1. 1초만에 Spring boot가 Virtual Thread를 사용하도록 할 수 있다.
  2. 하지만, 무작정 도입한다고 성능이 좋아지진 않는다.
  3. 그럼에도, 상황에 맞게 활용하면 강력하다.