본문 바로가기
서버/스프링

[스프링 AOP] 2. 스프링 AOP 프록시 생성 원리

by 베어 그릴스 2024. 10. 2.
320x100

 

이전 글에서 스프링 AOP의 정의와 기본 개념이 되는 Proxy에 대해서 알아보았다. 이제는 스프링에서 Proxy를 어떻게 만들어주고 있는지 알아보자.

 

Weaving

weaving이란 우리가 모듈화한 부가 기능을 타켓에 적용해 핵심 기능과 연결하는 과정을 뜻한다.

 

weaving은 크게 3가지로 나눌 수 있다.

컴파일 타임 위빙

.java 코드를 컴파일러를 사용해서 .class을 만드는 시점에 부가 기능을 추가하는 것을 말한다.

컴파일 과정에 부가 기능을 끼워넣기 때문에 특별한 컴파일러가 필요하다.

.class파일을 다시 디컴파일 해보면 애스팩트 관련 호출 코드가 들어간다.

클래스 로드 타임 위빙

자바언어는 .class파일을 JVM내에 클래스 로더에 보관하는데 이 때 부가 기능을 넣기 위해 .class파일의 바이트 코드를 조작하여 JVM에 올린다.

자바를 실행할 때 특별한 옵션을 통해 클래스 로더 조작기를 지정해야 한다.

런타임 시점 위빙

이미 자바가 실행된 다음 자바 언어가 제공하는 범위 안에서 부가기능을 적용

앞선 글에서 설명했듯이 스프링에서는 컨테이너의 도움으로 최종적으로 프록시를 통해서 부가 기능을 추가한다.

 

실제로 등록되는 프록시 빈은 다음과 같다.

 

 

스프링에서 프록시를 만드는 방법 - ProxyFactory

앞선 글에서 동적 프록시를 만드는 방법으로 인터페이스 기반, 클래스 기반 두가지 방식이 있었다.

스프링은 과연 무엇을 어떻게 만들고 있을까?

 

스프링은 ProxyFactory를 사용하고 있다.

ProxyFactory란 클래스 이름 그대로 프록시를 만들어주는 클래스로 spring aop 의존성에 라이브러리로 포함되어 있다.

 

ProxyFactory는 기타 설정 혹은 클래스의 인터페이스 여부를 확인해 인터페이스 기반 프록시를 만들지 클래스 기반 프록시를 만들지 선택한다.

(자세히는 proxyTargetClass = true면 CGLIB, false이면 인터페이스 여부에 따라 CGLIB을 쓸지 jdk 동적프록시를 만들지 갈린다.)

 

클라이언트 입장에서 ProxyFactory를 통해 프록시를 만들면 ProxyFactory가 알아서 jdk 동적 프록시 혹은 cglib 동적 프록시를 만들어준다.

클라이언트는 내가 쓰는 것이 jdk 동적 프록시든 CGLIB 프록시든 신경쓸 필요가 없다.

 

클라이언트는 ProxyFactory에게 프록시 생성을 요청하고 ProxyFactory가 알아서 생성해준다.

 

실제 구현체 코드는 다음과 같다.

 

 

이쯤에서 의문이 들 것 같다.

 

부가기능을 전달할 때 CGLIB에서는 MethodInterceptor 인터페이스를 구현해서 적용했고, Jdk 동적 프록시에서는 InvoicationHandler 인터페이스를 구현해서 전달했다.

 

ProxyFactory에도 마찬가지로 부가기능을 구현해서 전달해야 할텐데 두 인터페이스를 둘 다 구현해서 전달해야하나? 무언가 추상화한 인터페이스가 있을까?

 

여기서 바로 Advice의 개념이 나온다.

 

스프링 aop에서 Advice란 부가기능을 추상화한 인터페이스이다.

 

프록시 팩토리를 통해 반환된 프록시는 최종적으로 부가기능을 추상화한 Advice를 호출하기 때문에 프록시 팩토리를 사용하면 JDK 동적 프록시가 제공하는 InvocationHandler와 CGLIB이 제공하는 MethodInterceptor을 신경쓰지 않고 Advice 인터페이스를 구현하여 부가기능을 만들어 전달하면 된다.

 

프록시 팩토리에 Advice를 전달하여 프록시 객체를 생상하는 예시는 다음과 같다.

// Advice 인터페이스를 구현해 부가기능을 전달
// MethodInterceptor는 Advice 인터페이스를 상속받고 있음
class TimeAdvice: MethodInterceptor {
    override fun invoke(invocation: MethodInvocation): Any? {
        println("TimeAdvice start")
        val start = System.currentTimeMillis()

        // target 클래스 정보가 invocation에 들어있음
        // 프록시 팩토리로 프록시를 생성하는 단계에서 target 객체를 파라미터로 넣어주기 때문에 여기서 target 객체를 알 수 있음
        val result = invocation.proceed()

        val end = System.currentTimeMillis()
        println("Time: ${end - start}")
        println("TimeAdvice end")
        return null
    }
}



class ProxyFactoryTest {

    // 클라이언트 입장에서 ProxyFactory가 알아서 jdk 동적 프록시 혹은 cglib 동적 프록시를 만들어주고, 그 프록시는 adviceMethodInterceptor 혹은 handler로 Advice를 적용해준다.
    @Test
    fun `인터페이스가 있으면 ProxxyFactory가 Jdk동적프록시로 만들어준다`() {
        val target = ServiceImpl()
        val proxyFactory = ProxyFactory(target)
        
        // Advice를 전달한다.
        val pointcutAdvisor = DefaultPointcutAdvisor(Pointcut.TRUE, TimeAdvice())
        proxyFactory.addAdvisor(pointcutAdvisor)

        // 인터페이스가 있으면 JdkDynamicProxy를 사용한다. 없으면 CglibProxy를 사용한다.
        val proxy = proxyFactory.getProxy() as ServiceInterface

        println(target.javaClass)
        println(proxy.javaClass)
        proxy.save()

        AopUtils.isJdkDynamicProxy(proxy)
    }

    @Test
    fun `인터페이스가 없으면 ProxxyFactory가 Cglib동적프록시로 만들어준다`() {
        val target = ConcreteService()
        val proxyFactory = ProxyFactory(target)
        proxyFactory.addAdvice(TimeAdvice())

        val proxy = proxyFactory.getProxy() as ConcreteService

        println(target.javaClass)
        println(proxy.javaClass)
        proxy.call()

        AopUtils.isCglibProxy(proxy)
    }

    /**
     * 스프링 부트에서는 자동으로 ProxyTargetClass 옵션을 true로 설정한다.
     * 그래서 인터페이스가 있어도 CGLIB 클래스 기반 프록시를 사용한다.
     */
    @Test
    fun `ProxyTargetClass 옵션을 사용하면 인터페이스가 있어도 CGLIB 클래스 기반 프록시 사용`() {
        val target = ServiceImpl()
        val proxyFactory = ProxyFactory(target)
        proxyFactory.addAdvice(TimeAdvice())
        proxyFactory.isProxyTargetClass = true

        val proxy = proxyFactory.getProxy() as ServiceInterface

        println(target.javaClass)
        println(proxy.javaClass)
        proxy.save()

        AopUtils.isCglibProxy(proxy)
    }
}

 

Advice라는 인터페이스로 부가기능을 추상화하고 InvoicationHandler 인터페이스의 구현체 AdviceInvocationHandler 혹은  CGLIB에서는 MethodInterceptor 인터페이스를 구현하는 AdviceMethodInterceptor를 통해 전달받은 Advice를 실행하도록 한다.

 

 

순서대로 정리하면 다음과 같다.

  1.  ProxyFactory가 알아서 jdk 동적 프록시 혹은 cglib 동적 프록시를 반환한다.
  2.  AdviceInvocationHandler 혹은 AdviceMethodInterceptor는 최종적으로 프록시를 만들 때 전달받은 부가기능인 Advice를 호출한다.

위에서는 AdviceInvocationHandler라고 칭했지만, 실제로 InvocationHandler 구현체인 JdkDynamicProxy 클래스를 들어가보면 다음과 같이 invoke할 때 advice들을 실행시켜주는 것을 볼 수 있다.

 

 

스프링 AOP 용어 간단 정리

자 이제 원리는 어느 정도 알아본 것 같다. 스프링 aop에서 사용되는 용어를 간단히 정리해보자.

  • advice : 실제 객체 앞뒤로 실행될 로직
  • pointCut : 어드바이스가 실행될 조건
    • PointCut이 필요한 이유는 뭘까? 위에 예시를 빌려 실행 시간 측정 부가로직을 모든 메서드에 걸쳐 실행하고 싶지는 않을 것이다. PointCut의 조건을 통해 Advice가 실행될 메서드를 결정할 수 있다.
  • advisor : 어드바이스과 포인트컷을 하나로 합친 것을 advisor라고 부른다.

스프링에서 Advisor 빈을 등록하는 예시는 다음과 같다.

 

Time 어노테이션이 걸린 메서드에 한해 Advice를 적용한다.

 

참고로, AOP에 사용되는 용어는 위에 용어만 있는 것이 아니다.

아래는 공식 문서에서 AOP에 사용되는 용어를 정리한 것이다.

더보기

AOP 용어 정리 (스프링에 국한된 것이 아님)

  • Aspect: 여러 클래스에 걸쳐있는 관심사를 모듈화한 것. 트랜잭션 관리 같은 공통 관심사를 분리하여 Aspect로 구현, Spring AOP에서는 일반 클래스나 @Aspect 애노테이션을 사용하여 Aspect를 정의
  • Join Point: 프로그램 실행 중 특정 지점. Spring AOP에서 Join Point는 항상 메소드 실행임
    • 어떤 메서드에서?
  • Advice: 특정 Join Point에서 Aspect가 취하는 동작. "around", "before", "after" 등의 유형이 있으며, Join Point에 걸쳐 인터셉터 체인으로 구현됨
    • 실제 동작
  • Pointcut: Join Point를 매칭하는 조건 또는 표현식. Advice는 Pointcut 표현식과 연결되어 해당 Join Point에서 실행됨. Spring에서는 기본적으로 AspectJ의 Pointcut 표현식 언어를 사용함
    • 어디에 쓸지 조건 after, before
  • Introduction: 기존 타입에 새로운 메소드나 필드를 선언하는 것. Spring AOP에서는 새로운 인터페이스와 구현을 추가하여 객체를 확장할 수 있음
  • Target Object: 하나 이상의 Aspect에 의해 조언(advice)되는 객체. Spring AOP에서는 런타임 프록시로 구현된 객체임
  • AOP Proxy: Aspect를 구현하기 위해 AOP 프레임워크가 생성한 객체. Spring에서는 JDK 동적 프록시나 CGLIB 프록시가 사용됨
  • Weaving: Aspect를 다른 객체와 연결하여 조언된 객체를 생성하는 과정. 컴파일 시, 로드 시, 또는 런타임에 수행될 수 있으며, Spring AOP에서는 런타임에 이루어짐

 

여러 부가기능을 적용하기

한 메서드에 하나의 부가기능이 아니라 여러 부가기능을 적용하고 싶을 수 있다. 이때는 프록시 위에 프록시를 감싸서 여러 프록시 객체를 만들어야 할까? 위처럼 Advice로 부가기능을 추상화해 둔 덕에 프록시 객체를 하나만 만들어도 여러 부가기능을 적용할 수 있다.

 

정리해 보면 다음과 같다.

 

 

  1. 빈이 생성된다. 이때, 빈으로 등록되어있는 Advisor들의 PointCut을 보고 Advice가 실행될 수 있는지 (프록시가 될 필요성이 있는지) 확인하여 빈을 프록시 대체할지 결정해서 빈을 프록시 빈으로 대체한다. 이때는, 실행될 확률이 있는 Adviosor만 프록시에 같이 전달한다. (스프링 코드로도 볼 수 있는데 다음 글에서 확인해보자.)
  2. 메서드가 실행되면 프록시에 존재하는 Advisor들의 PointCut을 보고 실행되어야할 advice들을 골라 실행한다. 가령 한 클래스에서 A메서드는 시간 측정 부가기능을 필요로 하고 B 메서드는 로깅 부가기능을 필요로 한다면 프록시에는 시간 측정 Advisor, 로깅 Advisor가 등록되고 메서드 실행 시점에 Advisor의 PointCut을 보고 실행 여부를 판단한다.

중요한건 여러 부가로직을 넣고 싶다고 해서 프록시를 여러개 만드는 것이 아니라 하나만 가지고 있고 advisor에 등록된 포인트 컷에 따라 실행될 advice를 필터링하고 advice를 실제로 실행한다는 것이다.

 

다음으로

자 지금까지 스프링에서 프록시를 생성하는 원리에 대하여 알아보았다.

다음 글에서는 스프링 프레임워크에서 언제 어디서 누가 어떻게 일반 빈을 스프링 프록시 빈으로 대체하는지 알아보도록 하자.

728x90