Spring AOP가 제공하는 두 가지 AOP Proxy

mylime·2024년 6월 26일
1

이 포스팅은 2024.06.26에 작성되었습니다.



서론

@Transactional에 대해 알아보다 Spring의 AOP Proxy까지 찾아보게 되었다.
Spring AOP는 프록시 기반으로 JDK Dynamic Proxy와 CGLIB을 활용하여 AOP를 제공한다. 이번에 이 두 가지 프록시에 대해서 알아봄으로써, Spring AOP의 프록시를 기반으로 동작하는 @Transactional에 대해 더 잘 이해해보려고 한다.



목표

  • Spring AOP Proxy의 구현체인 JDK Dynamic ProxyCBLIB을 알아보고, 차이점을 비교해보기
  • 왜 SpringBoot에서 CGLIB을 기본 Proxy로 채택했는지 이해하기



프록시 패턴을 사용하는 이유

프록시 패턴은 프록시 객체가 원래 객체를 감싸서 client 요청을 처리하게 하는 패턴이다. 프록시 객체는 원래 객체를 감싸고 있는 객체이며, 원래 객체와 타입이 동일하다.
접근 권한을 부여하거나, 부가 기능을 추가할 때 사용된다.



Spring의 두 가지 프록시 구현체

Spring에서는 크게 두 가지 프록시를 사용한다.
첫 번째는 JDK Proxy(Dynamic Proxy)이고, 두 번째는 CGLIB이다.

Spring AOP는 프록시 기반으로 동작하며, 상황에 따라 두 가지 프록시 방식 중 하나를 사용하여 대상 객체에 대한 Proxy Bean을 생성한다.

Spring AOP는 사용자의 특정 호출 시점에 IoC 컨테이너에 의해 Proxy Bean을 생성한다.
동적으로 생성된 Proxy Bean은 타깃의 메서드가 호출되는 시점에 부가기능을 추가할 메서드를 자체적으로 판단해 가로채어 부가기능을 주입한다. 이를 호출시점에 동적으로 위빙한다고 하여 런타임 위빙이라고 한다.



1. JDK Proxy(Dynamic Proxy)

JDK Proxy는 JDK에 내장되어 있는 자바 기본 제공 기능이다.

JDK Proxy에서 Proxy Bean은 리플랙션 패키지에 존재하는 Proxy라는 클래스를 통해 생성된 객체를 의미하며, 타깃의 인터페이스를 기준으로 Proxy를 생성한다. (* 리플랙션의 Proxy 클래스가 동적으로 Proxy를 생성해준다고 하여, Dynamic Proxy라고도 불린다)


Spring 공식문서에는 Spring AOP가 기본적으로 AOP Proxy에 JDK Proxy를 사용한다고 적혀있다.

Spring AOP defaults to using standard JDK dynamic proxies for AOP proxies. This enables any interface (or set of interfaces) to be proxied.


간단한 사용방법은 다음과 같다

public class ProxyTest {
    // 인터페이스 생성
    interface Target {
        void print();
    }

    // 프록시를 적용할 구현체 생성
    class TargetImpl implements Target {
        @Override
        public void print() {
            System.out.println("Hello!");
        }
    }

    // InvocationHandler 구현체 생성
    class TestHandler implements InvocationHandler {
        private Object target;

        public TestHandler(final Object target) {
            this.target = target;
        }

        @Override
        public Object invoke(final Object proxy, final Method method, final Object[] args) throws Throwable {
            System.out.println("######## ---> method start ");
            Object result = method.invoke(target, args);
            System.out.println("######## ---> method end");
            return result;
        }
    }

    // Proxy 생성 및 호출
    @Test
    void test() {
        Target proxy = (Target) Proxy.newProxyInstance(
                ProxyTest.class.getClassLoader(),
                new Class[]{Target.class}, //타깃의 인터페이스
                new TestHandler(new TargetImpl()) //InvocationHandler 구현체
        );
        proxy.print();
    }
}

JDK Proxy가 Proxy 객체를 생성하는 방식은 다음과 같다.

  1. 타깃 인터페이스를 검증하고, ProxyFactory에 의해 해당 인터페이스를 상속한 Proxy 객체 생성
  2. Proxy 객체에 InvocationHandler를 포함시켜 하나의 객체로 반환

JDK Proxy를 사용할 경우, 인터페이스를 기준으로 Proxy 객체가 생성된다. 그러므로 구현체는 인터페이스를 상속해야 하고, 생성된 Proxy Bean을 사용하기 위해서는 반드시 인터페이스 타입으로 지정해줘야 한다.


만약, 아래와 같이 인터페이스 타입이 아닌 클래스타입으로 DI를 시도할 경우, 예외가 발생한다.

@Controller
public class MemberController{
  @Autowired
  private MemberServiceImpl memberService; // <- Runtime Error 발생...(Interface가 아닌 Class 타입으로 DI를 헀다)
  ...
}

@Service
public class MemberServiceImpl implements MemberService{
  @Override
  public Map<String, Object> findUserId(Map<String, Object> params){
    ...isLogic
    return params;
  }
}

MemberServiceImpl 클래스는 인터페이스를 상속받고 있기 때문에 Spring은 JDK Proxy방식으로 Proxy Bean을 생성한다. JDK Proxy는 인터페이스 타입으로 받아야하지만, 위 코드는 인터페이스가 아닌 구현체로 DI를 시도했기 때문에 예외가 발생한다.



2. CGLIB(Code Generator Library)

CGLIB는 일반적인 오픈소스 클래스 정의 라이브러리이다. Springboot 3.2 버전부터는 의존성을 주입하지 않고 사용이 가능하다. (Spring Core 패키지에 포함됨)
클래스의 바이트 코드를 조작하여 Proxy 객체를 생성하므로, 인터페이스가 아닌 클래스에 대해서도 Proxy를 생성할 수 있다.

CGLIB는 Enhancer라는 클래스를 통해 Proxy를 생성한다.


간단한 구현방식은 다음과 같다

public class ProxyTest {
    // 프록시를 적용할 구현체 생성
    class TargetImpl {
        public void print() {
            System.out.println("Hello!");
        }
    }

    // Proxy 생성 및 호출
    @Test
    void test() {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(TargetImpl.class);
        enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) -> {
            System.out.println("######## ---> method start ");
            Object result = proxy.invoke(obj, args);
            System.out.println("######## ---> method end");
            return result;
        });
        TargetImpl proxy = (TargetImpl) enhancer.create();
        proxy.print();
    }
}

CGLIB는 타깃 클래스를 상속받는 방식으로 Proxy를 생성한다. 그렇기 때문에 Proxy 대상 클래스는 인터페이스를 상속 받지 않아도 된다.



JDK Proxy vs CGLIB

(이미지 원본 링크)


성능차이

JDK Proxy보다 CGLIB가 성능이 좋다. 이유는 CGLIB가 타겟에 대한 정보를 제공받기 때문이다.
CGLib는 제공받은 타겟 클래스에 대한 바이트 코드를 조작해 Proxy를 생성하기 때문에, Handler안에서 타겟의 메서드를 호출할 때 다음과 같은 코드가 형성된다.

public Object invoke(Object proxy, Method proxyMethod, Object[] args) throws Throwable {
  Method targetMethod = target.getClass().getMethod(proxyMethod.getName(), proxyMethod.getParameterTypes());
  Ojbect retVal = targetMethod.invoke(target, args);
  return retVal;
}

메서드가 처음 호출되었을 때 동적으로 타겟의 클래스의 바이트 코드를 조작하고, 이후 호출시엔 조작된 바이트 코드를 재사용한다.



Spring과 SpringBoot의 기본 Proxy 전략


Spring의 기본 전략 - JDK Proxy

Spring AOP에서는 기본적으로 JDK Dynamic Proxy를 기반으로 Proxy를 생성한다. 다만 인터페이스가 없는 경우에는 CGLIB proxy를 사용한다.
(CGLIB 프록싱을 강제로 사용할 수 있긴 하다)

(Spring docs)


SpringBoot의 기본 전략 - CGLIB

Spring Boot에서는 2.0 버전 이후부터 기본적으로 CGLIB proxy를 사용하도록 바뀌었다.
(https://www.springcloud.io/post/2022-01/springboot-aop/#gsc.tab=0)


SpringBoot가 진작 CGLIB을 사용하지 않은 이유는, 기존 CGLIB가 3가지의 한계가 있었기 때문이다.

  1. net.sf-cglib.proxy.Enhancer 의존성 추가(Spring 기본 제공 x)
    • CGLIB은 오픈소스였기 때문에 신뢰하고 사용해도 될 정도의 검증할 시간이 필요했고, Spring에 별도로 의존성을 추가해야했음
  2. default 생성자 필수
  3. 타깃의 생성자 두 번 호출

하지만 어느 시점부터 Spring Boot 에서 CGLIB 방식으로 Proxy를 생성해주고 있었고, SpringBoot github-issues에서 어느 Spring 개발자가 문제를 제기하였다.

이에 대해 Spring Boot의 리더인 Phil은 CGLIB 프록시가 예상치 못한 cast exception을 일으킬 가능성이 적다고 답변하였다.

  1. 의존성을 추가해야한다는 문제
    • Spring 3.2버전부터 CGLIB을 Spring Core 패키지에 포함시켜 더이상 의존성을 추가하지 않아도 개발할 수 있게 됨
    • org.springframework.cglib.proxy
  2. default 생성자 필수 문제, 3. 생성자 두 번 호출
    • 4버전에서는 Objenesis 라이브러리의 도움을 받으며
    • default 생성자 없이도 Proxy를 생성할 수 있게 되었고
    • 생성자 두 번 호출 문제도 개선되었다

CGLIB-based proxy classes no longer require a default constructor. Support is provided via the objenesis library which is repackaged inline and distributed as part of the Spring Framework. With this strategy, no constructor at all is being invoked for proxy instances anymore.
(New Features and Enhancements in Spring Framework 4.0)


결과적으로 기존 CGLIB가 가지고 있던 대부분의 한계들이 개선되어,
Spring 4.3과 Spring Boot 2.0부터는 성능이 더 좋은 CGLIB로 Proxy를 생성하게 되었다.



마치며..

이번 기회를 통해 Spring의 proxy전략에 대해 이해할 수 있었다.
이후에는 이 지식을 기반으로 @Transactional 의 동작방식을 이해해보고자 한다!



참고자료

https://gmoon92.github.io/spring/aop/2019/04/20/jdk-dynamic-proxy-and-cglib.html
https://yeonyeon.tistory.com/289
https://stackoverflow.com/questions/29650355/why-in-spring-aop-the-object-are-wrapped-into-a-jdk-proxy-that-implements-interf

profile
깊게 탐구하는 것을 좋아하는 백엔드 개발자 지망생 lime입니다! 게시글에 틀린 정보가 있다면 지적해주세요. 감사합니다. 이전블로그 주소: https://fladi.tistory.com/

0개의 댓글