Unity 세이브 데이터 버전 관리와 마이그레이션
Unity에서 JSON을 활용한 세이브 및 로드 기능을 구현하면서 데이터 버전 관리와 마이그레이션(Migration)을 학습했다.
처음에는 버전마다 SaveDataV1, SaveDataV2, SaveDataV3 클래스를 만들고, VersionUp() 메서드로 다음 버전의 객체를 생성하는 방식을 사용했다.
public override SaveData VersionUp()
{
var data = new SaveDataV4();
data.Gold = Gold;
data.Experience = Experience;
return data;
}
하지만 구현하면서 의문이 생겼다.
버전이 10개, 데이터 필드가 100개라면 변경되지 않은 데이터까지 매번 복사해야 하는 걸까?
버전이 증가할수록 중복 코드가 늘어나고, 필드를 누락하면 기존 데이터가 손실될 가능성도 있다.
결국 문제는 VersionUp()을 어느 클래스에 작성하느냐가 아니라, 세이브 데이터의 변경사항을 어떤 방식으로 관리할 것인가라고 생각하게 되었따.
이를 알아보기 위해 Unity, C#, JSON을 기준으로 여러 직렬화 및 마이그레이션 전략을 비교해 보았다.
2. 직렬화와 마이그레이션
먼저 직렬화와 마이그레이션은 서로 다른 개념이다.
2.1. 직렬화(Serialization)
메모리에 있는 객체를 저장하거나 전송할 수 있는 형태로 변환하는 과정이다.
반대로 저장된 데이터로부터 객체를 복원하는 과정을 역직렬화(Deserialization)라고 한다.
C#에서는 Json.NET을 이용해 다음과 같이 구현할 수 있다.
// 직렬화
string json = JsonConvert.SerializeObject(data);
// 역직렬화
SaveData loaded = JsonConvert.DeserializeObject<SaveData>(json);
2.2. 마이그레이션(Migration)
기존에 저장된 데이터를 새로운 데이터 구조나 의미에 맞게 변환하는 과정이다.
예를 들어 V3에서는 이름을 하나의 문자열로 관리했지만, V4에서는 성과 이름을 분리한다고 가정하자.
직렬화가 데이터를 저장하고 복원하는 과정이라면, 마이그레이션은 데이터 구조가 변경되더라도 기존 데이터를 사용할 수 있도록 변환하는 과정이다.
| 구분 | 직렬화 | 마이그레이션 |
|---|---|---|
| 목적 | 데이터 저장 및 복원 | 버전 간 호환성 유지 |
| 대상 | 객체와 저장 형식 | 구버전과 신버전 데이터 |
| 예시 | C# 객체 → JSON | PlayerName → 성과 이름 |
| 필요 시점 | 저장 및 로드 | 데이터 구조나 의미 변경 시 |
JSON, XML, Binary, Protocol Buffers는 데이터 저장 및 직렬화와 관련된 형식이다.
반면 순차 마이그레이션, 버전 허용형 역직렬화 등은 데이터 변경에 대응하는 전략이다.
따라서 JSON을 사용한다고 해서 반드시 SaveDataV1, SaveDataV2처럼 버전별 클래스를 만들어야 하는 것은 아니다.
3. 대표적인 마이그레이션 전략
| 방식 | 핵심 원리 | 필드별 복사 |
|---|---|---|
| 순차 객체 변환 | V1 → V2 → V3 | 대체로 필요 |
| 버전 허용형 역직렬화 | 최신 클래스에 호환 필드 로드 | 보통 불필요 |
| JSON 직접 변환 | 변경되는 JSON 필드만 수정 | 불필요 |
| Custom Converter | 역직렬화 과정에서 변환 | 구현에 따라 다름 |
| 통합 마이그레이션 | 여러 변환 단계를 통합 | 구현에 따라 다름 |
| DB 스키마 마이그레이션 | 테이블과 컬럼 구조 변경 | 객체별 복사 불필요 |
3.1. 순차 객체 마이그레이션
오늘 구현한 방식이다.
각 버전의 클래스를 만들고 VersionUp() 메서드로 다음 버전의 객체를 생성한다.
public override SaveData VersionUp()
{
var data = new SaveDataV4();
data.Gold = Gold;
data.Experience = Experience;
return data;
}
이 방식은 각 버전의 구조와 변환 과정을 명확하게 관리할 수 있다.
또한 오래된 데이터를 최신 버전까지 순차적으로 변환할 수 있다.
장점
- 버전별 데이터 구조가 명확하다.
- 구현이 단순하고 이해하기 쉽다.
- 각 단계의 변환 로직을 독립적으로 관리할 수 있다.
단점
- 버전이 증가할 때마다 클래스가 늘어난다.
- 변경되지 않은 필드도 복사해야 한다.
- 복사 과정에서 필드를 누락하면 데이터가 손실될 수 있다.
데이터 구조가 복잡하거나 과거 버전의 구조를 정확하게 유지해야 하는 경우에 유용하다.
3.2. 버전 허용형 역직렬화
Version-Tolerant Serialization은 저장된 데이터와 현재 클래스의 구조가 완전히 같지 않더라도 호환되는 필드를 읽을 수 있도록 하는 접근 방식이다.
기존 V2 데이터가 다음과 같다고 가정하자.
{
"Version": 2,
"PlayerName": "Song JeBeak",
"Gold": 500
}
현재 클래스에는 Experience가 추가되었다.
public class SaveData
{
public int Version { get; set; } = 3;
public string PlayerName { get; set; } = string.Empty;
public int Gold { get; set; } = 100;
public int Experience { get; set; } = 47;
}
SaveData data = JsonConvert.DeserializeObject<SaveData>(json);
일반적인 Json.NET 설정에서는 JSON에 존재하는 PlayerName, Gold가 객체에 매핑된다.
JSON에 없는 Experience는 객체 초기화 및 직렬화 설정에 따라 기본값을 사용할 수 있다.
즉, 새로운 필드가 추가되었다는 이유만으로 매번 마이그레이션 클래스를 만들 필요는 없다.
장점
- 단순한 필드 추가에 효율적이다.
- 기존 필드를 복사하는 코드가 필요하지 않다.
- 최신 데이터 클래스 하나를 유지할 수 있다.
단점
- 필드 이름이나 구조가 변경되면 자동으로 해결되지 않는다.
- 누락된 필드의 기본값을 신중하게 설정해야 한다.
- 데이터의 의미가 변경되었다면 별도의 변환이 필요하다.
예를 들어 PlayerName을 FirstName, LastName으로 분리한다면 단순 역직렬화만으로는 해결하기 어렵다.
3.3. JSON 직접 마이그레이션 — JObject
JObject는 JSON 데이터를 객체 형태로 읽고 수정할 수 있도록 하는 Json.NET의 기능이다.
기존의 VersionUp() 방식에서는 새로운 객체를 만들고 모든 필드를 복사했다.
하지만 JObject를 사용하면 변경해야 하는 JSON 필드만 수정하고 나머지 데이터는 유지할 수 있다.
기존 V3 JSON:
{
"Version": 3,
"PlayerName": "Song JeBeak",
"Gold": 500,
"Experience": 120
}
다음과 같이 필요한 필드만 변경할 수 있다.
JObject jsonData = JObject.Parse(json);
string playerName = jsonData.Value<string>("PlayerName") ?? "";
string[] names = playerName.Split(
new[] { ' ' },
2,
System.StringSplitOptions.RemoveEmptyEntries
);
jsonData["LastName"] = names.Length > 0 ? names[0] : "";
jsonData["FirstName"] = names.Length > 1 ? names[1] : "";
jsonData.Remove("PlayerName");
jsonData["Version"] = 4;
변환 결과:
{
"Version": 4,
"Gold": 500,
"Experience": 120,
"LastName": "Song",
"FirstName": "JeBeak"
}
여기서 중요한 점은 Gold, Experience를 복사하는 코드를 작성하지 않았다는 것이다.
JObject가 기존 JSON 필드를 유지하기 때문에 변경할 부분만 수정하면 된다.
장점
- 변경되지 않은 필드를 복사하지 않아도 된다.
- 새로운 데이터 객체를 반복해서 생성할 필요가 없다.
- 최신 데이터 클래스를 하나만 유지하는 설계와 결합할 수 있다.
단점
- 필드명을 문자열로 작성하므로 오타에 주의해야 한다.
- 데이터 타입 변경 시 별도 변환 및 검증이 필요하다.
- JSON에 남아 있는 데이터가 최신 C# 클래스에서도 유효한지는 확인해야 한다.
현재 작성한 SaveLoadManager는 TypeNameHandling.All을 사용하기 때문에 $type 메타데이터 처리도 고려해야 한다.
위 코드는 일반적인 JSON 구조에서의 변환 예시이며, 기존 프로젝트에 그대로 적용한 완성 코드는 아니다.
3.4. Custom JsonConverter
JsonConverter를 활용하면 직렬화 및 역직렬화 과정에서 데이터를 변환하는 규칙을 직접 작성할 수 있다.
예를 들어 클래스에 Converter를 지정할 수 있다.
[JsonConverter(typeof(SaveDataConverter))]
public class SaveData
{
// 현재 데이터 구조
}
SaveDataConverter에서 구버전 데이터의 필드를 현재 구조에 맞게 해석하도록 구현하는 것이다.
장점
- 타입별 변환 규칙을 재사용할 수 있다.
- 저장 및 로드 코드에서 변환 로직을 분리할 수 있다.
- 특수한 JSON 구조를 처리하기 좋다.
단점
- Converter를 직접 구현해야 한다.
- 처리할 버전이 많아지면 Converter 내부가 복잡해질 수 있다.
- 역직렬화와 버전 관리 로직이 하나로 결합될 수 있다.
특정 타입에 대한 별도의 직렬화 규칙이 필요하거나 구버전 형식을 읽는 과정에 변환을 포함하고 싶을 때 고려할 수 있다.
3.5. 통합 마이그레이션과 체크포인트
버전이 많아지면 구버전에서 최신 버전으로 이동하는 단계도 많아진다.
예를 들어 V1부터 V100까지 순차적으로 변환하려면 최대 99단계를 거쳐야 한다.
이때 여러 단계의 변환을 하나로 통합하거나 특정 구버전에서 최신 버전으로 바로 이동하는 경로를 구현할 수 있다.
단, 중간 단계가 사라진다고 데이터 변환 자체가 필요 없어지는 것은 아니다.
중간 버전에서 발생한 모든 변경사항을 정확하게 반영하는 변환 로직이 필요하다.
또한 이 방식은 데이터 복구용 스냅샷과 구별해야 한다. 백업 파일을 만든다고 구버전 호환성이 자동으로 해결되는 것은 아니다.
버전이 매우 많아지고 구버전 호환성 관리 비용이 증가했을 때 고려할 수 있는 방법이다.
3.6. 데이터베이스 스키마 마이그레이션
게임 데이터를 JSON 파일이 아닌 SQLite와 같은 데이터베이스에 저장하는 방법도 있다.
데이터베이스에서는 객체별 필드를 복사하는 대신 테이블 구조를 변경할 수 있다.
예를 들어 Experience 컬럼을 추가한다면 다음과 같이 작성할 수 있다.
ALTER TABLE Player
ADD COLUMN Experience INTEGER DEFAULT 0;
SQLite는 데이터베이스에 애플리케이션 정의 버전 번호를 기록할 수 있는 PRAGMA user_version도 제공한다.
다만 스키마를 변경하는 SQL과 데이터 변환 과정은 개발자가 관리해야 한다.
장점
- 대량의 구조화된 데이터를 관리하기 좋다.
- 테이블과 컬럼 단위로 변경할 수 있다.
- 데이터 조회와 갱신에 유리하다.
단점
- 데이터베이스 구조를 설계해야 한다.
- SQL과 스키마 버전 관리가 필요하다.
- 단순한 로컬 세이브에는 구조가 과도할 수 있다.
4. 직렬화 포맷을 변경하면 해결될까?
마이그레이션 문제를 해결하기 위해 JSON이 아닌 다른 직렬화 포맷을 사용할 수도 있다.
그러나 직렬화 포맷을 변경한다고 모든 버전 호환성 문제가 해결되는 것은 아니다.
| 포맷 | 특징 | 버전 호환성 |
|---|---|---|
| JSON | 사람이 읽고 수정하기 쉬움 | 별도의 정책 필요 |
| XML | 태그를 이용한 계층적 데이터 | 별도의 정책 필요 |
| Binary | 이진 형식으로 저장 | 사용하는 형식에 따라 다름 |
| Protocol Buffers | 필드 번호 기반 직렬화 | 스키마 진화 지원 |
| SQLite | 관계형 데이터베이스 | 스키마 마이그레이션 필요 |
Protocol Buffers(Protobuf)
Protobuf는 필드마다 번호를 지정하여 데이터를 직렬화하는 방식이다.
기존 필드 번호를 변경하거나 재사용하지 않는 등 호환성 규칙을 준수하면 새로운 필드를 추가하거나 일부 필드를 제거하면서 기존 데이터와의 호환성을 유지할 수 있다.
하지만 PlayerName을 성과 이름으로 분리하는 것처럼 데이터의 의미 자체가 달라진다면 별도 변환이 필요하다.
결국 직렬화 포맷이 호환성을 일부 지원하더라도 모든 데이터 변경을 자동으로 처리할 수는 없다.
5. Unity 프로젝트에 적합한 설계
현재 구현한 V1부터 V4까지의 데이터 변화를 다시 살펴보면 다음과 같다.
| 변경사항 | 추천 처리 |
|---|---|
| V1 → V2: Gold 추가 | 기본값 처리 |
| V2 → V3: Experience 추가 | 기본값 처리 |
| V3 → V4: 이름 분리 | 명시적 마이그레이션 |
| V4 → V5: 새로운 옵션 추가 | 기본값 처리 |
| V5 → V6: 필드 자료형 변경 | 명시적 마이그레이션 |
모든 버전마다 클래스를 생성하고 데이터를 복사하는 것보다, 최신 SaveData 클래스 하나를 유지하면서 실제 변환이 필요한 경우에만 마이그레이션을 수행하는 방식을 고려할 수 있다.
이 구조는 다음과 같은 장점이 있다.
- 변경되지 않은 데이터는 일반 역직렬화로 처리한다.
- 새 필드는 적절한 기본값을 적용한다.
- 구조나 의미가 변경된 필드에만 별도 변환을 수행한다.
- 게임에서 사용하는 최신 데이터 클래스를 하나로 유지할 수 있다.
다만 주의할 점도 있다.
필드가 JSON에 존재하지 않는 것과 실제 값이 0인 것은 서로 다른 의미일 수 있다.
또한 중간 버전에서 여러 번 구조가 변경되었다면 해당 변경 이력을 정확히 반영해야 한다.
따라서 필드를 자동으로 읽을 수 있는지뿐 아니라, 기존 데이터의 의미가 그대로 유지되는지도 확인해야 한다.
6. 정리 및 향후 계획
이번 학습을 통해 마이그레이션은 단순히 버전 번호를 올리는 작업이 아니라, 기존 데이터를 새로운 데이터 구조에서 안전하게 사용할 수 있도록 만드는 과정이라는 것을 이해했다.
처음 구현한 VersionUp() 방식은 구조가 단순하고 버전별 변환 과정이 명확하다는 장점이 있다.
하지만 필드가 많아질수록 반복적인 데이터 복사와 관리 비용이 증가할 수 있다.
마이그레이션 클래스를 분리하면 책임을 명확하게 관리할 수 있지만, 데이터 복사 문제 자체가 해결되는 것은 아니다.
반면 버전 허용형 역직렬화나 JObject를 이용한 직접 변환은 변경되지 않은 데이터를 유지하면서 필요한 부분만 처리할 수 있다는 장점이 있다.
결국 중요한 것은 코드량을 줄이는 것뿐 아니라 데이터 보존, 호환성, 유지보수성을 함께 고려하여 적절한 설계를 선택하는 것이라고 생각한다.
처음 배우는 개념을 막 확장해서 찾아보면서 공부하다보니 머리에 잘 들어오지 않았다. 좀 더 자주 써볼 기회가 되면 좋겠고 앞으로 프로젝트에서도 잘 적용할 수 있도록 노력해야할 것 같다.



