뜨거운 감자, Virtual Thread

SeungHoon·2025년 5월 28일
1

Java

목록 보기
5/5
post-thumbnail

0. 들어가기 전에

  • Thread 에 대해서 좀 더 파고들면서 Java 21 에서 본격적으로 등장한 Virtual Thread에 대해서 포스팅한다.
  • 확실히 최근 핫한 주제라고 느끼는게 각종 대기업의 기술 블로그에서 관련 글을 많이 찾아볼 수 있었다. (네이버, 카카오, 배민)
  • 그 중에서 카카오와 네이버에서 쉽게 정리한 글이 있어 이를 위주로 작성해본다.

1. 과거의 Thread Model

  • JVM 내부에서 Platform thread를 생성할 때 JNI(Java Native Interface) 을 사용해 OS (Kernal) Thread에 직접 매핑되도록 설계되었다.
  • 이 구조는 Context Switching을 통해 OS 리소스를 공유한다.

1-1. 문제점?

  • OS에서 생성할 수 있는 Thread 최대값을 초과해서 Platform Thread를 생성할 수 없다.
  • 새로운 Thread 생성 및 Context Switch 비용이 크다.
  • 각 Thread가 차지하는 메모리 크기가 크다.
  • Spring MVC 는 request-per-thread 구조이기 때문에 받을 수 있는 요청 개수에 제한이 생긴다.
  • 과거에 하드웨어와 OS의 성능을 최대한 끌어올리기 위해 선택된 모델이었다.

1-2. 개선점?

  • 하지만 최근에는 하드웨어와 OS의 발전이 이루어지면서 Kernal 레벨에서 Context Switch, Scheduling 을 하지 않고, Runtime 레벨에서 이루어지는 경량 스레드가 등장한다.
  • 그래서 IO 작업이 많은 네트워크 기반 웹 서버 특성상, 경량 스레드를 활용한 높은 동시성 처리 성능을 위해 Kotlin Coroutine이나 Spring WebFlux 같은 비동기/논블로킹 기술이 많이 사용되었다.
  • 그런 와중에 2018년 Java 진영에서 Project Loom 을 발표한다.

2. Project Loom 에서 등장한 Virtual Thread

  • 이제 Task와 Virtual Thread 가 일대일 대응이 되어 미리 생성된 Carrier Thread 의 스케줄링으로 기존 Model에 비해 리소스가 훨씬 감소되었다.
  • Virtual Thread은 위에서 언급한 과거 Thread Model의 단점을 많이 해결해주었다. 다시 정리해보자.

    OS레벨에서 Context Switch가 없고, JVM 내부에서 실행 상태를 변경해주므로 빠르고 저렴한 Context Switching 가능 (약 10배 감소)
    Platform Thread에 비해 소요 메모리 감소 (약 200배)
    JVM heap 이 허용하는 범위 내 계속 생성 가능

2-1. 컨셉

  • 위 그림과 같이 Heap에 수많은 Virtual Thread를 할당해놓고, 플랫폼 스레드에 대상 Virtual Thread를 마운트/언마운트하여 컨텍스트 스위칭을 수행한다. 따라서 컨텍스트 스위칭 비용이 작아질 수 밖에 없다.
    • Virtual Thread가 Carrier Thread 위에서 실제 실행될 때를 mount라고 하고, 실행이 중단되어 Carrier Thread에서 분리되는 것을 unmount라고 한다.
    • park() 을 실행하면 mount되고, unpark() 을 실행하면 unmount된다
  • 스레드의 크기와 컨텍스트 스위칭 비용이 많이 감소한 모델이기 때문에 Spring MVC/Tomcat 등의 모델이 Spring Webflux/Netty에 비해 가진 단점이 많이 희석되었다.

2-2. 상태

  • Virtual Thread는 총 9가지 상태가 있다.
 	private static final int NEW      = 0;
    private static final int STARTED  = 1;
    private static final int RUNNABLE = 2;     // runnable-unmounted
    private static final int RUNNING  = 3;     // runnable-mounted
    private static final int PARKING  = 4;
    private static final int PARKED   = 5;     // unmounted
    private static final int PINNED   = 6;     // mounted
    private static final int YIELDING = 7;     // Thread.yield
    private static final int TERMINATED = 99;  // final state

1. NEW
• 설명: 아직 시작되지 않은 가상 스레드의 초기 상태.
• Mount 상태: ❌ Unmount
• 전이: Thread.start() 호출 시 → STARTED

2. STARTED
• 설명: 시작되었지만 아직 Carrier Thread에 붙지 않은 상태. JVM이 실행 스케줄링을 준비 중.
• Mount 상태: ❌ Unmount
• 전이: 스케줄러가 스레드를 할당 → PINNED 또는 RUNNABLE

3. PINNED
• 설명: 특정 Carrier Thread에 고정된 상태. 주로 JNI 코드나 블로킹 연산 등으로 인해 가상 스레드가 해당 플랫폼 스레드에 묶인(pinned) 상태입니다.
• Mount 상태: ✅ Mount
• 전이: yield 실패 시 → RUNNING

4. RUNNING
• 설명: 실제로 CPU에서 실행되고 있는 상태 (Carrier Thread 위에서 실행 중)
• Mount 상태: ✅ Mount
• 전이: yield() 또는 작업 완료 → YIELDING, PARKING, TERMINATED

5. YIELDING
• 설명: 다른 Virtual Thread에게 CPU를 양보하려는 상태
• Mount 상태: ✅ Mount
• 전이: Thread.yield() 성공 → RUNNABLE
• 실패 → 다시 RUNNING

6. RUNNABLE
• 설명: 실행 준비가 완료되어 실행 큐에 들어가 있는 상태. 아직 Carrier Thread에 mount되지 않음. 스케줄러에 의해 적절한 Virtual Thread 가 RUNNABLE 하게 된다.
• Mount 상태: ❌ Unmount
• 전이: JVM 스케줄러(기본값은 ForkJoinPool)에 의해 → RUNNING

7. PARKING
• 설명: Virtual Thread가 park() 호출 등으로 스스로 중단하려고 하는 순간의 상태
• Mount 상태: ✅ Mount
• 전이: park() 완료 시 → PARKED

8. PARKED
• 설명: park()에 의해 중단된 상태. 어떤 이벤트가 발생할 때까지 대기.
• Mount 상태: ❌ Unmount
• 전이: unpark() 호출 → RUNNABLE

9. TERMINATED
• 설명: Virtual Thread의 실행이 끝난 상태
• Mount 상태: ❌ Unmount
• 전이: 없음 (종료 상태)

2-3 VirtualThread 구조 보기

  • 다음과 같은 상속 관계를 가지고 있다. Thread 를 상속받은 클래스이다.
  • Virtual Thread 에서 기본적으로 사용하는 스케줄러는 ForkJoinPool 이다. 이는 코드에서 쉽게 확인할 수 있다.
final class VirtualThread extends BaseVirtualThread {
    ...
    private static final ForkJoinPool DEFAULT_SCHEDULER = createDefaultScheduler(); // 여기서 기본 스케줄러 생성
    private static final ScheduledExecutorService UNPARKER = createDelayedTaskScheduler();
    ...
  • ForkJoinPool에 대해서 살펴보자.

ForkJoinPool

  • ForkJoinPool은 Java 7부터 사용 가능한 클래스로 java.util.concurrent에 포함되어 있다. (이름에서부터 동시성 처리와 관련된 클래스임을 알 수 있다)
  • 동일한 작업을 여러 개의 Task로 분리하여 각각 처리하고, 이를 최종적으로 합쳐서 결과를 만들어내는 방식이다. (분할 정복의 개념이다)

왜 ForkJoinPool을 사용하나?

  • 여기서 포인트는 ForkJoinPoolWork Stealing 전략을 사용한다는 것이다.
  • Work Stealing 전략이란 각 워커 스레드가 자신의 작업 큐를 처리하다가 비면, 다른 스레드의 작업 큐에서 작업을 훔쳐서 실행하는 전략을 말한다. (나보다 성실하다)
  • 이 방식은 작은 단위의 많은 작업을 고르게 분산하여 처리하기에 CPU 활용률을 높이고, 높은 동시성 환경에 매우 효과적이다.
  • 그렇기에 수많은 경량 작업을 처리하는 것이 목적인 Virtual Thread과 매우 잘 맞아 사용한다!!

3. 단점 및 한계

  • 단점 및 한계를 보기 전에 장점을 한 번 정리해보자.

    Virtual Thread 의 장점은 JVM이 자체적으로 Virtual Thread를 스케줄링하고 Context Switching 비용이 줄어들어 효율적으로 운영할 수 있다는 것이다.

  • 하지만 Virtual Thread 가 제 성능을 내지 못하는 경우도 존재한다.
  1. Virtual Thread 내에서 synchronized block을 사용하는 경우
  2. JNI를 통해 native method를 사용하는 경우
  • 각각에 대해 성능 저하가 발생하는 이유를 알아보자.

synchronized block

  • Java에서 synchronized을 사용하는 경우 해당 객체의 Monitor Lock을 사용한다. 이를 기반으로 JVM이 내부적으로 스레드 간 동기화를 처리하는 것이다.
  • 그런데 Virtual Thread 가 synchronized block으로 들어가는 경우 Monitor Lock이 Virtual Thread에 걸리는게 아니라, 해당 Virtual Thread가 mount 되어 있는 Carrier Thread에 걸리게 된다. 그렇기 때문에 빠르게 Virtual Thread를 mount / unmount 할 수 없어 성능 저하가 발생한다.
  • 특히 MySQL, UUID 에서 synchronized을 사용한 Lock이 많아 이를 ReentrantLock 으로 바꾸고 있는 추세이다. 간단하게 보자.

MySQL Connector/J 8.0

  • 느낌만 보기 위해 코드 일부분을 가져왔다.

  • ReentrantLock을 적극적으로 사용하고, synchronized 을 제거하는 모습이다. 최신 버전은 어떻게 되어 있는지 궁금해서 찾아보았다.

MySQL Connector/J 9.0

public interface CacheAdapterFactory<K, V> {
	
    // ReentrantLock가 아닌 Lock 인터페이스를 사용하고 있다.
    CacheAdapter<K, V> getInstance(Lock lock, String url, int cacheMaxSize, int maxKeySize); 

}
class PerConnectionLRU implements CacheAdapter<String, QueryInfo> {

        private final int cacheSqlLimit;
        private final LRUCache<String, QueryInfo> cache;
        private final Lock lock; // 여기서도 Lock 인터페이스를 사용
        
        ...
        
        // ReentrantLock 방식을 사용 (명시적인 lock, unlock 사용)
        this.lock.lock();
        try {
            return this.cache.get(key);
        } finally {
            this.lock.unlock();
        }
        ...
 }

native method

  • JVM은 Java 코드와 C/C++ native 코드 간의 경계를 오갈 때, 내부적으로 스택 프레임 전환, 타입 검증, 메모리 포맷 변환 등을 행하는데 이는 비용이 큰 연산이며, 자주 호출되면 큰 오버헤드가 발생한다.
  • native method 호출 중에는 JVM이 Carrier Thread를 반드시 pin하기 때문에, 해당 Virtual Thread는 Carrier Thread에서 unmount 불가능 하다. 그렇기에 성능 저하가 발생한다.

4. 결론

  • Virtual Thread의 경우 Network IO가 많은 서버에서 많은 사용자의 요청을 비교적 적은 스레드로 처리할 수 있게 해준다.
  • Spring WebFlux, Kotlin Coroutine 을 사용하지 않고, 이제 Java로도 비슷한 성능을 낼 수 있게 되었다.
  • 하지만 아직은 Spring 곳곳에 존재하는 synchronized 때문에 제 성능을 내지 못하고 있다. 추후 미래의 Spring 에서는 지금보다 성능이 더 좋아질 것으로 보인다!
  • 현재 시점으로 최신 버전인 Java 24에서 synchronized block을 사용해도 성능 저하가 되지 않도록 코드를 개선되었다! 하지만 여전히 native method 호출 시에는 pinning 여전히 존재한다.

5. 참고자료

https://d2.naver.com/helloworld/1203723
https://tech.kakaopay.com/post/ro-spring-virtual-thread/
https://openjdk.org/jeps/491

profile
공유하며 성장하는 Spring 백엔드 취준생입니다

0개의 댓글