Java 메모리 누수: 흔한 원인과 탐지 방법
Java 애플리케이션이 처음에는 빠르게 시작하지만 몇 시간 또는 며칠이 지나면 거의 멈출 정도로 느려지고, OutOfMemoryError를 발생시키거나 컨테이너에 의해 종료됩니다. 재시작하면 같은 주기가 반복됩니다. 이것이 바로 Java 메모리 누수의 전형적인 특징입니다. 더 이상 필요하지 않지만 여전히 참조되어 가비지 컬렉터가 회수할 수 없는 객체가 쌓이는 것입니다. C/C++와 달리 Java의 GC는 대부분의 메모리를 자동으로 관리하지만, 의도치 않게 참조가 유지되면 누수가 발생합니다. 이 글에서는 가장 흔한 원인을 설명하고, 누수를 탐지하고 수정하는 실용적인 워크플로를 제공합니다.
Java 메모리 누수란 무엇인가?
메모리 누수는 객체가 더 이상 애플리케이션에서 사용되지 않지만 여전히 참조되어 가비지 컬렉션이 되지 않을 때 발생합니다. 시간이 지나면 힙 사용량이 증가하고, GC가 더 자주 실행되며, 결국 JVM이 OutOfMemoryError: Java heap space를 발생시킵니다. 누수는 작고 느릴 수도 있고(요청당 몇 KB), 크고 빠를 수도 있습니다(전체 결과 세트를 캐싱).
Java 메모리 누수의 흔한 원인
1. 무한히 커지는 정적 컬렉션
정적 필드는 JVM의 수명 동안 유지됩니다. 제거 없이 캐시로 사용되는 정적 Map은 전형적인 누수입니다. 예를 들어:
public class UserCache {
private static final Map<Long, User> CACHE = new HashMap<>();
public static void put(Long id, User user) {
CACHE.put(id, user); // never removed
}
}
추가된 모든 사용자는 영원히 남습니다. 해결: 크기 제한과 만료 기능이 있는 Caffeine이나 Guava 같은 제한된 캐시를 사용하세요.
2. 닫히지 않은 리소스
스트림, 연결, 리더는 반드시 닫아야 합니다. 잊어버리면 네이티브 리소스와 해당 Java 래퍼가 누수됩니다. 항상 try-with-resources를 사용하세요:
try (InputStream in = new FileInputStream(file)) {
// use in
} // automatically closed
이는 데이터베이스 연결, HTTP 클라이언트, 스레드 풀에도 적용됩니다.
3. 제거되지 않은 리스너와 콜백
리스너를 등록하고(addListener 등) 제거하지 않으면, 발행자가 리스너에 대한 참조를 유지하고, 리스너는 전체 객체 그래프에 대한 참조를 유지할 수 있습니다. 이는 GUI, 이벤트 버스, 장수명 컨텍스트에서 흔합니다.
4. ThreadLocal 변수
ThreadLocal 값은 스레드의 ThreadLocalMap에 저장됩니다. 스레드 풀에서는 스레드가 재사용되므로 remove()를 호출하지 않으면 값이 스레드에 계속 붙어 누수됩니다. 항상 정리하세요:
try {
threadLocal.set(context);
// work
} finally {
threadLocal.remove();
}
5. 외부 참조를 보유하는 내부 클래스
비정적 내부 클래스(익명 클래스 포함)는 외부 인스턴스에 대한 암시적 참조를 보유합니다. 내부 클래스 인스턴스가 외부 객체보다 오래 살아남으면(예: 정적 리스트에 저장됨), 외부 객체는 수집될 수 없습니다. 외부 인스턴스가 필요하지 않으면 내부 클래스를 정적으로 만드세요.
6. 잘못된 equals()와 hashCode()
HashMap에서 객체를 키로 사용하면서 hashCode() 없이 equals()만 재정의하거나(또는 그 반대), 조회가 실패하고 항목이 누적됩니다. 항상 둘 다 일관되게 재정의하세요.
7. 문자열 인터닝과 하위 문자열
Java 7 이전에는 String.substring()이 원래 char 배열을 공유했기 때문에 작은 하위 문자열이 큰 배열을 보유할 수 있었습니다. 최신 Java는 복사하지만, 많은 고유 문자열을 인터닝하면(String.intern() 등) 여전히 문자열 풀이 가득 찰 수 있습니다.
Java 메모리 누수 탐지 방법
탐지는 체계적인 과정을 따릅니다. 단계별 접근 방식은 다음과 같습니다:
- 시간 경과에 따른 힙 사용량을 모니터링합니다. JVisualVM, JConsole 또는 APM을 사용하세요. 건강한 앱은 톱니 모양 패턴을 보입니다: 힙이 증가하고, GC가 회수하고, 힙이 감소합니다. 누수는 각 GC 후에도 기준선이 상승합니다.
- GC 로깅을 활성화합니다.
-Xlog:gc*:file=gc.log:time,uptime,level,tags(Java 9+) 또는-XX:+PrintGCDetails(Java 8)를 추가하세요. GCeasy 같은 도구로 로그를 분석하여 old gen이 계속 증가하는지 확인하세요. - 힙 덤프를 캡처합니다. 힙이 높을 때
jmap -dump:live,format=b,file=heap.hprof <pid>를 실행하거나jcmd <pid> GC.heap_dump heap.hprof를 사용하세요.-XX:+HeapDumpOnOutOfMemoryError로 OutOfMemoryError 시 덤프를 트리거할 수도 있습니다. - 힙 덤프를 분석합니다. Eclipse MAT 또는 VisualVM에서 엽니다. 지배자 트리(dominator tree)를 보고 가장 많은 메모리를 유지하는 객체를 찾으세요. 큰 retained size를 가진 의심스러운 컬렉션을 확인하세요.
- 두 번째 덤프를 찍고 비교합니다. 덤프 간에 동일한 객체가 증가하면 누수를 발견한 것입니다.
- 실시간 분석을 위해 프로파일러를 사용합니다. YourKit, JProfiler, async-profiler 같은 도구는 앱을 중단하지 않고 할당 사이트와 참조 체인을 추적할 수 있습니다.
일반적인 도구 비교는 다음과 같습니다:
| 도구 | 유형 | 최적 용도 |
|---|---|---|
| Eclipse MAT | 힙 덤프 분석기 | 지배자와 누수 의심 객체 찾기 |
| VisualVM | 모니터링 + 힙 덤프 | 빠른 실시간 확인 및 기본 분석 |
| JProfiler / YourKit | 프로파일러 | 할당 추적 및 참조 체인 |
| async-profiler | 저오버헤드 프로파일러 | 플레임 그래프를 사용한 프로덕션 프로파일링 |
누수 수정 및 예방
누수를 식별했으면 의도치 않은 참조를 제거하여 수정하세요. 일반적인 해결 방법:
- 무제한 캐시를 제한된 캐시로 교체(Caffeine, Ehcache).
- 모든 Closeable 리소스에 try-with-resources 사용.
- finally 블록에서 리스너를 해제하거나 약한 참조를 사용.
- 항상
ThreadLocal.remove()호출. - 가능하면 내부 클래스를 정적으로 만들기.
- equals()와 hashCode()를 함께 재정의.
예방이 치료보다 낫습니다. CI/CD 파이프라인에 메모리 모니터링을 추가하고, 힙 검사를 포함한 부하 테스트를 실행하며, -Xmx를 적절히 설정하세요. SpotBugs 같은 정적 분석 도구로 흔한 패턴을 잡으세요.
FAQ
메모리 누수와 메모리 스파이크의 차이는 무엇인가요?
메모리 스파이크는 GC가 회수하는 일시적인 힙 사용량 증가입니다. 누수는 객체가 참조 가능하게 남아 수집될 수 없기 때문에 시간이 지남에 따라 꾸준히 증가하는 것입니다.
힙이 커도 Java 메모리 누수가 OutOfMemoryError를 일으킬 수 있나요?
예. 누수 속도가 GC의 회수 능력을 초과하면 힙 크기에 관계없이 결국 가득 찹니다. 힙을 늘리면 실패가 지연될 뿐입니다.
다운타임 없이 프로덕션에서 메모리 누수를 찾으려면 어떻게 해야 하나요?
async-profiler나 JFR(Java Flight Recorder) 같은 저오버헤드 도구를 사용하여 할당 및 힙 통계를 기록하세요. 앱 실행 중에 jcmd로 힙 덤프를 트리거할 수도 있지만 잠시 일시 중지될 수 있습니다.
GC 로그나 기타 텍스트 기반 로그를 분석해야 하나요? Nginx Log Analyzer를 사용하여 로그 패턴을 빠르게 파싱하고 시각화해 보세요.