Open
Description
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자가 확장할 수 없는 클래스라면 가능한 한 직렬화 프록시 패턴을 사용하자!
- 중요한 불변식을 안정적으로 직렬화해주는 가장 쉬운 방법 중 하나일 것