이전 포스팅에서 프레임워크가 리플렉션을 사용해 런타임에 객체를 동적으로 생성하고 주입하는 제어의 역전(IoC) 기초를 살펴보았다.
https://hsunnystory.tistory.com/272
[Java] 어노테이션(@)은 그저 마커(Maker)일 뿐이다. (Reflection API 기초)
실무에서 대규모 레거시 시스템을 걷어내거나 새로운 아키텍처를 설계하다 보면, 많은 개발자가 프레임워크의 동작 방식을 마법처럼 여긴다는 것을 알게 된다. 코드를 작성하다가 객체 주입이
hsunnystory.tistory.com
하지만 여기서 한 가지 근본적인 의문이 생긴다. 리플렉션으로 클래스를 분석하려면 그 클래스가 이미 JVM 메모리 어딘가에 올라와 있어야 한다. 그렇다면 하드디스크에 얌전히 저장된 수많은 .class 파일들은 도대체 언제, 어떻게 메모리(Metaspace)로 올라오는 것일까?
이번 포스팅에서는 스프릉 프레임워크와 WAS가 런티임의 압도적인 유연성을 확보하기 위해 사용하는 핵심 무기인 클래스로더(ClassLoader)의 동작 원리와 동적 로딩 아키텍처에 대해 다루어 본다.
1. 자바는 모든 것을 한 번에 로딩하지 않는다 (Lazy Loading)
C나 C++ 같은 언어는 컴파일 단계에서 링킹(Linking)을 거쳐 거대한 하나의 실행 파일을 만든다. 반면 자바는 애플리케이션이 실행될 때 프로젝트에 100개의 클래스가 있다고 해서 100개를 모두 메모리에 올리지 않는다.
실제로 해당 클래스가 코드 내에서 처음 호출되는 순간(예: new 키워드로 객체를 생성하거나, 정적 메서드에 접근할 때) JRE는 "어? 이 클래스는 아직 메모리에 없네?"라고 판단하고 그제야 클래스로더를 호출해 디스크에서 해당 클래스를 찾아 메모리에 적재한다. 이를 동적 로딩(Dynamic Loading)이라고 부른다.
이러한 지연 로딩(Laze Loading) 덕분에 자바 애플리케이션은 초기 기동 속도를 최적화하고 한정된 메모리를 효율적으로 방어할 수 있다.
2. 위임 계층 모델 (Delegation Model)의 철학
자바의 클래스로더는 단일 객체가 아니라 철저한 계층 구조를 이루고 있다. 특정 클래스를 찾아달라는 요청이 들어오면, 본인이 직접 찾기 전에 무조건 부모 클래스로더에게 책임을 위임(Delegation)한다.
a. Bootstrap ClassLoader: 차상위 계층. java.lang.String 같은 자바 코어 라이브러리를 로드한다.
b. Platform(Extension) ClassLoader: 자바 확장 라이브러리들을 로드한다.
c. System(Application) ClassLoader: 우리가 classpath에 작성한 애플리케이션 비즈니스 클래스들을 로드한다.
만약 OrderService 라는 클래스를 로드해야 한다면, System이 Platform에게, Platform이 Bootstrap에게 로딩을 위임한다. Bootstrap이 "내 구역엔 없는데?" 라고 한다면 다시 Platform으로 내려오고, 결국 System이 애플리케이션 클래스패스를 뒤져 로딩을 완료하는 식이다. 이 엄격한 위임 모델 덕분에, 해커가 임의로 조작한 가짜 java.lang.String 클래스를 주입하여 코어 라이브러리를 오염시키는 것을 원천 차단할 수 있다.
3. [Bad vs Good Code] 런타임에 외부 플러그인 로딩하기
이러한 클래스로더의 특성을 제대로 이해하면, 서버를 내리지 않고도 새로운 기능을 런타임에 추가하는 플러그인 아키텍처를 설계할 수 있다. 의료나 금융권의 보안 인프라 시스템에서 디바이스 인증을 위해 초기에는 RSA 인증서만 사용하다가, 운영 중에 런타임으로 ECDSA 알고리즘을 긴급히 추가해야 하는 상황을 가정해 보자.
[Bad Code] 구체 클래스에 강하게 결합되어 재배포가 필수적인 아키텍처
새로운 인증 모듈이 추가될 때마다 핵심 서버 코드를 수정하고 시스템을 전체 재시작해야 한다.
@Service
public class AuthenticationService {
// 코드가 특정 구체 클래스에 강하게 결합되어 유연성이 0에 수렴함
public void authenticate(String payload, String algoType) {
if ("RSA".equals(algoType)) {
RsaAlgorithm rsa = new RsaAlgorithm();
rsa.sign(payload);
} else if ("ECDSA".equals(algoType)) {
// 운영 중 ECDSA가 추가되면 메인 서버 코드를 수정하고, 재컴파일 후 시스템을 재시작해야 함
EcdsaAlgorithm ecdsa = new EcdsaAlgorithm();
ecdsa.sign(payload);
}
}
}
문제점
새로운 요구사항이 생길 때마다 핵심 도메인 로직이 오염되며, 무엉ㅅ보다 서비스 무중단 요건을 지킬 수 없다.
[Good Code] 커스텀 클래스로더를 활용한 동적 플러그인(Plugin) 아키텍처
URLClassLoader를 사용하면, 메인 애플리케이션의 클래스패스에 존재하지 않는 외부 디렉토리의 .class 파일(.jar)을 런타임에 긁어와 메모리에 올릴 수 있다. 서버 코드는 단 한 줄도 수정되지 않는다.
@Service
public class AuthenticationService {
public void authenticateWithPlugin(String payload, String pluginPath, String className) throws Exception {
// 1. 외부 플러그인 경로(예: /app/security/plugins/)를 바라보는 커스텀 클래스로더 생성
File pluginDir = new File(pluginPath);
URLClassLoader pluginLoader = new URLClassLoader(new URL[]{pluginDir.toURI().toURL()});
// 2. 런타임에 외부 클래스를 동적으로 로딩 (서버 재시작 불필요)
Class<?> pluginClass = pluginLoader.loadClass(className);
// 3. 공통 인터페이스(SignatureAlgorithm)로 캐스팅하여 다형성 활용
SignatureAlgorithm algorithm = (SignatureAlgorithm) pluginClass.getDeclaredConstructor().newInstance();
algorithm.sign(payload);
// 4. 사용이 끝난 클래스로더는 메모리 누수 방지를 위해 close 처리
pluginLoader.close();
}
}
결과
새로운 클라이언트를 위해 완전히 새로운 알고리즘 모듈이 필요해지면, 해당 모듈만 컴파일하여 서버의 특정폴더(/app/security/plugins/)에 던져두기만 하면 된다. 비즈니스 핵심 로직은 보호된 채로 시스템은 무한히 확장될 수 있다.
4. 도입시 주의점
클래스로더를 직접 다룰 때는 반드시 메모리 구조에 대한 이해가 수반되어야 한다.
메모리 누수(Metaspace OOM)
무분별하게 동적 로딩을 수행하고 클래스로더를 해제(GC)하지 않으면, 클래스의 메타데이터가 쌓이는 JVM의 Metaspace 영역이 꽉 차서 OOM(Out of Memory) 장애가 발생한다.
ClassCastException의 늪
자바에서 클래스의 동일성은 클래스의 이름뿐만 아니라 어떤 클래스로더가 로드했느나로 결정된다. 같은 User 클래스라도 A 클래스로더와 B 클래스로더가 각각 로드했다면 JVM은 이를 전혀 다른 클래스로 인식하여 캐스팅 에러를 뱉어낸다.
5. 결론
엔지니어링의 깊이를 더할수록 기본 API의 원리가 얼마나 강력한지 깨닫게 된다.
우리가 흔히 쓰는 톰캣(Tomcat) 같은 WAS에 여러 개의 스프링 프로젝트(war)를 띄워도 서로의 싱글톤 객체나 의존성이 충돌하지 않는 이유는 무엇일까? 바로 WAS가 각 웹 애플리케이션마다 독립적인 커스텀 클래스 로더를 생성하여 네임스페이스를 철저히 격리(Isolation)하기 때문이다.
런타임에 외부의 클래스를 동적으로 땡겨오는 이 강력한 기술은 스프링 부터의 AutoConfiguration이나 무중단 핫 스와핑(Hot Swapping)을 가능하게 하는 핵심 기반이 된다. 코드가 어떻게 메모리에 적재되는지 그 흐름을 장악하는 개발자만이 실무에서 예기치 못한 ClassNotFoundException 앞에서도 당황하지 않고 원인을 짚어낼 수 있다.
다음 포스팅에서는 메모리에 올라온 이 클래스들을, 리플렉션을 넘어 아예 런타임에 뜯어고치고 조작하는 법에 대해 알아본다.
'Programming > Java' 카테고리의 다른 글
| [Java] JDK Dynamic Proxy와 CGLIB를 활용한 런타임 프록시 생성 (0) | 2026.06.17 |
|---|---|
| [Java] 바이트 조작(ASM, ByteBuddy)의 개념 (0) | 2026.06.04 |
| [Java] 어노테이션(@)은 그저 마커(Maker)일 뿐이다. (Reflection API 기초) (0) | 2026.06.01 |
| [Java] ConcurrentHashMap 이란? (0) | 2026.03.17 |
| [Java] 추상 클래스(Abstract class) 란? (기본편) (0) | 2026.02.13 |
