Item90. 직렬화된 인스턴스 대신 직렬화 프록시 사용을 검토하라!
Intro
- Serializable을 구현하기로 결정한 순간 생성자 이외의 방법으로 인스턴스 생성이 가능해진다.
- 이는 버그와 보안 문제가 일어날 가능성이 커진다는 뜻이다.
- 이러한 위험을 크게 줄여줄 기법이 바로 직렬화 프록시 패턴(serialization proxy pattern)이다.
직렬화 프록시 패턴
바깥 클래스의 논리적 상태를 정밀하게 표현하는 중첩 클래스를 설계해 private static으로 선언한다.
- 이 중첩 클래스가 바로 바깥 클래스의 직렬화 프록시다.
중첩 클래스의 생성자는 단 하나여야 한다. 또 바깥 클래스를 매개변수로 받아야 한다.
- 이 생성자는 단순히 인수로 넘어온 인스턴스의 데이터를 복사한다.(일관성 검사, 방어적 복사 불필요)
설계상, 직렬화 프록시의 기본 직렬화 형태는 바깥 클래스의 직렬화 형태로 쓰기에 이상적이다.
바깥 클래스와 직렬화 프록시 모두 Serializable을 구현한다고 선언해야 한다.
예시. Period 클래스
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
class Period implements Serializable { private final Date start; private final Date end; public Period(Date start, Date end) { this.start = start; this.end = end; } private static class SerializationProxy implements Serializable { private static final long serialVersionUID = 2123123123L; // 아무 값이나 상관 없음. private final Date start; private final Date end; public SerializationProxy(Period p) { this.start = p.start; this.end = p.end; } // Deserialize -> Object 생성 // 역직렬화 시 직렬화 프록시를 바깥 클래스 인스턴스로 변환 private Object readResolve() { return new Period(start, end); } } // 직렬화 프록시 패턴용 writeReplace 메서드 // 결코 바깥 클래스의 직렬화된 인스턴스를 생성해낼 수 없다. private Object writeReplace() { return new SerializationProxy(this); // SerializationProxy 인스턴스 반환 } // 직렬화 프록시 패턴용 readObject 메서드 // Period 자체의 역직렬화를 방지 -> 역직렬화 시도하면 예외 던짐 (불변식 훼손 공격 막음) private void readObject(ObjectInputStream stream) throws InvalidObjectException { throw new InvalidObjectException("프록시가 필요합니다."); } }
- readResolve 메서드는 공개된 API 만을 사용해 바깥 클래스의 인스턴스를 생성한다.
- 즉, 일반 인스턴스를 만들 때와 동일한 생성자, 정적 팩터리, 혹은 다른 메서드를 사용해 역직렬화된 인스턴스를 생성하는 것이다.
- 그렇기에 역직렬화된 인스턴스가 해당 클래스의 불변식을 만족하는지 검사할 수단을 찾을 필요가 없어진다. (그 클래스의 정적 팩터리나 생성자가 불변식을 확인해주고 인스턴스 메서드들이 불변식을 잘 지켜준다면, 따로 해줘야할 일이 없는 것이다.)
- readResolve 메서드는 공개된 API 만을 사용해 바깥 클래스의 인스턴스를 생성한다.
방어적 복사처럼, 직렬화 프록시 패턴은 가짜 바이트 스트림 공격, 내부 필드 탈취 공격을 프록시 수준에서 차단해준다.
직렬화 프록시는 Period 필드를 final로 선언해도 된다. 그러니 Period 클래스를 진정한 불변으로 만들 수 있다.
직렬화 프록시 패턴의 강점
readObject의 방어적 복사와 비교해 직렬화 프록시 패턴은 강점을 하나 가진다. 바로 역직렬화한 인스턴스와 원래의 직렬화된 인스턴스의 클래스가 달라도 정상 작동한다는 점이다.
- A class를 직렬화 -> B class로 역직렬화 -> 정상 작동
이러한 강점을 사용하는 예시. EnumSet
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
private static class SerializationProxy <E extends Enum<E>> implements java.io.Serializable { // EnumSet의 원소 타입 private final Class<E> elementType; // EnumSet 내부 원소 private final Enum<?>[] elements; SerializationProxy(EnumSet<E> set) { elementType = set.elementType; elements = set.toArray(ZERO_LENGTH_ENUM_ARRAY); } // 새롭게 원소의 크기에 맞는 EnumSet 생성 private Object readResolve() { EnumSet<E> result = EnumSet.noneOf(elementType); for (Enum<?> e : elements) result.add((E)e); return result; } private static final long serialVersionUID = 362491234563181265L; }
- EnumSet 클래스는 public 생정자 없이 정적 팩터리들만 제공한다.
- 현재의 OpenJDK를 살펴보면, 이 팩터리들은 EnumSet 인스턴스를 반환하는 것이 아니라 열거 타입의 원소 개수에 따라서 하위 클래스의 인스턴스를 반환하고 있다. (64개 이하: RegularEnumSet, 64개 초과: JumboEnumSet)
- 처음에 64개의 열거 타입을 가진 EnumSet을 직렬화하고, 5개의 원소를 추가했다 가정해보자.
- 처음 직렬화된 것은 RegularEnumSet 인스턴스다. (64 개)
- 하지만 역직렬화는 JumboEnumSet 인스턴스로 하면 좋을 것이다. (64+5 개)
- EnumSet 클래스는 public 생정자 없이 정적 팩터리들만 제공한다.
직렬화 프록시 패턴의 한계
- 클라이언트가 멋대로 확장할 수 있는 클래스에는 적용할 수 없다.
- 객체 그래프에 순환이 있는 클래스에도 적용할 수 없다.
- 이러한 객체의 메서드를 직렬화 프록시의 readResolve 안에서 호출하려고 시도하면 ClassCastException이 발생할 것이다. 직렬화 프록시만 가졌을 뿐 실제 객체는 아직 만들어진 것이 아니기 때문이다.
직렬화 프록시 사용의 대가
- 직렬화 프록시 패턴이 주는 강력함과 안전성에는 대가가 따른다. 바로 속도가 느려진다는 점이다.
핵심 정리
- 제 3자가 확장할 수 없는 클래스가면 가능한 한 직렬화 프록시 패턴을 사용하자.
- 이 패턴이 아마도 중요한 불변식을 안정적으로 직렬화해주는 가장 쉬운 방법일 것이다.
Comments powered by Disqus.