Java Reflection은 어떻게 프레임워크를 만드는가
런타임 클래스 조작의 원리부터 Dynamic Proxy, Annotation 처리, 성능 최적화까지 — Spring·JPA가 Reflection 위에서 동작하는 방식을 추적한다.
- 01 Java String은 왜 불변인가
- 02 Java 수 타입의 설계 철학 — 왜 세 가지가 필요한가
- 03 Java 배열과 Arrays 클래스, 무엇을 알아야 하는가
- 04 Java Generics는 왜 타입을 지운가
- 05 Java Enum은 단순한 상수 묶음이 아니다
- 06 Java 예외는 어떻게 설계해야 하는가
- 07 Java Collections Framework, 하나의 철학으로 읽는다
- 08 Java 함수형 인터페이스는 왜 이렇게 설계됐을까
- 09 Modern Java의 불변 설계 — Record, Switch, Sealed Class
- 10 Java Util API의 공통 철학: 비교, 흐름, 부재, 패턴
- 11 Java 8 Time API는 왜 기존 Date를 버렸는가
- 12 Java IO는 왜 이렇게 복잡한가
- 13 Java Reflection은 어떻게 프레임워크를 만드는가
- 14 Java 동시성은 왜 이렇게 설계됐을까
Spring의 @Autowired는 어떻게 private 필드에 의존성을 주입하는가? JPA는 어떻게 @Entity 클래스를 SQL INSERT 문으로 변환하는가? JUnit은 어떻게 @Test 붙은 메서드만 골라 실행하는가? 이 질문들의 답은 모두 같은 곳을 가리킨다 — Reflection. 그렇다면 Reflection은 단순한 “런타임 클래스 조회” 도구인가, 아니면 Java 프레임워크 설계의 근간인가?
Reflection의 출발점: Class 객체
JVM은 클래스를 로딩할 때 해당 클래스를 표현하는 Class<T> 객체를 정확히 하나 생성한다. 이 객체가 Reflection의 입구다. 얻는 방법은 세 가지지만 모두 동일한 인스턴스를 반환한다.
Class<Person> c1 = Person.class; // 컴파일타임 리터럴
Class<?> c2 = new Person().getClass(); // 런타임 인스턴스에서
Class<?> c3 = Class.forName("com.example.Person"); // 문자열로 동적 로딩
System.out.println(c1 == c2); // true — 같은 객체
System.out.println(c2 == c3); // true
Class.forName()이 특히 중요하다. 컴파일 시점에 클래스명을 알 수 없어도 런타임에 문자열 하나로 로딩할 수 있다. 플러그인 시스템, 설정 파일 기반 DI 컨테이너, JDBC 드라이버 등록(Class.forName("com.mysql.cj.jdbc.Driver"))이 이 메커니즘을 사용한다.
Class 객체로부터 getDeclaredFields(), getDeclaredMethods(), getDeclaredConstructors()를 호출하면 접근 제어자와 무관하게 클래스의 모든 멤버를 조회할 수 있다. setAccessible(true) 한 줄이 private 장벽을 낮춘다. 단 이 호출은 SecurityManager가 허용해야 하며, Java 16 이후 모듈 시스템에서는 opens 선언이 필요한 경우가 있다.
Annotation: 코드에 붙이는 처리 가능한 메타데이터
Annotation은 Reflection이 읽어내는 메타데이터다. 주석(// @author Alice)과 달리 도구가 처리할 수 있다. 핵심 설정은 @Retention이다.
@Retention(RetentionPolicy.RUNTIME) // 런타임까지 보존 — Reflection으로 읽기 가능
@Target(ElementType.FIELD) // 필드에만 사용 가능
@interface Inject {}
RetentionPolicy.SOURCE로 설정하면 컴파일 후 제거된다(@Override, @SuppressWarnings). CLASS는 바이트코드에는 남지만 런타임에는 없다. RUNTIME만이 Reflection으로 접근 가능하다. Spring의 @Autowired, JPA의 @Column, JUnit의 @Test가 전부 RUNTIME으로 선언된 이유다.
Annotation 요소에는 기본 타입, String, Class, Enum, 다른 Annotation, 그리고 이들의 배열만 허용된다. 일반 클래스나 제네릭은 올 수 없다. value()라는 이름의 단일 요소는 이름 없이 사용할 수 있다(@Version("1.0")).
Reflection으로 Annotation을 읽는 패턴은 다음과 같다.
for (Field field : clazz.getDeclaredFields()) {
if (field.isAnnotationPresent(Inject.class)) {
field.setAccessible(true);
Object dependency = container.resolve(field.getType());
field.set(targetInstance, dependency);
}
}
이 패턴이 DI 컨테이너의 필드 주입 로직 전부다. @Column(name = "user_name", nullable = false)처럼 여러 요소를 가진 Annotation은 field.getAnnotation(Column.class).name()으로 꺼낸다.
Dynamic Proxy: 런타임에 생성되는 가짜 객체
Reflection의 가장 강력한 응용은 JDK Dynamic Proxy다. 인터페이스를 구현하는 가짜 객체를 런타임에 생성하고, 모든 메서드 호출을 InvocationHandler 하나로 가로챈다.
Calculator proxy = (Calculator) Proxy.newProxyInstance(
Calculator.class.getClassLoader(),
new Class<?>[]{ Calculator.class },
(p, method, args) -> {
System.out.println("[BEFORE] " + method.getName()
+ " " + Arrays.toString(args));
Object result = method.invoke(realCalculator, args);
System.out.println("[AFTER] result = " + result);
return result;
}
);
proxy.add(10, 5);
// [BEFORE] add [10, 5]
// [AFTER] result = 15
Spring AOP의 @Transactional이 동작하는 방식이 정확히 이것이다. 컨테이너는 빈을 프록시로 감싸고, 메서드 호출 앞뒤에 트랜잭션 시작/커밋 로직을 끼워 넣는다. 로깅, 캐싱, 권한 체크 — 횡단 관심사(cross-cutting concern)를 원본 코드에 손대지 않고 주입한다.
같은 원리로 캐싱 프록시도 만들 수 있다. InvocationHandler 내부에 Map<String, Object> 하나를 두고, 메서드명과 인자를 키로 결과를 저장하면 method.invoke()를 건너뛰는 간단한 메모이제이션이 된다.
성능: Reflection의 비용과 완화 방법
Reflection이 직접 호출보다 느린 이유는 두 가지다. 첫째, getMethod()나 getDeclaredField() 호출마다 클래스 메타데이터를 탐색한다. 둘째, method.invoke() 내부에서 접근 제어 검사와 박싱/언박싱 오버헤드가 발생한다.
캐싱이 가장 효과적인 완화책이다.
// Bad: 매 반복마다 메타데이터 탐색
for (int i = 0; i < 100_000; i++) {
Method m = clazz.getMethod("process");
m.invoke(obj);
}
// Good: 한 번 조회, setAccessible도 한 번, 이후 재사용
Method m = clazz.getMethod("process");
m.setAccessible(true);
for (int i = 0; i < 100_000; i++) {
m.invoke(obj);
}
실제 프레임워크는 ConcurrentHashMap<String, Method>를 두고 클래스명 + 메서드명을 키로 캐싱한다. 첫 호출에만 탐색 비용을 지불하고 이후에는 캐시에서 꺼낸다.
Java 7부터 제공되는 MethodHandle은 한 단계 더 나아간다. invokeExact는 JIT 컴파일러가 직접 호출에 가깝게 최적화할 수 있어 Method.invoke()보다 유의미하게 빠르다. 성능이 중요한 경로에서 Reflection을 반드시 써야 한다면 MethodHandle을 고려할 만하다. 벤치마크 결과는 대체로 직접 호출 > MethodHandle >> Reflection 순서를 따른다.
Reflection은 캡슐화를 파괴한다. private 필드에 외부에서 접근할 수 있다는 것은 컴파일 타임 타입 검증을 런타임 예외로 미룬다는 뜻이다. 잘못된 필드명은 NoSuchFieldException, 타입 불일치는 IllegalArgumentException으로 드러난다. 사용자 입력을 그대로 Class.forName()에 넘기면 임의 클래스를 로딩하는 보안 취약점이 된다. Reflection은 프레임워크 내부나 테스트 유틸리티에서만 사용하라는 권장이 이 이유에서 나온다. 일반 애플리케이션 코드에서는 인터페이스, 제네릭, 람다로 대부분 대체할 수 있다.
정리
- Reflection의 입구는
Class<T>객체다..class,getClass(),Class.forName()세 경로가 동일한 객체를 반환한다. @Retention(RUNTIME)Annotation은 Reflection으로 읽힌다. Spring, JPA, JUnit이 동작하는 기반이 여기에 있다.- JDK Dynamic Proxy는 인터페이스를 구현하는 가짜 객체를 런타임에 생성해 횡단 관심사를 주입한다. Spring AOP
@Transactional의 실제 구현이다. Method·Field객체를 캐싱하고setAccessible을 한 번만 호출하면 오버헤드를 크게 줄일 수 있다. 성능 임계 경로라면MethodHandle을 검토하라.- 캡슐화 파괴와 런타임 오류 위험 때문에 Reflection은 프레임워크 경계 안에서만 사용하는 것이 원칙이다.
Reflection은 Java의 “탈출구”다. 타입 시스템이 닫아놓은 문을 런타임에 여는 열쇠 — 하지만 그 문 뒤에 무엇이 있는지 알고 열어야 한다.