티스토리 뷰
싱글톤 패턴은 자바 개발자라면 한 번쯤 구현해 보았고, 동시에 가장 많이 잘못 구현되는 패턴이기도 합니다. 코드 자체는 단순해 보이지만, 멀티스레드 환경과 JVM 메모리 모델을 고려하지 않으면 “단 하나의 인스턴스”라는 전제는 쉽게 무너집니다. 그럼에도 불구하고 많은 설명은 여전히 생성자 은닉과 정적 필드 수준에서 멈추며, 왜 특정 구현이 안전한지에 대한 근거를 충분히 제시하지 않습니다.
자바에서 싱글톤이 논쟁적인 이유는 패턴의 문제가 아니라 언어와 실행 환경의 특성 때문입니다. 객체 생성과 참조 공개가 분리될 수 있고, 클래스 로딩 시점과 메모리 가시성은 JVM 명세에 의해 엄격히 정의됩니다. 이러한 배경을 이해하지 못한 채 싱글톤을 구현하면, 코드 리뷰와 테스트를 통과하더라도 운영 환경에서 문제를 일으킬 수 있습니다.
이 글은 자바에서 싱글톤 패턴이 어떻게 발전해 왔는지를 나열하는 데 목적이 있지 않습니다. 각 구현 방식이 어떤 기술적 한계를 해결하기 위해 등장했는지, 그리고 현재의 자바 메모리 모델과 클래스 로딩 규칙 하에서 어떤 의미를 가지는지를 설명합니다. 모든 판단은 JVM 동작 원리와 공식 문서에 근거하여 이루어지며, 실무 서버 애플리케이션에서 실제로 선택 가능한 구현과 그렇지 않은 구현을 명확히 구분합니다.
⸻
1. 싱글톤 패턴의 본질
싱글톤 패턴은 애플리케이션 내에서 특정 타입의 인스턴스를 정확히 하나만 유지하기 위해 사용됩니다. 이 패턴이 해결하려는 핵심 문제는 “객체 수의 통제”입니다. 데이터베이스 커넥션 풀 관리자, 전역 설정 로더, JVM 단위로 공유되어야 하는 레지스트리 객체와 같이 복수 인스턴스가 의미적으로 잘못된 경우가 존재합니다.
싱글톤은 흔히 전역 상태와 혼동됩니다. 전역 상태는 어디서든 접근 가능한 가변 데이터를 의미하며, 동시성 제어와 생명주기 관리가 어렵습니다. 싱글톤은 접근 지점을 제한하고 생성 시점을 통제한다는 점에서 구조적으로 다릅니다. 그러나 싱글톤이 내부적으로 가변 상태를 가진다면, 결과적으로 전역 상태와 동일한 위험을 갖게 됩니다.
자바에서 싱글톤이 특히 논쟁적인 이유는 멀티스레딩과 JVM 메모리 모델 때문입니다. 객체 생성과 참조 공개가 분리될 수 있으며, 잘못된 구현은 단일 인스턴스라는 전제를 쉽게 깨뜨립니다. 이 문제는 언어 차원이 아니라 JVM의 명세에 의해 설명됩니다. Java Language Specification은 객체 생성, 초기화, 가시성 규칙을 명확히 정의하며, 싱글톤 구현의 옳고 그름은 이 명세 위에서만 판단할 수 있습니다.
⸻
2. Java에서 싱글톤을 구현하는 주요 방식
2.1 Eager Initialization
public class EagerSingleton {
private static final EagerSingleton INSTANCE = new EagerSingleton();
private EagerSingleton() {}
public static EagerSingleton getInstance() {
return INSTANCE;
}
}
이 방식은 클래스 로딩 시점에 인스턴스를 생성합니다. 클래스 초기화는 JVM에 의해 동기화되므로 스레드 안전합니다. JLS에 따르면 클래스 초기화는 한 번만 수행되며, 해당 시점 이전에 어떤 스레드도 초기화된 정적 필드에 접근할 수 없습니다.
장점은 구현이 단순하고 동시성 문제가 없다는 점입니다. 단점은 사용 여부와 무관하게 인스턴스가 생성된다는 점입니다. 무거운 자원을 사용하는 객체라면 애플리케이션 기동 시간을 증가시킵니다. 실무에서는 생성 비용이 매우 작고 항상 필요한 객체에 한해 제한적으로 사용 가능합니다.
⸻
2.2 Lazy Initialization (synchronized)
public class LazySingleton {
private static LazySingleton instance;
private LazySingleton() {}
public static synchronized LazySingleton getInstance() {
if (instance == null) {
instance = new LazySingleton();
}
return instance;
}
}
이 방식은 최초 호출 시 인스턴스를 생성합니다. synchronized 키워드는 메서드 전체에 락을 걸어 스레드 안전성을 보장합니다. 자바 메모리 모델에서 모니터 락의 획득과 해제는 happens-before 관계를 형성하므로 가시성 문제는 없습니다.
문제는 성능입니다. 인스턴스가 이미 생성된 이후에도 모든 호출이 동기화 비용을 지불합니다. 고빈도 호출 경로에서는 병목이 됩니다. 실무 서버 애플리케이션에서는 거의 사용하지 않습니다.
⸻
2.3 Double-Checked Locking (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 키워드입니다. 자바 5 이전에는 객체 생성 과정에서 재정렬(reordering) 이 발생할 수 있었고, 초기화되지 않은 객체를 다른 스레드가 관측하는 문제가 있었습니다.
자바 메모리 모델 개정 이후, volatile 변수에 대한 쓰기는 해당 변수의 이후 읽기보다 happens-before 관계를 형성합니다. 이로 인해 객체 생성과 참조 공개가 재정렬되지 않습니다. 이 규칙은 OpenJDK의 메모리 모델 문서와 JLS에 명시되어 있습니다.
장점은 지연 로딩과 성능의 균형입니다. 단점은 코드 복잡성과 가독성입니다. 실무에서는 팀 내 합의와 명확한 문서화 없이는 권장되지 않습니다.
⸻
2.4 Initialization-on-Demand Holder Idiom
public class HolderSingleton {
private HolderSingleton() {}
private static class Holder {
private static final HolderSingleton INSTANCE =
new HolderSingleton();
}
public static HolderSingleton getInstance() {
return Holder.INSTANCE;
}
}
이 방식은 클래스 로딩 메커니즘을 활용합니다. 내부 정적 클래스는 외부 클래스가 로딩될 때 초기화되지 않습니다. getInstance() 호출 시점에만 Holder 클래스가 로딩되며, 이 과정은 JVM에 의해 동기화됩니다.
스레드 안전성, 지연 로딩, 성능을 모두 만족합니다. 추가적인 동기화 비용도 없습니다. 이 구현은 JLS의 클래스 초기화 규칙을 정확히 활용한 사례입니다. 실무에서 가장 합리적인 수동 싱글톤 구현 방식입니다.
⸻
2.5 Enum 기반 싱글톤
public enum EnumSingleton {
INSTANCE;
}
이 방식은 JLS에서 enum 타입의 인스턴스 생성과 직렬화를 특별 취급하는 규칙을 활용합니다. enum은 JVM 차원에서 인스턴스 수가 보장되며, 리플렉션과 직렬화로도 추가 인스턴스가 생성되지 않습니다.
Joshua Bloch는 이 방식을 가장 안전한 싱글톤 구현으로 제시합니다. 단점은 상속이 불가능하며, 지연 로딩 제어가 제한적이라는 점입니다. 또한 기존 클래스 구조를 enum으로 변경하기 어려운 경우가 많습니다. 조건이 맞는다면 가장 강력한 선택지입니다.
⸻
3. 자바 메모리 모델과 싱글톤
자바 메모리 모델은 스레드 간 메모리 가시성과 명령 재정렬을 정의합니다. volatile이 필요한 이유는 객체 생성이 원자적이지 않기 때문입니다. 객체 생성은 메모리 할당, 생성자 실행, 참조 할당의 단계로 이루어집니다.
재정렬이 허용되면 참조 할당이 생성자 실행보다 먼저 수행될 수 있습니다. 다른 스레드는 null이 아닌 참조를 관측하지만, 객체는 초기화되지 않은 상태일 수 있습니다. volatile은 이러한 재정렬을 금지합니다.
happens-before 관계는 싱글톤 안전성의 핵심입니다. 클래스 초기화, 모니터 락, volatile 변수 접근은 모두 명확한 happens-before 규칙을 형성합니다. 싱글톤 구현은 이 규칙 위에서만 안전하다고 말할 수 있습니다.
⸻
4. 리플렉션, 직렬화, 클래스 로더 관점의 문제
리플렉션은 private 생성자에도 접근할 수 있습니다. setAccessible(true)를 사용하면 싱글톤 생성자를 호출할 수 있으며, 이는 설계상 허용된 기능입니다. 생성자에서 인스턴스 존재 여부를 검사해 방어할 수 있으나 완전한 해결책은 아닙니다.
직렬화는 객체를 역직렬화할 때 새로운 인스턴스를 생성합니다. 이를 방지하기 위해 readResolve() 메서드를 구현하여 기존 인스턴스를 반환해야 합니다. 그러나 이 방식은 직렬화 메커니즘에 대한 이해가 필요하며, 누락 시 쉽게 깨집니다.
클래스 로더는 서로 다른 네임스페이스를 형성합니다. 동일한 클래스라도 서로 다른 클래스 로더에 의해 로딩되면 JVM은 완전히 다른 타입으로 인식합니다. 애플리케이션 서버, OSGi 환경에서는 싱글톤이 클래스 로더 단위로 여러 개 생성됩니다. 이는 버그가 아니라 JVM의 정상 동작입니다.
⸻
5. Spring 및 서버 애플리케이션 실무 관점
Spring에서 말하는 싱글톤은 컨테이너 범위입니다. JVM 전역 싱글톤이 아니라, 하나의 ApplicationContext 내에서 하나의 인스턴스를 의미합니다. 이는 GoF 싱글톤과 본질적으로 다릅니다.
Spring 환경에서는 직접 싱글톤을 구현할 이유가 거의 없습니다. 생명주기, 동시성, 테스트 대체가 모두 컨테이너에 의해 관리됩니다. 직접 구현해야 하는 경우는 프레임워크 외부 라이브러리나 JVM 전역 레지스트리가 필요한 경우로 제한됩니다.
구현하면 안 되는 경우는 테스트 격리가 필요한 컴포넌트, 상태를 가진 서비스 객체입니다. 싱글톤은 테스트를 어렵게 만들고, 확장성과 유지보수 비용을 증가시킵니다.
⸻
결론: 어떤 조건에서 어떤 선택이 합리적인가
싱글톤은 패턴이 아니라 제약 조건에 대한 해법입니다. JVM 전역에서 단 하나여야 하는 객체인가, 지연 로딩이 필요한가, 직렬화와 리플렉션을 고려해야 하는가에 따라 선택이 달라집니다.
수동 구현이 필요하다면 holder idiom이나 enum 기반 구현이 합리적입니다. Spring 환경에서는 컨테이너 싱글톤을 신뢰하는 것이 옳습니다. 중요한 것은 “최고의 싱글톤”이 아니라, 현재 실행 환경과 제약 조건에 맞는 선택입니다. 이는 패턴의 문제가 아니라, JVM과 자바 명세를 이해한 설계의 문제입니다.
⸻
맺음말
싱글톤 패턴은 “하나만 만들기 위한 트릭”이 아닙니다. 이는 JVM의 클래스 초기화 규칙, 자바 메모리 모델의 happens-before 관계, 그리고 객체 생명주기에 대한 이해를 전제로 하는 설계 선택입니다. 구현 코드의 길이나 복잡도가 아니라, 어떤 규칙 위에서 안전성이 보장되는지가 핵심입니다.
Eager initialization은 단순하지만 항상 생성 비용을 지불합니다. synchronized 기반 lazy initialization은 안전하지만 호출 경로에 비용을 남깁니다. double-checked locking은 메모리 모델을 이해하지 못하면 위험한 코드가 됩니다. holder idiom은 JVM의 클래스 로딩 메커니즘을 정확히 활용한 균형 잡힌 선택입니다. enum 기반 싱글톤은 언어 차원의 보장을 제공하지만 구조적 제약을 동반합니다. 어느 하나가 항상 옳은 선택은 아닙니다.
또한 현대의 자바 서버 애플리케이션, 특히 Spring 환경에서는 GoF 싱글톤을 직접 구현해야 할 이유가 거의 없습니다. 컨테이너가 제공하는 생명주기 관리와 범위 제어는 수동 싱글톤보다 명확하고 테스트 친화적입니다. 싱글톤이 필요해 보인다면, 먼저 그 요구가 객체 수의 제약인지, 단순한 공유 상태인지를 구분해야 합니다.
결국 싱글톤 구현에서 중요한 질문은 “어떤 방식이 가장 유명한가”가 아닙니다. 이 객체는 JVM 전역에서 하나여야 하는가, 지연 로딩이 필요한가, 클래스 로더 경계를 넘는가, 테스트와 확장이 가능한가라는 질문에 답하는 과정이 설계입니다. 싱글톤은 목적이 아니라 결과이며, 올바른 결과는 자바 명세를 이해한 선택에서만 나옵니다.
'STUDY' 카테고리의 다른 글
| Spring 생성자 주입 vs 필드 주입: 순환참조가 발생하는 이유와 설계 관점의 해결 전략 (0) | 2026.02.02 |
|---|---|
| 동시성과 병렬성의 차이와 가상 스레드 시대의 JVM 동시성 제어 전략: Spring Batch Partitioning 동시성 제어 전략까지 (1) | 2026.01.23 |
| Java 동시성 문제 해결: 스레드 관리부터 InterruptedException 처리까지 (0) | 2026.01.02 |
| Java Virtual Threads 활용 성능 개선 전략 – 기존 Thread Pool과의 성능 차이와 적용 기준 (0) | 2025.12.30 |
| Redis Cache Aside + RDBMS 인덱스 협응 성능 개선 전략 (0) | 2025.12.29 |
- Total
- Today
- Yesterday
- 스레드 생명주기
- 트래픽 처리
- Redis 캐시 전략
- Redis 성능 개선
- DB 트랜잭션
- Cache Aside
- Redis vs DB
- Cache Avalanche
- spring batch 5
- Cache Penetration
- Java Performance
- 백엔드 성능 설계
- 동시성처리
- 캐시와 인덱스
- 캐시 성능 비교
- Spring Batch
- Initialization-on-Demand Holder Idiom
- mybatis
- 트랜잭션 관리
- Hot Key 문제
- TTL 설계
- 백엔드 아키텍처
- 백엔드 성능
- 백엔드 성능 튜닝
- Eager Initialization
- Enum 기반 싱글톤
- DB 인덱스 성능
- InterruptedException
- Double-Checked Locking
- 캐시 장애
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
| 29 | 30 | 31 |

