이펙티브 자바 12장) 직렬화

동동주·2026년 1월 2일

이펙티브 자바

목록 보기
13/13

Item 85 - 자바 직렬화의 대안을 찾으라

직렬화란?

이펙티브 자바에서 말하는 직렬화(Serializable)
객체의 상태를 바이트 스트림으로 변환하는 것을 의미한다.
반대로 바이트 스트림을 다시 객체로 복원하는 과정은 역직렬화다.

바이트 스트림을 사용하는 이유는
네트워크, 파일, DB 등 출발지와 목적지 모두가 이해할 수 있는 공통 표현이기 때문이다.

왜 직렬화를 사용할까?

  • 자바 객체는 그대로 전송할 수 없음

  • 전송을 위해 모두가 이해 가능한 형태(byte) 로 변환 필요

  • 대표적인 예

    • 문자열 → 바이트 (소켓 통신)
    • 객체 → JSON 문자열 (웹 API)

JSON 직렬화는 보통 Jackson 같은 라이브러리가 대신 처리해준다.

자바 직렬화 방법

  • 객체가 Serializable 인터페이스를 구현하면 가능
  • ObjectOutputStream.writeObject()로 직렬화
  • ObjectInputStream.readObject()로 역직렬화
  • Serializable마커 인터페이스로, 직렬화 가능 여부만 표시

자바 직렬화의 문제점

자바 직렬화의 핵심 문제는 보안과 성능이다.

⚠️ 보안 문제

  • readObject()는 반환 타입이 Object
  • 클래스패스에 있는 거의 모든 객체를 생성 가능
  • 역직렬화 과정에서 해당 객체의 모든 코드가 실행됨
  • 결과적으로 공격 표면이 매우 넓어짐

⚠️ 성능 문제

  • 직렬화 데이터 크기가 큼
  • 역직렬화 시 의도적으로 만든 구조로 인해
    엄청난 연산 비용(역직렬화 폭탄) 이 발생할 수 있음

➡️ 신뢰할 수 없는 데이터를 역직렬화하는 행위 자체가 위험하다.

어떻게 대처해야 할까?

  • 가장 좋은 방법: 자바 직렬화를 사용하지 않는 것

  • 대안

    • JSON, protobuf 같은 명시적 포맷 사용
  • 불가피하게 역직렬화해야 한다면

    • Java 9의 ObjectInputFilter 사용
    • 허용된 클래스만 역직렬화하도록 제한

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

Serializable을 구현하면 객체를 쉽게 직렬화할 수 있다.
하지만 이는 단기적으로는 편해 보이지만, 장기적으로 매우 큰 비용을 초래하는 결정이다.

왜 위험한가?

1️⃣ 직렬화 형태 = 공개 API

  • Serializable 구현 순간

    • 클래스의 내부 구조가 외부에 고정
  • private 필드도 직렬화 대상

  • 한번 배포되면

    • 과거 형태와의 호환성 유지 필수

2️⃣ 클래스 수정이 거의 불가능해진다

발생 가능한 문제

  • 필드 추가 / 삭제 / 타입 변경

  • 역직렬화 시

    • InvalidClassException

serialVersionUID

  • 직렬화 버전 식별자
  • 반드시 명시
private static final long serialVersionUID = 1L;

단, UID를 고정해도

  • 구조 변경까지 안전해지는 것은 아님

3️⃣ 버그와 보안 취약점의 원인

  • 역직렬화는 생성자를 호출하지 않음

  • 생성자에서 보장하던 불변식 무력화

  • 결과

    • 잘못된 상태의 객체 생성
    • 보안 취약점 발생 가능

➡️ 역직렬화 = 숨겨진 생성자

4️⃣ 테스트 비용 폭증

클래스를 수정할 때마다

  • 구버전 → 신버전
  • 신버전 → 구버전

Serializable 클래스 수 × 릴리스 횟수
→ 테스트 복잡도 급증

언제 사용해야 할까?

자바 직렬화를 사용하는 프레임워크용 클래스이거나, Serializable을 반드시 요구하는 컴포넌트라면 선택의 여지가 없다.
하지만 그 외의 경우라면 이득과 비용을 반드시 비교해야 한다.
값 객체나 컬렉션은 직렬화를 지원하는 경우가 많지만, 스레드 풀처럼 “동작”을 표현하는 객체는 대부분 지원하지 않는다.


Item 87 - 커스텀 직렬화 형태를 고려하라

기본 직렬화 형태가 괜찮은 경우

✔ 객체의 물리적 표현 = 논리적 내용일 때

  • 물리적 표현: 코드 내부 구현 방식
  • 논리적 내용: 객체가 의미하는 실제 데이터
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️⃣ 직렬화 성능 저하

  • 객체 그래프를 직접 순회
  • O(n) 비용 발생

4️⃣ 스택 오버플로 위험

  • 재귀 순회 구조
  • 노드 수 많으면 위험

Item 88 - readObject 메서드는 방어적으로 작성하라

readObject는 단순히 객체를 복원하는 메서드가 아니다.
역직렬화 과정에서 객체를 새로 만들어내는 또 하나의 생성자라고 봐야 한다.

직렬화된 데이터라고 해서 항상 정상적이라고 가정하면 안 된다.
잘못된 바이트 스트림이 전달되면, 생성자를 거치지 않고도 불변식이 깨진 객체가 만들어질 수 있다.

readObject 작성 시 기본 원칙

  • readObject는 public 생성자처럼 다룬다
  • 어떤 바이트 스트림이 와도 유효한 객체만 생성해야 한다
  • 입력 데이터는 절대 신뢰하지 않는다

불변 클래스도 안전하지 않다

불변 클래스라도 내부에 Date 같은 가변 객체를 참조하고 있다면 위험하다.
역직렬화 과정에서 이 참조가 외부로 노출되면, final 필드라도 내부 상태가 변경될 수 있다.

생성자에서의 방어적 복사만으로는 충분하지 않으며,
readObject에서도 동일한 방어가 필요하다.

반드시 지켜야 할 작성 규칙

  1. 불변식 검증
    역직렬화 직후 객체 상태를 검사하고, 위반 시 InvalidObjectException을 던진다.

  2. 방어적 복사
    클래스 내부의 모든 가변 필드는 반드시 복사본으로 교체한다.

  3. 재정의 가능 메서드 호출 금지
    하위 클래스가 완전히 복원되기 전에 실행될 수 있다.


Item 89 - 인스턴스 수를 통제해야 한다면 readResolve보다는 열거 타입을 사용하라

싱글턴은 애플리케이션 전체에서 단 하나의 인스턴스만 존재함을 보장하는 패턴이다.
하지만 이 싱글턴이 Serializable을 구현하는 순간, 우리가 기대한 보장은 쉽게 깨진다.

직렬화가 싱글턴을 깨뜨리는 이유

클래스에 implements Serializable을 추가하면
역직렬화 과정에서 새로운 인스턴스가 생성된다.

  • 기본 직렬화를 쓰지 않아도
  • readObject를 직접 구현해도
  • 심지어 불변 클래스로 만들어도

역직렬화는 항상 새로운 객체를 만들어낸다.
즉, 직렬화 가능한 싱글턴은 더 이상 싱글턴이 아니다.

readResolve로 해결할 수 있을까?

readResolve는 역직렬화로 만들어진 객체를
다른 객체로 교체할 수 있는 훅(hook)이다.

이를 이용하면:

  • 역직렬화된 객체는 버리고
  • 클래스 초기화 시 생성된 기존 싱글턴 인스턴스를 반환해
  • 겉보기엔 싱글턴처럼 동작하게 만들 수 있다

이 방식이 바로 전통적인 직렬화 싱글턴 구현법이다.

하지만 readResolve는 매우 취약하다

문제는 readResolve가 실행되기 전이다.

  • 싱글턴이 transient가 아닌 참조 필드를 가지고 있다면

  • 그 필드들은 readResolve 이전에 이미 역직렬화된다

  • 이 틈을 이용하면 공격자가

    • 역직렬화 중인 싱글턴 객체의 참조를 훔쳐
    • 진짜 싱글턴과 별도의 인스턴스를 확보할 수 있다

즉, 순간적으로라도 싱글턴이 두 개 존재하게 된다.

이를 막으려면:

  • 모든 참조 타입 필드를 transient로 선언해야 하고
  • 구현 난이도와 실수 가능성이 급격히 올라간다

가장 안전한 해법: 열거 타입

이 모든 문제를 한 번에 해결하는 방법이 있다.
바로 싱글턴을 열거 타입(enum) 으로 구현하는 것이다.

  • JVM 차원에서 인스턴스 개수가 보장된다
  • 직렬화/역직렬화가 자동으로 안전하게 처리된다
  • 리플렉션 공격에도 강하다
public enum Elvis {
    INSTANCE;
}

이 방식은:

  • 가장 간결하고
  • 가장 안전하며
  • 유지보수 비용도 가장 낮다

readResolve가 필요한 경우도 있다

열거 타입이 항상 가능한 것은 아니다.

  • 인스턴스의 개수가 컴파일 타임에 결정되지 않는 경우
  • 상속 구조를 반드시 유지해야 하는 경우

이런 상황에서는 readResolve를 사용할 수밖에 없다.
단, 모든 참조 필드를 transient로 선언해야 하며
보안과 불변식 유지에 각별히 신경 써야 한다.

정리

  • 직렬화는 싱글턴을 쉽게 깨뜨린다
  • readResolve는 해결책이지만 매우 취약하다
  • 인스턴스 통제가 목적이라면 열거 타입이 최선의 선택이다
  • 불가피할 때만 readResolve를 사용하되, 모든 참조 필드는 transient로

Item 90 - 직렬화된 인스턴스 대신 직렬화 프록시 사용을 검토하라

Serializable을 구현하면 객체는 생성자를 거치지 않고 생성될 수 있다.
이로 인해 불변식이 깨지거나 보안 문제가 생길 수 있는데, 이를 해결하는 방법이 직렬화 프록시 패턴이다.

직렬화의 문제점

일반적인 직렬화는 다음과 같은 위험을 가진다.

  • 생성자와 유효성 검사를 우회한다
  • 가짜 바이트 스트림 공격이 가능하다
  • 내부 필드 탈취 위험이 있다
  • 불변 클래스를 보장하기 어렵다

직렬화 프록시 패턴 개념

직렬화 프록시 패턴은
실제 객체 대신 프록시 객체를 직렬화하는 방식이다.

  • 직렬화 시 writeReplace로 프록시 객체를 반환
  • 바깥 클래스의 readObject는 예외를 던져 차단
  • 프록시의 readResolve에서 생성자를 통해 객체 복원

이를 통해 역직렬화가 항상 정상적인 생성 경로를 따르게 된다.

장점

  • 가짜 바이트 스트림 및 필드 탈취 공격 차단
  • 필드를 final로 선언 가능 → 진정한 불변 클래스
  • 역직렬화 시 추가 검증 코드 불필요
  • 직렬화 전후 클래스가 달라도 안전하게 동작

한계

  • 상속 가능한 클래스에는 적용 불가
  • 객체 그래프에 순환 참조가 있으면 사용 불가
  • 성능은 다소 떨어진다

핵심 정리

  • 직렬화는 객체 생성 규칙을 쉽게 무너뜨린다
  • 직렬화 프록시 패턴은 이를 구조적으로 차단한다
  • 확장할 수 없는 클래스라면 직렬화 프록시를 우선 고려하자

참고 블로그
https://github.com/Meet-Coder-Study/book-effective-java/blob/main/12%EC%9E%A5/90_%EC%A7%81%EB%A0%AC%ED%99%94%EB%90%9C_%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4_%EB%8C%80%EC%8B%A0_%EC%A7%81%EB%A0%AC%ED%99%94_%ED%94%84%EB%A1%9D%EC%8B%9C_%EC%82%AC%EC%9A%A9%EC%9D%84_%EA%B2%80%ED%86%A0%ED%95%98%EB%9D%BC_%ED%99%A9%EC%A4%80%ED%98%B8.md

0개의 댓글