CodeGym /행동 /JAVA 25 SELF /직렬화된 데이터의 마이그레이션과 버전 관리

직렬화된 데이터의 마이그레이션과 버전 관리

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

1. 문제: 직렬화된 클래스를 변경하면 무엇이 발생하나요?

실제 프로젝트에서는 객체를 자주 직렬화해 파일, 데이터베이스 또는 캐시에 저장한 뒤 나중에 복원합니다. 그런데 클래스에서 필드를 추가하거나 제거하고, 타입을 바꾸면 어떻게 될까요? 프로덕션에는 이미 오래된 직렬화 객체가 남아 있는데 말입니다.

예를 들어, 프로덕션에 User 클래스의 직렬화된 객체가 저장된 파일이 있다고 합시다. 새 버전의 애플리케이션에서 User에 새 필드를 추가하거나 기존 필드의 타입을 변경했습니다. 프로그램이 오래된 데이터를 역직렬화하려고 하면, 보통 InvalidClassException 같은 오류가 발생하거나 데이터가 유실됩니다. 객체 구조가 더 이상 JVM의 기대와 일치하지 않기 때문입니다.

따라서 클래스 버전과 직렬화된 데이터 간의 호환성을 미리 설계하는 것이 중요합니다. 프로덕션에서는 예전 파일을 그냥 “삭제”할 수 없습니다. 하위 호환을 유지하거나 데이터 마이그레이션을 구현하여 새 클래스 버전이 이미 저장된 객체와 올바르게 동작하도록 해야 합니다.

2. serialVersionUID로 해결하기

serialVersionUID란?

직렬화 가능한 클래스의 “버전”을 정의하는 특수 필드입니다.

private static final long serialVersionUID = 1L;
  • 필드를 명시하지 않으면 Java가 클래스 구조를 바탕으로 값을 자동 계산합니다.
  • 역직렬화 시 직렬화된 데이터와 클래스의 serialVersionUID가 비교됩니다.
  • 값이 다르면 InvalidClassException이 발생합니다.

자동 생성과 수동 제어

자동: 명시하지 않으면 컴파일러가 클래스 구조(이름, 필드, 메서드 등)를 기반으로 값을 계산합니다.

수동 제어: 호환성을 통제하기 위해 직렬화 가능한 클래스에서는 항상 serialVersionUID를 명시적으로 선언하는 것이 좋습니다.

예:

public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    // ...
}

언제 값을 바꾸고, 언제 그대로 둘까?

  • 그대로 둠: 호환성을 깨지 않는 변경(예: 기본값으로 초기화 가능한 새 필드 추가).
  • 변경함: 필드 삭제, 필드 타입 변경, 클래스 계층 구조 변경 등 호환 불가한 변경.

규칙:

- 새 클래스 버전이 예전 직렬화 객체를 읽을 수 있어야 한다면 serialVersionUID를 바꾸지 마세요.
- 호환 불가가 치명적이라면(‘깨진’ 데이터보다 오류가 낫다면) serialVersionUID를 올리세요.

3. 데이터 마이그레이션 전략

편의상 “지연” 마이그레이션이라 부르는 접근법이 있습니다. 모든 오래된 데이터를 한꺼번에 변환하지 않고, 객체가 처음 읽힐 때 점진적으로 변환하는 방식입니다.

예를 들어 새 필드를 추가했다면, 오래된 객체를 역직렬화할 때 그 필드는 타입에 따라 기본값 — 0, null 또는 false — 으로 설정됩니다. 필드를 삭제했다면 역직렬화는 그 필드를 단순히 무시합니다. JVM이 이름과 타입으로 필드를 매칭해 주므로, 많은 변경이 “그냥” 동작합니다.

더 어려운 경우는 필드 타입 변경입니다. 예전에는 int였다가 String으로 바뀐 경우처럼요. 표준 역직렬화만으로는 해결되지 않습니다. 해결책은 수동 변환을 처리하는 readObject 메서드를 구현하는 것입니다.

private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
    ObjectInputStream.GetField fields = in.readFields();
    // 이전 필드: int age
    int age = fields.get("age", -1);
    // 새 필드: String ageStr
    this.ageStr = String.valueOf(age);
}

이렇게 하면 오래된 객체도 처음 읽는 시점에 새 클래스 버전에 맞게 올바르게 적응됩니다.

패턴 “제자리 변환”(in-place conversion)

이 접근법은 지연 마이그레이션과 달리 모든 데이터를 즉시 변환합니다. 아이디어는 간단합니다. 파일이나 데이터베이스에 저장된 각 직렬화 객체를 순회하며 구버전 클래스로 읽어 들인 뒤, 신버전 객체를 생성해 업데이트된 형식으로 다시 기록합니다.

객체가 드물게 읽히거나 데이터가 매우 많아 지연 마이그레이션에 의존할 수 없을 때 유용합니다. 실무에서는 별도 스크립트나 유틸리티로 수행하는 경우가 많습니다. 예를 들어 과정은 다음과 같습니다:

// 간단한 in-place 변환 예
List<File> files = getSerializedFiles(); // 이전 객체들이 저장된 파일 목록
for (File file : files) {
    try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file))) {
        OldUser oldUser = (OldUser) ois.readObject(); // 이전 객체 읽기
        NewUser newUser = new NewUser(oldUser); // 이전 객체를 기반으로 새 객체 생성
        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file))) {
            oos.writeObject(newUser); // 파일을 새 버전으로 덮어쓰기
        }
    } catch (IOException | ClassNotFoundException e) {
        e.printStackTrace();
    }
}

이 방법을 사용하면 모든 객체가 즉시 새 버전으로 정규화되어 프로덕션에서 안전하게 사용할 수 있습니다.

4. 구버전 처리: 고급 트릭

ObjectInputStream.readClassDescriptor()와 readFields()

  • readClassDescriptor() — 클래스 메타데이터 읽기 과정을 가로채 필요하면 대체하여 직렬화를 “속일” 수 있습니다.
  • readFields() — 구조가 바뀌어도 이름으로 필드를 읽을 수 있습니다.

예:

private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
    ObjectInputStream.GetField fields = in.readFields();
    String name = (String) fields.get("name", "unknown");
    int age = fields.defaulted("age") ? 0 : fields.get("age", 0);
    // ... 새 필드 초기화
}

5. 실전: 클래스 두 버전, 직렬화와 마이그레이션

1단계. 이전 버전 클래스

// OldUser.java
import java.io.Serializable;

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

    public OldUser(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

2단계. 이전 버전 객체 직렬화

OldUser user = new OldUser("Vasya", 30);
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("user.dat"))) {
    out.writeObject(user);
}

3단계. 새 버전 클래스(필드 email 추가, age 타입 변경)

// User.java
import java.io.*;

public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    public String name;
    public String age; // 타입 변경!
    public String email; // 새 필드

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        ObjectInputStream.GetField fields = in.readFields();
        this.name = (String) fields.get("name", "unknown");
        // 이전 필드 age(int)을 문자열로 변환
        if (!fields.defaulted("age")) {
            int oldAge = fields.get("age", 0);
            this.age = String.valueOf(oldAge);
        } else {
            this.age = "unknown";
        }
        // 새 필드 email — 기본값은 null
        this.email = (String) fields.get("email", null);
    }
}

4단계. 구버전 객체를 신버전 클래스로 역직렬화

try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("user.dat"))) {
    User user = (User) in.readObject();
    System.out.println(user.name + ", " + user.age + ", " + user.email);
}

결과:

  • 기존 필드 age가 문자열로 변환됩니다.
  • 새 필드 email은 null입니다.
  • InvalidClassException이 발생하지 않습니다. serialVersionUID가 동일하고 타입 불일치를 수동으로 처리했기 때문입니다.

불일치를 처리하지 않으면?

필드 타입만 바꾸고 readObject를 구현하지 않으면, 역직렬화 시 다음과 같은 오류가 발생합니다:

java.io.InvalidClassException: User; incompatible types for field age

6. 직렬화된 데이터 마이그레이션 시 흔한 실수

오류 №1: serialVersionUID를 지정하지 않음 — 클래스가 조금만 바뀌어도 사소한 변경에서조차 InvalidClassException이 발생할 수 있습니다.

오류 №2: readObject에서 타입 변경을 처리하지 않음 — 타입 호환성 오류가 발생합니다.

오류 №3: 필드를 삭제했는데 오래된 데이터에는 여전히 존재함 — Java는 그 필드를 그냥 무시하지만, 그 필드가 중요했다면 데이터가 유실됩니다.

오류 №4: 테스트 없이 모든 데이터를 수작업으로 마이그레이션함 — 일부 정보가 사라지거나 일관성 없는 객체가 생길 수 있습니다.

오류 №5: 객체를 직렬화/역직렬화하는 모든 지점을 업데이트하지 않음 — 코드 일부는 새 버전, 일부는 구버전을 사용해 “유령” 버그가 생깁니다.

오류 №6: 대용량 데이터에 대한 마이그레이션 전략을 준비하지 않음 — “지연” 마이그레이션에서는 사용자가 오래된 데이터에 처음 접근할 때 예상치 못한 오류를 겪을 수 있습니다.

오류 №7: 마이그레이션 전에 백업하지 않음 — 업데이트 전 직렬화 데이터를 반드시 백업하세요!

1
과제
JAVA 25 SELF, 레벨 45, 레슨 3
잠금
동물 관리 시스템: 기존 데이터 손실 없이 색상 필드 추가
동물 관리 시스템: 기존 데이터 손실 없이 색상 필드 추가
1
과제
JAVA 25 SELF, 레벨 45, 레슨 3
잠금
사용자 프로필 업데이트: 지능형 연령 마이그레이션
사용자 프로필 업데이트: 지능형 연령 마이그레이션
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION