본문 바로가기

Programming/JAVA

자바 서버가 흔적도 없이 사라졌다: OOM Killer와 JVM의 메모리 치킨게임

추천캐릭터 2026. 7. 19. 15:00
728x90

자바 서버가 흔적도 없이 사라졌다: OOM Killer와 JVM의 메모리 치킨게임

새벽 3시.

모니터링 알림이 울렸다.

Spring Boot application is down

서버에 접속해 보니 EC2는 살아 있다.

Nginx도 살아 있다.

MariaDB도 숨은 쉬고 있다.

그런데 자바 프로세스만 사라졌다.

애플리케이션 로그의 마지막 줄도 평범하다.

INFO  Request completed successfully

유언도 없다.

스택 트레이스도 없다.

그냥 증발했다.

이럴 때 개발자는 대개 이렇게 말한다.

“자바가 또 메모리를 다 먹었네.”

반은 맞고 반은 틀리다.

자바가 스스로 OutOfMemoryError를 내고 쓰러졌을 수도 있지만, Linux 커널이 보다 못해 자바 프로세스를 직접 제거했을 수도 있다.

두 사건은 이름은 비슷하지만 수사 방법이 완전히 다르다.

결론부터

메모리 장애는 크게 두 종류로 나눠야 한다.

Java OutOfMemoryError

JVM이 필요한 메모리를 할당하지 못해 발생한다.

java.lang.OutOfMemoryError: Java heap space

Linux OOM Killer

운영체제 전체 또는 컨테이너의 메모리가 부족해 Linux 커널이 프로세스를 강제로 종료한다.

Out of memory: Killed process 1234 (java)

Oracle Java 문서는 OutOfMemoryError를 JVM이 객체를 할당할 수 없고 GC로도 추가 메모리를 확보하지 못할 때 발생하는 오류로 정의한다. Linux 커널은 회수 가능한 메모리가 부족해지면 시스템 생존을 위해 OOM Killer로 프로세스를 선택해 종료할 수 있다.

비유하면 다음과 같다.

Java OOM:
자바가 자기 방 안에서 짐에 깔려 쓰러진다.

Linux OOM Killer:
집 전체가 무너질 것 같자 집주인이 자바를 창문 밖으로 던진다.

사건 1: Java가 직접 남긴 유서

Java OutOfMemoryError가 발생하면 보통 애플리케이션 로그에 흔적이 남는다.

java.lang.OutOfMemoryError: Java heap space

또는 다음과 같은 메시지가 나타날 수 있다.

java.lang.OutOfMemoryError: Metaspace
java.lang.OutOfMemoryError: Direct buffer memory
java.lang.OutOfMemoryError: unable to create native thread
GC overhead limit exceeded

모두 메모리 문제지만 범인은 다르다.

Java heap space

객체가 저장되는 Heap 영역이 부족한 경우다.

원인은 다음과 같다.

  • 무한히 증가하는 컬렉션
  • 캐시 만료 정책 부재
  • 대용량 조회 결과를 한 번에 메모리에 적재
  • 파일 전체를 byte 배열로 읽음
  • 객체 참조가 해제되지 않는 메모리 누수
  • Heap 최대 크기가 지나치게 작음

Metaspace

클래스 메타데이터가 저장되는 네이티브 메모리 영역이 부족한 경우다.

동적으로 클래스를 반복 생성하거나 클래스 로더가 해제되지 않을 때 발생할 수 있다.

Direct buffer memory

NIO, Netty, WebClient 등의 직접 메모리 사용량이 한도를 넘긴 경우다.

Heap 사용률이 낮다고 안심했는데 프로세스가 죽었다면 이쪽을 의심해야 한다.

Unable to create native thread

새 스레드를 생성할 운영체제 자원이나 네이티브 메모리가 부족한 경우다.

스레드 풀을 무한정 늘렸거나 스레드가 반환되지 않는 상황에서 발생할 수 있다.

사건 2: Linux가 자바를 강제 퇴거시켰다

Linux OOM Killer가 동작하면 자바 애플리케이션 로그에는 아무것도 남지 않을 수 있다.

커널이 JVM에 “종료 준비하세요”라고 정중하게 말하지 않기 때문이다.

그냥 프로세스를 제거한다.

먼저 커널 로그를 확인한다.

sudo dmesg -T | grep -i -E 'out of memory|killed process|oom-killer'

systemd 환경에서는 다음 명령도 사용할 수 있다.

sudo journalctl -k | grep -i -E 'out of memory|killed process|oom'

다음과 같은 로그가 나오면 Linux OOM Killer 사건이다.

Out of memory: Killed process 1842 (java)
total-vm:6291456kB, anon-rss:2810240kB

Linux 커널은 OOM 상황에서 휴리스틱을 이용해 종료할 프로세스를 선택한다. 반드시 메모리를 가장 많이 사용한 프로세스만 선택하는 것은 아니지만, 큰 자바 프로세스는 꽤 눈에 잘 띄는 용의자다.

Heap이 1GB인데 왜 프로세스는 1.5GB를 먹을까

많이 하는 착각이 있다.

-Xmx1g로 설정했으니 자바는 최대 1GB만 쓰겠지?

아니다.

-Xmx는 JVM 전체 메모리가 아니라 Java Heap의 최대 크기다.

실제 자바 프로세스가 사용하는 메모리는 대략 다음 항목으로 구성된다.

전체 프로세스 메모리
= Java Heap
+ Metaspace
+ Code Cache
+ Direct Memory
+ Thread Stack
+ GC 자료구조
+ JNI와 네이티브 라이브러리
+ JVM 자체 메모리

예를 들어 다음과 같이 설정했다고 가정한다.

-Xmx1024m

Heap은 최대 1GB지만 다음 메모리가 별도로 추가된다.

  • Metaspace 150MB
  • Direct Memory 200MB
  • 스레드 스택 200MB
  • Code Cache와 JVM 내부 메모리 150MB

그러면 전체 RSS는 1.7GB 근처까지 올라갈 수 있다.

메모리 2GB짜리 EC2에서 MariaDB, Nginx, Java를 함께 실행하고 있다면 분위기가 험악해진다.

세입자는 셋인데 냉장고는 하나다.

컨테이너에서는 호스트 메모리만 보면 안 된다

Docker나 Kubernetes에서는 호스트에 메모리가 남아 있어도 컨테이너 제한 때문에 프로세스가 종료될 수 있다.

예를 들어 컨테이너 제한이 1GB인데 JVM Heap을 1GB로 설정하면 문제가 생긴다.

컨테이너 제한: 1GB
Java Heap 최대: 1GB
네이티브 메모리: 별도 필요

Heap이 컨테이너 전체를 차지해 버리면 Metaspace, Direct Memory, Thread Stack이 들어갈 자리가 없다.

Linux cgroup의 메모리 한도에 도달하고 회수도 불가능하면 해당 cgroup 안에서 OOM Killer가 실행될 수 있다.

컨테이너 제한을 확인한다.

docker inspect 컨테이너명

실시간 사용량은 다음 명령으로 확인한다.

docker stats

Kubernetes라면 다음 항목을 확인한다.

resources:
  requests:
    memory: "1Gi"
  limits:
    memory: "2Gi"

Pod 상태에 다음이 표시된다면 컨테이너 메모리 제한에 의해 종료됐을 가능성이 높다.

Reason: OOMKilled
Exit Code: 137

JVM 메모리 사용량 확인하기

실행 중인 자바 프로세스를 찾는다.

jps -l

Heap 정보를 확인한다.

jcmd <PID> GC.heap_info

클래스별 객체 수와 메모리 사용량을 확인한다.

jcmd <PID> GC.class_histogram

네이티브 메모리를 추적하려면 JVM 실행 시 NMT를 활성화한다.

-XX:NativeMemoryTracking=summary

이후 다음 명령을 사용할 수 있다.

jcmd <PID> VM.native_memory summary

운영체제 관점의 메모리도 함께 확인한다.

free -h
ps aux --sort=-%mem | head
top

Heap 그래프만 보고 “메모리는 충분한데?”라고 말하면 안 된다.

Heap 그래프는 건물 내부 한 층만 보여준다.

건물 전체가 기울어지고 있을 수도 있다.

OOM이 발생하기 전에 Heap Dump를 남겨라

메모리 누수는 현장에서 증거를 확보해야 한다.

사건이 끝난 뒤 “그때 객체가 많았던 것 같습니다”라고 말해 봐야 아무 소용이 없다.

JVM 옵션에 다음 설정을 추가한다.

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/myapp/heapdump.hprof

OutOfMemoryError 발생 후 프로세스를 종료하려면 다음 옵션도 고려할 수 있다.

-XX:+ExitOnOutOfMemoryError

전체 예시는 다음과 같다.

java \
  -Xms512m \
  -Xmx1024m \
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/var/log/myapp/heapdump.hprof \
  -XX:+ExitOnOutOfMemoryError \
  -jar app.jar

Oracle의 문제 해결 문서에서도 -XX:+HeapDumpOnOutOfMemoryError 옵션으로 OutOfMemoryError 발생 시 Heap Dump를 저장할 수 있다고 안내한다.

주의할 점도 있다.

Heap Dump 파일은 Heap 크기만큼 커질 수 있다.

디스크 여유 공간이 없으면 증거를 남기려다 디스크까지 가득 채우는 2차 사고가 발생한다.

df -h

저장 경로와 디스크 용량을 반드시 확인한다.

메모리 누수의 흔한 주범

만료되지 않는 캐시

private static final Map<String, Object> CACHE = new HashMap<>();

데이터를 계속 넣고 제거하지 않으면 캐시가 아니라 수집품 보관함이 된다.

크기 제한과 만료 정책을 둬야 한다.

대용량 조회 결과

List<Order> orders = orderRepository.findAll();

테이블에 수백만 건이 있는데 전부 가져오면 DB보다 JVM이 먼저 쓰러질 수 있다.

페이징이나 스트리밍을 사용한다.

대용량 파일 처리

byte[] data = file.getBytes();

1GB 파일을 한 번에 byte 배열로 만들면 1GB만 사용하는 것이 아니다.

복사 과정과 후속 처리에서 추가 메모리가 필요할 수 있다.

스트림 방식으로 처리한다.

무제한 비동기 작업

Executors.newCachedThreadPool();

트래픽이 몰릴 때 스레드가 계속 증가하면 스레드 스택과 작업 큐가 메모리를 잡아먹는다.

크기가 제한된 스레드 풀과 큐를 사용한다.

로그 또는 이벤트 누적

메모리에 로그, 이벤트, 요청 데이터를 모아 두고 한꺼번에 처리하는 구조도 위험하다.

“나중에 처리하자”는 데이터는 대개 나중에 서버를 처리한다.

무조건 Xmx를 키우면 해결될까

메모리 부족을 만나면 가장 먼저 나오는 처방은 이것이다.

-Xmx2g

그리고 다음 장애에서는 이렇게 바뀐다.

-Xmx4g

계속 키우다 보면 서버와 개발자의 요구사항이 비슷해진다.

둘 다 더 큰 집을 원한다.

하지만 메모리 누수가 원인이라면 Heap을 키우는 것은 장애 시점을 늦출 뿐이다.

물론 Heap이 실제 트래픽에 비해 너무 작다면 증설이 맞다.

중요한 것은 원인을 구분하는 것이다.

  • 정상적인 데이터 증가라면 메모리 증설
  • 누수라면 객체 참조 구조 수정
  • 대용량 처리라면 스트리밍·페이징 적용
  • 스레드 과다라면 스레드 풀 제한
  • Direct Memory 문제라면 네트워크 버퍼와 직접 메모리 분석
  • 컨테이너 제한 문제라면 Heap과 전체 메모리 예산 재설계

2GB 서버의 현실적인 메모리 예산 예시

EC2 메모리가 2GB이고 Nginx와 Spring Boot를 함께 운영한다고 가정해 보자.

대략 다음처럼 예산을 잡을 수 있다.

운영체제와 파일 캐시: 400~600MB
Nginx 및 기타 프로세스: 100~200MB
Spring Boot 전체: 1.0~1.2GB
비상 여유 공간: 200MB 이상

이 환경에서 -Xmx1536m를 설정하면 위험할 수 있다.

Heap 외 메모리와 운영체제가 사용할 공간이 거의 남지 않기 때문이다.

보다 보수적으로 다음과 같이 시작할 수 있다.

-Xms512m
-Xmx768m

그다음 실제 트래픽에서 다음 지표를 관찰한다.

  • Heap 사용량
  • GC 일시정지 시간
  • 프로세스 RSS
  • 스레드 수
  • Direct Memory
  • 시스템 available memory
  • Swap 사용량
  • 컨테이너 working set

정답 숫자는 서버마다 다르다.

중요한 것은 Xmx = 서버 메모리라는 무모한 계산을 하지 않는 것이다.

장애 발생 시 수사 순서

1단계: 자바 로그 확인

grep -i -E 'OutOfMemoryError|Java heap space|Metaspace' application.log

로그가 있다면 JVM 내부 OOM 가능성이 높다.

2단계: 커널 로그 확인

sudo dmesg -T | grep -i -E 'out of memory|killed process|oom'

Killed process ... java가 있다면 Linux OOM Killer 사건이다.

3단계: 컨테이너 상태 확인

docker inspect 컨테이너명

Kubernetes에서는 다음 명령을 사용한다.

kubectl describe pod <POD_NAME>

4단계: Heap Dump 확인

heapdump.hprof

Eclipse Memory Analyzer, VisualVM 등의 도구로 큰 객체와 GC Root를 분석한다.

5단계: 전체 메모리 예산 재계산

Heap만 보지 말고 RSS, Direct Memory, Metaspace, Thread Stack, 다른 프로세스까지 포함한다.

마무리

자바 프로세스가 사라졌을 때 무조건 “Heap이 부족했다”고 결론 내리면 안 된다.

판별 기준은 간단하다.

Java 로그에 OutOfMemoryError가 남음
→ JVM 내부 메모리 문제를 우선 조사

Java 로그는 조용하고 커널 로그에 Killed process가 남음
→ Linux 또는 cgroup OOM Killer를 조사

그리고 운영 서버에는 최소한 다음 옵션을 넣어 두자.

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/myapp/heapdump.hprof

새벽 3시에 자바가 흔적도 없이 사라졌다면 초자연 현상은 아니다.

범인은 대개 Heap, Direct Memory, 스레드, 컨테이너 제한 중 하나다.

그리고 Linux OOM Killer는 이미 범인을 체포한 뒤다.

문제는 그 과정에서 용의자와 서버를 함께 때려눕힌다는 것뿐이다.

728x90