[Java] Reflection & Dynamic Call

rockstar·2023년 6월 6일
1

Java

목록 보기
2/2

Spring을 통해서 프록시를 구현하려 하다 보니, 모든 클래스와 인터페이스에 프록시를 적용시켜야 하는 불편함에 직면하게 되었다. 물론 횡단 관심사를 정해서 AOP 방식의 프로그래밍을 하면 되겠지만, AOP를 사용하지 않고 순수 Java만으로 Proxy 객체를 구현해보는 게 의미가 있겠다 싶어서 직접 해보려고 한다.

Java reflection은 Java에서 기본적으로 제공하며, java.lang.reflect패키지에 위치하고 있다. reflection이라는 게 사실 Java의 기본적인 걸 공부하면서 흔하게 볼 수 있는 키워드는 아니지 않을까 생각한다. 프레임워크에서 주입을 하는 방식을 터득하고 나면, 런타임 환경에서 기존의 코드 수정 없이 특정 메서드나 객체의 상태 동작 등 동적으로 적용하고 싶을 때가 생길 수도 있게 되는데, 이 때 reflection 개념이 적용된다고 보면 된다.

Reflection

Java 런타임 환경에서 클래스, 메서드, 필드, 인터페이스 및 기타 구성 요소의 동작을 검사하고 조작할 수 있게 해주는 기능이다.

import java.lang.reflect.Method;

public class ExampleClass {
  public void method1() {
    System.out.println("Method 1 called");
  }

  public void method2() {
    System.out.println("Method 2 called");
  }
}

public class Main {
  public static void main(String[] args) {
    ExampleClass example = new ExampleClass();

    Class<?> exampleClass = example.getClass();

    Method[] methods = exampleClass.getMethods();

    for (Method method : methods) {
      System.out.println(method.getName());
    }
  }
}

main메서드에서 인스턴스를 생성하고, getClass() 메서드를 통해서 클래스 정보를 가져왔다. 그 이후에는 class에서 getMethods() 메서드를 사용했고, 클래스 내에 있는 모든 메서드를 가져와 배열에 담았다. 그리고 나서 메서드 배열에서 하나씩 꺼낸 뒤, println() 메서드를 실행하여 모든 메서드의 이름을 출력했다.

극단적으로 생각을 해보자. 만약 1억 개가 넘는 메서드가 있는 상황에서 모든 메서드에 Log 기능을 추가하여, 실행 시간과 종료 시간을 체크하려고 한다면 어떻게 할 수 있을까?

처음부터 코드를 작성한다면 일일이 작성을 할 순 있겠지만, 그것과는 별개로 핵심 기능과 부가 기능을 분리를 시켜야 하는데, 분리를 시키기 위해서 인터페이스 또는 구현체를 직접 참조 등 프록시 방식을 사용한다거나 할 수도 있을 것이다.

다만, 이러한 방식도 결국엔 클래스마다 implement를 통해서 인터페이스를 구현한다던지, 직접 구현체를 extends 해야 하는 번거로움이 딸려올 수도 있기에, 어느 정도 한계가 있다. 이 때, reflection은 이러한 수고들을 한 번에 해결할 수 있게 해준다.

import java.lang.reflect.Method;

// LoggingInterceptor라는 이름을 가진 클래스를 생성
public class LoggingInterceptor {
  public Object intercept(Object obj, Method method, Object[] args) throws Throwable {

    // 메서드가 호출될 때 console 출력
    System.out.println("Method " + method.getName() + " called");

    // 메서드 호출 결과(return 값) 저장 -> 실제 인자 값을 invoke에 넣어야 함
    Object result = method.invoke(obj, args); 

    // 메서드가 리턴될 때 console 출력
    System.out.println("Method " + method.getName() + " returned " + result);
    return result;
  }
}
import java.lang.reflect.Method;

// 사용하고 싶은 메서드를 미리 구현함
public class ExampleClass {
  public void method1() {
    System.out.println("Method 1 called");
  }

  public void method2() {
    System.out.println("Method 2 called");
  }
}

public class Main {
  public static void main(String[] args) {

    // 메서드가 존재하는 클래스의 인스턴스를 생성(직접 구현 메서드)
    ExampleClass example = new ExampleClass();

    // LoggingInterceptor 인스턴스 생성(로그 관련 메서드)
    LoggingInterceptor interceptor = new LoggingInterceptor();

    // 구현한 메서드의 클래스 "정보"를 가져옴
    Class<?> exampleClass = example.getClass();

    // 구현한 메서드를 배열로 받음
    Method[] methods = exampleClass.getMethods();

    // 아래처럼 해당 클래스 내의 메서드라면 로그를 적용할 수 있게 사용할 수 있음
    for (Method method : methods) {
      try {
        // 접근 제어자를 확인하여 접근할 수 있는지 파악 후에, 접근할 수 없으면 접근할 수 있게 설정
        if (!method.isAccessible()) {
          method.setAccessible(true);
        }
        // 직접 구현한 메서드를 실행하기 전에 Logging을 하고 메서드를 실행한 후에 리턴 값 반환
        Object result = interceptor.intercept(example, method, new Object[0]);
      } catch (Throwable t) {
        t.printStackTrace();
      }
    }
  }
}

위와 같이 reflection이라는 라이브러리를 이용하여, 다양하게 클래스, 메서드, 인스턴스 등에 접근할 수 있게 된다.

문제점

컴파일 시점 에러 확인 불가
런타임 시점에 동적으로 클래스나 메서드의 정보를 사용한다. 위의 예시에선 getMethods() 메서드를 통해서 메서드를 직접 가져와서 사용했지만, 아래와 같이 코드를 작성할 수도 있다.

@Test
 void reflection2() throws NoSuchMethodException, ClassNotFoundException, InvocationTargetException, IllegalAccessException {
  Hello target = new Hello();
  Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");

  Method methodCallA = classHello.getMethod("callA");
  dynamicCall(methodCallA, target);

  Method methodCallB = classHello.getMethod("callB");
  dynamicCall(methodCallB, target);

 }

 private void dynamicCall(Method method, Object target) throws InvocationTargetException, IllegalAccessException {
  log.info("start");
  Object result = method.invoke(target);
  log.info("result = {}", result);
 }
 

위 코드와 같이 method를 직접 가져와서 쓰는 경우에는 메서드의 이름을 직접 작성해서 넣어야 되는데, "callA", "callB"를 직접 넣어도 컴파일 에러가 발생하지 않는다. 즉, 런타임 시에 에러가 발생할 수도 있게 되는 것인데 이런 문제들 때문에 조심히 사용해야 한다.

성능적 이슈
필드 및 메서드 등 메타 데이터를 얻기 위해서 필요한 것 이상의 작업을 해야 하고 추가적인 처리 시간이 필요하다. 이러한 추가 계산 리소스 및 시간을 오버헤드라고 표현하는데, 이러한 오버헤드는 성능과 유연성 또는 용이성 간의 Trade-off 관계에 있기 때문에 사용에 유의해야 한다. 상황에 따라 이러한 오버헤드를 최소화하는 게 최적의 성능을 달성하기 위한 중요한 요소가 될 수 있다.

@Test
 void reflection1() throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
// 클래스에 대한 참조를 얻음
  Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");
  Hello target = new Hello();

  // 메서드에 대한 참조를 얻음
  Method methodCallA = classHello.getMethod("callA");

  // invoke는 동적으로 호출하기 위해서 사용함(실제 인스턴스를 invoke() 메서드의 인자로 넣어 호출)
  // callA라는 메서드를 호출한 반환 값을 받는 듯?
  Object resultA = methodCallA.invoke(target);
  log.info("result A = {}", resultA);

  // invoke는 동적으로 호출하기 위해서 사용함(실제 인스턴스를 invoke() 메서드의 인자로 넣어 호출)
  // callB라는 메서드를 호출한 반환 값을 받는 듯?
  Method methodCallB = classHello.getMethod("callB");
  Object resultB = methodCallB.invoke(target);
  log.info("result B = {}", resultB);
}

처음부터 이해하기 힘들 수도 있다. 우리는 평소에 메서드를 사용하면 클래스나 메서드의 정보보다는 메서드의 return 값에만 관심을 가지는 경우가 많기 때문인데, 이 return 값에 대한 관심에서 클래스나 메서드 자체에 관심을 가질 수 있도록 어느 정도 연습이 필요하다고 생각이 든다. 그렇기 때문에 시간적 여유가 된다면 깊게 공부해보는 걸 추천하고, 여유가 되지 않는다고 하더라도 가볍게 숙지를 하고 넘어갔으면 한다.


잘못된 정보는 지적해주시면 감사하겠습니다.

0개의 댓글