이 글은 김영한님의 스프링 고급편 강의중 제목과 관련된 부분을 블로그장의 취향대로 요약한 것이며 강의 자료 및 출처는 가장 아래에서 확인할 수 있습니다.
1.리플렉션(reflection) 적용
단순하게 클래스 A,B가 있을때 프록시를 적용하고 싶다면 프록시 A클래스와 프록시 B클래스를 만들어서 적용하면 될 것이다. 그런데 이는 굉장히 불편하다. 프록시 객체를 동적으로 변경할 필요가 있다.
따라서 자바에서 지원하는 리플렉션(reflection)기술을 사용하여 공통으로 프록시 객체를 생성하도록 한다.
리플렉션은 클래스나 메서드의 메타정보를 사용해서 동적으로 호출하는 메서드를 변경할 수 있다.
@Test
void reflection() throws Exception{
Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");
Hello target = new Hello();
Method methodCallA = classHello.getMethod("callA");
dynamicCall(methodCallA, target);
Method methodCallB = classHello.getMethod("callB");
dynamicCall(methodCallB, target);
}
// 공통 로직1,2를 한번에 처리할 수 있는 통합된 공통 처리 로직
private void dynamicCall(Method method, Object target) throws Exception{
log.info("start");
Object result = method.invoke(target); // 메서드를 실행시킴
log.info("result={}", result);
}
위는 리플렉션 기술을 적용한 코드이다.
target과 method정보를 파라미터로 넘겨 메서드를 실행한다. 여기서 중요한 점은 callA와 callB메서드를 직접 호출하는 부분이 Method하나로 대체되면서 공통 로직을 만들 수 있게되었다.
2.동적 프록시(JDK Dynamic Proxy) 적용
이제 리플렉션 기술을 사용하여 자동으로 프록시 객체를 생성할것이다.
JDK 동적 프록시 기술을 사용하면 개발자가 직접 프록시 객체를 생성하지 않아도 된다.
적용하는 방법은 InvocationHandler 인터페이스를 구현하면된다.
@Slf4j
public class TimeInvocationHandler implements InvocationHandler {
private final Object target;
public TimeInvocationHandler(Object target){
this.target = target;
}
/**
* Object proxy : 프록시 자신
* Method method : 호출한 메서드
* Object[] args = 메서드를 호출할 때 전달한 인수
*/
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
log.info("TimeProxy 실행");
long startTime = System.currentTimeMillis();
Object result = method.invoke(target, args);
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("TimeProxy 종료 resultTime={}", resultTime);
return result;
}
}
위는 InvocationHandler 인터페이스를 구현한 예제이다.
클래스를 생성할때 target 대상 객체를 받도록한다.
@Test
void dynamicA(){
AInterface target = new AImpl();
TimeInvocationHandler handler = new TimeInvocationHandler(target); // 동적 프록시에 적용할 핸들러 로직
/**
* 동적 프록시는 java.lang.reflect.Proxy 를 통해서 실행할 수 있다.
* 클래스 로더 정보, 인터페이스, 핸들러 로직을 넣어주면 해당 인터페이스를 기반으로 동적 프록시를 생성하고 그 결과를 반환한다.
*/
AInterface proxy = (AInterface) Proxy.newProxyInstance(AInterface.class.getClassLoader(), new Class[]{AInterface.class},handler);
proxy.call();
log.info("targetClass={}", target.getClass());
log.info("proxyClass={}", proxy.getClass());
}
target 객체를 넣어 TimeInvocationHandler를 생성한뒤, Proxy.newProxyInstance()를 통하여 동적 프록시를 생성한다.
로그를 보면 알 수 있듯이, Proxy.newProxyInstance()로 생성된 프록시는 클래스 정보에 $Proxy를 가진다.
3. CGLIB 기술 적용
위 JDK동적 프록시는 사실 인터페이스가 필수이기 때문에 인터페이스에만 적용할 수 있다.
인터페이스가 없고 클래스만 있는 경우는 CGLIB라이브러리를 적용하여 동적 프록시를 적용해야 한다.
JDK동적 프록시 적용을 위해 InvocationHandler를 사용했듯이, CGLIB는 MethodInterceptor인터페이스를 구현하면 동적 프록시 적용이 가능하다.
@Slf4j
public class TimeMethodInterceptor implements MethodInterceptor {
private final Object target;
public TimeMethodInterceptor(Object target){
this.target = target;
}
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
log.info("TimeProxy 실행");
long startTime = System.currentTimeMillis();
Object result = proxy.invoke(target, args);
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("TimeProxy 종료 resultTime={}", resultTime);
return result;
}
}
JDK동적 프록시와 모습이 유사한 것을 확인할 수 있다.
4.프록시 팩토리(Proxy Factory) 적용
JDK동적 프록시에선 InvocationHandler를 구현하여 내부에 부가기능을 추가하였고, CGLIB는 MethodInterceptor를 구현하여 부가기능을 추가하였다. 그런데 이처럼 인터페이스에는 JDK 동적 프록시를 써야하고 없을땐 CGLIB를 써야한다면 중복으로 관리되어 불편할 것이다.
따라서 부가 기능을 적용하기 위해 이를 하나로 합치는 Advice라는 개념이 등장하고, 스프링은 프록시 팩토리(ProxyFactory)를 통해 이 Advice를 사용할 수 있도록한다. 프록시 팩토리는 자동으로 인터페이스가 있다면 JDK 동적 프록시를 사용하고 없다면 CGLIB를 사용한다.
프록시 팩토리를 사용하면 아래와 같이 Advice를 호출하는 전용 InvocationHandler와 MethodInterceptor를 내부에서 사용한다.
또한 프록시 팩토리는 특정 조건에 맞을 때 프록시 로직을 적용하는 기능도 제공하는데 Pointcut이라는 개념을 도입하여 이 문제를 해결한다.
먼저 Advice를 구현하는 방법은 아래와같이 MethodInterceptor인터페이스를 구현하면 된다.
(CGLIB를 구현할때도 MethodInterceptor를 구현하였는데 이름만 같고 서로 패키지가 다르다.)
public class TimeAdvice implements MethodInterceptor{
@Override
public Object invoke(MethodInvocation invocation) throws Throwable { // target 클래스의 정보는 MethodInvocation안에 모두 포함되어 있음
log.info("TimeProxy 실행");
long startTime = System.currentTimeMillis();
Object result = invocation.proceed();
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("TimeProxy 종료 resultTime={}ms", resultTime);
return result;
}
}
Advice를 생성하였다. 이제 프록시 팩토리에서 위 Advice를 실행하면 된다.
@Test
@DisplayName("인터페이스가 있으면 JDK 동적 프록시 사용")
void interfaceProxy(){
ServiceInterface target = new ServiceImpl();
/**
* 프록시 팩토리를 생성할 때, 생성자에 호출 대상을 함께 넘겨준다.
* 프록시 팩토리는 이 인스턴스 정보를 기반으로 프록시를 생성한다.
* 이 인스턴스에 인터페이스가 있다면 JDK 동적 프록시를 사용하고, 없고 구체 클래스만 있다면 CGLIB를 통해서 동적 프록시를 생성한다.
*/
ProxyFactory proxyFactory = new ProxyFactory(target);
/**
* 프록시 팩토리를 통해서 만든 프록시가 사용할 부가 기능 로직을 설정한다.
* 이와 같이 프록시가 제공하는 부가 기능 로직을 "어드바이스(Advice)"라 한다. 조언을 해준다고 생각하면 된다.
*/
proxyFactory.addAdvice(new TimeAdvice());
// 프록시 객체를 생성하고 그 결과를 받는다.
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
log.info("targetClass={}", target.getClass());
log.info("proxyClass={}", proxy.getClass());
proxy.save();
assertThat(AopUtils.isAopProxy(proxy)).isTrue();
assertThat(AopUtils.isJdkDynamicProxy(proxy)).isTrue();
assertThat(AopUtils.isCglibProxy(proxy)).isFalse();
}
주저리주저리 공부한다고 주석이 많다.
간단하게 요약하자면 프록시 팩토리를 생성할때 target을 지정해주고 앞에서 생성한 Advice를 추가한다.
target은 인터페이스이므로 JDK동적 프록시가 사용된다.
테스트가 문제없이 잘 실행되는 것을 확인할 수 있다.
5.빈 후처리기(BeanPostProcessor) 적용
지금까지 프록시 팩토리에 직접 Target을 지정하여 프록시를 적용하였다.
그런데 여러개의 Target이 있을 때, 예를 들어 인터페이스가 100개가 있다면 프록시를 통해 부가 기능을 적용할때 100개의 Target을 지정하는 코드가 필요할 것이다.
또한 컴포넌트 스캔을 사용하는 경우 수동으로 Config클래스에서 빈을 프록시로 등록하지 못할것이다.
따라서 이럴때 두 가지를 모두 해결하기 위해 스프링은 빈 후처리기(BeanPostProcessor)를 적용한다.
빈 후처리기를 적용하려면 BeanPostProcessor 인터페이스를 구현하고, 스프링 빈으로 등록하면 된다.
@Configuration
static class BeanPostProcessorConfig{
@Bean(name = "beanA") // 빈 이름 지정
public A a(){
return new A();
}
@Bean // 빈 후처리기를 빈으로 등록
public AToBPostProcessor helloPostProcessor(){
return new AToBPostProcessor();
}
}
static class A {
public void helloA() {
log.info("hello A");
}
}
static class B {
public void helloB() {
log.info("hello B");
}
}
간단하게 A,B 클래스가 있고 빈 후처리기를 적용하기 위해 BeanPostProcessor를 구현한 AToBPostProcessor()를 빈으로 등록하였다.
static class AToBPostProcessor implements BeanPostProcessor{
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if(bean instanceof A){
return new B();
}
return bean;
}
}
AToBPostProcessor 클래스는 타입이 A인 빈이 온다면 B로 바꿔치기 한다.
@Test
void postProcessor(){
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(BeanPostProcessorConfig.class);
//beanA 이름으로 B 객체가 빈으로 등록된다.
B b = applicationContext.getBean("beanA", B.class);
b.helloB();
//A는 빈으로 등록되지 않는다.
Assertions.assertThrows(NoSuchBeanDefinitionException.class, () -> applicationContext.getBean(A.class));
}
위 코드를 테스트해본다면 정상적으로 통과 되는것을 확인할 수 있다.
이제 100개의 Target 인터페이스를 실행할 동적 프록시 코드는 위 빈 후처리기 내에서 포인트컷을 지정하여 처리할 수 있다.
예를들어 컴포넌트 스캔을 쓰지않는 부분이 있다고 가정할때, 하나씩 모두 수동으로 Bean으로 등록할것이다.
@Configuration
public class AppConfig {
@Bean
public OrderControllerV1 orderControllerV1(){
return new OrderControllerV1Impl(orderServiceV1());
}
@Bean
public OrderServiceV1 orderServiceV1(){
return new OrderServiceV1Impl(orderRepositoryV1());
}
@Bean
public OrderRepositoryV1 orderRepositoryV1(){
return new OrderRepositoryV1Impl();
}
}
만약 빈 후처리기가 없고 프록시 팩토리만 적용한다고 했을때 아래와 같이 어드바이저(Advisor = Advice+Pointcut)를 직접 등록하여 하나씩 모두 중복으로 등록해줘야 할것이다.
@Configuration
public class ProxyFactoryConfig {
@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();
log.info("ProxyFactory proxy={}, target={}", proxy.getClass(), orderController.getClass());
return proxy;
}
@Bean
public OrderServiceV1 orderServiceV1(LogTrace logTrace){
OrderServiceV1 orderService = new OrderServiceV1Impl(orderRepositoryV1(logTrace));
ProxyFactory factory = new ProxyFactory(orderService);
factory.addAdvisor(getAdvisor(logTrace));
OrderServiceV1 proxy = (OrderServiceV1) factory.getProxy();
log.info("ProxyFactory proxy={}, target={}", proxy.getClass(), orderService.getClass());
return proxy;
}
...
}
빈 후처리기를 적용하면 아래와 같이 적용할 수 있다.
@Configuration
@Import({AppV1Config.class, AppV2Config.class})
public class BeanPostProcessorConfig {
@Bean
public PackageLogTraceProxyPostProcessor logTraceProxyPostProcessor(LogTrace logTrace){
// 패키지 명을 받도록하여 빈 후처리기 내에서 넘겨준 패키지에 속하지 않을경우 빈을 변경하지 않도록 설정
return new PackageLogTraceProxyPostProcessor("hello.proxy.app", getAdvisor(logTrace));
}
private Advisor getAdvisor(LogTrace logTrace){
//pointcut
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedNames("request*", "order*", "save*");
//advice
LogTraceAdvice advice = new LogTraceAdvice(logTrace);
//advisor = pointcut + advice
return new DefaultPointcutAdvisor(pointcut, advice);
}
}
기본 설정을 @Import로 불러와서, 포인트컷을 지정한 뒤 공통으로 처리할 수 있게되었다.
앞에서 언급했던 컴포넌트 스캔의 경우에도 빈 등록 전에 빈을 바꿔서 저장하기 때문에 컴포넌트 스캔을 활용할 수 있게되었다.
6. 스프링의 자동 프록시 생성기
스프링은 아래 의존성을 추가하면 AOP 관련 클래스를 자동으로 스프링 빈에 등록한다.
implementation 'org.springframework.boot:spring-boot-starter-aop'
위 의존성을 추가하면 @Aspect가 적용된 곳을 자동으로 인식해서 프록시를 만들고 AOP를 적용해준다.
자동으로 등록되는 빈 후처리기는 아래와 같은 구조를 가진다.
@Aspect 및 스프링 AOP는 다음 포스팅에서 제대로 설명한다.
*포인트컷(Pointcut)의 두 가지 역할
일일이 Target을 지정하여 프록시를 지정하는것이 아닌, 포인트 컷으로 처리하면 깔끔할 것 같다.
포인트 컷은 클래스,메서드 단위의 필터 기능을 가지고 있기 때문에 적용 대상 여부를 정밀하게 설정할 수 있다.
결과적으로 포인트 컷은 두 가지 역할을 한다.
- 프록시 적용 대상 여부를 체크해서 꼭 필요한 곳에만 프록시 적용 ( 빈 후처리기 - 자동 프록시 생성 )
- 프록시의 어떤 메서드가 호출 되었을 때 어드바이스를 적용할 지 판단 ( 프록시 내부 )
즉 포인트 컷은 빈 후처리기 내부에서도 사용하고, 빈이 생성되고 프록시 내부에서도 메서드를 걸러내는 두 가지 기능을 한다.
예를 들어본다면 hello.proxy.app 패키지 아래에 orderController가 있고 request()와 noLog()가 있다고 가정하고,포인트 컷의 표현식은 아래라고 가정해보자.
pointcut.setExpression("execution(* hello.proxy.app..*(..)) && !execution(* hello.proxy.app..noLog(..))");
- 생성 : 자동 프록시 생성기는 포인트컷을 사용하여 프록시 생성할 필요가 있는지 체크하는데 이때, 클래스와 메서드 조건을 모두 확인하여 조건에 맞는것이 하나라도 있으면 프록시를 생성한다. 위에서 패키지 아래는 모두 허용하는 첫번째 조건식으로 인해 프록시가 생성될 것이다.
- 사용 : 적용되어 있는 프록시에서 다시 포인트컷을 확인한다. 두번째 조건식에서 noLog()는 허용하지 않기때문에, 어드바이스를 호출하지 않고 바로 Target을 호출한다.
* 출처 자료
'Spring' 카테고리의 다른 글
Spring Boot 정리 1) 외부 설정과 프로필 (1) | 2024.08.12 |
---|---|
Spring Advanced 정리 3) 프록시 패턴과 데코레이터 패턴 (0) | 2024.03.26 |
Spring Advanced 정리 2) 템플릿 메서드 패턴과 템플릿 콜백 패턴 (0) | 2024.03.25 |
Spring Advanced 정리 1) 쓰레드 로컬 (0) | 2024.03.14 |
Spring DB 정리 4) 트랜잭션 전파 (2) | 2024.03.13 |