로그인게임하기
코드 리뷰

[ElServer 리뷰] 엑셀 스키마를 그대로 테이블로 쓰는 C# ORM

2026.10.11

코드 리뷰 · ElServer

ElServer 는 우리가 직접 만든 C# 게임 서버 라이브러리입니다. 여기에는 남이 만든 ORM 대신 손으로 지은 ORM 이 들어 있습니다. 엑셀에서 생성한 클래스를 그대로 DB 테이블로 쓰는 구조죠. 이번 리뷰에서는 그 ORM 이 어떻게 생겼는지, 지금 다시 만든다면 무엇을 바꿀지 살펴보겠습니다.

PROBLEM

생성한 클래스를 그대로 테이블로

「100턴 구조 읽기」 1편에서 엑셀이 스키마 JSON 이 되고, 생성기가 그 스키마로 C# 클래스와 TypeORM 엔티티를 함께 만든다는 이야기를 했습니다. 서버 쪽 C# 클래스가 생겼으니 남은 질문은 이것입니다. 이 클래스를 DB 에 어떻게 연결할까.

원본

스키마 JSON

엑셀에서 뽑은 표 정의. 컬럼 이름, 형식, 옵션이 들어 있습니다.

ElServer

C# 엔티티

ElTableDefinition 을 상속한 클래스. 이 글의 주인공입니다.

생성 대상

TypeORM 엔티티

같은 스키마에서 나온 .ts. NestJS 서버가 씁니다.

저장소

MySQL · SQLite

서버는 MySQL, 로컬 도구는 SQLite. 같은 API 로 둘 다 다뤄야 했습니다.

Entity Framework 나 Dapper 를 붙이는 대신, 우리는 규칙을 세웠습니다. 생성된 클래스가 곧 스키마이고, 곧 쿼리 대상이다. 생성기는 스키마의 옵션 글자 앞에 El 을 붙여 C# 속성(attribute)으로 적습니다. PrimaryKey 는 [ElPrimaryKey] 가 되죠. 그 속성들은 런타임 쪽에 이렇게 정의되어 있습니다.

ElDatabase.csElServer/ElCommon/Core/ElDatabase.csr163 · 930–933, 940–949줄ElAutoIncrement · ElIndexed · ElAllowNull 도 같은 모양이라 줄임
[AttributeUsage(AttributeTargets.Property)]
public class ElPrimaryKeyAttribute : Attribute
{
}
…
[AttributeUsage(AttributeTargets.Property)]
public class ElMaxLengthAttribute : Attribute
{
    public int Value { get; private set; }

    public ElMaxLengthAttribute(int length)
    {
        Value = length;
    }
}

런타임은 이 속성을 리플렉션으로 읽어 컬럼 정보를 만듭니다. 생성기와 런타임이 El 이라는 이름 규칙으로 맞물려 있는 셈입니다.

ElTableSchema.csElServer/ElCommon/Core/ElTableSchema.csr163 · 48–68줄
Column c = new Column(pi.Name, pi.PropertyType);

if (pi.GetCustomAttribute(typeof(ElPrimaryKeyAttribute)) != null)
{
    c.IsPrimaryKey = true;
    c.IsIndex = true;
    has_primary_key = true;
    ElILogger.Get().Log(pi.Name + " IsPrimaryKey");
}

if (c.IsPrimaryKey && pi.GetCustomAttribute(typeof(ElAutoIncrementAttribute)) != null)
{
    c.IsAutoIncrement = true;
    ElILogger.Get().Log(pi.Name + " IsAutoIncrement");
}

if (pi.GetCustomAttribute(typeof(ElIndexedAttribute)) != null)
{
    c.IsIndex = true;
    ElILogger.Get().Log(pi.Name + " IsIndex");
}
STRUCTURE

다섯 겹의 층

다섯 층 탑의 단면. 맨 위 엔티티 갤러리부터 목록 카드실, 접수대, 두 문이 있는 통역실, 맨 아래 배관실까지 로라가 승강기로 내려가는 그림
엔티티부터 커넥션까지 다섯 층. 넷째 층의 방언 계약이 가장 잘한 선택이었습니다.

ORM 은 위에서 아래로 다섯 층입니다. 핵심 다섯 파일이 2,208줄, 서버 쪽 DB 층 세 파일이 1,155줄입니다. 여기에 SQLite 구현으로 공개 라이브러리 sqlite-net(3,844줄)이 더해집니다.

엔티티생성된 클래스. 속성 다섯 종(ElPrimaryKey, ElAutoIncrement, ElIndexed, ElAllowNull, ElMaxLength)으로 컬럼을 설명합니다
메타데이터ElTableSchema 가 클래스를 표로 읽고, ElSchemaFactory 가 네임스페이스를 훑어 모두 등록합니다
정적 창구ElDatabase. FindRow<T>, AddRow, DeleteRow 같은 public static 메서드 27개가 SQL 을 조립합니다
방언 계약ElIDatabaseExecutor. 추상 멤버 18개, 구현 세 개(MySQL 스키마용 · MySQL 풀 · SQLite)
커넥션ElConnectionPool 이 미리 열어 둔 연결을 빌려주고, MySqlConnector 가 실제로 실행합니다

가장 잘한 선택은 넷째 층입니다. MySQL 과 SQLite 의 차이를 if 문으로 흩뿌리지 않고, 할 수 있는 일을 묻는 플래그로 모았습니다. 위층 코드는 DB 종류를 따지지 않고 "컬럼 수정이 되나?" 를 물어 길을 고릅니다.

ElDatabase.csElServer/ElCommon/Core/ElDatabase.csr163 · 883–895, 905–910줄896–904줄(조회 · 표 정보 멤버)은 줄임
public abstract class ElIDatabaseExecutor
{
    public abstract int ExecuteNonQuery(string sql);

    public abstract List<TableClass> ExecuteQuery<TableClass>(string sql);

    public abstract bool IsSupportGeneric();

    public abstract bool IsSupportDeclareAutoIncrement();

    public abstract bool IsSupportModify();

    public abstract string GetAutoIncrementKeyword();
…
    public abstract void BeginTransaction();

    public abstract void Commit();

    public abstract void Rollback();
FLOW

패킷이 SELECT 가 되기까지

  1. 수신소켓의 JSON 을 패킷 객체로
  2. 분배메시지 표에서 핸들러를 찾고 연결을 빌려 줌
  3. 조회 호출FindRow<T>("UID=…")
  4. SQL 조립스키마를 찾아 조건 글자를 WHERE 로
  5. 실행풀에서 연결을 빌려 실행
  6. 바인딩행마다 객체를 만들고 이름으로 채움

패킷이 들어오면 리스너가 풀에서 연결을 꺼내 핸들러에 넘겨줍니다.

ElMainListener.csElServer/ElServer/Core/ElMainListener.csr163 · 204–229줄
DbConnection conn = ElConnectionPool.Get().GetConnection();

try
{
    if (receive_packet.Type == -1)
    {
        ElPacket_GroupRequest typed_pk = (ElPacket_GroupRequest)receive_packet;
        ElPacket packet;
        for (int i = 0; i < typed_pk.PacketList.Count; ++i)
        {
            packet = typed_pk.PacketList[i];
            if (MessageTable.ContainsKey((int)packet.Type))
            {
                MessageTable[(int)packet.Type](packet, sender, conn);
            }
        }
    }
    else if (receive_packet.Type == -2)
    {
        ElPacket_Ping_Response response = new ElPacket_Ping_Response();
        SendPacketTo(sender, response);
    }
    else if (MessageTable.ContainsKey((int)receive_packet.Type))
    {
        MessageTable[(int)receive_packet.Type](receive_packet, sender, conn);
    }

핸들러가 FindRow<T> 를 부르면 ElDatabase 가 클래스 이름으로 스키마를 찾고, 조건 글자를 WHERE 절로 바꿔 SELECT 를 만듭니다. JOIN 은 없습니다. 언제나 표 하나를 읽습니다.

ElDatabase.csElServer/ElCommon/Core/ElDatabase.csr163 · 80–99줄
ElTableSchema? ts = ElSchemaFactory.GetTableSchema(tableName);

if (ts != null)
{
    string where = MakeWhereConditionSQL(ts, whereConditions);
    string sql = string.Format("SELECT " + wildcard + " FROM {0} WHERE {1}", tableName, where);

    if (where.Trim().Equals(""))
    {
        sql = sql.Replace(" WHERE ", "");
    }

    if (!string.IsNullOrEmpty(lastOption))
    {
        sql += " " + lastOption + ";";
    }
    else
    {
        sql += ";";
    }

결과는 리플렉션으로 객체에 채웁니다. 프로퍼티 이름과 컬럼 이름이 같으면 값을 넣고, 마지막에 OnEndInit 으로 "DB 에서 온 객체" 표시를 붙입니다. 이 표시가 다음 장의 열쇠입니다.

ElDatabase.csElServer/ElCommon/Core/ElDatabase.csr163 · 648–676줄
foreach (var fieldInfo in fields)
{
    foreach (DataColumn dc in dt.Columns)
    {
        // Matching the columns with fields
        if (fieldInfo.Name == dc.ColumnName)
        {
            // Get the value from the datatable cell
            object value = dr[dc.ColumnName];

            if (value != DBNull.Value)
            {
                if (value.GetType() == typeof(DateTime))
                {
                    fieldInfo.SetValue(ob, value.ToString());
                }
                else
                {
                    // Set the value into the object
                    fieldInfo.SetValue(ob, value);
                }
            }

            break;
        }
    }
}

ob.OnEndInit(currentExecutor);
연결이 둘

리스너가 핸들러에 넘겨준 연결과, ORM 이 SQL 을 실행할 때 쓰는 연결은 서로 다릅니다. ORM 의 풀 실행기는 문장마다 풀에서 새로 빌려 쓰거든요. 요청 하나가 연결 두 개를 잡는 구조입니다.

MAPPING

문자열 조건과 즉시 쓰기

조건은 "UID=abc" 같은 글자로 받습니다. ORM 은 연산자를 찾아 글자를 둘로 나누고, 문자열 컬럼이면 따옴표를 붙이고, 인덱스가 없는 컬럼으로 찾으면 로그로 경고합니다. 느린 쿼리를 만들기 전에 알려 주는 친절한 장치였죠.

ElDatabase.csElServer/ElCommon/Core/ElDatabase.csr163 · 169–193줄
Column? c = ts.GetColumn(search_column);

if (c != null)
{
    if (!keyword.Contains('\'') && c.ColumnType == typeof(string))
    {
        keyword = "'" + keyword + "'";
    }

    if (!c.IsIndex && !c.IsPrimaryKey)
    {
        ElILogger.Get().MessageLog("Column [" + c.ColumnName + "] is not index column. This will make slower.");
    }

    string condition = search_column + op + keyword;

    if (count > 0)
    {
        where += " AND ";
    }

    where += condition;

    count++;
}

쓰기는 더 과감합니다. 생성된 프로퍼티의 setter 는 값이 바뀌면 OnPropertyChanged 를 부르고, DB 에서 온 객체라면 값이 바뀌는 즉시 UPDATE 를 실행합니다. user.Gold = 100; 이 곧 DB 쓰기입니다.

ElTableDefinition.csElServer/ElCommon/Core/ElTableDefinition.csr163 · 134–147, 162–163줄148–161줄(값을 글자로 바꾸는 부분, 대부분 주석 처리된 옛 코드)은 줄임
protected void OnPropertyChanged(string propertyName, object newValue)
{
    if (!bLoaded) return;
    if (bStopUpdateDB)
    {
        changes.Add(new ChangeEntry(propertyName, newValue));
        return;
    }

    if (parentExecutor == null)
    {
        throw new Exception("DB Executor is null. Call ElDatabase.SetDatabaseExecutor(ElIDatabaseExecutor) to set DB Executor.");
    }

…
    parentExecutor.ExecuteNonQuery(string.Format("UPDATE {0} SET {1}={2} WHERE {3}", GetClassName(), propertyName, newValueStr, GetPrimaryKeyWhereStatement()));
}
쓰기 방식하는 일쓰는 때
기본setter 를 부를 때마다 UPDATE값 하나를 고칠 때
모아 쓰기StopUpdateDB() 뒤의 변경을 모았다가 UpdateChanges() 에서 UPDATE 한 번보상처럼 여러 칸을 함께 고칠 때
SYNC

마이그레이션 대신 부팅 때 맞추기

설계도에 맞춰 놋쇠 골렘들이 서랍장에 새 서랍을 끼우고, 크기를 바꾸고, 설계도에 없는 서랍을 빼 종이가 쏟아지는 그림
부팅 때 코드에 맞춰 표를 고칩니다. 코드에서 사라진 칸은 데이터와 함께 지워집니다.

버전을 매긴 마이그레이션 파일은 없습니다. 서버가 뜰 때 RebuildSchema 가 코드의 클래스와 DB 의 표를 비교해 맞춥니다. 코드가 진실이라는 방식이죠.

상황MySQLSQLite
표가 없음CREATE TABLECREATE TABLE
컬럼이 새로 생김ALTER … ADD COLUMN복사 표를 만들어 옮긴 뒤 이름 바꾸기
형식 · 옵션이 바뀜ALTER … MODIFY COLUMN복사 표를 만들어 옮긴 뒤 이름 바꾸기
코드에서 사라진 컬럼ALTER … DROP COLUMN복사할 때 빼기
ElSchemaFactory.csElServer/ElCommon/Core/ElSchemaFactory.csr163 · 371–391줄
DataColumn? dtc = FindDataColumn(c.ColumnName, ref dataColumns);

if (dtc == null)
{
    column_sql += "ADD COLUMN ";
    MakeColumnSql(c, ref column_sql, executor);
    do_modify = true;
}
else
{
    if (!executor.IsEqualTypes(c.ColumnType, dtc.DataType)
        || c.AllowDBNull != dtc.AllowDBNull
         || c.IsPrimaryKey != IsPrimaryKey(dtc)
         || c.IsAutoIncrement != dtc.AutoIncrement
         || c.DefaultValue != dtc.DefaultValue.ToString().Replace("'", "")
         || (dtc.MaxLength >= 0 && c.MaxLength >= 0 && c.MaxLength != dtc.MaxLength))
    {
        column_sql += "MODIFY COLUMN ";
        MakeColumnSql(c, ref column_sql, executor);
        do_modify = true;
    }
이름을 바꾸면 데이터가 사라집니다

프로퍼티 이름을 바꾸면 이 방식에서는 "새 컬럼 추가 + 옛 컬럼 삭제" 가 됩니다. 옛 컬럼의 값은 옮겨지지 않습니다. 이력 없는 자동 동기화의 편리함과 맞바꾼 위험입니다.

RETRO

돌아보며

잘된 점

  • 엑셀 → 스키마 → C# 클래스 → 표가 이름 규칙으로 꿰어집니다
  • DB 차이를 기능 플래그로 모아 MySQL 과 SQLite 를 같은 코드로 다룹니다
  • 인덱스 없는 조건을 경고하고, 여러 칸 쓰기를 UPDATE 한 번으로 묶는 메서드도 두었습니다
  • 서버를 띄우면 스키마가 따라옵니다. 프로토타입 속도가 빨랐습니다

아쉬운 점

  • 값을 SQL 문자열에 이어 붙입니다. 파라미터 바인딩을 쓰지 않습니다
  • 풀 실행기의 트랜잭션 메서드가 비어 있고 문장마다 다른 연결을 써서, 요청을 원자적으로 묶지 못합니다
  • 여러 DB 를 쓸 때 전역 실행기를 바꿔 끼우는 방식이라 스레드가 동시에 돌면 엉킬 수 있습니다
  • 스키마에 없는 컬럼 조건은 오류 없이 버려집니다. 오타가 조건 없는 쿼리가 될 수 있습니다
  • 마이그레이션 이력이 없고, 같은 스키마인데 C# 과 TS 의 형식이 어긋나는 곳(날짜 등)이 있습니다

돌아보면 이 ORM 은 빠르게 만드는 데 최적화되어 있었습니다. setter 가 곧 DB 쓰기가 되는 경험은 짜릿했지만, 그 편리함이 안전장치(바인딩, 트랜잭션, 이력)를 뒤로 미루게 만들었습니다.

NEXT

다시 만든다면

1순위

파라미터 바인딩

값은 언제나 SQL 밖에서 따로 넘깁니다. 문자열 조건 대신 식(expression)으로 조건을 받습니다.

2순위

요청 단위 연결 · 트랜잭션

핸들러가 받은 연결로 요청 전체를 실행하고, 끝에서 커밋합니다.

3순위

버전 매긴 마이그레이션

부팅 동기화는 개발 환경에만. 운영은 이력이 남는 마이그레이션으로 바꿉니다.

정리

  1. 이름 규칙이 파이프라인을 꿰었습니다. 생성기가 붙인 El 속성을 런타임이 읽어 엑셀에서 DB 표까지 이어졌습니다.
  2. 방언은 기능 플래그로. DB 종류 대신 "무엇을 할 수 있나" 를 묻게 하면 MySQL 과 SQLite 를 같은 코드로 다룰 수 있습니다.
  3. 편리함은 안전장치와 맞바꾸기 쉽습니다. 즉시 쓰기와 문자열 SQL 은 속도를 줬고, 바인딩 · 트랜잭션 · 이력이라는 숙제를 남겼습니다.

다음 리뷰에서는 「졸업까지 100턴: 아카데미 라이프」 에서 판정은 웹이, 그리기는 Cocos 가 맡는 웹 퍼스트 하이브리드 구조를 살펴봅니다.

← 개발 노트 목록