article thumbnail image
Published 2025. 4. 27. 23:19
반응형

ThreadLocal이 뭐지?


ThreadLocal은 각 스레드별로 독립된 값을 저장할 수 있게 해주는 특별한 저장소 역할을 하는 객체이다.

fun main() {
    val threadLocal = ThreadLocal<String>()

    val thread1 = Thread {
        threadLocal.set("Thread 1 value")
        println("Thread 1: ${threadLocal.get()}")
    }

    val thread2 = Thread {
        threadLocal.set("Thread 2 value")
        println("Thread 2: ${threadLocal.get()}")
    }

    thread1.start()
    thread2.start()

    thread1.join()
    thread2.join()
}

// 결과
Thread 2: Thread 2 value
Thread 1: Thread 1 value

 

여기서 알아두어야할 점

  • 같은 threadLocal 인스턴스에 쓰지만,
  • 각 스레드는 자기만의 값을 저장하고 읽는다.

 

그러면 비슷하게 전역변수를 썼으면 어떻게 됐을까?

fun main() {
    val map = mutableMapOf<String, String>()

    val thread1 = Thread {
        map["value"] = "Thread 1 value"
        println("Thread 1: ${map["value"]}")
    }

    val thread2 = Thread {
        map["value"] = "Thread 2 value"
        println("Thread 2: ${map["value"]}")
    }

    thread1.start()
    thread2.start()

    thread1.join()
    thread2.join()
}

// 결과
Thread 1: Thread 2 value
Thread 2: Thread 2 value

 

위 결과와 같이 여러 스레드가 일반 map 하나를 공유하게 되면 스레드 간 경합이 생기고, thread-safe 하지 않은 코드가 만들어진다. 예상 가능한 결과를 만들기 위해서는 동기화 기법을 사용해야하는 데, ThreadLocal은 이런 문제를 자연스럽게 피하는 것이다. (락 없이, 성능 저하 없이)

ThreadLocal 내부 메커니즘 살펴보기


threadLocal.set 메서드를 호출하면 다음과 같이 동작한다.

  • 현재 스레드를 가져와서 value를 set.

  • 현재 thread 객체 안에 있는 ThreadLocalMap에 key 값은 threadLocal 인스턴스를, value 는 지정한 값을 저장

→ 즉, ThreadLocal 인스턴스 자체가 키 역할을 하면서, Thread 마다 따로 관리되는 Map 안에 값을 넣어두는 방식이다.

ThreadLocal 메모리 누수 이슈


ThreadLocal은 Thread 객체가 살아있는 동안 ThreadLocalMap에 저장된다.

 

이에 Thread가 죽으면 함께 GC의 대상이 되어 정리되지만, Thread가 계속 살아있다면 (e.g. 서버에서 스레드 풀 방식을 사용하는 경우) ThreadLocalMap도 계속 메모리에 남아있게 된다.

 

따라서 set만 호출하고 remove를 호출하지 않으면 ThreadLocalMap 안에 불필요한 값들이 생길 수 있다. 이 때 Thread가 살아있다면 이 값들은 Thread가 참조하므로 GC의 대상이 되지도 않는다.

 

또한, Thread Pool 환경에서는 다음 작업자가 이전 작업자의 ThreadLocal 데이터를 읽어버릴 위험이 존재한다. 이는 보안적으로도 위험할 수 있다.

 

ThreadLocal을 사용한다면 다쓰고 나서 아래와 같이 remove를 해주는게 좋다.

try {
  threadLocal.set(userContext)
  // 비즈니스 로직 실행
} finally {
  threadLocal.remove()
}

Thread Pool 과 ThreadLocal 문제


스레드 풀 환경에서는 스레드를 계속 재사용한다. 즉, 새로운 요청이 오더라도 기존에 사용했던 스레드를 재활용하는 방식이다.

 

위에서 언급한 것처럼 ThreadLocal을 사용하는 코드에서 다음과 같은 경우 문제가 될 수 있다.

  1. A 요청에서 threadLocal.set(”user A의 정보”)
  2. remove 안함
  3. thread pool에 반납
  4. B 요청에서 thread pool에 반납된 thread 꺼냄
  5. threadLoca.get() → 사용자 A의 정보가 남아있음
  6. B 요청에서 사용자 A의 정보로 어떤 행위를 하게됨

Spring에서는 ThreadLocal을 어떻게 활용하는가


1. 트랜잭션 관리, @Transactional

스프링에서는 트랜잭션을 다음과 같이 관리한다.

  1. @Transactional 어노테이션을 읽는다.
  2. AOP로 메서드를 감싼다.
  3. 메서드 호출 시, 트랜잭션 매니저에게 트랜잭션 시작 요청을 보낸다.

여기서 핵심은 트랜잭션 매니저가 현재 트랜잭션 상태를 ThreadLocal에 저장한다는 점이다.

2. SecurityContext (인증 정보 저장)

인증정보도 스레드마다 관리돼야 한다. 예를 들면 로그인한 사용자가 누구인지, 권한이 무언인지는 HTTP 요청 처리 동안 어디서든 꺼내쓸 수 있어야한다. (컨트롤러에서든, 서비스에서든)

 

다음과 같이 적용될 수 있다.

  1. 사용자가 로그인하면..
  2. SecurityContextHolder가 ThreadLocal에 인증 정보(Authentication)를 저장
  3. 이후 이 스레드 안에서는 어느 레이어에서도 SecurityContextHolder.getContext().getAuthentication() 으로 꺼내서 쓸 수 있음

아래와 같은 코드가 있음

public class SecurityContextHolder {
	private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<>();
}

InheritableThreadLocal 은 뭐지?


InheritableThreadLocal은 부모 스레드의 ThreadLocal 값을 자식 스레드가 상속 받을 수 있도록 지원하는 특별한 ThreadLocal이다.

아래 코드는 Thread의 생성자 중 하나에서 발췌한 코드 일부인데

ThreadLocal.ThreadLocalMap parentMap = parent.inheritableThreadLocals;
if (parentMap != null && parentMap.size() > 0) {
    this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parentMap);
}

코드를 살펴보면 부모의 inheritableThreadLocals에 있는 값을 전부 복사해서 본인의 threadLocal에 담는 것을 알 수 있다.

 

이 때 주의할 점은 Thread가 직접 자식 Thread를 만들 때 ThreadLocal을 상속받는 형태인 것이라 Thread Pool 방식에서는 작동하지 않는다는 것을 알아야 한다는 것이다.

복사했습니다!