Effective Java 3rd - Ch12
포스트
취소

Effective Java 3rd - Ch12

Java 기초를 다지기 위해 효과적인 자바란 책을 공부 중입니다.

제12장 직렬화

  • 객체 직렬화란 직렬화와 역직렬화에 관한 메커니즘이다
    • 직렬화 : 자바가 객체를 바이트 스트림으로 인코딩하고
    • 역직렬화 : 그 바이트 스트림으로부터 다시 객체를 재구성하는
  • 직렬화된 객체는 다른 VM 에 전송하거나 디스크에 저장한 후 나중에 역직렬화할 수 있다
  • 이번 장은 직렬화가 품고 있는 위험과 그 위험을 최소화하는 방법에 집중한다

85) 자바 직렬화의 대안을 찾아라

프로그래머가 어렵지 않게 분산 객체를 만들 수 있다는 구호는 매력적이었지만,
보이지 않는 생성자, API 와 구현 사이의 모호해진 경계, 잠재적인 정확성 문제, 성능, 보안, 유지보수성 등 그 대가가 컸다.

직렬화의 근본적인 문제는 공격 범위가 너무 넓고 지속적으로 더 넓어져 방어하기 어렵다는 점이다.
:arrow_right: 예) ObjectInputStream 의 readObject 메서드는 거의 모든 타입의 객체를 만들어 낼 수 있는 마법같은 생성자다

코드 85-1 역직렬화 폭탄 - 이 스트림의 역직렬화는 영원히 계속된다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import java.util.HashSet;
import java.util.Set;

public class DeserializationBomb {
    public static void main(String[] args) throws Exception {
        System.out.println(bomb().length);
        deserialize(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);
    }
}

이 문제들을 어떻게 대처해야 할까?

  • 직렬화 위험을 회피하는 가장 좋은 방법은 아무것도 역직렬화하지 않는 것이다.
    • 여러분이 작성하는 새로운 시스템에서 자바 직렬화를 써야 할 이유는 전혀 없다
    • 객체와 바이트 시퀀스를 변환해주는 다른 메커니즘이 많이 있다.
  • :bangbang:   크로스-플랫폼 구조화된 데이터 표현 방법이 있다!!
    • 속성-값 쌍의 집합으로 구성된 간단하고 구조화된 데이터 객체를 사용 (자바 직렬화보다 훨씬 간단)
    • :arrow_right: JSON(데이터 표현용) 과 프로토콜 버퍼(protobuf)(binary와 텍스트표현도 지원)
  • 차선책으로 신뢰할 수 없는 데이터 는 절대 역직렬화하지 않는 것
  • 확신도 할 수 없다면 객체 역직렬화 필터링(java.io.ObjectInputFilter)을 사용하자
    • 클래스 단위로 받아들이거나 거부할 수 있다
    • 기본수용 모드(블랙리스트)기본거부 모드(화이트리스트) 가 있음 (화이트리스트 권장)

86) Serializable을 구현할지는 신중히 결정하라

보호된 환경에서만 쓰일 클래스가 아니라면 직렬화 구현은 아주 신중하게 이뤄져야 한다.

Serializable 구현의 문제점

  • 릴리즈한 뒤에는 수정하기 어렵다. (이미 API 처럼 널리 공개된 상태라면)
    • 뒤늦게 클래스 내부 구현을 손보면 원래의 직렬화 형태와 달라지게 된다.
      • 예: serialVersionUID 자동 생성값이 변경됨 (클래스 멤버들이 고려되기 때문에)
    • 직렬화 가능 클래스를 만들고자 한다면, 길게 보고 감당할 수 있을 만큼 주의해서 설계해야 한다.
  • 버그와 보안 구멍이 생길 위험이 높아진다
    • 언어의 기본 메커니즘을 우회하는 객체 생성 기법이기 때문에
  • 해당 클래스의 신버전을 릴리즈할 때 테스트할 것이 늘어난다
    • 신버전 인스턴스가 구버전으로 역직렬화할 수 있는지, 그 반대도 가능한지 검사해야 함

상속용으로 설계된 클래스는 대부분 Serializable 을 구현하면 안되며, 인터페이스도 대부분 Serializable 을 확장해서는 안된다.

  • Throwable 과 Component 는 예외

직렬화와 확장이 모두 가능하게 하려면 주의할 점

  • 인스턴스 필드값 중 불변식을 보장해야 할 것이 있다면 반드시 하위 클래스에서 finalize 메서드를 재정의하지 못하게 해야 한다.
    • finalizer 공격에 대한 방어 (항목 8)
  • 인스턴스 필드 중 기본값으로 초기화되면 위배되는 불변식이 있다면 readObjectNoData 메서드를 반드시 추가해야 한다.
    • 기존의 직렬화 가능 클래스에 직렬화 가능 상위 클래스를 추가하는 드문 경우를 위한 메서드 (자바 4부터)

코드 86-1 상태가 있고, 확장 가능하고, 직렬화 가능한 클래스용 readObjectNoData 메서드

1
2
3
private void readObjectNoData() throws InvalidObjectException {
    throw new InvalidObjectException("스트림 데이터가 필요합니다");
}

직렬화를 구현하지 않기로 할 때 주의점

  • 그 하위 클래스에서 직렬화를 지원하려 할 때 부담이 늘어난다
    • 이런 클래스를 역직렬화하려면 그 상위 클래스는 매개변수가 없는 생성자를 제공해야 하는데
    • 여러분이 이런 생성자를 제공하지 않으면 하위 클래스에서는 직렬화 프록시 패턴(항목 90)을 사용해야 한다

내부 클래스는 직렬화를 구현하지 말아야 한다.

  • 내부 클래스에 대한 기본 직렬화 형태는 분명하지가 않다
  • 단, 정적 멤버 클래스는 직렬화를 구현해도 된다

87) 커스텀 직렬화 형태를 고려해보라

기본 직렬화 형태를 사용하는 경우 고려사항

  • 먼저 고민해보고 괜찮다고 판단될 때만 기본 직렬화 형태를 사용하라
  • 객체의 물리적 표현과 논리적 내용이 같다면 기본 직렬화 형태라도 무방하다
  • 기본 직렬화 형태가 적합하다고 결정했더라도 불변식 보장과 보안을 위해 readObject 메서드를 제공해야 할 때가 많다

코드 87-1 기본 직렬화 형태에 적합한 후보

1
2
3
4
5
6
7
8
public class Name implements Serializable {

    private final String lastName;
    private final String firstName;
    private final String middleName;

    ... // 나머지 코드 생략
}

다음 클래스는 직렬화 형태에 적합하지 않은 예로, 문자열 리스트를 표현하고 있다.
객체의 물리적 표현과 논리적 표현의 차이가 클 때 기본 직렬화 형태를 사용하면 문제가 생긴다.

  • 공개 API 가 현재의 내부 표현 방식에 영구히 묶인다
    • private 클래스인 StringList.Entry 가 공개 API가 되어 관련 코드를 제거할 수 없게 된다
  • 너무 많은 공간을 차지할 수 있다
    • 문자열 연결 구조까지 직렬화 형태에 포함되어 디스크에 저장하거나 네트워크 전송에 느려진다
  • 시간이 너무 많이 걸릴 수 있다
    • 직렬화 로직은 객체 그래프의 위상에 관한 정보가 없으니 그래프를 직접 순회해볼 수 밖에 없다.
  • 스택 오버플로를 일으킬 수 있다
    • 기본 직렬화 과정은 객체 그래프를 재귀 순회하는데, 자칫 스택 오버플로를 일으킬 수 있다

코드 87-2 기본 직렬화 형태에 적합하지 않은 예

1
2
3
4
5
6
7
8
9
10
11
12
13
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;
    }

    ... // 나머지 코드 생략
}

StringList 를 위한 합리적인 직렬화 형태는 무엇일까?

  • 단순히 리스트가 포함한 문자열의 개수를 적은 다음, 그 뒤로 문자열들을 나열하는 수준이면 될 것이다
  • 물리적인 상세 표현은 배제한 채 논리적인 구성만 담는 것이다
  • 일시적이란 뜻의 transient 한정자는 해당 인스턴스 필드가 기본 직렬화 형태에 포함되지 않는다는 표시이다.
    • 해당 객체의 논리적 상태와 무관한 필드라고 확신할 때만 transient 한정자를 생략해야 한다
    • 기본 직렬화를 사용한다면 transient 필드들은 역직렬화될 때 기본값으로 초기화된다

코드 87-3 합리적인 커스텀 직렬화 형태를 갖춘 StringList

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 문자열들의 길이가 평균 10 이라면, 개선 버전의 직렬화 형태는
// ==> 원래 버전의 절반 정도의 공간을 차지하며, 두 배 정도 빠르게 수행된다
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) { ... }

    private void writeObject(ObjectOutputStream s) throws IOException {
        s.defaultWriteObject();
        s.writeInt(size);           // 원소의 사이즈를 기록

        // 리스트의 모든 원소를 올바른 순서로 기록한다
        for (Entry e = head; e != null; e = e.next)
            s.writeObject(e.data);
    }

    private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
        s.defaultReadObject();
        int numElements = s.readInt();      // 원소의 사이즈를 읽어오고

        // 모든 원소를 읽어 순서대로 연결한다
        for (int i = 0; i < numElements; i++)
            add((String) s.readObject());
    }

    ... // 나머지 코드 생략
}

직렬화 구현시 주의할 점

  • 객체의 전체 상태를 읽는 메서드에 적용해야 하는 동기화 메커니즘을 직렬화에도 적용해야 한다.
    • synchronized 선언된 객체를 기본 직렬화 하려면 writeObject 도 synchronized 선언을 해야 함
  • 어떤 직렬화 형태를 택하든 직렬화 가능 클래스 모두에 직렬버전 UID 를 명시적으로 부여하자.
    • 직렬버전 UID 를 명시하지 않으면 런타임에 이 값을 생성하느라 복잡한 연산을 수행하기 때문
  • 구버전으로 직렬화된 인스턴스들과의 호환성을 끊으려는 경우를 제외하고는 직렬버전 UID 를 절대 수정하지 말자.
    • 직렬버전 UID 가 다르면 InvalidClassException 이 던져질 것이다

코드 87-4 기본 직렬화를 사용하는 동기화된 클래스를 위한 writeObject 메서드

1
2
3
private synchronized void writeObject(ObjectOutputStream s) throws IOException {
    s.defaultWriteObject();
}

88) readObject 메서드는 방어적으로 작성하라

이 클래스의 선언에 implements Serializable 을 추가하면 불변식을 더는 보장하지 못하게 된다.

  • readObject 메서드가 실질적으로 또 다른 public 생성자이기 때문 (바이트 스트림을 받는 생성자)
    • 조작된 바이트 스트림으로 허용되지 않는 Period 인스턴스를 만들 수 있다
  • readObject 메서드에서도 인수가 유효한지 검사해야 하고, 필요하다면 매개변수를 방어적으로 복사해야 한다
    • 유효성 검사에 실패하면 InvaildObjectException 을 던지게 하여 잘못된 역직렬화가 일어나는 것을 막을 수 있다
    • 클라이언트가 소유해서는 안되는 객체 참조를 갖는 필드를 모두 반드시 방어적으로 복사해야 한다

코드 88-1 방어적 복사를 사용하는 불변 클래스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 항목 50에서 사용된 클래스 (불변을 유지하기 위해 방어적으로 복사하여 생성)
public final class Period {
    private final Date start;
    private final Date end;

    /**
     * @param  start 시작 시간
     * @param  end 종료 시각; 시작 시간보다 뒤여야 한다
     * @throws IllegalArgumentException 시작 시간이 종료 시간보다 늦을 때 발생한다
     * @throws NullPointerException 시작 또는 종료 시간이 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 + " after " + end);
    }

    public Date start () { return new Date(start.getTime()); }
    public Date end () { return new Date(end.getTime()); }
    public String toString() { return start + " - " + end; }

    ... // 나머지 코드 생략
}

코드 88-3 유효성 검사를 수행하는 readObject 메서드 - 아직 부족하다

1
2
3
4
5
6
7
8
9
private void readObject(ObjectInputStream stream)
        throws IOException, InvalidObjectException {
    s.defaultReadObject();

    // 불변식을 만족하는지 검사한다
    if( start.compareTo(end) > 0)
        throw new InvalidObjectException(start + " after " + end);
}

코드 88-4 방어적 복사와 유효성 검사를 수행하는 readObject 메서드

1
2
3
4
5
6
7
8
9
10
11
12
13
private void readObject(ObjectInputStream stream)
        throws IOException, InvalidObjectException {
    s.defaultReadObject();

    // 가변 요소들을 방어적으로 복사한다
    this.start = new Date(start.getTime());
    this.end   = new Date(end.getTime());

    // 불변식을 만족하는지 검사한다
    if( start.compareTo(end) > 0)
        throw new InvalidObjectException(start + " after " + end);
}

:bangbang:   readObject 메서드를 작성하는 지침

  • private 이여야 하는 객체 참조 필드는 각 필드가 가리키는 객체를 방어적으로 복사하라
    • 불변 클래스 내의 가변 요소가 여기 속한다
  • 모든 불변식을 검사하여 어긋나는게 발견되면 InvalidObjectException 을 던진다
    • 방어적 복사 다음에는 반드시 불변식 검사가 뒤따라야 한다
  • 역직렬화 후 객체 그래프 전체의 유효성을 검사해야 한다면 ObjectInputValidation 인터페이스를 사용하라
    • 이 책에서는 다루지 않는다
  • 직접적이든 간접적이든, 재정의할 수 있는 메서드는 호출하지 말자

89) 인스턴스 수를 통제해야 한다면 readResolve 보다는 Enum 타입을 사용하라

readResolve 기능을 이용하면 readObject 가 만들어낸 인스턴스를 다른 것으로 대체할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
public class Elvis {
    public static final Elvis INSTANCE = new Elvis();
    private Elvis() { ... }

    public void leaveTheBuilding() { ... }

    // 인스턴스 통제를 위한 readResolve - 개선의 여지가 있다!
    private Object readResolve() {
        // 진짜 Elvis 를 반환하고, 가짜 Elvis 는 가비지 컬렉터에 맡긴다
        return INSTANCE;
    }
}

코드 89-1 잘못된 싱글턴 - transient 가 아닌 참조 필드를 가지고 있다!

1
2
3
4
5
6
7
8
9
10
11
12
public class Elvis implements Serializable {
    public static final Elvis INSTANCE = new Elvis();
    private Elvis() { ... }

    // transient 가 아닌 참조필드
    private String[] favoriteSongs = { "Hound Dog", "Heartbreak Hotel" };
    public void printFavorites() {
        System.out.println(Arrays.toString(favoriteSongs));
    }

    private Object readResolve() { return INSTANCE; }
}

:bangbang:   핵심 정리

  • 불변식을 지키기 위해 인스턴스를 통제해야 한다면 가능한 한 열거 타입을 사용하자.
  • 여의치 않은 상황에서 직렬화와 인스턴스 통제가 모두 필요하다면
    • readResolve 메서드를 작성해 넣어야 하고
    • 그 클래스에서 모든 참조 타입 인스턴스 필드를 transient 로 선언해야 한다

코드 89-4 열거 타입 싱글턴 - 전통적인 싱글턴보다 우수하다

1
2
3
4
5
6
7
public enum Elvis {
    INSTANCE;
    private String[] favoriteSongs = { "Hound Dog", "Heartbreak Hotel" };
    public void printFavorites() {
        System.out.println(Arrays.toString(favoriteSongs));
    }
}

90) 직렬화된 인스턴스 대신 직렬화 프록시 사용을 검토하라

직렬화 프록시 패턴은 버그와 보안 문제에 대한 위험을 크게 줄여준다.

:bangbang:   핵심 정리

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

코드 90-1 Period 클래스용 직렬화 프록시

1
2
3
4
5
6
7
8
9
10
11
12
private static class SerializationProxy implements Serializable {
    private final Date start;
    private final Date end;

    SerializationProxy(Period p) {
        this.start = p.start;
        this.end = p.end;
    }

    private static final long serialVersionUID =
            234098243823485285L;    // 아무 값이나 상관 없다
}

직렬화 프록시 패턴은 그리 복잡하지 않다.

  • 바깥 클래스의 논리적 상태를 정밀하게 표현하는 중첩 클래스를 설계해 private static 으로 선언한다
    • 생성자는 단 하나여야 하며, 바깥 클래스를 매개변수로 받아야 한다
  • 바깥 클래스와 직렬화 프록시 모두 Serializable 을 선언해야 한다
  • 바깥 클래스에 writeReplace 메서드를 추가한다
    • 이 메서드는 범용적이니 그대로 복사해 쓰면 된다
  • 바깥 클래스와 논리적으로 동일한 인스턴스를 반환하는 readResolve 메서드를 Serialization 클래스에 추가한다
    • 이 메서드는 역직렬화 시에 직렬화 시스템이 직렬화 프록시를 다시 바깥 클래스의 인스턴스로 변환하게 해준다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// Immutable class that uses defensive copying
public final class Period implements Serializable {
    private final Date start;
    private final Date end;

    /**
     * @param  start the beginning of the period
     * @param  end the end of the period; must not precede start
     * @throws IllegalArgumentException if start is after end
     * @throws NullPointerException if start or end is 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 + " after " + end);
    }

    public Date start () { return new Date(start.getTime()); }
    public Date end () { return new Date(end.getTime()); }
    public String toString() { return start + " - " + end; }

    // Serialization proxy for Period class
    private static class SerializationProxy implements Serializable {
        private final Date start;
        private final Date end;

        SerializationProxy(Period p) {
            this.start = p.start;
            this.end = p.end;
        }

        private Object readResolve() {
            return new Period(start, end);  // public 생성자를 사용한다
        }

        private static final long serialVersionUID = 234098243823485285L;
    }

    // writeReplace method for the serialization proxy pattern
    private Object writeReplace() {
        return new SerializationProxy(this);
    }

    // readObject method for the serialization proxy pattern
    private void readObject(ObjectInputStream stream)
            throws InvalidObjectException {
        throw new InvalidObjectException("Proxy required");
    }
}

:hand: 참고 : Serialization Proxy Pattern in Java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 사용 사례
public static void main(String[] args) {
    // Creating and initializaing a Person object
    Person person = new Person("User1", 1, 22);
    // file name
    final String fileName = "F://person.ser";
    System.out.println("About to serialize ....");
    // serializing
    Util.serialzeObject(person, fileName);

    try {
        System.out.println("About to deserialize ....");
        // deserializing
        person = (Person)Util.deSerialzeObject(fileName);
        System.out.println("id " + person.getId() + " Name "+ person.getName()
                            + " Age " + person.getAge());
    } catch (ClassNotFoundException e) {
        e.printStackTrace();
    }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 직렬화, 역직렬화 Util
public class Util {
    public static void serialzeObject(Object obj, String fileName){
        try(ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(new File(fileName)))){
            oos.writeObject(obj);
        }
        catch (FileNotFoundException e) { e.printStackTrace(); }
        catch (IOException e) { e.printStackTrace(); }
    }

    public static Object deSerialzeObject(String fileName) throws ClassNotFoundException{
        Object obj = null;
        try(ObjectInputStream ois = new ObjectInputStream(new FileInputStream(new File(fileName)))){
            obj = ois.readObject();
        }
        catch (FileNotFoundException e) { e.printStackTrace(); }
        catch (IOException e) { e.printStackTrace(); }
        return obj;
    }
}

EnumSet 의 사례를 생각해보자.

코드 90-2 EnumSet 의 직렬화 프록시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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.toArrary(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 = 4321543254325643653L;
}

직렬화 프록시 패턴의 한계

  • 클라이언트가 멋대로 확장할 수 있는 클래스에는 적용할 수 없다
  • 객체 그래프에 순환이 있는 클래스에도 적용할 수 없다 (ClassCastException 발생)
  • 직렬화 프록시 패턴이 주는 강력함과 안전성에도 대가는 따른다
    • Period 의 경우 방어적 복사 때보다 14% 느렸다

 
 

끝!   읽어주셔서 감사합니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.