[JAVA 디자인패턴] Singleton 패턴의 모든 것 (멀티스레드 고려)

Singleton 패턴이란

Singleton 패턴은 인스턴스를 불필요하게 생성하지 않고 오직 JVM내에서 한 개의 인스턴스만 생성하여 재사용을 위해 사용되는 디자인패턴이다. 굉장히 널리 알려져 있는 디자인패턴이고, 나역시 디자인패턴의 존재를 Sigleton패턴을 통해서 알게 되었다. 습관 반복적으로 사용하다보니 별로 생각하지 못했는데 이 Singleton패턴은 멀티스레드 환경에서 문제가 생길 가능성이 있기 때문에 Thread-safe한 Singleton을 작성하려면 약간의 학습이 필요하다. 


고전적인 방식의 Singleton 패턴

public class Singleton {
	private static Singleton instance;
	
	private Singleton(){}
	
	public static Singleton getInstance() {
		if(instance == null) { // 1번 : 쓰레드가 동시 접근시 문제
			instance = new Singleton(); // 2번 : 쓰레드가 동시 접근시 인스턴스 여러번 생성
		}
		return instance;
	}
}

가장널리 사용되는 방식이다. private static으로 자기자신의 클래스를 인스턴스로 갖고 있고 getInstance() 대상 instance를 초기화 후 리턴하는 패턴이다. 우선 가볍게 보면 아무런 문제가 없어보인다. (나도 여태까지 이렇게 Singleton패턴을 구현해 왔으니까...) 그런데 멀티 스레드 환경이라면 어떤 문제가 발생할까?


1번 으로 표시된 if(instance == null)지점이 Thread A까지 진행된 뒤 제어권이 Thread B로 넘어간 경우 Thread B역시 if(instance == null)가 수행되어 2번 이 수행되어 instance = new Singleton() 생성된다. 이 때 다시 Thread A로 제어권이 넘어간다면 Thread A에서 다시한번 instance = new Singleton() 가 수행되어 결국 인스턴스는 2개가 생성되는 경우가 발생하게 된다.


Tread A : if(instance==null) 수행 결과 true

Tread B : if(instance==null) 수행 결과 true

Tread A : instance = new Singleton() 수행으로 인스턴스1 생성

Tread B : instance = new Singleton() 수행으로 인스턴스2 생성


synchronized 를 이용한 Singleton 패턴 

public class Singleton {
	private static Singleton instance;
	
	private Singleton(){}
	
	public static synchronized Singleton getInstance() {
		if(instance == null) { // 1번
			instance = new Singleton(); // 2번
		}
		return instance;
	}
}

쓰레드 동기화 문제의 가장 쉬운 해결방법은 synchronized키워드 이다. 단일 쓰레드가 대상 메소드를 호출시작~종료까지 다른 쓰레드가 접근하지 못하도록 lock 을 하기 때문에 위와 같이 getInstance()메소드를 synchronized로 처리하면 멀티 쓰레드에서 동시 접근으로 인한 인스턴스 중복생성 문제는 해결되게 된다.


하지만, synchronized getinstance()의 경우 인스턴스를 리턴 받을 때마다 Thread동기화 때문에 불필요하게 lock이 걸리게 되어 비용 낭비가 크다. 실제로 고전적인 방식에서 인스턴스가 2개 이상 생성될 확률은 매우 적다. 또한 최초 instance초기화 문제 때문에 synchronized를 추가하였는데, 초기화가 완료된 시점 이후라면 synchronized는 불필요하게 lock을 잡을 뿐 별다른 역할을 하지 못한다.


DCL(Double-Checked-Locking) Singleton 패턴

public class Singleton {

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

위에 제시 된 synchronized 를 이용한 singleton 패턴의 문제를 한마디로 요약하면, 인스턴스 할당시점만 synchronized 처리되면 될 문제를 getInstance() 전체를 synchronized처리하여 성능문제를 야기한다는 점이다. 그래서 고안된 방법이DCL(Double-Checked-Locking) singleton패턴이다.


DCL singleton패턴은 getInstance() 내부에서 instance를 생성하는 경우만 부분적으로 synchronized 처리를 하여 생성과 획득을 분리한 획기적인 방법이다. 즉 인스턴스가 생성되어 있는지 확인해보고 인스턴스가 없는 경우 lock을 잡고 instance를 생성하는 방법이다.


그런데 여기에도 문제가 있다. 소스코드 논리적으로는 문제가 없지만 컴파일러에 따라서 재배치(reordering)문제를 야기한다.  위에 소스가 컴파일 되는 경우 인스턴스 생성은 아래와 같은 과정을 거치게 된다.


public static Singleton getInstance() {

if(instance == null) { // Thread B 수행

synchronized (Singleton.class) {

if(instance == null) {

// instance = new Singleton(); 아래와 같이 변환 됨

some_space = allocate space for Singleton object;

instance = some_space; // Thread A가 수행

create a real object in some_space; // 실제 오브젝트 할당

}

}

}

return instance;

}

}

멀티스레드 환경일 경우 각 스레드마다 동일 메모리를 공유하는 것이 아닌 별도 메모리 공간(CPU캐시)에서 변수를 읽어온다. 이런 경우 각 스레드마다 동일한 변수의 값을 다르게 기억할 수 있다. 만약 Thread A가 인스턴스 생성을 위해서 instance = some_space;를 수행하는 순간 Thread B가 Singleton.getInstance()를 호출하게 되면 아직 실제로 인스턴스가 생성되지 않았지만, Thread B는 instance == null 의 결과가 false로 리턴되어 문제를 야기하게 된다.


volatile를 이용한 개선된 DCL(Double-Checked-Locking) Singleton 패턴 (jdk 1.5이상에서 사용)

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키워드를 이요하는 경우 instance는 CPU캐시에서 변수를 참조하지 않고 메인 메모리에서 변수를 참조한다. 그래서 위에서 초기에 제시된 DCL singleton패턴에서 reorder문제가 발생하지 않는다. 현재까지는 안정적이고 문제가 없는 방법으로 인정되고 있다. DCL singleton패턴을 사용한다면 반드시 volatile 접근제한자를 추가하여 주도록 하자.


static 초기화를 이용한 Singleton 패턴

public class Singleton {

	private static Singleton instance = new Singleton(); // static 초기화시 바로 할당
	
	private Singleton(){}
	
	public static Singleton getInstance() {
		return instance;
	}
	
}
public class Singleton {

	private static Singleton instance;
	
	static {
		instance = new Singleton();
	}
	
	private Singleton(){}
	
	public static synchronized Singleton getInstance() {
		return instance;
	}
	
}

이 방법은 일단 위에서 언급되어진 멀티 쓰레드 환경에서 야기되는 모든 문제를 해결한다. Thread-sfae하며 소스도 간결하고 성능역시 좋다. Thread가 getinstance()를 호출하는 시점이 아닌, Class가 로딩되는 시점. 즉 Static영역의 데이터 로딩시점에 private static Singleton instance = new Singleton(); 를 호출하여 하나의 인스턴스만 생성되는 것을 보장한다.


그런데 여기에도 문제가 있다. 실제로 사용할지 안할지 모르는 인스턴스를 미리 만들어 놓는것이 과연 옳은가 하는 문제이다. JVM이 구동환경에 충분한 메모리가 있다면 나쁘지 않은 방법이라고 생각하지만, 프로그램이 인스턴스를 필요한 시점이 아아니라 사전에 생성하는 것은 메모리의 낭비라는 의견이 있다. (개인적으로는 나쁘지 않다고 생각하지만...)


LazyHolder Singleton 패턴

public class Singleton {
	private Singleton(){}

	public static Singleton getInstance() {
		return LazyHolder.INSTANCE;
	}

	private static class LazyHolder {
		private static final Singleton INSTANCE = new Singleton();
	}
}
가장 완벽하다고 평가받는 방법이다. JAVA 버젼역시 무관하고 성능도 뛰어나다. 이 방법은 static영역에 초기화를 하지만 객체가 필요한시점까지 초기화를 미루는 방식이다. LazyHolder 클래스의 변수가 없기 때문에 Singleton 클래스 로딩 시 LazyHolder 클래스를 초기화하지 않는다. Singleton 클래스의 getInstance() 메서드에서 LazyHolder.INSTANCE를 참조하는 순간 Class가 로딩되며 초기화가 진행된다. Class를 로딩하고 초기화하는 시점은 thread-safe를 보장하기 때문에 volatile이나 synchronized 같은 키워드가 없어도 thread-safe 하면서 성능도 보장하는 아주 훌륭한 방법이다.

참고 : 


이 글을 공유하기

댓글(1)

  • 2020.12.04 14:36 신고

    마지막 소스코드에서 LazyHolder 가 변수가 없어서 초기화가 getInstance 메서드가 실행될때 된다는 부분이 좀 이해가 안갑니다. 싱글톤 클래스가 로딩되면서 그안에 있는 static 클래스인 LazyHolder의 static 변수들도 바로 초기화 되는거 아닌가요 ?

Email by JB FACTORY