AOP 1편을 잘 마치고 이제 실전에서 적용할 방법들, 주의사항들에 대해 다루어 보자.
1. 포인트컷
아래 사진이 바로 예시 상황이다. Service는 Repository를 의존하고 있고, orderItem()메서드를 호출하면 Repository에서 save메서드를 호출한다. 만약 save의 인자인 itemId가 "ex"이면 예외를 발생시킨다.
지금까지 배운 내용으로는 아래와 같은 코드로 @Aspect를 구현한다.
@Slf4j
@Aspect
public class AspectV1 {
@Around("execution(* hello.aop.order..*(..))")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable{
log.info("[log] {}", joinPoint.getSignature()); // joinPoint 시그니처
return joinPoint.proceed();
}
}
실행 전에 log를 찍고 핵심기능을 호출한다.
@Around 내부의 execution()에 대해 있는데 이 부분에 대해서는 밑에서 자세히 다루겠당. 간략하게 말하자면 포인트컷 적용 대상 선정인것인데 이러한 것을 포인트컷 지시자라고 부른다. 위 포인트 컷은 "모든 반환 타입에 대해 hello.aop.order 패키지와 하위 패키지의 모든 메서드에 대해(함수 인자와 상관없이) 적용하라" 라는 의미이다.
물론 위처럼 만들고 등록은 반드시 해야한다. 등록시 @Component를 사용하거나 수동 빈을 사용하거나 메인에서 @Import 를 사용한다. 이런 설정파일에 대해서는 @Component보다 나머지 두 방식이 나는 더 좋다고 생각한다.
이제 포인트 컷들을 분리할 수 있다. 포인트 컷을 다시 사용하고 싶을 수 도 있고 우리는 이럴때 복붙을 절대 하지 않는다.(현명한 개발자라면!)
@Slf4j
@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{
...로깅 로직...
}
//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());
}
}
}
위 코드 처럼 @Pointcut이라는 어노테이션을 통해 포인트컷을 선언한다. 이 어노테이션과 메스드 선언을 합쳐 포인트컷 시그니처라고 부른다. 메서드 반환 타입은 반드시 void 여야만 한다.
이렇게 되면 어드바이스 로직에는 @Around에 포인트컷 시그니쳐 이름을 등록하면 포인트 컷이 적용된다. 뿐만 아니라 논리 연산 &&, ||, ! 같은 것도 가능하다!
뿐만 아니라 포인트 컷 시그니처 모음을 하나의 클래스로 분리해낼 수도 있다. 그렇게 되면 아래 처럼 패키지 이름까지 잘 적어주어야만 한다.
@Around("hello.aop.order.aop.Pointcuts.allOrder()")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable{
...로깅 로직...
}
2. 어드바이스 순서와 종류
이전 글에서 proxyFactory.addAdvisor() 순서에 따라서 어드바이저가 적용된다고 다루었다. 하지만 이제는 AspectJ의 @Aspect의 경우 어떻게 동작할까? 코드 상에서 먼저 있으면 먼저 적용될까? 그렇지 않다. 어드바이스 적용 순서는 @Order를 이용한다. 이때 @Order의 적용은 @Aspect와 함께 조합되기 때문에 @Around에 붙지 못한다. 그렇다면 클래스 단위로 분리해야 한다. 아래 코드처럼.
@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{
...로깅 로직...
}
}
@Aspect
@Order(1)
public static class TxAspect {
@Around("hello.aop.order.aop.Pointcuts.orderAndService()")
public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
...tx 로직...
}
}
}
위 처럼 두 개의 클래스를 쪼개어 @Order어노테이션으로 순서를 부여했다.
어드바이스 종류는 @Around 뿐만 아니라 다양하게 존재한다. 하나씩 알아보자.
package hello.aop.order.aop;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
@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());
}
}
}
기존의 @Around의 경우 핵심 기능을 호출하는 코드가 반드시 필요하다. 이 핵심 기능 호출 코드를 중심으로 @Around로서 부가기능을 호출한다는 뜻인데 이를 작은 단위로 나눌 수 있을 것이다.
핵심 기능 호출 전 / 핵심 기능 정상 리턴 / 핵심 기능 오류 발생 / 핵심 기능 정상, 오류 이후. 마치 try-catch-finally 관계와도 같다. 위 코드를 각각을 이용해 불러낸 코드는 아래와 같다.
@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] {} return {}", joinPoint.getSignature(), result);
}
@AfterThrowing(value = "hello.aop.order.aop.Pointcuts.orderAndService()", throwing = "ex")
public void doThrowing(JoinPoint joinPoint, Exception ex){
log.info("[ex] {} message", joinPoint.getSignature(), ex);
}
@After("hello.aop.order.aop.Pointcuts.orderAndService()")
public void doAfter(JoinPoint joinPoint){
log.info("[after] {}",joinPoint.getSignature());
}
차이점을 보게 되면 함수 인자로 ProceedingJoinPoint가 아닌 JoinPoint를 받는다. 그 이유는 @Around는 핵심 기능을 호출해야하는데 위 코드들은 그럴 필요가 없기 때문이다.
@AfterReturning과 @AfterThrowing 같은 경우는 추가적인 인자가 있고 어노테이션 상에도 returning과 throwing이 존재하는데 보면 핵심 기능 결과값을 의미한다. @Around 같은 경우는 결과 값을 조작해 낼 수 있었지만, 두 방식의 경우는 그러하지 못하게 되어 있다. 물론 인자를 생략할 수 있다. JoinPoint 마저도 생략 가능!
JoinPoint의 기능에 대해 좀더 다루어보자.
Signature signature = joinPoint.getSignature();//적용될 메서드에 대한 설명
Object[] args = joinPoint.getArgs();//메서드 인수
Object proxy = joinPoint.getThis();//프록시 객체
Object target = joinPoint.getTarget();//대상 객체
String info = joinPoint.toString();//조언되는 방법에 대한 정보들
위 코드 말고도 다양한 정보를 얻어낼 수 있다. 물론 @Aspect 내에서 동일한 조인 포인트에 대한 우선순위는 보장 된다. 그렇지 않으면 @Before 이런거 사용하기 너무 불편할 것이다.. 각각을 다 클래스로 만들어야 하니깐. 아래 사진처럼.
하지만 굳이 @Around 말고 다른 걸 만든 이유는 무엇일까? 은근히 언급했듯이 빈 후처리기는 정말 막강하기에 핵심 기능을 호출을 하지 않으면 부가기능에서 끝나버린다. 뿐만 아니라 유지보수 상에서도 어떤 의도를 가진 프록시인지 어노테이션 이름만 보아도 알 수 있다. 그러한 이유로 만들어 졌다고 한다. 이를 통해 명언 한가지를 배웠다.
좋은 설계는 제약이 있는 것이다.
-영한님-
ㅜㅜ 내가 존경하는 김영한님.. 정말 영한님 같은 개발자가 되고 싶다. 스프링의 다양한 기능에 대해 아는 것 뿐만 아니라 좋은 설계란 무엇인가, 객체 지향 스럽다는 것은 무엇이고 이를 만족하기 위해 어떤 방법이 있을까?와 같은 개발 실력과 더불어 설계에 대한 깊은 통찰력을 가진 개발자가 되고 싶다.
3. 포인트컷 지시자(PCD)
이제 어드바이스 종류에 대해서도 배웠으니 포인트컷에 대해서도 다루어야 겠죠? execution 말고도 편리하게 적용할 여러 방법을 스프링은 제공했다. 잘 정리해서 앞으로 사용할 일이 있을 때 찾아서 잘 적용하자. 엄한 곳 프록시로 만들 일은 없어야 한다.
3.1 execution 지시자
메소드 실행 조인 포인트를 매칭한다. 스프링 AOP에서 가장 많이 사용하고, 기능도 복잡하다. 문법은 아래와 같다.
execution(modifiers-pattern? ret-type-pattern
declaring-type-pattern?name-pattern(param-pattern) throws-pattern?)
execution(접근제어자? 반환타입 선언타입?메서드이름(파라미터) 예외?)
?는 생략 가능하다는 뜻. 접근 제어자 - private, public 반환타입 - 메서드의 반환 타입(String, Integer) 선언타입 - 클래스와 클래스의 패키지 경로를 의미한다.(A.B.C.D) 메서드 이름 - 메서드 이름(hello) 파라미터 - 파라미터(String, Integer) 예외 - 예외 사항.(Exception) 이때 재밌는 표현식이 있다. '*'는 아무 값이라는 의미(리눅스의 ?)이고 '..'은 타입 개수 상관 없다는 의미이다.(리눅스의 *) 그래서 아래와 같은 표현이 가능하다.
@Test
void packageMatchSubPackage2(){
pointcut.setExpression("execution(* hello.aop..*.*(String, *, ..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
위 예시는 hello.aop의 최소 하나의 하위 패키지 중 최소 하나의 String 타입 메서드 인자+어떤 타입이든 인자 하나+다른 인자에 대한 제약은 상관 없고(있어도 되고 없어도 되고 타입 무관) 반환타입에 상관없이 모든 메서드에 대해 적용이라는 의미이다. 최소 하나의 하위 패키지라는 의미는 *가 하나 들어갔고 ..이 표현되었으니 하나 이상의 패키지가 필요하다.
뿐만 아니라 부모를 기준으로 pointcut을 만들었다고 하자. 자식에는 부모 메서드를 override 하거나 상속 받은 A라는 메서드가 존재한다고 할때 A의 경우 어드바이저가 적용 되지만, 부모에는 없는 B에 대해서는 적용 되지 않는다.
3.2 within 지시자
within 지시자는 특정 타입 내의 조인 포인트에 대한 매칭을 제한한다. 쉽게 이야기해서 해당 타입이 매칭되면 그 안의 메서드(조인 포인트)들이 자동으로 매칭된다. 그냥 execution에서 타입 매치(패키지와 클래스)만 신경 쓴다. 타입에 매치되면 모든 메서드에 적용된다.
@Test
void withinExact() {
pointcut.setExpression("within(hello.aop.member.MemberServiceImpl)");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
void withinStar() {
pointcut.setExpression("within(hello.aop.member.*Service*)");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
void withinSubPackage() {
pointcut.setExpression("within(hello.aop..*)");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
위 코드를 보면 쉽게 이해가 될 것 같다. 물론 *와 .. 사용 가능하다. 하지만 주의점이 execution과 달리 부모 타입을 지정하면 안된다. 무조건 정확하게 매칭 되어야 한다.
3.3 args 지시자
인자가 주어진 타입의 인스턴스인 조인 포인트로 매칭(?) 뭔소리야.. 기본 문법은 execution의 args부분과 같다. 차이점은 execution은 파라미터 타입이 정확하게 매칭 되어야 하지만 args같은 경우는 부모 타입을 허용한다. 왜냐하면 args는 실제 넘어온 파라미터 객체 인스턴스를 보고(런타임 시점) 판단하기 때문이다. 반면 execution은 Signature 정보(정적인 정보)를 보고 판단하기 때문에 이러한 차이가 발생한다.
아래 코드의 몇가지만 보아도 눈에 확 들어올 것이다.
assertThat(pointcut("args(String)")
.matches(helloMethod, MemberServiceImpl.class)).isTrue();
assertThat(pointcut("args(Object)")
.matches(helloMethod, MemberServiceImpl.class)).isTrue();
assertThat(pointcut("args(java.io.Serializable)")
.matches(helloMethod, MemberServiceImpl.class)).isTrue();
눈에 띄는 것은 Serializable인데 String의 인터페이스 중 하나이다. args 지시자는 그 자체로는 잘 안쓰인다. 당연..
3.4 @target, @within
어노테이션을 기준으로 조인한당. @target은 인스턴스의 모든 메서드를 조인 포인트로 적용하고 @within은 해당 타입 내에 있는 메서드만 조인 포인트로 적용한다. 좀 더 자세히 말하자면, @target은 자식 클래스 뿐만 아니라 부모 클래스에도 어드바이스가 적용되고 @within은 자식 클래스에 대해서만 적용된다.(within 지시자 처럼) 요 두 녀석또한 런타임 시점에 판단된다.
@Around("execution(* hello.aop..*(..)) && @target(hello.aop.member.annotation.ClassAop)")
public Object atTarget(ProceedingJoinPoint joinPoint) throws Throwable{
log.info("[@target] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
사용 법은 위와 같이 @target(어노테이션의 패키지명.어노테이션) 이름과 같이 사용한다. 참고로 ClassAop는 이미 만들어낸 클래스에 적용하는 어노테이션 이다.
중요한 것은 3.3,3.4,3.5인 args와 @target, @args같은 경우 실제 객체 인스턴스가 생성 되고 실행 될 때(런타임) 사용된다. 이 경우 프록시가 있어야 실행 시점에 판단할 수 있다. 하지만 프록시를 생성하는 시점은 스프링 컨테이너가 만들어지는 애플리케이션 로딩 시점. 이때 스프링 입장에서는 모든 빈에 대해서 AOP프록시를 적용하려고 노력해야 한다.
왜냐하면 정적인 정보를 이용할 경우 프록시를 만들지 말지를 결정할 수 있지만, 동적일 경우 가지고 있는 프록시를 기준으로 적용 하기 때문에 일단 만들고 봐야한다 .
하지만 스프링 내부 빈 중에서는 final로 선언된 빈들이 있고 이 경우 프록시를 만들지 못하기 때문에 빌드 시점에 에러가 발생한다. 그러하므로 이 3가지는 다른 지시자들과 함께 사용해야 한다.
3.5 @annotation, @args
@annotation은 메서드에 어노테이션이 있으면 매칭한다.
@Around("@annotation(hello.aop.member.annotation.MethodAop)")
public Object doAtAnnotation(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[@annotation] {}",joinPoint.getSignature());
return joinPoint.proceed();
}
참고로 MethodAop는 사전에 만들었고 메소드 레벨에 붙이는 어노테이션이다. 보다시피 @MethodAop라는 어노테이션이 붙은 클래스일 경우 aop가 적용 된다.
@args는 인수의 런타임에 주어진 어노테이션을 갖는 조인 포인트이다. 예를 들어 test 밑에 @Check라는 어노테이션을 함수 인자로 받고 있으면 적용 된다.
pulic order(@Check String name);
@args(test.Check)
내가 기존에 만들었던 검증 패턴을 이와 같이 적용하면 aop 방식으로 collection 검증 문제를 해결할 수 있을 것 같다. ㅎㅎ 하지만 런타임인 것을 조심!
3.6 bean
스프링 전용 포인트컷 지시자이다. 빈의 이름으로 지정한다.
@Around("bean(orderService) || bean(*Repository)")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[bean] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
bean의 이름이 확정적일때 좋다.
4. 매개변수 전달
포인트컷 표현식을 사용해서 어드바이스에 매개변수를 전달할 수 있다. [this, target, args,@target, @within, @annotation, @args]들의 경우 포인트컷 지시자의 인자를 통해 넘겨 받을 수 있다. 각각에 대해 다루겠다.
그 전에 다루지 않은 this와 target에 대해 말해주자면 this는 스프링 컨테이너에 들어가 있는 프록시 객체에 매칭 될 경우이고 target은 스프링 컨테이너에 들어가지 않은 프록시가 가리키는 타겟과 매칭 될 경우 조인한다.
@Pointcut("execution(* hello.aop.member..*.*(..))")
private void allMember(){}
위 같은 포인트컷 시그니처가 있다고 하자.
물론 아래의 코드와 같이 joinPoint를 이용해서 args를 가져올 수 있다. 이전에 다루었던 것 처럼.
@Around("allMember()")
public Object logArgs1(ProceedingJoinPoint joinPoint) throws Throwable {
Object arg1 = joinPoint.getArgs()[0];
log.info("[logArgs1] {}, arg={}", joinPoint.getSignature(), arg1);
return joinPoint.proceed();
}
하지만 너무 불편하다 아래 하나씩 예시를 들어보자.
@Around("allMember() && args(arg,..)")
public Object logArgs2(ProceedingJoinPoint joinPoint, Object arg) throws Throwable {
log.info("[logArgs2] {}, arg={}", joinPoint.getSignature(), arg);
return joinPoint.proceed();
}
args 같은 경우 인자를 하나씩 가져올 수 있다. 이때 인자의 type은 어드바이스의 인자 타입으로 넘어오는데 이때 매치가 되지 않으면 포인트컷이 적용되지 않는다.
@Before("allMember() && target(obj)")
public void targetArgs(JoinPoint joinPoint, MemberService obj){
log.info("[target]{}, obj={}",joinPoint.getSignature(), obj.getClass());
}
target 같은 경우 프록시가 MemberServie를 구현하므로 위와 같은 방식으로 가져올 수 있다.
@Before("allMember() && @target(annotation)")
public void atTargetArgs(JoinPoint joinPoint, ClassAop annotation){
log.info("[@target]{}, obj={}",joinPoint.getSignature(), annotation);
}
@target은 클래스 단위의 어노테이션에 적용 된다. 이때 만약 어노테이션에 값을 넣었다면 정말 유용할거 같다.
@within은 @target과 다르게 부모 타입에 대해서는 적용 되지 않는다는 점 말고는 같을 것이다.
@Before("allMember() && @annotation(annotation)")
public void atAnnotationArgs(JoinPoint joinPoint, MethodAop annotation){
log.info("[@annotation]{}, obj={}, annotationValue={}",joinPoint.getSignature(), annotation,annotation.value());
}
위 경우는 @annotation. 즉, 메서드 단위에 붙어있는 어노테이션을 대상으로 적용 된다. annotation.value()를 통해 값을 받아온 것을 볼 수 있다.
5. this와 target
this와 target은 잠시 언급했듯이 프록시를 대상으로 조인하느냐 target 객체를 대상으로 조인하는지의 차이에 대해 중점을 두고 있다고 한다. 그럼 이게 무슨 차이를 불러낼까?
this와 target 모두 *와 같은 패턴을 사용할 수 없다는 것과 부모타입을 허용한다는 특징을 가지고 있다. 이때 부모 타입을 허용한다는 것이 중요하다. 그 이유는..
JDK 동적 프록시의 경우 -> interface를 통해 구현.
CGLIB의 경우 -> 실제 클래스를 상속받아 구현.
이 차이가 있다. this와 target 모두 부모 타입을 허용하니, jdk 동적 프록시를 사용했을 경우 구체 클래스 타입으로 this()했을 때 join 될 수 가 없다! 아래 코드 처럼.
//부모 타입 불허용
@Around("this(hello.aop.member.MemberServiceImpl)")
public Object doThis(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[this-impl] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
만약 위 처럼 this로 MemberServiceImpl을 매칭할 경우 프록시는 인터페이스인 MemberService를 상속받아 구현했기에 MemberServiceImpl의 존재를 알 수 가 없다. MemberService 인터페이스가 부모라면 MemberServiceImpl과 해당 프록시는 형제 관계이기 때문에 구체 클래스를 기준으로 this 조인이 되지 않는다. 이점을 주의하자. 관련된 내용은 다음 글에 자세히 다룰 것이기 때문에 이해가 되지 않는다 하더라도 넘어가자.
마무리
이제 다음 글로서 AOP의 마지막인 스프링 aop의 제약사항과 이를 뛰어넘기 위한 스프링에 대해 다루어 보겠다. 화이팅!
'백앤드(스프링)' 카테고리의 다른 글
스프링(TDD) 테스트 코드 작성 (0) | 2022.11.07 |
---|---|
AOP 2편[내부 호출과 프록시 기술의 한계, 마무리] (0) | 2022.10.30 |
AOP 1편 [@Aspect, AOP 1편 정리] (0) | 2022.10.30 |
AOP 1편 [프록시 팩토리, 빈 후처리기] (0) | 2022.10.28 |
AOP 1편 [디자인 패턴-2, 동적 프록시] (0) | 2022.10.28 |