이 게시글은 코딩으로 학습하는 GoF의 디자인 패턴 (인프런 - 백기선)
강의를 듣고 정리한 내용을 바탕으로 작성되었습니다.
싱글톤 패턴은 객체의 생성과 관련한 패턴으로 객체를 단 하나만 생성하여 전역적으로 제공하는 패턴이다.
public class Settings {
private static Settings instance;
private Settings() {}
public static Settings getInstance() {
if (instance == null) {
instance = new Settings();
}
return instance;
}
}
@Test
@DisplayName("getInstance를 2번 호출해도 같은 객체를 반환한다.")
void getInstance() {
Settings instance1 = Settings.getInstance();
Settings instance2 = Settings.getInstance();
// 통과
assertTrue(instance1 == instance2);
}
다음과 같은 코드로 간단하게 싱글톤 패턴을 구현할 수 있다.
하지만 해당 구현은 멀티 쓰레드 상황에서는 객체의 동일성이 보장되지 않을 수 있다. (Thread-safe 하지 않다)
객체 동일성이 보장되지 않는 시나리오는 다음과 같다.
- 처음 메소드를 호출한 쓰레드 A가 if문을 통과한다.
- 쓰레드 A가 new로 객체를 생성하여
instance
필드에 할당하기 전에 쓰레드 B가 메소드를 호출하여 if문 조건을 true로 통과한다.- 쓰레드 A와 B가 호출한 메소드가 각자 다른 참조값을 가진다.
public class Settings2 {
private static Settings2 instance;
private Settings2() {
}
public static Settings2 getInstance() {
if (instance == null) {
instance = new Settings2();
}
return instance;
}
}
/** 출력 결과
* singleton.Settings@48b338ca
* singleton.Settings@768f4284
* */
@Test
@DisplayName("멀티 쓰레드는 환경에서 다른 참조값을 가질수 있다.")
void getInstance2() throws InterruptedException {
Thread t1 = new TestThread();
Thread t2 = new TestThread();
t1.start();
t2.start();
// t1 , t2가 종료되기 전에 테스트를 진행하는 쓰레드가 종료되지 않게 지연함
Thread.sleep(100L);
}
class TestThread extends Thread {
@Override
public void run() {
Settings2 instance = Settings2.getInstance();
System.out.println(instance);
}
}
public class Settings3 {
private static Settings3 instance;
private Settings3() {
}
public static synchronized Settings3 getInstance() {
if (instance == null) {
instance = new Settings3();
}
return instance;
}
}
synchronized로 쓰레드를 동시에 접근하지 못하게 제한하여 싱글톤을 보장할 수 있다.
단점 : 모든 경우에 쓰레드를 block하기 때문에 성능이 좋지 못하다.
/**
* 출력 결과 -> 참조값이 동일하다.
* singleton.Settings3@5e4d06df
* singleton.Settings3@5e4d06df
* singleton.Settings3@5e4d06df
* singleton.Settings3@5e4d06df
* singleton.Settings3@5e4d06df
* */
@Test
@DisplayName("synchronized 키워드로 싱글톤을 보장한다.")
void getInstance3() throws InterruptedException {
Thread t1 = new TestThread2();
Thread t2 = new TestThread2();
Thread t3 = new TestThread2();
Thread t4 = new TestThread2();
Thread t5 = new TestThread2();
t1.start();
t2.start();
t3.start();
t4.start();
t5.start();
Thread.sleep(100L);
}
public class Settings4 {
private static Settings4 instance;
private Settings4() {}
public static Settings4 getInstance() {
if (instance == null) {
synchronized (Settings4.class) {
if (instance == null) {
instance = new Settings4();
}
}
}
return instance;
}
}
해당 구현은 instance가 null인 경우에만 쓰레드를 block 하기 때문에 성능적으로 1번보다 조금 더 보완되었다.
public class Settings5 {
private static final Settings5 INSTANCE = new Settings5();
private Settings5() {
}
public static Settings5 getInstance() {
return INSTANCE;
}
}
static 필드로 초기화하여 클래스가 로딩되는 시점에 객체를 생성하여 제공하며 싱글톤을 보장할 수 있다.
단점 : 해당 방법은 클래스 로드 시점에 객체를 생성하는 리소스가 소모된다.
public class Settings6 {
private Settings6() {
}
private static class SettingsHolder {
private static final Settings6 INSTANCE = new Settings6();
}
public static Settings6 getInstance() {
return SettingsHolder.INSTANCE;
}
}
static inner class를 사용하여 객체를 제공하면 getInstance()
가 호출되는 시점에 객체를 생성하여 (지연로딩) 제공할 수 있다.
public enum SettingEnum {
INSTANCE;
}
가장 간단하게 enum을 사용하여 싱글톤을 구현할 수 있다.
public class BreakSingletonTest {
@Test
@DisplayName("1. 리플렉션을 사용하여 객체를 생성하여 객체 동일성을 깨뜨린다.")
void break1() throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
Settings6 settingsA = Settings6.getInstance();
Constructor<Settings6> constructor = Settings6.class.getDeclaredConstructor();
constructor.setAccessible(true);
Settings6 settingsB = constructor.newInstance();
// 통과 -> 객체 참조값이 다름
assertTrue(settingsA != settingsB);
}
@Test
@DisplayName("2. 객체 직렬화 & 역직렬화를 통해 객체 동일성을 깨뜨린다.")
void break2() {
Settings7 settingsA = Settings7.getInstance();
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("obj.out"))) {
out.writeObject(settingsA);
} catch (Exception e) {
throw new RuntimeException(e);
}
Settings7 settingsB = null;
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("obj.out"))) {
settingsB = (Settings7) in.readObject();
} catch (Exception e) {
throw new RuntimeException(e);
}
// 통과 -> 객체 참조값이 다름
assertTrue(settingsA != settingsB);
}
// enum은 리플렉션으로 생성할 수 없고 IllegalArgumentException를 던진다.
@Test
@DisplayName("3. Enum을 리플렉션으로 생성하기 - 생성자 파라미터 1개")
void break3() {
Constructor<?>[] constructors = SettingEnum.class.getDeclaredConstructors();
for (Constructor<?> constructor : constructors) {
constructor.setAccessible(true);
Assertions.assertThrows(IllegalArgumentException.class, () -> {
SettingEnum instance = (SettingEnum) constructor.newInstance("INSTANCE");
});
}
}
// ENUM은 기본 생성자가 존재하지 않고 name을 인자로 가지는 생성자가 존재하기 때문에
// NoSuchMethodException를 던진다.
@Test
@DisplayName("4. Enum을 리플렉션으로 생성하기 - 생성자 파라미터 0개")
void break4(){
Assertions.assertThrows(NoSuchMethodException.class, () -> {
Constructor<SettingEnum> declaredConstructor = SettingEnum.class.getDeclaredConstructor();
});
}
// enum은 직렬화 & 역직렬화시에도 동일성을 보장한다.
@Test
@DisplayName("5. 객체 직렬화 & 역직렬화를 통해 객체 동일성을 깨뜨린다.")
void break5() {
SettingEnum settingsA = SettingEnum.INSTANCE;
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("obj.out"))) {
out.writeObject(settingsA);
} catch (Exception e) {
throw new RuntimeException(e);
}
SettingEnum settingsB = null;
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("obj.out"))) {
settingsB = (SettingEnum) in.readObject();
} catch (Exception e) {
throw new RuntimeException(e);
}
// 통과 -> 객체 참조값이 같음
assertTrue(settingsA == settingsB);
}
-> 객체 직렬화 & 역 직렬화 방법을 막으려면 readResolve
메소드를 명시적으로 오버라이드 한다. 참조
-> Reflection으로 객체를 생성하는 것을 막으려면 enum으로 싱글톤을 구현해야 한다.
enum의 바이트코드
바이트 코드를 열어보면 enum은 java.lang.Enum을 상속 받고있고 Enum은 Serializable을 구현하고 있다.
newInstance 내부에서 enum의 객체화를 막아주고 있다.