인스턴스가 오직 1개만 생성되야 하는 경우에 사용되는 패턴입니다. 시스템 런타임, 환경 세팅에 대한 정보 등 인스턴스가 여러개일 때 문제가 생길 수 있는 상황에서 싱글턴 패턴을 사용합니다. 인스턴스가 1개만 생성되는 특징을 가진 싱글턴 패턴을 이용하면 하나의 인스턴스를 메모리에 등록해서 여러 스레드가 동시에 해당 인스턴스를 공유하여 사용하게끔 할 수 있으므로 요청이 많은 곳에서 사용하면 효율을 높일 수 있습니다. 싱글턴을 만들때 동시성(Concurrency) 문제를 고려해서 싱글턴을 설계해야합니다. 싱글턴을 사용하는 방법과 문제점을 알아보고 해결방법을 알아보겠습니다.
1. Eager Initialization
public class SingletonEagerInit {
private static SingletonEagerInit instance = new SingletonEagerInit();
private SingletonEagerInit() {
}
public static SingletonEagerInit getInstance() {
return instance;
}
}
장점
- static으로 생성된 변수에 Singleton 객체를 선언했기 때문에 클래스 로더에 의해 클래스가 로딩될 때 Singleton 객체가 생성됩니다. 클래스가 최초 로딩될 때 객체가 생성되기 때문에 Thread-Safe합니다.
단점
- 해당 Singleton 객체의 사용 유무와 관계없이 클래스가 로딩되는 시점에 항상 Singleton 객체가 생성되고, 메모리를 잡고 있기 때문에 비효율적일 수 있습니다.
2. Lazy Initialization
public class SingletonLazyInit {
private static SingletonLazyInit instance;
private SingletonLazyInit() {
}
public static SingletonLazyInit getInstance() {
if (instance == null) {
instance = new SingletonLazyInit();
}
return instance;
}
}
장점
- 싱글톤 객체가 필요할 때 인스턴스를 생성할 수 있습니다.
- Eager initialization 방식의 단점인 메모리 누수를 방지할 수 있습니다.
단점
- Mutli Thread 환경에서 동시에 인스턴스를 호출 할 경우 인스턴스가 두번 생성될 여지가 있습니다.
- Thread-Safe 하지 않습니다.
3. Lazy Initialization With Synchronize
public class SingletonLazyInitWithSynchronize {
private static SingletonLazyInitWithSynchronize instance;
private SingletonLazyInitWithSynchronize() {
}
public static synchronized SingletonLazyInitWithSynchronize getInstance() {
if (instance == null) {
instance = new SingletonLazyInitWithSynchronize();
}
return instance;
}
}
장점
- Lazy initialization에서 thread-safe하지 않은 점을 보완합니다.
단점
- synchronized 키워드를 사용할 경우 자바 내부적으로 해당 영역이나 메서드를 lock, unlock 처리하기 때문에 내부적으로 많은 cost가 발생합니다.
- 많은 Thread들이 인스턴스를 호출하게 되면 프로그램 전반적인 성능 저하가 발생합니다.
4. Double-Checked Locking
public class SingletonDoubleChecked {
private volatile static SingletonDoubleChecked instance;
private SingletonDoubleChecked() {
}
public static SingletonDoubleChecked getInstance() {
if (instance == null) {
synchronized (SingletonDoubleChecked.class) {
if (instance == null) {
instance = new SingletonDoubleChecked();
}
}
}
return instance;
}
}
첫번째 조건문에서 instance가 null인 경우 synchronized 블럭에 접근하고 한번 더 조건문으로 instance가 null 유무를 체크합니다. 2번 모두다 instance가 null인 경우에 new를 통해 인스턴스화 시킵니다. 그 후에 instance가 null이 아니기 때문에 행여나 2개 이상의 스레드가 조건문을 동시에 통과하더라도 synchronized에 의해 하나의 스레드만 통과할 수 있게 됩니다. 이런 Double-checked locking기법을 통해 성능저하를 보완할 수 있습니다. 하지만 volatile을 사용하지 않으면 Compiler의 reorder에 의해 thread-safe 하지 않기 때문에 주의해야 합니다. 현재 사용 권고하지 않습니다. DCL 는 자바 1.4 이전 버전 JVM 에서는 volatile 키워드를 사용하더라도 동기화가 잘 되지 않을 수 있습니다.
5. Lazy Initialization Holder
public class SingletonInitByHolder {
private SingletonInitByHolder() {
}
public static SingletonInitByHolder getInstance() {
return Holder.SINGLETON_LAZY_HOLDER;
}
private static class Holder {
private static final SingletonInitByHolder SINGLETON_LAZY_HOLDER = new SingletonInitByHolder();
}
}
클래스안에 클래스(Holder)를 두어 JVM의 Class Loader 매커니즘과 Class가 로드되는 시점을 이용한 방법입니다. Lazy initialization 방식을 가져가면서 Thread간 동기화문제를 동시에 해결할 수 있습니다. 중첩클래스 Holder는 getInstance 메서드가 호출되기 전에는 참조 되지 않으며, 최초로 getInstance() 메서드가 호출 될 때 클래스 로더에 의해 싱글톤 객체를 생성하여 리턴합니다. 우리가 알아둬야 할 것은 holder 안에 선언된 instance가 static이기 때문에 클래스 로딩 시점에 한번만 호출된다는 점을 이용한 방법입니다. 또 final을 써서 다시 값이 할당되지 않도록 합니다.
장점
- Class Loader가 Class를 로딩하는 시점에 초기화하기 때문에 Thread-Safe를 보장합니다.
- Holder 안에 선언된 instance가 static이기 때문에 클래스 로딩 시점에 한번만 호출 합니다.
- final을 써서 다시 값이 할당되지 않도록 합니다.
단점
- 리플렉션을 이용한 내부 생성자 호출 가능합니다.
- 역직렬화가 수행될 때마다 새로운 객체 생성합니다.
Singleton을 공격하는 방법
#리플랙션을 이용하는 방법#
class SingletonMainTest {
@DisplayName("싱글턴 깨기 1 : 리플랙션")
@Test
void singleton_test_1() throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
SingletonDoubleChecked singleton1 = SingletonDoubleChecked.getInstance();
SingletonDoubleChecked singleton2 = SingletonDoubleChecked.getInstance();
assertEquals(singleton1, singleton2);
Constructor<SingletonDoubleChecked> constructor = SingletonDoubleChecked.class.getDeclaredConstructor();
constructor.setAccessible(true);
SingletonDoubleChecked singleton3 = constructor.newInstance();
SingletonDoubleChecked singleton4 = constructor.newInstance();
assertNotEquals(singleton1, singleton3);
assertNotEquals(singleton1, singleton4);
assertNotEquals(singleton2, singleton3);
assertNotEquals(singleton2, singleton4);
assertNotEquals(singleton3, singleton4);
}
}
위에서 사용한 DCL을 활용하여 인스턴스1과 인스턴스2를 생성하고 assertEqauls()를 통해 객체가 일치하는지 체크를 하고 리플랙션을 통해 선언된 Constructor를 가지고 와서 새로운 인스턴스3과 인스턴스4를 생성하고 일치 여부를 테스트한 경우 입니다. 인스턴스1,2는 같은 객체로 인식하지만 인스턴스3,4는 리플랙션에서 가져온 생성자를 통해 새롭게 생성된 인스턴스로 서로 다른 객체입니다.
#직렬화 역직렬화를 이용하는 방법#
public class SerializeSingletonInitByHolder implements Serializable {
private SerializeSingletonInitByHolder() {
}
public static SerializeSingletonInitByHolder getInstance() {
return Holder.SINGLETON_LAZY_HOLDER;
}
private static class Holder {
private static final SerializeSingletonInitByHolder SINGLETON_LAZY_HOLDER = new SerializeSingletonInitByHolder();
}
}
class SingletonMainTest {
@DisplayName("싱글턴 꺠기 2 : 직렬화, 역직렬화")
@Test
void singleton_test_2() throws IOException, ClassNotFoundException {
SerializeSingletonInitByHolder singleton1 = SerializeSingletonInitByHolder.getInstance();
SerializeSingletonInitByHolder singleton2 = SerializeSingletonInitByHolder.getInstance();
assertEquals(singleton1, singleton2);
SerializeSingletonInitByHolder singleton3 = null;
//직렬화
try (ObjectOutput out = new ObjectOutputStream(new FileOutputStream("singleton.obj"))) {
out.writeObject(singleton1);
}
//역직렬화
try (ObjectInput in = new ObjectInputStream(new FileInputStream("singleton.obj"))) {
singleton3 = (SerializeSingletonInitByHolder) in.readObject();
}
assertNotEquals(singleton1, singleton3);
assertNotEquals(singleton2, singleton3);
}
}
직렬화 역직렬화 테스트를 하기위해 Serializable을 Implements한 클래스로 테스트를 진행해보겠습니다. 똑같이 인스턴스1과 인스턴스2를 생성하고 직렬화와 역직렬화를 거친 후 인스턴스3에 주입한 뒤 테스트를 진행해보면 인스턴스1,2는 동일한 인스턴스임을 알 수 있지만, 인스턴스3은 다른 인스턴스로 인식합니다.
6. Enum
public enum SingletonEnum {
INSTANCE;
public static SingletonEnum getInstance() {
return INSTANCE;
}
}
모든 enum들은 프로그램 내에서 한번만 초기화되는 점을 이용해 싱글톤을 구현하는 방법입니다. 또 리플랙션과 직렬화, 역직렬화 과정에서 생기는 싱글턴의 예외를 보장해줍니다.
장점
- 리플렉션을 통해 Singleton을 깨트리는 공격에 안전합니다.
- 직렬화, 역직렬화를 통해 Singleton을 깨트리는 공격에 안전합니다.
단점
- 싱글톤 초기화 과정에 다른 의존성이 끼어들 가능성이 높습니다.
- Enum 초기화는 컴파일 타임에 결정되므로 매번 메소드 등을 호출할 때 Context 정보를 넘겨야 하는 비효율적 상황이 발생 할 수 있습다.
- 상속을 활용할 수 없다는 단점이 있습다.
- enum 클래스 로딩 시점에 안의 INSTANCE가 바로 만들어집니다.
#Enum의 Singleton 방어 테스트#
class SingletonMainTest {
@DisplayName("싱글턴 방어 1 : 리플랙션")
@Test
void singleton_test_3() {
SingletonEnum singleton1 = SingletonEnum.INSTANCE;
SingletonEnum singleton2 = SingletonEnum.INSTANCE;
assertEquals(singleton1, singleton2);
final Constructor<?>[] constructors = SingletonEnum.class.getDeclaredConstructors();
for (Constructor<?> constructor : constructors) {
constructor.setAccessible(true);
assertThrows(IllegalArgumentException.class, constructor::newInstance);
}
}
@DisplayName("싱글턴 방어 2 : 직렬화, 역직렬화")
@Test
void singleton_test_4() throws IOException, ClassNotFoundException {
SingletonEnum singleton1 = SingletonEnum.INSTANCE;
SingletonEnum singleton2 = SingletonEnum.INSTANCE;
assertEquals(singleton1, singleton2);
SingletonEnum singleton3 = null;
//직렬화
try (ObjectOutput out = new ObjectOutputStream(new FileOutputStream("singleton.obj"))) {
out.writeObject(singleton1);
}
//역직렬화
try (ObjectInput in = new ObjectInputStream(new FileInputStream("singleton.obj"))) {
singleton3 = (SingletonEnum) in.readObject();
}
assertEquals(singleton1, singleton3);
assertEquals(singleton2, singleton3);
}
}
Enum 클래스를 리플랙션으로 생성하려는 코드를 작성하고 실행하면 Cannot reflectively create enum objects 메세지와 함께 IllegalArgumentException가 발생합니다. 리플랙션으로 접근하여 생성할 수 없습니다. 또, Enum으로 생성한 결과 동일한 인스턴스임이 확인됩니다. 따라서 직렬화 역직렬화로 발생하는 문제를 방지할 수 있습니다.
참고자료
'Computer Science > Design Pattern' 카테고리의 다른 글
관찰자패턴 (Observer Pattern) (0) | 2023.01.29 |
---|---|
전략패턴 (Strategy Pattern) (0) | 2023.01.29 |
[Design Pattern] Proxy Pattern (0) | 2021.08.18 |
[Design Pattern] Template Method Pattern (0) | 2021.08.03 |
[Design Pattern] Strategy Pattern (0) | 2021.08.03 |
댓글