// UserServicTx
public class UserServiceTx implements UserService {
...
@Override
public void upgradeLevels() {
// 트랜잭션 경계설정 부가기능 적용
TransactionStatus status = this.transactionManager.getTransaction(
new DefaultTransactionDefinition());
try {
this.userService.upgradeLevels(); // 핵심기능 클래스에게 위임
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
throw e;
}
}
@Override
public void add(User user) {
this.userService.add(user); // 핵심기능 클래스에게 위임
}
...
// UserServiceImpl
public class UserServiceImpl implements UserService {
...
// 핵심기능 구현
public void upgradeLevels() {
List<User> users = userDao.getAll();
for (User user : users) {
// 현재 레벨에 따른 레벨 업그레이드 가능 여부 확인
if (canUserUpgradeLevel(user)) {
upgradeLevel(user);
}
}
}
...
실제 클라이언트는 UserService 의 유저 레벨 수정 메서드를 사용하기 위해 핵심기능을 구현한 UserServiceImpl 객체를 사용하는 것이 아니라 부가기능을 적용하고 핵심기능을 위임한 UserServiceTx 를 사용하게된다.
프록시
자신이 클라이언트가 사용할려는 실제 대상인 것처럼 위장해 클라이언트의 요청을 대신 받아주는 역할을 하는 객체
타깃
프록시를 통해 최종적으로 요청을 위임받아 처리하는 실제 객체
따라서 UserServiceTx 가 프록시, UserServiceImpl 이 타깃이된다. 위의 예시에서 프록시는 트랜잭션 경계설정에 대한 부가기능을 제공하고있다. 이처럼 핵심기능에 부가기능을 제공하는 프록시도 존재하지만 타깃에 접근하는 방법을 제어하거나 접근권한을 제어하기위한 프록시도 존재한다.
만약 위의 예시에 유저권한을 검사한뒤 유저 서비스를 사용할 수 있어야한다는 요구사항이 추가되어 유저권한에 따라 접근할 메서드를 제한하는 용도의 프록시 객체를 사용한다면 이것은 프록시 패턴이고, 예시처럼 부가기능을 위해 프록시 객체를 사용한다면 데코레이터 패턴이라고 구분한다.
데코레이터 패턴
데코레이터 패턴은 타깃에 부가적인 기능을 런타임 시 다이내믹하게 부여하는 용도로 프록시를 사용하는 패턴을 말한다.
컴파일 시점, 코드 상에 어떤 방법과 순서로 프록시와 타깃이 연결되어 사용하는지 정해져있지 않은 것을 의미한다. 위의 예시에서도 UserService 인터페이스를 통해 UserServiceTx 에 주입해주므로 UserServiceTx 는 다음에 다른 프록시가오는지 진짜 핵심기능 객체가 오는지 알지못하므로 이는 데코레이터 패턴이라고 할 수 있다.
⇒ 타깃의 코드를 수정하지 않으며 클라이언트가 호출하는 방법도 변경하지 않은채로 새로운 기능을 추가할 때 매우 유용
프록시 패턴
프록시 패턴은 타깃의 기능을 확장/변경하지 않고 타깃에 접근하는 방식을 제어하거나 접근권한을 제어하는 용도로 프록시를 사용하는 패턴을 말한다.
원격 오브젝트를 사용하는 경우 다른 서버에 존재하는 객체를 사용해야할때 클라이언트는 프록시를 사용하도록해 실제로 원격 오브젝트가 쓰이는 시점에 프록시를 통해 원격 오브젝트를 생성하고 사용하도록한다면 이것은 프록시 패턴이다.
⇒ 타깃의 기능 자체에 관여하지 않고 접근방법을 제어하는데 매우 유용
⇒ 프록시 패턴의 프록시 객체는 자신이 만들거나 사용할 타깃의 정보를 알고있어야하는 경우가 많다는 점, 부가기능 제공 용도가 아니라는 점에서 데코레이터 패턴에서 차이가 존재
프록시는 핵심기능의 수정없이 쉽게 확장하고 제어할 수 있다는 점에서 매력적이지만 몇가지 단점이 존재한다.
다이나믹 프록시는 기본적으로 리플렉션 API 를 통해 프록시를 만든다.
리플렉션 → 자바 코드 자체를 추상화해 접근하도록 만든 것
public class ReflectionTest {
@Test
public void invokeMethod() throws Exception {
String name = "length7";
assertThat(name.length()).isEqualTo(7); // 기존 length 메서드
Method lengthMethod = String.class.getMethod("length");
assertThat((Integer) lengthMethod.invoke(name)).isEqualTo(7); // invoke 메서드
}
}
리플렉션 API 중 Method 를 통해 String 클래스의 length 메서드를 가져와 invoke 를 통해 실행한 결과를 검증하는 코드이다. invoke 로 실행한 결과와 String.length 가 동일한 결과를 내는 것을 알 수 있다.
public class HelloUppercase implements Hello {
Hello hello;
public HelloUppercase(Hello hello) {
this.hello = hello;
}
@Override
public String sayHello(String name) {
return this.hello.sayHello(name).toUpperCase();
}
@Override
public String sayHi(String name) {
return this.hello.sayHi(name).toUpperCase();
}
@Override
public String sayBanga(String name) {
return this.hello.sayBanga(name).toUpperCase();
}
}
// Client
@Test
public void helloUppercaseProxyTest() {
String name = "kim beob woo";
Hello helloProxy = new HelloUppercase(new HelloTarget());
assertThat(helloProxy.sayHello(name)).isEqualTo("HELLO, KIM BEOB WOO");
assertThat(helloProxy.sayHi(name)).isEqualTo("HI, KIM BEOB WOO");
assertThat(helloProxy.sayBanga(name)).isEqualTo("BANGA, KIM BEOB WOO");
}
public class UppercaseHandler implements InvocationHandler {
Object target;
public UppercaseHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Object ret = method.invoke(target, args);
if (ret instanceof String) {
return ((String) ret).toUpperCase();
} else {
return ret;
}
}
}
// Client
@Test
public void dynamicHelloProxyTest() {
String name = "kim beob woo";
Hello helloProxy = (Hello) Proxy.newProxyInstance(
getClass().getClassLoader(), // 동적 생성되는 다이내믹 프록시 클래스의 로딩에 사용할 클래스 로더
new Class[]{Hello.class}, // 구현할 인터페이스
new UppercaseHandler(new HelloTarget()) // 부가기능 및 위임 코드를 담은 핸들러
);
assertThat(helloProxy.sayHello(name)).isEqualTo("HELLO, KIM BEOB WOO");
assertThat(helloProxy.sayHi(name)).isEqualTo("HI, KIM BEOB WOO");
assertThat(helloProxy.sayBanga(name)).isEqualTo("BANGA, KIM BEOB WOO");
}
다이나믹 프록시는 프록시 팩토리에 의해 런타임 시 다이나믹하게 만들어지는 오브젝트이다. 위의 클라이언트 코드에서는 테스트를 위해 직접 생성하였다. 다이나믹 프록시 오브젝트는 (Hello) Proxy.newProxyInstance
를 통해 타깃 인터페이스와 동일한 타입으로 만들어진다.
다이나믹 프록시를 적용함으로써 얻는 가장 큰 효용은 핵심기능 인터페이스의 메서드 추가/수정 여부와는 관계없이 프록시 패턴을 동일하게 적용 할 수 있다. 기존 프록시 패턴은 메서드가 100개로 늘어나면 100개에 대한 부가기능을 처리하기 위해 모든 메서드를 구현해야한다. 기본적으로 핵심기능 인터페이스를 구현하기 떄문이다.
다이나믹 프록시는 리플렉션 API 를 사용해 부가기능을 적용하고 타깃에 위임하므로 확장성에 있어 매우 뛰어나다.
Date now = (Date) Class.forName("java.util.Date").newInstance();
따라서, 빈 정의에 나오는 클래스 이름이 필수적으로 필요하다.⇒ 스태틱 팩토리 메소드를 통해서만 만들 수 있다!
public class TxProxyFactoryBean implements FactoryBean<Object> {
PlatformTransactionManager transactionManager;
String pattern;
Object target;
Class<?> serviceInterface;
...
@Override
public Object getObject() {
TransactionHandler handler = new TransactionHandler();
handler.setPattern(this.pattern);
handler.setTarget(this.target);
handler.setTransactionManager(this.transactionManager);
return Proxy.newProxyInstance(
getClass().getClassLoader(), // 동적 생성되는 다이내믹 프록시 클래스의 로딩에 사용할 클래스 로더
new Class[]{this.serviceInterface}, // 구현할 인터페이스
handler // 부가기능 및 위임 코드를 담은 핸들러
);
}
...
<bean class="springbook.user.service.TxProxyFactoryBean" id="userService">
<property name="transactionManager" ref="transactionManager"/>
<property name="target" ref="userServiceImpl"/>
<property name="pattern" value="upgradeLevels"/>
<property name="serviceInterface" value="springbook.user.service.UserService"/>
</bean>
context.xml 에서는 FactoryBean 을 사용하지만 DI 를 받는 쪽에서는 UserService 를 확장/위임한 다이나믹 프록시 객체를 주입받을 수 있다.
만약 다른 서비스 빈이 트랜잭션 경계설정을 사용해야한다면 아래와 같은 설정 파일 내용 추가로 적용 할 수 있다.
<bean class="springbook.user.service.TxProxyFactoryBean" id="otherService">
<property name="transactionManager" ref="transactionManager"/>
<property name="target" ref="otherServiceImpl"/>
<property name="pattern" value="upgradeLevels"/>
<property name="serviceInterface" value="springbook.user.service.OtherService"/>
</bean>
타깃이 구현하는 인터페이스를 구현하는 프록시 클래스를 일일이 만들어야한다.
→ 설정 파일만 추가하면 클라이언트 수정 없이 데코레이터 패턴 적용 가능
부가적인 기능이 여러 메서드에 반복적으로 나타나 코드 중복이 발생.
**→ 다이나믹 프록시를 사용하므로 메서드에 코드 중복이 발생하지 않음.**
⇒ DI 는 신이다.
@Override
public Object getObject() {
TransactionHandler handler = new TransactionHandler();
handler.setPattern(this.pattern);
handler.setTarget(this.target);
handler.setTransactionManager(this.transactionManager);
...
TransactionHandler 는 조금의 수정만 거치면 빈으로 등록이 가능하다. 하지만 매번 생성해 중복된 객체를 만들어 사용하고있다.기본적으로 InvokeHandler 를 구현하는 TransactionHandler 는 재사용 가능하지만 매번 TxFactory 안에서 생성해 사용하고 있다. 이로 인해 트랜잭션 빈 팩토리는 극히 핸들러에 의존하고 있으며, 핸들러는 부가기능을 적용할 메서드를 선정하기 위해 타깃에도 극히 의존한다.
어드바이스는 프록시가 제공하는 순수한 부가기능만을 구현한 것이다.
이전 InvokeHandler 가 타깃에 대한 정보를 가지고 있어 싱글톤으로 여러 프록시에서 공유가 불가능했던것에 비해, 어드바이스는 MethodInterceptor 를 구현하게 되는데 invoke 메서드의 파라미터로 MethodInvocation 이라는 객체가 전달된다. 해당 객체는 proceed
메서드를 가지고 있으며 이는 InvokeHandler 에서 method.invoke 와 동일하게 타깃의 메서드를 실행한다.
public class TransactionAdvice implements MethodInterceptor {
PlatformTransactionManager transactionManager;
public void setTransactionManager(PlatformTransactionManager transactionManager) {
this.transactionManager = transactionManager;
}
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
TransactionStatus status = this.transactionManager.getTransaction(
new DefaultTransactionDefinition()
);
try {
Object ret = invocation.proceed();
this.transactionManager.commit(status);
return ret;
} catch (RuntimeException e) {
// InvokeHandler 와 다르게 InvocationTargetException 이 아닌 타깃 메서드 에러로 잡아 처리 할 수 있다.
this.transactionManager.rollback(status);
throw e;
}
}
}
여기에도 적용된 템플릿/콜백 패턴
재사용 가능한 기능을 만들어두고 바뀌는 부분만 외부에서 주입해 작업 흐름 중에 사용하게 하는 패턴.
MethodInterceptor 를 구현하는 어드바이스는 템플릿/콜백 패턴을 응용하여 어드바이스를 여러 프록시 빈에서 공유 할 수 있도록 한다. 구현을 강제하는 invoke 메서드의 파라미터로 전달되는 MethodInvocation 객체를 통해 타깃의 메서드를 호출한다. JdbcTemplate 에 전략 콜백을 제공하던 기억을 떠올리자.
⇒ 부가기능을 여러 프록시에서 공유!
원래 메서드 선정 알고리즘 기능은 TransactionHandler 에서 메서드 이름 패턴을 사용하던 기능을 어드바이스로부터 분리한 것이다. 하지만 더이상 어드바이스, 포인트컷은 타깃에 대한 정보를 가질 수 없다.타깃의 정보를 가지고 있는 순간 빈으로 등록해 프록시끼리 공유가 불가능하다.
어드바이스는 단순히 부가기능을 제공한다. 포인트컷이 존재해야 정확히 타깃의 어떤 기능에 부가기능을 적용할지 정할 수 있게 된다.
<!-- 트랜잭션 포인트컷-->
<bean class="org.springframework.aop.support.NameMatchMethodPointcut" id="transactionPointcut">
<property name="mappedName" value="upgrade*"/> <-- upgrade 로 시작하는 모든 메서드!
</bean>
<!-- 트랜잭션 어드바이스-->
<bean class="springbook.user.service.TransactionAdvice" id="transactionAdvice">
<property name="transactionManager" ref="transactionManager"/>
</bean>
<!-- 트랜잭션 포인트컷-->
<bean class="org.springframework.aop.support.NameMatchMethodPointcut" id="transactionPointcut">
<property name="mappedName" value="upgrade*"/>
</bean>
<!-- 트랜잭션 어드바이저-->
<bean class="org.springframework.aop.support.DefaultPointcutAdvisor" id="transactionAdvisor">
<property name="transactionAdvice" ref="transactionAdvice"/>
<property name="transactionPointcut" ref="transactionPointcut"/>
</bean>
<bean
class="org.springframework.aop.framework.ProxyFactoryBean"
id="userService"
>
<property name="target" ref="userServiceImpl"/>
<!-- 어드바이스와 어드바이저를 동시에 설정하는 프로퍼티-->
<property name="interceptorNames">
<list>
<value>transactionAdvisor</value>
</list>
</property>
</bean>
설정 파일이 늘어나긴했지만 트랜잭션 부가기능을 빈으로 등록하고 여러 빈에서 주입받아 재사용 가능하게 변경된 점을 유의깊게 봐야한다.
감탄만이 나온다. 이전 데코레이터 패턴처럼 여러개의 부가기능을 제공하고 싶을때 addAdvice
를 통해 어드바이스/포인트컷을 쌍으로 주입해주면 된다. 진정한 힘은 여러개의 서비스 클래스가 부가기능, 즉 프록시를 사용하고자 하는 경우인데 이떄 어드바이스나 포인트컷은 하나만 생성하고 빈에 등록하면 DI 를 통해 모든 서비스에 적용이 가능하다.
트랜잭션 경계 설정 코드를 완전히 비즈니스 로직에서 분리하고, DI 를 원하는 메서드에 트랜잭션 부가기능 적용 할 수 있도록 만들었지만 여전히 기존 프록시 빈 팩토리의 단점인 “중복 설정 파일”의 단점은 남아있다.
<bean
class="org.springframework.aop.framework.ProxyFactoryBean"
id="userService" **<-- 유저 서비스에 한정 따라서 밑의 코드는 모두 복붙되어야함.
>**
<property name="target" ref="userServiceImpl"/>
<!-- 어드바이스와 어드바이저를 동시에 설정하는 프로퍼티-->
<property name="interceptorNames">
<list>
<value>transactionAdvisor</value>
</list>
</property>
</bean>
빈 후처리기
빈 후처리기는 BeanPostProcessor 인터페이스를 구현하는 객체이다. 스프링 빈 오브젝트로 만들어지고난 뒤 빈 오브젝트를 다시 재가공 할 수 있도록 한다. 우리가 원하는 것은 어드바이저 포인트컷에 걸리는 타겟 빈을 어드바이저의 어드바이스가 적용된 프록시로 대체하는 후처리를 수행하는 후처리기이며 이는 DefaultAdvisorAutoProxyCreator 를 사용한다.
- 해당 빈 후처리기가 등록이 된 상태라면 스프링은 빈 오브젝트를 생성 할 때 마다 후처리기에 빈을 보낸다.
- 포인트컷을 사용해 해당 빈이 어드바이저 적용 대상인지 확인한다.
- 적용 대상이라면 내장된 프록시 생성기를 통해 현재 빈에 어드바이저를 적용한 프록시를 생성한다.
- 컨테이너에게 생성한 프록시를 전달한다.
- 최종적으로 컨테이너는 자신이 설정 파일을 통해 생성한 빈이 아닌 빈 후처리기가 반환한 오브젝트를 빈으로 등록하고 사용한다.
<!-- 타깃 서비스 클래스-->
<bean class="springbook.user.service.UserServiceImpl" id="userService">
<property name="userDao" ref="userDao"/>
<property name="mailSender" ref="mailSender"/>
</bean>
<!-- 트랜잭션 어드바이스-->
<bean class="springbook.user.service.TransactionAdvice" id="transactionAdvice">
<property name="transactionManager" ref="transactionManager"/>
</bean>
<!-- 트랜잭션 포인트컷-->
<bean class="springbook.user.service.NameMatchClassMethodPointcut" id="transactionPointcut">
<property name="mappedClassName" value="*ServiceImpl"/>
<property name="mappedName" value="upgrade*"/>
</bean>
<!-- 트랜잭션 어드바이저-->
<bean class="org.springframework.aop.support.DefaultPointcutAdvisor" id="transactionAdvisor">
<property name="transactionAdvice" ref="transactionAdvice"/>
<property name="transactionPointcut" ref="transactionPointcut"/>
</bean>
<!-- 자동 프록시 생성기-->
<bean class="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator"/>
이전과 달라진점은 타깃에 대해 매번 설정파일을 추가하거나 수정해야하는 수고를 제거한 부분이다.
<!-- 이전 스프링 프록시 빈 팩토리 사용 설정 파일 일부 -->
<bean
class="org.springframework.aop.framework.ProxyFactoryBean"
id="userService"
>
<property name="target" ref="userServiceImpl"/> **<-- 타깃 빈을 명시적으로 전달해야한다.**
<property name="interceptorNames">
<list>
<value>transactionAdvisor</value>
</list>
</property>
</bean>
즉, 포인트컷을 통해 대상 타깃 클래스와 클래스내의 메서드를 선정하는 기능을 적극적으로 사용해 프록시 빈 조차도 자동으로 생성되도록 만들었다고 할 수 있다.
이로서 2번째 문제점이었던 설정 파일의 중복을 해결 할 수 있다!
InvokeHandler 가 타깃 클래스에 의존하고 있기 때문에 새 타깃이 온 경우 새로 만들어야함.
타깃이 무엇이든간에 한번 만들어놓은 부가기능을 적용할 수는 없을까?
⇒ 어드바이스(부가기능)과 포인트컷(대상 선정 알고리즘), 대상 타깃 클래스를 빈으로 등록하고 스프링 프록시 팩토리 빈에 3가지를 주입해 자동으로 적용되도록 해보자!
어드바이저는 재사용 가능하지만 기본적으로 프록시 팩토리 빈 → 타깃 객체를 대신하는 프록시를 생성, 따라서 타깃 1개당 1개 필요하므로 설정 파일이 늘어남
⇒ 포인트컷에 선정된 빈이라면 팩토리를 자동으로 생성해서 프록시 빈으로 만들자!
개선을 해나가는 일련의 과정이 참 재미있었다. Nest.js + TypeORM 에서 트랜잭션을 사용할때 QueryRunner 를 통해 경계 설정을 하는데 이 코드가 중복되는게 무지막지하다. 코드 중복도 중복이고 다른 서비스 메서드를 실행중인 트랜잭션내에서 실행하기 위해서는 QueryRunner 나 EntityManager 를 함수 파라미터로 넘겨야하는데 이게 또 말이 아니다. 토스에서 만든 nesjts-aop 모듈 개념 + typeorm-trasnaction-cls-hook 의 개념을 잘 섞으면 멋지게 만들 수 도 있을것 같은데 도전을 해봐야겟다.