📌 글에서 사용되는 깃헙
Java 프로그램은 기본적으로 하나의 쓰레드에서 실행이 되고, 병렬처리를 하기 위해서는 또 다른 쓰레드를 사용하게 된다.
이렇게 Java 프로그램이 돌아가는 쓰레드를 Platform thread
라고 부르는데, Platform thread
는 OS Thread
를 Wrapping한 쓰레드이여서, Java에서 쓰레드를 사용한다는 것은 OS Thread
를 사용한다는 것과 다름이 없는 것이다.
특히나 스프링으로 개발한 애플리케이션에서는 하나의 요청(Request) 당 하나의 쓰레드를 할당하는 Thread per request
방식으로 쓰레드가 사용되는데, 이는 하나의 요청당 하나의 OS Thread
가 필요하다는 이야기이고, 많은 양의 트래픽을 처리하기 위해서는 많은 OS Thread
가 필요하다는 의미와 같다.
하지만, OS Thread
를 사용하게 되면 발생하는 한계점들이 있는데 그 한계점들은 아래와 같다.
OS Thread
를 사용하고 관리하는 데에는 자원(메모리, CPU)이 많이 필요함.물론 Java/Spring 진영에서도 이런 한계점을 그냥 두고 있었던 것은 아니고, 한계 극복을 위해서 다양한 기술들이 탄생하게 됐다.
Kotlin의 coroutine은 아마 많은 Java 개발자들에게 Kotlin을 쓰게 만든 가장 큰 이유 중에 하나이지 않을까 싶다.
Java에서 지원하지 않는 경량형 쓰레드 모델을 Kotlin이라는 언어 레벨에서 지원하다보니, 이는 매우 강력한 장점이라고 평가됐었다.
하지만, Java개발자 또는 Java로 서버 애플리케이션이 이미 만들어져있는 회사 입장에서는 또 다른 언어를 도입한다는 것은 큰 허들이므로, Kotlin coroutine은 좋은 대안이라고 할 수는 있지만, 근본적인 한계 극복 방법이라고 볼 수는 없었다.
즉, 당연하게도 Java 진영에서는 여전히 Thread에 대한 이슈를 갖고 있었다.
"Reactive programming"
한 때, Java/Spring 진영에서 사람들 입에 많이 오르내렸던 단어가 아닐까 싶다.
Spring WebFlux가 나온 초기에는 혁신적이라는 이야기가 많았던 것 같은데, 시간이 지날수록 그냥 잊혀져간 느낌이 약간 있는 것 같다.
WebFlux는 본연의 Java코드와는 매우 이질적인 코드 스타일이면서 선형적이지 못한 코드 흐름 때문에 여러 방면에서 개발 경험이 좋지 못하다고 개인적으로 생각한다. 아마 사람들이 점점 WebFlux에 관심을 갖지 않게 된 것도 나와 비슷한 이유가 아닐까 싶다.
또, WebFlux는 자체적인 쓰레드(Default. Jetty)를 사용해서 비동기 처리를 하는데, 이러한 특징 때문에 WebFlux에 태운 코드에서 버그/에러가 발생하면 디버깅이 어려워지는 특징 또한 있다.
결국, WebFlux 또한 Java 진영의 Thread 이슈에 대해서 명쾌한 해법을 내놓지는 못했다.
Virtual thread는 위에서 언급한 Java Thread의 한계들을 극복할 수 있는 특징들을 갖고 있는 JDK21에 추가된 경량 쓰레드이다. OS쓰레드를 직접 사용하지 않고, JVM 내부의 스케줄링을 통해서 수십/수백만개의 가상 쓰레드를 동시에 사용할 수 있게 한다.
여기서 조심할 부분은 Platform thread
를 대체하기 위함이 아닌, Platform thread
의 한계를 보완하기 위해서 등장했다고 보는 게 좀 더 정확할 것 같다.
Oracle의 공식문서 상에서도 Virtual thread는 '빠른 속도'의 관점이 아니라 '높은 동시 처리량'을 위해서 만들어졌다고 나와있기도 하다.
그렇다면, Virtual thread는 어떻게 동작하길래 이러한 특징을 가질 수 있는지 알아보자.
출처 : 카카오 테크 컨퍼런스 발표
Virtual thread에 특정 로직(작업)이 할당 되면, Carrier thread에 Mount되어서 로직이 수행된다.
할당된 작업에 I/O 작업이 포함되어 있다면, I/O 작업을 만났을 때, Virtual thread는 Carrier thread에서 Unmount되게 된다. 그리고 Carrier thread는 수행할 다른 작업이 있다면, 그 작업을 수행하게 된다.
즉, I/O 작업을 만나면 블로킹되던 Platform thread와 달리 거의 쉼 없이(?) 작업을 처리하는 Carrier thread를 통해서 높은 작업 처리 성능을 확보할 수 있는 것이다.
Virtual thread를 사용할 때는 몇가지 주의할 점이 있다.
JNI콜
이나 synchronized
구문을 사용하는 코드가 동작하게 되면 Carrier thread가 블로킹이 된다. 즉, Mount되어 있던 Virtual thread 또한 블로킹이 되는 것과 마찬가지이므로, 성능 이슈가 발생할 수 있다.💡 Pinning 방지하기
1. ReentrantLock
2. Pinning을 감지할 수 있는 디버깅 툴
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를 설치해주었다.
spring.threads.virtual.enabled=true
Spring boot에서 Virtual thread를 사용하려면 위 설정만 추가해주면 된다.
위 설정을 true
로 하면, WAS에서 Virtual thread를 처리할 수 있도록 필요한 Bean들이 생성되거나 설정들이 활성화된다.
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()
메서드를 사용하는 방법이 있다.
spring.datasource.hikari.maximum-pool-size=100
spring.datasource.hikari.minimum-idle=100
server.tomcat.threads.max=100
server.tomcat.threads.min-spare=10
-Xmx512M
-Xms512M
-Djdk.virtualThreadScheduler.maxPoolSize=100
-Djdk.virtualThreadScheduler.parallelism=100
@GetMapping("/sleep")
public String sleep() throws InterruptedException {
Thread.sleep(1000);
return "sleep";
}
위 API를 Virtual thread를 on/off해서 테스트해보았다.
이 API는 외부 네트워크 I/O를 테스트하기 위한 것이고, 네트워크 I/O 하나를 처리하는데 1초가 걸린다고 가정한 것이다.
회차 | 소요 시간 (초) | 처리량 (개) | TPS |
---|---|---|---|
1 | 5.2 | 1000 | 192 |
2 | 5.1 | 1000 | 196 |
3 | 5.1 | 1000 | 196 |
회차 | 소요 시간 (초) | 처리량 (개) | TPS |
---|---|---|---|
1 | 6.4 | 600 | 94 |
2 | 6.1 | 600 | 98 |
3 | 6.1 | 600 | 98 |
@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초가 소요된다고 가정하였다.
회차 | 소요 시간 (초) | 처리량 (개) | TPS |
---|---|---|---|
1 | 6.1 | 568 | 93 |
2 | 6 | 548 | 91 |
3 | 6.1 | 600 | 98 |
회차 | 소요 시간 (초) | 처리량 (개) | TPS |
---|---|---|---|
1 | 6.1 | 600 | 98 |
2 | 6.1 | 600 | 98 |
3 | 6.1 | 600 | 98 |
예상했던 결과는 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를 켰을 때와 껐을 때가 비슷한 정도의 성능을 보여주었다.
커넥션의 최대 갯수가 제한되어 있다보니 이러한 결과가 나온 것으로 보인다.