Dev/Java

[Effective Java : Item 90] 직렬화된 인스턴스 대신 직렬화 프록시 사용을 검토하라

김세진 2024. 5. 22. 15:20
반응형

 

 

 

 

개요

 

Serializable을 구현하기로 결정했다는 것은, 언어의 정상 메커니즘인 생성자 사용을 우회하여 인스턴스를 생성할 수 있게 된다는 것이다. 이로 인해, 버그와 보안 문제가 발생할 가능성이 일어날 가능성이 커지는데, 직렬화 프록시 패턴(serializable proxy pattern)을 통해 이를 크게 줄일 수 있다.

 

 

 


직렬화 프록시 패턴 사용법

 

* Proxy의 사전적 정의는 "대리", "대리인"을 의미

 

직렬화 프록시 패턴을 간략하게 요약하자면, 직렬화된 코드를 그대로 클래스의 인스턴스로 생성하는 것이 아니라, 그 클래스와 논리적 상태가 동일한 중첩 클래스를 설계해 방어적 생성을 하는 것이다.

 

 

직렬화 프록시 패턴을 구현하는 방법은 다음과 같다.

 

  1. 바깥 클래스의 논리적 상태를 정밀하게 표현하는 중첩 클래스를 private static으로 선언한다. (이 중첩 클래스가 곧 바깥 클래스의 직렬화 프록시이다.)
  2. 바깥 클래스와 중첩 클래스 모두 Serializable을 구현한다고 선언한다.
  3. 중첩 클래스의 생성자를 단 하나만 선언한다. 이 생성자는 바깥 클래스를 매개변수로 받아, 인수로 넘어온 인스턴스의 데이터를 단순히 복사하는 기능을 한다. (일관성 검사나 방어적 복사도 필요하지 않다.)
  4. 바깥 클래스에 writeReplace 메서드를 추가한다. 이 메서드는 직렬화 시스템이 바깥 클래스의 인스턴스 대신 직렬화 프록시의 인스턴스를 반환하게 한다. (코드 조각은 아래 코드에 기술)
  5. 공격자가 혹여 직렬화 프록시 패턴을 우회할 가능성을 대비해 바깥 클래스에 readObject 클래스를 추가한다. (아래 코드에 기술)
  6. 역직렬화 시 직렬화 시스템이 직렬화 프록시를 다시 바깥 클래스의 인스턴스로 변환하게 해주는 readResolve 메서드를 중첩 클래스에 추가한다.

 

Period 클래스를 예로 들면 다음과 같이 작성할 수 있다.

 

public final class Period implements Serializable {
	private final Date start;
	private final Date end;
    
    // Date 의 방어적 복사 등은 생략
	public Period(Date start, Date end) {
		this.start = start;
		this.end = end;
	}

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

	// 직렬화 프록시 패턴용
	private void readObject(ObjectInputStream stream) throws InvalidObjectException {
		throw new InvalidObjectException("프록시가 필요합니다.");
	}

	private static class SerializationProxy implements Serializable {
		private static final long serialVersionUID = 203564046815618758L; // 아무 값이나 상관 없다.
		private final Date start;
		private final Date end;

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

		// Period.SerializationProxy 용
		private Object readResolve() {
			return new Period(start, end);
		}
	}
}

 

writeReplace의 경우 직렬화 프록시를 사용하는 모든 클래스에서 범용적으로 쓰이는 형태이니 그대로 복사해서 사용해도 된다.

 

 


직렬화 프록시 패턴의 장점

 

1. 직렬화의 언어도단적 특성을 제거

개요에서 언급했다시피, 직렬화는 생성자를 이용하지 않고 인스턴스를 생성하는 우회적인 기법이기 때문에 버그와 보안 문제가 생기게 된다.

 

반면 직렬화 프록시 패턴은 readResolve 메서드를 통해 공개된 API만을 사용해 인스턴스를 생성한다. 즉, 생성자나 정적 팩터리 등을 통해 기존에 인스턴스를 생성하던 방식대로 인스턴스를 생성한다는 것이다. 그렇기에 역직렬화된 인스턴스가 해당 클래스의 불변식을 만족하는지 따로 검사하지 않아도 된다.

 

2. 직렬화 관련 공격 차단

가짜 바이트 스트림 공격내부 필드 탈취 공격을 프록시 수준에서 차단해준다.

 

두 공격을 간략하게 짚고 넘어가자면 가짜 바이트 스트림 공격은 공격자가 악의적인 데이터를 포함하는 바이트 스트림을 만들어 이를 역직렬화하도록 유도하거나 악의적인 객체를 생성하게 하는 공격이다. 내부 필드 탈취 공격은 직렬화된 객체가 private 필드까지 바이트 스트림에 포함하게 되는데, 역직렬화 시 이 데이터가 복원되는 것을 악용하여 민감한 정보를 탈취하려는 공격이다.

 

직렬화 프록시 패턴을 사용할 경우 복잡한 원본 객체 대신 단순한 프록시 객체를 직렬화하고, 이 프록시 객체를 통해 원본 객체를 재구성한다. 따라서 원본 객체의 복잡성과 민감한 내부 상태를 외부로부터 격리시키는 역할을 하여, 보안성을 크게 향상시킬 수 있다.

 

3. 역직렬화한 인스턴스와 원래의 직렬화된 인스턴스의 클래스가 달라도 정상 작동

EnumSet의 경우 public 생성자 없이 정적 팩터리만 제공하는데, 이 팩터리들은 단순히 EnumSet을 반환하는게 아니다. 원소가 64개 이하면 RegularEnumSet을, 그보다 크면 JumboEnumSet을 반환해준다고 한다.

 

만약 원소 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(EnumSet<E> set) {
        elementType = set.elementType;
        elements = set.toArray(new Enum<?>[0]);
    }

    private Object readResolve() {
        EnumSet<E> result = EnumSet.noneOf(elementType);
        for (Enum<?> e : elements)
            result.add((E)e);
        return result;
    }

    private static final long serialVersionUID = 362491234563181265L;
}

 

 

 


직렬화 프록시 패턴의 한계

 

1. 클라이언트가 임의로 확장할 수 있는 클래스, 객체 그래프에 순환이 있는 클래스에는 적용 불가

이러한 객체의 메서드를 직렬화 프록시의 readResolve 안에서 호출하려 하면 ClassCastException이 발생하게 될 것이다. 직렬화 프록시만 가졌을 뿐, 실제 객체는 아직 생성된 것이 아니기 때문이다.

 

2. 성능 감소

저자의 경우 readObject의 방어적 복사를 수행했을 때보다 14% 가량 성능이 감소하였다고 한다. 크게 유의미하지는 않은 수치이지만 성능이 중요한 클래스일 경우 이 패턴의 적용에 유의해야 할 것이다.

 

 


정리

 

1. 직렬화 프록시 패턴은 중요한 불변식을 안정적으로 직렬화해주는 가장 쉬운 방법이다.

2. 따라서 직렬화 프록시 패턴을 적용할 수 있는 클래스라면 가능한 한 이 패턴을 사용하도록 하자.

 

 

 

 

반응형