요약
클래스 직렬화 형태를 기본으로 할지, 커스텀으로 할지 신중하게 결정해라.
기본 직렬화 형태는 객체를 직렬화한 결과가 해당 객체의 논리적 표현에 부합할 때(논리적 구조 = 물리적 구조)만 사용하고, 그렇지 않으면 커스텀 직렬화 형태를 고안하라.
직렬화 형태에 포함된 필드도 릴리즈 후 1) 마음대로 제거할 수 없고 2) 추후 직렬화 호환성 유지를 위해 코드의 복잡성과 성능에 부정적인 영향을 미친다. 따라서 직렬화 필드도 신중하게 선택하도록 하자.
기본 직렬화
객체의 물리적 표현과 논리적 내용이 같은 경우에 기본 직렬화 형태를 사용해도 된다. 기본 직렬화 형태가 적합하다고 결정했다고 하더라도, 불변식 보장과 보안을 위해 readObject 메서드 제공을 권장한다.
기본 직렬화 사용 가능 예시
public class Name implements Serializable {
/**
* 성. null이 아니어야 함
* @serial
*/
private final String lastName;
/**
* 이름. null이 아니어야 함
* @serial
*/
private final String firstName;
/**
* 중간이름. 중간이름이 없다면 null
* @serial
*/
private final String middleName;
...
}
성명은 논리적으로 이름, 성, 중간이름이라는 3개의 문자열로 구성되며, 인스턴스 필드들은 이 논리적 구성요소를 정확히 반영했기에 기본 직렬화를 사용해도 무방하다.
- private 필드임에도 주석이 포함된 이유
- 위 코드의 lastName, firstName, middleName 필드는 클래스 직렬화 형태에 포함되는 공개 API에 포함되고, 공개 API는 모두 문서화 해야하기 때문이다
- @serial 태그
- 주석에 있는 이 태그는 private 필드의 설명을 API 문서에 포함하라고 자바독에 알려주는 역할을 한다
- @serial 태그로 기술한 내용은 API 문서에서 직렬화 형태를 설명하는 특별한 페이지에 기록된다
기본 직렬화 형태에 적합하지 않은 예시 - StringList
public class StringList {
private int size = 0;
private Entry head = null;
private static class Entry implements Serializable {
String data;
Entry next;
Entry previous;
}
// ...
}
- 논리적 : 일련의 문자열을 표현
- 물리적 : 문자열들을 이중 연결 리스트로 연결
- 이 클래스에 기본 직렬화 형태를 사용하면 각 노드의 양방향 연결 정보를 포함해 모든 엔트리를 철두철미하게 기록한다
발생 가능한 문제 4가지
이 코드처럼 물리적 표현과 논리적 표현의 차이가 클 때 기본 직렬화를 형태를 사용시 발생하는 문제는 다음과 같다.
- 공개 API가 현재의 내부 표현 방식에 영구히 묶인다
- private 클래스인 StringList.Entry가 공개 API가 되는데, 다음 릴리즈에서 내부 표현 방식을 바꾸더라도 StringList 클래스는 여전히 연결 리스트로 표현된 입력도 처리할 수 있어야 한다.
- 즉, 연결 리스트를 더는 사용하지 않더라도 관련 코드를 절대 제거할 수 없고, 과거 버전에 종속된 코드를 작성할 수밖에 없게 된다.
- 너무 많은 공간을 차지할 수 있다
- 위의 직렬화 형태는 내부 구현 정보인 연결 리스트의 모든 엔트리와 연결 정보까지 포함했기에 직렬화와는 관련없는 정보를 저장하는데 저장 공간을 사용하게 된다.
- 시간이 너무 많이 걸릴 수 있다
- 직렬화 로직은 그래프를 직접 순회하며 객체 그래프의 위상에 관한 정보를 얻는다. 그렇기에 많은 시간이 들 수 있다.
- 스택 오버플로우를 일으킬 수 있다
- 기본 직렬화 과정은 객체 그래프를 재귀 순회하는데, 이 작업은 중간 정도 크기의 객체 그래프에서도 자칫 스택 오버플로우를 일으킬 수 있다.
StringList 보완
위의 문제를 해결하기 위한 방법으로 다음과 같은 방식을 사용하면 된다.
- 리스트가 포함한 문자열의 개수를 적고
- 그 뒤로 문자열들을 나열한다
→ StringList의 물리적인 상세 표현은 배제한 채 논리적인 구성만 담는 것이다.
public class StringList implements Serializable {
// 모든 필드가 transient 되었다.
private transient int size = 0;
private transient Entry head = null;
private int num = 0;
// 이제는 직렬화되지 않는다.
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(); // not transient 필드 직렬화
s.writeInt(size); // 리스트 크기 s에 write
// 모든 원소를 올바른 순서로 기록한다.
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(일시적) 한정자 : 해당 인스턴스 필드가 기본 직렬화 형태에 포함되지 않는다는 표시
- readObject, writeObject : 직렬화 형태를 처리
- writeObject 주석
- private 메서드는 직렬화 형태에 포함되는 공개 API에 속하며, 공개 API는 모두 문서화 해야한다.
- @SerialData 태그
- 자바독 유틸리티에게 이 내용을 직렬화 형태 페이지에 추가하도록 요청하는 역할
transient 한정자
기본 직렬화를 수용하든, 하지 않든 defaultWriteObject 메서드를 호출하면 transient로 선언하지 않은 모든 인스턴스 필드가 직렬화된다. 따라서 해당 객체의 논리적 상태와 무관한 필드라고 확신할 때만 transient 한정자를 생략하고, transient 사용이 가능하다면 다 transient를 사용하도록 하자.
- transient 사용 예시
- 다른 필드에서 유도되는 필드(ex. 캐시된 해시값)
- JVM 실행시 마다 값이 달라지는 필드(ex. 네이티브 자료구조를 가리키는 long 필드)
- 커스텀 직렬화 형태 사용시, 위의 StringList처럼 대부분 혹은 모든 인스턴스 필드를 transient로 선언해야 한다.
- 기본 직렬화에서 transient 필드 사용시
- 해당 필드를 역직렬화 할 때, 기본값으로 초기화된다.
- 객체 참조 필드 : null
- 숫자 기본 타입 필드(int, long) : 0
- boolean : false
- 기본값으로 초기화하면 안되는 경우
- readObject()에서 defaultReadObject를 호출
- 해당 필드를 원하는 값으로 복원 or 해당 값을 처음 사용할 때 초기화
- 해당 필드를 역직렬화 할 때, 기본값으로 초기화된다.
defaultWriteObject, defaultReadObject
StringList의 모든 필드가 transient라고 해도, writeObject와 readObject는 각각 가장 먼저 defaultWriteObject, defaultReadObject를 호출한다. 이 호출은 무조건 선행되어야 하는 작업이다(직렬화 명세에서 요구하는 내용이다). 그 이유는 향후 릴리즈에서 transient가 아닌 인스턴스 필드가 추가되더라도, 상위/하위 모두 호환될 수 있기 때문이다.
해시테이블
위의 StringList의 기본 직렬화 형태는 유연성과 성능이 떨어질 뿐이지 객체를 직렬화한 후 역직렬화할 경우 원래 객체의 불변식까지 포함해 제대로 복원해낸다는 것을 보장할 수 있다. 하지만 그 불변식이 세부 구현에 따라 달라지는 객체에서는 이 정확성마저 깨질 수 있는데, 그 예시가 해시테이블이다.
해시테이블은 물리적으로는 키-값 엔트리들을 담은 해시 버킷을 차례로 나열한 형태이다. 어떤 엔트리를 어떤 버킷에 담을지는 키에서 구한 해시코드가 결정하는데, 해시코드 계산 방식은 구현에 따라 달라질 수 있다.
→ 해시테이블에 기본 직렬화를 사용하면 심각한 버그로 이어질 수 있다.
직렬버전 UID
어떤 직렬화 형태를 택하든, 직렬화 가능 클래스 모두에 직렬 버전 UID를 명시적으로 부여하자.
- 이점
- 직렬버전 UID가 일으키는 잠재적 호환성 문제가 사라짐
- 약간의 성능 향상
- 런타임에 UID를 생성하느라 복잡한 연산을 수행하기 때문
- 선언
- private static final long serialVersionUID = <무작위로 고른 long 값>;
- 직렬버전 UID 후보값
- 임의의 long 값(클래스 일련번호를 생성해주는 serialver 유틸리티, 아무값)
- 꼭 고유값이 아니어도 된다
만약 직렬 버전 UID가 없는 기존 클래스를 구버전으로 직렬화된 인스턴스와 호환성을 유지한 채 수정하고 싶다면, 구 버전에서 사용한 자동 생성값을 그대로 사용해야 한다.
기존 버전 클래스와의 호환성을 끊고 싶다면, 단순히 직렬 버전 UID의 값을 변경해주면 된다. 이렇게 하면 기존 버전의 직렬화된 인스턴스를 역직렬화할 때, InvalidClassException이 던져질 것이다.
→ 구버전으로 직렬화된 인스턴스들과의 호환성을 끊으려는 경우를 제외하고는 직렬 버전 UID를 절대 수정하지 말자.
'Dev Language > EffectiveJava' 카테고리의 다른 글
[EffectiveJava] 프로그램의 동작을 스레드 스케줄러에 기대지 말라 (2) | 2025.05.25 |
---|---|
[EffectiveJava] 스레드 안전성 수준을 문서화하라 (2) | 2025.05.18 |
[EffectiveJava] 스레드보다는 실행자, 태스크, 스트림을 애용하라 (0) | 2025.05.11 |
[Effective Java] 공유 중인 가변 데이터는 동기화해 사용하라 (0) | 2025.05.05 |
[Effective Java] 예외의 상세 메시지에 실패 관련 정보를 담으라 (0) | 2025.04.27 |