리플렉션 메타데이터
리플렉션으로 클래스 구성 요소와 타입 정보를 조사하고 접근 제어·모듈 경계·호출 예외를 존중하며 허용 목록 안에서 동적 호출과 객체 생성을 수행합니다.
리플렉션은 실행 중인 프로그램이 Class, Method, Field, Constructor 메타데이터를 조사하고 제한적으로 사용할 수 있게 합니다.
컴파일 시점에 controller.search(request)로 고정된 호출과 달리 문자열 또는 애노테이션에서 선택한 메서드를 실행할 수 있어 프레임워크의 라우트 탐색, 직렬화, 의존성 주입에 쓰입니다.
그 대가로 컴파일러가 제공하던 이름·타입·접근 검사를 런타임 코드가 직접 책임져야 합니다.
동적이라는 이유만으로 모든 멤버를 열어서는 안 됩니다. 사용자 입력을 그대로 클래스 이름이나 메서드 이름으로 사용하면 의도하지 않은 기능을 호출할 수 있습니다. 탐색 범위, 허용 시그니처, 접근 수준, 예외 번역, 캐시 수명을 먼저 정하고 그 안에서 리플렉션을 사용합니다. 정적 호출로 충분한 업무 코드는 그대로 두는 것이 읽기 쉽고 안전합니다.
가장 작은 메타데이터 조회
모든 객체는 실행 중 자신의 클래스 정보를 나타내는 Class 객체와 연결됩니다.
클래스 이름을 확인하고 선언된 필드 목록을 읽는 것부터 시작하면 리플렉션이 “무엇이든 실행하는 마법”이 아니라 클래스 구조를 데이터처럼 관찰하는 API라는 점을 이해할 수 있습니다.
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: intMember.class는 객체를 만들지 않고 클래스 정보를 얻습니다.
이미 객체가 있다면 object.getClass()로 같은 정보를 얻을 수 있습니다.
getDeclaredFields()는 현재 클래스에 선언된 필드 설명을 반환하지만 필드 값을 자동으로 읽지는 않습니다.
이 정상 조회를 기준으로 메서드, 생성자, 제네릭 타입 정보를 차례로 확장합니다.
무분별한 접근 해제
다음 코드는 발견한 모든 필드와 메서드에 setAccessible(true)를 적용합니다.
클래스가 세운 불변식과 모듈 캡슐화를 무시하고, JDK 버전이나 실행 옵션에 따라 InaccessibleObjectException이 발생할 수 있습니다.
비공개 메서드 실행 실패도 정상 업무 예외와 구분되지 않습니다.
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");
}
}getMethods()는 상속된 public 메서드까지 반환하고 getDeclaredMethods()는 현재 클래스에 선언된 모든 접근 수준을 반환하되 상속 멤버를 포함하지 않습니다.
Field와 Constructor도 public 조회와 선언된 조회가 같은 구분을 가집니다.
프레임워크가 어떤 의미를 원하는지 모른 채 둘을 바꾸어 쓰면 부모 라우트가 중복되거나 비공개 도우미가 노출됩니다.
Java 9 이후 모듈 시스템은 패키지를 외부 리플렉션에 열지 않을 수 있습니다.
setAccessible(true)는 언어 접근 검사 우회를 요청하지만 닫힌 모듈을 자동으로 열어 주지 않습니다.
trySetAccessible() 결과를 확인하고 필요한 패키지만 모듈 선언의 opens로 명시하는 편이 낫습니다.
광범위한 --add-opens는 임시 호환 수단이지 라이브러리의 기본 설계가 되어서는 안 됩니다.
Class 메타데이터
Class<?>는 런타임 원시 타입을 나타냅니다.
int.class는 기본형 Class이고 String[].class는 배열 Class이며 getComponentType()으로 요소 타입을 얻습니다.
제네릭 타입 인수는 타입 소거 때문에 일반 객체의 getClass()에서 복원되지 않습니다.
다만 선언부 시그니처에 기록된 Field.getGenericType(), Method.getGenericReturnType()은 ParameterizedType 형태로 읽을 수 있습니다.
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에서 처리하면 원인 타입을 잃습니다.
선택 단계는 허용된 이름과 정확 시그니처를 검사하고, 실행 단계는 래퍼의 원인을 꺼내 안정적인 결과로 번역합니다.
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를 강하게 붙잡아 언로드를 막지 않는지 고려합니다.
일반 단일 애플리케이션에서는 시작 시점 탐색과 요청 경로 호출을 분리하는 것만으로 충분합니다.
필드 변경의 위험
Field.get()과 Field.set()은 공통 null 보정이나 직렬화에 편리하지만 세터, 생성자 검증, 캡슐화를 건너뜁니다.
예를 들어 age 세터가 음수를 막아도 리플렉션으로 비공개 필드에 직접 음수를 넣을 수 있습니다.
final 필드 변경은 JVM 최적화와 Java 메모리 모델까지 얽혀 예측 가능한 일반 API로 취급할 수 없습니다.
공통 필드 처리가 필요하면 public 접근자, record 구성 요소, 명시적인 스키마 어댑터를 우선 검토합니다.
리플렉션이 필요한 프레임워크 내부에서는 애노테이션으로 명시적 사용한 필드만 다루고, static·final·합성 필드를 제외하며, 접근 실패를 조용히 무시하지 않습니다.
객체가 속한 모듈이 패키지를 열지 않았다면 사용자에게 필요한 구성과 대상 필드를 설명합니다.
Constructor도 같은 원칙을 가집니다.
Class.newInstance()는 사용 중단이며 예외 의미가 불명확하므로 getDeclaredConstructor(...).newInstance(...)를 씁니다.
생성자 본문 예외도 InvocationTargetException에 감싸집니다.
생성자를 열기 전에 public 생성자나 팩터리 메서드를 사용할 수 있는지 확인합니다.
| 리플렉션 대상 | 안전한 기본 탐색 | 우회가 필요할 때 | 거절 조건 |
|---|---|---|---|
Class | 클래스 리터럴·허용 이름 | Class.forName 제한 | 외부 임의 클래스 |
Method | public 정확 시그니처 | 명시적 사용 선언된 메서드 | 허용 목록 밖 이름 |
Field | public 접근자 우선 | 애노테이션 필드 | final·모듈 닫힘 |
Constructor | public 생성자·팩터리 | trySetAccessible 확인 | 불명확한 오버로드 |
연습 문제
문자열 클래스 이름을 받지 않고 이미 승인된 Class 키로만 객체를 만듭니다.
public 무인수 생성자를 우선하고, 없으면 비공개 String 생성자를 trySetAccessible()로 열어 레이블을 전달합니다.
생성자 본문 실패는 ConstructionFailure로 원인을 해제해 반환합니다.
생성자 선택과 예외 분류를 포함한 풀이
팩터리가 지원하는 타입을 Map에 직접 등록하면 사용자 입력이 임의 Class로 이어지지 않습니다.
각 생성기는 시작 시점에 만들어 두고 요청 시 리플렉션 탐색을 반복하지 않습니다.
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"));
}
}비공개 중첩 클래스는 같은 이름 없는 모듈 안에 있어 trySetAccessible이 성공합니다.
다른 이름 있는 모듈의 닫힌 패키지라면 실패할 수 있고 팩터리 시작 시점이 원인을 드러내야 합니다.
Object... 호출은 리플렉션 API의 가변 인수이며 실제 생성자 시그니처 검증은 생성기 생성 시 끝납니다.
리플렉션 승인 기준
탐색 결과에는 public과 선언된 차이, 기본형과 래퍼, 배열 구성 요소, 제네릭 Type 구현을 포함합니다.
동적 메서드 테스트는 정상 반환, 대상 업무 예외, 허용되지 않은 이름을 구분해야 합니다.
비공개 접근은 trySetAccessible 결과를 확인하고 모듈 구성 없이 열리지 않는 대상을 조용히 건너뛰지 않습니다.
리플렉션 코드가 요청마다 전체 멤버를 스캔하지 않는지, 캐시가 Class 로더 수명과 맞는지, 예외 래퍼의 원인을 보존하는지도 확인합니다.
다음 문서에서는 이름 규칙 대신 선언부에 구조화된 의미를 붙이는 애노테이션이 왜 필요한지 살펴보고, 실행 시점 리플렉션이 읽을 수 있는 메타데이터 계약으로 발전시킵니다.