Contents
see List메모리 부족은 GC 옵션부터 바꾸는 문제가 아니다
Java 서비스의 메모리 사용량이 시간이 갈수록 올라가고, 결국 응답 지연이나 OutOfMemoryError로 이어진다면 먼저 GC 옵션을 늘리기보다 객체가 왜 살아남는지 확인해야 한다. 힙 사용량이 높다는 사실만으로는 누수 여부를 판단할 수 없다. 트래픽 증가에 따라 캐시나 세션이 늘어난 것인지, 요청이 끝난 뒤에도 참조가 남은 것인지, 네이티브 메모리·스레드·메타스페이스가 문제인지 구분하는 순서가 필요하다.
실무에서는 재현 가능한 관측 정보, 장애 시점의 힙 덤프, 짧은 구간의 Java Flight Recorder(JFR)를 함께 확보하면 원인을 훨씬 빠르게 좁힐 수 있다. 운영 서버에서 무작정 전체 덤프를 반복 생성하면 디스크와 I/O에 부담을 줄 수 있으므로, 수집 조건과 보관 위치를 배포 전에 정해 둔다.
먼저 확인할 증상과 기준선
- Full GC 뒤에도 Old Generation 점유율이 이전 수준으로 내려오지 않고 계단식으로 상승하는지 확인한다.
- 배포 직후의 안정 구간, 일상 트래픽 구간, 문제 발생 구간의 힙·스레드 수·응답 시간을 같은 단위로 비교한다.
- 캐시, 큐, 비동기 작업, WebSocket 세션, ThreadLocal, 이벤트 리스너처럼 장기 참조를 만드는 구성요소를 우선 목록화한다.
- 컨테이너 환경에서는 JVM 힙뿐 아니라 Pod 메모리 제한, RSS, 임시 파일, direct buffer 사용량도 함께 본다.
GC 로그의 총량만 보고 결론을 내리면 안 된다. Young GC가 자주 일어나도 회수율이 좋고 지연 시간이 짧다면 정상일 수 있다. 반대로 Full GC가 드물어도 살아 있는 객체 집합이 지속적으로 커지면 누수 가능성이 높다. 같은 요청량에서 Live Set이 증가하는지 보는 것이 핵심이다.
장애 시 자동으로 남길 JVM 옵션
서비스 시작 옵션에 힙 덤프와 GC 로그의 저장 위치를 명시한다. 디렉터리는 애플리케이션 로그와 분리하고, 실제 운영 계정이 쓰기 가능한지 배포 단계에서 검증해야 한다. 힙 덤프에는 고객 입력값이나 세션 정보가 들어갈 수 있으므로 접근 권한과 보관 기간도 운영 정책에 포함한다.
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/myapp/heapdumps
-Xlog:gc*,safepoint:file=/var/log/myapp/gc/gc.log:time,level,tags:filecount=7,filesize=20M
-XX:+ExitOnOutOfMemoryError
컨테이너가 OOMKilled 된 경우에는 JVM이 덤프를 남길 기회가 없을 수 있다. JVM 최대 힙을 컨테이너 제한보다 충분히 낮게 잡고, 메타스페이스·스레드 스택·direct buffer·네이티브 라이브러리 영역을 위한 여유를 남긴다. 예를 들어 메모리 제한이 2GiB라면 Xmx를 2GiB로 동일하게 두는 방식은 위험하다.
JFR로 누수가 진행되는 구간 기록하기
JFR은 낮은 오버헤드로 할당, GC, 락, 스레드, 예외 등의 이벤트를 기록한다. 장애가 막 시작된 시점에 10~30분 정도 기록하면 어떤 클래스의 할당률이 높았는지와 어떤 요청 경로가 객체를 많이 만드는지 확인할 수 있다. PID는 jcmd로 조회한 뒤 아래처럼 이름을 붙여 수집한다.
jcmd 24871 JFR.start name=memorycheck settings=profile duration=20m filename=/var/log/myapp/jfr/memorycheck-20260728.jfr
jcmd 24871 GC.heap_info
jcmd 24871 Thread.print -l > /var/log/myapp/threads-20260728.txt
JDK Mission Control에서 JFR 파일을 열어 Allocation in new TLAB, Allocation outside TLAB, Old Object Sample, Garbage Collections 화면을 확인한다. 특정 DTO가 많이 만들어지는 것은 단순한 할당 과다일 수 있지만, Old Object Sample에 오래 남아 있는 컬렉션과 보유 경로가 반복되면 참조 누수를 의심할 수 있다. 이때 요청 ID나 배치 작업 시간대와 이벤트 시각을 연결하면 코드 위치를 좁히기 좋다.
Heap Dump 분석의 실전 순서
OOM 직전 힙 덤프는 Eclipse MAT 같은 분석 도구에서 연다. 먼저 Leak Suspects 보고서를 참고하되 결과를 그대로 믿지 말고 Dominator Tree에서 Retained Heap이 큰 객체를 확인한다. Retained Heap은 해당 객체를 제거했을 때 함께 회수될 수 있는 메모리 크기다. 큰 Map, List, 캐시 엔트리, 세션 저장소, 큐가 보이면 객체 수와 키의 특성을 확인한다.
- Histogram에서 인스턴스 수와 Shallow Heap이 급증한 클래스가 있는지 확인한다.
- Dominator Tree에서 Retained Heap 상위 객체를 확인하고, Path to GC Roots로 누가 참조를 유지하는지 추적한다.
- 동일 클래스의 정상 시점 덤프와 장애 시점 덤프를 비교해 증가분을 본다.
- static 필드, ThreadLocalMap, 스케줄러 작업 목록, 리스너 등록 목록은 특히 주의 깊게 점검한다.
예를 들어 요청별 데이터를 static Map에 넣고 제거하지 않으면 Map 하나가 원인이지만, 실제 수정 지점은 요청 종료 훅이나 캐시 만료 정책이다. 단순히 Map을 ConcurrentHashMap으로 바꾸는 것은 동시성 오류를 줄일 뿐 메모리 누수를 해결하지 못한다. 캐시라면 최대 크기, 만료 시간, 삭제 이벤트, 비정상 키 증가를 모두 설계해야 한다.
코드에서 자주 생기는 참조 누수
ThreadLocal은 스레드 풀과 결합될 때 특히 위험하다. 작업이 끝난 뒤 remove를 하지 않으면 다음 작업에도 값이 남고, 큰 객체가 장기간 유지될 수 있다. 요청 컨텍스트나 인증 정보를 ThreadLocal에 둘 때는 반드시 finally 블록에서 제거한다.
private static final ThreadLocal<RequestContext> CONTEXT = new ThreadLocal<>();
public void handle(RequestContext context) {
CONTEXT.set(context);
try {
process();
} finally {
CONTEXT.remove();
}
}
또한 무제한 캐시, 종료되지 않는 ExecutorService, 해제하지 않는 HTTP 응답 본문, 중복 등록된 이벤트 리스너도 같은 방식으로 누적된다. 수정 후에는 부하 테스트에서 처리량이 일정한 상태로 30분 이상 실행하고, Full GC 후 Live Set과 객체 수가 안정화되는지 확인한다.
배포 전 체크리스트
- OOM 시 힙 덤프와 GC 로그의 경로, 쓰기 권한, 디스크 여유 공간을 확인한다.
- JFR 수집 절차와 분석 담당자, 파일 보관·삭제 기준을 문서화한다.
- 컨테이너 메모리 제한과 Xmx 사이에 네이티브 메모리 여유를 둔다.
- 캐시·ThreadLocal·리스너·비동기 작업의 생성과 해제 조건을 코드 리뷰 항목으로 둔다.
- 수정 전후의 Live Set, Full GC 이후 점유율, 응답 시간을 같은 부하 조건에서 비교한다.
메모리 문제는 한 번의 덤프에서 추측으로 끝내지 말고, 시간에 따른 증가와 객체 보유 경로를 함께 검증해야 한다. 관측 설정을 먼저 갖추고 원인을 확인한 뒤 제한값과 코드를 수정하는 순서가 재발을 줄인다.
java
| No | 작성일 | Title |
|---|---|---|
| 2023 | 2025. 11. 30. | Java 직렬화와 JSON 처리 - Jackson 활용 |
| 2022 | 2025. 11. 30. | Java 테스트 - JUnit 5와 Mockito |
| 2021 | 2025. 11. 30. | Java 함수형 프로그래밍 - Lambda와 Method Reference |
| 2020 | 2025. 11. 30. | Lombok 활용 가이드 - 보일러플레이트 제거 |
| 2019 | 2025. 11. 30. | Java Record와 Sealed Class 활용 |
| 2018 | 2025. 11. 30. | 효과적인 Java 예외 처리 전략 |
| 2017 | 2025. 11. 30. | JVM 메모리 구조와 GC 튜닝 |
| 2016 | 2025. 11. 30. | Java 동시성 프로그래밍 - CompletableFuture |
| 2015 | 2025. 11. 30. | Stream API 고급 활용법 |
| 2014 | 2025. 11. 30. | Java 21 새 기능 - Virtual Threads와 Pattern Matching |