앞서 아이템 87, 아이템 88, 그리고 아이템 89에서 언급했던 것처럼 Serializable을 구현하기로 결정한 순간 언어의 생성자 이외의 방법으로도 인스턴스를 생성할 수 있게 됨에 따라 버그 및 보안 문제가 발생할 가능성이 커집니다.
위 문제를 해결하기 위해 커스텀 직렬화, readObject() 또는 readResolve()를 직접 구현하여 위험성을 낮출 수 있지만, 직렬화 프록시 패턴(Serialization Proxy Pattern)을 사용하여 위험을 낮출 수도 있습니다.
- 직렬화 프록시 패턴은 일반적으로 아이템 88에서 다룬 readObject()의 방어적 복사보다 강력함
직렬화 프록시 패턴
- 바깥 클래스의 논리적 상태를 정밀하게 표현하는 중첩 클래스를 설계해 private static으로 선언
- 해당 중첩 클래스가 바로 바깥 클래스의 직렬화 프록시
- 중첩 클래스의 생성자는 단 하나여야 하며 바깥 클래스를 매개변수로 받아야 함
- 해당 생성자는 단순히 인수로 넘어온 인스턴스의 데이터를 복사하며 이때 추가적인 일관성 검사나 방어적 복사 X
- 설계상 직렬화 프록시의 기본 직렬화 형태는 바깥 클래스의 직렬화 형태로 쓰기에 이상적
- 바깥 클래스와 직렬화 프록시 모두 마커 인터페이스인 Serializable을 구현한다고 선언해야 함
아이템 88에서 직렬화한 Period 클래스
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public final class Period implements Serializable { | |
private final Date start; | |
private final Date end; | |
/** | |
* @param start the beginning of the period | |
* @param end the end of the period; must not precede start | |
* @throws IllegalArgumentException if start is after end | |
* @throws NullPointerException if start or end is null | |
*/ | |
public Period(Date start, Date end) { | |
this.start = new Date(start.getTime()); | |
this.end = new Date(end.getTime()); | |
if (this.start.compareTo(this.end) > 0) { | |
throw new IllegalArgumentException( | |
start + " after " + end); | |
} | |
} | |
public Date start() { | |
return new Date(start.getTime()); | |
} | |
public Date end() { | |
return new Date(end.getTime()); | |
} | |
public String toString() { | |
return start + " - " + end; | |
} | |
// Serialization proxy for Period class | |
private static class SerializationProxy implements Serializable { | |
private final Date start; | |
private final Date end; | |
SerializationProxy(Period p) { | |
this.start = p.start; | |
this.end = p.end; | |
} | |
private static final long serialVersionUID = | |
234098243823485285L; // Any number will do (Item 87) | |
private Object readResolve() { | |
return new Period(start, end); // public 생성자를 사용한다. | |
} | |
} | |
// writeReplace method for the serialization proxy pattern | |
private Object writeReplace() { | |
return new SerializationProxy(this); | |
} | |
// readObject method for the serialization proxy pattern | |
private void readObject(ObjectInputStream stream) | |
throws InvalidObjectException { | |
throw new InvalidObjectException("Proxy required"); | |
} | |
} |
부연 설명
- Period는 아주 간단하기 때문에 직렬화 프록시도 바깥 클래스와 같은 필드로 구성
- 바깥 클래스의 writeReplace() 메서드는 범용적이니 직렬화 프록시를 사용하는 모든 클래스에 그대로 복사해 쓰면 됨
- 자바의 직렬화 시스템이 바깥 클래스의 인스턴스 대신 SerializationProxy의 인스턴스를 반환하게 하는 역할
- 직렬화가 이루어지기 전에 바깥 클래스의 인스턴스를 직렬화 프록시로 변환해줌
- writeReplace()로 인해 직렬화 시스템은 결코 바깥 클래스의 직렬화된 인스턴스를 생성해낼 수 없음
- 바깥 클래스와 논리적으로 동일한 인스턴스를 반환하는 readResolve() 메서드를 SerializationProxy 클래스에 추가
- 해당 메서드는 역직렬화 시 직렬화 시스템이 직렬화 프록시를 다시 바깥 클래스의 인스턴스로 변환하는 역할
- readResolve() 메서드는 공개된 API만을 사용해 바깥 클래스의 인스턴스를 생성
- 직렬화는 생성자를 사용하지 않고도 인스턴스를 생성하는 기능을 제공하는데 해당 패턴은 직렬화의 특성을 상당 부분 제거하여 일반 인스턴스를 만들 때와 똑같이 생성자, 정적 팩토리 혹은 다른 메서드를 사용해 역직렬화된 인스턴스를 생성
- 이에 따라 역직렬화된 인스턴스가 해당 클래스의 불변식을 만족하는지 검사할 다른 수단을 찾지 않아도 됨
직렬화 프록시 패턴의 장단점
장점
- 방어적 복사처럼 가짜 바이트 스트림 공격과 내부 필드 탈취 공격을 프록시 수준에서 차단
- 직렬화 프록시는 Period의 필드를 final로 선언해도 되므로 Period 클래스를 진정한 불변으로 만들 수 있음 (transient 필드로 선언하지 않아도 됨)
- 역직렬화할 때 유효성 검사를 수행하지 않아도 됨
- 역직렬화한 인스턴스와 원래의 직렬화된 인스턴스의 클래스가 달라도 정상 작동
단점
- 클라이언트가 입맛에 따라 확장할 수 있는 클래스에 적용할 수 없음
- 성능 저하 가능
- 객체 그래프에 순환이 있는 클래스에는 적용할 수 없음
- 이런 객체의 메서드를 직렬화 프록시의 readResolve() 내부에서 호출하려고 하면 ClassCastException 예외가 발생함
- 직렬화 프록시만 가졌을 뿐 실제 객체는 아직 만들어진 것이 아니기 때문
직렬화 프록시 적용 예 (EnumSet)

부연 설명
- public 생성자 없이 정적 팩토리들만 제공
- 클라이언트 입장에서는 이 팩토리들이 EnumSet 인스턴스를 반환하는 것으로 보이지만 현재의 OpenJDK를 보면 열거 타입의 크기에 따라 두 하위 클래스 중 하나의 인스턴스를 반환
- 열거 타입의 원소가 64개 이하면 RegularEnumSet을 사용하고 그보다 크면 JumboEnumSet을 사용
EnumSet 내 직렬화 프록시 패턴

부연 설명
- 원소 64개짜리 열거 타입을 가진 EnumSet을 직렬화한 다음 원소 5개를 추가하고 역직렬화하면
- 처음 직렬화된 것은 RegularEnumSet 인스턴스
- 하지만 역직렬화는 JumboEnumSet 인스턴스로 하면 좋을 것
- 그리고 EnumSet은 직렬화 프록시 패턴을 사용해서 실제로도 위와 같이 동작함
정리
- 제삼자가 확장할 수 없는 클래스라면 가능한 한 중요한 불변식을 안정적으로 직렬화해 주는 쉬운 방법인 직렬화 프록시 패턴을 적용하자
참고
이펙티브 자바
반응형
'JAVA > Effective Java' 카테고리의 다른 글
[아이템 89] 인스턴스 수를 통제해야 한다면 readResolve보다는 열거 타입을 사용하라 (1) | 2024.05.18 |
---|---|
[아이템 88] readObject 메서드는 방어적으로 작성하라 (0) | 2024.05.18 |
[아이템 87] 커스텀 직렬화 형태를 고려해보라 (2) | 2024.05.14 |
[아이템 86] Serializable을 구현할지는 신중히 결정하라 (1) | 2024.05.12 |
[아이템 85] 자바 직렬화의 대안을 찾으라 (1) | 2024.05.12 |