[트러블슈팅] 컨테이너(Docker/Kubernetes) 환경에서의 JVM 메모리 제한 이슈

지난 포스팅에서는 마이크로서비스(MSA) 환경에서 필연적으로 발생하는 외부 API의 타임아웃과 서킷 브레이커 방어 전략에 대해 알아보았습니다. MSA 아키텍처의 도입과 함께 백엔드 생태계에 일어난 가장 큰 변화는 바로 애플리케이션을 도커(Docker)나 쿠버네티스(Kubernetes) 같은 컨테이너 환경에 배포하게 되었다는 점입니다.

컨테이너는 배포의 편의성과 확장성을 제공하지만, 기존의 거대한 물리 서버 환경에 익숙해져 있던 JVM(Java Virtual Machine)에게는 낯설고 위험한 환경이기도 합니다. 클라우드 환경으로 마이그레이션한 후, 애플리케이션 로그에 아무런 에러도 남기지 않은 채 파드(Pod)가 갑자기 재시작되는 현상을 겪어보셨나요? 이번 글에서는 컨테이너 환경에서 발생하는 치명적인 메모리 충돌 이슈와 실무 최적화 기법을 완벽히 해부해 보겠습니다.

1. JVM의 착각: 컨테이너의 벽을 보지 못하다

과거 Java 8 초기 버전(8u191 이전)까지 JVM은 자신이 컨테이너 안에 갇혀 있다는 사실을 인지하지 못했습니다. 컨테이너의 자원 할당량(Limit)을 읽는 것이 아니라, 컨테이너가 띄워진 호스트(Host) 물리 서버의 전체 자원을 자신의 것으로 착각하는 치명적인 문제가 있었습니다.

  • 재앙의 시나리오: 호스트 노드의 메모리가 32GB이고, 쿠버네티스에서 해당 파드(Pod)의 메모리 Limit을 1GB로 엄격하게 제한했다고 가정해 봅시다. JVM은 호스트의 32GB를 기준으로 힙(Heap) 메모리의 최대치(기본값 1/4)를 약 8GB로 멋대로 설정해 버립니다.
  • 애플리케이션이 구동되며 메모리 사용량이 1GB를 넘어가는 순간, 리눅스 커널(cgroup)은 제한을 위반한 이 프로세스를 무자비하게 강제 종료시켜 버립니다.

2. OOMKilled (OS) vs OutOfMemoryError (JVM)

이러한 현상을 마주했을 때 개발자는 에러의 주체를 명확히 구분해야 합니다.

  • java.lang.OutOfMemoryError: 앞서 다루었던 에러로, JVM 내부의 힙 메모리가 꽉 차서 JVM이 스스로 뱉어내는 에러입니다. 에러 로그가 남고, 설정해 둔 힙 덤프(Heap Dump) 파일도 정상적으로 추출됩니다.
  • OOMKilled (Exit Code 137): 파드(Pod)의 상태가 OOMKilled로 찍히면서 재시작되는 현상입니다. 이는 JVM의 힙이 꽉 찬 것이 아니라, JVM 프로세스 전체가 사용하는 실제 메모리(RSS)가 컨테이너 Limit을 초과하여 운영체제(OS/Docker)가 JVM의 목을 쳐버린 것(Kill-9)입니다. 유언(로그)도, 힙 덤프도 남기지 않고 즉사하기 때문에 트러블슈팅이 매우 까다롭습니다.

3. 실무 해결책 1: 컨테이너 인지(Container Support) 활성화

가장 근본적인 해결책은 JVM이 컨테이너의 제한(Limit)을 제대로 인식하게 만드는 것입니다.

  • 최신 Java 버전 사용: Java 11, 17, 21 등 최신 LTS 버전을 사용하면 기본적으로 UseContainerSupport 옵션이 활성화되어 있습니다. JVM이 영리하게 호스트 노드가 아닌 도커 컨테이너에 설정된 cgroup의 메모리 Limit을 읽어와서 힙 사이즈를 알아서 조절합니다.
  • 만약 레거시 시스템 문제로 Java 8을 유지해야 한다면, 최소한 8u191 버전 이상으로 패치하여 컨테이너 서포트 기능을 적용받아야 합니다.

4. 실무 해결책 2: 비율 기반 힙 사이즈 조절 (-XX:MaxRAMPercentage)

과거 물리 서버 환경에서는 힙 사이즈를 -Xmx2048m처럼 고정값(Hardcoding)으로 설정했습니다. 하지만 파드의 메모리 Limit을 동적으로 늘리거나 줄일 수 있는 쿠버네티스 환경에서는 고정값 설정이 오히려 독이 됩니다. 컨테이너 환경의 표준 설정은 다음과 같습니다.

-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0

  • MaxRAMPercentage=75.0: 컨테이너에 할당된 전체 메모리(Limit) 중 75%만을 JVM의 힙 메모리로 사용하겠다는 뜻입니다.
  • 왜 100%가 아닌 75%일까?: 힙 메모리 외에도 메타스페이스, 스레드 스택, 그리고 GC가 동작하기 위한 네이티브 메모리(Off-Heap) 등 여유 공간이 반드시 필요하기 때문입니다. 이를 100%로 꽉 채우면 여지없이 OS에 의해 OOMKilled를 당하게 됩니다. 실무에서는 보통 70~80% 사이로 마진을 둡니다.

결론: 인프라와 애플리케이션의 유기적 튜닝

컨테이너 기술은 애플리케이션 배포의 혁명을 가져왔지만, 동시에 개발자에게 OS 레벨의 자원 제한 구조(cgroup)를 이해할 것을 요구합니다. 파드가 픽픽 쓰러진다면 애플리케이션 로직을 의심하기 전에, JVM의 시작 옵션(Flag)이 컨테이너 환경에 맞게 진화(MaxRAMPercentage)되었는지 가장 먼저 점검해 보시기 바랍니다.

이제 서버의 안정성을 위협하는 거의 모든 트러블슈팅 사례를 다루었습니다. 다음 포스팅에서는 대망의 시리즈 마지막을 장식할 주제, 우리가 최적화한 시스템이 실제 운영 환경의 트래픽을 견딜 수 있는지 사전에 검증하는 ‘성공적인 Java 애플리케이션 성능 부하 테스트(Load Testing) 시나리오 설계’를 다루어 보겠습니다.

댓글 남기기