클래스를 직렬화하기로 결정했다면 어떤 직렬화 형태를 사용할 지 신중하게 고려해야 한다.
자바 기본 직렬화 형태는 직렬화한 결과가 객체의 논리적 표현과 부합하는 경우에만 사용하고,
그 외에는 커스텀 직렬화 형태를 사용하자.

한번 직렬화 형태에 포함한 필드는 마음대로 제거할 수 없다. 즉 잘못된 직렬화 형태는 해당 클래스에 영구히 부정적인 영향을 남기므로, 커스텀 직렬화 형태를 설계할 때에는 많은 시간을 들여 신중히 설계하도록 하자.

기본 직렬화 형태를 사용하는 경우

• 객체의 기본 직렬화 형태는 그 객체가 포함한 데이터들과 그 객체에서 접근할 수 있는 모든 객체 등 다양한 데이터를 인코딩한다.
   따라서 기본 직렬화 형태는 객체의 물리적 표현과 논리적 내용이 같은 경우에만 사용하도록 하자.

• 기본 직렬화 형태를 사용해도 무방한 케이스

    ◦  논리적으로 성명은 성, 이름, 중간이름 3개의 문자열로 구성되며, 위 코드의 인스턴스 필드들은 이 논리적 구성요소를 정확히 반영함

    ◦  세 필드 모두 private 임에도 문서화 주석이 달려 있다 : 클래스의 직렬화 형태에 포함되는 공개 API는 모두 문서화 해야 하기 때문

          -  @serial : private 필드의 설명을 API 문서에 포함하라고 알려주는 역할 수행

          -  @serial 태그로 기술한 내용은 API 문서에서 직렬화 형태를 설명하는 특별한 페이지에 기록된다.

• 또한 불변식 보장과 보안을 위해 readObject 메서드가 어떤 기능을 제공해야 하는지 확인하자.

    ◦  위 Name 클래스의 경우 readObject 메서드가 lastName 과 firstName 필드가 null이 아님을 보장해 주어야 한다.


기본 직렬화 형태를 사용하면 안되는 경우

• 객체의 물리적 표현과 논리적 표현의 갭이 클 때 기본 직렬화 형태를 사용하면 크게 네가지 면에서 문제가 발생한다.

    ◦  공개 API가 현재의 내부 표현 방식에 영구히 종속되어 버린다.

    ◦  너무 많은 메모리 공간을 차지할 수 있다.

    ◦  시간이 너무 많이 소요될 수 있다.

    ◦  스택 오버플로우를 일으킬 수 있다.

• 예시와 함께 살펴보자. 아래 코드는 기본 직렬화 형태에 적합하지 않은 예시이다.

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 클래스는 여전히 연결 리스트로 표현된 입력도 처리할 수 있어야 한다.

              즉 연결 리스트를 더이상 사용하지 않게 되더라도 과거의 코드를 제거할 수 없게 된다.

 

          -  너무 많은 메모리 공간을 차지할 수 있다.

              위 코드에 직렬화를 적용하면 연결 리스트의 모든 엔트리와 연결 정보까지 기록하게 된다.

              하지만 엔트리와 연결 정보는 내부 구현에 해당하므로 직렬화 형태에 포함시킬 필요가 없다.

              그러나 이런 불필요한 부분 때문에 직렬화 형태가 너무 커져 디스크에 저장하거나 네트워크로 전송하는 속도가 느려진다.

 

          -  시간이 너무 많이 소요될 수 있다.

              문자열들의 길이가 평균 10 이라면, 이후 살펴볼 개선 버전의 StringList 직렬화 형태는 원래 버전의 절반 정도의 공간만 차지하며

              속도 역시 두배 정도 빠르다.

 

          -  스택 오버플로우를 일으킬 수 있다.

              기본 직렬화 과정은 객체 그래프를 재귀 순회하는데 이 작업은 중간 정도 크기의 객체 그래프에서도 스택 오버플로우를

              일으킬 수 있다. StringList 에 원소를 1000~1800 개 정도 담으면 직렬화 과정에서 StackOverflowError 가 발생한다.

 

• 다만 이 경우는 유연성과 성능이 떨어졌더라도 객체를 직렬화한 후 역직렬화하면 원래 객체를 그 불변식까지 포함해 제대로 복원해낸다.

• 불변식이 세부 구현에 따라 달라지는 객체에서는 이 마저 깨질 수 있다.

    ◦  예) 해시테이블 Hash Table

          -  key - valueList 의 형태로 객체를 저장하는 자료구조

          -  특정 객체가 어느 key 의 valueList 에 들어갈지는 보통 hash function에 의해 결정된다.

          -  그러나 이 hash function의 연산 방식은 구현에 따라 달라질 수 있으며 계산할 때마다 달라지기도 한다.

          -  여기에 기본 직렬화를 사용하면 심각한 버그로 이어질 수 있다 : 직렬화 할 때와 역직렬화 할 때의 hash function 동작 방식이

             다르면 객체가 훼손됨


기본 직렬화 형태 대신 커스텀 직렬화 형태를 사용하는 경우

• 위에서 살펴본 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 인스턴스를 직렬화한다.
    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()); }
    }

}

• writeObjectreadObject 가 직렬화 형태를 처리한다.

• transient 한정자는 해당 인스턴스 필드가 기본 직렬화 형태에 포함되지 않는다는 표시이다.

    ◦  이 때 StringList 의 모든 필드가 transient 더라도 writeObjectreadObject 는 각각 가장 먼저 defaultWriteObject 와

         defaultReadObject 를 호출한다.

    ◦  클래스의 인스턴스 필드가 모두 transient 이면 defaultWriteObject 와 defaultReadObject 를 호출할 필요가 없어 보이나,

         이 작업이 없으면 문제가 생긴다.

          -  향후 릴리즈에서 transient 가 아닌 인스턴스 필드가 추가되었을 때 상위와 하위 버전이 호환되지 않는다.

             신버전 인스턴스를 직렬화한 후 구버전으로 역직렬화하면 새로 추가된 필드들은 무시된다.

          -  구버전 readObject 메서드에서 defaultReadObject 를 호출하지 않으면 역직렬화할 때 StreamCorruptedException 이 발생함

• writeObject 는 private 메서드 임에도 문서화 주석이 달려 있다

    ◦  클래스의 직렬화 형태에 포함되는 공개 API는 모두 문서화 해야 하기 때문

    ◦  필드용의 @serial 태그처럼 메서드에 달린 @serialData 태그는 자바독 유틸리티에 이 내용을 직렬화 형태 페이지에 추가하도록

         요청하는 역할을 한다.


transient 한정자의 사용

• 객체의 논리적 상태와 무관한 필드라고 확신될 때에만 transient 한정자를 생략하자.

    ◦  defaultWriteObject 메서드를 호출하면 transient 로 선언하지 않은 모든 인스턴스 필드가 직렬화된다.

    ◦  transient 로 선언해도 되는 인스턴스 필드에는 모두 transient 한정자를 붙여야 한다.

          -  캐시된 해시 값 처럼 다른 필드에서 유도되는 필드

          -  JVM을 실행할 때 마다 값이 달라지는 필드 (네이티브 자료구조를 가리키는 long 필드 등)

• 커스텀 직렬화 형태를 사용한다면 StringList 예에서처럼 대부분의 인스턴스 필드를 transient 로 선언하자

• 기본 직렬화를 사용하면 transient 필드들은 역직렬화될 때 기본값으로 초기화된다.

유형 초기화 값
객체 참조 필드 null
숫자 기본 타입 필드 0
boolean 필드 false

• 기본값을 그대로 사용해서는 안 된다면

    ◦  readObject 메서드에서 defaultReadObject 를 호출한 다음, 해당 필드를 원하는 값으로 복원하자.

    ◦  그 값을 처음 사용할 때 초기화 하는 방법도 있다.


동기화 매커니즘과 직렬화

• 객체의 전체 상태를 읽는 메서드에 적용해야 하는 동기화 메커니즘은 직렬화에도 적용해야 한다.

• 모든 메서드를 synchronized 로 선언하여 thread safe 만든 객체에서 직렬화를 사용하려면 writeObject 메서드도 다음 코드처럼

    synchronized 로 선언해야 한다.

private synchronized void writeObject(ObjectOutputStream stream)
        throws IOException {
    stream.defaultWriteObject();
}

• writeObject 메서드 안에서 동기화 하고 싶다면 클래스의 다른 부분에서 사용하는 락 순서를 똑같이 따라야 한다.
    그렇지 않으면 자원 순서 교착상태(resource-ordering deadlock)에 빠질 수 있다.


직렬화와 직렬 버전 UID

• 직렬화 가능 클래스 모두에 직렬 버전 UID를 명시적으로 부여하자.

    ◦  이렇게 하면 직렬 버전 UID가 일으키는 잠재적인 호환성 문제가 사라진다.

    ◦  성능도 조금 빨라진다. 직렬 버전 UID를 명시하지 않으면 런타임에 이 값을 생성하느라 복잡한 연산을 수행하기 때문이다.

• 직렬버전 UID 선언은 각 클래스에 아래와 같은 한 줄만 추가해주면 끝이다.

// 무작위로 고른 long 값
private static final long serialVersionUID = 0204L;

• 어떤 long 값을 선택하든 상관 없다.

    ◦  클래스 일련 번호를 생성해주는 serialver 유틸리티를 사용해도 된다.

    ◦  그냥 생각나는 아무 값이나 넣어줘도 된다.

    ◦  반드시 고유한 값일 필요도 없다.

• 단, 직렬 버전 UID가 없는 기존 클래스를 구버전으로 직렬화된 인스턴스와 호환성을 유지한 채 수정하고 싶다면

    구버전에서 사용한 UID 값을 그대로 사용해야 한다.

    ◦  이 값은 직렬화된 인스턴스가 존재하는 구버전 클래스를 serialver 유틸리티에 입력으로 주어 실행하면 얻을 수 있다.

• 기본 버전 클래스와의 호환성을 끊고 싶다면 단순히 직렬 버전 UID의 값을 바꿔주면 된다.

    ◦  이렇게 하면 기존 버전의 직렬화된 인스턴스를 역직렬화할 때 InvalidClassException이 던져진다.

    ◦  단, 구버전으로 직렬화된 인스턴스들과의 호환성을 끊으려는 경우를 제외하고는 직렬 버전 UID를 절대 수정하지 말자.


참고자료

 

[이펙티브 자바 3판] 아이템 87. 커스텀 직렬화 형태를 고려해보라

[Effective Java 3th Edition] Item 85. Consider using a custom serialized form

madplay.github.io