[개발지식] Spring Framework 생명주기와 JVM Class Loader와의 관계, Component/Configuration/Bean을 다루기 전에 Class Loader를 명확하게 이해해야하는 이유

Hyo Kyun Lee·2025년 11월 1일
0

개발지식

목록 보기
96/100

1. 개요

금번 Spring Framework와 Spring Boot의 Batch 간의 설정 및 동작을 비교하기 위해 여러가지 시행착오를 겪고 있는데, 내부적인 동작원리 및 설정 등을 이해하기 전에 기본적으로 Spring Framework(boot 포함)의 생명주기와 JVM의 Class Loader의 관계를 먼저 이해하고 있는 것이 필요하다는 것을 느꼈다.

특히 향후 많이 쓰일 Boot 환경에서의 Batch 작성은 배치작성도 중요하지만, 환경설정을 정의하고 Spring 측에서 이를 이해하고 받아들일 수 있도록 그 동작원리를 이해하는 것도 중요하다는 것을 많이 느꼈던 것 같다.

스케쥴러 처리나 Redis, Kafka 등 이기종 시스템을 구성할때 중요한 환경설정, Batch 역시 Web Application과 엄연히 이기종 시스템이라 할 수 있고, 그렇기에 최초 설정이 중요하다는 생각으로 이 모든 것의 출발점인 Class Loader에 대해 자세하게 알아보았다.

2. 결론

먼저 결론부터 살펴보자면, Class Loader를 명확하게 이해해야 하는 이유는, Bean객체, 즉 환경설정을 위해 등록한 환경설정 Bean들은 클래스 로더에 의해 호출되어 객체로써 호출 및 관리되기 때문이다. 즉, Class Loader가 환경설정 Bean의 진입점이자 관리지점이다.

그리고 무엇보다, "생명주기"라 표현할 수 있을 정도의 클래스로더의 체계는 빈 관리체계와 유사하다고 볼 수는 없을지언정 "비슷하다"라고 표현할 수 있겠다.

기본적으로 이러한 전체적인 흐름 정도는 이해하고 있어야, 환경설정이라 해서 무작정 Bean으로 등록하는 것이 아닌 정확한 목적으로 Bean을 등록할 수 있겠다.

칼을 쥐어주어도 배를 깎는 방법을 모르면 배를 깎을 수 없다.

이제 본격적으로 Class Loader에 대해 알아보겠다.

3. pre ClassLoader : Compile

일단 클래스로더에 설계도를 입력해주기 위한 과정으로 컴파일을 해주어야 한다.

우리가 흔히 알고 있는, 자바소스코드를 클래스파일(바이트코드)로 변환해주기 위한 작업이다. 이 부분에 대해서도 조금 깊게 들어가보았는데, 당장 이해하기엔 조금 어려웠으나 큰 흐름으로 보았을때는 많이 듣고 보았던 개념이 있었기에 공부하였다.

1) 파싱 (Parsing)

javac가 .java 파일을 읽고 lexical/ syntactic/semantic 분석을 수행해 AST(추상 구문 트리)를 만든다. 타입 체크(타입 일관성, import 확인 등)도 이 단계에서 수행된다(컴파일 시점에서 매개변수나 제너릭 등의 타입 일관성을 체크).

2) 어노테이션 처리(Annotation Processing)

컴파일 타임 어노테이션 프로세서(javax.annotation.processing.Processor)가 있다면 이 시점에 실행되어, 어노테이션의 메타데이터를 런타임에 리플렉션하기 위한 준비작업을 진행한다.

3) 중간표현(IR) → 바이트코드 생성

AST/심볼 정보를 바탕으로 JVM 바이트코드(메서드 테이블, 상수풀, 필드, 메서드, 접근수정자 등)를 가진 .class 파일을 만든다.

컴파일 시점에서는 Class Loader에 올리기 위한 준비를 하며, JVM 메모리에는 아직 올리지 않는다.

4. classLoader : Class Loading LifeCycle

JVM이 바이트코드를 실행하는 것은 많이 들어봤을텐데, 이 JVM의 클래스로더가 바이트코드의 명세를 읽고 런타임 시 최종 로딩을 할 수 있도록, 즉 객체를 생성할 수 있도록 내부적인 일련의 과정을 거친다.

4-1. Class Loader Model

그 전에, 참고로 어떠한 형태 혹은 어떠한 경로의 클래스를 로딩할 것인가에 따라 클래스로더 모델이 달라지는데, 클래스 로더가 인식하는 클래스 아이덴티티가 클래스 이름과 클래스로더 모델이기에 클래스로딩을 진행하기 위한 중요 전제 작업이다.

  • Web Application에서 작성한 코드는 모두 일괄적으로 부모 우선 위임모델을 통해 로딩을 진행하며, 이 모든 소스코드가 동일한 클래스로더 모델로 로딩하기에 클래스 이름은 중복되어선 안된다는 컴파일 조건이 걸려있는 것이다.

  • Bootstrap ClassLoader: JRE 핵심 클래스(java.base 등)를 로드한다. 네이티브 코드로 구현되어 있고 null로 표시될 때도 있다.

  • Platform / Extension ClassLoader: JDK 확장 라이브러리를 로드한다.

  • Application / System ClassLoader: 애플리케이션 클래스패스(-cp, CLASSPATH)의 클래스를 로드(일전에 그렇게 고생했던 프로젝트 구성 작업/프로젝트 인식문제는 여기에서 비롯, gradle이나 Spring 프로젝트 인식을 해주어야 하는 이유).

  • 사용자 정의 ClassLoader: 애플리케이션 서버(예: Tomcat)나 프레임워크가 만든 커스텀 로더(웹앱당 로더 등)이다.

  • 부모 우선(Parent-first) 위임 모델: 로더가 클래스를 로드할 때 부모에게 먼저 요청하고, 부모가 없거나 못 찾으면 자신이 로드한다(우리가 알고있는 클래스로더의 모델이 바로 이것이다).

4-2. Class Loading LifeCycle

클래스 로딩을 하기 위한 과정은 크게 Load / Link / Initialize 단계로 나눈다.

Bean Cycle에서 빈 등록 > 초기화 순으로 진행이 되는데, 이와 매우 비슷하다는 것을 알 수 있다.

다시 한번, Class Loading 과정을 알아야 하는 이유를 상기하면서 각각의 단계를 자세하게 알아보았다.

  • Load (로딩)

ClassLoader의 loadClass(String name) 호출(보통 Class.forName, Reflection, 또는 JVM이 직접 필요 시), 쉽게 말해 위에서 등록한 클래스 아이덴티티를 호출(로딩)하거나 어노테이션의 명세에 따른 리플렉션, 즉 명세에 따른 실제 동작을 이 시점에 진행한다.

부모-위임 모델의 경우, 먼저 부모 로더에게 loadClass 요청하며 부모가 찾으면 반환하고 못 찾으면 findClass로 로컬에서 찾는다(즉 상속받은게 없다면 본인 클래스를 로딩한다는 의미, 상속받은 것이 있다면 부모클래스와 같이 로딩). findClass는 .class 바이트를 가져와 defineClass(byte[]) 호출하고, Internal Class 객체(런타임 메타데이터)를 생성한다. "객체"가 생성된 것인데 아직 링크 전이기에 사용은 할 수 없다.

  • Link (연결)

클래스를 로딩하면서 드디어 객체가 생성되었다. 이제 런타임에서 이를 실제로 사용할 수 있도록 연결하는 작업이 필요하다. 세부적으로는 검증·준비·해결의 단계로 이루어진다.

1) Verification (검증)과정을 통해 바이트코드가 유효한지, 타입 규칙 위반이 없는지, 잘못된 브랜치나 stack-map이 없는지 검사한다. (보안/안정성)

2) Preparation (준비)과정을 통해 클래스의 static 필드(정적 변수)들을 메모리상에 기본값(default)으로 할당(*생성자 주입 시점에서 이 link 과정을 통해 실제 객체를 사용할 수 있는 준비 완료, static 변수가 가장 먼저 객체 생성 및 빈으로 등록)한다.

참고로 준비 단계에서는 static 변수를 제외한, 상수 초기화(ex. static final compile-time 상수 제외) 는 아직 하지 않는다.

JVM 구현상 메서드영역(Method Area) / Metaspace(HotSpot) 또는 PermGen(이전) 영역에 메타데이터 배치하여 객체를 사용하기 위한 기능들을 먼저 배치해준다.

3) Resolution (해결)

클래스의 상징적 참조(Constant Pool의 심볼릭 레퍼런스)를 직접 참조(direct references) 로 변환한다. 이 시점에서 비로소 클래스, 메서드, 필드의 주소를 연결하여 객체의 정보(메타데이터)를 등록해주고, 이 정보들을 사용하기 위한 기능들을 모두 배치한다. 이 해석은 보통 lazy(필요 시)로 할 수도 있다.

  • Initialize (초기화)

이제 객체를 생성하고, 이 객체에 대한 정보까지 모두 주입해주었다면 런타임, 즉 실제로 활용할 단계 직전이다.

즉, 클래스가 처음으로 active하게 사용되는 시점이며 new, static method invocation, static field access, Class.forName(..., true), 특정 리플렉션 호출 등으로 클래스가 호출되었을때 위 정의한 객체를 실제로 사용하기위해 비로소 호출, 즉 초기화한다.

부모 클래스가 초기화되어 있지 않다면 본인 클래스를 먼저 초기화하며, 위에서 정의한 메타데이터에 의해 Spring 내부적으로 정의한, 초기화 순서대로 클래스를 호출하고 초기화한다..

<clinit> 실행 중 예외가 던져지면 ExceptionInInitializerError 발생하고 해당 클래스는 사용불가 상태가 될 수 있으며, 초기화는 스레드-안전하게 한 번만 수행되므로(다른 스레드는 초기화가 끝날 때까지 block) 클래스 아이덴티티 당 철저하게 하나의 객체만 만들어진다.

4-2-2. 객체 할당

클래스 인스턴스(객체) 할당 시, 위에서 기술하였듯 link 시점에 클래스를 호출하게 된다. 런타임 시에는, 예를 들어, new가 실행되면 런타임은 Class 객체를 통해 인스턴스 메모리 크기를 알아내고 힙에 그 객체를 할당한다(stack에 변수 저장, 힙에 클래스와 같은 참조변수의 주소정보 및 객체정보가 저장된다).

객체 헤더에는 클래스 포인터(또는 메타링크)를 담아 어떤 클래스의 인스턴스인지, 클래스 아이덴티티를 식별할 수 있는(loading 시점에서 생성한 메타데이터) 정보가 들어있다. 객체를 할당하는 시점에는 가장 최초로 생성자 <init>가 실행되어 인스턴스 초기화(인스턴스 필드 초기화 → 생성자 본문).

Garbage Collector는 힙 객체를 관리, 클래스 객체는 해당 클래스로더가 가비지컬렉션 대상이 되었을 때만 언로드, 즉 해당 객체를 더이상 참조할 수 있는 경로가 존재하지 않을 때 gc한다.

4-3. Class Loader의 특징

일단, 위에서 기술하였듯이 클래스 정체성(Class Identity)은 클래스 이름과 로더 모델로 구분한다. 같은 클래스 이름이라도 클래스로더가 다르면 서로 호환할 수 없기에 ClassCastException, LinkageError 발생 가능하다.

보통은 부모위임 모델을 사용하지만, 일부 어플리케이션 서버/플러그인 시스템은 child-first 전략을 사용해 의존성 충돌 해결할 수도 있다.

프록시/코드생성(CGLIB/Javassist), 즉 프록시 객체를 할당하기 위해선 부모객체가 먼저 필요한데, 런타임에 생성된 클래스(프록시)는 보통 원본 클래스가 로드된 로더를 부모로 하여 해당 로더에서 정의되어야 하겠다.

로더 경로에 따라 다른 리소스를 제공하며, 네이티브 라이브러리(System.loadLibrary) 특성의 경우 로더가 아닌 전역 네임스페이스에 로드되므로 주의 필요하다(프로젝트 구성 및 모듈의존성 등에 관한 설정도 반드시 클래스패스에 등록되어야 하는 과정이다).

5. Spring Framework, Bean Container와 클래스 로더의 상호작용

결론부터 말하자면 Bean 객체를 끌어다 쓰기 위해 클래스 로더에서 정의한 설계도를 참고하는 관계이다.

또한 위에서 Bean 등록/초기화 과정과 Class Loader의 초기화/연결 과정이 비슷한 원리로 동작되듯이, 전체적인 객체 초기화 과정과 원리가 둘이 비슷하다.

사실 윗부분은 너무 어려워서 이해할 시간이 더 필요한데, 아래 내용이 핵심이기에 아래 내용부터 살펴보아도 무방하다.

Spring Framework의 생명주기를 중심으로 살펴보겠다.

5-1. Spring Framework 실행 전

실행하기 위해 JVM 시작하고 필요한 클래스들을 호출하기 위해 클래스 로더 구성을 시작한다.

서블릿 컨테이너 등 WAS마다 별도의 웹앱 클래스 로더를 보유하고 있으며, 라이브러리나 클래스패스 등에 따라 다른 클래스로더 모델을 사용하여 클래스를 로딩한다.

5-2. Spring Framework 실행 후 - main 실행/JVM Class Loader의 객체 정보 생성/Spring Context의 객체 정보 등록

SpringApplication.run(...) 호출하여 Spring 프로젝트를 실행한다면, ApplicationContext(예: AnnotationConfigApplicationContext, AnnotationConfigServletWebServerApplicationContext) 생성하고 빈정보를 등록하기 위해 클래스로더를 참조한다.

이 시점에서 Spring은 자신의 ClassLoader에 본인이 명세한 객체 명세 및 메타정보들을 보관하고 있기에, applicationContext.getClassLoader())를 통해 객체를 등록한다(이미 Spring Context 생성 시점에서 JVM의 클래스로더에 의해 모든 객체 정보들이 호출 대기상태에 있다고 보면 된다).

5-3. Spring Context의 실제 객체 활용 - Bean 등록/정의/초기화 및 Class Loader Linking

위에서 Bean 객체를 등록하기 위해 클래스로더가 정의한 객체 및 객체정보를 읽는다. 이 객체 정보를 읽는 방법은 크게 두가지가 있다.

참고로 클래스로더의 load/link/initialize 과정은 JVM 차원의 클래스 타입을 "준비"하는 과정이며, Spring의 Bean 등록/초기화는 애플리케이션 프레임워크 차원의 객체 생명주기이다.

두 체계의 생명주기는 엄연히 다르지만, 다만 상호보완적이다.

  • Class 로드해서 Reflection으로 읽는다. (Class<?> clazz = classLoader.loadClass(name)) 이 경우 클래스가 실제로 JVM에 로드되고 초기화된다(단, lazy Evaluation, Class.forName(name, false, loader)로 초기화 지연 가능).
  • ASM 등의 바이트코드 리더로 메타데이터만 읽기 (MetadataReader, ClassMetadata)

이 부분이 환경설정의 핵심이다. 컴포넌트 스캔(@ComponentScan)이나 @Configuration 스캔 단계에서 Spring은 가능한 한 클래스를 직접 로드하지 않고 바이트코드(ASM)를 읽어 어노테이션 및 구조만 확인한다.

따라서 스캐닝 단계에서 언제 클래스를 실제 로드하느냐는 Spring 설정과(설정이라함은 어노테이션 및 어노테이션 명세 등) 상황에 따라 달라지며, 스캐닝은 보통 class-loading을 회피하려고 노력, 즉 위에서 기술한대로 환경설정을 위한 정보들은 메타데이터를 이 시점에서도 초기화하지 않는다. 단지 읽는다.

  • BeanDefinition 등록(빈 정의 및 등록)

(환경설정 제외하고) 탐색된 클래스(또는 수동등록된 @Bean 메서드 등)에 대해 BeanDefinition이 생성되고 BeanDefinitionRegistry에 등록된다.

이 단계는 클래스 메타데이터(어노테이션, 메서드 시그니처 등)만으로 이루어질 수 있다(실제 Bean 객체 생성은 아직 이전).

이후 빈을 초기화하기 위한 의존성그래프도 이 시점에 같이 정의된다.

  • 빈 인스턴스화(실제 클래스 로드·인스턴스 생성) — 빈 초기화

이 시점에 비로소 빈 객체를 실제로 사용할 수 있을 단계까지 온 것이다(빈 초기화).

프리프로세서(BeanFactoryPostProcessor 등) 실행하여, ConfigurationClassPostProcessor( @Configuration 읽기, @Bean 등록 )를 진행한다(즉, 환경설정 빈들을 포함한 모든 빈 객체를 실제로 초기화까지 하는 과정이다).

@Configuration 클래스는 보통 실제 Class를 로드해서 CGLIB 프록시를 만들고, @Bean 메서드들을 읽는다. (다만 어노테이션을 읽는 데는 ASM을 쓰기도 함.)

모든 객체 초기화는 빈등록 시 정의된 의존성 그래프를 기반으로 빈을 생성(유형: Singleton/Prototype 등).

실제로 인스턴스화 될 때 해당 클래스가 로드되지 않았다면(lazy loading), ClassLoader가 그 클래스를 로드(defineClass)한다(로드 → 링크 → initialize 순으로 진행하며, 위에서 아직 클래스화하지 않은 환경설정 정보들이 이 시점에 최종 로드 및 초기화까지 진행된다).

생성은 보통 InstantiationStrategy(기본: Reflection을 통한 생성자 호출, 또는 CGLIB을 통한 서브클래싱), 즉 어노테이션 및 리플렉션 등으로 정의된 명세에 따라 그대로 진행한다.

5-4. 프록시 객체의 생성

필요에 따라서는 모든 객체를 초기화하는 이 시점에 프록시객체를 생성(CGLIB/Proxy)한다.

특히 이기종 시스템간 환경설정(Kafka/Redis/Scheduler/Batch 등)으로 인해 @Configuration(proxyBeanMethods=true)나 AOP/Transaction 등으로 프록시가 필요한 경우, Spring은 런타임에 프록시 클래스를 생성한다.

5-5. DI(Spring Framework의 의존성 주입)

마찬가지로 빈이 초기화되었으므로, 의존성 주입 과정도 단계적으로 이루어지게 된다.

의존성 주입(Setter/생성자/필드 주입)의 모든 과정이 이 시점에 발생하며, DI 컨테이너가 의존성(다른 빈)을 주입한다. 당연하겠지만 이때도 해당 의존 빈들이 먼저 로드/생성되어야 한다.

참고로 유의해야할 점은, 빈 등록 시 라이프사이클 콜백이 발생한다는 점이다.

@PostConstruct, afterPropertiesSet()(InitializingBean), BeanPostProcessor의 postProcessBeforeInitialization/postProcessAfterInitialization을 관심사로 설정하여 AOP로 동작하게 된다.

5-6. 스케쥴링 환경설정 빈의 초기화

이 시점에서 클래스로더에서 읽은 환경설정 메타데이터에 대한 객체정보를 생성하고 Spring Context에 등록한다고 보면 되겠다.

@EnableScheduling을 통해 비로소 SchedulingConfiguration이 등록된다(이건 설정 시점에 메타데이터로 등록). 이후 이 환경설정에 대한 정보(빈) 객체는 ScheduledAnnotationBeanPostProcessor라는 BeanPostProcessor에 의해 SpringContext 컨테이너에 최종 등록된다.

이 어노테이션 명세에 의해, 이후 Spring Context에 등록된 모든 빈이 초기화될 때 @Scheduled 애노테이션을 검사하고 있을 시 실행한다(*위에서 살펴보았듯이 빈 초기화 시점이 리플렉션이 이루어지는 시점이다).

이 검사는 보통 이미 로드된 클래스/빈 인스턴스의 메서드 메타데이터를 보고 이루어진다.

참고로,

@Scheduled 메서드를 발견하면 ScheduledAnnotationBeanPostProcessor는 TaskScheduler(예: ThreadPoolTaskScheduler)를 사용해 해당 메서드를 반복 실행하는 Runnable을 등록한다.

TaskScheduler 빈(또는 내부 생성된 스케줄러)은 컨테이너에서 일반 빈처럼 생성·초기화된다(즉, 스케쥴러로 등록된 빈들의 클래스 로드 및 초기화 과정이 스케쥴러 환경설정 등록 후 스케쥴러 로직 빈들을 검사하는 시점에 비로소 일어난다).

스케줄러는 별도 스레드풀에서 실행되므로, 스케줄 실행 시 사용하는 클래스와 로더가 올바른지(특히 정적참조/스레드로컬 주의) 확인해야 한다.

5-7. Redis 환경설정 빈의 초기화

알아보는 김에 이기종 시스템과의 통신을 위한 환경설정 정보는 어떻게 관리하는지 궁금하여 자세히 알아보았다.

Spring Boot 사용을 기준으로, Auto-configuration이 활성화되어 RedisConnectionFactory, RedisTemplate, LettuceConnectionFactory 같은 빈들이 @Configuration 클래스에서 @Bean 으로 자동 등록된다.

이러한 @Configuration 클래스는 앞서 설명한대로 로드되어(필요시) 빈 정의로 변환되고, 실제 빈은 컨테이너가 초기화할 때 인스턴스화된다.

RedisTemplate 같은 빈은 내부적으로 직렬화기(Serializer)들을 설정하는데, 이 과정에서 관련 클래스(예: GenericJackson2JsonRedisSerializer)가 로드되고, 참고로 이 정보들은 환경설정 정보보다는 인프라적 정보가 되고 이는 라이브러리 초기화 모델에 의해 로드된 클래스들, 최종 초기화된다(로직 런타임 시점).

네트워크 드라이버/라이브러리(예: Lettuce, Jedis)는 네이티브 소켓을 사용하므로 라이브러리 로딩 시점(클래스가 로드되고 메서드를 호출하는 시점)에 네트워크 리소스가 열리고 connection pool 이 만들어진다(실제 로직에서의 커넥션 풀 사용은 리소스 모델에 의해 리소스가 생성 된 후, 이 리소스를 사용하기 위해 해당 커넥션 풀을 호출하는 시점이 되겠다).

따라서, Redis 환경설정 및 라이브러리도 초기화하는 시점이 존재하기에, Redis 관련 빈이 초기화되기 전에는 스케줄러를 통해 Redis 접근하는 작업이 실행되지 않도록 순서(의존성)를 확보해야 한다.

6. 요약 1- 애플리케이션 실행 후 환경설정 빈들의 등록 및 초기화 과정

솔직히 잘 이해가 안가는데, 일단 정말 핵심적인 요소만 추출하여 정리해보았다.
(*바이트코드는 명세서이며, 런타임시점에 JVM의 클래스로더가 이를 읽고 실제 클래스를 만들고 초기화하는 작업을 담당한다.)

1) JVM 시작 → Application ClassLoader 준비.
2) main()에서 SpringApplication.run() 호출.
3) Spring 내부: ApplicationContext 생성, Environment 초기화, 리스너 등록 등.
4) 컴포넌트 스캔 시작
5) ASM으로 클래스 메타데이터를 스캔하여 @Configuration, @Component, @EnableScheduling 등 발견(가능하면 클래스 로드 회피, 메타데이터만 읽는다).
6) BeanDefinition 등록 (RedisConfig, AppService 등).
7) BeanFactoryPostProcessor 실행 (예: ConfigurationClassPostProcessor) → @Configuration 클래스 실제 Class 로드(필요 시), CGLIB 처리하여 프록시 생성.
8) 싱글톤 빈 생성(의존성 그래프에 따라):
9) RedisConnectionFactory 생성 → 리소스(네트워크) 초기화.
10) RedisTemplate 생성 → serializer 등 초기화(관련 클래스 로드).
11) TaskScheduler 생성(스케줄링을 위해).
12) BeanPostProcessor 중 ScheduledAnnotationBeanPostProcessor가 @Scheduled 메서드 등록 → TaskScheduler에 작업 스케줄 등록.
13) 컨텍스트 리프레시 완료 → ApplicationReadyEvent 등 발행 → 스케줄러가 등록된 작업들을 실행 시작(스케줄러의 스레드에서).

7번과 12번은 빈등록 이전에, 각각 환경설정정보 로드 및 이기종 시스템 환경 구성 팩토리 객체 생성과 스케쥴링 작업 탐색 및 등록하는 과정으로 거의 동시적으로 진행되어 의존성 및 순서 등에 유의해야 한다(빈 등록 = 그 객체를 사용할 준비는 되었음을 의미).

7. 요약 2 - JVM / Spring 관점에서의 동작

이번엔 각각의 관점에서, 더 쉽게 정리해보았다.

정말 핵심만 요약하자면 .java가 최초 소스코드이고 컴파일후에 바이트코드인 .class로 생성되며, 이 클래스가 런타임시점에 클래스로더가 이를 로딩하면서 객체로 할당이 된다. 그런데 스프링은 이 클래스로더를 최대한 효율적으로 활용하고자 application context에 등록한 빈을 활용하거나 프록시객체를 생성 및 사용하는 것이다.

  • JVM

.java (소스코드) → javac 컴파일 → .class (바이트코드) 순으로 변환하여 컴파일 시점에 명세한다.

JVM 실행 시, ClassLoader가 .class를 로드(Load) 하고 클래스 메타정보를 메모리에 등록(Link/Initialize)..이 등록된 정보들을 Spring이 Bean으로 활용하는 것이다(빈으로써 필요할 시점에만 인스턴스화하며, 이 이외 시점에는 불필요한 객체생성을 피한다).

프로그램이 new 등을 호출할 때, JVM이 해당 클래스의 인스턴스(객체) 를 힙에 생성한다.

  • Spring 관점

Spring은 애플리케이션 전체를 ApplicationContext(컨테이너) 로 관리하는데, 객체나 메모리의 효율적인 관리를 위해 클래스로더의 객체 정보를 활용하는 것이다.

클래스 로더를 직접 제어하기보단, 이미 로드된 클래스를 메타데이터(어노테이션, BeanDefinition 등) 로 분석하여 사용한다.

실제 빈을 만들 때만 클래스를 인스턴스화 — 불필요한 로딩을 피함
프록시(CGLIB / JDK Proxy) 를 사용해 AOP, 트랜잭션, 스케줄링 등을 구현 (→ 클래스 로더를 통해 동적으로 서브클래스 생성)한다, 이때도 클래스로더가 만들어준 정보들을 이용한다

어노테이션 정보가 리플렉션 되는 시점은,

  • 이미 클래스로더로 읽힌 시점에서 어노테이션 명세가 완료되어있다.
  • 컴포넌트 스캔 시점에서도 실행은 안하고 읽기만 한다(환경설정 어노테이션 해당).
  • Spring Context에 의해 관리되는 시점에(환경설정 빈 정의 및 등록, AOP로 인한 프록시 객체 생성 시점 등에 일괄 어노테이션 리플렉션이 동작, 최종 빈의 로직 시점은 런타임 시점) 리플렉션이 반영되거나 최종 동작한다.

8. 참고) 어노테이션 리플렉션(공식문서를 참고한 챗 지피티 대답 발췌)

단계시점주요 동작리플렉션 여부
1️⃣ 클래스 로딩JVM이 .class 로드어노테이션 메타데이터 JVM에 등록
2️⃣ 컴포넌트 스캔Spring이 .class 스캔ASM으로 메타데이터 읽음❌ (바이트코드 수준)
3️⃣ BeanDefinition 생성Bean 메타정보 등록일부 어노테이션 리플렉션으로 확인✅ 일부
4️⃣ Bean 인스턴스 생성객체 생성 및 의존성 주입@Autowired, @PostConstruct
5️⃣ AOP 적용프록시 생성@Transactional, @Aspect, @Cacheable 등 분석
6️⃣ 실행 시점런타임 중스케줄러, 트랜잭션 경계 등✅ 동적 실행

즉, 한 번 로드된 클래스와 그 객체(빈) 를 컨테이너가 재사용하여 메모리·성능 효율을 극대화한다.

9. 이해가 아직도 안된다면, 정말 쉽게 간략히 정리.

클래스 로더는 “.class 바이너리 바이트코드 → JVM 내부의 Class 메타정보 구조” 로 변환해서 JVM 메모리에 적재하는 역할만 한다.

즉, 클래스 로딩 후 JVM은 해당 타입에 대한 Class 메타 객체 (예: java.lang.Class) 를 생성한다(쉽게 말하면 명세서). 이는 타입을 설명하는 객체이지 실제 인스턴스 객체가 아니다.

  • “객체 생성”이 아니다. “new 로 생성할 수 있는 준비만” 해놓는 것이다.
  • 더 쉽게, ClassLoader는 객체를 만들지 않는다.

객체 생성은 new, Reflection(Spring의 Bean Definition 시점), DI Container(Spring) 등이 수행한다.

Spring Bean과 ClassLoader의 관계

단계설명
1. ClassLoader로 클래스 로딩JVM 내부 메타정보 준비
2. BeanDefinition 등록어떤 클래스를 Bean으로 생성할지 목록 작성
3. Reflection으로 객체 생성new 대신 Constructor.newInstance() 사용
4. 의존성 주입(DI)@Autowired, 생성자 주입 등
5. 초기화@PostConstruct, InitializingBean 등

Class Loader가 객체생성을 위한 준비를 완료하였다.
Spring은 Bean 정의 및 Reflection/객체 생성 및 의존성 주입을 하여 최종적인 객체 초기화를 진행한다.

10. 결론

정말 방대하고 어려운 내용에 대해 잠깐 훑어보았는데, 결국 결론은 클래스 로더에 의해 만들어진 객체를 Spring이 관리하고 재사용한다는 점이다.

환경설정 정보들도 결국 각각의 시점이 존재하기에, 우리가 흔히 겪어왔던 빈 의존성 문제라든지 이에 대한 시점도 유의하면서 환경설정을 구성해주면 좋을 것 같다.

JVM 클래스 로더에 대해 깊게 알아보다가, 어쩌다보니 클래스 로딩 및 이를 활용하는 Spring Context와의 관계까지 알아보게 되었다.

더 늦어지기전에 본격적으로 진행하고 있는 프로젝트의 다음 단계로 넘어가보자.

0개의 댓글