Spring AOP

Jang990·2023년 2월 26일
0
post-thumbnail

출처

"도서 - 초보 웹 개발자를 위한 스프링5 프로그래밍 입문"의 내용을 요약한 글입니다. 제가 나중에 보기 쉽게 요약을 했기 때문에 책의 내용과 다를 수 있습니다.

계산기 클래스 설정

AOP를 설명하기 위해 팩토리얼의 값을 구하는 계산기를 만듭니다.

public interface Calculator {
	public long factorial(long num);
}

// ImpeCalculator는 반복문을 이용하여 팩토리얼을 계산합니다.
public class ImpeCalculator implements Calculator {
	@Override
	public long factorial(long num) {
		long result = 1;
		for (long i = 1; i <= num; i++) {
			result *= i;
		}
		return result;
	}
}

// RecCalculator는 재귀를 이용하여 팩토리얼을 계산합니다.
public class RecCalculator implements Calculator {
	@Override
	public long factorial(long num) {
		if(num == 0)
			return 1;
		return num * factorial(num-1);
	}
}

메서드의 실행 시간 출력하기

앞서 구현한 클래스의 실행 시간을 출력하려면 어떻게 해야 할까요?
쉬운 방법은 메서드의 시작과 끝에 시간을 구하고 이 두 시간의 차이를 출력하는 것입니다.

예를 들어 반복문을 이용하여 팩토리얼을 계산하는 ImpeCalculator에서는 다음과 같이 실행 시간을 구할 수 있습니다.

public class ImpeCalculator implements Calculator {
	@Override
	public long factorial(long num) {
    	long start = System.currentTimeMillis();
		long result = 1;
		for (long i = 0; i < num; i++) {
			result *= i;
		}
        long end = System.currentTimeMillis();
		System.out.println("Impe 실행 시간: "+ (end - start));
		return result;
	}
}

문제 발생

그렇다면 재귀로 팩토리얼의 계산하는 RecCalculator에서는 어떻게 구해야 할까요? 기존 코드에서 실행 시간을 구하는 것보다 다음 코드 처럼 실행 전후에 값을 구하는게 나을지도 모릅니다.

RecCalculator rec = new RecCalculator();
ImpeCalculator impe = new ImpeCalculator();
		
long start = System.currentTimeMillis();
rec.factorial(5);
long end = System.currentTimeMillis();
System.out.println("작동 시간: "+ (end - start));
	
start = System.currentTimeMillis();
impe.factorial(5);
end = System.currentTimeMillis();
System.out.println("작동 시간: "+ (end - start));

🙄 이제 문제가 해결됐을까요?

❌ 아니요. 만약 실행 시간이 밀리초 단위가 아니라 나노초 단위로 구해야 한다면 어떻게 될까요? 위 코드에서 시간을 구하는 중복 코드를 모두 변경해주어야 합니다.

기존 코드를 수정하지 않고 코드 중복도 피하는 방법은 없을까요? 이때 출현하는 것이 바로 프록시 객체입니다.

문제 수정

ExeTimeCalculator 클래스는 Calculator를 상속받고 Calculator delegate 필드를 가지고 있습니다. 다음과 같이 실행을 위임하고 시간을 체크해서 출력할 수 있습니다.

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.println(delegate.getClass().getSimpleName() + " 작동 시간: "+ (end - start));
		return result;
	}
}

다음과 같은 흐름으로 동작됩니다.


또한, ExeTimeCalculator를 사용하면 다음과 같은 방법으로 ImpeCalculator, RecCalculator의 실행 시간을 체크할 수 있습니다.

ExeTimeCalculator ttCal1 = new ExeTimeCalculator(new RecCalculator());
System.out.println(ttCal1.factorial(10));
ExeTimeCalculator ttCal2 = new ExeTimeCalculator(new ImpeCalculator());
System.out.println(ttCal2.factorial(15));
- 실행결과
RecCalculator 작동 시간: 6000
3628800
ImpeCalculator 작동 시간: 1800
1307674368000

프록시 객체

앞서 문제를 수정하면서 얻은 이점은 다음과 같습니다.

  • 기존 코드를 변경하지 않고 시간을 출력할 수 있다.
    ImpeCalculator, RecCalculator 구분없이 기존코드를 변경하지 않고 메서드 실행 시간을 출력할 수 있게 되었습니다.

  • 실행 시간을 구하는 코드의 중복을 제거했다.
    초를 세는 코드를 변경하고 싶다면 ExeTimeCalculator 클래스만 변경하면 됩니다.

이렇게 핵심 기능의 실행은 다른 객체에 위임하고 부가적인 기능을 제공하는 객체를 프록시(proxy)라고 부릅니다.
실제 핵심 기능을 실행하는 객체는 대상 객체라고 부릅니다.

그림 7.1에서 ExeTimeCalculator가 프록시이고 ImpeCalculator 객체가 프록시의 대상 객체가 됩니다.

엄밀히 말하면 지금 작성한 코드는 프록시라기 보다는 데코레이터 객체에 가깝다. 프록시는 접근 제어 관점에 초점이 맞춰져 있다면, 데코레이터는 기능 추가와 확장에 초점이 맞춰져 있기 때문이다.

프록시의 특징은 핵심 기능은 구현하지 않는다는 점입니다.

앞서 본 예시에서 ExeTimeCalculator 클래스는 실행 시간 측정이라는 공통으로 적용되는 기능을 구현합니다. 그러나 팩토리얼 계산이라는 핵심 기능은 구현하지 않습니다.

정리하자면 ImpeCalculator, RecCalculator는 팩토리얼을 구한다는 핵심 기능 구현에 집중합니다. 프록시인ExeTimeCalculator는 실행시간 측정이라는 공통 기능 구현에 집중합니다.
이렇게 공통 기능 구현과 핵심 기능 구현을 분리하는 것이 AOP의 핵심입니다.


AOP

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

핵심 기능에 공통 기능을 삽입하는 방법

핵심 기능에 공통 기능을 삽입하는 방법은 다음 3가지가 있습니다.

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

첫 번째와 두 번째 방식은 스프링 AOP에서는 지원하지 않지만, AspectJ와 같이 AOP 전용 도구를 사용해서는 적용할 수 있습니다.

스프링이 제공하는 AOP 방식은 프록시를 이용한 세 번째 방식입니다. 두 번째 방식을 일부 지원하지만 널리 사용되는 방법은 프록시를 이용한 방식입니다. 앞서 살펴본 ExeTimeCalcuator 클래스를 사용한 방식이 프록시를 사용한 방법입니다.

스프링 AOP는 프록시 객체를 자동으로 만들어 줍니다. 따라서 ExeTimeCalcuator 클래스 처럼 상위 타입의 인터페이스를 상속 받은 프록시 클래스를 직접 구현할 필요가 없습니다. 단지 공통 기능을 구현한 클래스만 알맞게 구현하면 됩니다.


AOP 주요 용어

  • Aspect : 여러 객체에 공통으로 적용되는 기능
    트랜잭션이나 보안 등이 Aspect의 좋은 예
  • Advice : 언제 공통 관심 기능을 핵심 로직에 적용할지를 정의
    예를 들어 '메서드를 호출하기 전(언제)에 '트랜잭션 시작'(공통 기능) 기능을 적용한다는 것을 정의.
  • Joinpoint : Advice를 적용 가능한 지점을 의미
    스프링은 프록시를 이용해서 AOP를 구현하기 때문에 메서드 호출에 대한 Joinpoint만 제공.
  • Pointcut : Joinpoint의 부분 집합으로서 실제 Advice가 적용되는 Joinpoint를 나타냄
    스프링에서는 정규 표현식이나 AspectJ의 문법을 이용하여 Pointcut을 정의할 수 있다.
  • Weaving : advice를 핵심 로직 코드에 적용하는 것

Advice 종류

스프링은 프록시를 이용해서 메서드 호출 시점에 Aspect를 적용하기 때문에 구현 가능한 Advice 종류는 다음과 같습니다.

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

이 중에서 가장 널리 사용되는 것은 Around Advice입니다. 이유는 다양한 시점에 원하는 기능을 삽입 할 수 있기 때문입니다. 캐시 기능, 성능 모니터링 과 같은 Aspect를 구현할 때에는 Around Advice를 주로 이용합니다.


실제 구현

스프링 AOP를 이용해서 공통 기능을 구현하고 적용하는 방법은 단순합니다. 다음 절차를 따르면 됩니다. 프록시는 스프링 프레임워크가 알아서 만들어줍니다.

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

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;

@Aspect // 공통의 기능을 정의할 클래스입니다.
public class AOP_ExeTimeCalculator {
	
	//
    /*
    공통 기능을 적용할 대상을 설정합니다.
    앞에 적혀있는 execution에 대해서는 이후 알아보겠습니다.
    지금은 "적용할 대상을 설정한다" 정도로만 알고 있으면 됩니다.
    */
	@Pointcut("execution(public * AopMainProcess..*(..))")
	private void publicTarget() {
	}
	
	/* 
    Around Advice 즉 대상 객체의 메서드 실행 전, 후 
    또는 익셉션 발생 시점에 공통 기능을 실행합니다.
    
    위에 있는 publicTarget 메서드에 checkTime을 적용합니다.
    */
	@Around("publicTarget()")
	public Object checkTime(ProceedingJoinPoint pjp) throws Throwable {
		Object result = null;
		long start, end;
		
		start = System.nanoTime();
		try {
			result = pjp.proceed(); // 핵심 기능 실행
			return result;
		} finally {
			end = System.nanoTime();
			Signature sig = pjp.getSignature(); 
			System.out.println(sig.getName() + " 작동 시간: "+ (end - start));
		}
	}
}

공통 기능을 적용하는데 필요한 코드를 구현했으므로 @Aspect 어노테이션을 붙인 클래스를 공통 기능으로 적용해야 합니다. @EnableAspectJAutoProxy 어노테이션을 추가하면 스프링은 @Aspect 어노테이션이 붙은 빈 객체를 찾아서 빈 객체의 @Pointcut,@Around 설정을 사용합니다.


✋ 잠깐, SpringBoot에서도 @EnableAspectJAutoProxy를 추가해야 할까? (책 내용 아님)

SpringBoot에서는 @SpringBootApplication 어노테이션을 붙이면 여러 설정들을 자동화 해줍니다. 그 설정들 중에는 AopAutoConfiguration.java(Github)도 있습니다. AopAutoConfiguration.java는 공식문서에 다음과 같이 나와있습니다.

Auto-configuration for Spring's AOP support. Equivalent to enabling @EnableAspectJAutoProxy in your configuration.

= Spring의 AOP 지원을 위한 자동 설정. @EnableAspectJAutoProxy구성에서 활성화하는 것과 같습니다.
출처: AopAutoConfiguration.java 스프링 공식 문서

AopAutoConfiguration가 정말 설정으로 로드됐을까요? 스프링 부트에서 자동으로 로드된 설정들을 확인해봅시다.

스프링 부트의 application.properties 파일에서 debug=true를 설정하면, 스프링 부트 애플리케이션에 대한 Auto-Configureation Report를 활성화할 수 있습니다. 활성화를 한 후에 서버를 실행시켜보면 다음과 같은 콘솔 로그를 확인할 수 있습니다.

Positive matches에서 로드된 설정을 확인할 수 있는데, 해당 로그를 보면 AopAutoConfiguration 관련 설정이 로드된 것으로 확인할 수 있습니다.

@Aspect와 같은 AOP 관련 어노테이션을 사용하면 자동으로 로드가 되는 것 같네요. 이 부분은 나중에 AutoConfiguration 관련 공부를 좀 더 해야 알 것 같아요...

결과적으로 @EnableAspectJAutoProxy 어노테이션을 추가하지 않아도 됩니다. 😊

@SpringBootApplication
// @EnableAspectJAutoProxy
public class DemoApplication {

	public static void main(String[] args) {
		SpringApplication.run(DemoApplication.class, args);
	}

}

출처

https://stackoverflow.com/questions/48625149/spring-aop-works-without-enableaspectjautoproxy
https://scshim.tistory.com/420


exection 명시자 표현식

execution 명시자는 Advice를 적용할 메서드를 지정할 때 사용합니다. 기본 형식은 다음과 같습니다.

execution(수식어패턴? 리턴타입패턴 클래스이름패턴?메서드이름패턴(파라미터패턴))

수식어패턴은 public, protected등이 오며 생략 가능합니다. 스프링 AOP는 public 메서드에만 적용할 수 있기 때문에 사실상 public만 의미있습니다.

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

예시

execution(public void set*(..))
리턴타입 - void
메서드 이름 - set으로 시작
파라미터 - 0개 이상
해당 조건에 포함되는 모든 메서드

파라미터 부분에 .. 을 사용해서 파라미터가 0개 이상인 것을 표현
execution(* chap07.*.*())
chap07 패키지에 속하는 패키지
리턴 타입 - 모든 타입
파라미터 - 없음
해당 조건에 포함되는 모든 메서드
execution(* chap07..*.*(..))
chap07 패키지 및 하위 패키지
리턴 타입 - 모든 타입
파라미터 - 0개 이상

패키지 부분에 .. 을 사용해서 해당 패키지 또는 하위 패키지 표현
execution(Long chap07.Calculator.factorial(..))
리턴 타입 - Long
Calculator 타입의
factoria 메서드 호출
execution(* get*(*))
메서드 이름이 get으로 시작
파라미터 - 1개

execution(* get*(*,*))
메서드 이름이 get으로 시작
파라미터 - 2개
execution(* read*(Integer, ..))
메서드 이름 read로 시작
첫 번째 파라미터 - Integer
한 개 이상의 파라미터를 갖는 메서드 호출

AOP의 순서 - @Order

만약 다음과 같이 하나의 핵심 기능에 여러 Aspect를 적용할 때 순서가 중요할 수 있습니다.

시간 체크 기능 -> 캐싱 기능 -> 핵심 기능

어떤 Aspect가 먼저 적용될지는 스프링 프레임워크나 자바 버전에 따라 달라질 수 있습니다. 적용 순서가 중요하다면 직접 순서를 지정해야 합니다. 이럴 때 사용하는 것이 @Order 입니다.

@Aspect
@Order(1) // Cache 보다 먼저 실행
public class TimeAspect { ... }

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

@Around의 Pointcut 설정

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

@Aspect
public class AOP_ExeTimeCalculator {
	@Around("execution(public * AopMainProcess..*(..))") 
	public Object checkTime(ProceedingJoinPoint pjp) throws Throwable {
		...
	}
}
profile
공부한 내용을 적지 말고 이해한 내용을 설명하자

0개의 댓글