Search
Duplicate

커스텀 직렬화 형태를 고려해보라 Effective Java 87

태그
Effective Java
Java
Serializable
Custom
Customize
공개여부
작성일자
2021/07/25
Serializable 을 implement 하기만 하면 직렬화를 맞출 수 있다. 하지만, 기본 직렬화가 커버하지 못하는 것이 있어 직접 private readObject, private writeObject 를 구현해야 할 경우가 있다. 언제 이러한 custom 직렬화를 선택해야 하는지 알아본다.

Consider using a custom serialized form

개발 일정에 쫓기다보면 동작만 하게 하고 릴리즈 이후 리팩토링을 고려하게 된다. 하지만 직렬화를 구현했다면 다음 릴리즈에서 버리려했던 구현에 영원히 묶일 수 있게 된다.
BigInteger 가 이 문제를 겪고 있다.
먼저 고민해보고 괜찮다고 판단될 때만 기본 직렬화 형태를 사용하라.
유연성, 성능, 정확성 측면의 고민이 필요하다
우리가 직접 설계하더라도 기본 직렬화 형태와 거의 같은 결과가 나올 경우에만 기본 형태를 써야 한다.
직렬화시 그 객체를 root 로 하여 graph 식으로 위상까지 인코딩하는 클래스가 있다. (물리적)
객체가 포함한 데이터들과, 그 객체에서 시작하여 접근할 수 있는 모든 객체
이상적인 형태는 물리적 형태와 독리적인 논리적 모습을 직렬화를 통해 표현해야 한다.
객체의 물리적 표현과 논리적 내용이 같다면 기본직렬화 형태라도 무방하다

직렬화가 적합한 경우

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; }
Java
이름인 성, 이름, 중간이름(미국) 3개 문자열로 구성되며, 위 클래스의 필드들은 이 논리적 구성요소를 정확히 반영하였다.
기본 직렬화 형태가 적합하더라도 불변식 보장과 보안을 위해 readObject 를 제공해야 할 때도 있다.
여기서는 lastName, firstName 필드가 null 이 아님을 보장해야 한다.
💡
세 필드 모두 private 임에도 주석이 달려있다. 직렬화시 모두 공개되기 때문에 공개 API에 속하는 것이다. 이러한 private 필드를 javadoc 에 포함하라고 알려주는 역할은 @serial 태그가 수행한다.

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

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; } }
Java
기본 직렬화 형태에 적합하지 않다
논리적으로 이 클래스는 일련의 문자열을 표현하는데 물리적으로는 double linked list 로 연결했다.
이 클래스에 default serialization 을 적용한다면 각 노드의 양방향 연결 정보를 포함해 Entry 의 모든 정보를 철두철미하게 기록한다.
즉, 객체의 물리적 표현(double linked list)와 논리적 표현(문자열 리스트) 의 차이가 발생한다.
이러한 차이가 클 때 기본 직렬화 형태를 사용하면 다음 네 가지 형태에 문제가 있다.

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

StringList.Entry 가 공개 API 가 된다. 그러면 inner class 로 생성한 이유가 없어진다.
다음 버젼에 내부 표현을 바꿔도 StringList 는 여전히 StringList.Entry 를 처리할 수 있어야 한다.
즉, double linked list 방식을 버려도 이 코드는 남아있어야 한다.

2. 너무 많은 공간을 차지한다.

StringListEntry 는 내부 구현이다. 굳이 외부로 노출할 이유가 없다.
따라서 직렬화 대상에 들어갈 필요도 없다.
경우에 따라 직렬화를 디스크에 저장하거나 network 로 전송하는 경우 너무 많은 용량 혹은 속도가 느려진다

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

직렬화 로직엔 객체 graph 의 위상 정보가 없으니 직접 순회하는 방법밖에 없다.
StringList.Entry 에서는 참조를 따라가 보는 정도밖에 안된다

4. stack overflow 를 일으킬 수 있다.

저자 컴퓨터에선 1000~1800 개 정도의 정보를 담으면 stack overflow 가 발생한다.
문제는 최소 크기가 달라지고 플랫폼, 컴퓨터 성능에 따라 달라져 문제가 될 수 있다.
StringList 의 합리적인 직렬화 형태는 다음과 같다.
리스트가 포함 문자열의 개수를 적는다
그 뒤로 문자열을 나열하는 수준으로 변경한다
StringList 의 물리적 표현은 제거하고 논리적인 구성만 담는다.
public class StringListEnhance implements Serializable { private transient int size = 0; private transient Entry head = null; private transient Entry next = null; // 이젠 직렬화 대상이 아니다. private static class Entry { String data; Entry next; Entry previous; } public final void add(String s) { } /** * 이 {@code StringListEnhance} 인스턴스를 직렬화 한다. * * @serialData 이 리스트의 크기(포함된 문자열의 개수)를 기록한 후 * ({@code int}, 이어서 모든 원소를(각각 {@code String}) 순서대로 기록한다. * @param s * @throws IOException */ private void writeObject(ObjectOutputStream s) throws IOException { s.defaultWriteObject(); s.writeInt(size); for (Entry e = head; e != null; e = e.next) { s.writeObject(s); } } 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()); } } }
Java
커스텀 직렬화 형태를 갖춘다
만약 StringList 의 모든 필드가 transient 라 하더라도 defaultWriteObjectdefaultReadObject 를 호출한다.
transient 라고 해서 호출을 안하는 것이 아니다
이렇게 해야 향후 릴리즈에서 transient 가 아닌 instance field 가 추가 되더라도 상호 호환된다.
구버젼의 경우 defaultReadObject 를 호출하지 않는 경우 StreamCorruptedException 이 발생한다.
💡
writeObjectprivate 임에도 문서화 주석이 달려있다. 여기선 @serialData 태그를 통해 앞서 사용한 @serial 의 기능을 수행한다. javadoc 유틸에 직렬화 형태 페이지에 추가하도록 요청하는 것이다.
앞서 언급한 공간의 경우 저자의 컴퓨터에서 절반 정도의 공간을 차지하도록 개선되었다.
StringList 의 경우 객체를 직렬화 한 후 역직렬화 하면 불변식까지 포함해 제대로 복원해내서 정확하지만, 성능과 유연성 면에서 다소 떨어진다 할 수 있다.

HashTable 에서

내부적으로 Entry, EntrySet 등을 사용하지만 직렬화에 포함되지 않는다
어떤 Entry 가 어느 버킷에 담기는지는 hash code 에 결정되는데, 그 계산 방식은 구현마다 다르다
사실 계산할 때 마다 달라진다
이러한 이유로 HashTable 이 기본 직렬화를 사용한다면 불변식이 훼손된 객체들이 생길 수 있다.

defaultWriteObject

기본 직렬화를 수용하든 수용하지 않든 defaultWriteObject 를 호출하면 transient 가 아닌 모든 필드가 직렬화 대상이다.
따라서 객체와 논리적으로 무관한 필드라면 transient 를 꼭 포함시키고, 이것을 깊게 고민해보자
기본 직렬화의 경우 transient 로 선언한 필드는 역직렬화 시에 기본 값으로 결정된다.
primitive type int, double 은 0
객체 참조는 null
boolean 은 false

동기화, 객체를 읽는 매커니즘은?

기본 직렬화 사용 여부와 상관없이 객체의 전체 상태를 읽는 method 에 적용해야 하는 동기화 매커니즘을 직렬화에도 적용해야 한다.
만약 모든 필드가 sychronized 이면 writeObjectsychronized 로 선언해야 한다
private synchronized void writeObject(ObjectOutputStream e) throws IOException { e.defaultWriteObject(); }
Java
SychronizedCollection 구현
만약 writeObject 안에서 동기화를 원한다면 다른 부분에서 사용하는 lock 의 순서도 똑같이 따라야 한다.

SerialVersionUID

private static final long serialVersionUID = 3053995032091335093L;
Java
어떤 직렬화 형태를 택하든 직렬화 가능 클래스 모두에 직렬버젼 UID 를 명시하자
명시되어 있지 않으면 runtime 에서 계산하여 할당하기 때문에 성능에도 영향을 준다.
새로운 클래스라면 어떤 long 을 선언해도 상관없다, 생각나는 값을 할당해도 된다.
만약 UID 가 없이 기존의 class 와 호환성을 유지하여 수정하고 싶다면 구버젼의 자동 생성된 값을 그대로 사용해야 한다.
구버젼 클래스를 serialver 유틸리티에 입력으로 주어 실행하면 얻을 수 있다.
호환성을 끊고 새로운 버젼을 만들고 싶다면 이 UID 를 변경한다
구버젼이 UID 가 변경되었다면 InvalidClassException 이 발생된다.
구버젼과 호환을 끊고싶은게 아니라면 절대로 UID 를 수정하지 말자
💡
정리 직렬화 하기로 결정했다면 어떤 직렬화(기본, custom)를 선택할지 심사숙고 해야한다 기본 직렬화는 물리적, 논리적 표현이 일치할때만 사용하라 일치하지 않으면 커스텀 직렬화를 고안해야 한다. 일단 직렬화를 하기로 했다면 설계가 탄탄해야 하고 모든것이 API로 제공되는 것이므로 이 호환성을 영원히 지원해야 한다.

직렬화 시리즈

직렬화(Serializ), 역직렬화에 대한 개념 정리

Effective Java 86 Serializable 을 구현할지 신중히 결정하자

Effective Java 87 커스텀 직렬화 형태를 고려해보자

Effective Java 88 readObject 는 방어적으로 작성하라

TOP