Link
Today
Total
10-17 00:15
Archives
관리 메뉴

초보개발자 긍.응.성

(이펙티브 자바 3) 12장 직렬화 정리 본문

책 정리/이펙티브 자바 3

(이펙티브 자바 3) 12장 직렬화 정리

긍.응.성 2020. 12. 12. 01:02
반응형

객체 직렬화란 자바가 객체를 바이트 스트림으로 인코딩하고(직렬화) 그 바이트 스트림으로부터 다시 객체를 재구성하는(역직렬화) 메커니즘이다. 직렬화된 객체는 다른 VM에 전송하거나 디스크에 저장한 후 나중에 역직렬화할 수 있다. 직렬화가 품고 있는 위험과 그 위험을 최소화하는 방법을 배워보자.

자바 직렬화의 대안을 찾으라

직렬화는 위험하니 피해야 한다. ObjectInputStream의 readObject는 객체 그래프가 역직렬화된다. 역직렬화를 통하면 클래스 패스 안의 모든 타입의 객체를 만들어 그 타입들 안의 모든 코드를 수행할 수 있다. 즉, 그 타입들의 코드 전체가 공격 범위에 들어간다는 뜻이다.

직렬화 위험을 회피하는 가장 좋은 방법은 아무것도 역질렬화 하지 않는 것이다. 새로운 시스템에서 자바 직렬화를 써야 할 이유는 전혀 없다. 대체 메커니즘으로 크로스-플랫폼 구조화된 데이터 표현(cross-platform structured-data representation)을 사용하자. 이들은 자바 직렬 화보다 훨씬 간단하다. 속성-값 집합으로 구성된 간단하고 구조화된 데이터 객체를 사용한다. 기본 타입 몇 개와 배열 타입만 지원하지만 이런 간단한 추상화만으로도 아주 강력한 분산 시스템을 구축하기에 충분하며, 자바 직렬 화가 가져온 심각한 문제들을 회피할 수 있다.

크로스-플랫폼 구조화된 데이터의 종류는 JSON과 프로토콜 버퍼(Protocol Buffers)가 존재한다. JSON은 텍스트 기반이라 사람이 읽을 수 있고, 프로토콜 버퍼는 이진 표현이라 효율이 훨씬 높다.

레거시 시스템 때문에 자바 직렬화를 정말 배제할 수 없다면 최대한 신뢰할 수 없는 데이터는 절대 역직렬화 하지 않아야 한다. 만약 역직렬 화한 데이터가 안전한지 완전히 확신할 수 없다면 객체 역직렬화 필터링(java.io.ObjectInputFilter)을 사용하자. 역직렬화 필터링을 통해 특정 클래스를 받아들이거나 거부할 수 있다. 거부하는 것은 블랙리스트 방식, 받아들이는 것은 화이트리스트 방식이며, 블랙리스트 방식보다는 화이트 리스트 방식을 추천한다 (확실히 안전한 객체만 역직렬화 할 것이기 때문이다).

Serializable을 구현할지는 신중히 결정하라

어떤 클래스의 인스턴스를 직렬화할 수 있게 하려면 클래스 선언에 implements Serializable만 덧붙이면 된다. 하지만 이는 아주 값비싼 일이다. 아래의 리스트는 Serializable을 구현하기 전 고민해봐야 할 사항들이다.

  • 한번 구현하면 릴리스한 뒤에는 수정하기 어렵다
    • Serializable을 구현하면 직렬화된 바이트 스트림 인코딩도 하나의 공개 API가 된다. 또한 직렬화형태에서는 클래스의 private, package-private 인스턴스 필드들 마저 API로 공개되어 캡슐화가 깨진다.
  • 버그와 보안 구멍이 생길 위험이 높아진다.
    • 생성자를 통해 객체가 생겨나는 것이 기본이나, 직렬화는 기본 메커니즘을 우회하는 객체 생성 기법이다. 불변식 깨짐과 허가되지 않은 접근에 쉽게 노출된다.
  • 해당 클래스의 신버전을 릴리스할 때 테스트할 것이 늘어난다.
    • 직렬화 가능 클래스가 수정되면 신버전 인스턴스를 직렬화한 후 구버전으로 역직렬화할 수 있는지, 그 반대도 가능한지 검사해야 한다.
  • 가볍게 결정할 사항이 아니다.
    • 객체를 전송하거나 저장할 때 자바 직렬화를 이용하는 프레임워크용으로 만든다면 어쩔 수 없이 직렬화를 하겠지만 그렇지 않으면 Serializable 구현에 따르는 비용과 이득을 잘 저울질해야 한다. 역사적으로 BigInteger와 Instant 같은 '값'클래스와 컬렉션 클래스들은 Serializable을 구현하고, 스레드 풀처럼 '동작'하는 객체를 표현하는 클래스들은 대부분 Serializable을 구현하지 않았다.
  • 상속용으로 설계된 클래스는 대부분 Serializable을 구현하면 안 되며, 인터페이스도 대부분 Serializable을 확장해서는 안 된다.
    • 구현하는 이에게 큰 부담을 안겨준다.
  • 정적 멤버 클래스를 제외한 내부 클래스는 직렬화를 구현하지 말아야 한다
    • 내부 클래스에는 바깥 인스턴스의 참조와 유효 범위 안의 지역변수 값들을 저장하기 위해 컴파일러가 생성한 필드들이 자동으로 추가된다. 익명 클래스와 지역 클래스의 이름을 짓는 규칙이 언어 명세에 나와 있지 않듯, 이 필드들이 클래스 정의에 어떻게 추가되는지도 정의되지 않다. 즉, 내부 클래스에 대한 기본 직렬화 형태는 분명하지가 않다.

커스텀 직렬화 형태를 고려해보라

기본 직렬화 형태는 유연성, 성능, 정확성 측면에서 신중히 고민한 후 합당할 때만 사용해야 한다. 기본 직렬화 형태는 writeObject나 readObject와 같은 직렬화 메서드를 추가해주지 않고 순수 Serializable 인터페이스를 구현한 형태이다.

객체의 물리적 표현과 논리적 내용이 같다면 기본 직렬화 형태라도 무방하다. 하지만, 기본 직렬화 형태가 적합하다고 결정했더라도 불변식 보장과 보안을 위해 readObject 메서드를 제공해야 할 때가 많다 (null이면 안되는 필드 제어).

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

  1. 공개 API가 현재 내부 표현 방식에 영구히 묶인다
    • 내부 클래스가 공개 API로 묶인다.
  2. 너무 많은 공간을 차지할 수 있다
    • 모든 정보를 직렬화 한다
  3. 시간이 너무 많이 걸릴 수 있다
    • 객체 그래프의 위상에 관한 정보가 없으므로 직접 순회하여야 한다
  4. 스택 오버플로를 일으킬 수 있다
    • 직렬화 과정에서 객체 그래프를 재귀 순화하는데 이 작업 중에 스택 오버플로가 일어날 수 있다

물리적인 상세 표현은 배제한 채 논리적인 구성만 담고 writeObject와 readObject를 구현하여 직렬화 /역직렬화 형태를 정의한다. transient 한정자는 직렬화에 포함되지 않도록 해준다. 직렬화 할 필요 없는 필드라면 transient 한정자를 붙여주자.

private void writeObject(ObjectOutputStream s) {
	...
}

private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundExcepton {
	...
}

writeObject와 readObject는 직렬화 형태를 처리한다. 각각 가장 먼저 defaultWriteObject와 defaultReadObject를 호출하자. 직렬화 명세는 이 작업을 무조건 하라고 요구한다. 그렇게 해야 향후 릴리스에서 transient가 아닌 인스턴스 필드가 추가되더라도 상호호환이 되기 때문이다 (신버전 인스턴스를 직렬화한 후 구버전으로 역직렬화 하면 새로 추가된 필드들은 무시될 것이다).

기본 직렬화를 사용한다면 transient 필드들은 역직렬화될 때 기본값으로 초기화된다. 그리고 직렬화 여부와 상관없이 객체의 전체 상태를 읽는 메서드에 적용해야 하는 동기화 메커니즘을 직렬화에도 적용해야 한다.

직렬화 가능 클래스 모두에 직렬 버전 UID를 명시적으로 부여하자. 이렇게 하면 직렬 버전 UID가 일으키는 잠재적인 호환성 문제가 사라진다. 런타임에 직렬 버전 UID 값을 생성하는 복잡한 로직도 수행할 필요가 없기에 성능도 조금 빨라진다. UID 값을 바꿔주면 기본 버전 클래스와의 호환성을 끊을 수 있다. 이렇게 하면 기존 버전의 직렬화된 인스턴스를 역직렬화할 때 InvalidClassException이 던져질 것이다. 구버전으로 직렬화된 인스턴스들과의 호환성을 끊으려는 경우를 제외하고는 직렬 UID를 절대 수정하지 말자.

readObject 메서드는 방어적으로 작성하라

readObject 메서드는 작성할 때는 언제나 public 생성자를 작성하는 자세로 임해야 한다. readObject는 어떤 바이트 스트림이 넘어오더라도 유효한 인스턴스를 만들어내야 한다.

  • private 이어야 하는 객체 참조 필드가 가리키는 객체를 방어적으로 복사하라.
  • 모든 불변식을 검사하여 어긋나는 게 발견되면 InvalidObjectException을 던진다. 방어적 복사 이후 반드시 불변식 검사를 수행하자
  • 역직렬화 후 객체 그래프 전체의 유효성을 검사해야 한다면 ObjectInputValidation 인터페이스를 작성하자
  • 직접적이든 간접적이든, 재정의할 수 있는 메서드는 호출하지 말자

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

싱글턴 패턴을 이용하면 인스턴스가 오직 하나인 싱글턴 클래스를 만들 수 있다. 하지만 implements Serializable을 붙이는 순간 더 이상 싱글턴이 아니게 된다. 역직렬화 시 새로운 객체를 만들어 내기 때문이다. 이는 readResolve를 통해 제거할 수 있다.

readObject를 통해 역직렬화 된 객체는 readResolve 메서드의 파라미터로 전달되며 이 메서드가 반환하는 객체를 사용하게 된다. readResolve에서 객체 반환 시 싱글턴 인스턴스를 반환하도록 재정의 하여 싱글턴을 유지시킬 수 있다. 이 또한 공격으로 무력화될 수 있는데 모든 참조 타입 필드를 transient로 선언하는 방법이 있다.

이보다 좋은 방법은 열거 타입으로 만드는 것이다. 직렬화 가능한 인스턴스를 열거 타입을 이용해 구현하면 선언한 상수 외의 다른 객체는 존재하지 않음을 자바가 보장해준다.

직렬화된 인스턴스 대신 직렬화 프록시 사용을 검토하라

직렬화 프록시 패턴(serialization proxy pattern)을 이용하면 앞서 보았던 직렬화에 대한 문제를 크게 줄여줄 수 있다.

먼저, 바깥 클래스의 논리적 상태를 정밀하게 표현하는 중첩 클래스를 설계해 private static으로 선언한다. 이 중첩 클래스가 바로 바깥 클래스의 직렬화 프록시다. 중첩 클래스의 생성자는 단 하나여야 하며, 바깥 클래스를 매개변수로 받아야 한다. 이 생성자는 단순히 인수로 넘어온 인스턴스의 데이터를 복사한다. 그리고 바깥 클래스와 직렬화 프록시 모두 Serializable을 구현한다고 선언해야 한다.

private class Period implements Serializable {
	private final Date start;
	private final Date end;

	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 + "가 " + end + "보다 늦다.");
	}

	// getter

	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 Object readResolve() { // 역직렬화 시 Period를 전달
			return new Period(start, end); // public 생성자
		}
	}

	private Object writeReplace() { // 직렬화 시 SerializationProxy를 전달
		return new SerializationProxy(this);
	}

	private void readObject(ObjectInputStream stream) throws InvlidObjectException {
			throw new InvlidObjectException("프록시가 필요합니다.");
	}
}

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

반응형
Comments