[강의] 김영한님의 스프링 핵심 원리 - 고급편 (스프링이 지원하는 프록시, 빈 후 처리기)

크리링·2023년 5월 14일
0
post-thumbnail

이전 문제점

  • 인터페이스가 있는 경우에는 JDK 동적 프록시를 적용하고, 그렇지 않은 경우에는 CGLIB를 적용
  • 두 기술을 함께 사용할 때 부가 기능을 제공하기 위해 JDK 동적 프록시가 제공하는 InvocationHandler 와 CGLIB가 제공하는 MethodInterceptor 를 각각 중복으로 만들어서 관리
  • 특정 조건에 맞을 때 프록시 로직을 적용하는 기능도 공통으로 제공



프록시 팩토리

스프링은 유사한 구체적인 기술들이 있을 때, 그것들을 통합해서 일관성 있게 접근할 수 있고, 더욱 편리하게 사용할 수 있는 추상화된 기술을 제공한다.
스프링은 동적 프록시를 통합해서 편리하게 만들어주는 프록시 팩토리(ProxyFactory)라는 기능을 제공한다.
=> 이 프록시 팩토리 하나로 편리하게 동적 프록시를 생성할 수 있다.




Advice 개념 적용

두 기술을 함께 사용할 때 부가 기능을 적용하기 위해 JDK 동적 프록시가 제공하는 InvocationHandler와 CGLIB가 제공하는 MethodInterceptor를 각각 중복으로 따로 만들 필요없이 Advice만 만들면 된다. InvocationHandlerMethodInterceptorAdvice를 호출하게 된다.




Advice 만들기

TimeAdvice

@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={}ms", resultTime);
 		return result;
 	}
}
  • Object result = invocation.proceed();
    • invocation.proceed()를 호출하면 target 클래스를 호출하고 그 결과를 받는다.
    • target 클래스의 정보는 MethodInvocation invocation 안에 모두 포함되어 있다.



ProxyFactoryTest

@Slf4j
public class ProxyFactoryTest {
 	@Test
 	@DisplayName("인터페이스가 있으면 JDK 동적 프록시 사용")
 	void interfaceProxy() {
 		ServiceInterface target = new ServiceImpl();
 		ProxyFactory proxyFactory = new ProxyFactory(target);
 		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();
 	}
}
  • new ProxyFactory(target) : 프록시 팩토리 생성할 때, 생성자에 프록시의 호출 대상을 함께 넘겨준다. 프록시 팩토리는 이 인스턴스의 정보를 기반으로 프록시를 만들어낸다. 인스턴스에 인터페이스가 있다면 JDK 동적 프록시를 기본으로 사용하고 인터페이스가 없고 구체 클래스만 있다면 CGLIB를 통해서 동적 프록시를 생성한다.
  • proxyFactory.addAdvice(new TimeAdvice()) : 프록시 팩토리를 통해서 만든 프록시가 사용할 부가 기능 로직을 설정한다. InvocationHandlerMethodInterceptor의 개념과 유사하다. 프록시가 제공하는 부가 기능 로직 Advice




실행 결과

ProxyFactoryTest - targetClass=class hello.proxy.common.service.ServiceImpl
ProxyFactoryTest - proxyClass=class com.sun.proxy.$Proxy13
TimeAdvice - TimeProxy 실행
ServiceImpl - save 호출
TimeAdvice - TimeProxy 종료 resultTime=1ms



정리

  • 프록시 팩토리의 서비스 추상화 덕분에 구체적인 CGLIB, JDK 동적 프록시 기술에 의존하지 않고, 매우 편리하게 동적 프록시를 생성할 수 있다.
  • 프록시의 부가 기능 로직도 특정 기술에 종속적이지 않게 Advice 하나로 편리하게 사용할 수 있다.






포인트컷, 어드바이스, 어드바이저

포인트컷(Pointcut) : 어디에 부가 기능을 적용할지, 어디에 부가 기능을 적용하지 않을지 판단하는 필터링 로직
어드바이스(Advice) : 프록시가 호출하는 부가 기능이다.
어드바이저(Advisor) : 하나의 포인트컷과 하나의 어드바이스를 가지고 있는것 (포인트컷1 + 어드바이스1)

  • Advice를 어디(Pointcut)에 할 것인가?
  • Advisor를 어디(Pointcut)에 조언(Advice)에 해야할지 알고 있다.



어드바이저

AdvisorTest

@Slf4j
public class AdvisorTest {
 	@Test
 	void advisorTest1() {
 		ServiceInterface target = new ServiceImpl();
 		ProxyFactory proxyFactory = new ProxyFactory(target);
 		DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(Pointcut.TRUE, new TimeAdvice());
 		proxyFactory.addAdvisor(advisor);
 		ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
        
 		proxy.save();
 		proxy.find();
 	}
}
  • new DefaultPointcutAdvisor : Advisor 인터페이스의 가장 일반적인 구현체. 생성자를 통해 하나의 포인트컷과 하나의 어드바이스를 넣어준다.
  • new TimeAdvice() : 앞서 개발한 TimeAdvice 어드바이스 제공
  • proxyFactory.addAdvisor(advisor) : 프록시 팩토리에 적용할 어드바이저를 지정한다.


실행 결과

#save() 호출
TimeAdvice - TimeProxy 실행
ServiceImpl - save 호출
TimeAdvice - TimeProxy 종료 resultTime=0ms

#find() 호출
TimeAdvice - TimeProxy 실행
ServiceImpl - find 호출
TimeAdvice - TimeProxy 종료 resultTime=1ms

주의

AOP 적용 수 만큼 프록시가 생성된다고 착각한다.
-> 스프링은 AOP를 적용할 때, 최적화를 진행해서 프록시는 하나만 만들고, 하나의 프록시에 여러 어드바이저를 적용한다.
하나의 target에 여러 AOP가 동시에 적용되어도, 스프링의 AOP는 target마다 하나의 프록시만 생성한다.



남은 문제

  • 너무 많은 설정
  • 컴포넌트 스캔






빈 후처리기

Bean이나 컴포넌트 스캔으로 스프링 빈을 등록하면, 스프링은 대상 객체를 생성하고 스프링 컨테이너 내부의 빈 저장소에 등록한다. 이후에는 스프링 컨테이너를 통해 등록한 스프링 빈을 조회해서 사용하면 된다.
스프링이 빈 저장소에 등록할 목적으로 생성한 객체를 빈 저장소에 등록하기 직전에 조작하고 싶다면 빈 후처리기를 사용하면 된다.



빈 후처리기의 기능

객체를 조작할 수도 있고, 다른 객체로 바꿔치기 하는 것도 가능하다.



빈 후처리기 과정

  1. 생성 : 스프링 빈 대상이 되는 객체를 생성한다. (@Bean, 컴포넌트 스캔 모두 포함)
  2. 전달 : 생성된 객체를 빈 저장소에 등록하기 직전에 빈 후처리기에 전달
  3. 후 처리 작업 : 빈 후처리기는 전달된 스프링 빈 객체를 조작하거나 다른 객체로 바꿔치기 할 수 있다.
  4. 등록 : 빈 후처리기는 빈을 반환한다. 전달된 빈을 그대로 반환하면 해당 빈이 등록되고, 바꿔치기 하면 다른 객체가 빈 저장소에 등록된다.



예제

BasicTest

public class BasicTest {
 	@Test
 	void basicConfig() {
 		ApplicationContext applicationContext = new AnnotationConfigApplicationContext(BasicConfig.class);
 		//A는 빈으로 등록된다.
 		A a = applicationContext.getBean("beanA", A.class);
 		a.helloA();
 		//B는 빈으로 등록되지 않는다.
 		Assertions.assertThrows(NoSuchBeanDefinitionException.class,
  () -> applicationContext.getBean(B.class));
 	}
    
 	@Slf4j
 	@Configuration
 	static class BasicConfig {
 		@Bean(name = "beanA")
 		public A a() {
 			return new A();
 		}
	}
    
    @Slf4j
 	static class A {
 		public void helloA() {
 			log.info("hello A");
 		}
 	}
    
 	@Slf4j
 	static class B {
 		public void helloB() {
 			log.info("hello B");
 		}
	}
}

BasicConfig.class - 등록

@Bean(name = "beanA")
public A a() {
 	return new A();
}

beanA라는 이름으로 A 객체를 스프링 빈으로 등록했다.

  • A a = applicationContext.getBean("beanA", A.class)
    beanA 라는 이름으로 A 타입의 스프링 빈을 찾을 수 있다.

  • applicationContext.getBean(B.class)
    B 타입의 객체는 스프링 빈으로 등록한 적이 없기 때문에 스프링 컨테이너에서 찾을 수 없다.



예제 - 바꿔치기

BeanPostProcessor 인터페이스 - 스프링 제공

public interface BeanPostProcessor {
	Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException
 	Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException
}
  • 빈 후처리기를 사용하려면 BeanPostProcessor 인터페이스를 구현하고, 스프링 빈으로 등록하면 된다.
  • postProcessBeforeInitialization : 객체 생성 이후에 @PostConstruct 같은 초기화가 발생하기 전에 호출되는 포스트 프로세서
  • postProcessAfterinitialization : 객체 생성 이후에 @PostConstruct 같은 초기화가 발생한 다음에 호출되는 포스트 프로세서



BeanPostProcessorTest

public class BeanPostProcessorTest {
   	@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));
 	}
    
 	@Slf4j
 	@Configuration
 	static class BeanPostProcessorConfig {
 		@Bean(name = "beanA")
 		public A a() {
 			return new A();
 		}
 		@Bean
 		public AToBPostProcessor helloPostProcessor() {
 			return new AToBPostProcessor();
 		}
 	}
    
 	@Slf4j
 	static class A {
 		public void helloA() {
 			log.info("hello A");
 		}
 	}
    
 	@Slf4j
 	static class B {
 		public void helloB() {
 			log.info("hello B");
 		}
 	}
    
 	@Slf4j
 	static class AToBPostProcessor implements BeanPostProcessor {
 		@Override
 		public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
 			log.info("beanName={} bean={}", beanName, bean);
 			if (bean instanceof A) {
 				return new B();
 			}
 			return bean;
 		}
 	}
}

AtoBPostProcessor

  • 빈 후처리기. 인터페이스인 BeanPostProcessor를 구현하고, 스프링 빈으로 등록하면 스프링 컨테이너가 빈 후처리기로 인식하고 동작한다.
  • 빈 후처리기는 A 객체를 새로운 B 객체로 바꿔치기 한다. 파라미터로 넘어오는 빈(bean) 객체가 A의 인스턴스이면 새로운 B 객체를 생성해서 반환한다. 여기서 A 대신에 반환된 값인 B가 스프링 컨테이너에 등록된다. 다음 실행결과를 보면 beanName=beanA, bean=A 객체의 인스턴스가 빈 후처리기에 넘어온 것을 확인


실행 결과

..AToBPostProcessor - beanName=beanA
bean=hello.proxy.postprocessor...A@21362712
..B - hello B
  • B b = applicationContext.getBean("beanA", B.class)
    실행 결과를 보면 최종적으로 "beanA" 라는 스프링 빈 이름에 A 객체 대신에 B 객체가 등록된 것을 확인할 수 있다. A 는 스프링 빈으로 등록조차 되지 않는다.



정리

빈 후처리기는 빈을 조작하고 변경할 수 있는 후킹 포인트
빈 객체를 프록시로 교체하는 것도 가능






스프링이 제공하는 빈 후처리기

build.gradle - 추가

implementation 'org.springframework.boot:spring-boot-starter-aop'

이 라이브러리를 추가하면 aspectweaver라는 aspectJ관련 라이브러리를 등록하고, 스프링 부트가 AOP 관련 클래스를 자동으로 스프링 빈에 등록한다.

자동 프록시 생성기 - AutoProxyCreator

  • 앞서 이야기한 스프링 부트 자동 설정으로 AnnotationAwareAspectAutoProxyCreator 라는 빈 후처리기가 스프링 빈에 자동으로 등록된다.
  • 자동으로 프록시를 생성해주는 빈 후처리기
  • 빈 후처리기는 스프링 빈으로 등록된 Advisor 들을 자동으로 찾아서 프록시가 필요한 곳에 자동으로 프록시를 적용해준다.
  • Advisor 안에는 PointcutAdvice가 이미 모두 포함되어 있다. 따라서 Advisor만 알고 있으면 그 안에 있는 Pointcut으로 어떤 스프링 빈에 프록시를 적용해야 할지 알 수 있다.

자동 프록시 생성기의 동작 과정

  1. 생성 : 스프링이 스프링 빈 대상이 되는 객체를 생성 (@Bean, 컴포넌트 스캔 모두 포함)
  2. 전달 : 생성된 객체를 빈 저장소에 등록하기 직전에 빈 후처리기에 전달
  3. 모든 Advisor 빈 조회 : 자동 프록시 생성기 - 빈 후처리기는 스프링 컨테이너에서 모든 Advisor를 조회
  4. 프록시 적용 대상 체크 : 앞서 조회한 Advisor에 포함되어 있는 포인트컷을 사용해서 해당 객체가 프록시 적용 대상인지 판단
  5. 프록시 생성 : 프록시 적용 대상이면 프록시 생성하 반환해서 프록시를 스프링 빈으로 등록, 대상이 아니라면 원본 객체를 반환해서 원본 객체를 스프링 빈으로 등록
  6. 빈 등록 : 반환된 객체는 스프링 빈으로 등록



생성된 프록시



적용

AutoProxyConfig

@Configuration
@Import({AppV1Config.class, AppV2Config.class})
public class AutoProxyConfig {
 	@Bean
 	public Advisor advisor1(LogTrace logTrace) {
 		NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
 		pointcut.setMappedNames("request*", "order*", "save*");
 		LogTraceAdvice advice = new LogTraceAdvice(logTrace);
 		//advisor = pointcut + advice
 		return new DefaultPointcutAdvisor(pointcut, advice);
 	}
}
  • Advisor1이라는 어드바이저 하나만 등록
  • 빈 후처리기는 이제 등록하지 않아도 된다.



@Import(AutoProxyConfig.class)
@SpringBootApplication(scanBasePackages = "hello.proxy.app")
public class ProxyApplication {
	 public static void main(String[] args) {
	 	SpringApplication.run(ProxyApplication.class, args);
	 }
	 @Bean
	 public LogTrace logTrace() {
	 	return new ThreadLocalLogTrace();
	 }
}



중요

포인트컷은 2가지에 사용된다.

1. 프록시 적용 여부 판단 - 생성 단계

  • 자동 프록시 생성기는 포인트컷을 사용해서 해당 빈이 프록시를 생성할 필요가 있는지 없는지 체크
  • 클래스 + 메서드 조건 모두 비교

2. 어드바이스 적용 여부 판단 - 사용 단계

  • 프록시가 호출되어쓸 때 부가 기능인 어드바이스를 적용할지 말지 포인트컷을 보고 판단






@Aspect AOP

스프링은 @Aspect 애노테이션으로 매우 편리하게 포인트컷과 어드바이스로 구성되어 있는 어드바이저 생성 기능을 지원한다.



적용

LogTraceAspect

@Slf4j
@Aspect
public class LogTraceAspect {
 	private final LogTrace logTrace;
    
 	public LogTraceAspect(LogTrace logTrace) {
 		this.logTrace = logTrace;
 	}
    
 	@Around("execution(* hello.proxy.app..*(..))")
 	public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
 		TraceStatus status = null;
    
		// log.info("target={}", joinPoint.getTarget()); //실제 호출 대상
		// log.info("getArgs={}", joinPoint.getArgs()); //전달인자
		// log.info("getSignature={}", joinPoint.getSignature()); //join point 시그니처
 		try {
 			String message = joinPoint.getSignature().toShortString();
 			status = logTrace.begin(message);
 			//로직 호출
 			Object result = joinPoint.proceed();
 			logTrace.end(status);
 			return result;
 		} catch (Exception e) {
 			logTrace.exception(status, e);
 			throw e;
 		}
 	}
}
  • @Aspect : 애노테이션 기반 프록시를 적용할 때 필요
  • @Around("execution(* hello.proxy.app..*(..))")
    • @Around의 값에 포인트컷 표현식을 넣는다.
    • @Around의 메서드는 어드바이스(Advice)가 된다.
  • ProceedingJoinPoint joinPoint : 어드바이스에서 살펴본 MethodInvocation invocation 과 유사한 기능이다. 내부에 실제 호출 대상, 전달 인자, 그리고 어떤 객체와 어떤 메서드가 호출되었는지 정보가 포함되어 있다.
  • joinPoint.proceed() : 실제 호출 대상( target )을 호출한다.



AopConfig

@Configuration
@Import({AppV1Config.class, AppV2Config.class})
public class AopConfig {
 	@Bean
 	public LogTraceAspect logTraceAspect(LogTrace logTrace) {
 		return new LogTraceAspect(logTrace);
 	}
}
  • @Import({AppV1Config.class, AppV2Config.class}) : V1, V2 애플리케이션은 수동으로 스프링 빈으로 등록해야 동작한다.
  • @Bean logTraceAspect() : @Aspect 가 있어도 스프링 빈으로 등록을 해줘야 한다. 물론 LogTraceAspect@Component 애노테이션을 붙여서 컴포넌트 스캔을 사용해서 스프링 빈으로 등록해도 된다.



Application

@Import(AopConfig.class)
@SpringBootApplication(scanBasePackages = "hello.proxy.app")
public class ProxyApplication {
 	public static void main(String[] args) {
 		SpringApplication.run(ProxyApplication.class, args);
 	}
    
 	@Bean
 	public LogTrace logTrace() {
 		return new ThreadLocalLogTrace();
 	}
}



@Aspect 프록시 - 설명

자동 프록시 생성기 - Advisor를 자동으로 찾아와서 필요한 곳에 프록시를 생성하고 적용

  • @Aspect를 찾아서 Advisor로 만들어준다.



정리

지금까지 우리가 진행한 애플리케이션 전반에 로그를 남기는 기능은 특정 기능 하나에 관심이 있는 기능이 아니다. 애플리케이션의 여러 기능들 사이에 걸쳐서 들어가는 관심사이다.
이것을 바로 횡단 관심사(cross-cutting concerns)라고 한다. 우리가 지금까지 진행한 방법이 이렇게 여러곳에 걸쳐 있는 횡단 관심사의 문제를 해결하는 방법이었다.

이제 횡단 관심사를 전문으로 해결하는 스프링 AOP에 대해 본격적으로 알아보자.

0개의 댓글