Java/Spring

[Spring] Spring AOP

SeungbeomKim 2023. 8. 15. 17:43

애플리케이션 로직은 큰 틀에서 핵심 기능, 부가 기능 2가지로 나눌 수 있습니다.

 

핵심 기능은 객체가 제공하는 비즈니스 로직이고, 부가 기능은 핵심 기능과 함께 사용되는 로그 추적 로직, 트랜잭션 기능과 같습니다. 부가 기능은 핵심 기능을 보조하기 위해 사용되는 기능입니다.

 

부가 기능은 여러 클래스에 걸쳐서 사용되는데 이를 횡단 관심사라고 하며, 하나의 부가기능이 여러 곳에서 동일하게 사용됨을 의미합니다. 하지만, 이러한 중복된 로직을 반복해서 사용하게 된다면 Refactoring에 있어서 많은 번거로움이 발생하게 됩니다.

 

즉, 이러한 문제점을 해결하기 위해 도입된 기술이 AOP 입니다. AOP가 생기면서 부가 기능을 핵심 기능에서 분리하고 한 곳에서 관리해 줄 수 있게 되었습니다. 그리고 이러한 기술을 적용하기 위해 부가 기능(Advice)과 부가 기능을 어디에 적용할지(Pointcut) 선택하는 기능을 합해서 하나의 모듈로 만들수 있는 애스펙트(Aspect~=Advisor(Pointcut + Advice))라는 개념이 도입되었습니다. 

 

결론적으로 AOP는 앞서 설명드린 횡단 관심사를 깔끔하게 처리(오류 검사 및 처리, 동기화, 성능 최적화(캐싱), 모니터링)하기 위한 목적으로 개발되었습니다.

 

AOP 필수 용어 정리 

  1. 조인 포인트(Join Point)
    • AOP를 적용할 수 있는 지점
    • 스프링 AOP는 프록시 방식을 사용하므로 조인 포인트는 항상 메서드 실행 지점으로 제한
  2. 포인트컷(Pointcut)
    • 조인 포인트 중에서 어드바이스가 적용될 위치를 선별하는 기능
    • 프록시를 사용하는 스프링 AOP는 메서드 실행 지점만 포인트컷으로 선별 가능
    • AspectJ 표현식을 사용해서 지정
  3. 타겟(Target)
    • 어드바이스를 받는 객체, 포인트컷으로 결정
  4. 어드바이스(Advice)
    • 부가 기능
    • 특정 조인 포인트에서 Aspect에 취해지는 조치
    • 어드바이스 종류
      1. @Around: 메서드 호출 전후에 실행, 가장 강력한 어드바이스, 조인 포인트 실행 여부 선택, 반환값 반환, 예외 변환
      2. @Before: 조인 포인트 직전에 실행
      3. @AfterReturning: 조인 포인트가 정상 완료 후 실행
      4. @AfterThrowing: 메서드가 예외를 던지는 경우 실행
      5. @After: 조인포인트가 정상 또는 예외에 관계없이 실행(finally)
  5. 애스펙트(Aspect)
    • 어드바이스, 포인트컷을 모듈화한 것
  6. 어드바이저(Advisor)
    • 1 Advice + 1 Pointcut
    • 스프링 AOP에서만 사용되는 용어
  7. 위빙(Weaving)
    • 포인트컷으로 결정한 타겟 조인 포인트에 어드바이스를 적용하는 것
    • 핵심 코드에 영향을 주지 않고, 부가 기능을 추가할 수 있음
  8. AOP 프록시 
    • AOP 기능을 구현하기 위해 만든 프록시 객체, 스프링에서 AOP 프록시는 JDK 동적 프록시(인터페이스 기반 프록시), CGLIB 프록시(구체클래스 기반 프록시, Default)

 

AOP 의존성 추가

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

 

AOP Example 

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

<AOP 적용 전 테스트>

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입니다.

 

이제 OrderRepository, OrderService에 AOP를 적용해 보겠습니다. 

 

Example 1

@Slf4j
@Aspect
public class AspectV1 {

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

AOP 적용을 위해 @Aspect 어노테이션을 작성하였고, doLog() 메서드는 하나의 부가 기능(Advice)이 됩니다.

execution("* hello.aop.order ..*(..)")은 간단하게 hello.aop.order 패키지와 하위 패키지를 지정하는 AspectJ 표현식입니다. 

표현식 설정을 통해 이제 OrderRepository와 OrderService에 존재하는 모든 메서드는 AOP 대상이 됩니다. Spring에서는 프록시 방식의 AOP를 지원하기에 반드시 프록시를 통하는 메서드에만 AOP 적용 대상이 됩니다.

 

AopTest 추가 

@Slf4j
@SpringBootTest
@Import(AspectV1.class) // 설정파일 추가 외에도 스프링 Bean을 등록하는데 사용하는 어노테이션

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

AspectV1을 AOP로 사용하기 위해서는 스프링 빈으로 등록해야 합니다. @Bean, @Component, @Import 방식이 있는데 저는 @Import 어노테이션을 사용해 스프링 빈을 등록하였습니다. 

 

Example 2

 

@Around에 포인트컷 표현식을 직접 넣어도 되지만, @Pointcut을 통해 별도로 분리가 가능합니다.

@Slf4j
@Aspect
public class AspectV2 {

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


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

다음과 같이 로그가 잘 출력되는 것을 확인할 수 있게 됩니다.

 

Example 3

Advice(Transaction 로직) 추가  

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

 

새로 추가한 어드바이스는 포인트컷 표현식을 통해 트랜잭션은 보통 서비스에서 처리되는 기능이기에 Service로 끝나는 것을 대상으로 지정해 주었습니다.

즉, orderService에는 doLog(), doTransaction() 2개의 어드바이스가 적용되고, OrderRepository에는 doLog() 1개의 어드바이스만 적용됩니다. 

추가적으로, 포인트컷 대상이 여러 개면 &&(AND), ||(OR), !(NOT)과 같이 연산자를 통해 조합할 수 있습니다.  

 

다음 로그를 통해 Service에는 log, transaction 어드바이스가 적용되고, Repository에는 log 어드바이스만 적용되었음을 확인할 수 있습니다. 

 

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

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

포인트컷 지정은 패키지명부터 포인트컷 시그니처까지 모두 지정해 주면 됩니다. 결과는 V3와 동일합니다.

 

Example 5

 

기존 어드바이스의 순서를 바꾸는 방법이 있습니다. 애스펙트를 별도의 클래스로 구분해 주고 @Order 어노테이션을 통해 순서를 지정해 주면 됩니다. 그러면 Transaction을 먼저 실행하고 Log를 출력할 수도 있게 됩니다.

@Slf4j
@Aspect
public class AspectV5Order {

    @Aspect
    @Order(1)
    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(2)
    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());
            }
        }
    }
}

 

Example 6

어드바이스는 앞서 설명드렸듯이 @Around 외에도 @Before, @AfterReturning, @AfterThrowing, @After가 있습니다. @Around가 가장 강력한 기능을 제공하지만, 이들을 사용하는 이유에 대해서도 설명드리겠습니다.

@Slf4j
@Aspect
public class AspectV6Advice {

    @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={}", joinPoint.getSignature(), ex);
    }

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

@Around를 제외한 모든 어드바이스는 Join Point를 생략하거나 첫 번째 파라미터에 사용할 수 있습니다. 하지만, @Around만 ProceedingJoinPoint를 사용해야 합니다. ProceedingJoinPoint의 proceed 메서드를 통해 다음 어드바이스를 호출하게 됩니다. 만약, 이를 호출하지 않으면 다음 어드바이스가 적용이 되지 않고 오류가 발생하게 됩니다. 하지만, @Around를 제외한 다른 어드바이스들은 proceed() 메서드를 호출하지 않아도 되어 이러한 오류를 걱정할 필요도 없어집니다. 더불어, 가독성 측면에서도 @Around보다 다른 어드바이스들이 더욱 직관적입니다. 

 

<참고 자료>

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%95%B5%EC%8B%AC-%EC%9B%90%EB%A6%AC-%EA%B3%A0%EA%B8%89%ED%8E%B8/dashboard

 

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

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

www.inflearn.com