@Component
public class AService {
private final ARepository repository;
public AService(ARepository repository) {
this.repository = repository;
}
}
@Component 하나 붙였을 뿐인데 Spring은 AService를 찾아서 빈으로 만들고 ARepository까지 주입한다.
Spring은 이 클래스를 어떻게 찾았을까.
JVM은 .class 파일을 로드할 때, 해당 클래스의 메타데이터를 Class<?> 객체에 담아 메모리에 올려둔다. 클래스 이름, 메서드 목록, 필드 목록, 생성자, 접근 제어자 — 이 모든 정보가 여기 들어있다.
리플렉션은 이 Class<?> 객체를 통해 런타임에 클래스 구조를 읽고 조작하는 기능이다.
Class<?> clazz = Class.forName("com.example.AService"); // 이름만으로 클래스 로드
clazz.getDeclaredMethods(); // 메서드 목록
clazz.getDeclaredFields(); // 필드 목록
clazz.getDeclaredConstructors(); // 생성자 목록
Class.forName()에 패키지 경로를 포함한 클래스 이름만 넘기면, 그 클래스가 현재 코드에 import되어 있지 않아도 런타임에 불러올 수 있다.
리플렉션은 메타데이터 조회에서 그치지 않는다. 인스턴스 생성, 메서드 호출, 필드 값 변경까지 가능하다.
Constructor<?> constructor = clazz.getDeclaredConstructor();
Object instance = constructor.newInstance(); // 인스턴스 생성
Method method = clazz.getDeclaredMethod("transfer");
method.invoke(instance); // 메서드 호출
private으로 선언된 메서드나 필드도 접근할 수 있다. setAccessible(true)를 호출하면 접근 제어자를 우회할 수 있기 때문이다.
Field field = clazz.getDeclaredField("secret");
field.setAccessible(true); // private 우회
field.get(instance); // 값 읽기
이게 가능한 이유는 접근 제어자가 컴파일 타임의 규칙이기 때문이다. 런타임에는 JVM이 setAccessible(true) 플래그를 보고 접근 제어 검사를 건너뛴다.
런타임에 클래스 구조를 읽을 수 있다는 특성 덕분에, 리플렉션은 프레임워크가 사용자 코드를 동적으로 처리해야 할 때 핵심 수단이 된다. Spring이 대표적이다.
Spring 프레임워크 코드는 Pivotal 팀이 작성해서 spring-core.jar에 컴파일해 넣은 것이다. 그 시점에 AService, ARepository 같은 클래스는 존재하지 않았다. 그래서 Spring 소스 어디에도 이런 코드는 없다.
new AService(new ARepository()); // Spring이 이렇게 쓸 수 없다
대신 Spring은 이렇게 한다. 애플리케이션이 시작될 때 @Component가 붙은 클래스를 전부 스캔하고, 클래스 이름(문자열)만 갖고 리플렉션으로 인스턴스를 만든다.
// Spring 내부 동작 (의사코드)
String className = "com.example.AService"; // 스캔으로 찾은 이름
Class<?> clazz = Class.forName(className);
Object instance = clazz.getDeclaredConstructor(ARepository.class)
.newInstance(aRepositoryInstance);
의존성 주입도 마찬가지다. AService의 생성자 파라미터를 리플렉션으로 분석하면 ARepository 타입이 필요하다는 걸 알 수 있다. 같은 타입의 빈이 여러 개라면 파라미터 이름까지 비교한다.
Constructor<?> constructor = AService.class.getDeclaredConstructors()[0];
Class<?>[] paramTypes = constructor.getParameterTypes(); // 타입 확인
Parameter[] params = constructor.getParameters(); // 이름 확인
ApplicationContext는 결국 이렇게 만들어진 인스턴스들을 담아두는 Map이다.
Map<String, Object> beans = new HashMap<>();
// "aService" → AService 인스턴스
// "aRepository" → ARepository 인스턴스
@Transactional이 붙은 메서드를 Spring이 감지하는 것도 리플렉션이다. 빈을 등록하는 시점에 각 메서드를 훑어보고 어노테이션이 있는지 확인한다.
for (Method method : AService.class.getDeclaredMethods()) {
if (method.isAnnotationPresent(Transactional.class)) {
// 이 메서드는 트랜잭션 처리가 필요하다
}
}
@Transactional이 감지되면 Spring은 원본 AService 대신 프록시 객체를 만들어 빈으로 등록한다. 프록시는 메서드 호출을 가로채서 트랜잭션 시작과 커밋/롤백을 앞뒤로 붙인다.
class AService$$Proxy extends AService {
@Override
public void transfer() {
트랜잭션 시작();
try {
super.transfer(); // 원본 메서드 실행
커밋();
} catch (Exception e) {
롤백();
}
}
}
@Component, @Autowired, @Transactional — 어노테이션 기반으로 동작하는 Spring의 모든 기능 뒤에는 리플렉션으로 어노테이션을 읽는 과정이 있다.
우리가 리플렉션을 직접 쓸 일은 거의 없다. @Component, @Transactional 같은 어노테이션을 붙이는 것 자체가 리플렉션을 간접적으로 쓰는 것이다.
그러니 이 원리를 알고 있으면 코드를 짤 때 달라지는 게 있다. @Transactional은 프록시 객체를 생성하는 비용이 따라온다. 트랜잭션이 필요 없는 조회 메서드에 습관적으로 붙이거나, 내부 메서드 호출에 기대하는 건 성능과 동작 모두에서 문제가 될 수 있다. Spring이 리플렉션을 시작 시점에 한 번에 몰아서 처리하는 것도 이 비용을 매 요청마다 치르지 않기 위해서다.
또 하나, 리플렉션 기반으로 동작하는 기능들은 잘못된 설정이 컴파일 타임에 잡히지 않는다. 빈 등록 실패나 의존성 주입 오류는 런타임, 그것도 애플리케이션이 뜨는 시점에야 터진다. 설정 오류가 배포 직후에 드러나는 이유가 여기 있다.