본문 바로가기
Java

리플렉션(Reflection)

by sangyunpark99 2025. 3. 7.
프록시로 부가 기능을 추가하고 싶은데,
클래스를 생성할 때마다 매번 프록시 객체를 따로 만들어야 할까요?

 

 

이번글은 동적 프록시에 사용되는 리플렉션에 대해서 정리했습니다.

참고 강의 : 김영한의 스프링 핵심 원리 - 고급편

 

만약 새로 생기는 클래스 100만개가 있다면, 어떻게 될까요?

 

만약 새로운 클래스가 100만 개나 생긴다면, 이 100만 개의 클래스마다 일일이 프록시 객체를 생성해주는 것은 매우 비효율적입니다.
이러한 문제를 해결하기 위해, 동적 프록시기법을 활용할 수 있습니다.

 

동적 프록시는 리플렉션(reflection)을 이용해 런타임에 필요한 부가 기능을 프록시 객체에 주입하는 방식으로,
별도의 프록시 클래스를 100만 개 준비할 필요 없이, 프록시 생성 로직을 공통화하여 관리할 수 있습니다.
이로써, 메모리 절약은 물론, 유지보수성도 크게 개선할 수 있습니다.

 

리플렉션

 

리플렉션은 런타임 시간에 클래스의 메타 정보를 읽고, 객체를 생성하거나 메서드를 호출하고, 필드 값을 조회 및 수정할 수 있게 해주는 Java의 매커니즘입니다. 컴파일 시점이 아닌 실행 시점에 타입 정보를 다룰 수 있기 때문에, 프레임워크나 라이브러리에서 동적 프록시 생성, 의존성 주입, 어노테이션 기반 처리 등 다양한 기능에 활용이 됩니다.

 

코드로 리플렉션을 간단하게 알아보겠습니다.

 

ReflectionTest

@Slf4j
public class ReflectionTest {

    @Test
    void reflection0() {

        Hello target = new Hello();

	// 공통 로직 1
        log.info("start");
        String result1 = target.callA();
        log.info("result={}", result1);

	// 공통 로직 2
        log.info("start");
        String result2 = target.callB();
        log.info("result={}", result2);
    }


    @Slf4j
    static class Hello {
        public String callA() {
            log.info("callA");
            return "A";
        }

        public String callB() {
            log.info("callB");
            return "B";
        }
    }
}

 

출력 결과

 

공통 로직 1부분과 공통 로직 2부분은 호출하는 메서드만 callA(), callB()로 다르고 코드 흐름이 완전히 동일합니다.

중복된 흐름이므로 하나의 메서드로 뽑아내자니 중간에 호출되는 메서드(callA, callB)가 서로 다르기 때문에 쉽지 않아 보입니다.

이 문제는 리플렉션을 사용해서 해결할 수 있습니다.

 

리플렉션을 적용한 간단한 코드는 다음과 같습니다.

 

ReflectionTset

@Test
void reflection1() throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
    Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");

    Hello target = new Hello();

    // callA 메서드 정보
    Method methodCallA = classHello.getMethod("callA");
    Object result1 = methodCallA.invoke(target);
    log.info("result={}", result1);

    // callB 메서드 정보
    Method methodCallB = classHello.getMethod("callB");
    Object result2 = methodCallB.invoke(target);

    log.info("result2={}", result2);
}

 

 

출력 결과

 

클래스의 메서드를 따로 호출하지 않았는데도 리플렉션을 사용해서 callA와 callB 메서드를 호출해줍니다.

 

각 코드는 어떤 의미를 가질까요?
Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");

 

Class.forName을 사용해서 Hello 클래스의 메타정보를 획득합니다. $는 내부 클래스 구분을 위해서 사용합니다.

$가 없다면 ReflectionTestHello가 되고, 이는 어디서부터 클래스 이름의 시작인지 알 수 없게 됩니다.

 

Method methodCallA = classHello.getMethod("callA");

 

classHello.getMethod("callA")를 사용해서 해당 클래스(Hello)의 call 메서드 정보를 획득합니다.

 

Object result1 = methodCallA.invoke(target);

 

획득한 callA 메서드 메타 정보를 통해서 실제 인스턴스의 메서드를 호출합니다.

invoke 메서드를 호출하면서 인스턴스를 넘겨주면, 해당 인스턴스의 callA() 메서드를 찾아서 실행해줍니다.

 

그냥 target.callA(), target.callB()를 호출하면 되는데, 굳이 왜 이런 방식으로 하나요?

 

리플릭션을 사용하면 같은 흐름을 가지는 로직 1부분과 공통 로직 2부분을 공통 처리 로직으로 분리할 수 있습니다.

 

 

ReflectionTest - dynamicCall

@Test
void reflection2() throws Exception {
    Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");
    
    Hello target = new Hello();
    Method methodCallA = classHello.getMethod("callA");
    dynamicCall(methodCallA,target);
    
    Method methodCallB = classHello.getMethod("callB");
    dynamicCall(methodCallB, target);
}

private void dynamicCall(Method method, Object target) throws Exception {
    log.info("start");
    Object result = method.invoke(target);
    log.info("result={}", result);
}

 

dynamicCall 메서드를 선언해서 공통 처리 로직으로 분리했습니다.

Method method 파라미터에는 호출할 메서드 정보가 넘어오고, Object target에는 실제 실행할 인스턴스 정보가 넘어오게 됩니다.

단, method.invoke(target)을 호출할 때 클래스와 메서드 정보가 서로 다르면 예외가 발생합니다.

 

예외가 발생하는 이유는 invoke 메서드 내부에 확인을 하는 로직이 존재합니다.

확인하는 로직이 정말 존재하는지 메서드 DFS를 해보겠습니다.

@CallerSensitive
@ForceInline // to ensure Reflection.getCallerClass optimization
@HotSpotIntrinsicCandidate
public Object invoke(Object obj, Object... args)
    throws IllegalAccessException, IllegalArgumentException,
       InvocationTargetException
{
    if (!override) {
        Class<?> caller = Reflection.getCallerClass();
        checkAccess(caller, clazz,
                    Modifier.isStatic(modifiers) ? null : obj.getClass(),
                    modifiers);
    }
    MethodAccessor ma = methodAccessor;             // read volatile
    if (ma == null) {
        ma = acquireMethodAccessor();
    }
    return ma.invoke(obj, args);
}

 

checkAccess 메서드의 내부 로직은 다음과 같습니다.

final void checkAccess(Class<?> caller, Class<?> memberClass,
                       Class<?> targetClass, int modifiers)
    throws IllegalAccessException
{
    if (!verifyAccess(caller, memberClass, targetClass, modifiers)) {
        IllegalAccessException e = Reflection.newIllegalAccessException(
            caller, memberClass, targetClass, modifiers);
        if (printStackTraceWhenAccessFails()) {
            e.printStackTrace(System.err);
        }
        throw e;
    }
}

 

Verify 로직은 다음과 같습니다.

final boolean verifyAccess(Class<?> caller, Class<?> memberClass,
                           Class<?> targetClass, int modifiers)
{
    if (caller == memberClass) {  // quick check
        return true;             // ACCESS IS OK
    }
    Object cache = securityCheckCache;  // read volatile
    if (targetClass != null // instance member or constructor
        && Modifier.isProtected(modifiers)
        && targetClass != memberClass) {
        // Must match a 2-list of { caller, targetClass }.
        if (cache instanceof Class[]) {
            Class<?>[] cache2 = (Class<?>[]) cache;
            if (cache2[1] == targetClass &&
                cache2[0] == caller) {
                return true;     // ACCESS IS OK
            }
            // (Test cache[1] first since range check for [1]
            // subsumes range check for [0].)
        }
    } else if (cache == caller) {
        // Non-protected case (or targetClass == memberClass or static member).
        return true;             // ACCESS IS OK
    }

 

아래 로직에서 실제 호출자 클래스와 리플렉션 대상 클래스가 동일한 클래스인지 확인 합니다.

이 코드에서 caller는 실제 호출자 클래스를 memberClass는 리플렉션 대상 클래스를 의미합니다.

    if (caller == memberClass) {  // quick check
        return true;             // ACCESS IS OK
    }

 

 

출력 결과 

 

target.callA(), target.callB()로 각각 호출할때와 동일하게 출력되었습니다.

traget.callA()와 target.callB() 코드를 리플렉션을 사용해서 Method라는 메타정보로 추상화하여 공통 로직을 분리했습니다.

 

리플렉션을 사용할 때, 주의해야 할 점은 없나요?

 

리플렉션은 런타임 시점에 동작하기 때문에, 컴파일 시점에서는 타입 체크나 메서드 존재 여부 등을 확인할 수 없습니다.
이로 인해, 잘못된 접근이 있더라도 컴파일 에러가 아닌, 런타임 에러로 이어집니다.

 

이러한 런타임 에러는 실제 서비스 중에 사용자에게 직접 영향을 줄 수 있는 치명적인 오류로 이어질 가능성이 있습니다.
따라서, 리플렉션은 프레임워크 개발이나, 다양한 클래스에 공통적으로 부가 기능을 적용하는 경우에만 제한적으로 사용해야 합니다.
필요할 때는 반드시 충분한 검증과 주의가 필요합니다.

'Java' 카테고리의 다른 글

[JVM] 핫스팟 VM에서의 객체 돌아보기  (0) 2025.04.16
[JVM] OutOfMemory 예외  (0) 2025.04.15
ArrayList가 크기를 늘리는 방법  (0) 2025.02.08
[Java] Serialization  (2) 2025.01.27
부동 소수점  (0) 2025.01.11