개발 일정에 쫓기는 상황이라면 세부적인 구현에 집중하기보다는 이번 릴리즈에 대충 동작은 하도록 API 설계에 노력을 집중하는 편이 낫습니다.
하지만 클래스가 Serializable 구현체이고 기본 직렬화 형태를 사용한다면 기본 직렬화 형태를 버릴 수 없게 되기 때문에 다음 릴리즈 때 버리려 한 대충 동작하는 현재의 구현에 영원히 발이 묶이게 됩니다.
먼저 고민해보고 괜찮다고 판단될 경우에만 기본 직렬화 형태를 사용하자
- 기본 직렬화 형태는 유연성, 성능, 정확성 측면에서 신중히 고민한 후 합당할 때만 사용하는 것을 권장
- 직접 설계하더라도 기본 직렬화 형태와 거의 같은 결과가 나올 경우에만 기본 형태를 사용
- 기본 직렬화 형태는 해당 객체를 루트로 하는 객체 그래프의 물리적 모습을 나름 효율적으로 인코딩한 형태
- 객체가 포함한 데이터들과 해당 객체에서부터 시작해 접근할 수 있는 모든 객체를 담아내며 객체들이 연결된 위상(topology)까지 기술해야 함
- 하지만 이상적인 직렬화 형태는 물리적인 모습과 독립된 논리적인 모습만을 표현해야 함
객체의 물리적 표현과 논리적 내용이 일치한다면 기본 직렬화 형태라도 무방
부연 설명
- 기본 직렬화 형태가 적합하다고 결정했더라도 불변식 보장과 보안을 위해 readObject 메서드를 제공해야 할 때가 많음
- 이에 따라 Name 클래스의 3 개의 필드는 private임에도 불구하고 문서화 주석이 달려있음
- 해당 필드들은 결국 클래스의 직렬화 형태에 포함되는 공개 API에 속하며 공개 API는 모두 문서화해야 하기 때문
- 역직렬화할 때 private 필드도 공개되버림
- private 필드의 설명을 API 문서에 포함하라고 Java Doc에 알려주는 역할은 @serial 태그
- @serial 태그로 기술한 내용은 API 문서에서 Serialized Form 페이지에 기록
기본 직렬화 형태에 적합하지 않은 클래스 예시
부연 설명
- StringList 클래스는 논리적으로 일련의 문자열을 표현
- 물리적으로는 문자열을 doubly linked list로 연결함
- 객체의 물리적 표현과 논리적 내용이 일치하지 않는 케이스
- 해당 클래스에 기본 직렬화 형태를 적용하면 각 노드의 양방향 연결 정보를 포함해 모든 엔트리를 철두철미하게 기록
객체의 물리적 표현과 논리적 표현의 괴리가 클 때 기본 직렬화 형태를 적용하면 발생하는 문제
1. 공개 API가 현재의 내부 표현 방식에 영구히 종속됨
- StringList 클래스의 private 정적 내부 클래스인 StringList.Entry가 공개 API가 되어버림
- 다음 릴리즈에서 내부 표현 방식을 바꾸고 싶더라도 하위 호환성을 지원해야 하기 때문에 StringList 클래스는 여전히 이중 연결 리스트로 표현된 입력도 처리할 수 있어야 함
- 정리하자면 이중 연결 리스트를 더 이상 사용하지 않더라도 관련 코드를 제거할 수 없음
2. 공간복잡도가 올라감
- StringList의 직렬화 형태는 이중 연결 리스트의 모든 엔트리와 연결 정보까지 기록
- 엔트리와 연결 정보는 내부 구현에 해당하므로 직렬화 형태에 포함할 필요는 없음
- 이처럼 직렬화 형태가 너무 커져 디스크에 저장하거나 네트워크로 전송할 때 속도가 느려질 수 있음
3. 시간복잡도 또한 올라감
- 직렬화 로직은 객체 그래프의 위상에 관한 정보가 없으므로 그래프를 직접 순회해 보는 수밖에 없음
- 하지만 StringList의 경우 간단히 다음 Entry의 참조를 따라가 보는 정도로 충분함
4. Stackoverflow 에러 발생 가능
- 기본 직렬화 과정은 객체 그래프를 재귀 순회하는데 중간 정도 크기의 객체 그래프에서도 Stackoverflow 에러 발생 가능
- 스택오버플로우 발생 여부가 플랫폼마다 상이할 수 있기 때문에 안전성 떨어짐
합리적인 커스텀 직렬화 형태를 적용한 StringList
부연 설명
- transient는 Serialize 하는 과정에 제외하고 싶은 경우 선언하는 키워드
- StringList의 모든 필드에 transient 키워드가 부여돼도 writeObject()와 readObject() 메서드는 각각 먼저 defaultWriteObject()와 defaultReadObject() 메서드를 호출
- 클래스의 인스턴스가 모두 transient더라도 향후 릴리즈에서 transient가 아닌 필드가 추가되더라도 호환 가능하도록 defaultWriteObject()와 defaultReadObject()를 호출해야 함
- 새로운 버전 인스턴스를 직렬화한 후 구버전으로 역직렬화할 경우 새로 추가된 필드들은 무시될 것이기 때문에 호환성 보장
- 구버전 readObject() 메서드에서 defaultReadObject()를 호출하지 않은 상태에서 역직렬화를 시도하면 StreamCorruptedException 예외 발생할 것
- writeObject()는 private 메서드임에도 불구하고 문서화 주석이 달려있는데 이는 직렬화 형태에 해당 메서드가 공개 API에 속하고 공개 API는 모두 문서화해야 하기 때문
- 메서드에 부여된 @serialData 태그는 Java Doc 유틸리티에게 해당 내용을 Serialized Form 페이지에 추가하도록 요청
- 개선된 StringList는 원래 버전의 절반 정도의 공간 복잡도를 가지며 시간복잡도 또한 줄어들어 두 배 이상 빨라짐
- 크기의 제한이 사라졌기 때문에 Stackoverflow 에러 또한 발생하지 않음
- 객체를 직렬화한 후 역직렬화하면 기존 객체를 그 불변식까지 포함해 제대로 복원해 낸다는 점에서 커스텀 직렬화 형태가 정확하다고 볼 수 있음
- 하지만 불변식이 세부 구현에 따라 달라지는 객체의 경우 정확성이 깨질 수도 있음
객체의 불변식이 깨질 수 있는 경우에는 직렬화를 주의하자
- HashTable이 대표적인 예
- HashTable은 물리적으로는 key-value 엔트리를 담은 해시 버켓을 차례로 나열한 여태
- 어떤 엔트리를 어떤 버켓에 담을지는 key에서 구한 hashcode가 결정하는데 계산 방식이 구현에 따라 달라지거나 계산할 때마다 달라질 수 있음
- 따라서 HashTable을 직렬화한 후 역직렬화하면 불변식이 심각하게 훼손된 객체들이 생겨날 수 있음
객체의 논리적 상태와 무관한 필드라고 확신할 수 있는 경우 transient 한정자를 생략하자
- 기본 직렬화를 수용하든 하지 않든 defaultWriteObject() 메서드를 호출하면 transient로 선언하지 않은 모든 인스턴스 필드는 직렬화됨
- 따라서 transient로 선언해도 되는 인스턴스 필드에는 모두 transient를 붙이는 것을 권장
- JVM을 실행할 때마다 값이 달라지는 필드도 transient 필드를 붙여야 함
- 커스텀 직렬화 형태를 사용한다면 StringList처럼 대부분의 혹은 모든 인스턴스 필드를 transient로 선언해야 함
동기화 메커니즘을 직렬화에도 적용해야 함
- 모든 메서드를 synchronized로 선언하여 thread-safe 하게 만든 객체에서 기본 직렬화를 사용하려면 writeObject() 메서드도 다음 코드처럼 synchronized로 선언해야 함
- writeObject() 안에서 동기화하고 싶다면 클래스의 다른 부분에서 사용하는 락 순서를 동일하게 따라야 함
- 순서를 동일하게 따르지 않을 경우 데드락 상태에 빠질 수 있음
SerialVersionUID를 명시적으로 부여하자
- 어떤 직렬화 형태를 사용하든 직렬 가능 클래스에 모두 SerialVersionUID를 명시적으로 부여하는 것을 권장
- 명시적으로 부여할 경우 SerialVersionUID에 의해 발생하는 호환성 문제가 사라짐
- 명시하지 않을 경우 런타임에서 SerialVersionUID를 복잡한 연산을 통해 생성하기 때문에 명시적으로 부여할 경우 성능상에도 유리
- 기존 클래스를 구버전으로 직렬화된 인스턴스와 호환성을 유지한 채 사용하고 싶다면 기존 클래스를 구버전에서 사용한 자동 생성된 값을 그대로 사용해야 함
- 클래스의 명세가 변경될 경우 자동으로 생성된 SerialVersionUID 값이 바뀌기 때문에 주의해야 함
- SerialVersionUID가 변경될 경우 구버전과 호환이 되지 않아 역직렬화가 되지 않음
- 기존 버전의 직렬화된 인스턴스를 역직렬화할 때 InvalidClassException 예외를 던질 것
- 구버전으로 직렬화된 인스턴스들과 호환성을 끊으려는 경우를 제외하고는 SerialVersionUID를 절대 수정하면 안 됨
정리
- 클래스를 직렬화하기로 했다면 기본 직렬화 형태를 사용할지 커스텀 직렬화 형태를 사용할지 심사숙고하는 것을 권장
- 자바의 기본 직렬화 형태는 객체를 직렬화한 결과가 해당 객체의 논리적 표현에 부합할 때만 사용하고 그렇지 않으면 객체를 적절히 설명하는 커스텀 직렬화 형태를 고안해야 함
- 한번 공개된 메서드는 향후 릴리즈에서 제거할 수 없듯이 직렬화 형태에 포함된 필드도 마음대로 제거할 수 없기 때문에 직렬화 형태도 공개 메서드를 설계할 때에 준하는 시간을 들여 설계해야 함
- 자바는 기본적으로 하위 호환성을 추구하기 때문에 직렬화 호환성을 유지하기 위해 영원히 지원해야 함
- 잘 못된 직렬화 형태를 선택하면 해당 클래스의 복잡성과 성능에 영구히 부정적인 영향을 남김
참고
이펙티브 자바
반응형
'JAVA > Effective Java' 카테고리의 다른 글
[아이템 89] 인스턴스 수를 통제해야 한다면 readResolve보다는 열거 타입을 사용하라 (1) | 2024.05.18 |
---|---|
[아이템 88] readObject 메서드는 방어적으로 작성하라 (0) | 2024.05.18 |
[아이템 86] Serializable을 구현할지는 신중히 결정하라 (1) | 2024.05.12 |
[아이템 85] 자바 직렬화의 대안을 찾으라 (1) | 2024.05.12 |
[아이템 84] 프로그램의 동작을 쓰레드 스케줄러에 기대지 말라 (0) | 2024.04.22 |