ElServer 는 우리가 직접 만든 C# 게임 서버 라이브러리입니다. 여기에는 남이 만든 ORM 대신 손으로 지은 ORM 이 들어 있습니다. 엑셀에서 생성한 클래스를 그대로 DB 테이블로 쓰는 구조죠. 이번 리뷰에서는 그 ORM 이 어떻게 생겼는지, 지금 다시 만든다면 무엇을 바꿀지 살펴보겠습니다.
생성한 클래스를 그대로 테이블로
「100턴 구조 읽기」 1편에서 엑셀이 스키마 JSON 이 되고, 생성기가 그 스키마로 C# 클래스와 TypeORM 엔티티를 함께 만든다는 이야기를 했습니다. 서버 쪽 C# 클래스가 생겼으니 남은 질문은 이것입니다. 이 클래스를 DB 에 어떻게 연결할까.
스키마 JSON
엑셀에서 뽑은 표 정의. 컬럼 이름, 형식, 옵션이 들어 있습니다.
C# 엔티티
ElTableDefinition 을 상속한 클래스. 이 글의 주인공입니다.
TypeORM 엔티티
같은 스키마에서 나온 .ts. NestJS 서버가 씁니다.
MySQL · SQLite
서버는 MySQL, 로컬 도구는 SQLite. 같은 API 로 둘 다 다뤄야 했습니다.
Entity Framework 나 Dapper 를 붙이는 대신, 우리는 규칙을 세웠습니다. 생성된 클래스가 곧 스키마이고, 곧 쿼리 대상이다. 생성기는 스키마의 옵션 글자 앞에 El 을 붙여 C# 속성(attribute)으로 적습니다. PrimaryKey 는 [ElPrimaryKey] 가 되죠. 그 속성들은 런타임 쪽에 이렇게 정의되어 있습니다.
[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 이라는 이름 규칙으로 맞물려 있는 셈입니다.
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");
}다섯 겹의 층

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 종류를 따지지 않고 "컬럼 수정이 되나?" 를 물어 길을 고릅니다.
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();
패킷이 SELECT 가 되기까지
- 수신소켓의 JSON 을 패킷 객체로
- 분배메시지 표에서 핸들러를 찾고 연결을 빌려 줌
- 조회 호출
FindRow<T>("UID=…") - SQL 조립스키마를 찾아 조건 글자를 WHERE 로
- 실행풀에서 연결을 빌려 실행
- 바인딩행마다 객체를 만들고 이름으로 채움
패킷이 들어오면 리스너가 풀에서 연결을 꺼내 핸들러에 넘겨줍니다.
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 은 없습니다. 언제나 표 하나를 읽습니다.
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 에서 온 객체" 표시를 붙입니다. 이 표시가 다음 장의 열쇠입니다.
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 의 풀 실행기는 문장마다 풀에서 새로 빌려 쓰거든요. 요청 하나가 연결 두 개를 잡는 구조입니다.
문자열 조건과 즉시 쓰기
조건은 "UID=abc" 같은 글자로 받습니다. ORM 은 연산자를 찾아 글자를 둘로 나누고, 문자열 컬럼이면 따옴표를 붙이고, 인덱스가 없는 컬럼으로 찾으면 로그로 경고합니다. 느린 쿼리를 만들기 전에 알려 주는 친절한 장치였죠.
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 쓰기입니다.
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 한 번 | 보상처럼 여러 칸을 함께 고칠 때 |
마이그레이션 대신 부팅 때 맞추기

버전을 매긴 마이그레이션 파일은 없습니다. 서버가 뜰 때 RebuildSchema 가 코드의 클래스와 DB 의 표를 비교해 맞춥니다. 코드가 진실이라는 방식이죠.
| 상황 | MySQL | SQLite |
|---|---|---|
| 표가 없음 | CREATE TABLE | CREATE TABLE |
| 컬럼이 새로 생김 | ALTER … ADD COLUMN | 복사 표를 만들어 옮긴 뒤 이름 바꾸기 |
| 형식 · 옵션이 바뀜 | ALTER … MODIFY COLUMN | 복사 표를 만들어 옮긴 뒤 이름 바꾸기 |
| 코드에서 사라진 컬럼 | ALTER … DROP COLUMN | 복사할 때 빼기 |
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;
}프로퍼티 이름을 바꾸면 이 방식에서는 "새 컬럼 추가 + 옛 컬럼 삭제" 가 됩니다. 옛 컬럼의 값은 옮겨지지 않습니다. 이력 없는 자동 동기화의 편리함과 맞바꾼 위험입니다.
돌아보며
잘된 점
- 엑셀 → 스키마 → C# 클래스 → 표가 이름 규칙으로 꿰어집니다
- DB 차이를 기능 플래그로 모아 MySQL 과 SQLite 를 같은 코드로 다룹니다
- 인덱스 없는 조건을 경고하고, 여러 칸 쓰기를 UPDATE 한 번으로 묶는 메서드도 두었습니다
- 서버를 띄우면 스키마가 따라옵니다. 프로토타입 속도가 빨랐습니다
아쉬운 점
- 값을 SQL 문자열에 이어 붙입니다. 파라미터 바인딩을 쓰지 않습니다
- 풀 실행기의 트랜잭션 메서드가 비어 있고 문장마다 다른 연결을 써서, 요청을 원자적으로 묶지 못합니다
- 여러 DB 를 쓸 때 전역 실행기를 바꿔 끼우는 방식이라 스레드가 동시에 돌면 엉킬 수 있습니다
- 스키마에 없는 컬럼 조건은 오류 없이 버려집니다. 오타가 조건 없는 쿼리가 될 수 있습니다
- 마이그레이션 이력이 없고, 같은 스키마인데 C# 과 TS 의 형식이 어긋나는 곳(날짜 등)이 있습니다
돌아보면 이 ORM 은 빠르게 만드는 데 최적화되어 있었습니다. setter 가 곧 DB 쓰기가 되는 경험은 짜릿했지만, 그 편리함이 안전장치(바인딩, 트랜잭션, 이력)를 뒤로 미루게 만들었습니다.
다시 만든다면
파라미터 바인딩
값은 언제나 SQL 밖에서 따로 넘깁니다. 문자열 조건 대신 식(expression)으로 조건을 받습니다.
요청 단위 연결 · 트랜잭션
핸들러가 받은 연결로 요청 전체를 실행하고, 끝에서 커밋합니다.
버전 매긴 마이그레이션
부팅 동기화는 개발 환경에만. 운영은 이력이 남는 마이그레이션으로 바꿉니다.
정리
- 이름 규칙이 파이프라인을 꿰었습니다. 생성기가 붙인
El속성을 런타임이 읽어 엑셀에서 DB 표까지 이어졌습니다. - 방언은 기능 플래그로. DB 종류 대신 "무엇을 할 수 있나" 를 묻게 하면 MySQL 과 SQLite 를 같은 코드로 다룰 수 있습니다.
- 편리함은 안전장치와 맞바꾸기 쉽습니다. 즉시 쓰기와 문자열 SQL 은 속도를 줬고, 바인딩 · 트랜잭션 · 이력이라는 숙제를 남겼습니다.
다음 리뷰에서는 「졸업까지 100턴: 아카데미 라이프」 에서 판정은 웹이, 그리기는 Cocos 가 맡는 웹 퍼스트 하이브리드 구조를 살펴봅니다.