유니티에서 세이브 데이터를 저장하는 방식은 여러가지가 있다. 대표적인 것이 JSON, XML, CSV 등의 파일형식으로 저장하거나 그것들을 바이너리화 하는 것이다. 특히 JSON방식은 자바스크립트의 인기에 힘입어 많이 쓰이는 편이다. 유니티에서는 Unity5 시절부터 JsonUtility라고 하여 엔진 내에서 Json관련 라이브러리를 제공한다.


이 글에서는 JsonUtility로 클래스 객체를 직렬화 한 뒤, System.Security.Cryptography를 이용해서 다른 사람이 세이브파일을 뜯어볼 수 없도록 암호화 해보도록 하겠다.


우선 JsonUtility가 기존에 많이 쓰였던 다른 Json 라이브러리에 비해 좋은 점은 아래와 같다.

1. Vector3 변수를 그대로 저장할 수 있다.

2. 가비지 컬렉터 메모리를 최소한만 이용한다.

3. 속도가 빠르다.

(출처 : https://docs.unity3d.com/kr/current/Manual/JSONSerialization.html)


일단 저장할 데이터를 담을 클래스를 아래와 같이 만들어보겠다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
using System.Collections.Generic;
using UnityEngine;
 
[System.Serializable]
public class SaveData
{
    //여러가지 타입이 가능하지만, Dictionary는 안된다.
    [SerializeField] private string characterName;
    [SerializeField] private int level;
    [SerializeField] private float exp;
    [SerializeField] private List<string> itemsName;
 
    //외부 Json 라이브러리가 아닌 JsonUtility를 쓰면 Vector3도 저장할 수 있다.
    [SerializeField] private Vector3 lastPosition;
 
    //생성자
    public SaveData(string t_characterName, int t_level, float t_exp, List<string> t_itemsName, Vector3 t_lastPosition)
    {
        characterName = t_characterName;
        level = t_level;
        exp = t_exp;
 
        //일일이 값을 복사하는게 깔끔하다.
        itemsName = new List<string>();
        foreach(var n in t_itemsName)
        {
            itemsName.Add(n);
        }
 
        lastPosition = new Vector3(t_lastPosition.x, t_lastPosition.y, t_lastPosition.z);
    }
}
cs


위의 코드는 저장할 변수들을 간추린 SaveData 클래스이다.

위의 코드에서 중요한 부분은, class 상단에 [System.Serializable] 어트리뷰트를 붙여줘야 한다는 것이다.

[System.Serializable] 어트리뷰트는 이 클래스를 직렬화 할 수 있게 표식을 남겨주는 의미이다.



직렬화라는 것은 데이터를 byte단위로 한가닥으로 길게 뽑아낸다고 생각하면 되는데, 

인터넷 통신을 할 때나 파일을 읽고 쓸 때는 데이터가 byte단위로 이동하기 때문에 세이브 데이터를 만들려면 이 표식을 붙여줘야 한다.



그 다음에 위의 코드에서 눈여겨볼 것은 저장할 변수 앞에 붙은 [SerializeField] 어트리뷰트이다.

내가 저장하고 싶은 변수를 체크하는 방법은 크게 2가지가 있다.

1. 변수를 public 접근지정자로 선언한다.

2. 변수를 private 접근지정자로 선언한 후 [SerializeField]를 앞에 붙여준다.

나 같은 경우에는 플레이어의 정보를 은닉하고 싶기 때문에 private으로 선언했고, 그 때문에 앞에 [SerializeField]를 붙여주었다.




1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
using System.Collections.Generic;
using UnityEngine;
using System.IO;
using System.Security.Cryptography;
 
public class SaveManager : MonoBehaviour
{
    private static readonly string privateKey = "1718hy9dsf0jsdlfjds0pa9ids78ahgf81h32re";
 
    public static void Save()
    {
        //원래라면 플레이어 정보나 인벤토리 등에서 긁어모아야 할 정보들.
        SaveData sd = new SaveData(
            "글릭"
            10
            0.1f, 
            new List<string> { "포션""단검" }, 
            new Vector3(123));
 
        string jsonString = DataToJson(sd);
        string encryptString = Encrypt(jsonString);
        SaveFile(encryptString);
    }
 
    public static SaveData Load()
    {
        //파일이 존재하는지부터 체크.
        if(!File.Exists(GetPath()))
        {
            Debug.Log("세이브 파일이 존재하지 않음.");
            return null;
        }
 
        string encryptData = LoadFile(GetPath());
        string decryptData = Decrypt(encryptData);
 
        Debug.Log(decryptData);
 
        SaveData sd = JsonToData(decryptData);
        return sd;
    }
 
    ////////////////////////////////////////////////////////////////////////////////////////
 
    //세이브 데이터를 json string으로 변환
    static string DataToJson(SaveData sd)
    {
        string jsonData = JsonUtility.ToJson(sd);
        return jsonData;
    }
 
    //json string을 SaveData로 변환
    static SaveData JsonToData(string jsonData)
    {
        SaveData sd = JsonUtility.FromJson<SaveData>(jsonData);
        return sd;
    }
 
    ////////////////////////////////////////////////////////////////////////////////////////
 
    //json string을 파일로 저장
    static void SaveFile(string jsonData)
    {
        using (FileStream fs = new FileStream(GetPath(), FileMode.Create, FileAccess.Write))
        {
            //파일로 저장할 수 있게 바이트화
            byte[] bytes = System.Text.Encoding.UTF8.GetBytes(jsonData);
 
            //bytes의 내용물을 0 ~ max 길이까지 fs에 복사
            fs.Write(bytes, 0, bytes.Length);
        }
    }
 
    //파일 불러오기
    static string LoadFile(string path)
    {
        using (FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read))
        {
            //파일을 바이트화 했을 때 담을 변수를 제작
            byte[] bytes = new byte[(int)fs.Length];
 
            //파일스트림으로 부터 바이트 추출
            fs.Read(bytes, 0, (int)fs.Length);
 
            //추출한 바이트를 json string으로 인코딩
            string jsonString = System.Text.Encoding.UTF8.GetString(bytes);
            return jsonString;
        }
    }
 
    ////////////////////////////////////////////////////////////////////////////////////////
 
    private static string Encrypt(string data)
    {
 
        byte[] bytes = System.Text.Encoding.UTF8.GetBytes(data);
        RijndaelManaged rm = CreateRijndaelManaged();
        ICryptoTransform ct = rm.CreateEncryptor();
        byte[] results = ct.TransformFinalBlock(bytes, 0, bytes.Length);
        return System.Convert.ToBase64String(results, 0, results.Length);
 
    }
 
    private static string Decrypt(string data)
    {
 
        byte[] bytes = System.Convert.FromBase64String(data);
        RijndaelManaged rm = CreateRijndaelManaged();
        ICryptoTransform ct = rm.CreateDecryptor();
        byte[] resultArray = ct.TransformFinalBlock(bytes, 0, bytes.Length);
        return System.Text.Encoding.UTF8.GetString(resultArray);
    }
 
 
    private static RijndaelManaged CreateRijndaelManaged()
    {
        byte[] keyArray = System.Text.Encoding.UTF8.GetBytes(privateKey);
        RijndaelManaged result = new RijndaelManaged();
 
        byte[] newKeysArray = new byte[16];
        System.Array.Copy(keyArray, 0, newKeysArray, 016);
 
        result.Key = newKeysArray;
        result.Mode = CipherMode.ECB;
        result.Padding = PaddingMode.PKCS7;
        return result;
    }
 
    ////////////////////////////////////////////////////////////////////////////////////////
 
    //저장할 주소를 반환
    static string GetPath()
    {
        return Path.Combine(Application.persistentDataPath, "save.abcd");
    }
 
}
cs



참고로 암호화 부분은 https://gist.github.com/TarasOsiris/9020497 이곳을 참고해 만들었다.


외부에서 쓰는 public 함수는 Save와 Load밖에 없다.

그 밑의 함수들은 모두 Save와 Load 안에서 실행될 뿐인 함수들이다.


암호화는 많은 공부가 필요한데 간략히만 설명하도록 하겠다.



일단 RijndaelManaged 클래스가 뭔지부터 알아야 하는데, 암호화와 관련된 것들을 총괄해주는 클래스이다.


코드의 123번째 줄부터 보면 설정을 해주는데 각각의 의미를 아주 간단하게 말하자면 아래와 같다.

Key : 암호화와 해석에 필요한 키. 외부로 유출되면 안된다.

Mode : 암호화 방식. 여기서 ECB는 key를 이용한 가장 간단한 암호화 방식이라고 할 수 있다.

padding : 데이터가 전체 암호화에 필요한 바이트보다 짧을 때 남은 바이트를 채워주는 방식을 설정한다.


이 RijndaelManaged로부터 암호화를 시켜주는 Encryptor와 암호화를 풀어주는 Decryptor를 만들 수 있다.



참고로 Application.persistentDataPath는 윈도우10 기준으로 아래와 같다.

C:\Users\(사용자이름)\AppData\LocalLow\(회사이름)\(유니티 프로젝트 이름)



위의 암호화 방식 없이 파일로 저장하면, 그 파일을 메모장으로 열었을 때 저장한 데이터가 고스란히 나오지만,

암호화를 한 파일을 연다면 이상한 문자들이 나오는 것을 알 수 있을 것이다.



오브젝트 풀링 기법은 유니티 게임 프로그래밍을 배울 때 가장 먼저 배우는 기법들 중 하나일 것이다.


일단 오브젝트 풀링이 왜 필요하고 정체가 무엇인지 예시를 들어 설명하겠다.


Strikers 1945 같은 전투기 슈팅 게임을 만들 때, 유저는 화면에 적이 있든 없든 총알 발사 버튼을 마구잡이로 누르게 된다.

이 때 총알 오브젝트를 Instantiate, Destroy 함수를 이용해서 생성과 소멸을 끊임없이 반복한다면 어떻게 될까?




1. 메모리 단편화(Memory fragmentation) 문제의 발생


바로 메모리 단편화(=메모리 파편화)라는 문제가 생기게 된다. 

메모리 단편화에는 외부 메모리 단편화내부 메모리 단편화가 있는데, 위의 상황에서는 외부 메모리 단편화가 발생한다..


외부 메모리 단편화란, 메모리에 빈 공간이 충분함에도 불구하고 빈 공간들이 따로 떨어져 있어서, 새로운 오브젝트를 생성하지 못하는 현상을 말한다.


아래 그림을 보면 더 쉽게 이해가 된다.


메모리 공간



위 그림의 상황 : 유니티에서 Instantiate 함수를 이용해서 오브젝트1 ~ 오브젝트4 에게 공간을 할당해줬다.



외부 메모리 단편화 상황



게임 진행을 하다보니 오브젝트2가 필요가 없어져서 Destroy함수를 이용해서 없애줬다.

그리고서 새로운 오브젝트5를 Instantiate함수로 생성하려고 했는데, 오브젝트5에게 할당해줄 알맞은 메모리 공간이 없다!

빈 공간 3개를 합치면 오브젝트5에게 할당해줄 연속된 공간이 충분할텐데, 빈 공간이 따로 떨어져있다보니 오브젝트5 생성에 문제가 생긴 것이다.


이렇게 Instantiate와 Destroy를 계속 사용하다보면 빈 공간이 우후죽순 생기게 되고, 메모리가 효율적으로 관리되지 못한다.

이런 상황을 외부 메모리 단편화라고 부른다.




2. 유니티는 메모리 단편화를 해결하지 못한다

보통의 C#에서는 가비지컬렉터(GC)가 메모리 단편화를 Compaction 해서 해결한다.
Compaction은 한글로 번역하면 압밀이라고 하는데, 압축, 밀착이라는 의미이다.

Compaction 진행 후. 이제 오브젝트 5가 들어갈 곳이 생겼다.


위의 그림과 같이, Compaction이 진행되면 오브젝트들의 메모리 공간을 좌로 밀착시키고, 빈 공간을 이어지게 해서 오브젝트5에게 메모리 공간을 할당해줄 수 있게 되었다.

하지만, 유니티의 GC는 Compaction기능을 지원하지 않는다.(출처 : https://docs.unity3d.com/kr/current/Manual/BestPracticeUnderstandingPerformanceInUnity4-1.html)


그렇기 때문에 Instantiate와 Destroy를 자주 해서 빈공간이 우후죽순 생기게 되면, 나중에 오브젝트 5처럼 거대한 객체가 만들어졌을 때 들어갈 공간이 없으니 메모리 점유를 늘리게 되고, 결과적으로는 비효율적인 메모리 운용이 생길 수 밖에 없다. 최악의 경우에는 유니티가 종료되거나 컴퓨터가 다운될 수도 있다.


그래서 Instantiate(혹은 new)와 Destroy의 사용을 최대한 자제하는게 유니티 메모리 최적화의 핵심이다.




3. 기초적인 오브젝트 풀링(Object Pooling)

그럼 아까 상황으로 돌아와서, Strikers 1945같은 전투기 슈팅게임을 만든다고 해보자. 버튼을 누를 때마다 총알이 나가는데, 이때마다 Instantiate를 호출해서 총알을 생성하고, 총알이 적에게 닿거나 맵 밖으로 나가면 Destroy로 총알을 삭제한다면? 무수한 빈 공간이 생길 것이다.

물론 아까 생성한 총알과 똑같은 용량의 총알이 또 생성된다면, 예전에 생성되었던 총알이 Destroy된 후 생겨난 빈 공간을 새로운 총알에게 할당해줄 수도 있을 것이다. 하지만 파워업 아이템을 먹어서 총알에 이펙트나 효과음이 추가되어서 용량이 커지게 된다면? 위의 그림들에서 오브젝트2가 삭제된 후 남겨진 빈 공간에 오브젝트5가 못 들어가는 것과 같은 상황이 발생할 것이다.

그래서 적당히 수십발의 총알을 미리 생성해놓고, 적에게 총알이 닿거나 맵 밖으로 총알이 나가면 SetActive(false)를 해서 안보이게 한 후, 나중에 총알을 재사용하는 방법을 오브젝트 풀링이라고 한다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
 
//전투기 클래스
public class Flight : MonoBehaviour
{
    //총알 프리팹
    public Bullet prefab_bullet;
 
    //총알 Pool
    private List<Bullet> bulletPool = new List<Bullet>();
 
    //내가 생성할 총알 갯수
    private readonly int bulletMaxCount = 10;
 
    //현재 장전된 총알의 인덱스
    private int curBulletIndex = 0;
 
    void Start()
    {
        //총알 10개 미리 생성
        for(int i = 0; i < bulletMaxCount; ++i)
        {
            Bullet b = Instantiate<Bullet>(prefab_bullet);
            
            //총알 발사하기 전까지는 비활성화 해준다.
            b.gameObject.SetActive(false);
 
            bulletPool.Add(b);
        }
    }
 
    void Update()
    {
        FireBulet();
    }
 
    //총알 발사
    void FireBulet()
    {
        //마우스 좌클릭 할 때마다 총알 발사
        if (Input.GetMouseButtonDown(0))
        {
            //발사되어야할 순번의 총알이 이전에 발사한 후로 아직 날아가고 있는 중이라면, 발사를 못하게 한다.
            if(bulletPool[curBulletIndex].gameObject.activeSelf)
            {
                return;
            }
 
            //총알 초기 위치는 전투기랑 같게
            bulletPool[curBulletIndex].transform.position = this.transform.position;
 
            //총알 활성화 해주기
            bulletPool[curBulletIndex].gameObject.SetActive(true);
 
            //방금 9번째 총알을 발사했다면 다시 0번째 총알을 발사할 준비를 한다.
            if (curBulletIndex >= bulletMaxCount - 1)
            {
                curBulletIndex = 0;
            }
            else
            {
                curBulletIndex++;
            }
        }
    }
}
cs


총알의 이동함수, 적에게 닿거나 맵 밖으로 나갔을 때 SetActive(false) 해주는 부분은 굳이 구현하지는 않겠다.

총알을 여러개 만들어서 리스트에 담아주고, 그것들을 필요할 때 빼서 쓰는 것이 기본 골자이다.


안쓰는 총알용 List와 Active된 총알용 List를 따로 만들어줘서, 총알을 발사하면 이 리스트에서 저 리스트에 넣어주고 이런식으로 관리를 해주는 방법도 있다.

또 위의 예제에서는 bulletPool[curBulletIndex] 이런식으로 List를 직접 가져다 썼지만, 총알 종류가 여러개라서 바꿔 쏠 수 있는 게임이라면, List를 여러개 만들어준 후 Get 함수를 만들어서 현재 유저가 설정한 총기 종류에 따라서 다른 List의 총알을 리턴해주는 방식도 있을 것이다.





오브젝트 풀링은 위에서 말했던 것처럼 메모리 단편화 문제 때문에도 쓰이고, 

애당초 Instantiate와 Destroy 함수 자체가 비용이 큰 함수이기 때문에 런타임때 자주 써주는건 좋지 못하다.


오브젝트 풀링의 안 좋은 점이라면, 내가 총알을 발사할 필요가 없을 때에도 총알이 메모리에 존재한다는 것인데, 총알의 max 갯수는 테스트를 통해 적당한 수치를 찾는게 좋을 것이다.

+ Recent posts