[Java] 리플렉션이란 무엇인가

yjkim·5일 전

리플렉션이란?

자바 리플렉션을 검색해보면 "객체가 원본이고, JVM 메모리에 로드된 클래스 정보가 거울에 비친 상(reflection)이다" 같은 비유를 자주 보게 됩니다.

틀린 설명은 아닙니다. 다만 저는 이걸로 "그래서 그게 왜 필요한데?"라는 의문이 풀리지 않았습니다. 비유는 리플렉션이 무엇인지는 알려주지만, 왜 존재하는지는 알려주지 않으니까요.

그래서 이해 자체를 목표로, 제 방식대로 다시 정리해보기로 했습니다.

한 줄 정의

컴파일 시점에 어떤 클래스인지 몰라도, 런타임에 클래스·필드·메서드를 알아내고 조작하는 기능.

핵심은 "컴파일 시점에 몰라도"입니다. 이 조건이 왜 중요한지가 이 글의 전부입니다.

컴파일 타임에 아는 코드 vs 모르는 코드

우리가 평소에 쓰는 코드입니다.

UserRepository repo = new UserRepository();
repo.findName();

컴파일러는 이 시점에 "이건 UserRepository다"라는 사실을 이미 알고 있습니다. 그래서 findName()이 실제로 존재하는지도 컴파일 시점에 검증됩니다. 없는 메서드를 호출하면 컴파일 자체가 되지 않습니다.

같은 일을 리플렉션으로 하면 이렇게 됩니다.

Class<?> clazz = Class.forName(className);
Object obj = clazz.getDeclaredConstructor().newInstance();
Method m = clazz.getMethod("findName");
m.invoke(obj);

훨씬 장황하고, 타입 안전성도 없고, 오타가 나도 컴파일러가 잡아주지 않습니다.

여기서 눈여겨볼 건 classNameString이라는 점입니다. 클래스 이름이 문자열이라는 건, 그 값을 설정 파일에서 읽어오든 스캔 결과에서 가져오든 상관없다는 뜻입니다. 즉 코드를 작성하는 시점에 그 클래스가 세상에 존재하지 않아도 된다는 겁니다.

이렇게 위험한 기능을 대체 왜 쓰는걸까요?

익숙한 Spring 코드를 봅시다.

@Component
public class UserRepository {
    public String findName() { return "hello"; }
}

@Component
public class UserService {
    @Autowired
    private UserRepository userRepository;   // ← 여기를 꽂아줘야 함

    public void print() {
        System.out.println(userRepository.findName());
    }
}

만약 리플렉션이 없다면 이 코드는 어떻게 될까요?

컴파일은 됩니다. @Component@Autowired도 어노테이션일 뿐이라 컴파일러는 아무 불평도 하지 않습니다. 실행도 됩니다. 대신 이렇게 터집니다.

NullPointerException: Cannot invoke "UserRepository.findName()"
                      because "this.userRepository" is null

당연합니다. 우리는 userRepository에 값을 넣는 코드를 어디에도 쓴 적이 없으니까요. new UserRepository()를 호출한 줄이 우리 프로젝트에 단 한 줄도 없습니다. @Autowired라는 글자만 적어놨을 뿐입니다.

그런데 이 코드를 Spring 위에서 돌리면 콘솔에 "hello"가 찍힙니다.

어떻게 가능한걸까?

@Autowired는 마법이 걸린 키워드가 아닙니다. 컴파일 결과물에 남는 메타데이터 딱지입니다. 딱지는 그 자체로 아무 일도 하지 않습니다. 누군가 읽어야 의미가 생깁니다.

그 "누군가"가 Spring이고, 읽는 도구가 리플렉션입니다.

애플리케이션이 뜰 때 Spring은 이 일을 순서대로 합니다.

  1. 클래스패스를 뒤져서 @Component가 붙은 클래스를 찾는다 → UserRepository, UserService 발견
  2. 찾은 클래스를 리플렉션으로 생성한다 → clazz.getDeclaredConstructor().newInstance()
  3. 각 객체의 필드를 훑어서 @Autowired가 붙은 걸 찾는다 → UserService.userRepository 발견
  4. 필드 타입(UserRepository)에 맞는 객체를 찾아서 꽂는다

코드로 줄여보면 이 정도입니다. 먼저 1~2번, @Component를 읽고 객체를 만드는 부분입니다.

Map<Class<?>, Object> container = new HashMap<>();

for (Class<?> clazz : classes) {                       // 클래스패스에서 긁어온 목록
    if (clazz.isAnnotationPresent(Component.class)) {  // ← @Component 딱지 확인
        Object bean = clazz.getDeclaredConstructor().newInstance();
        container.put(clazz, bean);                    // 타입 → 객체로 보관
    }
}

이 시점에 UserRepositoryUserService 객체가 만들어져 컨테이너에 들어갑니다. 다만 UserService의 필드는 아직 null입니다.

이어서 3~4번, @Autowired를 읽고 값을 꽂는 부분입니다.

for (Object bean : container.values()) {
    for (Field field : bean.getClass().getDeclaredFields()) {
        if (field.isAnnotationPresent(Autowired.class)) {    // ← @Autowired 딱지 확인
            field.set(bean, container.get(field.getType())); // ← 타입으로 찾아서 꽂는다
        }
    }
}

이제 꺼내 쓰면 됩니다.

UserService service = (UserService) container.get(UserService.class);
service.print();   // "hello"

생성과 주입이 두 단계로 나뉜 이유가 있습니다. UserService에 값을 꽂으려면 UserRepository 객체가 이미 만들어져 있어야 하기 때문입니다. 그래서 일단 전부 만들어놓고, 그다음에 서로 연결합니다. Spring도 같은 순서로 움직입니다.

우리가 쓰지 않은 new UserRepository()를, 우리가 붙여둔 딱지를 보고 Spring이 대신 실행해준 겁니다.

여기서 어노테이션의 정체가 정리됩니다.

어노테이션 = 표식, 리플렉션 = 그 표식을 읽고 실제로 뭔가 하는 손

@Transactional, @Entity, @GetMapping 전부 같은 구조입니다. 우리가 표시를 남기고, 프레임워크가 리플렉션으로 그 표시를 찾아 동작합니다.

왜 굳이 런타임에?

한 가지 의문이 남습니다. 왜 굳이 런타임에 클래스패스를 뒤져야 할까요? 컴파일 타임에 하면 타입 안전성도 챙기고 오타로 인한 오류도 막을 수 있을 텐데요. Spring 소스 어딘가에 그냥 이렇게 적어두면 안 되는 걸까요?

UserService service = new UserService();   // spring-context 안에?

결론부터 말함녀 불가능합니다. 이 코드가 컴파일되려면 컴파일러가 UserService 타입을 알아야 하는데, spring-context jar가 빌드되던 순간 우리 클래스는 세상에 없었습니다. 프레임워크는 언제나 응용 코드보다 먼저 컴파일되니까요.

즉 Spring은 자기가 모르는 타입을 다뤄야 하는 처지입니다. 이름조차 모르는 클래스를 찾아내고, 생성자가 뭘 요구하는지 알아내고, 인스턴스를 만들어야 합니다. 컴파일 타임에 존재하지 않던 정보를 실행 시점에 알아내는 것 — 이게 정확히 리플렉션이 하는 일입니다.

그래서 이 거래는 남는 장사일까

리플렉션은 공짜가 아닙니다. 정적 타입 시스템의 안전망을 반납하고 유연성을 사는 거래입니다.

반납하는 쪽은 꽤 구체적입니다. getMethod("getNmae") 같은 오타를 컴파일러는 잡아주지 않습니다. IDE의 "사용처 찾기"에도 걸리지 않으니, 아무도 안 쓰는 줄 알고 지운 메서드가 운영에서 사고를 냅니다. 필드 이름을 리팩터링해도 문자열은 따라오지 않습니다. 컴파일러가 지켜주던 것들이 전부 실행 시점으로 미뤄지는 겁니다.

그런데도 프레임워크가 이 거래를 하는 이유는, 프레임워크에겐 그 유연성이 곧 존재 이유이기 때문입니다. 모르는 타입을 다루지 못하는 프레임워크는 프레임워크가 아닙니다. 반대로 우리가 짜는 애플리케이션 코드에서는 대부분 손해입니다. 잃는 안전성에 비해 얻는 게 없으니까요. 서비스 코드 한복판에 getDeclaredField가 등장한다면, 대개는 설계를 다시 볼 신호입니다.

마무리

처음의 비유로 돌아가봅시다. 객체가 원본이고, 클래스 정보가 거울에 비친 상이라는 설명.

이제 보면 그 비유는 틀린 게 아니라 다른 질문에 답하고 있었습니다. "리플렉션이 무엇을 보여주는가"를 설명할 뿐, "그걸 왜 봐야 하는가"는 말해주지 않았던 거죠.

우리가 필요했던 답은 이겁니다. 컴파일러가 모르는 것을 다루기 위해서.

우리 코드는 프레임워크보다 나중에 태어납니다. 먼저 컴파일된 쪽은 나중에 올 클래스의 이름을 적어둘 수 없습니다. 그래서 이름 대신 약속을 남겨둡니다. @Component를 붙여두면 찾아주겠다, @Autowired를 붙여두면 꽂아주겠다는 약속. 그 약속을 실행 시점에 이행하는 도구가 리플렉션입니다.

profile
We may throw the dice, But the Lord determines how they fall.

0개의 댓글