Spring이 @Autowired를 보고 의존성을 주입하는 원리 — 애너테이션과 리플렉션

@Autowired
private UserService userService;

이 코드를 실행하면 Spring은 어떻게 UserService 타입의 bean을 찾아서 userService 필드에 주입하는가? 정답은 리플렉션(reflection)이다 — 런타임에 클래스의 필드, 메서드, 애너테이션을 검사하고 조작하는 메커니즘. (Reflection API)

리플렉션은 프레임워크(Spring, JUnit, Hibernate, Jackson)의 근간이지만, 잘못 쓰면 성능 저하, 캡슐화 파괴, 유지보수성 악화를 초래한다. 이 글은 애너테이션 정의, 리플렉션 API, 동적 프록시, 그리고 언제 리플렉션을 써야 하는지를 풀어간다.

애너테이션 — 메타데이터의 언어적 표현

애너테이션은 코드에 메타데이터를 부착하는 기능이다. (JLS §9.6)

// Java 25 — 커스텀 애너테이션 정의
import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)   // 런타임까지 유지
@Target(ElementType.METHOD)            // 메서드에만 적용
public @interface LogExecution {
    String value() default "";
}

메타 애너테이션 — 애너테이션을 제어하는 애너테이션

메타 애너테이션 의미 옵션
@Retention 애너테이션 유지 기간 SOURCE(컴파일 후 제거), CLASS(바이트코드엔 남음, 리플렉션 접근 불가), RUNTIME(리플렉션 접근 가능)
@Target 적용 가능 위치 TYPE(클래스), FIELD, METHOD, PARAMETER, CONSTRUCTOR, ANNOTATION_TYPE
@Inherited 하위 클래스에 상속 기본적으로 애너테이션은 상속되지 않음
@Repeatable 같은 애너테이션 여러 번 적용 (Java 8+)  

리플렉션으로 애너테이션을 읽으려면 @Retention(RUNTIME)이 필수다. SOURCE(예: @Override, @SuppressWarnings)는 컴파일 후 사라지므로 런타임에 접근할 수 없다. 이것이 자주 발생하는 실수다.

리플렉션 API — 런타임에 클래스 검사

// Java 25
import java.lang.reflect.*;

Class<?> clazz = String.class;

// 클래스 정보
System.out.println(clazz.getName());        // java.lang.String
System.out.println(clazz.getSimpleName());  // String
System.out.println(Modifier.isPublic(clazz.getModifiers()));   // true

// 필드
Field[] fields = clazz.getDeclaredFields();
for (Field f : fields) {
    System.out.println(f.getType().getSimpleName() + " " + f.getName());
}

// 메서드
Method[] methods = clazz.getDeclaredMethods();
for (Method m : methods) {
    System.out.println(m.getName());
}

// 생성자로 인스턴스 생성
Constructor<String> constructor = String.class.getConstructor(byte[].class);
String str = constructor.newInstance(new byte[] { 72, 105 });   // "Hi"

리플렉션으로 private 멤버 접근

// Java 25
import java.lang.reflect.Field;

class Secret {
    private String hidden = "password123";
}

Secret secret = new Secret();
Field field = Secret.class.getDeclaredField("hidden");
field.setAccessible(true);   // private 접근 허용
String value = (String) field.get(secret);
System.out.println(value);   // password123

field.set(secret, "hacked");
System.out.println(secret.hidden);   // 원래 private이지만 리플렉션으로 변경됨

setAccessible(true)는 접근 제어를 우회한다 — 캡슐화의 근본적 파괴. 프레임워크(JPA entity 주입, Jackson JSON 매핑)에서 불가피하게 사용하지만, 애플리케이션 코드에서는 쓰지 않는다. Java 16+에서는 모듈 시스템의 --add-opens 없이 다른 모듈의 private 멤버에 접근하면 예외가 발생한다.

리플렉션으로 애너테이션 읽기

// Java 25 — 애너테이션 기반 라우팅 (프레임워크 시뮬레이션)
@Retention(RUNTIME)
@Target(METHOD)
@interface Route {
    String path();
    String method() default "GET";
}

class MyController {
    @Route(path = "/users", method = "GET")
    public String getUsers() { return "[]"; }

    @Route(path = "/users", method = "POST")
    public String createUser() { return "created"; }
}

// 런타임에 애너테이션 읽기
for (Method m : MyController.class.getDeclaredMethods()) {
    if (m.isAnnotationPresent(Route.class)) {
        Route route = m.getAnnotation(Route.class);
        System.out.println(route.method() + " " + route.path() + " → " + m.getName());
    }
}
GET /users → getUsers
POST /users → createUser

이것이 Spring MVC, JUnit, JPA가 동작하는 원리다 — 애너테이션을 런타임에 읽어서 프레임워크가 코드를 구동한다.

동적 프록시(Dynamic Proxy) — 인터페이스 구현을 런타임에 생성

// Java 25 — JDK 동적 프록시
import java.lang.reflect.*;

interface Greeting {
    String hello(String name);
}

// InvocationHandler로 메서드 호출 가로채기
Greeting proxy = (Greeting) Proxy.newProxyInstance(
    Greeting.class.getClassLoader(),
    new Class[] { Greeting.class },
    (proxyObj, method, args) -> {
        System.out.println("[LOG] " + method.getName() + " 호출");
        return "Hello, " + args[0] + "!";
    }
);

System.out.println(proxy.hello("Java"));
[LOG] hello 호출
Hello, Java!

동적 프록시는 인터페이스에만 적용할 수 있다(Proxy.newProxyInstance). 클래스에 프록시를 적용하려면 CGLIB(바이트코드 조작)이 필요하다 — Spring AOP가 기본적으로 CGLIB를 사용하는 이유다. (Spring Framework 장 06-AOP 상세)

리플렉션의 성능 비용

// Java 25 — 직접 호출 vs 리플렉션 호출
class Calculator {
    public int add(int a, int b) { return a + b; }
}

// 직접 호출
Calculator calc = new Calculator();
long start = System.nanoTime();
for (int i = 0; i < 1_000_000; i++) {
    calc.add(1, 2);
}
// 약 수십 ms

// 리플렉션 호출
Method addMethod = Calculator.class.getMethod("add", int.class, int.class);
start = System.nanoTime();
for (int i = 0; i < 1_000_000; i++) {
    addMethod.invoke(calc, 1, 2);
}
// 약 수백 ~ 수천 ms — 10 ~ 100배 느림
항목 직접 호출 리플렉션
JIT 최적화 완전 적용 제한적 (JIT가 동적 호출을 최적화하기 어려움)
박싱 없음 invoke()Object 매개변수 → 박싱
접근 검사 컴파일 타임 런타임 (매 호출 시)
MethodHandle (Java 7+) 리플렉션보다 빠름 (캐시 가능)

MethodHandle(Java 7+)은 리플렉션보다 빠른 대안이다 — 한 번 MethodHandle을 얻으면 반복 호출이 직접 호출에 가깝다. 람다의 invokedynamic도 내부적으로 MethodHandle을 사용한다.

컴파일 타임 애너테이션 처리(Annotation Processing) — 리플렉션 없는 메타프로그래밍

리플렉션이 런타임에 동작한다면, Annotation Processor(APT)컴파일 타임에 애너테이션을 처리한다. 새 소스 코드를 생성하거나, 검증을 수행할 수 있다. (javax.annotation.processing)

대표적인 활용 사례:

  • Lombok@Data, @Builder로 컴파일 타임에 getter/setter/builder 생성
  • MapStruct@Mapper로 DTO ↔ Entity 변환 코드 생성
  • Dagger, Hilt@Inject로 의존성 주입 코드 생성
  • Hibernate Validator@NotNull, @Size로 컴파일/런타임 검증
// Java 25 — 컴파일 타임 검증 예 (개념적)
@Retention(SOURCE)
@Target(FIELD)
public @interface Range {
    int min() default 0;
    int max() default Integer.MAX_VALUE;
}

// 사용
class Product {
    @Range(min = 0, max = 100)
    private int stock;   // Annotation Processor가 컴파일 타임에 검증 코드 생성
}

Annotation Processing은 런타임 오버헤드가 없다 — 모든 코드가 컴파일 타임에 생성된다. Lombok이 런타임에 리플렉션을 쓰지 않으면서 getter/setter를 제공하는 원리다.

모듈 시스템과 리플렉션 — Java 9+의 접근 제어 강화

Java 9 모듈 시스템(JPMS)은 리플렉션의 setAccessible(true)을 제한한다. 다른 모듈의 internal 멤버에 접근하려면 그 모듈이 명시적으로 opens 해야 한다.

// Java 25 — 모듈 경계 너머의 리플렉션
// 모듈 A의 module-info.java:
// module A {
//     opens com.example.internal to B;   // B 모듈에 리플렉션 접근 허용
// }

// 또는 실행 시 JVM 옵션으로 강제:
// java --add-opens A/com.example.internal=B

Java 16+에서는 기본적으로 --illegal-access=deny가 적용된다. setAccessible(true)로 다른 모듈의 internal에 접근하면 InaccessibleObjectException이 발생한다. 리플렉션 의존 라이브러리(Hibernate, Jackson 등)를 쓸 때 --add-opens가 필요할 수 있다.

실습 — 미니 프레임워크 만들기

// Java 25 — MiniFrameworkDemo.java
import java.lang.annotation.*;
import java.lang.reflect.*;
import java.util.*;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface GET {
    String value();   // 경로
}

class UserController {
    @GET("/users")
    public String list() { return "[user1, user2]"; }

    @GET("/users/{id}")
    public String get(String id) { return "user:" + id; }
}

public class MiniFrameworkDemo {
    public static void main(String[] args) throws Exception {
        // 컨트롤러 스캔 — 애너테이션으로 라우트 매핑 구축
        Map<String, Method> routes = new HashMap<>();
        Object controller = new UserController();

        for (Method m : controller.getClass().getDeclaredMethods()) {
            if (m.isAnnotationPresent(GET.class)) {
                GET get = m.getAnnotation(GET.class);
                routes.put(get.value(), m);
                System.out.println("등록: GET " + get.value() + " → " + m.getName());
            }
        }

        // 요청 시뮬레이션
        String requestPath = "/users";
        Method handler = routes.get(requestPath);
        if (handler != null) {
            Object result = handler.invoke(controller);
            System.out.println("응답: " + result);
        }

        requestPath = "/users/{id}";
        handler = routes.get(requestPath);
        if (handler != null) {
            Object result = handler.invoke(controller, "42");
            System.out.println("응답: " + result);
        }
    }
}
java MiniFrameworkDemo.java
등록: GET /users → list
등록: GET /users/{id} → get
응답: [user1, user2]
응답: user:42

확인할 것: @GET 애너테이션과 리플렉션으로 미니 라우팅 프레임워크를 만들었다. Spring MVC, Javalin, Spark Java 같은 웹 프레임워크가 본질적으로 같은 원리로 동작한다 — 애너테이션으로 메타데이터를 부착하고, 리플렉션으로 런타임에 처리한다.

요약 — 이 글의 결론

  • 애너테이션은 메타데이터를 코드에 부착하는 기능이다. 런타임에 읽으려면 @Retention(RUNTIME)이 필수. SOURCE retention의 애너테이션(@Override)은 컴파일 후 사라진다.
  • 리플렉션은 런타임에 클래스 구조를 검사하고 조작한다. 프레임워크(Spring, JUnit, JPA, Jackson)의 근간이지만, 성능 비용이 크고 캡슐화를 파괴한다.
  • 동적 프록시는 인터페이스의 구현을 런타임에 생성한다. Spring AOP, Hibernate lazy loading 등의 기반. 클래스 프록시는 CGLIB 필요.
  • 리플렉션은 프레임워크 코드에서만 쓴다. 애플리케이션 코드에서 리플렉션을 직접 쓰면 성능 저하, 타입 안전성 상실, 유지보수성 악화. 필요한 추상화를 프레임워크가 제공하도록 한다.
  • MethodHandle이 리플렉션보다 빠른 대안이다. 반복 호출이 필요한 경우 MethodHandle로 캐시하여 성능을 개선한다.

생각해 볼 문제

  1. @Retention(SOURCE)인 애너테이션의 예를 3개 이상 찾아보자. 왜 런타임에 필요 없는가?
  2. @Repeatable 애너테이션을 정의하고, 같은 타입의 애너테이션을 한 요소에 여러 번 붙이는 코드를 작성해 보자.
  3. Method.invoke()MethodHandle.invoke()의 성능 차이를 JMH로 측정해 보자.
  4. Spring이 @Autowired private 필드에 주입할 때 사용하는 리플렉션 코드를 추정해 보자. 어떤 보안 검사를 우회하는가?
  5. record의 컴포넌트에 붙인 애너테이션을 리플렉션으로 어떻게 읽는가? ParameterField 중 어느 것인가?

참고

'Develop Artifacts > Java' 카테고리의 다른 글

Java - 19. concurrent  (0) 2026.07.12
Java - 18. thread sync  (0) 2026.07.12
Java - 16. io nio  (0) 2026.07.12
Java - 15. datetime  (0) 2026.07.12
Java - 14. optional  (0) 2026.07.12