본문 바로가기
Framework/Spring Boot

[Spring Boot] 힙 덤프(Heap Dump) 분석: 메모리 누수(OOM)의 범인을 체포하라

반응형

자바(Java)의 가장 큰 장점 중 하나는 개발자가 메모리(RAM) 관리에 신경 쓰지 않아도 된다는 것입니다. 객체를 다 쓰고 나면 가비지 컬렉터(Garbage Collector, GC)라는 청소부가 돌아다니며 쓰레기를 싹 치워주기 때문이죠. 그런데 여러분이 만든 쇼핑몰 서버가 일주일만 지나면 서서히 메모리가 차오르더니 java.lang.OutOfMemoryError (OOM)을 뱉으며 장렬히 전사하는 현상이 발생했습니다. 청소부(GC)가 왜 쓰레기를 치우지 못했을까요? 개발자가 코드 어딘가에서 ListMap 같은 저장소에 객체를 계속 쌓아두기만 하고 빼지 않으면(참조를 유지하면), GC는 "어? 이 객체는 아직 쓰고 있는 중요한 데이터인가 보네? 치우지 말아야지!" 하고 냅두게 됩니다. 이것이 바로 백엔드 개발자들의 최악의 악몽, '메모리 누수(Memory Leak)'입니다. 수만 줄의 소스코드에서 도대체 어떤 녀석이 메모리를 파먹고 있는지 어떻게 찾을까요?

이번 포스팅에서는 "서버가 죽기 직전 남기는 최후의 유언장!" 힙 덤프(Heap Dump)를 추출하고 Eclipse MAT 도구로 메모리 누수의 범인을 과학적으로 추적하는 실무 트러블슈팅 기법에 대하여 다뤄보겠습니다.

💀 1. 힙 덤프(Heap Dump) 자동 추출 세팅

서버가 메모리 부족으로 죽는 순간은 예고 없이 찾아옵니다. 죽고 나서 "왜 죽었지?" 하고 들어가 보면 이미 JVM 메모리는 날아가고 빈 껍데기만 남아있습니다. 따라서 우리는 스프링 부트 서버를 기동시킬 때, "너 OOM 터져서 죽을 때, 죽더라도 현재 메모리에 떠 있는 모든 객체 정보를 파일로 남기고 죽어라!"라는 유언장 작성 명령어를 줘야 합니다.

# 서버 기동 시 필수로 넣어야 하는 JVM 옵션
java -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/var/log/myapp-dump.hprof \
     -jar app.jar

이제 서버가 메모리 누수로 터지는 순간, 지정된 경로에 myapp-dump.hprof라는 엄청난 용량(수 GB)의 덤프 파일이 생성됩니다. 이 파일을 FTP 등으로 내 로컬 PC로 다운로드합니다.


🕵️ 2. Eclipse MAT으로 범인 검거하기

덤프 파일은 사람이 텍스트로 읽을 수 없습니다. 이를 시각화해서 분석해 주는 전 세계 백엔드 개발자들의 표준 도구인 Eclipse MAT (Memory Analyzer Tool)을 설치하고 .hprof 파일을 열어봅니다.

1단계: Leak Suspects Report (누수 의심 보고서)
파일을 열면 MAT이 자동으로 "내가 보니까 이 녀석이 범인인 것 같아!"라며 원형 차트(Pie Chart)를 하나 보여줍니다. "Problem Suspect 1: The thread java.lang.Thread @ 0x7f... occupies 85.3% of heap..." 이런 식으로 전체 메모리의 대부분을 처묵처묵하고 있는 객체의 이름과 크기를 친절하게 알려줍니다.

2단계: Dominator Tree (포식자 트리 확인)
상단의 [Dominator Tree] 탭을 클릭하면, 객체들의 크기(Retained Heap) 순서대로 엑셀 표처럼 정렬해서 볼 수 있습니다. 최상단에 있는 객체(예를 들어 java.util.concurrent.ConcurrentHashMap)의 트리를 계속 펼치고 파고들어 갑니다. 그러다 보면 우리가 짠 코드 패키지가 보입니다!

"아! 내가 만든 UserSessionCache 클래스 안에 있는 static Map 변수에 로그인한 유저 정보를 계속 넣기만 하고, 로그아웃할 때 remove()를 안 해줘서 메모리가 1기가나 차올랐구나!"


🔧 3. 실무에서 자주 터지는 메모리 누수 원인 TOP 3

  • 1. Static 컬렉션 (최악의 범인): static Liststatic Map을 캐시 용도로 쓰면서, 데이터를 지우는 로직을 빼먹은 경우입니다. 애플리케이션 수명과 동일하므로 GC가 절대 못 지웁니다. 무조건 외부 Redis 캐시나 Guava Cache(TTL 설정 지원)를 써야 합니다.
  • 2. 무한 스크롤(Paging) 미적용 조회: JPA로 게시글을 조회할 때 findAll()을 무심코 때렸는데, 데이터가 100만 건이라 100만 개의 엔티티(Entity)가 한 번에 메모리에 올라오면서 즉사하는 경우입니다.
  • 3. 스트림(Stream) 미종료: 파일이나 네트워크 통신을 열어두고(I/O), 에러가 터졌을 때 finally 블록이나 try-with-resources를 쓰지 않아 close()를 안 해준 경우입니다.

🎯 4. 마무리 및 다음 단계

지금까지 원인을 알 수 없어 서버 재시작 버튼만 누르게 만들던 메모리 누수(OOM)의 공포에서 벗어나, JVM 옵션으로 힙 덤프를 추출하고 Eclipse MAT을 통해 코드 레벨의 정확한 버그 위치를 과학적으로 찾아내는 시니어급 트러블슈팅 역량에 대해 다루어 보았습니다. 이제 여러분은 더 이상 "왜 죽었지?" 하고 떨지 않아도 됩니다.

메모리 누수 버그까지 다 고치고 나니, 대표님이 묻습니다. "우리 내일모레 대규모 TV 광고 나갈 건데, 동시 접속자 1만 명 몰려와도 우리 서버 안 터지고 버틸 수 있지?" 개발자는 동공이 흔들립니다. 로컬 환경에서 혼자 클릭해 본 것이 전부인데, 1만 명이 들어올 때 서버가 버틸지 어떻게 확신할 수 있을까요? 오픈 당일 트래픽 폭탄을 맞고 DB 커넥션이 마르고 서버가 죽어버리는 대참사를 막으려면, "진짜 트래픽이 오기 전에, 가짜 트래픽 1만 개를 쏴서 서버를 극한까지 괴롭혀보는 훈련"이 필요합니다. 이어지는 28단계 포스팅에서는 "오픈 당일 서버 터짐 방지 위원회!" Apache JMeter를 활용한 API 부하 테스트(Load Testing)와 병목(Bottleneck) 구간 식별 비법에 대해 아주 뼈 때리게 파헤쳐 보겠습니다!

반응형