이번 포스팅에서는 AOP의 개념과 @Aspect
어노테이션을 활용하여 스프링에서 AOP를 사용하는 예시를 통해 AOP의 동작 과정을 이해해 보도록 하겠습니다.
애플리케이션 로직은 크게 핵심 기능과 부가 기능으로 나눌 수 있습니다.
핵심 기능은 해당 객체가 제공하는 고유의 기능입니다. 예를 들어서 OrderService
의 핵심 기능은 주문 로직입니다.
부가 기능은 핵심 기능을 보조하기 위해 제공되는 기능입니다. 예를 들어서 로그 추적 로직, 트랜잭션 기능이 있습니다. 이러한 부가 기능은 단독으로 사용되지 않고, 핵심 기능과 함께 사용됩니다. 예를 들어서 로그 추적 기능은 어떤 핵심 기능이 호출되었는지 로그를 남기기 위해 사용한다. 그러니까 부가 기능은 이름 그대로 핵심 기능을 보조하기 위해 존재합니다.
만약 주문 로직을 실행하기 직전에 로그 추적 기능을 사용해야 하면, 핵심 기능인 주문 로직과 부가 기능인 로그 추적 로직이 하나의 객체 안에 섞여 들어가게 됩니다. 부가 기능이 필요한 경우 이렇게 둘을 합해서 하나의 로직을 완성한다. 이제 주문 서비스를 실행하면 핵심 기능인 주문 로직과 부가 기능인 로그 추적 로직이 함께 실행된다.
보통 부가 기능은 여러 클래스에 걸쳐서 함께 사용됩니다. 예를 들어서 모든 애플리케이션 호출을 로깅해야 하는 요구사항을 생각해보면 이러한 부가 기능은 횡단 관심사(cross-cutting concerns)가 됩니다. 쉽게 이야기해서 하나의 부가 기능이 여러 곳에 동일하게 사용된다는 뜻입니다.
그런데 이런 부가 기능을 여러 곳에 적용하려면 너무 번거롭습니다. 예를 들어서 부가 기능을 적용해야 하는 클래스가 100개면 100개 모두에 동일한 코드를 추가해야 합니다. 부가 기능을 별도의 유틸리티 클래스로 만든다고 해도, 해당 유틸리티 클래스를 호출하는 코드가 결국 필요합니다. 그리고 부가 기능이 구조적으로 단순 호출이 아니라 try~catch 같은 구조가 필요하다면 더욱 복잡해집니다.
더 큰 문제는 수정입니다. 만약 부가 기능에 수정이 발생하면, 100개의 클래스 모두를 하나씩 찾아가면서 수정해야 합니다. 여기에 추가로 부가 기능이 적용되는 위치를 변경한다면 어떻게 될까요? 예를 들어서 부가 기능을 모든 컨트롤러, 서비스, 리포지토리에 적용했다가, 로그가 너무 많이 남아서 서비스 계층에만 적용한다고 수정해야 한다면 어떻게 될까요? 또 수많은 코드를 고쳐야 할 것입니다.
이러한 문제를 해결하기 위해 부가 기능을 핵심 기능에서 분리하고 한곳에서 관리하도록 했습니다. 그리고 해당 부가 기능을 어디에 적용할지 선택하는 기능도 만들었습니다. 이렇게 부가 기능과 부가 기능을 어디에 적용할지 선택하는 기능을 합해서 하나의 모듈로 만든 것이 바로 @Aspect
어노테이션입니다.
애스펙트는 우리말로 해석하면 관점이라는 뜻입니다. 이름 그대로 애플리케이션을 바라보는 관점을 하나하나의 기능에서 횡단 관심사(cross-cutting concerns) 관점으로 달리 보는 것입니다. 이렇게 애스펙트를 사용한 프로그래밍 방식을 관점 지향 프로그래밍(AOP, Aspect-Oriented Programming)이라 합니다.
AOP는 OOP(Object-Oriented Programming)를 대체하기 위한 것이 아니라, 횡단 관심사를 깔끔하게 처리하기 어려운 OOP의 부족한 부분을 보완하기 위해 개발되었습니다.
설명했던 내용을 AOP의 대표적인 예시인 로그 출력과 트랜잭션 기능의 간단한 예제 코드를 통해 이해를 돕겠습니다. 예제 코드에서 트랜잭션 관련 코드는 진짜 트랜잭션을 실행하는 것은 아니고 기능이 동작한 것처럼 로그만 남기도록 하겠습니다.
@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);
}
}
@Slf4j
@Aspect
@Component
public class AspectV1 {
// hello.aop.order 패키지와 하위 패키지
@Around("execution(* hello.aop.order..*(..))")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[log] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
}
@Around
어노테이션의 값인 execution(* hello.aop.order..*(..))
는 포인트 컷이 되고 @Around
어노테이션의 메서드인 doLog
메서드는 어드바이스가 됩니다.
execution(* hello.aop.order..*(..))
는 hello.aop.order
패키지와 그 하위 패키지( ..
)를 지정하는 AspectJ 포인트 컷 표현식입니다. 이제 포인트 컷 조건과 일치하는 OrderService
클래스와 OrderRepository
클래스의 모든 메서드는 AOP 적용의 대상이 됩니다.
저번 포스팅에서도 언급했듯이 @Around
어노테이션이 컴포넌트 스캔의 대상이 되지 않기때문에
로그를 통해 프록시가 잘 적용되었는지 확인해보겠습니다.
@Slf4j
@SpringBootTest
public class AopTest {
@Autowired
OrderService orderService;
@Autowired
OrderRepository orderRepository;
@Test
void success() {
orderService.orderItem("itemA");
}
@Test
void exception() {
Assertions.assertThatThrownBy(() -> orderService.orderItem("ex"))
.isInstanceOf(IllegalStateException.class);
}
}
// success 실행 결과
[log] void hello.aop.order.OrderService.orderItem(String)
[orderService] 실행
[log] String hello.aop.order.OrderRepository.save(String)
[orderRepository] 실행
위 그림의 흐름과 같이 실제 타겟 클래스의 로직이 호출되기 전에, 프록시인 어드바이스 로직이 먼저 출력되고 실제 타켓 클래스의 로직이 출력된 것을 확인할 수 있습니다.
@Slf4j
@Aspect
public class AspectV2 {
// hello.aop.order 패키지와 하위 패키지
@Pointcut("execution(* hello.aop.order..*(..))")
private void allOrder(){}
// 클래스 이름 패턴이 *Service
@Pointcut("execution(* *..*Service.*(..))")
private void allService(){}
@Around("allOrder()")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[log] {}", joinPoint.getSignature());
Object target = joinPoint.getTarget();
log.info(target.getClass().getSimpleName());
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());
}
}
}
@Around
어노테이션에 포인트 컷 표현식을 직접 넣을 수도 있지만, 포인트 컷을 @Pointcut
어노테이션을 사용해서 별도로 분리할 수도 있습니다.
이제 각 포인트 컷을 조합해서 @Around
어노테이션에 적용할 수 있습니다. 포인트 컷이 중복되면 AspectV1
처럼 여러 번 작성하는 것이 아니라 AspectV2
처럼 포인트 컷을 별도 메서드로 빼내고 @Pointcut
을 사용해서 합치는 방식이 훨씬 깔끔합니다.
allOrder()
포인트 컷은 hello.aop.order
패키지와 하위 패키지를 대상으로 합니다. allService()
포인트 컷은 타입 이름 패턴이 *Service
를 대상으로 하는데 쉽게 이야기해서 XxxService
처럼 Service
로 끝나는 것을 대상으로 합니다.
포인트 컷은 @Around("allOrder() && allService()")
다음과 같이 조합할 수 있습니다. &&
(AND), ||
(OR), !
(NOT) 3가지 조합이 가능합니다.
포인트 컷이 적용된 AOP 결과
orderService
→ doLog()
어드바이스, doTransaction()
어드바이스 적용
orderRepository
→ doLog()
어드바이스 적용
두 개의 어드바이스가 동일한 메서드에 적용되는 경우, 일반적으로 AOP에서는 어드바이스가 체인 형식으로 결합됩니다. 따라서 joinPoint.proceed()
메서드가 두 번 호출되는 것은 아닙니다.
각각의 어드바이스는 순차적으로 호출되고, 첫 번째 어드바이스가 joinPoint.proceed()
메서드를 호출하면 다음 어드바이스로 제어가 넘어갑니다. 전체 흐름은 다음과 같은 순서로 실행됩니다:
로그 어드바이스 시작
트랜잭션 어드바이스 시작
OrderService
메서드 실행
트랜잭션 어드바이스 종료
로그 어드바이스 종료
즉, joinPoint.proceed()
메서드가 체인 형식으로 결합되어 각각의 어드바이스가 proceed()
를 호출할 때 다음 어드바이스로 제어가 넘어가고, 최종적으로 실제 대상 메서드가 실행됩니다.
따라서, joinPoint.proceed()
메서드는 각 어드바이스 내에서 한 번씩 호출되지만 실제로 대상 메서드는 한 번만 실행됩니다.
위에서 작성한 테스트 코드의 success()
메서드를 실행했을 때, 그림과 같이 프록시가 생성되고 실행되는 것을 확인할 수 있습니다.
// success 실행 결과
[log] void hello.aop.order.OrderService.orderItem(String)
[트랜잭션 시작] void hello.aop.order.OrderService.orderItem(String)
[orderService] 실행
[log] String hello.aop.order.OrderRepository.save(String)
[orderRepository] 실행
[트랜잭션 커밋] void hello.aop.order.OrderService.orderItem(String)
[리소스 릴리즈] void hello.aop.order.OrderService.orderItem(String)
AOP 적용 전
클라이언트 → orderService.orderItem()
→ orderRepository.save()
AOP 적용 후
클라이언트 → [ doLog()
→ doTransaction()
] → orderService.orderItem()
→ [ doLog()
] → orderRepository.save()
orderService
에는 doLog()
어드바이스, doTransaction()
어드바이스 총 두 가지 어드바이스가 적용되어 있고, orderRepository
에는 doLog()
어드바이스만 적용된 것을 확인할 수 있습니다.
// exception 실행 결과
[log] void hello.aop.order.OrderService.orderItem(String)
[트랜잭션 시작] void hello.aop.order.OrderService.orderItem(String)
[orderService] 실행
[log] String hello.aop.order.OrderRepository.save(String)
[orderRepository] 실행
[트랜잭션 롤백] void hello.aop.order.OrderService.orderItem(String)
[리소스 릴리즈] void hello.aop.order.OrderService.orderItem(String)
예외 상황에서는 트랜잭션 커밋 대신에 트랜잭션 롤백이 호출되는 것을 확인할 수 있습니다. 지금까지 우리가 작성한 코드는 로그를 남기는 순서가 [ doLog()
→ doTransaction()
] 순서로 작동합니다.
만약 요구사항이 변경돼서 어드바이스 실행 순서를 바꿔야 하는 상황이 생긴다면 어떻게 해야 할까요?
예를 들어서 실행 시간을 측정해야 하는데 트랜잭션과 관련된 시 간을 제외하고 측정하고 싶다면 [ doTransaction()
→ doLog()
] 이렇게 트랜잭션 이후에 로그를 남겨야 할 것입니다.
어드바이스는 기본적으로 순서를 보장하지 않습니다. 순서를 지정하려면 @Aspect
적용 단위로 org.springframework.core.annotation
패키지의 @Order
어노테이션을 적용해야 합니다.
문제는 이것을 어드바이스 단위가 아니라 클래스 단위로 적용할 수 있다는 점입니다. 그래서 지금처럼 하나의 애스펙트에 여러 어드바이스가 있으면 순서를 보장받을 수 없습니다. 따라서 애스펙트를 별도의 클래스로 분리해야 합니다.
로그를 남기는 순서를 바꾸어서 [doTransaction()
→ doLog()
] 다음과 같은 순서대로 트랜잭션이 먼저 처리되고, 이후에 로그가 남도록 변경해 보겠습니다.
@Slf4j
public class AspectV3 {
// hello.aop.order 패키지와 하위 패키지
@Pointcut("execution(* hello.aop.order..*(..))")
private void allOrder(){}
// 클래스 이름 패턴이 *Service
@Pointcut("execution(* *..*Service.*(..))")
private void allService(){}
@Aspect
@Order(2)
@Component
public static class LogAspect {
@Around("allOrder()")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[log] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
}
@Aspect
@Order(1)
@Component
public static class TxAspect {
@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());
}
}
}
}
하나의 애스펙트 안에 있던 어드바이스를 LogAspect
, TxAspect
애스펙트로 각각 분리했습니다. 그리고 각 애스펙트 에 @Order
어노테이션을 통해 실행 순서를 적용했다. 참고로 숫자가 작을수록 먼저 실행됩니다.
// success 실행 결과
[트랜잭션 시작] void hello.aop.order.OrderService.orderItem(String) [log] void hello.aop.order.OrderService.orderItem(String) [orderService] 실행
[log] String hello.aop.order.OrderRepository.save(String) [orderRepository] 실행
[트랜잭션 커밋] void hello.aop.order.OrderService.orderItem(String)
[리소스 릴리즈] void hello.aop.order.OrderService.orderItem(String)
이제 트랜잭션이 먼저 처리되고, 이후에 로그가 남는 것을 확인할 수 있습니다.
이번 포스팅에서는 AOP의 개념과 스프링에서 AOP가 어떻게 사용되는지, 그리고 그 동작 과정을 살펴보았습니다. 핵심 기능과 부가 기능을 분리하여 코드의 유지보수성과 재사용성을 높일 수 있는 AOP의 장점에 대해 이해할 수 있었습니다.
특히 스프링에서 @Aspect
어노테이션과 @Pointcut
어노테이션을 이용해 부가 기능을 모듈화하고, 이를 서비스 로직에 적용하는 방법을 예제 코드를 통해 구체적으로 확인했습니다. 어드바이스와 포인트컷을 사용하여 로깅과 트랜잭션 관리를 적용하는 과정을 단계별로 설명하였고, 여러 어드바이스의 적용 순서를 조정하기 위해 @Order
어노테이션을 활용하는 방법도 알아보았습니다.
스프링 AOP는 부가 기능을 핵심 로직에 깔끔하게 통합할 수 있는 강력한 도구입니다. 이를 통해 코드의 중복을 제거하고, 횡단 관심사를 한 곳에서 관리할 수 있게 되어, 어플리케이션의 유지보수성과 확장성을 크게 향상시킬 수 있습니다.