Scoped Values — ThreadLocal을 대체하는 경량 컨텍스트 전달
Scoped Values — ThreadLocal을 대체하는 경량 컨텍스트 전달
ThreadLocal은 왜 문제가 되었을까?
웹 서버에서 요청을 처리할 때, 인증된 사용자 정보를 여러 계층(Controller → Service → Repository)에 전달해야 하는 상황은 매우 흔합니다. 매번 파라미터로 넘기기 번거로우니 ThreadLocal을 써서 "현재 스레드에 값을 묶어두는" 패턴을 많이 씁니다.
// 전통적인 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()를 호출하지 않으면, 이전 요청의 데이터가 다음 요청에 남아있습니다. 보안 문제이자 메모리 릭입니다.
// 스레드 풀 환경에서 흔한 실수
try {
UserContext.set(authenticatedUser);
handleRequest();
} finally {
UserContext.clear(); // 여기서 예외가 나면? remove 안 됨
}
2. InheritableThreadLocal의 비용
부모 스레드의 ThreadLocal을 자식 스레드가 상속받으려면 InheritableThreadLocal을 써야 합니다. 자식 스레드 생성 시 부모의 모든 InheritableThreadLocal 값을 복사 합니다.
// 부모 스레드가 InheritableThreadLocal 10개를 가지고 있다면
// 자식 스레드 100개 생성 = 1,000번의 복사
3. Mutable — 누구든 값을 바꿀 수 있음
ThreadLocal.set()은 어디서든 호출 가능합니다. 의도치 않게 값이 덮어써지는 버그를 추적하기 어렵습니다.
Virtual Threads가 문제를 폭발시킨다
기존 플랫폼 스레드는 보통 수백 개 수준이었으니 ThreadLocal의 문제가 관리 가능했습니다. 하지만 Virtual Threads는 수백만 개 가 동시에 존재할 수 있습니다.
플랫폼 스레드 200개 × ThreadLocal 5개 = 1,000개 바인딩
Virtual Threads 1,000,000개 × ThreadLocal 5개 = 5,000,000개 바인딩 💥
InheritableThreadLocal은 더 심각합니다. Virtual Thread를 생성할 때마다 부모의 모든 ThreadLocal을 복사하면 메모리가 순식간에 고갈됩니다.
이것이 ScopedValue 가 필요한 이유입니다.
ScopedValue란?
ScopedValue는 불변(immutable)이고, 스코프에 묶이고, 자동으로 정리되는 컨텍스트 전달 메커니즘입니다.
| 특성 | ThreadLocal | ScopedValue |
|---|---|---|
| 변경 가능성 | mutable (set() 자유) | immutable (한번 바인딩하면 변경 불가) |
| 생명주기 | 수동 관리 (remove() 필요) | ** 스코프 자동 관리** |
| 상속 | 복사 (InheritableThreadLocal) | ** 공유** (복사 비용 없음) |
| Virtual Thread 적합성 | ❌ 메모리 폭발 위험 | ✅ 경량, 공유 기반 |
기본 사용법
// 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()를 깜빡할 일이 원천적으로 없습니다.
여러 값을 동시에 바인딩
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()을 씁니다:
public Response handleRequest(User user) {
return ScopedValue.where(CURRENT_USER, user).call(() -> {
// Callable처럼 값을 반환
return buildResponse();
});
}
스코프 중첩(Rebinding)
ScopedValue는 불변이지만, 중첩된 스코프에서 다른 값으로 바인딩 할 수 있습니다:
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)와 함께 쓸 때 나옵니다.
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
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
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()은 바인딩 없이 호출하면 예외
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이나 다른 방법이 필요합니다.
// 이런 패턴은 ScopedValue로 불가능
ThreadLocal<Integer> counter = ThreadLocal.withInitial(() -> 0);
counter.set(counter.get() + 1); // 값 변경
// ScopedValue는 불변 — 값을 바꾸려면 새 스코프를 열어야 함
Preview API
ScopedValue는 Java 21부터 프리뷰로 제공되고 있습니다. 컴파일 시 --enable-preview 플래그가 필요합니다.
javac --enable-preview --source 25 MyApp.java
java --enable-preview MyApp
정리
ScopedValue는 "스코프에 묶인 불변 값"입니다. ThreadLocal의 세 가지 문제(메모리 릭, 복사 비용, 변경 가능성)를 원천적으로 해결합니다.
| 상황 | ThreadLocal | ScopedValue |
|---|---|---|
| 요청 컨텍스트 전달 | 가능하지만 remove() 필수 | ✅ 자동 정리 |
| Virtual Threads | ❌ 메모리 폭발 | ✅ 공유 기반 |
| 자식 스레드 상속 | 복사 (비쌈) | ✅ 공유 (무료) |
| 값 변경 | ✅ set() 자유 | ❌ 불변 |
| 디버깅 | 어려움 (누가 set 했는지 추적 힘듦) | 쉬움 (스코프로 추적) |
공부하면서 인상 깊었던 건, ScopedValue가 단순히 "더 나은 ThreadLocal"이 아니라 Virtual Threads 시대의 필수 인프라 라는 점입니다. 100만 개의 가상 스레드가 각각 ThreadLocal을 복사하는 상황을 상상하면, ScopedValue가 왜 필요한지 바로 체감됩니다.