이번 글은 시니어 면접에서 정말 자주 나오는 주제이며,
ThreadLocal을 이해했다면 반드시 알아야 하는 내용이다.
많은 사람들이
"ThreadLocal은 remove() 안 하면 메모리 누수가 난다."
라고 외운다.
하지만
왜 누수가 발생하는지, WeakReference를 쓰는데도 왜 누수가 생기는지
를 이해하는 사람은 많지 않다.
이번 글에서는 JVM 메모리 구조와 GC 관점에서 하나씩 설명하겠다.
ThreadLocal Memory Leak
ThreadLocal 객체는 GC되었지만, ThreadLocalMap에 저장된 value가 살아남아 메모리가 해제되지 않는 현상
먼저 ThreadLocal 구조를 다시 보자.
많은 사람들이
ThreadLocal
↓
value
라고 생각한다.
하지만 실제 구조는
Thread
↓
ThreadLocalMap
↓
(ThreadLocal → value)
이다.
그림으로 보면
Thread-1
---------------------------------
ThreadLocalMap
---------------------------------
ThreadLocalA → User
ThreadLocalB → Connection
ThreadLocalC → SqlSession
---------------------------------
Thread가 Map을 가지고 있다.
Thread는 언제 없어질까?
일반 Thread
new Thread()
↓
작업
↓
종료
↓
GC
이므로 문제가 거의 없다.
하지만 웹 서버는 다르다.
Tomcat ThreadPool
Tomcat은
Thread-1
Thread-2
Thread-3
를 미리 만들어 놓고 계속 재사용한다.
Application Start
↓
Thread 생성
↓
사용
↓
사용
↓
사용
↓
Application 종료
즉,
Thread가 거의 평생 살아 있다.
ThreadLocalMap도 같이 살아 있다.
Thread
↓
ThreadLocalMap
↓
value
이므로
Thread가 살아 있는 동안
Map도 계속 살아 있다.
WeakReference를 사용하는 이유
실제 ThreadLocalMap Entry는
단순한 Map이 아니다.
대략 이런 구조이다.
class Entry
extends WeakReference<ThreadLocal<?>> {
Object value;
}
즉,
key
↓
WeakReference(ThreadLocal)
value
↓
Strong Reference(Object)
구조이다.
왜 WeakReference를 사용할까?
예를 들어
public void test() {
ThreadLocal<User> holder =
new ThreadLocal<>();
}
메서드 종료 후
holder
↓
참조 없음
이 된다.
만약 Strong Reference였다면
ThreadLocalMap
↓
ThreadLocal
↓
User
가 계속 살아 있게 된다.
그래서
ThreadLocal 객체는
WeakReference로 관리한다.
그러면 GC가 된다.
메서드 종료
↓
holder 변수 소멸
↓
GC 발생
↓
WeakReference key 제거
ThreadLocalMap은
이렇게 된다.
ThreadLocalMap
------------------------
null → User(Kim)
------------------------
여기서 중요한 점이 있다.
key는 없어졌는데 value는 살아 있다.
key
↓
null
value
↓
User(Kim)
이다.
왜?
value는 Strong Reference이기 때문이다.
이것이 Memory Leak이다.
메모리를 보면
Thread
↓
ThreadLocalMap
↓
Entry
↓
value(User)
Thread가 살아 있는 한
value도 살아 있다.
GC는
Thread
↓
ThreadLocalMap
↓
value
참조가 남아 있으므로
절대로 회수하지 못한다.
그림으로 이해
정상
Thread
↓
ThreadLocalMap
↓
ThreadLocal → User
GC 후
Thread
↓
ThreadLocalMap
↓
null → User
Thread는 살아 있다.
User도 살아 있다.
하지만 접근할 방법은 없다.
이런 객체를
Stale Entry(죽은 Entry)
라고 부른다.
remove()를 하지 않으면
요청 1
Thread-1
↓
ThreadLocal
↓
10MB 객체 저장
요청 종료
remove()
안 함
요청 2
Thread-1 재사용
↓
또 10MB 저장
요청 1000개
ThreadLocalMap
↓
Stale Entry
↓
10MB
10MB
10MB
...
결국
Heap 증가
↓
GC 증가
↓
OutOfMemoryError
까지 갈 수 있다.
그런데 ThreadLocalMap이 정리해주지 않나?
부분적으로는 맞다.
ThreadLocalMap은
set()
get()
remove()
할 때
주변의 Stale Entry를 정리한다.
하지만
문제가 있다.
접근하지 않으면 정리되지 않는다.
예를 들어
ThreadLocalA
↓
GC
↓
null → User
가 생겼다.
그 이후
그 ThreadLocal을
다시 사용하지 않으면
청소(set/get)
안 함
↓
Stale Entry 계속 유지
된다.
그래서
자동 정리만 믿으면 안 된다.
Spring은 어떻게 해결할까?
Spring Security
내부 구조
try {
SecurityContextHolder.setContext(...);
filterChain.doFilter(...);
} finally {
SecurityContextHolder.clearContext();
}
Transaction
try {
begin();
...
} finally {
TransactionSynchronizationManager.clear();
}
RequestContextHolder도
try
↓
set()
↓
Controller
↓
finally
↓
reset()
를 수행한다.
즉,
Spring은 항상
set()
↓
사용
↓
finally
↓
remove()
패턴을 사용한다.
실무에서 가장 위험한 코드
private static final ThreadLocal<User>
HOLDER =
new ThreadLocal<>();
public void login(User user) {
HOLDER.set(user);
}
끝.
remove()가 없다.
ThreadPool에서는
Thread-1
↓
Kim 저장
↓
다음 요청
↓
Lee 저장
↓
Kim 객체 그대로 남음
이 반복된다.
올바른 코드
try {
HOLDER.set(user);
service.execute();
} finally {
HOLDER.remove();
}
이 패턴은
실무에서 거의 공식처럼 사용된다.
실무 사례
로그 MDC
MDC.put()
↓
Controller
↓
Service
↓
Repository
↓
finally
↓
MDC.clear()
Spring Security
SecurityContextHolder
↓
finally
↓
clearContext()
MyBatis
SqlSession
↓
finally
↓
close()
Transaction
Connection
↓
finally
↓
unbindResource()
모두 같은 이유이다.
면접 포인트
ThreadLocal Memory Leak이 발생하는 이유는?
ThreadLocalMap의 key는 WeakReference이지만 value는 Strong Reference이므로, ThreadLocal 객체가 GC되어도 value가 ThreadLocalMap에 남아 GC되지 않을 수 있기 때문이다.
왜 ThreadPool에서 더 위험한가?
ThreadPool의 Thread는 애플리케이션 종료까지 재사용되므로 ThreadLocalMap도 계속 살아 있고, remove()를 하지 않으면 이전 요청의 객체가 계속 유지될 수 있다.
Spring은 어떻게 방지하는가?
모든 ThreadLocal 사용은
try {
set();
} finally {
remove();
}
패턴으로 정리한다.
여기까지 공부했다면
다음글에서는
다음 챕터인 최신 Java(Java 17~21) 로 넘어가기 전에,
이 챕터에서 배운 개념이 어떻게 연결되는지를 살펴본다
"Reflection + Annotation + Proxy + ThreadLocal이 Spring에서 어떻게 하나로 연결되는가"
이 글 하나를 읽으면 지금까지 배운 내용이 각각의 개념이 아니라 Spring이라는 하나의 프레임워크 안에서 유기적으로 동작하는 모습으로 이해되어 훨씬 오래 기억에 남는다.
'language > java' 카테고리의 다른 글
| var 키워드 (0) | 2026.06.17 |
|---|---|
| Reflection + Annotation + Proxy + ThreadLocal (0) | 2026.06.16 |
| ThreadLocal 원리 (0) | 2026.06.16 |
| BigDecimal을 사용하는 이유 (0) | 2026.06.16 |
| StringBuilder vs StringBuffer (0) | 2026.06.16 |
댓글