[Spring] AOP

Fortice·2021년 1월 15일
1

Spring

목록 보기
5/13
post-thumbnail

AOP (Aspect Oriented Programming)

IoC/DI, 서비스 추상화와 더불어 스프링의 3개 기반기술의 하나이다.
스프링의 기술 중 가장 이해하기 힘든 난해한 용어와 개념을 가진 기술이라고 한다. 스프링 프로그래밍 입문 5에서는 비교적 빨리 다루지만 토비의 스프링에서는 좀 비교적 늦게 다룬다.

토비의 스프링에서는 AOP의 등장배경, 도입한 이유, AOP를 적용해 얻을 수 있는 장점을 알아야 한다고 한다.

프록시 객체

AOP를 공부할 때 프록시란 용어가 나온다. 따라서 간단히 알고 넘어간다.

1. 개념

핵심 기능의 실행은 다른 객체에 위임하고 부가적인 기능을 제공하는 객체를 프록시라고 부른다. 핵심 기능을 실행하는 객체는 대상 객체라고 부른다. 흔히 사용하던 프록시 개념 처럼 흐름의 중간에 위치하여 요청, 결과를 대신 주고 받는 역할을 한다.

비슷한 개념으로 데코레이터가 있다.

데코레이터

프록시가 접근 제어 관점에 초점이 맞춰져 있다면, 데코레이터는 기능 추가확장에 초점이 맞춰져 있다.

2. 특징

프록시의 특징은 핵심 기능은 구현하지 않는다는 점이다. 대신 여러 객체에 공통으로 적용할 수 있는 기능을 구현한다.

3. 예시

아래에 팩토리얼 계산을 위한 클래스가 2개가 있다. 각각 반복문, 재귀를 이용한 메서드를 가지는데 이 시간을 측정하고 결과를 내주는 코드다.

만약 해당 코드에서 밀리초로 계산이 되는데 나노초로 계산하고 싶다면 두 클래스 모두 바꾸어 주여야 한다. 이 공통적인 기능을 따로 추출하여 수정 시 실제 기능 메서드는 고치지 않고 공통 기능만 고치면 되도록 한다.

public class ImpeCalculator implements Calculator{

    @Override
    public long factorial(long num) {
        long start = System.currentTimeMillis();
        long result = 1;
        for (long i = 1; i <= num; i++) {
            result *= i;
        }
        long end = System.currentTimeMillis();
        System.out.printf("impeCalculator(%d) 실행 시간 = %d \n", num, (end-start));

        return result;
    }
}

public class RecCalculator implements Calculator{
    
    @Override
    public long factorial(long num) {
        long start = System.currentTimeMillis();
        try {
        if(num == 0)
            return 1;
        else
            return num * factorial(num-1);
        } finally {
            long end = System.currentTimeMillis();
            System.out.printf("RecCalculator.factorial(%d) 실행 시간 = %d\n", num, (end-start));
        }
    }
}

ExeTimeCalculator 클래스로 공통기능 시간 측정을 실제 기능 factorial 계산을 분리해 냈다. 이 경우 시간 측정 기능을 교체 시 해당 공통 기능을 가진 클래스만 바꾸면 되기 때문에 재사용성이 증가했다. 이 ExeTimeCalculator 클래스가 바로 프록시 객체이다

public class ExeTimeCalculator implements Calculator{
    
    private Calculator delegate;

    public ExeTimeCalculator(Calculator delegate) {
        this.delegate = delegate;
    }

    @Override
    public long factorial(long num) {
        long start = System.nanoTime();
        long result = delegate.factorial(num);
        long end = System.nanoTime();
        System.out.printf("%s.factorial(%d) 실행 시간 = %d\n", delegate.getClass().getSimpleName(), num, (end-start));
        return result;
    }
}


public static void main(String[] args) throws ClassNotFoundException, SQLException{
	ExeTimeCalculator test1 = new ExeTimeCalculator(new ImpeCalculator());
	System.out.println(test1.factorial(20));

	ExeTimeCalculator test2 = new ExeTimeCalculator(new RecCalculator());
	System.out.println(test2.factorial(20));
}

4. AOP와의 연관성

AOP의 핵심이라 볼 수 있는게, 공통기능의 구현과 핵심 기능 구현을 분리하는 것이다. 따라서 프록시를 사용하기 때문에 알고 넘어간다.

AOP(Aspect Oriented Programming)

1. 개념

여러 객체에 공통으로 적용할 수 있는 기능을 분리해서 재사용성을 높여주는 프로그래밍 기법이다. 핵심 기능과 공통기능을 분리함으로써 핵심 기능을 구현한 코드의 수정 없이 공통 기능을 적용할 수 있게 만들어 준다.

권한 체크, 로깅 등 공통 작업을 분리하여 수행하고 싶지만, 자바에서 다중 상속이 불가능하기 때문에, 권한 체크, 로깅 객체를 따로 만들어서 상속 받을 수 없다. 연속적으로 의존관계를 엮어 만들면 필요하지 않은 기능을 실행해야할 수 있고, 비효율 적이다.

2. 핵심 기능에 공통 기능을 삽입하는 방법 3가지

  1. 컴파일 시점에 코드에 삽입
  2. 클래스 로딩 시점에 바이트 코드에 삽입
  3. 런타임 시점에 프록시 객체를 생성하여 삽입

1, 2번 방식은 스프링 AOP에서는 지원하지 않으며, AspectJ와 같이 AOP 전용 도구를 사용해서 적용 가능하다.
스프링에서는 프록시를 이용한 방식으로 AOP를 지원한다. 2번도 지원은 하지만 프록시 방식을 주로 이용한다. 스프링 AOP는 이 프록시 객체를 자동으로 만들어준다.
(1, 2번은 정확히 어떤 것인지 모르겠다.)

3. AOP 용어

  • Aspect : 여러 객체에 공통으로 적용되는 기능이다. ex) 트랜잭션, 보안
  • Advice : 언제 공통 관심 기능을 핵심 로직에 적용할 지를 정의하고 있다. ex) 메서드 호출 전, 후
  • Joinpoint : Advice를 적용 가능한 지점을 의미한다. 메서드 호출, 필드 값 변경 등이 있으며, 프록시의 경우 메서드 호출이다.
  • Pointcut : Joinpoint의 부분 집합으로 실제 Advice가 적용되는 Joinpoint를 나타낸다. 스프링에서 정규 표현식, AspectJ 문법으로 정의 가능하다.
  • Weaving : Advice를 핵심 로직 코드에 적용하는 것을 Weaving이라 한다.
3-1. 메서드 호출 시 Advice 종류

프록시에서는 메서드 호출 시 Aspect를 적용하므로 이 때, 구현 가능한 Advice를 알아본다.

  • Before Advice : 대상 객체의 메서드 호출 전에 공통 기능을 실행한다.
  • After Returning Advice : 대상 객체의 메서드가 익셉션 없이 실행된 이후에 공통 기능을 실행한다.
  • After Throwing Advice : 대상 객체의 메서드를 실행하는 도중 익셉션이 발생한 경우에 공통 기능을 실행한다.
  • After Advice : 익셉션 발생 여부에 상관 없이 대상 객체의 메서드 실행 후 공통 기능을 실행한다. (try-catch-finally의 finally와 비슷)
  • Around Advice : 대상 객체의 메서드 실행 전, 후 또는 익셉션 발생 시점에 공통 기능을 실행하는데 사용된다. ====================================

4. AOP 구현

스프링 AOP를 이용한 방법은 간단하다.

  • Aspect로 사용할 클래스에 @Aspect 어노테이션을 붙인다.
  • @Pointcut 어노테이션으로 공통 기능을 적용할 Pointcut을 정의한다.
  • 공통 기능을 구현한 메서드에 @Around 어노테이션을 적용한다.

위에 만든 시간 측정 클래스에 적용해본다. 상당히 복잡해보인다.

@Aspect //공통 기능 
public class ExeTimeAspect {
    
    @Pointcut("execution(public * studyaop.*.*(..))") //Advice 적용 지점 = sdudyaop 패키지 public 메서드들
    private void publicTarget() {
    }

    @Around("publicTarget()") 
    // advice = around advice = 메서드 호출 전/후 Aspect 실행
    public Object measure(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.nanoTime();
        try {
            Object result = joinPoint.proceed(); //실제 대상 객체의 메서드 호출
            return result;
        } finally {
            long finish = System.nanoTime();
            Signature sig = joinPoint.getSignature();
            System.out.printf(
                "%s.%s(%s) 실행 시간 : %d ns\n", 
                joinPoint.getTarget().getClass().getSimpleName(), 
                sig.getName(), 
                Arrays.toString(joinPoint.getArgs()), 
                (finish - start)
            );
        }
    }
}

코드 설명

  • @Aspect : 해당 클래스를 공통 기능을 담은 객체로 설정 (클래스 단에 설정)
  • @Pointcut(...) : Advice(메서드 호출 전/후)에 적용 가능한 지점인 Pointcut을 설정
    • 여기서 내용은 studyaop 패키지 내 public 메서드
  • @Around(...) : ... 메서드에 정의한 Pointcut에 Aspect 적용
    • Around는 around advice로 메서드 호출 전/후 실행을 의미
    • 위의 publicTarget에서 정의한 대로 studyaop 패키지 내 Bean 객체의 public 메서드에 @Around가 붙은 아래의 mesure() 메서드를 적용한다는 의미.
  • ProceedingJoinPoint : Joinpoint에 접근하기 위한 객체
    • JoinPoint는 Pointcut중 하나 == 실제 실행 객체
    • proceed()를 통해 실제 기능 메서드 실행
    • 이외에도 대상 객체와 메서드에 대한 정보를 불러올 수 있음
  • mesure : 공통 기능을 담은 함수
  • Signature : 자바에서 메서드 이름과 파라미터를 합쳐 메서드 시그니처라고 한다.

@Pointcut 문법

  1. execution : Advice를 적용할 메서드를 명시할 때 사용
    • 정의 : execution([수식어] 리턴타입 [클래스이름].메서드이름(파라미터)
      • 수식어 : public, protected 등 수식어 (사실상 public만 유효)
      • 리턴타입 : Integer, String 등 리턴 타입 명시
      • 클래스 이름 : 풀 패키지 형식의 클래스 이름
      • 메서드 이름 : 메서드 이름
      • 파라미터 : 메서드의 파라미터
      • "*" : 모든 값을 표현
      • ".." : 0개 이상의 값을 표현
  2. within : JoinPoint로 설정할 특정 타입에 속하는 메서드를 명시할 때 사용
    • 정의 : within("풀 패키지형식 클래스")
  3. bean : 스프링 빈을 이용하여 설정
    • 정의 : bean(Bean이름)
    • "*" : 빈이름 Be* = Be로 시작하는 모든 빈

AOP 설정 파일만 있으면 안되고, 스프링 빈 등록을 위한 설정파일을 만둘고 등록해줘야한다.

@Configuration
@EnableAspectJAutoProxy
public class CalCfg {
    @Bean
    public ExeTimeAspect exeTimeAspect() {
        return new ExeTimeAspect();
    }
    
    @Bean
    public Calculator calculator() {
        return new RecCalculator();
    }
}

이후 main에서 불러와 실행시켜본다.

public static void main(String[] args) throws ClassNotFoundException, SQLException{
    ApplicationContext context = new AnnotationConfigApplicationContext(CalCfg.class);
    Calculator cal = context.getBean("calculator", Calculator.class);
    long fiveFact = cal.factorial(5);
    System.out.println("factorial 5 : " + fiveFact);
    System.out.println(cal.getClass().getName());
 }

실행 결과를 보면 calculrator 빈에 대해 getName을 했더니 RecCalculator가 아닌 $Proxy18이 나온 것을 볼 수 있다. 이 타입은 스프링에서 자동으로 생성한 프록시 타입이다. (중간에 signature은 궁금해서 한번 sig를 찍어보았다)

main -> $Proxy18 -> ExeTimeAspect -> ProceedingJoinPoint -> RecCalculator 순으로 호출 후 역순으로 리턴을 한 과정을 보았다.

에러

이 과정을 하면서 두 에러가 났다

  1. 패키지가 예제랑 달라 풀 패키지를 잘못쓰니 Aspect가 실행되지 않았다.

  2. 디렉터리 구조가 예제와 달라 순환 참조 에러가 났다.

    한 패키지에 모든 소스를 넣으니 패키지 내 모든 메소드를 Pointcut에 등록하다가 exeTimeAspect에 대해 순환 참조 에러가 발생했다. Pointcut에 클래스를 정확히 명시하여 해결했는데, 실제 구현 시 디렉터리 구조나 명시를 잘 해야겠다.

4-1. 프록시 생성 방식

Calculator 타입으로 받던 빈을 RecCalculator로 받으면 어떻게 될까?
RecCalculator는 Calculator를 상속받아 타입 변환에 문제가 없어 보인다.

public static void main(String[] args) throws ClassNotFoundException, SQLException{
    ApplicationContext context = new AnnotationConfigApplicationContext(CalCfg.class);
    RecCalculator cal = context.getBean("calculator", RecCalculator.class);
    long fiveFact = cal.factorial(5);
    System.out.println("factorial 5 : " + fiveFact);
    System.out.println(cal.getClass().getName());
 }

하지만 실제 실행 결과는 $Proxy18과 RecCalculator 타입은 다르다고 오류가 나온다. 이유는 스프링이 빈 객체가 인터페이스를 상속하면 인터페이스를 이용해 Proxy 객체를 생성하기 때문이다.

이는 Config 파일의 @EnableAspectJAutoProxy 어노테이션 proxyTargetClass 속성을 true로 해서 클래스를 상속하도록 바꿀 수 있다.

@EnableAspectJAutoProxy(proxyTargetClass = true)

5. 여러개의 Advice 적용하기

여러개의 Advice를 적용해보기 위해 먼저 Cache기능의 Aspect를 만들고, 설정파일에 등록했다.

@Aspect
public class CacheAspect {

    private Map<Long, Object> cache = new HashMap<>();
    @Pointcut("within(com.fortice.spring.studyaop.RecCalculator)") //Advice 적용 지점 = sdudyaop 패키지 public 메소드들
    private void cacheTarget() {
    }

    @Around("cacheTarget()") // advice = around advice = 메소드 호출 전/후 Aspect 실행
    public Object measure(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;
    }
}
@Configuration
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class CalCfg {
    @Bean
    public CacheAspect cacheAspect() {
        return new CacheAspect();
    }
    @Bean
    public ExeTimeAspect exeTimeAspect() {
        return new ExeTimeAspect();
    }
    @Bean
    public Calculator calculator() {
        return new RecCalculator();
    }
}

CacheAspect를 ExeTimeAspect 위에 등록한 후 factorial 메서드를 여러번 실행시켜 봤다.

@Configuration
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class CalCfg {
    @Bean
    public ExeTimeAspect exeTimeAspect() {
        return new ExeTimeAspect();
    }
    @Bean
    public CacheAspect cacheAspect() {
        return new CacheAspect();
    }
    @Bean
    public Calculator calculator() {
        return new RecCalculator();
    }
}

이후 반대로 실행시켜보았다.

전자의 경우 원하는대로 CacheAspect가 먼저 실행되어서 cache추가하기 전에만 측정한다. 하지만 후자는 4번 모두 시간을 측정했다. 게다가 cache작업이 끝난 후 측정한다. 이를 보아 현재 버전의 Spring은 Bean을 등록한 순서대로 Aspect가 실행됨을 알 수 있다. 이는 항상 그렇다는 보장이 없기 때문에 순서를 지정해줘야한다.

Aspect proceed()

5-1. 순서 지정하기

순서는 @Aspect 어노테이션에 @Order(int) 어노테이션을 통해 지정할 수 있다.

@Aspect
@Order(2)
public class CacheAspect {...}

@Aspect //공통 기능 
@Order(4)
public class ExeTimeAspect {}

int값아 작은 Aspect 부터 우선적으로 실행한다. (음수 값도 된다)

6. @Around의 Pointcut 설정과 재사용

우리는 Pointcut을 @Pointcut 어노테이션에 지정했었는데, @Around 어노테이션에도 직접 설정 가능하다.

@Pointcut("within(com.fortice.spring.studyaop.RecCalculator)") 
private void cacheTarget() {
}

@Around("cacheTarget()") 

위에서 아래처럼 바꾸면 된다.

@Around("within(com.fortice.spring.studyaop.RecCalculator)") 

그럼 왜 굳이 위에 같이 지정할 경우를 만들어 놨을까?
위 방식의 장점은 @Around 어노테이션에 메서드 이름을 지정하기 때문에 외부 설정을 가져올 수 있기 때문이다.

이를 이용해 Pointcut 설정용 파일을 만들 수 있다.

package com.fortice.spring.config;
@Aspect
public class CommonConfig {
    @Pointcut("within(com.fortice.spring.studyaop.RecCalculator)") 
    public void commonCacheTarget() {
    }

    @Pointcut("execution(* com.fortice.spring.studyaop.RecCalculator.*(..))")
    public void commonPublicTarget() {
    }
}

위처럼 public으로 Pointcut 메서드를 만들고 이를 @Around 어노테이션에 넘겨주면 된다.

profile
서버 공부합니다.

0개의 댓글