본문으로 건너뛰기
  1. Posts/

Singleton, 그냥 구현하면 안 되나요?

·5 분
NineKoo9
작성자
NineKoo9
목차

이 문서는 의문을 가지고 있던 지점을 AI 에이전트와 대화하며 정리한 메모입니다. 멀티스레드 환경에서 Singleton 구현이 왜 깨질 수 있는지, Holder 패턴과 enum 방식, Spring singleton 개념을 함께 정리합니다.

Singleton, 그냥 구현하면 안 되나요?
#


“그냥 null 체크하면 되는 거 아닌가?”
#

Singleton을 처음 배우면 대부분 이렇게 구현합니다.

public class Settings {
    private static Settings instance;

    private Settings() { }

    public static Settings getInstance() {
        if (instance == null) {
            instance = new Settings();
        }
        return instance;
    }
}

논리적으로는 완벽해 보입니다.
단일 스레드라면 instance가 없을 때 만들고, 있으면 그걸 반환하니 인스턴스는 하나만 생기는 것처럼 보입니다.

그런데 이 코드는 멀티스레드 환경에서는 Singleton을 보장하지 못합니다.


어디서 문제가 생기는 걸까?
#

Thread A와 Thread B가 동시에 getInstance()를 호출하는 상황을 생각해봅시다.

Thread A → if (instance == null) 체크 → null이다, true!
Thread B → if (instance == null) 체크 → null이다, true!  ← 동시에 통과
Thread A → instance = new Settings() 실행
Thread B → instance = new Settings() 실행  ← 두 번째 인스턴스 생성!

두 스레드가 동시에 null 체크를 통과하면 각자 인스턴스를 만들어버립니다.
공유 필드 instance에 대한 동기화가 없기 때문에 Singleton 보장이 깨질 수 있는 거죠.


“그럼 synchronized 걸면 되지 않나요?”
#

맞습니다. 동기화하면 Thread-safe해집니다.

public static synchronized Settings getInstance() {
    if (instance == null) {
        instance = new Settings();
    }
    return instance;
}

하지만 이 방식은 getInstance()모든 호출이 같은 monitor를 거치게 됩니다.
인스턴스가 이미 만들어진 이후에도 매번 동기화 경로를 통과해야 하므로, 호출이 많고 경합이 있으면 불필요한 contention이 생길 수 있습니다.


“그럼 필요한 순간에만 락을 걸면 어떨까?”
#

그 아이디어가 Double-Checked Locking입니다.

public static Settings getInstance() {
    if (instance == null) {                 // 1차 체크 — 락 없음
        synchronized (Settings.class) {
            if (instance == null) {         // 2차 체크 — 락 있음
                instance = new Settings();
            }
        }
    }
    return instance;
}

인스턴스가 생성된 이후엔 락을 걸지 않으니 호출 경로는 가벼워집니다.
하지만 이 코드도 volatile 없이는 안전하지 않습니다.

Java 메모리 모델은 동기화되지 않은 공유 읽기/쓰기에 대해 예상 밖의 실행을 허용합니다.
객체 생성은 개념적으로 다음 순서로 생각할 수 있습니다.

1. 메모리 공간 할당
2. 생성자 실행
3. instance 변수에 참조 공개

volatile 없이 DCL을 쓰면 instance에 대한 쓰기와 이후 읽기 사이에 필요한 happens-before 관계가 없어,
다른 스레드가 생성이 완전히 끝나기 전의 참조를 관찰하는 실행이 허용될 수 있습니다.

이를 막으려면 volatile 키워드가 필요합니다.

private static volatile Settings instance; // DCL에서는 volatile 필요

결국 코드가 점점 복잡해집니다. 실수하기도 쉽고요.


더 좋은 방법이 있습니다 — Holder 패턴
#

public class Settings {
    private Settings() { }

    private static class SettingsHolder {
        private static final Settings INSTANCE = new Settings();
    }

    public static Settings getInstance() {
        return SettingsHolder.INSTANCE;
    }
}

synchronized도 없고, volatile도 없습니다.
그런데 어떻게 Thread-safe를 보장하는 걸까요?


핵심은 JVM의 클래스 초기화 메커니즘입니다
#

JVM은 클래스나 인터페이스를 처음 능동적으로 사용하는 시점에 초기화하고,
그 초기화 절차를 동기화합니다. 이건 JLS §12.4.1, §12.4.2가 보장하는 동작입니다.

Holder 패턴은 이 메커니즘을 그대로 활용합니다.

Settings 클래스는 로드될 수 있어도 SettingsHolder는 아직 초기화되지 않음
getInstance() 최초 호출
SettingsHolder.INSTANCE 에 처음 접근
SettingsHolder 초기화 시작
INSTANCE = new Settings() — 초기화 절차 안에서 한 번만 실행
이후 getInstance() 호출 → 그냥 반환만 함 (추가 락 없음)

락 없이도 안전하고, 실제로 필요한 시점까지 초기화를 미루며, 구현 복잡도도 낮습니다.


방식별 비교
#

구현 방식Lazy 초기화Thread-safe성능구현 복잡도
기본 null 체크낮음
synchronized낮음
Double-Checked Locking✅ (volatile 필수)높음
Holder 패턴낮음

그 외 알아두면 좋은 것들
#

Enum Singleton
#

《Effective Java》에서 자주 소개되는 방식입니다.

public enum Settings {
    INSTANCE;
}

// 사용
Settings.INSTANCE;

가장 짧고, enum 상수는 직렬화 시 이름 기반으로 특별 취급되므로 별도의 readResolve 같은 고민이 줄어듭니다.
또한 일반적인 reflective instantiation도 허용되지 않습니다.

다만 “애플리케이션 시작과 동시에 무조건 생성된다"고 일반화하면 정확하지 않습니다.
enum 상수도 해당 enum 타입이 초기화될 때 생성되므로, Holder 패턴처럼 getInstance() 호출 직전까지 미루는 형태와는 다릅니다.

Spring 환경이라면?
#

Spring을 쓴다면 직접 Singleton 패턴을 구현하기보다, 컨테이너가 관리하는 bean scope를 활용하는 경우가 많습니다.

@Component
public class Settings {
    // Spring IoC 컨테이너가 기본 singleton scope로 관리합니다.
}

Spring Bean의 기본 스코프는 singleton입니다.
다만 이것은 GoF Singleton처럼 JVM 전체에서 클래스당 하나라는 뜻이 아니라, Spring IoC 컨테이너 안에서 bean definition마다 하나라는 의미입니다.

그래서 Spring을 쓴다고 해서 상태 공유 문제가 사라지는 것은 아닙니다.
상태를 가지는 필드를 두면 여전히 동시성 문제가 생길 수 있으므로, 보통은 빈을 무상태에 가깝게 설계합니다.


정리
#

처음 봤던 코드가 틀린 이유는 로직이 단순해서가 아니라,
공유 상태를 여러 스레드가 동시에 읽고 쓸 수 있다는 점을 고려하지 않았기 때문입니다.

직접 Singleton 클래스를 구현해야 하고 지연 초기화가 필요하다면 Holder 패턴을 우선 검토할 만합니다.
JVM이 보장하는 클래스 초기화 절차를 활용하기 때문에, synchronized나 DCL보다 구현 실수가 적습니다.

지연 초기화가 굳이 필요 없고 enum의 제약이 괜찮다면 enum 방식도 좋은 선택지입니다.

“복잡한 문제를 복잡하게 푸는 건 실력이 아니다.
이미 있는 보장을 영리하게 활용하는 것이 진짜 실력이다.”

참고 자료
#