CodeGym /행동 /JAVA 25 SELF /직렬화 시 호환성과 역호환성(backward compatibility)

직렬화 시 호환성과 역호환성(backward compatibility)

JAVA 25 SELF
레벨 45 , 레슨 2
사용 가능

1. 호환성의 문제

자, 상상해 봅시다. 여러분이 애플리케이션의 첫 버전을 출시했고, 사용자가 데이터를 저장하기 시작했습니다(예: 사용자 프로필이나 설정). 한 달쯤 지나 보니 UserProfile 클래스에 email 필드가 없다는 걸 깨닫고 이를 추가했습니다. 모든 것이 완벽해 보이지만… 예전 파일을 불러오려는 순간 문제가 생길 수 있습니다. 운이 좋으면 새 필드는 비어 있을 것이고, 운이 나쁘면 예외가 발생하고 사용자가 실망하게 됩니다.

직렬화 호환성은 프로그램이 이전 버전의 클래스들로 직렬화된 데이터를 올바르게 읽을 수 있는 능력이며, 그 반대도 마찬가지입니다. Java(특히 Serializable을 통한 이진 직렬화)에서는 이 주제가 특히 중요합니다. JVM은 클래스 구조의 변경에 매우 민감하기 때문입니다.

문제가 발생하는 대표적인 시나리오:

  • 클래스에 새 필드를 추가했다.
  • 오래된 필드를 삭제했다.
  • 필드의 타입을 변경했다(예: int에서 String으로).
  • 클래스 이름을 바꾸거나 다른 패키지로 옮겼다.
  • 객체를 직렬화하는 라이브러리나 프레임워크를 업데이트했다.

이 모든 경우에, 기존에 직렬화된 데이터가 새 버전의 프로그램에 대해 “읽을 수 없는” 상태가 될 수 있습니다.

2. serialVersionUID: 직렬화 가능한 클래스의 신분증

Java에서 모든 직렬화 가능한 클래스(즉, Serializable 인터페이스를 구현하는 클래스)는 고유한 버전 식별자 — serialVersionUID를 가집니다. 이 필드는 해당 클래스가 객체를 역직렬화할 수 있는지 JVM이 확인할 때 사용됩니다. 식별자가 일치하지 않으면 InvalidClassException이 발생합니다.

private static final long serialVersionUID = 1L;

이 필드를 명시적으로 선언하지 않으면, Java는 클래스의 구조(필드, 메서드, 한정자 등)를 바탕으로 자동으로 값을 생성합니다. 하지만 이후에 클래스를 변경하면(사소한 변경이라도) 자동 생성된 serialVersionUID가 바뀌어 예전 데이터와 호환되지 않게 됩니다.

검사는 어떻게 동작할까?

객체가 직렬화될 때, 그 데이터와 함께 serialVersionUID 값도 스트림에 기록됩니다. 역직렬화 시 JVM은 이 식별자를 현재 클래스에 선언된 값과 대조합니다. 둘이 같으면 객체는 문제없이 복원됩니다. 하지만 값이 다르면 즉시 오류로 중단됩니다. JVM은 클래스가 너무 많이 변경되어 예전 데이터가 더 이상 맞지 않는다고 판단하기 때문입니다.

왜 serialVersionUID를 명시적으로 선언할까?

여러분이 직접 serialVersionUID를 지정하면, 클래스에서 어떤 변경이 “허용 가능한지”를 스스로 통제할 수 있습니다. 예를 들어, 새 필드를 추가했지만 예전 객체도 계속 로드되길 원한다면? 식별자를 그대로 두면 역직렬화가 문제없이 진행됩니다. 자동 생성에 의존하면 사소한 코드 변경만으로도 예전 저장본이 열리지 않는 불상사가 생길 수 있습니다.

예시:

public class Person implements Serializable {
    private static final long serialVersionUID = 1L;
    private String name;
    private int age;
    // ... getter와 setter
}

이제 필수적이지 않은 한에서 새 필드를 안심하고 추가할 수 있고, 예전 객체의 역직렬화가 깨지지 않습니다.

3. 클래스가 변경되면 무엇이 일어날까?

새 필드 추가

오래된 직렬화 객체 → 새 필드가 추가된 최신 클래스

  • 새 필드는 기본값을 갖습니다(null, 0, false).
  • 나머지는 정상적으로 역직렬화됩니다.

예시:

// 이전:
public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    private String name;
}

// 이후:
public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    private String name;
    private String email; // 새 필드
}

결과: 예전 객체는 로드되고, email == null입니다.

필드 삭제

오래된 직렬화 객체에는 필드가 있으나, 최신 클래스에는 없음

  • 그 필드는 역직렬화 시 그냥 무시됩니다.
  • 중요한 점 — serialVersionUID를 변경하지 마세요.

필드 타입 변경

예를 들어, 원래는 int age였다가 String age로 변경한 경우입니다.

  • 이는 호환되지 않는 변경입니다. 역직렬화 시 오류가 발생합니다(보통 InvalidClassException 또는 ClassCastException).
  • 이런 변경은 피하는 것이 좋고, 필요하다면 커스텀 직렬화로 호환성을 보장하세요(아래 참조).

클래스 또는 패키지 이름 변경

이 경우는 엄격합니다. 클래스나 패키지 이름을 바꾸면 역직렬화가 진행되지 않습니다. 직렬화된 스트림에는 클래스의 정규 이름이 저장되어 있으며, JVM은 정확히 그 이름을 기대합니다. 따라서 어떤 이름 변경도 치명적인 변경으로 간주됩니다. 프로젝트 구조를 꼭 변경해야 한다면, 수동 데이터 마이그레이션이 필요합니다.

4. transient와 static: 무엇이 직렬화되고, 무엇이 직렬화되지 않는가?

  • static 필드는 아예 직렬화되지 않습니다 — 이는 객체가 아니라 클래스에 속합니다.
  • transient 필드는 캐시나 임시 토큰처럼 직렬화에 포함되지 않아야 하는 임시 데이터를 표시합니다.

예시:

public class Session implements Serializable {
    private static final long serialVersionUID = 1L;
    private String user;
    private transient String sessionToken; // 직렬화되지 않음
}

역직렬화 시 sessionTokennull이 됩니다. 직렬화 전 객체에서 값이 채워져 있었더라도 마찬가지입니다.

5. 커스텀 직렬화: writeObject/readObject

더 복잡한 호환성 로직이 필요하다면(예: 기존 필드를 새 필드로 변환하거나, 바뀐 타입을 처리), 다음과 같은 특별 메서드를 구현할 수 있습니다:

private void writeObject(ObjectOutputStream out) throws IOException {
    out.defaultWriteObject();
    // 필요하다면 추가 로직
}

private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
    in.defaultReadObject();
    // 추가 로직, 예를 들어, 기존 데이터를 바탕으로 새 필드를 채운다
}

진화 예시:

public class User implements Serializable {
    private static final long serialVersionUID = 2L;
    private String name;
    private int age; // 과거에는 String birthYear 였음

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        // birthYear 필드가 있었다면 age로 변환
        // (birthYear를 transient로 보관하는 경우의 예시 코드)
    }
}

6. XML과 JSON에서의 호환성: 텍스트 포맷의 유연함

이진 직렬화와 달리, XML과 JSON 포맷은 클래스 구조 변경에 훨씬 관대합니다.

XML (JAXB)과 JSON (Jackson, Gson)

이진 직렬화와 달리, XML 또는 JSON을 사용할 때 역직렬화는 훨씬 유연하게 동작합니다. 데이터에 클래스에 없는 필드가 있어도 무시됩니다. 반대로 클래스에 새 필드가 추가되었지만 원본 데이터에 없으면 기본값을 받습니다 — 보통 객체는 null, 숫자는 0입니다. 요소의 순서는 중요하지 않으므로 태그나 키의 순서를 바꿔도 올바르게 파싱됩니다.

어노테이션으로 완전한 제어가 가능합니다. 파일에서 사용할 이름, 필수 여부, 생략 가능 여부, 포맷까지 지정할 수 있습니다. 예를 들어 JAXB에서 User 클래스는 다음과 같을 수 있습니다:

public class User {
    @XmlElement(required = true)
    private String name;

    @XmlElement
    private String email; // 새 필드, 필수 아님
}

Jackson 또는 Gson을 사용하는 JSON의 경우는 대략 다음과 같습니다:

public class User {
    @JsonProperty("name")
    private String name;

    @JsonProperty("email")
    private String email; // 새 필드
}

결과는 만족스럽습니다. 오래된 JSON 또는 XML 파일도 문제없이 로드되고, 새 필드는 null을 받으며, 데이터의 불필요한 필드는 무시됩니다. 클래스 구조를 비교적 자유롭게 변경해도 예전 저장본을 깨뜨릴 걱정이 줄어듭니다.

언제 제어가 필요할까?

필드를 필수로 만들 때 특히 중요합니다. 이전 데이터에 해당 필드가 없으면 역직렬화가 오류로 끝납니다. 타입 변경도 마찬가지입니다. 예전에 문자열이던 필드를 숫자로 바꾸면, 예전 데이터는 파싱에 실패할 수 있습니다. 따라서 이런 변경을 하기 전에 기존 저장본에 미치는 영향을 반드시 점검하고, 필요하다면 마이그레이션을 준비하거나 기본값을 지정하세요.

7. 호환성 보장 전략

  • 반드시 명시적으로 선언하세요: serialVersionUID. 이진 직렬화에서 호환성을 통제하는 핵심 수단입니다.
  • 새 필드는 선택적으로 추가하세요. 새 필드는 null이거나 기본값을 갖도록 합니다.
  • 다음에 transient 를 사용하세요 — 임시 또는 중요하지 않은 데이터. 이런 필드는 직렬화에 포함되지 않아 클래스 진화 시 문제를 줄입니다.
  • 클래스 변경을 문서화하세요. 어떤 필드가 어느 버전에서 추가/삭제되었는지 클래스 주석에 기록합니다.
  • 복잡한 경우에는 — writeObject/readObject. 로딩 과정에서 데이터 마이그레이션을 구현할 수 있습니다.
  • 중요한 데이터에는 스키마를 사용하세요 (XML Schema, JSON Schema). 데이터 구조를 명시적으로 기술하고 로딩 시 검증하는 데 도움이 됩니다.

8. 실습: 비호환성과 진화 시연

서로 다른 serialVersionUID 로 인한 오류 시연

// 먼저 한 버전의 클래스로 객체를 직렬화
public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    private String name;
}

// 그런 다음 serialVersionUID를 변경(예: 2L로), 컴파일 후 예전 파일을 로드 시도
public class User implements Serializable {
    private static final long serialVersionUID = 2L;
    private String name;
}

결과:

java.io.InvalidClassException: User; local class incompatible: stream classdesc serialVersionUID = 1, local class serialVersionUID = 2

클래스 진화의 성공 예

public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    private String name;
    // 새 필드
    private String email;
}

예전 객체(email 없이)를 직렬화한 뒤 필드를 추가하고 serialVersionUID를 바꾸지 않으면, 역직렬화는 정상 동작하며 emailnull이 됩니다.

9. 직렬화 호환성에서 흔한 실수

오류 №1: serialVersionUID를 선언하지 않음. serialVersionUID를 명시적으로 선언하지 않으면 JVM이 자동으로 생성합니다. 새 메서드 추가나 필드 한정자 변경 같은 사소한 클래스 변경만으로도 serialVersionUID가 바뀌어 예전 데이터를 역직렬화할 수 없게 됩니다. backward compatibility를 “깨뜨리는” 전형적인 방법입니다.

오류 №2: 필드 타입 변경. 필드 타입을 바꾸면(예: intString) 예외가 발생하거나 데이터가 부정확해질 수 있습니다. 이런 변경은 각별한 주의가 필요하며, 더 나은 방법은 수동 마이그레이션을 동반한 writeObject/readObject를 사용하는 것입니다.

오류 №3: 클래스/패키지 삭제 또는 이름 변경. 클래스 이름 변경이나 패키지 변경은 예전 객체의 역직렬화를 불가능하게 만듭니다. 클래스 이름과 패키지는 직렬화 스트림에 저장되며, JVM은 이를 매칭할 수 없습니다.

오류 №4: transient의 남용. 중요한 필드(예: 사용자 id)를 transient로 만들면 직렬화되지 않으며, 객체 복원 시 값이 사라집니다.

오류 №5: 컬렉션의 불일치 변경. 새 컬렉션 필드를 추가하거나 컬렉션 타입을 변경하면(예: ListSet) 예전 데이터가 올바르게 역직렬화되지 않거나 오류가 발생할 수 있습니다.

오류 №6: XML/JSON에서 지나치게 엄격한 제약. XML/JSON 스키마에서 필드를 필수(required = true)로 지정했는데 예전 데이터에 없으면 로딩이 오류로 끝납니다. 어노테이션과 스키마 설정에 주의하세요!

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION