ThreadLocal은 스레드마다 독립적인 값을 저장하고 꺼낼 수 있게 해주는 자바 기능이다.
즉, 같은 ThreadLocal 변수를 사용하더라도, Thread-1에서 저장한 값과 Thread-2에서 저장한 값은 서로 다르다.
private static final ThreadLocal<String> userName = new ThreadLocal<>();
이 userName 변수는 하나지만, 실제 값은 각 스레드마다 따로 저장된다.
private static String userName;
이 경우 userName 값은 JVM 안에 하나만 있다.
UserContext.userName = "kim"
여러 스레드가 동시에 접근하면 같은 값을 공유한다.
Thread-1 -> userName = "kim"
Thread-2 -> userName = "lee"
Thread-2가 값을 바꾸면 Thread-1도 바뀐 값을 볼 수 있다.
즉, 일반 static 변수는 모든 스레드가 공유하는 하나의 값이다.
public class UserContext {
private String userName;
}
인스턴스 변수는 객체마다 값이 다르다.
UserContext 객체 A -> userName = "kim"
UserContext 객체 B -> userName = "lee"
하지만 Spring Singleton Bean처럼 객체가 하나만 생성되어 여러 스레드가 공유하면, 결국 인스턴스 변수도 여러 스레드가 같이 쓰게 된다.
@Service
public class UserService {
private String userName;
}
Thread-1 ─┐
Thread-2 ─┼──> 같은 UserService 객체의 userName
Thread-3 ─┘
이 경우 동시성 문제가 생길 수 있다.
private static final ThreadLocal<String> userName = new ThreadLocal<>();
이 경우 userName이라는 ThreadLocal 객체는 하나일 수 있다.
하지만 값은 ThreadLocal 객체 안에 저장되는 것이 아니라, 각 Thread 객체 내부의 ThreadLocalMap에 저장된다.
Thread-1
└── ThreadLocalMap
└── key: userName ThreadLocal 객체 -> value: "kim"
Thread-2
└── ThreadLocalMap
└── key: userName ThreadLocal 객체 -> value: "lee"
그래서 같은 userName.get()을 호출해도, 호출한 스레드에 따라 다른 값이 나온다.
처음에는 이렇게 생각하기 쉽다.
UserContext 클래스
└── ThreadLocal userName
└── Map
├── Thread-1 -> "kim"
├── Thread-2 -> "lee"
└── Thread-3 -> "park"
하지만 실제 구조는 이쪽에 가깝다.
UserContext 클래스
└── userName ThreadLocal 객체 1개
Thread-1
└── ThreadLocalMap
└── key: userName -> value: "kim"
Thread-2
└── ThreadLocalMap
└── key: userName -> value: "lee"
Thread-3
└── ThreadLocalMap
└── key: userName -> value: "park"
중요한 포인트는 이것이다.
ThreadLocal자체가 Map을 들고 있는 것이 아니라, 각Thread가 자기만의ThreadLocalMap을 가지고 있다.
ThreadLocal이 스레드를 구분할 수 있는 이유는 내부에서 현재 실행 중인 스레드를 가져오기 때문이다.
핵심 메서드는 이것이다.
Thread.currentThread()
예를 들어 다음 코드가 있다고 하자.
userName.set("kim");
이 코드는 개념적으로 다음과 비슷하게 동작한다.
Thread current = Thread.currentThread();
current.threadLocalMap.put(userName, "kim");
만약 현재 실행 중인 스레드가 Thread-1이면 다음처럼 저장된다.
Thread-1.threadLocalMap[userName] = "kim"
현재 실행 중인 스레드가 Thread-2이면 다음처럼 저장된다.
Thread-2.threadLocalMap[userName] = "lee"
조회도 똑같다.
String name = userName.get();
개념적으로는 다음과 같다.
Thread current = Thread.currentThread();
return current.threadLocalMap.get(userName);
즉, ThreadLocal은 다음 순서로 동작한다.
1. Thread.currentThread()로 현재 실행 중인 스레드를 찾는다.
2. 그 스레드가 가진 ThreadLocalMap을 찾는다.
3. 현재 ThreadLocal 객체를 key로 사용한다.
4. 해당 key에 저장된 value를 꺼낸다.
private static final ThreadLocal<String> userName = new ThreadLocal<>();
여기서 userName은 단순한 문자열 key가 아니다.
userName이라는 ThreadLocal 객체 자체가 key 역할을 한다.
Thread-1.threadLocalMap
└── key: userName ThreadLocal 객체 -> value: "kim"
그래서 같은 스레드 안에서 같은 ThreadLocal에 다시 set()하면 기존 값이 덮어써진다.
userName.set("kim");
userName.set("lee");
System.out.println(userName.get()); // lee
같은 스레드의 같은 key에 값을 다시 넣은 것이기 때문이다.
Thread-1.threadLocalMap[userName] = "kim"
Thread-1.threadLocalMap[userName] = "lee" // 덮어쓰기
private static final ThreadLocal<String> userName = new ThreadLocal<>();
private static final ThreadLocal<String> traceId = new ThreadLocal<>();
그러면 같은 스레드 안에서도 각각 다른 key로 저장된다.
userName.set("kim");
traceId.set("abc-123");
System.out.println(userName.get()); // kim
System.out.println(traceId.get()); // abc-123
구조는 다음과 같다.
Thread-1
└── ThreadLocalMap
├── key: userName -> value: "kim"
└── key: traceId -> value: "abc-123"
결과적으로는 스레드마다 값이 격리되는 것처럼 동작한다.
하지만 스택과는 다르다.
지역변수는 메서드가 끝나면 스택 프레임과 함께 사라진다.
public void process() {
String userName = "kim";
}
process() 종료 -> 지역변수 userName 사라짐
하지만 ThreadLocal 값은 메서드가 끝난다고 자동으로 사라지는 것이 아니다.
public void process() {
userName.set("kim");
}
이 경우 process()가 끝나도 현재 스레드의 ThreadLocalMap에 값이 남아 있을 수 있다.
그래서 ThreadLocal은 다음처럼 정리하는 것이 정확하다.
스택 변수:
- 메서드 호출마다 생성
- 메서드 종료 시 자동 제거
ThreadLocal 값:
- Thread 객체 내부의 ThreadLocalMap에 저장
- remove()하지 않으면 스레드가 살아있는 동안 남아 있을 수 있음
웹 서버는 요청마다 스레드를 새로 만들지 않는다.
대부분 스레드풀을 사용한다.
요청 A -> Thread-1 사용
요청 A 종료
요청 B -> 같은 Thread-1 재사용
만약 요청 A에서 ThreadLocal에 값을 넣고 지우지 않으면 다음 요청에서 문제가 생길 수 있다.
요청 A: Thread-1.threadLocalMap[userName] = "kim"
요청 A 종료
요청 B: 같은 Thread-1 재사용
userName.get() -> 이전 요청의 "kim"이 나올 수 있음
그래서 직접 ThreadLocal을 사용할 때는 보통 finally에서 제거한다.
try {
userName.set("kim");
// 비즈니스 로직 실행
} finally {
userName.remove();
}
remove()를 호출하면 현재 스레드의 ThreadLocalMap에서 해당 key-value가 제거된다.
Thread.currentThread().threadLocalMap.remove(userName)
Spring에서는 ThreadLocal 개념이 여러 곳에서 사용된다.
대표적으로 다음과 같다.
SecurityContextHolder
TransactionSynchronizationManager
RequestContextHolder
로그인 사용자 정보를 현재 요청 스레드에 저장해두고, 서비스나 컨트롤러 여러 계층에서 꺼내 쓸 수 있게 한다.
Thread-1 -> 사용자 A의 인증 정보
Thread-2 -> 사용자 B의 인증 정보
현재 스레드에서 진행 중인 트랜잭션 정보나 DB Connection 정보를 관리하는 데 사용된다.
Thread-1 -> Transaction A
Thread-2 -> Transaction B
즉, 요청마다 다른 스레드가 처리될 때, 각 요청의 컨텍스트를 분리해서 관리하기 좋다.
이름이 비슷하게 느껴질 수 있지만 목적이 다르다.
| 구분 | TLAB | ThreadLocal |
|---|---|---|
| 목적 | 객체 생성 성능 최적화 | 스레드별 값 분리 |
| 누가 사용? | JVM 내부 | 개발자가 직접 사용 가능 |
| 위치 | 힙 안의 스레드별 할당 버퍼 | 각 Thread 내부의 ThreadLocalMap |
| 역할 | 객체를 빠르게 생성할 공간 | 스레드마다 다른 값을 저장 |
| 접근 제한 | 객체 접근을 막지 않음 | 스레드마다 다른 값 반환 |
TLAB는 이런 개념이다.
스레드마다 객체를 빠르게 생성할 수 있도록 힙의 일부를 미리 나눠주는 JVM 내부 최적화
ThreadLocal은 이런 개념이다.
현재 실행 중인 스레드의 ThreadLocalMap에 값을 저장하고 꺼내는 자바 기능
ThreadLocal의 핵심은 다음과 같다.
1. ThreadLocal 객체 자체는 공유될 수 있다.
2. 하지만 ThreadLocal에 저장한 값은 각 Thread 내부의 ThreadLocalMap에 따로 저장된다.
3. ThreadLocal은 내부에서 Thread.currentThread()를 사용해 현재 실행 중인 스레드를 찾는다.
4. 현재 스레드의 ThreadLocalMap에서 this ThreadLocal 객체를 key로 사용한다.
5. 같은 ThreadLocal key라도 스레드가 다르면 저장소가 다르므로 값도 다르다.
6. 같은 스레드 안에서 같은 ThreadLocal key에 다시 set()하면 값은 덮어써진다.
7. ThreadLocal 값은 스택 지역변수처럼 메서드 종료 시 자동 제거되지 않는다.
8. 스레드풀 환경에서는 remove()를 하지 않으면 이전 요청의 값이 남을 수 있다.
ThreadLocal은 공유되는 ThreadLocal 객체를 key로 사용해서, 현재 실행 중인 Thread의 ThreadLocalMap에 값을 저장하고 꺼내는 구조다.
그래서 같은 ThreadLocal 변수를 사용해도 Thread.currentThread()가 다르면 서로 다른 값을 가지게 된다.