본문 바로가기

Language/Java

[Java] JVM 클래스 로딩 Deep Dive 4편 — 리플렉션

2편에서 Class 객체와 InstanceKlass가 양방향 포인터로 연결된다는 것을 살펴봤습니다. 리플렉션은 바로 이 연결을 타고 동작합니다. 이번 편에서는 리플렉션이 내부적으로 어떻게 동작하는지, 왜 느린지, 그리고 프레임워크들이 어떻게 활용하는지를 다룹니다.

 


리플렉션 기본 흐름

리플렉션을 사용할 때 거치는 단계는 크게 세 가지입니다.

Class 객체 획득

모든 리플렉션의 시작점은 Class 객체입니다. 세 가지 방법으로 얻을 수 있습니다.

Class<?> clazz1 = MyClass.class;                       // 컴파일 타임에 결정
Class<?> clazz2 = obj.getClass();                      // 런타임 타입 기준
Class<?> clazz3 = Class.forName("com.example.MyClass"); // 동적 로드

 

Method / Field / Constructor 획득

Class 객체에서 원하는 멤버를 꺼냅니다.

Method method = clazz.getDeclaredMethod("myMethod", String.class);
Field field   = clazz.getDeclaredField("myField");

 

 

getMethod() / getField()는 public 멤버만, 상속된 것도 포함해서 반환합니다. 반면 getDeclaredMethod() / getDeclaredField()는 접근 제어와 관계없이 해당 클래스에 선언된 멤버만 반환합니다.

메서드 범위 접근 제어
getMethod() 해당 클래스 + 상속 public만
getDeclaredMethod() 해당 클래스만 모든 접근 제어

 

invoke() / get() / set()

획득한 Method, Field를 통해 실제로 실행하거나 값에 접근합니다.

method.invoke(instance, "hello");
field.set(instance, 42);
Object value = field.get(instance);

 


reflectionData 캐시 — 왜 중요한가

Method, Field, Constructor 객체는 매번 새로 생성하면 비쌉니다. JVM은 이를 Class 객체 안의 reflectionData에 캐시합니다.

java.lang.Class
  └── reflectionData: SoftReference<ReflectionData>
        ├── declaredFields[]
        ├── publicFields[]
        ├── declaredMethods[]
        ├── publicMethods[]
        └── declaredConstructors[]

SoftReference로 감싸져 있기 때문에 메모리가 부족하면 GC가 캐시를 통째로 수거합니다. 이후 리플렉션 호출이 들어오면 InstanceKlass에서 메타데이터를 다시 읽어 캐시를 재생성합니다.

 


접근 제어 우회 — setAccessible(true)

private 멤버는 기본적으로 리플렉션으로 접근할 수 없습니다. setAccessible(true)를 호출하면 접근 제어 검사를 건너뛸 수 있습니다.

Field field = clazz.getDeclaredField("secret");
field.setAccessible(true); // 접근 제어 검사 비활성화
field.set(instance, "value");

내부적으로는 Field 객체의 override 플래그를 true로 설정하는 것입니다. invoke() / get() / set() 호출 시 이 플래그를 확인해 접근 제어 검사를 건너뜁니다.

 

Java 9+ 모듈 시스템과의 충돌

Java 9에서 모듈 시스템(JPMS)이 도입되면서 setAccessible(true)에 제약이 생겼습니다. 모듈이 명시적으로 opens를 선언하지 않은 패키지에 대해 다른 모듈에서 setAccessible(true)를 시도하면 InaccessibleObjectException이 발생합니다.

java.lang.reflect.InaccessibleObjectException:
  Unable to make field private ... accessible:
  module java.base does not "opens java.lang" to unnamed module

 

Spring, Hibernate 같은 프레임워크들이 Java 9 이후 --add-opens JVM 옵션을 요구하는 이유가 바로 이것입니다.

--add-opens java.base/java.lang=ALL-UNNAMED

 


성능 — 리플렉션이 느린 이유와 inflation

리플렉션 호출이 일반 메서드 호출보다 느린 주요 이유는 세 가지입니다. invoke() 호출마다 접근 권한을 확인하는 접근 제어 검사, invoke(Object, Object...)의 가변 인수가 배열로 감싸지고 기본 타입은 박싱되는 인수 박싱/언박싱, 그리고 초기 invoke가 네이티브 코드를 통해 실행되는 JNI 호출입니다.

 

Inflation 메커니즘

HotSpot은 JNI 호출 비용을 줄이기 위해 inflation이라는 최적화를 사용합니다.

처음 invoke()가 호출되면 NativeMethodAccessor가 JNI를 통해 실행됩니다. 이 시점부터 호출 횟수를 카운트하다가 기본값 15회를 넘으면 JVM이 해당 메서드에 특화된 바이트코드를 런타임에 직접 생성합니다. 이를 GeneratedMethodAccessor라고 하며, 이후 모든 invoke() 호출은 이 생성된 바이트코드를 통해 실행됩니다.

// Java 8~16
-Dsun.reflect.inflationThreshold=15  // 기본값

// Java 17+: 내부 구현이 변경됐습니다. 위 옵션은 동작하지 않을 수 있으며,
// -Djdk.reflect.useDirectMethodHandle 등으로 동작을 제어합니다.

 

실무에서 주의할 점

inflation이 완료된 이후 리플렉션 성능은 일반 메서드 호출과 큰 차이가 없습니다. 성능 문제가 생기는 주된 원인은 두 가지입니다.

  • getDeclaredMethod()를 루프 안에서 반복 호출 — 캐시가 있어도 매번 배열 탐색 비용이 발생합니다. Method / Field 객체를 미리 꺼내 변수에 저장해 두고 재사용해야 합니다.
  • inflation 이전의 초기 호출 — 처음 15회는 JNI로 실행되므로 콜드 스타트 구간에서 성능 차이가 납니다. 성능에 민감한 코드라면 미리 더미 호출로 inflation을 유도하는 방법도 있습니다.

 


실전 — 프레임워크는 리플렉션을 어떻게 쓰나

Spring — 의존성 주입

Spring이 @Autowired로 빈을 주입할 때 Field.set()을 사용합니다.

// Spring 내부 동작 (간략화)
Field field = bean.getClass().getDeclaredField("repository");
field.setAccessible(true);
field.set(bean, repositoryInstance);

Spring은 애플리케이션 시작 시 한 번 Field 객체를 꺼내 캐시해 두고 재사용합니다. 덕분에 getDeclaredField() 비용은 최초 1회만 발생합니다.

 

Jackson — JSON 직렬화/역직렬화

Jackson은 객체를 JSON으로 직렬화할 때 Field.get()으로 값을 읽고, 역직렬화할 때 Field.set() 또는 Constructor.newInstance()로 객체를 생성하고 값을 채웁니다.

Jackson도 클래스별로 BeanPropertyWriter 캐시를 만들어 FieldMethod 객체를 재사용합니다. 같은 타입을 반복 처리할 때 리플렉션 비용이 최초 1회로 수렴하는 이유입니다.

 

Hibernate — 지연 로딩 프록시

Hibernate는 엔티티를 조회할 때 즉시 DB를 조회하지 않고, 런타임에 ByteBuddy(또는 CGLIB)로 엔티티의 서브클래스 프록시를 생성합니다. 실제 필드 접근이 일어나는 시점에 DB를 조회하고, 리플렉션으로 엔티티 필드에 값을 채웁니다. 이때 setAccessible(true)를 통해 private 필드에 직접 접근합니다.

 


정리

[기본 흐름]
  Class 객체 획득 → Method/Field 획득 → invoke()/get()/set()
  getMethod()        → public + 상속 포함
  getDeclaredMethod() → 해당 클래스 선언 멤버, 접근 제어 무관

[reflectionData 캐시]
  SoftReference<ReflectionData> — 메모리 부족 시 GC 수거
  Method/Field 객체는 미리 꺼내 캐시해서 재사용할 것

[setAccessible(true)]
  override 플래그 설정 → 접근 제어 검사 건너뜀
  Java 9+: 모듈이 opens 선언 없으면 InaccessibleObjectException
  → --add-opens로 우회

[Inflation]
  0~15회: NativeMethodAccessor (JNI, 느림)
  16회~:  GeneratedMethodAccessor (바이트코드 생성, 빠름)
  Java 17+: 내부 구현 변경, inflationThreshold 옵션 주의

[프레임워크 활용]
  Spring    → Field.set()으로 의존성 주입, Field 객체 캐시 재사용
  Jackson   → Field.get()/set()으로 직렬화, BeanPropertyWriter 캐시
  Hibernate → ByteBuddy로 프록시 생성 + setAccessible(true)로 필드 주입

 

4편까지 클래스 로딩부터 리플렉션까지 JVM의 핵심 메커니즘을 살펴봤습니다. Loading → Linking → Initialization으로 클래스가 메모리에 올라오고, InstanceKlass와 Class 객체가 양방향으로 연결되며, ClassLoader가 이 과정을 제어하고, 리플렉션이 그 연결을 타고 런타임에 클래스를 동적으로 다루는 흐름이 하나로 이어집니다.