[교재 EffectiveJava] 아이템 87. 커스텀 직렬화 형태를 고려해보라

반응형
728x90
반응형

커스텀 직렬화 형태를 고려

Serializable을 구현하고 기본 직렬화 형태를 사용한다면 다음 릴리스 때 버리려한 현재의 구현에 영원히 발이 묶이게된다. 기본 직렬화 형태를 버릴 수 없게된다. 실제로도 BigInteger 같은 일부 자바 클래스가 이 문제에 시달리고있다. 

먼저 고민해보고 괜찮다고 판단될 때만 기본 직렬화 형태를 사용하라. 기본 직렬화 형태는 유연성, 성능, 정확성 측면에서 신중히 고민한 후 합당할 때만 사용해야한다. 일반적으로 직접 설계하더라도 기본 직렬화 형태와 거의 같은 결과가 나올 경우에만 기본 형태를 써야한다. 

 

 

기본 직렬화 형태

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

기본 직렬화 형태에 적합한 후보
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;
    }
    // ... 생략
}
- 논리적으로 이 클래스는 일련의 문자열을 표현한다.
- 물리적으로는 문자열들을 이중 연결 리스트로 연결했다.

이 클래스에 기본 직렬화 형태를 사용하면 각 노드의 양방향 연결 정보를 포함해 모든 엔트리(Entry)를 철두철미하게 기록한다. 즉, 기본 직렬화 형태를 사용하여 각 노드에 연결된 노드들까지 모두 표현된다는 것이다. 

 

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

 

1) 공개 API가 현재의 내부 표현 방식에 영구히 묶인다.

  • 위 예제에서 private 클래스인 Entry가 공개 API가 되어버린다.
  • 다음 릴리스에서 내부 표현 방식을 바꾸더라도 StringList 클래스는 여전히 연결 리스트로 표현된 입력도 처리할 수 있어야한다. 즉, 연결 리스트의 관련 코드를 절대 제거할 수 없다.

2) 너무 많은 공간을 차지하면서, 속도가 느려질 수 있다.

  • 앞 예의 직렬화 형태는 직렬화 형태에 포함될 가치가 없는 연결 리스트의 모든 엔트리와 연결 정보까지 기록한다. 

3) 시간이 너무 많이 걸린다.

  • 그래프를 직접 순회할 수 밖에 없으므로 시간이 걸린다.

5) 스택 오버플로를 일으킬 수 있다.

  • 기본 직렬화 과정은 객체 그래프를 재귀 순회하는데, 이 작업은 중간 정도 크기의 객체 그래프에서도 자칫 스택 오버플로를 일으킬 수 있다.

 

 

합리적인 커스텀 직렬화 형태

위 StringList 예제의 합리적인 직렬화 형태는 무엇일까? 단순히 리스트가 포함한 문자열의 개수를 적은 다음, 그 뒤로 문자열들을 나열하는 수준이면 될 것이다. 

 

transient 한정자

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

 

StringList.java
package com.java.effective.item87;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

public final class StringList2 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 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면 defaultWriteObject, defaultReadObject를 호출하지 않아도 된다고 들었을지 모르지만, 직렬화 명세는 이 작업을 무조건 하라고 요구한다. 그래야 transient가 아닌 인스턴스 필드가 추가된 다음 릴리스에서도 상호 호환되기 때문이다. 

 

 

transient 한정자 사용

defaultWriteObject 메서드를 호출하면 transient로 선언하지 않은 모든 인스턴스 필드에는 모두 transient 한정자를 붙여야한다. 캐시된 해시 값처럼 다른 필드에서 유도되는 필드도 여기 해당한다. 해당 객체의 논리적 상태와 무관한 필드라고 확신할 때만 transient 한정자를 생략해야한다. 그래서 커스텀 직렬화 형태를 사용한다면, 대부분의 (혹은 모든) 인스턴스 필드를 transient로 선언해야한다. 

 

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

만약 기본값을 그대로 사용하면 안된다면, readObject 메서드에서 defaultReadObject를 호출한 다음, 해당 필드를 원하는 값으로 복원해야한다. 혹은 그 값을 처음 사용할때 초기화하는 방법도 있다.

 

 

동기화 메커니즘 직렬화

객체의 전체 상태를 읽는 메서드에 적용해야하는 동기화 메커니즘을 직렬화에도 적용해야한다. 따라서 모든 메서드를 synchronized로 선언하여 스레드 안전하게 만든 객체에서 기본 직렬화를 사용하려면 writeObject도 다음 코드처럼 synchronized로 선언해야한다.

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

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

 

 

직렬 버전 UID를 명시적으로 부여

어떤 직렬화 형태를 택하든 직렬화 가능 클래스 모두에 직렬 버전 UID를 명시적으로 부여하자. 이렇게하면 직렬 버전 UID가 일으키는 잠재적인 호환성 문제가 사라진다.  직렬 버전 UID를 명시하지 않으면 런타임에 이 값을 생성하느라 복잡한 연산을 수행하는데, 이 수행 시간도 절약할 수 있다.

private static final long serialVersionUID = <무작위로 고른 long 값>;

직렬 버전 UID가 없는 기존 클래스를 구버전으로 직렬화된 인스턴스와 호환성을 유지한 채 수정하고 싶다면, 구 버전에서 사용한 자동 생성된 값을 그대로 사용해야한다. 이 값은 직렬화된 인스턴스가 존재하는 구버전 클래스를 serialver 유틸리티에 입력으로 주어 실행하면 얻을 수 있다. 

 

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

 

 

반응형

Designed by JB FACTORY