JAVA/Effective Java

item 87. 커스텀 직렬화 형태를 고려해보라

Garonguri 2022. 9. 13. 02:52
728x90

 

개발 일정에 쫓기는 상황에서는 API설계에 노력을 집중하는 편이 낫다.

'애자일'하게 다음 릴리스에서 다시 구현하기로 하고, 이번 릴리스에서는 동작만 하게 만들어놓는.. 그런걸 의미한다.

 

그러나, 클래스가 Serializable을 구현하고 기본 직렬화 형태를 사용한다면, 다음 릴리스 때 버리려 한 현재의 구현에 영원히 발이 묶인다.

(ex BigInteger)

 

따라서, 먼저 고민해보고 괜찮다고 판단될 때만 기본 직렬화 형태를 사용해야 한다.

직렬화 형태는 유연성, 성능, 정확승 측면에서 고민하면 된다. 일반적으로 직접 설계했다고 하더라도 기본 직렬화 형태와 거의 같은 결과가 나올 경우에만 기본 형태를 써야 한다.

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

 

[ 기본 직렬화 형태에 적합한 후보 클래스 ] 

 

public class Name implements Serializable {
    
    private final Stirng lastName;

    private final String firstName;

    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;
    }
    ... // 나머지 코드는 생략
}

논리적으로 이 클래스는 일련의 문자열을 표현한다. 물리적으로는 문자열들을 이중 연결리스트로 연결했다.

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


객체의 물리적 표현과 논리적 표현의 차이가 클 때, 기본 직렬화를 사용하면 생기는 문제점 4가지

  1. 공개 API가 현재의 내부 표현 방식에 영원히 묶인다.
    • 앞의 예에서, StringList.Entry가 공개 API가 된다.
    • 다음 릴리스에서 내부 표현 방식을 바꾸더라도, StringList 클래스는 여전히 연결 리스트로 표현된 입력도 처리할 수 있어야 한다. 
    • 따라서, 더이상 연결 리스트를 사용하지 않더라도 앞서 만들어진 코드는 절대 지울 수 없다.
  2. 너무 많은 공간을 차지할 수 있다.
    • 앞선 직렬화 형태는 연결 리스트의 모든 엔트리와 연결 정보를 기록했다. 그런데 이는 직렬화에 포함 시킬 가치가 없다.
    • 직렬화 형태가 너무 커지면 디스크에 저장하거나 네트워크로 전송하는 속도가 느려진다.
  3. 시간이 너무 많이 걸릴 수 있다.
    • 직렬화 로직은 객체 그래프의 위상에 관한 정보가 없으니, 그래프를 직접 순회해볼 수밖에 없다.
  4. 스택 오버플로를 일으킬 수 있다.
    • 기본 직렬화 과정은 객체 그래프를 재귀 순회한다. 이 작업은 중간 정도 크기의 객체 그래프에서도 자칫 스택 오버플로를 일으킨다.

[ 합리적인 커스텀 직렬화 형태를 갖춘 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) { ... }

    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의 필드 모두가 transient더라도, writeObject와 readObject는 각각 가장 먼저 defaultWriteObject와 defaultReadObject를 호출한다.
  • 직렬화 명세에서는 이 호출 작업을 무조건 하라고 요구한다.
  • 이렇게 해야 향후 릴리스에서 transient가 아닌 인스턴스 필드가 추가되더라도 상호 호환되기 때문이다.

StringList에서도 기본 직렬화 형태는 적합하지 않았지만, 상태가 훨씬 심한 클래스들도 있다.

ex) 해시테이블 HashTable

 

해시 테이블은 물리적으로는 key-value 엔트리들을 담은 해시 버킷을 차례로 나열한 형태다.

어떤 엔트리를 어떤 버킷에 담을지는 키에서 구한 해시코드가 결정하는데, 해당 계산 방식은 구현에 따라 (계산 할 때마다) 달라진다.

따라서, 해시 테이블에 기본 직렬화를 사용하면 심각한 버그로 이어질 수 있다.


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

따라서 커스텀 직렬화 형태를 사용한다면,

StringList처럼 대부분 인스턴스 필드를 transient로 선언해야 한다.


기본 직렬화 사용 여부와 상관 없이,

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


어떤 직렬화 형태를 택하든 직렬화 가능 클래스 모두에 직렬 버전 UID를 명시적으로 부여하자.


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


핵심 정리

클래스를 직렬화하기로 했으면, 어떤 직렬화 형태를 사용할지 심사숙고 하자.
자바의 기본 직렬화 형태는 객체를 직렬화한 결과가 해당 객체의 논리적 표현에 부합할 때만 사용하고, 그렇지 않으면 객체를 적절히 설명하는 커스텀 직렬화 형태를 고안하자.
직렬화 형태도 공개 메서드를 설계할 때는 준하는 시간을 들여 설계해야 한다.
잘못된 직렬화 형태를 선택하면 해당 클래스의 복잡성과 성능에 영구히 부정적인 영향을 남긴다.
728x90