ThreadLocal을 정리 안 했다가 스레드 풀에서 메모리가 계속 늘어난 이유
문제 발생
요청마다 사용자 정보를 ThreadLocal에 담아 사용하는 서비스에서, 트래픽이 늘어날수록 힙 메모리 사용량이 꾸준히 증가하다가 결국 OutOfMemoryError가 발생했습니다.
private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();
public void handleRequest(Request req) {
CONTEXT.set(new UserContext(req.getUserId()));
// 처리 로직
// CONTEXT.remove()를 호출하지 않음
}원인 분석
Spring Boot 같은 웹 서버는 요청을 스레드 풀로 처리합니다 — 요청이 끝나도 스레드 자체는 종료되지 않고 풀로 반환되어 다음 요청에 재사용됩니다. ThreadLocal의 값은 그 스레드가 살아있는 한 계속 유지되므로, remove()를 호출하지 않으면 이전 요청에서 설정한 값이 다음 요청까지, 그리고 그 다음 요청까지 계속 스레드에 붙어 있게 됩니다.
각 요청이 새로운 값으로 덮어쓰기만 한다면 이전 값은 GC 대상이 되어 메모리가 무한정 늘어나지는 않지만, 문제는 두 가지입니다 — (1) 큰 객체를 담아두면 다음 요청이 그 값을 덮어쓰기 전까지 불필요하게 메모리를 점유하고, (2) 특정 조건에서만 set()을 호출하는 코드라면, 그 조건이 아닌 다음 요청이 이전 요청의 값을 그대로 이어받는 데이터 누출 버그로 이어질 수 있습니다 — 다른 사용자의 컨텍스트가 새 요청에 그대로 노출되는 심각한 문제입니다.
해결 방안
ThreadLocal을 쓴 자리에서는 반드시finally블록으로remove()를 호출합니다.
public void handleRequest(Request req) {
try {
CONTEXT.set(new UserContext(req.getUserId()));
// 처리 로직
} finally {
CONTEXT.remove();
}
}- 요청 단위로 정리가 보장되어야 하는 경우, Servlet 필터나 Spring interceptor의
afterCompletion처럼 요청 생명주기의 끝에서 확실히remove()가 호출되도록 한 곳에 모아둡니다 — 매 사용처에서 개별적으로 챙기게 하면 하나라도 빠뜨리기 쉽습니다. InheritableThreadLocal을 쓰는 경우, 자식 스레드가 생성될 때 부모의 값을 복사해가므로 스레드 풀과 조합하면 더 예측하기 어려운 누출이 생길 수 있습니다 — 정말 필요한 경우가 아니면 피합니다.- 힙 덤프에서 특정 클래스의 인스턴스 수가 스레드 수보다 훨씬 많이 쌓여 있다면
ThreadLocal누수를 우선 의심합니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.