디자인 패턴 - 프록시 패턴(Proxy pattern) 자바 코드 예시로 보기
먼저 프록시(proxy)의 뜻을 살펴봅시다.
위처럼 proxy 는 대리, 대리인, 대용물 이라는 뜻을 가집니다.
그렇다면 프록시 패턴이라 하면, 누군가가 어떠한 일(역할)을 대신 해주는 모습의 디자인 패턴임을 예상할 수 있겠네요.
Proxy pattern(프록시 패턴)
프록시 패턴은 클라이언트가 실제 서비스 객체를 대신하는 객체를 제공해주는 구조 디자인 패턴입니다.
클라이언트의 요청을 수신하고 일부 작업들을 수행한 다음 요청을 서비스 객체에 전달하는 형태이지요.
여기서 일부 작업들에는 흔히 접근 제어, 캐싱 등이 있습니다.
프록시 객체는 서비스 객체와 같은 인터페이스를 가지기 때문에 클라이언트에 전달되면 실제 객체와 상호적으로 교환이 가능합니다.
우리가 코드를 변경할 수 없는 클래스에 몇가지의 행동들을 추가해야 할 때 매우 중요합니다.
'알렉산더 슈베츠의 디자인 패턴에 뛰어들기` 라는 책에서는 이렇게 말하고 있습니다.
다른 객체에 대한 대체 혹은 placeholder(자리 표시자)를 제공할 수 있는 구조 디자인 패턴이다.
프록시는 원래 객체에 대한 접근을 제어한다.
우리의 요청이 원래 객체에 전달 되기 전 또는 후에 무언가를 수행할 수 있게 해준다.
그렇다면 객체에 대한 접근을 제한하는 이유는 무엇일까요?
아래 추상적인 문제 상황를 통해 알아봅시다.
문제 상황
아래 그림처럼 많은 양의 시스템 자원을 소비하는 거대한 객체가 있다고 합시다.
그리고 여러 클라이언트에서 이 객체를 사용합니다.
그런데 이 객체는 항상 필요한 것이 아닙니다.
그러니, 실제로 필요할 때, 사용하는 시점에 이 객체를 초기화하는 지연 초기화(lazy initilaize)를 하면 좋겠네요.
그런데 여러 클라이언트가 이 객체를 사용하므로 그런 클라이언트들은 해당 객체에 대한 지연된 초기화를 해야 합니다.
즉, 많은 중복 코드를 만들 수 밖에 없죠.
물론, 이 중복 코드를 거대 객체의 클래스에 직접 넣을 수도 있습니다.
하지만 만약 그 클래스가 마음대로 수정할 수 없는 third-party 라이브러리라면 어떻게 할까요?
그렇다면 이 중복 코드를 클래스에 직접 넣을 수 없습니다.
해결책
프록시 패턴으로 원래 서비스 객체와 같은 인터페이스로 새로운 프록시 클래스를 생성합시다.
그리고 프록시 객체를 원래 객체의 모든 클라이언트들에 전달하도록 바꿀 수 있습니다.
위처럼 클라이언트로부터 요청을 받으면 이 포록시는 실제 서비스 객체를 생성해주고, 모든 작업을 거대 객체에게 위임합니다.
프록시 객체는 위 그림에서의 오른쪽에 있는 거대 객체로 자신을 변장합니다.
그렇다면 프록시는 여러 작업을 클라이언트와 실제 거대 객체가 모르는 상태에서 처리할 수 있습니다.
실제로 프록시는 원래 클래스와 같은 인터페이스를 구현하므로 실제 서비스 객체를 기대하는 모든 클라이언트에게 전달될 수 있습니다.
실세계로 비유하면?
신용 카드와 현금은 결제에 사용가능합니다.
신용 카드는 은행 계좌의 프록시라고 할 수 있스며, 은행 계좌는 현금의 프록시라고 할 수 있겠죠.
그리고 위 그림에서처럼 CreditCard 와 Cash 역시 같은 인터페이스 Payment 를 구현하기 때문에 둘 다 결제에 사용될 수 있습니다.
이렇게 되면 손님은 신용카드만 들고 다녀도 되어서, 사장님은 가게에 너무 많은 현금을 가지고 있지 않아도 되어서 둘 다 이득이 되겠죠.
위 그림에서 보면 마치 단순 상속을 사용하는 관계로 보일 수 있지만 CreditCard 에서 Cash 로의 화살표가 있다는 것이 중요합니다.
프록시 패턴 구조
일반적인 프록시 패턴의 구조입니다.
위 구조에서는 Proxy 가 Service 를 직접 포함하고, 참조하고 있는 형태로 되어 있습니다.
이러한 구조말고도 Proxy 가 Service Interface 를 포함하면서 참조하고 있도록 만들기도 합니다.
그럴 경우에는 외부에서 Proxy 클래스 내에서의 ServiceInterface 에 대해 DI(Dependency Injection, 의존성 주입) 을 해주면 되겠죠.
자바에서의 프록시 패턴
먼저 자바에서의 프록시 패턴을 사용하는 부분은 아래와 같습니다.
- java.lang.reflect.Proxy
- javax.ejb.EJB
- javax.inject.Inject
- javax.persistence.PersistenceContext
위 java.lang.reflect.Proxy 는 이전 글( '.... Retrofit 알아보기 (2) - create .. ') 에서 사용했던 클래스네요.
다음 글에서는 자바 진영에서의 reflect 기술과 Proxy 클래스에 대해 자세히 알아봐야 겠군요.
자바에서 프록시 구현해보기
코드는 역시 '알렉산더 슈베츠의 디자인 패턴에 뛰어들기` 에서 참조했습니다.
프로시 패턴을 유투브 관련 서드파티 라이브러리에 적용한다고 예를 들어봅시다.
프록시 패턴으로 third-party 유투브 통합 라이브러리(some_media_library) 에 지연 초기화와 캐싱을 도입하는 예시입니다.
이 some_media_library 라이브러리에서는 비디오 다운로드를 쉽게 할 수 있도록 지원합니다. 하지만 매우 비효율적입니다.
만약 클라이언트 앱이 같은 비디오를 연속으로 여러 번 요청하면, 라이브러리는 계속해서 같은 비디오를 다운로드하게 됩니다.
기존 코드 보기
some_media_libary
ThirdPartyYoutubeLib.java : Remote service 인터페이스
public interface ThirdPartyYouTubeLib {
HashMap<String, Video> popularVideos();
Video getVideo(String videoId);
}
ThirdPartyYouTubeClass.java: Remote service 구현체
public class ThirdPartyYouTubeClass implements ThirdPartyYouTubeLib {
@Override
public HashMap<String, Video> popularVideos() {
connectToServer("http://www.youtube.com");
return getRandomVideos();
}
@Override
public Video getVideo(String videoId) {
connectToServer("http://www.youtube.com/" + videoId);
return getSomeVideo(videoId);
}
// -----------------------------------------------------------------------
// 아래는 실제 네트워크 통신이 아닌, 흉내내기 위해 만든 함수들임. 느리게 수행되게 하기 위해 sleep 사용함.
private int random(int min, int max) {
return min + (int) (Math.random() * ((max - min) + 1));
}
private void experienceNetworkLatency() {
int randomLatency = random(5, 10);
for (int i = 0; i < randomLatency; i++) {
try {
Thread.sleep(100);
} catch (InterruptedException ex) {
ex.printStackTrace();
}
}
}
private void connectToServer(String server) {
System.out.print("Connecting to " + server + "... ");
experienceNetworkLatency();
System.out.print("Connected!" + "\n");
}
private HashMap<String, Video> getRandomVideos() {
System.out.print("Downloading populars... ");
experienceNetworkLatency();
HashMap<String, Video> hmap = new HashMap<String, Video>();
hmap.put("catzzzzzzzzz", new Video("sadgahasgdas", "Catzzzz.avi"));
hmap.put("mkafksangasj", new Video("mkafksangasj", "Dog play with ball.mp4"));
hmap.put("dancesvideoo", new Video("asdfas3ffasd", "Dancing video.mpq"));
hmap.put("dlsdk5jfslaf", new Video("dlsdk5jfslaf", "Barcelona vs RealM.mov"));
hmap.put("3sdfgsd1j333", new Video("3sdfgsd1j333", "Programing lesson#1.avi"));
System.out.print("Done!" + "\n");
return hmap;
}
private Video getSomeVideo(String videoId) {
System.out.print("Downloading video... ");
experienceNetworkLatency();
Video video = new Video(videoId, "Some video title");
System.out.print("Done!" + "\n");
return video;
}
}
위 클래스는 서비스 커넥터의 구현체입니다. 이 클래스 메서드들로 유투브에서 정보를 요청합니다.
Video.java: 비디오 파일 관련 데이터 클래스
public class Video {
public String id;
public String title;
public String data;
Video(String id, String title) {
this.id = id;
this.title = title;
this.data = "Random video.";
}
// ...
}
우리는 여기에 프록시 클래스를 추가해서 더 효율적으로 동작하도록 만들겠습니다.
프록시 클래스 추가하기
프록시 클래스는 원래 인터페이스를 구현하고 다운로더에 모든 작업을 위임합니다.
만약 앱이 같은 비디오를 두 번 이상 요청한다면, 이미 다운로드한 파일을 추적한 후에 캐시된 결과를 리턴하도록 합니다.
YoutubeCacheProxy.java
public class YouTubeCacheProxy implements ThirdPartyYouTubeLib {
private ThirdPartyYouTubeLib youtubeService;
private HashMap<String, Video> cachePopular = new HashMap<String, Video>();
private HashMap<String, Video> cacheAll = new HashMap<String, Video>();
public YouTubeCacheProxy() {
this.youtubeService = new ThirdPartyYouTubeClass();
}
@Override
public HashMap<String, Video> popularVideos() {
if (cachePopular.isEmpty()) {
cachePopular = youtubeService.popularVideos();
} else {
System.out.println("Retrieved list from cache.");
}
return cachePopular;
}
@Override
public Video getVideo(String videoId) {
Video video = cacheAll.get(videoId);
if (video == null) {
video = youtubeService.getVideo(videoId);
cacheAll.put(videoId, video);
} else {
System.out.println("Retrieved video '" + videoId + "' from cache.");
}
return video;
}
public void reset() {
cachePopular.clear();
cacheAll.clear();
}
}
위 프록시 객체를 통해서 네트워크 통신 리소스를 절약하기 위해 요청 결과를 캐시하고 일정 기간동안 보관할 수 있습니다.
some_media_library 는 third-party 라이브러리이기 때문에 캐싱하는 코드를 직접 넣을 수 없어서 프록시 패턴을 사용해야 합니다.
또 어떤 클래스가 final 로 정의되어 있다면 상속이나 변경 등이 불가능하기 때문에 이 경우도 프록시 패턴을 사용해야 합니다.
YouTubeDownloader.java: 영상 다운로드 app 부분.
public class YouTubeDownloader {
private ThirdPartyYouTubeLib api;
public YouTubeDownloader(ThirdPartyYouTubeLib api) {
this.api = api;
}
public void renderVideoPage(String videoId) {
Video video = api.getVideo(videoId);
System.out.println("\n-------------------------------");
System.out.println("Video page (imagine fancy HTML)");
System.out.println("ID: " + video.id);
System.out.println("Title: " + video.title);
System.out.println("Video: " + video.data);
System.out.println("-------------------------------\n");
}
public void renderPopularVideos() {
HashMap<String, Video> list = api.popularVideos();
System.out.println("\n-------------------------------");
System.out.println("Most popular videos on YouTube (imagine fancy HTML)");
for (Video video : list.values()) {
System.out.println("ID: " + video.id + " / Title: " + video.title);
}
System.out.println("-------------------------------\n");
}
}
그리고 이제 만약 초기화 코드가 아래와 같다면,
Demo.java: Initialization code
public class Demo {
public static void main(String[] args) {
YouTubeDownloader naiveDownloader = new YouTubeDownloader(new ThirdPartyYouTubeClass());
YouTubeDownloader smartDownloader = new YouTubeDownloader(new YouTubeCacheProxy());
long naive = test(naiveDownloader);
long smart = test(smartDownloader);
System.out.print("Time saved by caching proxy: " + (naive - smart) + "ms");
}
private static long test(YouTubeDownloader downloader) {
long startTime = System.currentTimeMillis();
// User behavior in our app:
downloader.renderPopularVideos();
downloader.renderVideoPage("catzzzzzzzzz");
downloader.renderPopularVideos();
downloader.renderVideoPage("dancesvideoo");
// Users might visit the same page quite often.
downloader.renderVideoPage("catzzzzzzzzz");
downloader.renderVideoPage("someothervid");
long estimatedTime = System.currentTimeMillis() - startTime;
System.out.print("Time elapsed: " + estimatedTime + "ms\n");
return estimatedTime;
}
}
test 함수에서 catzzzzzzzzz 라는 비디오를 두번 렌더링하고 있습니다.
코드 실행 시 결과는 아래처럼 됩니다. (더보기 클릭하면 출력이 보입니다)
Connecting to http://www.youtube.com... Connected!
Downloading populars... Done!
-------------------------------
Most popular videos on YouTube (imagine fancy HTML)
ID: sadgahasgdas / Title: Catzzzz.avi
ID: asdfas3ffasd / Title: Dancing video.mpq
ID: 3sdfgsd1j333 / Title: Programing lesson#1.avi
ID: mkafksangasj / Title: Dog play with ball.mp4
ID: dlsdk5jfslaf / Title: Barcelona vs RealM.mov
-------------------------------
Connecting to http://www.youtube.com/catzzzzzzzzz... Connected!
Downloading video... Done!
-------------------------------
Video page (imagine fancy HTML)
ID: catzzzzzzzzz
Title: Some video title
Video: Random video.
-------------------------------
Connecting to http://www.youtube.com... Connected!
Downloading populars... Done!
-------------------------------
Most popular videos on YouTube (imagine fancy HTML)
ID: sadgahasgdas / Title: Catzzzz.avi
ID: asdfas3ffasd / Title: Dancing video.mpq
ID: 3sdfgsd1j333 / Title: Programing lesson#1.avi
ID: mkafksangasj / Title: Dog play with ball.mp4
ID: dlsdk5jfslaf / Title: Barcelona vs RealM.mov
-------------------------------
Connecting to http://www.youtube.com/dancesvideoo... Connected!
Downloading video... Done!
-------------------------------
Video page (imagine fancy HTML)
ID: dancesvideoo
Title: Some video title
Video: Random video.
-------------------------------
Connecting to http://www.youtube.com/catzzzzzzzzz... Connected!
Downloading video... Done!
-------------------------------
Video page (imagine fancy HTML)
ID: catzzzzzzzzz
Title: Some video title
Video: Random video.
-------------------------------
Connecting to http://www.youtube.com/someothervid... Connected!
Downloading video... Done!
-------------------------------
Video page (imagine fancy HTML)
ID: someothervid
Title: Some video title
Video: Random video.
-------------------------------
Time elapsed: 9354ms
Connecting to http://www.youtube.com... Connected!
Downloading populars... Done!
-------------------------------
Most popular videos on YouTube (imagine fancy HTML)
ID: sadgahasgdas / Title: Catzzzz.avi
ID: asdfas3ffasd / Title: Dancing video.mpq
ID: 3sdfgsd1j333 / Title: Programing lesson#1.avi
ID: mkafksangasj / Title: Dog play with ball.mp4
ID: dlsdk5jfslaf / Title: Barcelona vs RealM.mov
-------------------------------
Connecting to http://www.youtube.com/catzzzzzzzzz... Connected!
Downloading video... Done!
-------------------------------
Video page (imagine fancy HTML)
ID: catzzzzzzzzz
Title: Some video title
Video: Random video.
-------------------------------
Retrieved list from cache.
-------------------------------
Most popular videos on YouTube (imagine fancy HTML)
ID: sadgahasgdas / Title: Catzzzz.avi
ID: asdfas3ffasd / Title: Dancing video.mpq
ID: 3sdfgsd1j333 / Title: Programing lesson#1.avi
ID: mkafksangasj / Title: Dog play with ball.mp4
ID: dlsdk5jfslaf / Title: Barcelona vs RealM.mov
-------------------------------
Connecting to http://www.youtube.com/dancesvideoo... Connected!
Downloading video... Done!
-------------------------------
Video page (imagine fancy HTML)
ID: dancesvideoo
Title: Some video title
Video: Random video.
-------------------------------
Retrieved video 'catzzzzzzzzz' from cache.
-------------------------------
Video page (imagine fancy HTML)
ID: catzzzzzzzzz
Title: Some video title
Video: Random video.
-------------------------------
Connecting to http://www.youtube.com/someothervid... Connected!
Downloading video... Done!
-------------------------------
Video page (imagine fancy HTML)
ID: someothervid
Title: Some video title
Video: Random video.
-------------------------------
Time elapsed: 5875ms
Time saved by caching proxy: 3479ms
ThirdPartyYouTubeClass 를 사용하는 naiveDownloader 로 테스트를 수행한 결과는 모든 비디오 요청에 대해서 직접 네트워크에 연결해서 통신하고 비디오를 다운로드하고 있습니다.
9354 ms 가 걸린 것을 확인할 수 있습니다.
하지만 YoutubeCacheProxy 를 사용하는 smartDownloader 로 테스트를 수행한 결과는 비디오가 캐싱되어 있다면 따로 네트워크 연결로 비디오를 다운로드하지 않고 캐싱된 비디오를 가져오는 것을 알 수 있습니다.
Retrieved video 'catzzzzzzzzz' from cache. 라는 부분으로 확인할 수 있죠.
이 때는 5875 ms 가 걸렸다고 나오네요.
이렇게 캐싱을 사용해서 3400 ms 가량의 시간을 절약했습니다.
프록시 패턴의 적용
이제 프록시 패턴이 아래와 같은 경우에 적용됩니다.
- 지연 초기화 {Virtual Proxy (가상 프록시)} : 앱이 시작될 때 객체 생성 대신, 필요한 시점에 초기화하도록 할때
- 접근 제어 {Protection Proxy (보호 프록시)} : 클라이언트의 자격 증명이 정해진 기준을 만족하는 경우에만 서비스 객체에 요청을 전달할 수 있도록 할 때
- 원격 서비스를 로컬 실행 {Remote Proxy (원격 프록시)} : 프록시가 네트워크로 클라이언트 요청을 전달해서 네트워크 작업의 복잡한 세부 사항을 처리함.
- 로깅 {Logging Proxy (로깅 프록시)} : 프록시가 각 요청을 서비스에 전달하기 전에 로깅(기록)함.
- 요청 결과 캐싱 {Caching Proxy {캐싱 프록시)} : 같은 결과를 만드는 반복 요청에 캐싱 구현.
- 스마트 참조 (Smart Reference): 객체의 메서드를 호출하기 전에 해당 객체를 초기화하거나 다른 작업을 수행하는 데 사용.
프록시 패턴의 장단점
👍 장점
- 캡슐화의 이점
- 클라이언트들이 알지 못하는 상태에서 서비스 객체를 제어 가능..
- 클라이언트들이 신경 쓰지 않을 때 서비스 객체의 수명 주기를 관리 가능.
- 테스트의 이점
- 서비스 객체가 아직 없거나 사용할 수 없는 경우에도 프록시까지는 작동함. (서비스 객체 없이도 프록시만 테스트 가능함.)
- OCP(Open Closed Principal, 개방/폐쇄 원칙)을 만족
- 서비스나 클라이언트들을 변경하지 않고도 새 프록시들을 도입할 수 있습니다.
👎 단점
- 새로운 클래스들을 많이 도입해야 해서 코드가 복잡해질 수 있음.
- 서비스의 응답이 늦어질 수 있음.
프록시 패턴과 데코레이터 패턴 비교하기
바로 이전에 디자인 패턴 공부했던 글에서 데코레이터 패턴에 대해 공부했습니다.
그런데 패턴의 그림을 보니 꽤 비슷해 보입니다. 둘은 어떻게 다를까요?
일단 두 패턴 모두 한 객체가 일부 작업을 다른 객체에 위임해야 하는 합성 원칙을 기반으로 합니다.
하지만 프록시는 먼저 같은 인터페이스를 서비스 객체에게 제공합니다.
반면에 데코레이터는 향상된 인터페이스를 래핑된 객체에게 제공합니다.
데코레이터 패턴에서는 기본 인터페이스(Component)를 구현하는 컴포넌트( Concrete Component )가 있고, 인터페이스를 구현한 향상된 인터페이스( Base Decorator )는 Concrete Component 를 래핑합니다.
향상된 인터페이스를 구현하는 여러 데코레이터 클래스들로 필요하면 결과를 수정하거나 추가적인 동작을 수행하도록 할 수 있습니다.
(향상된 인터페이스에서 인터페이스는 java 의 interface 를 특정해서 말하는 것이 아닌, 개념적인 의미임.)
그리고 프록시는 일반적으로 자체적으로 자신의 서비스 객체의 수명 주기를 관리하는 반면에,
데코레이터의 합성은 항상 클라이언트에 의해 제어됩니다.
즉, 프록시에서는 프록시 객체가 생성되면 서비스 객체가 생성되는 것이고, 프록시 객체가 소멸되면 서비스 객체도 소멸되는 것입니다.
하지만 데코레이터에서는 클라이언트에서 객체에 필요한 데코레이터를 선택하고 조합한다는 것입니다.
참고 링크
https://sh1mj1-log.tistory.com/163
https://refactoring.guru/design-patterns/proxy
https://en.dict.naver.com/#/search?range=all&query=proxy
https://refactoring.guru/design-patterns/proxy