주요글: 도커 시작하기
반응형
리플렉션 API를 사용하여 런타임에 동적으로 코드를 교체할 수 있는 방법에 대해 알아본다.

리플렉션과 런타임 코드 교체

클래스는 기본적으로 기능을 모아 놓은 집합이며, 특정한 규칙에 따라 기능을 제공하는 개체 타입이다. 대부분의 프로그램에서 어떤 클래스를 사용하기 위해서는 그 클래스가 어떤 규칙을 제공하는 지 알고 있어야만 한다. 그러한 규칙(또는 계약; contract)은 메소드와 필드의 형태로 표현된다.

자바에서, 프로그램이 사용하는 클래스의 기능과 관련된 정보는 컴파일 타임(compile-time)에 정해지며, 자바는 철저하게 이러한 규칙들을 검사하고 확인한다. 이것을 통해서 자바컴파일러(javac)는 클래스를 읽고 이해할 수 있으며, 또한 객체 사이에 주고 받는 메시지의 타입을 알 수 있다. 예를 들어, 다음과 같은 Callee라는 클래스가 있다고 해보자.

class Callee {
   .
   .
   
   public void doIt () throws CantDoException {
      ...
   }
   ....
}

그리고 Callee 클래스의 객체를 갖고 그것의 doIt() 메소드를 호출하는 Caller 클래스가 다음과 같다고 해 보자.

class Caller {
   Callee callee;
   
   public void someMethod() {
      .....
      callee.doIt();
      .....
   }
}

CanDoException이 RuntimeException을 상속받은 것이 아니라고 가정할 경우, Caller를 컴파일할 때 컴파일러는 CanDoException을 잡아서(catch) 처리하거나 또는 메소드의 throws 구문에 추가해야 한다는 에러 메시지를 출력할 것이다. 여기서 컴파일러는 클래스가 제공하는 규칙(여기서는, doIt() 메소드의 선언)에 따라서 에러 메시지를 출력하는 것이다.

하지만, 컴파일 타임에 클래스가 어떤 기능을 제공하는 지 알 수 없는 경우 어떻게 해야 할까? 런타임(Run-time)에 그러한 기능들을 발견할 수 있을까? 자바에서는 런타임에 클래스를 분석할 수 있도록 하기 위해 리플렉션(Reflection) API를 제공하고 있다. 이 API들은 java.lang.reflect 패키지에 정의되어 있으며, 또한, java.lang 패키지의 Class 클래스도 리플렉션 API와 함께 사용된다. 리플렉션 API는 런타임에 클래스가 제공하는 기능들(메소드와 필드)을 알 수 있도록 해 준다. 따라서, 디버거, 인터프리터, 프로파일러 그리고 위지윅(WYSIWYG) GUI 개발툴과 같이 런타임에 클래스를 분석해야 하는 어플리케이션에서 주로 리플렉션 API를 사용한다.

위에서 언급한 어플리케이션을 개발하는 경우가 아니면 리플렉션을 사용해야 하는 경우가 많지는 않다. 하지만, 때때로 컴파일 타임에 메소드 시그너쳐(signature)나 필드 등을 알 수 없는 클래스를 사용하여 작업을 해야 하는 경우가 존재하며, 이러한 경우에는 리플렉션을 사용할 수 밖에 없다.

자바빈 프레임워크는 실제로 빈을 빈박스와 같은 빈을 인식하는 콘테이너에 설치하기 위해서 리플렉션 메커니즘을 사용한다.

예를 들어, 다음과 같은 조건을 만족시켜야 하는 어플리케이션을 개발해야 한다고 가정해보자.

  • 중단없이 24시간 지속적으로 운행되어야 한다.
  • 데이터가 입력되면 특정한 알고리즘을 사용하여 데이터를 처리한다.
위의 두 조건을 만족시켜야 하는 것으로 댐에서 수위를 검사하여 방수량을 결정하는 어플리케이션을 예로 들 수 있다. 댐에 있는 센서로부터 수위에 대한 데이터가 이 어플리케이션에 전달되면, 이 어플리케이션은 특정한 알고리즘을 사용하여 방수량을 조절할 필요가 있는 지 판단하게 된다. 이 어플리케이션은 24시간 지속적으로 동작해야하며, 만약 특정 순간에 동작하지 않게 되면, 댐 주변은 홍수와 같은 재해를 입을 수도 있다.

수위와 방수량 조절과 관련된 알고리즘은 언제든지 변할 수 있다. 누군가가 더 좋은 알고리즘을 발명할 수도 있기 때문이다. 이런 경우, 어플리케이션이 런타임에 동적으로 알고리즘을 변경할 수 없다면, 알고리짐을 변경하기 위해서 어플리케이션의 동작을 중지시켜야 한다. 하지만, 어플리케이션의 동작을 중지시키면 안 되기 때문에, 동적으로 알고리즘을 변경할 수 없는 어플리케이션은 기존의 좋지 않은 알고리즘을 계속해서 사용해야만 한다. 이처럼 런타임에 동적으로 코드를 교체하고자 할 때 사용되는 것이 리플렉션이다.

리플렉션이 실제로 어떻게 동작하는 지 살펴보기전에 Algorithm이라는 인터페이스를 정의해보자. Algorithm 인터페이스는 알고리즘을 구현하는 모든 클래스가 implements 하는 인터페이스라고 가정한다. Algorithm 인터페이스는 다음과 같다.

interface Algorithm {
   public void process();
}

Class.forName()을 이용한 코드의 교체

이제부터 리플렉션을 파고 들어가보자. 교체가능한 코드를 작성하는 가장 손쉬운 방법은 Class 클래스의 newInstance() 메소드를 사용하는 방법이다. 다음 코드를 살펴보자.

Algorithm algo;
...
String className = ...;
algo = (Algorithm)Class.forName(className).newInstance()
algo.process();

Class.forName(String) 메소드는 파라미터로 전달받은 이름을 갖는 클래스와 관련된 Class 클래스의 객체를 생성한다. 예를 들어, 위 코드에서 className의 값이 "com.xyz.someAlgo"라면 Class.forName(className)에 의해 생성되는 Class 클래스의 객체는 com.xyz.SomeAlgo 클래스를 나타낸다. Class 클래스의 newInstance() 메소드는 Class 클래스가 나타내는 클래스의 인스턴스를 생성한다. 즉, com.xyz.SomeAlgo와 관련된 Class 객체의 newInstance() 메소드는 com.xyz.SomeAlgo 클래스의 인스턴스를 생성하는 것이다.

동적으로 코드를 교체하는 간단한 예제를 살펴보자. 여기서 살펴볼 예제는 WaterLevelTracer 클래스로서, 이 클래스는 일정 주기마다 지정된 알고리즘을 수행한다. WaterLevelTracer 클래스는 다음과 같다.

import java.io.*;

public class WaterLevelTracer implements Runnable {
   private final Algorithm DEFAULT_ALGORITHM = new Algo1();
   
   private Algorithm algo;
   private Thread thread;
   
   public WaterLevelTracer(Algorithm algo) {
      if (algo == null) {
         this.algo = DEFAULT_ALGORITHM;
      } else {
         this.algo = algo;
      }
   }
   
   public void start() {
      thread = new Thread(this);
      thread.start();

      try {
         BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
         
         while(true){
            System.out.println("교체할 Algorithm 클래스를 입력하세요.");
            String className = br.readLine();
            
            if(className.length()==0)
               continue;
            else if (className.equals("!quit")) {
               thread.interrupt();
               break;
            }
            // 알고리즘 런타임에 동적으로 교체
            algo = (Algorithm) ((Class.forName(className)).newInstance());
         }
      } catch(Exception ex) {
         ex.printStackTrace();
      }
   }   
   
   public void run() {
      try {
         while(true) {
            Thread.sleep(3000);
            algo.process();
         }
      } catch(InterruptedException ex){}
   }
   
   public static void main(String[] args) {
      new WaterLevelTracer(null).start();
   }
}

WaterLevelTracer 클래스는 쓰레드를 생성하며, 그 쓰레드는 3초 간격으로 지정된 알고리즘의 process() 메소드를 수행한다. 이 알고리즘을 동적으로 변환해주는 부분은 WaterLevelTracer 클래스의 start() 메소드로서, start() 메소드를 보면 키보드로부터 알고리즘으로 사용할 클래스의 이름을 입력받은 후, Class.forName() 메소드를 사용하여 동적으로 알고리즘을 교체하는 것을 알 수 있다. Algo1 클래스와 Algo2 클래스가 다음과 같다고 해 보자.

public class Algo1 implements Algorithm {
   public void process() {
      System.out.println("Algo1에서 처리");
   }
}

public class Algo2 implements Algorithm {
   public void process() {
      System.out.println("Algo2에서 처리");
   }
}

이 경우, WaterLevelTracer를 실행하면 다음과 같은 결과가 출력될 것이다.

c:\test>java WaterLevelTracer
교체할 Algorithm 클래스를 입력하세요.
Algo1에서 처리
Algo1에서 처리
Algo2
교체할 Algorithm 클래스를 입력하세요.
Algo2에서 처리
Algo2에서 처리
Algo2에서 처리
!quit

위에서 중간에 있는 "Algo2"는 키보드를 통해서 입력한 것이다. 키보드를 사용하여 "Algo2"를 입력한 후, WaterLevelTracer가 알고리즘으로 Algo1 클래스가 아닌 Algo2 클래스를 사용하는 것을 볼 수 있다.

리플렉션 API의 사용

이제 다시 원래 우리가 하고자 했던 것을 생각해보자. 이 글을 시작할 때 주목적은 컴파일타임에 클래스와 관련된 메소드와 필드를 모르는 상태에서 런타임에 코드를 교체가능하도록 하는 것이었다. 하지만, 앞에 있는 WaterLevelTracer 클래스는 이미 알고리즘으로 사용할 클래스가 Algorithm 인터페이스에 정의된 기능(또는 행동; behavior)을 제공하다는 사실을 알고 있다. (즉, process() 메소드를 호출할 수 있다는 것을 알고 있다.) 이것은 이미 컴파일타임에 클래스에 대한 정보가 알려졌다는 것을 의미한다.

진정으로 컴파일타임에 클래스에 대한 정보를 알지 못한 상태에서, 런타임에 코드를 교체가능하도록 만들기 위해서는 단순히 Class 클래스의 forName()과 newInstance() 메소드 뿐만 아니라 Class 클래스에서 제공하는 또 다른 메소드인 getMethod(), getMethods(), getInterfaces(), getFiled()와 같은 메소드를 사용해야만 한다. 실제로 자바빈 컴포넌트 프레임워크에서 사용되는 인트로스펙터(introspector)는 이러한 메소드를 사용하여 클래스를 분석한다. Class 클래스의 getMethods()와 같은 메소드를 사용함으로써 우리는 클래스에 있는 각 메소드의 파라미터와 리턴타입을 조사할 수 있고, 메소드 중에서 알고리즘을 사용하고자 하는 것을 호출할 수 있다.

알고리즘을 수행할 때 사용할 process() 메소드가 파라미터로 InputParam 타입을 전달받고 리턴타입이 ReturnValue라고 해보자. 이 경우 다음과 같이 리플렉션 API를 사용하여 파라미터로 InputParam 객체를 입력받고 리턴 타입이 ReturnValue인 메소드를 호출할 수 있게 된다.

Class klass = Class.forName(className);
Method[] methods = klass.getDeclaredMethods();
Class[] params;
for (int i = 0 ; i < methods.length ; i++) {
   params = methods[i].getParameterTypes();
   if (methods[i].getReturnType().getName().equals("ReturnValue") &&
       params.length() == 1 && params[0].getName().equals("InputParam") ) {
      
      retVal = (ReturnValue) (methods[i].invoke(klass.newInstance(), paramObj);
   }
}

앞에서 Class.forName() 만으로 거의 모든 과정이 끝났던 것에 비하면, 위 코드는 더욱 복잡해진 것을 알 수 있다. 위 코드는 Class 객체의 getDeclaredMethods() 메소드를 호출하여 forName() 메소드를 통해서 구한 Class 객체에 선언(또는 정의)되어 있는 메소드를 구한다. 이때 각각의 메소드는 Method 클래스를 통해서 표현된다. 메소드 목록을 구하면, for 문을 사용하여 각 Method의 시그너쳐(signature)를 구한다. 메소드의 시그너쳐는 Method 클래스의 getParameterTypes() 메소드와 getReturnType() 메소드를 통해서 구한다. 그런 후, 메소드 중에 파라미터의 타입이 InputParam이고, 리턴 타입이 ReturnValue인 것을 발견할 경우 Method.invoke(Object, Object[])를 호출함으로써 Method와 관련된 메소드를 호출하게 된다.

위 코드는 어떤 작업을 처리하기 위해 사용하는 인터페이스나 클래스의 이름을 전혀 알지 못한다. (앞의 코드는 Algorithm 인터페이스를 사용한다는 것을 코드 내에서 알고 있었다는 사실과 비교해보라!) 심지어 작업을 처리할 때 호출할 메소드의 이름조차 명시되어 있지 않다. 단지, 파라미터의 타입과 리턴 타입만 명시되어 있을 뿐이다. 즉, 위 코드는 모든 것이 런타임에 동적으로 처리되고 있으며, 진정으로 교체 가능한 코드를 제공하는 방법인 것이다.

java.lang.reflect.Proxy

실제로 JDK1.3에 새롭게 추가된 다이나믹 프록시(Dynamic Proxy) API는 동적인 프록시가 갖는 기능을 제공하기 위해서 이와 비슷한 검색 메커니즘을 사용하고 있다. 다이나믹 프록시 API를 사용하여 개발자는 프록시 객체를 생성할 수 있는데, 프록시 객체는 그것이 처리해야 하는 어떤 인터페이스에 대한 정보를 갖고 있다. 프록시 객체는 그것이 처리할 인터페이스의 특정한 메소드를 호출한다. 프록시 객체는 또한 최종적으로 호출되는 메소드를 포함하고 있는 객체에 대한 정보를 갖는다. 최종적으로 사용되는 객체는 InvocationHandler 인터페이스를 구현한다. InvocationHandler 인터페이스는 다음과 같은 한 개의 메소드만을 갖고 있다.

public Object invoke (Object proxy, Method m, Object[] args) throws Throwable

여기서 proxy는 생성된 프록시 객체에 대한 레퍼런스이다. invoke() 메소드는 런타임에 호출되며, 클라이언트 뿐만 아니라 구현자도 invoke() 메소드에 대해 알지 못한다. 다음은 InvocationHandler 인터페이스를 구현한 예제이다.

public class MyHandler implements java.lang.reflect.InvocationHandler {
   // 이는 딜리게이트 객체이며, 사용자가 정의한 객체가 될 수 있다.
   private Object delegate;
   
   public MyHandler (Object obj) {
      this.delegate = obj ;
   }
   
   public Object invoke (Object proxy, Method  m, Object[] args) throws Throwable {
      try {
         
         // 실제로 딜리게이트 객체의 메소드가 호출될 것이다.
         
      } catch(InvocationTargetException ex) {
         throw ex.getTargetException();
      } catch(Exception ex) {
         throw ex;
      }
      // 특정한 값 리턴
   }
}

예를 들어, 딜리게이트 객체가 세 개의 인터페이스 A, B, C를 구현한다고 해 보자. 이때, 다이나믹 프록시 API를 사용하면 이 인터페이스들 중의 하나의 타입과 관련된 프록시 객체를 생성할 수 있다. 예를 들면 다음과 같다.

A a = (A) java.lang.reflect.Proxy.newProxyInstance(
                                  A.class.getClassLoader(),
                                  new Class[] { A.class, B.class, C.class },
                                  new MyHanlder(delegate);

위와 비슷한 방법으로 B와 C 타입의 레퍼런스를 할당할 수도 있다. 인터페이스 A에 doSomething() 메소드가 선언되어 있다고 가정할 경우, 클라이어?가 "a.doSomething()"를 실행하면 그 호출은 실제로 InvocationHandler 인스턴스에 전달된다. 위의 경우는 MyHandler 객체에 포워드(forwarding)된다. 실제로 호출이 포워드되기 전에, 리?렉션 API를 사용하여 newProxyInstance() 메소드의 두번째 파라미터를 통해서 프록시에 제공된 모든 인터페이스를 검사함으로써 그 Method에 대한 레퍼런스를 구한다. 이 Method 레퍼런스와 그것에 대한 파라미터는 핸들러의 invoke() 메소드에 전달되며, 핸들러의 invoke() 메소드는 실제로 딜리게이트 객체에 있는 메소드를 호출하거나 또는 그 외의 원하는 것을 실행할 것이다.

결론

필자가 생각하기엔 런타임에 동적으로 클래스를 교체할 수 있는 가장 좋은 방법은 이 글에서 설명한 리플렉션 API를 사용하는 것이다. 리플렉션 API를 사용함으로써 우리는 컴파일타임에 클래스가 제공하는 자세한 정보를 알 수 없는 상황에서 런타임에 동적으로 클래스를 교체할 수 있게 된다.

관련링크:

+ Recent posts