[헤드퍼스트 디자인패턴] 11. 프록시 패턴(Proxy Pattern)
프록시 패턴
프록시 패턴은 특정 객체로의 접근을 제어하는 대리인(객체의 대변인)을 제공합니다.
- 클라이언트는 대리인인 프록시가 마치 진짜 객체라고 생각하고 데이터를 주고 받습니다.
- 클라이언트와 진짜 객체가 직접 데이터를 주고받을 수 없는 경우에 둘 사이의 접근을 제어하는 역할을 합니다.
프록시의 종류
1. 원격 프록시: 원격 객체로의 접근을 제어합니다.
2. 가상 프록시: 생성하기 힘든 자원으로의 접근을 제어합니다.
3. 보호 프록시: 접근 권한이 필요한 자원으로의 접근을 제어합니다.
구조
1. ServiceInterface: 프록시와 진짜 서비스 객체 모두 이 인터페이스를 구현해야 합니다. 프록시는 Service와 똑같은 형태로 위장할 수 있습니다.
2. Service: 진짜 작업을 처리하는 객체입니다.
3. Proxy: Service 객체의 레퍼런스를 갖고 있으며, 그 객체로의 접근을 제어합니다. 진짜 객체가 필요할 때에만 그 레퍼런스를 사용해 요청을 전달합니다.
예제 1) 원격 프록시
책에서는 상태 패턴에서 다뤘던 뽑기 기계를 다시 원격 프록시 패턴의 예제로 사용하고 있습니다.
뽑기 기계 회사의 CEO가 자신의 서버에서 뽑기 기계를 모니터링하고 싶다고 하네요!
뽑기 기계들은 서로 다른 서버에 배포되어 있는 상태입니다.
즉,원격으로 뽑기 기계 객체들을 접근하기 위해 Java의 RMI 프로토콜과 원격 프록시 패턴을 사용하겠습니다.
1. GumballMonitor 클라이언트 클래스 만들기
클라이언트 서버에서 뽑기 기계를 모니터링하기 위한 목적으로 사용됩니다.
import java.rmi.*;
public class GumballMonitor {
GumballMachineRemote machine;
public GumballMonitor(GumballMachine machine) {
this.machine = machine;
}
public void report() {
try {
System.out.println("뽑기 기계 위치: " + machine.getLocation());
System.out.println("현재 재고: " + machine.getCount() + " gumballs");
System.out.println("현재 상태: " + machine.getState());
} catch (RemoteException e) {
// 네트워크를 통해 메소드를 호출할 때 네트워크 에러 발생 시, RemoteException이 발생합니다.
e.printStackTrace();
}
}
}
2. GumballMachine 클래스에 모니터링용 메소드를 선언하고, 원격 서비스로 바꾸기
import java.rmi.*;
// 원격 서비스로 만들기 위해 rmi의 Remote 인터페이스를 상속합니다.
public interface GumballMachineRemote extends Remote {
// 네트워크 에러를 대비해 RemoteException을 항상 던져야 합니다.
// 리턴값을 네트워크로 전송하기 위해 리턴값은 항상 원시 형식 또는 Serializable이어야 합니다.
public int getCount() throws RemoteException;
public String getLocation() throws RemoteException;
public State getState() throws RemoteException;
}
import java.rmi.*;
import java.rmi.servre.*;
// UnicastRemoteObject의 서브클래스여야 원격 서비스 역할을 할 수 있습니다.
public class GumballMachine extends UnicastRemoteObject implements GumballMachineRemote {
private static final long serialVersionUID = 2L;
...
public GumballMachine(String location, int numberGumballs) throws RemoteException {
...
}
...
public int getCount( return count; }
public State getState( return state; }
public String getLocation( return location; }
}
2-1. State 클래스도 네트워크로 전송할 때 직렬화할 수 있도록 바꿔줍시다.
import java.io.*;
public interface State extends Serializable {
...
}
public class NoQuarterState implements State {
// Serializable을 구현하기 위해 반드시 serialVersionUID 필드가 필요합니다.
private static final long serialVersionUID = 2L;
// 역참조하는 부분은 transient 키워드를 추가해 직렬화하지 않도록 합니다.
transient GumballMachine gumballMachine;
}
3. RMI 레지스트리에 뽑기 기계 서비스 등록하기
뽑기 기계 서버에는 모든 수정이 끝났습니다.
이제 클라이언트가 RMI 레지스트리에 뽑기 기계 서비스를 검색할 수 있도록 RMI 레지스트리에 서비스를 등록해봅시다.
public class GumballMachineTestDrive {
public static void main(String[] args) {
GumballMachineRemote gumballMachine = null;
int count;
if (args.length < 2) {
System.out.println("GumballMachine <name> <inventory>");
System.exit(1);
}
try {
count = Integer.parseInt(args[1]);
gumballMachine = new GumballMachine(args[0], count);
// GumballMachine 스텁을 gumballmachine 이름으로 등록합니다.
Naming.rebind("//" + args[0] + "/gumballmachine", gumballMachine);
} catch (Exception e) {
e.printStackTrace();
}
}
}
이제 cmd에서 테스트 클래스를 실행해주세요.
% rmiregistry
% java GumballMachineTestDrive austin.mightygumball.com 100
4. 이제 다음과 같이 모니터링이 가능합니다.
import java.rmi.*;
public class GumballMonitorTestDrive {
public static void main(String[] args) {
String[] location = {"rmi://santafe.mightygumball.com/gumballmachine",
"rmi://boulder.mightygumball.com/gumballmachine",
"rmi://austin.mightygumball.com/gumballmachine"};
GumballMonitor[] monitor = new GumballMonitor[location.length];
for (int i = 0; i < location.length; i++) {
try {
GumballMachineRemote machine = (GumballMachineRemote) Maming.lookup(location[i]);
monitor[i] = new GumballMonitor(machine);
System.out.println(monitor[i]);
} catch (Exception e) {
e.printStackTrace();
}
}
for (int i = 0; i < monitor.length; i++) {
monitor[i].report();
}
}
}
RMI는 우리 대신 클라이언트와 서비스의 보조 객체 두 가지를 만들어 줍니다.
이 보조 객체는 원격 서비스와 똑같은 메소드가 들어있는 프록시 클래스입니다.
클라이언트의 네트워크 통신을 보조하는 보조 객체를 '스텁(Stub)'이라고 부릅니다.
스텁은 클라이언트의 요청을 받아 서비스 서버의 스켈레톤으로 요청을 전달하는 역할을 수행합니다.
그리고 요청의 결과값을 받아 다시 클라이언트에게 돌려줍니다.
클라이언트는 스텁을 진짜 서비스 객체로 인식합니다.
서비스 서버에서 네트워크 통신을 보조하는 보조 객체는 '스켈레톤(Skeleton)'이라고 부릅니다.
스켈레톤은 스텁으로부터 전달받은 요청을 진짜 서비스 객체에게 다시 전달하는 역할을 수행합니다.
그리고 요청의 결과값을 받아 다시 스텁에게 돌려줍니다.
스켈레톤 역시, 스텁에게는 진짜 객체로 인식됩니다.
RMI를 사용하면 네트워킹이나 입출력과 관련된 코드를 작성하지 않아도 됩니다.
또한 클라이언트가 원격 객체를 찾아 접근하기 위한 lookup 메소드도 RMI에서 제공해주고 있습니다.
가상 프록시 예제
원격 프록시는 다른 네트워크의 객체를 대신하는 역할이었다면,
가상 프록시는 생성에 많은 비용이 드는 객체를 대신해 진짜 객체의 생성을 끝까지 미루는 역할을 합니다.
책에서는 앨범 커버 뷰어를 가상 프록시를 사용한 예제로 들고 있습니다.
AWS에 올라간 앨범 커버 이미지를 뷰어로 가져오려고 하는데요.
네트워크 상태가 너무 별로라 이미지를 가져오는 데 시간이 오래 걸린다고 합니다.
이미지를 불러오는 동안 동시에 화면에 뭔가 다른 걸 보여주도록 할까요?
화면에 보여줄 이미지인 ImageIcon 클래스를 상속받아 가상 프록시 클래스를 만들면 가능할 것 같습니다.
1. ImageProxy 클래스 만들기
class ImageProxy implements Icon {
volatile ImageIcon imageIcon;
final URL imageURL;
Thread retrievalThread;
boolean retrieving = false;
public ImageProxy(URL url) { imageUrl = url; }
// 이미지 로딩 전까지 기본 너비와 높이를 리턴합니다.
public int getIconWidth() {
if (imageIcon != null) {
return imageIcon.getIconWidth();
} else {
return 800;
}
}
public int getIconHeight() {
if (imageIcon != null) {
return imageIcon.getIconHeight();
} else {
return 600;
}
}
synchronized void setImageIcon(ImageIcon imageIcon) {
this.imageIcon = imageIcon;
}
public void paintIcon(final Component c, Graphics g, int x, int y) {
if (imageIcon != null) {
imageIcon.paintIcon(c, g, x, y);
} else {
g.drawString("앨범 커버를 불러오는 중입니다. 잠시만 기다려주세요.", x+300, y+190);
if (!retrieving) {
revrieving = true;
retrievalThread = new Thread(new Runnable() {
public void run() {
try {
setImageIcon(new ImageIcon(imageURL, "Album Cover"));
c.repaint();
} catch (Exception e) {
e.printStackTrace();
}
}
});
retrievalThread.start();
}
}
}
}
2. 가상 프록시 클래스 사용해보기
public class ImageProxyTestDrive {
ImageComponent imageComponent;
public static void main (String[] args) throws Exception {
ImageProxyTestDrive testDrive = new ImageProxyTestDrive();
}
public ImageProxyTestDrive() throws Exception {
...
Icon icon = new ImageProxy(initialURL);
imageComponent = new ImageComponent(icon);
frame.getContentPane().add(imageComponent);
}
}
테스트 코드를 실행하면 화면에 이미지를 가져와 ImageIcon 객체를 생성하는 스레드가 시작합니다.
그 동안, 화면에는 '불러오는 중입니다...'라는 텍스트가 표시됩니다.
ImageIcon 객체가 완성되면 화면에 이미지가 표시됩니다.
보호 프록시 예제
프록시는 파도 파도 끝이 없네요 ... 🤮
보호 프록시는 데이팅 앱에서 회원이 가질 수 있는 권한을 제한하고 싶을 때 사용할 수 있습니다.
데이팅 앱의 회원은 다른 회원의 호감 지수를 평가할 수 있습니다.
그러나 본인의 호감 지수를 조작하거나 또는 다른 회원의 개인 정보를 수정할 수는 없도록 권한을 구현하고 싶습니다.
자바의 java.lang.reflect 패키지를 활용한다면 동적 프록시로 위의 요구 사항을 구현할 수 있습니다.
동적 프록시는 런타임 중에 프록시 클래스를 동적으로 자동 생성해주는 자바의 기능입니다.
동적으로 생성된 프록시 클래스는 InvocationHandler 클래스를 통해 자신이 해야 할 일을 알 수 있습니다.
즉, 저희는 프록시 클래스를 구현하지 않고 InvocationHandler를 통해 프록시가 해야 하는 일만 구현해주면 됩니다.
1. 회원 클래스 구현하기
public interface Person {
String getName();
String getGender();
String getInterests();
int getGeekRating();
void setName(String name);
void setGender(String gender);
void setInterests(String interests);
void setGeekRating(int rating);
}
@Getter
@Setter
public class PersonImpl implements Person {
String name;
String gender;
String interests;
int rating;
int ratingCount = 0;
public int getGeekRating() {
if (ratingCount == 0) return 0;
return (rating/ratingCount);
}
public void setGeekRating(int rating) {
this.rating += rating;
ratingCount++;
}
}
2. Person 인터페이스용 동적 프록시 만들기
2-1. InvocationHandler 만들기
import java.lang.reflect.*;
public class OwnerInvocationHandler implements InvocationHandler {
Person person;
public OwnerInvocationHandler(Person person) {
this.person = person;
}
// 프록시 메소드가 호출될 때마다 호출되는 invoke() 메소드
public Object invoke(Object proxy, Method method, Object[] args) thwos IllegalAccessException {
try {
if (method.getName().startsWith("get") {
// getter 메소드라면 바로 호출
return method.invoke(person, args);
}
else if (method.getName().equals("setGeekRating")) {
// 자기 자신의 호감 지수는 설정 불가능
throw new IllegalAccessException();
}
else if (method.getName().startsWith("set")) {
return method.invoke(person, args);
}
} catch (InvocationTargetException e) {
e.printStackTrace();
}
return null;
}
}
자기 자신의 Person 클래스에 대한 OwnerInvocationHandler 만 구현해보았습니다.
타인 Person 클래스에 대한 NonOwnerInvocationHandler 클래스도 구현해주세요.
2. 동적 프록시 생성 코드와 테스트 코드 만들기
public class MatchMakingTestDrive {
...
public static void main(String[] args) {
MatchMakingTestDrive test = new MatchMakingTestDrive();
test.drive();
}
public MatchMakingTestDrive() {
initializeDatabase();
}
public void drive() {
// 테스트 코드들
}
Person getOwnerProxy(Person person) {
return (Person) Proxy.newProxyInstance(
person.getClass().getClassLoader(),
person.getClass().getInterfaces(),
new OwnerInvocationHandler(person));
}
}
이제 본인의 호감 지수를 조작하거나, 타인의 개인정보를 조작하는 행위는 불가능합니다.