가상 스레드와 ScopedValue
가상 스레드로 블로킹 작업마다 스레드를 하나씩 쓰고, Semaphore로 한정된 자원의 동시 사용을 제한하며, ScopedValue로 요청 문맥을 매개변수 없이 전달하는 방법을 배웁니다.
서버 프로그램은 요청 하나를 처리하는 동안 대부분의 시간을 데이터베이스나 다른 서비스의 응답을 기다리며 보냅니다. 요청마다 스레드를 하나씩 주고 그 스레드가 블로킹 호출로 기다리게 하는 방식은 코드를 위에서 아래로 읽을 수 있어서 이해하기 쉽습니다. 그런데 new Thread나 스레드 풀이 만드는 플랫폼 스레드(platform thread)는 운영체제 스레드와 하나씩 짝을 이루므로 비싸고, 수천 개 넘게 만들기 어렵습니다. 그래서 ExecutorService로 작업 실행하기에서 본 스레드 풀의 크기가 곧 동시에 처리할 수 있는 요청 수의 한계가 됩니다.
Java 21에서 정식 기능이 된 가상 스레드(virtual thread)는 기다리는 동안 운영체제 스레드를 놓아 주므로, 블로킹 코드를 그대로 두고도 작업마다 스레드를 하나씩 줄 수 있습니다. 다만 스레드가 많아진다고 데이터베이스 연결이나 CPU 코어가 늘지는 않습니다. 이 글의 앞부분에서는 가상 스레드를 쓰는 방법과 Semaphore로 실제 자원의 사용량을 제한하는 방법을 다룹니다. 뒷부분에서는 요청 식별자 같은 문맥 값을 매개변수 없이 전달하는 ScopedValue를 다루는데, ScopedValue는 Java 25에서 정식 기능이 되었으므로(JEP 506) JDK 25에서는 프리뷰 옵션 없이 컴파일하고 실행할 수 있습니다.
기다리는 동안 운영체제 스레드를 놓아 주는 스레드
가상 스레드는 JVM이 관리하는 가벼운 스레드입니다. 가상 스레드의 코드도 결국 운영체제 스레드 위에서 실행되는데, 이렇게 가상 스레드를 실행해 주는 플랫폼 스레드를 운반 스레드(carrier thread)라고 합니다. JVM은 기본적으로 CPU 코어 수만큼 운반 스레드를 두고, 실행할 가상 스레드를 그 위에 올려(mount) 실행합니다.
플랫폼 스레드와의 차이는 기다릴 때 드러납니다. 가상 스레드가 Thread.sleep이나 소켓 읽기처럼 기다려야 하는 호출을 만나면, JVM은 그 가상 스레드의 호출 스택을 힙에 옮겨 두고 운반 스레드에서 내립니다(unmount). 비워진 운반 스레드는 곧바로 다른 가상 스레드를 실행하고, 기다리던 일이 끝나면 내려 두었던 가상 스레드를 다시 비어 있는 운반 스레드에 올려 이어서 실행합니다. 그래서 기다리는 가상 스레드는 운영체제 스레드를 차지하지 않고 힙 메모리만 조금 씁니다. 코드에서는 평범한 블로킹 호출로 보이지만, 실제로는 운영체제 스레드 몇 개가 수많은 작업을 번갈아 실행하는 셈입니다.
다음 예제로 확인해 보겠습니다. 1초씩 잠드는 작업 1만 개를 가상 스레드로 실행하고 걸린 시간을 잽니다. Executors.newVirtualThreadPerTaskExecutor()는 제출한 작업마다 새 가상 스레드를 시작하는 실행기이고, try-with-resources가 실행기를 닫을 때 호출되는 close()는 제출된 작업이 모두 끝날 때까지 기다립니다.
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
public final class TenThousandSleepers {
public static void main(String[] args) {
AtomicInteger finished = new AtomicInteger();
long start = System.nanoTime();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
finished.incrementAndGet();
return null;
});
}
}
long millis = Duration.ofNanos(System.nanoTime() - start).toMillis();
System.out.println("finished=" + finished.get());
System.out.println("elapsed=" + millis + "ms");
}
}CPU 코어가 2개인 환경의 JDK 21에서 실행한 결과입니다. 걸린 시간은 실행할 때마다 조금씩 다릅니다.
finished=10000
elapsed=1164ms운반 스레드는 코어 수와 같은 2개뿐인데도 작업 1만 개가 1초 남짓에 모두 끝났습니다. 작업들이 잠든 동안에는 운반 스레드를 놓아 주므로, 1만 개의 기다림이 동시에 겹쳐서 진행되었기 때문입니다. 같은 일을 플랫폼 스레드 100개짜리 풀로 하면 스레드마다 작업 100개를 차례로 처리해야 하므로 적어도 100초가 걸립니다. 람다가 return null;로 끝나는 것은 Thread.sleep이 던지는 검사 예외 InterruptedException을 그대로 둘 수 있도록, 예외를 던질 수 있는 Callable로 제출하기 위해서입니다.
반대로 기다림 없이 계산만 하는 작업은 가상 스레드로 바꿔도 빨라지지 않습니다. 계산은 운반 스레드, 즉 CPU 코어에서만 진행되기 때문입니다. 그리고 가상 스레드는 만들기가 싸므로 풀에 모아 재사용하지 않습니다. 작업마다 새로 만들고, 작업이 끝나면 버립니다.
이번에는 여러 작업의 결과를 모아 보겠습니다. 다음 예제는 게시판 세 개의 이름을 받아, 5밀리초 동안 조회를 흉내 낸 뒤 대문자로 바꾼 결과를 모읍니다. invokeAll은 작업 목록을 한꺼번에 제출하고, 모든 작업이 끝나면 각 작업의 Future를 담은 목록을 돌려줍니다.
import java.time.Duration;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
public final class VirtualThreadBoardLookup {
private static String load(String slug) throws InterruptedException {
Thread.sleep(Duration.ofMillis(5));
return slug.toUpperCase();
}
public static void main(String[] args) throws Exception {
List<String> slugs = List.of("welcome", "event", "question");
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Callable<String>> tasks =
slugs.stream().<Callable<String>>map(slug -> () -> load(slug)).toList();
List<String> values =
executor.invokeAll(tasks).stream()
.map(
future -> {
try {
return future.get();
} catch (Exception exception) {
throw new IllegalStateException(exception);
}
})
.toList();
System.out.println(values);
}
}
}invokeAll은 모든 작업이 끝난 뒤에 돌아오므로, 이어지는 future.get()은 기다리지 않고 바로 결과를 꺼냅니다. 각 작업은 게시판 이름을 대문자로 바꿔 돌려줍니다.
[WELCOME, EVENT, QUESTION]slug -> () -> load(slug)는 게시판 이름 하나를 받아 '그 게시판을 조회하는 작업'을 돌려주는 람다이고, 그 앞의 <Callable<String>>은 만들어지는 값이 Callable<String>이라고 컴파일러에게 알려 주는 표기입니다. 결과가 입력과 같은 순서로 나온 것은 invokeAll이 돌려주는 Future 목록이 작업을 넣은 순서를 따르기 때문입니다. 그러니 이 출력으로는 어느 작업이 먼저 끝났는지 알 수 없습니다. 작업이 예외로 끝났다면 future.get()이 그 예외를 ExecutionException으로 감싸 던지는데, 이 예제는 그것을 다시 IllegalStateException으로 바꿔 스트림 밖으로 내보냅니다.
더 깊이: 운반 스레드를 놓아 주지 못하는 경우
가상 스레드가 기다릴 때 언제나 운반 스레드에서 내려오는 것은 아닙니다. 네이티브 메서드나 외부 함수(FFM API)를 실행하는 동안에는 가상 스레드가 운반 스레드에 고정(pinning)되어, 기다리는 동안에도 운반 스레드를 붙잡고 있습니다. JDK 21부터 23까지는 synchronized 블록이나 메서드 안에서 기다릴 때도 고정되었지만, JDK 24에서 이 제한이 없어졌습니다(JEP 491). 또 파일 시스템 작업 상당수는 운반 스레드를 붙잡은 채 기다리는데, JDK는 그동안 스케줄러의 스레드를 잠시 늘려 빈자리를 메웁니다.
고정이 프로그램을 틀리게 만들지는 않지만, 고정된 가상 스레드가 많아지면 운반 스레드가 모자라 확장성이 떨어집니다. 운영 환경에서는 JFR(JDK Flight Recorder)의 jdk.VirtualThreadPinned 이벤트로 이런 곳을 찾습니다. 이 이벤트는 기본으로 켜져 있으며, 가상 스레드가 고정된 채 20ms보다 오래 기다린 경우를 기록합니다.
스레드가 많아져도 자원은 그대로입니다
가상 스레드를 10만 개 만들 수 있어도, 데이터베이스 연결이 20개라면 동시에 실행할 수 있는 질의는 20개입니다. 플랫폼 스레드 풀을 쓸 때는 풀 크기가 동시 실행 수를 저절로 제한했지만, 가상 스레드에는 그런 상한이 없습니다. 그래서 한정된 자원 앞에는 허용량을 따로 정해야 하고, 이때 쓰는 도구가 세마포어(Semaphore)입니다. 세마포어는 정해진 수의 허가(permit)를 가지고 있어서, acquire()는 허가를 하나 얻을 때까지 기다리고 release()는 허가를 하나 돌려줍니다. 허가가 모두 나가 있으면 다음 acquire()는 누군가 허가를 돌려줄 때까지 기다립니다.
다음 예제는 작업 12개를 가상 스레드로 실행하되, 원격 호출을 흉내 낸 3밀리초짜리 구간에는 허가 3개짜리 세마포어를 둡니다. 작업 수와, 허가를 가지고 그 구간에 들어가는 작업 수를 따로 정한 것입니다.
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;
public final class VirtualThreadsWithResourceLimit {
private static final Semaphore REMOTE_LIMIT = new Semaphore(3);
private static String load(int id) throws InterruptedException {
REMOTE_LIMIT.acquire();
try {
Thread.sleep(Duration.ofMillis(3));
return "note-" + id;
} finally {
REMOTE_LIMIT.release();
}
}
public static void main(String[] args) throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (int id = 1; id <= 12; id++) {
int noteId = id;
futures.add(executor.submit(() -> load(noteId)));
}
for (Future<String> future : futures) {
System.out.println(future.get());
}
}
}
}load는 허가를 얻은 뒤 3밀리초를 쉬고, finally에서 허가를 돌려줍니다. acquire()를 try 바깥에 둔 위치가 왜 중요한지는 바로 다음에 다룹니다.
note-1
note-2
note-3
note-4
note-5
note-6
note-7
note-8
note-9
note-10
note-11
note-12출력이 번호 순서인 것은 Future를 제출한 순서대로 읽었기 때문이고, 작업이 끝난 순서와는 관계가 없습니다. 이 출력에서 알 수 있는 것은 작업 12개가 모두 끝났다는 사실까지입니다. 허가가 3개이므로 load의 try 구간에 동시에 들어가 있는 작업은 많아야 3개이고, 나머지는 acquire()에서 차례를 기다립니다. 기다리는 쪽도 가상 스레드이므로 이 대기에는 운영체제 스레드가 들지 않습니다. 다만 대기 중인 스레드가 몇 개였는지, 전체가 얼마나 걸렸는지는 이 출력만으로 알 수 없습니다. 연습 문제 1에서 동시에 허가를 가진 작업 수를 직접 세어 봅니다.
허가를 얻은 경로에서만 반환한다
acquire가 try 앞에서 성공해야 허가를 보유하고 finally에서 release합니다. 대기 중 인터럽트가 발생하면 허가를 반환하지 않고, 획득 후 인터럽트가 발생하면 반환하는 소유 상태를 단계로 구분할 수 있습니다.
허가를 얻은 경로에서만 반환한다
acquire 성공 뒤에만 finally 진입
허가를 얻은 경로에서만 반환한다
acquire는 try 앞에 있습니다. 허가를 얻기 전에 인터럽트되면 release하지 않고, 얻은 뒤에는 finally에서 반환합니다.
미획득 출구는 finally 밖에 있다
acquire가 try 앞에 있다. 대기 중 인터럽트는 release 없이 나간다. 성공해 허가 한 개를 얻은 경로만 정상·sleep 중단 모두 finally에서 한 개를 반환한다. 초기 허가 3·전체 제출 12의 sleep 모의 작업이며 실측 원격 요청 동시 수가 아니다.
상태별 상세 해설
| 진행 경로 | 허가증 보유 | 원문의 release |
|---|---|---|
| acquire 대기 중 인터럽트 | 획득하지 못함 | try 진입 전 예외; 반환하지 않음 |
| 획득 후 정상 작업 | 1개 보유 | finally에서 1개 반환 |
| 획득 후 sleep 인터럽트 | 1개 보유 | finally에서 반환한 뒤 예외 전파 |
- acquire 대기 중 인터럽트
- 허가증 보유
- 획득하지 못함
- 원문의 release
- try 진입 전 예외; 반환하지 않음
- 획득 후 정상 작업
- 허가증 보유
- 1개 보유
- 원문의 release
- finally에서 1개 반환
- 획득 후 sleep 인터럽트
- 허가증 보유
- 1개 보유
- 원문의 release
- finally에서 반환한 뒤 예외 전파
공유 Semaphore의 초기 허가 수는 3이므로 허가를 보유한 작업은 최대 3개입니다. 원문은 sleep 모의 작업이며 실제 원격 요청 수나 관측된 최고 동시 실행 수를 측정하지 않습니다.
이 예의 조건
- Semaphore 초기 허가 3이며 sleep 모의 작업이지 원격 요청 측정이 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 허가를 얻은 경로에서만 반환한다 허가를 얻은 경로에서만 반환한다 허가를 얻은 경로에서만 반환한다 acquire 성공 뒤에만 finally 진입 허가를 얻은 경로에서만 반환한다 acquire는 try 앞에 있습니다. 허가를 얻기 전에 인터럽트되면 release하지 않고, 얻은 뒤에는 finally에서 반환합니다. 허가를 얻은 경로에서만 반환한다 진행 경로 허가증 보유 원문의 release acquire 대기 중 인터럽트 획득하지 못함 try 진입 전 예외; 반환하지 않음 획득 후 정상 작업 1개 보유 finally에서 1개 반환 획득 후 sleep 인터럽트 1개 보유 finally에서 반환한 뒤 예외 전파 acquire 대기 중 인터럽트 허가증 보유 획득하지 못함 원문의 release try 진입 전 예외; 반환하지 않음 획득 후 정상 작업 허가증 보유 1개 보유 원문의 release finally에서 1개 반환 획득 후 sleep 인터럽트 허가증 보유 1개 보유 원문의 release finally에서 반환한 뒤 예외 전파 공유 Semaphore의 초기 허가 수는 3이므로 허가를 보유한 작업은 최대 3개입니다. 원문은 sleep 모의 작업이며 실제 원격 요청 수나 관측된 최고 동시 실행 수를 측정하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 획득 경계 acquire를 try 앞에서 실행합니다. |
|
|
| 허가 보유 작업 획득 성공 뒤 try에서 작업합니다. |
|
|
| finally 반환 정상 또는 sleep 중단 모두 finally에서 1개를 반환합니다. |
|
|
허가를 얻은 경로에서만 반환한다
acquire가 try 앞에서 성공해야 허가를 보유하고 finally에서 release합니다. 대기 중 인터럽트가 발생하면 허가를 반환하지 않고, 획득 후 인터럽트가 발생하면 반환하는 소유 상태를 단계로 구분할 수 있습니다.
허가를 얻은 경로에서만 반환한다
acquire 성공 뒤에만 finally 진입
허가를 얻은 경로에서만 반환한다
acquire는 try 앞에 있습니다. 허가를 얻기 전에 인터럽트되면 release하지 않고, 얻은 뒤에는 finally에서 반환합니다.
미획득 출구는 finally 밖에 있다
acquire가 try 앞에 있다. 대기 중 인터럽트는 release 없이 나간다. 성공해 허가 한 개를 얻은 경로만 정상·sleep 중단 모두 finally에서 한 개를 반환한다. 초기 허가 3·전체 제출 12의 sleep 모의 작업이며 실측 원격 요청 동시 수가 아니다.
상태별 상세 해설
| 진행 경로 | 허가증 보유 | 원문의 release |
|---|---|---|
| acquire 대기 중 인터럽트 | 획득하지 못함 | try 진입 전 예외; 반환하지 않음 |
| 획득 후 정상 작업 | 1개 보유 | finally에서 1개 반환 |
| 획득 후 sleep 인터럽트 | 1개 보유 | finally에서 반환한 뒤 예외 전파 |
- acquire 대기 중 인터럽트
- 허가증 보유
- 획득하지 못함
- 원문의 release
- try 진입 전 예외; 반환하지 않음
- 획득 후 정상 작업
- 허가증 보유
- 1개 보유
- 원문의 release
- finally에서 1개 반환
- 획득 후 sleep 인터럽트
- 허가증 보유
- 1개 보유
- 원문의 release
- finally에서 반환한 뒤 예외 전파
공유 Semaphore의 초기 허가 수는 3이므로 허가를 보유한 작업은 최대 3개입니다. 원문은 sleep 모의 작업이며 실제 원격 요청 수나 관측된 최고 동시 실행 수를 측정하지 않습니다.
이 예의 조건
- Semaphore 초기 허가 3이며 sleep 모의 작업이지 원격 요청 측정이 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 허가를 얻은 경로에서만 반환한다 허가를 얻은 경로에서만 반환한다 허가를 얻은 경로에서만 반환한다 acquire 성공 뒤에만 finally 진입 허가를 얻은 경로에서만 반환한다 acquire는 try 앞에 있습니다. 허가를 얻기 전에 인터럽트되면 release하지 않고, 얻은 뒤에는 finally에서 반환합니다. 허가를 얻은 경로에서만 반환한다 진행 경로 허가증 보유 원문의 release acquire 대기 중 인터럽트 획득하지 못함 try 진입 전 예외; 반환하지 않음 획득 후 정상 작업 1개 보유 finally에서 1개 반환 획득 후 sleep 인터럽트 1개 보유 finally에서 반환한 뒤 예외 전파 acquire 대기 중 인터럽트 허가증 보유 획득하지 못함 원문의 release try 진입 전 예외; 반환하지 않음 획득 후 정상 작업 허가증 보유 1개 보유 원문의 release finally에서 1개 반환 획득 후 sleep 인터럽트 허가증 보유 1개 보유 원문의 release finally에서 반환한 뒤 예외 전파 공유 Semaphore의 초기 허가 수는 3이므로 허가를 보유한 작업은 최대 3개입니다. 원문은 sleep 모의 작업이며 실제 원격 요청 수나 관측된 최고 동시 실행 수를 측정하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 획득 경계 acquire를 try 앞에서 실행합니다. |
|
|
| 허가 보유 작업 획득 성공 뒤 try에서 작업합니다. |
|
|
| finally 반환 정상 또는 sleep 중단 모두 finally에서 1개를 반환합니다. |
|
|
그림에서 볼 수 있듯이 acquire()는 try 앞에 있습니다. 허가를 얻기 전에 인터럽트되면 acquire()가 InterruptedException을 던지고, 아직 try에 들어가지 않았으므로 finally의 release()도 실행되지 않습니다. 허가를 얻은 뒤에는 작업이 정상으로 끝나든 sleep 도중 인터럽트되든 finally에서 정확히 한 번 돌려줍니다.
위치가 왜 중요한지는 acquire()를 try 안에 넣어 보면 알 수 있습니다. Semaphore는 누가 허가를 가졌는지 기록하지 않아서, 얻은 적 없는 허가를 release()해도 오류 없이 허가 수를 늘려 버립니다. 다음 코드는 acquire() 전에 인터럽트가 걸린 상황을 만들어, 잘못된 위치에서 무슨 일이 생기는지 보여 줍니다.
import java.util.concurrent.Semaphore;
public final class ReleaseWithoutAcquire {
public static void main(String[] args) {
Semaphore limit = new Semaphore(3);
Thread.currentThread().interrupt(); // acquire 전에 인터럽트가 걸린 상황
try {
limit.acquire(); // 잘못된 위치: try 안에 있다
System.out.println("작업 실행");
} catch (InterruptedException e) {
System.out.println("acquire 실패: " + e);
} finally {
limit.release();
}
System.out.println("availablePermits=" + limit.availablePermits());
}
}acquire()가 실패하면 catch가 예외를 찍고, finally는 성공 여부와 상관없이 실행됩니다. 마지막 줄의 availablePermits()는 그 뒤에 남은 허가 수입니다.
acquire 실패: java.lang.InterruptedException
availablePermits=4인터럽트 상태인 스레드가 acquire()를 호출하면 허가를 얻지 못하고 곧바로 InterruptedException이 납니다. 그런데도 finally가 release()를 실행했기 때문에 허가가 3개에서 4개로 늘었습니다. 이런 일이 쌓이면 '동시에 3개까지'라는 제한이 아무 오류 없이 무너집니다.
세마포어를 쓸 때 함께 기억할 점이 두 가지 더 있습니다. 첫째, 세마포어는 보호하려는 자원 하나에 하나만 만들어 모든 요청이 공유해야 합니다. 메서드를 호출할 때마다 새로 만들면 호출마다 허가 3개가 새로 생기므로 아무것도 제한하지 못합니다. 둘째, 무작정 기다리게 하는 대신 시간 제한을 두고 싶다면 tryAcquire(시간, 단위)를 씁니다. 정해진 시간 안에 허가를 얻지 못하면 false를 돌려주므로, 과부하 상태에서는 요청을 빨리 거절할 수 있습니다. 한편 데이터베이스 연결 풀은 그 자체가 연결 수를 제한하므로, 연결 풀 앞에 세마포어를 하나 더 둘 필요는 없습니다.
ScopedValue로 요청 문맥 전달하기
요청 하나를 처리하는 동안 여러 계층의 메서드가 같은 요청 식별자를 로그에 남겨야 한다고 해 봅시다. 식별자를 모든 메서드의 매개변수로 넘기면, 정작 그 값을 쓰지 않는 중간 메서드들까지 시그니처가 길어집니다. 지금까지는 이런 값을 ThreadLocal에 담아 왔습니다. 하지만 ThreadLocal은 아무 때나 set으로 값을 바꿀 수 있고 다 쓴 뒤에는 remove를 직접 호출해야 해서, 스레드 풀에서 정리를 빠뜨리면 같은 스레드에서 실행되는 다음 작업이 이전 요청의 값을 보게 됩니다.
ScopedValue는 값을 '어떤 호출이 실행되는 동안'에만 묶어 둡니다. ScopedValue.where(키, 값)으로 묶을 값을 정하고 run이나 call로 코드를 실행하면, 그 코드와 그 코드가 호출하는 모든 메서드에서 키.get()으로 값을 읽을 수 있습니다. 실행이 끝나면 이 묶음, 즉 바인딩(binding)은 저절로 사라지고, 실행 도중에 값을 바꾸는 메서드는 아예 없습니다. 이처럼 코드가 어디에 쓰였는지가 아니라 지금 어떤 호출 안에서 실행 중인지에 따라 값이 보이는 범위를 동적 범위(dynamic scope)라고 합니다.
다음 예제에서 main은 REQUEST_ID를 req-42로 묶고 serviceCall을 실행합니다. serviceCall은 저장소 메서드를 세 번 부르는데, 두 번째 호출만 req-42/child로 다시 묶은 범위 안에서 실행합니다.
public final class ScopedRequestContext {
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
private static void repositoryCall(String operation) {
System.out.println(REQUEST_ID.get() + " repository=" + operation);
}
private static void serviceCall() {
repositoryCall("load-notes");
ScopedValue.where(REQUEST_ID, REQUEST_ID.get() + "/child")
.run(() -> repositoryCall("load-tags"));
repositoryCall("save-summary");
}
public static void main(String[] args) {
ScopedValue.where(REQUEST_ID, "req-42").run(ScopedRequestContext::serviceCall);
System.out.println("bound-after=" + REQUEST_ID.isBound());
}
}repositoryCall은 호출될 때마다 지금 보이는 REQUEST_ID를 앞에 붙여 찍습니다. 마지막 줄의 isBound()는 run이 끝난 뒤의 main에서 값이 아직 묶여 있는지를 확인합니다.
req-42 repository=load-notes
req-42/child repository=load-tags
req-42 repository=save-summary
bound-after=false중첩 run을 지나며 바뀌는 요청 바인딩
바깥 req-42 바인딩 중 중첩 run에서는 요청 바인딩이 req-42/child로 바뀌고 반환 후 바깥 값으로 복구되며 최종 isBound=false가 됩니다. 동적 범위의 실제 수명 전환입니다.
중첩 run을 지나며 바뀌는 요청 바인딩
동적 범위를 벗어나면 이전 바인딩 복원
중첩 run을 지나며 바뀌는 요청 바인딩
중첩 run 안에서만 req-42/child를 읽습니다. 중첩 호출이 끝나면 req-42로 돌아가고 바깥 run 뒤에는 바인딩이 없습니다.
바깥 run 안
- 현재 값 또는 상태
- req-42
- 읽는 작업
- load-notes
중첩 run 안
- 현재 값 또는 상태
- req-42/child
- 읽는 작업
- load-tags
중첩 run 반환 뒤
- 현재 값 또는 상태
- req-42
- 읽는 작업
- save-summary
바깥 run 반환 뒤
- 현재 값 또는 상태
- isBound() = false
- 읽는 작업
- 바인딩 없음
ScopedRequestContext의 직접 호출 순서에서 도출한 값입니다.
이 예의 조건
- nested run 반환 시 바깥 바인딩을 복원한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 중첩 run을 지나며 바뀌는 요청 바인딩 중첩 run을 지나며 바뀌는 요청 바인딩 중첩 run을 지나며 바뀌는 요청 바인딩 동적 범위를 벗어나면 이전 바인딩 복원 중첩 run을 지나며 바뀌는 요청 바인딩 중첩 run 안에서만 req-42/child를 읽습니다. 중첩 호출이 끝나면 req-42로 돌아가고 바깥 run 뒤에는 바인딩이 없습니다. 바깥 run 안 현재 값 또는 상태 req-42 읽는 작업 load-notes 중첩 run 안 현재 값 또는 상태 req-42/child 읽는 작업 load-tags 중첩 run 반환 뒤 현재 값 또는 상태 req-42 읽는 작업 save-summary 바깥 run 반환 뒤 현재 값 또는 상태 isBound() = false 읽는 작업 바인딩 없음 ScopedRequestContext의 직접 호출 순서에서 도출한 값입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 바깥 바인딩 req-42 범위 안에서 load-notes를 실행합니다. |
|
|
| 중첩 바인딩 child 범위 안에서 load-tags를 실행합니다. |
|
|
| 중첩 반환 바깥 범위의 save-summary를 실행합니다. |
|
|
| 바깥 반환 모든 범위가 끝나 main이 검사합니다. |
|
|
중첩 run을 지나며 바뀌는 요청 바인딩
바깥 req-42 바인딩 중 중첩 run에서는 요청 바인딩이 req-42/child로 바뀌고 반환 후 바깥 값으로 복구되며 최종 isBound=false가 됩니다. 동적 범위의 실제 수명 전환입니다.
중첩 run을 지나며 바뀌는 요청 바인딩
동적 범위를 벗어나면 이전 바인딩 복원
중첩 run을 지나며 바뀌는 요청 바인딩
중첩 run 안에서만 req-42/child를 읽습니다. 중첩 호출이 끝나면 req-42로 돌아가고 바깥 run 뒤에는 바인딩이 없습니다.
바깥 run 안
- 현재 값 또는 상태
- req-42
- 읽는 작업
- load-notes
중첩 run 안
- 현재 값 또는 상태
- req-42/child
- 읽는 작업
- load-tags
중첩 run 반환 뒤
- 현재 값 또는 상태
- req-42
- 읽는 작업
- save-summary
바깥 run 반환 뒤
- 현재 값 또는 상태
- isBound() = false
- 읽는 작업
- 바인딩 없음
ScopedRequestContext의 직접 호출 순서에서 도출한 값입니다.
이 예의 조건
- nested run 반환 시 바깥 바인딩을 복원한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 중첩 run을 지나며 바뀌는 요청 바인딩 중첩 run을 지나며 바뀌는 요청 바인딩 중첩 run을 지나며 바뀌는 요청 바인딩 동적 범위를 벗어나면 이전 바인딩 복원 중첩 run을 지나며 바뀌는 요청 바인딩 중첩 run 안에서만 req-42/child를 읽습니다. 중첩 호출이 끝나면 req-42로 돌아가고 바깥 run 뒤에는 바인딩이 없습니다. 바깥 run 안 현재 값 또는 상태 req-42 읽는 작업 load-notes 중첩 run 안 현재 값 또는 상태 req-42/child 읽는 작업 load-tags 중첩 run 반환 뒤 현재 값 또는 상태 req-42 읽는 작업 save-summary 바깥 run 반환 뒤 현재 값 또는 상태 isBound() = false 읽는 작업 바인딩 없음 ScopedRequestContext의 직접 호출 순서에서 도출한 값입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 바깥 바인딩 req-42 범위 안에서 load-notes를 실행합니다. |
|
|
| 중첩 바인딩 child 범위 안에서 load-tags를 실행합니다. |
|
|
| 중첩 반환 바깥 범위의 save-summary를 실행합니다. |
|
|
| 바깥 반환 모든 범위가 끝나 main이 검사합니다. |
|
|
그림의 바깥 영역은 req-42를 묶은 run이고, 안쪽 영역은 그 실행 도중에 열린 중첩 run입니다. 첫 줄의 load-notes는 바깥 범위에서 실행되므로 req-42를 읽습니다. 둘째 줄의 load-tags는 안쪽 범위에서 실행되므로, 안쪽 바인딩이 바깥 값을 잠시 가려 req-42/child가 보입니다. 안쪽 run이 끝나면 이전 바인딩이 되살아나므로 셋째 줄의 save-summary는 다시 req-42를 읽습니다. 마지막으로 바깥 run까지 끝난 뒤에는 바인딩이 남지 않아 isBound()가 false입니다. repositoryCall은 매개변수로 식별자를 받지 않았는데도, 자기를 호출한 범위에 따라 다른 값을 읽었습니다.
바인딩이 없는 곳에서 get()을 호출하면 기본값 같은 것은 없고 예외가 납니다. 다음 코드를 실행하면 REQUEST_ID를 어디에서도 묶지 않았으므로 NoSuchElementException이 발생합니다.
public final class UnboundScopedValueFailure {
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
public static void main(String[] args) {
System.out.println(REQUEST_ID.get());
}
}그러니 값은 항상 ScopedValue.where로 바인딩을 정하고, run이나 call이 여는 범위 안에서 읽습니다. 있을 수도 없을 수도 있는 문맥이라면 isBound()로 먼저 확인하거나 orElse(대신쓸값)으로 읽습니다. 한편 메서드가 일을 하는 데 꼭 필요한 업무 입력은 ScopedValue에 숨기지 말고 매개변수로 받아야 합니다. 그래야 메서드 시그니처만 보고도 무엇이 필요한지 알 수 있고, 테스트에서도 값을 빠뜨릴 수 없습니다.
ScopedValue가 막는 것은 바인딩을 다른 값으로 바꾸는 일뿐이라는 점도 알아 두세요. 묶은 값이 가변 객체라면 그 객체의 내용은 여전히 바뀔 수 있습니다. 여러 스레드가 함께 읽을 값이라면 불변 객체를 쓰거나 접근을 동기화해야 합니다.
다른 스레드로는 바인딩이 넘어가지 않습니다
바인딩은 스레드마다 따로입니다. 그래서 값을 묶은 스레드가 실행기에 작업을 넘기면, 그 작업을 실행하는 스레드에서는 값이 보이지 않습니다. 다음 코드는 req-42를 묶은 범위 안에서 가상 스레드 실행기에 작업을 하나 제출하고, 양쪽에서 isBound()를 출력합니다.
import java.util.concurrent.Executors;
public final class ScopedValueNotInherited {
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
public static void main(String[] args) {
ScopedValue.where(REQUEST_ID, "req-42").run(() -> {
System.out.println("caller bound=" + REQUEST_ID.isBound());
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> System.out.println("task bound=" + REQUEST_ID.isBound()));
}
});
}
}첫 줄은 값을 묶은 스레드에서, 둘째 줄은 실행기가 만든 가상 스레드에서 찍습니다. 같은 REQUEST_ID를 물었을 때 두 스레드의 답이 같은지 보세요.
caller bound=true
task bound=false작업은 바인딩 범위 안에서 제출되었고 close()가 그 작업이 끝나기를 기다렸으니, 시간으로 보면 바인딩이 살아 있는 동안 실행되었습니다. 그래도 작업을 실행한 스레드는 값을 묶은 스레드가 아니므로 false가 나옵니다. 바인딩을 자식 스레드가 물려받는 경우는 StructuredTaskScope의 fork로 시작한 하위 작업뿐인데, 이 API는 JDK 25에서도 아직 프리뷰이며 구조화된 동시성과 StructuredTaskScope에서 다룹니다.
실행기에 작업을 넘길 때는 작업이 시작하는 곳에서 직접 바인딩합니다. 다음 예제의 request는 요청 문맥을 받아, 실행되면 그 문맥을 바인딩하고 handle을 호출하는 작업을 만듭니다.
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
public final class ScopedVirtualBoardRequests {
private static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();
private record RequestContext(String requestId, long userId) {}
private static String handle() {
RequestContext context = CONTEXT.get();
return context.requestId()
+ ":user="
+ context.userId()
+ ":virtual="
+ Thread.currentThread().isVirtual();
}
private static Callable<String> request(RequestContext context) {
return () -> ScopedValue.where(CONTEXT, context).call(ScopedVirtualBoardRequests::handle);
}
public static void main(String[] args) throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Callable<String>> requests =
List.of(
request(new RequestContext("req-a", 7)),
request(new RequestContext("req-b", 8)));
for (var future : executor.invokeAll(requests)) {
System.out.println(future.get());
}
}
}
}request는 문맥을 받아 Callable을 만들 뿐, 그 자리에서 값을 묶지 않습니다. 바인딩은 실행기가 그 작업을 실행할 때, 작업 안의 ScopedValue.where(...).call(...)에서 일어납니다.
req-a:user=7:virtual=true
req-b:user=8:virtual=truerun이 값을 돌려주지 않는 코드를 실행한다면, call은 값을 돌려주는 코드를 실행하고 그 결과를 그대로 돌려줍니다. 두 작업은 각자의 가상 스레드에서 자기 문맥을 바인딩하므로, 같은 CONTEXT를 읽어도 서로의 값이 섞이지 않습니다. virtual=true는 handle이 가상 스레드에서 실행되었다는 뜻입니다.
요청 문맥은 record로 묶었습니다. RequestContext의 구성 요소는 불변인 String과 long뿐이므로 여러 스레드가 읽어도 안전합니다. 하지만 record라는 이유만으로 구성 요소까지 깊게 불변이 되는 것은 아닙니다. 구성 요소에 가변 List가 있다면 record 심화와 불변 스냅샷에서 본 방어적 복사가 필요합니다.
ThreadLocal, ScopedValue, 매개변수 가운데 무엇을 쓸까
ThreadLocal은 스레드가 살아 있는 동안 값을 들고 있고, 언제든 set으로 바꿀 수 있습니다. 그래서 스레드 풀처럼 스레드를 재사용하는 환경에서는 remove를 빠뜨렸을 때 값이 다음 작업으로 새어 나갑니다. 가상 스레드에서도 ThreadLocal은 동작하지만, 가상 스레드는 작업마다 새로 만들어지므로 비싼 객체를 스레드마다 캐시해 두는 용도로는 맞지 않습니다. 반면 ScopedValue의 값은 바인딩한 호출이 실행되는 동안에만 보이고, 그 안에서는 읽기만 할 수 있습니다. 매개변수는 가장 명시적인 방법이라서, 메서드에 꼭 필요한 업무 입력이라면 여전히 매개변수가 낫습니다.
이 글의 내용을 바탕으로 선택 기준을 정리하면 다음과 같습니다.
- 블로킹 I/O가 많은 작업을 동시에 많이 처리해야 한다면 가상 스레드를 검토합니다.
- CPU 계산 위주의 병렬 처리에는 코어 수에 맞춘 플랫폼 스레드 풀이나 Fork/Join을 씁니다. 가상 스레드는 계산을 빠르게 하지 않습니다.
- 요청 식별자처럼 여러 계층이 읽기만 하는 불변 문맥에는
ScopedValue를 검토합니다. - 트랜잭션 핸들처럼 상태가 바뀌고 소유자가 분명해야 하는 자원은
ScopedValue뒤에 숨기지 말고 명시적으로 넘깁니다. - 가상 스레드의 수와는 별개로, 데이터베이스 연결 수나 외부 API 호출 한도 같은 하위 시스템의 제한을 따로 설정합니다.
가상 스레드 실행기의 풀 크기는 어떻게 정하나요?
정하지 않습니다. 가상 스레드는 풀에 모아 재사용하는 자원이 아니라 작업 하나를 나타내는 객체이므로, newVirtualThreadPerTaskExecutor()에는 크기를 정하는 인수가 없고 작업마다 스레드를 새로 만듭니다.
동시에 실행할 작업의 수를 줄여야 한다면 그 이유가 되는 자원 앞에서 제한합니다. 데이터베이스나 원격 API의 동시 호출 수는 세마포어나 연결 풀로 제한하고, 요청이 들어오는 양 자체는 서버의 유입 제어로 조절합니다. CPU를 오래 쓰는 구간은 크기가 정해진 별도의 실행기에 넘깁니다.
더 깊이: JDK 21의 프리뷰 API와 JDK 25의 정식 API
ScopedValue는 JDK 21에서 첫 프리뷰(JEP 446)로 나왔고, JDK 24의 네 번째 프리뷰(JEP 487)를 거쳐 JDK 25에서 정식 기능이 되었습니다. 그사이 API가 바뀌었기 때문에, 프리뷰 시절의 글에 나온 코드는 JDK 25에서 컴파일되지 않을 수 있습니다. JDK 21의 API와 JDK 25 API 문서를 비교하면 다음이 다릅니다.
- JDK 21에 있던 정적 메서드
ScopedValue.runWhere,callWhere,getWhere와Carrier.get(Supplier)는 JDK 25에 없습니다. 바인딩은ScopedValue.where(...)로 만든 뒤run이나call로만 실행합니다. - JDK 21의
call은Callable을 받고throws Exception을 선언했습니다. 그래서 JDK 21에서는ScopedValue.where(KEY, "duke").call(() -> "call:" + KEY.get())처럼 예외를 던지지 않는 람다를 넘겨도, 호출한 쪽에서Exception을 처리하지 않으면unreported exception Exception컴파일 오류가 납니다. JDK 25의call은ScopedValue.CallableOp를 받고 그 람다가 실제로 던지는 예외만throws로 전달하므로, 이런 람다라면 예외 처리가 필요 없습니다. - JDK 21에서는
orElse(null)이 허용되어null을 돌려줬습니다. JDK 25 문서는 따로 명시하지 않은 메서드에null을 넘기면NullPointerException이 난다고 정하고 있고,orElse에는null을 허용한다는 문구가 없습니다.
이 글의 ScopedValue 예제는 두 버전에 공통으로 있는 newInstance, where, run, call, get, isBound만 씁니다. 그래서 JDK 21에서도 javac --release 21 --enable-preview로 컴파일하고 java --enable-preview로 실행하면 본문과 같은 결과가 나옵니다. 하지만 프리뷰 기능은 해당 JDK에서만 돌아가는 실험용이므로, 실제 코드에서 ScopedValue를 쓰려면 JDK 25 이상을 기준으로 삼아야 합니다.
정리
- 가상 스레드는 기다리는 동안 운반 스레드에서 내려오므로, 블로킹 코드를 그대로 두고도 작업마다 스레드를 하나씩 쓸 수 있습니다. 기다림이 많은 작업에는 효과가 크지만 계산을 빠르게 하지는 않으며, 풀에 모아 재사용하지 않습니다.
- 스레드 수가 자원의 한계를 대신하지 않으므로, 한정된 자원 앞에는 공유하는
Semaphore나 연결 풀로 허용량을 정합니다.acquire()는try앞에,release()는finally에 둡니다. ScopedValue는where(...).run(...)이나call(...)로 실행하는 동안에만 값을 보이게 합니다. 중첩 바인딩은 바깥 값을 잠시 가리고, 범위가 끝나면 이전 값으로 돌아가며, 바인딩이 없을 때get()은NoSuchElementException을 던집니다.- 바인딩은 실행기에 넘긴 작업으로 저절로 넘어가지 않으므로, 작업이 시작하는 곳에서 직접 바인딩합니다.
연습 문제
-
VirtualThreadsWithResourceLimit를 고쳐,try구간에 동시에 들어가 있던 작업 수의 최댓값을 기록하고 모든 작업이 끝난 뒤 출력하세요. 남은 허가 수도 함께 출력하고, 두 값이 각각 얼마여야 하는지 설명하세요. -
사용자 식별자 7, 8, 9를 이 순서대로 하나씩 가상 스레드 작업으로 제출하세요. 각 작업은 자기 식별자를
ScopedValue에 바인딩한 범위 안에서 그 값을 읽어user=식별자문자열을 돌려줍니다. 결과는 제출한 순서대로 출력하고, 마지막으로main에서isBound()를 출력하세요. 실행하기 전에 출력 네 줄을 먼저 예상해 보세요. -
요청 식별자를
ThreadLocal에 담아 쓰던 서버가, 요청 처리가 끝난 뒤remove()를 호출하지 않았습니다. 이 서버가 크기 10인 스레드 풀을 쓸 때 생길 수 있는 문제를 설명하고, 같은 일을ScopedValue로 하면 왜 이 문제가 생기지 않는지 설명하세요.
해설 보기
1번. 지금 허가를 가진 작업 수는 AtomicInteger로 세고, 최댓값은 accumulateAndGet(현재값, Math::max)로 갱신합니다. 여러 가상 스레드가 동시에 값을 바꾸므로 일반 int 필드를 쓰면 갱신이 사라질 수 있습니다. 세는 코드는 허가를 얻은 뒤인 try 안에서 늘리고, finally에서 허가를 돌려주기 전에 줄입니다.
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicInteger;
public final class MaxConcurrentPermits {
private static final Semaphore REMOTE_LIMIT = new Semaphore(3);
private static final AtomicInteger inUse = new AtomicInteger();
private static final AtomicInteger maxInUse = new AtomicInteger();
private static String load(int id) throws InterruptedException {
REMOTE_LIMIT.acquire();
try {
int now = inUse.incrementAndGet();
maxInUse.accumulateAndGet(now, Math::max);
Thread.sleep(Duration.ofMillis(3));
return "note-" + id;
} finally {
inUse.decrementAndGet();
REMOTE_LIMIT.release();
}
}
public static void main(String[] args) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int id = 1; id <= 12; id++) {
int noteId = id;
executor.submit(() -> load(noteId));
}
}
System.out.println("maxInUse=" + maxInUse.get());
System.out.println("availablePermits=" + REMOTE_LIMIT.availablePermits());
}
}maxInUse=3
availablePermits=3maxInUse는 세마포어의 허가 수 때문에 3을 넘을 수 없습니다. 3보다 작게 나오는 것도 규칙상으로는 가능하지만, 작업 12개가 거의 동시에 시작되므로 실제로는 대부분 3이 나옵니다. 이 작업 공간에서도 여러 번 실행할 때마다 3이 나왔습니다. availablePermits는 반드시 3이어야 합니다. 허가를 얻은 작업은 모두 finally에서 하나씩 돌려줬고, 얻지 못한 작업은 돌려주지 않았기 때문입니다. 이 값이 3이 아니라면 acquire()와 release()의 짝이 어긋났다는 뜻입니다.
2번. 각 작업 안에서 ScopedValue.where(USER_ID, userId).call(...)로 바인딩하고 읽습니다. 바인딩은 작업을 실행하는 스레드 안에서 만들어지므로 작업끼리 값이 섞이지 않습니다.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public final class ScopedUserTaskSolution {
private static final ScopedValue<Long> USER_ID = ScopedValue.newInstance();
public static void main(String[] args) throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> results = new ArrayList<>();
for (long userId : List.of(7L, 8L, 9L)) {
results.add(
executor.submit(
() ->
ScopedValue.where(USER_ID, userId)
.call(() -> "user=" + USER_ID.get())));
}
for (Future<String> result : results) {
System.out.println(result.get());
}
}
System.out.println("bound=" + USER_ID.isBound());
}
}user=7
user=8
user=9
bound=false결과를 제출한 순서의 Future에서 읽었으므로 7, 8, 9 순서로 나옵니다. 이 순서는 작업이 끝난 순서와는 관계가 없습니다. 마지막 줄이 false인 것은 main 스레드가 USER_ID를 한 번도 바인딩하지 않았기 때문입니다. 바인딩은 각 작업자 스레드의 call 안에서만 있었고, call이 끝나면서 사라졌습니다.
3번. 스레드 풀의 스레드 10개는 요청이 끝나도 사라지지 않고 다음 요청을 처리합니다. remove()를 하지 않으면 그 스레드의 ThreadLocal에는 이전 요청의 식별자가 그대로 남습니다. 다음 요청이 식별자를 새로 set하기 전에 값을 읽거나, 어떤 경로에서는 set을 빠뜨리면 다른 사람의 요청 식별자로 로그가 남습니다. 값이 큰 객체라면 스레드가 사는 동안 메모리에서 풀려나지도 않습니다.
ScopedValue에서는 값이 run이나 call로 실행하는 동안에만 바인딩되고, 그 실행이 정상으로 끝나든 예외로 끝나든 바인딩이 사라집니다. 정리하는 코드를 따로 쓰지 않으므로 빠뜨릴 일도 없고, 같은 스레드가 다음 요청을 처리할 때는 이전 바인딩이 남아 있지 않습니다. 실행 도중에 값을 바꾸는 메서드도 없어서, 범위 안의 어느 코드도 다른 코드가 읽을 값을 몰래 바꿀 수 없습니다.
다음 글
Java 25로 마이그레이션하기에서는 이 장에서 다룬 기능을 실제 프로젝트에서 쓰기 위해 JDK 25로 올릴 때, 실행 중인 JDK와 컴파일 대상을 확인하고 경고와 의존성을 점검하며 단계적으로 전환하는 방법을 다룹니다.