게임 데이터 저장(Save/Load)과 데이터 암호화 메커니즘

게임 데이터 저장(Save/Load)과 데이터 암호화 메커니즘

세이브 데이터의 구조를 설계하고 저장·복구 과정에서 오류, 조작, 버전 변경에 대응하는 방법을 정리한다. 암호화와 무결성 검증의 역할, 로컬 클라이언트 환경의 한계도 함께 살펴본다.

세이브 시스템은 무엇을 보장해야 할까

세이브 시스템의 목표는 단순히 객체를 파일에 기록하는 일이 아니다. 플레이어가 게임을 종료한 뒤에도 진행 상태를 정확히 복원하고 저장 도중 발생한 오류로 기존 데이터가 망가지지 않도록 하며 게임 업데이트 이후에도 가능한 한 이전 데이터를 읽을 수 있어야 한다.

보통 세이브 파일에는 다음처럼 다시 만들기 어려운 상태를 담는다.

  • 스테이지 진행도와 퀘스트 상태
  • 캐릭터 능력치, 장비, 인벤토리
  • 재화와 해금 항목
  • 월드에서 사라진 오브젝트와 선택 결과
  • 옵션과 키 설정

반대로 언제든 계산할 수 있는 임시 캐시나 연출용 상태까지 저장하면 파일 크기와 호환성 부담만 커진다. 저장할 데이터는 게임을 재개하는 데 필요한 최소 상태라는 기준으로 고르는 편이 좋다.

저장 데이터와 런타임 객체를 분리하기

Unity의 GameObject나 컴포넌트 인스턴스 자체를 그대로 저장하려 하면 씬 참조, 이벤트, 리소스 참조가 섞여 구조가 빠르게 복잡해진다. 런타임 객체와 별도로 직렬화 전용 데이터 모델을 두는 방식이 관리하기 쉽다.

[Serializable]
public sealed class SaveData
{
    public int Version = 1;
    public string PlayerId = "";
    public int CurrentStage;
    public int Gold;
    public List<ItemStackData> Inventory = new();
}

[Serializable]
public sealed class ItemStackData
{
    public string ItemId = "";
    public int Count;
}

이 구조에서는 아이템 자체를 저장하지 않고 안정적인 식별자인 ItemId와 수량만 저장한다. 불러올 때는 아이템 데이터베이스에서 ItemId로 정의를 찾아 런타임 객체를 다시 구성한다. 에셋 경로나 배열 인덱스처럼 변경되기 쉬운 값을 영구 식별자로 쓰지 않는 것이 중요하다.

세이브 전용 데이터 모델과 런타임 게임 객체를 분리하는 구조

안전한 저장 순서

저장 중 앱이 종료되거나 디스크 쓰기가 실패하면 파일이 반쯤 기록될 수 있다. 기존 파일을 바로 덮어쓰기보다 임시 파일에 완전한 내용을 쓴 뒤 교체하는 방식이 낫다.

flowchart LR
    A[게임 상태 수집] --> B[직렬화]
    B --> C[무결성 보호 및 암호화]
    C --> D[임시 파일에 기록]
    D --> E{기록 성공?}
    E -- 예 --> F[기존 파일 교체]
    E -- 아니오 --> G[기존 세이브 유지]

핵심은 기존 세이브를 마지막 순간까지 보존하는 것이다. 플랫폼별 파일 교체 API의 원자성 보장 범위는 다를 수 있으므로 임시 파일과 백업 파일을 함께 두고 시작 시 복구하는 정책도 고려할 수 있다.

또한 자동 저장은 너무 자주 수행하면 끊김을 만들 수 있다. 상태 변경마다 즉시 저장하기보다는 체크포인트, 스테이지 전환, 메뉴 복귀처럼 의미 있는 시점에 예약하고 실제 파일 쓰기는 비동기 작업으로 분리하는 편이 일반적이다. 단, 게임 종료 시에는 저장 작업이 끝나기 전에 프로세스가 종료되지 않도록 플랫폼 제약을 확인해야 한다.

암호화와 무결성 검증은 다르다

세이브 파일을 Base64로 바꾸거나 XOR 연산을 적용하는 것은 난독화에 가깝다. 텍스트 편집을 조금 어렵게 만들 수는 있지만 공격자가 프로그램을 분석하면 쉽게 되돌릴 수 있다.

암호화는 내용을 읽지 못하게 하는 기능이다. 반면 무결성 검증은 파일이 변경되었는지 확인하는 기능이다. 암호화만 적용하면 공격자가 내용을 읽지 못하더라도 암호문을 다른 값으로 바꾸거나 과거의 세이브 파일로 되돌릴 수 있다.

가능하다면 AES-GCM처럼 암호화와 인증을 함께 제공하는 AEAD 방식을 사용한다. AES-CBC만 사용한다면 별도의 MAC을 붙여야 하며 이때는 일반적으로 Encrypt-then-MAC 순서를 지킨다. 복호화하기 전에 MAC을 검증해야 변조된 데이터를 처리하는 위험을 줄일 수 있다.

AES-GCM으로 저장 데이터 보호하기

.NET에서 사용할 수 있는 AesGcm 예시는 다음과 같다. 실제 프로젝트에서는 파일 형식, 예외 처리, 키 관리 정책을 더해 감싸는 것이 좋다.

using System;
using System.Security.Cryptography;
using System.Text;

public static class SaveCrypto
{
    private const int NonceSize = 12;
    private const int TagSize = 16;

    public static byte[] Encrypt(string json, byte[] key)
    {
        byte[] plaintext = Encoding.UTF8.GetBytes(json);
        byte[] nonce = RandomNumberGenerator.GetBytes(NonceSize);
        byte[] ciphertext = new byte[plaintext.Length];
        byte[] tag = new byte[TagSize];

        using var aes = new AesGcm(key, TagSize);
        aes.Encrypt(nonce, plaintext, ciphertext, tag);

        byte[] result = new byte[NonceSize + TagSize + ciphertext.Length];
        Buffer.BlockCopy(nonce, 0, result, 0, NonceSize);
        Buffer.BlockCopy(tag, 0, result, NonceSize, TagSize);
        Buffer.BlockCopy(ciphertext, 0, result, NonceSize + TagSize, ciphertext.Length);
        return result;
    }

    public static string Decrypt(byte[] payload, byte[] key)
    {
        if (payload.Length < NonceSize + TagSize)
            throw new CryptographicException("잘못된 세이브 데이터입니다.");

        byte[] nonce = payload[..NonceSize];
        byte[] tag = payload[NonceSize..(NonceSize + TagSize)];
        byte[] ciphertext = payload[(NonceSize + TagSize)..];
        byte[] plaintext = new byte[ciphertext.Length];

        using var aes = new AesGcm(key, TagSize);
        aes.Decrypt(nonce, ciphertext, tag, plaintext);
        return Encoding.UTF8.GetString(plaintext);
    }
}

nonce는 암호 키가 같더라도 매번 새로 생성해야 한다. AES-GCM에서 같은 키와 nonce 조합을 재사용하면 보안성이 심각하게 훼손될 수 있다. nonce와 인증 태그는 비밀값이 아니므로 암호문 앞에 함께 저장해도 된다.

AesGcm.Decrypt는 인증 태그 검증에 실패하면 예외를 발생시킨다. 이 경우 데이터를 억지로 읽지 말고 백업 세이브를 시도하거나 사용자에게 손상된 세이브임을 알리고 새 세이브를 만들도록 안내해야 한다.

로컬 세이브 암호화의 한계

클라이언트 게임에 암호 키를 포함하면 충분한 시간과 의지를 가진 사용자는 결국 키나 복호화 지점을 찾아낼 수 있다. 따라서 로컬 암호화만으로 재화 조작을 완벽하게 막을 수 있다고 기대하면 안 된다.

특히 경쟁 요소, 거래, 랭킹, 유료 재화처럼 신뢰성이 중요한 값은 서버가 권한을 가져야 한다. 클라이언트는 요청을 보내고 서버가 규칙을 검증한 뒤 최종 상태를 저장하는 구조가 적합하다. 오프라인 싱글플레이에서는 암호화와 무결성 검증을 조작 방지의 절대 수단이 아니라 일반적인 수정과 손상에 대한 방어층으로 보는 편이 현실적이다.

기기별 보안 저장소를 활용해 키 재료를 보관하면 단순 하드코딩보다 낫다. 다만 앱 재설치, 기기 변경, 백업 복원 시 키를 잃을 수 있으므로 세이브 복구 정책과 충돌하지 않는지 먼저 설계해야 한다.

버전 변경과 마이그레이션

출시 뒤에는 세이브 형식이 바뀐다. 필드를 추가하는 변경은 기본값으로 처리할 수 있지만 아이템 ID 체계 변경이나 데이터 분리는 이전 파일을 변환하는 코드가 필요하다. 처음부터 버전 번호를 넣고 단계적으로 변환하자.

public static void Migrate(SaveData save)
{
    if (save.Version < 2)
    {
        // 이전 버전의 Coins 값을 Gold로 옮기는 등의 변환을 수행한다.
        save.Version = 2;
    }

    if (save.Version < 3)
    {
        // 새 필드에 기본값을 채우고 참조 ID를 정규화한다.
        save.Version = 3;
    }
}

마이그레이션은 항상 오래된 버전에서 최신 버전까지 순서대로 수행하고 변환 결과를 다시 저장해 다음 실행부터는 최신 형식만 읽도록 만들면 복잡도가 낮아진다. 개발 중에는 과거 버전 세이브 파일을 보관해 실제 변환을 테스트하는 것이 좋다.

점검 목록

  • 저장 모델을 런타임 객체와 분리했는가
  • 파일을 원자적으로 교체하거나 백업을 남기는가
  • 파일 손상과 인증 실패를 정상적인 예외 흐름으로 처리하는가
  • 암호화가 필요한 이유와 공격 모델을 명확히 했는가
  • AES-GCM의 nonce를 저장마다 새로 생성하는가
  • 중요한 경제 데이터와 랭킹 판정을 서버에서 검증하는가
  • 세이브 버전과 마이그레이션 경로를 유지하는가

좋은 세이브 시스템은 눈에 잘 띄지 않는다. 하지만 저장 실패와 데이터 손상, 업데이트 후 호환성 문제를 미리 다루면 플레이어가 가장 소중하게 여기는 진행 데이터를 지킬 수 있다.

#Unity#SaveLoad#암호화#CSharp#게임보안

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs