리플렉션 — Class 객체와 동적 프록시 이해하기
@Autowired를 붙이면 Spring이 알아서 객체를 넣어준다. 그런데 Spring은 내가 만든 클래스 내부를 어떻게 들여다보고, 어떤 필드에 뭘 넣어야 하는지 아는 걸까? 이 마법의 정체가 바로 리플렉션(Reflection)이다. 리플렉션을 알면 Spring, JPA, Jackson이 내부에서 뭘 하는지가 보이기 시작한다.
리플렉션이 뭔가
리플렉션은 런타임에 클래스의 구조(필드, 메서드, 생성자, 어노테이션 등)를 조사하고 조작하는 기술 이에요.
보통 자바 코드는 컴파일 타임에 타입이 정해집니다. new ArrayList<>()라고 쓰면 컴파일러가 ArrayList를 알고 있어야 해요. 하지만 리플렉션을 쓰면 클래스 이름을 문자열로 받아서 런타임에 인스턴스를 만들고, 메서드를 호출하고, 필드 값을 바꿀 수 있습니다.
"리플렉션은 거울"이라는 비유가 와닿아요. 프로그램이 자기 자신을 거울에 비추듯 들여다보는 거예요.
컴파일 타임 런타임
┌─────────────┐ ┌──────────────────────┐
│ new Foo() │ │ Class.forName("Foo") │
│ foo.bar() │ │ method.invoke(obj) │
│ → 타입 확정 │ │ → 문자열로 타입 결정 │
└─────────────┘ └──────────────────────┘
** 한 줄 정리:** 리플렉션 = 런타임에 .class 메타데이터를 읽어서 객체를 생성하고, 메서드를 호출하고, 필드를 조작하는 API입니다.
Class 객체 얻기 — 세 가지 방법
리플렉션의 시작점은 java.lang.Class 객체입니다. 모든 클래스는 JVM에 로드될 때 딱 하나의 Class 객체가 만들어져요.
// 1. Class.forName() — 문자열로 클래스 로드 (JDBC 드라이버 로딩에서 많이 봤을 것)
Class<?> clazz1 = Class.forName("java.util.ArrayList");
// 2. .class 리터럴 — 컴파일 타임에 타입을 알 때
Class<ArrayList> clazz2 = ArrayList.class;
// 3. getClass() — 이미 인스턴스가 있을 때
ArrayList<String> list = new ArrayList<>();
Class<?> clazz3 = list.getClass();
| 방법 | 클래스 로드 시점 | 인스턴스 필요 여부 | 주 사용처 |
|---|---|---|---|
Class.forName("FQCN") | 호출 시 로드 | 불필요 | 설정 파일에서 클래스명을 읽어 동적 로딩 |
타입.class | 이미 로드됨 | 불필요 | 제네릭 타입 토큰, 리터럴 비교 |
obj.getClass() | 이미 로드됨 | 필요 | 런타임 타입 확인 |
핵심 차이는 ** 클래스 로드 시점 **이에요. forName()은 호출 시점에 클래스를 로드하면서 static 초기화 블록을 실행하지만, .class는 이미 로드된 클래스의 메타데이터를 가져옵니다.
생성자, 메서드, 필드 접근
Class 객체를 얻었으면 그 안의 모든 구성 요소에 접근할 수 있습니다.
생성자 — getDeclaredConstructor()
Class<?> clazz = Class.forName("com.example.User");
// 기본 생성자로 인스턴스 생성
Constructor<?> constructor = clazz.getDeclaredConstructor();
Object user = constructor.newInstance();
// 매개변수 있는 생성자
Constructor<?> paramCtor = clazz.getDeclaredConstructor(String.class, int.class);
Object user2 = paramCtor.newInstance("홍길동", 25);
메서드 — getMethod(), getDeclaredMethod()
// public 메서드 (상속 포함)
Method getName = clazz.getMethod("getName");
Object result = getName.invoke(user); // user.getName() 호출과 동일
// private 메서드 포함 (해당 클래스에 선언된 것만)
Method secret = clazz.getDeclaredMethod("secretMethod");
secret.setAccessible(true); // private 접근 허용
secret.invoke(user);
필드 — getField(), getDeclaredField()
// private 필드 접근
Field nameField = clazz.getDeclaredField("name");
nameField.setAccessible(true); // 접근 제어자 무시
// 값 읽기
String name = (String) nameField.get(user);
// 값 쓰기
nameField.set(user, "이순신");
getXxx() vs getDeclaredXxx() 차이:
| 메서드 | 접근 범위 | 상속 포함 |
|---|---|---|
getMethod() | public만 | O (상위 클래스 포함) |
getDeclaredMethod() | 모든 접근 제어자 | X (해당 클래스만) |
getField() | public만 | O |
getDeclaredField() | 모든 접근 제어자 | X |
여기서 헷갈리기 쉬운 부분이 있어요. getDeclaredXxx()는 "이 클래스에 선언된 것만, 대신 private도 포함"이고, getXxx()는 "상속 포함, 대신 public만"입니다.
private 필드/메서드 접근 — 왜 가능하고, 왜 위험한가
setAccessible(true)를 호출하면 Java의 접근 제어자(private, protected)를 무시할 수 있어요. 이게 가능한 이유는 ** 접근 제어자는 컴파일 타임 제약이지, JVM 수준의 보안 장벽이 아니기 때문 **입니다.
public class Secret {
private String password = "1234"; // 외부에서 접근 불가... 라고 생각했지만
}
// 리플렉션으로 뚫기
Secret secret = new Secret();
Field pwField = Secret.class.getDeclaredField("password");
pwField.setAccessible(true);
String pw = (String) pwField.get(secret); // "1234" 가져올 수 있다
왜 위험한가
- ** 캡슐화 파괴** — 클래스 설계자가 숨긴 내부 구현에 의존하면, 해당 클래스가 변경될 때 같이 깨져요.
- ** 타입 안전성 상실** — 컴파일러가 타입 체크를 못 하므로 런타임에
ClassCastException이 터질 수 있습니다. - ** 보안 이슈** — 민감한 데이터에 접근 가능해요. (Java 9+ 모듈 시스템에서 일부 제한이 강화되었습니다.)
Java 9+ 모듈 시스템의 제약
Java 9부터 모듈 시스템(JPMS)이 도입되면서, 다른 모듈의 내부 패키지에 대한 리플렉션 접근이 기본적으로 차단됩니다. --add-opens JVM 옵션으로 풀 수 있지만, 이것 자체가 "설계를 깨고 있다"는 신호예요.
# 모듈 시스템에서 리플렉션 접근을 열어주는 옵션
java --add-opens java.base/java.lang=ALL-UNNAMED -jar app.jar
동적 프록시 — java.lang.reflect.Proxy
동적 프록시는 ** 런타임에 인터페이스의 구현체를 자동으로 생성 **하는 기술입니다. Spring AOP의 핵심 메커니즘이기도 해요.
InvocationHandler
모든 메서드 호출을 가로채는 핸들러를 만들어 봅니다.
// 대상 인터페이스
public interface UserService {
String findUser(Long id);
void deleteUser(Long id);
}
// 실제 구현체
public class UserServiceImpl implements UserService {
@Override
public String findUser(Long id) {
return "User-" + id;
}
@Override
public void deleteUser(Long id) {
System.out.println(id + "번 유저 삭제");
}
}
// InvocationHandler — 모든 메서드 호출을 가로채서 로깅 추가
public class LoggingHandler implements InvocationHandler {
private final Object target; // 실제 객체
public LoggingHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 메서드 실행 전 로깅
System.out.println("[LOG] " + method.getName() + " 호출, 인자: " + Arrays.toString(args));
long start = System.nanoTime();
Object result = method.invoke(target, args); // 실제 메서드 실행
long elapsed = System.nanoTime() - start;
// 메서드 실행 후 로깅
System.out.println("[LOG] " + method.getName() + " 완료, 소요: " + elapsed + "ns");
return result;
}
}
// 프록시 생성 및 사용
UserService real = new UserServiceImpl();
UserService proxy = (UserService) Proxy.newProxyInstance(
UserService.class.getClassLoader(), // 클래스 로더
new Class[]{UserService.class}, // 구현할 인터페이스 배열
new LoggingHandler(real) // 호출 핸들러
);
proxy.findUser(1L);
// [LOG] findUser 호출, 인자: [1]
// [LOG] findUser 완료, 소요: 12345ns
동적 프록시의 동작 흐름
클라이언트 → proxy.findUser(1L)
↓
Proxy 객체 (런타임 생성)
↓
InvocationHandler.invoke()
↓
┌── 전처리 (로깅, 트랜잭션 시작 등) ──┐
│ method.invoke(target, args) │ ← 실제 메서드 실행
└── 후처리 (로깅, 트랜잭션 커밋 등) ──┘
↓
결과 반환
JDK Proxy vs CGLIB
| 구분 | JDK 동적 프록시 | CGLIB |
|---|---|---|
| 방식 | 인터페이스 기반 | 클래스 상속(바이트코드 조작) |
| 제약 | 인터페이스 필수 | final 클래스 불가 |
| 속도 | 상대적으로 느림 | 상대적으로 빠름 |
| Spring 기본값 | Spring MVC (과거) | Spring Boot 2.0+ 기본값 |
Spring Boot 2.0부터는 proxyTargetClass=true가 기본이라 CGLIB을 씁니다. 인터페이스가 없어도 프록시가 만들어지는 이유가 여기에 있어요.
Spring이 리플렉션을 쓰는 이유
Spring 프레임워크는 리플렉션의 헤비 유저입니다. 왜 그럴까요?
1. 의존성 주입 (DI)
@Autowired가 붙은 필드를 찾아서 값을 넣어주는 과정이 리플렉션이에요.
@Service
public class OrderService {
@Autowired
private UserRepository userRepository; // Spring이 리플렉션으로 값을 주입
}
Spring의 내부 동작을 간략히 보면 이렇습니다:
// Spring이 내부적으로 하는 일 (단순화)
for (Field field : clazz.getDeclaredFields()) {
if (field.isAnnotationPresent(Autowired.class)) {
field.setAccessible(true);
Object bean = beanFactory.getBean(field.getType()); // 컨테이너에서 빈 조회
field.set(instance, bean); // 리플렉션으로 필드에 주입
}
}
2. AOP 프록시
@Transactional, @Cacheable 같은 어노테이션이 붙은 메서드를 프록시로 감싸서 부가 기능을 추가합니다. 앞서 본 동적 프록시가 바로 이 원리예요.
3. 컴포넌트 스캔
@Component, @Service 등의 어노테이션이 붙은 클래스를 클래스패스에서 찾아내는 것도 리플렉션(+ ASM 바이트코드 분석)입니다.
4. Jackson, JPA 등
- Jackson:
@JsonProperty어노테이션을 읽고, 기본 생성자로 객체를 만든 뒤 필드에 값을 주입해요. - JPA/Hibernate: 엔티티 클래스의 필드를 읽어 SQL을 생성합니다. 기본 생성자가 필요한 이유도 리플렉션으로
newInstance()를 호출하기 때문이에요.
JPA 엔티티에 기본 생성자가 필요한 이유도 리플렉션이다. Hibernate가
Constructor.newInstance()로 객체를 생성하기 때문이다.
주의할 점
리플렉션은 강력하지만 대가가 있습니다.
1. 성능 저하
- JIT 컴파일러가 리플렉션 호출을 최적화하기 어렵습니다.
Method.invoke()는 일반 메서드 호출보다 ** 수십 배 느릴 수 있어요 **.- 다만 프레임워크들은 리플렉션 결과를 캐싱해서 성능 영향을 줄입니다.
// 일반 호출 — JIT가 인라인 가능
user.getName();
// 리플렉션 — JIT 최적화 어려움
Method m = User.class.getMethod("getName");
m.invoke(user); // invoke 내부에서 네이티브 코드 호출
2. 컴파일 타임 타입 안전성 상실
- 메서드명을 문자열로 쓰므로 오타를 컴파일러가 잡아주지 못합니다.
- 리팩토링 도구(IDE의 Rename 등)가 문자열 안의 이름까지 바꿔주지 않아요.
// 오타가 있어도 컴파일은 통과한다
Method m = clazz.getMethod("getNamee"); // 런타임에 NoSuchMethodException
3. 캡슐화 파괴
setAccessible(true)로 private 멤버에 접근하면 클래스의 불변 조건(invariant)을 깨뜨릴 수 있어요.- 내부 구현에 의존하는 코드는 라이브러리 업데이트 시 깨지기 쉽습니다.
4. 보안
- SecurityManager가 활성화된 환경에서는
setAccessible()호출이 차단될 수 있습니다. - GraalVM Native Image에서는 리플렉션 대상을 미리 등록해야 해요.
리플렉션 대안 — MethodHandle, VarHandle
리플렉션의 성능 문제를 해결하기 위해 Java 7과 Java 9에서 새로운 API가 도입되었습니다.
MethodHandle (Java 7+)
java.lang.invoke.MethodHandle은 리플렉션보다 JVM에 가까운 저수준 API입니다. JIT 컴파일러가 최적화할 수 있어 반복 호출 시 성능이 좋아요.
// MethodHandle로 메서드 호출
MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodHandle handle = lookup.findVirtual(
String.class, "substring",
MethodType.methodType(String.class, int.class)
);
String result = (String) handle.invoke("Hello World", 6); // "World"
VarHandle (Java 9+)
java.lang.invoke.VarHandle은 필드 접근에 특화된 API예요. Field.get()/set() 대신 사용하며, CAS 같은 atomic 연산도 지원해서 AtomicXxx 클래스를 대체할 수 있습니다.
비교
| 구분 | Reflection | MethodHandle | VarHandle |
|---|---|---|---|
| 도입 버전 | Java 1.1 | Java 7 | Java 9 |
| JIT 최적화 | 어려움 | 가능 | 가능 |
| 주 용도 | 프레임워크, 도구 | 메서드 호출 | 필드 접근, atomic 연산 |
| 접근 검사 시점 | 매 호출마다 | Handle 생성 시 1회 | Handle 생성 시 1회 |
| 사용 난이도 | 쉬움 | 보통 | 보통 |
실전 예제 — 간단한 DI 컨테이너 만들기
리플렉션으로 Spring의 @Autowired가 어떻게 동작하는지 직접 만들어 볼게요.
커스텀 어노테이션 정의
import java.lang.annotation.*;
// 빈으로 등록할 클래스에 붙이는 어노테이션
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface MyComponent {}
// 의존성 주입 대상 필드에 붙이는 어노테이션
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface MyInject {}
간단한 DI 컨테이너
먼저 빈 저장소와 등록 메서드를 만듭니다. @MyComponent가 붙은 클래스를 리플렉션으로 인스턴스화해요.
public class MiniContainer {
private final Map<Class<?>, Object> beans = new HashMap<>();
public void register(Class<?>... classes) throws Exception {
for (Class<?> clazz : classes) {
if (clazz.isAnnotationPresent(MyComponent.class)) {
Constructor<?> ctor = clazz.getDeclaredConstructor();
ctor.setAccessible(true);
beans.put(clazz, ctor.newInstance());
}
}
}
다음으로 @MyInject가 붙은 필드를 찾아서 의존성을 주입합니다. setAccessible(true)로 private 필드에도 접근해요.
public void inject() throws Exception {
for (Object bean : beans.values()) {
for (Field field : bean.getClass().getDeclaredFields()) {
if (field.isAnnotationPresent(MyInject.class)) {
field.setAccessible(true);
Object dependency = findBean(field.getType());
if (dependency != null) {
field.set(bean, dependency); // 리플렉션으로 필드에 주입
}
}
}
}
}
마지막으로 타입 기준 빈 검색 메서드입니다. isAssignableFrom으로 인터페이스 기반 주입도 지원해요.
private Object findBean(Class<?> type) {
for (Map.Entry<Class<?>, Object> entry : beans.entrySet()) {
if (type.isAssignableFrom(entry.getKey())) return entry.getValue();
}
return null;
}
@SuppressWarnings("unchecked")
public <T> T getBean(Class<T> type) { return (T) findBean(type); }
}
사용 예시
@MyComponent
public class UserRepository {
public String findById(Long id) {
return "User-" + id;
}
}
@MyComponent
public class UserService {
@MyInject
private UserRepository userRepository; // 컨테이너가 자동 주입
public String getUser(Long id) {
return userRepository.findById(id);
}
}
// 컨테이너 실행
public class Main {
public static void main(String[] args) throws Exception {
MiniContainer container = new MiniContainer();
// 빈 등록
container.register(UserRepository.class, UserService.class);
// 의존성 주입
container.inject();
// 사용
UserService service = container.getBean(UserService.class);
System.out.println(service.getUser(1L)); // "User-1"
}
}
이 35줄짜리 컨테이너가 Spring DI의 핵심 원리입니다. 물론 실제 Spring은 빈 스코프, 순환 참조 감지, 프록시 생성 등 훨씬 복잡하지만, 뼈대는 이것과 같아요.
TIP: 전체 실행 가능한 코드는 examples/19에서 확인할 수 있다. MiniContainer에 인터페이스 기반 주입과 순환 참조 감지를 추가한 버전도 포함되어 있다.
정리
| 개념 | 핵심 |
|---|---|
Class 객체 | 모든 클래스는 JVM에 딱 하나의 Class 객체를 가진다. forName()은 호출 시 로드, .class는 이미 로드된 메타데이터 |
getDeclaredXxx() | 해당 클래스에 선언된 모든 멤버(private 포함). getXxx()는 public + 상속 포함 |
setAccessible(true) | 접근 제어자를 무시하고 private에 접근. Java 9+ 모듈에서 제한됨 |
| 동적 프록시 | 런타임에 인터페이스 구현체 생성. JDK Proxy는 인터페이스 필수, CGLIB은 상속 기반 |
| Spring DI | @Autowired 필드를 리플렉션으로 주입. JPA 기본 생성자 필요 이유도 리플렉션 |
| 성능 | JIT 최적화 어려움. Method.invoke()는 일반 호출보다 수십 배 느릴 수 있음. 프레임워크는 캐싱으로 완화 |
| MethodHandle | Java 7+, JIT 최적화 가능. 접근 검사가 Handle 생성 시 1회만 수행 |
| VarHandle | Java 9+, 필드 접근 + atomic 연산. AtomicXxx 대체 가능 |
다음 글에서는 ** 직렬화와 JSON**을 다룹니다. 객체를 파일에 저장하거나 네트워크로 전송하는 방법이 궁금하시다면 이어서 봐주세요.