Java 기초를 다지기 위해 효과적인 자바란 책을 공부 중입니다.
제12장 직렬화
- 객체 직렬화란 직렬화와 역직렬화에 관한 메커니즘이다
- 직렬화 : 자바가 객체를 바이트 스트림으로 인코딩하고
- 역직렬화 : 그 바이트 스트림으로부터 다시 객체를 재구성하는
- 직렬화된 객체는 다른 VM 에 전송하거나 디스크에 저장한 후 나중에 역직렬화할 수 있다
- 이번 장은 직렬화가 품고 있는 위험과 그 위험을 최소화하는 방법에 집중한다
85) 자바 직렬화
의 대안을 찾아라
프로그래머가 어렵지 않게 분산 객체를 만들 수 있다는 구호는 매력적이었지만,
보이지 않는 생성자, API 와 구현 사이의 모호해진 경계, 잠재적인 정확성 문제, 성능, 보안, 유지보수성 등 그 대가가 컸다.
직렬화의 근본적인 문제는 공격 범위가 너무 넓고 지속적으로 더 넓어져 방어하기 어렵다는 점이다.
예) ObjectInputStream 의 readObject 메서드는 거의 모든 타입의 객체를 만들어 낼 수 있는 마법같은 생성자다
코드 85-1 역직렬화 폭탄 - 이 스트림의 역직렬화는 영원히 계속된다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import java.util.HashSet;
import java.util.Set;
public class DeserializationBomb {
public static void main(String[] args) throws Exception {
System.out.println(bomb().length);
deserialize(bomb());
}
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);
}
}
이 문제들을 어떻게 대처해야 할까?
- 직렬화 위험을 회피하는 가장 좋은 방법은 아무것도 역직렬화하지 않는 것이다.
- 여러분이 작성하는 새로운 시스템에서 자바 직렬화를 써야 할 이유는 전혀 없다
- 객체와 바이트 시퀀스를 변환해주는 다른 메커니즘이 많이 있다.
-
크로스-플랫폼 구조화된 데이터 표현
방법이 있다!!- 속성-값 쌍의 집합으로 구성된 간단하고 구조화된 데이터 객체를 사용 (자바 직렬화보다 훨씬 간단)
-
JSON
(데이터 표현용) 과프로토콜 버퍼
(protobuf)(binary와 텍스트표현도 지원)
- 차선책으로 신뢰할 수 없는 데이터 는 절대 역직렬화하지 않는 것
- 확신도 할 수 없다면
객체 역직렬화 필터링
(java.io.ObjectInputFilter)을 사용하자- 클래스 단위로 받아들이거나 거부할 수 있다
- 기본수용 모드(블랙리스트) 와 기본거부 모드(화이트리스트) 가 있음 (화이트리스트 권장)
86) Serializable
을 구현할지는 신중히 결정하라
보호된 환경에서만 쓰일 클래스가 아니라면 직렬화 구현은 아주 신중하게 이뤄져야 한다.
Serializable 구현의 문제점
- 릴리즈한 뒤에는 수정하기 어렵다. (이미 API 처럼 널리 공개된 상태라면)
- 뒤늦게 클래스 내부 구현을 손보면 원래의 직렬화 형태와 달라지게 된다.
- 예: serialVersionUID 자동 생성값이 변경됨 (클래스 멤버들이 고려되기 때문에)
- 직렬화 가능 클래스를 만들고자 한다면, 길게 보고 감당할 수 있을 만큼 주의해서 설계해야 한다.
- 뒤늦게 클래스 내부 구현을 손보면 원래의 직렬화 형태와 달라지게 된다.
- 버그와 보안 구멍이 생길 위험이 높아진다
- 언어의 기본 메커니즘을 우회하는 객체 생성 기법이기 때문에
- 해당 클래스의 신버전을 릴리즈할 때 테스트할 것이 늘어난다
- 신버전 인스턴스가 구버전으로 역직렬화할 수 있는지, 그 반대도 가능한지 검사해야 함
상속용으로 설계된 클래스는 대부분 Serializable 을 구현하면 안되며, 인터페이스도 대부분 Serializable 을 확장해서는 안된다.
- Throwable 과 Component 는 예외
직렬화와 확장이 모두 가능하게 하려면 주의할 점
- 인스턴스 필드값 중 불변식을 보장해야 할 것이 있다면 반드시 하위 클래스에서 finalize 메서드를 재정의하지 못하게 해야 한다.
- finalizer 공격에 대한 방어 (항목 8)
- 인스턴스 필드 중 기본값으로 초기화되면 위배되는 불변식이 있다면 readObjectNoData 메서드를 반드시 추가해야 한다.
- 기존의 직렬화 가능 클래스에 직렬화 가능 상위 클래스를 추가하는 드문 경우를 위한 메서드 (자바 4부터)
코드 86-1 상태가 있고, 확장 가능하고, 직렬화 가능한 클래스용 readObjectNoData 메서드
1
2
3
private void readObjectNoData() throws InvalidObjectException {
throw new InvalidObjectException("스트림 데이터가 필요합니다");
}
직렬화를 구현하지 않기로 할 때 주의점
- 그 하위 클래스에서 직렬화를 지원하려 할 때 부담이 늘어난다
- 이런 클래스를 역직렬화하려면 그 상위 클래스는 매개변수가 없는 생성자를 제공해야 하는데
- 여러분이 이런 생성자를 제공하지 않으면 하위 클래스에서는 직렬화 프록시 패턴(
항목 90
)을 사용해야 한다
내부 클래스는 직렬화를 구현하지 말아야 한다.
- 내부 클래스에 대한 기본 직렬화 형태는 분명하지가 않다
- 단, 정적 멤버 클래스는 직렬화를 구현해도 된다
87) 커스텀 직렬화
형태를 고려해보라
기본 직렬화 형태를 사용하는 경우 고려사항
- 먼저 고민해보고 괜찮다고 판단될 때만 기본 직렬화 형태를 사용하라
- 객체의 물리적 표현과 논리적 내용이 같다면 기본 직렬화 형태라도 무방하다
- 기본 직렬화 형태가 적합하다고 결정했더라도 불변식 보장과 보안을 위해 readObject 메서드를 제공해야 할 때가 많다
코드 87-1 기본 직렬화 형태에 적합한 후보
1
2
3
4
5
6
7
8
public class Name implements Serializable {
private final String lastName;
private final String firstName;
private final String middleName;
... // 나머지 코드 생략
}
다음 클래스는 직렬화 형태에 적합하지 않은 예로, 문자열 리스트를 표현하고 있다.
객체의 물리적 표현과 논리적 표현의 차이가 클 때 기본 직렬화 형태를 사용하면 문제가 생긴다.
- 공개 API 가 현재의 내부 표현 방식에 영구히 묶인다
- private 클래스인 StringList.Entry 가 공개 API가 되어 관련 코드를 제거할 수 없게 된다
- 너무 많은 공간을 차지할 수 있다
- 문자열 연결 구조까지 직렬화 형태에 포함되어 디스크에 저장하거나 네트워크 전송에 느려진다
- 시간이 너무 많이 걸릴 수 있다
- 직렬화 로직은 객체 그래프의 위상에 관한 정보가 없으니 그래프를 직접 순회해볼 수 밖에 없다.
- 스택 오버플로를 일으킬 수 있다
- 기본 직렬화 과정은 객체 그래프를 재귀 순회하는데, 자칫 스택 오버플로를 일으킬 수 있다
코드 87-2 기본 직렬화 형태에 적합하지 않은 예
1
2
3
4
5
6
7
8
9
10
11
12
13
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;
}
... // 나머지 코드 생략
}
StringList 를 위한 합리적인 직렬화 형태는 무엇일까?
- 단순히 리스트가 포함한 문자열의 개수를 적은 다음, 그 뒤로 문자열들을 나열하는 수준이면 될 것이다
- 물리적인 상세 표현은 배제한 채 논리적인 구성만 담는 것이다
- 일시적이란 뜻의 transient 한정자는 해당 인스턴스 필드가 기본 직렬화 형태에 포함되지 않는다는 표시이다.
- 해당 객체의 논리적 상태와 무관한 필드라고 확신할 때만 transient 한정자를 생략해야 한다
- 기본 직렬화를 사용한다면 transient 필드들은 역직렬화될 때 기본값으로 초기화된다
코드 87-3 합리적인 커스텀 직렬화 형태를 갖춘 StringList
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 문자열들의 길이가 평균 10 이라면, 개선 버전의 직렬화 형태는
// ==> 원래 버전의 절반 정도의 공간을 차지하며, 두 배 정도 빠르게 수행된다
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) { ... }
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.defaultReadObject();
int numElements = s.readInt(); // 원소의 사이즈를 읽어오고
// 모든 원소를 읽어 순서대로 연결한다
for (int i = 0; i < numElements; i++)
add((String) s.readObject());
}
... // 나머지 코드 생략
}
직렬화 구현시 주의할 점
- 객체의 전체 상태를 읽는 메서드에 적용해야 하는 동기화 메커니즘을 직렬화에도 적용해야 한다.
- synchronized 선언된 객체를 기본 직렬화 하려면 writeObject 도 synchronized 선언을 해야 함
- 어떤 직렬화 형태를 택하든 직렬화 가능 클래스 모두에 직렬버전 UID 를 명시적으로 부여하자.
- 직렬버전 UID 를 명시하지 않으면 런타임에 이 값을 생성하느라 복잡한 연산을 수행하기 때문
- 구버전으로 직렬화된 인스턴스들과의 호환성을 끊으려는 경우를 제외하고는 직렬버전 UID 를 절대 수정하지 말자.
- 직렬버전 UID 가 다르면 InvalidClassException 이 던져질 것이다
코드 87-4 기본 직렬화를 사용하는 동기화된 클래스를 위한 writeObject 메서드
1
2
3
private synchronized void writeObject(ObjectOutputStream s) throws IOException {
s.defaultWriteObject();
}
88) readObject 메서드는 방어적
으로 작성하라
이 클래스의 선언에 implements Serializable 을 추가하면 불변식을 더는 보장하지 못하게 된다.
- readObject 메서드가 실질적으로 또 다른 public 생성자이기 때문 (바이트 스트림을 받는 생성자)
- 조작된 바이트 스트림으로 허용되지 않는 Period 인스턴스를 만들 수 있다
- readObject 메서드에서도 인수가 유효한지 검사해야 하고, 필요하다면 매개변수를 방어적으로 복사해야 한다
- 유효성 검사에 실패하면 InvaildObjectException 을 던지게 하여 잘못된 역직렬화가 일어나는 것을 막을 수 있다
- 클라이언트가 소유해서는 안되는 객체 참조를 갖는 필드를 모두 반드시 방어적으로 복사해야 한다
코드 88-1 방어적 복사를 사용하는 불변 클래스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 항목 50에서 사용된 클래스 (불변을 유지하기 위해 방어적으로 복사하여 생성)
public final class Period {
private final Date start;
private final Date end;
/**
* @param start 시작 시간
* @param end 종료 시각; 시작 시간보다 뒤여야 한다
* @throws IllegalArgumentException 시작 시간이 종료 시간보다 늦을 때 발생한다
* @throws NullPointerException 시작 또는 종료 시간이 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(start + " after " + end);
}
public Date start () { return new Date(start.getTime()); }
public Date end () { return new Date(end.getTime()); }
public String toString() { return start + " - " + end; }
... // 나머지 코드 생략
}
코드 88-3 유효성 검사를 수행하는 readObject 메서드 - 아직 부족하다
1
2
3
4
5
6
7
8
9
private void readObject(ObjectInputStream stream)
throws IOException, InvalidObjectException {
s.defaultReadObject();
// 불변식을 만족하는지 검사한다
if( start.compareTo(end) > 0)
throw new InvalidObjectException(start + " after " + end);
}
코드 88-4 방어적 복사와 유효성 검사를 수행하는 readObject 메서드
1
2
3
4
5
6
7
8
9
10
11
12
13
private void readObject(ObjectInputStream stream)
throws IOException, InvalidObjectException {
s.defaultReadObject();
// 가변 요소들을 방어적으로 복사한다
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
// 불변식을 만족하는지 검사한다
if( start.compareTo(end) > 0)
throw new InvalidObjectException(start + " after " + end);
}
readObject 메서드를 작성하는 지침
- private 이여야 하는 객체 참조 필드는 각 필드가 가리키는 객체를 방어적으로 복사하라
- 불변 클래스 내의 가변 요소가 여기 속한다
- 모든 불변식을 검사하여 어긋나는게 발견되면 InvalidObjectException 을 던진다
- 방어적 복사 다음에는 반드시 불변식 검사가 뒤따라야 한다
- 역직렬화 후 객체 그래프 전체의 유효성을 검사해야 한다면 ObjectInputValidation 인터페이스를 사용하라
- 이 책에서는 다루지 않는다
- 직접적이든 간접적이든, 재정의할 수 있는 메서드는 호출하지 말자
89) 인스턴스 수
를 통제해야 한다면 readResolve 보다는 Enum 타입을 사용하라
readResolve 기능을 이용하면 readObject 가 만들어낸 인스턴스를 다른 것으로 대체할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
public void leaveTheBuilding() { ... }
// 인스턴스 통제를 위한 readResolve - 개선의 여지가 있다!
private Object readResolve() {
// 진짜 Elvis 를 반환하고, 가짜 Elvis 는 가비지 컬렉터에 맡긴다
return INSTANCE;
}
}
코드 89-1 잘못된 싱글턴 - transient 가 아닌 참조 필드를 가지고 있다!
1
2
3
4
5
6
7
8
9
10
11
12
public class Elvis implements Serializable {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
// transient 가 아닌 참조필드
private String[] favoriteSongs = { "Hound Dog", "Heartbreak Hotel" };
public void printFavorites() {
System.out.println(Arrays.toString(favoriteSongs));
}
private Object readResolve() { return INSTANCE; }
}
핵심 정리
- 불변식을 지키기 위해 인스턴스를 통제해야 한다면 가능한 한 열거 타입을 사용하자.
- 여의치 않은 상황에서 직렬화와 인스턴스 통제가 모두 필요하다면
- readResolve 메서드를 작성해 넣어야 하고
- 그 클래스에서 모든 참조 타입 인스턴스 필드를 transient 로 선언해야 한다
코드 89-4 열거 타입 싱글턴 - 전통적인 싱글턴보다 우수하다
1
2
3
4
5
6
7
public enum Elvis {
INSTANCE;
private String[] favoriteSongs = { "Hound Dog", "Heartbreak Hotel" };
public void printFavorites() {
System.out.println(Arrays.toString(favoriteSongs));
}
}
90) 직렬화된 인스턴스 대신 직렬화 프록시
사용을 검토하라
직렬화 프록시 패턴은 버그와 보안 문제에 대한 위험을 크게 줄여준다.
핵심 정리
- 제 3자가 확장할 수 없는 클래스라면 가능한 한 직렬화 프록시 패턴을 사용하자
- 이 패턴이 아마도 중요한 불변식을 안정적으로 직렬화해주는 가장 쉬운 방법일 것이다
코드 90-1 Period 클래스용 직렬화 프록시
1
2
3
4
5
6
7
8
9
10
11
12
private static 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 =
234098243823485285L; // 아무 값이나 상관 없다
}
직렬화 프록시 패턴은 그리 복잡하지 않다.
- 바깥 클래스의 논리적 상태를 정밀하게 표현하는 중첩 클래스를 설계해 private static 으로 선언한다
- 생성자는 단 하나여야 하며, 바깥 클래스를 매개변수로 받아야 한다
- 바깥 클래스와 직렬화 프록시 모두 Serializable 을 선언해야 한다
- 바깥 클래스에 writeReplace 메서드를 추가한다
- 이 메서드는 범용적이니 그대로 복사해 쓰면 된다
- 바깥 클래스와 논리적으로 동일한 인스턴스를 반환하는 readResolve 메서드를 Serialization 클래스에 추가한다
- 이 메서드는 역직렬화 시에 직렬화 시스템이 직렬화 프록시를 다시 바깥 클래스의 인스턴스로 변환하게 해준다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// Immutable class that uses defensive copying
public final class Period implements Serializable {
private final Date start;
private final Date end;
/**
* @param start the beginning of the period
* @param end the end of the period; must not precede start
* @throws IllegalArgumentException if start is after end
* @throws NullPointerException if start or end is 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(start + " after " + end);
}
public Date start () { return new Date(start.getTime()); }
public Date end () { return new Date(end.getTime()); }
public String toString() { return start + " - " + end; }
// Serialization proxy for Period class
private static class SerializationProxy implements Serializable {
private final Date start;
private final Date end;
SerializationProxy(Period p) {
this.start = p.start;
this.end = p.end;
}
private Object readResolve() {
return new Period(start, end); // public 생성자를 사용한다
}
private static final long serialVersionUID = 234098243823485285L;
}
// writeReplace method for the serialization proxy pattern
private Object writeReplace() {
return new SerializationProxy(this);
}
// readObject method for the serialization proxy pattern
private void readObject(ObjectInputStream stream)
throws InvalidObjectException {
throw new InvalidObjectException("Proxy required");
}
}
참고 : Serialization Proxy Pattern in Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 사용 사례
public static void main(String[] args) {
// Creating and initializaing a Person object
Person person = new Person("User1", 1, 22);
// file name
final String fileName = "F://person.ser";
System.out.println("About to serialize ....");
// serializing
Util.serialzeObject(person, fileName);
try {
System.out.println("About to deserialize ....");
// deserializing
person = (Person)Util.deSerialzeObject(fileName);
System.out.println("id " + person.getId() + " Name "+ person.getName()
+ " Age " + person.getAge());
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 직렬화, 역직렬화 Util
public class Util {
public static void serialzeObject(Object obj, String fileName){
try(ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(new File(fileName)))){
oos.writeObject(obj);
}
catch (FileNotFoundException e) { e.printStackTrace(); }
catch (IOException e) { e.printStackTrace(); }
}
public static Object deSerialzeObject(String fileName) throws ClassNotFoundException{
Object obj = null;
try(ObjectInputStream ois = new ObjectInputStream(new FileInputStream(new File(fileName)))){
obj = ois.readObject();
}
catch (FileNotFoundException e) { e.printStackTrace(); }
catch (IOException e) { e.printStackTrace(); }
return obj;
}
}
EnumSet 의 사례를 생각해보자.
코드 90-2 EnumSet 의 직렬화 프록시
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private static class SerializationProxy<E extends Enum<E>> implements Serializable {
// 이 EnumSet 의 원소 타입
private final Class<E> elementType;
// 이 EnumSet 안의 원소들
private final Enum<?>[] elements;
SerializationProxy(EnumSet<E> set) {
elementType = set.elementType;
elements = set.toArrary(new Enum<?>[0]);
}
private Object readResolve() {
EnumSet<E> result = EnumSet.noneOf(elementType);
for( Enum<?> e : elements )
result.add((E) e);
return result;
}
private static final long serialVersionUID = 4321543254325643653L;
}
직렬화 프록시 패턴의 한계
- 클라이언트가 멋대로 확장할 수 있는 클래스에는 적용할 수 없다
- 객체 그래프에 순환이 있는 클래스에도 적용할 수 없다 (ClassCastException 발생)
- 직렬화 프록시 패턴이 주는 강력함과 안전성에도 대가는 따른다
- Period 의 경우 방어적 복사 때보다 14% 느렸다
끝! 읽어주셔서 감사합니다.