리플렉션은 런타임에 클래스 구조를 분석하고 동적으로 조작하는 강력한 기법입니다. Spring, JPA, Jackson, JUnit이 모두 이를 기반으로 동작합니다. 원리부터 실무 활용까지 상세히 정리합니다.


1. 리플렉션이란?

리플렉션(Reflection) 은 프로그램이 실행 중(runtime)에 자기 자신의 구조를 조사하고 수정하는 능력입니다. 컴파일 타임에 알 수 없는 클래스, 메서드, 필드에 동적으로 접근할 수 있습니다.

동작 원리 — 일반 코드 흐름과의 차이

일반 Java 코드에서 person.getName()을 호출할 때, 컴파일러는 Person 클래스에 getName() 메서드가 있음을 확인하고 바이트코드의 invokevirtual 명령으로 변환합니다. 이 모든 것은 컴파일 시점에 결정됩니다.

리플렉션은 다릅니다. JVM이 클래스를 로딩할 때 생성하는 Class 객체에는 해당 클래스의 모든 메타데이터(필드 목록, 메서드 목록, 접근 제어자, 어노테이션 등)가 담겨 있습니다. 리플렉션은 이 메타데이터를 런타임에 읽어 동적으로 메서드를 찾고 호출합니다.

graph LR
    A[".java"] -->|"컴파일"| B[".class"]
    B -->|"JVM 로딩"| C["실행(타입 고정)"]
    B -->|"forName()"| D["Class 메타데이터"]
    D -->|"invoke()"| E["동적 조회/호출"]

왜 필요한가?

// 일반 코드: 컴파일 시점에 타입이 확정됨
Person p = new Person();  // 컴파일러가 Person을 앎
p.getName();              // 컴파일러가 getName()을 앎

// 리플렉션: 런타임에 타입을 결정
String className = config.get("class");
Class<?> clazz = Class.forName(className);  // 런타임에 로딩
Object obj = clazz.getDeclaredConstructor().newInstance();
// → 플러그인 시스템, DI 컨테이너, ORM, 직렬화 라이브러리 등에 필수

리플렉션이 쓰이는 곳

프레임워크/라이브러리 리플렉션 활용
Spring DI @Autowired 필드에 의존성 주입
Spring MVC @RequestMapping 메서드 탐색 및 호출
JPA (Hibernate) 엔티티 필드 매핑, Proxy 생성
Jackson JSON ↔ Java 객체 변환
JUnit @Test 메서드 탐색 및 실행
Lombok 어노테이션 기반 코드 생성 (APT)

2. Class 객체 획득

모든 리플렉션의 시작점은 java.lang.Class 객체입니다.

// 방법 1: 클래스 리터럴 (.class) — 컴파일 타임에 결정, 가장 안전하고 빠름
Class<String> c1 = String.class;
Class<int[]> c2 = int[].class;
Class<Void> c3 = void.class;

// 방법 2: 인스턴스의 getClass() — 런타임에 실제 타입 반환
Object obj = "Hello";
Class<?> c4 = obj.getClass();  // String.class (다형성 적용)

String str = "World";
Class<? extends String> c5 = str.getClass();

// 방법 3: Class.forName() — 문자열로 클래스 로딩 (가장 동적)
Class<?> c6 = Class.forName("java.util.ArrayList");
Class<?> c7 = Class.forName("com.example.MyPlugin");  // 런타임 로딩

// 배열 클래스
Class<?> intArrayClass = Class.forName("[I");      // int[]
Class<?> strArrayClass = Class.forName("[Ljava.lang.String;");  // String[]

// 방법 4: ClassLoader 직접 사용
ClassLoader cl = Thread.currentThread().getContextClassLoader();
Class<?> c8 = cl.loadClass("com.example.SomeClass");

// Class 객체 비교
System.out.println(c1 == c4);   // true (같은 Class 객체)
System.out.println(c1 == c6);   // false (String vs ArrayList)

기본형과 래퍼 타입

// 기본형과 래퍼 타입은 다른 Class 객체
Class<?> primitiveInt  = int.class;
Class<?> wrapperInt    = Integer.class;
System.out.println(primitiveInt == wrapperInt);  // false

// 기본형 타입 목록
Class<?>[] primitives = {
    byte.class, short.class, int.class, long.class,
    float.class, double.class, char.class, boolean.class, void.class
};

// 기본형 여부 확인
System.out.println(int.class.isPrimitive());     // true
System.out.println(Integer.class.isPrimitive()); // false

3. 클래스 정보 조회

Class<?> clazz = ArrayList.class;

// 기본 정보
System.out.println(clazz.getName());            // java.util.ArrayList
System.out.println(clazz.getSimpleName());      // ArrayList
System.out.println(clazz.getCanonicalName());   // java.util.ArrayList
System.out.println(clazz.getPackageName());     // java.util

// 타입 분류
System.out.println(clazz.isInterface());        // false
System.out.println(clazz.isArray());            // false
System.out.println(clazz.isEnum());             // false
System.out.println(clazz.isRecord());           // false (Java 16+)
System.out.println(clazz.isAnnotation());       // false
System.out.println(clazz.isSynthetic());        // false (컴파일러 생성 여부)
System.out.println(clazz.isAnonymousClass());   // false
System.out.println(clazz.isLocalClass());       // false
System.out.println(clazz.isMemberClass());      // false

// 수정자 (Modifier)
int mod = clazz.getModifiers();
System.out.println(Modifier.isPublic(mod));     // true
System.out.println(Modifier.isAbstract(mod));   // false
System.out.println(Modifier.isFinal(mod));      // false
System.out.println(Modifier.toString(mod));     // "public"

// 슈퍼클래스 / 인터페이스
Class<?> superClass = clazz.getSuperclass();    // AbstractList.class
Class<?>[] interfaces = clazz.getInterfaces();  // [List, RandomAccess, Cloneable, Serializable]

for (Class<?> iface : interfaces) {
    System.out.println("구현 인터페이스: " + iface.getSimpleName());
}

// 제네릭 타입 정보
Type genericSuper = clazz.getGenericSuperclass();  // AbstractList<E>
Type[] genericInterfaces = clazz.getGenericInterfaces();

// 내부 클래스
Class<?>[] declaredClasses = clazz.getDeclaredClasses();

// 외부 클래스 (멤버 클래스일 때)
Class<?> enclosing = clazz.getEnclosingClass();

4. 필드 조회 및 접근

public class Person {
    public String name;
    protected int age;
    private String email;
    static String company = "Java Corp";
}
Class<?> clazz = Person.class;

// getFields()        → public 필드만 (상속 포함)
// getDeclaredFields() → 모든 접근 제어자 (선언된 클래스만)

Field[] publicFields   = clazz.getFields();           // name (상속된 public도 포함)
Field[] declaredFields = clazz.getDeclaredFields();    // name, age, email, company

for (Field f : declaredFields) {
    System.out.printf("필드: %-15s | 타입: %-20s | 수정자: %s%n",
            f.getName(), f.getType().getSimpleName(), Modifier.toString(f.getModifiers()));
}

// 특정 필드 접근
Field nameField  = clazz.getField("name");           // public 필드
Field emailField = clazz.getDeclaredField("email");  // private 필드

// 필드 메타데이터
System.out.println(emailField.getName());             // email
System.out.println(emailField.getType());             // class java.lang.String
System.out.println(emailField.getGenericType());      // class java.lang.String
System.out.println(Modifier.isPrivate(emailField.getModifiers()));  // true

5. 접근 제어 우회 (setAccessible)

Person person = new Person();
person.name = "공개";  // public 필드: 직접 접근 가능

// private 필드 접근
Field emailField = Person.class.getDeclaredField("email");
emailField.setAccessible(true);  // 접근 제어 우회

// 값 읽기
emailField.set(person, "kim@example.com");  // 쓰기
String email = (String) emailField.get(person);  // 읽기
System.out.println("email: " + email);  // kim@example.com

// static 필드 접근 (인스턴스 대신 null 사용)
Field companyField = Person.class.getDeclaredField("company");
companyField.setAccessible(true);
String company = (String) companyField.get(null);  // static 필드
companyField.set(null, "New Company");

// 기본형 타입 전용 메서드 (언박싱 비용 절감)
// field.getInt(obj), field.getLong(obj), field.getDouble(obj) ...

Java 9+ 모듈 시스템과 setAccessible

// Java 9+ 에서 다른 모듈의 클래스에 접근 시 InaccessibleObjectException 발생 가능
// 해결: module-info.java에 opens 선언
// module com.example.myapp {
//     opens com.example.model to com.example.framework;
// }

// 또는 JVM 옵션 (임시 방편, 비권장)
// --add-opens java.base/java.lang=ALL-UNNAMED

6. 메서드 조회 및 호출

public class Calculator {
    public int add(int a, int b) { return a + b; }
    private double divide(double a, double b) { return a / b; }
    public static int square(int n) { return n * n; }
    public <T extends Number> T identity(T t) { return t; }
}
Class<?> clazz = Calculator.class;

// getMethods()          → public 메서드 (상속 포함, Object 메서드도 포함)
// getDeclaredMethods()  → 모든 접근 제어자 (선언된 클래스만)

Method[] methods = clazz.getDeclaredMethods();
for (Method m : methods) {
    System.out.printf("메서드: %-15s | 반환: %-10s | 파라미터: %s%n",
            m.getName(),
            m.getReturnType().getSimpleName(),
            Arrays.stream(m.getParameterTypes())
                  .map(Class::getSimpleName)
                  .collect(Collectors.joining(", ")));
}

// 특정 메서드 조회 (이름 + 파라미터 타입)
Method addMethod    = clazz.getMethod("add", int.class, int.class);
Method divideMethod = clazz.getDeclaredMethod("divide", double.class, double.class);
Method squareMethod = clazz.getMethod("square", int.class);

// 메서드 메타데이터
System.out.println(addMethod.getReturnType());          // int
System.out.println(addMethod.getParameterCount());      // 2
System.out.println(Arrays.toString(addMethod.getExceptionTypes()));

// 메서드 호출 (invoke)
Calculator calc = new Calculator();

Object result1 = addMethod.invoke(calc, 3, 4);  // 인스턴스 메서드
System.out.println("3 + 4 = " + result1);       // 7

divideMethod.setAccessible(true);
Object result2 = divideMethod.invoke(calc, 10.0, 3.0);  // private 메서드
System.out.printf("10 / 3 = %.4f%n", result2);

Object result3 = squareMethod.invoke(null, 5);   // static 메서드: null 전달
System.out.println("5^2 = " + result3);         // 25

// 파라미터 상세 정보
for (Parameter param : addMethod.getParameters()) {
    System.out.printf("파라미터: %s, 타입: %s%n",
            param.getName(), param.getType().getSimpleName());
    // 주의: param.getName()은 컴파일 시 -parameters 옵션이 있어야 실제 이름 반환
    //       없으면 arg0, arg1 ... 반환
}

제네릭 타입 정보 조회

// 런타임에 제네릭 타입 소거(Type Erasure)로 인해 타입 파라미터 정보가 사라짐
// 단, 선언부의 타입 파라미터는 ParameterizedType으로 조회 가능

public class Repository<T> {
    private List<T> items = new ArrayList<>();
    public List<String> getNames() { return List.of(); }
}

// 필드의 제네릭 타입
Field itemsField = Repository.class.getDeclaredField("items");
Type genericType = itemsField.getGenericType();  // java.util.List<T>
if (genericType instanceof ParameterizedType pt) {
    System.out.println("원시 타입: " + pt.getRawType());       // interface java.util.List
    System.out.println("타입 인수: " + Arrays.toString(pt.getActualTypeArguments())); // [T]
}

// 메서드 반환 타입의 제네릭
Method getNames = Repository.class.getMethod("getNames");
Type returnType = getNames.getGenericReturnType();  // java.util.List<java.lang.String>
if (returnType instanceof ParameterizedType pt) {
    Type[] typeArgs = pt.getActualTypeArguments();  // [class java.lang.String]
    System.out.println("반환 타입 인수: " + typeArgs[0]);  // class java.lang.String
}

7. 생성자 조회 및 동적 객체 생성

public class Product {
    private String name;
    private int price;

    public Product() { this("기본", 0); }
    public Product(String name) { this(name, 0); }
    public Product(String name, int price) {
        this.name = name;
        this.price = price;
    }
}
Class<?> clazz = Product.class;

// getConstructors()          → public 생성자만
// getDeclaredConstructors()  → 모든 접근 제어자

Constructor<?>[] constructors = clazz.getDeclaredConstructors();
for (Constructor<?> c : constructors) {
    System.out.println("생성자: " + Arrays.toString(c.getParameterTypes()));
}

// 기본 생성자로 생성 (Java 9+: getDeclaredConstructor() 권장)
Object p1 = clazz.getDeclaredConstructor().newInstance();

// 파라미터 있는 생성자
Constructor<?> twoArgCtor = clazz.getDeclaredConstructor(String.class, int.class);
Object p2 = twoArgCtor.newInstance("노트북", 1_500_000);

// private 생성자 접근 (싱글턴 패턴 우회 - 테스트 목적)
public class Singleton {
    private static final Singleton INSTANCE = new Singleton();
    private Singleton() {}
    public static Singleton getInstance() { return INSTANCE; }
}

Constructor<Singleton> ctor = Singleton.class.getDeclaredConstructor();
ctor.setAccessible(true);
Singleton s1 = ctor.newInstance();  // 새 인스턴스 강제 생성
Singleton s2 = Singleton.getInstance();
System.out.println(s1 == s2);  // false (주의: 이런 사용은 비권장)

8. 어노테이션 처리

8.1 커스텀 어노테이션 정의

import java.lang.annotation.*;

// @Retention: 어노테이션이 유지되는 시점
//   RetentionPolicy.SOURCE   → 컴파일 전까지 (Lombok)
//   RetentionPolicy.CLASS    → .class 파일까지 (기본값)
//   RetentionPolicy.RUNTIME  → 런타임까지 ← 리플렉션으로 읽으려면 이것
@Retention(RetentionPolicy.RUNTIME)

// @Target: 어노테이션 적용 대상
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD})

// @Inherited: 자식 클래스에 상속
@Inherited

// @Documented: Javadoc에 포함
@Documented

public @interface Validate {
    String message() default "유효성 검사 실패";
    int minLength() default 1;
    int maxLength() default 255;
    boolean required() default true;
}

// 메서드용 어노테이션
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface LogExecution {
    String value() default "";
    boolean logArgs() default true;
    boolean logResult() default false;
}

// 필드용 어노테이션
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Column {
    String name() default "";
    boolean nullable() default true;
    int length() default 255;
}

8.2 어노테이션 적용 및 리플렉션으로 처리

@Validate(message = "사용자 정보 오류")
public class User {
    @Column(name = "user_name", nullable = false, length = 50)
    @Validate(minLength = 2, maxLength = 50)
    private String name;

    @Column(name = "user_email", nullable = false)
    private String email;

    private int age;

    @LogExecution(value = "save", logArgs = true, logResult = true)
    public boolean save() { return true; }
}
// 어노테이션 읽기 및 처리기 구현
public class AnnotationProcessor {

    // 클래스 레벨 어노테이션
    public static void processClass(Class<?> clazz) {
        if (clazz.isAnnotationPresent(Validate.class)) {
            Validate v = clazz.getAnnotation(Validate.class);
            System.out.println("클래스 검증 메시지: " + v.message());
        }
    }

    // 필드 레벨 어노테이션으로 ORM 컬럼 매핑
    public static Map<String, String> getColumnMapping(Class<?> clazz) {
        Map<String, String> mapping = new LinkedHashMap<>();

        for (Field field : clazz.getDeclaredFields()) {
            Column column = field.getAnnotation(Column.class);
            if (column != null) {
                String columnName = column.name().isEmpty()
                        ? field.getName()
                        : column.name();
                mapping.put(field.getName(), columnName);
                System.out.printf("필드 %-10s → 컬럼 %-15s (nullable=%b, length=%d)%n",
                        field.getName(), columnName, column.nullable(), column.length());
            }
        }
        return mapping;
    }

    // 메서드 레벨 어노테이션으로 로깅 AOP 구현
    public static Object invokeWithLogging(Object target, String methodName, Object... args)
            throws Exception {
        Method method = target.getClass().getMethod(methodName);
        LogExecution log = method.getAnnotation(LogExecution.class);

        if (log != null) {
            System.out.printf("[LOG] %s 시작 (태그: %s)%n", methodName, log.value());
            if (log.logArgs()) {
                System.out.println("[LOG] 인수: " + Arrays.toString(args));
            }
        }

        Object result = method.invoke(target, args);

        if (log != null && log.logResult()) {
            System.out.println("[LOG] 결과: " + result);
        }

        return result;
    }

    public static void main(String[] args) throws Exception {
        processClass(User.class);
        getColumnMapping(User.class);

        User user = new User();
        invokeWithLogging(user, "save");
    }
}

8.3 어노테이션 기반 유효성 검사기

public class Validator {
    public static List<String> validate(Object obj) throws IllegalAccessException {
        List<String> errors = new ArrayList<>();
        Class<?> clazz = obj.getClass();

        for (Field field : clazz.getDeclaredFields()) {
            Validate v = field.getAnnotation(Validate.class);
            if (v == null) continue;

            field.setAccessible(true);
            Object value = field.get(obj);

            if (v.required() && value == null) {
                errors.add(field.getName() + ": 필수 항목입니다");
                continue;
            }

            if (value instanceof String s) {
                if (s.length() < v.minLength()) {
                    errors.add(field.getName() + ": 최소 " + v.minLength() + "자 이상이어야 합니다");
                }
                if (s.length() > v.maxLength()) {
                    errors.add(field.getName() + ": 최대 " + v.maxLength() + "자 이하여야 합니다");
                }
            }
        }
        return errors;
    }
}

9. 리플렉션의 성능 비용

동작 원리 — 왜 느린가?

Method.invoke()가 일반 메서드 호출보다 느린 이유는 실행 경로에 여러 오버헤드가 겹치기 때문입니다.

첫째, 접근 제어 검사: setAccessible(false) 상태에서는 호출마다 접근 권한을 확인합니다. 둘째, 가변인수 배열: Object... args로 인수를 전달하므로 배열 생성이 매번 발생합니다. 셋째, 오토박싱: 기본형 인수를 Object로 포장해야 합니다. 넷째, JIT 인라이닝 불가: 동적 디스패치 구조라 컴파일러가 메서드 호출을 인라이닝하지 못합니다. 다섯째, 예외 래핑: 실제 예외가 InvocationTargetException으로 감싸져 반환됩니다.

graph LR
    INVOKE["Method.invoke()"] --> CHECK["접근 제어 검사"]
    CHECK --> ARRAY["가변인수 배열 생성"]
    ARRAY --> BOX["오토박싱"]
    BOX --> DISPATCH["동적 디스패치"]
    DISPATCH --> WRAP["예외 래핑"]
// 성능 비교 (상대적 수치, JVM/JIT 최적화에 따라 다름):
// 직접 호출:              1x   (기준)
// Method.invoke():       ~5x  (접근 검사 포함)
// setAccessible 후 invoke: ~3x  (접근 검사 캐싱)
// MethodHandle:          ~1.5x (JIT 최적화 가능)

성능 최적화 전략

// 1. Method/Field 객체 캐싱 (매번 getDeclaredMethod 호출 금지)
public class ReflectionCache {
    private static final Map<String, Method> METHOD_CACHE = new ConcurrentHashMap<>();

    public static Method getCachedMethod(Class<?> clazz, String name, Class<?>... params)
            throws NoSuchMethodException {
        String key = clazz.getName() + "#" + name;
        return METHOD_CACHE.computeIfAbsent(key, k -> {
            try {
                Method m = clazz.getDeclaredMethod(name, params);
                m.setAccessible(true);  // 한 번만 설정
                return m;
            } catch (NoSuchMethodException e) {
                throw new RuntimeException(e);
            }
        });
    }
}

// 2. setAccessible(true)은 한 번만 호출 (매 invoke마다 하지 않음)
Method m = SomeClass.class.getDeclaredMethod("privateMethod");
m.setAccessible(true);  // 한 번만
for (int i = 0; i < 1_000_000; i++) {
    m.invoke(obj);  // 캐싱된 상태로 반복 호출
}

// 3. 생성자 인스턴스 생성도 캐싱
Constructor<?> ctor = MyClass.class.getDeclaredConstructor(String.class);
ctor.setAccessible(true);
// ctor를 재사용

10. 실무 활용 패턴

마치 설계도를 보고 집을 짓는 것처럼, 리플렉션은 Class 메타데이터(설계도)를 보고 런타임에 객체를 만들고 조립합니다. Spring의 @Autowired와 JPA의 프록시가 이 방식으로 동작합니다. 설계도를 읽는 비용이 있기 때문에, 반복 호출 시에는 MethodHandle로 캐싱하는 것이 중요합니다.

10.1 Spring DI 모방 — 필드 주입

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Inject {}

public class SimpleContainer {
    private final Map<Class<?>, Object> registry = new HashMap<>();

    public <T> void register(Class<T> type, T instance) {
        registry.put(type, instance);
    }

    public <T> T createAndInject(Class<T> clazz) throws Exception {
        T instance = clazz.getDeclaredConstructor().newInstance();

        for (Field field : clazz.getDeclaredFields()) {
            if (field.isAnnotationPresent(Inject.class)) {
                Object dependency = registry.get(field.getType());
                if (dependency == null) {
                    throw new RuntimeException("의존성을 찾을 수 없습니다: " + field.getType());
                }
                field.setAccessible(true);
                field.set(instance, dependency);
                System.out.println("주입 완료: " + field.getName() + " ← " + dependency.getClass().getSimpleName());
            }
        }
        return instance;
    }
}

// 사용
public class UserService {
    @Inject
    private UserRepository userRepository;

    public String findUser(long id) {
        return userRepository.findById(id);
    }
}

// 등록 및 사용
SimpleContainer container = new SimpleContainer();
container.register(UserRepository.class, new UserRepository());
UserService service = container.createAndInject(UserService.class);

10.2 Jackson 모방 — JSON 직렬화

public class SimpleJsonSerializer {

    public static String toJson(Object obj) throws IllegalAccessException {
        if (obj == null) return "null";

        Class<?> clazz = obj.getClass();
        StringBuilder sb = new StringBuilder("{");
        boolean first = true;

        for (Field field : clazz.getDeclaredFields()) {
            if (Modifier.isStatic(field.getModifiers())) continue;
            field.setAccessible(true);

            if (!first) sb.append(", ");
            first = false;

            sb.append("\"").append(field.getName()).append("\": ");
            Object value = field.get(obj);

            if (value == null) {
                sb.append("null");
            } else if (value instanceof String) {
                sb.append("\"").append(value).append("\"");
            } else if (value instanceof Number || value instanceof Boolean) {
                sb.append(value);
            } else {
                sb.append(toJson(value));  // 중첩 객체 재귀 처리
            }
        }
        sb.append("}");
        return sb.toString();
    }
}

// 사용
public record Point(int x, int y) {}

Point p = new Point(3, 4);
System.out.println(SimpleJsonSerializer.toJson(p));
// {"x": 3, "y": 4}

10.3 JUnit 모방 — 테스트 실행기

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface BeforeEach {}

public class SimpleTestRunner {

    public static void run(Class<?> testClass) throws Exception {
        Object testInstance = testClass.getDeclaredConstructor().newInstance();

        // @BeforeEach 메서드 찾기
        Method beforeEach = Arrays.stream(testClass.getDeclaredMethods())
                .filter(m -> m.isAnnotationPresent(BeforeEach.class))
                .findFirst().orElse(null);

        // @Test 메서드 실행
        int passed = 0, failed = 0;
        for (Method method : testClass.getDeclaredMethods()) {
            if (!method.isAnnotationPresent(Test.class)) continue;

            try {
                if (beforeEach != null) beforeEach.invoke(testInstance);
                method.invoke(testInstance);
                System.out.println("[PASS] " + method.getName());
                passed++;
            } catch (InvocationTargetException e) {
                System.out.println("[FAIL] " + method.getName() + ": " + e.getCause().getMessage());
                failed++;
            }
        }
        System.out.printf("결과: %d 통과, %d 실패%n", passed, failed);
    }
}

11. 동적 프록시 — InvocationHandler

동적 프록시는 리플렉션을 이용해 런타임에 인터페이스 구현체를 생성합니다. Spring AOP의 핵심 원리입니다.

클라이언트는 실제 객체 대신 프록시 객체를 받습니다. 프록시는 동일한 인터페이스를 구현하지만, 모든 메서드 호출이 InvocationHandler.invoke()를 거칩니다. 여기에 로깅, 트랜잭션, 캐싱 등의 부가 기능을 삽입합니다.

graph LR
    CLI["클라이언트"] -->|"메서드 호출"| PX["프록시"]
    PX -->|"invoke"| HND["Handler"]
    HND -->|"부가기능 처리"| HND
    HND -->|"결과 반환"| CLI
// 인터페이스 정의
public interface UserService {
    User findById(long id);
    void save(User user);
    void delete(long id);
}

// 실제 구현체
public class UserServiceImpl implements UserService {
    @Override public User findById(long id) { return new User(id, "김자바"); }
    @Override public void save(User user) { System.out.println("저장: " + user); }
    @Override public void delete(long id) { System.out.println("삭제: " + id); }
}

// 로깅 프록시
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 {
        long start = System.currentTimeMillis();
        System.out.printf("[LOG] %s.%s() 호출 시작%n",
                target.getClass().getSimpleName(), method.getName());

        try {
            Object result = method.invoke(target, args);  // 실제 메서드 호출

            long elapsed = System.currentTimeMillis() - start;
            System.out.printf("[LOG] %s.%s() 완료 (%dms)%n",
                    target.getClass().getSimpleName(), method.getName(), elapsed);
            return result;
        } catch (InvocationTargetException e) {
            System.out.printf("[LOG] %s.%s() 예외: %s%n",
                    target.getClass().getSimpleName(), method.getName(), e.getCause().getMessage());
            throw e.getCause();
        }
    }
}

// 프록시 생성 및 사용
UserService realService = new UserServiceImpl();

UserService proxy = (UserService) Proxy.newProxyInstance(
        realService.getClass().getClassLoader(),   // 클래스 로더
        new Class<?>[] { UserService.class },      // 구현할 인터페이스
        new LoggingHandler(realService)            // 핸들러
);

// proxy는 UserService를 구현하지만 실제로는 LoggingHandler.invoke()가 호출됨
proxy.findById(1L);
proxy.save(new User(2L, "이자바"));

트랜잭션 프록시 예제

public class TransactionHandler implements InvocationHandler {
    private final Object target;

    public TransactionHandler(Object target) { this.target = target; }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        System.out.println("[TX] 트랜잭션 시작");
        try {
            Object result = method.invoke(target, args);
            System.out.println("[TX] 트랜잭션 커밋");
            return result;
        } catch (InvocationTargetException e) {
            System.out.println("[TX] 트랜잭션 롤백: " + e.getCause().getMessage());
            throw e.getCause();
        }
    }
}

// 여러 핸들러 체이닝 (데코레이터 패턴)
UserService logProxy = (UserService) Proxy.newProxyInstance(
        UserService.class.getClassLoader(),
        new Class<?>[] { UserService.class },
        new LoggingHandler(
            Proxy.newProxyInstance(
                UserService.class.getClassLoader(),
                new Class<?>[] { UserService.class },
                new TransactionHandler(realService)
            )
        )
);
// 호출 순서: LoggingHandler → TransactionHandler → UserServiceImpl

동적 프록시 한계

// Proxy.newProxyInstance는 인터페이스만 지원
// 클래스(구현체)를 직접 프록시하려면 CGLIB (Spring이 내부적으로 사용)

// CGLIB: 바이트코드 조작으로 클래스를 상속하는 프록시 생성
// → Spring의 @Transactional이 인터페이스 없어도 동작하는 이유

12. MethodHandle (Java 7+)

MethodHandle은 리플렉션보다 빠르고, JIT 컴파일러가 최적화할 수 있는 저수준 메서드 참조입니다.

// 성능: 직접 호출 ≒ MethodHandle >> Method.invoke()
// JIT: MethodHandle은 인라이닝 가능, Method.invoke()는 불가

import java.lang.invoke.*;

MethodHandles.Lookup lookup = MethodHandles.lookup();

// 1. 인스턴스 메서드
MethodType addType = MethodType.methodType(int.class, int.class, int.class);
MethodHandle addHandle = lookup.findVirtual(Calculator.class, "add", addType);

Calculator calc = new Calculator();
int result = (int) addHandle.invoke(calc, 3, 4);  // 7
// 또는
int result2 = (int) addHandle.invokeExact(calc, 3, 4);  // 타입 정확히 일치해야 함

// 2. static 메서드
MethodHandle squareHandle = lookup.findStatic(
        Calculator.class, "square",
        MethodType.methodType(int.class, int.class));
int sq = (int) squareHandle.invoke(5);  // 25

// 3. 생성자
MethodHandle ctorHandle = lookup.findConstructor(
        Product.class,
        MethodType.methodType(void.class, String.class, int.class));
Product p = (Product) ctorHandle.invoke("노트북", 1_500_000);

// 4. 필드 접근
MethodHandle getNameHandle = lookup.findGetter(Person.class, "name", String.class);
MethodHandle setNameHandle = lookup.findSetter(Person.class, "name", String.class);

Person person = new Person();
setNameHandle.invoke(person, "김자바");
String name = (String) getNameHandle.invoke(person);

// 5. private 멤버 접근 (Java 9+ PrivateLookupIn)
MethodHandles.Lookup privateLookup =
        MethodHandles.privateLookupIn(Person.class, MethodHandles.lookup());
MethodHandle privateFieldHandle = privateLookup.findGetter(
        Person.class, "email", String.class);

// 6. MethodHandle 조합 (커링, 바인딩)
// 첫 번째 인수를 calc로 바인딩
MethodHandle boundAdd = addHandle.bindTo(calc);
int result3 = (int) boundAdd.invoke(10, 20);  // 30

// 7. asType으로 타입 변환
MethodHandle flexibleAdd = addHandle.asType(
        MethodType.methodType(Object.class, Object.class, int.class, int.class));

MethodHandle vs Method.invoke 비교

// Method.invoke — 범용, 느림, 오래된 API
Method m = Calculator.class.getMethod("add", int.class, int.class);
m.setAccessible(true);
Object r1 = m.invoke(calc, 3, 4);  // 오토박싱 발생

// MethodHandle — 빠름, JIT 최적화 가능
MethodHandle mh = lookup.findVirtual(Calculator.class, "add",
        MethodType.methodType(int.class, int.class, int.class));
int r2 = (int) mh.invokeExact(calc, 3, 4);  // 오토박싱 없음

// VarHandle (Java 9+) — 필드 접근의 원자적 연산
VarHandle vh = MethodHandles.lookup().findVarHandle(Person.class, "name", String.class);
vh.set(person, "새이름");
String current = (String) vh.get(person);
vh.compareAndSet(person, "새이름", "변경된이름");  // CAS 연산

13. 비유와 실무 실수

비유: 건물 설계도 vs 건물

Class 객체는 건물의 설계도입니다. 설계도를 보면 방이 몇 개인지(필드), 출입구가 어디인지(메서드), 어떤 재료로 지었는지(어노테이션)를 알 수 있습니다. 리플렉션은 설계도를 보고 건물에 새 문을 뚫거나(setAccessible), 원하는 방으로 직접 이동하는(invoke) 능력입니다.

극한 시나리오: 플러그인 시스템

// 런타임에 외부 JAR에서 플러그인 클래스 로딩
File pluginJar = new File("plugins/analytics.jar");
URLClassLoader loader = new URLClassLoader(
    new URL[]{pluginJar.toURI().toURL()},
    getClass().getClassLoader()
);

Class<?> pluginClass = loader.loadClass("com.plugin.AnalyticsPlugin");
Object plugin = pluginClass.getDeclaredConstructor().newInstance();

// 인터페이스 없이도 메서드 호출 가능
Method processMethod = pluginClass.getMethod("process", EventData.class);
processMethod.invoke(plugin, eventData);
// → 컴파일 시점에 플러그인 클래스를 알 수 없어도 동작

실무 실수

실수 1: getDeclaredMethod 결과를 매번 새로 조회

// 나쁜 예: 요청마다 리플렉션 조회 → 성능 저하
public void handleRequest(Object target, String methodName) throws Exception {
    Method m = target.getClass().getDeclaredMethod(methodName); // 매번!
    m.setAccessible(true);
    m.invoke(target);
}

// 좋은 예: 캐싱
private static final Map<String, Method> CACHE = new ConcurrentHashMap<>();

실수 2: InvocationTargetException 원인 예외 무시

// 나쁜 예: 래핑된 예외만 처리
try {
    method.invoke(obj, args);
} catch (InvocationTargetException e) {
    log.error("실패", e);  // 실제 원인이 e.getCause()에 있음!
}

// 좋은 예: 원인 예외 추출
} catch (InvocationTargetException e) {
    Throwable cause = e.getCause();
    log.error("실패: {}", cause.getMessage(), cause);
    throw cause;
}

14. 리플렉션 주의사항 정리

리플렉션 사용 시 반드시 고려해야 할 6가지 사항입니다.

성능 비용: Method, Field, Constructor 객체를 캐싱하고 setAccessible(true)을 한 번만 호출합니다. 고빈도 호출에는 MethodHandle 또는 코드 생성을 고려합니다.

타입 안전성 상실: 컴파일 타임 오류 대신 런타임 예외(ClassCastException, IllegalArgumentException, InvocationTargetException)가 발생합니다. 충분한 테스트가 필요합니다.

캡슐화 훼손: private 접근은 설계 의도를 무시합니다. 테스트 목적 외에는 최소한으로 사용합니다.

모듈 시스템(Java 9+): 모듈 간 접근 시 InaccessibleObjectException이 발생합니다. module-info.javaopens 선언이 필요합니다.

보안: SecurityManager 환경에서는 리플렉션 권한이 제한될 수 있습니다. 신뢰할 수 없는 코드에 리플렉션 권한을 부여하지 않습니다.

GraalVM Native Image: 리플렉션은 AOT 컴파일 시 정적 분석이 불가합니다. reflect-config.json으로 명시적 등록이 필요합니다.


15. 전체 구조 요약

graph LR
    REFLECT["java.lang.reflect"] --> CLASS["Class&lt;T&gt;"]
    CLASS --> FIELD["Field"]
    CLASS --> METHOD["Method"]
    CLASS --> CTOR["Constructor"]
    CLASS --> PROXY["Proxy"]
    REFLECT --> INVOKE["java.lang.invoke"]

핵심 정리

기능 API 주요 메서드
클래스 로딩 Class forName(), .class, getClass()
필드 접근 Field get(), set(), setAccessible()
메서드 호출 Method invoke(), setAccessible()
객체 생성 Constructor newInstance()
어노테이션 AnnotatedElement getAnnotation(), isAnnotationPresent()
동적 프록시 Proxy newProxyInstance()
고성능 접근 MethodHandle invoke(), invokeExact()
원자적 필드 접근 VarHandle get(), set(), compareAndSet()

면접 포인트

Q. 리플렉션의 성능 비용은 얼마나 되며 어떻게 완화하는가?

리플렉션 메서드 호출은 일반 호출보다 10~100배 느립니다. JIT 컴파일러가 리플렉션 호출을 인라이닝하지 못하기 때문입니다. JMH 벤치마크 기준 일반 메서드 호출 ~1ns, 리플렉션 호출 ~50~200ns. 완화 방법: ① Method, Field 객체를 캐싱(매번 getDeclaredMethod() 호출 금지) ② setAccessible(true)를 캐싱된 객체에 한 번만 호출 ③ 고성능이 필요하면 MethodHandle(Java 7+) 또는 LambdaMetafactory(JIT 최적화 가능)를 사용합니다.

Q. Spring이 리플렉션을 사용하는 방식과 성능 대응은?

Spring은 @Autowired, @Value 주입, @Transactional AOP 프록시 생성, @RequestMapping 핸들러 매핑 등에 리플렉션을 사용합니다. 그러나 대부분 애플리케이션 시작(startup) 시 한 번 실행되고 결과를 캐싱합니다. 요청 처리 경로에서는 캐싱된 Method 객체나 CGLIB/ByteBuddy가 생성한 바이트코드를 사용하므로 런타임 성능 영향은 미미합니다. GraalVM Native Image에서는 리플렉션 사용 정보를 reflect-config.json에 사전 등록해야 합니다.

Q. 리플렉션으로 private 필드에 접근하는 것이 캡슐화를 깨는가?

기술적으로 setAccessible(true)로 접근 가능하지만, 이는 의도적 캡슐화 우회입니다. 테스트 코드에서 내부 상태를 검증하기 위해 private 필드에 리플렉션으로 접근하는 것은 구현 세부사항에 테스트가 결합되는 문제를 만듭니다. 구현이 바뀌면 테스트가 깨집니다. 대안: 패키지-private 접근자나 테스트 전용 생성자를 제공합니다. Java 9+ 모듈 시스템에서는 모듈 외부에서 setAccessible()이 기본적으로 차단됩니다.

Q. 어노테이션 프로세서와 런타임 리플렉션의 차이는?

런타임 리플렉션은 실행 중에 클래스 정보를 조회합니다. 어노테이션 프로세서(javax.annotation.processing)는 컴파일 타임에 소스 코드를 분석해 코드를 생성합니다. Lombok의 @Getter, @Builder는 컴파일 타임에 바이트코드를 직접 조작합니다(런타임 비용 0). MapStruct는 컴파일 타임에 매핑 코드를 생성합니다. 성능이 중요한 코드 생성은 어노테이션 프로세서가 런타임 리플렉션보다 훨씬 효율적입니다.

Q. 직렬화 프레임워크(Jackson, Gson)가 리플렉션을 사용하는 원리는?

Jackson은 ObjectMapper가 클래스의 필드/게터를 리플렉션으로 조회하고, BeanDeserializer를 생성해 JSON → 객체 변환 방법을 캐싱합니다. 최초 직렬화/역직렬화 시 리플렉션 비용이 발생하지만 이후 캐싱된 BeanSerializer를 재사용합니다. Jackson 2.10+의 afterburner 모듈은 리플렉션 대신 바이트코드 생성으로 약 20~30% 성능 향상을 제공합니다. Record 클래스(Java 16+)는 Jackson이 직접 지원합니다.

Q. 리플렉션이란 무엇이고 어떤 상황에 사용하나요?

A. 리플렉션은 런타임에 클래스, 메서드, 필드 정보를 조회하고 조작하는 Java API다. 클래스 이름을 문자열로 받아 동적으로 인스턴스를 생성하거나, private 멤버에 접근할 수 있다. Spring의 @Autowired 의존성 주입, JPA의 엔티티 매핑, JUnit의 테스트 메서드 발견이 리플렉션으로 구현된다.

Q. 리플렉션의 성능 문제를 어떻게 완화하나요?

A. Method, Field 객체를 매번 조회하지 않고 캐싱해 재사용한다. MethodHandle(Java 7+)은 리플렉션보다 빠르고 JIT 최적화가 가능하다. 반복 호출이 많은 경우 바이트코드 생성(ASM, ByteBuddy)으로 리플렉션을 완전히 대체하는 것도 방법이다.

Q. setAccessible(true)의 보안 위험은?

A. setAccessible(true)는 private 접근 제한을 우회해 내부 상태를 외부에서 수정 가능하게 한다. 캡슐화가 파괴되고, 프레임워크가 아닌 비즈니스 코드에서 사용하면 유지보수가 어려워진다. Java 9+ 모듈 시스템에서는 opens 선언 없이 다른 모듈의 private 접근이 차단된다.

Q. 리플렉션으로 생성한 객체와 일반 생성자로 생성한 객체의 차이점은?

A. 기능상 차이는 없지만, 리플렉션 생성은 컴파일 타임 타입 검사를 받지 않아 예외가 런타임에 발생한다. 생성자가 없거나 시그니처가 맞지 않으면 NoSuchMethodException이 던져진다. 또한 리플렉션 생성자 호출은 일반 생성자 대비 수십 배 느릴 수 있다.

Q. 어노테이션 프로세싱과 리플렉션의 차이점은?

A. 어노테이션 프로세싱은 컴파일 타임에 어노테이션을 분석해 코드나 설정을 생성한다. Lombok, MapStruct가 이 방식을 사용한다. 리플렉션은 런타임에 어노테이션 정보를 읽는다. 컴파일 타임 처리는 런타임 오버헤드가 없고, 런타임 리플렉션은 동적으로 동작하지만 성능 비용이 있다.


함께 읽으면 좋은 글

카테고리:

업데이트:

댓글

이 글이 도움이 됐다면?

같은 카테고리의 다른 글도 확인해보세요

더 많은 글 보기 →