Published on

Spring Boot Metaspace OOM 원인과 해결 가이드

Authors

서버가 잘 돌다가 어느 순간 java.lang.OutOfMemoryError: Metaspace로 뻗는 케이스는 “힙이 아니라 클래스 메타데이터 영역이 찼다”는 신호입니다. 힙 덤프만 떠서는 원인이 안 보이는 경우가 많고, 재시작하면 잠깐 괜찮다가 다시 터지는 패턴이 흔합니다. 이 글에서는 Spring Boot 환경에서 Metaspace OOM이 왜 발생하는지, 무엇을 수집해 어떻게 판단해야 하는지, 그리고 옵션 땜질이 아닌 근본 해결까지 단계별로 정리합니다.

Metaspace란 무엇이고, 왜 OOM이 나는가

JDK 8부터 PermGen이 사라지고 Metaspace로 바뀌었습니다. Metaspace는 JVM 힙 바깥(native 메모리)에 위치하며, 주로 다음을 저장합니다.

  • 로드된 클래스의 메타데이터(클래스 구조, 메서드/필드 정보 등)
  • 리플렉션/프록시/바이트코드 생성 결과로 만들어진 클래스들
  • 클래스 로더 자체와 클래스 로더가 잡고 있는 참조들

Metaspace OOM은 단순히 “클래스가 너무 많다”보다는, 클래스가 계속 생성되는데 언로드가 되지 않는 상태(클래스 로더 누수 포함)일 때 많이 발생합니다. 특히 Spring Boot는 프록시(AOP), 리플렉션, 라이브러리 자동 설정이 많아 “동적 클래스 생성”이 일어날 여지가 큽니다.

증상 패턴: 힙은 멀쩡한데 Metaspace만 증가

대표적인 운영 지표 패턴은 다음과 같습니다.

  • -Xmx 대비 힙 사용량은 안정적이거나 GC로 잘 회수됨
  • RSS(프로세스 메모리) 또는 native 메모리는 꾸준히 증가
  • Metaspace 사용량이 서서히 증가하다가 특정 시점에 OOM

이때 -Xmx를 늘려도 소용이 없거나 효과가 미미합니다. Metaspace는 힙이 아니기 때문입니다.

1차 확인: 에러 로그와 JVM 옵션 점검

먼저 로그에서 정확한 메시지를 확인합니다.

  • java.lang.OutOfMemoryError: Metaspace
  • 간혹 Compressed class space 관련 메시지가 함께 보일 수 있음

JVM 옵션을 확인해 아래가 설정되어 있는지 봅니다.

  • -XX:MaxMetaspaceSize=...

이 값이 너무 작으면 정상 트래픽에서도 터질 수 있습니다. 다만 운영에서 흔한 함정은 “값을 크게 올려서 일단 안 터지게 만들고 근본 원인을 놓치는 것”입니다. 일단은 진단을 위한 안전장치로 접근하세요.

권장: OOM 시 자동 덤프 및 로그 남기기

운영에서 재현이 어렵다면, OOM 순간의 증거를 남겨야 합니다.

JAVA_TOOL_OPTIONS="\
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/app/heapdump.hprof \
-XX:ErrorFile=/var/log/app/hs_err_pid%p.log \
-Xlog:gc*:file=/var/log/app/gc.log:time,level,tags \
" \
java -jar app.jar

주의: 경로에 권한이 없으면 덤프가 생성되지 않습니다. 컨테이너라면 볼륨 마운트도 확인하세요.

핵심 원인 1: 클래스 로더 누수(ClassLoader Leak)

Metaspace OOM의 정석적인 원인은 클래스 로더가 해제되지 않아 클래스 언로드가 불가능해지는 상황입니다. 대표 원인은 다음과 같습니다.

  • ThreadLocal에 애플리케이션 클래스 인스턴스를 넣고 제거하지 않음
  • 스레드 풀(특히 static/글로벌)에서 컨텍스트 클래스 로더가 꼬임
  • 라이브러리가 캐시(Map)나 싱글톤에 Class, Method, Field, ClassLoader를 강하게 참조
  • 동적 로딩(플러그인, 스크립트 엔진, 리로드 메커니즘)을 도입했는데 언로드 경로가 없음

Spring Boot 단일 실행 파일(jar)에서 “배포 후 리로드” 같은 기능을 억지로 붙이면 특히 위험합니다.

진단 포인트

  • Metaspace가 증가하는데 로드된 클래스 수(LoadedClassCount)가 계속 증가하는가
  • Full GC 후에도 Metaspace가 거의 줄지 않는가
  • 특정 요청/배치 이후 급격히 증가하는가

핵심 원인 2: 과도한 동적 프록시/바이트코드 생성

Spring은 AOP 프록시를 많이 사용합니다. 보통은 문제가 없지만, 다음과 같은 코드/구성은 “요청마다 새로운 클래스 생성”을 유발할 수 있습니다.

  • CGLIB 기반 프록시가 캐시되지 않고 계속 생성되는 패턴
  • 런타임에 새로운 클래스를 계속 만들어내는 라이브러리(ByteBuddy, ASM, Groovy, MVEL 등)
  • 매 요청마다 새로운 ClassLoader를 만들어 그 아래에서 프록시를 생성

자주 보이는 실수 예시: 매번 새 프록시 팩토리 생성

아래는 개념적 예시입니다. 요청마다 프록시를 새로 만들면 프록시 클래스가 계속 늘어날 수 있습니다.

@Service
public class DangerousProxyFactory {

  public Object createProxyPerRequest(Object target) {
    // 매 호출마다 프록시 생성(개념 예시)
    org.springframework.aop.framework.ProxyFactory pf = new org.springframework.aop.framework.ProxyFactory(target);
    pf.addAdvice((org.aopalliance.intercept.MethodInterceptor) invocation -> invocation.proceed());
    return pf.getProxy();
  }
}

해결 방향은 “프록시를 재사용 가능한 범위로 만들기”, “필요한 곳에서만 AOP 적용”, “동적 생성 최소화”입니다.

핵심 원인 3: Devtools, 리스타트 로더, 핫 리로드의 오해

개발 환경에서 spring-boot-devtools는 클래스 로더를 분리하고 재시작을 돕습니다. 운영에 들어가면 보통 devtools를 빼지만, 다음 상황에서는 운영에서도 유사 문제가 생깁니다.

  • 운영에 devtools가 실수로 포함됨
  • 자체 핫 리로드/스크립트 로딩 기능을 운영에 넣음
  • WAS/에이전트가 재정의(redefine) 기능을 반복 수행

운영 빌드에서 devtools가 제외되는지 확인하세요.

dependencies {
  developmentOnly("org.springframework.boot:spring-boot-devtools")
}

빠른 진단 절차: 무엇을 수집하고 어떻게 판단할까

Metaspace는 힙 덤프만으로 부족한 경우가 많습니다. 아래 순서로 접근하면 실패 확률이 줄어듭니다.

1) 클래스 로딩/언로딩 로그 확인

JDK 9+에서는 -Xlog:class+load=info,class+unload=info로 로드/언로드를 관찰할 수 있습니다.

JAVA_TOOL_OPTIONS="-Xlog:class+load=info,class+unload=info" \
java -jar app.jar
  • 언로드 로그가 거의 없다면 클래스 로더가 살아있을 가능성이 큽니다.

2) jcmd로 Metaspace/클래스 통계 확인

운영 서버에서 다음을 주기적으로 찍어 추세를 봅니다.

jcmd $PID GC.class_stats > /tmp/class_stats.txt
jcmd $PID VM.native_memory summary > /tmp/nmt_summary.txt
jcmd $PID GC.heap_info

VM.native_memory-XX:NativeMemoryTracking=summary 또는 detail이 켜져 있어야 의미가 있습니다.

JAVA_TOOL_OPTIONS="-XX:NativeMemoryTracking=summary -XX:+UnlockDiagnosticVMOptions" \
java -jar app.jar

3) 메모리 프로파일링: 힙 덤프 + 클래스 로더 관점

도구는 Eclipse MAT, JProfiler, YourKit 등이 흔합니다.

  • 힙 덤프에서 ClassLoader 인스턴스가 비정상적으로 많은지
  • 특정 ClassLoader가 수많은 Class를 강하게 잡고 있는지
  • ThreadLocalMap이 원인 객체를 붙잡고 있는지

힙 덤프는 “Metaspace 자체”를 직접 보여주기보다는, 언로드를 막는 참조 그래프를 찾는 데 유용합니다.

해결 전략 1: MaxMetaspaceSize는 완화책이지 치료가 아니다

운영 안정화를 위해 일시적으로 Metaspace 상한을 올리는 것은 가능합니다. 다만 누수라면 결국 다시 찹니다.

JAVA_TOOL_OPTIONS="-XX:MaxMetaspaceSize=512m" \
java -jar app.jar
  • 너무 작게 잡으면 불필요한 Full GC가 잦아질 수 있습니다.
  • 너무 크게 잡으면 누수를 더 오래 숨겨 장애 시점을 늦출 뿐입니다.

권장 접근은 “적정 상한 설정 + 누수 제거 + 관측”입니다.

해결 전략 2: 원인별 처방

A) ThreadLocal 정리 패턴 적용

필요한 경우에만 ThreadLocal을 쓰고, 반드시 remove()를 보장합니다.

public final class RequestContextHolder {
  private static final ThreadLocal<String> traceId = new ThreadLocal<>();

  public static void setTraceId(String id) {
    traceId.set(id);
  }

  public static String getTraceId() {
    return traceId.get();
  }

  public static void clear() {
    traceId.remove();
  }
}

그리고 서블릿 필터나 스프링 인터셉터에서 예외가 나도 항상 정리되게 합니다.

@Component
public class TraceIdFilter extends OncePerRequestFilter {
  @Override
  protected void doFilterInternal(
      HttpServletRequest request,
      HttpServletResponse response,
      FilterChain filterChain
  ) throws ServletException, IOException {

    try {
      RequestContextHolder.setTraceId(UUID.randomUUID().toString());
      filterChain.doFilter(request, response);
    } finally {
      RequestContextHolder.clear();
    }
  }
}

B) 동적 프록시/바이트코드 생성을 “요청 범위”에서 제거

  • 프록시 생성 로직을 싱글톤 초기화 시점으로 이동
  • 가능하면 인터페이스 기반 JDK 프록시 사용(상황에 따라 클래스 생성 부담이 다를 수 있음)
  • 런타임 코드 생성 라이브러리의 캐시 정책 확인

또한 AOP가 과도하게 적용되어 프록시 수가 폭증하는 구성(예: 너무 넓은 포인트컷)을 점검하세요. 트랜잭션 AOP도 대표적입니다. 트랜잭션 경계 설계가 꼬이면 프록시 체인이 불필요하게 늘어나는 경우가 있습니다.

관련해서 트랜잭션 설계 함정은 아래 글도 참고할 만합니다.

C) 리플렉션 캐시/메타데이터 캐시가 ClassLoader를 잡지 않게

프레임워크나 자체 유틸에서 Map<Class<?>, ...> 같은 캐시를 둘 때, 애플리케이션 리로드나 플러그인 구조가 있다면 치명적입니다.

  • 단일 클래스 로더 환경이면 큰 문제는 아닐 수 있음
  • 다중 클래스 로더(플러그인, 스크립트, 재시작 로더)가 있다면 WeakReference 또는 WeakHashMap 고려

개념 예시:

public class ReflectionCache {
  // 클래스 언로드가 필요할 수 있는 구조라면 WeakHashMap 고려
  private final Map<Class<?>, List<Field>> cache = new WeakHashMap<>();

  public List<Field> fieldsOf(Class<?> type) {
    return cache.computeIfAbsent(type, t -> List.of(t.getDeclaredFields()));
  }
}

D) 에이전트/관측 도구 점검

APM/프로파일러/바이트코드 인스트루먼트 에이전트가 클래스 변환을 수행합니다. 보통은 안전하지만,

  • 에이전트 버그
  • 특정 라이브러리와의 충돌
  • 재정의/재변환이 반복되는 비정상 설정

등으로 Metaspace 압박이 커질 수 있습니다. 에이전트를 끈 상태에서 재현 여부를 비교해보세요.

운영에서 재발 방지: 관측 지표와 알람

Metaspace 장애는 “터지기 직전”까지 눈치채기 어렵습니다. 다음 지표를 대시보드/알람에 넣는 것을 권장합니다.

  • JVM Metaspace 사용량(사용/커밋)
  • 로드된 클래스 수, 언로드된 클래스 수
  • Full GC 횟수와 소요 시간
  • 프로세스 RSS 및 native 메모리 추세

Spring Boot Actuator와 Micrometer를 쓰면 JVM 메트릭을 손쉽게 노출할 수 있습니다.

컨테이너 환경(Kubernetes)에서의 추가 함정

컨테이너 메모리 제한이 빡빡하면, 힙은 제한 내인데 native 메모리(Metaspace 포함)가 더해져 OOMKilled가 날 수 있습니다.

  • -Xmx만 보고 컨테이너 limit을 잡으면 위험
  • Metaspace, 스레드 스택(-Xss), direct buffer, code cache까지 포함해서 여유를 둬야 함

클러스터에서 파드가 축출/재시작 루프에 빠지는 이슈와도 운영적으로 비슷한 양상을 보일 수 있습니다. 메모리 압박과 스케줄링 관점은 아래 글이 참고됩니다.

체크리스트: 가장 흔한 해결 루트

  1. OOM 시 증거 수집: hs_err_pid%p.log, GC 로그, 가능하면 NMT
  2. jcmd로 클래스/Metaspace 추세 확인
  3. 클래스 언로드가 일어나지 않는다면 클래스 로더 누수 의심
  4. ThreadLocal, 캐시, 스레드 풀, 에이전트, 동적 프록시 생성 지점 점검
  5. 임시 완화로 -XX:MaxMetaspaceSize 상향(하지만 근본 원인 제거가 우선)

마무리

OutOfMemoryError: Metaspace는 힙 튜닝 문제가 아니라 클래스 로딩 생명주기 문제인 경우가 많습니다. “Metaspace를 키워서 해결”은 대개 시간을 버는 정도이고, 진짜 해결은 “왜 클래스가 계속 늘어나고 왜 언로드가 안 되는지”를 찾아내는 데 있습니다.

운영에서 재현이 어렵다면, 로그와 덤프를 남길 옵션을 먼저 넣고, jcmd와 클래스 로딩 로그로 추세를 확인한 뒤, 클래스 로더를 붙잡는 참조(ThreadLocal, 캐시, 에이전트)를 제거하는 방식으로 접근하면 가장 빠르게 수렴합니다.