들어가며
Serializable을 구현하기로 결정한 순간 정상적인 언어 메커니즘인 생성자 이외 방법으로 인스턴스를 생성할 수 있게된다. 이는 다양한 보안, 버그 문제를 유발할 수 있는데 이를 방어하는 수단이 직렬화 프록시 패턴이다.
직렬화 프록시 패턴은 바깥 클래스의 논리적 상태를 정밀하게 포현하는 중첩 클래스를 설계해 private static으로 선언한다. 이 클래스가 프록시 역할을 한다. 중첩 클래스의 생성자는 단 하나여야 하고 바깥 클래스를 매개변수로 받아야 한다. 이 생성자는 단순히 인수로 넘어온 인스턴스의 데이터를 복사한다. 다음은 적시에 방어적 복사본을 만들라의 직렬화 프록시 코드이다.
1 | private static class SerializationProxy implements Serializable { |
그 뒤엔 바깥 클래스에 writeReplace 메소드를 추가한다.
1 | private Object writeReplace() { |
이 메소드를 통해 직렬화가 이루어 지기 전 바깥 클래스의 인스턴스를 직렬화 프록시로 변환시킨다. 이 덕분에 직렬화 시스템은 절대로 바깥 클래스의 인스턴스를 생성할 수 없다. 그 뒤엔 바깥 클래스의 readObject를 다음과 같이 작성한다.
1 | private void readObject(ObjectInputStream stream) throws InvalidObjectException { |
마지막으로 바깥 클래스와 논리적으로 동일한 인스턴스를 반환하는 readResolve 메소드를 프록시 클래스에 추가한다.
1 | //Period. SerializationProxy |
readResolve 메소드는 공개된 API만들 사용해 바깥 클래스의 인스턴스를 생성한다. 직렬화는 생성자를 이용하지 않고도 인스턴스를 생성하는 기능을 제공하는데 이 패턴은 이런 부분을 제거해준다. 즉, 일반 인스턴스를 생성하는 방법과 동일하게 생성할 수 있으므로 불변식이나 유효성 검사를 만족하는지에 대한 또 다른 수단을 강구할 필요가 없다. 또한 가짜 바이트 스트림 공격과 내부 필드 탈취 공격을 프록시 수준에서 차단해준다. 그리고 Period의 필드를 final로 지정해도 문제가 없다. 또한 역직렬화한 인스턴스와 원래의 직렬화된 인스턴스의 클래스가 달라도 정상동작한다. 비트필드 대신 EnumSet을 사용하라의 사례가 여기 해당된다. 원소 64개짜리 열거 타입을 가진 EnumSet을 직렬화한 뒤 원소를 추가하고 다시 역직렬화했을 때 RegularEnumSet이 아닌 JumboEnumSet을 생성해준다.
단 직렬화 프록시 패턴에도 한계는 있다. 첫째로 클라이언트가 확장할 수 있는 클래스에는 적용할 수 없다. 둘째로 객체 그래프에 순환이 있는 클래스에도 적용할 수 없다. 이를 시도하면 ClassCastException이 readResolve 메소드에서 발생한다. 직렬화 프록시만 가졌을 뿐 실제 객체는 아직 만들어지지 않았기 때문이다. 그리고 성능저하도 뒤따른다.