DevLog/유니티 프로젝트

구글 엑셀 연동 DT 관리 - GoogleDTTemplate 클래스 정리

뽀또치즈맛 2025. 5. 27. 15:52

 https://github.com/kwon1232/HDProject

 

GitHub - kwon1232/HDProject

Contribute to kwon1232/HDProject development by creating an account on GitHub.

github.com

 

목차

1. 개요

2. 전체 구조 설명

 

 주요 코드 상세 해설

   ScriptableObject 클래스 자동 생성
  데이터 타입 추론
  구글 시트에서 ScriptableObject 생성

 

3. 실제 사용 방법

 

4. 실무 활용 및 확장 포인트

 

5. 전체 코드 예시

 

 

 

1.개요

 

 이 툴은 크게 GoogleDTEditor.cs를 중심으로 GoogleDTTemplate.sc, GoogleSheetLoader.sc, SOGenerator.cs 파일로 구성되어있습니다. 구글 스프레드시트에서 관리하는 데이터들을 유니티에서 바로 ScriptableObject로 변환하여 리소스화라는 자동화 툴입니다. 소개할 내용은 GoogleDTTemplate.cs의 세부 내용입니다.

 

GoogleDTTemplate.cs를 활용하여,

테이블이 바뀌면 자동으로 타입 추론 -> 클래스 자동 생성 -> ScriptableObject 생성/업데이트까지 한 번에 가능합니다.

이 기능으로 데이터 관리 및 동기화의 번거로움을 대폭 줄이고, 실시간 협업에 강력하게 대응할 수 있습니다.

 

주된 내용으로는 기존 데이터와 중복 없이 신규/업데이트만 처리를 하는 것입니다.

이는 코드와 에디터 상에서 모두 호출 가능한 함수로 구현되어 있습니다.

 

 

2. 전체 구조 설명

 이 시스템은 구글 시트의 데이터를 자동으로 ScriptableObjec(이하 SO)로 변환/동기화 하는 작업을 자동화합니다.

각 단계를 더욱 세분화하여, 실제로 어떤 흐름과 역할로 동작하는지 아래와 같이 설명할 수 있습니다.

 

1) Google 시트 → CSV 파싱

  • 데이터 소스: 구글 스프레드시트에서 관리하는 데이터(예: 아이템, 몬스터 등 테이블)
    • 첫 행: 필드명(컬럼명)
    • 이후 행: 실제 데이터 행(각 오브젝트의 정보)
  • CSV 포맷으로 다운로드:
    • 구글 시트는 전용 URL을 통해 현재 시트의 내용을 CSV 포맷으로 바로 받을 수 있음
    • URL 예시 : 
https://docs.google.com/spreadsheets/d/{spreadsheetId}/gviz/tq?tqx=out:csv&sheet={sheetName}
  • CSV 파싱:
    • 이 URL을 통해 CSV 데이터를 받아오면, 첫 줄은 헤더(필드명), 나머지는 데이터 행들이 됨
    • 이 데이터를 List<List<string>> 구조로 파싱하여 이후 단계에서 활용
/// <summary>
/// ScriptableObject용 클래스 자동 생성기
/// </summary>
public static class ScriptableObjectTemplateGenerator
{
    public static void GenerateTemplateClass(string className, List<string> fields, List<string> types, string folder)
    {
        StringBuilder sb = new StringBuilder();
        sb.AppendLine("using UnityEngine;");
        sb.AppendLine($"\n[CreateAssetMenu(fileName = \"{className}\", menuName = \"Data/{className}\")]\npublic class {className} : ScriptableObject\n{{");

        for (int i = 0; i < fields.Count; i++)
        {
            sb.AppendLine($"    public {types[i]} {fields[i]};");
        }
        sb.AppendLine("}");

        Directory.CreateDirectory(folder);
        File.WriteAllText(Path.Combine(folder, className + ".cs"), sb.ToString());
        AssetDatabase.Refresh();
    }
}

 

2) C# 클래스 자동 생성

담당: ScriptableObjectTemplateGenerator

  • 역할:
    • 구글 시트의 컬럼명(필드명)과 각 컬럼별 타입을 받아,
      해당 구조에 맞는 ScriptableObject용 C# 클래스 코드를 자동으로 생성합니다.
  • 구체적 동작:
    • 이미 해당 이름의 클래스 파일이 있으면 건너뜀.
    • 없다면,
      • public 필드와 [CreateAssetMenu] 특성이 붙은 ScriptableObject 클래스를 문자열로 생성
        => 유니티 에디터의 메뉴에서 직접 데이터 에셋을 수동으로 생성
      • 지정한 폴더에 .cs 파일로 저장

 

3) 데이터 타입 자동 추론

담당: TypeInference

  • 역할:
    • 구글 시트에서 읽어온 각 열에 대해 데이터 값들을 전부 확인
    • 해당 열에 저장된 값들이
      • 전부 정수라면(int)
      • 전부 실수라면(float)
      • 전부 true/false라면(bool)
      • 그 외라면(string)
        중 어느 타입에 적합한지 자동으로 판별
  • 구체적 동작:
    • 모든 행에 대해 열별로 int, float, bool, string 체크 플래그를 둠
    • 가장 적합한 타입을 각 컬럼에 부여
  • 이점:
    • 컬럼 타입을 일일이 수동 지정하지 않아도 됨
    • 시트 데이터만 수정해도 타입 변경이 자연스럽게 반영됨
    • 실수/정수/불리언/문자열 등 다양한 데이터에 대응
/// <summary>
/// 시트 데이터에서 타입 자동 추론 (int, float, bool, string)
/// </summary>
public static class TypeInference
{
    /*
     * 목적: 구글 시트나 기타 데이터 테이블을 기반으로,
     * ScriptableObject 클래스를 자동으로 만들어주기 위한 코드 자동 생성기
     * 장점:
     * 사람이 일일이 클래스를 만들지 않아도 됨
     * 데이터 구조가 바뀌어도 자동화 가능
     * 에디터/자동화 파이프라인에서 활용 용이
     */
    public static List<string> InferFieldTypes(List<List<string>> rows)
    {
        var types = new List<string>();
        if (rows.Count == 0) return types;
        int colCount = rows[0].Count;
        for (int i = 0; i < colCount; i++)
        {
            bool isInt = true, isFloat = true, isBool = true;

            for (int j = 0; j < rows.Count; j++)
            {
                if (i >= rows[j].Count) continue;
                string val = rows[j][i].Trim();
                if (!int.TryParse(val, out _)) isInt = false;
                if (!float.TryParse(val, out _)) isFloat = false;
                // 문자열을 소문자로 변경하여 true false 판별 안정화
                string v = val.ToLowerInvariant();
                if (v != "true" && v != "false" && v != "1" && v != "0") isBool = false;
            }

            if (isInt) types.Add("int");
            else if (isFloat) types.Add("float");
            else if (isBool) types.Add("bool");
            else types.Add("string");
        }
        return types;
    }
}

 

4) ScriptableObject 자동 생성/동기화

담당: GoogleDTTemplate (MonoBehaviour)

  • 역할:
    • 실제로 구글 시트의 데이터를 불러와서
    • 위에서 자동 생성한 ScriptableObject 클래스를 찾아
    • 각 행마다 ScriptableObject 에셋을 자동 생성/업데이트

구체적 동작:

  • 스프레드시트 ID와 시트명을 입력받아 CSV 데이터 로딩
        // 구글 시트 CSV URL 조합
        string csvUrl = $"https://docs.google.com/spreadsheets/d/{spreadsheetId}/gviz/tq?tqx=out:csv&sheet={sheetName}";
        List<List<string>> data = GoogleSheetLoader.LoadCSVFromUrl(csvUrl);

        if (data == null || data.Count < 2)
        {
            Debug.LogError("데이터가 없거나 잘못된 시트입니다.");
            return;
        }

 

  • 첫 실행 시 C# SO 클래스가 없으면 자동 생성,
    컴파일 후 다시 실행하면 에셋 생성 로직 진행
        List<string> headers = data[0];
        List<List<string>> rows = data.GetRange(1, data.Count - 1);
        List<string> types = TypeInference.InferFieldTypes(rows);

        // SO 클래스 자동 생성 (없으면)
        string classFilePath = Path.Combine("Assets/Scripts/Generated", sheetName + ".cs");
        string assetFolderPath = "Assets/Scripts/Generated";
        if (!File.Exists(classFilePath))
        {
            // 추가 내용을  구글 시트나 기타 데이터 테이블을 기반으로,
            // ScriptableObject 클래스를 자동으로 만들어주기 위한 ""코드 자동 생성기""
            // ""클래스가 없다면 클래스 스크립트를 만들어준다"". 그뒤 코드를 자동으로 생성해주는 기능이다.
            ScriptableObjectTemplateGenerator.GenerateTemplateClass(sheetName, headers, types, assetFolderPath); ScriptableObjectTemplateGenerator.GenerateTemplateClass(sheetName, headers, types, assetFolderPath);
            Debug.Log($"Template for {sheetName} generated. (스크립트 컴파일 후 다시 실행하세요)");
            return;
        }
  • 첫 번째 컬럼(보통 id) 기준으로 이미 생성된 SO 에셋과 중복 체크

코드 상세 해석

// 컴파일 후 SO 타입 찾기
string assemblyName = typeof(GoogleDTTemplate).Assembly.GetName().Name;

한 줄씩 해석

1. typeof(GoogleDTTemplate)

  • 현재 프로젝트에 존재하는 GoogleDTTemplate 클래스의 타입 정보를 가져옴.

2. .Assembly

  • 그 타입이 **어느 어셈블리(=컴파일된 DLL, 보통은 "Assembly-CSharp")**에 정의되어 있는지 얻음.

3. .GetName().Name

  • 어셈블리의 이름(보통 "Assembly-CSharp" 혹은 플러그인이라면 다른 이름일 수도 있음)을 가져옴.

4. 변수에 저장

  • 그 결과(어셈블리 이름 문자열)를 assemblyName 변수에 저장.

 

왜 이런 한줄 짜리 코드를 분석해주는가?

어셈블리의 역할을 확실히 잡기 위해서.

 

상세 이유 : 
어셈블리명은 프로젝트/환경마다 달라질 수 있다
유니티, 일반 C#, 외부 라이브러리/플러그인 등 코드가 속한 어셈블리명이 항상 고정되어 있지 않다.
직접 "Assembly-CSharp" 등으로 하드코딩하면 에디터 환경, 패키지, 커스텀 DLL, 코드 리팩토링 등에서 오류가 발생할 수 있다.
따라서, 현재 코드가 속한 어셈블리명을 "코드로 직접 얻어서" 사용하는 것이 더 견고하다.

 

어셈블리명을 코드로 정확히 얻는 것은
실무 자동화, 타입 찾기, 툴 개발, 런타임 객체 생성 등
고급 C#/유니티 개발의 필수 기반이기 때문에
한 줄짜리라도 반드시 의미를 이해하고 쓸 줄 알아야 한다.

어셈블리란, c# 소스코드 파일(.cs)들이 빌드되어 만들어지는 결과물이다.
DLL 또는 실행파일 exe 형태로 존재한다.
.NET 프로그램의 배포 단위이자, 코드/타입/리소스의 묶음이다.

쉽게 비유하자면,
여러 개의 .cs 파일 -> 컴파일 -> 하나의 어셈블리 (모듈, 라이브러리, 프로그램, DLL, exe)로 보는 것이다.
이러한 것을 유니티 기준에서 살펴보자.

유니티 기준 어셈블리
사용자 제작 스크립트들은 보통  " Assembly-CSharp.dll " 이라는 어셈블리로 컴파일됨
외부 플러그인/패키지는 별도의 어셈블리로 묶여 제공될 수 있음


어셈블리의 역할

1. 코드와 타입(클래스, 인터페이스, 메서드 등), 리소스(이미지, 사운드, 문자열 등)를 하나로 묶어 배포
2. 프로그램을 나누고 관리하기 쉽도록 함(코드 분리, 라이브러리화, 네임스페이스 구분)
3. 런타임에서 타입을 찾거나 동적으로 객체를 만들 때 어셈블리명까지 함께 명시해야 정확하게 찾을 수 있음

유니티에서 어셈블리
1. 기본적으로 사용자 정의 스크립트들은 모두 " Assembly-CSharp " 이라는 어셈블리에 들어간다.
    - (참고) Assembly-CSharp.dll은 유니티가 자동으로 만들어준다.
2. 외부 플러그인이나 패키지에서 제공하는 DLL은 각각 다른 어셈블리명으로 존재한다.
3. 타입을 코드로 찾을 때 "클래스 이름, 어셈블리 이름"으로 완전히 지정해야 한다.


정리

어셈블리(Assembly)는 C# 코드가 컴파일되어 만들어지는 DLL(또는 EXE) 파일이며,
코드/타입/리소스의 논리적 묶음, 그리고 런타임 타입 탐색 및 배포 단위이다.

어셈블리명은 프로젝트/환경마다 달라질 수 있다
유니티, 일반 C#, 외부 라이브러리/플러그인 등 코드가 속한 어셈블리명이 항상 고정되어 있지 않다.
직접 "Assembly-CSharp" 등으로 하드코딩하면 에디터 환경, 패키지, 커스텀 DLL, 코드 리팩토링 등에서 오류가 발생할 수 있다.
따라서, 현재 코드가 속한 어셈블리명을 "코드로 직접 얻어서" 사용하는 것이 더 견고하다.

어셈블리명을 코드로 정확히 얻는 것은
실무 자동화, 타입 찾기, 툴 개발, 런타임 객체 생성 등
고급 C#/유니티 개발의 필수 기반이기 때문에
한 줄짜리라도 반드시 의미를 이해하고 쓸 줄 알아야 한다.

 

일반적 타입 찾기

// 자동 생성한 SO 클래스가 "ItemData"라는 이름이고,
// Assembly-CSharp 어셈블리에 들어있다면:
Type t = Type.GetType("ItemData, Assembly-CSharp");

어셈블리 이름을 코드로 얻고 싶을 때

string assemblyName = typeof(GoogleDTTemplate).Assembly.GetName().Name;
[ExecuteInEditMode]
public class GoogleDTTemplate : MonoBehaviour
{
    [ContextMenu("구글 시트 → ScriptableObject 자동 변환")]
    public void ConvertSheetToScriptableObjects()
    {
        // 1. 스프레드시트에서 데이터를 읽어옴 (CSV 파싱)
        // 2. 헤더/타입 자동 분석 및 클래스 자동 생성 (없으면)
        // 3. 컴파일 후 타입 동적으로 찾기
        // 4. 이미 생성된 id의 에셋은 건너뜀(중복 방지)
        // 5. 신규 데이터만 SO로 만들어 Assets/Resources/Generated/시트명/id.asset으로 저장
        // 6. AssetDatabase로 프로젝트에 반영 (에디터/런타임에서 모두 활용 가능)
    }
}

 

 

  • 신규 데이터만 자동으로 SO로 생성
  • 에셋은 Assets/Resources/Generated/{시트명}/{id}.asset에 저장
// 기존 에셋 중복 검사 (ID 기준, ID는 첫 Colum)
string saveFolder = $"Assets/Resources/Generated/{sheetName}";
if (!Directory.Exists(saveFolder)) Directory.CreateDirectory(saveFolder);

string[] existingAssets = AssetDatabase.FindAssets("t:" + sheetName, new[] { saveFolder });
HashSet<string> existingIds = new HashSet<string>();
for (int i = 0; i < existingAssets.Length; i++)
{
    string path = AssetDatabase.GUIDToAssetPath(existingAssets[i]);
    ScriptableObject so = AssetDatabase.LoadAssetAtPath<ScriptableObject>(path);
    if (so == null) continue;
    var field = soType.GetField(headers[0]);
    if (field != null)
    {
        object val = field.GetValue(so);
        if (val != null)
            existingIds.Add(val.ToString());
    }
}
  • 장점:
    • 기획자가 시트에서 직접 데이터를 관리하면
      => 유니티 프로젝트에서 ScriptableObject로 자동 반영
    • 기존 데이터와 중복 없이 신규/업데이트만 처리되어 데이터 정합성 보장
    • SO가 Resources 폴더에 저장되므로 런타임 로드 및 테스트 용이
        // 신규 ScriptableObject 에셋 생성
        int addCount = 0;
        for (int i = 0; i < rows.Count; i++)
        {
            string id = rows[i][0];
            if (existingIds.Contains(id)) continue;

            ScriptableObject asset = ScriptableObject.CreateInstance(soType);
            for (int j = 0; j < headers.Count && j < rows.Count; j++)
            {
                var field = soType.GetField(headers[j]);
                if (field == null) continue;
                string val = rows[i][j];
                object parsed;
                if (types[j] == "int")
                {
                    parsed = int.TryParse(val, out int iv) ? iv : 0;
                }
                else if (types[j] == "float")
                {
                    parsed = float.TryParse(val, out float fv) ? fv : 0f;
                }
                else if (types[j] == "bool")
                {
                    string v = val.ToLowerInvariant();
                    parsed = (v == "true" || v == "1");
                }
                else
                {
                    parsed = val;
                }
                field.SetValue(asset, parsed);
            }
            string assetPath = Path.Combine(saveFolder, id + ".asset");
            AssetDatabase.CreateAsset(asset, assetPath);
            addCount++;
        }
        // 프로젝트에 저장되어 언제든 에디터/런타임에서 불러올 수 있는 데이터의 단위로 변환한다.
        /* 변환 이유 :
         * ScriptableObject도 이런 에셋 형태로 저장하면
         * 1. 프로젝트 안에서 자산처럼 관리(버전관리, 리팩토링, 검색) 가능
         * 2. 인스펙터에서 쉽게 확인/수정 가능
         * 3. 런타임에서 쉽게 로드 및 활용 
         * ( Resources.Load, Addressables, AssetBundle 등으로
             빌드 후 게임 실행 중에도 바로 불러와 사용할 수 있음 )
         * 직접 메모리에 생성하는 것보다
           이미 저장된 자산을 읽어오는 게 훨씬 쉽고 관리가 쉬움
         */
        AssetDatabase.SaveAssets();
        AssetDatabase.Refresh();
        Debug.Log($"{sheetName} - 신규 ScriptableObject {addCount}개 생성, 이미 있는 데이터는 건너뜀");
    }

 

 

ScriptableObject를 에셋(asset)으로 저장하는 이유

1. 유니티의 "리소스 자산" 시스템 활용

  • 유니티의 asset 파일(ex: .asset, .prefab, .mat, .png 등)은
    프로젝트에 저장되어 언제든 에디터/런타임에서 불러올 수 있는 데이터의 단위
  • ScriptableObject도 이런 에셋 형태로 저장하면
    • 프로젝트 안에서 자산처럼 관리(버전관리, 리팩토링, 검색) 가능
    • 인스펙터에서 쉽게 확인/수정 가능

2. 런타임에서 쉽게 로드 및 활용

  • Resources.Load, Addressables, AssetBundle 등으로
    빌드 후 게임 실행 중에도 바로 불러와 사용할 수 있음
    (예: 아이템 데이터, 퀘스트, 몬스터 테이블 등)
  • 직접 메모리에 생성하는 것보다
    이미 저장된 자산을 읽어오는 게 훨씬 쉽고 관리가 쉬움

3. 에디터에서 미리보기, 수동 수정, 디버깅 용이

  • 에셋 파일은 Inspector에서 바로 확인, 편집 가능
  • 자동화로 만든 후에도 사람이 에디터에서 쉽게 볼 수 있음
  • 문제 발생 시 수동으로 임시 수정하기도 쉬움

4. 데이터의 재사용, 참조, 공유

  • ScriptableObject 에셋은 여러 오브젝트/컴포넌트/스크립트에서 쉽게 참조할 수 있음
    (싱글톤/공용 데이터, 테이블, 프리셋 등)
  • 프로젝트의 다양한 곳에서 하나의 에셋 데이터를 공유

5. 버전관리 및 협업 효율

  • 에셋 파일은 텍스트(SerializeField)로 저장 가능 → Git/SVN 등에서 버전관리
  • 기획, 아트, 프로그래머 모두 같은 데이터를 공유하고 수정

예시로 이해하기

  • 아이템 데이터가 500개인 RPG 게임
    • 모든 데이터를 코드로 만들거나 런타임에서 직접 생성하면 관리가 어려움
    • ScriptableObject 에셋 500개를 만들어두면
      → 프로젝트 전체에서 해당 데이터 활용이 쉽고
      → 실시간 수정도 가능
      → 런타임/테스트/에디터 환경에서 언제든 로드 가능

 

 

3. 실제 사용 방법

 

4. 실무 활용 및 확장 포인트

아이템, 몬스터, 퀘스트, 맵 등 모든 테이블 데이터 자동화

기획/아트/개발 모두가 구글 시트만 수정해도 Unity 데이터가 동기화됨

형식 자동화로 컬럼 추가/삭제/변경에 유연하게 대응

SO 생성 후,

  • 런타임 매니저에서 자동 로딩
  • Addressables, Resources 등과 연동
  • 시트간 참조, enum, 리스트, 오브젝트 필드 등 다양한 타입 확장 가능
  • 버전관리, 협업, QA 대응이 훨씬 쉬워짐

 

5. 전체 코드 예시

 

// GoogleDTTemplate.cs

using Mono.Cecil;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEngine;

/// <summary>
/// ScriptableObject용 클래스 자동 생성기
/// </summary>
public static class ScriptableObjectTemplateGenerator
{
    // 유니티에서 ScriptableObject용 C# 클래스 소스코드를 자동으로 만들어주는 유틸리티 함수
    /*
     * className : 생성할 클래스 이름 (예: ItemData)
     * fields : 필드(멤버 변수) 이름 목록 (예: ["id", "name", "value"])
     * types : 각 필드의 데이터 타입 목록 (예: ["int", "string", "float"])
     * folder : 생성할 .cs 파일이 저장될 폴더 경로 (예: "Assets/Scripts/Generated")
     */
    public static void GenerateTemplateClass(string className, List<string> fields, List<string> types, string folder)
    {
        StringBuilder sb = new StringBuilder();
        sb.AppendLine("using UnityEngine;");
        sb.AppendLine($"\n[CreateAssetMenu(fileName = \"{className}\", menuName = \"Data/{className}\")]\npublic class {className} : ScriptableObject\n{{");

        for (int i = 0; i < fields.Count; i++)
        {
            sb.AppendLine($"    public {types[i]} {fields[i]};");
        }
        sb.AppendLine("}");

        Directory.CreateDirectory(folder);
        File.WriteAllText(Path.Combine(folder, className + ".cs"), sb.ToString());
        AssetDatabase.Refresh();
    }
}

/// <summary>
/// 시트 데이터에서 타입 자동 추론 (int, float, bool, string)
/// </summary>
public static class TypeInference
{
    /*
     * 목적: 구글 시트나 기타 데이터 테이블을 기반으로,
     * ScriptableObject 클래스를 자동으로 만들어주기 위한 코드 자동 생성기
     * 장점:
     * 사람이 일일이 클래스를 만들지 않아도 됨
     * 데이터 구조가 바뀌어도 자동화 가능
     * 에디터/자동화 파이프라인에서 활용 용이
     */
    public static List<string> InferFieldTypes(List<List<string>> rows)
    {
        var types = new List<string>();
        if (rows.Count == 0) return types;
        int colCount = rows[0].Count;
        for (int i = 0; i < colCount; i++)
        {
            bool isInt = true, isFloat = true, isBool = true;

            for (int j = 0; j < rows.Count; j++)
            {
                if (i >= rows[j].Count) continue;
                string val = rows[j][i].Trim();
                if (!int.TryParse(val, out _)) isInt = false;
                if (!float.TryParse(val, out _)) isFloat = false;
                // 문자열을 소문자로 변경하여 true false 판별 안정화
                string v = val.ToLowerInvariant();
                if (v != "true" && v != "false" && v != "1" && v != "0") isBool = false;
            }

            if (isInt) types.Add("int");
            else if (isFloat) types.Add("float");
            else if (isBool) types.Add("bool");
            else types.Add("string");
        }
        return types;
    }
}


/// <summary>
/// Google 스프레드시트 → ScriptableObject 자동 생성기
/// </summary>
[ExecuteInEditMode]
public class GoogleDTTemplate : MonoBehaviour
{
    [Header("Google 스프레드시트 ID / 시트명")]
    public string spreadsheetId;
    public string sheetName;

    [ContextMenu("구글 시트 → ScriptableObject 자동 변환")]
    public void ConvertSheetToScriptableObjects()
    {
        // 구글 시트 CSV URL 조합
        string csvUrl = $"https://docs.google.com/spreadsheets/d/{spreadsheetId}/gviz/tq?tqx=out:csv&sheet={sheetName}";
        List<List<string>> data = GoogleSheetLoader.LoadCSVFromUrl(csvUrl);

        if (data == null || data.Count < 2)
        {
            Debug.LogError("데이터가 없거나 잘못된 시트입니다.");
            return;
        }

        List<string> headers = data[0];
        List<List<string>> rows = data.GetRange(1, data.Count - 1);
        List<string> types = TypeInference.InferFieldTypes(rows);

        // SO 클래스 자동 생성 (없으면)
        string classFilePath = Path.Combine("Assets/Scripts/Generated", sheetName + ".cs");
        string assetFolderPath = "Assets/Scripts/Generated";
        if (!File.Exists(classFilePath))
        {
            // 추가 내용을  구글 시트나 기타 데이터 테이블을 기반으로,
            // ScriptableObject 클래스를 자동으로 만들어주기 위한 ""코드 자동 생성기""
            // ""클래스가 없다면 클래스 스크립트를 만들어준다"". 그뒤 코드를 자동으로 생성해주는 기능이다.
            ScriptableObjectTemplateGenerator.GenerateTemplateClass(sheetName, headers, types, assetFolderPath); ScriptableObjectTemplateGenerator.GenerateTemplateClass(sheetName, headers, types, assetFolderPath);
            Debug.Log($"Template for {sheetName} generated. (스크립트 컴파일 후 다시 실행하세요)");
            return;
        }

        // 컴파일 후 SO 타입 찾기
        string assemblyName = typeof(GoogleDTTemplate).Assembly.GetName().Name;
        Type soType = Type.GetType($"{sheetName}, {assemblyName}");
        if (soType == null)
        {
            Debug.LogError($"타입 {sheetName} 을 찾을 수 없습니다. (스크립트 컴파일 후 다시 실행)");
            return;
        }

        // 기존 에셋 중복 검사 (ID 기준, ID는 첫 Colum)
        string saveFolder = $"Assets/Resources/Generated/{sheetName}";
        if (!Directory.Exists(saveFolder)) Directory.CreateDirectory(saveFolder);

        string[] existingAssets = AssetDatabase.FindAssets("t:" + sheetName, new[] { saveFolder });
        HashSet<string> existingIds = new HashSet<string>();
        for (int i = 0; i < existingAssets.Length; i++)
        {
            string path = AssetDatabase.GUIDToAssetPath(existingAssets[i]);
            ScriptableObject so = AssetDatabase.LoadAssetAtPath<ScriptableObject>(path);
            if (so == null) continue;
            var field = soType.GetField(headers[0]);
            if (field != null)
            {
                object val = field.GetValue(so);
                if (val != null)
                    existingIds.Add(val.ToString());
            }
        }

        // 신규 ScriptableObject 에셋 생성
        int addCount = 0;
        for (int i = 0; i < rows.Count; i++)
        {
            string id = rows[i][0];
            if (existingIds.Contains(id)) continue;

            ScriptableObject asset = ScriptableObject.CreateInstance(soType);
            for (int j = 0; j < headers.Count && j < rows.Count; j++)
            {
                var field = soType.GetField(headers[j]);
                if (field == null) continue;
                string val = rows[i][j];
                object parsed;
                if (types[j] == "int")
                {
                    parsed = int.TryParse(val, out int iv) ? iv : 0;
                }
                else if (types[j] == "float")
                {
                    parsed = float.TryParse(val, out float fv) ? fv : 0f;
                }
                else if (types[j] == "bool")
                {
                    string v = val.ToLowerInvariant();
                    parsed = (v == "true" || v == "1");
                }
                else
                {
                    parsed = val;
                }
                field.SetValue(asset, parsed);
            }
            string assetPath = Path.Combine(saveFolder, id + ".asset");
            AssetDatabase.CreateAsset(asset, assetPath);
            addCount++;
        }
        // 프로젝트에 저장되어 언제든 에디터/런타임에서 불러올 수 있는 데이터의 단위로 변환한다.
        /* 변환 이유 :
         * ScriptableObject도 이런 에셋 형태로 저장하면
         * 1. 프로젝트 안에서 자산처럼 관리(버전관리, 리팩토링, 검색) 가능
         * 2. 인스펙터에서 쉽게 확인/수정 가능
         * 3. 런타임에서 쉽게 로드 및 활용 
         * ( Resources.Load, Addressables, AssetBundle 등으로
             빌드 후 게임 실행 중에도 바로 불러와 사용할 수 있음 )
         * 직접 메모리에 생성하는 것보다
           이미 저장된 자산을 읽어오는 게 훨씬 쉽고 관리가 쉬움
         */
        AssetDatabase.SaveAssets();
        AssetDatabase.Refresh();
        Debug.Log($"{sheetName} - 신규 ScriptableObject {addCount}개 생성, 이미 있는 데이터는 건너뜀");
    }
}