생성 패턴의 핵심 목표는 객체 생성 과정과 사용을 분리하는 것입니다. 이를 통해 시스템의 결합도를 낮추고, 사용자는 객체가 어떻게 생성되는지 알 필요 없이 사용할 수 있습니다.
생성 패턴의 종류는 다음과 같습니다:
- 싱글톤 패턴
- 팩토리 메서드 패턴
- 추상 팩토리 패턴
- 프로토타입 패턴
- 빌더 패턴
싱글톤 패턴이란?
싱글톤 패턴은 클래스의 인스턴스가 오직 하나만 생성되도록 보장하며, 이 인스턴스에 전역적으로 접근할 수 있는 방법을 제공합니다. 즉, 애플리케이션 전체에서 동일한 객체를 사용해야 할 때 활용됩니다.
싱글톤 패턴은 크게 두 가지 방식으로 나뉩니다:
- 이른 초기화(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);
}
}
}
}
이상으로 싱글톤 패턴의 개념, 다양한 구현 방식, 안전성 문제와 해결 방법에 대해 살펴보았습니다.