ClassLoader 심화 — 클래스가 로딩되는 순서와 커스텀 ClassLoader 만들기
new MyClass()를 호출하면 JVM은MyClass의 바이트코드를 어디서, 어떤 순서로 찾아올까요?
자바 프로그램에서 클래스를 사용하면 JVM이 알아서 로드해주니까 평소에는 신경 쓸 일이 없습니다. 하지만 플러그인 시스템, 핫 리로드, 컨테이너 격리 같은 고급 기능을 이해하려면 ClassLoader의 동작 원리를 알아야 합니다.
ClassLoader 계층 구조
ClassLoader 는 .class 파일의 바이트코드를 JVM 메모리에 적재하는 컴포넌트입니다. JVM은 모든 클래스를 한꺼번에 로드하지 않고, 필요한 시점에 계층적 위임을 통해 로드합니다.
Java 9 이후의 ClassLoader 계층은 다음과 같습니다.
Bootstrap ClassLoader (네이티브 코드)
↑ 위임
Platform ClassLoader (구 Extension ClassLoader)
↑ 위임
Application ClassLoader (구 System ClassLoader)
↑ 위임
사용자 정의 ClassLoader
각 ClassLoader의 역할
| ClassLoader | 로드 대상 | 구현 |
|---|---|---|
| Bootstrap | java.lang.*, java.util.* 등 핵심 클래스 | 네이티브(C/C++) |
| Platform | java.sql.*, java.xml.* 등 플랫폼 모듈 | Java |
| Application | 클래스패스/모듈패스의 애플리케이션 클래스 | Java |
각 ClassLoader가 담당하는 영역이 다르기 때문에, 어떤 클래스를 로드했는지 확인하면 해당 클래스가 JDK의 어느 계층에 속하는지 파악할 수 있습니다.
System.out.println(String.class.getClassLoader());
// null (Bootstrap ClassLoader — 네이티브 구현이라 Java에서 null로 표현)
System.out.println(java.sql.Connection.class.getClassLoader());
// jdk.internal.loader.ClassLoaders$PlatformClassLoader
System.out.println(MyApp.class.getClassLoader());
// jdk.internal.loader.ClassLoaders$AppClassLoader
위 결과에서 null이 반환되는 이유는 Bootstrap ClassLoader가 C/C++로 구현되어 Java 객체로 표현할 수 없기 때문입니다.
위임 모델 (Parent Delegation Model)
ClassLoader가 클래스를 로드할 때는 항상 부모에게 먼저 위임 합니다. 이 구조가 없으면 누군가 악의적으로 java.lang.String을 만들어서 핵심 클래스를 대체할 수 있기 때문입니다.
1. 이미 로드된 클래스인지 캐시 확인
2. 캐시에 없으면 → 부모 ClassLoader에게 위임
3. 부모도 못 찾으면 → 자신이 직접 로드 시도
4. 자신도 못 찾으면 → ClassNotFoundException
이 흐름을 코드로 보면 더 명확합니다. ClassLoader.loadClass()의 핵심 로직을 간략화하면 다음과 같습니다.
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
Class<?> c = findLoadedClass(name); // 1. 캐시 확인
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false); // 2. 부모에게 위임
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) { }
if (c == null) {
c = findClass(name); // 3. 자신이 직접 로드
}
}
return c;
}
여기서 핵심은 자신이 직접 로드를 시도하기 전에 반드시 부모에게 먼저 기회를 준다 는 점입니다. 이 덕분에 두 가지 보장이 성립합니다.
- **안전성 **: Bootstrap ClassLoader가 먼저 진짜
java.lang.String을 로드하므로, 사용자 코드로 핵심 클래스를 대체할 수 없습니다. - ** 일관성 **: 같은 클래스가 여러 ClassLoader에 의해 중복 로드되는 것을 방지합니다.
클래스 로딩의 세 단계
클래스가 JVM에서 사용 가능해지려면 로딩 → 링킹 → 초기화 순서를 거칩니다. 각 단계가 분리된 이유는 ** 보안 검증과 지연 초기화 **를 가능하게 하기 위해서입니다.
1단계: 로딩 (Loading)
.class 파일의 바이트코드를 읽어서 JVM 메모리에 올립니다. 소스는 파일 시스템, JAR, 네트워크 등 다양할 수 있습니다.
2단계: 링킹 (Linking)
링킹은 세 하위 단계로 나뉩니다.
- ** 검증(Verification)**: 바이트코드가 JVM 명세에 맞는지 확인 — 잘못된 바이트코드가 JVM을 크래시시키는 것을 방지
- ** 준비(Preparation)**:
static필드에 기본값 할당 (0, null, false) — 아직 실제 값은 아님 - ** 해석(Resolution)**: 심볼릭 참조를 실제 참조로 변환 (선택적, lazy 가능)
3단계: 초기화 (Initialization)
static 블록 실행, static 필드에 실제 값 할당이 이루어집니다. 다음 코드에서 준비 단계와 초기화 단계의 차이를 확인할 수 있습니다.
public class Config {
// 준비 단계: dbUrl = null (기본값)
// 초기화 단계: dbUrl = "jdbc:mysql://..." (실제 값)
static String dbUrl = loadFromFile();
static {
System.out.println("Config 클래스 초기화됨");
}
}
여기서 중요한 점은 초기화가 ** 클래스가 처음 능동적으로 사용될 때** 비로소 발생한다는 것입니다. Class.forName()으로 로드만 하면 초기화가 실행되지만, ClassLoader.loadClass()로 로드하면 초기화 없이 로딩+링킹만 됩니다. 능동적 사용의 조건은 다음과 같습니다.
new키워드로 인스턴스 생성static메서드 호출static필드 접근 (final 상수 제외)- 리플렉션으로
Class.forName()호출 - 하위 클래스 초기화 시 상위 클래스
클래스의 정체성
JVM에서 클래스의 고유성은 ** 완전한 이름(FQCN) + ClassLoader**로 결정됩니다.
ClassLoader loader1 = new URLClassLoader(urls);
ClassLoader loader2 = new URLClassLoader(urls);
Class<?> class1 = loader1.loadClass("com.example.Plugin");
Class<?> class2 = loader2.loadClass("com.example.Plugin");
System.out.println(class1 == class2); // false — 다른 클래스!
System.out.println(class1.getName().equals(class2.getName())); // true — 이름은 같음
이 특성 때문에 서로 다른 ClassLoader로 로드한 같은 이름의 클래스 간에는 캐스팅이 불가능합니다.
Object obj = class1.getDeclaredConstructor().newInstance();
// class2의 타입으로 캐스팅하면 ClassCastException 발생!
커스텀 ClassLoader 만들기
커스텀 ClassLoader를 만들 때는 loadClass()가 아닌 findClass()를 오버라이드 합니다. loadClass()를 오버라이드하면 위임 모델 자체를 변경하게 되어 위험하기 때문입니다. findClass()를 오버라이드하면 부모가 못 찾은 경우에만 커스텀 로직이 실행됩니다.
다음은 특정 디렉토리에서 .class 파일을 읽어 로드하는 플러그인 ClassLoader입니다.
public class PluginClassLoader extends ClassLoader {
private final Path pluginDir;
public PluginClassLoader(Path pluginDir, ClassLoader parent) {
super(parent);
this.pluginDir = pluginDir;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
String fileName = name.replace('.', '/') + ".class";
Path classFile = pluginDir.resolve(fileName);
try {
byte[] bytes = Files.readAllBytes(classFile);
return defineClass(name, bytes, 0, bytes.length); // ← 바이트 배열 → Class 객체
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
}
핵심은 defineClass() 호출입니다. 이 메서드가 바이트 배열을 JVM이 인식하는 Class<?> 객체로 변환합니다. 사용법은 다음과 같습니다.
Path pluginDir = Path.of("/opt/plugins/my-plugin/classes");
PluginClassLoader loader = new PluginClassLoader(pluginDir, getClass().getClassLoader());
Class<?> pluginClass = loader.loadClass("com.example.MyPlugin");
Plugin plugin = (Plugin) pluginClass.getDeclaredConstructor().newInstance();
plugin.execute();
이 패턴을 응용하면 암호화된 클래스 파일을 로드하는 것도 가능합니다. findClass() 안에서 바이트 배열을 복호화한 뒤 defineClass()에 넘기면 됩니다.
public class EncryptedClassLoader extends ClassLoader {
private final byte[] key;
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
String path = name.replace('.', '/') + ".enc";
try {
byte[] encrypted = Files.readAllBytes(Path.of("classes", path));
byte[] decrypted = decrypt(encrypted, key);
return defineClass(name, decrypted, 0, decrypted.length);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
}
바이트코드의 출처가 파일이든 네트워크든 암호화된 스트림이든, findClass() → defineClass() 패턴은 동일합니다.
핫 리로드 구현 원리
한 번 로드된 클래스는 같은 ClassLoader에서 다시 로드할 수 없습니다. JVM이 클래스의 정체성을 FQCN + ClassLoader 조합으로 결정하기 때문에, 같은 ClassLoader에서 같은 이름의 클래스를 두 번 로드하면 이미 캐시된 결과가 반환됩니다.
그래서 핫 리로드는 ClassLoader 자체를 새로 만드는 방식 으로 동작합니다. 기존 ClassLoader를 버리고 새 ClassLoader를 생성하면, 같은 이름의 클래스라도 새 바이트코드를 로드할 수 있습니다.
public class HotReloader {
private ClassLoader currentLoader;
private Object plugin;
public void reload() throws Exception {
currentLoader = null; // 기존 ClassLoader 참조 해제 (GC 대상)
plugin = null;
currentLoader = new PluginClassLoader(
Path.of("/opt/plugins"), getClass().getClassLoader()
);
Class<?> pluginClass = currentLoader.loadClass("com.example.MyPlugin");
plugin = pluginClass.getDeclaredConstructor().newInstance();
}
}
Spring DevTools, JRebel 같은 도구들이 이 원리를 활용합니다. 다만 타입 호환 문제를 해결하기 위해, 인터페이스는 부모 ClassLoader에 두고 구현체만 자식 ClassLoader에서 교체하는 구조를 사용합니다.
부모 ClassLoader: Plugin 인터페이스 로드 (불변)
↑
자식 ClassLoader (교체 가능): PluginImpl 클래스 로드
주의할 점
메모리 누수 — ClassLoader가 GC되지 않는 경우
핫 리로드에서 가장 흔한 사고는 이전 ClassLoader가 GC되지 않아 Metaspace가 꽉 차는 것 입니다. ClassLoader가 GC되려면 해당 ClassLoader가 로드한 모든 클래스의 모든 인스턴스 가 먼저 GC되어야 합니다.
- 기존 ClassLoader로 로드한 객체를
static필드나 ThreadLocal에 저장해두면, 그 참조가 ClassLoader → 모든 Class 객체 → Metaspace 메모리를 붙잡고 있게 됩니다 - 리로드를 반복할수록 이전 ClassLoader들이 계속 쌓이면서
OutOfMemoryError: Metaspace발생 - 프로덕션에서 이 문제를 디버깅하려면 힙 덤프에서
ClassLoader인스턴스 수를 확인해야 합니다
Thread Context ClassLoader 혼동
위임 모델은 항상 아래에서 위로 올라가지만, JDBC나 JNDI 같은 SPI는 ** 위(Bootstrap)에서 아래(Application)의 클래스를 찾아야** 합니다. 이 역방향 참조를 위해 Thread Context ClassLoader가 존재합니다.
ClassLoader contextCL = Thread.currentThread().getContextClassLoader();
// 웹 컨테이너가 스레드별로 적절한 ClassLoader를 설정
이 패턴을 모르면 웹 컨테이너에서 ClassNotFoundException이 발생하는 원인을 찾기 어렵습니다.
ClassNotFoundException vs NoClassDefFoundError
이 두 예외는 이름이 비슷하지만 원인이 전혀 다릅니다.
| 예외 | 원인 | 발생 시점 |
|---|---|---|
ClassNotFoundException | Class.forName() 등으로 클래스를 찾았지만 없을 때 | 런타임에 동적 로딩 시 |
NoClassDefFoundError | 컴파일 시엔 있었지만 런타임에 찾지 못할 때 | 의존 라이브러리 누락 시 |
NoClassDefFoundError가 더 치명적입니다. 빌드는 성공했는데 배포 환경에서 특정 JAR가 누락된 경우 발생하기 때문입니다.
정리
| 항목 | 설명 |
|---|---|
| 위임 모델 | 부모에게 먼저 위임 → 못 찾으면 자신이 로드. 핵심 클래스 보호 |
| 클래스 정체성 | FQCN + ClassLoader 조합으로 결정. 같은 이름이라도 다른 ClassLoader면 다른 클래스 |
| 로딩 3단계 | 로딩(바이트코드 읽기) → 링킹(검증/준비/해석) → 초기화(static 블록 실행) |
| 커스텀 구현 | findClass()를 오버라이드. loadClass()는 위임 모델을 깨므로 비권장 |
| 핫 리로드 | ClassLoader 자체를 교체. 이전 ClassLoader의 참조가 남으면 Metaspace 누수 |
| Thread Context CL | SPI에서 역방향 참조가 필요할 때 사용하는 우회 메커니즘 |