Effective Java 2판 - 11장 정리 (직렬화)

 

1. 들어가며

 

드디어 마지막 장 정리인 직렬화입니다. 직렬화를 깊게 다루는 아티클을 찾아보기가 쉽지 않았는데 마지막 장까지 큰 도움이 되었습니다.

 

책의 두께는 그리 부담스럽지 않지만 읽어나가는 것이 순탄한 책은 아니었습니다. 추천해주신 분이 절대 가볍게 읽을 책은 아니다. 라고 하셨는데 확실히 다른 책보다 읽는 시간이 한참 걸린 것 같습니다.

 

바로 공감이 가서 음음 그렇지~ 하는 부분도 있었고, 이건 왜 이렇게 해야할까?하고 추가 자료를 찾아보는 부분도 많았습니다.  그 과정 속에서 배울 수 있는 점은 정말 많았다고 생각합니다 :)

 

2. 직렬화

 

규칙 74: Serializable 인터페이스를 구현할 때는 신중하라.

클래스 선언부에 implements Serializable만 붙이면 직렬화 가능한 객체를 만드는 클래스를 구현할 때가 있습니다. 너무 간단해보이지만 사실 복잡합니다.

 

Serializable 구현과 관련된 가장 큰 문제는 일단 클래스를 릴리스하고 나면 클래스 구현을 유연하게 바꾸기 어려워진다는 것입니다. Serializable 구현 시 그 클래스의 바이트 스크림 인코딩도 공개 API의 일부가 되어버립니다. 널리 배포된 클래스의 직렬화 형식은 일반적으로 영구 지원해야 합니다. 따라서 default 직렬화를 그대로 이용 시 영원히 클래스의 원래 내부 표현 방식에 종속됩니다. 따라서 그 클래스의 private package-private 객체 필드도 공개 API가 되어 버립니다. 그래서 나중에 클래스 표현을 바꾸면 직렬화 형식에 호환 불가능한 변화가 생길 수 있습니다.

 

Serializable을 구현하면 생기는 두 번째 문제는, 버그나 보안 취약점이 발생할 가능성이 높아집니다. 직렬화는 언어 외적인 객체 생성 매커니즘으로 불변식 훼손이나 불법 접근 문제에 쉽게 노출됩니다.

 

Serializable 구현 시 생기는 세 번째 문제는 새 버전 클래스를 내놓기 위한 테스트 부담이 늘어난다는 것입니다. 수정 시 새 릴리즈에서 만들고 직렬화한 객체가 이전 릴리스에서 역직렬화 가능한 지 테스트해야 합니다. 그 역도 마찬가지입니다.

 

상속을 염두에 뒀다면 직렬화를 구현하지 않는 것이 바람직합니다. 그걸 상속하는 순간 하위 클래스는 모두 부담을 껴안게 됩니다.

 

상위 클래스에 무인자 생성자가 없다면 직렬화 가능 하위 클래스는 구현이 불가능합니다. 따라서 계승을 고려해 설계한 직렬화 불가능 클래스에는 무인자 생성자를 제공하는 것이 어떨지 따져보는 것이 좋습니다.

 

내부 클래스는 Serialzable을 구현하면 안 됩니다. 내부 클래스에는 바깥 객체에 대한 참조를 보관하고 바깥 유효 범위의 지역 변수 값을 보관하기 위해 컴파일러가 자동 생성하는 인위생성 필드가 있습니다. 이런 필드가 클래스 정의에 어떻게 들어맞는지 나와 있지 않으므로 내부 클래스의 기본 직렬화 형식은 정의될 수 없습니다.

 

규칙 75: 사용자 지정 직렬화 형식을 사용하면 좋을 지 따져보라.

기본 직렬화 형식을 그대로 이용하면 기존 구현을 완전히 내버리기란 불가능해집니다. 어떤 직렬화 형식이 적절할 지 따져보지도 않고 기본 직렬화 형식을 그대로 받아들이지 않는 것이 좋습니다.

 

기본 직렬화 형식은 해당 객체가 root인 객체 그래프의 물리적 표현을 나름 효과적으로 인코딩한 것입니다. 다시 말해 객체 안에 담긴 데이터와, 해당 객체를 통해 접근할 수 있는 모든 객체에 담긴 데이터를 기술하고 그 토폴로지도 기술합니다.

 

기본 직렬화 형식은 그 객체의 물리적 표현이 논리적 내용과 동일할 때만 적절합니다. 예를 들어 사람의 이름을 표현한다면 성, 이름, 중간 이름으로 이루어지고 그 객체 필드가 충실히 반영할테니 적절합니.

 

단 설사 기본 직렬화 형식이 만족스럽다 하더라도, 불변식이나 보안 조건을 만족시키기 위해서는 readObject 메서드를 구현해야 마땅한 경우도 많습니다.

 

어떤 형식이 직렬화에 적합하고 아닌지 실제 코드를 보면서 살펴보겠습니다.

 

기본 직렬화가 적절한 예시

/**
*  사람의 이름을 표현할 때는 문자열로 구성되고
*  문자열로 사람의 이름을 표현하는 이 클래스는 기본 직렬화 형식이 적합합니다.
*/

public class NameClassGoodUseDefaultSerializedForm implements Serializable {
	private static final long serialVersionUID = 1L;

	/**
	 * Javadoc에서 제공하는 태그
	 * @serial
	 */
	private String lastName;
	/**
	 * 이름(first name). null이 될 수 없다.
	 *
	 * @serial
	 */
	private String firstName;
	/**
	 * 중간 이름(middle name). null이 될 수 있다.
	 *
	 * @serial
	 */
	private String middleName;
}

 

적절하지 않은 사례

/**
 * 링크드리스트로 문자열의 리스트를 표현하는 클래스입니다.
 * 이 클래스를 기본 직렬화 형식으로 직렬화 할 경우
 * 리스트에 있는 모든 정보가 담겨야 합니다. -> 원소, 연결 정보 등.
 * 1. 너무 많은 공간을 필요로 할 수 있습니다. (네트워크 속도, 디스크 저장 문제 발생가능)
 * 2. 너무 많은 시간을 소비하는 문제가 생길 수 있습니다.
 * 3. 스택 오버플로가 발생할 수 있습니다.
 *
 *
 * 이 클래스는 물리적으로는 리스트이지만
 * 논리적으로만 생각한다면 문자열을 순서대로 저장한 배열이다.
 * 따라서 기본 직렬화 형식이 아닌 사용자 지정 직렬화를 구현해서 직렬화하는 것이 좋습니다.
 *
 */
public class StringListClassNotGoodUseDefaultSerializedForm implements Serializable {
	private static final long serialVersionUID = 1L;

	private int size = 0;
	private Entry head = null;

	private static class Entry implements Serializable {
		String data;
		Entry next;
		Entry previous;
	}

	public void add(String data) {
		size++;

		if (head == null) {
			head = new Entry();
			head.data = data;
		} else {
			Entry newEntry = new Entry();
			newEntry.data = data;
			head.next = newEntry;
			newEntry.previous = head;
			head = newEntry;
		}

	}

	/**
	 * 기본 직렬화 형식을 사용해서 테스트해본 결과, 3482번째에서 스택 오버플로 발생
	 */
	public static void main(String[] args) throws IOException {
		StringListClassNotGoodUseDefaultSerializedForm stringList =
		        new StringListClassNotGoodUseDefaultSerializedForm();

		for (int i = 0; i < 5000; i++) {
			System.out.println(i);
			stringList.add(String.valueOf(i));

			byte[] serializedMember;
			try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
				try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
					oos.writeObject(stringList);
					serializedMember = baos.toByteArray();
				}
			}

		}
	}
}

 

적절하지 않은 사례를 적절하게 바꿔봅시다.

/**
 * 위 클래스를 적합한 예시로 바꿔보겠습니다.
 *
 * private 메서드여도 Serializable을 구현한 클래스면 접근 가능하므로 Javadoc에 표현해야 합니다.
 *
 */
public class StringListClassGoodUseUserSerializedForm implements Serializable {
	private static final long serialVersionUID = 1L;

	// transient를 사용하여 필요없는 값은 직렬화에서 제외
	private transient int size = 0;
	private transient Entry head = null;

	// Serializable 구현하지 않음
	private static class Entry {
		String data;
		Entry next;
		Entry previous;
	}

	public void add(String data) {
		size++;

		if (head == null) {
			head = new Entry();
			head.data = data;
		} else {
			Entry newEntry = new Entry();
			newEntry.data = data;
			head.next = newEntry;
			newEntry.previous = head;
			head = newEntry;
		}

	}

	/**
	 * 사용자 지정 직렬화
	 *
	 * @serialData 리스트의 크기가 먼저 기록되고, 그 다음에는 모든 문자열이 순서대로 기록된다.
	 * @param s
	 * @throws IOException
	 */
	private void writeObject(ObjectOutputStream s) throws IOException {
		s.defaultWriteObject(); // 객체의 모든 필드가 transient일 때도 호출하는게 좋다.
		s.writeInt(size);

		for (Entry e = head; e != null; e = e.next) {
			s.writeObject(e.data);
		}
	}

	/**
	 * 사용자 지정 역직렬화
	 *
	 *
	 * @param s
	 * @throws IOException
	 * @throws ClassNotFoundException
	 */
	private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
		s.defaultReadObject(); // 객체의 모든 필드가 transient일 때도 호출하는게 좋다.

		int numElements = s.readInt();
		for (int i = 0; i < numElements; i++) {
			add((String) s.readObject());
		}
	}

	/**
	 * 사용자 지정 직렬화 형식을 사용해서 테스트해본 결과, 스택 오버플로 발생하지 않음
	 */
	public static void main(String[] args) throws IOException {
		StringListClassGoodUseUserSerializedForm stringList =
		        new StringListClassGoodUseUserSerializedForm();

		for (int i = 0; i < 1000000; i++) {
			System.out.println(i);
			stringList.add(String.valueOf(i));

			byte[] serializedMember;
			try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
				try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
					oos.writeObject(stringList);
					serializedMember = baos.toByteArray();
				}
			}

		}
	}
}

 

 

 

 

객체의 물리적 표현 형태가 논리적 형태와 많이 다를 경우 기본 직렬화 형식을 그대로 받아들이면 아래의 네 가지 문제가 생기게 됩니다.

  • 공개 API가 현재 내부 표현 형태에 영원히 종속된다.
  • 너무 많은 공간을 차지할 수 있다. 리스트가 있다면 그 연결 정보를 모두 포함한다.
  • 너무 많은 시간을 소비할 수 있다. 그래프 토폴로지 정보를 이해하지 못하므로 많은 양의 그래프 순회를 해야 한다.
  • 스택 오버플로 문제가 생길 수 있다. StringList의 경우 원소가 1258개만 되어도 문제가 생겼다.

위의 변경 예시처럼 잘 바꿔봅시다 :) 주석을 보면서 따라가면 금방 보실 수 있을 것 같습니다 :) 

 

규칙 76: readObject는 방어적으로 구현하라.

readObject()
InputObjectStream/ OutputObjectStream 등을 통해 객체를 읽고 쓸 때 클래스에 정의된 readObject() / writeObject()가 있다면 이 메서드를 통해 직렬화, 역직렬화를 수행합니다.

 

readObject는 다음과 같은 특성을 가집니다.

  • 커스텀한 직렬화 (직렬화에 특정 처리를 하고 싶을 때) 사용.
  • private 메서드로 작성해야 한다.
  • 이 메서드들의 처음에 defaultWriteObject() / defaultReadObject() 를 호출하여 기본 직렬화를 실행하게 해야한다.
  • 리플렉션을 통해 작업을 수행한다.

다만 이 readObject는 구현 시 주의해야할 점이 많습니다.

 

우선 readObject는 새로운 객체를 만들어내는 특이한 public 생성자와 같다고 할 수 있습니다. 따라서, 생성자처럼 유효성검사, 방어적 복사 를 수행해야합니다. 그렇지 않으면, 불변식을 보장하지 못할 수 있습니다.

 

따라서 readObject를 사용할 때는 다음의 원칙을 따르는 것이 좋습니다.

  • readObject 를 정의하고, 유효성 검사를 실시한다.
  • 객체를 역직렬화할 때는 클라이언트가 소유해서는 안되는 객체 참조를 갖는 필드를 모두 방어적 복사한다.
  • 불변 클래스 안의 모든 private 가변 요소를 방어적 복사한다.
  • 유효성 검사보다 먼저 방어적 복사, 반대라면, 유효성 검사 이후 방어적 복사 이전에 불변식을 깨뜨릴 틈이 생긴다. 
  • final 필드는 방어적 복사가 불가능하므로, 필드를 final 이 아니게 해야함.

그렇다면 readObject를 직접 정의해야 하는 상황은 어떤 것이 있을까요? 다음의 기준으로 판단할 수 있습니다.

transient 필드를 제외한 모든 필드의 값을 매개변수로 받아 유효성 검사 없이 필드에 대입하는 public 생성자 를 추가해도 괜찮은가 ?

  • Yes -> 기본 readObject
  • No -> readObject 직접 정의 후 유효성 검사와 방어적 복사 수행. or 직렬화 프록시 패턴사용

또한 class가 final이 아닌 경우  readObject 메서드에서 재정의 가능 메서드를 호출하면 안됩니다. 클래스의 하위 클래스가 불리기 전, 생성자의 재정의 메서드가 실행되므로 오류가 발생합니다.

 

 

규칙 77: 객체 통제가 필요하다면 readResolve 대신 enum 자료형을 이용하라.

"implements Serializable"을 붙이는 순간 해당 클래스는 싱글턴 클래스일 수 없습니다. readObject 메서드는 새로 생성된 객체를 반환하고, 이 객체는 클래스가 초기화될 당시에 만들어진 객체와 같은 객체가 아닙니다.

 

readResolve를 이용하면 readObject가 만들어낸 객체를 다른 것으로 대체할 수 있습니다. 이 메서드가 반환하는 객체가 사용에게 반환됩니다. 

 

직렬화 가능한 객체 통제 클래스를 enum으로 구현한다면, 선언된 상수 이외의 다른 객체는 존재할 수 없다는 확실한 보장이 생깁니다.

 

 

 

규칙 78: 직렬화된 객체 대신 직렬화 프록시를 고려해 보라.

Serializable 인터페이스를 구현하겠다는 결정을 내리게 되면 버그나 보안 결함이 생길 가능성이 높습니다. 이러한 문제를 피하기 위해서 직렬화 프록시 패턴을 사용할 수 있습니다.

 

 우선 바깥 클래스 객체의 논리적 상태를 간결하게 표현하는 직렬화 가능 클래스를 private static 중첩 클래스로 설계합니다. 이 중첩클래스를 직렬화 프락시라고 부르는데, 바깥 클래스를 인자 자료형으로 사용하는 생성자를 하나만 가집니다. 이 생성자는 인자에서 데이터를 복사하기만 합니다. 일관성 검사를 할 필요도 없고, 방어적 복사를 할 필요도 없습니다.