본문 바로가기
language/java

ThreadLocal Memory Leak

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

이번 글은 시니어 면접에서 정말 자주 나오는 주제이며,
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

댓글