춤추는 개발자

[Spring] 다양한 관점에서 개발하는 스프링의 AOP - 2 본문

Developer's_til/스프링 프레임워크

[Spring] 다양한 관점에서 개발하는 스프링의 AOP - 2

Heon_9u 2021. 8. 23. 17:44
728x90
반응형

 

 

✅ 스프링의 프록시 팩토리 빈

 자바에는 JDK에서 제공하는 다이내믹 프록시 외에도 편리하게 프록시를 만들 수 있도록 지원해주는 다양한 기술이 존재한다. 그 중 스프링의 ProxyFactoryBean은 프록시를 생성해서 빈 오브젝트로 등록하게 해주는 팩토리 빈이다.

 

 기존에 만들었던 TxProxyFactoryBean과 달리, ProxyFactoryBean은 순수하게 프록시를 생성하는 작업만을 담당하고 프록시를 통해 제공해줄 부가기능은 별도의 빈에 둘 수 있다.

 

 또한, ProxyFactoryBean이 생성하는 프록시에서 사용할 부가기능은 MethodInterceptor 인터페이스를 구현해서 만든다. MethodInterceptor의 invoke() 메서드는 ProxyFactoryBean으로부터 타깃 오브젝트에 대한 정보까지 함께 제공받는다. 그래서 타깃이 다른 여러 프록시에서 함께 사용할 수 있고, 싱글톤 빈으로 등록 가능하다.

 

[ 어드바이스: 타깃이 필요없는 순수한 부가기능 ]

 어드바이스는 타깃 오브젝트에 종속되지 않는 순수한 부가기능을 담은 오브젝트다.

 

 MethodInvocation은 타깃 오브젝트의 메서드를 실행할 수 있는 기능이 있기 때문에 MethodInterceptor는 부가기능을 제공하는데만 집중할 수 있다. MethodInvocation은 일종의 콜백 오브젝트로, proceed() 메서드를 실행하면 타깃 오브젝트의 메서드를 내부적으로 실행해주는 기능이 있다. 그래서 MethodInvocation 구현 클래스는 일종의 공유 가능한 템플릿처럼 동작하는 것이다.

 

 즉, ProxyFactoryBean은 작은 단위의 템플릿/콜백 구조를 응용해서 적용했기 때문에 템플릿 역할을 하는 MethodInvocation을 싱글톤으로 두고 공유할 수 있다.

ProxyFactoryBean은 addAdvice()를 통해 여러 개의 MethodInterceptor를 추가할 수 있다. 그래서 아무리 많은 부가기능을 적용하더라도 ProxyFactoryBean 하나로 충분하다.

 

 MethodInterceptor처럼 타깃 오브젝트에 적용하는 부가기능을 담은 오브젝트를 스프링에서는 어드바이스라고 부른다. 그래서 addAdvice()라는 메서드로 ProxyFactoryBean에 MethodInterceptor를 추가하는 것이다.

 

 ProxyFactoryBean은 인터페이스 자동검출 기능을 사용해 타깃 오브젝트가 구현하고 있는 인터페이스 정보를 알아낸다. 그리고 알아낸 인터페이스를 모두 구현하는 프록시를 만들어준다. 또한, ProxyFactoryBean은 프록시를 작성하고 빈으로 등록해서 사용하는데 필요한 다양한 기능을 제공한다.

 

[ 포인트 컷: 부가기능 적용 대상 메서드 선정 방법 ]

 앞서 InvocationHandler를 직접 구현했을 때는 메서드의 이름을 가지고 부가기능을 적용할 대상 메서드를 선정하는 것이다. 하지만 MethodInterceptor 오브젝트는 여러 프록시가 공유해서 사용할 수 있고, 스프링의 싱글톤 빈으로 등록할 수 있었다. 그래서 특정 메서드를 선정하기위해 패턴을 넣어주는 것은 곤란하다.

 

 MethodInterceptor에는 재사용 가능한 순수한 부가기능 제공 코드만 남겨주는 것이다. 그리고 메서드를 선별하는 기능은 프록시로부터 다시 분리한다.

다이내믹 프록시를 이용한 방식

 

하지만, 이런 구조는 InvocationHandler가 타깃과 메서드 선정 알고리즘 코드에 의존하는 상태다. 만약 타깃이 다르고 메서드 선정 방식이 다른 경우, InvocationHandler 오브젝트를 여러 프록시가 공유할 수 없다. 한번 빈으로 구성된 InvocationHandler 오브젝트는 특정 타깃을 위한 프록시에 제한된다는 뜻이다.

 

 그래서 스프링의 ProxyFactoryBean 방식으로 확장이 용이한 부가기능메서드 선정 알고리즘을 활용할 것이다.

 위 구조의 차이점은 2가지가 있다.

 

  • 어드바이스: 부가기능을 제공하는 오브젝트
  • 포인트컷: 메서드 선정 알고리즘을 담은 오브젝트

 둘은 모두 프록시에 DI로 주입돼서 사용된다. 또한, 여러 프록시에서 공유가 가능하도록 만들어지기 때문에 스프링의 싱글톤 빈으로 등록이 가능하다.

 

 재사용 가능한 기능을 만들어두고 바뀌는 부분(콜백 오브젝트와 메서드 호출정보)만 외부에서 주입해서 이를 작업 흐름(부가기능 부여) 중에 사용하도록 하는 전형적인 템플릿/콜백 구조다. 어드바이스가 템플릿이 되고 타깃을 호출하는 기능을 갖고있는 MethodInvocation 오브젝트가 콜백이 되는 것이다.

 

 프록시로부터 어드바이스와 포인트 컷을 독립시키고 DI를 사용하는 것은 전략 패턴 구조다. 프록시와 ProxyFactoryBean 등의 변경 없이도 기능을 자유롭게 확장할 수 있는 OCP를 지키는 구조가 된 것이다. 앞에서 학습했던 Hello 클래스의 테스트 코드를 아래와 같이 변경할 수 있다.

@Test
void pointcutAdvisor() {
    ProxyFactoryBean proxyFactoryBean = new ProxyFactoryBean();
    proxyFactoryBean.setTarget(new HelloTarget());

    NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
    pointcut.setMappedName("sayH*");
    proxyFactoryBean.addAdvisor(new DefaultPointcutAdvisor(pointcut, new UppercaseAdvice()));

    Hello proxy = (Hello) proxyFactoryBean.getObject();
    assertThat(proxy.sayHello("hg"), is("HELLO HG"));
    assertThat(proxy.sayHi("hg"), is("HI HG"));
    assertThat(proxy.sayThankYou("hg"), is("THANK YOU HG"));
}

static class UppercaseAdvice implements MethodInterceptor {

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        String ret = (String) invocation.proceed();
        return ret.toUpperCase();
    }
}

 

 ProxyFactoryBean에는 여러 개의 어드바이스와 포인트컷이 추가될 수 있다. 그래서 어드바이스와 포인트컷을 Advisor 타입으로 묶어서 addAdvisor() 메서드를 호출해야 한다.

 

✅ 트랜잭션에 ProxyFactoryBean 적용

[ TransactionAdvice ]

 부가기능을 담당하는 어드바이스는 위에서 만들어본 것처럼 MethodInterceptor라는 Advice 서브인터페이스를 구현해서 만든다. 기존에 만들었던 TransactionHandler의 코드에서 타깃과 메서드 선정 부분을 제거하면 된다.

public class TransactionAdvice implements MethodInterceptor {

    private PlatformTransactionManager transactionManager;

    public void setTransactionManager(PlatformTransactionManager transactionManager) {
        this.transactionManager = transactionManager;
    }

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        TransactionStatus transaction = transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            Object result = invocation.proceed();
            transactionManager.commit(transaction);
            return result;
            
        } catch (RuntimeException runtimeException) {
            transactionManager.rollback(transaction);
            throw runtimeException;
        }
    }
}

 

[ 스프링 XML 설정파일 ]

마지막으로 위에서 테스트할 때 DI 했던 코드를 XML 설정으로 바꿔주면 된다.

 

  • TransactionAdvice를 등록하면서 TransactionManager를 DI 해준다.
  • 트랜잭션 적용 메서드 선정을 위한 포인트컷 빈을 등록한다. 메서드 이름 패턴은 upgrade로 시작하는 모든 메서드를 선택하도록 만든다.
  • 어드바이스와 포인트 컷을 담을 어드바이저를 빈으로 등록한다. 위에서는 생성자로 넣어줬지만 프로퍼티를 이용하여 DI한다.
  • 이제 ProxyFactoryBean을 타깃 빈과 어드바이저 빈을 지정하여 등록해주면 아래와 같은 설정 파일이 완성된다.
<bean id="transactionAdvice" class="spring.TransactionAdvice">
    <property name="transactionManager" ref="transactionManager"/>
</bean>

<bean id="transactionPointcut" class="org.springframework.aop.support.NameMatchMethodPointcut">
<property name="mappedName" value="upgrade*"/>
</bean>

<bean id="transactionAdvisor" class="org.springframework.aop.support.DefaultPointcutAdvisor">
    <property name="advice" ref="transactionAdvice"/>
    <property name="pointcut" ref="transactionPointcut"/>
</bean>

<bean id="userService" class="org.springframework.aop.framework.ProxyFactoryBean">
<property name="target" ref="userServiceImpl"/>
<property name="interceptorNames">
    <list>
        <value>transactionAdvisor</value>
    </list>
</property>
</bean>

 어드바이저의 프로퍼티 이름이 advisor가 아닌 interceptorNames인 이유는 어드바이스와 어드바이저를 혼합해서 설정할 수 있도록 하기 위해서다. 그래서 ref를 통한 설정 대신 list와 value 태그를 통해 여러 개의 값을 넣을 수 있도록 구성했다. value 태그에는 어드바이스 또는 어드바이저로 설정한 빈의 아이디를 넣으면 된다.

 

[ 테스트 ]

 TransactionHandler가 변경되었으니 테스트 코드도 변경해야 한다. 특히, 트랜잭션이 적용됐는지 확인하는 테스트 메서드인 upgradeAllOrNothing()은 ProxyFactoryBean을 통해 제공되는 트랜잭션 부가 기능에 대한 테스트를 진행해야 한다.

 

 수정사항은 TxProxyFactoryBean 대신 ProxyFactoryBean을 사용하는 것이다. 둘 다 같은 팩토리 빈이기 때문에 타입만 변경해주면 된다.

@Test
@DirtiesContext
public void upgradeAllOrNothing() throws Exception {
    TestUserService testUserService = new TestUserService(users.get(3).getId());
    testUserService.setUserDao(userDao);
    testUserService.setMailSender(mailSender);

    // 테스트용 타깃 주입
    ProxyFactoryBean txProxyFactoryBean = context.getBean("&userService", ProxyFactoryBean.class);
    txProxyFactoryBean.setTarget(testUserService);
    UserService txUserService = (UserService) txProxyFactoryBean.getObject();
	...
}

 

[ 어드바이스와 포인트 컷의 재사용 ]

ProxyFactoryBean은 스프링의 DI와 템플릿/콜백 패턴, 서비스 추상화 등의 기법이 모두 적용됐다. 이제 새로운 서비스 클래스가 만들어져도 TransactionAdvice를 그대로 재사용할 수 있다. 메서드 선정을 위한 포인트 컷이 필요하면 이름 패턴만 지정해서 ProxyFactoryBean에 등록해주기만 하면 된다.

 

 아래 구조는 ProxyFactoryBean으로 많은 수의 서비스 빈에게 트랜잭션 부가기능을 적용했을 때의 모습이다. 트랜잭션 부가기능을 담은 TransactionAdvice는 싱글톤 빈으로 등록해주면, DI설정으로 모든 서비스에 적용이 가능하다. 대신, 메서드 선정 방식이 달라지는 경우만 포인트 컷의 설정을 따로 등록하고 Advisor로 조합해서 적용하면 된다.

 

✅ 스프링의 AOP

 지금까지 해왔던 작업의 목표는 비즈니스 로직에 반복적으로 등장해야만 했던 트랜잭션 코드를 깔끔하고 효과적으로 분리해내는 것이다. 그 중에서 부가기능이 타깃 오브젝트마다 새로 만들어지는 문제는 스프링의 ProxyFactoryBean의 어드바이스를 통해 해결됐다.

 

 남은 것은 부가기능의 적용이 필요한 타깃 오브젝트마다 거의 비슷한 내용의 ProxyFactoryBean 빈 설정정보를 추가해주는 부분이다.

 

[ 중복 문제의 접근 방법 ]

 지금까지 반복적이고 기계적인 코드에 대해 템플릿과 콜백, 클라이언트로 나누는 방법인 전략패턴과 DI를 적용하여 해결했다.

 또한, 프록시 클래스 코드를 다이내믹 프록시라는 런타임 코드 자동생성 기법을 이용하여 작성했다. 변하지 않는 타깃으로의 위임과 부가기능 적용 여부 판단이라는 부분은 코드 생성 기법을 이용하는 다이내믹 프록시 기술에 맡기고, 변하는 부가기능 코드는 별도로 만들어서 다이내믹 프록시 생성 팩토리에 DI하는 방법을 사용한 것이다.

 그렇다면 반복적인 ProxyFactoryBean 설정이라는 문제를 해결할 수 있는 설정 자동등록 기법이 있는지 고민하게 된다.

 

[ 빈 후처리기를 이용한 자동 프록시 생성기 ]

 스프링은 컨테이너로서 제공하는 기능 중에서 변하지 않는 핵심적인 부분 외에는 대부분 확장할 수 있도록 확장 포인트를 제공해준다. 그 중에서 BeanPostProcessor 인터페이스를 구현해서 만든 빈 후처리기가 있다. 이는 스프링 빈 오브젝트로 만들어진 후에, 빈 오브젝트를 다시 가공할 수 있게 해준다.

 

 이러한 빈 후처리기 중 하나인 DefaultAdvisorAutoProxyCreator를 살펴보겠다. 어드바이저를 이용한 자동 프록시 생성기로 빈 후처리기 자체를 빈으로 등록하는 것이 가능하다.

 그러면 빈 오브젝트가 생성될 때마다 빈 후처리기에 보내서 후처리 작업을 요청한다. 빈 후처리기는 빈 오브젝트의 프로퍼티를 강제로 수정할 수 있고, 별도의 초기화 작업을 수행할 수 있다. 또는 이미 만들어진 빈 오브젝트 자체를 바꿔치기 할 수 도 있다. 따라서 스프링이 설정을 참고해서 만든 오브젝트가 아닌 다른 오브젝트를 빈으로 등록시키는 것이 가능하다.

 

DefaultAdvisorAutoProxyCreator 빈 후처리기가 등록되어 있으면 빈 오브젝트를 만들 때마다 후처리기에게 빈을 보낸다. DefaultAdvisorAutoProxyCreator를 이용한 자동 프록시 생성 방법은 다음과 같다.

 

  • DefaultAdvisorAutoProxyCreator는 빈으로 등록된 모든 어드바이저 내의 포인트 컷을 이용해 전달받은 빈이 프록시 적용 대상인지 확인한다.
  • 프록시 적용 대상이라면 내장된 프록시 생성기에게 현재 빈에 대한 프록시를 만들게 하고, 만들어진 프록시에 어드바이저를 연결해준다.
  • 빈 후처리기는 프록시가 생성되면 원래 컨테이너가 전달해준 빈 오브젝트 대신 프록시 오브젝트를 컨테이너에게 돌려준다.
  • 최종적으로 컨테이터는 빈 후처리기가 돌려준 오브젝트를 빈으로 등록하고 사용한다.

 

[ 확장된 포인트 컷 ]

 실제로 포인트컷은 클래스 필터메서드 매쳐 두 가지를 돌려주는 메서드를 갖고 있다.

public interface Pointcut {
    ClassFilter getClassFilter(); // 프록시를 적용할 클래스인지 확인.
    MethodMatcher getMethodMatcher(); // 어드바이스를 적용할 메서드인지 확인.
}

 지금까지는 사용한 NameMatchMethodPointcut은 메서드 선별 기능만 가진 포인트 컷이다. 포인트컷의 2가지 메서드를 모두 사용한다면, 먼저 프록시를 적용한 클래스인지 판단하고, 어드바이스를 적용할 메서드인지 확인하는 방식으로 동작한다.

 

모든 빈에 대해 프록시 자동 적용 대상을 선별해야 하는 빈 후처리기인 DefaultAdvisorAutoProxyCreator는 클래스와 메서드 선정 알고리즘을 모두 갖고 있는 포인트컷이 필요하다. 즉, 포인트컷과 어드바이스 결합된 어드바이저 등록되어 있어야 한다.

 

[ DefaultAdvisorAutoProxyCreator의 적용 ]

 기존 트랜잭션 코드에 메서드 이름만 비교하던 포인트컷인 NameMatchMethodPointcut을 상속해서 프로퍼티로 주어진 이름 패턴을 가지고, 클래스 이름을 비교하는 ClassFilter를 추가하도록 만들 것이다.

public class NameMatchClassMethodPointcut extends NameMatchMethodPointcut {

    public void setMappedClassName(String mappedClassName) {
        setClassFilter(new SimpleClassFilter(mappedClassName));
    }

    static class SimpleClassFilter implements ClassFilter {

        private final String mappedName;

        public SimpleClassFilter(String mappedName) {
            this.mappedName = mappedName;
        }

        @Override
        public boolean matches(Class<?> clazz) {
            // 와일드카드(*)가 들어간 문자열 비교를 지원하는 스프링의 유틸리티 메서드
            return PatternMatchUtils.simpleMatch(mappedName, clazz.getSimpleName());
        }
    }
}

 

 

[ 빈 등록 수정하기 ]

 DefaultAdvisorAutoProxyCreator를 이용하면 원래 타깃 빈에 의존한다고 정의된 빈들은 프록시 오브젝트를 대신 DI 받게 될 것이다. DefaultAdvisorAutoProxyCreator 등록은 다음 한 줄이면 충분하다. 다른 빈에서 참조되거나 코드에서 빈 이름으로 조회될 필요가 없기 때문에 id는 등록하지 않았다.

<bean class="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator"/>

 

 또한, 새로 만든 클래스 필터 지원 포인트컷을 빈으로 등록하며 기존에 포인트컷 설정은 삭제한다. ServiceImpl로 끝나는 클래스와 upgrade로 시작하는 메서드를 선정해주는 포인트컷이다.

<bean id="transactionPointcut" class="spring.NameMatchClassMethodPointcut">
    <property name="mappedClassName" value="*ServiceImpl"/>
    <property name="mappedName" value="upgrade*"/>
</bean>

 

어드바이스인 transactionAdvice 빈의 설정은 수정할 게 없다. 하지만 어드바이로서 사용되는 방법이 바뀌었다는 사실은 기억해야 한다. 어드바이저를 이용하는 자동 프록시 생성기인 DefaultAdvisorAutoProxyCreator에 의해 자동 수집되고, 프록시 대상 선정 과정에 참여하며, 자동생성된 프록시에 다이내믹하게 DI돼서 동작하는 어드바이저가 된다.

 

 이제 명시적인 프록시 팩토리 빈을 등록하지 않기 때문에 UserServiceImpl 빈의 아이디를 userService로 돌려놓을 수 있다. 마지막으로 ProxyFactoryBean 타입의 빈은 삭제해도 된다.

<bean id="userService" class="spring.service.UserServiceImpl">
    <property name="UserDao" ref="userDao"/>
    <property name="mailSender" ref="mailSender"/>
</bean>

 

✅ 포인트컷 표현식을 이용한 포인트컷

 지금까지 작성한 포인트컷은 일일이 클래스 필터와 메소드 매처를 구현하거나, 스프링이 제공하는 필터나 매처 클래스를 가져와 프로퍼티를 설정하는 방식이였다. 또한, 리플렉션 API를 통해 다양한 정보를 확인할 수 있지만, 코드 작성이 제법 번거롭다는 단점이 있다.

 

[ 포인트컷 표현식 ]

 AspectJExpressionPointcut은 클래스와 메서드 선정 알고리즘을 포인트컷 표현식을 이용해 한번에 지정할 수 있게 해준다. 이는 AspectJ라는 프레임워크에서 제공하는 것을 가져와 일부 문법을 확장해서 사용하는 것으로 AspectJ 포인트컷 표현식이라고도 불린다.

 

[ 포인트컷 표현식 적용하기 ]

포인트컷 표현식은 execution() 외에도 몇 가지 표현식 스타일을 갖고 있다. 대표적으로 스프링에서 사용될 때 빈의 이름으로 비교하는 bean()이 있다. bean(*Service)라고 쓰면 아이디가 Service로 끝나는 모든 빈을 선택한다. 또는 특정 어노테이션이 타입, 메서드, 파라미터에 적용되어 있는 것에 따라 포인트컷을 만들 수 있다.

@annotation(org.springframework.transaction.annotation.Transactional)

 이렇게 작성한다면 @Transactional이라는 어노테이션이 적용된 메서드를 선정하게 해준다.

 

이제 트랜잭션 기능에 적용해볼 차례다. 기존 포인트컷과 동일한 기준으로 메서드를 선정하는 알고리즘을 가진 포인트컷 표현식을 만들어본다.

<bean id="transactionPointcut" class="org.springframework.aop.aspectj.AspectJExpressionPointcut">
    <property name="expression" value="execution(* *..*ServiceImpl.upgrade*(..))"/>
</bean>

 이렇게 하면 기존 2개의 프로퍼티에 비해 코드와 설정이 단순해진다. 하지만, 문자열로 된 표현식이므로 런타임 시점까지 문법의 검증이나 기능 확인이 어렵다는 단점이 있다.

 

[ 타입 패턴과 클래스 패턴 ]

 기존에는 클래스 이름의 패턴을 이용해 타깃 빈을 선정하는 포인트컷을 사용했다. 해당 포스트에서는 테스트 코드를 작성하지 않았지만 테스트용 클래의 이름을 UserServiceImpl을 상속한 TestUserServiceImpl로 정의했다. 만약, TestUserServiceImpl을 TestUserService로 바꾸면 어떻게 될까? 테스트는 정상적으로 진행된다.

 

 왜냐하면 포인트컷 표현식의 클래스 이름에 적용된 패턴은 타입 패턴이기 때문이다. TestUserService의 클래스 이름은 타입을 따져보면, TestUserService 클래스이자, UserServiceImpl이라는 메인 클래스, UserService라는 구현 인터페이스 3가지 모두 적용된다. 즉, 타입 패턴에 따라 클래스 이름을 변경해도 테스트는 정상적으로 진행된다는 것이다.

 

🙋‍♂️ 느낀점

 이번에는 앞선 포스팅에서 발생했던 문제점 2가지를 어드바이스와 포인트컷으로 구성하여 ProxyFactoryBean빈 후처리기, 포인트컷의 표현식으로 해결했다. 해당 과정에서는 테스트 코드를 효율적으로 작성하고자 빈 설정정보를 수정하는 부분이 인상적이었다.

 

 정말 테스트에 진심을 다하는 것처럼 느껴졌고, 그만큼 테스트의 중요성을 느낄 수 있었다. 특히, 빈 후처리기를 사용하며 내부 동작방식을 테스트하는 절차를 보며, 프레임워크가 알아서 해준다고 해도 개발자가 제대로 활용하는지 테스트할 필요성을 느꼈다.

 

 

728x90
반응형