Spring Boot AOP
AOP는 무엇이고 어떻게 사용하는것일까
  • Spring

오늘은 Spring boot AOP에 대해 살펴보려고 합니다.

함수의 실행시간 비교

image

우선 두개의 클래스를 생성하였습니다.
ImpeCalculator는 long타입의 num변수를 넘겨받아 factorial을 계산합니다. 계산방법으로는 for문을 이용해 순차적으로 계산합니다.

두번 째 클래스 RecCalculator는 넘겨받은 num을 1식 빼며 재귀 호출하면서 값을 구한 후 리턴합니다.

가장 큰 차이는 for vs 재귀호출 이 되겠습니다.

factorial의 실행 테스트를 위해

image

main으로가서 2개의 클래스를 초기화해주고 각각 factoral을 호출합니다. 그리고 결과값 result를 출력하는 코드입니다.

결과값을 보면 둘 다 120으로 정상적인 결과가 나온것을 볼 수 있습니다.

과연 두 개 중에 어떤게 더 빠를까?

for문과 재귀호출 중 어떤게 더 빠를까요? 시간비교를 위해 main 코드를 다음과 같이 수정했습니다.

image

객체를 초기화하기 전 시간을 측정하고, factorial 함수가 호출된 후 다시 시간을 측정하여 두 시간의 차이를 계산합니다.

그러면 메서드가 실행되는 시간을 측정할 수 있습니다.

그런데 문제가 발생했습니다. 출력값을 확인해보니 실행시간이 0으로 나오고 있습니다.
그 원인으로는 코드가 너무 빠르게 실행되다보니 System.currentTimeMillis()의 밀리초로는 시간을 측정할 수 없었습니다.

밀리초 -> 나노초 로 바꾸어 비교를 해봐야겠다! 라고 생각을 하게 되었습니다.

바꾸어야지.. 라고 살펴보니 너무 많은곳을 고쳐야 합니다.
(비록 이 예제에서는 4개밖에 안되겠지만요)

그리고 중복되는 코드들이 보이는것이 거슬리기 시작합니다.
시간을 구하고, 함수 종료 후 구하고 결과를 찾아내는..

데코레이터 구현

데코레이터를 이용해서 반복되는 코드를 줄여보겠습니다.

데코레이터 구현을 위해 interface를 하나 만들도록 하겠습니다.

image

그리고 factoral만 하나 만들었습니다.
시간 계산을 처리할 ExeTimeCalculator 클래스를 생성합니다.

image

생성자로 Calculator를 넘겨받아 delegate라는 이름으로 저장해주고, factoral을 재정의하여 시간 측정하는 코드를 작성합니다.

순서대로 살펴보면 System.nanoTime()을 이용해 메서드 실행 전 시간을 측정하고 delegate.factorial()로 생성자를 통해 넘겨받은 Calculator의 factorial을 실행해줍니다.

리턴값을 받은 후 다시 nanoTime을 구해 두 시간차이를 계산하는 코드입니다.

main으로 돌아가서.

image

ExeTimeCalcurator 객체를 초기화하면서 생성자로 new ImpeCalculator를 넘겨줍니다.

동일한 방법으로 RecCalculator를 넘겨주는것도 생성합니다.
그리고 각각 factorial을 호출합니다.

결과값: 120, 실행시간: 2490
결과값: 120, 실행시간: 2408

과 같이 결과값이 잘 나온것을 확인할 수 있습니다.

main코드를 살펴보면 아무런 처리를 하지 않았지만 결과값이 실행시간이 출력된것을 볼 수 있습니다. 과연 어떻게 된걸까요?

앞에 만들었던 데코레이터 ExeTimeCalculator가 어떤 순서로 실행된건지 한번 더 살펴보도록 하겠습니다.

image

main에서 객체를 생성 후 factoral을 호출하면 ExeTimeCalculator.factorial()이 실행되고 시간을 구해옵니다 (nanoTime)

그리고 생성자를 통해 넘겨받은 핵심기능이 들어있는 delegate.factoral()을 호출합니다.

호출이 끝난 후 nanoTime을 구하여 시간측정을 할 수 있습니다.
그리고 return하며 실행이 종료됩니다.

여기서 중요한 내용이 있습니다.

개발자는 핵심기능을 건드리지 않고 데코레이터를 이용해 부가 기능을 구현할 수 있었습니다. 핵심기능과 부가기능을 분리했습니다.

이런 데코레이터와 유사하게 spring boot에서는 프록시로 기능을 제공합니다.

AOP proxy

AOP proxy가 실행되는 순서를 살펴보겠습니다.

image

client가 business()를 호출하면 그것은 AOP프록시가 넘겨받아 공통 기능을 실행한 후 핵심기능의 business()를 호출합니다.
그리고 결과값을 역순으로 리턴해줍니다.

앞에 살펴보았던 데코레이터와 매우 유사하게 동작하는것을 볼 수 있습니다.

이처럼, 프록시 기능을 개발자가 직접 구현하지않고 스프링 AOP를 통해 공통기능을 구현할 클래스만 적절하게 설정해주면 됩니다.

핵심기능을 수정하지 않으면서 공통 기능구현을 가능하게해주는것이 AOP의 핵심입니다.

AOP의 횡단 관심

image

서점사이트에 3개의 API가 있다고 가정합니다.

내 정보조회, 구매한 책 목록, 신간 목록이라는 핵심 기능을 하는 핵심 관심과,
각 핵심 관심마다 가지고있는 공통 관심들 즉 횡단 관심이 있습니다.

이런 경우 공통으로 구현되는 관점들을 별도의 클래스에 캡슐화하고 스프링에서는 관점 지향 프로그램 AOP라고 부릅니다.

이렇게 횡단 관심으로 처리할 작업들에는 보안, 캐시, db 커넥션 관리, 로깅 등이 있을 수 있습니다.

Aspect vs Spring AOP

AOP를 구현하는 방법에는 다양하게 있겠지만 대표적으로는 AspectJ, jBoss등이 있습니다. 그 중 Spring AOP는 AspectJ의 라이브러리 일부를 사용해 개발되었습니다.
따라서 AspectJ의 일부 기능과 어노테이션을 그대로 사용할 수 있습니다.

AspectJ를 이용해 AOP를 구현하는 방법에는

  • CTW : (Compile-Time Weaving) 컴파일 시점에 코드에 공통 기능을 삽입하는 방법
  • LTW : (Load-Time Weaving) 클래스 로딩 시점에 바이트 코드에 공통 기능을 삽입하는 방법

두가지가 있습니다. 둘 다 AspectJ Complier라고하는 컴파일러가 필요합니다. 둘 다 바이너리코드 앞/뒤로 부가기능을 삽입하는 방법입니다.

반면 Spring AOP는

  • RTW : (Run-time Weaving) 런타임에 프록시 객체를 생성해서 공통 기능을 삽입하는 방법

runtime시에 앞/뒤로 부가기능을 삽입하는 방법이며 별도의 컴파일러는 필요하지 않습니다.

AspectJ가 있는데 왜 AspectJ의 일부로 Spring AOP가 태어났을까

조금 전 Spring AOP는 AspectJ의 라이브러리 일부를 이용해 만들었다고 설명드렸습니다. 라이브러리를 사용해서 새로운 기능이 아닌 AspectJ와 비슷한 기능을 하는 (더 작은 기능을 하는) 동일한 AOP를 구현하였습니다.

그 이유는 AspectJ와 Spring AOP의 지향하는 목적의 차이에 있습니다.

AspectJ는 완벽한 AOP를 지향하여 완벽한 AOP를 구현하는 반면, Spring AOP는 IoC를 통한 더욱 간단한 AOP구현을 목적으로 하고 있습니다.

그래서 AspectJ에서는 생성자 호출, 정적변수 초기화 등 개발자가 원하는 모든 지점에 Pointcut적용이 가능하지만, Spring AOP는 메서드 호출에 대한 Pointcut만 지원하고 있습니다.

AOP의 용어

용어 설명
Advice 언제 공통 관심 기능을 핵심 로직에 적용할 지를 정의하고 있다. 예를 들어 ‘메서드를 호출하기 전’(언제)에 ‘트랜잭션 시작’(공통 기능) 기능을 적용한다는 것을 정의한다.
Joinpoint Advice를 적용 가능한 지점을 의미한다. 메서드 호출, 필드 값 변경 등이 Joinpoint에 해당한다. 스프링은 프록시를 이용해서 AOP를 구현하기 때문에 메서드 호출에 대한 Joinpoint만 지원한다.
Pointcut Joinpoint의 부분 집합으로서 실제 Advice가 적용되는 Joinpoint를 나타낸다. 스프링에서는 정규 표현식이나 AspectJ의 문법을 이용하여 Pointcut을 정의할 수 있다.
Weaving Advice를 핵심 로직 코드에 적용하는 것을 weaving이라고 한다.
Aspect 여러 객체에 공통으로 적용되는 기능을 Aspect라고 한다. 트랜잭션이나 보안 등이 Aspect의 좋은 예이다.

Advice의 종류

용어 설명
Before Advice 대상 객체의 메서드 호출 전에 공통 기능을 실행한다
After Advice 익셉션 발생 여부에 상관없이 대상 객체의 메서드 실행 후 공통 기능을 실행한다
Around Advic 대상 객체의 실행 전, 후 또는 익셉션 발생 시점에 공통 기능을 실행하는데 사용된다
After Returning Advice 대상 객체의 메서드가 익셉션 없이 실행된 이후에 공통 기능을 실행한다
After Throwing Advice 대상 객체의 메서드를 실행하는 도중 익셉션이 발생한 경우에 공통 기능을 실행한다

Spring AOP 구현

dependency 추가

Spring AOP를 구현하기 위해 dependency를 추가합니니다.

image [Gradle]을 사용하기 때문에 위와같이 추가하였습니다.

Aspect 정의

image

Aspect 클래스를 생성하고 어노테이션으로 @Ajpect를 명시해줍니다.
measure라는 메서드를 생성하고 @Advice를 사용할건데, 예제에서는 advice종류 중 Around를 사용하였습니다.

Advice target을 publicTarget() 으로 지정하고 publicTarget 메서드를 위에 정의해줍니다.

publicTarget() 의 @Pointcut으로는 사진과 같이 execution(public * kr..*(..))")으로 작성하였습니다. (뒤에가서 자세히 설명하겠습니다.)

measure메서드 내용을 살펴보면
System.nanoTime()을 호출하여 시간을 측정합니다.
그리고 joinPoint.proceed()를 통해 메서드를 호출합니다. 위에서 설명드렸던 것처럼 Spring AOP는 메서드 호출에 대한 Aspect만 지원합니다.
그리고 리턴을 받고나서 다시 System.nanoTime()을 호출하여 측정시간을 출력하는 예제입니다.

image

설정클래스로 가서 ExeTimeAspect를 리턴하는 Bean을 등록해줍니다.

image

main으로가서 컨테이너 초기화 후 getBean을 통해 calculator을 가져온 후 factorial을 호출해봅니다.

main코드에서는 결과출력이나 측정시간출력을 하지 않았지만 앞에서 살펴본 데코레이터의 ExeTimeCalculator처럼 결과값이 출력되는것을 볼 수 있습니다.

어떻게 동작한것인지 한번 더 살펴보겠습니다.

image

  1. main에서 factorial을 실행하면 그것을 Proxy가 받아 ExeTimeAspect의 measure를 실행합니다.
  2. ExeTimeAspect는 시간을 측정합니다.
  3. 실제 객체인 RecCalculator의 factorial을 호출합니다.
  4. 리턴을 받고난 후 시간을 측정하여 소요시간을 알아냅니다.
  5. 역순으로 리턴 결과를 돌려줍니다.

Advice의 Pointcut 명시

image

Advice에서 target메서드를 입력하지 않고 Pointcut의 표현식을 바로 작성해주어도 연결에 문제가 없습니다.

Pointcut 명시

포인트컷에는 위 예제에서 살펴본 execution외에도 다양하게 존재합니다.

용어 설명
execution 메소드 실행 결합점(join points)과 일치시키는데 사용된다.
within 특정 타입(클래스)에 속하는 결합점을 정의한다.
this 빈 참조가 주어진 타입의 인스턴스를 갖는 결합점을 정의한다.
target 대상 객체가 주어진 타입을 갖는 결합점을 정의한다.
args 인자가 주어진 타입의 인스턴스인 결합점을 정의한다.

표현식의 연결 연산자

용어 설명
* 모두
.. 0개 이상
&& and
! not

execution의 표현식

execution의 표현식의 순서는 아래와 같습니다. execution(수식어 패턴? 리턴타입패턴 클래스이름패턴?메서드이름패턴(파라미터패턴))

  • ?가 붙어있는 수식어 패턴과 클래스이름패턴은 생략이 가능합니다.
  • 수식어 패턴은 public과 protected만 지원하지만, Spring AOP는 메서드 호출에 대해서만 지원하기 때문에 항상 public이 오거나 생략되는 형태만 사용 가능합니다.

몇가지 예제를 살펴보겠습니다.

용어 설명
execution(public void set*(..)) 리턴 타입이 void이고, 메서드 이름이 set으로 시작하고, 파라미터가 0개 이상인 메서드 호출. 파라미터 부분에 ‘..’을 사용하여 파라미터가 0개 이상인 것을 표현했다.
execution(* io.fastcampus..()) chap07 패키지의 타입에 속한 파라미터가 없는 모든 메서드 호출
execution(* io.fastcampus...(..)) chap07 패키지 및 하위 패키지에 있는, 파라미터가 0개 이상인 메서드 호출. 패키지 부분에‘..’을 사용하여 해당 패키지 또는 하위 패키지를 표현했다.
execution(Long io.fastcampus.Calculator.factorial(..)) 리턴타입이 Long인 Calculator타입의 factorial() 메서드 호출
execution(* get*(*)) 이름이 get으로 시작하고 파라미터가 한 개인 메서드 호출
execution(* read*(Integer, ..)) 메서드 이름이 read로 시작하고, 첫 번째 파라미터 타입이 Integer이며, 한 개 이상의 파라미터를 갖는 메서드 호출
!execution(* io.fastcampus.adminController..(..)) && execution(* io.fastcampus.Controller..*(..)) 리턴 타입의 구분없이 io.fastcampus 패키지 아래 AdminController패키지를 제외한 클래스 io.fastcampus 패키지 아래 Controller로 끝나는 클래스의 메서드 호출

Aspect의 2개 사용

한 메서드에 Aspect는 여러개 사용될 수 있습니다. 테스를 위해 하나 더 생성해보도록 하겠습니다.

image

CacheAspect를 생성하였습니다.
코드를 살펴보면

  1. joinPoint.getArgs()[0]을 통해 메서드로 넘겨준 args를 가져옵니다.
  2. Map타입의 cache안에 해당 결과값이 있는지 검사합니다.
  3. 해당 결과값이 존재하면 cache에서 해당 값을 찾아 리턴합니다.
  4. 해당 결과값이 없다면 실제 객체의 factorial을 호출합니다.
  5. 결과값을 cache에 저장하고 결과값을 리턴합니다.

캐시에서 사용하는 일반적인 코드입니다.

image

사용을 위해 설정 클래스에 CacheAspect를 리턴하는 @Bean을 등록합니다.

image

main으로 가서 CacheAspect의 코드를 테스트 하기위하여 factorial에 동일한 값을 넣어 호출해보도록 하겠습니다. 5로 두번, 6으로 두번 호출했습니다.

결과값을 살펴보면

image

CacheAspect에 대한 결과만 우선 살펴보도록 하겠습니다.

  1. factorial(5) 호출 -> 캐시에 존재하지 않아 실제 대상 객체를 호출하여 결과값을 구한 뒤 cache에 추가 출력
  2. factorial(5) 호출 -> 캐시에 존재하여 cache에서 값을 찾았고 hits 출력
  3. factorial(6) 호출 -> 캐시에 존재하지 않아 실제 대상 객체를 호출하여 결과값을 구한 뒤 cache에 추가 출력
  4. factorial(6) 호출 -> 캐시에 존재하여 cache에서 값을 찾았고 hits 출력

4번 모두 정상적으로 출력된것을 볼 수 있습니다.

다음으로 ExeTimeAspect는 잘 실행되었을까 살펴보도록 하겠습니다.

제일 먼저 시간이 출력된 후 두번째는 출력되지 않은것을 확인할 수 있습니다.
그리고 세번째는 출력되고 역시 네번째는 출력되지 않았습니다.

CacheAspect는 4번 모두 실행되었는데.. 왜 이런일이 생겼을까요?

@Order 어노테이션

CacheAspect는 4번 모두 실행되었는데, 왜 ExeTimeAspect는 두번만 실행되었을까요?

그 이유는 설정클래스에 @Bean이 등록된 순서에 있습니다.

image

CacheAspect가 등록된 후 ExeTimeAspect가 등록되면서 실제 대상객체인 Calcurator를 바라보는데 순서가 생긴것입니다.

image

위에서 joinPoint.proceed() 를 호출하면 실제 대상 객체의 메서드가 실행된다고 설명했었지만, Aspect가 두개가 실행되면서 CacheAspect가 바라보는 실제 대상 객체가 Calculator가 아닌 ExeTimeAspect가 되었습니다.

따라서 CacheAspect에서 캐시에서 값이 존재하는지 확인한 후 cache에서 찾아서 리턴한 경우 joinPoint.proceed()를 호출하지 않게 되고, ExeTimeAspect가 호출되지 않게 되는 것 입니다.

이를 해결하기위해 우리는 @Order어노테이션을 이용할 수 있습니다.

image

@Aspect를 선언할 때 @Order을 통해 숫자를 넘겨줌으로써 순서를 지정할 수 있습니다.

위 예제의 경우 ExeTimeAspect@Order(1)을 주면 ExeTimeAspect는 항상 joinPoint.proceed()를 호출하기 때문에 Aspect가 실행되지 않는것을 막을 수 있습니다.

Pointcut의 재사용

@Advice()에 target메서드를 작성하여 Pointcut메서드를 지정할 수 있다고 설명드렸습니다.

이 target메서드는 공통으로 관리할 수 있습니다.

image

이와같이 CommonPointcut이라는 클래스를 만들고 CommonTarget메서드를 작성하였습니다. 그리고 Pointcut 표현식을 작성해줍니다.

아까 전 생성하였던 CacheAspect와 ExeTimeAspect의 Advice 탈겟 메서드를 CommonPointcut.commonTarget()으로 동일하게 지정해줍니다.

이렇게하면 여러개의 Aspect를 편리하게 하나의 Pointcut로 관리할 수 있습니다.