AI 보조 게임 개발

AI 에이전트에게 세이브 데이터 마이그레이션 맡기기: 버전 변환 코드와 회귀 테스트를 한 번에

kr-gamedev 2026. 6. 19. 09:03

라이브 서비스 게임을 운영하다 보면 세이브 데이터 구조는 한 번도 가만히 있지 않는다. 새 스탯이 붙고, 인벤토리 포맷이 바뀌고, 안 쓰는 필드가 빠진다. 문제는 업데이트 시점에 이미 수만 명의 플레이어가 구버전 포맷으로 저장된 세이브를 들고 있다는 점이다. 패치 한 번에 누군가의 진행도가 날아가면 그건 단순 버그가 아니라 사고다.

그래서 세이브 마이그레이션 코드를 짠다. v1을 v2로, v2를 v3로 옮기는 변환 코드다. 그런데 이 작업은 지독하게 지루하면서 동시에 실수 비용이 크다. 필드 하나 매핑을 빠뜨리면 골드가 0으로 초기화되고, 타입 변환을 잘못하면 파싱이 통째로 깨진다. 패턴은 뻔하고, 분량은 많고, 틀리면 유저 데이터가 증발한다. 딱 AI 코딩 에이전트에게 위임하기 좋은 형태다.

다만 핵심은 "알아서 변환해줘"가 아니다. 그렇게 던지면 그럴듯하지만 검증 안 된 코드가 나온다. 제대로 된 방법은 변환 코드와 회귀 테스트를 한 세트로 생성하게 만드는 것이다. 이번 글은 그 워크플로를 정리한다.

1. 모든 세이브에 버전 번호를 박는다

가장 먼저 할 일은 직렬화 데이터에 스키마 버전을 명시하는 것이다. 버전이 없으면 마이그레이션 자체가 성립하지 않는다. 어떤 포맷에서 어떤 포맷으로 옮기는지 알 수 없기 때문이다.

// 모든 세이브 루트는 SchemaVersion 으로 시작한다
public abstract record SaveBase
{
    public required int SchemaVersion { get; init; }
}

// v1: 초창기 포맷
public sealed record PlayerSaveV1 : SaveBase
{
    public required string PlayerId { get; init; }
    public int Gold { get; init; }
    public int Level { get; init; }
    public string[] Items { get; init; } = [];
}

// v2: Gold 를 통화 묶음으로 확장, Items 를 슬롯 구조로 변경
public sealed record PlayerSaveV2 : SaveBase
{
    public required string PlayerId { get; init; }
    public Dictionary<string, int> Currencies { get; init; } = [];
    public int Level { get; init; }
    public InventorySlot[] Inventory { get; init; } = [];
}

public readonly record struct InventorySlot(string ItemId, int Count);

여기서 record와 required, collection expression([]) 같은 C# 12 문법이 의도를 코드로 못 박아준다. 어떤 필드가 필수인지, 기본값이 무엇인지가 타입에 그대로 드러나기 때문에, 뒤에서 AI 에이전트가 이 차이를 읽고 변환 규칙을 추론하기 쉬워진다.

2. 마이그레이션을 체인으로 설계한다

v1에서 곧장 v5로 점프하는 변환 함수를 만들면, 버전이 늘어날수록 조합이 폭발한다. 대신 인접한 버전 사이만 변환하는 단계를 체인으로 잇는다. v1 세이브는 v1에서 v2, v2에서 v3 순으로 최신까지 흘러간다.

public interface ISaveMigration
{
    int FromVersion { get; }
    SaveBase Migrate(SaveBase input);
}

public sealed class MigrateV1ToV2 : ISaveMigration
{
    public int FromVersion => 1;

    public SaveBase Migrate(SaveBase input)
    {
        var v1 = (PlayerSaveV1)input;
        return new PlayerSaveV2
        {
            SchemaVersion = 2,
            PlayerId = v1.PlayerId,
            Level = v1.Level,
            // Gold 단일값을 통화 딕셔너리의 "gold" 키로 이전
            Currencies = new() { ["gold"] = v1.Gold },
            // 평면 문자열 배열을 슬롯 구조로, 수량 기본 1
            Inventory = [.. v1.Items.Select(id => new InventorySlot(id, 1))]
        };
    }
}

public sealed class SaveMigrator(IEnumerable<ISaveMigration> steps)
{
    readonly Dictionary<int, ISaveMigration> _byFrom =
        steps.ToDictionary(s => s.FromVersion);

    public SaveBase MigrateToLatest(SaveBase save, int latest)
    {
        var current = save;
        while (current.SchemaVersion < latest)
        {
            if (!_byFrom.TryGetValue(current.SchemaVersion, out var step))
                throw new InvalidOperationException(
                    $"v{current.SchemaVersion} 에서 올릴 마이그레이션 단계가 없음");
            current = step.Migrate(current);
        }
        return current;
    }
}

이 구조라면 새 버전이 생길 때 추가되는 건 단계 하나뿐이다. 바로 이 단계 클래스가 AI 에이전트에게 시킬 산출물의 단위가 된다.

3. 에이전트에게는 의도를 주고 코드와 테스트를 받는다

에이전트에게 변환을 시킬 때 가장 중요한 건 무엇이 어떻게 바뀌었는지를 자연어 의도로 명확히 주는 것이다. 두 record의 차이만 던지면 에이전트는 추측하기 시작하고, 추측은 데이터 손실로 이어진다. 그래서 다음처럼 규칙을 명시한다.

[작업] PlayerSaveV2 에서 PlayerSaveV3 로 가는 마이그레이션 단계와 회귀 테스트를 작성한다.
[변경 의도]
  - Currencies 딕셔너리에서 "gold" 키는 유지, "gem" 키가 없으면 0 으로 신설
  - Level 필드를 ProgressV3 { int Level, long Exp } 로 승격, Exp 기본 0
  - PlayerId 와 Inventory 는 그대로 보존 (절대 변형 금지)
[산출물]
  1) MigrateV2ToV3 : ISaveMigration 구현
  2) 보존 불변식을 검사하는 NUnit 테스트 (PlayerId, Inventory, gold 잔액 동일)

핵심은 마지막 줄이다. 보존해야 할 불변식(invariant)을 테스트로 같이 뽑게 만드는 것. 사람이 의심해야 할 지점을 미리 테스트로 고정해두면, 에이전트가 변환 코드를 잘못 짜도 그 테스트가 곧바로 빨갛게 터진다.

4. 골든 세이브로 회귀를 막는다

에이전트가 만든 테스트에 더해, 운영팀이 실제 프로덕션 세이브를 익명화한 골든 파일을 한 장 박아두면 회귀 방어가 단단해진다. 합성 데이터로는 안 잡히는 실제 엣지 케이스가 여기서 걸린다.

[Test]
public void Golden_V1_Save_Migrates_Without_Loss()
{
    var json = File.ReadAllText("TestData/golden_player_v1.json");
    var v1 = JsonSerializer.Deserialize<PlayerSaveV1>(json)!;

    var latest = (PlayerSaveV2)_migrator.MigrateToLatest(v1, latest: 2);

    Assert.Multiple(() =>
    {
        Assert.That(latest.PlayerId, Is.EqualTo(v1.PlayerId));
        Assert.That(latest.Currencies["gold"], Is.EqualTo(v1.Gold));
        Assert.That(latest.Inventory, Has.Length.EqualTo(v1.Items.Length));
    });
}

이 테스트를 CI에 물려두면, 누군가 나중에 마이그레이션 단계를 건드려 옛날 세이브를 깨는 순간 머지 전에 잡힌다. 에이전트가 생성한 코드든 사람이 손댄 코드든, 골든 세이브 앞에서는 평등하게 검증받는다.

마무리

세이브 마이그레이션은 반복적이고 실수 비용이 큰, 위임에 최적화된 작업이다. 그래서 AI 코딩 에이전트와 궁합이 좋다. 다만 그냥 던지지 말고 다음 세 가지를 지키면 안전하게 굴러간다.

첫째, 모든 세이브에 스키마 버전을 박고 인접 버전끼리만 잇는 마이그레이션 체인으로 설계한다. 둘째, 에이전트에게는 무엇이 어떻게 바뀌었는지 의도를 명시하고, 변환 코드와 보존 불변식 테스트를 한 세트로 받아낸다. 셋째, 실제 운영 세이브를 익명화한 골든 파일을 CI 회귀 테스트로 못 박아 누구의 변경이든 같은 기준으로 검증한다. 지루한 변환은 위임하되, 검증만큼은 자동화로 틀어쥐는 것이 핵심이다.