BottleH Blog

이펙티브자바 3판 12장 정리

    Tags

  • Java
이펙티브자바 3판 12장 정리 thumbnail

12장 직렬화

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

목차


아이템85. 자바 직렬화의 대안을 찾으라


직렬화의 근본적인 문제는 공격 범위가 너무 넓고 지속적으로 더 넓어져 방어하기 어렵다는 점이다.

  • 바이트 스트림을 역직렬화하는 과정에서 ObjectInputStreamreadObject 메서드는 그 타입들 안의 모든 코드를 수행할 수 있다.

  • 즉, 그 타입들이 코드 전체가 공격 범위에 들어간다는 뜻

  • 역직렬화 과정에서 호출되어 잠재적인 위험한 동작을 수행하는 메서드들을 가젯(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번 넘게 호출해야 한다.

위와 같은 문제를 해결하기 위해서는

  1. 아무것도 역직렬화하지 않기
  2. 크로스-플랫폼 구조화된 데이터 표현(Cross-Platform Structured-Data Representation) 사용하기
  3. 만약 레거시 시스템으로 인해, 자바 직렬화를 사용해야 한다면 신뢰할 수 없는 데이터는 절대 역직렬화하지 말자.
    • 자바9에서 추가된 객체 역직렬화 필터링을 사용하자.

85-2. Cross-Platform Structured-Data Representation

이 표현들의 공통점은 자바 직렬화보다 훨씬 간단하고, 자동으로 직렬화/역직렬화 하지 않는다.

  • JSON은 브라우저와 서버의 통신용으로 설계되었으며, 텍스트 기반이라 사람이 읽을 수 있는 장점이 있다.
  • 프로토콜 버퍼(protobuf)는 구글이 서버 사이에 데이터를 교환하고 저장하기 위해 설계되었으며, 이진 표현이라 효율이 높다.

아이템86. Serializable을 구현할지는 신중히 결정하라.


어떤 클래스의 인스턴스를 직렬화할 수 있게 하려면 클래스 선언에 implements Serializable만 덧붙이면 된다.

  • 이렇게 간단해보이는 행위는 사실 값비싸다.

86-1. Serializable 구현의 문제점

  1. 릴리즈한 뒤에는 수정하기 어렵다.

    • 클래스가 Serializable을 구현하면 하나의 공개 API가 된다. 즉, 영원히 지원해야 하는 것이다.
    • 더불어, 기본 직렬화 형태에서는 클래스의 privatepackage-private 인스턴스 필드들마저 API로 공개되는 꼴이다.(캡슐화가 깨진다.)
    • 필드로의 접근을 최대한 막아 정보를 은닉하라는 조언(아이템15)도 무력화된다.
  2. 버그와 보안 구멍이 생길 위험이 높아진다.

    • 객체는 생성자를 사용하는 것이 기본인데 직렬화는 그것을 우회하는 기법이다. 즉, 역직렬화는 숨은 생성자다. 이 생성자는 전면에 드러나지 않으므로 "생성자에서 구축한 불변식을 모두 보장해야 하고 생성 도중 공격자가 객체 내부를 들여다 볼 수 없도록 해야 한다" 를 떠올리기 어렵다.
    • 기본 역직렬화를 사용하면 불변식 깨짐과 허가되지 않은 접근에 쉽게 노출된다
  3. 해당 클래스의 신버전을 릴리즈할 때 테스트할 것이 늘어난다.

    • 신버전 인스턴스를 직렬화한 후 구버전으로 역직렬화할 수 있는지, 그 반대도 가능한지 테스트해야 한다. 더불어, 객체를 충실히 복제해내는지도 확인해야 한다.

86-2. Serializable 구현을 해야하는 경우

  1. 객체를 전송하거나 저장할 때 자바 직렬화를 이용하는 프레임워크용으로 만든 클래스

  2. Serializable을 반드시 구현해야 하는 다른 클래스의 컴포넌트로 쓰일 클래스

86-3. Serializable 구현을 피해야하는 경우

  1. 상속용으로 설계된 클래스(아이템19)

  2. 대부분의 인터페이스

  3. 내부 클래스(아이템24)

아이템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; ... // 나머지 코드는 생략 }
  1. 먼저 고민해보고 괜찮다고 판단될 때만 기본 직렬화 형태를 사용하라.

  2. 객체의 물리적 표현과 논리적 내용이 같다면 기본 직렬화 형태라도 무방하다.

  3. 기본 직렬화 형태가 적합하다고 결정했더라도 불변식 보장과 보안을 위해 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)를 철두철미하게 기록한다.

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

  1. 공개 API가 현재의 내부 표현 방식에 종속적이게 된다.

    • 연결 리스트를 사용하지 않게 바꾸더라도 관련 처리는 필요하다.(기존 소스)
  2. 너무 많은 공간을 차지할 수 있다.

    • 기본 직렬화를 사용할 때 각 노드의 연결 정보까지 모두 포함될 것이다.

    • 이런 정보는 내부 구현에 해당하니 직렬화 형태에 가치가 없다. => 성능⏬

  3. 시간이 너무 많이 걸릴 수 있다.

    • 직렬화 로직은 객체 그래프의 위상에 관한 정보가 없으니, 직접 순회할 수밖에 없다.
  4. 스택 오버플로를 일으킬 수 있다.

    • 기본 직렬화 형태는 객체 그래프를 재귀 순회한다. 호출 정도가 많아지면 자칫 스택 오버플로를 일으킬 수 있다.

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); } }

문제의 원인은 PeriodreadObject 메서드가 방어적 복사를 충분히 하지 않음에 있다. 객체를 역직렬화를 할 때는 클라이언트가 소유해서는 안 되는 객체 참조를 갖는 필드는 모두 방어적으로 복사를 해야 한다. 즉, 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로 선언할 수 있기 때문에 진정한 불변으로 만들 수도 있다.

  • 어떤 필드가 기만적인 직렬화 공격의 목표가 될지 고민하지 않아도 된다.

  • 역직렬화 때 유효성 검사를 수행하지 않아도 된다.

  • 역직렬화한 인스턴스와 원래의 직렬화된 인스턴스의 클래스가 달라도 정상 작동한다.

    • 대표적인 예로 EnumSetpublic 생성자 없이 정적 팩터리만 제공한다. 열거타입의 원소 개수가 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. 직렬화 프록시의 한계

  1. 클라이언트가 멋대로 확장할 수 있는 클래스에는 적용할 수 없다.

  2. 객체 그래프에 순환이 있는 클래스에도 적용할 수 없다.

  3. 방어적 복사보다 성능이 좋지 않다.

결론: 제3자가 확장할 수 없는 클래스라면 가능한 한 직렬화 프록시 패턴을 사용하자. 이 패턴이 아마도 중요한 불변식을 안정적으로 직렬화해주는 가장 쉬운 방법일 것이다.

Written by@BottleH
Back-End Developer

GitHub