싱글톤 패턴이란?
싱글톤 패턴은 애플리케이션에서 특정 클래스의 인스턴스가 오직 하나만 존재하도록 보장하는 디자인 패턴이다. 구현 방식에 따라 메모리 적재 시점, 스레드 안전성, 코드 복잡도가 달라지는데 각 방식의 특징과 실무적 관점에서의 선택 기준을 정리해보려고 한다.
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() 구문은 실제로 세 단계로 수행된다.
- 메모리 할당
- 생성자 호출 (객체 초기화)
- 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를 이해하고 상황에 맞는 구현을 선택하는 것이 중요하고 생각한다.
'개발지식 > Design Pattern' 카테고리의 다른 글
| 컴포짓 패턴 - 개념과 연습문제 (0) | 2026.01.11 |
|---|---|
| 브릿지 패턴 - 개념과 연습문제 (0) | 2026.01.06 |
| 어탭터 패턴 (객체 어댑터, 클래스 어댑터) - 개념과 연습문제 (0) | 2025.12.28 |
| 프로토타입 패턴 - 개념과 연습문제 (0) | 2025.12.24 |
| 빌더 패턴 - 개념과 연습문제 (0) | 2025.12.24 |