Spring Core Technolohies 2. AOP (2) @AspectJ를 이용해 Aspect 구현하기

#Spring#AOP

2023-08-08 11:44

대표 이미지

이번 글에서는 AOP의 실질적인 구현 방법을 중점적으로 살펴보며, AspectJ의 특징 및 Advice 파라미터를 다루는 방식과 Args, @Annotation, 그리고 argNames 등을 활용한 인자 바인딩 방법을 심층적으로 다룹니다. 또한, 여러 advice들이 동일한 join point에서 실행될 때 우선 순위와 실행 순서에 대한 중요 내용을 함께 해설합니다.

개요


AOP는 코드의 재사용성과 모듈화를 향상시키는 획기적인 방법론이다. 지난 글에서 AOP의 기본 개념에 대해 소개했다면, 이번 글에서는 이러한 개념을 실제로 어떻게 구현할 수 있는지에 대해 자세히 알아본다. AspectJ의 도입을 통해 어떻게 다양한 포인트컷과 조인 포인트를 설정하는지, 그리고 이들 사이에서 파라미터 바인딩이 어떻게 이루어지는지를 중점적으로 살펴보며, 실제 코드 예시를 통해 실제 코드 작성에 도움이 되길 바란다.

핵심 개념


AspectJ

  • AOP를 구현하는데 가장 널리 사용되는 Java 프레임워크.
  • AspectJ는 특정 조인 포인트에서 실행되는 코드 조각인 어드바이스와 이를 결합하는 포인트컷을 정의함으로써, AOP의 기본 구조를 구현할 수 있게 도와준다.

Pointcut & Advice

  • 포인트컷은 어떤 메서드에서 어드바이스가 실행될 것인지 결정하는 표현식이다.
  • 어드바이스는 포인트컷에서 지정한 메서드에 추가되어 실행되는 코드를 의미한다.

Join Point

  • AOP에서 Aspect가 적용되는 시점 혹은 위치를 의미한다.
  • 예를 들어, 메서드 호출이나 객체 생성 등의 시점에서 Aspect의 로직이 삽입될 수 있다.

Spring AOP와 Aspect J


Spring AOP와 AspectJ는 AOP를 구현하는 기술 중 하나이다.

하지만 두 기술은 목적과 사용 방법에서 차이가 있다.

우선, AspectJ는 Java 언어 자체에 AOP 기능을 추가하여 사용할 수 있는 기술이다. Java 컴파일러를 이용해 AOP 코드를 기존 코드에 직접 삽입하여 바이트 코드를 생성하는데, 이에 따라 AOP 코드가 컴파일 시점에 이미 적용되어 실행 속도가 빠르다는 장점이 있다. (이전에 우리가 어노테이션 방식으로 쭉 적용해온 것인 AspectJ이다.)

반면에 Spring AOP API는 자체적인 AOP 구현체를 가지고 있으며 프록시 기반의 AOP를 지원한다. Spring AOP는 대상 객체를 래핑하고, 호출되는 메서드에 대해 AOP 기능을 적용한다. 이 방식은 런타임에서 동적으로 프록시 객체를 생성하므로 AspectJ에 비해 실행 속도가 느릴 수 있지만 스프링 프레임워크와의 통합이 용이하고 프로그래밍이 간편하다.

따라서, Spring AOP는 AspectJ에 비해 프로그래밍이 간편하고, Spring Framework와의 통합이 용이하며, 실행 속도는 AspectJ에 비해 상대적으로 느릴 수 있지만, 대규모 엔터프라이즈 애플리케이션에서 유연하게 AOP를 적용할 수 있다.

요약하자면 이 둘은 AOP의 다른 구현체(라이브러리)일 뿐이다.

그렇다면 둘 중 어떤 것을 선택해야할까?

만약 프로젝트가 대규모이고 복잡한 시스템이라면 AspectJ를 사용하는 것이 유리하다. AspectJ는 자체적인 AOP 구현체를 가지고 있으며, Pointcut 표현식과 Advice 타입 등 다양한 AOP 기능을 제공한다. 또한, 컴파일 시점에 AOP 코드를 적용하므로, 실행 속도가 빠르기에 대규모 시스템에서는 AspectJ를 사용하여 보다 강력하고 유연한 AOP 기능을 구현할 수 있다.

반면, 작은 규모의 프로젝트나 Spring Framework를 사용하는 경우에는 Spring AOP를 사용하는 것이 좋다. Spring AOP는 Spring Framework와 함께 사용되므로, 프로그래밍이 간편하고 Spring Framework의 다른 기능과의 통합이 용이하다. 또한, 프록시 기반의 AOP를 지원하여 런타임 시 동적으로 프록시 객체를 생성하므로, 실행 속도가 느리더라도 대부분의 경우 문제가 되지 않는다. 따라서, 작은 규모의 프로젝트나 Spring Framework를 사용하는 경우에는 Spring AOP를 사용하여 AOP 기능을 구현할 수 있다.

결국 개발자는 프로젝트의 요구사항과 상황에 따라 적절한 AOP 기술을 선택해야 한다. 이를 위해서는 AspectJ와 Spring AOP를 모두 이해하고, 이 두 기술의 장단점을 비교하여 적절한 선택을 할 수 있어야 한다.

스프링에서는 Spring AOP의 사용만을 고집하는 것은 아니다.

@EnableAspectJAutoProxy어노테이션을 사용하면 Spring 프레임워크에서 AspectJ를 사용할 수 있게된다. 명확히 얘기하자면 Spring AOP의 프록시 기반 메커니즘을 사용해 AspectJ 스타일의 어노테이션을 지원하는 것이다. 즉, 여전히 프록시 기반 AOP를 사용하나 AspectJ의 문법과 스타일을 적용할 수 있으며, Spring 프레임워크와의 통합 및 설정이 용이해 질 뿐이다.

왜 @AspectJ를 사용할까


Spring AOP는 엔터프라이즈 애플리케이션에서 관심사의 분리 (Separation of Concerns, SoC)를 지원하기 위한 프레임워크로, 애플리케이션의 비즈니스 로직에서 크로스커팅 관심사 (cross-cutting concerns)를 분리하도록 도와준다. 예를 들면, 로깅, 트랜잭션 관리, 보안 등이 여기에 포함된다.

Spring AOP의 런타임 Weaving은 객체의 프록시를 통해 원래 객체의 동작을 가로채고 필요한 aspect 로직을 실행한 후 원래 동작을 계속 수행한다. AspectJ처럼 실제 바이트 코드에 aspect 로직을 삽입하는 것이 아닌 ‘런타임 프록시 기반 Weaving’을 사용하는 것이다.

Weaving이란?

AOP의 핵심 과정 중 하나로, 어플리케이션의 코드에 Aspect 코드를 삽입하는 과정을 의미한다. 이를 통해 원래의 비즈니스 로직 코드와 분리된 관심사(Aspect로 선언된 내용)를 합치게 되며, 결과적으로 개발자는 중복 코드없이 특정 로직을 여러 위치에 적용할 수 있다.

그럼 왜 Spring AOP에서는 AspectJ의 어노테이션과 Pointcut 표현식을 채택했을까?

  1. 표준화: AspectJ는 AOP 분야에서 가장 널리 알려진 툴로서, 많은 개발자들이 이미 AspectJ의 문법과 표현식에 익숙하다. Spring AOP는 AspectJ의 어노테이션과 문법을 채택함으로써 개발자들에게 친숙하고 일관된 개발 경험을 제공할 수 있다.
  2. 강력한 Pointcut 표현식: AspectJ의 Pointcut 표현식은 매우 강력하여, 다양한 조건에서 메서드나 필드에 접근하는 것을 제어할 수 있다. 이 강력한 표현식을 Spring AOP에서도 활용하면, 더 세밀한 조건에서 AOP를 적용할 수 있다.
  3. 통합 용이성: Spring AOP는 순수한 런타임 AOP만을 제공하기 때문에, 필요한 경우 AspectJ의 컴파일 시점 또는 로드 시점 Weaving과 같은 고급 기능을 활용하려면 AspectJ와의 통합이 필요하다. 이러한 통합을 더욱 쉽게 하기 위해 Spring AOP는 AspectJ의 문법과 어노테이션을 지원한다.

Spring AOP에서 @AspectJ 스타일의 어노테이션을 사용하면, AspectJ의 문법과 표현식을 활용하여 aspect를 정의하고 관리할 수 있다. 하지만 이것은 문법과 표현식만을 가져오는 것으로, 실제 weaving 과정은 Spring AOP의 프록시 기반 웨이빙을 사용한다.

즉, @Aspect, @Pointcut, @Before, @After 등의 AspectJ 어노테이션들을 사용하여 Aspect를 정의하고, 이러한 정의를 바탕으로 Spring AOP는 런타임에 프록시 객체를 생성하며 이 프록시 객체를 통해 관련된 advice들 (즉, aspect의 코드 부분)을 적용한다.

따라서, @AspectJ 어노테이션을 사용하는 Spring AOP 설정은 AspectJ의 강력한 문법과 표현식을 활용할 수 있으면서도, Spring의 프록시 기반의 런타임 웨이빙 메커니즘을 이용하여 aspect를 애플리케이션에 적용한다는 것을 의미한다.   물론 Spring의 non-invasive 철학에 따라 Spring AOP 외에 원하는 라이브러리를 사용해서 스프링 기반 어플리케이션을 제작하는 것도 전혀 문제가 되지 않는다.

Spring의 non-invasive 철학

비즈니스나 도메인 모델에 프레임워크에 특화된(framework-specific) 클래스나 인터페이스를 강제로 도입시키지 않는 것을 지향하는 철학. 다시 말해, 개발자들이 비즈니스나 도메인 모델을 구현할 때 Spring 프레임워크와 같은 특정 프레임워크에 종속되지 않도록 하는 것이다.

이러한 철학은 비즈니스 모델과 도메인 모델을 더욱 깔끔하고 간결하게 유지할 수 있게 해주며, 유지보수성과 확장성을 높여준다.

본격적으로 Spring AOP에서 AspectJ를 활성화하여 aspect, pointcut, advice를 선언하는 방법에 대해 알아보자.

@AspectJ 활성화


Spring AOP에서는 @AspectJ 활성화를 통해 AspectJ 스타일 어노테이션으로 aspect를 쉽게 선언할 수 있다. 이 때 Spring은 자동 프록시(auto-proxying) 기능을 사용해 어떤 빈이 advise 되어야하는지 자동으로 판단하여 프록시 빈을 생성한다.

auto-proxying

Spring이 빈이 하나 이상의 aspect로 advise 되는지 여부를 결정하고, 그 빈에 대한 프록시를 자동으로 생성하여 메소드 호출을 가로채고 필요한 대로 advise가 실행되도록 보장하는 것을 의미한다.

XML 또는 Java 스타일 구성으로 @AspectJ를 활성화할 수 있으며, 이를 위해 AspectJ의 aspectjweaver.jar라이브러리를 애플리케이션의 클래스패스에 추가해야 한다.

@Configuration
@EnableAspectJAutoProxy
public class AppConfig {
}

간단히 @Configuration과 함께 @EnableAspectJAutoProxy어노테이션을 추가하는 것으로 @AspectJ를 활성화한다.

Advise 되다

“advise 된다”는게 무슨 의미인지 의아할 것 같다. AOP에서 'advise'라는 용어는 특정 join point에 어떤 추가적인 동작 (advice)을 적용한다는 것을 의미한다. 이는 원래의 로직에 부가적인 로직을 끼워넣는 작업을 가리킨다.

그러나, Spring AOP에서는 이런 과정을 'intercept'라는 용어로도 표현한다. 이는 Spring AOP가 주로 프록시 기반의 방식으로 동작하기 때문이다. Spring AOP는 원래의 target object를 감싸는 프록시 객체를 생성하며, 이 프록시 객체가 메서드 호출을 가로채서 advice 로직을 실행한 후 원래의 메서드를 실행한다.

요약하자면:

  • 'Advise'는 특정 join point에 부가적인 로직(advice)을 적용하는 것을 의미한다.
  • Spring AOP에서는 이러한 과정을 'intercept'라고도 표현하며, 프록시 객체를 통해 메서드 호출을 가로채서 advice를 적용한 다음 원래의 메서드를 실행한다.

Aspect 선언


@Aspect
public class NotVeryUsefulAspect {
}

스프링은 해당 클래스가 Aspect로 사용될 것을 인지하고 AOP를 적용한다. @Aspect는 대게 비즈니스 로직과 관련된 클래스가 아니라, 공통적으로 적용되는 부가 기능을 제공하기 위한 클래스이므로 인터페이스의 구현체로 만들어지는 경우는 드물다. 따라서 JDK 다이나믹 프록시는 생성되지 않는다. (애초에 Aspect 모듈 자체는 프록시 객체가 없어도 된다.)

스프링 부트 프로젝트에서는 @EnableAspectJAutoProxy 설정이 자동으로 처리되므로, 추가적인 선언없이도@Aspect어노테이션이 있는 클래스는 모두 빈으로 등록된다.

그러나 스프링 프레임워크를 사용하는 프로젝트에서는 AspectJ를 함께 사용해야 하며, @EnableAspectJAutoProxy 를 추가하여야만 @Aspect 어노테이션이 있는 클래스를 빈으로 등록한다.

해당 설정이 추가되지 않은 경우, @Aspect 어노테이션만으로는 빈으로 자동 등록되지 않으므로 명시적으로 @Component 어노테이션을 사용해서 빈으로 등록해주어야 한다.

본격적으로 Aspect의 구성들을 살펴보기 전에 코드부터 보고 가자.

@Aspect
public class MyAspect {

    @Pointcut("execution(* com.example.MyClass.*(..))")
    public void myPointcut() {}

    @Before("myPointcut()")
    public void myAdvice() {
        System.out.println("Before advice executed!");
    }

    @After("myPointcut()")
    public void myOtherAdvice() {
        System.out.println("After advice executed!");
    }

    @Around("myPointcut()")
    public Object myThirdAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
        System.out.println("Before around advice executed!");
        Object result = joinPoint.proceed();
        System.out.println("After around advice executed!");
        return result;
    }
}

처음 접한다면 대체 이게 뭔가 싶을 것이다. 그래도 상관없다! 일단 눈에 익히는게 중요하니까. 이어지는 내용들을 다 보고나서 다시 이 코드를 보러왔을 때, 이 Aspect를 스스로 해석 할 수 있길 바란다.

  • MyAspect라는 이름의 Aspect를 정의한다.
  • myPointcut이라는 이름의 Pointcut을 정의한다.
  • @Before, @After, @Around 어드바이스에서 myPointcut()을 참조한다.
  • MyClass 클래스(myPointcut에서 명시)의 모든 메소드 호출 전후 및 메소드 실행 전후에 각각의 어드바이스가 실행된다.

Pointcut 선언


pointcut은 원하는 join point를 결정하여 advise가 실행되는 시기를 제어한다.

Spring AOP는 join point로 Spring 빈의 메서드 실행 시점만을 지원한다.

pointcut은 두 부분으로 구성되어 있다.

  • signature : 메소드 이름과 매개변수로 구성. 일반적인 메서드 정의로 작성
  • expression : 관심 있는 메서드 실행을 결정하게하는 pointcut 표현식. @Pointcut 어노테이션의 속성값으로 전달한다.

간단한 예제를 보도록 하자

@Pointcut("execution(* transfer(..))")  
private void anyOldTransfer() {}  

anyOldTransfertransfer라는 이름의 메소드를 실행하는 모든 메소드와 일치한다.

스프링 AOP는 pointcut expression 사용을 위해 AspectJ의 다양한 포인트컷 지정자(PointCut Designators)를 지원한다. 아래에 나열되는 모든 PCD는 && (and), || (or), ! (not) 연산자와 함께 사용할 수 있다.

PCD (PointCut Designators)

execution

메소드 실행 join point와 일치한다. Spring AOP와 함께 작업할 때 사용해야하는 기본적인 PCD이다.

@Pointcut("execution(public * com.example.service.*.*(..))")
public void serviceExecution() {}

com.example.service 패키지에 있는 모든 클래스의 public 메소드 호출을 가로채는 포인트컷.

within

특정 타입 내의 메소드 실행 join point와 일치한다.

@Pointcut("within(com.example.service.*)")
public void serviceLayer() {}

com.example.service 패키지의 클래스 내의 모든 메소드 호출을 가로채는 포인트컷.

@Pointcut("within(com.example.service.MemberService)")
public void serviceLayer() {}

com.example.service.MemberService클래스 내부에서 실행되는 모든 메소드 호출을 가로채는 포인트컷.

this

프록시 객체를 통해 매칭시키기 때문에, 프록시 객체의 타입이 지정한 타입과 일치하는 경우 매칭된다. 즉, 프록시 객체가 대상 빈을 대신하여 호출될 때, 이를 인터셉트하여 추가적인 작업을 수행할 수 있도록 하는 데 사용된다.

AOP가 CGLIB 기반 프록시를 생성할 때 작동하므로 인터페이스를 구현하지 않는 클래스에 대해 사용할 수 있다.

public class MyBean {
    public void doSomething() {
        // method implementation
    }
}

@Pointcut("this(com.example.model.MyBean)")
public void myBeanPointcut() {}

com.example.model.MyBean의 프록시 객체의 모든 메소드 호출을 가로채는 포인트컷.

thiswithin는 어떻게 다른 것일까?

한마디로 정의하자면 this 포인트 컷은 프록시 객체를 통해 매칭시키고 within 포인트 것은 타겟 객체를 직접 매칭시킨다. 프록시 객체가 없는 빈은 this를 사용할 수 없으므로 within을 사용하도록 하자.

target

프록시 객체가 아니라 실제 객체에 대해 매칭한다. 즉, 프록시 객체가 아닌 원본 객체의 타입이 지정한 타입과 일치하는 경우 매칭된다.

target을 사용할 때는 JDK dynamic proxy 방식으로 프록시 객체가 생성된 경우에만 적용된다.

@Pointcut("target(com.example.service.UserService)")
public void userServiceBeans() {}

com.example.service.UserService 클래스를 구현한 bean의 모든 메소드 호출을 가로채는 포인트컷.

thistarget은 어떻게 다른 것일까?

this는 프록시 객체를 통해 매칭하므로 인터페이스를 구현하지 않는 클래스에 대해서도 매칭이 가능하다. 반면에 target은 프록시 객체가 아닌 원본 객체를 대상으로 매칭하므로 인터페이스를 구현해야만 매칭이 가능하다.

args

인수가 주어진 타입의 인스턴스인 경우 조인 포인트를 일치시킨다.

@Pointcut("args(String)")
public void stringArgs() {}

위 코드에서는 하나의 String 타입 인자를 받는 메서드에 대해 pointcut을 정의하고 있다. 이 pointcut은 메서드의 매개변수가 하나밖에 없고 타입이 String인 경우에만 매칭된다.

만약 더 많은 매개변수가 있다면 args(String, Integer)를 사용할 수 있는데 이 때는 StringInteger 두 개의 매개변수를 받는 메서드에 대해 매칭된다. execution PCD을 이용하여도 인수 타입에 따른 매칭이 가능하긴 하다.

@Pointcut("execution(* *..find*(Long))")
public void longArgs() {}

메서드 이름이 "find"로 시작하고 하나의 Long 매개변수를 받는 모든 메서드와 일치시키는 포인트컷 예제이다.

".."은 0 개 이상의 패키지를 나타내며, ""은 임의의 클래스 이름을 나타낸다. 따라서 "..find*"는 패키지 이름에 상관없이 "find" 문자열을 포함하는 모든 메서드를 대상으로 한다.

이렇게 보면 args와 execution이 같은 join point에 매칭되니 어떤 것을 사용하든 상관 없어 보인다. 🤔 사실 차이점은 이 포인트컷 메소드를 호출 할 때에 있다.

@Aspect
public class MyAspect {
    
    @Pointcut("execution(* com.example.MyClass.foo(String, Integer)) && args(str, i)")
    public void myPointcut(String str, Integer i) {}
    
    @Pointcut("execution(* com.example.MyClass.bar(String))")
    public void myOtherPointcut() {}
    
    @Before("myPointcut(str, i) && myOtherPointcut()")
    public void beforeAdvice(String str, Integer i) {
        System.out.println("Before advice called with " + str + " and " + i);
    }
}

myPointcut의 매개변수 str, i는 어디서 가져오는 걸까?

@Pointcut에서 PCD로 정의한 내용을 보면 args(str, i)라고 동일한 매개변수명을 쓰고 있는 것을 볼 수 있다. (이 때 메소드는 str, i를 변수명으로 가지는 것으로 특정된다.)

즉, 이 포인트컷은 MyClass의 String, Integer를 파라미터로 가지는 foo가 호출되는 join point와 매칭되며 그 때 foo로 전달된 매개변수 str, imyPointcut의 매개변수로 전달되는 것이다.

반면에 myOtherPointcutargs를 사용하지 않았기때문에 매개변수로 어떤 값도 가져올 수가 없다.

beforeAdvice에서 포인트컷을 참조하면서 그 매개변수 또한 그대로 받아와서 쓸 수 있다. 이것이 args를 사용함으로써 얻을 수 있는 특이점이다.

@within

특정 어노테이션이 선언된 클래스 내부에서 실행되는 메서드에 대해 포인트컷을 설정한다.

@target

실행 객체의 클래스가 주어진 어노테이션을 가지고 있을 때, 메서드 실행 지점에 대해 포인트컷을 설정한다.

@Pointcut("@target(com.example.MyAnnotation)")
public void myPointcut() {}

클래스에 MyAnnotation 어노테이션이 지정되어 있는 경우 해당 클래스가 실행하는 모든 메소드를 가로채는 포인트컷.

@within과 @target은 어떻게 다른걸까?

@within@target은 비슷한 방식으로 동작하는 것처럼 보이지만, 주요 차이점이 있다. 두 PointCut Designator (PCD) 모두 어노테이션을 기반으로 포인트컷을 결정한다. 그러나 어노테이션의 위치와 관련된 시점에서 중요한 차이를 갖는다.

  1. @within:
    • @within은 특정 어노테이션이 선언된 타입 내의 메서드 실행 시점을 매칭한다.
    • 해당 어노테이션을 포함하는 타입(클래스 또는 인터페이스) 내의 모든 메서드가 포인트컷의 대상이 된다.
    • 즉, 해당 어노테이션이 포함된 클래스의 모든 메서드에 대해서 포인트컷이 적용된다.
  2. @target:
    • @target은 실행 중인 객체의 클래스에 지정된 특정 어노테이션을 기준으로 포인트컷을 결정한다.
    • 이 포인트컷은 해당 어노테이션이 포함된 클래스의 인스턴스에서 호출되는 모든 메서드에 적용된다.
    • 여기서 중요한 것은 어노테이션이 실제 실행 객체의 클래스에 있어야만 적용된다는 것.

정리하자면, 두 어노테이션은 다음과 같은 주요 차이점을 가진다.

  • @within은 해당 어노테이션이 포함된 클래스 내부의 모든 메서드를 대상으로 한다.
  • @target은 어노테이션이 포함된 실제 실행 객체의 모든 메서드를 대상으로 한다.
  • 이 차이는 AOP 프록시가 생성되는 방식과 관련이 있다.

간단한 예를 들어 설명하자면:

  • @within은 어노테이션을 포함하는 클래스의 상속 구조나 구현체와 관계없이 해당 클래스 내부의 모든 메서드에 적용된다.
  • @target은 구체적인 실행 객체의 클래스에 어노테이션이 있어야만 해당 객체의 메서드에 포인트컷이 적용된다.

@args

런타임 시점에 메서드의 인자로 전달되는 객체 중 하나라도 특정 어노테이션을 가지고 있을 때, 해당 메서드 실행 지점에 대해 포인트컷을 설정한다.

public class MyClass {
    
    public void myMethod(@MyAnnotation String str, Integer i) {
        // method logic here
    }
}

@Aspect
public class MyAspect {
    
    @Pointcut("@args(com.example.MyAnnotation)")
    public void myPointcut() {}

    @Before("myPointcut()")
    public void beforeAdvice() {
        // advice logic here
    }
}

메소드의 인자 중 하나 이상 MyAnnotation 어노테이션이 지정되어 있는 경우 해당 메소드를 가로채는 포인트컷.

@annotation

메서드가 주어진 어노테이션을 가지고 있을 때, 해당 메서드 실행 지점에 대해 포인트컷을 설정한다.

위에서 나열한 PCD를 구분하기 힘들거나 차이가 미미해보일 수 있다. 이는 사실 Spring AOP는 메서드 실행 join point에만 일치하는 포인트컷으로 제한하기 때문에 실제 AspectJ에서보다 좀 더 좁은 정의로 제공되기 때문이다.

bean

Spring AOP는 bean이라는 추가적인 PCD를 지원한다. 특정한 Spring bean의 이름으로 join point를 제한하거나, 와일드카드를 사용해 여러 Spring bean으로 제한할 수도 있다.

프록시와 포인트컷

Spring AOP 프레임워크는 기본적으로 JDK 프록시 기반으로 작동하므로 타깃 객체 내부에서 일어나는 호출에 대해서는 intercept가 발생하지 않는다. 즉, JDK 프록시의 경우, public 메서드 호출만 intercept 할 수 있다.

반면에 CGLIB의 경우, 추가로 protected 메서드도 프록시에서 intercept 된다.

예를 들어, 다음과 같은 클래스가 있다고 가정한다.

public interface MyClassInterface {
     void publicMethod();
}

@Component
public class MyClass implements MyClassInterface {
    public void publicMethod() {
        System.out.println("This is a public method.");
        protectedMethod();
        privateMethod();
    }

    protected void protectedMethod() {
        System.out.println("This is a protected method.");
    }

    private void privateMethod() {
        System.out.println("This is a private method.");
    }
}

public으로 선언된 메소드에서 protected, private 메소드를 호출 할 때, 프록시 객체에서는 어떻게 작동할까?

@Aspect
@Component
public class MyAspect {
    @Before("execution(* com.example.MyClassInterface.*(..))")
    public void beforePublicMethod(JoinPoint joinPoint) {
        System.out.println(String.format("[joinpoint info] kind: %s, this: %s, sourceLocation: %s", joinPoint.getKind(), joinPoint.getThis(), joinPoint.getSourceLocation()));
        System.out.println("Before public method");
    }
}

모든 접근제어자에 대해 적용하고, MyClassInterface가 호출하는 모든 메소드를 인터셉팅 할 것이다.

@SpringBootApplication
public class ExampleApplication implements CommandLineRunner {

	@Autowired
	private MyClassInterface myClassInterface;

	public static void main(String[] args) {
		SpringApplication.run(ExampleApplication.class, args);
	}

	@Override
	public void run(String... args) throws Exception {
		myClassInterface.publicMethod();
	}
}

실행 결과는 다음과 같다.

[joinpoint info] kind: method-execution, this: com.example.MyClass@4bd5849e, sourceLocation: org.springframework.aop.aspectj.MethodInvocationProceedingJoinPoint$SourceLocationImpl@7cdbaa50
Before public method
This is a public method.
This is a protected method.
This is a private method.

JDK 프록시의 경우, public 메서드 호출만 intercept 하기 때문에 최초에 단 한 번만 MyAspectadvise가 작동했다.

좋은 포인트컷 작성하기

간단하게 AspectJ가 pointcut과 join point를 일치시키는 방법에 대해 알아보자.

우선, AspectJ는 pointcut 선언을 처음 만나면 join point와의 일치 과정에 최적화되도록 이를 DNF (Disjunctive Normal Form)로 재작성한다.

DNF

여러 개의 논리식을 OR 조건으로 결합한 것으로, 각 논리식을 conjunction(AND) 조건으로 결합한 것보다 쉽게 처리할 수 있다. DNF로 재작성된 pointcut은 OR 연산으로 결합된 여러 개의 join point 패턴으로 나타난다.

또한, pointcut의 구성 요소는 계산 비용이 적은 것부터 먼저 확인되도록 정렬된다. 예를 들어, within 표현식은 런타임에 객체를 검사해야 하므로 계산 비용이 높다. 따라서, within 표현식은 계산 비용이 적은 kinded 디자이너보다 나중에 검사된다. 이렇게 하면 계산 비용이 높은 PCD는 나중에 검사되므로, 불필요한 처리를 최소화할 수 있다.

이러한 최적화 과정은 AspectJ에서 pointcut을 선언할 때 성능을 최적화하고, 불필요한 처리를 최소화할 수 있도록 도와준다.

뿐만 아니라 정적 매칭과 동적 매칭의 장점을 결합하여 처리 속도 또한 개선한다. AspectJ는 컴파일 시점에 pointcut을 분석하여 join point가 일치할 가능성이 있는 위치를 결정(정적매칭)한다. 그리고 런타임 시점에서 이 위치에서 동적 매칭을 수행하여 join point가 pointcut과 실제로 일치하는지 확인한다.

정적 매칭(static matching): 컴파일 시점에 join point가 pointcut과 정확히 일치하는지 확인한다. 이 경우에는 join point의 정보만을 사용하므로 처리 속도가 빠르다.

동적 매칭(dynamic matching): 런타임 시점에 join point가 pointcut과 일치하는지 확인한다. 이 경우에는 join point의 정보뿐만 아니라 런타임 정보도 사용하므로 처리 속도가 느리다.

이러한 최적화 과정은 pointcut을 잘 작성하면 자동으로 수행되지만, 성능을 최적화하려면 pointcut을 가능한 한 좁은 범위로 제한하는 것이 중요하다.

기존 PCD는 보통 아래 세 가지 그룹 중 하나에 속한다.

  • Kinded
    • 특정 join point 유형을 선택
    • execution, get, set, call, handler.
  • Scoping
    • 관심 있는 join point 그룹을 선택 (보통 여러 유형)
    • within, withincode
  • Contextual
    • 컨텍스트를 기반으로 선택. (선택적으로 바인딩)
    • this, target, @annotation

잘 작성된 pointcut은 적어도 첫 두 유형(Kinded, scoping)을 포함해야 한다. 이외에도 contextual을 포함시켜 join point context에 따라 일치하거나, advice에서 사용할 컨텍스트를 바인딩할 수 있다.

만약 kinded나 contextual만 제공한다면, weaving 성능(시간 및 메모리 사용)에 영향을 미칠 수 있다. 반면에 Scoping는 일치 속도가 매우 빠르기 때문에 좋은 pointcut을 작성하기 위해서는 가능한 경우 항상 scoping 디자이너를 포함해야 한다.

AspectJ를 사용하는 개발자는 pointcut을 작성할 때 어떤 디자이너(Kinded, Scoping, Contextual)를 사용해야 하는지를 알아보고, 가장 효율적인 방법으로 join point와 일치시키도록 노력해야 한다. 이를 통해 AspectJ에서 pointcut을 최적화하고 성능을 개선할 수 있다.

Advice


Advice는 일반적으로 조인 포인트 주위에 실행되는 코드 블록이다. AspectJ는 advice를 사용하여 코드 블록을 선택적으로 삽입하고, 각 블록의 실행 시기를 정할 수 있다.

AspectJ에서는 advice를 다섯 가지 유형으로 나눈다.

유형 실행 시점
Before 조인 포인트 실행 전
AfterReturning 조인 포인트가 성공적으로 실행된 후
AfterThrowing 조인 포인트에서 예외가 발생한 후
AfterAdvice (성공/예외 상관없이) 조인 포인트 실행 후
AroundAdvice 조인 포인트 실행 전과 후에 실행 (조인 포인트를 감싸는 코드 블록)

pointcut은 어떤 join point에 advice를 적용할 지 결정하고, advice는 join point 주위에 삽입될 코드를 제공한다.

AspectJ의 advice는 비즈니스 로직과 분리되어 있기 때문에 코드의 재사용성, 확장성과 유지 보수성을 높여준다. 예를 들어, 로깅, 트랜잭션 관리, 보안 등과 같은 부가적인 관심사를 분리하여 코드의 복잡도를 낮출 수 있다.

Before

조인 포인트 실행 전

@Aspect
public class BeforeExample {

    @Before("execution(* com.xyz.dao.*.*(..))")
    public void doAccessCheck() {
        // ...
    }
}

AfterReturning

조인 포인트가 성공적으로 실행된 후

@Aspect
public class AfterReturningExample {

    @AfterReturning(
        pointcut="execution(* com.xyz.dao.*.*(..))",
        returning="retVal")
    public void doAccessCheck(Object retVal) {
        // ...
    }
}

@AfterReturning의 속성 중 returning은 지정한 조인 포인트(메소드)가 반환하는 리턴 값을 의미한다.

같은 이름을 사용해 정의한 메소드의 Argument로 가져올 수 있다. 이 때 타입(Object)는 pointcut과 join point를 일치시키는 조건으로 사용된다. 즉, 위 예제에서는 패키지명이 com.xyz.dao인 클래스의 모든 메소드를 포함하며, 매개변수는 어떤 타입이든 상관없고, 반환값은 Object 타입인 메소드만이 최종적으로 일치하게 된다.

조인 포인트에서 예외가 발생한 후

@Aspect
public class AfterThrowingExample {

    @AfterThrowing(
        pointcut="execution(* com.xyz.dao.*.*(..))",
        throwing="ex")
    public void doRecoveryActions(DataAccessException ex) {
        // ...
    }
}

@AfterThrowing의 속성 중 throwing은 지정한 조인 포인트(메소드)에서 발생한 Exception을 의미한다.

@AfterReturning 때와 동일하게 같은 이름을 사용해 메소드의 Argument로 가져올 수 있으며, Exception 타입과 동일한 예외가 발생했을 때만 일치한다.

@AfterThrowing 는 일반적인 예외 처리 콜백이 아니다. 특히, @AfterThrowing 어드바이스 메소드는 조인 포인트 (사용자가 선언한 대상 메소드)에서 발생하는 예외만 받을 수 있습니다. 즉, 같은 aspect 내에 있는 @After 또는 @AfterReturning 어드바이스 메소드에서 발생한 예외는 @AfterThrowing 어드바이스 메소드에서 처리할 수 없다.

예를 들어, @AfterReturning 어드바이스 메소드에서 예외가 발생하면 @AfterThrowing 어드바이스 메소드에서 이 예외를 처리할 수 없다. 따라서, @AfterThrowing 어드바이스는 오직 join point에서 발생하는 예외만 처리하는데 사용해야 한다.

AfterAdvice

(성공/예외 상관없이) 조인 포인트 실행 후

@Aspect
public class AfterFinallyExample {

    @After("execution(* com.xyz.dao.*.*(..))")
    public void doReleaseLock() {
        // ...
    }
}

AroundAdvice

조인 포인트 실행 전과 후에 실행 (조인 포인트를 감싸는 코드 블록)

Around 어드바이스는 일치하는 메소드의 실행을 "둘러싸서" 실행된다. 이 어드바이스는 메소드 실행 전후에 작업을 수행하고 메소드가 실행되는 시점, 방식 및 심지어 메소드가 실행될지 여부를 결정할 수 있다. Around 어드바이스는 쓰레드 안전한 방식으로 메소드 실행 전후에 상태를 공유해야 하는 경우에 자주 사용된다. (예를 들어, 타이머를 시작하고 중지하는 경우 등)

항상 요구 사항을 충족하는 가장 간단한 형태의 어드바이스를 사용해야한다. 예를 들어, Before 어드바이스가 충분한 경우에는 굳이 Around 어드바이스를 사용할 필요가 없다.

@Aspect
public class AroundLoggingAspect {

    @Around("execution(* com.example.service.*.*(..))")
    public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
        long startTime = System.currentTimeMillis();

        // 메소드 실행 전 처리
        System.out.println("Before method: " + joinPoint.getSignature());

        Object result;
        try {
            // 메소드 실행
            result = joinPoint.proceed();
        } catch (Exception e) {
            // 예외 처리
            System.out.println("Exception caught: " + e.getMessage());
            throw e;
        }

        // 메소드 실행 후 처리
        long endTime = System.currentTimeMillis();
        System.out.println("After method: " + joinPoint.getSignature() + ", execution time: " + (endTime - startTime) + "ms");

        return result;
    }
}

이 예제에서는 @Around 어노테이션을 사용하여 대상 메소드 호출 전후로 System.out.println를 이용하여 로깅을 진행한다. 또한 System.currentTimeMillis()를 이용해 메소드의 실행 시간을 측정하였다. joinPoint.proceed()를 호출하여 대상 메소드를 실행하고, 반환 값 result를 얻은 뒤 return 하는 것으로 로직을 마무리한다.

반드시 반환 타입으로 Object를, 메소드의 첫 번째 매개변수는 ProceedingJoinPoint 타입이어야한다.

이때 ProceedingJoinPoint는 대상 메소드를 나타내며, proceed() 메소드를 호출해야 대상 메소드가 실행되기 때문에 Around 어드바이스는 대상 메소드의 실행 전/후에 필요한 작업을 수행할 수 있다.

만약 어드바이스 메소드 내부에서 proceed()를 호출하지 않으면 어떻게 될까?

만약 Around 어드바이스 메소드 내부에서 proceed() 메소드를 호출하지 않는다면, 대상 메소드는 호출됐음에도 불구하고 실제로 실행되지 않는다. 즉, Around 어드바이스는 대상 메소드의 실행을 결정할 수 있는 권한을 가지고 있다.

상황에 따라 대상 메소드 호출 유무를 결정하고 싶다면 Around 어드바이스 내부에서 proceed() 메소드의 호출 유무를 결정하는 로직을 구현하면 된다.

물론 어드바이스 메소드 내부에서 여러번 호출해도 된다.

또한, proceed() 메소드의 인수를 조작하여 대상 메소드에 전달되는 인수를 변경할 수 있다. 인수 없이 proceed()를 호출하면 호출자의 원래 인수가 제공된다.

어드바이스 메소드의 반환 타입을 void로 선언하면 항상 null이 호출자에게 반환되며, proceed()의 결과는 무시된다. 따라서 어드바이스 메소드의 반환 타입으로 Object를 선언하는 것이 좋다. 어드바이스 메소드는 일반적으로 대상 메소드가 void를 반환하더라도 proceed() 호출의 결과를 반환해야 하지만, 사용 사례에 따라 캐시된 값, 래핑된 값 또는 기타 값을 반환할 수도 있다.

Interception Around Advice를 사용하면 다음과 같은 상황에서 유용할 수 있다.

  1. 메소드 실행 시간을 측정하여 성능을 분석하는 경우
  2. 메소드 실행 전후로 트랜잭션을 시작하고 커밋 또는 롤백하는 경우
  3. 예외 처리 및 로깅을 중앙 집중화하여 관리하는 경우

Advice Parameters

AspectJ는 매개변수 이름을 기반으로 인자를 바인딩하기 때문에, ProceedingJoinPoint는 어드바이스 메소드의 인자 중 어느 위치에 위치하더라도 상관없다. (ProceedingJoinPoint를 가장 처음에 위치시키는 것이 일반적이긴 하다.)

여기서 사실 중요한 것은 AspectJ가 매개변수 이름을 기반으로 인자를 바인딩한다는 설명이다.

@Aspect
public class AfterReturningExample {

    @AfterReturning(
        pointcut="execution(* com.xyz.dao.*.*(..))",
        returning="retVal")
    public void doAccessCheck(Object retVal) {
        // ...
    }
}

위에서 본 코드이다. 대상 메소드의 리턴값을 retVal라고 명명하고, 이를 어드바이스 메소드의 인자에 동일한 이름으로 사용함으로써 값을 가져올 수 있다고 이야기했었다.

이렇듯 동일한 이름을 작성하는 것만으로도 대상 메소드와 관련된 여러가지 파라미터들을 가져올 수 있다.

Args

@Before("execution(* com.xyz.dao.*.*(..)) && args(account,..)")
public void validateAccount(Account account) {
    // ...
}

// 포인트 컷에서 정의해도 가져다 쓸 수 있다.
@Pointcut("execution(* com.xyz.dao.*.*(..)) && args(account,..)")
private void accountDataAccessOperation(Account account) {}

@Before("accountDataAccessOperation(account)")
public void validateAccount(Account account) {
    // ...
}

이 포인트컷은 com.xyz.dao 패키지 하위의 모든 클래스의 메소드 중 첫번째 인자가 account인 메소드를 일치시킨다. (account 이후의 인자는 어떤 것이 오든 상관없다.)

그리고 어드바이스 메소드에서 대상 메소드의 account 변수값을 그대로 자신의 인자로 가져온다. 동일한 이름을 사용했기 때문에 바인딩 되는 것이다.

@Annotation

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Auditable {
    AuditCode value();
}

@Before("com.xyz.Pointcuts.publicMethod() && @annotation(auditable)") 
public void audit(Auditable auditable) {
    AuditCode code = auditable.value();
    // ...
}

이 포인트컷은 @Auditable어노테이션을 사용한 publicMethod 메소드에 대해 필터링된다.

auditableAuditable의 이름이라고 생각하면 되며, 규칙상 어노테이션의 첫 대문자를 소문자로 변경하면 자동으로 매칭이된다. 따라서 어드바이스 메소드 audit에서 동일한 이름으로 auditable을 가져올 수 있다.

argNames

명시적으로 인수 이름을 지정하여 가져오는 방법이다.

@Before(
    value = "com.xyz.Pointcuts.publicMethod() && @annotation(auditable) && target(bean)", 
    argNames = "bean,auditable") 
public void audit(JoinPoint jp, Object bean, Auditable auditable) {
    AuditCode code = auditable.value();
    // ... use code, bean, and jp
}

매우 간단하고 직관적인데, argNames = "bean,auditable"로 각 PCD에서 명시한 인자들의 순서를 지정한다.

만약 argNames를 지정하지 않는다면 PCD의 선언 순서에 따라 auditable를 두번째 인자로, bean를 세번째 인자로 생각하여 오류가 날 것이다.

JoinPoint나, ProceedingJoinPointargNames에 명시하지 않아도 된다.

@Before(
    value = "com.xyz.Pointcuts.publicMethod() && @annotation(auditable) && target(bean)", 
    argNames = "bean") 
public void audit(JoinPoint jp, Object bean) {
    // ... use code, bean, and jp
}

argNames으로 bean 하나만 지정했기 때문에 auditable는 어드바이스 메소드의 인자로 들어오지 않는다.

Advice 실행 순서

Spring AOP에서 여러 advice가 같은 join point에서 실행되어야 할 경우, AspectJ와 동일한 우선순위 규칙을 따른다. before 어드바이스에서는 가장 높은 우선순위의 advice가 먼저 실행되며, after 어드바이스에서는 가장 높은 우선순위의 advice가 마지막으로 실행된다.

다른 aspect에서 정의된 여러 advice가 같은 join point에서 실행되어야 하는 경우

  • 기본적으로 무작위로 실행된다.
  • 이 때는 aspect 클래스에서 org.springframework.core.Ordered 인터페이스를 구현하거나 @Order 어노테이션을 사용해 실행 순서를 제어할 수 있다.

(Spring Framework 5.2.7부터) 같은 aspect에서 정의된 다른 유형의 advice가 같은 join point에서 실행되어야 하는 경우

  • 우선순위 : @Around, @Before, @After, @AfterReturning, @AfterThrowing.
    • 단 실제 실행 순서는 @Around, @Before, (@AfterThrowing, @AfterReturning,) @After 이다.

같은 aspect에서 같은 유형의 advice 메소드가 실행되어야 할 경우

  • 기본적으로 무작위로 실행된다.
  • 이 경우, advice 메소드를 하나의 메소드로 합치거나 별도의 @Aspect 클래스로 분리하여 @Order 어노테이션을 사용하여 우선순위를 지정하는 것이 좋다.

맺음말


AOP와 AspectJ의 이론과 구현에 대해 탐구해보았다. 이러한 개념들은 단순히 코드의 재사용성과 모듈화를 넘어서 개발 프로세스 전체의 효율성을 증대시키는 핵심 역할을 한다. 복잡한 시스템에서도 명확하게 기능과 책임을 분리함으로써, 유지보수의 복잡성을 줄이고, 코드의 품질을 향상시킬 수 있다.

이를 통해 개발자는 소프트웨어 아키텍처의 깊은 이해와 함께, 변화하는 비즈니스 요구사항에 빠르게 대응할 수 있는 능력을 키울 수 있게 될 것이다. 이번 글을 통해 AOP의 실질적인 가치와 그 구현 방법에 대한 깊은 통찰을 얻었길 바란다.

Ref