테마 선택

📚 지식 노트

AI·하드웨어·주변기기·게임·컴퓨터 시스템·이론 컴퓨터 과학·수학·개발과 운영·보안·응용 분야에 대해 알아 두면 좋은 내용을 정리한 노트다. 위에서 분야를 고르고 아래에서 노트를 고른다.

구조 패턴 2: 퍼사드·플라이웨이트·프록시

복잡한 것을 한 창구로, 많은 것을 가볍게, 진짜 대신 대리인을 세우는 법이다.

목차 (7)
  1. 들어가며
  2. 퍼사드: 복잡한 하위 시스템에 창구 하나
  3. 플라이웨이트: 같은 상태를 나눠 써서 가볍게
  4. 프록시: 진짜 대신 같은 모양의 대리인
  5. 일곱 구조 패턴 비교: 모양이 비슷한데 의도가 다르다
  6. 흔한 오해
  7. 핵심 자료

들어가며

구조 패턴 일곱 가지 가운데 나머지 셋을 다룬다. 퍼사드는 복잡한 것 앞에 창구 하나를 세우고, 플라이웨이트는 같은 상태를 여러 객체가 나눠 쓰게 하고, 프록시는 진짜 객체 앞에 같은 모양의 대리인을 세운다. 어댑터, 브리지, 컴포지트, 데코레이터는 구조 패턴 1에 있고, 글 끝에서 일곱 가지를 한 표로 비교한다. 패턴의 유래와 쓰지 말아야 할 때는 디자인 패턴이란, 결합도와 응집도 같은 설계 원칙은 소프트웨어 공학 노트에 있다.

클래스 그림 범례: 실선과 삼각형은 상속, 점선과 삼각형은 구현, 실선 화살표는 참조와 위임, 점선 화살표는 의존, 마름모는 부분을 가짐상속(extends): 삼각형 쪽이 부모구현(implements): 점선, 머리는 같다참조·위임: 필드로 들고 부른다의존: 만들거나 잠깐 쓴다부분을 가짐: 마름모 쪽이 전체강조: 이 패턴이 새로 세우는 역할상자 맨 위 작은 글자는 패턴 속 역할, 가운데는 예시 클래스 이름, 아래는 멤버다
이 글의 클래스 그림 범례. 구조 패턴 1과 같은 선 모양을 쓴다.

퍼사드: 복잡한 하위 시스템에 창구 하나

의도

여러 클래스로 이루어진 하위 시스템 앞에 높은 수준의 단순한 인터페이스 하나를 두어, 클라이언트가 내부를 몰라도 흔한 일을 할 수 있게 한다.

문제 상황

동영상을 올리면 다른 형식으로 바꿔야 한다. 파일을 열고, 맞는 코덱을 고르고, 원본 비트레이트를 읽고, 오디오를 섞고, 결과를 써야 한다. 컨트롤러가 이 클래스 다섯 개를 직접 순서대로 부르면, 변환 라이브러리를 바꾸거나 순서를 고칠 때 이 라이브러리를 쓰는 모든 곳을 찾아다녀야 한다. 주문도 같다. 재고 예약, 결제, 배송 접수, 알림을 컨트롤러마다 따로 엮으면 결제 실패 때 예약을 푸는 처리가 어딘가에서 빠진다.

구조

퍼사드 클래스 그림. UploadController는 VideoConverter 하나만 부르고, VideoConverter가 VideoFile, CodecFactory, BitrateReader, AudioMixer를 부린다클라이언트(Client)UploadController퍼사드(Facade)VideoConverterconvert(file, format)VideoFileCodecFactoryBitrateReaderAudioMixer하위 시스템파일 열기, 코덱 고르기, 비트레이트 읽기, 오디오 섞기의 순서는 퍼사드만 안다하위 클래스는 퍼사드를 모르고, 필요하면 직접 써도 된다(창구는 선택이다)
퍼사드의 클래스 그림. 클라이언트는 퍼사드 하나에만 의존하고, 하위 시스템 넷(Subsystem)은 퍼사드를 모른다.
  • 퍼사드(Facade): 어떤 하위 객체를 어떤 순서로 부를지 안다. 클라이언트의 요청을 하위 시스템에 나눠 맡긴다.
  • 하위 시스템(Subsystem): 실제 일을 하는 클래스들. 퍼사드의 존재를 모르고, 직접 써도 된다.

예시 코드

주문 퍼사드는 호출 순서와 실패했을 때의 되돌리기를 한곳에 둔다. 컨트롤러, 배치 작업, 관리자 화면이 모두 같은 placeOrder를 부르므로 "결제가 실패하면 예약을 푼다"는 규칙이 한 번만 쓰인다.

// 퍼사드: 주문 한 건에 필요한 하위 시스템 호출 순서와 되돌리기를 한곳에 모은다
public final class OrderFacade {
    private static final Logger log = LoggerFactory.getLogger(OrderFacade.class);

    private final InventoryService inventory;
    private final PaymentGateway payment;
    private final ShippingService shipping;
    private final NotificationService notification;

    public OrderFacade(
            InventoryService inventory,
            PaymentGateway payment,
            ShippingService shipping,
            NotificationService notification
    ) {
        this.inventory = inventory;
        this.payment = payment;
        this.shipping = shipping;
        this.notification = notification;
    }

    public OrderResult placeOrder(Cart cart, Address address) {
        Reservation reservation = inventory.reserve(cart.items());
        Receipt receipt;
        try {
            receipt = payment.pay(cart.total());
        } catch (PaymentFailedException e) {
            log.warn("결제 실패로 재고 예약을 푼다: {}", reservation.id(), e);
            inventory.release(reservation);
            throw e;
        }
        Shipment shipment = shipping.schedule(reservation, address);
        notification.orderPlaced(cart.customerId(), shipment.trackingNumber());
        return new OrderResult(receipt, shipment);
    }
}

// 컨트롤러는 한 줄만 안다
OrderResult result = orderFacade.placeOrder(cart, address);
// 동영상 변환 퍼사드를 쓰는 쪽: 코덱·비트레이트·오디오 혼합을 몰라도 된다
File converted = new VideoConverter().convert(new File("clip.ogg"), "mp4");

실제 쓰임

  • SLF4J: 이름부터 Simple Logging Facade for Java다. 사용자 매뉴얼은 SLF4J를 java.util.logging, logback 같은 여러 로깅 프레임워크의 "단순한 퍼사드 또는 추상"이라 소개하고, 쓰는 쪽이 원하는 로깅 프레임워크를 배포할 때 끼우게 한다고 적는다. 엄밀히는 백엔드를 갈아 끼우는 구조라 브리지의 성격도 함께 가진다.
  • 서비스 계층과 SDK의 고수준 클라이언트: 여러 저장소와 외부 호출을 엮는 애플리케이션 서비스, 여러 저수준 API 호출을 한 메서드로 묶은 SDK의 편의 클래스가 퍼사드 자리다.
// 코드는 SLF4J API만 안다. 실제 출력은 배포할 때 클래스패스에 둔 백엔드(logback 등)가 한다
private static final Logger log = LoggerFactory.getLogger(OrderFacade.class);

log.info("주문 {} 접수", orderId);

장단점

장점단점
클라이언트가 의존하는 클래스 수가 준다(결합도가 낮아진다)무엇이든 넣다 보면 모든 것을 아는 거대한 클래스(god object)가 된다
호출 순서와 실패 처리가 한곳에 모인다퍼사드가 감추는 세부 옵션이 필요한 클라이언트는 결국 하위 시스템을 직접 쓴다
하위 시스템을 바꿔도 퍼사드만 고친다얇은 전달만 하는 퍼사드는 이름만 하나 더 생긴 것이다

주의

  • 막는 벽이 아니라 창구다: 하위 시스템을 직접 쓰는 길을 막지 않는다. 막으려면 패키지 가시성이나 모듈 경계로 따로 정한다.
  • 쓰임새마다 퍼사드를 나눈다: 주문 접수, 환불, 정산을 한 퍼사드에 넣지 말고 흐름마다 둔다.
  • 어댑터와의 차이: 어댑터는 이미 정해진 인터페이스에 맞추고, 퍼사드는 쓰기 쉬운 인터페이스를 새로 정한다.

다른 언어에서는

  • Go, Python: 클래스보다 패키지·모듈 수준의 공개 함수 몇 개가 퍼사드 역할을 한다. Go는 이름의 첫 글자가 대문자인지로 패키지 밖에 보일지가 정해지므로(Effective Go의 이름 절), 하위 타입은 소문자로 감추고 진입 함수만 대문자로 드러낸다.
  • JavaScript: 라이브러리의 index.js가 내부 모듈 여럿을 다시 내보내며 고른 함수만 드러내는 것이 흔한 퍼사드다.

플라이웨이트: 같은 상태를 나눠 써서 가볍게

의도

아주 많은 객체가 똑같이 가진 상태를 공유 객체 하나로 빼내, 객체 수와 메모리를 줄인다.

문제 상황

게임 숲에 나무가 1만 그루 있다. 나무마다 메시와 텍스처를 들고 있으면, 실제로는 다섯 종류뿐인데도 같은 데이터가 1만 벌 메모리에 올라간다. 나무마다 다른 것은 위치와 크기와 회전 정도다. 같은 부분을 종류마다 한 벌만 두고 나무는 그것을 가리키게 하면 된다.

나무 10000그루에서 그루마다 공유 상태를 복사하면 9.8 GiB, 플라이웨이트로 나누면 5.3 MiB다 가정: 종류 5가지, 종류마다 메시·텍스처 1 MiB, 그루마다 위치·크기·회전 24 B, 참조 8 B10,000그루 · 그루마다 복사9.8 GiB10,000그루 · 플라이웨이트5.3 MiB100,000그루 · 그루마다 복사97.7 GiB100,000그루 · 플라이웨이트8.1 MiB1 MiB32 MiB1 GiB32 GiB가로축은 로그 눈금이다. 차이는 1만 그루에서 약 1,885배
숲의 메모리 어림셈. 종류마다 1 MiB인 메시·텍스처를 그루마다 복사하면 수 GiB, 한 벌씩만 두고 가리키면 몇 MiB다. 가정 값은 그림 위에 있고, 계산은 계산 함수로 했다.

Wikipedia는 이 개념을 1990년 Paul Calder와 Mark Linton이 WYSIWYG 문서 편집기의 글리프(글자 모양) 정보를 다루려고 처음 깊이 다뤘다고 적는다. 글자마다 글꼴 윤곽을 들지 않고, 같은 글자는 같은 글리프 객체를 가리키고 각자는 위치만 든다. 공유하는 쪽을 고유 상태(intrinsic, 맥락과 상관없이 같은 것), 객체마다 다른 쪽을 외부 상태(extrinsic, 맥락에 따라 바뀌어 밖에서 넘겨받는 것)라 부른다.

구조

플라이웨이트 클래스 그림. TreeTypeFactory가 TreeType을 종류마다 한 벌만 만들고, Forest가 가진 Tree 수천 개가 TreeType을 참조한다같은 키는 한 벌만종류를 달라고 한다수천 그루참조 하나팩터리(Factory)TreeTypeFactory- pool: Map<Key, TreeType>get(name, color, mesh)플라이웨이트 · 공유 상태TreeType- name, color, meshdraw(canvas, x, y, scale)클라이언트(Client)Forest- trees: List<Tree>plant(x, y, name, ...)문맥(Context) · 고유 상태Tree- x, y, scale- type: TreeTypedraw(canvas)공유 상태(종류마다 한 벌)는 TreeType에, 그루마다 다른 상태는 Tree에 둔다TreeType은 불변이어야 한다. 하나를 고치면 같은 종류의 나무가 전부 바뀐다
플라이웨이트의 클래스 그림. TreeType(플라이웨이트, Flyweight)이 공유 상태를, Tree가 그루마다 다른 상태를 든다. 팩터리가 같은 키의 TreeType을 한 벌만 만든다.
  • 플라이웨이트(Flyweight): 공유되는 고유 상태를 든 불변 객체. 외부 상태는 메서드 인자로 받는다.
  • 팩터리(Flyweight Factory): 키로 플라이웨이트를 찾고, 없을 때만 만든다. 공유를 보장하는 곳이다.
  • 문맥(Context): 외부 상태와 플라이웨이트 참조를 든 가벼운 객체. 여기서는 Tree.

예시 코드

// 공유 상태(종류마다 한 벌): 불변으로 만든다
public record TreeType(String name, Color color, Mesh mesh) {
    public void draw(Canvas canvas, float x, float y, float scale) {
        canvas.drawMesh(mesh, color, x, y, scale);
    }
}

// 팩터리: 같은 키에는 늘 같은 객체를 돌려준다
public final class TreeTypeFactory {
    private final Map<String, TreeType> pool = new ConcurrentHashMap<>();

    public TreeType get(String name, Color color, Supplier<Mesh> meshLoader) {
        return pool.computeIfAbsent(name + "/" + color, key -> new TreeType(name, color, meshLoader.get()));
    }

    public int size() {
        return pool.size();
    }
}

// 고유 상태(그루마다 다름): 위치, 크기와 공유 객체 참조 하나
public record Tree(float x, float y, float scale, TreeType type) {
    public void draw(Canvas canvas) {
        type.draw(canvas, x, y, scale);
    }
}

아래 단계 그림은 나무 여섯 그루를 심는 동안 팩터리가 하는 일이다. 처음 보는 종류만 새로 만들고, 나머지는 있던 것을 돌려준다.

플라이웨이트 팩터리 단계 그림. 참나무, 소나무, 자작나무 세 종류만 TreeType으로 만들어지고 나무 여섯 그루가 그것을 나눠 가리킨다심기 요청팩터리의 풀Tree 객체(고유 상태)plant(참나무)plant(소나무)plant(참나무)plant(자작나무)plant(소나무)plant(참나무)TreeType 참나무메시·텍스처 한 벌TreeType 소나무메시·텍스처 한 벌TreeType 자작나무메시·텍스처 한 벌Tree #1 (20, 60)Tree #2 (55, 107)Tree #3 (90, 64)Tree #4 (125, 111)Tree #5 (160, 68)Tree #6 (195, 115)새로 만듦: TreeType 참나무TreeType 1개, Tree 1개새로 만듦: TreeType 소나무TreeType 2개, Tree 2개있던 것을 돌려줌: TreeType 참나무TreeType 2개, Tree 3개새로 만듦: TreeType 자작나무TreeType 3개, Tree 4개있던 것을 돌려줌: TreeType 소나무TreeType 3개, Tree 5개있던 것을 돌려줌: TreeType 참나무TreeType 3개, Tree 6개
1 / 6

1번째 나무: 참나무는 처음이라 팩터리가 TreeType을 새로 만들어 풀에 넣는다. 공유 객체 1개.

나무 여섯 그루를 심는다. Tree는 그루마다 생기지만 TreeType은 종류 수만큼만 생긴다. 새로 만드는지는 계산 함수로 구했다.

실제 쓰임

  • Integer.valueOf: Javadoc은 이 메서드가 자주 쓰는 값을 캐시해 생성자보다 공간과 시간에서 훨씬 나을 수 있고, -128부터 127까지(양끝 포함)는 늘 캐시하며 그 밖의 값도 캐시할 수 있다고 적는다. 그래서 그 범위에서는 valueOf가 늘 같은 객체를 돌려준다. 범위 밖은 보장이 없으므로 ==로 비교하면 안 된다.
  • 문자열 인턴: String.intern()은 클래스가 따로 관리하는 문자열 풀에서 같은 내용의 문자열을 돌려주고, 없으면 풀에 넣는다. 모든 문자열 리터럴과 문자열 상수식은 이미 인턴되어 있다. 파이썬의 sys.intern도 같은 일을 하며, 문서는 인턴된 키끼리는 사전 조회의 문자열 비교를 포인터 비교로 끝낼 수 있다고 설명한다.
  • 게임과 그래픽스: 같은 메시를 위치만 바꿔 여러 번 그리는 인스턴싱, 타일 맵의 타일 종류 표도 같은 생각이다.
Integer a = Integer.valueOf(127);
Integer b = Integer.valueOf(127);
// true: -128 ~ 127은 캐시가 보장된다
boolean sameSmall = a == b;

Integer c = Integer.valueOf(128);
Integer d = Integer.valueOf(128);
// 보장되지 않는다(Javadoc은 범위 밖도 캐시할 수 있다고만 적는다). 값 비교는 늘 equals로
boolean sameLarge = c == d;

String s = new String("hello");
// true: intern()은 풀에 있는 같은 내용의 문자열을 돌려준다. 문자열 리터럴은 이미 풀에 있다
boolean pooled = s.intern() == "hello";

장단점

장점단점
같은 데이터가 한 벌만 올라가 메모리가 크게 준다외부 상태를 매번 인자로 넘겨야 해서 호출이 번거롭다
객체 생성이 줄어 할당과 GC 부담이 준다팩터리 조회(해시 계산, 동시성 제어)에 시간이 든다
같은 종류는 같은 객체라 참조 비교로 빨리 가를 수 있다공유 객체를 고치면 그것을 가리키는 모두가 바뀐다

주의

  • 공유 상태는 불변: 위 예시처럼 record로 만들고, 바뀌는 값은 모두 문맥 쪽으로 옮긴다. 공유 객체에 "선택됨" 같은 상태를 넣는 순간 같은 종류가 모두 선택된다.
  • 객체가 정말 많을 때만: 수십, 수백 개 수준이면 구조만 복잡해진다. 먼저 메모리를 재 보고 같은 데이터가 크게 겹치는지 확인한다.
  • 캐시와 동일성을 헷갈리지 않기: Integer 캐시처럼 "같은 객체일 수도 있다"는 최적화를 동일성의 약속으로 믿지 않는다. 값 비교는 equals로 한다.

다른 언어에서는

  • Python: sys.intern이 문자열 플라이웨이트이고, 팩터리는 functools.cache를 씌운 함수 하나로 만들 수 있다. 같은 인자에는 같은 객체가 돌아온다.
  • Go: 공유 상태는 포인터로 들고(*TreeType), 팩터리는 map과 sync.Mutex로 만든다. 값 타입 구조체를 복사해 넘기면 공유가 아니라 복사가 되는 점을 조심한다.
  • JavaScript: 큰 데이터(텍스처, 파싱한 설정)를 Map에 키로 모아 나눠 쓰는 것이 흔한 형태다. 여기서도 공유 객체는 고치지 않는다.

프록시: 진짜 대신 같은 모양의 대리인

의도

진짜 객체와 같은 인터페이스를 가진 대리 객체를 세워, 진짜 객체에 닿는 길(언제 만들지, 누가 부를 수 있는지, 어디 있는지)을 대신 맡는다.

문제 상황

사진 갤러리가 고해상도 이미지 300장을 목록에 담는다. 목록을 만들 때 모두 읽으면 화면에 보이지도 않는 이미지 때문에 몇 초를 기다리고 메모리를 다 쓴다. 갤러리 코드를 "보일 때만 읽기"로 고칠 수도 있지만, 그러면 이미지를 쓰는 모든 코드가 로딩 시점을 신경 써야 한다. 이미지와 똑같이 생긴 대리인이 처음 display()를 받을 때 진짜를 만들면 갤러리는 그대로 둘 수 있다.

구조

프록시 클래스 그림. LazyImage와 HighResImage가 Image를 구현하고, LazyImage는 HighResImage를 필드로 들고 처음 필요할 때 만든다처음 필요할 때 만든다클라이언트(Client)Gallery주체(Subject) · 인터페이스Imagedisplay()진짜 객체(RealSubject)HighResImage- pixels: byte[]display()프록시(Proxy)LazyImage- path: String- real: HighResImagedisplay()프록시와 진짜 객체는 같은 인터페이스라 클라이언트는 어느 쪽을 받았는지 모른다프록시는 기능보다 진짜 객체에 닿는 길(언제 만들지, 누가 부를지, 어디 있는지)을 다룬다
프록시의 클래스 그림. 프록시와 진짜 객체가 같은 Image를 구현하고, 프록시가 진짜 객체를 필드로 들고 처음 필요할 때 만든다.
  • 주체(Subject): 프록시와 진짜 객체가 함께 구현하는 인터페이스.
  • 진짜 객체(Real Subject): 실제 일을 하는 객체.
  • 프록시(Proxy): 진짜 객체를 가리키거나 만들고, 넘기기 전후에 접근을 다룬다.

프록시의 종류

Wikipedia는 쓰임으로 원격, 가상, 보호 프록시를 든다. 실무에서는 아래 둘을 더해 다섯 가지로 많이 나눈다. 모양은 모두 같고 프록시가 맡는 일만 다르다.

종류하는 일예
가상 프록시(virtual)만들기 비싼 객체를 처음 쓸 때 만든다큰 이미지, JPA·Hibernate 지연 로딩
보호 프록시(protection)권한을 확인한 뒤에만 넘긴다역할에 따라 메서드를 막는 래퍼, JS Proxy로 속성 막기
원격 프록시(remote)다른 주소 공간(다른 프로세스, 다른 기계)의 객체를 로컬 객체처럼 부르게 한다RPC 클라이언트 스텁, ATM이 은행 서버 정보를 대신하는 객체
캐시 프록시(cache)같은 요청의 결과를 저장해 두고 진짜 객체를 덜 부른다느린 조회 서비스 앞의 메모 래퍼
로깅·스마트 참조(smart reference)호출을 기록하거나 시간을 재거나, 참조 수를 세고 잠금을 건다java.lang.reflect.Proxy 기반 계측, Spring AOP 어드바이스

예시 코드

가상 프록시를 손으로 짜면 이렇다. 프록시는 경로만 들고 있다가 처음 display()에서 진짜 객체를 만든다.

public interface Image {
    void display();
}

// 진짜 객체: 만들 때 파일 전체를 읽는다(무겁다)
public final class HighResImage implements Image {
    private final byte[] pixels;

    public HighResImage(Path path) throws IOException {
        this.pixels = Files.readAllBytes(path);
    }

    @Override
    public void display() {
        Screen.draw(pixels);
    }
}

// 가상 프록시: 경로만 들고 있다가 처음 display()에서 진짜 객체를 만든다
public final class LazyImage implements Image {
    private final Path path;
    private HighResImage real;

    public LazyImage(Path path) {
        this.path = path;
    }

    @Override
    public void display() {
        if (real == null) {
            real = load();
        }
        real.display();
    }

    private HighResImage load() {
        try {
            return new HighResImage(path);
        } catch (IOException e) {
            throw new UncheckedIOException("이미지를 읽지 못했다: " + path, e);
        }
    }
}

메서드마다 손으로 넘기지 않고 실행 중에 프록시를 만드는 방법도 있다. java.lang.reflect.Proxy는 주어진 인터페이스들을 구현하는 프록시 클래스를 만들고, 프록시에 온 모든 호출을 InvocationHandler.invoke에 프록시, 불린 Method, 인자 배열과 함께 넘긴다. 넘길 수 있는 것은 인터페이스뿐이고 클래스는 안 된다.

// 인터페이스만 있으면 실행 중에 프록시 클래스를 만들어 모든 호출을 한 곳(InvocationHandler)으로 모은다
@SuppressWarnings("unchecked")
static <T> T timed(T target, Class<T> type) {
    InvocationHandler handler = (proxy, method, args) -> {
        long started = System.nanoTime();
        try {
            return method.invoke(target, args);
        } catch (InvocationTargetException e) {
            throw e.getCause();
        } finally {
            log.info("{} {} µs", method.getName(), (System.nanoTime() - started) / 1_000);
        }
    };
    return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[] {type}, handler);
}

OrderRepository repository = timed(new JdbcOrderRepository(dataSource), OrderRepository.class);

실제 쓰임

  • JPA·Hibernate 지연 로딩: Hibernate 소개서는 프록시를 "진짜 엔티티나 컬렉션인 척하지만 아직 데이터베이스에서 가져오지 않아 상태를 들고 있지 않은 객체"라 설명한다. 프록시의 메서드를 부르면 그때 상태를 가져온 뒤 진짜 객체로 호출을 넘긴다. 식별자를 읽는 일과 getReference로 얻은 프록시로 연관을 거는 일은 조회 없이 된다. 세션이 끝난 뒤 채워지지 않은 프록시의 메서드를 부르면 LazyInitializationException이 난다.
  • Spring AOP: Spring 문서에 따르면 대상이 인터페이스를 하나라도 구현하면 JDK 동적 프록시를, 없으면 대상의 하위 클래스를 실행 중에 만드는 CGLIB 프록시를 쓴다. final이나 private 메서드는 덮어쓸 수 없어 어드바이스가 걸리지 않는다.

아래 단계 그림은 지연 로딩 프록시의 일생이다. 책을 불러오면 출판사는 식별자만 든 프록시로 따라온다.

지연 로딩 프록시 단계 그림. 식별자 읽기는 조회가 없고, 처음 값을 읽을 때 한 번 조회하며, 세션을 닫은 뒤 채워지지 않은 프록시를 쓰면 오류가 난다DBPublisher 프록시id = 7Author 프록시id = 3Publisher publisher = book.getPublisher();name = ?country = ?name = ?조회 0번세션 열림프록시만 만들었다: 조회 없음publisher.getId()name = ?country = ?name = ?조회 0번세션 열림식별자는 프록시가 이미 안다: 조회 없음publisher.getName()name = 가나출판country = KRname = ?SELECT … WHERE id = 7조회 1번세션 열림처음 쓰는 값: 이때 한 번 조회해 채운다publisher.getCountry()name = 가나출판country = KRname = ?조회 1번세션 열림이미 채워졌다: 조회 없음em.close()name = 가나출판country = KRname = ?조회 1번세션 닫힘세션(영속성 컨텍스트)을 닫았다publisher.getName()name = 가나출판country = KRname = ?조회 1번세션 닫힘이미 채워졌다: 조회 없음author.getName()name = 가나출판country = KRname = ?LazyInitializationException조회 1번세션 닫힘세션이 닫혀 채울 수 없다: LazyInitializationException
1 / 7

책을 불러오면 출판사는 프록시로 들어온다. 저자도 getReference로 프록시만 받았다. 아직 출판사와 저자를 조회하지 않았다.

프록시는 진짜 엔티티처럼 생겼지만 처음에는 식별자만 들고 있다. 값이 필요한 순간에 조회하고, 세션이 닫힌 뒤에는 조회할 길이 없다. 조회 여부는 계산 함수로 구했다.

장단점

장점단점
클라이언트를 고치지 않고 생성 시점, 권한, 위치, 계측을 더한다호출이 언제 무거워지는지 코드에서 보이지 않는다(지연 로딩이 반복문 안에서 조회를 수십 번 낸다)
무거운 자원을 필요할 때까지 미룬다프록시는 진짜 객체와 다른 클래스라 getClass(), instanceof, ==가 기대와 다를 수 있다
동적 프록시로 횡단 관심사(트랜잭션, 로깅)를 한 곳에 모은다프레임워크가 만든 프록시는 스택 트레이스와 디버거를 읽기 어렵게 한다

주의

  • 자기 호출은 프록시를 건너뛴다: 프록시는 바깥에서 들어오는 호출만 가로챈다. 객체 안에서 this.place()로 자기 메서드를 부르면 프록시를 거치지 않는다. Spring 문서도 프록시 모드(기본값)에서는 자기 호출에 @Transactional이 실제 트랜잭션을 만들지 않는다고 적는다.
@Service
public class OrderService {
    public void placeAll(List<Order> orders) {
        // this.place()는 프록시를 거치지 않으므로 @Transactional이 걸리지 않는다
        orders.forEach(this::place);
    }

    @Transactional
    public void place(Order order) {
        ...
    }
}
  • 지연 로딩과 N+1: Hibernate 문서는 엔티티 하나씩 왕복해 가져오는 것이 가장 비효율적인 접근이며 N+1 조회 문제로 이어진다고 경고한다. 목록에서 연관을 쓸 것이 뻔하면 조인 페치로 한 번에 가져온다.
  • 다형 연관의 프록시: 같은 문서에 따르면 Hibernate는 프록시를 만들 때 하위 타입을 알 수 없어, 프록시에 instanceof나 형 변환이 제대로 동작하지 않는다. 엔티티의 equals도 getClass() 대신 instanceof로 짠다.
  • 데코레이터와의 차이: 코드 모양은 같다. 데코레이터는 클라이언트가 직접 골라 겹겹이 씌워 기능을 더하고, 프록시는 대개 진짜 객체의 수명과 접근을 맡으며 프레임워크가 만들어 클라이언트는 프록시인지 모른다. 의도가 "기능 추가"면 데코레이터, "접근 제어"면 프록시라 부른다.

다른 언어에서는

  • JavaScript의 Proxy 객체: 언어가 프록시를 내장한다. MDN에 따르면 Proxy는 다른 객체의 기본 동작(속성 읽기, 쓰기 등)을 가로채 다시 정의하고, 가로채는 함수를 트랩(trap)이라 부른다. 원래 동작은 같은 이름의 Reflect 메서드로 이어 준다.
// 보호 프록시: 밑줄로 시작하는 속성은 읽기와 쓰기를 막는다
const guarded = (target) =>
  new Proxy(target, {
    get(object, key, receiver) {
      if (typeof key === 'string' && key.startsWith('_')) {
        throw new Error(`비공개 속성: ${key}`);
      }
      return Reflect.get(object, key, receiver);
    },
    set(object, key, value, receiver) {
      if (typeof key === 'string' && key.startsWith('_')) {
        throw new Error(`비공개 속성: ${key}`);
      }
      return Reflect.set(object, key, value, receiver);
    },
  });

const account = guarded({ owner: 'kim', _token: 'secret' });
account.owner;
// account._token → Error
  • Python: __getattr__을 정의한 감싸개 클래스가 모르는 속성 접근을 모두 안쪽 객체로 넘기는 흔한 프록시다. 가로챌 메서드만 따로 정의한다.
  • Go: 인터페이스를 구조체에 임베딩하고 가로챌 메서드만 다시 쓴다. Effective Go가 말하듯 넘어간 메서드는 안쪽 값이 받는다.
// 인터페이스를 임베딩하면 나머지 메서드는 그대로 안쪽에 넘어가고, Delete만 가로챈다
type ReadOnlyStore struct {
    Store
}

func (s ReadOnlyStore) Delete(key string) error {
    return errors.New("읽기 전용 저장소")
}

일곱 구조 패턴 비교: 모양이 비슷한데 의도가 다르다

구조 패턴은 모두 "객체가 다른 객체를 들고 일을 넘긴다"는 같은 재료로 만든다. 그래서 클래스 그림만 보면 셋씩 넷씩 닮았고, 특히 어댑터, 데코레이터, 프록시는 코드가 거의 똑같다. 이름을 가르는 것은 왜 감쌌는가다. 같은 LoggingClient라도 기능을 더하려고 골라 씌웠으면 데코레이터, 프레임워크가 접근을 가로채려고 세웠으면 프록시다.

구조 패턴 일곱 가지의 모양. 어댑터, 데코레이터, 프록시는 모두 객체 하나를 감싸지만 어댑터만 인터페이스를 바꾼다어댑터인터페이스 A를 B로바꿔 잇는다A변환B브리지두 계층을 필드로이어 따로 늘린다무엇어떻게컴포지트잎과 묶음이 같은인터페이스로 답한다데코레이터같은 인터페이스로감싸 기능을 겹친다핵심퍼사드여러 하위 시스템에창구 하나를 둔다창구플라이웨이트많은 객체가 같은상태를 나눠 쓴다프록시같은 인터페이스로닿는 길을 맡는다대리진짜강조 테두리객체 하나를 감싸는 셋코드 모양은 거의 같다바꾸면: 어댑터더하면: 데코레이터길을 맡으면: 프록시(인터페이스 기준)
구조 패턴 일곱 가지의 모양. 강조 테두리의 셋은 모두 객체 하나를 감싸고, 인터페이스를 바꾸는지와 무엇을 맡는지로만 갈린다.
패턴감싸는 것인터페이스의도헷갈리는 짝
어댑터객체 하나바꾼다이미 있는 것의 인터페이스를 기대하는 모양으로 맞춘다데코레이터, 브리지
브리지구현 계층추상과 구현이 따로두 방향으로 늘어나는 것을 미리 떼어 놓는다어댑터, 전략
컴포지트자식 여럿같다낱개와 묶음을 같은 방법으로 다룬다데코레이터(자식이 하나인 컴포지트처럼 보인다)
데코레이터객체 하나같다같은 모양을 지키며 기능을 겹겹이 더한다프록시, 어댑터
퍼사드하위 시스템 여럿새 단순 인터페이스복잡한 것 앞에 쓰기 쉬운 창구를 둔다어댑터(퍼사드는 새 인터페이스를 정의한다)
플라이웨이트감싸지 않음(공유)공유 객체 + 인자같은 상태를 나눠 써서 객체 수와 메모리를 줄인다싱글턴(플라이웨이트는 키마다 하나)
프록시객체 하나같다진짜 객체에 닿는 길(생성 시점, 권한, 위치)을 대신 맡는다데코레이터
  • 인터페이스를 바꾸나: 바꾸면 어댑터(기존 것에 맞춤)나 퍼사드(새로 단순하게), 그대로면 데코레이터, 프록시, 컴포지트다.
  • 몇 개를 감싸나: 하나면 어댑터, 데코레이터, 프록시, 여럿이면 컴포지트(같은 종류의 자식들)나 퍼사드(서로 다른 하위 시스템)다.
  • 언제 정하나: 브리지는 설계할 때 미리 떼어 놓고, 어댑터는 이미 있는 것을 나중에 맞춘다. 플라이웨이트는 객체 수가 문제로 드러난 뒤 최적화로 들어오는 경우가 많다.

흔한 오해

  • "프록시와 데코레이터는 다른 구조다" — 구조는 같다. 같은 인터페이스를 구현하고 안쪽을 들고 넘긴다. 가르는 것은 의도(기능 추가 대 접근 제어)와 누가 감싸는가(클라이언트가 골라 겹침 대 프레임워크가 세움)다.
  • "퍼사드를 두면 하위 시스템을 직접 쓰면 안 된다" — 퍼사드는 흔한 일을 쉽게 하는 창구일 뿐, 세부가 필요한 코드는 하위 시스템을 직접 써도 된다. 막는 것은 모듈 경계의 일이다.
  • "플라이웨이트는 캐시다" — 팩터리 안에 캐시 같은 풀이 있지만, 핵심은 상태를 공유(고유)와 개별(외부)로 나누는 설계다. 나누지 않고 객체를 통째로 저장하면 그냥 캐시다.
  • "Integer는 작은 수라서 ==로 비교해도 된다" — Javadoc이 보장하는 것은 -128~127뿐이다. 그 밖은 구현에 따라 다르므로 값은 equals로 비교한다.
  • "@Transactional을 달면 어디서 불러도 트랜잭션이 걸린다" — 프록시 방식에서는 프록시를 거쳐 들어온 바깥 호출만 가로챈다. 같은 객체 안의 자기 호출은 걸리지 않는다.
  • "지연 로딩은 늘 빠르다" — 미루기만 할 뿐 조회 수는 줄지 않는다. 목록을 돌며 연관을 하나씩 건드리면 N+1 조회가 된다.

핵심 자료

요지는 직접 정리한 한 줄이다.

자료한 줄 요지
Wikipedia — Facade pattern복잡한 하위 시스템 앞에 단순한 인터페이스 하나. 리팩터링의 출발점으로도 쓴다.
Wikipedia — Flyweight pattern고유(intrinsic) 상태는 공유하고 외부(extrinsic) 상태는 인자로. 문서 편집기의 글리프에서 시작했다.
Wikipedia — Proxy pattern원격, 가상, 보호 프록시의 쓰임. 프록시와 진짜 객체는 같은 인터페이스를 구현한다.
Oracle — java.lang.reflect.Proxy인터페이스만으로 실행 중에 프록시를 만들고, 호출은 InvocationHandler.invoke로 모인다.
Spring — Proxying Mechanisms인터페이스가 있으면 JDK 프록시, 없으면 CGLIB 하위 클래스. 자기 호출은 프록시를 건너뛴다.
Hibernate — An Introduction to Hibernate 6지연 로딩 프록시의 동작과 함정(LazyInitializationException, instanceof, N+1).
MDN — Proxy언어가 프록시를 직접 지원한다. 트랩으로 속성 읽기, 쓰기 같은 기본 동작을 가로챈다.