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

"implements Serializable" 한 줄이 시스템 전체의 발목을 잡을 수 있다!


📌 핵심 요약

  • ⚠️ Serializable 구현은 신중히 결정해야 한다.

  • 📡 한 번 직렬화 형태가 공개되면 캡슐화 파괴, 호환성 유지 비용 증가, 보안 취약점 등 장기적인 부작용 발생.

  • 🛡️ 꼭 필요한 경우가 아니라면 구현을 피하고, 해야 한다면 불변식 보장 및 필터링 등 조치가 필요하다.


🧠 필수 개념 요약

개념
설명

직렬화 (Serialization)

객체를 바이트 스트림으로 변환하는 과정

직렬화 형태

직렬화된 바이트 스트림의 구조 → 공개 API처럼 취급됨

serialVersionUID

클래스 버전 식별자, 명시하지 않으면 자동 생성됨

캡슐화 깨짐

private 필드까지 외부에 노출될 수 있음

readObjectNoData()

스트림에 데이터가 없을 때 불변식 보장을 위해 호출


🔍 주요 문제점

1. ✏️ 변경의 어려움

  • 직렬화 형태도 공개 API가 되기 때문에 이후 구조 변경이 매우 어렵다.

  • 자바 기본 직렬화 방식은 private 필드까지 포함하여 캡슐화 원칙을 위반한다.

2. 🧮 serialVersionUID 호환성 문제

  • 자동 생성된 serialVersionUID는 작은 변경에도 달라짐.

  • 값을 명시하지 않으면 구버전과 역직렬화 시 InvalidClassException 발생 가능.

3. 🔓 보안 취약점

  • 생성자 없이 객체를 생성 → 불변식이 깨질 수 있고, 공격에 취약.

  • 역직렬화는 사실상 숨겨진 생성자.

4. 🧪 테스트 부담 증가

  • 버전 간 직렬화/역직렬화 호환 테스트가 필요.

  • 릴리스가 많아질수록 테스트 비용도 증가.


✅ Serializable 적절/부적절 판단 기준

상황
구현 권장 여부

값 클래스, 컬렉션 클래스 (예: BigInteger, Instant)

✅ 구현 OK

프레임워크 사용을 위한 DTO 등

✅ 구현 필요

동작 객체 (예: 스레드풀, GUI 등)

❌ 지양

상속용 클래스 / 인터페이스

❌ 지양

내부 클래스 (정적 멤버 클래스 제외)

❌ 지양


🛡️ 구현 시 주의사항 체크리스트

  1. serialVersionUID 명시적으로 선언 ✅

  2. 불변식을 깨뜨릴 수 있는 필드 → readObjectNoData() 메서드 구현 ✅

  3. finalizer 공격 방지 → finalize() 메서드 final로 선언 ✅

  4. 문서화 및 직렬화 형태 관리 ✅

  5. 양방향 직렬화/역직렬화 테스트 계획 ✅


🧪 예제 코드

예제 1: readObjectNoData() 방어 구현

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

예제 2: Serializable을 안전하게 구현한 클래스

import java.io.InvalidObjectException;
import java.io.Serializable;

public class Person implements Serializable {
    private static final long serialVersionUID = 1L;
    private final String name;
    private final int age;

    // 불변식을 보장하는 생성자
    public Person(String name, int age) {
        // 불변식 검증 코드
        if (name == null) throw new NullPointerException("이름은 null이 될 수 없습니다");
        if (age < 0) throw new IllegalArgumentException("나이는 음수가 될 수 없습니다");
        this.name = name;
        this.age = age;
    }

    // 스트림 데이터가 없을 때 불변식이 깨지는 것을 방지
    private void readObjectNoData() throws InvalidObjectException {
        throw new InvalidObjectException("스트림 데이터가 필요합니다");
    }
    // readObject 메서드도 구현하여 역직렬화 시 불변식을 보장할 수 있음
    // (아이템 88에서 다룰 내용)

    // 일반적인 접근자 메서드
    public String getName() { return name; }
    public int getAge() { return age; }
}

예제 3: 상속용 Serializable 클래스 방어 코드

// 상속용으로 설계된 클래스지만 부득이하게 Serializable을 구현해야 하는 경우
public abstract class AbstractVehicle implements Serializable {
    private static final long serialVersionUID = 1L;
    private String make, model;

    protected AbstractVehicle(String make, String model) {
        this.make = make;
        this.model = model;
    }

    // finalize 메서드를 final로 선언하여 하위 클래스가 재정의할 수 없게 함
    // 이는 finalizer 공격을 방지
    @Override
    protected final void finalize() throws Throwable {
        try {
            // Finalizer 방어 로직
        } finally {
            super.finalize();
        }
    }
    // 일반적인 접근자 메서드
    public String getMake() { return make; }
    public String getModel() { return model; }
}

// AbstractVehicle을 상속하는 클래스
// 부모 클래스가 Serializable을 구현하기 때문에 자동으로 Serializable을 상속
class Car extends AbstractVehicle {
    private int numDoors;

    public Car(String make, String model, int numDoors) {
        super(make, model);
        this.numDoors = numDoors;
    }

    public int getNumDoors() { return numDoors; }
}

🧱 내부 클래스에서의 Serializable

  • ❌ 익명, 지역, 비정적 내부 클래스 → 직렬화 금지

  • ✅ 정적 멤버 클래스는 예외적으로 허용 가능

컴파일러가 자동 생성한 필드와 참조가 존재하므로 예측 불가능한 직렬화 형태가 생성됨.


🧾 결론

  • Serializable은 단순히 한 줄짜리 선언이 아니라 전체 API 설계에 영향을 미치는 결정이다.

  • 반드시 필요한 상황이 아니면 구현하지 않는 것이 원칙.

  • 필요한 경우에는 철저한 방어와 테스트 전략을 수립하고, 클래스 변경 시마다 호환성 유지에 신경 써야 한다.


📌 자바 버전별 주요 변화

버전
기능

Java 4

readObjectNoData() 메서드 도입

Java 9

ObjectInputFilter 통한 역직렬화 필터링 지원 (참고: 아이템 85)


💬 느낀점

과거엔 단순히 implements Serializable만 붙이면 끝이라 생각했지만, 이제는 이것이 클래스의 미래 유지보수, 보안, 구조까지 바꿔버릴 수 있는 중대한 설계 결정임을 깨달았다. 앞으로는 꼭 필요한 상황이 아니면 피하고, 구현 시에는 serialVersionUID, 불변식 보장, readObjectNoData() 등을 꼼꼼히 챙기자.


📋 Serializable 구현 결정 체크리스트

1. 필요성 검토

  • 외부 시스템과 통신/저장 용도인가?

  • 다른 직렬화 방식(JSON, Protobuf 등)으로 대체할 수 있는가?

2. 구현 결정 시

  • serialVersionUID 명시 여부

  • readObjectNoData() 등 불변식 방어 로직 포함

  • 직렬화 형태 문서화 및 테스트 전략

3. 구조적 고려사항

  • 상속 구조와 충돌하지 않는가?

  • finalizer 공격에 대비했는가?

  • 내부 클래스는 사용하지 않았는가?

🧾결론

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

Last updated