직렬화를 지원하기란 짧게 보면 손쉬워 보이지만, 길게 보면 아주 값비싼 일이다.
📌 직렬화 단점
1️⃣ 릴리즈한 뒤에 수정하기 어렵다.
- 직렬화된 byte stream encoding(직렬화 형태)도 하나의 공개 API가 된다.
- 기본 직렬화 형태를 사용하면 private와 package-private 인스턴스 필드마저 API로 공개하는 꼴이다. (캡슐화와 정보 은닉성이 깨진다.)
- 적용 당시 클래스 내부 구형 방식에 영원히 묶이게 된다.
- 뒤늦게 클래스 내부 구현을 손보면 직렬화 형태가 달라지므로 버전 호환성이 깨진다.
- 직렬화 형태를 유지하면서 내부 표현을 바꿀 수는 있지만, 어려우면서 지저분한 혹을 남긴다.
- 직렬화 클래스를 만들 대는 길게 보고 감당할 수 있는 만큼 고품질 직렬화 형태도 주의해서 함께 설계하라. (Item 87, 90)
✒️ 직렬화가 클래스 개선을 방해하는 예
// 직렬화
void serialization() throws IOException {
Member member = new Member("jayang", 24, "대한민국");
byte[] serializedMember;
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(member);
// serializedMember -> 직렬화된 member 객체
serializedMember = baos.toByteArray();
}
}
// 바이트 배열로 생성된 직렬화 데이터를 base64로 변환
System.out.println(Base64.getEncoder().encodeToString(serializedMember));
}
// 역직렬화
void deserialization() throws IOException, ClassNotFoundException {
String base64Member = getSerializedMember(); // 앞에서 직렬화한 값을 바인딩해줘야합니다.
byte[] serializedMember = Base64.getDecoder().decode(base64Member);
Member member;
try (ByteArrayInputStream bais = new ByteArrayInputStream(serializedMember)) {
try (ObjectInputStream ois = new ObjectInputStream(bais)) {
// 역직렬화된 Member 객체를 읽어온다.
Object objectMember = ois.readObject();
member = (Member) objectMember;
}
}
assertThat(member.getName()).isEqualTo("jayang");
assertThat(member.getAge()).isEqualTo(24);
assertThat(member.getAddress()).isEqualTo("대한민국");
}
초기 릴리즈 버전에서 위의 테스트는 무사히 통과할 수 있다.
하지만, Member 클래스의 필드가 추가/수정되면 결과가 달라진다.
(새로 추가된 필드에 null이 들어가는 게 아니라, 예외가 발생하게 된다.)
public class Member implements Serializable {
private String name;
private int age;
private String address;
private String email; // 이메일 추가
...
}
- 모든 직렬화된 클래스는 고유 식별 번호를 부여받는다.
- `static final long serialVersionUID`필드로, 이 번호를 명시하지 않으면 시스템이 런타임에 암호 해시 함수(SHA-1)를 적용해 자동으로 클래스 안에 생성해 넣는다.
- 해당 값은 클래스 이름, 구현 인터페이스, 컴파일러가 자동 생성하는 것들을 포함한 대부분의 클래스 멤버들을 기반으로 생성된다.
- 자동 생성되는 값에 의존하면, 쉽게 호환성이 깨져 InvalidClassException이 발생한다.
public class Member implements Serializable {
private static final long serialVersionUID = 1L; // serialVersionUID 명시!
private String name;
private int age;
private String address;
private String email;
}
고유 식별 번호를 넣어준 후에 다시 테스트를 수행하면, 해당 값에 null이 들어가며 역직렬화에 성공한다.
그러나 만약 int 타입의 age를 long 타입으로 바꾸면, 마찬가지로 InvalidClassException이 발생한다.
이처럼 초기 버전에서 Serializable 객체를 구현하는 클래스를 이전 버전에 영향없이 수정하기란 매우 어렵다.
2️⃣ 버그와 보안 구멍이 생길 위험이 높아진다.
- 역직렬화는 일반 생성자 문제가 그대로 적용되는 숨은 생성자다.
- 언어의 기본 메커니즘을 우회하는 객체 생성 기법에 해당한다.
- 기본 역직렬화를 사용하면 불변식 깨짐과 허가되지 않은 접근에 쉽게 노출된다. (Item 88)
✒️ 예제
public class Member implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
private String address;
private String email;
public Member(String name, int age, String address) {
if(age ==26){
throw new IllegalArgumentException();
}
this.name = name;
this.age = age;
this.address = address;
}
}
- 기본 생성자 방식으로 생성하면 age가 26인 경우 예외를 던진다.
- 직렬화를 통한 객체 생성에선 불변식을 무시하고, 해당 객체에 26이 바인딩되어 생성될 수 있다.
3️⃣ 해당 클래스 신버전을 릴리즈할 때 테스트할 것이 늘어난다.
- 구버전과 신버전의 양방향 직렬화/역직렬화가 모두 성공해야 한다.
- 원래 객체를 충실히 복제해내는지 또한 확인해야 한다.
클래스를 처음 제작할 때 커스텀 클래스 형태를 잘 설계해놨다면, 이런 테스트 부담을 줄일 수 있다. (Item 87, 90)
📌 주의 사항
1️⃣ Serializable 구현 여부를 가볍게 결정하지 마라
- 역사적인 방식
- 값 클래스와 컬렉션 클래스(BigInteger, Instant 등)는 Serializable를 구현한다.
- 동작하는 객체(Thread Pool 등)는 대부분 Serializable을 구현하지 않는다.
- 객체 전송 혹은 저장할 때 Java Serialization을 사용하는 Framwork용 클래스라면 반드시 구현할 수밖에 없다.
2️⃣ 상속용으로 설계된 클래스(Item 19), 인터페이스 대부분은 Serializable을 구현하면 안 된다.
- Serializable을 구현한 클래스만 지원하는 Framwork를 사용하는 상황인 경우는 예외
- Throwable과 Component가 이에 해당하지만 거의 쓰이지 않는 방식이다.
🟡 Serializable을 구현하면서 확장이 가능한 클래스를 만들 거라면,
- finalize 메서드를 재정의하지 못하게 막아라. (Item 8)
- finalize 메서드를 재정의하여 final로 전언하면 된다.
- 인스턴스 필드 중 기본값(0, false, null)으로 초기화되면 위배되는 불변식은 readObjectNoData 메서드를 반드시 추가하라.
private void readObjectNoData() throws InvalidObjectException {
throw new InvalidObjectException("스트림 데이터가 필요합니다.");
}
해당 메서드는 기존의 직렬화 가능 클래스에 직렬화 가능 상위 클래스를 추가하는 드문 경우를 위함이다.
🟡 Serializable을 구현하지 않기로 했다면,
- 하위 클래스가 직렬화를 지원하려 할 때 부담이 늘어날 수 있다.
- 상위 클래스에서 매개변수 없는 생성자를 제공해야 한다.
- 혹은 하위 클래스에서 직렬화 프록시 패턴(Item 90)을 사용해야 한다.
3️⃣ 내부 클래스(Item 24)는 직렬화를 구현하지 말아야 한다.
- 내부 클래스에 대한 기본 직렬화 형태는 분명하지가 않다.
- 바깥 인스턴스 참조와 유효 범위 안 지역변수 값들을 저장하기 위해 컴파일러가 생성한 필드들이 자동으로 추가되는데, 이런 필드들이 어떻게 추가되는지도 정의되지 않았다.
- 정적 멤버 클래스라면 Serializable을 구현해도 된다.