스프링 AOP - 개념, 포인트컷, Advice, 실무 주의사항

 

1. 들어가며

 

앞서 저희는 로그 추적기라는 부가 기능을 기존 프로젝트에 추가하면서 생길 수 있는 문제점과, 그에 대한 해결책에 대해 알아보았습니다.

 

간단하게 그 과정을 복습해볼까요?

 

  1. 프로젝트 전반의 코드에 모두 적용되어야 하지만, 핵심적인 비즈니스 로직이 아닌 '부가 기능'이다.
  2. 그래서 기존 코드에 영향을 주지 않고 적용될 수 있으면 좋겠다.
  3. 그래서 템플릿 메서드 패턴 -> 전략 패턴 -> 프록시 패턴 -> 동적 프록시 및 CGLIB 적용

라는 과정을 통해서 기존 코드를 바꾸지 않고, 프록시 클래스도 자동생성을 맡기면서 코드 양도 훨씬 줄일 수 있었습니다.

저희가 구현한 기능들은 사실 Spring에서 구현해서 제공하고 있습니다.

 

그것이 AOP이고, 오늘은 Spring에서 AOP를 어떤 방식으로 제공하고 있는지 살펴보도록 하겠습니다!

 

2. 관점 지향 프로그래밍 - Aspect Oriented Programming이란?

 

AOP란?
관점(Aspect)지향 프로그래밍으로, 관점을 기준으로 다양한 기능을 분리하여 보는 프로그래밍이다. 관점(Aspect)이란, 부가 기능과 그 적용처를 정의하고 합쳐서 모듈로 만든 것이다.

 

관점 지향 프로그래밍은 OOP와 대립하는 용어가 아닙니다. 기존의 OOP는 각 객체에 역할과 책임을 부여합니다. 그리고 외부에선 해당 객체들에게 일을 맡기는 형식으로 (API, public 함수) 서로 상호작용하게 됩니다.

 

문제는 횡단 관심사를 처리하는 것입니다. 예를 들어서 저번 글에서 다룬 로그 추적이 있고 그 외에도 트랜잭션, 보안 등의 문제가 있습니다. 여러 객체들에 공통적으로 적용되어야 하지만 기존 객체들의 역할과 책임과는 별개의 영역이라 할 수 있습니다.

 

이럴 때 기존 객체의 역할과 책임은 그대로 둔 채 횡단 관심사를 적용해보고자 고민해온 결과가 AOP 입니다

 

 

AOP 적용

 

 

- AOP 용어 및 개념

 

앞서 살펴본 AOP를 적용하기 위해선 어떤 것이 필요할까요? 크게 묶어서 살펴본다면

  • 부가 기능의 로직을 서술할 클래스
  • 기 핵심 로직
  • 적용될 위치를 지정하는 기능

라고 할 수 있습니다. Spring에서는 각각 그 둘을 다음과 같이 정의합니다.

어드바이스(Advice): 부가 기능. 특정 로직의 실행 지점 전, 후 등에 실행할 액션
조인포인트: 클라이언트가 호출하는 모든 비즈니스 메소드
포인트 컷(Pointcut): 어드바이스가 적용될 위치를 선별하는 기능

 

그리고 이 둘을 묶어 Spring은 어드바이저(Advisor)이라 부릅니다.

 

이런 AOP를 적용하기 위한 방법은 크게 세 가지로 나뉘는데요. 

 

  • 컴파일 시점
  • 클래스 로딩 시점
  • 런타임 시점(프록시 방식)

이 있습니다. 여기서 Spring AOP은 클래스 로딩 시점을 지원하고 있습니다. 그 외의 방식은 AspectJ를 통해서만 가능합니다. AspectJ는 더 광범위한 AOP 지원을 제공하는 독립적인 프레임워크입니다. AspectJ는 프록시 방식 외에도 지원을 하면서 메소드뿐만 아니라 생성자, 필드 값 접근 등도 지원을 해주지만 그만큼 학습 곡선이 높고 적용하기 위해 추가 옵션이 필요하여 Spring AOP로 하되 부족하다면 AspectJ를 적용하면 좋을 것 같습니다:)

 

3. AOP 원리

 

위에서 구성 요소를 살펴본 분이라면 어느정도 눈치채셨겠지만 AOP는 다음과 같은 순서로 만들게 됩니다. 역시 Spring답게 Annotation 방식을 사용하고 있습니다

 

  1. @Aspect를 통해 어드바이저를 만들 클래스를 지정한다.
  2. 포인트컷 어노테이션을 통해 적용할 범위를 지정한다.
  3. 포인트컷 어노테이션이 적용된 메소드에서 부가 로직을 서술한다.

 

  1. 생성: 스프링 빈 대상이 되는 객체를 생성한다. ( @Bean , 컴포넌트 스캔 모두 포함)
  2. 전달: 생성된 객체를 빈 저장소에 등록하기 직전에 빈 후처리기에 전달한다.
  3. Advisor 빈 조회: 스프링 컨테이너에서 Advisor 빈을 모두 조회한다.
  4. @Aspect Advisor 조회: @Aspect 어드바이저 빌더 내부에 저장된 Advisor 를 모두 조회한다.
  5. 프록시 적용 대상 체크: 앞서 3, 4에서 조회한 Advisor 에 포함되어 있는 포인트컷을 사용해서 해당 객 체가 프록시를 적용할 대상인지 아닌지 판단한다. 이때 객체의 클래스 정보는 물론이고, 해당 객체의 모든 메서드 를 포인트컷에 하나하나 모두 매칭해본다. 그래서 조건이 하나라도 만족하면 프록시 적용 대상이 된다. 예를 들어 서 메서드 하나만 포인트컷 조건에 만족해도 프록시 적용 대상이 된다.
  6. 프록시 생성: 프록시 적용 대상이면 프록시를 생성하고 프록시를 반환한다. 그래서 프록시를 스프링 빈으로 등 록한다. 만약 프록시 적용 대상이 아니라면 원본 객체를 반환해서 원본 객체를 스프링 빈으로 등록한다.
  7. 빈 등록: 반환된 객체는 스프링 빈으로 등록된다.

즉 IOC에서 빈을 적재하는 역할이 프레임워크라는 것을 이용해서 프록시 적용 대상인지 확인하고 맞다면 해당 프록시를 실제 스프링 빈으로 등록하는 과정을 거칩니다.

 

4. AOP 사용 방법

 

그럼 AOP를 사용하는 방법을 간단한 예제와 함께 살펴보겠습니다.

우선 포인트컷을 통해 적용할 범위를 지정한다고 했으니, 해당 지정 방법을 알아야 합니다.

AspectJ는 포인트컷을 편리하게 표현하기 위한 특별한 표현식을 제공합니다. 우선 포인트컷 적용 대상을 정하기 위해 지시자를 선택하게 됩니다.

포인트컷 지시자의 종류

  • execution : 메소드 실행 조인 포인트를 매칭한다. 스프링 AOP에서 가장 많이 사용하고, 기능도 복잡하다.
  • within : 특정 타입 내의 조인 포인트를 매칭한다.
  • args : 인자가 주어진 타입의 인스턴스인 조인 포인트
  • this : 스프링 빈 객체(스프링 AOP 프록시)를 대상으로 하는 조인 포인트
  • target : Target 객체(스프링 AOP 프록시가 가리키는 실제 대상)를 대상으로 하는 조인 포인트
  • @target : 실행 객체의 클래스에 주어진 타입의 애노테이션이 있는 조인 포인트
  • @within : 주어진 애노테이션이 있는 타입 내 조인 포인트
  • @annotation : 메서드가 주어진 애노테이션을 가지고 있는 조인 포인트를 매칭
  • @args : 전달된 실제 인수의 런타임 타입이 주어진 타입의 애노테이션을 갖는 조인 포인트
  • bean : 스프링 전용 포인트컷 지시자, 빈의 이름으로 포인트컷을 지정한다.

여기서 가장 많은 기능은 제공하는 것은 상술한 것처럼 execution이고, 다음과 같은 문법으로 지정하게 됩니다.

execution(modifiers-pattern? ret-type-pattern declaring-type-pattern?name- pattern(param-pattern) throws-pattern?)

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

 

즉 웬만한 건 다 지정이 된다고 봐도 될 것 같습니다.

 

그럼 실제 코드에 적용해볼까요? 일정 수까지 실패한 API 콜을 재시도하는 AOP를 만들어 보겠습니다.

import hello.aop.exam.annotation.Retry;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;


// 재시도를 적용하는 AOP를 만들어봅시다!
@Slf4j
@Aspect
public class RetryAspect {

 @Around("@annotation(retry)") // retry 어노테이션에 적용
 public Object doRetry(ProceedingJoinPoint joinPoint, Retry retry) throws Throwable {
 	// ProceedingJoinPoint라는 매개 변수를 받아야 한다. 해당 매개 변수는 언제 실제 핵심 로직을 
    // 실행할지 결정할 때 쓰인다.
     log.info("[retry] {} retry={}", joinPoint.getSignature(), retry);
     int maxRetry = retry.value();
     Exception exceptionHolder = null;
     for (int retryCount = 1; retryCount <= maxRetry; retryCount++) {
         try {
             log.info("[retry] try count={}/{}", retryCount, maxRetry);
             // 핵심 로직 진행
             return joinPoint.proceed();
         } catch (Exception e) {
            exceptionHolder = e;
         }
         }
            throw exceptionHolder;
         }
}

 

여기서 @Around 어노테이션이 포인트컷을 적용하는 어노테이션입니다. 해당 어노테이션에서 @annotation을 통해 "retry"를 가진 어노테이션에 포인트컷이 적용되도록 지정하고 있습니다.

참고로 @Around 외에도 @Before, @After와 같은 어노테이션도 제공됩니다. @Around로 모두 사용 가능하지만, 더 구체적인 지시를 내리고 있기 때문에 코드 가독성도 올라가고 실수를 방지할 수 있기 때문입니다.

 

 

4. AOP 주의사항

지속적으로 언급한 것처럼 스프링은 프록시 방식의 AOP를 사용합니다.

따라서 AOP를 적용하려면 항상 프록시를 통해야 하는데요. 그렇다면 당연히 프록시를 거치지 않고 대상 객체를 바로 호출하면 AOP가 적용되지 않습니다.

 

이게 무슨 문제인가..? 싶을 수도  있지만 대상 객체 내부에서 내부 메서드 호출이 일어나는 경우 문제가 됩니다. 

 

코드로 한번 살펴볼까요? 아래 코드는 external 함수에서 내부 internal 함수를 호출하고 있습니다.

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class CallServiceV0 {
 public void external() {
 log.info("call external");
 internal(); //내부 메서드 호출(this.internal())
 }
 public void internal() {
 log.info("call internal");
 }
}

 

해당 클래스에 간단한 AOP를 적용해보겠습니다.

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
@Slf4j
@Aspect
public class CallLogAspect {
 @Before("execution(* hello.aop.internalcall..*.*(..))")
 public void doLog(JoinPoint joinPoint) {
 log.info("aop={}", joinPoint.getSignature());
 }
}

 

여기서 external 함수를 호출하면 어떻게 될까요?

import hello.aop.internalcall.aop.CallLogAspect;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV0Test {
 @Autowired
 CallServiceV0 callServiceV0;
 @Test
 void external() {
 callServiceV0.external();
 }
}

 

결과는 external 호출 시에는 정상적으로 AOP가 적용되는데, external 내부에서 internal 함수를 호출할 때는 내부 호출이므로 프록시를 거치지 않고 따라서 AOP가 적용되지 않게 됩니다.

 

1. //프록시 호출
2. CallLogAspect : aop=void hello.aop.internalcall.CallServiceV0.external()
3. CallServiceV0 : call external

 

그림으로 표현하면 다음과 같이 됩니다!

 

 

 

이러한 문제를 해결하기 위해선 여러 방법이 있습니다.

 

  1. 자기 자신 주입 - setter를 통해서 자기 자신을 주입받아(생성자 주입은 순환 사이클로 인해 오류가 납니다.) 해당 주입받은 자기 자신에 대해 호출합니다.
  2. 지연 조회 - ObjectProvider를 통해 스프링 컨테이너에서 직접 빈을 조회해서 메소드를 실행합니다.
  3. 구조 변경 - 내부 호출 자체가 발생하지 않도록 클래스를 분리하는 등의 방식으로 구조를 변경합니다.

 

자기 자신 주입, 지연 조회의 경우 코드 구조가 어색해진다는 문제점이 있습니다. 그래서 적절한 방식으로 구조 변경을 하는 것을 권하고 있습니다 :)