본문 바로가기

Spring

스프링 AOP

스프링 AOP 개념

애플리케이션의 실행로직은 크게 핵심기능과 부가기능으로 나뉘어진다. 이 부가기능을 보통 횡단관심사(cross-cutting-concerns)으로 부른다. 예를 들어 주문이라는 기능을 구현하기 위해 orderService라는 클래스를 만들었을 때 주문 로직은 핵심기능이다. 여기에 로그나 트랜잭션 기능을 추가하는 것은 부가기능을 더하는 것이라 볼 수 있다.

이 부가기능을 적용하는 것이 간단한 문제일까?

  • 이 질문에 대해 여러 상황을 생각해보자. 우리는 부가기능이라고 하는 기능을 여러 핵심기능에 적용할 수 있을 것이다.
    • 100개의 핵심기능에 로그나 트랜잭션을 추가하려고 한다면 쉽게 생각하면 100개의 부가기능로직을 각각의 소스코드에 추가해주어야 한다.
    • 부가기능이 변경되는 상황이 발생하면 어떻게 해야할까?
      • 예를들어 로그 포맷을 변경하고 싶을 때
    • 부가기능을 적용하는 대상을 변경하고 싶을때는 어떻게 해야할까?
      • 컨트롤러에만 부가기능 적용 or 주문 로직은 로그를 제외하고 싶을 때
  • 위와 같은 상황에서 중복과 반복이 너무나 많이 발생한다.

 

Aspect

이러한 부가기능 문제를 해결하기 위해서 Aspect라는 개념이 등장했다. Aspect는 부가기능 그 자체와 어디에 적용할 것인지 선택하는 기능을 합쳐서 모듈로 만든 것이다. 그리고 이러한 Aspect를 이용한 프로그래밍 기법을 Aspect-Oriented-Programming 관점지향 프로그래밍이라고 한다.

 

AspectJ 프레임워크

  • AOP의 대표적인 구현이다. (https://www.eclipse.org/aspectj)
    • 자바 프로그래밍 언어에 대한 관점 지향 프로그래밍
    • 횡단 관심사의 모듈화

 

AOP 적용 방식의 세 가지

  1. 컴파일 시점
  2. 클래스 로딩 시점
  3. 런타임 시점(프록시)

 

1. 컴파일 시점 (컴파일 타임 위빙)

  • java 컴파일 타임에 .java 파일을 .class 로 만드는 과정에서 부가기능을 추가한다. 따라서 java byte code(.class)파일에 대해 디컴파일 해보면 애스펙트 관련 호출 코드를 확인할 수 있다.
  • 컴파일 시에 여러 설정을 해주어야 하므로 적용하기 까다롭고 복잡하다.

2. 클래스 로딩 시점 (로딩 타임 위빙)

  • 이 때도 실행 시점에 다양한 옵션을 주어서 aop를 적용하는 방식이다. (번거롭다.)
  • 모니터링 툴들은 보통 이 방식을 사용하여 기능을 제공한다.

3. 런타임 시점

  • 스프링과 같은 프레임워크의 도움을 받아서 런타임에 프록시를 이용해서 AOP를 적용한다.
  • 복잡한 컴파일, 실행 옵션등을 사용하지 않고 적용할 수 있다.

 

적용 방식의 차이

  • 컴파일 타임 : 실제 코드에 부가기능이 추가된다. AspectJ를 직접 사용한다.
  • 클래스 로드 타임 : 실제 코드에 부가기능이 추가된다. AspectJ를 직접 사용한다.
  • 런타임 : 실제 코드가 유지된다. 프록시를 사용한다.

 

AOP 적용 위치

  • Join Point : AOP에서 부가기능을 적용할 수 있는 지점을 조인 포인트라고 한다.
    • 대표적으로 메서드 실행, 생성자, 필드 값 접근, static 메서드 접근 등이 조인포인트이다.
  • AspectJ를 직접 사용하면 바이트 코드를 조작할 수 있기 때문에 위의 모든 조인포인트에 부가기능을 적용할 수 있다. 하지만 프록시를 사용하는 방식에서는 조인 포인트는 메서드 실행 시점으로 제한된다.
  • 그치만 스프링 AOP 로도 대부분의 문제를 해결할 수 있다.

 

용어 정리

  • 조인 포인트 : AOP를 적용할 수 있는 지점을 의미한다.
    • 메서드 실행 시점, static 메서드, 생성자, 필드 값 등
  • 포인트 컷 : 부가기능을 적용할 대상을 선택하는 기능
  • 타겟 : 부가기능을 적용할 대상 (프록시를 적용할 대상). 즉 실제 객체
  • 어드바이스 : 부가기능 그 자체
  • Aspect : 부가기능과 이 부가기능을 적용할 대상을 선택하는 기능을 합해서 모듈화한 것 (어드바이스와 포인트컷을 합하여 모듈화)
  • 어드바이저 : 하나의 어드바이스와 하나의 포인트컷으로 구성
  • 위빙 : 포인트컷으로 결정한 타겟에 어드바이스(부가기능)을 적용한 것
    • AOP 적용을 위해서 애스펙트를 타겟에 연결한 상태
    • 컴파일 타임
    • 로드 타임
    • 런타임
  • AOP 프록시 : AOP 기능을 구현하기 위해 만든 프록시. 스프링에서는 JDK 동적 프록시와 CGLIB 중 하나로 만들어진다.

 

스프링 AOP 구현

스프링 AOP를 이해하기위해 예제코드를 살펴보자.

예제

  • 먼저 정말 간단한 OrderRepository, OrderService이다.
    • 주문을 하면 orderService.orderItem => orderRepository.save()가 호출된다.
    • 실제 저장하지 않고 로그만 찍고 리턴한다.
@Repository
public class OrderRepository {

    public String save(String itemId) {

        log.info("[orderRepository] 실행");
        if (itemId.equals("ex")) {
            throw new IllegalStateException("예외 발생!");
        }
        return "ok";
    }
}

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

  • 이제 여기에 부가기능을 추가해보자

 

1. 애스펙트를 사용하여 로그 추가하기

@Aspect
public class AspectV1 {

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

  • 로그를 추가하기 위해 간단한 Aspect 클래스를 만들었다.
  • @Aspect 어노테이션은 대상 클래스가 Aspect라는 것을 표시한다. (빈으로 등록해주지는 않는다.)
  • @Around 어노테이션은 포인트컷 역할을 한다. 적용 대상 클래스를 지정한다.
    • 위의 예제 코드에서는 hello.aop.order 패키지의 모든 클래스, 메서드를 대상으로 지정한다.
  • 간단하게 로그를 찍고 joinPoint.proceed() 메서드로 타겟 객체를 호출한다.
  • 테스트
@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() {
        assertThatThrownBy(() -> orderService.orderItem("ex"))
                .isInstanceOf(IllegalStateException.class);
    }
}

  • Spring 프레임워크는 @Aspect 어노테이션이 붙어있다고 자동으로 빈 등록해주지 않는다. 따라서 AspectV1 클래스를 빈으로 등록해줘야 하는데 위의 테스트코드에서는 @Import 를 사용해서 빈으로 등록해줬다.
  • 위의 테스트 결과를 예상해보자
    • aopInfo 테스트 결과 orderService, orderRepository 모두 Aop프록시로 등록된 것을 확인할 수 있다. (Import를 제외하면 당연히 프록시가 아닌 실제 객체로 등록된다.)
    • success와 exception 모두 테스트가 성공하고 로그가 잘 찍히는 것을 확인할 수 있다.

 

2. 포인트컷을 분리하기

@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()); //join point signature
        return joinPoint.proceed();
    }
}

  • 위의 코드와 같이 Pointcut 어노테이션을 이용하여 포인트컷 로직(대상을 지정하는 로직)을 분리할 수 있다.
  • 그리고 메서드 시그니처를 @Around("")의 인자로 전달하여 호출할 수 있다. (관심사 분리)
  • 모든 기능은 동일하기 때문에 이전의 테스트 코드의 결과는 같다.

 

3. 포인트컷 추가와 조합

@Aspect
public class AspectV3 {

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

  • allService라는 포인트컷을 하나 추가했다.
    • 패턴은 아직 익숙하지 않겠지만 모든 패키지의 Service로 끝나는 클래스의 모든 메서드를 대상으로 지정하고 있음을 추측할 수 있다.
  • doTransaction이라는 어드바이스도 추가를 했다.
    • 이 어드바이스에서는 트랜잭션과 관련된 로그만을 추가했다.
    • @Around 어노테이션의 인자를 살펴보면 && 라는 키워드로 포인트컷을 조합하고 있다.
      • 포인트컷 표현식에서는 &&(and), ||(or), !(not)을 사용하여 조합할 수 있다.
    • 위의 상황에서는 주문 패키지 아래의 서비스를 타겟 대상으로 어드바이스를 적용하게 된다.
    • joinPoint.proceed() 메서드로 타겟을 실행시키고 그 전과 후에 트랜잭션의 시작과 커밋 로그를 추가한다.
    • catch{} 구문에서 익셉션 발생시 롤백, finally {} 구문에서는 리소스 릴리즈에 대한 로그를 추가한다.
  • 테스트를 실행하면 orderService 에서는 로그와 트랜잭션이 모두 적용된 것을 확인할 수 있고 orderRepository 에서는 로그만 적용된 것을 볼 수 있다.

 

4. 포인트컷을 다른 클래스로 분리

public class Pointcuts {
    @Pointcut("execution(* hello.aop.order..*(..))")
    public void allOrder() {
    } //pointcut signature

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

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

  • 위와 같이 포인트컷을 다른 클래스로 분리할 수 있다.
    • 다른 클래스로 분리했을 때는 포인트컷을 사용할 때 패키지부터 클래스 메서드 까지 표현해줘야 한다.
    • 다른 클래스에서 접근할 수 있게 public 으로 접근제어자를 세팅해줘야 한다.
  • 테스트의 결과는 이전과 동일하다.

 

5. 애스펙트의 순서 정하기

  • 이전의 예제에서 로그와 트랜잭션 부가기능의 순서를 정하고 싶을때는 어떻게 해야할까?
  • @Order 라는 어노테이션을 이용해서 순서를 정할 수 있다.
    • 단, @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 signature
            return joinPoint.proceed();
        }
    }

    @Aspect
    @Order(2)
    public static class TxAspect{
        //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());
            }
        }
    }

}

  • 테스트에서도 Order에 따라 부가기능의 실행 순서가 바뀌는 것을 확인할 수 있다.

 

6. 어드바이스 종류

  • @Around : 메서드 실행 전/후에 실행한다.
    • 가장 강력한 어드바이스이다.
    • 실행 흐름을 제어할 수 있다
    • 파라미터를 변환할 수 있다.
    • 객체를 변환하여 반환할 수 있다.
    • 예외를 변환할 수 있다.
  • @Before : 조인포인트 실행 이전에 실행하는 부가기능
    • 실행 흐름을 제어할 수 없다.
  • @AfterReturning : 조인포인트 실행 정상 완료 이후에 실행하는 부가기능
  • @AfterThrowing(catch) : 조인포인트 메서드 예외 발생시 처리하는 부가기능
  • @After(finally) : 조인포인트 실행시 정상/예외에 관계없이 이후에 실행
  • 차이점
    • @Around 에서는 ProceedingJoinPoint 객체를 인자로 받아서 말 그대로 proceed() 메서드로 실행을 제어할 수 있다.
    • 반면 나머지는 JoinPoint 라는 객체를 인자로 받아서 작업 흐름을 제어할 수 없다.
@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] {} result={}", 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, ex.getMessage());
    }

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

 

순서

  • 아래의 그림은 어드바이스가 적용되는 순서를 나타낸다.
  • 적용되는 순서는 그림과 같지만 호출 순서와 응답 순서는 반대이므로 아래의 숫자의 순서로 호출/응답이 나타난다.

  • Advice의 종류를 살펴보면 @Around 만 있으면 모든 AOP에 대해 해결가능하니 다른 종류의 어드바이스는 필요없다고 생각할 수 있다.
  • 하지만 실행흐름을 제어할 수 있는 @Around는 proceed()를 호출하지 않는다면 실제객체가 실행되지 않는 문제가 발생한다. 또한 joinPoint 실행 전과 후에 어떤 로직이 들어가는지 코드를 이해하는데에 더 많은 시간이 필요하다.
  • 다른 종류의 Advice들은 강력한 제어, 변환 기능은 없지만 명확하게 적용되는 시점을 알 수 있고 안전하게 적용할 수 있다.
    • 이러한 제약이 있어서 실수를 미연에 방지할 수 있다.

 

Reference

  • 스프링 핵심 원리 - 고급편, 김영한