Skip to content

싱글톤 패턴 이해하기

Published:

싱글톤 패턴이란?

싱글톤(Singleton) 패턴은 GoF(Gang of Four)가 정리한 23가지 디자인 패턴 가운데 생성(Creational) 카테고리에 속한다. 의도는 클래스 인스턴스를 단 한 번만 만들고, 그 인스턴스를 전역적으로 노출하는 것이다. 필요할 때까지 생성을 미루는 lazy initialization도 함께 구현할 수 있어 리소스 낭비를 줄인다.

GoF는 “네 명의 갱”이라는 뜻으로, 1994년 《Design Patterns: Elements of Reusable Object-Oriented Software》를 함께 집필한 다음 네 저자를 가리킨다.

이 책에서 소개된 23가지 패턴은 이후 소프트웨어 공학의 표준 참고서가 되었고, 생성·구조·행위 세 가지 카테고리로 분류된다.

주요 특징은 다음과 같다.

대표적으로 설정 값, DB 커넥션 풀, 로거(Logger), API 클라이언트처럼 공용 리소스를 다루는 서비스에 활용된다. 다만 전역 상태를 남발하면 의존성이 코드 곳곳에 숨고 테스트가 어려워져 최근에는 싱글톤을 안티패턴으로 분류하기도 한다. 대신 의존성 주입(Dependency Injection)이나 명시적인 팩토리 패턴을 선호하는 프로젝트도 많으므로 문제 성격에 맞춰 선택해야 한다.

언제 사용할까?

하지만 전역 접근은 곧 전역 상태를 의미하므로 남용하면 의존성 추적이 어렵고 테스트가 힘들어질 수 있다.

기본 구현 예시 (JavaScript)

class Database {
  constructor() {
    if (Database.instance) {
      return Database.instance;
    }

    this.connection = null;
    this.isConnected = false;
    Database.instance = this;
  }

  connect() {
    if (!this.isConnected) {
      this.connection = "DB Connection Established";
      this.isConnected = true;
      console.log("데이터베이스 연결됨");
    }
    return this.connection;
  }

  query(sql) {
    if (!this.isConnected) {
      throw new Error("데이터베이스가 연결되지 않았습니다");
    }
    console.log(`쿼리 실행: ${sql}`);
    return `${sql} 결과`;
  }
}

// 사용 예제
const db1 = new Database();
db1.connect();

const db2 = new Database();
db2.connect();

console.log(db1 === db2); // true - 같은 인스턴스!

생성자 내부에서 이미 만들어둔 인스턴스를 반환하도록 처리해 new Database()로 여러 번 호출하더라도 동일한 객체를 돌려받는다. 실제 환경에서는 연결 정보, 커넥션 풀 등을 여기에 초기화해 공용 DB 접속 로직을 통일한다. 서버리스나 워커 기반 환경처럼 실행 컨텍스트가 자주 새로 생기면 인스턴스가 다시 만들어질 수 있다는 점은 여전히 주의해야 한다.

프런트엔드 예시 (Redux)

프런트엔드 애플리케이션에서는 전역 상태 관리 라이브러리인 Redux 스토어가 대표적인 싱글톤 구현이다. 스토어를 한 번 생성한 뒤 애플리케이션 전역에서 동일한 인스턴스를 재사용한다.

// store.ts
import { configureStore } from "@reduxjs/toolkit";
import authSlice from "./features/authSlice";
import todoSlice from "./features/todoSlice";

const store = configureStore({
  reducer: {
    auth: authSlice,
    todo: todoSlice,
  },
});

export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
export default store;
// main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import App from "./App";
import store from "./store";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>
);

store.ts에서 스토어를 한 번만 구성해 내보내고, Provider 컴포넌트로 React 트리 전체에 주입한다. 모든 컴포넌트는 동일한 스토어 인스턴스를 바라보므로 전역 상태를 일관되게 관리할 수 있다.

장점

단점

현대적 대안 예시

싱글톤이 가진 제약을 줄이려면 의존성을 명시적으로 주입하거나, 상태 범위를 좁혀 관리하는 도구를 활용한다.

1. NestJS DI 컨테이너 활용

NestJS는 모듈 단위로 프로바이더를 등록하고, 컨테이너가 라이프사이클을 관리한다. 기본 스코프는 싱글톤이지만, 요청(Request) 또는 트랜지언트(Transient) 스코프로 바꿀 수 있어 테스트나 확장에 유연하다.

// logger.service.ts
import { Injectable, Scope } from "@nestjs/common";

@Injectable({ scope: Scope.REQUEST }) // 요청마다 새로운 인스턴스
export class RequestLogger {
  log(message: string) {
    console.log(`[request] ${message}`);
  }
}

// users.controller.ts
import { Controller, Get } from "@nestjs/common";
import { RequestLogger } from "./logger.service";

@Controller("users")
export class UsersController {
  constructor(private readonly logger: RequestLogger) {}

  @Get()
  findAll() {
    this.logger.log("사용자 목록 조회");
    return [];
  }
}

테스트에서는 DI 컨테이너에 목 프로바이더를 등록해 전역 상태에 의존하지 않고 독립적으로 검증할 수 있다.

2. React Query로 상태 범위 한정

React Query는 QueryClient를 생성해 QueryClientProvider로 감싸고, 해당 Provider 범위 안에서만 캐시와 상태가 공유되게 한다. 앱의 특정 영역만 별도 Provider로 분리하면 상태가 서로 영향을 주지 않는다.

// query-client.ts
import { QueryClient } from "@tanstack/react-query";

export const createQueryClient = () =>
  new QueryClient({
    defaultOptions: { queries: { staleTime: 60_000 } },
  });

// main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import { createQueryClient } from "./query-client";

const queryClient = createQueryClient();

ReactDOM.createRoot(document.getElementById("root")!).render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
);

하위 컴포넌트는 useQuery 훅으로 데이터를 불러오되, Provider가 감싸는 범위 안에서만 동일한 캐시를 바라본다. 전역 싱글톤 대신 “명시적인 컨텍스트”를 사용해 상태를 가둔 셈이다.