[Java] Virtual thread 와 Platform Thread

Project Loom 으로부터 탄생한 결과물인 Virtual Thread가 Java 21 부터 정식 기능으로 사용 가능하게 되었다.
고전적인 1 Request per 1 Thread 모델에 사용되었던 Platform Thread 는 생성, 사용, Context Switch 하는데에 많은 비용을 요구했기 때문에 스레드가 Blocking 되어 대기상태에 오랜 시간 머무르는것이 성능 저하를 일으키는 원인이었다. 기존의 무거운 Platform Thread 보다 경량화된 새로운 Thread 가 추가되면서 앞서 언급한 내용을 보완하기 위해 나온 Spring WebFlux 를 대체할 수 있는지 정말 궁금했다.

WebFlux 는 분명 단순 DB CRUD, API 요청이 많은 I/O 비중이 높은 서비스는 분명 성능상 이점을 갖고 있다. 그러나 StackTrace의 파편화로 인한 트러블슈팅의 어려움, Mono와 Flux 라는 Publisher 로 감싸 코드를 작성해야 하고, Reactive Stream 의 처리와 핸들링 방식이 전통적으로 사용하는 방식과 다르기 때문에 학습 난이도가 높다는 점, 또한 Framework 의 기저부터 Reactor Netty 기반으로 되어 있는 만큼, Blocking 코드가 작성되지 않도록 주의해서 코딩해야 하고, 라이브러리가 Blocking 코드를 호출한다면 별도 스레드풀에서 해결해야 하는 등.. 불편한점이 많았던 것이 사실이다. ( 그리고 많이 느꼈던건 Spring Data JPA 를 사용할 수 없는데, 그 대용인 R2dbc 가 JPA 에 비해 불편한것도 이유다. )

하지만 이 Virtual Thread 가 Reactive 만큼의 성능은 못 내주더라도, 어느정도의 성능 이점을 갖는다면 충분히 대체 가능하지 않나 생각했다.
그래서 nGrinder 를 이용해 어느정도의 차이가 있을지 직접 확인해봤다.

1. Virtual Thread 살펴보기

Virtual Thread 의 개념은 OS 스레드와 대응되어 Context Switch 와 생성 & 삭제에 많은 비용이 드는 Platform Thread 보다 훨씬 가벼운 JVM상의 가상의 Thread 를 만드는 것이다.

실제 Virtual Thread를 살펴보면 크게 Carrier Thread ( 이게 바로 실제 작업을 실행시키는 Platform thread 이다.) runContinuation 이라는 실행해야하는 작업 내용을 갖고 있다. Scheduler 는 Virtual Thread 의 작업 Scheduling 을 담당한다.

Carrier Thread 에는 workQueue 라는 ForkJoinPool 을 내부적으로 갖고 있는걸 확인할 수 있다. 이 workQueue 에 실행해야 할 Virtual Thread 의 runContinuation 들이 푸시되고, Carrier Thread 에서 이 Task 들을 꺼내 실행하게 된다. 작업이 할당된 Virtual Thread 는 Carrier Thread 와 mount 되어 실행한다. 만약 해당 Virtual Thread 가 Blocking 되면, UnMount 되어 Carrier Thread 는 다른 Virtual Thread 와 mount 되어 작업을 진행하는 방식이다.

UnMount 가 일어날 때 Heap 영역에 Virtual Thread stack frames를 저장하게 된다. 그리고 언젠가 Blocking 이 끝나면 다시 Heap 에 저장해둔 Data를 Virtual Thread 로 가져온다. 쉽게 생각하면, Context Switch 가JVM 위에서, Heap 영역에 있는 정보를 바탕으로 일어난다고 생각하면 된다. 이때문에 Context Switching 비용이 OS 레벨이 아닌 JVM 레벨에서 마무리되기 때문에 훨씬 가볍고, 값싼 Thread 가 된다.

2. Spring Framework 에서 Virtual Thread 사용하기

Spring Boot를 3.2 이상 버전을 사용할 경우는 virtual thread 에 대한 정보가 이미 metadata 로 등록되어 있기 때문에, application.yaml 설정에 한줄만 추가해주면 손쉽게 이용할 수 있다.

// spring-configuration-metadata.json
...
{
      "name": "spring.threads.virtual.enabled",
      "type": "java.lang.Boolean",
      "description": "Whether to use virtual threads.",
      "defaultValue": false
},
# Virtual Thread Enable
spring:
  threads:
    virtual:
      enabled: true

Springboot 가 3.2 보다 낮은 버전일 경우, 관련 Bean 을 등록해주면 된다. Tomcat 이 Request 에 Mapping 하는 Thread 를 Virtual Thread 로 생성하게 하고, Async Task 에도 Virtual Thread 를 사용하게끔 Configuration 을 등록하면 된다.

@Configuration
public class Configurations {

    @Bean
    public TomcatProtocolHandlerCustomizer<?> tomcatProtocolHandlerCustomizer(){
        return protocolHandler -> protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
    }

    @Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
    public AsyncTaskExecutor asyncTaskExecutor() {
        return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
    }
    
}

3. Simple 테스트

@Test
public void test() {
    HTTPResponse response = request.GET("http://SERVER_HOST/virtual/io", params)

    if (response.statusCode == 301 || response.statusCode == 302) {
        grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
    } else {
        assertThat(response.statusCode, is(200))
    }
}

기본적으로 nGrinder 에서 제공해주는 request 에 Host 부분만 변경해서 사용했다.
간단한 성능을 보기 위한것이기 때문에 서버 역할은 단순하게 Thread를 Sleep 시키는 콜을 호출한다.

@Slf4j
@RestController
@RequestMapping("/virtual")
@RequiredArgsConstructor
public class Controller {

    private final TestService testService;

    // I/O Bound Task
    @GetMapping(value = "/io", produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<?> ioTask() throws InterruptedException {
        testService.sleep(); //Thread.sleep(300) x2
        log.info("Done");
        return ResponseEntity.status(200).body("io");
    }
}

모든 코드는 동일하고, application.yaml 에 Virtual Thread 를 Enable 하는 부분만 변경해서 2개의 Application 을 컴파일했다.

# Virtual Thread Enable
spring:
  threads:
    virtual:
      enabled: false # true

Agent 는 1개로 고정하고, Virtual User 의 수를 500명으로 설정한 뒤 5분간 테스트를 진행했다. 1초 뒤부터 유저가 서서히 증가하게 설정하여, 약 10초정도 후에 500명의 User에 도달하게 설정했다.

위쪽이 Platform Thread 를 이용한 결과이고, 아래쪽이 동일 조건에서 Virtual Thread 를 사용한 결과이다.

Virtual ThreadPlatform Thread
TPS820.4332.0
최고 TPS900381
평균 테스트시간(MTT)601.36 ms1,483.94 ms
총 실행 테스트242,90098,300
성공한 테스트242,90098,300
– 단순 Thread Sleep 하는 시간에 따른 결과이므로, 실제 비즈니스 모델에 적용하였을 때는 결과가 다를 수 있다.

TPS 가 어느 정도 고정되었을 때( 최대로 자원을 로드하고 사용할 때 ) Virutal Thread 의 경우는 820 개 가량의 요청을 처리했고, Platform thread 는 그 절반도 채 되지 않는 332 개정도의 요청을 처리했다. 이 TPS 의 차이가 총 실행한 테스트의 개수 비와 거의 일치하는 만큼, 처리량은 Virtual Thread 가 훨씬 많은것을 확인할 수 있었다.
평균 테스트 시간도 동일한 기능에서 Virtual Thread 쪽이 훨씬 더 빠른걸로 보아 Platform Thread 와 비교했을 때 확실한 이점이 보인다.

주의할 것

Spring WebFlux 를 사용할 때와 비슷한 이슈가 존재하는데 Blocking 이 많이 일어나지 않고 Cpu Bound 연산이 자주 일어나는 서비스라면 오히려 Platform Thread 를 사용하는 것 보다 성능상 크게 이점이 없다. ( Virtual Thread 자체도 어쨌든 Context Switch 를 하긴 하니까.. )
따라서 전통적인 Platform Thread 를 완전히 대체하는 방식이 아니고, 처리량을 증가시킬 수 있는 I/O 연산이 많은 경우에 이점을 가질 수 있다.

Virtual Thread 또한 마찬가지로 Thread 이기 때문에, ThreadLocal 을 지원한다. 기존 Platform Thread 와는 다르게 Virtual Thread 는 ThreadLocal 객체를 공유하지 않고, 빠르고 가볍게 사용하는쪽이 권장되므로, ThreadLocalMap 에 비싼 객체를 추가 하는것은 메모리를 많이 잡아먹는 원인이될 수 있다고 한다.

결론

Virtual Thread 는 Reactive Programing 이 가져올 수 있는 이점과 WebFlux 가 갖는 단점은 해결할 수 있는 좋은 기능이라 생각한다.
다만, WebFlux 가 등장했을 때도 마찬가지지만, 단점도 가지고 있는 만큼 사용할 때 신중하게 판단하고 테스트를 통해 필요한 부분에 사용해야 할 것이다.
그래도 Mono 와 Flux 는 사용하지 않고 Reactive 의 성능을 누릴 수 있다는 점은 분명 큰 장점인것 같다.

Leave a Comment