반응형
[이펙티브 자바] 11장 직렬화 (serializable)
이번 장은 객체 직렬화를 다룬다. 객체 직렬화란 자바가 객체를 바이트 스트림으로 인코딩하고(직렬화) 그 바이트 스트림으로부터 다시 객체를 재구성하는(역직렬화) 메커니즘이다. 직렬화된 객체는 다른 VM에 전송하거나, 디스크에 저장한 후 나중에 역직렬화 할 수 있다.
아이템 85. 자바 직렬화의 대안을 찾으라
- 자바 직렬화는 프로그래머가 어렵지않게 분산 객체를 만들수 있다는 장점이 있지만 보이지 않는 생성자, API와 구현 사이의 모호해진 경계, 잠재적인 정확성 문제, 성능, 보안, 유지보수성의 대가가 크다.
- 직렬화의 근본적인 문제는 공격 범위가 너무 넓고 지속적으로 더 넓어져 방어하기 어렵다는 점이다.
- readObject 메서드는 클래스패스 안의 거의 모든 타입의 객체를 만들어낼 수 있는 생성자인데, 바이트 스트림을 역직렬화하는 과정에서 이 메서드는 그 타입들 안의 모든 코드를 수행할 수 있다.
- 즉, 그 타입들의 코드 전체가 공격 범위에 들어간다.
가젯(gadget)
- 역직렬화 과정에서 호출되어 잠재적으로 위험한 동작을 수행하는 메서드
- 여러 가젯을 함께 사용하여 가젯 체인을 구성할 수 있다.
- 공격자가 하드웨어의 네이티브 코드를 마음대로 실행할 수도 있기 때문에 아주 신중하게 제작된 바이트 스트림만 역직렬화 해야 한다.
역직렬화 폭탄(deserialization bomb)
- 역직렬화에 시간이 오래 걸리는 짧은 스트림을 역직렬화 하는 것만으로도 서비스 거부 공격에 쉽게 노출될 수 있다.
- 이런 스트림을 역직렬화 폭탄이라고 한다.
역직렬화 폭탄 - 이 스트림의 역직렬화는 영원히 계속된다.
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 인스턴스의 역직렬화 해시코드 계산 때문에 영원히 끝나지 않는다.
- 이렇게 역직렬화가 영원히 계속된다는 것도 문제지만, 무언가 잘못되었다는 신호조차 주지 않는다는 것도 큰 문제다.
직렬화 위험을 회피하는 가장 좋은 방법
- 직렬화 위험을 회피하는 가장 좋은 방법은 아무것도 역직렬화하지 않는 것이다.
- 새로 작성하는 새로운 시스템에서 자바 직렬화를 써야할 이유는 전혀없다.
- 객체와 바이트 시퀀스를 변환해주는 다른 매커니즘을 사용하자.
- 레거시 시스템 때문에 직렬화를 배제할 수 없을때에는 신뢰할 수 없는 데이터는 절대 역직렬화하지 말자.
크로스-플랫폼 구조화된 데이터 표현
1. JSON
- 텍스트 기반이라 사람이 읽을 수 있어 텍스트 기반 표현에는 효과적이다.
- 오직 데이터를 표현하는 데만 사용된다.
2. 프로토콜 버퍼
- 이진 표현이라 효율이 훨씬 높다.
- 문서를 위한 스키마(타입)를 제공하고 올바로 쓰도록 강요한다.
- 텍스트 표현도 제공한다
객체 역직렬화 필터링
- 직렬화를 피할 수 없고 데이터가 안전한지 확신할 수 없다면 객체 역직렬화 필터링(java.io.ObjectInputFilter)을 사용하자.
- 객체 역직렬화 필터링은 데이터 스트림이 역직렬화되기 전에 필터를 설치하는 기능이다.
- 클래스 단위로, 특정 클래스를 받아들이거나 거부할 수 있다.
- 블랙리스트 방식과 화이트리스트 방식이 있다.
아이템 86. Srializable을 구현할지는 신중히 결정하라
- 어떤 클래스의 인스턴스를 직렬화할 수 있게 하려면 클래스 선언에 implements Serializable만 덧붙이면 된다.
- 그러나 Serializable을 구현하면 릴리스한 뒤에는 수정하기 어렵다.
- Serializable을 구현하면 직렬화된 바이트 스트림 인코딩도 하나의 공개 API가 되어 영원히 지원해야 한다.
- 그 결과 클래스의 private 인스턴스 필드들마저 API로 공개되어 버린다. (캡슐화가 깨진다)
직렬화가 클래스 개선을 방해하는 예
- 스트림 고유 식별자, 즉 직렬 버전 UID를 들 수 있다.
- 모든 직렬화된 클래스는 고유 식별 번호를 부여받는데, 명시하지 않을 경우 클래스 이름, 구현한 인터페이스들, 컴파일러가 자동으로 생성한 것을 포함한 대부분의 클래스 멤버들이 고려된다.
- 나중에 메서드를 추가하는 등, 이들 중 하나라도 수정한다면 직렬 버전 UID값도 변해 호환성이 깨져버린다.
버그와 보안의 문제점
- Serializable 구현은 버그와 보안 구멍이 생길 위험이 높아진다.
- 직렬화는 언어의 기본 메커니즘을 우회하는 객체 생성 기법이다.
- 역직렬화는 일반 생성자의 문제가 그대로 적용되는 '숨은 생성자'이다.
- 이 생성자는 전면에 드러나지 않으므로 생성자의 규칙을 떠올리기 어려워 불변식 깨짐과 허가되지 않은 접근에 쉽게 노출된다.
신버전 릴리스시 증가하는 테스트
- Serializable 구현은 해당 클래스의 신버전을 릴리스할 때 테스트 할 것이 늘어난다.
- 직렬화 가능 클래스가 수정되면 신버전인 인스턴스를 직렬화 한 후 구버전으로 역직렬화 할 수 있는지, 그 반대도 가능한지 검사해야 한다.
- 테스트해야 할 양이 직렬화 가능 클래스의 수와 릴리스 횟수에 비례한다.
Serializable 구현
- 상속용으로 설계 된 클래스는 대부분 Serializable을 구현하면 안되며, 인터페이스도 대부분 Serializable을 확장해서는 안된다.
- 클래스가 직렬화와 확장이 모두 가능하다면 인스턴스 필드 값 중 불변식을 보장해야 할 게 있다면 반드시 하위 클래스에서 finalize 메서드를 final로 선언해 재정의하지 못하게 해야 한다.
- 인스턴스 필드 중 기본값으로 초기화되면 위배되는 불변식이 있다면 클래스에 다음의 readObjectNoDate 메서드를 반드시 추가해야한다.
상태가 있고, 확장 가능하고, 직렬화 가능한 클래스용 readObjectNoData 메서드
private void readObjectNoData() throws InvalidObjectExcpetion {
throw new InvalidObjectException("스트림 데이터가 필요합니다.");
}
내부 클래스는 직렬화를 구현하지 말자
- 내부 클래스에는 바깥 인스턴스의 참조와 유효 범위 안의 지역변수 값들을 저장하기 위해 컴파일러가 생성한 필드들이 자동으로 추가된다.
- 그러므로 내부 클래스에 대한 기본 직렬화 형태는 분명하지가 않다.
- 단, 정적 멤버 클래스는 Serializble을 구현해도 된다.
아이템 87. 커스텀 직렬화 형태를 고려해보라
- 먼저 고민해보고 괜찮다고 판단될 때만 기본 직렬화 형태를 사용하라.
- 이상적인 직렬화 형태라면 물리적인 모습과 독립된 논리적인 모습만을 표현해야 한다.
- 객체의 물리적 표현과 논리적 내용이 같다면 기본 직렬화 형태라도 무방하다.
- 사람의 성명을 간략히 표현한 다음 예는 기본 직렬화 형태를 써도 괜찮을 것이다.
기본 직렬화 형태에 적합한 후보
public class Name implements Serializable {
/**
* 성. null이 아니어야 한다.
* @serial
*/
private final Stirng lastName;
/**
* 이름. null이 아니어야 한다.
* @serial
*/
private final String firstName;
/**
* 중간이름. 중간이름이 없다면 null
* @serial
*/
private final String middleName;
... // 나머지 코드는 생략
}
- 성명은 논리적으로 이름, 성, 중간이름이라는 3개의 문자열로 구성되며, 앞 코드의 인스턴스 필드들은 이 논리적 구성요소를 정확히 반영했다.
- 기본 직렬화 형태가 적합하다고 결정했더라도 불변식 보장과 보안을 위해 readObject 메서드를 제공해야 할 때가 많다.
- 위의 Name 클래스의 경우에는 readObject 메서드가 lastName과 firstName 필드가 null이 아님을 보장해야 한다.
기본 직렬화 형태에 적합하지 않은 클래스
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)를 철두철미하게 기록한다.
물리적, 논리적 표현 차이가 클 때 기본 직렬화 형태를 사용하면 생기는 문제
1. 공개 API가 현재의 내부 표현 방식에 영구히 묶인다.
- 앞의 예에서 private 클래스인 StringList.Entry가 공개 API가 되어 버린다.
- 다음 릴리스에서 내부 표현 방식을 바꾸더라도 StringList 클래스는 여전히 연결 리스트로 표현된 입력도 처리할 수 있어야 한다.
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) { ... }
/**
* 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());
}
}
// ... 생략
}
- StringList를 위한 합리적인 직렬화 형태는 단순히 리스트가 포함한 문자열의 개수를 적은 다음, 그 뒤로 문자열들을 나열하는 수준이면 될 것이다.
- StringList의 물리적인 상세 표현은 배제한 채 논리적인 구성만 담는 것이다.
- defaultWriteObject 메서드를 호출해 transient로 선언 된 필드를 직렬화/역직렬화시 무시하자.
- 캐시된 해시 값이나 JVM을 실행할 때마다 달라지는 필드는 transient를 선언해주자.
- 해당 객체의 논리적 상태와 무관한 필드라고 확신할 때만 transient 한정자를 생략해야 한다.
직렬화의 동기화
- 기본 직렬화 사용 여부와 상관없이 객체의 전체 상태를 읽는 메서드에 적용해야 하는 동기화 메커니즘을 직렬화에도 적용해야 한다.
- 예를들어 모든 메서드를 synchronized로 선언하여 스레드 안전하게 만든 객체에서 기본 직렬화를 사용하려면 writeObject도 다음 코드처럼 synchronozed로 선언해야 한다.
private synchronized void writeObject(ObjectOutputStream stream)
throws IOException {
stream.defaultWriteObject();
}
직렬버전 UID(SerialVersionUID)
- 어떤 직렬화 형태를 선택하더라도 직렬화가 가능한 클래스에는 직렬버전 UID(SerialVersionUID)를 명시적으로 선언하자.
- 이렇게 하면 직렬 버전 UID가 일으키는 잠재적 호환성 문제가 사라진다.
- 물론 선언하지 않으면 자동 생성되지만 런타임에 이 값을 생성하느라 복잡한 연산을 수행해야 한다.
// 무작위로 고른 long 값
private static final long serialVersionUID = 0204L;
- 직렬 버전 UID가 꼭 유니크할 필요는 없다.
- 다만 이 값이 변경되면 기존 버전 클래스와의 호환을 끊게 되는 것이다.
- 따라서 호환성을 끊는 경우가 아니라면 직렬 버전 UID 값을 변경해서는 안 된다.
아이템 88. readObject 메서드는 방어적으로 작성하라
- readObject 메서드는 실직적으로 또다른 생성자이기 때문에 다른 생성자와 똑같은 수준으로 주의를 기울여야 한다.
- 쉽게 말해, readObject는 매개변수로 바이트 스트림을 받는 생성자라 할 수 있다.
- 불변식을 깨뜨릴 의도로 임의의 바이트 스트림을 건네 정상적인 생성자로는 만들어낼 수 없는 객체를 생성할 수 있다.
허용되지 않는 Period 인스턴스를 생성할 수 있다.
public class BogusPeriod {
// 진짜 Period 인스턴스에서는 만들어질 수 없는 바이트 스트림,
// 정상적인 Period 인스턴스를 직렬화한 후에 손수 수정한 바이트 스트림이다.
private static final byte[] serializedForm = {
(byte)0xac, (byte)0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x06,
0x50, 0x65, 0x72, 0x69, 0x6f, 0x64, 0x40, 0x7e, (byte)0xf8,
... 생략
}
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);
}
}
}
- 종료 시각이 시작 시각보다 앞서는 괴이한 인스턴스를 만들 수 있다.
- 이를 방지하기 위해 Period의 readObject 메서드가 defaultReadObject를 호출한 다음 역직렬화된 객체가 유효한지 검사한 후, 유효성 검사에 실패하면 IvalidObjectException을 던지게 해야 한다.
유효성 검사를 수행하는 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 {
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 인스턴스는 불변식을 유지한 채 생성됐지만, 의도적으로 내부의 값을 수정할 수 있었다.
- 이는 인스턴스가 불변이라고 가정하는 클래스에 엄청난 보안 문제를 일으킬 수 있다.
- 이 문제의 원인은 readObejct의 방어적 복사가 충분하지 않은 것으로, 객체를 역직렬화할 때는 클라이언트가 소유해서는 안 되는 객체 참조를 갖는 필드를 모두 반드시 방어적으로 복사해야 한다.
- 따라서 readObject에서는 불변 클래스 안의 모든 private 가변 요소를 방어적으로 복사해야 한다.
방어적 복사와 유효성 검사를 수행하는 readObject 메서드
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);
}
}
- 방어적 복사를 유효성 검사보다 앞서 수행하며, Date의 clone 메서드는 사용하지 않았다.
- final 필드는 방어적 복사가 불가능하니 주의하자.
기본 readObject는 언제 써야 좋을까?
- transient 필드를 제외한 모든 필드의 값을 매개변수로 받아 유효성 검사 없이 필드에 대입하는 public 생성자를 추가해도 될 때
아이템 89. 인스턴스 수를 통제해야 한다면 readResolve 보다는 열거 타입을 사용하라
- 싱글턴 패턴은 바깥에서 생성자를 호출하지 못하게 막는 방식으로 인스턴스가 오직 하나만 만들어짐을 보장한다.
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { }
public void leaveTheBuilding() { ... }
}
- 그러나 이 클래스는 inplements Serializable을 추가하는 순간 더이상 싱글턴이 아니게 된다.
- 어떤 readObject를 사용하든 이 클래스가 초기화될 때 만들어진 인스턴스와는 별개의 인스턴스를 반환하게 된다.
readResolve
- readResolve 기능을 이용하면 readObject가 만들어낸 인스턴스를 다른 것으로 대체할 수 있다.
- 역직렬화한 객체의 클래스가 readResolve 메서드를 적절히 정의해뒀다면, 역직렬화 후 새로 생성된 객체를 인수로 이 메서드가 호출되고, 이 메서드가 반환한 객체 참조가 새로 생성된 객체를 대신해 반환한다.
- 대부분 이때 새로 생성된 객체의 참조는 유지하지 않으므로 바로 가비지 컬렉션 대상이 된다.
// 인스턴스 통제를 위한 readResolve - 개선의 여지가 있다.
private Object readResolve() {
// 진짜 Elvis를 반환하고, 가짜 Elvis는 가비지 컬렉터에 맡긴다.
return INSTANCE;
}
- 이 메서드는 역직렬화 객체는 무시하고 클래스 초기화 때 만들어진 Elvis 인스턴스를 반환하므로 Elvis 인스턴스의 직렬화 형태는 실 데이터를 가질 이유가 없으니 모든 인스턴스 필드를 transient로 선언해야 한다.
- 사실 readResolve를 인스턴스 통제 목적으로 사용한다면 객체 참조 타입 인스턴스 필드는 모두 transient로 선언해야 한다.
더 나은 선택
- 객체 참조 타입을 transient로 선언할 수도 있지만 원소 하나짜리 열거 타입으로 바꾸는 편이 더 낫다.
- 직렬화 가능한 인스턴스 통제 클래스를 열거 타입으로 구현하면 선언한 상수 외의 다른 객체는 존재하지 않음을 자바가 보장해준다.
아이템 90. 직렬화된 인스턴스 대신 직렬화 프록시 사용을 검토하라
728x90
반응형
'스터디 > 이펙티브 자바' 카테고리의 다른 글
[이펙티브 자바] 2022.08.03 ~ 2022.10.19 다섯번째 스터디 회고록 (0) | 2022.10.31 |
---|---|
[이펙티브 자바] 10. 동시성 (0) | 2022.10.12 |
[이펙티브 자바] 09. 예외 (1) | 2022.10.05 |
[이펙티브 자바] 08. 일반적인 프로그래밍 원칙 (0) | 2022.09.27 |
[이펙티브 자바] 07. 메서드 (1) | 2022.09.21 |
댓글