이펙티브 자바에서 말하는 직렬화(Serializable) 는
객체의 상태를 바이트 스트림으로 변환하는 것을 의미한다.
반대로 바이트 스트림을 다시 객체로 복원하는 과정은 역직렬화다.
바이트 스트림을 사용하는 이유는
네트워크, 파일, DB 등 출발지와 목적지 모두가 이해할 수 있는 공통 표현이기 때문이다.
자바 객체는 그대로 전송할 수 없음
전송을 위해 모두가 이해 가능한 형태(byte) 로 변환 필요
대표적인 예
JSON 직렬화는 보통 Jackson 같은 라이브러리가 대신 처리해준다.
Serializable 인터페이스를 구현하면 가능ObjectOutputStream.writeObject()로 직렬화ObjectInputStream.readObject()로 역직렬화Serializable은 마커 인터페이스로, 직렬화 가능 여부만 표시자바 직렬화의 핵심 문제는 보안과 성능이다.
readObject()는 반환 타입이 Object➡️ 신뢰할 수 없는 데이터를 역직렬화하는 행위 자체가 위험하다.
가장 좋은 방법: 자바 직렬화를 사용하지 않는 것
대안
불가피하게 역직렬화해야 한다면
ObjectInputFilter 사용Serializable을 구현하면 객체를 쉽게 직렬화할 수 있다.
하지만 이는 단기적으로는 편해 보이지만, 장기적으로 매우 큰 비용을 초래하는 결정이다.
Serializable 구현 순간
private 필드도 직렬화 대상
한번 배포되면
필드 추가 / 삭제 / 타입 변경
역직렬화 시
InvalidClassExceptionprivate static final long serialVersionUID = 1L;
단, UID를 고정해도
역직렬화는 생성자를 호출하지 않음
생성자에서 보장하던 불변식 무력화
결과
➡️ 역직렬화 = 숨겨진 생성자
클래스를 수정할 때마다
Serializable 클래스 수 × 릴리스 횟수
→ 테스트 복잡도 급증
자바 직렬화를 사용하는 프레임워크용 클래스이거나, Serializable을 반드시 요구하는 컴포넌트라면 선택의 여지가 없다.
하지만 그 외의 경우라면 이득과 비용을 반드시 비교해야 한다.
값 객체나 컬렉션은 직렬화를 지원하는 경우가 많지만, 스레드 풀처럼 “동작”을 표현하는 객체는 대부분 지원하지 않는다.
✔ 객체의 물리적 표현 = 논리적 내용일 때
public class Name implements Serializable {
private final String lastName;
private final String firstName;
private final String middleName;
}
객체의 물리적 표현과 논리적 내용이 다른 경우
public final class StringList implements Serializable {
private int size;
private Entry head;
private static class Entry implements Serializable {
String data;
Entry next;
Entry previous;
}
}
1️⃣ 내부 구현이 공개 API로 고정됨
2️⃣ 불필요하게 큰 직렬화 데이터
3️⃣ 직렬화 성능 저하
4️⃣ 스택 오버플로 위험
readObject는 단순히 객체를 복원하는 메서드가 아니다.
역직렬화 과정에서 객체를 새로 만들어내는 또 하나의 생성자라고 봐야 한다.
직렬화된 데이터라고 해서 항상 정상적이라고 가정하면 안 된다.
잘못된 바이트 스트림이 전달되면, 생성자를 거치지 않고도 불변식이 깨진 객체가 만들어질 수 있다.
readObject는 public 생성자처럼 다룬다불변 클래스라도 내부에 Date 같은 가변 객체를 참조하고 있다면 위험하다.
역직렬화 과정에서 이 참조가 외부로 노출되면, final 필드라도 내부 상태가 변경될 수 있다.
생성자에서의 방어적 복사만으로는 충분하지 않으며,
readObject에서도 동일한 방어가 필요하다.
불변식 검증
역직렬화 직후 객체 상태를 검사하고, 위반 시 InvalidObjectException을 던진다.
방어적 복사
클래스 내부의 모든 가변 필드는 반드시 복사본으로 교체한다.
재정의 가능 메서드 호출 금지
하위 클래스가 완전히 복원되기 전에 실행될 수 있다.
싱글턴은 애플리케이션 전체에서 단 하나의 인스턴스만 존재함을 보장하는 패턴이다.
하지만 이 싱글턴이 Serializable을 구현하는 순간, 우리가 기대한 보장은 쉽게 깨진다.
클래스에 implements Serializable을 추가하면
역직렬화 과정에서 새로운 인스턴스가 생성된다.
readObject를 직접 구현해도역직렬화는 항상 새로운 객체를 만들어낸다.
즉, 직렬화 가능한 싱글턴은 더 이상 싱글턴이 아니다.
readResolve는 역직렬화로 만들어진 객체를
다른 객체로 교체할 수 있는 훅(hook)이다.
이를 이용하면:
이 방식이 바로 전통적인 직렬화 싱글턴 구현법이다.
문제는 readResolve가 실행되기 전이다.
싱글턴이 transient가 아닌 참조 필드를 가지고 있다면
그 필드들은 readResolve 이전에 이미 역직렬화된다
이 틈을 이용하면 공격자가
즉, 순간적으로라도 싱글턴이 두 개 존재하게 된다.
이를 막으려면:
transient로 선언해야 하고이 모든 문제를 한 번에 해결하는 방법이 있다.
바로 싱글턴을 열거 타입(enum) 으로 구현하는 것이다.
public enum Elvis {
INSTANCE;
}
이 방식은:
열거 타입이 항상 가능한 것은 아니다.
이런 상황에서는 readResolve를 사용할 수밖에 없다.
단, 모든 참조 필드를 transient로 선언해야 하며
보안과 불변식 유지에 각별히 신경 써야 한다.
Serializable을 구현하면 객체는 생성자를 거치지 않고 생성될 수 있다.
이로 인해 불변식이 깨지거나 보안 문제가 생길 수 있는데, 이를 해결하는 방법이 직렬화 프록시 패턴이다.
일반적인 직렬화는 다음과 같은 위험을 가진다.
직렬화 프록시 패턴은
실제 객체 대신 프록시 객체를 직렬화하는 방식이다.
writeReplace로 프록시 객체를 반환readObject는 예외를 던져 차단readResolve에서 생성자를 통해 객체 복원이를 통해 역직렬화가 항상 정상적인 생성 경로를 따르게 된다.
final로 선언 가능 → 진정한 불변 클래스