개발하는 두더지

[Effective Java 규칙75] 사용자 지정 직렬화 형식을 사용하면 좋을지 따져보라 본문

Java,Android

[Effective Java 규칙75] 사용자 지정 직렬화 형식을 사용하면 좋을지 따져보라

덜지 2018. 11. 27. 11:13

[Effective Java 규칙75] 사용자 지정 직렬화 형식을 사용하면 좋을지 따져보라

Effective Java 2/E 책과 구글링을 통해 내용을 정리하고 개인적인 견해가 포함된 글입니다.



기본 직렬화 형식에는 객체 안에 담긴 데이터와 객체를 통해 접근할 수 있는 모든 객체를 담긴 데이터가 들어있다. 즉 외부로 공개하지 않으려고 했던 private으로 선언한 필드들도 직렬화에 담긴다. 


가장 효과적인 직렬화 형식은 논리적 데이터만 들어있어야 하며, 물리적 표현과는 무관해야 한다. 그래서 기본 직렬화 형식은 그 객체의 물리적 표현이 논리적 내용과 동일할 때만 적절하다. 예를들어, 사람의 이름을 표현하는 클래스의 경우 기본 직렬화 형식을 그대로 이용해도 된다.

어떤 사람의 이름은 성, 이름, 중간 이름을 나타내는 문자열 3개로 구성되며 각 필드들은 논리적 내용을 잘 반영하고 있기 때문이다.

class Name implements Serializable {
private final String lastName;
private final String firstName;
private final String middleName;

public Name(String lastName, String firstName, String middleName) {
this.lastName = lastName;
this.firstName = firstName;
this.middleName = middleName;
}
}



기본 직렬화 형식이 적절하지 않은 예시도 있다. 아래 클래스는 문자열 리스트를 표현한다. 이중 연결 리스트로 이루어져 있으며 기본 직렬화 형태를 이용하면 모든 연결 리스트간 양방향 연결 구조가 직렬화 형식에 그대로 반영될 것이다. 

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가 현재 내부 표현 형태에 영원히 종속된다. 

2. 너무 많은 공간을 차지하는 문제가 생길 수 있다.  위 클래스의 경우 모든 연결 정보가 쓸데없이 들어가 있다. 포함시킬 필요도 없는 값이며 직렬화 결과도 너무 커져서 디스크에 저장하거나 네트워크로 전송하는 속도도 너무 느려질 것이다.

3. 너무 많은 시간을 소비하는 문제가 생길 수 있다. 기본 직렬화 로직은 객체 그래프 토폴로지 정보를 이해하지 못하므로, 많은 양의 그래프 순회를 해야 한다.

4. 스택 오버플로 문제가 생길 수 있다. 재귀적인 객체 그래프 순회를 필요로 하는데, 개수가 많아지면 스택 오버플로 문제가 발생할 수 있다.


StringList의 적절할 직렬화 형식은 리스트에 담기는 문자열의 수와 실제 문자열이다. 논리적 데이터 형태만을 나타내는 형식으로 물리적 표현 형태에 대한 세부사항은 제거된 방식으로 구현할 수 있다. 직렬화 형식을 구현하는 writeObject와 readObject 메서드가 생겼으며 transient 키워드는 클래스의 기본 직렬화 형식에 포함되지 않는 객체 필드임을 나타내기 위해 쓰였다.

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 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());
}
}

모든 객체가 transient이더라도 defaultWriteObject를 호출하면 직렬화 형식이 바뀌며, 유연성이 크게 향상된다. 나중에 비 transient 객체 필드를 추가해도 상위, 하위호환성이 유지되기 때문이다. 


직렬화 성능과 비용에 관한 내용을 보자면 문자열의 평균 길이가 10이라고 가정했을 때, 원래 버전에 비해 공간 요구량은 절반가량 줄어들었고, 직렬화 성능도 2배 이상 빠르게 측정된다.


다음 예로 해시 테이블을 보자. 해시 테이블은 물리적으로 키-값 쌍이 들어있는 해시 버킷이 쭉 나열된 것이다. 어떤 쌍이 어느 버킷에 들어가느냐는 키 값을 인자로 계산하는 해시 코드에 따라 결정되는데, 이 코드는 일반적으로 JVM 환경마다 달라질 가능성이 있다. 사실 프로그램이 실행될 때마다 달라질 수도 있다. 따라서 해시 테이블 객체에 기본 직렬화 형식을 그대로 쓰면 심각한 버그가 발생한다. 



사용자 정의 직렬화 형식을 이용할 때는 논리적 상태를 구성하는 값이라고 확신이 들지 않는다면 StringList 클래스처럼 객체 필드 대부분을 transient으로 선언해야 한다. 기본 직렬화 형식을 사용하는 경우, transient 키워드가 붙은 필드들은 역직렬화되었을 때 기본값으로 초기화 된다. 객체 참조 필드면 null을, 수치 기본 자료형이면 0으로, boolean이면 false로 초기화 된다. transient 필드에 이런 값이 할당되면 안되는 경우에는 readObject 메서드를 구현하여 transient 필드의 값을 적절하게 복구시켜주면 된다.



객체를 직렬화 할때 객체의 상태 전부를 읽는 메서드에는 동기화를 적용해야 한다. writeObject 메서드에 synchronized 키워드를 넣어주면 된다. 


어떤 직렬화 형식을 이용하건, 직렬화 가능 클래스를 구현할 때는 직렬 버전 UID를 선언해야 한다. UID 때문에 생길 수 있는 잠재적 호환성 문제도 사라지고, 성능도 조금 개선되는 효과가 있다. UID를 지정하지 않으면 실행 시간에 UID를 만드느라 시간이 많이 걸리는 계산을 하기 때문이다. 

private static final long serialVersionUID = 8157301798955170430L;

새 클래스를 만드는 경우에는 아무 값이나 넣어도된다. 클래스에 serialver 유틸리티를 돌려서 얻은 값을 사용해도 되긴하지만, UID를 명시적으로 선언하지 않은 기존 클래스를 수정하면서 이미 직렬화된 기존 객체까지 유지하고 싶다면 반드시 자동 생성된 기존 UID를 사용해야 한다.  기존 버전과 호환되지 않는 새로운 클래스를 만들어도 상관없다면 아무 값이나 쓰면 된다.


Comments