Post

Singleton 에 대하여

Singleton 에 대하여

들어가며

프로그래밍을 하다 보면 특정 객체가 딱 1개 필요한 상황이 있다. 예를 들어 설정 파일 관리자, 로그 기록기 등이 있다. 이런 경우 사용하는 것이 싱글턴 패턴 이다.


싱글턴 패턴이란?

클래스의 인스턴스가 오직 하나만 생성도도록 보장하고, 이 인스턴스에 전역적으로 접근할 수 있는 방법을 제공하는 생성 디자인 패턴이다.


왜 싱글턴이 필요할까?

예시를 통해 알아가보자. 서비스를 개발할 때 다음과 같은 상황은 피하고 싶을 것이다.

1
2
3
DatabaseConnection db1 = new DatabaseConnection();
DatabaseConnection db2 = new DatabaseConnection();
DatabaseConnection db3 = new DatabaseConnection();

데이터베이스 연결이 3개나 생겼고 때문에 자원 낭비 및 데이터 일관성 문제가 생길 수 있다.

데이터베이스 연결 관리자는 하나만 있어도 충분하다. 오히려 단 1개만 있어야 한다.


싱글턴의 두 가지 책임

싱글턴이 해결하는 2가지 문제를 알아보자.

  1. 인스턴스 개수 제어 : 클래스의 인스턴스가 하나만 존재하도록 보장
  2. 전역 접근 제공 : 어디서든 이 인스턴스에 접근할 수 있는 방법을 제공


그러나 이것은 단일 책임 원칙 (Single Responsibility Principle) 을 위반하는 것으로 비판받기도 한다.


싱글턴 패턴 구현 방법

1. Eager Initialization (가장 단순한 형태)

1
2
3
4
5
6
7
8
9
10
11
public class EagerSingleton {
    // 클래스 로딩 시점에 인스턴스 생성
    private static final EagerSingleton instance = new EagerSingleton();
    
    // 외부에서 직접 생성하지 못하도록 생성자를 private으로
    private EagerSingleton() {}
    
    public static EagerSingleton getInstance() {
        return instance;
    }
}
  • 장점 : 단순한 구현, 스레드 안전
  • 단점 : 사용하지 않아도 인스턴스가 생성되어 메모리를 차지함


2. 정적 블록을 이용한 초기화

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class StaticBlockSingleton {
    private static StaticBlockSingleton instance;
    
    private StaticBlockSingleton() {}
    
    // 정적 블록에서 예외 처리 가능
    static {
        try {
            instance = new StaticBlockSingleton();
        } catch (Exception e) {
            throw new RuntimeException("싱글턴 인스턴스 생성 중 오류 발생");
        }
    }
    
    public static StaticBlockSingleton getInstance() {
        return instance;
    }
}
  • 장점 : 예외처리 가능
  • 단점 : 여전히 사용하지 않아도 인스턴스 생성됨


3. Lazy Initialization (지연 초기화)

1
2
3
4
5
6
7
8
9
10
11
12
public class LazySingleton {
    private static LazySingleton instance;
    
    private LazySingleton() {}
    
    public static LazySingleton getInstance() {
        if (instance == null) {
            instance = new LazySingleton();  // 필요할 때만 생성
        }
        return instance;
    }
}
  • 장점 : 실제 사용할 때까지는 인스턴스 생성을 미룰 수 있음
  • 단점 : 멀티스레드 환경에서 안전하지 않음

멀티스레드에서 왜 문제가 될까?

위 코드에서 Race Condition 이 발생하는 시나리오가 있다.

스레드 A, B가 있다고 하자. 두 스레드가 동시에 getInstance() 를 호출할 때

1
2
3
4
5
6
7
8
9
10
11
12
13
14
시간순               
                  스레드 A                               스레드 B
            ------------------------            ------------------------


  1.   if (instance == null)  true
         
  2.                                           if (instance == null)  true
         
  3.   instance = new LazySingleton() 생성
      
  4.                                           instance = new LazySingleton() 생성
                                                  객체2로 덮여씌워짐

이 상황에서

  • 2개의 서로 다른 인스턴스 생성
  • 싱글턴 패턴 x
  • 메모리 누수 (첫번째 객체 가비지 컬렉션 대상임)




싱글턴 패턴의 근본적인 문제들

위 구현들은 모두 문제점들을 가지고 있다. 이 문제들을 먼저 이해한 뒤 안전한 싱글턴을 만들어 보자.

문제 1. 리플렉션을 통한 파괴

Reflection을 사용하면 private 생성자에도 접근할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class ReflectionAttack {
    public static void main(String[] args) throws Exception {
        
        BillPughSingleton instance1 = BillPughSingleton.getInstance();
        
        // 리플렉션을 이용해 생성자에 접근
        Constructor<BillPughSingleton> constructor = BillPughSingleton.class.getDeclaredConstructor();
        constructor.setAccessible(true);  // private 접근 제한 해제
        
        BillPughSingleton instance2 = constructor.newInstance();
        
        
        System.out.println(instance1 == instance2); // false - 싱글턴이 깨짐!
        System.out.println(instance1.hashCode());   // 다른 해시코드
        System.out.println(instance2.hashCode());   // 다른 해시코드
    }
}


문제 2: 직렬화/역직렬화 문제

객체를 파일에 저장했다가 다시 읽어올 때 새로운 인스턴스가 생성될 수 있다.

1
2
3
4
5
6
7
8
9
public class SerializableSingleton implements Serializable {
    private static final SerializableSingleton instance = new SerializableSingleton();
    
    private SerializableSingleton() {}
    
    public static SerializableSingleton getInstance() {
        return instance;
    }
}

문제 예시 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class SerializationTest {
    public static void main(String[] args) throws Exception {
        SerializableSingleton instance1 = SerializableSingleton.getInstance();
        
        // 직렬화 (파일에 저장)
        ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("singleton.ser"));
        out.writeObject(instance1);
        out.close();
        
        // 역직렬화 (파일에서 읽기)
        ObjectInputStream in = new ObjectInputStream(new FileInputStream("singleton.ser"));
        SerializableSingleton instance2 = (SerializableSingleton) in.readObject();
        in.close();
        
        System.out.println(instance1 == instance2); // false - 다른 객체가 됨!
    }
}


문제 3. 테스트가 어렵다

싱글턴은 전역 상태를 가지므로 단위 테스트가 어렵다.

1
2
3
4
5
6
7
8
// 테스트하기 어려운 코드 예시

public class UserService {
    public boolean loginUser(String username, String password) {
        DatabaseConnection db = DatabaseConnection.getInstance(); // 싱글턴에 강하게 의존
        return db.authenticate(username, password);
    }
}

왜 테스트가 어려울까?

  1. Mock 객체 사용 불가: 싱글턴은 항상 같은 인스턴스를 반환하므로 테스트용 가짜 객체로 바꿀 수 없습니다.
  2. 테스트 간 상태 공유: 여러 테스트가 같은 싱글턴 인스턴스를 공유해서 테스트 결과가 서로 영향을 줄 수 있습니다.


**이제 위 문제들을 하나씩 해결하는 구현 방법들을 알아보자. **


4. 스레드 안전 싱글턴

지연 초기화의 멀티스레드 문제부터 해결해보자

1
2
3
4
5
6
7
8
9
10
11
12
13
public class ThreadSafeSingleton {
    private static ThreadSafeSingleton instance;
    
    private ThreadSafeSingleton() {}
    
    // synchronized 키워드로 스레드 안전성 확보
    public static synchronized ThreadSafeSingleton getInstance() {
        if (instance == null) {
            instance = new ThreadSafeSingleton();
        }
        return instance;
    }
}

synchronized 를 매번 거치는 것은 비효율적이다. 이것을 한번 더 개선해보자.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class DoubleCheckedLockingSingleton {
    // volatile 키워드가 중요!
    private static volatile DoubleCheckedLockingSingleton instance;
    
    private DoubleCheckedLockingSingleton() {}
    
    public static DoubleCheckedLockingSingleton getInstance() {
        // 성능 최적화를 위한 로컬 변수 사용
        DoubleCheckedLockingSingleton result = instance;
        if (result != null) {
            return result;
        }
        
        synchronized (DoubleCheckedLockingSingleton.class) {
            if (instance == null) {  // 동기화 블록 내에서 다시 체크
                instance = new DoubleCheckedLockingSingleton();
            }
            return instance;
        }
    }
}

이렇게 개선한 방법이 Double-Checked Locking 방식이다!

* volatile 이란?

Java에서 변수가 메인 메모리에서 직접 읽고 쓰여야 함을 JVM에 알려주는 키워드다. 멀티스레드 환경에서 변수의 가시성과 순서를 보장한다.

1. 가시성(Visibility) 보장

  • CPU 캐시를 우회하고 메인 메모리에서 직접 읽기/쓰기
  • 한 스레드의 변경사항이 다른 모든 스레드에게 즉시 보임

2. 순서 보장(Happens-Before Relationship)

  • Happens-Before 규칙:
    • volatile 변수 쓰기 이전의 모든 메모리 연산이 먼저 완료됨
    • volatile 변수 읽기 이후의 모든 메모리 연산이 나중에 실행됨


왜 volatile로 해결?

원인 분석부터 해보자.

객체 생성 instance = new Singleton(); 은 다음 과정을 거친다.

1
2
3
4
5
6
7
8
// 1단계: 메모리 할당
memory = allocate(Singleton.class);

// 2단계: 생성자 호출 (초기화)
constructor(memory);

// 3단계: instance 변수에 참조 할당
instance = memory;

문제 상황으로 명령어 재배열 (Instruction Recording) 이 있다.

JVM 최적화로 인해 순서가 바뀔 수 있는데

1
2
3
4
5
6
// 원래 순서: 1 → 2 → 3
// 재배열 후: 1 → 3 → 2

memory = allocate(Singleton.class);    // 1단계
instance = memory;                     // 3단계 (먼저 실행!)
constructor(memory);                   // 2단계 (나중에 실행!)

문제 상황으로는 두 스레드 A, B에서 잘못된 순서로 실행되면

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
         스레드 A                            스레드 B
------------------------           ------------------------
if (instance == null) {           
    synchronized (...) {           
        if (instance == null) {    
            // 1. 메모리 할당
            // 3. instance에 할당 (생성자 실행 전!)
                                   if (instance == null) {  // false!
                                       // 초기화 안된 객체 반환
                                   }
                                   return instance; // 반쪽짜리 객체!
            // 2. 생성자 실행 (늦게 실행됨)
        }
    }
}

스레드 B는 완전히 초기화되지 않은 객체를 할당받는다.

➡ volatile로 해결하기

  1. 메모리 가시성 보장 : 모든 스레드가 같은 값을 본다.
  2. 명령어 재배열 방지 : 객체 생성 3단계가 순서대로 실행된다.
  3. Happens-Before 보장 : 객체가 완전히 초기화된 후 다른 스레드가 접근한다.


아직 한계가 있다?

  1. 원자성 보장 문제 ```java private static volatile int counter = 0;

// 스레드 안전 X public static void increment() { counter++; // 실제로는 3단계: 읽기 → 증가 → 쓰기 }

// 올바른 방법 public static synchronized void increment() { counter++; } // 또는 private static AtomicInteger counter = new AtomicInteger(0);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
2. 복합연산도 부적합
```java
private static volatile boolean flag1 = false;
private static volatile boolean flag2 = false;

// 이것도 스레드 안전하지 않음
public static void updateFlags() {
    if (!flag1) {     // 읽기
        flag1 = true; // 쓰기
        flag2 = true; // 다른 쓰기
    }
    // flag1과 flag2의 일관성이 깨질 수 있음
}


5. Bill Pugh 방식 (권장되는 방법)

Double-Checked Locking의 복잡성을 피하고 싶다면 이 방식을 사용하자.

1
2
3
4
5
6
7
8
9
10
11
12
public class BillPughSingleton {
    private BillPughSingleton() {}
    
    // 내부 정적 클래스
    private static class SingletonHelper {
        private static final BillPughSingleton INSTANCE = new BillPughSingleton();
    }
    
    public static BillPughSingleton getInstance() {
        return SingletonHelper.INSTANCE;
    }
}

왜 이 방식이 좋을까?

JVM의 클래스 로딩 메커니즘을 잘 활용하기 때문!


JVM 클래스 로딩의 원리

JVM은 지연로딩 (Lazy Loading) 방식을 사용한다.

로딩 시점은

  • 해당 클래스 인스턴스 생성시
  • 해당 클래스 정적 메서드나 정적 변수 접근 시
  • 해당 클래스를 상속받은 하위 클래스가 로딩될 때

이렇게 정리할 수 있다.

핵심은 내부 클래스는 외부 클래스와 별개로 로딩된다는 점이다.


그럼 Bill Fugh 방식으로는 어떤 과정을 거칠까?

  1. BillPughSingleton 클래스가 로딩될 때 SingletonHelper 는 로딩되지 않는다.
  2. getInstance() 가 호출될 때 드디어 SingletonHelper 클래스가 로딩된다.
  3. 클래스 로딩은 JVM이 보장하는 스레드 안전 과정이다.


장점

  • 지연 로딩 가능 (getInstance() 호출될 때 SingletonHelper 클래스 로딩)
  • 스레드 안전 (JVM의 클래스 로딩 메커니즘 활용)
  • synchronized 키워드 없이도 안전함


여전히 리플렉션과 직렬화 문제에는 취약하다…

A. 리플렉션 공격 방어

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ReflectionSafeSingleton {
    private static volatile ReflectionSafeSingleton instance;
    private static boolean instanceCreated = false; // ← 핵심: 생성 플래그
    
    private ReflectionSafeSingleton() {
        // 생성자에서 중복 생성 체크
        if (instanceCreated) {
            throw new IllegalStateException("이미 인스턴스가 생성되었습니다!");
        }
        instanceCreated = true; // ← 첫 생성 후 플래그 설정
    }
    
    private static class Holder {
        // 정상 경로로 인스턴스 생성 (instanceCreated가 true가 됨)
        private static final ReflectionSafeSingleton INSTANCE = new ReflectionSafeSingleton();
    }
    
    public static ReflectionSafeSingleton getInstance() {
        return Holder.INSTANCE;
    }
}

이 방어코드도 완벽하진 않은게

  1. 공격자가 리플렉션으로 먼저 인스턴스를 생성하고
  2. 이후 정상적인 사용으로 getInstance() 가 호출되면 예외가 발생한다.

따라서 방어는 되지만 DOS(서비스 거부) 공격이 가능하다.


B. 직렬화 문제 해결

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class SerializationSafeSingleton implements Serializable {
    private SerializationSafeSingleton() {}
    
    private static class Holder {
        private static final SerializationSafeSingleton INSTANCE = new SerializationSafeSingleton();
    }
    
    public static SerializationSafeSingleton getInstance() {
        return Holder.INSTANCE;
    }
    
    // 역직렬화 시 기존 인스턴스 반환 (JVM이 역직렬화 과정에서 자동 호출)
    protected Object readResolve() {
        return getInstance(); // 새 객체 대신 기존 싱글턴 반환
    }
}


JVM 의 역직렬화 과정도 의사코드로 자세히 알아보면

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Object deserializeObject() {
    // 1. 새로운 객체 생성 (생성자 호출하지 않음)
    Object newObj = createObjectWithoutConstructor();
    
    // 2. 필드 값들 복원
    restoreFields(newObj);
    
    // 3. readResolve() 메서드가 있는지 확인
    if (hasReadResolveMethod(newObj)) {
        // 4. readResolve() 호출하고 그 결과를 반환
        return newObj.readResolve(); // ← 우리가 정의한 readResolve() 메서드 호출
    }
    
    // 5. readResolve()가 없으면 새 객체 반환
    return newObj;
}


6. Enum 방식 (가장 안전한 방법)

Effective Java의 저자 Joshua Bloch가 제안한 방법이다.

1
2
3
4
5
6
7
8
9
10
public enum EnumSingleton {
    INSTANCE;
    
    public void doSomething() {
        System.out.println("싱글턴에서 작업을 수행한다.");
    }
}

// 사용 방법
EnumSingleton.INSTANCE.doSomething();

왜 가장 안전할까?

JVM이 enum의 인스턴스가 하나만 생성되는것을 보장하기 때문!


**1. 리플렉션 공격 방어 : JVM이 enum의 리플렉션 기반 인스턴스 생성을 원천 차단 **

1
2
3
4
5
6
7
8
try {
    Constructor<EnumSingleton> constructor = EnumSingleton.class.getDeclaredConstructor();
    constructor.setAccessible(true);
    EnumSingleton instance = constructor.newInstance(); // 예외 발생!
} catch (Exception e) {
    System.out.println("리플렉션 공격 실패: " + e.getMessage());
    // java.lang.IllegalArgumentException: Cannot reflectively create enum objects
}


2. 직렬화/역직렬화 자동 처리 : JVM이 enum의 직렬화를 특별히 처리해서 항상 같은 인스턴스를 보장

JVM이 enum의 직렬화를 특별히 처리해서 항상 같은 인스턴스를 보장한다!


3. 스레드 안전성 자동 보장 : JVM이 enum 인스턴스 생성을 스레드 안전하게 처리


장점

  • 리플렉션 공격에 안전하다
  • 직렬화 / 역직렬화 문제 없음
  • 스레드 안전
  • 구현이 간단하다

단점

  • 지연 로딩이 불가능함 (enum은 클래스 로딩 시점에 모든 인스턴스가 생성됨)
  • 상속을 받을 수 없다 (enum은 이미 java.lang.Enum 을 상속받음)
  • 유연성이 떨어진다 (복잡한 초기화 로직 구현이 어려움)


싱글턴 테스트 문제 해결 : 의존성 주입 (DI)

테스트가 어려운 문제는 싱글턴 패턴의 근본적 문제다. 가장 좋은 해결책은 Dependency Injection, 즉 의존성 주입을 사용하는것이다.

개선된 설계
1
2
3
4
5
6
7
8
9
10
11
12
public class UserService {
    private DatabaseConnection db;
    
    // 생성자를 통해 의존성 주입
    public UserService(DatabaseConnection db) {
        this.db = db;
    }
    
    public boolean loginUser(String username, String password) {
        return db.authenticate(username, password);
    }
}

이제 테스트할 때 가짜 객체 (Mock) 을 주입할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
public void testLoginUser() {
    // Mock 객체 생성
    DatabaseConnection mockDb = mock(DatabaseConnection.class);
    when(mockDb.authenticate("user1", "password")).thenReturn(true);
    
    // 테스트 대상에 Mock 주입
    UserService userService = new UserService(mockDb);
    
    // 테스트 실행
    boolean result = userService.loginUser("user1", "password");
    
    // 검증
    assertTrue(result);
}


현대적 접근: 의존성 주입

최근에는 Spring Framework 같은 DI 컨테이너를 사용하여 싱글턴의 장점을 취하면서 단점을 보완한다.

1
2
3
4
5
6
7
8
9
10
@Component  // Spring에서 이 클래스를 빈으로 관리 (기본적으로 싱글턴)
public class UserService {
    
    @Autowired  // 의존성 주입
    private UserRepository userRepository;
    
    public User findUser(Long id) {
        return userRepository.findById(id);
    }
}

Spring의 싱글턴은 GoF 싱글턴과는 다르다

  • GoF 싱글턴: JVM 전체에서 하나의 인스턴스
  • Spring 싱글턴: 스프링 컨테이너 내에서 하나의 인스턴스


마치며

싱글턴 패턴은 “하나만 있으면 되는” 객체를 만들 때 유용한 패턴이다.

하지만 전역 상태와 강한 결합을 만들 수 있기 때문에 신중하게 사용해야한다.

  • Bill Pugh 방식이 가장 일반적인 권장 방식이다.
  • Enum 방식이 가장 안전하지만 유연성은 떨어지는 방식이다.
  • 현대적인 개발에서는 의존성 주입(DI) 을 통해 싱글턴의 이점을 더 안전하게 활용할 수 있다.
  • 테스트 가능성도 항상 고려해야한다.

싱글턴 패턴을 이해하는 것이 중요하며, 꼭 필요한 곳에서만 사용하고 유연한 방법을 고려해야함을 배웠다.



References

This post is licensed under CC BY 4.0 by the author.