관리 메뉴

나구리의 개발공부기록

동적 프록시 기술, 리플렉션, JDK 동적 프록시(소개/예제/적용), CGLIB(소개/예제) 본문

인프런 - 스프링 완전정복 코스 로드맵/스프링 핵심원리 - 고급편

동적 프록시 기술, 리플렉션, JDK 동적 프록시(소개/예제/적용), CGLIB(소개/예제)

소소한나구리 2024. 11. 9. 20:33

출처 : 인프런 - 스프링 핵심 원리 - 고급편 (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


1. 리플렉션

1) 리플렉션 이해

(1) 리플렉션과 동적프록시

  • 지금까지 프록시를 사용하여 기존 코드를 변경하지 않고 로그 추적기라는 부가 기능을 적용할 수 있었으나 대상 클래스 수 만큼 로그 추적을 위한 프록시 클래스를 만들어야 한다는 점의 문제가 있음
  • 자바가 기본으로 제공하는 JDK 동적 프록시 기술이나 CGLIB 같은 프록시 생성 오픈소스 기술을 활용하면 프록시 객체를 동적으로 만들어낼 수 있어 프록시 클래스를 지금처럼 계속 만들지 않아도 됨
  • 프록시를 적용할 코드를 하나만 만들어두고 동적 프록시 기술을 사용해서 프록시 객체를 찍어내면 됨
  • JDK 동적 프록시를 이해하기 위해서는 먼저 자바의 리플렉션 기술을 이해해야 알 수 있음
  • 리플렉션 기술을 활용하면 클래스나 메서드의 메타정보를 동적으로 획득하고 코드도 동적으로 호출할 수 있음
  • 해당 강의에서는 JDK 동적 프록시를 이해하기 위한 최소한의 리플렉션 기술만 알아볼 예정

(2) ReflectionTest - 예제 생성

  • test 하위에 jdkdynamic 패키지를 생성 후 작성
  • 공통로직1과 공통로직2는 호출하는 메서드만 다르고 전체 흐름이 완전히 같은데, 여기서 중복 제거를 하기 위해서 공통 로직1과 공통로직2를 하나의 메서드로 뽑아서 공통화하는 것은 생각보다 어려움
  • 그 이유는 중간에 호출하는 메서드가 다르기 때문인데, 이 부분만 동적으로 처리할 수 있다면 문제를 해결할 수 있는데 이럴 때 사용하는 기술이 바로 리플렉션임
  • 클래스나 메서드의 메타정보를 이용하여 동적으로 호출하는 메서드를 변경할 수 있음

** 참고

  • 람다를 사용해서 공통화하는 것도 가능하지만 리플렉션 학습이 목적이기 때문에 지금 예제에서는 람다를 사용하기 어려운 상황이라고 가정하고 진행
package hello.proxy.jdkdynamic;

@Slf4j
public class ReflectionTest {

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

        // 공통 로직1 시작
        log.info("start");
        String result1 = target.callA();    // 호출하는 메서드만 다르고 공통로직은 동일함
        log.info("result1={}", result1);
        // 공통 로직1 종료

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

    }

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

 

(3) reflection1 추가 및 실행

  • Class.forName(...) : 클래스 메타 정보를 획득, 내부 클래스는 구분을 위해 $를 사용함
  • classHello.getMethod(...) : 획득한 클래스 메타정보의 getMethod()메서드로 메서드에 대한 메타 정보를 획득, 예제에서는 문자열로 했으나 변수도 입력 가능함
  • methodCallA.invoke(target) : 획득한 메서드의 메타정보의 invoke()메서드로 생성한 객체를 참조한 변수를 입력하여 실제 인스턴스의 메서드를 호출함
  • 해당 예제에서는 methodCallA는 Hello 클래스의 callA()라는 메서드의 메타정보이고 methodCallA.invoke(인스턴스)를 호출하면서 인스턴스를 넘겨주게 되면 해당 인스턴스의 callA() 메서드를 찾아서 실행하여 target의 callA() 메서드가 실행됨
  • 해당 예제를 실행해보면 Hello 클래스의 callA()와 callB()가 정상적으로 출력됨
  • 여기서의 중요한 핵심은 클래스나 메서드 정보를 동적으로 변경할 수 있다는 점이며, 기존의 reflection0 테스트 케이스에서 직접 callA()와 callB() 메서드를 호출하는 부분이 Method라는 타입으로 추상화되어 공통로직을 적용할 수 있게 됨
@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); // methodCallA의 정보로 target에 있는 메서드를 호출
    log.info("result1={}", result1);

    // callB 메서드 정보
    Method methodCallB = classHello.getMethod("callB"); // 변수도 입력 가능함
    Object result2 = methodCallB.invoke(target); // methodCallB의 정보로 target에 있는 메서드를 호출
    log.info("result2={}", result2);
}

 

(4) reflection2() 추가 및 실행

  • reflection1 테스트케이스에서 획득한 각 메서드의 정보에서 invoke() 메서드를 통해 실제 객체의 메서드를 호출했다면, 이 부분을 메서드로 추상화하여 동적으로 처리할 수 있도록 변경
  • dynamicCall(Method method, Object target) : 공통1, 공통2 로직을 한번에 처리할 수 있는 공통 처리 로직을 가진 메서드
  • 첫 번째 파라미터에서 호출할 메서드 정보가 넘어오는 것이 핵심, Method라는 메타정보를 통해서 호출할 메서드 정보가 동적으로 제공됨
  • 두 번째 파라미터에서 실제 실행할 인스턴스 정보가 넘어오고, 타입을 Object로 하여 어떠한 인스턴스로 받을 수 있도록 작성
  • 물론 method.invoke(target)을 사용할 때 호출할 클래스와 메서드 정보가 서로 다르면 예외가 발생함
  • 실행해보면 해당 테스트도 정상적으로 모든 로직이 동작하는 출력결과를 확인할 수 있음
@Test
void reflection2() throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
    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 InvocationTargetException, IllegalAccessException {
    log.info("start");
    Object result = method.invoke(target);
    log.info("result={}", result);
}

/* 실행결과
16:03:02.087 [Test worker] INFO hello.proxy.jdkdynamic.ReflectionTest -- start
16:03:02.087 [Test worker] INFO hello.proxy.jdkdynamic.ReflectionTest$Hello -- callA
16:03:02.087 [Test worker] INFO hello.proxy.jdkdynamic.ReflectionTest -- result=A
16:03:02.087 [Test worker] INFO hello.proxy.jdkdynamic.ReflectionTest -- start
16:03:02.087 [Test worker] INFO hello.proxy.jdkdynamic.ReflectionTest$Hello -- callB
16:03:02.087 [Test worker] INFO hello.proxy.jdkdynamic.ReflectionTest -- result=B
*/

 

(5) 정리 및 주의

  • 정적인 target.callA(), target.callB() 코드를 리플렉션을 사용해서 Method라는 메타정보로 추상화하였고 덕분에 공통 로직을 만들 수 있게 되었음
  • 리플렉션을 사용하면 클래스와 메서드의 메타정보를 사용해서 애플리케이션을 동적으로 유연하게 만들 수 있지만 리플렉션 기술을 런타임에 동작하기 때문에 컴파일 시점에 오류를 잡을 수 없음
  • reflection1 테스트 케이스에서 getMethod("callA")안에 들어가는 문자를 전혀 관계없는 문자로 입력해도 컴파일 오류가 발생하지 않고 실행 후 해당 코드가 동작하는 시점에 오류가 발생하는 런타임 오류가 발생함
  • 가장 좋은 오류는 개발자가 즉시 확인할 수 있는 컴파일 오류이고 가장 무서운 오류는 사용자가 직접 실행할 때 발생하는 런타임 오류인데, 지금까지 프로그래밍 언어가 발달하면서 타입 정보를 기반으로 컴파일 시점에 오류를 잡아준 덕분에 편리하게 개발할 수 있던 것을 역행하는 방식이기 때문에 리플렉션은 일반적으로 사용하면 안됨
  • 리플렉션은 프레임워크 개발이나 또는 매우 일반적인 공통 처리가 필요할 때 부분적으로 주의해서 사용해야함

2. JDK 동적 프록시 - 소개

1) 소개

  • 지금까지는 프록시를 적용하기 위해서는 적용 대상의 개수만큼의 프록시 클래스를 만들었어야 했음
  • 프록시의 로직은 같은데 적용 대상만 차이가 있을 때 이렇게 프록시 클래스를 많이 생성하는 문제를 해결하는 것이 동적 프록시 기술임
  • 동적 프록시 기술을 사용하면 개발자가 직접 프록시 클래스를 만들지 않아도 되며 프록시 객체를 동적으로 런타임에 개발자 대신 만들어주고 동적 프록시에 원하는 실행 로직을 지정할 수 있음

** 주의

  • JDK 동적 프록시는 인터페이스를 기반으로 프록시를 동적으로 만들어주기 때문에 인터페이스가 필수임

2) 기본 예제 코드

  • test 하위에 생성한 jdkdynamic 패키지에 code 패키지를 만들어서 해당 예제들을 추가

(1) AInterface, AImpl, BInterface, BImpl

  • 기능이 하나만있는 A,B인터페이스와 A,B구현체들
public interface AInterface {
    String call();
}

@Slf4j
public class AImpl implements AInterface {

    @Override
    public String call() {
        log.info("A 호출");
        return "a";
    }
}

package hello.proxy.jdkdynamic.code;

public interface BInterface {
    String call();
}

@Slf4j
public class BImpl implements BInterface {

    @Override
    public String call() {
        log.info("B 호출");
        return "b";
    }
}

3. JDK 동적 프록시 - 예제 코드

1) JDK 동적 프록시 InvocationHandler

(1)  프록시의 로직은 직접 구현

  • 동적 프록시를 사용할때 프록시가 동작하는 로직은 별도로 작성해야하는데 InvocationHandler를 구현해서 작성해주면 됨
  • proxy : 프록시 자신
  • method: 호출한 메서드
  • args : 메서드를 호출할 때 전달할 인수
package java.lang.reflect;
public interface InvocationHandler {
    public Object invoke(Object proxy, Method method, Object[] args)
        throws Throwable;
}

 

(2) TimeInvocationHandler

  • 기본 예제 코드가 위치한 ~.jdkdynamic.code 패키지에 작성
  • InvocationHandler(import - java.lang.reflect)를 구현하여 JDK 동적 프록시에 적용할 공통 로직을 개발
  • 기존에 작성한 로그 추적기와 동일한 로직이며 동적 프록시가 호출할 대상을 의존관계 주입을 받고 invoke()메서드를 오버라이딩
  • method.invoke(target, args) : 리플렉션을 사용하여 target 인스턴스의 메서드를 실행하고 args로 메서드를 호출할 때 필요한 인수를 전달함
package hello.proxy.jdkdynamic.code;

@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;
    }
}

 

(3) JdkDynamicProxyTest - 동적 프록시 사용

  • new TimeInvocationHandler(target) : 동적 프록시에 적용할 핸들러 로직(프록시가 사용할 로직)을 생성
  • java.lang.reflect.Proxy의 .newProxyInstance()메서드의 인수에 클래스 로더 정보, 인터페이스, 핸들러 로직을 입력하여 호출하면 두번째 인수로 입력한 인터페이스를 기반으로 동적 프록시를 생성하고 결과를 반환함
  • 프록시를 생성할 기반 인터페이스를 여러개 입력할 수 있기에 배열로 입력
package hello.proxy.jdkdynamic;

@Slf4j
public class JdkDynamicProxyTest {

    @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);    // 프록시 로직

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

    @Test
    void dynamicB() {
        BInterface target = new BImpl();
        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());
    }
}

 

(4) 실행결과

  • 출력 결과를 보면 프록시가 정상적으로 수행되어 A에서는 Proxy12가 B에서는 Proxy13이 동작한 것을 알 수 있음
  • 프록시를 직접 만든것이 아니라 JDK 동적 프록시가 동적으로 만들어준 프록시이며 이 프록시는 TimeInvocationHandler로 직접 정의한 로직을 수행함
  • 테스트를 각각 수행하면 동일한 프록시로 동작하지만 지금처럼 한번에 테스트를 실행해보면 JDK 동적 프록시가 각각 다른 동적 프록시 클래스를 만들어 주는 것을 확인할 수 있음

 

(5) 실행 순서 이해(dynamicA 테스트 기준)

  1. 클라이언트가 JDK 동적 프록시의 call()을 실행, 테스트 코드의 proxy.call();
  2. JDK 동적 프록시가 InvocationHandler.invoke()를 호출하고 TimeInvocationHandler가 구현체로 작성되어있기에 TimeInvocationHandler.invoke()가 호출됨
  3. TimeInvocationHandler가 내부 로직을 수행하고 method.invoke(target, args)를 호출해서 target인 실제 객체(AImpl)를 호출
  4. AImpl 인스턴스의 call()이 실행됨
  5. AImpl 인스턴스의 call()의 실행이 끝나면 TimeInvocationHandler로 응답이 돌아오고 시간 로그를 출력하고 결과를 반환함

 

(6) 정리

  • AImpl, BImpl 각각의 프록시를 만들지 않았음에도 JDK 동적 프록시를 사용하여 동적으로 프록시를 만들고 TimeInvocationHandler는 공통으로 사용하였음
  • JDK 동적 프록시 기술 덕분에 적용 대상 만큼 프록시 객체를 만들지 않아도 되며 같은 부가 기능 로직을 한번만 개발해서 공통으로 적용할 수 있게 됨
  • 즉, 적용대상이 100개여도 동적 프록시를 통해서 생성하고 각각 필요한 InvocationHandler만 만들어서 넣어주면 됨
  • 프록시를 클래스를 수 없이 만들어야하는 문제도 해결함과 동시에 부가 기능 로직도 하나의 클래스에 모음으로 인해 단일 책임 원칙(SRP)도 지킬 수 있게 되었음

 

(7) 클래스 및 런타임 객체 의존관계

  • 점선은 개발자가 직접 만드는 클래스가 아님

JDK 동적 프록시 도입 전/후 비교, 좌) 클래스 의존 관계 / 우) 런타임 객체 의존 관계


4. JDK 동적 프록시 - 적용

1) V1 애플리케이션 적용

  • JDK 동적 프록시는 인터페이스가 필수이기 때문에 V1 애플리케이션에만 적용할 수 있음

(2) LogTraceBasicHandler

  • InvocationHandler 인터페이스를 구현한 클래스를 생성하면 JDK 동적 프록시에서 사용됨
  • Object target으로 프록시가 호출할 대상을 의존관계 주입
  • 기존 로그 추적기의 begin()메서드에는 각 계층에서 호출되었다는 메시지를 인수로 입력해야하는데, 파라미터로 넘어온 method를 통해 메시지를 생성하면 동적으로 파라미터로 넘어오는 method의 정보마다 메시지가 생성됨
  • getDeclaringClass() : method가 속한 클래스 정보를 획득
  • getSimpleName() : 획득한 클래스의 정보를 실제 클래스 이름만 반환(패키지 정보를 제거)
  • getName() : method의 이름을 반환
package hello.proxy.config.v2_dynamicproxy.handler;

public class LogTraceBasicHandler implements InvocationHandler {

    private final Object target;
    private final LogTrace logTrace;

    public LogTraceBasicHandler(Object target, LogTrace logTrace) {
        this.target = target;
        this.logTrace = logTrace;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        TraceStatus status = null;
        try {
            // begin()에 인수로 들어갈 메시지를 파라미터로 넘어온 method의 메타정보들로 꺼내서 입력
            // getDeclaringClass().getSimpleNam() - 메서드가 속한 클래스 정보를 반환하고, 패키지 경로를 제외한 클래스의 이름을 반환
            String message = method.getDeclaringClass().getSimpleName() + "." + method.getName() + "()";
            status = logTrace.begin(message);
            
            Object result = method.invoke(target, args);    // 로직 호출    
            logTrace.end(status);
            return result;
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }
}

 

(3) DynamicProxyBasicConfig

  • 직접 개발한 프록시 클래스를 스프링 빈으로 등록하지 않고 JDK 동적 프록시 기술을 사용해서 각각의 Controller, Service, Repository에 맞는 동적 프록시를 생성해주면 됨
  • 동적 프록시가 사용할 로그 추적기의 로직은 모든 계층에서 동일하기 때문에 LogTraceBasicHandler를 사용하며 각 계층마다 의존관계가 주입이 되기 때문에 new로 인스턴스를 생성해서 사용해야 함
package hello.proxy.config.v2_dynamicproxy;

@Configuration
public class DynamicProxyBasicConfig {

    @Bean
    public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
        OrderControllerV1 orderController = new OrderControllerV1Impl(orderServiceV1(logTrace));

        // 프록시 생성
        OrderControllerV1 proxy = (OrderControllerV1) Proxy.newProxyInstance(
                                                    OrderControllerV1.class.getClassLoader(),
                                                    new Class[]{OrderControllerV1.class},
                                                    new LogTraceBasicHandler(orderController, logTrace));

        return proxy;
    }

    @Bean
    public OrderServiceV1 orderServiceV1(LogTrace logTrace) {
        OrderServiceV1 orderService = new OrderServiceV1Impl(orderRepositoryV1(logTrace));

        // 프록시 생성
        OrderServiceV1 proxy = (OrderServiceV1) Proxy.newProxyInstance(
                                                OrderServiceV1.class.getClassLoader(),
                                                new Class[]{OrderServiceV1.class},
                                                new LogTraceBasicHandler(orderService, logTrace));

        return proxy;
    }

    @Bean
    public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
        OrderRepositoryV1 orderRepository = new OrderRepositoryV1Impl();

        // 프록시 생성
        OrderRepositoryV1 proxy = (OrderRepositoryV1) Proxy.newProxyInstance(
                                                    OrderRepositoryV1.class.getClassLoader(),
                                                    new Class[]{OrderRepositoryV1.class},
                                                    new LogTraceBasicHandler(orderRepository, logTrace));

        return proxy;
    }

}

 

(4) ProxyApplication 수정 및 실행

  • 방금 생성한 설정파일을 @Import로 등록하고 애플리케이션으로 실행 후 매핑된 URL로 접속하여 요청파라미터를 보내면 정상적으로 로그가 동작하는 것을 확인할 수 있음
  • 하지만 /v1/no-log로 접속했을 때에는 log를 남기지 말아야하는데 현재 상황에서는 동적프록시가 모두 동작하고 있어 요구사항에 맞춰 이부분을 로그가 남지 않도록 처리해야함
@Import(DynamicProxyBasicConfig.class)
@SpringBootApplication(scanBasePackages = "hello.proxy.app.v3") //주의
public class ProxyApplication {
    // 기존 로직 동일
}

 

(5) 그림으로 정리

동적 프록시를 도입한 V1 애플리케이션의 의존관계도, 좌) 클래스 의존관계 / 우) 런타임 객체 의존 관계

2) 메서드 이름 필터 기능 추가

  • 요구사항에 의해 /v1/no-log로 접속했을 때 로그가 남지 않도록 문제를 해결

(1) LogTraceFilterHandler

  • LogTraceBasicHandler에 특정 패턴일 경우에만 LogTrace가 동작하도록 필터를 적용한 핸들러 클래스를 생성
  • 생성자를 통해 외부에서 입력받은 patterns 변수의 값이 method의 이름과 매칭하는 로직을 작성
  • 스프링이 제공하는 PatternMatchUtils.simpleMatch(...)를 사용하면 단순한 매칭 로직을 쉽게 적용할 수 있음
package hello.proxy.config.v2_dynamicproxy.handler;

public class LogTraceFilterHandler implements InvocationHandler {

    private final Object target;
    private final LogTrace logTrace;
    private final String[] patterns;    // 특정 패턴일 때만 로그를 남기기 위한 변수 추가

    public LogTraceFilterHandler(Object target, LogTrace logTrace, String[] patterns) {
        this.target = target;
        this.logTrace = logTrace;
        this.patterns = patterns;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

        // 메서드 이름 필터
        String methodName = method.getName();
        // save, request, reque*, *est
        if (!PatternMatchUtils.simpleMatch(patterns, methodName)) {
            return method.invoke(target, args);
        }

        // 로그남기는 로직은 동일
    }
}

 

(2) DynamicProxyFilterConfig

  • 기존에 작성한 BasicConfig와 거의 동일하며 filter로 사용할 상수 PATTERNS를 생성하여 request, order, save로 시작하는 패턴일 때만 로그가 동작하도록 설정
  • 프록시 생성시 동작할 로직의 핸들러를 LogTraceFilterHandler가 동작하도록 모든 메서드를 변경
package hello.proxy.config.v2_dynamicproxy;

@Configuration
public class DynamicProxyFilterConfig {
	
    // 패턴 정의
    private static final String[] PATTERNS = new String[]{"request*", "order*", "save*"};

    @Bean
    public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
        OrderControllerV1 orderController = new OrderControllerV1Impl(orderServiceV1(logTrace));

        // 프록시 생성
        OrderControllerV1 proxy = (OrderControllerV1) Proxy.newProxyInstance(
                                                    OrderControllerV1.class.getClassLoader(),
                                                    new Class[]{OrderControllerV1.class},
                                                    // 필터가 적용된 핸들러로 프록시의 로직을 변경
                                                    new LogTraceFilterHandler(orderController, logTrace, PATTERNS));

        return proxy;
    }
        // serivce, repository로 마찬가지로 LogTraceFilterHandler로 프록시를 생성
}

 

(3) ProxyApplication 수정 및 실행

  • @Import(DynamicProxyFilterConfig.class)로 설정파일을 등록하고 실행해보면 /v1/no-log로 접속해보면 log가 남지않고 /v1/request?itemId=입력값에 접속했을 때에만 로그가 남도록 문제가 해결됨

(4) JDK 동적 프록시의 한계

  • JDK 동적 프록시는 인터페이스가 필수이기에 V2 예제처럼 구체 클래스만 있는 경우에는 적용하기가 어려움
  • 이런 경우에 동적 프록시를 적용하기 위해서는 CGLIB라는 바이트코드를 조작하는 특별한 라이브러리를 사용해야함

5. CGLIB

1) 소개

(1) CGLIB: Code Generator Library

  • 바이트 코드를 조작하여 동적으로 클래스를 생성하는 기술을 제공하는 라이브러리
  • CGLIB를 사용하면 인터페이스가 없어도 구체 클래스만 가지고 동적 프록시를 만들어낼 수 있음
  • CGLIB는 원래 외부 라이브러리인데 스프링 프레임워크가 스프링 내부 소스 코드에 포함하여 스프링을 사용한다면 별도의 외부 라이브러리를 추가하지 않아도 사용할 수 있음
  • 우리가 CGLIB를 직접 사용하는 경우는 거의 없고 이후에 설명할 스프링의 ProxyFactory라는 것이 이 기술을 편리하게 사용하도록 도와주기 때문에 이번 글에서는 CGLIB가 무엇인지 대략적인 개념만 잡고 넘어가면 됨

2) 공통 예제 코드

  • 인터페이스와 구현이 있는 서비스 클래스와 구체 클래스만 있는 서비스 클래스를 생성하여 공통으로 사용할 예제 코드를 생성
  • test 하위에 common.service 패키지를 생성하여 코드들을 작성

(1) ServiceInterface, ServiceImpl

  • 인터페이스와 구현이 있는 서비스 클래스
package hello.proxy.common.service;

public interface ServiceInterface {
    void save();
    void find();
}

package hello.proxy.common.service;

@Slf4j
public class ServiceImpl implements ServiceInterface {
    @Override
    public void save() {
        log.info("save 호출");
    }

    @Override
    public void find() {
        log.info("find 호출");
    }
}

 

(2) ConcreteService

  • 구체 클래스만 있는 서비스 클래스
package hello.proxy.common.service;

@Slf4j
public class ConcreteService {

    public void call() {
        log.info("ConcreteService 호출");
    }
}

3) CGLIB - 예제 코드

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

(1) MethodInterceptor

  • obj : CGLIB가 적용된 객체
  • method : 호출된 메서드
  • args : 메서드를 호출하면서 전달된 인수
  • proxy : 메서드 호출에 사용
package org.springframework.cglib.proxy;

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

 

(2) TimeMethodInterceptor

  • test 하위의 cglib.code 패키지를 생성하여 코드를 작성
  • MethodInterceptor의 Import는 springframework로 해야함
  • JDK 동적 프록시를 테스트할 때 생성한 TimeInvocationHandler와 거의 동일하며 invoke()메서드를 호출하는 대상이 method가 아니라 MethodProxy에서 invoke()메서드를 호출함
  • CGLIB 매뉴얼에서 MethodProxy를 사용하는것을 권장한다고 함(상세한 내부 로직은 사실 크게 알 필요가 없음)
package hello.proxy.cglib.code;

@Slf4j
public class TimeMethodInterceptor implements MethodInterceptor {

    // target 생성 및 생성자 코드 동일

    @Override
    public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
        // 기존 로직 동일
        
        // 마지막 파라미터인 MethodProxy를 사용하면 조금더 동작이 빠르다고 매뉴얼에 설명되어있음
        Object result = proxy.invoke(target, args);

        // 기존 로직 동일
    }
}

 

(3) CglibTest, CGLIB 사용 및 실행

  • 인터페이스가 없는 ConcreteService를 target으로 CGLIB를 사용하여 프록시를 생성하는 테스트
  • new Enhancer(): CGLIB를 생성하기 위해서는 Enhancer를 사용해야함
  • enhancer.setSuperclass(...) : CGLIB는 구체 클래스를 상속 받아서 프록시를 생성하기 때문에 어떤 구체클래스를 상속 받을지 지정해야함
  • enhancer.setCallback(...) : 프록시에 적용할 실행 로직을 할당
  • enhancer.create() : CGLIB로 동작하는 프록시를 생성, setSuperclass()에서 지정한 클래스를 상속받아서 프록시가 만들어짐
  • 실행 후 생성한 proxy의 클래스를 확인해보면 enhancerByCGLIB$$임의코드로 로그가 출력된 것이 확인되는데 이것이 CGLIB가 생성한 프록시라는 뜻이며 CGLIB가 동적으로 클래스를 생성할 때 이러한 규칙으로 이름을 생성함

** 참고

  • JDK 동적 프록시는 인터페이스를 구현해서 프록시를 만듦
  • CGLIB는 구체 클래스를 상속해서 프록시를 만듦
@Slf4j
public class CglibTest {

    @Test
    void cglib() {
        ConcreteService target = new ConcreteService();

        // CGLIB 생성하는 코드
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(ConcreteService.class);  // 상속받을 구체 클래스 지정
        enhancer.setCallback(new TimeMethodInterceptor(target));    // 프록시에 적용할 실행 로직
        ConcreteService proxy = (ConcreteService) enhancer.create();// 프록시 생성

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

    }
}

/* 실행 로그
20:12:22.618 [Test worker] INFO hello.proxy.cglib.CglibTest -- targetClass=class hello.proxy.common.service.ConcreteService
20:12:22.620 [Test worker] INFO hello.proxy.cglib.CglibTest -- proxyClass=class hello.proxy.common.service.ConcreteService$$EnhancerByCGLIB$$48bd19d7
20:12:22.620 [Test worker] INFO hello.proxy.cglib.code.TimeMethodInterceptor -- TimeProxy 실행
20:12:22.628 [Test worker] INFO hello.proxy.common.service.ConcreteService -- ConcreteService 호출
20:12:22.628 [Test worker] INFO hello.proxy.cglib.code.TimeMethodInterceptor -- TimeProxy 종료 resultTime=7
*/

 

(4) 그림으로 정리

  • JDK 동적 프록시와 구조가 크게 다른것이 없어 그림을 이해하기가 어렵지 않음

(5) CGLIB의 제약

  • CGLIB는 상속을 사용하기 때문에 상속을 사용할 때 따라오는 제약이 존재함
  • 부모 클래스의 생성자를 체크해야하고 자식 클래스를 동적으로 생성하기 때문에 기본 생성자가 필요함
  • 클래스에 final 키워드가 붙으면 상속할 수가 없어서 예외가 발생함
  • 메서드에 final 키워드가 붙으면 오버라이딩 할 수가 없어서 프록시 로직이 동작하지 않음

** 참고

  • CGLIB를 사용하면 인터페이스가 없는 V2 애플리케이션에 동적 프록시를 적용할 수 있으나 바로 적용하기에는 제약이 몇가지 있음
  • V2 애플리케이션에 기본 생성자를 추가하고 의존관계를 setter를 사용해서 주입하면 CGLIB를 적용할 수는 있으나 ProxyFactory를 통해서 CGLIB를 적용하면 이런 단점을 해결하고 더 편리하게 적용할 수 있기에 ProxyFactory를 배우면서 애플리케이션에 CGLIB를 프록시로 적용할 예정임