어댑터·퍼사드·프록시 패턴 — 구조 패턴의 세 가지 얼굴

인터페이스를 변환하는 어댑터, 복잡한 서브시스템을 단일 진입점으로 감싸는 퍼사드, 동일 인터페이스를 유지하며 접근을 제어하는 프록시 패턴을 JavaScript 예제와 함께 설명합니다.

· 7 min read · PALDYN Team

지난 글에서 전략·데코레이터 패턴으로 행위를 교체하고 확장하는 방법을 살펴봤습니다. 이번에는 구조 패턴(Structural Pattern) 세 가지—어댑터(Adapter), 퍼사드(Facade), 프록시(Proxy)—를 JavaScript 코드와 함께 정리합니다. 세 패턴 모두 “래퍼(wrapper)“를 두는 방식이지만, 해결하는 문제가 다릅니다.

어댑터·퍼사드·프록시 개념 구조

어댑터 패턴 — 인터페이스 변환

어댑터는 호환되지 않는 두 인터페이스를 연결합니다. 기존 코드를 수정하지 않고, 중간에 변환 레이어를 끼워 맞춥니다.

어디서 필요한가

  • 외부 라이브러리를 팀 내부 규격으로 감쌀 때
  • 레거시 API를 새 인터페이스로 마이그레이션할 때 (점진적 교체)
  • 테스트에서 실제 서비스를 페이크(fake)로 대체할 때

함수형 어댑터

클래스 없이 함수로 구현하면 가볍습니다.

// Adaptee: XML 반환 구형 API
function fetchLegacyData(id) {
  return `<data><id>${id}</id><value>42</value></data>`;
}

// Adapter: XML → JSON 변환
function legacyToJsonAdapter(id) {
  const xml = fetchLegacyData(id);
  const idMatch   = xml.match(/<id>(\d+)<\/id>/);
  const valMatch  = xml.match(/<value>(\d+)<\/value>/);
  return { id: Number(idMatch[1]), value: Number(valMatch[1]) };
}

// Target 인터페이스: JSON을 기대하는 클라이언트 코드
async function renderWidget(fetchJson) {
  const data = await fetchJson(1);
  console.log(data.value); // 42
}

renderWidget(legacyToJsonAdapter);

클래스 어댑터 — 외부 HTTP 클라이언트 통일

팀에서 axios 스타일({data, status} 응답)을 표준으로 정했지만, 일부 코드가 fetch를 사용한다면 어댑터로 감쌉니다.

class FetchAdapter {
  async get(url, config = {}) {
    const res = await fetch(url, {
      headers: config.headers ?? {},
    });
    if (!res.ok) throw Object.assign(new Error(res.statusText), { status: res.status });
    return { data: await res.json(), status: res.status };
  }

  async post(url, body, config = {}) {
    const res = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', ...config.headers },
      body: JSON.stringify(body),
    });
    return { data: await res.json(), status: res.status };
  }
}

// 팀 전체가 axios 스타일로 통일
const http = new FetchAdapter();
const { data } = await http.get('/api/users');

퍼사드 패턴 — 복잡성 은닉

퍼사드는 복잡한 서브시스템에 단순한 단일 진입점을 제공합니다. 서브시스템 자체를 변경하거나 숨기는 것이 아니라, 자주 사용되는 흐름을 하나의 메서드로 조합해 줍니다.

퍼사드가 필요한 순간

  • 인증, HTTP 요청, 캐시, 로깅이 각각 별도 클래스로 분리되어 있을 때
  • SDK를 초기화하는 순서가 복잡할 때
  • 여러 서비스를 조합해야 하는 유스케이스가 반복될 때
// 서브시스템들
class AuthService {
  async getToken() { return localStorage.getItem('token'); }
}

class HttpClient {
  async get(url, headers) {
    const res = await fetch(url, { headers });
    return res.json();
  }
}

class ResponseCache {
  #store = new Map();
  get(key)           { return this.#store.get(key); }
  set(key, val, ttl) {
    this.#store.set(key, val);
    setTimeout(() => this.#store.delete(key), ttl);
  }
}

// 퍼사드 — 세 서브시스템을 조율
class ApiFacade {
  #auth   = new AuthService();
  #http   = new HttpClient();
  #cache  = new ResponseCache();

  async get(url, cacheTtl = 30_000) {
    const cached = this.#cache.get(url);
    if (cached) return cached;

    const token = await this.#auth.getToken();
    const data  = await this.#http.get(url, {
      Authorization: `Bearer ${token}`,
    });

    this.#cache.set(url, data, cacheTtl);
    return data;
  }
}

// 클라이언트는 단 한 줄
const api  = new ApiFacade();
const user = await api.get('/api/me');

인증 흐름이 바뀌어도, 캐시 전략이 바뀌어도 클라이언트 코드를 건드리지 않아도 됩니다.


프록시 패턴 — 투명한 접근 제어

프록시는 실제 대상과 동일한 인터페이스를 가지면서, 그 앞에서 접근을 가로채 캐시·지연 로딩·접근 권한 검사 등을 삽입합니다.

ES6 Proxy를 활용한 메모이제이션 프록시

function memoProxy(fn) {
  const cache = new Map();
  return new Proxy(fn, {
    apply(target, thisArg, args) {
      const key = JSON.stringify(args);
      if (cache.has(key)) return cache.get(key);
      const result = Reflect.apply(target, thisArg, args);
      cache.set(key, result);
      return result;
    },
  });
}

function expensiveCalc(n) {
  console.log('계산 중...');
  return n * n;
}

const cachedCalc = memoProxy(expensiveCalc);
cachedCalc(10); // "계산 중..." 출력 후 100
cachedCalc(10); // 캐시 히트, 출력 없이 100

가상 프록시 — 지연 로딩

무거운 객체를 실제로 필요한 순간까지 생성을 미룹니다.

function lazyProxy(factory) {
  let instance = null;
  return new Proxy({}, {
    get(_, prop) {
      instance ??= factory();
      return instance[prop];
    },
  });
}

const heavyService = lazyProxy(() => {
  console.log('서비스 초기화');
  return { process: (x) => x * 2 };
});

// 이 시점까지 초기화 안 됨
heavyService.process(5); // "서비스 초기화" 출력 후 10
heavyService.process(5); // 초기화 없이 10

보호 프록시 — 접근 권한 검사

function protectedProxy(target, role) {
  const ADMIN_ONLY = new Set(['delete', 'reset']);
  return new Proxy(target, {
    get(obj, prop) {
      if (ADMIN_ONLY.has(prop) && role !== 'admin') {
        throw new Error(`'${prop}'은 관리자 전용입니다.`);
      }
      return Reflect.get(obj, prop);
    },
  });
}

세 패턴 한눈에 비교

JavaScript 구현 예시

구분어댑터퍼사드프록시
목적인터페이스 변환복잡성 단순화접근 제어
인터페이스Target 인터페이스 제공새 인터페이스 정의동일 인터페이스 유지
서브시스템 변경어댑티 코드 불변서브시스템 불변실제 대상 불변
JS 특기함수형 래퍼클래스 조합Proxy / Reflect

세 패턴의 공통점은 래핑이지만, 해결하는 문제가 다릅니다. 어댑터는 “이미 있는 두 인터페이스를 연결”하고, 퍼사드는 “복잡한 내부를 단순한 API 뒤에 숨기고”, 프록시는 “원본과 같은 인터페이스로 행동을 가로채거나 제어”합니다.


지난 글: 전략·데코레이터 패턴 — 행위의 교체와 확장

다음 글: 모듈 패턴 — 캡슐화와 네임스페이스


읽어주셔서 감사합니다. 😊