리플렉션 deep 하게 JDK관점에서 풀어보기

반응형

이전에 여러 객체를 프록시로 생성하거나, 프록시 객체를 활용하는 과정에서 Java는 자연스럽게 리플렉션(Reflection)이라는 기술을 사용하게 됩니다.

 

문제 발견: LazyConnectionDataSourceProxy 톺아보기

read-only일때 replica 사용하게 하기master-slave 구조 이해하기DB에서 master-slave 형태로 변경하다는 뜻은 읽기 전용과 쓰기 전용을 분리한다는 의미입니다.최근에는 master-slave라는 용어보다는 primary-repl

b-programmer.tistory.com

 

분명... slow쿼리를 모니터링하려고 했는데... 어째서...

return wrapPreparedStatement(preparedStatement, sql);​ 슬로쿼리 모니터링 인터페이스 제작슬로 쿼리를 모니터링 인터페이스를 만들기위해 어떤 작업을 해야 할까요? slow 쿼리는 어떻게 탐지할 수 있을까?Sl

b-programmer.tistory.com

리플렉션은 간단히 말하면, 런타임 시점에 클래스의 구조를 조회하고, 메서드나 필드에 접근하여 동적으로 실행할 수 있는 기능입니다.
이를 통해 일반적인 코드에서는 접근할 수 없는 private 메서드나 필드에도 접근이 가능합니다. 대표적으로 java.lang.Class, java.lang.reflect.Method 와 같은 API를 통해 클래스의 메타데이터를 조회하고 실행할 수 있습니다. 이러한 특성만 보면 "JVM을 조작한다"는 느낌 때문에 위험한 기술처럼 보일 수 있습니다. 하지만 정확히 말하면, 리플렉션은 JVM 자체를 변경하는 것이 아니라 JVM이 제공하는 메타데이터를 기반으로 객체를 동적으로 제어하는 기능에 가깝습니다.

그렇다면, 리플렉션 기술은 왜 필요하고, 어디에서 사용할까?

사실, 이 기술을 직접적으로 사용하는 일은 거의 없습니다. 대부분은 이미 구현되어 있는 프레임워크나 라이브러리를 통해 간접적으로 사용하게 됩니다. 그럼에도 불구하고 리플렉션을 알아야 하는 이유는 명확합니다. 우리가 사용하는 많은 기능들이, 내부적으로 리플렉션 위에서 동작하기 때문입니다. 예를 들어 Spring Framework 의 DI, AOP, 애노테이션 기반 기능들은 모두 런타임에 객체를 분석하고 제어해야 하는데, 이 과정에서 리플렉션이 적극적으로 사용됩니다. 또한 java.lang.reflect.Proxy 를 기반으로 한 동적 프록시 역시 리플렉션을 통해 메서드 호출을 가로채고 위임하는 구조로 동작합니다.

다음과 같은 의문이 들 수 있습니다. "이미 잘 만들어진 추상화가 있는데, 굳이 내부 구현까지 알아야 할까?"
이 질문에 대한 답은 명확합니다. 추상화는 편의를 제공하지만, 문제 상황에서는 원인을 숨기기 때문입니다.

실제로 이러한 문제를 경험한 적이 있습니다. readOnly 트랜잭션이 적용되어 있다면 일반적으로 Replica DB로 라우팅될 것이라고 기대합니다. 하지만 예상과 달리 Primary DB가 사용되는 문제가 발생했습니다. 원인을 분석해보니, 트랜잭션 속성이 적용되기 전에
Connection이 먼저 생성되면서 라우팅이 잘못 이루어지고 있었습니다. 이 문제를 해결하기 위해,
Connection 생성 시점을 트랜잭션 속성이 반영된 이후로 미루는 방식을 선택했습니다. 구체적으로 LazyConnectionDataSourceProxy 를 활용하여 올바른 시점에 Connection이 생성되도록 구조를 변경했습니다.

이제 실제로 리플렉션이 어떻게 동작하는지 살펴보겠습니다.

앞서 리플렉션은 런타임에 클래스의 구조를 조회하고, 메서드나 필드에 접근하여 동적으로 실행할 수 있는 기능이라고 설명했습니다.
그렇다면 내부에서는 어떤 흐름으로 동작하고 있을까요?

리플렉션의 시작은 항상 java.lang.Class 객체로부터 시작합니다.

이는 객체를 생성할 때 클래스를 정의하는 것과 비슷한 개념으로 볼 수 있습니다.
다만 중요한 차이는, 리플렉션에서는 실제 객체 생성 여부와 관계없이 클래스의 구조에 접근할 수 있다는 점입니다.

예를 들어 다음과 같은 클래스가 있다고 가정해보겠습니다.

Class Person {
  private int num;

  public Persion(int num) {
   this.num = num;
  }
  
  public void methods(int a) {
   // 암튼 구현
  }

}

하지만 리플렉션을 사용하면, 객체를 생성하지 않더라도 Class 객체를 통해 클래스의 구조에 접근할 수 있습니다.

Class<?> clazz = Person.class;

이렇게 얻은 Class 객체를 통해 필드, 메서드, 생성자 정보를 조회할 수 있습니다.

리플렉션을 통해 접근할 수 있는 주요 요소는 크게 세 가지입니다.

  • 필드(Field)
  • 메서드(Method)
  • 생성자(Constructor)

각 요소는 서로 다른 역할을 가지고 있습니다.

필드는 객체의 상태를 나타내며, 리플렉션을 통해 private 필드까지 접근하여 값을 조회하거나 변경할 수 있습니다.
생성자는 객체를 생성하는 역할을 하며, 리플렉션을 통해 특정 생성자를 선택하여 동적으로 객체를 생성할 수 있습니다.
하지만 실제로 리플렉션에서 가장 많이 사용되는 부분은 메서드입니다.
메서드는 단순 조회를 넘어, invoke()를 통해 런타임에 직접 실행할 수 있기 때문입니다.

리플렉션을 사용한 객체는 JVM에서 바로 실행되지 않습니다. 내부적으로는 Method.invoke() -> MethodAccessor.invoke() -> 실제 메서드 실행 이 되어집니다. 즉, 리플렉션은 바로 실행되는 것이 아니라, 중간 레이어를 통해 간접적으로 실행되는 구조입니다.

어째서 JVM으로 바로 실행하지 않고, 리플렉션은 중간과정을 거치는가?

리플렉션은 컴파일 시점에 구조가 확정되지 않기 때문에, JVM이 직접 최적화할 수 없고 중간 레이어를 거치게 됩니다.
일반 메서드 호출과 리플렉션 호출을 비교해보겠습니다. 일반 메서드 호출은 컴파일 시점에 어떤 메서드가 실행될지 이미 확정됩니다.
그렇기 때문에 JVM은 해당 호출에 대해 직접 최적화를 수행할 수 있습니다.
반면, 리플렉션 호출은 어떤 메서드가 실행될지 런타임에 결정됩니다.
즉, JVM 입장에서는 정적 정보가 부족한 상태입니다. 그렇다면 왜 invoke()로 바로 실행하지 못할까요?

그 이유는 크게 세 가지입니다.
첫 번째는 타입 안정성 보장입니다. 리플렉션은 실행 시점에 객체와 파라미터를 전달받습니다.

method.invoke(obj, args);

이때 JVM은 매번 다음과 같은 검사를 수행해야 합니다.

  • 객체 타입이 올바른지
  • 매개변수 타입이 맞는지
  • 해당 메서드를 호출할 수 있는지

즉, 컴파일 시점이 아니라 런타임에 모든 검증이 이루어집니다.
두 번째는 접근 제어 및 보안 문제입니다. 리플렉션은 private 메서드나 필드에도 접근이 가능합니다.
이는 캡슐화를 깨는 접근이기 때문에 JVM 입장에서는 반드시 보안 체크가 필요합니다.

즉, 검증 없이 바로 실행시키는 것은 위험합니다.
세 번째는 호출 대상의 불확정성입니다.

리플렉션은 어떤 메서드가 실행될지 런타임에 결정됩니다. 이 구조에서는 JVM이 호출 대상을 미리 알 수 없기 때문에 최적화가 어렵습니다.
결국 이러한 이유들 때문에, 리플렉션은 바로 실행되지 않고 중간 계층을 거치게 됩니다.

Method.invoke()
 → MethodAccessor.invoke()
   → 실제 메서드 실행

이 중간 계층은 타입 검증, 접근 제어, 호출 위임과 같은 역할을 수행합니다.
정리해보면, 리플렉션은 단순히 느린 것이 아니라, 유연성을 얻기 위해 필수적인 검증과정을 포함하고 있는 구조라고 볼 수 있습니다.

다행인 점은, 리플렉션이 항상 느린 것은 아니라는 점입니다. JDK는 성능 문제를 인지하고 있기 때문에, 내부적으로 최적화를 수행합니다.초기에는 Native 방식으로 실행되지만, 일정 횟수 이상 호출되면 bytecode 기반 호출로 변경됩니다.
이 과정에서 기존의 Native 호출은 보다 빠른 형태의 호출로 대체되며, 반복 호출이 많은 경우 성능을 개선할 수 있습니다.

이러한 최적화 과정을 Reflection Inflation이라고 합니다.

초기: NativeMethodAccessorImpl
 → 이후: GeneratedMethodAccessor

그렇다면, Reflection Inflation는 무엇일까요?

리플렉션은 앞서 살펴본 것처럼 중간 계층을 거쳐 실행되기 때문에 일반 메서드 호출보다 상대적으로 느릴 수 있습니다.
JDK는 이러한 성능 문제를 완화하기 위해 내부적으로 최적화 전략을 사용합니다.

그 중 하나가 바로 Reflection Inflation입니다.

리플렉션은 처음 호출될 때는 Native 방식으로 실행됩니다.
이 방식은 초기 실행 비용은 적지만, 반복 호출이 많아질수록 성능이 좋지 않습니다.

그래서 JDK는 일정 횟수 이상 동일한 리플렉션 호출이 발생하면,
해당 호출을 보다 빠르게 처리하기 위해 바이트코드 기반 호출 방식으로 변경합니다.

초기: NativeMethodAccessorImpl
 → 이후: GeneratedMethodAccessor
 

이렇게 기존의 Native 기반 호출을, 더 빠른 형태의 accessor로 교체하는 과정을 Reflection Inflation이라고 합니다.
이 구조를 보면 재미있는 점이 하나 있습니다. 처음부터 빠른 방식을 사용하지 않고, 왜 굳이 두 단계를 나누었을까요?

그 이유는 비용 때문입니다.
바이트코드를 생성하는 작업 자체도 비용이 크기 때문에, 호출이 적은 경우에는 오히려 손해가 될 수 있습니다.

그래서 JDK는 처음에는 가볍게 Native로 실행하고 반복 호출이 많아질 때만 최적화를 적용하는 방식으로 설계되어 있습니다.
하지만 Reflection Inflation방식은 과거의 방식이고 최근 JDK에는 MethodHandle 기반으로 내부 구현이 변경되었습니다.

어째서 Reflection Inflation방식대신 MethodHandle방식으로 변경되었는가?

Reflection Inflation은 반복 호출 시 성능을 개선하기 위한 전략이었지만,
근본적인 한계를 가지고 있었습니다.

첫 번째는 JVM이 이해하기 어려운 구조라는 점입니다.
리플렉션은 Method.invoke()를 통해 실행되며, 중간에 MethodAccessor와 같은 계층을 거치게 됩니다.
이 구조는 JVM 입장에서 "어떤 메서드가 호출되는지"를 명확하게 알기 어렵기 때문에, 인라이닝과 같은 최적화를 적용하기 힘듭니다.
즉, 최적화의 한계가 존재합니다.

두 번째는 복잡한 내부 구현 구조입니다.

Reflection Inflation은

  • Native 호출
  • 바이트코드 생성
  • accessor 교체

와 같은 여러 단계를 포함하고 있습니다.
이러한 구조는 유지보수 비용이 높고, JVM 내부 구현을 복잡하게 만드는 원인이 됩니다.

세 번째는 일관되지 않은 성능 특성입니다.
초기에는 Native 방식으로 실행되고, 일정 횟수 이후에만 최적화가 적용됩니다.
즉, 호출 횟수에 따라 성능이 달라지는 구조이며, 예측하기 어려운 성능 특성을 가지게 됩니다.
이러한 문제를 해결하기 위해 등장한 것이 MethodHandle입니다.
MethodHandle은 리플렉션과 달리 JVM이 이해할 수 있는 형태로 호출을 표현합니다.

그렇다면, MethodHandle은 어떻게 동작하길래 JVM이 이해할 수 있는 형태로 표현하는 걸까요?

MethodHandle은 단순한 메타데이터가 아니라 이미 호출 대상과 타입이 확정된 실행 가능한 참조로 동작합니다.
코드로 확인해봅시다.

리플렉션

리플렉션 방식에서는 다음과 같이 코드가 작성이 되었습니다.

class Person {
    public void hello(int a) {
        System.out.println("hello " + a);
    }
}
Method method = Person.class.getMethod("hello", int.class);
Person p = new Person();
// 실행
method.invoke(p, 10);

리플렉션은 런타임 시점에서 메서드가 실행됩니다. 그렇다는 이야기는, 컴파일 시점에는 호출 대상이 확정되지 않기 때문에 보안 검사와 호출 대상의 불확실성을 런타임에 처리해야 한다는 의미입니다. 이 과정에서 리플렉션은 중간 계층을 두어 실행되는 구조를 가지게 되었고,
그 결과 매번 해석과 검증이 필요한 문제가 발생했습니다. 이러한 성능 문제를 완화하기 위해 등장한 것이 Reflection Inflation입니다.

리플렉션은 처음에는 Native 방식으로 실행되지만, 일정 횟수 이상 호출이 발생하면 바이트코드 기반 호출로 전환됩니다.
이를 통해 반복 호출에 대한 성능을 일부 개선할 수 있었습니다.
하지만 이 방식은 근본적인 해결책은 아니었습니다. 여전히 중간 계층을 거치는 구조이기 때문에
추가적인 호출 비용이 존재했고, JVM이 직접 최적화하기 어려운 구조라는 한계를 가지고 있었습니다.

이러한 한계를 해결하기 위해 등장한 것이 MethodHandle입니다.

MethodHandle 방식

MethodHandle은 리플렉션과 달리, 호출 대상과 타입 정보를 미리 확정된 형태로 표현합니다.
이 덕분에 JVM은 해당 호출을 더 명확하게 이해할 수 있고, 인라이닝과 같은 최적화를 적용할 수 있게 됩니다.

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;

MethodHandles.Lookup lookup = MethodHandles.lookup();

MethodHandle mh = lookup.findVirtual(
    Person.class,
    "hello",
    MethodType.methodType(void.class, int.class)
);

Person p = new Person();

// 실행
mh.invokeExact(p, 10);

이렇게 되면 호출 방식 자체가 달라지게 됩니다. 리플렉션은 실행 시점마다 호출 대상을 해석하고 검증하는 구조입니다.
즉, 매번 "무엇을 호출할지"를 확인하는 과정이 필요합니다. 반면 MethodHandle은 메서드와 타입 정보가 이미 결합된 상태로 생성되기 때문에, 호출 시점에는 추가적인 해석 과정이 필요하지 않습니다.

핵심 차이는 다음과 같습니다.

Reflection
문자열 → 메서드 탐색 → 실행 시 검증 → 호출

MethodHandle
메서드 + 타입 → 생성 시 확정 → 바로 호출

이러한 차이로 인해, MethodHandle은 JVM이 호출 구조를 명확하게 인식할 수 있는 형태로 표현됩니다.
즉, 단순한 동적 실행이 아니라, 정적인 호출에 가까운 구조로 변환된 것입니다.

그 결과 JVM은 해당 호출에 대해

  • 인라이닝
  • JIT 최적화
  • 호출 경로 단순화

와 같은 최적화를 적용할 수 있게 됩니다.

마무리

리플렉션부터 MethodHandle까지 살펴보았습니다. 리플렉션의 한계와, 이를 어떻게 보완하고 개선해왔는지에 대해 학습할 수 있었습니다. 특히 단순히 "리플렉션은 느리다"는 수준이 아니라, 왜 그런 구조를 가지게 되었는지, 그리고 JDK가 이를 어떻게 해결하려 했는지를 이해할 수 있었다는 점이 의미 있었습니다. 다만 아쉬운 점도 있습니다. MethodHandle에 대해서는 개념적으로 이해하는 수준에 그쳤고,
아직 내부 동작이나 활용 방식까지 깊게 다루지는 못했습니다. 아무래도 처음 접한 개념이다 보니, 완전히 체화하기에는 시간이 조금 더 필요할 것 같습니다.

반응형

댓글

Designed by JB FACTORY