싱글톤 클래스를 작성하지 마세요: ES6 모듈 사용 (TypeScript 방식)

발행: (2025년 12월 26일 오후 09:44 GMT+9)
8 min read
원문: Dev.to

Source: Dev.to

If you come from an Object‑Oriented background (Java / C# /etc.), you are probably familiar with the Singleton Pattern – a class that can have only one instance and provides a global point of access to it.

전통적인 JavaScript에서는 정적 메서드를 가진 클래스나 getInstance() 함수를 사용해 이를 흉내 내곤 합니다.
하지만 그와 같은 보일러플레이트가 필요하지 않습니다.

JavaScript는 이미 네이티브 싱글톤 메커니즘을 제공합니다: ES6 모듈.

Why ES6 Modules Behave as Singletons

모듈이 import될 때, 한 번만 실행됩니다. 내보낸 값은 JavaScript 엔진에 의해 캐시되므로, 이후의 모든 import는 같은 인스턴스를 받습니다.

클래식 싱글톤 (Verbose)

// LoggerClass.ts
class Logger {
  private static instance: Logger;
  private logs: string[] = [];

  private constructor() {} // 직접 인스턴스화를 방지

  public static getInstance(): Logger {
    if (!Logger.instance) {
      Logger.instance = new Logger();
    }
    return Logger.instance;
  }

  public log(message: string): void {
    this.logs.push(message);
    console.log(`LOG: ${message}`);
  }

  public getCount(): number {
    return this.logs.length;
  }
}

export default Logger.getInstance();

단일 인스턴스를 보장하고 타입 안전성을 확보하기 위해 필요한 추가 코드에 주목하세요.

ES6 모듈 싱글톤 (클린 & 타입드)

// LoggerModule.ts

// 1️⃣ Private state (scoped to this file)
const logs: string[] = [];

// 2️⃣ Exported functions (public API)
export const log = (message: string): void => {
  logs.push(message);
  console.log(`LOG: ${message}`);
};

export const getCount = (): number => logs.length;

그게 전부입니다 – 클래스도 없고, new도 없고, static 멤버도 없고, private 키워드도 없습니다. 파일 스코프가 자연스럽게 프라이버시를 제공합니다.

구현 내부에서 일어나는 일

단계엔진이 수행하는 작업
첫 번째 import모듈을 실행하고 logs를 할당합니다.
캐싱모듈 인스턴스를 내부 캐시에 저장합니다.
후속 import캐시된 인스턴스를 반환합니다 – 동일한 logs 배열.

실제 예시: 전역 카운터 서비스

전역 카운터를 증가, 읽기, 그리고 구독할 수 있는 작은 서비스를 만들어 보겠습니다. 리스너 타입은 엄격한 타입을 보장합니다.

// services/CounterService.ts

// 👇 리스너 형태
type Listener = (count: number) => void;

// 🔒 프라이빗 상태
let count = 0;
let listeners: Listener[] = [];

// 모든 리스너에게 알리는 헬퍼
const notify = (): void => {
  listeners.forEach((listener) => listener(count));
};

// 📤 공개 API
export const increment = (): void => {
  count += 1;
  notify();
};

export const getValue = (): number => count;

export const subscribe = (listener: Listener): (() => void) => {
  listeners.push(listener);

  // 정리를 위한 구독 해제 함수 반환
  return () => {
    listeners = listeners.filter((l) => l !== listener);
  };
};

카운터를 수정하는 컴포넌트

// components/CounterButton.tsx
import React from 'react';
import { increment, getValue } from '../services/CounterService';

const CounterButton: React.FC = () => (
  <div>
    {/* Component A (Modifier) */}
    <div>Initial Value Load: {getValue()}</div>
    <button onClick={increment}>Increment Global Count</button>
  </div>
);

export default CounterButton;

카운터를 관찰하는 컴포넌트

// components/CounterDisplay.tsx
import React, { useState, useEffect } from 'react';
import { getValue, subscribe } from '../services/CounterService';

const CounterDisplay: React.FC = () => {
  // 현재 싱글톤 값을 초기 상태로 설정
  const [count, setCount] = useState(getValue());

  useEffect(() => {
    // `subscribe`는 구독 해제 함수를 반환합니다 – 정리에 안성맞춤
    const unsubscribe = subscribe((newCount) => setCount(newCount));
    return unsubscribe;
  }, []);

  return (
    <div>
      {/* Component B (Observer) */}
      <p>Watching singleton updates…</p>
      <h2>{count}</h2>
    </div>
  );
};

export default CounterDisplay;

Listener(count: number) => void 로 타입이 지정되어 있기 때문에, 컴포넌트가 잘못된 시그니처(예: 문자열을 기대)로 구독을 시도하면 TypeScript가 오류를 발생시킵니다.

이 패턴을 사용할 때

  • API 클라이언트 – 인터셉터가 포함된 단일 Axios 인스턴스.
  • WebSocket 연결 – 화면 간에 공유되는 하나의 활성 소켓.
  • Feature Flags – 기능이 활성화되었는지 확인하는 간단한 스토어.

참고: 이것은 복잡한 UI 상태를 위한 상태‑관리 라이브러리(Redux, Zustand, Context API)를 대체하는 것이 아닙니다. 유틸리티 로직 및 단일‑목적 서비스에 적합합니다.

Performance Benefits

Cleaner code는 훌륭하지만, 가장 강력한 논거는 성능입니다.

Modern bundlers (Webpack, Rollup, Vite) perform tree‑shaking – they drop unused code from the final bundle.

The Class Problem

// Traditional Class Import
import Logger from './LoggerClass';

// You only use .log(), but .getCount(), .reset(), .debug() …

클래스가 단일 export이기 때문에, 번들러는 하나만 사용하더라도 전체 클래스(모든 메서드)를 포함해야 하므로 번들이 커집니다.

module‑function 접근법을 사용하면, 각 export된 함수가 사용되지 않을 경우 개별적으로 제거될 수 있어, 더 작고 효율적인 번들을 만들 수 있습니다.

TL;DR

  • ES6 모듈은 설계상 싱글톤이며, 추가적인 보일러플레이트가 필요 없습니다.
  • 모듈 범위 변수와 내보낸 함수를 사용하여 깔끔하고 타입‑안전한 싱글톤을 구현합니다.
  • 공유 유틸리티, API 클라이언트, 소켓, 그리고 간단한 스토어에 이상적입니다.
  • 이점: 코드 감소, 향상된 타입 안전성, 그리고 더 작은 프로덕션 번들.
// Logger Example
// Original code
Logger.log('Hello');

The Module Solution

import { log } from './LoggerModule';

// The bundler sees that `getCount` is never imported.
// It removes it from the final JavaScript bundle.
log('Hello');

클래스 기반 싱글톤에서 ES6 모듈로 전환할 때의 장점

  • 단순성 – 보일러플레이트, 새로운 키워드, 정적 메서드가 없습니다.
  • 안전성 – 파일 스코프 변수를 통한 진정한 프라이빗 상태.
  • 성능 – 세분화된 임포트로 트리 쉐이킹이 향상되어 번들 크기가 작아집니다.

따라서 React나 TypeScript에서 싱글톤을 사용하려 할 때는, 아마도 모듈 하나만 있으면 충분하다는 점을 기억하세요.

Back to Blog

관련 글

더 보기 »

🔑 React에서 useId란 무엇인가?

useId는 React의 Hook으로, 버전 18부터 사용할 수 있으며 컴포넌트 내부에서 고유하고 안정적인 식별자를 생성합니다. 이 Hook은 보장된 문자열을 반환합니다.