스프링 AOP 개념
애플리케이션의 실행로직은 크게 핵심기능과 부가기능으로 나뉘어진다. 이 부가기능을 보통 횡단관심사(cross-cutting-concerns)으로 부른다. 예를 들어 주문이라는 기능을 구현하기 위해 orderService라는 클래스를 만들었을 때 주문 로직은 핵심기능이다. 여기에 로그나 트랜잭션 기능을 추가하는 것은 부가기능을 더하는 것이라 볼 수 있다.
이 부가기능을 적용하는 것이 간단한 문제일까?
- 이 질문에 대해 여러 상황을 생각해보자. 우리는 부가기능이라고 하는 기능을 여러 핵심기능에 적용할 수 있을 것이다.
- 100개의 핵심기능에 로그나 트랜잭션을 추가하려고 한다면 쉽게 생각하면 100개의 부가기능로직을 각각의 소스코드에 추가해주어야 한다.
- 부가기능이 변경되는 상황이 발생하면 어떻게 해야할까?
- 예를들어 로그 포맷을 변경하고 싶을 때
- 부가기능을 적용하는 대상을 변경하고 싶을때는 어떻게 해야할까?
- 컨트롤러에만 부가기능 적용 or 주문 로직은 로그를 제외하고 싶을 때
- 위와 같은 상황에서 중복과 반복이 너무나 많이 발생한다.
Aspect
이러한 부가기능 문제를 해결하기 위해서 Aspect라는 개념이 등장했다. Aspect는 부가기능 그 자체와 어디에 적용할 것인지 선택하는 기능을 합쳐서 모듈로 만든 것이다. 그리고 이러한 Aspect를 이용한 프로그래밍 기법을 Aspect-Oriented-Programming 관점지향 프로그래밍이라고 한다.
AspectJ 프레임워크
- AOP의 대표적인 구현이다. (https://www.eclipse.org/aspectj)
- 자바 프로그래밍 언어에 대한 관점 지향 프로그래밍
- 횡단 관심사의 모듈화
AOP 적용 방식의 세 가지
- 컴파일 시점
- 클래스 로딩 시점
- 런타임 시점(프록시)
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
- 스프링 핵심 원리 - 고급편, 김영한
'Spring' 카테고리의 다른 글
JDBC와 트랜잭션 문제, 스프링의 해결책 (0) | 2022.06.17 |
---|---|
스프링 AOP 내부호출 문제와 한계 (0) | 2022.06.16 |
빈 후처리기, BeanPostProcessor (0) | 2022.06.11 |
스프링이 제공하는 프록시, 프록시 팩토리 (0) | 2022.06.09 |
Servlet ? (0) | 2022.01.05 |