Unity

Unity와 Google 스프레드시트 연동 및 데이터 파싱하기 : 완벽 가이드

Wally's 2024. 10. 9. 21:52

개발에서 중요한 것은 데이터를 어떻게 관리하고 유지할 것인가입니다.

엑셀로 사용하는 방법도 있지만,

데이터를 효율적으로 관리하고 업데이트하는 방법 중 하나는 Google 스프레드시트를 활용하는 것입니다. 

 

이 블로그에서는 Unity Editor Window를 활용해 스프레드시트 데이터를 불러오고 JSON 파일로 저장하거나, 자동으로 C# 클래스를 생성하는 도구를 만드는 방법을 다룹니다.

 


목차

  1. 왜 Google 스프레드시트인가?
  2. Unity에서 Google 스프레드시트 데이터를 읽는 방법
  3. 스프레드시트 데이터 파싱 코드 
  4. 코드 설명

 

1. 왜 Google 스프레드시트인가?

Google 스프레드시트는 무료로 제공되며, 클라우드 기반으로 데이터를 관리할 수 있는 매우 유용한 툴입니다. 여러 개발자나 기획자가 데이터를 동시에 관리할 수 있으며, 실시간으로 데이터를 업데이트할 수 있는 장점이 있습니다. 특히, 게임 개발에서 아이템 정보나 설정값을 관리하는데 적합합니다.

  • 실시간 데이터 관리: 데이터를 실시간으로 변경하고 즉시 반영할 수 있습니다.
  • 다중 사용자 협업: 여러 명이 동시에 데이터를 관리하고 수정할 수 있습니다.
  • 손쉬운 접근성: 어디서나 브라우저로 접속하여 데이터를 확인하고 수정할 수 있습니다.

 

2. Unity에서 Google 스프레드시트 데이터를 읽는 방법

Unity와 Google 스프레드시트를 연동하려면 스프레드시트 데이터를 API로 받아와야 합니다.

Google 스프레드시트의 데이터를 읽기 위한 API를 만들고, Unity에서 활용하는 방식으로 구현할 수 있습니다.

 

2.1. Google 스프레드시트 API 설정하기

먼저 Google 스프레드시트의 데이터를 외부에서 접근할 수 있도록 API 설정이 필요합니다.

  1. Google 스프레드시트 공유 설정: 스프레드시트 파일에서 "공유" 버튼을 클릭하고, "링크를 가진 모든 사용자가 보기"로 설정합니다.
  2. Google Apps Script 생성: Google Apps Script를 통해 스프레드시트를 JSON 형태로 내보내기 위한 API를 생성합니다. 다음과 같은 코드를 사용해 간단한 Google Apps Script를 작성할 수 있습니다.
  3. Web 앱으로 배포: Google Apps Script의 "배포" 메뉴에서 "웹 앱으로 배포"를 선택하고 URL을 얻습니다. 이 URL을 통해 스프레드시트의 데이터를 JSON 형식으로 외부에서 접근할 수 있습니다.
function doGet() {
  // Google 스프레드시트의 고유 ID: URL에서 /d/와 /edit 사이의 문자열을 넣습니다.
  var spreadsheet = SpreadsheetApp.openById('** Google 스프레드시트의 고유 ID 문자열 정보 **');
  var sheets = spreadsheet.getSheets();
  
  var result = [];
  
  for (var i = 0; i < sheets.length; i++) {
    var sheet = sheets[i];
    result.push({
      "sheetName": sheet.getName(),
      "sheetId": sheet.getSheetId()
    });
  }
  
  // JSON 배열을 객체로 감싸서 반환
  var output = {
    "sheetData": result
  };
  
  return ContentService.createTextOutput(JSON.stringify(output)).setMimeType(ContentService.MimeType.JSON);
}

 

3. Unity 스프레드시트 데이터 파싱 코드 작성하기

Unity Editor에서 Google 스프레드시트 데이터를 불러오기 위해 Editor Window를 사용합니다.

Editor Window는 커스텀 툴을 제작할 수 있는 강력한 기능을 제공하며,

Unity 내에서 API를 통해 데이터를 가져오고 이를 JSON으로 저장하거나 C# 클래스로 변환할 수 있습니다.

 

코드 개요

  • SheetParsing 클래스는 Unity Editor에서 스프레드시트 데이터를 관리하는 도구입니다. 데이터를 API로 불러와 선택한 시트를 JSON 파일로 저장하거나, 시트의 구조를 자동으로 C# 클래스로 변환합니다.
  • 데이터를 가져오고 파싱하는 기능은 UnityWebRequest를 사용하며, 데이터를 비동기로 처리하기 위해 EditorCoroutine을 활용합니다
#if UNITY_EDITOR
using Newtonsoft.Json.Linq;
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using Unity.EditorCoroutines.Editor;
using Unity.Mathematics;
using UnityEditor;
using UnityEngine;
using UnityEngine.Networking;

public partial class SheetParsing : EditorWindow
{
    string sheetAPIurl = "** 배포 받은 url 링크 **";
    string sheeturl = "** 구글스프레드시트 링크 **";

    private List<SheetData> sheets = new List<SheetData>();
    private int selectedSheetIndex = 0;
    private bool isFetching = false;

    [MenuItem("Tools/Google Sheet Parsing Tool")]
    public static void ShowWindow()
    {
        EditorWindow window = GetWindow(typeof(SheetParsing));
        window.titleContent = new GUIContent("Google Sheet Parser");
        window.maxSize = new Vector2(600, 400);
        window.minSize = new Vector2(600, 400);
    }

    private void OnGUI()
    {
        GUILayout.Space(10);
        if (isFetching)
        {
            EditorGUILayout.LabelField("Fetching data...");
        }
        else
        {
            if (sheets.Count > 0)
            {
                string[] sheetNames = sheets.Select(s => s.sheetName).ToArray();
                selectedSheetIndex = EditorGUILayout.Popup("Select Sheet", selectedSheetIndex, sheetNames);
            }
            else
            {
                EditorGUILayout.LabelField("No sheets found.");
            }

            GUILayout.Space(20);
            if (GUILayout.Button("Fetch Sheets Data", GUILayout.Height(40)))
            {
                EditorCoroutineUtility.StartCoroutine(FetchSheetsData(), this);
            }
        }

        GUILayout.Space(30);
        if (GUILayout.Button("Parse Selected Sheet and Create Class", GUILayout.Height(40)))
        {
            if (sheets.Count > 0)
            {
                ParseSelectedSheet();
            }
            else
            {
                EditorUtility.DisplayDialog("Error", "Please fetch sheet names and select a sheet.", "OK");
            }
        }
    }

    private IEnumerator FetchSheetsData()
    {
        isFetching = true;
        UnityWebRequest request = UnityWebRequest.Get(sheetAPIurl);
        yield return request.SendWebRequest();

        if (request.result == UnityWebRequest.Result.Success)
        {
            ProcessSheetsData(request.downloadHandler.text);
        }
        else
        {
            Debug.LogError("Error fetching data: " + request.error);
        }

        isFetching = false;
        Repaint();
    }

    private void ProcessSheetsData(string json)
    {
        var sheetsData = JsonUtility.FromJson<SheetDataList>(json);
        sheets.Clear();
        sheets.AddRange(sheetsData.sheetData);

        if (sheets.Count > 0)
        {
            selectedSheetIndex = 0;
        }
    }

    private void ParseSelectedSheet()
    {
        var selectedSheet = sheets[selectedSheetIndex];
        string jsonFileName = RemoveSpecialCharacters(selectedSheet.sheetName);
        Debug.Log($"Selected Sheet: {selectedSheet.sheetName}, Sheet ID: {selectedSheet.sheetId}");

        EditorCoroutineUtility.StartCoroutine(ParseGoogleSheet(jsonFileName, selectedSheet.sheetId.ToString()), this);
    }

    private string RemoveSpecialCharacters(string sheetName)
    {
        return Regex.Replace(sheetName, @"[^a-zA-Z0-9\s]", "").Replace(" ", "_");
    }

    private IEnumerator ParseGoogleSheet(string jsonFileName, string gid, bool notice = true)
    {
        string sheetUrl = $"{sheeturl}/export?format=tsv&gid={gid}";

        UnityWebRequest request = UnityWebRequest.Get(sheetUrl);
        yield return request.SendWebRequest();

        if (request.result != UnityWebRequest.Result.Success)
        {
            EditorUtility.DisplayDialog("Fail", "GoogleConnect Fail!", "OK");
            yield break;
        }

        string data = request.downloadHandler.text;
        List<string> rows = ParseTSVData(data);

        if (rows == null || rows.Count < 4)
        {
            Debug.LogError("Not enough data rows to parse.");
            yield break;
        }

        HashSet<int> dbIgnoreColumns = GetDBIgnoreColumns(rows[0]);
        var keys = rows[1].Split('\t').ToList();
        var types = rows[2].Split('\t').ToList();

        JArray jArray = new JArray();
        for (int i = 3; i < rows.Count; i++)
        {
            var rowData = rows[i].Split('\t').ToList();

            // 첫 열이 DB_IGNORE라면 행 제외
            if (rowData[0].Equals("DB_IGNORE", StringComparison.OrdinalIgnoreCase))
            {
                Debug.Log($"Row {i + 1} ignored due to DB_IGNORE");
                continue;
            }

            var rowObject = ParseRow(keys, types, rowData, dbIgnoreColumns);
            if (rowObject != null)
            {
                jArray.Add(rowObject);
            }
        }

        SaveJsonToFile(jsonFileName, jArray);
        string className = CreateDataClass(jsonFileName, keys, types, dbIgnoreColumns);  // C# 클래스 생성

        if (notice)
        {
            EditorUtility.DisplayDialog("Success", "Sheet parsed and saved as JSON successfully!", "OK");
            AssetDatabase.Refresh();
        }
    }

    // TSV 데이터 파싱
    private List<string> ParseTSVData(string data)
    {
        return data.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries).ToList();
    }

    // DB_IGNORE 열 필터링
    private HashSet<int> GetDBIgnoreColumns(string headerRow)
    {
        var dbIgnoreColumns = new HashSet<int>();
        var firstRow = headerRow.Split('\t').ToList();

        for (int i = 0; i < firstRow.Count; i++)
        {
            if (firstRow[i].Equals("DB_IGNORE", StringComparison.OrdinalIgnoreCase))
            {
                dbIgnoreColumns.Add(i);
                Debug.Log($"Column {i + 1} ignored due to DB_IGNORE");
            }
        }

        return dbIgnoreColumns;
    }

    // 개별 행 파싱
    private JObject ParseRow(List<string> keys, List<string> types, List<string> rowData, HashSet<int> dbIgnoreColumns)
    {
        var rowObject = new JObject();

        for (int j = 0; j < keys.Count && j < rowData.Count; j++)
        {
            if (dbIgnoreColumns.Contains(j)) continue;

            string key = keys[j];
            string type = types[j];
            string value = rowData[j].Trim();

            if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(value)) continue;

            rowObject[key] = ConvertValue(value, type);
        }

        return rowObject.HasValues ? rowObject : null;
    }

    // 값을 적절한 형식으로 변환하는 메서드
    private JToken ConvertValue(string value, string type)
    {
        switch (type.Trim()) // 불필요한 공백 제거
        {
            case "int": return int.TryParse(value, out int intValue) ? intValue : 0;
            case "long": return long.TryParse(value, out long longValue) ? longValue : 0L;
            case "float": return float.TryParse(value, out float floatValue) ? floatValue : 0.0f;
            case "double": return double.TryParse(value, out double doubleValue) ? doubleValue : 0.0d;
            case "bool": return bool.TryParse(value, out bool boolValue) ? boolValue : false;
            case "byte": return byte.TryParse(value, out byte byteValue) ? byteValue : (byte)0;
            case "int[]": return JArray.FromObject(value.Split(',').Select(v => int.TryParse(v.Trim(), out int tempInt) ? tempInt : 0));
            case "float[]": return JArray.FromObject(value.Split(',').Select(v => float.TryParse(v.Trim(), out float tempFloat) ? tempFloat : 0.0f));
            case "string[]": return JArray.FromObject(value.Split(',').Select(v => v.Trim()));
            case "DateTime": return DateTime.TryParse(value, out DateTime dateTimeValue) ? dateTimeValue : DateTime.MinValue; // DateTime 변환
            case "TimeSpan": return TimeSpan.TryParse(value, out TimeSpan timeSpanValue) ? timeSpanValue : TimeSpan.Zero;
            case "Guid": return Guid.TryParse(value, out Guid guidValue) ? guidValue.ToString() : Guid.Empty.ToString();
            default: return value; // 기본적으로 문자열로 반환
        }
    }

    // JSON 파일 저장 메서드
    private void SaveJsonToFile(string jsonFileName, JArray jArray)
    {
        string directoryPath = Path.Combine(Application.dataPath, "Resources", "JsonFiles");

        // 폴더가 존재하지 않으면 생성
        if (!Directory.Exists(directoryPath))
        {
            Directory.CreateDirectory(directoryPath);
        }

        string jsonFilePath = Path.Combine(directoryPath, $"{jsonFileName}.json");

        File.WriteAllText(jsonFilePath, jArray.ToString());
        Debug.Log($"Saved JSON to: {jsonFilePath}");
    }

    // C# 클래스 생성 메서드
    private string CreateDataClass(string fileName, List<string> keys, List<string> types, HashSet<int> dbIgnoreColumns)
    {
        string className = fileName; // 파일 이름을 클래스 이름으로 사용
        string directoryPath = Path.Combine(Application.dataPath, "Resources/DataClass");

        // 폴더가 존재하지 않으면 생성
        if (!Directory.Exists(directoryPath))
        {
            Directory.CreateDirectory(directoryPath);
        }

        string dataClassPath = Path.Combine(directoryPath, $"{className}.cs");

        using (StreamWriter writer = new StreamWriter(dataClassPath))
        {
            writer.WriteLine("using System.Collections.Generic;");
            writer.WriteLine("[System.Serializable]");
            writer.WriteLine($"public class {className}");
            writer.WriteLine("{");

            // 클래스 필드 생성
            for (int i = 0; i < keys.Count; i++)
            {
                if (dbIgnoreColumns.Contains(i)) continue; // DB_IGNORE가 설정된 컬럼 건너뜀

                string fieldType = ConvertTypeToCSharp(types[i]);
                string fieldName = keys[i];

                // 필드명이 비어있지 않은지 확인
                if (!string.IsNullOrEmpty(fieldName))
                {
                    writer.WriteLine($"\tpublic {fieldType} {fieldName};");
                }
            }

            writer.WriteLine();

            // Dictionary 생성
            string keyType = ConvertTypeToCSharp(types[1]); // 첫 번째 컬럼을 Dictionary 키로 사용
            writer.WriteLine($"\tpublic static Dictionary<{keyType}, {className}> tableDic = new Dictionary<{keyType}, {className}>();");

            writer.WriteLine("}");
        }

        Debug.Log($"Saved C# class to: {dataClassPath}");
        AssetDatabase.Refresh(); // 새로 생성된 클래스를 에디터에서 인식하도록 리프레시

        return className; // 생성된 클래스 이름을 반환
    }

    private string ConvertTypeToCSharp(string type)
    {
        switch (type.Trim()) // 불필요한 공백 제거
        {
            case "int": return "int";
            case "long": return "long";
            case "float": return "float";
            case "double": return "double";
            case "bool": return "bool";
            case "byte": return "byte";
            case "int[]": return "int[]";
            case "float[]": return "float[]";
            case "string[]": return "string[]";
            case "DateTime": return "System.DateTime"; // DateTime에 대한 올바른 반환값
            case "TimeSpan": return "System.TimeSpan";
            case "Guid": return "System.Guid";
            default: return "string"; // 기본적으로 string으로 처리
        }
    }
}

// SheetData 클래스
[System.Serializable]
public class SheetData
{
    public string sheetName;
    public int sheetId;
}

// SheetDataList 클래스
[System.Serializable]
public class SheetDataList
{
    public SheetData[] sheetData;
}
#endif

 

 


4. 코드 설명

주요 함수 설명

  • FetchSheetsData: Google 스프레드시트의 시트 목록을 가져오는 함수로, UnityWebRequest를 사용해 데이터를 불러옵니다.
  • ProcessSheetsData: 불러온 시트 데이터를 처리해 리스트로 관리합니다.
  • ParseGoogleSheet: 선택한 시트를 TSV로 파싱하여 JSON으로 변환하고, 이를 저장하는 함수입니다.
  • SaveJsonToFile: 파싱된 데이터를 JSON 파일로 저장합니다.
  • CreateDataClass: 시트의 구조를 기반으로 자동으로 C# 클래스를 생성하여 파일로 저장합니다. 

 

*위 내용 오타 및 수정해야 하는 내용 있으면 댓글로 알려주시면 감사합니다.