📌 직렬화 프록시 패턴 (Serialization Proxy Pattern)
- 디자인 패턴의 프록시 패턴을 응용한 것과 같다. (그래서 장/단점도 비슷하게 따라간다.)
- 실제 객체를 숨기고 대변인을 통해 직렬화/역직렬화를 수행한다.
📌 작성 순서
1️⃣ 프록시 클래스 생성자
private static class SerializationProxy implements Serializable {
public SerializationProxy(Period p) {
this.start = p.start;
this.end = p.end;
}
}
- 중첩 클래스를 private static으로 선언한다.
- 생성자는 단 하나여야 한다.
- 바깥 클래스를 매개변수로 받아야 한다.
- 인스턴스를 복사하는 역할만 수행한다.
- 일관성 검사나 방어적 복사도 필요없다. (바깥 클래스를 불변 클래스로 관리할 수 있게 되었기 때문)
2️⃣ Serializable 구현
class Period implements Serializable {
...
private static class SerializationProxy implements Serializable {
...
}
...
}
- 바깥 클래스와 직렬화 프록시 모두 Serializable을 구현한다.
3️⃣ 프록시 클래스 필드
private static class SerializationProxy implements Serializable {
private static final long serialVersionUID = 2123123123L; // 아무 값이나 상관없음
private final Date start;
private final Date end;
...
}
- 직렬화 프록시도 바깥 클래스와 완전히 같은 필드로 구성한다. (단, 이 경우엔 Period가 너무 간단한 객체라서 그렇다.)
4️⃣ 바깥 클래스 writeReplace 메서드
class Period implements Serializable {
...
// Serialize -> 프록시 인스턴스 반환
// 결코 바깥 클래스의 직렬화된 인스턴스를 생성해낼 수 없다
private Object writeReplace() {
return new SerializationProxy(this);
}
...
}
- 자바의 직렬화 시스템이 바깥 클래스 인스턴스 대신 SerializationProxy 인스턴스를 반환하게 한다.
- 직렬화 시스템은 결코 바깥 클래스의 직렬화된 인스턴스 생성이 불가능하다. (직렬화 방지)
5️⃣ 바깥 클래스 readObject 메서드
class Period implements Serializable {
...
// Period 자체의 역직렬화를 방지 -> 역직렬화 시도시, 에러 반환
private void readObject(ObjectInputStream stream) throws InvalidObjectException {
throw new InvalidObjectException("프록시가 필요합니다.");
}
}
- 혹시라도 바깥 클래스의 불변식을 훼손하려는 공격을 막아낼 수 있다. (역직렬화 방지)
6️⃣ 프록시 클래스 readResolve 메서드
private static class SerializationProxy implements Serializable {
...
// Deserialize -> Object 생성 (바깥 클래스와 논리적으로 동일한 인스턴스 반환)
private Object readResolve() {
return new Period(start, end);
}
}
- 역직렬화 시, 직렬화 프록시를 다시 바깥 클래스 인스턴스로 변환한다.
- 숨은 생성자 기능을 가진 직렬화의 특성을 상당 부분 제거한다.
- 일반 인스턴스 만들 때와 똑같이 생성자, 정적 팩터리, 혹은 다른 메서드로 역직렬화 인스턴스를 생성하면 된다.
- 역직렬화된 인스턴스가 해당 클래스 불변식을 만족하는지 검사할 수단을 강구할 필요가 없다.
- 바깥 클래스의 생성자(혹은 정적 팩터리)가 불변식을 확인하고, 인스턴스 메서드들이 불변식을 잘 지켜준다면, 따로 더 할 일이 없다.
✨ 최종 구현
class Period implements Serializable {
// 불변 가능
private final Date start;
private final Date end;
public Period(Date start, Date end) {
this.start = start;
this.end = end;
}
// 직렬화 시, Period 인스턴스를 직렬화하지 않고, SerializationProxy 인스턴스를 직렬화한다.
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);
}
}
// Serialize -> 프록시 인스턴스 반환
// 결코 바깥 클래스의 직렬화된 인스턴스를 생성해낼 수 없다
private Object writeReplace() {
return new SerializationProxy(this);
}
// Period 자체의 역직렬화를 방지 -> 역직렬화 시도시, 에러 반환
private void readObject(ObjectInputStream stream) throws InvalidObjectException {
throw new InvalidObjectException("프록시가 필요합니다.");
}
}
📌 장점
- 가짜 바이트 스트림 공격과 내부 필드 탈취 공격을 프록시 수준에서 차단한다.
- Period 필드를 final로 선언 가능해지므로, Period 클래스를 진정한 불변 클래스로 만들 수 있다.
- 어떤 필드가 기만적 직렬화 공격 목표가 될지 고민하지 않아도 되며, 역직렬화 시 유효성 검사가 필요 없다.
- 역직렬화할 인스턴스와 원래 직렬화된 인스턴스의 클래스가 달라도 정상 작동한다. (ex. EnumSet)
📌 역직렬화 인스턴스를 선택할 수 있다: EnumSet
public abstract class EnumSet<E extends Enum<E>> extends AbstractSet<E>
implements Cloneable, java.io.Serializable
{
// declare EnumSet.class serialization compatibility with JDK 8
@java.io.Serial
private static final long serialVersionUID = 1009687484059888093L;
...
public static <E extends Enum<E>> EnumSet<E> noneOf(Class<E> elementType) {
Enum<?>[] universe = getUniverse(elementType);
if (universe == null)
throw new ClassCastException(elementType + " not an enum");
if (universe.length <= 64)
return new RegularEnumSet<>(elementType, universe);
else
return new JumboEnumSet<>(elementType, universe);
...
private static class SerializationProxy<E extends Enum<E>>
implements java.io.Serializable
{
private static final Enum<?>[] ZERO_LENGTH_ENUM_ARRAY = new Enum<?>[0];
private final Class<E> elementType;
private final Enum<?>[] elements;
SerializationProxy(EnumSet<E> set) {
elementType = set.elementType;
elements = set.toArray(ZERO_LENGTH_ENUM_ARRAY);
}
@SuppressWarnings("unchecked")
@java.io.Serial
private Object readResolve() {
// instead of cast to E, we should perhaps use elementType.cast()
// to avoid injection of forged stream, but it will slow the
// implementation
EnumSet<E> result = EnumSet.noneOf(elementType);
for (Enum<?> e : elements)
result.add((E)e);
return result;
}
@java.io.Serial
private static final long serialVersionUID = 362491234563181265L;
}
@java.io.Serial
Object writeReplace() {
return new SerializationProxy<>(this);
}
@java.io.Serial
private void readObject(java.io.ObjectInputStream s)
throws java.io.InvalidObjectException {
throw new java.io.InvalidObjectException("Proxy required");
}
@java.io.Serial
private void readObjectNoData()
throws java.io.InvalidObjectException {
throw new java.io.InvalidObjectException("Proxy required");
}
}
- EnumSet은 기본적으로 원소가 64개 이하면 RegularEnumSet, 그보다 크면 JumboEnumSet을 반환하도록 내부적으로 구현되어 있다. (호출자 입장에선 차이 못 느낌)
- `원소 64개짜리 EnumSet(RegularEnumSet) → 직렬화 → 원소 5개 추가 → 역직렬화 → EnumSet(JumboEnumSet)`이 가능하다.
📌 한계점
- 클라이언트가 확장 가능한 클래스에는 적용할 수 없다. (불변이 아니므로)
- 객체 그래프에 순환이 있는 클래스에서 사용할 수 없다.
- readResolve 호출 시, 이런 객체 메서드로 인해 ClassCastException이 발생할 것이다.
- 아직 Serialization Proxy만 가졌을 뿐, 실제 객체가 만들어진 상태가 아니기 때문이다.
- 실행 속도가 저하된다. (프록시 패턴 자체의 단점이기도 하다.)