본문으로 건너뛰기
김신건의 로그

[Java] ThreadLocal

· 수정 · 📖 약 2분 · 921자/단어 #java #concurrency #threadlocal #memory
ThreadLocal, InheritableThreadLocal, ScopedValue, 스레드 로컬, java threadlocal

정의

ThreadLocal<T>각 스레드마다 독립된 변수 사본을 가지게 해 주는 컨테이너. set/get/remove 로 접근하며, 한 스레드의 값은 다른 스레드에서 볼 수 없다.

쓰임은 두 가지: (1) 같은 인스턴스를 여러 스레드가 공유하지만 상태는 격리해야 할 때 (e.g., SimpleDateFormat 캐싱), (2) 메서드 인자로 전달하기 번거로운 “요청 컨텍스트” 를 호출 스택 전체에 보이게 하고 싶을 때 (e.g., 트랜잭션, 인증 정보).

시각화

각 스레드가 자신의 ThreadLocalMap 에만 값을 저장. 다른 스레드는 접근 자체 불가.

기본 사용

public class RequestId {
    private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();

    public static void set(String id) { CURRENT.set(id); }
    public static String get() { return CURRENT.get(); }
    public static void clear() { CURRENT.remove(); }
}

// 진입점에서 설정
RequestId.set("req-123");
try {
    service.doWork();   // 깊은 호출 스택 어디서든 RequestId.get() 으로 접근
} finally {
    RequestId.clear();  // 반드시 정리
}

실행해 보면 각 스레드가 독립 값을 보유한다:

java
public class ThreadLocalDemo {
  static final ThreadLocal<Integer> COUNT = ThreadLocal.withInitial(() -> 0);

  public static void main(String[] args) throws InterruptedException {
      Runnable task = () -> {
          for (int i = 0; i < 3; i++) {
              COUNT.set(COUNT.get() + 1);
          }
          System.out.println(Thread.currentThread().getName() + " count=" + COUNT.get());
      };
      Thread t1 = new Thread(task, "A");
      Thread t2 = new Thread(task, "B");
      t1.start(); t2.start();
      t1.join(); t2.join();
  }
}
결과
A count=3
B count=3

각 스레드는 자신의 카운터만 증가. 스레드 간 누수 없음.

withInitial 로 lazy 초기값 지정 가능:

private static final ThreadLocal<SimpleDateFormat> FORMAT =
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

String today = FORMAT.get().format(new Date());

SimpleDateFormat 은 thread-safe 하지 않으므로 흔한 패턴.

내부 구조

ThreadLocal 값은 ThreadLocal 인스턴스 자체에 저장되지 않는다. 각 Thread 객체 안의 ThreadLocalMap 에 저장된다.

Thread A.threadLocals = { ThreadLocal_X: "A 값", ThreadLocal_Y: 1 }
Thread B.threadLocals = { ThreadLocal_X: "B 값" }

ThreadLocal 인스턴스는 map 의 key 역할. 그래서 같은 ThreadLocal 변수에 대해 스레드별로 다른 값이 보이는 것.

map 의 키는 WeakReference<ThreadLocal>. ThreadLocal 변수 자체가 GC 되면 entry 의 key 가 null 이 되지만, value 는 strong reference 라 GC 안 됨. 이게 누수의 원인.

메모리 누수 함정

스레드 풀 환경에서 흔히 발생:

// 잘못된 패턴
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(() -> {
    BIG_OBJECT.set(new byte[100_000_000]);  // 100 MB 할당
    doWork();
    // remove() 안 함
});

스레드가 풀에 반환돼도 ThreadLocalMap 은 그대로. 다음 작업이 와도 그 100 MB 가 계속 살아있다. 10 개 스레드 풀이면 최악의 경우 1 GB 가 유지됨.

규칙: 진입점에서 try { set(...); ... } finally { remove(); } 패턴 강제. Web 프레임워크들이 servlet filter / interceptor 로 처리해 주는 이유.

InheritableThreadLocal

자식 스레드가 부모의 값을 상속:

InheritableThreadLocal<String> trace = new InheritableThreadLocal<>();
trace.set("trace-1");

new Thread(() -> {
    System.out.println(trace.get());  // "trace-1"
}).start();

중요한 한계: Thread 생성 시점에 값이 복사된다. 스레드 풀처럼 스레드를 재사용하면 풀에 들어갈 때 한 번만 복사되고, 이후 작업에서는 stale 한 부모 값이 그대로. 풀 환경에선 거의 무용지물.

Spring 의 TaskDecorator 로 매 작업마다 복사하는 패턴이 일반적.

ScopedValue (JDK 21+)

ThreadLocal 의 함정을 해결한 불변, 스코프 한정 대안:

private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

ScopedValue.where(CURRENT_USER, alice)
    .run(() -> {
        // 이 블록 안에서만 CURRENT_USER.get() == alice
        service.process();
    });
// 블록 종료 = 자동 해제, 누수 불가능

특징:

  • 불변: set 으로 덮어쓸 수 없음 (rebinding 만 가능, 새 스코프 안에서)
  • 자동 정리: try/finally 불필요
  • virtual thread 친화적: 가벼움 (ThreadLocal 은 virtual thread 마다 map 생성 비용)

JDK 25 에서도 preview 단계 (2026 기준). 정식 stable 화 진행 중.

Spring 활용

Spring 은 ThreadLocal 을 광범위하게 사용. 주요 위치:

컴포넌트저장 대상설정/해제 시점
RequestContextHolderHTTP 요청/응답 attributesRequestContextFilter, DispatcherServlet
LocaleContextHolder현재 요청의 localeLocaleResolver 가 갱신
TransactionSynchronizationManager현재 트랜잭션 자원 (Connection 등)@Transactional 진입/종료
SecurityContextHolder (Spring Security)인증된 Authentication 객체SecurityContextPersistenceFilter

@Async 메서드는 별도 스레드에서 실행되므로 위 ThreadLocal 이 자동 전파되지 않음. 해결책은 TaskDecorator 로 컨텍스트를 복사하거나 setRequestAttributes(attrs, inheritable=true) 옵션 사용. spring-request-context 참고.

베스트 프랙티스

  • private static final 로 선언 (인스턴스마다 만들면 누수 가속)
  • 반드시 try { set; ... } finally { remove; } 패턴
  • 프레임워크가 관리해 주는 ThreadLocal 은 직접 건드리지 말 것
  • 스레드 풀에서 쓸 때 더 조심
  • JDK 21+ 신규 코드에선 ScopedValue 우선 검토
  • virtual thread 사용 시 ThreadLocal 남용 피하기 (수백만 개 가능 = 누수 폭발)

관련 위키

이 글의 용어 (7개)
[Java] AtomicIntegerjava
정의 는 lock-free 로 원자성을 보장 하는 래퍼. 내부적으로 CAS (Compare-And-Swap) 를 사용해 락 없이 atomic 한 read-modify-write …
[Java] ReentrantLockjava
정의 는 키워드와 같은 상호 배제 (mutual exclusion) 를 제공하는 클래스 기반 락. JSR-166 (Java 5) 에서 추가됐다. "재진입 (reentrant)" …
[Java] volatilejava
정의 은 Java 의 키워드. 필드에 붙이면 두 가지를 보장한다. 1. 가시성 (visibility): 한 스레드의 쓰기가 다른 모든 스레드에 즉시 보인다. CPU 캐시에 머무르…
[Spring] @Scheduled와 @Asyncspring
정의 Spring은 @Scheduled (정기 작업)와 @Async (비동기 메서드)를 어노테이션으로 제공. ThreadPool, TaskScheduler 설정 가능. Quart…
[Spring] RequestContextHolder, LocaleContextHolder: ThreadLocal 기반 요청 컨텍스트spring
정의 Spring 은 HTTP 요청 / 응답 / locale / 트랜잭션 / 인증 같은 요청 범위 데이터를 ThreadLocal 에 저장 해 전역 노출한다. 메서드 인자로 일일이…
[Spring] Security: Filter Chain, JWT, OAuth2spring
정의 Spring Security는 인증·인가의 사실상 표준. 서블릿 필터 체인 위에 다양한 인증/권한 처리를 모듈식으로. 6.0+ 부터 Lambda DSL 표준, Bean 방식…
[Spring] Transaction: @Transactional, propagation, isolationspring
정의 Spring 트랜잭션은 AOP 기반 선언형 트랜잭션 관리. 한 줄로 메서드 전체를 트랜잭션 범위로. JDBC, JPA, MongoDB 등 backend에 동일 API. 기본…

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기