이펙티브 자바 스터디 - 1주차
스터디 깃허브 : https://github.com/SeolYoungKim/effective_java_study
- 7명이서 일주일마다 1인 1아이템 정리 및 발표
소스코드 깃허브 : https://github.com/SeolYoungKim/effective-java-example
이펙티브 자바 스터디 1주차
개인 주제 | 아이템 3. private 생성자나 열거 타입으로 싱글턴임을 보증하라
싱글톤이란?
- 인스턴스를 오직 하나만 생성할 수 있는 클래스
- 무상태 객체 or 설계상 유일해야 하는 시스템 컴포넌트
문제점
클래스를 싱글톤으로 만들면, 이를 사용하는 클라이언트를 테스트하기가 어려워질 수 있다고 합니다. 타입을 인터페이스로 정의하고, 그 인터페이스를 구현해서 만든 싱글톤이 아닐 경우, 가짜(mock) 객체 구현으로 대체할 수 없기 때문이라고 합니다.
대체 이게 대체 무슨 말일까요????????
- 싱글톤 클래스 자체는 가짜 객체(Mock)를 만들 수 없다.
- 인터페이스를 구현한 싱글톤 클래스는 가짜 객체(Mock)를 만들 수 있다.
가짜 객체를 만들 수 없는 경우, 클라이언트 코드에 대한 단위 테스트 (독립적인 테스트)를 할 수 없다는 제약 사항이 생기게 됩니다.
또한, 싱글톤 객체를 각 테스트마다 매번 생성하는 것은 상당히 비효율적이고, operation cost가 많이 들 수도 있다고 합니다. 사실 Mock 객체도 생성해서 사용할텐데, 이러한 연산 비용의 차이가 발생한다는 내용이 잘 이해가 가지 않았습니다.
- 싱글톤 객체를 생성할 때는 "필요한 모든 필드가 들어가있는 상태인 객체"를 생성하고, Mock객체를 생성할 때는 싱글톤 객체보다 필드가 적어서 상대적으로 비용이 적게 든다. 라고 추측하고 있습니다
Mocking이 안되는 경우
// 싱글톤 판다 티모 클래스입니다.
public class PandaTeemo {
public static PandaTeemo PANDA_TEEMO = new PandaTeemo();
private String pandaMoja;
private String daeNaMoo;
private String samgakMushroom;
private PandaTeemo() {
}
public void poisonShot() {
System.out.println("판다 티모가 독침을 쏩니다.");
}
public void sheared() {
System.out.println("판다 티모가 찢겼습니다.");
}
}
// Top 객체는 판다 티모를 직접적으로 참조하고 있습니다.
public class Top {
private final PandaTeemo pandaTeemo;
public Top(PandaTeemo pandaTeemo) {
this.pandaTeemo = pandaTeemo;
}
public boolean init;
public boolean teemoIsDead;
public void fight() {
init = true;
pandaTeemo.poisonShot();
pandaTeemo.sheared();
teemoIsDead = true;
}
}
// 테스트를 할 때, Mock 객체를 만들 수 없습니다.
// 판다 티모 싱글톤을 그대로 만들기 때문에,
// 껍데기만 만드는 Mock 객체를 사용할 때 보다 연산 비용이 비교적 많이 듭니다.
@Test
void fail() {
Top top = new Top(PandaTeemo.PANDA_TEEMO); // 아무런 기능이 없는 Mock 객체를 만들어서 사용할 수 없습니다. 싱글톤 객체를 생성하여 사용해야 합니다.
top.fight();
// 심지어 해당 테스트는 독립적인 테스트가 아닙니다.
// Top 객체의 Test만을 원했지만, 싱글톤 객체인 판다 티모 객체가 관여하기 때문입니다.
assertThat(top.init).isTrue();
assertThat(top.teemoIsDead).isTrue();
}
Interface를 구현한 싱글톤 객체를 만듦으로써 Mocking이 가능해지는 경우
// 인터페이스 입니다.
public interface Teemo {
void poisonShot();
void sheared();
}
// 인터페이스 구현체이며, 싱글톤 오메가 티모 클래스입니다.
public class OmegaTeemo implements Teemo{
public static OmegaTeemo OMEGA_TEEMO = new OmegaTeemo();
private String omegaMoja;
private String gun;
private String bombMushroom;
private OmegaTeemo() {
}
@Override
public void poisonShot() {
System.out.println("오메가 티모가 독침을 쏩니다.");
}
@Override
public void sheared() {
System.out.println("오메가 티모가 찢겼습니다.");
}
}
// Top2 객체는 오메가 티모 구현체가 아닌, 티모 인터페이스를 참조하고 있습니다.
public class Top2 {
private final Teemo teemo;
public Top2(Teemo teemo) {
this.teemo = teemo;
}
public boolean init;
public boolean teemoIsDead;
public void fight() {
init = true;
teemo.poisonShot();
teemo.sheared();
teemoIsDead = true;
}
}
// Test 디렉토리에 Teemo 인터페이스를 구현한 MockTeemo 객체를 만듭니다.
public class MockTeemo implements Teemo {
@Override
public void poisonShot() {
System.out.println("오메가 티모가 독침을 쏩니다.");
}
@Override
public void sheared() {
System.out.println("오메가 티모가 찢겼습니다.");
}
}
// 오메가 티모가 가진 필드가 이용되지도 않고, 생성 되지도 않습니다.
@Test
void success() {
Top2 top2 = new Top2(new MockTeemo()); // 싱글톤 오메가 티모가 생성되지 않습니다. 그저 MockTeemo입니다.
top2.fight();
assertThat(top2.init).isTrue();
assertThat(top2.teemoIsDead).isTrue();
}
싱글톤을 만드는 방법 1 : public static final 필드 + private 생성자
public class Kim {
public static final Kim SINGLETON_KIM = new Kim();
private Kim() {
}
}
@DisplayName("싱글톤이 유지된다.")
@Test
void singleton() {
Kim singletonKim1 = Kim.SINGLETON_KIM;
Kim singletonKim2 = Kim.SINGLETON_KIM;
System.out.println("singletonKim1 = " + singletonKim1);
System.out.println("singletonKim2 = " + singletonKim2);
assertThat(singletonKim1).isEqualTo(singletonKim2);
assertThat(singletonKim1 == singletonKim2).isTrue();
}
- 생성자는 private으로 감춰두고, 유일한 멤버에 접근할 수 있도록 public static 멤버를 마련합니다.
- 위와 같이 작성하면, SINGLETON_KIM을 초기화 할 때 딱 한번만 호출하게 됩니다.
- 생성자가 private이며, 다른 생성자가 없기 때문에, 해당 인스턴스가 전체 시스템에서 하나뿐임이 보장됩니다.
- 하지만, 위 방법은 Reflection API에서 AccessibleObject.setAccessible을 사용해 private 생성자를 호출할 경우에는 생성을 막을 수 없습니다.
@DisplayName("리플렉션으로 접근할 경우에는 접근하여 생성할 수 있다.")
@Test
void reflection() throws Exception {
Kim singletonKim = Kim.SINGLETON_KIM;
Arrays.stream(Kim.class.getDeclaredConstructors())
.forEach(constructor -> {
constructor.setAccessible(true);
try {
Kim reflectedKim = (Kim) constructor.newInstance();
System.out.println("reflectedKim = " + reflectedKim);
} catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e);
}
});
System.out.println("singletonKim = " + singletonKim);
}
위와 같이, 리플렉션 API를 이용할 경우 싱글톤을 깰 수 있습니다. 이를 방지하기 위해서는, 두 번째 객체가 생성되려 할 때 예외를 던지도록 아래와 같이 구성하면 됩니다.
public class Kim {
public static final Kim SINGLETON_KIM = new Kim();
private Kim() {
if (SINGLETON_KIM != null) {
throw new UnsupportedOperationException("이미 생성되어 있는 객체입니다.");
}
}
}
@DisplayName("리플렉션으로 접근할 경우에는 접근하여 생성할 수 있다.")
@Test
void reflection() throws Exception {
Kim singletonKim = Kim.SINGLETON_KIM;
Arrays.stream(Kim.class.getDeclaredConstructors())
.forEach(constructor -> {
constructor.setAccessible(true);
try {
Kim reflectedKim = (Kim) constructor.newInstance();
System.out.println("reflectedKim = " + reflectedKim);
} catch (InstantiationException | IllegalAccessException | UnsupportedOperationException | InvocationTargetException e) {
System.out.println("객체가 생성되지 않습니다!!! 해당 객체는 싱글톤으로 유지되어야 합니다.");
}
});
System.out.println("singletonKim = " + singletonKim);
}
방법 1의 장점
- 해당 클래스가 싱글턴임이 API에 명확하게 드러납니다.
- public static 필드에 final을 붙여 재할당을 막았기 때문에, 다른 객체를 참조할 수 없습니다.
- 간결합니다.
싱글톤을 만드는 방법 2 : 정적 팩터리 메서드 + private 생성자
public class Lee {
private static final Lee SINGLETON_LEE = new Lee();
private Lee() {
}
public static Lee getInstance() {
return SINGLETON_LEE;
}
}
@DisplayName("싱글톤이 유지된다")
@Test
void singleton() {
Lee singletonLee1 = Lee.getInstance();
Lee singletonLee2 = Lee.getInstance();
assertThat(singletonLee1 == singletonLee2).isTrue();
assertThat(singletonLee1).isEqualTo(singletonLee2);
}
- getInstance는 항상 같은 객체의 참조를 반환합니다. -> 싱글톤을 보장합니다.
- 단, Reflection API를 통한 접근은 가능하기 때문에, 방법1과 같이 Reflection API에서의 두번째 생성도 방지하기 위해서는 예외 처리를 해주어야 합니다.
방법 2의 장점 (아래의 장점이 필요하지 않을 경우, 방법 1이 더 좋습니다.)
- API를 바꾸지 않고도 싱글톤이 아니게 변경할 수 있습니다.
- 유일한 인스턴스를 반환하는 정적 팩토리 메서드가 호출하는 스레드 마다 다른 인스턴스를 넘겨주게 설정할 수 있습니다.
- 즉, 위 코드를 예로 들면 SINGLETON_LEE -> new Lee()로 바꾼다 해도 클라이언트 코드에 영향을 주지 않습니다.
- 정적 팩토리를 제네릭 싱글톤 팩토리로 만들 수 있습니다.
- 아래와 같이 만들 경우, Set에 다양한 타입의 값들을 넣을 수 있게 됩니다.
- 동일한 인스턴스를 원하는 타입으로 변환해줄 수 있다는 장점이 있습니다. (사용하고 싶을 경우)
// 정적 팩토리 예시
public class GenericSingletonFactory {
public static final Set<Object> GENERIC_SET = new HashSet<>();
private GenericSingletonFactory() {
}
@SuppressWarnings("unchecked")
public static <T> Set<T> getSet() {
return (Set<T>) GENERIC_SET;
}
}
@Test
void genericTest() {
Set<String> set1 = GenericSingletonFactory.getSet();
Set<Integer> set2 = GenericSingletonFactory.getSet();
Set<Kim> set3 = GenericSingletonFactory.getSet();
Set<Lee> set4 = GenericSingletonFactory.getSet();
set1.add("이게 된다고?");
set2.add(123456);
set3.add(Kim.SINGLETON_KIM);
set4.add(Lee.getInstance());
Set<Object> genericSet = GenericSingletonFactory.GENERIC_SET;
System.out.println(genericSet);
assertThat(genericSet.size()).isEqualTo(4);
// 그런데, 아래와 같이 쓰는 것도 경고메세지가 뜰 뿐, 문제는 없다.
// 그럼 제네릭 싱글톤 팩토리 메서드를 쓰는 이유는..? 알아봐야할듯..
HashSet hashSet = new HashSet();
hashSet.add("이게 된다고?");
hashSet.add(123456);
hashSet.add(Kim.SINGLETON_KIM);
hashSet.add(Lee.getInstance());
System.out.println(hashSet);
}
- 정적 팩토리의 메서드 참조를 공급자(supplier)로 사용할 수 있습니다.
- Supplier가 필요한 곳에 사용할 수 있습니다.
@DisplayName("공급자로 사용하기")
@Test
void supplier() {
Supplier<Lee> supplier = Lee::getInstance; // 공급자로 사용 가능
}
방법 1, 방법 2의 직렬화
두 방식으로 만든 싱글톤 클래스를 직렬화 하려면 Serializable을 구현하는 것만으로는 싱글톤을 보증하기 부족한데, 이는 직렬화된 인스턴스를 역직렬화 할 때 마다 새로운 인스턴스가 만들어지기 때문입니다.
- 직렬화된 인스턴스를 역직렬화할 땐 "Reflection API"를 이용하기 때문입니다.
- 그래서, 역직렬화 할 때 Reflection을 통해 인스턴스가 생성되어버립니다.
책에 기술된 해결 방법은 다음과 같습니다.
- 모든 인스턴스를 transient 선언 해줘야 합니다.
- 이는 반드시 해주지 않아도 되는것 같습니다. 이유를 찾아보고 있어요.
- readResolve 메서드를 제공해줘야 합니다.
- readResolve 메서드를 구현해둘 경우, 역직렬화에 사용되는 Reflection API가 해당 메서드를 인식하고 실행합니다.
- 때문에, readResolve 메서드가 싱글톤 객체를 반환하도록 구성하면 싱글톤을 보장할 수 있습니다.
예제 코드입니다.
// 싱글톤이 유지되지 않는 경우
public class SerializeSingletonFail implements Serializable {
private static final SerializeSingletonFail SINGLETON_FAIL = new SerializeSingletonFail();
private final String str = "fail";
private SerializeSingletonFail() {
}
public static SerializeSingletonFail getInstance() {
return SINGLETON_FAIL;
}
}
// 싱글톤이 유지되는 경우
public class SerializeSingletonOk implements Serializable {
private static final SerializeSingletonOk SINGLETON_OK = new SerializeSingletonOk();
private final String str = "ok"; // 선언 안해줘도 문제없던데.. 왜지?
private SerializeSingletonOk() {
}
public static SerializeSingletonOk getInstance() {
return SINGLETON_OK;
}
// 직렬화된 값을 역직렬화 할 때, Object를 새로 만드는 대신, 해당 메서드를 Reflection API를 이용하여 사용한다.
private Object readResolve() {
return SINGLETON_OK;
}
}
이를 확인한 테스트 코드입니다.
public class SerializeTest {
@DisplayName("싱글톤이 유지되지 않는다")
@Test
void fail() {
SerializeSingletonFail fail = SerializeSingletonFail.getInstance();
byte[] serializedData = serialize(fail);
SerializeSingletonFail result = (SerializeSingletonFail) deserialize(serializedData);
System.out.println(fail == result);
System.out.println(fail.equals(result));
assertThat(fail == result).isFalse();
assertThat(fail).isNotEqualTo(result);
}
@DisplayName("싱글톤이 유지된다.")
@Test
void success() {
SerializeSingletonOk success = SerializeSingletonOk.getInstance();
byte[] serializedData = serialize(success);
SerializeSingletonOk result = (SerializeSingletonOk) deserialize(serializedData);
System.out.println(success == result);
System.out.println(success.equals(result));
assertThat(success == result).isTrue();
assertThat(success).isEqualTo(result);
}
private byte[] serialize(Object instance) {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try {
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(instance);
} catch (IOException e) {
throw new RuntimeException(e);
}
return bos.toByteArray();
}
private Object deserialize(byte[] serializedData) {
ByteArrayInputStream bis = new ByteArrayInputStream(serializedData);
try {
ObjectInputStream ois = new ObjectInputStream(bis);
return ois.readObject();
} catch (IOException | ClassNotFoundException e) {
throw new RuntimeException(e);
}
}
}
싱글톤을 만드는 방법 3 : 원소가 하나인 Enum을 선언하라
public enum Park {
INSTANCE;
}
@DisplayName("Enum은 싱글톤이다.")
@Test
void enumTest() {
Park park1 = Park.INSTANCE;
Park park2 = Park.INSTANCE;
Park park3 = Park.INSTANCE;
System.out.println("park1 = " + park1);
System.out.println("park2 = " + park2);
System.out.println("park3 = " + park3);
Assertions.assertThat(park1).isEqualTo(park2).isEqualTo(park3);
Assertions.assertThat(park1 == park2 && park2 == park3).isTrue();
}
public 필드 방식과 비슷하지만 아래와 같은 장점이 있습니다.
- 더 간결합니다.
- 추가적인 노력 없이 직렬화를 할 수 있습니다.
- 복집한 직렬화 상황이나 리플렉션 공격이 온다 하더라도, 제 2의 인스턴스가 생기는 것을 완벽하게 막아줍니다.
거의 대부분의 상황에서, 원소가 하나뿐인 Enum 타입이 싱글톤을 만드는 가장 좋은 방법입니다. 단, 싱글톤이 Enum 외의 클래스를 상속해야 할 경우에는 해당 방식을 사용할 수 없습니다. (Enum 타입이 다른 인터페이스를 구현하도록 할 수는 있습니다.)
// 아래와 같이 인터페이스를 구현하여 사용 가능.
public interface EnumInterface {
void hahaha();
}
public enum Park implements EnumInterface{
INSTANCE;
@Override
public void hahaha() {
System.out.println("hahaha");
}
}
참고
https://madplay.github.io/post/what-is-readresolve-method-and-writereplace-method