item90.직렬화된 인스턴스 대신 직렬화 프록시 사용을 검토하라
직렬화 프록시 패턴(serialization proxy pattern)
Serializable을 구현하기로 결정한 순간 언어의 정상 메커니즘인 생성자 이외의 방법으로 인스턴스를 생성할 수 있게된다. 버그와 보안 문제가 일어날 가능성이 커진다는 뜻이다. 하지만 이 위험을 크게 줄여줄 기법이 바로 직렬화 프록시 패턴이다.
직렬화 프록시 패턴은 바깥 클래스의 논리적 상태를 정밀하게 표현하는 중첩 클래스를 설계해 private static으로 선언한다.
이 중첩 클래스가 바로 바깥 클래스의 직렬화 프록시다. 중첩 클래스의 생성자는 단 하나여야 하며, 바깥 클래스를 매개변수로 받아야 한다. 이 생성자는 단순히 인수로 넘어온 인스턴스의 데이터를 복사한다. 일관성 검사나 방어적 복사도 필요없다.
설계상, 직렬화 프록시의 기본 직렬화 형태는 바깥 클래스의 직렬화 형태로 쓰기에 이상적이다. 그리고 바깥 클래스와 직렬화 프록시 모두 Serializable을 구현한다고 선언해야 한다.
writeReplace 메서드는 자바의 직렬화 시스템이 바깥 클래스의 인스턴스 대신 SerializationProxy의 인스턴스를 반환하게 하는 역할을 한다. 직렬화가 이뤄지기 전 바깥 클래스의 인스턴스를 직렬화 프록시로 변환해준다.
writeReplace 덕분에 직렬화 시스템은 결코 바깥 클래스의 직렬화된 인스턴스를 생성해낼 수 없다. 하지만 공격자는 불변식을 훼손하려고 시도할 수 있는데 readObject 메서드를 바깥 클래스에 추가하면 이 공격을 막아낼 수 있다.
마지막으로, 바깥 클래스와 논리적으로 동일한 인스턴스를 반환하는 readResolve 메서드를 SerializationProxy클래스에 추가한다. 이 메서드는 역직렬화 시 직렬화 시스템이 직렬화 프록시를 다시 바깥클래스의 인스턴스로 변환하게 해준다.
readResolve 메서드는 공개된 API만을 사용해 바깥 클래스의 인스턴스를 생성하는데, 이 패턴이 아름다운 이유는 직렬화는 생성자를 이용하지 않고도 인스턴스를 생성하는 기능을 제공하는데 이 패턴은 직렬화의 이런 언어도단적 특성을 상당 부분 제거한다. 즉, 일반 인스턴스를 만들 때와 똑같은 생성자, 정적 팩터리, 혹은 다른 메서드를 사용해 역직렬화된 인스턴스를 생성하는 것이다.
따라서 역직렬화된 인스턴스가 해당 클래스의 불변식을 만족하는지 검사할 또 다른 수단을 강구하지 않아도 된다.
방어적 복사처럼 직렬화 프록시 패턴은 가짜 바이트스트림 공격과 내부 필드 탈취 공격을 프록시 수준에서 차단해준다.
직렬화 프록시는 Period의 필드를 final로 선언해도 되므로 Period 클래스를 진정한 불변으로 만들 수 있다.
직렬화 프록시 패턴이 readObject에서의 방어적 복사보다 강력한 경우가 하나 더있다.
직렬화 프록시 패턴은 역직렬화한 인스턴스와 원래의 직렬화된 인스턴스의 클래스가 달라도 정상 작동한다.
EnumSet의 사례를 보자
이 클래스는 public 생성자 없이 정적 팩터리들만 제공한다.
클라이언트 입장에서는 이 팩터리들이 EnumSet 인스턴스를 반환하는 걸로 보이지만, 현재의 OpenJDK를 보면 열거 타입의 크기에 따라 두 하위 클래스 중 하나의 인스턴스를 반환한다.
열거 타입의 원소가 64개 이하면 RegularEnumSet을 사용하고 그보다 크면 JumboEnumSet을 사용하는 것이다.
원소 64개 짜리 열거타입을 가진 EnumSet을 직렬화한 다음 원소 5개를 추가하고 역직렬화 하면 처음 직렬화 될 때는 RegularEnumSet 인스턴스이고 역직렬화할 때는 JumboEnumSet인스턴스로 하면 좋을 것이다.
실제 EnumSet은 직렬화 프록시 패턴을 사용해서 이렇게 동작한다.
제네릭으로 직렬화하고 역직렬화해서 EnumSet 하위객체의 Enum을 가지고 있는 객체들을 다 받을 수 있다.
readResolve 메서드에서 noneOf 메서드로 객체를 생성하여 넘겨주는데 내부 코드를 보면 EnumSet 안에 원소들의 갯수로 RegularEnumSet을 넘길지 JumboEnumSet으로 넘길지 판단한다.
직렬화 프록시 패턴의 한계
- 클라이언트가 멋대로 확장할 수 있는 클래스는 메서드를 오버라이드해서 변형시킬 수 있으니 적용할 수 없다.
- 객체 그래프에 순환이 있는 클래스에도 적용할 수 없다. 이런 객체의 메서드를 직렬화 프록시의 readResolve 안에서 호출하려 하면 ClassCastException이 발생할 것이다. 직렬화 프록시만 가졌을 뿐 실제 객체는 아직 만들어지지 않아서이다.
- 방어적 복사보다 느리다.
핵심정리
제3자가 확장할 수 없는 클래스라면 가능한 한 직렬화 프록시 패턴을 사용하자. 이 패턴이 아마도 중요한 불변식을 안정적으로 직렬화해주는 가장 쉬운 방법일 것이다.