✅ 프록시
단순히 확장성을 고려해서 한 가지 기능을 분리한다면 아래와 같이 전략 패턴을 사용해도 됩니다. 트랜잭션 기능은 추상화 작업을 통해 전략 패턴을 적용하였지만 여전히 트랜잭션을 적용한다는 코드가 남아있었습니다.
트랜잭션이라는 기능은 사용자 관리 비즈니스 로직과는 성격이 다르기 때문에 아예 그 적용 사실 자체를 밖으로 분리할 수 있습니다. 이 방법을 이용해 UserServiceTx를 만들었고, UserServiceImpl에는 트랜잭션 코드가 하나도 남아있지 않게 되었습니다.
하지만 이렇게 구성했더라고 클라이언트가 핵심기능을 가진 클래스를 직접 사용해버리면 부가 기능을 가진 클래스를 따로 빼두었기 때문에 부가 기능이 적용되지 않습니다. 그래서 부가 기능이 마치 자신이 핵심 기능을 가진 클래스처럼 꾸미기 위해 추상화를 진행하고, 클라이언트는 인터페이스를 통해서만 핵심 기능을 사용하도록 합니다. 클라이언트는 인터페이스를 통해 핵심 기능을 가진 클래스(UserServiceImpl)을 사용할 것이라고 기대하지만 실은 부가 기능(UserServiceTx)을 통해서 핵심 기능을 사용하는 것입니다.
- 프록시(proxy)
프록시는 객체의 대리 역할을 하는 객체입니다. 프록시는 실제 객체를 대신하여 요청을 처리하고, 실제 객체에 대한 액세스를 제어할 수 있습니다. 마치 자신이 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 클라이언트의 요청을 받아주는 대리자, 대리인과 같은 역할을 하는 객체를 말합니다.
👉 프록시를 통해 최종적으로 요청을 위임받아 처리하는 실제 오브젝트를 타켓(target) 또는 실체(real subject)라고 부릅니다.
✅ 데코레이터 패턴
데코레이터 패턴은 타겟의 부가적인 기능을 런타임 시 다이내믹하게 부여해주기 위해 프록시를 사용하는 패턴을 말합니다. 다이내믹하게 기능을 부가한다는 의미는 컴파일 시점, 즉 코드상에서 어떤 방법과 순서로 프록시와 타겟이 연결되어 사용되는지 정해져 있지 않다는 뜻입니다. 부가적인 기능들을 런타임 시에 적절한 순서로 조합해서 사용하면 됩니다.
프록시로서 동작하는 각 데코레이터는 자신이 위임할 대상도 인터페이스로 접근하기 때문에 자신이 최종 타겟으로 위임하는지, 다른 데코레이터 프록시로 위임하는지 알지 못합니다. 그래서 다음 데코레이터의 위임 대상은 인터페이스로 선언하고 생성자나 수정자 메소드를 통해 위임 대상을 외부에서 런타임 시에 주입받을 수 있도록 만들어야 합니다.
- TestServiceFactory
//== 데코레이터 ==// @Bean public UserService userService() { UserServiceTx userServiceTx = new UserServiceTx(); userServiceTx.setTransactionManager(transactionManager()); userServiceTx.setUserService(userServiceImpl()); return userServiceTx; } //== 타겟 ==// @Bean public UserServiceImpl userServiceImpl() { UserServiceImpl userServiceImpl = new UserServiceImpl(); userServiceImpl.setUserDao(userDao()); userServiceImpl.setMailSender(mailSender()); return userServiceImpl; }
public class UserServiceTx implements UserService { UserService userService; PlatformTransactionManager transactionManager;
인터페이스를 통한 데코레이터 정의와 런타임 시의 다이내믹한 구성 방법은 스프링의 DI를 이용하면 아주 편리합니다. UsserServiceTx는 UserService 타입의 오브젝트를 DI 받아서 기능을 위임하며, 이때 트랜잭션 경계설정 기능을 부여합니다. DI를 통해 다이내믹한 부가기능 부여라는 데코레이터 패턴이 적용되었습니다.
✅ 프록시 패턴
프록시를 사용하는 방법 중에서 타겟에 대한 접근 방법을 제어하려는 목적을 가진 경우를 프록시 패턴이라고 합니다. 객체 간의 간접적인 상호작용을 제어합니다.
프록시 패턴은 타겟의 기능을 확장하거나 추가하지 않습니다. 대신 클라이언트가 타겟에 접근하는 방법을 변경해줍니다. 그렇기 때문에 프록시 패턴은 코드에서 자신이 만드럭나 접근할 타겟 클래스의 정보를 알고 있는 경우가 많습니다. 생성을 지연하는 프록시라면 구체적인 생성 방법을 알아야 하기 때문에 타겟 클래스에 대한 직접적인 정보를 알아야 합니다.
그렇지만 위와 같이 인터페이스를 통해 위임할 수도 있는데, 이는 프록시와 데코레이터 패턴을 혼용해서 쓸 경우에 가능합니다.
✅ 다이내믹 프록시
다이내믹 프록시는 인터페이스를 구현하는 객체를 동적으로 생성하는 기술입니다. 프록시는 기존 코드에 영향을 주지 않으면서 타겟의 기능을 확장하거나 접근 방법을 제어할 수 있는 유용한 방법입니다. 그렇지만 프록시를 만드는 일은 매번 새로운 클래스를 정의해야 하고, 인터페이스의 모든 메소드를 구현해야 하기 때문에 목 오브젝트를 구현하는 것과 같이 번거롭습니다. 이러한 문제를 해결하기 위해 자바에서는 java.lang.reflect 패키지 안에 프록시를 쉽게 만들어주는 클래스를 지원하고 있습니다.
🔹 리플렉션(reflect)
리플렉션은 ‘구체적인 클래스 타입을 알지 못해도 그 클래스의 변수, 메서드, 타입에 접근할 수 있도록 해주는 자바 API'으로 동적으로 조사하고 수정할 수 있는 기능을 제공합니다. 주로 java.lang.reflect 패키지의 클래스와 메서드를 사용하여 구현됩니다.
String name = "spring";
자바의 모든 클래스는 그 클래스 자체의 구성 정보를 담은 Class 타입의 오브젝트를 하나씩 가지고 있습니다. ‘클래스 이름.class’ or ‘참조변수.getClass()’를 사용하여 Class 타입의 오브젝트를 가지고 올 수 있습니다.
이런 Class 타입의 오브젝트는 클래스의 이름, 상속, 인터페이스 구현, 필드, 각각의 타입, 메소드의 정의, 파라미터, 리턴타입 등에 대한 정보를 가지고 있습니다.
Method lengthMethod = String.class.getMethod("length");
위와 같이 클래스 정보에서 특정 이름을 가진 메소드 정보를 가져올 수도 있습니다. 또한 Method 인터페이스에 정의된 invoke()
메소드를 사용해서 메소드를 실행시킬 수도 있습니다.
int length = name.length();
Method lengthMethod = String.class.getMethod("length");
int length = lengthMethod.invoke(name);
- ReflectionTest
package com.jhcode.spring.ch6.learningtest.jdk; import static org.junit.jupiter.api.Assertions.assertEquals; import java.lang.reflect.Method; import org.junit.jupiter.api.Test; public class ReflectionTest { @Test public void invokeMethod() throws Exception { String name = "Spring"; // length() assertEquals(name.length(), 6); // invoke() Method lengthMethod = String.class.getMethod("length"); assertEquals(lengthMethod.invoke(name), 6); // charAt() assertEquals(name.charAt(0), 'S'); // invoke() Method charAtMethod = String.class.getMethod("charAt", int.class); assertEquals(charAtMethod.invoke(name, 0), 'S'); } }
reflection이 어떻게 동작하는지 학습테스트를 만들었습니다.
✅ 다이내믹 프록시 학습 테스트
🔹프록시 및 프록시 패턴 적용
- Hello
package com.jhcode.spring.ch6.learningtest.jdk.proxy; public interface Hello { String sayHello(String name); String sayHi(String name); String sayThankYou(String name); }
- HelloTarget
package com.jhcode.spring.ch6.learningtest.jdk.proxy; 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; } }
- HelloUppercase
package com.jhcode.spring.ch6.learningtest.jdk.proxy; 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(); } }
- DynamicProxyTest
package com.jhcode.spring.ch6.learningtest.jdk.proxy; import static org.junit.jupiter.api.Assertions.assertEquals; import org.junit.jupiter.api.Test; public class DynamicProxyTest { @Test public void simpleProxy() { Hello hello = new HelloTarget(); // 타겟은 인터페이스를 통해서 접근한다 assertEquals(hello.sayHello("Toby"), "Hello Toby"); assertEquals(hello.sayHi("Toby"), "Hi Toby"); assertEquals(hello.sayThankYou("Toby"), "Thank You Toby"); } }
🔹다이나믹 프록시 적용
다이나믹 프록시는 프록시 팩토리에 의해 런타임 시 다이나믹하게 만들어지는 오브젝트입니다. 타겟의 인터페이스와 같은 타입으로 만들어지며 클라이언트는 다이내믹 프록시 오브젝트를 타겟 인터페이스를 통해 사용할 수 있습니다. 프록시 팩토리에게 인터페이스 정보만 제공해주면 해당 인터페이스를 구현한 클래스의 오브젝트를 자동으로 만들어줍니다.
매번 프록시 객체를 만들어 사용해야 했던 기존의 단점을 제거하기 위해 다이나믹 프록시가 적용됩니다.
다이나믹 프록시를 구현해 내기 위해 자바에서는 java.lang.reflect 패키지에 있는 InvocationHandler를 제공합니다. 아래와 같은 하나의 메소드만 정의되어 있습니다.
Object invoke(Object proxy, Method method, Object[] args) throws Throwable;
- proxy: 메서드가 호출되는 프록시 객체
- method: 호출되는 메서드에 대한 정보
- args: 메서드에 전달되는 인자 배열
- UppercaseHandler
package com.jhcode.spring.ch6.learningtest.jdk.proxy; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; public class UppercaseHandler implements InvocationHandler { Hello target; public UppercaseHandler(Hello target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { //타겟으로 위임, 인터페이스의 모든 메소드 호출에 적용 String ret = (String)method.invoke(target, args); // 부가기능 제공 return ret.toUpperCase(); } }
다이나믹 프록시 패턴을 구현함으로써 기존의 정적 프록시 패턴을 사용할 때에 비해서 코드가 많이 간단해졌습니다. 밑은 기존의 정적 프록시 패턴의 코드입니다. 인터페이스의 모든 메소드를 구현해야했던 기존과 달리, 이제는 invoke() 메소드만 오버라이드하면 됩니다.
- HelloUppercase
package com.jhcode.spring.ch6.learningtest.jdk.proxy; 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(); } }
- HelloUppercase
- DynamicProxyTest
@Test public void dynamicProxy() { Hello dynamicProxy = (Hello)Proxy.newProxyInstance(getClass().getClassLoader(), new Class[] { Hello.class }, new UppercaseHandler(new HelloTarget())); assertEquals(dynamicProxy.sayHello("Toby"), "HELLO TOBY"); assertEquals(dynamicProxy.sayHi("Toby"), "HI TOBY"); assertEquals(dynamicProxy.sayThankYou("Toby"), "THANK YOU TOBY"); }
Proxy.newProxyInstance()
메서드를 호출하여 다이내믹 프록시 객체를 생성하는 부분은 프록시 팩토리의 역할을 수행하고 있습니다.➡️ Proxy.newProxyInstance() 메서드는 세 개의 인자를 받습니다.
- ClassLoader
프록시 객체의 클래스 로더를 지정합니다.
getClass().getClassLoader()
를 통해 현재 테스트 클래스의 클래스 로더를 사용하고 있습니다. 이를 통해 생성된 Proxy객체의 NameSpace를 지정하고 동일한 클래스에서 사용할 수 있도록 설정합니다.
- Interfaces
프록시 객체가 구현할 인터페이스를 지정합니다.
new Class[] { Hello.class }
를 통해Hello
인터페이스를 구현하는 프록시 객체를 생성하고 있습니다. 프록시 객체가 구현할 인터페이스는 target 객체가 구현한 인터페이스와 동일합니다. 구현한 인터페이스가 여러 개일 수 있기 때문에 Class 타입의 배열로 받습니다.Proxy Interface = target Interface
- InvocationHandler
프록시 객체의 메서드 호출을 가로챌 InvocationHandler를 지정합니다.
new UppercaseHandler(new HelloTarget())
를 통해UppercaseHandler
를 InvocationHandler로 사용하고 있으며,HelloTarget
객체를 감싸는 프록시 객체를 생성하고 있습니다.
- ClassLoader
🔹 다이나믹 프록시의 확장
Hello 인터페이스의 메소드가 3개가 아니라 30개일 경우, 모든 인터페이스의 메소드를 구현해야 했던 기존과 달리 다이나믹 프록시를 사용할 경우 변경할 코드가 없습니다. 또한 현재 UppercaseHandler는 모든 메소드의 리턴 타입을 String이라고 가정했지만 더 상위 타입으로 유연하게 확장할 수 있습니다. 이때 Method를 이용해 타겟 오브젝트의 메소드 호출 후 리턴 타입을 확인하여 동작을 수행하는 것으로 오버라이딩하면 됩니다.
- UppercaseHandler 확장
package com.jhcode.spring.ch6.learningtest.jdk.proxy; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; public class UppercaseHandler implements InvocationHandler { // 어떤 종류의 인터페이스를 구현한 타겟에도 적용 가능하도록 Object 타입으로 수정함 Object target; public UppercaseHandler(Hello target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { //타겟으로 위임, 인터페이스의 모든 메소드 호출에 적용 Object ret = (String)method.invoke(target, args); // 리턴 타입 확인 후 부가기능 제공 if(ret instanceof String) { return ((String)ret).toUpperCase(); } else { return ret; } } }
타겟 변수를 어떤 종류의 인터페이스를 구현한 타겟에도 적용 가능하도록 Object 타입으로 수정합니다.
invoke()
메소드도 Object 타입으로 리턴 받아, 모든 타입의 리턴 값을 받을 수 있도록 합니다. 그 후 instanceof 키워드를 사용하여 리턴 받은 값의 타입을 판별하고 부가 기능을 부여하면 여러 인터페이스를 구현한 타겟에 적용이 가능합니다.- 메소드 선별 부가 기능 추가
// 리턴 타입 확인 후 부가기능 제공 if(ret instanceof String && method.getName().startsWith("say")) { return ((String)ret).toUpperCase(); } else { return ret; }
메소드의 이름이 ‘say’로 시작하는 메소드일 경우에만 로직을 수행하도록 할 수 있습니다.
- 메소드 선별 부가 기능 추가
✅ 다이나믹 프록시 -트랜잭션
🔹트랜잭션 부가 기능 적용
UserServiceTx를 다이내믹 프록시 방식으로 변경해 보겠습니다. UserServiceTx는 서비스 인터페이스의 메소드를 모두 구현해야 했고, 트랜잭션이 필요한 메소드마다 트랜잭션 처리 코드가 중복돼서 나타나느 비효율적인 방법으로 만들어졌습니다. 다이나믹 프록시와 연동해서 트랜잭션 기능을 부가해주는 InvoccationHandler를 정의하여 효율적인 코드로 변경할 수 있습니다.
- TransactionHandler
package com.jhcode.spring.ch6.user.service; import java.lang.reflect.InvocationHandler; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.support.DefaultTransactionDefinition; public class TransactionHandler implements InvocationHandler { private Object target; //부가기능을 제공할 타겟 오브젝트 private PlatformTransactionManager transactionManager; //트랜잭션 기능을 제공하는데 필요한 객체 private String pattern; //트랜잭션을 적용할 메소드 이름 //생성자 public void setTarget(Object target) { this.target = target; } // DI 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 { if (method.getName().startsWith(pattern)) { return invokeInTransaction(method, args); } 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(); } } }
타겟 오브젝트의 모든 메소드에 트랜잭션을 적용하는 것이 아니라 DI 받은 pattern 문자열을 통해 어떤 메소드에 트랜잭션 기능을 부가할지 선별하는 작업을 먼저 진행합니다.
리플렉션 메소드인
Method.invoke()
를 이용해 타겟 오브젝트의 메소드를 호출할 때, 발생하는 예외는 InvocationTargetException으로 한 번 포장돼서 전달됩니다. 그래서 InvocationTargetException으로 받은 후getTargetException()
메소드로 중첩되어 있는 예외를 가져와야 합니다.
- UserServiceTest
//예외 발생 시 작업 취소 여부 테스트 @Test public void upgradeAllorNothing() throws Exception { UserServiceImpl testUserService = new TestUserService(users.get(3).getId()); testUserService.setUserDao(userDao); testUserService.setMailSender(mailSender); // UserServiceTx txUserService = new UserServiceTx(); // txUserService.setTransactionManager(transactionManager); // txUserService.setUserService(testUserService); TransactionHandler txHandler = new TransactionHandler(); txHandler.setTarget(testUserService); //타겟 오브젝트 주입, 핵심 기능을 위임할 오브젝트 txHandler.setTransactionManager(transactionManager); //트랜잭션 기능에 필요한 객체 주입 txHandler.setPattern("upgradeLevels"); //트랜잭션 기능을 부가할 메소드 이름 UserService txUserService = (UserService)Proxy.newProxyInstance(getClass().getClassLoader(), //현재 클래스의 로더를 가져옴 new Class[] {UserService.class}, //프록시 객체가 구현해야할 인터페이스 txHandler); //구현된 프록시 객체에 부가기능을 주고 위임할 invocation userDao.deleteAll(); //...생략
📖 토비 스프링 3.1 -p429~449
🚩jhcode33의 toby-spring-study.git으로 이동하기
🏷️이미지 출처 및 참고한 사이트
Uploaded by N2T