클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 클라이언트의 요청을 받아주는 것을 대리자와 같은 역할을 한다고 해서 프록시(Proxy)라고 부른다.
쉬운 이해를 위해서 트랜잭션 경계설정 코드를 비즈니스 로직 코드에서 분리해낼 때 적용했던 기법을 다시 검토해보자.
우리는 비즈니스 로직을 다루는 UserService에서 트랜잭션과 같은 부가기능을 다루는 코드를 분리시켰다.
이렇게 분리된 부가기능을 담은 클래스는 부가기능 외의 나머지 모든 기능을 핵심기능을 가진 클래스로 위임해줘야 한다. 핵심기능은 부가기능을 가진 클래스의 존재를 모른다. 따라서 부가기능이 핵심기능을 사용하는 구조가 된다.
문제는 클라이언트가 핵심기능을 가진 클래스를 직접 사용하면 부가기능이 적용되지 않기 때문에, 부가기능은 자신이 핵심기능을 가진 클래스인것 처럼 꾸며서 클라이언트가 자신을 거쳐서 핵심기능을 사용하도록 만들어야한다.
우리가 만든 UserServiceTx
는 자신이 핵심 클래스인것 처럼 꾸며서 클라이언트의 요청을 받고, 이렇게 대신 요청을 받는 오브젝트를 프록시(Proxy)라고 부른다.
그리고, 프록시을 통해 요청을 위임받아 처리하는 오브젝트(우리 코드에서는 UserServiceImpl
)를 타깃이라고 부른다.
프록시의 특징
프록시는 사용 목적에 따라 두 가지로 구분할 수 있다.
→ 두가지 모두 프록시를 사용하지만, 목적에 따라서 다른 디자인 패턴으로 구분된다.
데코레이터 패턴은 타깃에 부가적인 기능을 런타임 시 다이내믹하게 부여해주기 위해 프록시를 사용하는 패턴을 말한다.
다이내믹하게 기능을 부여한다는 의미는 컴파일 시점, 즉 코드상에서는 어떤 방법과 순서로 프록시와 타깃이 연결되어 사용되는지 정해져 있지 않다는 뜻이다.
예를 들어 소스코드를 출력하는 기능을 가진 핵심기능이 있다고 하면, 이 클래스에 데코레이터 개념을 부여해서 타킷과 같은 인터페이스를 구현하는 프록시를 만들 수 있다.
예를 들어 라인넘버를 붙히거나 색을 변경해주는 등의 부가기능을 가진 프록시를 만들고, 런타임 시에 적절한 순서로 조합해서 사용하면 된다.
또한, UserService 인터페이스를 구현한 타깃인 UserServiceImpl에 트랜잭션 부가기능을 제공해주는 UserServiceTx를 추가한 것도 데코레이터 패턴을 적용한 것이라고 볼 수 있다.
데코레이터 패턴은 인터페이스를 통해 위임하는 방식이므로 어느 데코레이터에서 타깃으로 연결될지 코드레벨에선 알 수 없다. 그러므로 여러개의 데코레이터를 적용 할 수 있다.
또한, 타깃의 코드에 손을 대지 않고 클라이언트가 호출하는 방법도 변경하지 않은 채로 새로운 기능을 추가할 때 유용하다.
그냥 프록시라는 용어와 디자인 패턴에서 말하는 프록시 패턴은 구분을 해야한다.
프록시 패턴은 타깃에 대한 접근 방법을 제어하기 위해 프록시를 사용하는 패턴을 말한다.
프록시 패턴의 프록시는 타깃의 기능을 확장하거나 추가하지 않는다. 대신 클라이언트가 타깃에 접근하는 방식을 변경해준다.
타깃 오브젝트를 생성하기가 복잡하거나 당장 필요가 없으면 필요한 시점 전까지는 생성하지 않는 것이 좋다. 하지만, 타깃 오브젝트에 대한 레퍼런스가 미리 필요할 수 있다. 이럴 때 프록시 패턴을 적용하면 된다.
예를 들면 클라이언트에게 타깃에 대한 레퍼런스를 넘겨야 하는데 실제 타깃 오브젝트를 만드는 대신 프록시를 넘겨주는 식이다. 그리고 프록시의 메소드를 통해 타깃을 사용하려고 시도하면 그때 프록시가 타깃 오브젝트를 생성하고 요청을 위임해주는 식이다.
이렇게 프록시 패턴은 타깃의 기능 자체에는 관여하지 않으면서 접근하는 방법을 제어해주는 프록시를 이용하는 것이다.
데코레이터 패턴과 프록시 패턴은 아래와 같이 혼용할 수 있다.
이렇게 일일히 프록시를 만드는것은 상당히 번거롭다. 번거로운 이유는 두 가지 정도가 있다.
이중 1번 문제를 해결하는데 유용한 것이 JDK의 다이내믹 프록시다.
리플렉션이란, 구체적인 클래스 타입을 알지 못해도 그 클래스의 메소드, 타입, 변수들에 접근할 수 있도록 해주는 자바 API을 말한다.
자바의 리플렉션은 클래스, 인터페이스, 메소드들을 찾을 수 있고, 객체를 생성하거나 속성을 변경하거나 메소드를 호출할 수 있다.
코드를 작성할 시점에는 어떤 타입의 클래스를 사용할지 모르지만, 런타임 시점에 지금 실행되고 있는 클래스를 가져와서 실행해야 하는 경우에 사용된다. 어노테이션이나 intelliJ의 자동완성 기능 등이 리플렉션을 이용한 기능이다.
class Person {
int age;
Person() {
this.age = 27;
}
Person(int age) {
this.age = age;
}
int getAge() {
return this.age;
}
}
예를 들어서 위와 같이 나이라는 정보를 가지고 있는 Person이라는 클래스가 있다면
// 클래스명.class 혹은 Class.forNale("클래스 이름") 이런식으로 클래스를 가져올 수 있다.
Class clazz = Person.class;
// 메소드 가져오기
Method[] methodList = clazz.getDeclaredMethods();
System.out.println(methods[0].invoke(clazz.newInstance())) // 27이 출력됨
// 필드 가져오기
Field[] field = clazz.getDeclaredFields();
System.out.println(field[0]); // 출력 : int reflection_test.Person.age
// 필드 조작
Person person = new Person();
field[0].set(person, 17);
System.out.println(field[0].get(person)); // 17이 출력됨
이런 식으로 클래스를 가져오고, 클래스의 메소드, 필드를 가져오고 실행, 조작할 수 있다.
다이내믹 프록시를 이용한 프록시를 만들어 보자.
일단 일반적인 프록시를 먼저 만들어보자.
Hello 인터페이스
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;
}
}
프록시 클래스
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();
}
}
이 프록시는 인터페이스의 모든 메소드를 구현해 위임하도록 코드를 만들어야 하며, 부가기능인 리턴값을 대문자로 바꾸는 기능이 모든 테스트에 중복되어 나타난다.
이제 여기에 다이내믹 프록시를 적용해보자. 다이내믹 프록시는 아래와 같이 동작한다.
다이내믹 프록시는 프록시 팩토리에 의해 런타임 시 다이내믹하게 만들어지는 오브젝트다. 다이내믹 프록시 오브젝트는 타깃의 인터페이스와 같은 타입이며, 클라이언트는 타깃 인터페이스를 통해 다이내믹 프록시 오브젝트를 사용할 수 있다.
이로 인해 프록시를 만들 때 인터페이스를 모두 구현해가며 클래스를 정의할 필요가없다.
프록시 팩토리에게 인터페이스 정보만 제공해주면 해당 인터페이스를 구현한 클래스의 오브젝트를 자동으로 만들어주기 때문이다. 대신, 부가기능 제공 코드는 직접 작성해야한다.
부가기능은 프록시 오브젝트와 독립적으로 InvocationHandler 인터페이스를 구현한 오브젝트에 담는다.
InvocationHandler 인터페이스
public interface InvocationHandler {
public Object invoke(Object proxy, Method method, Object[] args)
}
InvocationHandler 구현 클래스
public class UppercaseHandler implements InvocationHandler {
// 다이내믹 프록시로 부터 전달받은 요청을
// 타깃 오브젝트에 위임하기 위해 타깃 오브젝트 주입받음
Hello target;
public UppercaseHandler(Hello target) {
this.target =target;
}
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable {
// 타깃으로 위임, 인터페이스의 메소드 호출에 모두 적용된다.
String ret = (String)method.invoke(target, args);
return ret.toUpperCase();
}
}
다이내믹 프록시 오브젝트는 클라이언트의 모든 요청을 리플렉션 정보로 변환해서 InvocationHandler 구현 오브젝트의 invoke() 메소드로 넘기는 것이다. 타깃 인터페이스의 모든 메소드 요청이 하나의 메소드로 집중되기 때문에 중복되는 기능을 효과적으로 제공할 수 있다.
이러한 구조를 그림으로 나타내면 아래와 같다.
스프링은 내부적으로 리플렉션 API를 이용해서 빈 정의에 나오는 클래스 이름을 가 지고 빈 오브젝트를 생성한다. 문제는 다이내믹 프록시 오브젝트는 이런 식으로 프록시 오브젝트가 생성되지 않는다는 점이다.
→ 팩토리 빈 활용
팩토리 빈이란?
스프링을 대신해서 오브젝트의 생성로직을 담당하도록 만들어진 특별한 빈을 말한다.
팩토리 빈을 이용해서 트랜잭션 다이내믹 프록시를 적용하면 아래와 같다.