[Design Pattern] Singleton Pattern

Loopy·2022년 3월 21일
0

디자인패턴

목록 보기
4/9
post-thumbnail

☁️ 싱글턴 패턴이란?

클래스가 오직 한 개의 인스턴스만을 만들 수 있게 하고, 어디서나 생성된 인스턴스에 접근 가능하게 하는 패턴이다. 여러 개의 객체가 생성이 되면 상태 관리가 힘들어지는 경우 사용하면 좋다.

싱글턴 구현 방법의 5가지 변천사

☁️ Eager Initialization

public class Singleton {

    private static final Singleton instance = new Singleton(); // public 으로 열어도 되지 않나?

    private Singleton() {}

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

public static final 을 통해 처음부터 싱글톤 인스턴스를 만들어 놓고, 싱글턴 인스턴스가 불렸을 때 이미 만들어진 객체를 반환하는 방법이다.

new Singleton()을 허용하지 않으므로, 오직 객체를 생성하는 방법은 Singleton.getInstance() 를 통해서만 가능하다.

단점

하지만 해당 방식은 객체 생성 비용이 큰 경우, 사용 여부에 관계없이 메모리에 적재되기 때문에 공간 낭비가 심할 수 있다는 단점이 있다.

☁️ Lazy Initialization 방식

지연 초기화를 사용하면, 사용하지 않는 싱글턴 클래스를 필요할 때 생성할 수 있다.

public class Singleton {
  private static Singleton uniqueInstance; // Singleton 클래스의 유일한 인스턴스
  
  private Singleton() { }
  
  public static Singleton getInstance() {    //객체 생성 부분
    if (uniqueInstance == null) {
      	uniqueInstance = new Singleton();    //객체가 없으면 새로 생성
    }
    return uniqueInstance;            //객체가 존재하면 생성하지 않고 반환
  }
}
  1. private 생성자로 외부 객체 생성 방지
  2. 싱글턴 인스턴스를 저장하는 정적 멤버 변수를 생성한다.
  3. 싱글턴 인스턴스를 반환하는 정적 팩토리 메소드 구현한다.

단점

하지만, 멀티 스레딩 환경에서도 정상적으로 동작할까?

예를 들어 스레드 12동시에 if (uniqueInstance == null) 분기문에 도달했다고 치자. 두 스레드 모두 아직 객체가 생성되지 않은 걸로 판단할테고, 따라서 싱글턴 객체가 2개가 생성이 되는 문제가 발생한다.

직접 테스트해보자.

    @Test
    void singleton() {
        SingletonV2[] singletonArr = new SingletonV2[10];

        ExecutorService service = Executors.newCachedThreadPool();  // 스레드 풀

        for(int i = 0; i < 10; i++) {
            int finalI = i;
            service.submit(() -> {
                singletonArr[finalI] = SingletonV2.getInstance();
            });
        }

        service.shutdown();

        for(SingletonV2 s : singletonArr) {
            System.out.println(s.toString());  // 객체 해시코드 값 출력
        }
    }

☁️ 동기화 이슈를 고려한 방식(1): Synchronized

public class SingletonV3 {

    private static SingletonV3 instance;

    private SingletonV3() {};

    public synchronized static SingletonV3 getInstance() {
        if (instance == null) {
            instance = new SingletonV3();
        }
        return instance;
    }
}

synchronized 키워드를 붙이면, 멀티 스레드 환경에서 한 스레드가 다른 스레드의 접근을 방지하기 위해 잠금을 걸어 동기화 할 수 있다.

단점

하지만, 잠금을 건다는 것 자체가 시간이 걸린다. 즉, 매번 객체를 가져올 때 synchronized 메서드가 호출되어 동기화 처리 작업에 오버헤드가 발생해 성능이 하락될 수 있다.

☁️ Double-Checked Locking 방식

그렇다면 호출 시마다 동기화하지 않고, 인스턴스가 생성되어 있는지 확인해야 하는 맨 처음 단 한번만 동기화하는 방식은 어떨까?

public class SingletonV4 {

    private static volatile SingletonV4 instance;

    private SingletonV4() {};

    public synchronized static SingletonV4 getInstance() {
        if (instance == null) {
            synchronized(SingletonV4.class){   // 동기화 영역
                if (instance == null) {
                    instance = new SingletonV4();
                }
            }
        }
        return instance;
    }
}

이렇게 더블 체킹해야 하는 이유는, 말그대로 가장 바깥의 null 확인 로직은 동기화 처리가 한번만 일어남을 보장하기 위해 존재하기 때문이다. 스레드가 동시 접근이 가능하고, 따라서 내부에서 null 임을 한번 더 검사해주지 않으면, 이미 객체를 생성한 상태에서도 객체를 또 생성하는 불상사가 발생할 수 있다.

Volatile은 뭐지?

volatile 을 붙이면, 변경 작업이 다른 스레드에서도 보일 수 있도록, CPU 캐시가 아닌 메인 메모리를 대상으로 변경 작업이 일어난다.

Q. Volatile 을 꼭 붙어야 하는가?

사실 객체 생성은 3단계로 나눠진다.

  1. 객체를 위한 메모리 공간을 할당한다.
  2. 변수를 생성하고, 해당 메모리 공간을 참조하도록 한다.
  3. 객체를 초기화하기 위해 생성자를 호출한다.

하지만, JVM은 마음대로 이 세단계를 최적화 과정에서 재정렬할 수 있다. 따라서 항상 완벽히 초기화가 된 객체를 얻지 못할 수 있는 것이다.

문제는 바로 스레드 A가 인스턴스 구성을 모두 완료하기 전에 인스턴스에 대한 메모리 공간을 할당하여, 스레드 B가 instance == null 에 접근해 객체가 생성되었다고 판단했을 때 발생한다. 스레드 B가 해당 할당을 확인하고 이미 만들어졌으므로 사용하려고 하면, 부분적으로 생성된 버전의 인스턴스를 사용하고 있기 때문에 스레드 B가 실패하기 때문이다.

혹은, 더 쉽게 말하면 스레드가 로컬 캐시에 메모리와 동기화 되어 있지 않은 값을 바라보고 있을 수 있다. 따라서 결론적으로volatile 키워드를 붙이게 되면, 아래 컴파일 된 사진에서 볼 수 있듯이 생성 과정의 세 가지 순서를 보장해주어 더욱 안전하다.

 javap -c  /Users/kangsemi/Desktop/git/carefully-server/build/classes/java/test/com/example/carefully/singleton/SingletonV5.class

하지만 volatile 은 성능 면에서 안좋다. 따라서, 가급적이면 다음에 바로 나올 static 내부 클래스를 활용한 싱글턴을 사용하자.

https://stackoverflow.com/questions/7855700/why-is-volatile-used-in-double-checked-locking

☁️ Bill Pugh Solution (LazyHolder)

권장되는 방법 중 하나로, 멀티스레드 환경에서 안전하고 Lazy Loading 도 가능한 싱글톤 기법이다.

public class SingletonV5 {  // lazy initializer

    private SingletonV5() { }

    static class LazySingletonHolder {
        private static final SingletonV5 INSTANCE = new SingletonV5();
    }

    public static SingletonV5 getInstance() {
        return LazySingletonHolder.INSTANCE;
    }
}
  1. 내부클래스를 static으로 선언하였기 때문에, 싱글톤 클래스가 초기화되어도 SingleInstanceHolder 내부 클래스는 메모리에 로드되지 않음

  2. 어떠한 모듈에서 getInstance() 메서드를 호출할 때, SingleInstanceHolder 내부 클래스의 static 멤버를 가져와 리턴하게 되는데, 이때 내부 클래스가 한번만 초기화되면서 싱글톤 객체를 최초로 생성 및 리턴하게 된다.

  3. 마지막으로 final 로 지정함으로서 다시 값이 할당되지 않도록 방지한다.

엇? 싱글턴 객체는 어플리케이션 실행시에 모두 메모리에 올라가는게 아닌가?

라고 생각한 사람들을 위해, Static inner class 는 어떻게 지연 초기화가 가능할까?에 따로 정리 해놓았다.

☁️ 열거 타입의 싱글턴

public static final 로 공개되기 때문에, 멀티 스레드 환경에서도 인스턴스가 두개 이상 생성되는 일은 존재하지 않는다.

public enum SingletonV6 {
    UNITE_INSTANCE;
}

enum 은, 객체 자체가 하나만 만들어짐을 JVM 단에서 보장해준다. 또한 복잡한 직렬화 상황이나 리플렉션 공격에서, 또 다른 인스턴스가 생기는 일을 막을 수 있다. 자세한 내용은 인스턴스 수를 통제해야 한다면 readResolve 보다는 열거 타입을 사용하라를 참고하자.

☁️ 싱글턴 장점

  1. 전역 변수 보다 유연하다.
    전역 변수는 프로그램 시작 시점부터 종료 시점까지 실제 쓰이지 않더라도 메모리를 차지한다. 반면 싱글턴은, 실제 호출이 될때 객체를 생성하는 lazy loading 이 가능하다.

☁️ 싱글턴 단점

싱글턴은 상속과 추상화가 불가능하다. 그리고 이로 인해 아래와 같은 문제들이 발생한다.

1. 강하게 결합되어 느슨한 결합 원칙에 위배된다.

즉, 싱글턴을 사용하는 모든 클래스가 단 하나의 클래스로만 종속되어 있다. 만약 싱글턴 클래스에 변경이 가해진다면, 그것을 사용하고 있는 모든 클래스들에도 변경이 전파된다. 그리고 이걸 우리는 Tight coupled 되어 있다고 부른다.

싱글턴이 아니라면, 앞에서 배웠던 것처럼 의존 역전 원칙을 통해 객체를 동적으로 갈아끼워 변경 전파를 막을 수 있었을 것이다.

2. 단일 책임 원칙(Single Responseablity)을 위반한다.

일부 시각에서는 싱글톤 내부에 실제 역할에 객체 생성 기능까지 더해져, 책임을 2개 지니고 있다고 보아 단일 책임 원칙을 위반한다고 보고 있다.

하지만 이에 대해서는 생각이 조금 다르다.
만약 여러 역할을 수행하는 싱글턴이 만들어졌다면, 싱글턴이 SRP를 위반한게 아니라 프로그래머가 SRP를 지키지 않은 것이라고 생각한다.

3. 단위(Unit) 테스트 하기가 힘들어진다.

단위 테스트는 서로 독립적이어야 한다. 즉, 한 테스트를 수행이 다른 테스트에 영향을 주면 안되고 어떤 순서로든 실행 할 수 있어야 한다.

하지만 싱글톤은 전역 변수와 같이 자원을 공유하고 있기 때문에, 테스트가 결함없이 수행되려면 매번 인스턴스의 상태를 초기화시켜주어야 한다.

더불어 싱글턴은 mocking 할 수 없다. 고로, 가짜 객체를 만들어서 내가 원하는 대로 값을 조작하는 일도 힘들다.(외부 라이브러리를 사용하면 가능하다)

mock 객체 생성은 프록시 생성 과정과 비슷한데, 프록시 자체가 원본 클래스를 "상속" 받아야 하는 구조이기 때문이다. 하지만 싱글턴은 생성자가 막혀있으므로 상속이 불가능하다.

☁️ Spring과 싱글턴

우리는 싱글턴 패턴을 통해, 객체가 하나만 생성됨을 기대한다. 스프링에서도 이와 같이 객체 하나라는 비슷한 개념이 있다. 바로, Bean 이다.

Bean 은 스프링이 주로 적용되는 대규모 엔터프라이즈 서버환경에서 무수히 많은 요청이 올 때마다, 각 로직을 담당하는 오브젝트를 새로 만들어서 사용한다면 부하가 걸린다는 점을 감안해 하나만 생성되도록 보장했다.

하지만 주의할 점이 Bean 은 싱글턴 패턴을 통해 객체가 하나이게 되는게 아니다. 단지, ApplicationContextBean 이 하나임을 보장해준다.

왜 스프링은 싱글턴 패턴을 사용하지 않았을까?

바로 앞에서 얘기했던 싱글톤 패턴의 단점을 때문이다. 싱글톤 패턴의 한계 때문에, 스프링은 제어권 자체를 IOC 방식의 컨테이너에게 넘겨, 싱글톤 레지스트리라는 직접 싱글톤 형태의 오브젝트를 만들고 관리하는 기능을 제공한다.

이 덕분에 멀티 스레드 환경에서 객체가 하나임을 보장하기 위해 동기화 하는 불필요한 코드들도 없어지고 테스트 하기에도 편리해졌으며, 객체 지향적인 설계 방식과 원칙을 마음껏 적용할 수 있게 되었다.

https://velog.io/@minwest/Spring-스프링은-빈을-왜-싱글톤으로-생성할까

profile
개인용으로 공부하는 공간입니다. 잘못된 부분은 피드백 부탁드립니다!

0개의 댓글