🚨 GC 관련 OutOfMemoryError 분석기 (실제 사례 공유)
최근 업무 중 GC 관련 에러를 경험하여 이를 공유합니다.
개발 서버에서 배포 도중 스프링 애플리케이션이 비정상 종료되는 현상이 발생했고,
원인은 자바 힙 메모리(Heap Memory) Overflow였습니다.
당시 스프링은 외장 톰캣을 통해 실행되는 구조였으며,
에러 원인은 env 파일의 힙 메모리 설정값이 너무 작았던 것이었습니다.
과거에는 충분했던 용량이, 트래픽 증가로 인해 더 이상 감당되지 못한 것이죠.
☕ JVM 메모리 구조 이해하기
🧠 힙 메모리(Heap Memory)
• Java 객체들이 저장되는 공간
• 요청이 들어올 때마다 객체 생성 → 메모리 사용 증가
• 비유하자면 ‘창고’입니다.
• 물건(객체)을 계속 쌓아두다가
• 창고가 꽉 차면 OutOfMemoryError가 발생합니다.
🗑️ GC (Garbage Collection)란?
GC는 “쓰레기 수거차”와 같습니다.
GC 종류
• Young GC: 젊은 객체 영역만 청소 (빠르고 자주 실행)
• Full GC: 전체 영역 청소 (느리지만 확실)
📸 힙 덤프(Heap Dump)
메모리의 ‘사고 현장 사진’입니다.
OutOfMemoryError 발생 순간 → 객체 상태를 파일로 저장
→ 예: java_myrsmadm-svr-12_pid_20251017_110630.hprof
→ 이후 분석 도구로 열어 "어떤 객체가 메모리를 많이 잡고 있었나?" 확인
🕒 에러 발생 타임라인
1️⃣ 정상 상태
Heap: 1024MB 중 약 400MB 사용
[████████░░░░░░░░░░░░░░░░░░░░] 40%
2️⃣ 트래픽 증가
Heap: 1024MB 중 약 658MB 사용
[█████████████████░░░░░░░░░░░] 64%
→ Young GC가 10초마다 발생
3️⃣ 메모리 부족 조짐
Young 영역: 정상적으로 비워짐 ✅
Old 영역: 계속 증가 ⚠️
→ 메모리 누수 의심
4️⃣ OutOfMemoryError 발생 (11:06:33)
Old Generation 가득 참 → 새 객체 생성 불가
→ java.lang.OutOfMemoryError
→ 힙 덤프 생성
→ Tomcat 크래시 💥
5️⃣ 재시작 실패
이미 죽은 프로세스의 PID가 남아있음 → "이미 실행 중입니다" 착각
→ 좀비 프로세스 발생 🧟
6️⃣ DB 연결 실패
재시작 후 HikariCP가 DB 연결 시도
→ PostgreSQL 연결 타임아웃
→ SocketTimeoutException 발생
→ 인증 실패 (401)
⚙️ 문제 요약
구분 원인 결과
💀 OutOfMemoryError 힙 1GB 설정 → 부족 Tomcat 크래시
🧟 좀비 프로세스 비정상 종료 후 PID 잔존 재시작 실패
🔌 DB 연결 실패 네트워크/DB 지연 인증 실패
❓ “GC가 제대로 동작 안 한 거 아닌가요?”
좋은 질문입니다.
사실 GC는 정상적으로 동작했습니다.
다만, “Old Generation이 비워지지 않은 것”이 핵심 문제였습니다.
📊 GC 로그 분석
481초: GC 실행 → Heap 658.5M → 46.6M
492초: GC 실행 → Heap 658.7M → 46.7M
502초: GC 실행 → Heap 658.6M → 46.6M
즉,
• Young 영역(Eden): 완벽하게 비워짐 ✅
• Old 영역: 그대로 남음 ❌
→ Old Generation이 비워지지 않음 → 결국 메모리 부족 발생
🧩 원인 분석
1️⃣ Full GC가 실행되지 않음
• 로그상 Full GC가 단 한 번도 발생하지 않음
• Young GC만 계속 반복 (Old 영역은 청소하지 않음)
G1GC의 작동 방식
설정 예시:
-XX:InitiatingHeapOccupancyPercent=35
→ 힙의 35% 차면 Full GC 준비,
하지만 실제로는 Old가 40%인데도 Full GC가 일어나지 않았음.
2️⃣ 메모리 누수 가능성
GC가 Old 객체를 지우지 못하는 이유 중 하나는,
아직 참조 중인 객체가 있기 때문입니다.
// 나쁜 예시 1: 캐시 누수
private static Map<String, Object> cache = new HashMap<>();
public void addToCache(String key, Object value) {
cache.put(key, value); // 계속 쌓이기만 함!
}
// 나쁜 예시 2: Connection 미닫기
Connection conn = getConnection();
// ...
// conn.close(); 누락 시 누수 발생
💡 요약: GC는 열심히 일했지만…
구분 동작 결과
Young GC 자주 발생, Young 영역 완벽 청소 ✅ 정상
Old GC (Full GC) 실행 안 됨 ❌ Old 영역 누적
힙 크기 1GB (너무 작음) ⚠️ 빠르게 OOM
메모리 누수 참조 유지로 회수 불가 ⚠️ 누수 가능성 높음
🏚️ 비유로 이해하기
집(힙 메모리) = 방 2개
문제:
1. 거실은 깨끗하지만,
2. 창고로 계속 물건이 옮겨짐,
3. 창고 청소를 안 함,
4. 결국 집 전체가 터져버림 💥
🔧 해결 방법
1️⃣ 메모리 증설
-Xms1024m -Xmx1024m → -Xms2048m -Xmx2048m
2️⃣ Full GC 조건 조정
-XX:InitiatingHeapOccupancyPercent=35 → 45
3️⃣ 메모리 누수 점검
힙 덤프 파일 분석 → 메모리 많이 차지한 객체 확인 → 코드 수정
✅ 결론
GC가 제대로 동작하지 않은 게 아닙니다.
오히려 열심히 일했지만 감당할 수 없었던 상황이었습니다.
핵심 요약:
1. 힙 크기가 너무 작았고,
2. Full GC 타이밍이 늦었으며,
3. Old Generation에 객체가 계속 쌓였고,
4. 일부는 메모리 누수 가능성이 존재했습니다.
👉 즉, GC의 한계 + 설정 미비 + 누수가 복합적으로 작용한 사례였습니다.
💬 GC 관련 에러는 단순한 “메모리 부족” 이상의 의미를 갖습니다.
GC 로그와 힙 덤프를 함께 분석하면, 문제의 본질을 훨씬 정확히 파악할 수 있습니다.
