| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- event bus
- 개발
- github desktop
- Visual Studio Installer
- 추상 팩토리 패턴
- 스타일 지침
- 기술면접
- Object Pool
- 오브젝트 풀
- 상태 패턴
- unity
- MVP 패턴
- 의존성 주입
- 게임 디자인 패턴
- 콘솔 앱
- Unity 기술면접
- Render Mode
- 데이터 형식
- Di
- 프로젝트 생성
- 테이블 데이터
- C#
- New Input System
- 구글 스프레드 시트
- Canvas Scaler
- 이벤트 버스
- 구글플레이콘솔
- 유니티
- Abstract Factory Pattern
- Dependency Injection
- Today
- Total
DevDino
구글 스프레드 시트 데이터를 ScriptableObject로 변환하기 본문
프로젝트의 볼륨이 커지면 수많은 기획 데이터를 유니티 에디터에서 일일이 입력하는 것은 비효율적이며 휴먼 에러를 유발하기 쉽습니다.
이러한 문제를 해결하고 완벽한 데이터 파이프라인을 구축하기 위해, 구글 스프레드시트를 TSV 포맷으로 다운로드하여 리플렉션(Reflection)을 통해 ScriptableObject로 자동 변환하는 에디터 툴을 구현하게 되었습니다.
1. 비동기 TSV 데이터 다운로더 구현
구글 스프레드시트의 데이터를 읽어오는 TSVDownloader 클래스입니다. UnityWebRequest를 활용해 지정된 문서 ID와 시트 ID로부터 TSV 포맷의 텍스트 데이터를 비동기로 다운로드 합니다.
using System;
using System.Threading.Tasks;
using UnityEngine.Networking;
using UnityEngine;
public class TSVDownloader
{
public static async Task<string> DownloadAsync(string docId, string gid)
{
string url = $"https://docs.google.com/spreadsheets/d/{docId}/export?format=tsv&gid={gid}";
using (var request = UnityWebRequest.Get(url))
{
var operation = request.SendWebRequest();
// 통신 대기
while (!operation.isDone)
{
await Task.Yield();
}
// TSV 로드 성공
if (request.result == UnityWebRequest.Result.Success)
{
return request.downloadHandler.text;
}
else
{
Debug.LogError($"다운로드 실패: {request.error}");
return null;
}
}
}
}
2. 리플렉션을 활용한 데이터 매핑 및 자동화
다운로드한 TSV 문자열을 실제 객체로 매핑하는 핵심 로직입니다. 제네릭으로 타겟이 될 ScriptableObject 타입을 받고, 리플렉션을 사용해 내부의 리스트 형태 필드를 찾아 동적으로 인스턴스를 생성합니다.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;
public class TsvImporter
{
public static T ParseAndSave<T>(string tsvData) where T : ScriptableObject
{
// TSV 파일 유효성 검사
if(string.IsNullOrEmpty(tsvData))
{
Debug.LogError("TSV 데이터가 비어있습니다.");
return null;
}
T result = ScriptableObject.CreateInstance<T>();
var tType = typeof(T);
// 클래스 내에서 데이터를 담을 List 타입 필드를 찾음
var targetField = tType.GetFields().FirstOrDefault(f =>
f.FieldType.IsGenericType &&
f.FieldType.GetGenericTypeDefinition() == typeof(List<>));
if (targetField == null)
{
Debug.LogError($"{tType.Name} 클래스에 데이터를 담을 List가 없습니다.");
return null;
}
// \r(캐리지리턴)과 \n(줄바꿈)을 한 번에 제거하여 라인 분리
string[] lines = tsvData.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
if (lines.Length < 2) return result; // 헤더 제외 데이터가 없으면 종료
// 첫 번째 줄 (헤더) 추출
string[] columns = lines[0].Split('\t');
var currType = targetField.FieldType.GetGenericArguments()[0];
var dataList = Activator.CreateInstance(targetField.FieldType) as IList;
for (int i = 1; i < lines.Length; i++)
{
object temp = GetInstance(currType, columns, lines[i]);
dataList.Add(temp);
}
// 필드에 리스트 할당 및 에셋 저장
targetField.SetValue(result, dataList);
string assetPath = $"Assets/Table/{tType.Name}.asset";
AssetDatabase.CreateAsset(result, assetPath);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log($"[TSV Import] {tType.Name} 변환 및 저장 완료!");
return result;
}
private static object GetInstance(Type currType, string[] columns, string currentRow)
{
var temp = Activator.CreateInstance(currType);
string[] cells = currentRow.Split('\t');
for (int i = 0; i < columns.Length; i++)
{
if (i >= cells.Length) break;
string fieldName = columns[i];
string cellValue = cells[i];
var fieldInfo = currType.GetField(fieldName);
if (fieldInfo == null) continue;
SetValue(temp, cellValue, fieldInfo, fieldInfo.FieldType);
}
return temp;
}
리플렉션을 통해 파싱이 되기 때문에, 클래스 명을 테이블 이름에/변수명을 시트의 첫 번째 행과 일치시켜 주어야 합니다.


기획자가 스프레드 시트에 새로운 속성을 추가하더라도 프로그래머는 해당 이름의 변수만 선언하면 별도의 파서 수정 없이 데이터를 읽어올 수 있습니다.
3. 데이터 타입별 할당과 에셋 참조 처리
데이터 타입을 분류해주는 SetValue함수입니다. 자주 쓰이는 int, float, string, Enum 타입 매핑을 처리합니다.
필요에 따라 유니티 에셋들도 확장해서 쓸 수 있습니다.
// 자주 쓰이는 타입별 분류 및 할당
private static void SetValue(object temp, string column, System.Reflection.FieldInfo fieldInfo, Type ft)
{
if (ft == typeof(int))
{
if (column.Equals(string.Empty)) column = "-1";
fieldInfo.SetValue(temp, int.Parse(column));
}
else if (ft == typeof(float))
{
if (column.Equals(string.Empty)) column = "-1";
fieldInfo.SetValue(temp, float.Parse(column));
}
else if (ft == typeof(string))
{
fieldInfo.SetValue(temp, column);
}
else if (ft.IsEnum)
{
fieldInfo.SetValue(temp, Enum.Parse(ft, column));
}
// 프로젝트에 맞춰 커스텀 타입 및 유니티 리소스 매핑 로직 추가 가능
// else if (ft == typeof(Sprite))
// {
// fieldInfo.SetValue(temp, AssetDatabase.LoadAssetAtPath<Sprite>($"Assets/Sprites/{column}.png"));
// }
}
#endif
}
4. 파이프라인 통합 및 래퍼(Wrapper) 구현
분리된 기능을 하나로 묶어 에디터에서 호출할 수 있도록 해주는 래퍼를 구현합니다.
using System.Threading.Tasks;
using UnityEngine;
public class LoadGoogleSheet
{
public static async Task Load<T>(string docId, string gid) where T : ScriptableObject
{
// 1. 데이터 다운로드
string tsvText = await TSVDownloader.DownloadAsync(docId, gid);
// 2. 파싱 및 에셋 저장
TsvImporter.ParseAndSave<T>(tsvText);
}
}
데이터를 불러올 때는 구글 스프레드시트의 액세스를 '링크가 있는 모든 사용자'로 바꾸고, url 내의 문서 id와 gid를 입력하여 사용하면 됩니다.
https://docs.google.com/spreadsheets/d/{docId}/edit?gid={gid}
await LoadGoogleSheet.Load<ScriptableObject클래스>("docId", "gid");
자주 호출하기 편하게 MenuItem 어트리뷰트를 사용하였습니다.




5. 마무리 및 적용 후기
일반적으로 리플렉션은 오버헤드가 크기 때문에 사용을 지양해야 한다고 알려져 있습니다. 하지만 위의 파이프라인은 에디터 환경에서만 동작하도록 설계되었기 때문에, 게임의 실제 런타임 성능에는 전혀 악영향을 주지 않습니다.
이 시스템을 구축한 후, 개발 속도가 비약적으로 상승했습니다. 과거 리플렉션을 사용하지 않던 환경에서는 기획이 변경될 때마다 필드 하나하나의 파싱 로직을 하드코딩으로 수정해야 하는 번거로움이 있었습니다. 반면 현재 구조에서는 스프레드시트에 열을 추가하고, 스크립트에 변수 선언 단 한줄만 추가해 주면 모든 대응이 끝납니다. 테이블을 수정한 뒤 에디터에서 클릭 한 번이면 최신 데이터가 ScriptableObject로 바로 갱신됩니다.
하지만 컬럼명과 변수명을 일치시켜야 파싱이 동작한다는 제약이 있습니다. 협업 프로젝트를 진행하며 기획자와 공유 시트를 사용할 때, 헤더의 변수 명을 제가 정하고 기획자는 내부 데이터에만 집중하는 규칙을 정했습니다. 기획자가 자유롭게 수치를 조정하고 수정 사항만 공유해 주면 즉시 게임에 반영되었기 때문에 업무 효율과 만족도가 매우 높았습니다.
결론적으로 데이터 테이블 자동 로드 및 파싱 시스템은 항상 프로젝트의 초기 단계부터 구축할 정도로 중요하고 필수적인 기능이라고 체감하고 있습니다.
'개발 일지' 카테고리의 다른 글
| FSM을 활용한 캐릭터 행동 제어 (0) | 2026.07.14 |
|---|---|
| Unity 이벤트 버스 적용: 최적화와 데이터 주도 설계 (0) | 2026.07.14 |
| VContainer를 사용한 의존성 주입(Dependency Injection) 도입 (0) | 2026.03.23 |
| Unity New Input System (0) | 2026.01.13 |