| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
- 이벤트드리븐
- 실천방법
- kafka
- 추천
- vue store
- 멱등성Producer
- DI
- enum
- 백엔드개발
- KafkaFollower
- ECMAScript
- TCP/IP
- springboot
- 자기계발
- Kafka순서보장
- frontend
- 의존성주입
- webpack
- Javascript
- MSA
- HTTP란
- 카프카실무
- GC
- 카프카설계
- TypeScript
- 자바스크립트
- KafkaOrdering
- Vue+Typescript
- 타입스크립트
- 인생꿀팁
- Today
- Total
끄적끄적
서버가 새벽마다 OOM으로 죽었다 — Java 메모리 누수 패턴 7가지와 Heap Dump로 범인 잡는 법 본문
서버가 새벽마다 OOM으로 죽었다
"Java 메모리 누수, 직접 잡아본 경험"
Heap Dump 분석 · Static 참조 함정 · ThreadLocal 누수 · Connection Pool 고갈 — 실무 트러블슈팅 총정리
📋 목차
- 들어가며 — 새벽 3시 OOM 장애 복기
- Java 메모리 구조 — 이것만 알면 됩니다
- 메모리 누수가 생기는 대표 패턴 7가지
- Heap Dump 분석으로 범인 찾기
- 실무 트러블슈팅 BEST 4
- JVM 메모리 튜닝 설정값
- 메모리 누수 예방 체크리스트
들어가며 — 새벽 3시 OOM 장애 복기
운영 서버가 주기적으로 새벽에 죽는 일이 반복됐습니다. 처음엔 단순히 트래픽 문제라고 생각했어요. 스케일 아웃을 해도 며칠 지나면 또 죽었습니다. 로그를 보니 java.lang.OutOfMemoryError: Java heap space. Heap이 계속 올라가다가 GC가 감당 못하고 터지는 전형적인 메모리 누수 패턴이었습니다.
문제를 찾는 데 3일이 걸렸습니다. 범인은 캐시로 쓰던 static HashMap에 TTL 없이 계속 데이터를 쌓고 있던 것이었어요. 코드 한 줄의 실수가 며칠씩 서버를 죽이고 있었습니다.
더 이상 사용하지 않는 객체가 GC에 의해 수거되지 못하고 Heap에 계속 쌓이는 현상.
Java는 GC가 있어 안전하다고 착각하기 쉽지만, 참조가 살아있으면 GC는 절대 수거하지 않습니다.
Java 메모리 구조 — 딱 알아야 할 것만
| 영역 | 역할 | 누수 발생 여부 | 모니터링 지표 |
|---|---|---|---|
| 🔴 Heap (Young/Old) | 객체 인스턴스 저장 | 누수 주 발생 구역 | heap.used, GC 횟수 |
| 🟡 Metaspace | 클래스 메타정보 저장 | 동적 클래스 로딩 시 누수 | metaspace.used |
| 🟢 Stack | 메서드 호출 스택, 지역변수 | 일반적으로 안전 | StackOverflow 모니터링 |
| 🔵 Native/Off-Heap | NIO, DirectByteBuffer | GC 대상 아님 — 직접 관리 | direct.memory.used |
GC Root(Static 변수, 스레드 스택, JNI 참조 등)에서 참조 체인이 끊긴 객체만 수거합니다.
즉 아무도 참조 안 하는 객체 = 수거 대상. 참조가 1개라도 살아있으면 = 절대 수거 안 함.
메모리 누수가 생기는 대표 패턴 7가지
패턴 1 — Static 컬렉션에 무한정 쌓기
public class UserCache {
// ❌ TTL 없이 계속 쌓임 → OOM 직행
private static final Map<String, UserInfo> cache
= new HashMap<>();
public static void put(String key, UserInfo value) {
cache.put(key, value); // ❌ 삭제 로직 없음
}
}
@Bean
public Cache<String, UserInfo> userCache() {
return Caffeine.newBuilder()
.maximumSize(10_000) // ✅ 최대 개수 제한
.expireAfterWrite(10, TimeUnit.MINUTES) // ✅ TTL 설정
.recordStats() // ✅ 통계 모니터링
.build();
}
패턴 2 — ThreadLocal 사용 후 미제거
// ❌ 잘못된 패턴 — remove() 누락
public class UserContext {
private static final ThreadLocal<User> current
= new ThreadLocal<>();
public static void set(User user) { current.set(user); }
public static User get() { return current.get(); }
// ❌ clear() / remove() 없음!
}
// ✅ 올바른 패턴 — Filter/Interceptor에서 반드시 제거
@Override
public void afterCompletion(HttpServletRequest req,
HttpServletResponse res, Object handler, Exception ex) {
UserContext.clear(); // ✅ 요청 완료 후 반드시 제거!
}
패턴 3 — 이벤트 리스너 / 콜백 미해제
// ✅ 방법 1: @EventListener + Spring 관리 빈으로 등록
@Component
public class OrderEventListener {
@EventListener
public void onOrderCreated(OrderCreatedEvent event) {
// Spring이 빈 생명주기 관리 → 누수 없음
}
}
// ✅ 방법 2: Disposable/AutoCloseable 패턴으로 명시적 해제
@PreDestroy
public void cleanup() {
eventBus.unregister(this); // ✅ 빈 종료 시 해제
}
패턴 4 — Stream / Connection 미닫기
// ❌ 예외 발생 시 close() 미호출 → Connection 누수
Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql);
// 여기서 예외 발생하면 conn, ps 절대 닫히지 않음!
// ✅ try-with-resources — 예외 발생해도 자동 close()
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
// 로직 수행
} // ✅ 블록 끝에서 자동으로 close() 호출
패턴 5 — 비정적 내부 클래스(Inner Class) 참조
Android 개발자에게 특히 자주 발생하며, Spring에서도 익명 클래스로 Listener 구현 시 주의해야 합니다.
Heap Dump 분석으로 범인 찾기
Heap Dump 생성 방법
OOM 발생 시 자동으로 Heap Dump를 생성하도록 JVM 옵션을 미리 설정해두는 것이 필수입니다. 나중에 문제 발생 후 설정하면 이미 늦어요.
# OOM 발생 시 자동 힙 덤프 생성
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heapdump/
# 수동으로 즉시 Heap Dump 생성 (서버 살아있을 때)
jmap -dump:format=b,file=/tmp/heap.hprof <PID>
# Java 9+ 권장 방식
jcmd <PID> GC.heap_dump /tmp/heap.hprof
Eclipse MAT(Memory Analyzer Tool)로 분석
Heap Dump(.hprof 파일)를 Eclipse MAT에 로드하면 "Leak Suspects Report"를 자동으로 생성해줍니다. 어떤 객체가 얼마나 메모리를 차지하는지 한눈에 볼 수 있어요.
실시간 모니터링 — JVM Actuator + Prometheus
Heap Dump는 사후 분석이고, 평소에는 실시간 모니터링이 중요합니다. Spring Boot Actuator + Micrometer + Prometheus + Grafana 조합으로 Heap 사용량 추이를 그래프로 볼 수 있습니다.
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
metrics:
export:
prometheus:
enabled: true
# Grafana에서 핵심 쿼리
# jvm_memory_used_bytes{area="heap"} → Heap 사용량
# jvm_gc_pause_seconds_sum → GC 소요 시간
# jvm_memory_max_bytes{area="heap"} → Heap 최대값
JVM 메모리 튜닝 설정값
# 기본 메모리 설정
-Xms2g # 초기 Heap (시작부터 2GB 할당 → GC 부담 감소)
-Xmx2g # 최대 Heap (Xms와 동일하게 → 동적 조정 오버헤드 제거)
-XX:MetaspaceSize=256m # Metaspace 초기값
-XX:MaxMetaspaceSize=512m # Metaspace 최대값 제한 (무한 증가 방지)
# GC 설정 (Java 17 기본: G1GC 권장)
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # GC 최대 pause 목표값 200ms
-XX:G1HeapRegionSize=16m # G1 Region 크기
# OOM 시 자동 Heap Dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heapdump/
# OOM 시 프로세스 재시작 (컨테이너 환경)
-XX:+ExitOnOutOfMemoryError
Java 8 이하에서는 컨테이너 메모리 제한을 인식 못하고 호스트 메모리 기준으로 Heap을 잡아 OOM이 발생합니다.
Java 11+에서는 -XX:+UseContainerSupport (기본 활성화)로 컨테이너 메모리를 자동 인식합니다.
컨테이너 메모리 제한의 75% 이하로 Xmx를 설정하는 것이 안전합니다.
메모리 누수 예방 체크리스트
☑️ Static 컬렉션에 사이즈 제한 또는 TTL 설정 여부
☑️ ThreadLocal 사용 후 remove() 호출 여부 (특히 Filter/Interceptor)
☑️ InputStream, Connection, ResultSet try-with-resources 사용 여부
☑️ 이벤트 리스너 등록/해제 쌍으로 구현 여부
☑️ 캐시 라이브러리(Caffeine, Redis) 사용 시 eviction 정책 설정 여부
☑️ JVM 옵션에 HeapDumpOnOutOfMemoryError 설정 여부
☑️ Grafana/Prometheus로 Heap 사용량 실시간 모니터링 여부
1️⃣ jcmd <PID> GC.heap_dump 로 현재 Heap Dump 채취
2️⃣ Eclipse MAT → Leak Suspects Report 확인
3️⃣ Dominator Tree에서 Retained Heap 상위 객체 확인
4️⃣ 해당 객체의 GC Root 참조 체인 역추적 → 누수 위치 특정
5️⃣ 코드 수정 후 로컬에서 JMeter/Gatling으로 부하 테스트 → Heap 증가 없음 확인
🔍 "메모리 누수는 갑자기 죽는 게 아니라, 천천히 죽어가는 것입니다"
서버가 배포 직후엔 멀쩡하다가 며칠 후 죽는다면 메모리 누수를 의심하세요.
GC 후 Heap이 완전히 내려가지 않고 조금씩 올라가는 패턴 — 그게 신호입니다.
처음부터 Heap Dump 설정과 모니터링을 갖춰두면 발생해도 두렵지 않습니다 💪
비슷한 경험 있으시면 댓글로 공유해 주세요!
'Back-end > Java' 카테고리의 다른 글
| Kafka 실무 완전 정복 — Consumer Lag 폭증·메시지 중복·파티션 설계 실수 직접 겪고 해결한 이야기 (0) | 2026.02.25 |
|---|---|
| Spring Boot Filter vs Interceptor — 실무에서 겪은 차이점 완전 정리 (코드 예제 포함) (0) | 2026.02.25 |
| [SPOCK] Spock의 Mock, Stub, Spy에 대해서 알아보자 (0) | 2022.09.13 |
| [JAVA] @Transaction 전파 및 격리에 대한 정리 (0) | 2022.04.28 |
| gradle 환경에서 apache log4j 2.15.0 버전 업데이트하기 (0) | 2021.12.13 |
