[Spring] AOP 프로그래밍

첫걸음 - 7

Posted by owin2828 on 2019-12-30 15:57 · 21 mins read

1. Proxy, AOP, Advice


1-1. About proxy

프록시는 기존 코드를 수정하지 않고 코드 중복을 피할 수 있는 방법으로써 다음과 같은 특징을 지님

  • 핵심 기능의 실행은 다른 객체에 위임하고 부가적인 기능을 제공하는 객체 = 프록시(proxy)
  • 실제 핵심 기능을 실행하는 객체 = 대상 객체
  • 프록시는 핵심 기능을 구현하지 않음
  • 대신 여러 객체에 공통으로 적용할 수 있는 기능을 구현

1-2. About AOP

AOP는 Aspect Oriented Programming의 약자로,
여러 객체에 공통으로 적용할 수 있는 기능을 분리해 재사용성을 높여주는 기법으로써 다음과 같은 특징을 지님

  • 공통 기능 구현과 핵심 기능 구현의 분리
  • 핵심 기능에 공통 기능을 삽입하며 다음과 같은 방법이 존재
    1. 컴파일 시점에 코드에 공통 기능을 삽입
    2. 클래스 로딩 시점에 바이트 코드에 공통 기능을 삽입
    3. 런타임에 프록시 객체를 생성해서 공통 기능을 삽입

      이 중, 스프링이 제공하는 AOP 방식은 프록시를 이용한 세 번째 방식

  • 스프링 AOP는 프록시 객체를 자동으로 생성해줌
  • AOP의 공통 기능을 Aspect라 칭하며, 아래와 같은 주요 용어가 존재
    1. Advice: 언제 공통 관심 기능을 핵심 로직에 적용할 지를 정의
    2. Joinpoint: Advice를 적용 가능한 지점을 의미
      (매서드 호출, 필드 값 변경 등, 스프링은 프록시를 이용해 AOP를 구현하기 때문에 매서드 호출에 대한 Joinpoint만 지원)
    3. Pointcut: Joinpoint의 부분 집합, 실제 Advice가 적용되는 Joinpoint를 나타냄
      (스프링에서는 정규 표현식 및 AspectJ의 문법을 통해 Pointcut 정의)
    4. Weaving: Advice를 핵심 로직 코드에 적용하는 것
    5. Aspect: 여러 객체에 공통으로 적용되는 기능

1-3. About Advice

  1. Before Advice: 대상 객체의 매서드 호출 전에 공통 기능을 실행
  2. After Returning Advice: 대상 객체의 매서드가 익셉션 없이 실행된 이후에 공통 기능을 실행
  3. After Throwing Advice: 대상 객체의 매서드를 실행하는 도중 익셉션이 발생한 경우에 공통 기능을 실행
  4. After Advice: 익셉션 발생 여부에 상관없이 대상 객체의 매서드 실핼 후 공통 기능을 실행
  5. Around Advice: 대상 객체의 매서드 실행 전, 후 또는 익셉션 발생 시점에 공통 기능을 실행

    이 중 널리 사용되는 것은 Around Advice이며,
    그 이유는 대상 객체의 매서드를 실행하기 전/후, 익셉션 발생등 다양한 시점원하는 기능을 삽입할 수 있기 때문

2. 스프링 AOP 구현


스프링 AOP를 통해 공통 기능을 구현 및 적용하는 순서

  1. Aspect로 사용할 클래스에 @Aspect 어노테이션을 붙임
  2. @Pointcut 어노테이션으로 공통 기능을 적용할 Pointcut을 정의
  3. 공통 기능을 구현한 매서드에 @Around 어노테이션을 적용

    후술할 예제는 크게

    • 핵심기능: Calculator(인터페이스) <- RecCalculator(상속)
    • 공통기능: ExeTimeAspect(시간 측정) / CachceAspect(캐싱 기능)로 구현
      // Caculator.java 
      public interface Calculator {
      
      	public long factorial(long num);
      
      }
      
      // RecCalculator.java
      public class RecCalculator implements Calculator {
      
      	@Override
      	public long factorial(long num) {
            if (num == 0)
                return 1;
            else
                return num * factorial(num - 1);
      	}
      }
      


2-1. @Aspect, @Pointcut, @Around를 통한 AOP 구현

    //ExeTimeAspect
    // 공통 기능을 제공하는 클래스 설정
    @Aspect
    public class ExeTimeAspect {

            // 공통 기능을 적용할 Pointcut 설정
        @Pointcut("execution(public * chap07..*(..))")
        private void publicTarget() {
        }

            // 공통 기능을 구현할 매서드 설정
        @Around("publicTarget()")
        public Object measure(ProceedingJoinPoint joinPoint) throws Throwable {
            long start = System.nanoTime();
            try {
                            // proceed() 매서드를 사용해서 실제 대상 객체의 매서드를 호출
                Object result = joinPoint.proceed();
                return result;
            } finally {
                System.out.printf("%s.%s(%s) 실행 시간 : %d ns\n",
                ...
            }
        }

    }
  • @Aspect 어노테이션을 적용한 클래스는 Advice와 Pointcut을 함께 제공
  • @Pointcut 어노테이션은 cahp07 패키지나 그 하위 패키지에 속한 Bean 객체의 public 매서드를 설정

    @Pointcut이 붙은 매서드는 다음의 조건을 일반적(반드시?)으로 만족해야 함

    • void 리턴 타입
    • 비어있는 매서드
    • 어떤 throws 문도 포함하지 않음
  • @Around 어노테이션은 publicTarget() 매서드에 정의한 Pointuct에 공통 기능을 적용
  • joinPoint.proceed() 매서드를 실행시, 대상 객체의 매서드가 실행되므로 이 코드 전후로 공통 기능을 위한 코드를 위치시킴

    결과적으로 위 코드를 실행하게 되면,
    Calculator 타입이 spring 패키지에 속하므로,
    calculator Bean에 ExeTimeAspect 클래스에 정의한 공통 기능 measure()를 적용

      // AppCtx.java
      @Configuration
      @EnableAspectJAutoProxy
      public class AppCtx {
              // exeTimeAspect 매서드가 공통 기능으로 사용될 프록시를 위한 Bean 객체를 생성하는 매서드
              // 앞선 코드 ExeTimeAspect 클래스의 @Pointcut과 @Around 설정을 사용
          @Bean
          public ExeTimeAspect exeTimeAspect() {
              return new ExeTimeAspect();
          }
    
          @Bean
          public Calculator calculator() {
              return new RecCalculator();
          }
    
      }
    
  • @Aspect 어노테이션을 붙인 클래스를 공통 기능으로 적용하려면,
    @EnableAspectJAutoProxy 어노테이션을 설정 클래스에 붙여야 함
  • @Aspect 어노테이션이 붙은 Bean 객체를 찾아 Bean 객체의 @Pointcut 설정과 @Around 설정을 사용

    위의 코드를 수행하면 결과적으로 아래와 같은 흐름으로 진행

    • Main -> Proxy -> ExeTimeAspect(공통기능) -> ProceedingJoinPoint -> RecCalculator(핵심기능)
  • @Before 어노테이션의 사용 예는 다음과 같다.
    @Aspect
    public class ExeTimeAspect {
    
    	@Pointcut("execution(public * chap07..*(..))")
    	private void publicTarget() {
    	}
    	
    	@Before("publicTarget()")
    	public void beforeMethod() {
    		System.out.println("실행전 시작!");
    	}
    
           @Around("publicTarget()")
           public Object measure(ProceedingJoinPoint joinPoint) throws Throwable {	
           ...
    
  • 실행결과는 다음과 같다.

    실행전 시작!
    ———————————————————————// 여기까지 매서드 실행전 공통기능 수행 (@Before)
    RecCalculator.factorial([7]) 실행 시간 : 126851 ns
    CacheAspect: Cache에 추가[7]
    CacheAspect: Cache에서 구함[7]
    ———————————————————————// 여기까지 원하는 위치에서 공통기능 수행 (@Around)

2-2. ProceedingJoinPoint의 매서드

  • Around Advice에서 사용할 공통 기능 매서드는 대부분
    파라미터로 전달받은 ProceedingJoinPoint의 proceed() 매서드만 호출하면 됨
    (앞선 코드 ExeTimeAspect.java의 joinPoint.proceed() 부분)

  • 이때 세부적인 정보가 필요하다면 아래와 같은 매서드를 통해 정보를 획득 가능

    1. Signature getSignature(): 호출되는 매서드의 대한 정보를 구함
    2. Object getTaget(): 대상 객체를 구함
    3. Object[] getArgs(): 파라미터 목록을 구함
      • 예시: joinPoint.getTarget() 등

3. 프록시 생성 방식


    // 수정 전 MainAspect.java
    Calculator cal = ctx.getBean("calculator", Calculator.class);

    // 수정 후 MainAspect.java
    RecCalculator cal = ctx.getBean("calculator", RecCalculator.class);
  • 만약, 위와 같이 타입을 변경하여 Bean 객체를 가져온다고 하면,
    아래와 같이 설정 파일에 RecCalculator 객체를 반환하므로 문제가 없어 보임
      // AppCtx.java
      @Bean
      public Calculator calculator() {
          return new RecCalculator();
      }
    

    하지만 getBean() 매서드에 사용한 타입이 RecCalculator 인데 반해 실제 타입은 $Proxy17 이라는 메세지와 함께, 다음과 같은 이유로 에러 발생

  • 현재 구조는 Calculator(인터페이스)RecCalculator$Proxy17가 상속받는 관계
  • 스프링은 AOP를 위한 프록시 객체를 생성할 때 실제 생성할 Bean 객체가 인터페이스를 상속하면 인터페이스를 이용해 프록시를 구현
  • 따라서 아래 코드처럼 Bean의 실제 타입이 RecCalculator 라고 해도,
    “calculator”에 해당하는 Bean 객체 타입은 Caculator 를 상속받은 프록시 타입이됨

      // AppCtx.java
      // AOP 적용시 RecCalculator가 상속받은 Calculator 인터페이스를 이용해 프록시 생성
      @Bean
      public Calculator calculator(){
          return new RecCalculator();
      }
    
      // MainAspect.java
      // calculator Bean의 실제 타입은 Calculator를 상속한 프록시 타입이므로
      // RecCalculator로 타입변환을 할 수 없기 때문에 익셉션 발생
      RecCalculator cal = ctx.getBean("calculator", RecCalculator.class);
    


3-1. execution 명시자 표현 방식

execution 명시자는 Advice를 적용할 매서드를 지정할 때 아래와 같이 사용

    @Pointcut("execution(public * cahp07..*(..))")
    private void public Target(){
    }
  • Aspect를 적용할 위치를 지정하기 위한 Pointcut 설정
  • 스프링 AOP는 public 매서드에만 적용할 수 있기 때문에, 사실상 public만 가능

    execution(수식어패턴? 리턴타입패턴 클래스이름패턴?매서드이름패턴(파라미터패턴)) 방식으로 사용

    • 수식어패턴은 생략 가능하며 public, protected등이 올 수 있음
    • 리턴타입패턴은 리턴 타입을 명시
    • 클래스이름패턴, 매서드이름패턴은 클래스 이름 및 매서드 이름을 패턴으로 명시
    • 파라미터패턴은 매칭될 파라미터에서 대해 명시
    • 각 패턴에서 '..'은 0개 이상이라는 의미

3-2. Advice 적용 순서

기존의 코드에는 ExeTimeAspect 클래스만 공통기능으로 구현했지만,
이에 추가로 CacheAspect 클래스를 공통기능으로 구현해, 한 Pointcut에 여러 Advice를 적용

    //CacheAspect.java
    @Aspect
    public class CacheAspect {

        private Map<Long, Object> cache = new HashMap<>();

        @Pointcut("execution(public * chap07..*(long))")
        public void cacheTarget() {
        }
        
        @Around("cacheTarget()")
        public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
            Long num = (Long) joinPoint.getArgs()[0];
            if (cache.containsKey(num)) {
                System.out.printf("CacheAspect: Cache에서 구함[%d]\n", num);
                return cache.get(num);
            }

            Object result = joinPoint.proceed();
            cache.put(num, result);
            System.out.printf("CacheAspect: Cache에 추가[%d]\n", num);
            return result;
        }
    }

위 클래스는 캐싱의 기능을 제공하며, 키 값이 hashmap에 존재하면 그 값을 리턴하고,
없다면 프록시 대상 객체를 실행하여 그 결과를 캐시에 추가해 준 후, 그 값을 리턴

ExeTimeAspect 클래스와 동일하게,
공통기능으로 사용할 클래스에 @Aspect
공통기능을 적용할 부분에 @Pointcut
공통기능을 구현한 매서드에 @Around 어노테이션을 차례로 적용하여 구현

  • @Around 값으로 cacheTarget() 매서드를 지정
  • @Pointcut 설정은 첫 번째 인자가 long인 매서드를 대상으로 함
  • 따라서 execute() 매서드는 앞서 작성한 Calculator의 fatoring(long) 매서드에 적용
  • 새로운 Aspect를 구현했으므로 아래와 같이 두개의 Aspect를 추가하는 작업이 필요

      // AppCtxWithCahce.java
      @Configuration
      @EnableAspectJAutoProxy
      public class AppCtxWithCache {
    
          @Bean
          public CacheAspect cacheAspect() {
              return new CacheAspect();
          }
    
          @Bean
          public ExeTimeAspect exeTimeAspect() {
              return new ExeTimeAspect();
          }
    
          @Bean
          public Calculator calculator() {
              return new RecCalculator();
          }
    
      }
    
  • 위의 설정 클래스를 적용해 실행하는 코드는 아래와 같다.

      // MainAspectWithCache.java
      public class MainAspectWithCache {
            
          public static void main(String[] args) {
              AnnotationConfigApplicationContext ctx = 
                      new AnnotationConfigApplicationContext(AppCtxWithCache.class);
    
              Calculator cal = ctx.getBean("calculator", Calculator.class);
              cal.factorial(7);
              cal.factorial(7);
              cal.factorial(5);
              cal.factorial(5);
              ctx.close();
          }
      }
    
  • 위의 코드를 실행하면 아래와 같은 결과를 출력 함

    RecCalculator.factorial([7]) 실행 시간 : 16584 ns
    CacheAspect: Cache에 추가[7]
    —————————————————- // 여기까지 처음 cal.factorial(7) 결과
    CacheAspect: Cache에서 구함[7] // 두 번째 cal.factorial(7) 결과

  • 처음 factorial(7)을 실행할 때와 두 번째 실행할 때의 결과가 다른 것을 확인할 수 있으며,
    첫 번째는 ExeTimeAspect와 CacheAspect가 모두 적용되었고
    두 번째는 CacheAspect 적용됨

    이렇게 실행결과가 다른 이유는 Advice를 다음 순서로 적용했기 때문
    CacheAspect 프록시 -> ExeTimeAspect 프록시 -> 실제 대상 객체

  • 앞선 코드의 caculator Bean 객체는 실제로 CacheAspect 프록시 객체
    CacheAspect 프록시 대상 객체는 ExeTimeAspect의 프록시 객체
    ExeTimAsepct 프록시 대상 객체실제 대상 객체
  • 실제 코드상의 흐름은 다음과 같다.

      // CacheAsepct.java
      ... 
      if (cache.containsKey(num)) {
          System.out.printf("CacheAspect: Cache에서 구함[%d]\n", num);
          return cache.get(num);
      }
    
      /* 이 부분에서 해당 객체의 대상 객체를 호출
      * 현재 CacheAspect -> ExeTimeAspect -> RecCalculator의 순서이므로
      * jointPoint.proceed()를 호출하게 되면
      * 대상 객체인 ExeTimeAspect 프록시를 호출
      */
      Object result = joinPoint.proceed(); // 실행순서 1)
    
      cache.put(num, result);
      System.out.printf("CacheAspect: Cache에 추가[%d]\n", num); // 실행순서 5)
      return result;
    
      // ExeTimeAspect.java
      ...
      try {
              // 위와 마찬가지로 joinPoint.proceed()에서 해당 객체의 대상 객체를 호출
              // 여기서는 실제 대상 객체 (RecCalculator)를 호출
          Object result = joinPoint.proceed(); // 실행순서 2)
          return result;
      } finally {
              ...
          System.out.printf("%s.%s(%s) 실행 시간 : %d ns\n", // 실행순서 4)
          ...
      }
      ...
    
      // RecCalculator.java
      ...
    
      @Override
      public long factorial(long num){ // 실행순서 3)
      ...
    


3-3. @Order 어노테이션과 Aspect 적용 순서

앞선 예제 코드의 흐름은 아래와 같다.

  • CacheAspect 프록시 -> ExeTimeAspect 프록시 -> RecCalculator (실제 대상 객체)

    하지만 우리는 순서를 지정해준 적이 없는데, 어떤 정책에 의해 위와 같은 순서로 진행 되는가?

  • 그 이유는 설정 파일에 아래와 같은 순서로 작성했기 때문

      // AppCtxWithCahce.java
      @Configuration
      @EnableAspectJAutoProxy
      public class AppCtxWithCache {
    
              // 1번째로 적용될 Aspect
          @Bean
          public CacheAspect cacheAspect() {
              return new CacheAspect();
          }
              // 2번째로 적용될 Aspect
          @Bean
          public ExeTimeAspect exeTimeAspect() {
              return new ExeTimeAspect();
          }
    
          @Bean
          public Calculator calculator() {
              return new RecCalculator();
          }
    
      }
    

    실제 두 Apsect의 위치를 맞바꾸면, 실행결과가 달라짐

  • 어떤 Aspect가 적용되는지 순서가 중요하다면, 직접 순서를 지정해야 함
  • @Order 어노테이션을 해당 클래스에 적용함으로써 그 순서를 아래와 같이 지정 가능

      import org.springframework.core.annotation.Order;
    
      // ExeTimeAspect.java
      @Aspect
      @Order(1)
      public class ExeTimeAspect{
      ...
      }
    
      // CacheAspect.java
      @Aspect
      @Order(2)
      public class CacheAspect{
      ...
      }
    

    위의 설정대로 수행 시, 아래와 같은 순서로 실행
    ExeTimeAspect 프록시 -> CacheAspect 프록시 -> RecCalculator 실제 대상 객체

3-4. @Around의 Pointcut 설정과 @Pointcut의 재사용

  • @Pointcut 어노테이션이 아닌 @Around 어노테이션에 execution 명시자를 지접 아래와 같이 지정 가능

      // CacheAspect.java
      @Aspect
      public class CacheAspect {
              ...
          @Around("execution(public * chap07..*(long))")
          public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
              ...
          }
      }
    
  • 만약 같은 Pointcut을 여러 Advice가 함께 사용한다면, 아래와 같이 공통 Pointcut을 재사용 가능

      // ExeTimeAspect.java
      @Aspect
      public class ExeTimeAspect {
              // 다른 Advice가 참조하기 위해 prviate -> public으로 변경
          @Pointcut("execution(public * chap07..*(..))")
          public void publicTarget() {
          }
          ...
      }
    
      @Aspect
      public class CacheAspect {
          ...
              // 앞선 코드의 Pointcut 재사용
          @Around("aspect.ExeTimeAspect.publicTarget()")
          public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
              ...
          }
      }
    

    만약 여러 Aspect에서 공통으로 사용하는 Pointcut이 있다면,
    별도 클래스에 Pointcut을 정의하고,
    각 Aspect 클래스에서 해당 Pointcut을 사용하도록 구성

    이렇게 구성된 별도의 클래스는 따로 Bean에 등록할 필요 없고,
    @Around 어노테이션에서 해당 클래스에 접근 가능하면 사용 가능