· 15 min read

C# 로직을 TypeScript로 옮기는 transpiler를 만들었다 — Mirrorgen

타입만 생성하는 codegen으로는 부족했다. C#의 로직을 byte-exact한 TypeScript로 옮기고, 두 구현이 같은 값을 내는지 자동 검증하는 transpiler를 만들어 OSS로 뗐다.

TL;DR — 내 게임은 OFF라는 직접 만든 C# 게임 프레임워크 위에 있고, 웹 클라이언트는 일부 레이어를 TypeScript로 쓴다. 같은 로직을 C#과 TS에 두 벌씩 손으로 짜는 게 문제였다. 타입만 뽑아주는 기존 도구로는 절반밖에 못 풀어서, C#의 로직 자체를 byte-exact한 TS로 변환하는 transpiler Mirrorgen을 만들어 OSS로 공개했다. Roslyn 기반, opt-in attribute, 그리고 C#과 생성된 TS가 같은 입력에 같은 출력을 내는지 자동 검증(cross-validation)이 핵심이다.

두 벌씩 짜는 게 싫었다

내 게임 splanet.ioOFF라는, 직접 만든 .NET 게임 프레임워크 위에 올라가 있다. OFF는 서버뿐 아니라 Three.js/WebGPU 기반 웹 렌더링 클라이언트까지 품는 풀스택 프레임워크인데, 게임 로직은 C#에 한 번 쓰고 어느 클라이언트에서든 재사용하는 게 원칙이다. 클라이언트가 Unity면 그대로 C#이라 경계가 없다.

문제는 splanet.io의 웹 클라이언트다. 큰 틀은 Blazor WebAssembly(이것도 C#)로 돌지만, 렌더링처럼 성능이 중요한 레이어는 TypeScript로 직접 짠다. 그 순간 C#과 TypeScript 사이에 경계가 생기고, 양쪽이 같은 데이터와 같은 규칙을 공유해야 한다.

데이터 계약(DTO, enum, 채널 이름, 매직 넘버)뿐 아니라, 배치 범위 계산·supply 범위·SOA 그리드·페인트 인코딩 같은 로직까지 C#에 한 벌, TypeScript에 또 한 벌 손으로 짜고 있었다.

두 벌 관리의 진짜 문제는 유지보수 비용이 아니라 조용히 깨진다(silent failure)는 점이다. 서버에서 AirTileId0에서 1로 바꿨는데 클라이언트의 tile === 0 가정을 같이 고치지 않으면, 컴파일도 통과하고 에러도 없이 동작만 미묘하게 틀어진다. 며칠 뒤에야 발견하는 종류의 버그다.

자바스크립트 특유의 함정도 있다. JS의 숫자는 전부 IEEE 754 double이라, C#의 ushort 오버플로우나 고정소수점 곱셈 결과가 JS에서는 다르게 나온다. 서버 시뮬레이션과 클라이언트 예측이 정확히 일치해야 하는 곳에서 이 차이는 추적하기 가장 어려운 버그가 된다.

타입만 뽑는 codegen으로는 절반만 풀린다

사실 OFF에는 이 경계를 메우려고 tsgen이라는 작은 코드 생성기를 이미 만들어 뒀었다. C#에서 [TsExport] 붙은 타입을 읽어 TypeScript interface와 enum으로 뽑아주는 도구다. 타입 경계는 이걸로 풀렸다.

그런데 tsgen은 타입의 모양(shape)만 만든다. interface와 enum은 만들어도 실행 가능한 함수 본체는 못 만들고, 상수 값조차 보존하지 못해 Foo: number 같은 멤버 선언으로만 떨어진다. 정작 양쪽에서 똑같아야 하는 건 타입이 아니라 로직인데, 그건 여전히 손코딩으로 남았다.

“이게 다 두 벌씩 있으니까 너무 보수가 힘들어서, 차라리 코드 생성 엔진을 제대로 만드는 게 낫겠다.”

목표는 C#을 Single Source of Truth로 두고 TypeScript를 자동 생성하는 것이었다. tsgen이 하던 타입뿐 아니라 로직까지. 그게 Mirrorgen의 출발점이다.

직접 만들기 전에 기존 도구부터 찾아봤다

컴파일러 비슷한 걸 만드는 건 범위가 넓은 일이라, 가능하면 가져다 쓰고 싶었다.

  • 타입만 생성하는 도구들(TypeGen, Tapper, Reinforced.Typings, NSwag 등): C# 타입 → TS interface/enum은 잘 하지만 함수 본체는 못 만든다. tsgen과 같은 한계다.
  • C#을 통째로 JS로 컴파일하는 도구들(Bridge.NET, H5, JSIL): 출력이 TS가 아니라 JS다. Bridge.NET은 이미 죽은 프로젝트인데, 지원하는 C# subset이 끝없이 커지다 감당하지 못한 게 원인이다.
  • WASM 경로(Blazor의 JSExport/JSImport): 코드 생성이 0줄이라는 장점이 있지만, 마샬링 비용 때문에 예전에 폐기한 경로다. 자주 호출하면 무겁고 디버깅도 어렵다.

가장 가까운 건 Rosetta(andry-tino)였다. Roslyn 기반으로 메서드 본체까지 C#→TS로 변환하는, 내가 만들려던 것과 거의 같은 도구였는데 abandoned 상태였다. 같은 걸 하던 도구가 대부분 멈췄다는 건 그만큼 어렵다는 뜻이고, 그들이 어디서 멈췄는지가 곧 설계 참고가 됐다.

그리고 검토한 도구 중 C#과 생성된 코드가 같은 값을 내는지 검증하는 것은 하나도 없었다. 거기에 빈자리가 있었다.

핵심 1 — opt-in attribute + Roslyn SemanticModel

변환 대상은 [Transpile] attribute로 명시적으로 고른다.

[Transpile]
public static class Pricing
{
    [Transpile, GenerateCrossTest(Samples = 16, Seed = 2)]
    public static int Total(int unitPrice, int quantity)
    {
        return unitPrice * quantity;
    }
}

opt-in으로 한 건 Bridge.NET의 교훈 때문이다. 전부 변환하려 들면 지원할 C# 문법이 끝없이 커진다. 변환할 것만 표시하면 subset의 경계를 내가 통제할 수 있다.

처음엔 구문 트리(syntactic AST)만 순회하는 walker로 시작했다가, 타입 추론·여러 파일에 걸친 reachability·상수 폴딩이 필요해지면서 CSharpCompilation + SemanticModel 기반으로 바꿨다. subset의 경계는 Roslyn analyzer로 강제한다 — [Transpile] 안에서 async, LINQ, Span<T> 같은 변환 불가능한 기능을 쓰면 IDE에서 바로 경고가 뜬다. 런타임이 아니라 작성 시점에 막는다.

핵심 2 — byte-exact 정수 의미론 (제일 어려웠다)

JS에는 정수 타입이 없고 모든 숫자가 double이다. 그래서 C#의 정수 의미론을 명시적으로 복원해야 한다. 위의 Total이 생성하는 TS를 보면:

export function Total(unitPrice: number, quantity: number): number {
  return Math.imul(unitPrice, quantity);
}

곱셈이 *가 아니라 Math.imul이다. C#의 int 곱셈은 32비트로 wrap되는데, JS의 *는 double 곱이라 큰 값에서 결과가 달라지기 때문이다. 다른 규칙도 마찬가지다:

C#TypeScript
int + - / %(a op b) | 0
int *Math.imul(a, b)
long / ulongBigInt.asIntN(64, …) / asUintN(64, …)
(byte)exprexpr & 0xff
x == y x != yx === y x !== y

discountPct를 clamp하고 적용하는 메서드를 보면 | 0이 어떻게 끼는지 더 잘 보인다:

export function ApplyDiscount(total: number, discountPct: number): number {
  let pct: number = discountPct < 0 ? 0 : (discountPct > 100 ? 100 : discountPct);
  return ((Math.imul(total, (((100 - pct) | 0))) / 100) | 0);
}

여기서 함정 둘을 만났다. 하나는 연산자 우선순위(a + b) | 0 <= c를 그대로 생성하면 |가 비교 연산자보다 우선순위가 낮아 (a + b) | (0 <= c)로 파싱된다. 그래서 모든 하위 표현식을 괄호로 감싼다(위 코드가 괄호 범벅인 이유다). 다른 하나는 로케일 — C#의 숫자 → 문자열 변환이 내 한국어 로케일을 타서 3.143,14로 찍히는 버그가 있었다. 생성 코드는 항상 InvariantCulture를 강제한다.

longbigint로 매핑한 것도 의도적이다. tsgen은 longnumber로 떨궈서 53비트 너머에서 정밀도를 잃었다. bigint는 64비트 안전하다. 조용히 틀리는 것보다 정확한 게 낫다고 봤다.

핵심 3 — cross-validation fixture

변환 규칙을 잘 짜더라도 C#과 생성된 TS가 같은 값을 내는지 증명하지 못하면 의미가 없다. 그래서 검토한 어떤 도구에도 없던 기능을 넣었다.

[Transpile, GenerateCrossTest(Samples = 16, Seed = 1)]
[CrossTestCase(int.MinValue, 100)]
[CrossTestCase(int.MaxValue, 100)]
public static int ClampQuantity(int requested, int max) { ... }

[GenerateCrossTest]가 붙으면, 같은 시드로 랜덤 입력(+ [CrossTestCase]로 지정한 코너 케이스)을 생성해 C#에서 한 번, 생성된 TS에서 한 번 실행하고 결과가 비트 단위로 일치하는지 검증하는 테스트를 만든다. 변환 규칙이 어딘가에서 어긋나면 CI가 머지 전에 잡는다. 타입만 생성하는 도구와, 로직 미러를 신뢰할 수 있는 도구의 차이가 여기서 갈린다.

tsgen을 흡수하고, OFF에서 들어냈다

처음 계획은 “로직이 필요한 곳에만 Mirrorgen을 쓰고 타입은 tsgen이 계속 맡는다”였다. 그런데 C#→TS 생성을 하는 도구가 둘 공존하면 어느 게 뭘 담당하는지가 또 다른 관리 부담이 된다. 두 벌 손코딩을 없애려고 시작한 일인데 도구를 두 벌 두는 꼴이었다.

그래서 Mirrorgen이 tsgen을 superset으로 흡수하도록 방향을 바꿨다. tsgen이 하던 type-only 출력을 포함하고, 그 위에 상수 값 보존, 인스턴스 메서드를 가진 클래스(토폴로지 계산기 등) 변환, 여러 csproj 교차 스캔을 얹었다.

중요한 건 Mirrorgen을 OFF에 다시 박지 않고 특정 게임·엔진에 묶이지 않은 범용 OSS 도구로 분리했다는 점이다. 그걸 OFF가 NuGet 의존성으로 소비하고, 게임은 OFF를 통해 쓴다. OFF의 빌트인이던 tsgen은 제거했다 — OpenFieldFramework.TsGen/ 디렉터리와 TsExportAttribute를 삭제하고, 18개 .cs 파일의 [TsExport][Transpile]로 바꿨다.

타입 커버리지는 숫자로 확인했다. tsgen의 golden 출력이 126개 export였는데 Mirrorgen은 127개를 냈다 — 누락 0개에, tsgen이 놓치던 상수 하나를 추가로 잡았다.

진짜 이득은 tsgen이 못 하던 손코딩 로직 미러 제거다. OFF 클라이언트에는 “*.cs의 TS 미러”라고 주석을 단 TypeScript 파일이 8개 있었다 — 셀 ID, 평면/토러스 토폴로지, 큐브스피어 투영·엣지 인접·힐베르트 곡선처럼 서버와 한 비트도 어긋나면 안 되는 기하 계산들이다. 이걸 C# 원본에서 자동 생성하는 쪽으로 하나씩 옮기고 있다. 첫 타자 HilbertCurve는 손코딩 TS 미러를 지우고 C#에서 생성하도록 바꿨다 — 줄 수는 비슷하지만, 그 코드는 더 이상 사람이 유지보수하지 않는다.

지금 상태

github.com/penspanic/Mirrorgen으로 MIT 오픈소스 공개돼 있고, NuGet에 다섯 개 패키지가 올라가 있다(현재 0.3.1). 로직을 정의하는 프로젝트에 패키지를 걸면 빌드 타임에 TS가 생성된다.

<ItemGroup>
  <PackageReference Include="Mirrorgen.Attributes" Version="0.3.1" />
  <PackageReference Include="Mirrorgen.Analyzers" Version="0.3.1" PrivateAssets="all" />
  <PackageReference Include="Mirrorgen.MSBuild" Version="0.3.1" PrivateAssets="all" />
</ItemGroup>

CLI로 가볍게 돌려보려면:

dotnet tool install -g Mirrorgen.Cli
mirrorgen --help

OSS로 분리한 데 특별한 이유는 없다. 비슷한 문제(C#↔TS 이중 관리)를 겪는 사람이 있을 수 있고, NuGet·npm에선 패키지 배포·소비가 편하고, 공개 저장소로 두면 유지보수에 더 신경 쓰게 된다.

손코딩 미러를 걷어내는 작업은 아직 진행 중이다 — HilbertCurve는 끝났고 토폴로지·큐브스피어 쪽이 남아 있다. 그 진행 상황은 다음에 다시 적겠다.


이 글은 2026년 5월 splanet.io / OFF 개발 중 Mirrorgen을 만든 Claude Code 세션 로그를 기반으로 작성됐다. 코드 예시는 Mirrorgen 저장소의 minimal 샘플에서 가져온 실제 생성 결과다.

Related Posts

View All Posts »
NuGet 프라이빗 패키지로 .NET 코드 분리하기 — 멀티 레포 9개 함정

NuGet 프라이빗 패키지로 .NET 코드 분리하기 — 멀티 레포 9개 함정

OFF를 NuGet 프라이빗 패키지로 떼어, 협업자에게 프레임워크·서버 소스를 노출하지 않고도 풀스택 dev 루프를 유지한 멀티 레포 구조와, 옮기면서 밟은 9개의 함정.

#OFF #Aethelgard #GameDev #DotNet
엔진 독립 풀스택 오픈월드 게임 프레임워크 — OFF

엔진 독립 풀스택 오픈월드 게임 프레임워크 — OFF

엔진에 종속되지 않는 풀스택 오픈월드 게임 프레임워크 OFF(Open Field Framework) 소개. 분산 .NET 서버, Three.js/WebGPU 웹 클라이언트, 월드 저작 툴링까지 한 번에 제공한다.

#OFF #Architecture #CSharp #Multiplay
OFF에 행성을 얹다 — Topology-Agnostic 아키텍처

OFF에 행성을 얹다 — Topology-Agnostic 아키텍처

무한 평면 분산 서버 프레임워크였던 OFF에 cube-sphere 행성을 얹은 이야기. 평면도 구체도 같은 코어 위에서 도는 구조로 어떻게 바꿨나.

#OFF #Architecture #GameServer #Distributed