장애 대응 실무 가이드 · Part 7

메모리 문제

메모리를 늘리기 전에 무엇이 쌓이고 있는지 확인하기

작성 기준2026년 7월

이 파트에서 다루는 내용

OOM은 한 종류가 아니다늘어남과 샘GC 로그힙 덤프전형적 누수
01

OutOfMemoryError는 한 종류가 아닙니다

OOM이 났다는 말만으로는 아무것도 알 수 없습니다. 뒤에 붙는 문구에 따라 원인도 대응도 완전히 다릅니다.

메시지를 정확히 확인하는 것이 첫걸음입니다. 로그에서 이 부분을 그대로 복사해 두면 이후 판단이 빨라집니다.

Java heap space
가장 흔함

힙이 부족합니다. 정말 부족한 것인지 누수인지 가려야 합니다. 대량 조회로 한 번에 많이 올린 경우도 여기입니다.

GC overhead limit exceeded
누수 신호

GC를 계속 도는데 회수되는 양이 거의 없다는 뜻입니다. 누수일 가능성이 높습니다.

Metaspace
클래스 영역

힙과 무관합니다. 클래스가 계속 로드되는 경우로, 재배포를 반복하거나 동적 클래스 생성이 있을 때 납니다.

unable to create new native thread
스레드

메모리가 아니라 스레드 개수 문제입니다. OS 한계이거나 스레드가 종료되지 않고 쌓이는 것입니다.

Direct buffer memory
힙 밖

NIO 다이렉트 버퍼입니다. 힙을 늘려도 해결되지 않습니다. 오히려 힙을 늘리면 남는 공간이 줄어 악화됩니다.

컨테이너 강제 종료
OOM 아님

컨테이너 메모리 한계를 넘어 프로세스가 죽으면 OOM 로그도 없이 사라집니다. 애플리케이션 로그가 중간에 끊겼다면 이걸 의심합니다.

02

늘어나는 것과 새는 것은 다릅니다

메모리 사용량이 올라간다고 문제는 아닙니다. Java는 원래 쓸 수 있는 만큼 쓰다가 GC로 회수합니다. 톱니 모양으로 올라갔다 내려가면 정상입니다.

판단 기준은 GC 이후의 값입니다. GC를 돌고도 내려오지 않고 바닥이 계속 올라가면 누수입니다.

정상
톱니 모양

사용량이 올라갔다가 GC 후 원래 수준으로 내려옵니다. 바닥값이 일정합니다.

누수
우상향

GC 후 바닥값이 계속 올라갑니다. 회수되지 않는 객체가 쌓이고 있습니다. 결국 OOM에 도달합니다.

일시적 급증
설계 문제

특정 기능 실행 시에만 치솟습니다. 대량 조회를 한 번에 메모리에 올리는 경우입니다. 누수는 아니지만 고쳐야 합니다.

재기동 주기
단서

재기동 후 며칠 뒤 항상 문제가 난다면 누수가 거의 확실합니다. 주기가 일정한 것이 결정적 힌트입니다.

현재 힙 상태 확인bash
# 힙 사용량 요약
jcmd <PID> GC.heap_info

# 영역별 사용량 (몇 초 간격으로 반복해 추세를 봅니다)
jstat -gcutil <PID> 5000 12

# 출력 예: O 열이 Old 영역 사용률(%)
#   GC 후에도 O 값이 계속 올라가면 누수 신호입니다

jstat 을 5초 간격으로 1분간 돌려 Old 영역 사용률의 추세를 봅니다. Full GC가 도는데도 값이 안 내려가면 그 자리에서 누수를 판단할 수 있습니다.

03

GC 로그는 미리 켜 둬야 합니다

GC 로그가 있으면 언제부터 회수가 안 되기 시작했는지, Full GC가 얼마나 자주 얼마나 오래 돌았는지가 전부 나옵니다. 메모리 문제 진단에서 가장 유용한 자료입니다.

문제는 장애가 난 뒤에 켜면 이번 장애에는 쓸 수 없다는 것입니다. 운영 서버에는 평소에 켜 두는 것이 맞습니다. 부하가 거의 없습니다.

  • `HeapDumpOnOutOfMemoryError` 를 켜 두면 OOM 발생 순간의 힙이 자동으로 저장됩니다. 사후 분석에서 결정적입니다.
  • 덤프 경로에 힙 크기만큼의 여유 공간이 있어야 합니다. 없으면 저장에 실패하고 그 사실도 모르고 지나갑니다.
  • GC 로그에서 볼 것은 세 가지입니다. Full GC 빈도, 한 번에 걸리는 시간, 그리고 GC 후 남은 사용량의 추세입니다.
  • Full GC가 수 초씩 걸리면 그동안 애플리케이션이 멈춥니다. Part 3의 멈춤 증상이 이것 때문일 수 있습니다.
GC 로그 옵션 (JDK 버전에 따라 다릅니다)bash
# JDK 9 이상
-Xlog:gc*:file=/app/logs/gc.log:time,uptime:filecount=5,filesize=20M

# JDK 8
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-Xloggc:/app/logs/gc.log \
-XX:+UseGCLogFileRotation \
-XX:NumberOfGCLogFiles=5 \
-XX:GCLogFileSize=20M

# 함께 켜 두면 좋은 것 (버전 공통)
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/app/dumps

JDK 9에서 로깅 방식이 통합되면서 옵션이 완전히 바뀌었습니다. 8 기준 옵션을 9 이상에 쓰면 기동 자체가 실패하거나 경고가 납니다. 운영 중인 JDK 버전을 먼저 확인합니다.

04

힙 덤프는 무엇이 쌓였는지 보여 줍니다

GC 로그로 누수라는 것까지는 알 수 있지만, 무엇이 쌓이고 있는지는 힙 덤프를 봐야 압니다.

Part 2에서 다룬 대로 힙 덤프는 뜨는 데 비용이 큽니다. 메모리 문제가 확인됐을 때만 뜹니다.

  • 덤프 파일은 힙 크기만 합니다. 8GB 힙이면 8GB 파일이고, 이걸 받아서 열 PC의 메모리도 그만큼 필요합니다.
  • 뜨는 동안 애플리케이션이 멈춥니다. 수십 초 이상 걸릴 수 있으므로 서비스 영향을 감안합니다.
  • 분석은 전용 도구가 필요합니다. 무엇이 메모리를 붙들고 있는지 참조 관계를 따라가는 기능이 핵심입니다.
  • 고객사에서 덤프를 반출할 수 없는 경우가 많습니다. 개인정보가 그대로 들어 있기 때문입니다. 이때는 class_histogram 결과만 받는 것도 방법입니다.
힙 덤프 확보bash
# 뜨기 전에 디스크 여유 확인 (힙 크기 이상 필요)
df -h /app/dumps

# 살아있는 객체만 (GC 후 상태, 파일이 작아집니다)
jcmd <PID> GC.heap_dump /app/dumps/heap.hprof

# 클래스별 인스턴스 수와 크기만 빠르게 (덤프 없이)
jcmd <PID> GC.class_histogram | head -30

class_histogram 이 유용합니다. 덤프 파일 없이 어떤 클래스가 몇 개나 있는지 바로 보여 줍니다. 예상보다 많은 객체가 상위에 있으면 그것이 단서입니다. 힙 덤프를 뜰지 판단하기 전에 먼저 실행해 볼 만합니다.

05

누수는 대개 몇 가지 패턴입니다

정적 컬렉션
가장 흔함

static 으로 선언한 Map이나 List에 계속 넣기만 하고 지우지 않습니다. 애플리케이션이 살아 있는 동안 회수되지 않습니다.

무제한 캐시
설계 누락

직접 만든 캐시에 최대 크기나 만료 시간이 없으면 그냥 누수입니다. 캐시 라이브러리를 쓰더라도 설정을 확인합니다.

ThreadLocal 미정리
풀 환경

스레드 풀에서는 스레드가 재사용되므로 ThreadLocal 값이 남습니다. 사용 후 제거하지 않으면 계속 쌓입니다.

리소스 미반환
연결·스트림

커넥션, 스트림, 파일 핸들을 닫지 않는 경우입니다. 메모리와 함께 Part 8의 자원 고갈로도 이어집니다.

리스너 등록 해제 누락
이벤트

등록만 하고 해제하지 않으면 등록한 객체가 계속 참조됩니다.

대량 조회
누수 아님

수십만 건을 한 번에 메모리에 올리는 경우입니다. 누수는 아니지만 OOM의 직접 원인이 됩니다. DB 코스 Part 9의 페이징으로 해결합니다.

메모리를 늘리는 것에 대해

OOM이 나면 힙을 늘리는 것이 가장 빠른 복구입니다. 실제로 그렇게 해야 할 때도 있습니다. 다만 누수가 원인이라면 발현 시점이 미뤄질 뿐이고, 주기가 길어져서 원인 추적은 오히려 어려워집니다. Part 2에서 다룬 대로 임시 조치로 기록하고 재검토 기한을 붙입니다.

체크

이 파트 완료 기준