JVM 클래스로더는 어떻게 JVM을 지탱하는가
Parent Delegation의 보안 원칙부터 바이트코드 검증, 심볼릭 참조 해결, 언로딩 조건, 커스텀 ClassLoader 구현, ClassLoader 격리까지 — JVM 클래스 로딩 전 계층을 추적한다.
- 01 JVM 클래스로더는 어떻게 JVM을 지탱하는가
- 02 JVM은 메모리를 어떻게 나누는가
- 03 JVM 바이트코드는 어떻게 플랫폼을 초월하는가
- 04 JVM은 바이트코드를 어떻게 실행하는가
- 05 JVM GC는 어떻게 살아있는 객체를 판단하는가
- 06 Java Memory Model은 무엇을 추상화하는가
- 07 JVM 동기화는 어떻게 작동하는가
- 08 JVM 튜닝의 철학 — 덜 건드릴수록 더 잘 돌아간다
- 09 JVM은 객체를 어떻게 들여다보는가
String.class.getClassLoader()는 null을 반환한다. Bootstrap ClassLoader가 Java 객체가 아니기 때문이다. 이 작은 사실 하나가 JVM이 클래스 로딩 전체를 어떻게 설계했는지를 압축해서 보여준다. 왜 계층이 필요했고, 그 계층이 보안·일관성·격리라는 세 문제를 동시에 어떻게 해결하는가?
계층 구조와 Parent Delegation — 세 문제를 한 번에
JVM이 ClassLoader 계층을 만든 이유는 세 가지다.
문제 1: 보안 — classpath에 심어둔 가짜 String이 진짜를 대체하면?
문제 2: 일관성 — Object가 여러 버전으로 로드되면 instanceof와 캐스팅이 무너진다
문제 3: 격리 — A는 jackson 2.12, B는 jackson 2.15가 필요한데 동시에 쓰려면?
세 문제의 해답이 Parent Delegation이다. 클래스 로드 요청은 항상 부모로 먼저 올라가고, 부모가 못 찾을 때만 자식이 직접 탐색한다. Bootstrap이 rt.jar의 진짜 String을 먼저 찾아 돌려주므로 classpath의 악성 String은 로드될 기회 자체가 없다.
Bootstrap ClassLoader ← null (C++ 구현, Java 객체 아님)
↑ parent
Platform ClassLoader ← $JAVA_HOME/lib/ext
↑ parent
Application ClassLoader ← -classpath, 내가 만든 클래스
↑ parent
Custom ClassLoader ← Tomcat, OSGi, Spring DevTools 등
Parent Delegation은 강제 규칙이 아니라 권장 패턴이다. Tomcat WebApp ClassLoader는 java.lang.* 같은 핵심 클래스만 부모에 위임하고, 나머지는 웹앱 자체를 먼저 탐색한다. 이 역전 덕분에 WebApp A와 B가 서로 다른 Spring 버전을 같은 JVM에서 공존시킬 수 있다.
Loading → Linking → Initializing — 단계가 분리된 이유
클래스 파일을 읽는 것과 static 블록을 실행하는 것 사이에는 두 단계가 더 있다.
- Loading:
.class바이트를 읽어Class객체 생성 - Linking: 검증(Verification) → 준비(Preparation) → 해결(Resolution)
- Initializing:
<clinit>실행 — 개발자가 작성한 값이 비로소 적용
Preparation 단계에서 static 필드는 타입의 기본값(0, null, false)으로 먼저 세팅된다. 개발자가 지정한 값은 Initializing에서 덮어쓴다. 단, static final int MAX = 100처럼 리터럴로만 이뤄진 컴파일 타임 상수는 Preparation에서 즉시 실제 값이 들어간다. 그래서 이 상수에 접근하는 코드는 해당 클래스를 초기화하지 않아도 값을 얻을 수 있다.
초기화 트리거는 정확히 여섯 가지다: new, static 필드 접근, static 메서드 호출, Class.forName(), 리플렉션 사용, 서브클래스 초기화. ClassLoader.loadClass()는 이 목록에 없다 — Loading과 Linking까지만 수행한다. JDBC 드라이버 등록에 Class.forName()을 써야 하는 이유가 여기 있다. static 블록 안에서 DriverManager.registerDriver()를 호출하기 때문이다.
순환 초기화는 에러가 아니다. A가 B를 필요로 하고 B가 A를 필요로 하면, JVM은 “초기화 진행 중”인 A의 현재(미완성) 상태를 그대로 사용한다. 의도치 않은 0 또는 null이 사용될 수 있다.
바이트코드 검증 — 실행 전에 안전성을 수학적으로 증명한다
JVM이 실행하는 .class 파일은 반드시 javac가 만든 것이 아닐 수 있다. 헥스 에디터로 수정하거나, 바이트코드 조작 도구로 변형하거나, 네트워크에서 가져올 수 있다. Verifier는 출처와 무관하게 안전성을 보장한다.
검증은 네 Pass로 나뉘는데, Pass 3(Data Flow Analysis)이 핵심이다. 모든 실행 경로에서 각 바이트코드 명령어 시점의 operand stack 타입을 정적으로 추론하고, 명령어가 기대하는 타입과 불일치하면 VerifyError를 던진다.
[int] iload_2 → [int, int]
[int, int] iadd → [int] ✅ 타입 일치
[int, int] dadd → ??? ❌ VerifyError: dadd는 double 필요
Java 7에서 도입된 StackMapTable은 컴파일러가 분기 합류점(Join Point)에서의 타입 상태를 미리 계산해 .class 파일에 포함시킨다. JVM은 직접 추론하는 대신 이 표를 확인하기만 하면 된다 — 검증을 O(n) 선형 시간으로 줄이면서 DoS 공격도 방어한다.
실무에서 VerifyError는 내 코드의 버그보다 Lombok, MockK, JaCoCo 같은 바이트코드 조작 라이브러리의 버그나 Java 버전 불일치가 원인인 경우가 훨씬 많다.
심볼릭 참조 해결과 클래스 언로딩
.class 파일은 메서드를 메모리 주소가 아닌 이름 문자열로 참조한다. "com/example/Service.process(Ljava/lang/String;)V" — 이것이 심볼릭 참조다. Resolution은 이 문자열을 실제 메모리 주소나 vtable 오프셋으로 교체한다. 한 번 해결되면 캐시되어 재 Resolution이 없다.
Resolution은 지연(lazy) 실행이 기본이다. 해당 코드가 실제로 실행될 때까지 미룬다. Holder.class가 classpath에 없어도 그 코드 경로가 실행되지 않으면 NoClassDefFoundError가 발생하지 않는 이유다.
NoClassDefFoundError와 ClassNotFoundException을 혼동하기 쉽다. ClassNotFoundException은 Class.forName() 같은 동적 로딩 실패로 복구 가능한 Checked Exception이다. NoClassDefFoundError는 Resolution 실패 — “컴파일 시 있었는데 런타임에 없어진” 상황으로 대부분 배포 문제다.
클래스 언로딩은 세 조건이 모두 충족될 때만 가능하다: ① 인스턴스가 모두 GC됨, ② Class 객체 참조가 없음, ③ 해당 ClassLoader 자체가 GC 대상임. Bootstrap/Platform/Application ClassLoader가 로드한 클래스는 JVM이 직접 참조를 보유하므로 JVM 종료까지 절대 언로딩되지 않는다. 내가 작성한 비즈니스 클래스 전부가 여기 해당한다.
static 필드 → 인스턴스 → Class 객체 → ClassLoader → Metaspace에 적재된 모든 메타데이터. 이 체인 어딘가에 강한 참조가 살아있으면 ClassLoader가 GC되지 않아 Metaspace가 계속 증가한다. ThreadLocal에 저장된 인스턴스와 프레임워크 레벨 정적 등록(DriverManager)이 가장 흔한 원인이다. jstat -class <pid>로 Loaded 숫자가 계속 증가하고 Unloaded가 0에 가깝다면 누수를 의심하라.
커스텀 ClassLoader와 격리 설계
커스텀 ClassLoader를 만들 때 loadClass()가 아닌 findClass()만 오버라이드하는 것이 원칙이다. loadClass()를 오버라이드하면 Parent Delegation 전체 흐름을 바꾸게 되어 java.lang.Object까지 내가 로드하려 하는 사태가 벌어질 수 있다. findClass()는 부모가 모두 실패했을 때만 호출된다.
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] bytes = loadClassBytes(name); // 파일, 네트워크, DB, 복호화 등
if (bytes == null) throw new ClassNotFoundException(name);
return defineClass(name, bytes, 0, bytes.length);
}
Hot Reload의 원리는 단순하다. 같은 ClassLoader는 한 번 로드한 클래스를 캐시하므로 재로드가 불가능하다. 새 ClassLoader 인스턴스를 생성하면 캐시가 없어 파일을 새로 읽는다. 구 ClassLoader에 대한 모든 참조를 끊으면 GC 대상이 된다. Spring Boot DevTools의 Restart ClassLoader가 이 원리 그대로다 — 3rd party 라이브러리는 Base ClassLoader에 남기고, 개발자 코드를 담은 Restart ClassLoader만 교체해 수 초 내에 재시작을 완료한다.
격리된 두 ClassLoader 사이에서 객체를 직접 주고받으면 ClassCastException이 발생한다. 해결책은 인터페이스 브리지 패턴이다. 공통 부모 ClassLoader에 인터페이스를 두고 구현체만 격리된 ClassLoader에서 로드한다. 인터페이스 타입은 양쪽이 같은 ClassLoader에서 가져오므로 캐스팅이 안전하다.
ClassLoader 격리는 클래스 공간을 분리하지만 실행 권한을 제한하지 않는다. 격리된 ClassLoader의 코드도 리플렉션, 파일 I/O, System.exit()을 자유롭게 쓸 수 있다. 진정한 보안 격리가 필요하다면 Java Module System, 별도 JVM 프로세스, 또는 컨테이너 격리를 함께 써야 한다. 반면 격리의 비용도 있다 — 같은 라이브러리가 여러 ClassLoader에 중복 로드되면 Metaspace 사용량이 ClassLoader 수에 비례해 늘어난다. 공유 가능한 API 계층은 공통 부모에 두어 이 비용을 줄인다.
정리
- ClassLoader 계층과 Parent Delegation은 보안·일관성·격리를 하나의 메커니즘으로 해결한다.
- Loading → Linking → Initializing은 별개의 단계다.
static블록은 클래스가 “로드”될 때가 아니라 처음 사용될 때