싱글턴이란?
- 클래스의 인스턴스가 오직 1개만 생성되는 것을 보장하는 디자인 패턴
싱글턴 객체를 직렬화할 때 발생하는 문제점
- 클래스에 마커 인터페이스인 Serializable을 구현하는 순간 더 이상 싱글턴 객체가 아님
- 아이템 87에서 언급한 커스텀 직렬화를 사용하더라도, 아이템 88에서 언급한 커스텀 readObject() 메서드를 사용하더라도 소용없음
- 어떤 readObject() 메서드를 선언하든 해당 클래스가 초기화될 때 만들어진 인스턴스와는 별개인 인스턴스를 반환
readResolve() 기능을 이용할 때
- readObject()가 생성한 인스턴스를 다른 것으로 대체 가능
- 역직렬화한 객체의 클래스가 readResolve() 메서드를 적절히 구현했다면 역직렬화 후 새로 생성된 객체를 인수로 해당 메서드가 호출되고 해당 메서드가 반환한 객체 참조가 새로 생성된 객체를 대신해 반환
- 대부분의 경우 이때 새로 생성된 객체의 참조는 유지하지 않으므로 바로 GC 대상이 됨
readResolve() 메서드를 통해 싱글턴 속성을 유지하는 방법
- readResolve() 메서드가 역직렬화한 객체를 무시하고 클래스 초기화 때 생성된 인스턴스 반환
- 이때 인스턴스의 직렬화 형태는 아무런 실 데이터를 가질 이유가 없기 때문에 모든 인스턴스 필드를 transient로 선언
- 정리하자면 객체 참조 타입 인스턴스 필드는 모두 transient로 선언
- 위 조건이 충족되지 않을 경우 아이템 88에서 언급한 MutablePeriod 공격과 유사하게 readResolve() 메서드를 호출하기 전 역직렬화된 객체의 참조를 공격할 여지가 남음
역직렬화 공격 파헤치기
- 싱글턴 객체가 transient가 아닌 참조 필드를 지니고 있다면 해당 필드 내용은 readResolve() 메서드가 호출되기 전에 역직렬화됨
- 이때 잘 조작된 스트림을 써서 해당 참조 필드의 내용이 역직렬화되는 시점에 그 역직렬화된 인스턴스의 참조를 훔쳐올 수 있음 (ElvisStealer 참고)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class Elvis implements Serializable { | |
private static final long serialVersionUID = -8870240565519414478L; | |
public static final Elvis INSTANCE = new Elvis(); | |
private String[] favoriteSongs = {"Hound Dog", "Heartbreak Hotel"}; | |
private Elvis() { | |
} | |
public void printFavorites() { | |
System.out.println(Arrays.toString(favoriteSongs)); | |
} | |
private Object readResolve() throws ObjectStreamException { | |
return INSTANCE; | |
} | |
} | |
public class ElvisStealer implements Serializable { | |
private static final long serialVersionUID = 0; | |
static Elvis impersonator; | |
private Elvis payload; | |
private Object readResolve() { | |
// Save a reference to the "unresolved" Elvis instance | |
impersonator = payload; | |
// Return an object of correct type for favorites field | |
return new String[] {"A Fool Such as I"}; | |
} | |
} | |
public class ElvisImpersonator { | |
// Byte stream could not have come from real Elvis instance! | |
private static final byte[] serializedForm = new byte[] {(byte)0xac, (byte)0xed, 0x00, 0x05, 0x73, 0x72, 0x00, | |
0x05, 0x45, 0x6c, 0x76, 0x69, 0x73, (byte)0x84, (byte)0xe6, (byte)0x93, 0x33, (byte)0xc3, (byte)0xf4, | |
(byte)0x8b, 0x32, 0x02, 0x00, 0x01, 0x4c, 0x00, 0x0d, 0x66, 0x61, 0x76, 0x6f, 0x72, 0x69, 0x74, 0x65, 0x53, | |
0x6f, 0x6e, 0x67, 0x73, 0x74, 0x00, 0x12, 0x4c, 0x6a, 0x61, 0x76, 0x61, 0x2f, 0x6c, 0x61, 0x6e, 0x67, 0x2f, | |
0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x3b, 0x78, 0x70, 0x73, 0x72, 0x00, 0x0c, 0x45, 0x6c, 0x76, 0x69, 0x73, | |
0x53, 0x74, 0x65, 0x61, 0x6c, 0x65, 0x72, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, | |
0x4c, 0x00, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x74, 0x00, 0x07, 0x4c, 0x45, 0x6c, 0x76, 0x69, | |
0x73, 0x3b, 0x78, 0x70, 0x71, 0x00, 0x7e, 0x00, 0x02}; | |
public static void main(String[] args) { | |
// Initializes ElvisStealer.impersonator and returns | |
// the real Elvis (which is Elvis.INSTANCE) | |
Elvis elvis = (Elvis)deserialize(serializedForm); | |
Elvis impersonator = ElvisStealer.impersonator; | |
elvis.printFavorites(); | |
impersonator.printFavorites(); | |
} | |
// Returns the object with the specified serialized form | |
private static Object deserialize(byte[] sf) { | |
try (InputStream is = new ByteArrayInputStream(sf); | |
ObjectInputStream ois = new ElvisImpersonator.CustomObjectInputStream(is)) { | |
return ois.readObject(); | |
} catch (Exception e) { | |
throw new IllegalArgumentException(e); | |
} | |
} | |
private static class CustomObjectInputStream extends ObjectInputStream { | |
public CustomObjectInputStream(InputStream in) throws IOException { | |
super(in); | |
} | |
@Override | |
protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { | |
String name = desc.getName(); | |
if ("ElvisStealer".equals(name)) { | |
return com.tistory.jaimemin.effectivejava.ch12.item89.ElvisStealer.class; | |
} | |
if ("Elvis".equals(name)) { | |
return com.tistory.jaimemin.effectivejava.ch12.item89.Elvis.class; | |
} | |
return super.resolveClass(desc); | |
} | |
} | |
} |

부연 설명
- readResolve() 메서드와 인스턴스 필드 하나를 포함한 ElvisStealer 클래스 작성
- 해당 클래스의 인스턴스 필드는 도둑이 `숨길` 직렬화된 싱글턴을 참조하는 역할
- 직렬화된 스트림에서 싱글턴의 비휘발성 필드를 ElvisStealer의 인스턴스로 교체
- 이제 싱글턴 객체는 ElvisStealer를 참조하고 ElvisStealer는 싱글턴 객체를 참조하는 순환고리가 만들어짐
- 싱글턴 객체가 도둑을 포함하므로 싱글턴 객체가 역직렬화될 때 ElvisStealer의 readResolve() 메서드가 먼저 호출됨
- 그 결과 ElvisStealer의 readResolve() 메서드가 수행될 때 ElvisStealer의 인스턴스 필드에는 역직렬화 도중인 그리고 readResolve() 메서드가 수행되기 전인 싱글턴 객체의 참조가 담겨 있음
- ElvisStealer의 readResolve() 메서드는 해당 인스턴스 필드가 참조한 값을 정적 필드로 복사하여 readResolve() 메서드가 끝난 후에도 참조할 수 있도록 하고 해당 메서드는 ElvisStealer가 숨긴 transient가 아닌 필드의 원래 타입에 맞는 값을 반환
- 위 과정을 생략하면 직렬화 시스템이 ElvisStealer의 참조를 해당 필드에 저장하려 할 때 JVM이 ClassCastException 예외를 던짐
- 결론적으로 직렬화의 허점을 이용해 싱글턴 객체를 2개 생성한 꼴
- 도둑 클래스 공격으로 보여줬듯이 readResolve() 메서드를 사용해 `순간적으로` 만들어진 역질렬화된 인스턴스에 접근하지 못하게 하는 방법은 깨지기 쉽고 신경을 많이 써야 하는 작업
- favoriteSongs 필드를 transient로 선언하여 위 문제를 고칠 수 있지만 해당 클래스를 원소 하나짜리 열거 타입으로 바꾸는 편이 더 나은 선택
싱글턴을 보장하는 Enum 클래스
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public enum Elvis { | |
INSTANCE; | |
private String[] favoriteSongs = {"Hound Dog", "Heartbreak Hotel"}; | |
public void printFavorites() { | |
System.out.println(Arrays.toString(favoriteSongs)); | |
} | |
} |
부연 설명
- 직렬화 가능한 인스턴스 통제 클래스를 열거 타입을 이용해 구현할 경우 선언한 상수 외의 다른 객체는 존재하지 않음을 자바가 보장
- 공격자가 AccessibleObject.setAccessible과 같은 priviledged 메서드를 악용하지 않는 이상 직렬화 공격으로부터 방어할 수 있음
- 인스턴스 통제를 위해 readResolve() 메서드를 사용하는 방식이 완전히 쓸모없는 것은 아님
- 직렬화 가능한 인스턴스를 통제하는 클래스를 작성해야 하는데, 컴파일 타임에는 어떤 인스턴스들이 있는지 알 수 없는 상황이라면 열거 타입으로 표현할 수 없음
readResolve() 메서드의 접근 제한자
- final 클래스인 경우 readResolve() 메서드는 private 접근 제한자여야 함
- final이 아닌 클래스의 경우 readResolve() 메서드의 접근 제한자가
- private일 경우 하위 클래스에서 사용할 수 없음
- package-private으로 선언할 경우 같은 패키지에 속한 하위 클래스에서만 사용 가능
- protected나 public으로 선언할 경우 이를 재정의하지 않은 모든 하위 클래스에서 사용 가능하지만 하위 클래스의 인스턴스를 역직렬화하면 상위 클래스의 인스턴스를 생성하여 ClassCastException 예외 발생시킴
정리
- 불변식을 지키기 위해 인스턴스 개수를 하나 즉 싱글턴 객체로 통제해야 한다면 가능한 한 열거 타입을 사용하자
- 여의치 않은 상황에서 직렬화와 인스턴스 통제가 모두 필요할 경우 readResolve() 메서드를 재정의해야 하고 해당 크래스 내 모든 참조 타입 인스턴스 필드를 transient 필드로 선언하자
참고
이펙티브 자바
반응형
'JAVA > Effective Java' 카테고리의 다른 글
[아이템 90] 직렬화된 인스턴스 대신 직렬화 프록시 사용을 검토하라 (0) | 2024.05.19 |
---|---|
[아이템 88] readObject 메서드는 방어적으로 작성하라 (0) | 2024.05.18 |
[아이템 87] 커스텀 직렬화 형태를 고려해보라 (2) | 2024.05.14 |
[아이템 86] Serializable을 구현할지는 신중히 결정하라 (1) | 2024.05.12 |
[아이템 85] 자바 직렬화의 대안을 찾으라 (1) | 2024.05.12 |