본문으로 건너뛰기

버그 제보

이상한 점을 알려 주시면 고칠게요. 지금 보고 있는 페이지와 브라우저 정보가 함께 담겨요.

페이지:

Computer Science

싱글턴 패턴

추가
목차12개 항목
출처와 참고

jbee의 「Interview Question for Beginner」 저장소 디자인 패턴 문서의 Singleton 절을 바탕으로, Java 메모리 모델 기준으로 확인해 재구성한 노트다. C++ 구현은 GitHub 자료 모음 「Subjective-studies_OS-CN-DBMS」의 OOP 면접 정리를 바탕으로 보강했다.

싱글턴이란

싱글턴(Singleton)은 클래스의 인스턴스를 프로그램 전체에서 하나만 만들고, 어디서나 그 하나에 접근하게 하는 생성 패턴이다. 커넥션 풀, 스레드 풀, 설정 객체, 로거처럼 여러 개 만들면 자원을 낭비하거나 상태가 갈라져 버그가 나는 객체에 쓴다. GoF 23개 패턴의 전체 지도는 GoF 디자인 패턴에 있다.

구현의 세 요소는 같다.

  1. 생성자를 private으로 막아 밖에서 new를 못 하게 한다.
  2. 유일한 인스턴스를 담을 static 필드를 둔다.
  3. 그 인스턴스를 돌려주는 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 상수를 하나만 만들도록 보장하고, 직렬화·역직렬화와 리플렉션 공격에도 두 번째 인스턴스가 생기지 않는다. 다른 클래스를 상속해야 하면 쓸 수 없다.

비교

방식스레드 안전지연 생성호출 비용비고
지연 초기화XO없음단일 스레드 전용
synchronized 메서드OO매번 잠금단순하지만 느리다
DCL + volatileOO생성 뒤 volatile 읽기volatile 필수, 실수하기 쉽다
이른 초기화OX없음생성 비용이 작으면 충분하다
홀더 클래스OO없음클래스 기반 싱글턴의 기본 선택
enumOX(클래스 로딩 때)없음직렬화·리플렉션까지 안전하다

직렬화되는 클래스 싱글턴은 역직렬화 때 새 객체가 생기므로 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 언어 기본, 프로세스 동기화

원본 기록2

댓글

GitHub 계정으로 댓글을 남길 수 있어요.