...
싱글톤 객체
싱글톤 객체는 단 하나의 유일한 객체를 의미 한다. 해당 인스턴스가 리소스를 많이 차지하는 무거운 인스턴스일때, 메모리 절약을 위해 인스턴스가 필요할 때 똑같은 인스턴스를 새로 만들지 않고 기존의 인스턴스를 가져와 활용하는 기법이다.
아래는 싱글톤 디자인 패턴을 구현하는 방법중 멀티쓰레드 환경에서 안전하고 검증된 방법인 Bill Pugh Solution 방법으로 구현한 싱글톤 객체이다.
class Singleton implements Serializable {
// 생성자를 private로 선언 (외부에서 new 사용 X)
private Singleton() {
}
private static class SettingsHolder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SettingsHolder.INSTANCE;
}
}
public static void main(String[] args) {
Singleton singleton1 = Singleton.getInstance();
Singleton singleton2 = Singleton.getInstance();
System.out.println("singleton1 == singleton2 : " + (singleton1 == singleton2));
System.out.println(singleton1);
System.out.println(singleton2);
}
코드 실행 결과를 보면 알듯이 두 singleton 변수는 서로 같은 메모리 주솟값을 가진 똑같은 인스턴스임을 알 수 있다.
역직렬화로 깨지는 싱글톤
자바의 직렬화(Serialize)는 JVM의 힙 메모리에 있는 객체 데이터를 바이트 스트림(byte stream) 형태로 바꿔 외부 파일로 내보낼수 있게 하는 기술을 말한다. 반대로 외부로 내보낸 직렬화 데이터를 다시 읽어들여 자바 객체로 재변환하는 것을 역직렬화(Deserialize) 라 한다.
직렬화하여 내보낸 외부 파일은 데이터베이스에 저장되기도 하며 네트워크를 통해 전송되기도 한다. 이 직렬화를 적용하기 위해선 클래스에 Serializable 인터페이스를 implements 하면 된다.
그런데 만일 어떤 클래스를 직렬화하여 다른 컴퓨터에 전송하려는데, 이 클래스를 싱글톤으로 구성하려고 한다. 하지만 이 싱글톤 클래스는 송신자가 파일을 받고 역직렬화시 깨지게 되어 더이상 싱글톤이 아니게 된다.
// 싱글톤 + 직렬화
class Singleton implements Serializable {
private Singleton() {}
private static class SettingsHolder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SettingsHolder.INSTANCE;
}
}
public static void main(String[] args) throws IOException, ClassNotFoundException {
Singleton singleton1 = Singleton.getInstance();
String fileName = "singleton.obj";
// 직렬화
ObjectOutputStream out = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(fileName)));
out.writeObject(singleton1);
out.close();
// 역직렬화
ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(new FileInputStream(fileName)));
Singleton singleton2 = (Singleton) in.readObject();
in.close();
System.out.println("singleton1 == singleton2 : " + (singleton1 == singleton2));
System.out.println(singleton1);
System.out.println(singleton2);
}
이러한 현상이 생기는 이유는 역직렬화 자체가 보이지 않은 생성자로서 역할을 수행하기 때문에 인스턴스를 또다시 만들어, 직렬화에 사용한 인스턴스와는 전혀 다른 인스턴스가 되기 때문에 일어나는 것이다. 따라서 클래스에 Serializable을 구현하면 더 이상 이 클래스는 싱글톤이 아니게 되어 메모리 이점을 더이상 얻을수 없게 된다.
그럼 만약 Serializable을 반드시 구현해야 하며, 그 인스턴스는 반드시 싱글턴 이어야만 한다면 어떻게 대처 해야할까?
싱글톤 역직렬화 대응 방안
이러한 싱글톤의 역직렬화의 대응 방안으로 직렬화 관련 메서드인 readResolve() 를 정의하면 된다.
readResolve 메서드를 정의하게 되면, 역직렬화 과정에서 readObject를 통해 만들어진 인스턴스 대신 readResolve에서 반환되는 인스턴스를 내가 원하는 것으로 바꿀 수 있기 때문이다. 그리고 기존에 역직렬화를 통해 새로 생성된 객체는 알아서 Garbage Collector의 대상이 된다.
class Singleton implements Serializable {
private Singleton() {}
private static class SettingsHolder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SettingsHolder.INSTANCE;
}
// 역직렬화한 객체는 무시하고 클래스 초기화 때 만들어진 인스턴스를 반환
private Object readResolve() {
return SettingsHolder.INSTANCE;
}
}
직렬화 코드를 실행해보면 똑같은 객체가 성공적으로 반환됨을 볼 수 있다. 이렇게 직렬화를 사용하면서 동시에 싱글톤 패턴을 유지할 수 있게 된 것이다.
이때 싱글턴 인스턴스의 직렬화 결과에는 아무런 실 데이터를 가질 이유가 없기 때문에, 싱글톤 클래스에 필드 변수들이 있을 경우 모든 인스턴스 필드를 transient로 선언한다. 아무리 readResolve 메서드라도 역직렬화 과정 중간에 역직렬화된 인스턴스의 참조를 훔쳐오는 공격을 행할경우 다른 객체로 바뀔 위험이 있기 때문이다.
class Singleton implements Serializable {
// 싱글톤 객체의 필드들을 transient 설정하여 직렬화 제외
transient String str = "";
transient ArrayList lists = new ArrayList();
transient Integer[] integers;
private Singleton() {}
private static class SettingsHolder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SettingsHolder.INSTANCE;
}
private Object readResolve() {
return SettingsHolder.INSTANCE;
}
}
리플렉션으로 깨지는 싱글톤
자바 리플렉션(Reflection - 거울 등에 비친, 반사)은 객체를 통해 클래스의 정보를 분석하여 런타임에 클래스의 동작을 조작하는 프로그램 기법이다. 클래스 파일의 위치나 이름만 있다면 해당 클래스의 정보를 얻어내고 객체를 생성하는 것 또한 가능하게 해준다.
이러한 리플렉션 기법은 프레임워크, 라이브러리에서 많이 사용된다. 왜냐하면 프레임워크, 라이브러리는 사용하는 사람이 어떤 클래스명과 멤버들을 구성할지 모르는데, 이러한 사용자 클래스들을 기존의 기능과 동적으로 연결 시키기 위하서 이다. 이미 Spring, Lombok 등 많은 프레임워크에서 리플렉션 기능을 사용하고 있다.
그런데 문제는 리플렉션을 통해 싱글톤 객체를 생성하게 되면 다른 객체를 반환해 싱글톤이 다시 한번 깨지는 것이다. 클래스 객체를 통해 해당 객체의 생성자를 받아와 newInstance() 메서드를 실행하면 인스턴스를 생성할 수 있게 되는데, 여기서 생성된 인스턴스는 Holder가 가지고 있는 인스턴스와는 전혀 다른 새로운 인스턴스이기 때문이다.
public static void main(String[] args) throws NoSuchMethodException, InstantiationException, IllegalAccessException, InvocationTargetException {
/* Reflection API */
// 1. Singleton의 Class에서 생성자를 가져온다
Constructor<Singleton> consructor = Singleton.class.getDeclaredConstructor();
// 2. 생성자가 private 이기 때문에 외부에서 access 할 수 있도록 true 설정
consructor.setAccessible(true);
// 3. 가져온 생성자를 이용해 인스턴스화 한다
Singleton singleton1 = consructor.newInstance();
Singleton singleton2 = consructor.newInstance();
System.out.println("singleton1 == singleton2 : " + (singleton1 == singleton2));
System.out.println(singleton1);
System.out.println(singleton2);
}
자바의 직렬화 & 역직렬화는 readResolve() 를 구현함으로써 대응할 수 있었지만, 리플렉션에는 대응하지는 못했다. 그렇다면 리플렉션은 절대 방어하지 못하는 걸까?
싱글톤 리플렉션 대응방안
Enum 싱글톤
다른 프로그래밍 언어에서 열거형(Enum)은 단순히 정수나 타입이지만, 자바에서의 Enum은 일종의 클래스로 취급된다.
이 Enum 객체를 이용하면 아주 간단하게 싱글턴 객체를 구성할 수 있다. enum은 애초에 멤버를 만들때 private로 만들고 한번만 초기화 하기 때문에 Thread-Safe 하며, enum 내에서 상수 뿐만 아니라 변수나 메서드를 선언해 사용이 가능하기 때문에, 이를 이용해 독립된 싱글톤 클래스 처럼 응용이 가능한 것이다.
보편적인 싱글턴 방법인 Bill Pugh Solution 기법과 달리, 클라이언트에서 리플렉션(Reflection)을 통한 공격에도 안전하다. 더 나아가 Enum은 기본적으로 serializable 인터페이스를 구현하고 있기 때문에 직렬화도 역시 가능하다. 따라서 직렬화가 필요하며 인스턴스 수가 하나임을 보장하고 싶을 때, enum 타입을 가장 먼저 고려하면 된다.
그러나 싱글턴으로 구성할 클래스가 특정 클래스의 상속이 필요한 구성일 경우, enum은 같은 enum 이 외의 클래스 상속은 불가능하기 때문에 어쩔수 없이 일반적인 클래스로 구성해야 한다는 단점이 있다.
enum Singleton {
INSTANCE; // 싱글톤 인스턴스
// 이넘은 필드와 메서드도 가질 수 있다
private int value = 3;
public int getValue() {
return value;
}
}
public static void main(String[] args) {
Singleton singleton1 = Singleton.INSTANCE;
Singleton singleton2 = Singleton.INSTANCE;
System.out.println("singleton1 == singleton2 : " + (singleton1 == singleton2));
System.out.println(singleton1.hashCode());
System.out.println(singleton2.hashCode());
System.out.println(singleton1.getValue());
}
실행 결과를 보면 알 수 있듯이 같은 인스턴스 객체를 가지고 있음을 알 수 있다.
그러면 정말로 Enum 싱글톤 객체가 리플렉션에 방어가 되는지 확인해보자.
public static void main(String[] args) throws NoSuchMethodException, InstantiationException, IllegalAccessException, InvocationTargetException {
/* Reflection API */
// 1. Singleton Enum의 생성자는 숨겨져 있기 때문에 getDeclaredConstructors로 배열로 가져온다.
Constructor<?>[] consructors = Singleton.class.getDeclaredConstructors();
// 2. 생성자 배열을 순회하여 인스턴스를 생성한다
for(Constructor<?> constructor : consructors){
constructor.setAccessible(true); // 생성자가 private 이기 때문에 외부에서 access 할 수 있도록 true 설정
Singleton singleton = (Singleton) constructor.newInstance("INSTANCE");
}
}
열거형은 리플렉션을 통해 newInstance() 를 실행하지 못하도록 막아놓았기 때문에 애초에 리플렉션 동작이 불가능하다.
이렇게 자바에서 싱글톤이 깨지는 상황과 이를 극복하는 두가지 방법에 대해 알아보았다.
# 참고자료
코딩으로 학습하는 GoF의 디자인 패턴 - 백기선
[10분 테코톡] 🍄비밥의 자바 직렬화
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.