Spring AOP : @Aspect를 이용한 프록시 객체 실습

    이 포스팅은 인프런 김영한님의 강의를 복습하며 작성한 글입니다.


    @Aspect 횡단 관심사 적용

    앞선 게시글들에서 로그 추적기를 달기 위해서 '프록시 개념'을 처음으로 도입했다. 그리고 그 프록시 개념을 좀 더 편리하게 쓰기 위해 자바 리플렉션을 활용했다. 자바 리플렉션으로 부족해 JDK 동적 프록시, CGLIB 동적 프록시를 사용했다. 거기에 스프링이 제공하는 프록시 팩토리를 사용했고, @Component들에도 프록시를 넣어주기 위해 빈 포스트 처리기(Bean Post Processor)까지 도입했다.

    마지막으로 스프링이 제공하는 AnnotationAwareAutoProxyCreator라는 빈 후처리기를 이용해 손쉽게 @Aspect로 횡단 관심사를 적용할 수 있게 되었다.

     


    Spring이 제공하는 AutoProxyCreator

    AutoProxyCreator는 크게 두 가지 기능을 한다.

    1. 스프링 빈 저장소에 등록된 어드바이저, @AspectJ 어드바이저 빌더에 등록된 어드바이저를 모두 찾아와 스프링 빈을 등록할 때, '포인트 컷'의 대상이 되면 프록시 객체로 만들어 스프링 빈으로 등록해준다.
    2. @AspectJ가 붙은 스프링 빈이 등록되면, 자동으로 어드바이저를 만들어 @AspectJ 어드바이저 빌더에 등록해준다. 

     

    Spring이 제공해주는 AutoProxyCreator 덕분에 앞으로 편리하게 @AspectJ 어노테이션을 달고 스프링 빈으로 등록해주기만 하면, 편리하게 AOP를 적용할 수 있게 되었다. 

     


    Spring이 제공하는 다양한 어드바이스

    • @Around : 메서드 호출 전후에 수행. 가장 강력한 어드바이저. 조인 포인트 실행여부, 반환 값 변환, 예외 변환 등 가능
    • @Before : 조인 포인트 실행 이전에 실행. 조인 포인트는 자동 실행. 예외 발생 시, 다음 코드는 호출 X
    • @AfterReturning : 조인 포인트 정상 실행 완료 직후에 실행. 조인 포인트 값은 자동으로 리턴됨. 반환되는 값을 받을 수 있다.
    • @AfterThrowing : 조인 포인트가 예외를 던진 직후 실행. 예외는 자동으로 던짐. 반환되는 예외를 받을 수 있다.
    • @After : 조인 포인트가 정상, 예외 실행 완료되면 실행. 

    @Around 어드바이스는 @Before, @AfterReturning, @AfterThorwing, @After로 나누어서 사용할 수 있다. 가장 큰 차이는 @Around는 JoinPoint를 개발자가 직접 실행을 해줘야만 한다는 것이고, 나머지 어드바이스는 자동으로 JoinPoint가 실행된다는 점이다. 

    public static class TxAspect{
        @Around("(hello.aop.order.aop.MyPointcut.allService())")
        public Object doTx(ProceedingJoinPoint joinPoint) throws Throwable {
            try {
                
                // @Before Advice
                log.info("[트랜잭션 시작] {}", joinPoint.getSignature().toShortString());
    
                Object result = joinPoint.proceed();
                
                // @AfterReturning
                log.info("[트랜잭션 종료] {}", joinPoint.getSignature().toShortString());
    
                return result;
            } catch (Exception e) {
                
                // @AfterThrowing
                log.info("[트랜잭션 롤백] {}", joinPoint.getSignature().toShortString());
                throw e;
            }finally {
                // @After
                log.info("[리소스 릴리즈] {}", joinPoint.getSignature().toShortString());
            }
        }
    }

    아래에서 예제로 트랜잭션 코드를 볼텐데, 트랜잭션 @Around는 각각 위와 같은 형태로 나눌 수 있다. 자세히 쪼개는 것은 아래 6번 코드에서 확인할 예정이다. 

    이 어드바이스는 내부적으로 순서가 정해져있다. 우선순위는 @Order > @Before > @After> @AfterReturning > @AfterThrowing 형태로 적용된다. 그렇지만 실제로 호출되는 순서는 @Around, @Before, @AfterThrowing, @AfterReturning, @After 순서대로 된다. 즉, 우선순위와 실제 호출되는 순서가 다르다는 점을 알고 넘어가자.

     


    @AspectJ 관련 실습

    @AspectJ를 적용하면서 점진적으로 어드바이저를 개선하는 방식으로 코드를 작성하고자 한다. 이 코드의 목적은 점진적으로 @AspectJ가 어떻게 동작하는지 알면서, 코드를 리팩토링 하는 것이다. 

     

    리포지토리 / 서비스 코드

    @Slf4j
    @Repository
    public class OrderRepository {
    
        public String save(String itemId) {
            log.info("[orderRepository] 실행");
            //저장 로직
            if (itemId.equals("ex")) {
                throw new IllegalStateException("예외 발생!");
            }
            return "ok";
        }
    }
    
    @Slf4j
    @Service
    public class OrderService {
    
        private final OrderRepository orderRepository;
        public OrderService(OrderRepository orderRepository) {
            this.orderRepository = orderRepository;
        }
        public void orderItem(String itemId) {
            log.info("[orderService] 실행");
            orderRepository.save(itemId);
        }
    
    
    }

    단순한 Repository, Service 코드를 작성했다.

    • Service 클래스는 Repository 객체를 호출해주면서, itemId를 매개변수로 넘겨준다.
    • Repository 클래스는 Service로부터 itemId를 받는다. itemId가 "ex"인 경우 예외를 발생시킨다. 

    런타임 시점의 객체는 다음과 같이 의존해서 움직이게 된다. 

     

    테스트 코드

    @Slf4j
    @SpringBootTest
    public class AopTest {
    
        @Autowired
        OrderRepository orderRepository;
    
        @Autowired
        OrderService orderService;
    
    
        @Test
        void aopInfo() {
            log.info("isAopProxy, orderService = {}", AopUtils.isAopProxy(orderService));
            log.info("isAopProxy, orderRepository = {}", AopUtils.isAopProxy(orderRepository));
        }
    
        @Test
        void success() throws NoSuchMethodException {
            orderService.orderItem("itemA");
        }
    
        @Test
        void exception() {
            Assertions.assertThatThrownBy(() ->
                    orderService.orderItem("ex")).isInstanceOf(IllegalStateException.class);
        }
    
    }
    • Aop Info 메서드
      • 현재 객체가 AoP Proxy에 의해 생성된 객체인지를 확인한다.
    • Suceess 메서드 
      • itemId에 "itemA"를 넘겨줘서, OrderRepository까지 정상적으로 수행된다.
    • Exception 메서드
      • itemId에 "ex"를 넘겨줘서, OrderRepository에서 예외가 발생해서 올라온다. 

     

    테스트 코드 실행 결과 : AOP 적용 X

    AOP를 적용하지 않았을 때 실행 결과는 다음과 같다. orderService → orderRepository로 정상적으로 실행되는 것이 확인되었다. 

    다음에는 가장 쉽게 AOP를 적용해보려고 한다. 각 OrderService, OrderRepository에 로그를 남기는 AOP를 적용하고자 한다. 

     

    AOP 1번 코드

    @Aspect
    @Slf4j
    public class AspectV1 {
        @Around("execution(* hello.aop.order..*.*(..))")
        public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[LOG] {}", joinPoint.getSignature().toShortString());
            return joinPoint.proceed();
        }
    }
    • @Aspect로 이 클래스가 어드바이저로 만들어져서 적용될 것이라는 것을 알려준다.
    • @AspectJExpressionPointCut을 이용해 @AOP를 적용해준다. 이 때, order 하위에 있는 모든 패키지 + 모든 메서드에 AOP가 적용된다.
    • 테스트 코드에서 @Import로 AspectV1을 스프링 빈으로 등록해줘야한다. 

     

    AOP 1번 코드 실행 결과

    OrderService, OrderRepository에 정상적으로 AOP가 적용된 것을 확인할 수 있다. 내부적인 구조로는 OrderServiceProxy → OrderService → OrderRepositoryProxy → OrderReposity 순서대로 의존관계를 가지는 것을 알 수 있다. 

    AOP 프록시는 위와 같이 적용이 되었다. 1번째 코드는 현재 어드바이스에 포인트 컷 표현식이 그대로 적용되어있다. 이 부분을 분리하는 작업을 2번째 코드에서 할 것이다.

     

    AOP 2번 코드

    public class AspectV2 {
    
        @Pointcut("execution(* hello.aop.order..*.*(..))")
        public void allOrder(){}
    
        @Around("allOrder()")
        public Object doLog1(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[doLog1{}]", joinPoint.getSignature().toShortString());
            return joinPoint.proceed();
        }
    
        @Around("allOrder()")
        public Object doLog2(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[doLog2{}]", joinPoint.getSignature().toShortString());
            return joinPoint.proceed();
        }
    
    }
    • 포인트컷을 @Pointcut 어노테이션으로 분리했다.
    • @Pointcut 어노테이션을 달고 AspectJ 표현식을 작성해준다.
    • @Pointcut 메서드 반환 타입을 void로 하고, 인자 및 메서드 내용을 비워둔다
    • 어드바이스에서는 포인트컷의 함수명을 가져다쓰면 적용된다. 

     

    AOP 2번 코드 실행 결과

    AOP 1번 → AOP 2번으로 오면서 포인트컷을 메서드로 빼고, 그것을 각 어드바이스에서 사용할 수 있도록 작성했다. 그것을 보여주기 위해 동일한 어드바이스를 doLog1, doLog2로 만들었고 그래서 실행 결과 로그가 2번씩 남게 되었다. 

    다음 번에는 AOP에서 가장 많이 사용하는 Transaction을 AOP를 통해 흉내를 내보고자 한다. 

     

    AOP 3번 코드

    public class AspectV3 {
    
    	// 모든 패키지 적용
        @Pointcut("execution(* hello.aop.order..*.*(..))")
        public void allOrder(){}
    
    	// Service 이름만 가지는 클래스에 적용 
        @Pointcut("execution(* hello.aop.order.*Service*.*(..))")
        public void allService() {}
    
    	// 기존 Log 
        @Around("allOrder()")
        public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[log] {}", joinPoint.getSignature().toShortString());
            return joinPoint.proceed();
        }
    
    	// 트랜잭션
        @Around("allService()")
        public Object doTx(ProceedingJoinPoint joinPoint) throws Throwable {
            try {
                log.info("[트랜잭션 시작] {}", joinPoint.getSignature().toShortString());
    
                Object result = joinPoint.proceed();
                log.info("[트랜잭션 종료] {}", joinPoint.getSignature().toShortString());
    
                return result;
            } catch (Exception e) {
                log.info("[트랜잭션 롤백] {}", joinPoint.getSignature().toShortString());
                throw e;
            }finally {
                log.info("[리소스 릴리즈] {}", joinPoint.getSignature().toShortString());
            }
    
        }
    
    }
    • doTx라는 어드바이스를 만들었다.
    • doTx는 클래스 이름이 Service일 경우만 포인트컷의 적용 대상이 된다.
    • 트랜잭션 정상 적용 시, 값을 리턴. 실패 시, 롤백하고 예외를 던진다.

     

    AOP 3번 실행 결과 → 여러 어드바이저를 가지는 1개의 프록시가 생성됨. 

    로그 AOP와 트랜잭션 AOP가 정상적으로 잘 남는 것을 알 수 있다. OrderService는 포인트 컷이 2군데에 적용이 되어, 2개의 어드바이저를 가진 1개의 프록시로 만들어진다. 

    실행 시점에서 프록시는 다음과 같이 적용이 되는 것으로 알 수 있다. 그런데 여기서 두 가지 문제점이 있다는 것을 알 수 있다. 포인트컷이 점점 많아지면서, @Aspect안에 너무 많은 포인트컷 메서드가 만들어진다는 점이 있다. 또, 트랜잭션 로그가 찍힌 후 로그가 찍히면 좋겠는데, 그 반대로 찍히고 있다는 것이다. 

     

    위 두 가지 문제점을 Aspect 4번, 5번에서 순서대로 개선해본다.

     

    AOP 4번 코드 → 포인트컷의 분리

    포인트컷 분리 코드

    @Aspect
    public class MyPointcut {
    
        @Pointcut("execution(* hello.aop.order..*.*(..))")
        public void allOrder(){}
    
        @Pointcut("execution(* hello.aop.order.*Service*.*(..))")
        public void allService() {}
    
        @Pointcut("allService() && allOrder()")
        public void allOrderAndService(){}
    
    }

    AspectV4 코드

    @Aspect
    @Slf4j
    public class AspectV4Pointcut {
    
    
        @Around("hello.aop.order.aop.MyPointcut.allOrder()")
        public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[Log] {}", joinPoint.getSignature().toShortString());
            return joinPoint.proceed();
        }
    
    
    
        @Around("(hello.aop.order.aop.MyPointcut.allService())")
        public Object doTx(ProceedingJoinPoint joinPoint) throws Throwable {
            try {
                log.info("[트랜잭션 시작] {}", joinPoint.getSignature().toShortString());
    
                Object result = joinPoint.proceed();
                log.info("[트랜잭션 종료] {}", joinPoint.getSignature().toShortString());
    
                return result;
            } catch (Exception e) {
                log.info("[트랜잭션 롤백] {}", joinPoint.getSignature().toShortString());
                throw e;
            }finally {
                log.info("[리소스 릴리즈] {}", joinPoint.getSignature().toShortString());
            }
    
        }
    
    
    
    
    }
    • 포인트컷 클래스를 하나 빼고, 필요한 포인트컷 메서드를 선언해둔다.
    • 포인트컷 메서드는 exeuction을 &&, || 하는 것처럼 메서드명끼리 논리 연산을 해서 사용해줄 수 있다.
    • 다른 클래스에 있는 포인트컷 메서드를 사용하려면 패키지명 + 메서드명을 넣어주면 된다. 

     

    AOP 4번 코드 실행 결과 

    4번 코드에서는 포인트컷들만 가지는 클래스를 따로 빼고, @Aspect가 붙은 클래스에서 해당 메서드를 불러오는 방식으로 처리했다. 이 때, 당연한 이야기지만 포인트컷들만 있는 클래스는 @Aspect / 스프링 빈 등록을 하지 않아도 된다. 어드바이저로 등록이 될 클래스만 @Aspect + 스프링 빈 등록을 해주면 된다. 

    그렇지만 아직까지 트랜잭션 AOP → Log AOP 순으로 적용이 되지는 못했다. 다음 코드에서는 트랜잭션 AOP → Log AOP 순으로 적용되도록 코드를 작성한다.

     

    AOP 5번 코드 → AOP 순서 설정

    @Slf4j
    public class AspectV5Order {
    
        @Aspect
        @Order(2)
        public static class LogAspect{
            @Around("hello.aop.order.aop.MyPointcut.allOrder()")
            public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
                log.info("[Log] {}", joinPoint.getSignature().toShortString());
                return joinPoint.proceed();
            }
        }
    
        @Aspect
        @Order(1)
        public static class TxAspect{
            @Around("(hello.aop.order.aop.MyPointcut.allService())")
            public Object doTx(ProceedingJoinPoint joinPoint) throws Throwable {
                try {
                    log.info("[트랜잭션 시작] {}", joinPoint.getSignature().toShortString());
    
                    Object result = joinPoint.proceed();
                    log.info("[트랜잭션 종료] {}", joinPoint.getSignature().toShortString());
    
                    return result;
                } catch (Exception e) {
                    log.info("[트랜잭션 롤백] {}", joinPoint.getSignature().toShortString());
                    throw e;
                }finally {
                    log.info("[리소스 릴리즈] {}", joinPoint.getSignature().toShortString());
                }
            }
        }
    
    }
    • AOP 어드바이저의 적용 순서는 @Order 어노테이션으로 한다
    • @Order 어노테이션은 @Aspect 적용 단위로 나누었을 때, 정상적으로 적용된다.
    • @Order 어노테이션은 동일 클래스 내의 메서드에서는 정상적으로 적용되지 않음.

     

    위와 같은 이유 때문에 AOP를 위해 동일 클래스를 써야한다면, 내부 static 클래스를 만들고 그 static 클래스를 각각 AOP로 만들어주면 된다. 꼭 내부적으로 같은 클래스로 묶여야 하는 것이 아니라면, 클래스를 2개로 나누어 만들고 각각@Order로 순번을 정해주면 된다. 

     

    AOP 5번 코드 → AOP 순서 설정 완료

    @Order로 어드바이스 간 실행 순서를 설정해주었고, 트랜잭션을 @Order(1)로 설정해주어 로그 AOP보다 더 빨리 실행된 것을 알 수 있다. 

    실제 실행 순서는 다음과 같이 작성된 것을 알 수 있다. 

     

     

    AOP 6번 코드 : 어드바이스 종류에 따른 분리

    @Aspect
    @Slf4j
    public class AspectV6 {
    
    
        // 트랜잭션 구현
    
        @Around("hello.aop.order.aop.MyPointcut.allOrder()")
        public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
            log.info("[log] {}", joinPoint.getSignature().toShortString());
            return joinPoint.proceed();
        }
    
    
        @Before("hello.aop.order.aop.MyPointcut.allService()")
        public void beforeTx(JoinPoint joinPoint) throws Throwable {
            log.info("[트랜잭션 시작] {}", joinPoint.getSignature().toShortString());
        }
    
        @AfterReturning(value = "hello.aop.order.aop.MyPointcut.allOrderAndService()", returning = "result")
        public void afterReturnTx(JoinPoint joinPoint, Object result) throws Throwable {
            log.info("[After Returning] result : {}", result);
            log.info("[트랜잭션 종료] {}", joinPoint.getSignature().toShortString());
        }
    
    
    
        @AfterThrowing(value = "hello.aop.order.aop.MyPointcut.allOrderAndService()", throwing = "ex")
        public void afterThrowingTx(JoinPoint joinPoint, Exception ex) throws Throwable {
            log.info("[After Throw] Catch : {}", ex.getMessage());
            log.info("[트랜잭션 롤백] {}", joinPoint.getSignature().toShortString());
        }
    
    
        @After("hello.aop.order.aop.MyPointcut.allService()")
        public void doTx(JoinPoint joinPoint) throws Throwable {
            log.info("[리소스 릴리즈] {}", joinPoint.getSignature().toShortString());
        }
    
    }
    • @Around 어드바이스는 4개의 어드바이스로 나눌 수 있다.
    • @Before 어드바이스는 Joinpoint 실행 직전에 실행되고, 자동으로 JoinPoint를 실행해준다.
    • @AfterReturning 어드바이스는 JoinPoint 정상 실행 직후에 실행되고, 자동으로 값을 리턴해준다. 이 때, Returning 값을 설정하게 되면 JoinPoint 실행 결과를 받을 수 있다.
    • @AfterThrowing 어드바이스는 JoinPoint 예외 직후에 실행되고, 자동으로 예외를 던져준다. 이 때, Throwing 값을 설정하게 되면 JoinPoint 예외값을 받을 수 있다. 
    • @After 어드바이스는 JoinPoint가 정상 실행, 예외 이후 마지막에 실행된다. 

     

    위처럼 사용한 이유는 @Around 어드바이스를 @Before, @AfterReturning, @AfterThrowing, @After 어드바이스로 나누어 표현할 수 있다는 것을 보여주기 위함이다. 

     

    AOP 6번 코드 실행 결과

    정상적으로 @Around 어드바이스를 @Before, @After, @AfterReturning, @AfterThorwing으로 나눌 수 있는 것을 확인했다. @Around는 강력한 어드바이스지만, 의미 전달이 명확하지 않을 수 있고 JoinPoint를 실행하지 않는 실수를 할 수도 있다. 따라서 필요에 따라 적재적소에 적당한 어드바이스를 넣는 것이 중요하다.

    댓글

    Designed by JB FACTORY