아이템 3 private 생성자나 열거타입으로 싱글턴임을 보증하라
p.23 클래스를 싱글턴으로 만들면 이를 사용하는 클라이언트를 테스트하기가 어려워질 수 있다.
타입을 인터페이스로 정의한 다음 그 인터페이스를 구현해서 만든 싱글턴이 아니라면 mock 구현으로 대체할 수 없기 때문이다.
인터페이스 없이 구현한 싱글턴 코드
public class Validator {
private static final Validator INSTANCE = new Validator();
public static final Validator getInstance() {
return INSTANCE;
}
private int bound;
private Validator() {
this.bound = 10;
}
public void validate(int target) {
if (target > bound) {
throw new IllegalArgumentException();
}
}
}
class Service {
private final Validator validator = Validator.getInstance();
public void validate(int target) {
validator.validate(target); // validator 가 제대로 작동하는지 테스트를 하려는데 bound 값을 바꿔서 테스트하고 싶다면?
}
}
인터페이스로 정의한 다음 구현한 싱글턴 코드
public interface Validator {
void validate(int target);
}
class RealValidator implements Validator {
private static final Validator INSTANCE = new RealValidator();
private int bound = 10;
private RealValidator() {
}
@Override
public void validate(int target) {
if (target > bound) {
throw new IllegalArgumentException();
}
}
}
class MockValidator implements Validator {
private static final Validator INSTANCE = new MockValidator();
private int bound;
private MockValidator() {
}
// 테스트를 위한 코드, 이러한 코드는 production 코드에 노출되어선 안되기 때문에 mock 클래스에만 존재한다.
public void changeMockBound(int mockBound) {
this.bound = mockBound;
}
@Override
public void validate(int target) {
if (target > bound) {
throw new IllegalArgumentException();
}
}
}
테스트를 유연하게 할 수 있는 구조가 된다.
주의해야할 점
싱글턴 클래스를 직렬화하려면 단순히 Serializable
을 구현할 것이 아니라, 모든 인스턴스 필드를 transient
라고 선언해줘야지 역직렬화 할때 새로운 인스턴스가 생성이 안된다.
혹은 readResolve()
메서드를 추가하여 싱글턴임을 보장하자.
public class Singleton implements Serializable {
private static final Singleton INSTANCE = new Singleton();
public static Singleton getInstance() {
return INSTANCE;
}
// 역직렬화시 새로운 인스턴스 생성 요인 차단방법
// transient 예약어를 인스턴스 변수에 추가하여 직렬화를 차단하여 역직렬화시 새로운 인스턴스가 생성되지 못하도록 한다.
transient private int value;
private Singleton() {
value = 1;
}
// 역직렬화시 새로운 인스턴스 생성 요인 차단방법
// 역직렬화시 readResolve 를 재정의하여 기존 인스턴스를 반환하여 싱글턴을 보장하도록 한다.
private Object readResolve() {
return INSTANCE;
}
@Override
public String toString() {
return "Singleton{" +
"value=" + value +
'}';
}
}
테스트 코드(Junit5 + assertJ)
class SingletonTest {
@DisplayName("자바 직렬화 테스트")
@Test
void singletonTest1() throws IOException, ClassNotFoundException {
Singleton singleton = Singleton.getInstance();
byte[] serializedSingletonByte;
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(singleton);
// serializedSingletonByte -> 직렬화된 객체
serializedSingletonByte = baos.toByteArray();
}
}
// 바이트 배열로 생성된 직렬화 데이터를 base64로 변환
String serializedSingleton = Base64.getEncoder().encodeToString(serializedSingletonByte);
byte[] deSerializedSingletonByte = Base64.getDecoder().decode(serializedSingleton);
ByteArrayInputStream bais = new ByteArrayInputStream(deSerializedSingletonByte);
ObjectInputStream ois = new ObjectInputStream(bais);
// 역직렬화된 Singleton 객체를 읽어온다.
Object objectMember = ois.readObject();
Singleton deSerializedSingleton = (Singleton) objectMember;
assertAll(
() -> assertThat(singleton).isEqualTo(deSerializedSingleton),
() -> assertThat(singleton == deSerializedSingleton).isTrue(),
() -> assertThat(singleton.toString().equals(deSerializedSingleton.toString())).isTrue()
);
}
}
테스트 결과
대부분의 상황에서 가장 바람직한 방법은 논리적으로 원소가 하나 뿐인 Enum과 같은 열거형을 이용하여 싱글턴을 구현하는 것이다.