· 15 min read
C# 로직을 TypeScript로 옮기는 transpiler를 만들었다 — Mirrorgen Mirrorgen — A C#-to-TypeScript Transpiler for Logic, Not Just Types
타입만 생성하는 codegen으로는 부족했다. C#의 로직을 byte-exact한 TypeScript로 옮기고, 두 구현이 같은 값을 내는지 자동 검증하는 transpiler를 만들어 OSS로 뗐다. Type-only codegen wasn't enough. I built a transpiler that turns C# logic into byte-exact TypeScript and proves the two stay in lockstep — and open-sourced it.
TL;DR — 내 게임은 OFF라는 직접 만든 C# 게임 프레임워크 위에 있고, 웹 클라이언트는 일부 레이어를 TypeScript로 쓴다. 같은 로직을 C#과 TS에 두 벌씩 손으로 짜는 게 문제였다. 타입만 뽑아주는 기존 도구로는 절반밖에 못 풀어서, C#의 로직 자체를 byte-exact한 TS로 변환하는 transpiler Mirrorgen을 만들어 OSS로 공개했다. Roslyn 기반, opt-in attribute, 그리고 C#과 생성된 TS가 같은 입력에 같은 출력을 내는지 자동 검증(cross-validation)이 핵심이다.
두 벌씩 짜는 게 싫었다
내 게임 splanet.io는 OFF라는, 직접 만든 .NET 게임 프레임워크 위에 올라가 있다. OFF는 서버뿐 아니라 Three.js/WebGPU 기반 웹 렌더링 클라이언트까지 품는 풀스택 프레임워크인데, 게임 로직은 C#에 한 번 쓰고 어느 클라이언트에서든 재사용하는 게 원칙이다. 클라이언트가 Unity면 그대로 C#이라 경계가 없다.
문제는 splanet.io의 웹 클라이언트다. 큰 틀은 Blazor WebAssembly(이것도 C#)로 돌지만, 렌더링처럼 성능이 중요한 레이어는 TypeScript로 직접 짠다. 그 순간 C#과 TypeScript 사이에 경계가 생기고, 양쪽이 같은 데이터와 같은 규칙을 공유해야 한다.
데이터 계약(DTO, enum, 채널 이름, 매직 넘버)뿐 아니라, 배치 범위 계산·supply 범위·SOA 그리드·페인트 인코딩 같은 로직까지 C#에 한 벌, TypeScript에 또 한 벌 손으로 짜고 있었다.
두 벌 관리의 진짜 문제는 유지보수 비용이 아니라 조용히 깨진다(silent failure)는 점이다. 서버에서 AirTileId를 0에서 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 / ulong | BigInt.asIntN(64, …) / asUintN(64, …) |
(byte)expr | expr & 0xff |
x == y x != y | x === 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.14가 3,14로 찍히는 버그가 있었다. 생성 코드는 항상 InvariantCulture를 강제한다.
long을 bigint로 매핑한 것도 의도적이다. tsgen은 long을 number로 떨궈서 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 --helpOSS로 분리한 데 특별한 이유는 없다. 비슷한 문제(C#↔TS 이중 관리)를 겪는 사람이 있을 수 있고, NuGet·npm에선 패키지 배포·소비가 편하고, 공개 저장소로 두면 유지보수에 더 신경 쓰게 된다.
손코딩 미러를 걷어내는 작업은 아직 진행 중이다 — HilbertCurve는 끝났고 토폴로지·큐브스피어 쪽이 남아 있다. 그 진행 상황은 다음에 다시 적겠다.
이 글은 2026년 5월 splanet.io / OFF 개발 중 Mirrorgen을 만든 Claude Code 세션 로그를 기반으로 작성됐다. 코드 예시는 Mirrorgen 저장소의 minimal 샘플에서 가져온 실제 생성 결과다.


