클래스의 인스턴스를 단 하나로 제한하는 가장 단순하면서도 가장 논쟁적인 생성 패턴.
올바른 구현 방법과 왜 남용하면 안 되는지를 깊이 탐구합니다.
2025년 3월읽기 시간 약 14분SingletonCreational PatternGoF
01
싱글턴 패턴이란?
싱글턴 패턴(Singleton Pattern)은 GoF(Gang of Four)가 정의한
23가지 디자인 패턴 중 생성 패턴(Creational Pattern)에 속하며,
특정 클래스의 인스턴스가 프로그램 전체에서 오직 하나만 존재하도록 보장하고,
그 단일 인스턴스에 전역 접근점을 제공하는 패턴입니다.
"Ensure a class only has one instance, and provide a global point of access to it."
— GoF, Design Patterns: Elements of Reusable Object-Oriented Software
싱글턴이 필요한 이유는 직관적입니다. 애플리케이션에서 하나만 존재해야 하는
자원이 있기 때문입니다. 설정(Configuration) 객체가 두 개라면 어느 쪽이
진짜인지 알 수 없고, 로그 파일에 여러 Logger가 동시에 쓴다면 출력이 뒤섞이며,
DB 커넥션 풀이 여러 개 생성되면 연결 수가 폭증합니다.
싱글턴 패턴은 두 가지 문제를 동시에 해결합니다. 첫째, 클래스가 인스턴스를
하나 이상 가지지 못하도록 생성을 통제합니다.
둘째, 해당 인스턴스에 어디서든 접근할 수 있는 전역 접근점을 제공합니다.
이 두 번째 특성이 나중에 살펴볼 문제의 핵심 원인이 됩니다.
📖
생성 패턴(Creational Pattern)이란? 객체 생성 메커니즘을 다루는 패턴으로,
상황에 맞는 방식으로 객체를 생성하려 합니다. GoF의 생성 패턴에는 싱글턴 외에
팩토리 메서드, 추상 팩토리, 빌더, 프로토타입 패턴이 있습니다.
모두 어떻게 객체를 생성할 것인가라는 문제를 다루며,
직접적인 객체 생성(new)이 초래하는 복잡성을 해소합니다.
02
구조 — UML 클래스 다이어그램
싱글턴 패턴의 구조는 GoF 패턴 중 가장 단순합니다. 클래스 하나와
자기 자신을 가리키는 정적 참조가 전부입니다.
«Singleton»
Singleton
FIELDS
−instance : Singletonstatic
−data : SomeState
CONSTRUCTOR
−Singleton()private
METHODS
+getInstance() : Singletonstatic
+businessLogic() : void
instance 필드가 자기 자신을 참조
↩Singleton *
구조의 핵심은 세 가지입니다. 첫째, 생성자가 private으로 선언되어
외부에서 new Singleton()을 호출할 수 없습니다.
둘째, 자기 자신 타입의 정적(static) 인스턴스 필드가 유일한 인스턴스를 보관합니다.
셋째, 공개된 정적 메서드 getInstance()가 인스턴스가 없으면
생성하고, 이미 있으면 그것을 반환하는 Lazy Initialization을 수행합니다.
03
기본 구현과 문제점
싱글턴의 가장 단순한 구현부터 시작해 무엇이 문제인지 살펴봅니다.
public classConfiguration {
// 1. 유일한 인스턴스를 보관할 정적 필드private staticConfiguration instance;
// 2. private 생성자 — 외부에서 new 불가privateConfiguration() {
loadFromFile("application.yml");
}
// 3. 전역 접근점public staticConfiguration getInstance() {
if (instance == null) { // ← 멀티스레드 환경에서 위험!
instance = newConfiguration();
}
return instance;
}
publicString get(String key) { ... }
}
// 사용 — 어디서든 같은 인스턴스Configuration config = Configuration.getInstance();
config.get("db.url");
이 구현은 단일 스레드 환경에서는 완벽히 동작하지만, 멀티스레드 환경에서
치명적인 경쟁 조건(Race Condition)이 발생합니다. 두 스레드가 동시에
getInstance()를 호출하고 둘 다 instance == null이라고
판단하면, 두 개의 인스턴스가 생성됩니다. 싱글턴의 핵심 불변식이 깨지는 것입니다.
// Thread-A와 Thread-B가 동시에 getInstance() 호출
Thread-A: instance == null → true// 조건 검사 통과
Thread-B: instance == null → true// 조건 검사 통과 (A가 아직 생성 안 함)
Thread-A: instance = newConfiguration() // 인스턴스 #1 생성
Thread-B: instance = newConfiguration() // 인스턴스 #2 생성 ← 싱글턴 깨짐!// 결과: 두 스레드가 서로 다른 인스턴스를 가짐// 설정 변경이 한쪽에만 반영되는 버그 발생
04
구현 변형 — 5가지 방식 비교
싱글턴의 스레드 안전 문제를 해결하기 위해 다양한 구현 방식이 등장했습니다.
각 방식의 특성과 적합한 상황을 비교합니다.
Eager Initialization
Thread-Safe
클래스 로드 시점에 즉시 인스턴스를 생성합니다. JVM이 클래스 초기화를 한 번만 수행하므로 별도 동기화 없이 스레드 안전합니다.
인스턴스가 없을 때만 동기화 블록에 진입해 성능 손실을 최소화합니다. volatile 키워드가 필수입니다.
적합한 경우Lazy 초기화와 성능을 모두 원할 때. Java 5 이상 필수
Initialization-on-Demand Holder
Thread-Safe
내부 정적 클래스를 활용해 JVM의 클래스 로딩 보장을 이용합니다. 동기화 없이도 Lazy + Thread-Safe를 달성합니다.
적합한 경우Java에서 싱글턴이 꼭 필요할 때 가장 권장되는 방식
Enum Singleton
Thread-Safe
Java enum은 JVM이 단 하나의 인스턴스만 생성함을 보장합니다. 직렬화·리플렉션 공격에도 안전한 완벽한 구현입니다.
적합한 경우Joshua Bloch 권장 방식. 직렬화 안전성이 필요한 경우
구현 방식
Thread-Safe
Lazy 초기화
성능
직렬화 안전
기본 구현
✗
✓
최고
✗
Eager Initialization
✓
✗
높음
✗
Synchronized Method
✓
✓
낮음
✗
Double-Checked Locking
✓
✓
높음
✗
Holder 패턴
✓
✓
최고
✗
Enum Singleton
✓
✗
높음
✓
05
스레드 안전 구현 심층 분석
실무에서 가장 많이 사용되는 세 가지 스레드 안전 구현을 코드와 함께 분석합니다.
Double-Checked Locking
인스턴스가 이미 생성된 이후에는 동기화 없이 빠르게 반환하고,
최초 생성 시에만 동기화 블록에 진입합니다.
volatile 키워드는 CPU 캐시가 아닌 메인 메모리에서 직접 읽도록 강제해
가시성(Visibility)을 보장합니다. Java 5 미만에서는 메모리 모델 문제로 동작이 보장되지 않습니다.
public classDatabaseConnectionPool {
// volatile: 변수 쓰기가 다른 스레드에 즉시 가시화됨private static volatileDatabaseConnectionPool instance;
privateDatabaseConnectionPool() {
initializePool();
}
public staticDatabaseConnectionPool getInstance() {
// 1차 검사: 이미 생성됐으면 동기화 없이 반환 (성능)if (instance == null) {
synchronized (DatabaseConnectionPool.class) {
// 2차 검사: 락 획득 후 다시 확인 (안전성)if (instance == null) {
instance = newDatabaseConnectionPool();
}
}
}
return instance;
}
}
Initialization-on-Demand Holder (권장)
JVM의 클래스 로딩 메커니즘을 활용합니다. 내부 Holder 클래스는
getInstance()가 처음 호출될 때 로드되며, 클래스 초기화는 JVM이
스레드 안전하게 한 번만 수행함을 보장합니다.
synchronized 키워드 없이도 Lazy 초기화와 Thread-Safety를 동시에 달성하는
우아한 구현입니다.
public classAppLogger {
privateAppLogger() {
configure();
}
// Holder 클래스는 getInstance()가 최초 호출될 때 로드됨private static classHolder {
// JVM이 클래스 초기화를 단 한 번, 스레드 안전하게 수행private static finalAppLogger INSTANCE = newAppLogger();
}
public staticAppLogger getInstance() {
returnHolder.INSTANCE; // 동기화 없이 안전
}
public void log(String message) { ... }
}
Enum Singleton (직렬화 안전)
Joshua Bloch가 Effective Java에서 권장한 방식입니다.
Java enum은 JVM이 직렬화·역직렬화, 리플렉션을 통한 인스턴스 추가 생성을
원천 차단합니다. 단, 상속이 불가하고 Lazy 초기화가 안 된다는 제약이 있습니다.
싱글턴은 "반드시 하나만 존재해야 하는" 자원을 표현할 때 적합합니다.
이 기준이 충족되지 않는다면 다른 패턴을 고려해야 합니다.
⚙️
설정 관리자
애플리케이션 전역 설정(DB URL, API Key 등)을 단일 진입점으로 관리. 설정 불일치를 방지.
📝
로거 (Logger)
로그 파일 핸들러를 하나로 유지. 여러 Logger가 동시에 파일에 쓰면 순서 보장이 깨짐.
🗄️
커넥션 풀
DB 커넥션 풀이 여러 개 생성되면 연결 수 제한을 초과할 수 있음. 하나의 풀에서 커넥션을 대여.
🧵
스레드 풀
ExecutorService를 전역으로 공유해 스레드 생성 비용을 최소화. 여러 풀이 생기면 리소스 낭비.
📦
레지스트리
객체나 서비스의 중앙 등록소. ServiceLocator, 플러그인 레지스트리 등.
🖥️
하드웨어 인터페이스
프린터 스풀러, 파일 시스템 등 물리적으로 하나인 자원에 대한 접근을 직렬화.
✅
싱글턴이 적합한지 판단하는 체크리스트:
① 이 객체가 진정으로 하나만 존재해야 하는 이유가 있는가?
② 복수의 인스턴스가 존재하면 실제 문제(데이터 불일치, 리소스 고갈)가 발생하는가?
③ 전역 상태가 아닌 공유 자원을 표현하는가?
세 질문 모두 "예"일 때만 싱글턴을 고려하세요.
07
싱글턴의 문제점
싱글턴은 단순해 보이지만, 잘못 사용하면 심각한 설계 문제를 일으킵니다.
이것이 싱글턴이 GoF 패턴 중 가장 비판받는 이유입니다.
문제 1 — 전역 상태로서의 숨겨진 의존성
getInstance()를 통한 접근은 의존성을 메서드 시그니처에서 숨깁니다.
코드를 읽는 사람은 해당 클래스가 싱글턴에 의존한다는 사실을 코드를 직접 뒤져봐야만 알 수 있습니다.
이는 암묵적 결합(Hidden Coupling)을 만들어냅니다.
// 나쁜 예: OrderService가 Configuration에 암묵적으로 의존public classOrderService {
public void processOrder(Order order) {
// 메서드 내부를 보기 전까지 Configuration 의존성을 알 수 없음String url = Configuration.getInstance().get("payment.url");
String key = Configuration.getInstance().get("payment.key");
paymentGateway.charge(url, key, order.getAmount());
}
}
// 좋은 예: 생성자 주입으로 의존성을 명시적으로 선언public classOrderService {
private finalConfiguration config; // ← 의존성이 명시적publicOrderService(Configuration config) {
this.config = config;
}
...
}
문제 2 — 단위 테스트의 적
싱글턴의 전역 상태는 테스트 간 격리를 파괴합니다. 테스트 A에서 싱글턴의 상태를 변경하면
테스트 B가 오염됩니다. Mock으로 교체하기도 어려워 단위 테스트가 사실상 통합 테스트가 됩니다.
// 싱글턴을 사용하는 코드는 테스트에서 Mock 교체가 불가classOrderServiceTest {
@Testvoid testProcessOrder() {
// Configuration.getInstance()가 실제 파일을 읽음// 테스트 환경에서 payment.url이 없으면 실패// Mock으로 교체할 방법이 없음 ← 문제!
orderService.processOrder(order);
}
@Testvoid testWithDifferentConfig() {
// 이전 테스트의 싱글턴 상태가 남아있어 오염 가능// 테스트 순서에 따라 결과가 달라지는 불안정한 테스트
}
}
문제 3 — 단일 책임 원칙(SRP) 위반
싱글턴은 자신의 본래 책임(비즈니스 로직)에 더해 자기 자신의 생명주기를 관리하는
책임까지 가집니다. 두 가지 책임을 동시에 가지므로 SRP를 위반합니다.
문제 4 — 리플렉션과 직렬화 공격
Java의 리플렉션(Reflection)을 통해 private 생성자를 강제로 호출하거나,
직렬화(Serialization)/역직렬화(Deserialization) 과정에서 새 인스턴스가 생성될 수 있습니다.
Enum Singleton을 제외한 모든 구현이 이 공격에 취약합니다.
08
싱글턴은 안티패턴인가?
"싱글턴은 안티패턴이다"라는 주장은 개발 커뮤니티에서 오랫동안 논쟁의 대상이었습니다.
양쪽 주장을 균형 있게 살펴봅니다.
✦ 패턴으로 보는 시각
진정한 단일 인스턴스 자원에는 여전히 적합
프레임워크 레벨(Spring Bean Scope)에서는 관리되어 문제 해소
Logger, Config 같은 인프라 레이어엔 실용적
단순성이 오버엔지니어링보다 나을 때도 있음
✦ 안티패턴으로 보는 시각
전역 상태는 대부분의 OOP 원칙에 위배
테스트 가능성을 심각하게 저하
실제로 "하나만 있어야 하는" 경우는 드묾
모듈화·마이크로서비스 환경에서 의미 퇴색
DI 컨테이너가 더 나은 해법을 제공
⚖️
균형 잡힌 결론: 싱글턴 자체가 악한 것이 아니라, 잘못된 맥락에서의
남용이 문제입니다. "전역 변수가 필요한데 클래스처럼 보이게 하고 싶다"는 이유로
싱글턴을 사용한다면 그것은 안티패턴입니다. 반면 DB 커넥션 풀이나 하드웨어 인터페이스처럼
진정으로 하나여야 하는 자원을 표현한다면 여전히 유효한 패턴입니다.
09
의존성 주입으로 대체하기
현대 애플리케이션에서 싱글턴의 대부분의 사용 사례는 의존성 주입(Dependency Injection, DI)
컨테이너로 더 우아하게 해결됩니다. Spring, Guice, Dagger 등의 DI 프레임워크는
Bean의 스코프를 통해 "싱글턴처럼 동작하되 테스트 가능한" 객체를 제공합니다.
// Spring의 @Component는 기본적으로 Singleton Scope// 클래스 자체는 순수한 POJO — 싱글턴 코드가 없음@Component// Spring이 하나만 생성해서 관리public classConfiguration {
private finalMap<String, String> props;
publicConfiguration(@Value("${config.path}") String path) {
this.props = loadFromFile(path);
}
publicString get(String key) { return props.get(key); }
}
// OrderService는 생성자 주입으로 의존성을 명시적으로 선언@Servicepublic classOrderService {
private finalConfiguration config;
publicOrderService(Configuration config) { // 생성자 주입this.config = config;
}
}
// 테스트: Mock으로 교체 가능 → 단위 테스트 용이classOrderServiceTest {
@Testvoid testProcessOrder() {
Configuration mockConfig = mock(Configuration.class);
when(mockConfig.get("payment.url")).thenReturn("http://mock-payment");
OrderService svc = newOrderService(mockConfig); // 주입!// 완전히 격리된 단위 테스트 가능
}
}
DI 컨테이너 방식의 핵심 차이점은 인스턴스의 단일성을 클래스 자체가 강제하지 않고
컨테이너가 관리한다는 점입니다. 덕분에 테스트 환경에서는 여러 인스턴스를 생성하거나
Mock으로 교체하는 것이 자유롭습니다.
10
언어별 싱글턴 구현
언어마다 언어 특성을 활용한 관용적 싱글턴 구현 방식이 있습니다.
Python — 모듈 수준 싱글턴
Python에서는 모듈 자체가 싱글턴입니다. 모듈 수준 변수와 함수를 사용하면
별도의 싱글턴 패턴 구현 없이 동일한 효과를 얻을 수 있습니다.
# 방법 1: 모듈 자체를 싱글턴으로 사용 (Python 관용구)# config.py
_config: dict = {}
defload(path: str) -> None:
global _config
_config = _parse_yaml(path)
defget(key: str) -> str:
return _config.get(key)
# 방법 2: __new__ 오버라이드classSingleton:
_instance = Nonedef__new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
# 방법 3: 메타클래스 (가장 우아)classSingletonMeta(type):
_instances = {}
def__call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
classConfiguration(metaclass=SingletonMeta):
pass
Kotlin — object 키워드
Kotlin은 object 키워드로 싱글턴을 언어 수준에서 지원합니다.
별도의 보일러플레이트 없이 스레드 안전한 싱글턴이 선언됩니다.
// Kotlin: object 선언 = 즉시 싱글턴. 추가 코드 불필요.objectConfiguration {
private val props = mutableMapOf<String, String>()
funload(path: String) { props.putAll(parseYaml(path)) }
funget(key: String) = props[key]
}
// Lazy 초기화가 필요한 경우 — companion object + lazyclassDatabasePoolprivate constructor() {
companion object {
val instance: DatabasePoolbylazy { DatabasePool() }
}
}
TypeScript / JavaScript
// ES Module은 기본적으로 싱글턴 — 같은 모듈을 import해도 한 번만 실행// config.tsclassConfiguration {
private static instance: Configuration;
private props = newMap<string, string>();
private constructor() {}
static getInstance(): Configuration {
if (!Configuration.instance) {
Configuration.instance = newConfiguration();
}
returnConfiguration.instance;
}
get(key: string): string | undefined { returnthis.props.get(key); }
}
// 더 간단한 방법: 모듈 레벨 인스턴스 exportexport const config = newConfiguration(); // 모듈당 한 번 실행
11
결론
싱글턴은 GoF 디자인 패턴 중 가장 단순하지만, 역설적으로 가장 잘못 사용되는 패턴입니다.
구현 코드 몇 줄이면 되는 단순함이 오히려 "모든 것을 싱글턴으로" 만드는 남용을
부추깁니다. 결국 싱글턴 남용으로 가득한 코드베이스는 전역 상태의 지뢰밭이 되고,
테스트와 리팩터링이 두려운 레거시로 전락합니다.
올바른 접근은 이렇습니다. 진정으로 하나만 존재해야 하는 자원
— DB 커넥션 풀, 하드웨어 드라이버, 운영체제 레벨의 자원 — 에는 싱글턴을,
애플리케이션 레이어의 서비스나 컴포넌트에는 DI 컨테이너의 싱글턴 스코프를
사용하는 것입니다. 전자는 클래스가 자신의 단일성을 스스로 강제할 이유가 있지만,
후자는 컨테이너에게 그 책임을 위임하는 것이 테스트 가능성과 유지보수성을 위해 훨씬 낫습니다.
싱글턴을 사용하기 전에 항상 물어보세요. "이 클래스가 스스로 자신의
단일성을 강제해야 하는가, 아니면 그것은 호출자의 책임인가?" 대부분의 경우
답은 후자입니다. 그리고 그 때 DI 컨테이너는 이미 더 나은 해법을 제공하고 있습니다.
댓글