🔦 리플렉션

리플렉션(Reflection)실행 중(런타임)에 클래스/메서드/필드 정보에 접근해서, 객체를 만들거나 메서드를 호출하거나 값을 읽고, 바꾸는 기능이다. 즉, 코드를 미리 확정하지 않고, 런타임에 구조를 들여다보고 조작하는 기술이라고 이해하면 된다. 자바가 기본으로 제공하는 JDK 동적 프록시 기술이나 CGLIB 같은 프록시 생성 오픈소스를 이해하기 위한 최소한의 리플렉션 기술에 대해 알아보자.

package com.example.advanced_spring.proxy.jdkdynamic;

import java.lang.reflect.Method;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

@Slf4j
public class ReflectionTest {

    @Test
    void reflectionV0() {
        Hello target = new Hello();

        log.info("Start...");
        String result1 = target.methodA();
        log.info("result1: {}", result1);

        log.info("Start...");
        String result2 = target.methodB();
        log.info("result2: {}", result2);

        /**
         * 13:03:01.047 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest -- Start...
         * 13:03:01.049 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest$Hello -- calling A...
         * 13:03:01.049 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest -- result1: A
         * 13:03:01.049 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest -- Start...
         * 13:03:01.049 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest$Hello -- calling B...
         * 13:03:01.049 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest -- result2: B
         */
    }

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

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

reflectionV0을 보면 호출하는 메서드만 다를 뿐, 코드 흐름은 완전히 같다. 그렇다면 공통된 로직을 메서드로 하나로 뽑을 수는 없을까? 이는 생각보다 어렵다. 왜냐하면 중간에 호출하는 메서드가 다르기 때문이다. 딱 저 메서드를 호출하는 부분만 동적으로 처리할 수 있다면 얼마나 좋을까?

 

이럴 때 사용하는 기술이 바로 리플렉션이다. 리플렉션은 클래스나 메서드의 메타정보를 사용해서 동적으로 호출하는 메서드를 변경할 수 있다. 아래 코드를 살펴보자.

...

@Test
void reflectionV1() throws Exception {
    // 클래스 정보
    Class classHello = Class.forName("com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest$Hello");

    Hello target = new Hello();
    Method methodA = classHello.getMethod("methodA");
    Object result1 = methodA.invoke(target);
    log.info("result1: {}", result1);

    Method methodB = classHello.getMethod("methodB");
    Object result2 = methodB.invoke(target);
    log.info("result2: {}", result2);

    /**
     * 13:07:35.329 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest$Hello -- calling A...
     * 13:07:35.331 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest -- result1: A
     * 13:07:35.331 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest$Hello -- calling B...
     * 13:07:35.331 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest -- result2: B
     */
     
     ...

일단 클래스의 메타정보를 획득하기 위해 Class.forName("해당 클래스의 패키지 경로")를 사용한다. 참고로 내부 클래스 구분하기 위해 $를 사용했다. getMethod()를 이용해서 특정 메서드의 메타정보를 획득한 후에 invoke()로 실제 인스턴스의 메서드를 호출하면 된다. 여기서 methodAHello 클래스의 methodA()라는 메서드 메타정보다. methodA.invoke(target)를 호출하면서 인스턴스를 넘겨주면 해당 인스턴스의 methodA() 메서드를 찾아서 실행하는 것이다.

 

꽤 복잡하다. 이렇게 메서드 정보를 획득해서 메서드를 호출하는 이유가 뭘까? 아래 코드를 보자.

...

@Test
void reflectionV2() throws Exception {
    Class classHello = Class.forName("com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest$Hello");

    Hello target = new Hello();

    Method methodA = classHello.getMethod("methodA");
    dynamicCall(methodA, target);

    Method methodB = classHello.getMethod("methodB");
    dynamicCall(methodB, target);

    /**
     * 13:13:49.726 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest -- Start...
     * 13:13:49.728 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest$Hello -- calling A...
     * 13:13:49.728 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest -- result: A
     * 13:13:49.729 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest -- Start...
     * 13:13:49.729 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest$Hello -- calling B...
     * 13:13:49.729 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.ReflectionTest -- result: B
     */
}

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

공통 로직을 수행하는 dynamicCall() 메서드를 추가했다. 이 메서드는 첫 번째 파라미터로 “호출할 메서드 정보” 가 넘어온다. 이 부분이 핵심이다. 기존에는 메서드 이름을 직접 호출했지만, 이제는 Method라는 메타정보를 통해 호출할 메서드 정보가 동적으로 제공되는 것이다. 그리고 두 번째 파라미터로 실제 실행할 인스턴스 정보가 넘어온다.

 

💥 주의 사항

리플렉션은 런타임에 동작하기 때문에 컴파일 시점에 오류를 알아차릴 수 없다. 따라서 일반적으로는 리플렉션 사용이 권장되지 않는다. 리플렉션은 프레임워크 개발이나 매우 일반적인 공통 처리가 필요할 때 부분적으로 주의해서 사용해야 한다.

 

⚙ JDK 동적 프록시

먼저 자바가 기본으로 제공하는 JDK 동적 프록시에 대해 알아보기 위해 간단한 예제를 살펴보자.

// 인터페이스 (JDK 동적 프록시는 인터페이스가 필수적)
package com.example.advanced_spring.proxy.jdkdynamic.code;

public interface AInterface {
    String call();
}

// 구현체
package com.example.advanced_spring.proxy.jdkdynamic.code;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class AInterfaceImpl implements AInterface {
    @Override
    public String call() {
        log.info("A 호출...");
        return "a";
    }
}
// 인터페이스
package com.example.advanced_spring.proxy.jdkdynamic.code;

public interface BInterface {
    String call();
}

// 구현체
package com.example.advanced_spring.proxy.jdkdynamic.code;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class BInterfaceImpl implements BInterface {
    @Override
    public String call() {
        log.info("B 호출...");
        return "b";
    }
}

 

이제 동적 프록시에 적용할 로직은 JDK 동적 프록시가 제공하는 InvocationHandler 인터페이스를 구현하면 된다.

package java.lang.reflect;

public interface InvocationHandler {
	public Object invoke(Object proxy, Method method, Object[] args) throws Throwable;
}

간단하게 코멘트하자면, proxy는 프록시 자기 자신, method는 호출한 메서드, args는 메서드를 호출할 때 전달한 파라미터를 뜻한다.

 

이제 실행 시간을 측청하는 구현체를 만들고 테스트 해보도록 하자.

package com.example.advanced_spring.proxy.jdkdynamic.code;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class TimeInvocationHandler implements InvocationHandler {

    private final Object target;  // 프록시가 호출할 대상

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

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        log.info("TimeProxy 실행...");
        long startTime = System.currentTimeMillis();

        // 여기서 메서드를 동적으로 호출
        Object result = method.invoke(target, args);

        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("TimeProxy 종료 resultTime={}", resultTime);
        return result;
    }
}
package com.example.advanced_spring.proxy.jdkdynamic;

import com.example.advanced_spring.proxy.jdkdynamic.code.AInterface;
import com.example.advanced_spring.proxy.jdkdynamic.code.AInterfaceImpl;
import com.example.advanced_spring.proxy.jdkdynamic.code.BInterface;
import com.example.advanced_spring.proxy.jdkdynamic.code.BInterfaceImpl;
import com.example.advanced_spring.proxy.jdkdynamic.code.TimeInvocationHandler;
import java.lang.reflect.Proxy;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

@Slf4j
public class JdkDynamicProxyTest {

    @Test
    void dynamicA() {
        AInterface target = new AInterfaceImpl();
        TimeInvocationHandler handler = new TimeInvocationHandler(target);
        AInterface proxy = (AInterface) Proxy.newProxyInstance(
                AInterface.class.getClassLoader(),
                new Class[]{AInterface.class}, handler);

        proxy.call();
        log.info("targetClass={}", target.getClass());
        log.info("proxyClass={}", proxy.getClass());

        /**
         * 13:39:51.584 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.code.TimeInvocationHandler -- TimeProxy 실행...
         * 13:39:51.586 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.code.AInterfaceImpl -- A 호출...
         * 13:39:51.586 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.code.TimeInvocationHandler -- TimeProxy 종료 resultTime=0
         * 13:39:51.587 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.JdkDynamicProxyTest -- targetClass=class com.example.advanced_spring.proxy.jdkdynamic.code.AInterfaceImpl
         * 13:39:51.587 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.JdkDynamicProxyTest -- proxyClass=class jdk.proxy3.$Proxy12
         */
    }

    @Test
    void dynamicB() {
        BInterface target = new BInterfaceImpl();
        TimeInvocationHandler handler = new TimeInvocationHandler(target);
        BInterface proxy = (BInterface) Proxy.newProxyInstance(
                BInterface.class.getClassLoader(),
                new Class[]{BInterface.class}, handler);

        proxy.call();
        log.info("targetClass={}", target.getClass());
        log.info("proxyClass={}", proxy.getClass());

        /**
         * 13:40:09.633 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.code.TimeInvocationHandler -- TimeProxy 실행...
         * 13:40:09.635 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.code.BInterfaceImpl -- B 호출...
         * 13:40:09.635 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.code.TimeInvocationHandler -- TimeProxy 종료 resultTime=0
         * 13:40:09.636 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.JdkDynamicProxyTest -- targetClass=class com.example.advanced_spring.proxy.jdkdynamic.code.BInterfaceImpl
         * 13:40:09.636 [Test worker] INFO com.example.advanced_spring.proxy.jdkdynamic.JdkDynamicProxyTest -- proxyClass=class jdk.proxy3.$Proxy12
         */
    }
}

위의 테스트 코드에서 Proxy.newProxyInstance(AInterface.class.getClassLoader(), new Class[]{AInterface.class}, handler) 부분을 살펴보자. 동적 프록시는 java.lang.reflect.Proxy를 통해서 생성할 수 있고, 클래스 로더 정보, 인터페이스, 핸들러 로직을 넣어주면 된다. 그러면 해당 인터페이스를 기반으로 동적 프록시를 생성하고 그 결과를 반환한다.

 

실행 순서를 자세히 뜯어보면서 이해해보자.

  1. client는 JDK 동적 프록시의 call()을 실행한다.

  2. JDK 동적 프록시는 InvocationHandler.invoke()를 호출한다. 현재 예시 코드에서는 TimeInvocationHandler가 구현체로 있으므로 TimeInvocationHandler.invoke()가 호출된다.

  3. TimeInvocationHandler가 내부 로직을 수행하고, method.invoke(target, args)를 호출해서 target인 실제 객체를 호출한다.

  4. 실제 객체 인스턴스의 call()이 호출된다.

  5. 인스턴스의 call()의 실행이 끝나면 TimeInvocationHandler로 응답이 돌아오고, 시간 로그를 출력 후 결과를 반환한다.

 

정리하면, AInterfaceImpl, BInterfaceImpl는 각각 프록시를 만든 것이 아니다. 프록시는 JDK 동적 프록시를 사용해서 동적으로 만들고 TimeInvocationHandler는 공통으로 사용했다. JDK 동적 프록시 기술 덕분에 적용 대상 만큼 프록시 객체를 만들지 않아도 되고, 같은 부가 기능 로직을 한번만 개발해서 공통으로 적용할 수 있다. 만약 적용 대상이 100개여도 동적 프록시를 통해서 생성하고, 각각 필요한 InvocationHandler 만 만들어서 넣어주면 된다. 결과적으로 수많은 프록시 클래스를 만들어야 하는 문제도 해결하고, 부가 기능 로직도 하나의 클래스에 모아서 SRP 원칙도 지킬 수 있게 된 것이다.

 

🎃 CGLIB

CGLIB는 바이트코드를 조작해서 동적으로 클래스를 생성하는 기술을 제공하는 라이브러리다. 이 라이브러리를 사용하면 인터페이스 없이 구체 클래스만 가지고도 동적 프록시를 만들 수 있다. 대략 뭔지만 알고 넘어가도록 하자.

 

CGLIB는 아래 MethodInterceptor를 제공한다.

package org.springframework.cglib.proxy;

public interface MethodInterceptor extends Callback {
		Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable;
}

여기서 obj는 CGLIB가 적용된 객체, method는 호출된 메서드, args는 메서드를 호출하면서 전달된 파라미터, proxy는 메서드 호출에 사용되는 프록시다.

 

아래 클래스 의존 관계와 런타임에서의 의존 관계를 간단히 살펴보고 넘어가자.

profile
판교 함 가보자

0개의 댓글