[Spring] 프록시(Proxy)와 스프링 AOP

개요

 예전 JPA를 처음 공부하던 시절에 '프록시'라는 개념을 접한 적이 있고, 구조 패턴 중 하나인 프록시 패턴과 데코레이터 패턴에 대해서 공부했던 적이 있다. 이처럼 스프링 환경의 다양한 곳에서 프록시가 사용되고 있다는 것은 들어본 적이 있는데, 지금까지는 @Transactional 사용시 스프링이 트랜잭션 대상 코드를 프록시 형태로 감싼다는 정도로만 알고 있었다. 이번 포스트에서 프록시에 대한 개념부터, 스프링이 프록시를 어떻게 활용하고 있는지에 대해서 자세하게 정리해보도록 하겠다.

 

 

동적 프록시

 타겟 클래스 하나마다 프록시 클래스를 생성하여 적용하는 방법을 정적 프록시라고 한다. 프록시의 사용 목적이 접근 제어나 기능 추가와 같이 공통 코드의 반복이라는 점을 고려하면, 프록시 적용 대상이 되는 서비스 코드의 증가에 따라서 유사한 프록시 클래스가 반복 생성되는 문제를 낳게 될 확률이 높다. 따라서 JDK나 CGLIB같은 기술을 사용하여 동적으로 프록시를 생성 및 적용할 수 있다. 단, 이러한 동적 프록시 기술을 직접 사용하기보다는 원리를 이해하여 AOP와 같은 스프링에서 제공하는 기술들을 이해하는 것을 목적으로 하자.

 

 아래 방법들은 타겟 클래스마다 프록시 클래스를 생성해주는 방법 대신, 핸들러(Handler)를 만들어 적용할 공통 로직을 작성하고, 프록시를 적용할 타겟(target)과 함께 핸들러를 같이 지정해주어 타겟 클래스가 동작할 때 핸들러의 로직이 돌아가도록 하는 원리로 동작한다. 타겟의 경우 인터페이스를 이용하거나, 구체 클래스를 이용하는 방법 모두 가능하다.

 

 

리플렉션

 리플렉션은 클래스나 메서드의 메타정보를 사용하여 호출하는 메소드를 동적으로 변경하는 기술이다. 

Target target = new Target(); // 실제 인스턴스
// 클래스 메타정보 획득
Class classInfo = Class.forName("com.eckrin.test.Reflection$reflectionTest");
// 메서드 메타정보 획득
Method methodCall = classInfo.getMethod("execute"); // 문자열을 사용한 함수 호출 -> 파라미터화해서 클래스 분리 가능
Object result = methodCall.invoke(target); // 실제 인스턴스의 메소드 호출

 

 자바에서 제공하는 Class.forName()이나, Class.getMethod(), Method.invoke()와 같은 메소드들을 사용하여 특정 클래스나 메소드의 메타정보를 가져오고, 문자열을 이용해서 함수를 호출할 수 있다. 이렇게 리플렉션을 사용하면 함수 호출을 파라미터화해서 로직을 분기하여 동적 프록시를 사용할 수 있다. 하지만 .getMethod()를 보면 파라미터로 문자열을 받기 때문에, 파라미터 관련 에러를 컴파일 시점에 잡아낼 수 없다는 단점이 있다.

 

JDK 동적 프록시

 JDK 동적 프록시는 InvocationHandler를 구현하여 적용할 수 있다.

 

// InvocationHandler 구현

/**
 * @param proxy 메서드가 호출될 프록시 인스턴스
 * @param method 호출할 메서드 정보
 * @param args 메서드에 들어갈 파라미터들
 */
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    /**
     * @param target 메소드를 호출할 인스턴스
     * @param args 메소드 호출 요청시 넘길 파라미터들
     */
    Object result = method.invoke(target, args);
    return result;
}

 

 위에서 InvocationHandler를 오버라이딩한 invoke 메소드를 보면, method 파라미터의 invoke 메소드를 통해서 프록시를 통해서 실행할 타겟을 지정해주고 있다. 필요한 공통 로직의 경우 위에서 오버라이딩한 invoke() 메소드 안에 함께 작성해주면 된다.

Object result = method.invoke(target, args);

 

오버라이딩한 invoke() 메소드 안의 이 부분이 실제 로직으로 제어가 넘어가는 부분이다.

 

@Test
void dynamicJDK() {
    InterfaceEx target = new ExImpl();
    TimeInvocationHandler handler = new TimeInvocationHandler(target);

    /**
     * @param loader 프록시의 로딩에 사용될 클래스 로더
     * @param interfaces 프록시가 구현해야 하는 인터페이스들의 리스트
     * @param invocationHandler 프록시 객체의 메서드 호출을 처리하는 invoke 메소드를 구현한 객체
     */
    // 동적으로 프록시 객체 생성
    InterfaceEx proxy = (InterfaceEx) Proxy.newProxyInstance(
            InterfaceEx.class.getClassLoader(),
            new Class[]{InterfaceEx.class},
            handler
    );
    proxy.call();
}

 

 InvocationHandler의 invoke 메소드를 구현하여 만든 구현체는 newProxyInstance라는 Proxy 클래스의 static 메서드의 인자로 들어가고, 함수 실행시 프록시 객체가 반환된다. 이 객체는 JDKProxy가 동적으로 만들어준 프록시 객체이며, 프록시 객체에 있는 메소드를 호출하면 newProxyInstance 메소드의 세 번쨰 인자로 들어온 invocationHandler의 invoke 메소드에 있는 로직이 같이 수행된다.

 

 

CGLIB

 CGLIB(code generator library)라는 외부 라이브러리도 사용할 수 있는데, CGLIB를 사용하면 인터페이스가 없이 구체 클래스만 가지고도 동적 프록시를 만들어낼 수 있다. JDKProxy를 사용할 때는 동적 프록시 적용을 위해서 InvocationHandler를 구현했다면, CGLIB는 MethodIntercepter를 제공한다.

 

// MethodInterceptor 구현

/**
 *
 * @param obj CGLIB가 적용된 객체
 * @param method 호출된 메서드
 * @param args 메서드를 호출하면서 전달된 인수
 * @param methodProxy 프록시 적용할때 사용
 */
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
    Object result = methodProxy.invoke(target, args); // 프록시를 적용할 인스턴스의 메소드
    return result;
}
@Slf4j
public class CglibTest {

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

        // 프록시 설정
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(ConcreteService.class); // 구체 클래스를 상속받아서 프록시를 생성할 수 있다.
        enhancer.setCallback(new TimeMethodInterceptor(target));
        // 프록시 생성
        ConcreteService proxy = (ConcreteService) enhancer.create();
        proxy.call();
    }
}

 

CGLIB가 제공하는 Enhancer를 이용하면 위와 같이 프록시를 세팅, 생성해서 사용할 수 있다. 인자로 제공하는 methodProxy를 이용해서 프록시를 적용하는 구체적인 로직은 JDKProxy와 차이가 존재하지만, 플로우 자체는 유사함을 알 수 있다. 

 

 

스프링과 프록시

 스프링에서 사용하는 프록시 기능들도 위에서 소개한 JDK 동적프록시와 CGLIB 등을 이용한다. 스프링에서 제공하는 ProxyFactory를 사용하면 JDK 동적 프록시와 CGLIB를 같이 사용하는 것도 가능하다.

 

@Test
@DisplayName("JDKProxy를 사용하여 ServiceInterface의 구현체들에 동적 프록시 적용")
void interfaceProxy() {
    ServiceInterface target = new ServiceImpl();
    ProxyFactory proxyFactory = new ProxyFactory(target); // 프록시를 만들때 타겟 주입
    proxyFactory.addAdvice(new TimeAdvice());
    ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy(); // 프록시객체 생성
}

 

포인트컷(Pointcut): 프록시를 통해 적용할 로직을 적용할지 여부를 결정하는 필터링 로직이다. 
어드바이스(Advice): 프록시를 통해 적용할 로직 자체를 말한다.
어드바이저(Advisor): Pointcut+Advice 쌍을 의미한다.
-> 공통 로직을 어느 범위에 적용할 것인가? = 어드바이스를 어느 포인트컷에 적용할 것인가?

 

JDK 동적 프록시와 CGLIB를 사용할 때에도 프록시를 적용할 대상과 로직을 핸들러를 통해서 지정했는데, Pointcut을 적용 대상을 지정하는 필터, Advice이 적용하는 공통 로직이라고 할 수 있다. 

 

 

스프링은 이를 위해서 Pointcut이라는 인터페이스를 지원하는데, 위에 보이는 두 메서드를 오버라이딩하여 적용할 클래스와 메소드 조건을 걸어줄 수 있다.

 

 

빈 후처리기

 @Bean이나 @Component와 같은 어노테이션을 설정해주면 스프링은 컴포넌트 스캔을 통해서 객체를 스프링 빈으로 등록하게 되는데, 이러한 작업은 ApplicationContext가 생성될 때 이루어진다. 따라서 컴포넌트 스캔을 통해서 등록된 객체에 프록시 패턴을 적용하기 위해서는 원래 서비스 객체가 스프링 빈으로 등록되는 과정 사이에 개입이 필요한데, 이 때 사용가능한 것이 스프링에서 제공하는 빈 후처리기이다.

 

 

이것이 스프링에서 기본 제공하는 빈 후처리기이다. 이 후처리기는 스프링 빈으로 등록된 Advisor들을 자동으로 찾고, 내부의 Pointcut과 Advice들을 바탕으로 프록시가 필요한 곳에 자동으로 프록시를 적용해준다. 즉, Advisor들을 스프링 빈으로 등록해주기만 하면 Advisor를 통해서 Pointcut에 부합하는 경우 Advice를 추가하여 프록시 객체를 빈으로 등록해주는 것이다.

 

 

일반적으로 Pointcut은 이 AspectJExpressionPointcut을 많이 사용한다. 

 

추가로 하나의 프록시에 여러개의 Advisor들을 포함할 수 있다는 점을 이용하여 스프링은 임의의 스프링 빈이 여러개의 Advisor의 Pointcut을 만족한다고 할 때에도 하나의 프록시만을 생성하여 비용을 최소화한다. 물론 생성된 프록시는 Pointcut을 만족하는 여러 Advisor들이 세팅되어 있을 것이다.

 

 

Spring AOP

 지금까지 설명한 프록시의 목적은 공통 로직에 대한 책임을 분리하고 코드의 중복을 최소화하는데 있다. 실제 스프링으로 서비스를 구현할때도 핵심 비즈니스 로직과 공통 부가 로직이 나누어지게 되는 경우가 많은데, AOP(Aspect-Oriented Programming)를 통해 핵심 기능에서 부가 기능을 분리하여 역할과 책임의 분리, 중복 최소화를 통해 개발시 변경 지점이 하나가 될 수 있도록 모듈화할 수 있다.

 

 사실 AOP를 적용하는 시점도 컴파일 시점, 클래스 로딩 시점, 런타임 시점과 같이 여러가지 방법들이 존재하는데, 위에서 설명한 프록시와 빈 후처리기를 활용하는 예시가 런타임 시점에 AOP를 적용하는 예시이다. AspectJ와 같은 기능을 사용하면 런타임이 아닌 컴파일, 클래스 로딩 시점에 AOP를 적용하는 것도 가능하기 때문에 스프링 빈을 통해 메소드에만 AOP를 적용할 수 있다는 단점이 상쇄되지만, 여기서는 프록시를 사용하는 스프링 AOP에 대해서 서술하도록 하겠다.

 

밑에 나오는 용어들은 스프링 AOP에서 사용되는 용어들이며 기억해두도록 하자(스프링 AOP는 AspectJ의 용어들을 활용하고 있으나 AspectJ를 그대로 사용하지는 않는다.).

 

JoinPoint 추상적인 개념으로, Advice가 적용될 수 있는 지점을 말한다. 스프링 AOP는 프록시 기반으로 동작하므로 항상 메소드 실행 시점으로 제한된다.
Pointcut JoinPoint 중에 Advice가 적용되어야 하는 지점을 말하며, 주로 AspectJ 표현식을 사용하여 지정한다. AspectJ를 직접 사용하면 메서드 호출 외에도 생성자 호출, 필드값 접근, static메소드 접근과 같은 다양한 JoinPoint들에 Pointcut을 설정할 수 있으나, 스프링 AOP를 사용하면 메소드 호출 시점으로 제한된다.
Target Advice를 받는 객체 자체를 말하며, Pointcut으로 대상이 결정된다.
Advice 부가 기능 자체를 의미하며, 특정 JoinPoint에서 Aspect에 의해서 실행되는 로직을 말한다. Around, Before, After와 같은 다양한 종류의 Advice들이 존재한다.
Aspect 앞서 @Aspect 어노테이션을 사용한 적이 있는데, @Aspect를 적용한 클래스 내 Pointcut과 Advice(둘이 합쳐 Advisor)가 존재했다.  스프링 AOP의 Aspect도 Advisor와 비슷하게 부가 기능과, 해당 부가 기능을 어디에 적용할지 정의한 것을 의미한다.
Advisor 스프링 AOP에만 사용되는 용어로, 하나의 Pointcut-Advice 쌍을 말한다. 
Weaving Pointcut으로 결정한 Target의 JoinPoint에 Advice를 적용하는 행위를 말한다. 다시 말하면 Aspect를 객체에 연결하여 AOP를 적용하는 것을 말한다.

 

 

@Aspect

 위에서 스프링 aop가 제공하는 빈 후처리기를 사용해서 Advisor를 스프링 빈으로 등록하는 과정을 통해서 프록시를 자동 생성할 수 있었다. 하지만 @Aspect 어노테이션을 이용하면 그러한 과정을 더욱 편리하게 간편화할 수 있다.

 

@Aspect
public class LogTraceAspect {

    // Advisor
    @Around("execution(* packagename..*(..))") // Pointcut
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable { 
        // Advisor 로직
        Object result = joinPoint.proceed(); // 프록시로 감싼 객체 호출
    }
}

 

클래스 레벨에 @Aspect 어노테이션을 선언하고, @Around 어노테이션이 붙은 메소드를 선언하면 하나의 Advisor가 완성된다. @Around 안에 들어간 표현식이 프록시를 적용한 Pointcut을 의미하고, execute함수의 body가 Advice가 되어 함수 전체가 Advisor로 동작할 수 있게 된다.

 

위와 같이 설정하면 앞서 설명한 스프링 aop의 빈 후처리기(AnnotationAwareAspectJAutoProxyCreator)가 @Aspect가 적용된 클래스들을 찾아서 Advisor로 등록해준다. 즉 스프링 aop의 빈 후처리기는 스프링 빈으로 등록된 Advisor 뿐 아니라, @Aspect 어노테이션도 찾아서 Advisor로 변환하고 프록시를 생성한다.

 

 

Advice 범위 설정

 Advice를 코드레벨에서 컨트롤할 수도 있지만, 어노테이션을 통해서 적용 시점을 관리할 수 있다.

@Around 메소드 호출 전후에 전반적으로 수행된다.
@Before JoinPoint 실행 직전에 수행한다.
@After JoinPoint 실행 후 정상 혹은 예외여부에 관계없이 수행한다.
@AfterReturning JoinPoint 정상실행 직후에 수행한다.
@AfterThrowing JoinPoint 실행후 예외 발생시 수행한다.

 

코드로 적용 위치를 살펴보면 다음과 같다. 

 

@Aspect
public class LogTraceAspect {

    @Around("execution(* packagename..*(..))") // Pointcut
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable { 
        try {
            // @Before
            Object result = joinPoint.proceed(); // JoinPoint 실행
            // @AfterReturning
        } catch (Exception e) {
            // @AfterThrowing
        } finally {
            // @After
        }
    }
}

 

사실 모두 @Around 안에서 수행되는 작업들이고, 별도의 함수로 분리하고 싶을 때 사용할 수 있다. 또한 @Around는 ProceedingJoinPoint를 인자로 받아 proceed() 메소드를 통해서 내부 로직을 실행해주어야 하지만(실수로 proceed() 메소드를 빼먹으면 비즈니스 로직은 실행되지 않고 공통 로직만 실행되는 문제가 생긴다), 나머지 어노테이션을 적용한 메소드는 JoinPoint만을 인자로 받고, proceed()를 별도로 수행하지 않아도 된다.

 

 

Pointcut 지시자

@Around("execution(* packagename..*(..))")

 

 AspectJ는 Pointcut을 표현하기 위한 여러 지시자를 제공한다. 위에서 @Around 내에 존재하는 것을 포인트컷 표현식이라고 하고, 괄호를 묶고 있는 execution을 포인트컷 지시자라고 한다. Spring AOP에서 사용할 수 있는 지시자는 execution 이외에도 여러 종류가 있지만, 가장 많이 쓰이는 지시자는 execution이다.

 

 

Execution

 

 execution({접근제어자} 반환타입 {선언타입} 메서드이름(파라미터) {예외})

 

- 접근제어자와 선언타입, 메서드명과 예외는 생략할 수 있다.

 

 

기타 포인트컷 지시자

 

 execution 이외에는 사용빈도가 높아보이지는 않는다. args, @args, @target의 경우 단독으로 사용하면 안되고, 다른 지시자와 함께 사용해야 한다.

within 특정 타입 내의 JoinPoint로 매핑
args 인자가 주어진 타입의 인스턴스인 JointPoint로 매핑
@target 인스턴스의 모든 메서드를 JoinPoint로 매핑 (부모 클래스 메서드까지 적용)
@within 해당 타입 내의 메서드를 JoinPoint로 매핑 (부모 클래스 메서드는 적용 X)
@annotation 메서드가 주어진 어노테이션을 가지고 있는 JoinPoint로 매핑
@args 전달된 실제 인수의 런타임 타입이 주어진 타입의 어노테이션을 갖는 JoinPoint로 매핑
bean 스프링 빈의 이름으로 JointPoint 매핑
this 스프링 빈 객체(AOP 프록시)를 대상으로 하는 JoinPoint로 매핑
target Target 객체(AOP 프록시가 가리키는 실제객체)를 대상으로 하는 JoinPoint로 매핑

 

 

 

Spring AOP 사용시 주의할 점

내부 호출

 앞서 이야기했듯이, 스프링 AOP는 실제 클래스가 아닌, 프록시 패턴이 적용된 클래스를 빈으로 등록하는 방법으로 동작한다. 코드를 직접 삽입하는(AspectJ같은) 방식이 아니기 때문에, 대상 클래스를 컨테이너에서 불러서 사용할 경우 프록시 객체가 호출되게 된다. 하지만 프록시 객체는 하나의 객체가 아니라 내부 객체에 프록시 패턴이 적용된 만큼, this를 사용한 호출이 발생하는 경우 프록시가 적용되지 않은 내부 객체를 호출하게 된다.

 

@Slf4j
@Aspect
public class InternalAspect {

    @Before("execution(* com.eckrin.test.aop_internalcall..*.*(..))")
    public void doLog(JoinPoint joinPoint) {
        log.info("aop 적용됨 : {}", joinPoint.getSignature());
    }
}
@Slf4j
@Component
public class InternalService {

    public void outer() {
        log.info("외부 메서드 호출 로직");
        inner(); // 내부 메서드 호출 (== this.inner();) -> AOP가 적용되지 않음
    }

    public void inner() {
        log.info("내부 메서드 호출 로직");
    }
}

 

위와 같이 간단하게 프록시가 적용될 타겟을 잡고, outer()메서드를 호출해보면 쉽게 결과를 확인해볼 수 있다. 의도대로라면 outer()메서드를 호출할 때와 outer() 내부에서 inner() 메서드를 호출할 때 모두 AOP프록시가 적용되어야 하지만, 로그를 확인하면 inner()에는 프록시가 적용되지 않는 것을 확인할 수 있다.

 

 

이는 outer에서 호출한 inner메서드는 프록시가 적용된 그것이 아닌, 순수한 InternalService이기 때문이다. (this.inner()로 이해하면 바로 이해가 갈 것이다.)

 

자주 발생하지는 않을 상황인 것 같기는 하지만, AOP의 프록시에 대해서 완벽하게 이해하지 못하고 있다면 찾기 힘들 것 같아 정리해두었다.

 

 

상속으로 인한 한계

 스프링 AOP는 기본값으로 CGLIB를 사용한다.

spring.aop.proxy-target-class=true // default

 

 JDK 동적 프록시는 인터페이스만을 대상으로 프록시를 적용할 수 있기 때문에 인터페이스가 존재하지 않는 경우 프록시를 적용할 수 없고, DI시에도 구체 클래스를 대상으로는 의존관계 주입이 불가능하기 때문이다. 하지만 CGLIB도 상속을 기본 전략으로 하기에 기본 생성자가 반드시 필요하고, final 클래스나 final 메서드가 존재하는 클래스를 대상으로는 프록시가 정상적으로 생성되지 않는다는 문제가 존재한다. (당연히 final 변수는 상관 없다 - 이 부분은 자바 문법과 관련된 부분임)

 

 스프링은 이 중에서 'objenesis'라는 특별한 라이브러리를 사용하여 기본 생성자가 필수로 필요한 문제를 해결했다. 따라서 우리는 스프링 AOP를 이용할 때, 메서드나 클래스에 적용된 final 키워드에 대해서만 주의하면 된다.