6 분 소요

 인프런 스프링 핵심 원리 - 고급편을 학습하고 정리한 내용 입니다.

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

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

문제는 이것을 어드바이스 단위가 아니라 클래스 단위로 적용할 수 있다는 점이다.

그래서 하나의 애스펙트에 여러 어드바이스가 있다면 순서를 보장 받을 수 없다. 따라서 애스펙트를 별도의 클래스로 분리해야 한다.

로그를 남기는 순서를 바꾸어서 [doTransaction() → doLog()] 트랜잭션이 먼저 처리되고, 이후에 로그가 남도록 변경해보자.

AspectV5Order

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Slf4j  
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());  
            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());  
            }  
        }  
    }  
}

하나의 애스펙트 안에 있던 어드바이스를 LogAspect, TxAspect 스태틱 클래스로 빼서 애스펙트로 각각 분리했다. 그리고 각 애스펙트에 @Order애노테이션을 통해 실행 순서를 적용했다. 참고로 숫자가 작을 수록 먼저 실행된다.

1
2
3
4
@Import({AspectV5Order.TxAspect.class, AspectV5Order.LogAspect.class})  
public class AopTest {
	..
}

AspectV5Order를 실행하기 위해서 다음과 같이 2개를 import 했다.

실행 결과를 보면 트랜잭션을 먼저 실행 되는 걸 확인할 수 있다.

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

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

어드바이스 종류

  • @Around : 메서드 호출 전후에 수행, 가장 강력한 어드바이스, 조인 포인트 실행 여부 선택, 반환 값 변환, 예외 변환 등이 가능
  • @Before : 조인 포인트 실행 이전에 실행
  • @AfterReturning : 조인 포인트가 정상 완료 후 실행
  • @AfterThrowing : 메서드가 예외를 던지는 경우 실행
  • @After : 조인 포인트가 정상 또는 예외에 관계없이 실행(finally)

예제를 만들면서 학습해보자.

AspectV6Advice

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Slf4j  
@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()); // before는 joinPoint.proceed()이전 까지만 구현  
    }  
  
    @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("[throw] {} throw={}",joinPoint.getSignature(), ex);  
    }  
  
    @After(value = "hello.aop.order.aop.Pointcuts.orderAndService()")  
    public void doAfter(JoinPoint joinPoint) {  
        log.info("[after] {}",joinPoint.getSignature());  
    }  
}

doTransaction() 메서드에 남겨둔 주석을 보자.

뭐 엄청 많아보이지만 사실 @Around를 제외한 나머지 메서드들은 @Around가 할 수 있는 일의 일부만 제공할 뿐이다.

따라서 @Around 어드바이스만 사용해도 사실 아래의 모든 기능들을 다 수행할 수 있다.

참고 정보 획득
모든 어드바이스는 org.aspectj.lang.JoinPoint를 첫번째 파라미터에 사용할 수 있다. (생략해도 된다.) 단 @AroundProceedingJoinPoint을 사용해야 한다.

참고로 ProceedingJoinPointorg.aspectj.lang.JoinPoint의 하위 타입이다.

JoinPoint 인터페이스의 주요 기능

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

ProceedingJoinPoint 인터페이스의 주요 기능

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

@Before

조인 포인트 실행 전

1
2
3
4
@Before("hello.aop.order.aop.Pointcuts.orderAndService()")  
public void doBefore(JoinPoint joinPoint) {  
    log.info("[before] {}", joinPoint.getSignature()); // before는 joinPoint.proceed()이전 까지만 구현  
}

@Around와 다르게 작업 흐름을 변경할 수는 없다.

@AroundProceedingJoinPoint.proceed()를 호출해야 다음 대상이 호출된다. 하지만 @BeforeProceedingJoinPoint.proceed()를 사용하지 않는다. 메서드 종료와 함께 바로 다음 타겟이 호출 된다. 물론 예외가 발생하면 다음 코드가 호출되지 않는다.

1
2
3
4
@Import(AspectV6Advice.class)  
public class AopTest {
	...
}

다음과 같이 계속 하던 AopTest로 실행한다.

나머지 코드들을 주석 처리 후 @Before만 실행시켰을 때 딱 Service 시작 전에 호출된다.

@AfterReturning

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

1
2
3
4
@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속성에 사용된 이름은 어드바이스 메서드의 매개변수 이름과 일치해야 한다.
  • returning절에 지정된 타입의 값을 반환하는 메서드만 대상으로 실행한다. (부모 타입을 지정하면 모든 자식 타입은 인정된다.)
  • @Around 와 다르게 반환되는 객체를 변경할 수는 없다. 반환 객체를 변경하려면 @Around를 사용해야 한다. 참고로 반환 객체를 조작할 수는 있다.(setter가 반환 값이라면 그걸 살짝 수정..)

보면 서비스, 리포지토리 실행 후 리턴될 때 실행되는 걸 볼 수 있다.

@AfterThrowing

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

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

AopTestexception()테스트 코드에서 에러를 발생 시키는데 다음과 같이 @AfterThrowing이 동작했다.

@After

1
2
3
4
@After(value = "hello.aop.order.aop.Pointcuts.orderAndService()")  
public void doAfter(JoinPoint joinPoint) {  
    log.info("[after] {}",joinPoint.getSignature());  
}
  • 메서드 실행이 종료되면 실행된다. (finally를 생각하면 된다.)
  • 정상 및 예외 반환 조건을 모두 처리한다.
  • 일반적으로 리소스를 해제하는 데 사용한다.

@Around

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@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());  
    }  
}
  • 메서드의 실행의 주변에서 실행된다. 메서드 실행 전후에 작업을 수행한다.
  • 가장 강력한 어드바이스
    • 조인 포인트 실행 여부 선택 joinPoint.proceed()호출 여부 선택
    • 전달 값 변환: joinPoint.proceed(args[])
    • 반환 값 변환
    • 예외 변환
    • 트랜잭션 처럼 try ~ catch~ finally 모두 들어가는 구문 처리 가능
  • 어드바이스의 첫 번째 파라미터는 ProceedingJoinPoint를 사용해야 한다.
  • proceed()를 통해 대상을 실행한다.
  • proceed()를 여러번 실행할 수도 있음(재시도)

이제 모든 주석을 해제하고 전체 다 실행해 보자.

다음은 정상적인 흐름의 결과다.

다음은 예외가 발생한 상황이다.

순서

  • 스프링은 5.2.7 버전부터 동일한 @Aspect 안에서 동일한 조인포인트의 우선순위를 정했다.
  • 실행 순서: @Around, @Before, @After, @AfterReturning, @AfterThrowing
  • 어드바이스가 적용되는 순서는 이렇게 적용되지만, 호출 순서와 리턴 순서는 반대라는 점을 알아두자.
  • 물론 @Aspect안에 동일한 종류의 어드바이스가 2개 있으면 순서가 보장되지 않는다. 이 경우에는 @Aspect를 분리하고, @Order를 사용하자

@Around 외에 다른 어드바이스가 존재하는 이유

@Around 하나만 있어도 모든 기능을 수행할 수 있다. 그런데 다른 어드바이스들이 존재하는 이유는 무엇일까?

1
2
3
4
@Around("hello.aop.order.aop.Pointcuts.orderAndService()") 
public void doBefore(ProceedingJoinPoint joinPoint) { 
	log.info("[before] {}", joinPoint.getSignature()); 
}

다음 코드를 누가 작성했다고 생각해보자.

이 코드는 타겟을 호출하지 않는 치명적인 문제가 있다.

다음 코드를 보자.

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

@BeforejoinPoint.proceed()를 호출하는 고민을 하지 않아도 된다.

약간 @RequestMapping , @GetMapping, @PostMapping느낌이다.

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

반면에 @Before, @After같은 어드 바이스는 기능은 적지만 실수할 가능성이 낮고, 코드도 단순하다. 그리고 가장 중요한 점이 있는데, 바로 이 코드를 작성 한 의도가 명확하게 드러난다는 점이다.

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

좋은 설계는 제약이 있는 것이다.
좋은 설계는 제약이 있는 것이다. @Around만 있으면 되는데 왜? 이렇게 제약을 두는가?
제약은 실수를 미연에 방지한다. 일종의 가이드 역할을 한다. 만약 @Around를 사용했는데, 중간에 다른 개발자가 해당 코드를 수정해서 호출하지 않았다면? 큰 장애가 발생했을 것이다.
처음부터 @Before를 사용했다면 이런 문제 자체가 발생하지 않는다. 제약 덕분에 역할이 명확해진다. 다른 개발자도 이 코드를 보고 고민해야 하는 범위가 줄어들고 코드의 의도도 파악하기 쉽다.

댓글남기기