안동민 개발노트

본문 시작

가상 스레드와 ScopedValue

Java 25 안정 기능인 가상 스레드와 ScopedValue를 프리뷰 플래그 없이 실행하고 unbound 접근, 블로킹 자원 제한, 동적 범위를 설계합니다.

가상 스레드는 Java 21에서 정식 도입됐고 ScopedValue는 Java 25에서 정식 기능이 됐습니다.

둘 다 JDK 25 기본 javac와 java로 프리뷰 플래그 없이 컴파일·실행됩니다.

가상 스레드는 많은 블로킹 작업을 비교적 적은 플랫폼 스레드로 실행하도록 돕고, ScopedValue는 제한된 동적 범위에서 읽는 문맥 값을 바인딩합니다.

CPU 코어나 데이터베이스 연결 같은 실제 제한 자원을 늘리지는 않습니다.


ScopedValue 바인딩 범위

ScopedValue는 전역 기본값이 아닙니다.

현재 동적 범위에 값이 바인딩되지 않았는데 get()을 호출하면 실제 NoSuchElementException이 발생합니다.

lab/UnboundScopedValueFailure.java
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는 제출된 작업 완료를 기다립니다.

src/VirtualThreadBoardLookup.java
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입니다.

아래 예제는 전체 작업 수와 허가증을 보유하는 작업 수를 분리합니다.

src/VirtualThreadsWithResourceLimit.java
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());
            }
        }
    }
}
허가를 얻은 경로에서만 반환한다

허가를 얻은 경로에서만 반환한다

허가를 얻은 경로에서만 반환한다
진행 경로허가증 보유원문의 release
acquire 대기 중 인터럽트획득하지 못함try 진입 전 예외; 반환하지 않음
획득 후 정상 작업1개 보유finally에서 1개 반환
획득 후 sleep 인터럽트1개 보유finally에서 반환한 뒤 예외 전파
acquire 대기 중 인터럽트
허가증 보유: 획득하지 못함
원문의 release: try 진입 전 예외; 반환하지 않음
획득 후 정상 작업
허가증 보유: 1개 보유
원문의 release: finally에서 1개 반환
획득 후 sleep 인터럽트
허가증 보유: 1개 보유
원문의 release: finally에서 반환한 뒤 예외 전파

공유 Semaphore의 초기 허가 수는 3이므로 허가를 보유한 작업은 최대 3개입니다. 원문은 sleep 모의 작업이며 실제 원격 요청 수나 관측된 최고 동시 실행 수를 측정하지 않습니다.

시간 제한과 과부하 시 요청 거부가 필요하다면 기한을 받는 tryAcquire를 사용합니다.

세마포어를 메서드마다 새로 만들면 요청 사이의 제한이 되지 않으므로 자원 소유자가 공유합니다.


ScopedValue의 동적 범위

요청 식별자와 행위자 같은 관찰 문맥을 여러 메서드 시그니처에 반복하지 않고 읽기 전용으로 전달할 수 있습니다.

바깥 호출과 중첩 호출에서 같은 키가 가리키는 값이 어떻게 달라지는지 살펴봅니다.

src/ScopedRequestContext.java
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을 지나며 바뀌는 요청 바인딩

중첩 run을 지나며 바뀌는 요청 바인딩

중첩 run을 지나며 바뀌는 요청 바인딩
원문 호출 시점현재 값 또는 상태읽는 작업
바깥 run 안req-42load-notes
중첩 run 안req-42/childload-tags
중첩 run 반환 뒤req-42save-summary
바깥 run 반환 뒤isBound() = false바인딩 없음
바깥 run 안
현재 값 또는 상태: req-42
읽는 작업: load-notes
중첩 run 안
현재 값 또는 상태: req-42/child
읽는 작업: load-tags
중첩 run 반환 뒤
현재 값 또는 상태: req-42
읽는 작업: save-summary
바깥 run 반환 뒤
현재 값 또는 상태: isBound() = false
읽는 작업: 바인딩 없음

ScopedRequestContext의 직접 호출 순서에서 도출한 값입니다.


가상 작업의 문맥 바인딩

임의로 생성한 스레드에 문맥이 자동으로 전파된다고 가정하지 않습니다.

작업 래퍼가 요청 문맥 값을 받아 작업 본문의 동적 범위에 명시적으로 바인딩합니다. 이 예제의 RequestContext는 String과 long으로 구성됩니다. record라는 이유만으로 가변 구성 요소까지 깊게 불변이 되는 것은 아닙니다.

app/ScopedVirtualBoardRequests.java
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로 바인딩하고 결과가 섞이지 않는지 확인하세요.

해설 보기
exercise/ScopedUserTaskSolution.java
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인 것입니다.