본문 바로가기

나(다)/책

이펙티브 자바 - 12장 : 직렬화

반응형

직렬화

객체 직렬화란 자바가 객체를 바이트 스트림으로 인코딩하고(직렬화) 그 바이트 스트림으로부터 다시 객체를 재구성하는(역직렬화) 메커니즘이다.

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

자바 직렬화는 취약점들로 인해 공격을 받는데 직렬화의 근본적인 문제는 공격 범위가 넓고 지속적으로 넓어져 방어하기가 어렵다.

ObjectStream의 readObject 메서드를 호출하면서 객체 그래프가 역질렬화되기 때문이다.

공격자와 보안 전문가들은 자바 라이브러리와 널리 쓰이는 서드파티 라이브러리에서 직렬화 기능 타입들을 연구하여 역직렬화 과정에서 호출되어 잠재적으로 위험한 동작을 수행하는 메서드들으 찾아보았고 이런 메서드를 가젯 메서드라고 부른다.

역직렬화에 시간이 오래 걸리는 짦은 스트림을 역질렬화하는 것만으로도 서비스 거부 공격에 쉽게 노출될 수 있고 이런 스트림을 역질렬화 폭탄이라고 한다.

// 버우터르 쿠카르츠가 HashSet을 이용해 만든 예이다.
// 역질렬화 폭탄 - 이 스트림의 역직렬화는 영원히 계속된다.
static byte[] bomb() {
    Set<Object> root = new HashSetM<>();
    Set<Object> s1 = root;
    Set<Obejct> s2 = new HashSet<>();
    for (int i = 0; i < 100; i++) {
        Set<Object> t1 = new HashSet<>();
        Set<Object> t2 = new HashSet<>();
        t1.add("foo");
        s1.add(t1); s1.add(t2);
        s2.add(t1); s2.add(t2);
        s1 = t1;
        s2 = t2;
    }
    return serialize(root);
}
  • 객체 그래프는 201개의 HashSet 인스턴스로 구성되며, 그 각각은 3개 이하의 객체 참조를 갖는다. 스트림의 전체 크기는 5,744 바이트지만, 역질렬화는 태양이 불타 식을 때까지도 끝나지 않을 것이다.
  • 문제는 HashSet 인스턴스를 역질렬화하려면 그 원소들의 해시코드를 계산해야 한다는데 있다.
  • 루트 HashSet에 담긴 두 원소는 각각 (루트와 마찬가지로) 다른 HashSet 2개씩을 원소로 갖는 HashSet이다. 그리고 반복문에 의해 이 구조가 깊이 100단계까지 만들어 진다. 따라서 이 HashSet을 역직렬화하려면 hashCode 메서드를 2^100번 넘게 호출해야 한다.
  • 역직렬화가 영원히 계속된다는 것도 문제지만, 무언가 잘못되었다는 신호조차 주지 않는다는 것도 큰 문제다. 이 코드는 단 몇 개의 객체만 생성해도 스택 깊이 제한에 걸려버린다.

가장 간단한 해결방법

  • 직렬화 위험을 회피하는 가장 좋은 방법은 아무것도 역직렬화하지 않는 것이다.
    • 애초에 신뢰할 수 없는 바이트 스트림을 역직렬화하는 일 자체가 스스로를 공격에 노출하는 행위다.
  • 여러분이 작성하는 새로운 시스템에서 자바 직렬화를 써야 할 이유는 전혀 없다.
    • 승리하는 유일한 길은 전쟁하지 않는 것이다.
  • 객체와 바이트 시퀀스를 변환해주는 다른 메커니즘이 많이 있다. 이 방식들은 자바 직렬화의 여러 위험을 회피하면서 다양한 플랫폼 지원, 우수한 성능, 풍부한 지원 도구, 활발한 커뮤니티와 전문가 집단 등 수많은 이점까지 제공한다. 이러한 메커니즘들도 직렬화 시스템이라 불리기도 하지만, 이 책에서는 자바 직렬화와 구분하고자 크로스-플랫폼 구조화 데이터 표현이라 한다.

크로스-플랫폼 구조화 데이터 표현들의 공통점

  • 자바 직렬화보다 훨씬 간단하다는 것이다. 임의 객체 그래프를 자동으로 직렬화/역직렬화하지 않는다.
  • 대신 속성-값 쌍의 집합으로 구성된 간단하고 구조화된 데이터 객체를 사용한다.
  • 그리고 기본 타입 몇개와 배열 타입만 지원할 뿐이다.
  • 이런 간단한 추상화만으로도 아주 강렬한 분산 시스템을 구출하기에 충분하고, 자바 직렬화만으로도 아주 강렬한 분산 시스템을 구축하기에 충분하고, 자바 직렬화가 가져온 심각한 문제들을 회피할 수 있음이 밝혀졌다.

크로스-플랫폼 데이터 표현의 선두주자

  • JSON과 프로토콜 버퍼(Protocol Buffers 혹은 짧게 protouf)다.
  • 특징
    • JSON은 더글라스 크록퍼드가 브라우저와 서버의 통신용으로 설계했고, 프로토콜 버퍼는 구글이 서버 사이에 데이터를 교환하고 저장하기 위해 설계했다.
    • 보통은 이들을 언어 중립적이라고 하지만, 사실 JSON은 자바스트립트용으로, 프로토콜 버퍼는 C++용으로 만들어 졌고 아직도 흔적이 남아있다.
  • 차이점
    • JSON은 텍스트 기반이라 사람이 읽을 수 있고, 프로토콜 버퍼는 이진 표현이라 효율이 훨씬 높다는 점이다.
    • JSON은 오직 데이터를 표현하는데만 쓰이지만, 프로토콜 버퍼는 문서를 위한 스키마(타입)를 제공하고 올바로 쓰도록 강요한다.
    • 효율은 프로토콜 버퍼가 훨씬 좋지만 텍스트 기반 표현에는 JSON이 아주 효과적이다. 또한 프로토콜 버퍼는 이진 표현뿐 아니라 사람이 읽을 수 있는 텍스트 표현도 지원한다.

레거시 시스템 때문에 자바 직렬화를 완전히 배제할 수 없을 때의 차선책

  • 신뢰할 수 없는 데이터는 절대 역직렬화라지 않는 것이다.
  • 신뢰할 수 없는 발신원으로부터의 RMI는 절대 수용해서는 안 된다.
  • 자바의 공식 보안 코딩 지침에서는 "신뢰할 수 없는 데이터의 역직렬화는 본질적으로 위험하므로 절대로 피해야 한다."라고 조언한다.
  • 직렬화를 피할 수 없고 역직렬화환 데이터가 안전한지 확신할 수 없다면 객체 역직렬화 필터링을 사용하자(자바 9에 추가되었고 이전 버전에도 쓸 수 있도록 이식되었다.)
  • 객체 역직렬화 필터링은 데이터 스트림이 역직렬화되기 전에 필터를 설치하는 기능이다.
    • 클래스 단위로, 특정 클래스를 받아들이거나 거부할 수 있다.
    • '기본 거부' 모드에서는 화이트리스트에 기록된 안전하다고 알려진 클래스들만 수용한다.
  • 블랙리스트 방식보다는 화이트리스트 방식을 추천한다.
    • 블랙리스트 방식은 이미 알려진 위험으로부터만 보호할 수 있기 때문이다.
    • 화이트리스트를 자동으로 생성해주는 스왓(SWAT)이라는 도구도 있으니 참고하자
    • 필터링 기능은 메모리를 과하게 사용하거나 객체 그래프가 너무 깊어지는 사태로부터도 보호해준다. 하지만 앞서 보여준 직렬화 폭탄은 걸러내지 못한다.

직렬화는 여전히 자바 생태계 곳곳에서 쓰이고 있다. 자바 직렬화를 사용하는 시스템을 관리해야 한다면 시간과 노력을 들여서라도 크로스-플랙폼 구조화된 데이터 표현으로 마이그레이션하는 것을 심각하게 고민해보길 바란다.

현실적인 이유로 지금도 직렬화 가능 클래스를 작성하거나 유지보수해야 하는 사람이 있을 수 있다.

직렬화 가능 클래스를 올바르고 안전하고 효율적으로 작성하려면 상당한 주의가 필요하다. 다음 아이템부터는 이런분들을 위한 조언을 담았다.

직렬화는 위험하니 피해야 한다. 시스템을 밑바닥부터 설계한다면 JSON이나 프로토콜 버퍼 같은 대안을 사용하자.
신뢰할 수 없는 데이터는 역질렬화하지 말자. 꼭 해야 한다면 객체 역직렬화 필터링을 사용하되, 이마저도 모든 공격을 막아줄 수는 없음을 기억하자.
클래스가 직렬화를 지원하도록 만들지 말고, 꼭 그렇게 만들어야 한다면 정말 신경 써서 작성해야한다.


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

어떤 클래스의 인스턴스를 직렬화할 수 있게 하려면 클래스 선언에 implements Serializable만 덧 붙이면 된다. 너무 쉽게 적용할 수 있기 때문에 프로그래머가 특별히 신경쓸 게 없다는 오해가 생길 수 있지만, 진실은 훨씬 더 복잡하다.

직렬화를 지원하기란 짧게 보면 손쉬워 보이지만, 길게 보면 아주 값비싼 일이다.

Serializable을 구현하면 릴리스한 뒤에는 수정하기 어렵다.

  • Serializable을 구현하면 직렬화된 바이트 스트림 인코딩(직렬화 형태)도 하나의 공개의 API가 되다. 그래서 널리 퍼진다면 직렬화 형태도(다른 공개 API와 마찬가지로) 영원히 지원해야 하는 것이다.
  • 커스텀 직렬화 형태를 설계하지 않고 자바의 기본 방식을 사용하면 직렬화 형태는 최소 적용 당시 클래스의 내부 구현 방식에 영원히 묶여버린다. 요약하면 기본 직렬화 형태에서는 클래스의 pirvate과 package-private 인스턴스 필드들마저 API로 공개되는 꼴이 된다.(캡슐화가 깨진다.)
  • 뒤늦게 클래스 내부 구현을 손보면 원래의 직렬화 형태와 달라지게 된다. 한쪽은 구버전 인스턴스를 직렬화하고 다른 한쪽은 신버전 클래스로 역질렬화한다면 실패를 맛볼 것인다.
  • 원래의 직렬화 형태를 유지하면서 내부 표현을 바꿀수도 있지만, 어렵기도 하거니와 소스코드에 지저분한 혹을 남겨놓게 된다.
  • 그러므로 직렬화 가능 클래스를 만들고자 한다면, 길게 보고 감당할 수 있을만큼 고품질의 직렬화 형태도 주의해서 함께 설계해야 한다.(초기 개발 비용은 높아지지만 그만한 보상을 해줄것이다.)

직렬화가 클래스 개선을 방해하는 예시

  • 모든 직렬화된 클래스는 고유 식별 번호를 부여받는다. serialVersionUID라는 이름의 static final long 필드로, 이 번호를 명시하지 않으면 시스템이 런타임에 암호 해시 함수(SHA-1)를 적용해 자동으로 클래스 안에 생성해 넣는다.
  • 이 값을 생성하는데는 클래스 이름, 구현한 인터페이스들, 컴파일러가 자동으로 생성해 넣은 것을 포함한 대부분의 클래스 멤버들이 고려된다. 그래서 나중에 편의 메서드를 추가하는 식으로 이들 중 하나라도 수정한다면 직렬 버전 UID 값도 변한다. 이는 자동 생성되는 값에 의존하면 쉽게 호환성이 깨져버려 런타임에 InvalidClassException 이 발생할 것이다.

Serializable 구현의 두 번째 문제는 버그와 보안 구멍이 생길 위험이 높아진다는 점이다.

  • 객체는 생성자를 사용해 만드는게 기본이다. 직렬화는 언어의 기본 메커니즘을 우회하는 객체 생성 기법인 것이다.
  • 기본 방식을 따르든 재정의해 사용하든, 역직렬화는 일반 생성자의 문제가 그대로 적용되는 '숨은 생성자'다. 이 생성자는 전면에 드러나지 않으므로 "생성자에서 구축한 불변식을 모두 보장해야 하고 생성 도중 공격자가 객체 내부를 들여다 볼 수 없도록 해야 한다"는 사실을 떠올리기 어렵다. 기본 역직렬화를 사용하면 불변식 깨짐과 허가되지 않은 접근에 쉽게 노출된다는 뜻이다.

Seializable 구현의 세 번째 문제는 해당 클래스의 신버전을 릴리스할 때 테스트 할 것이 늘어난다는 점이다.

  • 직렬화 가능 클래스가 수정되면 신버전 인스턴스를 직렬화한 후 구버전으로 역직렬화할 수 있는지, 그리고 그 반대도 가능한지를 검사해야 한다. 따라서 테스트 할 양이 직렬화 가능 클래스의 수와 릴리스 횟수에 비례해 증가한다.
  • 양방향 직력화/역직렬화가 모두 성공하고, 원래의 객체를 충실히 복제해내는지를 반드시 확인해야 한다. 클래스를 처음 제작할 때 커스텀 직렬화 형태를 잘 설계해놨다면 이러한 테스트 부담을 줄일수 있다.

Serializable 구현 여부는 가볍게 결정할 사안이 아니다.

  • 객체를 전송하거나 저장할 때 자바 직렬화를 이용하는 프레임워크용으로 만든 클래스라면 선택의 여지가 없다.
  • 역사적으로 BigInteger와 Instant 같은 '값' 클래스와 컬렉션 클래스들은 Serializable을 구현하고, 스레드 풀처럼 '동작'하는 객체를 표현하는 클래스들은 대부분 Serializable을 구현하지 않았다.

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

  • 위 내용을 따르지 않으면, 그런 클래스를 확장하거나 그런 인터페이스를 구현하는 이에게 커다란 부다을 지우게 된다.
  • 위 규칙을 어겨야 하는 경우도 있는데 예를 들어 Serializble을 구현한 클래스만 지원하는 프레임워크를 사용하는 상황이라면 다른 방도가 없을 것이다.

작성한 클래스의 인스턴스가 직렬화와 확장이 모두 가능하다면 주의해야 할 점

  • 인스턴스 필드 값 중 불변식을 보장해야 할 게 있다면 반드시 하위 클래스에서 finalize 메서드를 재정의하지 못하게 해야 한다. 즉, finalize 메서드를 자신이 재정의하면서 final로 선언하면 된다.
  • 인스턴스 필드 중 기본 값(정수형은 0, boolan은 false, 객체 참조 타입은 null) 으로 초기화되면 위배되는 불변식이 있다면 클래스에 다음의 readObjectNoData 메서드를 반드시 추가해야 한다.
// 상태가 있고, 확장 가능하고, 직렬화 가능한 클래스용 readObjectNoData 메서드
private void readObjrctNoData() throws InvalidObjectException {
    throw new InvalidObjectException("스트림 데이터가 필요합니다.");
}

Serializable을 추가할 때는 한 가지만 주의하자

  • 상속용 클래스인데 직렬화를 지원하지 않으면 그 하위 클래스에서 직렬화를 지원하려할 때 부담이 늘어난다. 보통은 이런 클래스를 역직렬화하려면 그 상위 클래스는 매개변수가 없는 생성자를 제공해야 하는데 이런 생성자를 제공하지 않으면 하위 클래스에서는 어쩔 수 없이 직렬화 프록시 패턴을 사용해야 한다.

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

내부 클래스에는 바깥 인스턴스의 참조와 유효 범위 안의 지역변수 값들을 저장하기 위해 컴파일러가 생성한 필드들이 자동으로 추가된다. 익명 클래스와 지역 클래스의 이름을 짓는 규칙이 언어 명세에 나와 있지 않듯, 이 필드들이 클래스 정의에 어떻게 추가된느지도 정의되지 않았다. 다시 말해 내부 클래스에 대한 기본 직렬화 형태는 분멸하지 않다. 정적 멤버 클래스는 Serializable을 구현해도 된다.

정보은닉(아이템15), 고품질의 직렬화(아이템87,90), Serializable 구현의 두 번째 문제는 버그와 보안 구멍이 생길 위험이 높아진다는 점이다.(아이템 85), 역직렬화를 사용하면 불변식 깨짐과 허가되지 않는 접근에 쉽게 노출된다는 뜻이다.(아이템88), 테스트 부담을 줄일 수 있다.(아이템 87, 90), 상속(아이템19), finalizer(아이템8), 직렬화 프록시 패턴(아이템90), 내부 클래스(아이템 24)

Serializable은 구현한다고 선언하기에는 아주 쉽지만, 그것은 눈속임일 뿐이다. 한 클래스의 여러 버전이 상호작용할 일이 없고 서버가 신뢰할 수 없는 데이터에 노출될 가능성이 없는 등, 보호된 환경에서만 쓰일 클래스가 아니면 serializable 구현은 아주 신중하게 이뤄져야 한다. 상속할 수 있는 클래스라면 주의 사항이 더욱 많아진다.


아이템 87 : 커스텀 직렬화 형태를 고려해보라

개발 일정에 쫓기는 상황에서는 이번 릴리즈에서는 그냥 동작 하도록만 만들고 다음 릴리즈에서 제대로 구현하기로 하는게 낫다. 하지만 Serializable을 구현하고 기본 직렬화 형태글 사용한다면 다음 릴리즈 때 버리려 한 현재의 구현을 영원히 발이 묶이게 된다.

  • 먼저 고민해 보고 괜찮다고 판단될 때만 기본 직렬화 형태를 사용하라.
    • 직접 설계하더라도 기본 직렬화 형태와 거의 같은 결과가 나올 경우에만 기본 형태를 써야한다.
  • 객체의 물리적 표현과 논리적 내용이 같다면 기본 직렬화 형태라도 무방하다.
      // 기본 직렬화 형태에 적합한 후보
      public class Name implements Serializable {
      /** 
      * 성. null이 아니어야 함.
      * @serial
      */
      private final String lastName;
      /**
      * 이름. null이 아니어야 함.
      * @serial
      */
      private final String finrstName;
      /**
      * 중간 이름. 중간이름이 없다면 null.
      * @serial
      */
      private final String middleName;
      }
  • 기본 직렬화 형태가 적합하다고 결정했더라도 불변식 보장과 보안을 위해 readObject 메서드를 제공해야 할 때가 많다.
// 기본 직렬화 형태에 적합하지 않은 클래스
public final class StringList implements Seializable {
    private int size = 0;
    private Entry head = null;

    private static class Entry implements Serializable {
        String data;
        Entry next;
        Entry previous;
    }
...
}

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

  1. 공개 API가 현재의 내부 표현 방식에 영구히 묶인다.
  2. 너무 많은 공간을 차지할 수 할 수있고 네트워크 전송 속도가 느려진다.
  3. 직렬화 로직은 객체 그래프의 위상에 관한 정보가 없으니 그래프를 직접 순회하여 시간이 너무 많이 걸릴 수 있다.
  4. 기본 직렬화 과정은 객체 그래프를 재귀 순회하여 스택 오버플로를 일으킬 수 있다.

StringList를 합리적으로 직렬화 하는 방법

  • 단순히 리트스가 포함한 문자열의 개수를 적은 다음, 그 뒤로 문자열들을 나열하는 수준이면 될 것이다.
// 합리적인 커스텀 직렬화 형태를 갖춘 StringList
public final class StringList implements Serializable {
    private transient int size = 0;
    private transient Enty head = null;

// 이제는 직렬화 되지 않는다.
private static class Entry { 
    String data;
    Entry next;
    Entry previous;
}

// 지정한 문자열을 이 리스트에 추가한다.
public final void add(String s) { ... }

/** 
* 이 {@code StringList} 인스턴스를 직렬화한다.
*
* @serialData 이 리스트의 크기(포함된 문자열의 개수)를 기록한 후
* ({@code int}), 이어서 모든 원소를(각각은 {@code String})
* 순서대로 기록한다.
*/
private void writeObject(ObjectOutputStream s) throws IOException {
    s.delfualtWriteObject();
    s.writeInt(size);

    // 모든 원손를 올바른 순서로 기록한다.
    for (Entry e = head; e != null; e = e.next) 
        s.writeObject(e.data);
}
private void readObject(ObjectInputStream s) throws IOException, ClassNotFundException {
    s.defaultReadObject();
    int numElements = s.readint();

    // 모든 원소를 읽어 이 리스트에 삽입한다.
    for (int i = 0; i < numElements; i++) 
        add((String) s.readObject());
}

...
}
  • 신버전 인스턴스를 직렬화한 후 구버전으로 역직렬화하면 새로 추가된 필드들은 무시될 것이다.
  • 기본 직렬화를 수용하든 하지 않든 defaultWriteObject메서드를 호출하면 transient로 선언하지 않은 모든 인스턴스 필드가 직렬화된다.
    • transient로 선언해도 되는 인스턴스 필드에는 모두 transient한정자를 붙여야 한다.
    • 캐시된 해시 값처럼 다른 필드에서 유도되는 필드도 여기 해당된다.
    • JVM을 실행할 때마다 값이 달라지는 필드도 마찬가지인데, 네이티브 자료구조를 가리키는 long필드가 여기에 속한다.
  • 해당 객체의 논리적 상태와 무관한 필드라고 확시할 때만 transient 한정자를 생략할 수 있다.
  • 기본 직렬화를 사용한다면 tranfient 필드들은 역직렬화 될 때 기본값으로 초기화됨을 잊지 말자.
    • 기본 값을 그대로 사용해서는 안 된다면 readObject메서드에서 defaultObject를 호출한 다음, 해당 필드를 원하는 값으로 복원하자. 혹은 그 값을 처음 사용할 때 초기화하는 방법도 있다.
  • 객체 전체 상태를 읽은 메서드에 적용해야 하는 동기화 메커니즘을 직렬화에도 적용해야 한다.
    • 모든 메서드를 synchonized로 선언하여 스레드 안전하게 만든 객체에서 기본 직렬화를 사용하려면 writeObject도 다음 코드처럼 synchronized로 선언해야 한다.
      // 기본 직렬화를 사용하는 동기화된 클래스를 위한 writeObject메서드
      private synchronized void writeObject(ObjectOutputStream s) throws IOExecption {
        s.defaultWirteObject();
      }
  • 어떤 직렬화 형태를 택하든 직렬화 가능 클래스 모두에 직렬 버전 UID를 명시적으로 부여하자
      private static final long serialVersionUID = <무작위로 고른 long 값>;
    • 직렬 버전 UID가 꼭 고유할 필요는 없다.
    • 직렬 버전 UID가 없는 기존 클래스를 구버전으로 직렬화된 인스턴스와 호환성을 유지한 채 수정하고 싶다면, 구버전에서 사용한 자동 생성된 값을 그대로 사용해야 한다.
    • 기존 버전과 호환성을 끊고 싶다면 단순히 직렬 버전 UID의 값을 바꿔주면 된다.
    • 구버전으로 직렬화된 인스턴스들과의 호환성을 끊으려는 경우를 제외하고는 직렬 버전 UID를 절대 수정하지 말자

복원(아이템88), 초기화(아이템83), 스레드 안전(아이템82), 잠재적호환성(아이템86)

클래스를 직렬화하기로 했다면 어떤 직렬화 형태를 사용할지 심사숙고하기바란다.
자바의 기본 직렬화 형태는 직렬화한 결과가 해당 객체의 논리적 표현에 부합할 때만 사용하고, 그렇지 않으면 객체를 적절히 설명하는 커스텀 직렬화 형태를 고안하라. 직렬화 형태도 공개 메서드를 설계할 때에 준하는 시간을 들여 설계 해야 한다. 한번 공개된 메서드는 향후 릴리스에서 제거할 수 없듯이, 직렬화 형태에 포함된 필드도 마음대로 제거할 수 없다. 직렬화 호환성을 유지하기 위해 영원히 지원해야 하는것이다. 잘못된 직렬화 형태를 선택하면 해당 클래스의 복잡성과 성능에 영구히 부정적인 영향을 남긴다.


아이템 88 : readObject 메서드는 방어적으로 작성하라

  • 클래스 선언에 implements Serializable을 추가하는 것으로 모든 일을 끝낼 수 있을 것 같지만 불변식을 더 이상 보장 할 수 없다.
  • readObject 메서드가 실직적으로 또 다른 public 생성자이기 때문에 다른 생성자와 똑같은 수준으로 주의를 기울여야 한다.
  • readObject는 매개변수로 바이트 스트림을 받는 생성자라 할 수 있다.
    • 보통의 경우 바이트 스트림은 정상적으로 생성된 인스턴스를 직렬화해 만들어지지만 불변식을 깨뜨릴 의도로 임의 생성한 바이트 스트림을 건네면 문제가 생긴다. 정상적인 생성자로는 만들어 낼 수 없는 객체를 생성해 낼 수 있기 때문이다.

기본 직렬화(아이템87), 유효한지 검사하고(아이템49, 방어적 복사(아이템50)

readObecjt 메서드를 작성할 때는 언제나 public생성자를 작성하는 자세로 임해야 한다. readObject는 어떤 바이트 스트림이 넘어 오더라도 유효한 인스턴스를 만들어내야 한다. 바이트 스트림이 진짜 직렬화된 인스턴스라고 가정해서는 안 된다. 이번 아이템에서는 기본 직렬화 형태를 사용한 클래스를 예로 들었지만 직렬화를 사용하더라도 모든 문제가 그대로 발생할 수 있다. 이어서 안전한 readObject 메서드를 작성하는 지짐을 요약해보았다.

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

아이템 89 : 인트턴스 수를 통제해야 한다면 readResolve보다는 열거 타입을 사용하라

  • 싱글턴 패턴도 implements Serializable을 추가하는 순간 더 이상 싱글턴이 아니게 된다.
  • 명시적으로 readObject를 제공하더라도 소용 없다.
  • readResolve 기능을 이용하면 readObject가 만들어낸 인스턴스를 다른 것으로 대체할 수 있다.
    • 역직렬화한 객체의 클래스가 readReseolve메서드를 적절히 정의해뒀다면, 역직렬화한 후 새로 생성된 객체를 인수로 이 메서드가 호출되고, 이 메서드가 반환한 객체 참조가 새로 생성된 객체를 대신해 반환한다. 대부분의 경우 이때 새로 생성된 객체의 참조는 유지 하지 않으므로 바로 가비지 컬렉션 대상이 된다.
  • 사실, readResolve를 인스턴스 통제 목적으로 사용한다면 객체 참조 타입 인스턴스 필드는 모두 transient로 선언해야 한다.
    • 싱글턴이 transient가 아닌 참조 필드를 가지고 있다면, 그 필드의 내용은 readResolve메서드가 실행되기 전에 역직렬화된다. 그렇다면 잘 조작된 스트림을 써서 해당 참조 필드의 내용이 역직렬화되는 시점에 그 역직렬화된 인스턴스의 참조를 훔쳐올 수 있다.
    • 더 자세히 알아보면 readResolve 메서드와 인스턴스 필드 하나를 포함한 '도둑' 클래스를 작성하고 이 인스턴스 필드는 도둑이 '숨길' 직렬화된 싱글턴을 참조하는 역할이 된다.
      • 직렬화된 스트림에서 싱글턴의 비휘발성 필드를 이 도둑의 인스턴스로 교체한다. 이제 싱글턴은 도둑을 참조하고 도둑은 싱글턴을 참조하는 순환고리가 만들어졌다.
      • 싱글턴이 도둑을 포함하므로 싱글턴이 역직렬화될 때 도둑의 readResolve메서드가 먼저 호출된다. 그 결과, 도둑의 readResolve 메서드가 수행될 때 도둑의 인스턴스 필드에는 역직렬화 도중인(그리고 readResolve가 수행되기 전인) 싱글턴의 참조가 담겨 있게 된다.
      • 도둑의 readResolve 메서드는 이 인스턴스 필드가 참조한 값을 정적 필드로 복사하여 readResolve가 끝난 후에도 참조할 수 있도록 하고 이 메서드는 도둑이 숨긴 transient가 아닌 필드의 원래 타입에 맞는 값을 반환한다.
  • readResolve를 사용하는 방식이 완전히 쓸모없는 것은 아니다.
    • 직렬화 가능 인스턴스 통제 클래스를 작성해야 하는데, 컴파일타임에는 어떤 인스턴스들이 있는지 알 수 없는 상황이라면 열거 타입으로 표현하는 것이 불가능하기 때문이다.
  • readResolve 메서드의 접근성은 매우 중요하다.
    • final 클래스에서라면 readResolve메서드는 private이어야 한다

기본 직렬화를 쓰지 않더라도(아이템87), 명시적인 readObject를 제공하더라도(아이템88), 열거타입(아이템 3)

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


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

  • 계속 이야기 했듯이 Serializable을 구현하기로 결정한 순간 언어의 정상 메커니즘인 생성자 이외의 방법으로 인스턴스를 생성할 수 있게 된다. 버그와 보안 문제가 일어날 가능성이 커진다는 의미이다.
  • 이 위험을 줄여줄 기법으로 직렬화 프록시 패턴이다.

직렬화 프록시 패턴 사용 방법

  1. 바깥 클래스의 논리적 상태를 정밀하게 표현하는 중첩 클래스를 설계해 private static으로 선언하고 이 중첩 클래스가 바로 바깥 클래스의 직렬화 프록시이다.
  2. 중첩 클래스의 생성자는 단 하나여야 하며, 바깥 클래스를 매개변수로 받아야 한다. 이 생성자는 단순히 인수로 넘어온 인스턴스의 데이터를 복사한다.
  3. 일관성 검사나 방어적 복사도 필요 없다.
  4. 설계상, 직렬화 프록시의 기본 직렬화 형태는 바깥 클래스의 직려화 형태로 쓰기에 이상적이다.
  5. 바깥 클래스와 직렬화 프록시 모두 Serializable을 구현한다고 선언해야 한다.
     // Priod 클래스용 직렬화 프록시
     private static class SerializationProxy implements Serializable {
         private final Date start;
         private final Date end;
    
         SerializtionProxy(Period p) {
             this.start = p.start;
             this.end = p.end;
         }
    
         private static final long serialVersionUID = 2342352345098L; // 아무 값이든 상관없다.
     }
    
     // 직렬화 프록시 패턴용 writeReplace 메서드
     private Object writeReplace() {
     // 이 메서드는 자바의 직렬화 시스템이 바깥 클래스의 인스턴스 대신 SerializationProxy의 인스턴스를 반환하게 하는 역할을 한다.
         return new SerializationProx(this); 
     }
    
     private void readObject(ObejctInpuSteam stream) throws InvalidObjectException {
         throw new InvalidObjectException("프록시가 필요합니다.");
     } 
     // writeReplace 덕분에 직렬화 시스템은 결코 바깥 클래스의 직렬화된 인스턴스를 생성해 낼 수 없다.
  6. 바깥 클래스와 논리적으로 동일한 인스턴스를 반환하는 readResolve 메서드를 SerializationProxy 클래스에 추가하고 역직렬화 시에 직렬화 시스템이 직렬화 프록시를 다시 바깥 클래스의 인스턴스로 변환하게 해준다.
  7. readResolve 메서드는 공개된 API만을 사용해 바깥 클래스의 인스턴스를 생성하는데, 이 패턴이 아름다운 이유이다.
  8. 직렬화는 생성자를 이용하지 않고도 인스턴스를 생성하는 기능을 제공하는데, 이 패턴은 직렬화의 이런 언어도단적 특성을 상당 부분 제거한다. 즉, 일반 인스턴스를 만들 때와 똑같은 생성자, 정적 팩터리, 혹은 다른 메서드를 사용해 역직렬화된 인스턴스를 생성하는 것이다.
  9. 역직렬화된 인스턴스가 해당 클래스의 불변식을 만족하는지 검사할 또 다른 수단을 강구하지 않아도 된다.
  10. 그 클래스의 정적 팩터리나 생성자가 불변식을 확인해주고 인스턴스 메서드들이 불변식을 잘 지켜준다면, 따로 더 해줘야 할 일은 없을 것이다.

방어적 복사처럼, 직렬화 프록시 패턴은 가짜 바이트 스트림 공격과 내부 필드 탈취 공격을 프록시 수준에서 차단해준다.

직렬화 프록시 패턴에는 두 가지 한계가 있다.

  1. 클라이언트가 멋대로 확장할 수 있는 클래스에는 적용할 수 없다.
  2. 객체 그래프에 순환이 있는 클래스에도 적용할 수 없다.]

불변(아이템17), EnumSet(아이템36), 확장(아이템19)

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


12장을 공부하면서 더 공부해야 하는 부분

  • SWAT 도구
반응형

'나(다) > ' 카테고리의 다른 글

[오디오북] 신경 끄기의 기술  (0) 2021.03.25
이펙티브 자바 요약(아이템1~90)  (0) 2021.02.12
완벽한 공부하는 법  (2) 2021.02.07
이펙티브 자바 - 11장 : 동시성  (0) 2021.02.06
이펙티브 자바 - 10장 : 예외  (5) 2021.01.26