Spring-Boot/스프링핵심원리 - 고급편

Spring 핵심 원리 고급편 - 5. 동적 프록시 기술

PHM 2022. 6. 7. 13:42

- 자바가 기본으로 제공하는 JDK 동적 프록시 기술이나 CGLIB 같은 프록시 생성 오픈소스 기술을 활용하면 프록시 객체를 동적으로 만들어 낼 수 있다.

- 프록시를 적용할 코드를 하나만 만들어두고 동적 프록시 기술을 사용해서 프록시 객체를 찍어내면 된다.

 

- JDK 동적 프록시를 이해하기 위해서는 먼저 자바의 리플렉션 기술을 이해해야 한다.

리플렉션

- 리플렉션 기술을 사용하면 클래스나 메서드의 *메타정보*를 동적으로 획득하고, 코드도 동적으로 호출할 수 있다.

 

* 참고 * - 람다를 사용해서 공통화하는 것도 가능!

 

   @Test
    void reflection1() throws Exception {
        // 클래스 정보
        // 클래스 메타정보를 획득한다. 참고로 내부 클래스는 구분을 위해 $를 사용한다.
        Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");

        Hello target = new Hello();
        
        // 해당 클래스의 call메서드 메타정보를 획득
        Method methodCallA = classHello.getMethod("callA");
        
        // 획득한 메서드 메타정보로 실제 인스턴스의 메서드를 호출
        Object result1 = methodCallA.invoke(target);    // 동적으로 콜이 가능
        log.info("result1={}",result1);
    }

- 중요한 핵심은 클래스나 메서드 정보를 동적으로 변경할 수 있다는 점이다!

 

    @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 )

     ㆍ공통로직1, 공통 로직2를 한번에 처리할 수 있는 통합된 공통 처리 로직이다.

     ㆍMethod method : 첫번째 파라미터는 호출할 메서드 정보가 넘어온다. 이것이 핵심이다.

기존의 메서드 이름을 직접 호출했지만, 이제는 ' Method '라는 메타정보를 통해서 호출할 메서드 정보가 동적으로 제공

     ㆍObject target : 실제 실행할 인스턴스 정보가 넘어온다.

                                타입이 Object라는 것은 어떠한 인스턴스도 받을 수 있다는 뜻이다. 

 

* 정리 *

: 정적인 target.callA(), target.callB() 코드를 리플렉션을 사용해서 Method라는 메타정보로 추상화했다. 덕분에 공통 로직을 만들 수 있게 되었다.

 

* 주의 *

- 리플랙션을 사용하면 클래스와 메서드의 메타정보를 사용해서 애플리케이션을 동적으로 유연하게 만들 수 있다.

- 하지만 리플렉션 기술은 런타임에 동작하기 때문에, 컴파일 시점에 오류를 잡을 수 없다.

- " getMethod("callZ") " 로 작성해도 컴파일오류가 발생하지 않고 직접 실행하는 시점에 발생하는 오류인 런타임에 오류가 발생한다.

- 가장 좋은 오류는 개발자가 즉시 확인할 수 있는 컴파일 오류이고, 가장 무서운 오류는 사용자가 직접 실행할 때 발생하는 런타임 오류다.

- 리플렉션은 일반적으로 사용하면 안된다. 지금까지 프로그래밍 언어가 발달하면서 타입 정보를 기반으로 컴파일 시점에 오류를 잡아준 덕분에 개발자가 편하게 살았는데, 리플렉션은 그것에 역행하는 방식이다.

- 리플렉션은 프레임워크 개발이나 또는 매우 일반적인 공통 처리가 필요할 때 부분적으로 주의해서 사용해야 한다.

 


JDK 동적 프록시

- 동적 프록시 기술을 사용하면 개발자가 직접 프록시 클래스를 만들지 않아도 된다. 이름 그대로 프록시 객체를 동적으로 런타임에 개발자 대신 만들어준다. 그리고 동적 프록시에 원하는 실행 로직을 지정할 수 있다.

 

* 주의 * - JDK 동적프록시는 인터페이스를 기반으로 프록시를 동적으로 만들어준다. 따라서 인터페이스가 필수이다.

 

JDK 동적 프록시 InvocationHandler

- Object proxy : 프록시 자신

- Method method : 호출한 메서드

- Object[ ] args : 메서드를 호출할 때 전달한 인수

 

@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();

        // 리플렉션을 사용해서 target 인스턴스의 메서드를 실행한다
        // args는 메서드 호출시 넘겨줄 인수이다.
        Object result = method.invoke(target, args);

        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("TimeProxy 종료 resultTume={}", resultTime);
        return result;
    }
}

- 활용

    @Test
    void dynamicA() {
        AInterface target = new AImpl();
        TimeInvocationHandler handler = new TimeInvocationHandler(target);

        AInterface proxy = (AInterface)Proxy.newProxyInstance(AInterface.class.getClassLoader(), new Class[]{AInterface.class}, handler);
        // handler의 로직이 수행
        proxy.call();
        // handler 메소드에 call을 전달
        log.info("targetClass={}", target.getClass());
        log.info("proxyClass={}", proxy.getClass());
    }

- new TimeInvocationHandler(target) : 동적 프록시에 적용할 핸들러 로직이다.

- Proxy.newProxyInstance(AInterface.class.getClassLoader(), new Class[]{AInterface.class}, handler);

     ㆍ동적 프록시는 java.lang.reflect.Proxy를 통해서 생성할 수 있다

     ㆍ클래스 로더 정보, 인터페이스, 그리고 핸들러 로직을 넣어주면 된다.

     ㆍ그러면 해당 인터페이스를 기반으로 동적 프록시를 생성하고 그 결과를 반환한다.

 

* 실행 순서 *

1. 클라이언트는 JDK 동적 프록시의 call( ) 을 실행한다.

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

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

4. AImpl 인스턴스의 call( )이 실행된다.

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

 

* 정리 *

- 프록시 클래스를 수 없이 만들어야 하는 문제도 해결

- 부가 기능 로직도 하나의 클래스에 모아서 단일 책임 원칙(SRP)도 지킬 수 있게 되었다.

 

* 메서드 이름 필터 기능 추가 *

- 메서드 이름을 기준으로 특정 조건을 만족할 때만 로그를 남기는 기능을 개발해보자

 

- 스프링이 제공하는 PatternMatchUtils.simpleMatch( . . ) 를 사용하면 단순한 매칭 로직을 쉽게 적용할 수 있다

     ㆍxxx : xxx가 정확히 매칭되면 참

     ㆍxxx* : xxx로 시작하면 참

     ㆍ*xxx : xxx로 끝나면 참

     ㆍ*xxx* : xxx가 있으면 참

 

* JDK 동적 프록시 - 한계 *

- JDK 동적 프록시는 인터페이스가 필수이다

- 그렇다면 V2 애플리케이션처럼 인터페이스 없이 클래스만 있는 경우에는 어떻게 동적 프록시를 적용할 수 있을까?

   이것은 일반적인 방법으로는 어렵고 ' CGLIB '라는 바이트코드를 조작하는 트별한 라이브러리를 사용해야한다.


CGLIB ( Code Generator Library )

- CGLIB는 바이트코드를 조작해서 동적으로 클래스를 생성하는 기술을 제공하는 라이브러리이다

- CGLIB를 사용하면 인터페이스가 없어도 구체 클래스만 가지고 동적 프록시를 만들어낼 수 있다.

- CGLIB는 원래는 외부 라이브러리인데, 스프링 프레임워크가 스프링 내부 소스 코드에 포함했다. 따라서 스프링을 사용한다면 별도의 외부라이브러리를 추가하지 않아도 사용할 수 있다.

 

- 참고로 우리가 CGLIB를 직접 사용하는 경우는 거의 없다. 이후에 설명할 스프링의 ProxyFactory라는 것 이 기술을 편리하게 사용하게 도와주기 떄문에, 너무 깊이있게 파기 보다는 CGLIB가 무엇인지 대략 개념만 잡으면 된다.

 

- JDK 동적 프록시에서 실행 로직을 위해 InvocationHandler를 제공했듯이, CGLIB는 MethodInterceptor를 제공한다

     ㆍobj : CGLIB가 적용된 객체

     ㆍmethod : 호출된 메서드

     ㆍargs : 메서드를 호출하면서 전달된 인수

     ㆍproxy : 메서드 호출에 사용

@Slf4j
public class TimeMethodInterceptor implements MethodInterceptor {

    private final Object target;

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

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

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

        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("TimeProxy 종료 resultTume={}", resultTime);
        return result;
    }
}

- proxy.invoke(target, args) : 실제 대상을 동적으로 호출

     ㆍ참고로 ' method ' 를 사용해도 되지만, CGLIB는 성능상 ' MethodProxy proxy '를 사용하는 것을 권장한다.

 

@Test
    void cglib() {
        ConcreteService target = new ConcreteService();
        
        // Enhancer : CGLIB는 Enhancer를 사용해서 프록시 생성
        Enhancer enhancer = new Enhancer();
        
        // CGLIB는 구체 클래스를 상속 받아서 프록시를 생성할 수 있다.
        enhancer.setSuperclass(ConcreteService.class);
        
        // 프록시에 적용할 실행 로직을 할당한다
        enhancer.setCallback(new TimeMethodInterceptor(target));
        
        // 프록시를 생성한다.
        // setSuperclass에서 지정한 클래스를 상속받아서 프록시 생성
        ConcreteService proxy = (ConcreteService)enhancer.create();
        
        log.info("targetClass={}", target.getClass());
        log.info("proxyClass={}", proxy.getClass());
    }

- JDK 동적 프록시는 인터페이스를 구현( implements ) 해서 프록시를 만든다. CGLIB는 구체 클래스를 상속( extends ) 해서 프록시를 만든다.

 

* CGLIB 제약 *

- 클래스 기반 프록시는 상속을 사용하기 때문에 몇가지 제약이 있다.

     ㆍ부모 클래스의 생성자를 체크해야한다

                              → CGLIB는 자식 클래스를 동적으로 생성하기 때문에 기본 생성자가 필요하다

     ㆍ클래스에 ' final ' 키워드가 붙으면 상속이 불가능하다 → CGLIB에서는 예외가 발생한다

     ㆍ메소드에 ' final ' 키워드가 붙으면 해당 메서드를 오버라이딩 할 수 없다.  

                              → CGLIB에서는 프록시 로직이 동작하지 않는다

 

 

 

정리

* 남은 문제 *

- 인터페이스가 있는 경우에는 JDK 동적 프록시를 적용하고, 그렇지 않은 경우에는 CGLIB를 적용하려면 어떻게 해야할까?

- 두 기술을 함께 사용할 때 부가 기능을 제공하기 위해서 JDK 동적 프록시가 제공하는 InvocationHandler와 CGLIB가 제공하는 MethodInterceptor를 각각 중복으로 만들어서 관리해야 할까?

- 특정 조건에 맞을 때 프로시 로직을 적용하는 기능도 공통으로 제공되었으면?

 

 

 

출처 : 인프런 김영한님의 스프링 핵심원리 - 고급편

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%95%B5%EC%8B%AC-%EC%9B%90%EB%A6%AC-%EA%B3%A0%EA%B8%89%ED%8E%B8