본문 바로가기
프로그래밍/C#

[C#]🎮 유니티 게임 데이터, 어디에 어떻게 저장하고 불러올까? (PlayerPrefs, 파일 입출력, JSON/XML, 보안)

by 다다면체 2025. 4. 22.
728x90
반응형

게임을 플레이하다가 열심히 키운 캐릭터나 달성한 업적이 날아간다면 정말 허무하겠죠? 😭 사용자 설정이 초기화되어 매번 다시 설정해야 한다면 불편할 거고요. 그래서 게임 데이터를 안전하게 저장하고 필요할 때 불러오는 기능은 필수입니다!

이번 포스팅에서는 유니티에서 데이터를 저장하고 로드하는 주요 방법들을 깊이 있게 살펴보고, 각 방법의 장단점과 실무 팁, 그리고 보안 고려 사항까지 꼼꼼하게 알아보겠습니다.

반응형

1. PlayerPrefs: 간단한 데이터 저장의 시작 🔑

PlayerPrefs는 유니티가 제공하는 가장 간단한 데이터 저장 방식입니다. 마치 게임의 작은 메모장처럼, 정수(int), 실수(float), 문자열(string) 타입의 간단한 데이터를 키(Key)-값(Value) 형태로 저장하고 로드할 수 있습니다.

  • 주요 용도:
    • 볼륨, 그래픽 품질 등 간단한 사용자 설정
    • 최고 점수, 플레이어 이름 등 단일 데이터
    • 튜토리얼 완료 여부 등 간단한 상태 플래그
  • 사용법:
    using UnityEngine;
    
    public class SimpleDataSaver : MonoBehaviour
    {
        private const string HIGHSCORE_KEY = "HighScore"; // ✨ 실무 팁: 키 이름은 상수로 관리하면 오타를 줄이고 관리가 편해요!
        private const string VOLUME_KEY = "MasterVolume";
        private const string PLAYERNAME_KEY = "PlayerName";
    
        void Start()
        {
            // 데이터 저장 예시
            SaveHighScore(1500);
            SaveVolume(0.8f);
            SavePlayerName("CodingPartner");
    
            // 데이터 로드 예시
            LoadData();
        }
    
        // --- 데이터 저장 함수 ---
        public void SaveHighScore(int score)
        {
            PlayerPrefs.SetInt(HIGHSCORE_KEY, score);
            PlayerPrefs.Save(); // ✨ 중요: Set 함수 호출 후 Save()를 호출해야 실제로 디스크에 저장됩니다! (앱 종료 시 자동 저장되기도 함)
            Debug.Log($"최고 점수 저장됨: {score}");
        }
    
        public void SaveVolume(float volume)
        {
            PlayerPrefs.SetFloat(VOLUME_KEY, volume);
            PlayerPrefs.Save();
            Debug.Log($"볼륨 저장됨: {volume}");
        }
    
        public void SavePlayerName(string playerName)
        {
            PlayerPrefs.SetString(PLAYERNAME_KEY, playerName);
            PlayerPrefs.Save();
            Debug.Log($"플레이어 이름 저장됨: {playerName}");
        }
    
        // --- 데이터 로드 함수 ---
        public void LoadData()
        {
            // Get 함수 사용 시, 해당 키가 없을 경우를 대비해 기본값(defaultValue)을 지정할 수 있습니다.
            int highScore = PlayerPrefs.GetInt(HIGHSCORE_KEY, 0); // 키가 없으면 0을 반환
            float volume = PlayerPrefs.GetFloat(VOLUME_KEY, 1.0f); // 키가 없으면 1.0f를 반환
            string playerName = PlayerPrefs.GetString(PLAYERNAME_KEY, "Guest"); // 키가 없으면 "Guest"를 반환
    
            Debug.Log($"로드된 최고 점수: {highScore}");
            Debug.Log($"로드된 볼륨: {volume}");
            Debug.Log($"로드된 플레이어 이름: {playerName}");
    
            // 특정 키 삭제: PlayerPrefs.DeleteKey(KEY_NAME);
            // 모든 PlayerPrefs 데이터 삭제: PlayerPrefs.DeleteAll(); (🚨 주의해서 사용!)
        }
    }
    
  • 장점: 사용법이 매우 간단하고 빠릅니다. 별도의 파일 관리나 복잡한 코드 없이 몇 줄만으로 구현 가능합니다.
  • 단점:
    • 보안 취약: 데이터가 암호화되지 않고 쉽게 접근 가능한 위치(레지스트리, plist 등)에 저장되어 위변조에 취약합니다. 치명적인 데이터 저장에는 부적합합니다.
    • 데이터 타입 제한: int, float, string 외 복잡한 데이터 구조(배열, 리스트, 클래스 등)는 직접 저장할 수 없습니다. (JSON 문자열로 변환하여 저장하는 꼼수는 가능합니다.)
    • 성능: 많은 양의 데이터를 저장/로드할 경우 성능 저하가 발생할 수 있습니다.
    • 플랫폼 의존성: 저장 위치나 방식이 플랫폼(Windows, macOS, Android, iOS 등)마다 다릅니다.
  • ✨ 심층 분석: PlayerPrefs는 내부적으로 운영체제가 제공하는 간단한 데이터 저장소를 사용합니다. 따라서 빠르고 편리하지만, 그만큼 기능과 보안에 제약이 따릅니다. 간단한 설정값 외 중요한 게임 데이터 저장에는 다른 방법을 고려하는 것이 좋습니다.

2. 파일 입출력 (File I/O): 내 손으로 직접 관리하는 데이터 💾

System.IO 네임스페이스를 활용하면 C#의 표준 파일 입출력 기능을 사용하여 원하는 형식(텍스트, 바이너리 등)으로 데이터를 직접 파일에 쓰고 읽을 수 있습니다. PlayerPrefs보다 훨씬 유연하고 강력한 데이터 관리가 가능합니다.

  • 핵심 네임스페이스: using System.IO;
  • 주요 클래스:
    • File: 파일 생성, 복사, 삭제, 존재 여부 확인 등 정적 메서드 제공
    • Directory: 디렉토리 생성, 삭제, 확인 등
    • Path: 파일/디렉토리 경로 문자열 조작 (결합, 확장자 얻기 등)
    • StreamWriter: 텍스트 파일 쓰기
    • StreamReader: 텍스트 파일 읽기
    • FileStream: 파일 스트림 제어 (바이너리 데이터 처리에 주로 사용)
    • BinaryWriter: 바이너리 데이터 쓰기
    • BinaryReader: 바이너리 데이터 읽기
  • 저장 위치: Application.persistentDataPath 를 사용하는 것이 일반적입니다. 이 경로는 각 플랫폼에서 사용자 데이터를 저장하기 위한 안전하고 영구적인 공간을 가리킵니다. (예: Windows의 AppData, Android/iOS의 앱 데이터 폴더)
  • using UnityEngine;
    using System.IO; // 필수!
    
    public class FileIOManager : MonoBehaviour
    {
        private string saveFilePath_Text;
        private string saveFilePath_Binary;
    
        void Awake()
        {
            // 파일 경로 설정 (persistentDataPath는 앱이 삭제되지 않는 한 유지되는 경로)
            saveFilePath_Text = Path.Combine(Application.persistentDataPath, "playerData.txt");
            saveFilePath_Binary = Path.Combine(Application.persistentDataPath, "playerData.bin");
            Debug.Log($"텍스트 저장 경로: {saveFilePath_Text}");
            Debug.Log($"바이너리 저장 경로: {saveFilePath_Binary}");
        }
    
        // --- 텍스트 파일 저장 ---
        public void SaveDataAsText(string data)
        {
            try // ✨ 실무 팁: 파일 입출력은 예외 발생 가능성이 높으므로 try-catch로 감싸는 것이 안전합니다.
            {
                // StreamWriter를 사용하여 텍스트 파일 쓰기 (파일이 없으면 생성, 있으면 덮어쓰기)
                using (StreamWriter writer = new StreamWriter(saveFilePath_Text, false)) // using 구문은 자동으로 리소스를 해제해줍니다 (writer.Close() 불필요)
                {
                    writer.WriteLine(data);
                    // writer.Write(...) // 줄바꿈 없이 쓰기
                }
                Debug.Log("텍스트 데이터 저장 완료!");
            }
            catch (IOException e)
            {
                Debug.LogError($"텍스트 파일 저장 중 오류 발생: {e.Message}");
            }
        }
    
        // --- 텍스트 파일 로드 ---
        public string LoadDataFromText()
        {
            if (File.Exists(saveFilePath_Text)) // 파일 존재 여부 확인
            {
                try
                {
                    using (StreamReader reader = new StreamReader(saveFilePath_Text))
                    {
                        string data = reader.ReadToEnd(); // 파일 전체 읽기
                        // string line = reader.ReadLine(); // 한 줄씩 읽기
                        Debug.Log("텍스트 데이터 로드 완료!");
                        return data;
                    }
                }
                catch (IOException e)
                {
                    Debug.LogError($"텍스트 파일 로드 중 오류 발생: {e.Message}");
                    return null;
                }
            }
            else
            {
                Debug.LogWarning("저장된 텍스트 데이터 파일이 없습니다.");
                return null;
            }
        }
    
        // --- 바이너리 파일 저장 ---
        public void SaveDataAsBinary(int level, float health, string playerName)
        {
            try
            {
                using (FileStream fs = new FileStream(saveFilePath_Binary, FileMode.Create)) // FileMode.Create: 파일 생성 또는 덮어쓰기
                using (BinaryWriter writer = new BinaryWriter(fs))
                {
                    writer.Write(level);    // 정수 쓰기
                    writer.Write(health);   // 실수 쓰기
                    writer.Write(playerName); // 문자열 쓰기
                }
                Debug.Log("바이너리 데이터 저장 완료!");
            }
            catch (IOException e)
            {
                Debug.LogError($"바이너리 파일 저장 중 오류 발생: {e.Message}");
            }
        }
    
        // --- 바이너리 파일 로드 ---
        public void LoadDataFromBinary()
        {
             if (File.Exists(saveFilePath_Binary))
             {
                try
                {
                    using (FileStream fs = new FileStream(saveFilePath_Binary, FileMode.Open)) // FileMode.Open: 기존 파일 열기
                    using (BinaryReader reader = new BinaryReader(fs))
                    {
                        int level = reader.ReadInt32();
                        float health = reader.ReadSingle();
                        string playerName = reader.ReadString();
    
                        Debug.Log($"로드된 바이너리 데이터 - 레벨: {level}, 체력: {health}, 이름: {playerName}");
                    }
                }
                catch (IOException e)
                {
                    Debug.LogError($"바이너리 파일 로드 중 오류 발생: {e.Message}");
                }
            }
            else
            {
                Debug.LogWarning("저장된 바이너리 데이터 파일이 없습니다.");
            }
        }
    }
    
  • 장점:
    • 유연성: 원하는 어떤 형식이든 데이터를 저장/로드할 수 있습니다. (텍스트, 바이너리, CSV, 직접 정의한 포맷 등)
    • 데이터 타입: C#이 지원하는 거의 모든 데이터 타입을 저장할 수 있습니다. (직접 변환 로직 필요)
    • 용량: 대용량 데이터 처리에 PlayerPrefs보다 적합합니다.
  • 단점:
    • 복잡성: PlayerPrefs보다 코드가 길어지고 파일/스트림 관리에 신경 써야 합니다.
    • 데이터 구조화: 단순 텍스트나 바이너리 스트림은 데이터 구조를 표현하기 어렵습니다. (어떤 순서로 무엇을 저장했는지 명확히 관리해야 함)
    • 오류 처리: 파일 접근 권한, 디스크 공간 부족 등 다양한 예외 상황을 직접 처리해야 합니다.
  • ✨ 실무 팁: try-catch 블록으로 파일 접근 오류를 처리하고, using 구문을 사용하여 파일 스트림이 확실하게 닫히도록 하는 것이 중요합니다. (리소스 누수 방지)

3. JSON / XML: 구조화된 데이터의 힘 🏛️

복잡한 게임 데이터(플레이어 인벤토리, 퀘스트 상태, 캐릭터 스탯 등)를 저장할 때는 구조화된 데이터 포맷인 JSON 이나 XML 을 사용하는 것이 매우 효율적입니다. 데이터를 사람이 읽기 쉬운 텍스트 형태로 표현하며, 객체나 리스트 같은 복잡한 구조를 잘 나타낼 수 있습니다.

유니티는 내장 기능(JsonUtility) 및 C# 표준 라이브러리(XmlSerializer)를 통해 이러한 포맷을 쉽게 다룰 수 있도록 지원합니다. 이 과정을 직렬화(Serialization) 및 **역직렬화(Deserialization)**라고 합니다.

  • 직렬화(Serialization): 메모리 상의 객체(클래스 인스턴스)를 파일이나 네트워크 전송에 적합한 형태(JSON, XML 문자열, 바이트 스트림 등)로 변환하는 과정입니다.
  • 역직렬화(Deserialization): 직렬화된 데이터(JSON, XML 문자열 등)를 다시 메모리 상의 객체로 복원하는 과정입니다.

3.1 JSON (JsonUtility)

JSON(JavaScript Object Notation)은 가볍고 사람이 읽기 쉬우며, 웹에서 널리 사용되는 데이터 포맷입니다. 유니티의 JsonUtility는 사용법이 비교적 간단합니다.

  • 필수 조건: 저장하려는 데이터 클래스나 구조체에 [System.Serializable] 어트리뷰트를 붙여야 합니다. 또한, 직렬화하려는 필드는 public 이어야 하거나 [SerializeField] 어트리뷰트가 붙어야 합니다.
  • using UnityEngine;
    using System.IO;
    
    // 1. 저장할 데이터 구조 정의 (Serializable 어트리뷰트 필수!)
    [System.Serializable]
    public class PlayerData
    {
        public string playerName;
        public int level;
        public float health;
        public Vector3 position; // Vector3 같은 유니티 타입도 지원
        public System.Collections.Generic.List<string> inventory; // 리스트도 지원!
        // public System.Collections.Generic.Dictionary<string, int> skillLevels; // ⚠️ JsonUtility는 Dictionary 직접 지원 안 함!
    
        // 생성자 (편의상 추가)
        public PlayerData(string name, int lvl, float hp, Vector3 pos, System.Collections.Generic.List<string> items)
        {
            playerName = name;
            level = lvl;
            health = hp;
            position = pos;
            inventory = items;
        }
    }
    
    public class JsonDataHandler : MonoBehaviour
    {
        private string saveFilePath;
    
        void Awake()
        {
            saveFilePath = Path.Combine(Application.persistentDataPath, "playerData.json");
            Debug.Log($"JSON 저장 경로: {saveFilePath}");
        }
    
        // --- JSON 데이터 저장 ---
        public void SavePlayerData(PlayerData data)
        {
            try
            {
                // 2. 객체를 JSON 문자열로 직렬화
                string jsonData = JsonUtility.ToJson(data, true); // 'true'는 예쁘게 포맷팅 (가독성 좋음)
    
                // 3. JSON 문자열을 파일에 쓰기
                File.WriteAllText(saveFilePath, jsonData);
                Debug.Log($"JSON 데이터 저장 완료:\n{jsonData}");
            }
            catch (System.Exception e) // JsonUtility 관련 예외 포함
            {
                Debug.LogError($"JSON 저장 중 오류 발생: {e.Message}");
            }
        }
    
        // --- JSON 데이터 로드 ---
        public PlayerData LoadPlayerData()
        {
            if (File.Exists(saveFilePath))
            {
                try
                {
                    // 1. 파일에서 JSON 문자열 읽기
                    string jsonData = File.ReadAllText(saveFilePath);
    
                    // 2. JSON 문자열을 객체로 역직렬화
                    PlayerData loadedData = JsonUtility.FromJson<PlayerData>(jsonData);
                    Debug.Log("JSON 데이터 로드 완료!");
                    return loadedData;
                }
                catch (System.Exception e)
                {
                     Debug.LogError($"JSON 로드 중 오류 발생: {e.Message}");
                     return null;
                }
            }
            else
            {
                Debug.LogWarning("저장된 JSON 데이터 파일이 없습니다.");
                return null; // 또는 기본값 객체 반환
            }
        }
    
        // --- 예시 사용 ---
        void Start()
        {
            // 예시 데이터 생성
            System.Collections.Generic.List<string> items = new System.Collections.Generic.List<string> { "Sword", "Potion", "Shield" };
            PlayerData myData = new PlayerData("Hero", 5, 85.5f, new Vector3(1.0f, 0.5f, 2.0f), items);
    
            // 저장
            SavePlayerData(myData);
    
            // 로드
            PlayerData loaded = LoadPlayerData();
            if (loaded != null)
            {
                 Debug.Log($"로드된 플레이어 이름: {loaded.playerName}, 레벨: {loaded.level}, 첫번째 아이템: {loaded.inventory[0]}");
            }
    
            // JsonUtility의 한계: 딕셔너리(Dictionary)는 직접 직렬화/역직렬화 불가
            // 해결책: 딕셔너리를 리스트 등으로 변환하여 저장하거나, Newtonsoft.Json (Json.NET) 같은 외부 라이브러리 사용 고려
        }
    }
    
  • JsonUtility 장점: 유니티 내장 기능이라 별도 설치가 필요 없고 사용법이 간단합니다. 유니티 타입(Vector3, Quaternion 등)을 잘 지원합니다.
  • JsonUtility 단점: 딕셔너리(Dictionary)나 복잡한 상속 구조, 프로퍼티(Property) 등을 직접 지원하지 않는 등 기능 제한이 있습니다. 성능이 매우 중요한 경우 다른 라이브러리보다 느릴 수 있습니다.
  • ✨ 실무 팁: 복잡한 데이터를 다루거나 JsonUtility의 제한 사항에 부딪힌다면, Newtonsoft.Json (Json.NET) 라이브러리 사용을 강력히 추천합니다. 유니티 패키지 매니저를 통해 쉽게 설치할 수 있으며, 훨씬 강력하고 유연한 JSON 처리를 제공합니다.

3.2 XML (XmlSerializer)

XML(Extensible Markup Language)은 태그 기반의 마크업 언어로, 데이터 구조를 표현하는 또 다른 방법입니다. JSON보다 장황하지만, 스키마 정의 등을 통해 데이터 유효성 검증이 용이한 면이 있습니다. C#의 System.Xml.Serialization 네임스페이스를 사용합니다.

  • 사용법: JSON과 유사하게, [System.Serializable] 또는 public 접근 제한자가 필요하며, XmlSerializer 클래스를 사용합니다.
  • using UnityEngine;
    using System.IO;
    using System.Xml.Serialization; // 필수!
    using System.Collections.Generic; // List 사용 위해 추가
    
    // JSON 예시와 동일한 PlayerData 클래스 사용 가능 (Serializable 어트리뷰트 있음)
    // 여기서는 XML 직렬화를 위해 그대로 사용
    
    public class XmlDataHandler : MonoBehaviour
    {
        private string saveFilePath;
    
        void Awake()
        {
            saveFilePath = Path.Combine(Application.persistentDataPath, "playerData.xml");
            Debug.Log($"XML 저장 경로: {saveFilePath}");
        }
    
        // --- XML 데이터 저장 ---
        public void SavePlayerData(PlayerData data)
        {
            try
            {
                // 1. XmlSerializer 생성 (저장할 객체 타입 명시)
                XmlSerializer serializer = new XmlSerializer(typeof(PlayerData));
    
                // 2. 파일 스트림 열기 (using으로 자동 리소스 관리)
                using (FileStream stream = new FileStream(saveFilePath, FileMode.Create))
                {
                    // 3. 객체를 XML로 직렬화하여 파일에 쓰기
                    serializer.Serialize(stream, data);
                }
                Debug.Log("XML 데이터 저장 완료!");
            }
            catch (System.Exception e)
            {
                Debug.LogError($"XML 저장 중 오류 발생: {e.Message}");
            }
        }
    
        // --- XML 데이터 로드 ---
        public PlayerData LoadPlayerData()
        {
            if (File.Exists(saveFilePath))
            {
                try
                {
                    // 1. XmlSerializer 생성 (로드할 객체 타입 명시)
                    XmlSerializer serializer = new XmlSerializer(typeof(PlayerData));
    
                    // 2. 파일 스트림 열기
                    using (FileStream stream = new FileStream(saveFilePath, FileMode.Open))
                    {
                        // 3. XML 데이터를 객체로 역직렬화
                        PlayerData loadedData = serializer.Deserialize(stream) as PlayerData;
                        Debug.Log("XML 데이터 로드 완료!");
                        return loadedData;
                    }
                }
                catch (System.Exception e)
                {
                    Debug.LogError($"XML 로드 중 오류 발생: {e.Message}");
                    return null;
                }
            }
            else
            {
                Debug.LogWarning("저장된 XML 데이터 파일이 없습니다.");
                return null;
            }
        }
    
        // --- 예시 사용 ---
        void Start()
        {
            // 예시 데이터 생성
            List<string> items = new List<string> { "Axe", "Health Potion", "Helmet" };
            PlayerData myData = new PlayerData("Warrior", 10, 120.0f, Vector3.one, items);
    
            // 저장
            SavePlayerData(myData);
    
            // 로드
            PlayerData loaded = LoadPlayerData();
            if (loaded != null)
            {
                Debug.Log($"로드된 플레이어 이름: {loaded.playerName}, 레벨: {loaded.level}");
            }
        }
    }
    
  • XML 장점: 데이터 구조가 명확하고, 주석 추가가 가능하며, 스키마(XSD)를 이용한 유효성 검증이 용이합니다. 복잡한 데이터 구조 표현에 강점이 있습니다.
  • XML 단점: JSON에 비해 파일 크기가 크고 파싱 속도가 느릴 수 있습니다. 가독성은 좋지만, JSON보다 장황하게 느껴질 수 있습니다.
  • ✨ JSON vs XML 선택 가이드:
    • JSON: 가볍고 빠른 처리가 중요할 때, 웹 연동이 많을 때, 모바일 환경에서 주로 사용됩니다. 현대적인 개발에서 더 선호되는 경향이 있습니다.
    • XML: 엄격한 데이터 구조 정의나 유효성 검증이 필요할 때, 기존 시스템과의 호환성이 중요할 때, 복잡한 구성 파일 등에 사용될 수 있습니다.
  • ✨ 심층 분석 & 실무 팁 (직렬화 공통):
    • 데이터 버전 관리: 게임 업데이트 시 데이터 구조가 변경될 수 있습니다. 저장 파일에 버전 정보를 포함하고, 이전 버전 데이터 로드 시 호환성 처리 로직을 구현하는 것이 중요합니다. (예: PlayerDataV1, PlayerDataV2 클래스를 만들고 변환 로직 추가)
    • 데이터 클래스 설계: 저장할 데이터만 포함하는 순수한 데이터 클래스(Plain Old Data, POD)를 만드는 것이 좋습니다. MonoBehaviour 등을 직접 직렬화하려고 하면 예상치 못한 문제가 발생할 수 있습니다.
    • 오류 처리: 역직렬화 시 데이터가 손상되었거나 형식이 맞지 않으면 예외가 발생합니다. try-catch로 이를 처리하고, 데이터 복구 또는 초기화 로직을 고려해야 합니다.

4. 보안 및 데이터 관리 고려 사항 🛡️

열심히 만든 게임의 데이터가 쉽게 위변조된다면 큰 문제겠죠? 특히 경쟁 요소가 있거나 중요한 재화가 포함된 게임이라면 보안은 필수입니다.

  • PlayerPrefs/텍스트 파일의 취약점: 앞서 언급했듯, PlayerPrefs나 암호화되지 않은 텍스트(JSON, XML 포함) 파일은 내용을 쉽게 열어보고 수정할 수 있습니다.
  • 기본적인 데이터 보호 (암호화/난독화):
    • 목표: 데이터를 알아보기 어렵게 만들거나 간단한 수정을 방지하는 것. (완벽한 보안은 아님!)
    • 간단한 예시 (XOR 암호화): 간단한 키를 사용하여 데이터 바이트를 XOR 연산하는 방식입니다. 키를 모르면 원본 데이터를 보기 어렵지만, 알고리즘과 키가 노출되면 쉽게 해독 가능합니다.
    • Base64 인코딩: 데이터를 ASCII 문자열로 변환하는 인코딩 방식입니다. 암호화는 아니지만, 바이너리 데이터를 텍스트로 전송/저장할 때 유용하며, 데이터를 바로 알아보기 어렵게 만듭니다.
    using UnityEngine;
    using System.IO;
    using System; // Convert 클래스 사용 위함
    using System.Text; // Encoding 클래스 사용 위함
    
    public class SecureDataHandler
    {
        private string saveFilePath = Path.Combine(Application.persistentDataPath, "secureData.dat");
        private string encryptionKey = "MySecretKey"; // 🚨 중요: 실제 게임에서는 키를 코드에 직접 넣지 마세요! (분리, 난독화 등 필요)
    
        // --- 간단한 XOR 암호화/복호화 ---
        private string EncryptDecrypt(string data)
        {
            StringBuilder result = new StringBuilder();
            for (int i = 0; i < data.Length; i++)
            {
                result.Append((char)(data[i] ^ encryptionKey[i % encryptionKey.Length]));
            }
            return result.ToString();
        }
    
        // --- 데이터 저장 (JSON + Base64 + XOR) ---
        public void SaveSecureData(PlayerData data)
        {
            try
            {
                string jsonData = JsonUtility.ToJson(data);
    
                // 1. XOR 암호화
                string encryptedData = EncryptDecrypt(jsonData);
    
                // 2. Base64 인코딩 (바이너리처럼 보이지 않게)
                string base64EncodedData = Convert.ToBase64String(Encoding.UTF8.GetBytes(encryptedData));
    
                File.WriteAllText(saveFilePath, base64EncodedData);
                Debug.Log("보안 데이터 저장 완료!");
            }
            catch (Exception e) { Debug.LogError($"보안 데이터 저장 오류: {e.Message}"); }
        }
    
        // --- 데이터 로드 (Base64 + XOR + JSON) ---
        public PlayerData LoadSecureData()
        {
            if (File.Exists(saveFilePath))
            {
                try
                {
                    string base64EncodedData = File.ReadAllText(saveFilePath);
    
                    // 1. Base64 디코딩
                    byte[] tmp = Convert.FromBase64String(base64EncodedData);
                    string encryptedData = Encoding.UTF8.GetString(tmp);
    
                    // 2. XOR 복호화
                    string jsonData = EncryptDecrypt(encryptedData);
    
                    PlayerData loadedData = JsonUtility.FromJson<PlayerData>(jsonData);
                    Debug.Log("보안 데이터 로드 완료!");
                    return loadedData;
                }
                catch (Exception e)
                {
                    // 복호화 실패 (키 불일치, 데이터 손상 등)
                    Debug.LogError($"보안 데이터 로드 오류: {e.Message}");
                    // 🚨 데이터 로드 실패 시 처리 로직 필요 (파일 삭제, 초기화 등)
                    // File.Delete(saveFilePath); // 예시: 손상된 파일 삭제
                    return null;
                }
            }
            return null;
        }
    
         // --- 예시 사용 ---
        void Start()
        {
            // 예시 데이터 생성 (PlayerData 클래스는 이전 예제 참고)
            PlayerData myData = new PlayerData("AgentX", 7, 99.9f, Vector3.zero, new System.Collections.Generic.List<string>{"Keycard", "Gadget"});
    
            // 저장
            SaveSecureData(myData);
    
            // 로드
            PlayerData loaded = LoadSecureData();
            if (loaded != null)
            {
                 Debug.Log($"로드된 보안 플레이어 이름: {loaded.playerName}");
            }
            else {
                 Debug.Log("보안 데이터 로드 실패 또는 파일 없음");
            }
        }
    }
    
    • 🚨 경고: 위 예시는 매우 기본적인 난독화/암호화이며, 전문적인 해커에게는 쉽게 뚫릴 수 있습니다. 온라인 게임의 재화나 경쟁 순위 등 정말 중요한 데이터는 서버에 저장하고 검증하는 것이 필수적입니다. 클라이언트 측 암호화는 보조적인 수단으로 사용해야 합니다.
    • 더 강력한 암호화: AES 같은 표준 대칭키 암호화 알고리즘을 사용하면 훨씬 안전합니다. C#의 System.Security.Cryptography 네임스페이스를 활용할 수 있지만, 키 관리 등 고려할 사항이 많아집니다.
  • 저장 위치 및 파일 관리:
    • Application.persistentDataPath: 사용자별 데이터 저장에 가장 적합한 경로입니다. 앱 업데이트 시에도 유지됩니다.
    • Application.dataPath: 프로젝트의 Assets 폴더를 가리킵니다. (에디터) 빌드 후에는 게임 실행 파일과 데이터가 있는 폴더입니다. 런타임에 파일을 쓰기에는 부적합하거나 권한 문제가 있을 수 있습니다. (주로 읽기 전용 데이터)
    • Application.streamingAssetsPath: 빌드 시 포함되어 읽기 전용으로 접근해야 하는 파일(초기 설정 파일, 번역 텍스트 등)을 넣는 경로입니다. 플랫폼별 경로 접근 방식이 조금 다릅니다.
    • 파일 관리:
      • 세이브 슬롯 구현: 여러 개의 저장 파일을 관리 (save_slot_1.dat, save_slot_2.dat)
      • 백업: 중요한 저장 시점에 이전 파일을 백업 (save.dat -> save.bak)하여 데이터 손상 시 복구 기회 제공
      • 데이터 유효성 검사: 로드 시 데이터가 정상 범위 내에 있는지, 필수 값이 누락되지 않았는지 등을 검사하여 비정상적인 상태 방지 (체력이 음수, 레벨이 9999 등)

결론 ✨

지금까지 유니티에서 게임 데이터를 저장하고 로드하는 다양한 방법들을 살펴보았습니다.

  • PlayerPrefs: 간단한 설정값 저장에 빠르고 편리합니다.
  • File I/O: 텍스트, 바이너리 등 원하는 형식으로 유연하게 데이터를 관리할 수 있습니다.
  • JSON/XML 직렬화: 복잡하고 구조화된 데이터를 효율적으로 다룰 수 있습니다. (특히 JsonUtility 또는 Newtonsoft.Json)
  • 보안: 간단한 암호화/난독화로 데이터 위변조를 어렵게 만들 수 있지만, 중요한 데이터는 서버 저장을 고려해야 합니다.

어떤 방법을 선택해야 할까요? 정답은 없습니다! 저장하려는 데이터의 복잡성, 중요도, 보안 요구 사항 등을 종합적으로 고려하여 가장 적합한 방식을 선택하거나 여러 방식을 조합하여 사용하는 것이 좋습니다.

예를 들어, 게임 설정은 PlayerPrefs로, 플레이어 진행 상황은 암호화된 JSON 파일로 저장하는 식이죠. 😉

데이터 저장은 게임의 완성도를 높이는 중요한 부분입니다. 꾸준히 연습하고 다양한 상황에 적용해보면서 여러분의 게임을 더욱 풍성하게 만들어 보세요! 💪 궁금한 점이 있다면 언제든지 저에게 다시 질문해주세요!

728x90
반응형