본문 바로가기
Spring&SpringBoot/<토비의 스프링 3.1 Vol1.1>, 이일민

Step2&3. 다이나믹 프록시와 팩토리빈

by 민휘 2023. 4. 11.

프록시와 프록시를 사용하는 디자인 패턴

프록시는 클라이언트의 요청을 받아주는 대리인과 같은 역할을 하는 오브젝트이다. 이때 프록시는 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장한다. 그리고 프록시를 통해 최종적으로 요청을 위임받아 처리하는 실제 오브젝트를 타깃이라고 한다.

프록시를 사용하는 디자인 패턴은 프록시의 사용 목적에 따라 두 가지로 나뉜다.

  • 데코레이터 패턴 : 타깃에 부가적인 기능을 부여
  • 프록시 패턴 : 타깃의 접근 방법 제어

데코레이터 패턴과 디자인 패턴 둘다 프록시를 사용하기 때문에 구현에 DI를 사용한다는 점에서 유사하다. 하지만 데코레이터 패턴은 인터페이스를 통해서만 위임하는 방식이기 때문에 여러개의 부가기능을 체인처럼 연결하고, 각 데코레이터는 자신이 위임하는 인터페이스가 또다른 데코레이터인지 타깃인지 알 수 없다. 반면 프록시 패턴은 타깃을 알고 있는 경우가 많다. 주로 타깃 바로 앞에서 생성이나 호출 권한을 제어하기 때문이다. 물론 프록시 패턴도 인터페이스로 위임이 가능하다. 앞장에서 분리한 트랜잭션 코드는 디자인 패턴을 적용했다.

 

 

프록시 작성의 단점

프록시를 작성하는 작업은 타깃이 가진 메소드나 타깃 자체가 많아지면 매우 번거로워진다. 중복이 발생하기 때문이다. 다음과 같은 문제가 발생한다.

  1. 위임 코드를 모든 메소드에 일일이 만들어줘야함
  2. 부가기능 코드가 메소드나 다른 타깃에 중복됨

부가기능을 적용하지 않는 메소드는 그냥 위임만 하는데, 위임 코드를 일일이 만들어줘야한다. 타깃 인터페이스가 변경되거나 추가되면 함께 수정된다. 또 부가기능을 적용하는 메소드가 많아지면 부가기능 코드도 중복된다. (이부분은 메소드로 추출하면 될듯한데..) 또 비즈니스 로직을 담은 클래스가 새로 만들어져 타깃이 추가되면 그곳에도 중복되는 코드가 나타난다.

부가기능의 중복 문제는 중복되는 코드를 분리해서 어떻게든 해결할 수 있다. (템플릿-콜백 패턴을 적용하면 될듯) 하지만 메소드의 구현과 위임 기능은 동일한 코드가 반복되는 것이 아니기 때문에 단순한 리팩터링이나 패턴으로 해결하기 어렵다. 이런 문제를 해결하는 것이 바로 JDK의 다이나믹 프록시이다.

 

 

JDK 다이나믹 프록시

다이나믹 프록시는 런타임에 다이나믹하게 만들어지는 오브젝트이다. 컴파일 타임에 만들어지는 것이 아니므로 코드로 작성할 필요가 없다. 프록시 작성의 문제인 코드 중복을 해결하기 위한 솔루션이므로 런타임에 만들어지는 방법을 선택했다.

 

다이나믹 프록시는 프록시 팩토리에 의해 만들어지며, 이렇게 만들어진 프록시는 해당 인터페이스를 구현한 오브젝트를 자동으로 만들어준다. 프록시 팩토리에게 인터페이스 정보만 제공해주면 해당 인터페이스를 통해 사용할 수 있다. 부가기능 로직은 IncovationHandler에 담고, 프록시의 요청에 의해 부가기능을 적용한다.

 

다이나믹 프록시는 리플렉션을 사용해 구현한다. 위임 중복을 해결하기 어려웠던 이유가 호출하는 타깃 메소드가 다르기 때문에 중복 코드를 추출할 수 없던 것인데, 리플렉션의 Method 인터페이스로 추상화하면 위임 코드도 추상화되어 일관된 방법으로 위임 호출이 가능하다.

 

 

다이나믹 프록시 구현 예제

간단한 예제로 다이나믹 프록시를 구현하는 방법을 살펴보자.

우선 일반적인 프록시 작성의 문제가 드러나는 프록시 오브젝트이다. 이 프록시는 Hello 핵심 로직에서 반환하는 문자열을 대문자로 바꾸는 부가 기능을 가진다. 모든 메소드에서 위임과 부가기능 적용 코드가 중복된다.

public class HelloUppercase implements Hello {

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

 

 

타깃 위임과 부가기능 추가를 위한 InvokcationHandler을 구현하자. InvocationHandler는 추상 메소드를 딱 하나 가진 함수형 인터페이스이다. 다이나믹 프록시를 생성하는 부분에 InvocationHandler를 구현한 클래스를 넘기면 다이나믹 프록시가 받는 모든 요청을 추상 메소드인 invoke로 넘긴다. invoke는 Method에 담긴 메소드 메타정보와 타깃, 매개변수를 사용해 일관된 메시지로 타깃 위임을 사용할 수 있다. 무슨 말이냐면.. invoke는 메소드 이름에 따라 달라지는 메소드 호출 코드를 추상화한다.

 

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 && method.getName().startsWith("say")) { // 반환값이 문자열이면 대문자로 반환
            return ((String)ret).toUpperCase();
        } else {
            return ret; // 아니면 그대로
        }

    }
}

InvocationHandler는 단일 메소드에서 모든 요청을 처리하기 때문에, 부가기능을 적용할 메소드의 조건을 지정해야할 수도 있다. 위의 예시에서 부가기능은 문자열에 대해서만 적용 가능하므로 반환 타입이 문자열이고, 메소드 이름이 say로 시작하는 경우에만 부가기능을 적용하도록 제한했다.

 

이제 다이나믹 프록시를 생성해서 적용하는 테스트 코드를 작성해보자. Proxy.newProxyInstance로 프록시를 생성할 수 있다. 첫번째 인자로는 클래스 로더, 두번째 인자는 구현할 인터페이스, 세번째 인자는 InvocationHandler의 구현체를 넘겼다. 결과적으로 만들어진 다이나믹 프록시는 두번째 인자로 넘긴 인터페이스 타입의 클래스이므로 타입 변환이 가능하다. 다이나믹 프록시는 인터페이스를 통해서 사용할 수 있다.

public class ReflectionTest {

		@Test
    public void dynamicProxy() {
        Hello proxiedHello = (Hello) Proxy.newProxyInstance( // newProxyInstance에 의해 만들어지는 오브젝트는 Hello 타입
                getClass().getClassLoader(), // 다이나믹 프록시 클래스 로딩에 사용하는 클래스 로더
                new Class[] {Hello.class}, // 구현할 인터페이스
                new UppercaseHandler(new HelloTarget())); // InvocationHandler

        assertThat(proxiedHello.sayHello("Min"), is("HELLO MIN"));
        assertThat(proxiedHello.sayHi("Min"), is("HI MIN"));
        assertThat(proxiedHello.sayThankYou("Min"), is("THANK YOU MIN"));
    }

}

결과적으로 다이나믹 프록시 생성 기능을 사용하면, 중복으로 인해 컴파일 시점에 작성하기 번거로운 프록시 클래스를 런타임에 만들 수 있다. (1) 타깃 기능 위임은 invoke 메소드 하나만으로 가능하고, (2) 부가기능 적용시 메소드에서 중복되는 것 역시 invoke 메소드 하나에만 두기 때문에 중복 제거가 가능하다. 타깃이 많아져도 타깃의 타입은 Object로 추상화되므로 타깃까지 추상화된다. 이때 부가기능을 적용하는 메소드에는 조건 적용이 가능하다. 이로써 프록시 클래스 작성의 두가지 문제점을 추상화로 해결했다.

 

 

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

트랜잭션 부가기능과 UserService 기능 위임 역할을 맡는 InvocationHandler를 구현하자.

public class TransactionHandler implements InvocationHandler {

    Object target;
    PlatformTransactionManager transactionManager;
    String pattern;

    // setter ..

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

    }
}

TransactionHandler에서 살펴볼 곳은 다음과 같다.

  • Object target : 범용적인 타겟에 대해서 리플렉션 적용이 가능하다.
  • 부가기능 적용 조건 : pattern을 주입받아서 pattern으로 시작하는 메소드 이름을 가진 요청만 부가기능을 적용한다.
  • InvocationTargetException : invoke는 타깃 오브젝트에서 발생하는 예외를 InvocationTargetException으로 포장해서 전달한다. 일단 InvocationTargetException으로 받은 후 getTargetException() 메소드로 중첩된 예외를 가져와야 한다.

TransactionHandler를 사용하는 테스트 코드를 고쳐보자. 기존에는 UserServiceTx에 UserServiceImpl을 수동 DI해서 사용했는데, 이번에는 TransactionHandler를 생성하고, 리플렉션을 사용해서 트랜잭션 부가기능을 제공하는 프록시를 만들어서 사용하자.

public class UserServiceTest {

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

        // 부가기능과 기능위임을 담당하는 TransactionHandler 생성
        TransactionHandler txHandler = new TransactionHandler();
        txHandler.setTarget(testUserService);
        txHandler.setTransactionManager(transactionManager);
        txHandler.setPattern("upgradeLevels");
        // Proxy 생성
        **UserService txUserService = (UserService) Proxy.newProxyInstance(
                getClass().getClassLoader(), new Class[] {UserService.class}, txHandler);**

        userDao.deleteAll();
        for(User user: users) userDao.add(user);

        try {
            txUserService.upgradeLevels();
            fail("TestUserServiceException expected");
        } catch (TestUserServiceException e) {}

        checkLevelUpgraded(users.get(1), false);
    }

}

TransactionHandler는 기본 생성자를 사용해 수정자 주입으로 생성했다. 그리고 UserService 타입으로 반환되는 다이나믹 프록시는 Proxy의static 메소드인 newProxyInstance를 사용한다. 트랜잭션이 적용된 upgradeLevels를 사용하려면 다이나믹 프록시인 txUserService를 사용하면 된다.

 

 

다이나믹 프록시 DI를 위한 프록시 팩토리 빈

테스트에서 살펴보았듯이 클라이언트는 TransactionHandler과 다이나믹 프록시를 생성해야한다. TransactionHandler는 원래 하던 방식으로 xml 등의 설정 정보에 구현체를 적어주면, 스프링 DI 컨텍스트가 기본 생성자로 객체를 만들고 의존값을 주입해준다.

하지만 다이나믹 프록시는 생성자를 사용할 수 없다. Proxy의 스태틱 팩토리 메소드로만 생성 가능하다. 애초에 다이나믹 프록시의 오브젝트 클래스가 어떤 것인지 컴파일 시점에는 알 수 없다. 그래서 설정 정보로 다이나믹 프록시를 등록할 수 없다. 그러면 어떻게 스프링에게 다이나믹 프록시를 DI 해달라고 요청할 수 있을까?

스프링은 클래스 정보로 오브젝트를 만드는 방법 외에도 빈을 만들 수 있는 여러 방법을 제공한다. 대표적으로 팩토리 빈을 이용한 빈 생성이 있다. 팩토리 빈은 스프링을 대신해서 오브젝트의 생성로직을 담당하도록 만들어진 특별한 빈을 말한다.

스프링이 제공하는 FactoryBean 인터페이스를 구현한 클래스를 스프링 빈으로 등록하면 팩토리 빈으로 동작한다. 팩토리 빈의 getObject에서 스태틱 팩토리 메소드를 호출하여 생성된 오브젝트를 받을 수 있다. 그리고 팩토리 빈을 등록하면 다이나믹 프록시를 인터페이스로 DI 받을 수 있다.

 

트랜잭션 부가기능을 가진 프록시의 DI를 위한 팩토리 빈을 생성하고, 다음과 같이 팩토리 빈을 등록한다.

public class TxProxyFactoryBean implements FactoryBean<Object> { // 범용적 사용을 위해 Object로 지정

    **Object target;**
    PlatformTransactionManager transactionManager;
    String pattern;
    **Class<?> serviceInterface; // 다이나믹 프록시를 생성할 때 필요. UserService 외의 인터페이스를 가진 타깃도 적용 가능**

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

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

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

    public void setServiceInterface(Class<?> serviceInterface) {
        this.serviceInterface = serviceInterface;
    }

    @Override
    public Object getObject() throws Exception {
        TransactionHandler txHandler = new TransactionHandler();
        txHandler.setTarget(target);
        txHandler.setTransactionManager(transactionManager);
        txHandler.setPattern(pattern);

        return Proxy.newProxyInstance(
                getClass().getClassLoader(), new Class[] {**serviceInterface**}, txHandler);
    }

    @Override
    public Class<?> getObjectType() {
        return **serviceInterface**;
    }

    @Override
    public boolean isSingleton() {
        return false;
    }
}
<bean id="userService" class="springbook.user.service.TxProxyFactoryBean">
	<property name="target" ref="userServiceImpl" />
	<property name="transactionManager" ref="transactionManager"/>
	<property name="pattern" value="upgradeLevels"/>
	<property name="serviceInterface" value="springbook.user.service.UserService"/>
</bean>

 

스프링이 팩토리 빈으로 만들어주는 트랜잭션 다이나믹 프록시를 사용하는 테스트 코드를 작성하자. add는 트랜잭션을 사용하지 않으므로 TransactionHandler에 의해 기능만 위임된다. 반면 upgradeAllOrNothing는 트랜잭션 대상이므로 트랜잭션 처리 부가기능이 적용된다. 그런데 이 메소드의 타깃은 테스트에서 새로 정의한 TestUserService이다. 이미 생성된 다이나믹 프록시의 타깃을 변경하기는 어렵기 때문에 팩토리 빈을 가져와서 타깃을 변경한 후 다이나믹 프록시를 새로 생성해 사용한다.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration
public class UserServiceTest {

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

        // 컨텍스트에서 TxProxyFactoryBean을 직접 가져와서 타깃 변경
        // 변경된 타깃으로 다이나믹 프록시 다시 생성
        **TxProxyFactoryBean txProxyFactoryBean = applicationContext.getBean("&userService", TxProxyFactoryBean.class);
        txProxyFactoryBean.setTarget(testUserService);
        UserService txUserService = (UserService) txProxyFactoryBean.getObject();**

        userDao.deleteAll();
        for(User user: users) userDao.add(user);

        try {
            txUserService.upgradeLevels();
            fail("TestUserServiceException expected");
        } catch (TestUserServiceException e) {}

        checkLevelUpgraded(users.get(1), false);
    }
}

 

 

프록시 팩토리 빈의 장단점

프록시 팩토리 빈을 적용하면 다이나믹 프록시를 스프링 DI로 사용할 수 있게 된다. 다이나믹 프록시는 부가기능을 적용할 타깃을 Object 타입으로 받을 수 있기 때문에, 부가기능을 적용해야하는 타깃 클래스가 새로 만들어지면 프록시 팩토리 빈 설정만으로 부가기능 적용이 가능하다.

 

다이나믹 프록시를 사용하면 코드를 컴파일 시점에 작성하여 발생하는 기능 위임과 부가기능 적용 코드의 중복 문제를 해결할 수 있다. 프록시 팩토리 빈을 사용해 스프링 DI를 하면 번거로운 다이나믹 프록시 생성 코드를 제거하고 스프링 컨텍스트로부터 빈을 주입받을 수 있다.

하지만 프록시 팩토리 빈을 사용하는 방법은 타깃과 부가기능이 많아질수록 확장이 어려워진다. 그 이유는 InvokeHandler 구현체가 기능 위임, 부가기능을 적용할 메소드 선택, 부가기능 실행 등 복합적인 책임을 가지고 있기 때문이다. 적용할 타깃이 추가됨에 따라 부가기능 선택, 실행이 달라질텐데, 타깃 선택과 부가기능이 강하게 결합되어있기 때문에 확장에 불리하다.

 

그래서 설정 파일에 팩토리 빈을 등록할 때 InvokeHandler가 필요로 하는 의존성을 넣어주기 위해 타깃과 부가기능에 필요한 의존성을 함께 등록해야한다. 타깃과 부가기능이 많아지면 타깃만 다르고 부가기능은 다른 설정 정보가 늘어날 것이다. (그 반대도 마찬가지다) 그리고 그 설정 정보를 사용하는 InvokeHanlder 구현체의 개수는 타깃과 부가기능 조합에 비례하여 늘어날 것이다.

정리하자면, 지금은 InvokeHandler가 다양한 책임을 가지고 있어 타깃과 부가기능의 구현체에 의존한다. 그래서 타깃과 부가기능의 조합에 따라 다른 InvokeHandler 구현체가 필요하고, 설정 정보도 중복된다. 이런 중복 문제를 해결할 방법은 없을까? 스프링이 추상화해놓은 다이나믹 프록시 생성 방법이 있을까?

 

 

(다음 포스팅에서 계속..)