Spring의 AOP

Single Ko·2023년 6월 1일
0

Spring 강의 정리

목록 보기
31/31

AOP 소개

핵심 기능과 부가 기능


  • 애플리케이션은 핵심기능과 부가기능으로 나눌수 있다.

    핵심기능: 해당 객체가 제공하는 고유의 기능.

    부가기능: 핵심기능을 보조하기 위해 제공되는 기능. 단독사용x. 부가기능과 함꼐 사용

    같이 사용하기위해서는 부가기능과 핵심기능이 하나의 객체 안에 섞여 들어가게 됨.
    보통 부가 기능은 여러 클래스에 걸쳐서 함께 사용된다. 이러한 부가 기능을 횡단 관심사
    (cross-cutting concerns)가 된다. 하나의 부가기능이 여러 곳에 동일하게 사용된다는 뜻.

부가 기능 적용 문제

  1. 부가기능 적용에 아주 많은 반복이 필요
  2. 부가기능이 여러 곳에 퍼져 중복 코드를 만들어냄
  3. 부가기능을 변경할 때 중복 때문에 많은 수정이 필요
  4. 부가기능의 적용 대상을 변경할 때 많은 수정이 필요
  • 부가기능을 여러곳에 적용하려면 너무 번거로움(이를 위해 우리는 전부터 Proxy 패턴, 프록시 팩토리, 빈 후처리기 등을
    살펴 보았다.)
  • 소프트웨어 개발에서 변경 지점은 하나가 될 수 있도록 잘 모듈화가 되어야 한다. 그런데 부가 기능처럼 특정 로직을
    애플리케이션 전반에 적용하는 문제는 일반적인 OOP 방식으로는 해결이 어렵다.
  • 이런 것을 모아서 해결하기위해 관점 지향 프로그래밍(Aspect Oriented Programing)

AOP 소개 - Aspect


핵심 기능과 부가 기능의 분리

  • 수많은 개발자가 이런 부가 기능 도입을 위해 오랜기간 고민
  • 부가 기능과 부가 기능을 어디에 적용할지 선택하는 기능을 합해서 하나의 모듈로 만듬 -> Aspect이다

Aspect란 쉽게 이야기해서 부가 기능과, 해당 부가 기능을 어디에 적용할지 정의한 것

참고 : AOP는 OOP를 대체하기위한 것이 아니라 횡단 관심사를 깔끔하게 처리하기 어려운 OOP의 부족한
부분을 보조하는 목적으로 개발되었다.

AspectJ 프레임워크
AOP의 대표적인 구현으로 AspectJ 프레임워크가 있다. 스프링도 AOP를 구현하지만 대부분 AspectJ의
문법을 차용하고, AspectJ가 제공하는 기능의 일부만 제공한다.(AspectJ란 Aspect Java)

AspectJ 프레임워크의 설명

  • 자바 프로그래밍 언어에 대한 완벽한 관점 지향 확장
  • 횡단 관심사의 깔끔한 모듈화
    • 오류 검사 및 처리
    • 동기화
    • 성능 최적화(캐싱)
    • 모니터링 및 로깅

AOP 적용 방식

3가지 방법
1. 컴파일 시점
2. 클래스 로딩 시점
3. 런타임 시점(프록시)

1. 컴파일 시점
.java 소스 코드를 컴파일러를 사용해 .class를 만드는 시점에 부가기능 로직을 추가한다.
쉽게 이야기하자면 부가 기능 코드가 컴파일 되면서 실제 클래스에 들어간다고 생각하면 된다.
이렇게 원본 로직에 부가기능 로직이 추가되는 것을 위빙(Weaving)이라 한다

단점 : 컴파일 시점에 부가기능을 적용하려면 특별한 컴파일러도 필요하고 복잡하다. 잘사용안함

2. 클래스 로딩 시점
자바를 실행하면 자바 언어는.class 파일을 JVM 내부의 클래스 로더에 보관한다. 이때 중간에서
.class파일을 조작한 다음 JVM에 올릴 수 있다. 자바 언어는 .class를 JVM에 저장하기 전에
조작할 수 있는 기능을 제공한다. java.instrumentation 이라 검색해보면 나옴. (참고 :
모니터 툴들이 이 방식을 사용한다) 이런 로딩시점에 에스펙트를 적용하는 것을 로드 타임 위빙이라 한다.

단점 : 자바를 실행할 때 특별한 옵션(java -javaagent)을 통해 클래스 로더 조작기를 지정해야 하는데,
이 부분이 번거롭고 운영하기 어렵다.

3. 런타임 시점
런타임 시점은 컴파일도 끝나고, 클래스 로더에 클래스도 다 올라가서 이미 자바가 실행되고 난 다음을
말한다. 자바의 메인 메서드가 이미 실행된 다음이다. 따라서 자바 언어가 제공하는 범위안에서 부가기능
을 적용해야 한다. 컨테이너의 도움을 받고, 프록시와 DI, 빈 포스트 프로세서 같은 개념들을 총 동원.

프록시를 사용하기 때문에 AOP 기능에 일부 제약이 있다.(final이 붙으면 상속이나 오버라이드가 안됨..
, 다형성이 적용되는 메서드에만 적용가능)

하지만 자바를 실행할 때 복잡한 옵션과 클래스 로더 조작기를 설정하지 않아도 된다. 스프링만 있으면
얼마든지 AOP를 적용할 수 있다.

3가지의 차이점

  • 컴파일 시점 : 실제 대상 코드에 에스팩트를 통한 부가 기능 호출 코드가 포함된다. AspectJ를 직접 사용
  • 클래스 로딩 시점 : 실제 대상 코드에 에스팩트를 통한 부가 기능 호출 코드가 포함된다. AspectJ 직접 사용
  • 런타임 시점: 실제 대상 코드는 그대로 유지. 대신 프록시를 통해 부가 기능이 적용된다. 항상 프록시를
    통해야 부가기능을 사용할 수 있다. 스프링 AOP는 이 방식을 사용한다.

AOP 적용 위치
AOP는 지금까지 학습한 메서드 실행 위치 뿐만 아니라 아래의 위치에 적용 가능

  • 적용 가능 지점(Join Point) : 생성자, 필드 값 접근, static 메서드 접근, 메서드 실행
    • 이렇게 AOP를 적용할 수 있는 지점을 Join point라 한다.
  • AspectJ를 사용해서 컴파일 시점과 클래스 로딩 시점에 적용하는 AOP는 바이트코드를 실제 조작..
    해당 기능을 모든 지점에 다 적용 가능.
  • 하지만!! 프록시 방식을 사용하는 스프링 AOP는 메서드 실행 시점에만 AOP를 적용할 수 있다.
    • 프록시 메서드 오버라이딩 개념으로 동작. 생성자나 static메서드, 필드값 접근에는 프록시 개념이 적용 될 수 없다.
    • 프록시를 사용하는 스프링 AOP의 조인 포인트는 메서드 실행으로 제한 된다.
  • 프록시 방식을 사용하는 스프링 AOP는 스프링 컨테이너가 관리할 수 있는 스프링 빈에만 AOP를 적용할 수 있다.

스프링은 AspectJ의 문법을 차용, 프록시 방식의 AOP방식을 적용. AspectJ를 직접 사용하는것은 아니다.

중요

AspectJ를 사용하려면 너무 방대한 양을 공부해야됨. 자바 관련 설정도 복잡함. 반면 스프링 AOP는 별도의
추가 자바 설정 없이 스프링만 있으면 편리하게 AOP를 사용할 수 있다. 실무에서는 스프링이 제공하는
AOP 기능만 사용해도 대부분의 문제를 해결할 수 있다. (스프링 AOP 기능 학습만해도 힘듬...
근데 이것보다 더 훨씬 복잡하고 어려운 AspectJ는????)

용어 정리

  1. 조인 포인트(Join point)
  • 어드바이스가 적용될 수 있는 위치. 프로그램 실행 중 지점.
  • 추상적인 개념. AOP를 적용할 수 있는 모든 지점이라 생각하면 됨.
  • 스프링 AOP는 프록시 방식으로 조인 포인트는 항상 메소드 실행 지점으로 제한된다.
  1. 포인트컷(Pointcut)
  • 조인 포인트 중에서 어드바이스가 적용될 위치를 선별하는 기능
  • 주로 AspectJ 표현식을 사용해서 지정
  • 프록시를 사용하는 스프링 AOP는 메서드 실행 지점만 포인트컷으로 선별 가능
  1. 타겟(Target)
  • 어드바이스를 받는 객체, 포인트컷으로 결정(어드바이스를 적용받는 실제 객체)
  1. 어드바이스(Advice)
  • 부가 기능
  • Around(주변), Before(전), After(후)와 같은 다앙햔 종류의 어드바이스가 있음. 부가기능이 적용되는 시점.
  1. 에스펙트(Aspect)
  • 어드바이스 + 포인트컷을 모듈화 한 것
  • @Aspect를 생각 하면됨
  • 여러 어드바이스와 포인트컷이 함께 존재
  1. 어드바이저(Advisor)
  • 하나의 어드바이스와 하나의 포인트컷으로 구성
  • 스프링 AOP에서만 사용되는 특별한 용어
  1. 위빙(Weaving)
  • 포인트컷으로 결정한 타겟의 조인 포인트에 어드바이스를 적용하는 것
  • 위빙을 통해 핵심 기능 코드에 영향을 주지 않고 부가 기능을 추가할 수 있음
  • AOP 적용을 위해 에스팩트를 객체에 연결한 상태
    • 컴파일타임, 클래스 로드 타임, 런타임(스프링 AOP의 방식, 프록시 방식)
  1. AOP 프록시
  • AOP 기능을 구현하기 위해 만든 프록시 객체, 스프링에서 AOP 프록시는 JDK 동적 프록시 or CGLIB 이다.
  • @Aspect: 클래스에 애노테이션을 붙여야 스프링의 AOP를 사용 가능
  • execution( hello.aop.order..(..)) 는 hello.aop.order 패키지와 그 하위패키지를 지정하겠다는
    AspectJ 포인트컷 표현식이다. 표현식은 중요함!
  • Spring의 빈으로 등록해야된다. @Aspect는 AOP를 사용이 된다는 것이지 스프링에 빈으로
    등록한것은 아니기때문에!
@Pointcut("execution(* *..*Service.*(..))")
private void allService(){}

@Pointcut("execution(* hello.aop.order..*(..))")
private void allOrder(){}

@Around("allOrder() && allService()")
pubic Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
    //로직 코드...
}

포인트 컷을 따로 빼내서 모듈화 시켜둘수도 있다. 이렇게 따로 포인트컷을 관리하고,
joinpoint에서는 다양하게 조합해서 사용 가능. 이때 &&(and), ||(or), !(not) 연산자를 사용 할 수 있음.
여러개의 로직에 범위를 지정할때 관리가 더 쉬워짐. 같은 클래스내에서 따로 빼서 만들어도 되고 아예
외부에서 만들어서 관리해도 된다.


@Pointcut("execution(* *..*Service.*(..))")
public void allService(){}

@Pointcut("execution(* hello.aop.order..*(..))")
public void allOrder(){}

@Pointcut("allService() && allOrder()")
public void orderServcieAndOrder() {}

---------------------------
다른 클래스


@Around("hello.aop.order.aop.Pointcuts.allOrder()")
pubic Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
    //로직
}
@Around("hello.aop.order.aop.Pointcuts.orderAndService()")
pubic Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
    //로직
}

외부에 뺀 포인트컷을 사용하려면 이렇게 패키지명과 클래스를 다 붙여서 적어주어야 한다.
조금 귀찮긴 하지만 알아서 따로 포인컷들을 관리하려면 이런식으로 해줘야됨.
그리고 포인트컷끼리도 서로 연산해서 가능.

어드바이스 순서

  • 어드바이스는 기본적으로 순서를 보장하지 않는다. 순서를 지정하고 싶으면 @Aspect 적용 단위로
    애노테이션을 적용해야됨. 문제는 이것이 어드바이스 단위가 아니라 클래스 단위로 적용할 수 있다는 점.
  • 하나의 Aspect에 여러 어드바이스가 있으면 순서를 보장 받을 수 없다. 따라서 Aspect를 별도의 클래스로
    분리해야 한다.
   @Aspect
   @Order(2)
   public static class LogAspect {
        @Around("hello.aop.order.aop.Pointcuts.allOrder()")
        pubic Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
            //로직
        }
   }
   
   @Aspect
   @Order(1)
   public static class TxAspect{
        @Around("hello.aop.order.aop.Pointcuts.orderAndService()")
        pubic Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
            //로직
        }
   }
  • 여기선 내부 클래스르 만들어서 그냥 @Order(숫자)로 지정해주었다. 따로 클래스를 만들어도
    상관없다.

어드바이스의 종류

  1. @Around : 메서드 전후에 수행되고 가장 강력한 어드바이스. 조인 포인트 실행 여부 선택, 반환 값
    변환 , 예외 변환 등이 가능. 나머지 들은 사실 어라운드의 기능의 조각내서 조금씩 범위를 가지고 있는 것
    - 조인 포인트의 실행 여부를 결정할수있음.
    - 전달 값, 반환 값, 예외 변환
    - 트랜잭션 처럼 try~catch~fianlly 모두 들어가는 구문 처리 가능
    - 어드바이스의 첫번째 파라미터는 ProceedingJoinPoin를 사용해야 한다.
    - proceed()를 통해 대상을 실행. proceed()를 여러번 실행할 수도 있음(재시도).

  2. @Before : 조인 포인트 실행 이전에 실행

    • 작업 흐름을 변경할 수는 없다. @Around는 직접 다음타겟을 호출해야 되지만, @Before는 메서드 종료시
      자동으로 다음 타겟이 호출된다.(예외 발생시 다음 코드 호출 x)
  3. @After Returning : 조인 포인트가 정상 완료후 실행. 반환값을 쓸수는 있지만 변환 불가능.

    • returning 속성에 사용된 이름은 어드바이스의 메서드 매개변수와 일치해야됨.
    • @AfterReturning(value = "hello.aop.order.aop.Pointcuts.allOrder()", returning = "result")
    • returning 절에 지정된 타입의 값을 반환하는 메서드 대상으로 실행한다.(부모타입을 지정하면 자식 타입은 인정됨)
    • 반환되는 객체를 변환할 수는 없다. 반환 객체를 변경하려면 @Around사용해야됨. 반환객체 조작은 가능
  1. @After Throwing : 메서드가 예외를 던지는 경우 실행 (catch)
    • throwing 속성에 사용된 이름은 어드바이스 메서드의 매개변수 이름과 일치해야한다.
    • @AfterThrowing(value = "hello.aop.order.aop.Pointcuts.orderAndService()", throwing = "ex")
    • trhowing 절에 지정된 타입과 맞은 예외를 대상으로 실행한다.(부모타입은 자식타입을 모두 포함)
  1. @After : 조인 포인트가 정상 또는 예외에 관계없이 실행(finally)
    • 정상 및 예외 반환 조건을 모두 처리한다.
    • 일반적으로 리소스 해제하는데 사용한다.

모든 어드바이스는 JoinPoint를 첫번째 파라미터에 사용할 수 있다.
@AroundProceedingJoinPoin를 사용해야 한다.(다음 어드바이스나 타겟을 호출 해야됨.)

ProceedingJoinPoin는 JoinPoin 인터페이스의 하위 타입 인터페이스이다.

JoinPoin 인터페이스 주요기능

  • getArgs() : 메서드 인수를 반환

  • getThis() : 프록시 객체를 반환

  • getTarget() : 대상 객체를 반환

  • getSignature() : 조언되는 메서드에 대한 설명을 반환

  • toString() : 조언되는 방법에 대한 유용한 설명을 인쇄

    ProceedingJoinPoint 인터페이스의 주요 기능

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

    순서

  • 스프링 5.27 버전부터 동일한 @Aspect 안에서 동일한 조인포인트의 우선순위를 정했다.

  • 실행 순서 : -> @Around(1) -> @Before(2) , @After(5) <- @AfterReturning(4) <- @AfterThrowing(3) <-

  • 어드바이스가 적용되는 순서는 이렇지만, 호출순서와 리턴순서는 반대라는 점을 알아두자.

  • @Aspect 안의 동일한 종류의 어드바이스는 순서 보장이 되지 않는다. 이때는 클래스를 분리해 @Order()를
    통해 순서 적용 가능

@Around 외에 다른 어드바이스가 존재하는 이유는? (다른 어드바이스는 @Around의 하위호환들)
항상 다음 타겟을 호출해줘야됨. 타겟 실행전에 하나만 하고 끝내고 싶은데(@Before범위) 다음 타겟
호출을 적어줘야되고, 실수로 까먹기라도 하면 큰일난다.
따라서 기능은 적지만 실수할 가능성이 적은 @Before, @After 같은 어드바이스. 코드도 단순하다.
의도도 명확하게 들어남. @Before를 처음 보는 순간 타겟 실행 전에 한정해서 어떤 일을 하는 코드라는 것을 명확하게 알 수 있다.

결국 위에서 말하는것은 "제약" 이다.
제약 덕분에 역할이 명확해진다. 다른 개발자도 이 코드를 보고 고민해야 하는 범위가 줄어들고
코드의 의도도 파악하기 쉽다.

포인트컷 지시자


AspectJ 는 포인트컷을 편리하게 표현하기 위해 특별한 표현식을 제공한다.

포인트컷 지시자의 종류

  • execution: 메소드 실행 조인 포인트를 매칭한다. 스프링 AOP에서 가장 많이 사용.기능도 복잡

  • whithin: 특정 타입 내의 조인 포인트를 매칭한다.

  • args: 인자가 주어진 타입의 인스턴스인 조인 포인트

  • this: 스프링 빈 객체(스프링AOP프록시)를 대상으로 하는 조인 포인트

  • target: Target 객체(스프링AOP프록시가 가르키는 실제 대상)를 대상으로 하는 조인 포인트

  • @target: 실행 객체의 클래스에 주어진 타입의 애노테이션이 있는 조인 포인트

  • @within: 주어진 애노테이션이 있는 타입 내 조인 포인트

  • @annotation: 메서드가 주어진 애노테이션을 가지고 있는 조인 포인트를 매칭

  • @args: 전달된 실제 인수의 런타임 타입이 주어진 타입의 애노테이션을 갖는 조인 포인트

  • bean: 스프링 전용 포인트컷 지시자, 빈의 이름으로 포인트컷을 지정한다.

    execution은 가장 많이 사용하고, 나머지는 자주 사용하지 않는다. execution을 중점적으로 이해하자.

execution


execution(modifiers-pattern? ret-type-pattern declaring-type-pattern?.name-pattern(param-pattern)``throws-pattern?)

  • 메소드 실행 조인포인트를 매칭한다
  • ?는 생략가능
  • *같은 패턴을 지정할 수 있다.

실제 코드를 보며 execution을 이해하자

  1. 가장 정확한 포인트컷
//public java.lang.String
hello.aop.member.MemberServiceImpl.hello(java.lang.String)
pointcut.setExpression("execution(public String hello.aop.member.MemberServiceImpl.hello(String))");
  • 접근제어자?(modifiers-pattern): public
  • 반환타입(ret-type-pattern): (java.lang)String //기본 타입 패키지는 풀 패키지 생략가능
  • 선언타입(declaring-type-pattern): hello.aop.member.MemberServiceImpl
  • 메서드이름(name-pattern): hello
  • 파라미터(param-pattern): (java.lang)String
  • 예외?(throws-pattern): 생락
  1. 가장 많이 생략한 포인트컷
pointcut.setExpression("execution(* *(..))");
  1. 다양한 포인트컷
  • execution 패키지, 메서드 관련 매칭
//(접근제어자생략), *(반환타입) , (선언유형생략), hel* (hel로 시작하는 메서드, (..) 메서드 파라미터와 관계없이 메서드에 적용 
pointcut.setExpression("execution(* hel*(..))");   

//* (모든 반환 유형 ) hello메서드에 (..) 아무 파라미터나
pointcut.setExpression("execution(* hello(..))");

//nono 메서드에 (..) 아무 파라미터
pointcut.setExpression("execution(* nono(..))");

//(접근제어생략), *(반환타입), 선언유형,메서드, (..) 메서드파라미터 아무거나
pointcut.setExpression("execution(* hello.aop.member.MemberServiceImpl.hello(..))");

//(접근제어생략), *(반환타입), 패키지 hello.aop.member에 있는 *(하위클래스),*(메서드) (..) 파라미터
pointcut.setExpression("execution(* hello.aop.member.*.*(..))");

//(접근제어생략), *(반환타입), hello.aop패키지에 *하위클래스,*매서드(..)파라미터
// 이때 hello.aop. 바로 아래에 있는 클래스의 메서드를 대상
pointcut.setExpression("execution(* hello.aop.*.*(..))");

// hello.aop.member.. 은 아래에 있는 하위 패키지 또는 중첩된 패키지까지 대상으로 한다.
pointcut.setExpression("execution(* hello.aop.member..*.*(..))");

// hello.aop.. -> aop뿐 아니라 그 하위 패키지에 있는 클래스와 메서드들을 대상
pointcut.setExpression("execution(* hello.aop..*.*(..))");
  • execution 타입 관련 매칭
    1. "부모 타입을 선택해도 그 자식타입은 매칭된다."
      pointcut.setExpression("execution(* hello.aop.member.MemberService.*(..))");
      MemberServiceImpl에 override 된 메서드들은 조회 가능
    2. "부모타입으로 선언시 자식타입에 단독 정의된 메서드는 조회 불가 "
      pointcut.setExpression("execution(* hello.aop.member.MemberService.*(..))");
      MemberServiceImpl에 단독으로 선언된 메서드는 사용 불가.
  • execution 파라미터 매칭 규칙은 다음과 같다.
    1. (String) : 정확하게 String 타입 파라미터
    2. () : 파라미터가 없어야 한다.
    3. (*) : 정확히 하나의 파라미터, 단 모든 타입을 허용한다.
    4. (, ) : 정확히 두 개의 파라미터, 단 모든 타입을 허용한다.
    5. (..) : 숫자와 무관하게 모든 파라미터, 모든 타입을 허용한다. 참고로 파라미터가 없어도 된다. 0..* 로
      이해하면 된다.
    6. (String, ..) : String 타입으로 시작해야 한다. 숫자와 무관하게 모든 파라미터, 모든 타입을 허용한다.
      예) (String) , (String, Xxx) , (String, Xxx, Xxx) 허용

within


within 지시자는 특정 타입 내의 조인 포인트에 대한 매칭을 제한한다. 쉽게 말해 해당 타입이
매칭되면 그 안의 메서드(조인 포인트)들이 자동으로 매칭된다.
execution에서 타입 부분만 사용한다고 보면 된다.

  • execution에서 접근제어자나 메소드, 파라미터, 예외가 빠진 진짜 패키지랑 클래스만 들어옴.
  • 주의해야 할점 !! : 표현식에 부모타입을 지정하면 안된다. 정확하게 타입이 맞아야 한다.
pointcut.setExpression("within(hello.aop.member.MemberServiceImpl)");
pointcut.setExpression("within(hello.aop.member.*Service*)");
pointcut.setExpression("within(hello.aop..*)");
  • 아무래도 실무에서 사용은 떨어지는 편.
  • execution으로 사용되다보니 아무래도 그 하위의 기능을 가진 within은 사용할수도 있지만, 계속 사용
    되던것이 더 자주 사용된다고 보면됨

args


args: 인자가 주어진 타입의 인스턴스인 조인 포인트로 매칭
기본문법은 executionargs 부분과 같다.

execution과 args의 차이점

  • execution은 파라미터 타입이 정확하게 매칭되어야 한다. execution은 클래스에 선언된 정보를 기반으로 판단한다.

  • args는 부모 타입을 허용한다. args는 실제 넘어온 파라미터 객체 인스턴스를 보고 판단한다.

  • execution이랑 parameter 지정하는 부분과 똑같다.

  • "execution - 메서드의 시그니처로 판단(정적), args - 런타임에 전달된 인수로 판단(동적)"

pointcut("args(Object)")
pointcut("args()")
pointcut("args(..)")
pointcut("args(*)")
pointcut("args(String,..)")
pointcut("args(java.io.Serializable)")  // String의 상위 클래스인 Serializable을 보고 동적으로 판단
pointcut("execution(* *(java.io.Serializable))")  // isFalse() , 허용하지 않는다.
pointcut("execution(* *(Object))") // isFalse(), 허용하지 않는다.

args지시자는 단독으로 사용되기 보다는 나중에 설명할 파라미터 바인딩에서 주로 사용된다.

@target, @within


@target : 실행 객체의 클래스에 주어진 타입의 애노테이션이 있는 조인 포인트
@within : 주어진 애노테이션이 있는 타입 내 조인 포인트

@target, @within은 다음과 같이 타입에 있는 애노테이션으로 AOP 적용여부를 판단.

-@target(hello.aop.member.annotation.ClassAop)
-@within(hello.aop.member.annotation.ClassAop)

무슨 차이일까? @target vs @within

  • @target은 인스턴스의 모든 메서드를 조인 포인트로 적용

  • @within은 해당 타입 내에 있는 메서드만 조인 포인트로 적용한다.

    @target은 부모 클래스의 메서드까지 어드바이스를 다 적용, @within은 자기 자신의 클래스에
    정의된 메서드에만 어드바이스를 적용한다.

  • 테스트에 앞서, @target 과 @within은 pointcut으로 바로 판단이 안됨. 실제 객체 인스턴스가 등록되고 실행되는
    과정에서 어드바이스 적용 여부 파악 가능.

    한번더 강조.. 중요!!

  • @target은 부모 클래스의 메서드까지 어드바이스 적용!

  • @within은 자기 자신의 클래스에 정의된 메서드에만 어드바이스 적용!

    뒤에 설명할 parameter 바인딩에 적용 가능하다.

    주의점
    args, @args, @target 다음 포인트컷 지시자는 단독사용 하면 안됨
    args, @args, @target 은 실제 객체 인스턴스가 생성되고 실행될 때 어드바이스 적용 여부를 확인할 수 있다.
    실행 시점에 일어나는 포인트컷 적용 여부도 결국 프록시가 있어야 실행 시점에 판단할 수 있다. 프록시가
    없다면 판단 자체가 불가능하다.
    그런데 스프링 컨테이너가 프록시를 생성하는 시점은 스프링 컨테이너가 만들어지는 애플리케이션 로딩시점에
    적용할 수 있다. 따라서 args, @args, @target 같은 포인트컷 지시자가 있으면 스프링은
    모든 스프링 빈에 AOP를 적용하려고 한다. 이때 final 이 지정된 빈들도 있기 때문에 오류가 발생할 수 있다.
    따라서 이러한 표현식은 최대한 프록시 적용 대상을 축소하는 표현식과 함께 사용해라

@annotation, @args


@annotation : 메서드가 주어진 애노테이션을 가지고 있는 조인포인트 매칭
@annotation(hello.aop.member.annotation.MethodAop)

@args : 전달된 실제 인수의 런타입 타입이 주어진 타입의 애노테이션을 갖는 조인 포인트
@args(test.Check) : 전달된 인수의 런타임 타입에 @Check 애노테이션이 있는 경우에 매칭한다.

bean


bean : 스프링 전용 포인트컷 지시자, 빈의 이름으로 지정한다.

설명

  • 스프링 빈의 이름으로 AOP 적용 여부를 지정한다. 이것은 스프링에서만 사용할 수 있는 특별한 지시자.
  • bean(orderService) || bean(*Repository)
    • 과 같은 패턴을 사용할 수 있다.

매개변수 전달


포인트컷 표현식을 사용해서 어드바이스에 매개변수를 전달할 수 있다.

  • this, target, args, @target, @within, @annotation, @args
@Before("allMember() && args(param,..)")
public void logArgs3(String param) {
 log.info("[logArgs3] param={}", param);
}

포인트컷의 이름과 매개변수의 이름을 맞춰야 한다. 여기서는 param
추가로 타입이 메서드에 지정한 타입으로 제한됨(여기선 String)
args(arg,..) -> args(String,..)


    // 배열의 인덱스로 받는 방법.딱 봐도 안좋아 보임.
    @Around("allMember()")
    public Object logArgs1(ProceedingJoinPoint joinPoint) throws Throwable {
        Object arg1 = joinPoint.getArgs()[0];
        return joinPoint.proceed();
    }

    // args를 이용해서 받는 방법. 이 방법을 많이 사용
    @Around("allMember() && args(arg,..)")
    public Object logArgs2(ProceedingJoinPoint joinPoint, Object arg) throws Throwable {
        log.info("[logArgs1]{} arg={}", joinPoint.getSignature(), arg);
        return joinPoint.proceed();
    }

    //완전 최적화. 타입까지 맞춰주었고, Before를 사용
    @Before("allMember() && args(arg,..)")
    public void logArgs3(String arg) {
        log.info("[logArgs3] arg={}", arg);
    }

    // this는 proxy 객체 (실제 스프링 빈에 등록된 객체)를 가지고옴
    @Before("allMember() && this(obj)")
    public void thisArgs(JoinPoint joinPoint, MemberService obj) {
        log.info("[this]{}, obj={}", joinPoint.getSignature(), obj.getClass());
    }

    // target은 프록시가 아니라 프록시가 호출하는 실제 객체를 가지고 옴. 
    @Before("allMember() && target(obj)")
    public void targetArgs(JoinPoint joinPoint, MemberService obj) {
        log.info("[target]{}, obj={}", joinPoint.getSignature(), obj.getClass());
    }

    @Before("allMember() && @target(annotation)")
    public void atTarget(JoinPoint joinPoint, ClassAop annotation) {
        log.info("[@target]{}, obj={}", joinPoint.getSignature(), annotation);
    }

    @Before("allMember() && @within(annotation)")
    public void atWithin(JoinPoint joinPoint, ClassAop annotation) {
        log.info("[@within]{}, obj={}", joinPoint.getSignature(), annotation);
    }

    // @annotation 같은경우 실제 annotation에 값이 붙어 넘어오면 그 값을 받을 수 있음.
    @Before("allMember() && @annotation(annotation)")
    public void atAnnotation(JoinPoint joinPoint, MethodAop annotation) {
        log.info("[@annotation]{}, annotationValue={}", joinPoint.getSignature(), annotation.value());
    }

this, target

this : 스프링 빈 객체(스프링 AOP 프록시)를 대상으로 하는 조인 포인트
target : Target 객체(스프링 AOP프록시가 가르키는 실제 대상)를 대상으로 하는 조인 포인트

  • 둘 다 적용 타입 하나를 정확하게 지정해야 한다.
  • * 과 같은 패턴을 사용할 수 없다.
  • 부모 타입을 허용한다
 this(hello.aop.member.MemberService)
 target(hello.aop.member.MemberService)

this vs target

  • 어떤 차이가 있을까?
  • 프록시 생성 방식에 따른 차이
    • 실제 객체를 대상으로 포인트컷 (target)
    • 프록시 객체를 대상으로 포인트컷 (this)

스프링은 프록시를 생성할때 JDK 동적 프록시와 CGLIB을 선택할 수 있다. 둘의 프록시를 생성하는 방식이 다르기
때문에 차이가 발생한다.

  • JDK 동적 프록시 : 인터페이스가 필수고, 인터페이스를 구현한 프록시 객체 생성
  • CGLIB : 인터페이스가 있어도 구체 클래스를 상속 받아서 프록시 객체 생성.
  1. JDK 동적 프록시 적용 상황에서 ...

    1. MemberService 인터페이스 지정(hello.aop.member.MemberService)

      • this -> Proxy 객체를 보고 판단. this는 부모 타입을 허용하기 때문에 AOP가 적용된다.
      • target -> 실제 객체를 보고 판단. target은 부모타입을 허용하기때문에 AOP가 적용된다
    2. MemberServiceImpl 구체 클래스 지정(hello.aop.member.MemberServiceImpl)

      • this -> proxy 객체를 보고 판단한다. JDK동적 프록시로 만들어진 proxy객체는 MemberService
        인터페이스 기반으로 구현된 새로운 클래스다. 따라서 AOP 적용 대상이 아니다.
      • target -> target 객체를 보고 판단한다. target 객체가 MemberServiceImpl 타입이므로 AOP
        적용 대상이다.
  1. CGLIB 프록시
    1. MemberService 인터페이스 지정(hello.aop.member.MemberService)
      • this -> Proxy 객체를 보고 판단한다. 부모타입 허용. AOP 적용된다
      • target -> target 객체를 보고 판단한다. 부모타입 허용. AOP 적용됨.
    2. MemberServiceImpl 구체 클래스 지정 (hello.aop.member.MemberServiceImpl)
      • this -> proxy 객체를 보고 판단한다. CGLIB로 만들어진 proxy 객체는 MemberServiceImpl을
        상속받아서 만들었기 때문에 AOP가 적용이 된다. 포인트컷의 대상이됨
      • target -> 객체를 보고 판단한다. target 객체가 MemberServiceImpl 타입이므로 AOP 적용 대상이다.

정리
프록시를 대상으로하는 this의 경우 구체클래스를 지정하면 프록시 생성 전략에 따라 다른 결과가 나올 수 있다
는 점을 알아두자.

thistarget 지시자는 단독으로 사용되기 보다는, 파라미터 바인딩에 주로 사용된다.

실전 AOP 예제


@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retry {
    int value() default 3;
}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Trace {
}

애노테이션기반으로 AOP 적용을 하기 위한 것. 이름으로 알아보듯 Trace 기능과, Retry 기능이다.
이때 Retry는 주의해야되는데, 무한히 요청하면 안됨.(서버가 터지거나 문제가 생긴다). 그래서 int value()
값을 넣어두었다.(defalut 3)

이런 식으로 애노테이션을 만든뒤 Aspect 클래스를 만들어 adviser를 만들어 주면 됨

@Before("@annotation(hello.aop.exam.annotation.Trace)")
public void doTrace(JoinPoint joinPoint) {
    // 로직 
}

@Around("@annotation(retry)")
public Object doRetry(ProceedingJoinPoint joinPoint, Retry retry) throws Throwable {
    // 로직
}

이렇게 애노테이션 기반의 어드바이저를 적용하기위한 기반이 다 마련되었다. 실제 적용은 적용하고 싶은
메서드에 @Trace, @Retry 를 붙여 주기만 하면 적용 된다.
이와 비슷하게 동작하는 스프링의 대표적인 애노테이션 기반 AOP는 바로 @Transactional 이 있다.

AOP 주의 사항


프록시와 내부 호출

스프링은 프록시 방식의 AOP를 사용한다.
AOP를 적용하려면 항상 프록시를 통해서 대상 객체(Target)를 호출해야 한다.
프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP가 적용되지 않고, 어드바이스도 호출x

AOP를 적용하려면 스프링은 대상 객체 대신에 프록시를 스프링 빈으로 등록한다.
대상 객체를 직접 호출하는 문제는 일반적으로 발생하지 않는다.
하지만 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는
문제가 발생한다.


public class CallServiceV0 {

    public void external() {
        log.info("call external");
        internal();  //내부 메서드 호출 this.internal();
    }

    public void internal() {
        log.info("call internal");
    }

}
  • 이 상황에서 CallServiceV0에 AOP를 걸어놨을때, external()을 실행하면 어떻게 되겠는가?
    1. callServiceV0.external() 을 실행할때는 프록시 호출해서 로직을 실행하고 실제 타겟인
      target.external()을 호출한다.
    2. 이제 다음으로 external안에 internal()을 호출할텐데 여기서 문제가 발생한다. 이때는 Aspect
      클래스가 실행되지 않는다.
    3. 왜냐? -> 자바 언어는 메서드앞에 별도의 참조가 없으면 this 가 붙어있다. 즉 this.internal()
      이 된다는 뜻이다. 결과적으로 이러한 내부호출은 프록시를 거치지 않으므로 어드바이스도 적용할
      수 없다.

프록시 AOP 방식의 한계
스프링 AOP는 프록시 방식이기에 내부 호출에 프록시를 적용할 수 없다. 참고로 실제 코드에 AOP를
직접 적용하는 AspectJ를 사용하면 이러한 문제가 발생하지 않는다. 다만 AspectJ는 사용하기 너무
어렵다. 그리고 프록시 방식의 AOP 내부 호출에 대응할 다른 대안들도 존재한다.
이제부터 이러한 방식을 알아보자

대안 1 - 자기 자신 주입

가장 간단한 방법은 자기 자신을 의존관계 주입하는 것이다.

public class CallServiceV1 {

    private CallServiceV1 callServiceV1;

    //생성자는 할 수 없다. 자기자신이 존재하지도 않는데 생성자를 받게되면 순환참조 문제가 발생함
    //이때 사용할 수 있는 방법이 setter 주입 방식.

    @Autowired
    public void setCallServiceV1(CallServiceV1 callServiceV1) {
        this.callServiceV1 = callServiceV1;
    }

    public void external() {
        log.info("call external");
        callServiceV1.internal();  
    }

    public void internal() {
        log.info("call internal");
    }

}
  • 일단 setter 주입을 사용해야된다는 문제점이 있다.
  • 다음으로 스프링 부트 2.6 이상부터는 순환참조를 디폴트로 허용하지 않는 설정을 해두었다.(
    즉 setter주입 방식도 안됨. (properties에서 순환참조 허용을 하면 가능하지만... 좋은 방법은 아닌거 같다.))

대안 2 - Lazy loading

지연 조회를 하면 된다. 필요할때, 자기 자신을 주입 받게 만들면 setter를 사용할 필요 없이 생성자로 만들 수 있다.
ApplicationContext를 바로 사용해도 되지만 이 ApplicationContext는 너무 기능이 많고 거대하다. 우리는 지연 로딩만
시킬 수 있으면 된다.

이때 사용할 수 있는 기능이 있다. 기억이 나나?

  • ObjectProvider
  • Provider
    여기서는 자기자신을 주입받지 않기때문에 순환 사이클이 생기지 않는다. 또한 스프링이 제공하는 ObjectProvider를
    사용하면 나름 편하게 할 수 있지만(생성자 방식보단..) 그래도 억지로 코드를 끼어 넣는 느낌이 있다.

대안 3 - 구조 변경

앞선 방법들은 조금 어색한 모습을 만들었다. 가장 나은 대안은 내부 호출이 발생하지 않도록 구조를 변경해버리는 것이다.
스프링에서는 이 방법을 권장하며, 이 방법으로 사용하는것이 좋다고 한다.

단순하게 내부 메서드를 따로 클래스로 만들어서, 분리하는 방법뿐 아니라 다양한 방법들이 있을 수 있다.
아예 클라이언트측에서 둘 다 호출하는 방법도 있겠다. 이 경우에는 내부 호출을 하지 않도록 코드 변경.

참고
AOP는 주로 트랜잭션 적용이나 주요 컴포넌트의 로그 출력 기능에 사용한다. 쉽게 말해서 인터페이스에
메서드가 나올 정도의 규모에 AOP를 적용하는 것이 적당하다. 또한 AOP는 public 메서드에만 적용.
private 메서드 처럼 작은 단위에는 적용하지 않는다.(적용도 안됨).
public 메서드에서 public 메서드를 내부 호출하는 경우에는 문제가 발생한다.

프록시 기술과 한계 - 타입 캐스팅

스프링은 JDK 동적 프록시와 CGLIB을 사용해서 AOP 프록시를 만드는데 이 방법에는 각각 장단점이 있다.
스프링이 프록시를 만들때 제공하는 ProxyFactoryproxyTargetClass옵션에 따라 둘중 하나를 선택해서
프록시를 만들 수 있다.

JDK 동적 프록시 한계
인터페이스 기반으로 프록시를 생성하는 JDK 동적 프록시는 구체 클래스로 타입 캐스팅이 불가능한 한계가 있다.
JDK 동적 프록시는 인터페이스를 기반으로 프록시를 생성하기 때문이다. 인터페이스로 캐스팅은 가능하지만
그 구현체로의 캐스팅은 불가능하다.(이는 자바 문법)

CGLIB는 인터페이스이든 구현체든 둘다 캐스팅이 가능하다. 왜냐하면 구현체를 통해 프록시를 생성하기 때문이다.

정리
JDK 동적 프록시는 대상 객체인 MemberServiceImpl로 캐스팅 할 수 없다.
CGLIB 프록시는 대상 객체인 MemberServiceImpl로 캐스팅 할 수 있다.

그런데 이게 문제가 될까? 프록시에서 타입 캐스팅을 할 일이 얼마나 있다고? -> 진짜 문제는 의존관계
주입에서 일어난다.

프록시 기술과 한계 - 의존 관계 주입

  • @Autowired MemberService memberService; - 문제 없음. 인터페이스를 기반으로 만들어지는 JDK 동적 프록시

  • @Autowired MemberServiceImpl memberServiceImpl; - 문제가 발생. 인터페이스를 기반으로 만들어지는 JDK 동적 프록시.
    MemberServiceImpl의 타입이 뭔지 전혀 모른다. 그래서 해당 타입에 주입할 수 없다.

  • Interface 타입으로는 캐스팅 가능. MemberService = JDK Proxy가 성립한다.

반대로 저 둘다 CGLIB을 사용하면 가능하다.

JDK 동적 프록시가 가지는 한계점을 알아 보았다. 실제 개발때는 인터페이스를 기반으로 의존관계를 주입
받는 것이 맞다. 다만 여러가지 사정에 의해 AOP 프록시가 적용된 구체 클래스를 직접 의존관계 주입
받아야 하는 경우가 있을 수 있다. 이때는 CGLIB을 통해 구체 클래스 기반으로 AOP 프록시를
적용하면 된다.

여기까지 보면 CGLIB가 더 좋아 보인다. CGLIB를 사용하면 이런 고민을 하지 않아도 된다. 지금부터는
CGLIB의 단점을 알아보자.

프록시 기술과 한계 - CGLIB

CGLIB의 문제점
구체 클래스를 상속받기 때문에 다음과 같은 문제가 있다.
1. 대상 클래스에 기본 생성자 필수
2. 생성자 2번 호출 문제
3. final 키워드 클래스, 메서드 사용 불가

대상 클래스에 기본 생성자 필수
상속을 받으면 부모 생성자를 호출해야한다. 생략하면 자동 super()들어감. CGLIB는 우리가 호출하는 것이
아니다. CGLIB 프록시는 대상 클래스를 상속받고, 생성자에서 대상 클래스의 기본 생성자를 호출. 따라서
대상 클래스에 기본 생성자가 필수로 있어야 한다.

생성자 2번 호출 문제
CGLIB는 구체 클래스를 상속. 상속 받으면 자식 생성자를 호출할때 부모 클래스의 생성자도 호출해야한다.
그래서 CGLIB는 아래와 같이 2번 생성자 호출을 한다.
1번째 - 실제 target의 객체를 생성할 때
2번째 - 프록시 객체를 생성할 때 부모 클래스의 생성자 호출

final 키워드 클래스, 메서드 사용 불가
fianl 키워드가 클래스에 있으면 상속이 불가능, 메서드에 있으면 오버라이딩이 불가능. 때문이 CGLIB가
상속을 기반으로 하기 때문에 두 경우에는 프록시가 생성되지 않거나 정상 작동하지 않는다.
사실 프레임 워크 같은 개발이 아니라 일반적인 웹 애플리케이션 개발할 때는 final 키워드를 잘 사용하지
않아 이 부분은 문제가 크게 되지 않는다.

프록시 기술과 한계 - 스프링의 해결책

JDK 동적 프록시와 CGLIB의 단점을 알아보았다. 그렇다면 스프링은 이런 단점과 한계를 어떻게 해결하고 있는가?

CGLIB 기본 생성자 필수 문제 해결

  • 스프링 4.0부터 CGLIB의 기본 생성자 필수인 문제가 해결 되었다.
  • objenesis라는 특별한 라이브러리를 사용해서 기본 생성자 없이 객체 생성이 가능(생성자 호출없이 객체 생성)

생성자 2번 호출 문제

  • 스프링 4.0부터 CGLIB의 생성자 2번 호출 문제가 해결되었다.
  • 이것도 역시 objenesis라는 특별한 라이브러리 덕분에 가능해졌다. 이제 생성자가 1번 호출된다.

스프링부트 2.0 - CGLIB 기본 사용

  • 스프링은 결국 CGLIB의 문제를 모두 해결 해서 스프링 부트 2.0부터는 CGLIB를 기본으로 사용하도록 했다.
  • 이렇게 해서 구체 클래스 타입으로 의존관계를 주입하는 문제를 해결했다.

final 키워드

  • final 키워드는 AOP를 적용할 대상에는 잘 사용하지 않기 때문에 크게 문제될 일이 없다.

사실 개발자 입장에서는 어떤 프록시 기술을 사용하든 상관이 없다. JDK 동적 프록시이든 CGLIB든 또는 어떤 새로운
프록시 기술을 사용해도 상관이 없다. 단지 문제가 없고 개발만 편리하면 되는 것이다.
지금은 CGLIB의 문제점이 거의 다 해결 되었고, 기본으로 CGLIB가 선택되어 있다. 즉 그말은 그냥 AOP를 사용하면 된다는 뜻이다.
내가 특별하게 설정을 하지 않는이상 JDK 동적 프록시가 사용 될 일이 없다.

참고 : 본 글은 김영한님의 스프링 강의를 정리한 것이다.

profile
공부 정리 블로그

0개의 댓글