AOP 소개
AOP는 관점지향 프로그래밍 기법이다. 핵심 기능과 부가 기능을 분리하고 모듈화하기 위해서이다.
AOP는 다양하게 적용될 수 있다. 우리가 흔히 사용하는 런타임 시점에 메소드 호출 시에 AOP를 적용할 수 있지만, 그 외에 컴파일 시, 클래스 로딩 시, 런타임에서도 필드 변경, 정적 변수 접근 등 다양하게 적용이 가능하다. 하지만 Spring AOP 에서는 런타임 시에 메서드 호출 시에만 사용 할 수 있다. 프록시를 사용하기 때문이다. AspectJ 를 사용하면 위에 나열한 다양한 상황에서 AOP를 적용할 수 있다.
예를 들면, 로깅 로직은 모든 기능 또는 도메인의 핵심로직 대부분에 들어가있다. 만약 로깅을 사용하는 소스코드가 수정이 발생하면, 모든 로직을 수정해야한다. 비지니스 로직을 핵심로직으로, 로깅을 부가적인 로직으로 관점을 정하고 소스코드 상 완전 분리한다면 변경에 대한 영향을 없앨 수 있다.
AOP 개념
AOP 는 다음과 같은 개념들이 있다.
- target: AOP 관점으로의 핵심 기능. 프록시 대상.
- advice: AOP 관점으로의 부가 기능. around, before, after 등 다양한 종류.
- joinpoint: advice를 적용할 수 있는 시점. 예를 들면 메소드 실행 시점, 필드값 접근 시점, 정적 메서드 접근, 컴파일, 클래스 로딩 등 추상적 개념이다. Spring AOP에서는 프록시 방식을 사용해서 메서드 실행 시점만 해당된다.
- pointcut: 부가적인 로직을 실행할 jointpoint보다 더 구체적 지점. 어떤 패키지, 클래스, 메서드를 대상으로 advice를 실행. 필터링 역할.
- aspect: advice 와 pointcut 을 합쳐 모듈화한 것. 여러 advice 와 pointcut 이 함께 존재. @Aspect 과 같다.
- advisor: advice 1개 + pointcut 1개 개념. Spring AOP 용어.
- weaving 위빙: 포인트컷으로 결정한 지점에 어드바이스를 적용하는 것
AOP 동적프록시로 구현
AOP는 프록시 패턴을 통해서 구현할 수 있다. Java 프록시 패턴에는 프록시 패턴과 데코레이션 패턴 2가지가 있다. 프록시 패턴은 요청자와 응답자 간의 중계자 역할을 하는 실행 흐름을 관리하는 패턴이고, 데코레이션 패턴은 중계자 역할 뿐만 아니라 부가적인 기능을 수행할 수 있는 패턴이다. AOP 에서는 부가적인 로직을 실행해야 하기 때문에 데코레이션 패턴을 사용한다.
Java 언어에서는 프록시 패턴을 쉽게 구현할 수 있도록 동적 프록시를 지원하고 있다. 프록시 대상자가 '인터페이스'면 JDK 동적 프록시(ProxyFactory)로 프록시를 만들 수 있고, '클래스'면 CGLIB를 통해서 프록시를 만들 수 있다. 하지만 이렇게 프록시를 만들어서 사용하면 프록시 대상자 별로 프록시를 만들어야하는 단점이 있다.
Spring AOP 원리
Spring 에서는 프록시 대상자 상관없이 편하게 프록시를 만들 수 있도록 Spring AOP를 제공한다. 원리는 Spring Context가 로딩되고 Bean들을 생성하고 Spring Container에 등록하기 전에 프록시를 생성한다. 그리고 프록시를 생성하고 나면 Container에 프록시로 된 빈을 등록한다. 이 과정은 스프링의 '빈 후처리기'를 통해 이루어진다. 참고로, 포인트컷에 부합되는 bean 들만 프록시 bean으로 등록한다.
한 target에 여러 프록시를 적용해야하는 상황이 있을 수도 있다. 이 때 Spring 에서는 한 target에 여러 프록시를 만들지 않고, 여러 advisor를 만들어서 효율적으로 AOP를 관리한다.
Spring AOP 예시
다음은 Spring AOP 사용 예시이다.
@Aspect
public class AspectDemo {
@Around("execution(* hello.proxy.app..*(..))") // pointcut 설정
public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
// advice 내용
}
}
포인트컷 시그니처를 사용한 AspectV2 예시
@Slf4j
@Aspect
public class AspectV2 {
//hello.aop.order 패키지와 하위 패키지
@Pointcut("execution(* hello.aop.order..*(..))")
private void allOrder(){} //pointcut signature
@Around("allOrder()") // 포인트컷 시그니처 사용
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[log] {}", joinPoint.getSignature()); //join point 시그니처
return joinPoint.proceed();
}
}
포인트컷 시그니처들을 조합해서 사용한 AspectV3 예시
@Slf4j
@Aspect
public class AspectV3 {
//hello.aop.order 패키지와 하위 패키지
@Pointcut("execution(* hello.aop.order..*(..))")
private void allOrder(){} //pointcut signature
//클래스 이름 패턴이 *Service
@Pointcut("execution(* *..*Service.*(..))")
private void allService(){}
@Around("allOrder()")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[log] {}", joinPoint.getSignature()); //join point 시그니처
return joinPoint.proceed();
}
//hello.aop.order 패키지와 하위 패키지 이면서 클래스 이름 패턴이 *Service
@Around("allOrder() && allService()") // 포인트컷 시그니처를 조합해서 사용 가능
public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
try {
log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
Object result = joinPoint.proceed();
log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
return result;
} catch (Exception e) {
log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
throw e;
} finally {
log.info("[리소스 릴리즈] {}", joinPoint.getSignature());
}
}
}
여러 어드바이스의 순서를 지정하는 방법은 어드바이스 별로 클래스를 생성해서 @Order를 사용해야 한다.
@Slf4j
public class AspectV5Order {
@Aspect
@Order(2)
public static class LogAspect {
@Around("hello.aop.order.aop.Pointcuts.allOrder()")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[log] {}", joinPoint.getSignature()); //join point 시그니처
return joinPoint.proceed();
}
}
@Aspect
@Order(1)
public static class TxAspect {
@Around("hello.aop.order.aop.Pointcuts.orderAndService()")
public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
try {
log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
Object result = joinPoint.proceed();
log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
return result;
} catch (Exception e) {
log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
throw e;
} finally {
log.info("[리소스 릴리즈] {}", joinPoint.getSignature());
}
}
}
}
어드바이스 종류
- @Before: 조인 포인트 실행전. @Around 와 다르게 작업흐름을 변경할 수 없다. 메서드 종료시 자동으로 다음 타겟 호출됨. 물론 예외 발생 시 다음 코드 호출 안됨.
- @AfterReturning: 메서드 실행이 정상적으로 반환될 때 실행. 실제 반환되는 타입과 매개변수 리턴 타입이 같아야 실행된다(부모 타입을 지정하면 자식 타입 인정됨). @Around와 달리 리턴되는 객체를 변경할 순 없다.
- @AfterThrowing: 메서드 실행이 예외를 던져서 종료될 때 실행. throw 되는 예외 객체와 같아야 실행된다(부모 타입을 지정하면 자식 타입 인정됨)
- @After: 메서드 실행이 종료되면 실행됨(finally와 유사), 정상 및 예외 반환 조건을 모두 처리. 주로 리소스 해제하는데 사용.
- @Around: 메서드 실행 전후에 작업을 수행한다. 가장 강력한 어드바이스. 전달값, 반환값, 예외 변환 가능. 트랜잭션 try
catchfinally 구문 처리 가능. proceed() 여러번 실행 가능.
* Around 외 여러 어드바이스가 제공되는 이유는 '좋은 설계는 제약이 있다' 이다. 제약은 개발자 실수를 방지한다. 코드를 간결하게 만들어 코드 파악을 쉽게 할 수 있으며, 간결하면 실수 확률을 낮추고, 디버깅하기가 빠르기 때문이다.
@Slf4j
@Aspect
public class AspectV6Advice {
@Around("hello.aop.order.aop.Pointcuts.orderAndService()")
public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
try {
//@Before
log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
Object result = joinPoint.proceed();
//@AfterReturning
log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
return result;
} catch (Exception e) {
//@AfterThrowing
log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
throw e;
} finally {
//@After
log.info("[리소스 릴리즈] {}", joinPoint.getSignature());
}
}
@Before("hello.aop.order.aop.Pointcuts.orderAndService()")
public void doBefore(JoinPoint joinPoint) {
log.info("[before] {}", joinPoint.getSignature());
}
@AfterReturning(value = "hello.aop.order.aop.Pointcuts.orderAndService()", returning = "result")
public void doReturn(JoinPoint joinPoint, Object result) {
log.info("[return] {} return={}", joinPoint.getSignature(), result);
}
@AfterThrowing(value = "hello.aop.order.aop.Pointcuts.orderAndService()", throwing = "ex")
public void doThrowing(JoinPoint joinPoint, Exception ex) {
log.info("[ex] {} message={}", ex);
}
@After(value = "hello.aop.order.aop.Pointcuts.orderAndService()")
public void doAfter(JoinPoint joinPoint) {
log.info("[after] {}", joinPoint.getSignature());
}
}
포인트컷 지시자
Pointcut Designator(PCD) 라고도 부름
- execution: 메소드 실행 조인포인트 매칭. 가장 많이 사용. 기능 복잡.
@Pointcut("execution(* hello.aop.order..**(..))")
- 접근제어자(생략), 반환타입(), 선언타입(hello.aop.order), 메서드이름(), 파라미터(..), 예외(없음)
- 패키지 매칭: .(정확한 해당 위치 패키지), ..(해당 위치의 패키지와 그 하위 패키지 포함)
- 파라미터 매칭
- (String): 정확한 String 타입 파라미터
- (): 파라미터 없음
- (*): 정확히 하나의 파라미터, 모든 타입 허용
- (*, *): 두 개의 파라미터, 모든 타입 허용
- (..): 파리미터 갯수 상관 없이, 모든 타입 허용
- (String, ..): 첫번쨰 파라미터 String 타입이고 이후는 0~N 개 무관한 파라미터, 모든 타입
within
: 특정 타입 내의 조인 포인트를 매칭한다.- 타입 매칭. 타입 내에 있는 모든 메서드에 조인포인트 자동 매칭. execution 에서 타입 부분 기능
@Pointcut("within(hello.aop.member.*Service)")
args
: 인자가 주어진 타입의 인스턴스인 조인포인트@Pointcut("args(Object, ..)")
execution
에서는 파라미터 타입이 정확하게 일치해야 하지만,args
를 사용해서 부모타입을 설정하면 자식타입까지 매칭가능하다. 실제 넘어온 파라미터 객체 인스터를 보고 판단하기 때문이다.- 파라미터로만 포인트컷을 설정하면, 너무 많은 bean들이 매칭이 된다. 가능한 다른 포인트컷 지시자와 함께 쓰는 것이 좋다.
@target
: 실행 객체의 클래스에 주어진 타입의 애노테이션이 있는 조인 포인트- 인스턴스의 모든 메서드를 조인포인트로 적용한다
- 상속구조일 떈, 부모의 메서드까지 매칭(즉 인스턴스 객체 정보를 다 사용)
@Pointcut("@target(hello.aop.common.annoation.LogTracer)")
@within
: 주어진 애노테이션이 있는 조인 포인트.- 상속구조일 땐, 해당 타입에만 있는 메서드만 매칭. 부모 메서드 매칭 x
@Pointcut("@within(hello.aop.common.annoation.LogTracer)")
@annotation
: 메서드가 주어진 애노테이션을 가지고 있는 조인 포인트를 매칭- 메서드 위에 달린 어노테이션을 대상으로 매칭
@Pointcut("@annoation(hello.aop.member.annoation.MethodAop)")
@args
: 전달된 실제 인수의 런타임 타입이 주어진 타입의 애노테이션을 갖는 조인 포인트- 파라미터에 달린 어노테이션을 대상으로 매칭
bean
: 스프링 전용 포인트컷 지시자, 빈의 이름으로 포인트컷을 지정한다.@Pointcut("bean(orderService)")
this
: 스프링 빈 객체(스프링 AOP 프록시)를 대상으로 하는 조인 포인트- 빈으로 등록된 프록시 객체를 대상으로 포인트컷 매칭
- 프록시 생성 방식 JDK 동적프록시와 CGLIB 방식에 따라 매칭이 안 되는 경우가 있음
- 프록시 객체를 보고 판단함. JDK 동적 프록시 방식에서
구체 클래스
를 대상으로 할 경우, AOP 적용 대상이 아니다. 프록시 객체에는 타깃인 구체클래스에 대한 정보가 없음. 반면 CGLIB로 생성된 프록시 객체는 프록시 대상인 구체 클래스를 '상속'해서 만들기 때문에 매칭이 됨.
target
: Target 객체(스프링 AOP 프록시가 가르키는 실제 대상)를 대상으로 하는 조인포인트- 실제 target 객체를 대상으로 포인트컷 매칭하기 떄문에 this 와 같이 프록시 방식에 따라 매칭이 달라지는 경우는 없음
- 부모 타입을 설정하면 자식 타입까지 매칭 될 수 있음.
참고
spring.aop.proxy-target-class=true
(Spring Boot 기본값) 은 CGLIB 로 프록시 생성.false
는 JDK 동적 프록시로 우선 생성(interface가 없을 시엔 CGLIB 사용).
AOP 사용 예제
@Slf4j
@Aspect
public class TraceAspect {
@Before("@annotation(hello.aop.exam.annotation.Trace)")
public void doTrace(JoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
log.info("[trace] {} args={}", joinPoint.getSignature(), args);
}
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Trace {
}
@Service
@RequiredArgsConstructor
public class ExamService {
private final ExamRepository examRepository;
@Trace
public void request(String itemId) {
examRepository.save(itemId);
}
}
@Slf4j
@Aspect
public class RetryAspect {
@Around("@annotation(retry)")
public Object doRetry(ProceedingJoinPoint joinPoint, Retry retry) throws Throwable {
log.info("[retry] {} retry={}", joinPoint.getSignature(), retry);
int maxRetry = retry.value();
Exception exceptionHolder = null;
for (int retryCount = 1; retryCount <= maxRetry; retryCount++) {
try {
log.info("[retry] try count={}/{}", retryCount, maxRetry);
return joinPoint.proceed();
} catch (Exception e) {
exceptionHolder = e;
}
}
throw exceptionHolder;
}
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retry {
int value() default 3;
}
@Service
@RequiredArgsConstructor
public class ExamService {
private final ExamRepository examRepository;
@Trace
public void request(String itemId) {
examRepository.save(itemId);
}
}
실무 주의사항
1. 클래스 내부 메서드 호출
클래스 내부에서 메서드 호출 시에는 인스턴스(this)의 메서드를 호출하기 때문에, AOP 가 적용되지 않는다.
내부 메서드 호출에서도 AOP를 적용하려면, 자기 자신을 지연로딩 또는 수정자(setter)로 인젝션 방법을 사용하든지 ObjectProvider
를 사용해서 프록시 빈을 사용해서 메서드를 호출하면 된다.
하지만 가장 좋은 방법은 설계를 개선하는 것이다. 호출대상을 새 클래스로 분리하는 것이다. 보통 AOP 를 적용 대상들은 public 으로 오픈되어 있는 경우가 대부분이다.
@Slf4j
@Component
public class CallServiceV0 {
public void external() {
log.info("call external");
internal(); //내부 메서드 호출(this.internal())
}
public void internal() {
log.info("call internal");
}
}
2. 타입 변환/의존관계 주입
JDK 동적프록시 설정으로 Spring AOP 를 사용 시, 타입 변환 문제를 만날 수 있다. 프록시 팩토리로부터 인터페이스가 아니라 구체 클래스로 타입캐스팅하면 에러가 발생한다. 왜냐하면 JDK 동적프록시로 구현한 프록시는 기존 구체 클래스와 아무 관계가 없기 때문에 타입캐스팅이 되지 않는다.
반면, CGLIB 방식은 프록시를 대상 구체 클래스를 상속해서 만들기 때문에 상속구조로 인해 구체 클래스(부모)로 타입캐스팅이 가능하다.
그래서 보통 CGLIB 방식(스프링부트 default)를 사용한다.
JDK 동적 프록시 방식으로 구현체를 Injection 하려고 하면, 에러가 뜨게 된다. 왜냐하면 해당 구현체 타입의 프록시를 찾을 수 없기 때문이다. 타입캐스팅이 안되는 이유와 같다.
@Slf4j
//@SpringBootTest(properties = {"spring.aop.proxy-target-class=false"}) //JDK 동적 프록시
//@SpringBootTest(properties = {"spring.aop.proxy-target-class=true"}) //CGLIB 프록시
@SpringBootTest
@Import(ProxyDIAspect.class)
public class ProxyDITest {
@Autowired
MemberService memberService;
@Autowired
MemberServiceImpl memberServiceImpl;
@Test
void go() {
log.info("memberService class={}", memberService.getClass());
log.info("memberServiceImpl class={}", memberServiceImpl.getClass());
memberServiceImpl.hello("hello");
}
}
JDK 동적 프록시를 사용하면 `MemberServiceImpl` 주입에 실패한다.
4. CGLIB
- 단순히 CGLIB 로 프록시를 사용하면 성능 상 문제가 있다.
- 첫째, 상속을 위해 기본 생성자가 반드시 필요하다. Java 에서는 자식 클래스 생성자에서 반드시 부모 생성자를 호출하도록 되어 있다. 첫줄에
super()
호출. - 둘째, 프록시를 생성하려면 총 2번의 생성자 호출 해야한다. 실제 target 객체를 생성할 때 한번, 프록시 객체를 생성할 때 부모 클래스 생성자 호출이 두번. 총 2번이 호출된다.
- 셋째,
final
키워드 클래스, 메서드 사용 불가.final
키워드로 선언된 것들은 상속을 하지 못하기 때문이다. CGLIB는 상속 기반 프록시이다.
5. 스프링 해결책
- 스프링에서는 CGLIB 효율을 높이기 위해 다음과 같은 수단을 사용한다
- target 클래스의 기본생성자 필수 문제는
objenesis
라이브러리를 사용해서 기본생성자 없이 객체 생성할 수 있도록 한다. - 생성자 2번 호출도
obenesis
사용해서 생성자를 총 한 번만 호출하도록 변경되었다. - 스프링 부트 2.0 부터는 CGLIB 방식이 기본값이다.
spring.aop.proxy-target-class=false
- final 은 AOP 대상에서는 크게 사용하지 않으므로 문제가 되지 않는다.
'공부노트 > 스프링' 카테고리의 다른 글
[JPA] Collection, 고아, 트랜잭션, Facade, OSIV (0) | 2022.08.06 |
---|---|
[JPA] N+1 문제 (0) | 2022.08.06 |
Spring AOP 적용 (0) | 2022.06.25 |
스프링 AOP (0) | 2022.06.19 |
프록시 패턴과 데코레이션 패턴 (0) | 2022.04.30 |