Java 리플렉션 완벽 가이드: 동적 프로그래밍의 핵심

리플렉션이란 무엇인가?

Java 보안은 역직렬화 취약점에서 시작될 수 있으며, 역직렬화 취약점은 다시 리플렉션에서 출발합니다. 리플렉션은 대부분의 프로그래밍 언어에서 필수적인 부분이며, 객체를 통해 해당 클래스를 얻을 수 있고, 클래스를 통해 모든 메서드(비공개 포함)를 가져올 수 있으며, 가져온 메서드를 호출할 수 있습니다. 요컨대 "리플렉션"을 통해 Java라는 정적 언어에 동적 특성을 부여할 수 있습니다. 아마 이 설명만으로는 리플렉션이 무엇인지 여전히 명확하지 않을 수 있습니다. 이해를 돕기 위해 먼저 요구 사례를 제시하고 이를 통해 리플렉션을 소개하겠습니다.

요구 사례는 다음과 같습니다: 설정 파일 config.properties에 지정된 정보를 기반으로 객체를 생성하고 메서드를 호출합니다.

classfullpath=com.example.Dog

method=greet

이러한 요구 사례는 프레임워크를 학습할 때 매우 흔하며, 외부 파일 설정을 통해 소스 코드 수정 없이 프로그램을 제어하는 것입니다.

기존 기술로 이를 구현할 수 있을까요? 직접 코드를 작성해 보겠습니다.

먼저 설정 파일을 생성하고 위 내용을 작성한 후, 다음과 같은 클래스를 생성합니다:

public class Dog {

    private String name = "멍멍이";

    public void greet(){
        System.out.println("안녕하세요, " + name);
    }
}

전통적인 방법으로 객체를 먼저 생성한 후 메서드를 호출할 수 있습니다. 다음과 같이 작성할 수 있습니다:

Dog dog = new Dog();
dog.greet();

전통적인 방법을 통해 greet() 메서드를 호출할 수는 있지만, 이는 우리의 요구 사례와 다릅니다. 여기서는 설정 파일 config.properties에 지정된 정보를 통해 작업해야 합니다. 이 지점에서 학생 중 일부는 IO 스트림을 사용하여 설정 파일의 정보를 읽을 수 있다고 말할 수 있습니다. 좋습니다. 코드로 작성해 보겠습니다.

Properties를 사용하여 설정 파일을 읽고 쓰는 방법입니다. 예제 코드는 다음과 같습니다:

        Properties properties = new Properties();
        properties.load(new FileInputStream("src//config.properties"));
        String classfullpath = properties.get("classfullpath").toString();
        String methodName = properties.get("method").toString();
        System.out.println("클래스 경로: " + classfullpath);
        System.out.println("메서드 이름: " + methodName);

실행하면 내용이 성공적으로 읽히는 것을 확인할 수 있습니다.

그 다음 객체를 생성해야 합니다. 어떻게 객체를 생성할까요? 학생 중 일부는 new classfullpath를 직접 사용하면 되지 않을까요? 아이디어는 좋지만, 다음에는 그 생각을 하지 마세요. 현재 classfullpath는 문자열 타입이므로 new를 사용할 수 없습니다. 따라서 기존 기술로는 이 작업을 수행할 수 없습니다. 여기서 우리가 소개할 핵심 개념인 리플렉션 메커니즘을 도입해야 합니다.

리플렉션을 더 잘 이해하기 위해 먼저 작은 예제를 작성한 후 설명하겠습니다.

첫 번째 단계: 클래스를 로드하고 Class 타입의 객체 cls를 반환합니다

Class cls = Class.forName(classfullpath);

두 번째 단계: cls를 통해 로드된 클래스 com.example.Dog의 객체 인스턴스를 얻습니다

Object o = cls.getDeclaredConstructor().newInstance();

학생 중 일부는 어떻게 이것이 com.example.Dog인지 알 수 있는지 물을 수 있습니다. 출력하여 확인해 보겠습니다. System.out.println(o.getClass())의 출력 결과는 다음과 같습니다:

세 번째 단계: cls를 통해 로드된 클래스 com.example.Dog의 methodName 메서드 객체를 얻습니다. 리플렉션에서 메서드는 객체로 간주할 수 있습니다.

Method method1 = cls.getMethod(methodName);

마지막으로: method1을 통해 메서드를 호출하며, 메서드 객체를 통해 메서드 호출을 구현합니다

method1.invoke(o);

여기서 리플렉션과 전통적인 방법의 차이점을 발견할 수 있습니다. 전통적인 방법은 객체.메서드()이고, 리플렉션에서는 메서드.invoke(객체)입니다. 이제 메서드 내용을 출력할 수 있는지 확인해 보겠습니다:

여기까지 오면 리플렉션의 개념이 머리에 떠오를 것입니다. 사실 리플렉션 메커니즘에는 외부 파일 설정을 통해 소스 코드 수정 없이 프로그램을 제어할 수 있다는 장점도 있습니다. 예를 들어, Dog 클래스 아래에 다음과 같은 메서드를 추가해 보겠습니다. bark() 메서드 코드는 다음과 같습니다:

public void bark(){
    System.out.println(name + ": 왕왕!");
}

전통적인 방법을 사용하여 이 메서드를 호출하려면 코드를 수정해야 합니다. 예를 들어 dog.bark();와 같이요. 하지만 리플렉션을 사용하면 설정 파일만 수정하면 됩니다. config.properties에서 method=greet를 method=bark로 변경하기만 하면 됩니다.

실행하면 메서드가 성공적으로 호출되고 내용이 출력되어 설정 파일을 변경하고 코드를 변경하지 않음으로써 결합을 해제했습니다.

리플렉션 메커니즘

위에서 작은 예제를 통해 리플렉션을 간단히 살펴보았습니다. 이제 체계적으로 설명해 보겠습니다. 리플렉션 메커니즘은 실행 시 Reflection API를 통해 모든 클래스의 내부 정보(예: 멤버 변수, 생성자, 멤버 메서드 등)를 얻고 객체의 속성 및 메서드를 조작할 수 있게 해줍니다. 클래스가 로드되면 힙에 Class 타입의 객체(하나의 클래스에는 하나의 Class 객체만 있음)가 생성되며, 이 객체에는 클래스의 전체 구조 정보가 포함됩니다. 이 객체를 통해 클래스 구조를 얻을 수 있습니다. 이해를 돕기 위해 Java 리플렉션 메커니즘 원리 다이어그램을 그려드리겠습니다. 다음과 같습니다:

이제 간단히 정리해 보겠습니다. Java 리플렉션 메커니즘은 다음을 수행할 수 있습니다:

  • 실행 시 임의의 객체가 속한 클래스 판별
  • 실행 시 임의의 클래스의 객체 생성
  • 실행 시 임의의 클래스가 가진 멤버 변수와 메서드 얻기
  • 실행 시 임의의 객체의 멤버 변수와 메서드 호출
  • 동적 프록시 생성

리플렉션 관련 주요 클래스는 다음과 같습니다:

1. java.lang.Class: 클래스를 나타내며, Class 객체는 클래스가 로드된 후 힙의 객체를 나타냅니다
2. java.lang.reflect.Method: 클래스의 메서드를 나타냅니다
3. java.lang.reflect.Field: 클래스의 멤버 변수를 나타냅니다
4. java.lang.reflect.Constructor: 클래스의 생성자를 나타냅니다

위의 예제 코드에서 Method와 Class 관련 메서드를 사용했습니다. 이제 Field를 통해 멤버 변수를 얻는 방법을 보여드리겠습니다. 코드는 다음과 같습니다:

Field nameField = cls.getDeclaredField("name");
nameField.setAccessible(true);
System.out.println(nameField.get(o));

멤버 변수 값이 성공적으로 얻어지는 것을 확인할 수 있습니다.

Class 클래스 분석

다음으로 Class 클래스의 특징을 정리해 보겠습니다. 먼저 Class 클래스 다이어그램을 살펴보겠습니다

부클래스가 여전히 Object임을 알 수 있습니다.

두 번째 포인트는 Class 클래스 객체는 new로 생성되지 않고 시스템이 생성합니다. 여기서 어떻게 이해해야 할까요? 위에서 그린 원리 다이어그램을 기억하시나요? Class 클래스는 loadClass() 메서드를 통해 클래스 로드가 완료되어 특정 클래스에 해당하는 Class 클래스 객체가 생성됩니다. 이제 예제 코드로 보여드리겠습니다. 다음과 같은 예제 코드를 작성합니다:

Class<?> aClass = Class.forName("com.example.Dog");

그리고 이 코드 앞에 중단점을 설정하고 디버깅합니다. 성공적으로 LoaderClass 클래스에 들어가 loadClass() 메서드에 도달합니다. 다음과 같습니다:

세 번째 포인트는 특정 클래스의 Class 클래스 객체는 메모리에 하나만 존재하며, 클래스는 한 번만 로드되기 때문입니다. 이것을 확인하기 위해 작은 예제를 작성해 보겠습니다. hashCode를 통해 판단하며, 다음과 같은 몇 줄의 코드를 작성합니다:

Class<?> cls1 = Class.forName("com.example.Dog");
Class<?> cls2 = Class.forName("com.example.Dog");
System.out.println(cls1.hashCode());
System.out.println(cls2.hashCode());

실행 결과는 다음과 같으며, 값이 동일합니다

마지막으로 Class 클래스 객체에 대해 두 가지 더 말씀드리겠습니다. 첫째, 각 클래스의 인스턴스는 자신이 어떤 Class 인스턴스로 생성되었는지 기억합니다. 둘째, Class 객체는 일련의 API를 통해 클래스의 전체 구조를 완전히 얻을 수 있습니다.

Class 클래스 일반 메서드

여기서 작은 예제를 통해 Class 클래스 일반 메서드를 설명해 드리겠습니다. 먼저 Car 클래스를 새로 생성합니다. 코드는 다음과 같습니다:

public class Car {
    public String brand;
    public int price;
    public String color;
}

그리고 Car 클래스에 해당하는 Class 객체를 얻고 싶습니다. 여기서 사용하는 것이 forName() 메서드입니다:

String classAllPath = "com.example.Car";
//Car 클래스에 해당하는 Class 객체를 얻습니다
Class cls = Class.forName(classAllPath);

출력해 볼 수 있습니다

System.out.println(cls);
System.out.println(cls.getClass());

첫 번째 출력은 cls 객체이며, 어떤 클래스의 Class 객체인지, 두 번째 출력은 cls 실행 타입입니다. 다음과 같습니다:

패키지 이름을 얻고 싶다면 getPackageName() 메서드를 사용할 수 있습니다. System.out.println(cls.getPackageName())을 통해 출력하면 com.example이 출력됩니다. 클래스 이름을 얻고 싶다면 getName() 메서드를 사용할 수 있습니다. 또 매우 중요한 메서드는 객체 인스턴스를 생성하는 것입니다: newInstance() 예제: Object o = cls.getDeclaredConstructor().newInstance(); 여기서 주의할 점은 JDK 1.9 이상에서는 더 이상 newInstance()를 사용하지 않는다는 것입니다. 또한 getField()를 통해 속성을 얻을 수도 있습니다. 다른 메서드들도 있지만 여기서는 일일이 예시를 들지 않겠습니다. 표로 정리했습니다. 다음과 같습니다:

앞서 말씀드린 내용이 많았는데, 어떤 타입에 Class 객체가 있을까요? 다음 목록과 같습니다:

  • 외부 클래스, 멤버 내부 클래스, 정적 내부 클래스, 지역 내부 클래스, 익명 내부 클래스
  • interface: 인터페이스
  • 배열
  • enum: 열거형
  • annotation: 애너테이션
  • 기본 데이터 타입
  • void

예제 코드는 다음과 같습니다:

Class<String> cls1 = String.class;  //외부 클래스
Class<Serializable> cls2 = Serializable.class;  //인터페이스
Class<Integer[]> cls3 = Integer[].class;   //배열
Class<Deprecated> cls4 = Deprecated.class;   //애너테이션
System.out.println(cls1);
System.out.println(cls2);
System.out.println(cls3);
System.out.println(cls4);

출력 결과는 다음과 같습니다:

동적 로딩

글의 시작에서 "리플렉션"을 통해 Java라는 정적 언어에 동적 특성을 부여할 수 있다고 말씀드렸습니다. 다시 말해, 리플렉션 메커니즘은 Java가 동적 언어를 구현하는 핵심이며, 리플렉션을 통해 클래스 동적 로딩을 구현합니다. 이해를 돕기 위해 실행 시 필요한 클래스를 로드하고, 실행 시 해당 클래스를 사용하지 않으면 오류가 발생하지 않으며 의존성이 감소하는 것입니다.

예를 들어보겠습니다

Java 파일을 새로 생성하여 DynamicLoader라 명명하고 다음 코드를 작성합니다:

Scanner scanner = new Scanner(System.in);
System.out.println("숫자를 입력하세요");
String key = scanner.next();
switch (key){
    case "1":
        System.out.println("1입니다");
    case "2":
        Class<?> cls = Class.forName("Person");
        Object o = cls.getDeclaredConstructor().newInstance();
        Method m = cls.getMethod("greet");
        m.invoke(o);
        System.out.println("완료!");
        break;

}

여기서 Person 클래스를 작성하지 않았지만, 프로그램을 컴파일할 때 오류가 발생하지 않습니다. 즉, 프로그램이 case "2"를 실행할 때만 오류가 발생합니다. 위에서 언급한 실행 시 필요한 클래스를 로드하고, 실행 시 해당 클래스를 사용하지 않으면 오류가 발생하지 않는 것이 바로 동적 로딩입니다. 이제 실행해 보겠습니다. 먼저 1을 입력하면 프로그램이 정상적으로 실행되고, 2를 입력하면 오류가 발생합니다.

이제 동적 로딩을 이해하셨을 것입니다.

클래스 로딩

아직도 어떤 학생들은 클래스 로딩 과정이 어떻게 진행되는지 알고 싶어할 수 있습니다. 사실 클래스 로딩은 크게 세 단계(로딩 단계(Loading), 링크 단계(검증, 준비, 해석), 초기화 단계(initalization))로 나뉩니다. 이해를 돕기 위해 다이어그램을 그려드리겠습니다.

이 내용은 추후에 다루기로 하고, 여기서는 깊이 다루지 않겠습니다.

참고

Java 보안 이야기 - 01. 리플렉션 편

태그: java 리플렉션 동적로딩 Class객체 JavaAPI

7월 14일 18:34에 게시됨