JAVA

[Java]Virtual Thread란 ?

로춘남 2023. 5. 12. 11:54
728x90


 

2023년 9월에 릴리즈 예정인 Java 21은 Java 8 이후 세번째 LTS 버전 입니다. LTS는 Long Term Support 의 약자인데 보통 장기 지원 버전. 즉, 일반경우보다 장기간에 걸쳐 지원하도록 고안된 버전을 의미합니다.

※ 현재까지 출시된 LTS은 Java 8, 11, 17 이다.

LTS를 사용하게 되면, 사용하는 소프트웨어의 버전 업그레이드에 대한 부담을 줄이고 안정성을 높힐 수 있게 되죠.

새롭게 출시되는 Java 21에는 가상 스레드(Virtual Thread) 라는 기능이 추가 될 예정 입니다. 이 기능을 한번 성능 테스트 해보겠습니다.

 

해당 글은 

https://findstar.pe.kr/2023/04/17/java-virtual-threads-1/

 

Virtual Thread란 무엇일까? (1)

Software Developer, I love code.

findstar.pe.kr

을 참고하여 작성하였습니다.

 

가상 스레드란?

가상 스레드란 기존 Java 스레드에 새롭게 추가되는 경량 스레드 입니다.

Projcet Loom의 결과물로 추가된 기능으로 OS 스레드를 그대로 사용하지 않고, JVM 자체적으로 내부 스케줄링을 통해 사용 할 수 있는 경량 스레드를 제공합니다. 하나의 프로세스가 수십만개 이상의 스레드를 동시에 발생 할 수 있게 설계가 되어있습니다.

 

※ 여기서 잠깐! Project Loom 이란? (Room이 아니라 Loom) 입니다.

경량의 스레드를 Java에 추가하기 위해서 가상 스레드를 비롯한 여러가지 기능들을 개발하는 프로젝트로 Loom이란 단어는 Thread 의 사전적 정의가 ‘’ 이라는데 착안하여 실을 엮어 ‘직물을 만든다는 뜻‘이다. Loom 프로젝트의 결과로 탄생한 ‘Virtual Thread’도 처음에는 Fiber-섬유 라고하는 별도의 기능으로 개발되었으나, 최종적으로는 기존 스레드 문법과 호환될 수 있는 형태로 발전했다.

Project Loom의 주요 목표는 Java 에서 처리량이 많고 가벼운 동시성 모델을 지원하는 것!

 

그렇다면 이 가상 스레드는 왜 필요하게 되었는지 한 번 살펴보겠습니다.

 

배경

  1. 자바의 스레드는 OS의 스레드를 기반!
    • 자바의 전통적인 스레드는 OS 스레드를 랩핑(wrapping)한 것으로 이를 플랫폼 스레드 라고 정의합니다. (자바의 전통적인 스레드=플랫폼 스레드)
    • 따라서 Java 애플리케이션에서 스레드를 사용하는 코드는 실제적으로는 OS 스레드를 이용하는 방식으로 동작.
    • OS 커널에서 사용할 수 있는 스레드는 갯수가 제한적이고 생성과 유지 비용이 비싸다.
    • 이 때문에 기존에 애플리케이션들은 비싼 자원인 플랫폼 스레드를 효율적으로 사용하기 위해서 스레드 풀(Thread Pool) 만들어서 사용해왔다.
  2. 처리량(throughput)의 한계
    • Spring Boot와 같은 애플리케이션의 기본적인 사용자 요청 처리 방식은 Thread Per Request 이다. 이는 하나의 request(요청)을 처리하기 위해서 하나의 스레드를 사용한다는것.
    • 애플리케이션에서 처리량을 늘리려면 스레드를 늘려야 하지만 스레드를 무한정 늘릴 수는 없다. → OS 스레드를 무한정 늘릴 수가 없기 때문...
    • 따라서 애플리케이션의 처리량(throughput)은 스레드 풀에서 감당할 수 있는 범위를 넘어서 늘어날 수 없다.
  3. Blocking으로 인한 리소스 낭비
    • Thread per Request 모델에서는 요청을 처리하는 스레드에서 IO 작업 처리할 때 Blocking 이 일어난다.
    • 이 때문에 스레드는 IO 작업이 마칠 때까지 다른 요청을 처리하지 못하고 기다려야 한다.(Blocking 동안 대기)
    • 애플리케이션에 유입되는 요청이 많지 않거나 또는 스케일 아웃으로 충분히 커버할 수 있는 정도라면 문제가 없겠지만, 아주 많은 요청을 처리해야하는 상황이라면? Blocking 방식으로 인해 발생하는 낭비를 줄여야할 필요가 있다. → 이러한 이유때문에 Blocking이 아니라 Non-blocking 방식의 Reactive Programming 이 발전하였음.
  4. Reactive Programming의 단점
    • 한정된 자원인 플랫폼 스레드가 Blocking 되면서 대기하는 데 소요된 스레드 자원을 Non-blocking 방식으로 변경하면서 다른 요청을 처리하는데 사용할 수 있게 되었다. 대표적으로 Webflux가 Non-blokcing으로 동작. 하지만... 이런 Reactive 코드는 작성하고 이해하는 비용을 높게 만들었다.... ex) Mono, Flux
  5. 자바 플랫폼의 디자인
    • 자바 플랫폼은 전통적으로 스레드를 중심으로 구성되어 있었다.
    • 스레드 호출 스택은 thread local을 사용하여 데이터와 컨텍스트를 연결하도록 설계되어 있다.
    • 이 외에도 Exception, Debugger, Profile(JFR)이 모두 스레드를 기반으로 하고 있다.
    • Reactive 스타일로 코드를 작성하면 사용자의 요청이 스레드를 넘나들면서 처리되는데, 이 때문에 컨텍스트 확인이 어려워져 결국 디버깅 및 성능테스트가 힘들어졌다. → 가상 스레드는 기존 스레드 구조를 사용하기때문에 디버깅, 프로파일링 등 기존의 도구 그대로 사용 가능!

출처 : https://findstar.pe.kr/2023/04/17/java-virtual-threads-1/

구조

먼저 플랫폼 스레드는 OS 스레드를 감싼 형태이다. 애플리케이션 코드가 플랫폼 스레드를 사용하면 실제로는 OS 스레드를 사용하게되고, 이 때 사용되는 스레드는 비용이 비싸기때문에 스레드풀을 이용하여 접근하는 방식으로 사용되었다.

출처 : https://findstar.pe.kr/2023/04/17/java-virtual-threads-1/

이에 반해 가상 스레드는 OS 스레드를 감싼 구조가 아니기 때문에 애플리케이션 코드는 가상 스레드 풀 없이 사용하고 JVM 자체적으로 가상 스레드를 OS 스레드와 연결하는 스케줄링합니다. 기존 플랫폼 스레드라고 하던 부분을 Carrier 스레드라고 하는데, 이는 가상 스레드를 실제 OS 스레드로 연결해준다는 의미 입니다.

출처 : https://findstar.pe.kr/2023/04/17/java-virtual-threads-1/

구조를 보면 OS 스레드를 사용하기전 1개의 레이어가 더 있는것처럼 보이죠? 하지만 이 자체적인 스케줄링을 통해서 큰 차이가 발생 됩니다. 기존의 스레드는 Blocking이 발생하면 그냥 기다려야 했는데, 가상 스레드는 Blocking이 발생하면 내부의 스케줄링을 통해서 실제 작업을 처리하는 Carrier 스레드는 다른 가상 스레드의 작업을 처리하면 됩니다. 따라서 Non-blocking이 누리는 장점을 동일하게 누릴 수 가 있는것이죠.

 

다만 이러한 구조는 가상 스레드가 수백만까지 늘어날수있기때문에 전통적인 플랫폼 스레드와 동일한 메모리 비용이 발생하면 감당하기가 어려울것 같습니다. 따라서 사용하는 자원의 차이에 따라 어떤 스레드를 사용해야할지 고민을 해봐야겠네요. 참고로 플랫폼 스레드는 메모리의 경우 미리 할당된 Stack을 사용하고, 가상 스레드의 경우에는 필요할때마다 Heap을 사용 합니다.

 

사용법

자, 그럼 가상 스레드를 한 번 사용해보겠습니다.

해당 작업은 관련 링크 https://www.baeldung.com/spring-6-virtual-threads  을 참고하여 진행하였습니다.

 

1. 환경 설정

 

앞서 언급한것처럼 가상 스레드는 Java 21에 출시될 예정이기때문에 프로젝트 Setting은 Java 19 혹은 Java 20인 상태에서 진행해주셔야 합니다.(안그러면 가상 스레드를 불러 올 수 없어요~)

저는 Java 19의 환경에서 성능테스트를 실행했습니다.

가상 스레드는 JVM에 애플리케이션에서 활성화하는것을 인식해야되기 때문에 Maven이나 Gradle을 통해 다음의 코드를 포함해줘야 합니다. 언급한 url에서는 Maven으로 나타나있지만, 저는 작업중인 프로젝트와 최대한 동일한 환경에서 테스트하고자 Gradle Kotlin 상태에서 설정을 해주었습니다.

 

다음의 해당 코드를 build.gradle.kts 파일에 적용해줍니다.

tasks.withType<JavaCompile>{
	options.compilerArgs.add("--enable-preview")
}

만약 Gradle Groovy에서 설정을 해준다면,

compileJava {
    options.compilerArgs.addAll(['--enable-preview'])
}

의 코드를 넣어주면 됩니다.

 

2. Bean 클래스 구성

이제 Java의 시점에서 Apache Tomcat 및 가상 스레드로 작업을 위해선 2개의 Bean이 있는 간단한 클래스가 필요합니다.

@EnableAsync
@Configuration
@ConditionalOnProperty(value = "spring.thread-executor", havingValue = "virtual")
public class ThreadConfig {
    @Bean
    public AsyncTaskExecutor applicationTaskExecutor() {
        return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
    }

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

각각의 Bean을 통해 새 가상 스레드를 시작하는 Executors 를 제공하는 메소드를 설정 할 수 있습니다.

참고로 주석 @ConditionalOnProperty는 application.yml 파일에서 가상 스레드를 활성화하기 위해 필요한 부분 입니다.

이 부분은 변수 값만 변경하면 가상 스레드와 표준 스레드 간에 전환이 가능 합니다.

spring:
    thread-executor: virtual

application.yml에 해당 부분을 설정해줍니다.

 

이제 가상스레드에 대한 설정이 끝났다면, 이 것을 이용하여 웹 요청을 처리하는 성능을 한 번 테스트해보겠습니다.

테스트를 하려면 request 요청을 반환하는 Controller가 있어야겠지요?!

 

3. Controller의 구성

https://www.baeldung.com/spring-6-virtual-threads 에서는 다음과 같은 코드가 나타나 있습니다.

@RestController
@RequestMapping("/load")
public class LoadTestController {

    private static final Logger LOG = LoggerFactory.getLogger(LoadTestController.class);

    @GetMapping
    public void doSomething() throws InterruptedException {
        LOG.info("hey, I'm doing something");
        Thread.sleep(1000);
    }
}

"hey, I'm doing something"의 결과를 나타내기 위한 코드지만, 저는 DB와 연계한 테스트를 위해 기본적인 CRUD를 구성했습니다. 이번 테스트에서는 전체 목록을 불러오기 위한 list api의 결과만 나타내보겠습니다.

성능 테스트를 위한 기본 CRUD 구성. 이번 테스트에서는 list api만 실행하였다.

 

성능테스트를 위해선 JMeter를 이용해줘야 합니다.

 

테스트 조건은 30초 동안 list api에 도달하는 500명의 동시 사용자를 시뮬레이션 해보겠습니다.

더 극악의 상황으로 몰고가기위해 DB에 10,000개의 데이터를 추가해줬습니다. 그렇다면, 30초동안 500명의 유저가 1만개의 데이터를 계속 불러오기를 한다는 뜻이겠지요?!

성능테스트의 결과는 Response TIme Graph의 결과를 통해 유추해볼수가 있습니다.

플랫폼 스레드의 Response Time Graph

해당 그래프는 플랫폼 스레드의 Response Time Graph 입니다. DB의 목록을 불러오는 list API를 요청했을때의 결과 입니다. 작업에 반응하기까지 6,600 miliseconds가 소요됬습니다.

 

다음은 가상 스레드의 Response Time Graph를 확인해보겠습니다.

가상 스레드의 Response Time Graph

가상 스레드에서는 작업에 반응하기까지 1,200 miliseconds 의 시간이 소요됬습니다. 

플랫폼 스레드 대비 더 빠른시간내에 Response를 했습니다. 가상 스레드는 리소스 관점에서 Request 요청 직후에 생성되어 사용되어진 결과로 볼 수 있겠습니다.

 

추가적으로 Transactions per Second에 관한 그래프를 보겠습니다. 1초당 어느정도의 트랜잭션 처리를 할 수 있는가?! 의 그래프 입니다.

플랫폼 스레드에 관한 Transactions per Second 입니다. 요청 작업을 처리하는동안 나타난 트랜잭션의 처리수인데, 평균적으로 10 - 12 사이의 결과값을 보여주고 있습니다.

다음은 가상 스레드에 관한 Transactions per Second 그래프 입니다. 평균적으로 62-68 사이의 결과값을 보여주고 있습니다.

 

그래프 결과에 따르면 플랫폼 스레드는 1초당 10 - 12 의 트랜잭션을 처리 할 수 있고, 가상 스레드는 1초당 62 -68의 트랜잭션을 처리 할 수 있습니다. 작업시간도 가상 스레드가 조금 더 빠른것을 확인 할 수 있습니다.

 

정리를 해보자면,

기존에는 높은 처리량을 높이기 위해 Reactive Programming 방식을 사용했지만, 가상 스레드를 사용 할 경우 Non-blocking을 통한 효율적인 자원 사용이 가능할것 같습니다.

또한 가상 스레드는 JVM 내부에서 스케줄링을 해주기 때문에 가상 스레드풀을 사용하지 않을뿐더러 Reative Programming과 달리 기존 스레드와 동일하게 동작 할 수 있기에 디버깅이 수월할것으로 보입니다.

 

다만, 가상 스레드 기능이 추가되었다고 응답속도가 빨라질수있겠으나 처리량도 늘어나기때문에 이러한 부분을 고려해봐야할것 같습니다. 

보통 개발을 할때 스레드를 직접 다루거나 Executors를 사용하는 코드가 많지 않은것으로 알고있는데, Java 21 출시 이후 가상 스레드를 사용 할 경우 기존의 라이브러리들이 가상 스레드를 위해 개선되야될것으로 보입니다.

높이 처리량을 필요로하는 Reactive Programming 같은 형태가 가상 스레드를 사용하는 방식으로 전환될 가능성도 있습니다.

가상 스레드를 사용한다고해서, 기존의 플랫폼 스레드를 사용못하는것이 아니기때문에 요구하는 상황에 따라 둘의 공존의 효과를 이끌어낸다면 더 효율적인 개발 성능을 보여주지 않을까 싶습니다.

 

 

728x90