Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
JoisFe opened this issue Apr 10, 2023 Discussed in #203 · 0 comments
Assignees
Labels
12장 직렬화 이펙티브 자바 12장 (직렬화)

Comments

@JoisFe
Copy link
Member

JoisFe commented Apr 10, 2023

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자가 확장할 수 없는 클래스라면 가능한 한 직렬화 프록시 패턴을 사용하자!

  • 중요한 불변식을 안정적으로 직렬화해주는 가장 쉬운 방법 중 하나일 것
@JoisFe JoisFe added the 12장 직렬화 이펙티브 자바 12장 (직렬화) label Apr 10, 2023
@JoisFe JoisFe self-assigned this Apr 10, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
12장 직렬화 이펙티브 자바 12장 (직렬화)
Projects
None yet
Development

No branches or pull requests

1 participant