콘텐츠로 이동

이펙티브 자바 실전 강의 교재

12장 — 직렬화

대상: Java/Spring 백엔드 입문~중급 수강생 형식: 개념 설명 → 비유 → 현업 예제 → 따라하기(실습) → 함정 → 체크리스트 → 퀴즈 전제 환경: Java 17+, Spring Boot 3.x


0. 이 장을 시작하기 전에

0.1 학습 목표

  • 자바 직렬화의 세 가지 본질적 위험(보안·역호환·성능)을 안다.
  • 가능한 한 JSON·Protobuf 등 대안으로 우회한다.
  • 어쩔 수 없이 자바 직렬화를 써야 할 때의 방어 패턴을 배운다 — readObject 방어, readResolve, 직렬화 프록시.
  • Serializable 구현을 신중히 결정하는 기준을 세운다.

0.2 큰 그림 — "자바 직렬화는 가능한 한 피하라"

[ 결정 단계 ]                  [ 어쩔 수 없으면 ]              [ 방어 패턴 ]
 85 자바 직렬화 대안 ⭐         86 Serializable 신중           88 readObject 방어
                                 87 커스텀 직렬화 형태           89 readResolve보다 enum
                                                                  90 직렬화 프록시 ⭐

비유 — 자바 직렬화는 "비밀번호 없는 우편물"입니다.

객체를 통째로 바이트로 떠 우편(저장·전송)에 부치는 건데, 받는 쪽은 그 우편의 내용을 그대로 객체로 복원합니다. 봉투 안에 폭탄(악성 클래스)이 들어 있어도 그대로 폭발합니다. 그래서 가능한 한 우편 자체를 안 쓰고, 쓰더라도 검사·잠금장치(방어 패턴)가 필수.

0.3 현업에서 왜 중요한가

  • 자바 직렬화 역사상 최악의 보안 취약점들이 여기서 나옴 — Apache Commons Collections, Spring4Shell 등 RCE(원격 코드 실행) 사고의 단골.
  • 대부분의 백엔드 프로젝트는 JSON(Jackson)·Protobuf·MessagePack으로 충분하며, 자바 직렬화는 거의 사용 안 함.
  • 그래도 알아야 하는 이유: 레거시 시스템·일부 캐시(Hazelcast 등) ·RMI·JPA의 일부 컬렉션에 여전히 남아 있음.

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

한 줄 요약

자바 직렬화는 위험하고 비효율적이다. 새 시스템은 JSON·Protocol Buffers 같은 크로스 플랫폼 구조화된 데이터 형식을 써라.

위험 3가지

위험 설명
보안 (가장 큼) 역직렬화 시 임의 클래스의 readObject가 실행 → RCE 취약점. 공격자가 만든 바이트 스트림 하나로 서버 장악 가능 (Apache Commons Collections gadget chain)
역호환 클래스 구조가 조금만 바뀌어도 깨짐. serialVersionUID 관리 부담
성능·크기 바이너리 포맷이지만 메타데이터 과다, JSON보다 큰 경우도 흔함

대안 비교

포맷 장점 단점
JSON (Jackson) 표준, 사람이 읽음, 도구 풍부, 크로스 플랫폼 텍스트라 크기·파싱 비용
Protocol Buffers 작고 빠름, 스키마 진화 지원 별도 IDL·코드 생성
MessagePack JSON 호환 + 바이너리 도구 적음
Avro 스키마 진화·분산 학습 곡선

Spring 현업 메모

  • Spring REST 컨트롤러는 기본 JSON(Jackson). 직렬화 신경 쓸 일 거의 없음.
  • 캐시(Redis): JSON 또는 GenericJackson2 권장. JDK 직렬화 사용 시 보안 검토 필수.
  • 세션(Tomcat): JDK 직렬화 사용. 세션 객체에 위험한 클래스가 없게 관리.

그래도 자바 직렬화를 써야 한다면

  • 신뢰할 수 있는 출처의 바이트 스트림만 역직렬화
  • ObjectInputFilter(Java 9+) 로 허용 클래스 화이트리스트
  • 가능하면 Item 90의 직렬화 프록시로 안전 강화

체크리스트

  • 새 직렬화 요구사항이 정말 JSON/Protobuf로 안 되는가
  • JDK 직렬화 입력이 신뢰할 수 없는 외부 출처가 아닌가
  • ObjectInputFilter 적용 가능 지점이 있는가

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

한 줄 요약

Serializable 구현은 공개 API의 일부가 된다. 한 번 직렬화 형태를 약속하면 영원히 가져가야 한다.

Serializable 구현의 5가지 비용

  1. 직렬화 형태가 영구적 API: 필드 이름·타입을 바꾸면 호환성 깨짐
  2. equals/hashCode 같은 불변식 깨짐 위험: readObject가 생성자 우회
  3. 새 버전 출시 시 테스트 부담 증가: 옛 직렬 형태 → 새 클래스 역직렬화 확인
  4. 보안 책임 추가: 역직렬화 게이트가 또 하나 열림
  5. 상속이 위험: 부모가 Serializable이면 자식도 자동 (이를 막으려면 명시적 패턴 필요)

결정 기준

상황 권장
값 객체·DTO (외부 저장·전송 가능) Serializable 검토 — 단, 87·88·90 패턴
도메인 엔티티 가능한 한 회피
상속 가능 클래스 절대 금지 또는 매우 신중
내부 구현 (외부 노출 X) 불필요

안티패턴 — 무지성 implements Serializable

// ❌ — "혹시 모르니까"
public class Order implements Serializable {
    private List<OrderItem> items;
    ...
}

→ 향후 필드 변경이 막힘. 보안·테스트 부담 증가.

권장 — 명시적 결정

// ✅ — 캐시 대상으로 명시 결정
public final class UserCacheEntry implements Serializable {
    private static final long serialVersionUID = 1L;
    private final String userId;
    private final String name;
    ...
}

serialVersionUID반드시 명시. 자동 생성에 맡기면 클래스 변경 시 호환성 자동 깨짐.

Spring/JPA 현업 메모

  • JPA 엔티티에 Serializable 구현 의무 없음 (Hibernate가 식별자만 직렬화).
  • DTO에 무지성 implements Serializable 자제. 정말 필요한 곳만.

체크리스트

  • implements Serializable이 정말 필요한가 (캐시·세션·RMI?)
  • serialVersionUID = 1L을 명시했는가
  • 직렬화 형태 변경 시 호환성 영향을 인지하고 있는가

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

한 줄 요약

기본 직렬화는 객체의 물리적 구조(필드)를 그대로 쓴다. 그게 논리적 표현과 다르면, writeObject/readObject로 커스텀 형태를 정의하라.

기본 직렬화가 적합한 경우

객체의 물리 구조 = 논리 구조 일 때 (예: 좌표 (x, y)).

기본 직렬화가 부적합한 사례 — 연결 리스트

// ❌ — 기본 직렬화는 모든 노드를 재귀로 직렬화
public class StringList implements Serializable {
    private int size;
    private Entry head;
    private static class Entry implements Serializable {
        String data; Entry next; Entry prev;
    }
}
  • 노드가 많으면 스택 오버플로 (재귀 깊이)
  • 내부 구조(이중 연결)를 외부에 노출
  • 미래 구조 변경(배열 기반으로) 불가

커스텀 직렬화

public class StringList implements Serializable {
    private transient int size;     // transient — 기본 직렬화 제외
    private transient Entry head;
    private static final long serialVersionUID = 1L;

    private void writeObject(ObjectOutputStream s) throws IOException {
        s.defaultWriteObject();
        s.writeInt(size);
        for (Entry e = head; e != null; e = e.next) s.writeObject(e.data);   // 논리만
    }

    private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
        s.defaultReadObject();
        int numElements = s.readInt();
        for (int i = 0; i < numElements; i++) add((String) s.readObject());
    }
}

→ 논리(문자열 목록)만 직렬화, 물리(이중 연결 구조)는 향후 변경 자유.

함정

  • transient 표시한 필드는 역직렬화 후 기본값으로 — 메서드 안에서 명시적 초기화 필요.
  • synchronized 메서드면 writeObject도 synchronized로 (스레드 안전성 유지).

체크리스트

  • 직렬화할 클래스의 물리 구조와 논리 구조가 같은가
  • 다르다면 writeObject/readObject + transient 적용했는가
  • serialVersionUID 명시했는가

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

한 줄 요약

readObjectpublic 생성자와 같다 — 잘못된 바이트 스트림이 들어와도 객체 불변식·보안이 깨지지 않게 검증·복사하라.

비유 — "역직렬화는 생성자 우회"

생성자에서 들어오는 인자를 검사하듯, readObject로 들어오는 필드도 그대로 믿으면 안 됩니다. 외부에서 임의 바이트 스트림을 주면 생성자 검증을 우회한 객체가 만들어집니다.

안티패턴 — 검증·복사 없음

public final class Period implements Serializable {
    private final Date start, end;
    public Period(Date start, Date end) {
        this.start = new Date(start.getTime());
        this.end = new Date(end.getTime());
        if (this.start.after(this.end)) throw new IllegalArgumentException();
    }
    // readObject 없음 → 잘못된 직렬 데이터로 start > end인 Period 생성 가능
}

권장 — 방어적 복사 + 검증

private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
    s.defaultReadObject();

    // 방어적 복사 — 원본 참조가 외부에 노출되어 있을 수 있음
    start = new Date(start.getTime());
    end = new Date(end.getTime());

    // 불변식 검증
    if (start.after(end)) throw new InvalidObjectException(start + " after " + end);
}

규칙 모음

  1. private 가변 컴포넌트는 방어적 복사
  2. 불변식 검증 — 위반 시 InvalidObjectException
  3. 오버라이드 가능 메서드 호출 금지 — 직접 자식이 가로채 악용 가능
  4. 외부 반환 메서드도 방어적 복사 (Item 50과 동일)

Spring/JPA 현업 메모

자바 직렬화를 안 쓰면 이 함정도 없음 (Item 85). 쓰는 경우만 적용.

체크리스트

  • readObject가 생성자와 같은 수준의 검증을 하는가
  • 가변 컴포넌트를 방어적 복사하는가
  • 불변식 위반 시 InvalidObjectException인가

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

한 줄 요약

싱글턴이 Serializable이면 역직렬화로 두 번째 인스턴스가 생긴다. 옛 방식은 readResolve로 막았지만, enum이 더 안전.

옛 방식 — readResolve (취약)

public class Elvis implements Serializable {
    public static final Elvis INSTANCE = new Elvis();
    private Elvis() { ... }

    // 역직렬화 시 INSTANCE 반환
    private Object readResolve() { return INSTANCE; }
}
  • 모든 인스턴스 필드를 transient로 두지 않으면 공격 가능(가짜 필드로 두 번째 인스턴스 만들기).
  • 코드가 복잡해지고 실수 여지 많음.

권장 — enum 싱글턴

public enum Elvis {
    INSTANCE;

    public void leaveTheBuilding() { ... }
}
  • JVM이 enum의 단일 인스턴스 보장 (직렬화·리플렉션 모두 안전)
  • 코드 한 줄로 끝
  • 6장 아이템 34와 직결

체크리스트

  • 싱글턴을 enum으로 구현했는가
  • 어쩔 수 없이 readResolve를 쓴다면 모든 필드가 transient인가
  • 새 코드라면 readResolve 패턴을 도입하지 않는가

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

한 줄 요약

가장 안전한 직렬화 패턴. 별도 정적 내부 클래스(프록시) 가 직렬화·역직렬화를 담당하고, 원본 클래스는 직접 노출되지 않는다.

비유 — "원본 대신 대리인이 직렬화"

원본(Period)이 직접 우편에 실리는 게 아니라, 대리인(SerializationProxy)이 대신 실리고, 받는 쪽에서 대리인을 통해 원본을 새로 만듭니다. 모든 검증을 생성자에서, 방어적 복사는 자동.

패턴 — 3단계

public final class Period implements Serializable {
    private final Date start, end;

    public Period(Date start, Date end) {
        // 정상 생성자 + 방어적 복사 + 검증
        this.start = new Date(start.getTime());
        this.end = new Date(end.getTime());
        if (this.start.after(this.end)) throw new IllegalArgumentException();
    }

    // 1) writeReplace — 직렬화 시 프록시로 대체
    private Object writeReplace() {
        return new SerializationProxy(this);
    }

    // 2) readObject 무력화 — 원본을 직접 역직렬화하지 못하게
    private void readObject(ObjectInputStream s) throws InvalidObjectException {
        throw new InvalidObjectException("Proxy required");
    }

    // 3) 직렬화 프록시 — 정적 내부 클래스
    private static class SerializationProxy implements Serializable {
        private static final long serialVersionUID = 1L;
        private final Date start, end;

        SerializationProxy(Period p) {
            this.start = p.start;
            this.end = p.end;
        }

        // 역직렬화 시 원본 객체 생성 (정상 생성자 통과)
        private Object readResolve() {
            return new Period(start, end);   // 생성자 검증·복사 자동
        }
    }
}

장점

장점 설명
생성자 우회 불가 역직렬화도 정상 생성자를 거침 → 검증·복사 자동
불변식 보장 잘못된 바이트 스트림은 정상 생성자에서 거부
final 필드 가능 일반 직렬화는 final 필드 복원 제한이 있음
방어 코드 최소 readObject 방어 로직 안 짜도 됨

한계

  • 클라이언트가 상속 가능한 클래스에는 적용 불가
  • 약간의 성능 비용 (객체 2개 거침)

Spring/JPA 현업 메모

  • 자바 직렬화를 쓰는 한정된 영역(캐시 객체 등)에서 가장 추천.
  • 가능하면 Item 85대로 JSON으로 가는 게 더 근본적.

체크리스트

  • Serializable이 필요한 값 객체에 직렬화 프록시 패턴을 검토했는가
  • 원본의 readObject가 무력화되어 있는가
  • 프록시의 readResolve가 정상 생성자를 호출하는가

12장 종합 정리

한눈에 보는 결정 가이드

상황 선택
새 시스템의 직렬화 JSON/Protobuf(85) — 자바 직렬화 회피
어쩔 수 없이 자바 직렬화 ObjectInputFilter + 화이트리스트
Serializable 구현 결정 신중히(86), serialVersionUID = 1L 명시
물리 구조와 논리 구조 다름 커스텀 직렬화(87)writeObject/readObject + transient
직렬화 가능한 값 객체 방어적 readObject(88) 또는 직렬화 프록시(90)
싱글턴 직렬화 enum(89)readResolve 패턴 회피
값 객체 + 최대 안전 직렬화 프록시(90) ⭐

종합 체크리스트 (코드 리뷰용)

  • 새 코드에서 자바 직렬화 외 대안을 먼저 검토했는가
  • Serializable 구현이 정말 필요한 클래스에만 있는가
  • serialVersionUID가 명시되어 있는가
  • readObject가 방어적 복사 + 검증을 하는가
  • 싱글턴은 enum인가
  • 신뢰할 수 없는 입력의 역직렬화는 ObjectInputFilter로 막혀 있는가
  • 값 객체에 직렬화 프록시 패턴이 적용되어 있는가 (어쩔 수 없는 자바 직렬화에서)

종합 퀴즈

Q1. 자바 직렬화의 가장 큰 위험 하나를 들면?

A. 역직렬화 RCE(원격 코드 실행). 신뢰할 수 없는 바이트 스트림을 역직렬화하면, 그 안의 임의 클래스 readObject가 실행되어 공격자가 서버 명령을 실행할 수 있다. Apache Commons Collections gadget chain이 대표 사례.

Q2. serialVersionUID를 자동 생성에 맡기면 안 되는 이유는?

A. JVM은 클래스 구조(필드·메서드 시그니처)를 해시해 UID를 만든다. 클래스를 조금만 바꿔도 UID가 자동으로 변경되어 기존 직렬 데이터와 호환 불가. 명시적으로 serialVersionUID = 1L을 두면 호환성 결정을 개발자가 관리한다.

Q3. readObject를 "public 생성자와 같다"고 보는 이유는?

A. readObject는 정상 생성자를 우회해 객체를 생성하기 때문. 잘못된 직렬 데이터가 들어와도 객체 불변식이 깨지지 않게, 생성자와 동일한 수준의 방어적 복사·검증이 필요하다.

Q4. 직렬화 프록시 패턴이 가장 안전한 이유 3가지?

A. (1) 역직렬화도 정상 생성자를 거침 → 검증·복사 자동, (2) 잘못된 바이트 스트림은 생성자에서 거부되어 불변식 보장, (3) final 필드 복원 등 일반 직렬화 제약이 사라짐.

Q5. 싱글턴 직렬화를 enum으로 하는 게 readResolve보다 안전한 이유는?

A. JVM이 enum의 단일 인스턴스를 보장(직렬화·리플렉션 모두). readResolve 패턴은 모든 필드를 transient로 두지 않으면 공격으로 두 번째 인스턴스가 만들어지는 함정이 있다 (Item 89 + Item 34와 결합).


책 전체 마치며 — 90개 아이템을 관통하는 6가지 원칙

12장까지 다 왔습니다. 90개 아이템을 다 외울 필요는 없습니다. 책 전체를 관통하는 다음 6가지 원칙만 체화하면 됩니다.

  1. 불변을 기본값으로 (Items 17·76 + 11장 동시성 + 12장 직렬화 프록시)
  2. 인터페이스에 의존 (Items 20·64 + DI + 테스트 가능성)
  3. 표준을 우선 (Items 44·59·72 — 함수형 인터페이스·라이브러리·예외)
  4. 검증·계약을 명시 (Items 49·56·74·82 — 매개변수·Javadoc·예외·스레드 안전성)
  5. 합성 > 상속 (Items 18·87 + 4장 + 11장)
  6. 측정 후 최적화 (Items 48·67 — 병렬화·미세 최적화 모두)

12장 → 다음 단계

위 6원칙이 자연스럽게 손에 붙으면, 다음은: - 오브젝트 (조영호) — 같은 원칙들의 한국어 책임 주도 설계 관점 해설 - Clean Code (Martin) — 가독성·이름 짓기·함수 길이 등 미시적 단위 - Domain-Driven Design (Evans/Vernon) — 비즈니스 도메인의 큰 그림 - Java Concurrency in Practice (Goetz) — 11장의 깊이 있는 전개

이어서 만들까요? - 통합 교재 묶기 (12장을 한 권으로 정리, 책 전체 결정 가이드·체크리스트 종합) - 실습 프로젝트 (각 장의 핵심 아이템을 한 코드베이스로 통합 적용) - 다른 책으로 진행 (Clean Code · TDD · 더 깊은 동시성)