팀 프로젝트 festabook에서 학습한 내용을 정리한 글입니다.
시스(CS 스터디)에서 발표한 내용을 정리한 글입니다.
💭 들어가며
본 글은 시스(CS 스터디)에서 발표한 내용을 정리한 자료로, 발표용 흐름에 맞추어 순차적으로 구성되어 있다. 개념에 대한 보다 정확한 이해가 필요하다면, 시스 유튜브에 업로드된 영상 자료를 함께 참고 바란다.
✅ 문제 상황

JVM 상태를 관찰하던 중, Old 영역 사용량이 높지 않음에도 불구하고 Full GC가 간헐적으로 발생하는 현상을 확인했다. 원인을 파악하며 Heap 영역보다 Metaspace 영역의 사용량이 비정상적으로 크다는 점을 발견했다.
Old 영역도 여유가 있는데, 왜 Full GC가 발생하는 걸까?
✅ 사전 지식
▶ JVM 메모리 구조

JVM 메모리는 크게 다음과 같이 구성된다.
- Heap
- Young(S0, S1)
- Old
- Metaspace
이 중 Metaspace는 Heap 바깥(Native Memory)에 존재한다.
▶ Native Memory
Native Memory란 OS로부터 할당받은 전체 메모리 영역 중 Heap(-Xmx)으로 잡히지 않은 나머지 영역을 말한다.
✅ 파고들기
▶ Metaspace란
Metaspace는 JVM에서 클래스 메타데이터(Class Metadata)를 저장하는 영역이다. 객체 인스턴스가 아닌, 클래스 자체를 설명하기 위한 정보가 이 영역에 저장된다.
여기에 저장되는 정보는 다음과 같다.
- 클래스 구조 정보 (필드, 메서드 시그니처)
- 상수 풀(Constant Pool)
- 메서드 바이트코드
- 어노테이션 메타정보
- 리플렉션 관련 메타데이터
🔽 Java 7: PermGen

Java 8 이전에는 Metaspace가 아닌 PermGen(Permanent Generation)이 이 역할을 담당했다.
하지만 PermGen은 다음과 같은 구조적 한계를 가지고 있었다.
- Heap 내부에 존재
- 크기가 고정됨
→ 클래스 로딩이 많아지면 쉽게 OutOfMemoryError: PermGen space가 발생했다.
🔽 Java 8: Metaspace

Java 8부터 PermGen은 제거되고 Metaspace가 도입되었다.
이 변화의 핵심은 클래스 메타데이터를 Heap 영역에서 분리해, Native Memory로 이동시킨 것이다.
🔽 PermGen과 Metaspace의 차이
| PermGen | Metaspace | |
| 위치 | Heap 내부 | Native Memory |
| 크기 | 고정 | 기본 무제한 |
| 관리 | JVM 힙 GC | Native 메모리 관리 |
▶ Metaspace와 객체의 관계

자바 객체 인스턴스는 Heap 메모리에 저장된다. 하지만 객체 내부에는 자신이 어떤 클래스의 인스턴스인지를 가리키는 Klass Pointer가 포함되어 있다. 이 포인터는 Metaspace에 저장된 클래스 메타데이터를 참조한다.
객체가 메서드를 호출할 때의 흐름은 다음과 같다.
- 객체 내부의 Klass Pointer를 통해
- Metaspace에 존재하는 클래스 메타데이터를 참조하고
- 그 안에 정의된 메서드 정보를 찾아 실행한다.
즉, Metaspace가 없다면 객체는 자신이 어떤 구조를 가지는지, 어떤 메서드를 실행해야 하는지조차 알 수 없다. 객체는 Heap에 존재하지만, 그 동작의 기준은 Metaspace에 정의된 클래스 정보에 의해 결정된다.
▶ Metaspace 회수 조건
Oracle 공식 문서에 따르면, Metaspace가 실제로 회수되기 위해서는 다음 조건이 모두 충족되어야 한다.
- 클래스를 로딩한 ClassLoader라는 객체가 GC 대상
- 해당 ClassLoader가 참조하던 클래스들이 모두 미사용 상태
▶ Metaspace는 왜 정적으로 보일까
운영 환경에서 Metaspace를 관찰하면 사용량이 거의 줄어들지 않고 지속적으로 증가하는 것처럼 보이는 경우가 많다. 이는 Spring 환경에서 특정 ClassLoader가 잘 회수되지 않는 구조적 이유 때문이다.
- Spring은 애플리케이션 전반을 관리하기 위해 ApplicationContext를 사용
- 다음은 ApplicationContext에 의해 생성 및 관리
- Controller / Service / Repository
- @Configuration
- @Component
- AOP Proxy
- Scheduler Task
- Listener
애플리케이션 수명주기 ≈ ApplicationContext 수명주기
≈ 해당 ClassLoader 수명주기
▶ (결론) Metaspace와 Full GC의 관계
GC는 기본적으로 Heap 객체 그래프를 기준으로 살아 있는 객체를 판별한다. 이 때문에 네이티브 메모리 영역에 위치한 Metaspace는 GC 대상이 아니라고 생각했다.
- 그러나 Metaspace에 적재된 클래스 메타데이터는 Heap 객체인 ClassLoader와 강하게 결합되어 있다. 클래스는 이를 로딩한 ClassLoader가 살아 있는 동안 유지되며, ClassLoader가 GC 대상이 될 때만 클래스 언로딩과 함께 Metaspace 회수가 가능하다.
- 문제는 일반적인 Spring 기반 서버 애플리케이션에서 Application ClassLoader가 애플리케이션 전반에 걸쳐 장기간 참조되며, 대부분 Old 영역에 머문 채 GC되지 않는다는 점이다. 이로 인해 Metaspace 회수는 Full GC 단계에서만 제한적으로 발생하며, 운영 환경에서는 사용량이 거의 줄어들지 않는 것처럼 관측된다.
- 이번 사례에서는 서버를 Scale Down하면서 전체 가용 메모리가 감소한 상태에서 클래스 로딩이 빈번하게 발생했다. 그 결과 Metaspace 압박이 빠르게 증가했고, 이를 완화하기 위해 Full GC가 반복적으로 트리거되었다. Full GC는 힙 객체 정리뿐 아니라 클래스 언로딩 가능 여부를 판단하기 위해 ClassLoader 그래프까지 탐색해야 하므로, 처리 시간이 길어질 수밖에 없다.
즉, 관측된 Full GC는 GC 기반 Metaspace 회수 + 제한된 메모리 환경 + 회수되지 않는 ClassLoader 구조가 결합된 결과로 해석할 수 있다.
✅ 확장하기
▶ 주의해야 할 Metaspace 이슈
🔽 동적 프록시 / AOP 남용
- Spring AOP
- CGLIB
- ByteBuddy
- SpEL 문법
이들은 모두 잘못 사용했을 때 Runtime에 클래스를 생성하게 될 수 있는데, 클래스 수 폭증 → Metaspace 고갈 → Full GC 반복 → OOM으로 이어질 수 있다.
▶ Metaspace 관련 주요 JVM 옵션

Metaspace가 Native Memory이기 때문에, JVM이 먼저 막아주지 않아 문제 발생 시 OS 레벨에서의 위험이 있다. 따라서 직접적으로 서버 사양과 고려하여 명시해 주는 것이 좋다.
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
- MetaspaceSize
- Metaspace GC 트리거 기준점
- 지정해 주지 않으면, JVM이 초기 클래스 로딩 규모를 보고 동적으로 잡음
- MaxMetaspaceSize
- Metaspace 최대 한계
- 초과 시 즉시 OutOfMemoryError: Metaspace
- 지정해 주지 않으면, 제한 없음
📍 참고 자료
- Oracle 공식 문서 - HotSpot Virtual Machine Garbage Collection Tuning Guide
- Oracle 공식 문서 - 11 Other Considerations
- Oracle 공식 문서 - Chapter 12. Execution
- OpenJDK 공식 문서 - HotSpot Glossary of Terms
- Spring 공식 문서 - Proxying Mechanisms
- Spring 공식 문서 - Spring Expression Language (SpEL)
- ByteBuddy 공식 문서 - Welcome,I'm your Byte Buddy!
- 사진