본문 바로가기

Programming/JAVA

Java 25 ScopedValue란? ThreadLocal 대안과 요청 컨텍스트 전달 방법

추천캐릭터 2026. 7. 27. 08:00
728x90

Java 25 ScopedValue란? ThreadLocal 대안과 요청 컨텍스트 전달 방법

도입부

Java 애플리케이션에는 요청 ID, 테넌트 ID, 로그인 사용자처럼 여러 계층에서 함께 사용하는 값이 있다. 모든 메서드에 같은 인자를 계속 전달하기 어려워 지금까지는 ThreadLocal을 많이 사용했다.

하지만 ThreadLocal은 값을 언제든 바꿀 수 있고, remove()를 빠뜨리면 스레드 풀의 다음 작업에 이전 값이 남을 수 있다. Java 25에서 정식 API가 된 ScopedValue는 이 문제를 줄이기 위해 읽기 중심 컨텍스트를 제한된 실행 범위에 바인딩한다.

결론부터 말하면 ScopedValue는 모든 ThreadLocal의 대체품이 아니다. 요청 정보처럼 호출자에서 하위 메서드로 한 방향으로 전달되는 값에 적합하다.

ScopedValue란?

ScopedValue<T>는 값을 특정 동적 범위에 연결하는 컨테이너다. 호출자는 ScopedValue.where()로 값을 바인딩하고 run() 또는 call()을 실행한다. 그 안에서 호출된 메서드는 매개변수 없이 값을 읽을 수 있다.

private static final ScopedValue<String> REQUEST_ID =
        ScopedValue.newInstance();

ScopedValue.where(REQUEST_ID, "req-20260727-001")
        .run(() -> processOrder());

processOrder()가 여러 서비스를 호출해도 REQUEST_ID 키에 접근할 수 있는 코드는 같은 값을 읽는다. 실행이 정상 종료되거나 예외로 끝나면 바인딩은 자동으로 해제된다. Oracle 문서가 ScopedValue를 “암시적 메서드 매개변수”와 비슷하다고 설명하는 이유다.

왜 중요한가?

ThreadLocal은 스레드마다 독립된 값을 저장하지만, 값의 변경과 정리를 개발자가 책임져야 한다.

try {
    REQUEST_ID.set("req-20260727-001");
    processOrder();
} finally {
    REQUEST_ID.remove();
}

finally를 빠뜨리면 값이 예상보다 오래 남을 수 있다. 반면 ScopedValue는 값의 시작과 끝이 run() 또는 call() 구조에 드러난다.

비교 항목 ThreadLocal ScopedValue
값 설정 set()으로 변경 가능 실행 범위에 바인딩
수명 스레드 수명 또는 remove()까지 run()·call() 범위까지
정리 직접 remove() 범위 종료 시 자동 복원
주 용도 스레드별 가변 상태 하위 호출로 전달하는 읽기 중심 값
Java 버전 오래된 표준 API Java 25 정식 API

ScopedValue가 참조하는 객체 자체를 JVM이 불변으로 만드는 것은 아니다. 변경 가능한 객체를 넣으면 그 내부 상태는 바뀔 수 있으므로 record 같은 불변 데이터 객체를 권장한다.

쉬운 예시: 요청 컨텍스트 전달

요청 ID와 테넌트 ID를 하나로 묶어 보자.

public record RequestContext(
        String requestId,
        String tenantId
) {
}

키를 외부에 직접 공개하기보다 바인딩과 조회를 담당하는 클래스로 감싼다.

public final class RequestContexts {

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

    private RequestContexts() {
    }

    public static void run(RequestContext context, Runnable task) {
        ScopedValue.where(CURRENT, context).run(task);
    }

    public static RequestContext current() {
        return CURRENT.orElseThrow(
                () -> new IllegalStateException(
                        "RequestContext가 없습니다."
                )
        );
    }
}

애플리케이션 진입 지점에서 값을 바인딩한다.

RequestContext context =
        new RequestContext(requestId, tenantId);

RequestContexts.run(context, () ->
        orderService.findOrder(orderId)
);

하위 서비스는 반복되는 매개변수 없이 현재 요청 정보를 읽는다.

아래 서비스 예제에서는 로거 선언과 저장소 코드를 생략했다.

public void findOrder(Long orderId) {
    RequestContext context = RequestContexts.current();

    log.info(
            "requestId={}, tenantId={}, orderId={}",
            context.requestId(),
            context.tenantId(),
            orderId
    );
}

가상 스레드와 Spring에서 사용하는 방식

바인딩은 기본적으로 현재 스레드 기준이다. Java 25의 StructuredTaskScope로 만든 구조화된 자식 작업은 부모 값을 상속할 수 있지만, 임의로 시작한 ThreadCompletableFuture에 자동 전파된다고 가정하면 안 된다.

또한 Java 25에서 ScopedValue는 정식 API지만 StructuredTaskScope는 프리뷰 API다. 구조화된 동시성을 사용하려면 별도의 프리뷰 설정이 필요하다. 가상 스레드의 운영 방식은 기존 글인 Virtual Threads 실전 활용법에서 확인할 수 있다.

Spring MVC에서는 필터나 인터셉터처럼 요청 전체를 감싸는 경계에서 애플리케이션 소유의 컨텍스트를 바인딩하는 방식이 자연스럽다. 다만 Spring 트랜잭션, SecurityContext, 로깅 MDC처럼 프레임워크가 이미 관리하는 ThreadLocal을 임의로 교체해서는 안 된다.

자주 하는 실수

  • 모든 ThreadLocal을 일괄 교체한다. ScopedValue는 한 방향의 컨텍스트 전달에 적합하다.
  • 바인딩하지 않고 get()을 호출한다. 이 경우 NoSuchElementException이 발생하므로 orElse()orElseThrow()를 사용한다.
  • 변경 가능한 DTO나 컬렉션을 공유한다. 가능하면 불변 record 하나에 값을 묶는다.
  • 요청 ID, 사용자 ID, 테넌트 ID마다 키를 만든다. Oracle API는 소수의 키 사용을 권장한다.
  • 비동기 작업에도 자동 전파된다고 생각한다. 실행 스레드가 바뀌면 별도의 전달 전략이 필요하다.

핵심 요약

  • ScopedValue는 Java 25에서 정식 도입된 제한 범위 컨텍스트 전달 API다.
  • 값은 run() 또는 call() 범위에서만 유효하고 종료 시 자동 복원된다.
  • 요청 정보는 불변 객체 하나로 묶는 방식이 좋다.
  • 임의의 비동기 작업에 값이 자동 전파되지는 않는다.
  • Spring 내부 컨텍스트보다 애플리케이션 소유의 읽기 전용 값부터 적용한다.

마무리

ScopedValue의 핵심은 문법을 줄이는 것이 아니라 컨텍스트의 수명을 코드 구조로 제한하는 데 있다. Java 25 프로젝트라면 요청 ID나 테넌트 정보처럼 아래 방향으로만 전달되는 값부터 작은 범위에서 적용해 보자.

Java 25의 다른 실무 변화는 Java 25 LTS 핵심 정리에서 이어서 확인할 수 있다.

공식 자료:

728x90