상세 컨텐츠

본문 제목

[스프링1] 7장. AOP 프로그래밍

22-23/22-23 Spring 1

by YUZ 유즈 2022. 11. 10. 10:00

본문

728x90

 

우리는 흔히 특정 로직의 실행 시간을 측정하여 해당 로직의 유효성 혹은 효율성을 판단해야 할 때가 있다. 이때 어떤 방법을 사용할 수 있을까?

❗ 실행 시간을 측정하는 방법 : 프록시

📌 메서드의 시작과 끝

가장 쉬운 방법으로는 메서드의 시작과 끝에서 시각을 구하고 두 시각의 차이를 나타내는 방법이다.

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.factorial(%d) 실행 시간 = %d\n", num, (end-start));
        return result;
    }
}

재귀 호출이기 때문에 해당 함수가 재귀적으로 호출될 때마다 실행 시간이 출력된다는 사소한 문제가 발생하기도 한다.

그것보다 더 큰 문제는 코드의 가독성이 급격히 저하된다는 점이다.

📌 프록시 객체

프록시(proxy) : 핵심 기능의 실행은 다른 객체에게 위임하고 부가적인 기능을 제공하는 객체

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;
    }
}

실제 계산기 로직을 delegate로 위임하여 계산하는 방식이다.

ExeTimeCalculator에는 핵심 로직을 구현하지 않고, 시간을 계산하는 로직만 존재한다.

사용할 땐 아래와 같이 ExeTimeCalculator를 생성하고 그 안에 사용할 계산기를 넣어준다.

ImpeCalculator impeCal = new ImpeCalculator();
ExeTimeCalculator calculator = new ExeTimeCalculator(impeCal);
long result = calculator.factorial(4);

1) 프록시 객체를 사용하는 곳에서 직접 factorial 로직을 구현하는 게 아니라

delegate(Calculator) 객체로 로직을 위임하여 어떤 calculator 로직이냐에 관계 없이 시간을 측정할 수 있게 되었다.

 

2) 실행 시간을 구하는 코드를 ExeTimeCaculator로 빼서 중복을 제거했다.

 

❗ AOP(Aspect Oriented Programming)

여러 객체에 공통으로 적용할 수 있는 기능을 분리해서 재사용성을 높여주는 프로그래밍 기법

 

📌 AOP의 기본 개념

핵심 기능을 방해하거나 해치지 않으면서 공통 기능을 삽입하는 것을 말한다.

  • 컴파일 시점에 코드에 공통 기능을 삽입하는 방법
  • 클래스 로딩 시점에 바이트 코드에 공통 기능을 삽입하는 방법
  • 런타임에 프록시 객체를 생성해서 공통 기능을 삽입하는 방법

스프링이 제공하는 방식은 마지막 세 번째 방식으로, 프록시 객체를 활용한다.

 

📌 AOP 주요 용어

  • Advice : 언제 공통 관심 기능을 핵심 로직에 적용할 지를 정의한다.
  • Joinpoint : Advice를 적용 가능한 지점을 의미한다. 메서드 호출, 필드 값 변경 등이 Joinpoint에 해당한다.
  • Pointcut : Joinpoint의 부분 집합으로서 실제 Advice가 적용되는 Joinpoint를 나타낸다.
  • Weaving : Advice를 핵심 로직 코드에 적용하는 것을 weaving이라고 한다.
  • Aspect : 여러 객체에 공통으로 적용되는 기능을 Aspect라고 한다. 트랜잭션이나 보안 등이 Aspect의 좋은 예다.

❗ 스프링으로 AOP 구현하기

📌 @Aspect, @Pointcut, @Around

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;

import java.util.Arrays;


@Aspect
public class ExeTimeAspect {
    @Pointcut("execution(public * chap07..*(..))")
    private void publicTarget() {

    }
    @Around("publicTarget()")
    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 애노테이션을 적용한 클래스에서 Advice와 Pointcut을 사용할 수 있다.

@Pointcut은 공통 기능을 적용할 대상을 설정한다. 

@Around은 Around Advice를 설정한다. publicTarget 메서드에 정의한 Pointcut에 공통 기능을 적용한다는 것을 의미한다.

 

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

@Aspect 애노테이션을 붙인 클래스를 공통 기능으로 적용하려면 @EnableAspectJAutoProxy 애노테이션을 설정 클래스에 붙여야 한다. 이 애노테이션을 추가하면 스프링은 @Aspect 애노테이션이 붙은 빈 객체를 찾아 빈 객체의 @Pointcut 설정과 @Around 설정을 사용한다.

    @Pointcut("execution(public * chap07..*(..))")
    private void publicTarget() {

    }
    @Around("publicTarget()")
    public Object measure(ProceedingJoinPoint joinPoint) throws Throwable {
		...
    }

@Around 애노테이션은 Pointcut으로 publicTarget() 메서드를 설정했다.

public class MainAspect {

    public static void main(String[] args) {
        AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(AppCtx.class);

        Calculator cal = ctx.getBean("calculator", Calculator.class);
        long fiveFact = cal.factorial(5);
        System.out.println("cal.factorial(5) = " + fiveFact);
        System.out.println(cal.getClass().getName());
        ctx.close();
    }
}

 

❗ 프록시 생성 방식

getBean 메서드에 Calculator 타입 대신 RecCalculator 타입을 사용하도록 하면 실제 타입과 기대한 타입이 다르다는 오류가 뜬다. 이때 실제 타입은 'com.sun.proxy.$Proxy17'이라고 뜨는데, 이 $Proxy17 클래스는 AOP를 위한 프록시 객체이며, RecCalculator 클래스가 상속받은 Calculator 인터페이스를 상속받는다.

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

// 수정 후
RecCaculator cal = ctx.getBean("calculator", RecCalculator.class);

정리하자면, AOP를 위한 프록시 객체를 생성할 때는 실제 생성할 빈 객체가 인터페이스를 상속하면 인터페이스를 이용해서 프록시를 생성한다는 것이다.

 

이때, 인터페이스가 아닌 클래스로 프록시를 설정하고 싶다면 다음과 같이 설정한다.

@Configuration
@EnableAspectAutoProxy(proxyTargetClass = true)
public class AppCtx {

❗ execution 명시자 표현식

execution 명시자는 Advice를 적용할 메서드를 지정할 떄 사용한다.

@Pointcut("execution(public * chap07..*(..))")
private void publicTarget() {
}

기본 형식은 다음과 같다.

execution(수식어패턴? 리턴타입패턴 클래스이름패턴?메서드이름패턴(파라미터패턴))
  • 수식어패턴 : public, protected 등
  • 리턴타입패턴 : 리턴 타입 명시
  • 클래스이름패턴 : 클래스 이름을 패턴으로 명시
  • 메서드이름패턴 : 메서드 이름을 패턴으로 명시
  • 파라미터패턴 : 매칭될 파라미터 명시

각 패턴은 '*'을 이용하여 모든 값을 표현할 수 있다. 또한 '..'을 이용하여 0개 이상이라는 의미를 표현한다.

 

❗ 프록시 Advice 적용 순서

@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]; // 첫번째 인자를 Long 타입으로 구한다
        // 구한 값이 cache에 존재하면 키에 해당하는 값을 구해 리턴한다
        if(cache.containsKey(num)) {
        	System.out.printf("CacheAspect: Cache에서 구함 [%d]\n", num);
            return cache.get(num);
        }
        
        Object result = joinPoint.proceed();
        cache.put(num, result); // cache에 값이 존재하지 않으면 프록시 대상 객체를 실행한다
        System.out.printf("CacheAspect: Cache에 추가 [%d]\n", num); // 프록시 대상 객체를 실행한 결과를 cache에 추가
        return result; // 프록시 대상 객체의 실행 결과 리턴
    }
}

캐시를 구현한 공통 기능이다.

  • @Around 값으로 cacheTarget() 메서드 지정
  • @Pointcut 설정은 첫 번째 인자가 long인 메서드를 대상

이 Aspect를 스프링 설정 클래스에 다음과 같이 적용한다.

@Configuration
@EnableAspectJAutoProxy
public class AppCtxWithCache {

	...
    @Bean
    public CacheAspect cacheAspect() {
    	return new CacheAspect();
    }
    
    ...
}

이 설정 클래스는 다음과 같이 이용한다.

AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(AppCtxWithCache.class);

Calculator cal = ctx.getBean("calculator", Calculator.class);

❗ @Around의 Pointcut 설정과 @Pointcut 재사용

@Aspect
public class CacheAspect {
	@Around("execution(public * chap07..*(..))")
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
    	...
    }
}

@Pointcut 애노테이션이 아닌 @Around 애노테이션에 execution 명시자를 직접 지정할 수도 있다.

Pointcut을 여러 Advice가 함께한다면 공통 Pointcut을 재사용할 수도 있다. 

 

다른 클래스에 있는 특정 public Pointcut을 사용하려면 다음과 같이 명시할 수 있다.

패키지명.클래스명.Pointcut명

@Aspect
public class CacheAspect {
	@Around("aspect.ExeTimeAspect.publicTarget()")
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
    	...
    }
}

Spring 1

EDITOR: PORO

728x90

관련글 더보기