게임을 꺼도 유지되어야 할 데이터는
간단한 싱글게임에서는 PlayerPrefs를 이용해 저장하고는 했습니다.
하지만 이 경우 변조가 매우 쉽기에 최소한의 보안조치로
데이터를 암호화하여 세이브 파일로 저장하는 방법을 공부해 보았습니다.
해당 내용은 통신용 패킷 암호화/복호화에도 활용이 가능합니다.
그 과정을 간단히 말해보자면,
1) 데이터 저장
데이터 > JSON 형식으로 직렬화 > 암호화 > 파일 출력으로 저장
2) 데이터 불러오기
파일 불러오기 > 복호화 > JSON에서 데이터 형식으로 변환 > 데이터
저장과 불러오기는 역순으로 진행됩니다.
1. 데이터 타입 클래스 선언
여기서는 클래스로 데이터를 담아두고 저장 및 불러오는 형태로 썼습니다.
아래 예시 코드의 PlayerData, Container 클래스를 참조하여 필요에 맞게 변형하면 됩니다.
2. JSON 형식으로 변환 / 원래 데이터 형식으로 복구
JSON이란?
https://www.json.org/json-ko.html
JSON
JSON (JavaScript Object Notation)은 경량의 DATA-교환 형식이다. 이 형식은 사람이 읽고 쓰기에 용이하며, 기계가 분석하고 생성함에도 용이하다. JavaScript Programming Language, Standard ECMA-262 3rd Edition - December 1
www.json.org
UnityEngine에 포함된 JsonUtility 클래스를 이용하면 간단히 한 줄씩으로로 표현할 수 있습니다.
dataTemp = 게임에서 사용 가능한 데이터 형식
jsonData = JSON 형식으로 변환한 데이터
Container = dataTemp의 데이터 타입(클래스 이름)
1) 사용자 정의 데이터 타입 > JSON
string jsonData = JsonUtility.ToJson(dataTemp);
2) JSON > 사용자 정의 데이터 타입
Container dataTemp = JsonUtility.FromJson<Container>(jsonData);
3. 암호화 / 복호화
암호화 알고리즘에는 여러 방식이 있습니다.
여기서는 고급 암호화 표준(Advanced Encryption Standard, AES) 방식을 사용하였습니다.
1) 암호화 알고리즘들의 종류 및 특징, 용어 설명
AES에 관련된 페이지이나, 해당 참고자료 하단의 링크 텍스트를 통해 다른 암호화 알고리즘 및 용어에 대한 문서로도 이동이 가능합니다.
https://ko.wikipedia.org/wiki/%EA%B3%A0%EA%B8%89_%EC%95%94%ED%98%B8%ED%99%94_%ED%91%9C%EC%A4%80
고급 암호화 표준 - 위키백과, 우리 모두의 백과사전
위키백과, 우리 모두의 백과사전. 고급 암호화 표준Advanced Encryption Standard(Rijndael) SubBytes 스텝: AES 라운드 4단계 중 하나설계자Vincent Rijmen, Joan Daemen최초 출판일1998기원스퀘어(Square)차기 방식Anubis,
ko.wikipedia.org
2) AES 클래스 및 기초적인 구조
해당 매뉴얼의 예제를 참고하여 아래 예시 코드를 작성하였습니다.
https://learn.microsoft.com/ko-kr/dotnet/api/system.security.cryptography.aes?view=net-8.0
Aes 클래스 (System.Security.Cryptography)
모든 AES(Advanced Encryption Standard) 구현에서 상속해야 하는 추상 기본 클래스를 나타냅니다.
learn.microsoft.com
제가 작성한 아래 예시 코드에서는 공부하면서
기본적인 사용 및 이해를 위한 주석을 달아두었지만,
해당 분야의 지식이 짧아 다소 난해하였기에
제 해석에 틀린 부분이 있다면 지적해 주시면 감사하겠습니다.
4. 파일 입/출력
System.IO 네임스페이스가 필요합니다.
savePath = 세이브 파일 경로를 나타내는 string 문자열
encryptedData = 암호화된 JSON (string 타입) 데이터
1) 파일 쓰기
File.WriteAllText(savePath, encryptedData);
2) 파일 읽기
string encryptedData = File.ReadAllText(savePath);
< 예시 코드 >
위에서 얘기한 내용을 모아서 하나의 예시 코드를 작성해 보았습니다.
유니티에서 코드를 그대로 사용할 경우 아래 코드의
데이터 클래스 (PlayerData, Container),
저장 경로 (savePath),
키 (encryption_Key)
만 조정하여 간단하게 암호화할 수 있습니다.
C#에서 사용하려면,
1) 유니티에서 사용하는 네임스페이스 지우기
using UnityEngine;
2) 해당 네임스페이스와 관련된 빨간줄이 뜨는 부분 지우기
: MonoBehaviour 상속받는 클래스 지우기
Awake(){} 안의 내용은 복사해서 빼두고 메서드 지우기
3) UnityEngine.JsonUtility 대신에 C# 에서 JSON을 사용하는 방법을 찾아 교체
4) 복사해 두었던 Awake() 메서드 내부의 내용을 Main() 메서드에 가져다 쓰면 됩니다.
다만 파일 경로는 변경해야 할 것입니다.
그 외 게임에서 사용하는 코드라 프로그램 목적에 맞게 바꿔야 하는 부분도 있을 것입니다.
using System;
using System.IO;
using System.Text;
using System.Security.Cryptography;
using UnityEngine;
public class DataSaveLoad : MonoBehaviour
{
// 플레이어 데이터를 담아둘 글로벌 변수
public PlayerData playerData;
// 세이브 파일 저장 위치
private string savePath;
private void Awake()
{
// Application.persistentDataPath 때문에 여기서 초기화
savePath = Application.persistentDataPath + "/savefile.txt";
// 시작할 때 저장한 데이터 불러오기
LoadData(playerData);
}
// 게임을 꺼도 저장해야 할 데이터들 (예시)
[Serializable]
public class PlayerData
{
// private로 선언해도 public에 비해 외부 접근이 까다로울 뿐 변조, 해킹이 가능
// 추가적인 보안 방법들이 있기는 하나 이를 다 적용한다 한들 100% 안전하지 않고 비용을 많이 먹어서 여기서는 다루지 않음
// 정말 제대로 된 암호화를 원한다면 key, 암호화된 데이터를 외부 서버에 저장 및 암호화된 데이터로만 주고받는 방법을 권장
// 현실로 비유하자면 기기, 스크립트 내에 보관하는 것은 창고의 문을 잠궜어도 열쇠를 문 앞 화분 밑에 숨겨놓는 것과 같다고 보면 된다
string name;
int gold;
int crystal;
// 데이터 종류에는 배열, 리스트,
// 유니티 내부 변수 타입(Vector3, Quaternion, Rect, Color, Sprite 등)도 사용 가능
// 데이터 종류를 추가/제거하려면,
// 1. 변수 추가/제거
// 2. 바로 아래 PlayerData(){}의 매개변수, (클래스 내 변수)=(매개변수) 구문 추가/제거
// 3. 아래의 해당 데이터 관련한 get, set 프로퍼티 사용 구문 추가/제거
// 4. Container 클래스 내 1:1 매칭되는 동일한 타입의 public 변수 추가/제거
// 5. Container 클래스 내 Income, Export 메서드 내에 프로퍼티 사용 구문 추가/제거
// 데이터 형식
public PlayerData(string name, int gold, int crystal)
{
this.name = name;
this.gold = gold;
this.crystal = crystal;
}
// 데이터 불러오기(Get), 데이터 쓰기(Set)
public string Name
{
get { return name; }
set { name = value; }
}
public int Gold
{
get { return gold; }
set { gold = value; }
}
public int Crystal
{
get { return crystal; }
set { crystal = value; }
}
}
// PlayerData 클래스와 동일한 타입의 변수들을 갖되 이를 public으로 선언
// 이유1. 클래스 내 변수를 JsonUtility.ToJson()을 통해 JSON으로 변환하기 위해서는 변수의 접근제한자가 public이어야 함
// 이유2. 암호화해서 불러오고 저장하는데 그 변수들에 접근하기 쉬워서는 의미가 희석되기에
// 임시로 생성한 Container를 통해서 저장/불러오기 후 그 값을
// 변수들의 접근제한자가 private인 PlayerData로 넘겨줘서 게임에서 쓰기
[Serializable]
public class Container
{
public string name;
public int gold;
public int crystal;
public void Income(PlayerData playerData)
{
playerData.Name = name;
playerData.Gold = gold;
playerData.Crystal = crystal;
}
public void Export(PlayerData playerData)
{
name = playerData.Name;
gold = playerData.Gold;
crystal = playerData.Crystal;
}
}
// 데이터를 저장
public void SaveData(PlayerData data)
{
// 새로운 임시 데이터 컨테이너 생성
Container dataTemp = new Container();
// playerData에서 저장에 필요한 데이터들을 옮기기
dataTemp.Export(data);
// 1. 데이터를 JSON 형식(string)으로 변환
string jsonData = JsonUtility.ToJson(dataTemp);
// 2. JSON 데이터 > 암호화된 JSON 데이터
// 암호화/복호화 키 (LoadData() 내의 Key와 동일해야 함!!!)
string encryption_Key = "여기에키입력해주기";
string encryptedData = Encrypt(jsonData, encryption_Key);
// 3. 파일로 저장
File.WriteAllText(savePath, encryptedData);
}
// 데이터를 불러오기
public void LoadData(PlayerData data)
{
// 테스트용 데이터 저장 파일 삭제
//File.Delete(savePath);
// 세이브 파일이 없다면?
if (!File.Exists(savePath))
{
// 초기화 데이터로 세이브 파일 생성
PlayerData initData = new PlayerData("기본이름", 0, 0);
SaveData(initData);
//Debug.Log("데이터 생성");
}
// 1. 파일 읽기
string encryptedData = File.ReadAllText(savePath);
// 2. 암호화된 데이터를 복호화 > JSON 형식의 데이터
// 암호화/복호화 키 (SaveData() 내의 Key와 동일해야 함!!!)
string encryption_Key = "여기에키입력해주기";
string jsonData = Decrypt(encryptedData, encryption_Key);
// 3. JSON 형식의 데이터 > 정의한 데이터 타입으로 변환
Container dataTemp = JsonUtility.FromJson<Container>(jsonData);
// 임시 컨테이너에 담긴 데이터를 게임 데이터로 옮겨줘서 로드 완료
dataTemp.Income(data);
}
// 데이터 암호화
private string Encrypt(string plainText, string key)
{
using (Aes aes = Aes.Create())
{
// string > byte[] 변환
aes.Key = GetFixedLengthKey(key, 16);
// 랜덤 IV 생성
// 초기화 벡터를 동일한 값으로 계속 쓰는 것은 보안상 좋지 않음
// 1번의 암호화 후 복호화까지만 해당 벡터 사용
aes.GenerateIV();
// 사용자가 정의한 Key, IV 값을 통해, 스트림 변환을 수행할 암호화기 생성
ICryptoTransform encryptor = aes.CreateEncryptor(aes.Key, aes.IV);
// 암호화에 사용할 스트림 생성
// 메모리에 백업 저장하는 스트림 생성
using (var ms = new MemoryStream())
{
// IV를 예시로 데이터와 함께 저장 (복호화에 필요)
// 예시를 들어 데이터 이전에 오도록 했으나
// 저장 위치는 임의 지정 가능
ms.Write(aes.IV, 0, aes.IV.Length);
// 생성한 메모리 스트림에 암호화 스트림 생성
using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write))
{
using (var sw = new StreamWriter(cs))
{
sw.Write(plainText);
}
}
// 암호화가 끝난 데이터를 byte[] 타입 > string으로 변환하여 리턴
return Convert.ToBase64String(ms.ToArray());
}
}
}
// 데이터 복호화
private string Decrypt(string cipherText, string key)
{
using (Aes aes = Aes.Create())
{
// 주의: 복호화 때의 키, 초기화 벡터는 암호화 때와 동일해야 함
aes.Key = GetFixedLengthKey(key, 16);
// string > byte[] 변환
byte[] fullCipher = Convert.FromBase64String(cipherText);
// 암호에 포함된 IV 추출
// 예시로 암호 처음 16자리에 붙여둠
// 초기화 벡터에 해당하는 부분을 변수 iv에 복사
byte[] iv = new byte[16];
Array.Copy(fullCipher, 0, iv, 0, iv.Length);
// 초기화 벡터 값 넣어주기
aes.IV = iv;
// 암호화된 데이터 추출
// 초기화 벡터 iv.Length(16)자리를 제외한 길이
byte[] cipherBytes = new byte[fullCipher.Length - iv.Length];
// 암호문에 해당하는 부분을 cipherBytes에 복사
Array.Copy(fullCipher, iv.Length, cipherBytes, 0, cipherBytes.Length);
// Key, IV 값을 통해, 스트림 변환을 수행할 복호화기 생성
ICryptoTransform decryptor = aes.CreateDecryptor(aes.Key, aes.IV);
// 메모리 스트림 생성 및 암호화된 byte[] 타입 데이터를 넣어줌
using (var ms = new MemoryStream(cipherBytes))
{
// 생성한 메모리 스트림에서 복호화
using (var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read))
{
// 복호화가 끝난 메모리 스트림에서 문자를 읽어오기 위한 텍스트 리더
using (var sr = new StreamReader(cs))
{
// 메모리 스트림의 끝까지 읽어서 리턴
return sr.ReadToEnd();
}
}
}
}
}
// AES 암호화 키 길이 고정
// 키 길이(length)를 16, 24 ,32바이트 중에 맞춰주기
private byte[] GetFixedLengthKey(string key, int length)
{
// 암호화 키의 타입 변환(string > byte[])
byte[] keyBytes = Encoding.UTF8.GetBytes(key);
// 길이가 고정된 byte[] 형태의 키
byte[] fixedKey = new byte[length];
// fixedKey의 length 자리까지 byte[] 타입으로 전환한 key를 한자리씩 넣어주기
// keyBytes가 fixedKey보다 짧으면 남은 자리는 0으로 채워줌
for (int i = 0; i < length; i++)
{
fixedKey[i] = (i < keyBytes.Length) ? keyBytes[i] : (byte)0;
}
return fixedKey;
}
}
< 결과 >
'기능 구현 방법 정리' 카테고리의 다른 글
유니티 UI 이미지 마스크 (0) | 2025.01.27 |
---|---|
유니티 UI에 스파인 애니메이션 넣기 (0) | 2025.01.26 |
유니티 2D) 부모 오브젝트 주위를 자식 오브젝트들이 원형으로 회전하게 하고 싶다면? (RotateAround 사용 버전도 포함) (0) | 2024.12.12 |
유니티) 레이어마스크(LayerMask) 사용법, 2D 가장 가까운 적 탐색 (0) | 2024.12.02 |
유니티) 머티리얼(Material) 색상 곱하기 (0) | 2024.11.25 |