[개발 일지 #3] 구글 시트 API 연동, 유니티 ScriptableObject화(2)






다음 과정을 하기 위해 먼저 Package Manager에서 Newtonsoft Json 을 설치해야 됩니다.


viewimage.php?id=2abcdd23dad63db0&no=24b0d769e1d32ca73ce787fa1bd625314b5d207020df860bbe98c96cc2c374c8745682db0af47f9267b8f4bf82099ee95288212f384a0545885634131c76c3c2d211d351


다음 에셋을 설치해주면 됩니다. 


그 이후  

using UnityEngine;

using UnityEditor;

using System.IO;

using System.Collections.Generic;

using Newtonsoft.Json;


public class DataBaker : EditorWindow

{

    // 데이터가 저장될 기본 경로 (폴더가 없으면 에러가 날 수 있으니 미리 생성해두는 것을 권장)

    private const string JSON_PATH = "Assets/Resources/Data/";

    private const string SO_PATH = "Assets/Resources/Data/";


    [MenuItem("Data/Bake All JSON to SO (Dynamic)")]

    public static void BakeAllData()

    {

        BakeNodesData();

        // Localization은 여러 시트를 하나의 SO에 합치거나 각각 나눌 수 있습니다. 여기서는 분리하는 방식을 씁니다.

        BakeLocalizationData("Localization_Dialogue");

        BakeLocalizationData("Localization_UI");

        BakeLocalizationData("Localization_Record");

        BakeSelectNodesData();

        

        AssetDatabase.SaveAssets();

        AssetDatabase.Refresh();

        Debug.Log("<color=green>모든 데이터 베이킹 완료!</color>");

    }


    // --- 1. Nodes 데이터 베이킹 ---

    private static void BakeNodesData()

    {

        var response = LoadJson("Nodes");

        if (response == null) return;


        var headerMap = CreateHeaderMap(response.values[0]);

        var database = GetOrCreateSO<NodeDatabaseSO>("NodeDatabase");

        database.nodes.Clear();


        for (int i = 1; i < response.values.Count; i++)

        {

            var row = response.values[i];

            if (IsEmptyRow(row) || string.IsNullOrEmpty(GetValue(row, headerMap, "nodeId"))) continue;


            NodeData newNode = new NodeData

            {

                nodeId = GetValue(row, headerMap, "nodeId"),

                chapterId = GetValue(row, headerMap, "chapterId"),

                sessionId = GetValue(row, headerMap, "sessionId"),

                speakerId = GetValue(row, headerMap, "speakerId"),

                nodeType = GetValue(row, headerMap, "nodeType"),

                textKey = GetValue(row, headerMap, "textKey"),

                nodeStartEvent = GetValue(row, headerMap, "nodeStartEvent"),

                EventTiger = GetValue(row, headerMap, "EventTiger"),

                autoNextNodeId = GetValue(row, headerMap, "autoNextNodeId"),

                selectNodeId = GetValue(row, headerMap, "selectNodeId")

            };

            database.nodes.Add(newNode);

        }

        EditorUtility.SetDirty(database);

        Debug.Log($"Nodes 베이킹 완료 ({database.nodes.Count}개)");

    }

    private static void BakeLocalizationData(string sheetName)

    {

        var response = LoadJson(sheetName);

        if (response == null) return;


        var headerMap = CreateHeaderMap(response.values[0]);

        // 파일명을 시트명과 동일하게 생성 (예: Localization_Dialogue.asset)

        var database = GetOrCreateSO<LocalizationDatabaseSO>(sheetName);

        database.localizedTexts.Clear();


        for (int i = 1; i < response.values.Count; i++)

        {

            var row = response.values[i];

            if (IsEmptyRow(row) || string.IsNullOrEmpty(GetValue(row, headerMap, "textKey"))) continue;


            LocalizeData newData = new LocalizeData

            {

                textKey = GetValue(row, headerMap, "textKey"),

                ko = GetValue(row, headerMap, "ko"),

                En = GetValue(row, headerMap, "En"),

                Jp = GetValue(row, headerMap, "Jp"),

                Note = GetValue(row, headerMap, "Note")

            };

            database.localizedTexts.Add(newData);

        }

        EditorUtility.SetDirty(database);

        Debug.Log($"{sheetName} 베이킹 완료 ({database.localizedTexts.Count}개)");

    }

    private static void BakeSelectNodesData()

    {

        var response = LoadJson("SelectNodes");

        // SelectNodes 파일이 아직 없다면 패스

        if (response == null) return; 


        var headerMap = CreateHeaderMap(response.values[0]);

        var database = GetOrCreateSO<SelectNodeDatabaseSO>("SelectNodeDatabase");

        database.choices.Clear();


        for (int i = 1; i < response.values.Count; i++)

        {

            var row = response.values[i];

            if (IsEmptyRow(row) || string.IsNullOrEmpty(GetValue(row, headerMap, "selectGroupId"))) continue;


            SelectNodeData newData = new SelectNodeData

            {

                selectGroupId = GetValue(row, headerMap, "selectGroupId"),

                choiceId = GetValue(row, headerMap, "choiceId"),

                textKey = GetValue(row, headerMap, "textKey"),

                nextNodeId = GetValue(row, headerMap, "nextNodeId"),

                condition = GetValue(row, headerMap, "condition")

            };

            database.choices.Add(newData);

        }

        EditorUtility.SetDirty(database);

        Debug.Log($"SelectNodes 베이킹 완료 ({database.choices.Count}개)");

    }

    private static GoogleSheetResponse LoadJson(string fileName)

    {

        string fullPath = Path.Combine(Application.dataPath.Replace("Assets", ""), JSON_PATH, fileName + ".json");

        if (!File.Exists(fullPath))

        {

            Debug.LogWarning($"[DataBaker] {fileName}.json 파일을 찾을 수 없습니다.");

            return null;

        }

        string jsonText = File.ReadAllText(fullPath);

        return JsonConvert.DeserializeObject<GoogleSheetResponse>(jsonText);

    }


    private static Dictionary<string, int> CreateHeaderMap(List<string> headers)

    {

        var map = new Dictionary<string, int>();

        for (int i = 0; i < headers.Count; i++)

        {

            string headerName = headers[i].Trim();

            if (!string.IsNullOrEmpty(headerName) && !map.ContainsKey(headerName))

                map.Add(headerName, i);

        }

        return map;

    }


    private static string GetValue(List<string> row, Dictionary<string, int> headerMap, string headerName)

    {

        if (headerMap.TryGetValue(headerName, out int index) && index < row.Count)

            return row[index];

        return string.Empty;

    }


    private static T GetOrCreateSO<T>(string fileName) where T : ScriptableObject

    {

        string fullPath = SO_PATH + fileName + ".asset";

        T asset = AssetDatabase.LoadAssetAtPath<T>(fullPath);

        if (asset == null)

        {

            asset = ScriptableObject.CreateInstance<T>();

            AssetDatabase.CreateAsset(asset, fullPath);

        }

        return asset;

    }


    private static bool IsEmptyRow(List<string> row)

    {

        return row == null || row.Count == 0;

    }

}


다음 코드를 Editor폴더에 저장해줍니다 


그리고 순차적으로 다음 스크립트들을 Assets/Scripts/Data/ 안에 생성해 둡니다. 


using System.Collections.Generic;


[System.Serializable]

public class GoogleSheetResponse

{

    public string range;

    public string majorDimension;

    // 실제 데이터가 담기는 2차원 리스트 [행][열]

    public List<List<string>> values; 

}


이 밑에는 실질적인 데이터를 어떻게 ScriptableObject에 집어 넣을 것 인지를 정합니다. 


using System.Collections.Generic;

using UnityEngine;


[System.Serializable]

public class LocalizeData

{

    public string textKey;

    public string ko;

    public string En;

    public string Jp;

    public string Note;

}


[CreateAssetMenu(fileName = "LocalizationDatabase", menuName = "Data/LocalizationDatabase")]

public class LocalizationDatabaseSO : ScriptableObject

{

    public List<LocalizeData> localizedTexts = new List<LocalizeData>();

    private Dictionary<string, LocalizeData> textDict;


    public void Initialize()

    {

        textDict = new Dictionary<string, LocalizeData>();

        foreach (var text in localizedTexts)

        {

            if (!string.IsNullOrEmpty(text.textKey) && !textDict.ContainsKey(text.textKey))

                textDict.Add(text.textKey, text);

        }

    }

    public LocalizeData GetTextData(string key) => (textDict != null && textDict.TryGetValue(key, out var data)) ? data : null;

}


*ScriptableObject화 코드는 시트 내용을 각 열에 맞게 채우는 과정이라 1가지만 첨부했습니다.


해당 코드를 작성하게 되면 유니티 에디터 상단의 Data -> Bake ALL Json to SO 버튼을 누르게 되면



viewimage.php?id=2abcdd23dad63db0&no=24b0d769e1d32ca73ce787fa1bd625314b5d207020df860bbe98c96cc2c374c8745682db0af47f9267b8f4bfef679ae37273b077cdc7a0b384acacc135bc0d50ccea3ae0b5


다음과 같이 모든 데이터가 ScriptableObject로 만들어지게 됩니다.


이상으로 시트 데이터 api연결 -> Json 파일 변환 -> ScriptableObject화 해주는 (임시) 정규화 파이프라인 구축이 끝났습니다.


다음으로는 해당 데이터를 유니티 씬에 출력하는 로직을 작성해보겠습니다.