로컬에서는 잘 되는데 ☘️

[생성 패턴] 싱글톤 패턴(Singleton Pattern)

by youngjun._.

이번 글에서는 디자인 패턴 중 생성 패턴 중 하나인 싱글톤 패턴에 대해 알아본다.

 

0. 생성 패턴

생성 패턴은 인스턴스를 만드는 절차를 추상화하는 패턴이다.

 

객체를 생성, 합성하는 방법을 시스템과 분리해주며, 시스템이 상속(inheritance) 보다 복합(composite) 방법을 사용하는 방향으로 진화되어 가면서 더 중요해지고 있다.

 

생성 패턴의 중요한 포인트

  1. 생성 패턴은 시스템이 어떤 Concrete Class를 사용하는지에 대한 정보를 캡슐화한다.
  2. 클래스의 Instance들이 어떻게 만들고 어떻게 결합하는지에 대한 부분을 완전히 은닉해준다.

 

1. 싱글톤 패턴이란?

생성 패턴 중 하나로, 인스턴스를 오직 한 개만 만들어서 제공하는 클래스가 필요한 경우에 사용하는 패턴

싱글톤 패턴 UML

1-1. 정의(Definition)

소프트웨어 디자인 패턴에서 싱글턴 패턴(Singleton pattern)을 따르는 클래스는, 생성자가 여러 차례 호출되더라도 실제로 생성되는 객체는 하나이고 최초 생성 이후에 호출된 생성자는 최초의 생성자가 생성한 객체를 리턴한다. 주로 공통된 객체를 여러 개 생성해서 사용하는 DBCP(DataBase Connection Pool)와 같은 상황에서 많이 사용된다고 한다.

 

한마디로 객체를  메모리에 한번만 올리고, 해당 메모리에 다시 접근한다.

 

1-2. 사용 이유

시스템 런타임, 환경 세팅 관련 정보 등 인스턴스가 여러 개일 때 문제가 발생하는 경우 등이 있는데 싱글톤 패턴을 사용함으로써 가져갈 수 있는 이점은 다음과 같다.

  1. 메모리, 속도 측면 : 객체의 인스턴스를 재사용하기 때문(고정된 메모리 영역을 사용)
  2. 데이터 공유가 쉬움 : 기존 인스턴스가 전역으로 사용되기 때문
  3. 인스턴스가 한 개만 존재하는 것을 보장하고 싶은 경우

 

1-3. 싱글톤 패턴 구현

  • private constructor 선언
  • static method 사용

싱글톤 패턴에서는 생성자를 클래스 자체에서만 접근할 수 있어야 하기 때문에 private으로 접근 제어를 해야 한다.

 

✍🏻 주의할 점
인스턴스는 생성 이후 수정이 되지 않도록 막아주자.
생성 이후에 해당 클래스의 인스턴스를 NULL로 초기화해버릴 수도 있기 때문이다.

인스턴스가 하나만 존재함을 보장해야 하기 때문에 Single Thread에서는 문제가 되지 않지만 Multi Thread 환경에서 싱글톤 객체에 접근 시 초기화 관련한 문제가 발생할 수 있다.

 

아래에서 이를 해결하기 위한 방법을 알아보자.

 

1-3-1. 이른 초기화(Eager Initialization)

static 키워드를 통해 클래스 로더가 초기화하는 시점정적 바인딩(Static Binding)을 통해 해당 인스턴스를 메모리에 등록하기 때문에 Thread-safe 하다. 

public class Singleton {
    private static final Singleton INSTANCE = new Singleton();
    
    private Singleton() {}
    
    public static Singleton getInstance() {
        return INSTANCE;
    }
}
장점
Thread-safe

단점
미리 만들어두기 때문에 실제 해당 인스턴스를 사용하지 않으면 메모리 측면에서 손해

 

1-3-2. 늦은 초기화(Lazy Initialization)

인스턴스를 실제 사용하는 시점에서 생성하는 방법 - 동적 바인딩(Dynamic Binding)

  • 이른 초기화 방법보다 메모리 측면에서 효율적
  • 아래 getInstance( )멀티 스레드 환경에서는 안전하지 않다.
public class Singleton {
    private static Singleton instance;
    private Singleton() { }

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

만약 두 Thread 가 동시에 해당 인스턴스에 접근 시 인스턴스가 생성되어 있지 않는 것으로 보고 중복으로 생성할 수 있기 때문이다.

Thread A : if(INSTANCE == null) 수행 결과 true
Thread B : if(INSTANCE == null) 수행 결과 true

Thread A : INSTANCE = new Singleton() 수행으로 인스턴스1 생성
Thread B : INSTANCE = new Singleton() 수행으로 인스턴스2 생성
장점
사용 시점에 인스턴스를 생성하여 메모리를 효율적으로 사용

단점
Thread Safe 하지 않음

 

1-3-3. 늦은 초기화, 동기화 처리(Lazy Initialization with synchronized)

위에서 살펴본 Lazy Initialization의 멀티 스레드 문제는 Synchronized 키워드를 사용하여 동기화 처리를 통해 해결할 수 있다.

 

단점은 getInstance( )를 호출 시에 해당 인스턴스의 생성 여부와 상관없이 동기화 블록을 거쳐야 한다는 점이다.

 

기본적으로 동기화라는 과정이 락(Lock)을 거는 메커니즘을 사용하기 때문에 성능이 떨어질 수밖에 없다.

public class Singleton {
    private static Singleton instance;
    private Singleton() { }

    public static synchronized Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}
장점
메모리 효율적으로 사용, Thread Safe

단점
인스턴스 생성 여부와 상관없는 동기화(Lock) 때문에 성능이 떨어짐

 

1-3-4. 늦은 초기화, DCL(Lazy Initialization. Double Checked Locking)

위 동기화 블록 방식을 개선한 방식으로, 먼저 인스턴스의 생성 여부를 확인하는 방법이 있다.

 

인스턴스가 생성되지 않은 경우에 동기화 처리를 하기 때문에 효율적으로 동기화 블록을 만들 수 있다.

public class Singleton {
    private volatile static Singleton instance;
    private Singleton() { }

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

이 경우에는 volatile 키워드를 사용해야 DCL이 정상적으로 동작할 수 있다.

멀티스레딩을 쓰더라도 instance 변수가 Sigleton 인스턴스로 초기화되는 과정이 올바르게 진행되기 때문이다.


volatile 키워드가 필요한 이유 ?


volatile 변수를 사용하고 있지 않는 멀티 스레드 어플리케이션에서는 작업(Task)을 수행하는 동성능 향상을 위해 Main Memory에서 읽은 변수 값을 CPU Cache 에 저장하게 된다. 만약에 멀티 스레드 환경에서 스레드가 변수 값을 읽어올 때 각각의 CPU Cache 에 저장된 값이 다르기 때문에 변수 값 불일치 문제가 발생하게 되는데, volatile 키워드가 이런 문제를 해결할 수 있다.

즉, volatile 변수는 Main Memory 에 값을 저장하고 읽어오기 때문에(read and write) 변수 값 불일치 문제가 생기지 않는다.

1. 하나의 스레드는 read and write 하며, 나머지 스레드는 read 만 하는 경우 변수의 최신 값을 보장한다.
2. 여러 개의 스레드가 write 하는 상황이라면 동기화 블록(synchronized)을 지정해서 원자성(atomic)을 보장해야 한다.

출처 : webdevtechblog : 싱글턴 패턴

jdk 1.5 버전 이하일 때 volatile을 사용하지 않았을 때 발생한 DCL 코드를 보면 더 이해가 쉽다. 다른 블로그에서 정리를 잘해두었다. 궁금하면 읽어보자!

장점
- 메모리 효율적으로 사용
- Thread Safe
- 인스턴스 생성 여부 검사 (Lock 이슈 해결)

단점
- 비동기화된 Resource 필드에 의존하게 되어 위에서 알아본 것처럼 변수의 최신 값이나 원자성을 보장해줘야 한다.
- 자세히 알아보려면 해당 글을 읽어보자. 번역한 글도 있다.

 

 

1-3-5. LazyHolder - 늦은 초기화, Static Inner class사용

클래스 안에 클래스(Holder)를 두어 JVM의 Class loader 메커니즘과 Class가 Load 되는 시점을 이용한 방법이다.

public class Singleton {
    private Singleton() { }

    private static class SingletonHolder {
        public static final Singleton INSTANCE = new Singleton();
    }
    
    public static Singlton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

여기서 getInstance가 호출될 때 SingletonHolder 클래스호출이 되면 실제 인스턴스가 만들어지기 때문에 성능 이슈가 없다.

 

Singleton 클래스의 getInstance() 메서드에서 SingletonHolder.INSTANCE를 참조하는 순간 Class가 로딩되며 초기화가 진행된다.

Class를 로딩하고 초기화하는 시점thread-safe를 보장하기 때문에 volatile이나 synchronized 같은 키워드가 없어도 된다.

 

다만, 이경우에도 해당 싱글톤 패턴을 깨트릴 수 있는데 다음과 같은 방법은 있다.

  1. 리플렉션의 사용
  2. 직렬화 그리고 역직렬화의 사용

리플렉션의 사용을 먼저 살펴보면 다음과 같다. 

public class Application {
    public static void main(String[] args) throws NoSuchMethodExceotion, 
                                                  InvocationTargetException,
                                                  InstantiationExcetpion {
        Singleton singleton = Singleton.getInstance();
    
        Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Singleton singleton2 = constructor.newInstance();
    
        System.out.println(singleton == singletons2);    //false
    }
}

의도한 바와 다르게 인스턴스를 생성하게 되면 새로운 인스턴스가 생성될 수 있다.

 

또 다른 방법으로는 직렬화와 역직렬화를 사용하는 방법이 있다.

public class Application {
    public static void main(String[] args) throws IOException {
        Singleton singleton = Singleton.getInstance();
        Singleton singleton2 = null;
        
        try (ObjectOutput out = new ObjectOutputStream(new FileOutputStream("singleton.obj"))) {
            out.writeObject(singleton);
        }
        
        try (ObjectInput in = new ObjectInputStream(new FileInputStream("singleton.obj"))) {
            singleton2 = (singleton) in.readObject();
        }
        
        System.out.println(singleton == singleton2);    //false
    }
}

역직렬화를 하는 과정에서 새로 생성자가 실행되기 때문에 다른 결과가 나올 수 있다.

다만 역직렬화의 경우에는 readResolve를 생성해주면 해당 케이스에 대하여 대응할 수 있다.

public class Singleton implements Serializable {
    private Singleton() { }

    private static class SingletonHolder {
        public static final Singleton INSTANCE = new Singleton();
    }
    
    public static Singlton getInstance() {
        return SingletonHolder.INSTANCE;
    }
    
    protected Object readResolve() {
        return getInstance();
    }
}

하지만 리플렉션의 경우 딱히 대응이 힘들기 때문에 새로운 싱글톤 패턴의 구현 방법이 필요하다.

 

1-3-6. 늦은 초기화, Enum 사용

Enum 인스턴스의 생성은 기본적으로 Thread-safe 하기 때문에 스레드 관련 코드를 사용하지 않아도 되기 때문에 간편해진다.

public enum Singleton {
    INSTANCE;
}

Enum을 사용하는 방식의 장점은 위에서 언급한 리플랙션, 직렬화와 역직렬화의 상황을 방지할 수 있다는 것이다.

 

다만, 이 경우에는 상속을 사용할 수 없다.

또한, Context 의존성이 있는 환경에서는 싱글턴의 초기화 과정에 Context라는 의존성이 끼어들 가능성이 있는 단점이 있다.

 

2. 싱글톤은 어떻게 사용될까?

자바와 스프링에서 싱글톤 패턴을 어떻게 사용하고 있는지

 

2-1. 자바와 스프링의 싱글톤 차이점

그렇다면 실무에서의 싱글톤 패턴은 어떻게 사용될까? 

우선 다른 디자인 패턴 구현체의 일부로 사용될 수 있으며, 다음과 같은 상황에서 사용된다.

 

java.lang.Runtime

Runtime이라는 자바가 제공하고 있는 라이브러리를 사용하는 경우

Runtime runtime = Runtime.getRuntime();

new 생성자를 통해 생성할 수 없다.

 

스프링에서의 싱글톤 스코프

특정 정의된 빈을 가지고 ApplicationContext를 만들면 항상 같은 type의 빈이 나오게 된다.

ApplicationContext applicationContext = new AnnotationConfigApplicationContext(Singleton.class);

이경우 싱글톤 스코프라고 말하는데 엄밀히 말해서는 싱글톤 패턴과는 다르다고 한다.

 

ApplicationContext내부에서 유일한 인스턴스로서 관리가 되는 것일 뿐이기 때문이다.

 

 

Reference

 

블로그의 정보

개발하는만두

youngjun._.

활동하기