Proxy 에 대한 이해 – 3. Spring AOP

이 글을 작성하게 된 계기이기도 하고, Proxy 하면 가장 먼저 떠오르는것이 Spring AOP 에 관한 내용일 것이다.
Spring AOP 는 기본적으로 Proxy 를 이용해서 동작하게 된다. 핵심이 되는 비즈니스 로직과 부가 기능을 서로 분리시켜 로직을 재사용 할 수 있게 해주고, 핵심이 되는 기능은 온전히 그 역할에만 집중할 수 있도록 만들어준다.
Proxy 파트 마지막 글로 이번에는 SpringFramework 에서 Proxy 를 어떤 식으로 사용하는지에 대해 한번 정리해본다.

Spring AOP – JDK Dynamic Proxy

Spring에서는 JDK Proxy 방식과 CGLib 을 이용한 Proxy를 둘 다 사용한다. 별도의 설정이 없다면 기본값은 CGLib 을 이용해 Proxy 객체를 만든다. ( SpringBoot )왜 JDK Proxy 가 아닌 CGLib이 기본값일까? Proxy 2편에서 살펴본 DiscountPolicy 예제를 통해서 살펴보자.

public interface DiscountPolicy{
    int totalDiscountMoney(int price);
}

@Component
public class FixedRateDiscountPolicy implements DiscountPolicy {
    private int rate = 10;
    
    public FixedRateDiscountPolicy(int rate){
        this.rate = rate;
    }    

    @Override
    public int totalDiscountMoney(int price){
        return ( price / this.rate );
    }

}

@Configuration
public class DiscountProxyBeanHandler {


    @Bean
    public InvocationHandler discountPolicyInvocationHandler(DiscountPolicy discountPolicy){
        return new DiscountPolicyInvocationHandler(discountPolicy);
    }

    @Bean
    public DiscountPolicy discountPolicyProxy(DiscountPolicy discountPolicy, InvocationHandler discountInvocationHandler){
        return (DiscountPolicy) Proxy.newProxyInstance(
                discountPolicy.getClass().getClassLoader(),
                discountPolicy.getClass().getInterfaces(),
                discountInvocationHandler);
    }
}

이런 식으로 Bean 을 등록해 볼 수 있겠다.
이 Configuration 을 보면 DiscountPolicy 라는 Interface의 하위에 Bean이 2개가 등록되어 있다.
Bean 이 여러개 등록되면 @Qualifier 나 name 을 다르게 설정해서 사용할수는 있지만 좋은 방법은 아니다.
이를 회피하려면 discountPolicyProxy( ) 에서 DiscountPolicy 객체를 생성하면 될것이다. 한번 변경해보자.

//@Component
public class FixedRateDiscountPolicy implements DiscountPolicy {
    private int rate = 10;
    
    public FixedRateDiscountPolicy(int rate){
        this.rate = rate;
    }    

    @Override
    public int totalDiscountMoney(int price){
        return ( price / this.rate );
    }

}

@Configuration
public class DiscountProxyBeanHandler {

    @Bean
    public InvocationHandler discountPolicyInvocationHandler(DiscountPolicy discountPolicy){
        return new DiscountPolicyInvocationHandler(discountPolicy);
    }

    @Bean
    public DiscountPolicy discountPolicy(InvocationHandler discountInvocationHandler){
        FixedDiscountPolicy discountPolicy = new FixedDiscountPolicy(10);
        return (DiscountPolicy) Proxy.newProxyInstance(
                discountPolicy.getClass().getClassLoader(),
                discountPolicy.getClass().getInterfaces(),
                discountInvocationHandler);
    }
}

이렇게 하면 Bean 이 2개가 등록되는 문제를 회피하고 discountPolicy 의 Proxy 를 만들 수 있다. 그러면 문제가 이제 전부 해결되었는가? 그렇지 않다.
JDK Proxy 에서도 살펴보았지만 Interface 에 대한 Proxy 를 만드는 특성때문에, 구체 클래스에 대한 의존성이 없어지게 된다.

@RestController
@RequiredArgsConstructor
public class DiscountController{
    //private final FixedDiscountPolicy fixedDiscountPolicy; -> Cannot autowired
    private final DiscountPolicy discountPolicy;

...
}

이렇게 되면 구체 클래스로는 Autowired 할 방법이 없어진다. 이 문제를 해결하려면 다시 FixedDiscountPolicy 에 해당하는 Bean 을 등록하면 되겠지만, 그렇게되면 또 같은 interface에 Bean이 2개가 등록되는 문제가 발생한다. 따라서 이 방식은 구체 클래스에 의존이 불가능해지는 문제를 안고 갈 수 밖에 없다.
Spring Framework 도 살펴본 Configuration 처럼 JDK Proxy 를 생성할 때 내부적으로 2개의 Bean 을 모두 등록하지 않는다. Proxy 가 처리된 Bean 으로 바꿔 끼기 때문에, 구체 클래스에는 의존할 수 없게 되고 오직 Proxy 가 적용된 Bean 딱 하나만 사용가능하게 된다.

Spring AOP – CGLib

이런 단점을 가지고도 CGLib 이 기본으로 탑재되지 않고 JDK Proxy 를 썼던 이유는 크게 2가지이다.
1. 생성자 2번 호출
2. Default Constructor 필수

우선 Default Constructor 는 CGLib 이 상속을 기반으로 만들어지는 Proxy 이기 때문에 그렇다.
Java 는 상속받은 자식 클래스가 Instance 화 될 때 super( ) 를 반드시 호출해야 한다. 때문에 기본 생성자가 반드시 필요하다.

그리고 생성자가 2번 호출되는 문제의 경우는 이런 것이다.

public class FixedDiscountPolicy implements DiscountPolicy { ... }
public class FixedDiscountPolicyProxyByCGLib extends FixedDiscountPolicy{
    FixedDiscountPolicyProxyByCGLib(){
        ...
        super( ... ); //Call SuperClass constructor
    }
}

Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(FixedDiscountPolicy.class);
enhancer.setCallback(new MethodInterceptor() {
    public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
        System.out.println("Before method call");
        Object result = proxy.invokeSuper(obj, args);
        System.out.println("After method call");
        return result;
    }
});

// 하위 FixedDiscountPolicy 생성자 다시 호출
FixedDiscountPolicy cglibFixedPolicyProxy = (FixedDiscountPolicy) enhancer.create( ... ); //여기서 호출

CGLIB은 원본 클래스를 상속받는 자식( Proxy ) Class 를 동적으로 만들기 위해, Super Class 에 대한 정보가 필요하다. 이 과정에서 SuperClass 를 인스턴스화 하고 만들어낸다. 그 과정에서 super클래스의 생성자를 호출하게 된다.
그리고 이렇게 동적으로 만들어낸 Class를 Enhancer 가 실제로 인스턴스화 하는 그 순간에 자식 클래스의 생성자 내부에 반드시 포함된 super( … ) 가 호출되며 super클래스의 생성자를 2번 호출한 셈이 된다.

상기한 이런 문제들로 인해서 CGLib 이 사용되지 않고 있다가, 관련 문제들이 이제는 다 해결되어서 기본값으로 지정되어 있다.

이 2가지 문제는 objenesis 라는 라이브러리를 이용해서 해결했다고 한다. 이 objenesis 는 객체를 생성할 때 생성자를 호출하지 않고 객체를 인스턴스화할 수 있는 방법을 제공한다. 따라서, 기본생성자가 필요한 문제도, 그리고 Superclass 의 생성자를 여러번 invoke 하던 문제도 해결한다.
이러한 문제들을 해결하고 나면, JDK Proxy의 단점인 Interface 이슈도 해결하고, 성능상 이점도 존재하는 CGLib 방법이 JDK Proxy 의 상위호환격인 Proxy 생성 기법이 된다.

결론

이러한 이유 때문에 SpringBoot 에서는 별다른 설정이 없다면 CGLib 을 이용해서 프록시 객체를 만들어낸다. 심지어 Interface 를 구현한 구현체라서 JDK Proxy 로 구현할 수 있는 경우에도 위에서 살펴본 JDK Proxy 가 갖는 문제점들 때문에 CGLib 으로 프록시를 만든다.

따라서 Spring Boot 2 이상의 프로젝트에서 Spring AOP 를 사용하게되면 AOP 의 근간을 이루는 Proxy 는 CGLib 을 이용한 Byte 코드 조작을 통해 만들어진다고 정리하면 될것 같다.

Leave a Comment