[Java] JDK21의 Virtual Thread 정리 (w. 성능 테스트)

Kai·2024년 5월 13일
0

Java

목록 보기
17/22

📌 글에서 사용되는 깃헙

🤔 Java Thread


Java 프로그램은 기본적으로 하나의 쓰레드에서 실행이 되고, 병렬처리를 하기 위해서는 또 다른 쓰레드를 사용하게 된다.
이렇게 Java 프로그램이 돌아가는 쓰레드를 Platform thread라고 부르는데, Platform threadOS Thread를 Wrapping한 쓰레드이여서, Java에서 쓰레드를 사용한다는 것은 OS Thread를 사용한다는 것과 다름이 없는 것이다.

특히나 스프링으로 개발한 애플리케이션에서는 하나의 요청(Request) 당 하나의 쓰레드를 할당하는 Thread per request 방식으로 쓰레드가 사용되는데, 이는 하나의 요청당 하나의 OS Thread가 필요하다는 이야기이고, 많은 양의 트래픽을 처리하기 위해서는 많은 OS Thread가 필요하다는 의미와 같다.

하지만, OS Thread를 사용하게 되면 발생하는 한계점들이 있는데 그 한계점들은 아래와 같다.

  1. OS Thread를 사용하고 관리하는 데에는 자원(메모리, CPU)이 많이 필요함.
  2. CPU의 스펙에 종속적이고, 실제로 사용 가능한 쓰레드는 CPU에서 사용 가능한 쓰레드보다 무조건 적다.
  3. I/O 작업을 만나면, I/O 작업이 완료될 때까지 해당 쓰레드는 블로킹된다. 이러한 특징으로 인해서, I/O작업이 많아지면 쓰레드 풀이 고갈되기 쉽다.

물론 Java/Spring 진영에서도 이런 한계점을 그냥 두고 있었던 것은 아니고, 한계 극복을 위해서 다양한 기술들이 탄생하게 됐다.

한계 극복을 위한 방법 1 : Kotlin coroutine

Kotlin의 coroutine은 아마 많은 Java 개발자들에게 Kotlin을 쓰게 만든 가장 큰 이유 중에 하나이지 않을까 싶다.
Java에서 지원하지 않는 경량형 쓰레드 모델을 Kotlin이라는 언어 레벨에서 지원하다보니, 이는 매우 강력한 장점이라고 평가됐었다.

하지만, Java개발자 또는 Java로 서버 애플리케이션이 이미 만들어져있는 회사 입장에서는 또 다른 언어를 도입한다는 것은 큰 허들이므로, Kotlin coroutine은 좋은 대안이라고 할 수는 있지만, 근본적인 한계 극복 방법이라고 볼 수는 없었다.

즉, 당연하게도 Java 진영에서는 여전히 Thread에 대한 이슈를 갖고 있었다.

한계 극복을 위한 방법 2 : Spring WebFlux

"Reactive programming"

한 때, Java/Spring 진영에서 사람들 입에 많이 오르내렸던 단어가 아닐까 싶다.
Spring WebFlux가 나온 초기에는 혁신적이라는 이야기가 많았던 것 같은데, 시간이 지날수록 그냥 잊혀져간 느낌이 약간 있는 것 같다.

WebFlux는 본연의 Java코드와는 매우 이질적인 코드 스타일이면서 선형적이지 못한 코드 흐름 때문에 여러 방면에서 개발 경험이 좋지 못하다고 개인적으로 생각한다. 아마 사람들이 점점 WebFlux에 관심을 갖지 않게 된 것도 나와 비슷한 이유가 아닐까 싶다.

또, WebFlux는 자체적인 쓰레드(Default. Jetty)를 사용해서 비동기 처리를 하는데, 이러한 특징 때문에 WebFlux에 태운 코드에서 버그/에러가 발생하면 디버깅이 어려워지는 특징 또한 있다.

결국, WebFlux 또한 Java 진영의 Thread 이슈에 대해서 명쾌한 해법을 내놓지는 못했다.


🧐 Virtual Thread란?


Virtual thread는 위에서 언급한 Java Thread의 한계들을 극복할 수 있는 특징들을 갖고 있는 JDK21에 추가된 경량 쓰레드이다. OS쓰레드를 직접 사용하지 않고, JVM 내부의 스케줄링을 통해서 수십/수백만개의 가상 쓰레드를 동시에 사용할 수 있게 한다.

여기서 조심할 부분은 Platform thread를 대체하기 위함이 아닌, Platform thread의 한계를 보완하기 위해서 등장했다고 보는 게 좀 더 정확할 것 같다.

Oracle의 공식문서 상에서도 Virtual thread는 '빠른 속도'의 관점이 아니라 '높은 동시 처리량'을 위해서 만들어졌다고 나와있기도 하다.

그렇다면, Virtual thread는 어떻게 동작하길래 이러한 특징을 가질 수 있는지 알아보자.


🤓 Virtual Thread 파헤치기


1) 구조

  • Fork/Join pool : Carrier thread pool의 역할을 함과 동시에 Virtual thread에 작업을 분배하는 스케줄러의 역할을 한다.
  • Carrier thread : 이름은 다르지만, Platform thread와 개념적으로 동일하다. OS Thread와 1:1로 맵핑되어 있다.
  • Virtual thread : 실질적인 로직이 수행되는 영역으로, OS 커널과 통신 하지 않아서 Context switching 비용이 비교적 매우 작다. 또, 스택 영역에서 동작하던 Platform thread와 달리 Heap메모리에 생성되어 동작하므로 메모리 관리 비용 또한 비교적 작다. 또, Platform thread 보다 작은 Metadata 사이즈를 갖고 있기도 하다.

출처 : 카카오 테크 컨퍼런스 발표

2) 동작 원리

Virtual thread에 특정 로직(작업)이 할당 되면, Carrier thread에 Mount되어서 로직이 수행된다.

할당된 작업에 I/O 작업이 포함되어 있다면, I/O 작업을 만났을 때, Virtual thread는 Carrier thread에서 Unmount되게 된다. 그리고 Carrier thread는 수행할 다른 작업이 있다면, 그 작업을 수행하게 된다.

즉, I/O 작업을 만나면 블로킹되던 Platform thread와 달리 거의 쉼 없이(?) 작업을 처리하는 Carrier thread를 통해서 높은 작업 처리 성능을 확보할 수 있는 것이다.

3) Virtual Thread 사용시 주의사항

Virtual thread를 사용할 때는 몇가지 주의할 점이 있다.

  1. Virtual thread를 사용할 때의 마인드(?) : Virtual thread에는 작은 단위의 Task를 할당해서 처리하고, 소비하는 느낌으로 사용하자. Virtual thread 풀을 관리할 필요도 없고, 비지니스 로직 전체를 Virtual thread에서 수행하는 것은 지양할 필요가 있다.
  2. ThreadLocal 사용을 지양하자 : Virtual thread는 동시에 매우 많이 생성될 가능성이 있다. 그래서 Virtual thread 안에서는 메모리 리소스가 많이 필요한 ThreadLocal과 같은 클래스의 사용을 지양하는 것이 좋다.
  3. Pinning 이슈 조심 : Virtual thread안에서 JNI콜이나 synchronized 구문을 사용하는 코드가 동작하게 되면 Carrier thread가 블로킹이 된다. 즉, Mount되어 있던 Virtual thread 또한 블로킹이 되는 것과 마찬가지이므로, 성능 이슈가 발생할 수 있다.

💡 Pinning 방지하기
1. ReentrantLock
2. Pinning을 감지할 수 있는 디버깅 툴


📒 Virtual Thread 사용하기


1) build.gradle

plugins {
	id 'java'
	id 'org.springframework.boot' version '3.2.4'
	id 'io.spring.dependency-management' version '1.1.4'
}

group = 'study'
version = '0.0.1-SNAPSHOT'

java {
	sourceCompatibility = '21'
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.mysql:mysql-connector-j'
	annotationProcessor 'org.projectlombok:lombok'

	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testCompileOnly 'org.projectlombok:lombok'
	testAnnotationProcessor 'org.projectlombok:lombok'
}

tasks.named('test') {
	useJUnitPlatform()
}

Virtual thread를 사용하기 위해서 설치해야할 라이브러리는 당연히 없지만, I/O 테스트와 로깅 편의를 위해서 MySQL 드라이버와 Lombok과 JPA를 설치해주었다.

2) Spring boot에서 Virtual thread 사용하기

spring.threads.virtual.enabled=true

Spring boot에서 Virtual thread를 사용하려면 위 설정만 추가해주면 된다.
위 설정을 true로 하면, WAS에서 Virtual thread를 처리할 수 있도록 필요한 Bean들이 생성되거나 설정들이 활성화된다.

3) Virtual thread 사용하는 방법

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

@Slf4j
@SpringBootTest
class VirtualThreadApplicationTests {

	@Test
	@DisplayName("Virtual thread 사용법")
	void howToUse() throws Exception {

		Runnable runnable = () -> log.info("Hello world");

		// 1) .startVirtualThread() 사용
		Thread.startVirtualThread(runnable);

		// 2) Builder 사용
		Thread.ofVirtual()
				.name("Virtual thread") // 생략 가능
				.start(runnable);

		// 3) ExecutorService 사용
		ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
		executorService.submit(runnable);

	}

}

Virtual thread를 사용하는 방법은 3가지 정도가 있다.
Thread클래스에서 제공하는 메서드를 사용하는 방법과 빌더 패턴을 사용하는 방법이 있고, ExecutorService의 새롭게 추가된 .newVirtualThreadPerTaskExecutor() 메서드를 사용하는 방법이 있다.


🧐 Virtual Thread 성능 테스트


1) 환경 세팅

서버 (개인 컴퓨터)

  • OS: Ubuntu 20.04
  • JDK: 21

Spring boot & JVM

  • application.properties
spring.datasource.hikari.maximum-pool-size=100
spring.datasource.hikari.minimum-idle=100
server.tomcat.threads.max=100
server.tomcat.threads.min-spare=10
  • JVM
-Xmx512M
-Xms512M
-Djdk.virtualThreadScheduler.maxPoolSize=100
-Djdk.virtualThreadScheduler.parallelism=100

테스트 환경

  • 테스트 도구: K6
  • VUs: 200
  • Duration: 5s

2) Sleep 테스트

    @GetMapping("/sleep")
    public String sleep() throws InterruptedException {
        Thread.sleep(1000);
        return "sleep";
    }

위 API를 Virtual thread를 on/off해서 테스트해보았다.
이 API는 외부 네트워크 I/O를 테스트하기 위한 것이고, 네트워크 I/O 하나를 처리하는데 1초가 걸린다고 가정한 것이다.

🧐 테스트 결과: Virtual thread ON

회차소요 시간 (초)처리량 (개)TPS
15.21000192
25.11000196
35.11000196

🧐 테스트 결과: Virtual thread OFF

회차소요 시간 (초)처리량 (개)TPS
16.460094
26.160098
36.160098

3) Query 테스트

    @GetMapping("/query")
    public String query() {
        String string = jdbcTemplate.queryForList("select sleep(1)").toString();
        log.info(string);
        return string;
    }

이 테스트는 DB I/O를 테스트하기 위한 API이고, DB I/O가 1초가 소요된다고 가정하였다.

🧐 테스트 결과: Virtual thread ON

회차소요 시간 (초)처리량 (개)TPS
16.156893
2654891
36.160098

🧐 테스트 결과: Virtual thread OFF

회차소요 시간 (초)처리량 (개)TPS
16.160098
26.160098
36.160098

4) 결과 분석

예상했던 결과는 Network I/O와 DB I/O 테스트 모두 Virtual thread를 켰을 때, 더 높은 TPS를 뽑아내는 것이였는데, DB I/O에서는 오히려 근소하게 성능이 저하되는 것을 확인하였다.

테스트한 Spring boot서버에서는 MySQL DB를 사용하고 있는데, 검색을 해보니, MySQL JDBC 드라이버에 synchronized를 사용하는 코드가 많아서, Pinning 이슈가 발생할 확률이 매우 높다고 한다.

그래서 예상과 달리 Virtual thread가 DB I/O 테스트에서 더 낮은 성능을 보여준 것 같다.

Virtual thread는 출시된지 얼마되지 않은 기능이여서 다른 라이브러리들과의 호환을 잘 신경써야할 것 같다.

추가적으로, Postgres에서도 쿼리 테스트를 해보고, 커넥션 풀의 커넥션 수도 조정해서 테스트 해보았는데, Virtual thread를 켰을 때와 껐을 때가 비슷한 정도의 성능을 보여주었다.
커넥션의 최대 갯수가 제한되어 있다보니 이러한 결과가 나온 것으로 보인다.


🙏 참고


0개의 댓글