먼저 고민해 보고 괜찮다고 판단될 때만 기본 직렬화 형태를 사용하라. 기본 직렬화 형태는 유연성, 성능, 정확성 측면에서 신중히 고민한 후 합당할 때만 사용해야 한다. 직접 설계하더라도 기본 직렬화 형태와 거의 같은 결과가 나올 경우에만 기본 형태를 써야 한다.
이상적인 직렬화 형태라면 물리적인 모습과 독립된 논리적인 모습만을 표현해야 한다. 객체의 물리적 표현과 논리적 내용이 같다면 기본 직렬화 형태라도 무방하다.
public class Name implements Serializable {
/**
* 성. null이 아니어야 함.
* @serial
*/
private final String lastName;
/**
* 이름. null이 아니어야 함.
* @serial
*/
private final String firstName;
/**
* 중간이름. 중간이름이 없다면 null.
* @serial
*/
private final String middleName;
}
성명은 논리적으로 이름, 성, 중간이름이라는 3개의 문자열로 구성되며, 앞 코드들의 인스턴스 필드들은 이 논리적 구성요소를 정확히 반영했다.
기본 직렬화 형태가 적합하다고 결정했더라도 불변식 보장과 보안을 위해 readObject()
메서드를 제공해야 할 때가 많다. 앞의 Name
클래스의 경우에는 readObject()
메서드가 lastName
과 firstName
필드가 null
이 아님을 보장해야 한다.
다음 클래스는 직렬화 형태에 적합하지 않은 예로, 문자열 리스트를 표현하고 있다.
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가 현재의 내부 표현 방식에 영구히 묶인다. 앞의 예에서
private
클래스인StringList.Entry
가 공개 API가 되어 버린다. 다음 릴리스에서 내부 표현 방식을 바꾸더라도StringList
클래스는 여전히 연결 리스트로 표현된 입력도 처리할 수 있어야 한다. 즉, 연결 리스트를 더는 사용하지 않더라도 관련 코드를 제거할 수 없다. - 너무 많은 공간을 차지할 수 있다.
- 시간이 너무 많이 걸릴 수 있다. 직렬화 로직은 객체 그래프의 위상에 관한 정보가 없으니 그래프를 직접 순회해 볼 수밖에 없다.
- 스택 오버플로를 일으킬 수 있다. 기본 직렬화 과정은 객체 그래프를 재귀 순회하는데, 이 작업은 중간 정도 크기의 객체 그래프에서도 자칫 스택 오버플로를 일으킬 수 있다.
// 합리적인 커스텀 직렬화 형태를 갖춘 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) {
...
}
/**
* 이 StringList 인스턴스를 직렬화한다.
* @serialData 이 리스트의 크기(포함된 문자열의 개수)를 기록한 후,
* 이어서 모든 원소를 순서대로 기록한다.
*/
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 (i = 0; i < numElements; i++) {
add((String) s.readObject());
}
}
}
기본 직렬화를 수용하든 하지 않든 defaultWriteObject()
메서드를 호출하면 transient
로 선언하지 않은 모든 인스턴스 필드가 직렬화된다. 따라서 transient
로 선언해도 되는 인스턴스 필드에는 모두 transient
g한정자를 붙여야 한다. 캐시된 해시 값처럼 다른 필드에서 유도되는 필드도 여기 해당한다. JVM을 실행할 때마다 값이 달라지는 필드도 마찬가지인데, 네이티브 자료구조를 가리키는 long
필드가 여기 속한다. 해당 객체의 논리적 상태와 무관한 필드라고 확신할 때만 transient
한정자를 생략해야 한다. 그래서 커스텀 직렬화 형태를 사용한다면, 앞서의 StringList
예에서처럼 대부분의 (혹은 모든) 인스턴스 필드를 transient
로 선언해야 한다.
기본 직렬화를 사용한다면 transient
필드들은 역직렬화될 때 기본값으로 초기화됨을 잊어선 안 된다. 객체 참조 필드는 null
로, 숫자 기본 타입 필드는 0으로, boolean
필드는 false
로 초기화된다.
기본 직렬화 사용 여부와 상관없이 객체 전체 상태를 읽는 메서드에 적용해야 하는 동기화 메커니즘을 직렬화에도 적용해야 한다. 예컨대 모든 메서드를 synchronized
로 선언하여 스레드 안전하게 만든 객체에서 기본 직렬화를 사용하면 writeObject()
도 다음 코드처럼 synchronized
로 선언해야 한다.
private synchronized void writeObject(ObjectOutputStream s) throws IOException {
s.defaultWriteObject();
}
writeObject()
메서드 안에서 동기화하고 싶다면 클래스의 다른 부분에서 사용하는 락 순서를 똑같이 따라야 한다. 그렇지 않으면 자원 순서 교착상태에 빠질 수 있다.
어떤 직렬화 형태를 택하든 직렬화 가능 클래스 모두에 직렬 버전 UID를 명시적으로 부여한다. 이렇게 하면 직렬 버전 UID가 일으키는 잠재적인 호환성 문제가 사라진다. 성능도 조금 빨라지는데, 직렬 버전 UID를 명시하지 않으면 런타임에 이 값을 생성하느라 복잡한 연산을 수행하기 때문이다.
직렬 버전 UID가 없는 기존 클래스를 구버전으로 직렬화된 인스턴스와 호환성을 유지한 채 수정하고 싶다면, 구버전에서 사용한 자동 생성된 값을 그대로 사용해야 한다. 이 값은 직렬화된 인스턴스가 존재하는 구버전 클래스를 serialver
유틸리티에 입력으로 주어 실행하면 얻을 수 있다.
기존 버전 클래스와의 호환성을 끊고 싶다면 단순히 직렬 버전 UID의 값을 바꿔 주면 된다. 이렇게 하면 기존 버전의 직렬화된 인스턴스를 역직렬화할 때 InvalidClassException
이 던져질 것이다. 구버전으로 직렬화된 인스턴스들과의 호환성을 끊으려는 경우를 제외하고는 직렬 버전 UID를 절대 수정하지 말아야 한다.