JAVA/Effective Java

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

꾸준함. 2024. 5. 12. 16:14

클래스 인스턴스를 직렬화하기 위해서는 단순히 마커 인터페이스인 Serializable을 구현하면 됩니다.

이처럼 너무 쉽게 적용할 수 있기 때문에 프로그래머가 특별히 신경 쓸 것이 없다는 오해가 생길 수 있지만 실제로는 직렬화를 적용하는 것은 아주 값비싼 일입니다.

 

Serializable을 구현할 경우 릴리즈한 뒤에는 수정하기 어려움

  • 클래스가 Serializable 구현체일 경우 직렬화 형태인 바이트 스트림 인코딩도 하나의 공개 API가 됨
    • 자바는 하위 호환성을 지원하는 것을 추구하기 때문에 직렬화된 클래스가 널리 쓰이는 라이브러리에 포함될 경우 릴리즈 이후에는 직렬화 형태도 영원히 지원해야 함
    • 클래스의 private과 package-private 인스턴스 필드마저 API로 공개하는 꼴이 되어 객체지향의 핵심인 캡슐화가 깨짐

 

  • 뒤늦게 클래스 내부 구현을 손보게 되면 기존의 직렬화 형태와 달라짐
    • 한쪽은 구버전의 인스턴스를 직렬화하고 다른 쪽은 신버전 클래스로 역직렬화할 경우 실패를 맛보게 됨
    • 원래의 직렬화 형태를 유지하면서 내부 표현을 바꿀 수도 있지만 어렵기도 하고 소스코드가 지저분해짐
    • 따라서 직렬화 가능 클래스를 생성할 경우 길게 보고 감당할 수 있을 만큼 고품질의 직렬화 형태도 주의해서 함께 설계해야 함

 

직렬화/역직렬화 예제

 

 

MyClass에 필드 추가할 경우 에러 발생

 

 

부연 설명

  • 예상한 결과는 newData가 null인 상태로 역직렬화
  • 하지만 MyClass에 필드가 추가되면서 인코딩 결과가 달라짐
  • 이에 따라 serialVersionUID도 달라지면서 역직렬화하면서 에러 발생
    • serialVersionUID 를 클래스 내에 static fianl long 필드
    • 명시적으로 선언하지 않으면 시스템이 런타임에 암호해시 함수(SHA-1)를 적용해 자동으로 클래스 안에 생성
    • 명시적으로 serialVersionUID를 선언할 경우 위와 같은 에러는 피할 수 있음
    • 하지만 변수의 자료형이 바뀌면서 기존의 데이터가 현재 버전의 데이터 타입과 호환되지 않을 경우 여전히 오류 발생
    • 이처럼 초기 버전에서 Serializable 구현체를 선언할 경우 추후 버전에서 이전 버전에 영향을 끼치지 않으면서 수정하기 매우 어려워짐

 

Serializable 구현은 버그와 보안 구멍이 생길 위험이 높아짐

  • 객체는 생성자를 사용해 생성하는 것이 기본이지만 직렬화는 생성자가 숨겨져있기 때문에 자바 언어의 기본 메커니즘을 우회하는 객체생성 기법
    • 기본 방식을 따르든 재정의해 사용하든 역직렬화는 일반 생상자의 문제가 그대로 적용되는 "숨은 생성자"
    • 기본 역직렬화 사용시 불변식이 깨지기 쉽고 허가되지 않은 접근에 쉽게 노출됨

 

MyClass 생성자에 제약 조건 추가


 

앞서 작성한 테스트 코드를 그대로 돌리면 @BeforeEach에 존재하는 생성자 때문에 IllegalArgumentException 에러 발생

 

 

하지만 생성자에 제약조건이 걸리기 전에 만든 인코딩 문자열을 기반으로 역직렬화할 경우 숨은 생성자에 보안 구멍이 생겨 정상적으로 역직렬화가 됨

 

 

Serializable 구현은 해당 클래스의 새로운 버전을 릴리즈할 때마다 테스트할 것이 늘어남

  • 직렬화 가능 클래스가 수정될 경우 신버전 인스턴스를 직렬화한 후 구버전에서도 역직렬화가 가능한지
  • 그리고 구버전에서 직렬화한 후 신버전에서도 역직렬화 가능한지 확인해야 함
  • 이 때문에 버전을 릴리즈할 수록 테스트 커버리지가 기하급수적으로 늘어남
  • 커스텀 직렬화 형태를 잘 설계했을 경우 이러한 테스트 부담을 줄일 수 있음 (아이템 87, 90 참고)

 

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

  • 객체를 전송하거나 저장할 때 반드시 자바 직렬화를 이용해야 하는 프레임워크용으로 만든 클래스라면 선택의 여지가 없지만 웬만해서는 아이템 85에서 언급한 크로스-플랫폼 구조화된 데이터 표현을 사용하는 것을 권장
    • Serializable 구현에 따른 비용이 적지 않으므로 클래스 설계마다 직렬화 적용했을 때 이득과 비용을 비교해서 사용 여부를 판단해야 함
    • 습관성으로 Serializable 마커 인터페이스 구현체로 만드는 것은 지양!
    • 역사적으로 BigInteger 와 Instant 같은 '값' 클래스와 컬렉션 클래스들은 Serializable을 구현
    • 쓰레드 풀처럼 '동작' 하는 객체를 표현하는 클래스 들은 대부분 Serializable을 구현하지 않음

 

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

  • 상속용 클래스가 직렬화를 지원하지 않은 상태인데 하위 클래스에서 직렬화를 지원하려고 하면 부담이 늘어남
  • 단, Serializable을 구현한 클래스만 지원하는 프레임워크를 사용하는 상황이라면 다른 방도가 없음
    • ex) Throwable은 서버가 RMI를 통해 클라이언트로 예외를 보내기 위해 Serializable 구현

 

클래스의 인스턴스 필드가 직렬화와 확장이 모두 가능하다면 주의할 점

  • 인스턴스 필드 값 중 불변식을 보장해야 할 것이 있다면 반드시 하위 클래스에서 finalize 메서드를 재정의하지 못하게 해야 함
    • 아이템 8에서 언급한 finalizer attack을 방지하기 위해 finalize 메서드를 자신이 재정의하면서 final로 선언

 

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

  • 내부 클래스에는 바깥 인스턴스의 참조와 유효 범위 내 지역변수 값들을 저장하기 위해 컴파일러가 생성한 필드들이 자동으로 추가됨
  • 익명 클래스와 지역 클래스의 이름을 짓는 규칙이 언어 명세에 나와 있지 않듯이 내부 클래스에 대한 기본 직렬화 형태는 불분명하기 때문에 직렬화를 구현하지 않는 것을 권장
    • 단, 정적 멤버 클래스는 Serializable 구현체여도 됨

 

정리

  • 직렬화 가능 클래스는 마커 인터페이스인 Serializable을 구현하기만 하면 돼서 쉬워 보이지만 실상은 그렇지 않음
  • 한 클래스의 여러 버전이 상호작용할 일이 없고 서버가 신뢰할 수 없는 데이터에 노출될 가능성이 없는 등, 보호된 환경에서만 쓰일 클래스가 아니라면 Serializable 구현 여부는 신중히 판단하고 적용해야 됨
    • 특히, 상속 가능 클래스로 설계했다면 더더욱 신중히 고려해야함

 

참고

이펙티브 자바

반응형