도서 요약 / / 2023. 2. 11. 19:56

디자인 패턴 - 싱글턴(singleton) 패턴

udemy 강좌(Java Design Patterns & SOLID Design Principles)를 정리한 내용이다.

https://www.udemy.com/course/design-patterns-in-java-concepts-hands-on-projects/learn/lecture/9604610?start=0#overview


싱글턴은 무엇인가?

싱글턴 디자인 패턴에 대해 알아보자. 이 패턴은 생성 디자인 패턴이고 이미 여러분 코드에서 사용해봤을 가능성이 있다. 왜냐하면 가장 많이 사용되는 패턴 중 하나이기 때문이다.

  • 싱글턴은 단일 지점을 통해 접근할 수 있는 유일한 인스턴스를 가진다. (싱글턴 클래스의 메소드/필드를 통해 접근)
  • 이 패턴이 해결하려고 하는 주요 문제는 이 클래스에서 하나의 인스턴스만 존재하는 것을 보장하는 것이다.
  • 싱글턴에 추가되는 어떤 상태도 애플리케이션의 전역 상태의 일부가 된다.

UML

싱글턴 구현

싱글턴을 구현하기 위해 따라야할 단계를 알아보자. 하나의 인스턴스만 있다는 것을 보장하기 위해 인스턴스 생성을 제어할 필요가 있다.

  • 하나의 인스턴스만 있다는 것을 보장하기 위해 인스턴스 생성을 제어할 필요가 있다
    • 생성자가 클래스 외부로부터 접근하지 못하게 해야 한다.
    • 서브클래싱/상속은 허용되지 않는다.
  • 인스턴스를 추적
    • 클래스 그 자체는 인스턴스를 추적하기 좋은 지점이다.
  • 싱글턴 인스턴스에 접근을 허용
    • public static 메소드를 사용하는 것이 좋다.
    • final public static 필드로 인스턴스를 노출할 수 있다면 모든 싱글턴에 적용되지는 않는다.

싱글턴 구현 방법

  • 싱글턴을 구현하는 두 가지 방법
    • 빠른 초기화 = Eager Singleton
      • 클래스가 로딩될 때 싱글턴을 만든다.
      • 인스턴스 생성 요청을 할 필요가 없다.
    • 느린 초기화 - Lazy Singleton
      • 필요한 시점에 싱글턴이 생성된다.
      • 최초 요청하기 전까지는 인스턴스를 생성하지 않는다.

Java 구현

빠른 초기화(Eager Initialization)를 사용하는 방법

우선, 빠른 초기화를 사용하는 방법으로 시작해보자. 이 방법은 싱글턴을 구현하는 가장 간단한 방법 중 하나이다. 앞서 본 바와 같이 싱글턴은 클래스 인스턴스 생성을 아무나 만들지 못하게 하는 것을 의미한다. 그러기 위해서 private 생성자를 만든다.

public class EagerRegistry {

    private EagerRegistry() {

    }

    private static final EagerRegistry INSTANCE = new EagerRegistry();

    public static final EagerRegistry getInstance() {
        return INSTANCE;
    }
}

그리고 싱글턴 인스턴스를 초기화한다. 이 인스턴스는 생성하고자 하는 유일한 인스턴스를 가지고 있다.

마지막으로 외부로 이 인스턴스를 리턴하는 public static 메소드를 제공할 필요가 있다.

다음은 Client를 만들고 메인 메소드를 가진다.

public class Client {

    public static void main(String[] args) {
        EagerRegistry registry = EagerRegistry.getInstance();
        EagerRegistry registry2 = EagerRegistry.getInstance();
        System.out.println(registry == registry2); // true
    }
}

생성자 자체는 외부에서 안보이기 때문에 접근할 수 없다. 그래서 static 메소드를 통해 싱글턴 인스턴트를 접근할 수 있다.

느린 초기화(Lazy Initialization)를 사용하는 방법

이번에는 느린 초기화를 사용하여 싱글턴을 만들어보자. 누군가 인스턴스를 요청할 때 싱글턴 인스턴스가 생성이 된다는 것을 의미한다.

public class LazyRegistryWithDCL {

    private LazyRegistryWithDCL() {

    }

    private static LazyRegistryWithDCL INSTANCE;

    public static LazyRegistryWithDCL getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new LazyRegistryWithDCL();
        }

        return INSTANCE;
    }
}

여기서 동기화 문제가 있다. 두 개의 쓰레드가 동시에 요청을 한다면 두 개의 객체가 생성이 될 것이다. 그래서 synchronized 블럭을 사용할 것이다.

public class LazyRegistryWithDCL {

    private LazyRegistryWithDCL() {

    }

    private static volatile LazyRegistryWithDCL INSTANCE;

    public static LazyRegistryWithDCL getInstance() {
        if (INSTANCE == null) {
            synchronized (LazyRegistryWithDCL.class) {
                if (INSTANCE == null) {
                    INSTANCE = new LazyRegistryWithDCL();
                }
            }
        }

        return INSTANCE;
    }
}

여러 개의 쓰레드 중 하나의 쓰레드만 접근할 수 있게 volatile 키워드를 사용할 수 있다. 쓰레드가 캐쉬된 것을 사용하지 않도록 하는 것이다. 그래서 매번 인스턴스를 접근할 때 캐쉬가 아닌 메인 메모리를 참조하게 될 것이고 synchronize가 동작하게 보장하는 것이다.

volatile는 캐쉬를 사용하지 않고 메인 메모리에서 읽기/쓰기를 하게 만든다.

(JDK 1.5 이상에서만 동작)

다음 클라이언트를 만들어보자.

public class Client {

    public static void main(String[] args) {
        LazyRegistryWithDCL lazySingleton1 = LazyRegistryWithDCL.getInstance();
        LazyRegistryWithDCL lazySingleton2 = LazyRegistryWithDCL.getInstance();
        System.out.println(lazySingleton1 == lazySingleton2); // true
    }
}

이런식으로 더블 체크 락(DCL)을 사용하여 느린 싱글턴을 구현할 수 있다.

Holder를 사용한 느린 초기화 (Lazy Initialization with Holder)를 사용하는 방법

public class LazyRegistryIODH {

    private LazyRegistryIODH() {

    }

    private static class RegistryHolder {
        static LazyRegistryIODH INSTANCE = new LazyRegistryIODH();
    }

    public static LazyRegistryIODH getInstance() {
        return RegistryHolder.INSTANCE;
    }
}

여기서 getInstance를 호출하기 전까지는LazyRegistryIODH가 생성되지 않을 것이다.

클라이언트를 구현해보자.

public class Client {

    public static void main(String[] args) {
        LazyRegistryIODH lazyRegistryIODH1 = LazyRegistryIODH.getInstance();
        LazyRegistryIODH lazyRegistryIODH2 = LazyRegistryIODH.getInstance();
        System.out.println(lazyRegistryIODH1 == lazyRegistryIODH2); // true
    }
}

Enum을 사용한 방법

여기서는 일반적인 클래스를 통해 싱글턴을 생성하지 않고 Enum을 만들것이다.

우선, enum은 확장할 수가 사용할 수가 없고 상속은 신경을 안써도 된다는 것을 의미한다.

두 번째로 클래스에서 enum 객체를 생성할 수 없다. 여기서 유일한 객체는 우리가 선언한 인스턴스 상수이다.

public class Client {

    public static void main(String[] args) {
        RegistryEnum registryEnum1 = RegistryEnum.INSTANCE;
        RegistryEnum registryEnum2 = RegistryEnum.INSTANCE;
        System.out.println(registryEnum1 == registryEnum2); // true
    }
}

구현 고려사항

  • 빠른 초기화를 사용하는 방법이 가장 간단하고 선호되는 방법이다. 우선 이 방법을 사용하는게 좋다.
    • 빠르게 만들고 싶다면 이 방법을 사용하고 생성하는 데 시간이 걸리는 작업이 있다면 다른 방법을 찾는게 좋다.
  • 고전적인 싱글턴 패턴의 구현방식은 DCL과 volatile 필드를 사용한다.
  • Holder를 사용한 느린 초기화 방법은 동기화 이슈를 신경쓸 필요가 없고 구현하기 쉬워서 가장 좋은 방법이다.
  • enum을 사용하여 싱글턴을 구현할 수도 있다. 하지만 enum에 대한 사전 지식으로 인해 불변이 아닌 필드가 있는 경우라면 코드 리뷰할 때 힘들어 질 수도 있다.
  • 간단한 싱글턴이 동작한다면 그 방법을 사용해라.

디자인 고려사항

  • 싱글턴 생성은 어떤 파라미터도 필요하지 않다. 만일 생성자 인수를 가지려면 심플 팩토리나 팩토리 메소드 패턴을 사용해야 할 필요가 있다.
  • 싱글턴이 전역 불변이 아닌 상태가 있어서는 안된다. 많은 버그를 만들수도 있다.

위험요소

  • 싱글턴 패턴은 실제 의존관계를 알 수 없을수도 있다. 전역에서 접근하기 때문에 의존관계를 놓칠 수도 있다.
  • 단위 테스트하기가 어렵다. 리턴되는 인스턴스를 쉽게 모킹할 수 없다.
  • 싱글턴을 구현하는 가장 흔한 방법은 static 변수를 사용하는 방법이고 JVM이 아닌 클래스 로더에 있다. 그래서 OSGI나 웹 애플리케이션에서는 진짜 싱글턴이 아닐 수도 있다.
  • 불변이 아닌 전역 상태가 많은 싱글턴은 싱글턴 패턴을 잘못 사용하는 것을 나타낸다.



반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유