본문 바로가기
Spring

[Spring AOP] @Aspect AOP

by 감자b 2024. 12. 28.

스프링은 @Aspect 애노테이션으로 매우 편리하게 어드바이저를 생성할 수 있도록 지원한다.

@Slf4j
@Aspect
public class LogTraceAspect {
    private final LogTrace logTrace;
    public LogTraceAspect(LogTrace logTrace) {
        this.logTrace = logTrace;
    }
    @Around("execution(* hello.proxy.app..*(..))")
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        TraceStatus status = null;
        try {
            String message = joinPoint.getSignature().toShortString();
            status = logTrace.begin(message);
            //로직 호출
            Object result = joinPoint.proceed();
            logTrace.end(status);
            return result;
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e; 
        }
    } 
}
  • @Aspect : 애노테이션 기반 프록시를 적용할 때 필요
  • @Around("execution(* hello.proxy.app..*(..))")
    • @Around의 값에 AspectJ 표현식을 사용
    • @Around가 붙은 메서드는 어드바이스
  • ProceedingJoinPoint joinPoint
    • 어드바이스에서 살펴본 MethodInvocation invocation과 유사한 기능으로 내부에 실제 호출 대상, 전달 인자, 그리고 어떤 객체와 어떤 메서드가 호출되었는지에 대한 정보가 포함되어 있음
  • joinPoint.proceed()
    • 실제 호출 대상(target)을 호출

이렇게 만든 @Aspect 역시 스프링 빈으로 등록을 해야 적용된다. (수동, 컴포넌트 스캔 상관없음)

이전 게시글에서 자동 프록시 생성기는 주로 2가지 일을 한다고 하였다.

  1. 스프링 빈으로 등록된 Advisor 들을 찾아 프록시가 필요한 곳에 자동으로 프록시를 적용시켜줌
  2. @Aspect도 자동으로 인식해서 프록시를 만들고 AOP를 적용시켜줌

여기서 2번째를 자세히 설명하면 자동 프록시 생성기는 @Aspect가 붙은 클래스를 찾은 뒤, 내부 정보를 가지고 Advisor로 변환시켜 준다.

이게 가능한 이유는 @Around 내부에 포인트컷 표현식이 있고 @Around가 붙은 메서드 로직이 어드바이스 역할을 하기 때문이다.

하나의 포인트컷과 하나의 어드바이스를 지니고 있으므로 자동 프록시 생성기는 이를 어드바이저로 변환한다.

어드바이저 변환 과정은 다음과 같다.

  1. 스프링 애플리케이션 로딩 시점에 자동 프록시 생성기를 호출
  2. 자동 프록시 생성기는 스프링 컨테이너에서 @Aspect 애노테이션이 붙은 스프링 빈을 모두 조회
  3. @Aspect 어드바이저 빌더를 통해 @Aspect 애노테이션 정보를 기반으로 어드바이저 생성
  4. 생성한 어드바이저를 @Aspect 어드바이저 빌더 내부에 저장

@Aspect 어드바이저 빌더란?

  • BeanFactoryAspectJAdvisorsBuilder 클래스
  • @Aspect의 정보를 기반으로 포인트컷, 어드바이스, 어드바이저를 생성하고 보관하는 역할
  • 생성된 어드바이저는 @Aspect 어드바이저 빌더 내부 저장소에 캐시
    • 캐시에 어드바이저가 이미 만들어져 있다면 캐시에 있는 어드바이저를 반환

지금까지 배운 내용을 바탕으로 자동 프록시 생성기의 전체적인 동작 흐름을 정리하면 이렇게 된다.

  1. 스프링이 빈 대상이 되는 빈 객체를 생성
  2. 생성된 빈을 스프링 애플리케이션 로딩 시점에 자동 프록시 생성기에 전달
  3. 자동 프록시 생성기는 스프링 컨테이너에서 Advisor 빈을 모두 조회
  4. 추가로 @Aspect 어드바이저 빌더 내부에 저장된 Advisor를 모두 조회
  5. 조회환 어드바이저의 포인트컷을 가지고 해당 객체가 프록시 적용 대상인지 확인
    • 객체의 클래스 정보, 모든 메서드를 하나하나 조회하는데, 여기서 메서드 하나라도 포인트컷 조건을 만족하면 해당 객체는 프록시 적용 대상이 됨
  6. 프록시 적용 대상이라면 프록시 생성, 아니라면 원본 객체 반환
  7. 반환된 빈을 스프링 컨테이너에 등록

포인트컷 분리

@Around 에 포인트컷 표현식을 직접 넣을 수도 있지만, @Pointcut 애노테이션을 사용해서 별도로 분리할 수 도 있다.

@Slf4j
@Aspect
public class LogTraceAspect {
    private final LogTrace logTrace;
    public LogTraceAspect(LogTrace logTrace) {
        this.logTrace = logTrace;
    }

    @Pointcut("execution(* hello.proxy.app..*(..))")
    private void allLog(){} 

    @Around("allLog()")
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        TraceStatus status = null;
        try {
            String message = joinPoint.getSignature().toShortString();
            status = logTrace.begin(message);
            //로직 호출
            Object result = joinPoint.proceed();
            logTrace.end(status);
            return result;
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e; 
        }
    } 
}

@Pointcut

포인트컷 표현식을 사용

메서드 이름과 파라미터를 합쳐서 포인트컷 시그니처라 함

→ 위 예시에서 포인트컷 시그니처는 allLog()

메서드의 반환 타입은 void, 코드 내용은 비워야 함

@Around 어드바이스에서는 포인트컷을 직접 지정하거나, 포인트컷 시그니처 사용 가능

접근 제어자는 내부에서만 사용하면 private을 사용해도 되지만, 다른 애스팩트에서 참고하려면 public을 사용해야 한다.

이렇게 포인트컷을 분리하면 다른 클래스에 있는 외부 어드바이스에서도 해당 포인트컷을 함께 사용할 수 있다.

여러 포인트컷을 && , || , ! 사용하여 조합할 수도 있다. (@Around("allOrder() && allService()"))


포인트 컷 참조

포인트컷을 공용으로 사용하기 위해 별도의 외부 클래스에 모아두는 것 역시 가능하다.

이 때 외부에서 호출해야하므로 포인트컷의 접근 제어자를 public 으로 해야한다.

public class Pointcuts {
    @Pointcut("execution(* hello.aop.order..*(..))") 
    public void allOrder(){}

    @Pointcut("execution(* *..*Service.*(..))") 
    public void allService(){}

    @Pointcut("allOrder() && allService()")
    public void orderAndService(){}
}
@Around("hello.aop.order.aop.Pointcuts.allOrder()")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
    log.info("[log] {}", joinPoint.getSignature());
    return joinPoint.proceed();
}

외부 클래스의 포인트컷을 사용할 땐 패키지명을 포함한 클래스 이름과 포인트컷 시그니처를 모두 지정해야 한다.

여러 어드바이스에서 공통으로 포인트컷을 사용할 때 효과적임.


어드바이스 순서

어드바이스는 기본적으로 순서를 보장하지 않는다.

만약 순서를 지정하고 싶다면 @Aspect 적용 단위로 @Order 애노테이션을 적용해야 한다.

하지만 해당 애노테이션은 클래스 단위로 적용이 가능하기에, 하나의 애스팩트 내에 여러 어드바이스가 있다면 애스팩트를 별도의 클래스로 분리해야 한다.

@Aspect
@Order(2)
public static class LogAspect {
    @Around("hello.aop.order.aop.Pointcuts.allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature());
        return joinPoint.proceed();
    }
}

@Aspect
@Order(1)
public static class TxAspect {
    @Around("hello.aop.order.aop.Pointcuts.orderAndService()")
    public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
        try {
            log.info("[트랜잭션 시작] {}", joinPoint.getSignature()); 
            Object result = joinPoint.proceed();
            log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
            return result;
        } catch (Exception e) {
            log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
            throw e;
        } finally {
            log.info("[리소스 릴리즈] {}", joinPoint.getSignature());
        } 
    }
}

이렇게 별도의 클래스로 분리한 뒤 @Order로 순서를 지정해주면 순서가 보장된다.

위 예제에서는 TxAspect.doTransaction() → LogAspect.doLog 순서로 실행된다.


어드바이스 종류

  • @Around : 메서드 호출 전후에 수행, 가장 강력한 어드바이스, 조인 포인트 실행 여부 선택, 반환 값 변환, 예외 변환 등 가능
  • @Before : 조인 포인트 실행 이전에 실행
    • ProceedingJoinPoint.proceed() 를 호출하지 않아도 메서드 종료 시 자동으로 다음 타겟을 호출
  • @AfterReturning : 조인 포인트가 정상 완료 후 실행
    • returning 속성에 사용된 이름은 어드바이스 메서드의 매개변수 이름과 일치해야 함
    • returning 절에 지정된 타입의 값을 반환하는 메서드만 대상으로 실행 (부모 타입을 지정하면 모든 자식 타입은 인정)
    • 참고로 반환 객체를 조작할 수는 있지만 변경할 수는 없음 (변경하려면 @Around를 사용해야 함)
@AfterReturning(value = "hello.aop.order.aop.Pointcuts.orderAndService()", returning = "result")
public void doReturn(JoinPoint joinPoint, Object result) {
    log.info("[return] {} return={}", joinPoint.getSignature(), result);
}
  • @AfterThrowing : 메서드가 예외를 던지는 경우 실행
    • throwing 속성에 사용된 이름은 어드바이스 메서드의 매개변수 이름과 일치해야 함
    • throwing 절에 지정된 타입과 맞는 예외를 대상으로 실행 (부모 타입을 지정하면 모든 자식 타입은 인정)
@AfterThrowing(value = "hello.aop.order.aop.Pointcuts.orderAndService()", throwing = "ex")
public void doThrowing(JoinPoint joinPoint, Exception ex) {
    log.info("[ex] {} message={}", joinPoint.getSignature(), ex.getMessage());
}
  • @After : 조인 포인트가 정상 또는 예외에 관계없이 실행(finally)
    • 일반적으로 리소스를 해제할 때 사용

스프링은 동일한 @Aspect 안에서 동일한 조인포인트의 우선순위를 지정했다.

  1. @Around: 가장 먼저 호출되며, 타겟 메서드의 실행을 감싸고 제어
    • 이 어드바이스는 메서드 실행 전후에 추가 작업을 수행할 수 있다.
  2. @Before: 타겟 메서드가 실행되기 전에 호출
  3. 타겟 메서드 실행: 실제 비즈니스 로직이 실행
  4. @AfterThrowing: 타겟 메서드 실행 중 예외가 발생했을 때 호출.
    • 또는 @AfterReturning: 타겟 메서드가 정상적으로 실행된 후에 호출. 주로 메서드의 반환 값을 처리하는 데 사용
  5. @After: 타겟 메서드가 실행된 후, 예외 발생 여부와 관계없이 항상 호출
    • 주로 리소스 정리 작업을 수행

물론 @Aspect 안에 동일한 종류의 어드바이스가 2개 있으면 순서가 보장되지 않음.

→ @Aspect를 분리하고 @Order 적용


포인트컷 지시자

AspectJ는 포인트컷을 편리하게 표현할 수 있도록 표현식을 제공한다.

포인트컷 지시자(PCD) 설명
execution 어떤 메서드에 대해 조인포인트를 적용할 것인지 지정. 가장 많이 사용하며 기능이 복잡
within 특정 클래스나 패키지 내부의 모든 메서드에 대해 조인포인트를 설정
args 특정 타입의 매개변수를 가지는 메서드 호출에 대해 조인포인트를 설정
this 스프링 빈 객체(스프링 AOP 프록시)를 대상으로 하는 조인포인트
target Target 객체(스프링 AOP 프록시가 가르키는 실제 대상)를 대상으로 하는 조인포인트
@target 런타임 시 실제 객체가 특정 애노테이션을 가지고 있는지 판단 즉 인스턴스를 기준으로 모든 메서드의 조인포인트를 설정, 부모 타입도 적용
@within 컴파일 시 클래스에 특정 애노테이션을 가지고 있는지 판단 즉 선택된 클래스 내부에 있는 메서드만 조인포인트를 설정
@annotation 주어진 애노테이션을 가지고 있는 특정 메서드에 대해 조인포인트를 적용
@args 메서드의 매개변수 중 하나 이상의 타입에 특정 애노테이션이 붙어있는 대상에 대해 조인포인트를 적용
bean 스프링 전용 포인트컷 지시자로 빈의 이름으로 포인트컷을 지정

execution

execution(modifiers-pattern? return-type-pattern declaring-type-pattern? method-name-pattern(param-pattern) throws-pattern?)
pointcut.setExpression("execution(* hello.aop.member.MemberService.*(..))");
  • 접근제어자(modifiers-pattern?) (선택): 접근 제어자(public, protected 등등)
  • 반환타입(return-type-pattern): 메서드의 반환 타입(* 모든 반환 타입을 의미)
  • 선언타입(declaring-type-pattern?) (선택): 메서드가 선언된 클래스나 인터페이스의 패키지 및 이름
  • 메서드이름(method-name-pattern): 메서드 이름(와일드 카드 * 지원)
  • 파라미터(param-pattern): 메서드의 매개변수 목록
  • 예외(throws-pattern?) (선택): 메서드가 던지는 예외

. : 해당 위치의 패키지

.. : 해당 위치의 패키지와 하위 패키지 포함

execution에서는 부모 타입을 선언해도 해당 타입의 자식 타입 역시 매칭된다.

단 부모 타입을 표현식에 선언한 경우 부모 타입에서 선언한 메서드가 자식 타입에 있어야만 매칭에 성공한다.

execution 파라미터 매칭 규칙

(String) : 정확하게 String 타입 파라미터

() : 파라미터가 없음

(*) : 정확히 하나의 파라미터, 모든 타입을 허용

(*, *) : 정확히 두 개의 파라미터, 모든 타입을 허용

(..) : 숫자와 무관하게 모든 파라미터, 모든 타입을 허용. 파라미터가 없어도 되며 0..*과 같음

(String, ..) : String 타입으로 시작. 그 이후 숫자와 무관하게 모든 파라미터, 모든 타입을 허용


within

within(type-pattern)
pointcut.setExpression("within(hello.aop.member.*Service*)");
  • type-pattern - 특정 클래스나 패키지의 이름을 지정
    • 패키지 이름 뒤에 ..를 붙이면 하위 패키지도 포함
    • 클래스 이름에는 와일드카드(*) 사용 가능

execution과는 다르게 표현식에 부모 타입을 지정하면 안되고 정확하게 타입이 일치해야 한다.

within은 클래스의 메서드에만 적용 가능하며, 인터페이스에 적용하려면 execution을 사용해야 한다.


args

args(param-type-pattern1, param-type-pattern2, ...)
pointcut("args(java.io.Serializable)")
pointcut("args(Object)")
pointcut("args(String,..)")
  • param-type-pattern - 매개변수의 타입 패턴을 지정
    • 와일드카드(*)를 사용하여 모든 타입을 지정할 수 있다.
    • ..은 0개 이상의 매개변수를 의미

execution은 파라미터 타입이 정확하게 매칭되어야 하고, 클래스에 선언된 정보를 기반으로 판단 (정적)

args는 부모 타입을 허용하며, 런타임에 실제 넘어온 파라미터 객체 인스턴스를 보고 판단 (동적)

args 지시자는 단독으로 사용되지 않고, 파라미터 바인딩에서 주로 사용


@target

@target(annotation)
@Around("execution(* hello.aop..*(..)) && @target(hello.aop.member.annotation.ClassAop)")
  • 런타임 클래스를 기준으로 애노테이션이 있는지 확인
  • 클래스에 선언된 애노테이션만 확인하고 메서드에 선언된 애노테이션은 무시

@within

@within(annotation)
@Around("execution(* hello.aop..*(..)) && @within(hello.aop.member.annotation.ClassAop)")
  • 컴파일 시점에 클래스에 선언된 애노테이션을 기준으로 적용
  • 클래스에 선언된 애노테이션만 확인하고 메서드에 선언된 애노테이션은 무시

@target 은 인스턴스의 모든 메서드를 조인 포인트로 적용 (부모 포함)

@within 은 해당 타입 내에 있는 메서드만 조인 포인트로 적용


@annotation

@annotation(annotationType)
@Around("@annotation(hello.aop.member.annotation.MethodAop)")
  • annotationType: 메서드에 붙어 있는 특정 애노테이션 클래스

@args

@args(annotationType1, annotationType2, ...)
@args(test.Check)
  • annotationType: 메서드 인자의 클래스에 선언된 애노테이션

@bean

bean(beanNamePattern)
@Around("bean(orderService) || bean(*Repository)")
  • beanNamePattern: 빈의 이름에 대해 와일드카드나 정규식 패턴을 적용하여 조건을 지정

매개변수 전달

스프링 AOP는 매개변수를 전달받아 어드바이스 내에서 활용이 가능하다.

즉 포인트컷을 정의할 때 매개변수 이름을 명시하고, 어드바이스 메서드에 해당 매개변수를 받아 처리가 가능하다.

전달 흐름

  1. 대상 메서드 호출
    • 특정 빈의 메서드를 호출
    • 스프링 컨테이너는 해당 메서드 호출을 감지하고, AOP 설정에 따라 해당 메서드에 적용할 포인트컷(Pointcut)과 어드바이스(Advice)를 확인
  2. 포인트컷 표현식을 가지고 매칭을 시도
    • 포인트컷이 매칭되면, 스프링은 해당 요청에 대해 어드바이스를 실행
execution(* com.example.MyService.process(String, int)) && args(data, count)
  1. 어드바이스로 파라미터 전달
    • args(data, count)는 메서드 호출 시 전달된 data와 count 값을 동적으로 매핑
    • 이렇게 포인트컷에서 캡처된 매개변수는 어드바이스 메서드의 파라미터와 이름에 따라 매핑
@Before("execution(* com.example.MyService.process(String, int)) && args(data, count)")
public void logParameters(String data, int count) {
    System.out.println("Data: " + data + ", Count: " + count);
}
  1. 어드바이스 로직 진행 후 타켓 메서드 실행

this, target

this(hello.aop.member.MemberService)
target(hello.aop.member.MemberService)
  • * 패턴을 사용할 수 없음
  • 부모 타입을 허용

this는 스프링 빈으로 등록되어 있는 프록시를 대상으로 포인트컷을 매칭

target은 실제 target 객체를 대상으로 포인트컷을 매칭

프록시 생성 방식에는 2가지 종류가 있다.

 

1. JDK 동적프록시 (인터페이스 기반)

  • 인터페이스를 지정한 경우

 

this(hello.aop.member.MemberService
target(hello.aop.member.MemberService))

둘 모두 부모타입을 허용하므로 정상적으로 AOP가 동작 

 

  • 구체 클래스를 지정한 경우
//this(hello.aop.member.MemberServiceImpl)
target(hello.aop.member.MemberServiceImpl)

this : proxy 객체를 보고 판단하는데, JDK 동적 프록시로 만들어진 proxy 객체는 인터페이스를 기반으로 구현된 클래스이다. 이는 구체 클래스와 관계가 없으므로 AOP 적용 대상이 아님

target : target 객체를 보고 판단하는데, target 객체가 구체 클래스 기반이므로 AOP 적용 대상

 

 

2. CGLIB (구체 클래스 기반)

  • 인터페이스를 지정한 경우
this(hello.aop.member.MemberService
target(hello.aop.member.MemberService))

둘 모두 부모타입을 허용하므로 정상적으로 AOP가 동작

  • 구체 클래스를 지정한 경우
this(hello.aop.member.MemberServiceImpl)
target(hello.aop.member.MemberServiceImpl)

this : proxy 객체를 보고 판단하는데, CGLIB로 만들어진 proxy 객체는 구체 클래스를 상속 받아서 만들었기 때문에 AOP가 적용된다. (this가 부모 타입을 허용하기 때문)

target : target 객체를 보고 판단하는데, target 객체가 구체 클래스 타입이므로 AOP 적용 대상

 

→ 프록시 대상인 this 의 경우 구체클래스를 지정하면 프록시 생성 전략에 따라서 다른 결과가 나올 수 있음


주의 사항

  1. 참고로 다음 포인트컷 지시자는 단독으로 사용하면 안된다. (args, @args, @target)
    • 그 이유는 args, @args, @target은 실제 객체 인스턴스가 생성되고 실행될 때 어드바이스 적용 여부를 확인할 수 있다. 또한 실행 시점에 일어나는 포인트컷 적용 여부도 결국 프록시가 있어야 실행 시점에 판단할 수 있다.
    • 즉 프록시가 없다면 판단 자체가 불가능한데, 스프링 컨테이너가 프록시를 생성하는 시점은 스프링 컨테이너가 만들어지는 애플리케이션 로딩 시점에 적용할 수 있다.
    • 따라서 args, @args, @target 같은 포인트컷 지시자가 있으면 스프링은 모든 스프링 빈에 AOP를 적용하려고 시도한다.
    • 문제는 이렇게 모든 스프링 빈에 AOP 프록시를 적용하려고 하면 스프링이 내부에서 사용하는 빈 중에는 final로 지정된 빈들도 있기 때문에 오류가 발생할 수 있다.
    • 이러한 표현식은 최대한 프록시 적용 대상을 축소하는 표현식과 함께 사용해야 한다.
  2. 내부 호출 문제
    • 스프링 항상 프록시를 통해서 대상 객체를 호출한다. 하지만 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하기 때문에 어드바이스가 적용되지 않는다.
    • 따라서 이런 내부 호출 문제가 발생한다면 서비스를 분리하고 Service.external() → InternalService.internal()을 호출하도록 구조를 변경하도록 한다.

스프링이 CGLIB를 사용하는 이유

위에서 살펴봤다시피 JDK 동적 프록시의 경우 인터페이스를 기반으로 동작하기 때문에, 인터페이스가 존재하지 않는다면 프록시를 생성할 수 없다.

따라서 인터페이스가 아닌 구체 클래스 타입으로 캐스팅 시도 시 오류가 발생한다.

이는 스프링의 의존 관계 주입에서 문제가 발생하게 된다.

구체 클래스를 의존관계 주입 시 JDK 동적 프록시로 생성한 프록시는 구체 클래스에 대해 아는 것이 없으므로 주입을 받을 수 없게 된다.

보통 구체 클래스를 주입을 받지 않지만, 테스트의 경우 구체 클래스를 의존하는 경우가 종종 있다.

그리고 CGLIB의 경우 대상 클래스에 기본 생성자가 필요하다는 문제, 생성자를 2번 호출하는 문제점이 존재하는데 스프링 4.0이상 부터 objenesis라는 라이브러리를 사용해서 위 문제를 모두 해결하였다.

따라서 스프링은 기본적으로 인터페이스, 구체 클래스 상관없이 CGLIB를 사용하여 프록시를 생성하도록 한다.


참고

 

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

김영한 | 스프링의 핵심 원리와 고급 기술들을 깊이있게 학습하고, 스프링을 자신있게 사용할 수 있습니다., 핵심 디자인 패턴, 쓰레드 로컬, 스프링 AOP스프링의 3가지 핵심 고급 개념 이해하기

www.inflearn.com

 

'Spring' 카테고리의 다른 글

[Spring] Auto Configuration  (0) 2024.12.28
[Spring-boot] JAR, WAR  (0) 2024.12.28
[Spring AOP] 스프링 AOP와 용어  (0) 2024.12.28
[Spring AOP] 빈 후처리기  (0) 2024.12.28
[Spring AOP] 포인트컷, 어드바이스, 어드바이저  (0) 2024.12.28