C# 게임 개발 라이브러리 제작과 NuGet 패키지 배포 방법

게임 프로젝트마다 반복되는 유틸리티를 C# 클래스 라이브러리로 분리하고 API 설계·테스트·Unity 호환성·NuGet 패키지 생성과 배포까지 실무 순서대로 정리합니다.

✓ 복사 완료!
챕터일반 가이드
난이도중급
상태● 검토 완료
대상 엔진
Engine Agnostic
최종 업데이트
C# 게임 개발 라이브러리 제작과 NuGet 패키지 배포 방법

게임 개발용 C# 라이브러리는 프로젝트별로 복사하던 코드를 독립 패키지로 바꾸는 방법이다. 핵심은 Unity나 엔진 API에 직접 의존하지 않는 순수 C# 코어를 먼저 만들고 명확한 공개 API·자동 테스트·버전 규칙을 갖춘 뒤 NuGet 패키지로 배포하는 것이다.

왜 게임 개발 코드를 C# 라이브러리와 패키지로 분리해야 할까?

쿨다운 계산, 가중치 랜덤, 이벤트 버스, 저장 데이터 직렬화 전처리처럼 여러 게임에서 반복되는 코드는 프로젝트 내부의 Utils 폴더에 두기 쉽다. 하지만 복사 방식은 버그 수정과 개선 사항이 프로젝트마다 갈라지는 문제가 있다.

패키지로 분리하면 의존성과 버전을 명시적으로 관리할 수 있다. 예를 들어 GameDevKit.Random 1.2.0의 동작을 사용하는 프로젝트는 재현 가능한 버전을 고정하고 다음 개선은 1.3.0으로 검토한 후 올릴 수 있다.

구분프로젝트 내부 유틸리티NuGet C# 라이브러리
재사용파일 복사와 수동 병합패키지 참조로 설치
버그 수정각 프로젝트에 반복 반영새 버전 배포 후 선택 업데이트
API 변경영향 범위 파악이 어려움시맨틱 버전으로 호환성 전달
테스트게임 프로젝트에 묶임엔진 없이 빠르게 실행 가능
공개 배포소스 저장소 공유에 그침NuGet.org에서 검색·설치 가능

C# 게임 라이브러리는 어떤 구조로 시작해야 할까?

가장 안전한 출발점은 netstandard2.1 또는 소비 환경에 맞는 .NET 타깃 프레임워크를 사용하는 순수 클래스 라이브러리다. Unity 프로젝트도 .NET Standard 2.1 호환 설정에서 참조할 수 있지만 실제 지원 범위는 사용하는 Unity 버전과 API Compatibility Level에서 반드시 확인해야 한다.

1. 엔진 의존 코어와 어댑터를 분리한다

UnityEngine, MonoBehaviour, Debug 같은 타입을 코어 패키지의 공개 API에 넣으면 일반 .NET 테스트와 다른 엔진에서의 재사용이 어려워진다. 계산과 규칙은 코어에 두고 Unity 전용 구현은 별도 패키지나 프로젝트로 둔다.

GameDevKit.Core          // 순수 C#: 랜덤, 시간, 규칙, 자료구조
GameDevKit.Unity         // UnityEngine을 사용하는 어댑터와 컴포넌트
GameDevKit.Tests         // xUnit, NUnit 등 자동 테스트

순수 C# 코어와 Unity 어댑터로 분리된 게임 라이브러리 구조

예를 들어 가중치 랜덤 선택기는 Unity에 의존하지 않는 형태로 작성할 수 있다.

namespace GameDevKit.Random;

public readonly struct WeightedItem<T>
{
    public T Value { get; }
    public int Weight { get; }

    public WeightedItem(T value, int weight)
    {
        Value = value;
        Weight = weight;
    }
}

public static class WeightedPicker
{
    public static T Pick<T>(
        IReadOnlyList<WeightedItem<T>> items,
        System.Random random)
    {
        ArgumentNullException.ThrowIfNull(items);
        ArgumentNullException.ThrowIfNull(random);

        var totalWeight = 0;
        foreach (var item in items)
        {
            if (item.Weight < 0)
                throw new ArgumentOutOfRangeException(nameof(items));

            totalWeight += item.Weight;
        }

        if (totalWeight == 0)
            throw new InvalidOperationException("양수 가중치 항목이 필요합니다.");

        var target = random.Next(totalWeight);
        foreach (var item in items)
        {
            if (target < item.Weight)
                return item.Value;

            target -= item.Weight;
        }

        throw new InvalidOperationException("가중치 계산 결과가 올바르지 않습니다.");
    }
}

이 알고리즘의 선택 확률은 항목 i의 가중치 w_i와 전체 가중치 합으로 결정된다.

P(i)=wi∑j=1nwjP(i)=\frac{w_i}{\sum_{j=1}^{n}w_j}

무작위 생성기를 외부에서 주입하면 테스트에서 new Random(1234)처럼 시드를 고정해 같은 결과를 재현할 수 있다.

2. 공개 API는 작고 예측 가능하게 설계한다

패키지를 한번 공개하면 public 타입과 메서드는 사용자의 코드와 계약을 맺는다. 처음부터 모든 구현을 공개하지 말고 필요한 최소 타입만 public으로 둔다.

  • 메서드 입력이 null일 수 없다면 즉시 예외를 던진다.
  • 반환값과 예외 조건을 XML 문서 주석으로 명시한다.
  • 컬렉션 입력에는 가능한 한 IReadOnlyList<T>, IEnumerable<T> 같은 읽기 전용 추상화를 사용한다.
  • Unity 전용 타입은 GameDevKit.Unity처럼 별도 네임스페이스와 어셈블리로 격리한다.
  • 로그 출력이나 전역 싱글턴처럼 숨은 부작용을 기본 동작으로 만들지 않는다.

NuGet 패키지는 어떻게 만들고 메타데이터를 설정할까?

1. 클래스 라이브러리 프로젝트를 생성한다

.NET SDK CLI에서 다음 명령으로 시작할 수 있다.

dotnet new classlib -n GameDevKit.Core -f netstandard2.1
cd GameDevKit.Core
dotnet new sln -n GameDevKit
dotnet sln add GameDevKit.Core.csproj

2. .csproj에 패키지 메타데이터를 작성한다

NuGet 검색 결과와 설치 경험은 코드만큼 패키지 메타데이터의 영향을 받는다. PackageId는 고유해야 하며 라이선스와 저장소 주소도 공개 패키지에는 명확히 넣는 편이 좋다.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.1</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>

    <PackageId>GameDevKit.Core</PackageId>
    <Version>1.0.0</Version>
    <Authors>your-name</Authors>
    <Description>게임 개발에서 재사용하는 순수 C# 유틸리티입니다.</Description>
    <PackageTags>game;gamedev;unity;csharp;utility</PackageTags>
    <PackageLicenseExpression>MIT</PackageLicenseExpression>
    <RepositoryUrl>https://github.com/your-name/GameDevKit</RepositoryUrl>
    <RepositoryType>git</RepositoryType>
    <PackageReadmeFile>README.md</PackageReadmeFile>
    <PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>

    <GeneratePackageOnBuild>false</GeneratePackageOnBuild>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
    <IncludeSymbols>true</IncludeSymbols>
    <SymbolPackageFormat>snupkg</SymbolPackageFormat>
  </PropertyGroup>

  <ItemGroup>
    <None Include="../README.md" Pack="true" PackagePath="\" />
  </ItemGroup>
</Project>

README.md에는 설치 명령, 최소 사용 예제, 지원 프레임워크, 라이선스와 호환성 정책을 넣는다. 사용자는 패키지 설치 전에 NuGet 페이지에서 이 정보를 확인한다.

3. 로컬에서 패키지를 생성하고 검증한다

dotnet restore
dotnet build -c Release
dotnet pack -c Release --no-build

생성된 .nupkg는 보통 bin/Release에 있다. 배포 전에 별도 샘플 프로젝트에서 로컬 패키지 소스를 등록하거나 패키지 압축 파일의 구성과 README 포함 여부를 확인한다.

Release 빌드부터 nupkg 생성과 소비 프로젝트 설치까지의 NuGet 배포 흐름

게임 라이브러리 테스트는 어떻게 구성해야 할까?

랜덤, 시간, 상태 전이처럼 게임플레이 로직에 가까운 코드는 특히 경계 조건을 테스트해야 한다. 코어 라이브러리가 Unity에 묶이지 않아야 CI 환경에서도 dotnet test만으로 검증할 수 있다.

dotnet new xunit -n GameDevKit.Tests
dotnet add GameDevKit.Tests reference GameDevKit.Core
dotnet sln add GameDevKit.Tests/GameDevKit.Tests.csproj
dotnet test

가중치 선택기라면 다음 항목을 최소로 확인한다.

  1. 음수 가중치가 들어오면 ArgumentOutOfRangeException이 발생하는가?
  2. 모든 가중치가 0이면 실패 원인이 분명한 예외로 전달되는가?
  3. 하나의 양수 가중치 항목만 있으면 항상 그 항목이 반환되는가?
  4. 고정 시드에서 결과가 재현되는가?
  5. 빈 목록과 null 입력이 문서화된 방식으로 처리되는가?

통계적 분포를 단위 테스트의 유일한 기준으로 삼는 것은 피하는 편이 좋다. 난수 표본은 변동이 있으므로 경계 조건과 결정 가능한 규칙을 우선 테스트하고 필요할 때 허용 오차를 둔 통계 테스트를 추가한다.

NuGet.org에 C# 게임 라이브러리를 배포하는 절차

1. 배포 전 공개 저장소를 준비한다

README, LICENSE, 변경 이력, 기여 방법을 저장소 루트에 둔다. 오픈소스 라이선스는 코드 사용 조건을 정하는 법적 문서이므로 다른 프로젝트의 파일을 그대로 복사하지 말고 프로젝트 의도에 맞는 정식 라이선스 전문을 포함해야 한다.

2. NuGet.org API 키를 발급한다

NuGet.org 계정의 API key 설정에서 패키지 게시 권한을 가진 키를 만든다. 키는 비밀번호처럼 취급하며 저장소, 소스 코드, README에 넣지 않는다. CI에서는 GitHub Actions Secrets 같은 비밀 저장소에 NUGET_API_KEY로 등록한다.

3. 패키지를 게시한다

dotnet nuget push ./bin/Release/GameDevKit.Core.1.0.0.nupkg \
  --api-key "$NUGET_API_KEY" \
  --source https://api.nuget.org/v3/index.json

PowerShell에서는 환경 변수 표기가 다르다.

dotnet nuget push .\bin\Release\GameDevKit.Core.1.0.0.nupkg `
  --api-key $env:NUGET_API_KEY `
  --source https://api.nuget.org/v3/index.json

NuGet.org에 이미 게시한 동일한 버전 번호는 덮어쓸 수 없다. 코드나 패키지 내용을 수정했다면 반드시 새 버전을 발행해야 한다.

시맨틱 버전은 게임 라이브러리에 어떻게 적용할까?

시맨틱 버전은 MAJOR.MINOR.PATCH 형식을 사용한다. API 호환성 판단 기준은 주로 소비자가 컴파일하는 공개 API다.

변경 유형예시권장 버전 변경
호환성이 깨지는 변경공개 메서드 제거, 매개변수 타입 변경MAJOR
호환되는 기능 추가새 선택기, 새 오버로드 추가MINOR
버그 수정잘못된 예외 처리, 계산 오류 수정PATCH

예를 들어 1.2.3에서 공개 메서드의 이름이나 반환 타입을 바꾸면 기존 게임 프로젝트가 빌드되지 않을 수 있으므로 2.0.0이 적절하다. API를 없애야 한다면 먼저 [Obsolete] 특성으로 대체 API와 제거 예정 시점을 안내한 뒤 다음 주요 버전에서 제거하는 방식이 안전하다.

Unity에서도 NuGet 패키지를 바로 사용할 수 있을까?

가능하지만 모든 NuGet 패키지가 Unity에 그대로 맞는 것은 아니다. Unity 프로젝트의 C# 언어 기능, API Compatibility Level, 네이티브 플러그인 의존성, 대상 프레임워크를 함께 점검해야 한다.

일반적으로 순수 netstandard2.1 라이브러리는 호환 가능성이 높다. 반면 ASP.NET Core, 데스크톱 UI, 런타임 코드 생성, 플랫폼별 네이티브 라이브러리에 의존하는 패키지는 Unity에서 사용하기 어렵거나 별도 설정이 필요할 수 있다.

Unity용 배포를 고려한다면 다음 기준을 정한다.

  • 코어 DLL은 UnityEngine 참조 없이 컴파일한다.
  • Unity 전용 기능은 별도 어셈블리 정의 파일(.asmdef)과 어댑터 패키지로 분리한다.
  • IL2CPP 빌드에서 리플렉션이나 동적 코드 생성에 의존하는 부분을 실제 타깃 플랫폼에서 검증한다.
  • NuGet 소비 방식과 Unity Package Manager(UPM) 소비 방식을 문서에서 구분한다.

자주 묻는 질문 (FAQ)

게임 유틸리티는 처음부터 공개 패키지로 만들어야 할까?

아니다. 재사용 가능성이 확인된 작은 기능부터 분리하는 편이 낫다. 특정 게임의 도메인 규칙, 씬 구조, 리소스 경로에 깊이 묶인 코드는 해당 프로젝트 안에 남기는 것이 자연스럽다.

NuGet 패키지와 Unity Package Manager 중 무엇을 사용해야 할까?

순수 C# 코드와 일반 .NET 도구까지 함께 지원하려면 NuGet이 적합하다. Unity 에디터 확장, 샘플 씬, ScriptableObject, 에셋까지 배포해야 한다면 UPM이 더 자연스럽다. 두 방식이 필요한 경우 코어와 Unity 어댑터를 분리해 각각 배포할 수 있다.

오픈소스 패키지에 테스트가 꼭 필요한가?

필수 도구라는 의미를 넘어 공개 API의 신뢰를 유지하는 장치다. 특히 버그 수정과 리팩터링 뒤에도 기존 동작을 보장하려면 dotnet test를 CI의 필수 단계로 두는 것이 좋다.

정리

오픈소스 C# 게임 라이브러리는 재사용할 규칙을 순수 코어로 추출하고 작은 공개 API와 자동 테스트로 계약을 지키며 NuGet 메타데이터와 시맨틱 버전으로 배포를 관리할 때 오래 유지된다. 처음에는 하나의 독립 유틸리티와 명확한 사용 예제부터 시작해 실제 프로젝트에서 검증한 뒤 범위를 넓혀 가자.