Home [Effective Java] Item87. 커스텀 직렬화 형태를 고려해보라!
Post
Cancel

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

Item87. 커스텀 직렬화 형태를 고려해보라!

Intro

  • 개발 일정에 쫓기는 상황에서는 현재 릴리즈에서는 API 설계에 노력을 집중하여 동작만 하면 되도록 만들어 놓는 것이 나을 것이다.
  • 하지만 클래스가 Serializable을 구현하고, 기본 직렬화 형태를 사용한다면 얘기가 다르다. 현재의 릴리즈에 영원히 발을 묶이게 되기 때문이다.
  • 이러한 예로 BigInteger가 있다.

기본 직렬화 형태

  • 먼저 고민해보고 괜찮다고 판단될 때 기본 직렬화 형태를 사용하라.
    • 기본 직렬화 형태는 유연성, 성능, 정확성 측면에서 신중한 고민이 필요하다. 일반적으로 직접 설계하더라도 기본 직렬화 형태와 거의 같은 결과가 나오는 경우에만 기본 형태를 사용한다.
    • 어떤 객체의 기본 직렬화 형태는 그 객체를 루트로 하는 객체 그래프의 물리적 모습을 나름 효율적으로 인코딩한다. 즉, 객체로부터 접근할 수 있는 모든 객체를 담아내고, 심지어 이 객체들이 연결된 위상까지 기술한다. 이는 이상적인 모습이 아니다.
    • 이상적인 직렬화 형태라면 물리적인 모습과는 독립된 논리적인 모습만을 표현해야 한다.
  • 객체의 물리적 표현과 논리적 내용이 같다면 기본 직렬화 형태라도 무방하다.
    • 물리적 표현은 어떻게 코드를 구현했는지를 의미하고, 논리적 내용은 실제 그 표현의 의미를 뜻한다고 볼 수 있다.
  • 기본 직렬화 형태가 적합하다고 결정했더라도 불변식 보장, 보안을 위해 readObject 메서드를 제공해야 할 때가 많다.

물리적 표현과 논리적 표현의 차이가 클 때, 기본 직렬화 형태 사용의 문제점

  • 예시

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    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가 현재의 내부 표현 방식에 영구히 묶인다.
    • private 클래스인 StringList.Entry가 공개 API가 되어 버린다.
    • 다음 릴리즈에서 연결 리스트를 더 이상 사용하지 않더라도 관련 코드 삭제는 불가능하게 된다.
  2. 너무 많은 공간을 차지할 수 있다.
    • 사실 엔트라와 연결 정보는 내부 구현에 해당하니 직렬화 형태에 포함될 가치가 없다. 하지만 포함된다.
    • 이처럼 직렬화 형태가 너무 커져서 디스크에 저장하거나 네트워크로 전송하는 속도가 느려진다.
  3. 시간이 너무 많이 걸릴 수 있다.
    • 직렬화 로직은 객체 그래프의 위상 정보가 없으니, 직접 순회해야 한다.
  4. 스택 오버플로를 일으킬 수 있다.
    • 기본 직렬화 과정은 객체 그래프를 재귀 순회한다. 이 작업은 중간 정도 크기의 객체 그래프에서도 스택 오버플로를 일으킬 수 있다.
    • StringList에 원소를 1000~1800개 정도 담으면, 직렬화 과정에서 StackOverflowError가 뜬다.

합리적인 직렬화 형태

  • 단순히 리스트가 포함한 문자열의 개수를 적은 다음, 그 뒤로 문자열들을 나열하는 수준이면 될 것이다.
  • StringList의 물리적인 상세 표현은 배제한 채 논리적인 구성만 담는 것이다.
  • transient 한정자는 일시적이란 뜻으로, 해당 인스턴스 필드가 기본 직렬화 형태에 포함되지 않는다는 표시를 한다.
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
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());
        }
    }
    // ... 생략
}
  • writeObject, readObject를 private으로 선언해 하위 클래스에서 오버라이드하지 못하게 하고 있다.
  • 직렬화 명세는 transient더라도 defaultWriteObject, defaultReadObject를 호출하라 요구하고 있다.
    • 이렇게 해야 향후 릴리즈에서 transient가 아닌 인스턴스 필드가 추가되더라도 상호(상위, 하위 모두) 호환되기 때문이다.
    • 이렇게 하지 않으면 신버전 인스턴스를 직렬화한 후 구버전으로 역직렬화하면 새로 추가된 필드들은 무시될 것이다.
  • 기본 직렬화를 수용하든 하지 않든 defaultWriteObject 메서드를 호출하면 transient로 선언하지 않은 모든 인스턴스 필드가 직렬화된다. 따라서 transient로 선언해도 되는 인스턴스 필드에는 모두 transient 한정자를 붙여야 한다.
    • 해당 객체의 논리적 상태와 무관한 필드라고 확신할 때만 transient 한정자를 생략해야 한다.
  • 기본 직렬화 사용 시 transient 필드들은 역직렬될 때 기본값으로 초기화된다. 객체 참조 필드는 null, 숫자 기본 타입 필드는 0, boolean 필드는 false로 초기화되니, 기본값이 아닌 다른 값을 사용해야 한다면 호출 후 원하는 값으로 복원하자.

직렬화와 동기화

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

    • 모든 메서드를 synchronized로 선언하여 스레드 안전하게 만든 객체에서 기본 직렬화를 사용하려면 writeObject도 synchronized로 선언해야 한다.
    1
    2
    3
    
    private synchronized void writeObject(ObjectOutputStream s) throws IOException {
    		s.defaultWriteObject();
    }
    

Serial Version UID

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

    • 이렇게 하면 직렬 버전 UID가 일으키는 잠재적 호환성 문제가 사라진다.
    1
    
    private static final long serialVersionUID = <무작위로 고른 long value>;
    
  • 기본 버전 클래스와의 호환성을 끊고 싶다면 단순히 직렬 버전 UID 값을 바꿔주면 된다.

    • 이렇게 하면 기존 버전의 직렬화된 인스턴스를 역직렬화 시 InvalidClassException이 던져질 것이다.
  • 구버전으로 직렬화된 인스턴스들과의 호환성을 끊으려는 경우를 제외하고는 직렬 버전 UID를 절대 수정하지 말자.

핵심 정리

  • 클래스를 직렬화하기로 했다면 어떤 직렬화 형태를 사용할지 심사숙고하자.
  • 기본 직렬화 형태는 객체 직렬화 결과가 해당 객체의 논리적 표현에 부합할 때만 사용하고, 그렇지 않으면 커스텀 직렬화 형태를 고려하자.
  • 직렬화 형태도 공개 메서드를 설계할 때에 준하는 노력을 들여야 한다.
    • 한번 공개된 메스드와 마찬가지로, 직렬화 형태에 포함된 필드도 마음대로 제거하는 것이 불가하다. 직렬화 호환성을 위해 영원히 지원해야 한다.
  • 잘못된 직렬화 형태를 선택하면 클래스의 복잡성과 성능에 영구히 부정적인 영향을 끼친다.
This post is licensed under younghwani by the author.

[Effective Java] Item86. Serializable을 구현할지는 신중히 결정하라!

[Effective Java] Item88. readObject 메서드는 방어적으로 작성하라!

Comments powered by Disqus.