본문 바로가기
Framework/Spring Boot

[Spring Boot] 힙 덤프(Heap Dump) 분석 및 메모리 누수

자바(Java)의 가장 큰 장점 중 하나는 개발자가 메모리(RAM) 관리에 신경 쓰지 않아도 된다는 것입니다. 객체를 다 쓰고 나면 가비지 컬렉터(Garbage Collector, GC)라는 청소부가 돌아다니며 쓰레기를 싹 치워주기 때문이죠.
그런데 여러분이 만든 쇼핑몰 서버가 일주일만 지나면 서서히 메모리가 차오르더니 java.lang.OutOfMemoryError (OOM)을 뱉으며 장렬히 전사하는 현상이 발생했습니다. 청소부(GC)가 왜 쓰레기를 치우지 못했을까요?
개발자가 코드 어딘가에서 List나 Map 같은 저장소에 객체를 계속 쌓아두기만 하고 빼지 않으면(참조를 유지하면), 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 파일을 열어봅니다.

 

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

 

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

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

 

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

  • 1. Static 컬렉션 (최악의 범인): static List나 static 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만 개를 쏴서 서버를 극한까지 괴롭혀보는 훈련"이 필요합니다.

 

이번 설명에서는 "오픈 당일 서버 터짐 방지 위원회!" Apache JMeter를 활용한 API 부하 테스트(Load Testing)와 병목(Bottleneck) 구간 식별 비법에 대해 살펴보겠습니다.


여러분이 만든 수강신청 시스템이 내일 아침 9시에 오픈합니다. 개발자 본인이 로컬 환경에서 포스트맨(Postman)으로 /api/enroll API를 한 번 클릭했을 때는 응답 속도가 0.1초(100ms)로 완벽했습니다. 하지만 다음 날 아침 9시, 1만 명의 학생이 동시에 접속(새로고침 연타)을 시작하자마자 서버는 "Connection Timeout"을 뱉으며 장렬히 전사했습니다. 1만 개의 트래픽이 쏟아질 때 DB 커넥션 풀(HikariCP)이 버티는지, CPU가 몇 퍼센트까지 치솟는지, 톰캣(Tomcat)의 기본 쓰레드 200개가 부족하진 않은지 미리 검증하지 않은 대가입니다. 오픈 당일 사직서를 쓰고 싶지 않다면, "실제 유저 1만 명이 접속하는 것과 똑같은 환경을 시뮬레이션해서 가짜 트래픽 폭탄을 서버에 쏴보는 훈련"이 필수적입니다. 이 극한의 스트레스 테스트를 도와주는 전 세계 표준 도구가 바로 Apache JMeter(제이미터)입니다.

본 글에서는 "서버의 한계를 측정하고 병목(Bottleneck)을 찾아내는 부하 테스트(Load Testing) 완벽 가이드"에 대하여 딥다이브 해보겠습니다.

1. 부하 테스트의 핵심 용어: TPS와 응답 속도

부하 테스트를 할 때 경영진에게 보고해야 할 가장 핵심적인 지표 두 가지가 있습니다.

  • TPS (Transactions Per Second): 1초에 서버가 처리해 낸 요청의 개수입니다. (JMeter에서는 Throughput이라고 부릅니다.) "우리 서버는 초당 500개의 결제를 처리할 수 있습니다(500 TPS)."라는 것이 백엔드 성능의 절대적 척도가 됩니다.
  • 응답 속도 (Response Time): 요청을 보내고 응답받기까지 걸린 시간입니다. 평소엔 0.1초 였다가도 유저가 몰려 대기열(Queue)에 쌓이면 3초, 5초로 늘어납니다. 한국인의 인내심 한계인 '3초'를 넘어가면 사실상 서버가 죽은 것과 다름없습니다.

2. JMeter 시나리오 세팅: 가짜 1만 명 만들기

JMeter(GUI 환경)를 실행하고 트래픽 시나리오를 구성해 봅니다.

1. Thread Group (가상 유저 세팅)
몇 명이 얼마나 빠르게 접속할지 정합니다.
- Number of Threads (users): 1000 (가상 유저 1000명)
- Ramp-up period (seconds): 10 (10초 동안 1000명이 서서히 들어오게 만듦. 즉 1초에 100명씩 투입!)
- Loop Count: 1 (유저 1명당 API를 1번만 호출)

2. HTTP Request (공격할 타겟 지정)
스프링 부트 서버의 IP와 GET /api/v1/courses/1/enroll 같은 API 경로를 적습니다.

3. View Results in Table & Summary Report (결과 보기)
초록색 재생() 버튼을 누르면, JMeter가 내 PC의 네트워크를 풀가동하여 1초에 100발씩 서버를 향해 트래픽 폭격을 날립니다!


🩺 3. 병목(Bottleneck) 찾기와 튜닝의 예술

테스트가 끝난 후 Summary Report를 보니 Error% 가 15%가 찍혀있고, 평균 응답 속도가 8초가 넘습니다. 서버가 버티지 못한 것입니다! 이제 에서 배운 Actuator + Grafana 대시보드를 열어서 서버 상태를 엑스레이 찍듯 들여다봐야 합니다.

  • [상황 A] DB 커넥션 수치가 바닥을 치고 있다면?
    1,000명의 유저가 HikariCP의 기본값인 10개의 커넥션을 서로 차지하려 싸우느라 타임아웃(Timeout)이 발생한 것입니다. 풀 사이즈를 10에서 50으로 늘려보고 다시 쏴봅니다.
  • [상황 B] CPU 사용률이 99%를 치고 있다면?
    애플리케이션 내부에 복잡한 for 루프 연산이나 암호화 작업(BCrypt)이 CPU를 갉아먹고 있는 것입니다. 코드를 리팩토링하여 연산을 줄이거나, 스케일 아웃(서버 증설)을 해야 합니다.
  • [상황 C] 무거운 쿼리가 발견됐다면?
    JPA N+1 문제가 터졌거나, DB 테이블에 인덱스(Index)가 안 걸려 있어서 디스크를 풀 스캔하느라 느려진 것입니다. 쿼리를 최적화하거나 에서 배운 Redis 캐시(Cache)를 덮어씌워 DB 부하를 0으로 만들어 버립니다.

4. 마무리 및 주요 발전 방향

지금까지 오픈 당일 서버가 다운되는 대참사를 막기 위해, JMeter를 활용해 트래픽 폭탄을 던져보고 모니터링 툴과 연계하여 병목 지점을 찾아내 튜닝하는 시니어급 백엔드 성능 최적화(Performance Tuning) 기법에 대해 아주 치밀하게 다루어 보았습니다. 이제 여러분은 "우리 서버는 1만 명도 거뜬합니다!"라고 당당하게 외칠 수 있습니다.

 

튜닝을 극한으로 깎아보니, 전통적인 스프링 부트(Spring Web MVC)가 가진 태생적인 한계에 부딪히게 됩니다. 하나의 요청이 들어올 때마다 하나의 쓰레드(Thread)를 꽉 물고(Blocking) 놔주지 않으니, 트래픽이 폭주하면 톰캣의 쓰레드 200개가 순식간에 고갈(Thread Exhaustion)되어 서버가 뻗어버리는 것이죠.

 

"쓰레드 하나가 절대 멈춰있지(Block) 않고, 일 시켜놓고 바로 다음 유저를 응답하러 날아다니면 안 될까?"
이 미친 효율의 비동기 논블로킹(Non-Blocking) 철학을 실현한 차세대 기술이 등장했습니다.

 

다음 글에서는 "넷플릭스, 라인 등 대규모 기업이 사랑하는 적은 자원으로 극한의 성능을 뽑아내는 마법!"

Spring WebFlux(웹플럭스)와 리액티브 프로그래밍(Reactive Programming) 아키텍처에 대해 알아보겠습니다.