안동민 개발노트

본문 시작

리플렉션 메타데이터

리플렉션으로 클래스 구성 요소와 타입 정보를 조사하고 접근 제어·모듈 경계·호출 예외를 존중하며 허용 목록 안에서 동적 호출과 객체 생성을 수행합니다.

리플렉션은 실행 중인 프로그램이 Class, Method, Field, Constructor 메타데이터를 조사하고 제한적으로 사용할 수 있게 합니다.

컴파일 시점에 controller.search(request)로 고정된 호출과 달리 문자열 또는 애노테이션에서 선택한 메서드를 실행할 수 있어 프레임워크의 라우트 탐색, 직렬화, 의존성 주입에 쓰입니다.

그 대가로 컴파일러가 제공하던 이름·타입·접근 검사를 런타임 코드가 직접 책임져야 합니다.

동적이라는 이유만으로 모든 멤버를 열어서는 안 됩니다.

사용자 입력을 그대로 클래스 이름이나 메서드 이름으로 사용하면 의도하지 않은 기능을 호출할 수 있습니다.

탐색 범위, 허용 시그니처, 접근 수준, 예외 번역, 캐시 수명을 먼저 정하고 그 안에서 리플렉션을 사용합니다.

정적 호출로 충분한 업무 코드는 그대로 두는 것이 읽기 쉽고 안전합니다.


가장 작은 메타데이터 조회

모든 객체는 실행 중 자신의 클래스 정보를 나타내는 Class 객체와 연결됩니다.

클래스 이름을 확인하고 선언된 필드 목록을 읽는 것부터 시작하면 리플렉션이 “무엇이든 실행하는 마법”이 아니라 클래스 구조를 데이터처럼 관찰하는 API라는 점을 이해할 수 있습니다.

src/FirstReflection.java
import java.lang.reflect.Field;

public final class FirstReflection {
    static final class Member {
        private String name;
        private int age;
    }

    public static void main(String[] args) {
        Class<Member> type = Member.class;
        System.out.println("class=" + type.getSimpleName());

        for (Field field : type.getDeclaredFields()) {
            System.out.println(field.getName() + ": "
                    + field.getType().getSimpleName());
        }
    }
}
class=Member
name: String
age: int

이 출력 블록은 가능한 표시 예입니다. getDeclaredFields()는 필드 순서를 보장하지 않으므로 age가 name보다 먼저 나올 수도 있습니다.

Member.class는 객체를 만들지 않고 클래스 정보를 얻습니다.

이미 객체가 있다면 object.getClass()로 같은 정보를 얻을 수 있습니다.

getDeclaredFields()는 현재 클래스에 선언된 필드 설명을 반환하지만 필드 값을 자동으로 읽지는 않습니다.

이 정상 조회를 기준으로 메서드, 생성자, 제네릭 타입 정보를 차례로 확장합니다.


무분별한 접근 해제

다음 inspectAndCall은 발견한 모든 필드와 메서드에 setAccessible(true)를 적용합니다. main은 이 메서드를 호출하지 않고 안내 문자열만 출력합니다.

클래스가 세운 불변식과 모듈 캡슐화를 무시하고, JDK 버전이나 실행 옵션에 따라 InaccessibleObjectException이 발생할 수 있습니다.

비공개 메서드 실행 실패도 정상 업무 예외와 구분되지 않습니다.

bad/OpenEveryReflectiveMember.java
import java.lang.reflect.Field;
import java.lang.reflect.Method;

public final class OpenEveryReflectiveMember {
    static void inspectAndCall(Object target) throws Exception {
        for (Field field : target.getClass().getDeclaredFields()) {
            field.setAccessible(true);
            System.out.println(field.get(target));
        }
        for (Method method : target.getClass().getDeclaredMethods()) {
            method.setAccessible(true);
            if (method.getParameterCount() == 0) {
                method.invoke(target);
            }
        }
    }

    public static void main(String[] args) {
        System.out.println("compile-only access bypass counterexample");
    }
}
멤버 종류별 public 조회와 선언된 조회

멤버 종류별 public 조회와 선언된 조회

멤버 종류별 public 조회와 선언된 조회
조회 대상public 조회선언된 조회
메서드getMethods(): 상속된 public 포함getDeclaredMethods(): 현재 타입의 모든 접근 수준
필드getFields(): 상속된 public 포함getDeclaredFields(): 현재 타입의 모든 접근 수준
생성자getConstructors(): 현재 클래스의 public만getDeclaredConstructors(): 현재 클래스의 모든 접근 수준
메서드
public 조회: getMethods(): 상속된 public 포함
선언된 조회: getDeclaredMethods(): 현재 타입의 모든 접근 수준
필드
public 조회: getFields(): 상속된 public 포함
선언된 조회: getDeclaredFields(): 현재 타입의 모든 접근 수준
생성자
public 조회: getConstructors(): 현재 클래스의 public만
선언된 조회: getDeclaredConstructors(): 현재 클래스의 모든 접근 수준

생성자는 상속되지 않습니다. 메타데이터를 찾는 것과 실제 값을 읽거나 호출할 수 있는지는 별도 검사입니다.

프레임워크가 어떤 의미를 원하는지 모른 채 조회 방식을 바꾸면 상속 라우트를 놓치거나 비공개 도우미까지 후보에 포함할 수 있습니다.

Java 9 이후 모듈 시스템은 패키지를 외부 리플렉션에 열지 않을 수 있습니다.

setAccessible(true)는 언어 접근 검사 우회를 요청하지만 닫힌 모듈을 자동으로 열어 주지 않습니다.

trySetAccessible() 결과를 확인하고 필요한 패키지만 모듈 선언의 opens로 명시하는 편이 낫습니다.

광범위한 --add-opens는 임시 호환 수단이지 라이브러리의 기본 설계가 되어서는 안 됩니다.


Class 메타데이터

Class<?>는 어떤 클래스·인터페이스·배열·기본형인지를 나타내는 런타임 타입 정보입니다.

int.class는 기본형 Class이고 String[].class는 배열 Class이며 getComponentType()으로 요소 타입을 얻습니다.

제네릭 타입 인수는 타입 소거 때문에 일반 객체의 getClass()에서 복원되지 않습니다.

다만 선언부 시그니처에 기록된 Field.getGenericType(), Method.getGenericReturnType()은 ParameterizedType 형태로 읽을 수 있습니다.

src/ReflectionTypeExplorer.java
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.Arrays;
import java.util.List;

public final class ReflectionTypeExplorer {
    static final class Catalog<T extends Number> {
        public List<String> names;
        private T value;
        int[] scores;

        public Catalog() {
        }

        private Catalog(T value) {
            this.value = value;
        }

        public List<T> values(T fallback) {
            return List.of(value == null ? fallback : value);
        }
    }

    static void printType(String label, Type type) {
        System.out.println(label + " type=" + type.getTypeName());
        if (type instanceof ParameterizedType parameterized) {
            System.out.println(label + " raw=" + parameterized.getRawType());
            System.out.println(label + " args="
                    + Arrays.toString(parameterized.getActualTypeArguments()));
        }
    }

    public static void main(String[] args) throws Exception {
        Class<Catalog> type = Catalog.class;
        System.out.println("name=" + type.getName());
        System.out.println("simple=" + type.getSimpleName());
        System.out.println("modifiers="
                + Modifier.toString(type.getModifiers()));
        System.out.println("typeParameters="
                + Arrays.toString(type.getTypeParameters()));

        Field names = type.getDeclaredField("names");
        Field scores = type.getDeclaredField("scores");
        printType("names", names.getGenericType());
        System.out.println("scores array=" + scores.getType().isArray());
        System.out.println("scores component="
                + scores.getType().getComponentType());
        System.out.println("int primitive=" + int.class.isPrimitive());

        Method values = type.getDeclaredMethod("values", Number.class);
        printType("return", values.getGenericReturnType());
        printType("parameter", values.getGenericParameterTypes()[0]);

        Constructor<?>[] constructors = type.getDeclaredConstructors();
        for (Constructor<?> constructor : constructors) {
            System.out.println("constructor=" + constructor);
        }

        Object list = List.of("a", "b");
        System.out.println("runtime list class=" + list.getClass());
    }
}

Catalog<T>의 비공개 생성자는 소거 뒤 매개변수 타입이 Number로 보이므로 getDeclaredConstructor(Number.class)로 찾아야 합니다.

T.class라는 표현은 존재하지 않습니다.

Field 선언의 List<String>은 제네릭 시그니처에서 String을 읽을 수 있지만 List.of("a") 인스턴스의 Class는 원소 타입을 제공하지 않습니다.

와일드카드, 제네릭 배열, 타입 변수가 나타나면 Type의 구현이 WildcardType, GenericArrayType, TypeVariable일 수 있습니다.

모든 제네릭 타입을 Class로 강제 형변환하지 않습니다.

JSON 바인딩 같은 라이브러리가 TypeToken 패턴을 사용하는 이유는 익명 하위 클래스의 제네릭 상위 클래스 시그니처에 구체 인수를 남기기 위해서입니다.


메서드 호출과 예외 해제

Method.invoke는 대상 메서드가 던진 예외를 InvocationTargetException으로 감쌉니다.

리플렉션 접근 오류와 업무 메서드 실패를 같은 catch에서 처리하면 원인 타입을 잃습니다.

선택 단계는 허용된 이름과 정확 시그니처를 검사하고, 실행 단계는 래퍼의 원인을 꺼내 안정적인 결과로 번역합니다.

src/SafeReflectiveInvoker.java
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.Set;

public final class SafeReflectiveInvoker {
    static final class Calculator {
        public int add(int left, int right) {
            return Math.addExact(left, right);
        }

        public int divide(int left, int right) {
            if (right == 0) {
                throw new IllegalArgumentException("zero divisor");
            }
            return left / right;
        }

        private int secret() {
            return 42;
        }
    }

    sealed interface CallResult permits Success, TargetFailure, Rejected {
    }

    record Success(Object value) implements CallResult {
    }

    record TargetFailure(String type, String message) implements CallResult {
    }

    record Rejected(String reason) implements CallResult {
    }

    private static final Set<String> ALLOWED = Set.of("add", "divide");
    private static final Map<String, Method> METHODS = discover();

    static CallResult invoke(Calculator target, String name,
                             int left, int right) {
        Method method = METHODS.get(name);
        if (method == null) {
            return new Rejected("operation not allowed");
        }
        try {
            return new Success(method.invoke(target, left, right));
        } catch (InvocationTargetException wrapper) {
            Throwable cause = wrapper.getCause();
            return new TargetFailure(
                    cause.getClass().getSimpleName(),
                    cause.getMessage());
        } catch (ReflectiveOperationException error) {
            throw new IllegalStateException("validated method became unusable", error);
        }
    }

    private static Map<String, Method> discover() {
        var methods = new java.util.LinkedHashMap<String, Method>();
        for (String name : ALLOWED) {
            try {
                Method method = Calculator.class.getMethod(
                        name,
                        int.class,
                        int.class);
                methods.put(name, method);
            } catch (NoSuchMethodException error) {
                throw new ExceptionInInitializerError(error);
            }
        }
        return Map.copyOf(methods);
    }

    public static void main(String[] args) {
        var calculator = new Calculator();
        System.out.println(invoke(calculator, "add", 7, 5));
        System.out.println(invoke(calculator, "divide", 8, 0));
        System.out.println(invoke(calculator, "secret", 0, 0));
    }
}

메서드 이름만으로 찾지 않고 매개변수 타입까지 지정해야 오버로드 중 원하는 시그니처가 정해집니다.

기본형 int.class와 래퍼 Integer.class는 다른 시그니처입니다.

invoke는 박싱과 일부 확대 변환을 처리할 수 있지만 프레임워크가 임의 변환에 기대지 않도록 사전 바인딩 단계에서 인수 타입을 명시적으로 맞춥니다.

메타데이터 탐색은 요청마다 반복하지 않고 애플리케이션 시작 시 검증해 불변 Map으로 캐시할 수 있습니다.

Class 로더를 동적으로 폐기하는 환경에서는 캐시 키가 Class를 강하게 붙잡아 언로드를 막지 않는지 고려합니다.

일반 단일 애플리케이션에서는 시작 시점 탐색과 요청 경로 호출을 분리하는 것만으로 충분합니다.

허용된 메서드 호출의 세 결과

허용된 메서드 호출의 세 결과

허용된 메서드 호출의 세 결과
원문의 호출메서드 본문 실행반환 결과
덧셈: add(7, 5)실행, 12 반환Success(value=12)
0으로 나눔실행, IllegalArgumentException 발생TargetFailure(type=IllegalArgumentException, message=zero divisor)
미등록 이름: secret호출 전에 거절Rejected(reason=operation not allowed)
덧셈: add(7, 5)
메서드 본문 실행: 실행, 12 반환
반환 결과: Success(value=12)
0으로 나눔
메서드 본문 실행: 실행, IllegalArgumentException 발생
반환 결과: TargetFailure(type=IllegalArgumentException, message=zero divisor)
미등록 이름: secret
메서드 본문 실행: 호출 전에 거절
반환 결과: Rejected(reason=operation not allowed)

SafeReflectiveInvoker의 main을 코드와 API 계약으로 해석한 값이며 새 실행 관측은 아닙니다. 대상 메서드 예외의 원인을 꺼내는 경로와 호출 전 거절을 구분합니다.


필드 변경의 위험

Field.get()과 Field.set()은 공통 null 보정이나 직렬화에 편리하지만 세터, 생성자 검증, 캡슐화를 건너뜁니다.

예를 들어 age 세터가 음수를 막아도 리플렉션으로 비공개 필드에 직접 음수를 넣을 수 있습니다.

final 필드 변경은 JVM 최적화와 Java 메모리 모델까지 얽혀 예측 가능한 일반 API로 취급할 수 없습니다.

공통 필드 처리가 필요하면 public 접근자, record 구성 요소, 명시적인 스키마 어댑터를 우선 검토합니다.

리플렉션이 필요한 프레임워크 내부에서는 애노테이션으로 명시적 사용한 필드만 다루고, static·final·합성 필드를 제외하며, 접근 실패를 조용히 무시하지 않습니다.

객체가 속한 모듈이 패키지를 열지 않았다면 사용자에게 필요한 구성과 대상 필드를 설명합니다.

Constructor도 같은 원칙을 가집니다.

Class.newInstance()는 사용 중단이며 예외 의미가 불명확하므로 getDeclaredConstructor(...).newInstance(...)를 씁니다.

생성자 본문 예외도 InvocationTargetException에 감싸집니다.

생성자를 열기 전에 public 생성자나 팩터리 메서드를 사용할 수 있는지 확인합니다.

리플렉션 대상안전한 기본 탐색우회가 필요할 때거절 조건
Class클래스 리터럴·허용 이름Class.forName 제한외부 임의 클래스
Methodpublic 정확 시그니처명시적 사용 선언된 메서드허용 목록 밖 이름
Fieldpublic 접근자 우선애노테이션 필드final·모듈 닫힘
Constructorpublic 생성자·팩터리trySetAccessible 확인불명확한 오버로드

연습 문제

문자열 클래스 이름 대신 고정된 public·private 키로 승인된 생성기를 선택합니다.

public 키에는 무인수 생성기를, private 키에는 trySetAccessible()로 접근을 확인한 String 생성기를 미리 등록합니다. 무인수 생성자가 없을 때 자동으로 다른 생성자를 찾는 코드는 아닙니다.

생성자 본문 실패는 ConstructionFailure로 원인을 해제해 반환합니다.

생성자 선택과 예외 분류를 포함한 풀이

팩터리가 지원하는 타입을 Map에 직접 등록하면 사용자 입력이 임의 Class로 이어지지 않습니다.

각 생성기는 시작 시점에 만들어 두고 요청 시 리플렉션 탐색을 반복하지 않습니다.

solution/AllowedConstructorFactorySolution.java
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.util.Map;
import java.util.function.Function;

public final class AllowedConstructorFactorySolution {
    interface Component {
        String label();
    }

    public static final class PublicComponent implements Component {
        public PublicComponent() {
        }

        @Override
        public String label() {
            return "public";
        }
    }

    public static final class PrivateComponent implements Component {
        private final String label;

        private PrivateComponent(String label) {
            if (label.isBlank()) {
                throw new IllegalArgumentException("blank label");
            }
            this.label = label;
        }

        @Override
        public String label() {
            return label;
        }
    }

    record ConstructionFailure(String type, String message) {
    }

    sealed interface Result permits Created, Failed {
    }

    record Created(Component value) implements Result {
    }

    record Failed(ConstructionFailure failure) implements Result {
    }

    private final Map<String, Function<String, Result>> creators = Map.of(
            "public", publicCreator(PublicComponent.class),
            "private", stringCreator(PrivateComponent.class));

    Result create(String key, String label) {
        Function<String, Result> creator = creators.get(key);
        if (creator == null) {
            return new Failed(new ConstructionFailure(
                    "Rejected",
                    "component not allowed"));
        }
        return creator.apply(label);
    }

    private static Function<String, Result> publicCreator(
            Class<? extends Component> type) {
        try {
            Constructor<? extends Component> constructor =
                    type.getConstructor();
            return ignored -> instantiate(constructor);
        } catch (NoSuchMethodException error) {
            throw new ExceptionInInitializerError(error);
        }
    }

    private static Function<String, Result> stringCreator(
            Class<? extends Component> type) {
        try {
            Constructor<? extends Component> constructor =
                    type.getDeclaredConstructor(String.class);
            if (!constructor.trySetAccessible()) {
                throw new IllegalStateException("constructor is not open");
            }
            return label -> instantiate(constructor, label);
        } catch (NoSuchMethodException error) {
            throw new ExceptionInInitializerError(error);
        }
    }

    private static Result instantiate(
            Constructor<? extends Component> constructor,
            Object... arguments) {
        try {
            return new Created(constructor.newInstance(arguments));
        } catch (InvocationTargetException wrapper) {
            Throwable cause = wrapper.getCause();
            return new Failed(new ConstructionFailure(
                    cause.getClass().getSimpleName(),
                    cause.getMessage()));
        } catch (ReflectiveOperationException error) {
            return new Failed(new ConstructionFailure(
                    error.getClass().getSimpleName(),
                    error.getMessage()));
        }
    }

    public static void main(String[] args) {
        var factory = new AllowedConstructorFactorySolution();
        System.out.println(factory.create("public", "ignored"));
        System.out.println(factory.create("private", "worker"));
        System.out.println(factory.create("private", ""));
        System.out.println(factory.create("other", "x"));
    }
}

이 예제의 PrivateComponent 클래스는 public이고 생성자가 private입니다. 같은 최상위 클래스의 중첩 타입이라 같은 모듈에 속하며 생성자 접근을 허용할 수 있습니다.

다른 이름 있는 모듈의 닫힌 패키지라면 실패할 수 있고 팩터리 시작 시점이 원인을 드러내야 합니다.

Object... 호출은 리플렉션 API의 가변 인수이며 실제 생성자 시그니처 검증은 생성기 생성 시 끝납니다.


리플렉션 승인 기준

탐색 결과에는 public과 선언된 차이, 기본형과 래퍼, 배열 구성 요소, 제네릭 Type 구현을 포함합니다.

동적 메서드 테스트는 정상 반환, 대상 업무 예외, 허용되지 않은 이름을 구분해야 합니다.

비공개 접근은 trySetAccessible 결과를 확인하고 모듈 구성 없이 열리지 않는 대상을 조용히 건너뛰지 않습니다.

리플렉션 코드가 요청마다 전체 멤버를 스캔하지 않는지, 캐시가 Class 로더 수명과 맞는지, 예외 래퍼의 원인을 보존하는지도 확인합니다.

다음 문서에서는 이름 규칙 대신 선언부에 구조화된 의미를 붙이는 애노테이션이 왜 필요한지 살펴보고, 실행 시점 리플렉션이 읽을 수 있는 메타데이터 계약으로 발전시킵니다.