헤드퍼스트 디자인패턴 Chapter11 - 프록시 패턴

헤드퍼스트 디자인패턴

  • 본 책을 읽고 책의 내용을 간략하게 정리한 글입니다.

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)이라고 한다.

원격 서비스 만들기

  1. 원격 인터페이스 만들기
    • 원격 인터페이스는 클라이언트가 원격으로 호출할 메서드를 정의한다.
    • 클라이언트에서 이 인터페이스를 서비스의 클래스 형식으로 사용한다.
    • 스텁과 실제 서비스에 이 인스턴스를 구현해야 한다.
  2. 서비스 구현 클래스 만들기
    • 실제 작업을 처리하는 클래스
    • 원격 메서드를 실제로 구현한 코드가 들어있는 부분
  3. RMI 레지스트리 실행하기
    • rmiregistry는 전화번호부와 비슷하다고 보면 된다.
    • 클라이언트는 이 레지스트리로부터 프록시(스텁, 클라이언트 보조 객체)를 받아 간다.
  4. 원격 서비스 실행하기
    • 서비스를 구현한 클래스에서 서비스의 인스턴스를 만들고 그 인스턴스를 RMI 레지스트리에 등록한다.
    • 이러면 그 서비스를 클라이언트에서 사용할 수 있다.

1단계: 원격 인터페이스 만들기

  1. java.rmi.Remote를 확장한다.
    • Remote는 표식용(marker) 인터페이스인데 메서드가 없다. 하지만 RMI에서 Remote는 의미를 가지므로 반드시 확장해야 한다.
    • 인터페이스를 확장해서 다른 인터페이스를 만들 수 있다는 것을 알아둔다.
public interface MyRemote extends Remote {
  1. 모든 메서드를 RemoteException을 던지도록 선언한다.
    • 클라이언트는 서비스 원격 인터페이스 형식으로 선언해 사용한다.
    • 스텁이 각종 입출력 작업을 처리할 때 네트워크 등에 이슈가 발생할 수 있으므로 클라이언트는 원격 예외를 처리하거나 선언하여 이슈에 대비해야 한다.
    • 인터페이스를 정의할 때 모든 메서드에서 예외를 선언했다면, 인터페이스 형식의 레퍼런스에 관한 메서드를 호출하는 코드에서 반드시 그 예외를 처리하거나 선언해야 한다.
import java.rmi.*;

public interface MyRemote extends Remote {
    String hello() throws RemoteException;    // 모든 원격 메서드 호출은 위험이 따르는 것으로 간주해야 한다.
}
  1. 원격 메서드의 인자와 리턴값은 반드시 원시 형식(primitive) 또는 Serializable 형식으로 선언한다.
    • 원격 메서드의 인자와 리턴값은 모두 네트워크로 전달되어야 하며, 직렬화로 포장한다.
    • 원시 형식이나 String 또는 배열, 컬렉션 등을 사용하는 것은 괜찮을 수 있다.
    • 만약 직접 만든 형식을 전달한다면, 클래스를 만들 때 Serializable 인터페이스도 구현해야 한다.

2단계: 서비스 구현 클래스 만들기

  1. 서비스 클래스에 원격 인터페이스 구현
public class MyRemoteImpl extends UnicastRemoteObject implements MyRemote {
    public String hello() {
        return "Server says, 'Hello'";
    }
    
    // 기타 코드 작성
}
  1. UnicastRemoteObject를 확장한다.
    • 원격 서비스 객체 역할을 하려면 객체에 원격 객체 기능을 추가해야 한다.
    • 가장 간단한 방법은 UnicastRemoteObject를 확장해서, 슈퍼클래스에서 제공하는 기능으로 처리하는 방법이다.
public class MyRemoteImpl extends UnicastRemoteObject implements MyRemote {
    // UnicastRemoteObject는 Serializable을 구현하므로 serialVersionUID 필드가 필요함
	private static final long serialVersionUID = 1L;
}
  1. RemoteException을 선언하는 생성자를 구현한다.
    • 슈퍼클래스 UnicastRemoteObject는 생성자가 RemoteException을 던진다는 문제가 있는데, 이를 해결하려면 서비스를 구현하는 클래스에 RemoteException을 선언하는 생성자를 만들어야 한다.
    • 어떤 클래스가 생성될 때 그 슈퍼클래스의 생성자도 반드시 호출되므로 슈퍼클래스 생성자가 어떤 예외를 던진다면 서브클래스의 생성자도 그 예외를 선언해야 한다.
public MyRemoteImpl() throws RemoteException() {    }   // 생성자에 별다른 코드를 넣을 필요는 없다. 이 생성자에서도 예외를 선언하려고 그냥 만든 것일 뿐
  1. 서비스를 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();
        }
    }
}

원격 서비스 작동 방식

  1. 클라이언트에서 RMI 레지스트리를 룩업한다.
    • Naming.lookup("rmi://127.0.0.1/RemoteHello");
  2. RMI 레지스트리에서 스텁 객체를 리턴한다. 스텁 객체는 lookup() 메서드의 리턴값으로 전달되며, RMI에서는 그 스텁을 자동으로 역직렬화한다. 이 떄 스텁 클래스는 반드시 클라이언트에만 있어야 한다.
  3. 클라이언트는 스텁의 메서드를 호출한다.

클라이언트 코드 살펴보기

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();
        }
    }
}

뽑기 기계용 원격 프록시 만들기

뽑기 기계 클래스를 원격 서비스로 바꾸기

  1. GumballMachine의 원격 인터페이스를 만든다. 이 인터페이스는 원격 클라이언트에서 호출할 수 있는 메서드를 정의한다.
  2. 인터페이스의 모든 리턴 형식을 직렬화할 수 있는지 확인한다.
  3. 구상 클래스에서 인터페이스를 구현한다.
// 원격 인터페이스
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();
        }
    }
}

프록시 패턴 정의

  • 프록시 패턴은 특정 객체로의 접근을 제어하는 대리인을 제공한다.
  • 프록시에서 접근을 제어하는 몇 가지 방법
    1. 원격 프록시를 써서 원격 객체로의 접근을 제어할 수 있다.
    2. 가상 프록시(virtual proxy)를 써서 생성하기 힘든 자원으로의 접근을 제어할 수 있다.
    3. 보호 프록시(protection proxy)를 써서 접근 권한이 필요한 자원으로의 접근을 제어할 수 있다.

원격 프록시와 가상 프록시 비교

원격 프록시
  • 원격 프록시는 다른 JVM에 들어있는 객체의 대리인에 해당하는 로컬 객체이다.
  • 프록시의 메서드를 호출하면 그 호출이 네트워크로 전달되어 결국 원격 객체의 메서드가 호출된다.
  • 그 결과는 다시 프록시를 거쳐서 클라이언트에게 전달된다.
가상 프록시
  • 가상 프록시는 생성하는 데 많은 비용이 드는 객체를 대신한다.
  • 진짜 객체가 필요한 상황이 오기 전까지 객체의 생성을 미루는 기능을 제공한다. 객체 생성 전이나 객체 생성 도중에 객체를 대신하기도 한다.
  • 객체 생성이 끝나면 그냥 생성하는 데 비용이 많이 드는 객체에 직접 요청을 전달한다.

예제 프로젝트2. 앨범 커버 뷰어 만들기

  • 앨범 타이틀 메뉴를 만든 다음 이미지를 아마존 같은 온라인 서비스로부터 가져오면 될 것 같다.
  • 스윙을 사용한다면 아이콘을 만든 다음, 그 아이콘 객체로 네트워크에서 이미지를 불러오도록 할 수 있을 것으로 보인다.
  • 이미지를 불러오는 동안 뭔가 다른 걸 보여주면 좋겠다.
  • 이미지를 기다리는 동안 애플리케이션 전체가 작동을 멈춰서도 안 된다.

앨범 커버 뷰어 클래스 다이어그램

|  <인터페이스>    |
|     Icon        |
| --------------- |
| getIconWidth()  |
| getIconHeight() |
| paintIcon()     |
|    ImageIcon    |
| --------------- |
| getIconWidth()  |
| getIconHeight() |
| paintIcon()     |
|   ImageProxy    |
| --------------- |
| getIconWidth()  |
| getIconHeight() |
| paintIcon()     |


ImageProxy 작동 방법

  1. ImageProxy는 ImageIcon을 생성하고 네트워크 URL로부터 이미지를 불러온다.
  2. 이미지를 가져오는 동안 “앨범 커버를 불러오는 중입니다. 잠시만 기다려 주세요.”라는 메시지를 화면에 표시한다.
  3. 이미지 로딩이 끝나면 paintIcon(), getWidth(), getHeight()를 비롯한 모든 메서드 호출을 이미지 아이콘 객체에게 넘긴다.
  4. 새로운 이미지 요청이 들어오면 프록시를 새로 만들고 위의 과정을 처음부터 다시 반복한다.

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 에서 볼 수 있음