본문 바로가기
Unity

유니티 시스템 프로그래밍 Pt.1 - 7~8 주차

by argentdarae 2024. 11. 8.

개요

7~8주차 학습을 진행하였다

 

학습 내용은 다음과 같다

  • 모델링 된 데이터를 CSV로 어떻게 관리/사용 하는지 학습
  • GPM을 사용한 무한 스크롤 세팅

 

내용

5. Lobby 시스템 제작

유저 인벤토리 시스템

모델링 된 데이터를 CSV로 관리/사용하는법

모델링은 5자리의 숫자를 이용, 각 자릿수를 무기 종류 및 등급 등으로 분류하였고

이 데이터를 게임 내에서 추출하여 필요한 동작을 구현하였다

 

예를들어 다음과 같은 방법으로 등급을 추출하고 사용한다

var itemGrade = (ItemGrade)(m_InventoryItemSlotData.ItemId / 1000 % 10);
var gradeBgTexture = Resources.Load<Texture2D>($"Textures/{itemGrade}");

 

진행하다보니, 한 번도 주도적으로 제대로된 데이터 모델링을 진행해본적이 없다는 사실을 깨달았는데...

그러다보니 여러 자료를 검색해서 찾아보았고

데이터 모델링 개념 & ERD 다이어그램 작성 💯 총정리 라는 잘 정리된 자료를 찾았다. 그 외에 SQL 관련 강의도 하나 구매해 둔 게 있는데, 이 스터디가 끝나면 바로 공부해보고 싶다

 

 

GPM을 사용한 무한 스크롤 세팅

GPM은 NHN에서 제공하는 게임 제작에 필요한 라이브러리 묶음 이다

 

강사는 이 중 무한 스크롤 모듈을 사용하였다. 그리고 나 역시 강의를 들으면서 느꼈지만

라이브러리에서 제공하는 사용법을 토대로, UpdateData만 재정의하여 구현하였다

잘 만들어진 라이브러리를 사용하는 것은 개발 생산성, 유지보수성, 코드 품질 등 여러 측면에서 큰 장점을 제공하는 것 같다

 

코드 제안사항

InventoryUI에서 case마다 람다문으로 매번 계산을 수행하는데, 이를 함수 객체로 정의하여 캐싱하고 사용하는 편이 좋아보인다

성능 최적화에도 유리할 것이며, 가독성도 올라갈 것으로 보인다

// 정렬 함수 캐싱
private readonly Dictionary<InventorySortType, Comparison<InfiniteScroll.DataContext>> sortComparisons;

private void Awake()
{
    // 정렬 기준을 초기화하여 각 InventorySortType에 해당하는 비교 함수 저장
    sortComparisons = new Dictionary<InventorySortType, Comparison<InfiniteScroll.DataContext>>
    {
        { InventorySortType.ItemGrade, CompareByGrade },
        { InventorySortType.ItemType, CompareByType }
    };
}

private void SortInventory()
{
    SortBtnTxt.text = m_InventorySortType.ToString().ToUpper();

    // Dictionary에서 해당 정렬 함수를 가져와 사용
    InventoryScrollList.SortDataList(sortComparisons[m_InventorySortType]);
}

private int CompareByGrade(InfiniteScroll.DataContext a, InfiniteScroll.DataContext b)
{
    var itemA = a.data as InventoryItemSlotData;
    var itemB = b.data as InventoryItemSlotData;

    int gradeA = GetItemGrade(itemA.ItemId);
    int gradeB = GetItemGrade(itemB.ItemId);

    int compareResult = gradeB.CompareTo(gradeA); // 내림차순으로 정렬

    // 같은 등급일 경우 무기 종류로 정렬
    return compareResult != 0 ? compareResult : CompareByType(a, b);
}

private int CompareByType(InfiniteScroll.DataContext a, InfiniteScroll.DataContext b)
{
    var itemA = a.data as InventoryItemSlotData;
    var itemB = b.data as InventoryItemSlotData;

    int typeA = GetWeaponType(itemA.ItemId);
    int typeB = GetWeaponType(itemB.ItemId);

    int compareResult = typeA.CompareTo(typeB); // 오름차순으로 정렬

    // 같은 무기 종류일 경우 등급으로 정렬
    return compareResult != 0 ? compareResult : CompareByGrade(a, b);
}

 

그리고, 매번 형 변환이 이루어지는데 인자의 타입을 확정해놓고 사용하는 것이 좋아보인다

private int CompareByGrade(InventoryItemSlotData itemA, InventoryItemSlotData itemB)
{
    int gradeA = GetItemGrade(itemA.ItemId);
    int gradeB = GetItemGrade(itemB.ItemId);

    int compareResult = gradeB.CompareTo(gradeA); // 내림차순으로 정렬

    // 같은 등급일 경우 무기 종류로 정렬
    return compareResult != 0 ? compareResult : CompareByType(itemA, itemB);
}

private int CompareByType(InventoryItemSlotData itemA, InventoryItemSlotData itemB)
{
    int typeA = GetWeaponType(itemA.ItemId);
    int typeB = GetWeaponType(itemB.ItemId);

    int compareResult = typeA.CompareTo(typeB); // 오름차순으로 정렬

    // 같은 무기 종류일 경우 등급으로 정렬
    return compareResult != 0 ? compareResult : CompareByGrade(itemA, itemB);
}

 

또한 사소한 부분이지만, 객체는 생성되는 시점에 유효하여야 한다

// Before
var itemSlotData = new InventoryItemSlotData();
itemSlotData.SerialNumber = itemData.SerialNumber;
itemSlotData.ItemId = itemData.ItemId;

// After
var itemSlotData = new InventoryItemSlotData
{
    SerialNumber = itemData.SerialNumber,
    ItemId = itemData.ItemId
};

 

 

보안

UserInventoryData에서 PlayerPref로 유저 데이터를 Json 형태로 통으로 저장해버리는데, 데이터를 그대로 저장하는 것이 위험해보였다

 

그래서 간단하게 적용해 볼 보안 알고리즘을 찾게 되었는데

AES 알고리즘을 발견하고 적용해보았다

 

간략하게 AES를 설명하자면

2001년부터 미국 정부와 전 세계에서 표준으로 채택된 강력한 암호화 알고리즘이며 민감한 데이터를 안전하게 보호하는 데 적합하다.

128, 192, 256비트 키 길이를 지원하며, 키 길이에 따라 암호화 강도가 달라진다. AES 암호화 과정은 대칭 키 암호화 방식이다

 

암호화 과정은 데이터를 고정 블록 크기로 나누어, 각 블록에 여러 수학적 변환을 적용한다. AES 암호화 과정은 여러 단계로 이루어지며, 각 단계를 거쳐 데이터를 더 복잡하게 변환한다. 자세한 설명은 다음의 링크로 대체한다

 

public static class AESHelper
{
    private const string ENCRYPTION_KEY = "UnitySafeKey2023";
    private const string IV_KEY = "AES_IV";

    public static string Encrypt(string plainText)
    {
        byte[] keyBytes = Encoding.UTF8.GetBytes(ENCRYPTION_KEY);
        byte[] iv = GenerateRandomIv();
        
        // IV를 Base64로 저장하여 복호화 시 사용
        PlayerPrefs.SetString(IV_KEY, Convert.ToBase64String(iv));

        using Aes aes = Aes.Create();
        aes.Key = keyBytes;
        aes.IV = iv;
        aes.Mode = CipherMode.CBC;
        aes.Padding = PaddingMode.PKCS7;

        ICryptoTransform encryptor = aes.CreateEncryptor(aes.Key, aes.IV);
        byte[] plainBytes = Encoding.UTF8.GetBytes(plainText);

        byte[] encryptedBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
        return Convert.ToBase64String(encryptedBytes);
    }

    public static string Decrypt(string encryptedText)
    {
        byte[] keyBytes = Encoding.UTF8.GetBytes(ENCRYPTION_KEY);

        // 저장된 IV를 불러오기
        string ivBase64 = PlayerPrefs.GetString(IV_KEY);
        byte[] iv = Convert.FromBase64String(ivBase64);

        using Aes aes = Aes.Create();
        aes.Key = keyBytes;
        aes.IV = iv;
        aes.Mode = CipherMode.CBC;
        aes.Padding = PaddingMode.PKCS7;

        ICryptoTransform decryptor = aes.CreateDecryptor(aes.Key, aes.IV);
        byte[] encryptedBytes = Convert.FromBase64String(encryptedText);

        byte[] decryptedBytes = decryptor.TransformFinalBlock(encryptedBytes, 0, encryptedBytes.Length);
        return Encoding.UTF8.GetString(decryptedBytes);
    }

    private static byte[] GenerateRandomIv()
    {
        byte[] iv = new byte[16]; // AES는 16바이트 IV를 사용
        using var rng = new RNGCryptoServiceProvider();
        rng.GetBytes(iv);
        
        return iv;
    }
}

// UserInventoryData...

public bool LoadData()
{
    Logger.Log($"{GetType()}::LoadData");

    bool result = false;

    try
    {
        string encryptedData = PlayerPrefs.GetString("InventoryItemDataList");
        if (!string.IsNullOrEmpty(encryptedData))
        {
            Debug.Log($"load raw data: {encryptedData}");
            string inventoryItemDataListJson = AESHelper.Decrypt(encryptedData); // 복호화
            Debug.Log($"inventoryItemDataListJson: {inventoryItemDataListJson}");
            UserInventoryItemDataListWrapper itemDataListWrapper = JsonUtility.FromJson<UserInventoryItemDataListWrapper>(inventoryItemDataListJson);
            InventoryItemDataList = itemDataListWrapper.InventoryItemDataList;
        }

        result = true;
    }
    catch (Exception e)
    {
        Logger.Log($"Load failed (" + e.Message + ")");
    }

    return result;
}

public bool SaveData()
{
    Logger.Log($"{GetType()}::SaveData");

    bool result = false;

    try
    {
        UserInventoryItemDataListWrapper itemDataListWrapper = new UserInventoryItemDataListWrapper();
        itemDataListWrapper.InventoryItemDataList = InventoryItemDataList;
        string inventoryItemDataListJson = JsonUtility.ToJson(itemDataListWrapper);

        Debug.Log($"save raw data: {inventoryItemDataListJson}");
        string encryptedData = AESHelper.Encrypt(inventoryItemDataListJson); // 암호화
        Debug.Log($"encryptedData: {encryptedData}");
        PlayerPrefs.SetString("InventoryItemDataList", encryptedData);

        result = true;
    }
    catch (Exception e)
    {
        Logger.Log($"Save failed (" + e.Message + ")");
    }

    return result;
}

 

C#에 존재하는 Aes 클래스를 이용하여 구현하였다

특히 여기서, iv의 경우 별도의 모듈에 저장한 뒤 가져오는 형태로 만들면 여러 암호키를 조합하는 형태가 되어 높은 수준의 보안을 만들 수 있을 것으로 보인다

 

실행 결과는 다음과 같다

 

 

실제로 디바이스에 저장되는 형태는 encryptedData가 되어 해커의 입장에서는 의미 불명의 데이터가 되는 것이다

 

 

마무리

후반부 이미 구현된 AES 클래스를 사용하니까 너무 쉬워서 직접 구현해보다가 머리가 깨지고 다시 AES 클래스를 사용하는 방법으로 돌아왔는데... 과정에서 배운 게 많다

특히 PKCS5, 7은 무엇인지, 블록 암호 운용 방식이 무엇인지 알게되었다. 나중에 보안 관련된 공부를 하게 된다면 다시 재도전하여 구현해보고 싶다

 

또한 GPM을 사용하고 느낀바와 결이 같은데, 이미 만들어진 라이브러리를 잘 쓰자. 꼭 필요한 경우에만 구현하여 사용하자. 시간과 정신 건강 모두를 챙겨준다!

 

단순히 머릿속에 입력하는 행위는 의미가 없고 실제로 사용을 해야한다는 주의라 언제 공부하게 될지는 모르겠지만, 주도적으로 해당 업무를 처리해야 할 일이 생긴다면 즐겁게 공부할 수 있을 것 같다

 

최근 일이 바쁜 와중 시간내어 머릿속에 지식을 넣기 굉장히 쉽지 않은데, 경험치 이벤트라 생각하고 잘 극복해보겠다

화이팅!