졸업까지 100턴에는 캐릭터, 스킬, 보드, 상점까지 데이터 테이블이 70종 있습니다. 기획자가 엑셀을 고치면 클라이언트와 서버가 함께 바뀌어야 하죠. 이번 편에서는 엑셀이 게임 데이터가 되기까지의 길을 따라가 보겠습니다.
같은 데이터를 세 곳이 씁니다

캐릭터의 공격력 하나도 세 곳에서 읽습니다. 기획자는 엑셀에서 숫자를 고치고, Unity 클라이언트는 그 숫자로 전투를 그리고, NestJS 서버는 같은 숫자로 보상을 검증합니다.
기획자
엑셀에서 값을 고칩니다. 코드를 몰라도 게임이 바뀌어야 합니다.
클라이언트
Unity C# 이 표를 읽어 화면과 전투를 만듭니다.
서버
NestJS 가 MySQL 의 같은 표로 결과를 검증합니다.
세 곳이 각자 정의를 들고 있으면 언젠가 어긋납니다. 그래서 우리는 원본을 하나로 두고 나머지는 모두 생성하는 길을 택했습니다.
전체 흐름
- 엑셀 58개기획자가 고치는 유일한 원본
- ElDataExporter엑셀을 읽어 JSON 과 DB 로 내보냅니다
- JSON 51개 · MySQL클라이언트용 파일과 서버 DB
- Addressablesdata 묶음으로 원격 배포
- 런타임 테이블부팅할 때 Dictionary 로 채웁니다
| 도구 | 입력 | 출력 |
|---|---|---|
| ElJsonSchemaBuilder | 스키마 JSON | C# 클래스 8,465줄, TypeORM 엔티티 2,233줄 |
| ElDataExporter | 엑셀 표 | 클라이언트 JSON, 서버 MySQL |
| LoadJsonDataToTable | JSON 파일 | 메모리의 테이블 |
스키마가 두 언어가 됩니다
테이블의 모양은 TableClasses.json 에 적습니다. 열 이름과 타입, 기본 키 같은 제약을 짧은 문자열로 씁니다.
"Character": {
"IDX": "int PrimaryKey",
"NameIDX": "int",
"Rarity": "string MaxLength(100)",
"ClassType": "string MaxLength(255)",
"AttackPoint": "int DefaultValue(0)",
"DefensePoint": "int DefaultValue(0)",생성기는 출력 파일의 확장자만 보고 어떤 언어로 만들지 정합니다. .cs 면 Unity 용 C# 클래스, .ts 면 서버용 TypeORM 엔티티입니다.
if (extension == "ts")
{
targetExtensionType = TargetExtensionType.TypeScript;
}
else if (extension == "cs")
{
targetExtensionType = TargetExtensionType.CSharp;
}같은 Character 표가 C# 에서는 이런 클래스가 됩니다. 손으로 고치지 않도록 파일 맨 위에 생성 코드라는 표시가 붙습니다.
[ElPrimaryKey]
public int IDX { get { return _IDX; } set { if (_IDX != value) { _IDX = value; OnPropertyChanged("IDX", value); } } }
private int _IDX;
public int NameIDX { get { return _NameIDX; } set { if (_NameIDX != value) { _NameIDX = value; OnPropertyChanged("NameIDX", value); } } }
private int _NameIDX;엑셀을 읽는 쪽의 작은 규칙들
ElDataExporter 는 외부 라이브러리 없이 xlsx 를 직접 풉니다. 그러면서 기획 현장에서 생기는 일을 규칙으로 정리해 두었습니다.
메모 열은 내보내지 않는다
열 이름이 memo 로 시작하거나 끝나면 JSON, 코드, DB 어디에도 실리지 않습니다. 기획자가 표 안에 마음껏 설명을 적을 수 있습니다.
행 길이를 맞춘다
xlsx 는 뒤쪽 빈 칸을 저장하지 않습니다. 그대로 두면 서버에 넣을 때 열과 값의 개수가 어긋나서, 헤더 길이로 모든 행을 채웁니다.
private static bool IsMemoColumn(string columnName)
{
if (string.IsNullOrEmpty(columnName)) return false;
string lower = columnName.ToLower();
return lower.StartsWith("memo") || lower.EndsWith("memo");
}// 헤더 열 수에 맞춰 모든 행의 길이를 통일한다.
// xlsx 는 뒤쪽 빈 셀을 저장하지 않아 행마다 열 수가 달라질 수 있고,
// 그대로 두면 서버 INSERT 시 컬럼/값 개수가 어긋난다.
if (rows.Count > 0)
{
int width = rows[0].Length;
for (int i = 0; i < rows.Count; i++)
{
if (rows[i].Length == width) continue;
var fixedRow = new string[width];
for (int c = 0; c < width; c++)
{
fixedRow[c] = c < rows[i].Length ? rows[i][c] : "";
}
rows[i] = fixedRow;
}런타임에서는 필드가 곧 테이블입니다
클라이언트는 테이블마다 정적 Dictionary 를 하나씩 둡니다. 지금 51개가 있습니다.
public partial class JsonDataStorage
{
public static readonly Dictionary<long, ActiveSkill> ActiveSkillTable = new Dictionary<long, ActiveSkill>();
public static readonly Dictionary<long, ActiveSkillName> ActiveSkillNameTable = new Dictionary<long, ActiveSkillName>();
public static readonly Dictionary<long, AdsReward> AdsRewardTable = new Dictionary<long, AdsReward>();로더는 JSON 파일 이름 뒤에 Table 을 붙인 필드를 리플렉션으로 찾고, 그 Dictionary 의 값 타입으로 JSON 을 변환합니다. 그래서 새 테이블을 추가할 때 로더 코드는 고치지 않습니다. 필드를 더하면 됩니다.
protected void LoadJsonDataToTable(TextAsset jsonData)
{
try
{
string tableName = jsonData.name + "Table";
// JsonDataStorage 클래스에서 해당 테이블 찾기
Type storageType = typeof(JsonDataStorage);
FieldInfo tableField = storageType.GetField(tableName, BindingFlags.Public | BindingFlags.Static);
if (tableField == null)
{
Debug.LogWarning($"Table {tableName} not found in JsonDataStorage");
return;
}
// 테이블의 제네릭 타입 가져오기 (Dictionary<long, XXX>에서 XXX 부분)
Type tableType = tableField.FieldType;
Type[] genericArgs = tableType.GetGenericArguments();
if (genericArgs.Length != 2)
{
Debug.LogWarning($"Table {tableName} is not a Dictionary<long, T> type");
return;
}
Type dataType = genericArgs[1]; // Dictionary<long, XXX>에서 XXX 타입
같은 함수 아래쪽에는 키를 찾는 순서를 프로퍼티에서 필드로 넓힌 까닭이 적혀 있습니다. 일부 테이블은 생성 결과가 순수 필드로 나와 키를 찾지 못했고, 엉뚱한 프로퍼티를 키로 잡아 형 변환에 실패했습니다. 장애의 원인이 코드 옆에 그대로 남아 있어 다음 사람이 같은 실수를 하지 않습니다.
앱을 다시 빌드하지 않는 데이터 교체

내보낸 JSON 은 Addressables 의 data 묶음에 들어갑니다. 이 묶음은 부팅할 때 가장 먼저 받는 라벨입니다.
/// <summary>부팅(StartScene) 시 필수 — 최소한으로 유지할 것</summary>
public static readonly string[] BootLabels =
{
"data",
"resources^prefab",
"sound",
};부팅할 때 원격 카탈로그를 비교해 바뀐 묶음만 받으므로, 밸런스를 고친 엑셀은 앱 심사를 다시 거치지 않고 배포할 수 있습니다. 6편에서 이 에셋 배포 구조를 자세히 다룹니다.
돌아보며
잘된 점
- 기획자는 엑셀을 고칩니다. 코드와 데이터 파일은 모두 생성됩니다.
- 클라이언트와 서버의 타입을 같은 스키마에서 생성합니다.
- 새 테이블을 추가해도 로더는 그대로입니다.
- 메모 열 규칙 덕분에 표가 기획 문서 역할도 합니다.
아쉬운 점
- 스키마 JSON 이 클라이언트 70종, 서버 64종으로 두 벌이 되었습니다. 원본이 하나라는 장점이 반쯤 깨졌습니다.
- 서버와 같은 클래스를 쓰다 보니 클라이언트에도 모든 setter 에 변경 알림 코드가 들어 있습니다.
- 키를 IDX 하나로 잡기 때문에, 여러 열이 키인 표는 IDX 가 겹치지 않는다는 관례에 기댑니다.
정리
- 원본은 하나, 나머지는 생성합니다. 엑셀과 스키마 JSON 이 원본이고 C#, TypeScript, JSON, DB 는 모두 결과물입니다.
- 규칙을 도구에 넣습니다. 메모 열과 빈 칸처럼 사람이 매번 신경 써야 하는 일은 내보내기 도구가 처리합니다.
- 데이터와 앱을 따로 배포합니다. 데이터 묶음을 원격으로 받으면 밸런스 수정에 앱 빌드가 필요 없습니다.
다음 편에서는 이 데이터 가운데 ActiveSkill 표가 어떻게 전투 스킬로 조립되는지 보겠습니다.