본문 바로가기

Spring

[Spring] AOP

* 해당 블로그의 모든 내용과 사진은 글 제일 아래 참고자료를 작성하였습니다.

 

간단한 AOP 맛보기에 이어 이번 글은 AOP의 다양한 사용에 대해 정리하는 글입니다.

 

 

[Spring] Rest API와 AOP

Rest API 다양한 Client들이 나오면서 브라우저는 그에 맞는 적절한 응답을 해주어야합니다. 이를 해결하기위해 Rest Api가 등장하였습니다. Rest Api란 웹서버의 자원을 클라이언트에게 구애받지 않고

mirr-coding.tistory.com

 

 

AOP 사용 방법

 

위의 작업은 AdvisePointCut이 들어간 Advisor를 빈 후처리기에 등록 시키는 것과 같습니다.

 

 

위의 작업은 AspectV1와 비교해보면 하나의 함수안에 PointCut 그대로 적용하지않고, 분리하여 사용하는 방법입니다.

여기서 Signature는 메서드의 이름과 파라미터를 합친것의 의미를 가집니다.

 

 

또한 위의 작업과 같이 여러개의 PointCuts을 적용할 수도 있습니다.

 

 

 

그리고 외부 클래스로 만들어서 위와같이 사용할 수 도 있습니다.

 

위의 코드들은 편의성을 위한 방법들이 제공되었다면 해당 코드에서도 한가지 고민을 할 수 있습니다.

hello.aop.order.aop.PointCuts.orderAndService() 와 같이 로그트랜잭션기능을 둘다 사용하려했을때,

그 순서는 어떻게 될까요?

 

 

위와 같이 로깅작업이 먼저 실행되고 이후에 트랜잭션이 처리가 됩니다.

 

하지만 트랜잭션 작업을 포함하지 않는 시간을 재고싶다면 로깅과 트랜잭션의 작업순서를 바꿔야될텐데요.

이럴때 어떻게 할 수 있을까요?

 

해결 방법은 @Order를 사용하는 것인데, 해당 어노테이션은 메서드 단위가 아니라 클래스단위의 순서를 지정해주는 것입니다.

 

 

위와 같이 클래스 단위로 나누어 순서를 지정해줄 수 있습니다.

 

그럼 Advise의 종류에 대해 알아볼게요.

 

 

@Around 메서드 호출 전후에 수정, 조인 포인트 실행 여부선택, 반환 값 변환, 예외 변환 가능
@Before 조인 포인트 실행 이전에 실행
@After Returning 조인 포인트가 정상 완료 후 실행
@After Throwing 메서드가 예외를 던지는 경우 실행
@After 조인 포인트가 정상 또는 예외에 관계없이 실행 (finally)

 

다음과 같이 5가지가 존재합니다.

예제를 한번 볼까요?

 

 

위의 표를 보면 알다싶이 @Around 는 모든 기능이 제공되어 다른 Advise들을 대체할 수 있습니다. 

 

 

위와 같이 다른 Advise 들로 @Around를 대체할 수도 있습니다.

여기서 모든 Advise들은 JoinPoint를 첫번째 파라미터로 사용할 수 있습니다. (생략가능)

단, @Around는 ProceedingJoinPoint를 사용해야합니다. 그 이유는 ProceedingJoinPoint에 proceed() 가 들어있기 때문입니다.

다른 Advise 들은 개발자가 직접 proceed를 호출할 수 없는데, @Around는 호출 해야만 하기에 그렇습니다.

따라서 @Around를 제외한 다른 Advise는 작업의 흐름을 변경할 수는 없습니다.

여기서 @AfterReturningreturning을 통해 return 값을 받아볼 수 있습니다. 하지만 직접 리턴하진 않습니다.

또한 @AfterThrowing 은 자동으로 예외를 throw 해줍니다.

 

 

그럼 JoinPoint와 ProceedJoinPoint를 표로 알아볼게요.

JoinPoint

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

ProceedingJoinPoint

proceed() 다음 Advise나 Target을 호출할 수 있습니다.

 

여기서 ProceedingJoinPointJoinPoint 인터페이스상속받은 것으로 JoinPoint의 기능을 다 사용할 수 있습니다.

 

 

 

여기서 returing 에 설정된 이름으로 String result와 같이 받아올 수 있지만, 리턴되는 타입에 적용할 수 없다면 aop 호출 자체가 되지않습니다.

 

Advise 우선순위

같은 Target에 대하여 어떤 Advise를 먼저 실행할지에 대한 우선순위입니다.

 

@Around - @Before - @After - @AfterReturning - @AfterThrowing

의 순으로 동작을 하고, 자명하게도 리턴될때는 반대로 실행이 됩니다.

 

Around 외에 다른 Advise가 존재하는 이유

개발자로 하여금 @Around를 사용했을 때의 joinPoint.proceed()를 사용하지 않는 실수를 방지하는 것입니다.

 

또한 해당 프록시를 작성한 의도가 명확하게 알 수 있기 때문입니다.

 

 

PointCut

PointCut에는 다양한 매칭 조건이 존재합니다.

 

접근제어자 : public (생략가능)

반환타입 : String 

선언타입 : hello.aop.member.MemberServiceImpl(생략가능)

메서드이름 : hello 

파라미터 : (String)

예외 : (생략가능)

NameMatch - PointCut

 

위의 예제들은 한번보면 다 이해할만한 예제입니다.

여기서 packageMatchSubPackage를 눈여겨 봐야합니다.

packageExactFalse 메서드에서 선언타입과 메서드명을 표현할 때 hello.aop.*.* 으로 사용하여 실패의 결과가 나옵니다. 

해당 *.* 은 클래스와 메서드 명이므로, 패키지 명이 hello.aop가 되는데 따라서 실패하는 것입니다.

hello.aop 패키지 하위 패키지까지 모두 포함을 시키려면 hello.aop.. 으로 .. 을 표시해 적용시키면 됩니다.

 

또한 nameMatcherStar 메서드에서 보았듯이 *의 사용은 앞뒤로 가능합니다.

TypeMatch - PointCut

 

위는 Type Match - PointCut 예제 입니다.

typeExactMatch 메서드는 정확하게 Match 시켜줬으니 당연히 성공하지만, 

typeMatchSuperType 메서드는 자식 객체도 Match가 되는것을 알 수 있습니다. 

 

 

하지만 위의 예제는 어떨까요?

안타깝게도 자식 객체의 메서드까지는 Match가 되지 않습니다.

즉, 메서드는 부모에서 선언된 것만, 클래스는 자식 클래스 까지 입니다!

 

ParameterMatch - PointCut

 

위와 같이 한번 보면 이해할 예제들이지만, 여기서도 ..이 보입니다.

파라미터에서 ..은 파라미터의 개수와 타입에 상관없다는 이야기입니다.

 

또한 argsMatchComplex 메서드에서 보다싶이 앞에 무조건 String이 와야하는 경우라면 위와같이 작성할 수 있습니다.

 

Within - PointCut

특정타입 내의 조인 포인트에 대한 매칭을 제한하는 지시자입니다.

쉽게 이야기하면 execution에서 Type 부분만 가져온 지시자입니다.

 

 

위와 같이 Type Match만 하는 것입니다.

하지만 within 에서 주의할 점이 하나 있는데 

자식 객체도 Match가 되는 execution과는 달리 within은 정확하게 맞아야 한다는것입니다.

 

Args - PointCut

인자가 주어진 타입의 인스턴스인 조인포인트로 매칭이되는 지시자입니다.

쉽게 이야기하면 execution에서 Args 부분만 가져온 지시자입니다.

 

 

하지만 역시 다른 점이 존재하는데 Args 는 상위타입을 허용하지만 execution은 정확하게 입력해야한다는 것입니다.

정확하게 이야기하면 execution은 메서드의 시그니처로 판단을 하고, Args는 런타임때 전달된 인수로 판단을 하는 것입니다.

 

 

위와같이 실제 적용을 할 수 있습니다.

 

@Target, @Within - PointCut

해당 어노테이션들은 해당 타입에있는 어노테이션으로 AOP 적용여부를 판단합니다.

 

 

설정은 다음과 같습니다. Child 클래스에 @ClassAop 이 적용되었습니다.

 

 

해당 실험의 결과입니다.

 

 

여기서 차이를 알 수 있네요. @Target 은 부모 자식클래스의 메서드 둘다 호출이 됐는데, @Within은 자식 클래스의 메서드만 호출이 되었습니다.

 

 

참고

args, @args, @target 과 같은 지시자는 단독으로 사용하면 안됩니다.

이유는 위의 지시자들은 실제 객체 인스턴스가 생성되고 실행될 때 Advise 적용여부를 확인할 수 있습니다. (런타임)

하지만 실행시점에 일어나는 포인트 컷 적용여부도 결국 프록시가 있어야 실행 시점에 판단할 수 있는데, 여기서 스프링 컨테이너가 프록시를 생성하는 시점은 스프링 컨테이너가 만들어지는 애플리케이션 로딩시점에 적용할 수 있습니다. 

따라서 스프링은 프록시가 없으면 실행시점에 판단을 못하는것을 방지하기 위해 args, @args, @target 지시자들이 있으면 모든 스프링 Bean에 AOP를 적용하려고 합니다. 

문제는 이렇게 모든 AOP 프록시를 적용하려고하면 스프링이 내부에서 사용하는 Bean 중에 final로 선언된 Bean도 있기때문에 오류가 발생할 수 있습니다.

따라서 이러한 표현식은 최대한 프록시 적용대상을 축소하는 표현식과 함께 사용해야합니다.

 

 

Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'org.springframework.boot.autoconfigure.AutoConfigurationPackages': Could not generate CGLIB subclass of class org.springframework.boot.autoconfigure.AutoConfigurationPackages$BasePackages: Common causes of this problem include using a final class or a non-visible class

 

 

@Annotation - PointCut

메서드가, 주어진 어노테이션을 가지고있는 조인포인트를 매칭합니다.

 

 

다음과 같은 상황에서 MemberService의 hello 메서드에 @MethodAop가 달려있기때문에 해당 doAtAnnotation 에서 로그를 찍을 수 있습니다.

 

또한 다음과 같이 특정 값을 저장해놓을 수 있는데,

 

 

이는 다음과 같이 해당 어노테이션에서 꺼내 사용할 수 있습니다.

 

 

 

this,  target - PointCut

위와같이 this 는 프록시 객체를, target은 스프링 컨테이너가 등록시킨 실제 대상 객체를 전달받습니다.

 

this 는 스프링 AOP 프록시를 대상으로 하는 조인 포인트입니다.

target 은 스프링 AOP 프록시가 가르키는 실제 대상을 대상으로하는 조인 포인트입니다.

 

this 는 적용 타입 하나를 정확하게 지정해야합니다. 따라서 * 은 허용하지 않습니다.

하지만 둘다 부모타입은 허용합니다. 

 

또한 thistarget 은 단독으로 사용되기 보다는 파라미터 바인딩에서 주로 사용하게 됩니다.

 

프록시와 내부 호출 문제 

 

설정은 위와 같습니다.

 

 

그에대한 결과는 external 에만 AOP가 적용된 걸 알 수 있습니다.

왜 internal에는 AOP가 걸리지 않은 걸까요?

 

스프링은 프록시 방식의 AOP를 사용합니다.

즉, @Autowired로 가져온 callServiceV0는 실제 객체의 Proxy입니다.

 

따라서 Proxy 를 호출하며 external에는 Advise를 적용하지만 Advise 안에 proceed에서는 실제 객체의 로직이 동작하기 때문에, this.internal() 로직은 프록시가 아닌 실제객체의 호출이므로 Advise가 적용되지 않습니다.

 

이는 스프링 AOP의 한계 중 하나입니다. (AspectJ 를 사용하면 Byte 로 코드를 때려박기 때문에 이런 문제가 발생하지 않습니다.)

 

해당 방법을 해결하는 대안은 있습니다.

 

지연조회

 

다음과 같이 실제 사용하는 시점에 스프링 컨테이너에서 꺼내는 ObjectProvider 를 사용하므로써 해결할 수 있습니다.

 

구조의 변경

스프링에서 가장 권장하고 있는 방법입니다.

 

 

위와같이 상황에 따라 다양한 방법으로 스프링 AOP에 자연스럽게 녹아내릴 수 있는 구조로 변경합니다.

다만, 구조를 변경할 수 있는 선에서 사용합니다.

 

프록시 기술과 한계

JDK 동적 프록시는 구체 클래스로 타입 캐스팅이 불가능한 한계가 있습니다.

 

 

다음과 같은 상황에서 JDK 동적프록시는 인터페이스를 구현한 프록시일 뿐, 해당 인터페이스가 구현한 구현체는 전혀 알지못합니다.

따라서 해당 코드는 ClassCastException이 발생합니다.

 

 

CGLIB 를 사용하면 잘 성공하는 것을 볼 수 있습니다.

 

근데 직관적으로 생각해봤을때 이런 문제는 발생하지 않을거같은데 왜 알아야될까 싶습니다.

하지만 이런문제는 의존관계주입때 발생합니다.

 

 

해당 코드와 같이 MemberServiceImpl을 주입 받는 코드에서 문제가 생깁니다.

 

해당 예제는 인터페이스로 주입받으면 안정성이 높아지는 예시 중 하나입니다. 

 

CGLIB의 단점

대상 클래스에 기본 생성자가 필수입니다.

  • CGLIB는 구체 클래스를 상속받습니다. 자바는 상속을 받으면 자식 클래스의 생성자를 호출할 때 부모 클래스의 생성자도 호출해야합니다.
  • 따라서 해당 부분이 생략되어있다면 생성자 첫줄에 super() 가 자동으로 들어갑니다.
  • 또한 생성자를 2번 호출합니다. 내가 실제 객체를 생성할 때, 프록시 객체가 부모클래스의 인스턴스를 호출할 때 이렇게 2번 호출이 됩니다. (생성자에 과금하는 로직이 있다면 과금이 2번되는 것입니다.)

final 키워드 클래스, 메서드 사용불가합니다.

  • final 키워드가 클래스에 있으면 상속이 불가능하기에 그렇습니다.
  • 메서드에 있다면 오버라이딩이 불가능하기에 그렇습니다.

스프링의 해결책

  • 스프링 4.0 부터 objenesis 라는 특별한 라이브러리를 사용해 생성자 없이 객체 생성이 가능하게 만들었습니다. 드에따라 생성자가 2번 호출되는 문제 또한 해결하였습니다.

 

 


 

● 참고자료 : 스프링 핵심원리 - 고급편 (김영한 | 인프런)

 

'Spring' 카테고리의 다른 글

[Spring] Annotation Processor  (0) 2024.04.11
[Spring] WebSocket  (0) 2023.08.23
[Spring] JPA? Hibernate? Persistence?  (0) 2023.04.30
[Spring] Session (feat. 프로젝트 경험)  (0) 2023.04.28
[Spring] application-{환경}.yml 과 @Value  (0) 2023.04.17