grep

Engineering

JVM heap은 멀쩡한데 왜 메모리가 터질까? — Docker 환경 네이티브 메모리 삽질기 (Part 2)

Philip Park여기어때

2026년 6월 19일

원문에서 보기 ↗

글. 박성진(Philip) / 전시개발팀

안녕하세요, 지난 편에 이어 네이티브 메모리 이야기를 마저 풀어보려고 합니다.

1편에서는 이런 상태에서 멈췄었죠.

그리고 세 가지 질문을 남겨뒀습니다.

그 5GB는 어디서 쓰고 있나? 왜 계속 증가하나? 어떻게 잡나?

이번 편에서 그 답을 찾아가 보겠습니다.

이 글에서 하고 싶은 이야기

솔직히 고백하자면, 저도 이 일을 겪기 전까지는 힙 바깥의 메모리에 대해 거의 생각해본 적이 없었습니다.

자바 개발자라면 다들 비슷하지 않을까 싶습니다. 힙은 정말 잘 봅니다. 힙 덤프 뜨고, GC 로그 읽고, VisualVM으로 누가 메모리를 쥐고 있는지 잘만 추적하죠. 그런데 힙 밖에서 일어나는 일은 도구도 생소하고, 애초에 “거기서도 메모리를 쓴다”는 사실 자체가 낯설었습니다.

그래서 이번 편은 단순히 “범인은 누구였다”로 끝내기보다, 그 사각지대를 어떻게 들여다보는지 그 과정을 같이 따라가 보려고 합니다.

작은 예제를 하나 만들어 놓고, NMT → jemalloc/jeprof → async-profiler 순서로 “힙 밖에서 새는 네이티브 메모리”를 추적하고, 어떤 자바 코드가 범인인지 찾아내는 워크플로우를 그대로 보여드리겠습니다.

이 글의 수치와 그래프는 작은 Spring Boot 예제를 직접 돌려 얻은 실측값입니다. 환경은 arm64/Docker입니다.

코드는 보안 검토 후 공개 예정!

1. 현상 재현 — “난 압축한 적도 없는데?”

예제는 아주 평범합니다. 클래스패스, 즉 JAR 안에 있는 리소스를 읽어서 응답하는 엔드포인트 하나입니다.

// 누수의 핵심 — Andrei Pangin의 "Memory footprint of a Java process" 영상에 나오는 예제입니다.
InputStream in = getClass().getResourceAsStream("/leak-payload.bin");
byte[] buf = new byte[8192];
while (in.read(buf) != -1) {
    // ... 소비 ...
}
// in.close()를 호출하지 않습니다.

평범해 보이지만 함정이 있습니다.

이 예제의 leak-payload.bin은 bootJar 안에 DEFLATE 엔트리로 들어갑니다. JAR 엔트리는 STORED일 수도 있고, 빌드·패키징 설정에 따라 달라집니다. 여기서는 압축되도록 만들어 둔 케이스입니다.

그래서 getResourceAsStream으로 읽으면, JDK가 압축을 풀려고 내부적으로 java.util.zip.Inflater, 즉 zlib을 만듭니다. 우리는 new Inflater()를 쓴 적이 없는데 말이죠.

그리고 이 Inflater는 네이티브 메모리를 잡습니다. 스트림을 닫지 않으면 그 네이티브 버퍼가 GC/Cleaner 회수 전까지 해제되지 않습니다.

이 /churn에 부하를 주고 힙 사용량과 프로세스 RSS를 같이 찍어보면, 1편에서 봤던 그 그림이 다시 나옵니다.

힙은 GC가 정상적으로 돌며 수십~수백 MB를 오르내립니다. 그런데 프로세스 RSS는 한 번 뛰어오른 뒤 부하가 끝나도 내려오지 않습니다. 힙만 보고 있었다면 “정상”이라고 판단했을 겁니다.

메모리 한도를 1GB로 주면 결과는 이렇습니다.

$ docker inspect demo-glibc --format '{{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}'
137 OOMKilled=true

Exit 137, OOMKilled=true.

Java OutOfMemoryError는 한 줄도 찍히지 않았습니다. 힙을 다 쓴 게 아니거든요. cgroup이 보는 RSS가 한도를 넘자 리눅스 OOM Killer가 컨테이너를 그냥 죽인 겁니다.

2. 왜 힙 모니터링으로는 안 보일까? — 측정 관점의 차이

같은 프로세스인데 측정 도구가 보는 범위가 다릅니다. 이게 사각지대의 정체였습니다.

측정 관점을 나눠보면 이렇습니다.

힙은 JVM이 쓰는 메모리의 일부일 뿐입니다. 1편에서 NMT로 추적해봤지만, NMT 합계 약 9GB와 실제 RSS 약 14GB 사이엔 여전히 갭이 있었죠.

그 5GB는 NMT조차 추적하지 못하는 네이티브 malloc 영역에 있었습니다. 거길 보려면 도구를 바꿔야 합니다.

3. 네이티브 할당 추적 1 — jemalloc + jeprof

jemalloc은 멀티스레드에 강한 할당기인 동시에 힙 프로파일링을 내장하고 있습니다.

LD_PRELOAD로 끼워 넣고 MALLOC_CONF로 프로파일을 켜면, 부하가 도는 동안 *.heap 스냅샷을 떨궈줍니다.

ENV LD_PRELOAD=/opt/jemalloc/lib/libjemalloc.so
ENV MALLOC_CONF="prof:true,lg_prof_interval:24,prof_prefix:/app/jeprof/jeprof"
jeprof --show_bytes --svg $(which java) /app/jeprof/jeprof.*.heap > graph.svg

콜그래프를 보면 모든 화살표가 아래의 prof_backtrace_impl, 즉 추적된 전체 네이티브 할당으로 모입니다. 굵은 줄기를 따라 올라가면 범인이 보입니다.

Total: ~390 MB (샘플)
  67.2%  Java_java_util_zip_Inflater_inflateBytesBytes → inflate → updatewindow
  16.1%  Java_java_util_zip_Inflater_init             → inflateInit2_

이 캡처에서 샘플링된 네이티브 malloc 할당 바이트의 80% 이상이 Inflater, 즉 zlib에서 나왔습니다.

getResourceAsStream이 만든 바로 그 Inflater입니다. 1편에서 봤던 원본 분석 그래프의 Inflater_init, inflateInit2_, updatewindow와 정확히 같은 흐름이죠.

물론 이 예제 캡처는 약 390MB 샘플이라 운영 환경의 5GB 전체를 그대로 증명하는 그림은 아닙니다. 다만 행방불명이던 메모리가 어떤 종류의 네이티브 malloc 경로에서 생기는지, 그 정체가 여기서 드러납니다.

jeprof의 한계도 있습니다. jeprof는 네이티브 C 심볼, 예를 들어 inflateInit2_는 보여주지만, JIT으로 컴파일된 자바 프레임은 16진수 주소로만 나옵니다.

그래서 “어느 자바 메서드가 이걸 불렀나”를 더 알고 싶으면 도구가 하나 더 필요합니다.

4. 어떤 자바 코드가 그걸 불렀나 — async-profiler

여기서 한 가지 정확히 짚고 갈 게 있습니다.

바로 쓸 이벤트는 -e alloc인데, 이건 native malloc을 직접 찍는 도구가 아닙니다. -e alloc은 자바 힙 할당, 즉 JVM의 TLAB 할당을 샘플링합니다.

그러니까 “native malloc이 어디서 일어났나”가 아니라, “어떤 자바 경로가 InflaterInputStream/Inflater 객체 생성을 유발했나”를 보여줍니다.

네이티브 malloc 자체는 앞에서 본 jeprof가 잡고, async-profiler는 그걸 유발한 자바 코드 경로를 보완해주는 셈입니다.

./profiler.sh -d 30 -e alloc -f alloc.html <pid>

스택이 또렷합니다.

Undertow 워커 → 우리 핸들러 → 리소스 읽기, getResourceAsStream → java.util.zip.Inflater.

jeprof가 “네이티브에서 zlib, 즉 inflateInit2_가 범인”이라고 알려줬다면, async-profiler -e alloc은 “그 Inflater 객체를 만든 건 우리 핸들러의 리소스 읽기”라고 자바 코드 경로까지 짚어줍니다.

네이티브, jeprof와 자바, async-profiler 두 시점이 만나는 곳에 범인이 있습니다.

이 flame graph, 어떻게 읽나

flame graph는 세로가 호출 깊이입니다. 아래가 진입점이고, 위가 leaf입니다.

가로 너비는 그 경로가 먹은 비율입니다. 가로축은 시간 순서가 아닙니다.

우리 캡처에서 할당이 가장 많았던 경로, 즉 가장 넓은 기둥을 아래에서 위로 읽으면 이렇습니다.

[바닥] java/lang/Thread.run
       ThreadPoolExecutor.runWorker / FutureTask.run      ← ① 워커 스레드가 작업을 꺼내 실행
─────────────────────────────────────────────────────────
       ResourceLeakService.readResource                   ← ② 우리 코드, 리소스 읽기
─────────────────────────────────────────────────────────
       Class.getResourceAsStream
       URLClassLoader.findResource …                      ← ③ JDK/Spring이 리소스를 찾아 엶
─────────────────────────────────────────────────────────
       NestedJarFile.getInputStream
       JarEntryInflaterInputStream.<init>
       ZipInflaterInputStream.<init>
       InflaterInputStream.<init>                         ← ④ JAR 엔트리를 풀려고
                                                            InflaterInputStream, 즉 Inflater 생성
─────────────────────────────────────────────────────────
[leaf] byte[]   (74,713 / 58,997 …)                       ← ⑤ 그 과정에서 잡힌 자바 힙 바이트

봐야 할 핵심은 ④번 층입니다.

우리는 getResourceAsStream만 불렀는데, 그 위로 스택이 InflaterInputStream.<init>까지 자동으로 뻗어 있죠. 이게 바로 “내가 new Inflater()를 쓴 적 없는데 Inflater가 생긴다”의 증거입니다.

②번, 우리 readResource가 ④번, Inflater 생성을 부른다는 게 한눈에 보입니다.

헷갈리기 쉬운 두 가지도 짚어둘게요.

-e alloc은 할당된 자바 힙 바이트를 셉니다. Inflater 객체 자체는 작고, 눈에 띄게 잡히는 건 그 주변 byte[] 버퍼입니다. 우리 코드의 new byte[8192]와 InflaterInputStream 내부 버퍼가 여기에 해당합니다.

그래서 leaf가 byte[]로 보입니다. 진짜 네이티브 zlib 버퍼, 즉 누수 본체는 이 그래프에 안 나옵니다. 그건 jeprof가 잡는 거고요. 이 그래프의 가치는 “어떤 자바 코드가 Inflater를 만드는가”를 보여주는 데 있습니다.

스택이 깊어서 그렇습니다. Spring Boot loader 내부까지 약 20층 정도 들어가고, 위로 갈수록 좁아진 것뿐입니다. 정보가 없는 게 아니라 “한 줄기로 깊게 들어간” 모양입니다.

참고로 async-profiler에는 -e malloc처럼 자바와 네이티브를 한 장의 flame graph로 묶어 보는 방법도 있습니다. Andrei Pangin의 영상에서 보여주는 방식입니다.

다만 이 글에서는 역할을 명확히 나눴습니다. 네이티브 측은 jeprof, 자바 측은 async-profiler -e alloc로 봤습니다. 두 시점을 합쳐 같은 결론에 도달하는 방식입니다.

5. 원인 분석과 처방 — 추측 말고 측정으로

범인은 잡았습니다.

닫지 않은 Inflater의 네이티브 버퍼입니다.

그럼 어떻게 잡을까요? 후보 처방들을 같은 부하 조건에서 peak RSS로 재봤습니다.

단, 측정 축이 두 가지 섞여 있다는 걸 먼저 밝혀둡니다.

/noleak, 즉 close 행은 “코드를 고치는” 비교입니다. 엔드포인트를 바꾼 compare-endpoints.sh 결과입니다.

allocator 행들은 코드는 그대로 /churn에 두고 할당기 설정만 바꾼 비교입니다. compare-arenas.sh 결과입니다.

둘 다 같은 부하 기준이라 한 표로 요약했습니다.

결과는 이렇습니다.

여기서 두 가지를 배웠습니다.

첫째, 근본은 결국 “닫는 것”이었습니다.

스트림을 close()하면 그 내부의 Inflater도 close/end 경로를 타서 네이티브 자원을 즉시 반납합니다. 그러면 GC를 기다리며 버퍼가 쌓일 일이 없습니다.

닫는 것만으로 RSS가 기준선 1328MB의 절반 이하, 516MB로 떨어졌습니다.

둘째, 그래도 RSS가 높으면 할당기를 의심해봅니다.

glibc는 멀티스레드에서 여러 arena를 만들고, 한 번 확보한 메모리를 OS에 잘 반납하지 않아 RSS가 부풀 수 있습니다. 흔히 arena 단편화라고 부르는 문제입니다.

jemalloc/tcmalloc 교체나 MALLOC_ARENA_MAX 축소가 완화책이 됩니다. 다만 컨테이너의 jemalloc은 그냥 끼우면 dirty page를 쥐고 있어 오히려 더 높게 나올 수 있고, dirty_decay_ms:0 같은 튜닝이 필요했습니다.

영상은 jemalloc 교체를 강조하는데, 실제로 떠보면 이런 디테일이 보입니다. 그래서 추측하지 말고 측정해야 합니다.

6. 정리하며 — 자바 개발자도 네이티브 메모리를 추적할 수 있습니다

돌이켜보면 이번 일의 핵심은 “어떤 처방이 정답이냐”보다 “어떻게 들여다보느냐”였습니다.

  1. 힙만 보지 마세요.컨테이너 OOM은 RSS로 일어납니다. 힙, NMT, RSS는 보는 범위가 다릅니다.

  2. NMT < RSS면, 추적되지 않는 네이티브 malloc을 의심하세요.

  3. 도구를 알면 절반은 끝납니다.

jemalloc + jeprof로 네이티브 콜그래프를 보고, async-profiler로 자바 메서드까지 봅니다. 두 시점이 만나는 곳에 범인이 있습니다.

  1. 네이티브 자원은 확실하게 닫으세요.

InputStream, Inflater, Deflater, ZipFile 같은 것들은 try-with-resources로 닫는 게 안전합니다. 특히 getResourceAsStream처럼 암묵적으로 네이티브를 쓰는 코드를 조심해야 합니다.

  1. 처방 효과는 워크로드마다 다릅니다.

측정하세요.

힙 추적이 익숙해진 것처럼, 네이티브 메모리도 결국 도구와 워크플로우의 문제라고 생각합니다.

한 번 길을 익혀두면, 다음에 “힙은 멀쩡한데 메모리가 터질 때” 덜 당황하게 되실 겁니다.

긴 글 읽어주셔서 감사합니다.

참고 링크