Scoped Values — ThreadLocal을 대체하는 경량 컨텍스트 전달

ThreadLocal은 왜 문제가 되었을까?

웹 서버에서 요청을 처리할 때, 인증된 사용자 정보를 여러 계층(Controller → Service → Repository)에 전달해야 하는 상황은 매우 흔합니다. 매번 파라미터로 넘기기 번거로우니 ThreadLocal을 써서 "현재 스레드에 값을 묶어두는" 패턴을 많이 씁니다.

JAVA
// 전통적인 ThreadLocal 패턴
public class UserContext {
    private static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();

    public static void set(User user) { CURRENT_USER.set(user); }
    public static User get() { return CURRENT_USER.get(); }
    public static void clear() { CURRENT_USER.remove(); }  // 깜빡하면?
}

이 패턴에는 세 가지 근본적인 문제가 있습니다:

1. 메모리 릭 — remove()를 깜빡하면 끝

스레드 풀에서 스레드를 재사용하는 환경(대부분의 웹 서버)에서 remove()를 호출하지 않으면, 이전 요청의 데이터가 다음 요청에 남아있습니다. 보안 문제이자 메모리 릭입니다.

JAVA
// 스레드 풀 환경에서 흔한 실수
try {
    UserContext.set(authenticatedUser);
    handleRequest();
} finally {
    UserContext.clear();  // 여기서 예외가 나면? remove 안 됨
}

2. InheritableThreadLocal의 비용

부모 스레드의 ThreadLocal을 자식 스레드가 상속받으려면 InheritableThreadLocal을 써야 합니다. 자식 스레드 생성 시 부모의 모든 InheritableThreadLocal 값을 복사 합니다.

JAVA
// 부모 스레드가 InheritableThreadLocal 10개를 가지고 있다면
// 자식 스레드 100개 생성 = 1,000번의 복사

3. Mutable — 누구든 값을 바꿀 수 있음

ThreadLocal.set()은 어디서든 호출 가능합니다. 의도치 않게 값이 덮어써지는 버그를 추적하기 어렵습니다.


Virtual Threads가 문제를 폭발시킨다

기존 플랫폼 스레드는 보통 수백 개 수준이었으니 ThreadLocal의 문제가 관리 가능했습니다. 하지만 Virtual Threads는 수백만 개 가 동시에 존재할 수 있습니다.

PLAINTEXT
플랫폼 스레드 200개 × ThreadLocal 5개 = 1,000개 바인딩
Virtual Threads 1,000,000개 × ThreadLocal 5개 = 5,000,000개 바인딩 💥

InheritableThreadLocal은 더 심각합니다. Virtual Thread를 생성할 때마다 부모의 모든 ThreadLocal을 복사하면 메모리가 순식간에 고갈됩니다.

이것이 ScopedValue 가 필요한 이유입니다.


ScopedValue란?

ScopedValue불변(immutable)이고, 스코프에 묶이고, 자동으로 정리되는 컨텍스트 전달 메커니즘입니다.

특성ThreadLocalScopedValue
변경 가능성mutable (set() 자유)immutable (한번 바인딩하면 변경 불가)
생명주기수동 관리 (remove() 필요)** 스코프 자동 관리**
상속복사 (InheritableThreadLocal)** 공유** (복사 비용 없음)
Virtual Thread 적합성❌ 메모리 폭발 위험✅ 경량, 공유 기반

기본 사용법

JAVA
// 1. ScopedValue 선언 — static final로
private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

// 2. 값을 바인딩하고 스코프 안에서 실행
public void handleRequest(User user) {
    ScopedValue.where(CURRENT_USER, user).run(() -> {
        // 이 블록 안에서는 CURRENT_USER.get()으로 접근 가능
        processRequest();
    });
    // 블록이 끝나면 바인딩 자동 해제 — remove() 필요 없음
}

// 3. 스코프 안 어디서든 값 읽기
public void processRequest() {
    User user = CURRENT_USER.get();       // 현재 바인딩된 값
    boolean bound = CURRENT_USER.isBound(); // 바인딩 여부 확인
}

핵심은 where().run() 블록이 끝나면 바인딩이 자동으로 사라진다 는 것입니다. remove()를 깜빡할 일이 원천적으로 없습니다.


여러 값을 동시에 바인딩

JAVA
private static final ScopedValue<User> USER = ScopedValue.newInstance();
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
private static final ScopedValue<Locale> LOCALE = ScopedValue.newInstance();

public void handleRequest(User user, String requestId, Locale locale) {
    ScopedValue.where(USER, user)
               .where(REQUEST_ID, requestId)
               .where(LOCALE, locale)
               .run(() -> {
                   // 세 값 모두 이 스코프에서 접근 가능
                   processRequest();
               });
}

where()를 체이닝하면 여러 ScopedValue를 한 번에 바인딩할 수 있습니다.


반환값이 필요할 때: call()

run()void를 반환합니다. 결과가 필요하면 call()을 씁니다:

JAVA
public Response handleRequest(User user) {
    return ScopedValue.where(CURRENT_USER, user).call(() -> {
        // Callable처럼 값을 반환
        return buildResponse();
    });
}

스코프 중첩(Rebinding)

ScopedValue는 불변이지만, 중첩된 스코프에서 다른 값으로 바인딩 할 수 있습니다:

JAVA
ScopedValue.where(CURRENT_USER, admin).run(() -> {
    System.out.println(CURRENT_USER.get());  // admin

    // 내부 스코프에서 다른 값으로 리바인딩
    ScopedValue.where(CURRENT_USER, regularUser).run(() -> {
        System.out.println(CURRENT_USER.get());  // regularUser
    });

    // 내부 스코프가 끝나면 원래 값 복원
    System.out.println(CURRENT_USER.get());  // admin
});

이 동작은 "스택 프레임"과 비슷합니다. 내부 스코프의 바인딩이 외부를 오염시키지 않습니다.


Structured Concurrency와의 조합

ScopedValue의 진짜 힘은 Structured Concurrency(StructuredTaskScope)와 함께 쓸 때 나옵니다.

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

public Response handleRequest(String requestId) {
    return ScopedValue.where(REQUEST_ID, requestId).call(() -> {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {

            // 자식 태스크들이 부모의 ScopedValue를 자동으로 "상속"
            // (복사가 아님 — 같은 바인딩을 공유)
            Subtask<UserProfile> profile = scope.fork(() -> {
                // REQUEST_ID.get() 사용 가능
                return fetchProfile(REQUEST_ID.get());
            });

            Subtask<List<Order>> orders = scope.fork(() -> {
                // 여기서도 같은 REQUEST_ID에 접근
                return fetchOrders(REQUEST_ID.get());
            });

            scope.join().throwIfFailed();
            return new Response(profile.get(), orders.get());
        }
    });
}

InheritableThreadLocal과 달리 ** 값을 복사하지 않고 공유 **합니다. Virtual Thread 100만 개가 같은 ScopedValue를 읽어도 메모리 사용량이 늘지 않습니다.


ThreadLocal에서 ScopedValue로 마이그레이션

Before — ThreadLocal

JAVA
public class SecurityContext {
    private static final ThreadLocal<Principal> PRINCIPAL = new ThreadLocal<>();

    public static void setPrincipal(Principal p) { PRINCIPAL.set(p); }
    public static Principal getPrincipal() { return PRINCIPAL.get(); }
    public static void clear() { PRINCIPAL.remove(); }
}

// 사용하는 쪽
public class RequestFilter {
    public void doFilter(Request req) {
        try {
            SecurityContext.setPrincipal(authenticate(req));
            chain.doFilter(req);
        } finally {
            SecurityContext.clear();  // 반드시 호출해야 함
        }
    }
}

After — ScopedValue

JAVA
public class SecurityContext {
    public static final ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance();
    // set, clear 메서드가 필요 없음!
}

// 사용하는 쪽
public class RequestFilter {
    public void doFilter(Request req) {
        Principal principal = authenticate(req);
        ScopedValue.where(SecurityContext.PRINCIPAL, principal).run(() -> {
            chain.doFilter(req);
        });
        // 자동 정리 — finally 블록 불필요
    }
}

코드가 더 간결하고, 안전합니다. 실수할 여지 자체를 없앤 거죠.


주의할 점

ScopedValue.get()은 바인딩 없이 호출하면 예외

JAVA
private static final ScopedValue<User> USER = ScopedValue.newInstance();

// 바인딩 없이 호출하면 NoSuchElementException
User user = USER.get();  // 💥 예외

// 안전하게 접근
if (USER.isBound()) {
    User user = USER.get();
}

// 또는 기본값과 함께
User user = USER.orElse(User.ANONYMOUS);

ThreadLocal을 완전히 대체하지는 않음

ScopedValue는 ** 불변 **입니다. 스코프 안에서 값을 변경해야 하는 경우(카운터, 누적기 등)에는 여전히 ThreadLocal이나 다른 방법이 필요합니다.

JAVA
// 이런 패턴은 ScopedValue로 불가능
ThreadLocal<Integer> counter = ThreadLocal.withInitial(() -> 0);
counter.set(counter.get() + 1);  // 값 변경

// ScopedValue는 불변 — 값을 바꾸려면 새 스코프를 열어야 함

Preview API

ScopedValue는 Java 21부터 프리뷰로 제공되고 있습니다. 컴파일 시 --enable-preview 플래그가 필요합니다.

BASH
javac --enable-preview --source 25 MyApp.java
java --enable-preview MyApp

정리

ScopedValue는 "스코프에 묶인 불변 값"입니다. ThreadLocal의 세 가지 문제(메모리 릭, 복사 비용, 변경 가능성)를 원천적으로 해결합니다.

상황ThreadLocalScopedValue
요청 컨텍스트 전달가능하지만 remove() 필수✅ 자동 정리
Virtual Threads❌ 메모리 폭발✅ 공유 기반
자식 스레드 상속복사 (비쌈)✅ 공유 (무료)
값 변경✅ set() 자유❌ 불변
디버깅어려움 (누가 set 했는지 추적 힘듦)쉬움 (스코프로 추적)

공부하면서 인상 깊었던 건, ScopedValue가 단순히 "더 나은 ThreadLocal"이 아니라 Virtual Threads 시대의 필수 인프라 라는 점입니다. 100만 개의 가상 스레드가 각각 ThreadLocal을 복사하는 상황을 상상하면, ScopedValue가 왜 필요한지 바로 체감됩니다.

댓글 로딩 중...