Spirng AOP 구현

hoyong.eom·2023년 9월 25일
0

스프링

목록 보기
48/59
post-thumbnail

Spring

Spring AOP 구현

Spring-AOP를 사용하려면 아래의 의존성이 필요하다.

implementation 'org.springframework.boot:spring-boot-starter-aop'

참고
@Aspect를 사용하려면 @EnableAspectJAutoProxy를 스프링 설정에 추가해야 하지만, 스프링 부트를 사용하면 자동으로 추가된다.

이번 학습을 위한 예제 코드는 아래와 같다.

@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);
    }

}
@Slf4j
@Repository
public class OrderRepository {

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

}
@Slf4j
@SpringBootTest
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);
    }

}

AopUtils.isAopProxy(...)을 통해서 AOP 프록시가 적용되었는지 확인할 수 있다. 현재 AOP 관련 코드를 작성하지 않았기 때문에 프록시가 적용되지 않았고 결과도 false를 반환한다.

위 코드는 현재 아래의 구현과 같다.

스프링 AOP 구현 1 - 시작

스프링 AOP를 구현하는 일반적인 방법은 앞서 학습한 @Aspect를 사용하는 방법이다.

@Slf4j
@Aspect
public class AspectV1 {

    //hello.aop.order 패키지와 하위 패키지
    @Around("execution(* hello.aop.order..*(..))")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature()); //join point 시그니처
        return joinPoint.proceed();
    }
}
  • @Around애노테이션의 값인 execution(* hello.aop.order..*(..))는 포인트컷이 된다.
  • @Around애노테이션의 메서드인 doLog는 어드바이스(Advice)가 된다.
  • execution(* hello.aop.order..*(..))hello.aop.order패키지와 그 하위 패키지(..)를 지정하는 AspectJ포인트컷 표현식이다.
  • 이제 OrderServiceOrderRepository의 모든 메서드는 AOP 적용 대상이 된다. 참고로 스프링은 프록시 방식의 AOP를 사용하므로 프록시를 통하는 메서드만 적용 대상이 된다.

참고
스프링 AOP는 AspectJ의 문법을 차용하고, 프로시 방식의 AOP를 제공한다. AspectJ를 직접사용하는것이 아니다.
스프링 AOP를 사용할 때는 @Aspect애노테이션을 주로 사용하는데 이 애노테이션도 AspectJ가 제공하는 애노테이션이다.

참고
@Aspect를 포함한 org.aspectj패키지 관련 기능은 aspectjweaver.jar라이브러리가 제공하는 기능이다. 앞서 build.gradlespring-boot-starter-aop를 포함하는데, 이렇게 하면 스프링의 AOP관련 기능과 함께 aspectjweaver.jar도 함께 사용할 수 있게 의존 관계에 포함된다.
다만 스프링에서는 AspectJ가 제공하는 애노테이션이나 관련 인터페이스만 사용하는것이고 실제 AspectJ가 제공하는 컴파일, 로드 타임 위버 등을 사용하는것이 아니다. 스프링은 프록시 방식의 AOP를 사용한다.

@Slf4j
@SpringBootTest
@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);
    }

}

@Aspect는 애스펙트라는 표식이지 컴포넌트 스캔이 되는것은 아니다. 따라서 AspectV1를 AOP로 사용하려면 스프링 빈으로 등록해야한다.

다 알고 있지만 스프링 빈으로 등록하는 방법은 다음과 같다.

  • @Bean을 사용해서 직접 등록
  • @Component 컴포넌트 스캔을 사용해서 자동 등록
  • @Import주로 설정 파일을 추가할때 사용(@Configuration)

@Import는 주로 설정 파일을 추가할 때 사용하지만, 이 기능으로 스프링 빈도 등록할 수 있다.

스프링 AOP 구현2 - 포인트컷 분리

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

@Slf4j
@Aspect
public class AspectV2 {

    //hello.aop.order 패키지와 하위 패키지
    @Pointcut("execution(* hello.aop.order..*(..))")
    private void allOrder(){} //pointcut signature

    @Around("allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature()); //join point 시그니처
        return joinPoint.proceed();
    }

}

@Pointcut

  • @Pointcut에 포인트컷 표현식을 사용한다.
  • 메서드 이름과 파라미터를 합쳐서 포인트컷 시그니처라 한다.
  • 메서드의 반환 타입은 void여야 한다.
  • 코드 내용은 비워 둔다.
  • 위 코드에서 포인트컷 시그니처는 allOrder()이다.
  • @Around어드바이스는 포인트컷을 직접 지정해도 되지만, 포인트컷 시그니처를 사용해도 된다. 여기서는 @Around("allOrder()")를 사용한다.
  • private, public같은 접근 제어자는 내부에서만 사용하면 private를 사용해도 되지만 다른 애스펙트를 참고하려면 public을 사용해야한다.

스프링 AOP 구현 3 - 어드바이스 추가

로그를 출력하는 부가 기능 뿐만 아니라 트랜잭션을 적용하는 부가 기능도 추가해보자. 여기서는 진짜 트랜잭션을 실행하는것이 아니다

트랜잭션 기능은 보통 다음과 같이 동작한다.

  • 핵심 로직 실행 직전에 트랜잭션을 시작
  • 핵심 로직 실행
  • 핵심 로직 실행에 문제가 없으면 커밋
  • 핵심 로직 실행에 예외가 발생하면 롤백
@Slf4j
@Aspect
public class AspectV3 {

    //hello.aop.order 패키지와 하위 패키지
    @Pointcut("execution(* hello.aop.order..*(..))")
    private void allOrder(){} //pointcut signature

    //클래스 이름 패턴이 *Service
    @Pointcut("execution(* *..*Service.*(..))")
    private void allService(){}

    @Around("allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature()); //join point 시그니처
        return joinPoint.proceed();
    }

    //hello.aop.order 패키지와 하위 패키지 이면서 클래스 이름 패턴이 *Service
    @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());
        }
    }

}
  • allOrder()포인트컷은 hello.aop.order패키지와 하위 패키지를 대상으로 한다.
  • allService()포인트컷은 타입 이름 패턴이*Service를 대상으로 하는데 쉽게 이야기해서 xxxService처럼 Service로 끝나는것을 대상으로 한다.

@Around("allOrder() && allService()")

  • 포인트컷은 이렇게 조합이 가능하다. &&, ||, !으로 3가지 조합이 가능하다.

  • hello.aop.order패키지와 그 하위 패키지이면서 타입 이름 패턴이 *Service인것을 대상으로 한다.

  • 결과적으로 doTransaction() 어드바이스는 OrderService에만 적용된다.

  • doLog()어드바이스는 OrderService, OrderRepository에 모두 적용된다.

  • OrderService에는 doLog(), doTransaction() 두가지 어드바이스 적용

  • OrderRepository에는 doLog()하나의 어드바이스만 적용

스프링 AOP 구현 4 - 포인트컷 참조

포인트컷을 공용으로 사용하기 위해 별도의 외부 클래스에 모아두어도 된다. 참고로 외부에서 호출할때는 포인트컷의 접근제어자를public으로 열어두어야 한다.


public class Pointcuts {

    //hello.aop.order 패키지와 하위 패키지
    @Pointcut("execution(* hello.aop.order..*(..))")
    public void allOrder(){} //pointcut signature

    //클래스 이름 패턴이 *Service
    @Pointcut("execution(* *..*Service.*(..))")
    public void allService(){}

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

}

orderAndService(): allOrder() 포인트컷과 allService() 포인트컷을 조합해서 새로운 포인트컷을 만들었다.

@Slf4j
@Aspect
public class AspectV4Pointcut {

    @Around("hello.aop.order.aop.Pointcuts.allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("[log] {}", joinPoint.getSignature()); //join point 시그니처
        return joinPoint.proceed();
    }

    @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());
        }
    }

}

사용하는 방법은 패키지명을 포함한 클래스이름과 포인트컷 시그니처를 모두 지정하면 된다. 포인트컷을 여러 어드바이스에서 함께 사용할때 이 방법을 사용하면 효과적이다.

스프링 AOP 구현 5 - 어드바이스 순서

어드바이스는 기본적으로 순서를 보장하지 않는다. 순서를 지정하고 싶으면 @Aspect 적용 단위로 org.springframework.core.annotation.@Order 애노테이션을 적용해야 한다. 문제는 이것을 어드바이스 단위가 아니라 클래스 단위로 적용할 수 있다는 점이다. 그래서 지금 처러 하나의 애스펙트에 여러 어드바이스가 있으면 순서를 보장받을 수 없다. 따라서 애스펙트를 별도의 클래스로 분리해야한다.

현재 예제에서는 doLog() -> doTransaction() 순서로 실행된다.
여기에서 이 순서를 바꾸고자 한다면 별도의 클래스로 분리후에 @Order 애노테이션을 적용한다.

@Slf4j
public class AspectV5Order {

    @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()); //join point 시그니처
            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());
            }
        }
    }


}

하나의 애스펙트 안에 있던 어드바이스를 LogAspect, TxAspect애스펙트로 각각 분리헀다. 그리고 각 애스펙트에 @Order 애노테이션을 통해 실행 순서를 적용했다. 참고로 숫자가 작을 수록 먼저 실행된다.

@Import({AspectV5Order.LogAspect.class, AspectV5Order.TxAspect.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);
    }

}

스프링 AOP 구현 6 - 어드바이스 종류

어드바이스는 앞서 살펴본 @Around외에도 여러 가지 종류가 있다.

어드바이스 종류

  • @Around : 메서드 호출 전후에 수행, 가장 강력한 어드바이스, 조인 포인트 실행 여부 선택, 반환 값 벼환, 예외 변환 등이 가능
  • @Before : 조인포인트 실행 이전에 실행
  • @AfterReturning : 조인포인트가 정상 완료후 실행
  • @AfterThrowing : 메서드가 예외를 던지는 경우 실행
  • @After : 조인 포인트가 정상 또는 예외에 관계 없이 실행(finally)
@Slf4j
@Aspect
public class AspectV6Advice {

    @Around("hello.aop.order.aop.Pointcuts.orderAndService()")
    public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {

        try {
            //@Before
            log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
            Object result = joinPoint.proceed();
            //@AfterReturning
            log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
            return result;
        } catch (Exception e) {
            //@AfterThrowing
            log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
            throw e;
        } finally {
            //@After
            log.info("[리소스 릴리즈] {}", joinPoint.getSignature());
        }
    }

    @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 = "ex")
    public void doThrowing(JoinPoint joinPoint, Exception ex) {
        log.info("[ex] {} message={}", ex);
    }

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

}

doTransaction()메서드에 남겨둔 주석을 보면 아래와 같다.
복잡해보이지만 사실 @Around를 제외한 나머지 어드바이스들은 @Around가 할 수 있는 일의 일부만 제공할 뿐이다. 따라서 @Around어드바이스만 사용해도 필요한 기능을 모두 수행할 수 있다.

참고 정보 획득
모든 어드바이스는 org.aspectj.lang.JoinPoint를 첫번째 파라미터에 사용할 수 있다.(생략해도 된다.)
@ArondProceedingJoinPoint를 사용해야 한다.

참고로 ProceedingJoinPointorg.aspectj.lang.JoinPont의 하위 타입이다.

JoinPoint 인터페이스의 주요 기능

  • getArgs() : 메서드 인수를 반환
  • getThis() : 프록시 객체를 반환
  • getTarget() : 대상 객체를 반환
  • getSignature() : 조언되는 메서드에 대한 설명을 반환

ProceedingJoinPoint 인터페이스의 주요 기능

  • proceed() : 다음 어드바이스나 타겟을 호출

어드바이스 종류

@Before
조인 포인트 실행전

    public void doBefore(JoinPoint joinPoint) {
        log.info("[before] {}", joinPoint.getSignature());
    }

@Around와 다르게 작업 흐름을 변경할 수 없다.
@AroindProceedingJoinPoint.proceed()를 호출해야 다음 대상으로 호출된다. 만약 호출하지 않으면 다음 대상이 호출되지 않는다. 반면에 @BeforeProceedingJoinPoint.proceed()자체를 사용하지 않는다. 메서드 종료시 자동으로 다음 타겟이 호출된다. 물론 예외가 발생하면 다음코드가 호출되지 않는다.

@AfterReturning
메서드 실행이 정상적으로 반환될때 실행

    @AfterReturning(value = "hello.aop.order.aop.Pointcuts.orderAndService()", returning = "result")
    public void doReturn(JoinPoint joinPoint, Object result) {
        log.info("[return] {} return={}", joinPoint.getSignature(), result);
    }
  • returning 속성에 사용된 이름은 어드바이스 메서드의 매개변수 이름과 일치해야한다.
  • returning절에 지정된 타입의 값을 반환하는 메서드만 대상으로 실행한다.(부모타입을 지정하면 모든 자식 타입은 인정된다.)
  • @Around와 다르게 반환되는 객체를 변경할 수 없다. 반환 객체를 변경하려면 @Around를 사용해야 한다. 참고로 반환 객체를 조작할 수 는 있다.

AfterThrowing
메서드 실행이 예외를 던져서 종료될 때 실행

    @AfterThrowing(value = "hello.aop.order.aop.Pointcuts.orderAndService()", throwing = "ex")
    public void doThrowing(JoinPoint joinPoint, Exception ex) {
        log.info("[ex] {} message={}", ex);
    }
  • throwing속성에 사용된 이름은 어드바이스 메서드의 매개 변수 이름과 일치해야한다.
  • throwing절에 지정된 타입과 맞는 예외를 대상으로 실행한다.(부모 타입을 지정하면 모든 자식 타입은 인정된다.)

@After

  • 메서드 실행이 종료되면 실행된다.(finally를 생각하면된다.)
  • 정상 및 예외 반환 조건을 모두 처리한다.
  • 일반적으로 리소스를 해제하는데 사용한다.

@Around

  • 메서드의 실행의 주변에서 실행된다. 메서드 실행 전후에 작업을 수행한다.
  • 가장 강력한 어드바이스
    - 조인 포인트 실행 여부 선택 joinPoint.proceed() 호출 여부 선택
    • 전달 값 변환 : `joinPoint.proceed(args[])
    • 반환 값 변환
    • 예외 값 변환
    • 트랜잭션 처럼 try-catc-finally모두 들어가는 구문 처리 가능
  • 어드바이스의 첫번째 파라미터는 ProceedJoinPoint를 사용해야 한다.
  • proceed()를 통해 대상을 실행한다.
  • proceed()를 여러번 실행할 수도 잇다.(재시도)

순서

  • 스프링은 5.2.7 버전 부터 동일한 @Aspect안에서 동일한 조인포인트의 우선순위를 정했다.
  • 실행 순서 :@Around, @Before, @After, @AfterReturning, @AfterThrowing
  • 어드바이스가 적용되는 순서는 이렇지만, 호출 순서와 리턴 순서는 반대이다.
  • 물론 @Aspect안에 동일한 종류의 어드바이스가 2개 있으면 순서가 되지 않는다. 이경우 앞서 배운것처럼 @Aspect를 분리하고 @Order를 사용해야한다.

@Around 이외의 다른 어드바이스 존재하는 이유
@Around 하나만 있어도 모든 기능을 수행할 수 있다 .그런데 다른 어드바이스들이 존재하는 이유는 무엇일까?

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

위 코드는 문제가 있다. 바로 타겟을 호출하지 않는다.(proceed())
이 코드를 개발한 의도는 타겟 실행전에 로그를 출력하는것이다. 그런데 @Around는 항상 joinPoint.prceed()를 호출해야한다. 만약 실수로 호출하지 않으면 타겟이 호출되지 않는 치명적인 버그가 발생한다.

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

@Before은 joinpoint.proceed()를 호출하는 고민을 하지 않아도 된다.

@Around가 가장 넓은 기능 제공하는것은 맞지만, 실수할 가능성이 있다. 반면에 @Before, @After 같은 어드바이스는 기능은 적지만 실수할 가능성이 낮고 코드도 단순하다. 그리고 가장 중요한 이 코드의 의도가 명확하게 드러난다.

참고

해당 포스팅은 아래의 강의를 학습한 정리한 내용입니다.
김영한님의 SpringAOP-구현

0개의 댓글