리플렉션 — 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() | 이미 로드됨 | 필요 | 런타임 타입 확인 |
면접에서 "Class.forName()과 .class의 차이"를 물어보면, 핵심은 클래스 로드 시점이다. 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 컨테이너
import java.lang.reflect.*;
import java.util.*;
public class MiniContainer {
// 빈 저장소: 타입 → 인스턴스
private final Map<Class<?>, Object> beans = new HashMap<>();
// 1단계: 클래스 등록 및 인스턴스 생성
public void register(Class<?>... classes) throws Exception {
for (Class<?> clazz : classes) {
if (clazz.isAnnotationPresent(MyComponent.class)) {
// 기본 생성자로 인스턴스 생성
Constructor<?> ctor = clazz.getDeclaredConstructor();
ctor.setAccessible(true);
Object instance = ctor.newInstance();
beans.put(clazz, instance);
}
}
}
// 2단계: @MyInject 필드에 의존성 주입
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);
}
}
}
}
}
// 타입 또는 구현체 기준으로 빈 검색
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() vs .class vs getClass() 차이 |
getDeclaredXxx() | 해당 클래스에 선언된 모든 멤버(private 포함) | getXxx()는 public + 상속 포함 |
setAccessible(true) | 접근 제어자를 무시하고 private에 접근 | Java 9+ 모듈에서 제한됨 |
| 동적 프록시 | 런타임에 인터페이스 구현체 생성 | JDK Proxy는 인터페이스 필수, CGLIB은 상속 기반 |
| Spring DI | @Autowired 필드를 리플렉션으로 주입 | JPA 기본 생성자 필요 이유도 리플렉션 |
| 성능 | JIT 최적화 어려움, 일반 호출보다 느림 | 프레임워크는 캐싱으로 완화 |
| MethodHandle | Java 7+, JIT 최적화 가능한 메서드 호출 | 접근 검사가 생성 시 1회만 |
| VarHandle | Java 9+, 필드 접근 + atomic 연산 | AtomicXxx 대체 가능 |
다음 글에서는 직렬화와 JSON을 다룬다. 객체를 파일에 저장하거나 네트워크로 전송하는 방법이 궁금하다면 이어서 보자.