• 디자인 패턴, 싱글톤(Singleton pattern)

    2023. 5. 18.

    by. mason.jeong

    싱글톤이라고 하는 디자인 패턴은 다양한 프로그래밍 디자인 패턴 중 비교적 간단한 형태의 디자인 패턴입니다. 이 패턴은 공유 객체에 대한 효율적인 생성과 접근을 하기 위해 전역에서 관리되는 하나의 정적 필드를 통해 객체를 공유하고 있습니다. 정적 필드를 통해 생성되기 때문에 전역에서 단 하나의 인스턴스를 가질 수 있으며 싱글톤 객체에 대한 참조를 public static 필드 또는 public static 메서드로 노출하고 있어 어디서나 자유롭게 싱글톤 객체에 접근이 가능합니다.

     

    #1 싱글톤의 사용

    객체가 전체 사이클에서 하나만 생성되도록 보장하기 위해 정적(static) 필드를 사용합니다. 모든 객체가 공유하는 필드를 정적 필드를 통해 생성할 수 있습니다. 한번만 생성되며 별도의 메모리 공간에 저장됩니다.

     

    public static 필드

    public class Singleton {
    	public static Sington instance = new Singleton();
    }

     

    public 정적 필드를 사용하면 어디에서나 접근이 가능하며 아래와 같이 접근하여 사용할 수 있습니다.

     

    Singleton 인스턴스 사용

    Singleton.instance.run();

     

    보통은 생성자를 private 으로 생성하여 다른 곳에서 인스턴스를 임의로 생성하지 못하게 하고 getInstance 등의 메서드를 호출하여 instnace를 반환받아 사용하거나 주입해 주는 형태로 사용합니다.

     

    getInstnace 메소드 구현

    public class Singleton {
    	private static instance = new Instnace();
        
        private static Singleton() {}
        
        public static getInstnace() {
        	return instance;
        }
    }

     

     

    #2 스레드 동기화

    만약 멀티 스레드를 사용하는 환경에서 위와 같은 초기화를 이용하는 경우 문제가 발생할 수 있습니다. 두 개 이상의 스레드가 getInstance를 동시에 호출한다면 스레드마다 각각의 인스턴스를 가질 수 있게 되며 서로 다른 인스턴스가 생성될 수 있습니다. 이런 경우 하나의 인스턴스를 보장할 수 없으므로 동기화 문제가 발생합니다. 이런 경우 자바에서는 synchronized 키워드와 지연된 초기화로 해결할 수 있습니다.

     

     

    #2-1 동기화 (Syncronized)

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

     

    하지만 정적 필드가 초기화 되고 난 후 싱글톤 객체를 얻으려고 할 때 불필요한 동기화가 일어나므로 성능이 저하될 수 있습니다. 성능이 문제가 된다면 더블 체크 락킹이라는 방법을 적용해 볼 수 있습니다.

     

     

    #2-2 더블 체크 락킹 (Double-checked locking)

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

     

    객체가 생성된 후에는 다른 수정 작업 없이 참조를 반환하기 떄문에 동기화 범위를 줄일 수 있다는 이점이 있다고 볼 수 있습니다만. 항상 멀티스레드 환경에서는 예상을 빗나가는 일이 종종 있습니다. 특히 자바에서는 최적화라는 작업을 하는데 이런 경우 연산의 순서가 변경되기도 합니다. 단일 스레드인 경우에는 이런 경우 문제가 될 일이 적지만 멀티스레드의 경우 다음과 같은 문제가 발생할 수 있습니다.

     

    연산의 순서

    if (instance == null) { // 1
    	synchronized (Sington.class) { // 2
        	if (instance == null) { // 3
            	instance = new Singleton(); // 4
            }
        }
    }
    return instance; // 5

    위와같은 코드의 경우 한 스레드에서 싱글톤 객체를 생성하기 위해 메모리 공간을 할당하고 싱글톤 객체의 생성자에서 내부 상태 초기화가 일어나고 있는 도중 만약 1번으로 다른 스레드에서 인스턴스가 null이 아니기 때문에 초기화 중인 객체 참조를 그대로 반환하게 되고 이런 경우 올바르게 초기화되지 않은 객체의 상태를 확인할 수 있습니다. 

     

    보통은 객체의 초기화가 완전히 끝난 뒤에야 객체에 대한 참조가 instance에 저장되는것이 라고 생각 하는데 실제로는 동기화 블록의 내부에서 컴파일러로 인해 재배열이 일어나게 되어 실제와는 다르게 동작할 수도 있습니다. 연산 순서가 바뀌더라도 사용자가 확인할 수 있는 결과는 바뀌지 않습니다. 하지만 멀티 스레드 환경에서 이러한 코드가 동시 실행될 가능성이 있기 때문에 이러한 최적화는 적절하지 않다고 볼 수 있습니다.

     

    이런 경우 자바에서는 인스턴스 필드를 volatile 로 선언하여 문제를 해결할 수 있습니다. volatile 은 컬파일러에게 이러한 재배열이 일어나지 않도록 지시하여 문제를 해결할 수 있습니다.

     

    volatile 사용 예제

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

     

    하지만 volatile 만으로는 원자성을 보장할수 없기 때문에 synchronized와 같이 사용해야 합니다. 이 말은 결국 synchronized의 성능 저하 문제가 여전히 발생할 수 있다는 이야기입니다. 그래서 지연된 초기화를 이용하여 이러한 문제를 어느 정도 해결할 수 있습니다. 요청 시 초기화 홀더 패턴이라는 방법입니다.

     

     

    #2-2 요청 시 초기화 홀더 패턴 (Initialization-on-demand holder pattern)

    public class Singleton {
    	private Singleton() { }
        
        private static final class Holder {
        	private static final Singleton instance = new Singleton();
        }
        
        public static Singleton getInstance() {
        	return Holder.instance;
        }
    }

     

    이 방법은 더 클 체크 락킹보다 간단하며 더 안전하다고 볼 수 있습니다. 단순히 보기에는 그저 초기화를 조금 더 확장한 형태라고도 볼 수 있지만. 사실 클래스가 어떻게 초기화되는지를 이해하면 알 수 있는 문제입니다. getInstance() 메서드가 호출되면 Holder클래스의 instnace에 변수에 접근할 수 있는데 Holder가 정적 클래스 이므로 클래스의 초기화 과정이 일어납니다. 그와 동시에 Holder 클래스의 초기화 단계에서 정적 필드인 Instance의 초기화가 단 한 번만 이루어집니다. 이 내용은 JLS 12.4.1에서 확인할 수 있습니다.

     

    JSL에서 위와 같은 내용을 보장하기 때문에 Singleton 클래스의 초기화는 Singleton 클래스의 정적 메서드 또는 상수 변수가 아닌 정적 필드를 사용하게 아닌 이상 이루어지지 않습니다. 따라서 아래와 같은 방식으로도 지연 초기화처럼 동작시킬 수 있습니다. 

     

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

     

     

    #2-3 열거형 (enum)

    이 방법은 조슈아 블로치가 이펙티브 자바에서 소개한 방법입니다. 지금까지 소개한 방법 중 가장 간결하고 완벽한 방법이라고 볼 수 있고 이를 통해 리플렉션 API 와함께 인스턴스를 만들려는 시도를 무력화시킬 수 있습니다. 거기에 추가적인 노력 없이 직렬화도 가능하죠. 열거형은 열거 상수를 통해 정의된 인스턴스 이외의 인스턴스는 없습니다. 열거형을 명시적으로 인스턴스화하려고 시도하면 컴파일 타임 에러가 발생합니다. 컴파일 타임 에러 외에도 enum의 final cone() 메서드를 통해 열거 상수를 복제할 수 없음을 보장하고 직렬화 메커니즘을 통한 특수 처리로 역 직렬화의 결과로 중복 인스턴스가 생성되지 않음을 보장할 수 있습니다. 또한 리플렉션을 통해 열거형을 인스턴스화하는 것을 금지합니다.

     

    사용 예

    public class Singleton extends Enum<Singleton> {
    	public static final instance = new Singleton();
    }

     

     

    #3 문제점

    싱글톤 패턴은 멀티스레드에서의 여러 가지 문제점 말고도, 객체지향적 문제, 테스트의 문제 등 여러가지 문제가 있습니다. 객체의 유일성보다는 전역적으로 접근할 수 있다는 점을 과도하게 사용할 때 문제가 발생합니다. 어디에서나 접근이 가능하기 때문에 싱글톤의 구조가 변경되면 싱글톤에 의존된 모든 클래스에서도 문제가 발생합니다. 또한 멀티 스레의 환경에서 싱글톤에 공유할 수 있는 상태가 있다면 경쟁 상태가 문제가 될 수도 있습니다.

     

    또한 정적 필드는 한번 할당되면 프로그램이 종료될 때까지 계속 메모리에 남아있는데, 각 테스트는 독립적, 또는 다른 테스트에 영향을 미치지 않아야 하는데 한 테스트에서 싱글톤 객체가 생성되면 다른 테스트에서도 공유가 되기 때문에 일반적으로 인터페이스가 아닌 클래스를 통해 구현되는 싱글톤은 mock으로 대체될 수 없으므로  단위 테스트하기가 어렵습니다. 이를 해결하기 위해서 의존성 주입을 사용할 수 있으며 보통 DI 프레임워크가 싱글톤 객체의 생성을 제어하게 됩니다.

     

     

    댓글