🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

Chrome DevTools 디자인 패턴 마스터 - 슬라이드 1/11
A

AI Generated

2025. 11. 2. · 15 Views

Chrome DevTools 디자인 패턴 마스터 가이드

Chrome DevTools의 강력한 기능들을 디자인 패턴 관점에서 접근하여, 실무에서 바로 활용 가능한 디버깅과 최적화 패턴을 익힐 수 있습니다. 초급 개발자도 쉽게 따라할 수 있는 실전 예제와 팁을 제공합니다.


목차

  1. Observer Pattern으로 DOM 변경 감지하기
  2. Singleton Pattern으로 Console 로깅 관리
  3. Strategy Pattern으로 성능 측정 전략 구현
  4. Decorator Pattern으로 함수 실행 추적
  5. Factory Pattern으로 에러 핸들러 구축
  6. Proxy Pattern으로 객체 접근 감시
  7. Command Pattern으로 브레이크포인트 관리

1. Observer Pattern으로 DOM 변경 감지하기

시작하며

여러분이 웹 애플리케이션을 개발하다가 "어? 이 요소가 언제 바뀌는 거지?"라고 의아해하신 적 있나요?

특히 복잡한 SPA에서 여러 컴포넌트가 동시에 DOM을 조작할 때, 특정 요소의 변경 시점을 파악하기가 정말 어렵습니다. 이런 문제는 실제 개발 현장에서 자주 발생합니다.

다른 팀원이 작성한 코드나 써드파티 라이브러리가 DOM을 예기치 않게 변경하면 버그를 찾기가 매우 힘들죠. console.log를 여기저기 찍어봐도 비동기 타이밍 때문에 정확한 시점을 잡기 어렵습니다.

바로 이럴 때 필요한 것이 MutationObserver와 Chrome DevTools의 조합입니다. Observer 패턴을 활용하면 DOM 변경을 실시간으로 감시하고, 정확히 어떤 변경이 발생했는지 추적할 수 있습니다.

개요

간단히 말해서, MutationObserver는 DOM 트리의 변경사항을 비동기적으로 관찰하는 Web API입니다. Observer 패턴의 전형적인 구현체로, 관찰 대상(Subject)과 관찰자(Observer)를 분리하여 느슨한 결합을 만들어냅니다.

왜 이 패턴이 필요한가요? 실무에서는 동적으로 생성되는 요소에 이벤트를 바인딩하거나, 특정 요소의 변경을 감지해 다른 작업을 트리거해야 하는 경우가 많습니다.

예를 들어, 무한 스크롤 구현 시 새로 추가되는 아이템을 감지하거나, 광고 스크립트가 DOM을 조작하는 시점을 파악해야 할 때 매우 유용합니다. 기존에는 setInterval로 주기적으로 DOM을 검사하거나 DOMSubtreeModified 같은 deprecated 이벤트를 사용했다면, 이제는 MutationObserver로 효율적이고 정확하게 변경을 감지할 수 있습니다.

이 패턴의 핵심 특징은 첫째, 비동기 처리로 성능 영향을 최소화하고, 둘째, 세밀한 필터링으로 원하는 변경만 감지하며, 셋째, 배치 처리로 여러 변경을 한 번에 받을 수 있다는 점입니다. 이러한 특징들이 실시간 디버깅과 성능 최적화에 모두 도움이 됩니다.

코드 예제

// DevTools Console에서 실행할 수 있는 DOM 변경 감지 옵저버
const targetNode = document.querySelector('#app');

// 옵저버 설정: 어떤 변경을 감시할지 정의
const config = {
  attributes: true,        // 속성 변경 감시
  childList: true,         // 자식 노드 추가/제거 감시
  subtree: true,           // 모든 하위 노드까지 감시
  characterData: true,     // 텍스트 변경 감시
  attributeOldValue: true  // 이전 속성 값 기록
};

// 콜백 함수: 변경이 감지되면 실행됨
const callback = (mutationsList, observer) => {
  for(const mutation of mutationsList) {
    console.group(`🔍 ${mutation.type} 변경 감지`);
    console.log('변경된 노드:', mutation.target);
    console.log('추가된 노드:', mutation.addedNodes);
    console.log('제거된 노드:', mutation.removedNodes);
    if(mutation.type === 'attributes') {
      console.log(`속성 "${mutation.attributeName}" 변경`);
      console.log('이전 값:', mutation.oldValue);
    }
    console.groupEnd();

    // 스택 트레이스로 어디서 호출했는지 확인
    console.trace('호출 스택');
  }
};

// 옵저버 생성 및 시작
const observer = new MutationObserver(callback);
observer.observe(targetNode, config);

// 중단하려면: observer.disconnect();

설명

이것이 하는 일: 이 코드는 지정한 DOM 요소와 그 하위 트리의 모든 변경사항을 실시간으로 감시하고, 변경이 발생할 때마다 상세한 정보를 콘솔에 출력합니다. 첫 번째로, config 객체는 어떤 종류의 변경을 감시할지 정의합니다.

attributes는 class나 style 같은 속성 변경을, childList는 요소의 추가/제거를, subtree는 모든 하위 노드까지 감시 범위를 확장합니다. 이렇게 세밀하게 설정할 수 있어서 필요한 변경만 감지하고 성능 부담을 줄일 수 있습니다.

그 다음으로, callback 함수가 실행되면서 mutationsList 배열을 순회합니다. 각 mutation 객체는 변경의 타입, 영향받은 노드, 이전 값 등 풍부한 정보를 담고 있습니다.

console.group으로 그룹화하여 가독성을 높이고, console.trace로 호출 스택까지 출력하여 어떤 코드가 변경을 일으켰는지 정확히 파악할 수 있습니다. 마지막으로, observer.observe()로 감시를 시작하면 이후 모든 변경이 자동으로 감지됩니다.

비동기로 동작하기 때문에 메인 스레드를 블로킹하지 않으며, 여러 변경이 동시에 일어나면 배치로 묶어서 한 번에 처리하여 효율적입니다. 여러분이 이 코드를 사용하면 써드파티 스크립트의 DOM 조작을 추적하거나, 예상치 못한 레이아웃 시프트의 원인을 찾거나, 동적 컴포넌트의 렌더링 시점을 정확히 파악할 수 있습니다.

특히 React나 Vue 같은 프레임워크의 렌더링 패턴을 이해하는 데도 큰 도움이 됩니다. DevTools의 Elements 패널에서 "Break on" 기능과 함께 사용하면 더욱 강력합니다.

특정 요소에서 "Break on subtree modifications"를 설정하고 이 옵저버를 함께 실행하면, 변경 시점에 디버거가 자동으로 멈추고 상세 로그를 확인할 수 있습니다.

실전 팁

💡 감시 범위는 가능한 좁게 설정하세요. document.body 전체를 감시하면 성능에 부담이 되므로, 문제가 발생하는 특정 컴포넌트만 타겟팅하는 것이 좋습니다.

💡 attributeFilter 옵션으로 특정 속성만 감시할 수 있습니다. 예를 들어 class 변경만 관심 있다면 attributeFilter: ['class']로 설정하여 불필요한 노이즈를 줄이세요.

💡 프로덕션 환경에서는 반드시 observer.disconnect()로 감시를 중단하세요. 메모리 누수의 원인이 될 수 있습니다. 개발 중에만 사용하거나 조건부로 활성화하는 것을 권장합니다.

💡 mutation.target과 mutation.addedNodes를 활용하면 동적으로 생성된 요소에 자동으로 이벤트 리스너를 추가하는 패턴을 구현할 수 있습니다. 이벤트 위임의 대안으로 유용합니다.

💡 Chrome DevTools의 Performance 탭과 함께 사용하면 DOM 변경이 리플로우/리페인트에 미치는 영향을 시각적으로 확인할 수 있습니다. 성능 병목을 찾는 데 매우 효과적입니다.


2. Singleton Pattern으로 Console 로깅 관리

시작하며

여러분의 프로젝트 코드를 보면 console.log가 여기저기 흩어져 있지 않나요? 개발 중에는 유용하지만, 프로덕션에 그대로 배포되면 보안 위험이 있고 성능에도 영향을 줍니다.

게다가 로그 레벨을 조절하거나 특정 모듈의 로그만 켜고 싶을 때 일일이 주석 처리하기도 번거롭죠. 이런 문제는 팀 프로젝트에서 특히 심각합니다.

각자 다른 스타일로 로그를 남기면 일관성이 없고, 중요한 로그가 수많은 디버그 메시지에 묻혀버립니다. 나중에 버그를 추적하려고 해도 어디서부터 봐야 할지 막막합니다.

바로 이럴 때 필요한 것이 Singleton 패턴을 활용한 중앙화된 로거입니다. 애플리케이션 전체에서 단 하나의 로거 인스턴스만 사용하고, 환경과 레벨에 따라 똑똑하게 로그를 관리할 수 있습니다.

개요

간단히 말해서, Singleton 패턴은 클래스의 인스턴스가 오직 하나만 존재하도록 보장하고, 전역 접근점을 제공하는 디자인 패턴입니다. 로거처럼 애플리케이션 전역에서 일관되게 사용해야 하는 객체에 이상적입니다.

왜 이 패턴이 필요한가요? 실무에서는 개발/스테이징/프로덕션 환경마다 다른 로그 레벨을 적용해야 하고, 로그를 원격 서버로 전송하거나 로컬 스토리지에 저장하는 등의 부가 기능이 필요합니다.

예를 들어, 프로덕션에서는 error만 Sentry로 전송하고, 개발 환경에서는 모든 로그를 콘솔에 출력하는 식이죠. 이런 복잡한 로직을 한 곳에서 관리하면 유지보수가 훨씬 쉬워집니다.

기존에는 전역 함수나 네임스페이스를 사용했다면, 이제는 ES6 클래스와 모듈 시스템으로 더 견고한 Singleton을 구현할 수 있습니다. private 생성자는 없지만 모듈 캐싱을 활용하면 자연스럽게 싱글톤이 됩니다.

이 패턴의 핵심 특징은 첫째, 전역 상태를 안전하게 관리하고, 둘째, 지연 초기화로 필요할 때만 인스턴스를 생성하며, 셋째, 환경 설정을 중앙에서 제어할 수 있다는 점입니다. 이러한 특징들이 대규모 애플리케이션의 로깅 전략에 필수적입니다.

코드 예제

// logger.js - Singleton 로거 모듈
class Logger {
  static instance = null;

  constructor() {
    if (Logger.instance) {
      return Logger.instance;
    }

    this.logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'debug';
    this.levels = { debug: 0, info: 1, warn: 2, error: 3 };
    this.history = [];

    Logger.instance = this;
  }

  setLevel(level) {
    this.logLevel = level;
    return this; // 메서드 체이닝 지원
  }

  _shouldLog(level) {
    return this.levels[level] >= this.levels[this.logLevel];
  }

  _log(level, ...args) {
    if (!this._shouldLog(level)) return;

    const timestamp = new Date().toISOString();
    const logEntry = { timestamp, level, message: args };

    this.history.push(logEntry);

    // 레벨별 스타일링과 DevTools 그룹화
    const styles = {
      debug: 'color: #888',
      info: 'color: #0066cc',
      warn: 'color: #ff9900; font-weight: bold',
      error: 'color: #cc0000; font-weight: bold'
    };

    console[level](`%c[${level.toUpperCase()}] ${timestamp}`, styles[level], ...args);
  }

  debug(...args) { this._log('debug', ...args); }
  info(...args) { this._log('info', ...args); }
  warn(...args) { this._log('warn', ...args); }
  error(...args) { this._log('error', ...args); }

  getHistory() { return this.history; }
  clearHistory() { this.history = []; }
}

// 모듈 export로 싱글톤 보장
export default new Logger();

설명

이것이 하는 일: 이 로거는 애플리케이션에서 단 하나의 인스턴스만 생성되도록 보장하고, 환경에 따라 자동으로 로그 레벨을 조절하며, 모든 로그를 히스토리에 저장하여 나중에 분석할 수 있게 합니다. 첫 번째로, 생성자에서 Logger.instance를 체크하여 이미 인스턴스가 존재하면 그것을 반환합니다.

이것이 Singleton의 핵심 로직입니다. 처음 생성될 때만 초기화 로직이 실행되고, logLevel을 환경 변수에 따라 자동으로 설정합니다.

process.env.NODE_ENV를 체크해서 프로덕션에서는 error만, 개발에서는 모든 레벨을 출력하도록 똑똑하게 동작합니다. 그 다음으로, _shouldLog 메서드가 현재 로그 레벨을 기준으로 출력 여부를 판단합니다.

levels 객체에 각 레벨의 우선순위를 숫자로 정의했기 때문에 간단한 비교로 필터링할 수 있습니다. 예를 들어 logLevel이 'warn'이면 debug와 info는 무시되고 warn과 error만 출력됩니다.

_log 메서드는 실제 로깅을 담당하는 private 메서드입니다. 타임스탬프와 함께 로그 엔트리를 생성하고 history 배열에 저장합니다.

그리고 console[level]로 동적으로 적절한 콘솔 메서드를 호출하며, CSS 스타일을 적용해 각 레벨을 시각적으로 구분하기 쉽게 만듭니다. 마지막으로, 모듈의 default export로 이미 생성된 인스턴스를 내보냅니다.

ES6 모듈은 캐싱되기 때문에 어디서 import하든 항상 같은 인스턴스를 받게 됩니다. 이것이 JavaScript에서 Singleton을 구현하는 가장 깔끔한 방법입니다.

여러분이 이 로거를 사용하면 코드베이스 전체에서 일관된 로깅 스타일을 유지하고, 환경 전환 시 로그 레벨을 자동으로 조절하며, 필요시 히스토리를 분석해 버그를 추적할 수 있습니다. 특히 Chrome DevTools에서 색상으로 구분된 로그를 보면 중요한 경고와 에러를 놓치지 않게 됩니다.

실무에서는 이 기본 구조에 원격 로깅(Sentry, LogRocket 등), 로그 샘플링, 민감 정보 마스킹 등의 기능을 추가할 수 있습니다. Singleton이기 때문에 이런 설정을 한 번만 하면 전체 애플리케이션에 적용됩니다.

실전 팁

💡 setLevel 메서드로 런타임에 로그 레벨을 변경할 수 있습니다. Chrome DevTools 콘솔에서 logger.setLevel('debug')를 실행하면 프로덕션에서도 임시로 디버그 로그를 볼 수 있어 매우 유용합니다.

💡 history 배열에 크기 제한을 두세요. 무한정 쌓이면 메모리 누수가 발생합니다. 예를 들어 최근 1000개만 유지하도록 if (this.history.length > 1000) this.history.shift()를 추가하세요.

💡 console.group/groupEnd를 활용하면 연관된 로그를 그룹화할 수 있습니다. 로거에 group 메서드를 추가해서 복잡한 작업의 시작과 끝을 명확히 표시하세요.

💡 에러 로그에는 스택 트레이스를 자동으로 포함시키세요. if (level === 'error') console.trace()를 추가하면 에러 발생 지점을 쉽게 찾을 수 있습니다.

💡 TypeScript를 사용한다면 로그 메시지에 타입을 적용해 컴파일 타임에 실수를 잡을 수 있습니다. 특히 구조화된 로그 객체를 전달할 때 유용합니다.


3. Strategy Pattern으로 성능 측정 전략 구현

시작하며

여러분이 "이 함수가 느린 것 같은데..."라고 느낀 적 있나요? 막연한 느낌이 아니라 정확한 측정이 필요합니다.

하지만 Date.now()를 이용한 간단한 측정부터 Performance API의 정밀한 측정, 심지어 User Timing API를 활용한 브라우저 프로파일링까지 방법이 너무 많아서 어떤 걸 써야 할지 헷갈립니다. 이런 문제는 측정 목적과 환경에 따라 적절한 방법이 다르기 때문에 발생합니다.

단순한 A/B 비교에는 Date.now()면 충분하지만, 마이크로초 단위의 정밀도가 필요하거나 DevTools Performance 탭과 연동하려면 더 고급 API가 필요합니다. 코드베이스 전체에서 측정 방법이 제각각이면 나중에 변경하기도 어렵습니다.

바로 이럴 때 필요한 것이 Strategy 패턴입니다. 다양한 측정 전략을 인터페이스로 통일하고, 런타임에 상황에 맞는 전략을 선택할 수 있게 하면 유연하고 확장 가능한 성능 측정 시스템을 만들 수 있습니다.

개요

간단히 말해서, Strategy 패턴은 동일한 목적을 가진 알고리즘들을 캡슐화하고, 런타임에 교체 가능하게 만드는 패턴입니다. 성능 측정이라는 공통 목적 아래 여러 구현 방법을 유연하게 전환할 수 있습니다.

왜 이 패턴이 필요한가요? 실무에서는 개발 환경과 프로덕션 환경에서 다른 측정 전략을 사용하거나, 브라우저 지원 여부에 따라 fallback 전략을 적용해야 합니다.

예를 들어, Performance API가 없는 구형 브라우저에서는 자동으로 Date.now()로 전환하고, Node.js 환경에서는 process.hrtime()을 사용하는 식이죠. 이렇게 환경에 따라 적응하는 코드를 깔끔하게 작성할 수 있습니다.

기존에는 if-else나 switch로 분기 처리했다면, 이제는 Strategy 객체를 교체하는 방식으로 깔끔하게 구현할 수 있습니다. 새로운 측정 방법이 추가되어도 기존 코드를 수정할 필요 없이 새 Strategy만 추가하면 됩니다.

이 패턴의 핵심 특징은 첫째, 알고리즘을 독립적으로 변경할 수 있고, 둘째, 조건문 대신 객체 위임을 사용하며, 셋째, Open/Closed 원칙을 따라 확장에는 열려있고 수정에는 닫혀있다는 점입니다. 이러한 특징들이 유지보수성과 테스트 용이성을 크게 향상시킵니다.

코드 예제

// 성능 측정 전략 인터페이스
class PerformanceStrategy {
  start(label) { throw new Error('구현 필요'); }
  end(label) { throw new Error('구현 필요'); }
  measure(label) { throw new Error('구현 필요'); }
}

// 전략 1: Performance API 사용 (고정밀도, DevTools 연동)
class PerformanceAPIStrategy extends PerformanceStrategy {
  start(label) {
    performance.mark(`${label}-start`);
  }

  end(label) {
    performance.mark(`${label}-end`);
    performance.measure(label, `${label}-start`, `${label}-end`);
  }

  measure(label) {
    const entries = performance.getEntriesByName(label);
    return entries.length ? entries[0].duration : 0;
  }
}

// 전략 2: Date.now() 사용 (범용, 모든 환경 지원)
class DateNowStrategy extends PerformanceStrategy {
  constructor() {
    super();
    this.timers = new Map();
  }

  start(label) {
    this.timers.set(label, Date.now());
  }

  end(label) {
    if (!this.timers.has(label)) {
      console.warn(`타이머 "${label}"가 시작되지 않았습니다`);
      return;
    }
  }

  measure(label) {
    const startTime = this.timers.get(label);
    return startTime ? Date.now() - startTime : 0;
  }
}

// Context: 전략을 사용하는 클래스
class PerformanceMonitor {
  constructor(strategy) {
    // 기본 전략: Performance API가 있으면 사용, 없으면 fallback
    this.strategy = strategy || (
      typeof performance !== 'undefined' && performance.mark
        ? new PerformanceAPIStrategy()
        : new DateNowStrategy()
    );
  }

  setStrategy(strategy) {
    this.strategy = strategy;
  }

  async measureAsync(label, asyncFunc) {
    this.strategy.start(label);
    try {
      const result = await asyncFunc();
      this.strategy.end(label);
      const duration = this.strategy.measure(label);
      console.log(`⏱️ ${label}: ${duration.toFixed(2)}ms`);
      return result;
    } catch (error) {
      this.strategy.end(label);
      throw error;
    }
  }

  measureSync(label, syncFunc) {
    this.strategy.start(label);
    const result = syncFunc();
    this.strategy.end(label);
    const duration = this.strategy.measure(label);
    console.log(`⏱️ ${label}: ${duration.toFixed(2)}ms`);
    return result;
  }
}

// 사용 예시
const monitor = new PerformanceMonitor();

// 비동기 함수 측정
await monitor.measureAsync('API 호출', async () => {
  const response = await fetch('/api/data');
  return response.json();
});

설명

이것이 하는 일: 이 코드는 성능 측정이라는 공통 작업을 여러 가지 방법으로 수행할 수 있게 하고, 실행 환경에 따라 자동으로 최적의 방법을 선택하며, 필요시 런타임에 전략을 교체할 수 있게 합니다. 첫 번째로, PerformanceStrategy 기본 클래스는 모든 전략이 구현해야 할 인터페이스를 정의합니다.

start, end, measure라는 세 가지 메서드로 측정의 수명주기를 표현하고, 각 구현체가 자신만의 방식으로 이를 수행하게 합니다. 이렇게 인터페이스를 통일하면 Context 클래스는 어떤 전략이 사용되는지 신경 쓰지 않아도 됩니다.

그 다음으로, PerformanceAPIStrategy는 브라우저의 Performance API를 활용합니다. performance.mark()로 시작과 끝 지점을 표시하고, performance.measure()로 두 지점 사이의 시간을 측정합니다.

이 방법의 장점은 마이크로초 단위의 고정밀도와 함께, Chrome DevTools의 Performance 탭에 자동으로 표시된다는 것입니다. 프로파일링할 때 코드의 어느 부분이 느린지 시각적으로 확인할 수 있어 매우 강력합니다.

DateNowStrategy는 fallback 전략으로, Performance API가 없는 환경에서 사용됩니다. Map을 이용해 레이블별로 시작 시간을 저장하고, measure 호출 시 현재 시간과의 차이를 계산합니다.

정밀도는 낮지만 모든 JavaScript 환경에서 동작하므로 호환성이 뛰어납니다. PerformanceMonitor는 Context 역할로, 선택된 전략을 사용해 실제 측정을 수행합니다.

생성자에서 환경을 감지해 자동으로 적절한 전략을 선택하고, measureAsync와 measureSync 메서드로 동기/비동기 함수를 편리하게 측정할 수 있게 합니다. try-catch로 에러가 발생해도 타이머를 정리하여 메모리 누수를 방지합니다.

여러분이 이 패턴을 사용하면 코드 한 줄 수정 없이 측정 방법을 교체할 수 있고, 새로운 전략(예: console.time API)을 쉽게 추가할 수 있으며, 단위 테스트에서 Mock Strategy를 주입해 테스트하기 쉬워집니다. 특히 Chrome DevTools Performance 탭에서 User Timings 섹션을 보면 여러분의 커스텀 측정이 타임라인에 표시되어 렌더링, 네트워크 요청과 함께 분석할 수 있습니다.

실무에서는 이 기본 구조에 임계값 경고(예: 100ms 이상이면 warn), 통계 집계(평균, 최대, 최소), 원격 전송(APM 도구로) 등의 기능을 추가할 수 있습니다. Strategy 패턴 덕분에 이런 기능도 각각 독립적인 전략으로 구현할 수 있습니다.

실전 팁

💡 Performance API의 mark와 measure는 Navigation Timing, Resource Timing과 함께 사용할 수 있습니다. performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart로 페이지 로드 시간과 여러분의 측정을 비교하세요.

💡 Chrome DevTools에서 Ctrl+Shift+P → "Show Performance monitor"로 실시간 FPS, CPU 사용률을 보면서 측정하면 성능 영향을 직접 확인할 수 있습니다.

💡 측정 오버헤드를 최소화하려면 프로덕션에서는 샘플링 전략을 사용하세요. 예를 들어 10% 사용자에게만 측정을 활성화하거나, 특정 기능만 선택적으로 측정하는 FilteredStrategy를 구현하세요.

💡 Long Task API와 결합하면 50ms 이상 메인 스레드를 블로킹하는 작업을 자동으로 감지할 수 있습니다. new PerformanceObserver(list => {...}).observe({entryTypes: ['longtask']})를 추가하세요.

💡 React DevTools Profiler와 함께 사용하면 컴포넌트 렌더링 시간과 커스텀 측정을 함께 분석할 수 있습니다. 어느 부분이 리렌더링을 유발하는지 명확해집니다.


4. Decorator Pattern으로 함수 실행 추적

시작하며

여러분이 버그를 찾다가 "이 함수가 몇 번이나 호출되는 거야?"라고 궁금해하신 적 있나요? 특히 이벤트 핸들러나 debounce된 함수는 실행 횟수와 타이밍을 파악하기 어렵습니다.

console.log를 함수 안에 넣으면 원본 코드가 지저분해지고, 나중에 일일이 제거하기도 번거롭죠. 이런 문제는 프로덕션 버그를 디버깅할 때 특히 심각합니다.

로컬에서는 재현이 안 되는데 사용자 환경에서만 발생하는 버그라면, 함수 호출 패턴을 로깅해야 합니다. 하지만 원본 코드에 직접 로깅 로직을 추가하면 비즈니스 로직과 디버깅 로직이 뒤섞여 유지보수가 어려워집니다.

바로 이럴 때 필요한 것이 Decorator 패턴입니다. 원본 함수를 수정하지 않고 감싸서(wrapping) 추가 기능을 동적으로 붙일 수 있습니다.

실행 추적, 성능 측정, 에러 핸들링 등을 원본 코드와 완전히 분리할 수 있습니다.

개요

간단히 말해서, Decorator 패턴은 객체나 함수에 새로운 기능을 동적으로 추가하는 패턴입니다. JavaScript의 고차 함수(Higher-Order Function) 특성을 활용하면 매우 자연스럽게 구현할 수 있습니다.

왜 이 패턴이 필요한가요? 실무에서는 크로스커팅 관심사(logging, caching, validation 등)를 비즈니스 로직과 분리해야 합니다.

예를 들어, API 호출 함수에 재시도 로직, 캐싱, 로깅, 성능 측정을 모두 추가하고 싶다면 각각을 데코레이터로 만들어 조합할 수 있습니다. 순서를 바꾸거나 특정 데코레이터만 제거하기도 쉽죠.

기존에는 상속을 사용했다면, 이제는 컴포지션(조합)을 사용합니다. 상속은 정적이고 깊은 계층 구조를 만들지만, 데코레이터는 동적이고 유연하게 기능을 추가/제거할 수 있습니다.

ES7 데코레이터 문법(@decorator)도 있지만, 함수 래핑만으로도 충분히 강력합니다. 이 패턴의 핵심 특징은 첫째, 원본 코드를 수정하지 않고 기능을 추가하고(Open/Closed 원칙), 둘째, 여러 데코레이터를 조합할 수 있으며(Composable), 셋째, 런타임에 동적으로 적용/제거할 수 있다는 점입니다.

이러한 특징들이 디버깅과 테스트를 훨씬 쉽게 만들어줍니다.

코드 예제

// 함수 실행 추적 데코레이터
function traced(fn, options = {}) {
  const {
    logArgs = true,      // 인자 로깅 여부
    logResult = true,    // 반환값 로깅 여부
    logTime = true,      // 실행 시간 로깅 여부
    groupInConsole = true // 콘솔 그룹화 여부
  } = options;

  let callCount = 0;

  return function(...args) {
    callCount++;
    const callId = `${fn.name || 'anonymous'}#${callCount}`;

    if (groupInConsole) {
      console.group(`🔍 ${callId}`);
    } else {
      console.log(`🔍 ${callId} 시작`);
    }

    // 인자 로깅
    if (logArgs) {
      console.log('📥 인자:', args);
    }

    // 호출 스택 추적
    console.trace('호출 위치');

    // 실행 시간 측정
    const startTime = performance.now();

    try {
      // 원본 함수 실행 (this 바인딩 유지)
      const result = fn.apply(this, args);

      // Promise 처리
      if (result instanceof Promise) {
        return result.then(
          value => {
            const duration = performance.now() - startTime;
            if (logResult) console.log('✅ 결과:', value);
            if (logTime) console.log(`⏱️ 실행 시간: ${duration.toFixed(2)}ms`);
            if (groupInConsole) console.groupEnd();
            return value;
          },
          error => {
            const duration = performance.now() - startTime;
            console.error('❌ 에러:', error);
            if (logTime) console.log(`⏱️ 실행 시간: ${duration.toFixed(2)}ms`);
            if (groupInConsole) console.groupEnd();
            throw error;
          }
        );
      }

      // 동기 함수 결과 처리
      const duration = performance.now() - startTime;
      if (logResult) console.log('✅ 결과:', result);
      if (logTime) console.log(`⏱️ 실행 시간: ${duration.toFixed(2)}ms`);
      if (groupInConsole) console.groupEnd();

      return result;
    } catch (error) {
      const duration = performance.now() - startTime;
      console.error('❌ 에러:', error);
      if (logTime) console.log(`⏱️ 실행 시간: ${duration.toFixed(2)}ms`);
      if (groupInConsole) console.groupEnd();
      throw error;
    }
  };
}

// 사용 예시
const fetchUser = traced(async (userId) => {
  const response = await fetch(`/api/users/${userId}`);
  return response.json();
}, { logArgs: true, logResult: true, logTime: true });

// 호출하면 자동으로 상세 로그가 출력됨
await fetchUser(123);

설명

이것이 하는 일: 이 데코레이터는 원본 함수를 감싸서 호출될 때마다 상세한 정보를 자동으로 로깅하고, 동기/비동기 함수 모두 지원하며, this 바인딩과 에러 처리를 올바르게 유지합니다. 첫 번째로, traced 함수는 원본 함수 fn과 옵션 객체를 받아서 새로운 래퍼 함수를 반환합니다.

이 래퍼 함수가 실제로 호출되는 함수이며, 내부에서 fn.apply()로 원본 함수를 실행합니다. apply를 사용하는 이유는 this 컨텍스트를 올바르게 전달하기 위해서입니다.

화살표 함수가 아닌 일반 function을 사용한 것도 같은 이유입니다. 그 다음으로, callCount 변수가 클로저로 캡처되어 함수가 호출될 때마다 증가합니다.

이를 이용해 각 호출에 고유한 ID(callId)를 부여하고, 여러 호출을 구분할 수 있게 합니다. console.group으로 각 호출의 로그를 그룹화하면 DevTools에서 접었다 펼 수 있어 가독성이 훨씬 좋습니다.

console.trace()는 현재 호출 스택을 출력하여 이 함수가 어디서 호출되었는지 추적합니다. 특히 여러 곳에서 호출되는 유틸 함수의 경우, 어느 경로로 들어왔는지 파악하는 데 매우 유용합니다.

Promise 처리 부분이 핵심입니다. 원본 함수가 Promise를 반환하면 then과 catch로 체이닝하여 비동기 작업이 완료된 후에 로그를 출력합니다.

이때 중요한 것은 결과를 그대로 반환해야 한다는 점입니다. 데코레이터는 투명하게 동작해야 하므로 원본 함수의 동작을 전혀 변경하지 않습니다.

여러분이 이 데코레이터를 사용하면 디버깅 모드와 프로덕션 모드를 쉽게 전환할 수 있습니다. 개발 중에는 모든 함수에 traced를 적용하고, 프로덕션 빌드 시에는 데코레이터를 제거하거나 no-op 함수로 교체하면 됩니다.

원본 코드는 전혀 바뀌지 않으므로 안전합니다. 또한 여러 데코레이터를 조합할 수 있습니다.

예를 들어 traced(cached(memoized(expensiveFunction)))처럼 래핑하면 메모이제이션, 캐싱, 추적을 모두 적용할 수 있습니다. 각 데코레이터는 독립적이므로 순서를 바꾸거나 특정 레이어를 제거하기도 쉽습니다.

실전 팁

💡 프로덕션에서는 환경 변수로 데코레이터를 조건부로 적용하세요. const traced = process.env.NODE_ENV === 'development' ? actualTraced : (fn => fn)로 설정하면 빌드 타임에 최적화됩니다.

💡 React 컴포넌트 함수에도 적용할 수 있습니다. const TracedComponent = traced(MyComponent)로 감싸면 props 변경과 렌더링 횟수를 추적할 수 있습니다. 불필요한 리렌더링을 찾는 데 유용합니다.

💡 데코레이터를 조합할 때는 순서가 중요합니다. traced(cached(fn))는 캐시 히트도 로깅하지만, cached(traced(fn))는 캐시 미스만 로깅합니다. 목적에 맞게 순서를 선택하세요.

💡 클래스 메서드에 적용하려면 this.method = traced(this.method.bind(this))처럼 명시적으로 바인딩하세요. 또는 클래스 필드 문법(method = traced(() => {...}))을 사용하면 더 깔끔합니다.

💡 TypeScript에서는 제네릭을 활용해 타입 안전한 데코레이터를 만들 수 있습니다. function traced<T extends (...args: any[]) => any>(fn: T): T처럼 정의하면 원본 함수의 시그니처가 보존됩니다.


5. Factory Pattern으로 에러 핸들러 구축

시작하며

여러분의 코드에서 에러가 발생했을 때 어떻게 처리하시나요? try-catch로 잡아서 console.error만 찍고 끝내시나요?

실무에서는 에러의 종류에 따라 다르게 대응해야 합니다. 네트워크 에러는 재시도하고, 인증 에러는 로그인 페이지로 리다이렉트하고, 치명적인 에러는 Sentry로 전송해야 하죠.

이런 문제는 에러 처리 로직이 코드 곳곳에 흩어지면서 발생합니다. 같은 에러를 다른 곳에서 다르게 처리하거나, 새로운 에러 타입이 추가될 때마다 모든 catch 블록을 수정해야 합니다.

일관성 없는 에러 처리는 사용자 경험을 해치고 디버깅도 어렵게 만듭니다. 바로 이럴 때 필요한 것이 Factory 패턴입니다.

에러의 타입에 따라 적절한 핸들러를 자동으로 생성하고, 중앙화된 정책으로 일관된 에러 처리를 보장할 수 있습니다.

개요

간단히 말해서, Factory 패턴은 객체 생성 로직을 캡슐화하여 클라이언트 코드가 구체적인 클래스를 알 필요 없게 만드는 패턴입니다. 에러 타입에 따라 적절한 핸들러를 선택하는 로직을 한 곳에 모을 수 있습니다.

왜 이 패턴이 필요한가요? 실무에서는 HTTP 상태 코드, 에러 메시지, 에러 클래스 등 다양한 기준으로 에러를 분류하고 각각 다르게 처리해야 합니다.

예를 들어, 401 Unauthorized는 토큰 갱신을 시도하고, 404 Not Found는 사용자에게 친절한 메시지를 보여주고, 500 Internal Server Error는 개발팀에게 알림을 보내는 식이죠. 이런 복잡한 분기 로직을 Factory로 캡슐화하면 catch 블록이 간결해집니다.

기존에는 거대한 switch 문이나 if-else 체인을 사용했다면, 이제는 Factory가 적절한 핸들러 객체를 반환하고 그 객체가 알아서 처리하게 할 수 있습니다. 새로운 에러 타입이 추가되어도 Factory에 핸들러만 등록하면 되므로 기존 코드를 건드리지 않아도 됩니다.

이 패턴의 핵심 특징은 첫째, 생성 로직과 사용 로직을 분리하고, 둘째, 다형성으로 타입별 처리를 구현하며, 셋째, 확장이 쉽다는 점입니다. 이러한 특징들이 에러 처리의 일관성과 유지보수성을 크게 향상시킵니다.

코드 예제

// 에러 핸들러 기본 클래스
class ErrorHandler {
  handle(error, context) {
    throw new Error('handle 메서드 구현 필요');
  }
}

// 네트워크 에러 핸들러 (재시도 로직)
class NetworkErrorHandler extends ErrorHandler {
  async handle(error, context) {
    console.warn('🌐 네트워크 에러 발생, 재시도 중...', error);

    const maxRetries = 3;
    const retryDelay = 1000;

    for (let i = 0; i < maxRetries; i++) {
      await new Promise(resolve => setTimeout(resolve, retryDelay * (i + 1)));
      try {
        return await context.retryFunction();
      } catch (retryError) {
        if (i === maxRetries - 1) throw retryError;
      }
    }
  }
}

// 인증 에러 핸들러 (리다이렉트)
class AuthErrorHandler extends ErrorHandler {
  handle(error, context) {
    console.error('🔒 인증 에러:', error);

    // 토큰 제거
    localStorage.removeItem('authToken');

    // 로그인 페이지로 리다이렉트
    window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
  }
}

// 검증 에러 핸들러 (사용자 피드백)
class ValidationErrorHandler extends ErrorHandler {
  handle(error, context) {
    console.warn('⚠️ 검증 에러:', error);

    // 사용자에게 친절한 메시지 표시
    const message = error.message || '입력값을 확인해주세요';
    // UI 프레임워크의 toast/snackbar 사용
    context.showToast?.(message, 'warning');
  }
}

// 기본 에러 핸들러 (로깅 및 알림)
class DefaultErrorHandler extends ErrorHandler {
  handle(error, context) {
    console.error('❌ 처리되지 않은 에러:', error);

    // DevTools에서 보기 좋게 구조화
    console.group('에러 상세 정보');
    console.log('메시지:', error.message);
    console.log('스택:', error.stack);
    console.log('컨텍스트:', context);
    console.groupEnd();

    // 프로덕션에서는 Sentry 등으로 전송
    if (process.env.NODE_ENV === 'production') {
      // window.Sentry?.captureException(error);
    }
  }
}

// Factory: 에러 타입에 따라 핸들러 선택
class ErrorHandlerFactory {
  constructor() {
    this.handlers = new Map([
      ['NetworkError', new NetworkErrorHandler()],
      ['TypeError', new NetworkErrorHandler()], // fetch 실패시 발생
      ['AuthError', new AuthErrorHandler()],
      ['ValidationError', new ValidationErrorHandler()],
    ]);
    this.defaultHandler = new DefaultErrorHandler();
  }

  getHandler(error) {
    // HTTP 상태 코드로 판단
    if (error.response?.status === 401 || error.response?.status === 403) {
      return this.handlers.get('AuthError');
    }

    // 에러 이름이나 커스텀 타입으로 판단
    const handler = this.handlers.get(error.name) || this.handlers.get(error.type);

    return handler || this.defaultHandler;
  }

  async handle(error, context = {}) {
    const handler = this.getHandler(error);
    return handler.handle(error, context);
  }
}

// 전역 Factory 인스턴스
const errorFactory = new ErrorHandlerFactory();

// 사용 예시
async function fetchData() {
  try {
    const response = await fetch('/api/data');
    if (!response.ok) {
      const error = new Error('HTTP Error');
      error.response = response;
      throw error;
    }
    return response.json();
  } catch (error) {
    return errorFactory.handle(error, {
      retryFunction: () => fetch('/api/data').then(r => r.json()),
      showToast: (msg, type) => console.log(`Toast: ${msg}`)
    });
  }
}

설명

이것이 하는 일: 이 Factory는 발생한 에러를 분석하여 네트워크, 인증, 검증, 기타 에러로 분류하고, 각 타입에 맞는 전문 핸들러를 자동으로 선택하여 일관된 에러 처리를 제공합니다. 첫 번째로, ErrorHandler 기본 클래스는 모든 핸들러가 따라야 할 인터페이스를 정의합니다.

각 구체적인 핸들러 클래스는 이를 상속받아 자신만의 처리 로직을 구현합니다. NetworkErrorHandler는 지수 백오프로 재시도하고, AuthErrorHandler는 인증 정보를 정리하고 리다이렉트하며, ValidationErrorHandler는 사용자 친화적인 피드백을 제공합니다.

그 다음으로, ErrorHandlerFactory가 핵심 로직을 담당합니다. 생성자에서 Map으로 에러 타입과 핸들러를 매핑해두고, getHandler 메서드가 에러 객체를 분석하여 적절한 핸들러를 찾습니다.

HTTP 응답 상태 코드를 먼저 체크하고, 그 다음 에러의 name이나 type 속성을 확인하며, 매칭되는 것이 없으면 defaultHandler를 반환합니다. handle 메서드는 Factory의 공개 인터페이스로, 클라이언트 코드는 이 메서드만 호출하면 됩니다.

내부적으로 적절한 핸들러를 찾아서 위임하는 방식이므로, 클라이언트는 구체적인 핸들러 클래스를 전혀 알 필요가 없습니다. 이것이 Factory 패턴의 핵심 가치입니다.

context 객체는 핸들러에게 추가 정보나 기능을 전달하는 수단입니다. 예를 들어 retryFunction으로 재시도할 함수를, showToast로 UI 피드백 함수를 전달할 수 있습니다.

이렇게 하면 핸들러가 UI 프레임워크에 의존하지 않으면서도 필요한 기능을 사용할 수 있습니다. 여러분이 이 패턴을 사용하면 try-catch 블록이 매우 간결해집니다.

catch (error) { errorFactory.handle(error, context); } 한 줄이면 모든 에러가 적절히 처리됩니다. 새로운 에러 타입이 추가되어도 Factory에 핸들러만 등록하면 되므로 기존 코드를 건드릴 필요가 없습니다.

Chrome DevTools와의 통합도 뛰어납니다. console.group으로 구조화된 에러 정보를 출력하고, 각 핸들러가 적절한 로그 레벨(warn, error)을 사용하여 DevTools 콘솔에서 필터링하기 쉽게 만듭니다.

Source 탭에서 "Pause on caught exceptions"를 활성화하면 핸들러 내부에서 디버깅할 수도 있습니다.

실전 팁

💡 에러 핸들러에 우선순위를 부여하세요. 예를 들어 AuthError가 NetworkError보다 먼저 체크되어야 합니다. Map 대신 배열로 순서를 보장하거나, 체이닝 패턴을 사용할 수 있습니다.

💡 핸들러가 에러를 완전히 처리했는지, 아니면 다시 던져야 하는지 명시하세요. handle 메서드의 반환값으로 처리 결과를 전달하면 상위 레벨에서 판단할 수 있습니다.

💡 개발 환경에서는 더 상세한 로그를, 프로덕션에서는 간결한 로그를 출력하도록 환경별로 핸들러를 교체할 수 있습니다. Factory가 환경을 감지해 다른 핸들러 인스턴스를 생성하게 하세요.

💡 React Error Boundary와 통합하면 컴포넌트 에러도 중앙에서 처리할 수 있습니다. componentDidCatch에서 errorFactory.handle을 호출하면 됩니다.

💡 async 핸들러의 경우 에러가 발생할 수 있으므로, Factory의 handle 메서드도 try-catch로 감싸세요. 핸들러가 실패해도 애플리케이션이 죽지 않도록 보호해야 합니다.


6. Proxy Pattern으로 객체 접근 감시

시작하며

여러분이 "누가 이 객체를 변경하는 거지?"라고 궁금해하신 적 있나요? 특히 전역 상태나 공유 객체는 여러 곳에서 접근하기 때문에 예상치 못한 변경이 버그를 유발합니다.

Vue.js의 반응성 시스템이나 MobX의 observable이 바로 이 원리를 활용합니다. 이런 문제는 JavaScript 객체의 mutable한 특성 때문에 발생합니다.

누구든 객체의 속성을 마음대로 바꿀 수 있고, 어디서 변경했는지 추적하기 어렵습니다. 특히 깊게 중첩된 객체나 배열의 경우 변경 감지가 더욱 어렵습니다.

바로 이럴 때 필요한 것이 Proxy 패턴입니다. ES6 Proxy를 활용하면 객체의 모든 접근과 변경을 가로채서 로깅하고, 검증하고, 추가 로직을 실행할 수 있습니다.

DevTools에서 정확히 언제 어디서 변경이 일어났는지 추적할 수 있습니다.

개요

간단히 말해서, Proxy 패턴은 원본 객체를 감싸는 대리 객체를 만들어 접근을 제어하는 패턴입니다. ES6 Proxy는 이를 언어 레벨에서 지원하며, get, set, has, deleteProperty 등 13가지 트랩(trap)을 제공합니다.

왜 이 패턴이 필요한가요? 실무에서는 객체 변경을 감지해 UI를 업데이트하거나, 변경 이력을 저장하거나, 불법적인 접근을 차단해야 할 때가 많습니다.

예를 들어, 사용자 권한에 따라 특정 속성 접근을 막거나, API 응답 객체가 수정되지 않도록 보호하거나, 상태 변경을 자동으로 로컬 스토리지에 저장하는 식이죠. Proxy를 사용하면 이 모든 것을 원본 객체 수정 없이 구현할 수 있습니다.

기존에는 Object.defineProperty로 getter/setter를 정의했다면, Proxy는 더 강력하고 유연합니다. 존재하지 않는 속성 접근도 가로챌 수 있고, 배열 메서드까지 완벽하게 처리합니다.

Vue 3가 Vue 2의 defineProperty 기반에서 Proxy 기반으로 전환한 이유이기도 합니다. 이 패턴의 핵심 특징은 첫째, 투명하게 동작하여 클라이언트 코드가 프록시인지 모르고, 둘째, 모든 연산을 가로챌 수 있으며, 셋째, 원본 객체를 보호한다는 점입니다.

이러한 특징들이 디버깅, 보안, 반응형 시스템 구현에 필수적입니다.

코드 예제

// 객체 접근 감시 Proxy 생성 함수
function createObservableProxy(target, options = {}) {
  const {
    logReads = true,      // 읽기 로깅
    logWrites = true,     // 쓰기 로깅
    freezeWrites = false, // 쓰기 차단 (읽기 전용)
    onChange = null       // 변경 콜백
  } = options;

  // 변경 이력 추적
  const changeHistory = [];

  const handler = {
    // 속성 읽기 가로채기
    get(target, property, receiver) {
      const value = Reflect.get(target, property, receiver);

      if (logReads) {
        console.log(`📖 읽기: ${String(property)} = ${JSON.stringify(value)}`);
        console.trace('호출 위치');
      }

      // 중첩 객체도 Proxy로 감싸기 (깊은 감시)
      if (typeof value === 'object' && value !== null) {
        return createObservableProxy(value, options);
      }

      return value;
    },

    // 속성 쓰기 가로채기
    set(target, property, value, receiver) {
      if (freezeWrites) {
        console.error(`🔒 쓰기 차단: ${String(property)}는 읽기 전용입니다`);
        return false;
      }

      const oldValue = target[property];

      // 실제로 변경되었을 때만 로깅
      if (oldValue !== value) {
        if (logWrites) {
          console.group(`✏️ 쓰기: ${String(property)}`);
          console.log('이전 값:', oldValue);
          console.log('새 값:', value);
          console.trace('호출 위치');
          console.groupEnd();
        }

        // 변경 이력 저장
        const change = {
          timestamp: new Date().toISOString(),
          property: String(property),
          oldValue,
          newValue: value,
          stack: new Error().stack
        };
        changeHistory.push(change);

        // 변경 콜백 실행
        onChange?.(change);
      }

      return Reflect.set(target, property, value, receiver);
    },

    // 속성 삭제 가로채기
    deleteProperty(target, property) {
      if (freezeWrites) {
        console.error(`🔒 삭제 차단: ${String(property)}는 삭제할 수 없습니다`);
        return false;
      }

      console.warn(`🗑️ 삭제: ${String(property)}`);
      return Reflect.deleteProperty(target, property);
    },

    // 속성 존재 확인 가로채기
    has(target, property) {
      console.log(`❓ 존재 확인: "${String(property)}" in object`);
      return Reflect.has(target, property);
    }
  };

  const proxy = new Proxy(target, handler);

  // 변경 이력 조회 메서드 추가
  proxy.__getHistory = () => changeHistory;
  proxy.__clearHistory = () => changeHistory.length = 0;

  return proxy;
}

// 사용 예시
const state = createObservableProxy({
  user: { name: 'Alice', age: 25 },
  counter: 0
}, {
  logReads: false,
  logWrites: true,
  onChange: (change) => {
    console.log('🔔 상태 변경 감지:', change);
    // 여기서 UI 업데이트, 로컬 스토리지 저장 등 수행
  }
});

// 이제 모든 변경이 자동으로 추적됨
state.counter++; // ✏️ 쓰기: counter 로그 출력
state.user.name = 'Bob'; // 중첩 객체도 감시됨

설명

이것이 하는 일: 이 함수는 원본 객체를 ES6 Proxy로 감싸서 모든 속성 접근을 가로채고, 읽기/쓰기/삭제를 자동으로 로깅하며, 변경 이력을 저장하고, 중첩 객체까지 재귀적으로 감시합니다. 첫 번째로, Proxy의 get 트랩은 속성을 읽을 때마다 실행됩니다.

Reflect.get으로 실제 값을 가져온 후 로깅하고, 만약 그 값이 객체라면 또 다시 Proxy로 감싸서 반환합니다. 이것이 깊은 감시(deep observation)를 가능하게 하는 핵심입니다.

이렇게 하면 state.user.name처럼 중첩된 속성 접근도 모두 감지됩니다. 그 다음으로, set 트랩은 속성 쓰기를 가로챕니다.

freezeWrites 옵션이 활성화되어 있으면 쓰기를 차단하고 false를 반환하여 에러를 발생시킵니다. 그렇지 않으면 이전 값과 비교해서 실제로 변경되었을 때만 로깅합니다.

이렇게 하면 state.counter = 5; state.counter = 5;처럼 같은 값을 다시 할당해도 불필요한 로그가 출력되지 않습니다. 변경 이력은 changeHistory 배열에 클로저로 캡처되어 저장됩니다.

각 변경마다 타임스탬프, 속성명, 이전/새 값, 스택 트레이스를 기록하므로 나중에 "10초 전에 누가 이 값을 바꿨지?"를 정확히 추적할 수 있습니다. new Error().stack으로 스택 트레이스를 캡처하는 것이 핵심 트릭입니다.

onChange 콜백은 반응형 시스템의 기초입니다. 상태가 변경될 때마다 이 콜백이 실행되므로, 여기서 React setState를 호출하거나, DOM을 업데이트하거나, 웹소켓으로 서버에 전송할 수 있습니다.

Vue나 MobX의 반응형 시스템도 이와 비슷한 원리로 동작합니다. 여러분이 이 Proxy를 사용하면 Redux DevTools 없이도 상태 변경을 완벽하게 추적할 수 있고, 불변성을 강제하여 실수로 인한 버그를 방지하며, 디버깅 중에만 감시를 활성화하여 성능 영향을 최소화할 수 있습니다.

특히 Chrome DevTools의 "Pause on caught exceptions"와 함께 사용하면 잘못된 변경이 일어나는 순간 디버거가 멈추게 할 수 있습니다. 실무에서는 이 기본 구조에 스키마 검증(예: Zod, Yup)을 추가하거나, Immer처럼 불변 업데이트를 편리하게 만들거나, Time-travel 디버깅(변경 이력을 되돌리기)을 구현할 수 있습니다.

Proxy의 강력함은 무한합니다.

실전 팁

💡 성능이 중요한 경우 프로덕션에서는 Proxy를 비활성화하세요. const createProxy = process.env.NODE_ENV === 'development' ? createObservableProxy : (obj => obj)처럼 조건부로 적용하면 됩니다.

💡 배열 메서드(push, pop, splice 등)도 자동으로 감지됩니다. Proxy는 배열의 길이 변경까지 추적하므로 별도 처리가 필요 없습니다. 다만 성능을 위해 배치 업데이트를 고려하세요.

💡 Proxy는 원시값(primitive)을 감쉴 수 없습니다. 숫자, 문자열, 불린을 추적하려면 객체로 감싸야 합니다. createObservableProxy({ value: 42 })처럼 사용하세요.

💡 WeakMap으로 이미 프록시된 객체를 캐싱하면 중복 프록시를 방지할 수 있습니다. if (proxyCache.has(value)) return proxyCache.get(value)를 get 트랩에 추가하세요.

💡 revocable Proxy를 사용하면 나중에 감시를 완전히 비활성화할 수 있습니다. Proxy.revocable(target, handler)로 생성하고 revoke()를 호출하면 모든 트랩이 에러를 던지게 됩니다.


7. Command Pattern으로 브레이크포인트 관리

시작하며

여러분이 복잡한 버그를 디버깅할 때 브레이크포인트를 여러 개 설정하고, 조건을 걸고, 로그를 찍고, 다시 제거하는 과정이 번거롭지 않으신가요? 특히 같은 디버깅 세션을 여러 번 반복해야 할 때, 매번 똑같은 브레이크포인트를 설정하는 것은 시간 낭비입니다.

이런 문제는 디버깅 작업이 일회성이고 재사용하기 어렵기 때문에 발생합니다. Chrome DevTools는 브레이크포인트를 저장하지만 프로젝트별로 관리하거나, 팀과 공유하거나, 프로그래밍 방식으로 제어하기 어렵습니다.

복잡한 디버깅 시나리오를 스크립트로 자동화할 수도 없습니다. 바로 이럴 때 필요한 것이 Command 패턴입니다.

디버깅 작업(브레이크포인트 설정, 조건 추가, 로그 출력 등)을 객체로 캡슐화하면 저장하고, 취소하고, 재실행할 수 있습니다. 디버깅 시나리오를 코드로 작성하여 버전 관리하고 팀과 공유할 수 있습니다.

개요

간단히 말해서, Command 패턴은 요청이나 작업을 객체로 캡슐화하여 매개변수화, 큐잉, 로깅, 취소를 가능하게 하는 패턴입니다. 디버깅 작업을 일급 객체로 만들어 프로그래밍 방식으로 제어할 수 있습니다.

왜 이 패턴이 필요한가요? 실무에서는 "로그인 버그", "결제 버그" 같은 반복적인 디버깅 시나리오가 있습니다.

각 시나리오마다 필요한 브레이크포인트, 워치 표현식, 조건부 로그가 정해져 있죠. 예를 들어, 결제 버그를 디버깅할 때마다 API 호출, 상태 업데이트, 검증 로직에 항상 같은 브레이크포인트를 설정한다면, 이를 "결제 디버그 세트"라는 커맨드로 저장해두면 편리합니다.

기존에는 DevTools의 UI를 수동으로 조작했다면, 이제는 JavaScript로 프로그래밍할 수 있습니다. Chrome DevTools Protocol(CDP)을 사용하면 코드로 디버거를 제어할 수 있지만 복잡하므로, 여기서는 간단한 커맨드 패턴으로 조건부 로깅을 자동화하는 방법을 보여드립니다.

이 패턴의 핵심 특징은 첫째, 작업을 객체로 만들어 저장하고, 둘째, Undo/Redo를 쉽게 구현하며, 셋째, 작업 히스토리를 기록한다는 점입니다. 이러한 특징들이 디버깅 워크플로우를 크게 개선합니다.

코드 예제

// 디버깅 커맨드 기본 클래스
class DebugCommand {
  execute() { throw new Error('execute 구현 필요'); }
  undo() { throw new Error('undo 구현 필요'); }
  describe() { return 'DebugCommand'; }
}

// 조건부 로그 브레이크포인트 커맨드
class ConditionalLogCommand extends DebugCommand {
  constructor(funcName, condition, logMessage) {
    super();
    this.funcName = funcName;
    this.condition = condition;
    this.logMessage = logMessage;
    this.originalFunc = null;
  }

  execute() {
    // 전역 함수나 객체 메서드를 찾아서 래핑
    const target = window[this.funcName] || eval(this.funcName);
    if (typeof target !== 'function') {
      console.error(`함수 "${this.funcName}"을 찾을 수 없습니다`);
      return;
    }

    this.originalFunc = target;

    // 조건부 로그를 추가한 래퍼 함수
    const wrappedFunc = (...args) => {
      if (this.condition(...args)) {
        console.group(`🔍 브레이크포인트: ${this.funcName}`);
        console.log(this.logMessage);
        console.log('인자:', args);
        console.trace('호출 스택');
        console.groupEnd();

        // 실제로 멈추고 싶으면 debugger 추가
        // debugger;
      }
      return this.originalFunc.apply(this, args);
    };

    // 함수 교체
    try {
      eval(`${this.funcName} = wrappedFunc`);
      console.log(`✅ 브레이크포인트 설정: ${this.funcName}`);
    } catch (error) {
      console.error('함수 교체 실패:', error);
    }
  }

  undo() {
    if (this.originalFunc) {
      eval(`${this.funcName} = this.originalFunc`);
      console.log(`❌ 브레이크포인트 제거: ${this.funcName}`);
    }
  }

  describe() {
    return `${this.funcName}에 조건부 로그 (조건: ${this.condition})`;
  }
}

// 워치 표현식 커맨드
class WatchCommand extends DebugCommand {
  constructor(expression, interval = 1000) {
    super();
    this.expression = expression;
    this.interval = interval;
    this.timerId = null;
    this.lastValue = undefined;
  }

  execute() {
    this.timerId = setInterval(() => {
      try {
        const value = eval(this.expression);
        if (value !== this.lastValue) {
          console.log(`👁️ Watch: ${this.expression} = `, value);
          this.lastValue = value;
        }
      } catch (error) {
        console.error(`Watch 평가 실패: ${this.expression}`, error);
      }
    }, this.interval);
    console.log(`✅ Watch 시작: ${this.expression}`);
  }

  undo() {
    if (this.timerId) {
      clearInterval(this.timerId);
      console.log(`❌ Watch 중지: ${this.expression}`);
    }
  }

  describe() {
    return `${this.expression} 감시 (${this.interval}ms 간격)`;
  }
}

// 커맨드 매니저 (Invoker)
class DebugSession {
  constructor(name) {
    this.name = name;
    this.commands = [];
    this.history = [];
  }

  addCommand(command) {
    this.commands.push(command);
    return this;
  }

  start() {
    console.group(`🚀 디버그 세션 시작: ${this.name}`);
    this.commands.forEach(cmd => {
      console.log(`실행 중: ${cmd.describe()}`);
      cmd.execute();
      this.history.push(cmd);
    });
    console.groupEnd();
  }

  stop() {
    console.group(`🛑 디버그 세션 종료: ${this.name}`);
    this.history.reverse().forEach(cmd => {
      cmd.undo();
    });
    this.history = [];
    console.groupEnd();
  }

  save() {
    // 세션을 JSON으로 저장 (실제로는 localStorage나 파일로)
    const serialized = this.commands.map(cmd => ({
      type: cmd.constructor.name,
      params: cmd
    }));
    console.log('저장된 세션:', JSON.stringify(serialized, null, 2));
    return serialized;
  }
}

// 사용 예시: "로그인 버그" 디버깅 세션
const loginDebugSession = new DebugSession('로그인 버그 디버깅')
  .addCommand(new ConditionalLogCommand(
    'handleLogin',
    (email) => email.includes('test'),
    '테스트 계정 로그인 시도 감지'
  ))
  .addCommand(new WatchCommand('window.currentUser', 500));

// 세션 시작 (모든 브레이크포인트/워치 활성화)
loginDebugSession.start();

// 디버깅 완료 후 정리
// loginDebugSession.stop();

설명

이것이 하는 일: 이 코드는 디버깅 작업(조건부 로깅, 변수 감시 등)을 Command 객체로 캡슐화하여 프로그래밍 방식으로 제어하고, 여러 커맨드를 세션으로 묶어 한 번에 실행/중지할 수 있게 합니다. 첫 번째로, DebugCommand 기본 클래스는 모든 디버깅 커맨드의 인터페이스를 정의합니다.

execute는 작업을 수행하고, undo는 되돌리며, describe는 사람이 읽을 수 있는 설명을 제공합니다. 이렇게 통일된 인터페이스 덕분에 DebugSession이 커맨드의 구체적인 타입을 모르고도 실행할 수 있습니다.

그 다음으로, ConditionalLogCommand는 조건부 브레이크포인트를 구현합니다. eval을 사용해 함수 이름으로 실제 함수를 찾고, 원본 함수를 저장한 후 조건부 로그가 추가된 래퍼 함수로 교체합니다.

조건 함수가 true를 반환할 때만 로그가 출력되므로, "특정 사용자 ID일 때만" 같은 복잡한 조건을 설정할 수 있습니다. WatchCommand는 Chrome DevTools의 Watch 패널을 프로그래밍 방식으로 재현합니다.

setInterval로 주기적으로 표현식을 평가하고, 값이 변경되었을 때만 로그를 출력합니다. 이렇게 하면 DevTools를 열지 않고도 변수 변화를 추적할 수 있습니다.

DebugSession은 여러 커맨드를 묶는 Invoker 역할입니다. start 메서드로 모든 커맨드를 순서대로 실행하고 history에 저장합니다.

stop 메서드는 history를 역순으로 순회하며 각 커맨드의 undo를 호출하여 원상 복구합니다. 이렇게 하면 디버깅이 끝난 후 코드가 깨끗한 상태로 돌아옵니다.

여러분이 이 패턴을 사용하면 자주 사용하는 디버깅 시나리오를 스크립트로 만들어 프로젝트에 저장하고, 새로운 팀원이 와도 "이 버그를 디버깅하려면 이 세션을 실행하세요"라고 안내할 수 있습니다. 또한 CI/CD 파이프라인에서 자동화된 디버깅을 실행하거나, 프로덕션 환경에서 원격으로 디버깅을 활성화하는 데도 사용할 수 있습니다.

save 메서드로 세션을 JSON으로 직렬화하면 팀 전체가 같은 디버깅 설정을 공유할 수 있습니다. Git에 커밋해서 버전 관리하면 "이 버전에서 이 버그를 어떻게 디버깅했는지" 역사적 기록도 남습니다.

실전 팁

💡 eval 사용을 피하고 싶다면 함수를 직접 전달받는 방식으로 수정하세요. new ConditionalLogCommand(myFunction, ...)처럼 사용하면 더 안전합니다.

💡 브라우저 확장으로 만들면 DevTools 패널에 커스텀 UI를 추가할 수 있습니다. Chrome Extension API와 CDP를 결합하면 진짜 브레이크포인트를 프로그래밍 방식으로 설정할 수 있습니다.

💡 Macro Command를 구현하면 여러 커맨드를 하나로 묶을 수 있습니다. 예를 들어 "API 버그 세트"는 네트워크 요청 로깅 + 상태 감시 + 에러 핸들러를 포함할 수 있습니다.

💡 커맨드에 타임스탬프를 추가하면 시간순으로 정렬하거나, 특정 시점으로 되돌리는 Time-travel 디버깅을 구현할 수 있습니다.

💡 Playwright나 Puppeteer와 결합하면 E2E 테스트 중에 자동으로 디버깅 세션을 활성화할 수 있습니다. 테스트가 실패하면 상세 로그가 자동으로 기록됩니다.


#JavaScript#DevTools#Debugging#Performance#Profiling

댓글 (0)

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