도입
자바를 처음 배울 때는 ClassLoader를 “클래스를 읽어 오는 도구” 정도로 이해하기 쉽습니다. 하지만 실제 런타임에서는 훨씬 더 중요합니다.
JVM은 클래스를 Loading, Linking, Initialization 단계로 준비하고, ClassLoader는 그 진입점에서 어떤 클래스를 누구에게 위임해 어떻게 정의할지를 결정합니다. 결국 ClassLoader를 이해해야 ClassNotFoundException, NoClassDefFoundError, 버전 충돌, SPI/JDBC 로딩, 플러그인 격리 문제를 구조적으로 해석할 수 있습니다.
필요성
JVM에서 클래스의 정체성은 이름만으로 끝나지 않습니다. 런타임에서는 binary name + defining loader가 함께 클래스 정체성을 이룹니다. 그래서 FQCN이 같더라도 누가 정의했는지가 다르면 다른 타입처럼 취급될 수 있습니다.
이 관점이 중요한 이유는 단순합니다. 애플리케이션 서버, 테스트 격리 환경, 플러그인 구조, SPI, JDBC 드라이버 검색처럼 로더 경계가 여러 층으로 나뉘는 순간, 문제는 “코드가 틀렸다”가 아니라 “어떤 로더가 무엇을 봤느냐”로 바뀌기 때문입니다.
정의
ClassLoader는 자바 런타임에서 클래스와 리소스를 찾는 추상 기반 클래스입니다. 보통은 클래스 이름을 파일 경로로 바꿔 JAR 또는 디렉터리에서 읽어 오지만, 꼭 파일 시스템만 고집할 필요는 없습니다. 네트워크, 메모리, 암호화된 저장소, 생성된 바이트코드도 소스가 될 수 있습니다.
중요한 점은 ClassLoader가 “파일을 읽는 역할”에서 끝나지 않는다는 것입니다. 실제 바이트를 읽은 뒤에는 defineClass를 통해 JVM이 이해하는 타입으로 등록해야 하고, 이때 어떤 로더가 정의했는지가 이후 타입 비교와 가시성에 계속 영향을 줍니다.
- 클래스의 바이너리 표현을 찾거나 생성하기
- 부모 로더에게 먼저 위임할지 결정하기
defineClass로 실제 타입 정의하기- 클래스뿐 아니라 리소스 검색도 담당하기
클래스 로딩 생명주기
실무에서 가장 흔한 오해는 “클래스가 로드됐다 = 실행 준비가 끝났다”라고 보는 것입니다. 하지만 JVM은 이 과정을 더 세밀하게 나눕니다.
| 단계 | 설명 | 실무 포인트 |
|---|---|---|
| Loading | 클래스의 바이너리 표현을 찾고 Class 객체를 만드는 단계 |
ClassLoader가 직접 관여하는 입구 |
| Linking | Verification, Preparation, Resolution을 통해 실행 가능 상태로 결합 | 버전 충돌, 심볼 참조 오류가 자주 드러남 |
| Initialization | static 초기화 블록과 static 필드 초기화 식 실행 | 부작용과 예외가 가장 체감되기 쉬운 지점 |
Preparation 단계는 특히 자주 헷갈립니다. 이 단계는 static 필드를 기본값으로 준비하는 과정이지, 개발자가 적은 실제 초기화 식을 실행하는 단계가 아닙니다. 명시적 static 초기화는 Initialization에서 실행됩니다.
또 하나 중요한 포인트는 Resolution이 언제 끝나는지가 구현마다 늦춰질 수 있다는 점입니다. 즉, 링크 오류가 클래스 로드 직후가 아니라 실제 참조 사용 시점에 터질 수도 있습니다.
Loading
↓
Linking
├─ Verification
├─ Preparation
└─ Resolution
↓
Initialization
내장 ClassLoader 종류
| 로더 | 역할 | 메모 |
|---|---|---|
| Bootstrap | VM 내장 로더 | 보통 null로 표현되며 부모가 없음 |
| Platform | 플랫폼 클래스 로드 | Java SE API 및 런타임 클래스 가시성의 중간 축 |
| System / Application | 애플리케이션 class path, module path 기반 로드 | 대부분의 애플리케이션 코드가 여기에 정의됨 |
일반적인 애플리케이션 코드는 대부분 system loader 쪽에서 시작합니다. 또한 새 ClassLoader를 만들 때 기본 위임 부모로 system loader가 사용되는 경우가 많기 때문에, 이 계층을 기준으로 가시성 문제가 발생합니다.
System.out.println(String.class.getClassLoader()); // null (bootstrap)
System.out.println(ClassLoader.getPlatformClassLoader()); // platform loader
System.out.println(ClassLoader.getSystemClassLoader()); // app/system loader
부모 위임 모델
Oracle 문서 기준으로 loadClass(name, resolve)의 기본 구현은 다음 순서를 따릅니다.
findLoadedClass(name)로 이미 로드됐는지 확인한다.- 부모 ClassLoader에
loadClass를 위임한다. - 부모가 찾지 못하면 현재 로더의
findClass(name)를 호출한다. resolve == true라면 마지막에resolveClass를 수행한다.
이 구조 덕분에 공통 클래스가 여러 곳에서 제멋대로 중복 정의되는 일을 줄일 수 있고, 상위 계층이 먼저 표준 라이브러리와 공용 클래스를 장악하게 됩니다. 그래서 기본 ClassLoader 설계는 “내가 먼저 찾는다”가 아니라 “부모가 먼저 본다”에 가깝습니다.
loadClass와 findClass
loadClass는 “어떤 순서로 찾을 것인가”를 담당합니다. 반면 findClass는 “부모가 못 찾았을 때, 내 방식으로 실제 클래스를 어디서 구해 정의할 것인가”를 담당합니다.
그래서 커스텀 로더를 만들 때는 대개 loadClass 전체를 뒤엎기보다 findClass를 오버라이드하는 쪽이 안전합니다. Oracle 문서도 서브클래스는 보통 findClass를 오버라이드하라고 안내합니다.
public class ByteArrayClassLoader extends ClassLoader {
public ByteArrayClassLoader(ClassLoader parent) {
super(parent);
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] bytes = loadBytes(name); // 직접 구현
if (bytes == null) {
throw new ClassNotFoundException(name);
}
return defineClass(name, bytes, 0, bytes.length);
}
private byte[] loadBytes(String name) {
// 파일, 네트워크, 메모리 등에서 바이트를 읽는 로직
return null;
}
}
URLClassLoader로 충분한 경우가 많습니다. 처음부터 모든 로직을 직접 구현하기보다, 정말 필요한 정책만 커스터마이즈하는 편이 유지보수에 유리합니다.초기화와 Class.forName
JLS 기준으로 클래스 또는 인터페이스는 특정 조건에서만 초기화됩니다. 대표적으로 인스턴스 생성, static 메서드 호출, static 필드 대입, 상수가 아닌 static 필드 사용, 일부 리플렉션 호출이 트리거가 됩니다.
이 때문에 “로드됨”과 “초기화됨”은 같은 말이 아닙니다. static 초기화 블록이 무거운 코드일수록 이 차이가 중요해집니다.
- 클래스 인스턴스를 생성할 때
- 그 클래스가 선언한 static 메서드를 호출할 때
- 그 클래스가 선언한 static 필드에 값을 대입할 때
- 상수가 아닌 static 필드를 사용할 때
- 일부 reflective API가 초기화를 유발할 때
ClassLoader loader = Thread.currentThread().getContextClassLoader();
Class<?> a = Class.forName("com.example.Plugin"); // 초기화 O
Class<?> b = Class.forName("com.example.Plugin", false, loader); // 로드 O, 초기화 X
Class.forName("X")는 사실상 Class.forName("X", true, currentLoader)와 같은 의미라서 초기화를 유발합니다. 반대로 initialize = false를 주면 로드만 하고 static 초기화는 미룰 수 있습니다.Thread Context ClassLoader
Thread.getContextClassLoader()는 스레드 생성자가 제공하는 로더입니다. 설정하지 않으면 기본적으로 부모 스레드의 context loader를 이어받고, 원시(main) 스레드 쪽에서는 보통 애플리케이션을 시작한 system loader가 들어갑니다.
이 로더가 중요한 이유는 일부 프레임워크와 표준 API가 실제 구현체 탐색에 이 값을 사용하기 때문입니다. 대표적으로 ServiceLoader.load(service)는 현재 스레드의 context ClassLoader를 사용하고, JDBC DriverManager도 드라이버 provider 탐색에 이를 활용합니다.
ClassLoader tccl = Thread.currentThread().getContextClassLoader();
ServiceLoader<MyService> services = ServiceLoader.load(MyService.class);
// ServiceLoader.load(service)는 현재 스레드의 context ClassLoader를 사용
커스텀 ClassLoader
커스텀 로더는 “특정 위치에서 클래스를 읽는다” 수준을 넘어서, “무엇을 누구에게 먼저 위임할지”, “어떤 클래스를 로컬에서만 정의할지”, “어떤 패키지를 격리할지” 같은 정책을 담습니다.
Oracle 문서 예시처럼 클래스는 네트워크에서 받아도 되고, 애플리케이션이 직접 조립한 바이트 배열에서 정의해도 됩니다. 중요한 것은 defineClass로 JVM 타입을 만들기 전에 이름, 패키지, 중복 정의 여부, 부모 위임 규칙을 일관되게 유지하는 것입니다.
- 플러그인 또는 확장 모듈을 격리해 로딩해야 할 때
- 외부 JAR / URL / 네트워크에서 동적으로 로딩해야 할 때
- 메모리상의 바이트코드를 즉시 정의해야 할 때
- 기본 부모 위임 규칙과 다른 검색 정책이 필요할 때
자주 만나는 오류
| 예외 / 에러 | 주로 의미하는 것 | 읽는 포인트 |
|---|---|---|
| ClassNotFoundException | 문자열 이름 기반 로드 시 클래스를 찾지 못함 | reflection, 직접 로딩, 커스텀 로더에서 많이 봄 |
| NoClassDefFoundError | 필요한 클래스 정의를 relevant loader가 찾지 못함 | 컴파일 시점엔 있었지만 런타임 경로에서 사라진 경우를 의심 |
| ExceptionInInitializerError | static 초기화 중 예외 발생 | ClassLoader 문제가 아니라 static 부작용일 수도 있음 |
| NoSuchMethodError / NoSuchFieldError | 링크 시 심볼 참조 대상이 현재 런타임에 없음 | 대개 버전 충돌, 바이너리 비호환성 |
| VerifyError | 검증 단계에서 바이트코드 구조 문제 발견 | 바이트코드 조작, 프록시, 에이전트, 손상된 클래스 의심 |
LinkageError 계열은 특히 중요합니다. 소스코드가 아니라 바이너리 호환성이 깨졌을 때 드러나는 경우가 많기 때문입니다. 그래서 이 계열의 예외는 “컴파일은 됐는데 런타임에 깨진다”는 상황과 연결해서 봐야 합니다.
디버깅
ClassLoader가 실제로 무엇인지 출력해 본다.Thread.currentThread().getContextClassLoader()가 의도한 값인지 본다.loadClass를 과도하게 뒤엎지 않았는지, findClass 중심으로 설계했는지 점검한다.System.out.println("Foo loader = " + Foo.class.getClassLoader());
System.out.println("Bar loader = " + Bar.class.getClassLoader());
System.out.println("TCCL = " + Thread.currentThread().getContextClassLoader());
요약
- ✅ ClassLoader는 클래스와 리소스를 찾고 정의하는 런타임 핵심 인프라다.
- ✅ 클래스 준비는 Loading → Linking → Initialization으로 나뉜다.
- ✅ Java 9+ 기준 내장 로더는 bootstrap, platform, system(application)이다.
- ✅ 기본
loadClass는 이미 로드 여부 확인 → 부모 위임 →findClass순서로 동작한다. - ✅ 커스텀 로더는 보통
findClass를 오버라이드하는 쪽이 안전하다. - ✅
Class.forName("X")는 초기화를 유발하고,initialize = false로 이를 미룰 수 있다. - ✅ TCCL은 ServiceLoader, JDBC 같은 동적 탐색 패턴에서 매우 중요하다.
- ✅ ClassLoader 문제는 로딩, 링크, 초기화 중 어느 단계의 문제인지 나눠서 봐야 빠르게 풀린다.