ch12 직렬화 - LenKIM/everyone-is-effective-java-study GitHub Wiki
객체 직렬화란 자바가 객체를 바이트 스트림으로 인코딩하고(직렬화) 그 바이트 스트림으로부터 다시 객체를 재구성하는(역직렬화) 메커니즘. 직렬화된 객체는 다른 VM에 전송하거나 디스크에 저장한 후 나중에 역직렬화할 수 있다.
- 1997년, 자바에 처음으로 직렬화 도입 - 당시 도입시, 프로그래머가 어렵지 않게 분산 객체를 만들 수 있다는 구호는 매력적이지만, 보이지 않는 생성자, API와 구현 사이의 모호해진 경계, 잠재적인 정확성 문제, 성능, 보안, 유지보수성 등 그 대가가 컸음.
- 2000년 초반에 논의된 취약점으로 랜섬웨어 공격을 받아 요금 징수 시스템이 이틀간 마비되는 사태.
- 직렬화의 근본적인 문제는 공격 범위가 넓고 지속적으로 더 넓어져 방어하기 어렵다는 점.
- ObjectInputStream의 readObject 메서드를 호출하면서 객체 그래프가 역직렬화되기 때문 readObject 메서드는 클래스패스 안의 거의 모든 타입의 객체를 만들어 낼 수 있는, 사실상 마법같은 생성자.
- 바이트 스트림을 역직렬화하는 과정에서 이 메서드는 그 타입들 안의 모든 코드를 수행할 수 있다. 이 말인즉슨, 그 타입들의 코드 전체가 공격범위에 들어간다는 뜻.
자바의 역직렬화는 명백하고 현존하는 위험이다. 이 기술은 지금도 애플리케이션에서 직접 혹은, 자바 하부 시스템(RMI(Remote Method Invocation)), JMX(Java Management Extension), JMS(Java Messaging System) 같은)을 텅해 간접적으로 쓰이고 있기 때문이다. 신뢰할 수 없는 스트림을 역직렬화하면 원격 코드 실행(remote code execution,), 서비스 거부 등의 공격으로 이어질 수 있다. 잘못한 게 아무것도 없는 애플리케이션이라도 이런 공격에 취약해질 수 있다.
public class BombSerialization {
static byte[] bomb() {
Set<Object> root = new HashSet<>();
Set<Object> s1 = root;
Set<Object> s2 = new HashSet<>();
for (int i = 0; i < 100; i++) {
Set<Object> t1 = new HashSet<>();
Set<Object> t2 = new HashSet<>();
t1.add("foo"); // t1을 t2와 다르게 만든다.
s1.add(t1);
s1.add(t2);
s2.add(t1);
s2.add(t2);
s1 = t1;
s2 = t2;
}
return serialize(root);
}
public static void main(String[] args) {
System.out.println(bomb().length);
deserialize(bomb());
}
}
위 코드에서 문제점은, 5,744바이트의 전체 크기를 나타내지만, 역직렬화를 하려면 hashcode 메서드를 2^100 번 넘게 호출해야 한다.
이문제를 해결하기 위해서는 어떻게 대처해야 할까?
직렬화 위험을 회피하는 가장 좋은 방법은 아무것도 역직렬화하지 않는 것이다.
우리가 작성하는 새로운 시스템에서 자바 직렬화를 써야 할 이유는 전혀 없다. 객체와 바이트 시퀀스를 변환해주는 다른 메커니즘이 많이 있다.
이 책에서는 자바 직렬화와 구분도하고자 크로스-플랫폼 구조화된 데이터 표현 (ex, JSON, 프로토콜 버퍼)
이것들은 자바 직렬화보다 휠씬 간단하다는 것, 임의 객체 그래프를 자동으로 직렬화/역직렬화 하지 않는대신, 속성-값 쌍의 집합으로 구성된 간단하고 구조화된 데이터 객체를 사용한다.
- JSON은 브라우저와 서버가 통신하기 위해 설계되었고,
- 프로토콜 버퍼는 구글이 서버 사이에 데이터를 교환하고 저장하기 위한 용도로 설계
레거시 시스템 때문에 자바 직렬화를 완전히 배제할 수 없을 때의 차선책은 신뢰할 수 없는 데이터는 절대 역직렬화하지 않는 것.
그럼에도 불구하고 사용해야 한다면, 자바 역직렬화 필터링(java.io.ObjectInputFilter)을 사용하자. 이 필터는 역직렬화 전에 필터를 설치하여 특정 클래스 단위로 받아들이거나 거부할 수 있다.
자바 직렬화를 사용하는 시스템을 관리해야 한다면 시간과 노력을 들여서라도 크로스-플랫폼 구조화된 데이터 표현으로 마이그레이션하는 것을 심각하게 고민해보길 바란다.
implements Serializable
만 덧붙이면 간단한 직렬화. 너무 쉽기 때문에 쉽게 적용할 수 있지만, 진실은 휠씬 복잡하다.
- Serializable을 구현하면 릴리스한 뒤에는 수정하기 어렵다. 클래스가 Serializable을 구현하면 직렬화된 바이트 스트림 인코딩(직렬화 형태)도 하나의 공개 API가 된다. 그래서 이 클래스가 널리 퍼진다면 그 직렬화 형태도 영원히 지원해야 하는 것이 된다. 뒤늦게 클래스 내부 구현을 손보면 원래의 직렬화 형태와 달라지게 되고, 이는 실패를 맛보게 할 것이다.
- UID, serialVerionUID 라는 이름의 필드로, 이 번호를 명시하지 않으면 시스템이 런타임에 암호해시 함수(SHA-1)를 적용해 자동으로 클래스 안에 생성해 넣는다. 이 후 클래스가 변경되면 직렬 버전 UID 값도 변경되면서, 자동 생성되는 값에 의존하면 쉽게 호환성이 깨져버려 런타임에 InvalidClassException 이 발생.
-
Serializable 구현의 두번째 문제는 버그와 보안 구멍이 생길 위험이 높아진다는 점
일반적으로 우리는 객체를 생성자를 통해 생성한다. 그러나 직렬화를 활용하면 기본 메커니즘을 우회하는 객체 생성 기법이 추가된다. 기본 방식을 따른 재정해 사용하든, 역직렬화는 일반 생성자의 문제가 그대로 적용되는 "숨은 생성자" - 해당 클래스의 신버전을 릴리즈할 때 테스트할 것이 늘어난다는 점.
- Serializable 구현 여부는 가볍게 결정할 사안이 아니다.
- 상속용으로 설계된 클래스는 대부분 Serializable을 구현하면 안되며, 인터페이스도 대부분 Serializable을 확장해서는 안된다.
- 내부 클래스는 직렬화를 구현하지 말아야 한다. 왜?
내부 클래스에는 바깥 인스턴스의 참조와 유효 범위 안의 지역변수 값들을 저장하기 위해 컴파일러가 생성한 필드들이 자동으로 추가된다.
익명 클래스와 지역 클래스의 이름을 짓는 규칙이 언어 명세에 나와 있지 않듯, 이 필드들이 클래스 정의에 어떻게 추가되는지도 정의되지 않았다. 다시말해 내부 클래스에 대한 기본 직렬화 형태는 분명하지가 않다.
먼저 고민해보고 괜찮다고 판단될 때만 기본 직렬화 형태를 사용하라.
- 기본 직렬화 형태는 유연성, 성능, 정확성 측면에서 신중히 고민한 후 합당할 떄만 사용해야 한다.
- 객체가 포함한 데이터들과 그 객체에서부터 시작해 접근할 수 있는 모든 객체를 담아내며, 심지어 이 긱채들이 연결된 위상(topology)까지 기술한다.
- 이상적인 직렬화 형태라면 물리적인 모습과 독립된 논리적인 모습만을 표현해야 한다?
객체의 물리적 표현과 논리적 내용이 같다면 기본 직렬화 형태라도 무방하다.
public class Name impements Serializable {
/**
* 성. null이 아니어야 함.
* @serial
*/
private final String lastName;
/**
* 이름. null이 아니어야 함.
* @serial
*/
private final String firstName;
/**
* 중간이름. 중간이름이 없다면 null
* @serial
*/
private final String middleName;
... // 생략
}
- 기본 직렬화 형태에 적합한 경우에도 불변식 보장과 보안을 위해 readObject 메서드를 제공해야할 때가 있다. (아이템 88,90)
기본 직렬화 형태에 적합하지 않은 클래스
public final class StringList implements Serializable {
private int size = 0;
private Entry head = null;
private static class Entry implements Serializable {
String data;
Entry next;
Entry previous;
}
... // 나머지 코드는 생략
}
→ 이중 연결 리스트인 물리적 표현과 문자열 표현인 논리적 표현이 차이가 커서 직렬화 형태에 적합하지 않다.
- 공개 API가 현재의 내부 표현방식에 영구히 묶인다.
- 너무 많은 공간을 차지할 수 있다.
- 시간이 너무 많이 걸릴 수 있다.
- 스택 오버플로를 일으킬 수 있다.
합리적인 커스텀 직렬화 형태를 갖춘 StringList
public final class StringList implements Serializable {
private transient int size = 0;
private transient Entry head = null;
// 이제는 직렬화되지 않는다.
private static class Entry {
String data;
Entry next;
Entry previous;
}
// 지정한 문자열을 이 리스트에 추가한다.
public final void add(String s) { ... }
/**
* 이 {@code StringList} 인스턴스를 직렬화한다.
*
* @serialData 이 리스트의 크기*포함된 문자열의 개수)를 기록한 후
* ({@code int}), 이어서 모든 원소를(각각은 {@code String})
* 순서대로 기록한다.
*/
private void writeObject(ObjectOutputStream s) throws IOException {
s.defaultWriteObject();
s.writeInt(size);
// 모든 원소를 올바른 순서로 기록한다.
for (Entry e = head; e != null; e = e.next)
s.writeObject(e.data);
}
private void readObject(ObjectInputStream s) throws IOException,
ClassNotFoundException {
s.detaultReadObject();
int numElements = s.readInt();
// 모든 원소를 읽어 이 리스트에 삽입한다.
for(int i =0; i < numElements; i++)
add((String) s.readObject());
}
... // 나머지 코드는 생략
}
-
클래스의 인스턴스 필드 모두가 transient이지만 defaultWriteObject()와 defaultReadObject()메서드를 호출해서 다음 릴리스에 필드 추가 시 호환될 수 있도록 한다.
-
원래의 절반 정도 공간과 두 배 정도 빠르게 직렬화 수행, 스택 오버플로가 생기지 않음
- 객체의 논리적 상태와 무관한 필드인것이 확실할 때만 transient 한정자를 생략한다.
- 기본 직렬화 사용시 transient필드들은 역직렬화할 때 기본값으로 초기화 된다.
private synchronized void writeObject(ObjectOutputStream s) throws IOException{
s.defaultWriteObject();
}
- 호환성을 위해서 (+복잡한 연산 수행 없어짐)
- 꼭 고유할 필요는 없으며 구버전 인스턴스와 호환성을 유지하고 싶다면 자동생성된 값을 그대로 사용한다.
- serialver 유틸리티에 입력하여 얻을 수 있음
참고자료 : http://woowabros.github.io/experience/2017/10/17/java-serialize2.html
- readObject 메서드는 public 생성자를 작성하듯 방어적으로 작성해야 하고, 매개변수로 넘어오는 바이트 스트림에 대한 유효성 검사를 하여 안전하게 인스턴스를 만들어 낼 수 있도록 만들어야 한다.
- readObject 메서드는 매개변수로 바이트 스트림을 받는 생성자라 할 수 있다.
- 불변 날짜 범위 클래스 (아이템 50)를 이용하여 readObject 메서드를 작성해보자.
//코드 88-1 방어적 복사를 사용하는 불변 클래스
public final class Period {
private final Date start;
private final Date end;
/**
* @param start 시작 시각
* @param end 종료 시각. 시작 시각보다 뒤여야 한다.
* @throws IllegalArgumentException 시작 시각이 종료 시각보다 늦을 때 발생한다.
* @throws NullPointerException start나 end가 null이면 발생한다.
*/
public Period(Date start, Date end) {
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
if (this.start.compareTo(this.end) > 0)
throw new IllegalArgumentException(this.start + "가 " + this.end + "보다 늦다.");
}
public Date start() {
return new Date(start.getTime());
}
public Date end() {
return new Date(end.getTime());
}
@Override
public String toString() {
return start + " - " + end;
}
}
여기서 방어적 복사를 잘하고 있다고 판단되어 끝에 Serializable 을 넣으면 된다고 생각하지 말자.
readObject 메서드가 실질적으로 또 다른 public 생성자이기 때문이다. 공격자를 이를 활용해 해당 클래스의 불변식을 깨뜨릴수 있다.
위 클래스에 implements Serializable 를 추가하여 직렬화가 가능하게 만들었다고 가정하자.
- 단순히 implements Serializable만 했다면, default readObject 메서드를 사용한다는 뜻이고, 이럴 땐 종료 시간보다 시작 시간이 앞서는 Period 인스턴스가 생성되는 문제가 발생할 수 있다.
//코드 88-2 허용되지 않는 Period 인스턴스를 생성할 수 있다
public class BogusPeriod {
// 진짜 Period 인스턴스에서는 만들어질 수 없는 바이트 스트림
private static final byte[] serializedForm = {
(byte) 0xac, (byte) 0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x06,
0x50, 0x65, 0x72, 0x69, 0x6f, 0x64, 0x40, 0x7e, (byte) 0xf8,
0x2b, 0x4f, 0x46, (byte) 0xc0, (byte) 0xf4, 0x02, 0x00, 0x02,
0x4c, 0x00, 0x03, 0x65, 0x6e, 0x64, 0x74, 0x00, 0x10, 0x4c,
0x6a, 0x61, 0x76, 0x61, 0x2f, 0x75, 0x74, 0x69, 0x6c, 0x2f,
0x44, 0x61, 0x74, 0x65, 0x3b, 0x4c, 0x00, 0x05, 0x73, 0x74,
0x61, 0x72, 0x74, 0x71, 0x00, 0x7e, 0x00, 0x01, 0x78, 0x70,
0x73, 0x72, 0x00, 0x0e, 0x6a, 0x61, 0x76, 0x61, 0x2e, 0x75,
0x74, 0x69, 0x6c, 0x2e, 0x44, 0x61, 0x74, 0x65, 0x68, 0x6a,
(byte) 0x81, 0x01, 0x4b, 0x59, 0x74, 0x19, 0x03, 0x00, 0x00,
0x78, 0x70, 0x77, 0x08, 0x00, 0x00, 0x00, 0x66, (byte) 0xdf,
0x6e, 0x1e, 0x00, 0x78, 0x73, 0x71, 0x00, 0x7e, 0x00, 0x03,
0x77, 0x08, 0x00, 0x00, 0x00, (byte) 0xd5, 0x17, 0x69, 0x22,
0x00, 0x78};
public static void main(String[] args) {
Period p = (Period) deserialize(serializedForm);
System.out.println(p);
}
private static Object deserialize(byte[] sf) {
try {
InputStream is = new ByteArrayInputStream(sf);
ObjectInputStream ois = new ObjectInputStream(is);
Object obj = ois.readObject();
return obj;
} catch (IOException | ClassNotFoundException e) {
throw new IllegalArgumentException(e);
}
}
}
/*
실행결과
Sat Jan 02 05:00:00 KST 1999 - Mon Jan 02 05:00:00 KST 1984
*/
- 이 문제를 해결하기 위해 readObject 메서드에서 역직렬화 된 객체가 유효한지 검사하도록 만들자.
// 코드 88-3 유효성 검사를 수행하는 readObject 메서드 - 아직 불완전
public final class Period implements Serializable {
// 코드 생략
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
s.defaultReadObject();
// 불변식을 만족하는지 검사
if (start.compareTo(end) > 0) {
throw new InvalidObjectException(this.start + "가 " + this.end + "보다 늦다.");
}
}
}
불변식에 대한 유효성 검사 코드가 추가되었지만, 아직도 공격자가 이상한 Period를 생성할 수 있는 방법이 있다.
- 바이트 스트림을 통해 Period 인스턴스 내부 private 필드에 대한 참조를 추가하는 것
public class MutablePeriod {
private final Period period;
private final Date start;
private final Date end;
public MutablePeriod() {
try {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(bos);
// 유효한 Period 인스턴스를 직렬화 함 (현재 시간의 start와 end를 가진 Period 생성)
out.writeObject(new Period(new Date(), new Date()));
// Period 내부의 Date 필드에 악의적인 참조를 추가함
byte[] ref = {0x71, 0, 0x7e, 0, 5}; // Ref #5
bos.write(ref); // start 필드
ref[4] = 4; // Ref # 4
bos.write(ref); // end 필드
// Period 인스턴스를 역직렬화 한 후, Date 참조를 훔침
ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()));
period = (Period) in.readObject();
start = (Date) in.readObject();
end = (Date) in.readObject();
} catch (IOException | ClassNotFoundException e) {
throw new AssertionError(e);
}
}
public static void main(String[] args) {
MutablePeriod mp = new MutablePeriod();
Period p = mp.period;
Date pEnd = mp.end;
pEnd.setYear(50);
System.out.println(p);
Date pStart = mp.start;
pStart.setYear(122);
System.out.println(p);
}
}
/*
실행결과
Tue May 07 10:14:18 KST 2019 - Sun May 07 10:14:18 KST 1950
Sat May 07 10:14:18 KST 2022 - Sun May 07 10:14:18 KST 1950
*/
- readObject 메서드가 방어적 복사를 충분히 하지 않았기 때문에, 의도적으로 Date start와 Date end 를 변경할 수 있는 상황이다
- 역직렬화시 클라이언트가 소유하면 안되는 필드는 반드시 방어적으로 복사되도록 해야한다.
- 이제 Period의 불변식과 불변 성질이 지켜지도록 readObject 메서드를 수정하자.
- 방어적 복사를 시행하되, 유효성 검사보다 앞서 수행하기
- 방어적 복사를 위해 final이었던 필드를 non-final 변수로 선언하기
//코드 88-5 방어적 복사와 유효성 검사를 수행하는 readObject 메서드
public final class Period implements Serializable {
// final 제거
private Date start;
private Date end;
// 코드 생략
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
s.defaultReadObject();
// 가변적 요소를 방어적으로 복사
start = new Date(start.getTime());
end = new Date(end.getTime());
// 불변식을 만족하는지 검사
if (start.compareTo(end) > 0) {
throw new InvalidObjectException(this.start + "가 " + this.end + "보다 늦다.");
}
}
}
/*
MutablePeriod 실행결과
Tue May 07 10:18:19 KST 2019 - Tue May 07 10:18:19 KST 2019
Tue May 07 10:18:19 KST 2019 - Tue May 07 10:18:19 KST 2019
*/
객체 내부의 모든 non-transient 필드를 매개변수로 받아, 유효성 검사 없이 필드에 대입하는 public 생성자를 추가해도 괜찮은가?
- 예
- 기본 readObject 메서드를 사용해도 됨.
- 아니요.
- 위에서 작업했던 것처럼, readObject 메서드를 커스텀하여 방어적 복사와 모든 유효성 검사(생성자에서 수행했어야 할)를 수행하도록 해야 한다.
- 또는 직렬화 프록시 패턴(아이템 90) 사용하는 방법도 있다.
- 생성자 처럼, readObject 메서드도 재정의 가능 메서드를 (직접적으로든 간접적으로든)호출해서는 안된다.
- 위에서 작업했던 것처럼, readObject 메서드를 커스텀하여 방어적 복사와 모든 유효성 검사(생성자에서 수행했어야 할)를 수행하도록 해야 한다.
핵심 정리
- private이어야 하는 객체 참조 필드는 각 필드가 기리키는 객체를 방어적으로 복사하라. 불변 클래스 내의 가변 요소가 여시 속한다.
- 모든 불변식을 검사하여 어긋나는 게 발견되면 InvalidObjectException을 던진다. 방어적 복사 다음에는 반드시 불변식 검사가 뒤따라야 한다.
- 역직렬화 후 객체 그래프 전체의 유효성을 검사해야 한다면 ObjectInputValidation인터페이스 사용하라
- 직접적이든 간접적이든, 재정의할 수 있는 메서드는 호출하지 말자.
- readObject 메서드는 새로 생성된 객체를 반환한다.
- 싱글턴은 하나의 인스턴스만 생성되어야 하기 때문에, implements Serializable 한다면 readResolve 메서드를 이용해야 한다.
- readObject 메서드는 항상 새로운 인스턴스를 반환함
- 아이템 3에서 살펴보았던 싱글턴 패턴을 이용하여 인스턴스 수를 통제해보자.
public class Elvis implements Serializable {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
private Object readResolve() {
return INSTANCE;
}
}
만약 클래스 내부에 인스턴스 필드가 존재한다면, 인스턴스 필드는 직렬화될 필요가 없으므로 transient를 선언해주어야 한다.
- 싱글톤에서는 readResolve 메서드가 최초 생성된 인스턴스를 리턴하게 만들어 readObject 메서드가 만들어낸 새로운 인스턴스가 필요 없고, 인스턴스 변수 역시 직렬화할 필요가 없다
//코드 89-1 잘못된 싱글턴 - transient가 아닌 참조 필드를 가지고 있다
public class Elvis implements Serializable {
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() {
return INSTANCE;
}
}
//코드 89-2 도둑 클래스
public class ElvisStealer implements Serializable {
private static final long serialVersionUID = 0;
static Elvis impersonator;
private Elvis payload;
private Object readResolve() {
// resolve되기 전의 Elvis 인스턴스의 참조를 저장
impersonator = payload;
// favoriteSongs 필드에 맞는 타입의 객체를 반환
return new String[] {"My way"};
}
}
ublic class ElvisImpersonator {
private static final byte[] serializedForm = new byte[]{
(byte) 0xac, (byte) 0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x05,
// 코드 생략
};
private static Object deserialize(byte[] sf) {
// 코드 생략
}
public static void main(String[] args) {
// ElvisStealer.impersonator 를 초기화한 다음,
// 진짜 Elvis(즉, Elvis.INSTANCE)를 반환
Elvis elvis = (Elvis) deserialize(serializedForm);
Elvis impersonator = ElvisStealer.impersonator;
elvis.printFavorites();
impersonator.printFavorites();
}
}
/*
실행 결과
[Hound Dog, Heartbreak Hotel]
[My way]
*/
-
싱글턴이지만 인스턴스가 2개 생성되었음을 확인할 수 있다.
-
이 문제는 favoriteSongs 필드를 transient로 선언하여 방어할 수도 있지만,
Elvis를 원소 하나짜리 Enum타입으로 바꾸는게 더 권장되는 해결법이다.
- Enum 타입을 이용해 구현하면, 좀 더 간편하기도 하고 자바가 선언한 상수 외의 다른 객체는 존재하지 않음을 보장해준다.
//코드 89-4 열거 타입 싱글턴 - 전통적인 싱글턴보다 우수하다
public enum Elvis {
INSTANCE;
private String[] favoriteSongs = {"Hound Dog", "Heartbreak Hotel"};
public void printFavorites() {
System.out.println(Arrays.toString(favoriteSongs));
}
}
- 직렬화 프록시
- 바깥 클래스의 논리적 상태를 표현하는 private static 중첩클래스 == 바깥 클래스의 직렬화 프록시
- 중첩 클래스는 바깥 클래스를 매개변수로 받는 단일 생성자를 가져야 한다.
- 이 생성자는 단순히 인수로 넘어온 인스턴스의 데이터를 복사한다 (일관성 검사나 방어적 복사를 하지 않음)
- 바깥 클래스와 중첩 클래스(직렬화 프록시) 모두 implements Serializable 해야 한다.
- Period 클래스를 이용하여 Serialization Proxy 를 만들어보자.
//코드 90-1 Period 클래스용 직렬화 프록시
public class SerializationProxy implements Serializable {
private final Date start;
private final Date end;
SerializationProxy(Period p) {
this.start = p.start;
this.end = p.end;
}
private static final long serialVersionUID = 0; // 아무값이나 상관없음(아이템 87)
}
바깥 클래스에 writeReplace 메서드를 구현한다.
- 이 메서드는 직렬화가 이뤄지기 전에 바깥 클래스의 인스턴스를 직렬화 프록시로 변환하여 직렬화 시스템이 바깥 클래스의 직렬화된 인스턴스를 생성할 수 없게 만든다.
//직렬화 프록시 패턴용 writeReplace 메서드
private Object writeReplace() {
return new SerializationProxy(this);
}
- 이제 바깥 클래스의 직렬화된 인스턴스는 생성할 수 없지만, 공격자는 불변식을 훼손하고자 바깥 클래스의 객체를 직렬화하려는 시도를 할 수도 있다. 이러한 공격을 막기 위해 readObject 메서드를 바깥 클래스에 추가하면 공격을 막을 수 있다.
private void readObject(ObjectInputStream stream)
throws InvalidObjectException {
throw new InvalidObjectException("프록시가 필요합니다.");
}
마지막으로, 바깥 클래스와 논리적으로 동일한 인스턴스를 반환하는 readResolve 메서드를 중첩 클래스(직렬화 프록시)에 추가한다
- 이 메서드는 역직렬화 시 직렬화 시스템이 직렬화 프록시를 다시 바깥 클래스의 인스턴스로 변환하게 해준다.
// Period.SerializationProxy용 readResolve 메서드
private Object readResolve() {
return new Period(start, end); // public 사용자를 사용
}
- 방어적 복사처럼, 직렬화 프록시 패턴은 가짜 바이트 스트림 공격과 내부 필드 탈취 공격을 프록시 수준에서 차단.
- 또한, final이기 때문에 불변으로 만들 수 있다.
- 클라이언트가 확장할 수 있는 클래스(아이템 19)에는 적용할 수 없다.
- 객체 그래프에 순환이 있는 클래스에는 적용할 수 없다.
- 방어적 복사보다 비용이 더 들어간다.