Unity SaveData JSON 직렬화와 AES-256 암호화 세이브 시스템 구현

Unity C#에서 SaveData를 JSON으로 직렬화하고 AES-256-CBC와 HMAC-SHA256으로 암호화·무결성 검증하는 세이브 시스템을 구현한다. 키 파생, 원자적 파일 저장, 변조 대응과 클라이언트 보안 한계까지 정리한다.

✓ 복사 완료!
챕터데이터 저장 & 직렬화
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
Unity SaveData JSON 직렬화와 AES-256 암호화 세이브 시스템 구현

핵심 요약

Unity의 세이브 데이터는 JsonUtility.ToJson()으로 문자열화한 뒤 AES-256으로 암호화하고 별도 HMAC-SHA256 태그로 무결성을 확인하면 된다. AES-CBC만 사용하면 암호문 변조를 탐지할 수 없으므로 Encrypt-then-MAC 순서로 AES 키와 HMAC 키를 분리해 사용해야 한다.

다만 클라이언트 게임에 포함된 비밀키는 분석으로 추출될 수 있다. 이 방식은 일반적인 세이브 파일 열람·수정의 비용을 높이는 수단이며 경쟁·재화 데이터의 최종 권한은 서버에 두어야 한다.

SaveData JSON 직렬화와 AES-256 암호화는 왜 함께 필요한가?

JSON은 디버깅과 스키마 변경에 유리하지만 파일을 열면 플레이 시간, 재화, 퀘스트 진행도처럼 게임 상태가 그대로 노출된다. Base64는 인코딩일 뿐 암호화가 아니므로 값 수정 방지 수단으로 사용할 수 없다.

AES-256-CBC는 JSON 원문을 읽기 어렵게 만들고 HMAC-SHA256은 파일이 바뀌었는지 검증한다. 이 글의 파일 구성은 다음과 같다.

구역크기용도
매직 값4바이트세이브 파일 형식 식별 (SAV1)
버전1바이트향후 포맷 마이그레이션 기준
Salt16바이트PBKDF2 키 파생용 난수
IV16바이트AES-CBC 초기화 벡터
Ciphertext가변암호화된 JSON
HMAC32바이트앞선 모든 바이트의 변조 검증값
flowchart LR
    A[SaveData] --> B[JsonUtility.ToJson]
    B --> C[UTF-8 JSON]
    C --> D[AES-256-CBC 암호화]
    D --> E[HMAC-SHA256 추가]
    E --> F[원자적으로 파일 교체]
    F --> G[Load]
    G --> H{HMAC 검증}
    H -->|성공| I[AES 복호화]
    H -->|실패| J[세이브 폐기 또는 백업 복구]

Unity에서 암호화 세이브 시스템을 어떻게 구현할까?

1. 직렬화할 SaveData를 단순한 데이터 컨테이너로 만든다

JsonUtility는 Unity 직렬화 규칙을 따르므로 필드는 public으로 두고 대상 타입에 [Serializable]을 붙인다. 다형성, Dictionary<TKey, TValue>, 복잡한 프로퍼티 기반 모델이 필요하다면 별도 JSON 라이브러리를 검토하되 일반적인 세이브 데이터는 단순한 DTO가 가장 안전하다.

using System;

[Serializable]
public class SaveData
{
    public int formatVersion = 1;
    public int level;
    public int gold;
    public float playTimeSeconds;
    public string lastScene;
}

2. AES 키와 HMAC 키를 분리해 파생한다

비밀번호 또는 앱별 시크릿을 원문 키로 바로 쓰지 않는다. 파일마다 새 Salt를 만들고 PBKDF2-HMAC-SHA256으로 64바이트를 파생한 뒤 앞 32바이트는 AES-256 키로 뒤 32바이트는 HMAC 키로 나눈다.

using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using UnityEngine;

public static class EncryptedSave
{
    private static readonly byte[] Magic = Encoding.ASCII.GetBytes("SAV1");
    private const byte FormatVersion = 1;
    private const int SaltSize = 16;
    private const int IvSize = 16;
    private const int MacSize = 32;
    private const int Iterations = 100_000;

    // 앱마다 고유한 값을 빌드 파이프라인 또는 보안 저장소에서 주입한다.
    // 클라이언트에 있는 값만으로는 강한 치트 방지가 불가능하다.
    private const string Secret = "replace-with-build-time-secret";

    public static void Save(SaveData data, string fileName = "save.dat")
    {
        string json = JsonUtility.ToJson(data);
        byte[] salt = RandomNumberGenerator.GetBytes(SaltSize);
        byte[] iv = RandomNumberGenerator.GetBytes(IvSize);
        DeriveKeys(salt, out byte[] aesKey, out byte[] macKey);

        byte[] cipherText;
        using (Aes aes = Aes.Create())
        {
            aes.KeySize = 256;
            aes.Key = aesKey;
            aes.IV = iv;
            aes.Mode = CipherMode.CBC;
            aes.Padding = PaddingMode.PKCS7;

            using ICryptoTransform encryptor = aes.CreateEncryptor();
            byte[] plainText = Encoding.UTF8.GetBytes(json);
            cipherText = encryptor.TransformFinalBlock(plainText, 0, plainText.Length);
        }

        byte[] payload = Combine(Magic, new[] { FormatVersion }, salt, iv, cipherText);
        byte[] mac;
        using (var hmac = new HMACSHA256(macKey))
            mac = hmac.ComputeHash(payload);

        WriteAtomically(Path.Combine(Application.persistentDataPath, fileName), Combine(payload, mac));
    }

    public static bool TryLoad(out SaveData data, string fileName = "save.dat")
    {
        data = null;
        string path = Path.Combine(Application.persistentDataPath, fileName);
        if (!File.Exists(path)) return false;

        try
        {
            byte[] bytes = File.ReadAllBytes(path);
            if (bytes.Length <= Magic.Length + 1 + SaltSize + IvSize + MacSize)
                return false;

            int payloadLength = bytes.Length - MacSize;
            byte[] payload = new byte[payloadLength];
            byte[] storedMac = new byte[MacSize];
            Buffer.BlockCopy(bytes, 0, payload, 0, payloadLength);
            Buffer.BlockCopy(bytes, payloadLength, storedMac, 0, MacSize);

            if (!HasValidHeader(payload)) return false;

            byte[] salt = Slice(payload, Magic.Length + 1, SaltSize);
            DeriveKeys(salt, out byte[] aesKey, out byte[] macKey);
            using (var hmac = new HMACSHA256(macKey))
            {
                if (!CryptographicOperations.FixedTimeEquals(storedMac, hmac.ComputeHash(payload)))
                    return false;
            }

            byte[] iv = Slice(payload, Magic.Length + 1 + SaltSize, IvSize);
            int cipherOffset = Magic.Length + 1 + SaltSize + IvSize;
            byte[] cipherText = Slice(payload, cipherOffset, payload.Length - cipherOffset);

            using Aes aes = Aes.Create();
            aes.KeySize = 256;
            aes.Key = aesKey;
            aes.IV = iv;
            aes.Mode = CipherMode.CBC;
            aes.Padding = PaddingMode.PKCS7;
            using ICryptoTransform decryptor = aes.CreateDecryptor();
            byte[] plainText = decryptor.TransformFinalBlock(cipherText, 0, cipherText.Length);
            data = JsonUtility.FromJson<SaveData>(Encoding.UTF8.GetString(plainText));
            return data != null;
        }
        catch (CryptographicException)
        {
            return false;
        }
        catch (IOException)
        {
            return false;
        }
    }

    private static void DeriveKeys(byte[] salt, out byte[] aesKey, out byte[] macKey)
    {
        using var kdf = new Rfc2898DeriveBytes(Secret, salt, Iterations, HashAlgorithmName.SHA256);
        byte[] keyMaterial = kdf.GetBytes(64);
        aesKey = Slice(keyMaterial, 0, 32);
        macKey = Slice(keyMaterial, 32, 32);
    }

    private static bool HasValidHeader(byte[] payload)
    {
        for (int i = 0; i < Magic.Length; i++)
            if (payload[i] != Magic[i]) return false;
        return payload[Magic.Length] == FormatVersion;
    }

    private static byte[] Slice(byte[] source, int offset, int length)
    {
        byte[] result = new byte[length];
        Buffer.BlockCopy(source, offset, result, 0, length);
        return result;
    }

    private static byte[] Combine(params byte[][] arrays)
    {
        int length = 0;
        foreach (byte[] array in arrays) length += array.Length;
        byte[] result = new byte[length];
        int offset = 0;
        foreach (byte[] array in arrays)
        {
            Buffer.BlockCopy(array, 0, result, offset, array.Length);
            offset += array.Length;
        }
        return result;
    }

    private static void WriteAtomically(string path, byte[] bytes)
    {
        string tempPath = path + ".tmp";
        File.WriteAllBytes(tempPath, bytes);
        if (File.Exists(path))
            File.Replace(tempPath, path, null);
        else
            File.Move(tempPath, path);
    }
}

3. 저장과 로드 실패를 게임 흐름에서 처리한다

로드 실패는 앱 오류가 아니라 새 게임 시작, 이전 버전 파일, 파일 손상, 변조 시도 모두에서 발생할 수 있는 정상 분기다. 따라서 TryLoad()의 반환값으로 기본 데이터를 생성한다.

SaveData current;
if (!EncryptedSave.TryLoad(out current))
{
    current = new SaveData
    {
        level = 1,
        gold = 0,
        lastScene = "Title"
    };
}

EncryptedSave.Save(current);

AES-CBC와 AES-GCM 중 무엇을 선택해야 할까?

방식기밀성변조 검증Unity 적용 시 주의점
AES-256-CBC 단독가능불가사용하지 않는다
AES-256-CBC + HMAC-SHA256가능가능폭넓은 .NET 호환성이 필요할 때 적합
AES-256-GCM가능가능AEAD 방식이지만 대상 Unity 런타임·플랫폼 지원을 먼저 확인

AES-GCM은 인증 암호화(AEAD)라서 별도 HMAC이 필요 없다는 장점이 있다. 그러나 여러 Unity 버전과 모바일 타깃을 함께 지원해야 한다면 AesGcm API 지원 여부를 실제 빌드 환경에서 확인해야 한다. 호환성 우선 프로젝트에서는 위 코드처럼 CBC와 HMAC을 조합하는 편이 예측 가능하다.

세이브 데이터 보안에서 자주 놓치는 점

  • Salt와 IV는 비밀값이 아니다. 파일에 평문으로 저장해도 되며 매 저장마다 새로 생성해야 한다.
  • HMAC은 복호화보다 먼저 검증한다. 잘못된 암호문을 먼저 복호화하면 예외 처리와 공격 표면이 불필요하게 늘어난다.
  • File.WriteAllText()로 곧바로 대상 파일을 덮어쓰면 저장 도중 종료될 때 파일이 깨질 수 있다. 임시 파일을 만든 뒤 교체한다.
  • formatVersion은 JSON 내부에만 두지 말고 파일 헤더에도 둔다. 구버전 암호화 포맷을 빠르게 구분할 수 있다.
  • 로컬 재화 값만으로 랭킹이나 구매 권한을 결정하면 안 된다. 서버 검증이 필요한 데이터는 서버가 진실의 원천이어야 한다.

자주 묻는 질문 (FAQ)

AES-256이면 세이브 파일 조작을 완전히 막을 수 있나?

아니다. 클라이언트에 배포한 키와 로직은 결국 분석 대상이 된다. AES는 평문 노출과 단순 변조를 어렵게 할 뿐이며 경제·경쟁 데이터는 서버에서 검증해야 한다.

PlayerPrefs에 암호화된 JSON을 저장해도 되나?

작은 설정값에는 가능하지만 큰 세이브 데이터나 슬롯 관리에는 파일 저장이 더 적합하다. Application.persistentDataPath 아래에 파일을 두면 백업, 교체, 용량 관리가 명확하다.

PBKDF2 반복 횟수는 100,000으로 고정해야 하나?

아니다. 기기 성능과 저장 빈도를 측정해 조정해야 한다. 목표 플랫폼에서 저장·로드 지연이 허용 범위인지 확인하고 값 변경 시에는 파일 포맷 버전과 마이그레이션 전략도 함께 관리한다.

정리

안전한 Unity 로컬 세이브의 최소 구성은 JSON 직렬화 → AES-256-CBC 암호화 → HMAC-SHA256 검증 → 원자적 파일 교체다. 암호화와 무결성 검증을 함께 적용하고 로드 실패를 정상 흐름으로 처리하면 세이브 파일 손상과 단순 편집에 훨씬 견고하게 대응할 수 있다.