백엔드 면접 용어 사전
Java·객체지향Scoped Value · 스코프 값

Scoped Values

ThreadLocal을 대체하는 불변 컨텍스트 전달 기능. 값이 블록 범위에서만 살아 있고 자동으로 해제된다. Java 25 정식.

요청 컨텍스트처럼 여러 메서드에 걸쳐 흘려보낼 값을 파라미터로 넘기지 않고 전달하는 기능. Java 25에서 정식화됐다. ThreadLocal의 대체재다.

먼저 — ThreadLocal이 무엇이었나

ThreadLocal스레드마다 자기만의 값 한 칸을 갖게 해 주는 저장소다. 메모리 관점에서는 각 Thread 객체 안에 맵이 하나 달려 있고, 키가 ThreadLocal 인스턴스, 값이 그 스레드의 값이다.

Thread A ─ threadLocals: { userIdHolder → "u-100" }
Thread B ─ threadLocals: { userIdHolder → "u-200" }

덕분에 userIdHolder.get() 한 줄로 "지금 이 요청의 사용자"를 아무 데서나 꺼낼 수 있었다. 문제는 둘이었다.

  1. 직접 지워야 한다. 스레드가 풀에서 재사용되므로, remove()를 안 하면 다음 요청이 앞 요청의 값을 보게 된다. 값이 무거우면 그대로 메모리 누수다.
  2. 아무 데서나 덮어쓸 수 있다. set()이 열려 있어 값의 수명과 주인이 불분명하다.

가상 스레드에서 더 나빠지는 지점

가상 스레드는 요청마다 만들고 버린다. 그래서 ThreadLocal에 담아 둔 캐시가 재사용되지 못하고 요청마다 다시 만들어진다. 같은 60초 워크로드에서 초기화 횟수가 200회 → 443,267회로 약 2,200배가 된 측정이 있다. 예외도 경고도 없이 GC 압력만 늘어난다.

Scoped Values의 방식 — 블록 안에서만 산다

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

ScopedValue.where(USER_ID, "u-100").run(() -> {
    handleRequest();          // 이 블록 안에서만 USER_ID.get() 이 유효
});                           // 블록을 벗어나면 자동으로 사라진다 — remove() 불필요

차이는 세 가지다.

ThreadLocalScopedValue
값 변경언제든 set() 가능불변 — 바인딩된 블록 안에서 못 바꾼다
해제remove()를 직접 호출블록 종료 시 자동
자식 작업 전달InheritableThreadLocal로 값을 복사구조적 동시성의 자식 작업이 복사 없이 공유

마지막 항목이 성능 차이를 만든다. 값을 복사하지 않고 참조만 넘기므로, 요청마다 새 스레드를 만드는 환경에서도 비용이 거의 없다.

면접 포인트

"가상 스레드로 전환할 때 무엇을 점검하겠나"라는 질문에 무거운 객체를 담은 ThreadLocal 을 짚고, 대안으로 Scoped Values를 말하면 깊이가 드러난다.

자식 작업으로 어떻게 전달되나

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

ScopedValue.where(USER_ID, "u-100").run(() -> {
    try (var scope = StructuredTaskScope.open()) {
        scope.fork(() -> audit());      // 자식도 USER_ID.get() 가능 — 복사 없이
        scope.fork(() -> notifyUser());
        scope.join();
    }
});

InheritableThreadLocal은 자식 스레드마다 값을 복사했다. 요청마다 스레드를 새로 만드는 환경에서는 그 복사가 곧 비용이다. Scoped Values는 부모의 바인딩을 참조로 공유한다.

값을 바꿔야 한다면 — 덮어쓰지 말고 다시 바인딩한다

// ❌ ScopedValue 에는 set() 이 없다
// ✅ 더 안쪽 범위에 새 바인딩을 만든다
ScopedValue.where(USER_ID, "admin").run(() -> {
    doPrivilegedWork();       // 이 안에서만 "admin"
});                           // 벗어나면 바깥 값으로 되돌아온다

값의 유효 범위가 코드 블록으로 눈에 보인다는 것이 ThreadLocal 대비 가장 큰 차이다.

함께 보면 좋은 용어

노트에서 맥락과 함께 보기 — 모던 자바·스프링 — 가상 스레드·JEP 491·리액티브·jOOQ·Boot 4