12장 직렬화
객체 직렬화란 자바가 객체를 바이트 스트림으로 인코딩하고(직렬화) 그 바이트 스트림으로부터 다시 객체를 재구성하는(역직렬화) 메커니즘이다. 직렬화된 객체는 다른 VM에 전송하거나 디스크에 저장한 후 나중에 역직렬화할 수 있다.
목차
아이템85. 자바 직렬화의 대안을 찾으라
직렬화의 근본적인 문제는 공격 범위가 너무 넓고 지속적으로 더 넓어져 방어하기 어렵다는 점이다.
-
바이트 스트림을 역직렬화하는 과정에서
ObjectInputStream
의readObject
메서드는 그 타입들 안의 모든 코드를 수행할 수 있다. -
즉, 그 타입들이 코드 전체가 공격 범위에 들어간다는 뜻
-
역직렬화 과정에서 호출되어 잠재적인 위험한 동작을 수행하는 메서드들을 가젯(gadget) 이라고 한다.
85-1. 역직렬화 폭탄
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
을 역직렬화하려면 hashCode
메서드를 2**100번 넘게 호출해야 한다.
위와 같은 문제를 해결하기 위해서는
- 아무것도 역직렬화하지 않기
- 크로스-플랫폼 구조화된 데이터 표현(Cross-Platform Structured-Data Representation) 사용하기
- 만약 레거시 시스템으로 인해, 자바 직렬화를 사용해야 한다면 신뢰할 수 없는 데이터는 절대 역직렬화하지 말자.
- 자바9에서 추가된 객체 역직렬화 필터링을 사용하자.
85-2. Cross-Platform Structured-Data Representation
이 표현들의 공통점은 자바 직렬화보다 훨씬 간단하고, 자동으로 직렬화/역직렬화 하지 않는다.
- JSON은 브라우저와 서버의 통신용으로 설계되었으며, 텍스트 기반이라 사람이 읽을 수 있는 장점이 있다.
- 프로토콜 버퍼(protobuf)는 구글이 서버 사이에 데이터를 교환하고 저장하기 위해 설계되었으며, 이진 표현이라 효율이 높다.
아이템86. Serializable을 구현할지는 신중히 결정하라.
어떤 클래스의 인스턴스를 직렬화할 수 있게 하려면 클래스 선언에 implements Serializable
만 덧붙이면 된다.
- 이렇게 간단해보이는 행위는 사실 값비싸다.
86-1. Serializable 구현의 문제점
-
릴리즈한 뒤에는 수정하기 어렵다.
- 클래스가
Serializable
을 구현하면 하나의 공개 API가 된다. 즉, 영원히 지원해야 하는 것이다. - 더불어, 기본 직렬화 형태에서는 클래스의
private
과package-private
인스턴스 필드들마저 API로 공개되는 꼴이다.(캡슐화가 깨진다.) - 필드로의 접근을 최대한 막아 정보를 은닉하라는 조언(아이템15)도 무력화된다.
- 클래스가
-
버그와 보안 구멍이 생길 위험이 높아진다.
- 객체는 생성자를 사용하는 것이 기본인데 직렬화는 그것을 우회하는 기법이다. 즉, 역직렬화는 숨은 생성자다. 이 생성자는 전면에 드러나지 않으므로 "생성자에서 구축한 불변식을 모두 보장해야 하고 생성 도중 공격자가 객체 내부를 들여다 볼 수 없도록 해야 한다" 를 떠올리기 어렵다.
- 기본 역직렬화를 사용하면 불변식 깨짐과 허가되지 않은 접근에 쉽게 노출된다
-
해당 클래스의 신버전을 릴리즈할 때 테스트할 것이 늘어난다.
- 신버전 인스턴스를 직렬화한 후 구버전으로 역직렬화할 수 있는지, 그 반대도 가능한지 테스트해야 한다. 더불어, 객체를 충실히 복제해내는지도 확인해야 한다.
86-2. Serializable 구현을 해야하는 경우
-
객체를 전송하거나 저장할 때 자바 직렬화를 이용하는 프레임워크용으로 만든 클래스
-
Serializable
을 반드시 구현해야 하는 다른 클래스의 컴포넌트로 쓰일 클래스
86-3. Serializable 구현을 피해야하는 경우
아이템87. 커스텀 직렬화 형태를 고려해보라
87-1. 기본 직렬화 형태에 적합한 경우
public class Name implements Serializable {
/**
* 성. null이 아니어야 함.
* @serial
*/
private final Stirng lastName;
/**
* 이름. null이 아니어야 함.
* @serial
*/
private final String firstName;
/**
* 중간이름. 중간이름이 없다면 null.
* @serial
*/
private final String middleName;
... // 나머지 코드는 생략
}
-
먼저 고민해보고 괜찮다고 판단될 때만 기본 직렬화 형태를 사용하라.
-
객체의 물리적 표현과 논리적 내용이 같다면 기본 직렬화 형태라도 무방하다.
-
기본 직렬화 형태가 적합하다고 결정했더라도 불변식 보장과 보안을 위해 readObject 메서드를 제공해야 할 때가 많다.
✔ @serial
태그로 기술한 내용은 API 문서에서 직렬화 형태를 설명하는 특별한 페이지에 기록된다.
87-2. 기본 직렬화 형태에 적합하지 않은 경우
public final class StringList implements Serializable {
private int size = 0;
private Entry head = null;
private static class Entry implements Serializable {
String data;
Entry next;
Entry previous;
}
... // 나머지 코드는 생략
}
위 클래스는 논리적으로 일련의 문자열을 표현했고, 물리적으로는 문자열들을 이중 연결 리스트로 표현했다. 이 클래스에 기본 직렬화 형태를 사용하면 각 노드의 양방향 연결 정보를 포함해 모든 엔트리(Entry)를 철두철미하게 기록한다.
이렇게 객체의 물리적 표현과 논리적 표현의 차이가 클 때 기본 직렬화 형태를 사용하면 크게 네 가지 면에서 문제가 생긴다.
-
공개 API가 현재의 내부 표현 방식에 종속적이게 된다.
- 연결 리스트를 사용하지 않게 바꾸더라도 관련 처리는 필요하다.(기존 소스)
-
너무 많은 공간을 차지할 수 있다.
-
기본 직렬화를 사용할 때 각 노드의 연결 정보까지 모두 포함될 것이다.
-
이런 정보는 내부 구현에 해당하니 직렬화 형태에 가치가 없다. => 성능⏬
-
-
시간이 너무 많이 걸릴 수 있다.
- 직렬화 로직은 객체 그래프의 위상에 관한 정보가 없으니, 직접 순회할 수밖에 없다.
-
스택 오버플로를 일으킬 수 있다.
- 기본 직렬화 형태는 객체 그래프를 재귀 순회한다. 호출 정도가 많아지면 자칫 스택 오버플로를 일으킬 수 있다.
87-3. 합리적인 커스텀 직렬화 형태
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) { ... }
/**
* StringList 인스턴스를 직렬화한다.
*/
private void writeObject(ObjectOutputStream stream)
throws IOException {
stream.defaultWriteObject();
stream.writeInt(size);
// 모든 원소를 순서대로 기록한다.
for (Entry e = head; e != null; e = e.next) {
s.writeObject(e.data);
}
}
private void readObject(ObjectInputStream stream)
throws IOException, ClassNotFoundException {
stream.defaultReadObject();
int numElements = stream.readInt();
// 모든 원소를 읽어 이 리스트에 삽입한다.
for (int i = 0; i < numElements; i++) {
add((String) stream.readObject());
}
}
... // 나머지 코드는 생략
}
transient
키워드가 붙은 필드는 기본 직렬화 형태에 포함되지 않는다.
- 해당 객체의 논리적 상태와 무관한 필드라고 확신할 때만
transient
한정자를 생략해야 한다.
87-4. 동기화와 직렬버전 UID
기본 직렬화 사용 여부와 상관없이 객체의 전체 상태를 읽는 메서드에 적용해야 하는 동기화 메커니즘을 직렬화에도 적용해야 한다.
private synchronized void writeObject(ObjectOutputStream stream)
throws IOException {
stream.defaultWriteObject();
}
어떤 직렬화 형태를 택하든 직렬화 가능 클래스 모두에 직렬 버전 UID를 명시적으로 부여하자.
- 잠재적인 호환성 문제가 사라진다.
- 성능도 조금 빨라진다.(런타임에 연산수행필요 없어짐)
- 구버전으로 직렬화된 인스턴스들과의 호환성을 끊으려는 경우를 제외하고는 직렬 버전 UID를 절대 수정하지 말자.
private static final long serialVersionUID = <무작위로 고른 long 값>;
아이템88. readObject 메서드는 방어적으로 작성하라
Item 50에서는 불변인 날짜 범위 클래스를 만드는데 가변인 Date
필드를 이용했다.
- 불변식을 지키고 불변을 유지하기 위해 생성자와 접근자에서
Date
객체를 방어적으로 복사하느라 코드가 상당히 길어졌다.
public final class Period {
private final Date start;
private final Date end;
/**
* @param start 시작 시각
* @param end 종료 시각; 시작 시각보다 뒤여야 한다.
* @throws IllegalArgumentException 시작 시각이 종료 시각보다 늦을 때 발생한다.
* @throws NullPointerException start나 end가 null이면 발행한다.
*/
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 + "보다 늦다.");
}
}
public Date start() { return new Date(start.getTime()); }
public Date end() { return new Date(end.getTime()); }
public String toString() { return start + "-" + end; }
... // 나머지 코드는 생략
}
이 클래스를 직렬화하고자 한다면 물리적 표현과 논리적 표현이 부합하므로 기본 직렬화 형태를 사용해도 나쁘지 않다. 즉, implements Serializable
을 추가하는 것으로 끝낼 수 있을 것 같다. 하지만 그렇게 된다면 불변식을 보장하지 못하게 된다.
readObject
가 또 다른 public 생성자이기 때문
88-1. readObject 메서드
readObject
메서드란❓
- 매개변수로 바이트 스트림을 받는 생성자라고 할 수 있다.
- 보통 바이트 스트림은 정상적으로 생성된 인스턴스를 직렬화해서 만들어진다.
- 하지만 불변을 깨뜨릴 의도로 만들어진 바이트 스트림을 받으면 문제가 생긴다.
- 정상적인 생서자로는 만들어낼 수 없는 객체를 생성할 수 있기 때문이다.
단순히, Period
클래스에 Serializable
구현을 추가했다고 가정했을 때, 아래와 같은 코드는 종료 시각이 시작 시각보다 앞서는 Period 인스턴스를 만들 수 있다.
public class BogusPeriod {
// 진짜 Period 인스턴스에서는 만들어질 수 없는 바이트 스트림,
private static final byte[] serializedForm = {
(byte)0xac, (byte)0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x06,
... // 생략
};
public static void main(String[] args) {
Period p = (Period) deserialize(serializedForm);
System.out.println(p);
}
// 주어진 직렬화 형태(바이트 스트림)로부터 객체를 만들어 반환한다.
static Object deserialize(byte[] sf) {
try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(sf)) {
try (ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream)) {
return objectInputStream.readObject();
}
} catch (IOException | ClassNotFoundException e) {
throw new IllegalArgumentException(e);
}
}
}
✔ serializedForm
에서 상위 비트가 1인 바이트 값들은 byte
로 형변환 했는데, 이유는 자바가 바이트 리터럴을 지원하지 않고 byte
타입은 부호가 있는(signed) 타입이기 때문이다.
아래와 같이 불변식을 깨뜨리는 결과가 나온다.
Fri Jan 01 12:00:00 PST 1999 - Sun Jan 01 12:00:00 PST 1984
88-2. readObject 메서드 방어적 사용
readObject
메서드가 defaultReadObject
를 호출한 다음 역직렬화된 객체가 유효한지 검사해야 한다. 여기서 유효성 검사에 실패한다면 InvalidObjectException
을 던지게 하여 잘못된 역직렬화가 발생하는 것을 막을 수 있다.
private void readObject(ObjectInputStream s)
throws IOException, ClassNotFoundException {
s.defaultReadObject();
// 불변식을 만족하는지 검사한다.
if (start.compareTo(end) > 0) {
throw new InvalidObjectException(start + "가" + end + "보다 늦다.");
}
}
위 코드는 살짝 부족하다. 정상적인 Period
인스턴스에서 시작된 바이트 스트림 끝에 private Date
필드로의 참조를 추가하면 가변적인 Period 인스턴스를 만들어낼 수 있다. 아래 가변 공격 코드를 살펴보자.
public class MutablePeriod {
// Period 인스턴스
public final Period period;
// 시작 시각 필드 - 외부에서 접근할 수 없어야 한다.
public final Date start;
// 종료 시각 필드 - 외부에서 접근할 수 없어야 한다.
public final Date end;
public MutablePeriod() {
try {
ByteArrayOutputStream 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 }; // 참조 #5
bos.write(ref); // 시작(start) 필드
ref[4] = 4; // 참조 #4
bos.write(ref); // 종료(end) 필드
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);
}
}
public static void main(String[] args) {
MutablePeriod mp = new MutablePeriod();
Period p = mp.period;
Date pEnd = mp.end;
// 시간을 되돌리자!
pEnd.setYear(78);
System.out.println(p);
// 60년대로 회귀!
pEnd.setYear(69);
System.out.println(p);
}
}
문제의 원인은 Period
의 readObject
메서드가 방어적 복사를 충분히 하지 않음에 있다. 객체를 역직렬화를 할 때는 클라이언트가 소유해서는 안 되는 객체 참조를 갖는 필드는 모두 방어적으로 복사를 해야 한다. 즉, readObject
에서는 불변 클래스 안의 모든 private
가변 요소를 방어적으로 복사해야 한다.
88-3. 방어적 복사와 유효성 검사
방어적 복사를 유효성 검사보다 앞서 수행하며, 또한 Date의 clone
메서드는 사용하지 않았음에 주목하자.
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
s.defaultReadObject();
// 가변 요소들을 방어적으로 복사한다.
start = new Date(start.getTime());
end = new Date(end.getTime());
// 불변식을 만족하는지 검사한다.
if (start.compareto(end) > 0) {
throw new InvalidObjectException(start + " after " + end);
}
}
또한 final
필드는 방어적 복사가 불가능하니 주의해야 한다. 따라서 start와 end 필드에서 final
키워드를 제거해야 한다. 아쉬운 일이지만 공격을 받는 것보다낫다.
88-4. 결론
readObject
메서드를 작성할 때는 언제나public
생성자를 작성하는 자세로 임해야 한다.readObject
메서드는 어떤 바이트 스트림이 넘어오더라도 유효한 인스턴스를 만들어내야 한다.- 바이트 스트림이 진짜 직렬화된 인스턴스라고 가정해서는 안된다.
- 안전한
readObject
메서드를 작성하는 지침private
이여야 하는 객체 참조 필드는 각 필드가 가리키는 객체를 방어적으로 복사하라.- 모든 불변식을 검사하고, 어긋난다면
InvalidObjectException
을 던져라. - 역직렬화 후 객체 그래프 전체의 유효성을 검사해야 한다면
ObjectInputValidation
를 사용하라. - 직접적이든 간접적이든, 재정의(Overriding) 가능한 메서드는 호출하지 말자.
아이템89. 인스턴스 수를 통제해야 한다면 readResolve보다는 열거 타입을 사용하라
89-1. 싱글턴 패턴
앞선 아이템 3에서는 아래와 같은 싱글턴 패턴 예를 보았다.
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() {...}
public void leaveTheBuilding(){...}
}
하지만 이 클래스는 implements Serializable
를 추가하는 순간 싱글턴이 아니게 된다. 기본 직렬화를 쓰지 않더라도, 명시적인 readObject
메서드를 제공하더라도 소용이 없다. 어떤 readObject
메서드를 사용하더라도 초기화될 때 만들어진 인스턴스와 다른 인스턴스를 반환하게 된다.
89-2. readResolve
readResolve
기능 이용하면 readObject
메서드가 만든 인스턴스를 다른 것으로 대체할 수 있다. 이때 새로 생성된 객체의 참조는 유지하지 않으므로 가비지 컬렉션의 대상이 된다.
// 인스턴스 통제를 위한 readResolve - 개선의 여지가 있다!
private Object readResolve() {
// 진짜 Elvis를 반환하고, 가짜 Elvis는 가비지 컬렉터에 맡긴다.
return INSTANCE;
}
한편 여기서 살펴본 Elvis
인스턴스의 직렬화 형태는 아무런 실 데이터를 가질 이유가 없으니, 모든 인스턴스 필드는 transient
로 선언해야 한다. 사실readResolve
메서드를 인스턴스의 통제 목적으로 사용한다면 객체 참조 타입 인스턴스 필드는 모두 transient
로 선언해야 한다.
- 잘 조작된 스트림을 써서 해당 참조 필드의 내용이 역직렬화 되눈 시점에 그 역직렬화된 인스턴스의 참조를 훔쳐올 수 있다.
89-3. Enum
직렬화 가능한 인스턴스 통제 클래스를 열거 타입을 이용해 구현하면 선언한 상수 외의 다른 객체는 존재하지 않음을 자바가 보장해준다.
AccessibleObject.setAccessible
같은 특권 메서드를 악용하면 예외이다.
public enum Elvis {
INSTANCE;
private String[] favoriteSongs = {"Hound Dog", "Heartbreak Hotel"};
public void printFavorites(){
System.out.println(Arrays.toString(favoriteSongs));
}
}
인스턴스 통제를 위해 readResolve
메서드를 사용하는 방식이 완전히 쓸모없는 것은 아니다. 직렬화 가능 인스턴스 통제 클래스를 작성해야 하는데, 컴파일 타임에는 어떤 인스턴스들이 있는지 알 수 없는 상황이라면 열거 타입으로 표현하는 것이 불가능하기 때문이다.
아이템90. 직렬화된 인스턴스 대신 직렬화 프록시 사용을 검토하라
Serializable
을 구현할 때 버그와 보안 문제가 일어날 가능성이 커진다는 것을 앞선 아이템들에서 알아보았다. 이 위험을 크게 줄여줄 기법이 직렬화 프록시 패턴
이다.
90-1. 직렬화 프록시 패턴
바깥 클래스의 논리적 상태를 표현하는 중첩 클래스를 설계하여 private static
으로 선언한다.
-
이 중첩 클래스가 바깥 클래스의 직렬화 프록시다.
-
중첩 클래스의 생성자는 단 하나여야 하며, 바깥 클래스를 매개변수로 받아야 한다. 이 생성자는 단순히 인수로 넘어온 인스턴스의 데이터를 복사한다. 일관성 검사나 방어적 복사도 필요가 없다.
-
바깥 클래스와 직렬화 프록시 모두
Serializable
을 구현해야 한다.
class Period implements Serializable {
private final Date start;
private final Date end;
public Period(Date start, Date end) {
this.start = start;
this.end = end;
}
// Period 클래스용 직렬화 프록시
private static class SerializationProxy implements Serializable {
private final Date start;
private final Date end;
public SerializationProxy(Period p) {
this.start = p.start;
this.end = p.end;
}
private static final long serialVersionUID = 234098243823485285L; // 아무 값이나 상관없다.
// Period.SerializationProxy용 readResolve 메서드
private Object readResolve() {
return new Period(start, end); // public 생성자 사용
}
}
// 직렬화 프록시 패턴용 writeReplace 메서드
private Object writeReplace() {
return new SerializationProxy(this);
}
// 직렬화 프록시 패턴용 readObject 메서드
private void readObject(ObjectInputStream stream) throws InvalidObjectException {
throw new InvalidObjectException("프록시가 필요해요.");
}
}
90-2. 직렬화 프록시의 장점
-
가짜 바이트스트림 공격과 내부 필드 탈취 공격을 프록시 수준에서 차단해준다.
-
직렬화 프록시는 멤버 필드를
final
로 선언할 수 있기 때문에 진정한 불변으로 만들 수도 있다. -
어떤 필드가 기만적인 직렬화 공격의 목표가 될지 고민하지 않아도 된다.
-
역직렬화 때 유효성 검사를 수행하지 않아도 된다.
-
역직렬화한 인스턴스와 원래의 직렬화된 인스턴스의 클래스가 달라도 정상 작동한다.
- 대표적인 예로
EnumSet
은public
생성자 없이 정적 팩터리만 제공한다. 열거타입의 원소 개수가 64개 이하면RegularEnumSet
을 사용하고 그보다 크면JumboEnumSet
을 사용하는 것이다. 그런데, 64개짜리 원소를 가진EnumSet
을 직렬화한 다음에 원소 5개를 추가하고 역직렬화를 한다면, 역직렬화할 때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;
}
90-3. 직렬화 프록시의 한계
-
클라이언트가 멋대로 확장할 수 있는 클래스에는 적용할 수 없다.
-
객체 그래프에 순환이 있는 클래스에도 적용할 수 없다.
-
방어적 복사보다 성능이 좋지 않다.
결론: 제3자가 확장할 수 없는 클래스라면 가능한 한 직렬화 프록시 패턴을 사용하자. 이 패턴이 아마도 중요한 불변식을 안정적으로 직렬화해주는 가장 쉬운 방법일 것이다.