본문은 인프런의 [스프링 핵심 원리 - 고급편]를 수강하고 정리한 글입니다. 필요에 따라 생략/수정된 부분이 있을 수 있으며, 내용이 추후 변경될 수 있습니다.
스프링 핵심 원리 - 고급편
1. 쓰레드 로컬
2. 템플릿 메서드 패턴과 콜백 패턴
3. 프록시 패턴과 데코레이터 패턴
4. 동적 프록시 기술
5. 스프링이 지원하는 프록시
6. 빈 후처리기
7. @Aspect AOP
8. 스프링 AOP 개념
9. 스프링 AOP 구현
10. 포인트컷
11. 실무 주의사항
전 챕터를 통해 동적 프록시를 제공하는 두 가지 기술인 JDK 동적 프록시와 CGLIB를 살펴봤다. 이 기술들을 바탕으로 바로 동적 프록시를 사용하기엔 한계가 남아있다.
1) JDK 동적 프록시와 CGLIB를 동시에 사용하기 어렵다
- 인터페이스가 있을 땐 JDK 동적 프록시를 적용하고, 구체 클래스가 있을 땐 CGLIB를 적용해야 하는 문제가 있다
2) JDK 동적 프록시의 InvocationHandler, CGLIB의 MethodInterceptor를 중복으로 만들어서 관리해야 하기엔 너무 번거롭다
3) 특정 조건에 맞을 때 프록시 로직을 적용하는 기능을 공통으로 제공하기 어렵다
- 아래와 같이 임시 방편으로 해결할 수 있긴 하지만, 특정 메서드의 이름만 비교하므로 너무 한정적이다
public class LogTraceFilterHandler implements InvocationHandler {
private final String[] patterns; //패턴을 추가!!
// ...
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
//메서드 이름으로 패턴을 매칭하여 일치하지 않는 경우에는 pass
String methodName = method.getName();
if (!PatternMatchUtils.simpleMatch(patterns, methodName)) {
return method.invoke(target, args); // 부가기능을 수행하지 않고 원래 동작만 수행
}
//...
}
}
1. 프록시 팩토리
1) 프록시 팩토리 소개
위 문제는 프록시 팩토리(ProxyFactory)를 사용하면 해결할 수 있다. 프록시 팩토리는 스프링이 제공하는 기능으로서, 동적 프록시를 통합해서 추상화한 기술이다. 쉽게 말해 우리는 프록시 팩토리에 프록시 생성 요청만 하면 되고, JDK 동적 프록시와 CGLIB 중 어떤 기술을 쓸 것인지는 프록시 팩토리에서 내부적으로 결정한다(물론 설정을 통해 다른 기술을 사용할 수도 있다).
또한, JDK 동적 프록시의 InvocationHandler와 CGLIB의 MethodInterceptor를 중복으로 만들어야 하는 문제는 Advice로써 해결된다. 프록시 팩토리 내부의 InvocationHander나 MethodInterceptor는 Advice를 호출하므로 우리는 Advice만 작성해주면 된다. 결론적으로 프록시 생성은 프록시 팩토리로, 로직은 Advice에 넣으면 된다. 나머지는 스프링이 추상화해서 처리해준다.
이와 더불어 스프링은 Pointcut이라는 개념을 도입했는데, 이를 통해 특정 조건에 따라 프록시 로직을 손쉽게 적용할 수 있다. 이에 관련된 내용은 아래에서 살펴보자.
2) 프록시 팩토리 예제
Advice는 프록시에 적용하는 부가 기능 로직이다.
- InvocationHandler와 MethodInterceptor을 개념적으로 추상화한 것 → 기능적으로는 유사하다
- 프록시 팩토리를 사용하면 둘을 직접 구현하는 대신 Advice만 구현하면 된다
Advice를 만드는 여러 방법 중 대표적인 것은 MethodInterceptor를 구현하는 것이다.
package org.aopalliance.intercept;
public interface MethodInterceptor extends Interceptor {
Object invoke(MethodInvocation invocation) throws Throwable;
}
- MethodInvocation invocation
- 다음 메서드를 호출하는 방법, 현재 프록시 객체 인스턴스, 인수 정보, 메서드 정보 등이 다 들어가 있다
- 프록시 팩터리 생성 시 넣어준다
- CGLIB의 MethodInterceptor와 이름이 같지만 패키지가 다르므로 주의
- Inteceptor는 Advice를 상속한다
- 상속관계: MethodInterceptor -> Interceptor -> Advice
TimeAdvice - MethodInterceptor를 구현
@Slf4j
public class TimeAdvice implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
log.info("TimeProxy 실행");
long startTime = System.currentTimeMillis();
Object result = invocation.proceed(); // 메서드 호출
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("TimeProxy 종료 resultTime={}", resultTime);
return result;
}
}
- 부가 기능을 정의한다
- invocation.proceed()
- target을 찾아서 인수를 넘기고 메서드를 호출해준다. 그리고 그 결과를 받는다
- 앞서 언급했다시피 target은 이미 invocation 안에 있음
테스트 코드
@Test
@DisplayName("인터페이스가 있으면 JDK 동적 프록시 사용")
void interfaceProxy() {
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target); // 생성자에 프록시의 호출 대상(target)을 넘겨줌
proxyFactory.addAdvice(new TimeAdvice()); // Advice 추가
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
proxy.save(); // JDK 동적 프록시에 의해 프록시 생성
assertThat(AopUtils.isAopProxy(proxy)).isTrue();
assertThat(AopUtils.isJdkDynamicProxy(proxy)).isTrue();
assertThat(AopUtils.isCglibProxy(proxy)).isFalse();
}
@Test
@DisplayName("구체 클래스만 있으면 CGLIB 사용")
void concreteProxy() {
ConcreteService target = new ConcreteService();
ProxyFactory proxyFactory = new ProxyFactory(target); // 생성자에 프록시의 호출 대상(target)을 넘겨줌
// proxyFactory.setProxyTargetClass(true);
proxyFactory.addAdvice(new TimeAdvice()); // Advice 추가
ConcreteService proxy = (ConcreteService) proxyFactory.getProxy();
proxy.call(); // CGLIB에 의해 프록시 생성
assertThat(AopUtils.isAopProxy(proxy)).isTrue();
assertThat(AopUtils.isJdkDynamicProxy(proxy)).isFalse();
assertThat(AopUtils.isCglibProxy(proxy)).isTrue();
}
- 프록시 팩토리를 생성할 때엔 생성자에 프록시의 호출 대상(target)을 넘겨주고, 프록시 팩토리는 이 인스턴스 정보를 기반으로 프록시를 만들어낸다
- 해당 인스턴스가 인터페이스를 구현하고 있다면 JDK 동적 프록시에 의해 프록시가 생성되고, 구체 클래스만 존재한다면 CGLIB에 의해 프록시가 생성된다
- proxyTargetClass 옵션을 사용하면 인터페이스가 있어도 CGLIB를 사용한다
- 스프링 부트는 AOP를 적용할 떄 기본적으로 setProxyTargetClass=true를 설정해서 사용한다
- 따라서 인터페이스가 있어도 항상 CGLIB를 사용해서 구체 클래스를 기반으로 프록시를 생성한다
3) 정리
추상화된 프록시 팩토리 기술 덕분에 JDK 동적 프록시나 CGLIB와 같은 구체 기술에 의존하지 않고 편리하게 동적 프록시를 생성할 수 있게 됐다. 프록시의 부가 기능 로직도 마찬가지로 특정 기술에 종속적이지 않게 Advice 하나로 사용할 수 있다.
2. 포인트컷, 어드바이스, 어드바이저
1) 포인트컷, 어드바이스, 어드바이저 소개
다음으로는 스프링 AOP의 핵심개념인 포인트컷, 어드바이스, 어드바이저에 대해 알아보자.
1) 포인트컷 (Pointcut)
- 어디에 부가 기능을 적용할지 or 어디에 부가 기능을 적용하지 않을지 판단하는 필터링 로직이다
- 주로 클래스와 메서드 이름으로 필터링한다
- 이름 그대로 어떤 포인트(Point)에 기능을 적용할지 or 안 할지 잘라서(cut) 구분하는 것이다
2) 어드바이스 (Advice)
- 이전에 본 것처럼 프록시가 호출하는 부가기능이다
- 프록시 로직이라고 생각하면 된다
3) 어드바이저 (Advisor)
- 하나의 포인트컷과 하나의 어드바이스를 가지고 있는 것이다
- 어드바이저 = 포인트컷1 + 어드바이스1
정리하자면 부가 기능 로직을 적용할 때, 어디에 적용할지 선택하는 건 포인트컷이며 어떤 로직을 적용할지 선택하는 건 어드바이스이다. 어디에 어떤 로직을 적용할지 모두 알고 있는 것은 어드바이저이다.
- 조언(Advice)을 어디(Pointcut)에 할 것인가?
- 조언자(Advisor)는 어디(Pointcut)에 조언(Advice)을 해야할지 알고 있다
2) 포인트컷, 어드바이스, 어드바이저 예제
2-1) 어드바이스 예제
public class TimeAdvice implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
log.info("TimeProxy 실행");
long startTime = System.currentTimeMillis();
Object result = invocation.proceed();
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("TimeProxy 종료 resultTime={}", resultTime);
return result;
}
}
- 기존 Advice 사용
2-2) 어드바이저 예제
@Test
void advisorTest1() {
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(Pointcut.TRUE, new TimeAdvice()); // default Pointcut 사용
proxyFactory.addAdvisor(advisor); // Advisor 추가
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
proxy.save();
proxy.find();
}
- DefaultPointcutAdvisor
- Advisor 인터페이스의 가장 일반적인 구현체다
- 생성자를 통해 하나의 포인트컷과 하나의 어드바이스를 주입받는다
- Pointcut.TRUE
- 항상 true를 반환하는 포인트컷이다
- proxyFactory.addAdvisor(advisor)
- 프록시 팩토리를 사용할 때 어드바이저는 필수이다
- 이전에 사용했던 proxyFactory.addAdvice(new TImeAdvice())는 내부에서 DefaultPointcutAdvisor(Pointcut.TRUE, new TimeAdvice())가 생성되었고, 결과적으로 어드바이저가 추가된 것이다.
2-3) 포인트컷 예제
스프링이 제공하는 Pointcut 인터페이스
public interface Pointcut {
ClassFilter getClassFilter();
MethodMatcher getMethodMatcher();
}
public interface ClassFilter {
boolean matches(Class<?> clazz);
}
public interface MethodMatcher {
boolean matches(Method method, Class<?> targetClass);
//..
}
- 포인트컷은 크게 ClassFilter와 MethodMatcher 둘로 이루어진다
- ClassFilter는 클래스가 맞는지, MethodMatcher는 메서드가 맞는지 확인할 때 사용한다
- 둘다 true로 반환해야 어드바이스를 적용할 수 있다
MyPointcut - Pointcut을 구현
static class MyPointcut implements Pointcut {
@Override
public ClassFilter getClassFilter() {
return ClassFilter.TRUE;
}
@Override
public MethodMatcher getMethodMatcher() {
return new MyMethodMatcher();
}
}
static class MyMethodMatcher implements MethodMatcher {
private String matchName = "save"; // save 메서드만 부가 기능 적용
// isRuntime이 false면 호출된다
@Override
public boolean matches(Method method, Class<?> targetClass) {
boolean result = method.getName().equals(matchName);
log.info("포인트컷 호출 method={} targetClass={}", method.getName(), targetClass);
log.info("포인트컷 결과 result={}", result);
return result;
}
@Override
public boolean isRuntime() {
return false;
}
// isRuntime이 true면 호출된다
@Override
public boolean matches(Method method, Class<?> targetClass, Object... args) {
return false;
}
}
- MyMethodMatcher
- matches(): method, targetClass 정보가 매개변수로 넘어오므로 이를 이용하여 판단 로직을 작성할 수 있다
- isRuntime(): 이 값이 참이면 matches(... args) 메서드가 대신 호출된다
- false인 경우엔 클래스의 정적 정보만 사용하므로 스프링이 내부에서 캐싱을 사용하지만, true인 경우엔 동적으로 매개변수가 바뀌는 것을 가정하므로 캐싱을 사용하지 않는다
- matches(..args): 동적으로 넘어오는 매개변수를 판단 로직으로 사용가능
- 결과적으로 save() 메서드만 Advice의 부가 기능이 적용된다
참고로 스프링이 제공하는 포인트컷엔 여러가지가 있다. 가장 중요한 것은 AspectExpressionPointcut이다.
- NameMatchMethodPointcut: 메서드 이름을 기반으로 매칭. 내부에서 PatterMatchUtils를 사용(=패턴 매칭 사용 가능)
- JdkRegexpMethodPointcut: JDK 정규 표현식을 기반으로 포인트컷을 매칭
- TruePointcut: 항상 참을 반환
- AnnotationMatchingPointcut: 애너테이션으로 매칭한다
- *AspectJExpressionPointcut: aspectJ 표현식으로 매칭한다
- 실무에서는 거의 AspectJExpressionPointcut을 사용한다. 사용하기도 편리하고 기능이 가장 많기 때문이다.
하나의 프록시에 여러 어드바이저를 함께 적용할 수 있다. 프록시와 어드바이저를 각각 N개씩 생성한 후 프록시가 연쇄적으로 호출하는 방법도 있긴 하지만, 이는 비효율적이다. 아래는 프록시를 1개만 생성하고 어드바이저 N개를 등록하는 예를 보여준다.
@Test
@DisplayName("하나의 프록시, 여러 어드바이저")
void multiAdvisorTest2() {
//client -> proxy -> advisor2 -> advisor1 -> target
DefaultPointcutAdvisor advisor1 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice1());
DefaultPointcutAdvisor advisor2 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2());
//프록시1 생성
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory1 = new ProxyFactory(target);
proxyFactory1.addAdvisor(advisor2);
proxyFactory1.addAdvisor(advisor1);
ServiceInterface proxy = (ServiceInterface) proxyFactory1.getProxy();
//실행
proxy.save();
}
- 등록하는 순서대로 어드바이저가 호출된다
- 스프링은 AOP를 적용할 때 최적화를 통해 프록시는 하나만 만들고, 거기에 여러 어드바이저를 적용한다
- *하나의 target에 여러 AOP가 동시에 적용되어도 스프링의 AOP는 target마다 하나의 프록시만 생성한다
- *AOP를 적용할 때마다 프록시가 중첩해서 생성된다고 착각하면 안 된다!
3. 프록시 팩토리 적용
이제 애플리케이션에 프록시 팩터리를 적용해보자. 프록시 팩터리를 적용하기 위해선 Configuration 클래스 내에서 프록시를 빈으로 등록해줘야 한다.
LogTraceAdvice
public class LogTraceAdvice implements MethodInterceptor {
private final LogTrace logTrace;
public LogTraceAdvice(LogTrace logTrace) {
this.logTrace = logTrace;
}
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
TraceStatus status = null;
try {
Method method = invocation.getMethod();
String message = method.getDeclaringClass().getSimpleName() + "." +
method.getName() + "()";
status = logTrace.begin(message);
//로직 호출
Object result = invocation.proceed();
logTrace.end(status);
return result;
} catch (Exception e) {
logTrace.exception(status, e);
throw e;
}
}
}
ProxyFactoryConfigV1 - 인터페이스 기반 클래스에 적용
@Configuration
public class ProxyFactoryConfigV1 {
@Bean
public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
OrderControllerV1 orderController = new OrderControllerV1Impl(orderServiceV1(logTrace));
ProxyFactory factory = new ProxyFactory(orderController);
factory.addAdvisor(getAdvisor(logTrace));
OrderControllerV1 proxy = (OrderControllerV1) factory.getProxy();
return proxy;
}
// ...
// 어드바이저는 하나의 포인트컷과 하나의 어드바이스로 구성된다.
private Advisor getAdvisor(LogTrace logTrace) {
//pointcut
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedNames("request*", "order*", "save*");
//advice
LogTraceAdvice advice = new LogTraceAdvice(logTrace);
return new DefaultPointcutAdvisor(pointcut, advice);
}
}
- 인터페이스 기반의 target을 주입받아 프록시 팩토리를 생성한다
- 이때 프록시는 JDK 동적 프록시로 생성된다
ProxyFactoryConfigV2 - 구체클래스 기반 클래스에 적용
@Slf4j
@Configuration
public class ProxyFactoryConfigV2 {
@Bean
public OrderControllerV2 orderControllerV2(LogTrace logTrace) {
OrderControllerV2 orderController = new OrderControllerV2(orderServiceV2(logTrace));
ProxyFactory factory = new ProxyFactory(orderController);
factory.addAdvisor(getAdvisor(logTrace));
OrderControllerV2 proxy = (OrderControllerV2) factory.getProxy();
log.info("ProxyFactory proxy={}, target={}", proxy.getClass(), orderController.getClass());
return proxy;
}
// ...
private Advisor getAdvisor(LogTrace logTrace) {
//pointcut
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedNames("request*", "order*", "save*");
//advice
LogTraceAdvice advice = new LogTraceAdvice(logTrace);
return new DefaultPointcutAdvisor(pointcut, advice);
}
}
- 구체클래스 기반의 target을 주입받아 프록시 팩토리를 생성한다
- 이때 프록시는 CGLIB로 생성된다
'Spring > 스프링 핵심 원리 - 고급편' 카테고리의 다른 글
[스프링 핵심 원리 - 고급편] @Aspect AOP (0) | 2023.03.07 |
---|---|
[스프링 핵심 원리 - 고급편] 빈 후처리기 (0) | 2023.03.06 |
[스프링 핵심 원리 - 고급편] 동적 프록시 기술 (0) | 2023.02.21 |
[스프링 핵심 원리 - 고급편] 프록시 패턴과 데코레이터 패턴 (0) | 2023.02.17 |
[스프링 핵심 원리 - 고급편] 템플릿 메서드 패턴과 콜백 패턴 (0) | 2023.02.09 |