Java ‐ Serializable을 구현할지는 신중히 결정하라[Effective Java Item 86] - thought-corner/Backend-PlayGround GitHub Wiki

  • Serializable은 Java에서 직렬화를 사용하기 위한 시작점이다. int, long같은 Primitive Type은 Java에서 기본적으로 직렬화를 지원하지만 객체의 경우 직렬화를 사용하기 위해서는 Serializable을 구현해야한다.
  • Serializable을 객체가 구현하면 해당 객체는 Java가 지원하는 직렬화 시스템의 지원을 받을 수 있다. 사용하기는 편하지만 길게 봤을 때 값비싼 일이 될 수 있기 때문이다.
  • 참고로 Serializable 객체를 직렬화할 때는 ObjectOutputStream을 사용하며 Serializable을 구현하지 않은 객체를 직렬화하면 java.io.NotSerializableException가 발생한다.

Serializable을 구현하면 릴리즈 뒤에는 수정하기 어렵다.

  • 클래스가 Serializable 인터페이스를 구현하게 되면 직렬화된 바이트 스트림 인코딩도 하나의 공개 API가 된다. 때문에 이 클래스가 널리 퍼지면 그 직렬화 형태로 영원히 지원해야한다.
  • Serializable을 구현한 순간부터 해당 객체의 직렬화 형태는 Java 직렬화 형태에 묶이는 것이 된다. 기본 직렬화 형태에서는 privatepackage-private 수준의 필드마저도 API로 공개가 된다. 즉, 정보 은닉이 깨지게 되는 셈이다.
public class Person implements Serializable {

    private final String name;
    private final int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
}
@Test
void serialize() throws IOException {
    Person person = new Person("pkch", 28);

    try (FileOutputStream fileOutputStream = new FileOutputStream(SERIALIZE_OBJECT_FILE_PATH)) {
        try (ObjectOutputStream objectOutputStream = new ObjectOutputStream(fileOutputStream)) {
            objectOutputStream.writeObject(person);
        }
    }
}
  • 객체를 ObjectOutputStream을 통해 직렬화를 한 뒤 이를 FileOutputStream을 통해 객체 내용을 저장하면 private 필드가 공개되는 것을 볼 수 있다.
@Test
void deserialize() throws IOException {
    Person person = null;

    try (FileInputStream fileInputStream = new FileInputStream(SERIALIZE_OBJECT_FILE_PATH)) {
        try (ObjectInputStream objectInputStream = new ObjectInputStream(fileInputStream)) {
            person = (Person) objectInputStream.readObject();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }

    assertThat(person.getName()).isEqualTo("pkch");
    assertThat(person.getAge()).isEqualTo(28);
}
public class Person implements Serializable {
    private final String name;
    private final int age;
    private final double height;
    private final double weight;

    public Person(String name, int age, double height, double weight) {
        this.name = name;
        this.age = age;
        this.height = height;
        this.weight = weight;
    }
}
  • 위와 같이 Person 클래스에 height, weight라는 새로운 필드가 추가된 이후 실행하면 다음과 같은 오류가 발생한다.
java.io.InvalidClassException: edu.pkch.serialize.Person; local class incompatible: stream classdesc serialVersionUID = -6765962567694553436, local class serialVersionUID = -2416939271889238383
  • 기본적으로 serialVersionUID는 정의하지 않으면 해당 객체의 hashCode를 기반으로 설정이 되는데 height, weight가 추가되면서 serialVersionUID가 바뀐 것이다.
  • 그렇기 때문에 이런 문제를 방지하기 위해서 serialVersionUID도 같이 관리해야한다.
public class Person implements Serializable {
    private static final long serialVersionUID = 1L;

    private final String name;
    private final int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

serialVersionUID의 한계

  • 앞서 말했듯이 새로운 필드가 추가/제거됨에 따라 변경사항이 발생하면 직렬화/역직렬화 과정에서 예외가 발생하게 된다.
  • 그러나 기존에 존재하던 변수 이름을 변경했을 때에는 해당 데이터가 누락된다는 문제가 있다.
  • 변수명을 변경하는 경우 값의 누락이 있을 뿐 에러는 발생하지 않는다. 그러나 기존에 존재하는 변수의 타입을 변경하면 이야기가 달라진다.
public class Person implements Serializable {
    private static final long serialVersionUID = 1L;

    private final String name;
    private final int na2;  // 기존 int, 필드명 na2

    public Person(String name, int na2) {
        this.name = name;
        this.na2 = na2;
    }
}
public class Person implements Serializable {
    private static final long serialVersionUID = 1L;

    private final String name;
    private final long age;

    public Person(String name, long age) {
        this.name = name;
        this.age = age;
    }
}
java.io.InvalidClassException: edu.pkch.serialize.Person; incompatible types for field age

버그와 보안 구멍이 생길 위험이 높아진다.

  • 객체를 생성하는 가장 기본적인 방법은 생성자를 이용하는 것이다.
  • ObjectInputStream#readObject는 객체를 만들어 낼 수 있는 메서드이다. 즉, 객체를 Serializable 인터페이스로 구현하면 생성자 이외에 객체를 생성할 수 있는 숨은 생성자를 만들게 되는 것이다.
  • 기본 역직렬화를 통해 불변식이 깨질 수 있으며, 허가되지 않은 접근에 쉽게 노출될 우려가 있다.

해당 클래스의 신버전 릴리즈 시 테스트할 것이 늘어난다.

  • Serializable의 문제점과 같이 구버전의 직렬화 형태가 신버전에서 역직렬화가 가능한지, 그 역도 가능한지 테스트해야한다.
  • 즉, 테스트의 양이 직렬화 가능 클래스 수와 릴리즈 횟수에 비례한다.
  • 릴리즈 할 때마다 반드시 양방향 직렬화/역직렬화가 가능한지 확인하고 원래의 객체를 충실히 복제가능한지를 반드시 확인해야한다.

Serializable 구현 여부에 신중할 것을 당부한다.

  • 객체를 전송할 때나 저장할 때 Java 직렬화를 사용하는 프레임워크용으로 만든 클래스라면 선택의 여지없이 Serializable 인터페이스를 구현해야할 것이다. 참고로 이 경우에 Serializable 구현 클래스에 사용되는 컴포넌트 클래스들도 모두 Serializable을 구현해야 한다.
  • 이 경우 Serializable 인터페이스 구현에 따른 이점과 비용을 생각해서 구현하는 것이 좋다. 참고로 BigInteger, Integer과 같은 값 객체나 컬렉션 객체는 Serializable 인터페이스를 구현했고 쓰레드 풀과 같이 동작을 표현하는 Serializable 인터페이스를 구현하지 않았다.

상속용으로 설계된 클래스는 Serializable을 구현하면 안되며, 인터페이스도 Serializable을 확장해선 안 된다.

  • 만약 Serializable을 확장, 구현하면 하위 클래스들이 고스란히 그 문제를 같이 가져가게 된다.
  • 상속용으로 설계된 클래스 중 Serializable 인터페이스를 구현한 대표적인 사례로 ThrowableComponent가 있다. Throwable은 RMI를 통해 클라이언트로 예외를 보내기 위해, Component는 GUI를 전송하고 저장, 복원하기 위해 구현했다.
public class Throwable implements Serializable {
    /** use serialVersionUID from JDK 1.0.2 for interoperability */
    private static final long serialVersionUID = -3042686055658047285L;

    // ...
}

직렬화와 확장이 모두 가능할 때

  • 인스턴스 필드의 값 중에 불변식을 보장해야할 것이 있다면 반드시 하위 클래스에서 finalize() 메서드를 재정의하지 못하게 해야한다.
  • finalize()를 재정의하면서 final 키워드를 붙여서 선언하는 것이다. 이렇게 하지 않으면 finalizer 공격에 취약해질 수 있다.
  • 인스턴스 필드 중 기본값 int = 0, Object = null과 같이 설정되면 위배되는 불변식이 있다면 readObjectNoData() 메서드를 반드시 추가해야 한다.
private void readObjectNoData() throws InvalidObjectException {
    throw new InvalidObjectException("스트림 데이터가 필요합니다");
}

상위 클래스에서 직렬화를 지원하지 않을 때

  • 상위 클래스에서 Serializable을 구현하지 않는다면 하나만 생각하면 된다. 상속용 클래스가 Serializable 인터페이스를 지원하지 않는 경우 하위 구현 클래스가 Serializable을 구현할 때 부담이 늘어난다.
  • 이 때, 상위 클래스에서 인자가 없는 기본 생성자를 지원하면 하위 클래스에서 간단히 Serializable 인터페이스로 직렬화를 구현할 수 있다. 만약 지원하지 않는다면 하위 클래스에서 직렬화 프록시 패턴을 사용해야 한다.

내부 클래스는 Serializable을 구현하면 안된다.

  • 내부 클래스는 바깥 인스턴스 참조와 유효 범위 안의 지역변수들을 저장하기 위해 컴파일러가 자동으로 생성한 필드가 추가된다. 익명 클래스와 지역 클래스의 이름 짓는 규칙이 언어 명세에 없기 때문에 이 필드들이 클래스 정의에 어떻게 추가되는지도 정의되지 않았다.
  • 따라서 내부 클래스 직렬화 형태는 불분명하기에 Serializable 인터페이스를 구현하면 안 된다. 정적 멤버 클래스는 Serializable 인터페이스를 구현하므로 Java 직렬화가 가능하다.