왜 컨테이너에서는 Java 메모리 계산이 달라지는가

Java 애플리케이션을 가상 서버에서만 운영할 때는 보통 전체 장비 메모리와 -Xmx 값을 비교하면 큰 문제가 없었습니다. 하지만 Docker, Kubernetes, PaaS 환경에서는 컨테이너에 걸린 메모리 제한이 실제 운영 한계입니다. 여기서 자주 생기는 실수는 힙만 보고 메모리를 판단하는 것입니다. Java 프로세스는 힙 외에도 메타스페이스, 스레드 스택, 직접 메모리, JIT 코드 캐시, GC 내부 구조, 네이티브 라이브러리, mmap 파일 버퍼를 사용합니다. 그래서 컨테이너 제한이 1GiB인데 -Xmx를 900m로 주면 여유가 있어 보이지만 실제로는 OOMKilled가 발생할 수 있습니다.

운영 목표는 단순히 힙을 크게 잡는 것이 아니라 서비스의 요청량, 객체 생존 시간, 외부 라이브러리의 네이티브 메모리 사용량을 고려해 컨테이너 제한 안에서 안정적인 예산을 나누는 것입니다. 특히 Netty, gRPC, 압축 라이브러리, 이미지 처리, 파일 업로드, 데이터베이스 드라이버처럼 직접 메모리나 네이티브 버퍼를 쓰는 구성에서는 힙 사용률이 낮아도 컨테이너 메모리가 계속 올라갈 수 있습니다.

기본 메모리 예산 잡기

첫 번째 기준은 컨테이너 메모리 제한의 60~70%만 힙으로 배정하는 것입니다. 나머지 30~40%는 힙 밖의 JVM 영역과 OS 버퍼, 순간적인 피크를 위해 남겨둡니다. 트래픽이 낮고 라이브러리가 단순한 API 서버는 70%까지도 가능하지만, 스레드가 많거나 대용량 파일 처리, WebFlux/Netty, 메시지 브로커 클라이언트를 많이 쓰는 서비스는 55~65%에서 시작하는 편이 안전합니다.

-Xmx를 고정으로 박아도 되지만 컨테이너 크기가 배포 환경마다 다르면 MaxRAMPercentage를 쓰는 편이 관리하기 쉽습니다. 다만 이 값은 힙만 결정합니다. 직접 메모리, 메타스페이스, 스택까지 자동으로 안전하게 제한해 주는 설정은 아니므로 함께 상한을 정해야 합니다. 특히 MaxDirectMemorySize를 지정하지 않으면 프레임워크와 JDK 동작에 따라 예상보다 큰 직접 메모리가 사용될 수 있습니다.

JAVA_TOOL_OPTIONS=
  -XX:InitialRAMPercentage=20
  -XX:MaxRAMPercentage=65
  -XX:MaxMetaspaceSize=256m
  -XX:MaxDirectMemorySize=256m
  -Xss512k
  -Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20m
  -XX:+HeapDumpOnOutOfMemoryError
  -XX:HeapDumpPath=/var/log/app/heapdump.hprof
  -XX:ErrorFile=/var/log/app/hs_err_pid%p.log

위 예시는 1GiB~2GiB급 컨테이너에서 출발점으로 쓸 수 있는 형태입니다. 운영에서는 컨테이너 limit, 평균 RSS, 피크 RSS, GC 후 힙 사용량, 스레드 수를 보고 조정해야 합니다. Xss를 낮추면 스레드당 스택 메모리를 줄일 수 있지만, 재귀 호출이 깊거나 프레임워크 호출 체인이 긴 서비스에서는 StackOverflowError 위험이 있으므로 부하 테스트에서 확인해야 합니다.

GC 로그는 장애가 난 뒤가 아니라 항상 남긴다

메모리 장애를 조사할 때 가장 먼저 필요한 자료는 GC 로그입니다. GC 로그가 없으면 힙이 부족했는지, 객체가 오래 살아남았는지, Full GC가 반복됐는지, safepoint 정지가 길었는지 추측으로 판단하게 됩니다. 운영 서비스는 기본적으로 회전 파일 형태의 GC 로그를 남겨야 합니다. 로그량을 걱정해 완전히 끄는 것보다 파일 개수와 크기를 제한하는 편이 훨씬 낫습니다.

GC 로그에서 먼저 볼 항목은 세 가지입니다. 첫째, Young GC 후 old 영역이 계속 증가하는지 확인합니다. 둘째, Full GC 또는 concurrent cycle이 너무 자주 발생하는지 봅니다. 셋째, GC 이후에도 힙 사용량이 컨테이너 예산에 가깝게 남는지 확인합니다. GC 이후 사용량이 계속 높다면 캐시 상한, 세션 저장 방식, 배치 로딩 크기, ORM 영속성 컨텍스트 범위를 의심해야 합니다.

힙 밖 메모리는 Native Memory Tracking으로 확인한다

컨테이너가 OOMKilled 됐는데 Java의 OutOfMemoryError 로그가 없다면 힙 밖 메모리나 커널의 강제 종료를 의심해야 합니다. 이때 RSS와 JVM 내부 지표를 함께 봐야 합니다. 운영 중 원인 확인을 위해서는 Native Memory Tracking을 켜 두면 도움이 됩니다. NMT는 약간의 오버헤드가 있으므로 모든 서비스에 무조건 detail로 켜기보다, 장애 가능성이 높은 서비스나 점검 기간에는 summary부터 사용하는 방식이 현실적입니다.

# 시작 옵션에 추가
-XX:NativeMemoryTracking=summary

# 실행 중 점검
PID=$(pgrep -f app.jar)
jcmd $PID VM.native_memory summary
jcmd $PID GC.heap_info
jcmd $PID VM.metaspace
jstat -gcutil $PID 1000 10

# 컨테이너 관점 RSS 확인
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.max

jcmd 결과에서 Java Heap은 안정적인데 Class, Thread, Code, GC, Internal, Other 항목이 증가한다면 힙만 늘리는 방식으로 해결되지 않습니다. 클래스 로딩이 계속 증가하면 동적 프록시, 스크립트 엔진, 플러그인 구조, 잘못된 ClassLoader 보관을 봐야 합니다. Thread 항목이 크면 스레드풀 개수와 Xss 설정을 확인합니다. Other나 Internal이 계속 늘면 직접 메모리, JNI, 압축/암호화 라이브러리, 파일 mmap 사용을 의심해야 합니다.

컨테이너 종료와 Java OOM은 다르게 대응한다

Java 힙이 부족하면 보통 java.lang.OutOfMemoryError가 남고 heap dump가 생성됩니다. 반면 컨테이너 메모리 limit을 넘으면 OS가 프로세스를 죽이기 때문에 애플리케이션 로그가 갑자기 끊길 수 있습니다. Kubernetes에서는 마지막 상태가 OOMKilled로 표시됩니다. 두 상황은 대응이 다릅니다. Java OOM이면 객체 보관 경로와 힙 덤프 분석이 중심이고, OOMKilled이면 컨테이너 limit, RSS, 힙 외 영역, sidecar, 로그 버퍼까지 함께 봐야 합니다.

  • 힙 OOM: heap dump를 열어 가장 큰 retained size, 캐시, 컬렉션, 요청별 임시 객체를 확인합니다.
  • Metaspace OOM: 동적 클래스 생성, ClassLoader 누수, 과도한 프록시 생성을 점검합니다.
  • Direct buffer OOM: Netty, NIO, 파일 업로드, 대용량 응답 스트리밍 설정을 확인합니다.
  • OOMKilled: JVM 로그가 없어도 컨테이너 이벤트, cgroup 메모리, RSS 추이를 먼저 확인합니다.

운영 적용 순서

처음부터 완벽한 값을 찾으려고 하기보다 표준 시작값을 정하고 서비스별로 관측하면서 조정하는 방식이 좋습니다. 신규 Java API 서버라면 컨테이너 limit을 먼저 정하고 MaxRAMPercentage 60~65, MaxMetaspaceSize, MaxDirectMemorySize, GC 로그, heap dump 경로를 기본 템플릿에 넣습니다. 그 다음 부하 테스트에서 정상 요청, 느린 외부 API, 대량 조회, 파일 업로드 같은 피크 시나리오를 실행합니다. 테스트 중에는 GC 후 힙 사용량이 안정되는지, RSS가 limit의 80~85%를 오래 넘지 않는지, Full GC가 반복되지 않는지 확인합니다.

운영 배포 후에는 알림 기준도 분리해야 합니다. 힙 사용률만 보는 알림은 부족합니다. 컨테이너 RSS, GC pause p95, Full GC 횟수, old 영역 사용률, 스레드 수, direct memory 관련 지표를 함께 봐야 장애 전조를 잡을 수 있습니다. 또한 배포 직후 30분과 트래픽 피크 시간대의 그래프를 따로 비교하면 메모리 누수와 정상 캐시 워밍업을 구분하기 쉽습니다.

점검 체크리스트

  • 컨테이너 limit의 30% 이상을 힙 밖 영역과 피크 여유로 남겼는지 확인합니다.
  • MaxRAMPercentage만 쓰지 말고 Metaspace, DirectMemory, 스레드 스택 예산을 함께 정합니다.
  • GC 로그, heap dump, hs_err 파일 경로를 컨테이너 안에서 실제로 보존 가능한 위치로 지정합니다.
  • OOMKilled와 Java OutOfMemoryError를 같은 장애로 취급하지 말고 종료 원인별 조사 순서를 분리합니다.
  • 부하 테스트에서는 평균 사용량보다 피크 RSS, GC 후 old 영역, 긴 pause, 스레드 증가 추이를 우선 확인합니다.