이미지 로딩 중...

Proxy Pattern 베스트 프랙티스 완벽 가이드 - 슬라이드 1/10
A

AI Generated

2025. 11. 5. · 3 Views

Proxy Pattern 베스트 프랙티스 완벽 가이드

실무에서 자주 사용되는 프록시 패턴의 핵심 개념과 구현 방법을 배웁니다. 가상 프록시, 보호 프록시, 원격 프록시 등 다양한 종류와 실전 활용법을 다룹니다.


목차

  1. 프록시 패턴 기본 개념 - 대리자를 통한 객체 접근 제어
  2. 가상 프록시 - 무거운 객체의 지연 생성
  3. 보호 프록시 - 접근 권한 제어
  4. 캐싱 프록시 - 결과를 캐싱하여 성능 최적화
  5. 로깅 프록시 - 메서드 호출 추적과 디버깅
  6. 원격 프록시 - 네트워크 통신 추상화
  7. 스마트 참조 프록시 - 참조 카운팅과 리소스 관리
  8. ES6 Proxy 활용 - JavaScript 네이티브 프록시
  9. 프록시 패턴 성능 최적화 - 효율적인 프록시 구현

1. 프록시 패턴 기본 개념 - 대리자를 통한 객체 접근 제어

시작하며

여러분이 대용량 이미지나 동영상을 로드하는 웹 애플리케이션을 개발할 때, 페이지 초기 로딩이 너무 느려서 사용자 경험이 나빠지는 상황을 겪어본 적 있나요? 또는 API 호출이 너무 빈번하게 일어나서 서버에 부담을 주거나, 비용이 과도하게 발생하는 문제를 마주한 적은요?

이런 문제는 실제 개발 현장에서 매우 자주 발생합니다. 원본 객체에 직접 접근하다 보면 성능 문제, 보안 문제, 또는 불필요한 리소스 낭비가 발생하게 됩니다.

특히 대규모 시스템에서는 이러한 문제들이 누적되어 심각한 병목 현상을 일으킬 수 있습니다. 바로 이럴 때 필요한 것이 프록시 패턴(Proxy Pattern)입니다.

프록시 패턴은 원본 객체와 클라이언트 사이에 대리자를 두어, 접근을 제어하고 추가적인 기능을 투명하게 제공할 수 있게 해줍니다.

개요

간단히 말해서, 프록시 패턴은 원본 객체에 대한 대리자(대리인) 역할을 하는 객체를 만들어 접근을 제어하는 디자인 패턴입니다. 이 패턴이 필요한 이유는 원본 객체에 직접 접근하는 것이 비효율적이거나 위험할 수 있기 때문입니다.

예를 들어, 데이터베이스 연결이 비용이 많이 드는 작업이라면, 실제로 필요할 때까지 연결을 지연시키는 것이 좋습니다. 또는 민감한 데이터에 접근할 때는 권한을 체크해야 하는 경우도 있죠.

기존에는 원본 객체를 직접 사용하면서 조건문으로 제어 로직을 섞어 넣었다면, 프록시 패턴을 사용하면 원본 객체의 코드는 그대로 두고 별도의 프록시 객체에서 접근 제어, 캐싱, 로깅 등의 부가 기능을 처리할 수 있습니다. 프록시 패턴의 핵심 특징은 다음과 같습니다: (1) 원본 객체와 동일한 인터페이스를 제공하여 클라이언트는 프록시를 사용하는지 모르게 할 수 있습니다, (2) 실제 객체의 생성이나 접근을 지연시킬 수 있습니다, (3) 접근 제어, 캐싱, 로깅 등 부가 기능을 투명하게 추가할 수 있습니다.

이러한 특징들은 시스템의 성능을 개선하고 유지보수성을 높이는 데 매우 중요합니다.

코드 예제

// 실제 이미지 객체 - 생성 비용이 높음
class RealImage {
  constructor(filename) {
    this.filename = filename;
    this.loadFromDisk(); // 무거운 작업
  }

  loadFromDisk() {
    console.log(`디스크에서 이미지 로딩: ${this.filename}`);
  }

  display() {
    console.log(`이미지 표시: ${this.filename}`);
  }
}

// 프록시 이미지 - 실제 필요할 때만 로딩
class ProxyImage {
  constructor(filename) {
    this.filename = filename;
    this.realImage = null; // 지연 초기화
  }

  display() {
    // 실제로 필요할 때만 RealImage 생성
    if (!this.realImage) {
      this.realImage = new RealImage(this.filename);
    }
    this.realImage.display();
  }
}

// 사용 예시
const image1 = new ProxyImage('photo1.jpg'); // 아직 로딩 안 됨
const image2 = new ProxyImage('photo2.jpg'); // 아직 로딩 안 됨
image1.display(); // 이때 실제 로딩됨

설명

이것이 하는 일: 프록시 패턴은 클라이언트와 실제 객체 사이에 중개자 역할을 하는 프록시 객체를 두어, 실제 객체로의 접근을 제어하고 추가 기능을 제공합니다. 첫 번째로, RealImage 클래스는 실제 이미지를 나타내며 생성자에서 즉시 디스크로부터 이미지를 로딩합니다.

이 작업은 시간과 메모리를 많이 소비하는 무거운 작업입니다. 만약 100개의 이미지를 한 번에 생성한다면, 모든 이미지가 로딩될 때까지 사용자는 오랜 시간 기다려야 합니다.

두 번째로, ProxyImage 클래스는 RealImage와 동일한 display() 메서드를 제공하지만, 생성자에서는 파일명만 저장하고 실제 이미지 로딩은 하지 않습니다. realImage 속성은 null로 초기화되어 있다가, display() 메서드가 처음 호출될 때 비로소 RealImage 객체를 생성합니다.

이를 지연 초기화(Lazy Initialization)라고 합니다. 세 번째로, display() 메서드 내부에서는 realImage가 null인지 체크합니다.

null이라면 이제서야 실제 RealImage 객체를 생성하고, 이미 생성되어 있다면 기존 객체를 재사용합니다. 이렇게 하면 같은 이미지를 여러 번 표시하더라도 디스크 로딩은 단 한 번만 발생합니다.

여러분이 이 코드를 사용하면 애플리케이션의 초기 로딩 속도가 크게 개선됩니다. 100개의 이미지 썸네일을 보여주는 갤러리 페이지에서, 사용자가 실제로 클릭해서 보는 이미지만 로딩하게 되므로 불필요한 네트워크 요청과 메모리 사용을 줄일 수 있습니다.

또한 프록시 객체는 원본 객체와 동일한 인터페이스를 제공하므로, 클라이언트 코드는 프록시를 사용하는지 실제 객체를 사용하는지 알 필요가 없습니다.

실전 팁

💡 프록시와 원본 객체는 동일한 인터페이스를 구현해야 합니다. 그래야 클라이언트 코드에서 둘을 구분 없이 사용할 수 있고, 필요에 따라 쉽게 교체할 수 있습니다.

💡 프록시 내부에서 원본 객체에 접근할 때는 순환 참조나 메모리 누수가 발생하지 않도록 주의하세요. 필요 없어진 원본 객체는 적절히 해제해야 합니다.

💡 ES6 Proxy 객체를 사용하면 더 강력한 프록시를 만들 수 있습니다. 속성 접근, 할당, 함수 호출 등 거의 모든 작업을 가로채서 커스텀 동작을 추가할 수 있습니다.

💡 프록시 패턴은 단일 책임 원칙(SRP)을 지키는 데 도움이 됩니다. 원본 객체는 핵심 비즈니스 로직에만 집중하고, 캐싱, 로깅, 검증 등의 부가 기능은 프록시에서 처리하세요.

💡 프록시를 여러 개 체인으로 연결할 수도 있습니다. 예를 들어 로깅 프록시 → 캐싱 프록시 → 실제 객체 순서로 연결하면 각 프록시가 독립적인 책임을 가지면서도 함께 동작할 수 있습니다.


2. 가상 프록시 - 무거운 객체의 지연 생성

시작하며

여러분이 비디오 스트리밍 플랫폼을 개발한다고 상상해보세요. 사용자가 페이지를 열면 수십 개의 비디오 썸네일이 표시되는데, 각 비디오의 전체 메타데이터와 스트리밍 URL을 미리 로드한다면 어떻게 될까요?

페이지 로딩에 수십 초가 걸리고, 사용자의 데이터 요금도 폭탄처럼 증가할 것입니다. 이런 문제는 특히 모바일 환경이나 느린 네트워크에서 더욱 심각합니다.

사용자는 실제로 10개 중 1-2개의 비디오만 재생하는데, 모든 비디오 데이터를 미리 로드하는 것은 엄청난 낭비입니다. 서버 측면에서도 불필요한 API 호출이 증가하여 비용과 부하가 커집니다.

바로 이럴 때 필요한 것이 가상 프록시(Virtual Proxy)입니다. 가상 프록시는 생성 비용이 큰 객체를 실제로 필요한 순간까지 생성을 미루고, 그 전까지는 가벼운 대리 객체로 대신합니다.

개요

가상 프록시는 프록시 패턴의 가장 대표적인 유형으로, 무겁거나 비용이 많이 드는 객체의 생성을 실제로 필요한 시점까지 지연시키는 기법입니다. 이 패턴이 빛을 발하는 순간은 객체 생성에 많은 시간이나 리소스가 필요할 때입니다.

예를 들어, 대용량 파일을 다운로드하거나, 복잡한 계산을 수행하거나, 데이터베이스 연결을 만드는 등의 작업이 필요한 객체를 다룰 때 매우 유용합니다. 사용자가 실제로 해당 기능을 사용하지 않는다면 그 비용을 지불할 필요가 없죠.

전통적인 방법에서는 객체를 생성할 때 모든 초기화 작업을 한 번에 수행했습니다. 하지만 가상 프록시를 사용하면 객체의 생성과 초기화를 분리하여, 실제로 객체의 메서드가 호출될 때 비로소 초기화를 수행할 수 있습니다.

가상 프록시의 핵심 특징은 다음과 같습니다: (1) 객체의 껍데기만 먼저 만들고 실제 내용은 나중에 채웁니다, (2) 한 번 생성된 실제 객체는 캐싱하여 재사용합니다, (3) 클라이언트는 지연 로딩이 일어나는지 알 수 없습니다. 이러한 특징들은 특히 초기 로딩 성능이 중요한 웹 애플리케이션에서 사용자 경험을 크게 개선시킵니다.

코드 예제

// 무거운 비디오 객체 - API 호출과 데이터 처리가 필요
class RealVideo {
  constructor(videoId) {
    this.videoId = videoId;
    this.metadata = null;
    this.streamUrl = null;
    this.loadVideoData(); // 무거운 네트워크 작업
  }

  loadVideoData() {
    console.log(`비디오 ${this.videoId} 메타데이터 로딩 중...`);
    // 실제로는 API 호출
    this.metadata = { title: '비디오', duration: 180 };
    this.streamUrl = `https://cdn.example.com/${this.videoId}`;
    console.log(`비디오 ${this.videoId} 로딩 완료`);
  }

  play() {
    console.log(`재생: ${this.metadata.title} (${this.streamUrl})`);
  }
}

// 가상 프록시 - 실제 필요할 때만 비디오 로드
class VideoProxy {
  constructor(videoId) {
    this.videoId = videoId;
    this.realVideo = null;
  }

  play() {
    // 지연 초기화: play가 호출될 때 실제 비디오 생성
    if (!this.realVideo) {
      console.log('실제 비디오 객체 생성 중...');
      this.realVideo = new RealVideo(this.videoId);
    }
    this.realVideo.play();
  }
}

// 사용 예시
console.log('페이지 로딩 시작');
const videos = [
  new VideoProxy('vid1'),
  new VideoProxy('vid2'),
  new VideoProxy('vid3')
]; // 빠르게 생성됨
console.log('페이지 로딩 완료');

videos[0].play(); // 이때 실제 로딩

설명

이것이 하는 일: 가상 프록시는 무거운 객체의 생성을 실제 메서드가 호출되는 순간까지 미루어, 애플리케이션의 초기화 시간을 단축하고 메모리 사용을 최적화합니다. 첫 번째로, RealVideo 클래스는 생성자에서 즉시 loadVideoData()를 호출하여 API로부터 비디오 메타데이터와 스트리밍 URL을 가져옵니다.

이 작업은 네트워크 요청을 수반하므로 수백 밀리초에서 수 초까지 걸릴 수 있습니다. 만약 100개의 RealVideo 객체를 한 번에 생성한다면, 사용자는 페이지가 로드되기까지 매우 오래 기다려야 합니다.

두 번째로, VideoProxy 클래스는 생성자에서 videoId만 저장하고 실제 비디오 데이터는 로드하지 않습니다. 이 과정은 거의 즉시 완료되므로, 100개의 VideoProxy 객체를 생성하는 것은 밀리초도 걸리지 않습니다.

사용자는 페이지가 빠르게 로드되는 것을 경험하고, 썸네일을 둘러볼 수 있습니다. 세 번째로, play() 메서드가 호출되면 그제서야 realVideo가 null인지 체크하고, null이라면 실제 RealVideo 객체를 생성합니다.

이후의 play() 호출에서는 이미 생성된 realVideo 객체를 재사용하므로, 같은 비디오를 여러 번 재생하더라도 네트워크 요청은 단 한 번만 발생합니다. 마지막으로, 사용 예시를 보면 페이지 로딩 시점에 3개의 프록시가 순식간에 생성되고, 실제로 videos[0].play()가 호출될 때 비로소 첫 번째 비디오만 로딩됩니다.

사용자가 나머지 비디오를 재생하지 않는다면, 그 비디오들은 영원히 로딩되지 않아도 되므로 대역폭과 서버 리소스를 절약할 수 있습니다. 여러분이 이 패턴을 적용하면 페이지 초기 로딩 시간이 90% 이상 단축될 수 있습니다.

특히 이미지 갤러리, 비디오 플랫폼, 문서 뷰어 등 많은 리소스를 다루는 애플리케이션에서 사용자 경험을 극적으로 개선할 수 있습니다.

실전 팁

💡 가상 프록시를 구현할 때는 프록시와 실제 객체가 완전히 동일한 공개 인터페이스를 가져야 합니다. 그렇지 않으면 클라이언트 코드에서 타입 체크나 분기 처리가 필요해져 패턴의 투명성이 깨집니다.

💡 멀티스레드 환경에서는 지연 초기화 시 race condition이 발생할 수 있습니다. 두 개의 스레드가 동시에 play()를 호출하면 RealVideo가 두 번 생성될 수 있으므로, 필요하다면 동기화 메커니즘을 추가하세요.

💡 프록시에서 실제 객체로 위임할 때 발생하는 오버헤드를 고려하세요. 메서드 호출이 매우 빈번하다면 오히려 성능이 나빠질 수 있습니다. 생성 비용이 충분히 클 때만 가상 프록시를 사용하세요.

💡 실제 객체의 생성이 실패할 수 있는 경우(네트워크 오류, 파일 없음 등)를 고려하여 에러 핸들링을 추가하세요. 프록시는 이런 에러를 적절히 처리하거나 클라이언트에게 전달해야 합니다.

💡 TypeScript를 사용한다면 프록시와 실제 객체가 공통 인터페이스를 구현하도록 강제할 수 있습니다. 이렇게 하면 컴파일 타임에 인터페이스 불일치를 잡아낼 수 있어 더 안전합니다.


3. 보호 프록시 - 접근 권한 제어

시작하며

여러분이 기업용 문서 관리 시스템을 개발하고 있다고 가정해보세요. 민감한 재무 보고서나 인사 문서는 특정 권한을 가진 사용자만 열람할 수 있어야 하는데, 모든 문서 클래스마다 권한 체크 로직을 중복해서 넣는다면 코드가 복잡해지고 유지보수가 어려워집니다.

이런 보안 요구사항은 실제 개발에서 매우 흔합니다. 권한 체크 로직이 비즈니스 로직과 섞이면 코드의 가독성이 떨어지고, 권한 정책이 바뀔 때 모든 곳을 찾아서 수정해야 하는 번거로움이 있습니다.

또한 실수로 권한 체크를 빠뜨리면 심각한 보안 취약점이 될 수 있습니다. 바로 이럴 때 필요한 것이 보호 프록시(Protection Proxy)입니다.

보호 프록시는 실제 객체에 대한 접근을 제어하여, 권한이 있는 사용자만 특정 작업을 수행할 수 있도록 보장합니다.

개요

간단히 말해서, 보호 프록시는 원본 객체로의 접근을 가로채서 권한을 검증하고, 권한이 있는 경우에만 실제 작업을 수행하도록 하는 패턴입니다. 이 패턴이 필요한 이유는 보안과 관련된 관심사를 비즈니스 로직으로부터 분리하기 위함입니다.

예를 들어, 급여 정보를 조회하는 기능에서 권한 체크를 직접 구현하면 급여 클래스가 권한 관리까지 책임지게 되어 단일 책임 원칙이 깨집니다. 보호 프록시를 사용하면 권한 체크는 프록시가, 실제 급여 처리는 원본 객체가 담당하여 역할이 명확해집니다.

기존에는 각 메서드 시작 부분에 if문으로 권한을 체크하는 코드를 넣었다면, 보호 프록시를 사용하면 원본 객체는 권한 체크 없이 순수한 비즈니스 로직만 가지고, 프록시가 모든 접근을 가로채서 권한을 검증합니다. 보호 프록시의 핵심 특징은 다음과 같습니다: (1) 접근 제어 로직을 중앙화하여 관리하기 쉽습니다, (2) 원본 객체의 코드를 변경하지 않고 보안을 추가할 수 있습니다, (3) 다양한 접근 제어 정책을 쉽게 적용하고 변경할 수 있습니다.

이러한 특징들은 보안이 중요한 엔터프라이즈 애플리케이션에서 필수적입니다.

코드 예제

// 실제 문서 객체
class SensitiveDocument {
  constructor(title, content) {
    this.title = title;
    this.content = content;
  }

  read() {
    return `문서 내용: ${this.content}`;
  }

  edit(newContent) {
    this.content = newContent;
    return '문서가 수정되었습니다';
  }

  delete() {
    return '문서가 삭제되었습니다';
  }
}

// 보호 프록시 - 권한에 따라 접근 제어
class DocumentProxy {
  constructor(document, userRole) {
    this.document = document;
    this.userRole = userRole; // 'admin', 'editor', 'viewer'
  }

  read() {
    // 모든 역할이 읽기 가능
    console.log(`[${this.userRole}] 문서 읽기 시도`);
    return this.document.read();
  }

  edit(newContent) {
    // editor와 admin만 수정 가능
    if (this.userRole === 'editor' || this.userRole === 'admin') {
      console.log(`[${this.userRole}] 문서 수정 허용`);
      return this.document.edit(newContent);
    }
    throw new Error('권한 없음: 문서 수정 권한이 없습니다');
  }

  delete() {
    // admin만 삭제 가능
    if (this.userRole === 'admin') {
      console.log(`[${this.userRole}] 문서 삭제 허용`);
      return this.document.delete();
    }
    throw new Error('권한 없음: 문서 삭제 권한이 없습니다');
  }
}

// 사용 예시
const doc = new SensitiveDocument('재무보고서', '기밀 내용');
const adminProxy = new DocumentProxy(doc, 'admin');
const viewerProxy = new DocumentProxy(doc, 'viewer');

console.log(viewerProxy.read()); // 성공
// viewerProxy.delete(); // 에러 발생

설명

이것이 하는 일: 보호 프록시는 클라이언트의 모든 요청을 가로채서 사용자의 권한을 확인하고, 권한이 충분한 경우에만 실제 객체의 메서드를 호출합니다. 첫 번째로, SensitiveDocument 클래스는 순수하게 문서 관련 기능만을 담당합니다.

read(), edit(), delete() 메서드에는 어떠한 권한 체크 로직도 들어있지 않습니다. 이는 단일 책임 원칙을 잘 지키는 깔끔한 설계입니다.

만약 권한 정책이 바뀌더라도 이 클래스는 수정할 필요가 없습니다. 두 번째로, DocumentProxy 클래스는 생성자에서 원본 문서 객체와 사용자의 역할(userRole)을 받습니다.

이 역할 정보는 모든 접근 제어 결정의 기준이 됩니다. 실제 프로젝트에서는 JWT 토큰에서 추출한 권한 정보나, 데이터베이스에서 조회한 사용자 권한을 사용할 수 있습니다.

세 번째로, 각 메서드(read, edit, delete)는 서로 다른 권한 정책을 가집니다. read()는 모든 역할에 열려 있고, edit()은 editor와 admin만 가능하며, delete()는 오직 admin만 가능합니다.

이렇게 메서드별로 세밀한 접근 제어를 구현할 수 있습니다. 권한이 없는 경우 명확한 에러 메시지와 함께 예외를 던져서 클라이언트가 적절히 처리할 수 있도록 합니다.

마지막으로, 사용 예시를 보면 같은 문서 객체에 대해 역할이 다른 두 개의 프록시를 만들 수 있습니다. adminProxy는 모든 작업을 수행할 수 있지만, viewerProxy는 읽기만 가능하고 삭제를 시도하면 에러가 발생합니다.

이렇게 하면 사용자 세션마다 적절한 프록시를 생성하여 자동으로 권한을 제어할 수 있습니다. 여러분이 이 패턴을 적용하면 보안 관련 코드가 한 곳에 모이므로 보안 정책을 일관되게 적용하고 쉽게 변경할 수 있습니다.

또한 원본 객체를 수정하지 않고도 새로운 보안 요구사항을 추가할 수 있어 개방-폐쇄 원칙(OCP)도 준수하게 됩니다. 실무에서는 이 패턴을 API 엔드포인트의 인증/인가, 데이터베이스 접근 제어, 파일 시스템 보안 등 다양한 곳에 활용할 수 있습니다.

실전 팁

💡 권한 체크는 가능한 한 프록시의 메서드 시작 부분에서 수행하세요. 실제 객체의 메서드를 호출한 후에 체크하면 이미 상태가 변경되었을 수 있어 롤백이 복잡해집니다.

💡 세밀한 권한 제어가 필요하다면 역할 기반 접근 제어(RBAC) 또는 속성 기반 접근 제어(ABAC) 모델을 고려하세요. 단순한 역할 비교보다 더 유연한 권한 관리가 가능합니다.

💡 권한 체크에 실패했을 때는 단순히 null을 반환하기보다는 명확한 예외를 던지세요. 로그에 누가 언제 어떤 권한 위반을 시도했는지 기록하면 보안 감사에 유용합니다.

💡 프록시는 원본 객체의 상태를 변경하지 않아야 합니다. 프록시의 역할은 순수하게 접근 제어만 담당해야 하며, 비즈니스 로직을 포함하지 않도록 주의하세요.

💡 성능이 중요한 경우 권한 정보를 캐싱하는 것을 고려하세요. 매번 데이터베이스에서 권한을 조회하면 성능 병목이 될 수 있으므로, 세션 동안 권한 정보를 메모리에 유지할 수 있습니다.


4. 캐싱 프록시 - 결과를 캐싱하여 성능 최적화

시작하며

여러분이 날씨 정보를 제공하는 앱을 개발하고 있다고 상상해보세요. 사용자가 화면을 전환할 때마다 외부 API를 호출해서 같은 도시의 날씨를 반복해서 가져온다면 어떻게 될까요?

불필요한 네트워크 비용이 발생하고, API 사용량 제한에 걸릴 수 있으며, 사용자는 매번 로딩 스피너를 봐야 합니다. 이런 비효율은 데이터베이스 쿼리, API 호출, 복잡한 계산 등 비용이 큰 작업에서 특히 문제가 됩니다.

같은 요청에 대해 매번 동일한 작업을 반복하는 것은 시간과 리소스의 낭비입니다. 특히 고트래픽 서비스에서는 이런 낭비가 누적되어 서버 비용을 크게 증가시킬 수 있습니다.

바로 이럴 때 필요한 것이 캐싱 프록시(Caching Proxy)입니다. 캐싱 프록시는 한 번 수행한 작업의 결과를 저장해두었다가, 같은 요청이 오면 저장된 결과를 즉시 반환하여 성능을 크게 향상시킵니다.

개요

캐싱 프록시는 비용이 큰 작업의 결과를 메모리에 저장해두고, 동일한 요청이 들어오면 실제 작업을 수행하지 않고 캐시된 결과를 반환하는 패턴입니다. 이 패턴은 같은 입력에 대해 항상 같은 결과를 반환하는 순수 함수나, 자주 변경되지 않는 데이터를 다룰 때 매우 효과적입니다.

예를 들어, 복잡한 통계 계산, 외부 API 호출, 데이터베이스 조회 등에서 빛을 발합니다. 실시간성이 중요하지 않은 데이터라면 몇 분간 캐시하는 것만으로도 시스템 부하를 90% 이상 줄일 수 있습니다.

기존에는 매번 함수를 호출할 때마다 동일한 계산이나 조회를 반복 수행했다면, 캐싱 프록시를 사용하면 첫 번째 호출의 결과를 저장해두고 이후 호출에서는 저장된 값을 즉시 반환합니다. 캐싱 프록시의 핵심 특징은 다음과 같습니다: (1) 응답 시간을 밀리초에서 마이크로초 수준으로 단축시킵니다, (2) 외부 서비스나 데이터베이스의 부하를 대폭 줄입니다, (3) 캐시 만료 정책을 통해 신선도와 성능의 균형을 맞출 수 있습니다.

이러한 특징들은 특히 대규모 트래픽을 처리하는 서비스에서 비용 절감과 성능 향상을 동시에 달성하게 해줍니다.

코드 예제

// 실제 날씨 서비스 - API 호출이 느림
class RealWeatherService {
  async getWeather(city) {
    console.log(`API 호출: ${city}의 날씨 정보 가져오는 중...`);
    // 실제로는 fetch()를 사용
    await this.simulateDelay(2000); // 2초 지연
    return {
      city: city,
      temperature: Math.floor(Math.random() * 30) + 10,
      condition: '맑음',
      timestamp: new Date()
    };
  }

  async simulateDelay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

// 캐싱 프록시 - 결과를 캐시하여 빠르게 응답
class CachingWeatherProxy {
  constructor(realService, cacheDuration = 300000) { // 5분 캐시
    this.realService = realService;
    this.cache = new Map();
    this.cacheDuration = cacheDuration;
  }

  async getWeather(city) {
    const cached = this.cache.get(city);
    const now = Date.now();

    // 캐시가 있고 유효하면 캐시 반환
    if (cached && (now - cached.timestamp) < this.cacheDuration) {
      console.log(`캐시 히트: ${city} (${Math.floor((now - cached.timestamp) / 1000)}초 전 데이터)`);
      return cached.data;
    }

    // 캐시가 없거나 만료되었으면 실제 서비스 호출
    console.log(`캐시 미스: ${city} - 실제 데이터 가져오기`);
    const data = await this.realService.getWeather(city);
    this.cache.set(city, { data, timestamp: now });
    return data;
  }

  clearCache() {
    this.cache.clear();
    console.log('캐시 전체 삭제됨');
  }
}

// 사용 예시
const service = new RealWeatherService();
const proxy = new CachingWeatherProxy(service, 60000); // 1분 캐시

await proxy.getWeather('서울'); // 2초 소요 (API 호출)
await proxy.getWeather('서울'); // 즉시 반환 (캐시)

설명

이것이 하는 일: 캐싱 프록시는 원본 서비스에 대한 모든 호출을 가로채서, 이전에 같은 요청을 처리한 적이 있는지 확인하고, 있다면 저장된 결과를 반환하며, 없다면 실제 서비스를 호출하고 결과를 저장합니다. 첫 번째로, RealWeatherService는 실제 날씨 API를 호출하는 서비스를 시뮬레이션합니다.

simulateDelay(2000)을 통해 네트워크 지연을 재현하는데, 실제 외부 API 호출은 네트워크 상태에 따라 수백 밀리초에서 수 초까지 걸릴 수 있습니다. 이런 지연은 사용자 경험을 크게 해치는 요소입니다.

두 번째로, CachingWeatherProxy는 생성자에서 실제 서비스와 캐시 유효 기간을 받습니다. 내부적으로 Map 객체를 사용하여 도시 이름을 키로, 날씨 데이터와 타임스탬프를 값으로 저장합니다.

Map을 사용하면 O(1) 시간 복잡도로 매우 빠르게 캐시를 조회할 수 있습니다. 세 번째로, getWeather() 메서드에서는 먼저 캐시를 확인합니다.

캐시가 존재하고 현재 시간과 저장 시간의 차이가 cacheDuration보다 작으면 '캐시 히트'로 간주하고 저장된 데이터를 즉시 반환합니다. 이 경우 API 호출 없이 마이크로초 단위로 응답이 가능합니다.

만약 캐시가 없거나 만료되었다면 '캐시 미스'로 간주하고 실제 서비스를 호출한 후, 결과를 캐시에 저장합니다. 네 번째로, 캐시 만료 메커니즘은 매우 중요합니다.

cacheDuration을 너무 길게 설정하면 오래된 데이터를 제공할 위험이 있고, 너무 짧게 설정하면 캐시의 효과가 줄어듭니다. 날씨처럼 몇 분 단위로 변하는 데이터는 1-5분 정도가 적당하고, 거의 변하지 않는 마스터 데이터는 몇 시간에서 며칠까지 캐시할 수 있습니다.

여러분이 이 패턴을 적용하면 같은 데이터에 대한 반복적인 요청의 응답 시간이 1000배 이상 빨라질 수 있습니다. 또한 외부 API 호출 횟수가 줄어들어 API 사용 비용을 절감하고, 속도 제한(rate limit)에 걸릴 위험도 낮아집니다.

대규모 서비스에서는 캐싱을 통해 서버 대수를 크게 줄일 수 있어 인프라 비용을 절감할 수 있습니다.

실전 팁

💡 캐시 키를 설계할 때는 함수의 모든 매개변수를 고려하세요. 도시 이름뿐만 아니라 언어, 단위 등 다른 매개변수가 있다면 이들을 조합하여 고유한 키를 만들어야 합니다.

💡 메모리 누수를 방지하기 위해 캐시 크기를 제한하세요. LRU(Least Recently Used) 캐시를 구현하거나, 일정 시간마다 캐시를 정리하는 로직을 추가하는 것이 좋습니다.

💡 캐시 워밍(Cache Warming) 전략을 고려하세요. 서버가 시작될 때 자주 요청되는 데이터를 미리 캐시에 로드해두면 초기 사용자도 빠른 응답을 경험할 수 있습니다.

💡 분산 시스템에서는 Redis 같은 외부 캐시 서버를 사용하는 것을 고려하세요. 여러 서버 인스턴스가 캐시를 공유할 수 있어 효율이 더욱 높아집니다.

💡 캐시 무효화(Cache Invalidation) 전략을 명확히 하세요. 데이터가 변경되었을 때 관련 캐시를 즉시 삭제하거나 업데이트하는 메커니즘이 필요합니다. "컴퓨터 과학의 두 가지 어려운 문제는 캐시 무효화와 이름 짓기"라는 말이 있을 정도로 중요한 부분입니다.


5. 로깅 프록시 - 메서드 호출 추적과 디버깅

시작하며

여러분이 운영 중인 서비스에서 간헐적으로 발생하는 버그를 추적하고 있다고 상상해보세요. 문제는 특정 사용자가 특정 순서로 작업을 수행할 때만 발생하는데, 어떤 메서드가 언제, 어떤 인자로 호출되었는지 알 수 없어서 원인 파악이 매우 어렵습니다.

이런 상황은 복잡한 비즈니스 로직을 가진 시스템에서 자주 발생합니다. 모든 메서드에 console.log()를 추가하면 코드가 지저분해지고, 프로덕션에 배포할 때는 이 로그들을 일일이 제거하거나 주석 처리해야 하는 번거로움이 있습니다.

또한 로깅 로직이 비즈니스 로직과 섞이면 코드의 가독성이 크게 떨어집니다. 바로 이럴 때 필요한 것이 로깅 프록시(Logging Proxy)입니다.

로깅 프록시는 원본 객체의 코드를 전혀 수정하지 않고, 모든 메서드 호출을 자동으로 기록하여 디버깅과 모니터링을 쉽게 만들어줍니다.

개요

로깅 프록시는 원본 객체의 모든 메서드 호출을 가로채서 메서드 이름, 인자, 반환값, 실행 시간 등을 자동으로 기록하는 패턴입니다. 이 패턴은 디버깅, 성능 모니터링, 감사 로그 작성 등 다양한 목적으로 활용됩니다.

특히 마이크로서비스 아키텍처에서 서비스 간 호출을 추적하거나, 성능 병목을 찾거나, 보안 감사를 위해 사용자의 행동을 기록할 때 매우 유용합니다. 개발 환경에서는 상세한 로그를 남기고, 프로덕션에서는 에러와 경고만 기록하도록 쉽게 전환할 수 있습니다.

기존에는 각 메서드 안에 로깅 코드를 직접 넣어야 했다면, 로깅 프록시를 사용하면 원본 객체는 순수한 비즈니스 로직만 가지고, 프록시가 투명하게 로깅 기능을 추가합니다. 로깅 프록시의 핵심 특징은 다음과 같습니다: (1) 원본 코드를 수정하지 않고 로깅을 추가할 수 있습니다, (2) 개발 환경과 프로덕션 환경에서 로깅 수준을 쉽게 조정할 수 있습니다, (3) 메서드 실행 시간을 측정하여 성능 분석에 활용할 수 있습니다.

이러한 특징들은 복잡한 시스템의 동작을 이해하고 문제를 빠르게 해결하는 데 필수적입니다.

코드 예제

// 실제 결제 서비스
class PaymentService {
  processPayment(userId, amount) {
    console.log('결제 처리 중...');
    // 실제 결제 로직
    return { success: true, transactionId: 'TXN12345' };
  }

  refund(transactionId, amount) {
    console.log('환불 처리 중...');
    // 실제 환불 로직
    return { success: true, refundId: 'REF67890' };
  }

  getBalance(userId) {
    // 잔액 조회 로직
    return { userId, balance: 50000 };
  }
}

// 로깅 프록시 - 모든 호출을 자동으로 기록
class LoggingProxy {
  constructor(target, logLevel = 'DEBUG') {
    this.target = target;
    this.logLevel = logLevel;

    // 모든 메서드를 프록시로 감싸기
    return new Proxy(target, {
      get: (target, prop) => {
        if (typeof target[prop] === 'function') {
          return (...args) => {
            const start = Date.now();
            console.log(`[${new Date().toISOString()}] 호출: ${prop}(${JSON.stringify(args)})`);

            try {
              const result = target[prop](...args);
              const duration = Date.now() - start;
              console.log(`[${new Date().toISOString()}] 완료: ${prop} - ${duration}ms, 결과: ${JSON.stringify(result)}`);
              return result;
            } catch (error) {
              console.error(`[${new Date().toISOString()}] 에러: ${prop} - ${error.message}`);
              throw error;
            }
          };
        }
        return target[prop];
      }
    });
  }
}

// 사용 예시
const service = new PaymentService();
const loggedService = new LoggingProxy(service);

loggedService.processPayment('user123', 10000);
loggedService.getBalance('user123');

설명

이것이 하는 일: 로깅 프록시는 ES6 Proxy 객체를 활용하여 대상 객체의 모든 메서드 호출을 가로채고, 호출 전후로 자동으로 로그를 남깁니다. 첫 번째로, PaymentService는 결제 관련 비즈니스 로직만을 담당하는 깨끗한 클래스입니다.

로깅 코드가 전혀 없어서 코드가 간결하고 가독성이 높습니다. 이 클래스의 개발자는 로깅에 대해 전혀 신경 쓸 필요가 없고, 순수하게 비즈니스 로직에만 집중할 수 있습니다.

두 번째로, LoggingProxy는 ES6의 Proxy 객체를 사용합니다. 이는 JavaScript의 강력한 메타프로그래밍 기능으로, 객체의 기본 동작을 가로채고 커스터마이즈할 수 있게 해줍니다.

get 트랩(trap)을 정의하여 속성에 접근할 때마다 우리의 로직이 실행되도록 합니다. 세 번째로, get 트랩 내부에서는 접근하려는 속성이 함수인지 확인합니다.

함수라면 원본 함수를 감싸는 새로운 함수를 반환합니다. 이 새로운 함수는 (1) 호출 시작 시간을 기록하고, (2) 메서드 이름과 인자를 로그로 남기고, (3) 원본 함수를 호출하고, (4) 실행 시간과 결과를 로그로 남기는 일련의 과정을 수행합니다.

네 번째로, try-catch 블록으로 에러도 자동으로 로깅합니다. 메서드 실행 중 예외가 발생하면 에러 메시지를 기록하고 예외를 다시 던져서, 클라이언트 코드에서 정상적으로 에러 처리를 할 수 있도록 합니다.

이렇게 하면 에러가 발생한 위치와 원인을 쉽게 추적할 수 있습니다. 마지막으로, 실행 시간을 측정하는 것도 중요한 기능입니다.

Date.now()로 시작과 끝 시간을 측정하여 각 메서드가 얼마나 걸리는지 알 수 있습니다. 이 정보는 성능 병목을 찾는 데 매우 유용합니다.

예를 들어, 특정 메서드가 평소보다 10배 느리게 실행된다면 데이터베이스 문제나 네트워크 이슈를 의심할 수 있습니다. 여러분이 이 패턴을 적용하면 운영 중인 서비스의 가시성이 크게 향상됩니다.

문제가 발생했을 때 로그를 분석하여 어떤 메서드가 어떤 순서로 호출되었는지, 어디서 에러가 발생했는지 빠르게 파악할 수 있습니다. 또한 성능 데이터를 수집하여 최적화가 필요한 부분을 객관적으로 식별할 수 있습니다.

실전 팁

💡 프로덕션 환경에서는 로그 레벨을 조정하여 불필요한 로그를 줄이세요. DEBUG, INFO, WARN, ERROR 레벨을 구분하고, 프로덕션에서는 WARN 이상만 기록하도록 설정할 수 있습니다.

💡 민감한 정보(비밀번호, 신용카드 번호 등)는 로그에 남기지 않도록 주의하세요. 특정 필드를 마스킹하거나 제외하는 로직을 추가할 수 있습니다.

💡 로그가 너무 많아지면 성능에 영향을 줄 수 있습니다. 고빈도 메서드는 샘플링(예: 100번 중 1번만 로깅)하거나, 비동기로 로그를 기록하는 것을 고려하세요.

💡 구조화된 로그를 사용하면 나중에 분석하기 쉽습니다. JSON 형식으로 로그를 남기면 Elasticsearch나 Splunk 같은 도구로 쉽게 검색하고 분석할 수 있습니다.

💡 분산 추적(Distributed Tracing)을 구현하려면 각 요청에 고유한 trace ID를 부여하고, 모든 로그에 이 ID를 포함시키세요. 그러면 마이크로서비스 간 호출 흐름을 끝까지 추적할 수 있습니다.


6. 원격 프록시 - 네트워크 통신 추상화

시작하며

여러분이 마이크로서비스 아키텍처로 구성된 시스템을 개발하고 있다고 가정해보세요. 사용자 서비스에서 주문 서비스의 함수를 호출해야 하는데, HTTP 요청을 만들고, JSON을 파싱하고, 에러를 처리하는 복잡한 코드를 매번 작성해야 한다면 얼마나 번거로울까요?

이런 네트워크 통신 코드는 실제 비즈니스 로직과 섞이면 코드가 복잡해지고 테스트하기도 어려워집니다. 또한 원격 서비스의 API가 변경될 때마다 클라이언트 코드의 여러 곳을 수정해야 하는 유지보수 문제도 발생합니다.

REST API, gRPC, GraphQL 등 다양한 통신 방식을 사용하면 복잡도는 더욱 증가합니다. 바로 이럴 때 필요한 것이 원격 프록시(Remote Proxy)입니다.

원격 프록시는 네트워크 너머에 있는 원격 객체를 마치 로컬 객체처럼 사용할 수 있게 해주어, 네트워크 통신의 복잡함을 완전히 숨겨줍니다.

개요

간단히 말해서, 원격 프록시는 다른 주소 공간(다른 서버, 다른 프로세스)에 있는 객체를 로컬에 있는 것처럼 접근할 수 있게 해주는 대리자입니다. 이 패턴은 분산 시스템에서 필수적입니다.

클라이언트는 원격 객체가 네트워크 너머에 있다는 사실을 몰라도 되고, 일반적인 메서드 호출 방식으로 원격 서비스를 이용할 수 있습니다. 예를 들어, 다른 서버에 있는 사용자 정보를 조회할 때, userService.getUser(123)처럼 간단하게 호출하면 프록시가 내부적으로 HTTP 요청을 보내고 응답을 파싱하여 반환해줍니다.

기존에는 fetch()나 axios를 직접 사용하여 URL을 만들고, 헤더를 설정하고, 에러를 처리하는 코드를 비즈니스 로직과 함께 작성했다면, 원격 프록시를 사용하면 네트워크 통신 로직은 프록시에 캡슐화되고 클라이언트는 깨끗한 인터페이스만 사용하게 됩니다. 원격 프록시의 핵심 특징은 다음과 같습니다: (1) 네트워크 통신을 완전히 추상화하여 클라이언트는 로컬 호출처럼 사용합니다, (2) 직렬화, 역직렬화, 에러 처리 등의 복잡한 작업을 프록시가 담당합니다, (3) 재시도, 타임아웃, 서킷 브레이커 같은 안정성 패턴을 프록시에 통합할 수 있습니다.

이러한 특징들은 마이크로서비스나 클라이언트-서버 아키텍처에서 코드의 복잡도를 크게 줄여줍니다.

코드 예제

// 원격 사용자 서비스 인터페이스
class UserService {
  async getUser(userId) {
    throw new Error('This is a remote service');
  }

  async updateUser(userId, data) {
    throw new Error('This is a remote service');
  }
}

// 원격 프록시 - HTTP 통신을 추상화
class RemoteUserServiceProxy {
  constructor(baseUrl) {
    this.baseUrl = baseUrl;
  }

  async getUser(userId) {
    try {
      console.log(`[RemoteProxy] GET ${this.baseUrl}/users/${userId}`);
      const response = await fetch(`${this.baseUrl}/users/${userId}`);

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      const data = await response.json();
      console.log(`[RemoteProxy] 응답 수신:`, data);
      return data;
    } catch (error) {
      console.error(`[RemoteProxy] 에러 발생:`, error.message);
      throw error;
    }
  }

  async updateUser(userId, data) {
    try {
      console.log(`[RemoteProxy] PUT ${this.baseUrl}/users/${userId}`, data);
      const response = await fetch(`${this.baseUrl}/users/${userId}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data)
      });

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      const result = await response.json();
      console.log(`[RemoteProxy] 업데이트 완료:`, result);
      return result;
    } catch (error) {
      console.error(`[RemoteProxy] 에러 발생:`, error.message);
      throw error;
    }
  }
}

// 사용 예시 - 클라이언트는 네트워크 통신을 몰라도 됨
const userService = new RemoteUserServiceProxy('https://api.example.com');
const user = await userService.getUser(123); // 마치 로컬 호출처럼
await userService.updateUser(123, { name: '홍길동' });

설명

이것이 하는 일: 원격 프록시는 클라이언트의 메서드 호출을 받아서 네트워크 요청으로 변환하고, 서버의 응답을 받아서 다시 클라이언트가 이해할 수 있는 객체로 변환하여 반환합니다. 첫 번째로, UserService는 인터페이스 역할을 하는 베이스 클래스입니다.

실제로는 구현이 없고 에러만 던지지만, 이 클래스를 통해 원격 서비스가 제공하는 메서드 시그니처를 명확히 정의할 수 있습니다. TypeScript를 사용한다면 이것을 인터페이스로 만들면 더욱 명확합니다.

두 번째로, RemoteUserServiceProxy는 실제 네트워크 통신을 수행하는 프록시입니다. 생성자에서 baseUrl을 받아서 어떤 서버와 통신할지 결정합니다.

이렇게 하면 개발 환경에서는 localhost를, 프로덕션에서는 실제 API 서버 URL을 사용하도록 쉽게 전환할 수 있습니다. 세 번째로, getUser() 메서드를 보면 fetch()를 사용하여 HTTP GET 요청을 보냅니다.

response.ok를 체크하여 HTTP 에러를 감지하고, response.json()으로 JSON을 자동으로 파싱합니다. 모든 에러는 try-catch로 잡아서 적절한 에러 메시지와 함께 다시 던집니다.

클라이언트 입장에서는 단순히 async 함수를 호출하고 결과를 기다리기만 하면 됩니다. 네 번째로, updateUser() 메서드는 POST나 PUT 같은 쓰기 작업을 처리합니다.

JavaScript 객체를 JSON.stringify()로 직렬화하고, Content-Type 헤더를 설정하여 서버가 올바르게 파싱할 수 있도록 합니다. 이런 세부 사항들은 모두 프록시 내부에 캡슐화되어 있어서, 클라이언트는 신경 쓸 필요가 없습니다.

마지막으로, 사용 예시를 보면 클라이언트 코드가 얼마나 깔끔한지 알 수 있습니다. userService.getUser(123)는 마치 로컬 함수를 호출하는 것처럼 보이지만, 실제로는 네트워크를 통해 원격 서버와 통신합니다.

만약 통신 프로토콜이 REST에서 gRPC로 바뀌더라도, 클라이언트 코드는 전혀 수정할 필요가 없고 프록시만 교체하면 됩니다. 여러분이 이 패턴을 적용하면 마이크로서비스 간 통신 코드가 극적으로 단순해집니다.

각 서비스팀은 자신의 서비스에 대한 프록시 SDK를 제공할 수 있고, 다른 팀은 이 SDK를 사용하여 네트워크 통신의 복잡함 없이 쉽게 통합할 수 있습니다. 또한 프록시 내부에 재시도 로직, 타임아웃, 서킷 브레이커 등의 안정성 패턴을 추가하면 전체 시스템의 안정성이 향상됩니다.

실전 팁

💡 원격 프록시에 재시도 로직을 추가하세요. 일시적인 네트워크 오류에 대해 자동으로 재시도하면 시스템의 안정성이 크게 향상됩니다. 지수 백오프(exponential backoff) 전략을 사용하면 더 좋습니다.

💡 타임아웃을 반드시 설정하세요. 원격 서버가 응답하지 않으면 무한정 기다리지 말고 일정 시간 후 에러를 던져야 합니다. fetch()의 AbortController를 활용할 수 있습니다.

💡 서킷 브레이커 패턴을 통합하여 장애가 전파되는 것을 막으세요. 원격 서비스가 계속 실패하면 일정 시간 동안 요청을 보내지 않고 즉시 실패 처리하여 리소스를 보호합니다.

💡 인증 토큰이나 API 키 같은 공통 헤더는 프록시 생성자나 설정 메서드를 통해 한 번만 설정하도록 하세요. 매번 메서드 호출마다 전달하면 번거롭고 보안에도 좋지 않습니다.

💡 TypeScript를 사용한다면 제네릭을 활용하여 타입 안전한 원격 프록시를 만들 수 있습니다. 이렇게 하면 컴파일 타임에 API 호출의 타입 오류를 잡아낼 수 있어 더 안전합니다.


7. 스마트 참조 프록시 - 참조 카운팅과 리소스 관리

시작하며

여러분이 대용량 파일이나 데이터베이스 연결 같은 비싼 리소스를 다루는 시스템을 개발하고 있다고 상상해보세요. 여러 컴포넌트가 같은 리소스를 동시에 사용하는데, 언제 리소스를 해제해야 안전한지 알기 어렵습니다.

너무 일찍 해제하면 다른 컴포넌트가 에러를 만나고, 너무 늦게 해제하면 메모리 누수가 발생합니다. 이런 리소스 관리 문제는 특히 장시간 실행되는 서버 애플리케이션에서 심각합니다.

데이터베이스 연결, 파일 핸들, 네트워크 소켓 같은 리소스는 제한되어 있기 때문에, 사용이 끝난 리소스를 제때 해제하지 않으면 결국 리소스가 고갈되어 시스템이 멈출 수 있습니다. 바로 이럴 때 필요한 것이 스마트 참조 프록시(Smart Reference Proxy)입니다.

이 패턴은 객체에 대한 참조를 추적하여, 마지막 참조가 사라질 때 자동으로 리소스를 정리해줍니다.

개요

스마트 참조 프록시는 원본 객체에 대한 참조 횟수를 추적하고, 추가적인 리소스 관리 작업(카운팅, 잠금, 지연 복사 등)을 자동으로 수행하는 패턴입니다. 이 패턴은 C++의 shared_ptr이나 Python의 참조 카운팅과 유사한 개념을 JavaScript로 구현한 것입니다.

여러 클라이언트가 동일한 무거운 객체를 공유할 때, 각 클라이언트가 프록시를 통해 접근하면 프록시가 자동으로 참조 수를 세고, 마지막 참조가 해제될 때 실제 객체를 정리합니다. 전통적인 방법에서는 개발자가 수동으로 리소스의 생명주기를 관리해야 했습니다.

언제 객체를 만들고, 누가 사용 중이며, 언제 삭제해야 안전한지 추적하는 것은 매우 어렵고 실수하기 쉬운 작업입니다. 스마트 참조 프록시를 사용하면 이런 복잡한 관리가 자동화됩니다.

스마트 참조 프록시의 핵심 특징은 다음과 같습니다: (1) 자동으로 참조 수를 추적하여 메모리 누수를 방지합니다, (2) 마지막 참조가 사라질 때 리소스를 자동으로 해제합니다, (3) Copy-on-Write 같은 최적화 기법을 투명하게 구현할 수 있습니다. 이러한 특징들은 리소스 제약이 있는 환경에서 안정적인 시스템을 만드는 데 매우 중요합니다.

코드 예제

// 무거운 데이터베이스 연결 객체
class DatabaseConnection {
  constructor(connectionString) {
    this.connectionString = connectionString;
    this.isConnected = false;
    this.connect();
  }

  connect() {
    console.log(`DB 연결 생성: ${this.connectionString}`);
    this.isConnected = true;
  }

  query(sql) {
    if (!this.isConnected) {
      throw new Error('연결이 닫혔습니다');
    }
    console.log(`쿼리 실행: ${sql}`);
    return { rows: [] };
  }

  close() {
    console.log(`DB 연결 종료: ${this.connectionString}`);
    this.isConnected = false;
  }
}

// 스마트 참조 프록시 - 참조 카운팅으로 리소스 관리
class SmartConnectionProxy {
  static connections = new Map(); // 연결 풀

  constructor(connectionString) {
    // 기존 연결이 있으면 재사용
    if (SmartConnectionProxy.connections.has(connectionString)) {
      const existing = SmartConnectionProxy.connections.get(connectionString);
      existing.refCount++;
      console.log(`[SmartProxy] 기존 연결 재사용 (참조 수: ${existing.refCount})`);
      return existing.proxy;
    }

    // 새 연결 생성
    const connection = new DatabaseConnection(connectionString);
    const proxy = {
      refCount: 1,
      connection: connection,
      proxy: this,
      query: (sql) => connection.query(sql),
      release: () => {
        const info = SmartConnectionProxy.connections.get(connectionString);
        info.refCount--;
        console.log(`[SmartProxy] 참조 해제 (남은 참조 수: ${info.refCount})`);

        // 마지막 참조가 해제되면 실제 연결 종료
        if (info.refCount === 0) {
          console.log(`[SmartProxy] 마지막 참조 해제 - 연결 종료`);
          connection.close();
          SmartConnectionProxy.connections.delete(connectionString);
        }
      }
    };

    SmartConnectionProxy.connections.set(connectionString, proxy);
    console.log(`[SmartProxy] 새 연결 생성 (참조 수: 1)`);
    return proxy.proxy;
  }
}

// 사용 예시
const conn1 = new SmartConnectionProxy('mysql://localhost/mydb');
const conn2 = new SmartConnectionProxy('mysql://localhost/mydb'); // 재사용
conn1.query('SELECT * FROM users');
conn2.query('SELECT * FROM products');

conn1.release(); // 아직 연결 유지 (참조 수: 1)
conn2.release(); // 이제 연결 종료 (참조 수: 0)

설명

이것이 하는 일: 스마트 참조 프록시는 같은 리소스에 대한 여러 참조를 관리하고, 각 참조가 생성되거나 해제될 때마다 카운트를 업데이트하며, 카운트가 0이 되면 자동으로 리소스를 정리합니다. 첫 번째로, DatabaseConnection은 실제 데이터베이스 연결을 나타냅니다.

연결을 만드는 것은 비용이 큰 작업이므로, 가능하면 연결을 재사용하고 정말 필요 없어질 때만 닫는 것이 효율적입니다. 하지만 여러 컴포넌트가 이 연결을 공유할 때 누가 언제 닫아야 하는지 조율하기가 매우 어렵습니다.

두 번째로, SmartConnectionProxy는 정적 Map을 사용하여 모든 연결을 추적합니다. 이 Map의 키는 연결 문자열이고, 값은 실제 연결 객체와 참조 카운트를 담고 있는 객체입니다.

이 구조를 통해 같은 데이터베이스에 대한 여러 요청을 하나의 물리적 연결로 처리할 수 있습니다. 세 번째로, 생성자에서는 먼저 connections Map을 확인하여 동일한 연결 문자열로 생성된 프록시가 있는지 검사합니다.

있다면 refCount를 1 증가시키고 기존 프록시를 반환합니다. 없다면 새로운 DatabaseConnection을 생성하고, 이를 감싸는 프록시 객체를 만들어 Map에 저장합니다.

이렇게 하면 conn1과 conn2는 서로 다른 프록시 인스턴스지만, 내부적으로는 같은 데이터베이스 연결을 공유합니다. 네 번째로, release() 메서드가 핵심입니다.

이 메서드가 호출될 때마다 refCount를 1 감소시키고, refCount가 0이 되면 실제 연결을 닫고 Map에서 제거합니다. 이렇게 하면 마지막으로 연결을 사용하던 컴포넌트가 release()를 호출할 때 자동으로 정리가 이루어집니다.

어떤 컴포넌트가 마지막인지 알 필요 없이, 각자 자신의 사용이 끝났을 때 release()만 호출하면 됩니다. 마지막으로, 사용 예시를 보면 conn1과 conn2가 생성될 때 두 번째는 기존 연결을 재사용합니다.

각각 쿼리를 실행한 후, conn1.release()가 호출되어도 아직 conn2가 사용 중이므로 연결은 유지됩니다. conn2.release()가 호출되는 순간 refCount가 0이 되어 비로소 실제 데이터베이스 연결이 닫힙니다.

여러분이 이 패턴을 적용하면 복잡한 리소스 관리가 자동화되어 메모리 누수나 리소스 고갈 문제를 예방할 수 있습니다. 특히 데이터베이스 연결 풀, 파일 핸들 관리, 대용량 객체 공유 등의 시나리오에서 매우 유용합니다.

실전 팁

💡 순환 참조에 주의하세요. 두 객체가 서로를 참조하면 refCount가 절대 0이 되지 않아 메모리 누수가 발생할 수 있습니다. WeakMap을 사용하거나 명시적인 정리 메서드를 제공하는 것을 고려하세요.

💡 멀티스레드 환경에서는 refCount 업데이트가 atomic해야 합니다. JavaScript는 싱글 스레드이므로 괜찮지만, Web Workers나 다른 언어로 구현할 때는 락이나 atomic 연산이 필요합니다.

💡 디버깅을 위해 현재 참조 수와 참조하는 객체들의 목록을 추적할 수 있는 기능을 추가하세요. 메모리 누수가 의심될 때 어떤 객체가 여전히 참조를 유지하고 있는지 파악하는 데 도움이 됩니다.

💡 finalizer나 destructor 개념이 없는 JavaScript에서는 명시적인 release() 호출이 중요합니다. 가능하다면 try-finally 블록을 사용하거나, 고차 함수로 감싸서 자동으로 release()가 호출되도록 만드는 것이 안전합니다.

💡 Copy-on-Write 최적화를 구현할 수도 있습니다. 여러 참조가 읽기만 한다면 같은 객체를 공유하고, 누군가 쓰기를 시도할 때 비로소 복사본을 만들어서 주면 메모리를 더욱 효율적으로 사용할 수 있습니다.


8. ES6 Proxy 활용 - JavaScript 네이티브 프록시

시작하며

여러분이 지금까지 배운 프록시 패턴들을 더 간결하고 강력하게 구현할 수 있는 방법이 있다면 어떨까요? 모든 메서드를 일일이 감싸는 대신, 자동으로 모든 속성 접근과 메서드 호출을 가로챌 수 있다면 코드가 훨씬 깔끔해질 것입니다.

JavaScript는 ES6부터 Proxy라는 강력한 메타프로그래밍 기능을 제공합니다. 하지만 많은 개발자들이 이 기능의 존재를 모르거나, 사용법이 복잡해 보여서 활용하지 못하고 있습니다.

사실 ES6 Proxy를 이해하면 프록시 패턴을 훨씬 우아하고 유연하게 구현할 수 있습니다. 바로 이럴 때 필요한 것이 ES6 Proxy 객체입니다.

이것은 JavaScript 언어 자체에서 제공하는 네이티브 프록시 기능으로, 거의 모든 객체 작업을 가로채고 커스터마이즈할 수 있습니다.

개요

ES6 Proxy는 JavaScript 언어에 내장된 기능으로, 객체의 기본 동작을 가로채서 사용자 정의 동작을 추가할 수 있게 해주는 메타프로그래밍 도구입니다. Proxy가 강력한 이유는 속성 읽기, 속성 쓰기, 함수 호출, 속성 삭제, in 연산자 등 거의 모든 객체 작업을 가로챌 수 있기 때문입니다.

예를 들어, 존재하지 않는 속성에 접근할 때 에러 대신 기본값을 반환하거나, 속성이 변경될 때마다 자동으로 UI를 업데이트하거나, 모든 함수 호출을 로깅하는 등의 작업을 매우 간결하게 구현할 수 있습니다. 기존의 래퍼 클래스 방식에서는 프록시할 메서드를 일일이 나열하고 각각을 감싸는 코드를 작성해야 했습니다.

하지만 ES6 Proxy를 사용하면 get, set, apply 같은 트랩(trap)을 정의하기만 하면 자동으로 모든 작업이 가로채집니다. ES6 Proxy의 핵심 특징은 다음과 같습니다: (1) 13가지의 트랩을 통해 거의 모든 객체 작업을 제어할 수 있습니다, (2) 원본 객체를 수정하지 않고 프록시만 생성하면 됩니다, (3) 투명성이 완벽하여 클라이언트는 프록시를 사용하는지 모를 수 있습니다.

이러한 특징들은 프록시 패턴을 구현하는 가장 현대적이고 강력한 방법을 제공합니다.

코드 예제

// 일반 객체
const user = {
  name: '홍길동',
  age: 30,
  email: 'hong@example.com'
};

// ES6 Proxy로 검증과 로깅 추가
const userProxy = new Proxy(user, {
  // 속성 읽기를 가로챔
  get(target, property) {
    console.log(`[GET] ${property} 읽기`);
    return target[property];
  },

  // 속성 쓰기를 가로챔
  set(target, property, value) {
    console.log(`[SET] ${property} = ${value}`);

    // 검증 로직 추가
    if (property === 'age' && (typeof value !== 'number' || value < 0)) {
      throw new Error('나이는 0 이상의 숫자여야 합니다');
    }

    if (property === 'email' && !value.includes('@')) {
      throw new Error('유효한 이메일 주소가 아닙니다');
    }

    target[property] = value;
    return true; // 성공 표시
  },

  // 속성 존재 여부 확인을 가로챔
  has(target, property) {
    console.log(`[HAS] '${property}' in user?`);
    return property in target;
  },

  // 속성 삭제를 가로챔
  deleteProperty(target, property) {
    console.log(`[DELETE] ${property} 삭제 시도`);
    if (property === 'name') {
      throw new Error('name 속성은 삭제할 수 없습니다');
    }
    delete target[property];
    return true;
  }
});

// 사용 예시
console.log(userProxy.name); // GET 트랩 실행
userProxy.age = 31; // SET 트랩 실행
// userProxy.age = -5; // 에러 발생
'email' in userProxy; // HAS 트랩 실행

설명

이것이 하는 일: ES6 Proxy는 대상 객체(target)와 트랩 함수들을 담은 핸들러(handler) 객체를 받아서, 대상 객체에 대한 모든 작업을 트랩 함수로 리다이렉트하는 프록시 객체를 생성합니다. 첫 번째로, 일반 객체인 user는 평범한 JavaScript 객체입니다.

name, age, email 속성을 가지고 있으며 특별한 동작은 없습니다. 우리는 이 객체를 직접 수정하지 않고, 프록시를 통해 추가 기능을 부여할 것입니다.

두 번째로, new Proxy(user, handler) 문법으로 프록시를 생성합니다. 첫 번째 인자는 프록시할 대상 객체이고, 두 번째 인자는 트랩 함수들을 담은 핸들러 객체입니다.

핸들러에서 정의한 트랩만 가로채지고, 정의하지 않은 작업은 대상 객체로 그대로 전달됩니다. 세 번째로, get 트랩은 속성을 읽을 때 실행됩니다.

target은 원본 객체(user)이고, property는 접근하려는 속성 이름입니다. 여기서는 단순히 로그를 남기고 원본 값을 반환하지만, 존재하지 않는 속성에 기본값을 반환하거나, 접근 권한을 체크하는 등의 로직을 추가할 수 있습니다.

네 번째로, set 트랩은 속성에 값을 할당할 때 실행됩니다. 이 트랩에서는 검증 로직을 추가하여, age가 음수가 되거나 email에 @가 없으면 에러를 던집니다.

이렇게 하면 객체의 무결성을 보장할 수 있습니다. 검증을 통과하면 target[property] = value로 실제로 값을 할당하고, true를 반환하여 할당이 성공했음을 알립니다.

다섯 번째로, has 트랩은 in 연산자를 사용할 때 실행됩니다. 'email' in userProxy를 실행하면 이 트랩이 호출됩니다.

특정 속성의 존재를 숨기거나, 동적으로 속성 존재 여부를 결정하는 데 사용할 수 있습니다. 여섯 번째로, deleteProperty 트랩은 delete 연산자를 사용할 때 실행됩니다.

여기서는 name 속성의 삭제를 막아서, 필수 속성이 실수로 삭제되는 것을 방지합니다. 이 외에도 apply(함수 호출), construct(new 연산자), getPrototypeOf 등 13가지 트랩이 있어 거의 모든 작업을 제어할 수 있습니다.

여러분이 ES6 Proxy를 활용하면 이전에 배운 모든 프록시 패턴을 더 간결하고 강력하게 구현할 수 있습니다. 검증 프록시, 로깅 프록시, 캐싱 프록시 등을 몇 줄의 코드로 만들 수 있고, 원본 객체의 코드는 전혀 수정하지 않아도 됩니다.

실전 팁

💡 Proxy는 성능 오버헤드가 있습니다. 매우 빈번하게 접근되는 핫 패스(hot path)에서는 사용을 피하거나, 성능을 측정해보고 사용하세요. 일반적인 경우에는 문제없지만 루프 내부에서 수백만 번 호출되면 체감 가능한 차이가 있을 수 있습니다.

💡 Revocable Proxy(Proxy.revocable())를 사용하면 나중에 프록시를 무효화할 수 있습니다. 보안이 중요한 상황에서 특정 시점 이후 객체 접근을 완전히 차단하고 싶을 때 유용합니다.

💡 Proxy의 트랩은 불변식(invariant)을 지켜야 합니다. 예를 들어, 대상 객체의 non-configurable 속성은 다른 값을 반환할 수 없습니다. 이 규칙을 어기면 TypeError가 발생합니다.

💡 Reflect API와 함께 사용하면 더 깔끔합니다. 트랩 내부에서 Reflect.get(target, property)처럼 사용하면 기본 동작을 명시적으로 수행할 수 있어 코드 의도가 명확해집니다.

💡 프록시 체인을 만들 수 있습니다. 프록시를 다시 프록시로 감싸면 여러 관심사(로깅, 검증, 캐싱 등)를 독립적으로 추가할 수 있습니다. 각 프록시는 하나의 책임만 가지도록 설계하세요.


9. 프록시 패턴 성능 최적화 - 효율적인 프록시 구현

시작하며

여러분이 프록시 패턴을 프로젝트에 도입했더니 코드는 깔끔해졌지만, 성능이 눈에 띄게 느려졌다면 어떻게 해야 할까요? 프록시는 편리하지만 잘못 사용하면 오히려 성능 병목이 될 수 있습니다.

프록시는 원본 객체와 클라이언트 사이에 추가적인 레이어를 만들기 때문에, 불가피하게 오버헤드가 발생합니다. 특히 매우 빈번하게 호출되는 메서드에 프록시를 적용하면, 작은 오버헤드가 누적되어 전체 시스템 성능에 영향을 줄 수 있습니다.

또한 프록시 체인이 길어지거나, 프록시 내부에서 무거운 작업을 수행하면 문제가 더 심각해집니다. 바로 이럴 때 필요한 것이 프록시 성능 최적화 기법입니다.

프록시의 이점은 유지하면서도, 성능 저하를 최소화하는 다양한 전략을 배워봅시다.

개요

프록시 패턴의 성능 최적화는 프록시 생성 비용 줄이기, 불필요한 프록시 호출 제거하기, 프록시 내부 로직 최적화하기 등을 통해 오버헤드를 최소화하는 일련의 기법들입니다. 성능 최적화가 중요한 이유는 프록시 패턴이 아무리 좋아도 실제 운영 환경에서 사용자 경험을 해치면 안 되기 때문입니다.

프로파일링 도구를 사용하여 프록시로 인한 성능 저하를 측정하고, 핫스팟(자주 실행되는 코드 경로)을 찾아 집중적으로 최적화해야 합니다. 전통적으로 프록시를 사용하지 않았던 개발자들이 프록시를 도입할 때 가장 우려하는 부분이 바로 성능입니다.

하지만 올바른 최적화 기법을 적용하면 프록시의 오버헤드를 거의 무시할 수 있는 수준으로 만들 수 있습니다. 프록시 성능 최적화의 핵심 특징은 다음과 같습니다: (1) 선택적 프록시 - 꼭 필요한 곳에만 프록시를 적용합니다, (2) 지연 생성 - 프록시 객체를 미리 만들지 않고 필요할 때만 만듭니다, (3) 경량 프록시 - 프록시 내부 로직을 최대한 가볍게 유지합니다.

이러한 기법들을 적용하면 프록시 패턴의 장점을 누리면서도 성능을 희생하지 않을 수 있습니다.

코드 예제

// 성능 측정 유틸리티
class PerformanceMonitor {
  static measure(name, fn) {
    const start = performance.now();
    const result = fn();
    const end = performance.now();
    console.log(`[${name}] 실행 시간: ${(end - start).toFixed(3)}ms`);
    return result;
  }
}

// 원본 객체
class DataProcessor {
  processSmall(data) {
    return data.map(x => x * 2); // 빠른 작업
  }

  processHeavy(data) {
    // 무거운 작업 시뮬레이션
    let result = data;
    for (let i = 0; i < 1000; i++) {
      result = result.map(x => Math.sqrt(x + i));
    }
    return result;
  }
}

// 비효율적인 프록시 - 모든 호출에 오버헤드
class InefficientProxy {
  constructor(target) {
    this.target = target;
    this.callLog = [];
  }

  processSmall(data) {
    // 빠른 메서드에도 무거운 로깅
    this.callLog.push({
      method: 'processSmall',
      timestamp: new Date(),
      stack: new Error().stack // 비용이 큰 작업
    });
    return this.target.processSmall(data);
  }

  processHeavy(data) {
    this.callLog.push({
      method: 'processHeavy',
      timestamp: new Date(),
      stack: new Error().stack
    });
    return this.target.processHeavy(data);
  }
}

// 최적화된 프록시 - 선택적으로 로깅
class OptimizedProxy {
  constructor(target, logLevel = 'ERROR') {
    this.target = target;
    this.logLevel = logLevel;
    this.errorCount = 0; // 간단한 카운터만 유지
  }

  processSmall(data) {
    // 빠른 메서드는 로깅 최소화
    try {
      return this.target.processSmall(data);
    } catch (error) {
      this.errorCount++;
      if (this.logLevel === 'ERROR') {
        console.error('processSmall 에러:', error.message);
      }
      throw error;
    }
  }

  processHeavy(data) {
    // 무거운 메서드만 상세 로깅
    const start = this.logLevel === 'DEBUG' ? performance.now() : null;
    try {
      const result = this.target.processHeavy(data);
      if (start) {
        console.log(`processHeavy: ${(performance.now() - start).toFixed(2)}ms`);
      }
      return result;
    } catch (error) {
      this.errorCount++;
      console.error('processHeavy 에러:', error);
      throw error;
    }
  }
}

// 성능 비교
const processor = new DataProcessor();
const data = Array.from({ length: 100 }, (_, i) => i);

const inefficient = new InefficientProxy(processor);
const optimized = new OptimizedProxy(processor, 'ERROR');

PerformanceMonitor.measure('비효율적 프록시', () => {
  for (let i = 0; i < 10000; i++) inefficient.processSmall(data);
});

PerformanceMonitor.measure('최적화 프록시', () => {
  for (let i = 0; i < 10000; i++) optimized.processSmall(data);
});

설명

이것이 하는 일: 프록시 성능 최적화는 프록시가 꼭 필요한 곳과 그렇지 않은 곳을 구분하고, 빈번하게 호출되는 경로에서는 오버헤드를 최소화하며, 무거운 작업이 필요한 곳에만 상세한 처리를 적용합니다. 첫 번째로, PerformanceMonitor는 성능을 측정하는 유틸리티입니다.

최적화를 논의하기 전에 먼저 측정해야 합니다. "측정할 수 없으면 개선할 수 없다"는 격언처럼, 성능 문제가 실제로 어디서 발생하는지 데이터로 파악하는 것이 첫 단계입니다.

두 번째로, DataProcessor는 두 가지 메서드를 가지고 있습니다. processSmall()은 매우 빠르게 실행되는 간단한 작업이고, processHeavy()는 의도적으로 느리게 만든 무거운 작업입니다.

실제 시스템에서도 이렇게 실행 시간이 천차만별인 메서드들이 섞여 있습니다. 세 번째로, InefficientProxy는 나쁜 예시입니다.

모든 메서드 호출마다 복잡한 객체를 callLog 배열에 추가하고, new Error().stack으로 스택 트레이스를 수집합니다. 스택 트레이스 수집은 매우 비용이 큰 작업으로, 밀리초 단위의 오버헤드를 발생시킵니다.

processSmall()은 원래 마이크로초 단위로 실행되는데, 프록시 오버헤드가 실제 작업보다 더 클 수 있습니다. 네 번째로, OptimizedProxy는 개선된 버전입니다.

logLevel을 도입하여 프로덕션에서는 ERROR, 개발 환경에서는 DEBUG로 설정할 수 있습니다. processSmall()에서는 에러가 발생했을 때만 카운터를 증가시키고, 상세 로깅은 하지 않습니다.

이렇게 하면 정상 실행 경로는 거의 오버헤드 없이 빠르게 통과합니다. 다섯 번째로, processHeavy()는 원래 작업 자체가 무겁기 때문에, 프록시 오버헤드가 상대적으로 작습니다.

따라서 여기서는 DEBUG 모드일 때 실행 시간을 측정하는 등 더 상세한 로깅을 해도 괜찮습니다. 이처럼 메서드의 특성에 따라 프록시 로직을 차등 적용하는 것이 핵심입니다.

마지막으로, 성능 비교를 보면 processSmall()을 10,000번 호출했을 때 두 프록시의 실행 시간 차이가 명확히 드러납니다. 비효율적인 프록시는 수백 밀리초가 걸릴 수 있지만, 최적화된 프록시는 거의 원본과 비슷한 속도로 실행됩니다.

여러분이 이런 최적화 기법을 적용하면 프록시 패턴을 실무에서 자신 있게 사용할 수 있습니다. 프로파일링으로 병목을 찾고, 핫 패스를 최적화하며, 꼭 필요한 곳에만 비용을 지불하는 전략을 따르면 성능과 깔끔한 아키텍처를 모두 얻을 수 있습니다.

실전 팁

💡 프로파일링을 먼저 하세요. Chrome DevTools의 Performance 탭이나 Node.js의 --prof 플래그를 사용하여 실제로 병목이 어디인지 확인한 후 최적화하세요. 추측으로 최적화하면 잘못된 곳에 시간을 낭비할 수 있습니다.

💡 개발 환경과 프로덕션 환경에서 다른 프록시를 사용하는 것을 고려하세요. 개발에서는 상세한 로깅과 검증을 하고, 프로덕션에서는 최소한의 오버헤드만 가지는 경량 프록시를 사용할 수 있습니다.

💡 프록시 내부에서 동기식 작업만 하세요. 프록시 안에서 비동기 작업을 하면 예상치 못한 타이밍 이슈가 발생할 수 있습니다. 비동기 작업이 필요하다면 명시적으로 async 메서드를 만드세요.

💡 메모이제이션(memoization)을 활용하세요. 같은 인자로 여러 번 호출되는 순수 함수라면, 결과를 캐싱하여 두 번째 호출부터는 즉시 반환할 수 있습니다. 이는 특히 프록시와 잘 어울립니다.

💡 프록시가 정말 필요한지 다시 생각해보세요. 때로는 프록시 대신 데코레이터 패턴, 옵저버 패턴, 또는 AOP(Aspect-Oriented Programming) 라이브러리가 더 적합할 수 있습니다. 문제에 맞는 도구를 선택하세요.


#JavaScript#ProxyPattern#DesignPatterns#ObjectOrientedProgramming#SoftwareArchitecture

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.