디자인 패턴 - 싱글톤 패턴 완벽 이해하기

생성 패턴의 핵심 목표는 객체 생성 과정과 사용을 분리하는 것입니다. 이를 통해 시스템의 결합도를 낮추고, 사용자는 객체가 어떻게 생성되는지 알 필요 없이 사용할 수 있습니다.

생성 패턴의 종류는 다음과 같습니다:

  • 싱글톤 패턴
  • 팩토리 메서드 패턴
  • 추상 팩토리 패턴
  • 프로토타입 패턴
  • 빌더 패턴

싱글톤 패턴이란?

싱글톤 패턴은 클래스의 인스턴스가 오직 하나만 생성되도록 보장하며, 이 인스턴스에 전역적으로 접근할 수 있는 방법을 제공합니다. 즉, 애플리케이션 전체에서 동일한 객체를 사용해야 할 때 활용됩니다.

싱글톤 패턴은 크게 두 가지 방식으로 나뉩니다:

  • 이른 초기화(Eager Initialization): 클래스가 로드될 때 인스턴스가 생성됩니다.
  • 게으른 초기화(Lazy Initialization): 인스턴스가 실제로 필요할 때 (첫 번째 호출 시) 생성됩니다.

이른 초기화 방식

이른 초기화 방식은 클래스 로딩 시점에 인스턴스를 생성합니다. 이는 특히 JVM의 클래스 초기화 단계에서 static 변수나 static 블록이 실행될 때 인스턴스화가 이루어집니다.

정적 변수를 이용한 방식
public class EarlySingleton {
    private EarlySingleton() { }

    private static final EarlySingleton INSTANCE = new EarlySingleton();

    public static EarlySingleton getInstance() {
        return INSTANCE;
    }
}

public class Test {
    public static void main(String[] args) {
        EarlySingleton s1 = EarlySingleton.getInstance();
        EarlySingleton s2 = EarlySingleton.getInstance();
        System.out.println(s1 == s2); // true
    }
}
정적 블록을 이용한 방식
public class StaticBlockSingleton {
    private static final StaticBlockSingleton INSTANCE;
    private StaticBlockSingleton() { }

    static {
        INSTANCE = new StaticBlockSingleton();
    }

    public static StaticBlockSingleton getInstance() {
        return INSTANCE;
    }
}
열거형(Enum)을 이용한 방식

열거형을 사용한 싱글톤은 가장 안전하고 권장되는 방식입니다. 열거형은 JVM에 의해 단 한 번만 초기화되며, 직렬화나 리플렉션으로부터도 안전합니다.

장점:

  • 스레드 안전성: JVM이 초기화 과정을 보장합니다.
  • 직렬화 방어: 역직렬화 시 새로운 인스턴스가 생성되지 않습니다.
  • 리플렉션 공격 방어: JVM이 리플렉션을 통한 새 인스턴스 생성을 금지합니다.
  • 간결함: 코드가 매우 짧고 이해하기 쉽습니다.
  • 성능 우수: JVM 수준의 최적화가 적용됩니다.
public enum EnumSingleton {
    INSTANCE;
}

public class Test {
    public static void main(String[] args) {
        EnumSingleton e1 = EnumSingleton.INSTANCE;
        EnumSingleton e2 = EnumSingleton.INSTANCE;
        System.out.println(e1 == e2); // true
    }
}

메모리가 충분하고 성능이 중요한 경우 열거형 방식이 최선의 선택입니다.

게으른 초기화 방식

게으른 초기화는 인스턴스를 실제로 사용할 때까지 생성을 미룹니다.

기본적인 게으른 초기화의 문제점
public class LazySingleton {
    private static LazySingleton instance;
    private LazySingleton() { }

    public static LazySingleton getInstance() {
        instance = new LazySingleton(); // 매번 새 객체 생성!
        return instance;
    }
}

위 코드는 호출할 때마다 새로운 객체를 생성하므로 싱글톤이 아닙니다.

스레드 안전하지 않은 조건 검사
public class UnsafeLazySingleton {
    private static UnsafeLazySingleton instance;
    private UnsafeLazySingleton() { }

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

이 방식은 다중 스레드 환경에서 여러 인스턴스가 생성될 위험이 있습니다.

동기화 메서드를 이용한 방식
public class SyncSingleton {
    private static SyncSingleton instance;
    private SyncSingleton() { }

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

메서드 전체에 동기화를 적용하면 성능이 저하됩니다.

이중 검사 잠금 (DCL) 방식

이중 검사 잠금은 동기화 범위를 최소화하여 성능을 개선한 방식입니다. 하지만 instance = new Singleton() 작업이 원자적이지 않아 CPU의 명령어 재배치로 인해 초기화되지 않은 객체에 접근할 위험이 있습니다. 이를 방지하기 위해 volatile 키워드를 사용합니다.

public class DclSingleton {
    private static volatile DclSingleton instance;
    private DclSingleton() { }

    public static DclSingleton getInstance() {
        if (instance == null) {
            synchronized (DclSingleton.class) {
                if (instance == null) {
                    instance = new DclSingleton();
                }
            }
        }
        return instance;
    }
}

volatile은 변수의 가시성을 보장하고 명령어 재배치를 막아줍니다.

정적 내부 클래스(holder)를 이용한 방식

이 방식은 JVM의 클래스 로딩 메커니즘을 활용합니다. 외부 클래스가 로드될 때 내부 클래스는 로드되지 않으며, getInstance()가 처음 호출될 때 내부 클래스가 로드되면서 인스턴스가 생성됩니다.

public class HolderSingleton {
    private HolderSingleton() { }

    private static class SingletonHolder {
        private static final HolderSingleton INSTANCE = new HolderSingleton();
    }

    public static HolderSingleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

public class Test {
    public static void main(String[] args) {
        HolderSingleton h1 = HolderSingleton.getInstance();
        HolderSingleton h2 = HolderSingleton.getInstance();
        System.out.println(h1 == h2); // true
    }
}

정적 내부 클래스 방식은 다음 장점이 있습니다:

  • 스레드 안전: JVM이 클래스 로딩을 보장합니다.
  • 지연 초기화: 실제 사용 시점에 인스턴스가 생성됩니다.
  • 성능 우수: 동기화 오버헤드가 없습니다.

이 방식은 오픈소스 프로젝트에서도 자주 사용됩니다.

싱글톤 패턴의 안전성 문제와 해결

직렬화/역직렬화로 인한 파괴

역직렬화 과정에서 새로운 인스턴스가 생성될 수 있습니다.

import java.io.*;

public class SerializableSingleton implements Serializable {
    private SerializableSingleton() { }

    private static class Holder {
        private static final SerializableSingleton INSTANCE = new SerializableSingleton();
    }

    public static SerializableSingleton getInstance() {
        return Holder.INSTANCE;
    }

    // 역직렬화 시 이 메서드가 호출되어 기존 인스턴스를 반환
    public Object readResolve() {
        return Holder.INSTANCE;
    }
}

public class TestSerialization {
    public static void main(String[] args) throws Exception {
        SerializableSingleton original = SerializableSingleton.getInstance();
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton.dat"));
        oos.writeObject(original);
        oos.close();

        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("singleton.dat"));
        SerializableSingleton deserialized = (SerializableSingleton) ois.readObject();
        ois.close();

        System.out.println(original == deserialized); // true (readResolve 덕분)
    }
}

readResolve() 메서드는 역직렬화 시 호출되어 지정된 객체를 반환하도록 합니다.

리플렉션으로 인한 파괴

리플렉션을 사용하면 private 생성자에 접근하여 새 인스턴스를 생성할 수 있습니다.

import java.lang.reflect.Constructor;

public class ReflectionSafeSingleton {
    private static volatile ReflectionSafeSingleton instance;

    private ReflectionSafeSingleton() {
        synchronized (ReflectionSafeSingleton.class) {
            if (instance != null) {
                throw new RuntimeException("싱글톤 인스턴스가 이미 존재합니다.");
            }
        }
    }

    public static ReflectionSafeSingleton getInstance() {
        if (instance == null) {
            synchronized (ReflectionSafeSingleton.class) {
                if (instance == null) {
                    instance = new ReflectionSafeSingleton();
                }
            }
        }
        return instance;
    }
}

// 내부 클래스 방식에서 리플렉션 방어
public class HolderReflectionSafe {
    private static boolean flag = false;

    private HolderReflectionSafe() {
        synchronized (HolderReflectionSafe.class) {
            if (flag) {
                throw new RuntimeException("싱글톤 인스턴스가 이미 존재합니다.");
            }
            flag = true;
        }
    }

    private static class Holder {
        private static final HolderReflectionSafe INSTANCE = new HolderReflectionSafe();
    }

    public static HolderReflectionSafe getInstance() {
        return Holder.INSTANCE;
    }
}

public class TestReflection {
    public static void main(String[] args) throws Exception {
        Class<HolderReflectionSafe> clazz = HolderReflectionSafe.class;
        Constructor<HolderReflectionSafe> constructor = clazz.getDeclaredConstructor();
        constructor.setAccessible(true);
        try {
            HolderReflectionSafe instance1 = constructor.newInstance();
            HolderReflectionSafe instance2 = constructor.newInstance();
        } catch (RuntimeException e) {
            System.out.println("리플렉션 공격이 차단되었습니다: " + e.getMessage());
        }
    }
}

JDK의 Runtime 클래스

Java의 Runtime 클래스는 싱글톤 패턴으로 구현된 대표적인 예입니다.

public class RuntimeDemo {
    public static void main(String[] args) throws Exception {
        Runtime runtime = Runtime.getRuntime();
        Process process = runtime.exec("cmd /c dir");
        try (var reader = new java.io.BufferedReader(
                new java.io.InputStreamReader(process.getInputStream(), "EUC-KR"))) {
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
        }
    }
}

이상으로 싱글톤 패턴의 개념, 다양한 구현 방식, 안전성 문제와 해결 방법에 대해 살펴보았습니다.

태그: 싱글톤패턴 자바 디자인패턴 JVM 멀티스레드

7월 5일 04:46에 게시됨