Skip to content

아이템 90. 직렬화된 인스턴스 대신 직렬화 프록시 사용을 검토하라 #206

Open
@JoisFe

Description

@JoisFe

Discussed in https://github.com/orgs/Study-2-Effective-Java/discussions/203

Originally posted by JoisFe April 9, 2023

아이템 90. 직렬화된 인스턴스 대신 직렬화 프록시 사용을 검토하라

문제점

  • Serializable을 구현하기로 결정한 순간 언어의 정상 메커니즘인 생성자 이외의 방법으로 인스턴스를 생성할 수 있음

버그와 보안 문제가 일어날 가능성이 커짐

해결책

직렬화 프록시 패턴 (serialization proxy pattern)

  • 먼저 바깥 클래스의 논리적 상태를 정밀하게 표현하는 중첩 클래스를 설계해 private static으로 선언
  • 해당 중첩 클래스가 바로 바깥 클래스의 직렬화 프록시
  • 중첩 클래스의 생성자는 단 하나여야 하며 바깥 클래스를 매개변수로 받아야 한다.
  • 이 생성자는 단순히 인수로 넘어온 인스턴스의 데이터를 복사
  • 일관성 검사나 방어적 복사도 필요 없음!
  • 설계상 직렬화 프록시의 기본 직렬화 형태는 바깥 클래스의 직렬화 형태로 쓰기에 이상적
  • 그리고 바깥 클래스와 직렬화 프록시 모두 Serializable을 구현한다고 선언해야 함
public final class Period {

    private final Date start;
    private final Date end;

    Period(Date start, Date end) {
        if (start.compareTo(end) > 0) {
            throw new IllegalArgumentException(start + "가 " + end + "보다 늦다.");
        }
        
        this.start = start;
        this.end = end;
    }
    
    public Date start() {
        return this.start;
    }
    
    public Date end() {
        return this.end;
    }

    private static class SerializationProxy implements Serializable {
        private final Date start;
        private final Date end;

        public SerializationProxy(Period p) {
            this.start = p.start;
            this.end = p.end;
        }

        // Period.SerializationProxy 용 readResolve 메서드
        private Object readResolve() {
            return new Period(this.start, this.end);
        }
    }
    
    private static final long serialVersionUID = 453452354;
    
     private static final long serialVersionUID = 453452354;

    // 직렬화 프록시 패턴용 writeReplace 메서드
    private Object writeReplace() {
        return new SerializationProxy(this);
    }

    // 직렬화 프록시 패턴용 readObject 메서드
    private Object readObject(ObjectInputStream stream) throws InvalidObjectException {
        throw new InvalidObjectException("프록시가 필요합니다.");
    }
}
  • SerializationProxy 클래스는 Period 클래스의 직렬화 프록시이다.
 // 직렬화 프록시 패턴용 writeReplace 메서드
    private Object writeReplace() {
        return new SerializationProxy(this);
    }
  • 바깥 클래스에 다음의 writeReplace 메서드를 추가
  • 해당 메서드는 범용적이므로 직렬화 프록시를 사용하는 모든 클래스에 그대로 복사해 쓰면 됨
  • 이 메서드는 자바의 직렬화 시스템이 바깥 클래스의 인스턴스 대신 SerializationProxy 인스턴스를 반환하게 하는 역할을 함
  • 달리 말하면 직렬화가 이뤄지기 전에 바깥 클래스의 인스턴스를 직렬화 프록시로 변환해줌
  • writeReplace 덕분에 직렬화 시스템은 결코 바깥 클래스의 직렬화된 인스턴스를 생성해낼 수 없음
  • 하지만 공격자는 불변식을 훼손하고자 이런 시도를 해볼 수 있음
  // 직렬화 프록시 패턴용 readObject 메서드
    private Object readObject(ObjectInputStream stream) throws InvalidObjectException {
        throw new InvalidObjectException("프록시가 필요합니다.");
    }
  • readObject 메서드를 바깥 클래스에 추가하면 이 공격을 가볍게 막아낼 수있음
   // Period.SerializationProxy 용 readResolve 메서드
    private Object readResolve() {
        return new Period(this.start, this.end);
    }
  • 바깥 클래스와 논리적으로 동일한 인스턴스를 반환하는 readResolve 메서드를 SerializationProxy 클래스에 추가
  • 이 메서드는 역직렬화 시에 직렬화 시스템이 직렬화 프록시를 다시 바깥 클래스의 인스턴스로 변환하게 해줌
  • readResolve 메서드는 공개된 API 만을 사용해 바깥 클래스의 인스턴스를 생성하는데 이 패턴이 아름다운 이유가 여기 있음
  • 직렬화는 생성자를 이용하지 않고도 인스턴스를 생성하는 기능을 제공하는 이 패턴은 직렬화의 이런 언어도단적 특성을 상당 부분 제거
  • 즉 일반 인스턴스를 만들 때와 똑같은 생성자, 정적 팩터리 혹은 다른 메서드를 사용해 역질렬화된 인스턴스를 생성하는 것
  • 따라서 역직렬화된 인스턴스가 해당 클래스의 불변식을 만족하는지 검사할 또 다른 수단을 강구하지 않아도 됨
  • 그 클래스의 정적 팩터리나 생성자가 불변식을 확인해주고 인스턴스 메서드들이 불변식을 잘 지켜준다면 따로 더 해줘야 할 일이 없음

프록시 패턴 vs 방어적 복사

  • 방어적 복사처럼 직렬화 프록시 패턴은 가짜 바이트 스트림 공격과 내부 필드 탈취 공격을 프록시 수준에서 차단해줌
  • 방어적 복사 방식과 달리 직렬화 프록시는 Period의 필드를 final로 선언해도 되므로 Period 클래스를 진정한 불변으로 만들 수 있음
  • 또한 이리저리 고민할 거리도 없어짐
  • 어떤 필드가 기만적인 직렬화 공격의 목표가 될지 고민하지 않아도 되며 역직렬화 때 유효성 검사를 수행하지 않아도 됨

직렬화 프록시 패턴이 readObject 에서의 방어적 복사보다 강력한 경우

  • 직렬화 프록시 패턴은 역직렬화환 인스턴스와 원래의 직렬화된 인스턴스의 클래스가 달라도 정상 동작
  • 실전에서 크게 쓸모가 없어 보이나 쓸모가 있음

EX) EnumSet (#91)

  • 해당 클래스는 public 생성자 없이 정적 팩터리들만 제공
  • 클라이언트 입장에서 이 팩터리들이 EnumSet 인스턴스를 반환하는 걸로 보이지만 현재의 OpenJDK를 보면 열거 타입의 크기에 따라 두 하위 클래스 중 하나의 인스턴스를 반환
  • 열거 타입의 원소가 64개 이하이면 RegularEnumSet을 사용하고 그보다 크면 JumboEnumSet을 사용

EnumSet 직렬화 프록시 패턴

  • 원소 64개 짜리 열거 타입을 가진 EnumSet을 직렬화 한 다음 원소 5개를 추가하고 역직렬화하면 어떤 일이 벌어질지 알아보자
  • 처음 직렬화된 것은 RegularEnumSet 인스턴스
  • 하지만 역직렬화는 JumboEnumSet 인스턴스로 하면 좋을 것
  • 그리고 EnumSet은 직렬화 프록시 패턴을 사용해서 실제로도 아래와 같이 동작
private static class SerializationProxy <E extends Enum<E>> implements Serializable {

        // 이 EnumSet의 원소 타입
        private final Class<E> elementType;

        // 이 EnumSet 안의 원소들
        private final Enum<?>[] elements;

        SerializationProxy(Enum<E> set) {
            this.elementType = set.elementType;
            this.elements = set.toArray(new Enum<?>[0]);
        }

        private Object readResolve() {
            EnumSet<E> result = EnumSet.noneOf(this.elementType);

            for (Enum<?> e : this.elements) {
                result.add((E) e);
            }

            return result;
        }

        private static final long serialVersionUID = 23542435L;
    }

직렬화 프록시 패턴의 한계

1. 클라이언트가 멋대로 확장할 수 있는 클래스에는 적용할 수 없음

2. 객체 그래프에 순환이 있는 클래스에도 적용할 수 없음

  • 이러한 객체의 메서드를 직렬화 프록시의 readResolve 안에서 호출하려 하면 ClassCastException 발생
  • 직렬화 프록시만 가졌을 뿐 실제 객체는 아직 만들어진 것이 아니기 때문

직렬화 프록시 패턴이 주는 대가

직렬화 프록시 패턴은 강력함과 안정성을 주지만 그만한 대가가 따름

  • 위 Period 코드가 방어적 복사에 비해 14% 정도 느려짐

정리

제 3자가 확장할 수 없는 클래스라면 가능한 한 직렬화 프록시 패턴을 사용하자!

  • 중요한 불변식을 안정적으로 직렬화해주는 가장 쉬운 방법 중 하나일 것

Metadata

Metadata

Assignees

Labels

12장 직렬화이펙티브 자바 12장 (직렬화)

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions