[Java] 스레드 로컬(ThreadLocal)

나른한 개발자·2026년 1월 1일

f-lab

목록 보기
10/44

스레드 로컬(ThreadLocal)

TreadLocal은 각 스레드마다 값을 따로 저장할 수 있는 자바의 클래스 중 하나이다.

스레드 로컬이 필요한 이유

멀티 스레드 환경에서 전역변수, 싱글톤 빈과 같은 공유 변수는 동일 시점에 여러 스레드가 접근하게되면 레이스 컨디션이 발생한다. 이 문제를 해결하기 위해 락(lock)을 사용해 동기화를 하는데, 성능 저하나 데드락과 같은 문제가 생길 수가 있다.

이러한 배경에서 ThreadLocal은 "공유자원"을 없애서 동기화를 하지않고 스레드 단위로 데이터를 안전하게 유지하기 위해 등장했다.

ThreadLocal을 사용하면 전역 변수처럼 보여도 스레드 당 자신만의 값을 가지게되어 lock을 걸고 푸는 과정없이 동시성 문제를 해소할 수 있다.

사용 방법

public class ThreadLocalExample {
    private static ThreadLocal threadLocal = new ThreadLocal<>();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            threadLocal.set("Thread 1's Value");
            System.out.println(threadLocal.get());
        });

        Thread thread2 = new Thread(() -> {
            threadLocal.set("Thread 2's Value");
            System.out.println(threadLocal.get());
        });

        thread1.start();
        thread2.start();
    }
}

위 코드에서 각 스레드는 독립적으로 값을 설정하고 가져온다. 따라서 스레드 간의 데이터 충돌이 발생하지 않는다. 왜냐하면 ThreadLocal은 각 스레드마다 별도의 저장 공간을 제공하기 때문이다.

하지만 ThreadLocal을 사용할 때는 반드시 remove() 메서드를 호출하여 메모리를 해제해야 한다. 그렇지 않으면 메모리 누수가 발생할 수 있다.

ThreadLocal 내부 동작

모든 Thread 객체는 ThreadLocalMap 타입의 threadLocals 멤버를 가지고 있다. ThreadLocalMap은 Thread 객체 내부에 있는 Map으로, 각 ThreadLocal을 key로 하여 스레드별 독립적인 값을 저장하는 자료구조이다.

ThreadLocal.set()

ThreadLocal.set()의 내부 구현은 다음과 같다.

 public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t); //threadLocals 반환
        if (map != null) {
            map.set(this, value);
        } else {
            createMap(t, value);
        }
    }
  1. 현재 스레드 객체를 가져온다.
  2. 현재 스레드의 threadLocals를 가져온다.
  3. 없으면 생성하여 값을 넣고, 있다면 this(ThreadLocal 객체)를 key로, 저장하고자하는 값을 value로 넣는다.

ThreadLocal.get()

ThreadLocal.get()의 내부 구현은 다음과 같다.

    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }
  1. 현재 스레드를 가져온다.
  2. 현재 스레드의 threadLocals를 가져온다.
  3. this 를 key로하여 Entry를 가져온 후 값을 반환한다.

이렇게 각 Thread마다 가진 threadLocals에 저장되기 때문에 스레드들이 각자의 값을 고유하게 저장할 수 있는 것이다.

이때 ThreadLocalMap을 쓰는 이유는 한 스레드가 여러 개의 ThreadLocal을 사용할 수 있기 때문이다. 예를 들어 한 스레드 안에서 userId, userName, requestId 등 여러 정보를 각각 다른 ThreadLocal에 저장할 수 있다.

주의점

  • 메모리 누수: 스레드풀 환경에서 스레드는 재사용되기 때문에 스레드와 함께 ThreadLocalMap이 살아있게 된다. 따라서 remove()를 하지 않으면 이전 요청의 데이터가 남아 메모리 누수가 발생할 수 있어 꼭 데이터를 지워야한다.
  • 데이터 오염: remove()를 하지 않으면 데이터가 남아있어, 이전 사용자 정보를 사용하게 되는 등 로직상 영향을 줄 수 있다.
  • 디버깅의 어려움: 스레드 로컬은 전역 변수처럼 어디서든 접근할 수 있기 때문에, 어디서 이 값이 세팅되었는지 추적하기가 어렵다.

메모리 누수 방지 설계

ThreadLocalMap.Entry는 ThreadLocalMap안에서 key-value 쌍을 저장하는 객체이다. 내부적으로는 WeakReference를 확장하고 있다.

일반 참조(Strong Reference)는 GC 처리가 되지 않고 메모리에 남아있지만 Weak Reference는 다른 곳에서 참조가 없으면 GC처리가 가능하다.

static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        super(k);  // key는 WeakReference로 저장
        value = v;  // value는 그냥 일반 참조
    }
}

내부 구현을 보면 key는 약한 참조로, value는 일반 참조로 되어있는 것을 볼수있다. 즉 key는 외부에서 참조가 없으면 GC처리가 되지만 value는 Entry가 살아있는 한 GC처리가 되지 않고 계속 남아있는다.

public void someMethod() {
    ThreadLocal<String> local = new ThreadLocal<>();
    local.set("데이터");
    
    // 메서드 끝 - local 변수가 사라짐
}

위 예시처럼 메서드가 끝나서 ThreadLocal 객체가 사라지는 경우에 key가 GC처리 되어 메모리 누수를 예방할 수 있다.

stale entry

key가 없어진 entry를 stale entry라고 한다. ThreadLocalMap은 stale entry를 자동으로 정리하려고 한다. 정리가 일어나는 타이밍은 다음과 같다.

  • get()
  • set()
  • remove()
  • 테이블 크기 조정 시(rehash)

하지만 value는 여전히 강한 참조이고 key만 GC되어도 value는 즉시 정리되지 않을 수 있어 메모리 누수 위험이 있다. 따라서 사용 후 반드시 remove()를 호출해야 한다.

활용 사례

웹 요청별 사용자 관리

가장 대표적인 사례로 Spring Security의 SecurityContextHolder를 예시로 들 수 있다.

SecurityContextHolder의 핵심 구조

public class SecurityContextHolder {
    
    // SecurityContext 저장 전략
    private static SecurityContextHolderStrategy strategy;
    
    // 기본 전략: ThreadLocal 사용
    private static final String MODE_THREADLOCAL = "MODE_THREADLOCAL";
    private static final String MODE_INHERITABLETHREADLOCAL = "MODE_INHERITABLETHREADLOCAL";
    private static final String MODE_GLOBAL = "MODE_GLOBAL";
    
    static {
        initialize();
    }
    
    private static void initialize() {
        // 시스템 프로퍼티로 전략 선택 가능
        String strategyName = System.getProperty(SYSTEM_PROPERTY);
        
        if (MODE_THREADLOCAL.equals(strategyName)) {
            strategy = new ThreadLocalSecurityContextHolderStrategy();
        } else if (MODE_INHERITABLETHREADLOCAL.equals(strategyName)) {
            strategy = new InheritableThreadLocalSecurityContextHolderStrategy();
        } else if (MODE_GLOBAL.equals(strategyName)) {
            strategy = new GlobalSecurityContextHolderStrategy();
        } else {
            // 기본값: ThreadLocal
            strategy = new ThreadLocalSecurityContextHolderStrategy();
        }
    }
    
    public static void clearContext() {
        strategy.clearContext();
    }
    
    public static SecurityContext getContext() {
        return strategy.getContext();
    }
    
    public static void setContext(SecurityContext context) {
        strategy.setContext(context);
    }
}

SecurityContextHolder의 구조는 위 코드와 같다.

MODE_THREADLOCAL, MODE_INHERITABLETHREADLOCAL, MODE_GLOBAL 세 가지의 모드를 지원하고 기본 값은 MODE_THREADLOCAL이다.

ThreadLocalSecurityContextHolderStrategy

SecurityContextHolderStrategy를 구현한 ThreadLocalSecurityContextHolderStrategy는 이런 모습이다.

final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {
    
    // 핵심: ThreadLocal 사용
    private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<>();
    
    @Override
    public void clearContext() {
        contextHolder.remove();
    }
    
    @Override
    public SecurityContext getContext() {
        SecurityContext ctx = contextHolder.get();
        
        if (ctx == null) {
            // 없으면 빈 컨텍스트 생성
            ctx = createEmptyContext();
            contextHolder.set(ctx);
        }
        
        return ctx;
    }
    
    @Override
    public void setContext(SecurityContext context) {
        Assert.notNull(context, "Only non-null SecurityContext instances are permitted");
        contextHolder.set(context);
    }
    
    @Override
    public SecurityContext createEmptyContext() {
        return new SecurityContextImpl();
    }
}

ThreadLocal을 사용하여 해당 스레드의 Security Context를 저장한다.

  • getContext(): ThreadLocal에서 현재 스레드의 SecurityContext를 가져온다.
  • clearContext(): ThreadLocal에서 remove()하여 데이터를 지운다.

서블릿 환경에서는 기본적으로 1요청=1스레드가 배정된다. 따라서 동일한 요청 내에서 코드가 어느 계층으로 가든 SecurityContextHolder.getContext()를 부르면 동일한 인증 정보를 받을 수 있는 것이다.

이로 인해 아래와 같은 이점을 누릴 수 있다.

  • 파라미터 전달: 인증정보를 매번 인자로 전달해주지 않아도 된다.
  • 동시성 문제 해소: 여러 요청이 들어오더라도 각 스레드만의 SecurityContext를 사용하게 된다.
  • 편의성: 스프링 시큐리티가 ThreadLocal을 관리(초기화/제거) 해주므로 개발자는 편리하게 인증 정보를 쓸 수 있다.

filter chain에서의 동작

1. 요청 진입 - Filter에서 설정

  • SecurityContextHolderFilter (또는 과거 SecurityContextPersistenceFilter)가 요청 시작 시 SecurityContextRepository.loadContext()를 통해 기존 세션 등에 저장된 인증 정보를 불러오거나 없으면 빈(SecurityContext)를 생성한다.
  • SecurityContextHolder.setContext(contextBeforeChainExecution)로 ThreadLocal을 초기화한다.

2. 인증 처리

  • 인증 로직(form로그인, JWT 등)이 성공하면 SecurityContextHolder.getContext().setAuthentication(auth) 식으로 스레드 전용 인증 정보를 셋팅한다.

3. 비즈니스 로직에서 사용

  • SecurityContextHolder.getContext().getAuthentication() 으로 Controller, Service 등 다양한 계층에서 어디서든 사용할 수 있다.

4. 요청 종료

  • 필터가 SecurityContextHolder.getContext() 로 최종 컨텍스트를 열어 세션에 저장할지 결정한다.
  • SecurityContextHolder.clearContext()를 호출해 ThreadLocal.remove() 하여 스레드 풀 재사용시 오염을 방지한다.

@Transactional

@Transactional은 TransactionSynchronizationManager라는 클래스의 ThreadLocal을 사용해서 트랜잭션을 관리한다.간단히 말하자면, 트랜잭션이 시작될 때 DB Connection을 ThreadLocal에 저장하고, 같은 트랜잭션 내의 모든 Repository나 JdbcTemplate이 ThreadLocal에서 같은 Connection을 꺼내 쓰는 방식이다.

동작

1. 트랜젝션 시작

  • @Transactional 메서드에 진입하면 AOP가 동작해서 TransactionManager가 Connection을 생성하고, setAutoCommit(false)를 호출한다.
  • 이 Connection을 TransactionSynchronizationManager의 ThreadLocal에 DataSource를 key로 저장한다.

2. 비즈니스 로직에서 사용

  • Service 메서드 안에서 여러 Repository를 호출하면, JdbcTemplate이나 Hibernate는 DB 작업 시 DataSourceUtils를 통해 ThreadLocal에서 Connection을 조회한다. 그래서 같은 스레드에서는 계속 같은 Connection을 재사용하게 된다.

3. 트랜젝션 종료

  • 메서드가 정상 종료되면 commit(), 예외가 발생하면 rollback()을 호출하고, finally 블록에서 ThreadLocal을 정리한다.
  • Connection도 풀에 반환한다.

중첩 트랜젝션

중첩 트랜젝션은 Propagation 전략에 따라 다르게 처리된다.

  • REQUIRED: 트랜젝션이 있으면 재사용한다.
  • REQUIRES_NEW: 새 트랜잭션을 시작한다. 먼저 기존 Connection을 ThreadLocal에서 일시 제거(suspend)하고, 새 Connection을 생성해서 ThreadLocal에 바인딩한다. 내부 트랜잭션이 끝나면 다시 기존 Connection을 복원(resume)한다.

@Transactional에서 ThreadLocal 사용 이유

  1. Connection 공유: 같은 트랜잭션 내 모든 DB 작업이 같은 Connection 사용
  2. 파라미터 전달 불필요: Connection을 메서드마다 전달할 필요 없음
  3. 중첩 트랜잭션 관리: 여러 레벨의 트랜잭션을 ThreadLocal로 관리
  4. 스레드 격리: 각 HTTP 요청(스레드)마다 독립적인 트랜잭션

참고링크

스레드 로컬은 각 스레드마다 값을 따로 저장할 수 있도록 하는 자바 클래스이다. 멀티 스레드 환경에서는 공유 자원에 대한 race condition이 발생할 수 있는데, 스레드 로컬은 공유자원을 없애 동기화를 하지 않고도 안전하게 유지할 수 있도록 한다. 각 스레드마다 데이터를 저장할 수 있는 이유는 데이터가 ThreadLocal객체가 아닌 Thread 객체 내에 저장되기 때문이다. Thread에는 threadlocals라는 멤버를 가지고 있다. threadlocals는 ThreadLocalMap 타입으로 되어있으며, key에는 ThreadLocal 객체, value에는 값이 저장된다. 그래서 ThreadLocal에서 값을 가지고 올 때는 현재 스레드 인스턴스를 가지고와서 그 스레드의 threadlocals를 조회하기 때문에 스레드마다 각자의 값을 저장하고 읽을 수 있는 것이다. 다만 주의할 점은 스레드 풀 환경에서는 스레드를 재사용하기 때문에 ThreadLocal에 저장된 값을 remove()하지 않으면 데이터가 남아있어서 메모리 누수나, 이전 데이터를 보게 되는 데이터 오염이 발생할 수 있어 주의해야한다.

profile
Start fast to fail fast

0개의 댓글