스프링의 프록시

프록시 패턴의 종류

간단하게 프록시 패턴을 어떻게 구현하는 지 간략하게 알아보자.

인터페이스 기반 프록시

인터페이스가 있고 그 인터페이스를 구현한 구현체가 있는 상황일 때 사용한다. 그 구현체의 프록시는 인터페이스를 구현하여 만든다. 프록시는 타겟으로 구현체를 멤버 변수로 가지며 퍼블릭 메서드를 수행할 때 타겟 객체의 메서드 호출과 함께 프록시 로직을 수행한다.

클라이언트 코드에서는 인터페이스를 통해 메시지를 던지므로 이때 메시지 수신 객체가 프록시던 원래 구현체건 신경쓰지 않는다.

구현체 기반 프록시

인터페이스가 없고 그냥 구현체만 있을 때 사용하는 방식이다. 프록시 클래스가 구현체의 클래스를 상속하도록 하고 멤버 변수로 타겟 구현체를 가진다. 구현체의 퍼블릭 메서드를 오버라이딩 하는데, 프록시 객체의 메서드가 호출 될 때 타겟 객체의 메서드 호출과 함께 프록시 로직도 같이 수행되도록 구현한다.

클라이언트 코드에서는 구현체의 타입을 통해 메시지를 던지므로 실제 구현체의 객체나 프록시의 객체 모두 해당 메시지를 처리할 수 있게 된다.

차이

구현체의 종류가 확장되지 않고, 인터페이스가 굳이 없는 상황이면 구현체 기반 프록시가 낫다.
하지만 구현체 기반 프록시는 구현체 클래스가 final이거나 확장하려는 메서드가 final이면 프록시를 적용할 수 없다.

JDK 동적 프록시

프록시 객체를 동적으로 만들어주는 기술이다. JDK 동적 프록시는 인터페이스 기반 프록시이기 때문에 인터페이스가 있어야 가능하다.

JDK 동적 프록시는 프록시에 적용할 로직을 InvocationHandler 인터페이스를 구현해서 작성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class InvocationHandlerImpl implements InvocationHandler {
private final Object target;

public InvocationHandlerImpl(final Object target) {
this.target = target;
}

@Override
public Object invoke(final Object proxy, final Method method, final Object[] args) throws Throwable {
// 프록시 로직,,,
System.out.println("proxy begin");
final Object result = method.invoke(target, args);
// 프록시 로직,,,
System.out.println("proxy end");
return result;
}
}

리플렉션을 사용해서 호출된 메서드에 프록시 로직을 추가해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
interface FooInterface {
void foo();
}

class FooImpl implements FooInterface {
@Override
public void foo() {
System.out.println("FOO~~~");
}
}

class JDKProxyTest {

@Test
void JDKProxyTest() {
FooInterface fooRef = new FooImpl();
InvocationHandler handler = new InvocationHandlerImpl(fooRef);
final FooInterface proxy =(FooInterface) Proxy.newProxyInstance(FooInterface.class.getClassLoader(), new Class[]{FooInterface.class},
handler);
proxy.foo();
}
}

인터페이스가 있는 상황에서 Proxy.newProxyInstance 메서드를 통해 ,어떤 클래스 로더에 프록시 클래스를 둘 것인지, 어떤 인터페이스를 구현해야 하는지, 어떤 핸들러(프록시 로직)을 적용할 것인지 정한다.

생성된 프록시는 메시지를 전달받으면 InvocationHandler 를 호출한다. 이때 프록시 로직과 본 객체의 메서드가 실행된다.

프록시 로직은 InvocationHandler 하나만 만들고 프록시를 적용해야 할 대상마다 재활용하면 된다.

하지만 이 방법은 타입 캐스팅을 해줘야 하고 인터페이스가 있어야 하는 단점이 있다.

CGLIB

바이트 코드를 조작해서 동적으로 클래스를 생성하는 기능.

JDK 동적 프록시의 InvocationHandler 처럼 MethodInterceptor 에 프록시 로직을 담아서 사용한다.

CGLIB은 상속을 통해 구현체에서 바로 프록시를 만들기 때문에 부모의 기본 생성자가 필요하고 final 클래스이면 안되며, final 메서드인 경우 프록시가 작동하지 않는다. (스프링의 ProxyFactory 에서 모두 해결한다)

상황에 따라 JDK 동적 프록시와 CGLIB을 활용한 프록시를 사용할 수 있다. 하지만 상황마다 일일히 관련 클래스를 구현해서 적용해야 되나? 이런 문제를 스프링의 ProxyFactory 가 해결할 수 있다.

스프링의 ProxyFactory 를 활용할 때 내부에서 CGLIB를 사용한다. 하지만 우리가 CGLIB를 직접 다루진 않는다.

ProxyFactory

스프링의 프록시 팩토리는 인터페이스 유무에 따라 JDK Proxy 혹은 CGLIB을 선택해서 프록시를 생성해준다.

프록시 팩토리는 InvocationHandlerMethodInterceptor 를 대신하는 Advice 를 도입한다. 어떤 방식으로 프록시를 만들던 Advice 를 호출하게 되도록 구현됐다.

AdviceInvocationHandlerAdviceMethodInterceptor 를 스프링에서 구현해서 이 객체들이 개발자가 구현한 Advice에게 프록시 로직 실행을 위임하고, Advice는 프록시 로직 수행 후 진짜 객체에게 메시지를 전달한다.

Advice 구현

org.aopalliance.intercept 패키지의 MethodInterceptor 를 구현하는 방법이 있다. (Advice를 상속한 인터페이스)

1
2
3
4
5
6
7
8
9
10
11
12
13
@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;
}
}

실행 예시

1
2
3
4
5
6
7
8
FooService target = new FooService();
ProxyFactory proxyFactory = new ProxyFactory(target);

// 만약 인터페이스가 있어도 CGLIB 방식 프록시를 쓰고 싶다면
proxyFactory.setProxyFactoryClass(true);

proxyFactory.addAdvice(new FooAdvice());
FooService proxy = (FooService) proxyFactory.getProxy();

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

포인트컷 : 어디에 적용할까? (대상 여부 필터)

어드바이스 : 어떤 내용을 적용할까? (프록시 로직)

어드바이저 : 포인트컷 + 어드바이스의 조합

즉 조언자(어드바이저)는 어디에(포인트컷) 조언을(어드바이스) 적용해야 할 지 알고 있다!

1
2
DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(Pointcut.TRUE, new TimeAdvice());
proxyFactory.addAdvisor(advisor);

ProxyFactory는 어드바이저를 받아서 프록시를 만든다. 어드바이스만 설정하면 항상 참으로 판단하도록 하는 포인트컷을 적용하여 어드바이저를 만든다.

ProxyFactory는 여러 어드바이스를 적용한 하나의 프록시 객체를 만들 수 있다. 여러 AOP 로직을 적용한다고 여러 프록시 객체가 만들어지는 게 아니다.

여러 종류의 포인트컷

PointCut 인터페이스는 크게 ClassFilterMethodFilter 인터페이스로 필터링한다. 클래스 이름 기준으로 하거나 메서드 이름 기준으로 필터링할 때 사용된다.

스프링에서는 이런 인터페이스 기반으로 여러 포인트컷을 제공한다.

  • NameMatchMethodPointcut : 메서드 이름을 기반으로 매칭한다. 내부에서는 PatternMatchUtils 를
    사용한다.
  • JdkRegexpMethodPointcut : JDK 정규 표현식을 기반으로 포인트컷을 매칭한다.
  • TruePointcut : 항상 참을 반환한다.
  • AnnotationMatchingPointcut : 애노테이션으로 매칭한다.
  • AspectJExpressionPointcut : aspectJ 표현식으로 매칭한다.

여기서 제일 중요한 건 마지막 AspectJ 기반 포인트컷이다. 실무에서 제일 많이 사용한다.

빈 후처리기

스프링의 도움으로 인터페이스 기반과 구현체 기반 신경쓰지 않고 어드바이저로 프록시를 적용할 수 있게 됐다. 하지만 여전히 ProxyFactory로 프록시 객체를 만들어서 빈 등록해줘야 한다. 그리고 컴포넌트 스캔으로 등록되는 빈은 이 방법을 쓸 수 없다.

이런 문제를 빈 후처리기가 해결한다.
빈 후처리기는 빈 객체가 생성되고 스프링 빈 저장소에 등록되기 전에 특정 작업을 실행할 수 있다. 이때 특정 작업은 등록될 객체를 조작하거나 심지어는 아예 다른 객체를 등록시켜버릴 수 있다. 즉 타겟 객체를 빈으로 생성해놓고 빈 후처리기를 통해 타겟 객체를 품고 있는 프록시 객체를 등록시켜버릴 수 있다는 의미이다.

빈 후처리기를 사용하려면 BeanPostProcessor 인터페이스를 구현하고 스프링 빈으로 등록하면 된다.

1
2
3
4
5
6
7
8
9
10
11
public interface BeanPostProcessor {
@Nullable
default Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
return bean;
}

@Nullable
default Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
return bean;
}
}

@PostConstruct 같이 초기화 전에 하거나 @PostConstruct 같이 초기화가 일어나고 나서 진행될 수 잇다.

반환되는 객체가 빈으로 등록된다.

@PostConstruct 를 구현할 때도 빈 후처리기가 활용된다. CommonAnnotationBeanPostProcessor 를 통해 해당 어노테이션이 붙은 메서드를 호출한다.

컴포넌트 스캔으로 빈 등록되는 객체를 조작하거나 바꾸기 어려웠는데, 빈 후처리기를 통해 조작할 수 있게됐다. 빈 후처리기를 통해 빈 객체를 프록시로 바꿔서 등록할수도 있다는 의미이다!!!! 즉 설정 파일을 통해서 프록시를 하지 않고 빈 후처리기로 바꿔치기하면 된다! 이제 개발자는 프록시 관련 걱정을 안해도 되겟구나~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class PackageLogTracePostProcessor implements BeanPostProcessor {

private final String basePackage;
private final Advisor advisor;

public PackageLogTracePostProcessor(final String basePackage, final Advisor advisor) {
this.basePackage = basePackage;
this.advisor = advisor;
}

@Override
public Object postProcessAfterInitialization(final Object bean, final String beanName) throws BeansException {
final String packageName = bean.getClass().getPackageName();
if (!packageName.startsWith(basePackage)) {
return bean;
}

final ProxyFactory proxyFactory = new ProxyFactory(bean);
proxyFactory.addAdvisor(advisor);

return proxyFactory.getProxy();
}
}

위 예시는 패키지 이름을 주입받아서 프록시 적용을 판단햇지만 advisor의 포인트컷으로 판단할 수 있다.

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

spring-boot-start-aop 라이브러리를 추가하고 @EnableAspectJAutoProxy 설정을 해주면(스프링 부트는 생략) AOP 관련 빈을 등록해준다.
이 과정에서 AnnotationAwareAspectJAutoProxyCreator라는 빈 후처리기가 스프링 빈으로 등록된다!

이 빈 후처리기는 스프링 빈으로 등록된 advisor들을 자동으로 찾아와서 프록시가 필요한 곳에 자동으로 프록시를 적용한다.(@Aspect 도 인식해서 프록시를 적용해서 AOP한다)

즉 모든 어드바이저를 가져와서 그 어드바이저 안에 있는 포인트컷을 통해 각 객체들이 프록시를 만들어야 되는지 확인하고 프록시를 만들어준다.
그러면 만들어진 프록시는 포인트컷에 해당하는 어드바이저들과 타겟 객체가 있겟구나! (여러 프록시를 만드는게 아니라 하나의 프록시에 여러 어드바이저를 가진다!!)

즉 스프링에서 제공하는 빈 후처리기가 프록시를 자동으로 등록해주는 과정은 다음과 같다.
빈 객체 생성 - 빈 후처리기에 전달 - 빈으로 등록된 모든 어드바이저 조회 - 포인트컷 필터링으로 프록시 객체 필요 여부 확인 - 프록시 생성 - 프록시를 빈 등록

이제 어드바이저만 잘 정의하면 프록시와 관련된 걱정을 하지 않고 추가 기능을 마음껏 구현할 수 있게 됐다.

Share