[DesignPattern] Singleton Pattern

suhan0304·2024년 9월 4일

Design Pattern

목록 보기
2/16
post-thumbnail

디자인 패턴 중 가장 먼저 다룰 패턴은 Singleton 패턴이다. 어떤 곳에서는 '단일체' 패턴이라고 작성하는 곳도 있지만 아무래도 번역을 하다보니 그렇게 적은 것이고 실제 사용할 때는 그냥 싱글톤 패턴이락 부른다. 싱글톤 패턴은 객체지향 디자인 패턴에서 가장 유명한 패턴 중 하나로, 대부분이 익히 알고 있는 패턴이다.

싱글톤을 빠르게 구현하기 위해서 예전에 싱글톤 코드만 따로 정리해둔 문서가 있다.


Singleton

싱글톤 패턴은 뭘까? 단순히 쉽게 한 문장으로 정리하자면 단 하나의 유일한 객체를 만들기 위한 코드 패턴이라고 할 수 있다. 좀 더 정확한 문장은 인스턴스를 만드는 절차를 추상화하는 패턴이다. 여기서 주목할 점은 유일한이라는 단어에 집중해야 한다. 즉, 생성자를 통해서 여러 번 호출을 하더라도 객체, 인스턴스는 새로 생성되지 않고 최초 호출 시에 만들어두었던 인스턴스를 재활용하는 패턴이다.


Why

왜 싱글톤을 사용하는 걸까? 이 또한 한 문장을 정리가 가능한데 메모리 낭비를 방지하기 위해서가 가장 크다. 인스턴스가 필요할 때 똑같은 인스턴스를 새로 만들지 않고 기존의 인스턴스를 가져와 활용하기 때문에 메모리를 절약할 수 있다. 즉, 똑같은 인스턴스를 10번 생성하고 삭제하는 것보다 인스턴스를 1번만 생성하고 10번 참조하는게 성능면에서 굉장히 유리하다는 것이다.

어떻게 보면 전역 변수를 쓰는 이유와 동일한데, 똑같은 데이터를 메서드마다 지역 변수로 선언해서 사용하면 무의미 + 메모리 낭비이기 때문에, 전역 변수로 한 번만 선언하고 가져와 사용하는데, 싱글톤 패턴은 인스턴스 자체를 전역변수화 한다고 생각하면 쉽다.

대표적으로 데이터베이스 연결 모듈이 예로 있는데, 데이터 베이스에 접속하는 작업(I/O 바운드)은 그자체로 무거운 작업에 속하며 한 번만 객체를 생성하고 재사용하면 되지 굳이 필요할 때마다 여러번 생성할 필요가 없기 때문이다. 이 외에도 디스크 연결, 네트워크 통신, DBCP 커넥션풀, 스레드풀, 캐리, 로그 기록 객체등에 이용된다.

하지만 싱글톤을 사용할 때는 항상 인스턴스 중복 생성 + 멀티 쓰레드 환경에서의 문제를 잊지 말자.


But

싱글톤도 문제점은 존재한다.

  1. 모듈간 의존성이 높아진다.

클래스의 객체를 미리 생성하고 정적 메소드를 이용하기 떄문에 클래스 사이에 강한 의존성과 높은 결합이 생기게 된다. 즉, 하나의 싱글톤 클래스를 여러 모듈들이 공유를 해서, 싱글톤의 인스턴스가 변경되면 이를 참조하는 모듈들의 수정도 필요한다.

클래스 간의 결합도가 높아지면 오히려 패턴을 사용 안하느니만 못하다!

  1. SOLID 원칙에 위배되기 쉽다.

싱글톤 인스턴스 자체가 하나만 생성하기 때문에 여러 가지 책임을 하나의 인스턴스가 지니게 되는 경우가 많아 단일 책임 원칙(SRP)를 위반하기도 하고, 싱글톤 인스턴스가 혼자 너무 많은 일을 하거나, 데이터를 공유시키면 다른 클래스들 간의 결합도가 높아지면서 개방-폐쇄 원칙(OCP)에도 위배된다.

또한 의존 관계상 클라이언트가 인터페이스와 같은 추상화가 아닌, 구체 클래스에 의존하게 되어 의존 역전 원치(DIP)도 위반할 수 있다.

싱글톤 패턴을 너무 많이 사용하면 잘못된 디자인 패턴! 싱글폰 패튼은 객체 지향 프로그래밍의 안티 패턴

  1. TDD 단위 테스트에 애로사항이 있다.

싱글톤 클래스를 사용하는 모듈 테스트가 어렵다. 단위 테스트를 할 때 서로 독립적이어야하고 테스트를 어떤 순서로든 실행할 수 있어야 하는데, 싱글톤 인스턴스는 자원을 공유하고 있기 때문에 테스트가 결함없이 수행되려면 매번 인스턴스의 상태를 초기화시켜주어야 한다.


How

싱글톤 패턴을 구현하는 것은 전혀 복잡하지 않은데 패턴을 구현하는 방법은 크게 6가지 (+a) 방법이 있다. 그러나 각각의 방법이 결국에는 아래와 같은 공통적 특징이 있다.

  • private 생성자만을 정의해 인스턴스 생성 차단
  • private static 객체 변수 instance
  • public static 메소드를 통한 외부 접근 접점 제공

어렵게 생각할 필요없이 일단 외부 클래스에서 new 생성자를 통해 인스턴스화 하는 것을 제한하기 위해 private를 붙여주고, instance 필드 변수가 null일 경우 초기화를 진행하고 null이 아닐 경우 이미 생성된 객체를 반환하는 식으로 구성한다는 뜻이다.

예전에 작성했던 Signelton 예제 코드에서도 확인할 수 있다!

public class Singleton
{
    private static Singleton instance; // private static 객체 변수!
 
    private Singleton() { } // private 생성자!
 
    public static Singleton Instance // public static 외부 접근 제공!
    {
        get
        {
            if (instance == null)
            {
                instance = new Singleton();
            }
            return instance;
        }
    }
}

Singleton Instance = Singleton.Instance;

그럼 이제 싱글톤을 구현하는 6가지 구현 기법에 대해 알아보자. 만약 당장 검증된 싱글톤 패턴을 보고 싶다면 6번Enum 이용 예제를 위주로 보면 된다.


1. Eager Initialization

  • 한 번만 미리 만들어두는, 가장 직관적이면서도 심플한 기법
  • static final이라 멀티 쓰레드 환경에서도 안전
  • static 멤버는 당장 객체를 사용하지 않더라도 메모리에 적재 = 객체의 리소스가 크면, 공간 지원 낭비
  • 예외를 처리할 수 없음

만일 싱글톤 객체가 그리 크지 않은 객체라면 이 기법으로 적용해도 괜찮다.

class Singleton
{
    // 싱글톤 객체를 담을 인스턴스 변수
    private static readonly Singleton INSTANCE = new Singleton(); // Unity는 final 대신 readonly로 선언

    // 생성자는 private로 선언
    private Singleton() {}

    // 외부에서는 public static으로 선언되 getInstance로 접근
    public static Singleton getInstance() {
        return INSTANCE;
    }
}

싱글톤 패턴에 static final 필드를 사용하면, 멀티스레드 환경에서 클래스 로드 시점에 인스턴스가 안전하게 초기화되어 중복 생성의 위험이 없어진다. 이를 통해 멀티스레드 환경에서도 싱글톤의 안전성을 보장할 수 있다.


2. Static block initialization

  • static block을 이용해 예외를 잡을 수 있음
  • 여전히 static 때문에 객체를 사용하지 않아도 공간을 차지함

static block? : 클래스가 로딩되고 클래스 변수가 준비된 후 자동으로 실행되는 블록

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

    // 생성자는 private로 선언 
    private Singleton() {}

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

    // 외부에서는 public static으로 선언되 getInstance로 접근
    public static Singleton getInstance() {
        return INSTANCE;
    }
}

유니티에서는 명시적인 static block을 지원하지 않는다.


3. Lazy Initialization

  • 객체 생성에 대한 관리를 내부적으로 처리
  • 메서드를 호출했을 때 인스턴스 변수의 null 유무에 따라 초기화 하거나 있는 걸 반환하는 기법
  • 위 1,2의 static 사용으로 인한 미사용 고정 메모리 차지의 한계를 극복
  • 스레드 세이프(Thread Safe)하지 않는 치명적인 단점
class Singleton {
    // 싱글톤 객체를 담을 인스턴스 변수
    private static Singleton instance;

    // 생성자는 private로 선언 
    private Singleton() {}
	
    // 외부에서 정적 메서드를 호출하면 초기화 진행
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton(); // 오직 1개의 객체만 생성
        }
        return instance;
    }
}

내가 제일 자주 쓰는 방식이다! 인스턴스의 초기화를 호출할 때 생성해서 고정해서 미사용하는 싱글톤 인스턴스 고정 메모리 차지의 한계를 극복했다. 대부분 구글링을 하면 위와 같은 싱글톤 패턴을 정석이라고 소개하고, 주로 사용하지만 치명적인 문제가 있다..

Lazy Initialization은 멀티 스레드 환경에서 세이프 하지 않다!

각 스레드는 자신의 실행단위를 기억하면서 코드를 위에서 아래로 읽어가는데 아래와 같은 문제가 발생할 수 있다.

public static Singleton getInstance() {
    if (instance == null) {			// A
        instance = new Singleton(); // B
    }
    return instance;
}
  1. 스레드 A, 스레드 B가 존재한다고 하자.
  2. 스레드 A가 if 문을 평가하고 인스턴스 생성 코드로 진입 (아직 초기화 전)
  3. 이 때, 스레드 B가 if 문을 평가하기 시작하고 A는 인스턴스화 코드를 실행하기 전이기 때문에 if 문을 통과
  4. A가 인스턴스 초기화 하고 나서 B도 인스턴스 초기화가 진행 (싱글톤이 아니게 됨)

즉, 멀티 스레드 환경에서 완전히 원자성이 보장되지 않는다라는 단점이 있다.


4. Thread Safe Initialization

  • synchronized 키워드를 통해 메서드에 스레드들을 하나씩 접근하게 하도록 설정 (동기화)
  • 하지만 여러개의 모듈들이 매번 객체를 가져올 때 synchronized 메서드를 매번 호출하여 동기화 처리 작업에 overhead가 발생해 성능 하락이 발생한다.

synchronized? 멀티 스레드 환경에서 두 개 이상의 스레드가 하나의 변수에 접근할 때 Race Condition(경쟁 상태)이 발생하지 않도록 한다. 한 마디로 쓰레드가 해당 메서드를 실행하는 동안 다른 스레드가 접근하지 못하도록 락(lock)을 건다.

class Singleton {
    private static Singleton INSTANCE;

    private Singleton() {}

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

5. Double-Chcked-locking

  • 매번 동기화를 실행하는 것이 아니라, 최초 초기화할때만 적용하고 이미 만들어진 인스턴스를 반환할때는 사용하지 않도록 하는 기법
  • 인스턴스 필드에 volatile 키워드를 붙여줘야 I/O 불일치 문제를 해결할 수 있다.
  • 그러나 여전히 스레드 세이프하지 않는 경우가 발생하기 때문에 사용하기를 지양하는 편
class Singleton {
    private static volatile Singleton instance; // volatile 키워드 적용

    private Singleton() {}

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

volatile? 스레드를 여러 개 사용할 경우, 성능을 위해서 각 스레드들은 변수를 메인 메모리(RAM)으로부터 가져오는 것이 아니라 캐시(Cache) 메모리에서 가져오게 된다. 문제는 비동기로 변수 값을 캐시에 저장하다가, 각 스레드마다 할당되어있는 캐시 메모리의 변수 값이 일치하지 않을 수 있다는 점이다. 그래서 volatile 키워드를 통해 이 변수는 캐시에서 읽지 말고 메인 메모리에서 직접 읽어오도록 지정해주는 것이다.


6. Bill Pugh Solution (LazyHolder)

  • 권장되는 두 가지 방법 중 하나
  • 멀티 스레드 환경에서 안전 + Lazy Loading(나중에 인스턴스 생성)도 가능
  • 클래스 안에 내부 클래스(Holder)를 두어 클래스 로더 매커니즘과 로드되는 시점을 이용하는 방법
  • static 메소드에서는 static 멤버만을 호출할 수 있기 때문에 내부 클래스를 static으로 설정
  • 이 밖에도 내부 클래스의 치명적인 문제점인 메모리 누수를 해결하기 위하여 내부 클래스를 static으로 설정
  • 다만 클라이언트가 임의로 싱글톤을 파괴할 수 있다는 단점을 지님 (Refleaction API, 직렬화/역직렬화를 통해)
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;
    }
}
  1. 내부 클래스를 static으로 선언해서, 싱글톤 클래스가 초기화되어도 SingleInstanceHolder 내부 클래스는 메모리에 로드되지 않음
  2. 어떠한 모듈에서 getInstance() 메서드를 호출할 떄, SingleInstanceHolder 내부 클래스의 static 멤버를 가져와 리턴하게 되는데, 이 때 내부 클래스가 한번만 초기화되면서 싱글톤 객체를 최초로 생성 및 리턴하게 된다.
  3. 마지막으로 final로 지정함으로서 다시 값이 할당되지 않도록 방지한다.

Enum

  • 권장되는 두 가지 방법 중 하나
  • enum 은 애초에 멤버를 만들때 private로 만들고 한번만 초기화 하기 때문에 스레드 세이프함
  • enum 내에서 상수 뿐만 아니라, 변수나 메서드를 선언해 사용이 가능하기 때문에, 이를 이용해 싱글톤 클래스처럼 응용이 가능
  • 클라이언트에서 Reflection을 통한 공격에도 안전
  • 하지만 만일 싱글톤 클래스를 멀티톤으로 마이그레이션 해야할 때 처음부터 코드를 다시 짜야되는 단점이 존재한다.
  • 클래스 상속이 필요할 때, enum 외의 클래스 상속은 불가능하다.
enum SingletonEnum {
    INSTANCE;

    private final Client dbClient;
	
    SingletonEnum() {
        dbClient = Database.getClient();
    }

    public static SingletonEnum getInstance() {
        return INSTANCE;
    }

    public Client getClient() {
        return dbClient;
    }
}

public class Main {
    public static void main(String[] args) {
        SingletonEnum singleton = SingletonEnum.getInstance();
        singleton.getClient();
    }
}

하지만 유니티에서는 enum을 사용한 싱글톤 패턴을 직접적으로는 지원하지 않는다.


Example

유니티에서는 보통 lazy instantiation을 통해 구현된다. static을 통하여 외부에서 접근 핟로고 하는 방식 자체는 동일하다. 단, Scene이 반복해서 실행될 때 중첩될 수 있다는 경우를 고려하여, Scene이 Load될 때 instance의 존재 여부를 체크해야 한다.

물론 멀티 스레드 환경에서 안전하지 않다는 단점은 여전하다. 따라서 아래와 같은 방식으로 유니티에서 스레드 세이프한 싱글톤을 구현할 수 있다.

4-1. Thread Safe Initialization (Ver.Unity[C#])

유니티의 C#에서는 synchronized 키워드를 제공하지 않는다. 그 대신 lock문을 통해 스레드에서 사용 중일때 끝날 때까지 스레드를 기다리게 할 수 있다.

public class Singleton
{
   private static volatile Singleton instance;
   private static object syncRoot = new Object();

   private Singleton() {}

   public static Singleton Instance
   {
      get 
      {
        lock (syncRoot) 
        {
            if (instance == null) 
                instance = new Singleton();
        }
        return instance;
      }
   }
}

C# 프로퍼티로 GetInstance를 쉽게 구현할 수 있다. 공식 문서 참고

5-1. Double-Chcked-locking (Ver. Unity[C#])

유니티의 C#에서도 lock 문은 리소스를 많이 잡아먹어서 if(INSTANCE == null)보다 비싼 연산이다. 그래서 먼저 인스턴스가 초기화 됐는지를 확인하고 lock 문을 건 다음에 다시 한 번 인스턴스가 존재하는지를 확인해서 보다 빠르게 작동할 수 있다.

public sealed class Singleton
{
   private static volatile Singleton instance;
   private static object syncRoot = new object();

   private Singleton() {}

   public static Singleton Instance
   {
      get 
      {
         if (instance == null) 
         {
            lock (syncRoot) 
            {
               if (instance == null) 
                  instance = new Singleton();
            }
         }
         return instance;
      }
   }
}

sealed Class : 상속을 금지하여 클래스의 설계를 보호 + 성능 최적화
sealed Method : 메서드의 오버라이드를 방지하여 메서드의 동작 고정, 상속 계층 구조에서의 일관성을 유지


profile
Be Honest, Be Harder, Be Stronger

0개의 댓글