한 줄 정의
ScopedValue는 값을 특정 코드 블록(동적 스코프)에 불변으로 바인딩해, 그 안에서 호출되는 모든 메서드가 파라미터 없이 값을 읽고 블록을 벗어나면 자동으로 사라지게 하는 컨텍스트 전파 도구입니다.
쉽게 말하면
무대 조명을 떠올려 봅시다. 한 장면(스코프)을 시작하며 조명을 파란색으로 켜면, 그 장면 동안 무대에 오르는 모든 배우(호출되는 메서드)는 별도로 손전등을 받지 않아도 파란 조명 아래 있습니다. 장면이 끝나면 조명은 자동으로 이전 상태로 꺼지고, 장면 도중에는 조명 색을 바꿀 수 없습니다.
이 조명 방식을 파라미터 전달·ThreadLocal과 비교하면 차이가 분명합니다.
- 파라미터 전달: 배우마다 손전등을 직접 손에서 손으로 넘김. 조명에 관심 없는 배우도 반드시 받아 다음 배우에게 넘겨야 함.
ThreadLocal: 극장 벽에 공용 스위치를 달아둠. 아무나 켜고 아무나 끄고, 누가 껐는지 알 수 없고, 끄는 걸 잊으면 다음 공연까지 켜진 채 남음.ScopedValue: 장면 단위로만 켜지고 끝나면 자동으로 꺼지는 조명. 도중에 색을 못 바꾸고(불변), 다른 무대(스레드)로 새어 나가지 않음.
왜 중요한가?
코드 여러 곳에서 같은 데이터(요청 사용자, 트랜잭션, 로깅 컨텍스트)를 공유해야 하는데, 이를 메서드 인자로 넘기면 파라미터 오염이 발생합니다. 프레임워크가 만든 JobContext 같은 내부 컨텍스트를 사용자 코드의 호출 체인 전체가 계속 달고 다녀야 하기 때문입니다.
// context를 직접 쓰지 않는 메서드조차 프레임워크에 넘기려고 파라미터를 받아야 한다
private void processJobData(JobContext context) {
Object priority = jobScheduler.getJobMetadata("priority", context);
...
}이 방식은 세 가지를 무너뜨립니다.
- 파라미터 오염: 컨텍스트를 안 쓰는 메서드까지 시그니처가 프레임워크 세부 사항으로 오염됩니다.
- 인터페이스 취약성: 컨텍스트에 필드 하나(트레이싱, 로깅 참조 등)만 추가돼도 이를 전달하던 모든 메서드로 영향이 퍼집니다.
- 결합도·테스트: 비즈니스 로직 하나를 테스트하려 해도 항상 유효한 컨텍스트를 만들어 줘야 합니다.
전통적 해법은 ThreadLocal이지만, 뒤에서 보듯 태생적 결함이 있고 가상 스레드 시대에 그 결함이 치명적으로 커집니다. ScopedValue는 파라미터 오염을 없애면서 ThreadLocal의 결함까지 해결하려고 등장했습니다.
핵심 내용
ThreadLocal과 그 한계
ThreadLocal은 컨텍스트를 스레드에 매달아 두어 파라미터를 시그니처에서 걷어냅니다. schedule()에서 set()하고 finally에서 remove()하면, 사용자 코드는 컨텍스트를 모른 채 비즈니스 로직에만 집중할 수 있습니다. 스프링이 보안·트랜잭션·요청 컨텍스트를 관리하는 방식이 바로 이것입니다.
문제는 세 가지 태생적 결함입니다.
| 결함 | 내용 | 결과 |
|---|---|---|
| 변경 무제약 | get()이 되는 곳이면 어디서든 set()으로 값을 바꿀 수 있음 | 값이 언제·어디서 바뀌는지 추적 불가 |
| 무한 생애주기 | remove() 전까지 스레드가 살아있는 한 값이 유지됨 | 스레드 풀 재사용 시 이전 태스크 값이 다음 태스크로 누수 (메모리·보안·오동작) |
| 상속 오버헤드 | InheritableThreadLocal은 자식 스레드마다 참조를 복사 | 자식이 수정 안 해도 각자 ThreadLocalMap에 참조 보관 → 큰 객체의 GC 지연 |
특히 누수가 위험합니다. 단일 스레드 풀에서 첫 태스크가 currentUser를 "Alice"로 설정하고 remove()를 잊으면, 같은 스레드를 재사용하는 두 번째 태스크가 "Alice"를 그대로 읽습니다. 인증 토큰 같은 민감 데이터가 관련 없는 요청 사이로 새는 것입니다.
이 결함들은 가상 스레드에서 증폭됩니다. 수백만 개의 가상 스레드가 각자 ThreadLocal 복사본을 갖는다면 메모리 오버헤드가 감당 불가능해집니다. 필요한 건 불변이고, 복제 없이 공유되며, 수명이 스코프에 묶인 새 메커니즘입니다.
ScopedValue의 세 가지 특징
| 특징 | 의미 | 얻는 것 |
|---|---|---|
| 불변성 | 바인딩된 값은 스코프 동안 변경 불가 | 경쟁 상태·데이터 불일치 예방, 예측 가능 |
| 스레드 제한 바인딩 | 바인딩은 현재 스레드에 한정 | 스레드 간 의도치 않은 공유 차단 |
| 유한한 수명 | 코드 블록 실행 동안만 유지, 끝나면 자동 해제 | 메모리 누수 위험 제거 |
기본 API와 사용법
ThreadLocal처럼 static final 필드로 선언하되, 생성자가 private이라 팩토리 메서드로만 만듭니다.
private static final ScopedValue<JobContext> CONTEXT = ScopedValue.newInstance();
public void schedule(Job job, String jobName, Priority priority) {
JobContext context = new JobContext(jobName, priority);
ScopedValue.where(CONTEXT, context) // 값 바인딩
.run(() -> runJob(job)); // 스코프 안에서 실행
}runJob 이하로 아무리 깊이 호출이 이어져도 CONTEXT.get()으로 값을 읽을 수 있습니다. 파라미터가 사라진 것입니다.
| API | 역할 |
|---|---|
newInstance() | 인스턴스 생성 (팩토리 강제). static final로 선언 |
where(sv, value) | 값 바인딩. .where(...).where(...)로 여러 개 동시 바인딩 |
run(Runnable) | 바인딩 스코프에서 실행, void 반환 |
call(Callable) | 바인딩 스코프에서 실행, 값 반환 (계산·변환 결과가 필요할 때) |
get() | 값 조회. 미바인딩 시 NoSuchElementException |
isBound() | 현재 바인딩 여부 검사 |
orElse(기본값) / orElseThrow(예외) | 미바인딩 시 기본값 사용 / 예외 던짐 |
get()이 미바인딩 시 null이 아니라 예외를 던지는 점이 중요합니다. “값을 안 넣은 것”과 “null을 넣은 것”을 구분 못 해 생기는 ThreadLocal의 미묘한 버그를 초기에 잡아줍니다.
동적 스코프: 값이 실행 흐름을 따라 산다
자바 변수는 보통 {} 블록으로 정해지는 렉시컬 스코프를 갖지만, ScopedValue는 동적 스코프에서 작동합니다. run()으로 진입한 실행 흐름과 그 흐름이 호출하는 모든 메서드에서 값이 보이고, run()이 반환되면 바인딩이 자동 해제됩니다.
flowchart TB subgraph S["바인딩 스코프 (where + run)"] A["a()"] --> B["b()"] --> C["c() : NAME.get() = duke"] end S --> X["run() 반환 → 바인딩 자동 해제"] X --> D["스코프 밖 : NAME.get() → NoSuchElementException"]
같은 task라도 where(...).run(task)로 실행하면 값이 보이고(Name is bound), 스코프 밖에서 그냥 task.run()하면 보이지 않습니다(Name is not bound). 값의 수명이 코드 구조가 아니라 실행 흐름에 묶여 있기 때문입니다.
스레드 상속 규칙
ScopedValue는 새로 만든 스레드에 자동 상속되지 않습니다. 스코프 안에서 스레드를 시작(thread::start)해도, 그 스레드에서 도는 실제 태스크는 바인딩을 물려받지 못합니다(플랫폼·가상 스레드 모두 동일). 이는 ThreadLocal 풀 재사용 누수의 근본 원인을 아예 없앤 안전한 기본값입니다.
단 하나의 예외가 구조적 동시성입니다. StructuredTaskScope 안에서 fork()한 자식 스레드는 부모의 ScopedValue 바인딩을 자동 상속합니다.
ScopedValue.where(USERNAME, "Bazlur").run(() -> doSomething());
void doSomething() {
try (var scope = StructuredTaskScope.open()) {
var t1 = scope.fork(() -> USERNAME.get() + " from task 1"); // 상속됨
var t2 = scope.fork(() -> USERNAME.get() + " from task 2");
scope.join();
...
}
}구조적 동시성이 명확한 경계를 갖기에 상속이 안전합니다. 스코프를 벗어나면 자식 스레드가 모두 종료·GC되므로 누수가 생기지 않습니다.
중첩 리바인딩
중첩 스코프에서 같은 ScopedValue에 새 값을 바인딩할 수 있고, 중첩 스코프가 끝나면 바깥 값이 자동 복원됩니다.
ScopedValue.where(USER_ROLE, "Admin").run(() -> {
performTask(); // Admin
ScopedValue.where(USER_ROLE, "Guest").run(() -> {
performTask(); // Guest (중첩 스코프)
});
performTask(); // Admin (자동 복원)
});같은 performTask()가 호출 시점의 스코프에 따라 다른 값을 읽습니다. 특정 작업 동안만 권한을 임시로 낮추는 역할 기반 접근 제어처럼, 넓은 컨텍스트를 건드리지 않고 특정 실행 흐름만 덮어쓰기 할 때 유용합니다.
대표 활용 패턴
숨은 파라미터 역할을 넘어, 아래 세 패턴은 모두 isBound()로 재진입 감지 → 리바인딩 → 자동 복원이라는 한 메커니즘의 응용입니다.
- 재귀 깊이 제한: 재진입 불가 프레임워크에서
isBound()로 최초 진입인지 판별하고, 중첩마다RECURSION_DEPTH를 +1 리바인딩해 한계 초과 시 예외. 순환 참조 템플릿의 무한 재귀를 방어합니다. - 평탄화 트랜잭션:
isBound()면 진행 중인 바깥 트랜잭션에 조인하고, 없을 때만 새 트랜잭션 시작. 중첩 연산이 모두 최외곽 트랜잭션 하나에 참여해 일관성이 유지됩니다. - 그래픽스 컨텍스트: 컨테이너가 설정한 그리기 스타일(색·선 굵기)을 자식이 상속하되, 자식은 리바인딩으로 자기 스타일을 적용하고 끝나면 부모 스타일로 자동 복원.
비교 / 트레이드오프
ThreadLocal vs ScopedValue
| 구분 | ThreadLocal | ScopedValue |
|---|---|---|
| 변경 가능성 | 어디서든 set() 가능 | 불변, where()로만 바인딩 |
| 수명 | 무한 (수동 remove() 필요) | 스코프 종료 시 자동 해제 |
| 스레드 풀 누수 | 발생 (remove 누락 시) | 구조적으로 불가능 |
| 스레드 상속 | InheritableThreadLocal로 복사 (오버헤드) | 미상속 / 구조적 동시성만 경계 내 상속 |
| 미설정 조회 | null 반환 (“안 넣음”과 “null” 구분 못 함) | NoSuchElementException |
| 가상 스레드 | 복사본 급증, 메모리 병목 | 공유·경량, 대량 태스크에 최적 |
| API | 장황·암묵적 | where/run으로 스코프 명시 |
마이그레이션 주의점
- 불변 데이터만 공유 가능. 스코프 안에서 값을 바꿔야 한다면 ScopedValue는 부적합합니다.
NoSuchElementException(ScopedValue) vsnull(ThreadLocal)의 미설정 처리 차이를 확인해야 합니다.- JDK 25부터 프리뷰를 벗어나 정식 API가 되었습니다. 이는 현대 자바에서 컨텍스트 전파의 권장 방식이 ScopedValue라는 뜻이며, API 변경 걱정 없이 기존
ThreadLocal코드를 전환할 수 있습니다.
내 생각
- 불변성 강제가 진짜 안전장치입니다. ThreadLocal의 “아무 데서나 set” 이 디버깅을 지옥으로 만들었는데, ScopedValue는 값이 바뀌는 지점이
where하나로 못 박히고 수명이 코드 구조로 드러납니다. - 자동 상속을 막은 게 오히려 옳은 기본값입니다. 편의(자동 상속)를 포기하는 대신 누수의 근본 원인을 제거했고, 정말 상속이 필요한 곳(구조적 동시성)에서만 명확한 경계 안에서 허용합니다.
isBound()+ 리바인딩 하나로 재귀 감지·트랜잭션 평탄화·컨텍스트 상속을 다 풉니다. API 표면은 작은데 표현력이 높다는 점이 설계의 핵심입니다.
관련 개념
- Ch04 빈틈없이 견고하고 편리한 구조적 동시성 —
StructuredTaskScope안에서만 ScopedValue가 자식 스레드에 상속되는 이유 - Ch02 쉼 없이 CPU를 활용하는 가볍고 부지런한 가상 스레드 — ThreadLocal 오버헤드가 치명적으로 커지는 대량 스레드 환경