"도서 - 초보 웹 개발자를 위한 스프링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는 Aspect Oriented Programming의 약자로, 여러 객체에 공통으로 적용할 수 있는 기능을 분리해서 재사용성을 높여주는 프로그래밍 기법입니다. AOP는 핵심 기능과 공통 기능의 구현을 분리함으로써 핵심 기능을 구현한 코드의 수정 없이 공통 기능을 적용할 수 있게 만들어줍니다.
핵심 기능에 공통 기능을 삽입하는 방법은 다음 3가지가 있습니다.
첫 번째와 두 번째 방식은 스프링 AOP에서는 지원하지 않지만, AspectJ와 같이 AOP 전용 도구를 사용해서는 적용할 수 있습니다.
스프링이 제공하는 AOP 방식은 프록시를 이용한 세 번째 방식입니다. 두 번째 방식을 일부 지원하지만 널리 사용되는 방법은 프록시를 이용한 방식입니다. 앞서 살펴본 ExeTimeCalcuator
클래스를 사용한 방식이 프록시를 사용한 방법입니다.
스프링 AOP는 프록시 객체를 자동으로 만들어 줍니다. 따라서 ExeTimeCalcuator
클래스 처럼 상위 타입의 인터페이스를 상속 받은 프록시 클래스를 직접 구현할 필요가 없습니다. 단지 공통 기능을 구현한 클래스만 알맞게 구현하면 됩니다.
스프링은 프록시를 이용해서 메서드 호출 시점에 Aspect를 적용하기 때문에 구현 가능한 Advice 종류는 다음과 같습니다.
이 중에서 가장 널리 사용되는 것은 Around Advice
입니다. 이유는 다양한 시점에 원하는 기능을 삽입 할 수 있기 때문입니다. 캐시 기능, 성능 모니터링 과 같은 Aspect를 구현할 때에는 Around Advice를 주로 이용합니다.
스프링 AOP를 이용해서 공통 기능을 구현하고 적용하는 방법은 단순합니다. 다음 절차를 따르면 됩니다. 프록시는 스프링 프레임워크가 알아서 만들어줍니다.
@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에서는 @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
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
한 개 이상의 파라미터를 갖는 메서드 호출
만약 다음과 같이 하나의 핵심 기능에 여러 Aspect를 적용할 때 순서가 중요할 수 있습니다.
시간 체크 기능 -> 캐싱 기능 -> 핵심 기능
어떤 Aspect가 먼저 적용될지는 스프링 프레임워크나 자바 버전에 따라 달라질 수 있습니다. 적용 순서가 중요하다면 직접 순서를 지정해야 합니다. 이럴 때 사용하는 것이 @Order
입니다.
@Aspect
@Order(1) // Cache 보다 먼저 실행
public class TimeAspect { ... }
@Aspect
@Order(2)
public class CacheAspect { ... }
@Pointcut
어노테이션이 아닌 @Around
어노테이션에 execution 명시자를 직접 지정할 수도 있습니다.
@Aspect
public class AOP_ExeTimeCalculator {
@Around("execution(public * AopMainProcess..*(..))")
public Object checkTime(ProceedingJoinPoint pjp) throws Throwable {
...
}
}