가상 스레드와 ScopedValue
Java 25 안정 기능인 가상 스레드와 ScopedValue를 프리뷰 플래그 없이 실행하고 unbound 접근, 블로킹 자원 제한, 동적 범위를 설계합니다.
가상 스레드는 Java 21에서 정식 도입됐고 ScopedValue는 Java 25에서 정식 기능이 됐습니다.
둘 다 JDK 25 기본 javac와 java로 프리뷰 플래그 없이 컴파일·실행됩니다.
가상 스레드는 많은 블로킹 작업을 비교적 적은 플랫폼 스레드로 실행하도록 돕고, ScopedValue는 제한된 동적 범위에서 읽는 문맥 값을 바인딩합니다.
CPU 코어나 데이터베이스 연결 같은 실제 제한 자원을 늘리지는 않습니다.
ScopedValue 바인딩 범위
ScopedValue는 전역 기본값이 아닙니다.
현재 동적 범위에 값이 바인딩되지 않았는데 get()을 호출하면 실제 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()로 구분합니다.
필수 도메인 입력을 숨은 ScopedValue로 전달하지 말고 메서드 매개변수로 유지합니다.
가상 스레드의 작업별 코드 구조
블로킹 API를 그대로 호출하면서 많은 독립 작업을 표현할 수 있습니다.
newVirtualThreadPerTaskExecutor()는 작업마다 새 가상 스레드를 시작하며 실행기 close는 제출된 작업 완료를 기다립니다.
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);
}
}
}Thread.sleep과 많은 JDK 소켓 연산은 대기 중 운반자 스레드를 양보할 수 있습니다. 모든 파일 I/O와 네이티브·외부 함수 호출이 같은 방식으로 동작하는 것은 아니므로 JFR 고정 이벤트와 운영 프로파일을 확인합니다.
invokeAll이 돌려주는 Future 목록은 입력 작업 순서입니다. 이를 차례로 읽는 출력 순서는 실제 작업 완료 순서를 보여 주지 않습니다.
Semaphore와 희소 자원
가상 스레드를 10만 개 만들 수 있어도 데이터베이스 연결이 20개라면 동시에 실행할 수 있는 질의 수는 20입니다.
아래 예제는 전체 작업 수와 허가증을 보유하는 작업 수를 분리합니다.
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());
}
}
}
}허가를 얻은 경로에서만 반환한다
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개를 반환합니다. |
|
|
시간 제한과 과부하 시 요청 거부가 필요하다면 기한을 받는 tryAcquire를 사용합니다.
세마포어를 메서드마다 새로 만들면 요청 사이의 제한이 되지 않으므로 자원 소유자가 공유합니다.
전체 작업은 12개지만 허가를 보유한 load 작업은 동시에 최대 3개입니다. 대기 중인 작업까지 합친 스레드 수와 완료까지 걸린 시간을 이 숫자로 확정할 수는 없습니다. 특히 acquire()는 try 앞에 있습니다. 획득 전에 인터럽트로 실패하면 finally에 들어가지 않아 얻지 않은 허가를 반환하지 않고, 획득한 뒤에는 정상·예외 종료 모두 한 번 반환합니다. Semaphore 공식 API의 허가 수와 예제 코드의 제어 흐름을 함께 읽습니다.
ScopedValue의 동적 범위
요청 식별자와 행위자 같은 관찰 문맥을 여러 메서드 시그니처에 반복하지 않고 읽기 전용으로 전달할 수 있습니다.
바깥 호출과 중첩 호출에서 같은 키가 가리키는 값이 어떻게 달라지는지 살펴봅니다.
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());
}
}중첩 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입니다. 출력은 req-42의 load-notes, req-42/child의 load-tags, 다시 req-42의 save-summary, 끝으로 bound-after=false 순서입니다. 중첩 범위를 벗어나면 이전 바인딩이 다시 보이고, 바깥 범위까지 끝나면 기본값이 남지 않습니다. 이 읽기 전용 바인딩이 가변 객체의 내부까지 불변으로 만드는 계약은 아닙니다.
가상 작업의 문맥 바인딩
임의로 생성한 스레드에 문맥이 자동으로 전파된다고 가정하지 않습니다.
작업 래퍼가 요청 문맥 값을 받아 작업 본문의 동적 범위에 명시적으로 바인딩합니다. 이 예제의 RequestContext는 String과 long으로 구성됩니다. record라는 이유만으로 가변 구성 요소까지 깊게 불변이 되는 것은 아닙니다.
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());
}
}
}
}ThreadLocal 선택 기준
ThreadLocal은 가변적인 set·remove 생명 주기를 가지므로 스레드 풀을 재사용할 때 정리를 누락할 위험이 있습니다.
ScopedValue의 값은 바인딩 범위로 제한되고 하위 호출 스택에서 읽기 전용으로 사용됩니다.
하지만 요청 문맥이 모든 메서드에 필수 업무 입력이라면 매개변수가 더 명시적입니다.
- 블로킹 I/O 동시성이 크면 가상 스레드를 검토합니다.
- CPU 중심 병렬성에는 상한이 있는 Fork/Join 실행기나 CPU 전용 실행기를 사용합니다.
- 요청 상관관계 식별자처럼 여러 계층에 걸쳐 전달하는 불변 문맥에는
ScopedValue를 검토합니다. - 트랜잭션 핸들처럼 가변하고 엄격한 소유자가 있는 자원을 무분별하게 숨기지 않습니다.
- 가상 스레드 수와 하위 시스템의 연결 수·처리율 제한을 별도로 설정합니다.
가상 스레드 실행기의 풀 크기는 어떻게 정하나요?
가상 스레드는 고정 작업자 풀처럼 작업 수를 제한하려는 도구가 아닙니다.
동시 작업 수는 작업 부하와 유입 제어로, 데이터베이스·원격 API 동시성은 세마포어나 연결 풀로 제한합니다.
CPU 중심 구간은 별도의 상한이 있는 실행기에 넘깁니다.
연습 문제
세 작업에 서로 다른 사용자 식별자를 ScopedValue로 바인딩하고 결과가 섞이지 않는지 확인하세요.
사용자 식별자는 7·8·9를 이 순서로 제출합니다. 각 작업은 자신의 call 범위 안에서 식별자를 읽고, 결과도 제출 순서의 Future에서 읽습니다. 세 결과와 메인 호출의 마지막 isBound()를 먼저 적습니다. 작업의 완료 순서는 이 출력 순서만으로 알아낼 수 없습니다. 이 연습의 ScopedValue 코드는 JDK 25 대상으로 실행하며 JDK 21 가상 스레드 예제의 실행 결과와 섞지 않습니다.
해설 보기
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());
}
}종료 기준은 세 사용자 식별자가 각각 한 번 출력되고 실행기 종료 뒤 bound=false인 것입니다.