요청 컨텍스트처럼 여러 메서드에 걸쳐 흘려보낼 값을 파라미터로 넘기지 않고 전달하는 기능. Java 25에서 정식화됐다. ThreadLocal의 대체재다.
먼저 — ThreadLocal이 무엇이었나
ThreadLocal은 스레드마다 자기만의 값 한 칸을 갖게 해 주는 저장소다. 메모리 관점에서는 각 Thread 객체 안에 맵이 하나 달려 있고, 키가 ThreadLocal 인스턴스, 값이 그 스레드의 값이다.
Thread A ─ threadLocals: { userIdHolder → "u-100" }
Thread B ─ threadLocals: { userIdHolder → "u-200" }
덕분에 userIdHolder.get() 한 줄로 "지금 이 요청의 사용자"를 아무 데서나 꺼낼 수 있었다. 문제는 둘이었다.
- 직접 지워야 한다. 스레드가 풀에서 재사용되므로,
remove()를 안 하면 다음 요청이 앞 요청의 값을 보게 된다. 값이 무거우면 그대로 메모리 누수다. - 아무 데서나 덮어쓸 수 있다.
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() 불필요
차이는 세 가지다.
ThreadLocal | ScopedValue | |
|---|---|---|
| 값 변경 | 언제든 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 대비 가장 큰 차이다.