Reflection

mongBrown·2026년 5월 2일

Spring은 내 클래스를 어떻게 알았을까

@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 DI

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 인스턴스

AOP — @Transactional

@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이 리플렉션을 시작 시점에 한 번에 몰아서 처리하는 것도 이 비용을 매 요청마다 치르지 않기 위해서다.

또 하나, 리플렉션 기반으로 동작하는 기능들은 잘못된 설정이 컴파일 타임에 잡히지 않는다. 빈 등록 실패나 의존성 주입 오류는 런타임, 그것도 애플리케이션이 뜨는 시점에야 터진다. 설정 오류가 배포 직후에 드러나는 이유가 여기 있다.

profile
화이팅!

0개의 댓글