JavaScript 디자인 패턴: Factory와 Singleton의 진짜 이야기

“왜 Class 하나로 다 만들면 안 되나요?” - 과거의 저도 이렇게 생각했습니다.


여러분도 이런 경험 있으신가요?

시나리오 1: 객체 생성의 악몽

저는 최근에 게임 프로젝트를 진행하면서 캐릭터 시스템을 구현했습니다. 처음에는 간단했어요:

// 처음엔 이렇게 시작했죠
class Warrior {
  constructor(name, level) {
    this.name = name;
    this.level = level;
    this.hp = 100;
    this.attack = 50;
  }
}

const player = new Warrior('아서', 1);

“괜찮은데? 뭐가 문제야?”

하지만 게임이 복잡해지면서…

// 마법사 추가
class Mage {
  constructor(name, level) {
    this.name = name;
    this.level = level;
    this.hp = 70;
    this.attack = 30;
    this.mana = 100;
  }
}

// 궁수 추가
class Archer {
  constructor(name, level) {
    this.name = name;
    this.level = level;
    this.hp = 80;
    this.attack = 40;
    this.range = 'long';
  }

  // 어? 궁수는 특별한 스킬이...
  specialAttack() { /* ... */ }
}

// 이제 캐릭터 생성 코드가...
function createCharacter(type, name, level) {
  if (type === 'warrior') {
    return new Warrior(name, level);
  } else if (type === 'mage') {
    return new Mage(name, level);
  } else if (type === 'archer') {
    return new Archer(name, level);
  }
  // 새 캐릭터 타입 추가할 때마다 이 함수 수정... 😫
}

문제점들:

  • 새 캐릭터 타입 추가 시 createCharacter 함수 수정 필요
  • 캐릭터별 초기화 로직이 흩어져 있음
  • 타입 체크가 if-else 지옥으로…

시나리오 2: 설정 관리의 혼돈

다음 문제는 게임 설정 관리였습니다.

// 여러 파일에서 설정 객체를 생성
// utils.js
const config1 = {
  volume: 50,
  difficulty: 'normal'
};

// player.js
const config2 = {
  volume: 70,  // 어? 다른 값?
  difficulty: 'hard'
};

// ui.js
const config3 = {
  volume: 50,
  difficulty: 'normal'
};

“이게 왜 문제죠?”

// utils.js에서 설정 변경
config1.volume = 100;

// 하지만 ui.js의 config3는 여전히 50...
console.log(config3.volume);  // 50 (???)

각 파일이 다른 설정 객체를 사용하고 있었던 거예요!

문제점들:

  • 설정 동기화 불가능
  • 어디서든 새로운 설정 객체 생성 가능
  • 일관성 없는 상태 관리

Factory Pattern: 객체 생성의 마법사

“공장”을 상상해보세요

실제 공장을 떠올려봅시다.

고객: "자동차 한 대 주세요!"
공장: "어떤 종류로 드릴까요? 세단, SUV, 트럭?"
고객: "SUV요!"
공장: "알겠습니다!"
      → [제조 과정...]
      → "여기 새 SUV입니다!"

Factory Pattern도 똑같습니다.

개발자: "캐릭터 하나 주세요!"
Factory: "어떤 타입으로 드릴까요?"
개발자: "전사요!"
Factory: "알겠습니다!"
        → [생성 로직 실행...]
        → "여기 새 전사입니다!"

Factory Pattern의 진짜 모습

Before: 혼돈의 객체 생성

// ❌ 문제투성이 코드
function createCharacter(type, name, level) {
  if (type === 'warrior') {
    const char = new Warrior(name, level);
    char.weapon = 'sword';
    char.armor = 'heavy';
    return char;
  } else if (type === 'mage') {
    const char = new Mage(name, level);
    char.weapon = 'staff';
    char.spellbook = true;
    return char;
  } else if (type === 'archer') {
    const char = new Archer(name, level);
    char.weapon = 'bow';
    char.arrows = 50;
    return char;
  }
}

문제점:

  1. if-else 지옥
  2. 새 타입 추가 시 함수 수정
  3. 초기화 로직이 흩어짐

After: Factory Pattern 적용

// ✅ Factory Pattern
class CharacterFactory {
  // 각 타입별 생성 메서드를 등록
  static creators = {
    warrior: (name, level) => {
      const char = new Warrior(name, level);
      char.weapon = 'sword';
      char.armor = 'heavy';
      return char;
    },

    mage: (name, level) => {
      const char = new Mage(name, level);
      char.weapon = 'staff';
      char.spellbook = true;
      return char;
    },

    archer: (name, level) => {
      const char = new Archer(name, level);
      char.weapon = 'bow';
      char.arrows = 50;
      return char;
    }
  };

  // 팩토리 메서드
  static create(type, name, level) {
    const creator = this.creators[type];

    if (!creator) {
      throw new Error(`Unknown character type: ${type}`);
    }

    return creator(name, level);
  }

  // 새 타입 등록 (확장 가능!)
  static register(type, creator) {
    this.creators[type] = creator;
  }
}

// 사용
const warrior = CharacterFactory.create('warrior', '아서', 1);
const mage = CharacterFactory.create('mage', '멀린', 1);

// 새 타입 추가가 쉬워요!
CharacterFactory.register('assassin', (name, level) => {
  const char = new Assassin(name, level);
  char.weapon = 'dagger';
  char.stealth = true;
  return char;
});

const assassin = CharacterFactory.create('assassin', '제로', 1);

개선 효과:

  • if-else 제거
  • ✅ 새 타입 추가 시 기존 코드 수정 불필요
  • ✅ 각 타입의 초기화 로직이 한 곳에
  • register() 메서드로 확장 가능

시각화: Factory Pattern의 흐름

┌─────────────────────────────────────────────────────┐
│  개발자 코드                                         │
│                                                     │
│  CharacterFactory.create('warrior', '아서', 1)      │
│          │                                          │
│          ↓                                          │
├─────────────────────────────────────────────────────┤
│  CharacterFactory (공장)                            │
│                                                     │
│  1. creators['warrior'] 찾기                        │
│  2. creator 함수 실행                               │
│     ├─ new Warrior() 생성                           │
│     ├─ weapon = 'sword' 설정                        │
│     └─ armor = 'heavy' 설정                         │
│  3. 완성된 객체 반환                                 │
│          │                                          │
│          ↓                                          │
├─────────────────────────────────────────────────────┤
│  결과                                                │
│                                                     │
│  { name: '아서', level: 1, weapon: 'sword', ... }   │
└─────────────────────────────────────────────────────┘

실전 예제: UI 컴포넌트 생성

// ✅ 실무에서 자주 쓰는 패턴
class ButtonFactory {
  static styles = {
    primary: {
      backgroundColor: '#007bff',
      color: 'white',
      border: 'none'
    },

    secondary: {
      backgroundColor: '#6c757d',
      color: 'white',
      border: 'none'
    },

    outline: {
      backgroundColor: 'transparent',
      color: '#007bff',
      border: '2px solid #007bff'
    }
  };

  static create(type, text, onClick) {
    const style = this.styles[type];

    if (!style) {
      throw new Error(`Unknown button type: ${type}`);
    }

    const button = document.createElement('button');
    button.textContent = text;
    button.onclick = onClick;

    // 스타일 적용
    Object.assign(button.style, style);

    return button;
  }
}

// 사용
const saveBtn = ButtonFactory.create('primary', '저장', () => {
  console.log('저장됨!');
});

const cancelBtn = ButtonFactory.create('outline', '취소', () => {
  console.log('취소됨!');
});

document.body.appendChild(saveBtn);
document.body.appendChild(cancelBtn);

왜 좋은가요?

  • 버튼 생성 로직이 한 곳에
  • 일관된 스타일 보장
  • 새로운 버튼 타입 추가 쉬움

Singleton Pattern: 하나뿐인 존재

“대통령”을 떠올려보세요

한 나라에는 대통령이 단 한 명만 있어야 합니다.

국민1: "대통령 좀 불러주세요!"
      → 김대통령 등장

국민2: "저도 대통령 좀..."
      → 똑같은 김대통령 등장 (새 대통령이 아님!)

국민3: "대통령!"
      → 역시 김대통령 (동일 인물)

Singleton도 똑같습니다.

파일A: "Config 객체 주세요!"
      → Config 인스턴스 반환

파일B: "저도 Config 주세요!"
      → 똑같은 Config 인스턴스 (새로 만들지 않음!)

파일C: "Config!"
      → 역시 같은 Config

Singleton Pattern의 진짜 모습

Before: 혼돈의 설정 관리

// ❌ 문제: 여러 인스턴스 생성 가능
class Config {
  constructor() {
    this.theme = 'light';
    this.language = 'ko';
    this.volume = 50;
  }
}

// utils.js
const config1 = new Config();
config1.theme = 'dark';

// ui.js
const config2 = new Config();  // 새 인스턴스!
console.log(config2.theme);    // 'light' (???)

문제점:

  • 각 파일이 다른 인스턴스 사용
  • 설정 변경이 전역적으로 반영 안 됨
  • 메모리 낭비 (여러 인스턴스)

After: Singleton Pattern 적용

// ✅ Singleton Pattern (Class 버전)
class Config {
  static instance = null;  // 유일한 인스턴스 저장

  constructor() {
    // 이미 인스턴스가 있으면 그것을 반환!
    if (Config.instance) {
      return Config.instance;
    }

    // 처음 생성될 때만 초기화
    this.theme = 'light';
    this.language = 'ko';
    this.volume = 50;

    // 인스턴스 저장
    Config.instance = this;
  }

  setTheme(theme) {
    this.theme = theme;
    console.log(`테마가 ${theme}로 변경되었습니다.`);
  }

  getTheme() {
    return this.theme;
  }
}

// 테스트
const config1 = new Config();
config1.setTheme('dark');

const config2 = new Config();  // 새로 만들려고 했지만...
console.log(config2.getTheme());  // 'dark' (같은 인스턴스!)

console.log(config1 === config2);  // true (완전히 동일!)

시각화:

첫 번째 new Config() 호출:
┌──────────────────────────────┐
│  Config.instance === null?   │
│          ↓ YES               │
│  새 인스턴스 생성                │
│  Config.instance에 저장        │
│          ↓                   │
│  인스턴스 반환                  │
└──────────────────────────────┘

두 번째 new Config() 호출:
┌──────────────────────────────┐
│  Config.instance === null?   │
│          ↓ NO!               │
│  기존 인스턴스 반환              │
│  (새로 만들지 않음!)             │
└──────────────────────────────┘

더 깔끔한 방법: 클로저 사용

// ✅ Singleton Pattern (함수형 - 더 안전함)
const Config = (function() {
  let instance = null;  // 클로저로 숨김!

  function createInstance() {
    // 실제 설정 객체
    let settings = {
      theme: 'light',
      language: 'ko',
      volume: 50
    };

    // Public API
    return {
      setTheme(theme) {
        settings.theme = theme;
        console.log(`테마가 ${theme}로 변경되었습니다.`);
      },

      getTheme() {
        return settings.theme;
      },

      setLanguage(lang) {
        settings.language = lang;
      },

      getLanguage() {
        return settings.language;
      },

      getAllSettings() {
        return { ...settings };  // 복사본 반환 (안전!)
      }
    };
  }

  return {
    getInstance() {
      if (!instance) {
        instance = createInstance();
      }
      return instance;
    }
  };
})();

// 사용
const config1 = Config.getInstance();
config1.setTheme('dark');

const config2 = Config.getInstance();
console.log(config2.getTheme());  // 'dark' (같은 인스턴스!)

console.log(config1 === config2);  // true

장점:

  • settings에 직접 접근 불가 (클로저로 보호)
  • getInstance()로만 접근 가능
  • new Config()처럼 실수로 생성 불가

실전 예제: Logger (로깅 시스템)

// ✅ 실무에서 자주 쓰는 Singleton
const Logger = (function() {
  let instance = null;

  function createLogger() {
    const logs = [];  // 로그 저장소

    return {
      log(message, level = 'info') {
        const timestamp = new Date().toISOString();
        const logEntry = {
          timestamp,
          level,
          message
        };

        logs.push(logEntry);
        console.log(`[${level.toUpperCase()}] ${timestamp}: ${message}`);
      },

      error(message) {
        this.log(message, 'error');
      },

      warn(message) {
        this.log(message, 'warn');
      },

      getLogs() {
        return [...logs];  // 복사본 반환
      },

      clearLogs() {
        logs.length = 0;
        console.log('로그가 삭제되었습니다.');
      }
    };
  }

  return {
    getInstance() {
      if (!instance) {
        instance = createLogger();
        console.log('Logger 인스턴스가 생성되었습니다.');
      }
      return instance;
    }
  };
})();

// 여러 파일에서 사용
// utils.js
const logger1 = Logger.getInstance();
logger1.log('유틸리티 초기화');

// api.js
const logger2 = Logger.getInstance();
logger2.error('API 호출 실패');

// ui.js
const logger3 = Logger.getInstance();
logger3.warn('렌더링 지연');

// 모든 로그 확인
console.log(logger1.getLogs());
// [
//   { timestamp: '...', level: 'info', message: '유틸리티 초기화' },
//   { timestamp: '...', level: 'error', message: 'API 호출 실패' },
//   { timestamp: '...', level: 'warn', message: '렌더링 지연' }
// ]

console.log(logger1 === logger2 && logger2 === logger3);  // true

왜 Singleton이 필요한가?

  • 모든 파일이 같은 로그 저장소 공유
  • 로그가 한 곳에 모임
  • 메모리 효율적 (인스턴스 하나만)

두 패턴의 비교

언제 어떤 패턴을?

상황 Factory Singleton
목적 객체 생성 방법 캡슐화 인스턴스 하나만 보장
인스턴스 수 여러 개 생성 단 하나만
사용 예 캐릭터, 버튼, 위젯 설정, Logger, DB 연결

시각적 비교

Factory Pattern:
─────────────────────────────────────
개발자: create('warrior')
        ↓
     Factory
        ↓
    [생성 로직]
        ↓
   새 Warrior 객체 ①

개발자: create('warrior')
        ↓
     Factory
        ↓
    [생성 로직]
        ↓
   새 Warrior 객체 ② (다른 객체!)


Singleton Pattern:
─────────────────────────────────────
파일A: getInstance()
       ↓
   Singleton
       ↓
   Config 객체 ①

파일B: getInstance()
       ↓
   Singleton
       ↓
   Config 객체 ① (같은 객체!)

실전 조합: Factory + Singleton

// ✅ 두 패턴을 함께 사용
const CharacterFactory = (function() {
  let instance = null;  // Singleton

  function createFactory() {
    const creators = {
      warrior: (name, level) => new Warrior(name, level),
      mage: (name, level) => new Mage(name, level)
    };

    return {
      // Factory 메서드
      create(type, name, level) {
        const creator = creators[type];
        if (!creator) {
          throw new Error(`Unknown type: ${type}`);
        }
        return creator(name, level);
      },

      register(type, creator) {
        creators[type] = creator;
      }
    };
  }

  return {
    getInstance() {
      if (!instance) {
        instance = createFactory();
      }
      return instance;
    }
  };
})();

// 사용
const factory1 = CharacterFactory.getInstance();
const warrior = factory1.create('warrior', '아서', 1);

const factory2 = CharacterFactory.getInstance();
const mage = factory2.create('mage', '멀린', 1);

console.log(factory1 === factory2);  // true (Singleton!)
console.log(warrior === mage);       // false (Factory로 다른 객체 생성!)

왜 이렇게 하나요?

  • Factory는 Singleton으로 (팩토리는 하나면 충분)
  • 생성되는 객체는 여러 개

함정과 주의사항

함정 1: Singleton의 테스트 어려움

// ❌ 문제: 테스트 간 상태 공유
const config = Config.getInstance();
config.setTheme('dark');

// Test 1
test('should use dark theme', () => {
  expect(config.getTheme()).toBe('dark');  // ✓
});

// Test 2
test('should start with light theme', () => {
  // 어? config는 아직 'dark' 상태...
  expect(config.getTheme()).toBe('light');  // ✗ 실패!
});

해결책: Reset 메서드 추가

// ✅ 해결
const Config = (function() {
  let instance = null;

  function createInstance() {
    let settings = { theme: 'light' };

    return {
      setTheme(theme) { settings.theme = theme; },
      getTheme() { return settings.theme; },

      // 테스트용 reset
      reset() {
        settings = { theme: 'light' };
      }
    };
  }

  return {
    getInstance() {
      if (!instance) instance = createInstance();
      return instance;
    },

    // 테스트용: 인스턴스 제거
    resetInstance() {
      instance = null;
    }
  };
})();

// 테스트에서
afterEach(() => {
  Config.getInstance().reset();  // 상태 초기화
});

함정 2: Factory의 타입 안전성

// ❌ 문제: 오타에 취약
const character = CharacterFactory.create('warroir', 'Bob', 1);
// TypeError: Unknown character type: warroir

해결책: 상수 사용

// ✅ 해결
const CHARACTER_TYPES = {
  WARRIOR: 'warrior',
  MAGE: 'mage',
  ARCHER: 'archer'
};

// 사용
const character = CharacterFactory.create(
  CHARACTER_TYPES.WARRIOR,  // 자동완성!
  'Bob',
  1
);

// 잘못된 타입 방지
const invalid = CharacterFactory.create(
  CHARACTER_TYPES.WARROIR,  // ✗ 컴파일 에러!
  'Bob',
  1
);

함정 3: Singleton의 글로벌 상태

// ❌ 문제: 모듈 간 의존성
// moduleA.js
const config = Config.getInstance();
config.setTheme('dark');

// moduleB.js - 모르는 사이에 영향받음!
const config = Config.getInstance();
console.log(config.getTheme());  // 'dark' (예상치 못한 값!)

해결책: Event Bus와 함께 사용

// ✅ 해결
const Config = (function() {
  let instance = null;

  function createInstance() {
    let settings = { theme: 'light' };
    let listeners = [];

    return {
      setTheme(theme) {
        const oldTheme = settings.theme;
        settings.theme = theme;

        // 변경 알림!
        listeners.forEach(listener => {
          listener({ oldTheme, newTheme: theme });
        });
      },

      getTheme() {
        return settings.theme;
      },

      // 구독
      onChange(callback) {
        listeners.push(callback);

        // 구독 해제 함수 반환
        return () => {
          listeners = listeners.filter(l => l !== callback);
        };
      }
    };
  }

  return { getInstance() { /* ... */ } };
})();

// moduleB.js - 변경 감지 가능!
const config = Config.getInstance();
config.onChange(({ oldTheme, newTheme }) => {
  console.log(`테마 변경: ${oldTheme}${newTheme}`);
});

실전 체크리스트

Factory Pattern 사용 전

  • 객체 생성 로직이 복잡한가?
  • 여러 타입의 객체를 생성하는가?
  • 생성 로직이 여러 곳에 흩어져 있는가?
  • 새 타입 추가가 자주 일어나는가?

하나라도 Yes면 Factory 고려!

Singleton Pattern 사용 전

  • 인스턴스가 진짜 하나만 필요한가?
  • 전역 상태를 관리하는가?
  • 여러 모듈이 같은 인스턴스를 공유해야 하는가?
  • 리소스가 제한적인가? (DB 연결, Logger 등)

하나라도 Yes면 Singleton 고려!

주의사항

  • Singleton은 꼭 필요할 때만! (테스트 어려움)
  • Factory는 너무 복잡하게 만들지 않기
  • TypeScript 사용 시 타입 명시
  • 테스트 코드 작성

실전 예제 모음

예제 1: Modal Factory

// ✅ 실무: 모달 생성 팩토리
class ModalFactory {
  static create(type, options = {}) {
    const modal = document.createElement('div');
    modal.className = 'modal';

    switch (type) {
      case 'alert':
        modal.innerHTML = `
          <div class="modal-content">
            <h2>${options.title || '알림'}</h2>
            <p>${options.message}</p>
            <button onclick="this.closest('.modal').remove()">확인</button>
          </div>
        `;
        break;

      case 'confirm':
        modal.innerHTML = `
          <div class="modal-content">
            <h2>${options.title || '확인'}</h2>
            <p>${options.message}</p>
            <button class="confirm-btn">확인</button>
            <button class="cancel-btn">취소</button>
          </div>
        `;

        // 이벤트 연결
        modal.querySelector('.confirm-btn').onclick = () => {
          options.onConfirm?.();
          modal.remove();
        };
        modal.querySelector('.cancel-btn').onclick = () => {
          options.onCancel?.();
          modal.remove();
        };
        break;

      case 'prompt':
        modal.innerHTML = `
          <div class="modal-content">
            <h2>${options.title || '입력'}</h2>
            <input type="text" placeholder="${options.placeholder || ''}" />
            <button class="submit-btn">확인</button>
            <button class="cancel-btn">취소</button>
          </div>
        `;

        const input = modal.querySelector('input');
        modal.querySelector('.submit-btn').onclick = () => {
          options.onSubmit?.(input.value);
          modal.remove();
        };
        modal.querySelector('.cancel-btn').onclick = () => {
          modal.remove();
        };
        break;
    }

    return modal;
  }
}

// 사용
const alertModal = ModalFactory.create('alert', {
  title: '저장 완료',
  message: '파일이 저장되었습니다.'
});
document.body.appendChild(alertModal);

const confirmModal = ModalFactory.create('confirm', {
  title: '삭제 확인',
  message: '정말 삭제하시겠습니까?',
  onConfirm: () => console.log('삭제!'),
  onCancel: () => console.log('취소!')
});
document.body.appendChild(confirmModal);

예제 2: Database Connection Singleton

// ✅ 실무: DB 연결 Singleton
const Database = (function() {
  let instance = null;

  function createConnection() {
    let isConnected = false;
    let connection = null;

    return {
      async connect() {
        if (isConnected) {
          console.log('이미 연결되어 있습니다.');
          return connection;
        }

        // 실제로는 DB 연결 로직
        console.log('데이터베이스 연결 중...');
        await new Promise(resolve => setTimeout(resolve, 1000));

        connection = {
          query: async (sql) => {
            console.log(`쿼리 실행: ${sql}`);
            // 실제 쿼리 실행...
            return { rows: [] };
          }
        };

        isConnected = true;
        console.log('연결 완료!');
        return connection;
      },

      async disconnect() {
        if (!isConnected) return;

        console.log('연결 종료 중...');
        await new Promise(resolve => setTimeout(resolve, 500));

        connection = null;
        isConnected = false;
        console.log('연결 종료!');
      },

      isConnected() {
        return isConnected;
      }
    };
  }

  return {
    getInstance() {
      if (!instance) {
        instance = createConnection();
      }
      return instance;
    }
  };
})();

// 여러 모듈에서 사용
// userService.js
const db1 = Database.getInstance();
await db1.connect();
await db1.query('SELECT * FROM users');

// productService.js
const db2 = Database.getInstance();
await db2.connect();  // 이미 연결됨! 재사용!
await db2.query('SELECT * FROM products');

console.log(db1 === db2);  // true

마무리

핵심 요약

Factory Pattern: “객체 생성의 공장”

  • 여러 타입의 객체를 생성할 때
  • 생성 로직을 한 곳에 모을 때
  • 확장 가능한 구조가 필요할 때

Singleton Pattern: “하나뿐인 존재”

  • 인스턴스가 하나만 필요할 때
  • 전역 상태를 관리할 때
  • 리소스를 공유해야 할 때

실제로 느낀 변화

Before (패턴 없이):
  - 객체 생성 코드가 여기저기 흩어짐
  - if-else 지옥
  - 설정 동기화 불가능
  - 테스트 어려움

After (패턴 적용):
  - 생성 로직이 한 곳에 모임
  - 확장 가능한 구조
  - 일관된 상태 관리
  - 코드가 훨씬 깔끔!

다음 단계

두 패턴을 마스터했다면:

  • Observer Pattern: 이벤트 기반 통신
  • Strategy Pattern: 알고리즘 교체
  • Decorator Pattern: 기능 확장

디자인 패턴은 문제 해결의 도구입니다. 패턴을 위한 패턴이 아니라, 실제 문제를 해결하기 위해 사용하세요!


Happy Coding! 🏭✨


참고 자료

관련 문서

외부 자료


작성일: 2025-10-20 주제: Design Patterns (Factory & Singleton) 난이도: Beginner to Intermediate

댓글