싱글톤 디자인 패턴을 파헤쳐보자

김세준·2023년 6월 30일
0

1. 개요

싱글톤 패턴은 인스턴스를 하나만 만들어 사용하는 디자인 패턴이다. 커넥션 풀 또는 스레드 풀과 같이 객체 생성 자체에 비용이 많이 드는 객체들을 여러 개 생성하는 것은 그만큼 불필요한 자원을 사용하는 것이기 때문에 이런 경우 싱글톤 패턴을 사용한다. 싱글톤 패턴을 사용하는 이유는 두 개 이상의 객체 생성을 막는 것이다. 따라서 클래스를 설계할 때 몇 가지 규칙을 지켜야 한다.

  • new 연산자를 제한하기 위해 생성자의 접근 제한자는 항상 private으로 선언한다.
  • 유일한 단일 객체를 반환하기 위해 스태틱 메서드가 필요하다.
  • 유일한 단일 객체를 참조할 수 있는 스태틱 참조 변수가 필요하다.

이 규칙을 따라 코드를 작성하면 다음과 같은 형태가 된다.

public class Singleton {
    private static Singleton singletonObject;

    private Singleton(){
        //기본 생성자
    }

    public Singleton getInstance(){
        if(singletonObject == null){
            singletonObject = new Singleton();
        }
        return singletonObject;
    }
}

싱글톤 참조 변수와 기본 생성자의 접근 제한자는 모두 private으로 선언했고, 오로지 getInstance() 메서드를 통해서만 싱글톤 객체를 생성할 수 있다. if 블록에서 싱글톤 객체가 null일 때만 새로운 객체를 생성하기 때문에 일반적인 상황일 때 객체가 하나만 생성됨을 보장할 수 있다.

추가로 싱글톤 클래스를 설계할 때, 클래스 내부에서 쓰기 가능한 속성값은 절대로 가지면 안 된다. 하나의 참조 변수가 변경한 단일 객체의 속성값이 다른 참조 변수에 영향을 미치기 때문이다. 이렇게 되면 싱글톤 객체를 사용하는 이유가 없어진다. 다만 읽기만 가능한 속성값을 가지는 것은 문제가 되지 않는다.

1.1 일반적인 상황?

위 코드는 일반적인 상황일 때 객체가 하나만 생성됨을 보장한다고 했다. 그렇다면 일반적이지 않은 상황은 뭘까? 바로 별개의 작업 스레드가 같은 싱글톤 객체를 생성하려고 접근했을 때다. 일단 thread-safe 한 싱글톤 패턴의 구현 방법을 알아보기 전에 초기화 시기에 따른 싱글톤 패턴의 구현 방법을 알아야 한다.

2. 초기화 시기에 따른 싱글톤 패턴의 구현 방법

초기화 시기는 Eager initialization 과 Lazy initialization으로 나뉜다. 직역하면 빠른 초기화와 느린 초기화이다. 싱글톤 패턴을 Eager initialization으로 구현하면 애플리케이션 시작과 동시에 JVM에 의해 객체가 생성된다. 반면 Lazy initialization 은 싱글톤 객체가 필요한 경우에만 그 객체를 생성한다. 즉, 객체의 생성 시기를 필요한 순간이 올 때까지 최대한 미루는 것이다.

2.1. Eager initialization

애플리케이션이 항상 이 클래스를 사용하는 것이 확실하거나 인스턴스를 생성하는 비용이 크지 않을 때 이 방법을 선택할 수 있다. 코드는 매우 단순하다.

public class Singleton{
    public static final Singleton instance = new Singleton();

    private Singleton(){}

    private static Singleton getInstance() {
        return instance;
    }
}

코드를 보면 싱글톤 클래스의 속성에서 new 연산자를 이용해 싱글톤 인스턴스를 생성하고 있다. getInstance() 메서드는 단순히 인스턴스를 리턴해주는 역할만 한다. 이러한 방식의 장단점은 다음과 같다.

  •  장점
    1. 구현이 간단하다.
  • 단점
    1. 클래스의 인스턴스가 항상 생성되기 때문에 자원 낭비로 이어지기 쉽다.
    2. 예외 처리가 불가능하다. 이미 필드에서 객체를 바로 생성해버렸기 때문이다.

2.2. Lazy initialization

자원 낭비를 막기 위해 필요할 때까지 생성을 최대한 늦추는 초기화 방법이다. 이 코드는 개요에서 나온 싱글톤 패턴 코드와 동일하다.

public class Singleton{
    static Singleton singletonObject;

    private Singleton(){};

    public static Singleton getInstance() {
        if (singletonObject == null) {
            singletonObject = new Singleton()
        }
        return singletonObject;
    }
}

Lazy initialization의 장단점은 다음과 같다.

  • 장점
    1. 필요한 경우에만 객체가 생성된다. 따라서 자원 낭비가 없다.
    2. getInstance() 메서드에서 객체 생성을 전담하고 있기 때문에 예외처리가 가능하다.
  • 단점
    1. null 체크를 반드시 해줘야한다.
    2. 인스턴스에 직접 접근이 불가능하다.
    3. 멀티 스레드 환경에서는 싱글톤 속성을 깨뜨릴 수 있다.

사실 Lazy initialization에서 가장 중요한 단점은 3번이다. Eager initialization의 경우 애플리케이션 로딩 시점부터 싱글톤 객체가 생성되기 때문에 멀티 스레드 환경이라도 하나만 생성된다는 것을 보장할 수 있다. 하지만 Lazy initialization의 경우 객체가 필요한 순간만큼 초기화를 늦춘다. 그런데 A라는 쓰레드와 B라는 쓰레드가 작업을 하다가 동시에 싱글톤 객체가 필요한 시점이 올 수 있다. 이렇게 동시에 접근했을 때 getInstance() 메서드가 한 번만 실행되고 싱글톤 객체가 한 번만 생성될 것이라고는 그 누구도 보장할 수 없다. 자원의 효율 때문에 Lazy initialization 방법을 채택한 것이 여러 쓰레드가 접근할 수 있는 환경에선 오히려 발목을 잡은 것이다.

하지만 자바는 멀티 쓰레드 환경에서 참조 객체의 동시 접근을 막는 방법으로 synchronized라는 키워드를 제공하고 있다. synchronized를 getInstance() 메서드에 적용해 보자.

2.3. Synchronized Singleton

public class Singleton {
    private static Singleton singletonObject;

    private Singleton(){}

    synchronized public Singleton getInstance(){
        if(singletonObject == null){
            singletonObject = new Singleton();
        }
        return singletonObject;
    }
}

어떤 스레드가 getInstnace() 메서드를 호출했을 때 다른 쓰레드가 getInstance() 메서드에 접근하더라도 synchronized 키워드가 있으므로 동시 접근을 막을 수 있다. 일단 다른 스레드의 접근을 막는 것은 성공했다. 하지만 synchronized 키워드를 전체 메서드 앞에 붙여버리는 형식은 구현은 간단할 수 있어도 성능 문제를 일으킬 수 있다. getInstance()에서 새로운 싱글톤 객체를 생성하는 부분은 정해져 있는데 메서드 앞에 synchronized를 붙이는 것은 동시 접근할 수 있어도 되는 구간까지도 동기화가 되기 때문이다.

이 방법을 해결하기 위해 synchronized 구간을 최소화하고 싱글톤 instance의 null 검사를 두 번 하는 방법이 있는데, Double Checked Locking라고 불리는 방법이다.

2.4. Double Checked Locking Singleton

public class Singleton {
    private static Singleton singletonObject;

    private Singleton(){}

    public static Singleton getInstance() {
    if (singletonObject == null) {
        synchronized(Singleton.class){
            if(singletoneObject == null){
                singletonObject = new Singleton()
            }
        }
    }
    return singletonObject;
}

사실 우리가 동기화를 적용해야 할 부분은 싱글톤 객체를 새로 생성하는 부분이 끝이다. 싱글톤 객체를 리턴해주는 부분의 동기화는 필요 없다. 따라서 해당 부분만 synchronized 키워드를 적용하면 위와 같은 코드가 된다. null 검사를 하는 if 블록이 두 번 나타나는 이유는 이미 싱글톤 객체가 할당된 상태라면 첫 번째 if 블록 조건을 만족하지 않기 때문에, synchronized 부분을 거치지 않고 바로 싱글톤 객체를 리턴해줄 수 있기 때문이다. 덕분에 Double Checked Locking 방식은 thread-safe 하면서 앞의 방법보다 더 나은 성능을 보여준다.

하지만 Double Checked Locking(DCL) 방식은 최적화가 잘 되어 있는 것처럼 보이지만, 제대로 동작하지 않는다. 더 정확하게 말하면, DCL은 정상적으로 동작하는 것을 보장하지 못한다. 코드를 보면 getInstance() 메서드는 비동기화된 singletonObject 필드에 의존한다.

무슨 문제인가 싶지만, 문제가 있다. 다음 그림을 보자

  • 스레드 B가 getInstance() 메서드에 접근하고 있다.
  • 이미 쓰레드 A는 동기화된 블록 안에서 singleObject = new Singleton()을 실행하고 있다.

다음 상황일 때 (2)의 과정이 끝나면 당연히 싱글톤 객체를 위해 메모리가 할당되고 Singleton의 생성자가 호출된다. 그러나 쓰레드 B가 접근하는 (1)의 과정은 동기화되지 않은 블록에서 실행되고 있다. 따라서 쓰레드 B는 쓰레드 A가 실행하는 것과는 다른 결과를 볼 수 있다. 쓰레드 A가 singletonObject = new Singleton() 구문을 처리했다고 가정했을 때, 사실 생성자가 호출되기까지 약간의 빈틈이 생길 수 있다. 다음 과정을 보자.

  1. 메모리를 할당한다.
  2. singletonObject에 참조 객체를 할당한다.
  3. 생성자를 호출한다.

싱글톤 객체 생성을 위해 이러한 과정을 거치고 있을 때 쓰레드 B가 3번 과정 이전에 진입한다고 생각해 보자. 그러면 스레드 B는 null이 아닌 singletonObject를 보게 된다. 현재 singletonObject는 null이 아니기 때문에 synchronized 내부는 그냥 건너뛰고 바로 singletonObject를 리턴한다. 하지만 이 리턴된 singletonObject는 불완전하다. 생성자는 그 객체의 필드를 초기화시키는 역할을 맡는데, 아직 생성자가 호출되지 않은 객체를 쓰레드 B가 리턴해버렸기 때문이다. 이런 이유로 DCL은 약간의 버그를 일으킬 수 있다.

2.4.1. 그렇다면 필드에 volatile 선언은?

DCL을 사용할 때 캐시 일관성을 보장하기 위해 volatile을 선언하라고 한다. volatile을 선언하면 다음과 같이 코드를 작성할 수 있다.

public class Singleton {
    private static volatile Singleton singletonObject;

    private Singleton(){}

    public static Singleton getInstance() {
    if (singletonObject == null) {
        synchronized(Singleton.class){
            if(singletoneObject == null){
                singletonObject = new Singleton()
            }
        }
    }
    return singletonObject;
}

필드를 volatile로 선언하면 데이터를 쓰고 읽음에 있어 항상 메인 메모리에서 그 값을 가져오는 것을 보장해 준다. 하지만 이것도 최종적인 해답은 되지 못한다. 싱글톤 클래스에는 일반적인 속성값은 들어올 수 없지만 읽기 전용 속성이나 다른 객체를 참조하고 있는 참조 변수는 선언할 수 있다. 즉, 변수 개수가 많아질수록 모든 필드를 volatile로 선언해 줘야 된다는 뜻인데 volatile은 메인 메모리로부터 값을 읽고 쓰는 탓에 너무 남발하면 성능이 상당히 떨어진다. 성능을 올리려고 singletonObject만 volatile로 선언하고 나머지 필드가 non-volatile인 경우, 위에서 설명했다시피 스레드 B는 여전히 생성자 문제를 볼 수 있다.

2.4.2. DCL 싱글톤의 문제 해결 방법

DCL의 문제를 해결하는 가장 좋은 방법은 DCL을 사용하지 않는 것이다. 두 번째 해결 방법으론 메서드 전체에 synchronized를 선언하는 방법이 있다. 이 방법은 메서드 자체를 동기화시키기 때문에 생성자 관련 문제가 깔끔히 해결되긴 하지만 성능은 보장할 수 없다.

또 다른 방법으로 Lazy initialization을 아예 버리고 Eager initialization을 선택하는 것이다. Eager initialization은 스태틱 필드에서부터 new 생성자를 통해 객체를 생성하기 때문에 Lazy initialization을 선택했을 때처럼 성능이나 버그가 발생할 염려가 없다. 하지만 애플리케이션 로딩 시점부터 계속 남아있어서 메모리 측면에서 좋지 않은 방법이다.

3. 대안

자원을 효율적으로 사용하고 구현이 간단하며, 생성자 문제를 일으키지 않고 thread-safe 한 싱글톤 패턴은 없는 것일까? 물론 있다. 이러한 해결 방법의 공통점은 동기화 작업을 JVM에게 위임한다는 점이다.

3.1. Bill pugh Singleton

다음 코드는 윌리엄 퓨가 만들었다고 해서 Bill pugh Singleton이라고 불린다.

public class Singleton{
    private Singleton{
        // private 생성자    
    }

    private static class BillPughSingleton{
        private static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return BillPughSingleton.INSTANCE;
    }
}

이 코드는 Eager initialization 같지만 Lazy initialization이다. 자바 애플리케이션은 클래스를 로딩할 때 스태틱 이너 클래스는 바로 생성하지 않는다. 이너 클래스는 아래의 getInstance() 메서드가 호출됐을 때 호출된다. 즉, Lazy initialization 방식과 같다. 한 가지 차이점은 이 설계 방법은 동기화 작업을 JVM에 위임한다는 점이다. 만약 다른 스레드가 getInstance() 메서드를 호출할 때 static final로 선언된 INSTANCE 가 이미 JVM 메모리에 올라와 있기 때문에 싱글톤 객체를 한 번만 생성할 것이라고 JVM이 보장해 준다. 그리고 DCL과 volatile을 결합한 코드보다 구현도 간단하다.

3.2. Enum Singleton

마지막 대안은 Joshua Block의 Effective Java book(Item 3)에서 가져온 것으로 class 대신 enum을 사용한다. Bill pugh Singleton보다 구현이 더욱 간단하다.

public enum EnumSingleton {
    INSTANCE;

    // other methods...
}

Enum은 고정된 상수들의 집합이다. 따라서 런타임이 아닌 컴파일 타임에 모든 값을 알고 있어야 한다. 그래서 Enum 클래스 내에서 인스턴스 생성은 불가능하며 생성자의 접근 제한자를 private으로 설정해 인스턴스 생성을 제어한다. Enum Singleton의 장점은 직렬화를 자체적으로 처리한다는 점이다. 기존 싱글톤 클래스의 경우 readObject() 메서드가 Java 생성자처럼 항상 새로운 인스턴스를 리턴하기 때문에 직렬화 인터페이스를 구현하면 싱글톤 클래스가 더 이상 싱글톤이 아니게 된다. 직렬화된 싱글톤 객체의 문제를 해결하기 위해서는 그 과정이 조금 복잡하다.

  • 모든 필드를 transient로 선언한다.
  • 다음과 같이 readResolve() 메서드를 구현한다.
 private Object readResolve(){
      return INSTANCE;
  }

하지만 Enum Singleton의 경우 직렬화를 보장해 주기 때문에 위의 과정이 필요 없다. Enum Singleton의 단점은 기존 클래스와 Enum 클래스의 차이점에서 오는 코딩 제약인데, 사실 이 부분은 사소하다.

4. 정리

가장 원시적인 형태의 싱글톤 클래스부터 thread-safe 한 싱글톤 클래스부터 살펴봤다. 사실 싱글톤 클래스를 설계할 때, 여러 가지 문제들에 대한 답안은 이미 나와 있기 때문에 직렬화 가능성이 없고 기존 클래스의 코딩 규약을 따르고 싶다면 Bill pugh Singleton을 사용하면 되고 직렬화 가능성이 있을 때는 Enum Singleton을 사용하면 된다. 앞서 설명했듯이 두 가지 방법 모두 JVM이 동기화를 수행하기 때문에 thread-safe 하고 Lazy initialization 방식이다. 따라서 싱글톤 클래스를 설계할 때는 두 가지 방법 중에서 하나를 선택하여 구현하는 것이 옳다.

5. 참고 자료

https://www.infoworld.com/article/2074979/double-checked-locking--clever--but-broken.html

https://www.baeldung.com/java-singleton-double-checked-locking

https://www.geeksforgeeks.org/advantages-and-disadvantages-of-using-enum-as-singleton-in-java/

0개의 댓글