ThreadLocal

박태현·2025년 11월 22일

Java

목록 보기
15/17

자바에서 각 스레드는 다른 스레드와 공유되지 않는 독립적인 저장 공간을 갖는데, 이를 ThreadLocal이라고 합니다.

왜 ThreadLocal을 사용할까


ThreadLocal을 사용하는 가장 큰 이유는 각 스레드가 독립적인 상태를 유지할 수 있도록 하기 위함입니다.

멀티 스레드 환경에서는 객체가 여러 스레드에 의해 공유되며, 이로 인해 동시에 접근할 경우 동시성 문제가 발생할 수 있습니다.

이를 해결하기 위해 lock을 사용할 수도 있지만, lock은 성능 저하나 교착 상태와 같은 문제가 발생할 수 있습니다.

ThreadLocal은 이러한 문제를 공유 자체를 제거하는 방식을 사용합니다.

각 스레드마다 독립적인 저장 공간을 제공함으로써, 전역 변수처럼 접근할 수 있으면서도 실제로는 스레드별로 서로 다른 값을 유지하도록 합니다.

하지만, ThreadLocal은 상태를 공유해야 하는 경우에는 적합하지 않으며, 특히 스레드 풀 환경에서는 값을 제거를 하지 않으면 메모리 누수가 발생할 수 있고 이전 요청에서 저장된 잘못된 값을 읽을 수 있기에 주의해야 합니다.

ThreadLocal의 구조


java.lang.ThreadLocal의 ThreadLocal<T> 제네릭 클래스로, 원하는 타입의 데이터를 스레드별로 저장할 수 있습니다.

ThreadLocal 자체가 스레드 내부에 직접 값을 저장하는 것이 아니며, 각 스레드는 내부적으로 ThreadLocalMap이라는 독립적인 저장소를 가지고 있습니다.

이 저장소에서는 ThreadLocal 객체가 key로 사용되고, 해당 스레드에 바인딩된 값이 value로 저장됩니다.

즉, ThreadLocal 객체는 스레드별 데이터를 식별하기 위한 키 역할을 하며, 실제 데이터는 각 스레드 내부에 존재하는 ThreadLocalMap에 저장됩니다

Thread A
   └─ ThreadLocalMap
        └─ { ThreadLocal → 값A }
        └─ { ... }

---

ThreadLocal<Integer> userId = new ThreadLocal<>(); // 힙에 생성됨

// Thread A
userId.set(1);

// Thread B
userId.set(2);

Thread A
 └── ThreadLocalMap
      └── (userId → 1)

Thread B
 └── ThreadLocalMap
      └── (userId → 2)
      
-- 코드

Thread current = Thread.currentThread();     // Thread A
ThreadLocalMap map = current.threadLocals;   // Thread A 전용 저장소
map.put(userId, 1);

ThreadLocal 사용 예제

[ 다양한 함수 ]

get()set() 모두 현재 작업을 진행 중인 스레드를 key 값으로 getMap()을 사용하여 ThreadLocalMap을 불러옵니다.

# get()
Map이 null이 아니라면 ThreadLocalMap의 내부에 있는 Entry를 불러오고 ThreadLocal에 저장한 값을 불러옴

# set()
ThreadLocal 객체를 key로 사용해 ThreadLocalMap에 값을 저장하며, 내부적으로 현재 ThreadLocal이 가진 hash code를 사용합니다.

# getMap()
현재 스레드의 ThreadLocalMap을 반환

ThreadLocalMap getMap(Thread t) {      
  return t.threadLocals;
}

# createMap()
현재 스레드의 ThreadLocalMap을 새로 생성

void createMap(Thread t, T firstValue) {      
  t.threadLocals = new ThreadLocalMap(this, firstValue);
}

[ 예제 코드 ]

@RequiredArgsConstructor
public class CustomThread extends Thread {

    // ThreadLocal의 값을 1로 초기화
    private static final ThreadLocal<Integer> th = ThreadLocal.withInitial(() -> 0);
    private final Integer number;

    @Override
    public void run() {
        // ThreadLocal의 초기값 get()
        int initValue = th.get();
        System.out.println("[" + number + " Thread] - ThreadLocal initValue: " + initValue );

        // ThreadLocal에 값 set()
        th.set(number);

        // ThreadLocal의 셋팅된 값 get()
        int setValue = th.get();
        System.out.println("[" + number + " Thread] - ThreadLocal setValue: " + setValue );
    }
}

@SpringBootApplication
public class ThreadLocalApplication {

	public static void main(String[] args) {
		for (int n = 1; n <= 5; n++) {
			CustomThread thread = new CustomThread(n);

			// 새로운 스레드를 생성하여 run() 메서드를 비동기적으로 실행시키는 메서드
			thread.start();
		}
	}
}
[4 Thread] - ThreadLocal initValue: 0
[1 Thread] - ThreadLocal initValue: 0
[2 Thread] - ThreadLocal initValue: 0
[2 Thread] - ThreadLocal setValue: 2
[4 Thread] - ThreadLocal setValue: 4
[1 Thread] - ThreadLocal setValue: 1
[3 Thread] - ThreadLocal initValue: 0
[3 Thread] - ThreadLocal setValue: 3
[5 Thread] - ThreadLocal initValue: 0
[5 Thread] - ThreadLocal setValue: 5

ThreadLocal의 주의점

스레드 풀 환경에서는 스레드가 재사용되기 때문에, 요청 처리가 끝난 뒤 ThreadLocal에 저장된 값을 반드시 제거해야 합니다.

이를 비우지 않으면 이전 요청의 데이터가 다음 요청으로 전달되는 데이터 오염이 발생할 수 있으며, 스레드가 오래 유지될 경우 메모리 누수로 이어질 위험도 있으므로 주의해야 합니다.

( remove() 함수 사용 )

@SpringBootApplication
public class ThreadLocalApplication {

	// 스레드 풀을 생성
	private static final ExecutorService executorService = Executors.newFixedThreadPool(3);

	public static void main(String[] args) {
		for (int n = 1; n <= 5; n++) {
			CustomThread thread = new CustomThread(n);
			executorService.execute(thread);
		}

		executorService.shutdown();
	}
}
[3 Thread] - ThreadLocal initValue: 0
[2 Thread] - ThreadLocal initValue: 0
[1 Thread] - ThreadLocal initValue: 0
[3 Thread] - ThreadLocal setValue: 3
~~[4 Thread] - ThreadLocal initValue: 3~~
[4 Thread] - ThreadLocal setValue: 4
~~[5 Thread] - ThreadLocal initValue: 4~~
[5 Thread] - ThreadLocal setValue: 5
[2 Thread] - ThreadLocal setValue: 2
[1 Thread] - ThreadLocal setValue: 1

4, 5번 스레드의 ThreadLocal의 초깃값으로 이전에 사용했던 값이 들어가있음을 확인할 수 있습니다.

따라서, 스레드 풀을 사용할 때는 사용한 스레드의 ThreadLocal 값을 초기화 시켜주는 것이 중요합니다.

@Override
    public void run() {
        ...
        threadLocal.remove();
    }

또한, ThreadLocal은 key가 WeakReference로 관리되기 때문에GC가 발생하면 ThreadLocal 객체는 회수되어 key가 사라질 수 있습니다

이 경우 value는 ThreadLocalMap에 그대로 남아 메모리 누수가 발생할 수 있으므로, 개발자가 remove()를 명시적으로 호출하여 정리해야합니다.

ThreadLocal의 활용

각각의 스레드가 독립적으로 데이터를 처리하는 곳에서 활용될 수 있습니다.

ex ) Spring Security의 ContextHolder ( ThreadLocal로 구현되어 있음 )

final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {
    private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<>();
    ...
}

ContextHolder는 인증 정보를 내부에 저장하며, Spring Security는 이를 ThreadLocal에 저장하여 요청을 처리하는 동안 필요할 때마다 재사용합니다.

JPA 트랜잭션에서의 ThreadLocal


JPA 트랜잭션에서는 트랜잭션 범위 내에서 동일한 EntityManager( 영속성 컨텍스트를 관리하는 객체 )를 일관되게 사용하기 위해 ThreadLocal에 EntityManager를 저장하고, 요청 처리 과정에서 필요할 때마다 이를 꺼내 재사용합니다.

트랜잭션 범위 동안 동일한 EntityManager를 유지할 수 있게 되기 때문에 Dirty Checking, 1차 캐시 일관성 같은 기능이 정상적으로 작동할 수 있는 것입니다.

또한, DB Connection도 ThreadLocal에 저장해서 재사용합니다.

비동기 WebFlux나 코루틴 환경에서 JPA가 정상적으로 동작하지 않는 이유


JPA는 트랜잭션과 영속성 컨텍스트가 요청 처리 동안 같은 스레드에서 유지된다는 가정을 바탕으로 동작합니다.

즉, JPA는 ThreadLocal 기반으로 EntityManager와 트랜잭션을 관리하기 때문에, 하나의 요청이 처음부터 끝까지 단일 스레드에서 처리되는 동기 기반 MVC 환경에 적합합니다.

하지만, WebFlux나 코루틴 환경에서는 요청 처리 중 스레드가 계속 바뀌는 비동기/논블로킹 모델을 사용합니다.

이로 인해 트랜잭션, EntityManager, 영속성 컨텍스트가 스레드 간에 유지되지 않아 JPA가 정상적으로 동작하지 않습니다.

profile
꾸준하게

0개의 댓글