ThreadLocal이 가상 스레드에서 위험해지는 이유
ThreadLocal 은 원래 "스레드당 하나"를 전제로 설계됐다
ThreadLocal 은 스레드마다 독립된 값을 갖게 해주는 도구입니다. 대표적으로 요청별 사용자 정보를 담는 컨텍스트, DB 커넥션, SimpleDateFormat 같은 스레드-안전하지 않은 객체를 캐싱하는 데 씁니다.
private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();
public void handleRequest(Request req) {
CONTEXT.set(new UserContext(req.getUserId()));
try {
processBusinessLogic();
} finally {
CONTEXT.remove();
}
}이 패턴이 문제없이 동작했던 이유는, 스레드 풀의 스레드 수가 제한적이었기 때문입니다. 스레드 200개짜리 풀이라면 ThreadLocal 값도 최대 200벌만 존재합니다. 스레드가 재사용될 때마다 값을 새로 set() 하고, 끝나면 remove() 하는 식으로 관리하면 메모리 사용량이 예측 가능했습니다.
가상 스레드는 재사용되지 않는다
가상 스레드는 요청 하나에 하나씩, 쓰고 버리는 구조로 설계됐습니다(다음 장에서 이유를 자세히 다룹니다). 이 말은 곧, ThreadLocal 을 쓰는 값도 요청마다 새로 생긴다는 뜻입니다. 스레드 200개가 값을 200벌 유지하던 세계에서, 초당 만 개의 가상 스레드가 생성되는 세계로 옮겨가면 ThreadLocal 값도 초당 만 벌씩 생성되고 버려집니다.
값 자체가 가볍다면 큰 문제가 안 됩니다. 하지만 실무 코드에는 이런 패턴이 자주 있습니다.
// 스레드 풀 시절에는 문제없던 코드
private static final ThreadLocal<ObjectMapper> MAPPER =
ThreadLocal.withInitial(ObjectMapper::new);
private static final ThreadLocal<byte[]> BUFFER =
ThreadLocal.withInitial(() -> new byte[8192]);ObjectMapper 생성 비용이나 8KB 버퍼 자체는 크지 않아 보이지만, 이게 초당 만 번씩 새로 만들어지고 GC 대상이 되면 얘기가 다릅니다. 스레드 풀 환경에서는 "한 번 만들고 재사용"이던 최적화가, 가상 스레드 환경에서는 "매번 새로 만들고 즉시 버림"으로 뒤집힙니다. GC 압박이 늘어나는 건 애플리케이션 코드가 아니라 프레임워크나 서드파티 라이브러리 내부의 ThreadLocal 사용에서 비롯되는 경우도 많아서, 프로파일러로 힙 덤프를 열어보기 전까지는 원인을 짐작하기 어렵습니다.
Scoped Values — 대안으로 나온 것
이 문제를 근본적으로 풀기 위해 JDK 는 ScopedValue(Java 21 에서 프리뷰로 시작, 이후 버전에서 계속 다듬어지는 중인 기능)를 제안했습니다. 요청 범위 안에서만 값이 유효하고, 범위를 벗어나면 자동으로 해제되는 불변 값입니다.
private static final ScopedValue<UserContext> CONTEXT = ScopedValue.newInstance();
public void handleRequest(Request req) {
ScopedValue.where(CONTEXT, new UserContext(req.getUserId()))
.run(() -> processBusinessLogic());
}ThreadLocal 과 다른 점은 값이 불변이고(set() 으로 중간에 바꿀 수 없음), run() 블록을 벗어나는 순간 명시적으로 해제된다는 겁니다. remove() 를 깜빡해서 값이 새는 문제 자체가 구조적으로 발생하지 않습니다. 다만 이 글을 쓰는 시점 기준으로 ScopedValue 는 아직 실무 표준으로 자리 잡았다고 말하기엔 이릅니다. 프레임워크 생태계(로깅 MDC, 트레이싱 컨텍스트 전파 등)가 ThreadLocal 을 전제로 만들어진 곳이 많아서, 전면 교체는 당분간 쉽지 않습니다.
지금 할 수 있는 현실적인 조치
ScopedValue 로 전면 교체를 당장 밀어붙이기보다, 다음 두 가지가 더 실용적입니다.
- 무거운 객체를 담은
ThreadLocal을 찾아 값의 크기를 줄이거나, 애초에 풀링 가능한 형태(예: 정적 유틸리티 메서드)로 바꿉니다. - 가상 스레드 도입 전후로 힙 사용량과 GC 빈도를 비교합니다. 특히 Old Gen 이 아니라 Young Gen GC 빈도가 눈에 띄게 늘었다면
ThreadLocal남용을 의심할 근거가 됩니다.
이 장의 핵심은 하나입니다. 가상 스레드로 전환한다고 기존 ThreadLocal 코드가 당장 깨지는 건 아니지만, "재사용되는 자원"이라는 전제가 사라지는 순간 숨어있던 비용이 표면으로 드러난다는 것. 전환 전에 코드베이스에서 ThreadLocal 사용처를 한 번 훑어보는 작업을 건너뛰지 마세요.