- 출처 / 참고: Apache Tomcat Documentation (Class Loader HOW-TO), OpenJDK HotSpot JVM Memory Management Guide (JEP 122 Remove Permanent Generation)
명령어:
jcmd,jstat,ps,lsof,grep,tail
키워드: java.lang.OutOfMemoryError, Metaspace, MaxMetaspaceSize, ClassLoader Leak, WebappClassLoaderBase, JDBC Driver Deregister, GC.class_stats
사용처: 톰캣 Manager 콘솔 또는 WAR 파일 덮어쓰기 재배포(Redeploy) 반복 시 서버 다운 장애 분석 및 복구, JVM 네이티브 메모리 누수 방지
실행예제
톰캣을 재기동하지 않고 애플리케이션(WAR)을 반복 재배포(Redeploy)할 때 메타스페이스 영역이 계속 증가하다가 크래시가 발생하는 현상을 점검하고, 클래스로더 누수 여부를 실시간 추적합니다.
# 1. 톰캣 catalina.out 로그에서 Metaspace OOM 및 클래스로더 경고 확인
$ tail -n 50 /opt/tomcat/logs/catalina.out
# [출력 예시]
# java.lang.OutOfMemoryError: Metaspace
# WARNING [http-nio-8080-exec-5] org.apache.catalina.loader.WebappClassLoaderBase.clearReferencesJdbc
# The web application [ROOT] registered the JDBC driver [com.mysql.cj.jdbc.Driver] but failed to unregister it when the web application was stopped.
# To prevent a memory leak, the JDBC Driver has been forcibly unregistered.
# WARNING [http-nio-8080-exec-5] org.apache.catalina.loader.WebappClassLoaderBase.clearReferencesThreads
# The web application [ROOT] appears to have started a thread named [abandoned-connection-cleanup] but has failed to stop it.
# 2. 톰캣 PID 확인 및 JVM Metaspace 관련 구동 인자 확인
$ ps -ef | grep -E 'tomcat|Bootstrap' | grep -v grep
# -> -XX:MetaspaceSize 및 -XX:MaxMetaspaceSize 설정 여부 확인
# 3. 실시간 Metaspace 사용량 및 클래스 로딩 수치 모니터링 (jstat)
# MC: Metaspace Capacity(KB), MU: Metaspace Utilization(KB), CCSC: Compressed Class Capacity, CCSU: Compressed Class Utilization
$ jstat -gc 32145 1000 3
# 또는 퍼센트 단위 확인 (-gcutil)
$ jstat -gcutil 32145 1000 3
S0 S1 E O M CCS YGC YGCT FGC FGCT CGC CGCT GCT
0.00 0.00 42.50 31.20 99.85 98.12 12 0.210 5 1.420 - - 1.630
# -> 재배포 후 M(Metaspace) 점유율이 낮아지지 않고 99% 이상으로 유지되는지 확인
# 4. JVM에 로드된 클래스 수 및 로더 인스턴스 확인 (jcmd)
$ sudo -u tomcat jcmd 32145 VM.classloader_stats
# [출력 예시]
# Loader Class Parent Class Loaded Classes
# org.apache.catalina.loader.ParallelWebappClassLoader ... 4520
# org.apache.catalina.loader.ParallelWebappClassLoader ... 4518 <-- 이전 배포 버전의 클래스로더가 언로드되지 않고 남아있음!
스크립트
구동 중인 톰캣의 메타스페이스 설정값(MaxMetaspaceSize)을 조회하고, jstat 및 jcmd를 통해 현재 로드된 클래스 수와 누적된 WebappClassLoader의 인스턴스 개수를 집계하여 누수 여부를 판별하는 쉘 스크립트입니다.
#!/bin/bash
# check_metaspace_leak.sh - Diagnose Tomcat Metaspace usage and ClassLoader leaks
echo "=========================================="
echo " [톰캣 Metaspace 및 클래스로더 누수 진단]"
echo "=========================================="
# 1. 톰캣 프로세스 감지
TOMCAT_PID=$(pgrep -f "org.apache.catalina.startup.Bootstrap" | head -n 1)
if [ -z "$TOMCAT_PID" ]; then
echo " [오류] 구동 중인 톰캣 프로세스를 찾을 수 없습니다."
exit 1
fi
TOMCAT_USER=$(ps -o user= -p "$TOMCAT_PID")
echo " - 톰캣 PID : ${TOMCAT_PID}"
echo " - 실행 계정 : ${TOMCAT_USER}"
# 2. Metaspace 옵션 확인
JVM_ARGS=$(ps -p "$TOMCAT_PID" -o args=)
MAX_META=$(echo "$JVM_ARGS" | grep -oE "\-XX:MaxMetaspaceSize=[0-9]+[kmgKMG]")
INIT_META=$(echo "$JVM_ARGS" | grep -oE "\-XX:MetaspaceSize=[0-9]+[kmgKMG]")
echo -e "\n[1] JVM Metaspace 메모리 한도 설정:"
echo " - 초기 크기(-XX:MetaspaceSize) : ${INIT_META:-"미설정 (기본값 사용)"}"
echo " - 최대 크기(-XX:MaxMetaspaceSize) : ${MAX_META:-"무제한 (시스템 가용 물리 메모리 전체 소비 가능)"}"
# 3. 실시간 Metaspace 소비량 및 로드된 클래스 수
echo -e "\n[2] 클래스 로딩 및 Metaspace 사용량 통계:"
if command -v jstat &>/dev/null; then
CLASS_STATS=$(sudo -u "$TOMCAT_USER" jstat -class "$TOMCAT_PID" | tail -n 1)
LOADED_CLASSES=$(echo "$CLASS_STATS" | awk '{print $1}')
UNLOADED_CLASSES=$(echo "$CLASS_STATS" | awk '{print $3}')
META_STATS=$(sudo -u "$TOMCAT_USER" jstat -gcutil "$TOMCAT_PID" | tail -n 1)
META_PERCENT=$(echo "$META_STATS" | awk '{print $5}')
echo " - 현재 로드된 클래스 수 : ${LOADED_CLASSES}개"
echo " - 언로드(회수)된 클래스 수 : ${UNLOADED_CLASSES}개"
echo " - Metaspace 점유율 : ${META_PERCENT}%"
else
echo " [안내] jstat 명령어를 사용할 수 없습니다."
fi
# 4. WebappClassLoader 누수 검사 (jcmd)
echo -e "\n[3] WebappClassLoader 인스턴스 잔존 여부 검증:"
if command -v jcmd &>/dev/null; then
LOADER_DUMP=$(sudo -u "$TOMCAT_USER" jcmd "$TOMCAT_PID" VM.classloader_stats 2>/dev/null)
WEBAPP_LOADERS=$(echo "$LOADER_DUMP" | grep -c "ParallelWebappClassLoader\|WebappClassLoader")
echo " - 감지된 활성 WebappClassLoader 수: ${WEBAPP_LOADERS}개"
if [ "$WEBAPP_LOADERS" -gt 1 ]; then
echo " [위험] 이전 배포본의 ClassLoader가 GC되지 않고 메모리에 남아있습니다!"
echo " -> 애플리케이션 재배포(Redeploy) 누수로 인한 Metaspace OOM 발생 위험 매우 높음."
else
echo " [정상] 단일 WebappClassLoader만 활성화되어 있습니다."
fi
else
echo " [안내] jcmd 명령어를 사용할 수 없습니다."
fi
echo -e "\n=========================================="
echo " [진단 완료]"
echo "=========================================="
해설
Java 8부터 영구 영역(PermGen)이 제거되고 네이티브 메모리(Native Memory)를 사용하는 Metaspace가 도입되었습니다. 클래스의 구조적 메타데이터, 메서드 바이트코드, 상수 풀(Constant Pool) 등이 이 영역에 저장됩니다.
일반적인 운영 환경에서 재기동 없이 애플리케이션 WAR 파일만 교체하거나 Tomcat Manager를 통해 재배포(Hot Redeploy)를 수 차례 반복할 때 발생하는 Metaspace OOM은 거의 100% 클래스로더 누수(ClassLoader Leak)에 의해 발생합니다.
1. 클래스로더 누수(ClassLoader Leak) 발생 원리
- 톰캣의 독립 클래스로더 격리 구조:
- 톰캣은 애플리케이션마다 독립적인
WebappClassLoaderBase를 생성하여 WAR 파일 내부의 클래스를 로드합니다. - 애플리케이션을 언디플로이(Undeploy)하면 톰캣은 해당
WebappClassLoader를 폐기하고, 다음 배포 시 새로운 클래스로더를 만들어 클래스들을 새로 읽어 들입니다.
- 톰캣은 애플리케이션마다 독립적인
- GC Root에 물려 언로드(Unload) 불가:
- JVM 규칙상 어떤 클래스로더가 로드한 단 1개의 객체라도 외부(JVM 레벨의 시스템 스레드, JVM 전역 등록체 등)에서 참조하고 있으면, 그 클래스로더와 해당 로더가 Metaspace에 적재한 수천 개의 클래스 메타데이터 전체가 가비지 컬렉션(GC)되지 못합니다.
- 결국 재배포를 할 때마다 기존 클래스 메타데이터는 그대로 남고 신규 메타데이터가 계속 누적되어
MaxMetaspaceSize를 초과하게 됩니다.
2. 누수를 일으키는 3대 주범
- JDBC 드라이버 등록 해제 누락:
- 애플리케이션이 구동되면서
DriverManager.registerDriver()로 로드된 드라이버는 시스템 레벨의DriverManager에 정적(static)으로 영구 등록됩니다. 웹앱이 종료될 때 이를deregisterDriver하지 않으면 구형 클래스로더 전체가 묶입니다.
- 애플리케이션이 구동되면서
- 스레드 풀 및 자체 백그라운드 스레드 미종료:
Timer,ScheduledExecutorService, 또는 MySQL의abandoned-connection-cleanup스레드가 종료되지 않고 살아있으면 이 스레드의 ContextClassLoader가 이전 클래스로더를 붙잡고 있게 됩니다.
- ThreadLocal 변수 미정리:
- 톰캣의 워커 스레드(HTTP 요청 처리 스레드)는 재배포 후에도 톰캣 자체 스레드 풀에서 재사용됩니다. 요청 처리 중
ThreadLocal에 적재한 애플리케이션 객체를ThreadLocal.remove()로 비우지 않으면 워커 스레드가 구형 클래스를 영원히 참조합니다.
- 톰캣의 워커 스레드(HTTP 요청 처리 스레드)는 재배포 후에도 톰캣 자체 스레드 풀에서 재사용됩니다. 요청 처리 중
3. 해결 및 예방 조치
1) JVM 시작 인자에 적정 한도 부여 (setenv.sh)
기본값 상태의 Metaspace는 상한이 없어 서버 물리 메모리를 고갈시킬 때까지 확장되므로 반드시 명시적 상한을 지정해야 합니다.
# /opt/tomcat/bin/setenv.sh
export CATALINA_OPTS="$CATALINA_OPTS \
-XX:MetaspaceSize=256m \
-XX:MaxMetaspaceSize=512m"
2) 애플리케이션 수명주기 리스너(ServletContextListener) 구현
웹앱이 undeploy될 때 수동으로 드라이버 등록을 해제하고 스레드를 중단합니다.
@WebListener
public class AppCleanupListener implements ServletContextListener {
@Override
public void contextDestroyed(ServletContextEvent sce) {
// 1. JDBC 드라이버 등록 해제
Enumeration<Driver> drivers = DriverManager.getDrivers();
while (drivers.hasMoreElements()) {
Driver driver = drivers.nextElement();
try {
DriverManager.deregisterDriver(driver);
} catch (SQLException e) {
e.printStackTrace();
}
}
// 2. MySQL 클린업 스레드 명시적 종료
com.mysql.cj.jdbc.AbandonedConnectionCleanupThread.checkedShutdown();
}
}
주의사항
- 운영 환경에서의 Hot Redeploy(동적 재배포) 지양:
- 톰캣의 동적 재배포 기능은 개발 편의를 위한 기능입니다. 아무리 완벽하게 코딩된 서드파티 라이브러리(Spring, Hibernate, 로깅 프레임워크)라도 미세한 static 참조 누수가 존재할 수 있습니다. 무중단 서비스가 필요한 상용 환경에서는 Hot Redeploy를 하지 말고, 블루/그린 배포 또는 로드밸런서(L4/Nginx)에서 트래픽을 차단한 후 톰캣 프로세스 자체를 완전히 재기동(
systemctl restart tomcat)하는 롤링 배포를 적용해야 합니다.
- 톰캣의 동적 재배포 기능은 개발 편의를 위한 기능입니다. 아무리 완벽하게 코딩된 서드파티 라이브러리(Spring, Hibernate, 로깅 프레임워크)라도 미세한 static 참조 누수가 존재할 수 있습니다. 무중단 서비스가 필요한 상용 환경에서는 Hot Redeploy를 하지 말고, 블루/그린 배포 또는 로드밸런서(L4/Nginx)에서 트래픽을 차단한 후 톰캣 프로세스 자체를 완전히 재기동(
MaxMetaspaceSize미설정 시 커널 OOM-Killer 유발:- 최대 메타스페이스 크기를 지정하지 않으면, 힙 메모리는 멀쩡한데 메타스페이스가 호스트의 네이티브 RAM을 잠식하여 리눅스 커널 OOM Killer가 톰캣 프로세스 또는 DB(MySQL) 프로세스를 불시에 강제 종료(
kill -9)시킬 수 있습니다.
- 최대 메타스페이스 크기를 지정하지 않으면, 힙 메모리는 멀쩡한데 메타스페이스가 호스트의 네이티브 RAM을 잠식하여 리눅스 커널 OOM Killer가 톰캣 프로세스 또는 DB(MySQL) 프로세스를 불시에 강제 종료(
server.xml의antiResourceLocking설정 주의:- 윈도우 환경 등에서 파일 락 문제를 피하기 위해 Context 설정에
antiResourceLocking="true"를 무분별하게 켜면 임시 디렉터리에 WAR가 통째로 복제되어 재배포 시 디스크 I/O 병목 및 추가적인 클래스로더 격리 오류가 발생할 수 있습니다.
- 윈도우 환경 등에서 파일 락 문제를 피하기 위해 Context 설정에