본문 바로가기

Back-End/토비의 스프링3

토비의 스프링 6장(3) - 다이나믹 프록시와 팩토리 빈

프록시 패턴 / 데코레이터 패턴

6-1장에서 트랜잭션 코드와 비즈니스 코드를 분리했었다.


01.png


위의 구조를 다시 그린 후에 프록시에 대한 개념을 살펴보면


이해하기 편하다.



프록시와 타킷

아래 그림은 위의 구조와 같은 것이다.

  • 프록시(Proxy) : 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 클라이언트의 요청을 받아주는 대리자 ( UserServiceTx )
  • 타깃(target) : 프록시를 통해 최종적으로 요청을 위임받아 처리하는 실제 오브젝트 ( UserServiceImpl ) 

프록시의 사용목적

  1. 클라이언트가 타킷에 접근하는 방법을 제어하기 위함
  2. 부가적인 기능을 부여하기 위함
두 가지 모두 프록시를 사용한다는 점은 동일하지만, 목적에 따라서 디자인 패턴에서는 다른 패턴으로 구분한다.

데코레이터 패턴

타깃에 부가적인 기능을 런타임 시 다이나믹하게 부여해주기 위해 프록시를 사용하는 패턴

프록시가 꼭 한개로 제한되지 않는다.

<!-- Decorator -->
<bean id="userService" class="springbook.user.service.UserServiceTx">
<property name="userService" ref="userServiceImpl" />
<property name="transactionManager" ref="transactionManager" />
</bean>

<!-- target -->
<bean id="userServiceImpl" class="springbook.user.service.UserServiceImpl">
<property name="userDao" ref="userDao"/>
</bean>

필요하다면 언제든지 트랜잭션 외에도 다른 기능을 부여해주는 데코레이터를 만들어서 UserServiceTx와 UserServiceImpl 사이에 추가해줄 수 있다.


프록시 패턴

타켓(UserServiceImpl)에 대한 접근방법을 제어하려는 목적을 가진 경우 사용

사용 예

  • 원격 오브젝트를 사용하는 경우 ( RMI, EJB ) : 원격 오브젝트에 대한 프록시를 만들어두고, 클라이언트는 마치 로컬에 존재하는 오브젝트를 쓰는 것처럼 프록시를 사용
  • 타킷에 대한 접근권한을 제어 : 수정 가능한 오브젝트가 있는데, 특정 레이어로 넘어가서는 읽기전용으로만 동작하게 강제해야 하는 경우


프록시의 구성과 프록시 작성의 문제점

  • 타킷 오브젝트로 위임하는 코드 작성하기 귀찮음
  • 부가기능 코드가 중복될 가능성이 많다는 점 ( 트랜잭션 경계 설정과 같은 코드 )

프록시 코드의 예제


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 "sayHello "+name;
}

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

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

public class HelloUppercase implements Hello {

Hello hello; // Target Object

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();
}
}


public void test(){
Hello proxiedHello = new HelloUppercase(new HelloTarget());
assertThat(proxiedHello.sayThankyou("ted"),is("SAYTHANKYOU TED"));
}


위의 예제를 보면 프록시 적용의 일반적인 문제점 두가지를 가지고 있다.

  1. 인터페이스의 모든 메소드를 구현해 위임하도록 코드를 만들어야 함.

  2. 부가기능인 리턴 값을 대문자로 바꾸는 기능이 모든 메소드에 중복돼서 나타남


다이나믹 프록시의 적용

위의 문제를 해결하기 위해 다이나믹 프록시를 이용하여 HelloUpercase를 만들어보자.


다이나믹 프록시 동작하는 방식은 아래와 같다.

dynamic-proxy


  1. 다이나믹 프록시는 프록시 팩토리에 의해 런타임 시 다이나믹하게 만들어지는 오브젝트이다.

  2. 클라이언트는 다이내믹 프록시 오브젝트를 타깃 인터페이스를 통해 사용 할 수 있음.
    ( 프록스 팩토리에게 인터페이스 정보만 제공해주면 해당 인터페이스를 구현한 클래스의 오브젝트를 자동으로 만들어주기 때문에 인터페이스를 모두 구현해가는 수고가 필요없음 )

  3. 다이나믹 프록시가 인터페이스 구현 클래스의 오브젝트는 만들어주지만, 프록시로 필요한 부가기능 제공 코드는 직접 작성해야 한다. ( InvocationHandler )
    - 타킷 인터페이스의 모든 메소드 요청이 하나의 메소드로 집중되기 때문에 중복되는 기능을 효과적으로 제공할 수 있음.


아래 그림은 다이나믹 프록시 오브젝트와 InvocationHandler 오브젝트, 타켓 오브젝트 사이의 메소드 호출이 일어나는 과정이다.



 아래 코드는 다이나믹 프록시를 구현한 것이다.

public class UppercaseHandler implements InvocationHandler {

Object target;

private 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 ret.toString().toUpperCase();
else
return ret;
}
}

다이나믹 프록시의 확장

이 전 코드보다 다이나믹 프록시가 훨씬 어려워 보이는데
어떤게 장점일까?

다이나믹 프록시 방식이 직접 정의해서 만든 프록시보다 훨씬 유연하고 많은 장점이 있다.
  • Hello 인터페이스의 메소드가 3개가 아니라 30개가 된다면? HelloUppercase 클래스에 매번 코드가 추가되어야 한다. 하지만 UppercaseHandler는 전혀 손댈 것이 없다.
  • 타킷의 종류에 상관없이도 적용이 가능하다. Method 인터페이스를 이용해 타킷의 메소드를 호출하는 것이니 Hello 타입의 타깃으로 제한할 필요는 없다.
  • 리턴 타입뿐만 아니라 메소드 이름으로 조건을 걸 수 도 있다.

@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("say"))
return ret.toString().toUpperCase();
else
return ret;
}

다이나믹 프록시를 이용한 트랜잭션 부가기능

트랜잭션 부가기능도 앞의 예시처럼 다이나믹 프록시를 이용해보자.

public class TransactionHandler implements InvocationHandler {

@Autowired
private PlatformTransactionManager transactionManager;

private Object target;
private String pattern;

public void setTarget(Object target) {
this.target = target;
}

public void setPattern(String pattern) {
this.pattern = pattern;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getName().startsWith(pattern)) {
return invokeInTransaction(method, args);
} else {
return method.invoke(target, args);
}
}

private Object invokeInTransaction(Method method, Object[] args) throws Throwable {

TransactionStatus status = this.transactionManager
.getTransaction(new DefaultTransactionDefinition());

try {
Object ret = method.invoke(target, args);
this.transactionManager.commit(status);
return ret;
} catch (InvocationTargetException e) {
this.transactionManager.rollback(status);
throw e.getTargetException();
}
}
}
다이나믹 프록시를 이용하여 트랜잭션 부가기능을 구현한 경우 장점
  • 타킷을 저장할 변수( UserServiceImpl )를 Object로 선언 -> 트랜잭션 적용이 필요한 어떤 타킷 오브젝트에도 적용할 수 있음
  • method 이름 검사를 통해 트랜잭션이 필요한 함수에만 적용이 가능함
  • 트랜잭션 관련 코드가 중복되지 않고 해당 프록시를 이용하면 됨

TransactionHandler와 다이나믹 프록시를 이용한 테스트

TransactionHandler가 UserServiceTx를 대신 할 수 있는지 확인하기 위해 테스트를 적용해보자.
  @Test
public void upgradeAllOrNothing() {

TestUserService testUserService = new TestUserService(users.get(3).getId());
testUserService.setUserDao(this.userDao);
// testUserService.setMailSender(mailSender);

TransactionHandler txHandler = new TransactionHandler();
txHandler.setTarget(testUserService);
txHandler.setPattern("upgradeLevels");
txHandler.setTransactionManager(transactionManager);

UserService txUserService = (UserService) Proxy
.newProxyInstance(getClass().getClassLoader(),
// 동적으로 생성되는 다이나믹 프록시 클래스의 로딩에 사용할 클래스 로더
new Class[]{UserService.class}, // 구현할 인터페이스
txHandler); // 부가기능과 위임 코드를 담은 InvocationHandler

userDao.deleteAll();

for (User user : users)
userDao.add(user);
try {
txUserService.upgradeLevels();
fail("TestUserServiceException expected"); // 중간에 예외를 안 던저주면 문제가 있는거임
} catch (TestUserServiceException e) { // 예외를 잡아서 어떤 작업을 진행

}

checkLevelUpgraded(users.get(1), false); // Test는 실패하게 됨 ( upgradeLevel()은 하나의 트랜잭션이 아니기 때문에 )
}