[헤드 퍼스트 디자인 패턴] 05. 싱글턴 패턴

akim·2023년 5월 15일
0
post-thumbnail

인스턴스가 하나뿐인 특별한 객체를 만들어 봅시다
싱글턴 패턴은 클래스 다이어그램만 보면 그 어떤 패턴보다도 간단합니다. 클래스 다이어그램에 클래스가 하나뿐이니까요! 하지만 그렇다고 해서 너무 만만하게 보지는 마세요. 아주 단순하지만 구현하기는 까다롭습니다.

1. 싱글턴 패턴

스레드 풀, 캐시, 대화상자, 사용자 설정, 레지스트리 설정을 처리하는 객체, 로그 기록용 객체, 디바이스 드라이버 등 하나만 있어도 충분히 잘 돌아가는 객체는 많다. 이런 객체를 쓸 때 인스턴스가 2개 이상이면 프로그램이 이상하게 돌아간다는가, 자원을 불필요하게 잡아먹는다든가, 결과에 일관성이 없어진다든가 하는 심각한 문제가 생길 수 있다.

싱글턴 패턴은 특정 클래스에 객체 인스턴스가 하나만 만들어지도록 해 주는 패턴이다.

싱글턴 패턴을 사용하면

  • 전역 변수를 사용할 때와 마찬가지로 객체 인스턴스를 어디서든지 액세스 할 수 있게 만들며
  • 전역 변수를 쓸 때처럼 여러 단점을 감수할 필요도 없다

전역 변수의 경우 객체를 대입하면 어플리케이션이 시작될 때 객체가 생성되는데, 어플리케이션이 끝날 때 까지 이를 한 번도 쓰지 않는다면 괜히 자원만 잡아먹는 쓸데없는 객체가 되는 셈이다.

그러나 싱글턴 패턴을 사용하면 필요할 때만 객체를 만들 수 있다.


리틀 싱글턴 알아보기

1개의 개체를 만들려면 아래와 같이 코드를 쓸 수 있다.

new MyObject();

다른 객체에서 MyObject 를 만들려면 MyObjectnew 연산자를 다시 쓰면 된다. public 으로 선언되어 있는 경우 클래스만 있으면 언제든지 인스턴스를 만들 수 있는 셈이다.

만약 public 으로 선언하지 않았다면 어떨까? 그렇다면 같은 패키지에 있는 클래스에서만 인스턴스를 만들 수 있다. 마찬가지로 2개 이상의 인스턴스도 만들 수 있다.

그렇다면 아래와 같은 경우는 어떨까?

public MyClass{
	private MyClass(){}
}

생성자가 private 으로 되어 있기 때문에 인스턴스를 만들 수 없는 클래스다.

생성자를 호출하려면 일단 그 클래스의 인스턴스가 있어야 하는데, 다른 클래스에서 이 클래스의 인스턴스를 만들 수 없다. MyClass 형식의 객체에서만 private 으로 선언된 생성자를 사용할 수 있고, 다른 어떤 클래스에서 new MyClass() 라고 쓸 수 없기 때문이다.

MyClass 의 인스턴스를 만들 수 있도록 하려면 아래와 같이 코드를 쓸 수 있다.

public MyClass{
	private MyClass() {}
    public static MyClass getInstance(){
    	return new MyClass();
    }
}

// 인스턴스 생성
MyClass.getInstance();

이제 MyClass 의 인스턴스가 하나만 만들어지도록 고전적인 싱글턴 패턴을 알아보자.


고전적인 싱글턴 패턴 구현법

public class Singleton {
	private static Singleton uniqueInstance; // Singleton 클래스의 하나뿐인 인스턴스를 저장하는 정적변수
    
    //기타 인스턴스 변수
    
   	private Singleton(){ // 생성자를 private으로 선언했으므로 Singleton에서만 클래스의 인스턴스를 만들 수 있음
    
    // getInstance() 메소드는 클래스의 인스턴스를 만들어서 리턴함
    public static Singleton getInstance(){
    	if(uniqueInstance == null){ // uniqueInstance에 하나뿐인 인스턴스가 저장됨 (정적 변수)
        	uniqueInstance = new Singleton(); // lazyinstantiation
        }
        return uniqueInstance; // null이 아니면 인스턴스가 존재하는 상황이므로 그 인스턴스를 return
        
   }
   
   // 기타 메소드
}
  • lazyinstantiation : 아직 인스턴스가 만들어지지 않았다면 private 으로 선언된 생성자를 사용해서 Singleton 객체를 만든 다음 uniqueInstance 에 그 객체를 대입한다. 이러면 인스턴스가 필요한 상황이 닥치기 전까지 아예 인스턴스를 생성하지 않게 된다. 이러한 방법을 게으른 인스턴스 생성이라고 한다.

싱글턴 패턴의 특징

  • 인스턴스가 절대 2개 이상이 되지 않도록 한다.
  • public 으로 지정된 생성자가 없으며, 생성자는 private 으로 저장되어 있다.
  • 따라서 싱글턴 객체가 필요할 때는 인스턴스를 달라고 요청해야 한다. (getInstanc() 정적 메소드를 호출)

싱글턴 패턴의 정의

싱글턴 패턴은 클래스 인스턴스를 하나만 만들고, 그 인스턴스로의 전역 접근을 제공한다.

  • 인스턴스가 필요하다면 반드시 클래스 자신을 거치도록 해야 한다.

싱글턴 패턴을 실제로 적용할 때는 클래스에서 하나뿐인 인스턴스를 관리하도록 만들고, 다른 어떤 클래스에서도 자신의 인스턴스를 추가로 만들지 못하게 해야 한다.

  • 어디서든 그 인스턴스에 접근할 수 있도록 전역 접근 지점을 제공한다.

언제든 이 인스턴스가 필요하면 클래스에 요청할 수 있게 만들어 놓고, 요청이 들어오면 그 하나뿐인 인스턴스를 건네주도록 만들어야 한다. 특히 자원을 많이 잡아먹는 인스턴스가 있다면 싱글턴이 게으른 방식으로 생성되도록 구현할 수도 있다.


싱글턴 패턴 클래스 다이어그램

  • uniqueInstance 클래스 변수에 싱글턴의 하나뿐인 인스턴스가 저장된다.
  • getInstance() 메소드는 정적 메소드, 즉 클래스 메소드다. Singleton.getInstance() 라는 코드만 사용하면 언제 어디서든 이 메소드를 호출할 수 있다.
  • 싱글턴 패턴을 사용할 때 이렇게 간단하게 클래스를 구성해야 하는 것은 아니다. 일반적인 클래스와 마찬가지로 다양한 데이터와 메소드를 사용할 수 있다.

2. 초콜릿 보일러 예시

공장에서 초콜릿을 끓이는 장치인 '초콜릿 보일러'는 초콜릿과 우유를 받아서 끓이고 초코바를 만드는 단계로 넘겨준다. 아래는 초콜릿 보일러를 제어하는 클래스를 작성한 것이다.

public class ChocolateBoiler {
	private boolean empty;
	private boolean boiled;
	private static ChocolateBoiler uniqueInstance;
  
  	// 보일러가 비어 있을 때만 돌아감
	private ChocolateBoiler() {
		empty = true;
		boiled = false;
	}
  
  	// 보일러가 비어 있을 때만 재로를 넣음
    // 원료를 가득 채우고 나면 empty와 boiled 플래그를 false로 설정함
	public void fill() {
		if (isEmpty()) {
			empty = false;
			boiled = false;
			// 보일러에 우유왕 초콜릿을 혼합한 재료를 넣음
		}
	}
 
 	// 보일러가 가득 차 있고, 다 끓여진 상태에서만 보일러에 들어있는 재료를 다음 단계로 넘김
    // 보일러를 다 비우고 나면 empty 플래그를 다시 true로 설정함
	public void drain() {
		if (!isEmpty() && isBoiled()) {
			// drain the boiled milk and chocolate
			empty = true;
		}
	}
 
 	// 보일러가 가득 차 있고 아직 끓지 않은 상태에서만 초콜릿과 우유가 혼합된 재료를 끓임
    // 재료를 다 끓으면 bolied 플래그를 true로 설정함
	public void boil() {
		if (!isEmpty() && !isBoiled()) {
			// bring the contents to a boil
			boiled = true;
		}
	}
  
	public boolean isEmpty() {
		return empty;
	}
 
	public boolean isBoiled() {
		return boiled;
	}
}

코드를 잘 보면 아직 끓지 않은 재료를 그냥 흘려 버린다거나, 보일러가 가득 차 있는 상태에서 새로운 재료를 붓는다거나, 빈 보일러에 불을 지핀다거나 하는 실수를 하지 않도록 세심한 주의를 기울였음을 알 수 있다.

하지만 2개의 ChocolateBolier 인스턴스가 따로 돌아가면 문제가 생길 수 있다.


멀티스레딩 문제 살펴보기

2개의 스레드에서 아래 코드를 실행한다고 가정해보자.

Chocolate boiler = ChocolateBoiler.getInstance();
boiler.fill();
boiler.boil();
boiler.drain();

위 그림과 같이 두개의 다른 객체가 리턴된다. 즉, 우리는 두개의 ChocolateBolier 인스턴스를 갖는 것이다.


3. 멀티스레딩 문제 해결하기

getInstance() 를 동기화하면 멀티스레딩과 관련된 문제가 간단하게 해결된다.

public class Singleton{
	private static Singleton uniqueInstance; 
    
    //기타 인스턴스 변수
    
   	private Singleton(){
    
    // getInstance() 에 synchronized 키워드만 추가하면 한 스레드가 메소드 사용을 끝내기 전까지 다른 스레드는 기다려야 함.
    // 즉, 2개의 스레드가 이 메소드를 동시에 실행하는 일은 일어나지 않음
    public static sychronized Singleton getInstance(){
    	if(uniqueInstance == null){
        	uniqueInstance = new Singleton();
        }
        return uniqueInstance;
   }
   
   // 기타 메소드
}

위 코드를 사용하면 문제가 해결되긴 하겠지만, 동기화할 때 속도 문제가 생길 수 있다.

그리고 조금 더 생각해보면 사실 동기화가 꼭 필요한 시점은 이 메소드가 시작되는 때 뿐이라는 사실을 알 수 있다.

일단 uniqueInstance 변수에 Singleton 인스턴스를 대입하면 굳이 이 메소드를 동기화된 상태로 유지할 필요가 없다. 처음을 제외하면 동기화된 메소드는 불필요한 오버헤드만 증가시킬 뿐이다.


더 효율적으로 멀티스레딩 문제 해결하기

방법 1) getInstance()의 속도가 그리 중요하지 않다면 그냥 둔다.

getInstance() 메소드가 애플리케이션에 큰 부담을 주지 않는다면 그냥 둬도 된다.

다만 메소드를 동기화하면 성능이 100배정도 저하된다는 사실을 기억하자. 만약 getInstance() 가 어플리케이션에서 병목으로 작용한다면 다른 방법을 생각해 봐야 한다.

방법 2) 인스턴스가 필요할 때는 생성하지 말고 처음부터 만든다.

애플리케이션에서 Singleton 의 인스턴스를 생성하고 계속 사용하거나 인스턴스를 실행 중에 수시로 만들고 관리하기가 성가시다면 아래와 같이 처음부터 Singleton 인스턴스를 만들면 좋다.

public class Singleton {

	//정적 초기화 부분(static initializer) Singleton의 인스턴스를 생성
    // 이러면 스레드를 써도 별문제가 없음
	private static Singleton uniqueInstance = new Singleton();
		
	private Singleton() {}
    
	public static Singleton getInstance) {
		return uniqueInstance; // 인스턴스는 이미 있으니까 리턴만 하면 됨
    }
}

이 경우 클래스가 로딩될 때 JVM에서 Singlton의 하나뿐인 인스턴스를 생성해 준다. 이 전까지는 그 어떤 스레드도 uniqueInstance 정적 변수에 접근할 수 없다.

방법 3) DCL을 써서 getInstance()에서 동기화되는 부분을 줄인다.

DCL(Double Checked Locking)을 사용하면 인스턴스가 생성되어 있는지 확인한 다음에 생성되어 있지 않았을 때만 동기화할 수 있다. 이러면 처음에만 동기화하고 나중에는 동기화 하지 않아도 된다.

public class Singleton {
	private volatile static Singleton uniqueInstance;
    
	private Singleton() {}
    
	public static Singleton getInstance) {
		if (uniqueInstance == null) { //인스턴스가 있는지 확인하고, 없으면 동기화된 블록으로 들어감
        
        	// 이러면 처음에만 동기화됨
            // 블록에서도 다시 한 번 변수가 null인지 확인한 다음 인스턴스를 생성함
			synchronized (Singleton.class) {
				if (uniqueInstance = null) { 
					uniqueInstance = new Singleton(); 
                }
			}
        }
		return uniqueInstance;
    }
}      
  • volatile 키워드를 사용하면 멀티스레딩을 쓰더라도 uniqueInstance 변수가 Singleton 인스턴스로 초기화되는 과정이 올바르게 진행된다.
  • DCL은 자바 1.4 이전 버전에서는 쓸 수 없다.

4. 싱글턴 패턴의 문제점

1. 클래스 로더 2개가 각기 다른 싱글턴의 인스턴스를 가지게 될 수 있음

클래스 로더마다 서로 다른 네임스페이스를 정의하기에 클래스 로더가 2개 이상이라면 같은 클래스를 여러 번 로딩할 수도 있다. 싱글턴을 이런 식으로 로딩하면 인스턴스가 여러 개 만들어지는 문제가 발생할 수 있다.

따라서 클래스 로더가 여러 개라면 싱글턴을 조심해서 사용해야 한다. (클래스 로더를 직접 지정하면 이 문제를 피할 수 있다.)

2. 리플렉션, 직렬화, 역직렬화 문제

리플렉션, 직렬화, 역직렬화도 싱글턴에서 문제가 될 수 있다.

3. 느슨한 결합 원칙에 위배됨

느슨한 결합 원칙에 따르면 "상호작용하는 객체 사이에서 최대한 느슨한 결합을 추구해야" 한다. 싱글턴을 사용하다 보면 이런 원칙을 위배하기 쉽다. 싱글턴을 바꾸면 연결된 모든 객체도 바꿔야 할 가능성이 높기 때문이다.

4. 한 클래스에서 2가지 일을 함

한 클래스가 한 가지만 책임진다는 원칙에서 벗어난다. 싱글턴은 자신의 인스턴스를 관리하는 일 외에도 원래 그 클래스를 사용하고자 하는 목적에 부합하는 작업을 책임져야 한다. 따라서 2가지를 책임지고 있다고 말할 수도 있다.


eunm의 사용

enum 을 사용하면 동기화 문제, 클래스 로딩 문제, 리플렉션, 직렬화와 역직렬화 문제 등 싱글턴의 여러 문제점을 해결할 수 있다.

public enum Singleton {
	UNIQUE_INSTANCE;
	// 기타 필요한 필드
}

public class SingletonClient {
	public static void main(String[] args) (
		Singleton singleton = Singleton.UNIQUE_INSTANCE;
		// 여기서 싱글턴 사용
    }
}

이렇게 사용하면 된다!

그러면 여기서 의문이 하나 생긴다.

'지금까지 내가 본.. getInstance() 메소드가 들어있는 Singleton 클래스를 만들고 동기화하는 일은 대체 뭐지..? 왜.. 본거지..?'

지금까지 설명한 내용은 싱글턴이 어떻게 작동하는지 확실히 이해하려고 싱글턴의 작동 원리를 하나하나 따라가 본 것이다. 이제 이 내용을 배웠으니 싱글턴이 필요할 때면 바로 enum을 쓰면 된다.

그리고 만약.. 기술 인터뷰 등에서 enum을 쓰지 않고 싱글턴을 구현하는 방법을 물어본다면? 천하무적이 된 것이다.. 하하ㅏ하하..



본 포스팅에 쓰인 이미지와 내용의 모든 출처는 책 '헤드 퍼스트 디자인 패턴' 에 있습니다.

profile
학교 다니는 개발자

0개의 댓글