가상 스레드와 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로 바인딩을 만들고 그 콜백 안에서만 사용하거나, 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 VirtualThreadStudyLookup {
private static String load(String topic) throws InterruptedException {
Thread.sleep(Duration.ofMillis(5));
return topic.toUpperCase();
}
public static void main(String[] args) throws Exception {
List<String> topics = List.of("record", "sealed", "module");
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Callable<String>> tasks =
topics.stream().<Callable<String>>map(topic -> () -> load(topic)).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가 지원하는 블로킹 연산에서는 가상 스레드가 대기할 때 운반자 스레드를 양보할 수 있습니다.
모든 네이티브 호출과 락 상황이 같은 방식으로 동작하는 것은 아니므로 JFR의 고정된 스레드 이벤트와 운영 프로파일링 결과를 확인합니다.
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());
}
}
}
}허가증 획득도 인터럽트될 수 있으며 finally에서 반환합니다.
시간 제한과 과부하 시 요청 거부가 필요하다면 기한을 받는 tryAcquire를 사용합니다.
세마포어를 메서드마다 새로 만들면 요청 사이의 제한이 되지 않으므로 자원 소유자가 공유합니다.
ScopedValue의 동적 범위
요청 식별자와 행위자 같은 관찰 문맥을 여러 메서드 시그니처에 반복하지 않고 읽기 전용으로 전달할 수 있습니다.
where 바인딩은 콜백 종료 시 자동으로 사라지고 중첩 바인딩은 안쪽 범위에서만 값을 가립니다.
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());
}
}출력의 중첩 호출만 req-42/child를 보고 마지막에는 원래 바인딩으로 복원됩니다.
가변 보관 객체를 ScopedValue로 전달하면 읽기 전용 바인딩 안에서도 내부 상태가 변할 수 있으므로 불변 문맥 record를 사용합니다.
가상 작업의 문맥 바인딩
임의로 생성한 스레드에 문맥이 자동으로 전파된다고 가정하지 않습니다. 작업 래퍼가 요청 문맥을 캡처한 불변 값을 받고 작업 본문의 동적 범위에 명시적으로 바인딩합니다.
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
public final class ScopedVirtualStudyRequests {
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(ScopedVirtualStudyRequests::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로 바인딩하고 결과가 섞이지 않는지 확인하세요.
해설 보기
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인 것입니다.