싱글톤 패턴이란?

싱글톤 패턴은 애플리케이션에서 특정 클래스의 인스턴스가 오직 하나만 존재하도록 보장하는 디자인 패턴이다. 구현 방식에 따라 메모리 적재 시점, 스레드 안전성, 코드 복잡도가 달라지는데 각 방식의 특징과 실무적 관점에서의 선택 기준을 정리해보려고 한다.


1. Eager Initialization

public class Singleton {
    private static final Singleton INSTANCE = new Singleton();
    
    private Singleton() {}
    
    public static Singleton getInstance() {
        return INSTANCE;
    }
}

메모리 적재 시점

static final 필드는 클래스가 JVM에 로딩되는 시점(JVM이 뜰 때)에 인스턴스가 생성된다.

 

특징

JVM의 클래스 로딩 메커니즘 자체가 thread-safe하기 때문에 별도의 동기화 처리가 필요 없다. 구현이 단순하고 쉽지만 애플리케이션에서 해당 싱글톤을 사용하지 않더라도 JVM이 뜰 때 인스턴스가 생성되어 메모리를 점유한다.

인스턴스 생성 비용이 크지 않고 애플리케이션 실행 중 반드시 사용되는 객체라면 가장 단순하고 안전한 선택이다.


2. Lazy Initialization

public class Singleton {
    private static Singleton instance;
    
    private Singleton() {}
    
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

메모리 적재 시점

getInstance()가 처음 호출되는 시점에 인스턴스가 생성된다. 실제로 필요할 때까지 메모리 할당을 미루는 지연 로딩(lazy loading) 전략이다.

 

문제점: 스레드 안전성 미보장

이 방식은 싱글스레드 환경에서만 정상 동작한다. 멀티스레드 환경에서는 다음과 같은 시나리오가 발생할 수 있다.

Thread A: if (instance == null) → true 확인
Thread B: if (instance == null) → true 확인 (A가 아직 생성 전)
Thread A: instance = new Singleton() 실행
Thread B: instance = new Singleton() 실행 → 인스턴스 2개 생성

두 스레드가 거의 동시에 null 체크를 통과하면 인스턴스가 여러 개 생성된다. 싱글톤의 핵심 목적이 깨지는 것이다.


3. Lazy Holder 

public class Singleton {
    private Singleton() {}
    
    private static class Holder {
        private static final Singleton INSTANCE = new Singleton();
    }
    
    public static Singleton getInstance() {
        return Holder.INSTANCE;
    }
}

메모리 적재 시점

JVM 클래스 로딩 시 Singleton 클래스가 로딩되어도 내부 클래스 Holder는 로딩되지 않는다. getInstance()가 호출되어 Holder.INSTANCE를 참조하는 시점에 비로소 Holder 클래스가 로딩되고 그때 INSTANCE가 생성된다.

 

Lazy Initialization과의 차이점

둘 다 필요할 때 생성한다는 점에서 lazy하지만 Lazy Initialization은 개발자가 직접 동기화를 처리해야 하는 반면 Lazy Holder는 JVM이 클래스 로딩 시 내부적으로 동기화를 보장하기 때문에 별도 코드 없이도 thread-safe하다.


4. Double-Checked Locking (DCL)

public class Singleton {
    private static volatile Singleton instance;
    
    private Singleton() {}
    
    public static Singleton getInstance() {
        if (instance == null) {                    // 1st check
            synchronized (Singleton.class) {
                if (instance == null) {            // 2nd check
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

동작 원리

첫 번째 체크에서 이미 인스턴스가 존재하면 synchronized 블록 진입 없이 바로 반환한다. null인 경우에만 락을 획득하고 락 내부에서 다시 한 번 체크하여 다른 스레드가 먼저 생성했는지 확인한다.

 

volatile이 필요한 이유

volatile 없이 DCL을 사용하면 문제가 발생할 수 있다. instance = new Singleton() 구문은 실제로 세 단계로 수행된다.

  1. 메모리 할당
  2. 생성자 호출 (객체 초기화)
  3. instance 변수에 참조 할당

JVM의 instruction reordering에 의해 2번과 3번의 순서가 바뀔 수 있다. 이 경우 다른 스레드가 초기화되지 않은 객체를 참조할 가능성이 생긴다. 

volatile은 코드가 순서에 따라 실행됨을 보장하여 이 문제를 방지한다. volatile 쓰기 이전의 모든 작업이 다른 스레드의 volatile 읽기 이전에 완료됨을 보장한다.

 

실무적 관점에서는 권장하지 않는다.

DCL은 synchronized 메서드 방식보다 성능이 좋을 수 있다. 인스턴스가 생성된 후에는 락을 획득하지 않기 때문이다. 그러나 코드가 복잡해지며 동시성에 대한 이해도가 높지 않으면 버그가 발생할 수 있기에 실무에서 권장하지 않는다.

// 동기화가 필요하다면 차라리 이게 낫다
public static synchronized Singleton getInstance() {
    if (instance == null) {
        instance = new Singleton();
    }
    return instance;
}

synchronized 메서드는 매 호출마다 락을 획득하는 오버헤드가 있지만 코드가 명확하고 스레드 안전성을 쉽게 검증할 수 있다. 싱글톤 객체의 getInstance() 호출이 병목이 될 정도로 빈번하지 않다면 이 방식도 충분하다. 컨텍스트 스위칭이 발생하며 메모리 가시성도 함께 보장되기 때문이다. (volatile이 없어도 됨)


5. Enum Singleton

public enum Singleton {
    INSTANCE;
    
    private final Connection connection;
    
    Singleton() {
        this.connection = createConnection();
    }
    
    public void doSomething() {
        // 비즈니스 로직
    }
    
    private Connection createConnection() {
        // 연결 생성 로직
        return null;
    }
}

메모리 적재 시점

Enum 상수는 클래스 로딩 시점에 생성된다. 이 점에서 Eager Initialization과 동일하다.

 

Enum은 완벽한 싱글톤을 보장한다.

Enum은 자바 언어 스펙 차원에서 인스턴스가 하나만 존재함을 JVM이 보장한다. enum 상수는 클래스 로딩 시 한 번 생성되며 이후 추가 생성이 불가능하다. 또한 직렬화/역직렬화에도 싱글톤이 유지된다. 일반 클래스의 경우 역직렬화 시 새로운 인스턴스가 생성될 수 있어 readResolve() 메서드로 방어해야 하는데 Enum은 이 처리가 자동으로 된다.

// 일반 클래스의 경우 필요한 방어 코드
private Object readResolve() {
    return INSTANCE;
}

 

또한 리플렉션 공격을 방어한다. 일반 클래스는 리플렉션으로 private 생성자에 접근하여 새 인스턴스를 만들 수 있다. Enum은 이것이 언어 차원에서 차단된다.

// 일반 클래스는 이렇게 뚫린다
Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
Singleton anotherInstance = constructor.newInstance(); // 새 인스턴스 생성됨

// Enum은 불가능
Constructor<EnumSingleton> constructor = EnumSingleton.class.getDeclaredConstructor();
// java.lang.NoSuchMethodException 발생

 

제약 사항

Enum은 다른 클래스를 상속할 수 없다. 모든 enum은 암묵적으로 java.lang.Enum을 상속하기 때문이다. 인터페이스 구현은 가능하다. 또한 lazy loading이 필요한 경우에는 적합하지 않다. 클래스 로딩 시점에 무조건 인스턴스가 생성되기 때문이다.


실무에서의 선택 기준은 다음과 같다.

Lazy Holder: 대부분의 경우 가장 좋은 선택이다. lazy loading과 thread-safe를 동시에 만족하면서 코드가 단순하다. 직렬화나 리플렉션 방어가 필요하지 않은 일반적인 상황에 적합하다.

Enum: 직렬화가 필요하거나 리플렉션 공격 방어가 중요한 경우 선택한다. 다만 상속 불가와 eager loading이라는 제약을 감수해야 한다.

Eager: 인스턴스 생성 비용이 크지 않고, 애플리케이션 생명주기 동안 반드시 사용되는 객체라면 가장 단순한 선택이다.

DCL: 특별한 이유가 없다면 사용하지 않는다. Lazy Holder로 동일한 효과를 더 안전하게 얻을 수 있다.


마치며

싱글톤 패턴은 단순해 보이지만 제대로 사용하려면 JVM의 클래스 로딩 메커니즘, 메모리 모델, 동시성에 대한 이해가 필요하다. 각 방식의 trade-off를 이해하고 상황에 맞는 구현을 선택하는 것이 중요하고 생각한다.

 

+ Recent posts