Featured image of post [Design Patterns] 05. 싱글톤: 논란이 많은 패턴

[Design Patterns] 05. 싱글톤: 논란이 많은 패턴

가장 논란이 많은 디자인 패턴인 Singleton의 장단점을 객관적으로 분석합니다. 전역 상태의 위험성, 테스트의 어려움, 멀티스레드 환경에서의 문제점을 깊이 있게 다루고, 언제 사용해야 하고 언제 피해야 하는지에 대한 명확한 가이드라인을 제시합니다. 대안 패턴과 현대적 접근법도 함께 탐구합니다.

가장 논란이 많은 디자인 패턴인 Singleton의 장단점을 객관적으로 분석합니다. 전역 상태의 위험성, 테스트의 어려움, 멀티스레드 환경에서의 문제점과 대안 패턴을 탐구합니다.

서론: 사랑받으면서도 미움받는 패턴의 역설

“Singleton은 디자인 패턴의 양날의 검이다. 올바르게 사용하면 시스템을 단순화하지만, 잘못 사용하면 시스템을 파괴한다.”

개발자들 사이에서 Singleton 패턴만큼 극명하게 갈리는 의견을 보이는 패턴은 드뭅니다. 어떤 이들은 “간단하고 효율적"이라며 자주 사용하고, 다른 이들은 “Anti-pattern의 대표주자"라며 완전히 피하려 합니다.

 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
// 겉보기에는 단순해 보이는 코드
public class ConfigurationManager {
    private static ConfigurationManager instance;
    private Properties config;
    
    private ConfigurationManager() {
        // 설정 파일 로드
        config = new Properties();
        // ...
    }
    
    public static ConfigurationManager getInstance() {
        if (instance == null) {
            instance = new ConfigurationManager();
        }
        return instance;
    }
    
    public String getProperty(String key) {
        return config.getProperty(key);
    }
}

// 어디서든 쉽게 접근 가능
String dbUrl = ConfigurationManager.getInstance().getProperty("db.url");

이 코드는 언뜻 보기에 완벽해 보입니다. 전역적으로 접근 가능하고, 메모리 효율적이며, 구현도 간단합니다. 하지만 여기에는 보이지 않는 함정들이 도사리고 있습니다.

Singleton이 논란의 중심에 있는 이유:

  1. 편의성 vs 설계 원칙: 사용하기는 쉽지만 좋은 설계 원칙들을 위반
  2. 성능 vs 안전성: 빠른 접근 vs Thread Safety 보장의 딜레마
  3. 단순성 vs 테스트: 구현은 간단하지만 테스트하기 어려움
  4. 전역 접근 vs 의존성 관리: 편한 접근 vs 명시적 의존성

이 글에서는 Singleton 패턴의 기술적 구현부터 철학적 논쟁까지, 그리고 언제 사용해야 하고 언제 피해야 하는지에 대한 명확한 가이드라인을 제시하겠습니다.

Singleton 패턴의 본질과 동기

GoF의 원래 의도

Gang of Four가 처음 Singleton 패턴을 제시했을 때의 목적은 명확했습니다:

“클래스의 인스턴스가 단 하나만 존재하도록 보장하고, 이에 대한 전역 접근점을 제공한다.” (원문: “Ensure a class only has one instance, and provide a global point of access to it.”) — Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides, 『Design Patterns: Elements of Reusable Object-Oriented Software』(1994), Singleton 패턴 Intent 절

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// GoF가 제시한 전형적인 사례
public class PrinterSpooler {
    private static PrinterSpooler instance;
    private Queue<PrintJob> jobQueue;
    
    private PrinterSpooler() {
        jobQueue = new LinkedList<>();
    }
    
    public static PrinterSpooler getInstance() {
        if (instance == null) {
            instance = new PrinterSpooler();
        }
        return instance;
    }
    
    public void addJob(PrintJob job) {
        jobQueue.offer(job);
    }
    
    // 물리적으로 하나의 프린터만 존재하므로 여러 인스턴스가 있으면 안 됨
}

“단 하나"가 필요한 진짜 상황들

Singleton이 비판받는 이유는 대부분 “굳이 하나일 필요가 없는 것"을 억지로 하나로 묶기 때문입니다. 반대로 아래 두 사례처럼 물리적 제약이나 시스템 전역 규약 때문에 정말로 인스턴스가 하나여야 하는 경우도 존재합니다. 이런 경우를 먼저 구분해두면, 뒤에서 다룰 “Singleton이 Anti-pattern으로 여겨지는 이유"를 읽을 때 “모든 Singleton이 나쁘다"는 과도한 일반화에 빠지지 않을 수 있습니다.

물리적 제약이 있는 리소스:

 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
// 파일 시스템 접근 관리자
public class FileSystemManager {
    private static FileSystemManager instance;
    private final Map<String, FileLock> lockMap;
    
    private FileSystemManager() {
        lockMap = new ConcurrentHashMap<>();
    }
    
    public static FileSystemManager getInstance() {
        if (instance == null) {
            synchronized (FileSystemManager.class) {
                if (instance == null) {
                    instance = new FileSystemManager();
                }
            }
        }
        return instance;
    }
    
    public boolean acquireLock(String filePath) {
        // 동일한 파일에 대한 중복 락 방지
        return lockMap.putIfAbsent(filePath, new FileLock(filePath)) == null;
    }
}

시스템 전역 상태 관리:

파일 락 관리자가 “동시에 두 개가 있으면 충돌이 나는” 물리적 제약형 사례였다면, 애플리케이션 설정은 성격이 다릅니다. 설정 파일 자체는 여러 번 읽어도 무방하지만, 매 호출마다 디스크 I/O로 다시 읽는 것은 비효율적이고, 동일한 프로세스 내에서 서로 다른 설정값을 보는 컴포넌트가 생기면 일관성 문제가 발생합니다. 그래서 “한 번 읽고 캐싱해 전역에서 공유"하는 용도로 Singleton이 흔히 쓰입니다.

 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
32
33
34
// 애플리케이션 설정 관리자
public class ApplicationConfig {
    private static volatile ApplicationConfig instance;
    private final Properties properties;
    
    private ApplicationConfig() {
        properties = new Properties();
        loadConfiguration();
    }
    
    public static ApplicationConfig getInstance() {
        if (instance == null) {
            synchronized (ApplicationConfig.class) {
                if (instance == null) {
                    instance = new ApplicationConfig();
                }
            }
        }
        return instance;
    }
    
    private void loadConfiguration() {
        // 설정 파일 로드 - 한 번만 실행되어야 함
        try (InputStream input = getClass().getResourceAsStream("/app.properties")) {
            properties.load(input);
        } catch (IOException e) {
            throw new RuntimeException("Failed to load configuration", e);
        }
    }
    
    public String getProperty(String key) {
        return properties.getProperty(key);
    }
}

흔한 오개념 바로잡기

Singleton을 처음 접하거나 겉핥기로만 아는 개발자가 자주 빠지는 오해 세 가지를 구현 방식과 논쟁을 본격적으로 다루기 전에 짚어둔다. 이 오해들을 먼저 정리해두면, 뒤에서 볼 구현 방식별 장단점과 Anti-pattern 논쟁을 “모든 Singleton이 나쁘다"거나 “Singleton이면 다 안전하다"는 식의 과도한 일반화 없이 읽을 수 있다.

오해 1: “private 생성자와 static 인스턴스 필드만 있으면 Singleton이다”

많은 코드가 정적 필드 하나와 getInstance() 메서드만 추가해 놓고 “이건 Singleton이다"라고 부른다. 하지만 GoF의 정의는 단순히 인스턴스를 하나 캐싱해 두는 것이 아니라 “인스턴스가 정확히 하나만 존재함을 보장(ensure)“하는 것이다. 앞서 본 ConfigurationManager처럼 if (instance == null) 검사만 있고 동기화가 없으면, 멀티스레드 환경에서 두 스레드가 동시에 진입해 인스턴스가 실제로 두 개 생성될 수 있다. 즉 코드의 겉모양은 Singleton이지만 “유일성 보장"이라는 패턴의 핵심 계약은 깨져 있는 상태다. getInstance()라는 이름과 실제 유일성 보장 여부는 별개로 확인해야 한다.

오해 2: “Singleton은 정적 유틸리티 클래스(static utility class)와 같은 것이다”

MathUtils.getInstance().calculateArea(radius)처럼 상태가 전혀 없는 계산 로직에도 Singleton을 씌우는 코드를 흔히 볼 수 있다. 그러나 Singleton은 “생명주기를 가진 상태 있는 인스턴스"를 하나로 유지하려는 패턴이고, 상태가 없는 순수 함수 모음에는 애초에 인스턴스 자체가 필요 없다. 앞서 본 MathUtils/BetterMathUtils 비교처럼, 상태 없는 유틸리티는 private 생성자로 인스턴스화만 막은 static 메서드 모음으로 표현하는 것이 정확하며, 여기에 getInstance()를 덧씌우는 것은 불필요한 간접 호출과 “인스턴스가 하나뿐"이라는 잘못된 의미만 추가할 뿐이다.

오해 3: “GoF 원저가 처음부터 Singleton을 Anti-pattern으로 규정했다”

Singleton을 둘러싼 논쟁이 워낙 격렬하다 보니 “GoF 스스로도 이걸 나쁜 패턴이라 인정했다"고 오해하기 쉽다. 하지만 앞서 인용한 것처럼 Gamma 등의 1994년 원저는 Singleton을 다섯 가지 생성 패턴(Creational Pattern) 중 하나로 정식 소개했을 뿐, Anti-pattern으로 분류하지 않았다. “Anti-pattern” 평가는 이후 단위 테스트와 의존성 주입을 중시하는 흐름(2000년대 이후 TDD·DI 확산)에서 나온 비판이다. 이 글에서 다루는 “논란"은 원저 자체의 결함이 아니라, 원저가 크게 고려하지 않았던 사용 맥락(대규모 자동화 테스트, 분산 시스템)에서 패턴이 드러내는 한계에 관한 것이다.

다양한 Singleton 구현 방식 심화 분석

Eager Initialization (이른 초기화)

앞서 본 ConfigurationManagergetInstance()if (instance == null) 검사와 생성이 원자적이지 않아, 두 스레드가 동시에 진입하면 인스턴스가 두 번 생성될 수 있습니다. Eager Initialization은 이 경쟁 조건 자체를 없애기 위해 인스턴스 생성 시점을 “최초 호출 시"가 아니라 “클래스 로딩 시"로 앞당깁니다. 클래스 로더가 초기화를 직렬화해 주므로 별도의 동기화 코드 없이 Thread Safety를 확보할 수 있습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public class EagerSingleton {
    // 클래스 로딩 시점에 인스턴스 생성
    private static final EagerSingleton INSTANCE = new EagerSingleton();
    
    private EagerSingleton() {
        // 생성자에서 복잡한 초기화 작업 수행
        initializeResources();
    }
    
    public static EagerSingleton getInstance() {
        return INSTANCE;
    }
    
    private void initializeResources() {
        // 데이터베이스 연결, 파일 로드 등
        System.out.println("Initializing singleton resources...");
    }
}

장점:

  • Thread-Safe: 클래스 로더가 Thread Safety 보장
  • 단순함: 구현이 매우 간단
  • 성능: 동기화 오버헤드 없음

단점:

  • 메모리 낭비: 사용하지 않아도 메모리 점유
  • 초기화 시간: 애플리케이션 시작 시 부담
  • 예외 처리 어려움: 생성자 예외 처리 복잡

Lazy Initialization (늦은 초기화)

Eager Initialization은 Thread Safety를 얻는 대신 애플리케이션 시작 시점에 무조건 인스턴스를 만들어 메모리와 초기화 시간을 소비합니다. 인스턴스가 실제로 필요할지 알 수 없거나 초기화 비용이 큰 경우에는 낭비입니다. Lazy Initialization은 최초 호출 시점까지 생성을 미루되, getInstance() 전체를 synchronized로 감싸 앞선 경쟁 조건 문제를 다시 막습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class LazySingleton {
    private static LazySingleton instance;
    
    private LazySingleton() {
        // 필요할 때까지 초기화 지연
        System.out.println("Lazy singleton created");
    }
    
    // synchronized 키워드로 Thread Safety 보장
    public static synchronized LazySingleton getInstance() {
        if (instance == null) {
            instance = new LazySingleton();
        }
        return instance;
    }
}

장점:

  • 메모리 효율: 필요할 때만 생성
  • 지연 초기화: 애플리케이션 시작 시간 단축

단점:

  • 성능 저하: 매번 동기화 오버헤드
  • 확장성 제한: 멀티스레드 환경에서 병목

Double-Checked Locking (DCL)

Lazy Initialization의 synchronized 메서드는 인스턴스가 이미 생성된 이후의 모든 호출에서도 매번 락을 획득해, 뒤에서 볼 벤치마크처럼 수십 배의 성능 저하를 유발합니다. DCL은 instance == null을 동기화 블록 진입 전후로 두 번 검사해, 락이 필요한 최초 생성 구간에만 동기화 비용을 지불하고 이후 호출은 락 없이 즉시 반환하도록 합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public class DCLSingleton {
    // volatile 키워드 필수!
    private static volatile DCLSingleton instance;
    
    private DCLSingleton() {
        System.out.println("DCL singleton created");
    }
    
    public static DCLSingleton getInstance() {
        // 첫 번째 체크 - 동기화 블록 진입 최소화
        if (instance == null) {
            synchronized (DCLSingleton.class) {
                // 두 번째 체크 - 실제 인스턴스 생성 보장
                if (instance == null) {
                    instance = new DCLSingleton();
                }
            }
        }
        return instance;
    }
}

DCL의 미묘한 문제 - Reordering:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// JVM이 최적화를 위해 코드 순서를 바꿀 수 있음
// instance = new DCLSingleton(); 는 실제로 3단계:
// 1. 메모리 할당
// 2. 생성자 호출
// 3. instance 변수에 할당

// 2와 3의 순서가 바뀌면 문제 발생!
// Thread A: 메모리 할당 → instance 할당 → 생성자 호출 (진행 중)
// Thread B: instance != null로 판단하고 미완성 객체 사용!

// volatile이 이 문제를 해결함

Bill Pugh Solution (Initialization-on-demand holder)

DCL은 성능 문제는 해결하지만 volatile 키워드 없이는 앞서 살펴본 재정렬(reordering) 버그가 남고, 팀원 전원이 메모리 모델을 이해해야 안전하게 유지보수할 수 있다는 부담이 있습니다. Bill Pugh Solution은 동기화 코드를 아예 작성하지 않고, 정적 중첩 클래스가 getInstance() 최초 호출 시에만 로드된다는 JVM 클래스 로딩 규약을 이용해 지연 초기화와 Thread Safety를 동시에 달성합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public class BillPughSingleton {
    private BillPughSingleton() {
        System.out.println("Bill Pugh singleton created");
    }
    
    // 내부 클래스는 getInstance() 호출 시점에 로드됨
    private static class SingletonHelper {
        private static final BillPughSingleton INSTANCE = new BillPughSingleton();
    }
    
    public static BillPughSingleton getInstance() {
        return SingletonHelper.INSTANCE;
    }
}

작동 원리:

1
2
3
4
5
// JVM의 클래스 로딩 메커니즘을 영리하게 활용
// 1. BillPughSingleton 클래스 로드 시 SingletonHelper는 로드되지 않음
// 2. getInstance() 호출 시 SingletonHelper 클래스 로드
// 3. 클래스 로더가 Thread Safety 보장
// 4. 지연 초기화 + Thread Safety + 성능 최적화 모두 달성!

장점:

  • Lazy Loading: 필요할 때만 초기화
  • Thread-Safe: JVM 클래스 로더가 보장
  • 성능: 동기화 오버헤드 없음
  • 우아함: 복잡한 로직 없이 간단

Enum Singleton - Joshua Bloch의 권장사항

Bill Pugh Solution은 지연 초기화와 성능 문제를 모두 해결하지만, 여전히 일반 클래스이기 때문에 직렬화/역직렬화 과정에서 새 인스턴스가 생길 수 있고 리플렉션으로 private 생성자를 강제 호출하면 단일성이 깨집니다. Enum Singleton은 JVM이 언어 차원에서 열거형 인스턴스의 유일성과 직렬화 안전성을 보장하도록 만들어, 이 두 가지 공격 경로를 근본적으로 차단합니다.

 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
public enum EnumSingleton {
    INSTANCE;
    
    private final Properties config;
    
    // Enum 생성자는 private으로 제한됨
    EnumSingleton() {
        config = new Properties();
        loadConfiguration();
    }
    
    public void doSomething() {
        System.out.println("Enum singleton working...");
    }
    
    public String getProperty(String key) {
        return config.getProperty(key);
    }
    
    private void loadConfiguration() {
        // 설정 로드 로직
    }
}

// 사용법
EnumSingleton.INSTANCE.doSomething();
String value = EnumSingleton.INSTANCE.getProperty("key");

Enum Singleton의 특별한 장점:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 1. 직렬화 안전
// 일반 Singleton은 직렬화/역직렬화 시 새 인스턴스 생성 위험
// Enum은 JVM이 직렬화 시 단일성 보장

// 2. 리플렉션 공격 방지
// 일반 Singleton은 리플렉션으로 private 생성자 호출 가능
try {
    Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
    constructor.setAccessible(true);
    Singleton hackInstance = constructor.newInstance(); // 가능!
} catch (Exception e) {
    // ...
}

// Enum은 리플렉션으로 인스턴스 생성 불가능
try {
    Constructor<EnumSingleton> constructor = EnumSingleton.class.getDeclaredConstructor();
    constructor.setAccessible(true);
    EnumSingleton hackInstance = constructor.newInstance(); // 런타임 에러!
} catch (Exception e) {
    System.out.println("Cannot instantiate enum: " + e.getMessage());
}

Thread Safety와 성능 최적화 심화

성능 벤치마크 분석

앞서 다섯 가지 구현 방식(Eager, Lazy, DCL, Bill Pugh, Enum)을 살펴보았지만, “Thread-Safe하다"는 것과 “빠르다"는 것은 별개의 문제입니다. 특히 synchronized 키워드를 매 호출마다 거치는 방식은 락 경합으로 인한 오버헤드가 이론과 실제에서 크게 다르게 나타날 수 있습니다. 아래 JMH 벤치마크는 다섯 방식의 상대적 성능 차이를 실측해, “왜 Lazy Synchronized를 피해야 하는가"를 수치로 뒷받침합니다.

 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class SingletonPerformanceBenchmark {
    
    @Benchmark
    public EagerSingleton testEager() {
        return EagerSingleton.getInstance();
    }
    
    @Benchmark
    public LazySingleton testLazy() {
        return LazySingleton.getInstance();
    }
    
    @Benchmark
    public DCLSingleton testDCL() {
        return DCLSingleton.getInstance();
    }
    
    @Benchmark
    public BillPughSingleton testBillPugh() {
        return BillPughSingleton.getInstance();
    }
    
    @Benchmark
    public EnumSingleton testEnum() {
        return EnumSingleton.INSTANCE;
    }
}

/*
JMH 벤치마크 예시 결과 (나노초/operation):
※ 아래 수치는 경향을 보여주기 위한 예시값이다. 절대값은 JVM 버전·하드웨어·
   JIT 워밍업 상태에 따라 크게 달라지므로, 위 벤치마크 코드를 직접 실행해
   자신의 환경에서 측정할 것.

구현 방식               | 평균 시간 | 표준편차 | Throughput
Eager Initialization   |    2.1   |   ±0.1  |  매우 높음
Bill Pugh Solution     |    2.3   |   ±0.1  |  매우 높음
Enum Singleton         |    1.8   |   ±0.1  |  가장 높음
Double-Checked Locking |    2.7   |   ±0.2  |  높음
Lazy Synchronized      |   45.2   |   ±2.1  |  낮음 (병목!)

결론(환경과 무관하게 유지되는 경향):
- 동기화 없는 방식(Enum/Eager/Bill Pugh)은 초기화 후 오버헤드가 거의 없음
- 매 호출 synchronized(Lazy Synchronized)만 수십 배 느림 — 락 경합이 원인
- 초기화 후에는 나머지 방식 간 차이가 미미함
*/

Singleton이 Anti-pattern으로 여겨지는 이유

전역 상태의 문제점 - 숨겨진 의존성

지금까지는 “어떻게 하면 Singleton을 안전하고 빠르게 구현할 것인가"에 집중했습니다. 하지만 구현이 완벽해도 Singleton이라는 접근 방식 자체가 만드는 구조적 문제가 있습니다. 그중 가장 먼저 드러나는 것이 숨겨진 의존성입니다. 아래 OrderService는 메서드 시그니처만 봐서는 어떤 외부 컴포넌트에 의존하는지 전혀 알 수 없고, 이는 코드를 읽는 사람과 테스트를 작성하는 사람 모두에게 부담이 됩니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 겉보기에는 깔끔해 보이는 코드
public class OrderService {
    public void processOrder(Order order) {
        // 숨겨진 의존성들!
        String dbUrl = ConfigManager.getInstance().getDbUrl();
        Logger logger = LoggerManager.getInstance();
        PaymentGateway gateway = PaymentGatewayFactory.getInstance().getGateway();
        
        logger.log("Processing order: " + order.getId());
        
        // 비즈니스 로직
        gateway.processPayment(order.getPayment());
        
        logger.log("Order processed successfully");
    }
}

// 문제점 분석:
// 1. 의존성이 명시되지 않음 - 메서드 시그니처만 보고는 알 수 없음
// 2. 테스트 시 Mock 객체 주입 불가능
// 3. 설정 변경이 전역적으로 영향
// 4. 코드 추적이 어려움

테스트의 어려움

 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// 테스트하기 어려운 Singleton 의존 코드
public class EmailService {
    public void sendEmail(String to, String subject, String body) {
        EmailConfig config = EmailConfig.getInstance();
        SmtpClient client = SmtpClient.getInstance();
        
        Email email = new Email(to, subject, body);
        email.setFrom(config.getFromAddress());
        
        client.send(email);
    }
}

// 테스트 코드 - 문제가 많음
@Test
public void testSendEmail() {
    // 문제 1: Singleton 상태 초기화 어려움
    EmailConfig.reset(); // 이런 메서드가 있다면...
    SmtpClient.reset();  // 하지만 보통 없음!
    
    // 문제 2: Mock 객체 주입 불가능
    // Mockito 3.4+의 mockStatic()으로 static 메서드 mocking이 가능해졌지만,
    // try-with-resources로 스코프를 관리해야 하고 테스트가 장황해짐 —
    // "가능하다"와 "설계가 좋다"는 다른 문제
    
    // 문제 3: 테스트 간 격리 실패
    // 이전 테스트의 상태가 영향을 줄 수 있음
    
    EmailService service = new EmailService();
    service.sendEmail("test@example.com", "Test", "Body");
    
    // 검증도 어려움 - Mock이 없으면 실제 이메일이 발송됨!
}

// 더 나은 설계
public class TestableEmailService {
    private final EmailConfig config;
    private final SmtpClient client;
    
    // 의존성 주입
    public TestableEmailService(EmailConfig config, SmtpClient client) {
        this.config = config;
        this.client = client;
    }
    
    public void sendEmail(String to, String subject, String body) {
        Email email = new Email(to, subject, body);
        email.setFrom(config.getFromAddress());
        client.send(email);
    }
}

// 테스트 코드 - 깔끔함
@Test
public void testSendEmail() {
    // Mock 객체 생성
    EmailConfig mockConfig = mock(EmailConfig.class);
    SmtpClient mockClient = mock(SmtpClient.class);
    
    when(mockConfig.getFromAddress()).thenReturn("noreply@example.com");
    
    // 테스트
    TestableEmailService service = new TestableEmailService(mockConfig, mockClient);
    service.sendEmail("test@example.com", "Test", "Body");
    
    // 검증
    verify(mockClient).send(any(Email.class));
}

확장성 저해 - 분산 시스템의 한계

 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 단일 JVM에서만 작동하는 Singleton
public class DistributedCacheManager {
    private static DistributedCacheManager instance;
    private final Map<String, Object> cache;
    
    private DistributedCacheManager() {
        cache = new ConcurrentHashMap<>();
    }
    
    public static DistributedCacheManager getInstance() {
        if (instance == null) {
            instance = new DistributedCacheManager();
        }
        return instance;
    }
    
    public void put(String key, Object value) {
        cache.put(key, value);
    }
    
    public Object get(String key) {
        return cache.get(key);
    }
}

// 문제점:
// 1. 서버 A의 캐시와 서버 B의 캐시가 다를 수 있음
// 2. 로드 밸런싱 환경에서 데이터 불일치
// 3. 마이크로서비스 간 상태 공유 불가능
// 4. 수평 확장 시 각 인스턴스마다 별도의 "싱글톤"

// 더 나은 접근법: 외부 캐시 시스템 사용
@Service
public class RedisBasedCacheManager {
    private final RedisTemplate<String, Object> redisTemplate;
    
    public RedisBasedCacheManager(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
    
    public void put(String key, Object value) {
        redisTemplate.opsForValue().set(key, value);
    }
    
    public Object get(String key) {
        return redisTemplate.opsForValue().get(key);
    }
}

Singleton의 올바른 사용 시나리오

진정한 단일 리소스

 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
// 물리적으로 하나만 존재하는 리소스
public class HardwareManager {
    private static HardwareManager instance;
    
    private HardwareManager() {
        // 하드웨어 초기화
        initializeGPU();
        initializeSensors();
    }
    
    public static synchronized HardwareManager getInstance() {
        if (instance == null) {
            instance = new HardwareManager();
        }
        return instance;
    }
    
    public void controlMotor(int speed) {
        // 물리적 모터 제어 - 동시에 여러 명령이 오면 안 됨
    }
    
    public SensorData readSensors() {
        // 센서 데이터 읽기
        return new SensorData();
    }
}

무상태 유틸리티

 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
32
33
34
35
// 상태가 없는 유틸리티 클래스
public class MathUtils {
    private static final MathUtils INSTANCE = new MathUtils();
    
    private MathUtils() {}
    
    public static MathUtils getInstance() {
        return INSTANCE;
    }
    
    public double calculateDistance(Point p1, Point p2) {
        double dx = p1.getX() - p2.getX();
        double dy = p1.getY() - p2.getY();
        return Math.sqrt(dx * dx + dy * dy);
    }
    
    public double calculateArea(double radius) {
        return Math.PI * radius * radius;
    }
}

// 하지만 이런 경우는 static 메서드가 더 적합할 수 있음
public class BetterMathUtils {
    private BetterMathUtils() {} // 인스턴스화 방지
    
    public static double calculateDistance(Point p1, Point p2) {
        double dx = p1.getX() - p2.getX();
        double dy = p1.getY() - p2.getY();
        return Math.sqrt(dx * dx + dy * dy);
    }
    
    public static double calculateArea(double radius) {
        return Math.PI * radius * radius;
    }
}

시스템 전반의 공통 기능

 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
32
33
34
35
36
37
38
39
40
// 로깅 시스템 - 실제로 많이 사용되는 패턴
public class ApplicationLogger {
    private static volatile ApplicationLogger instance;
    private final PrintWriter logWriter;
    private final String logFilePath;
    
    private ApplicationLogger() {
        this.logFilePath = "application.log";
        try {
            this.logWriter = new PrintWriter(new FileWriter(logFilePath, true));
        } catch (IOException e) {
            throw new RuntimeException("Failed to initialize logger", e);
        }
    }
    
    public static ApplicationLogger getInstance() {
        if (instance == null) {
            synchronized (ApplicationLogger.class) {
                if (instance == null) {
                    instance = new ApplicationLogger();
                }
            }
        }
        return instance;
    }
    
    public synchronized void log(String level, String message) {
        String timestamp = LocalDateTime.now().toString();
        logWriter.println(String.format("[%s] %s: %s", timestamp, level, message));
        logWriter.flush();
    }
    
    public void info(String message) {
        log("INFO", message);
    }
    
    public void error(String message) {
        log("ERROR", message);
    }
}

현대적 대안들

지금까지의 구현들은 모두 “Thread-Safe하게 유일한 인스턴스를 어떻게 만들 것인가"라는 문제를 풀었지만, Singleton의 근본 문제인 숨겨진 의존성과 테스트 어려움은 그대로 남습니다. 아래 대안들은 유일성을 포기하지 않으면서도 의존성을 명시적으로 드러내는 방향으로 접근합니다.

Dependency Injection

Singleton은 getInstance()를 호출하는 모든 코드에 클래스가 하드코딩되어 Mock으로 교체할 수 없습니다. DI는 인스턴스를 하나만 유지하는 책임을 컨테이너(Spring 등)로 옮기고, 사용하는 쪽에는 생성자를 통해 명시적으로 주입해 의존성을 코드에 드러내고 테스트 시 대체 가능하게 만듭니다.

 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
32
33
34
35
36
37
38
// Spring의 관리하는 Singleton
@Component
@Scope("singleton")  // 기본값이므로 생략 가능
public class ConfigurationService {
    private final Properties properties;
    
    public ConfigurationService() {
        properties = new Properties();
        loadConfiguration();
    }
    
    public String getProperty(String key) {
        return properties.getProperty(key);
    }
    
    private void loadConfiguration() {
        // 설정 로드
    }
}

// 사용하는 쪽
@Service
public class OrderService {
    private final ConfigurationService configService;
    private final Logger logger;
    
    // 의존성이 명시적으로 주입됨
    public OrderService(ConfigurationService configService, Logger logger) {
        this.configService = configService;
        this.logger = logger;
    }
    
    public void processOrder(Order order) {
        String dbUrl = configService.getProperty("db.url");
        logger.info("Processing order: " + order.getId());
        // ...
    }
}

Static Factory Methods

DI는 상태를 가진 서비스에는 적합하지만, 애초에 상태가 없는 순수 유틸리티 함수 모음까지 컨테이너에 등록하는 것은 과합니다. Static Factory Methods는 인스턴스 개념 자체를 없애 “하나만 존재해야 하는가"라는 질문을 무의미하게 만들고, private 생성자로 인스턴스화만 막습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 인스턴스화를 방지하는 유틸리티 클래스
public class DateUtils {
    private DateUtils() {} // 인스턴스화 방지
    
    public static String formatDate(LocalDate date) {
        return date.format(DateTimeFormatter.ISO_LOCAL_DATE);
    }
    
    public static LocalDate parseDate(String dateString) {
        return LocalDate.parse(dateString, DateTimeFormatter.ISO_LOCAL_DATE);
    }
    
    public static boolean isWeekend(LocalDate date) {
        DayOfWeek dayOfWeek = date.getDayOfWeek();
        return dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY;
    }
}

Functional Approach

Java 계열 해법들은 클래스와 접근 제어자로 유일성을 강제하지만, JavaScript처럼 클래스가 선택 사항인 언어에서는 클로저만으로 같은 효과를 낼 수 있습니다. 모듈 스코프에 상태를 감추고 외부에는 함수 인터페이스만 노출하면, 별도의 클래스 설계 없이도 캡슐화된 단일 상태를 유지할 수 있습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// JavaScript에서의 모듈 패턴
const configModule = (() => {
    let config = {};
    
    return {
        setConfig: (newConfig) => {
            config = { ...config, ...newConfig };
        },
        getConfig: () => ({ ...config }),
        getProperty: (key) => config[key]
    };
})();

// 사용법
configModule.setConfig({ dbUrl: 'localhost:5432' });
const dbUrl = configModule.getProperty('dbUrl');

실무 적용 가이드라인

Singleton 사용 결정 트리

아래 결정 트리는 앞서 다룬 네 가지 판단 축(물리적 제약, 상태 유무, 공유 범위, 테스트 중요도)을 하나의 흐름으로 연결한 것이다. 각 질문에 “아니오"로 답할 때마다 다음 질문으로 넘어가며, 마지막 질문까지 도달했다면 그때는 신중하게 Singleton을 선택지로 남겨 둔다.

flowchart TD
    Start["Singleton을 고려하는 상황인가?"] --> Q1{"물리적으로 하나만
존재해야 하는가?"} Q1 -->|"Yes"| A1["Singleton 고려
(하드웨어, 파일 시스템 등)"] Q1 -->|"No"| Q2{"상태가 없는
유틸리티인가?"} Q2 -->|"Yes"| A2["Static Methods 고려"] Q2 -->|"No"| Q3{"시스템 전반에서
공유되는 상태인가?"} Q3 -->|"Yes"| A3["DI Container 관리
Singleton 고려"] Q3 -->|"No"| Q4{"테스트 가능성이
중요한가?"} Q4 -->|"Yes"| A4["DI 사용"] Q4 -->|"No"| A5["Singleton 고려
(신중하게)"]

구현 방식 선택 가이드

 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
32
33
34
35
36
37
38
39
// 상황별 최적 구현 선택
public class SingletonChoiceGuide {
    
    // 1. 성능이 중요하고 즉시 초기화해도 되는 경우
    public class EagerCase {
        private static final EagerCase INSTANCE = new EagerCase();
        public static EagerCase getInstance() { return INSTANCE; }
    }
    
    // 2. 메모리 효율이 중요하고 복잡한 초기화가 없는 경우
    public class BillPughCase {
        private static class Helper {
            private static final BillPughCase INSTANCE = new BillPughCase();
        }
        public static BillPughCase getInstance() { return Helper.INSTANCE; }
    }
    
    // 3. 직렬화가 중요한 경우
    public enum EnumCase {
        INSTANCE;
        public void doSomething() { /* ... */ }
    }
    
    // 4. 대부분의 일반적인 경우
    public class GeneralCase {
        private static volatile GeneralCase instance;
        
        public static GeneralCase getInstance() {
            if (instance == null) {
                synchronized (GeneralCase.class) {
                    if (instance == null) {
                        instance = new GeneralCase();
                    }
                }
            }
            return instance;
        }
    }
}

Singleton 리팩토링 전략

 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 기존 Singleton 코드
public class LegacySingleton {
    private static LegacySingleton instance;
    
    public static LegacySingleton getInstance() {
        if (instance == null) {
            instance = new LegacySingleton();
        }
        return instance;
    }
    
    public void doSomething() {
        // 비즈니스 로직
    }
}

// 1단계: 인터페이스 추출
public interface BusinessService {
    void doSomething();
}

public class LegacySingleton implements BusinessService {
    private static LegacySingleton instance;
    
    public static LegacySingleton getInstance() {
        if (instance == null) {
            instance = new LegacySingleton();
        }
        return instance;
    }
    
    @Override
    public void doSomething() {
        // 비즈니스 로직
    }
}

// 2단계: 일반 클래스로 변환
public class RefactoredBusinessService implements BusinessService {
    @Override
    public void doSomething() {
        // 동일한 비즈니스 로직
    }
}

// 3단계: DI로 관리
@Component
public class FinalBusinessService implements BusinessService {
    @Override
    public void doSomething() {
        // 동일한 비즈니스 로직
    }
}

한눈에 보는 Singleton 패턴

Singleton 구현 방식 비교표

구현 방식Thread-SafeLazy Loading성능직렬화 안전구현 복잡도
Eager InitializationOX최상X낮음
Synchronized MethodOO나쁨X낮음
Double-Checked LockingOO좋음X중간
Bill Pugh (Holder)OO최상X중간
Enum SingletonOX최상O낮음

구현 방식별 성능 벤치마크

구현 방식평균 시간 (ns, 예시값)표준편차권장 사용
Enum Singleton1.8±0.1직렬화 필요, 리플렉션 방지
Eager Initialization2.1±0.1즉시 초기화 허용
Bill Pugh Solution2.3±0.1지연 초기화 필요
Double-Checked Locking2.7±0.2volatile 이해 가능한 팀
Synchronized Method45.2±2.1사용 비권장

수치는 본문 벤치마크 코드의 예시 실행값으로, JVM·하드웨어에 따라 달라진다. 신뢰할 수 있는 것은 절대값이 아니라 “매 호출 동기화만 수십 배 느리다"는 경향이다.

Singleton vs 대안 패턴 비교

비교 항목SingletonDependency InjectionStatic Methods
테스트 용이성낮음높음중간
의존성 명시성숨겨짐명시적해당 없음
분산 환경 지원불가가능해당 없음
전역 접근가능제어됨가능
상태 관리가변유연함무상태
생명주기 제어어려움컨테이너 관리불필요

Singleton 사용 결정 가이드

상황권장 여부대안
물리적으로 하나만 존재해야 하는 리소스O 사용-
무상태 유틸리티△ 고려Static Methods
시스템 전반 설정 관리△ 고려DI + @Scope(“singleton”)
비즈니스 로직이 포함된 서비스X 피해야 함DI 사용
테스트가 중요한 컴포넌트X 피해야 함DI + Mock
분산/마이크로서비스 환경X 피해야 함외부 상태 저장소

Anti-pattern으로서의 Singleton 문제점

문제 유형설명영향
숨겨진 의존성메서드 시그니처에 드러나지 않음코드 이해도 저하
테스트 어려움Mock 객체 주입 불가단위 테스트 복잡
전역 상태예측 불가능한 부작용버그 발생 위험
확장성 저해단일 JVM에서만 동작수평 확장 불가
결합도 증가구체 클래스 직접 참조유지보수 어려움

적용 체크리스트

체크 항목확인 내용
진정한 필요성정말로 “하나"여야 하는가?
테스트 가능성Mock으로 대체 가능한가?
의존성 명시사용처에서 의존성이 드러나는가?
확장성분산 환경에서도 동작하는가?
대안 검토DI, Static Methods로 충분하지 않은가?

결론: Singleton 패턴의 현명한 사용

Singleton 패턴은 강력하지만 위험한 도구입니다. 올바르게 사용하면 시스템을 단순화하고 효율성을 높일 수 있지만, 잘못 사용하면 코드의 품질과 유지보수성을 크게 떨어뜨릴 수 있습니다.

Singleton 패턴의 핵심 교훈:

  1. 진정한 필요성 검토: 정말로 “하나"여야 하는지 신중히 판단
  2. 테스트 가능성 우선: 테스트하기 어려우면 설계를 재고
  3. 의존성 명시: 숨겨진 의존성은 코드를 취약하게 만듦
  4. 현대적 대안 고려: DI Container, Static Methods 등 검토
  5. 확장성 고려: 분산 환경에서도 작동할지 검토

사용 여부 판단 기준은 앞의 “Singleton 사용 결정 가이드” 표와 “적용 체크리스트” 표를 참조한다.

미래의 관점에서 보면, 클라우드 네이티브와 마이크로서비스 아키텍처가 주류가 되면서 전통적인 Singleton 패턴의 활용도는 줄어들 것입니다. 대신 외부 상태 저장소(Redis, Database)와 DI Container가 Singleton의 역할을 더 안전하고 확장 가능한 방식으로 대체하고 있습니다.

그럼에도 불구하고 Singleton 패턴을 이해하는 것은 중요합니다. 왜냐하면 기존 레거시 시스템을 이해하고, 올바른 설계 판단을 내리며, 더 나은 대안을 선택하기 위해서는 Singleton의 장단점을 명확히 알고 있어야 하기 때문입니다. 사용 전에 신중히 고려하고 사용 후에도 지속적으로 그 필요성을 검토해야 하며, 때로는 사용하지 않는 것이 더 나은 설계일 수 있다는 점이 이 글 전체를 관통하는 핵심입니다.

다음 글에서는 Builder와 Prototype 패턴을 살펴보겠습니다. 복잡한 객체를 생성하는 두 가지 서로 다른 접근법과 그들의 현대적 활용을 깊이 있게 탐구해보겠습니다.

평가 기준

독자가 이 글을 읽은 후 달성해야 할 목표:

  • 5가지 Singleton 구현 방식의 차이점과 특징을 설명할 수 있다
  • Singleton이 적절한 상황과 부적절한 상황을 구분할 수 있다
  • Thread-safety 문제를 이해하고 해결할 수 있다
  • Singleton의 대안들을 제시하고 비교할 수 있다
  • 기존 Singleton 코드를 더 나은 설계로 리팩토링할 수 있다

참고 문헌