Singleton 패턴: 전역 인스턴스를 안전하게 하나로 유지하는 법
Singleton 패턴의 생성 시점 전략, 동시성 제어, 우회 생성 방어와 Lazy Holder·enum 구현을 실무 관점에서 정리한다.
2026-08-13 · 최초 발행 2025-10-14
애플리케이션 전역에서 구성 관리, 로그, 메트릭 수집 같은 공용 리소스를 여러 인스턴스가 동시에 만들면 경쟁 조건이 생긴다. Singleton 패턴은 하나의 클래스에 대해 단 하나의 인스턴스만 생성하고 전역 접근점을 제공해 이 문제를 막는다.
하나의 인스턴스만 허용한다는 것
Singleton의 핵심 메커니즘은 인스턴스 생성 시점 전략(사전 초기화, 지연 초기화)과 동시성 제어(락, 메모리 가시성)다. 실무에서는 여기에 더해 인스턴스의 수명 관리, 직렬화·리플렉션 대응, 테스트·DI 연계까지 고려해야 한다.
생성 시점 전략: Eager와 Lazy
사전 초기화(Eager)는 클래스 로딩 시점에 인스턴스를 만든다. 구현이 단순하고 스레드 안전하지만, 초기화 비용이 들고 미사용 시 자원을 낭비할 수 있다. 지연 초기화(Lazy)는 최초 요청 시점에 생성해 메모리를 아끼지만 동시성 제어가 필요하다. JVM이나 언어 특성에 따라 Initialization-on-demand Holder, volatile을 쓴 Double-Checked Locking 같은 방식을 선택한다.
동시성 제어와 메모리 가시성
동시성 제어는 synchronized, ReentrantLock, CAS 기반 구현 중 경합 수준에 맞춰 고른다. 메모리 가시성을 보장하려면 volatile을 쓰거나 클래스 초기화 규약을 이용한다. 생성 실패 재시도, 순환 초기화 방지 같은 예외 흐름도 함께 설계해야 한다.
수명 관리와 프로세스 경계
애플리케이션 시작·종료 훅과 결합해 리소스 획득·해제 순서를 보장해야 한다. 클래스로더 경계도 인지할 필요가 있다 — 클래스로더별로 별도 인스턴스가 생길 수 있기 때문이다. 장애 복구 시 상태를 어떻게 재초기화할지도 정책으로 명문화해야 한다.
우회 생성 막기: 리플렉션과 직렬화
리플렉션을 통한 우회 생성은 생성자 가드, SecurityManager나 모듈 권한 제어, 혹은 enum 사용으로 방지한다. 직렬화·역직렬화 시에는 readResolve를 구현해 단일성을 유지한다. 다중 프로세스 환경이라면 OS 수준 락이나 IPC, 혹은 ZooKeeper 같은 외부 코디네이터가 필요하다.
테스트·DI와의 긴장
전역 접근은 결합도를 높이고 테스트 격리를 어렵게 만든다. 그래서 인터페이스 추상화와 팩토리·DI 컨테이너를 우선 권장한다. 상태를 최소화하고 멱등적으로 동작하도록 설계하면 테스트 용이성이 확보되고, 피처 토글이나 테스트 더블 주입 경로를 별도로 마련해두는 것도 방법이다.
Singleton을 쓰는 실무 지점
- 구성·설정 레지스트리: 환경 변수, 피처 플래그, 동적 설정 캐시를 일원화한다.
- 로깅·텔레메트리 수집기: 버퍼, 백프레셔, 비동기 큐 관리를 단일화한다.
- 커넥션 풀·스레드 풀 관리자: 시스템 자원 경쟁을 제어하고 상한 설정의 일관성을 확보한다.
- 애플리케이션 메트릭·트레이싱 익스포터: 외부 엔드포인트당 하나의 전송 파이프를 유지한다.
- 라이선스 매니저·키 관리: 접근 제어와 감사 로그를 일원화한다.
Singleton 도입 효과
정량적으로는 동일 기능의 인스턴스 N개를 1개로 줄이면, 객체당 15MB를 가정할 때 N=10이면 945MB를 절감할 수 있다. 지연 초기화에 캐시 워밍을 더하면 워크로드에 따라 p99 초기화 지연을 30~70% 줄일 수 있다. Double-Checked Locking을 적용하면 핫 패스에서 락을 회피해 경합 구간 진입률을 1/N 수준으로 축소할 수 있다. 정성적으로는 전역 일관성이 확보되고 정책이 단일화돼 운영 오류가 줄며, 수명·해제 지점을 통제하기 쉬워져 장애 원인 추적이 단순해진다.
요청부터 반환까지: 동작 흐름
클래스 구조로 보면
Singleton 클래스는 자신의 인스턴스를 정적 필드(instance)로 보관한다. 생성자는 외부에서 호출할 수 없도록 private(혹은 protected)로 선언한다. getInstance() 메서드를 통해서만 인스턴스에 접근할 수 있고, someBusinessMethod()는 Singleton 객체가 제공하는 비즈니스 메서드의 예시다.
구현해보기: Lazy Holder부터 enum까지
전제조건은 Java 17 이상, Gradle·Maven 환경, 단일 JVM 프로세스다.
Lazy Holder 방식은 클래스 초기화 규약을 이용해 락 비용을 회피하는 권장 방식이다.
// src/main/java/example/ConfigRegistry.java
package example;
import java.util.concurrent.ConcurrentHashMap;
import java.util.Map;
public final class ConfigRegistry {
private final Map<String, String> store = new ConcurrentHashMap<>();
private ConfigRegistry() {
// 초기화 로직(원격 로드 등) 필요 시 예외 처리 포함
store.putIfAbsent("app.mode", "prod");
}
private static class Holder {
static final ConfigRegistry INSTANCE = new ConfigRegistry();
}
public static ConfigRegistry getInstance() {
return Holder.INSTANCE;
}
public String get(String key) { return store.get(key); }
public void set(String key, String value) { store.put(key, value); }
}
enum 기반 방식은 직렬화와 리플렉션에 안전하지만 Java 전용이다.
// src/main/java/example/LogBus.java
package example;
public enum LogBus {
INSTANCE;
public void publish(String msg) {
// 비동기 큐 연동 등
System.out.println(msg);
}
}
두 방식을 함께 쓰는 실행 예는 다음과 같다.
// src/main/java/example/Main.java
package example;
public class Main {
public static void main(String[] args) {
ConfigRegistry reg = ConfigRegistry.getInstance();
reg.set("feature.x", "on");
LogBus.INSTANCE.publish("feature.x=" + reg.get("feature.x"));
}
}
빌드·실행은 gradle run이나 javac/java 직접 컴파일·실행으로 한다. 생성자에서 예외가 발생하면 상위 호출 스택으로 전파되므로, 재시도 정책이나 폴백 구현이 필요하다.
구현 방식 비교
| 구현 방식 | 성능 | 확장성 | 일관성 | 안정성 | 운영 편의 |
|---|---|---|---|---|---|
| Eager 초기화 | 매우 낮은 지연, 예측 가능 | 메모리 상수 비용 증가 | 강한 | 높음(단순) | 매우 쉬움 |
| Lazy + synchronized | 최초 지연 존재, 경합 시 비용 큼 | 요청 급증 시 락 병목 | 강한 | 중간(락 오류 위험 낮음) | 보통 |
| Lazy + DCL(volatile) | 핫패스 고성능 | CPU/메모리 모델 준수 필요 | 강한 | 높음(JMM 준수 시) | 보통 |
| Lazy Holder | 핫패스 고성능 | 클래스 초기화 타이밍에 종속 | 강한 | 높음 | 쉬움 |
| enum Singleton | 직렬화/리플렉션 안전, 간결 | 언어 제약(메서드 시그니처 제약) | 강한 | 매우 높음 | 매우 쉬움 |
| DI/컨테이너 대안 | 전역 없이 수명/스코프 제어 | 멀티 모듈/멀티 인스턴스 유연 | 설계에 따름 | 높음(검증된 프레임워크) | 초기 학습 필요 |
설계 포인트 정리
요청이 들어오면 null 체크 → 필요 시 락 획득 → 생성자 실행 → 메모리 가시성 보장 → 참조 공개 순으로 처리하고, 예외가 발생하면 생성을 중단하고 로그를 남긴 뒤 폴백이나 재시도 정책을 적용한다. 최종적으로는 단일 인스턴스 핸들을 반환한다. 일관성 측면에서는 JMM이나 해당 언어의 메모리 모델을 준수해 volatile·final 필드를 안정적으로 공개하고, 클래스 초기화의 순환 의존성을 방지해야 한다. 보안·무결성 측면에서는 enum이나 readResolve, 생성자 가드로 우회 생성을 차단하고, 멀티 프로세스 요구가 있다면 외부 코디네이터를 도입한다.
실무 체크리스트: 모범사례와 트레이드오프
상태는 최소화하고(불변 또는 스레드 안전 컬렉션 사용), 초기화 실패는 가시화하며, 종료 훅에서 해제한다. API는 인터페이스 기반으로 설계하고 전역 접근은 어댑터로 한정하며, 테스트 더블 주입 경로를 확보한다. Lazy Holder나 enum을 우선 채택하고, DCL을 쓴다면 volatile은 필수다.
다만 전역 접근성이 늘어나는 만큼 결합도가 상승하고 테스트 격리 난이도도 올라간다. 초기화를 지연시키면 콜드 스타트에 영향을 줄 수 있지만 미사용 시 자원을 절약할 수 있다. enum처럼 간결한 구현은 언어·프레임워크 제약이 뒤따른다. 복잡한 시스템이라면 DI 컨테이너나 스코프 기반 설계로 전역 의존성을 최소화하는 대안도 고려할 만하다.