토비의 스프링 Chapter 6.3.1 ~ 6.3.3 정리

종명·2021년 5월 7일
0

다이내믹 프록시와 팩토리 빈

프록시와 프록시 패턴, 데코레이터 패턴

  • 단순히 확장성을 고려해서 한 가지 기능을 분리한다면 전형적인 전략 패턴을 사용하면 된다. 하지만 전략 패턴으로는 트랜잭션 기능의 구현 내용을 분리해냈을 뿐이다. 트랜잭션을 적용한다는 사실은 코드에 그대로 남아 있다. 구체적인 구현 코드는 제거했을지라도 위임을 통해 기능을 사용하는 코드는 핵심 코드와 함께 남아 있다.
  • 트랜잭션이라는 기능은 사용자 관리 비즈니스 로직과는 성격이 다르기 때문에 아예 그 적용 사실 자체를 밖으로 분리할 수 있다. UserServiceTx를 만들어 부가기능 전부를 핵심 코드가 담긴 UserServiceImpl에서 분리해 냈다.
  • 이렇게 분리된 부가기능을 담은 클래스는 중요한 특징이 있다. 부가기능 외의 나머지 모든 기능은 원래 핵심기능을 가진 클래스로 위임해줘야 한다. 핵심기능은 부가기능을 가진 클래스의 존재 자체를 모른다. 따라서 부가기능이 핵심기능을 사용하는 구조가 되는 것이다.
  • 문제는 이렇게 구성했더라도 클라이언트가 핵심기능을 가진 클래스를 직접 사용해버리면 부가기능이 적용될 기회가 없다는 점이다. 그래서 부가기능은 마치 자신이 핵심기능을 가진 클래스인 것처럼 꾸며서, 클라이언트가 자신을 거쳐서 핵심기능을 사용하도록 만들어야 한다. 그러기 위해서는 클라이언트는 인터페이스를 통해서만 핵심기능을 사용하게 하고, 부가기능 자신도 같은 인터페이스를 구현한 뒤에 자신이 그 사이에 끼어들어야 한다.
  • 이렇게 마치 자신이 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 클라이언트의 요청을 받아주는 것을 대리자, 대리인과 같은 역할을 한다고 해서 프록시(proxy)라고 부른다. 그리고 프록시를 통해 최종적으로 요청을 위임받아 처리하는 실제 오브젝트를 타깃(target) 또는 실체(real subject)라고 부른다.

프록시는 사용 목적에 따라 두 가지로 구분할 수 있다.

  • 첫째는 클라이언트가 타깃에 접근하는 방법을 제어하기 위해서다.
  • 두 번째는 타깃에 부가적인 기능을 부여해주기 위해서다.

두 가지 모두 대리 오브젝트라는 개념의 프록시를 두고 사용한다는 점은 동일하지만, 목적에 따라서 디자인 패턴에서느 다른 패턴으로 구분한다.

데코레이터 패턴

  • 데코레이터 패턴은 타깃에 부가적인 기능을 런타임 시에 다이내믹하게 부여해주기 위해 프록시를 사용하는 패턴을 말한다.
  • 다이내믹하게 기능을 부여한다는 의미는 컴파일 시점, 즉 코드상에서는 어떤 방법과 순서로 프록시와 타깃이 연결되어 사용되는지 정해져 있지 않다는 뜻이다.
  • 이 패턴이 데코레이터라고 불리는 이유는 마치 제품이나 케익 등을 여러 겹으로 포장하고 그 위에 장식을 붙이는 것처럼 실제 내용물은 동일하지만 부가적인 효과를 부여해 줄 수 있기 때문이다.
  • 데코레이터 패턴에서는 프록시가 꼭 한 개로 제한되지 않는다.
  • 프록시가 직접 타깃을 사용하도록 고정시킬 필요도 없다.
  • 데코레이터 패턴에서는 같은 인터페이스를 구현한 타겟과 여러 개의 프록시를 사용할 수 있다.
  • 프록시가 여러 개인 만큼 순서를 정해서 단계적으로 위임하는 구조로 만들면 된다.

클라이언트 -> 라인넘버 데코레이터 -> 컬러 데코레이터 -> 페이징 데코레이터 -> 소스 코드 출력 기능

  • 프록시로서 동작하는 각 데코레이터는 위임하는 대상에도 인터페이스로 접근하기 때문에 자신이 최종 타깃으로 위임하는지, 아니면 다음 단계의 데코레이터 프록시로 위임하는지 알지 못한다. 그래서 데코레이터의 다음 위임 대상은 인터페이스로 선언하고 생성자나 수정자 메소드를 통해 위임 대상을 외부에서 런타임 시에 주입받을 수 있도록 만들어야 한다.

자바 IO 패키지의 InputStream, OutputStream 구현 클래스는 데코레이터 패턴의 대표적인 예이다.

InputStream is = new BufferedInputStream(new FileInputStream("a.txt"));
  • UserService 인터페이스를 구현한 타킷인 UserServiceImpl에 트랜잭션 부가기능을 제공해주는 UserServiceTx를 추가한 것도 데코레이터 패턴을 적용한 것이라고 볼 수 있다.

  • 인터페이스를 통한 데코레이터 정의와 런타임 시의 다이내믹한 구성 방법은 스프링의 DI를 이용하면 아주 편리하다. 데코레이터 빈의 프로퍼티로 같은 인터페이스를 구현한 다른 데코레이터 또는 타깃 빈을 설정하면 된다.

  • 데코레이터 패턴은 타깃의 코드를 손대지 않고, 클라이언트가 호출하는 방법도 변경하지 않은 채로 새로운 기능을 추가할 때 유용한 방법이다.

프록시 패턴

  • 일반적으로 사용하는 프록시라는 용어와 디자인 패턴에서 말하는 프록시 패턴은 구분할 필요가 있다. 전자는 클라이언트와 사용 대상 사이에 대리 역할을 맡은 오브젝트를 두는 방법을 말한다면, 후자는 프록시를 사용하는 방법 중에서 타깃에 대한 접근 방법을 제어하려는 목적을 가진 경우를 가리킨다.
  • 프록시 패턴의 프록시는 타깃의 기능을 확장하거나 추가하지 않는다. 대신 클라이언트가 타깃에 접근하는 방식을 변경해준다.
  • 프록시 패턴은 타깃의 기능 자체에는 관여하지 않으면서 접근하는 방법을 제어해주는 프록시를 이용하는 것이다. 구조적으로 보자면 프록시와 데코레이터는 유사하다. 다만 프록시는 코드에서 자신이 만들거나 접근할 타깃 클래스 정보를 알고 있는 경우가 많다. 생성을 지연하는 프록시라면 구체적인 생성 방법을 알아야 하기 때문에 타깃 클래스에 대한 직접적인 정보를 알아야 한다. 물론 프록시 패턴이라고 하더라도 인터페이스를 통해 위임하도록 만들 수도 있다. 인터페이스를 통해 다음 호출 대상으로 접근하게 되면 그 사이에 다른 프록시나 데코레이터가 계속 추가될 수 있기 때문이다.

클라이언트 -> 접근제어 프록시 -> 컬러 데코레이터 -> 페이징 데코레이터 -> 소스코드 출력 기능

다이내믹 프록시

  • 프록시는 기존 코드에 영향을 주지 않으면서 타깃의 기능을 확장하거나 접근 방법을 제어할 수 있는 유용한 방법이다. 하지만 매번 프록시를 만드는 것은 매우 번거롭다. (매번 새로운 클래스를 정의하고, 인터페이스의 구현해야 할 메소드가 많으면 모든 메소드를 구현하고 위임하는 코드를 넣어야 한다.)
  • 목 오브젝트를 만드는 불편함을 목 프레임워크를 사용해 편리하게 바꿨던 것처럼 프록시도 일일이 모든 인터페이스를 구현해서 클래스를 새로 정의하지 않고도 편리하게 만들어서 사용할 방법은 없을까?
  • 물론 자바에는 java.lang.reflect 패키지 안에 프록시를 쉽게 만들 수 있도록 지원해주는 클래스들이 있다. 기본적인 아이디어는 목 프레임워크와 비슷하다. 일일이 프록시 클래스를 정의 하지 않고도 몇가지 API를 이용해 프록시처럼 동작하는 오브젝트를 다이내믹하게 생성하는 것이다.

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

프록시는 다음의 두 가지 기능으로 구성된다.

  • 타깃과 같은 메소드를 구현하고 있다가 메소드가 호출되면 타깃 오브젝트로 위임한다.
  • 지정된 요청에 대해서는 부가기능을 수행한다.
public class UserServiceTx implements UserService {
    UserService userService;
    ...
    
    // 메소드 구현과 위임
    public void add(User user) {
        this.userService.add(user); 
    }
    
    // 메소드 구현
    public void upgradeLevels() {
        // 부가 기능 수행
        TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            // 위임
            userService.upgradeLevels(); 
            
        // 부가 기능 수행
            this.transactionManager.commit(status);
        } catch(RuntimeException e) {
            this.transactionManage.rollback(status);
            throw e;
        }
    }
    
}

프록시를 만들기가 번거로운 이유는 두 가지가 있다.

  • 첫째는 타깃의 인터페이스를 구현하고 위임하는 코드를 작성하기가 번거롭다는 점이다. 부가기능이 필요 없는 메소드도 구현해서 타깃으로 위임하는 코드를 일일이 만들어줘야 한다. 또, 타깃 인터페이스의 메소드가 추가되거나 변경될 때마다 함께 수정해줘야 한다는 부담도 있다.
  • 두 번째 문제점은 부가기능 코드가 중복될 가능성이 많다는 점이다.

두 번째 문제인 부가기능의 중복 문제는 중복되는 코드를 분리해서 어떻게든 해결해 보면 될 것 같지만, 첫 번째 문제인 인터페이스 메소드의 구현과 위임 기능 문제는 간단해 보이지 않는다. 바로 이런 문제를 해결하는 데 유용한 것이 바로 JDK의 다이내믹 프록시다.

리플렉션

  • 다이내믹 프록시는 리플렉션 기능을 이용해서 프록시를 만들어준다. 리플렉션은 자바의 코드 자체를 추상화해서 접근하도록 만든 것이다.
  • 자바의 모든 클래스는 그 클래스 자체의 구성정보를 담은 Class 타입의 오브젝트를 하나씩 갖고 있다. ‘클래스이름.class’라고 하거나 오브젝트의 getClass() 메소드를 호출하면 클래스 정보를 담은 Class 타입의 오브젝트를 가져올 수 있다. 클래스 오브젝트를 이용하면 크래스 코드에 대한 메타정보를 가져오거나 오브젝트를 조작할 수 있다.

다음은 리플렉션 학습테스트이다.

package springbook.learningtest.jdk;
...
public class ReflectionTest {
    @Test
    public void invokeMethod() throws Exception() {
        String name = "Spring";
        
        // length()
        assertThat(name.length(), is(6));
        
	Method lengthMethod = String.class.getMethod("length");
        assertThat((Integer)lengthMethod.invoke(name), is(6));
        
        // charAt()
        assertThat(name.charAt(0), is('S'));
        
        Method charAtMethod = String.class.getMethod("charAt", int.class);
        assertThat((Character)charAtMethod.invoke(name, 0), is('S'));
    }
}

프록시 클래스

다이내믹 프록시를 이용한 프록시를 만들어보자.

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

타깃클래스

public class HelloTarget implements Hello {
    public String sayHello(String name){
        return "Hello " + name;
    }
    public String sayHi(String name){
        return "Hi " + name;
    }
    public String sayThankYou(String name){
        return "Thank you " + name;
    }
}

클라이언트 역할의 테스트

@Test
public void simpleProxy() {
    Hello hello = new HelloTarget();
    asserThat(hello.sayHello("Toby"), is("Hello Toby"));
    asserThat(hello.sayHi("Toby"), is("Hi Toby"));
    asserThat(hello.sayThankYou("Toby"), is("Thank you Toby"));
}

Hello 인터페이스를 구현한 프록시를 만들어보자. 프록시에는 데코레이터패턴을 적용해 타깃인 HelloTarget에 부가기능을 추가하겠다.

public class HelloUppercase implements Hello {
    // 위임할 타깃 오브젝트, 여기서는 타깃 클래스의 오브젝트인 것은 알지만 다른 프록시를 추가할 수도 있으므로 인터페이스로 접근한다.
    Hello hello;
    
    public HelloUppercase(Hello hello){
        this.hello = hello;
    }

    public String sayHello(String name){
        // 위임과 부가기능 적용
        return hello.sayHello(name).toUpperCase(); 
    }
    
    public String sayHi(String name){
        // 위임과 부가기능 적용
        return hello.sayHi(name).toUpperCase();
    }
    
    public String sayThankYou(String name){
        // 위임과 부가기능 적용
        return hello.sayThankYou(name).toUpperCase();
    }
}

HelloUppercase 프록시 테스트

@Test
public void simpleProxy() {
    HelloUppercase proxyHello = new HelloUppercase(new HelloTarget());
    asserThat(proxyHello.sayHello("Toby"), is("HELLO TOBY"));
    asserThat(proxyHello.sayHi("Toby"), is("HI TOBY"));
    asserThat(proxyHello.sayThankYou("Toby"), is("THANK YOU TOBY"));
}

다이내믹 프록시 적용

  • 다이내믹 프록시는 프록시 팩토리에 의해 런타임 시 다이내믹하게 만들어지는 오브젝트다.
  • 다이내믹 프록시 오브젝트는 타깃의 인터페이스와 같은 타입으로 만들어진다.
  • 클라이언트는 다이내믹 프록시 오브젝트를 타깃 인터페이스를 통해 사용할 수 있다.
  • 이 덕분에 프록시를 만들 때 인터페이스를 모두 구현해가면서 클래스를 정의하는 수고를 덜 수 있다. 프록시 팩토리에게 인터페이스 정보만 제공해주면 해당 인터페이스를 구현한 클래스의 오브젝트를 자동으로 만들어주기 때문이다.

다이내믹 프록시가 인터페이스 구현 클래스의 오브젝트는 만들어주지만, 프록시로서 필요한 부가기능 제공 코드는 직접 생성해야 한다. 부가기능은 프록시 오브젝트와 독립적으로 InvocationHandler를 구현한 오브젝트에 담는다. InvocationHandler 인터페이스는 다음과 같은 메소드 한 개만 가진 간단한 인터페이스다.

public Object invoke(Object proxy, Method method, Object[] args)

invoke() 메소드는 리플렉션의 Method 인터페이스를 파라미터로 받는다. 메소드를 호출할 때 전달되는 파라미터도 args로 받는다. 다이내믹 프록시 오브젝트는 클라이언트의 모든 요청을 리플렉션 정보로 변환해서 InvocationHandler 구현 오브젝트의 invoke() 메소드로 넘기는 것이다. 타깃 인터페이스의 모든 메소드 요청이 하나의 메소드로 집중되기 때문에 중복되는 기능을 효과적으로 제공할 수 있다.

다이내믹 프록시로부터 메소드 호출정보를 받아서 처리하는 InvocationHandler를 만들어보자. 아래는 모든 요청을 타깃에 위임하면서 리턴값을 대문자로 바꿔주는 부가기능을 가진 InvocationHandler 구현 클래스이다.

public class UppercaseHandler implements InvocationHandler {
    // 다이내믹 프록시로부터 전달받은 요청을 다시 타깃 오브젝트에 위임해야 하기 때문에 타깃 오브젝트를 주입받아둔다.
    Hello target;

    private UppercaseHandler(Object target) {
        this.target = target;
    }

    public Object invoke(Object proxy, Method method, Object[] args) thorws Throwable
    {
        String ret = method.invoke(target, args);
        return ret.toUpperCase();
    }
}

프록시 생성

// 생성된 다이내믹 프록시 오브젝트는 Hello 인터페이스를 구현하고 있으므로 Hello 타입으로 캐스팅해도 안전하다.
Hello proxiedHello = (Hello)Proxy.newProxyInstance(
	getClass().getClassLoader(), // 동적으로 생성되는 다이내믹 프록시 클래스의 로딩에 사용할 클래스 로더
    new Class[] { Hello.class }, // 구현할 인터페이스
    new UppercaseHandler(new HelloTarget())); // 부가기능과 위임 코드를 담은 InvocationHandler

다이내믹 프록시 확장

public class UppercaseHandler implements InvocationHandler {
    // 어떤 종류의 인터페이스를 구현한 타깃에도 적용 가능하도록 Object 타입으로 수정
    Object target;
    private UppercaseHandler(Object target) {
        this.target = target;
    }
    
    public Object invoke(Object proxy, Method method, Object[] args) 
        thorws Throwable {
        //호출한 메소드의 리턴타입이 String인 경우만 대문자로 변경 기능을 적용하도록 수정
        Object ret = method.invoke(target, args);
        if (Ret instanceof String) {
            return ((String)ret).toUpperCase();
        } else {
            return ret;
        }
    }
}

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

트랜잭션 InvocationHandler

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;
    }
    
    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 invokeTransaction(Method method, Object[] args) throws Throwable {
        TransactionStatus = 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와 다이내믹 프록시를 이용하는 테스트

@Test
public void upgradeAllOrNothing() throws Exception {
    ...
    TransactionHandler txHandler = new TransactionHandler();
    
    //트랜잭션 핸들러에 필요한 정보와 오브젝트를 DI해준다.
    txHandler.setTarget(testUserService);
    txHandler.setTransactionManager(transactionManager);
    txHandler.setPattern("upgradeLevels");
    
    // UserService 인터페이스 타입의 다이내믹 프록시 생성
    UserService txUSerService = (UserService)Proxy.newProxyInstance(
            getClass().getClassLoader(), new Class[] {UserService.class }, txHandler);
    ...
}

0개의 댓글