본문 바로가기
스터디/이펙티브 자바

[이펙티브 자바] 11장 직렬화 (serializable)

by 디토20 2022. 10. 19.
반응형

 

 

 

 

[이펙티브 자바] 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
반응형

댓글