과거에 JDK21이 LTS 버전으로 선정된 후, Virtual thread라는 구미 당기는 신규 기능을 알게 되었다.
Go의 고루틴, Kotlin의 코루틴과 같은 경량 쓰레드를 Java에서도 쓸 수 있게 된 것이다.
그래서 호다다닥 해당 기능에 대해서 사용해봤었는데, 매우 치명적이게도 Pinning 이슈가 있음을 확인하였다.
이론상, Virtual thread를 사용하면 실질적인 로직은 Virtual thread에서 실행되고, I/O작업에 대해서만 Carrier thread가 처리하는 방식이여서 Virtual thread에서 실행되는 로직과 무관하게 Carrier thread는 블락킹없이 작업을 처리할 수 있어야하는데, 로직에서 JNI가 호출되거나 synchronized 키워드를 사용하면 Carrier thread가 블로킹되버리는 이슈가 있었다.
이를 바로 Pinning 이슈라고 하며, 서비스 개발할 때 무조건 필요한 DB와 관련된 많은 라이브러리에는 synchronized 키워드가 많이 사용되어 있었다.
그래서 Virtual thread라는 혁명적인 기능이 추가되었지만 사실상 실무에서는 사용할 수 없던 상황이 됐었다.
얼마전 새로운 JDK LTS 버전이 릴리즈됐었다. JDK 25가 그 대상이다.
그래서 '오~ 또 뭐가 업데이트됐으려나'하고 신규 기능을 살펴보던 중, JDK24에 포함된 JEP 491을 알게 되었다.

처음 JEP 491 제목을 보고, 깜짝 놀라서 눈을 동그랗게 뜨고 봤던 거 같다.
공식 문서 외에도 다양한 블로그 글과 커뮤니티 글들을 확인해보니, 진짜로 Pinning 이슈가 해소된 것으로 확인했다. (JNI 호출 시 발생하는 Pinning 이슈는 아직 해소 안됨)
그래~~서, 직접 테스트 코드를 작성해서 테스트를 해보기로 했다. 🤔
바로 드가자 🔥
🔗 깃헙
이번 글에서는 synchronized를 사용하며, DB 쿼리를 수행하는 임의의 메서드를 만들고 이 메서드를 Virtual thread를 사용하여 실행해보고, 성능을 측정해보도록 하겠다.
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.6'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'study'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '25'
}
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()
}
Java버전은 25버전으로 세팅해주고, Spring boot, JPA,MySQL을 사용할 수 있도록 의존성을 정의해준다.
(Pinning 이슈를 테스트할 때에는 Java 버전을 21버전으로 바꿔가며 작업할 예정)
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/vt
username: root
password: 1234
hikari:
maximum-pool-size: 100
minimum-idle: 100
설정 파일에서는 다른 값들은 중요하지 않고, DB 커넥션 풀 사이즈를 원하는 값으로 설정해준다.
@Slf4j
@SpringBootTest
class VirtualThreadPinningTests {
@Autowired
private JdbcTemplate jdbcTemplate;
// Pinning 관찰용: synchronized로 모니터를 잡은 상태에서 블로킹 호출 실행
public void runQuery(int count) {
Object lock = new Object();
synchronized (lock) {
jdbcTemplate.queryForList("select sleep(1)");
}
log.info("실행(pinned {}/100)", count);
}
@Test
@DisplayName("Virtual Thread - pinning 이슈")
void test() throws ExecutionException, InterruptedException {
long startTime = System.currentTimeMillis();
// 작업 정의
List<Runnable> tasks = new ArrayList<>();
for (int i = 0; i < 100; i++) {
int count = i + 1;
tasks.add(() -> runQuery(count));
}
// 작업 실행 및 종료 보장
ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
List<? extends Future<?>> futures = tasks.stream()
.map(executorService::submit)
.toList();
// 작업 취합
for (Future<?> future : futures) {
future.get();
}
// 결과 출력
long endTime = System.currentTimeMillis();
long elapsedMs = endTime - startTime;
double tps = 100.0 / (elapsedMs / 1000.0);
log.info("전체 실행 시간: {} ms", elapsedMs);
log.info("TPS: {}", String.format("%.2f", tps));
}
}
1초 Sleep하는 쿼리를 synchronized 키워드를 붙여서 실행하도록 메서드를 만들어주고, 이를 100회 실행하는 테스트 코드를 작성하였다.

테스트 코드를 실행해보면, 총 2.6초 정도의 시간이 소요됐고, TPS는 37.92로 측정된 것을 확인할 수 있다.
Virtual thread가 정상적으로 동작했을 것으로 보여진다.
💡
build.gradle에서 Java버전을 21로 변경하고, IDE를 사용한다면 프로젝트 실행에 사용된느 JDK의 버전도 21로 변경하고 테스트 코드를 실행한다.

테스트 코드를 실행해보면, 총 9초 정도의 시간이 소요됐고, TPS는 11.01로 측정된 것을 확인할 수 있다.
25버전과 성능 차이가 매우 많이 나고, 이를 통해서 JEP 491을 통해서 확실히 Pinning 이슈가 해결된 것으로 생각된다. 🔥🔥🔥
Virtual thread가 처음 나왔을 때, 너무 쓰고 싶었는데 Pinning 이슈 때문에 사용할 수 없게 되어서 많이 아쉬웠었는데, 예상했던 것보다 빠르게 Pinning 이슈가 해결된 것 같아서 좋고, 개발자의 개발자님들에게 리스펙하는 마음이 드는 것 같다. 🙂
실무에서도 활용할 수 있을지 좀 더 검토하고, 회사의 팀원들에게도 공유해보도록 해야겠다! 🫡