GoF 디자인 패턴: 구조 패턴(1) - 정처기 실기 대비 개념 이해(어댑터, 데코레이터, 퍼사드, 프록시)

정처기디자인 패턴GoF어댑터데코레이터퍼사드프록시구조패턴개발
읽는데 약 21분 정도 소요
처음 쓰여진 날: 2025-07-03
마지막으로 고쳐진 날: 2025-07-03
이 글을 보러온 횟수: 46

요약

클래스와 객체를 조합해 더 큰 구조를 만드는 GoF(Gang of Four) 디자인 패턴의 구조 패턴인 어댑터(Adapter), 데코레이터(Decorator), 퍼사드(Facade), 프록시(Proxy)를 자바스크립트 코드와 함께 알아봅니다.

📚 GoF 디자인 패턴이란?

GoF는 'Gang of Four'의 약자로, 객체지향 프로그래밍 분야에서 자주 발생하는 문제들에 대한 해결책을 담은 디자인 패턴 책의 저자 4명을 의미합니다. 이들이 정리한 패턴들은 코드의 재사용성, 유지보수성, 확장성을 높이는 데 큰 도움을 줍니다. 💡

디자인 패턴은 크게 생성(Creational) 🏗️, 구조(Structural) 🧩, 행위(Behavioral) 🎭 세 가지로 나뉩니다. 이번 글에서는 클래스와 객체를 조합하여 더 큰 구조를 만드는 방법을 다루는 구조 패턴 🧩 을 살펴보겠습니다.

🧱 구조 패턴이란 무엇일까?

구조 패턴은 단어 뜻 그대로 '구조' 🏗️ 를 만드는 데 중점을 둡니다. 작은 클래스와 객체들을 마치 레고 블록 🧩 처럼 조립하여 더 크고 복잡하지만, 유연하고 효율적인 구조를 만드는 방법을 다룹니다.

예를 들어:

  • 🔌 서로 다른 인터페이스를 가진 객체들을 어댑터로 연결하거나 (어댑터 패턴)
  • 🎁 객체에 새로운 책임을 동적으로 추가하고 (데코레이터 패턴)
  • 🚪 복잡한 시스템을 더 쉽게 사용할 수 있도록 단순한 창구를 제공하거나 (퍼사드 패턴)
  • 🛡️ 객체에 대한 접근을 제어하는 것 (프록시 패턴)

이 모든 것이 구조 패턴에 해당합니다.

핵심은 '관계' 🔗 입니다. 구조 패턴을 사용하면 클래스 간의 상속 관계나 객체 간의 합성 관계를 효과적으로 설계하여, 시스템이 새로운 요구사항에 쉽게 적응하고 확장될 수 있도록 만들 수 있습니다. ✨

🔌 1. 어댑터 (Adapter) 패턴

어댑터 패턴은 이름 그대로, 호환되지 않는 인터페이스를 가진 클래스들을 함께 동작하도록 변환해주는 디자인 패턴입니다. 현실 세계의 '돼지코' 어댑터(110V를 220V로)를 생각하면 이해하기 쉽습니다. 기존 코드를 수정하지 않고도 새로운 클래스나 외부 라이브러리를 시스템에 통합하고 싶을 때 매우 유용합니다.

🏗️ 기본 구조

어댑터 패턴은 세 가지 주요 요소로 구성됩니다.

  • 🎯 Target: 클라이언트가 사용하려는 목표 인터페이스입니다.
  • 🔌 Adaptee: 호환되지 않는 인터페이스를 가진 기존 클래스입니다. (우리가 사용하고 싶은 기능)
  • 🔄 Adapter: Adaptee의 인터페이스를 Target 인터페이스에 맞게 변환해주는 역할을 합니다.

💻 예시: 웹 스토리지 API 호환시키기

웹 브라우저에는 localStoragesessionStorage라는 두 가지 데이터 저장소가 있습니다. 두 API는 setItem(key, value), getItem(key) 등 유사한 인터페이스를 가지고 있지만, 만약 sessionStorage에만 데이터를 JSON 형태로 자동 변환하여 저장하는 특별한 기능이 필요하다고 가정해봅시다.

우리의 애플리케이션은 Storage라는 표준 인터페이스(Target)를 통해 데이터 저장을 처리하도록 설계되었습니다.

javascript
// Target 인터페이스
class Storage {
  setData(key, data) {
    throw new Error("setData()는 반드시 구현되어야 합니다.");
  }
  getData(key) {
    throw new Error("getData()는 반드시 구현되어야 합니다.");
  }
}

기본적으로 localStorage는 이 Storage 인터페이스를 그대로 사용할 수 있습니다.

javascript
// Concrete Target
class LocalStorageManager extends Storage {
  setData(key, data) {
    localStorage.setItem(key, data);
  }
  getData(key) {
    return localStorage.getItem(key);
  }
}

이제 sessionStorage를 사용하고 싶은데, 이 클래스는 데이터를 저장할 때 자동으로 JSON.stringify를 수행하는 saveObject와, 가져올 때 JSON.parse를 수행하는 loadObject라는 다른 메서드를 가지고 있다고 가정해봅시다. 이것이 우리의 Adaptee입니다.

javascript
// Adaptee (호환되지 않는 인터페이스를 가진 클래스)
class SessionStorageHandler {
  saveObject(key, obj) {
    sessionStorage.setItem(key, JSON.stringify(obj));
  }
  loadObject(key) {
    const item = sessionStorage.getItem(key);
    return item ? JSON.parse(item) : null;
  }
}

SessionStorageHandler를 우리 시스템의 Storage 인터페이스에 맞추기 위해 어댑터를 만듭니다.

javascript
// Adapter
class SessionStorageAdapter extends Storage {
  constructor() {
    super();
    this.handler = new SessionStorageHandler();
  }

  // Target의 setData()를 Adaptee의 saveObject()로 연결
  setData(key, data) {
    // Adaptee는 객체를 받으므로, 문자열 데이터를 객체로 감싸줍니다.
    this.handler.saveObject(key, { value: data });
  }

  // Target의 getData()를 Adaptee의 loadObject()로 연결
  getData(key) {
    const obj = this.handler.loadObject(key);
    return obj ? obj.value : null;
  }
}

// 클라이언트 코드
function saveUserData(storage, userData) {
  storage.setData("user", userData);
  console.log("데이터가 저장되었습니다. ✅");
}

// LocalStorage 사용 (기존 방식)
const localManager = new LocalStorageManager();
saveUserData(localManager, "John Doe");
console.log("LocalStorage에서 읽음:", localStorage.getItem("user")); // "John Doe"

// SessionStorage 사용 (어댑터 활용)
const sessionAdapter = new SessionStorageAdapter();
saveUserData(sessionAdapter, "Jane Doe");
console.log("SessionStorage에서 읽음:", sessionStorage.getItem("user")); // {"value":"Jane Doe"}

// 클라이언트는 getData를 호출할 뿐이지만, 어댑터가 내부적으로 JSON 파싱을 처리합니다.
console.log("어댑터를 통해 읽음:", sessionAdapter.getData("user")); // "Jane Doe"

클라이언트 코드(saveUserData)는 Storage라는 동일한 인터페이스를 사용하므로, 데이터가 localStorage에 저장되는지 sessionStorage에 JSON 형태로 저장되는지 알 필요가 없습니다. 이처럼 어댑터 패턴을 사용하면 서로 다른 인터페이스를 가진 객체들을 손쉽게 통합하여 사용할 수 있습니다.

어댑터 패턴 중요 키워드 🔑

  • 🤝 호환되지 않는 인터페이스를 연결한다.
  • ♻️ 기존 코드를 수정하지 않고 재사용한다.
  • 'Wrapper' 클래스라고도 불린다.
  • 상속을 이용하는 클래스 패턴과 위임을 이용하는 인스턴스 패턴이 존재한다.

클래스 어댑터와 인스턴스 어댑터

어댑터 패턴은 구현 방식에 따라 두 종류로 나뉩니다.

  • 클래스 어댑터 (Class Adapter): 상속을 사용합니다. 어댑터가 Target 클래스를 상속하고 Adaptee의 기능을 구현합니다. 앞서 본 SessionStorageAdapter extends Storage 예시가 여기에 해당합니다.
  • 인스턴스 어댑터 (Instance Adapter): 위임(Delegation) 을 사용합니다. 어댑터가 Adaptee의 인스턴스를 내부에 품고, Target의 요청이 들어오면 Adaptee에게 작업을 위임합니다. 이 방식이 더 유연하여 일반적으로 더 많이 사용됩니다.

인스턴스 어댑터 예시: 새로운 로깅 라이브러리 통합

우리 시스템은 간단히 메시지만 받는 log(message) 인터페이스를 사용하는데, 새로 도입할 라이브러리는 타임스탬프까지 함께 받는 logMessage(timestamp, message) 인터페이스를 사용한다고 가정해봅시다.

javascript
// Adaptee: 우리가 통합하려는 새로운 로거.
class NewLogger {
  logMessage(timestamp, message) {
    console.log(`[${new Date(timestamp).toISOString()}] - ${message}`);
  }
}

// Target: 우리 시스템이 기대하는 로거 인터페이스.
// 여기서는 'log(message)' 메서드를 가진 객체로 가정합니다.
// const logger = { log: (message) => { console.log(message) } };

// Instance Adapter
class LoggerAdapter {
  constructor(newLogger) {
    // Adaptee의 인스턴스를 내부에 품습니다. (Composition)
    this.adaptee = newLogger;
  }

  // Target 인터페이스에 맞는 log 메서드를 구현합니다.
  log(message) {
    // Adaptee가 요구하는 형식에 맞춰 데이터를 가공한 뒤,
    const timestamp = Date.now();
    // 실제 작업은 Adaptee에게 위임합니다.⭐️
    this.adaptee.logMessage(timestamp, message);
  }
}

// 클라이언트 코드
function logSystemEvent(logger, message) {
  logger.log(message);
}

const newLogger = new NewLogger();
const adapter = new LoggerAdapter(newLogger);

logSystemEvent(adapter, "사용자가 로그인했습니다.");
// 출력: [2025-07-02TXX:XX:XX.XXX] - 사용자가 로그인했습니다.

위 예시에서 LoggerAdapter는 어떤 클래스도 extends 하지 않습니다. 대신 생성자에서 NewLogger의 인스턴스를 받아 this.adaptee에 저장(구성)합니다. 클라이언트가 adapter.log()를 호출하면, 어댑터는 실제 로깅 작업을 adaptee에게 '위임'합니다. 이처럼 인스턴스 어댑터는 구성을 사용하므로 더 유연한 구조를 만들 수 있습니다.

☕️ 2. 데코레이터 (Decorator) 패턴

데코레이터 패턴은 객체의 기존 코드를 수정하지 않고도 동적으로 새로운 기능이나 책임을 추가할 수 있게 해주는 구조 패턴입니다. 이름처럼 객체를 장식(decorate)하여 기능을 확장하는 방식입니다. 상속을 통해 기능을 확장할 수도 있지만, 상속은 정적인 방식이라 모든 조합에 대한 서브클래스를 만들어야 하는 '클래스 폭발(class explosion)' 문제를 야기할 수 있습니다. 데코레이터 패턴은 이러한 문제를 해결하는 유연한 대안입니다.

현실 세계의 예로 '커피 주문'을 생각해볼 수 있습니다. 기본 커피(아메리카노)에 우유를 추가하고(카페라떼), 거기에 시럽을 추가하고(바닐라 라떼), 휘핑 크림을 올리는(아인슈페너)처럼, 기본 객체에 여러 '장식(옵션)'을 동적으로 추가해 새로운 객체를 만드는 것과 같습니다.

기본 구조

데코레이터 패턴은 다음 요소들로 구성됩니다.

  • 📦 Component: 장식될 객체와 장식하는 객체(데코레이터)가 모두 구현해야 할 공통 인터페이스(또는 추상 클래스)입니다.
  • ConcreteComponent: 장식의 대상이 되는 핵심 기능을 가진 구체적인 클래스입니다. (예: 기본 커피)
  • 🎁 Decorator: Component 인터페이스를 구현하면서, 내부에 Component 객체에 대한 참조를 가집니다. 이 참조를 통해 장식할 객체와 연결됩니다.
  • ConcreteDecorator: Decorator를 상속받아 실제로 새로운 기능이나 책임을 추가하는 클래스입니다. (예: 우유 추가, 시럽 추가)

예시: 커피 주문 시스템

다양한 옵션을 추가할 수 있는 커피 주문 시스템을 만들어 보겠습니다.

먼저 모든 커피 객체가 따를 Coffee 인터페이스(여기서는 클래스로 정의)를 만듭니다.

javascript
// Component: 공통 인터페이스
class Coffee {
  cost() {
    throw new Error("cost()는 반드시 구현되어야 합니다.");
  }

  getDescription() {
    throw new Error("getDescription()은 반드시 구현되어야 합니다.");
  }
}

가장 기본이 되는 커피, SimpleCoffee를 만듭니다.

javascript
// ConcreteComponent: 장식될 기본 객체
class SimpleCoffee extends Coffee {
  cost() {
    return 5; // 기본 커피 가격 5
  }

  getDescription() {
    return "기본 커피";
  }
}

이제 데코레이터의 기반이 될 추상 클래스를 만듭니다. 이 클래스는 다른 Coffee 객체를 감쌀(wrap) 것입니다.

javascript
// Decorator: 다른 Coffee 객체를 감싸는 역할
class CoffeeDecorator extends Coffee {
  constructor(coffee) {
    super();
    this._coffee = coffee; // 감쌀 Coffee 객체를 저장
  }

  cost() {
    return this._coffee.cost(); // 감싼 객체의 cost()를 호출
  }

  getDescription() {
    return this._coffee.getDescription(); // 감싼 객체의 getDescription()을 호출
  }
}

이제 구체적인 데코레이터들을 만듭니다. WithMilkWithSugar는 각각 우유와 설탕을 추가하는 역할을 합니다.

javascript
// ConcreteDecorator A: 우유 추가
class WithMilk extends CoffeeDecorator {
  constructor(coffee) {
    super(coffee);
  }

  cost() {
    return super.cost() + 2; // 기본 가격에 우유 가격 추가
  }

  getDescription() {
    return super.getDescription() + ", 우유 추가";
  }
}

// ConcreteDecorator B: 설탕 추가
class WithSugar extends CoffeeDecorator {
  constructor(coffee) {
    super(coffee);
  }

  cost() {
    return super.cost() + 1; // 기본 가격에 설탕 가격 추가
  }

  getDescription() {
    return super.getDescription() + ", 설탕 추가";
  }
}

// 클라이언트 코드
let coffee = new SimpleCoffee();
console.log(`${coffee.getDescription()} - 가격: ${coffee.cost()}`);
// 출력: 기본 커피 - 가격: 5

// 우유를 추가해봅시다.
coffee = new WithMilk(coffee);
console.log(`${coffee.getDescription()} - 가격: ${coffee.cost()}`);
// 출력: 기본 커피, 우유 추가 - 가격: 7

// 거기에 설탕도 추가해봅시다.
coffee = new WithSugar(coffee);
console.log(`${coffee.getDescription()} - 가격: ${coffee.cost()}`);
// 출력: 기본 커피, 우유 추가, 설탕 추가 - 가격: 8

// 순서를 바꿔서 설탕을 먼저 추가할 수도 있습니다.
let anotherCoffee = new SimpleCoffee();
anotherCoffee = new WithSugar(anotherCoffee);
anotherCoffee = new WithMilk(anotherCoffee);
console.log(
  `${anotherCoffee.getDescription()} - 가격: ${anotherCoffee.cost()}`
);
// 출력: 기본 커피, 설탕 추가, 우유 추가 - 가격: 8

이처럼 데코레이터 패턴을 사용하면, SimpleCoffee 클래스를 전혀 수정하지 않고도 WithMilk, WithSugar 같은 새로운 기능을 동적으로 계속 추가할 수 있습니다. 만약 '휘핑 크림 추가', '샷 추가' 등의 기능이 필요하다면 새로운 데코레이터 클래스만 만들어주면 됩니다.

데코레이터 패턴 중요 키워드 🔑

  • 🎁 객체를 감싸서(wrap) 새로운 책임을 추가합니다.
  • 🧩 객체의 결합(SimpleCoffee, WithMilk, WithSugar)을 통해 기능을 확장합니다.
  • 💡 상속의 유연한 대안입니다.
  • 동적으로 기능을 추가하거나 제거할 수 있습니다.
  • 핵심 기능과 추가 기능이 동일한 인터페이스를 따릅니다.
  • 데코레이터의 순서가 결과에 영향을 줄 수 있습니다.

🎬 3. 퍼사드 (Facade) 패턴

퍼사드(Facade)는 '건물의 정면'이라는 뜻으로, 복잡한 서브시스템(subsystem)에 대한 간단한 통합 인터페이스를 제공하는 디자인 패턴입니다. 클라이언트가 서브시스템의 복잡한 내부 구조를 알 필요 없이, 퍼사드 객체가 제공하는 단순한 메서드만 호출하여 원하는 기능을 수행할 수 있도록 합니다.

현실 세계의 예로 '컴퓨터 전원 버튼'을 들 수 있습니다. 우리는 전원 버튼 하나만 누르면 컴퓨터가 켜진다고 생각하지만, 내부적으로는 CPU, 메모리, 하드디스크, 운영체제 등 수많은 부품과 소프트웨어가 복잡하게 상호작용합니다. 이때 '전원 버튼'이 바로 퍼사드 역할을 하는 것입니다. 복잡한 과정을 숨기고 사용자에게 단순한 인터페이스를 제공합니다.

🏗️ 기본 구조

  • 🚪 Facade: 서브시스템의 기능을 통합하여 간단한 인터페이스를 제공합니다. 클라이언트의 요청을 받으면, 자신이 알고 있는 여러 서브시스템 객체들에게 작업을 위임합니다.
  • ⚙️ Subsystem Classes: 실제 기능을 수행하는 여러 클래스들의 집합입니다. 퍼사드에 의해 호출되며, 클라이언트는 보통 이 클래스들을 직접적으로 알지 못합니다.

🎥 예시: 홈시어터 시스템 제어하기

집에 DVD 플레이어, 앰프, 프로젝터, 조명 등으로 구성된 홈시어터 시스템이 있다고 상상해봅시다. 영화를 보려면 다음과 같은 여러 단계를 거쳐야 합니다.

  1. 프로젝터를 켠다.
  2. DVD 플레이어를 켠다.
  3. 앰프를 켠다.
  4. 앰프의 볼륨을 조절한다.
  5. 조명을 어둡게 한다.

이 모든 과정을 매번 직접 제어하는 것은 매우 번거롭습니다. 이때 퍼사드 패턴을 사용하여 "영화 보기"라는 간단한 명령 하나로 모든 것을 처리할 수 있습니다.

먼저, 각 장치를 나타내는 서브시스템 클래스들을 정의합니다.

javascript
// Subsystem Class 1: 프로젝터
class Projector {
  on() {
    console.log("프로젝터가 켜졌습니다.");
  }
  off() {
    console.log("프로젝터가 꺼졌습니다.");
  }
}

// Subsystem Class 2: 앰프
class Amplifier {
  on() {
    console.log("앰프가 켜졌습니다.");
  }
  setVolume(level) {
    console.log(`볼륨이 ${level}로 설정되었습니다.`);
  }
  off() {
    console.log("앰프가 꺼졌습니다.");
  }
}

// Subsystem Class 3: 조명
class Lights {
  dim(level) {
    console.log(`조명이 ${level}% 밝기로 어두워졌습니다.`);
  }
  brighten() {
    console.log("조명이 밝아졌습니다.");
  }
}

// Subsystem Class 4: DVD 플레이어
class DvdPlayer {
  on() {
    console.log("DVD 플레이어가 켜졌습니다.");
  }
  play(movie) {
    console.log(`'${movie}' 영화를 재생합니다.`);
  }
  off() {
    console.log("DVD 플레이어가 꺼졌습니다.");
  }
}

이제 이 모든 서브시스템을 제어하는 HomeTheaterFacade를 만듭니다.

javascript
// Facade
class HomeTheaterFacade {
  constructor(amp, projector, lights, dvd) {
    this.amplifier = amp;
    this.projector = projector;
    this.lights = lights;
    this.dvdPlayer = dvd;
  }

  // 영화 보는 과정을 하나의 메서드로 통합
  watchMovie(movie) {
    console.log("--- 영화 볼 준비를 시작합니다 ---");
    this.lights.dim(10);
    this.projector.on();
    this.amplifier.on();
    this.amplifier.setVolume(7);
    this.dvdPlayer.on();
    this.dvdPlayer.play(movie);
  }

  // 영화 끝내는 과정을 하나의 메서드로 통합
  endMovie() {
    console.log("\n--- 홈시어터를 종료합니다 ---");
    this.dvdPlayer.off();
    this.amplifier.off();
    this.projector.off();
    this.lights.brighten();
  }
}

// 클라이언트 코드
const amp = new Amplifier();
const projector = new Projector();
const lights = new Lights();
const dvd = new DvdPlayer();

// 퍼사드 객체 생성
const homeTheater = new HomeTheaterFacade(amp, projector, lights, dvd);

// 클라이언트는 복잡한 과정을 알 필요 없이, 간단한 메서드만 호출합니다.
homeTheater.watchMovie("인터스텔라");
homeTheater.endMovie();

출력 결과:

text
--- 영화 볼 준비를 시작합니다 ---
조명이 10% 밝기로 어두워졌습니다.
프로젝터가 켜졌습니다.
앰프가 켜졌습니다.
볼륨이 7로 설정되었습니다.
DVD 플레이어가 켜졌습니다.
'인터스텔라' 영화를 재생합니다.

--- 홈시어터를 종료합니다 ---
DVD 플레이어가 꺼졌습니다.
앰프가 꺼졌습니다.
프로젝터가 꺼졌습니다.
조명이 밝아졌습니다.

클라이언트는 HomeTheaterFacadewatchMovie()endMovie() 메서드만 호출하면 됩니다. 프로젝터를 먼저 켜야 하는지, 앰프 볼륨은 언제 조절해야 하는지와 같은 복잡한 내부 동작 순서나 의존성을 전혀 알 필요가 없습니다. 이처럼 퍼사드 패턴은 서브시스템과 클라이언트 간의 결합도를 낮추어 코드를 더 단순하고 유지보수하기 쉽게 만들어줍니다.

퍼사드 패턴 중요 키워드 🔑

  • 🚪 복잡한 서브시스템에 대한 **단순한 창구(인터페이스)**를 제공합니다.
  • 🔗 클라이언트와 서브시스템 간의 결합도를 낮춥니다.
  • 📦 서브시스템의 내부 구현을 캡슐화합니다.
  • 🧑‍⚖️ 퍼사드는 단지 요청을 위임할 뿐, 스스로 새로운 기능을 만들지는 않습니다.
  • ✅ 오류에 대해서 단위별로 확인할 수 있습니다.

🛡️ 4. 프록시 (Proxy) 패턴

프록시(Proxy)는 '대리인 🧑‍💼'이라는 뜻으로, 다른 객체에 대한 접근을 제어하기 위해 그 객체의 대리자 또는 자리 표시자 역할을 하는 객체를 만드는 패턴입니다. 실제 객체에 대한 접근을 직접 하는 대신 프록시 객체를 통해 간접적으로 하게 되며, 이 과정에서 접근을 제어하거나 부가적인 작업을 수행할 수 있습니다. 🔒

현실 세계의 예로 '비서 🧑‍💼''고객센터 직원 ☎️' 을 들 수 있습니다. 우리가 회사의 CEO와 직접 통화하기는 어렵습니다. 대신 비서를 통해 약속을 잡거나 메시지를 전달합니다. 이때 비서가 프록시 역할을 하며, CEO(실제 객체)에게 가해지는 불필요한 요청을 걸러내고 접근을 제어합니다. 🚦

🏗️ 기본 구조

  • 🧑‍⚖️ Subject: RealSubjectProxy가 모두 구현해야 하는 공통 인터페이스
  • 👑 RealSubject: 프록시가 대변하는 실제 객체. 핵심적인 비즈니스 로직을 담고 있다.
  • 🛡️ Proxy: RealSubject와 동일한 인터페이스를 가진다. 내부에 RealSubject에 대한 참조를 가지고 있으며, 클라이언트의 요청을 받아 RealSubject에 전달하거나, 때로는 직접 처리한다.

🖼️ 예시: 이미지 로딩 지연 (가상 프록시)

웹 페이지에서 매우 큰 이미지를 로딩한다고 가정해보겠습니다. 이미지가 완전히 로드될 때까지 화면이 멈춰있다면 사용자 경험이 좋지 않을 것입니다. 이때 가상 프록시(Virtual Proxy) 🕒 를 사용하여 실제 이미지 객체 생성을 필요한 시점까지 지연시킬 수 있습니다.

먼저, 모든 이미지가 따를 Image 인터페이스를 정의합니다.

javascript
// Subject: 공통 인터페이스
class Image {
  display() {
    throw new Error("display()는 반드시 구현되어야 합니다.");
  }
}

실제로 무거운 이미지 파일을 로딩하고 화면에 표시하는 RealImage 클래스를 만듭니다. 생성자에서부터 무거운 작업을 수행한다고 가정합니다.

javascript
// RealSubject: 실제 객체 (생성 비용이 비쌈)
class RealImage extends Image {
  constructor(fileName) {
    super();
    this.fileName = fileName;
    this.loadFromDisk(); // 생성 시점에 바로 이미지 로딩 (무거운 작업)
  }

  loadFromDisk() {
    console.log(
      `${this.fileName} 파일을 디스크에서 로딩하는 중... (시간 소요)`
    );
  }

  display() {
    console.log(`${this.fileName} 이미지를 화면에 표시합니다.`);
  }
}

이제 RealImage를 대리할 ProxyImage를 만듭니다.

javascript
// Proxy: RealImage를 대리하는 객체
class ProxyImage extends Image {
  constructor(fileName) {
    super();
    this.fileName = fileName;
    this.realImage = null; // 처음에는 실제 이미지 객체를 null로 둔다.
  }

  display() {
    // display()가 호출되는 시점에 실제 객체가 필요한지 확인
    if (this.realImage === null) {
      console.log(`[프록시] 실제 이미지가 필요해졌습니다. 지금 생성합니다.`);
      // 실제 객체가 필요한 바로 그 순간에 생성! (Lazy Initialization)
      this.realImage = new RealImage(this.fileName);
    }
    // 실제 객체에게 작업 위임
    this.realImage.display();
  }
}

// 클라이언트 코드
console.log("--- 프록시 객체 생성 ---");
const image = new ProxyImage("고화질_풍경사진.jpg");

console.log("\n--- 첫 번째 display() 호출 ---");
// 이 시점에 RealImage 객체가 생성되고 로딩됩니다.
image.display();

console.log("\n--- 두 번째 display() 호출 ---");
// 이미 RealImage 객체가 생성되었으므로, 바로 display만 호출됩니다.
image.display();

출력 결과:

text
--- 프록시 객체 생성 ---

--- 첫 번째 display() 호출 ---
[프록시] 실제 이미지가 필요해졌습니다. 지금 생성합니다.
고화질_풍경사진.jpg 파일을 디스크에서 로딩하는 중... (시간 소요)
고화질_풍경사진.jpg 이미지를 화면에 표시합니다.

--- 두 번째 display() 호출 ---
고화질_풍경사진.jpg 이미지를 화면에 표시합니다.

클라이언트는 ProxyImageRealImage와 똑같이 사용하지만, 실제 무거운 작업인 이미지 로딩은 display()가 처음 호출될 때까지 지연됩니다. 만약 display()가 한 번도 호출되지 않는다면, 불필요한 이미지 로딩을 아예 하지 않을 수도 있습니다.

💡 왜 프록시가 필요한가요? (시간과 반응성)

위 예시에서 프록시를 사용하는 가장 큰 이유는 애플리케이션의 초기 반응성 향상입니다.

  • 프록시가 없다면? 🐌 100개의 고화질 이미지가 있는 페이지를 열 때, 100개의 RealImage 객체가 즉시 생성되면서 모든 이미지를 한 번에 로딩하기 시작합니다. 사용자는 모든 로딩이 끝날 때까지 몇 초간 흰 화면만 봐야 합니다.
  • 프록시가 있다면? 🚀 페이지를 열 때 생성되는 것은 100개의 가벼운 ProxyImage 객체뿐입니다. 페이지는 즉시 뜨고, 사용자가 스크롤하여 이미지가 화면에 보여야 할 때(.display() 호출 시) 비로소 해당 이미지만 로딩됩니다.

이처럼 프록시는 '지연 초기화(Lazy Initialization)' 기법을 통해 당장 필요하지 않은 무거운 작업(객체 생성, 데이터 로딩 등)을 뒤로 미뤄, 시스템의 초기 구동 시간을 단축시키고 리소스(메모리, 네트워크)를 효율적으로 사용하게 해줍니다.

프록시의 종류

프록시 패턴은 사용 목적에 따라 여러 종류로 나뉩니다.

  • 가상 프록시 (Virtual Proxy): 🖼️ 위 예시처럼 생성 비용이 큰 객체의 생성을 필요한 시점까지 연기합니다.
  • 보호 프록시 (Protection Proxy): 🔐 특정 클라이언트만 실제 객체에 접근할 수 있도록 접근을 제어하고 권한을 검사합니다.
  • 원격 프록시 (Remote Proxy): 🌐 다른 주소 공간(예: 원격 서버)에 있는 객체를 로컬에 있는 것처럼 사용할 수 있게 해줍니다.
  • 로깅/캐싱 프록시: 📝 실제 객체에 대한 요청 전후에 로그를 남기거나, 요청 결과를 캐싱하여 성능을 향상시킵니다.

프록시 패턴 중요 키워드 🔑

  • 🛡️ 실제 객체를 대신하여 제어 흐름을 조정한다.
  • 🎭 실제 객체와 프록시는 동일한 인터페이스를 사용한다.
  • 🎯 접근 제어, 비용 절감, 복잡도 감소 등 다양한 목적으로 사용된다.
  • 🤫 클라이언트는 자신이 프록시를 사용하는지 실제 객체를 사용하는지 모를 수 있다.