Java프로그래밍시니어 자바 개발자

ThreadLocalMap의 엔트리 저장소의 어떤 특정 특성이 관련된 ThreadLocal 키가 null로 설정된 후에도 가비지 수집기가 값 객체를 회수하지 못하게 막는가?

Hintsage AI 어시스턴트로 면접 통과

질문에 대한 답변.

ThreadLocal은 메소드 매개변수 전달 없이 스레드-로컬 변수를 제공하기 위해 Java 1.2에서 도입되었습니다. 구현은 각 Thread 객체에 저장된 ThreadLocalMap을 사용하며, 이 맵의 키는 ThreadLocal 인스턴스 주위에 WeakReference 래퍼로 되어 있습니다. 중요한 설계 결함은 맵의 Entry 클래스가 강한 참조 필드를 통해 값을 보유하고 있다는 점에서 발생하며, 이는 WeakReference 키가 가비지 수집에 의해 지워져도 값 객체가 살아 있는 Thread에 의해 강하게 참조된 상태로 남아 있음을 의미합니다. 이로 인해 스레드 풀에서 스레드가 무한히 생존하면서 고아 값이 누적되는 메모리 누수가 발생합니다. remove()를 명시적으로 호출하지 않으면, 오래된 엔트리가 스레드의 수명 동안 지속될 수 있으며, 이는 값 객체를 메모리에 고정시키는 효과가 있습니다.

실제 상황

한 금융 거래 플랫폼은 깊게 중첩된 서비스 호출을 통해 요청별 시장 데이터 스냅샷을 저장하기 위해 ThreadLocal을 사용했습니다. 고정 ThreadPoolExecutor를 사용하던 중, 어플리케이션은 프로덕션 부하 아래 매 12시간마다 신비롭게 힙 공간이 소진되었습니다. 힙 덤프는 Thread 객체가 null 키를 가진 ThreadLocalMap 엔트리를 통해 큰 byte[] 배열을 유지하고 있음을 드러내어 서비스 성능 저하를 초래했습니다.

해결책 1: 수동 try-finally 위생

개발자들은 모든 엔트리 포인트를 remove()를 호출하는 try-finally 블록으로 감싸려 했습니다.

  • 장점: 제로 의존성으로 결정론적 정리.
  • 단점: 200개 이상의 엔드포인트에서 강제로 시행하기 비현실적; 주니어 개발자들이 기능 개발 중 이 패턴을 자주 생략하여 간헐적인 누수로 이어졌습니다.

해결책 2: 자동 정리를 위한 스레드 풀 래퍼

엔지니어들은 Runnable 작업을 감싸 스레드 실행 후 모든 ThreadLocal을 캡처하고 정리하는 방안을 고려했습니다.

  • 장점: 제출 지점에서 중앙 집중적인 제어.
  • 단점: ThreadLocalMap은 공개적으로 접근할 수 없기 때문에 Java 모듈 시스템 제한으로 인해 깨진 반사 해킹이 필요했습니다.

해결책 3: 요청 범위 의존성 주입

SpringRequestScope 빈으로 컨텍스트 저장을 마이그레이션하여 자동 프록시 정리 기능을 구현했습니다.

  • 장점: 프레임워크 관리 생명 주기 덕분에 수동 정리 코드가 사라졌습니다.
  • 단점: 정적 유틸리티 메소드의 상당한 리팩터링; 프록시 생성 및 빈 조회로 인한 15% 성능 오버헤드.

선택한 해결책 및 결과

팀은 중앙 집중적인 시행을 보장할 수 있는 Servlet Filter와 try-finally를 사용하여 모든 요청 범위의 ThreadLocal에 대해 remove()가 호출되도록 하는 하이브리드 접근 방식을 선택했습니다. 이는 아키텍처 리팩터링 없이 누적을 방지하였고, 예외 발생 중에도 유지되었습니다. 힙 보유량은 90% 감소하여 강제 재시작 주기를 없애고 99.99% 가동 시간 SLA를 만족했습니다. 지속적인 모니터링을 통해 몇 주간의 운영 동안 안정적인 힙 사용이 확인되었습니다.

후보자들이 자주 놓치는 점

왜 ThreadLocalMap은 키에 대해 WeakReference를 사용하고 값에 대해 강한 참조를 사용하는가, 둘 다 약하게 만들지 않는가?

값이 WeakReference를 통해 보유되었다면, 가비지 수집기가 ThreadLocal 키가 여전히 접근 가능한 동안 값 객체를 회수할 수 있습니다. 이는 이후의 get() 호출이 예상치 못한 null을 반환하게 되어, 스레드가 설정한 값이 해당 스레드의 실행 동안 안정적으로 유지된다는 기대를 위배합니다. 강한 참조는 값의 안정성을 보장하며, 약한 키는 더 이상 애플리케이션 로직에 의해 참조되지 않는 ThreadLocal 인스턴스가 stale로 표시될 수 있도록 합니다.

InheritableThreadLocal이 어떻게 자식 스레드에 값을 전파하며, 스레드 풀 환경에서 이것이 도입하는 고유한 메모리 누수 위험은 무엇인가?

InheritableThreadLocalThread 초기화 중에 부모 스레드의 엔트리를 자식 스레드의 inheritableThreadLocals 맵에 복사합니다. 이 얕은 복사는 스레드 생성 시 발생하므로 스레드 풀에서는 스레드가 한 번 생성되고 재사용되기 때문에 임의의 부모 스레드로부터 값을 상속받습니다. 만약 그 부모가 큰 컨텍스트를 보유하고 있다면, 스레드 풀의 모든 스레드는 해당 참조를 영구히 유지하여, 서로 다른 사용자를 위해 작업을 처리할 때 서로 다른 요청 간에 민감한 데이터가 유출될 수 있습니다.

정리 중 expungeStaleEntry 메서드의 재해싱 동작의 목적은 무엇이며, 단순히 stale 슬롯을 null로 설정하는 것이 맵의 불변성을 깨는 이유는 무엇인가?

ThreadLocalMap은 선형 탐사를 사용하는 개방 주소 방식으로 충돌을 해결합니다. stale 엔트리가 제거될 때 단순히 슬롯을 null로 설정하면 충돌로 인해 그 뒤에 저장된 엔트리에 대한 탐색 체인이 깨집니다. expungeStaleEntry 메서드는 null 슬롯을 찾을 때까지 프로브 시퀀스에서 모든 후속 엔트리를 재해싱하여 올바른 위치로 재배치합니다. 이 재해싱이 없으면, 그 이동된 엔트리에 대한 조회 작업이 null 슬롯에서 조기에 종료되어, 테이블에 나중에 엔트리가 있음에도 불구하고 잘못된 null을 반환할 수 있습니다.