Programming Language/Java

자바(Java)에서의 싱글톤(Singleton) 패턴에 대해 알아보자

TwinParadox 2020. 12. 13. 17:16
728x90

Singleton이 무엇인가?

Singleton은 어떤 클래스가 최초 한 번만 메모리에 할당하고(Static) 그 메모리에 대해서 객체를 만들어 사용하는 디자인 패턴이다. 생성자 호출이 반복적으로 발생한다고 하더라도, 새로운 인스턴스를 생성하는 것이 아니라 최초 생성된 인스턴스를 반환해주는 것을 말한다.

 

 

Singleton을 왜 사용하는가?

고정된 메모리 영역을 얻으며 생성된 인스턴스를 계속 사용하기 때문에 메모리 낭비를 방지할 수 있으며, 생성된 인스턴스는 전역 인스턴스이기 때문에 다른 클래스의 인스턴스들이 데이터를 공유하기가 쉽다고 한다.

DBCP(DataBase Connection Pool) 같이 공통된 객체를 여럿 생성해 사용해야할 때 많이 사용한다.

 

Singleton 사용 시 조심해야 되는 부분

Singleton에게 너무 많은 일을 하거나, 많은 데이터를 공유시키면 다른 클래스의 인스턴스 간 결합도가 높아지면서 개방-폐쇄 원칙을 위배하는 문제가 있다.

수정이 어려워지고 테스트하기가 어려워지기도 하고, Multi-Thread에서 동기화 처리를 해주지 않는 경우 인스턴스가 두 개 생성되는 등 Thread-Safe 문제가 발생할 수 있다.

 

 

Singleton의 다양한 구현 예제

Eager Initialization

public class EagerInit {
	private static EagerInit instance = new EagerInit();

	private EagerInit() {
		System.out.println("Constructor");
	}

	public static EagerInit getInstance() {
		return instance;
	}

	public void print() {
		System.out.println("EagerInit instance Hash : " + instance.hashCode());
	}
}

이러한 Singleton은 가장 흔하게 보이는 예제다. 이 코드가 정말로 인스턴스를 또 생성하지 않는지 확인하는 다음 코드를 동작시켜보면 알 수 있다.

 

public class SingletonTest {
	public static void main(String[] args) {
		EagerInit ei = EagerInit.getInstance();
		ei.print();

		EagerInit ei2 = EagerInit.getInstance();
		ei2.print();
	}
}

Eager Init 실행 결과

ei와 ei2는 Singleton으로 구현하지 않았다면 생성자가 각각 호출되어 서로 다른 인스턴스를 다루고 있을 것이다. Singleton으로 구현된 예제를 보면 hashCode가 동일하게 나타나고, 생성자가 ei에 getInstance() 메소드를 통해 인스턴스를 할당해줄 때만 호출되고 ei2에서는 호출되지 않는 것을 알 수 있다. 일단 이렇게만 짜도 Singleton을 구현한 것이긴 한데, 이 구조에는 문제가 있다.

 

사용하던 사용하지 않던 클래스 로딩 과정에서 인스턴스를 생성한다.

이게 작은 크기의 리소스여도 무시하기 어려운 문제인데, 큰 리소스들 예를 들어서 파일 시스템이나 DB 연결과 관련된 쪽에서 이러한 방법으로 접근하면, 사용하거나 말거나 생성해버리는 문제가 발생한다.

Exception Handling이 불가능하다.

 

 

Static Block

public class StaticBlock {
	private static StaticBlock instance;

	private StaticBlock() {
		System.out.println("Constructor");
	}

	static {
		try {
			instance = new StaticBlock();
		} catch (Exception ex) {
			throw new RuntimeException("Exception in creating singleton instance");
		}
	}

	public static StaticBlock getInstance() {
		return instance;
	}

	public void print() {
		System.out.println("StaticBlock instance Hash : " + instance.hashCode());
	}
}

Static Block을 이용해서 Exception Handling을 하는데, 생성 방식에는 변함이 없어서 Eager Initialization에서 가지고 있는 문제를 그대로 가지고 있다.

 

 

Lazy Initialization

public class LazyInit {
	private static LazyInit instance;

	private LazyInit() {
		System.out.println("Constructor");
	}

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

		return instance;
	}

	public void print() {
		System.out.println("LazyInit instance Hash : " + instance.hashCode());
	}
}

이렇게 클래스 로딩 단계에서 그냥 인스턴스를 생성하고 보는 문제를 해소할 방법은 없을까? 해서 나오게 된 방식으로, getInstance() 메소드를 호출하는 시점에 인스턴스가 없을 때 생성한다. 

앞선 Eager Initialization이나 Static Block 방식에 비해서는 일정 부분 인스턴스 생성 시점의 문제를 해소한 것 같지만, 치명적인 문제가 있는데 Multi-Thread에서의 동기화 문제, Thread Safe 문제가 있다. 예를 들어, 다음과 같은 상황을 고려해보자.

 

여러 개의 Thread가 인스턴스가 생성되지 않은 시점에서 동시에 getInstance() 메소드를 호출한다고 하면, 하나의 인스턴스가 아니라 여러 개의 인스턴스가 생성될 수 있다. 따라서, 여기까지 언급된 3개의 방법은 단일 Thread 환경이 보장되었을 때 안정적이다.

 

 

Thread Safe Singleton

Thread Safe를 가져가려면, getInstance에 synchronized를 걸어서 동기화를 해주는 방법이 있다. synchronized는 ciritical section을 형성해서 해당 영역에 오직 하나의 Thread만 접근할 수 있게 해주어서, 앞서 언급된 Thread Safe를 해소할 수 있는 유용한 기능이다.

 

public class SyncSingleton {
	private static SyncSingleton instance;

	private SyncSingleton() {
		System.out.println("Constructor");
	}

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

		return instance;
	}

	public void print() {
		System.out.println("SyncSingleton instance Hash : " + instance.hashCode());
	}
}

이렇게 보면, 이제 모든 문제를 해결한 것 같지만, 간과하지 못한 곳에서 문제가 발생하는데 synchronized를 걸어두는 것만으로도 비용이 커져서, 성능 저하 이슈를 발생시키는 것이다. 단순하게 인스턴스 한 번 호출하는 정도의 어플리케이션에서는 체감하지 못할 수 있지만, 여러 번 호출하면 눈에 띄는 문제를 발생시킬 수 있다.

 

이 문제를 해결하기 위해 고안된 것이 getInstance()에 lock을 거는 것이 아니라 instance가 null일 떄에 한해서만 synchronized가 동작하도록 설계한 것인데, 다음과 같이 구현한다.

public class SyncSingleton {
	private static SyncSingleton instance;

	private SyncSingleton() {
		System.out.println("Constructor");
	}

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

		return instance;
	}

	public void print() {
		System.out.println("SyncSingleton instance Hash : " + instance.hashCode());
	}
}

 

 

LazyHolder, Bill Pugh Singleton

이름이 다양한 형태로 존재하는데 이 방식이 가장 보편적이고 Thread-Safe까지 가져가는 싱글톤 구현 방법으로 알려져 있다. Inner Static Class를 사용해서 이 문제를 해결하는 것이다. 다음과 같이 구현한다.

public class Singleton {
	private Singleton() {
		System.out.println("Constructor");
	}

	private static class LazyHolder {
		private static final Singleton INSTANCE = new Singleton();
	}

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

	public void print() {
		System.out.println("Singleton instance Hash : " + LazyHolder.INSTANCE.hashCode());
	}
}

 

일단 구조가 복잡해보이긴 해도, inner static class로 넣어서 싱글톤 인스턴스를 가져가도록 하는 것이다. 앞선 방식과의 차이라면 LazyHolder는 Singleton 클래스가 로드되는 시점에 로드되는 것이 아니라, getInstance()가 호출되는 시점에 JVM에 로드되고 인스턴스를 생성한다. 이렇게 사용하면 Synchronized를 사용하지 않아서 성능 저하까지 해결이 가능하다. 왜 synchronized를 사용하지 않아도 Thread-safe를 가져갈 수 있냐면? 클래스 로딩 시점에 Lock을 사용하기 때문이다.

 

여기까지 언급한 Singleton 구현 방식은 Java Reflection을 사용해서 Singleton을 파괴할 수 있다고 하여, Enum으로 이를 극복할 수 있다고 한다. 근데 막상 Singleton으로 구현된 프로젝트들을 보면, Enum을 사용한 것보다 LazyHolder 방식이 가장 많이 보인다. 관련 내용은 Reference를 참고하면 좋을 것 같다.

 

 

[Reference]

medium.com/programming-sharing/implement-singleton-pattern-in-java-33e0a6f0aabb

www.journaldev.com/1377/java-singleton-design-pattern-best-practices-examples

 

728x90
728x90