문서 편집기에 커다란 이미지를 삽입한다고 하자. 이미지 파일은 수 메가바이트라 디스크에서 읽어 디코딩하는 데 시간이 걸린다. 그런데 문서를 열자마자 모든 이미지를 다 읽어 들이면, 정작 화면에 보이지도 않는 100페이지 뒤의 이미지까지 로딩하느라 문서가 열리는 데 몇 초씩 걸린다. 우리가 원하는 건 “이 이미지가 실제로 화면에 그려질 때가 돼서야 파일을 읽는 것”이다. 하지만 편집기의 레이아웃 코드는 이미지의 크기(폭·높이)를 알아야 문단을 배치하므로, 이미지 객체 자체는 문서를 여는 순간부터 자리에 있어야 한다.

여기서 요구가 둘로 갈라진다. 객체는 지금 있어야 하고, 그 객체의 무거운 내용물은 나중에 채워져야 한다. 프록시(Proxy) 패턴은 이 틈을 메운다. 진짜 이미지와 똑같은 얼굴(같은 인터페이스)을 한 대리 객체를 그 자리에 세워 두고, 크기만 물으면 미리 알던 값을 즉시 돌려주되, “그려라”라는 진짜 무거운 요청이 처음 올 때 비로소 실제 이미지를 로딩해 그 요청을 넘긴다. 클라이언트는 자기가 대리인과 이야기하는지 실체와 이야기하는지 모른다 — 그게 이 패턴의 핵심이다.

무엇이 문제인가 — 동기

접근을 통제하고 싶은 자리는 지연 로딩 하나가 아니다. 실체로 가는 길목에 무언가 끼워 넣어야 하는 상황은 네 갈래로 반복된다.

가상 프록시(virtual proxy) — 비싼 생성을 미룬다. 위의 이미지가 여기다. 객체가 무겁고(메모리·디스크·네트워크), 당장 쓰이지 않을 수도 있다면, 실제 생성을 실제 사용 시점까지 미루고 싶다. 하지만 “미룬다”고 객체 자리를 비워 둘 수는 없으니, 가벼운 대역을 세워 둔다.

보호 프록시(protection proxy) — 접근 권한을 확인한다. 어떤 문서는 편집 권한이 있는 사람만 고칠 수 있어야 한다. 실제 문서 객체의 delete()write()를 아무나 호출하지 못하게, 호출을 실체에 넘기기 전에 “이 호출자가 그럴 권한이 있는가”를 먼저 검사하는 문지기가 필요하다.

원격 프록시(remote proxy) — 다른 주소 공간을 감춘다. 진짜 객체가 다른 프로세스나 다른 서버에 산다면, 클라이언트는 그 객체를 직접 만질 수 없다. 로컬에 그 객체 행세를 하는 **스텁(stub)**을 두고, 메서드 호출을 네트워크 요청으로 바꿔 원격 실체에 보내고 응답을 받아 온다. 클라이언트는 그냥 로컬 메서드를 부른 줄 안다(자바 RMI, gRPC 스텁이 이 구조다).

스마트 참조(smart reference) — 접근에 부가 작업을 얹는다. 실제 객체에 접근하는 순간마다 참조 수를 세거나(마지막 참조가 사라지면 해제), 처음 접근할 때 디스크에서 영속 객체를 적재하거나, 동시 접근을 잠금으로 막는다. 포인터를 단순히 역참조하는 것 이상의 일을 접근 시점에 끼워 넣는다.

네 갈래는 목적이 다르지만 구조는 하나다. 실체와 클라이언트 사이에 같은 인터페이스를 한 객체를 세우고, 접근을 가로채 무언가를 한 뒤 실체에 넘긴다.

판단 기준: “실체로 가는 길목에서 무언가(로딩·권한·원격 전송·계수)를 해야 하는데, 클라이언트 코드는 그 사실을 몰라야 한다”가 보이면 프록시다. 함정: 그 “무언가”가 실체의 기능을 늘리는 일이라면 프록시가 아니라 데코레이터다 — 프록시는 접근을 통제·중개할 뿐, 실체가 하는 일을 확장하지 않는다.

패턴의 구조 — 누가 무엇을 하나

프록시는 세 배역이다. 닮은 이름의 어댑터·데코레이터와 갈라지는 지점은 “왜 감싸는가”에 있으므로, 각자의 책임을 분명히 못박아 둔다.

Subject(공통 인터페이스). 진짜 객체와 대리 객체가 함께 쓰는 얼굴이다. Image라면 draw()getWidth(). 클라이언트는 오직 이 인터페이스만 알고, 자기 손에 쥔 것이 실체인지 대리인지 구분하지 못한다 — 구분하지 못해야 프록시가 투명하게 끼어들 수 있다.

RealSubject(실제 객체). 진짜 일을 하는 무거운 실체. RealImage는 생성자에서 파일을 읽어 디코딩하고, draw()에서 실제로 픽셀을 그린다. 이 객체는 자신이 프록시 뒤에 가려져 있다는 사실조차 모른다 — 평범하게 자기 일만 한다.

Proxy(대리 객체). Subject를 구현해 클라이언트 앞에 서고, 안에 RealSubject로 가는 참조를 (지금 또는 나중에) 쥔다. 요청이 오면 자기 책임(로딩 여부 판단·권한 검사·네트워크 전송·계수)을 먼저 수행한 뒤, 실체로 요청을 넘긴다(forward). 가상 프록시라면 이 참조가 처음엔 null이고, 무거운 요청이 처음 올 때 실체를 생성해 채운다.

협력의 결정적 지점은 프록시가 실체를 언제 만드느냐다. 가상 프록시의 생명은 “게으름”에 있다 — 값싼 요청(getWidth())은 프록시가 미리 캐시해 둔 값으로 직접 답하고 실체를 만들지 않는다. 오직 비싼 요청(draw())이 처음 도착했을 때만 실체를 생성한다. 이 “처음 한 번만 생성”을 뜻하는 지연 초기화 코드가 프록시의 심장이고, 여기가 가장 자주 삐끗한다(생성 판단을 매번 빼먹거나, 동시 접근에서 두 번 생성하거나).

판단 기준: 프록시가 값싸게 답할 수 있는 요청과 실체가 있어야만 답할 수 있는 요청을 갈라, 전자는 프록시가 직접 처리하고 후자에서만 실체를 깨운다. 함정: 모든 요청을 무조건 실체로 넘기면 지연의 이득이 사라진다 — getWidth()까지 실체를 만들어 답한다면 프록시를 세운 의미가 없다.

코드로 — 패턴이 자라나는 과정

무거운 이미지를 직접 생성해 쓰던 코드에서 출발해, 같은 인터페이스의 프록시를 끼워 지연 로딩을 얻고, 거기에 접근 제어까지 얹는 과정을 단계로 따라간다.

Refactoring Step 직접 접근 — 문서를 여는 순간 모든 이미지를 로딩한다
1/4
public class RealImage {    private final String path;    private int width, height;    private byte[] pixels;      // 수 MB — 무겁다    public RealImage(String path) {        this.path = path;        loadFromDisk();          // 생성하자마자 디스크를 읽고 디코딩한다    }    private void loadFromDisk() {        System.out.println("디스크에서 로딩: " + path);   // 느린 I/O        // ... 파일을 읽어 pixels/width/height를 채운다 ...    }    public int getWidth()  { return width; }    public void draw()     { /* pixels를 화면에 그린다 */ }}// 문서를 열 때 100개의 이미지를 전부 new 하면, 화면에 안 보이는 것까지// 전부 디스크를 때린다. 레이아웃은 getWidth()만 필요한데 draw용 pixels까지// 미리 다 읽는다 — 생성 시점과 사용 시점이 붙어 있는 게 병목이다.public class Document {    public void open() {        RealImage img = new RealImage("big-photo.png");  // 여기서 이미 로딩        layout(img.getWidth());                          // 크기만 쓰는데    }}

세 번째 스텝이 프록시의 정수다. Document의 코드는 new RealImagenew ImageProxy로 바뀐 것 말고 한 줄도 달라지지 않았다. 그런데도 동작은 바뀌었다 — 크기만 물을 때는 디스크를 건드리지 않는다. 접근을 가로채는 층 하나를 실체와 똑같은 얼굴로 끼워 넣었기 때문에, 클라이언트는 자신이 무엇과 이야기하는지 모른 채 지연 로딩의 이득을 받는다. 네 번째 스텝은 그 층에 얹는 일을 지연 생성에서 권한 검사로 바꿨을 뿐, “길목에서 무언가 하고 실체로 넘긴다”는 뼈대는 그대로다.

판단 기준: 프록시를 끼운 뒤 클라이언트 코드가 생성 지점 한 줄 말고 그대로면 투명하게 앉은 것이다. 함정: 프록시가 Subject 인터페이스에 없는 메서드(proxy.forceLoad() 같은)를 노출하고 클라이언트가 그걸 부르기 시작하면, 대리인의 존재가 새어 나온 것이다 — 투명성이 깨지면 프록시를 나중에 실체로 바꿔 끼울 수 없다.

결과 — 무엇을 얻고 무엇을 내주나

프록시가 사는 값은 접근의 통제와 지연이다. 실체로 가는 길목에 층 하나를 세워, 클라이언트가 눈치채지 못하는 사이에 로딩을 미루고(가상), 권한을 지키고(보호), 원격 호출을 로컬처럼 감추고(원격), 접근을 계수한다(스마트). 클라이언트도 실체도 이 통제를 몰라도 되므로, 관심사가 깔끔히 갈라진다 — 실체는 자기 일만, 프록시는 접근 관리만. 가상 프록시라면 쓰이지 않는 무거운 객체를 아예 만들지 않아, 문서 여는 시간과 메모리가 눈에 띄게 준다.

대가는 세 가지다. 첫째, 간접층이 하나 는다. 모든 호출이 프록시를 한 번 거쳐 실체로 간다. 값싼 위임이지만, 호출 경로에 층이 끼면 “이 draw()가 실제로 어디서 실행되나”를 추적할 때 프록시를 한 단계 더 건너뛰어야 한다. 둘째, 응답 지점이 흐려진다. 클라이언트가 getWidth()를 불렀는데 어떤 때는 프록시가 답하고(실체 미생성) 어떤 때는 실체가 답한다(이미 생성됨). 겉보기 같은 호출이 상황에 따라 다른 객체에서 처리되니, 디버깅할 때 “지금 실체가 있나 없나”라는 상태를 먼저 따져야 한다. 원격 프록시라면 더 심하다 — 로컬 메서드처럼 보이는 호출이 실은 네트워크를 타므로, 지연·실패·타임아웃이 메서드 시그니처에는 안 보이는 채로 숨어 있다. 셋째, 일관성 관리 부담이 생긴다. 프록시와 실체가 같은 인터페이스를 구현하니, Subject에 메서드가 추가되면 실체와 프록시 양쪽을 다 고쳐야 한다.

운영비용 렌즈로 보면 순이득의 조건이 분명하다. 실체가 무겁거나(가상), 접근이 통제돼야 하거나(보호), 다른 주소 공간에 있거나(원격) — 접근 자체에 관리할 무언가가 있을 때만, 늘어난 간접층과 흐려진 응답 지점의 대가보다 통제·지연의 이득이 크다. 반대로 실체가 가볍고 접근에 아무 조건이 없다면, 프록시는 호출을 한 번 튕겨 보낼 뿐인 빈 껍데기 — 간접성만 더하고 이득은 없다.

판단 기준: 얻는 것(지연·통제·원격 은닉)과 내주는 것(간접층·응답 지점 분산·이중 유지보수)을 세어 순이득이 양수일 때만 프록시를 앉힌다. 함정: “혹시 나중에 무거워질지 모르니” 가벼운 객체까지 프록시로 감싸면, 오지 않을 부하에 거는 보험으로 지금의 추적 비용만 치른다 — 지연은 실제로 무거운 것에만.

언제 쓰고, 언제 과용인가

프록시가 정확히 맞는 자리는 접근에 조건이 붙는 곳이다. 생성이 비싸고 당장 안 쓰일 수 있는 객체(가상 — 큰 이미지·문서·대용량 컬렉션의 지연 적재), 호출자에 따라 접근을 허용·차단해야 하는 객체(보호 — 권한별 편집 제어), 다른 프로세스·서버에 사는 객체(원격 — RMI·gRPC 스텁), 접근 시점에 계수·잠금·적재를 끼워야 하는 객체(스마트 참조). 자바 표준에도 있다 — java.lang.reflect.Proxy가 동적으로 인터페이스 프록시를 만들어, 스프링 AOP의 트랜잭션·보안 프록시가 이 위에서 메서드 호출을 가로챈다.

과용의 냄새는 “통제할 게 없는데 감싸는” 데서 난다. 실체가 가볍고 접근에 아무 조건이 없는데 프록시를 두면, 그건 호출을 그대로 전달만 하는 죽은 층이다. 지연이 필요 없는데 가상 프록시를 흉내 내면, getWidth()까지 실체를 만들어 답하면서 지연의 이득은 하나도 못 얻고 코드만 두 배가 된다. 또 하나 흔한 실수는 프록시에 실체를 조종하는 특별 메서드를 달아 클라이언트가 그걸 부르게 하는 것 — 그 순간 프록시는 투명한 대역이 아니라 클라이언트가 의존하는 별개 타입이 돼, 나중에 실체로 갈아 끼울 수 없게 된다.

여기서 이웃 패턴과의 경계를 그어 둘 필요가 있다. [데코레이터]와 프록시는 구조가 쌍둥이다 — 둘 다 Subject 인터페이스를 구현하고 안에 같은 인터페이스의 객체를 감싸 호출을 위임한다. 갈라지는 것은 의도다. 데코레이터는 실체가 하는 일에 기능을 더한다(“이 스트림에 버퍼링·압축을 얹어라”) — 감싸는 목적이 확장이다. 프록시는 실체가 하는 일은 그대로 두고 접근을 통제한다(“이 실체를 아직 만들지 마라 / 권한 없으면 막아라”) — 감싸는 목적이 중개다. 데코레이터는 보통 여러 겹을 자유로이 쌓아 조합하는 게 요점이고(런타임에 기능을 붙였다 뗐다), 프록시는 실체와 일대일로 서서 그 하나로 가는 길을 지키는 게 요점이다. [어댑터]와도 헷갈리기 쉬운데, 어댑터는 다른 인터페이스를 원하는 인터페이스로 변환한다(맞지 않는 것을 맞게) — 프록시는 실체와 같은 인터페이스를 유지한다(바꾸지 않고 지킨다). 인터페이스를 바꾸면 어댑터, 그대로 두고 접근만 통제하면 프록시, 그대로 두고 기능을 더하면 데코레이터다.

판단 기준: 감싸는 목적이 “실체로 가는 접근을 통제·지연·중개”면 프록시, “실체의 기능을 확장”이면 데코레이터, “실체의 인터페이스를 다른 모양으로 변환”이면 어댑터다. 함정: 셋의 구조(같은 인터페이스로 감싸 위임)가 똑같아 코드만 봐서는 구분이 안 된다 — “왜 감쌌는가”라는 의도로만 갈린다. 그래서 이름(ImageProxy vs BufferedImage vs ImageAdapter)으로 의도를 드러내는 게 중요하다.

다음으로 책임연쇄