DesignPattern

싱글톤 패턴 -1- 예외 상황과 해결법

hj.choi 2022. 10. 11. 14:22

저번 게시글에서는 싱글톤 패턴에 대한 정의와 코드 구현법에 대한 이야기를 해보았다. 그런데, Java에서는 싱글톤으로 생성한 객체를 어떻게 활용하느냐에 따라, 싱글톤을 보장하지 못하는 경우가 있다고 한다.

이번 게시글에서는 싱글톤을 보장하지 못하는 경우와, 그 경우에 대한 대처법에 대해 정리해보고자 한다.

싱글톤 적용이 안되는 케이스 1 Reflection

우선 싱글톤 예제 코드를 하나 정리보도록 하자

LazyHolderSingleton

public class LazyHolderSingleton implements Serializable {
	priate LazyHolderSingleton() {}

	public static class LazyHolder {
		private static final LazyHolderSingleton INSTANCE = new LazyHolderSingleton();
	}

	public static LazyHolderSingleton getInstance() {
		return LazyHolder.INSTANCE;
	}
}

싱글톤을 구현하는 방법은 여러가지가 있지만, LazyHolder 방식이 구현 난이도도 적고, Lazy initialization을 통해, 불필요한 리소스 소모를 방지하는 방식을 선호하는 개인적인 성향도 있어, 이번 예제 코드는 이 방식을 채택하였다.

그런데, 이 싱글톤 패턴은 소제목에서도 이야기 했듯이, 싱글톤으로 생성한 인스턴스를 reflection으로 복사할 경우, 기존 싱글톤 객체와 전혀 다른 인스턴스가 생성된다. 코드를 먼저 보자

PossibleError

public class PossibleError {
	public static LazyHolderSingleton getInstanceWithReflection() {
		Constructor<LazyHolderSingleton> constructor = null;
		try {
			constructor = LazyHolderSingleton.class.getDeclaredConstructor();
		} catch (NoSuchMethodException e) {
			throw new RuntimeException(e);
		}
		constructor.setAccessible(true);
		try {
			return constructor.newInstance();
		} catch (InstantiationException | InvocationTargetException | IllegalAccessException e) {
			throw new RuntimeException(e);
		}
    }
}

reflection api 메소드 명을 보고, 추측해볼 수 있겠지만, reflection은 싱글톤 패턴에서 기본 생성자를 private로 막아도, `setAccessible`함수 하나로 접근 허용을 할 수 있다.

따라서, reflection이 개입하면, 아무리 싱글톤 패턴 코드를 잘 설계해도, 일반 클래스들과도 마찬가지로 새로운 인스턴스를 계속 생성할 수 있고, 결과적으로는 싱글톤을 보장하지 못하게 된다. 이 부분을 테스트 코드로 증명하자면 다음과 같다.

@Test
@DisplayName("싱글톤 패턴으로 생성한 인스턴스와 리플렉션으로 생성한 객체는 싱글톤을 보장하지 못한다.")
void notSingletonWithReflection() {
    LazyHolderSingleton singletonInstance = LazyHolderSingleton.getInstance();
    LazyHolderSingleton reflectedInstance = PossibleError.getInstanceWithReflection();

    assertThat(singletonInstance).isNotSameAs(reflectedInstance);
}

싱글톤 적용이 안되는 케이스 2 직렬화

사실 이 기능은 거의 100% 사용하지 않는 기능이지만, 싱글톤 객체를 직렬화하고, 나중에 역직렬화해서 읽을 경우에도 싱글톤을 보장하지 못한다. 코드로 보여주자면 다음과 같다.

object serializtion&deserialization

public static LazyHolderSingleton getDeserializedInstance() {
    LazyHolderSingleton result;

    LazyHolderSingleton source = LazyHolderSingleton.getInstance();
    try (ObjectOutput out = new ObjectOutputStream(new FileOutputStream("lazyHolderSingleton.obj"))) {
        out.writeObject(source);
    } catch (IOException e) {
        throw new RuntimeException(e);
    }

    try (ObjectInput in = new ObjectInputStream(new FileInputStream("lazyHolderSingleton.obj"))) {
        result = (LazyHolderSingleton)in.readObject();
    } catch (IOException | ClassNotFoundException e) {
        throw new RuntimeException(e);
    }

    return result;
}

test

@Test
@DisplayName("싱글톤 패턴으로 생성된 객체를 직렬화할 경우, 싱글톤을 보장하지 못한다.")
void notSingletonWithSerialized() {
    LazyHolderSingleton singletonInstance = LazyHolderSingleton.getInstance();
    LazyHolderSingleton deserializedInstance = PossibleError.getDeserializedInstance();

    assertThat(singletonInstance).isNotSameAs(deserializedInstance);
}

직렬화 문제와 관련한 부분에 대한 해결책은 https://stackoverflow.com/questions/3930181/how-to-deal-with-singleton-along-with-serialization 이 부분을 참고해보면 알겠지만, `readResolve()` 메소드를 정의해주면 해결된다고 한다. 그러나, 자바 객체를 직접 직렬화해서 사용하는 경우는 거의 없기도 하거니와 `Serializable` 인터페이스에서 제공해주지 않는 api 이기 때문에 직관적인 방식은 아닌 듯 하다.

결론, 싱글톤을 직접 사용해야 한다면, enum을 고려해보자

직렬화 문제의 경우, `Object readResolve()`를 재정의하면 해결된다. 그렇다면, 리플렉션의 경우는 어떻게 해결할까?

제목에서도 이야기 했듯이, enum을 활용하면 된다.

enum은 jvm 단위에서 클래스 로딩 시점에서, 단 하나의 인스턴스를 생성하도록 설계되어 있어서, 별도의 코드를 추가하지 않고도, 싱글톤을 보장할 수 있으며, 일반 클래스 처럼, 필드를 선언하고, 메소드를 정의할 수 있다. 그리고, enum의 경우 reflection을 막아놓았기 때문에, 리플렉션으로 인해 발생하는 문제를 아예 미연에 방지할 수 있다. 또한 기본적으로 직렬화/역직렬화된 객체와도 싱글톤을 보장해준다. 코드로 표현하자면 다음과 같다.

SingletonEnum

public enum SingletonEnum {
	INSTANCE;

	private int value;

	public void setValue(int value) {
		this.value = value;
	}

	public int getValue() {
		return value;
	}
}
@Test
@DisplayName("Enum은 리플렉션 생성자체가 불가능하기 때문에, 리플렉션으로 인해 싱글톤이 깨지는 것을 컴파일 타임에서 막을 수 있다.")
void cannotReflectWithEnum() {
    assertThrows(RuntimeException.class, PossibleError::getEnumInstanceWithReflection);
}

@Test
@DisplayName("Enum은 역직렬화한 객체와도 싱글톤을 보장한다.")
void isSingletonWithEnum() {
    SingletonEnum singletonEnum = SingletonEnum.INSTANCE;
    SingletonEnum deserializedEnumInstance = PossibleError.getDeserializedEnumInstance();

    assertThat(singletonEnum).isSameAs(deserializedEnumInstance);
}

만약, 하나의 클래스를 상황에 따라 다르게 쓰고 싶다면?

좀 더 구체적으로 이야기 하자면, 하나의 클래스를 경우에 따라 싱글톤으로 쓰고 싶기도 하고, 그냥 별개의 인스턴스로 사용하고 싶을 수 있다. 이럴 땐 어떻게 하면 될까? 정답이 될지는 모르겠지만, 싱글톤으로 쓰고 싶은 객체는 컴파일 타임 직후에 한번 리플렉션으로 객체를 복사해서 그 객체만 사용하도록 하고, 아닌 경우에는 별도로 새로 인스턴스화 하면 될 것 같다. 그런데, 그렇게 할바에야 차라리, 스프링의 생명주기 콜백을 활용하는게 더 낫지 않을까 싶다.