Singleton pattern

Design Pattern 공부

디자인 패턴 공부를 모아놓은 tag 입니다.


Singleton

singleton pattern : class가 오직 한 개의 instance만 제공하게 하는 sw design pattern이다. - 출처

사실 작년에 Java공부를 처음 했을 때 Singleton 에 대해 들어봤었고 간단하게 구현도 해봤었다. 그 때 내가 느꼈던 singleton pattern은

‘이게 끝? 생각보다 별거 없네’

이런 생각을 하면 안됐었다. 그 당시에도 그랬지만, 솔직히 왜 굳이 이렇게 쓰지? 라는 생각이 들었는데 다음과 같은 경우에서라면 필요해보였다.


그리고 계속 1개만 만들어야 한다!를 강조하는데 그 이유에 대해서 말해보자면,

new keyword는 새로운 instance를 생성할 수 있다.

그렇다. 우리는 ‘새로운’이 아닌, 1개의 instance만을 원하는 것이다. 근데 처음에 1개의 instance는 생성을 해놔야 그 다음에 걔를 사용할 수 있을 것이다. 따라서 어쩔 수 없이 new를 한번은 써야한다는 것이다. 지금부터 여러가지 방법으로 singleton pattern을 구현해 볼 것이다.


Simple Singleton

일단 singleton pattern을 구현하기 위해서는 2가지가 꼭 필요하다.

  1. private contructor → 다른 클래스에서 생성 못하도록 방지
  2. Static instance field → instance를 제공하기 위해 getInstance() method를 구현하여 해당 field를 반환하게 할 것이다.


보통 Web application을 만들 때에는 multi-thread를 사용하게 된다. 그 때 이 코드는 안전하지 않음을 아래에서 볼 수 있다.

public class SimpleSingleton {
	private static SimpleSingleton instance; // SimpleSingleton class 에서 instance 를 미리 안만들기로 함.

	private SimpleSingleton() {} // private contructor

	/**
	 * 다음과 같은 singleton method 는 문제가 있다. 바로 thread-safe 하지 않다는 것!!!
	 * 1. thread1, thread2 가 if 에 동시에 접근하고, 현재 instance field 는 null 이기에 둘 다 접근!!
	 * 2. thread1 이 new keyword 로 SimpleSingleton class 생성. 이제 instance field 는 null 이 아니다
	 * 3. thread2 도 new keyword 로 SimpleSingleton class 생성. 이제 ...? 아까 instance 는 null 아니었는데...?
	 * '이미 if 안에 들어온 시점에서 instance == null' 이었기에 thread2 도 instance 를 생성하게 된 것.
	 */
	public static SimpleSingleton getInstance() { // instance를 제공하기 위해 만든 method
		if (instance == null) {
			instance = new SimpleSingleton();
		}
		return instance;
	}
}

singleton구현 코드와 해당 코드를 테스트한 코드를 만들었다. 해당 코드는 인프런 GoF 디자인 패턴 - 백기선 강사님 강의를 참고하였다. 아래는 테스트 코드에 대한 출력이다.

javastudy.designpattern.singleton.SimpleSingleton@6e0d1c57
javastudy.designpattern.singleton.SimpleSingleton@78426c1f ← 다르다!!
javastudy.designpattern.singleton.SimpleSingleton@6e0d1c57

singleton 으로 구현했음에도 불구하고 다른 instance가 생성됐음을 볼 수 있다. 우리가 원했던 singleton의 동작이 아닌 것이다. 현재 이 문제는 thread-safe하지 않았다는 점이었다. 따라서 다음에는 동기화 처리를 해줘보겠다.


Synchronized Singleton

간단하게 말해서 위 SimpleSingleton class의 getInstance()method에 동기화 처리를 해주는 것이다. synchronized keyword를 사용하여 동기화 method로 만들어주도록 하면 해결된다…!

public class SynchronizedSingleton {
	private static SynchronizedSingleton instance;

	private SynchronizedSingleton() {}

	/**
	 * multi-threading 환경에서도 하나의 instance 만 생성됨을 보장할 수 있다.
	 * 그러나 getInstance method 를 호출할 때 마다 동기화 처리하는 작업 때문에 성능에 영향을 미칠 수 있다.
	 */
	public static synchronized SynchronizedSingleton getInstance() {
		if (instance == null) {
			instance = new SynchronizedSingleton();
		}
		return instance;
	}
}

아까보다 코드가 거의 바뀐게 없어보이지만, method에 synchronized keyword가 추가됐음을 볼 수 있다. 테스트 코드는 여기서 볼 수 있다. 이번에야 말로 동기화처리까지 했으니 완벽한 singleton 아니겠는가…? 라고 생각했지만,

동기화를 instance를 호출할 때 마다 해줘야 하나…?

라는 생각을 한번 해본다면 과연 위 코드는 완벽한지에 대한 의문이 들 수도 있다. 먼저 ‘왜 동기화 처리를 해줬는지’ 부터 다시 생각해보자.

instance 생성할 때 if조건에 2개의 thread가 동시에 접근할 수 있기 때문에 동기화 처리를 진행했다.

이거보다 좀 더 근원적인 이유를 생각하면,

instance를 1개만 생성하기 위해서 동기화 처리를 진행.

맞다. 1개의 instance만 ‘생성’하기 위해서 우리는 동기화 처리를 해준 것이다. 그렇다면

생성 안할 때도 동기화 처리를 해줘야 하는가…?

라는 물음에는… 당연히 동기화 처리를 해줄 필요가 없다고 말할 수 있다. 그렇다. 우리는 불필요하게 method전체에 동기화 처리를 하고 있던 것이다. 다음에 소개할 singleton 구현방법은 synchronized block을 이용한 동기화 처리 방법이다.


Double Checked Locking Singleton

이전에 만들었던 synchronized singleton은 비효율적이었다. 이미 생성된 instance에 접근하는 것에는 굳이 동기화 처리를 해줄 필요가 없었기 때문이다. 따라서 이전에 Java multi-threading programming을 공부하면서 배웠던 synchronized block을 사용하여 동기화 처리를 해줄 것이다.

public class DoubleCheckedLockingSingleton {
	private static volatile DoubleCheckedLockingSingleton instance; // field volatile 선언

	private DoubleCheckedLockingSingleton() {}

	public static DoubleCheckedLockingSingleton getInstance() {
		if (instance == null) { // 1번째 확인
			synchronized (DoubleCheckedLockingSingleton.class) { // class lock 사용
				if (instance == null) { // 2번째 확인
					instance = new DoubleCheckedLockingSingleton(); // 1,2 번째 확인 후 생성안되어 있었다면, instance생성
				}
			}
		}

		return instance;
	}
}

아까와는 달리, instance가 null이 아니라면 동시에 여러 thread가 접근하여 해당 instance를 반환받을 수 있다. 그리고 instance생성 시 1번째 if문를 여러개의 thread가 동시에 통과하더라도, instance생성하기 위해서는 동기화 블럭에 lock을 얻은 후 접근할 수 있기에 동기화처리가 됐다고 할 수 있다. 왜냐하면 instance가 생성된 후 다른 thread가 동기화 블럭에 접근해도, 그 안의 2번째 if문에 의해 instance의 생성이 무시되기 때문이다. 테스트 코드도 확인해보길 바란다.

당연하게도 항상 동일한 instance가 생성된다.


Eager Singleton

동기화를 사용하는게 성능적으로 안좋을 것 같고, 그냥 미리 선언해버리고 싶다면 다음과 같은 방법으로 구현하면 된다.

public class EagerSingleton {
	/**
	 * 동기화 keyword 를 사용하는건 성능적으로 안좋을 것 같아서 multi-thread 환경에서도 안전한 '미리선언하는 방법' 을 사용.
	 * INSTANCE 는 class loading 할 때에 초기화되어 미리 생성된다.
	 * 이 방법의 단점은 '미리 생성' 한다는 것이다.
	 * 만약 EagerSingleton class 가 생성하는데 많은 비용이 든다면,
	 * 필요하지도 않을 때 큰 비용을 들여가며 instance 를 생성할 필요가 있는지 생각을 해봐야 한다.
	 */
	private static final EagerSingleton INSTANCE = new EagerSingleton(); // EagerSingleton class 에서 instance 를 미리 만들기로 함.

	private EagerSingleton() {}

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

여러개의 thread가 접근하기도 전에 class loading시점에 이미 instance를 생성했기에 우리는 그냥 가져다 쓰기만 하면된다. 하지만 위 class가 loading하는데 많은 자원을 소비한다면, 당장 쓰지도 않을 class에 이렇게 많은 투자를 미리 하는게 성능상 좋을지는 생각해봐야 한다.


Static Inner Class Singleton

가장 많이 쓰이는 방법이라고 한다. 이 방법은

  1. 우리가 원할 때 class loading가능 - Lazy loading
  2. thread-safe 환경 제공 - 동시성 처리 됨.

이라는 2가지 장점을 갖고 있다.

public class StaticInnerClassSingleton {
	private StaticInnerClassSingleton() {}

	/**
	 * 장점
	 * 1. multi-threading 환경에서도 안전하다.
	 * 		왜? : JVM 에서는 class loading 할 때 안전한 동기화 환경을 제공하기 때문이다.
	 * 		따라서 Holder 라는 inner class 가 loading 될 때, 그 과정 자체가 JVM 에서 동기화 환경을 제공하니깐 thread-safe 하게
	 * 		INSTANCE 를 생성할 수 있는 것.
	 * 2. Lazy loading 이 가능하다.
	 * 		StaticInnerClassSingleton class 가 로딩되는 시점이 아닌, getInstance() method 를 호출할 때에
	 * 		INSTANCE 객체가 생성되므로 우리가 원하는 시점에 생성핳 수 있다는 장점이 있다.
	 *
	 * Double Checked Locking 과 같이 복잡한 이론이나, 코드가 필요없이 간단하게 만들 수 있다.
	 */
	
	// 2. Holder class loading.
	private static class Holder {
		// 3. class loading 되면서 static field 가 초기화 되는데 그 때 JVM 에서 동기화 환경을 제공한다.
		// 		→ 따라서 thread-safe 하다고 할 수 있다.
		private static final StaticInnerClassSingleton INSTANCE = new StaticInnerClassSingleton();
	}

	public StaticInnerClassSingleton getInstance() {
		return Holder.INSTANCE; // 1. Holder class 호출.
	}
}

그리고 DoubleCheckedLockingSingleton 마냥 복잡한 이론이나 코드가 필요없이 간단하게 구현할 수 있다는게 또 하나의 장점이다.


Singleton Pattern 부숴버리기~

우리가 위에서 배운 내용을 기반으로 singleton class를 제공했어도, 쓰는 사용자가 제대로 쓸려는 마음이 없다면… 결국 singleton이 깨질 수 있음을 이번에 보여줄 것이다.


Reflection

Java에는 Reflection이라는 기술이 있다. 쉽게 말하자면, run-time시에 java 코드를 조작하는 것이다. 우리는 reflection을 사용하여 private으로 선언된 constructor들을 직접 호출하여 singleton을 깨버릴 것이다.

import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;

/**
 * 만약에 제공하는 사람은 singleton 으로 잘 제공했는데 쓰는 사람이 이상하게 쓴다면...?
 * singleton 을 깨트리는 방법에 대해 알아보자
 */
public class ReflectionConstructor {
	public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
		StaticInnerClassSingleton singleton = StaticInnerClassSingleton.getInstance();

		Constructor<StaticInnerClassSingleton> constructor = StaticInnerClassSingleton.class.getDeclaredConstructor();
		constructor.setAccessible(true); // private constructor 에 접근 true 하게 만들기
		StaticInnerClassSingleton instance = constructor.newInstance(); // 새로운 instance 생성

		System.out.println("singleton == reflection instance : " + (singleton == instance));
		// singleton == reflection instance : false
	}
}

이렇게 singleton을 깨트릴 수 있다…


Serialization

생성된 객체를 file로 저장했다가(Serialization) 다시 꺼내서 읽게(Deserialization) 된다면…?

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInput;
import java.io.ObjectInputStream;
import java.io.ObjectOutput;
import java.io.ObjectOutputStream;

/**
 * 이 실험을 위해서 SingletonClass 를 생성. 
 * SingletonClass : StaticInnerClass 와 똑같이 구현됐다.
 * test 를 위해서는 SingletonClass 에서 Serializable 을 implements 해야 한다.
 */
public class Serializing {
	public static void main(String[] args) throws IOException, ClassNotFoundException {
		SingletonClass singleton = SingletonClass.getInstance();
		SingletonClass instance = null;

		// singleton object 를 file 에 저장 : serialization
		try (ObjectOutput out = new ObjectOutputStream(new FileOutputStream("singleton.obj"))) {
			out.writeObject(singleton);
		}

		// file 에 저장된 객체를 read : deserialization
		// 이 때 생성자를 사용하여 instance 를 만들어주기 때문에 다른 instance 가 나오는 것이다.
		try (ObjectInput in = new ObjectInputStream(new FileInputStream("singleton.obj"))) {
			instance = (SingletonClass) in.readObject();
		}

		System.out.println("serialization object : " + singleton);
		System.out.println("deserialization object : " + instance);
		/*
		serialization object : javastudy.designpattern.singleton.brokepattern.SingletonClass@234bef66
		deserialization object : javastudy.designpattern.singleton.brokepattern.SingletonClass@7c16905e
		 */
	}
}

출력 결과가 다름을 볼 수 있다.

이렇게 2가지 방법을 사용해서 singleton을 깰 수 있음을 알게됐다. 깨는 방법이 있다면 나름의 대응방법도 존재한다!! 알아보도록 하자.


Singleton 지켜!!

Reflection → Enum class로!

reflectionEnum class의 constructor는 조작하지 못한다고 한다. 따라서 Enum class로 구현한다면 reflection에 안전한 코드가 된다. 그러나 ‘loading하는 순간 미리 만들어지기 때문’에 lazy loading이 안된다는 단점이 있다.

public enum ReflectionPreventSingleton {
	INSTANCE
}


Serialization → readResolve method declare

readResolve() method를 Serializable 를 implements 한 singleton class에서 정의해줌으로써 동일한 instance를 반환하게 만들 수 있다.

import java.io.Serializable;

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

	private static class Holder {
		private static final SerializationSolutionSingleton INSTANCE = new SerializationSolutionSingleton();
	}

	public static SerializationSolutionSingleton getInstance() {
		return SerializationSolutionSingleton.Holder.INSTANCE;
	}

	/**
	 * 해당 method 를 정의해주게 되면, deserialization 할 때에 읽어서 instance 를 새로 생성하는게 아니라
	 * getInstance() 로 주기 때문에 같은 instance 를 반환받을 수 있게 된다.
	 */
	protected Object readResolve() {
		return getInstance();
	}
}


Reference

GoF 디자인 패턴 - 인프런 백기선 강사님

Static Inner Class Singleton

singleton

singleton examples