이 글에서 얻는 것
- 메모리 누수의 원인과 증상을 이해합니다.
- 힙 덤프를 생성하고 분석할 수 있습니다.
- 프로파일링 도구(MAT, VisualVM)를 사용할 수 있습니다.
- 실전 디버깅 시나리오를 해결할 수 있습니다.
0) 메모리 누수는 “서서히 죽는다”
메모리 누수란?
메모리 누수 (Memory Leak):
- 더 이상 사용하지 않는 객체가 GC되지 않음
- 시간이 지날수록 메모리 사용량 증가
- 결국 OutOfMemoryError 발생
증상:
- 애플리케이션이 점점 느려짐
- Full GC 빈번하게 발생
- 결국 OutOfMemoryError로 크래시
- 재시작하면 괜찮다가 다시 발생
전형적인 패턴
// ❌ 메모리 누수 예시
public class CacheManager {
private static final Map<String, User> cache = new HashMap<>();
public void cacheUser(User user) {
cache.put(user.getId(), user); // 계속 쌓임!
// 제거 로직 없음 → 메모리 누수
}
}
1) 메모리 누수 감지
1-1) 증상 확인
메모리 사용량 모니터링:
# JVM 메모리 사용량 확인
jstat -gc <pid> 1000
# 결과:
# S0C S1C S0U S1U EC EU OC OU
# 25600 25600 0.0 0.0 204800 150000 512000 480000
# ↑ Old Gen 계속 증가
# 반복 실행하면서 Old Generation이 계속 증가하는지 확인
GC 로그 분석:
# GC 로그 활성화
java -Xlog:gc*:file=gc.log -jar app.jar
# 로그 확인
cat gc.log | grep "Full GC"
# Full GC가 빈번하면서도 메모리가 줄지 않음 → 메모리 누수 의심
1-2) 힙 덤프 생성
자동 생성 (OOM 발생 시):
# OOM 발생 시 자동으로 힙 덤프 생성
java -XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/tmp/heapdump.hprof \
-jar app.jar
수동 생성:
# 1. PID 확인
jps
# 2. 힙 덤프 생성
jmap -dump:live,format=b,file=/tmp/heapdump.hprof <pid>
# 또는 jcmd 사용
jcmd <pid> GC.heap_dump /tmp/heapdump.hprof
2) Eclipse MAT로 힙 덤프 분석
2-1) MAT 설치 및 열기
# 다운로드
https://www.eclipse.org/mat/
# 힙 덤프 열기
File → Open Heap Dump → heapdump.hprof
2-2) Leak Suspects Report
MAT가 자동으로 의심스러운 메모리 누수 탐지:
"Problem Suspect 1":
- HashMap이 전체 힙의 60% 차지
- 500만 개의 User 객체 보유
- CacheManager에서 참조
→ CacheManager의 HashMap이 원인!
2-3) Dominator Tree
Dominator Tree:
- 어떤 객체가 메모리를 가장 많이 차지하는지
예시:
CacheManager 60% (600MB)
└── HashMap 60%
└── User[] 58%
├── User#1 0.01%
├── User#2 0.01%
└── ...
→ HashMap이 User 객체를 계속 들고 있음
2-4) OQL (Object Query Language)
-- 모든 HashMap 찾기
SELECT * FROM java.util.HashMap
-- 크기가 10000 이상인 HashMap
SELECT * FROM java.util.HashMap WHERE size() > 10000
-- User 객체 찾기
SELECT * FROM com.example.User
-- 특정 클래스의 인스턴스 개수
SELECT COUNT(*) FROM com.example.User
3) VisualVM으로 실시간 모니터링
3-1) VisualVM 시작
# VisualVM 실행
jvisualvm
# 또는
<JAVA_HOME>/bin/jvisualvm
3-2) 힙 덤프 분석
1. 애플리케이션 선택
2. Monitor 탭:
- Heap 사용량 실시간 확인
- GC 실행 버튼으로 강제 GC
3. Sampler 탭:
- CPU: 어떤 메서드가 많이 실행되는지
- Memory: 어떤 객체가 많이 생성되는지
4. Heap Dump:
- "Heap Dump" 버튼 클릭
- Classes 탭에서 인스턴스 수 확인
3-3) 프로파일링
Profiler 탭:
- Memory 프로파일링 시작
- 애플리케이션 사용
- 결과 확인:
- 어떤 클래스가 많이 생성됐는지
- 어디서 할당됐는지 (Allocation Stack Trace)
4) 전형적인 메모리 누수 패턴
4-1) Static 컬렉션
// ❌ 메모리 누수
public class UserCache {
private static final Map<String, User> CACHE = new HashMap<>();
public void addUser(User user) {
CACHE.put(user.getId(), user);
// 제거 로직 없음!
}
}
// ✅ 해결: 크기 제한 + TTL
public class UserCache {
private static final Map<String, User> CACHE = new ConcurrentHashMap<>();
private static final int MAX_SIZE = 10000;
public void addUser(User user) {
if (CACHE.size() >= MAX_SIZE) {
// LRU 방식으로 가장 오래된 항목 제거
String oldestKey = findOldestKey();
CACHE.remove(oldestKey);
}
CACHE.put(user.getId(), user);
}
}
// 또는 Caffeine Cache 사용
LoadingCache<String, User> cache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(Duration.ofHours(1))
.build(key -> userRepository.findById(key));
4-2) 리스너 미제거
// ❌ 메모리 누수
public class EventManager {
private List<EventListener> listeners = new ArrayList<>();
public void addListener(EventListener listener) {
listeners.add(listener);
// 제거하지 않으면 계속 쌓임!
}
}
// ✅ 해결: 명시적 제거
public class EventManager {
private List<EventListener> listeners = new ArrayList<>();
public void addListener(EventListener listener) {
listeners.add(listener);
}
public void removeListener(EventListener listener) {
listeners.remove(listener);
}
}
// 또는 WeakReference 사용
private List<WeakReference<EventListener>> listeners = new ArrayList<>();
4-3) ThreadLocal 미정리
// ❌ 메모리 누수
public class UserContext {
private static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();
public static void setUser(User user) {
CURRENT_USER.set(user);
// 제거 안 함 → 스레드 풀 환경에서 누수!
}
}
// ✅ 해결: 명시적 정리
public class UserContext {
private static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();
public static void setUser(User user) {
CURRENT_USER.set(user);
}
public static void clear() {
CURRENT_USER.remove(); // 반드시 정리!
}
}
// Filter에서 사용
public class UserContextFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
try {
User user = extractUser(request);
UserContext.setUser(user);
chain.doFilter(request, response);
} finally {
UserContext.clear(); // 반드시!
}
}
}
4-4) 스트림 미종료
// ❌ 메모리 누수 (파일 핸들 누수)
public List<String> readLines(String path) {
return Files.lines(Paths.get(path))
.collect(Collectors.toList());
// Stream이 닫히지 않음!
}
// ✅ 해결: try-with-resources
public List<String> readLines(String path) throws IOException {
try (Stream<String> lines = Files.lines(Paths.get(path))) {
return lines.collect(Collectors.toList());
}
}
5) 실전 디버깅 시나리오
시나리오: OOM 발생
1. 증상 확인
java.lang.OutOfMemoryError: Java heap space
2. 힙 덤프 분석
jmap -dump:live,format=b,file=dump.hprof <pid>
3. MAT로 열기
- Leak Suspects Report 확인
- Dominator Tree에서 큰 객체 탐색
4. 원인 파악
HashMap이 600MB 차지
└── 500만 개의 Session 객체
원인: 세션이 만료되지 않고 계속 쌓임
5. 수정
// Before
private static final Map<String, Session> sessions = new HashMap<>();
// After: TTL 추가
private static final Cache<String, Session> sessions = Caffeine.newBuilder()
.expireAfterAccess(Duration.ofMinutes(30))
.maximumSize(100000)
.build();
6) 프로덕션 환경 팁
✅ 1. JVM 옵션 설정
java -Xms2g \
-Xmx4g \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/app/heapdump.hprof \
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-Xlog:gc*:file=/var/log/app/gc.log \
-jar app.jar
✅ 2. 모니터링 설정
# Spring Boot Actuator
management:
metrics:
enable:
jvm: true
endpoint:
metrics:
enabled: true
✅ 3. 주기적인 점검
# 메모리 사용량 추이 확인
jstat -gc <pid> 60000 | tee -a memory-usage.log
# 스레드 덤프
jstack <pid> > thread-dump.txt
연습 (추천)
메모리 누수 재현
- Static Map에 객체 계속 추가
- 힙 덤프 생성 및 분석
MAT 사용
- Dominator Tree 탐색
- OQL로 객체 검색
수정 및 검증
- 메모리 누수 수정
- 재실행 후 메모리 안정화 확인
요약
- 메모리 누수는 GC되지 않는 객체가 쌓이는 현상
- 힙 덤프로 메모리 상태 분석 가능
- MAT, VisualVM으로 원인 파악
- Static 컬렉션, 리스너, ThreadLocal 주의
- 프로덕션에서 OOM 시 자동 덤프 설정 필수
다음 단계
- GC 튜닝:
/learning/deep-dive/deep-dive-gc-tuning-practical/ - JVM 메모리:
/learning/deep-dive/deep-dive-jvm-memory/ - Java 동시성:
/learning/deep-dive/deep-dive-java-concurrency-basics/
💬 댓글