[Effective Java] 커스텀 직렬화 형태를 고려해보라

들어가며

기본 직렬화 형태는 유연성, 성능, 정확성 측면에서 신중히 고려한 뒤에 합당할때만 사용해야 한다. 기본 직렬화 형태는 그 객체를 루트로 하는 객체 그래프의 물리적 모습을 나름 효율적으로 인코딩한다. 그렇기 때문에 객체가 포함한 데이터들과 그 객체에서부터 시작해 접근할 수 있는 모든 객체를 담아낸다.

객체의 물리적 표현과 논리적 내용이 같다면 기본 직렬화 형태도 무관하다. 기본 직렬화 형태가 적합하다고 결정했더라도 불변식 보장과 보안을 위해 readObject 메소드를 제공해야할 때가 많다. 다음 코드는 직렬화에 적합하지 않은 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
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;
}

...
}

이 클래스는 각 노드의 양방향 연결 정보를 포함한 모든 엔트리를 기록한다. 그래서 다음과 같은 문제가 생긴다.

  1. 공개 API가 현재 내부 표현 방식에 영구히 묶임
    • private 클래스인 Entry가 공개 API가 되어버린다. 다음 릴리스에도 내용을 바꾸더라도 Entry를 지원해야하기 때문에 종속적이게 되어버린다.
  2. 너무 많은 공간 차지
    • 엔트리 연결 정보는 내부 구현에 해당하니 직렬화 형태에 포함할 필요가 없다. 하지만 이까지 포함하여 직렬화를 수행한다.
  3. 시간이 너무 많이 걸림
    • 직렬화 로직은 객체 그래프의 위상에 대한 정보가 없기 때문에 엔트리 그래프를 직접 순회하는 수 밖에 없다.
  4. 스택 오버플로
    • 기본 직렬화 과정은 객체 그래프를 재귀 순회하는데 많은 양의 엔트리가 들어가 있다면 스택 오버플로를 발생시킨다.

이를 해결하는 코드는 다음과 같다.

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
public final class StringList implements Serializable {
//transient 한정자를 통해 기본 직렬화 형태에 포함시키지 않기
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) { ... }

/**
* StringList 인스턴스를 직렬화한다.
*/
private void writeObject(ObjectOutputStream stream)
throws IOException {
stream.defaultWriteObject();
stream.writeInt(size);

// 모든 원소를 순서대로 기록한다.
for (Entry e = head; e != null; e = e.next) {
s.writeObject(e.data);
}
}

private void readObject(ObjectInputStream stream)
throws IOException, ClassNotFoundException {
stream.defaultReadObject();
int numElements = stream.readInt();

for (int i = 0; i < numElements; i++) {
add((String) stream.readObject());
}
}
// ... 생략
}

기존에 StringList 클래스는 객체의 불변식마저 직렬화한다는 점과 이를 정확히 복원해낸다는 점에서 정확하다고 할 수 있다. 하지만 그 불변식이 세부 구현에 따라 달리지는 객체에서는 이 정확성이 깨질 수 있다.

가량 해시테이블에서 키 값 계산 방식은 구현에 따라 달라질 수 있다. 그렇기 때문에 해시테이블 같은 경우에는 기본 직렬화를 사용하면 불변식이 훼손된 객체들이 생겨날 수 있다.

defaultWriteObject 메소드를 호출하면 transient로 선언하지 않은 모든 인스턴스 필드가 직렬화된다. 그렇기 때문에 transient로 선언해도 되는 필드에는 모두 한정자를 붙혀야 한다. 캐시된 해시 값 처럼 유도되는 필드나 JVM을 실행할 때마다 값이 달라지는 필드도 마찬가지다. 해당 객체의 논리적 상태와 무관한 필드라고 확실할 때만 transient 한정자를 생략해야 한다. 기본 직렬화를 사용한다면 transient 필드들은 역직렬화시 기본값으로 초기화됨을 기억하자.

기본 직렬화 사용 여부와 상관 없이 객체 전체 상태를 읽는 메소드에 적용해야 하는 동기화 메커니즘을 직렬화에도 적용해야 한다. 또한 어떤 직렬화 형태를 택하든 직렬화 가능 클래스 모두에 직렬 버전 UID를 명시적으로 부여하자. 직렬 버전 UID가 일으키는 잠재적 호환성 문제가 사라진다.

한편, 직렬 버전 UID가 없는 클래스를 구버전과 호환성을 가지게 하고 싶다면 구버전에서 사용한 자동 생성된 값을 그대로 사용해야 한다. 기본 버전 클래스와 호환성을 끊고 싶다면 단순히 직렬 버전 UID 값을 바꿔주면 된다. 이렇게 하면 기존 버전의 직렬화된 인스턴스를 역직렬화할 때 InvalidClassException이 발생한다.

Author: Song Hayoung
Link: https://songhayoung.github.io/2020/08/25/Languages/Effective%20JAVA/item87/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.