요약
재3자가 확장할 수 없는 클래스라면 가능한한 직렬화 프록시 패턴을 사용하자
- 직렬화 프록시 패턴 사용 불가능한 경우
- 클라이언트가 클래스 확장할 수 있는 경우
- 객체 그래프에 순환이 있는 클래스
직렬화 프록시 패턴을 사용하면 쉽게 중요한 불변식을 안정적으로 직렬화 할 수 있을 것이다.
1. 직렬화 프록시 패턴(Serialization Proxy Pattern)
Serializable을 구현하기로 했다면, 일반적인 인스턴스 생성 방법인 생성자 이외의 방법으로 인스턴스를 생성할 수 있게 된다. 이는 버그와 보안 문제가 커질 가능성이 있다. 따라서 직렬화로 인해 발생하는 버그/보안 문제를 직렬화 프록시 패턴으로 해결할 수 있다.
1-1. 직렬화 프록시 패턴 구현하기
바깥 클래스의 논리적 상태를 정밀하게 표현하는 중첩 클래스를 설계해 private static으로 선언한다. 바깥 클래스와 직렬화 프록시 모두 implements Serializable 해야 한다.
- 직렬화 프록시의 생성자 특징
- 단 1개여야 한다
- 바깥 클래스를 매개변수로 받아야 한다
- 인수로 넘어온 인스턴스의 데이터를 복사하는 형태
- 일관성 검사나 방어적 복사가 필요없다
1-2. 직렬화 프록시 패턴 예시
- 직렬화 프록시 - SerializationProxy 클래스
public class SerializationProxy implements Serializable {
private static final long serialVersionUID = 234209470239740923L; // 아무 값
private final Date start;
private final Date end;
public SerializationProxy(Period period) {
this.start = period.start;
this.end = period.end;
}
// Period.SerializationProxy 용 메서드 - readResolve()
private Object readResolve() {
return new Period(start, end);
}
}
- readResolve()
- 바깥 클래스(Period)와 논리적으로 동일한 인스턴스를 반환하는 메서드
- 직렬화 프록시 → 바깥 클래스 인스턴스 변환하는 역할이다
- 생성자를 이용하지 않고도 역직렬화된 인스턴스 생성 기능을 제공한다
- 바깥 클래스 - Period 클래스
- writeReplace() : 직렬화가 이뤄지기 전에 바깥 클래스의 인스턴스를 직렬화 프록시로 반환하는 메서드이다. 이로 인해 직렬화 시스템은 바깥 클래스의 직렬화된 인스턴스 생성이 불가능하다
- readObject() : writeReplace 덕분에 바깥 클래스의 직렬화 인스턴스를 생성해낼 수 없지만, 바깥 클래스에 접근하려는 공격이 있을 수 있다. 이런 공격을 readObject()가 막아준다.
public class Period implements Serializable {
// 접근자 어떻게 해야하나?
final Date start;
final Date end;
public Period(Date start, Date end) {
this.start = start;
this.end = end;
}
// 직렬화 프록시 패턴용 메서드1
private Object writeReplace() {
return new SerializationProxy(this);
}
// 직렬화 프록시 패턴용 메서드2
private void readObject(ObjectInputStream stream) throws InvalidObjectException {
throw new InvalidObjectException("프록시가 필요합니다.");
}
}
1-3. 직렬화 프록시 패턴 장점
- (방어적 복사와의 공통점) 가짜 바이트 스트림 공격과 내부 필드 탈취 공격을 프록시 수준에서 차단
- 직렬화 프록시는 final 필드로 선언 가능하기에 바깥 클래스를 진정한 불변으로 만들 수도 있다
- 필드의 직렬화 공격 걱정이 없다
- 역직렬화 할 때 유효성 검사 수행하지 않아도 됨
- 직렬화 인스턴스 클래스 ≠ 역직렬화 인스턴스 인 경우에도 정상 작동한다 (ex. EnumSet)
1-4. EnumSet - 5번 예시
이 클래스는 public 생성자 없이 정적 팩터리들만 제공한다. 이 클래스는 열거 타입의 원소 개수에 따라 다른 클래스를 사용하는데, 원소 개수가 64개 이하면 RegularEnumSet을, 65개 이상이면 JumboSet을 사용한다.
원소 64개짜리 열거 타입을 가진 EnumSet을 직렬화한 후(RegularEnumSet), 원소 5개를 추가해 69개의 원소를 가진 EnumSet(JumboSet)을 역직렬화 한다면 어떻게 될까?
- 처음 직렬화된 인스턴스는 RegularEnumSet 인스턴스이다
- 역직렬화는 JumboEnumSet 인스턴스가 반환된다
- 이는 EnumSet이 직렬화 프록시 패턴을 사용했기 때문이다
2. 직렬화 프록시 패턴의 한계
- 클라이언트가 멋대로 확장할 수 있는 클래스에는 직렬화 프록시 패턴을 적용할 수 없다.
- 객체 그래프에 순환이 있는 클래스에도 적용할 수 없다. 이런 객체의 메서드를 직렬화 프록시의 readResolve 안에서 호출하려고 하면, ClassCastException이 발생할 것이다. 직렬화 프록시만 가졌을 뿐, 실제 객체는 아직 만들어진 것이 아니기 때문이다.
- 직렬화 프록시 패턴이 강력한 안정감을 주지만, 성능이 떨어질 수 있다.
'Dev Language > EffectiveJava' 카테고리의 다른 글
[EffectiveJava] 인스턴스 수를 통제해야 한다면 readResolve보다는 열거 타입을 사용하라 (0) | 2025.06.07 |
---|---|
[EffectiveJava] 커스텀 직렬화 형태를 고려해보라 (0) | 2025.06.01 |
[EffectiveJava] 프로그램의 동작을 스레드 스케줄러에 기대지 말라 (2) | 2025.05.25 |
[EffectiveJava] 스레드 안전성 수준을 문서화하라 (2) | 2025.05.18 |
[EffectiveJava] 스레드보다는 실행자, 태스크, 스트림을 애용하라 (0) | 2025.05.11 |