로그인게임하기
구조 읽기

[100턴 구조 읽기 1] 엑셀에서 클라이언트와 서버까지

2026.10.10

졸업까지 100턴 구조 읽기 · 1편

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

PROBLEM

같은 데이터를 세 곳이 씁니다

캐릭터 카드 하나를 기획자 로라, 게임기 화면, 서버 탑의 저울이 함께 읽는 그림
같은 공격력을 기획자, 클라이언트, 서버가 함께 읽습니다.

캐릭터의 공격력 하나도 세 곳에서 읽습니다. 기획자는 엑셀에서 숫자를 고치고, Unity 클라이언트는 그 숫자로 전투를 그리고, NestJS 서버는 같은 숫자로 보상을 검증합니다.

엑셀 58개

기획자

엑셀에서 값을 고칩니다. 코드를 몰라도 게임이 바뀌어야 합니다.

C#

클라이언트

Unity C# 이 표를 읽어 화면과 전투를 만듭니다.

TypeScript

서버

NestJS 가 MySQL 의 같은 표로 결과를 검증합니다.

세 곳이 각자 정의를 들고 있으면 언젠가 어긋납니다. 그래서 우리는 원본을 하나로 두고 나머지는 모두 생성하는 길을 택했습니다.

FLOW

전체 흐름

  1. 엑셀 58개기획자가 고치는 유일한 원본
  2. ElDataExporter엑셀을 읽어 JSON 과 DB 로 내보냅니다
  3. JSON 51개 · MySQL클라이언트용 파일과 서버 DB
  4. Addressablesdata 묶음으로 원격 배포
  5. 런타임 테이블부팅할 때 Dictionary 로 채웁니다
도구입력출력
ElJsonSchemaBuilder스키마 JSONC# 클래스 8,465줄, TypeORM 엔티티 2,233줄
ElDataExporter엑셀 표클라이언트 JSON, 서버 MySQL
LoadJsonDataToTableJSON 파일메모리의 테이블
SCHEMA

스키마가 두 언어가 됩니다

테이블의 모양은 TableClasses.json 에 적습니다. 열 이름과 타입, 기본 키 같은 제약을 짧은 문자열로 씁니다.

TableClasses.jsonTDClient/Assets/Sources/Schema/TableClasses.jsonr5406 · 2–8줄
"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 엔티티입니다.

Program.csElServer/ElJsonSchemaBuilder/Program.csr163 · 29–36줄
if (extension == "ts")
{
    targetExtensionType = TargetExtensionType.TypeScript;
}
else if (extension == "cs")
{
    targetExtensionType = TargetExtensionType.CSharp;
}

같은 Character 표가 C# 에서는 이런 클래스가 됩니다. 손으로 고치지 않도록 파일 맨 위에 생성 코드라는 표시가 붙습니다.

TableClasses.csTDClient/Assets/Sources/Schema/TableClasses.csr5406 · 16–21줄
[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;
EXPORT

엑셀을 읽는 쪽의 작은 규칙들

ElDataExporter 는 외부 라이브러리 없이 xlsx 를 직접 풉니다. 그러면서 기획 현장에서 생기는 일을 규칙으로 정리해 두었습니다.

메모 열은 내보내지 않는다

열 이름이 memo 로 시작하거나 끝나면 JSON, 코드, DB 어디에도 실리지 않습니다. 기획자가 표 안에 마음껏 설명을 적을 수 있습니다.

행 길이를 맞춘다

xlsx 는 뒤쪽 빈 칸을 저장하지 않습니다. 그대로 두면 서버에 넣을 때 열과 값의 개수가 어긋나서, 헤더 길이로 모든 행을 채웁니다.

Program.csElServer/ElDataExporter/Program.csr163 · 967–973줄
private static bool IsMemoColumn(string columnName)
{
    if (string.IsNullOrEmpty(columnName)) return false;

    string lower = columnName.ToLower();
    return lower.StartsWith("memo") || lower.EndsWith("memo");
}
Program.csElServer/ElDataExporter/Program.csr163 · 995–1011줄
// 헤더 열 수에 맞춰 모든 행의 길이를 통일한다.
// 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;
    }
RUNTIME

런타임에서는 필드가 곧 테이블입니다

클라이언트는 테이블마다 정적 Dictionary 를 하나씩 둡니다. 지금 51개가 있습니다.

JsonDataStorage.csTDClient/Assets/Sources/StaticData/JsonDataStorage.csr5406 · 4–8줄
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 을 변환합니다. 그래서 새 테이블을 추가할 때 로더 코드는 고치지 않습니다. 필드를 더하면 됩니다.

TDScene.csTDClient/Assets/Sources/Base/TDScene.csr5406 · 533–559줄
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 타입
주석에 남은 장애 기록

같은 함수 아래쪽에는 키를 찾는 순서를 프로퍼티에서 필드로 넓힌 까닭이 적혀 있습니다. 일부 테이블은 생성 결과가 순수 필드로 나와 키를 찾지 못했고, 엉뚱한 프로퍼티를 키로 잡아 형 변환에 실패했습니다. 장애의 원인이 코드 옆에 그대로 남아 있어 다음 사람이 같은 실수를 하지 않습니다.

DEPLOY

앱을 다시 빌드하지 않는 데이터 교체

창고에서 바뀐 상자만 수레에 실어 앱 심사 관문을 돌아 스마트폰으로 옮기는 그림
바뀐 묶음만 받아 오므로 밸런스 수정은 앱 심사를 거치지 않습니다.

내보낸 JSON 은 Addressables 의 data 묶음에 들어갑니다. 이 묶음은 부팅할 때 가장 먼저 받는 라벨입니다.

TDAddressablesConfig.csTDClient/Assets/Sources/Base/TDAddressablesConfig.csr5406 · 22–28줄
/// <summary>부팅(StartScene) 시 필수 — 최소한으로 유지할 것</summary>
public static readonly string[] BootLabels =
{
    "data",
    "resources^prefab",
    "sound",
};

부팅할 때 원격 카탈로그를 비교해 바뀐 묶음만 받으므로, 밸런스를 고친 엑셀은 앱 심사를 다시 거치지 않고 배포할 수 있습니다. 6편에서 이 에셋 배포 구조를 자세히 다룹니다.

RETRO

돌아보며

잘된 점

  • 기획자는 엑셀을 고칩니다. 코드와 데이터 파일은 모두 생성됩니다.
  • 클라이언트와 서버의 타입을 같은 스키마에서 생성합니다.
  • 새 테이블을 추가해도 로더는 그대로입니다.
  • 메모 열 규칙 덕분에 표가 기획 문서 역할도 합니다.

아쉬운 점

  • 스키마 JSON 이 클라이언트 70종, 서버 64종으로 두 벌이 되었습니다. 원본이 하나라는 장점이 반쯤 깨졌습니다.
  • 서버와 같은 클래스를 쓰다 보니 클라이언트에도 모든 setter 에 변경 알림 코드가 들어 있습니다.
  • 키를 IDX 하나로 잡기 때문에, 여러 열이 키인 표는 IDX 가 겹치지 않는다는 관례에 기댑니다.

정리

  1. 원본은 하나, 나머지는 생성합니다. 엑셀과 스키마 JSON 이 원본이고 C#, TypeScript, JSON, DB 는 모두 결과물입니다.
  2. 규칙을 도구에 넣습니다. 메모 열과 빈 칸처럼 사람이 매번 신경 써야 하는 일은 내보내기 도구가 처리합니다.
  3. 데이터와 앱을 따로 배포합니다. 데이터 묶음을 원격으로 받으면 밸런스 수정에 앱 빌드가 필요 없습니다.

다음 편에서는 이 데이터 가운데 ActiveSkill 표가 어떻게 전투 스킬로 조립되는지 보겠습니다.

← 개발 노트 목록