본문 바로가기
TYLER SONGBlog
← Blog index
CS학습6 min read

Singleton: 인스턴스를 하나로 제한하고 전역 접근점을 제공하는 생성 패턴

싱글턴(Singleton)은 클래스의 인스턴스가 프로세스 안에서 단 하나만 존재하도록 보장하고, 그 인스턴스에 접근하는 지점을 제공하는 생성 패턴(Creational Pattern)이다. 이 글은 TypeScript의 모듈 캐시를 이용한 구현, 클래스 기반 구현, 비동기 초기화 문제를 다룬다. 전역 상태로 인한 테스트 어려움과 서버리스·다중 프로세스 환경의

By 송민성

1. 개념

싱글턴(Singleton)은 특정 클래스의 인스턴스가 하나만 만들어지도록 제한하고, 어디서든 그 인스턴스를 얻을 수 있는 접근점을 제공하는 생성 패턴(Creational Pattern)이다. GoF(Gang of Four)의 디자인 패턴 중 하나다.

구성 요소는 세 가지다.

  • 외부에서 new로 마음대로 생성하지 못하게 막는 장치(비공개 생성자)
  • 유일한 인스턴스를 보관하는 저장 위치(정적 필드 또는 모듈 변수)
  • 인스턴스를 반환하는 접근 메서드(getInstance() 등)

여기서 "하나"의 범위는 프로세스(process) 단위다. 서버가 여러 대이거나 프로세스가 여러 개이면 각각 하나씩 존재한다.

2. 왜 사용하는가

  • 자원 공유: 데이터베이스 커넥션 풀(Connection Pool), 로거(Logger), 설정(Config)처럼 여러 곳에서 같은 객체를 써야 하는 경우
  • 생성 비용 절감: 만드는 데 비용이 큰 객체를 한 번만 만들고 재사용
  • 일관성 보장: 같은 설정·상태를 모든 호출부가 동일하게 본다

다만 "전역 접근"이 가능하다는 점은 장점이면서 동시에 단점의 원인이다(7장 참조). 단순히 편해서 쓰기보다 "정말 하나여야 하는가"를 먼저 따져야 한다.

3. 동작 원리

클래스 기반 구현

  1. 생성자를 private으로 선언해 외부 생성을 막는다.
  2. 정적 필드에 인스턴스를 저장한다.
  3. getInstance()가 호출되면 인스턴스가 없을 때만 생성하고, 이후에는 저장된 것을 반환한다. 이를 지연 초기화(Lazy Initialization)라 한다.

모듈 캐시를 이용한 구현

Node.js의 모듈 시스템은 한 모듈을 처음 require/import할 때 평가(evaluate)한 뒤 결과를 캐시한다. 같은 프로세스에서 같은 경로로 다시 가져오면 캐시된 같은 객체를 받는다. 따라서 모듈 최상위에서 객체를 만들어 export하면 별도 장치 없이 싱글턴이 된다. TypeScript에서는 이 방식이 가장 단순하고 흔하다. 단, 모듈 경로가 달라 같은 파일이 다른 모듈로 해석되거나 번들러가 코드를 중복 포함하면 인스턴스가 둘 이상 생길 수 있다.

멀티스레드 환경

Java나 C++처럼 스레드가 병렬로 실행되는 언어에서는 두 스레드가 동시에 getInstance()를 호출해 인스턴스가 둘 생기는 경쟁 조건(race condition)이 발생할 수 있어 동기화가 필요하다. Node.js의 JavaScript 코드는 단일 스레드 이벤트 루프에서 실행되므로 동기 코드에서는 이 문제가 없다. 그러나 비동기 초기화에서는 await 지점에서 다른 호출이 끼어들 수 있어 비슷한 문제가 생긴다.

4. 코드 예제

4-1. 클래스 기반 싱글턴

typescript
class Logger { private static instance: Logger | null = null; private readonly logs: string[] = []; private constructor() { console.log("Logger 생성"); } static getInstance(): Logger { if (Logger.instance === null) { Logger.instance = new Logger(); } return Logger.instance; } log(message: string): void { this.logs.push(message); console.log(`[LOG] ${message}`); } get count(): number { return this.logs.length; } } const a = Logger.getInstance(); const b = Logger.getInstance(); a.log("첫 번째 메시지"); b.log("두 번째 메시지"); console.log(a === b); // true console.log(a.count); // 2

"Logger 생성"은 한 번만 출력된다.

4-2. 모듈 캐시를 이용한 싱글턴

typescript
// config.ts export interface Config { readonly env: string; readonly port: number; } export const config: Config = Object.freeze({ env: process.env.NODE_ENV ?? "development", port: Number(process.env.PORT ?? 3000), });
typescript
// main.ts import { config } from "./config"; console.log(config.env, config.port);

어느 파일에서 import하든 같은 프로세스 안에서는 같은 config 객체를 받는다.

4-3. 비동기 초기화: Promise를 캐시한다

typescript
class Database { private static instancePromise: Promise<Database> | null = null; private constructor(readonly url: string) {} private static async connect(url: string): Promise<Database> { await new Promise((resolve) => setTimeout(resolve, 100)); // 연결 대기 흉내 console.log("DB 연결됨"); return new Database(url); } static getInstance(): Promise<Database> { if (Database.instancePromise === null) { Database.instancePromise = Database.connect("postgres://localhost/app"); } return Database.instancePromise; } } async function main() { const [x, y] = await Promise.all([ Database.getInstance(), Database.getInstance(), ]); console.log(x === y); // true } main();

인스턴스가 아니라 Promise 자체를 저장하는 것이 핵심이다. 인스턴스를 await 이후에 대입하면 그 사이에 들어온 두 번째 호출이 또 연결을 만든다.

5. 시간 복잡도 또는 성능 특성

  • getInstance()는 null 검사 후 참조를 반환하므로 호출당 시간복잡도는 O(1)이다.
  • 생성 비용은 최초 한 번만 든다. 지연 초기화를 쓰면 첫 호출 시점에, 모듈 최상위 방식은 모듈 로드 시점에 비용이 발생한다.
  • 모듈 로드 시점 초기화는 애플리케이션 시작을 늦출 수 있고, 지연 초기화는 첫 요청의 응답 시간을 늘릴 수 있다. 어느 쪽이 나은지는 워크로드에 따라 다르다.
  • 멀티스레드 언어에서 락(lock)으로 보호하면 경합이 생길 수 있으므로, 초기화 시점에만 동기화하도록 설계하는 것이 일반적이다.

6. 실무 사용 사례

  • 설정 객체: 환경변수를 한 번 읽어 고정한 뒤 앱 전체에서 공유
  • 로거: 출력 대상과 레벨을 한곳에서 관리
  • DB 커넥션 풀: 요청마다 연결을 만들지 않고 풀 하나를 공유. pg의 Pool 같은 객체를 모듈에서 한 번 만들어 내보내는 방식이 흔하다.
  • 캐시 클라이언트: Redis 클라이언트 등을 프로세스당 하나로 유지
  • DI 컨테이너의 싱글턴 스코프: NestJS, Spring 같은 프레임워크는 직접 패턴을 구현하지 않고 컨테이너가 "인스턴스 하나만 관리"하도록 위임한다. 이 방식이 테스트하기에도 유리하다.

7. 주의할 점

  • 전역 상태(global state): 어디서든 값을 바꿀 수 있어 변경 추적이 어렵고, 테스트 사이에 상태가 새어 나간다. 테스트마다 초기화하는 reset() 메서드를 두거나 의존성 주입(DI)으로 대체하는 것이 좋다.
  • 숨은 의존성: 함수 시그니처에 드러나지 않은 채 내부에서 getInstance()를 호출하면 의존 관계가 코드만 봐서는 보이지 않는다. 가능하면 인스턴스를 인자로 전달받는다.
  • 다중 프로세스·서버리스: 클러스터 모드, 여러 서버, 서버리스 함수에서는 프로세스마다 인스턴스가 생긴다. "전체 시스템에서 하나"가 필요하면 DB나 분산 락 같은 외부 수단이 필요하다.
  • 가변 상태 공유: 싱글턴이 요청별 데이터(사용자 정보 등)를 필드에 저장하면 동시 요청 간에 값이 섞인다. 싱글턴에는 요청 단위 상태를 두지 않는다.
  • 비동기 초기화 경쟁: 4-3처럼 Promise를 캐시하지 않으면 중복 생성이 일어난다. 초기화가 실패했을 때 실패한 Promise가 영구히 캐시되지 않도록 실패 시 null로 되돌리는 처리도 고려한다.
  • 모듈 중복 로드: 번들링이나 패키지 버전 중복으로 같은 모듈이 두 번 포함되면 싱글턴이 깨질 수 있다.

8. 핵심 정리

  • 싱글턴은 프로세스 안에서 인스턴스를 하나로 제한하고 접근점을 제공하는 생성 패턴이다.
  • TypeScript/Node.js에서는 모듈 캐시를 활용한 모듈 최상위 export가 가장 간단한 구현이다.
  • 비동기 초기화가 필요하면 인스턴스가 아니라 Promise를 캐시한다.
  • getInstance() 호출은 O(1)이며, 비용은 최초 생성 시점에 한 번 발생한다.
  • 전역 상태와 숨은 의존성이 가장 큰 단점이므로, 가능하면 DI 컨테이너의 싱글턴 스코프처럼 생명주기를 외부에서 관리하는 방식을 우선 고려한다.
  • "하나"는 프로세스 단위이며, 다중 서버·서버리스 환경에서는 시스템 전체의 유일성을 보장하지 않는다.