· 8 min read

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

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

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

문제: 모노레포에 협업자를 부를 수가 없다

Aethelgard는 OFF(OpenFieldFramework) 위에 올린 첫 게임 프로젝트다. 단일 레포 + OFF git submodule 구조로 잘 굴러갔지만, 클라/아트/기획 1인 협업자가 합류하면서 문제가 생겼다. 게다가 OFF 위에 올릴 두 번째 게임도 기획 중이고 거기서도 협업자가 붙을 가능성이 있으니, 매번 모노레포를 풀어내는 대신 한 번 구조를 잡아두기로 했다.

협업자에게 보여주고 싶지 않은 것:

  1. OFF 소스코드 — 1년 넘게 쌓아온 자산. 향후 오픈소스화 가능성은 있지만 시점은 API 안정 후.
  2. 서버 소스코드 — 핵심 게임 메카닉이 노출돼 있다.
  3. 서버 빌드 셋업dotnet publish + RID + 4 플랫폼 zip 준비를 시키고 싶지 않다.

동시에 나 자신은:

  • OFF 코드에 breakpoint 걸고 게임 띄우는 워크플로우를 잃고 싶지 않다.
  • OFF 변경이 두 게임 레포에 빠르게 전파돼야 한다.
  • 협업자는 zip 다운받고 실행만으로 서버를 띄울 수 있어야 한다.

최종 구조

Aethelgard 멀티 레포 구조
Aethelgard 멀티 레포 구조

Datra(데이터 관리 라이브러리)는 별개 public 레포다. 이번 작업에서 새로 한 건 NuGet 발행뿐이고, 외부 의존으로 자연스럽게 흡수됐다.

핵심 결정:

항목결정근거
OFF 버전 정책모든 OFF.* 패키지 동일 버전mix-and-match 디버깅 비용 회피
버전 관리Directory.Packages.props (CPM)단일 지점 pin
클라 → 서버 embedsubmodule 유지WASM 정적 자산은 NuGet에 안 맞음
서버 배포dotnet publish --self-contained → zip + chmod 스크립트Docker 학습 부담 0
배포 RIDosx-arm64, osx-x64, win-x64, linux-x64Windows runner cross-compile
산출물GitHub Releases + S3 (OIDC)무료, 협업자는 S3에서 RID zip만 다운
협업자 레포 CIGH-hosted ubuntu팀 공유 레포에 self-hosted 금지 (PR 통한 머신 장악 방지)

옮긴 흐름

Phase 1 — OFF NuGet 패키징. 16개 라이브러리 csproj에 IsPackable=true + 메타데이터, SourceLink + DebugType=embedded + EmbedAllSources (snupkg 분리 대신 PDB embed — 협업자 셋업 비용 최소화). 태그 푸시 시 publish, main push 시 -alpha.{run} 프리릴리즈. OFF에 박혀있던 Datra submodule도 같이 떼고 PackageReference Datra 0.1.*로 전환.

Phase 2 — Aethelgard 클라이언트 레포 분리. git filter-repo로 모노레포에서 클라 surface(client/, Aethelgard.Shared, data/, docs/gdd/, 협업자용 .claude/skills/)만 추출, 경로 평탄화. CPM 도입(단일 bump 지점: OpenFieldFrameworkVersion, DatraVersion). dev-host/OFF.Hosting.Web만 참조하는 경량 호스트로, 협업자가 여기서 dotnet watch로 클라 핫리로드. TsGen 산출물(*.ts)은 git에 커밋된 단방향 흐름 — 클라 레포에선 TsGen이 안 돌고, 서버 레포가 빌드 시 생성·푸시한다. CI는 GH-hosted ubuntu.

Phase 3 — 서버 레포 분리. aethelgard-internal에 server-only surface 추출. external/aethelgard(클라, 항상 init) + external/openfieldframework(OFF, 오너 dev 모드 전용) 두 submodule. UseLocalOFF 기본 false (NuGet, SourceLink로 step debugging 가능), true는 오너 전용. release.yml은 main push 시 4 RID publish → S3 + GH Releases.

9가지 함정

  1. PrivateAssets=“all”이 transitive 빌드를 깨뜨릴 수 있다Hosting.WebWorldsControllerDemos.VoxelWorld 타입을 직접 참조하고 있어서 demo 의존을 외부에 안 보이게 막는 순간 namespace 해석 실패. 코드를 먼저 정리해야 한다. 일단은 VoxelWorld도 같이 패킹.
  2. 로컬 패치된 upstream nupkg는 별도 미러 필수 — OFF는 Arch.System.SourceGenerator 2.1.1-fix(로컬 패치본)를 nupkgs/에 두고 nuget.configlocal 소스로 끌어 쓴다. 외부 소비자는 그 경로에 못 닿으니 release에서 nupkgs/*.nupkg도 같이 push.
  3. CPM + 다중 피드 = packageSourceMapping 강제 — 안 넣으면 NU1100.
  4. CPM + submodule 동시 빌드는 transitive pinning을 끄게 한다 — server CPM scope와 클라 submodule CPM scope가 만나면 transitive 의존성에서 “key not present in dictionary” 에러. CentralPackageTransitivePinningEnabled=false + sentinel Directory.Packages.props로 scope 누설 방지.
  5. GITHUB_TOKEN은 cross-repo 패키지에 닿지 못한다penspanic/aethelgard의 워크플로우 자동 토큰은 penspanic/OpenFieldFramework의 패키지에 403. 별도 PAT(OFF_PACKAGES_PAT, read:packages ) secret 필요.
  6. Submodule의 nuget.config도 인증해야 한다 — recursive 빌드는 sub-config를 따로 읽는다. 각각에 대해 dotnet nuget update source 단계 필요.
  7. Linux는 case-sensitive다OpenFieldFramework(레포명, PascalCase)와 external/openfieldframework(submodule 디렉토리, lowercase) 경로 mismatch.
  8. UseLocalOFF + 클라 submodule(NuGet 전용) = CS1704 — 동일 어셈블리(OFF.Networking)가 ProjectReference 경로와 PackageReference 경로 두 번 입력. 모드 분리를 명시 문서화.
  9. 셸 환경변수 인증은 협업자에게 약하다 — dotfile에 평문 secret이 남고 셸/IDE마다 깨진다. setup.shdotnet nuget 사용자 레벨 자격증명(~/.config/NuGet/NuGet.Config)에 한 번만 박아두면, 이후 모든 dotnet restore가 소스 이름으로 자동 매칭한다.

결과

  • 협업자가 보는 surface: 클라 C# + Blazor + TS + 데이터 YAML + GDD 마크다운. 서버나 OFF 코드는 한 줄도 보이지 않음.
  • 오너 dev 루프: aethelgard-internal만 clone → 두 submodule init → IDE에서 풀 솔루션. OFF에 breakpoint 그대로.
  • OFF 변경 흐름: OFF 태그 push → NuGet publish → 두 소비 레포에서 OpenFieldFrameworkVersion 한 줄 bump → PR.
  • 서버 배포: aethelgard-internal main push → CI가 4 RID publish → S3 + GH Releases. 협업자는 update-server.sh latest.
  • SourceLink + embedded PDB 덕에 협업자 IDE에서 OFF 호출 스택트레이스 정상 노출(소스는 안 보임).

후기

설계 문서를 미리 써둔 게 결정적이었다. 단일 패스로 옮기면 도중에 결정 점이 너무 많아 헷갈리는데, 실행 단계에선 “문서 따라 입력만 하면 되는 작업”으로 떨어져 있었다. 함정도 대부분 문서의 “알려진 함정” 섹션에 미리 적혀 있었고, 추가로 밟은 건 §6(submodule recursive nuget.config 인증)와 §7(Linux 대소문자) 둘이었다.

다음 큰 구조 작업도 이렇게 하자.


참고

Related Posts

View All Posts »

.NET 서버 메모리 누수 잡기 — dotnet-gcdump로 안 보이는 할당 폭탄

.NET 서버 메모리가 2분 만에 136MB에서 14GB로 폭증했다. dotnet-gcdump와 dotnet-counters로도 안 잡히는 할당 폭탄을 추적하고, 그 과정을 재사용 가능한 스킬로 만든 이야기.

#Aethelgard #AI #Claude #GameDev

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

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

#dotnet #typescript #transpiler #Roslyn
self-hosted CI에 Claude Code 구독 물리기 — PR 자동 분석·테스트 파이프라인

self-hosted CI에 Claude Code 구독 물리기 — PR 자동 분석·테스트 파이프라인

API가 아닌 Claude Code 구독을 self-hosted runner에 물려, CI가 돌 때마다 테스트 자동화와 PR 변경 분석을 수행하는 파이프라인. AI와 함께 코딩하니 테스트가 늘고, 테스트가 늘으니 CI가 필요해진 흐름을 정리했다.

#Aethelgard #AI #Claude #GameDev