ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Spring 핵심 원리 고급편 - 5. 동적 프록시 기술
    Spring-Boot/스프링핵심원리 - 고급편 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

    댓글

Designed by Tistory.