← Back to list

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

들어가며

Philip Park in 여기어때 기술블로그 · 2025-12-14 16:17 · 102 claps · 10.3 min read
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud

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

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

안녕하세요 여기어때 전시 개발팀 백엔드 개발자 필립입니다.

오늘은 제가 2년 전 겪었던 꽤나 골치 아팠던 메모리 이슈에 대해 이야기해보려고 합니다. 같은 문제로 고민하시는 분들께 도움이 되었으면 좋겠습니다.

들어가며

2년전 어느 날 모니터링 알람이 울렸습니다. 서버 메모리 사용량이 80%를 넘어가고 있었죠. “GC 튜닝이 필요한가?” 싶어서 힙 메모리부터 확인했는데… 힙은 너무나 건강했습니다. GC도 정상적으로 돌고 있고, 메모리 사용량도 안정적이었죠.

그런데 서버 메모리는 계속 올라갑니다. 대체 뭐가 메모리를 먹고 있는 걸까요?

이 글에서는 Docker 환경에서 JVM 애플리케이션의 메모리가 지속적으로 증가하는 현상을 추적한 과정을 공유합니다.

현재 서버 환경

문제가 발생한 서버의 스펙은 다음과 같습니다.

EC2 Spec

EC2 Spec

JVM 옵션은 아래와 같이 설정되어 있었습니다.

-Xms8G -Xmx8G -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -XX:+UseG1GC

힙을 8GB로 고정하고, Metaspace도 512MB로 제한했으니 JVM이 쓸 수 있는 메모리는 충분히 통제되고 있다고 생각했습니다.

발생한 현상

Grafana를 통해 메모리 추이를 확인해보니 이상한 패턴이 보였습니다.

EC2 Memory 증가 그래프

EC2 Memory 증가 그래프

“트래픽과 무관하게 시간이 지남에 따라 메모리가 계속 증가하고 있다”

특이한 점은 트래픽이 많든 적든 상관없이 메모리가 증가했고, 시간이 지나면 지날수록 꾸준히 우상향했으며, 결국 서버 메모리의 80% 이상을 사용하게 되었습니다.

“혹시 메모리 릭인가?” 싶어서 힙 덤프를 떠보기도 하고, GC 로그를 분석해보기도 했지만 힙 영역에서는 아무런 문제가 없었습니다.

왜 이게 심각한 문제인가? — Docker와 OOM Killer

“메모리가 좀 늘어나면 어때, GC가 알아서 하겠지”라고 생각할 수 있습니다. 하지만 Docker 환경에서는 이야기가 다릅니다.

cgroup이란?

cgroup(Control Groups)은 Linux 커널의 기능으로, 프로세스 그룹의 리소스 사용량을 제한하고 격리합니다. Docker는 바로 이 cgroup을 사용해서 컨테이너의 CPU, 메모리 등을 제한합니다.

# Docker 메모리 제한 설정
docker run -m 16GB my-app
# 이 설정은 내부적으로 cgroup의 memory.limit_in_bytes로 적용됨

문제는 “무엇을 기준으로 메모리를 측정하는가”

여기서 중요한 차이가 있습니다.

측정 주체        측정 기준                      포함 범위
─────────────────────────────────────────────────────────────
JVM (-Xmx)      Heap Size                  Java 힙 영역만
JVM (NMT)       JVM이 직접 할당한 메모리        힙 + JVM 관리 네이티브 메모리
cgroup/Docker   RSS (Resident Set Size)    프로세스가 사용하는 모든 물리 메모리

cgroup은 JVM이 뭘 하든 상관없이, 프로세스가 실제로 점유한 물리 메모리(RSS)를 기준으로 판단합니다. 여기에는 JVM이 모르는 네이티브 라이브러리의 malloc, glibc의 내부 구조 등 모든 것이 포함됩니다.

OOM Killer의 작동 방식

컨테이너의 메모리 사용량이 cgroup 제한에 도달하면:

1. cgroup이 메모리 제한 초과 감지
2. Linux OOM Killer 호출
3. 컨테이너 내 프로세스 강제 종료 (SIGKILL)
4. 애플리케이션 즉시 다운

가장 무서운 점은 JVM의 OutOfMemoryError가 발생하지 않는다는 것입니다. JVM 입장에서는 힙도 여유 있고, 자기가 아는 네이티브 메모리도 괜찮은데, 갑자기 OS에 의해 프로세스가 죽어버립니다.

# dmesg에서 볼 수 있는 OOM Killer 로그
[123.456789] Memory cgroup out of memory: Killed process 1234 (java)

즉, -Xmx8G로 힙을 제한해도, 네이티브 메모리가 통제 불능으로 늘어나면 컨테이너가 OOM으로 죽습니다. 이것이 바로 팀에서 겪은 상황이었습니다.

JVM 메모리 구조 — 힙만 보면 안 되는 이유

여기서 한 가지 중요한 사실을 짚고 넘어가야 합니다. JVM이 사용하는 메모리는 힙만이 아닙니다.

Heap vs Native Memory

구분            Heap Memory                    Native Memory
───────────────────────────────────────────────────────────────────────
관리 주체       JVM (GC가 자동 관리)            OS (malloc/free)
제한 설정       -Xmx로 명확히 제한               제한하기 어려움
모니터링        jstat, VisualVM, JMX 등       NMT, pmap, top
누수 발생 시     OutOfMemoryError             OOM Killer에 의해 프로세스 강제 종료
디버깅 난이도    상대적으로 쉬움 (힙 덤프 분석)      어려움 (어디서 새는지 찾기 힘듦)

Native Memory 구성 요소

JVM이 사용하는 네이티브 메모리는 생각보다 다양합니다.

Metaspace — 클래스 메타데이터 저장 (Java 8+에서 PermGen 대체). -XX:MaxMetaspaceSize로 제한 가능.

Thread Stack — 스레드별 호출 스택. 스레드 수 × 스택 크기만큼 사용. -Xss로 설정 (기본 1MB).

Code Cache — JIT 컴파일러가 생성한 기계어 코드 저장. -XX:ReservedCodeCacheSize로 제한 가능.

GC — GC 알고리즘의 내부 데이터 구조 (카드 테이블, 마킹 비트맵 등). GC 종류에 따라 다름.

Compiler — JIT 컴파일러 자체가 사용하는 메모리.

Direct ByteBuffer — NIO에서 사용하는 off-heap 버퍼. -XX:MaxDirectMemorySize로 제한 가능.

Symbol — String Pool, 상수 풀 등. -XX:StringTableSize로 설정 가능.

Native Library — JNI를 통해 로드된 네이티브 라이브러리. 제한 불가.

간과하기 쉬운 포인트

스레드 수가 늘어나면 네이티브 메모리도 같이 늘어납니다. 예를 들어:

스레드 200개 × 스택 1MB = 200MB (네이티브 메모리)

Undertow나 Netty 같은 비동기 서버는 스레드 풀을 사용하지만, 요청이 많아지면 스레드가 늘어날 수 있고, 이는 곧 네이티브 메모리 증가로 이어집니다.

Native Memory Tracking (NMT) 활성화

힙이 아닌 곳에서 메모리가 새고 있다면, 네이티브 메모리를 추적해야 합니다. 다행히 JVM은 Native Memory Tracking 기능을 제공합니다.

NMT 활성화 방법

JVM 옵션에 다음을 추가합니다.

-XX:NativeMemoryTracking=detail

⚠NMT는 약 5–10%의 성능 오버헤드가 있으므로 운영 환경에서는 주의가 필요합니다.

NMT 사용법

# Java 프로세스 ID 확인
jps -l
# 출력: 1 app.jar
# 베이스라인 설정 (현재 상태를 기준점으로)
jcmd 1 VM.native_memory baseline
# 시간이 지난 후, 베이스라인 대비 변화량 확인
jcmd 1 VM.native_memory summary.diff

NMT 결과 분석

며칠간 모니터링 후 확인한 NMT 결과입니다. (가독성을 위해 MB 단위로 변환)

Native Memory Tracking:
Total: reserved=9,621MB (+57MB), committed=8,919MB (+27MB)
-        Java Heap (reserved=8,192MB, committed=8,192MB)
-            Class (reserved=417MB, committed=21.6MB +0.7MB)
                   (classes #26096 +375)
-           Thread (reserved=170MB +49MB, committed=17.9MB +5.5MB)
                   (stack: reserved=169.6MB +49.2MB, committed=17.4MB +5.3MB)
-             Code (reserved=248MB, committed=97.6MB +12.6MB)
-               GC (reserved=364MB, committed=364MB)
         ... (생략) ...

여기서 중요한 숫자들:

reserved — 앱이 잠재적으로 사용할 수 있는 메모리

committed — 실제로 현재 사용 중인 메모리

+숫자 — 베이스라인 이후 증가량

NMT 기준으로 전체 committed 메모리는 약 9GB 정도였습니다.

그런데 뭔가 이상하다

NMT로는 9GB 정도를 사용하고 있다고 나옵니다. 그런데 top 명령어로 확인하면?

top

top

RES(실제 물리 메모리 사용량)가 14GB입니다.

NMT는 9GB라고 하는데, 실제로는 14GB를 쓰고 있다? 약 5GB의 메모리가 어딘가에서 사라지고(?) 있었습니다.

NMT의 한계

NMT는 JVM이 직접 할당한 메모리만 추적합니다. 하지만 JVM 위에서 동작하는 네이티브 라이브러리들이 malloc으로 직접 할당한 메모리는 추적하지 못합니다.

즉, NMT만으로는 전체 그림을 볼 수 없습니다.

삽질의 시작

원인을 찾기 위해 여러 가지를 시도해봤습니다.

그 전에 솔직히 고백하자면, 기존에 힙 메모리 누수를 분석할 때는 나름의 루틴이 있었습니다. 힙 덤프를 뜨고 Memory Analyzer(MAT)로 분석하고, 스레드 덤프를 떠서 메모리 피크 시점에 어떤 스레드가 많이 도는지 보면서 원인을 추적하는 방식이죠.

그런데 이번엔 달랐습니다. Docker 컨테이너가 설정된 메모리 제한을 넘어서면서 애플리케이션이 죽어버리는 상황이었고, 네이티브 메모리에 대한 개념이 부족한 상태에서 “일단 뭐라도 해보자”는 심정으로 민간요법 같은 시도들을 했습니다.

1. GC 로그와 Undertow Access 로그 비활성화

혹시 로그 파일 기록 시 메모리 누수가 발생하는 건 아닐까?

# GC 로그 비활성화
# Undertow access log 비활성화

결과: 실패 ❌ 여전히 메모리 증가

2. Docker Memory 제한 명시

Java 11 이상에서는 컨테이너 메모리를 자동 인식하지만, 혹시 모르니 명시적으로 설정해봤습니다.

docker run -m 10GB app java \
  -XX:MinRAMPercentage=50.0 \
  -XX:MaxRAMPercentage=80.0 \
  ...

결과: 실패 ❌ 메모리 제한과는 무관한 문제

3. JVM 버전 변경

혹시 특정 JVM 버전의 버그일까?

Amazon Corretto 17 → 19 : 실패 ❌

Temurin 17 : 실패 ❌

GraalVM Community : 실패 ❌

GraalVM + JIT 컴파일러 수정 (토스 발표 참고) : 실패 ❌

결과: 전부 실패

JVM 버전이나 벤더의 문제가 아니었습니다.

Part 1 정리

여기까지가 “범인을 찾아 헤매던” 과정입니다.

알게 된 것들:

  • JVM 메모리 = 힙 + 네이티브 메모리
  • NMT로 네이티브 메모리를 추적할 수 있다
  • 하지만 NMT에도 한계가 있다 (JVM 외부 malloc 추적 불가)
  • NMT 기준 9GB vs 실제 14GB → 약 5GB가 미스터리

아직 모르는 것:

  • 5GB는 대체 어디서 쓰고 있는 건가?
  • 왜 메모리가 계속 증가하는 건가?
  • 어떻게 해결할 수 있는가?

다음 편에서는 jemalloc/jeprof와 async-profiler로 NMT가 보지 못한 native malloc 경로를 추적하고, 실제로 어떤 처방이 RSS를 줄였는지 측정해보겠습니다.

다음 편 예고

Part 2: NMT가 못 보는 native malloc 추적하기

  • jemalloc + jeprof로 JVM 밖 malloc 호출 스택 보기
  • async-profiler로 어떤 Java 코드가 그 native allocation을 유발했는지 찾기
  • getResourceAsStream 뒤에 숨어 있던 Inflater 확인하기
    • close()/end() 호출과 allocator 튜닝의 효과 비교하기
  • glibc arena와 jemalloc decay가 RSS에 어떤 영향을 주는지 측정하기

이 글이 “heap 은 멀쩡한데 서버가 죽는” 상황을 겪는 분들께 조금이라도 힌트가 되었으면 합니다.


메타데이터
post_id
4d48d788e1cc
slug
jvm-heap은-멀쩡한데-왜-메모리가-터질까-docker-환경-네이티브-메모리-삽질기-part-1-4d48d788e1cc
url
https://techblog.gccompany.co.kr/jvm-heap%EC%9D%80-%EB%A9%80%EC%A9%A1%ED%95%9C%EB%8D%B0-%EC%99%9C-%EB%A9%94%EB%AA%A8%EB%A6%AC%EA%B0%80-%ED%84%B0%EC%A7%88%EA%B9%8C-docker-%ED%99%98%EA%B2%BD-%EB%84%A4%EC%9D%B4%ED%8B%B0%EB%B8%8C-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EC%82%BD%EC%A7%88%EA%B8%B0-part-1-4d48d788e1cc
canonical_url
https://techblog.gccompany.co.kr/jvm-heap%EC%9D%80-%EB%A9%80%EC%A9%A1%ED%95%9C%EB%8D%B0-%EC%99%9C-%EB%A9%94%EB%AA%A8%EB%A6%AC%EA%B0%80-%ED%84%B0%EC%A7%88%EA%B9%8C-docker-%ED%99%98%EA%B2%BD-%EB%84%A4%EC%9D%B4%ED%8B%B0%EB%B8%8C-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EC%82%BD%EC%A7%88%EA%B8%B0-part-1-4d48d788e1cc
author_url
https://medium.com/@philip_park
status
ok
fetched_at
2026-07-09 09:01:30