[Java] 29. Reflection 기능을 사용하는 방법 - Class편


Study/Java  2020. 6. 4. 20:12

안녕하세요. 명월입니다.


이 글은 Java에서의 Reflection 기능을 사용하는 방법 - Class편에 대한 글입니다.


Reflection이란 클래스의 구조를 분석하여 동적 로딩을 가능하게 하는 기능입니다. 라고 설명은 되어 있습니다만, 추상적인 표현으로는 무슨 이야기인지 어렵습니다.

링크 - https://www.oracle.com/technical-resources/articles/java/javareflection.html

제가 개인적으로 쉽게 설명하면, 지금까지 Java를 사용하면서 인스턴스를 할당하는 것은 new 키워드를 사용해서 선언했습니다.

링크 - [Java] 07. 클래스를 생성하는 방법 (생성자를 사용하는 방법)


그런데 이 Reflection은 new를 사용하지 않고 인스턴스를 생성할 수 있습니다.

import java.lang.reflect.Constructor;
// 테스트할 클래스
class Node {
  // 생성자
  public Node() { }
  // 함수 생성
  public void print() {
    // 콘솔 출력
    System.out.println("Hello world");
  }
}
public class Example {
  // 실행 함수
  public static void main(String... args) {	
    try {
      // Node 클래스의 타입을 선언한다.
      Class<?> cls = Node.class;
      // Node 클래스의 생성자를 취득한다.
      Constructor<?> constructor = cls.getConstructor();	
      // 생성자를 통해 newInstance 함수를 호출하여 Node 인스턴스를 생성한다.
      Node node = (Node)constructor.newInstance();	
      // node 인스턴스의 print 함수를 실행한다.
      node.print();
    } catch (Throwable e) {
      e.printStackTrace();
    }
  }
}

결과를 보시면 node.print를 호출해서 콘솔에 Hello world의 결과가 출력되었습니다.

위 예제에서 어디에도 new Node라는 것은 없습니다만, Reflection의 기능을 통해서 Node의 인스턴스를 생성한 것입니다.


그렇다면, new Node를 사용했으면, 더 적은 스탭으로 인스턴스를 생성할 수 있는데 굳이 이 Reflection을 사용하는 것일까?

import java.lang.reflect.Constructor;
// 테스트할 클래스
class Node {
  // 생성자
  public Node() { }
  // 함수를 재정의 했다.
  @Override
  public String toString() {
    // 콘솔 출력
    System.out.println("Hello world");
    return null;
  }
}	
public class Example {
  // 실행 함수
  public static void main(String... args) {
    try {
      // Class.forName의 함수를 사용해서 문자열로 Class<?> 타입을 취득해 올 수 있다.
      Class<?> clz = Class.forName("Node");
      // String으로 취득한 클래스 타입으로 생성자를 취득합니다.
      Constructor<?> constructor = clz.getConstructor();
      // 생성자를 통해 newInstance 함수를 호출하여 Node 인스턴스를 생성한다.
      Object node =  constructor.newInstance();
      // node 인스턴스의 toString 함수를 실행한다.
      node.toString();
    } catch (Throwable e) {
      e.printStackTrace();
    }
  }
}

위 예제를 보시면 String 타입으로 클래스를 탐색해서 선언하는 게 가능합니다.

이것은 두가지 매력이 있습니다.

첫번째는 String의 값은 컴파일 단계에서 체크를 하지 않습니다. 위 예제에서 toString을 재정의하는 것으로 캐스팅도 필요없이 toString으로 콘솔 출력을 했습니다.

즉, Example 클래스 내부에서는 Node 함수를 사용하는 것이 없으므로 컴파일할 때, Node 클래스를 만들지 않아도 생성이 가능합니다. 즉, 실행할 때만 Node클래스가 있으면 되지 동적 바인딩이 가능하다는 뜻입니다.

public class Example {
  // 실행 함수
  public static void main(String... args) {
    try {
      // Class.forName의 함수를 사용해서 문자열로 Class<?> 타입을 취득해 올 수 있다.
      Class<?> clz = Class.forName("Node");
      // String으로 취득한 클래스 타입으로 생성자를 취득합니다.
      Constructor<?> constructor = clz.getConstructor();
      // 생성자를 통해 newInstance 함수를 호출하여 Node 인스턴스를 생성한다.
      Object node =  constructor.newInstance();
      // node 인스턴스의 toString 함수를 실행한다.
      node.toString();
    } catch (Throwable e) {
      e.printStackTrace();
    }
  }
}

위처럼 Example 클래스만 만들고 Node 클래스는 만들지 않았습니다. 컴파일은 문제없이 잘됩니다.

실행을 하면 Node 클래스가 없다고 하네요.

Node 클래스를 만들고 Node 클래스만 컴파일하고 Example를 실행하겠습니다.

동적 바인딩이 되는 것을 확인할 수 있습니다. 이 뜻은 기동 중인 프로그램에서 재실행이 필요없이 소스 교체가 가능하다는 것입니다.

이론은 그렇습니다. 그러나 실무에서는 예기치 못하는 다른 에러가 발생할 수 있기 때문에 가령 Node 소스를 바꾸는데 프로그램에서 이미 Node를 메모리에 할당했다. 그런데 소스가 바뀌어 있다.. 등등의 문제가 발생할 수 있습니다. 때문에 사용하지 않습니다.


두번째는 인스턴스 할당을 외부에서 할 수 있습니다.

// 테스트할 클래스
class Node1 {
  // 생성자
  public Node1() { }
  // 함수를 재정의 했다.
  @Override
  public String toString() {
    return "Node1";
  }
}
// 테스트할 클래스
class Node2 {
  // 생성자
  public Node2() { }
  // 함수를 재정의 했다.
  @Override
  public String toString() {
    return "Node2";
  }
}
// 테스트할 클래스
class Node3 {
  // 생성자
  public Node3() { }
  // 함수를 재정의 했다.
  @Override
  public String toString() {
    return "Node1";
  }
}
public class Example {
  // 클래스 취득 함수
  private static Object getClass(String name) {
    // 파라미터 값이 Node1일 경우 Node1클래스를 반환
    if("Node1".equals(name)) {
      return new Node1();
    // 파라미터 값이 Node2일 경우 Node2클래스를 반환
    } else if("Node2".equals(name)) {
      return new Node2();
    // 파라미터 값이  Node3일 경우 Node3클래스를 반환
    } else if("Node3".equals(name)) {
      return new Node3();
    }
    return null;
  }
  // 실행 함수
  public static void main(String... args) {
    // 클래스를 받아온다.
    Object instance = getClass("Node1");
    // 콘솔 출력
    System.out.println(instance.toString());
  }
}

위 소스를 보시면 제가 getClass 함수에서 Node1의 값을 넣어서 Node1클래스를 받아옵니다. 실제 이런식으로 작성 많이 합니다.

그런데 여기에 들어가는 클래스가 사양에 따라 증가한다면 getClass의 if문은 점점 늘어날 것입니다. 한 100개 정도로 생성되면 if else만 100개 만들어야 겠네요.

그런데 이런 Reflection을 이용하면 소스가 간단해 집니다.

import java.lang.reflect.Constructor;
// 테스트할 클래스
class Node1 {
  // 생성자
  public Node1() { }
  // 함수를 재정의 했다.
  @Override
  public String toString() {
    return "Node1";
  }
}
// 테스트할 클래스
class Node2 {
  // 생성자
  public Node2() { }
  // 함수를 재정의 했다.
  @Override
  public String toString() {
    return "Node2";
  }
}
// 테스트할 클래스
class Node3 {
  // 생성자
  public Node3() { }
  // 함수를 재정의 했다.
  @Override
  public String toString() {
    return "Node1";
  }
}
public class Example {
  // 클래스 취득 함수
  private static Object getClass(String name) {
    try {
      // Class.forName의 함수를 사용해서 문자열로 Class<?> 타입을 취득해 올 수 있다.
      Class<?> clz = Class.forName(name);
      // String으로 취득한 클래스 타입으로 생성자를 취득합니다.
      Constructor<?> constructor = clz.getConstructor();
      // 생성자를 통해 newInstance 함수를 호출하여 Node 인스턴스를 생성한다.
      return constructor.newInstance();
    } catch (Throwable e) {
      return null;
    }
  }
  // 실행 함수
  public static void main(String... args) {
    // 클래스를 받아온다.
    Object instance = getClass("Node1");
    // 콘솔 출력
    System.out.println(instance.toString());
  }
}

위 소스는 Class가 100개가 늘어나도 getClass는 수정이 없을 것입니다.


이 Reflection은 이렇다고 만능은 아닙니다. 문제는 성능입니다.

import java.lang.reflect.Constructor;
import java.util.ArrayList;
import java.util.List;
// 테스트할 클래스
class Node {
  // 테스트 변수
  private String data;
  // 생성자
  public Node(String data) {
    this.data = data;
  }
}
public class Example {
  // 클래스 취득 함수
  private static Object getClass(String name, String param) {
    try {
      // Class.forName의 함수를 사용해서 문자열로 Class<?> 타입을 취득해 올 수 있다.
      Class<?> clz = Class.forName(name);
      // String으로 취득한 클래스 타입으로 생성자를 취득합니다.
      Constructor<?> constructor = clz.getConstructor(String.class);
      // 생성자를 통해 newInstance 함수를 호출하여 Node 인스턴스를 생성한다.
      return constructor.newInstance(param);
    } catch (Throwable e) {
      return null;
    }
  }
  // 실행 함수
  public static void main(String... args) {
    // 예제로 100만개 인스턴스 만들어 보자
    int count = 1000000;
    // 인스턴스를 저장할 리스트
    List<Object> list = new ArrayList<>(count);
    // 시작
    long startTime = System.currentTimeMillis();
    // 루프
    for (int i = 0; i < count; i++) {
      // 할당하면서 리스트에 삽입
      list.add(new Node(Integer.toString(i)));
    }
    // 끝
    long endTime = System.currentTimeMillis();
    // 시간 측정
    System.out.println(endTime - startTime);
    // 리스트 비우고
    list.clear();
    // 시작
    startTime = System.currentTimeMillis();
    // 루프
    for (int i = 0; i < count; i++) {
      // 할당하면서 리스트에 삽입
      list.add(getClass("Node", Integer.toString(i)));
    }
    // 끝
    endTime = System.currentTimeMillis();
    // 시간 측정
    System.out.println(endTime - startTime);
  }
}

예제로 변수가 하나있는 class를 100만개 할당해 봤습니다. 차이가 5배 차이가 나네요. 아마 변수가 많고 생성할 인스턴스가 많아지면 더더욱 느려질 것입니다.

여기서 이렇게 큰 차이가 나는 것은 아마 저 Class.forName과 getConstructor 때문입니다. 왜냐하면 탐색을 하는 시간이 들기 때문입니다.

그럼 저 탐색 부분을 빼보겠습니다.

import java.lang.reflect.Constructor;
import java.util.ArrayList;
import java.util.List;
// 테스트할 클래스
class Node {
  // 테스트 변수
  private String data;

  // 생성자
  public Node(String data) {
    this.data = data;
  }
}
public class Example {
  // 실행 함수
  public static void main(String... args) {
    try {
      // 예제로 100만개 인스턴스 만들어 보자
      int count = 1000000;
      // 인스턴스를 저장할 리스트
      List<Object> list = new ArrayList<>(count);
      // 시작
      long startTime = System.currentTimeMillis();
      // 루프
      for (int i = 0; i < count; i++) {
        // 할당하면서 리스트에 삽입
        list.add(new Node(Integer.toString(i)));
      }
      // 끝
      long endTime = System.currentTimeMillis();
      // 시간 측정
      System.out.println(endTime - startTime);
      // 리스트 비우고
      list.clear();
      // Class.forName의 함수를 사용해서 문자열로 Class<?> 타입을 취득해 올 수 있다.
      Class<?> clz = Class.forName("Node");
      // String으로 취득한 클래스 타입으로 생성자를 취득합니다.
      Constructor<?> constructor = clz.getConstructor(String.class);
      // 시작
      startTime = System.currentTimeMillis();
      // 루프
      for (int i = 0; i < count; i++) {
        // 할당하면서 리스트에 삽입
        list.add(constructor.newInstance(Integer.toString(i)));
      }
      // 끝
      endTime = System.currentTimeMillis();
      // 시간 측정
      System.out.println(endTime - startTime);
    } catch (Throwable e) {
      e.printStackTrace();
    }
  }
}

그래도 Reflection이 느리기는 합니다. 그러나 이 정도 차이면 어느 정도 감수할 만한 수치입니다. 그러나 여기서 문제는 저 Class<?> clz와 Constructor<?> constructor 데이터입니다.

성능의 차를 줄이기 위해서는 할당할 때마다 따른 변수에 보관해야 합니다. 즉, 이래저래 성능에 영향을 미치는 건 마찬가지이네요.


두번째는 에러가 컴파일에 걸리지 않습니다. 우리가 Node 생성자의 파라미터가 String 타입인데 int형을 넣으면 바로 컴파일할 때 에러가 발생합니다.

그런데 Reflection은 실행하지 않는 이상 에러를 잡아 낼 수가 없습니다. 즉, 확실하게 검증되는 소스가 아니라면, 또 빈번하게 수정되는 소스라면 매우 불편한 기능입니다.


Reflection 기능은 보통 Unit 테스트 환경이나 Framework를 만들 때, 의존성 주입을 설계할 때 많이 사용되는 기능입니다.


여기까지 Java에서의 Reflection 기능을 사용하는 방법 - Class편에 대한 글이었습니다.


궁금한 점이나 잘못된 점이 있으면 댓글 부탁드립니다.