Development Log
운영 Pod 메모리 사용률 80% 원인 분석 회고.
운영 Java Pod의 메모리가 80%까지 상승한 원인을 Datadog JVM 지표, RSS와 cgroup으로 추적해 Heap Committed 확장으로 좁힌 회고입니다.
- #Kubernetes
- #Operations
- #Performance
한 줄 결론 운영 Pod의 메모리가 약 1.5GiB에서 1.9GiB로 갑자기 올라간 직접 원인은 JVM이 Heap Committed 영역을 확장했기 때문이었다. 실제 사용 중인 객체는 GC로 상당 부분 정리됐지만, JVM은 한 번 확보한 Heap 공간을 바로 OS에 반환하지 않아 RSS가 높은 상태로 유지됐다. 현재까지의 데이터만 보면 메모리 누수보다는 JVM의 정상적인 메모리 확장 동작에 더 가깝다.
1. 문제를 발견한 계기
Datadog에서 prd-service-deployment Pod들의 메모리 사용률을 확인하던 중 일부 Pod가 **Memory Limit의 약 80%**까지 올라가 있는 것을 확인했다.
특이했던 점은 메모리가 천천히 계속 증가하는 형태가 아니라, 특정 시점에 약 1.5GiB → 1.9GiB로 계단식 상승한 뒤 다시 내려오지 않는 패턴이었다.
확인한 주요 Pod:
sczw2— 8/25 메모리 상승xxbz2— 8/29 메모리 상승wr79l— 9/7 메모리 상승 세 Pod 모두 재시작 없이 장기간 실행 중이었고Ready=1,Restart=0상태였다.
2. 처음 세운 가설
처음에는 다음 가능성을 의심했다.
- 메모리 누수 — 불필요한 객체가 GC로 정리되지 않고 계속 쌓이는 상황
- 특정 API 트래픽 폭증 — 특정 요청이 순간적으로 대량의 메모리를 사용하는 상황
- 파일/Page Cache 증가 — 실제 애플리케이션 메모리가 아닌 OS 캐시가 증가한 상황
- Java Heap 외 영역 증가 — Direct Buffer, Non-Heap, Thread, Native Memory 등이 증가한 상황
3. Working Set과 RSS 비교
먼저 Datadog에서 아래 지표를 비교했다.
kubernetes.memory.working_setkubernetes.memory.rss
두 그래프가 거의 동일하게 움직였다.
즉 Working Set만 증가한 것이 아니라 Java 프로세스의 실제 Resident Memory(RSS)도 함께 증가하고 있었다.
Pod 내부에서도 /proc/1/status를 확인했다.
VmRSS: 약 2.0 GiB
RssAnon: 약 1.93 GiB
RssFile: 약 36 MiB
RSS 대부분이 Anonymous Memory였기 때문에 단순 파일 캐시 문제는 아니라고 판단했다.
4. 컨테이너 메모리 상태 확인
운영 컨테이너의 설정은 다음과 같았다.
Container Memory Limit : 2500 MiB
JVM Xms : 1024 MiB
JVM Xmx : 2048 MiB
memory.stat 기준으로는 대략:
anon ≈ 1.98 GiB
file ≈ 0.51 GiB
kernel ≈ 10 MiB
또한 memory.events에서:
max 1902
oom 0
oom_kill 0
를 확인했다. 즉 OOM이 발생한 것은 아니지만 컨테이너가 Memory Limit에 접근해 reclaim이 여러 차례 발생한 상태였다.
5. JVM 내부 메모리를 확인하다
Datadog Java Runtime Metric을 확인한 결과 다음 지표를 볼 수 있었다.
jvm.heap_memoryjvm.heap_memory_committedjvm.non_heap_memoryjvm.buffer_pool.direct.usedjvm.thread_countjvm.gc.eden_sizejvm.gc.old_gen_sizejvm.gc.survivor_sizePod 이름이 JVM Metric의 Group By 태그로 제공되지 않아, APM Trace에서 각 Pod와runtime-id를 연결한 뒤 개별 JVM을 추적했다.
6. 첫 번째 Pod — sczw2
메모리 상승 직전 JVM 상태를 분 단위로 확대했다.

Heap Used
약 700 MiB → 약 1,000 MiB
Eden
약 100~200 MiB → 약 500 MiB
↓ Minor GC
Heap Used
약 1,000 MiB → 약 550 MiB
Eden
약 500 MiB → 약 80 MiB
Heap Committed
약 1.1 GiB → 약 1.5 GiB
여기서 중요한 점은 GC 이후 Heap Used는 크게 감소했는데 Heap Committed는 감소하지 않았다는 것이다. 즉 불필요한 객체 자체는 정상적으로 정리됐지만, JVM이 추가로 확보한 Heap 영역은 그대로 유지했다.
7. 다른 Pod에서도 같은 패턴이 반복됐다
xxbz2 — 8/29

Heap Used ≈ 1.01 GiB까지 증가
Eden ≈ 528 MiB까지 증가
↓ GC
Heap Used ≈ 0.6 GiB
Eden ≈ 117 MiB
Heap Committed ≈ 1.1 → 1.5 GiB
wr79l — 9/7
wr79l에서도 동일하게 Young 영역의 Allocation과 Minor GC가 발생한 뒤 Heap Committed가 증가했다.

- Non-Heap: 큰 변화 없음
- Direct Buffer: 큰 변화 없음
- Thread Count: 큰 변화 없음
- Major GC: 발생하지 않음
- Minor GC: 발생 확인 세 Pod 모두 서로 다른 날짜에 거의 동일한 형태를 보였다.
핵심 관찰 특정 Pod 하나의 이상 현상이 아니라, 각 JVM이 장기간 운영되면서 Young 영역에 객체를 할당하고 Minor GC를 반복하는 과정에서 Heap Committed를 확대하는 동일한 패턴이 반복됐다.
8. 왜 메모리가 갑자기 '팍' 올라갔나
JVM은 실제 사용량만큼 메모리를 아주 조금씩 늘리는 방식으로 동작하지 않는다. 처음에는 약 1GB 정도의 Heap 공간을 확보해서 사용하다가, 요청 처리 과정에서 임시 객체가 많이 만들어지고 현재 확보된 공간의 여유가 부족해지면 JVM이 추가 Heap 공간을 한 번에 확보할 수 있다. 쉽게 비유하면:
10평짜리 사무실을 사용하다 사람이 많아져 15평짜리 사무실로 확장한 것과 비슷하다. 이후 사람이 빠져 실제 사용하는 공간은 다시 줄어도, 이미 계약한 15평짜리 사무실을 바로 반납하지는 않는다. 그래서 실제 그래프는 다음처럼 보인다.
Heap Used
/\
/ \
______/ \_____ ← GC 후 감소
Heap Committed
______|────────── ← 한번 확장된 뒤 유지
RSS
______|────────── ← 함께 높은 상태 유지
9. 특정 API가 원인이었나
APM Trace CSV를 각 메모리 상승 시점 기준으로 확인했다.
8/25, 8/29, 9/7 모두 상승 시점에 요청량 폭증이나 5xx 증가 같은 특이점은 확인되지 않았다.
access/status, smartux/comm/ad, watchlist, authorize, channel/epg 등 평상시 요청이 반복되고 있었다.
Scheduler Trace도 확인했다.
예를 들어 wr79l의 상승 구간에는:
ScheduleService.scheduleFetchServiceMfaUtv
가 있었지만 실행시간은 약 50.8μs, Child Span도 없었다.
따라서 현재 데이터만으로는 특정 API 또는 Scheduler 하나를 직접 원인으로 지목할 근거가 부족하다.
10. 현재까지의 결론
현재까지 확인된 흐름은 다음과 같다.
일반 요청 처리
↓
Young/Eden 영역에 단명 객체 생성
↓
Eden 사용량 증가
↓
Minor GC 발생
↓
불필요 객체 대부분 정상 회수
↓
JVM이 향후 Allocation 여유 확보를 위해 Heap Committed 확대
↓
RSS가 계단식 상승
↓
Committed Heap을 바로 반환하지 않아 높은 RSS 유지
따라서 현재 현상은 갑작스러운 메모리 누수라기보다 JVM의 Heap Sizing / Memory Management 동작으로 보는 것이 더 타당하다.
11. 아직 확인하지 못한 것
이번 분석으로 왜 RSS가 올라갔는지는 확인했지만, 한 단계 더 들어간 아래 질문은 아직 답하지 못했다.
어떤 Java 메서드가 Young 영역에 가장 많은 객체를 할당하고 있는가? 현재 Datadog APM은 Request, Duration, Trace 관계는 남기지만 특정 메서드가 몇 MB의 객체를 할당했는지에 대한 과거 정보는 가지고 있지 않았다. 또한 Datadog Continuous Profiler는 현재 활성화되어 있지 않아 과거 8/25~9/7의 Allocation Profile을 소급해서 확인할 수 없었다.
12. 다음 액션
- 다음 배포에서 Datadog Continuous Profiler 활성화 여부 검토
-
Allocated MemoryProfile을 통해 메서드별 객체 할당량 추적 - Heap Used의 GC 이후 baseline이 장기간 계속 상승하는지 모니터링
-
Xmx=2048MiB대비 Container Limit2500MiB가 충분한지 검토 -
memory.events max증가 추이를 Monitor로 추가 검토 - 동일 현상 재발 시 runtime-id 기준으로 Heap / Eden / GC / RSS를 같은 시간축에서 비교
용어 정리
| 용어 | 의미 |
| Heap | Java가 객체와 데이터를 저장하는 작업 공간 |
| Heap Used | Heap 중 현재 실제 객체가 사용하고 있는 영역 |
| Heap Committed | JVM이 OS로부터 확보해 놓은 Heap 공간. 실제 사용량보다 클 수 있음 |
| Eden | 새로 생성된 대부분의 객체가 처음 들어가는 Young 영역 |
| Minor GC | Young 영역의 불필요한 객체를 정리하는 GC |
| Major GC | 오래 살아남은 객체가 위치하는 Old 영역까지 대상으로 하는 상대적으로 무거운 GC |
| RSS | 운영체제가 보는 프로세스의 실제 물리 메모리 점유량 |
| Memory Leak | 필요 없어진 객체가 해제되지 않아 실제 사용 메모리가 계속 누적되는 현상 |
| Profiler | 어떤 코드가 CPU나 메모리 Allocation을 많이 발생시키는지 코드 레벨에서 분석하는 도구 |
회고
이번 분석에서 가장 크게 배운 점은 Pod 메모리 사용률이 높다는 사실만으로 메모리 누수라고 판단하면 안 된다는 것이었다. 처음에는 Kubernetes의 Working Set만 보고 문제를 추적했지만, RSS → JVM Heap → Eden/Old Gen → GC → Heap Committed 순서로 계층을 하나씩 분리하니 실제 현상을 설명할 수 있었다. 또한 장기간 실행되는 JVM에서는 실제 객체 사용량이 GC로 감소하더라도, JVM이 한 번 확보한 메모리 영역을 유지하면서 컨테이너 관점의 메모리 사용률이 높은 상태로 남을 수 있다는 점을 확인했다. 다음에는 Profiler를 미리 활성화해 두어 '메모리가 왜 확장됐는가'에서 끝나는 것이 아니라 '어떤 코드가 Allocation Pressure를 만들었는가'까지 바로 추적할 수 있는 관측 환경을 만드는 것이 이번 회고의 다음 과제다.
12. 유사 사례와 공식 근거
이번 현상은 우리 서비스에만 나타나는 특이한 형태라기보다, 장시간 실행되는 Java/JVM 애플리케이션에서 실제로 자주 논의되는 메모리 패턴과 유사했다.
SSG.COM — 돌아오지 않는 메모리를 찾아서
SSG.COM의 운영 사례에서도 Kubernetes 위 Java 서비스의 메모리가 높은 수준까지 올라간 뒤 잘 내려오지 않아 처음에는 Heap Leak을 의심했다. 분석 과정에서 실제 객체가 계속 누적되는 문제뿐 아니라 JVM이 한 번 확보한 Committed Heap을 유지하는 현상과 Page Cache를 함께 확인했고, JVM/컨테이너 메모리 설정을 재검토했다. 우리 사례와 공통점은 Pod 메모리 수치가 높게 유지된다는 사실만으로 곧바로 메모리 누수라고 판단할 수 없었다는 점이다.
OpenJDK JEP 346 — G1의 미사용 Committed Memory 반환 개선
OpenJDK에는 아예 **Promptly Return Unused Committed Memory from G1**이라는 JEP가 존재한다.
G1 GC가 사용하지 않는 Java Heap을 OS에 더 적극적으로 반환하도록 개선하기 위한 기능으로, JVM이 확보한 Heap 메모리가 애플리케이션의 실제 사용량 감소와 동시에 바로 OS로 반환되는 것은 아니라는 점을 보여준다.
- 참고: OpenJDK JEP 346
Java 17 + Kubernetes 유사 사례
Java 17을 Kubernetes 환경에서 운영하면서 Heap Used는 GC 이후 감소하지만 Heap Committed가 높은 상태로 장시간 유지되는 현상을 질문한 사례도 확인할 수 있었다. G1의 Heap 축소와 Periodic GC 동작이 주요 논점이었다.
우리 사례와 연결하면
sczw2,xxbz2,wr79l세 Pod 모두 서로 다른 날짜에 Young 영역 증가 → Minor GC → Heap Committed 확대 → RSS 상승 후 유지라는 유사한 흐름을 보였다. 따라서 현재까지의 관측은 특정 Pod에서 우연히 발생한 단발성 메모리 누수보다는 JVM의 Heap 확장 및 메모리 유지 동작과 더 잘 맞는다. 다만 어떤 코드가 순간적인 객체 할당을 많이 만들었는지까지 특정하려면 다음 재현 시 Allocation Profiler/JFR 등의 추가 수집이 필요하다.