11 min read

<Spring> AOP 발전과정 살펴보기 1

스프링 AOP는 공통적인 횡단 관심사 영역을 프록시를 통해 한 곳에 모아서 Aspect로 관리하는 프로그래밍 패러다임이다. 공통 관심사의 대표적인 예는 로깅,트랜잭션,보안 처리 등이 있을 수 있다. 이러한 공통 관심사들을 비즈니스 로직에서 분리한다라는 목적을 가진 프로그래밍 방식이라고 이해할 수 있다. 이번 글에서는 스프링에서 제공하는 AOP가 어떻게 발전하였는지 그 순서를 차근차근 알아본다.

시작은 Proxy

스프링은 등록된 스프링 빈들을 관리하는 컨테이너를 제공한다. 해당 컨테이너로부터 빈들을 싱글톤으로 등록된 빈들을 요구사항이 필요한 또 다른 빈에 주입을 시켜줄 수 있다.여기서 상상을 한번 해보자. A라는 인터페이스의 빈이 필요한 B라는 클래스에게 AProxy라는 A 타입의 구현체를 넣을 수 있을까? 우리가 배운 다형성에 의하면 부모 타입은 자식 타입을 허용하니 될 것만도 같다. 코드로 간단히 알아보자.

보통의 빈 등록을 한다고 가정하면 아래와 같이 각각의 구현체들의 오브젝트를 빈으로 등록하면 된다.

@Configuration
public class TestConfig {

    @Bean
    public A a() {
        return new AImpl();
    }

    @Bean
    public B b() {
        return new BImpl(a());
    }
}

위 코드를 아래와 같이 바꿀 수 있지 않을까?

@Configuration
public class TestConfig {

    @Bean
    public A a() {
        return new AProxy();
    }

    @Bean
    public B b() {
        return new BImpl(a());
    }
}

이렇게 하면 빈을 바꿔치기 할 수 있다. 외부에서 A라는 빈을 호출할 경우 이제부터는 AProxy 오브젝트가 호출되면서 요청을 처리할 것이다. 즉,기존에는 AImpl 오브젝트가 요청을 받고 특정 비지니스 로직을 수행한다. 하지만 변경 후에는 AProxy가 먼저 호출된다. 즉, Aproxy도 기존의 AImpl이 수행하던 비지니즈 로직을 정상적으로 수행하여야 정상적인 호출 흐름을 보장할 수 있다.간략히 그림으로 보면 아래와 같다.

요청의 흐름이 Client -> Proxy(AProxy) -> RealSubject(AImpl) 순으로 진행된다.그러면 AProxy 오브젝트는 실제로 자신이 호출할 대상인 AImpl의 레퍼런스를 가지고 있어야 한다. 의존성 주입 코드를 추가해보자.

@Configuration
public class TestConfig {

    @Bean
    public A a() {
		AImpl impl = new AImpl();
        return new AProxy(impl);
    }

    //...
}

위와 같이 구성하면 얼추 AOP스럽게 구현된것 같다. 관점을 분리시켰는지 한번 확인해보자.우리가 원하는 것은 비지니스 로직과 인프라 로직을 분리하는것이다. 비지니즈 로직은 여전히 AImpl 내의 메서드가 수행한다.그리고 인프라 로직은 AProxy() 내부의 메서드가 수행하고 해당 메서드 내부에서 AImpl의 메서드를 호출하면 된다.즉,프록시를 통해 부가 기능을 추가하였다.(데코레이터 패턴의 Intent와 일치한다)

더 이해하기 위해 AProxy 클래스의 코드를 살펴보자.

public class AProxy implements A {

    private final AImpl target;

    public AProxy(AImpl target) {
        this.target = target;
    }

    @Override
    public String call() {
        long start = System.currentTimeMillis();
        String result = target.call();
        long end = System.currentTimeMillis() - start;
        System.out.println("end = " + end);
        return result;
    }
}

위 코드에서 인프라 로직은 메서드의 측정 시간을 로그로 남겨주는 것이다.그리고 target의 call()을 호출할 경우 비지니즈 로직이 실행된다.결론적으로 우리는 원본 코드인 AImpl의 코드를 수정하지 않고 공통적인 관심사를 구현 할 수 있게 되었다.

그러면 여기서 동일한 관심사를 B라는 인터페이스에 추가하고 싶다면 어떻게 해야할까? 동일하게 BProxy 클래스를 생성하고 위와 같이 데코레이터 패턴을 활용하여 부가 기능을 추가해주면 된다. 만약 관리해야하는 빈들의 수가 100개,1000개가 넘어가기 시작하면 이들에 해당하는 모든 빈들의 프록시 클래스를 생성해줘야한다.

JDK Dynamic Proxy와 CGLIB

앞선 내용을 통해 원본 코드를 수정하지 않고 부가적인 인프라 로직을 구현하는 지 알아 보았다.위 방식은 관리하는 빈의 개수에 따라 그만큼의 프록시 객체를 생성해줘야하는 단점이 존재한다. 이를 해결하기 위해 Java에서는 JDK Dynamic Proxy와 CGLIB을 제공한다. 위 두가지 클래스를 알아보기 전에 인터페이스 기반의 프록시와 구체 클래스 기반의 프록시에 대해 먼저 알아보겠다.

인터페이스 vs 구체 클래스 기반의 프록시

이 둘의 차이는 proxy 객체의 target이 되는 오브젝트가 인터페이스로부터 생성 되었냐, 클래스로부터 생성 되었냐에 따라 발생한다.아래의 코드를 보면 이해할 수 있다.

우선 인터페이스 기반의 프록시 클래스를 먼저 살펴보자.

아래와 같은 인테페이스에 대한 프록시 클래스를 MyInterfaceProxy라는 이름으로 생성한다.

public interface MyInterface {
    void call();
}

인터페이스에 대한 프록시이기에 인터페이스의 call() 메서드를 구현한다.구현 내용에 적용시킬 공통 관심사 로직(인프라 로직)을 구현하고 이후 target 변수를 통해 실제 호출될 오브젝트의 비지니스 로직을 수행 시키면 된다.

public class MyInterfaceProxy implements MyInterface {

    private final MyInterface target;

    public MyInterfaceProxy(MyInterface target) {
        this.target = target;
    }

    @Override
    public void call() {
        /**
         * do some infra logic
         */
        target.call();//invoke target biz logic
    }
}

다음으로 구체 클래스 기반의 프록시를 생성하는 경우이다.아래와 같이 프록시의 target이 되는 구체 클래스가 구현 되어있다. object라는 변수를 외부로부터 주입받아야 하는 오브젝트이다.해당 클래스를 기반으로 프록시를 생성해보자.

public class MyConCreteClass {

    private final Object object;

    public MyConCreteClass(Object object) {
        this.object = object;
    }

    public void call() {
        System.out.println("MyConCreteClass.call");
    }
}

아래와 같이 구체 클래스 기반의 프록시 객체를 만들 수 있다.의존성을 주입받는것을 비롯한 call() 메서드의 로직은 인터페이스 기반의 프록시와 동일하다.다만,부모 타입의 생성자를 호출해줘야하는 코드를 프록시의 생성자 내에 추가해야한다.

public class MyConcreteClassProxy extends MyConCreteClass {
    private final MyConCreteClass target;

    public MyConcreteClassProxy(MyConCreteClass myConCreteClass) {
        super(null);
        this.target = myConCreteClass;
    }

    @Override
    public void call() {
        /**
         * do Some infra logic
         */
        target.call(); // invoke target biz logic
    }
}

결론적으로 인터페이스 기반의 프록시 클래스를 생성하는것이 유연한 구조를 가져갈 수 있다는 것을 알 수 있다.

JDK Dynamic Proxy 적용하기

이제 다시 본론으로 돌아와보면, JDK 동적 프록시는 Java에서 제공하는 Proxy 클래스를 통해 프록시 객체들을 생성할 수 있는 기능을 제공한다.해당 기능을 이해하기 위해 리플렉션에 대해 코드로 통해 간단히 알아보자.

@Slf4j
public class ReflectionTest {

    @Test
    void reflection1() throws Exception {
        //클래스 정보
        Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");

        Hello target = new Hello();

        //callA 메서드 정보
        Method callA = classHello.getMethod("callA");
        Object result1 = callA.invoke(target);
        log.info("result1: {}", result1);

        //callB 메서드 정보
        Method callB = classHello.getMethod("callB");
        Object result2 = callB.invoke(target);
        log.info("result2: {}", result2);
    }

    @Test
    void reflection2() throws Exception {
        Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");

        Hello target = new Hello();

        //callA 메서드 정보
        Method callA = classHello.getMethod("callA");
        dynamicCall(callA, target);

        //callB 메서드 정보
        Method callB = classHello.getMethod("callB");
        dynamicCall(callB, target);
    }

    private void dynamicCall(Method method, Object target) throws Exception {
        log.info("start");
        Object result = method.invoke(target);
        log.info("result: {}", result);
    }

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

        public String callB() {
            log.info("callB");
            return "B";
        }
    }
}

reflection2() 테스트 코드를 확인해보면 런타임에 Hello라는 클래스가 가지는 메서드 정보를 뽑아낼 수 있다.이렇게 뽑아낸 정보를 Method 타입의 인자로 넘겨 동적으로 해당 메서드를 실행시킨다.즉, 위와 같은 리플렉션을 통해 런타임에 특정 메서드의 정보를 뽑고 이에 대한 부가적인 기능을 추가한 뒤, 실제 메서드를 호출하는 식으로 프록시 객체를 생성하는 것이 JDK 동적 프록시이다.

앞서 생성한 TestConfig에서 빈 등록을 할 때 프록시 클래스를 직접 생성해서 등록하던 방식을 JDK 동적 프록시를 사용하여 변경해보자. Proxy 클래스의 스태틱 메서드인 newProxyInstance을 호출하여 프록시 오브젝트를 생성할 수 있다.

@Configuration
public class TestConfig {


	@Bean
    public A a() {
        AImpl a = new AImpl();
        return (A) Proxy.newProxyInstance(a.getClass().getClassLoader(), new Class[]{A.class}, new TimeInvocationHandler(a));
    }

    //...
}

또한 JDK 동적 프록시의 경우, 공통 관심사인 인프라 로직을 InvocationHandler 구현하는 클래스에 구현해줘야한다. 아래는 간단하게 메서드의 측정 시간을 로그로 남겨주는 인프라 로직을 구현하였다.

@Slf4j
public class TimeInvocationHandler implements InvocationHandler {

    private final Object target; // proxy가 호출할 대상

    public TimeInvocationHandler(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        log.info("TimeProxy 실행");
        long start = System.currentTimeMillis();
        Object result = method.invoke(target, args); // 타겟 메서드 호출(타겟은 AImpl, BImpl)
        long end = System.currentTimeMillis();
        long resultTime = end - start;
        log.info("Result Time: " + resultTime);
        return result;
    }
}

JDK 동적 프록시를 사용하면 한결 수월하게 프록시 객체를 생성할 수 있다.

다만 JDK 동적 프록시는 인터페이스 기반의 오브젝트에 한해서만 프록시만을 생성해줄 수 있다.즉,앞서 생성했던 구체 클래스를 기반으로 하는 오브젝트의 경우 JDK 동적 프록시로 프록시를 생성할 수 없다.

CGLIB

이를 해결해주는 라이브러리가 소개된 CGLIB이다. 이번에는 CGLIB을 사용하여 TestConfig에서 프록시 객체를 빈으로 등록해보자. Cglib 라이브러리에 제공해주는 Enhancer라는 클래스를 통해 프록시를 생성할 수 있다. 아래에 보이는 setSuperclass()의 javadoc을 읽어보면 인자로 클래스 타입과 인터페이스 타입을 모두 처리해준다고 알려준다. 즉, JDK 동적 프록시가 수행하지 못하는 구체 클래스 타입의 프록시를 제공해 줄 수 있다.

@Configuration
public class TestConfig {


	@Bean
    public A a() {
        AImpl a = new AImpl();
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(A.class);
        enhancer.setCallback(new TimeMethodInterceptor(a));
        return (A) enhancer.create();
    }

    //...
}

CGLIB 또한 JDK 동적 프록시의 InvocationHandler 처럼 인프라 로직을 구현을 담당하는 클래스가 존재한다. MethodInterceptor를 구현하면 된다. 아래는 앞선 예제와 동일하게 메서드의 실행 시간을 로깅해주는 인프라 로직을 구현하였다.

@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("Method Name: " + method.getName());
        log.info("TimeProxy 실행");
        long start = System.currentTimeMillis();
        Object result = methodProxy.invoke(target, args);
        long end = System.currentTimeMillis();
        long resultTime = end - start;
        log.info("Result Time: " + resultTime);
        return result;
    }
}

이렇게 프록시 클래스를 생성하지 않고 CGLIB과 JDK 동적프록시를 활용하여 프록시 객체를 만들어 보았다. 다만, 두 가지 방식을 각각 구체 클래스와 인터페이스인 경우를 나누어 사용자가 직접 사용하기에는 불편하다고 생각된다. 이를 사용자가 편리하게 사용하게 해주는 ProxyFactory를 스프링은 제공해준다.

해당 주제부터는 다음 포스팅에서 살펴보겠다.