싱글톤 (Singleton) 패턴

weekbelt·2022년 11월 26일
0

1. 개요

싱글톤 패턴은 2가지 목적이 있습니다. 어떠한 클래스의 오직 하나만 존재하는 인스턴스에 접근할수 있고 글로벌하게 접근할 수 있는 방법을 제공해야합니다. 왜 이런경우가 생기는지 살펴보면 예를들어 게임의 환경설정과 관련된 데이터가 포함된 인스턴스는 하나여야 합니다.

예를들어 Settings라는 클래스가 있습니다

public class Settings {}

App클래스에서 Settings클래스의 인스턴스를 여러개 만들어 사용할 수 있는데 이 인스턴스들은 같지 않습니다.


public class App {

    public static void main(String[] args) {
		Settings settings = new Settings();
        Settings settings1 = new Settings();
        System.out.println(settings != settings1); // true
    }
}

그런데 싱글톤 패턴을 구현하려면 new연산자를 쓸 수 없게해야합니다. new를 사용해서 새로운 인스턴스를 만들게끔 허용하면 싱글톤 패턴을 만족시킬 수 없습니다. 그렇다면 단순하게 싱글톤 패턴을 구현하는 방법 먼저 살펴보겠습니다.

2. 싱글톤 패턴을 가장 단순히 구현하는 방법

일단 싱글톤패턴을 만족하려면 new연산자를 써서 새로운 인스턴스의 생성을 생성하는것을 막아야 합니다. 그 방법은 기본생성자를 private으로 생성해서 Settings외부에서 인스턴스를 생성할 수 없고 오직 Settings안에서만 접근할 수 있도록 합니다.

public class Settings {
	private Settings () {} // 기본 생성자의 접근제한을 private으로 변경
}

이렇게 기본생성자를 private으로 설정하면 Settings내부에서 인스턴스를 생성하는 방법 밖에는 없습니다. 그렇다면 싱글톤 패턴의 2가지 목적중의 하나인 글로벌하게 접근할 수 있도록 Settings내부에서 정적 메서드를 구현하여 인스턴스를 생성해 주도록 합니다.

public class Settings {
	private Settings () {} // 기본 생성자의 접근제한을 private으로 변경
    
    public static Settings getInstance() {
    	return new Settings();
    }
}

정적 메서드로 인스턴스를 생성하도록 구현을 하면 사용하는 Client입장에서는 Settings에서 바로 getInstance메서드를 접근해서 인스턴스를 생성할 수 있습니다.


public class App {

    public static void main(String[] args) {
		Settings settings = Settings.getInstance();
        Settings settings1 = Settings.getInstance();
        System.out.println(settings != settings1); // true
    }

}

하지만 여전히 getInstance메서드안에 new연산자를 사용해서 인스턴스를 생성하도록 했기때문에 매번 새로운 인스턴스를 생성하고 있습니다. 그래서 싱글톤 패턴의 2가지 목적중에 나머지 하나인 오직 하나뿐인 인스턴스를 만족하지 못합니다. 아래와 같이 해결할 수 있습니다.

/**
 * private 생성자와 public static 메소드를 사용하는 방법
 */
public class Settings {

    private static Settings instance;

    private Settings() { }

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

        return instance;
    }
}

getInstance를 호출할때 instance가 null이면 new연산자를 통해서 새로운 Settings인스턴스를 생성해주고 instance가 null이 아니라면 그대로 미리 생성된 instance를 리턴하도록 구현하였습니다. 이렇게 되면 첫 호출에만 Settings인스턴스가 생성되고 그 이후로 생성된 인스턴스를 리턴하기때문에 여러번 getInstance()를 호출해도 같은 Settings인스턴스를 리턴하게 됩니다.

public class App {

    public static void main(String[] args) {
		Settings settings = Settings.getInstance();
        Settings settings1 = Settings.getInstance();
        System.out.println(settings == settings1); // true
    }
}

지금까지 방법이 private생성자와 static메서드를 이용해서 구현하는 가장 흔한 방법입니다. 하지만 이 방법엔 심각한 문제가 있습니다. 우리가 보통 웹 애플리케이션을 구현할 때 멀티스레드를 사용하게 됩니다. 결국 여러 스레드가 코드에 동시접근이 가능한 상황인데 이런 상황에서는 안전하지 않습니다. 그래서 멀티스레드 환경에서 안전하게 구현하는 방법을 살펴보겠습니다.

3. 멀티 스레드 환경에서 안전하게 구현하는 방법

먼저 왜 아래와 같은 방식이 멀티스레드 환경에서 안전하지 않은지 알아봅시다.

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

        return instance;
    }

먼저 스레드1, 스레드2가 getInstance()에 접근한다고 가정해 보겠습니다. 스레드1은 if문에서 instance가 null인지 판단하는 부분에서 true를 확인하고 if문 안으로 들어가서 new연산자로 Settings인스턴스를 생성하려고 합니다. 순간 스레드2도 if문에서 instance가 null이라는 확인을 하고 스레드1과 같이 new연산자로 Settings인스턴스를 생성하도록 if문안으로 들어올 수 있습니다. 그럼 결과적으로 스레드1, 스레드2 둘다 new연산자로 각각 새로운 인스턴스를 생성하게되고 싱글톤이 깨지게 됩니다.

thread-safe하게 구현하는 여러가지 방법을 알아보겠습니다.

3.1 synchronized 키워드 사용하기

먼저 가장 쉬운방법은 getInstance메서드에 synchronized 키워드를 사용해서 오직 한 스레드만 접근할 수 있도록 하는 방법입니다.

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

        return instance;
    }

오직 한 스레드만 허용하므로 멀티스레드 환경에서도 안정적으로 싱글톤을 보장할 수 있습니다. 하지만 getInstance()를 호출할 때마다 동기화처리하는 작업때문에 성능관련 이슈가 생길 수 있습니다. 동기화라는 메커니즘은 lock을 사용해서 그 lock을 가지고있는 스레드만 해당 영역에 접근할 수 있고 다 쓰고나서 lock을 해제 해주는 처리가 필요하기 때문에 부가적인 성능저하가 생길 수 있습니다.

3.2 인스턴스를 미리 생성하기

멀티스레드 환경에 안전하긴하지만 성능을 좀 더 신경쓰고 싶고 만약에 Settings객체를 꼭 나중에 만들지 않거나 인스터스를 생성하는데 비용이 많이 들지 않는다면 미리 인스턴스를 생성해서 getInstance메서드를 호출할때 생성된 인스턴스를 리턴하도록 싱글톤을 보장할 수 있습니다.

public class Settings {

    private static final Settings INSTANCE = new Settings();

    private Settings() { }

    public static Settings getInstance() {
        return INSTANCE;
    }
}

미리 인스턴스를 생성해서 초기화시키는 방법을 eager initialization이라고 합니다. 클래스가 로딩되는 시점에 인스턴스가 생성되어있기 때문에 멀티스레드가 getInstance메서드를 호출하더라도 INSTANCE를 리턴만 하기 때문에 싱글톤이 보장됩니다. 단점이라면 미리 인스턴스를 만든다는 것 자체가 단점이 될 수 있습니다. 인스턴스를 만드는 과정이 굉장히 길고 오래걸리거나 메모리를 많이 사용하고 생성을 됐는데 쓰지를 않는다면 애플리케이션을 로딩할때 굉장히 많은 리소스를 소비함에도 불구하고 안쓰는 객체를 미리 만드는 경우가 됩니다.

3.3 double checked locking 사용하기

3.2의 단점인 미리 생성해서 쓰지 않고 getInstance메서드를 호출할때 인스턴스를 사용하고 싶은데 3.1방법인 synchronized를 쓰자니 성능이슈가 신경쓰이고 이런 경우 아래와 같이 싱글톤을 보장할 수 있습니다.

public class Settings {

    private static volatile Settings instance;

    private Settings() { }

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

}

synchronized키워드를 getInstance메서드 전체에 걸지 말고 if문에서 instance가 null일때 synchronized블록을 생성하여 Settings.class를 lock으로 쓰게끔 설정하고 블록 안에서 한번더 null check를 하도록 합니다. synchronized블록 밖에서 null체크를 한번하고 블록안에서 한번 더 한다고 해서 double checked locking이라고 합니다.

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

double checked locking을 적용한 getInstance메서드를 좀 더 자세히 살펴보면 스레드1, 스레드2가 있고 스레드1이 synchronized블록 밖의 null체크를 하고 블록안으로 들어왔는데 스레드2도 마침 스레드1의 synchronized블록이 다 끝나기 전에 블록 밖의 if문으로 들어왔다면 스레드1이 이미 synchronized블로 안으로 들어왔기 때문에 스레드2는 블록 안으로 진입할 수 없고 스레드1이 lock을 해제할 때까지 기다려야 합니다. 스레드1이 getInstance()를 호출할때는 Settings인스턴스를 새로 생성하여 리턴하고 스레드2가 getInstance()를 호출할때는 생성된 Settings인스턴스를 리턴하게 됩니다.

그런데 이런 의문이 들 수 있습니다. 기존에 3.1읭 방법처럼 getInstance메서드 전체에 synchronized를 적용하는것 보다 왜 이 방법이 성능상의 이점이 있는지를 알아보면 현재 방법은 getInstance()를 호출할때마다 lock이 걸리지 않기 때문입니다. 결국 instance가 null인 경우 딱 한번만 lock이 걸리고 인스턴스가 생성한 후에는 동시에 여러 스레드가 synchronized블록 밖에 if문에 접근할 때는 synchronized블록을 거치지 않고 바로 instance를 리턴하게 됩니다.

하지만 이런방법은 instance변수가 왜 volatile이 선언되어야 하는지 알아야하는데 그이유를 알기엔 복잡하고 이 방법은 자바 1.5이상부터만 쓸 수 있는 방법입니다. 만약에 자바 버전 상관없이 1.5이전 버전에도 싱글톤을 보장할 수 있게 하려면 다른 방법을 사용해야 합니다.

3.4 static inner class 사용하기

public class Settings {

    private Settings() { }

    private static class SettingsHolder {
        private static final Settings INSTANCE = new Settings();
    }

    public static Settings getInstance() {
        return SettingsHolder.INSTANCE;
    }

}

private inner class인 SettingsHolder를 정의하고 new연산자를 사용해서 인스턴스를 생성하도록 INSTANCE변수에 선언합니다. getInstance()를 호출할 때 SettingsHolder를 통해서 INSTANCE를 리턴하도록 합니다. 이 방법은 멀티스레드 환경에서도 안전하고 getInstance()를 호출할때 SettingsHolder클래스가 로딩이 되고 그때 Settings인스턴스를 생성하기 때문에 lazy loading도 가능한 방법입니다. 3.3의 double checked locking방법보다 코드도 간단하기 때문에 권장하는 방법중의 하나입니다.

이렇게 멀티스레드 환경에서 싱글톤을 보장할 수 있는 다양한 방법을 살펴봤습니다. 하지만 지금까지 살펴본 이 모든 방법을 깨뜨릴 수 있는 다양한 코딩 방법들이 존재합니다. 그 방법들에 대해서 살펴 보겠습니다.

4. 싱글톤 패턴 구현 방법을 깨트리는 방법

위에서 살펴봤던 여러가지 방법들을 구현해도 사용하는 Client쪽에서 제대로 사용하지 않고 자바에서 허용하는 여러가지 방법들을 사용하면 싱글톤이 깨지게 됩니다. 아래와 같이 Settings외부에서 getInstance()를 사용하면 getInstance()에서 정의한 방법대로 사용할 수 밖에 없고 당연히 settings와 settings1이 같은 값이 됩니다.

public class App {

    public static void main(String[] args) {
		Settings settings = Settings.getInstance();
        Settings settings1 = Settings.getInstance();
        System.out.println(settings == settings1);
    }
}

그렇다면 어떻게 싱글톤을 깨뜨려서 settings와 settings1의 값이 다르게 할지 알아보겠습니다.

4.1 리플렉션 사용하기

아래와 같이 DeclaredConstuctor()로 Settings클래스의 생성자를 가져오고 setAccessible를 true로 설정하면 private 접근제한자를 접근할 수 있습니다. 그 후 newInstance()를 사용해서 인스턴스를 만들면 Settings타입의 인스턴스를 생성할 수 있습니다. 하지만 getInstance()로 만든 인스턴스는 싱글톤을 보장하는 인스턴스이고 newInstance()로 만든 인스턴스는 new연산자를 쓰는 것과 같은 새로 생성된 인스턴스이기 때문에 싱글톤이 깨지게 됩니다.

public class App {

    public static void main(String[] args) {
		Settings settings = Settings.getInstance();
        
        Constructor<Settings> constructor = Settings.class.getDeclaredConstructor();
        constructor.setAccessible(true);
		Settings settings1 = constructor.newInstance();
    }
}

결국 settings와 settings1은 다른 값이 나오게 됩니다.

System.out.println(settings == settings1); // false

4.2 직렬화 & 역직렬화 사용하기

자바에는 직렬화와 역직렬화의 개념이 있는데 Object를 파일형태로 디스크에 저장할때 직렬화를 해서 저장하고 다시 읽어들일때 역직렬화를 하게 됩니다. 이 직렬화 & 역직렬화로 어떻게 싱글톤을 깨뜨리는지 살펴보겠습니다.

public class Settings implements Serializable {
	
    private Settings() {}
    
    private static class SettingsHolder {
    	private static final Settings INSTANCE = new Settings();
    }

	public static Settings getInstance() {
    	return SettingsHolder.INSTANCE;
    }
}

Settings클래스를 Serializable 인터페이스의 구현체로 선언 합니다. Serializable 인터페이스를 구현한 것들은 직렬화 & 역직렬화에 사용할 수 있습니다. 그 말인 즉슨 Settings의 인스턴스를 파일로 직렬화해서 저장하고 불러올때 역직렬화를 통해서 불러올 수 있다는 뜻입니다.

public class App {
	
    public static void main(String[] args) {
    	Settings settings = Settings.getInstance();
        Settings settings1 = null;

        
        // 직렬화 
        try (ObjectOutput out = new ObjectOutputStream(new FileOutputStream("settings.obj"))) {
            out.writeObject(settings);
        }

		// 역직렬화
        try (ObjectInput in = new ObjectInputStream(new FileInputStream("settings.obj"))) {
            settings1 = (Settings) in.readObject();
        }

		System.out.println(settings == settings1); // false
    }
}

ObjectOutput을 사용해서 settings.obj라는 파일에 settings인스턴스를 직렬화하여 파일로 저장합니다. 반대로 생성된 settings.obj를 역직렬화를 통해서 settings1로 읽어오면 역직렬화시 반드시 생성자를 사용해서 인스턴스를 생성해주기 때문에 settings와 settings1은 다른객체가 됩니다. 그렇지만 역직렬화시에 새로 인스턴스가 생성하는것을 막을 수 있는 방법이 있는데 한번 살펴보겠습니다.

4.2.1 역직렬화 대응 방안

Settings클래스에서 구현하고 있는 Serializable 인터페이스 자체는 명시적으로 메서드가 정의되어 있지는 않습니다.

public interface Serializable {
}

그런데 Settings에서 readResolve메서드가 있다면, 이 시그니처가 존재한다면 역직렬화시 사용이 됩니다.

public class Settings implements Serializable {
	
    private Settings() {}
    
    private static class SettingsHolder {
    	private static final Settings INSTANCE = new Settings();
    }

	public static Settings getInstance() {
    	return SettingsHolder.INSTANCE;
    }
    
    protected Object readResolve() {		// 역직렬화시 사용
        return getInstance();
    }
}

워래는 readResolve()안에서 new연산자로 Settings인스턴스가 새로 생성되지만 getInstance()를 실행해서 리턴하도록하면 readResolve를 사용하기 때문에 settings와 settings1이 동일한 인스턴스라는 것을 확인할 수 있습니다.

역직렬화시 readResolve메서드의 조작을 통해서 싱글톤을 깨지지 않게 할 수 있지만 이 방법으로는 여전히 리플렉션을 대응할 수 없습니다. 다른 방법을 통해서 리플렉션을 통해서도 싱글톤을 깨지지 않게 할 수 있는지 알아보겠습니다.

5. 안전하고 단순하게 구현하는 방법

이전까지는 멀티스레드에 안전하게 싱글톤패턴을 구현하는 방법까지 살펴봤습니다. 그런데 그렇게 구현하더라고 리플렉션이나 직렬화 & 역직렬화시 싱글톤을 깨뜨릴 수 있다는 것을 확인했습니다. 직렬화 & 역직렬화 까지는 대응이 되지만 여전히 리플렉션은 대응이 되지 않았습니다. Enum을 사용하면 리플렉션을 대응할수 있게 간단한 방법으로 싱글톤을 보장할 수 있습니다.

public enum Settings {

    INSTANCE;
    
    Settings() {} // enum은 기본생성자의 접근제한자가 private 입니다.

    private Integer number;

    public Integer getNumber() {
        return number;
    }

    public void setNumber(Integer number) {
        this.number = number;
    }
}

위와 같이 enum으로 Settings를 선언하면 됩니다. enum은 클래스와 같이 필드와 메서드를 선언할 수 있습니다. 아무 문제없이 Settings클래스를 싱글톤으로 사용할 수 있습니다. 이렇게 enum으로 사용하면 리플렉션에 안전합니다.

public class App {
	
    public static void main(String[] args) {
    	Settings settings = Settings.INSTANCE;
        
        Constructor<Settings> constructor = Settings.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Settings settings1 = constructor.newInstance();
        
        System.out.println(settings == settings1);

}

리플렉션을 이용해서 settings1을 생성해보겠습니다. 그런데 위 코드를 실행해보면 Settings.()와 같은 생성자가 없다고 에러가 발생합니다.

Exception in thread "main" java.lang.NoSuchMethodException: me.whiteship.designpatterns._01_creational_patterns._01_singleton.Settings.()
at java.base/java.lang.Class.getConstructor0(Class.java:3349)
at java.base/java.lang.Class.getDeclaredConstructor(Class.java:2553)
at me.whiteship.designpatterns._01_creational_patterns._01_singleton.App.main(App.java:25)

byte code로 Settings를 확인해보겠습니다.

public enum Settings {

    INSTANCE;
}
Settings의 byte code
  // access flags 0x2
  // signature ()V
  // declaration: void <init>()
  private <init>(Ljava/lang/String;I)V
    // parameter synthetic  $enum$name
    // parameter synthetic  $enum$ordinal
   L0
    LINENUMBER 3 L0
    ALOAD 0
    ALOAD 1
    ILOAD 2
    INVOKESPECIAL java/lang/Enum.<init> (Ljava/lang/String;I)V
    RETURN
   L1
    LOCALVARIABLE this Lme/whiteship/designpatterns/_01_creational_patterns/_01_singleton/Settings; L0 L1 0
    MAXSTACK = 3
    MAXLOCALS = 3

바이트코드 일부를 가져왔는데 private (Ljava/lang/String;I)V 이부분에서 String이 하나 있는 생성자를 쓰고 있습니다. 그래서 Constructor constructor = Settings.class.getDeclaredConstructor(); 이부분에서 잘못된 생성자를 찾았다고 생성자를 못가져 왔었습니다. 결국 기본생성자를 못가져오기 때문에 선언되어있는 모든 생성자를 다 가져오게 한다음에 생성자를 순회하면서 Settings의 인스턴스를 만들도록 수정을 해보겠습니다.

public class App {
	
  public static void main(String[] args) {
      Settings settings = Settings.INSTANCE;
      Settings settings1 = null;

      Constructor<?>[] declaredConstructors = Settings.class.getDeclaredConstructors();
      for (Constructor<?> declaredConstructor : declaredConstructors) {
          declaredConstructor.setAccessible(true);
          settings1 = (Settings) declaredConstructor.newInstance("INSTANCE");
      }

      System.out.println(settings == settings1);
  }

}

생성자에 "INSTANCE"라는 문자열을 줘서 실행해보면 Cannot reflectively create enum objects라고 enum객체를 생성할 수 없다고 리플렉션 내부에서 막아버립니다. enum은 리플렉션에서 newInstance()를 할 수 없게끔 막아놨습니다.

    @CallerSensitive
    @ForceInline // to ensure Reflection.getCallerClass optimization
    public T newInstance(Object ... initargs)
        throws InstantiationException, IllegalAccessException,
               IllegalArgumentException, InvocationTargetException
    {
        if (!override) {
            Class<?> caller = Reflection.getCallerClass();
            checkAccess(caller, clazz, clazz, modifiers);
        }
        if ((clazz.getModifiers() & Modifier.ENUM) != 0)	// enum은 newInstance()를 막아놨다
            throw new IllegalArgumentException("Cannot reflectively create enum objects");
        ConstructorAccessor ca = constructorAccessor;   // read volatile
        if (ca == null) {
            ca = acquireConstructorAccessor();
        }
        @SuppressWarnings("unchecked")
        T inst = (T) ca.newInstance(initargs);
        return inst;
    }

결국 enum타입으로 Settings를 생성하게되면 리플렉션으로 새로운 인스턴스를 생성할 수 없기때문에 싱글톤을 보장할 수 있습니다. 하지만 enum타입으로 Settings를 선언하면 인스턴스가 미리 생성된다는 단점이 있습니다. 미리 생성된다는 것이 큰 문제가 아니라면 이 방법을 권장하고 있습니다.

public class App {

    public static void main(String[] args) throws IOException, 
        Settings settings = Settings.INSTANCE;
        Settings settings1 = null;
        try (ObjectOutput out = new ObjectOutputStream(new FileOutputStream("settings.obj"))) {
            out.writeObject(settings);
        }

        try (ObjectInput in = new ObjectInputStream(new FileInputStream("settings.obj"))) {
            settings1 = (Settings) in.readObject();
        }

        System.out.println(settings == settings1);	// true

    }

}

다시 enum타입으로 선언된 Settings를 직렬화 & 역직렬화를 통해서 settings와 settings1값을 비교해보면 같은 인스턴스를 생성하는것을 확인할 수 있습니다.

6. 결론

java에서 제일 많이 권장하는 방법은 enum으로 구현하는 방법, static enum class로 holder를 사용하는 2가지 방법을 권장합니다.

참고

profile
백엔드 개발자 입니다

0개의 댓글