Spring

[Spring] 스프링 AOP 예제 구현

이덩우 2024. 5. 9. 00:43

지난 포스팅까지 직접 ProxyFactory를 사용해 프록시를 생성하는 단계부터 AutoProxyCreator, 그리고 AOP의 개념에 대해 공부했다. 

이제 간단한 예제 프로젝트를 통해 스프링 AOP를 활용해 부가 기능을 적용해보자.

 

 


1. 예제 세팅

예제 프로젝트는 간단하게 하나의 Service 계층 클래스와 Repository 클래스로 구성된다.

@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    public void orderItem(String itemId) {
        log.info("[orderService] 실행");
        orderRepository.save(itemId);
    }

}


@Slf4j
@Repository
public class OrderRepository {

    public String save(String itemId) {
        log.info("[orderRepository] 실행");
        // 저장 로직
        if (itemId.equals("ex")) {
            throw new IllegalStateException("예외 발생!");
        }
        return "ok";
    }
}

 

 


2. @Aspect를 사용한 AOP 구현

가장 먼저 호출되는 메서드의 시그니쳐를 로그로 남겨줄 로그 추적기 기능을 만들 것이다.

@Slf4j
@Aspect
public class AspectV1 {
    @Around("execution( * hello.aop.order..*(..))")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature());
        return joinPoint.proceed();
    }
}

 

  • `doLog()` 메서드의 구현이 어드바이스가 된다.
  • `@Around`는 AOP가 적용되는 시점을 나타내며 가장 넓은 범위이다. 종류가 많아 뒤에서 자세히 설명하겠다.
  • AspectJ 포인트컷 표현식에 대해서는 이번 포스팅에서 자세히 다루진 않는다. 
    • 현재 내용은 Order 도메인에 관련된 OrderService, OrderRepository에만 AOP를 적용한다는 의미이다.

 

테스트 코드를 한번 돌려볼까?

@SpringBootTest
@Slf4j
@Import(AspectV1.class)
public class AopTest {

    @Autowired
    OrderService orderService;

    @Autowired
    OrderRepository orderRepository;

    @Test
    void aopInfo() {
        log.info("isAopProxy, orderService={}", AopUtils.isAopProxy(orderService));
        log.info("isAopProxy, orderRepository={}", AopUtils.isAopProxy(orderRepository));
    }

    @Test
    void success() {
        orderService.orderItem("itemA");
    }

    @Test
    void exception() {
        Assertions.assertThatThrownBy(() -> orderService.orderItem("ex"))
                .isInstanceOf(IllegalStateException.class);
    }
}

aopInfo() 결과
success() 결과

`AopUtils`클래스를 통해 현재 스프링 빈으로 등록된 객체가 프록시 객체임을 알 수 있다. 

나머지 테스트에도 로그 추적기의 기능이 정상적으로 적용됨을 확인할 수 있다. 

 

 


3. @Pointcut 분리

바로 전 단계에서 @Aspect 애노테이션이 붙은 클래스를 생성해 어드바이저를 만들 수 있었다.

이번에는 약간 변형해서, AOP 적용 시점을 나타내는 애노테이션에 포인트컷 표현식을 직접 적지 않고 분리하는 방법에 대해 알아보자.

@Slf4j
@Aspect
public class AspectV2 {

    @Pointcut("execution( * hello.aop.order..*(..))")
    private void allOrder() {} // 포인트컷 시그니쳐


    @Around("allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature());
        return joinPoint.proceed();
    }
}

 

  • `@Pointcut` 애노테이션을 통해 메서드를 만들어 *포인트컷만 따로 관리가 가능하다.*
  • 메서드 이름과 파라미터를 합쳐 포인트컷 시그니쳐라 한다.
  • 메서드의 반환 타입은 `void`여야 한다.
  • 코드 내용은 비워둔다.
  • 만약 공통으로 사용되는 포인트컷을 외부 클래스에 따로 관리할 것이라면, 접근제어자를 `public`으로 지정해야한다.

결과적으로는 이전 단계와 동일한 실행 결과를 보여준다.

 

 


4. 어드바이스 추가

이번에는 기존의 로그 추적기의 기능을 수행하는 어드바이스 외에, 가상의 트랜잭션을 처리하는 어드바이스를 추가해보자.

서비스 계층에 트랜잭션 처리를 추가하는 것을 목표로 한다.

@Slf4j
@Aspect
public class AspectV3 {

    @Pointcut("execution( * hello.aop.order..*(..))")
    private void allOrder() {} // 포인트컷 시그니쳐

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


    //hello.aop.order 패키지 + 서비스 계층인 경우
    @Around("allOrder() && allService()")
    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());
        }
    }

    @Around("allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature());
        return joinPoint.proceed();
    }

}
  • 클래스 명으로 Service를 포함시키는 곳을 필터링하는 포인트 컷을 추가했다.
  • 포인트컷 시그니처를 조합할 때는 위 코드처럼 AND, OR 혹은 NOT 연산자를 사용할 수 있다.
  • 결과적으로 `doTransaction()` 어드바이스는 `OrderService`에만 적용된다.

 

테스트 결과를 확인해보자.

success() 결과

로그 관련, 트랜잭션 관련 어드바이스가 모두 적용되었다.

이 쯤 들었던 의문이 '만약 어드바이스의 적용 순서를 제어해야한다면 어떻게 할까..?' 였다.

@Aspect 애노테이션이 적용된 하나의 클래스 안에 두 개의 어드바이스가 존재하니 코드 위치를 바꿔보기도 하다가 방법을 알게되었다. 뒤에서 자세히 설명하겠다!

 

 


5. 포인트컷 -> 별도 클래스로 분리

잠깐 언급했듯, 포인트컷은 분리가 가능하다. 별도의 클래스로 분리해 관리해보자.

public class Pointcuts {

    @Pointcut("execution( * hello.aop.order..*(..))")
    public void allOrder() {} // 포인트컷 시그니쳐

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

    @Pointcut("allOrder() && allService()")
    public void orderAndService() {}
}

 

@Slf4j
@Aspect
public class AspectV4 {
    //hello.aop.order 패키지 + 서비스 계층인 경우
    @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());
        }
    }

    @Around("hello.aop.order.aop.Pointcuts.allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature());
        return joinPoint.proceed();
    }
}
  • 포인트컷을 모아둔 클래스를 임포트해 이전 코드를 그대로 재활용할 수 있지 않을까?
    • 정확히는 틀렸다.
    • 인식을 못하며, 패키지명을 포함한 정확한 메서드명을 기입해줘야 인식한다.
  • 그 외에는 동일한 코드이다. 포인트컷을 여러 어드바이스에서 함께 사용한다면 효과적인 방법이다.

 


6. 어드바이스의 적용 순서 제어

이전의 의문처럼 `@Aspect` 애노테이션이 적용된 하나의 클래스 안에서 어드바이스가 여러개라면 *기본적으로 순서가 보장되지 않는다.*

순서를 지정하고 싶다면 `@Aspect` 단위로 *클래스를 분리*해야하며, 이후 `@Order` 애노테이션을 통해 순서를 적용해야한다.

애노테이션의 인자로는 Integer 값을 지정해 우선순위를 부여하면 된다.

@Slf4j
public class AspectV5 {
    @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 {
        //hello.aop.order 패키지 + 서비스 계층인 경우
        @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());
            }
        }
    }
}

본 예제에서는 간단하게 내부 클래스를 활용했다.

 

테스트 결과를 통해 순서가 제대로 반영되는지 확인해보자. 

success() 결과

원하는 순서로 결과가 나왔다.

 

 


7. 어드바이스 종류

위의 모든 예제는 전부 `@Aroud` 타입의 어드바이스에 대해 알아봤다. 모든 종류에 대해 알아보자.

  • `@Around` : 메서드(조인 포인트) 호출 전후에 수행, 가장 넓은 의미이고 강력한 어드바이스이다. 
    • 조인 포인트의 실행 여부를 선택할 수 있고, 리턴 값 변경도 가능, 예외 처리가 가능하다.
  • `@Before` : 조인 포인트 실행 이전에 실행되는 어드바이스
  • `@AfterReturning` : 조인 포인트가 정상 완료된 후 실행되는 어드바이스
    • @Around와 달리 반환되는 객체를 변경할 수는 없다. 만약 변경하려면 @Around를 사용해야한다.
  • `@AfterThrowing` : 실행한 메서드가 예외를 던지는 경우 실행되는 어드바이스
  • `@After` : 조인 포인트의 정상, 예외 유무와 관계없이 마지막에 실행되는 어드바이스(finally의 포지션)

설명만 들으면 `@Aroud`만으로도 모든 것을 커버할 수 있을 것 같다. 나머지는 굳이 왜 있을까,,?

우선 예제를 만들며 학습해보자.

@Slf4j
@Aspect
public class AspectV6 {
    @Before("hello.aop.order.aop.Pointcuts.orderAndService()")
    public void doBefore(JoinPoint joinPoint) {
        log.info("[before] {}", joinPoint.getSignature());
    }

    // 리턴값 변경은 안된다.
    @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(value = "hello.aop.order.aop.Pointcuts.orderAndService()", throwing = "exception")
    public void doThrowing(JoinPoint joinPoint, Exception exception) {
        log.info("[ex] {} message={}", exception);
    }

    @After("hello.aop.order.aop.Pointcuts.orderAndService()")
    public void doAfter(JoinPoint joinPoint) {
        log.info("[after] {}", joinPoint.getSignature());
    }
}

 

  • 기존에 `@Around` 어드바이스를 활용해 만들었던 가상의 트랜잭션 AOP를 나머지 어드바이스들로 구성한 예제이다.
  • 가장 큰 차이점으로 보이는 것은, 어드바이스 메서드의 파라미터이다.
    • `@Around`의 경우는 `ProceedingJoinPoint`를 *사용해야한다.*
    • 나머지는 `JoinPoint`를 사용한다. (생략해도 된다.)
    • 이는 중요한 차이점이다. `ProceedingJoinPoint`의 `proceed()` 메서드는 다음 어드바이스나 타겟을 호출하는 역할이다. 따라서 `@Around` 어드바이저의 경우 *필수로 호출해줘야한다.*
    • 반면 `@Before` 어드바이스의 경우 직접 호출할 필요가 없다. 따라서 정말로 조인 포인트 호출 이전에 실행되어야할 로직만 작성하면 된다.

 

이렇게 하나의 @Aspect 클래스 안에 다양한 종류의 어드바이스가 있다면 적용되는 순서는 아래와 같이 정해져있다.

  1. @Around
  2. @Before
  3. @After
  4. @AfterReturning
  5. @AfterThrowing

물론 호출 순서와 리턴 순서는 반대이다.

어드바이스 적용 순서

 

 

마지막으로, `@Around` 외 다른 어드바이스들의 존재 이유를 고민해보자.

`@Around` 하나만 있어도 모든 기능을 수행할 수 있는데, 다른 것들은 왜 필요할까?

 

가장 큰 걸림돌은 `@Around`는 무조건 `proceed()`를 호출해야하는 것에 있다고 생각한다. 

특정 개발자는 마치 `@Before` 어드바이스를 작성하듯, 실수로 다음 어드바이스나 타겟을 호출하지 않을 수 있다. 이렇게 되면 단순히 AOP 로직만 수행되고, 실제 객체는 호출되지 않는 불상사가 일어날 수 있다. 

 

또한 협업을 하는 입장에서 모든 어드바이저가 `@Around`로만 이루어져있다면 어떤 기능을 하는 어드바이저인지 알아내기까지 까다로울 수 있다. 

하지만 @Before, @AfterThrowing 처럼 명시적으로 언제 쓰이는 어드바이저인지 구분해놓는다면 더욱 알아보기 쉬운 코드가 되지 않을까? 코드의 의도를 파악하기 쉬울 것이다.

 

 

 

출처 : 인프런, 김영한의 스프링 핵심 원리 - 고급편