12장 - 직렬화
객체 직렬화란 자바가 객체를 바이트 스트림으로 인코딩하고 (직렬화) 그 바이트 스트림으로부터 다시 객체를 재구성하는(역직렬화) 매커니즘이다.
직렬화된 객체는 다른 VM에 전송하거나 디스크에 저장한 후 나중에 역직렬화 할 수 있다.
이번 장은 직렬화가 품고 있는 위험과 그 위험을 최소화하는 방법에 집중한다.
85. 자바 직렬화의 대안을 찾으라
1997년, 자바에 처음으로 직렬화가 도입되었다.
그 때만 하더라도 직렬화는 연구용 언어인 모듈라-3에서나 시도되었을 뿐, 대중적 언어에 적용된건 처음이었기에 다소 위험하지 않겠냐는 이야기가 나왔다.
프로그래머가 어렵지 않게 분산 객체를 만들 수 있다는 구호는 매력적이었지만, 보이지 않는 생성자, API와 구현 사이의 모호해진 경계, 잠재적인 정확성 문제, 성능, 보안, 유지보수성 등 그 대가가 컸다.
지지자들은 장점이 이런 위험성을 압도한다고 생각했지만, 지금까지 경험한 바로는 그 반대다.
보안 문제가 심각한 것으로 밝혀졌다 (샌프란시스코 시영 교통국이 랜섬웨어 공격을 받아 요금 징수 시스템이 이틀간 마비)
직렬화의 근본적인 문제는 공격 범위가 너무 넓고 지속적으로 더 넓어져 방어하기 어렵다는 점이다.
ObjectInputStream의 readObject 메서드를 호출하면서 객체 그래프가 역직렬화 되기 때문이다.
readObject 메서드는 (Serialiable 인터페이스를 구현했다면) 클래스패스 안의 거의 모든 타입의 객체를 만들어 낼 수 있는, 사실상 마법 같은 생성자다.
바이트 스트림을 역직렬화 하는 과정에서 이 메서드는 그 타입들 안의 모든 코드를 수행할 수 있다.
이 말인즉슨, 그 타입들의 코드 전체가 공격 범위에 들어간다는 뜻이다.
역직렬화 과정에서 호출되어 잠재적으로 위험한 동작을 수행하는 메서드를 가젯이라 부른다.
여러 가젯들이 모여 체인을 이룰 수도 있다. (아주 위험해질 수도 있음)
그래서 아주 신중하게 제작한 바이트 스트림만 역직렬화해야 한다.
역직렬화에 시간이 오래 걸리는 짧은 스트림을 역직렬화 하는 것만으로도 서비스 거부 공격에 쉽게 노출될 수 있다 ⇒ 역직렬화 폭탄
HashSet 인스턴스를 역직렬화 하려면 그 원소들의 해시코드를 계산해야 하는데, 매우 오래걸리기 때문에 시도해서는 안 된다.
애초에 신뢰할 수 없는 바이트 스트림을 역직렬화하는 일 자체가 스스로를 공격에 노출하는 행위다.
따라서 직렬화 위험을 회피하는 가장 좋은 방법은 아무것도 역직렬화하지 않는 것이다.
여러분이 작성하는 새로운 시스템에서 자바 직렬화를 써야 할 이유는 전혀 없다.
객체와 바이트 시퀀스를 변환해주는 다른 매커니즘이 많이 있다.
이 방식들은 자바 직렬화의 여러 위험을 회피하면서 다양한 플랫폼 지원, 우수한 성능, 풍부한 지원 도구, 활발한 커뮤니티와 전문가 집단 등 수많은 이점까지 제공한다.
이런 매커니즘들도 직렬화 시스템이라 불리기도 하지만, 이 책에서는 자바 직렬화와 구분하고자 크로스-플랫폼 구조화된 데이터 표현 이라 한다.
이 표현들의 공통점은 자바 직렬화보다 훨씬 간단하다는 것이다.
임의 객체 그래프를 자동으로 직렬화, 역직렬화 하지 않는다.
대신 속성-값 쌍의 집합으로 구성된 간단하고 구조화된 데이터 객체를 사용한다.
그리고 기본 타입 몇 개와 배열 타입만 지원할 뿐이다.
이런 간단한 추상화만으로도 아주 강력한 분산 시스템을 구축하기에 충분하고, 자바 직렬화가 가져온 심각한 문제들을 회피할 수 있음이 밝혀졌다.
크로스 플랫폼 구조화된 데이터 표현의 선두주자는 JSON과 프로토콜 버퍼다.
JSON은 더글라스 프록퍼드가 브라우저와 서버의 통신용으로 설계했고, 프로토콜 버퍼는 구글이 서버 사이에 데이터를 교환하고 저장하기 위해 설계했다.
보통 이들은 언어 중립적이라고 하지만, 사실 JSON은 자바스크립트용으로, 프로토콜 버퍼는 C++용으로 만들어졌고, 아직도 그 흔적이 남아 있다.
둘의 가장 큰 차이점은 JSON 은 텍스트 기반이라 사람이 읽을 수 있고, 프로토콜 버퍼는 이진 표현이라 효율이 훨씬 높다는 점이다.
또한 프로토콜 버퍼는 이진 표현 뿐 아니라 사람이 읽을 수 잇는 텍스트 표현도 지원한다.
레거시 시스템 때문에 자바 직렬화를 완전히 배재할 수 없을 때의 차선책은 신뢰할 수 없는 데이터는 절대 역직렬화하지 않는 것이다.
특히, 신뢰할 수 없는 발신원으로부터의 RMI는 절대 수용해서는 안 된다.
직렬화를 피할 수 없고, 역직렬화한 데이터가 안전한지 완전히 확신할 수 없다면 객체 역직렬화 필터링을 사용하자 (java.io.ObjectInputFilter)
객체 역직렬화 필터링은 데이터 스트림이 역직렬화되기 전에 필터를 설치하는 기능이다.
클래스 단위로, 특정 클래스를 받아들이거나 거부할 수 있다.
화이트 리스트에 기록된 안전하다고 알려진 클래스들만 수용하는 방법이 블랙리스트보다 추천한다.
86. Serializable을 구현할지는 신중히 결정하라
어떤 클래스의 인스턴스를 직렬화할 수 있게 하려면 클래스 선언에 implements Serializable만 덧붙이면 된다.
너무 쉽게 적용할 수 있기 때문에 프로그래머가 특별히 신경쓸 게 없다는 오해가 생길 수 있지만, 진실은 훨씬 더 복잡하다.
직렬화를 지원하기란 짧게 보면 손쉬워 보이지만, 길게 보면 아주 값비싼 일이다.
Serializable을 구현하면 릴리스한 뒤에는 수정하기 어렵다.
클래스가 Serializable을 구현하면 직렬화된 바이트 스트림 인코딩도 하나의 공개 API가 된다.
그래서 이 클래스가 널리 퍼진다면 그 직렬화 형태도 영원히 지원해야 한다는 것이다.
커스텀 직렬화 형태를 설계하지 않고 자바의 기본 방식을 사용하면 직렬화 형태는 최소 적용 당시 클래스의 내부 구현 방식에 영원히 묶여버린다.
달리 말하면 기본 직렬화 형태에서는 클래스의 private, package-private 인스턴스 필드들 마저 API로 공개하는 꼴이 된다(캡슐화가 깨진다)
직렬 버전 UID는 클래스 안에 자동으로 생성되어 넣어지는데, 클래스의 정보가 조금이라도 수정되면 이 값이 변경되어 클래스 개선을 방해하게 된다.
Serializable 구현의 두 번째 문제는 버그와 보안 구멍이 생길 위험이 높아진다는 점이다.
객체는 생성자를 사용해 만드느 게 기본이다.
즉, 직렬화는 언어의 기본 메커니즘을 우회하는 객체 생성 기법인 것이다.
기본 방식을 따르든 재정의해 사용하든, 역직렬화는 일반 생성자의 문제가 그대로 적용되는 ‘숨은 생성자’다.
Serializable 구현의 세 번째 문제는 해당 클래스의 신버전을 릴리스할 때 테스트할 것이 늘어난다는 점이다.
직렬화 가능 클래스가 수정되면 신버전 인스턴스를 직렬화한 후 구버전으로 역직렬화할 수 있는지, 그리고 그 반대도 가능한지를 테스트해야 한다.
Serializable구현 여부는 가볍게 결정할 사안이 아니다.
상속용으로 설계된 클래스는 대부분 Serializable을 구현하면 안 되며, 인터페이스도 대부분 Serializable을 확장해서는 안 된다.
여러분이 작성하는 클래스의 인스턴스 필드가 직렬화와 확장이 모두 가능하다면 주의해야 할 점
- 반드시 하위 클래스에서 finalize 메서드를 재정의하지 못하게 해야한다. 즉, finalize 메서드를 자신이 재정의하면서 final로 선언하면 된다.
- 인스턴스 필드 중 기본값 (정수형은 0, boolean은 false 등) 으로 초기화되면 위배되는 불변식이 있다면 클래스에 다음의 readObjectNoData 메서드를 반드시 추가해야 한다.
private void readObjectNoData() throws InvalidObjectException {
throw new Invalid..
}
이 메서드는 자바 4에 추가된 것으로, 기존의 직렬화 가능 클래스에 직렬화 가능 상위 클래스를 추가하는 드문 경우를 위한 메서드다.
Serializable을 구현하지 않기로 할 때는 한 가지만 주의하면 된다.
상속용 클래스인데 직렬화를 지원하지 않으면 그 하위 클래스에서 직렬화를 지원하려할 때 부담이 늘어난다.
보통은 이런 클래스를 역직렬화하려면 그 상위 클래스는 매개변수가 없는 생성자를 제공해야 하는데 여러분이 이런 생성자를 제공하지 않으면 하위 클래스에서는 어쩔 수 없이 직렬화 프록시 패턴을 사용해야 한다.
내부 클래스는 직렬화를 구현하지 말아야 한다.
내부 클래스에는 바깥 인스턴스의 참조와 유효 범위 안의 지역변수 값들을 저장하기 위해 컴파일러가 생성한 필드들이 자동으로 추가된다.
익명 클래스와 지역 클래스의 이름을 짓는 규칙이 언어 명세에 나와 있지 않듯, 이 필드들이 클래스 정의에 어떻게 추가되는지도 정의되지 않았다.
다시 말해 내부 클래스에 대한 기본 직렬화 형태는 분명하지가 않다. 단 정적 멤버 클래스는 Serializable을 구현해도 된다.
87. 커스텀 직렬화 형태를 고려해보라
객체의 물리적 표현과 논리적 내용이 같다면 기본 직렬화 형태라도 무방하다.
기본 직렬화 형태가 적합하다고 결정했더라도 불변식 보장과 보안을 위해 readObject 메서드를 제공해야 할 때가 많다.
객체의 물리적 표현과 논리적 표현의 차이가 클 때 기본 직렬화 형태를 사용하면 크게 네 가지 면에서 문제가 생긴다.
- 공개 API가 현재의 내부 표현 방식에 영구히 묶인다.
- 너무 많은 공간을 차지할 수 있다.
- 시간이 너무 많이 걸릴 수 있다.
- 스택 오버 플로를 일으킬 수 있다.
88. readObject 메서드는 방어적으로 작성하라
어떤 클래스를 직렬화하기로 결정했다고 해보자.
물리적 표현과 논리적 표현이 부합하면 기본 직렬화 형태를 사용해도 나쁘지 않을 것이다.
그러니 이 클래스 선언에 implements Serializable을 추가하는 것으로 모든 일을 끝낼 수 있을 것 같다.
하지만 이렇게 해서는 이 클래스의 주요한 불변식을 더이상 보장하지 못하게 된다.
문제는 readObject 메서드가 실질적으로 또 다른 public 생성자이기 때문이다.
따라서 다른 생성자와 똑같은 수준으로 주의를 기울여야 한다.
보통의 생성자처럼 readObject 메서드에서도 인수가 유효한지 검사해야 하고, 필요하다면 매개변수를 방어적으로 복사해야 한다.
쉽게 말해 readObject는 매개변수로 바이트 스트림을 받는 생성자라 할 수 있다.
불변식을 깨뜨릴 의도로 임의 생성한 바이트 스트림을 건네면 문제가 생길 수 있다.
정상적인 생성자로는 만들어낼 수 없는 객체를 생성해낼 수 있기 때문이다.
객체를 역직렬화할 때는 클라이언트가 소유해서는 안 되는 객체 참조를 갖는 필드를 모두 반드시 방어적으로 복사해야 한다.
안전한 readObject 메서드를 작성하는 지침
- private이어야 하는 객체 참조 필드는 각 필드가 가리키는 객체를 방어적으로 복사하라. 불변 클래스 내의 가변 요소가 여기 속한다.
- 모든 불변식을 검사하여 어긋나는 게 발견되면 InvalidObjectException을 던진다. 방어적 복사 다음에는 반드시 불변식 검사가 뒤따라야 한다.
- 역직렬화 후 객체 그래프 전체의 유혀성을 검사해야 한다면 ObjectInputValidation 인터페이스를 사용하라
- 직접적이든, 간접적이든, 재정의할 수 있는 메서드는 호출하지 말자.
89. 인스턴스 수를 통제해야 한다면 readResolve 보다는 열거 타입을 사용하라
아이템 3에서 싱글턴 패턴을 설명하며 다음 예를 보여주었다.
이 클래스는 바깥에서 생성자를 호출하지 못하게 막는 방식으로 인스턴스가 오직 하나만 만들어짐을 보장했다
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
public void leaveTheBuildind() {... }
}
아이템 3에서 이야기했듯 이 클래스는 그 선언에 implements Serializable을 추가하는 순간 더 이상 싱글턴이 아니게 된다.
기본 직렬화를 쓰지 않더라도, 그리고 명시적인 readObject를 제공하더라도 소용없다.
어떤 readObject를 사용하든 이 클래스가 초기화될 때 만들어진 인스턴스와는 별개인 인스턴스를 반환하게 된다.
readResolve 기능을 이용하면 readObject가 만들어낸 인스턴스를 다른 것으로 대체할 수 있다.
역직렬화한 객체의 클래스가 readResolve 메서드를 적절히 정의해뒀다면, 역직렬화 후 새로 생성된 객체를 인수로 이 메서드가 호출되고, 이 메서드가 반환한 객체 참조가 새로 생성된 객체를 대신해 반환된다.
대부분의 경우 이때 새로 생성된 객체의 참조는 유지하지 않으므로 바로 가비지컬렉션 대상이 된다.
앞의 Elvice 클래스가 Serializable을 구현한다면 다음의 readResolve 메서드를 추가해 싱글턴이라는 속성을 유지할 수 있다.
private Object readResolve() {
return INSTANCE;
}
이 메서드는 역직렬화한 객체는 무시하고 클래스 초기화 때 만들어진 Elvis 인스턴스를 반환한다.
따라서 Elvis 인스턴스의 직려화 형태는 아무런 실 데이터를 가질 이유가 없으니 모든 인스턴스 필드를 transient로 선언해야 한다.
사실, readResolve를 인스턴스 통제 목적으로 사용한다면 객체 참조 타입 인스턴스 필드는 모두 transient로 선언해야 한다.
그렇지 않으면 아이템 88에서 살펴본 공격과 비슷한 방식으로 readResolve 메서드가 수행되기 전에 역직렬화된 객체의 참조를 공격할 여지가 남는다.
다소 복잡한 공격 방법이지만 기본 아이디어는 간단하다. 싱글턴이 transient가 아닌 참조 필드를 가지고 있다면, 그 필드의 내용은 readResolve 메서드가 실행되기 전에 역직렬화 된다.
그렇다면 잘 조작된 스트림을 써서 해당 참조 필드의 내용이 역직렬화되는 시점에 그 역직렬화된 인스턴스의 참조를 훔쳐올 수 있다.
직렬화 가능한 인스턴스 통제 클래스를 열거 타입을 이용해 구현하면 선언한 상수 외의 다른 객체는 존재하지 않음을 자바가 보장해준다.
물론 공격자가 AccessibleObject.setAccessible 같은 특권 메서드를 악용한다면 이야기가 달라진다.
임의의 네이티브 코드를 수행할 수 있는 특권을 가로챈 공격자에게는 모든 방어가 무력화된다.
다음은 Elvis 예를 열거 타입으로 구현한 모습이다.
public enum Elvis {
INSTANCE;
private String[] favoriteSongs =
{ "song1", "song2" };
public void printFavorites() {
sout(Arrays.toString(favoriteSongs));
}
}
인스턴스 통제를 위해 readResolve를 사용하는 방식이 완전히 쓸모없는 것은 아니다.
직렬화 가능 인스턴스 통제 클래스를 작성해야 하는데, 컴파일 타임에는 어떤 인스턴스들이 있는지 알 수 없는 상황이라면 열거 타입으로 표현하는 것이 불가능하기 때문이다.
readResolve 메서드의 접근성은 매우 중요하다.
90. 직렬화된 인스턴스 대신 직렬화 프록시 사용을 검토하라
Serializable을 구현하기로 결정한 순간 언어의 정상 메커니즘인 생성자 이외의 방법으로 인스턴스를 생성할 수 있게 된다.
버그와 보안 문제가 일어날 가능성이 커진다는 뜻이다.
하지만 이 위험을 크게 줄여줄 기법이 하나 있다.
바로 직렬화 프록시 패턴이다.
직렬화 프록시 패턴은 그리 복잡히자 않다.
먼저, 바깥 클래스의 논리적 상태를 정밀하게 표현하는 중첩 클래스를 설계해 private static으로 선언한다.
이 중첩 클래스가 바로 바깥 클래스의 직렬화 프록시다.
중첩 클래스의 생성자는 단 하나여야 하며, 바깥 클래스를 매개변수로 받아야 한다.
이 생성자는 단순히 인수로 넘어온 인스턴스의 데이터를 복사한다.
일관성 검사나 방어적 복사도 필요없다.
설계상, 직렬화 프록시의 기본 직렬화 형태는 바깥 클래스의 직렬화 형태로 쓰기에 이상적이다.
그리고 바깥 클래스와 직렬화 프록시 모두 Serializable을 구현한다고 선언해야 한다.
다음은 직렬화 프록시 클래스의 예시이다 (내부 중첩 클래스임)
private static class SerializableProxy implements Serializable {
private final Date start;
private final Date end;
SerializationProxy(Period p) {
this.start = p.start;
this.end = p.end;
}
}
다음으로 바깥 클래스에 다음의 writeReplace 메서드를 추가한다.
이 메서드는 범용적이니 직렬화 프록시를 사용하는 모든 클래스에 그대로 복사해 쓰면 된다.
private Object writeReplace() {
return new SerializationProxy(this);
}
이 메서드는 자바의 직렬화 시스템이 바깥 클래스의 인스턴스 대신 SerialzationProxy의 인스턴스를 반환하게 만드는 역할을 한다.
달리 말해, 직렬화가 이뤄지기 전에 바깥 클래스의 인스턴스를 직렬화 프록시로 변환해준다.
마지막으로, 바깥 클래스와 논리적으로 동일한 인스턴스를 반환하는 readResolve 메서드를 SerializationProxy 클래스에 추가한다.
이 메서드는 역직렬화 시에 직렬화 시스템이 직렬화 프록시를 다시 바깥 클래스의 인스턴스로 변환하게 해준다.