리플렉션의 시작 - Class 객체와 JVM 클래스 로딩

mseo39·2025년 11월 11일
0

TIL

목록 보기
15/15
post-thumbnail

스프링 프레임워크를 사용하며 @Autowired로 private 필드에 의존성을 주입받는 것을 보면 '마법 같다'는 생각을 하곤 합니다. 이 '마법'의 근간이 되는 기술 중 하나가 바로 자바 리플렉션(Reflection)입니다.

리플렉션은 "런타임에 클래스 정보를 읽고 조작하는 기술" 입니다. 하지만 이 강력한 기술을 제대로 이해하려면, JVM이 .class 파일에 생명을 불어넣는 '클래스 로딩' 과정을 알아야 합니다.


1. 클래스 로딩은 언제 일어나는가?

Java는 프로그램을 실행할 때 모든 .class 파일을 한꺼번에 메모리에 올리지 않습니다. 대신 동적 로딩(Dynamic Loading) 방식을 사용합니다.

즉, 런타임(Runtime) 중에 해당 클래스가 '처음으로 필요해지는 순간'에만 메모리에 적재합니다. 이 "처음으로 필요한 순간"을 JVM 명세에서는 '능동적 사용(Active Use)'이라고 부르며, 주요 시점은 다음과 같습니다.

  • new 키워드: new Student()처럼 객체 인스턴스를 생성할 때.
  • static 멤버 접근: Student.schoolName이나 Student.getSchoolName()처럼 static 필드나 메서드를 사용할 때. (단, 예외 있음)
  • Class.forName(): 리플렉션을 통해 Class.forName("com.example.Student")처럼 클래스 로딩을 강제할 때.

🧐 static 멤버 접근 예외가 뭐가 있을까?

  1. 🏃능동적 사용
  • 코드: public static int a = 10;
  • 호출: System.out.println(MyClass.a);
  • 동작: a의 값 10MyClass초기화가 실행되어야만 알 수 있습니다.
  • 결과: JVM은 MyClass를 로딩하고, 링킹하고, 초기화까지 모두 수행합니다.
  1. 😴수동적 사용 - 바로 이 예외입니다.
  • 코드: public static final int b = 20;
  • 호출: System.out.println(MyClass.b);
  • 동작: bfinal이 붙은 상수입니다. 컴파일러는 MyClass.b를 호출하는 코드(예: Main.class)에 b의 값 20을 그냥 복사해서 넣어버립니다.
  • 결과: 런타임에 JVM은 MyClass를 쳐다볼 필요도 없이 Main.class에 박혀있는 20이라는 값을 사용합니다. MyClass는 로딩조차 되지 않을 수 있으며, 당연히 초기화도 일어나지 않습니다.

결론: static 멤버에 접근하더라도, 그것이 static final 상수라면 클래스 '초기화'를 유발하는 '능동적 사용'에서 제외됩니다. 이것이 바로 "단, 예외 있음"의 의미입니다.


2. 클래스 로딩 3단계: '설계도'는 어떻게 '상태'가 진화하는가

능동적 사용이 발생하면, 클래스 로더는 .class 파일을 찾아 JVM 메모리에 올립니다. 이 과정은 크게 로딩(Loading) → 링킹(Linking) → 초기화(Initialization)의 3단계를 거칩니다.

🗺️ JVM 클래스 로딩과 메모리 구조

🔬 다이어그램 상세 분석

1단계: 로딩 (Loading) - 생성과 즉각적인 연결

  • 메소드 영역 (Metaspace): JVM이 코드를 실행하기 위해 필요한 "설계도 원본(메타데이터)"을 이곳에 저장합니다.

    • 클래스의 이름, 부모/인스페이스 정보
    • 메서드 바이트코드, 필드 정보 (private, public 등)
    • static 변수의 선언
  • 힙 영역 (Heap): 개발자가 리플렉션을 하기 위해 필요한 "Class 객체(관문/리모컨)"를 힙에 단 하나 생성합니다. 이 Class 객체는 Metaspace에 있는 "설계도 원본"을 가리키는 내부 참조를 갖습니다.

2단계: 링킹 (Linking) - 기본값 할당 (즉시 실행)

로딩연결이 끝난 직후, JVM은 링킹을 수행합니다. 링킹[검증 -> 준비 -> 해석]으로 나뉘며, 핵심은 준비입니다.

  • 검증: .class 파일이 유효한지(보안 검사, 문법 검사) 확인합니다.
  • 준비: 설계도 원본 내부의 static 변수 메모리를 할당하고, 기본값(0, null)으로 자동 초기화합니다.
  • 해석: 클래스가 참조하는 다른 클래스나 메서드의 기호적 참조를 실제 메모리 주소로 바꾸는 과정을 수행(이 과정은 실제 사용할 때까지 지연될 수 있습니다)

3단계: 초기화 (Initialization) - 실제 값 할당 (지연 실행)

초기화 프로세스가 바로 '능동적 사용'이 필요한 이유입니다. 이 단계는 게으르게(Lazy) 동작합니다.

  • 시점: 로딩 직후가 아닌, 능동적 사용이 최초로 발생할 때까지 미뤄집니다.
  • 동작: static 변수 '수동 초기화' 및 static 블록 실행
    • "준비" 단계에서 0이나 null이 되었던 static 변수들에 개발자가 지정한 '실제 값'을 할당합니다.
    • 이 과정은 클래스 파일에 작성된 순서대로 실행됩니다.
      • public static int version = 10; → 이 시점에 version 변수에 10이 할당됩니다.
      • static { ... } 블록이 있다면 이 시점에 실행됩니다.

결론: 리플렉션의 시작점, Class 객체

리플렉션의 시작은 Heap에 존재하는 Class 객체(리모컨)를 획득하는 것입니다.

Class 객체를 얻는 3가지 방법

  • TargetClass.class (리터럴)
    • 의미: "컴파일러야, MyClass의 '설계도 리모컨'을 코드로 직접 줘."
    • 가장 간단하고, 컴파일 시점에 타입이 확정될 때 씁니다.
  • instance.getClass() (인스턴스)
    • 의미: "내가 지금 '제품'(myInstance)을 가지고 있는데, 이 제품을 만든 '설계도 리모컨' 좀 줘."
    • 이미 만들어진 객체(제품)로부터 그 근본(설계도)을 역추적할 때 씁니다.
  • Class.forName("TargetClass") (문자열)
    • 의미: "내가 '설계도 이름'(String)을 알고 있는데, 이 이름으로 '설계도 리모컨'을 찾아서 줘. 만약 공장에 없으면 지금 당장 로딩해."
    • 이름만 알고 있고 클래스가 로드되지 않았을 수도 있을 때, 동적으로 클래스를 로드(Loading)하며 리모컨을 가져옵니다.

이 Class 객체를 통해 개발자는 '메소드 영역(Metaspace)'에 저장된 '설계도 원본'을 런타임에 조회하고 조작할 수 있게 됩니다.

[참고] Class.forName("...") vs Class.forName("...", false, ...)

  • Class.forName("...")
    • 동작: 클래스의 로딩 → 링킹 → 초기화까지 모두 수행
  • Class.forName("...", false, ...)
    • 동작(initialize = false): 클래스의 로딩 → 링킹까지만 수행
    • 결과: 기본값(0, null) 상태로 남아있으며, static { ... } 블록은 실행X
    • 용도: 프레임워크의 클래스 스캔, 메타데이터만 조회할 때
profile
하루하루 성실하게

0개의 댓글