-
Hibernate QueryPlanCache 메모리 누수 트러블슈팅Troubleshooting 2026. 2. 18. 23:36
운영 중인 서비스가 예고 없이 멈추는 상황은 개발자에게 가장 당혹스러운 순간입니다. 이번 글에서는 실제 운영 환경에서 발생했던 간헐적인 서버 중단 이슈를 추적하고, 단순 설정 수정을 넘어 인프라 최적화로 해결한 과정을 공유합니다.📋 목차
- 문제 상황: 장애 증상과 모니터링 지표 분석
- 원인 추론: 힙 덤프(Heap Dump) 분석을 통한 범인 특정
- 심층 분석: 왜 QueryPlanCache가 폭발했을까?
- 해결 과정: 3차에 걸친 시도와 최종 해결책
- 교훈 및 요약
1. 문제 상황: "불규칙한 서비스 중단과 자연 복구"
1.1 증상 파악
운영팀으로부터 간헐적인 서비스 응답 불가 리포트가 접수되었습니다.
- 현상: 서비스 응답 없음(Time-out) 및 대기열 급증
- 특징: 재시작 없이도 2~3분 후면 자연 복구됨
- 패턴: 발생 시점이 랜덤하며, "특정 자원이 임계치에 도달했다가 Full GC를 통해 해소되는 패턴"이 강하게 의심되었습니다.
1.2 모니터링 지표 확인 (Datadog)
장애 시점의 메트릭은 가설을 뒷받침해주었습니다.
지표 정상 시 장애 발생 시 Heap Memory 4~5GB 7.5GB 초과 (임계치 도달) GC Pause < 100ms 수 초(Seconds) 이상 발생 CPU 사용량 20~30% 100% (STW로 인한 스파이크) 핵심 관찰: 힙 메모리가 7.5GB에 도달하면 과도한 Full GC가 발생하고, 이로 인해 CPU 점유율이 100%를 치며 서비스가 마비되는 구조였습니다.
2. 힙 덤프(Heap Dump) 분석: 범인은 이 안에 있다
메모리 누수를 확신하고 장애 직후 힙 덤프를 확보하여 **Eclipse MAT(Memory Analyzer Tool)**으로 분석을 시작했습니다.
2.1 힙 덤프 생성
운영 환경에서는 서비스 중단 영향을 최소화하기 위해 jmap 대신 jcmd를 사용했습니다.
Bash# jcmd를 이용한 안전한 덤프 생성 jcmd <PID> GC.heap_dump /path/to/heapdump.hprof2.2 Leak Suspects Report
MAT의 분석 결과는 놀라웠습니다. 단 하나의 인스턴스가 전체 메모리의 상당 부분을 점유하고 있었습니다.
- Suspect: org.hibernate.engine.query.spi.QueryPlanCache
- 점유 메모리: 약 1.8GB
- 구조: QueryPlanCache -> BoundedConcurrentHashMap -> 수천 개의 HQLQueryPlan
확인 결과, GC가 회수하지 못하는 Strong Reference 구조로 캐시가 무한히 증식하고 있었습니다.
3. 원인 분석: 비즈니스 로직과 프레임워크의 충돌
3.1 Hibernate의 쿼리 캐싱 메커니즘
Hibernate는 실행되는 쿼리 문자열 자체를 키(Key)로 사용합니다.
- WHERE id IN (?, ?)
- WHERE id IN (?, ?, ?, ?)
위 두 쿼리는 로직상 동일하지만, Hibernate 입장에서는 완전히 다른 쿼리로 인식되어 별도의 실행 계획(Plan)을 캐싱합니다.
3.2 서비스의 특수성
우리 서비스는 특정 컬럼을 기준으로 대량으로 다루는 기능이 많았습니다. 특정 컬럼을 기준으로 파라미터 개수가 가변적으로 변했고, 이는 그대로 QueryPlanCache의 폭발적인 증가로 이어졌습니다.
4. 해결 과정: 3차에 걸친 단계적 대응
4.1 1차 시도: @QueryHints 적용 (실패) ❌
Java@QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "false"))- 결과: 효과 없음. 이 설정은 데이터 자체를 캐싱하는 '2차 캐시'용이며, 엔진 내부의 '실행 계획 캐시'인 QueryPlanCache와는 무관했습니다.
4.2 2차 시도: Parameter Padding 설정 (부분 성공) ⚠️
쿼리 종류를 강제로 규격화하여 캐시 효율을 높이기로 했습니다.
YAMLhibernate: query: plan_cache_max_size: 1024 in_clause_parameter_padding: true # 핵심!- 원리: IN 절 파라미터를 2의 거듭제곱(2, 4, 8, 16...) 단위로 패딩합니다.
- IN(?, ?, ?) -> IN(?, ?, ?, ?) (4개로 패딩)
- IN(?, ?, ?, ?, ?) -> IN(?, ?, ?, ?, ?, ?, ?, ?) (8개로 패딩)
- 효과: 수만 개에 달하던 캐시 종류가 수십 개 단위로 압축되었습니다. 하지만 피크 타임의 메모리 압박은 여전히 존재했습니다.
4.3 3차 시도: JVM 메모리 전략 최적화 (최종 해결) ✅
Padding 설정으로 캐시 개수는 줄였지만, 대규모 조사가 빈번한 서비스 특성상 각 실행 계획이 차지하는 메모리 점유율(Retained Heap) 자체는 여전히 무시할 수 없는 수준이었습니다.
서버의 물리 RAM(32GB)은 충분했기에, 컨테이너 환경에서 JVM이 가용 자원을 더 유연하게 쓰도록 비율 설정을 수정했습니다.
Bash# 컨테이너 가용 RAM의 70%까지 힙 메모리로 할당 -XX:InitialRAMPercentage=25 -XX:MaxRAMPercentage=70- 결과: 힙 공간을 최대 22GB까지 확보함으로써, 대규모 쿼리 플랜이 상주하더라도 Full GC 없이 여유롭게 처리할 수 있는 환경을 구축했습니다. 애플리케이션 튜닝(Padding)과 인프라 최적화(Heap 확대)가 만나 비로소 장애가 종결되었습니다.
5. 최종 결과 및 교훈
지표 조치 전 조치 후 서버 중단 일 평균 3회 0회 (완전 해결) GC Pause 수 초 이상 (STW 발생) 200ms 미만 (안정)
💡 핵심 교훈- 추상화의 비용을 인지하라: Hibernate는 편리하지만, 그 뒤에서 벌어지는 캐싱 전략을 모르면 '메모리 폭탄'이 될 수 있습니다.
- 힙 덤프는 거짓말을 하지 않는다: 로그만으로 알 수 없는 원인은 힙 덤프 분석을 통해 가장 빠르게 찾을 수 있습니다.
- 리소스 전략은 비즈니스에 맞춰야 한다: 코드를 깎는 튜닝도 중요하지만, 서비스의 데이터 규모에 맞는 과감한 리소스 배정(JVM 옵션)이 때로는 가장 확실한 정답이 됩니다.
마치며: 이번 장애가 남긴 기록
- 추상화의 뒷면을 살피는 습관: 프레임워크는 많은 것을 감춰주지만, 그 편리함 뒤에는 복잡한 비용이 숨어 있습니다. 내부 캐싱 전략을 이해하지 못한 채 사용하는 도구는 언제든 예기치 못한 '메모리 폭탄'으로 돌아올 수 있음을 실감했습니다.
- 로그로 부족할 땐 힙 덤프 활용: 로그만 보고 추측하기보다 힙 덤프를 통해 객체들이 메모리를 얼마나 점유하고 있는지 수치로 확인하는 것이 가장 빨랐습니다.
- 설정 최적화와 리소스 확보의 병행: 애플리케이션 설정을 고치는 것만큼이나, 서비스 규모에 맞춰 JVM 메모리 비중을 조정해 GC 여유 공간을 확보하는 것도 실질적인 해결책이 되었습니다.