DEV ℧ Developer Diary

[EffectiveJava] item87 - 커스텀 직렬화 형태를 고려해보라

개발 일정에 쫓기는 상황에서는 API 설계에 노력을 집중하고 종종 다음 릴리스에서 제대로 다시 구현하고, 이번 릴리스에서는 그냥 동작만 하도록 만드는 경우가 있다.

직렬화 형태

먼저 고민해보고 괜찮다고 판단될 때만 기본 직렬화 형태를 사용하라. 기본 직렬화 영태는 유연성, 성능, 정확성 측면에서 신중히 고민한 후 합당할 때만 사용해야 한다.

일반적으로 직접 설계하더라도 기본 직렬화 형태와 거의 같은 결과가 나올 경우에만 기본 형태를 써야 한다.

객체의 물리적 표현과 논리적 내용이 같다면 기본 직렬화 형태라도 무방하다.

기본 직렬화 형태에 적합한 예

기본 직렬화 형태에 적합한 후보

public class name implements Serializable {
    /**
     * 성. null이 아니어야 함.
     * @serial
     */
    private final String lastName;

    /**
     * 이름. null이 아니어야 함.
     * @serial
     */
    private final String finalName;

    /**
     * 중간이름. 중간이름이 없다면 null.
     * @serial
     */
    private final String middleName;

    ... // 나머지 코드는 생략
}

기본 직렬화 형태가 적합하다고 결정했더라도 불변식 보장과 보안을 위해 readObject 메서드를 제공해야 할 때가 많다.

기본 직렬화 형태에 적합하지 않은 예

기본 직렬화 형태에 적합하지 않은 클래스

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;
    }

  ... // 나머지 코드는 생략
}

이 클래스에 기본 직렬화 형태를 사용하면 각 노드의 양방향 연결 정보를 포함해 모든 Entry를 철두철미하게 기록한다.

기본 직렬화 형태를 사용할 때 문제가 생기는 예

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

  1. 공개 API가 현재의 내부 표현 방식에 영구히 묶인다.
  2. 너무 많은 공간을 차지할 수 있다.
  3. 시간이 너무 많이 걸릴 수 있다.
  4. StackOverflowError를 일으킬 수 있다.

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;
    }

    /** 지정한 문자열을 이 리스트에 추가한다. */
    private 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.defaultReadObject();
        int numElements = s.readInt();

        /** 모든 원소를 읽어 이 리스트에 삽입한다. */
        for (int i = 0; i < numElements; i++)
            add((String) s.readObject());
    }
    ... // 나머지 코드는 생략
}

StringList의 필드 모두가 transient더라도 writeObject 와 readObject는 각각 가장 먼저 defaultWriteObject와 defaultReadObject를 호출한다.
직렬화 명세는 이 작업을 무조건 하라고 요구하므로, 향후 릴리스에서 transient가 아닌 인스턴스 필드가 추가되더라도 상호 호환된다.

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

구버전 readObject 메서드에서 defaultReadObject를 호출하지 않는다면 역직렬화할 때 StreamCorruptedException이 발생할 것이다.

transient

기본 직렬화를 수용하든 하지 않든 defaultWriteObject 메서드를 호출하면 transient로 선언하지 않은 모든 인스턴스 필드가 직렬화 된다. 따라서 transient로 선언해도 되는 인스턴스 필드에는 모두 transient 한전자를 붙여야 한다.

해당 객체의 논리적 상태와 무관한 필드라고 확신할 때만 transient 한정자를 생략해야 한다.

동기화의 직렬화

기본 직렬화 사용 여부와 상관 없이 객체의 전체 상태를 읽는 메서드에 적용해야 하는 동기화 메커니즘을 직렬화에도 적용해야 한다.

예컨데 모든 메서드를 synchronized로 선언하여 스레드 안전하게 만든 객체에서 기본 직렬화를 사용하려면 writeObject도 다음 코드처럼 synchronized로 선언해야 한다.

기본 직렬화를 사용하는 동기화된 클래스를 위한 writeObject 메서드

private synchronized void writeObject(ObjectOutputSteam s) throws IOException {
    s.defaultWriteObject();
}

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

serialVersionUID

어떤 직렬화 형태를 택하든 직렬화 가능 클래스 모두에 직렬버전 UID를 명시적으로 부여하자. 이렇게하면 직렬 버전 UID가 일으키는 잠재적인 호환성 문제가 사라진다.

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

private static fina llong serialVersionUID = <무작위로 고른 long >;

기본 버전 클래스의 호환성을 끊고 싶다면 단순히 직렬 버전 UID 값을 바꿔주면 된다. 구버전으로 직렬화된 인스턴스들과의 호환성을 끊으려는 경우를 제외하고는 직렬 버전 UID를 절대 수정하지 말자.