지난 포스팅에선 객체 생성을 추상 인터페이스를 통해 캡슐화하여 일련의 제품들을 공급할 수 있는 추상 팩토리 패턴에 대해 알아보았다. 이번엔 어떤 클래스의 인스턴스 개수를 프로그램에서 하나로 제한하는 싱글톤 패턴에 대해 복습한다.


정의

해당 클래스의 인스턴스가 하나만 만들어지고, 어디서든지 그 인스턴스에 접근할 수 있도록 하기 위한 패턴. 싱글톤은 말 그대로 객체를 하나밖에 생성할 수 없는 클래스를 말한다. 동기화 문제를 가진 고전적 구현법, 동기화 문제를 해결하는 방법과 속도를 보완하는 방법 등 여러 방법으로 싱글톤 패턴을 구현할 수 있다.

고전적인 싱글톤패턴

싱글톤 패턴의 고전적인 구현법은 간단하다. 클래스의 생성자를 사용할 수 없게 private으로 선언한 뒤 메소드로 인스턴스에 접근할 수 있게 한다. 인스턴스에 접근하는 메소드가 호출되었을 때, 프로그램 메모리에서 해당 인스턴스가 하나도 생성되지 않았을 경우에만 한번 생성하고 이후에는 인스턴스를 생성하지 않게하면 된다.

public class Singleton {
	private static Singleton uniqueInstance;
	private Singleton() { }
	// 인스턴스에 접근을 허용하기 위한 메소드
	public static Singleton getInstance() {
	if (uniqueInstance == null) {
        uniqueInstance = new Singleton();
	}
	return uniqueInstance;
    }
}

Singleton이라는 클래스에 자신의 인스턴스를 저장하기 위한 정적 변수를 선언한 뒤 생성자와 함께 캡슐화하여 감춘다. 캡슐화된 인스턴스는 getInstance()라는 정적 메소드를 이용하여 접근할 수 있다. 인스턴스를 반환하는 정적메소드는 해당 인스턴스가 null인지 체크한다. null일 경우 private 생성자를 호출해서 인스턴스를 만들어야한다. 인스턴스가 필요한 상황이 닥치기 전에는 아예 인스턴스를 생성하지 않기 위함이다. 이런 방법을 게으른 인스턴스 생성(Lazy Instantation) 이라고 부른다.

특징

싱글톤 패턴은 완전히 유일하다 (= 인스턴스가 절대 두 개 이상 존재하지 않는다.)

예를 들어, 레지스트리의 설정과 같은 정보를 담는 객체가 있다면 해당 객체는 하나만 있어야 한다. 레지스트리를 설정하는 객체가 여러 개 있다면 서로 다른 설정 내역들이 있다는 뜻이므로 혼란을 야기한다. 싱글톤 패턴을 사용하면 애플리케이션에 존재하는 어떤 객체에서도 똑같은 자원을 활용하도록 할 수 있다. 스레드 풀과 같은 자원 풀을 관리하는 데도 싱글톤 패턴이 자주 등장한다.

개발자가 자신도 모르는 사이에 객체 인스턴스가 여러 개 생기면서 의도하지 않은 버그가 발생하는 일을 방지할 수 있다.

싱글톤 객체가 필요할 때는 인스턴스를 달라고 요청을 해야한다.

보통 getInstance()라는 정적 메소드를 이용해 유일한 인스턴스에 접근하며 다른 어떤 클래스에서도 싱글톤 클래스의 인스턴스를 추가로 만들지 못하게 해야 한다. 싱글톤 객체는 스레드에서 공유 자원을 사용하는 것과 같은 동기화 문제가 존재한다. 싱글톤 객체가 생성되지 않은 상태에서 두 개의 스레드가 getInstance() 메소드를 거의 동시에 호출했다고 하더라도 uniqueInstance == null 을 검사하는 시점이 달라서 인스턴스가 두 개 만들어지는 상황이 벌어진다. 이를 방지하기 위해 자바에서는 동기화 키워드를 사용한다.

public class Singleton {
    private static Singleton uniqueInstance;
    private Singleton() { };
    
	// 인스턴스에 접근을 허용하기 위한 메소드
	public static synchronized Singleton getInstance() {
        if (uniqueInstance == null) {
            uniqueInstance = new Singleton();
        }
        return uniqueInstance;
	}
}

synchronized 키워드로 메소드 접근을 동기화할 수 있다. 메소드를 동기화하게 되면 다른 클라이언트들은 내 차례가 끝나기 전까지 기다려야 한다. 10번째로 호출한 클라이언트는 앞의 9명의 클라이언트의 메소드 호출이 끝날때까지 기다려야한다. 이처럼 block 상태가 발생하기 때문에 동기화는 늘 속도 관련 문제가 딸려온다.

동기화와 속도 보완

가장 간단한 방법은 클래스(정적) 변수에 아예 시작부터 인스턴스를 대입하는 방법이다.

클래스를 로드하면서 인스턴스를 생성하므로 null검사나 객체 생성을 할 필요가 없다.

private static Singleton uniqueInstance = new Singleton();

위 코드와 같이 정적 변수를 선언과 동시에 인스턴스화하면 클래스가 로딩될 때 JVM이 유일한 인스턴스를 생성해준다.

getInstance() 메소드의 속도가 중요하지 않다면 그냥 메소드를 동기화시킨 상태로 둔다.

getInstance() 메소드가 애플리케이션의 성능에 큰 부담을 주지 않는다면 그냥 놔둬도 된다. 동기화의 방법이 어렵거나 복잡하지도 않기 때문이다. (메소드를 동기화하면 성능이 약 100배 정도 저하된다고 한다.)

DCL(Double-Checking Locking)을 써서 getInstance() 메소드에서 동기화되는 부분을 줄인다.

DCL을 사용하면 일단 인스턴스가 생성되어 있는지 확인한 다음 생성 되지 않았을 때만 동기화를 할 수 있다. 이렇게 하면 처음에만 동기화를 하고 나중에는 동기화가 필요없게된다.

public class Singleton {
    private volatile static Singleton uniqueInstance;
    private Singleton() { };
    
    // 인스턴스에 접근을 허용하기 위한 메소드
    public static Singleton getInstance() {
        if (uniqueInstance == null) {
        	synchronized (Singleton.class) {
                if(uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        
    return uniqueInstance;
    }
}

처음 조건문에서 인스턴스가 있는지 확인하고 없다면 동기화(synchronized)된 블럭으로 감싼다. 이는 맨 처음 인스턴스가 없을 때만 동기화 하며 다음 두 번째부턴 비동기 상태로 만든다. 동기화 블럭 내부에선 또 다시 if문으로 null검사를 하고 null일 경우에만 인스턴스를 생성한다. DCL을 이용하면 싱글톤의 동기화 오버헤드를 극적으로 줄일 수 있지만, 클래스 로더마다 서로 다른 네임스페이스를 정의하기 때문에 싱글톤 인스턴스가 두 개 생성 될 수 있으므로 주의해야한다.

volatile 키워드

volatile 키워드를 사용하면 멀티스레딩을 쓰더라도 uniqueInstance 변수가 Singleton 인스턴스로 초기화 되는 과정이 올바르게 진행되도록 할 수 있다. volatile 키워드로 선언된 변수는 변수를 CPU 캐시가 아닌 메인 메모리를 이용한다. 메모리의 최신값에 접근하므로 문제의 소지를 없앨 가능성을 높인다.

volatile 키워드를 사용헀다고 모든 문제가 해결되진 않는다. 멀티스레드 환경에서 변수에 접근할 때, 하나의 스레드가 아직 메모리에 값을 쓰지 못했다면 나머지 스레드는 쓰기 이전의 값을 읽어온다. 두 개의 스레드가 동시에 하나의 변수를 사용하는 경우에도, 마지막에 쓰여진 값이 최종값이 되기 때문에 두 스레드간의 동기화가 깨진다.

출처: HAMA 블로그

의문사항

모든 메소드와 변수가 static으로 이루어진 클래스를 만들어도 되지 않은가?

복잡한 초기화가 필요 없는 경우에만 싱글톤 대신 static으로만 이루어진 클래스를 이용할 수 있다. 그러나 초기화가 복잡한 경우, 자바에서 정적 초기화를 처리하는 방법때문에 일이 복잡해질 수도 있다. 특히 여러 클래스가 얽혀 있는 경우, 초기화 순서와 관련된 아주 찾아내기 어려운 복잡미묘한 버그가 생길 가능성이 있다.

싱글톤 패턴은 단일 역할 원칙을 위반하는 것이 아닌가?

싱글톤은 자신의 인스턴스를 관리하는 역할과 원래 그 인스턴스를 사용하고자하는 목적에 부합하는 역할을 맡고 있다. 두 가지 역할을 책임지고 있다고 할 수 있다. 하지만 클래스 내에 자신의 인스턴스를 관리하는 기능을 포함하고 있는 클래스를 적지 않게 볼 수 있다. 이는 전체적인 디자인을 좀 더 간단하게 만들 수 있기 때문이다. (싱글톤에 들어있는 기능을 캡슐화해야 된다는 의견도 많이 있다.)

싱글톤 객체의 서브클래스를 만들어도 되는가?

싱글톤 패턴에서는 클래스의 생성자가 private이기 떄문에 서브 클래스에서 해당 클래스를 확장할 수 없게 된다. 생성자를 고친다고 하더라도 정적 변수를 바탕으로 구성되어 있기 때문에 모든 서브클래스에서 같은 인스턴스 변수를 공유하게 된다. 따라서 베이스 클래스에서 레지스트리 같은 것을 구현해놓아야한다.

싱글톤 클래스를 확장해서 큰 이점을 얻을 수 있는 상황이 아니면 바람직하지 않다. 싱글톤 패턴은 제한된 용도로 특수한 상황에서 사용하기 위한 용도이기 때문에 싱글톤을 꽤 많이 사용하고 있다면 전반적인 디자인을 다시 한 번 생각해보는 것이 좋다.

싱글톤의 여러가지 구현법

public static 필드를 이용한 싱글톤

public class Foo {
	public static Foo INSTANCE = new Foo();
	private Foo() { };
}
public class Singleton {
	Foo foo1 = Foo.INSTANCE;
	Foo foo2 = new Foo(); // The constructor Foo() is not visible 
}

public 필드를 사용할 경우, 클래스의 선언부만 보면 싱글톤인지 금방알 수 있다. private 생성자는 public static final 필드인 Foo.Instance가 초기화 될 때 한 번만 호출된다. private 생성자 하나만 존재하기 때문에 일단 Foo 클래스가 초기화되고나면 더 이상 Foo 객체를 만들 수가 없다. 이전에는 클라이언트가 이 상태를 변경할 방법은 없었지만, 리플렉션 기능의 등장으로 private 생성자를 호출할 수 있게 되었다.

public class Singleton {
	public static void main(String[] args) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException {
		Foo foo1 = Foo.INSTANCE;
		// Foo foo2 = new Foo(); // The constructor Foo() is not visible
        
		Constructor<?> con = Foo.class.getDeclaredConstructors()[0];
		con.setAccessible(true);
		Foo foo2 = (Foo) con.newInstance();
        
		System.out.println(foo1 == foo2);
	}
}
 
출력 결과
false

위처럼 AccessibleOject, setAccessible 의 도움을 받아 권한을 획득한 뒤 리플렉션 기능을 통해 private 생성자를 호출 할 수 있다. 이런 공격을 방어하기 위해서는 두 번째 객체를 생성하라는 요청을 받으면 예외를 던지도록 생성자를 고쳐야 한다.

정적 팩토리 메소드를 이용한 싱글톤

public class Foo {
	private static Foo INSTANCE = new Foo();
	private Foo() { };
    
	public static Foo getInstance() {
		return INSTANCE;
	}
}

public class Singleton {
	public static void main(String[] args) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException {
		Foo foo1 = Foo.getInstance();
		// Foo foo2 = new Foo(); // The constructor Foo() is not visible
        
		Constructor<?> con = Foo.class.getDeclaredConstructors()[0];
		con.setAccessible(true);
		Foo foo2 = (Foo) con.newInstance();
        
		System.out.println(foo1 == foo2);
	}
}
 
출력 결과
false

Foo.getInstance는 항상 같은 객체를 반환하며 클라이언트는 객체를 만들 방법이 없다. 이전에는 이 방법이 성능이 더 좋을것이라고 기대하였지만, 최선 JVM이 정적 팩토리 메소드를 거의 항상 인라인 처리하게 되면서 그런 기대는 사라지게 되었다. 정적 팩토리 메소드를 이용하여 싱글톤을 구현할 경우 두 가지의 장점을 얻을 수 있다.

1. API를 변경하지 않고도 싱글톤을 포기할 수 있다.

싱글톤 객체를 참조하는 유일한 필드가 private이므로 이를 지우고 Foo.getInstance의 구현을 변경해도 API는 영향을 받지 않는다. 해당 메소드의 주석은 변경해야할 것이다. 또 다른 예로, 싱글톤은 그대로 적용하면서 쓰레드마다 별도의 객체를 반환하도록 변경할 수도 있다.

2. 제네릭 타입을 수용하기 쉽다.

제네릭 타입을 수용하면 자료형 유추가 가능하고, 형 안정성을 제공할 수 있다.싱글톤을 포기하지않아도 되고, 제네릭 타입이 필요없을 경우도 있다. 그런 경우 이 방법은 어울리지 않는다.

enum 싱글톤 패턴

고전적인 싱글톤 패턴 구현법에 대한 문제를 살펴보자.

public class Foo implements Serializable {
	private static final long serialVersionUID = -9083057440306700412L;
    
	private String name = "foo";
	private int age = 23;
	private static Foo INSTANCE = new Foo();
	private Foo() { };
    
	public static Foo getInstance() {
		return INSTANCE;
	}
 
	public String getName() {
		return name;
	}
 
	public int getAge() {
		return age;
	}
}
 
public class SingletonSerialize {
	public static void main(String[] args) {
		Foo foo = Foo.getInstance();
		serializing(foo);
        
		Foo foo2 = deserializing();
        
		System.out.printf("Foo1(%s): (%s, %s)\n", foo, foo.getName(), foo.getAge());
		System.out.printf("Foo2(%s): (%s, %s)\n", foo2, foo.getName(), foo.getAge());
		System.out.println(foo == foo2);
		System.out.println(foo.equals(foo2));
	}
    
    public static void serializing(Foo foo) {   
		try (FileOutputStream fos = new FileOutputStream("test");
			BufferedOutputStream bos = new BufferedOutputStream(fos);
			ObjectOutputStream out = new ObjectOutputStream(bos)) {
			out.writeObject(foo);
		} catch(Exception e) {
			e.printStackTrace();
		}
	}
    
	public static Foo deserializing() {
		try (FileInputStream fis = new FileInputStream("test");
             BufferedInputStream bis = new BufferedInputStream(fis);
			ObjectInputStream in = new ObjectInputStream(bis)) {
        
			Foo foo = (Foo) in.readObject();
			return foo;
		} catch(Exception e) {
			e.printStackTrace();
		}
        
		return null;
	}
}
 
출력 결과
Foo1(singleton.Foo@55f96302): (foo, 23)
Foo2(singleton.Foo@568db2f2): (foo, 23)
false
false

싱글톤 클래스를 직렬화 가능 클래스로 만들기 위해서는 Serializable 인터페이스를 구현해야한다. 그러나 막상 해당 클래스를 사용하면 직렬화된 객체가 역직렬화될 때마다 새로운 객체가 생기게 된다. 위의 코드에서, 역직렬화된 객체와 직렬화대상이 되는 객체를 비교하면 false가 출력된다. 이는 객체가 하나만 존재해야 한다는 싱글톤의 특성을 깨뜨리게 된다. 이를 방지하기 위해서는 모든 필드를 transient로 선언하고 readResolve 메소드를 추가해야 한다.

enum을 이용하면 직렬화 문제를 간단하게 해결할 수 있다.

public enum FooEnum {
	INSTANCE("foo", 23);
    
	private String name;
	private int age;
    
	private FooEnum(String name, int age) {
		this.name = name;
		this.age = age;
    }
    
	public static FooEnum getInstance() {
        return INSTANCE;
	}
 
	public String getName() {
		return name;
	}
 
	public int getAge() {
		return age;
	}
}
 
public class SingletonSerialize {
	public static void main(String[] args) {
		FooEnum foo = FooEnum.getInstance();
		serializing(foo);
        
		FooEnum foo2 = deserializing();
        
		System.out.printf("Foo1(%s): (%s, %s)\n", foo, foo.getName(), foo.getAge());
		System.out.printf("Foo2(%s): (%s, %s)\n", foo2, foo.getName(), foo.getAge());
		System.out.println(foo == foo2);
		System.out.println(foo.equals(foo2));
	}
    
	public static void serializing(FooEnum foo) {    
		try (FileOutputStream fos = new FileOutputStream("test");
			BufferedOutputStream bos = new BufferedOutputStream(fos);
			ObjectOutputStream out = new ObjectOutputStream(bos)) {
			out.writeObject(foo);
		} catch(Exception e) {
			e.printStackTrace();
        }
	}
    
	public static FooEnum deserializing() {
		try (FileInputStream fis = new FileInputStream("test");
			BufferedInputStream bis = new BufferedInputStream(fis);
			ObjectInputStream in = new ObjectInputStream(bis)) {
        
			FooEnum foo = (FooEnum) in.readObject();
			return foo;
        } catch(Exception e) {
			e.printStackTrace();
		}
        
		return null;
	}
}
 
출력 결과
Foo1(INSTANCE): (foo, 23)
Foo2(INSTANCE): (foo, 23)
true
true

이 접근법은 기능적으로는 public 필드를 사용하는 구현법과 같다. 다만 좀 더 간결하며, 결과를 보면 알 수 있는 것처럼 **직렬화가 자동으로 처리된다는 것 **때문에 더 낫다. 직렬화가 아무리 복잡하게 이루어져도 여러 객체가 생겨 규칙이 어긋날 일도 없으며, 리플렉션을 통한 공격에도 안전하다. 또한 enum은 인스턴스가 여러 개 생기지 않도록 확실하게 보장해준다. 유일한 원소인 INSTANCE가 생성될 떄, 멀티 쓰레드 환경에서 안전하다.

하지만 enum에도 한계는 있다. 싱글톤 초기화 과정에 다른 의존성이 끼어들 가능성이 높기 떄문이다. 예를 들어 enum의 초기화는 컴파일 타임에 결정되므로 매번 메스드 등을 호출할 때 Context 정보를 넘겨야 하는 비효율적 상황이 발생할 수 있다. enum은 효율적이지만 상황에 따라 사용이 어려울 수 있다.

LazyHolder 싱글톤

public class FooLazyHolder implements Serializable {
	private static final long serialVersionUID = -426757407979005682L;
	private String name = "foo";
	private int age = 23;
	private FooLazyHolder() { };
    
	public static FooLazyHolder getInstance() {
		return LazyHolder.INSTANCE;
	}
    
    private static class LazyHolder {
		private static final FooLazyHolder INSTANCE = new FooLazyHolder();
	}
 
	public String getName() {
		return name;
	}
    
	public int getAge() {
		return age;
	}
}

멀티쓰레드 환경에서 싱글톤 패턴을 적용할 때, enum의 장점인 자동 직렬화가 필요하지 않을 경우 사용할 수 있는 다른 대안으로 LazyHolder 구현법이 있다. LazyHolder 구현법은 private static inner class를 이용하여 초기화를 미루는 방법이다. 위 코드에서 FooLazyHolder의 정적 내부 클래스인 LazyHolder는 FooLazyHolder 클래스가 로딩할 떄 초기화되지 않는다. LazyHolder는 FooLazyHolder.getInstace() 메소드가 처음 호출되면서, FooLazyHolder.INSTANCE가 처음 참조되는 순간에 클래스가 로딩되며 초기화가 진행된다. 클래스를 로딩하고 초기화하는 시점은 쓰레드 안정성을 보장하기 때문에 동기화 기법을 사용하지 않아도 안정성과 성능이 보장된다.

성능과 안정성 측면에선 높은 이점을 갖지만, 아쉽게도 이 방법 또한 문제가 존재한다. 첫 번째는 고전적인 구현에서 등장한 리플렉션을 이용한 내부 생성자 호출 공격이며 두 번째는 역직렬화가 수행될 때마다 새로운 객체가 생성된다는 것이다.

싱글톤 패턴 예제