저장/불러오기

크래시에도 안전한 원자적 쓰기와 버전 마이그레이션을 갖춘 슬롯 기반 JSON 저장.

크래시에도 안전한 원자적 쓰기와 버전 마이그레이션을 갖춘 슬롯 기반 JSON 저장 · 한 줄 오프 스위치: NullSaveService

무엇을 하는가

저장 서비스는 게임 상태를 디스크에 기록하고 나중에 복원합니다 — 저장 도중 게임이 크래시해도 손상 없이. "slot0"이나 "autosave" 같은 이름 있는 슬롯(슬롯은 그저 확장자 없는 파일 이름입니다)으로 Save<T>(slot, data)Load<T>(slot)을 호출하면 나머지는 서비스가 처리합니다: JSON 직렬화, 원자적 파일 쓰기(임시 파일에 쓴 뒤 교체), 그리고 데이터 포맷이 바뀔 때의 스키마 마이그레이션까지. 저장 파일이 손상되었거나 그 버전을 더는 마이그레이션할 수 없으면, 예외 대신 명시적인 결과 코드를 받습니다 — 복구 경험의 주도권은 여러분에게 있습니다.

빠른 예제

using CommonGameSystem.Core;
using UnityEngine;
using Logger = CommonGameSystem.Core.Logger;  // Unity has its own Logger type

[DefaultExecutionOrder(100)]
public class SaveLoadDemo : MonoBehaviour
{
    private ISaveService _save;

    void Awake()
    {
        _save = ServiceLocator.Resolve<ISaveService>();
    }

    public void SaveGame(string slot, PlayerData data)
    {
        var result = _save.Save(slot, data);
        if (result.Status != SaveStatus.Ok)
            Logger.Error(Logger.Categories.Save, $"Save failed: {result.Message}");
    }

    public void LoadGame(string slot)
    {
        var result = _save.Load<PlayerData>(slot);
        if (result.Status == SaveStatus.Ok)
            ApplyPlayerData(result.Value);
        else
            Logger.Warning(Logger.Categories.Save, $"Load failed: {result.Message}");
    }

    private void ApplyPlayerData(PlayerData data)
    {
        // Apply the loaded values to your game state here.
    }
}

[System.Serializable]
public class PlayerData
{
    public int level;
    public float health;
}

저장 파일은 기본적으로 {Application.persistentDataPath}/saves/{slot}.json에 놓입니다.

전체 API

쓰기

  • SaveResult Save<T>(string slot, T data) where T : classdata를 직렬화해 이름 있는 슬롯에 원자적으로 씁니다. 성공 시 SaveStatus.Ok를, 디스크 오류(디스크 가득 참, 권한) 시 IOFailure를 반환합니다 — 그 경우 기존 파일은 전혀 건드리지 않습니다. 잘못된 슬롯 이름에는 ArgumentNullException / ArgumentException을 던집니다 — 그것은 디스크 상황이 아니라 호출 코드의 버그입니다.

  • SaveResult Delete(string slot) — 슬롯 파일을 삭제합니다. 성공 시 Ok, 슬롯(또는 saves 폴더 자체)이 없으면 NotFound — 두 번 호출해도 안전하고 예외 없음 — 파일 시스템이 삭제를 거부하면 IOFailure를 반환합니다. 동반되는 .bak 백업 파일은 의도적으로 남겨 둡니다; 그 수명은 해당 슬롯에 대한 다음 Save가 관리합니다.

  • void RegisterMigration(int fromVersion, ISaveMigration migration) — 버전 v에서 v + 1로 가는 스키마 마이그레이션 한 단계를 순수 JSON 텍스트 변환으로 등록합니다. fromVersion은 1 이상이어야 하고 마이그레이션은 null이면 안 됩니다. 마이그레이션은 시작 시, 첫 Load 전에 한 번 등록하십시오. 두 마이그레이션이 같은 fromVersion을 공유하면 마지막에 등록된 쪽이 이깁니다.

읽기

  • SaveLoadResult<T> Load<T>(string slot) where T : class — 슬롯을 읽어 역직렬화합니다. 성공 시 값과 함께 Ok를 반환합니다. 슬롯이 없으면 NotFound, 파일을 파싱할 수 없으면(또는 마이그레이션 단계가 예외를 던졌으면) Corrupt, 파일을 현재 버전으로 끌어올릴 수 없으면 VersionUnsupported를 반환합니다. 디스크나 데이터 상황으로는 결코 예외를 던지지 않습니다 — 잘못된 슬롯 이름에만 던집니다.

  • bool Exists(string slot) — 슬롯 파일이 디스크에 존재하면 true를 반환합니다. 파일이 읽을 수 있는지, 형식이 올바른지는 확인하지 않습니다 — 그것은 Load로 확인하십시오.

  • IReadOnlyList<string> ListSlots() — 모든 슬롯 이름(확장자 없는 파일 이름)을 알파벳순으로 나열합니다. 저장이 아직 없으면 빈 리스트를 반환하며, 부작용으로 saves 폴더를 만드는 일은 결코 없습니다.

프로퍼티

  • int CurrentSchemaVersion { get; } — 모든 새 저장에 찍히는 스키마 버전. 이미 이 버전인 파일은 마이그레이션 없이 로드됩니다. 기본값 1; 데이터 포맷이 바뀌면 SaveServiceOptions를 통해 올리십시오.

결과 타입

두 결과 타입 모두 public 읽기 전용 필드를 가진 가벼운 struct입니다:

  • SaveResultStatus(SaveStatus)와 Message(진단 문자열, Ok이면 null; 플레이어용이 아니라 로그용).
  • SaveLoadResult<T>Status, Value(Status == Ok일 때만 null 아님), 그리고 Message.

SaveStatus 값:

상태의미
Ok작업이 성공적으로 완료되었습니다.
NotFound슬롯 파일이 존재하지 않습니다. LoadDelete가 반환합니다.
Corrupt파일은 있지만 파싱하거나 역직렬화할 수 없었습니다 — 잘못된 형식의 JSON, 누락된 필드, 타입 불일치, 또는 예외를 던진 마이그레이션 단계. 파일과 그 .bak은 바이트 하나 바뀌지 않고 남으므로 수작업으로 여전히 복구할 수 있습니다.
VersionUnsupported저장된 버전이 CurrentSchemaVersion보다 최신이거나(저장은 결코 다운그레이드하지 않습니다), 마이그레이션 체인에 공백이 있어 파일을 끌어올릴 수 없습니다. 파일은 그대로 보존됩니다.
IOFailure쓰기 또는 삭제 중 파일 시스템 오류가 발생했습니다. 대상 파일과 그 .bak은 그대로이며, 기저 예외는 던져지는 대신 로그에 남습니다.

스키마 마이그레이션

저장 데이터 포맷을 바꿀 때는 CurrentSchemaVersion을 올리고 버전 단계마다 ISaveMigration을 하나씩 등록하십시오. 각 단계는 원시 페이로드 JSON 텍스트를 버전 v에서 v + 1로 변환합니다 — 파일 접근도, 부작용도 없이 그저 string이 들어가고 string이 나옵니다. Load 시 서비스는 데이터가 현재 버전에 도달할 때까지 단계를 순서대로 적용한 뒤 역직렬화합니다.

using CommonGameSystem.Core;

// v1 stored "oldField"; v2 renamed it to "newField".
public sealed class V1ToV2Migration : ISaveMigration
{
    public string Migrate(string payloadJson)
        => payloadJson.Replace("\"oldField\"", "\"newField\"");
}
// Once at startup, before the first Load:
_save.RegisterMigration(1, new V1ToV2Migration());

마이그레이션 단계가 예외를 던지면 LoadCorrupt를 반환하고 디스크의 파일은 그대로 남습니다. 하향 마이그레이션은 지원하지 않습니다: 더 새로운 버전의 게임이 쓴 파일은 VersionUnsupported를 반환합니다.

커스텀 위치, 확장자, 시리얼라이저

기본 서비스는 부트스트랩이 표준 옵션으로 등록합니다. 폴더 이름, 파일 확장자, 백업 동작, 스키마 버전을 바꾸려면 인스턴스를 직접 생성해 등록을 교체하십시오:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)]
public class CustomSaveInstaller : MonoBehaviour
{
    void Awake()
    {
        var options = new SaveServiceOptions(
            saveDirectoryName: "mygame_saves",   // folder inside persistentDataPath (default "saves")
            fileExtension: ".sav",               // default ".json"
            backupExtension: ".sav.bak",         // default ".bak"; must differ from fileExtension
            currentSchemaVersion: 2,             // default 1; must be >= 1
            createBackup: true);                 // default true — keep a backup of the prior save

        ServiceLocator.Replace<ISaveService>(
            new SaveService(new JsonUtilitySaveSerializer(), options));
    }
}

생성자는 모든 인자를 검증하며 잘못된 값(경로 구분자가 든 디렉터리 이름, 점으로 시작하지 않는 확장자, 파일 확장자와 백업 확장자가 같은 경우, 1 미만의 버전)에는 ArgumentException을 던집니다.

Newtonsoft JSON, System.Text.Json, 또는 암호화를 쓰려면 ISaveSerializer를 구현해 JsonUtilitySaveSerializer 자리에 SaveService 생성자로 넘기십시오.

끄는 방법

ServiceLocator.Replace<ISaveService>(new NullSaveService());

이 한 줄이 호출자 코드를 전혀 건드리지 않고 영속화를 끕니다. Save는 (아무것도 하지 않고) Ok를, LoadNotFound를, Existsfalse를, ListSlots()는 빈 리스트를 반환합니다. 디스크 접근 0, 할당 0. 개발 중이거나 헤드리스 서버 빌드에 유용합니다. 설정 서비스가 기본적으로 저장 서비스를 통해 설정을 라우팅한다는 점에 유의하십시오 — 그 라우팅은 시작 시 한 번 결정되므로, 세션 초반에 기록된 설정은 이후의 NullSaveService 교체에 영향받지 않습니다.

동작 및 엣지 케이스

  • 메인 스레드 전용. 모든 메서드는 Unity 메인 스레드에서 실행되는지 확인합니다(검사는 Release 빌드에서 제거됩니다). 다른 스레드에서 호출하면 InvalidOperationException이 발생합니다.

  • 잘못된 슬롯 이름은 즉시 예외를 던집니다. 슬롯은 순수한 파일 이름 스템이어야 합니다: null 금지, 빈 문자열이나 공백 금지, 경로 구분자 금지, .. 금지, : 금지, 그리고 파일 시스템이 파일 이름에 금지하는 문자도 금지입니다. 위반은 디스크 접근 전에 ArgumentNullException이나 ArgumentException을 던지며 — 결과 코드로 감싸지지 않습니다. 실제 디스크 상황(파일 없음, 손상, 버전 공백)은 대신 결과 코드로 돌아옵니다.

  • 원자적 쓰기. 서비스는 먼저 .tmp 파일에 쓴 뒤 File.Replace로 교체합니다. 교체가 중단되어도(예: 정전) 원래 슬롯은 온전하게 남습니다. 다음 Save가 남은 .tmp 파일을 그냥 덮어씁니다. 이전 저장의 .bak 백업도 자동으로 생깁니다; 디스크 부담을 줄이고 싶다면 SaveServiceOptions.CreateBackup으로 끄십시오(스키마 버전을 올리는 중에는 권장하지 않습니다 — 실패한 마이그레이션 쓰기를 복구할 .bak이 없게 됩니다).

  • 실패는 결코 데이터를 파괴하지 않습니다. Ok가 아닌 모든 결과 — Corrupt, VersionUnsupported, IOFailure — 는 슬롯 파일과 그 .bak을 바이트 하나 바뀌지 않은 채 남깁니다. 서비스는 읽지 못한 파일을 결코 "정리"하지 않습니다.

  • IL2CPP / link.xml. 서비스 자체의 타입은 이미 IL2CPP 코드 스트리핑에서 보호됩니다. 여러분의 저장 데이터 클래스(위의 PlayerData 같은)는 **여러분 프로젝트의 link.xml**에서 보존해야 하며, 그러지 않으면 스트리핑된 빌드에서 역직렬화에 실패합니다.

  • 직렬화 한계. 기본 시리얼라이저는 Unity의 JsonUtility를 사용합니다: public 필드와 [SerializeField] 필드만 지원하며, 자동 프로퍼티 금지, UnityEngine.Object 참조 금지, 참조 순환 금지입니다. 필드가 소리 없이 저장에 실패한다면 대개 자동 프로퍼티가 범인입니다 — public 필드로 바꾸십시오.

관련 페이지