목차12개 항목
출처와 참고
jbee의 「Interview Question for Beginner」 저장소 디자인 패턴 문서의 Singleton 절을 바탕으로, Java 메모리 모델 기준으로 확인해 재구성한 노트다. C++ 구현은 GitHub 자료 모음 「Subjective-studies_OS-CN-DBMS」의 OOP 면접 정리를 바탕으로 보강했다.
싱글턴이란
싱글턴(Singleton)은 클래스의 인스턴스를 프로그램 전체에서 하나만 만들고, 어디서나 그 하나에 접근하게 하는 생성 패턴이다. 커넥션 풀, 스레드 풀, 설정 객체, 로거처럼 여러 개 만들면 자원을 낭비하거나 상태가 갈라져 버그가 나는 객체에 쓴다. GoF 23개 패턴의 전체 지도는 GoF 디자인 패턴에 있다.
구현의 세 요소는 같다.
- 생성자를
private으로 막아 밖에서new를 못 하게 한다. - 유일한 인스턴스를 담을
static필드를 둔다. - 그 인스턴스를 돌려주는
static메서드(getInstance())를 연다.
Java 구현 방식
1. 지연 초기화 (스레드 안전하지 않음)
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 두 스레드가 동시에 통과할 수 있다
instance = new Singleton();
}
return instance;
}
}두 스레드가 동시에 null 검사를 통과하면 인스턴스가 둘 생긴다. 단일 스레드에서만 맞다.
2. synchronized 메서드
getInstance()에 synchronized를 붙이면 안전하지만, 인스턴스가 이미 있는데도 호출할 때마다 잠금을 잡는다. 자주 불리면 병목이 된다.
3. DCL (Double-Checked Locking) + volatile
public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
Singleton local = instance;
if (local == null) { // 1차 검사: 잠금 없이
synchronized (Singleton.class) {
local = instance;
if (local == null) { // 2차 검사: 잠금 안에서
instance = local = new Singleton();
}
}
}
return local;
}
}인스턴스가 만들어진 뒤에는 잠금 없이 읽는다. volatile이 빠지면 틀린 코드다. new는 「메모리 할당 → 생성자 실행 → 참조 대입」인데, 컴파일러·CPU가 순서를 바꾸면 다른 스레드가 생성자가 덜 끝난 객체의 참조를 볼 수 있다. Java 5 이후의 메모리 모델에서 volatile은 쓰기 전의 동작이 읽는 쪽에 모두 보이도록(happens-before) 보장해 이 문제를 막는다. 지역 변수 local은 volatile 읽기 횟수를 줄이는 관용 표현이다.
4. 이른 초기화 (eager)
public class Singleton {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {}
public static Singleton getInstance() { return INSTANCE; }
}클래스 초기화는 JVM이 한 번만, 스레드 안전하게 한다. 가장 단순하지만 쓰지 않아도 클래스가 로딩되면 만들어진다.
5. 홀더 클래스 (Initialization-on-demand holder)
public class Singleton {
private Singleton() {}
private static class Holder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() { return Holder.INSTANCE; }
}Holder는 getInstance()가 처음 불릴 때 초기화된다. 지연 초기화 + 잠금 없음 + 스레드 안전을 클래스 로딩 규칙만으로 얻는다.
6. enum
public enum Singleton {
INSTANCE;
public void doWork() { }
}JVM이 enum 상수를 하나만 만들도록 보장하고, 직렬화·역직렬화와 리플렉션 공격에도 두 번째 인스턴스가 생기지 않는다. 다른 클래스를 상속해야 하면 쓸 수 없다.
비교
| 방식 | 스레드 안전 | 지연 생성 | 호출 비용 | 비고 |
|---|---|---|---|---|
| 지연 초기화 | X | O | 없음 | 단일 스레드 전용 |
| synchronized 메서드 | O | O | 매번 잠금 | 단순하지만 느리다 |
| DCL + volatile | O | O | 생성 뒤 volatile 읽기 | volatile 필수, 실수하기 쉽다 |
| 이른 초기화 | O | X | 없음 | 생성 비용이 작으면 충분하다 |
| 홀더 클래스 | O | O | 없음 | 클래스 기반 싱글턴의 기본 선택 |
| enum | O | X(클래스 로딩 때) | 없음 | 직렬화·리플렉션까지 안전하다 |
직렬화되는 클래스 싱글턴은 역직렬화 때 새 객체가 생기므로 readResolve()로 기존 인스턴스를 돌려줘야 한다. enum은 이 처리가 필요 없다.
C++ 구현: 함수 안 static
C++에서도 세 요소는 같다. 다만 C++11부터는 함수 안 static 지역 변수의 초기화를 언어가 스레드 안전하게 한 번만 해 주므로(magic statics), DCL 같은 장치 없이 짧게 끝난다. Scott Meyers가 소개해 Meyers 싱글턴이라 부른다.
class Logger {
public:
static Logger& instance() {
static Logger inst; // 처음 호출될 때 한 번만 생성. 동시에 불려도 안전하다
return inst;
}
Logger(const Logger&) = delete; // 복사로 두 번째 객체가 생기는 것을 막는다
Logger& operator=(const Logger&) = delete;
void log(std::string_view msg);
private:
Logger() = default; // 바깥에서 생성 금지
~Logger() = default; // 바깥에서 delete 금지. 프로그램 종료 때 자동 소멸
};
Logger::instance().log("start");- 생성자뿐 아니라 복사 생성자와 복사 대입도 지워야 한다. 빠뜨리면
Logger copy = Logger::instance();로 두 번째 인스턴스가 생긴다. 생성·소멸을private로 막는 의미는 private 생성자와 소멸자에 있다. - 포인터를
new로 만들어static Logger* inst에 두는 방식은 해제 시점이 없고 스레드 안전도 직접 챙겨야 해서 쓰지 않는다. - 함수 안
static객체는 프로그램이 끝날 때 생성의 역순으로 소멸한다. 다른 정적 객체의 소멸자가 이미 사라진 싱글턴을 부르면 문제가 되므로, 소멸자에서 다른 싱글턴에 기대지 않는다.
단점과 대안
싱글턴은 사실상 전역 상태다.
| 문제 | 이유 |
|---|---|
| 테스트가 어렵다 | getInstance()를 직접 부르는 코드는 가짜 객체로 바꿔 끼울 수 없고, 테스트끼리 상태가 샌다 |
| 의존이 숨는다 | 생성자·매개변수에 드러나지 않아 무엇에 기대는지 보이지 않는다 |
| 동시성 위험 | 하나뿐인 객체의 필드를 여러 스레드가 고친다 |
| 클래스로더·프로세스마다 하나 | 「하나」의 범위가 JVM 전체가 아니다. 여러 서버로 늘리면 각자 하나씩이다 |
그래서 실무에서는 클래스가 스스로 하나임을 지키기보다 DI 컨테이너가 하나만 만들어 주입하게 한다. Spring 빈의 기본 스코프가 싱글턴이고, 이때 빈을 상태 없이 짜는 법은 싱글톤 빈과 스레드 안전성에 있다. JavaScript·Python에서는 모듈이 한 번만 평가되므로 모듈 최상위에서 만든 객체가 곧 싱글턴이다(JavaScript 생성 패턴, Express 싱글톤 패턴).
정리
- 싱글턴 = private 생성자 + static 인스턴스 + static 접근 메서드
- 멀티스레드에서 단순 지연 초기화는 인스턴스가 둘 생길 수 있다
- DCL은
volatile없이는 반쯤 만들어진 객체를 노출한다 - 클래스로 만든다면 홀더 클래스, 직렬화까지 막으려면 enum이 기본 선택이다
- C++은 함수 안
static지역 변수로 만들고 복사 생성자·복사 대입을= delete로 지운다 - 테스트와 의존 관리를 위해 직접 구현보다 DI 컨테이너의 싱글턴 스코프를 먼저 고려한다
관련 노트: GoF 디자인 패턴, C++ 기초, 싱글톤 빈과 스레드 안전성, Java 언어 기본, 프로세스 동기화