GoF 23 패턴 중 가장 단순하고 가장 자주 가르치는 첫 패턴이다. 동시에 실무에서는 가장 자주 안티패턴으로 분류된다. 같은 패턴을 두고 “기초” 와 “안티” 가 동시에 붙는 이유는 패턴 자체에 있지 않다. Singleton 이 두 가지 의도를 한 패턴에 묶었기 때문이다.
단일 인스턴스 보장과 전역 접근. 두 의도가 같이 묶이는 순간 강한 결합과 테스트 어려움이 따라온다. dependency-injection 글에서 다룬 DI 가 등장한 배경과 그대로 이어진다.
GoF 의도
GoF 는 Singleton 을 “단일 인스턴스만 존재하도록 보장하고 그 인스턴스에 전역 접근점을 제공한다” 로 정의한다. 두 가지 보장이 한 묶음이다.
- 단일 인스턴스 보장 — 자원 라이프사이클의 결정. 프로세스 안에서 하나만 존재해야 하는 자원이 있다는 도메인 사실이다.
- 전역 접근 — 의존성 표현의 결정. 클라이언트가 인스턴스를 어떻게 가져오는가 (생성자 주입 / 메서드 호출 / 전역 참조) 의 선택이다.
두 결정은 본래 별개의 축이다. Singleton 은 둘을 한 패턴에 묶어 단순함을 얻었다. 그 단순함이 안티패턴 논란의 출발점이기도 하다.
언어별 구현
언어마다 동시성 안전성을 다르게 다룬다.
Java 는 네 가지 구현이 흔하다.
- Eager initialization — 클래스 로딩 시점에 인스턴스 생성. 가장 단순하지만 사용 안 해도 메모리 점유
- Lazy initialization — 첫 호출 시점에 생성. 동시성 안전성 직접 처리 필요
- DCL (Double-Checked Locking) —
volatile+ 두 단계 null 체크로 동시성 + 지연 초기화.volatile누락 시 깨지는 미묘함 - Initialization-on-demand Holder — 내부 정적 클래스로 JVM 의 클래스 로딩 보장을 활용. DCL 의 미묘함 없이 같은 효과
Python 은 두 방식이 자주 쓰인다.
- 모듈 — 모듈 자체가 import 시점에 단 한 번 로딩되어 자연스러운 Singleton. 가장 일반적인 방식
- 메타클래스 —
__call__을 재정의해서 인스턴스 생성을 제어. 클래스 계층이 깊을 때 활용
TypeScript / JavaScript 는 모듈 시스템이 자체적으로 단일 인스턴스를 보장한다. ESM 의 모듈 캐시가 같은 모듈을 한 번만 로딩하기 때문에 export 된 객체가 그대로 Singleton 이 된다. class 의 static getInstance() 패턴도 가능하지만 모듈 export 가 더 자연스럽다.
동시성 안전성 측면에서는 JVM 기반 (Java/Kotlin) 이 가장 까다롭고, Python 의 GIL 과 JavaScript 의 단일 스레드 모델은 같은 고민이 줄어든다.
안티패턴 이유
Singleton 이 안티패턴으로 분류되는 이유는 네 가지 문제로 정리된다.
- 전역 상태 — 어디서든 접근 가능한 상태는 변경 추적이 어렵다. A 모듈에서 상태를 바꾸면 B 모듈에서 보이는 값이 함께 바뀐다. 추적 범위가 코드베이스 전체로 넓어진다.
- 테스트 어려움 — 단위 테스트는 격리된 상태에서 실행되어야 한다. 전역 인스턴스는 테스트 간에 공유되어 한 테스트의 변경이 다른 테스트에 영향을 준다. mock 으로 교체하기도 까다롭다 — 호출자가 구체 클래스를 직접 참조하기 때문에 인터페이스 단위로 끊을 지점이 없다.
- 강한 결합 —
Logger.getInstance()같은 호출은 호출자가Logger라는 구체 클래스를 안다는 뜻이다. 구현 교체 (다른 로거 라이브러리로 이동) 가 호출자 전체를 건드린다. - 라이프사이클 통제 불가 — Singleton 은 처음 호출되는 시점에 생성되고 프로세스가 끝날 때까지 살아있다. 명시적 해제나 재생성 시점을 호출자가 결정할 수 없다.
네 가지가 함께 작동하면 코드베이스는 시간이 지날수록 변경에 약해진다. 한 모듈에서 시작한 변경이 어디까지 영향을 미치는지 알기 어렵다.
DI 와의 대조
DI 컨테이너는 Singleton 의 두 의도를 분리한다.
단일 인스턴스 보장은 컨테이너의 scope 설정으로 표현된다. Spring 의 @Scope("singleton"), NestJS 의 기본 provider scope 가 같은 효과를 제공한다. 컨테이너가 인스턴스를 한 번만 생성하고 의존성이 필요한 곳에 같은 인스턴스를 주입한다.
전역 접근은 의존성 주입으로 대체된다. 클라이언트는 생성자 매개변수로 의존성을 받고, 어떻게 얻는지는 모른다. 구현 교체는 컨테이너 설정 한 줄로 끝나고, 테스트에서는 mock 을 주입해서 격리된 환경을 만든다.
두 의도가 분리되면 단일 인스턴스의 이점은 그대로 살리면서 전역 접근의 비용은 사라진다. dependency-injection 글에서 다룬 DIP 가 이 분리를 가능하게 한 추상이다.
적합한 경우
DI 가 일반 대안이라고 해서 Singleton 이 모든 경우에 안티는 아니다. 좁은 경우에서는 여전히 적합하다.
- 스레드 풀, 커넥션 풀 — 프로세스 단위로 하나만 존재해야 하는 자원. 풀 자체를 여러 개 만들면 자원 경합이 생긴다.
- 로거 — 출력 채널을 일관되게 관리해야 한다. 인스턴스가 여러 개면 출력 순서나 포맷이 흐트러진다.
- 설정 로더 — 프로세스 시작 시 한 번 읽어서 메모리에 둔다. 인스턴스가 여러 개면 같은 값을 중복 로딩하거나 일관성이 깨진다.
- 캐시 — 프로세스 안에서 공유되어야 의미가 있다. 인스턴스마다 다른 캐시면 캐시 효과 자체가 사라진다.
공통점은 두 가지다. 첫째, 라이프사이클이 프로세스 수명과 같다. 인스턴스를 늘리거나 줄일 이유가 없다. 둘째, 상태가 본질이다. 같은 자원을 여러 곳에서 공유해야 의미가 있다. 외부에서 주입받는 방식은 오히려 복잡도를 늘린다.
이런 경우에도 DI 컨테이너의 singleton scope 로 표현하는 게 더 유연하다. 단 컨테이너가 없는 환경 (간단한 스크립트, CLI 도구, 임베디드) 에서는 Singleton 의 직접 구현이 비용이 더 낮다.
결론
Singleton 자체가 안티가 아니라 단일 인스턴스 보장과 전역 접근을 한 패턴에 묶은 결정이 안티의 원인이다. 두 의도를 분리하면 단일 인스턴스의 이점은 살리면서 강한 결합과 테스트 어려움을 피할 수 있다. DI 가 그 분리를 일반화한 도구다.
선택은 두 가지 질문으로 좁혀진다.
- DI 컨테이너가 있는 환경인가. 있다면 singleton scope 로 두 의도를 분리하는 게 첫 선택이다.
- 분리가 오히려 비용인 좁은 경우인가. 컨테이너 없는 환경에서 라이프사이클이 프로세스와 같고 상태가 본질인 자원이라면 Singleton 의 직접 구현이 자연스럽다.
가장 단순한 패턴이지만 적용 결정이 가장 미묘한 패턴이기도 하다.
참고
- Dependency Injection — DIP, IoC, DI의 위계 — DI 가 Singleton 대안인 맥락
- Misko Hevery — Singletons are Pathological Liars
- GoF — Design Patterns: Elements of Reusable Object-Oriented Software (1994)