본문 바로가기
language/java

ThreadLocal 원리

by 죄니안죄니 2026. 6. 16.
반응형

여기부터는 Spring을 이해하는 마지막 핵심 퍼즐이라고 생각하면 된다.

많은 사람들이 ThreadLocal을

"스레드마다 변수를 하나씩 가지는 것"

정도로 외운다.

하지만 이렇게 외우면 Spring Security, @Transactional, MyBatis, 로그 MDC가 왜 동작하는지 절대 이해할 수 없다.

이번 글에서는 JVM 메모리 구조부터 실제 Spring 내부 동작까지 연결해서 설명하겠다.


ThreadLocal 원리

ThreadLocal은 "변수를 저장하는 객체"가 아니라 "현재 Thread를 기준으로 값을 저장하고 조회하는 Map"이다.


먼저 왜 필요한가?

웹 서버에는 동시에 수많은 요청이 들어온다.

예를 들어

사용자 A

↓

로그인

↓

주문 조회
 

동시에

사용자 B

↓

로그인

↓

결제
 

도 수행된다.


Spring Boot(Tomcat)는 보통

Thread-1

↓

사용자 A 처리
 
Thread-2

↓

사용자 B 처리
 

처럼 각각 다른 Thread가 처리한다.


사용자 정보를 어디에 저장할까?

예를 들어 로그인하면

 
User user = login(...);
 

를 얻었다.


Service에서도

 
OrderService

PaymentService

ProductService
 

모두

현재 로그인한 사용자를 알아야 한다.


매번

 
service.save(user);
 

처럼 전달해야 할까?

Controller

↓

Service

↓

Repository

↓

DAO

↓

Util

↓

Logger
 

모든 메서드에 user를 전달해야 한다.

엄청 불편하다.


static 변수에 저장하면?

 
public class UserContext {

    static User currentUser;

}
 

사용자 A

currentUser = Kim
 

동시에 사용자 B

currentUser = Lee
 

그러면

Kim

↓

Lee로 덮어쓰기
 

가 발생한다.


즉,

모든 Thread가 같은 변수 사용

↓

동시성 문제
 

가 생긴다.


ThreadLocal 등장

ThreadLocal은

Thread-1

↓

Kim
 
Thread-2

↓

Lee
 

를 각각 따로 저장한다.


그림

                ThreadLocal

                      │

      ┌───────────────┼───────────────┐

      │                               │

Thread-1                        Thread-2

      │                               │

 currentUser=Kim             currentUser=Lee
 

많이 하는 오해

많은 사람들이

ThreadLocal

↓

value 저장
 

이라고 생각한다.


실제로는

Thread

↓

ThreadLocalMap

↓

(ThreadLocal → value)
 

이다.


내부 구조

Thread 안에는

실제로 이런 필드가 있다.

 
class Thread {

    ThreadLocalMap threadLocals;

}
 

즉,

Thread

------------------------

ThreadLocalMap

------------------------
 

을 가지고 있다.


ThreadLocalMap

Map 형태이다.

Thread-1

↓

ThreadLocalMap

----------------------------

ThreadLocalA → User(Kim)

ThreadLocalB → Connection

ThreadLocalC → Locale(KR)

----------------------------
 

Thread-2

ThreadLocalMap

----------------------------

ThreadLocalA → User(Lee)

ThreadLocalB → Connection

ThreadLocalC → Locale(US)

----------------------------
 

Thread마다 Map이 따로 존재한다.


set()

 
ThreadLocal<User> holder =
        new ThreadLocal<>();

holder.set(user);
 

실제로는

현재 Thread 찾기

↓

Thread.threadLocals

↓

Map.put(holder, user)
 

를 수행한다.


get()

 
User user = holder.get();
 

실제로는

현재 Thread

↓

Thread.threadLocals

↓

Map.get(holder)

↓

user 반환
 

이다.


왜 Thread마다 다른 값이 나올까?

코드는 동일하다.

 
holder.get();
 

하지만

Thread-1

Thread.currentThread()

↓

ThreadLocalMap

↓

Kim
 

Thread-2

Thread.currentThread()

↓

ThreadLocalMap

↓

Lee
 

항상

현재 실행 중인 Thread 기준으로 조회하기 때문이다.


Spring Security도 이것을 사용한다.

로그인

Authentication

↓

SecurityContext

↓

ThreadLocal 저장
 

Controller

 
SecurityContextHolder

.getContext()

.getAuthentication()
 

어떻게 user를 전달하지 않았는데도

현재 로그인 사용자를 알 수 있을까?


현재 Thread

↓

ThreadLocal

↓

Authentication 반환
 

하기 때문이다.


@Transactional도 동일하다.

Controller

Service

 
@Transactional
public void save() {

}
 

Spring 내부

Transaction 시작

↓

Connection 생성

↓

ThreadLocal 저장
 

Repository

DataSourceUtils.getConnection()

↓

ThreadLocal 조회

↓

같은 Connection 반환
 

그래서

Service

↓

Repository1

↓

Repository2

↓

Repository3
 

모두 같은 Connection을 사용한다.


MyBatis도 동일하다.

SqlSession

↓

ThreadLocal 저장
 

DAO

Mapper

Executor

모두

ThreadLocal

↓

SqlSession 조회
 

를 수행한다.


MDC(Logback)도 동일하다.

로그

requestId

↓

ThreadLocal 저장
 

로그 출력

현재 Thread

↓

ThreadLocal

↓

requestId 조회

↓

로그 출력
 

그래서

[requestId=123]

로그인

주문

결제
 

가 자동으로 출력된다.


ThreadLocal은 static과 같이 사용된다.

많이 보는 코드

 
private static final ThreadLocal<User>
        HOLDER =
            new ThreadLocal<>();
 

여기서

static

↓

객체 하나
 

이다.


하지만

값은

Thread

↓

ThreadLocalMap

↓

저장
 

이므로

Thread마다 완전히 별도
 

이다.


그래서

static인데

사용자마다 값이 다름
 

이 가능한 것이다.


그림으로 전체 흐름

static ThreadLocal

           │

           ▼

Thread-1 ------------ ThreadLocalMap

                         │

                         ▼

                      User(Kim)



Thread-2 ------------ ThreadLocalMap

                         │

                         ▼

                      User(Lee)
 

실무 예제

현재 로그인 사용자

 
public class LoginUserHolder {

    private static final ThreadLocal<User>
            HOLDER =
                new ThreadLocal<>();

    public static void set(User user) {

        HOLDER.set(user);

    }

    public static User get() {

        return HOLDER.get();

    }

}
 

Controller

 
LoginUserHolder.set(loginUser);
 

Service

 
User user =
        LoginUserHolder.get();
 

Repository

 
User user =
        LoginUserHolder.get();
 

인자를 전달하지 않아도

같은 Thread에서는 동일한 사용자를 얻는다.


하지만 치명적인 문제가 있다.

Tomcat은

Thread를 매 요청마다 새로 만들지 않는다.

ThreadPool

Thread-1

Thread-2

Thread-3

재사용
 

한다.


사용자 A

Thread-1

↓

ThreadLocal

↓

Kim 저장
 

요청 종료

하지만

remove()

안 함
 

다음 요청

사용자 B

Thread-1 재사용

↓

ThreadLocal 조회

↓

Kim 발견
 

사용자 정보가 섞이는 심각한 버그가 발생한다.


그래서 반드시 remove()

 
try {

    holder.set(user);

    ...

} finally {

    holder.remove();

}
 

를 수행해야 한다.


면접 포인트

ThreadLocal이란?

현재 Thread를 기준으로 독립적인 값을 저장하고 조회할 수 있도록 지원하는 클래스이다.


실제 값은 어디에 저장되는가?

많은 사람들이

ThreadLocal

↓

value
 

라고 생각하지만,

실제로는

Thread

↓

ThreadLocalMap

↓

(ThreadLocal → value)
 

형태로 저장된다.


Spring에서 어디에 사용되는가?

  • Spring Security (SecurityContextHolder)
  • @Transactional
  • MyBatis SqlSession
  • TransactionSynchronizationManager
  • Log MDC

가장 중요한 실무 주의사항은?

ThreadPool 환경에서는 Thread가 재사용되므로 ThreadLocal.remove()를 호출하지 않으면 이전 요청의 데이터가 다음 요청에 남아 심각한 메모리 누수와 데이터 오염이 발생할 수 있다.


다음 글 추천

다음은 ThreadLocal Memory Leak이다.

이 글에서는

  • 왜 WeakReference를 사용하는지
  • remove()를 안 하면 정확히 어떤 메모리 누수가 발생하는지
  • Tomcat ThreadPool에서 실제로 어떻게 장애가 나는지
  • Spring이 왜 finally에서 ThreadLocal을 정리하는지

까지 JVM 내부 구조와 함께 깊이 있게 이어서 설명할 수 있다.

반응형

'language > java' 카테고리의 다른 글

Reflection + Annotation + Proxy + ThreadLocal  (0) 2026.06.16
ThreadLocal Memory Leak  (0) 2026.06.16
BigDecimal을 사용하는 이유  (0) 2026.06.16
StringBuilder vs StringBuffer  (0) 2026.06.16
Integer Cache  (0) 2026.06.16

댓글