Aspect의 등장 이유
스프링 AOP는 부가 기능 추가시 횡단 관심사로 인한 문제 때문에 등장하게 되었다.
만약 횡단 관심사를 객체 지향적으로 해결하기 위해서는 많은 문제들이 발생한다.
- 부가 기능을 적용할 때, 부가 기능을 적용하기 위한 코드를 작성해주어야 한다.(템플릿 적용 전)
- 부가 기능이 여러곳에 퍼져서 중복 코드를 만들어 낸다. (적용 이후에도 동일)
- 부가기능 변경 시 많은 부분을 수정해야 한다. (제대로 된 캡슐화가 되지 않음)
- 부가기능 적용대상 변경 시 많은 수정이 필요하다. (적용하지 않을 대상들을 직접 찾아야 함.)
이러한 문제점을 해결하기 위해 핵심 로직과 부가 기능 로직을 나누고, 부가 기능을 어디에 적용하고 어떤 부가 기능을 적용할 지 선택하는 기능을 합한 하나의 모듈로서 등장한 것이 Aspect 이다.
따라서, 스프링에서 사용하는 어드바이저도 Aspect의 일환이다.
이 부분에서 잠깐 헷갈렸던 부분이 있다.
스프링에서 제공하는 Filter와 Interceptor도 어떠한 요청 사항에 대한 처리를 자동으로 해줄 수 있게 도와주는데 이것도 Aspect 일까?
라는 궁금증이 생겼고, 찾아보니 Filter와 Interceptor는 단순히 웹 요청에 대한 요청을 어떻게 처리할지에 대한 관점이고,
Aspect는 횡단 관심사에 처리에 대해 포커스를 두고 있다는 것을 알게 되었다.
즉, Aspect에서 가장 중요한 부분은 횡단 관심사를 어떻게 처리할 것이냐 인 것이다.
애플리케이션을 횡단 관심사 관점으로 바라보자는 의미로 AOP(Aspect-Oriented-Programming) 가 탄생했다.
AOP가 탄생하기 전에 그 당시 시대상에 객체 지향적으로 바라보는 OOP가 대세였기 때문에, 이러한 이름을 채택했다는 설이 있다.
하지만 그렇다고 AOP가 OOP를 대체한다는 것은 아니고 그저 OOP의 부족한 부분을 보조하는 것이다.
AspectJ란
이러한 AOP의 기능을 AspectJ 라는 프레임워크가 있다. AspectJ 공식문서에서는 해당 프레임워크를 다음과 같이 설명하고 있다.
1. 자바 프로그래밍 언어에 대한 완벽한 관점 지향을 확장
2. 횡단 관심사의 깔끔한 모듈화
- 오류 검사 및 처리
- 동기화
- 성능 최적화(캐싱)
- 모니터링 및 로깅
AspectJ의 적용시점
AspectJ는 자바에서 3가지 시점에 적용이 된다. 컴파일 시점, 클래스 로딩 시점, 런타임 시점이다.
컴파일 시점
javac를 이용해 .java를 JVM에 맞게 바이트 코드로 변환하면서 .class로 바꾸는 그 컴파일 시점에 적용이 가능하다.
[장점] : 컴파일 시점에 사용되는 것이기 때문에 런타임 시점과 달리 스태틱 메서드, 생성자, 필드 값에도 적용이 가능하다.
[단점] : AspectJ에서 제공하는 특별한 컴파일러를 사용해야 하며, 적용하기가 복잡하다.
클래스 로딩 시점
java 실행 시 .class 파일을 class Loader 영역에 올릴 때 사용하는 방법이다.
[장점] : 컴파일 시점과 동일하게 아직 런타임 전이라서 컴파일 시점의 장점과 동일하다.
[단점] : 자바 실행 시 java -javaagent 라는 명령어를 통해 클래스 로더 조작기를 지정해야 하는데 복잡하다.
런타임 시점
자바가 완전히 실행되고 난 이후 즉, main 실행 이후에 시점이다.
[장점] : 프록시를 사용해서 적용을 하는 방식이고, 스프링 aop가 이를 구현해두어서 적용하기가 간단하다.
[단점] : 런타임 이후에 실행되기 때문에 생성자, 필드 값, 스태틱 메서드에 적용이 안되고 메서드 실행 부분에만 적용이 가능하다.
여기까지만 본다면 컴파일 시점이나 클래스 로딩 시점에 AspectJ를 사용하도록 하여 적용하게 하는 것이 좋아 보인다. 실제로 기능적으로 봐도 그렇다. 하지만 직접 적용하려면 많은 양을 공부해야하고, 운영단계에서도 굉장히 복잡하다는 단점이 있다.
따라서, 스프링 AOP가 제공해주는 기능에 집중해서 이 기능들을 적절히 사용 할 수 있도록 하는 것이 좋다.
스프링 AOP를 적용하는 과정을 살펴보기 전에, AspectJ에서 사용하는 용어들에 대해서 먼저 살펴보자.
AspectJ의 용어
조인포인트(JointPoint) | 어드바이스가 적용될 수 있는 지점이다. 스프링 AOP의 경우 메서드 실행 부분에만 적용이 가능하다. |
포인트컷(Pointcut) | 조인포인트 중에서 어드바이스가 적용될 지점을 찾는 것이다. |
타겟(target) | 어드바이스를 받는 객체이다. 포인트컷으로 결정된다. |
어드바이스(Advice) | 특정 조인포인트에서 AspectJ에 의해서 수행되는 부가기능이다. Around, Before, After 등이 있다. |
애스펙트(Aspect) | 어드바이스와 포인트 컷을 모듈화 한 것이고, 여러 어드바이스와 포인트 컷이 존재한다. |
어드바이저(Advisor) | 스프링 AOP에만 존재하는 하나의 어드바이스와 포인트컷을 합친 것을 말한다. |
위빙(weaving) | 포인트컷으로 결정한 타겟의 조인포인트에 어드바이스를 적용하는 것을 말한다. |
AOP 프록시 | AOP 기능을 구현하기 위해 만든 프록시 객체이다. |
스프링 AOP 적용
먼저 스프링에서 AOP를 적용하기 위해서는 스프링에서 제공하는 aop 라이브러리를 import 해야한다.
builid.gradle의 dependencies 영역에 다음을 추가해주자.
implementation 'org.springframework.boot:spring-boot-starter-aop'
그리고 이어서 클래스에 @Aspect를 선언해주어야 한다.
@Aspect
class aop(){
}
이때, 주의해야 할점은 @Aspect는 @Component를 상속받은 것이 아니기 때문에, 자동으로 빈으로 등록되지 않는다. Aspect는 빈으로 등록했을 때만 적용되기 때문에 직접 빈으로 등록을 해주어야 한다.
이제 메서드에 어드바이스를 추가하기 위해서는 다음과 같이 작성한다.
@Around("execution(* hello.aop.test..*(..))")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("log Start");
Object result = joinPoint.proceed();
log.info("log End");
}
execution에 해당하는 부분이 포인트 컷이다. 해당 문법은 AspectJ 에서 사용하는 문법인데 이 부분은 상당히 많은 양을 포함하므로 다음에 포스팅을 따로 해보려고 한다.
포인트 컷은 추가적으로 &&(AND), ||(OR), !(NOT) 연산이 가능하다.
하지만 매번 Advice를 추가 할 때마다 pointcut을 위 처럼 적는 것은 상당히 불편하다. 따라서 이 부분을 나눌 수 있도록 @Pointcut 애노테이션을 제공하고 있다.
@Pointcut("execution(* hello.aop.test..*(..))")
private void test(){} // 접근제어자 public도 가능
다음과 같이 작성이 가능하다. 만약, 같은 Aspect 내에서만 사용되어야 하는 pointcut 이라면 private로 선언을 하고, 다른 Aspect 에서도 사용 될 필요가 있다면 public으로 접근제어자를 지정해서 불러오는 것이 가능하다.
이렇게 포인트컷을 위해 생성된 메서드를 메서드의 이름과 파라미터를 합쳐서 포인트컷 시그니처라고 부른다.
주의해야 할 점은 반환타입이 무조건 void여야 한다는 것이다.
하지만 가져오는 방식이 조금 다르다.
포인트 컷을 어드바이스에 가져오는 방법을 확인해보자.
// 같은 Aspect 클래스에 있는 경우
@Around("test()")
public Object test(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("log Start");
Object result = joinPoint.proceed();
log.info("log End");
}
// 다른 Aspect 클래스에 있는 경우
@Around("hello.aop.test.Pointcut.test()")
public Object test(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("log Start");
Object result = joinPoint.proceed();
log.info("log End");
}
위와 같이 같은 패키지에 있는 경우라면 포인트컷 시그니처만 사용하면 되지만, 외부에 있는 경우에는 패키지.클래스명.포인트컷 시그니처 까지 입력을 해주어야 한다.
어드바이스 우선순위
때때로 어드바이스에 대한 우선순위를 적용하고 싶을 수가 있다. 예를 들어서, A 부가 기능과 B 부가 기능이 있을 때, B를 먼저 사용하고 A를 사용한다거나 A를 먼저 사용하고 B를 사용하는 경우이다.
이럴 경우에 @Order 애노테이션을 사용해서 우선순위를 적용 할 수 있는데, 문제는 @Order가 클래스 단위에서만 적용이 가능하다는 부분이다.
@Aspect
@Slf4j
class AspectOrder {
@Pointcut("execution(* hello.aop.test..*(..))")
private void test1();
@Pointcut("execution(* *..*test.*(..))")
private void test2();
@Aspect
@Order(1)
public static class OneTarget {
@Around("test1()")
public Object test(ProceedingJoinPoint joinPoint) {
log.info("one test start");
Object result = joinPoint.proceed();
log.info("one test end");
}
}
@Aspect
@Order(2)
public static class TwoTarget {
@Around("test1() && test2()")
public Object test(ProceedingJoinPoint joinPoint) {
log.info("two test start");
Object result = joinPoint.proceed();
log.info("two test end");
}
}
}
이렇게 내부 클래스를 선언해서 사용하거나 외부에 해당 어드바이스의 우선순위를 적용하기 위한 클래스를 따로 선언해주어야 한다.
내부 클래스 사용시에는 Aspect 클래스를 빈으로 등록을 해주어야 하기 때문에 public으로 선언해주는 것을 잊지 말아야 한다.
참고로 Order의 값이 낮을 수록 높은 우선순위를 가진다.
이어서 어드바이스의 종류를 살펴보자.
Advice의 종류
어드바이스의 종류에는 5가지가 있다. @Around, @Before, @AfterReturning, @AfterThrowing, @After 이다. 각각이 무엇을 담당하는지 알아보자.
@Around
메서드 호출 전후에 수행되는 어드바이스이다. 해당 어드바이스가 가장 강력한 어드바이스이고 조인 포인트 실행 여부 선택이 가능하고 반환값 변환이나 예외 변환또한 가능하다.
@Around와 달리 나머지 4개는 직접 조인포인트를 실행하는 지점을 정 할 수 없다. Around를 세부적으로 나눠놓은 기능들이기 때문이다.
실행 시점을 살펴보면 다음과 같다.
또한 주의해야 할 점은 Around만 JoinPoint 인터페이스를 상속받은 ProceedingJoinPoint를 사용하고, 4개의 세부 종류들은 JoinPoint 인터페이스를 사용한다.
@Around("test1()")
public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
try {
//@Before
Object result = joinPoint.proceed();
//@AfterReturning
return result;
} catch (Exception e) {
//@AfterThrowing
throw e;
} finally {
//@After
}
}
@Before
@Before("test()")
public Object test(JoinPoint joinPoint) throws Throwable {
log.info("log Start");
}
조인포인트가 실행되기 이전에 실행된다.
@AfterReturning
@AfterReturning(value = "test()", returning = "result")
public Object test(JoinPoint joinPoint, Object result) throws Throwable {
log.info("log end");
}
조인포인트 정상 완료 후에 실행된다. 이 때, returning 속성이 필요하다.
returning 속성의 값은 어드바이스 메서드의 매개변수 이름과 동일해야 하고, 반환 값은 실제 실행되는 target의 반환 값의 부모 타입이거나 자기 자신이여야 한다. 형제 혹은 자식이라면 제대로 수행되지 않는다.
@AfterThrowing
@AfterThrowing(value = "test()", throwing = "ex")
public Object test(JoinPoint joinPoint, Throwable ex) throws Throwable {
log.info("log ex", ex);
}
메서드가 예외를 던지는 경우에 실행된다. 이때, throwing 속성을 사용해야 하며, 조건은 AfterReturning과 동일하다.
@After
@After(value = "test()")
public Object test(JoinPoint joinPoint) throws Throwable {
log.info("aop end");
}
조인포인트가 정상 수행하던지 예외 수행하던지에 관계 없이 무조건 수행된다.
이는 마치 finally와 유사하다. 따라서 리소스 해제 시에 자주 사용된다.
참고로 스프링은 5.27 버전부터 조인포인트의 우선순위를 다음과 같이 정해두었다.
하지만 해당 부분을 읽으면서 다음과 같은 생각이 강하게 들 것이다. 그냥 Around만 사용하면 되지 나머지 4개는 왜 사용을 해야하는 거지?
이는 좋은 설계를 위한 제약을 주기 위해서이다. Around 사용 시 ProceedingJoinPoint에 있는 proceed() 메서드를 수행하지 않으면 프록시가 전파되지 않는다거나 하는 문제로 인해 AOP가 제대로 수행되지 않는 다는 문제가 있다.
이는 큰 문제가 없을 것 같으나, 만약 자원을 반환하는 release 같은 것을 추가해두었는데 개발자가 실수로 제대로 수행되지 않도록 하면 운영에 있어서 큰 문제가 발생 할 것이다.
따라서, 4가지에 대해서 좀 더 세부적으로 제약을 걸고 수정 시에도 어떻게 수행 할 것인지에 대한 고민 범위를 줄여 개발자들간에 커뮤니케이션과 실수를 줄이기 위한 좋을 설계이기 때문에 존재한다.
만약, 혼자 개발하거나 소수의 인원끼리 개발한다면 문제 없겠지만 프로젝트 규모가 크고 다양한 사람들이 코드를 봐야한다면 세부적으로 구분하는 것이 좋을 것 같다.
해당 게시물을 인프런 김영한님의 스프링 핵심 원리 - 고급편 을 바탕으로 작성되었습니다.
'Backend > Spring' 카테고리의 다른 글
Spring Security OAuth2 주요 용어와 인증 방식 (0) | 2023.08.14 |
---|---|
Invalid character found in method name 에러 (2) | 2023.05.07 |
스프링의 빈 후처리기 (0) | 2023.04.24 |
[Intellij] JUnit Test 시 No tests found for given includes: (0) | 2023.04.22 |
자바 리플렉션과 이를 이용한 JDK 동적 프록시와 CGLIB (0) | 2023.04.20 |