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: trueRequest-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는 이름이 simpleAsyncTaskScheduler인 SimpleAsyncTaskScheduler 타입이에요.
@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, 메모리 16G 에요.
테스트를 위해 도커로 cpu 2, 메모리 2G짜리 amazoncorretto:21-alpine
컨테이너를 만들고, V_THREAD 환경변수만 다르게 넣어서 테스트했어요.
// K6 부하테스트
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초만에 Spring boot가 Virtual Thread를 사용하도록 할 수 있다.
- 하지만, 무작정 도입한다고 성능이 좋아지진 않는다. 오히려 위험할 수도..
- 그럼에도, 제대로 사용하면 정말 강력하다.