[Effective Java] 12장. 직렬화

kkatal_chae·2022년 11월 28일
0

Effective Java

목록 보기
11/11
post-thumbnail

객체 직렬화란 자바가 객체를 바이트 스트림으로 인코딩( 직렬화 )하고 그 바이트 스트림으로부터 다시 객체를 재구성( 역직렬화 )하는 메커니즘이다

아이템 85. 자바 직렬화의 대안을 찾으라


자바 직렬화의 근본적인 문제는 공격 범위가 너무 넓고 지속적으로 더 넓어져 방어하기 어렵다는 점이다.

역직렬화 과정에서 호출되어 잠재적으로 위험한 동작을 수행하는 메서드들을 가젯이라고 부른다.


역직렬화에 시간이 오래 걸리는 짧은 스트림을 역직렬화하는 것만으로도 서비스 거부 공격에 쉽게 노출될 수 있다. 이런 스트림을 역직렬화 폭탄이라고 한다.

// 역직렬화 폭탄 
static byte[] bomb() {
	Set<Object> root = new HashSet<>();
	Set<Object> s1 = root;
	Set<Object> s2 = new HashSet<>();
	for ( int i = 0; i < 100; i++; ) {
		Set<Object> t1 = new HashSet<>();
		Set<Object> t2 = new HashSet<>();
		t1.add("foo"); // t1 을 t2 와 다르게 만든다.
		s1.add( t1 );		s1.add( t2 );
		s2.add( t1 );   s2.add( t2 );
		s1 = t1;
		s2 = t2;
	}
	return serialize( root ); // 간결하게 하기 위해 이 메서드의 코드 생략
}

문제는 HashSet 인스턴스를 역직렬화하려면 그 원소들의 해시코드를 계산해야 한다는 데 있다.

애초에 신뢰할 수 없는 바이트 스트림을 역직렬화하는 일 자체가 스스로를 공격에 노출하는 행위다. 따라서 직렬화 위험을 회피하는 가장 좋은 방법은 아무것도 역직렬화하지 않는 것이다.


크로스-플랫폼 구조화된 데이터 표현의 선두주자는 JSON프로토콜 버퍼다.

JSON 은 더글라스 크록퍼드가 브라우저와 서버의 통신용으로 설계했고, 프로토콜 버퍼는 구글이 서버 사이에 데이터를 교환하고 저장하기 위해 설계했다.

둘의 가장 큰 차이는 JSON 은 텍스트 기반이라 사람이 읽을 수 있고, 프로토콜 버퍼는 이진 표현이라 효율이 훨씬 높다는 점이다. 또한 JSON 은 오직 데이터를 표현하는 데만 쓰이지만, 프로토콜 버퍼는 문서를 위한 스키마를 제공하고 올바로 쓰도록 강요한다.


레거시 시스템 떄문에 자바 직렬화를 완전히 배제할 수 없을 때의 차선책은 신뢰할 수 없는 데이터는 절대 역직렬화하지 않는 것이다.

직렬화를 피할 수 없고 역직렬화한 데이터가 안전한지 완전히 확신할 수 없다면 객체 역직렬화 필터링( java.io.ObjectInputFilter ) 를 사용하자.


💡 핵심 정리
직렬화는 위험하니 피해야 한다.
시스템을 밑바닥부터 설계한다면 JSON 이나 프로토콜 버퍼 같은 대안을 사용하자.


아이템 86. Serializable 을 구현할지는 신중히 결정하라


어떤 클래스의 인스턴스를 직렬화할 수 있게 하려면 클래스 선언에 implements Serializable 만 덧붙이면 된다.

Serializable 을 구현하면 릴리스한 뒤에는 수정하기 어렵다.


직렬화가 클래스 개선을 방해하는 예로 스트림 고유 식별자를 들 수 있다.

serialVersionUID 라는 이름의 static final long 필드로, 이 번호를 명시하지 않으면 시스템이 런타임에 암호 해시 함수 ( SHA-1 ) 를 적용해 자동으로 클래스 안에 생성해 넣는다.


Serializable 구현의 두 번째 문제는 버그와 보안 구멍이 생길 위험이 높아진다는 점이다.


Serializable 구현의 세 번째 문제는 해당 클래스의 신버전을 릴리스할 때 테스트할 것이 늘어난다는 점이다.


Serializable 구현 여부는 가볍게 결정할 사안이 아니다.

단, 객체를 전송하거나 저장할 때 자바 직렬화를 이용하는 프레임워크용으로 만든 클래스라면 선택의 여지가 없다.

상속용으로 설계된 클래스는 대부분 Serializable 을 구현하면 안 되며, 인터페이스도 대부분 Serializable 을 확장해서는 안 된다.

내부 클래스는 직렬화를 구현하지 말아야 한다

단, 정적 멤버 클래스는 Serializable 을 구현해도 된다.


💡 핵심 정리
Serializable 은 구현한다고 선언하기는 아주 쉽지만, 그것은 눈속임일 뿐이다. 한 클래스의 여러 버전이 상호작용할 일이 없고 서버가 신뢰할 수 없는 데이터에 노출될 가능성이 없는 등, 보호된 환경에서만 쓰일 클래스가 아니라면 Serialzable 구현은 아주 신중하게 이뤄져야 한다.


아이템 87. 커스텀 직렬화 형태를 고려해보라


먼저 고민해보고 괜찮다고 판단될 때만 기본 직렬화 형태를 사용하라


객체의 물리적 표현과 논리적 내용이 같다면 기본 직렬화 형태라도 무방하다

// 기본 직렬화 형태에 적합한 후보
public class Name implements Serializable {
	/**
	 * 성. null 이 아니어야 함.
	 * @serial
	 */
	private final String lastName;

	/**
	 * 이름. null 이 아니어야 함
	 * @serial 
	 */
	private final String firstName;

	/** 
	 * 중간이름. 중간이름이 없다면 null.
	 * @serial
	 */
	private final String middleName;

	... // 나머지 코드 생략 
}

기본 직렬화 형태가 적합하다고 결정했더라도 불변식 보장과 보안을 위해 readObject 메서드를 제공해야 할 때가 많다.


객체의 물리적 표현과 논리적 표현의 차이가 클 때 기본 직렬화 형태를 사용하면 생기는 네 가지 면에서의 문제

  1. 공개 API 가 현재의 내부 표현 방식에 영구히 묶인다.
  2. 너무 많은 공간을 차지할 수 있다.
  3. 시간이 너무 많이 걸릴 수 있다.
  4. 스택 오버플로우를 일으킬 수 있다.

// 합리적인 커스텀 직렬화 형태를 갖춘 StringList 
public final class StringList implements Serializable {
	private transient int size = 0;
	private transient Entry head = null;

	// 직렬화되지 않음 
	private static class Entry {
		String data;
		Entry next;
		Entry previous;
	}

	// 지정한 문자열을 리스트에 추가 
	public final void add( String s ) { ... }

	/**
	 * 이 {@code StringList} 인스턴스를 직렬화한다.
	 *
	 * @serialData  이 리스트의 크기를 기록한 후
	 * {@code int}, 이어서 모든 원소를 순서대로 기록한다.
	 */
	private void writeObject( ObjectOutputStream s )
		throws IOException {
		s.defaultWriteObject();
		s.writeInt( size );
	
		// 모든 원소를 올바른 순서로 기록한다.
		for ( Entry e = head; e != null; e = e.next )
			s.writeObject( e.data );
	}
	
	private void readObject( ObjectInputStream s )
		throws IOException, ClassNotFoundException {
		s.defaultReadObject();
		int numElements = s.readInt();

		// 모든 원소를 읽어 이 리스트에 삽입한다.
		for ( int i = 0; i < numElements; i++ ) 
			add( (String) s.readObject() );
	}
	// 나머지 코드 생략 
}

기본 직렬화를 사용한다면 transient 필드들은 역직렬화될 때 기본값으로 초기화됨을 잊지 말자.


기본 직렬화 사용 여부와 상관없이 객체의 전체 상태를 읽는 메서드에 적용해야 하는 동기화 메커니즘을 직렬화에도 적용해야 한다.

어떤 직렬화 형태를 택하든 직렬화 가능 클래스 모두에 직렬 버전 UID 를 명시적으로 부여하자

구버전으로 직렬화된 인스턴스들과의 호환성을 끊으려는 경우를 제외하고는 직렬 버전 UID 를 절대 수정하지 말자


💡 핵심 정리
클래스를 직렬화하기로 했다면 어떤 직렬화 형태를 사용할지 심사숙고하바란다.
자바의 기본 직렬화 형태는 객체를 직렬화한 결과가 해당 객체의 논리적 표현에 부합할 때만 사용하고, 그렇지 않으면 객체를 적절히 설명하는 커스텀 직렬화 형태를 고안하라.


아이템 88. readObject 메서드는 방어적으로 작성하라


readObject 메서드는 실질적으로 또 다른 public 생성자이기 때문에 직렬화 과정에서 불변식을 깨뜨릴 우려가 있다.

따라서 다른 생성자와 똑같은 수준으로 주의를 기울여야 한다. 보통의 생성자처럼 readObject 메서드에서도 인수가 유효한지 검사해야 하고 필요하다면 매개변수를 방어적으로 복사해야 한다.

쉽게 말해, readObject 는 매개변수로 바이트 스트림을 받는 생성자라 할 수 있다.


불변식이 깨지는 것을 고치려면 PeriodreadObject 메서드가 defaultReadObject 를 호출한 다음 역직렬화된 객체가 유효한지 검사해야 한다. 이 유효성 검사에 실패하면 InvalidObjectException 을 던지게 하여 잘못된 역직렬화가 일어나는 것을 막을 수 있다.

// 유효성 검사를 수행하는 readObject 메서드 
private void readObject( ObjectInputStream s ) 
	throws IOException, ClassNotFoundException {
	s.defaultReadObject();

	// 불변식을 만족하는지 검사
	if ( start.compareTo( end ) > 0 ) 
		throw new InvalidObjectException( start + " after " + end );
}

이상의 작업으로 공격자가 허용되지 않는 Period 인스턴스를 생성하는 일을 막을 수 있지만 완전하지 않다.

정상 Period 인스턴스에서 시작된 바이트 스트림 끝에 private Date 필드로의 참조를 추가하면 가변 Period 인스턴스를 만들어낼 수 있다.


// 가변 공격의 예
public class MutablePeriod {
	// Period 인스턴스
	public final Period period;

	// 시작 시각 필드 
	public final Date start;

	// 종료 시각 필드 
	public final Date end;

	public MutablePeriod() {
		try {
			ByteArrayOutputStread bos = 
				new ByteArrayOutputStream();
			ObjectOutputStream out = 
				new ObjectOutputStream( bos );

			// 유효한 Period 인스턴스를 직렬화한다.
			out.writeObject( new Period( new Date(), new Date() ) );

			/**
			 * 악의적인 '이전 객체 참조', 즉 내부 Date 필드로의 참조 추가
			 * 상세 내용은 자바 객체 직렬화 명세의 6.4절 참고
			 */
			byte[] ref = { 0x71, 0, 0x7e, 0, 5 };
			bos.write( ref ); // 시작 필드 
			ref[4] = 4; 
			bos.write( ref ); // 종료 필드 

			// Period 역직렬화 후 Date 참조를 '훔친다'.
			ObjectInputStream in = new ObjectInputStream(
				new ByteArrayInputStream( bos.toByteArray() ) );
			period = ( Period ) in.readObject();
			start = ( Date ) in.readObject();
			end ( Date ) in.readObject();
		} catch ( IOException | ClassNotFoundException e ) {
			throw new AssertionError( e );
		}
	}
}

객체를 역직렬화할 때는 클라이언트가 소유해서는 안 되는 객체 참조를 갖는 필드를 모두 반드시 방어적으로 복사해야 한다

// 방어적 복사와 유효성 검사를 수행하는 readObject 메서드
private void readObject( ObjectInputStread s ) 
	throws IOException, ClassNotFOundException {
	s.defaultReadObject();

	// 가변 요소들을 방어적으로 복사한다. 
	start = new Date( start.getTime() );
	end = new Date( end.getTime() );

	// 불변식을 만족하는지 검사한다.
	if ( start.compareTo( end ) > 0 )
		throw new InvaildObjectException( start + " after " + end );
}

여기서 주목할 점은 방어적 복사를 유효성 검사보다 앞서 수행하며, Dateclone 메서드는 사용하지 않았다는 점이다.

또한, final 필드는 방어적 복사가 불가능하니 주의하자.


💡 핵심 정리
안전한 readObject 메서드를 작성하는 지침

  • private 이어야 하는 객체 참조 필드는 각 필드가 가리키는 객체를 방어적으로 복사하라. 불변 클래스 내의 가변 요소가 여기 속한다

  • 모든 불변식을 검사하여 어긋나는 게 발견되면 InvalidObjectException 을 던진다. 방어적 복사 다음에는 반드시 불변식 검사가 뒤따라야 한다.

  • 역직렬화 후 객체 그래프 전체의 유효성을 검사해야 한다면 objectInputValidation 인터페이스를 사용하라

  • 직접적이든 간접적이든, 재정의할 수 있는 메서드는 호출하지 말자.


아이템 89. 인스턴스 수를 통제해야 한다면 readResolve 보다는 열거 타입을 사용하라


싱글턴은 그 선언에 implements Serializable 을 추가하는 순간 더 이상 싱글턴이 아니게 된다. 어떤 readObject 를 사용하든 이 클래스가 초기화될 때 만들어진 인스턴스와는 별개인 인스턴스를 반환하게 된다.

readResolve 기능을 이용하면 readObject 가 만들어낸 인스턴스를 다른 것으로 대체할 수 있다.


역직렬화한 객체의 클래스가 readResolve 메서드를 적절히 정의해뒀다면, 역직렬화 후 새로 생성된 객체를 인수로 이 메서드가 호출되고, 이 메서드가 반환한 객체 참조가 새로 생성된 객체를 대신해 반환된다.

사실, readResolve 를 인스턴스 통제 목적으로 사용한다면 객체 참조 타입 인스턴스 필드는 모두 transient 로 선언해야 한다.


// 열거 타입 싱글턴 
public enum Elvis {
	INSTANCE;
	private String[] favoriteSongs = { "Hound Dog", "Heartbreak Hotel" };

	public void printFavorites() {
		System.out.printIn( Arrays.toString( favoriteSongs ));
	}
}

readResolve 메서드의 접근성은 매우 중요하다 final 클래스에서라면 readResolve 메서드는 private 이어야 한다.


💡 핵심 정리
불변식을 지키기 위해 인스턴스를 통제해야 한다면 가능한 한 열거 타입을 사용하자. 여의치 않은 상황에서 직렬화와 인스턴스 통제가 모두 필요하다면 readResolve 메서드를 작성해 넣어야 하고, 그 클래스에서 모든 참조 타입 인스턴스 필드를 transient 로 선언해야 한다.


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


직렬화 프록시 패턴을 만드는 방법

먼저, 바깥 클래스의 논리적 상태를 정밀하게 표현하는 중첩 클래스를 설계해 private static 으로 선언한다.

중첩 클래스의 생성자는 단 하나여야 하며, 바깥 클래스를 매개변수로 받아야 한다.

그리고 바깥 클래스와 직렬화 프록시 모두 Serializable 을 구현한다고 선언해야 한다.

// Period 클래스용 직렬화 프록시 
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 = 327598472L;

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

이 메서드는 자바의 직렬화 시스템이 바깥 클래스의 인스턴스 대신 SerializationProxy 의 인스턴스를 반환하게 하는 역할을 한다. 달리 말해, 직렬화가 이뤄지기 전에 바깥 클래스의 인스턴스를 직렬화 프록시로 변환해준다.

// 직렬화 프록시 패턴용 readObject 메서드
private void readObject( ObjectInputStream stream ) 
	throws InvalidObjectException {
	throw new InvalidObjectException( "프록시가 필요합니다." );
}
// Period.SerializationProxy 용 readResolve 메서드
private Object readResolve() {
	return new Period( start, end );
}

readResolve 메서드는 역직렬화 시에 직렬화 시스템이 직렬화 프록시를 다시 바깥 클래스의 인스턴스로 변환하게 해준다.

이 메서드는 공개된 API 만을 사용해 바깥 클래스의 인스턴스를 생성하는데, 이 패턴이 아름다운 이유가 바로 여기 있다.

직렬화는 생성자를 이용하지 않고도 인스턴스를 생성하는 기능을 제공하는데, 이 패턴은 직렬화의 이런 언어도단적 특성을 상당 부분 제거한다.

즉, 일반 인스턴스를 만들 때와 똑같은 생성자, 정적 팩터리, 혹은 다른 메서드를 사용해 역직렬화된 인스턴스를 생성하는 것이다. 따라서 역직렬화된 인스턴스가 해당 클래스의 불변식을 만족하는지 검사할 또 다른 수단을 강구하지 않아도 된다.

// 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 =
		74829374L;
}

직렬화 프록시 패턴의 한계

  • 클라이언트가 멋대로 확장할 수 있는 클래스에는 적용할 수 없다.
  • 객체 그래프에 순환이 있는 클래스에 적용할 수 없다.
  • 직렬화 프록시 패턴이 주는 강력함과 안전성에도 대가는 따른다

💡 핵심 정리
제 3 자가 확장할 수 없는 클래스라면 가능한 한 직렬화 프록시 패턴을 사용하자. 이 패턴이 아마도 중요한 불변식을 안정적으로 직렬화해주는 가장 쉬운 방법일 것이다.

0개의 댓글