헤드퍼스트 디자인패턴
- 본 책을 읽고 책의 내용을 간략하게 정리한 글입니다.
Chapter 11. 객체 접근 제어하기 - 프록시 패턴
예제 프로젝트. 뽑기 기계 모니터링 코드 만들기
- 10장에서 만든 뽑기 기계의 상태를 파악하기 위해 모든 뽑기 기계의 재고와 현재 상태를 알려 주는 기능을 개발해야 한다.
- 기계별 상태를 알기 위해 뽑기 기계의 위치를 알려 주는 필드도 추가해야 한다.
public class GumballMachine {
// 기타 인스턴스 변수
int count;
// 현재 위치를 저장하는 인스턴스 변수
String location;
pubic GumballMachine(String location, int count) {
this.location = location;
this.count = count;
}
public String getLocation() {
return location;
}
// 기타 메서드
}
// 뽑기 기계 모니터링 클래스
public class GumballMonitor {
GumballMachine machine;
public GumballMonitor(GumballMachine machine) {
this.machine = machine;
}
public void report() {
System.out.println("뽑기 기계 위치:" + machine.getLocation());
System.out.println("현재 재고:" + machine.getCount());
System.out.println("현재 상태: " + machine.getState());
}
}
추가 요구사항
- 뽑기 기계를 원격으로 모니터링 하고자 한다.
- 현재 상황은 모니터링 코드가 뽑기 기계랑 같은 JVM에서 실행되고 있다.
원격 프록시의 역할
- 원격 프록시는 원격 객체의 로컬 대변자 역할을 한다.
- 원격 객체: 다른 자바 가상 머신의 힙에서 살고 있는 객체
- 로컬 대변자: 로컬 대변자의 어떤 메서드를 호출하면 다른 원격 객체에게 그 메서드 호출을 전달해 주는 객체
모니터링 코드에 원격 프록시 추가하기
- 다른 JVM에 들어있는 객체의 메서드를 호출하는 프록시를 어떻게 만들까?
- 아래처럼 다른 힙에 들어있는 객체 레퍼런스를 가져올 수 없다.
GumballMonitor m = <다른 힙에 있는 객체>
- 객체
m
이 어떤 객체를 참조하든, 그 객체는 선언문이 들어있는 코드와 같은 힙 공간에 있어야만 한다.
- 여기서 자바의 원격 메서드 호출(RMI, Remote Method Invocation)이 쓰인다.
- RMI를 사용하면 원격 JVM에 있는 객체를 찾아 그 메서드를 호출할 수 있다.
자바 RMI의 개요
- RMI는 대신 클라이언트와 서비스 보조 객체를 만들어 준다. 보조 객체에는 원격 서비스와 똑같은 메서드가 들어있다.
- RMI를 사용하면 네트워킹 및 입출력 관련 코드를 직접 작성하지 않아도 된다. 클라이언트는 로컬 JVM에 있는 메서드를 호출하듯이 원격 메서드를 호출할 수 있다.
- RMI와 로컬 메서드 호출의 차이점
- 클라이언트는 로컬 메서드 호출과 똑같은 식으로 메서드를 호출하지만, 실제로는 클라이언트 보조 객체가 네트워크로 호출을 전송해야 하므로 네트워킹 및 입출력 기능이 반드시 필요하다.
- 네트워킹이나 입출력 기능을 사용할 때는 문제가 발생할 위험이 따르므로 클라이언트에서 항상 예상치 못한 상황을 대비하고 있어야 한다.
RMI 용어
- RMI에서 클라이언트 보조 객체는 스텁(stub), 서비스 보조 객체는 스켈레톤(skeleton)이라고 한다.
원격 서비스 만들기
- 원격 인터페이스 만들기
- 원격 인터페이스는 클라이언트가 원격으로 호출할 메서드를 정의한다.
- 클라이언트에서 이 인터페이스를 서비스의 클래스 형식으로 사용한다.
- 스텁과 실제 서비스에 이 인스턴스를 구현해야 한다.
- 서비스 구현 클래스 만들기
- 실제 작업을 처리하는 클래스
- 원격 메서드를 실제로 구현한 코드가 들어있는 부분
- RMI 레지스트리 실행하기
- rmiregistry는 전화번호부와 비슷하다고 보면 된다.
- 클라이언트는 이 레지스트리로부터 프록시(스텁, 클라이언트 보조 객체)를 받아 간다.
- 원격 서비스 실행하기
- 서비스를 구현한 클래스에서 서비스의 인스턴스를 만들고 그 인스턴스를 RMI 레지스트리에 등록한다.
- 이러면 그 서비스를 클라이언트에서 사용할 수 있다.
1단계: 원격 인터페이스 만들기
java.rmi.Remote
를 확장한다.- Remote는 표식용(marker) 인터페이스인데 메서드가 없다. 하지만 RMI에서 Remote는 의미를 가지므로 반드시 확장해야 한다.
- 인터페이스를 확장해서 다른 인터페이스를 만들 수 있다는 것을 알아둔다.
public interface MyRemote extends Remote {
- 모든 메서드를 RemoteException을 던지도록 선언한다.
- 클라이언트는 서비스 원격 인터페이스 형식으로 선언해 사용한다.
- 스텁이 각종 입출력 작업을 처리할 때 네트워크 등에 이슈가 발생할 수 있으므로 클라이언트는 원격 예외를 처리하거나 선언하여 이슈에 대비해야 한다.
- 인터페이스를 정의할 때 모든 메서드에서 예외를 선언했다면, 인터페이스 형식의 레퍼런스에 관한 메서드를 호출하는 코드에서 반드시 그 예외를 처리하거나 선언해야 한다.
import java.rmi.*;
public interface MyRemote extends Remote {
String hello() throws RemoteException; // 모든 원격 메서드 호출은 위험이 따르는 것으로 간주해야 한다.
}
- 원격 메서드의 인자와 리턴값은 반드시 원시 형식(primitive) 또는 Serializable 형식으로 선언한다.
- 원격 메서드의 인자와 리턴값은 모두 네트워크로 전달되어야 하며, 직렬화로 포장한다.
- 원시 형식이나 String 또는 배열, 컬렉션 등을 사용하는 것은 괜찮을 수 있다.
- 만약 직접 만든 형식을 전달한다면, 클래스를 만들 때 Serializable 인터페이스도 구현해야 한다.
2단계: 서비스 구현 클래스 만들기
- 서비스 클래스에 원격 인터페이스 구현
public class MyRemoteImpl extends UnicastRemoteObject implements MyRemote {
public String hello() {
return "Server says, 'Hello'";
}
// 기타 코드 작성
}
- UnicastRemoteObject를 확장한다.
- 원격 서비스 객체 역할을 하려면 객체에 원격 객체 기능을 추가해야 한다.
- 가장 간단한 방법은 UnicastRemoteObject를 확장해서, 슈퍼클래스에서 제공하는 기능으로 처리하는 방법이다.
public class MyRemoteImpl extends UnicastRemoteObject implements MyRemote {
// UnicastRemoteObject는 Serializable을 구현하므로 serialVersionUID 필드가 필요함
private static final long serialVersionUID = 1L;
}
- RemoteException을 선언하는 생성자를 구현한다.
- 슈퍼클래스 UnicastRemoteObject는 생성자가 RemoteException을 던진다는 문제가 있는데, 이를 해결하려면 서비스를 구현하는 클래스에 RemoteException을 선언하는 생성자를 만들어야 한다.
- 어떤 클래스가 생성될 때 그 슈퍼클래스의 생성자도 반드시 호출되므로 슈퍼클래스 생성자가 어떤 예외를 던진다면 서브클래스의 생성자도 그 예외를 선언해야 한다.
public MyRemoteImpl() throws RemoteException() { } // 생성자에 별다른 코드를 넣을 필요는 없다. 이 생성자에서도 예외를 선언하려고 그냥 만든 것일 뿐
- 서비스를 RMI 레지스트리에 등록한다.
- 원격 서비스를 원격 클라이언트에서 쓸 수 있게 만들어야 한다. 인스턴스를 만든 다음 RMI 레지스트리에 등록한다.
- 해당 클래스가 실행될 때 RMI 레지스트리가 돌아가고 있어야 한다.
- 서비스를 등록할 때는
java.rmi.Naming
클래스의rebind()
정적 메서드를 사용한다.
try {
MyRemote service = new MyRemoteImpl();
Naming.rebind("RemoteHello", service); // 서비스를 등록할 때는 이름을 지정한다. 클라이언트는 그 이름으로 레지스트리를 검색한다.
} catch(Exception ex) { ... }
3단계: rmiregistry 실행하기
- 터미널에서 rmiregistry를 실행한다.
- 클래스에 접근할 수 있는 디렉토리에서 실행해야 한다.
- classes 디렉토리에서 실행하면 처리 가능
%rmiregistry
4단계: 원격 서비스 실행하기
- 다른 터미널을 열고 서비스를 실행한다.
- 원격 서비스를 구현한 클래스의
main()
메서드로 실행할 수 있지만, 별도의 클래스로부터 실행할 수도 있다.%java MyRemoteImpl
서버에서 필요한 코드 살펴보기
원격 인터페이스
import java.rmi.*;
public interface MyRemote extends Remote {
String hell() throws RemoteException;
}
원격 서비스를 구현한 클래스
import java.rmi.*;
import java.rmi.server.*;
public class MyRemoteImpl extends UnicastRemoteObject implements MyRemote {
private static final long serialVersionUID = 1L;
public String hello() {
return "Server says, 'Hello'";
}
public MyRemoteImpl() throws RemoteException { }
public static void main(String[] args) {
try {
MyRemote service = new MyRemoteImpl();
Naming.rebind("RemoteHello", service);
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
원격 서비스 작동 방식
- 클라이언트에서 RMI 레지스트리를 룩업한다.
Naming.lookup("rmi://127.0.0.1/RemoteHello");
- RMI 레지스트리에서 스텁 객체를 리턴한다. 스텁 객체는
lookup()
메서드의 리턴값으로 전달되며, RMI에서는 그 스텁을 자동으로 역직렬화한다. 이 떄 스텁 클래스는 반드시 클라이언트에만 있어야 한다. - 클라이언트는 스텁의 메서드를 호출한다.
클라이언트 코드 살펴보기
import java.rmi.*;
public class MyRemoteClient {
public static void main(String[] args) {
new MyRemoteClient().go();
}
public void go() {
try {
MyRemote service = (MyRemote) Naming.lookup("rmi://127.0.0.1/RemoteHello");
String s = service.hello();
System.out.println(s);
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
뽑기 기계용 원격 프록시 만들기
뽑기 기계 클래스를 원격 서비스로 바꾸기
- GumballMachine의 원격 인터페이스를 만든다. 이 인터페이스는 원격 클라이언트에서 호출할 수 있는 메서드를 정의한다.
- 인터페이스의 모든 리턴 형식을 직렬화할 수 있는지 확인한다.
- 구상 클래스에서 인터페이스를 구현한다.
// 원격 인터페이스
import java.rmi.*;
public interface GumballMachineRemote extends Remote {
public int getCount() throws RemoteException;
public String getLocation() throws RemoteException;
public String getState() throws RemoteException;
}
// State 인터페이스를 직렬화할 수 있는 형식으로 고치기
import java.io.*;
public interface State extends Serializable {
public void insertQuarter();
public void ejectQuarter();
public void turnCrank();
public void dispense();
}
- 모든 State 객체에는 뽑기 기계의 메서드를 호출하거나 상태를 변경할 때 사용하는 뽑기 기계의 레퍼런스가 들어있다.
- State 객체가 전송될 때 GumballMachine 클래스도 전부 직렬화해서 같이 보내는 일은 별로 바람직하지 않다.
public class NoQuarterState implements State {
private static final long serialVersionUID = 2L;
// State를 구현하는 모든 클래스에서, GumballMachine 인스턴스 변수를 선언하는 부분에 transient 키워드 추가
// 이러면 JVM 에서 그 필드를 직렬화하지 않음
transient GumballMachine gumballMachine;
}
- GumballMachine 클래스를 네트워크로 들어온 요청을 처리하는 서비스로 고쳐야 한다.
- 우선 GumballMachine 클래스에서 GumballMachineRemote 인터페이스를 구현할 떄 필요한 메서드를 모두 구현했는지 확인 필요
import java.rmi.*;
import java.rmi.server.*;
public class GumballMachine extends UnicastRemoteObject implements GumballMachineRemote {
private static final long serialVersionUID = 2L;
// 기타 인스턴스 변수
public GumballMachine(String location, State state, int count) throws RemoteException {
// 생성자 코드
}
public int getCount() {
return count;
}
public State getState() {
return state;
}
public String getLocation() {
return location;
}
// 기타 메서드
}
모니터링 클라이언트 코드 고치기
- GumballMonitor 클라이언트에서 네트워크로 데이터를 받아오도록 고치기
import java.rmi.*;
public class GumballMonitor {
GumballMachineRemote machine;
public GumballMonitor(GumballMachineRemote 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) {
e.printStackTrace();
}
}
}
새로운 모니터링 기능 테스트
import java.rmi.*;
public class GumballMachineTestDrive {
public static void main(String[] args) {
String[] location = {"rmi://santafe.mightygumball.com/gumballmachine",
"rmi://boulder.mightygumball.com/gumballmachine",
"rmi://austin.mightygumball.com/gumballmachine"};
GumballMonitor[] monitors = new GumballMonitor[location.length];
for (int i = 0; i < location.length; i++) {
try {
GumballMachineRemote machineRemote = (GumballMachineRemote) Naming.lookup(location[i]);
monitors[i] = new GumballMonitor(machineRemote);
System.out.println(monitors[i]);
} catch (Exception e) {
e.printStackTrace();
}
}
for (int i = 0; i < monitors.length; i++) {
monitors[i].report();
}
}
}
프록시 패턴 정의
- 프록시 패턴은 특정 객체로의 접근을 제어하는 대리인을 제공한다.
- 프록시에서 접근을 제어하는 몇 가지 방법
- 원격 프록시를 써서 원격 객체로의 접근을 제어할 수 있다.
- 가상 프록시(virtual proxy)를 써서 생성하기 힘든 자원으로의 접근을 제어할 수 있다.
- 보호 프록시(protection proxy)를 써서 접근 권한이 필요한 자원으로의 접근을 제어할 수 있다.
원격 프록시와 가상 프록시 비교
원격 프록시
- 원격 프록시는 다른 JVM에 들어있는 객체의 대리인에 해당하는 로컬 객체이다.
- 프록시의 메서드를 호출하면 그 호출이 네트워크로 전달되어 결국 원격 객체의 메서드가 호출된다.
- 그 결과는 다시 프록시를 거쳐서 클라이언트에게 전달된다.
가상 프록시
- 가상 프록시는 생성하는 데 많은 비용이 드는 객체를 대신한다.
- 진짜 객체가 필요한 상황이 오기 전까지 객체의 생성을 미루는 기능을 제공한다. 객체 생성 전이나 객체 생성 도중에 객체를 대신하기도 한다.
- 객체 생성이 끝나면 그냥 생성하는 데 비용이 많이 드는 객체에 직접 요청을 전달한다.
예제 프로젝트2. 앨범 커버 뷰어 만들기
- 앨범 타이틀 메뉴를 만든 다음 이미지를 아마존 같은 온라인 서비스로부터 가져오면 될 것 같다.
- 스윙을 사용한다면 아이콘을 만든 다음, 그 아이콘 객체로 네트워크에서 이미지를 불러오도록 할 수 있을 것으로 보인다.
- 이미지를 불러오는 동안 뭔가 다른 걸 보여주면 좋겠다.
- 이미지를 기다리는 동안 애플리케이션 전체가 작동을 멈춰서도 안 된다.
앨범 커버 뷰어 클래스 다이어그램
| <인터페이스> |
| Icon |
| --------------- |
| getIconWidth() |
| getIconHeight() |
| paintIcon() |
| ImageIcon |
| --------------- |
| getIconWidth() |
| getIconHeight() |
| paintIcon() |
| ImageProxy |
| --------------- |
| getIconWidth() |
| getIconHeight() |
| paintIcon() |
ImageProxy 작동 방법
- ImageProxy는 ImageIcon을 생성하고 네트워크 URL로부터 이미지를 불러온다.
- 이미지를 가져오는 동안 “앨범 커버를 불러오는 중입니다. 잠시만 기다려 주세요.”라는 메시지를 화면에 표시한다.
- 이미지 로딩이 끝나면
paintIcon()
,getWidth()
,getHeight()
를 비롯한 모든 메서드 호출을 이미지 아이콘 객체에게 넘긴다. - 새로운 이미지 요청이 들어오면 프록시를 새로 만들고 위의 과정을 처음부터 다시 반복한다.
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) {
retrieving = true;
retrievalThread = new Thread(new Runnable() {
public void run() {
try {
setImageIcon(new ImageIcon(imageURL, "Album Cover"));
c.repaint();
} catch (Exception ex) {
ex.printStackTrace();
}
}
});
}
}
}
}
앨범 커버 뷰어 테스트
public class ImageProxyTestDrive {
ImageComponent imageComponent;
public static void main(String[] args) {
ImageProxyTestDrive testDrive = new ImageProxyTestDrive();
}
public ImageProxyTestDrive() throws Exception {
// 프레임 및 메뉴 설정
Icon icon = new ImageProxy(initialURL);
imageComponent = new ImageComponent(icon);
frame.getContentPanel().add(imageComponent);
}
}
실전 프록시 탐방
- 방화벽 프록시(Firewall Proxy)
- 일련의 네트워크 자원으로의 접근 제어
- 기업용 방화벽 시스템에서 볼 수 있음
- 스마트 레퍼런스 프록시(Smart Reference Proxy)
- 주제가 참조될 때마다 추가 행동 제공
- 캐싱 프록시(Caching Proxy)
- 비용이 많이 드는 작업의 결과를 임시로 저장
- 여러 클라이언트에서 결과를 공유하게 해 줌으로써 계산 시간과 네트워크 지연을 줄여 주는 효과
- 웹 서버 프록시 또는 컨텐츠 관리 및 퍼블리싱 시스템에서 볼 수 있음
- 동기화 프록시(Synchronization Proxy)
- 여러 스레드에서 주제에 접근할 때 안전하게 작업을 처리할 수 있게 해줌
- 분산 환경에서 일련의 객체로의 동기화된 접근을 제어해주는 자바 스페이스에서 볼 수 있음
- 복잡도 숨김 프록시(Complexity Hiding Proxy)
- 복잡한 클래스의 집합으로의 접근을 제어하고 그 복잡도를 숨겨줌
- 퍼사드 프록시라고 부르기도 함
- 퍼사드 패턴과의 차이점은 프록시는 접근을 제어하지만 퍼사드 패턴은 대체 인터페이스만 제공한다는 점
- 지연 복사 프록시(Copy-On-Write Proxy)
- 클라이언트에서 필요로 할 때까지 객체가 복사되는 것을 지연시킴으로써 객체의 복사를 제어
- 변형된 가상 프록시라고 할 수 있음
- 자바의 CopyOnWriteArrayList 에서 볼 수 있음