게임 세이브 파일 버전 업·다운 호환성 검증: 퍼징 기반 마이그레이션 QA 파이프라인

게임 세이브 파일 버전 업·다운 호환성 검증: 퍼징 기반 마이그레이션 QA 파이프라인

게임 패치 뒤 세이브 파일이 깨지거나 롤백에서 로드되지 않는 문제를 줄이는 방법을 정리합니다. 스키마 마이그레이션, 퍼징, 불변식 검사, 크래시 최소화까지 실무 QA 파이프라인으로 설명합니다.

TL;DR

세이브 파일 호환성은 단순히 버전 번호를 비교하는 문제가 아니라 schemaVersion 사이의 변환 규칙과 데이터 불변식을 검증하는 문제입니다. 각 버전의 시드 파일을 퍼징한 뒤 현재 버전으로 업그레이드하고 지원하는 구버전으로 다시 다운그레이드해 로드·저장·재로드 결과를 자동 비교하면 패치와 롤백에서 발생하는 세이브 손상을 조기에 찾을 수 있습니다.

핵심은 decode → validate → migrate → invariant check → downgrade export → reload를 하나의 반복 가능한 테스트 흐름으로 만들고 실패 입력을 최소화한 재현 파일로 CI 아티팩트에 보존하는 것입니다.

게임 세이브 파일 버전 호환성은 왜 자주 깨지는가?

세이브 파일에는 플레이어 위치와 인벤토리뿐 아니라 퀘스트 상태, 월드 이벤트, DLC 플래그, 경제 데이터, 튜토리얼 진행도 같은 장기 상태가 함께 들어갑니다. 패치에서 클래스 필드를 변경하거나 enum 값을 추가하는 순간 저장 포맷도 사실상 API가 됩니다.

특히 다음 네 가지 변경이 위험합니다.

변경 유형업그레이드 위험다운그레이드 위험
필드 추가구버전 파일에 값이 없음신버전 필드가 사라짐
필드 삭제·이름 변경기존 값 매핑 누락구버전 필드로 되돌릴 수 없음
enum 값 변경알 수 없는 값 역직렬화 실패새 상태를 표현하지 못함
자료형 변경intlong, 배열과 객체 간 변환 실패정밀도 손실 또는 데이터 절삭

업그레이드 호환성은 v1 → v2 → v3처럼 오래된 파일을 최신 스키마로 읽는 능력입니다. 다운그레이드 호환성은 최신 파일을 이전 클라이언트가 읽을 수 있는 포맷으로 내보내는 능력입니다. 둘은 대칭이 아닙니다.

새 기능의 상태를 구버전이 표현할 수 없다면 다운그레이드는 무손실이 아니라 명시적인 손실 변환이 됩니다.

세이브 파일 버전과 빌드 버전은 어떻게 분리해야 하는가?

buildVersionschemaVersion을 하나의 값으로 관리하지 않는 것이 좋습니다.

  • buildVersion: 실행 파일의 릴리스 버전입니다. 크래시 분석과 고객 지원에 사용합니다.
  • schemaVersion: 세이브 데이터 구조의 버전입니다. 마이그레이션 체인을 선택하는 데 사용합니다.
  • contentVersion: 아이템 테이블이나 퀘스트 정의 같은 외부 콘텐츠의 버전입니다.
  • featureFlags: 특정 기능이 활성화되었는지 기록합니다.

예를 들어 Unity 2022.3 LTS와 C# 10 기반 프로젝트에서 다음처럼 헤더를 고정할 수 있습니다.

{
  "format": "game-save",
  "schemaVersion": 5,
  "buildVersion": "1.8.0",
  "contentVersion": 42,
  "profileId": "local-001",
  "payload": {
    "player": {
      "level": 17,
      "experience": 24310,
      "gold": 850
    },
    "inventory": [],
    "quests": [],
    "world": {
      "flags": {}
    }
  }
}

파서가 파일 전체를 무조건 객체로 간주하지 않도록 formatschemaVersion을 먼저 검사합니다. 헤더가 유효하지 않으면 마이그레이션을 시도하지 않고 손상 파일로 분류해야 합니다.

퍼징 기반 세이브 마이그레이션 QA 파이프라인은 어떻게 구성하는가?

퍼징은 무작위 바이트만 던지는 테스트가 아닙니다. 실제 플레이에서 생성된 정상 세이브를 시드로 삼고 필드 누락, 경계값, 타입 혼합, 중첩 깊이, 배열 크기, 잘못된 enum을 체계적으로 변형하는 방식이 게임 세이브 검증에 더 적합합니다.

flowchart LR
    A[정상 시드 파일] --> B[생성 및 변형]
    B --> C[디코드와 기본 검증]
    C --> D[현재 스키마로 마이그레이션]
    D --> E[불변식 검사]
    E --> F[지원 구버전으로 내보내기]
    F --> G[구버전 재로드와 비교]
    C --> H[크래시·타임아웃 수집]
    D --> H
    E --> H
    G --> H
    H --> I[입력 최소화 및 리포트]

파이프라인의 각 단계는 테스트 대상과 실패 의미를 분리해야 합니다. JSON 파서가 거부한 파일과 마이그레이션 로직이 크래시를 낸 파일은 같은 버그가 아닙니다. 전자는 입력 정책 문제일 수 있지만 후자는 반드시 재현 가능한 결함으로 관리해야 합니다.

세이브 파일을 버전별로 변환하고 검증하는 퍼징 파이프라인 개념도

1단계: 정상 시드 코퍼스를 버전별로 준비한다

시드 코퍼스에는 최소한 다음 상태를 포함합니다.

  • 새 게임 직후의 빈 세이브
  • 최대 레벨에 가까운 진행 상태
  • 인벤토리가 가득 찬 상태
  • 퀘스트가 수락 직전, 진행 중, 완료 직후인 상태
  • 월드 이벤트가 연쇄적으로 적용된 상태
  • DLC와 시즌 콘텐츠가 혼합된 상태
  • 사망, 부활, 지역 이동, 저장 중 종료 직후의 상태

파일마다 다음 메타데이터를 함께 저장하면 실패 분류가 쉬워집니다.

메타데이터용도
schemaVersion시작 스키마 확인
seedId원본 시드 추적
mutationId변형 재현
rngSeed난수 시퀀스 재현
platformWindows, PlayStation, Xbox 등 환경 구분
compression압축·암호화 경로 구분

2단계: 구조를 보존하는 변형기를 만든다

변형기는 완전히 무작위인 값보다 오류가 발생하기 쉬운 경계를 집중적으로 생성해야 합니다.

변형 전략예시
필드 삭제gold, quests, world 제거
null 주입필수 객체나 배열을 null로 변경
경계값-1, 0, 1, Int32.MaxValue
타입 변형숫자를 문자열로 배열을 객체로 변경
enum 오염정의되지 않은 정수 값 입력
배열 확장아이템 수를 0개, 1개, 최대치, 초과치로 설정
중첩 확장비정상적으로 깊은 객체를 생성
문자열 변형빈 문자열, 매우 긴 문자열, 유니코드 입력

난수 생성기는 반드시 rngSeed를 기록해야 합니다. 동일한 시드와 변형 순서를 사용하면 CI에서 발견된 실패를 로컬에서 그대로 재현할 수 있습니다.

3단계: 마이그레이션 체인을 한 단계씩 검증한다

모든 버전을 최신 버전으로 직접 변환하는 방식은 초기에는 빠르지만 버전이 늘수록 분기 수가 급격히 증가합니다. 일반적으로는 인접 버전 마이그레이션을 연결한 선형 체인이 관리하기 쉽습니다.

flowchart LR
    v1 --> v2 --> v3 --> v4 --> v5

각 마이그레이션은 다음 조건을 만족해야 합니다.

  1. 입력 버전과 출력 버전이 명확해야 합니다.
  2. 같은 입력에 대해 같은 출력이 나와야 합니다.
  3. 알 수 없는 선택적 필드는 보존하거나 정책에 따라 기록해야 합니다.
  4. 필수 값이 없을 때 기본값을 넣는 규칙이 문서화되어야 합니다.
  5. 한 단계가 실패하면 파일을 조용히 덮어쓰지 않아야 합니다.

C#으로 세이브 마이그레이션을 어떻게 구현할까?

아래 예시는 Newtonsoft.Json의 JObject를 사용하는 단순한 구조입니다. 실제 프로젝트에서는 파일 읽기와 마이그레이션을 분리해 테스트 가능한 순수 함수로 두는 편이 안전합니다.

using Newtonsoft.Json.Linq;

public interface ISaveMigration
{
    int FromVersion { get; }
    int ToVersion { get; }
    JObject Apply(JObject root);
}

public sealed class V4ToV5Migration : ISaveMigration
{
    public int FromVersion => 4;
    public int ToVersion => 5;

    public JObject Apply(JObject root)
    {
        var payload = (JObject?)root["payload"] ?? new JObject();
        var world = (JObject?)payload["world"] ?? new JObject();

        if (world["flags"] == null)
        {
            world["flags"] = new JObject();
        }

        payload["world"] = world;
        root["payload"] = payload;
        root["schemaVersion"] = ToVersion;
        return root;
    }
}

public static class SaveMigrator
{
    private const int CurrentSchema = 5;

    public static JObject Migrate(JObject input, IReadOnlyList<ISaveMigration> migrations)
    {
        var root = (JObject)input.DeepClone();
        var version = root.Value<int?>("schemaVersion")
            ?? throw new InvalidDataException("schemaVersion is missing");

        while (version < CurrentSchema)
        {
            var step = migrations.SingleOrDefault(m => m.FromVersion == version);
            if (step == null)
            {
                throw new InvalidDataException($"No migration from schema {version}");
            }

            root = step.Apply(root);
            version = root.Value<int?>("schemaVersion")
                ?? throw new InvalidDataException("Migration removed schemaVersion");
        }

        if (version != CurrentSchema)
        {
            throw new InvalidDataException($"Unsupported schema {version}");
        }

        return root;
    }
}

DeepClone()을 사용하는 이유는 테스트 입력을 보존하기 위해서입니다. 마이그레이션 함수가 원본 객체를 직접 수정하면 동일한 시드로 여러 전략을 비교하기 어렵고 실패 재현도 불안정해집니다.

세이브 파일의 데이터 불변식은 어떻게 검사하는가?

파싱 성공은 데이터가 정상이라는 뜻이 아닙니다. 예를 들어 gold가 음수인 파일은 JSON으로는 완벽하지만 게임 규칙상 유효하지 않을 수 있습니다.

불변식은 스키마 검증과 게임 규칙 검증으로 나누는 것이 좋습니다.

  • 스키마 불변식: 필수 필드 존재, 자료형 일치, 배열 최대 길이, enum 범위
  • 게임 불변식: 레벨과 경험치 관계, 퀘스트 상태 전이, 인벤토리 수량, 월드 플래그 조합
  • 보안 불변식: 파일 크기 제한, 문자열 길이, 중첩 깊이, 압축 해제 후 크기

예를 들어 경험치가 레벨 구간에 맞는지 검사하는 정책을 다음처럼 정의할 수 있습니다.

0experience<XP(level+1)0 \leq experience < XP(level + 1)

인벤토리 수량 검사는 다음과 같이 표현할 수 있습니다.

0quantityMaxStack(itemId)0 \leq quantity \leq MaxStack(itemId)

테스트에서 모든 불변식을 동시에 검사하면 실패 원인이 흐려질 수 있습니다. schema, economy, quest, security처럼 규칙 그룹별 오류 코드를 반환하고 첫 번째 오류와 전체 오류 목록을 모두 보존하는 방식이 유용합니다.

불변식 검사 예시

public static IReadOnlyList<string> ValidateInvariants(JObject save)
{
    var errors = new List<string>();
    var player = save["payload"]?["player"] as JObject;

    if (player == null)
    {
        errors.Add("schema.player.missing");
        return errors;
    }

    var level = player.Value<int?>("level");
    var experience = player.Value<long?>("experience");
    var gold = player.Value<long?>("gold");

    if (level is null || level < 1)
        errors.Add("player.level.invalid");

    if (experience is null || experience < 0)
        errors.Add("player.experience.invalid");

    if (gold is null || gold < 0)
        errors.Add("player.gold.invalid");

    return errors;
}

업그레이드와 다운그레이드 테스트를 어떻게 분리할까?

업그레이드 테스트는 모든 지원 구버전 파일이 최신 빌드에서 읽히는지 확인합니다. 다운그레이드 테스트는 최신 데이터가 구버전의 표현 범위 안에서 안전하게 축소되는지 확인합니다.

테스트 방향입력기대 결과
업그레이드v1, v2, v3, v4최신 v5로 변환 후 불변식 통과
동일 버전v5변환 없이 로드하거나 멱등 처리
다운그레이드v5지원 대상 버전으로 명시적 내보내기
왕복v4 → v5 → v4보존 대상 필드가 동일
비지원 버전v0, 미래 버전 v6안전한 거부와 사용자 메시지

다운그레이드는 항상 보존 정책을 함께 검사해야 합니다. 예를 들어 v5에서만 존재하는 seasonRankv4로 내보낼 때 다음 중 하나를 선택해야 합니다.

  • 해당 기능을 사용하지 않은 상태에서만 내보내기 허용
  • seasonRank를 별도 보조 파일로 보존
  • 구버전에서 표현 가능한 기본값으로 초기화
  • 데이터 손실을 사용자에게 알리고 내보내기 중단

조용히 데이터를 삭제하는 것은 호환성이 아니라 손상입니다. 테스트 결과에는 lossless, lossy-but-allowed, rejected를 구분해 기록해야 합니다.

세이브 파일 버전별 호환성 매트릭스와 왕복 테스트 결과를 보여주는 QA 대시보드 예시

퍼징 테스트 코드는 어떻게 작성할까?

NUnit을 사용하는 예시에서는 시드 파일을 불러온 뒤 변형하고 로드부터 왕복 검증까지 한 번에 실행합니다. 반복 횟수와 최대 실행 시간을 CI 환경에 맞춰 조정합니다.

[Test]
public void Fuzz_SaveMigration_PreservesInvariants()
{
    const int iterations = 10000;

    for (var i = 0; i < iterations; i++)
    {
        var rngSeed = 100000 + i;
        var seed = SeedCorpus.Select(rngSeed);
        var mutated = SaveMutator.Mutate(seed, rngSeed);

        try
        {
            var decoded = SaveCodec.Decode(mutated.Bytes);
            var migrated = SaveMigrator.Migrate(decoded, MigrationRegistry.All);
            var errors = ValidateInvariants(migrated);

            Assert.That(errors, Is.Empty,
                $"seed={mutated.SeedId}, mutation={mutated.MutationId}, rng={rngSeed}");

            var exported = SaveDowngrader.Export(migrated, mutated.TargetVersion);
            var reloaded = SaveCodec.Decode(exported.Bytes);
            Assert.That(reloaded.Value<int>("schemaVersion"),
                Is.EqualTo(mutated.TargetVersion));
        }
        catch (Exception exception)
        {
            FailureArtifacts.Write(mutated, exception, rngSeed);
            throw;
        }
    }
}

실패 파일은 원본 전체를 그대로 저장하는 것보다 다음 정보를 포함한 재현 패키지로 만드는 편이 좋습니다.

failure-2026-08-24-00017/
  input.json
  input.sha256
  seed-id.txt
  mutation.json
  rng-seed.txt
  target-version.txt
  exception.txt
  runner-log.txt

압축 해제 폭탄, 과도한 중첩, 수십만 개 배열처럼 파서 자원을 고갈시키는 입력도 별도의 보안 퍼징 범주로 관리합니다. 테스트 러너에는 파일 크기, CPU 시간, 메모리 사용량, 재귀 깊이 제한을 둬야 합니다.

CI와 라이브 운영에서 어떤 품질 게이트를 둘까?

모든 퍼징 결과를 단순히 성공·실패 두 값으로만 보면 운영 판단이 어렵습니다. 다음과 같은 게이트를 두면 빌드와 라이브 모니터링을 연결할 수 있습니다.

게이트검사 내용실패 시 조치
파싱 안전성예외, 프로세스 종료, 무한 루프빌드 차단
마이그레이션 결정성동일 입력의 해시 비교빌드 차단
불변식 통과율규칙 그룹별 오류율기준 초과 시 차단
왕복 보존율보존 대상 필드 비교손실 정책 검토
성능파일 크기별 로드·변환 시간회귀 티켓 생성
관측성오류 코드와 버전 로그 존재릴리스 전 수정

결정성 검사는 같은 입력을 두 번 실행해 정규화된 JSON 해시를 비교하는 방식으로 구현할 수 있습니다. 객체 키 순서나 공백 때문에 해시가 달라지지 않도록 정렬된 직렬화 또는 canonical JSON을 사용해야 합니다.

운영 환경에서는 다음 이벤트를 구조화 로그로 남깁니다.

  • 시작 스키마와 목표 스키마
  • 적용된 마이그레이션 목록
  • 파일 크기와 압축 여부
  • 실패 오류 코드
  • 플랫폼과 빌드 버전
  • 복구 백업 생성 여부

개인정보나 계정 토큰이 세이브에 포함될 수 있다면 원본 파일을 그대로 전송하지 않습니다. 해시, 필드 경로, 길이, 오류 코드만 수집하고 재현 파일은 접근 통제가 가능한 별도 저장소에 보관합니다.

단계별 도입 순서

1. 호환성 정책을 먼저 고정한다

지원하는 최소 스키마 버전과 최대 파일 크기, 다운그레이드 허용 범위, 손실 데이터 처리 방식을 문서화합니다. 정책이 없으면 테스트가 통과해도 어떤 파일을 지원해야 하는지 판단할 수 없습니다.

2. 마이그레이션과 불변식 검사를 순수 코드로 분리한다

파일 시스템, Unity 씬, 네트워크 서비스에 의존하지 않는 테스트 코드를 먼저 만듭니다. 이렇게 하면 에디터 테스트와 CI 테스트가 같은 변환 로직을 사용하게 됩니다.

3. 정상 시드와 경계값 변형을 추가한다

처음부터 모든 필드를 무작위화하지 말고 실제 기능 단위로 코퍼스를 확장합니다. 각 버그가 발견될 때마다 최소 재현 파일을 회귀 시드로 승격합니다.

4. 왕복 테스트와 다운그레이드 정책을 추가한다

현재 버전 로드만 통과시키면 롤백 가능성을 검증할 수 없습니다. 지원하는 구버전 클라이언트가 읽을 수 있는 출력이 필요한지 제품 정책에 맞춰 별도 테스트합니다.

5. 실패 입력 최소화와 아티팩트 보존을 자동화한다

수천 개의 변형 중 실제 원인에 필요한 필드만 남긴 최소 입력을 저장합니다. 최소화된 파일은 개발자가 로컬에서 빠르게 재현하고 회귀 테스트로 추가하기 쉽습니다.

자주 묻는 질문 (FAQ)

세이브 파일을 JSON으로 저장하면 마이그레이션이 쉬운가요?

필드 확인과 디버깅은 쉬워지지만 호환성이 자동으로 보장되지는 않습니다. 명시적인 schemaVersion, 기본값 정책, 불변식 검사가 여전히 필요합니다. 바이너리 포맷도 동일한 버전 계약과 마이그레이션 체인이 필요합니다.

모든 구버전과 최신 버전의 조합을 테스트해야 하나요?

지원 정책이 허용하는 최소 버전부터 현재 버전까지는 반드시 테스트해야 합니다. 조합 수가 커지면 인접 버전 마이그레이션과 대표 왕복 테스트를 중심으로 구성하고 릴리스 후보에서는 실제 클라이언트 빌드로 교차 로드 테스트를 추가합니다.

퍼징에서 파싱 오류가 나면 버그인가요?

정의에 따라 다릅니다. 손상되거나 악의적인 입력을 안전하게 거부한 것이라면 정상 동작일 수 있습니다. 다만 크래시, 무한 루프, 과도한 메모리 사용, 오류 없이 잘못된 데이터 생성은 버그로 분류해야 합니다.

정리

세이브 파일 마이그레이션 QA의 목표는 모든 파일을 무조건 읽는 것이 아니라 지원 범위 안의 파일을 예측 가능하고 안전하게 변환하는 것입니다.

schemaVersion을 빌드 버전과 분리하고 버전별 시드 코퍼스에 구조적 퍼징을 적용한 뒤 불변식과 왕복 결과를 검사하면 패치·롤백·라이브 운영에서 발생하는 호환성 문제를 자동화된 품질 게이트로 관리할 수 있습니다.

#세이브 파일#퍼징 테스트#마이그레이션#호환성 테스트#게임 QA#라이브 운영

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs