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)이 필수.SOURCEretention의 애너테이션(@Override)은 컴파일 후 사라진다. - 리플렉션은 런타임에 클래스 구조를 검사하고 조작한다. 프레임워크(Spring, JUnit, JPA, Jackson)의 근간이지만, 성능 비용이 크고 캡슐화를 파괴한다.
- 동적 프록시는 인터페이스의 구현을 런타임에 생성한다. Spring AOP, Hibernate lazy loading 등의 기반. 클래스 프록시는 CGLIB 필요.
- 리플렉션은 프레임워크 코드에서만 쓴다. 애플리케이션 코드에서 리플렉션을 직접 쓰면 성능 저하, 타입 안전성 상실, 유지보수성 악화. 필요한 추상화를 프레임워크가 제공하도록 한다.
MethodHandle이 리플렉션보다 빠른 대안이다. 반복 호출이 필요한 경우MethodHandle로 캐시하여 성능을 개선한다.
생각해 볼 문제
@Retention(SOURCE)인 애너테이션의 예를 3개 이상 찾아보자. 왜 런타임에 필요 없는가?@Repeatable애너테이션을 정의하고, 같은 타입의 애너테이션을 한 요소에 여러 번 붙이는 코드를 작성해 보자.Method.invoke()와MethodHandle.invoke()의 성능 차이를 JMH로 측정해 보자.- Spring이
@Autowiredprivate 필드에 주입할 때 사용하는 리플렉션 코드를 추정해 보자. 어떤 보안 검사를 우회하는가? - record의 컴포넌트에 붙인 애너테이션을 리플렉션으로 어떻게 읽는가?
Parameter와Field중 어느 것인가?
참고
- Reflection API (Java 25) - 접근 2026-07-10
- JLS §9.6 - Annotation Types - 접근 2026-07-10
- JLS §9.7 - Annotations - 접근 2026-07-10
- Proxy API - 접근 2026-07-10
- MethodHandle API - 접근 2026-07-10
'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 |
