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

핵심 요약
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바이트 | 향후 포맷 마이그레이션 기준 |
| Salt | 16바이트 | PBKDF2 키 파생용 난수 |
| IV | 16바이트 | AES-CBC 초기화 벡터 |
| Ciphertext | 가변 | 암호화된 JSON |
| HMAC | 32바이트 | 앞선 모든 바이트의 변조 검증값 |
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 검증 → 원자적 파일 교체다. 암호화와 무결성 검증을 함께 적용하고 로드 실패를 정상 흐름으로 처리하면 세이브 파일 손상과 단순 편집에 훨씬 견고하게 대응할 수 있다.



