TIL

  1. 싱글톤 패턴
  2. 싱글톤 패턴 구현 원리
  3. 싱글톤 패턴 구현 방법 종류

 


 

싱글톤 패턴

 

  • 유일한 객체를 만들기 위한 코드 패턴
  • 메모리 절약을 위해 기존에 만들어진 단 하나의 인스턴스를 활용한다.
  • 싱글톤 패턴이 적용된 객체가 필요한 경우는 리소스를 많이 차지하는 역할을 하는 무거운 클래스일 때 적합하다. 예를 들어, 데이터베이스 연결 모듈, 데이터베이스에 접속하는 작업은 무겁고 객체를 한번만 생성해서 돌려쓰면 되므로 싱글톤 패턴을 적용한다.

 

 

 

 

싱글톤 패턴 구현 원리

class Student {

    private static Student instance;

    private Student() {}

    public static Student getInstance() {
        if (instance == null) {
            instance = new Student();
        }
        return instance;
    }
}
  • 싱글톤으로 이용할 클래스를 외부에서 `new` 생성자를 통해 마구잡이로 인스턴스화 시키는 것을 제한하기 위해 생성자 메소드에 `private` 키워드를 붙여준다.
  • `getInstance()` 메소드에서 생성자를 초기화 해준다.
  • 클라이언트가 싱글톤 클래스로부터 인스턴스를 생성해 사용하려면 `getInstance()` 메소드를 호출하여 `instance` 필드가 null일 경우 초기화를 진행하고, null이 아닌 경우 기존에 생성한 객체를 반환하는 식으로 구성한다.

 

 

 

 

싱글톤 패턴 구현 방법 종류

 

  1. Eager Initialization
  2. Static block initialization
  3. Lazy initialization
  4. Thread safe initialization
  5. Double-Checked Locking
  6. LazyHolder

 

 

 

Eager Initialization

class Singleton {
    // 싱글톤 클래스 객체를 담을 인스턴스 변수
    private static final Singleton INSTANCE = new Singleton();

    // 생성자를 private로 선언 (외부에서 new 사용 X)
    private Singleton() {}

    public static Singleton getInstance() {
        return INSTANCE;
    }
}
  • 미리 한번만 만들어두는 가장 간단한 방법이다.
  • `static final`이라 멀티 스레드 환경에서도 안전하다.
  • 그러나 `static` 멤버는 당장 객체를 사용하지 않더라도 메모리에 적재하기 때문에 메모리 공간 낭비가 발생한다.
  • 예외 처리를 할 수 없다.

 

 

 

Static block initialization

class Singleton {
    // 싱글톤 클래스 객체를 담을 인스턴스 변수
    private static Singleton instance;

    // 생성자를 private로 선언 (외부에서 new 사용 X)
    private Singleton() {}

    // static block을 이용해 예외 처리
    static {
        try {
            instance = new Singleton();
        } catch (Exception e) {
            throw new RuntimeException("싱글톤 객체 생성 오류");
        }
    }

    public static Singleton getInstance() {
        return instance;
    }
}
  • `static block`(클래스가 로딩되고 클래스 변수가 준비된 후, 자동으로 실행되는 블록)을 이용해 예외를 잡을 수 있다.
  • 그러나 여전히 `static`의 특성으로 사용하지도 않는데도 메모리 공간을 차지한다.

 

 

 

Lazy initialization

class Singleton {
    // 싱글톤 클래스 객체를 담을 인스턴스 변수
    private static Singleton instance;

    // 생성자를 private로 선언 (외부에서 new 사용 X)
    private Singleton() {}

    // 외부에서 정적 메서드를 호출하면 그제서야 초기화 진행 (lazy)
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton(); // 오직 1개의 객체만 생성
        }
        return instance;
    }
}
  • 객체 생성에 대한 관리를 내부적으로 처리한다.
  • `getInstance()` 메소드를 호출했을 때 `instance` 변수의 null 유무에 따라 초기화 하거나 있는 객체를 반환한다.
  • 외부에서 해당 객체가 필요할 때, 초기화가 진행되기 때문에 쓸데없는 메모리 공간 낭비를 하지 않는다.
  • 그러나 Thread Safe 하지 않는 치명적인 단점을 가지고 있다. (객체가 2개 이상 생성될 확률이 있다)

 

 

 

Thread safe initialization

class Singleton {
    private static Singleton instance;

    private Singleton() {}

    // synchronized 메서드
    public static synchronized Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}
  • `synchronized` 키워드를 통해 `getInstance()` 메서드에 스레드들을 하나씩 접근할 수 있도록 설정한다. (동기화)
  • 하지만 여러개의 모듈들이 해당 객체를 가져올 때, `synchronized` 메소드에 오직 1개의 스레드만 진입할 수 있으므로 다른 스레드들이 blocking 상태이고 이는 곧 성능 이슈가 발생한다.

 

 

 

Double-Checked Locking

 

정의

멀티스레딩 환경에서 객체를 안전하게 초기화하기 위한 디자인 패턴이다. 멀티스레딩 환경에서 싱글톤 패턴을 구현할 때, 특정 객체가 하나만 생성되도록 보장해야한다. 가장 단순한 방법은 동기화를 사용하는 것이지만 이는 성능 저하를 초래할 수 있다. 더블 체크 라킹 디자인 패턴은 성능 저하를 최소화하면서도 객체의 단일 생성을 보장하는 방법이다.

 

싱글톤 객체를 가져올 때마다 매번 `synchronized` 동기화를 실행하는 것이 문제라면, 더블 체크 라킹 패턴은 최초 초기화할 때만 `synchronized`를 적용한다.

 

그러나 JVM에 따라서 여전히 Thread Safe 하지 않는 경우가 발생하기 때문에 사용하기를 지양하는 편이다.

스프링에서 싱글톤 만드는 코드 보면 이 패턴을 사용한다.

 

 

 

원리

1. 첫 번째 체크 (Non-Synchronized Check)

객체가 초기화 되었는지 확인한다. 이미 초기화된 경우, 동기화 블록을 건너뛰고 바로 객체를 반환한다.

 

2. 동기화 블록

객체가 초기화 되지 않은 경우에만 동기화 블록을 사용해서 다른 스레드가 동시에 객체를 초기화하지 못하도록 한다.

 

3. 두 번째 체크 (Synchronized Check)

동기화 블록 내부에서 다시 한 번 객체가 초기화 되었는지 확인한다. 다른 스레드가 이미 객체를 초기화 했을 수도 있으므로 이중 확인이 필요하다. 객체가 초기화 되지 않았다면 객체를 초기화한다.

 

 

 

예제 코드 및 멀티 스레드 환경 시나리오

class Singleton {
	// volatile 키워드 적용
    private static volatile Singleton instance; 

	// private 생성자로 외부에서 인스턴스를 생성할 수 없다.
    private Singleton() {}

	// Singleton 객체를 반환하는 메서드
    public static Singleton getInstance() {
    	// ⭐️첫번째 체크
        if (instance == null) {
            // Singleton 클래스 자체를 동기화 걸어버림
            synchronized (Singleton.class) {
            	// ⭐️두번째 체크
                if(instance == null) {
                	// 최초 초기화만 동기화 작업이 일어나서 리소스 낭비를 최소화
                    instance = new Singleton();
                }
            }
        }
        // 최초 초기화가 되면 앞으로 생성된 인스턴스만 반환
        return instance;
    }
}

(실제로 스프링의 싱글톤 빈 관리 코드를 살펴보고 싶다면 `DefaultSingletonBeanRegistry` 클래스의 `getSingleton()` 메서드 참고)

 

싱글톤 객체를 생성하는데 왜 더블 체크가 필요할까? 시나리오를 통해 알아보자.

 

1. 첫번째 스레드가 접근

  • 첫번째 스레드가 `getInstance()`를 호출한다.
  • `instance`가 null이므로 동기화 블록에 진입한다.

 

2. 두번째 스레드가 접근

  • 첫번째 스레드가 아직 동기화 블록 내부에서 `instance`를 초기화하기 전에 두번째 스레드가 `getInstance()`를 호출한다.
  • 두번째 스레드도 `instance`가 null이라고 판단하여 동기화 블록에 진입하려고 대기한다.

 

3. 첫번째 스레드가 초기화

  • 첫번째 스레드가 동기화 블록 내부에서 `instance`를 초기화한다.

 

4. 두번째 스레드가 동기화 블록에 진입

  • 첫번째 스레드가 동기화 블록을 빠져나가면 두번째 스레드가 동기화 블록에 진입한다.
  • 두번째 체크가 없다면 두번째 스레드는 여전히 `instance`가 null이라고 판단하고 다시 초기화를 시도한다.

 

 

결론

동기화 블록 내부에서 두번째 체크 로직을 추가하면 두번째 스레드가 동기화 블록에 진입한 후, 다시 `instance`가 null인지 확인하게 된다. 이미 첫번째 스레드에 의해 초기화가 완료된 상태이므로, 두번째 스레드는 `instance`가 null이 아님을 체크하고 초기화를 생략하게 된다. 이처럼 두번째 체크를 통해 객체가 여러 번 초기화되는 문제를 방지할 수 있다.

 

 

중요 포인트

`instance` 필드에 `volatile` 키워드를 붙인 이유는 무엇일까?

예를 들어, 2개의 스레드가 `getInstance()` 메소드에 거의 동시에 접근한다고 치자. 스레드1이 첫번째 체크 로직을 통과한다. 스레드2도 첫번째 체크 로직을 통과한다. 스레드1이 lock을 얻어 `synchronized` 블록으로 들어간다. `instance` 참조 변수는 여전히 null이므로 두번째 체크 로직을 통과한다. Singleton 인스턴스를 만들어 할당한다. 스레드1이 동기화 블록을 빠져나온다. 스레드2가 동기화 블록으로 들어가는데 cpu 로컬 캐시에 있는 값을 계속 참조하고 있다.(스레드2 입장에서는 첫번째 체크 로직을 통과할 때, `instance` 변수가 이미 null이란 걸 읽었음) 메인 메모리에서 `instance` 변수를 다시 읽어오지 않는 이상 instance 변수가 업데이트 되었는지, 안 되었는지 알 수가 없다.

 

 

 

LazyHolder

class Singleton {

    private Singleton() {}

    // static 내부 클래스를 이용
    // Holder로 만들어, 클래스가 메모리에 로드되지 않고 getInstance 메서드가 호출되어야 로드됨
    private static class SingleInstanceHolder {
        private static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return SingleInstanceHolder.INSTANCE;
    }
}
  • 권장되는 방법
  • 멀티스레드 환경에서 안전하고 Lazy Loading(나중에 객체 생성)도 가능한 완벽한 싱글톤 기법
  • 클래스 안에 내부 클래스(holder)를 두어 JVM의 클래스 로더 매커니즘과 클래스가 로드되는 시점을 이용한 방법 (스레드 세이프함)
  • `static` 메소드에서는 `static` 멤버만을 호출할 수 있기 때문에 내부 클래스를 `static`으로 설정
  • 이밖에도 내부 클래스의 치명적인 문제점인 메모리 누수 문제를 해결하기 위하여 내부 클래스를 `static`으로 설정
  • 다만 클라이언트가 임의로 싱글톤을 파괴할 수 있다는 단점을 지님 (Reflection API, 직렬화/역직렬화를 통해)

 

  1. 내부 클래스를 `static`으로 선언하였기 때문에, `Singleton` 클래스가 초기화 되어도 `SingleInstanceHolder` 내부 클래스는 메모리에 로드되지 않음
  2. 어떠한 모듈에서 `getInstance()` 메서드를 호출할 때, `SingleInstanceHolder` 내부 클래스의 `static` 멤버를 가져와 리턴하게 되는데, 이때 내부 클래스가 한번만 초기화되면서 싱글톤 객체를 최초로 생성 및 리턴하게 된다. `final` 로 지정함으로서 다시 값이 할당되지 않도록 방지한다.

 

 

 

📚 참고
https://inpa.tistory.com/entry/GOF-%F0%9F%92%A0-%EC%8B%B1%EA%B8%80%ED%86%A4Singleton-%ED%8C%A8%ED%84%B4-%EA%BC%BC%EA%BC%BC%ED%95%98%EA%B2%8C-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90