LazyLoading 완벽 마스터

LazyLoading의 핵심 개념과 실전 활용법

React중급
8시간
4개 항목
학습 진행률0 / 4 (0%)

학습 항목

1. JavaScript
고급
Optimization|디자인|패턴|완벽|가이드
퀴즈튜토리얼
2. React
초급
Performance|최적화|실전|프로젝트|가이드
퀴즈튜토리얼
3. React
React|Suspense|Lazy|Loading|완벽|가이드
퀴즈튜토리얼
4. iOS
중급
iOS|성능|최적화|완벽|가이드
퀴즈튜토리얼
1 / 4

이미지 로딩 중...

Optimization 디자인 패턴 완벽 가이드 - 슬라이드 1/13

코드 최적화와 디자인 패턴 완벽 가이드

실무에서 바로 적용할 수 있는 핵심 최적화 기법과 디자인 패턴을 배워보세요. 초급 개발자도 쉽게 이해할 수 있도록 실제 코드 예제와 함께 설명합니다. 성능 개선부터 유지보수성 향상까지, 더 나은 코드를 작성하는 방법을 알려드립니다.


목차

  1. 메모이제이션
  2. 싱글톤 패턴
  3. 팩토리 패턴
  4. 옵저버 패턴
  5. 전략 패턴
  6. 데코레이터 패턴
  7. 지연 로딩
  8. 디바운싱과 쓰로틀링

1. 메모이제이션

시작하며

여러분이 복잡한 계산을 반복적으로 수행하는 함수를 작성했는데, 같은 입력값에 대해 매번 똑같은 연산을 다시 하고 있다면? 예를 들어, 피보나치 수열을 재귀로 계산할 때 fibonacci(5)를 구하려면 fibonacci(3)을 여러 번 중복 계산하게 됩니다.

이런 문제는 실제 개발 현장에서 성능 병목의 주요 원인이 됩니다. 특히 API 응답이 느려지거나, 사용자 인터페이스가 버벅거리는 현상으로 나타나죠.

불필요한 연산이 CPU 자원을 낭비하고, 사용자 경험을 해칩니다. 바로 이럴 때 필요한 것이 메모이제이션입니다.

한 번 계산한 결과를 저장해두고, 같은 입력이 들어오면 저장된 값을 즉시 반환하여 성능을 극적으로 향상시킬 수 있습니다.

개요

간단히 말해서, 메모이제이션은 함수의 결과값을 캐시에 저장했다가 재사용하는 최적화 기법입니다. 동일한 입력에 대해 반복적으로 계산하는 경우, 첫 번째 호출에서만 실제 연산을 수행하고 결과를 메모리에 저장합니다.

이후 같은 입력이 들어오면 연산 없이 저장된 값을 바로 반환하죠. 예를 들어, 복잡한 데이터 변환이나 무거운 계산을 수행하는 함수에서 매우 유용합니다.

기존에는 같은 계산을 매번 반복했다면, 이제는 한 번만 계산하고 결과를 재사용할 수 있습니다. 메모이제이션의 핵심 특징은 첫째, 투명성(함수의 동작이 변하지 않음), 둘째, 시간-공간 트레이드오프(메모리를 사용해 시간을 절약), 셋째, 순수 함수에 적합(같은 입력에 같은 출력)입니다.

이러한 특징들이 예측 가능하고 안정적인 성능 최적화를 가능하게 합니다.

코드 예제

// 메모이제이션 헬퍼 함수
function memoize(fn) {
  // 계산 결과를 저장할 캐시 객체
  const cache = {};

  return function(...args) {
    // 인자를 문자열로 변환하여 키로 사용
    const key = JSON.stringify(args);

    // 캐시에 결과가 있으면 즉시 반환
    if (cache[key]) {
      console.log('캐시에서 반환:', key);
      return cache[key];
    }

    // 없으면 함수를 실행하고 결과를 캐시에 저장
    console.log('새로 계산:', key);
    const result = fn.apply(this, args);
    cache[key] = result;
    return result;
  };
}

// 사용 예시: 피보나치 함수
const fibonacci = memoize(function(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
});

설명

이것이 하는 일: 메모이제이션은 함수 호출 결과를 메모리에 저장하고, 동일한 인자로 다시 호출될 때 저장된 값을 반환하여 불필요한 재계산을 방지합니다. 첫 번째로, memoize 함수 내부에서 cache 객체를 생성합니다.

이 객체는 클로저를 통해 유지되며, 각 함수 호출의 결과를 key-value 형태로 저장합니다. 함수의 인자들을 JSON.stringify로 문자열 키로 변환하는 이유는, 객체의 속성명은 문자열이어야 하고, 여러 인자를 하나의 고유한 키로 만들기 위함입니다.

그 다음으로, 반환된 함수가 호출되면 먼저 cache 객체를 확인합니다. 만약 해당 키에 대한 값이 이미 존재한다면, 원본 함수를 실행하지 않고 즉시 저장된 값을 반환합니다.

이 과정에서 시간 복잡도가 O(n)에서 O(1)로 줄어들게 되죠. 마지막으로, 캐시에 값이 없는 경우에만 원본 함수 fn을 실행하고, 그 결과를 cache[key]에 저장한 후 반환합니다.

fn.apply(this, args)를 사용하는 이유는 원본 함수의 this 컨텍스트와 모든 인자를 그대로 전달하기 위함입니다. 여러분이 이 코드를 사용하면 fibonacci(40) 같은 큰 수를 계산할 때 수십억 번의 연산을 수십 번으로 줄일 수 있습니다.

CPU 사용률이 급격히 감소하고, 응답 시간이 밀리초 단위로 개선되며, 사용자는 즉각적인 반응을 경험하게 됩니다.

실전 팁

💡 순수 함수(같은 입력에 항상 같은 출력)에만 메모이제이션을 적용하세요. 랜덤 값이나 현재 시간을 사용하는 함수는 메모이제이션하면 안 됩니다.

💡 메모리 누수를 방지하기 위해 캐시 크기를 제한하는 LRU(Least Recently Used) 캐시를 구현하세요. 예를 들어, 최근 100개 결과만 저장하도록 설정할 수 있습니다.

💡 인자가 객체나 배열인 경우 JSON.stringify는 순서에 민감하므로, 객체 키를 정렬하거나 더 정교한 해싱 함수를 사용하세요.

💡 개발 환경에서는 console.log로 캐시 히트율을 모니터링하여 메모이제이션의 효과를 측정하세요. 히트율이 낮다면 메모이제이션이 불필요할 수 있습니다.

💡 React에서는 useMemo와 useCallback 훅이 내장 메모이제이션 기능을 제공하므로, 이들을 우선 활용하세요.


2. 싱글톤 패턴

시작하며

여러분이 애플리케이션에서 데이터베이스 연결 객체를 여러 번 생성하고 있다면? 각 모듈에서 new DatabaseConnection()을 호출할 때마다 새로운 연결이 생성되어 리소스가 낭비되고, 연결 풀이 고갈될 수 있습니다.

이런 문제는 설정 관리자, 로거, 캐시 매니저 같은 전역 상태를 다룰 때 자주 발생합니다. 여러 인스턴스가 존재하면 상태 불일치가 발생하고, 메모리 낭비와 예측 불가능한 동작으로 이어지죠.

바로 이럴 때 필요한 것이 싱글톤 패턴입니다. 클래스의 인스턴스가 단 하나만 존재하도록 보장하고, 전역적으로 접근할 수 있는 지점을 제공합니다.

개요

간단히 말해서, 싱글톤 패턴은 클래스의 인스턴스를 오직 하나만 생성하고, 어디서든 동일한 인스턴스에 접근할 수 있게 하는 디자인 패턴입니다. 애플리케이션 전역에서 공유해야 하는 리소스나 상태를 관리할 때 필수적입니다.

생성자를 여러 번 호출해도 항상 같은 객체를 반환하여, 불필요한 객체 생성을 방지하고 메모리를 절약하죠. 예를 들어, 앱 설정, 로깅 시스템, 스레드 풀 같은 경우에 매우 유용합니다.

기존에는 전역 변수를 사용하거나 모듈 스코프에 객체를 선언했다면, 이제는 명시적인 싱글톤 패턴으로 의도를 분명히 하고 인스턴스 생성을 제어할 수 있습니다. 싱글톤의 핵심 특징은 첫째, 단일 인스턴스 보장, 둘째, 전역 접근점 제공, 셋째, 지연 초기화 지원(필요할 때 생성)입니다.

이러한 특징들이 리소스 효율성과 상태 일관성을 보장합니다.

코드 예제

// 싱글톤 클래스 구현
class DatabaseConnection {
  // 유일한 인스턴스를 저장할 정적 변수
  static instance = null;

  constructor() {
    // 이미 인스턴스가 존재하면 기존 인스턴스 반환
    if (DatabaseConnection.instance) {
      return DatabaseConnection.instance;
    }

    // 데이터베이스 연결 설정
    this.connection = null;
    this.host = 'localhost';
    this.port = 5432;

    // 인스턴스를 정적 변수에 저장
    DatabaseConnection.instance = this;
  }

  // 연결 메서드
  connect() {
    if (!this.connection) {
      this.connection = `Connected to ${this.host}:${this.port}`;
      console.log(this.connection);
    }
    return this.connection;
  }
}

// 사용 예시
const db1 = new DatabaseConnection();
const db2 = new DatabaseConnection();
console.log(db1 === db2); // true - 같은 인스턴스

설명

이것이 하는 일: 싱글톤 패턴은 생성자가 여러 번 호출되더라도 최초 한 번만 실제 객체를 생성하고, 이후에는 항상 그 객체를 반환하여 인스턴스의 유일성을 보장합니다. 첫 번째로, 클래스의 정적 속성 instance에 null을 할당합니다.

이 변수는 클래스 레벨에서 관리되며, 모든 인스턴스 생성 시도에서 공유됩니다. 정적 속성을 사용하는 이유는 인스턴스가 아닌 클래스 자체에 데이터를 저장하여, 어떤 컨텍스트에서든 접근 가능하게 하기 위함입니다.

그 다음으로, 생성자 내부에서 DatabaseConnection.instance의 존재 여부를 확인합니다. 만약 이미 인스턴스가 존재한다면, 새로운 객체를 생성하는 대신 기존 인스턴스를 즉시 반환합니다.

이 조기 반환(early return) 패턴이 중복 생성을 방지하는 핵심입니다. 마지막으로, 인스턴스가 없는 경우에만 실제 초기화를 수행합니다.

데이터베이스 연결 정보를 설정하고, 생성된 인스턴스를 DatabaseConnection.instance에 저장한 후 this를 반환합니다. 이렇게 하면 이후 모든 생성 시도에서 이 저장된 인스턴스가 재사용됩니다.

여러분이 이 코드를 사용하면 애플리케이션 전체에서 단일 데이터베이스 연결을 공유하게 됩니다. 메모리 사용량이 줄어들고, 연결 풀 관리가 단순해지며, 상태 동기화 문제가 사라집니다.

특히 마이크로서비스 환경에서 서비스 디스커버리나 설정 관리에 유용합니다.

실전 팁

💡 JavaScript의 모듈 시스템은 기본적으로 싱글톤처럼 동작하므로, 단순한 경우 export const instance = new MyClass()로 충분합니다.

💡 멀티스레드 환경에서는 동시에 여러 생성자 호출이 발생할 수 있으므로, 뮤텍스나 락을 사용해 스레드 안전성을 보장해야 합니다.

💡 테스트 시 싱글톤은 상태를 공유하여 테스트 간 간섭을 일으킬 수 있으므로, 인스턴스를 초기화하는 reset() 메서드를 제공하세요.

💡 의존성 주입(Dependency Injection)을 사용하면 싱글톤의 단점(강한 결합, 테스트 어려움)을 완화하면서 동일한 이점을 얻을 수 있습니다.

💡 ES6 Proxy를 활용하면 더 유연한 싱글톤 구현이 가능합니다. 예를 들어, 인스턴스 생성을 가로채서 추가 로직을 실행할 수 있죠.


3. 팩토리 패턴

시작하며

여러분이 다양한 타입의 사용자(일반 사용자, 관리자, 게스트)를 생성해야 하는데, 각 타입마다 다른 속성과 메서드를 가진다면? 코드 곳곳에 if-else로 타입을 체크하고 new User(), new Admin(), new Guest()를 직접 호출하면 코드가 복잡해지고 유지보수가 어려워집니다.

이런 문제는 객체 생성 로직이 여러 곳에 분산되어 있을 때 발생합니다. 새로운 사용자 타입을 추가하거나 생성 로직을 변경할 때마다 모든 코드를 수정해야 하고, 실수로 빠뜨린 부분이 있으면 버그가 발생하죠.

바로 이럴 때 필요한 것이 팩토리 패턴입니다. 객체 생성 로직을 한 곳에 캡슐화하여, 클라이언트 코드는 구체적인 클래스를 몰라도 객체를 생성할 수 있게 합니다.

개요

간단히 말해서, 팩토리 패턴은 객체 생성 로직을 별도의 팩토리 함수나 클래스에 위임하여, 객체를 생성하는 인터페이스를 제공하는 디자인 패턴입니다. 클라이언트 코드가 직접 생성자를 호출하지 않고, 팩토리에게 "이런 타입의 객체를 만들어줘"라고 요청합니다.

팩토리는 내부적으로 어떤 클래스를 인스턴스화할지 결정하죠. 예를 들어, 파일 파서를 만들 때 확장자에 따라 JSONParser, XMLParser, CSVParser를 자동으로 생성하는 경우에 유용합니다.

기존에는 객체 생성 코드가 비즈니스 로직과 섞여 있었다면, 이제는 팩토리가 생성 책임을 전담하여 관심사를 분리할 수 있습니다. 팩토리 패턴의 핵심 특징은 첫째, 생성 로직 캡슐화(복잡한 생성 과정 숨김), 둘째, 느슨한 결합(구체 클래스에 의존하지 않음), 셋째, 확장 용이성(새 타입 추가 시 팩토리만 수정)입니다.

이러한 특징들이 코드의 유연성과 재사용성을 높입니다.

코드 예제

// 사용자 클래스들
class User {
  constructor(name) {
    this.name = name;
    this.type = 'user';
    this.permissions = ['read'];
  }
}

class Admin {
  constructor(name) {
    this.name = name;
    this.type = 'admin';
    this.permissions = ['read', 'write', 'delete'];
  }
}

class Guest {
  constructor(name) {
    this.name = name;
    this.type = 'guest';
    this.permissions = [];
  }
}

// 팩토리 함수
function createUser(name, role) {
  // 역할에 따라 적절한 객체 생성
  switch(role) {
    case 'admin':
      return new Admin(name);
    case 'guest':
      return new Guest(name);
    default:
      return new User(name);
  }
}

// 사용 예시
const user1 = createUser('Alice', 'admin');
const user2 = createUser('Bob', 'user');
console.log(user1.permissions); // ['read', 'write', 'delete']

설명

이것이 하는 일: 팩토리 패턴은 복잡한 객체 생성 로직을 중앙화하고, 런타임에 전달된 매개변수에 따라 동적으로 적절한 클래스의 인스턴스를 생성하여 반환합니다. 첫 번째로, 각 사용자 타입별로 클래스를 정의합니다.

User, Admin, Guest 클래스는 각각 고유한 permissions 배열을 가지며, type 속성으로 구분됩니다. 이렇게 분리하는 이유는 각 타입의 특화된 동작과 속성을 명확히 하고, 단일 책임 원칙을 따르기 위함입니다.

그 다음으로, createUser 팩토리 함수가 역할(role) 매개변수를 받아 switch 문으로 분기합니다. 이 함수는 클라이언트 코드와 구체 클래스 사이의 중간 계층 역할을 하며, 어떤 클래스를 인스턴스화할지 결정하는 책임을 담당합니다.

클라이언트는 Admin이나 Guest 클래스의 존재조차 알 필요가 없습니다. 마지막으로, 팩토리 함수는 생성된 객체를 반환하고, 클라이언트는 일관된 인터페이스로 모든 사용자 타입을 다룰 수 있습니다.

default 케이스를 통해 안전한 폴백(fallback)을 제공하며, 예상치 못한 role 값에도 안정적으로 대응합니다. 여러분이 이 코드를 사용하면 새로운 사용자 타입(예: Moderator)을 추가할 때 팩토리 함수만 수정하면 됩니다.

객체를 사용하는 수백 곳의 코드는 전혀 변경할 필요가 없죠. 또한 생성 시 검증, 로깅, 초기화 같은 공통 로직을 팩토리에 집중시켜 코드 중복을 제거할 수 있습니다.

실전 팁

💡 팩토리를 클래스로 만들면 상태를 유지하고 설정을 주입할 수 있습니다. 예를 들어, UserFactory 클래스에 데이터베이스 연결을 주입하여 사용자 생성 시 자동으로 DB에 저장할 수 있죠.

💡 타입스크립트에서는 팩토리 반환 타입을 유니온 타입으로 정의하여 타입 안전성을 확보하세요. 예: function createUser(...): User | Admin | Guest

💡 객체 풀 패턴과 결합하면 자주 생성되는 객체를 재사용하여 성능을 최적화할 수 있습니다. 팩토리가 풀에서 객체를 가져오거나 새로 생성하는 로직을 담당합니다.

💡 추상 팩토리 패턴으로 확장하면 관련된 객체군을 함께 생성할 수 있습니다. 예를 들어, UIFactory가 Button, Input, Modal을 일관된 테마로 생성하는 식입니다.

💡 팩토리 메서드를 정적 메서드로 구현하면 인스턴스 없이 호출할 수 있어 편리합니다. User.create('Alice', 'admin') 같은 형태로 사용할 수 있죠.


4. 옵저버 패턴

시작하며

여러분이 데이터가 변경될 때 여러 UI 컴포넌트를 동시에 업데이트해야 한다면? 각 컴포넌트를 직접 찾아서 update() 메서드를 호출하면, 데이터 소스가 모든 의존 컴포넌트를 알아야 하고 강하게 결합됩니다.

이런 문제는 이벤트 기반 시스템이나 반응형 UI를 구축할 때 자주 발생합니다. 새로운 컴포넌트를 추가하거나 제거할 때마다 데이터 소스 코드를 수정해야 하고, 의존성이 복잡하게 얽혀 디버깅이 어려워지죠.

바로 이럴 때 필요한 것이 옵저버 패턴입니다. 객체(주체) 상태 변화를 관찰하는 옵저버들에게 자동으로 알림을 보내, 느슨한 결합으로 일대다 관계를 구현합니다.

개요

간단히 말해서, 옵저버 패턴은 객체의 상태 변화를 관찰하는 옵저버 리스트를 유지하고, 상태가 변할 때 모든 옵저버에게 자동으로 알림을 전송하는 디자인 패턴입니다. 주체(Subject)와 옵저버(Observer)가 느슨하게 결합되어, 주체는 옵저버의 구체적인 구현을 몰라도 됩니다.

옵저버는 언제든 구독을 시작하거나 취소할 수 있죠. 예를 들어, 주식 가격 변동을 여러 대시보드와 알림 시스템에 동시에 전달하는 경우에 매우 유용합니다.

기존에는 데이터 소스가 각 소비자를 직접 호출했다면, 이제는 발행-구독(pub-sub) 모델로 관심 있는 객체들이 스스로 구독하고 알림을 받을 수 있습니다. 옵저버 패턴의 핵심 특징은 첫째, 느슨한 결합(주체와 옵저버가 독립적), 둘째, 동적 관계(런타임에 옵저버 추가/제거), 셋째, 브로드캐스트 통신(일대다 알림)입니다.

이러한 특징들이 유연하고 확장 가능한 시스템을 만들어줍니다.

코드 예제

// 주체(Subject) 클래스
class DataStore {
  constructor() {
    // 옵저버 리스트
    this.observers = [];
    this.data = {};
  }

  // 옵저버 등록
  subscribe(observer) {
    this.observers.push(observer);
  }

  // 옵저버 제거
  unsubscribe(observer) {
    this.observers = this.observers.filter(obs => obs !== observer);
  }

  // 모든 옵저버에게 알림
  notify(data) {
    this.observers.forEach(observer => observer.update(data));
  }

  // 데이터 변경 시 자동 알림
  setData(key, value) {
    this.data[key] = value;
    this.notify({ key, value });
  }
}

// 옵저버 예시
class Dashboard {
  update(data) {
    console.log(`Dashboard: ${data.key} changed to ${data.value}`);
  }
}

// 사용 예시
const store = new DataStore();
const dashboard = new Dashboard();
store.subscribe(dashboard);
store.setData('price', 100); // Dashboard: price changed to 100

설명

이것이 하는 일: 옵저버 패턴은 주체 객체가 내부 상태를 변경할 때, 등록된 모든 옵저버 객체의 update 메서드를 자동으로 호출하여 변경 사항을 전파합니다. 첫 번째로, DataStore 클래스는 observers 배열을 유지합니다.

이 배열은 모든 구독자를 담고 있으며, subscribe와 unsubscribe 메서드로 동적으로 관리됩니다. 배열을 사용하는 이유는 순서를 유지하고, 여러 옵저버에게 순차적으로 알림을 보내기 위함입니다.

그 다음으로, notify 메서드가 observers 배열을 순회하면서 각 옵저버의 update 메서드를 호출합니다. 이때 변경된 데이터를 매개변수로 전달하여, 옵저버가 어떤 변화가 있었는지 알 수 있게 합니다.

forEach를 사용해 모든 옵저버에게 동일한 정보를 브로드캐스트하죠. 마지막으로, setData 메서드가 데이터를 변경하고 즉시 notify를 호출합니다.

이 패턴은 데이터 변경과 알림을 원자적으로 결합하여, 옵저버들이 항상 최신 상태를 유지하도록 보장합니다. 클라이언트 코드는 setData만 호출하면 되고, 알림은 자동으로 처리됩니다.

여러분이 이 코드를 사용하면 새로운 UI 컴포넌트를 추가할 때 DataStore 코드를 전혀 수정하지 않아도 됩니다. 컴포넌트가 스스로 store.subscribe(this)를 호출하기만 하면 되죠.

이는 개방-폐쇄 원칙(확장에는 열려있고 수정에는 닫혀있음)을 완벽히 따릅니다. React의 상태 관리, Vue의 반응성 시스템이 모두 이 패턴을 기반으로 합니다.

실전 팁

💡 메모리 누수를 방지하기 위해 컴포넌트가 소멸될 때 반드시 unsubscribe를 호출하세요. React에서는 useEffect의 cleanup 함수에서 구독을 취소합니다.

💡 옵저버가 많아지면 알림 성능이 저하될 수 있으므로, 디바운싱이나 배치 업데이트로 최적화하세요. 예를 들어, 짧은 시간 내 여러 변경을 하나로 묶어 한 번만 알림을 보낼 수 있습니다.

💡 옵저버의 update 메서드에서 예외가 발생하면 다른 옵저버에게 알림이 전달되지 않을 수 있으므로, try-catch로 에러를 격리하세요.

💡 이벤트 타입별로 구독할 수 있도록 확장하면 더 유연해집니다. subscribe('price', observer)처럼 특정 이벤트만 구독하는 식입니다.

💡 RxJS 같은 라이브러리를 사용하면 옵저버 패턴에 연산자(map, filter, debounce 등)를 결합하여 강력한 반응형 프로그래밍을 할 수 있습니다.


5. 전략 패턴

시작하며

여러분이 결제 시스템을 개발하는데, 신용카드, 페이팔, 암호화폐 같은 다양한 결제 방법을 지원해야 한다면? 거대한 if-else 문으로 각 결제 방법을 처리하면 코드가 복잡해지고, 새로운 결제 방법을 추가하기 어려워집니다.

이런 문제는 알고리즘이나 동작을 런타임에 선택해야 할 때 자주 발생합니다. 정렬 알고리즘, 압축 방식, 검증 규칙 등 다양한 전략이 존재하고, 상황에 따라 적절한 전략을 선택해야 할 때죠.

조건문이 중첩되면서 코드 가독성이 떨어지고 유지보수가 어려워집니다. 바로 이럴 때 필요한 것이 전략 패턴입니다.

알고리즘 군을 정의하고 캡슐화하여, 런타임에 전략을 교체할 수 있게 하여 유연성을 극대화합니다.

개요

간단히 말해서, 전략 패턴은 동일한 목적을 가진 여러 알고리즘을 별도의 클래스로 캡슐화하고, 컨텍스트 객체가 런타임에 원하는 전략을 선택하여 실행할 수 있게 하는 디자인 패턴입니다. 각 알고리즘을 독립적인 전략 객체로 분리하여, 클라이언트는 전략을 쉽게 교체할 수 있습니다.

조건문 없이 다형성으로 동작을 변경하죠. 예를 들어, 데이터 압축 시 파일 크기에 따라 ZIP, GZIP, BZIP2를 동적으로 선택하는 경우에 매우 유용합니다.

기존에는 모든 알고리즘이 하나의 메서드 안에 조건문으로 섞여 있었다면, 이제는 각 알고리즘이 독립적인 클래스가 되어 단일 책임 원칙을 따릅니다. 전략 패턴의 핵심 특징은 첫째, 알고리즘 교체 용이성(런타임에 전략 변경), 둘째, 조건문 제거(다형성 활용), 셋째, 개방-폐쇄 원칙(새 전략 추가 시 기존 코드 수정 불필요)입니다.

이러한 특징들이 코드를 깔끔하고 확장 가능하게 만듭니다.

코드 예제

// 전략 인터페이스 (각 전략은 pay 메서드를 구현)
class CreditCardStrategy {
  pay(amount) {
    console.log(`Paid ${amount} using Credit Card`);
    // 실제로는 카드 API 호출
    return { method: 'credit_card', amount };
  }
}

class PayPalStrategy {
  pay(amount) {
    console.log(`Paid ${amount} using PayPal`);
    // 실제로는 PayPal API 호출
    return { method: 'paypal', amount };
  }
}

class CryptoStrategy {
  pay(amount) {
    console.log(`Paid ${amount} using Cryptocurrency`);
    // 실제로는 블록체인 트랜잭션
    return { method: 'crypto', amount };
  }
}

// 컨텍스트 클래스
class PaymentProcessor {
  constructor(strategy) {
    this.strategy = strategy;
  }

  // 전략 변경 메서드
  setStrategy(strategy) {
    this.strategy = strategy;
  }

  // 전략 실행
  processPayment(amount) {
    return this.strategy.pay(amount);
  }
}

// 사용 예시
const processor = new PaymentProcessor(new CreditCardStrategy());
processor.processPayment(100);
processor.setStrategy(new PayPalStrategy());
processor.processPayment(200);

설명

이것이 하는 일: 전략 패턴은 동일한 인터페이스를 구현하는 여러 전략 클래스를 정의하고, 컨텍스트 객체가 현재 전략의 메서드를 위임하여 호출함으로써 알고리즘을 동적으로 변경합니다. 첫 번째로, 각 결제 방법을 별도의 전략 클래스로 구현합니다.

CreditCardStrategy, PayPalStrategy, CryptoStrategy는 모두 pay 메서드를 가지고 있어 동일한 인터페이스를 따릅니다. 이렇게 통일된 인터페이스를 제공하는 이유는 컨텍스트가 전략의 구체적인 타입을 몰라도 사용할 수 있게 하기 위함입니다.

그 다음으로, PaymentProcessor 컨텍스트 클래스가 현재 전략을 this.strategy에 저장합니다. setStrategy 메서드로 언제든 전략을 교체할 수 있으며, 이는 의존성 주입(Dependency Injection) 패턴의 한 형태입니다.

전략을 외부에서 주입받기 때문에 컨텍스트는 전략의 생성이나 선택에 관여하지 않습니다. 마지막으로, processPayment 메서드가 현재 전략의 pay 메서드에 작업을 위임합니다.

이 위임(delegation) 패턴이 핵심으로, 컨텍스트는 "어떻게" 결제할지 모르고 단지 전략에게 "결제해줘"라고 요청만 합니다. 이렇게 하면 if-else 조건문 없이도 다양한 동작을 수행할 수 있습니다.

여러분이 이 코드를 사용하면 새로운 결제 방법(예: ApplePayStrategy)을 추가할 때 기존 PaymentProcessor 코드를 전혀 수정하지 않습니다. 새 전략 클래스만 만들고 processor.setStrategy(new ApplePayStrategy())로 사용하면 되죠.

이는 특히 테스트에 유리한데, Mock 전략을 주입하여 실제 API 호출 없이 단위 테스트를 할 수 있습니다.

실전 팁

💡 전략이 많아지면 팩토리 패턴과 결합하여 전략을 생성하세요. PaymentStrategyFactory.create('paypal')처럼 문자열로 전략을 선택할 수 있습니다.

💡 전략 객체가 상태를 가지지 않는다면(stateless), 싱글톤으로 재사용하여 메모리를 절약하세요. 매번 new를 호출할 필요가 없습니다.

💡 함수형 프로그래밍에서는 클래스 대신 함수로 전략을 구현할 수 있습니다. const creditCardPay = (amount) => {...} 같은 형태로 더 간결하게 작성 가능합니다.

💡 타입스크립트에서는 인터페이스를 명시적으로 정의하여 모든 전략이 동일한 메서드를 구현하도록 강제하세요. interface PaymentStrategy { pay(amount: number): PaymentResult; }

💡 여러 메서드를 가진 복잡한 전략은 추상 클래스를 사용하여 공통 로직을 공유하고, 하위 클래스가 특화된 부분만 구현하게 할 수 있습니다.


6. 데코레이터 패턴

시작하며

여러분이 기본 커피 객체에 우유, 시럽, 휘핑크림 같은 옵션을 동적으로 추가해야 한다면? 모든 조합을 위한 클래스(CoffeeWithMilk, CoffeeWithMilkAndSyrup...)를 만들면 클래스 폭발(class explosion) 문제가 발생합니다.

이런 문제는 객체에 책임을 런타임에 추가해야 할 때 자주 발생합니다. 로깅, 캐싱, 인증 같은 횡단 관심사(cross-cutting concerns)를 기존 코드를 수정하지 않고 추가하고 싶을 때죠.

상속으로 해결하려면 경직되고, 조합이 폭발적으로 늘어납니다. 바로 이럴 때 필요한 것이 데코레이터 패턴입니다.

객체를 감싸서(wrap) 새로운 기능을 동적으로 추가하며, 원본 객체는 전혀 수정하지 않습니다.

개요

간단히 말해서, 데코레이터 패턴은 객체를 다른 객체로 감싸서 추가 기능을 부여하되, 동일한 인터페이스를 유지하여 클라이언트가 차이를 인식하지 못하게 하는 디자인 패턴입니다. 기존 객체의 코드를 변경하지 않고, 데코레이터가 원본 객체를 감싸고 메서드 호출을 가로채어 전후에 추가 동작을 수행합니다.

여러 데코레이터를 중첩하여 기능을 조합할 수 있죠. 예를 들어, API 호출에 로깅, 캐싱, 재시도 로직을 각각 데코레이터로 추가하는 경우에 매우 유용합니다.

기존에는 상속으로 기능을 확장했다면, 이제는 구성(composition)으로 유연하게 기능을 조합할 수 있습니다. 데코레이터 패턴의 핵심 특징은 첫째, 개방-폐쇄 원칙(확장은 가능하되 수정은 불필요), 둘째, 단일 책임 원칙(각 데코레이터가 하나의 기능만 추가), 셋째, 투명성(클라이언트는 데코레이터 존재를 모름)입니다.

이러한 특징들이 유지보수성과 재사용성을 높입니다.

코드 예제

// 기본 컴포넌트
class Coffee {
  cost() {
    return 5;
  }

  description() {
    return 'Plain Coffee';
  }
}

// 데코레이터 기본 클래스
class CoffeeDecorator {
  constructor(coffee) {
    this.coffee = coffee; // 원본 객체 저장
  }

  cost() {
    return this.coffee.cost(); // 위임
  }

  description() {
    return this.coffee.description(); // 위임
  }
}

// 구체적인 데코레이터들
class MilkDecorator extends CoffeeDecorator {
  cost() {
    return this.coffee.cost() + 2; // 기능 추가
  }

  description() {
    return this.coffee.description() + ', Milk';
  }
}

class SyrupDecorator extends CoffeeDecorator {
  cost() {
    return this.coffee.cost() + 3;
  }

  description() {
    return this.coffee.description() + ', Syrup';
  }
}

// 사용 예시
let coffee = new Coffee();
coffee = new MilkDecorator(coffee);
coffee = new SyrupDecorator(coffee);
console.log(coffee.description()); // 'Plain Coffee, Milk, Syrup'
console.log(coffee.cost()); // 10

설명

이것이 하는 일: 데코레이터 패턴은 원본 객체를 내부에 저장하고, 메서드 호출 시 원본 객체의 메서드를 먼저 호출한 후 추가 기능을 덧붙여 반환함으로써 기능을 확장합니다. 첫 번째로, Coffee 기본 클래스가 cost와 description 메서드를 정의합니다.

이것이 데코레이션될 핵심 컴포넌트입니다. 그리고 CoffeeDecorator 기본 데코레이터 클래스가 생성자에서 원본 coffee 객체를 받아 저장합니다.

이 참조를 통해 원본 객체의 기능에 접근하게 됩니다. 그 다음으로, MilkDecorator와 SyrupDecorator 같은 구체적인 데코레이터들이 CoffeeDecorator를 상속받습니다.

각 데코레이터는 메서드를 오버라이드하여, this.coffee.cost()로 원본 결과를 받고 거기에 자신의 추가 값을 더합니다. 이 "호출 후 추가" 패턴이 데코레이터의 핵심입니다.

마지막으로, 사용 시 데코레이터를 중첩합니다. coffee = new MilkDecorator(coffee)는 기존 coffee를 MilkDecorator로 감싸고, 다시 coffee = new SyrupDecorator(coffee)로 한 번 더 감쌉니다.

최종적으로 coffee.cost() 호출 시, SyrupDecorator → MilkDecorator → Coffee 순으로 체인이 실행되며 각 단계의 비용이 누적됩니다. 여러분이 이 코드를 사용하면 기존 Coffee 클래스를 전혀 수정하지 않고 무한히 많은 옵션을 조합할 수 있습니다.

새로운 WhipCreamDecorator를 추가해도 기존 코드는 영향받지 않죠. Java의 InputStream 클래스들(BufferedInputStream, GZIPInputStream 등)이 바로 이 패턴을 사용합니다.

React의 고차 컴포넌트(HOC)도 유사한 개념입니다.

실전 팁

💡 데코레이터 순서가 중요할 수 있습니다. 예를 들어, 암호화 후 압축과 압축 후 암호화는 다른 결과를 만들므로, 순서를 문서화하세요.

💡 너무 많은 데코레이터를 중첩하면 디버깅이 어려워지므로, 스택 트레이스를 확인하기 위한 로깅을 추가하세요.

💡 타입스크립트 데코레이터(@decorator 문법)는 이 패턴을 언어 레벨에서 지원합니다. 클래스, 메서드, 속성에 메타데이터나 동작을 추가할 수 있죠.

💡 함수형 프로그래밍에서는 고차 함수로 데코레이터를 구현할 수 있습니다. const withLogging = (fn) => (...args) => { log(args); return fn(...args); }

💡 프록시 패턴과 유사하지만, 데코레이터는 기능 추가가 목적이고 프록시는 접근 제어가 목적이라는 의도의 차이가 있습니다.


7. 지연 로딩

시작하며

여러분이 웹 페이지를 로드할 때 모든 이미지, 스크립트, 데이터를 한 번에 불러온다면? 초기 로딩 시간이 길어지고, 사용자가 실제로 보지 않는 콘텐츠까지 다운로드하여 대역폭을 낭비하게 됩니다.

이런 문제는 리소스가 많은 애플리케이션에서 성능 병목의 주요 원인입니다. 특히 모바일 환경이나 느린 네트워크에서 사용자는 빈 화면을 오래 보게 되고, 이탈률이 증가하죠.

불필요한 리소스 로드는 서버 부하도 증가시킵니다. 바로 이럴 때 필요한 것이 지연 로딩(Lazy Loading)입니다.

리소스를 즉시 로드하지 않고, 실제로 필요한 시점까지 지연시켜 초기 성능을 극적으로 향상시킵니다.

개요

간단히 말해서, 지연 로딩은 리소스나 객체의 초기화를 실제로 사용될 때까지 미루는 최적화 기법입니다. 애플리케이션 시작 시 모든 것을 로드하는 대신, 사용자가 스크롤하거나 탭을 클릭할 때 필요한 부분만 로드합니다.

초기 번들 크기를 줄이고, Time to Interactive를 단축하죠. 예를 들어, 페이지 하단의 이미지는 사용자가 스크롤해서 화면에 들어올 때만 로드하는 경우에 매우 유용합니다.

기존에는 모든 리소스를 즉시 로드했다면, 이제는 온디맨드(on-demand) 방식으로 필요할 때만 로드하여 효율성을 높입니다. 지연 로딩의 핵심 특징은 첫째, 초기 로딩 시간 단축(중요한 것만 먼저), 둘째, 메모리 효율성(사용하지 않는 것은 로드 안 함), 셋째, 대역폭 절약(필요한 만큼만 다운로드)입니다.

이러한 특징들이 사용자 경험을 크게 개선합니다.

코드 예제

// 이미지 지연 로딩 구현
class LazyImage {
  constructor(imageUrl) {
    this.imageUrl = imageUrl;
    this.image = null; // 아직 로드하지 않음
  }

  // 실제로 필요할 때만 로드
  load() {
    if (!this.image) {
      console.log(`Loading image: ${this.imageUrl}`);
      this.image = new Image();
      this.image.src = this.imageUrl;

      // 로드 완료 핸들러
      this.image.onload = () => {
        console.log(`Image loaded: ${this.imageUrl}`);
      };
    }
    return this.image;
  }
}

// Intersection Observer를 사용한 뷰포트 감지
class LazyImageLoader {
  constructor() {
    this.observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const img = entry.target;
          img.src = img.dataset.src; // data-src를 실제 src로
          this.observer.unobserve(img); // 로드 후 관찰 중단
        }
      });
    });
  }

  observe(element) {
    this.observer.observe(element);
  }
}

// 사용 예시
const lazyImg = new LazyImage('/large-image.jpg');
// 이 시점에는 이미지가 로드되지 않음
setTimeout(() => {
  lazyImg.load(); // 3초 후 실제 로드
}, 3000);

설명

이것이 하는 일: 지연 로딩은 객체 생성 시에는 리소스를 할당하지 않고, 첫 번째 접근 시점에 실제 초기화를 수행하여 불필요한 리소스 사용을 방지합니다. 첫 번째로, LazyImage 클래스의 생성자는 imageUrl만 저장하고 image를 null로 둡니다.

이 단계에서는 실제 Image 객체를 생성하지 않으므로, 메모리 할당이나 네트워크 요청이 발생하지 않습니다. 수백 개의 LazyImage 인스턴스를 생성해도 오버헤드가 거의 없죠.

그 다음으로, load 메서드가 호출될 때 this.image의 존재 여부를 확인합니다. null이면 처음 호출된 것이므로 new Image()로 실제 객체를 생성하고 src를 설정하여 로드를 시작합니다.

이미 로드된 경우에는 즉시 기존 image를 반환하여 중복 로드를 방지합니다. 마지막으로, IntersectionObserver를 사용한 LazyImageLoader는 브라우저 API를 활용합니다.

이 API는 요소가 뷰포트에 들어오는지 효율적으로 감지하며, 스크롤 이벤트 리스너보다 훨씬 성능이 좋습니다. entry.isIntersecting이 true일 때만 이미지를 로드하고, unobserve로 더 이상 감시하지 않아 리소스를 절약합니다.

여러분이 이 코드를 사용하면 페이지 초기 로딩 시 10MB였던 데이터가 2MB로 줄어들 수 있습니다. Lighthouse 성능 점수가 향상되고, First Contentful Paint 시간이 단축되며, 사용자는 즉시 콘텐츠와 상호작용할 수 있습니다.

React의 lazy()와 Suspense, Webpack의 코드 스플리팅이 모두 이 개념을 활용합니다.

실전 팁

💡 중요한 above-the-fold 콘텐츠는 지연 로딩하지 마세요. 사용자가 즉시 보는 영역은 빠르게 로드되어야 합니다.

💡 로딩 중 placeholder나 스켈레톤 UI를 표시하여 사용자에게 진행 상황을 알리세요. 빈 공간보다 훨씬 나은 경험을 제공합니다.

💡 loading="lazy" HTML 속성을 사용하면 브라우저가 자동으로 이미지 지연 로딩을 처리합니다. <img src="..." loading="lazy">

💡 네트워크가 빠른 환경에서는 지연 로딩이 오히려 불필요한 복잡성을 추가할 수 있으므로, 환경에 따라 조건부로 적용하세요.

💡 React에서는 React.lazy(() => import('./Component'))로 컴포넌트 레벨에서 코드 스플리팅을 할 수 있으며, Suspense로 로딩 상태를 우아하게 처리할 수 있습니다.


8. 디바운싱과 쓰로틀링

시작하며

여러분이 검색창에 입력할 때마다 API를 호출한다면? 사용자가 "JavaScript"를 타이핑하면 J, Ja, Jav...

총 10번의 API 요청이 발생하여 서버에 엄청난 부하를 주게 됩니다. 이런 문제는 스크롤, 리사이즈, 키보드 입력 같은 고빈도 이벤트를 다룰 때 자주 발생합니다.

이벤트가 밀리초 단위로 수백 번 발생하면, 각 이벤트마다 무거운 작업을 수행하면 브라우저가 느려지고 UI가 버벅거리죠. 바로 이럴 때 필요한 것이 디바운싱(Debouncing)과 쓰로틀링(Throttling)입니다.

이벤트 호출 빈도를 제어하여 성능을 최적화하고, 불필요한 연산을 제거합니다.

개요

간단히 말해서, 디바운싱은 연속된 이벤트를 그룹화하여 마지막 이벤트만 처리하고, 쓰로틀링은 일정 시간 간격으로 한 번만 이벤트를 처리하는 최적화 기법입니다. 디바운싱은 사용자가 입력을 멈출 때까지 기다렸다가 한 번만 실행합니다.

쓰로틀링은 지정된 시간마다 주기적으로 실행하죠. 예를 들어, 검색 자동완성은 디바운싱(타이핑 멈춘 후 검색), 무한 스크롤은 쓰로틀링(일정 간격으로 체크)에 적합합니다.

기존에는 모든 이벤트를 즉시 처리했다면, 이제는 지능적으로 호출 빈도를 조절하여 불필요한 연산을 제거합니다. 디바운싱과 쓰로틀링의 핵심 특징은 첫째, 성능 최적화(연산 횟수 감소), 둘째, 서버 부하 감소(API 호출 최소화), 셋째, 사용자 경험 개선(부드러운 인터랙션)입니다.

이러한 특징들이 고성능 웹 애플리케이션을 만들어줍니다.

코드 예제

// 디바운스 함수 구현
function debounce(func, delay) {
  let timeoutId;

  return function(...args) {
    // 이전 타이머 취소
    clearTimeout(timeoutId);

    // 새 타이머 시작
    timeoutId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
}

// 쓰로틀 함수 구현
function throttle(func, limit) {
  let inThrottle;

  return function(...args) {
    if (!inThrottle) {
      func.apply(this, args);
      inThrottle = true;

      // limit 시간 후 다시 호출 가능
      setTimeout(() => {
        inThrottle = false;
      }, limit);
    }
  };
}

// 사용 예시
const searchAPI = debounce((query) => {
  console.log(`Searching for: ${query}`);
  // API 호출
}, 500); // 500ms 대기 후 실행

const handleScroll = throttle(() => {
  console.log('Scroll event handled');
  // 무한 스크롤 로직
}, 200); // 200ms마다 최대 1번 실행

// 이벤트 리스너 등록
// input.addEventListener('input', (e) => searchAPI(e.target.value));
// window.addEventListener('scroll', handleScroll);

설명

이것이 하는 일: 디바운싱은 타이머를 매번 리셋하여 마지막 호출 후 일정 시간이 지난 후에만 실행하고, 쓰로틀링은 플래그로 실행 중 상태를 추적하여 일정 시간 내에는 한 번만 실행되도록 제한합니다. 첫 번째로, debounce 함수는 클로저를 활용해 timeoutId 변수를 유지합니다.

이 변수는 반환된 함수가 호출될 때마다 접근 가능하며, 현재 진행 중인 타이머를 추적합니다. 함수가 호출될 때마다 clearTimeout으로 이전 타이머를 취소하는 것이 핵심입니다.

이렇게 하면 사용자가 연속으로 타이핑할 때 마지막 입력 후에만 실제 함수가 실행됩니다. 그 다음으로, throttle 함수는 inThrottle 플래그로 실행 가능 여부를 추적합니다.

플래그가 false일 때만 함수를 즉시 실행하고, 플래그를 true로 설정하여 일정 시간 동안 추가 호출을 차단합니다. setTimeout으로 지정된 시간 후 플래그를 다시 false로 바꿔 다음 호출을 허용합니다.

마지막으로, 두 함수 모두 func.apply(this, args)를 사용하여 원본 함수의 컨텍스트와 인자를 그대로 전달합니다. 이는 이벤트 핸들러에서 this가 올바른 DOM 요소를 가리키도록 보장합니다.

반환된 함수는 원본 함수를 완전히 대체할 수 있는 drop-in replacement입니다. 여러분이 이 코드를 사용하면 검색 API 호출이 초당 수십 번에서 1-2번으로 줄어듭니다.

서버 비용이 절감되고, 네트워크 트래픽이 감소하며, 사용자는 더 빠른 응답을 경험합니다. 무한 스크롤에서 쓰로틀링을 적용하면 스크롤이 부드러워지고, 메인 스레드 블로킹이 줄어듭니다.

Lodash의 _.debounce와 _.throttle이 같은 원리로 동작하며, React에서는 useDebouncedValue 같은 커스텀 훅으로 활용할 수 있습니다.

실전 팁

💡 디바운싱과 쓰로틀링을 선택하는 기준: 사용자가 동작을 멈춘 후 실행하려면 디바운싱(검색), 주기적으로 실행하려면 쓰로틀링(스크롤 위치 추적)을 사용하세요.

💡 React 컴포넌트에서 사용 시 useCallback이나 useMemo로 디바운스/쓰로틀 함수를 메모이제이션하세요. 렌더링마다 새 함수가 생성되면 효과가 없습니다.

💡 디바운싱 delay는 UX를 고려해 설정하세요. 너무 길면(1000ms+) 느리게 느껴지고, 너무 짧으면(100ms 미만) 효과가 적습니다. 보통 300-500ms가 적당합니다.

💡 컴포넌트 언마운트 시 clearTimeout을 호출하여 메모리 누수를 방지하세요. React에서는 useEffect cleanup 함수에서 처리합니다.

💡 leading과 trailing 옵션을 추가하면 더 유연합니다. leading은 첫 호출 즉시 실행, trailing은 마지막 호출 후 실행을 제어합니다. Lodash는 이런 옵션을 제공합니다.

이상으로 코드 최적화와 디자인 패턴 완벽 가이드를 마칩니다. 각 패턴과 기법을 실무에 적용하여 더 나은 코드를 작성해보세요!


#JavaScript#Memoization#LazyLoading#ObjectPool#Performance