프록시와 프록시 패턴, 데코레이터 패턴
트랜잭션을 적용하는 단계애서 비즈니스 로직과 트랜잭션 로직을 분리했었다.
그리고 분리하는 과정에서 인터페이스를 통한 추상화를 적용했다.
UserServiceTx에서 UserService를 받아서 사용하는 방법으로, UserService의 비즈니스 로직을 구현한 UserServiceImpl에는 비즈니스 로직이 남지 않게 되었다.
이렇게 개발을 하게 된다면 부가기능 외의 모든 기능은 원래의 핵심기능을 가진 클래스로 위임해줘야 한다.
따라서 부가기능이 핵심기능을 사용하는 구조가 되며, 핵심 기능은 부가기능의 존재조차 신경쓰지 않아도 되는 것이다.
하지만 이런 방법은 문제가 있다.
만약 클라이언트가 핵심기능 클래스를 직접 사용해버린다면 부가기능이 적용되지 않는다는 것이다.
현재 클라이언트가 UserSerive를 UserServiceTx로 주입받아서 사용하고는 있지만, 만약 UserServiceImpl를 바로 사용해버리면 트랜잭션이 적용되지 않는다는 것이다.
그렇기에 할수만 있다면 가장 좋은 방법은 다음과 같을 것이다.
이렇게 중간에 무언가 부가기능을 감싸주는 부분이 있는게 가장 좋은 방법일 것이다.
이런 역할을 하는 코드를 프록시라고 하자.
그리고 프록시를 통해 최종적으로 요청을 받아 처리하는 실제를 타깃 혹은 실체라고 부른다.
여기서는 중간에 무언가가 프록시고, 핵심 기능 코드가 타깃 인 것이다.
이런 프록시는 크게 2가지 이유로 사용한다.
1. 클라이언트가 타깃에게 접근 하는 방법을 제어
2. 타깃 자체에 부가적인 기능을 부여
물론 프록시를 사용한다는 것은 동일하지만, 역할이 너무 다르기 때문에 디자인 패턴에서는 아예 다른 패턴으로 생각한다고 한다.
우선 데코레이터 패턴에 대해 알아보자.
테코레이터 패턴은 말 그대로 장식이다.
런타임 중 타깃에게 동적으로 부가적인 기능을 부여하기 위해 사용한다.
가장 쉽게 생각나는 부분이 로깅이다.
컨트롤러에서 서비스의 메서드를 호출 할 때, 사용자의 정보를 로깅하기 위해 프록시를 사용 할 수 있다.
하지만 여기서 사용자의 정보만이 아니라, 추가적으로 메서드의 정보도 남기기 위해 프록시를 중복으로 사용 할 수 있다.
중복 사용 뿐만 아니라, 프록시간에 단계적으로 위임하는 구조로 프록시를 만들 수도 있다.
저번에 만들었던 UserServiceTx도 데코레이터 패턴을 사용한 것이다.
UserServiceImpl에 트랙잭션 기능을 감싸서 부여하였기 때문이다.
여기에 추가적인 부가기능이 필요하다면, UserServiceLog와 같은 데코레이터를 추가해서 만들 수도 있는 것이다.
기본적으로 소프트웨에서 말하는 프록시 패턴과 지금 말하는 프록시는 좀 다르다.
프록시 패턴은 기능을 확장하는 것이 아닌, 접근하는 방법을 변경해주는 것이다.
지금부터 클라이언트와 타깃 사이에서 무언가 동작하는 모든 것을 프록시라고 할테니, 프록시 패턴과 잘 구별할 수 있도록 하자.
다이나믹 프록시
기존에 만들었던 UserServiceTx를 다시 생각해보자.
분명 비즈니스 로직과 트랜잭션을 분리 할 수 있는 아주 좋은 방법이었다.
@Override
public void upgradeLevels() {
TransactionStatus transactionStatus = this.transactionManager.getTransaction(new DefaultTransactionDefinition());
try
{
userService.upgradeLevels();
transactionManager.commit(transactionStatus);
this.transactionManager.commit(transactionStatus);
}
catch(Exception e)
{
this.transactionManager.rollback(transactionStatus);
throw e;
}
}
하지만 이 코드를 살펴보면 현재 userService의 메서드를 트랜잭션으로 위 아래로 감싸고 있으며, 이 코드는 거의 모든 코드에서 중복적으로 발생한다.
그럼 이런 코드를 계속 작성해서 복사해야 하는 것인가?
대부분의 개발과정에서 이런 방법은 많은 문제를 가져왔으니, 무언가 해결 방법이 있을 것 같다.
우선 자바의 리플랙션 기능을 알아보자.
리플랙션은 자바 코드 자체를 추상화해서 접근하는 방법을 말한다.
모든 자바 클래스는 구성정보를 담은 getClass() 메서드를 가지고 있다.
여기에는 클래스의 이름, 상속받은 정보, 정의된 메서드의 정보가 담겨있다.
Method lengthMethod = String.class.getMethod("length");
이렇게 getMethod()와 같은 메서드로 Method 타입의 클래스 메서드를 가져올 수 있다.
그리고 Method 클래스의 Invoke() 메서드를 사용해 실제로 실행 할 수도 있다.
대상 오브젝트와 파라미터 목록을 넘겨줘서 실행하면 된다.
lengthMethod.invoke("hello");
이 리플렉션을 이용해서 다이나믹 프록시를 적용한다.
다이나믹 프록시가 동작하는 방식은 다음과 같다.
다이나믹 프록시는 요청마다 해당 메서드를 감싸는게 아니라, 프록시 팩토리에 의해 런타임시에 다이나믹하게 만들어지는 오브젝트이다.
이렇게 만들어지는 다이내믹 프록시 오브젝트를 클라이언트가 사용하는 것이다.
다이나믹 프록시 오브젝트는 클라이언트의 요청을 리플렉션으로 변환해서 InvocationHandler 구현 오브젝트의 invoke() 메서드로 넘기는 것이다.
이런 방법을 사용하면 부가적인 기능들을 프록시 클래스에 하나하나 추가해야 하는 번거로움이 사라진다.
InvocationHandler 인터페이스를 구현한 오브젝트를 제공해주면 다이나믹 프록시가 받는 모든 요청을 InvocationHandler의 Invoke()로 보내준다.
그렇게 처리하기 때문에 타깃에 얼마나 많은 메서드가 있어도 하나의 메서드(invoke)로 처리할 수 있다.
public interface Hello {
String sayHello(String name);;
String sayHi(String name);
String sayBye(String name);
}
public class HelloImpl implements Hello {
public String sayHello(String name) {
return "hello " + name;
}
public String sayHi(String name) {
return "hi " + name;
}
public String sayBye(String name) {
return "bye " + name;
}
}
이런 추상 인터페이스와 구현체가 존재한다.
HelloImpl을 건드리지 않고 반환되는 값을 모두 대문자로 변경하고 싶다면 이렇게 HelloImpl을 멤버로 가져와서 사용하는 다음과 같은 클래스를 만들어야 한다.
@RequiredArgsConstructor
public class HelloUpperCase implements Hello {
private final 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 sayBye(String name) {
return this.hello.sayBye(name).toUpperCase();
}
}
이거를 InvocationHandler를 사용해 바꿔보자.
@RequiredArgsConstructor
public class HelloUpperCase implements InvocationHandler {
private final Hello target;
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getReturnType().equals(String.class)) {
String returnValue = (String)method.invoke(target, args);
return returnValue.toUpperCase();
}
else return method.invoke(target, args);
}
}
이렇게 호출받은 메서드를 실행하면서 전후로 무언가 추가적인 작업을 할 수 있다.
나는 여기서 리턴의 타입이 String이면 대문자로 넘겨줄 수 있도록 만들었다.
@Test
public void simpleProxy2(){
Hello proxyHello = (Hello) Proxy.newProxyInstance(
getClass().getClassLoader(),
//구현할 인터페이스
new Class[]{Hello.class},
//부가기능을 담은 InvocationHandler 구현체
new HelloUpperCase(new HelloImpl())
);
Assertions.assertEquals("HELLO SEUNGKYU", proxyHello.sayHello("seungkyu"));
Assertions.assertEquals("HI SEUNGKYU", proxyHello.sayHi("seungkyu"));
Assertions.assertEquals("BYE SEUNGKYU", proxyHello.sayBye("seungkyu"));
}
Proxy.newProxyInstance를 통해 프록시가 설정된 클래스를 생성한다.
파라미터를 하나씩 살펴보자.
- 첫번째 파라미터
그냥 클래스로더이다. 다이나믹 프록시가 적용되는 클래스 로더를 지정하는 것이라고 한다.
- 두번째 파라미터
다이나믹 프록시가 구현할 인터페이스이다. 여기서 배열을 사용하는 이유는 하나 이상의 인터페이스를 구현하기 때문이다.
- 세번째 파라미터
부가기능을 가지고 있는 InvocationHandler 구현 오브젝트이다.
여기서 invoke 메서드는 요청받은 클래스의 메서드만 사용 가능하다.
만약 이렇게 FakeHello가 존재하고, FakeHello에도 동일한 메서드들이 존재한다면
@RequiredArgsConstructor
public class HelloUpperCase implements InvocationHandler {
private final Hello target;
private final FakeHello fakeTarget;
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getReturnType().equals(String.class)) {
String returnValue = (String)method.invoke(target, args);
return returnValue.toUpperCase();
}
else return method.invoke(target, args);
}
}
FakeHello를 통해서 invoke 하려고 해도, 에러가 발생한다는 것이다.
이렇게 해서 simpleProxy2를 테스트해보니 다음과 같이 성공하는 것을 볼 수 있었다.
그리고 또 궁금한 점이 생겼다.
만약 타입에 맞지 않는 값을 반환한다면?
UserService에 정수를 반환하는 메서드를 추가하고, invocationHandler를 아래와 같이 바꾸고
@RequiredArgsConstructor
public class HelloUpperCase implements InvocationHandler {
private final Hello target;
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
String returnValue = (String)method.invoke(target, args);
return returnValue.toUpperCase();
}
}
다음의 테스트를 실행해보았다.
@Test
public void return1Test(){
Hello proxyHello = (Hello) Proxy.newProxyInstance(
getClass().getClassLoader(),
//구현할 인터페이스
new Class[]{Hello.class},
//부가기능을 담은 InvocationHandler 구현체
new HelloUpperCase(new HelloImpl())
);
proxyHello.return1();
}
그러니 다음과 같은 에러가 발생했다.
리플렉션을 사용할 때는 반환값의 타입을 꼭 확인하도록 하자.
이거 뿐만 아니라 메서드 이름을 보고 실행을 결정할 수도 있다고 한다.
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if(method.getName().startsWith("say")) {
String returnValue = (String)method.invoke(target, args);
return returnValue.toUpperCase();
}
return method.invoke(target, args);
}
이렇게 메서드의 이름을 가져와서 문자열로 비교가 가능하다.
다이나믹 프록시를 이용한 트랜잭션 부가기능
이전부터 굉장히 편하게 사용하던 기능이다.
정말 부끄럽지만 AOP가 뭔지도 모르던 시절부터 아무생각도 안하고 그냥 따라쓰던 방법이다.
이번 기회에 자세히 알아보도록 하자.
우선 UserServiceTx를 프록시 방식으로 변경해보도록 하자.
지금은 UserService에 메서드가 추가될 때마다, UserServiceTx에도 하나씩 추가해줘야 하는 문제가 있었다.
@RequiredArgsConstructor
public class TransactionHandler implements InvocationHandler {
private final Object target;
private final PlatformTransactionManager platformTransactionManager;
private final String pattern;
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if(method.getName().startsWith(pattern)) {
return this.invocationTransaction(method, args);
}
else{
return method.invoke(target, args);
}
}
private Object invocationTransaction(Method method, Object[] args) throws Throwable {
TransactionStatus status = this.platformTransactionManager.getTransaction(new DefaultTransactionDefinition());
try
{
Object returnValue = method.invoke(target, args);
this.platformTransactionManager.commit(status);
return returnValue;
}
catch(InvocationTargetException e)
{
this.platformTransactionManager.rollback(status);
throw e.getTargetException();
}
}
}
모든 메서드에 트랜잭션 설정을 할 수는 없으니, 메서드의 이름을 확인하며 해당 패턴에 맞는 경우에만 트랜잭션 설정을 할 수 있도록 했다.
그리고 invoke로 발생한 모든 예외는 InvocationTargetException으로 전환되는 것 같다.
그렇기에 호출과정에서 발생하는 예외를 rollback하기 위해 InvocationTargetException을 catch한다.
필요한 PlatformTransactionManager는 생성자를 통해 받도록 했다.
다이나믹 프록시를 위한 팩토리 빈
일단은 프록시를 설정했는데, 지금으로서 이걸 쓰기에는 계속 proxyInstance로 생성해주며 사용해야 한다.
원래 목적으로라면 DI를 통해 프록시 설정된 빈을 받도록 해야하는데, 프록시로 감싼 빈을 주입받을 수 없기 때문이다.
그렇기에 xml을 통해 빈을 만들지 말고 팩토리 빈을 사용해 만들어보자.
package org.springframework.beans.factory;
import org.jspecify.annotations.Nullable;
public interface FactoryBean<T> {
String OBJECT_TYPE_ATTRIBUTE = "factoryBeanObjectType";
@Nullable T getObject() throws Exception;
@Nullable Class<?> getObjectType();
default boolean isSingleton() {
return true;
}
}
일단 스프링 공식문서에서 정리된 FactoryBean은 다음과 같다.
이 FactoryBean을 구현하는 것도 자바 빈을 만드는 방법의 한가지라고 한다.
예시로 간단한 클래스를 만들어보자.
@Getter
@Builder
public class Person {
String name;
private Person(String name) {
this.name = name;
}
}
이렇게 name이라는 필드를 가지지만, 생성자가 private이기 때문에 외부에서는 만들지 못하는 클래스가 있다.
물론 Builder로 생성이 가능하지만, 일단은 생성자가 private이다.
그렇기에 현재는 직접 스프링 빈으로 등록이 불가능하다.
그렇기에 FactoryBean을 사용해 등록한다.
@AllArgsConstructor
public class PersonFactoryBean implements FactoryBean<Person> {
String name;
@Override
public Person getObject() throws Exception {
return Person.builder()
.name(this.name)
.build();
}
@Override
public Class<?> getObjectType() {
return Person.class;
}
public boolean isSingleton() {
return true;
}
}
getObject()를 통해 사용될 오브젝트를 직접 생성해준다. 코드를 사용해 등록하기 때문에 Builder를 사용해서도 등록이 가능하다.
해당 클래스의 생성자나 setter를 통해 대신 DI를 받을 수 있도록 한다.
이렇게 주입된 정보로 getObject()와 같은 곳에서 객체 생성에 사용한다.
isSingleton()을 사용해 싱글톤으로 등록하는지 설정한다.
싱글톤이 아니라면 요청마다 오브젝트를 생성해주며, 대부분의 경우는 싱글톤이고 그렇기에 default도 싱글톤이다.
이 팩토리빈은 딱 오브젝트를 생성하는 과정에서만 사용한다.
그럼 해당 팩토리 빈은 어떻게 등록하는 것일까?
<bean id="person" class="seungkyu.PersonFactoryBean">
<constructor-arg value="seungkyu"/>
</bean>
이렇게 등록해두고 테스트 코드를 사용해 클래스의 정보를 찍어보자.
@Test
public void getTypeFromFactoryBean(){
Object person = applicationContext.getBean("person");
System.out.println(person.getClass());
}
해당 메서드로 타입을 확인해보니
이렇게 Person 클래스로 확인된다.
타입을 가져오더라도 PersonFactoryBean으로 등록했지만, 자바 제네릭으로 등록한 Person 클래스로 가져오는 것을 볼 수 있다.
기존에 트랜잭션 작업을 하는 TransactionHandler 클래스를 구현했었다.
해당 클래스는 InvocationHandler를 구현한 상태였다.
이 방식으로 프록시를 구현한다면
UserService proxyUser = (UserService) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class[]{UserService.class},
new TransactionHandler(
new UserServiceImpl(userDao, mailSender),
platformTransactionManager,
"tx"
)
);
이렇게 생성하고 있다.
FactoryBean의 getObject()에 저렇게 클래스를 만들고 반환하게 해준다면 프록시가 적용된 클래스로 빈을 적용할 수 있을 것 같다.
@RequiredArgsConstructor
public class TxProxyFactoryBean implements FactoryBean<Object> {
private final Object target;
private final PlatformTransactionManager platformTransactionManager;
private final String pattern;
private final Class<?> serviceInterface;
@Override
public Object getObject() throws Exception {
TransactionHandler transactionHandler = new TransactionHandler(target, platformTransactionManager, pattern);
return Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class[] {serviceInterface},
transactionHandler
);
}
@Override
public Class<?> getObjectType() {
return serviceInterface;
}
@Override
public boolean isSingleton() {
return false;
}
}
트랜잭션 프록시를 현재는 UserService만 적용하겠지만, 이후에는 다른 서비스들에도 적용 할 수 있으니 제네릭으로 클래스를 생성하도록 만들었다.
대신 이 방법으로 만든다면 클래스의 getObjectType()을 위해 해당 클래스가 어떤 인터페이스를 구현한지의 정보도 생성자를 통해 받을 수 있도록 했다.
이제 이 팩토리 빈을 xml을 통해 빈으로 등록해보자.
<bean id="userService" class="seungkyu.TxProxyFactoryBean">
<constructor-arg ref="userServiceImpl"/>
<constructor-arg ref="transactionManager"/>
<constructor-arg value="tx"/>
<constructor-arg value="seungkyu.UserService"/>
</bean>
<bean id="userServiceImpl" class="seungkyu.UserServiceImpl">
<constructor-arg ref="userDao"/>
<constructor-arg ref="mailSender"/>
</bean>
여기서 다른 대부분의 class들은 빈으로 주입했지만, serviceInterface는 value로 주입하였다.
이렇게 주입하더라도 스프링 자체에서 Class.forName()을 호출해 맞는 타입으로 mapping 해준다고 한다.
프록시 팩토리 빈 방식의 장점과 한계
일단 TxProxyFactoryBean을 제네릭으로 받도록 해두었기에 다양한 타입의 클래스에 코드의 수정없이 프록시를 설정 할 수 있다.
만약 AuthService를 개발하고 있다고 하면 우선 트랜잭션 없이 개발 할 수 있다.
이 클래스를 빈으로 등록한다면
<bean id="authService" class="seungkyu.AuthServiceImpl">
<constructor-arg ref="userDao"/>
</bean>
이렇게 개발하고 있다가 트랜잭션이 필요한 순간에 다음과 같이 트랜잭션 프록시를 추가할 수 있다.
<bean id="authService" class="seungkyu.TxProxyFactoryBean">
<constructor-arg ref="authServiceImpl"/>
<constructor-arg ref="transactionManager"/>
<constructor-arg value="tx"/>
<constructor-arg value="seungkyu.AuthService"/>
</bean>
<bean id="authServiceImpl" class="seungkyu.AuthServiceImpl">
<constructor-arg ref="userDao"/>
</bean>
기존에 트랜잭션을 적용하는 방법보다 훨씬 빠른 방법이다.
트랜잭션 프록시를 적용하겠다는 이유로 새로운 클래스를 작성할 필요가 없기 때문이다.
기존에 비해 너무 편리해졌지만, 아직도 아주 작은 불편함이 남아있다.
우선 대부분의 프록시 설정은 사실 클래스 단위가 아닌, 클래스 속의 메서드 단위에서 이루어진다.
그렇기에 하나의 클래스에서 하나의 메서드에만 프록시를 적용해야 한다면, 그 하나의 메서드를 위해 모든 클래스에 프록시 설정을 해두고 패턴을 찾아서 매칭시켜야 한다는 문제가 있다.
지금은 프로젝트가 크지 않지만, 프로젝트가 커진다면 xml에 하나씩 등록해주는 것조차 큰 문제가 될 것이다.
또한, 지금은 isSingleton()의 리턴이 false이다.
사실 여기서 사용하는 TransactionHandler는 모든 트랜잭션 설정에서 사용하지만, 공유하지 못하고 계속 생성해야한다.
이런 것도 싱글톤으로 만들고 관리하고 싶다.
이런 사소한 문제까지 최대한 스프링에서 해결해보도록 하겠다.
'Spring > 토비의 스프링' 카테고리의 다른 글
[토비의 스프링] 6.5 스프링 AOP (1) | 2025.06.25 |
---|---|
[토비의 스프링] 6.4 스프링의 프록시 팩토리 빈 (0) | 2025.06.19 |
[토비의 스프링] 6.2 고립된 단위 테스트 (1) | 2025.06.16 |
[토비의 스프링] 6.1 트랜잭션 코드의 분리 (1) | 2025.06.14 |
[토비의 스프링] 5.4 메일 서비스 추상화 (0) | 2025.06.11 |