JVM 힙 외 네이티브 메모리 누수, Docker OOM의 진실
JVM 힙 메모리 외 네이티브 메모리(Native Memory) 누수로 인해 Docker 컨테이너 OOM 발생 가능성 제기
`getResourceAsStream` 사용 시 압축 해제 과정에서 네이티브 메모리 누수 발생 메커니즘 분석
jemalloc + jeprof와 async-profiler를 활용한 네이티브 메모리 추적 워크플로우 제시
스트림의 `close()` 호출이 근본적인 해결책이며, 할당기 튜닝은 완화책임을 실험 결과로 입증
네이티브 메모리 누수의 근본 원인: `getResourceAsStream`과 `Inflater`
자바 개발자가 간과하기 쉬운 힙(Heap) 외부의 네이티브 메모리(Native Memory) 누수는 `getResourceAsStream` 사용 시 발생할 수 있다. JAR 파일 내 압축된 리소스(`DEFLATE` 엔트리)를 읽을 때, JDK 내부적으로 `java.util.zip.Inflater`가 사용되며 네이티브 메모리를 할당한다. 이 스트림을 명시적으로 `close()`하지 않으면, 할당된 네이티브 버퍼가 GC/Cleaner에 의해 회수되기 전까지 해제되지 않아 메모리 누수를 유발한다. 이는 JVM의 힙 메모리 사용량과 무관하게 프로세스의 전체 RSS(Resident Set Size)를 증가시켜 리눅스 OOM Killer에 의해 컨테이너가 종료되는 직접적인 원인이 된다.
측정 관점의 차이: NMT vs RSS vs 네이티브 할당 추적
JVM의 네이티브 메모리 추적 도구(NMT)는 JVM 내부에서 관리하는 네이티브 메모리만 보여주며, 약 9GB를 추적했지만 실제 프로세스 RSS는 14GB에 달했다. 이 5GB의 차이는 JVM 외부에서 발생한 `malloc` 호출, 즉 추적되지 않는 네이티브 메모리 할당(Untracked Native Memory Allocation) 때문이다. 이를 파악하기 위해 `jemalloc`과 같은 외부 메모리 할당기(Allocator)와 `jeprof`를 사용한 네이티브 힙 프로파일링, 그리고 `async-profiler`를 이용한 자바 코드 경로 추적이 필요하다. 이 두 가지 도구의 결과를 교차 분석하여 문제의 근원을 파악하는 것이 핵심이다.
네이티브 메모리 추적 워크플로우: `jemalloc` + `jeprof` + `async-profiler`
네이티브 메모리 누수 추적을 위해 `jemalloc`을 `LD_PRELOAD`로 끼워 넣고 프로파일링을 활성화하면, `jeprof`를 통해 네이티브 `malloc` 호출 스택을 분석할 수 있다. 샘플링된 데이터에서 `Inflater` 관련 함수(`inflateBytesBytes`, `inflateInit2_`)가 대부분의 네이티브 할당을 차지함을 확인했다. 하지만 `jeprof`는 JIT 컴파일된 자바 프레임을 직접 보여주지 못한다. 이때 `async-profiler`의 `-e alloc` 옵션을 사용하면, 어떤 자바 코드 경로(`ResourceLeakService.readResource` → `getResourceAsStream`)가 `Inflater` 객체 생성을 유발했는지 파악할 수 있다. 이 두 도구의 결과를 종합하면 네이티브 메모리 누수의 원인과 자바 코드 상의 트리거를 명확히 식별 가능하다.
메모리 누수 해결 방안 비교: 코드 수정 vs 할당기 튜닝
실험 결과, 가장 효과적인 해결책은 스트림을 명시적으로 `close()`하는 것이었다. `close()` 호출 시 RSS가 1328MB에서 516MB로 절반 이하로 감소했다. 이는 근본적인 자원 해제(Resource Deallocation)를 통해 누수를 막는 방법이다. 코드를 수정할 수 없는 경우, `jemalloc` 사용 및 `MALLOC_ARENA_MAX=1` 설정과 같은 할당기(Allocator) 튜닝이 완화책이 될 수 있다. 다만, `jemalloc`의 `dirty_decay_ms:0` 같은 세부 튜닝이 필요할 수 있으며, `glibc`의 기본 설정은 메모리 반납에 소극적이어서 RSS가 더 높게 나올 수 있다. 따라서 정확한 원인 진단과 효과적인 처방을 위해서는 반드시 측정이 선행되어야 한다.
자바 개발자를 위한 네이티브 메모리 관리 전략
자바 개발자도 네이티브 메모리 관리에 대한 이해가 필수적이다. 힙 메모리(Heap Memory)만 모니터링하는 것은 불충분하며, 프로세스 RSS를 꾸준히 관찰해야 한다. NMT와 RSS 간의 큰 차이는 추적되지 않는 네이티브 `malloc`을 의심하는 신호다. `jemalloc` + `jeprof` 조합으로 네이티브 측면을, `async-profiler`로 자바 측면을 분석하는 워크플로우를 익혀두면 문제 해결에 큰 도움이 된다. 특히 `InputStream`, `Inflater`, `ZipFile` 등 네이티브 자원을 사용하는 객체는 `try-with-resources` 구문을 사용하여 확실하게 `close()`하는 습관이 중요하다. 결국, 추측이 아닌 측정을 통해 문제의 원인을 파악하고 최적의 해결책을 찾는 것이 중요하다.