본문 바로가기
Spring/Spring AOP

[Spring] AOP 핵심 원리 정리

by 제우제우 2024. 12. 15.

개요

스프링 시큐리티의 메소드 기반 인가 허용을 공부하는데 스프링 AOP 부분 내용을 많이 까먹어서 복습한다 

스프링 시큐리티의 메소드 기반 인가 요청은 특정 애노테이션을 기반으로 메소드 호출 여부를 판단하는데 이때 사용하는 기술이 스프링 AOP 기술이다.

스프링 AOP는 프록시 패턴을 바탕으로 하는 기술이다.

스프링 AOP를 프록시/데코레이터 패턴리플랙션동적 프록시 기술스프링 제공 프록시 관련 기술(ProxyFactory, Advice, Pointcut, Advisor)   스프링 빈 후처리기   스프링 AOP까지 단계적으로 이해해 보자

참고 자료

인프런 김영한님 - 스프링 핵심 원리 고급편

스프링 공식 문서/AOP

AOP

AOP는 Aspect Orientied Prograimming 즉, 관점 지향 프로그래밍이다

 

여기서 말하는 관점(concern)이란 무엇일까? 

게시글 조회를 예시로 들어보겠다 

 

여기서 게시글을 조회하는 필수 비즈니스 로직을 핵심 관심사(core concern)라고 한다. 

이런 API를 개발하는 개발자에게 추가 요구사항이 들어왔다. 

각 layer에 대한 요청 시작부터 반환까지 얼마나 걸리는지 로그로 확인하고 싶다는 요구사항이다. 

이런 핵심 비즈니스 로직이 아닌 추가적인 필요에 의한 로직을 부가 관심사(cross - cutting - concern)라고 한다. 

 

한번 간단한게 요청 시작부터 반환까지 걸리는 시간을 코드로 표현해 보자 

public String getArticle(){
    long startTime = System.currentTimeMillis();
    String result  = repository.get();
    long endTime   = System.currentTimeMillis();
    log.info("Service resultTime={}", endTime - startTime);
    return result;
}

 

현재 코드의 문제점은 무엇일까?

핵심 비즈니스 로직과 추가적인 관심사인 로깅에 대한 코드가 섞여있다. 

만약 상사가 로깅을 하지 말자고 한다면? 

로깅이 적용된 모든 클래스 메소드에 로깅 부분을 빼야 한다. 

또는 로깅 코드를 수정해야 한다면?

마찬가지로 로깅이 적용된 모든 클래스 메소드에 로깅 부분을 수정해야 한다. 

 

이런 문제점을 해결할 수 있는 방법이 AOP 이다. 

관점 지향적으로 관심사를 핵심 비즈니스 로직과 부가적인 관심사로 분리하여 유지보수성을 높일 수 있다.

proxy - Proxy/Decorator Pattern

Proxy = 대리인

Proxy는 소프트웨어 디자인 패턴의 하나, 특정 객체에 대한 접근을 제어하거나 그 객체의 동작을 추가 또는 변경하기 위해 사용된다. 

프록시는 대상 객체(Target)의 대리 역할을 하며, 클라이언트가 대상 객체에 직접 접근하지 않고 프록시를 통해 접근하도록 중간 다리 역할을 한다. 

 

GOF 디자인 패턴 (Proxy Pattern, Decorator Pattern)

둘다 프록시를 사용하는 방법이지만 GOF 디자인 패턴에서는 이 둘을 의도(intent)에 따라서 프록시 패턴과 데코레이터 패턴으로 구분한다.

프록시 패턴: 접근 제어가 목적

데코레이터 패턴: 새로운 기능 추가가 목적

 

프록시 패턴 

여기서 중요한 점은 Client는 ServerInterface에만 의존하고 있다는 점이다. 

즉, Proxy는 Server와 같은 인터페이스를 사용함으로써 클라이언트가 사용하는 서버 객체를 프록시 객체로 변경해도 클라이언트 코드는 변경되지 않고 동작할 수 있다.

 

Proxy 예제 코드 

public interface Subject {
    String operation();
}

@Slf4j
public class RealSubject implements Subject{
    @Override
    public String operation() {
        log.info("실제 객체 호출");
        sleep(1000);
        return "data";
    }
    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        }
        catch (InterruptedException e){
            log.info("sleep fail={}", e);
        }
    }
}

@Slf4j
public class CacheProxy implements Subject {
    private Subject target;
    private String cacheValue;
    public CacheProxy(Subject target) {
        this.target = target;
    }
    @Override
    public String operation() {
        log.info("프록시 호출");
        if(cacheValue == null){
            cacheValue = target.operation();
        }
        return cacheValue;
    }
}

public class ProxyPatternClient {
    private Subject subject;
    public ProxyPatternClient(Subject subject) {
        this.subject = subject;
    }
    public void execute(){
        subject.operation();
    }
}

public class ProxyPatternTest {
    @Test
    void noProxyTest(){
        ProxyPatternClient client = new ProxyPatternClient(new RealSubject());
        client.execute();
        client.execute();
        client.execute();
    }
    @Test
    void cacheProxyTest(){
        CacheProxy cacheProxy = new CacheProxy(new RealSubject());
        ProxyPatternClient client = new ProxyPatternClient(cacheProxy);
        client.execute();
        client.execute();
        client.execute();
    }
}

 

noProxyTest

캐싱을 위한 프록시를 사용하지않고 바로 실제 객체 RealSubject를 호출한다.

3초가 걸린다. 

 

cacheProxyTest

ProxyPatternClient는 CaheProxy(대리인)호출하고 CaheProxy(대리인)가 RealSubject 호출한다. 

처음 요청에는 캐싱해둔 cacheValue가 null 이어서 RealSubject를 호출해서 값을 가져오는데 1초가 걸리지만

이후 요청에서는 캐싱해둔 값을 바로 반환하기 때문에 빠르게 반환된다. 

 

프록시 패턴의 적용 사례

  • 접근 제어
    • 권한에 따른 접근 차단 (스프링 시큐리티)@PreAuthorize, @PostAuthorize
    • 캐싱
    • 지연 로딩
  • 부가 기능 추가
    • 로그 (실행 시간 체크)
    • 요청 값, 응답 값 중간에 변형 (스프링 시큐리티)@PreFilter, @PostFilter
  • 프록시 체인
    • 2개 이상의 프록시 적용하여 여러가지 기능을 추가 

데코레이터 패턴

 

 

데코레이터 패턴 - 추상 클래스 적용 

 

데코레이터 패턴에서 Decorator는 필드로 Targert(RealComponent / Decorator)를 가진다. 

생성자 주입을 통해서 Target을 설정하는데 그림과 같이 중간에 Decorator 추상클래스를 적용하면 

생성자 주입을 통해 Targer을 저장하는 로직을 추상 클래스에 적용해 공통화할 수 있다. 

또한 Decorator 추상 클래스를 상속받는 클래스는 Decorator 역할을 하는 클래스/ Component 인터페이스를 구현하는 클래스는 RealComponent로 구조 파악이 쉽다. 

 

단순 프록시 패턴의 한계

이런 구조에 어떤 추가 기능을 위해서 프록시 패턴을 적용하려면?

  • OrerControllerV1 인터페이스를 구현하는 OrderControllerV1Proxy
  • OrerServiceV1 인터페이스를 구현하는 OrderServiceV1Proxy
  • OrerRepositoryV1 인터페이스를 구현하는 OrderRepositoryV1Proxy

총 3개의 프록시 클래스가 필요하다.

만약 여러 기능이 필요하다면? 이런 클래스의 개수는 엄청 많아진다.

→ 이를 해결하는 기술이 동적 프록시(Dynamic Proxy)

리플랙션

동적 프록시 기술을 이해하기 위해서는 리플랙션에 대한 이해가 먼저 필요하다. 

이전에 리플랙션에 대해서 정리한 글인데 참고하면 좋다. 

https://20240228.tistory.com/323

 

프록시 패턴을 직접 적용의 문제점

대상 클래스 수 만큼 프록시 클래스를 만들어야 한다는 점이다.

프록시 클래스들의 소스코드는 거의 같은 모양을 하고 있다

자바가 기본으로 제공하는 JDK 동적 프록시 기술이나 CGLIB 같은 프록시 생성 오픈소스 기술을 활용하면 프록시 객체를 동적으로 만들어낼 수 있다.

→ 프록시 클래스를 지금처럼 계속 만들지 않아도 된다는 것

JDK 동적 프록시를 이해하기 위해서는 먼저 자바의 리플렉션 기술을 이해해야 한다.

리플렉션 기술을 사용하면 클래스나 메서드의 메타정보를 런타임에 획득하고, 코드도 동적으로 호출할 수 있다.

 

리플랙션 예제1 - 적용 X

@Slf4j
public class ReflectionTest { 
     @Test
     void reflection0() {
         Hello target = new Hello();
         //공통 로직1 시작
         log.info("start");
         String result1 = target.callA(); //호출하는 메서드가 다름
         log.info("result={}", result1);
         //공통 로직1 종료

         //공통 로직2 시작
         log.info("start");
         String result2 = target.callB(); //호출하는 메서드가 다름
         log.info("result={}", result2);
         //공통 로직2 종료 
     }
     @Slf4j
     static class Hello {
         public String callA() {
             log.info("callA");
             return "A";
         }
         public String callB() {
             log.info("callB");
             return "B";
         }
     }
}

공통 로직1, 공통 로직2는 호출하는 메소드만 다르고 전체 코드 흐름이 완전히 같다. 

리플랙션을 사용해 보자 

 

리플랙션 예제2 - 적용 O

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

    Hello target = new Hello();

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

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

@Test
void reflection3() throws Exception{
    Hello target = new Hello();
    Class classHello = target.getClass();
    // callA 정보
    Method callA = classHello.getMethod("callA");
    dynamicCall(callA, target);

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

@Test
void reflection4() throws Exception{
    Hello target = new Hello();
    List<String> list = List.of("callA", "callB");
    Class helloClass = target.getClass();
    List<Method> collect = Arrays.stream(helloClass.getDeclaredMethods())
            .filter(m -> list.contains(m)).collect(Collectors.toList());

    for (Method m: collect) {
        dynamicCall(m, target);
    }
}

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

 

Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello") - reflection2

클래스 정보를 FQCN(Fully Qualified Class Name)을 통해서 얻는다. 

$는 정적 중첩 클래스를 의미한다. 

클래스가 JVM에 로드되지 않았다면 이 메소드를 호출하는 시점에 클래스를 로드한다. 

 

특정 객체.getClass() - reflection3

마찬가지로 클래스 정보를 얻는다. 

Class.forName()은 특정 객체의 참조 없이 클래스의 FQCN을 통해서 클래스 정보(Class)를 얻는 것이고 

getClass()는 객체의 참조를 통해 클래스 정보를 얻는다.

 

공통화 - dynamicCall(Method method, Object object)

log.info("start");
Object result = method.invoke(target);
log.info("result = {}", result);

공통 로직을 가진다. 

호출하는 메소드를 Method 추상화를 통해서 런타임에 동적으로 공통 로직 중간에 다른 메소드를 호출할 수 있다. 

 

람다식 - Supplier

@Test
public void supplier(){
    Hello hello = new Hello();
    dynamicCall(hello::callA);    
    dynamicCall(hello::callB);    
}
private void dynamicCall(Supplier<String> supplier) {
    log.info("start");
    String result = supplier.get();
    log.info("result={}", result);
}

리플랙션과 관련은 없지만 이렇게 Supplier를 통해서 해결할 수도 있다. 

JDK 동적 프록시

등장 배경

지금까지 프록시를 적용하기 위해서는 적용 대상의 숫자만큼 많은 프록시 클래스를 만들어야 한다.

적용 대상이 100개면 프록시 클래스도 100개 만들었다.

그런데 앞서 살펴본 것과 같이 프록시 클래스의 기본 코드와 흐름은 거의 같고, 프록시를 어떤 대상에 적용하는가 정도만 차이가 있었다. 쉽게 이야기해서 프록시의 로직은 같은데, 적용 대상만 차이가 있는 것이다.

 

동적 프록시 기술을 사용하면 개발자가 직접 프록시 클래스를 만들지 않아도 된다.

이름 그대로 프록시 객체를 동적으로 런타임에 개발자 대신 만들어준다.

그리고 동적 프록시에 원하는 실행 로직을 지정할 수 있다.

 

자바 언어가 기본으로 제공하는 JDK 동적 프록시를 알아보자.

 

주의!!

JDK 동적 프록시는 인터페이스를 기반으로 프록시를 동적으로 만들어준다. 따라서 인터페이스가 필수이다

 

JDK 동적 프록시 제공 InvocationHandler 인터페이스 

public interface InvocationHandler {
	public Object invoke(Object proxy, Method method, Object[] args) throws Throwable;
}

 

invoke 메소드 파라미터

Object proxy: 프록시 자신 

Method method: 호출한 메소드 

Object[] args: 메소드를 호출할 때 전달한 인수 

 

TimeInvocationHandler

@Slf4j
public class TimeInvocationHandler implements InvocationHandler {
    private final Object target; // 동적 프록시가 호출할 대상
    public TimeInvocationHandler(Object target) {
        this.target = target;
    }
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        log.info("TimeProxy 실행");
        long startTime = System.currentTimeMillis();
        Object result = method.invoke(target, args); 
        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("TimeProxy 종료 resultTime={}ms", resultTime);
        return result;
    }
}

 

Test

@Slf4j
public class JdkDynamicProxyTest {
    @Test
    void dynamicA(){
        AInterface target = new AImpl();
        TimeInvocationHandler handler = new TimeInvocationHandler(target);
        AInterface proxy = (AInterface)Proxy.newProxyInstance(
                AInterface.class.getClassLoader(), new Class[]{AInterface.class}, handler);

        String result = proxy.call();
        log.info("result={}", result);
        log.info("targetClass={}", target.getClass());
        log.info("proxyClass={}", proxy.getClass());
    }
    @Test
    void dynamicB(){
        BInterface target = new BImpl();
        TimeInvocationHandler handler = new TimeInvocationHandler(target);
        BInterface proxy = (BInterface)Proxy.newProxyInstance(
                BInterface.class.getClassLoader(), new Class[]{BInterface.class}, handler);

        String result = proxy.call();
        log.info("result={}", result);
        log.info("targetClass={}", target.getClass());
        log.info("proxyClass={}", proxy.getClass());
    }
}

 

Proxy.newProxyInstance(): static method

public static Object newProxyInstance(
	ClassLoader loader, Class<?>[] interfaces, InvocationHandler h) {
        // 생략 ...  
}

파라미터

Target 클래스 로더 , Target이 구현하는 인터페이스 정보, InvoationHandler

 

호출 흐름 

JDK Dynamic Proxy call() → (Time) Invocation Handler → Target call()

CGLIB

CGLIB: Code Generator Library

CGLIB는 바이트코드를 조작해서 동적으로 클래스를 생성하는 기술을 제공하는 라이브러리이다

CGLIB를 사용하면 인터페이스가 없어도 구체 클래스만 가지고 동적 프록시를 만들어낼 수 있다

CGLIB는 원래는 외부 라이브러리인데, 스프링 프레임워크가 스프링 내부 소스 코드에 포함했다. 따라서 스프링
을 사용한다면 별도의 외부 라이브러리를 추가하지 않아도 사용할 수 있다

 

JDK 동적 프록시에서 실행 로직을 위해 InvocationHandler 를 제공했듯이, CGLIB는 MethodInterceptor
를 제공한다.

 

MethodInterceptor -CGLIB 제공

public interface MethodInterceptor extends Callback {
    Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable;
}

 

intercept 파라미터 

obj : CGLIB가 적용된 객체
method : 호출된 메서드
args : 메서드를 호출하면서 전달된 인수
proxy : 메서드 호출에 사용

 

TimeMethodInterceptor

@Slf4j
public class TimeMethodInterceptor implements MethodInterceptor {
    private final Object target;
    public TimeMethodInterceptor(Object target) {
        this.target = target;
    }
    @Override
    public Object intercept(Object o, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
        log.info("TimeProxy 실행");
        long startTime = System.currentTimeMillis();

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

        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;

        log.info("TimeProxy 종료 resultTime={}ms", resultTime);
        return result;
    }
}

 

JDK 동적 프록시에서 사용했던 TimeInvocationHandler와 똑같은 처리를 해준다. 

method.invoke(target, args)를 사용하지 않고 methodProxy.invoke(target, args)를 사용하는데

해당 방식이 더 빠르다고 한다.

이미 조작된 바이트 코드를 통해 메소드 호출은 리플랙션을 사용하여 런타임에 인스턴스 조회에 대한 과정이 필요없기 때문이다. 

그래서 CGLIB는 methodProxy.invoke(target, args)을 권장한다. 

 

CGLIB 프록시 생성 - Enhancer

@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();
        log.info("targetClass={}", target.getClass());
        log.info("proxyClass={}", proxy.getClass());
        proxy.call();
    }
}

/* 출력 
targetClass=class hello.proxy.common.service.ConcreteService
proxyClass=class hello.proxy.common.service.ConcreteService$$EnhancerByCGLIB$$25d6b0e3
*/

 

enhancer.setSuperclass(ConcreteService.class);

프록시를 생성할 때 상속 받을 슈퍼(부모)클래스를 설정

 

 enhancer.setCallback(new TimeMethodInterceptor(target));

프록시가 호출할 MethodInterceptor(CallBack) 설정 

 

CGLIB 제약

클래스 기반 프록시는 상속을 사용하기 때문에 상속에서의 제약이 그대로 적용된다 

1. 클래스, 메소드 레벨에서의 final 사용을 사용할 수 없다. (final 설정 → 예외 발생)

2. 부모 클래스의 생성자를 체크해야 한다. (자식 클래스는 부모 클래스의 생성자를 호출)

중간 정리 

동적 프록시 기술 CGLIB / JDK Dynamic Proxy 2가지를 통해서 프록시 패턴을 직접 개발자가 클래스를 전부 생성하는 방식으로 적용하지 않을 수 있게 되었다. 

인터페이스가 있는 경우에는 JDK 동적 프록시 인터페이스가 없는 경우에는 CGLIB를 사용해야 하는데 두 기술을 함께 사용할 때 InvocationHandler / MethodInterceptor를 각각 중복으로 만들어서 관리해야 할까?

특정 조건에 맞을 때 프록시 로직을 적용하는 기능도 공통으로 제공되는 기술이 있다면?

 

스프링은 유사한 구체적인 기술들이 있을 떄, 그것들을 통합해서 일관성 있게 접근할 수 있고 더욱 편리하게 사용할 수 있는 추상화된 기술을 제공한다 ex) JDBC

 

스프링은 동적 프록시를 통합해서 편리하게 만들어주는 프록시 팩토리( ProxyFactory )라는 기능을 제공한다.
이전에는 상황에 따라서 JDK 동적 프록시를 사용하거나 CGLIB를 사용해야 했다면, 이제는 이 프록시 팩토리 하나로
편리하게 동적 프록시를 생성할 수 있다.
프록시 팩토리는 인터페이스가 있으면 JDK 동적 프록시를 사용하고, 구체 클래스만 있다면 CGLIB를 사용한다

스프링이 제공하는 동적 프록시

프록시 팩토리 

Advice 

프록시 생성은 ProxyFactory를 통해서 간편하게 할수 있구나를 알았다.

그렇다면 JDK 동적 프록시가 호출하는 InvocationHandler와 CGLIB 기반 프록시가 호출하는 MethodInterceptor를 각각 중복으로 따로 만들어야 하는 문제는 어떻게 할까?

스프링은 이 문제를 해결하기 위해 부가 기능을 적용할 때 Advice라는 개념을 도입했다.

개발자는 InvocationHandler나 MethodInterceptor 를 신경 쓰지 않고, Advice만 만들면 된다

프록시 팩토리를 사용하면 Advice를 호출하는 전용 InvocationHandler , MethodInterceptor를 내부에서 사용한다.

 

전체 흐름 

Pointcut 

특정 조건에 맞을 때 프록시 로직을 적용하는 기능 또한 스프링은 제공한다.

스프링은 Pointcut 이라는 개념을 도입해서 이 문제를 일관성 있게 해결한다.

 

MethodInterceptor

package org.aopalliance.intercept;
@FunctionalInterface
public interface MethodInterceptor extends Interceptor {
    @Nullable
    Object invoke(@Nonnull MethodInvocation invocation) throws Throwable;
}

 

Advice를 만드는 방법중에 가장 기본적인 방법 MethodInterceptor 인터페이스 구현하기 

 

MethodInvocation invocation

내부에서 다름 메소드를 호출하는 방법, 현재 프록시 객체 인스턴스, args, 메소드 정보 등이 포함

 

CGLIB의 MethodInterceptor

이름이 같다 → 패키지 이름에 주의하자

 

TimeAdvice

@Slf4j
public class TimeAdvice implements MethodInterceptor {
    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        log.info("TimeProxy 실행");
        long startTime = System.currentTimeMillis();

        Object result = invocation.proceed(); // target 호출

        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;

        log.info("TimeProxy 종료 resultTime={}ms", resultTime);
        return result;
    }
}

Object result = invocation.proceed()

프록시 팩토리로 프록시를 생성하는 단계에서 이미 target 정보를 파라미터로 전달받기 때문에 target 클래스에 대한 정보를 필드로 갖지 않는다. invocation에 이미 정보가 있다. 

 

ProxyFactoryTest

@Test
@DisplayName("인터페이스가 있으면 JDK 동적 프록시를 사용")
void interfaceProxy(){
    ServiceInterface target = new ServiceImpl();
    ProxyFactory proxyFactory = new ProxyFactory(target); 
    proxyFactory.addAdvice(new TimeAdvice());
    ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
    log.info("targetClass={}", target.getClass());
    log.info("proxyClass={}", proxy.getClass());
    proxy.save();
    proxy.find();

    // 프록시 객체인지?
    boolean isProxy = AopUtils.isAopProxy(proxy);
    assertThat(isProxy).isTrue();

    // JDK 동적 프록시 인지?
    boolean isJdkDynamicProxy = AopUtils.isJdkDynamicProxy(proxy);
    assertThat(isJdkDynamicProxy).isTrue();

    // CGLIB 프록시 인지?
    boolean isCglibProxy = AopUtils.isCglibProxy(proxy);
    assertThat(isCglibProxy).isFalse();
}

@Test
@DisplayName("구체 클래스만 있으면 CGLIB 프록시 사용")
void concreteProxy(){
    ConcreteService target = new ConcreteService();
    ProxyFactory proxyFactory = new ProxyFactory(target); 
    proxyFactory.addAdvice(new TimeAdvice());            
    ConcreteService proxy = (ConcreteService)proxyFactory.getProxy();

    log.info("targetClass={}", target.getClass());
    log.info("proxyClass={}", proxy.getClass());

    proxy.call();

    // 프록시 객체인지?
    boolean isProxy = AopUtils.isAopProxy(proxy);
    assertThat(isProxy).isTrue();

    // JDK 동적 프록시 인지?
    boolean isJdkDynamicProxy = AopUtils.isJdkDynamicProxy(proxy);
    assertThat(isJdkDynamicProxy).isFalse();

    // CGLIB 프록시 인지?
    boolean isCglibProxy = AopUtils.isCglibProxy(proxy);
    assertThat(isCglibProxy).isTrue();

    // PatternMatchUtils 연습하기
    assertThat(PatternMatchUtils.simpleMatch(new String[]{"*CGLIB*"}, proxy.getClass().toString()))
            .isTrue();
}

 

결과 

targetClass=class hello.proxy.common.service.ServiceImpl
proxyClass=class jdk.proxy3.$Proxy13

targetClass=class hello.proxy.common.service.ConcreteService
proxyClass=class hello.proxy.common.service.ConcreteService$$EnhancerBySpringCGLIB$$cef7dbc2

 

new ProxyFactory(target)

프록시 팩토리를 생성할 때, 생성자에 프록시의 호출 대상을 함께 넘겨준다

프록시 팩토리는 이 인스턴스 정보를 기반으로 프록시를 만들어낸다. 

만약 이 인스턴스에 인터페이스가 있다면 JDK 동적 프록시를 기본으로 사용하고 인터페이스가 없고 구체 클래스만 있다면 CGLIB를 통해서 동적 프록시를 생성한다.

 

인터페이스가 있지만 CGLIB를 통해서 동적 프록시를 생성하고 싶다면?

 proxyFactory.setProxyTargetClass(true);

최근 스프링 부트는 AOP를 적용할 때 기본적으로 해당 설정을 true로 설정해서 사용한다. 

따라서 인터페이스가 있어도 항상 CGLIB를 사용해서 구체 클래스를 기반으로 프록시를 생성한다.

 

proxyFactory.addAdvice(new TimeAdvice())

프록시 팩토리를 통해서 만든 프록시가 사용할 부가 기능 로직을 설정한다.

 

AopUtils static methods

  • AopUtils.isAopProxy(): proxy 객체인지 확인
  • AopUtils.isCglibProxy: CGLIB 기반 proxy 객체인지 확인
  • AopUtils.isJdkDynamicProxy: JDK 동적 프록시 기반 proxy 객체인지 확인 

포인트컷, 어드바이스, 어드바이저

포인트컷(Pointcut)

어디에 부가 기능을 적용할지, 어디에 부가 기능을 적용하지 않을지 판단하는 필터링 로직이다.

주로 클래스와 메서드 이름으로 필터링 한다.

 

어드바이스(Advice)

프록시가 호출하는 부가 기능이다

 

어드바이저(Advisor)

하나의 포인트컷과 하나의 어드바이스를 가지고 있는 것

 

정리

조언자(Advisor)는 어디(Pointcut)에 어떤 조언(Advice)을 해야할지 알고 있다

 

예제 코드1 - 어드바이저

어드바이저는 하나의 포인트컷과 하나의 어드바이스를 가지고 있다

프록시 팩토리를 통해 프록시를 생성할 때 어드바이저를 제공하면 어디에 어떤 기능을 제공할 지 알 수 있다

ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(Pointcut.TRUE, new TimeAdvice());
proxyFactory.addAdvisor(advisor);
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();

 

DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(Pointcut.TRUE, new TimeAdvice());

Advisor 인터페이스의 구현체 생성자로 하나의 포인트컷, 하나의 어드바이스를 넣어주면 된다. 

 

Pointcut.TRUE

항상 true 를 반환하는 포인트컷

예제 코드2 - 직접 만든 포인트컷

포인트컷을 활용해서 특정 메소드는 어드바이스 로직을 적용하지 않도록 해보자

public interface Pointcut {
    ClassFilter getClassFilter();
    MethodMatcher getMethodMatcher();
    Pointcut TRUE = TruePointcut.INSTANCE;
}

ClassFilter: 클래스가 맞는지 확인 

MethodMatcher: 메소드가 맞는지 확인 

→ 둘다 true로 반환해야 어드바이스를 적용할 수 있다 

class AdvisorStudy {
    @Test
    void advisorTest(){
        ServiceInterface target = new ServiceImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);
        proxyFactory.addAdvisor(new DefaultPointcutAdvisor(new MyPointCut(), new TimeAdvice()));
        ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
        proxy.save();
        proxy.find();
    }
    static class MyPointCut implements Pointcut{
        @Override
        public ClassFilter getClassFilter() {
            return ClassFilter.TRUE;
        }
        @Override
        public MethodMatcher getMethodMatcher() {
            return new MyMethodMatcher();
        }
        static class MyMethodMatcher implements MethodMatcher{
            @Override
            public boolean matches(Method method, Class<?> targetClass) {
                return method.getName().equals("save");
            }
            @Override
            public boolean isRuntime() {
                return false;
            }
            @Override
            public boolean matches(Method method, Class<?> targetClass, Object... args) {
                throw new UnsupportedOperationException();
            }
        }
    }
}

 

Pointcut 인터페이스를 구현한 MyPointcut에서는 MethodMatcher 인터페이스를 구현한 MyMethodMatcher를 사용한다. 

MyMethodMatcher에서는 간단한게 메소드명이 save면 true를 반환한다. 

 

MethodMatcher -  isRuntime() & matches()

isRuntime() 반환에 따라 사용하는 오버로딩된 mathces 메소드가 다르다.

true면 매개변수에 args가 들어있는 matches 메소드가 사용된다. 

fasle인 경우 클래스의 정적 정보만 사용하기 때문에 스프링이 내부에서 캐싱을 통해 성능 향상이 가능하지만, true인 경우매개변수가 동적으로 변경된다고 가정하기 때문에 캐싱을 하지 않는다. 

 

실행 결과 (result)

INFO hello.proxy.common.advice.TimeAdvice - TimeProxy 실행
INFO hello.proxy.common.service.ServiceImpl - save 호출
INFO hello.proxy.common.advice.TimeAdvice - TimeProxy 종료 resultTime=0ms
INFO hello.proxy.common.service.ServiceImpl - find 호출

save 메소드는 MyPointcut 내부의 MyMethodMatcher가 true를 반환하기 때문에 Advice인 TimeAdvice 로직이 동작하지만 반대로 find 메소드는 동작하지 않는다. 

 

스프링이 제공하는 포인트컷 구현체 

스프링은 무수히 많은 포인트컷을 제공

  • NameMatchMethodPointcut: 메서드 이름을 기반으로 매칭한다. 내부에서는 PatternMatchUtils를 사용한다.
  • JdkRegexpMethodPointcut : JDK 정규 표현식을 기반으로 포인트컷을 매칭한다.
  • TruePointcut : 항상 참을 반환한다.
  • AnnotationMatchingPointcut : 애노테이션으로 매칭한다.
  • AspectJExpressionPointcut : aspectJ 표현식으로 매칭한다. 실무에서 가장 많이 사용

여러 어드바이저 함께 적용

어드바이저는 하나의 포인트컷과 하나의 어드바이스를 가지고 있다.
만약 여러 어드바이저를 하나의 target에 적용하려면 어떻게 해야할까?

 

프록시를 n개 만들어서 적용하는 방법이 있다. 

@Slf4j
public class MultiAdvisorStudy {
    @Test
    void multiProxy(){
        ServiceInterface target = new ServiceImpl();
        ProxyFactory proxyFactory1 = new ProxyFactory(target);
        proxyFactory1.addAdvisor(new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice1()));
        Object proxy1 = proxyFactory1.getProxy();

        ProxyFactory proxyFactory2 = new ProxyFactory(proxy1);
        proxyFactory2.addAdvisor(new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2()));
        ServiceInterface proxy2 = (ServiceInterface) proxyFactory2.getProxy();

        proxy2.find();
    }
    static class Advice1 implements MethodInterceptor{
        @Override
        public Object invoke(MethodInvocation invocation) throws Throwable {
            log.info("advice1");
            return invocation.proceed();
        }
    }
    static class Advice2 implements MethodInterceptor{
        @Override
        public Object invoke(MethodInvocation invocation) throws Throwable {
            log.info("advice2");
            return invocation.proceed();
        }
    }
}

 

실행 결과 

15:11:57.557 [Test worker] INFO hello.proxy.advisor.MultiAdvisorStudy - advice2
15:11:57.568 [Test worker] INFO hello.proxy.advisor.MultiAdvisorStudy - advice1
15:11:57.568 [Test worker] INFO hello.proxy.common.service.ServiceImpl - find 호출

 

여러 프록시의 문제점 

이 방법이 잘못된 것은 아니지만, 프록시를 2번 생성해야 한다는 문제가 있다. 

만약 적용해야 하는 어드바이저가 n개라면 n개의 프록시를 생성해야한다.

 

하나의 프록시, 여러 어드바이저

스프링은 이 문제를 해결하기 위해 하나의 프록시에 여러 어드바이저를 적용할 수 있게 만들어두었다

 

client → proxy advisor2 advisor1 target

 

@Test
void singleProxy(){
    ServiceInterface target = new ServiceImpl();
    ProxyFactory proxyFactory = new ProxyFactory(target);
    proxyFactory.addAdvisor(new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2()));
    proxyFactory.addAdvisor(new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice1()));
    ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
    proxy.find();
}

 

실행 결과 

15:18:57.893 [Test worker] INFO hello.proxy.advisor.MultiAdvisorStudy - advice2
15:18:57.899 [Test worker] INFO hello.proxy.advisor.MultiAdvisorStudy - advice1
15:18:57.899 [Test worker] INFO hello.proxy.common.service.ServiceImpl - find 호출

등록하는 순서대로 advisor가 호출된다. 여기서는 advisor2 , advisor1 순서로 등록했다.

결과적으로 여러 프록시를 사용할 때와 비교해서 결과는 같고, 성능은 더 좋다

중간 정리

프록시 팩토리 덕분에 개발자는 매우 편리하게 프록시를 생성할 수 있게 되었다.

추가로 어드바이저, 어드바이스, 포인트컷 이라는 개념 덕분에 어떤 부가 기능을 어디에 적용할 지 명확하게 이해할 수
있었다.

 

하지만 현재 방식은 컴포넌트 스캔으로 등록되는 빈들에는 적용하지 못한다. 

실제 객체를 컴포넌트 스캔으로 스프링 컨테이너에 스프링 빈으로 등록을 다 해버린 상태이기 때문이다.

또한 프록시 팩토리를 통해서 직접 프록시 클래스 생성이나 MethodIterceptor(CGLIB), MethodInvocation(Jdk dynamic Proxy)같은 클래스를 생성할 필요는 없지만 @Configuration 설정 클래스에서 프록시 관련 설정이 너무 많아진다.

 

이런 문제점을 한번에 해결하는 방법이 빈 후처리기이다.  

빈 후처리기

일반적인 스프링 빈 등록 

@Bean이나 컴포넌트 스캔으로 스프링 빈을 등록하면, 스프링은 대상 객체를 생성하고 스프링 컨테이너 내부의 빈 저
장소에 등록한다

 

빈 후처리기 - BeanPostProcessor

스프링이 빈 저장소에 등록할 목적으로 생성한 객체를 빈 저장소에 등록하기 직전에 조작하고 싶다면 빈 후처리기를 사
용하면 된다.

객체를 조작할 수도 있고, 완전히 다른 객체로 바꿔치기 하는 것도 가능하다.

 

빈 후처리기 과정

 

빈 후처리기에서 B객체를 만들어서 스프링 컨테이너에 B객체를 저장할 수 있다.

빈 이름은 beanA로 동일하다 

BeanPostProcessor - 스프링 제공 인터페이스 

public interface BeanPostProcessor {
    @Nullable
    default Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        return bean;
    }
    @Nullable
    default Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        return bean;
    }
}

빈 후처리기를 사용하려면 BeanPostProcessor 인터페이스를 구현하고, 스프링 빈으로 등록하면 된다.

 

postProcessBeforeInitialization 

객체 생성 이후에 @PostConstruct 같은 초기화가 발생하기 전에 호출되는 포스트 프로세서이다.

 

postProcessAfterInitialization

객체 생성 이후에 @PostConstruct 같은 초기화가 발생한 다음에 호출되는 포스트 프로세서이다.

 

BeanPostProcessor 예제 코드 

public class BeanPostProcessorStudy {
    @Test
    void test(){
        ApplicationContext ac = new AnnotationConfigApplicationContext(Config.class);
        B b = ac.getBean("beanA", B.class);

        assertThatThrownBy(() -> ac.getBean("beanA", A.class))
                .isInstanceOf(BeanNotOfRequiredTypeException.class);
    }
    @Configuration
    static class Config{
        @Bean
        public A beanA(){
            return new A();
        }
        @Bean
        public BeanPostProcessor myPostProcessor(){
            return new MyPostProcessor();
        }
    }
    @Slf4j
    static class MyPostProcessor implements BeanPostProcessor{
        @Override
        public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
            log.info("beanName={}, bean={}", beanName, bean.getClass());
            if(bean instanceof A) return new B();
            return bean;
        }
    }
    @Slf4j
    static class A {
        public void helloA(){
            log.info("hello A");
        }
        @PostConstruct
        public void init(){
            log.info("post construct A");
        }
    }
    @Slf4j
    static class B {
        public void helloB() {
            log.info("hello B");
        }
        @PostConstruct
        public void init(){
            log.info("post construct B");
        }
    }
}

 

Config 클래스에서 beanA라는 이름으로 A type 빈을 등록한다. 

또한 BeanPostProcessor 인터페이스를 구현한 MyPostProcessor(빈 후처리기) 또한 빈으로 등록한다. 

MyPostProcessor에서는 @PostConstruct 이후에 실행되는 default 메소드인 postProcessAfterInitialization를 오버라이딩 하였다. 

오버라이딩 내용: 매개변수로 넘어온 빈이 A 면 B로 바꾼다. 

 

ApplicationContext 생성자로 Config 클래스를 인자로 넘겨준다.

ApplicationContext로 beanA 빈 이름과 B 클래스 타입으로 조회를 하면 정상적으로 조회가 된다.

MyPostProcessor에서 객체를 생성해 바꾼 것이다.

→ 빈 후처리기를 사용하면 어떤 방식으로 빈을 등록하든 원본 객체를 프록시 객체로 손쉽게 바꿀 수 있다. 

 

 @PostConstruct의 비밀

@PostConstruct는 스프링 빈 생성 이후에 빈을 초기화 하는 역할을 한다.

빈의 초기화 라는 것이 단순히 @PostConstruct 애노테이션이 붙은 초기화 메서드를 한번 호출만 하면 된다. 

생성된 빈을 한번 조작하는 것이다.
스프링은 CommonAnnotationBeanPostProcessor 라는 빈 후처리기를 자동으로 등록하는데, 여기에서
@PostConstruct 애노테이션이 붙은 메서드를 호출한다. 

따라서 스프링 스스로도 스프링 내부의 기능을 확장하기 위해 빈 후처리기를 사용한다.

 

스프링은 프록시를 생성하기 위한 빈 후처리기를 이미 만들어서 제공한다 

스프링이 제공하는 빈 후처리기

build.gradle - 추가

implementation 'org.springframework.boot:spring-boot-starter-aop'

이 라이브러리를 추가하면 aspectjweaver라는 aspectJ 관련 라이브러리를 등록하고, 스프링 부트가 AOP 관련
클래스를 자동으로 스프링 빈에 등록한다. - AopAutoConfiguration(스프링 부트) 

 

자동 프록시 생성기 - AutoProxyCreator

스프링 부트 자동 설정으로 AnnotationAwareAspectJAutoProxyCreator라는 빈 후처리기가 스프링 빈에 자동으로 등록된다.

이 빈 후처리기는 스프링 빈으로 등록된 Advisor들을 자동으로 찾아서 프록시가 필요한 곳에 자동으로 프록시를 적용해준다.

Advisor안에는 Pointcut 과 Advice가 이미 모두 포함되어 있다.

따라서 Advisor만 알고 있으면 그 안에 있는 Pointcut 으로 어떤 스프링 빈에 프록시를 적용해야 할지 알 수 있다. 

그리고 Advice로 부가 기능을 적용하면 된다.

AnnotationAwareAspectJAutoProxyCreator@AspectJ와 관련된 AOP 기능도 자동으로 찾아서 처리해준다.

 

자동 프록시 생성기 동작 

1. 생성: 스프링이 스프링 빈 대상이 되는 객체를 생성한다. (@Bean , 컴포넌트 스캔)

2. 전달: 생성된 객체를 빈 저장소에 등록하기 직전에 빈 후처리기에 전달한다.

3. 모든 Advisor 빈 조회: 자동 프록시 생성기 - 빈 후처리기는 스프링 컨테이너에서 모든 Advisor를 조회한다.

4. 프록시 적용 대상 체크: Advisor안에 포함되어 있는 포인트컷을 활용하여 해당 객체가 프록시를 적용할 대상인지 아닌지 판단한다. 

5. 프록시 생성: 4번 과정에서의 결과로 프록시 적용 대상이면 프록시를 생성하고 반환해서 프록시를 스프링 빈으로 등록한다. 프록시 적용 대상이 아니면 원본 객체를 반환해서 스프링 빈으로 등록한다. 

 

★ 포인트컷은 2가지에 사용된다.

 

1. 프록시 적용 여부 판단 - 빈 후처리기 

자동 프록시 생성기는 포인트컷을 사용해서 해당 빈이 프록시를 생성할 필요가 있는지 없는지 체크한다. 

클래스 + 메소드 조건을 모두 비교한다. 포인트컷 조건에 하나라도 해당하면 프록시를 생성한다. 

 

2. 어드바이스 적용 여부 판단 - 사용 단계

프록시가 호출되었을 때 Advice를 적용할지 말지 포인트컷을 보고 판단한다. 

 

AspectExpressionPointcut

AspectJ라는 AOP에 특화된 포인트컷 표현식을 적용할 수 있다. 

Pointcut 인터페이스를 구현하는 AspectExpressionPointcut을 사용한다.

@Aspect AOP

스프링 애플리케이션에 프록시를 적용하려면 포인트컷 + 어드바이스로 구성된 어드바이저를 만들어서 스프링 빈으로 등록하면 끝이다.

스프링은 @Aspect 애노테이션으로 편리하게 어드바이저 생성 기능을 지원한다. 

@Slf4j
@Aspect
@Component
public class AspectExample {
    @Around("execution(* hello.proxy.myaspect.AspectTest.A.yes_log(..)))")
    public Object execute(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        Object result = proceedingJoinPoint.proceed();
        log.info("time={}", System.currentTimeMillis() - start);
        return result;
    }
}

 

@Aspect

애노테이션 기반 프록시를 적용할 때 필요하다.

 

@Around("execution(* hello.proxy.myaspect.AspectTest.A.yes_log(..)))")

@Around의 값에 포인트컷 표현식을 넣는다. 표현식은 AspectJ 표현식을 사용한다.

@Around의 메소드는 어드바이스(Adbice)가 된다. 

 

ProceedingJoinPoint joinPoint 

내부에 실제 호출 대상, 전달 인자, 그리고 어떤 객체와 어떤 메서드가 호출되었는지 정보가 포함되어 있다.

 

joinPoint.proceed() : 실제 호출 대상(target)을 호출한다.

 

@Aspect - 스프링 빈 

@Aspect가 있어도 스프링 빈으로 등록을 해줘야 한다. 

@Component 애노테이션을 붙여서 컴포넌트 스캔을 사용하여 스프링 빈으로 등록해도 된다. 

스프링이 제공하는 빈 후처리기 - AnnotationAwareAspectJAutoProxyCreator

자동 프록시 생성기는 Advisor를 찾아와서 필요한 곳에 프록시를 생성하고 적용해준다고 했다. 

자동 프록시 생성기는 추가로 하나의 역할을 더 하는데, @Aspect를 찾아서 이것을 Advisor로 만들어준다. 

즉, @Aspect를 Advisort로 변환해서 저장하는 기능도 한다. 

그래서 이름에 AnotationAware가 붙어 있는 것이다. 

@Aspect를 어드바이저로 변환해서 저장하는 과정 

1. 실행

스프링 애플리케이션 로딩 시점에 자동 프록시 생성기를 호출한다.

 

2. 모든 @Aspect 빈 조회 

자동 프록시 생성기는 스프링 컨테이너에서 @Aspect 애노테이션이 붙은 스프링 빈을 모두 조회한다.

 

3. 어드바이저 생성 

@Aspect 어드바이저 빌더를 통해 @Aspect 애노테이션 정보를 기반으로 어드바이저를 생성한다. 

 

4. @Aspect 기반 어드바이저 저장 

생성한 어드바이저를 @Aspect 어드바이저 빌더 내부에 저장한다.

 

@Aspect 어드바이저 빌더 ?

BeanFactoryAspectJAdvisorsBuilder 클래스이다. 

@Aspect 의 정보를 기반으로 포인트컷, 어드바이스, 어드바이저를 생성하고 보관하는 것을 담당한다.

@Aspect 의 정보를 기반으로 어드바이저를 만들고, @Aspect 어드바이저 빌더 내부 저장소에 캐시한다. 

캐시에 어드바이저가 이미 만들어져 있는 경우 캐시에 저장된 어드바이저를 반환한다.

어드바이저를 기반으로 프록시 생성

1. 생성

스프링 빈 대상이 되는 객체를 생성한다. (@Bean , 컴포넌트 스캔)


2 전달

생성된 객체를 빈 저장소에 등록하기 직전에 빈 후처리기에 전달한다.

 

3-1. Advisor 빈 조회

스프링 컨테이너에서 Advisor 빈을 모두 조회한다.
3-2. @Aspect Advisor 조회

@Aspect 어드바이저 빌더 내부에 저장된 Advisor를 모두 조회한다.

 

4. 프록시 적용 대상 체크

앞서 3-1, 3-2에서 조회한 Advisor에 포함되어 있는 포인트컷을 사용해서 해당 객체가 프록시를 적용할 대상인지 아닌지 판단한다.

 

5. 프록시 생성

프록시 적용 대상이면 프록시를 생성하고 프록시를 반환한다. 그래서 프록시를 스프링 빈으로 등록한다. 

만약 프록시 적용 대상이 아니라면 원본 객체를 반환해서 원본 객체를 스프링 빈으로 등록한다.

 

6. 빈 등록: 반환된 객체는 스프링 빈으로 등록된다.

 

 

'Spring > Spring AOP' 카테고리의 다른 글

[Spring] AOP 전체 정리  (1) 2024.12.16