뒤에서 설명할 트랜잭션의 처리 방식을 이해하려면 AOP(Aspect Oriented Programming)를 알아야 한다.
프로젝트 준비
aspectjweaver 의존 추가
build.gradle에 추가
implementation group: 'org.aspectj', name: 'aspectjweaver', version: '1.8.13'
스프링의 AOP 기능은 spring—aop 모듈이 제공하는데 spring—context 모듈을 의존 대상에 추가하면 spring-aop 모듈도 함께 의존 대상에 포함된다. 따라서 spring—aop 모듈에 대한 의존을 따로 추가하지 않아도 된다. aspectjweaver 모듈은 AOP를 설정하는데 필요한 애노테이션을 제공하므로 이 의존을 추가해야 한다.
프록시와 AOP
Calculator 인터페이스를 만들고 이를 구현하는 클래스를 두 개 만든다. 이때 이 클래스의 실행시간이 알고 싶어 실행시간을 구하는 코드를 추가했다. 이때, 시간을 구하는 방식을 변경 할때 직접 코드를 수정해야 하며 두 클래스간의 코드 중복이 생긴다. 이 사항들을 피하는 방법이 있을까?
Calculator 인터페이스를 구현하는 클래스를 추가해, 프록시 패턴처럼 실행을 대신한다. 이전의 두 클래스의 핵심 메서드 사이에 시간을 구하는 코드를 추가하면 중복을 피하고, 소스 변경도 이때 추가한 클래스만 수정하면 된다.
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;
}
}
ExeTimeCalculator ttCal1 = new ExeTimeCalculator(new ImpeCalculator());// ImpeCalculator=> 프록시의 대상 객체
System.out.println(ttCal1.factorial(20));
ExeTimeCalculator ttCal2 = new ExeTimeCalculator(new RecCalculator());// RecCalculator => 프록시의 대상 객체
System.out.println(ttCal2.factorial(20));
문제점 해결
- 기존 코드를 변경하지 않고 실행 시간을 구할 수 있다.
- 실행 시간을 구하는 코드의 중복을 제거했다.
이것이 가능한 이유는 ExeTimeCalculator 클래스를 다음과 같이 구현했기 때문이다.
- 핵심 기능을 직접 구현하기보다는 다른 객체에 실행을 위임했다.
- 계산 기능 외에 다른 부가적인(실행 시간 측정) 기능을 실행한다.
엄밀히 따지면 데코레이터 패턴에 가깝지만, AOP를 설명할 때, 프록시란 용어를 사용하고 있어 프록시라 부르겠다.
프록시의 특징
- 핵심 기능은 구현하지 않는다. (피보나치 구하기)
- 여러 객체에 공통으로 적용할 수 있는 기능을 구현 (예제의 실행 시간 측정)
이렇게 공통 기능 구현과 핵심 기능 구현을 분리하는 것이 AOP의 핵심이다!
AOP
📌 AOP는 Aspect Oriented Programming의 약자로, 여러 객체에 공통으로 적용할 수 있는 기능을 분리해서 재사용성을 높여주는 프로그래밍 기법이다 (관점[기능 or 관심] 지향 프로그래밍)
핵심 기능의 코드를 수정하지 않으면서 공통 기능의 구현을 추가하는 것이 AOP이다. 핵심 기능에 공통 기능을 삽입하는 방법에는 다음 세 가지가 있다.
- 컴파일 시점에 코드에 공통 기능 삽입하는 방법 (AOP 전용 도구 사용 방식)
- 클래스 로딩 시점에 바이트 코드에 공통 기능을 삽입하는 방법 (AOP 전용 도구 사용 방식)
- 런타임에 프록시 객체를 생성해서 공통 기능을 삽입하는 방법 (스프링이 제공) ⭐
스프링 AOP는 프록시 객체를 자동으로 만들어 준다. 따라서 ExeTimeCalculator 클래스처럼 상위 타입의 인터페이스를 상속받은 프록시 클래스를 직접 구현할 필요가 없다.
AOP 주요 용어
용어 | 의미 |
Advice | 언제 공통 관심 기능을 핵심 로직에 적용할 지를 정의하고 있다. 예를 들어 '메서드를 호출하기 전(언제)에 트랜잭션 시작’(공통 기능) 기능을 적용한다는 것을 정의한다. |
Joinpoint | Advice 를 적용 가능한 지점을 의미한다. 메서드 호출, 필드 값 변경이 Joinpoint에 해당한다. 스프링은 프록시를 이용해서 AOP를 구현하기 때문에 메서드 호출에 대한 Joinpoint 만 지원한다. |
Pointcut | Joinpoint 의 부분 집합으로서 실제 Advice 가 적용되는 Joinpoint 를 나타낸다 . 스프링에서는 정규 표현식이나 AspectJ 의 문법을 이용하여 Pointcut 을 정의할 수 있다. |
Weaving | Advice를 핵심 로직 코드에 적용하는 것을 weaving 이라고 한다. |
Aspect | 여러 객체에 공통으로 적용되는 기능을 Aspect 라고 한다. 트랜잭션이나 보안 등이 Aspect 의 좋은 예이다. |
Advice의 종류
용어 | 의미 |
Before Advice | 대상 객체의 메서드 호출 전에 공통 기능을 실행한다. |
After Returning Advice | 대상 객체의 메서드가 익셉션 없이 실행된 이후에 공통 기능을 실행한다. |
After Throwing Advice | 대상 객체의 메서드를 실행하는 도중 익셉션이 발생한 경우에 공통 기능을실행한다. |
After Advice | 익셉션 발생 여부에 상관없이 대상 객체의 메서드 실행 후 공통 기능을 실행한다. (try-catch finally 의 finally 블록과 비슷하다.) |
Around Advice | 대상 객체의 메서드 실행 전, 후 또는 익셉션 발생 시점에 공통 기능을 실행하는데 사용된다. |
이 중 에서 널리 사용되는 것은 Around Advice이다.
스프링 AOP구현
- Aspect로 사용할 클래스에 @Aspect 애노테이션을 붙인다 .
- @Pointcut 애노테이션으로 공통 기능을 적용할 Pointcut을 정의한다.
- 공통 기능을 구현한 메서드에 @Around 애노테이션을 적용한다.
공통 기능을 제공하는 Aspect 구현 클래스를 만들고 자바 설정을 이용해서 Aspect를 어디에 적용할지 설정하면 된다.
package org.example.chap07.aspect;
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 * org.example.chap07..*(..))")
private void publicTarget(){
}
@Around("publicTarget()") //Around Advice 를 설정 한다. publicTarget() 메서드에 정의한 Pointcut에 공통 기능을 적용한다.
public Object measure(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System .nanoTime();
try {
Object result = joinPoint.proceed(); // 1)
return result;
} finally {
long finish = System .nanoTime();
Signature sig = joinPoint. getSignature(); // 2)
System.out.printf("%s.%s(%s) 실행 시간 : %d ns\\n",
joinPoint.getTarget().getClass().getSimpleName(), // 3)
sig.getName(), Arrays.toString(joinPoint.getArgs()), // 4)
(finish - start));
}
}
}
- proceed(): 실제 대상 객체의 메서드를 호출
- Signature getSignature(): 호출한 메서드의 시그너처(메서드 이름과 파라미터를 뜻함), 정보를 구한다.
- Object getTarget(): 호출한 메서드의 대상 객체를 구한다.
- Object[] getArgs(): 호출한 메서드의 인자 목록을 구한다.
@Configuration
@EnableAspectJAutoProxy // @Aspect 애노테이션을 붙인 클래스를 공통 기능으로 적용
// 스프링은 @Aspect 애노테이션이 붙은 빈 객체를 찾아서
// @Pointcut 설정과 @Around 설정을 사용한다.
public class AppCtx {
@Bean
public ExeTimeAspect exeTimeAspect(){
return new ExeTimeAspect();
}
... 생략
📌 @Around 애노테이션은 Pointcut으로 publicTarget() 메서드를 설정했다. public Target() 메서드의 @Pointcut은 chap07 패키지나 그 하위 패키지에 속한 빈 객체의 public 메서드를 설정한다. calculator 빈에 ExeTimeAspect 클래스에 정의한 공통 기능인 measure()를 적용한다.
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();
ProceedingjoinPoint 의 메서드
Around Advice 에서 사용할 공통 기능 메서드는 부분 파라미터로 전달 받은 ProceedingJoinPoint의 proceed() 메서드만 호출하면 된다.
org.aspectj.lang.Signature 인터페이스는 다음 메서드를 제공한다.
- String getName() : 호출되는 메서드의 이름을 구한다
- String tolongString() : 호출되는 메서드를 완전하게 표현한 문장을 구한다(메서드의 리턴 타입 파라미터 타입이 모두 표시된다).
- String toShortString() : 호출되는 메서드를 축약해서 표현한 문장을 구한다(기본 구현은 메서드의 이름만을 구한다)
프록시 생성방식
// 메인클래스:
// 수정 전
Calculator cal = ctx.getBean("calculator", Calculator.class);
// 수정 후
RecCalculator cal = ctx.getBean("calculator", RecCalculator.class);
// 설정 클래스:
// AOP 적용 시 RecCalculator가 상속받은 Calculator 인터페이스를 이용해서 프록시 생성
@Bean
public Calculator calculator() {
return new RecCalculator();
}
// 자바 코드:
// "calculator" 빈의 실제 타입 은 Calculator를 상속한 프록시 타입 이므로
// RecCalculator로 타입 변환을 할 수 없기 때문에 익셉션 발생
RecCalculator cal = ctx.getBean("calculator", RecCalculator.class);
빈 객체가 인터페이스를 상속할 때 인터페이스가 아닌 클래스를 이용해서 프록시를 생성하고 싶다면 다음과 같이 설정하면 된다.
@Configuration
@EnableAspectJAutoProxy(proxyTargetClass = true) // 인터페이스가 아닌 자바 클래스를 상속받아 프록시 생성
public class AppCtx {
// 자바 코드 , "calculator" 프록시의 실제 타입은 RecCalculator를 상속받았으므로
// RecCalculator로 타입 변환 가능
RecCalculator cal = ctx.getBean("calculator", RecCalculator.class);
execution 명시자 표현식
@Pointcut("execution(public * chap07..*(..))") // execution 명시자 표현식
// Advice를 적용할 메서드를 지정할 때 사용
//‘*’을 이용하여 모든 값을 표현, ‘..’(점 두개)을 이용하여 0개 이상이라는 의미를 표현.
private void publicTargetO {
}
📌 execution( 수식어패턴 ? 리턴타입패턴 클래스이름패턴?메서드이름패턴(파라미터패턴))
- 수식어패턴: 생략 가능하며 public, protected 이 온다. 스프링 AOP는 public 메서드만 적용할 수 있기 때문에 사실상 public만 의미있다.
- 리턴타입패턴: 리턴 타입을 명시
- 클래스이름패턴, 메서드이름패턴: 클래스 이름 및 메서드 이름을 패턴으로 명시
- 파라미터패턴: 매칭될 파라미터 에 대해서 명시
Advice 적용 순서
한 Pointcut에 여러 Advice를 할 수도 있다.
@Aspect
public class CacheAspect {
private Map<Long, Object> cache = new HashMap<>();
@Pointcut("execution(public * org.example.chap07..*(..))")
public void cacheTarget() {
}
.. 생략
@Configuration
@EnableAspectJAutoProxy
public class AppCtxWithCache {
@Bean
public CacheAspect cacheAspect(){ //Aspect 추가 1
return new CacheAspect();
}
@Bean
public ExeTimeAspect exeTimeAspect(){ //Aspect 추가 2
return new ExeTimeAspect();
}
... 생략
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); // CacheAspect 실행 -> ExeTimeAspect 실행 -> 대상 객체 실행
cal.factorial(7);
cal.factorial(5);
cal.factorial(5);
ctx.close();
}
}
어떤 Aspect가 먼저 적용될지는 스프링 프레임워크나 자바 버전에 따라 달라질 수 있기 때문에 적용 순서가 중요하다면 직접 순서를 지정해야 한다.
@Order
@Aspect 애노테이션과 함께 @Order 애노테이션을 클래스에 붙이면 @Order 애노테이션에 지정한 값에 따라 적용 순서를 결정한다.
@Aspect
@Order(1)
public class ExeTimeAspect {
}
@Aspect
@Order(2)
public class CacheAspect {
}
// ExeTimeAspect프록시 -> CacheAspect 프록시 -> 실제 대상 객체
@Around 의 Pointcut 설정과 @Pointcut 재사용
@Around 애노테이션에 execution 명시자를 직접 지정할 수도 있다.
@Aspect
public class CacheAspect {
@Around("execution{public * chap07 .. *(..))")
public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
}
@Aspect
public class ExeTimeAspect {
@Pointcut("execution{public * chap07 .. •(. .))")
//private void publicTarget() {
public void publicTarget() {
}
}
//다른 클래스에 위치한 @Around 애노테이션에서 publicTarget() 메서드의
// Pointcut을 시용하고 싶다면 publicTarget() 메서드를 public으로 꾸면된다.
@Aspect
public class CacheAspect {
//@Around("aspect.ExeTlmeAspect.publicTarget()")
@Around("ExeTlmeAspect.publicTarget()") // 같은 패키지에 있으므로 클래스 이름으로 설정 가능
public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
}
}
여러 Aspect에서 공통으로 사용하는 Pointcut이 있다면 아래 사진과 같이 별도 클래스에 Pointcut을 정의하고, 각 Aspect 에서 해당 Pointcut을 사용하도록 구성하면 Pointcut 관리가 편해진다.
'Spring > 스프링5 프로그래밍 입문' 카테고리의 다른 글
Chapter 10. 스프링 MVC 프레임워크 동작 방식 (0) | 2023.04.03 |
---|---|
Chapter8. DB연동 (0) | 2023.02.26 |
Chapter6. 빈 라이프사이클과 범위 (0) | 2023.02.21 |
Chapter5. 컴포넌트 스캔 (0) | 2023.02.21 |
Chapter4. 의존 자동 주입 (0) | 2023.02.20 |