스프링 AOP : 프록시 기술과 한계

    이 포스팅은 인프런 영한님의 강의를 복습하며 작성한 글입니다.


    스프링 AOP : 프록시 기술과 한계

    JDK 동적 프록시, CGLIB 동적 프록시를 이용하는 방법은 각각 장단점이 있다. JDK 동적 프록시는 인터페이스가 필수이고, 인터페이스를 기반으로 프록시를 구현한다. CGLIB는 구체를 기반으로 프록시를 구현한다. 그렇다면 JDK 동적 프록시, CGLIB 동적 프록시의 각각의 한계는 어떤 것이 있을까?

     

     


    JDK 동적 프록시의 한계 → 타입 캐스팅 한계

    JDK 동적 프록시는 인터페이스를 기반으로 프록시 객체를 만든다. 따라서 프록시 객체는 Target 객체의 구현체 클래스에 대해서 전혀 알지 못한다. 따라서 JDK 동적 프록시 객체는 Target 객체의 구현체 클래스로 타입 캐스팅이 불가능하다. 

    JDK 동적 프록시 객체의 클래스 의존도는 다음과 같다. 

    • JDK 프록시는 MemberService 인터페이스를 안다.
    • JDK 프록시는 MemberServiceImpl을 모른다. 왜냐하면 MemberServiceImpl만 MemberService 인터페이스를 알기 때문이다. 

    그럼에도 불구하고 JDK 프록시 객체를 MemberServiceImpl(Target 객체 타입)으로 캐스팅을 하려고 하면 예외가 발생한다. 위에서 말한 것처럼 JDK 프록시는 인터페이스를 기반으로 만들어진 객체고, 인터페이스의 구현체는 어떻게 구성되어 있는지 모르기 때문이다. 따라서 JDK 프록시 객체는 ClassCastException.class 예외가 발생한다.

     

    JDK 동적 프록시의 한계 → CGLIB 프록시는 타입 캐스팅 가능

    반면 CGLIB 프록시는 어떤 타입으로든 타입 캐스팅이 가능하다. CGLIB 객체는 구체 클래스를 상속받아 만들기 때문이다.

    클래스 다이어그램을 살펴보면 이는 더욱 명확하다. CGLIB Proxy는 구체인 MemberServiceImpl을 상속받아 프록시 객체를 만들었다. 그리고 MemberServiceImpl은 MemberService를 구현한다. 따라서 CGLIB Proxy는 MemberServiceImpl, MemberService로 모두 타입 캐스팅이 가능하다.

     


    JDK 동적 프록시의 한계 → 의존관계 주입

    위에서 JDK 동적 프록시의 한계를 이야기한 것은 다름이 아니라 의존관계 주입에 문제가 있기 때문이다.

    먼저 JDK 동적 프록시를 살펴보자. JDK 동적 프록시는 다음과 같이 의존관계 주입 가능 상황을 보여준다.

    • MemberService(인터페이스) : JDK 동적 프록시 의존관계 주입 가능
    • MemberServiceImpl(구현체) : JDK 동적 프록시 의존관계 주입 불가능 

     

    그렇다면 CGLIB 프록시는 의존관계 주입에서 어떤 모습을 보여줄까? CGLIB는 구현체를 상속받은 클래스이다. 따라서 다음과 같은 의존관계 주입 가능성을 보여준다

    • MemberService(인터페이스) : 의존관계 주입 가능
    • MemberServiceImpl(구현체) : 의존관계 주입 가능

     

    JDK 동적 프록시 한계 정리

    JDK 동적 프록시는 구체 클래스로 타입 캐스팅이 불가능했다. 이런 이유 때문에 구체 클래스 타입으로 의존관계 주입이 불가능하다. 이것은 의존관계 주입의 장점을 갉아먹는 현상이다. DI는 의존관계를 주입 받으면서 실제 내 코드의 변경 없이 다형성을 이용하는 장점이 있다. 

     

    DI는 주로 인터페이스를 기반으로 주입을 받기 때문에 실제 구체 클래스를 선언하는 경우가 없어서 큰 문제가 없을 가능성도 있다. 따라서 올바르게 잘 설계된 어플리케이션에서는 이런 문제가 발생할 가능성이 낮다. 그렇지만 그럼에도 불구하고 테스트, 또는 여러 이유로 AOP가 적용된 프록시가 적용된 구체 클래스를 직접 의존관계를 받아야 하는 경우가 있을 수 있다. 

    이럴 때는 JDK 동적 프록시 객체를 사용할 수 없어, CGLIB 동적 프록시 객체를 사용해야한다. 

     


    CGLIB 동적 프록시의 한계 

    CGLIB 동적 프록시는 상속을 받아 프록시 객체를 생성한다. 따라서 상속의 단점을 고스란히 가져온다. CGLIB 동적 프록시의 단점은 아래 세 가지 정도로 정의할 수 있다. 

    • 대상 클래스에 기본 생성자 필수
    • 생성자 2번 호출 문제
    •  final 키워드 클래스, 메서드 사용 불가 

     

    대상 클래스에 기본 생성자 필수 문제

    자바 언어에서 상속을 받으면 자식 클래스를 생성할 때, 부모 클래스의 생성자도 같이 호출해야한다. CGLIB를 사용할 때 CGLIB가 만드는 프록시의 생성자는 개발자가 호출하는 것이 아니다. CGLIB 프록시는 대상 클래스를 상속 받고, 생성자에서 대상 클래스의 기본 생성자를 호출한다. 따라서 대상 클래스에 기본 생성자를 만들어야 한다

     

    생성자 2번 호출 문제 → JDK 동적 프록시는 1회 

    1. 실제 target 객체를 생성할 때
    2. 프록시 객체를 생성할 때, 부모 클래스의 생성자 호출 

    CGLIB로 프록시를 만들 때, 생성자는 총 2번이 호출된다. 

     

    Final 키워드 클래스, 메서드 사용 불가

    final 키워드가 클래스에 있으면 상속이 불가능하고, 메서드에 있으면 오버라이딩이 불가능하다. CGLIB는 상속을 기반으로 하기 때문에 이런 경우 프록시가 생성되지 않거나 동작하지 않는다. 그렇지만 일반적인 웹 개발에서는 Final 키워드를 잘 사용하지 않는다. 

     

    CGLIB 동적 프록시 한계 극복

    CGLIB 기본 생성자 필수 문제, 생성자 2번 호출 문제는 스프링이 objenesis라는 특별한 라이브를 사용해서 기본 생성자 없이 객체 생성이 가능하도록 해결되었다.

     


    정리

    스프링부트는 2.0부터 AOP 프록시 객체를 만들 때 인터페이스가 있다하더라도 기본적으로 CGLIB 프록시 객체를 만드는것으로 결정했다. CGLIB를 사용하면 구체 클래스에도 DI가 가능하다. 그리고 CGLIB의 단점들이 많이 해견되었기 때문이다. 

     


    코드로 확인해보기

    @Aspect 코드 작성 → AOP 프록시 생성 목적

    @Aspect
    @Slf4j
    public class ProxyDiAspect {
    
        @Before("execution(* hello.aop..*(..))")
        public void doTrace(JoinPoint joinPoint) {
            log.info("[proxy Di Advice] {}", joinPoint.getSignature());
        }
    }

    어드바이저 적용해서 단순히 프록시 객체를 생성할 목적으로 @Aspect 클래스를 구현했다. 

     

    JDK 동적 프록시 타입 캐스팅 테스트

    @Test
    void jdkProxy() {
    
        MemberServiceImpl target = new MemberServiceImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);
        proxyFactory.setProxyTargetClass(false);
    
        MemberService proxy = (MemberService)proxyFactory.getProxy();
    
        Assertions.assertThrows(ClassCastException.class,
                () -> {
                    MemberServiceImpl castingMemberService = (MemberServiceImpl) proxy;});
    }
    • JDK 동적 프록시 객체를 만들었다. 이 때 Target class를 false로 했기 때문에 인터페이스 기반의 JDK 동적 프록시 객체가 만들어진다.
    • 프록시가 MemberService 인터페이스로는 타입 캐스팅 되는 것이 확인된다.
    • 프록시가 MemberServiceImpl 구현체로 타입 캐스팅 시, Exception 발생이 확인된다. 

     

    CGLIB 동적 프록시 타입 캐스팅 

    @Test
    void cglibProxy() {
    
        MemberServiceImpl target = new MemberServiceImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);
        proxyFactory.setProxyTargetClass(true);
    
        MemberService proxy = (MemberService)proxyFactory.getProxy();
        MemberServiceImpl castingMemberService = (MemberServiceImpl) proxy;
    }
    • CGLIB 동적 프록시 객체를 만들었다.
    • MemberService, MemberServiceImpl로 모두 타입 캐스팅 되는 것이 확인되었다.

     

    JDK 동적 프록시 DI 테스트

    @Import(ProxyDiAspect.class)
    @SpringBootTest(properties = "spring.aop.proxy-target-class=false")
    @Slf4j
    public class ProxyDiTest {
    
    
        @Autowired
        MemberService memberService;
    
        @Autowired
        MemberServiceImpl memberServiceImpl;
    
        @Test
        void go() {
            log.info("memberService = {}", memberService);
            log.info("memberServiceImpl = {}", memberServiceImpl);
        }
    }
    
    • @Aspect 어드바이저에 의해 MemberService 인터페이스는 JDK 동적 프록시로 만들어진다.
    • JDK 동적 프록시는 MemberService에는 의존관계 주입이 된다.
    • JDK 동적 프록시는 MemberServiceImpl에는 의존관계 주입 되지 않아, NPE 발생함. 

     

    CGLIB  동적 프록시 DI 테스트

    @Import(ProxyDiAspect.class)
    @SpringBootTest(properties = "spring.aop.proxy-target-class=true")
    @Slf4j
    public class ProxyDiTest {
    
    
        @Autowired
        MemberService memberService;
    
        @Autowired
        MemberServiceImpl memberServiceImpl;
    
        @Test
        void go() {
            log.info("memberService = {}", memberService);
            log.info("memberServiceImpl = {}", memberServiceImpl);
        }
    }
    • @Aspect 어드바이저에 의해 MemberService 프록시 객체는 MemberServiceImpl 상속받아 만들어짐.
    • JDK 동적 프록시는 MemberService에는 의존관계 주입됨.

    JDK 동적 프록시는 MemberServiceImpl에는 의존관계 주입됨.

    댓글

    Designed by JB FACTORY