자바 리플렉션 소개

자바 리플렉션 소개

자바 리플렉션(Reflection)은 객체가 거울을 들여다보듯 자신이 가진 필드, 메서드, 생성자를 알아내게 해 주는 기능이에요. 필드를 읽고 쓰고, 메서드를 호출하고, 생성자를 호출해 새 객체까지 만들 수 있죠. 이 튜토리얼은 리플렉션을 처음 접하는 분들은 물론, 이미 알고 있는 분들도 재미있게 볼 수 있게 구성했어요. 코드 조각을 직접 실행해 보면서 따라와 주세요.

출처: dev.java

본문

오늘 아침 거울에 비친 얼굴은 이야기를 들려줬어요. 저는 급하게 면도가 필요했고(지금도 그렇고요), 어제 정원에 앉아 있을 때는 자외선 차단제를 바르거나 최소한 모자를 썼어야 했어요. 크레타에서는 그런 별미를 찾을 수 없어 직접 베이컨을 만들어 보려다가, 웨버(WEBER)에서 5시간 동안 훈연하며 병렬 스트림과 가상 스레드에 관한 Java Specialists 뉴스레터(Issue 311)를 쓰는 데 그 시간을 잘 활용했지요. 거울 속의 반영이 눈을 깜빡였어요. 오래 바라볼 시간은 없었어요. 이제 막 목격한 것, 바로 리플렉션(reflection)에 대해 쓰기 시작할 때였죠.

자바 리플렉션은 객체가 거울을 들여다보고 자신이 어떤 필드, 메서드, 생성자를 가졌는지 발견하게 해 줘요. 우리는 필드를 읽고 쓰고, 메서드를 호출하고, 심지어 생성자를 호출해 새 객체까지 만들 수 있어요. 얼굴의 수염 자국과 살짝 탄 피부처럼, 우리는 다른 사람의 눈을 통해서도 우리 자신을 볼 수 있어요.

왜 이 튜토리얼을 읽어야 할까요? 리플렉션을 이미 안다면 재미로 한 번 훑어봐도 좋아요. 하지만 리플렉션을 한 번도 들어본 적 없다면, 이제 거울을 제대로 들여다볼 시간이에요. 몇 개만 위치를 잘 잡은 리플렉션 구절로 때로는 수천 줄의 코드를 아낄 수 있는 새로운 마법을 발견하게 될 거예요. 그리고 일부 고용주가 리플렉션으로 쉽게 풀리는 까다로운 면접 문제를 내기도 한다는 얘기를 들었나요? 튜토리얼의 코드 조각을 직접 시험해 보시길 바라요. 이 여정에 함께해 주셔서 감사해요.

Class라는 클래스

아니요, 오타가 아니에요. Class라는 이름의 클래스가 있어요. 그리고 그것은 Object의 하위 클래스예요. 그런데 ObjectClass를 가지고 있죠. 멋진 순환 의존성(circular dependency)이에요.

우리 객체의 Class를 어떻게 얻을 수 있을까요? 모든 객체에는 java.lang.Object로부터 상속받은 getClass() 메서드가 있어요. 이걸 호출하면 실제 구현 클래스의 Class를 돌려받아요.

예를 들어 다음 코드를 볼게요. 코드 조각에서는 암시적 선언 클래스(implicitly declared classes)를 사용하고 있는데, 이건 Java 25의 정식 기능이에요. JEP 512: Compact Source Files and Instance Main Methods를 참고하세요. java GetClassDemo.java로 바로 실행할 수 있어요.


// GetClassDemo.java
import java.util.List;
import java.util.ArrayList;

// Using Implicitly Declared Classes which is a preview feature of Java 22.
void main() {
    List<String> list1 = new ArrayList<>();
    IO.println(list1.getClass());
    var list2 = new ArrayList<String>();
    IO.println(list2.getClass());
}

즉 변수를 어떻게 선언했는지는 중요하지 않아요. 우리는 항상 실제 구현 객체의 클래스를 얻어요. 그럼 List 클래스는 어떻게 얻을까요? 그것은 꽤 쉬워요. **클래스 리터럴(class literal)**을 쓰면 되죠. 클래스 이름 뒤에 .class를 붙이면 돼요.


// ClassLiteral.java
void main() {
    IO.println(Number.class); // class java.lang.Number
    IO.println(java.util.List.class); // interface java.util.List
}

런타임에 클래스가 사용 가능할지조차 모르는 채로, 클래스를 String 이름으로 로드할 수도 있어요. 예를 들어 콘솔에 입력하는 어떤 클래스든 로드할 수 있어요.


// ClassForName.java
void main() throws ClassNotFoundException {
    var console = System.console();
    String className = console.readLine("Enter class name: ");
    IO.println(Class.forName(className));
}

예를 들면:


heinz$ java --enable-preview --source 21 ClassForName.java 
Note: ClassForName.java uses preview features of Java SE 21.
Note: Recompile with -Xlint:preview for details.
java.util.Iterator
interface java.util.Iterator

각 클래스는 ClassLoader(클래스 로더)에 로드돼요. JDK 클래스는 모두 부트스트랩 클래스 로더(bootstrap class loader)에 있고, 우리 클래스는 시스템 클래스 로더(system class loader, 애플리케이션 클래스 로더라고도 함)에 있어요. 여기서 클래스 로더를 볼 수 있어요.


// ClassLoaderDemo.java
void main() {
    IO.println(String.class.getClassLoader());
    IO.println(this.getClass().getClassLoader());
}

흥미로운 점은 이 코드를 어떻게 호출하느냐에 따라 다른 결과가 나온다는 거예요. 예를 들어 java ClassLoaderDemo.java로 호출하면 클래스 로더 타입은 MemoryClassLoader인 반면, 먼저 컴파일한 뒤 java ClassLoaderDemo로 호출하면 AppClassLoader예요. JDK 클래스의 클래스 로더는 null로 돌아와요.


heinz$ java --enable-preview --source 21 ClassLoaderDemo.java 
null
com.sun.tools.javac.launcher.Main$MemoryClassLoader@6483f5ae

그리고


heinz$ javac --enable-preview --source 21 ClassLoaderDemo.java
heinz$ java --enable-preview ClassLoaderDemo
null
jdk.internal.loader.ClassLoaders$AppClassLoader@3d71d552

클래스 로더의 목적은 보안을 위해 클래스를 분할하는 거예요. JDK 안의 클래스는 우리 클래스를 전혀 볼 수 없고, 마찬가지로 AppClassLoader 안의 클래스는 MemoryClassLoader 안의 클래스와 아무 관계가 없어요. 이로 인해 우리가 클래스를 컴파일한 뒤 단일 파일 명령 java SomeClass.java로도 실행할 때 몇 가지 놀라움이 생길 수 있어요.

얕은 리플렉티브 접근

클래스를 얻고 나면 그 클래스에 대한 많은 정보를 알아낼 수 있어요. 슈퍼클래스가 누구인지, 어떤 public 멤버를 가졌는지, 어떤 인터페이스를 구현했는지 등이요. sealed 타입이라면 하위 타입(subtype)까지 찾을 수 있어요.

java.util.Iterator에 정의된 메서드를 찾아보도록 할게요.


// MethodsOnIterator.java
import java.util.Iterator;
import java.util.stream.Stream;

void main() {
    Stream.of(Iterator.class.getMethods())
            .forEach(System.out::println);
}

네 가지 메서드가 보이는데, 그중 둘은 디폴트 인터페이스 메서드예요.


heinz$ java --enable-preview --source 21 MethodsOnIterator.java
public default void java.util.Iterator.remove()
public default void java.util.Iterator.forEachRemaining(java.util.function.Consumer)
public abstract boolean java.util.Iterator.hasNext()
public abstract java.lang.Object java.util.Iterator.next()

java.util.Iterator 타입의 객체를 만들면 이런 메서드들을 호출할 수도 있어요. 다음 예시에서는 forEachRemaining이라는, Consumer를 파라미터로 받는 메서드를 찾아봐요. 그런 다음 List.of()에서 Iterator를 만들어 리플렉션으로 forEachRemaining 메서드를 호출해요. 여기서는 몇 가지 문제가 발생할 수 있는데, 가장 대표적인 것은 메서드가 존재하지 않는 경우(NoSuchMethodException)와 메서드를 호출할 권한이 없는 경우(IllegalAccessException)예요. Java 7부터는 리플렉션에서 발생할 수 있는 모든 문제를 아우르는 포괄적인 예외가 있는데, 바로 ReflectiveOperationException이에요.


// MethodsOnIteratorCalling.java
import java.util.List;
import java.util.Iterator;
import java.util.function.Consumer;

void main() throws ReflectiveOperationException {
    var iterator = List.of("Foo", "Dev", "Java").iterator();
    var forEachRemaining = Iterator.class.getMethod(
            "forEachRemaining", Consumer.class);
    var println = System.out::println;
    forEachRemaining.invoke(iterator, println);
}

다음 예시는 훨씬 더 흥미로워요. List 하나를 가져와서 Collections 클래스를 훑어보며, 우리가 넘겨줄 메서드를 줄 수 있는 메서드가 있는지 찾아볼 거예요. 메서드를 호출하고 리스트에 무슨 일이 생기는지 봐요. 메서드들이 Collections에서 static으로 선언되어 있으므로, invoke()의 첫 번째 파라미터는 null이 돼요. 스트림을 쓸 수도 있지만 checked 예외와는 "잘 어울리지" 않아서, 평범한 옛날 for-in 루프를 써야 해요.


// CollectionsListMethods.java
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;

void main() throws ReflectiveOperationException {
    var pi = "3141592653589793".chars()
            .map(i -> i - '0')
            .boxed()
            .collect(Collectors.toList());
    IO.println(pi);
    for (var method : Collections.class.getMethods()) {
        if (method.getReturnType() == void.class
                && method.getParameterCount() == 1
                && method.getParameterTypes()[0] == List.class) {
            IO.println("Calling " + method.getName() + "()");
            method.invoke(null, pi);
            IO.println(pi);
        }
    }
}

이건 잘 동작하고, 요구 사항에 맞는 메서드 세 개(sort(), shuffle(), reverse())를 찾아요. 이 메서드들의 순서는 보장되지 않아요. 예를 들어 OpenJDK 21의 Collections.java 파일에서는 sort(), reverse(), shuffle() 순서로 되어 있는데, 코드를 실행해 보면 이렇게 나와요.


heinz$ java --enable-preview --source 21 CollectionsListMethods.java 
[3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8, 9, 7, 9, 3]
Calling reverse()
[3, 9, 7, 9, 8, 5, 3, 5, 6, 2, 9, 5, 1, 4, 1, 3]
Calling sort()
[1, 1, 3, 3, 4, 5, 5, 6, 7, 8, 9, 9, 9, 9, 9, 9]
Calling shuffle()
[9, 3, 1, 5, 3, 8, 9, 9, 7, 8, 1, 4, 1, 3, 5, 6]

깊은 리플렉티브 접근

지금까지 우리는 특히 위험한 일은 하지 않았어요. 발견하고 호출한 메서드들은 모두 public이었죠. 약간 위험했던 유일한 부분은, 이런 메서드들이 존재하고 접근 가능하다는 컴파일러 검사가 없었다는 점이에요. 하지만 더 깊이 거울을 들여다볼 수도 있어요.

예를 들어 Person 클래스를 생각해 볼게요.


public class Person {
    private final String name;
    private final int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String toString() {
        return "Person(" + name + "," + age + ")";
    }
}

이제 두 개의 별도 클래스를 다루고 있으므로 컴파일해야 해요. 데모에는 이전처럼 무명 클래스(unnamed class)를 쓸 수 있지만, 둘 다 컴파일하는 게 좋아요. 단일 파일 호출을 쓰면 실수하기 쉬운데, 그 경우 Person과 데모가 서로 다른 클래스 로더에 존재하게 되거든요. 이는 이해하기 어려운 런타임 오류를 일으킬 수 있어요. 저도 한때 이 정확한 오류를 쫓느라 하루를 보낸 적이 있어요. 저처럼 되지 마세요.

FountainOfYouth.java 파일은 이렇게 생겼어요.


// FountainOfYouth.java
import java.lang.reflect.*;

void main() throws ReflectiveOperationException {
    var person = new Person("Heinz Kabutz", 51);
    IO.println(person);
    var ageField = Person.class.getDeclaredField("age");
    ageField.setAccessible(true); // deep reflection engaged!
    var age = ageField.getInt(person);
    age *= .9;
    ageField.setInt(person, age);
    IO.println(person);
}

먼저 FountainOfYouth 클래스를 컴파일해요. 그러면 Person.java도 함께(전이적으로) 컴파일돼요. 그리고 실행하면, 보세요. 제 나이에서 10%를 깎았어요.


heinz$ javac --enable-preview --source 21 FountainOfYouth.java 
heinz$ java --enable-preview FountainOfYouth
Heinz Kabutz (51)
Heinz Kabutz (45)

age 필드가 private final인데도 우리는 그것을 바꿀 수 있었어요. 만약 Person을 record로 바꾸면, 더 이상 깊은 리플렉션으로 프로퍼티를 바꿀 수 없게 돼요.

자바 모듈 시스템

깊은 리플렉션은 클래스가 있는 모듈이 우리에게 열려(open) 있을 때만 동작해요. 이상적으로는 모듈 작성자에게 우리 모듈을 위해 패키지를 열어 달라고 요청해야 해요. 하지만 그들은 아마 거절할 거고, 그럴 만한 이유가 있어요. 패키지를 열어 주면 가장 내밀한 구현 세부 사항에 대한 무제한 접근을 허용하는 셈이니까요. 가까운 미래든 먼 미래든, 필드 이름이나 타입을 바꾸고 싶다면 어떻게 될까요? 깊은 리플렉션 코드는 아마 동작을 멈출 거고, 그들은 영원히 다른 모듈을 고쳐야 할지도 몰라요.

--add-opens라는 명령줄 파라미터로 다른 모듈의 패키지를 깊은 리플렉션을 위해 열 수 있지만, 그것은 절대 최후의 수단으로만 써야 해요. 그렇게 바람직하지 않아서, 저는 여기서 혐오스러움을 담아 언급만 하고, 사용법에 대한 자세한 내용은 보여 주지 않을게요.

결론

이 튜토리얼에서 리플렉션이 어떻게 동작하는지에 대한 통찰을 조금 얻었길 바라요. 탐구할 다른 주제는 많이 있어요. 배열, 동적 프록시, 제네릭, sealed 클래스 등이요. record의 프로퍼티를 어떻게 읽는지, 파라미터 이름을 어떻게 보존하는지 같은 것들도 있죠. 하지만 이미 충분히 길어졌으니, 이 정도면 올바른 방향으로 시작하기에 충분하길 바라요.

자바 프로그래밍 언어에 관한 더 깊은 탐구가 필요하다면, 자바에 더 능숙해지고 싶은 모든 사람을 위한 뉴스레터인 The Java Specialists' Newsletter를 구독하는 것을 꼭 고려해 보세요.

더 알아보기 (Learn more)

리플렉션으로 클래스의 멤버를 나열하고 속성을 검사하는 방법, 메서드 호출과 접근 검사가 어떻게 이뤄지는지는 Reflection API 공식 문서에서 더 상세히 확인할 수 있어요. 리플렉션을 직접 실행해 보면서 각 코드 조각을 실험해 보는 것이 가장 빠른 학습 방법이에요.