토비의 스프링 | 6장 AOP 발전과정 (학습)

주싱·2022년 10월 24일
1

토비의 스프링

목록 보기
21/30

토비의 스프링 6장 AOP를 읽고 배우고 학습한 것들을 정리합니다. 특별히 최신 AOP 기술이 등장하기 까지 스프링이 걸어온 집요한 문제 해결 과정에 집중해 봅니다.

1. 직접 만드는 프록시

용어

  • Proxy : a person authorized to act on behalf of another. (대리자)
  • 대리 : 대신해서 일을 처리하다.
  • 위임 : 어떤 일을 책임 지워 맡기다.

구현개념

  • 데코레이터 패턴이 적용된다.
  • 클라이언트는 인터페이스를 통해서만 핵심기능을 사용하게 하고, 부가기능 자신도 같은 인터페이스를 구현한 뒤에 자신이 그 사이에 끼어들어야 한다.
  • 프록시는 자신이 클라이언트가 사용하려는 실제 대상인 것처럼 위장해서 클라이언트의 요청을 받고 자신의 부가기능을 수행한 이후에 최종적으로 요청을 위임받아 처리하는 실제 타겟 오브젝트를 호출해 준다.
  • 핵심기능은 부가기능을 가진 클래스의 존재 자체를 모른다.
  • 프록시 역시 타겟을 인터페이스로 접근하기 때문에 자신이 최종 타깃으로 위임하는지, 아니면 다음 단계의 데코레이터 프록시로 위임하는지 알지 못한다.

소스 코드

Hello 인터페이스

public interface Hello {
    String sayHello(String name);
    String sayHi(String name);
    String sayThankYou(String name);
}

타깃 클래스

public class HelloTarget implements Hello {
    @Override
    public String sayHello(String name) {
        return "Hello " + name;
    }

    @Override
    public String sayHi(String name) {
        return "Hi " + name;
    }

    @Override
    public String sayThankYou(String name) {
        return "Thank You " + name;
    }
}

프록시 클래스

public class HelloUppercase implements Hello {
    private Hello hello;

    public HelloUppercase(Hello hello) {
        this.hello = hello;
    }

    @Override
    public String sayHello(String name) {
        return hello.sayHello(name).toUpperCase();
    }

    @Override
    public String sayHi(String name) {
        return hello.sayHi(name).toUpperCase();
    }

    @Override
    public String sayThankYou(String name) {
        return hello.sayThankYou(name).toUpperCase();
    }
}

클라이언트 역할의 테스트

@Test
public void manualProxy() {
    Hello hello = new HelloUppercase(new HelloTarget());
    Assertions.assertEquals("HELLO TOBY", hello.sayHello("Toby"));
    Assertions.assertEquals("HI TOBY", hello.sayHi("Toby"));
    Assertions.assertEquals("THANK YOU TOBY", hello.sayThankYou("Toby"));
}

해결한 문제

데코레이터 패턴은 타깃의 코드를 손대지 않고, 클라이언트가 호출하는 방법도 변경하지 않은 채로 새로운 기능을 추가할 수 있다.

새로운 문제

  • 타겟 인터페이스의 모든 메서드를 구현해 위임하는 코드를 만들어야 한다.
  • 부가기능을 수행하는 코드가 필요한 모든 메서드에 중복되어 나타난다.

문제 해결을 위한 질문

목 오브젝트를 만드는 불편함을 목 프레임워크를 사용해 편리하게 바꿧던 것처럼 프록시도 일일이 모든 인터페이스를 구현해서 클래스를 새로 정의하지 않고도 편리하게 만들어서 사용할 방법은 없을까?

2. JDK 다이나믹 프록시

구현개념

  • 다이나믹 프록시는 Java Reflection기술을 활용해 런타임 시 다이나믹하게 프록시 객체를 만들어 준다. 우리는 이제 프록시 팩토리에 타겟 인터페이스 정보만 제공하면 팩토리가 프록시를 런타임에 다이나믹하게 생성해 준다. 그래서 우리가 프록시를 위해 인터페이스를 일일이 구현할 필요가 없어졌다.
  • 부가기능은 InvocationHandler 인터페이스를 구현한 분릴된 객체에 구현된다. InvocationHandler 객체에는 변하지 않는 템플릿이 될 부가기능 코드가 구현되고, 변하는 타겟 메서드 호출은 리플렉션 Method 객체를 콜백으로서 전달 받아 실행한다. 이제 부가기능 코드가 프록시 메서드 마다 반복되는 중복이 제거되었다.

소스코드

독립적인 부가기능 (InvocationHandler)

public class UpperCaseHandler implements InvocationHandler {
    private final Object target;
		private final String pattern;

    public UpperCaseHandler(Object target, String pattern) {
        this.target = target;
				this.pattern = pattern;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        Object ret = method.invoke(target, args);
        if (ret instanceof String && method.getName().startsWith(pattern)) {
            return ((String) ret).toUpperCase();
        } else {
            return ret;
        }
    }
}

다이나믹 프록시와 InvocationHandler를 사용하는 테스트

@Test
public void dynamicProxy() {
		HelloTarget helloTarget = new HelloTarget();
    Hello hello = (Hello) Proxy.newProxyInstance(
            getClass().getClassLoader(),
            new Class[] { Hello.class }, // 프록시가 구현할 타겟 인터페이스 
            new UpperCaseHandler(helloTarget, "say") // 부가기능 구현
    );
    Assertions.assertEquals("HELLO TOBY", hello.sayHello("Toby"));
    Assertions.assertEquals("HI TOBY", hello.sayHi("Toby"));
    Assertions.assertEquals("THANK YOU TOBY", hello.sayThankYou("Toby"));
}

해결한 문제

  • Hello 인터페이스의 메서드가 3개가 아니라 30개로 늘어나면 HelloUppercase (Hello 인터페이스를 타겟 처럼 직접 구현한)처럼 직접 구현한 프록시는 매번 코드를 추가해 주어야 한다. 하지만 다이나믹 프록시를 생성해서 사용하는 코드에는 전혀 손댈 게 없다. 다이내믹 프록시가 만들어질 때 추가된 메서드가 자동으로 포함되고 UppercaseHandler.invoke() 메서드로의 연결을 수행하는 코드가 자동으로 만들어 질 것이고, 부가기능은 Invoke() 메서드에서 처리되기 때문이다.
  • 이제 부가기능을 담당하는 코드는 UppercaseHandler로 분리되었고, 다이나믹 프록시의 각 메서드에서 공유해서 사용된다.

새로운 문제

  • 다아나믹하게 생성된 프록시를 스프링 컨텍스트의 빈으로 등록하고 클라이언트는 DI 받아서 사용하게 할 수는 없을까?

3. 다이내믹 프록시를 위한 팩토리 빈

구현개념

  • 스프링은 지정된 클래스 이름을 가지고 리플렉션을 이용해서 해당 클래스의 객체를 만든다. 반면에 다이내믹 프록시는 다이나믹하게 생성됨으로 클래스에 대한 정보를 알 수가 없다.
Date now = (Date) Class.forName("java.util.Datte").newInstance()'
  • Proxy의 newProxyInstance() 메서드를 통해서만 생성이 가능한 다이나믹 프록시 객체는 일반적인 방법으로는 스프링의 빈으로 등록할 수 없다.
  • 그래서 스프링을 대신해서 프록시 객체 생성로직을 담당하도록 특별히 만들어진 팩토리 빈이란 것을 활용하게 된다.

소스코드

팩토리빈

@Setter
public class UppercaseProxyFactoryBean implements FactoryBean<Object> { // 범용적으로 사용하기 위해 Object 타입 지정
    private Object target;
    private Class<?> targetInterface; // 타겟과 타겟의 인터페이스를 함께 설정해야 한다.
    private String pattern; // 부가기능에서 필요로 하는 값에 의존한다.

    @Override
    public Object getObject() throws Exception {
        return Proxy.newProxyInstance(
                getClass().getClassLoader(),
                new Class[] { targetInterface },
                new UpperCaseHandler(target, pattern)); // 팩토리빈과 부가기능 객체가 결합된다.
    }

    @Override
    public Class<?> getObjectType() { return targetInterface; }
    @Override
    public boolean isSingleton() { return false; }
}

팩토리 빈 설정

<bean id="helloImpl" class="study.proxy.target.HelloImpl" />
<bean id="hello" class="study.proxy.factory.UppercaseProxyFactoryBean">
  <property name="target" ref="helloImpl" />
  <property name="pattern" value="say" />
  <property name="targetInterface" value="study.proxy.target.Hello" />
</bean>

팩토리 빈 테스트

public class FactoryBeanTest {
		@Autowired
		Hello hello;
		
		@Test
		public void setupFactoryBean() {
		    Assertions.assertEquals("HELLO TOBY", hello.sayHello("Toby"));
		    Assertions.assertEquals("HI TOBY", hello.sayHi("Toby"));
		    Assertions.assertEquals("THANK YOU TOBY", hello.sayThankYou("Toby"));
		}
}

해결한 문제

한 번 부가기능을 가진 프록시를 생성하는 팩토리 빈을 만들어두면 타깃의 타입에 상관없이 재사용할 수 있다.

새로운 문제

  • 한 번에 여러 개의 클래스에 공통적인 부가기능을 제공할 수 없다. 아래와 같이 팩토리 생성과 설정 코드가 반복된다.
<bean id="helloImpl" class="study.proxy.target.HelloImpl" />
<bean id="hello" class="study.proxy.factory.UppercaseProxyFactoryBean">
  <property name="target" ref="helloImpl" />
  <property name="pattern" value="say" />
  <property name="targetInterface" value="study.proxy.target.Hello" />
</bean>
<bean id="introduceImpl" class="study.proxy.target.IntroduceImpl" />
<bean id="introduce" class="study.proxy.factory.UppercaseProxyFactoryBean">
  <property name="target" ref="introduceImpl" />
  <property name="pattern" value="introduce" />
  <property name="targetInterface" value="study.proxy.target.Introduce" />
</bean>
  • 하나의 타깃에 여러 개의 부가기능을 적용할 수 없다.
  • 부가기능을 담당하는 핸들러가 팩토리 빈 개수만큼 만들어진다. 팩토리 구현에서 부가기능 핸들러를 직접 생성하고 있기 때문이다.

4. 스프링 ProxyFactoryBean

구현개념

  • ProxyFactoryBean은 부가기능을 담당하는 MethodInterceptor 객체를 외부에서 주입는 구조를 가짐으로 프록시 생성 기능 자체에만 집중한다.
  • 부가기능을 담당하는 MethodInterceptor 객체는 타겟과 메서드 정보를 추상화한 MethodInvocation 객체를 invoke() 메서드 파라미터로 받는다. 따라서 부가기능을 담당하는 객체는 타겟 정보를 더 이상 가지고 있을 필요가 없고 그래서 싱글톤 빈으로 등록하여 공유가 가능하다.
  • ProxyFactoryBean에는 여러개의 부가기능(MethodInterceptor)들을 추가할 수 있다.
  • ProxyFactoryBean은 타겟 객체를 입력받지만, 프록시를 생성할 타겟 객체의 인터페이스 정보를 입력받지 않는다. ProxyFactoryBean은 인터페이스 자동검출 기능을 사용해 구현된다. 타겟의 일부 인터페이스만 프록시를 적용하기 원한다면 인터페이스 정보를 직접 제공할 수도 있다.
  • 기존에 부가기능에 부가기능을 적용할 메서드를 선정하는 로직이 섞여 있었다. 이를 외부에서 주입 받도록 역시 분리했다.

소스코드

스프링의 ProxyFactoryBean을 이용한 다이나믹 프록시 테스트

@Test
public void springProxyFactoryBean() {
		// 스프링의 ProxyFactoryBean 생성
    ProxyFactoryBean factoryBean = new ProxyFactoryBean();
    
    // 팩토리빈에 타겟 설정
    factoryBean.setTarget(new HelloImpl());

    // 포인트컷과 어디바이스 생성과 설정 
    NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
    pointcut.setMappedName("sayH*");
    UppercaseAdvice advice = new UppercaseAdvice();
    
    // 어드바이저 팩토리에 추가 
    factoryBean.addAdvisor(new DefaultPointcutAdvisor(pointcut, advice));
    
    // 프록시 생성
    Hello hello = (Hello) factoryBean.getObject();

    Assertions.assertEquals("HELLO TOBY", hello.sayHello("Toby"));
    Assertions.assertEquals("HI TOBY", hello.sayHi("Toby"));
    Assertions.assertEquals("Thank You Toby", hello.sayThankYou("Toby"));
}

설정파일

<bean id="helloImpl" class="study.proxy.target.HelloImpl" />
<bean id="uppercaseAdvice" class="study.proxy.advice.UppercaseAdvice" />
<bean id="uppercasePointcut" class="org.springframework.aop.support.NameMatchMethodPointcut">
  <property name="mappedName" value="say" />
</bean>
<bean id="uppercaseAdvisor" class="org.springframework.aop.support.DefaultPointcutAdvisor">
  <property name="pointcut" ref="uppercasePointcut" />
  <property name="advice" ref="uppercaseAdvice" />
</bean>
<bean id="hello" class="org.springframework.aop.framework.ProxyFactoryBean">
  <property name="target" ref="helloImpl" />
  <property name="interceptorNames">
    <list>
      <value>uppercaseAdvisor</value>
    </list>
  </property>
</bean>

해결한 문제

  • 부가기능 마다 프록시 팩터리를 구현하지 않아도 되며, 스프링에서 변하지 않는 템플릿 부분인 ProxyFactoryBean을 공유해서 사용하고 변하는 부분인 Advice, Pointcut은 분리된 객체로 외부에서 주입받아 사용할 수 있게 되었다.
  • 부가기능과 메서드 선정 로직을 자체도 싱글톤 빈으로 등록하여 공유해서 사용 가능하다.

새로운 문제

부가기능 적용이 필요한 타겟 객체마다 거의 비슷한 내용의 ProxyFactoryBaen 빈 설정정보가 반복되고 있다.

문제 해결을 위한 질문

반복적인 프록시의 메서드 구현을 코드 자동생성 기법을 이용해 해결했다면 반복적인 ProxyFactoryBean 설정 문제는 설정 자동등록 기법으로 해결할 수 없을까? 일정한 타겟 빈의 목록을 제공하면 자동으로 각 타깃 빈에 대한 프록시를 만들어주는 방법이 있다면 ProxyFactoryBean 타입 빈 설정을 매번 추가해서 프록시를 만들어내는 수고를 덜 수 있을 것 같다.

5. 자동 프록시 생성 & AspectJ 포인트컷 표현식

구현개념

  • 스프링 컨텍스트 빈 후처리기인 DefaultAdvisorAutoProxyCreator를 사용한다.
  • DefaultAdvisorAutoProxyCreator는 설정된 빈 중에 Advisor 인터페이스를 구현한 빈을 찾고 해당 빈의 Pointcut 정보를 참조하여 프록시를 생성할 빈을 선정한다. 그리고 선정된 빈들이 생성된 이후에 Advice 빈을 사용하는 프록시 객체를 생성하여 기존 빈을 대체하여 준다.
  • AspectJ 포인트컷 표현식을 사용해 Pointcut 지정을 단순화한다.

소스코드

DefaultAdvisorAutoProxyCreator 통한 자동 프록시 생성 설정

<!--Target-->
<bean id="helloImpl" class="study.proxy.target.HelloImpl" />

<!--Advice (부가기능)-->
<bean id="uppercaseAdvice" class="study.proxy.advice.UppercaseAdvice" />

<!--Pointcut (메서드 선정 알고리즘)-->
<bean id="uppercasePointcut" class="org.springframework.aop.aspectj.AspectJExpressionPointcut">
  <property name="expression" value="execution(* *..*Impl.*(..))" />
</bean>

<!--자동 프록시 생성자가 참조하는 직접적인 정보-->
<bean id="uppercaseAdvisor" class="org.springframework.aop.support.DefaultPointcutAdvisor">
  <property name="advice" ref="uppercaseAdvice" />
  <property name="pointcut" ref="uppercasePointcut" />
</bean>

<!--자동 프록시 생성자-->
<bean class="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator" />

AOP 네임스페이스

구현개념

스프링에서는 AOP를 위해 기계적으로 적용하는 빈들(Advice, Pointcut, Advisor)을 간편한 방법으로 등록할 수 있다. 스프링은 AOP와 관련된 태그를 정의해둔 aop 스키마를 제공한다.

소스코드

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:aop="http://www.springframework.org/schema/aop"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
       http://www.springframework.org/schema/beans/spring-beans.xsd
       http://www.springframework.org/schema/aop
       http://www.springframework.org/schema/aop/spring-aop-3.0.xsd"
>
  <!--Target-->
  <bean id="helloImpl" class="study.proxy.target.HelloImpl" />

  <!--Advice (부가기능)-->
  <bean id="uppercaseAdvice" class="study.proxy.advice.UppercaseAdvice" />

  <!-- aop 네임스페이스 -->
  <aop:config>
    <aop:advisor advice-ref="uppercaseAdvice" pointcut="execution(* *..*Impl.*(..))" />
  </aop:config>
</beans>
profile
소프트웨어 엔지니어, 일상

0개의 댓글