본문 바로가기

Spring/Core

[Core] AOP 구현 - 심화

이전 장(링크) 에서는 스프링 AOP를 적용하는 기본적인 방법에 대해서 알아보았다.
이번 장에서는 복잡한 상황에서 스프링 AOP를 적용하는 방법을알아보도록 한다.
모든 코드는 깃허브(링크) 에 올려두었다.


어드바이스 추가

마치 트랜잭션 기능이 추가되는 것과 같이 로그를 출력해보도록 한다.
트랜잭션 기능은 일반적으로 아래와 같은 기능을 제공해야 한다.

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

    @Pointcut("execution(* com.roy.spring.myaop.order..*(..))")
    private void allOrder() {
    }

    @Pointcut("execution(* *..*Service.*(..))")
    private void allService() {
    }

    @Around("allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {

        log.info("[log] {}", joinPoint.getSignature());
        return joinPoint.proceed();
    }

    @Around("allOrder() && allService()")
    public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {

        try {
            log.info("[Transaction Start] {}", joinPoint.getSignature());
            Object result = joinPoint.proceed();
            log.info("[Transaction Commit] {}", joinPoint.getSignature());
            return result;
        } catch (Exception exception) {
            log.info("[Transaction Rollback] {}", joinPoint.getSignature());
            throw exception;
        } finally {
            log.info("[Resource Release] {}", joinPoint.getSignature());
        }
    }
}
  • allOrder() 포인트컷은 com.roy.spring.myaop.order패키지와 하위 패키지를 대상으로 한다.
  • allService() 포인트컷은 타입 이름 패턴이 *Service*를 대상으로 하는데 쉽게 이야기해서 XxxService처럼 Service로 끝나는 것을 대상으로 한다. *Service와 같은 패턴도 가능하다.
    "타입 이름 패턴" 이라고 표현한 이유는 클래스, 인터페이스에 모두 적용이 되기 때문이다.
  • @Around("allOrder() && allService()")와 같이 포인트컷을 조합할 수 있다. &&, !!, ! 3가지 조합이 가능하다.
  • com.roy.spring.myaop.order 패키지와 하위 패키지이고 타입 이름 패턴이 *Service인 것을 대상으로 한다.
  • 결과적으로 doTransaction() 어드바이스는 OrderService에만 적용된다.
  • doLog() 어드바이스는 OrderService, OrderRepository에 모두 적용된다.

테스트 코드를 수정하여 AspectVersion4가 빈으로 등록될 수 있도록 한다.

@Slf4j
@SpringBootTest
// @Import(AspectVersion1.class)
// @Import(AspectVersion2.class)
@Import(AspectVersion3.class)
public class AopTest {
    // ...
}

successTest() 테스트를 실행하면 우리가 예상한대로 프록시가 적용되어 있으며 트랜잭션 관련 로그가 출력되는 것을 확인할 수 있다.

[log] void com.roy.spring.myaop.order.OrderService.orderItem(String)
[Transaction Start] void com.roy.spring.myaop.order.OrderService.orderItem(String)
[orderService] 실행
[log] String com.roy.spring.myaop.order.OrderRepository.save(String)
[orderRepository] 실행
[Transaction Commit] void com.roy.spring.myaop.order.OrderService.orderItem(String)
[Resource Release] void com.roy.spring.myaop.order.OrderService.orderItem(String)

AOP 적용 전 Process: 클라이언트 -> orderService.orderItem() -> orderRepository.save()
AOP 적용 후 Process: 클라이언트 -> [doLog() -> doTransaction()] -> orderService.orderItem() -> [doLog()] -> orderRepository.save()

orderService에는 doLog(), doTransaction() 두 가지 어드바이스가 적용되어 있고, orderRepository에는 doLog() 하나의 어드바이스만 적용된 것을 확인할 수 있다.
트랜잭션 처리 확인을 위해 exceptionTest() 테스트를 실행시키고 출력을 확인해본다.

[log] void com.roy.spring.myaop.order.OrderService.orderItem(String)
[Transaction Start] void com.roy.spring.myaop.order.OrderService.orderItem(String)
[orderService] 실행
[log] String com.roy.spring.myaop.order.OrderRepository.save(String)
[orderRepository] 실행
[Transaction Rollback] void com.roy.spring.myaop.order.OrderService.orderItem(String)
[Resource Release] void com.roy.spring.myaop.order.OrderService.orderItem(String)

예외 상황에서는 "트랜잭션 커밋"이 아니라 "트랜잭션 롤백"이 호출되는 것을 확인할 수 있다.


포인트컷 참조

포인트컷을 공용으로 사용하기 위해 아래와 같이 접근 제어를 public으로 하고 하나의 클래스로 모아두는 것이 가능하다.

Pointcuts

public class Pointcuts {

    @Pointcut("execution(* com.roy.spring.myaop..*(..))")
    public void allOrder() {}

    @Pointcut("execution(* *..*Service.*(..))")
    public void allService() {}

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

AspectVersion4Pointcut

@Slf4j
@Aspect
public class AspectVersion4Pointcut {

    @Around("com.roy.spring.myaop.order.aop.Pointcuts.allOrder()")
    public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {

        log.info("[log] {}", joinPoint.getSignature());
        return joinPoint.proceed();
    }

    @Around("com.roy.spring.myaop.order.aop.Pointcuts.orderAndService()")
    public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {

        try {
            log.info("[Transaction Start] {}", joinPoint.getSignature());
            Object result = joinPoint.proceed();
            log.info("[Transaction Commit] {}", joinPoint.getSignature());
            return result;
        } catch (Exception exception) {
            log.info("[Transaction Rollback] {}", joinPoint.getSignature());
            throw exception;
        } finally {
            log.info("[Resource Release] {}", joinPoint.getSignature());
        }
    }
}

orderAndService(): allOrder() 포인트컷과 allService() 포인트컷을 조합해서 새로운 포인트컷을 만들었다.
이러한 방식의 사용방법은 패키지명을 포함한 클래스 이름과 포인트컷 시그니처를 모두 지정하면 된다.
포인트컷을 여러 어드바이스에서 함께 사용할 때 이러한 방식을 사용하여 재사용하면 효율적으로 사용할 수 있다.

테스트 코드를 수정하여 AspectVersion4Pointcut이 스프링 빈으로 등록될 수 있게 한다.

@Slf4j
@SpringBootTest
// @Import(AspectVersion1.class)
// @Import(AspectVersion2.class)
// @Import(AspectVersion3.class)
@Import(AspectVersion4Pointcut.class)
public class AopTest {
    // ...
}

테스트 코드의 결과는 기존과 같다.


어드바이스 적용 순서 제어

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

현재 로그를 남기는 순서는 [doLog() -> doTransaction()]이며 매번 실행마다 순서가 변경될 수 있다. 이러한 순서 변경은 JVM이나 실행 환경에 따라 달라질 수 있다.

로그를 남기는 순서를 바꿔서 [doTransaction() -> doLog()]로 변경한다. 트랜잭션이 먼저 처리되고, 이후에 로그가 남도록 변경해본다.

AspectVersion5Order

@Slf4j
public class AspectVersion5Order {

    @Aspect
    @Order(2)
    public static class LogAspect {

        @Around("com.roy.spring.myaop.order.aop.Pointcuts.allOrder()")
        public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {

            log.info("[log] {}", joinPoint.getSignature());
            return joinPoint.proceed();
        }
    }

    @Aspect
    @Order(1)
    public static class TxAspect {

        @Around("com.roy.spring.myaop.order.aop.Pointcuts.orderAndService()")
        public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {

            try {
                log.info("[Transaction Start] {}", joinPoint.getSignature());
                Object result = joinPoint.proceed();
                log.info("[Transaction Commit] {}", joinPoint.getSignature());
                return result;
            } catch (Exception exception) {
                log.info("[Transaction Rollback] {}", joinPoint.getSignature());
                throw exception;
            } finally {
                log.info("[Resource Release] {}", joinPoint.getSignature());
            }
        }
    }
}

하나의 애스펙트 안에 있던 어드바이스를 LogAspect, TxAspect 애스펙트로 각각 분리했다. 그리고 애스펙트에 @Order 애너테이션을 통해 실행 순서를 적용했다.
테스트 코드를 수정하여 AspectVersion5Order가 스프링 빈으로 등록될 수 있도록 한다.

AopTest

@Slf4j
@SpringBootTest
// @Import(AspectVersion1.class)
// @Import(AspectVersion2.class)
// @Import(AspectVersion3.class)
// @Import(AspectVersion4Pointcut.class)
@Import({ AspectVersion5Order.LogAspect.class, AspectVersion5Order.TxAspect.class })
public class AopTest {
    // ...
}

테스트 코드를 실행시켜 출력을 살펴보면 트랜잭션 어드바이스가 먼서 실행되는 것을 확인할 수 있다.

[Transaction Start] void com.roy.spring.myaop.order.OrderService.orderItem(String)
[log] void com.roy.spring.myaop.order.OrderService.orderItem(String)
[orderService] 실행
[log] String com.roy.spring.myaop.order.OrderRepository.save(String)
[orderRepository] 실행
[Transaction Commit] void com.roy.spring.myaop.order.OrderService.orderItem(String)
[Resource Release] void com.roy.spring.myaop.order.OrderService.orderItem(String)


어드바이스 종류

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

  • @Around: 메서드 호출 전후에 수행, 가장 강력한 어드바이스로 조인 포인트 실행 여부 선택, 반환 값 변환, 예외 변환 등이 가능하다.
  • @Before: 조인 포인트 실행 이전에 실행된다.
  • @After: 조인 포인트가 정상 또는 예외에 관계없이 실행된다.(finally와 유사하다.)

AspectVersion6Advice

@Slf4j
@Aspect
public class AspectVersion6Advice {

    @Around("com.roy.spring.myaop.order.aop.Pointcuts.orderAndService()")
    public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {

        try {
            // @Before
            log.info("[Transaction Start] {}", joinPoint.getSignature());
            Object result = joinPoint.proceed();
            // @AfterReturning
            log.info("[Transaction Commit] {}", joinPoint.getSignature());
            return result;
        } catch (Exception exception) {
            // @AfterThrowing
            log.info("[Transaction Rollback] {}", joinPoint.getSignature());
            throw exception;
        } finally {
            // @After
            log.info("[Resource Release] {}", joinPoint.getSignature());
        }
    }

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

    @AfterReturning(value = "com.roy.spring.myaop.order.aop.Pointcuts.orderAndService()", returning = "result")
    public void doAfterReturn(JoinPoint joinPoint, Object result) {
        log.info("[return] {} return = {}", joinPoint.getSignature(), result);
    }

    @AfterThrowing(value = "com.roy.spring.myaop.order.aop.Pointcuts.orderAndService()", throwing = "exception")
    public void doAfterThrowing(JoinPoint joinPoint, Exception exception) {
        log.info("[exception] {}, message = {}", exception, exception.getMessage());
    }

    @After("com.roy.spring.myaop.order.aop.Pointcuts.orderAndService()")
    public void doAfter(JoinPoint joinPoint) {
        log.info("[after] {}", joinPoint.getSignature());
    }
}

@Around를 어떻게 구성하느냐에 따라 다른 어드바이스를 사용하지 않고 원하는 시점에 원하는 작업을 할 수 있다.
즉, @Around만 사용하더라도 다른 어드바이스는 사용하지 않아도 좋다.
개인적은 생각이지만 @Around를 제외한 어드바이스들은 실행시점이 직관적이기 때문에 코드를 작성한 의도가 정확하게 전달하기 쉬울 것으로 생각된다.

참고 정보 획득

모든 어드바이스는 org.aspectj.lang.JoinPoint를 첫번째 파라미터에 사용할 수 있으며 생략해도 좋다.
단, @AroundProceedingJoinPoint를 사용해야 한다.
참고로 ProceedingJoinPointorg.aspectj.lang.JoinPoint의 하위 타입이다.

JoinPoint 인터페이스의 주요 기능

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

ProceedingJoinPoint 인터페이스의 주요 기능

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

어드바이스 종류

@Before: 조인 포인트 실행 전

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

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


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

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

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

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

@After

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

@Around

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

테스트 코드를 수정하여 AspectVersion6Advice가 스프링 빈으로 등록될 수 있도록 수정한다.

AopTest

@Slf4j
@SpringBootTest
// @Import(AspectVersion1.class)
// @Import(AspectVersion2.class)
// @Import(AspectVersion3.class)
// @Import(AspectVersion4Pointcut.class)
// @Import({ AspectVersion5Order.LogAspect.class, AspectVersion5Order.TxAspect.class })
@Import(AspectVersion6Advice.class)
public class AopTest {
    // ...
}

출력되는 결과는 아래와 같다.

[Transaction Start] void com.roy.spring.myaop.order.OrderService.orderItem(String)
[before] void com.roy.spring.myaop.order.OrderService.orderItem(String)
[orderService] 실행
[orderRepository] 실행
[return] void com.roy.spring.myaop.order.OrderService.orderItem(String) return = null
[after] void com.roy.spring.myaop.order.OrderService.orderItem(String)
[Transaction Commit] void com.roy.spring.myaop.order.OrderService.orderItem(String)
[Resource Release] void com.roy.spring.myaop.order.OrderService.orderItem(String)

실행되는 순서는 아래와 같다.

  • 스프링 5.2.7 버전부터 동일한 @Aspect안에서 동일한 조인 포인트의 우선순위를 정했다.
  • 실행 순서: @Around -> @Before -> @After -> @AfterReturning -> @AfterThrowing
  • 어드바이스가 적용되는 순서는 이렇게 적용되지만, 호출 순서와 응답 순서는 반대이다.
  • @Aspect안에 동일한 종류의 어드바이스가 2개 있으면 순서가 보장되지 않는다. 이러한 경우 @Aspect를 분리하고 @Order를 적용해야 한다.

@Around 외에 다른 어드바이스가 존재하는 이유
@Around 하나만 있어도 모든 기능을 수행할 수 있다. 여러 종류의 어드바이스가 존재하는 이유를 알아보도록 한다.

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

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

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

반면 @BeforejoinPoint.proceed()를 호출하지 않아도 된다.

@Around가 가장 넓은 기능을 제공하는 것이 많지만 제약이 적어서 실수할 가능성이 있다. 반면 @Before, @After 같은 어드바이스는 기능은 적지만 실수할 가능성이 낮고 코드도 단순하다.
그리고 코드의 의도가 명확하게 들어난다는 장점이 있다.

좋은 설계는 제약이 있는 것이다.

좋은 설계는 제약이 있는 것이다. @Around만 있으면 되지만 @After, @Before를 사용했을 때의 제약 사항을 활용하면 실수를 방지할 수 있다.
기존에 개발된 코드에 @Around가 사용되었다고 가정할 때 새로운 개발자가 해당 코드를 수정해서 타겟을 호출하지 않도록 코드를 작성하게 되면 큰 장애가 발생하게 된다.
하지만 처음부터 @Before로 개발되었다면 이러한 장애는 발생하지 않는다.

제약 덕분에 역할이 명확해진다. 내가 작성한 코드를 다른 개발자들이 보았을 때 고민해야 하는 범위가 줄어들고 코드의 의도도 파악하기 쉬워진다.


참고한 자료:

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

[Core] 포인트컷 지시자 - within, args, @target, @within ...  (0) 2022.11.11
[Core] 포인트컷 지시자 - execution  (0) 2022.11.11
[Core] AOP 구현 - 기본  (0) 2022.11.11
[Core] AOP - 개념  (0) 2022.11.09
[Core] @Aspect  (0) 2022.11.09