[Spring][AOP] 동적 프록시 기술

donghyeok·2023년 2월 23일
0

Spring

목록 보기
4/9

리플렉션

동적 프록시가 아닌 일반 프록시 사용시 단점은 대상 클래스 수만큼 프록시 클래스를 만들어야 한다는 점이다.
(상황에 따라 모양이 매우 비슷한 코드가 중복될 수도 있다.)

자바가 기본으로 제공하는 JDK 동적 프록시 기술이나 CGLIB 같은 프록시 생성 오픈소스 기술을 활용하면 프로시 객체를 동적으로 만들어낼 수 있다. 프록시를 적용할 코드를 하나만 만들어두고 동적 프록시 기술을 사용해서 프록시 객체를 찍어내면 된다.

동적 프록시를 이해하기 위해서는 먼저 자바의 리플렉션 기술을 이해해야 한다.
리플렉션 기술을 사용하면 클래스나 메서드의 메타 정보를 동적으로 획득하고, 코드도 동적으로 호출할 수 있다.

예제

다음은 Hello 클래스의 callA()메서드를 메서드명을 통해 실행시키는 방식이다.

@Sl4j
public static class Hello {
	public String callA() {
    	log.info("callA");
        return "A";
    }
}	

@Test 
void reflection() throws Exception {
	Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello"); //클래스 메타정보 획득 
    Hello target = new Hello();
    
    Method methodA = classHello.getMethod("callA");   //메서드 메타정보 획득
    dynamicCall(methodA, target);
}

private void dynamicCall(Method method, Object target) throws Exception {
	log.info("start");
    Object result = method.invoke(target); //실행 
    log.info("result={}", result);
}

주의점

리플렉션을 사용하면 클래스와 메서드의 메타정보를 사용해서 애플리케이션을 동적으로 유연하게 만들 수 있다.
하지만 리플렉션 기술은 런타임에 동작하기 때문에, 컴파일 시점에 오류를 잡을 수 없다.

따라서 리플렉션은 일반적으로 사용하면 안되고 프레임워크 개발이나 매우 일반적인 공통처리가 필요할 때 부분적으로 주의해서 사용해야 한다.

JDK 동적 프록시

앞서 설명했듯이 동적 프록시 기술을 사용하면 개발자가 직접 프록시 클래스를 만들지 않아도 된다.
JDK 동적 프록시는 인터페이스 기반으로 프록시 객체를 동적으로 런타임에 대신 만들어주고 원하는 실행 로직을 지정할 수 있다.

이는 프록시 클래스를 수 없이 만들어야 하는 문제도 해결하고, 부가 기능 로직도 하나의 클래스에 모아서 단일 책임 원칙(SRP)도 지킬 수 있게 된다.

예제

아래는 A구현체와 B구현체의 프록시를 동적으로 생성해주는 예제이다.
JDK 동적 프록시의 경우 인터페이스가 필수적이기 때문에 인터페이스를 따로 만들어 주었다.

public interface AInterface { String call(); }
public interface BInterface { String call(); }

@Sl4j
public class AImpl implements AInterface {
	@Override
    public String call() {
    	log.info("A 호출");
        return "A";
    }
}

@Sl4j 
public class BImpl implements BInterface { //A와 동일한 형태 }

TimeInvocationHandlerInvocationHandler 인터페이스를 구현한다.
이렇게 JDK 동적 프록시에 적용할 공통 로직을 개발할 수 있다.

@Sl4j 
public class TimeInvacationHandler implements InvocationHandler {
	private final Object target;    // 적용할 대상 클래스 
    
    public TimeInvocationHandler (Object target) { this.target = target; }
    
    @Override 
    public Object invoke(Object proxy, Method method, Object[] args) throws Exception {
    	//적용할 공통 로직 실행
        
        //대상 클래스 로직 실행 
        Object result = method.invoke(target, args);
        return result;
    }
}

아래는 위 설정으로 동적 프록시를 생성하여 각 구현체의 메서드를 실행해보는 예제이다.

@Test
void dynamicA() {
	AInterface target = new AImpl();
    //핸들러 생성
    TimeInvocationhandler handler = new TimeInvocationHandler(target);
    //프록시 생성
    AInterface proxy = (AInterface) Proxy.newProxyInstance(AInterface.class.getClassLoader(), 
    															new class[] {AInterface.class}, handler);
    proxy.call();
}

void dynamicB() {
	//위와 같은 로직
}

JDK 동적 프록시 동작 순서

  1. 클라이언트는 동적 프록시의 call()을 실행한다.
  2. JDK 동적 프록시는 InvocationHandler.invoke()를 호출한다.
  3. InvacationHandler가 내부 로직을 수행하고, mehod.invokde(target, args)를 호출해서 실제 객체 (AImpl)을 호출한다.
  4. AImpl 인스턴스의 call()이 실행된다.

CGLIB (Code Genrator Library)

CGLIB는 바이트코드를 조작해서 동적으로 클래스를 생성하는 기술을 제공하는 라이브러리이다.
CGLIB을 사용하면 인터페이스가 없어도(<-> JDK 동적 프록시) 구체 클래스만 가지고 동적 프록시를 만들 수 있다.
CGLIB는 원래 외부 라이브러리인데, 스프링 프레임워크가 내부 소스에 포함했다. 따라서 따로 외부 라이브러리를 추가하지 않아도 된다.

예제

아래에는 앞선 예제와 달리 구체 클래스를 대상으로 프록시를 생성한다.

@Sl4j
public class ConcreteService {
	public void call() {
    	log.info("ConcreteService 호출");
    }
}

JDK 동적 프록시에서 실행 로직을 위해 InvocationHandler를 제공했듯이, CGLIB에서는 MethodInterceptor를 제공한다.
TimeMethodInterceptorMethodInterceptor를 구현한다.
특이한 점은 실제 메서드를 호출할때 method.invoke()가 아닌 proxy.invoke()를 쓰는데, 성능상 이를 권장한다.

@Sl4j
public class TimeMethodInterceptor implements MethodInterceptor {
	private final Object target;
    
    public TimeMethodInterceptor (Object target) { this.target = target; }
    
    //obj : CGLIB가 적용된 객체, method : 호출 메서드, args : 인자, proxy : 메서드 호출에 사용 
    @Override 
    public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
    	//적용할 공통 로직 실행
        
        //대상 클래스 로직 실행 
        Object result = proxy.invoke(target, args);
        return result;
    }
}

아래는 위 설정으로 동적 프록시를 생성하여 구현체의 메서드를 실행하는 예제이다.

@Test
void cglib() {
	ConcreteService target = new ConcreteService();
    
    Enhancer enhancer = new Enhancer();                         //CGLIB는 enhancer로 프록시 생성
    enhancer.setSuperClass(ConcreteService.class);              //어떤 구체 클래스를 상속받을 것인지.
    enhancer.setCallback(new TimeMethodInterceptor(target));    //실행 로직 할당 
    ConcreteService proxy = (ConcreteService)enhancer.create(); //프록시 생성 
    
	proxy.call();
}

CGLIB 제약점

클래스 기반 프록시는 구현이 아닌 상속을 하기 때문에 다음과 같은 제약이 있다.

  • 부모 클래스의 생성자를 체크해야 한다. (기본 생성자가 필요하므로)
  • 클래스에 final 키워드가 붙으면 상속 불가능 하다 -> CGLIB 예외 발생
  • 메서드에 final 키워드가 붙으면 오버라이딩 불가능 -> CGLIB에서 프록시 로직 동작X

프록시 팩토리

앞서 JDK 동적 프록시와 CGLIB는 상황에 따라 쓰임새가 다르다. (인터페이스, 구현클래스)
그럼 범용성 있게 동적으로 선택해서 적용할 수 있는 방법은 없을까?

스프링은 이러한 문제를 해결하기 위해 프록시 팩토리라는 기능을 제공한다.
프록시 팩토리는 인터페이스가 있으면 JDK 동적 프록시를, 구현 클래스만 있다면 CGLIB를 사용한다.
그러면 JDK 동적 프록시의 InvocationHandler나 CGLIB의 MethodInterceptor는 둘 다 생성해야 할까?

스프링은 이런 문제 해결을 위해 Advice라는 개념을 도입했다.
개발자는 추가 로직을 위해 단순히 Advice 한 개만 생성하면 된다.
프록시 팩토리를 사용하면 Advice를 호출하는 전용 Invocationhandler, MethodInterceptor를 내부에서 사용한다.

또한 조건에 따라 프록시 로직을 적용하고 적용하지 않게 하기위해서
스프링은 Pointcut이라는 개념을 도입해서 일관성 있게 문제를 해결한다.

예제

//스프링이 제공하는 어드바이스 전용 인터페이스
public interface MethodInterceptor extends Interceptor {
	Object invoke(MethodInvocation) invocation) throws Throwable;
}

//사용자 어드바이스 
@Sl4j
public class TimeAdvice implements MethodInterceptor {
	@Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
    	//사용자 로직 수행
        //타겟 로직 수행
        Object result = invocation.proceed();
       	return result;
    } 
}

위 예제는 스프링이 제공하는 인터페이스를 상속한 어드바이스를 만드는 예제이다.
기존 JDK 동적 프록시, CGLIB를 사용할 때와는 다르게 target 클래스의 정보가 없는데 이는 프록시 팩토리로 생성하는 단계에서 이미 target 정보를 파라미터로 전달받기 때문이다.

@Test
void interfaceProxy() {
	ServiceInterface target = new ServiceImpl();                         //타겟 클래스 생성
    ProxyFactory proxyFactory = new ProxyFactory(target);				 //프록시 팩토리 생성
    proxyFactory.addAdvice(new TimeAdvice());							 //사용자 어드바이스 설정 
    ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy(); //프록시 생성
    proxy.save();														 //함수 실행 
}

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

위 단어들은 스프링 AOP에서는 빠질 수 없는 용어들이다. 각 용어의 뜻은 다음과 같다.

  • 포인트컷 (Pointcut) : 어디에 부가 가능을 적용할지를 판단하는 필터링 로직이다. 주로 클래스와 메서드 이름으로 필터링한다. 이름 그대로 어떤 포인트(point)에 기능을 적용할지 잘라서(cut) 구분하는 것이다.
  • 어드바이스 (Advice) : 프록시가 호출하는 부가 기능이다. 단순하게 프록시 로직이라고 생각하면 된다.
  • 어드바이저 (Advisor) : 단순하게 하나의 포인트컷과 하나의 어드바이스 묶음을 뜻한다.

역할과 책임

이렇게 구분하게 된 것은 역할과 책임을 명확하게 분리하기 위함이다.

  • 포인트컷은 대상 여부를 판별하는 필터이다.
  • 어드바이스는 부가 기능 로직만 담당한다.
  • 어드바이저는 포인트컷과 어드바이스로 구성된다.

예제 (포인트컷, 어드바이스 적용 어드바이저)

@Test
 void advisorTest3() {
      ServiceImpl target = new ServiceImpl();
      ProxyFactory proxyFactory = new ProxyFactory(target);
      NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
      pointcut.setMappedNames("save");
      DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(pointcut, new TimeAdvice());
      proxyFactory.addAdvisor(advisor);
      ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
      proxy.save();
      proxy.find();
  }

위처럼 어드바이스, 포인트컷을 적용한 어드바이저는 프록시 팩토리에 추가해주면 프록시는 해당 설정대로 프록시를 상황에 맞게 생성해 준다.

위의 예시에 나온 것포함하여 스프링에서는 다양한 포인트컷 구현체를 제공하고 있어 따로 복잡하게 구현하지 않아도 된다.
대표적인 포인트컷 구현체는 아래와 같다.

  • NameMatchMethodPointcut : 메서드 이름을 기반으로 매칭한다. 내부에서는 PatternMatchUtils 사용
  • JdkRegexMethodPointcut : JDK 정규 표현식 기반 포인트컷을 매칭한다.
  • TruePointcut : 항상 참을 반환
  • AnnotationMatchingPointcut : 어노테이션으로 매칭한다.
  • AspectJExpressionPointcur : aspectJ 표현식으로 매칭한다. (실무에서 가장 많이 사용!)

0개의 댓글