Skip to content

[아이템 87] 커스텀 직렬화 형태를 고려해보라 #89

@kihyun-yang

Description

@kihyun-yang

서론

  • Serializable을 implement 하면 직렬화가 가능한 클래스를 만들 수 있다.
  • 그렇지만, 기본 직렬화가 커버하지 못하는 것이 있어 직접 private readObject, private writeObject를 구현해야 하는 경우가 있다.
  • 먼저 고민해보고 괜찮다고 판단될 때만 기본 직렬화 형태를 사용하라.

기본 직렬화가 적합한 경우

public class Name implements Serializable {
    
    public Name(String lastName, String firstName, String middleName) {
        this.lastName = lastName;
        this.firstName = firstName;
        this.middleName = middleName;
    }
    
    /**
     * 성, null이 아니어야 한다
     * @serial 
     */
    private final String lastName;

    /**
     * 이름, null이 아니어야 한다.
     * @serial
     */
    private final String firstName;

    /**
     * 중간이름, 중간이륾이 없다면 null
     * @serial
     */
    private final String middleName;
}
  • 객체의 물리적 표현과 논리적 내용이 같다면 기본 직렬화 형태라도 무방하다.
  • @serial 태그로 기술한 내용은 API 문서에서 직렬화 형태를 설명하는 특별한 페이지에 기록된다.

직렬화가 적합하지 않은 경우

public class StringList implements Serializable {
    
    private final int size = 0;
    private Entry head = null
    
    private static class Entry implements Serializable {
        String data;
        Entry next;
        Entry previous;
    }
}
  • 논리적으로 이 클래스는 일련의 문자열을 표현하는데 물리적으로는 문자열들을 이중 연결 리스트로 연결했다.
  • 이 클래스에 기본 직렬화 형태를 사용하면 각 노드의 양방향 연결 정보를 포함해 모든 엔트리(Entry)를 철두철미하게 기록한다.
  • 객체의 물리적 표현과 논리적 표현의 차이가 클 때 기본 직렬화를 사용하면 크게 네가지 문제가 생긴다
    • 공개 API가 현재의 내부 표현 방식에 영구히 묶인다.
      • 앞의 코드에서 StringList.Entry가 공개 API가 되는데 다음 릴리즈에서 내부 표현 방식을 바꾸더라도 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;
    }
    
    // 지정한 문자열을 이 리스트에 추가한다.
    public 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());
        }
    }
}
  • readObject , wirteObject
    • 자바에서 직렬화 또는 역직렬화 과정에서 별도의 처리가 필요할 때는 readObject와 writeObject 메서드를 클래스 내부에 선언해주면 된다.
  • readObject, writeObject 메서드 각각 가장 먼저 defaultWriteObject, defaultReadObject 메서드를 호출한다.
    • 직렬화 명세에서 이 작업을 무조건 먼저 하라고 요구하고 이렇게 해야 향후 릴리즈에서 transient가 아닌 인스턴스 필드가 추가되더라도 상호(상,하위 모두) 호환되기 때문이다.
      • 신버전에서 인스턴스를 직렬화 한 후 구버전으로 역직렬화하면 새로 추가된 필드들은 무시될 것.
      • 이때, 구버전 readObject 메서드에서 defaultReadObject를 호출하지 않는다면 역직렬화 시 StreamCorruptedException이 발생할 것.
      • 기본 직렬화를 수용하든 수용하지 않든 defaultWriteObject를 호출하면 transient가 아닌 모든 필드가 직렬화 대상이다.

SerialVersionUID

private static final long serialVersionUID = 8540816269016850655L;
  • 직렬화를 할때에는 serialVersionUID를 직렬화된 데이터에 같이 저장, 직렬화된 객체를 자바 객체로 다시 읽어들일 때는 그 값을 체크하는 용도로 사용.
  • 어떤 직렬화 형태를 택하든 직렬화 가능 클래스 모두에 직렬버전 UID를 명시하자.
    • 이렇게 하면 직렬 버전 UID가 일으키는 잠재적인 호환성 문제가 사라진다.
  • 직접 작성하면 성능도 조금 빨라지는데 직렬 버전 UID를 명시하지 않으면 런타임에 이 값을 생성하느라 복잡한 연산을 수행하기 때문이다.
    • 직접 명시하지 않으면 JVM에서 내부 알고리즘에 따라 자동으로 작성함
  • 새로운 클래스라면 어떤 long 을 선언해도 상관없다, 생각나는 값을 할당해도 된다.
  • 주의점
    • 구버전으로 직렬화된 인스턴스와 호환성을 유지한 채 신버전을 릴리즈 하려면 구버전에서 사용한 UID 값을 그대로 사용해야 한다.
    • 기본 버전 클래스와의 호환성을 끊고 싶다면 단순히 직렬 버전 UID의 값을 바꿔주면 된다.
      • 이렇게 하면 기존 버전의 직렬화된 인스턴스를 역직렬화 할 때 InvalidClassException을 던진다.
    • 구버전으로 직렬화된 인스턴스들과의 호환성을 끊으려는 경우를 제외하고는 직렬 버전 UID를 절대 수정하지 말자.

정리

  • 클래스를 직렬화하기로 했다면 어떤 직렬화 형태를 사용할지 심사숙고하자.
  • 자바의 기본 직렬화 형태는 객체를 직렬화한 결과가 해당 객체의 논리적 표현에 부합할 때만 사용하고, 그렇지 않으면 객체를 적절히 설명하는 커스텀 직렬화 형태를 고려해라.
  • 한번 공개된 메서드는 향후 릴리즈에서 제거할 수 없듯이, 직렬화 형태에 포함된 필드도 마음대로 제거할 수 없다.
    • 직렬화 호환성을 유지하기 위해 영원히 지원해야 하는 것.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions