리플렉션으로 클래스 정보 읽고 호출하기
Class 객체로 필드·메서드·생성자 정보를 읽고, 리플렉션으로 메서드를 호출하거나 객체를 만들 때 생기는 예외와 setAccessible·모듈 시스템의 접근 제한을 다루는 방법을 배웁니다.
앞 장의 Servlet 라우터에서는 경로마다 처리 코드를 Map에 손으로 등록했습니다. 기능이 하나 늘 때마다 등록 코드도 함께 고쳐야 하니, 컨트롤러 클래스만 넘기면 프레임워크가 처리 메서드를 알아서 찾아 주면 편할 것입니다. 그러려면 프로그램이 실행 중에 클래스의 구조를 들여다볼 수 있어야 합니다. 어떤 필드와 메서드가 있는지, 각각의 타입이 무엇인지 알아내고, 찾아낸 메서드를 호출할 수도 있어야 합니다. 자바에서는 이 기능을 리플렉션(reflection)이라고 부릅니다.
평소의 자바 코드는 controller.search(request)처럼 호출할 메서드를 소스에 직접 적습니다. 그러면 컴파일러가 그 메서드가 정말 있는지, 인수 타입이 맞는지, 호출해도 되는 접근 수준인지를 미리 검사해 줍니다. 리플렉션은 호출할 메서드를 문자열이나 애노테이션 같은 데이터로 고를 수 있게 해 주는 대신, 이 검사를 모두 실행 시점으로 미룹니다. 이름을 잘못 적어도 컴파일은 되고, 실행 중에 예외가 날 뿐입니다.
그래서 리플렉션은 다룰 클래스를 미리 알 수 없는 코드에 주로 쓰입니다. 스프링이 생성자를 찾아 객체를 만들고 의존성을 넣어 주는 일, JUnit이 테스트 메서드를 찾아 실행하는 일, JSON 라이브러리가 객체의 필드를 읽어 문자열로 바꾸는 일이 모두 리플렉션 위에서 돌아갑니다. 반대로 호출할 대상을 이미 아는 업무 코드라면 평소처럼 직접 호출하는 편이 읽기 쉽고 안전합니다. 이 글에서는 클래스 정보를 읽는 것부터 시작해 메서드 호출, 필드 읽고 쓰기, 객체 생성까지 차례로 해 보고, 그 과정에서 만나는 예외와 접근 제한을 실제 실행 결과로 확인합니다.
클래스마다 하나씩 있는 Class 객체
JVM은 클래스를 메모리에 올릴 때 그 클래스의 이름, 필드, 메서드, 생성자 같은 구조를 담은 Class 객체를 함께 만듭니다. 리플렉션은 모두 이 객체에서 출발합니다. Class 객체를 얻는 방법은 세 가지입니다. 소스에 클래스 이름을 쓸 수 있으면 Member.class 같은 클래스 리터럴을 쓰고, 이미 객체가 있으면 member.getClass()를 호출합니다. 클래스 이름을 문자열로만 알 때는 Class.forName("java.lang.String")처럼 완전한 이름으로 찾는데, 그런 클래스가 없으면 ClassNotFoundException이 납니다.
다음 예제는 Member의 Class 객체를 얻어 이름과 선언된 필드를 출력하고, 객체에서 얻은 Class와 같은 것인지도 비교합니다. Member의 필드는 모두 private이지만, 필드의 이름과 타입을 읽는 데는 접근 제한이 걸리지 않는다는 점도 눈여겨보세요.
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());
}
Member member = new Member();
System.out.println("same Class object: " + (member.getClass() == type));
}
}getSimpleName()은 패키지와 바깥 클래스 이름을 뺀 짧은 이름을 돌려줍니다. 마지막 줄의 ==는 두 Class 참조가 같은 객체를 가리키는지를 비교합니다.
class=Member
name: String
age: int
same Class object: truegetDeclaredFields()는 Member에 선언된 필드마다 Field 객체를 하나씩 돌려줍니다. Field는 필드의 이름과 타입, 접근 제한자를 설명하는 객체일 뿐 필드에 든 값이 아닙니다. 그래서 Member 객체를 하나도 만들기 전에도 필드 목록을 읽을 수 있습니다. 어떤 객체의 필드 값을 읽으려면 뒤에서 볼 Field.get에 그 객체를 따로 넘겨야 합니다.
마지막 줄이 true인 것은 new Member()로 만든 객체의 getClass()가 Member.class와 같은 Class 객체를 돌려주기 때문입니다. 한 클래스에는 Class 객체가 하나만 있어서 어떤 방법으로 얻든 ==로 비교할 수 있습니다. 정확히는 클래스 로더마다 하나씩이지만, 보통의 프로그램에서는 신경 쓰지 않아도 됩니다.
반면 필드의 순서는 믿으면 안 됩니다. 공식 문서는 getDeclaredFields()가 돌려주는 배열이 정렬되어 있지 않고 특정 순서를 따르지도 않는다고 밝힙니다. 이번에는 선언 순서대로 나왔지만, 순서가 중요한 코드라면 이름으로 정렬해서 써야 합니다.
public 멤버를 찾을까, 선언된 멤버를 찾을까
Class에는 멤버를 찾는 메서드가 두 벌 있습니다. getMethods()처럼 이름에 Declared가 없는 쪽과 getDeclaredMethods()처럼 있는 쪽인데, 찾는 범위가 서로 다릅니다. 라우트 메서드를 찾는 프레임워크라면 어느 쪽을 쓰느냐에 따라 라우트가 되는 메서드가 달라지므로, 상속 관계가 있는 작은 클래스로 직접 비교해 보겠습니다.
import java.lang.reflect.Method;
import java.util.Arrays;
public final class MemberLookupScope {
static class Parent {
public void inherited() {
}
}
static final class Child extends Parent {
public void open() {
}
private void hidden() {
}
}
static String names(Method[] methods) {
return Arrays.stream(methods)
.filter(method -> method.getDeclaringClass() != Object.class)
.map(Method::getName)
.sorted()
.toList()
.toString();
}
public static void main(String[] args) {
Method[] publicMethods = Child.class.getMethods();
Method[] declaredMethods = Child.class.getDeclaredMethods();
System.out.println("getMethods " + names(publicMethods));
System.out.println("getDeclaredMethods " + names(declaredMethods));
long fromObject = Arrays.stream(publicMethods)
.filter(method -> method.getDeclaringClass() == Object.class)
.count();
System.out.println("public methods from Object: " + fromObject);
}
}names는 Object에서 물려받은 메서드를 빼고 이름만 정렬해 돌려주므로, 첫 두 줄에는 두 메서드가 찾는 범위의 차이만 남습니다. 셋째 줄은 걸러 낸 Object의 메서드가 몇 개였는지를 따로 셉니다.
getMethods [inherited, open]
getDeclaredMethods [hidden, open]
public methods from Object: 9getMethods()는 이 클래스로 호출할 수 있는 public 메서드를 상속받은 것까지 모두 돌려줍니다. 그래서 Parent에 선언된 inherited()가 들어 있고 private인 hidden()은 빠졌습니다. 세 번째 줄은 그 결과에 Object에서 물려받은 public 메서드 9개도 섞여 있다는 뜻입니다. equals, hashCode, toString, getClass, notify, notifyAll, 그리고 매개변수가 서로 다른 wait 세 개입니다. 예제의 names가 Object의 메서드를 걸러 낸 이유가 이것입니다.
반대로 getDeclaredMethods()는 Child의 소스에 직접 선언한 메서드만, 접근 제한자와 상관없이 돌려줍니다. 그래서 hidden()은 보이고 물려받은 inherited()는 보이지 않습니다. 결과 배열의 순서는 이쪽도 정해져 있지 않아서, 예제는 이름을 정렬한 뒤 출력했습니다.
필드와 생성자를 찾는 메서드도 같은 짝으로 되어 있습니다. 아래 그림처럼 getFields()는 상속받은 public 필드까지, getDeclaredFields()는 현재 클래스에 선언된 모든 필드를 돌려줍니다. 생성자만은 상속되지 않기 때문에 getConstructors()도 현재 클래스의 public 생성자만 돌려줍니다.
멤버 종류별 public 조회와 선언된 조회
리플렉션 조회 · public과 declared 범위
멤버 종류별 public 조회와 선언된 조회
메서드·필드의 public 조회에는 상속된 멤버가 포함됩니다. 생성자는 상속되지 않으며 declared 조회는 현재 타입의 선언을 봅니다.
public 조회
- 메서드
- getMethods(): 상속된 public 포함
- 필드
- getFields(): 상속된 public 포함
- 생성자
- getConstructors(): 현재 클래스의 public만
선언된 조회
- 메서드
- getDeclaredMethods(): 현재 타입의 모든 접근 수준
- 필드
- getDeclaredFields(): 현재 타입의 모든 접근 수준
- 생성자
- getDeclaredConstructors(): 현재 클래스의 모든 접근 수준
생성자는 상속되지 않습니다. 메타데이터를 찾는 것과 실제 값을 읽거나 호출할 수 있는지는 별도 검사입니다.
어느 쪽이 맞는지는 무엇을 찾으려는지에 달려 있습니다. 라우트를 getDeclaredMethods()로 찾으면 부모 클래스에 선언한 라우트를 놓치고, 외부에 노출하면 안 되는 private 도우미 메서드까지 후보에 오릅니다. getMethods()로 찾으면 private 메서드는 빠지지만 toString 같은 Object의 메서드와 부모의 public 메서드가 모두 섞입니다. 그래서 프레임워크는 조회 범위를 먼저 정한 다음, 애노테이션처럼 명시적인 표시가 붙은 메서드만 고릅니다. 이 방법은 다음 글에서 다룹니다.
그림 아래쪽의 설명도 기억해 두세요. 메서드나 필드를 찾을 수 있다는 것과 실제로 호출하거나 값을 읽을 수 있다는 것은 별개의 검사입니다. 예제는 private 메서드인 hidden()의 Method 객체도 얻었지만, 그것으로 호출까지 할 수 있는지는 뒤에서 볼 접근 검사가 따로 정합니다.
기본형, 배열, 제네릭 타입 정보 읽기
Class 객체는 일반 클래스에만 있는 것이 아닙니다. 인터페이스, 열거형, 배열, 그리고 int 같은 기본형에도 각자의 Class 객체가 있습니다. int.class는 기본형 int를, int[].class는 int 배열 타입을 나타내고, 배열 타입의 Class에서는 getComponentType()으로 요소 타입을 얻습니다.
제네릭 타입은 조금 더 복잡합니다. 타입 소거 때문에 실행 중인 객체는 자신이 List<String>으로 만들어졌는지 List<Integer>로 만들어졌는지 모릅니다. 하지만 필드 타입이나 메서드의 반환 타입처럼 선언에 적힌 제네릭 정보는 클래스 파일에 시그니처로 남기 때문에 리플렉션으로 읽을 수 있습니다. 이 정보는 Class보다 넓은 개념인 java.lang.reflect.Type으로 돌아옵니다. 다음 예제는 제네릭 클래스 Catalog를 대상으로 두 경우를 모두 확인합니다.
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<?> 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());
}
}printType은 이름표 뒤에 type을 찍고, 받은 타입이 ParameterizedType이면 원시 타입(raw)과 타입 인수(args)를 두 줄 더 찍습니다. 그래서 names와 return은 세 줄씩, 타입 변수 하나뿐인 parameter는 한 줄만 나옵니다.
name=ReflectionTypeExplorer$Catalog
simple=Catalog
modifiers=static final
typeParameters=[T]
names type=java.util.List<java.lang.String>
names raw=interface java.util.List
names args=[class java.lang.String]
scores array=true
scores component=int
int primitive=true
return type=java.util.List<T>
return raw=interface java.util.List
return args=[T]
parameter type=T
constructor=public ReflectionTypeExplorer$Catalog()
constructor=private ReflectionTypeExplorer$Catalog(java.lang.Number)
runtime list class=class java.util.ImmutableCollections$List12출력을 위에서부터 읽어 보겠습니다. name이 ReflectionTypeExplorer$Catalog인 것은 컴파일러가 중첩 클래스의 이름을 바깥 클래스 이름 뒤에 $를 붙여 짓기 때문이고, getSimpleName()은 소스에 쓴 이름 Catalog만 돌려줍니다. modifiers와 typeParameters는 클래스 선언의 static final과 <T extends Number>의 T를 그대로 읽은 것입니다.
names 필드를 getType()으로 읽으면 소거된 List만 나오지만, getGenericType()으로 읽으면 ParameterizedType이 돌아옵니다. 그래서 raw(원래 타입 List)와 args(타입 인수 String)를 따로 꺼낼 수 있습니다. scores 필드의 타입은 배열이고 요소 타입은 int이며, int.class.isPrimitive()는 int가 기본형이라는 것을 확인해 줍니다.
메서드 values를 찾을 때 매개변수 타입으로 Number.class를 넘긴 것에 주목하세요. T는 소거되면 상한인 Number가 되므로 실행 중의 시그니처는 values(Number)입니다. T.class 같은 표현은 존재하지 않습니다. 비공개 생성자가 Catalog(java.lang.Number)로 출력된 것도 같은 이유이고, 이 생성자를 찾으려면 getDeclaredConstructor(Number.class)를 써야 합니다. 생성자 두 줄의 순서는 필드와 마찬가지로 정해져 있지 않습니다.
한편 반환 타입 List<T>의 타입 인수는 구체적인 클래스가 아니라 타입 변수 T로 나옵니다. 리플렉션은 선언에 적힌 것을 그대로 돌려줄 뿐이고, 선언에 적힌 것이 T이기 때문입니다.
마지막 줄은 실행 중인 객체의 Class입니다. List.of("a", "b")가 만든 객체는 JDK 내부 클래스의 인스턴스이고(클래스 이름은 JDK 버전마다 다를 수 있습니다), 어디에도 String이라는 정보는 없습니다. 선언에 적은 제네릭 정보는 읽을 수 있어도, 객체 하나가 어떤 타입 인수로 만들어졌는지는 읽을 수 없다는 것이 이 예제의 요점입니다.
Type은 인터페이스이고, 실제로 돌아오는 객체의 종류는 선언에 따라 다릅니다. List<String>은 ParameterizedType, T는 TypeVariable, List<? extends Number> 안의 ? extends Number는 WildcardType, T[]는 GenericArrayType이고, 제네릭이 없는 String은 그냥 Class입니다. 그래서 Type을 무조건 Class로 형변환하면 ClassCastException이 날 수 있습니다. 예제의 printType처럼 instanceof로 종류를 확인한 뒤 다뤄야 합니다.
더 깊이: 타입 토큰으로 제네릭 정보 넘기기
JSON 라이브러리에 "이 문자열을 List<String>으로 바꿔 달라"고 하려면 List<String>이라는 타입 정보를 넘겨야 합니다. 그런데 List<String>.class라는 표현은 없고, List.class에는 String이 빠져 있습니다. 그래서 Gson의 TypeToken이나 Jackson의 TypeReference는 익명 하위 클래스를 이용합니다. 클래스를 선언할 때 적은 상위 타입은 선언 정보이므로 클래스 파일에 남는다는 점을 이용하는 것입니다.
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.List;
public final class TypeTokenDemo {
abstract static class TypeToken<T> {
Type type() {
ParameterizedType parent =
(ParameterizedType) getClass().getGenericSuperclass();
return parent.getActualTypeArguments()[0];
}
}
public static void main(String[] args) {
Type type = new TypeToken<List<String>>() {
}.type();
System.out.println(type);
}
}println(type)은 Type의 toString()을 찍습니다. type()이 꺼낸 것이 List.class 같은 Class 객체였다면 타입 인수 없이 interface java.util.List로 찍혔을 것입니다.
java.util.List<java.lang.String>new TypeToken<List<String>>() 뒤의 중괄호가 이름 없는 하위 클래스를 선언합니다. 이 익명 클래스의 상위 타입은 TypeToken<List<String>>으로 클래스 파일에 기록되므로, getGenericSuperclass()로 읽어 첫 번째 타입 인수를 꺼내면 List<String>이 나옵니다. 하위 클래스를 만들지 않고 TypeToken 객체를 바로 만들면 기록된 선언이 없어서 이 방법이 통하지 않습니다. 예제에서 TypeToken을 abstract로 선언한 것도 중괄호를 빠뜨리는 실수를 컴파일 단계에서 막기 위해서입니다.
이름으로 메서드 호출하기: Method.invoke
Method 객체의 invoke를 부르면 그 메서드가 실행됩니다. 첫 번째 인수는 메서드를 실행할 객체이고(static 메서드라면 null), 나머지는 메서드에 넘길 인수입니다. 반환값은 Object로 돌아오며, int 같은 기본형 결과는 Integer 같은 래퍼 객체로 감싸져 나옵니다.
호출할 메서드를 고르는 방법이 중요합니다. 사용자가 보낸 문자열을 그대로 메서드 이름으로 쓰면, 개발자가 공개할 생각이 없던 메서드까지 호출될 수 있습니다. 그래서 다음 예제는 호출해도 되는 이름을 허용 목록으로 정해 두고, 프로그램이 시작할 때 그 이름의 메서드를 매개변수 타입까지 정확히 지정해 미리 찾아 둡니다. 실행 중에 대상 메서드가 예외를 던진 경우와 리플렉션 자체가 실패한 경우를 나눠 처리하는 방법도 함께 봅니다.
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.HashMap;
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() {
Map<String, Method> methods = new HashMap<>();
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));
}
}invoke는 CallResult를 구현한 레코드 세 종류 가운데 하나를 돌려주므로, 각 줄은 레코드 이름과 구성 요소로 찍힙니다. 세 호출은 차례로 정상 반환, 대상 메서드 안에서 난 예외, 허용 목록 밖의 이름을 보여 줍니다.
Success[value=12]
TargetFailure[type=IllegalArgumentException, message=zero divisor]
Rejected[reason=operation not allowed]main보다 먼저, 클래스가 초기화될 때 discover()가 실행됩니다. getMethod("add", int.class, int.class)처럼 이름과 매개변수 타입을 함께 넘겨야 같은 이름의 오버로드 가운데 하나가 정해집니다. 이때 기본형 int.class와 래퍼 Integer.class는 서로 다른 타입이어서, Integer.class로 찾으면 NoSuchMethodException이 납니다. 허용 목록의 이름에 맞는 메서드가 없으면 ExceptionInInitializerError를 던져 클래스 초기화 자체를 실패시키는데, 잘못된 설정을 첫 요청이 아니라 프로그램이 시작할 때 발견하려는 것입니다.
찾은 Method는 불변 Map에 담아 두고 호출할 때마다 꺼내 씁니다. 메서드를 찾는 작업은 이름과 타입을 비교하느라 비용이 들기 때문에 요청마다 반복하지 않는 편이 낫습니다.
첫 줄은 add가 정상적으로 12를 반환한 결과입니다. invoke에 넘긴 7과 5는 Object 가변 인수로 전달되면서 Integer로 박싱되었다가, invoke가 다시 int로 풀어 메서드에 넘깁니다.
두 번째 줄의 divide에서는 메서드 본문이 IllegalArgumentException을 던졌습니다. 이때 invoke는 그 예외를 그대로 던지지 않고 InvocationTargetException으로 한 번 감싸서 던집니다. 원래 예외는 getCause()로 꺼낼 수 있고, 예제는 그 타입과 메시지를 TargetFailure에 담았습니다. 세 번째 줄의 secret은 허용 목록에 없어서 METHODS에서 찾지 못했으므로, 메서드를 실행하기 전에 Rejected가 됩니다.
catch 블록의 순서에도 이유가 있습니다. InvocationTargetException은 ReflectiveOperationException의 하위 클래스이므로 먼저 잡아야 합니다. 뒤의 catch에 걸리는 IllegalAccessException 같은 예외는 대상 메서드가 실패한 것이 아니라 리플렉션 호출 자체가 실패했다는 뜻입니다. 시작할 때 검증까지 마친 메서드에서 이런 일이 생겼다면 프로그램의 버그이므로, 예제는 업무 결과로 바꾸지 않고 IllegalStateException으로 다시 던집니다. 두 종류를 같은 catch로 처리하면 '나눗셈의 인수가 잘못됐다'와 '메서드에 접근할 수 없다'를 구분할 수 없게 됩니다.
아래 단계 그림은 세 호출이 메서드 선택, 본문 실행, 결과 변환의 각 단계에서 어떻게 갈라지는지 정리한 것입니다. secret만 본문 실행 단계에 들어가지 못한다는 점을 확인해 보세요.
허용된 메서드 호출의 세 결과
허용 목록에서 add·divide를 선택해 호출하고 결과 또는 원인 예외를 풀어내며 secret은 본문 실행 전에 차단합니다. 세 입력의 호출 허가와 실행 결과를 대응 단계로 설명할 수 있습니다.
허용된 메서드 호출의 세 결과
리플렉션 호출 · 실행 실패와 선택 거절
허용된 메서드 호출의 세 결과
add는 12를 반환하고 divide는 대상 본문의 예외를 결과로 바꿉니다. secret은 허용 목록에 없어서 본문을 실행하기 전에 거절합니다.
허용 목록 경계 앞의 거절과 대상 본문 실패
불변 METHODS 허용 목록의 조회 경계다. secret은 미등록이라 invoke 전에 Rejected다. 등록된 add와 divide만 실행되며 add는 Success 12, divide의 InvocationTargetException에서 원인을 꺼내 TargetFailure로 반환한다.
상태별 상세 해설
덧셈: add(7, 5)
- 메서드 본문 실행
- 실행, 12 반환
- 반환 결과
- Success(value=12)
0으로 나눔
- 메서드 본문 실행
- 실행, IllegalArgumentException 발생
- 반환 결과
- TargetFailure(type=IllegalArgumentException, message=zero divisor)
미등록 이름: secret
- 메서드 본문 실행
- 호출 전에 거절
- 반환 결과
- Rejected(reason=operation not allowed)
SafeReflectiveInvoker의 main을 코드와 API 계약으로 해석한 값이며 새 실행 관측은 아닙니다. 대상 메서드 예외의 원인을 꺼내는 경로와 호출 전 거절을 구분합니다.
이 예의 조건
- 예제의 main의 add(7,5), divide(8,0), secret(0,0)을 같은 처리 경계마다 비교합니다. 서로 다른 호출의 입력입니다.
- 코드·API 해석값이며 새 실행 관측이 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 허용된 메서드 호출의 세 결과 허용된 메서드 호출의 세 결과 허용된 메서드 호출의 세 결과 리플렉션 호출 · 실행 실패와 선택 거절 허용된 메서드 호출의 세 결과 add는 12를 반환하고 divide는 대상 본문의 예외를 결과로 바꿉니다. secret은 허용 목록에 없어서 본문을 실행하기 전에 거절합니다. 덧셈: add(7, 5) 메서드 본문 실행 실행, 12 반환 반환 결과 Success(value=12) 0으로 나눔 메서드 본문 실행 실행, IllegalArgumentException 발생 반환 결과 TargetFailure(type=IllegalArgumentException, message=zero divisor) 미등록 이름: secret 메서드 본문 실행 호출 전에 거절 반환 결과 Rejected(reason=operation not allowed) SafeReflectiveInvoker의 main을 코드와 API 계약으로 해석한 값이며 새 실행 관측은 아닙니다. 대상 메서드 예외의 원인을 꺼내는 경로와 호출 전 거절을 구분합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 이름으로 허용 메서드 선택 각 호출이 METHODS에서 이름에 맞는 메서드를 조회합니다. |
|
|
| 선택된 대상 본문 실행 add와 divide만 invoke되며 secret의 거절은 이미 끝났습니다. |
|
|
| 각 호출의 결과로 번역 정상 반환과 대상 예외의 원인을 구별해 각 CallResult를 돌려줍니다. |
|
|
허용된 메서드 호출의 세 결과
허용 목록에서 add·divide를 선택해 호출하고 결과 또는 원인 예외를 풀어내며 secret은 본문 실행 전에 차단합니다. 세 입력의 호출 허가와 실행 결과를 대응 단계로 설명할 수 있습니다.
허용된 메서드 호출의 세 결과
리플렉션 호출 · 실행 실패와 선택 거절
허용된 메서드 호출의 세 결과
add는 12를 반환하고 divide는 대상 본문의 예외를 결과로 바꿉니다. secret은 허용 목록에 없어서 본문을 실행하기 전에 거절합니다.
허용 목록 경계 앞의 거절과 대상 본문 실패
불변 METHODS 허용 목록의 조회 경계다. secret은 미등록이라 invoke 전에 Rejected다. 등록된 add와 divide만 실행되며 add는 Success 12, divide의 InvocationTargetException에서 원인을 꺼내 TargetFailure로 반환한다.
상태별 상세 해설
덧셈: add(7, 5)
- 메서드 본문 실행
- 실행, 12 반환
- 반환 결과
- Success(value=12)
0으로 나눔
- 메서드 본문 실행
- 실행, IllegalArgumentException 발생
- 반환 결과
- TargetFailure(type=IllegalArgumentException, message=zero divisor)
미등록 이름: secret
- 메서드 본문 실행
- 호출 전에 거절
- 반환 결과
- Rejected(reason=operation not allowed)
SafeReflectiveInvoker의 main을 코드와 API 계약으로 해석한 값이며 새 실행 관측은 아닙니다. 대상 메서드 예외의 원인을 꺼내는 경로와 호출 전 거절을 구분합니다.
이 예의 조건
- 예제의 main의 add(7,5), divide(8,0), secret(0,0)을 같은 처리 경계마다 비교합니다. 서로 다른 호출의 입력입니다.
- 코드·API 해석값이며 새 실행 관측이 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 허용된 메서드 호출의 세 결과 허용된 메서드 호출의 세 결과 허용된 메서드 호출의 세 결과 리플렉션 호출 · 실행 실패와 선택 거절 허용된 메서드 호출의 세 결과 add는 12를 반환하고 divide는 대상 본문의 예외를 결과로 바꿉니다. secret은 허용 목록에 없어서 본문을 실행하기 전에 거절합니다. 덧셈: add(7, 5) 메서드 본문 실행 실행, 12 반환 반환 결과 Success(value=12) 0으로 나눔 메서드 본문 실행 실행, IllegalArgumentException 발생 반환 결과 TargetFailure(type=IllegalArgumentException, message=zero divisor) 미등록 이름: secret 메서드 본문 실행 호출 전에 거절 반환 결과 Rejected(reason=operation not allowed) SafeReflectiveInvoker의 main을 코드와 API 계약으로 해석한 값이며 새 실행 관측은 아닙니다. 대상 메서드 예외의 원인을 꺼내는 경로와 호출 전 거절을 구분합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 이름으로 허용 메서드 선택 각 호출이 METHODS에서 이름에 맞는 메서드를 조회합니다. |
|
|
| 선택된 대상 본문 실행 add와 divide만 invoke되며 secret의 거절은 이미 끝났습니다. |
|
|
| 각 호출의 결과로 번역 정상 반환과 대상 예외의 원인을 구별해 각 CallResult를 돌려줍니다. |
|
|
invoke는 래퍼 객체를 기본형으로 풀고, int를 long으로 넓히는 것 같은 기본형 확대 변환까지는 해 주지만 그 이상의 변환은 하지 않습니다. 예를 들어 long을 받는 메서드에 21을 넘기면 동작하지만, 문자열 "21"을 넘기면 invoke가 argument type mismatch라는 메시지와 함께 IllegalArgumentException을 던집니다. 이 예외는 대상 메서드에서 난 것이 아니므로 InvocationTargetException으로 감싸지지 않습니다. 그래서 프레임워크는 요청에서 받은 문자열을 메서드의 매개변수 타입에 맞게 바꾸는 단계를 invoke 앞에 따로 둡니다.
확인 문제
예제의 main이 add(Integer.MAX_VALUE, 1), divide(8, 2), secret(0, 0)을 호출하도록 바꾸면 각각 어떤 결과가 출력될까요? 대상 메서드가 실행된 뒤 실패한 경우와 실행 전에 거절된 경우를 먼저 구분해 보세요.
해설
세 호출은 각각 다른 종류의 결과를 냅니다. 실행 결과는 다음과 같습니다.
TargetFailure[type=ArithmeticException, message=integer overflow]
Success[value=4]
Rejected[reason=operation not allowed]add는 허용된 메서드라 실행되지만, Math.addExact가 int 범위를 넘는 덧셈에서 ArithmeticException을 던집니다. 이 예외가 InvocationTargetException에 감싸져 나오고, 예제가 원인을 꺼내 TargetFailure로 바꿉니다. 허용된 메서드라고 해서 모든 입력에서 성공하는 것은 아닙니다. divide(8, 2)는 정상적으로 4를 반환하고, secret은 인수와 상관없이 허용 목록에 없으므로 메서드 본문이 실행되지 않은 채 Rejected가 됩니다.
필드 값 읽고 쓰기와 setAccessible
Field의 get과 set은 어떤 객체의 필드 값을 읽고 씁니다. getInt, setInt처럼 기본형 전용 메서드도 있습니다. 리플렉션도 기본적으로는 소스 코드와 같은 접근 규칙을 따르기 때문에, 다른 클래스의 private 필드를 그냥 읽으려 하면 IllegalAccessException이 납니다. 이 검사를 끄는 메서드가 setAccessible(true)입니다. 다음 예제는 음수 나이를 거절하는 세터가 있는 클래스에, 리플렉션으로 음수를 직접 써 넣어 봅니다.
import java.lang.reflect.Field;
public final class FieldWriteBypass {
public static void main(String[] args) throws Exception {
Member member = new Member();
try {
member.setAge(-5);
} catch (IllegalArgumentException error) {
System.out.println("setter: " + error.getMessage());
}
Field age = Member.class.getDeclaredField("age");
try {
age.get(member);
} catch (IllegalAccessException error) {
System.out.println("get: " + error.getMessage());
}
age.setAccessible(true);
age.setInt(member, -5);
System.out.println("after set: " + member.age());
}
}
final class Member {
private int age;
void setAge(int age) {
if (age < 0) {
throw new IllegalArgumentException("age must not be negative");
}
this.age = age;
}
int age() {
return age;
}
}setAccessible(true)는 이 Field 객체 하나의 접근 검사만 끕니다. 셋째 줄에서는 세터가 거절한 음수가 필드에 그대로 들어갔는지를 확인합니다.
setter: age must not be negative
get: class FieldWriteBypass cannot access a member of class Member with modifiers "private"
after set: -5첫 줄은 세터가 음수를 거절한 결과입니다. 두 번째 줄은 FieldWriteBypass가 다른 클래스인 Member의 private 필드를 읽으려다 거절당한 것입니다. 소스 코드에 member.age라고 쓰면 컴파일 오류가 나는 것과 같은 규칙이 실행 시점에 적용되었습니다.
setAccessible(true)를 부른 뒤에는 이 검사가 꺼져서 setInt가 세터를 거치지 않고 필드에 -5를 직접 씁니다. 그 결과 마지막 줄처럼 Member는 클래스 스스로는 절대 만들지 않을 상태가 되었습니다. 리플렉션으로 필드를 쓰면 세터와 생성자의 검증, 그리고 클래스가 지키려던 불변식을 모두 건너뛰게 됩니다.
Member를 중첩 클래스로 두지 않고 같은 파일의 별도 최상위 클래스로 선언한 데는 이유가 있습니다. Java 11부터 중첩 클래스는 바깥 클래스와 같은 네스트(nest)로 묶여 서로의 private 멤버에 접근할 수 있고, 리플렉션의 접근 검사도 이 관계를 따릅니다. 네스트는 중첩 클래스의 종류와 정적 중첩 클래스에서 설명했습니다. 그래서 Member가 중첩 클래스였다면 setAccessible 없이도 get이 성공해 두 번째 줄의 예외를 볼 수 없습니다. 직접 실험할 때 이 차이를 알아 두면 헷갈리지 않습니다.
모든 멤버를 열어 두는 코드의 문제
setAccessible(true)가 이렇게 쉽게 통하다 보니, 발견한 멤버를 전부 열어서 다루는 코드를 쓰기 쉽습니다. 다음 inspectAndCall은 객체를 점검하겠다는 의도로 모든 필드를 열어 출력하고, 매개변수가 없는 메서드는 모두 호출해 봅니다.
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.getName() + " = " + 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) throws Exception {
Account account = new Account();
inspectAndCall(account);
System.out.println("balance after inspection = " + account.balance());
}
}
final class Account {
private long balance = 1000;
long balance() {
return balance;
}
void reset() {
balance = 0;
}
}Account에서 매개변수가 없는 메서드는 balance()와 reset() 둘입니다. getDeclaredMethods()가 돌려주는 순서는 정해져 있지 않지만, 어느 순서든 두 메서드가 모두 호출된다는 점은 같습니다.
balance = 1000
balance after inspection = 0점검만 하려던 코드가 잔액을 0으로 만들었습니다. reset()도 매개변수가 없는 메서드라서 balance()와 똑같이 호출되었기 때문입니다. 리플렉션은 메서드의 이름과 시그니처는 알려 주지만, 그 메서드가 무슨 일을 하는지는 알려 주지 않습니다. 게다가 inspectAndCall은 모든 실패를 throws Exception으로 그대로 내보내므로, 호출된 메서드 안에서 난 업무 예외인지 접근에 실패한 것인지를 가려내는 일이 호출한 쪽에 떠넘겨집니다. 이 메서드에 String 같은 JDK 클래스의 객체를 넘기면 또 다른 문제가 생기는데, 이것은 다음 절에서 봅니다.
그래서 리플렉션으로 멤버를 다룰 때는 무엇을 다룰지부터 좁혀야 합니다. 다음 세 가지를 지키면 앞의 예제 같은 사고를 대부분 막을 수 있습니다.
- 다룰 대상을 명시적으로 좁힙니다. 애노테이션으로 표시한 멤버만 고르고,
static필드나 컴파일러가 만든 합성(synthetic) 멤버는 제외합니다. - 접근을 열기 전에
public메서드, 레코드의 읽기 메서드, 생성자나 정적 팩터리 같은 정식 경로가 있는지 먼저 봅니다. - 접근을 열어야 한다면
trySetAccessible()의 결과를 확인하고, 실패했을 때 조용히 건너뛰지 말고 어떤 클래스의 어떤 멤버를 열 수 없었는지 알려 줍니다.
더 깊이: final 필드를 리플렉션으로 바꾸면
final 필드도 setAccessible(true) 뒤에는 set으로 바꿀 수 있는 경우가 있습니다. 하지만 바꾼 값이 프로그램에 어떻게 반영될지는 예측하기 어렵습니다. 다음 예제는 세 종류의 final 필드에 값을 써 봅니다.
import java.lang.reflect.Field;
public final class FinalFieldWrite {
public static void main(String[] args) throws Exception {
Config config = new Config();
Field limit = Config.class.getDeclaredField("limit");
limit.setAccessible(true);
limit.setInt(config, 99);
System.out.println("field value = " + limit.getInt(config));
System.out.println("limit() = " + config.limit());
Field max = Config.class.getDeclaredField("MAX");
max.setAccessible(true);
try {
max.setInt(null, 1);
} catch (IllegalAccessException error) {
System.out.println("static final: " + error.getMessage());
}
Field seat = Seat.class.getDeclaredField("number");
seat.setAccessible(true);
try {
seat.setInt(new Seat(1), 2);
} catch (IllegalAccessException error) {
System.out.println("record: " + error.getMessage());
}
}
}
final class Config {
static final int MAX = 100;
private final int limit = 10;
int limit() {
return limit;
}
}
record Seat(int number) {
}세 필드는 차례로 인스턴스 final 필드, static final 필드, 레코드 구성 요소의 필드입니다. 셋 다 setAccessible(true)를 먼저 불렀으므로, 결과의 차이는 필드의 종류에서만 나옵니다.
field value = 99
limit() = 10
static final: Can not set static final int field Config.MAX to (int)1
record: Can not set final int field Seat.number to (int)2첫 두 줄을 보면 필드의 값은 99로 바뀌었는데 limit()는 여전히 10을 돌려줍니다. limit처럼 상수 표현식으로 초기화한 final 필드는 상수 변수(constant variable)여서, javac가 이 필드를 읽는 코드를 컴파일할 때 값 10을 그 자리에 바로 넣어 버리기 때문입니다. Java 언어 명세의 17.5.3절도 이런 경우에는 final 필드를 바꿔도 변경이 보이지 않을 수 있다고 적고 있습니다. 같은 절은 명세가 final 필드에 대한 적극적인 최적화를 허용한다는 점도 짚는데, 그래서 상수가 아닌 final 필드라도 다른 코드가 바뀐 값을 언제 보게 될지 장담할 수 없습니다.
나머지 두 줄처럼 static final 필드와 레코드의 필드는 setAccessible(true)를 해도 쓰기가 아예 허용되지 않습니다. AccessibleObject의 공식 문서는 숨은 클래스(hidden class)의 final 필드도 이렇게 쓸 수 없는 필드로 함께 꼽습니다. final 필드를 바꾸는 리플렉션은 언제 어떻게 동작할지 예측하기 어려우므로, 역직렬화처럼 특별한 경우가 아니라면 쓰지 않는 것이 좋습니다.
모듈이 막는 리플렉션: InaccessibleObjectException
지금까지 setAccessible(true)는 매번 성공했습니다. 그런데 같은 일을 JDK 클래스에 시도하면 결과가 달라집니다. 다음 예제는 String이 내용을 담아 두는 private 필드 value를 열어 보려고 합니다.
import java.lang.reflect.Field;
public final class ModuleWall {
public static void main(String[] args) throws Exception {
Field value = String.class.getDeclaredField("value");
System.out.println("found: " + value);
System.out.println("trySetAccessible: " + value.trySetAccessible());
value.setAccessible(true);
System.out.println("opened: " + value.canAccess("hi"));
}
}실행 결과는 다음과 같습니다. 중간에 찍히는 JDK 내부의 스택 프레임은 ...으로 줄였습니다.
found: private final byte[] java.lang.String.value
trySetAccessible: false
Exception in thread "main" java.lang.reflect.InaccessibleObjectException: Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module @1540e19d
...
at ModuleWall.main(ModuleWall.java:9)필드를 찾는 것까지는 성공했습니다. 앞에서 말한 대로 찾는 것과 접근하는 것은 별개의 검사이기 때문입니다. trySetAccessible()은 접근을 열 수 없을 때 예외 대신 false를 돌려주고, setAccessible(true)는 InaccessibleObjectException을 던집니다.
예외 메시지가 이유를 정확히 말해 줍니다. String이 속한 java.base 모듈이 java.lang 패키지를 우리 코드가 속한 이름 없는 모듈(unnamed module)에 열어 두지(opens) 않았다는 것입니다. 끝의 @1540e19d는 모듈 객체를 구분하는 값이라 실행 환경에 따라 다르게 나옵니다. 앞 절의 inspectAndCall에 문자열을 넘겨도 첫 필드를 열 때 같은 예외가 납니다.
공식 문서에 따르면 setAccessible은 다음 가운데 하나에 해당할 때만 성공합니다. 호출하는 클래스와 대상 클래스가 같은 모듈에 있거나, 대상 멤버와 클래스가 public이고 그 패키지가 호출하는 쪽 모듈에 공개(exports)되어 있거나, 대상 클래스의 패키지가 호출하는 쪽 모듈에 열려(opens) 있어야 합니다(protected static 멤버를 하위 클래스에서 여는 경우도 하나 더 있습니다).
클래스 경로(class path)에서 실행한 클래스는 모두 이름 없는 모듈에 속하고, 이름 없는 모듈은 모든 패키지를 모든 모듈에 열어 둡니다. 앞의 Member나 Account에서 setAccessible이 늘 성공했던 것은 이 때문입니다. 반면 java.base는 java.lang을 공개만 하고 열지는 않습니다. 공개된 패키지의 public 멤버는 리플렉션으로도 쓸 수 있지만, private 멤버까지 다루는 깊은 리플렉션(deep reflection)은 열린 패키지에만 허용됩니다.
이 제한은 JDK 버전에 따라 단계적으로 강해졌습니다. 모듈 시스템이 들어온 Java 9부터 Java 15까지는 JDK 8에도 있던 내부 패키지에 대한 깊은 리플렉션을 경고와 함께 허용했습니다. 기본값이 --illegal-access=permit였기 때문입니다. Java 16의 JEP 396이 이 기본값을 거부로 바꿨고, Java 17의 JEP 403은 옵션 자체를 없앴습니다. 지금의 JDK에 이 옵션을 주면 다음 경고만 출력하고 무시합니다.
OpenJDK 64-Bit Server VM warning: Ignoring option --illegal-access=permit; support was removed in 17.0그래서 Java 17부터 JDK 내부 패키지를 깊은 리플렉션으로 다루려면, 어떤 패키지를 누구에게 열지를 실행 옵션 --add-opens로 직접 적어 주어야 합니다. --add-opens 모듈/패키지=대상 형식으로 쓰고, 대상의 ALL-UNNAMED는 이름 없는 모듈, 즉 클래스 경로의 모든 코드를 뜻합니다. 같은 프로그램을 java --add-opens java.base/java.lang=ALL-UNNAMED ModuleWall로 실행하면 이번에는 접근이 열립니다.
found: private final byte[] java.lang.String.value
trySetAccessible: true
opened: true실행 가능한 JAR라면 매니페스트의 Add-Opens 속성으로 같은 설정을 넣을 수도 있습니다. 하지만 --add-opens는 오래된 라이브러리를 당장 실행해야 할 때 쓰는 임시방편으로 생각해야 합니다. 이 옵션은 패키지 전체를 클래스 경로의 모든 코드에 열어 버리고, 무엇보다 JDK 내부 구현은 예고 없이 바뀝니다. 예를 들어 String.value는 Java 8까지 char[]였다가 Java 9의 문자열 압축(JEP 254, Compact Strings)과 함께 byte[]로 바뀌었습니다. 내부 필드에 기대던 코드는 이런 변경이 있을 때마다 깨집니다.
이 규칙은 JDK에만 적용되는 것이 아닙니다. 애플리케이션을 클래스 경로가 아니라 이름 있는 모듈로 만들면, 우리 패키지도 기본적으로 다른 모듈의 깊은 리플렉션에 열려 있지 않습니다. 그래서 직접 만든 모듈의 클래스를 프레임워크가 리플렉션으로 다뤄야 한다면, 모듈 선언에 opens를 적어 필요한 패키지만 엽니다.
module com.example.shop {
requires com.fasterxml.jackson.databind;
opens com.example.shop.dto to com.fasterxml.jackson.databind;
}to 뒤에 모듈 이름을 적으면 그 모듈에만 패키지를 엽니다. 모두에게 여는 opens com.example.shop.dto;나 모듈 전체를 여는 open module보다 범위가 좁아서, 리플렉션이 필요한 JSON 라이브러리에만 깊은 접근을 허용할 수 있습니다. 모듈 선언의 exports와 opens는 모듈과 ServiceLoader를 다루는 36장의 글에서 자세히 설명합니다.
생성자로 객체 만들기
Constructor는 Method와 같은 방식으로 씁니다. getDeclaredConstructor에 매개변수 타입을 순서대로 넘겨 생성자를 찾고, newInstance에 인수를 넘겨 객체를 만듭니다. 생성자 본문이 예외를 던지면 invoke와 마찬가지로 InvocationTargetException에 감싸져 나옵니다.
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
public final class ConstructorDemo {
public static void main(String[] args) throws Exception {
Constructor<Ticket> constructor =
Ticket.class.getDeclaredConstructor(String.class, int.class);
System.out.println(constructor.newInstance("A-1", 2));
try {
constructor.newInstance("A-2", 0);
} catch (InvocationTargetException wrapper) {
System.out.println("wrapped: " + wrapper.getCause());
}
}
}
record Ticket(String seat, int count) {
Ticket {
if (count <= 0) {
throw new IllegalArgumentException("count must be positive");
}
}
}첫 newInstance는 검증을 통과하는 값을 넘기고, 두 번째는 count에 0을 넘깁니다. catch가 잡는 타입이 IllegalArgumentException이 아니라 InvocationTargetException이라는 점을 보고 출력을 읽어 보세요.
Ticket[seat=A-1, count=2]
wrapped: java.lang.IllegalArgumentException: count must be positiveTicket은 레코드라서 생성자의 매개변수가 구성 요소의 타입 그대로 (String, int)입니다. 첫 줄은 정상적으로 만든 객체를 출력한 것이고, 두 번째 줄은 count가 0이라서 생성자 본문의 검증이 던진 예외를 getCause()로 꺼낸 것입니다. 필드를 직접 쓰는 방식과 달리 생성자를 호출하는 방식은 클래스가 정한 검증을 그대로 거친다는 점이 중요합니다. 리플렉션으로 객체를 만들어야 한다면 빈 객체를 만들어 필드를 채우기보다 생성자나 정적 팩터리를 호출하는 편이 안전합니다.
Java 9부터 사용 중단(deprecated)된 Class.newInstance()는 쓰지 않습니다. 이 메서드는 인수 없는 생성자를 호출하면서, 생성자가 던진 예외를 감싸지 않고 검사 예외까지 그대로 다시 던집니다. 컴파일러가 해 주던 검사 예외 확인을 우회하는 셈입니다. getDeclaredConstructor().newInstance()를 쓰면 생성자의 예외가 언제나 InvocationTargetException으로 감싸지므로 어디서 처리해야 할지가 분명해집니다.
정리
| 하고 싶은 일 | 객체를 얻는 방법 | 사용하는 메서드 | 자주 만나는 예외 |
|---|---|---|---|
| 클래스 정보 읽기 | Foo.class, obj.getClass(), Class.forName(이름) | getName(), getDeclaredFields() 등 | ClassNotFoundException |
| 메서드 호출 | getMethod(이름, 매개변수 타입들) | invoke(대상, 인수들) | NoSuchMethodException, InvocationTargetException |
| 필드 읽고 쓰기 | getDeclaredField(이름) | get, set | NoSuchFieldException, IllegalAccessException |
| 객체 생성 | getDeclaredConstructor(매개변수 타입들) | newInstance(인수들) | NoSuchMethodException, InvocationTargetException |
| 접근 검사 끄기 | 위에서 얻은 Field, Method, Constructor | setAccessible(true), trySetAccessible() | InaccessibleObjectException |
- 리플렉션은
Class객체에서 시작합니다.Field,Method,Constructor는 멤버를 설명하는 객체이고, 값을 읽거나 호출할 때는 대상 객체를 따로 넘깁니다. - 이름에 Declared가 없는 조회는 상속받은 것까지 포함한
public멤버를, Declared가 붙은 조회는 현재 클래스에 선언된 모든 멤버를 돌려줍니다. 어느 쪽이든 결과의 순서는 정해져 있지 않습니다. - 제네릭 정보는 선언에 적힌 것만
Type으로 읽을 수 있고, 실행 중인 객체의 타입 인수는 읽을 수 없습니다. invoke와newInstance는 대상이 던진 예외를InvocationTargetException으로 감쌉니다.getCause()로 원인을 꺼내 리플렉션 자체의 실패와 구분합니다.setAccessible(true)는 언어의 접근 검사를 끌 뿐, 모듈이 열지 않은 패키지는 열지 못합니다. 직접 만든 모듈이라면 필요한 패키지만opens로 열고,--add-opens는 임시방편으로만 씁니다.- 호출할 대상은 허용 목록이나 애노테이션으로 좁히고, 멤버를 찾는 작업은 시작할 때 한 번 해서 캐시해 둡니다.
연습 문제
MemberLookupScope의Child에inherited()를 재정의하는public메서드를 추가하면 출력의 첫 두 줄은 어떻게 바뀔까요?FieldWriteBypass의Member.age에는setAccessible(true)가 성공하는데ModuleWall의String.value에는 실패하는 이유를 모듈의 관점에서 설명하세요.String.value를 열 수 있는 방법과, 그 방법을 기본 설계로 삼으면 안 되는 이유도 적으세요.- 사용자가 보낸 문자열을 클래스 이름으로 쓰지 않고, 미리 정한 키
"public"과"private"로만 객체를 만드는 팩터리를 작성하세요."public"에는public무인수 생성자가 있는 클래스를,"private"에는String하나를 받는private생성자가 있는 클래스를 등록하고,private생성자는 팩터리를 만들 때trySetAccessible()로 열 수 있는지 확인합니다. 생성자 본문이 던진 예외는 원인을 꺼내 실패 결과로 돌려주고, 등록되지 않은 키는 거절합니다.
힌트
- 재정의한 메서드는
Child의 소스에 선언된 메서드입니다. 그리고getMethods()가 같은 시그니처의 메서드를 두 번 돌려줄지 생각해 보세요. - 앞의 예제들은 모두 클래스 경로에서 실행했습니다. 클래스 경로의 코드는 어느 모듈에 속하나요?
- 생성자는 팩터리를 만들 때 한 번만 찾아 두고, 요청이 오면 키로 생성기를 고르기만 하세요.
해설
- 첫 줄은 그대로
[inherited, open]이고, 둘째 줄은[hidden, inherited, open]이 됩니다. 재정의한inherited()는Child의 소스에 선언되었으므로 이제getDeclaredMethods()에도 나타납니다.getMethods()는 재정의된 부모의 메서드를 따로 돌려주지 않고Child의 것 하나만 돌려주므로 이름 목록은 그대로입니다. 달라진 것은 그 메서드의getDeclaringClass()가Parent가 아니라Child라는 점입니다. - 클래스 경로에서 실행한 코드는 모두 이름 없는 모듈에 속하고, 이름 없는 모듈은 모든 패키지를 모든 모듈에 열어 둡니다. 그래서
Member의private필드에는setAccessible이 성공합니다. 반면String은java.base모듈에 있고,java.base는java.lang을 공개만 하고 열지는 않으므로 실패합니다. 실행할 때--add-opens java.base/java.lang=ALL-UNNAMED를 주면 열 수 있지만, 이 옵션은 패키지를 클래스 경로의 모든 코드에 열어 버립니다. 또 JDK 내부 필드는 버전마다 바뀔 수 있습니다.String.value도 Java 9에서char[]가byte[]로 바뀌었습니다. 그래서 임시방편으로만 써야 합니다. - 아래 풀이는 지원하는 타입을
Map에 직접 등록하므로 사용자 입력이 임의의Class로 이어질 길이 없습니다. 생성자는 팩터리를 만들 때 한 번 찾아서 생성기 함수에 담아 두고, 요청이 오면 키로 생성기를 고르기만 합니다.
3번 풀이 코드와 실행 결과
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()));
}
}
static String describe(Result result) {
return switch (result) {
case Created created -> "created " + created.value().label();
case Failed failed -> "failed " + failed.failure();
};
}
public static void main(String[] args) {
var factory = new AllowedConstructorFactorySolution();
System.out.println(describe(factory.create("public", "ignored")));
System.out.println(describe(factory.create("private", "worker")));
System.out.println(describe(factory.create("private", "")));
System.out.println(describe(factory.create("other", "x")));
}
}실행 결과:
created public
created worker
failed ConstructionFailure[type=IllegalArgumentException, message=blank label]
failed ConstructionFailure[type=Rejected, message=component not allowed]첫 두 줄은 각각 public 생성자와, 팩터리를 만들 때 열어 둔 private 생성자로 객체를 만든 결과입니다. 세 번째 줄은 빈 문자열 때문에 생성자 본문이 IllegalArgumentException을 던졌고, instantiate가 InvocationTargetException에서 원인을 꺼내 실패 결과로 바꾼 것입니다. 네 번째 줄의 "other"는 등록된 키가 아니므로 리플렉션을 쓰기도 전에 거절됩니다. describe의 switch는 봉인된 Result의 두 경우를 모두 다루므로 default가 필요 없고, 경우가 하나 빠지면 컴파일러가 알려 줍니다.
PrivateComponent는 같은 파일의 중첩 클래스라 이름 없는 모듈에 함께 속하므로 trySetAccessible()이 성공합니다. 이 클래스가 패키지를 열지 않은 다른 이름 있는 모듈에 있었다면 false가 나오고, 풀이는 팩터리를 만드는 시점에 IllegalStateException을 던져 문제를 바로 드러냅니다. instantiate의 Object...는 리플렉션 API에 인수를 넘기기 위한 가변 인수일 뿐이고, 생성자의 시그니처가 맞는지는 생성기를 만들 때 getDeclaredConstructor(String.class)로 이미 확인했습니다.
다음 글
런타임 애노테이션 만들고 리플렉션으로 읽기에서는 메서드 이름 대신 메서드 선언 위에 붙인 애노테이션으로 라우트를 표시합니다. 그리고 이 글에서 익힌 getDeclaredMethods()와 invoke로 그 애노테이션을 읽어 라우트 표를 만듭니다.