1. 왜 ThreadLocal이 덜 적합해지는가
ThreadLocal은 왜 필요한가?
전통적인 멀티스레딩(예: 서버)처럼 스레드가 오래 살아있는 환경에서는, 스레드마다 서로 섞이면 안 되는 고유 데이터를 저장해야 할 때가 있습니다. 예를 들어 사용자 이름, 요청 ID, 임시 버퍼 등이 그렇습니다.
이를 위해 Java에는 ThreadLocal<T>이 도입되었습니다. 스레드의 ‘개인 공간’에 데이터를 저장해 이웃 스레드와 간섭하지 않도록 합니다:
ThreadLocal<String> user = new ThreadLocal<>();
user.set("Alice"); // 이 값은 현재 스레드에만 저장됩니다
String name = user.get(); // 여기서는 "Alice"를 반환하고, 다른 스레드에서는 null입니다
왜 ThreadLocal은 가상 스레드와 잘 맞지 않는가
가상 스레드는 기존의 ‘무거운’ 스레드와는 전혀 다르게 살아갑니다. 수천 개가 생겼다가 사라지며 — 때로는 밀리초의 일부만 존재하기도 합니다. 그런데 ThreadLocal은 데이터를 특정 스레드에 묶어, 그 스레드가 마치 영원히 살 것처럼 가정합니다.
가상 스레드가 작업을 끝내면, ThreadLocal의 데이터가 메모리에 남아 있을 수 있습니다 — 스레드는 이미 사라졌는데도요. 이는 누수로 이어질 수 있으며, JVM이 그 값이 더 이상 필요 없다는 사실을 항상 알 수는 없습니다.
또한 스레드가 재사용되면(예: 풀) 더 골치 아픈 문제가 생길 수 있습니다. ‘남의’ 컨텍스트가 새 요청으로 우연히 넘어갈 수 있기 때문입니다. 예를 들어 사용자 Petya가 Vasya의 데이터를 받는다면 — 버그와 취약점으로 이어집니다.
ThreadLocal은 스레드 수가 적고 오래 살아있는 환경에서는 훌륭합니다. 하지만 가상 스레드와 함께라면 — 매초 사라지는 옷장에 물건을 보관하려는 것과 같습니다.
2. Scoped Values: 컨텍스트를 전달하는 새로운 방법
Scoped Values는 Java 21의 신기능으로, 오래된 ThreadLocal 문제를 더 우아하게 해결합니다. ThreadLocal처럼 데이터를 스레드 내부에 저장하는 대신, 실행 영역(특정 코드 구간)에 ‘부착’합니다. 값은 그 구간이 실행되는 동안에만 존재하고, 끝나면 자동으로 사라져 메모리에 흔적을 남기지 않습니다.
import java.lang.ScopedValue;
ScopedValue<String> USER = ScopedValue.newInstance();
ScopedValue.where(USER, "Alice").run(() -> {
System.out.println("Hello, " + USER.get()); // 출력: Hello, Alice
});
코드가 run 블록을 벗어나면 값은 더 이상 접근할 수 없습니다 — 접근을 시도하면 예외가 발생합니다. 수동으로 정리할 필요가 없습니다.
Scoped Values는 메모리를 어지럽히지 않고, 스레드 간 컨텍스트가 섞이지 않으며, 내부 값이 외부 값을 일시적으로 덮어쓰는 중첩 영역을 만들 수 있습니다. 특히 가상 스레드 환경에서 컨텍스트를 전달하기 위한 깔끔하고 예측 가능하며 안전한 방법입니다.
3. Scoped Values 사용 예시
예시 1: 사용자 컨텍스트 전달
여러 사용자의 요청을 처리하는 서버가 있다고 가정해 봅시다. 각 요청마다 누가 요청했는지 알고 싶습니다.
import java.lang.ScopedValue;
public class ServerExample {
static final ScopedValue<String> USER = ScopedValue.newInstance();
public static void main(String[] args) {
processRequest("Alice");
processRequest("Bob");
}
static void processRequest(String userName) {
ScopedValue.where(USER, userName).run(() -> {
handleBusinessLogic();
});
}
static void handleBusinessLogic() {
System.out.println("사용자에 대해 처리 중: " + USER.get());
}
}
무슨 일이 일어나는가:
- 각 요청마다 자체 scope가 생성되고, 그 안에서 USER는 “Alice” 또는 “Bob”이 됩니다.
- handleBusinessLogic() 내부에서는 항상 올바른 사용자 이름을 얻습니다.
- 요청 처리가 끝나면 값은 사라집니다.
예시 2: 컨텍스트가 포함된 로깅
로그에 요청 식별자를 자동으로 삽입하고 싶다고 가정해 봅시다:
import java.lang.ScopedValue;
public class LoggingExample {
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
public static void main(String[] args) {
for (int i = 1; i <= 3; i++) {
String reqId = "REQ-" + i;
ScopedValue.where(REQUEST_ID, reqId).run(() -> {
log("처리 시작");
doWork();
log("처리 종료");
});
}
}
static void log(String message) {
System.out.printf("[%s] %s%n", REQUEST_ID.get(), message);
}
static void doWork() {
log("작업 중...");
}
}
결과(예):
[REQ-1] 처리 시작
[REQ-1] 작업 중...
[REQ-1] 처리 종료
[REQ-2] 처리 시작
[REQ-2] 작업 중...
[REQ-2] 처리 종료
[REQ-3] 처리 시작
[REQ-3] 작업 중...
[REQ-3] 처리 종료
각 scope는 자기 요청 식별자를 보유하며, 스레드 간 혼동이 발생하지 않습니다.
4. Scoped Values와 가상 스레드: 완벽한 조합
가상 스레드에서 Scoped Values가 특히 유용한 이유
가상 스레드는 수명이 짧습니다 — 수천 개가 생성·소멸하며 때로는 1초의 일부만 존재합니다. 따라서 데이터가 스레드 자체에 ‘단단히’ 묶이는 ThreadLocal 방식은 잘 맞지 않습니다. 스레드가 너무 빨리 사라지고, 컨텍스트가 우연히 누수되거나 뒤섞일 수 있기 때문입니다.
반면 ScopedValue는 데이터를 작업 자체, 즉 그 실행 영역에 묶습니다. 이는 컨텍스트(예: 사용자 이름이나 요청 ID)가 스레드가 아니라 코드 흐름을 따라간다는 뜻입니다. 작업이 끝나면 값은 자동으로 사라집니다. 가상 스레드에 이상적인 해법입니다 — 안전하고 깔끔하며 예상 가능하니까요.
예시: 가상 스레드로 대량 작업 처리
import java.lang.ScopedValue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualThreadScopedValueDemo {
static final ScopedValue<Integer> TASK_ID = ScopedValue.newInstance();
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 10_000; i++) {
int taskId = i;
executor.submit(() -> ScopedValue.where(TASK_ID, taskId).run(() -> {
processTask();
}));
}
executor.shutdown();
}
static void processTask() {
// 각 작업마다 고유한 TASK_ID
System.out.println("작업 처리 중 #" + TASK_ID.get());
}
}
핵심 포인트:
- 각 작업마다 TASK_ID 값의 자체 scope가 생성됩니다.
- 작업이 병렬 실행되더라도 값이 스레드 간에 뒤섞이지 않습니다.
- 메모리 누수 없음: scope는 작업과 함께 ‘소멸’합니다.
5. 비교: ThreadLocal vs ScopedValue
| 기준 | ThreadLocal | ScopedValue |
|---|---|---|
| 바인딩 대상 | 스레드 | 코드 영역(scope) |
| 수명 주기 | 스레드가 살아있는 동안 | scope가 실행되는 동안 |
| 안전성 | 누수·혼동 위험 | 누수 없음, 혼동 없음 |
| 가상 스레드 | 비효율적, 위험 | 이상적 |
| 사용 패턴 | |
|
| 중첩 | override 미지원 | 값을 덮어쓸 수 있음 |
6. 중첩 영역(scopes): 값 덮어쓰기
ScopedValue<String> INFO = ScopedValue.newInstance();
ScopedValue.where(INFO, "외부").run(() -> {
System.out.println(INFO.get()); // "외부"
ScopedValue.where(INFO, "내부").run(() -> {
System.out.println(INFO.get()); // "내부"
});
System.out.println(INFO.get()); // "외부"
});
결과:
외부
내부
외부
예를 들어 하나의 작업 내부에서 컨텍스트 값을 잠시 재정의해야 할 때 유용합니다.
Scoped Values: 대표 사용 시나리오
- 사용자 또는 요청 식별자 전달: 로깅이나 권한 확인을 위해.
- 로깅: 컨텍스트를 로그에 자동 삽입.
- 트레이싱: 디버그와 프로파일링을 위해.
- 트랜잭션 파라미터: 예를 들어 격리 수준이나 동작 모드.
- 하나의 작업(또는 그 하위 작업) 범위에서만 보이는 모든 ‘컨텍스트’.
7. 다른 새로운 메커니즘: Structured Concurrency
Structured Concurrency는 서로 관련된 작업(예: 하나의 연산에 속한 하위 프로세스들)을 하나의 단위로 관리하는 접근법입니다. 부모 작업이 끝나거나 실패하면, 모든 자식 작업이 자동으로 취소됩니다. 잊히거나 ‘매달린’ 스레드의 위험을 줄여줍니다.
예시(아주 개략적으로):
try (var scope = StructuredTaskScope.ShutdownOnFailure()) {
Future<String> result1 = scope.fork(() -> fetchData1());
Future<String> result2 = scope.fork(() -> fetchData2());
scope.join(); // 둘 다 끝날 때까지 대기
scope.throwIfFailed(); // 하나라도 실패하면 예외를 던짐
String combined = result1.resultNow() + result2.resultNow();
System.out.println(combined);
}
장점:
- 작업 수명 주기를 더 깔끔하게 관리.
- ‘매달린’ 하위 프로세스 없음.
- 오류 처리가 쉬움.
Structured Concurrency는 아직 preview 단계이지만 활발히 발전 중입니다.
8. 실전 팁과 제약사항
Scoped Values를 언제 사용할까?
- 작업 간 컨텍스트를 전달해야 할 때 항상 — 특히 가상 스레드와 함께 사용할 때.
- 예전에 ThreadLocal을 사용했다면 — ScopedValue로 전환하는 것이 더 나은지 고민해 보세요.
ThreadLocal이 아직 필요한 경우?
- 매우 드물지만, 스레드가 아주 오래 살고 그 생애 전체에 걸쳐 컨텍스트가 ‘상수’여야 하는 경우(예: 레거시 코드와의 작업).
제약사항
- Scoped Values는 scope 생성 후 변경할 수 없습니다 — 읽기 전용입니다.
- Scoped Values는 scope 밖에서 사용할 수 없습니다: 영역 밖에서 값을 얻으려 하면 예외가 발생합니다.
- 큰 객체 저장에는 사용하지 마세요 — 영역은 가볍고 빠르게 유지되어야 합니다.
9. Scoped Values 사용 시 흔한 실수
오류 №1: scope 밖에서 값을 가져오려는 시도. ScopedValue.where(...) 블록 밖에서 USER.get()을 호출하면 NoSuchElementException이 발생합니다. 접근이 반드시 영역 내부에서만 이뤄지는지 확인하세요.
오류 №2: scope 내부에서 값을 변경하려는 시도. Scoped Values는 변경 불가능한 컨테이너입니다. 값을 일시적으로 ‘재정의’해야 한다면 중첩 scope를 만드세요.
오류 №3: ThreadLocal과 ScopedValue를 함께 사용. 특별한 이유가 없다면 이러한 메커니즘을 섞지 마세요 — 컨텍스트가 혼동되고 오류로 이어질 수 있습니다.
오류 №4: run() 블록에 로직을 넣는 것을 잊음. ScopedValue.where(USER, "Alice")만 작성하고 .run(() -> { ... })을 호출하지 않으면, 어떤 scope도 생성되지 않습니다!
오류 №5: 장시간 살아있는 전역 정보를 Scoped Value로 보관하려는 시도. 이런 경우에는 일반 변수나 ThreadLocal(정당한 경우)을 사용하는 것이 더 낫습니다.
GO TO FULL VERSION