@Autowired를 붙이면 Spring이 알아서 객체를 넣어준다. 그런데 Spring은 내가 만든 클래스 내부를 어떻게 들여다보고, 어떤 필드에 뭘 넣어야 하는지 아는 걸까? 이 마법의 정체가 바로 리플렉션(Reflection)이다. 리플렉션을 알면 Spring, JPA, Jackson이 내부에서 뭘 하는지가 보이기 시작한다.

리플렉션이 뭔가

리플렉션은 런타임에 클래스의 구조(필드, 메서드, 생성자, 어노테이션 등)를 조사하고 조작하는 기술 이에요.

보통 자바 코드는 컴파일 타임에 타입이 정해집니다. new ArrayList<>()라고 쓰면 컴파일러가 ArrayList를 알고 있어야 해요. 하지만 리플렉션을 쓰면 클래스 이름을 문자열로 받아서 런타임에 인스턴스를 만들고, 메서드를 호출하고, 필드 값을 바꿀 수 있습니다.

"리플렉션은 거울"이라는 비유가 와닿아요. 프로그램이 자기 자신을 거울에 비추듯 들여다보는 거예요.

PLAINTEXT
컴파일 타임                    런타임
┌─────────────┐          ┌──────────────────────┐
│ new Foo()   │          │ Class.forName("Foo") │
│ foo.bar()   │          │ method.invoke(obj)   │
│ → 타입 확정  │          │ → 문자열로 타입 결정    │
└─────────────┘          └──────────────────────┘

** 한 줄 정리:** 리플렉션 = 런타임에 .class 메타데이터를 읽어서 객체를 생성하고, 메서드를 호출하고, 필드를 조작하는 API입니다.


Class 객체 얻기 — 세 가지 방법

리플렉션의 시작점은 java.lang.Class 객체입니다. 모든 클래스는 JVM에 로드될 때 딱 하나의 Class 객체가 만들어져요.

JAVA
// 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()

JAVA
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()

JAVA
// 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()

JAVA
// 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 수준의 보안 장벽이 아니기 때문 **입니다.

JAVA
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" 가져올 수 있다

왜 위험한가

  1. ** 캡슐화 파괴** — 클래스 설계자가 숨긴 내부 구현에 의존하면, 해당 클래스가 변경될 때 같이 깨져요.
  2. ** 타입 안전성 상실** — 컴파일러가 타입 체크를 못 하므로 런타임에 ClassCastException이 터질 수 있습니다.
  3. ** 보안 이슈** — 민감한 데이터에 접근 가능해요. (Java 9+ 모듈 시스템에서 일부 제한이 강화되었습니다.)

Java 9+ 모듈 시스템의 제약

Java 9부터 모듈 시스템(JPMS)이 도입되면서, 다른 모듈의 내부 패키지에 대한 리플렉션 접근이 기본적으로 차단됩니다. --add-opens JVM 옵션으로 풀 수 있지만, 이것 자체가 "설계를 깨고 있다"는 신호예요.

BASH
# 모듈 시스템에서 리플렉션 접근을 열어주는 옵션
java --add-opens java.base/java.lang=ALL-UNNAMED -jar app.jar

동적 프록시 — java.lang.reflect.Proxy

동적 프록시는 ** 런타임에 인터페이스의 구현체를 자동으로 생성 **하는 기술입니다. Spring AOP의 핵심 메커니즘이기도 해요.

InvocationHandler

모든 메서드 호출을 가로채는 핸들러를 만들어 봅니다.

JAVA
// 대상 인터페이스
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 + "번 유저 삭제");
    }
}
JAVA
// 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;
    }
}
JAVA
// 프록시 생성 및 사용
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

동적 프록시의 동작 흐름

PLAINTEXT
클라이언트 → 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가 붙은 필드를 찾아서 값을 넣어주는 과정이 리플렉션이에요.

JAVA
@Service
public class OrderService {
    @Autowired
    private UserRepository userRepository; // Spring이 리플렉션으로 값을 주입
}

Spring의 내부 동작을 간략히 보면 이렇습니다:

JAVA
// 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()는 일반 메서드 호출보다 ** 수십 배 느릴 수 있어요 **.
  • 다만 프레임워크들은 리플렉션 결과를 캐싱해서 성능 영향을 줄입니다.
JAVA
// 일반 호출 — JIT가 인라인 가능
user.getName();

// 리플렉션 — JIT 최적화 어려움
Method m = User.class.getMethod("getName");
m.invoke(user); // invoke 내부에서 네이티브 코드 호출

2. 컴파일 타임 타입 안전성 상실

  • 메서드명을 문자열로 쓰므로 오타를 컴파일러가 잡아주지 못합니다.
  • 리팩토링 도구(IDE의 Rename 등)가 문자열 안의 이름까지 바꿔주지 않아요.
JAVA
// 오타가 있어도 컴파일은 통과한다
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 컴파일러가 최적화할 수 있어 반복 호출 시 성능이 좋아요.

JAVA
// 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 클래스를 대체할 수 있습니다.

비교

구분ReflectionMethodHandleVarHandle
도입 버전Java 1.1Java 7Java 9
JIT 최적화어려움가능가능
주 용도프레임워크, 도구메서드 호출필드 접근, atomic 연산
접근 검사 시점매 호출마다Handle 생성 시 1회Handle 생성 시 1회
사용 난이도쉬움보통보통

실전 예제 — 간단한 DI 컨테이너 만들기

리플렉션으로 Spring의 @Autowired가 어떻게 동작하는지 직접 만들어 볼게요.

커스텀 어노테이션 정의

JAVA
import java.lang.annotation.*;

// 빈으로 등록할 클래스에 붙이는 어노테이션
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface MyComponent {}

// 의존성 주입 대상 필드에 붙이는 어노테이션
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface MyInject {}

간단한 DI 컨테이너

먼저 빈 저장소와 등록 메서드를 만듭니다. @MyComponent가 붙은 클래스를 리플렉션으로 인스턴스화해요.

JAVA
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 필드에도 접근해요.

JAVA
    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으로 인터페이스 기반 주입도 지원해요.

JAVA
    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); }
}

사용 예시

JAVA
@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);
    }
}
JAVA
// 컨테이너 실행
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()는 일반 호출보다 수십 배 느릴 수 있음. 프레임워크는 캐싱으로 완화
MethodHandleJava 7+, JIT 최적화 가능. 접근 검사가 Handle 생성 시 1회만 수행
VarHandleJava 9+, 필드 접근 + atomic 연산. AtomicXxx 대체 가능

다음 글에서는 ** 직렬화와 JSON**을 다룹니다. 객체를 파일에 저장하거나 네트워크로 전송하는 방법이 궁금하시다면 이어서 봐주세요.

댓글 로딩 중...