💻 백엔드

JDK 21 : 가상쓰레드(Virtual Thread) Deep dive 해보자! - 1편

twoweekhee 2025. 12. 15. 19:23

안녕하세요 :) 트윅히 입니다.

 

우리가 어떤 기술을 쓸 때 왜 그 기술을 써야 하는 지 어떤 기술이 현재 우리에게 맞을 지 고민하곤 합니다.

mq 중에서는 어떤 걸 쓰면 좋을까? 모니터링 툴은 어떤 걸쓰지? 등등

 

사실 그 중에서 제일 기본이 되는 것 중 하나가 JDK 버전에 대한 것일 수 있을 것 같아요!

JDK1.8을 쓴다하면 엄청 오래 된 걸 쓰고 있다고 생각할 수 있는데 사실 거의 왠만한 기능들은 1.8에서도 쓸 수 있어요!

람다나 스트림도 1.8부터 쓸 수 있었습니다. ㅎㅎ

 

JDK21을 선택한다고 했을 때 가장 중요하게 생각되는 요소가 바로 오늘 소개할

가상스레드 입니다.

가상 스레드란?

- 기존의 스레드보다 가벼운 가짜 스레드

 

플랫폼 스레드 vs 가상 스레드

기존의 스레드를 가상스레드와 비교하려 플랫폼 스레드라고 부릅니다. (진짜 스레드라고 불렀으면 웃겻겠다..)

        Runnable r = () -> {
            System.out.println("Hello World");
        };
        // 플랫폼 스레드 생성 방법
        Thread t = Thread.ofPlatform().start(r);
        
        // 가상 스레드 생성 방법
        Thread v = Thread.ofVirtual().start(r);

가상 스레드의 특징

- 가상 스레드는 항상 데몬 스레드로만 가능하다. (main은 데몬 스레드를 기다려주지 않는다. main이 기다리지 않아도 되는 스레드.)

- 가상스레드의 우선순위는 항상 5호 고정, 가상스레드의 우선 순위는 바뀌지 않는다. (우선순위 관리가 어렵기만하고 효과 없어서 ㅋㅋ) (아래 예시 참조!!)

- 스레드에 이름을 부여하지 않는 것이 기본, 너무 많이 생성되니까 이름만 설정 안해도 낭비 줄임

- 가상 스레드는 그룹으로 묶어서 관리하지 않는다. 그룹으로 다루는 것이 별 의미가 없고 관리하는 데 부담만 됨.

 public final void setPriority(int newPriority) {
        if (newPriority > MAX_PRIORITY || newPriority < MIN_PRIORITY) {
            throw new IllegalArgumentException();
        }
        // 가상 스레드 아닐때만 ..
        if (!isVirtual()) {
            priority(newPriority);
        }
    }

 

플랫폼 스레드의 특징

- OS 스레드와 1:!로 연결

- 쓰레드 객체를 생성하는 건 단순히 자바 객체를 생성하는 것이고 start()를 호출해야 플랫폼스레드가 생성되며 실행

- OS스레드는 OS의 스케줄러에 의해 실행순서가 결정되며, JVM도 스레드의 스케줄을 맘대로 못함(내자식인ㄷ..!!!!)

 

결론적으로

가상 스레드가 플랫폼 스레드나 객체를 생성하는 것이기에 생성 시간은 별차이 없지만 start()를 호출하는 순간 성능 차이를 보인다. 플랫폼스레드는 그 즉시 OS 에 요청하여 OS 스레드를 생성해야함. 하지만 가상스레드는 꼭 OS 스레드를 생성하지 않아도 된다. 또 하지만!! 결국 이를 실행하는 건 OS 스레드 라는 사실을 기억하자~

 

왜 가상 스레드를 사용하는 것이 좋을까?

1. 적은 메모리 할당

플랫폼 스레드는 start()가 호출되면 스레드마다 호출 스택(고정크키 2MB)이 할당

가상스레드는 호출 스택이 필요 없음. 대신 스택 청크(4KB)를 필요한 갯수만큼 생성해서 연결. 스택청크를 재사용하기 때문에 메모리 할당와 제거에 걸리는 시간이 적음!

2. 컨텍스트 스위칭 비용이 낮다!

플랫폼 스레드는 컨텍스트 스위칭 할때 OS로 요청을 보내기 때문에 JVM 내부에서 스위칭하는 가상스레드보다 컨텍스트 스위칭 비용이 낮다.

 

가상스레드가 시작되면
-> 가상 스레드의 작업이 스케줄러에게 전달되고,
-> 이 작업은 워커 스레드의 작업 큐에 저장이 됨.
-> 워커 스레드는 자신의 작업 큐에 있는 작업을 꺼내서 처리
: 하나의 플랫폼 스레드에 여러개의 가상 스레드가 번갈아 가면서 연결되어 작업이 처리 됨!!


여기서 궁금점이 들어야 한다. -> 그럼 어떤식으로 가상스레드와 플랫폼 스레드는 연결이 될까??

가상 스레드를 생성하고 시작하면 스케줄러의 워커 스레드(이를 가상 스레드의 캐리어 스레드라고 함) 중의 하나와 연결이 되는데, 

이 둘을 연결하는 것을 마운팅(mounting)

분리하는 것을 언마운팅(unmounting)이라고 한다. (언마운트가 되면 스케줄링 대기 줄 다시 들어가게 됨)

 

마운트 언마운트가 반복되며 캐리어 스레드는 여러 가상 스레드를 번갈아가면서 실행하게 된다~~ (이는 컨티뉴에이션 때문)

 

만약에 작업을 계속할 수 없는 상태가 된다면 잠시 스케줄링 대상에서 제거를 해야하는데 그럴때 사용하는 것이 park와 unpark이다.

스케줄링 대상에서 제외시키고 싶다면 unpark()를

스케줄링 대상에 다시 넣고 싶다면 park()를 호출하면 된다.  

 

그럼 언마운팅이 되고 다시 마운팅이 되면 실행시키는 캐리어 스레드가 변할 수 있다.! 그럼..?? 여기서 의문점 아까 스택 청크는 그럼 어디에 있는거지??

## 스택 청크는 캐리어 스레드에 있지 않습니다!

### 잘못된 이해 ❌
```
┌──────────────────────┐
│  Carrier Thread 1    │
│  ┌────────────────┐  │
│  │  Stack Chunk   │  │  ← 여기 있는 게 아님!
│  └────────────────┘  │
└──────────────────────┘
```

### 올바른 이해 ✅
```
┌──────────────────────┐
│  Virtual Thread      │
│  ┌────────────────┐  │
│  │  Stack Chunk   │  │  ← 스택은 Virtual Thread 소유!
│  │  (Heap에 저장)   │  │
│  └────────────────┘  │
│         ↓            │
│    마운트/언마운트       │
│         ↓            │
└──────────────────────┘
         ↓
┌──────────────────────┐
│  Carrier Thread 1    │  ← 스택 청크 없음!
│  (실행만 담당)          │
└──────────────────────┘

Pinned 문제가 발생할 수 있다.

원래는 가상 스레드가 작업을 진행할 수 없는 상황일 때 park()가 호출되어 캐리어 스레드로부터 언마운트 되어야 하지만, 언마운트 되지 않은 상태로 멈춰 있는 상태가 발생할 때가 있다. 어떨때 이러지..? 그건 바로 synchronized 블럭을 사용할 때!!!

 

JDK 21에서 synchronized 블럭은 네이티브 모니터 락(OS의 mutex/semaphore를 직접 사용하는 락)을 사용하기 때문에 캐리어 스레드에 종속 될 수 밖에 없는데 가상스레드 안의 synchronzied 블럭 안에서 park()가 호출되면 언마운트가 되어야 하지만 언마운트가 되고 다시 마운트가 될 때에 다른 캐리어 스레드에서 실행이 될 수도 있으므로 그렇게 되면 이미 락을 기존의 캐리어 스레드에서 가지고 있기 때문에 진퇴 양난의 상황이 발생!!!! 그래서 언마운트가 될 수 없다.

 

하지만??? JDK 24에서 synchronized블럭 락 구현이 자바 레벨의 락으로 변경되어 이젠 이런 문제 없이 쓸 수 있다!!

(역시 신상이 쵝오?)

 

가상 스레드 사용 시 주의사항

1. Thread Local 사용을 주의하자

 thread local 이란 스레드마다 독립적인 변수를 가질 수 있게 해주는 Java의 특수한 변수 저장소입니다.

가상 스레드는 엄청 많이 만들어질 수 있기 때문에 약간의 메모리 누수도 경계해야한다.

원래는 수동으로 remove()를 호출해야하지만, scoped value를 사용하면 자동으로 관리를 해준다.

* scoped value 기능은 원래 Preview 였지만 JDK 25부터 정식 기능이 되었다. 

// ❌ 비권장 - 여러 ScopedValue 사용
private static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
private static final ScopedValue<String> SESSION_ID = ScopedValue.newInstance();
// 캐시 크기 낭비!

// ✅ 권장 - Record로 묶어서 하나의 ScopedValue 사용
record RequestContext(String userId, String requestId, String sessionId) {}

private static final ScopedValue<RequestContext> CONTEXT = 
    ScopedValue.newInstance();

// 사용
RequestContext ctx = new RequestContext("user-123", "req-456", "session-789");
ScopedValue.where(CONTEXT, ctx).run(() -> {
    RequestContext context = CONTEXT.get();
    System.out.println("User: " + context.userId());
    System.out.println("Request: " + context.requestId());
});

 

https://openjdk.org/jeps/506

 

JEP 506: Scoped Values

JEP 506: Scoped Values AuthorAndrew Haley & Andrew DinnOwnerAndrew HaleyTypeFeatureScopeSEStatusClosed / DeliveredRelease25Componentcore-libsDiscussionloom dash dev at openjdk dot orgRelates toJEP 487: Scoped Values (Fourth Preview)Reviewed byAlan Bate

openjdk.org

 

https://docs.oracle.com/en/java/javase/25/docs/api//java.base/java/lang/ScopedValue.html

 

ScopedValue (Java SE 25 & JDK 25)

 

docs.oracle.com

 

2. 가상 스레드를 풀링하지 말자

 

스레드 풀링의 장점은 미리 생성해놨다가 반복적으로 재사용함으로써 생성시간을 절약하는 것인데, 가상 스레드는 생성비용이 기존의 1/100정도 이므로 필요할 때 생성해도 충분! 풀링하기 위한 관리 비용이 더든다. 스레드 풀 갯수를 고정시킴으로써 공유 자원에 갑자기 요청이 몰리는 것을 막기 위한 용도로 쓰고 싶다면 차라리 세마포어를 사용해보자!

 

세마포어는 공유 자원을 동시에 사용할 수 있는 스레드의 수를 제어할 수 있다.
JDK에서는 세마포어의 구현체인 java.util.concurrent.Semaphore 클래스를 제공하고 있다.

 

쓰다 보니까.. 살짝 길어진 감이 있어서 

컨티뉴에이션과 was와의 연동성, 그리고 아쉬운 점에 대한 부분은 다음 글에서 다루도록 하겠다..! (절대 지친 것 아님..)

 

 

가상 스레드에 대해서 좀 더 구체적으로 알아가는 시간이 되었기를..

어떤 걸 도입할 때는 언제나 왜 도입하는 지 잘 알아보자..!