Reference/Effective-Java

[Effective-Java] Chapter12 #90. 직렬화된 인스턴스 대신 직렬화 프록시 사용을 검토하라

나죽못고나강뿐 2023. 10. 7. 00:48
📌 직렬화 프록시 패턴 (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("프록시가 필요합니다.");
    }
}

 

📌 장점
  1. 가짜 바이트 스트림 공격과 내부 필드 탈취 공격을 프록시 수준에서 차단한다.
  2. Period 필드를 final로 선언 가능해지므로, Period 클래스를 진정한 불변 클래스로 만들 수 있다.
  3. 어떤 필드가 기만적 직렬화 공격 목표가 될지 고민하지 않아도 되며, 역직렬화 시 유효성 검사가 필요 없다.
  4. 역직렬화할 인스턴스와 원래 직렬화된 인스턴스의 클래스가 달라도 정상 작동한다. (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만 가졌을 뿐, 실제 객체가 만들어진 상태가 아니기 때문이다.
    • 실행 속도가 저하된다. (프록시 패턴 자체의 단점이기도 하다.)