반응형
반응형

1. 개요

 다이내믹 프록시에 대해 이해한다.

 


2. 프록시란?

 먼저 프록시가 뭘까? 프록시에 대해 알아보기 전 이전 스터디때 배웠던 UserService의 트랜잭션 기능을 회고해보았다.

 

2.1. UserService의 트랜잭션 기능 회고

 비지니스 로직에 대해 얼마의 처리 시간이 걸렸는지에 대한 로깅처리, 트랜잭션 처리와 같은 부가 기능을 추가하기 위해 인터페이스를 통한 추상화 방법을 사용할 수 있다. 앞선 스터디에서 UserService에 트랜잭션 기능을 추가하기 위해 인터페이스를 통한 추상화 방법을 적용하였고, 아래와 같이 부가기능과 핵심기능을 분리하는 구조가 되었다.

UserService에 대한 Transaction 기능 추가 추상화

 

 

2.2. '핵심인 척' 하는 '부가기능'

 이러한 구조가 가지는 중요한 특징이 있는데, 부가 기능 수행 후 핵심 기능을 가진 클래스로 요청을 위임해줘야 한다는 것과, 핵심기능은 부가기능을 가진 클래스의 존재 자체를 몰라야 한다는 것이다. 존재 자체를 모르는 것은 런타임시 DI를 통한 추상화 때문이다.

 클라이언트도 인터페이스를 통해 서비스를 호출하기 때문에 실제로 어떤 구현체를 사용하는지 모른다. 핵심기능을 호출한다고 생각할 뿐이다. 부가기능(UserServiceTx)을 통해 핵심기능(UserServiceImpl)을 사용하고 있는데 말이다. 즉, 부가기능 클래스(UserServiceTx)는 의도치않게 클라이언트를 속여 '핵심인 척' 하고있다.

 

2.3. 그래서 프록시가 뭔가요?

 이렇듯 자신이 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 요청을 받아주는 오브젝트를 프록시라고 한다. 추가로 프록시를 통해 최종적으로 요청을 위임받아 핵심 기능을 처리하는 오브젝트를 타겟이라고 한다. 앞서 구성했던 Client, UserServiceTx, UserServiceImpl 구조를 아래와 같이 프록시와 타겟으로 표현할 수 있다.

프록시 관점으로 본 UserService 구조

 

2.4. 프록시의 목적

 프록시의 목적은 크게 두가지이다. 첫째는 클라이언트가 타겟에 접근하는 방법을 제어하기 위해서, 두 번째는 타겟에 부가적인 기능을 부여하기 위해서이다. 각각의 목적에 따라 디자인 패턴에서는 다른 패턴으로 구분한다.

 

2.5. 데코레이터 패턴

 데코레이터 패턴은 타겟에 부가적인 기능을 런타임 시 다이나믹하게 부여하기 위해 프록시를 사용하는 패턴이다. 핵심 기능은 그대로 두고 부가 기능, 즉 데코레이션만 추가하는 것이다.

 부가기능은 하나일수도, 여러개일수도 있다. 때문에 이 패턴에서는 프록시가 한 개로 제한되지 않는다.

 UserService 인터페이스를 구현한 타겟인 UserServiceImpl에 트랜잭션 부가기능을 제공하는 UserServiceTx를 추가한 것도 데코레이터 패턴을 적용한 것이다.

 데코레이터 패턴은 인터페이스를 통해 위임하는 방식이기 때문에 어느 데코레이터에서 타겟으로 연결될지는 코드 레벨에선 알 수 없다. DI 설정에 따라 다이나믹하게 구성되게 때문이다.

 이 패턴은 타겟 코드의 수정도 없고, 클라이언트 호출 방법도 변경하지 않은 채 새로운 기능을 추가할 때 유용한 방법이다.

 

2.5. 프록시 패턴

 프록시 패턴의 프록시는 타겟에 대한 접근 방법을 제어하는 의미의 프록시이다. 즉, 클라이언트가 타겟에 접근하는 방식을 변경해주는 패턴이다.

 클라이언트에게 타겟에 대한 래퍼런스를 넘길 때 실제 타겟이 아닌 프록시를 넘겨주는 것이다. 그리고 프록시의 메서드를 통해 타겟을 사용하려고 시도하면, 그때 프록시가 타겟 오브젝트를 생성하고 요청을 위임해준다.

 이 방식의 장점은 해당 객체가 메모리에 존재하지 않아도 프록시를 통해 정보를 참조할 수 있고, 타겟이 반드시 필요한 시점까지 타겟 객체의 생성을 미뤄 메모리를 사용 시점을 늦출 수 있다.

 

2.6. 프록시는 어디에 쓰나요?

 프록시는 기존 코드에 영향을 주지 않으면서 기능을 확장하거나 접근 방법을 제어한다. 그럼에도 불구하고 많은 개발자는 타겟 코드를 고치고 말지 번거롭게 프록시를 만들지는 않는다고 한다.

 그 이유는 프록시를 만드려면 부가 기능을 추가하고자 할때마다 새로운 클래스를 정의해야 하고, 인터페이스의 구현 메서드가 많다면 모든 메서드에 일일히 구현하고 위임하는 코드를 넣어야 한다.

 UserService가 아닌 ProductService가 있다고 가정하고, 여기에 트랜잭션 부가기능을 처리하는 프록시를 통해 구성한다고 하자. ProductServiceTx 클래스를 만들고, 구현해야할 메서드마다 트랜잭션 로직과 핵심 기능을 담당하는ProductServiceImpl를 만들어 위임하는 로직을 넣어야 한다. 메서드 양이 많아지면 작업량도 많아지고, 중복코드도 많아진다.

 이러한 문제점들을 해결하여 좀더 간단하게 프록시를 구성하는 방법이 있을까? 있다! 그게 바로 다이나믹 프록시이다.

 


3. 다이나믹 프록시

3.1. 다이나믹 프록시란?

 다이니믹 프록시는 런타임 시점프록시를 자동으로 만들어서 적용해주는 기술이다. 자바에서는 리플렉션 기능을 사용해서 프록시를 만드는 JDK 다이나믹 프록시를 사용한다.

 

 

3.2. 리플렉션이란?

 리플렉션에 대한 내용은 따로 포스팅하였다. 다이나믹 프록시의 기반 기술이기도 하지만 스프링 프레임워크의 기반 기술이기도 하기에 이 개념에 대해 잘 모른다면 이해해보길 권장한다.

 

https://tlatmsrud.tistory.com/112

 

String name = "Spring";

 

 위 문자열의 길이를 알고 싶다면 어떻게 할까? 단순히 length 메서드를 호출해도 되지만 리플렉션 기능을 사용해도 된다.

 

Method lengthMethod = String.class.getMethod("length");

 

 리플렉션을 통해 String 클래스의 length라는 메서드 정보를 가져온다. 메서드를 실행시키려면 Method.invoke() 메서드를 사용하면 된다.

int length = lengthMethod.invoke(name); // == name.length()

 

 아래는 간단한 리플렉션 테스트이다.

@Test
public void invokeMethod() throws Exception{
    String name = "Spring";

    assertThat(name.length()).isEqualTo(6);

    Method lengthMethod = String.class.getMethod("length");
    assertThat((Integer)lengthMethod.invoke(name)).isEqualTo(6);

    assertThat(name.charAt(0)).isEqualTo('S');
    Method charAtMethod = String.class.getMethod("charAt", int.class); // method name, parameterType ...
    assertThat((Character) charAtMethod.invoke(name,0)).isEqualTo('S');
}

 

결론은 리플렉션을 사용하면 런타임시에 클래스에 대한 정보를 가져올 수 있고, 클래스의 메서드를 호출할 수 있는 것이다. 그럼 이 클래스의 메서드를 호출하기 전, 후에 부가기능을 추가한다면 앞서 배웠던 프록시 클래스를 만들어 적용했던 것과 동일하게 동작시킬수도 있는 것이다.

 

 

3.3. 프록시 클래스

 프록시 객체를 이용한 방법과 다이나믹 프록시를 이용한 방법의 차이를 느끼기 위해 먼저 데코레이터 패턴을 적용해보자. 패턴을 만들기 위해 타겟 클래스와 인터페이스, 부가기능 클래스를 정의했다.

 

3.2.1. Hello.java

 프록시 인터페이스 클래스이다.

public interface Hello {
    String sayHello(String name);
    String sayHi(String name);
    String sayThankYou(String name);
}

 

3.2.2. HelloTarget.java

 핵심 기능을 처리하는 타겟 오브젝트이다.

public class HelloTarget implements Hello{

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

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

    @Override
    public String sayThankYou(String name) {
        return "Thank You "+name;
    }
}

 

3.2.3. Test.java

 간단한 테스트 코드이다.

@Test
public void simpleProxy(){
    Hello hello = new HelloTarget(); // 타깃은 인터페이스를 통해 접근
    assertThat(hello.sayHello("Sim")).isEqualTo("Hello Sim");
    assertThat(hello.sayHi("Sim")).isEqualTo("Hi Sim");
    assertThat(hello.sayThankYou("Sim")).isEqualTo("Thank You Sim");
}

 

일단은 부가기능은 넣지 않고 타겟클래스와 인터페이스만 사용하였다. 이제 문자열을 대문자로 치환하는 부가기능을 추가한 프록시를 만들어보자.

 

3.2.4. HelloUppercase.java

public class HelloUppercase implements Hello{

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

 멤버 필드로 정의된 Hello는 타겟 클래스로 HelloTarget을 참조하도록 해야한다.

 

3.2.5. Test.java

@Test
public void simpleProxy(){
    Hello hello = new HelloTarget(); // 타겟 오브젝트 생성
    Hello proxyHello = new HelloUppercase(hello); // 프록시 오브젝트 생성 및 의존성 주입
    assertThat(proxyHello.sayHello("Sim")).isEqualTo("HELLO SIM");
    assertThat(proxyHello.sayHi("Sim")).isEqualTo("HI SIM");
    assertThat(proxyHello.sayThankYou("Sim")).isEqualTo("THANK YOU SIM");
}

 이로써 데코레이터 패턴을 적용한 프록시 구조가 만들어졌다. 부가 기능을 하는 HelloUppercase 코드를 보면 프록시의 문제점인 부가기능(대문자로 치환)과 위임(hello.method) 코드가 중복되는 것을 알 수 있다.

 이제 다이나믹 프록시를 적용하여 이 문제를 해결해보자.

 

3.3. 다이나믹 프록시 적용

 다이나믹 프록시는 프록시 팩토리에 의해 런타임 시 다이나믹하게 만들어지는 오브젝트이다. 다이나믹 프록시의 오브젝트는 타겟의 인터페이스와 같은 타입으로 자동으로 만들어진다. 다이나믹 프록시는 오브젝트를 타겟 인터페이스를 통해 사용할 수 있다.

 부가기능 제공 코드는 InvocationHandler를 구현한 오브젝트로 생성해야 한다.

 

3.3.1. UppercaseHandler.java

 타겟 메서드를 실행한 결과 값이 String이거나 해당 메서드명이 say로 시작할 경우 대문자로 변환하는 부가기능을 추가하였다.

public class UppercaseHandler implements InvocationHandler {

    private 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); // invoke를 통해 target 오브젝트의 method 실행

        if(ret instanceof String && method.getName().startsWith("say")){ // 문자열 타입이거나 say로 시작하는 메서드일 경우
            return ((String)ret).toUpperCase();
        }
        return ret;
    }
}

 

3.3.2. Test.java

 Hello 인터페이스에 대한 부가기능을 UppercaseHandler로 설정함과 동시에 타겟 오브젝트를 HelloTarget으로 설정하였다. 데코레이터 패턴에서 부가기능을 처리하기 위해 구현했던 HelloUppercase 클래스가 필요하지 않게 되었고, 이 클래스가 안고 있던 부가기능 로직과 위임 로직의 중복이 모두 해결되었다.

@Test
public void dynamicProxy(){
    Hello proxyHello = (Hello) Proxy.newProxyInstance(
            getClass().getClassLoader(), // 다이나믹 프록시를 정의하는 클래스 로더
            new Class[] {Hello.class}, // 다이나믹 프록시가 구현해야할 인터페이스
            new UppercaseHandler(new HelloTarget())); // 부가기능 및 위임 코드를 담는 InvocationHandler 구현 클래스

    assertThat(proxyHello.sayThankYou("Sim")).isEqualTo("THANK YOU SIM");
}

 


4. 트랜잭션 기능 리팩토링

 이제 트랜잭션 기능을 넣기 위해 사용했던 전략 패턴 대신 다이나믹 프록시를 사용하도록 변경해보자. 먼저 부가기능을 처리하는 InvocationHandler 구현체 클래스를 구현하자.

 

4.1. TransactionHandler.java

public class TransactionHandler implements InvocationHandler {
    private Object target;
    private PlatformTransactionManager transactionManager;
    private String pattern;

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

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

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

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

        // 메서드 명이 pattern 으로 시작할 경우
        if(method.getName().startsWith(pattern)){
            return invokeInTransaction(method, args); // 트랜잭션 기능과 함께 메서드 실행
        }
        return method.invoke(target, args); // 트랜잭션 기능 없이 메서드 실행
    }

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

        // 트랜잭션 생성
        TransactionStatus status = this.transactionManager
                .getTransaction(new DefaultTransactionDefinition());

        try{
            // 메서드 실행
            Object ret = method.invoke(target, args);

            // 트랜잭션 commit
            this.transactionManager.commit(status);
            return ret;
        } catch (InvocationTargetException e) {

            // 타겟 메서드 실행 중 예외 발생 시 트랜잭션 rollback
            this.transactionManager.rollback(status);
            throw e.getTargetException();
        }
    }
}

 메서드 명이 pattern으로 시작할 경우 트랜잭션 기능 사이에 타겟 메서드를 호출하는 invokeInTransaction() 메서드를 호출한다. 내부에서는 리플렉션을 사용하여 타겟 오브젝트에 대한 메서드를 실행한다. 예외가 발생할 경우 롤백이, 발생하지 않을 경우 커밋이 된다.

 그리고 다이나믹 프록시를 통한 타겟 메서드 호출 시 예외가 발생할 경우 InvocationTargetException 예외 안에 타겟 메서드에서 발생한 예외가 포장되므로 이에 맞게 예외 처리 로직을 수정한다.

 

4.2. 테스트

@ExtendWith(SpringExtension.class)
@ContextConfiguration(locations = "/test-applicationContext.xml")
public class DynamicProxyUserServiceTest {

    @Autowired
    private UserServiceImpl userService;

    @Autowired
    private PlatformTransactionManager transactionManager;

    @SpyBean
    private IUserDao userDao;

    @SpyBean
    private DefaultUserLevelUpgradePolicy userLevelUpgradePolicy;

    private final List<User> users = Arrays.asList(
            new User("test1","테스터1","pw1", Level.BASIC, 49, 0, "tlatmsrud@naver.com"),
            new User("test2","테스터2","pw2", Level.BASIC, 50, 0, "tlatmsrud@naver.com"),
            new User("test3","테스터3","pw3", Level.SILVER, 60, 29, "tlatmsrud@naver.com"),
            new User("test4","테스터4","pw4", Level.SILVER, 60, 30, "tlatmsrud@naver.com"),
            new User("test5","테스터5","pw5", Level.GOLD, 100, 100, "tlatmsrud@naver.com")
    );
    @BeforeEach
    void setUp(){
        userService.setUserDao(userDao);
        userService.setUserLevelUpgradePolicy(userLevelUpgradePolicy);

        given(userDao.getAll()).willReturn(users);

        willThrow(new RuntimeException()).given(userLevelUpgradePolicy).upgradeLevel(users.get(3));
    }

    @Test
    void upgradeAllOrNothingWithNoProxy(){

        // 테이블 데이터 초기화
        userDao.deleteAll();

        // 테이블에 유저 정보 insert
        users.forEach(user -> userDao.add(user));

        // 유저 레벨 업그레이드 메서드 실행 및 예외 발생 여부 확인 (setUp 메서드에 4번째 유저 업그레이드 처리 시 예외 발생하도록 스터빙 추가)
        assertThatThrownBy(() -> userService.upgradeLevels())
                .isInstanceOf(RuntimeException.class);

        // DB
        assertThat(userDao.get("test1").getLevel()).isEqualTo(Level.BASIC);
        assertThat(userDao.get("test2").getLevel()).isEqualTo(Level.SILVER); // 트랜잭션이 적용되지 않아 BASIC 레벨로 롤백되지 않음.
        assertThat(userDao.get("test3").getLevel()).isEqualTo(Level.SILVER);
        assertThat(userDao.get("test4").getLevel()).isEqualTo(Level.SILVER);
        assertThat(userDao.get("test5").getLevel()).isEqualTo(Level.GOLD);

    }

    @Test
    void upgradeAllOrNothingWithProxy(){

        // 부가기능 핸들러 객체 생성
        TransactionHandler txHandler = new TransactionHandler();
        txHandler.setTarget(userService);
        txHandler.setTransactionManager(transactionManager);
        txHandler.setPattern("upgradeLevels");

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

        // 테이블 데이터 초기화
        userDao.deleteAll();

        // 테이블에 유저 정보 insert
        users.forEach(user -> userDao.add(user));

        assertThatThrownBy(() -> proxyUserService.upgradeLevels())
                .isInstanceOf(RuntimeException.class);

        assertThat(userDao.get("test1").getLevel()).isEqualTo(Level.BASIC);
        assertThat(userDao.get("test2").getLevel()).isEqualTo(Level.BASIC); // 트랜잭션이 적용되지 않아 BASIC 레벨로 롤백됨.
        assertThat(userDao.get("test3").getLevel()).isEqualTo(Level.SILVER);
        assertThat(userDao.get("test4").getLevel()).isEqualTo(Level.SILVER);
        assertThat(userDao.get("test5").getLevel()).isEqualTo(Level.GOLD);
    }
}

 users 필드에 설정한 테스트 픽스처에 대해 두가지 테스트를 진행하도록 하였다.

upgradeAllOrNothingWithNoProxy 테스트는 다이나믹 프록시를 적용하지 않은 케이스로 UserService의 구현체 클래스를 직접 호출하고 있어 트랜잭션이 적용되지 않는다. upgradeAllOrNothingWithProxy는 다이나믹 프록시를 적용한 케이스로 트랜잭션이 적용되어 있다.

 

  setUp 메서드에서  4번째 유저의 업그레이드 시 예외가 발생하도록 스터빙 처리하였기에, 첫번째 테스트 케이스에서는 예외가 발생해도 이전 업그레이드 처리 된 유저에 대해서 롤백이 되지 않는지를 체크했고, 두번째 테스트 케이스에서는 예외가 발생할 경우 롤백이 되는지를 체크했다. 테스트 결과는 성공적이었다.

테스트 결과

 

반응형

+ Recent posts