BackEnd/Spring

[Spring] 스프링 AOP 구현

샤아이인 2022. 8. 18.

내가 공부한것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼겸 상세히 기록하고 얕은부분들은 가겹게 포스팅 하겠습니다.

 

1. 스프링 AOP 구현1 - 시작

이번 시간에는 @Aspect 를 사용해서 가장 단순한 AOP를 구현해보자.

 

▶ AspectV1

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

@Around 애너테이션의 값인 "execution(* hello.aop.order..*(..))" 는 포인트 컷이 된다.

hello.aop.order 패키지와 그 하위 패키지( .. )지정하는 AspectJ 포인트컷 표현식이다.

 

메서드 부분은 Advice가 된다.

 

이제 OrderService , OrderRepository 의 모든 메서드는 AOP 적용의 대상이 된다.

참고로 스프링은 프록시 방식의 AOP를 사용하므로 프록시를 통하는 메서드만 적용 대상이 된다.

 

Spring은 @Aspect 애너테이션을 주로 사용하는데, 이는 AspectJ가 제공하는 애너테이션 이다.

AspectJ가 제공하는 애너테이션이나, 인터페이스만 사용하고 실제 AspectJ가 제공하는 컴파일, 로드타임 위버 등을 사용하는 것은 아니다.

 

Spring은 Proxy방식의 AOP기술을 사용하기 때문이다.

 

다음과 같이 테스트 코드를 추가해보자.

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

    @Autowired
    OrderService orderService;

    @Autowired
    OrderRepository orderRepository;
    
    @Test
    public 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);
    }
}

결과 확인용으로 사용하는 코드이다. 일종의 Client역할을 한다.

 

@Aspect 는 애스펙트라는 표식이지 컴포넌트 스캔이 되는 것은 아니다.

따라서 AspectV1 AOP로 사용하려면 스프링 빈으로 등록해야 한다.

 

▶ 스프링 빈으로 등록하는 방법 3가지

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

 

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

 

success() 메서드 테스트시 다음과 같이 [log]가 잘 출력되는 것을 확인할 수 있다.

 

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

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

 

▶ AspectV2

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

메서드 이름과 파라미터를 합쳐서 포인트컷 시그니처(signature)라 부른다.

위 코드 상에서 포인트컷 시그니처는 allOrder()에 해당된다.

반환 타입은 void 이여야 하며, 메서드 바디 부분은 비워둔다.

 

즉, 어드바이스를 식별하는 수식에 이름을 부여했다 생각하면 편하다. 이러면 다른곳에서 재사용도 가능해진다.

또한 allOrder()와 같이 포인트컷 시그니처를 사용하면 표현식보다 의미가 더 명확하게 전달된다.

 

private , public 같은 접근 제어자는 내부에서만 사용하면 private 을 사용해도 되지만, 다른 Aspect에서 참고하려면 public 을 사용해야 한다.

 

이렇게 분리하면 하나의 포인트컷 표현식을 여러 어드바이스에서 함께 사용할 수 있게 되었다.

 

이후 Test 코드 부분에 다음과 같이 @Import만 변경시켜주면 된다.

@Import(AspectV2.class)

실행시 정상적으로 동작한다.

 

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

이번에는 앞선 로그 출력 기능에 더 나아가서, Service의 경우 Transactoin 처리를 해줘보자!

진짜 Transaction 처리는 아니구, 기능이 동작하는 것 처럼 문자열만 남겨보자.

 

일반적으로 트랜잭션 기능은 다음 순서로 동작한다.

  1. 핵심 로직 실행 직전에 트랜잭션을 시작
  2. 핵심 로직 실행
  3. 핵심 로직 실행에 문제가 없으면 커밋
  4. 핵심 로직 실행에 예외가 발생하면 롤백

 

이를 처리할 Aspect를 만들어보자.

▶ AspectV3

@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와 같은 패턴을 대상으로 하는 포인트컷 이다.

 

여기서 타입 이름이 패턴이라 한 이유는, 단순히 class 뿐만 아니라, 인터페이스 까지 모두 적용되기 때문이다.

 

포인트컷은 이렇게 조합

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

위와 같이 여러 포인트컷을 조합하여 사용할수가 있다. && (AND), || (OR), ! (NOT) 3가지 조합이 가능하다.

위 포인트 컷을 조합하면, hello.aop.order 패키지와 하위 패키지 이면서 타입 이름 패턴이 *Service 인 것을 대상으로 한게된다.

 

우리의 프로젝트에서는 hello.aop.order의 OrderService에만 트랜잭션 처리가 추가된다.

 

포인트것이 적용된 AOP 결과는 다음과 같다.

orderService : doLog() , doTransaction() 어드바이스 적용

orderRepository : doLog() 어드바이스 적용

 

이제 기존 테스트에서 @Import를 V3버전으로 변경한 후 실행해보자.

위 로직을 그림으로보면 다음과 같다.

출처 - 인프런 김영한 고급폅

log 기능이 먼저 추가된 후, 트랜잭션이 시작된후에 Service기능이 실행된다.

위 로그를 살펴보면서 그림과 함께보면 이해할 수 있다.

 

예외가 발생하는 경우도 살펴보자.

예외 상황에서는 트랜잭션 커밋 대신에 트랜잭션 롤백과 리소스 릴리즈가 출력되는것을 확인할 수 있다.

 

만약 어드바이스를 적용하는 순서를 달리하고 싶다면 어떻게 해야할까?

지금은 log출력 -> 트랜잭션 순인데, 트랜잭션 -> log출력의 순서로 되려면 어떻게 해야 할까?

 

이를 알아보기 전에 잠깐 포인트컷을 외부로 빼서 사용하는 방법을 먼저 알아보자.

 

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

다음과 같이 포인트컷을 공용으로 사용하기 위해 별도의 외부 클래스에 모아두어도 된다.

 

▶ Pointcuts

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

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

위 Class처럼 포인트컷들을 한곳에 모아두고, 다른곳에서 가져다 사용할수가 있다.

 

기존의 Aspect 코드는 다음처럼 수정된다.

▶ AspectV4Pointcut

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

사용하는 방법은 패키지명을 포함한 클래스 이름과 포인트컷 시그니처를 모두 지정하면 된다.

 

이후 @Import문을 수정한 후 실행하면, 기존과 동일하게 정상적으로 수행된다.

 

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

어드바이스는 기본적으로 순서를 보장하지 않는다. 순서를 지정하고 싶으면 @Aspect 적용 단위로 @Order 애너테이션을 적용해야 한다.

문제는 @Order가 Class단위로 적용 가능하다는 점 이다.

 

지금처럼 하나의 애스펙트에 여러 어드바이스가 있으면 순서를 보장 받을 수 없다.

따라서 애스펙트를 별도의 클래스로 분리해야 한다.

 

▶ AspectV5Order

@Slf4j
@Aspect
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 {
        //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());
            }
        }
    }
}

 하나의 Aspect 안에 있던 Advice를 LogAspect , TxAspect로 각각의 Aspect Class로 각각 분리했다.

또한 각각의 Class에 @Order를 적용하게 되었다.

 

이후 테스트코드에 다음과 같이 Import문을 추가해주자.

@Import({AspectV5Order.logAspect.class, AspectV5Order.TxAspect.class})

실행 결과는 다음과 같다.

 

이번에는 우리가 순서를 지정해준것 처럼 트랜잭션 -> 로그 순으로 출력됬다.

 

다음 그림과 같은 것 이다.

출처 - 인프런 김영한 고급폅

 

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

이번 시간에는 여러 어드바이스의 종류에 대하여 학습해보자.

 

@Around : 메서드 호출 전후에 수행, 가장 강력한 어드바이스, 조인 포인트 실행 여부 선택, 반환 값 변환, 예외 변환 등이 가능

@Before : 조인 포인트 실행 이전에 실행

@AfterReturning : 조인 포인트가 정상 완료후 실행

@AfterThrowing : 메서드가 예외를 던지는 경우 실행
@After : 조인 포인트가 정상 또는 예외에 관계없이 실행(finally)

 

복잡해 보이지만 @Around 를 제외한 나머지 5개의 어드바이스들은 @Around 가 할 수 있는 일의 일부만 제공할 뿐이다.

따라서 @Around 어드바이스만 사용해도 필요한 기능을 모두 수행할 수 있다.

 

우선 코드로 순서를 한번 살펴보자.

 

▶ AspectV6Advice

@Slf4j
@Aspect
public class AspectV6Advice {

    //hello.aop.order 이하의 패키지 이면서 클래스 이름 패턴이 *Service
    @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={}", joinPoint.getSignature(), ex.getMessage());
    }

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

우선 실행결과를 보면 다음과 같다.

실행 흐름을 그림으로 살펴보면 다음과 같다.

출처 - 인프런 김영한 고급편

스프링은 5.2.7 버전부터 동일한 @Aspect 안에서 동일한 조인포인트의 우선순위를 정했다.

실행 순서: @Around , @Before , @After , @AfterReturning , @AfterThrowing

물론 @Aspect 안에 동일한 종류의 어드바이스가 2개 있으면 순서가 보장되지 않는다.

이 경우 앞서 배운 것 처럼 @Aspect 를 분리하고 @Order 를 적용하자.

 

어드바이스 종류에 대하여 좀더 자세하게 알아보자!

 

▶ 어드바이스 종류

 

@Before : 조인 포인트 실행 전

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

@Around는 proceed를 호출해야 다음 대상이 호출된다. 만약 호출하지 않으면 다음 대상이 호출되지 않는다.

반면 @Before 는메서드 종료시 자동으로 다음 타켓이 호출된다.

물론 예외가 발생하면 다음 코드가 호출되지는 않는다.

 

@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 속성에 사용된 이름은 어드바이스 메서드의 매개변수 이름과 일치해야 한다.

 

또한 파라미터에서 result의 타입 또한 중요하다. 예를 들어 다음과 같이 String 타입이였다고 가정해보자.

public void doReturn(JoinPoint joinPoint, String result)

 이렇게 되면 AfterReturning이 실행조차 안된다.

[return] 문 부분이 사라진것을 확인 할 수 있다.

 

왜 이렇게 작동할까? 우리의 Advice는 OrderService에 적용이 된다.

하지만 다음과 같이 OrderService의 orderItem의 반환형이 void 이다.

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

 즉 반환되는 값이 없어서 String 으로 받을수가 없는 것 이다.

Object같은 경우 null을 사용해서라도 해결할수가 있다.

 

예시를 하나 더 들어보자. 다음과 같은 doReturn2를 추가해보자.

@AfterReturning(value = "hello.aop.order.aop.Pointcuts.allOrder()", returning = "result")
public void doReturn2(JoinPoint joinPoint, String result) {
    log.info("[return] {} return={}", joinPoint.getSignature(), result);
}

 

위 경우 다음과 같이 OrderRepository에서 String을 반환하기 때문에 실행이 된다.

public class OrderRepository {

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

 

실행 결과는 다음과 같다. 성공적으로 "ok"문을 확인할수 있다.

부모타입을 지정해도 성공적으로 동작한다.

 

추가적으로 @Around 와 다르게 반환되는 객체를 변경할 수는 없다.

 

예를 들어 A를 반환할 것을 B를 반환하도록 할수는 없다.

반환 객체를 변경하려면 @Around 를 사용해야 한다. 참고로 반환 객체의 상태는 변경시킬수가 있다.

 

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

@AfterThrowing(value = "hello.aop.order.aop.Pointcuts.orderAndService()", throwing = "ex")
public void doThrowing(JoinPoint joinPoint, Exception ex) {
    log.info("[ex] {} message={}", joinPoint.getSignature(), ex.getMessage());
}

throwing 속성에 사용된 이름은 어드바이스 메서드의 매개변수 이름과 일치해야 한다.

throwing 절에 지정된 타입과 맞은 예외를 대상으로 실행한다. (부모 타입을 지정하면 모든 자식 타입은 인정된다.)

 

@After : 메서드 실행이 종료되면 실행된다.

(finally를 생각하면 된다.) 정상 및 예외 반환 조건을 모두 처리한다.
일반적으로 리소스를 해제하는 데 사용한다.

 

@Around :

메서드의 실행의 주변에서 실행된다. 메서드 실행 전후에 작업을 수행한다. 가장 자주 사용하는 어드바이스 이다.

  • 조인 포인트 실행 여부 선택 joinPoint.proceed() 호출 여부 선택
  • 전달 값 변환: joinPoint.proceed(args[])
  • 반환 값 변환
  • 예외 변환
  • 트랜잭션 처럼 try ~ catch~ finally 모두 들어가는 구문 처리 가능

어드바이스의 첫 번째 파라미터는 ProceedingJoinPoint 를 사용해야 한다.

proceed() 를 통해 대상을 실행한다.

proceed() 를 여러번 실행할 수도 있음(재시도)


참고 정보 획득

Advice를 잘 살펴보면 JoinPoint 를 첫번째 파라미터로 사용하고 있다.

하지만 @Around ProceedingJoinPoint 을 사용해야 한다. ProceedingJoinPoint는 JoinPoint를 상속받고 있다.

 

JoinPoint의 인터페이스를 보면 다음과 같은 기능을 제공한다.

  • getArgs() : 메서드 인수를 반환합니다.
  • getThis() : 프록시 객체를 반환합니다.
  • getTarget() : 대상 객체를 반환합니다.
  • getSignature() : 조언되는 메서드에 대한 설명을 반환합니다.
  • toString() : 조언되는 방법에 대한 유용한 설명을 인쇄합니다.

ProceedingJoinPoint는 여기에 타겟을 호출하는 proceed() 메서드가 추가된 것 이다.


그럼 @Around 말고 다른 Advice가 존재하는 이유는 무엇일까?

@Around 가 가장 넓은 기능을 제공하는 것은 맞지만, 실수할 가능성이 있다.

@Around 는 항상 joinPoint.proceed() 를 호출해야 한다. 만약 실수로 호출하지 않으면 타켓이 호출되지 않는 치명적인 버그가 발생한다

 

반면에 @Before , @After 같은 어드바이스는 기능은 적지만 실수할 가능성이 낮고, 코드도 단순하다.

그리고 가장 중요한 점이 있는데, 바로 이 코드를 작성한 의도가 명확하게 들어난다는 점이다.

@Before 라는 애노테이션을 보는 순간 ~ 이 코드는 타켓 실행 전에 한정해서 어떤 일을 하는 코드구나 라는 것이 들어난다.

'BackEnd > Spring' 카테고리의 다른 글

[Spring] 스프링 AOP - 포인트컷 - 2  (0) 2022.08.20
[Spring] 스프링 AOP - 포인트컷 - 1  (0) 2022.08.19
[Spring] 스프링 AOP 개념  (0) 2022.08.17
[Spring] @Aspect AOP  (0) 2022.08.16
[Spring] 빈 후처리기 - 2  (0) 2022.08.16

댓글