· 8 min read
NuGet 프라이빗 패키지로 .NET 코드 분리하기 — 멀티 레포 9개 함정 Splitting .NET Code into Private NuGet Packages — Nine Traps in a Multi-Repo Migration
OFF를 NuGet 프라이빗 패키지로 떼어, 협업자에게 프레임워크·서버 소스를 노출하지 않고도 풀스택 dev 루프를 유지한 멀티 레포 구조와, 옮기면서 밟은 9개의 함정. Splitting OFF into private NuGet packages: a multi-repo layout that hides framework and server source from collaborators while preserving the owner's full-stack dev loop, plus nine traps along the way.

문제: 모노레포에 협업자를 부를 수가 없다
Aethelgard는 OFF(OpenFieldFramework) 위에 올린 첫 게임 프로젝트다. 단일 레포 + OFF git submodule 구조로 잘 굴러갔지만, 클라/아트/기획 1인 협업자가 합류하면서 문제가 생겼다. 게다가 OFF 위에 올릴 두 번째 게임도 기획 중이고 거기서도 협업자가 붙을 가능성이 있으니, 매번 모노레포를 풀어내는 대신 한 번 구조를 잡아두기로 했다.
협업자에게 보여주고 싶지 않은 것:
- OFF 소스코드 — 1년 넘게 쌓아온 자산. 향후 오픈소스화 가능성은 있지만 시점은 API 안정 후.
- 서버 소스코드 — 핵심 게임 메카닉이 노출돼 있다.
- 서버 빌드 셋업 —
dotnet publish+ RID + 4 플랫폼 zip 준비를 시키고 싶지 않다.
동시에 나 자신은:
- OFF 코드에 breakpoint 걸고 게임 띄우는 워크플로우를 잃고 싶지 않다.
- OFF 변경이 두 게임 레포에 빠르게 전파돼야 한다.
- 협업자는 zip 다운받고 실행만으로 서버를 띄울 수 있어야 한다.
최종 구조
Datra(데이터 관리 라이브러리)는 별개 public 레포다. 이번 작업에서 새로 한 건 NuGet 발행뿐이고, 외부 의존으로 자연스럽게 흡수됐다.
핵심 결정:
| 항목 | 결정 | 근거 |
|---|---|---|
| OFF 버전 정책 | 모든 OFF.* 패키지 동일 버전 | mix-and-match 디버깅 비용 회피 |
| 버전 관리 | Directory.Packages.props (CPM) | 단일 지점 pin |
| 클라 → 서버 embed | submodule 유지 | WASM 정적 자산은 NuGet에 안 맞음 |
| 서버 배포 | dotnet publish --self-contained → zip + chmod 스크립트 | Docker 학습 부담 0 |
| 배포 RID | osx-arm64, osx-x64, win-x64, linux-x64 | Windows runner cross-compile |
| 산출물 | GitHub Releases + S3 (OIDC) | 무료, 협업자는 S3에서 RID zip만 다운 |
| 협업자 레포 CI | GH-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가지 함정
- PrivateAssets=“all”이 transitive 빌드를 깨뜨릴 수 있다 —
Hosting.Web의WorldsController가Demos.VoxelWorld타입을 직접 참조하고 있어서 demo 의존을 외부에 안 보이게 막는 순간 namespace 해석 실패. 코드를 먼저 정리해야 한다. 일단은 VoxelWorld도 같이 패킹. - 로컬 패치된 upstream nupkg는 별도 미러 필수 — OFF는
Arch.System.SourceGenerator 2.1.1-fix(로컬 패치본)를nupkgs/에 두고nuget.config의local소스로 끌어 쓴다. 외부 소비자는 그 경로에 못 닿으니 release에서nupkgs/*.nupkg도 같이 push. - CPM + 다중 피드 = packageSourceMapping 강제 — 안 넣으면 NU1100.
- CPM + submodule 동시 빌드는 transitive pinning을 끄게 한다 — server CPM scope와 클라 submodule CPM scope가 만나면 transitive 의존성에서 “key not present in dictionary” 에러.
CentralPackageTransitivePinningEnabled=false+ sentinelDirectory.Packages.props로 scope 누설 방지. - GITHUB_TOKEN은 cross-repo 패키지에 닿지 못한다 —
penspanic/aethelgard의 워크플로우 자동 토큰은penspanic/OpenFieldFramework의 패키지에 403. 별도 PAT(OFF_PACKAGES_PAT, read:packages ) secret 필요. - Submodule의 nuget.config도 인증해야 한다 — recursive 빌드는 sub-config를 따로 읽는다. 각각에 대해
dotnet nuget update source단계 필요. - Linux는 case-sensitive다 —
OpenFieldFramework(레포명, PascalCase)와external/openfieldframework(submodule 디렉토리, lowercase) 경로 mismatch. - UseLocalOFF + 클라 submodule(NuGet 전용) = CS1704 — 동일 어셈블리(
OFF.Networking)가 ProjectReference 경로와 PackageReference 경로 두 번 입력. 모드 분리를 명시 문서화. - 셸 환경변수 인증은 협업자에게 약하다 — dotfile에 평문 secret이 남고 셸/IDE마다 깨진다.
setup.sh로dotnet 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-internalmain push → CI가 4 RID publish → S3 + GH Releases. 협업자는update-server.sh latest. - SourceLink + embedded PDB 덕에 협업자 IDE에서 OFF 호출 스택트레이스 정상 노출(소스는 안 보임).
후기
설계 문서를 미리 써둔 게 결정적이었다. 단일 패스로 옮기면 도중에 결정 점이 너무 많아 헷갈리는데, 실행 단계에선 “문서 따라 입력만 하면 되는 작업”으로 떨어져 있었다. 함정도 대부분 문서의 “알려진 함정” 섹션에 미리 적혀 있었고, 추가로 밟은 건 §6(submodule recursive nuget.config 인증)와 §7(Linux 대소문자) 둘이었다.
다음 큰 구조 작업도 이렇게 하자.
참고
- Central Package Management: https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management
- SourceLink: https://github.com/dotnet/sourcelink
- GitHub Packages NuGet: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-nuget-registry
- Git filter-repo: https://github.com/newren/git-filter-repo
