반응형

[PART1.C# 런타임과 .NET 플랫폼 기초(10/11)] 프로젝트 구조와 빌드 — .csproj, bin/, obj/

dotnet build 한 줄이 왜 두 개의 폴더를 만드는가 / .csproj XML 한 장에 빌드 전체가 담기는 이유 / Unity가 bin/obj 를 무시하고 Library/ 를 쓰는 이유


1. 문제 제기 — dotnet new 한 번에 왜 폴더 두 개가 튀어나오는가

dotnet new console -n HelloBuild 를 실행하면 HelloBuild/ 폴더 하나가 생기고, 그 안에 Program.cs, HelloBuild.csproj 그리고 아직 빌드도 하지 않았는데 obj/ 폴더 하나가 덤으로 만들어집니다. 여기서 dotnet build 한 번 돌리면 이번엔 bin/ 이라는 폴더가 추가로 생기고, obj/ 안에는 GlobalUsings.g.cs · project.assets.json · HelloBuild.AssemblyInfo.cs 처럼 우리가 쓴 적도 없는 파일들이 20개 가까이 쌓입니다.

대부분의 입문 강의가 "bin 은 최종 산출물, obj 는 무시해도 되는 임시 폴더" 정도로 건너뜁니다. 하지만 실무에서는 이 한 문장으로 설명되지 않는 상황이 끊임없이 나타납니다.

  • 빌드는 성공했는데 실행하면 FileNotFoundException: Newtonsoft.Json 이 뜨는 경우 — 범인은 bin/ 이 아니라 obj/project.assets.json 일 때가 많습니다.
  • git pull 을 받고 나서 첫 빌드가 이상하게 오래 걸린다 — obj/ 의 incremental 캐시가 무효화된 것입니다.
  • Unity 프로젝트에서 dotnet build 를 돌렸더니 Library/ScriptAssemblies/ 와 별개로 bin/obj 가 만들어지는데, Unity 에디터는 이걸 쳐다보지도 않습니다.
  • using System; 을 코드 어디에도 안 썼는데 Console.WriteLine 이 그냥 동작합니다 — <ImplicitUsings>enable</ImplicitUsings>obj/ 에 파일을 하나 찍어 놓은 결과입니다.

이 장에서는 .csproj 한 장이 어떻게 빌드 전체를 지배하는지, bin/obj/ 가 왜 굳이 나뉘어 있는지, NuGet 패키지가 .csproj 에서 선언되어 obj/project.assets.json 을 거쳐 최종 bin/ 에 복사되기까지의 전 과정을 실제 파일 트리와 함께 따라가 보겠습니다.


2. 개념 정의 — .csproj 는 레시피, obj/ 는 조리대, bin/ 은 접시

2.1 주방 비유

C# 빌드 시스템은 레스토랑 주방과 닮아 있습니다.

  • .csproj레시피 카드 입니다. 어떤 재료(NuGet 패키지)를 쓸지, 어떤 오븐(TargetFramework=net10.0)을 쓸지, 어떤 향신료 세트(ImplicitUsings)를 기본으로 쓸지가 적혀 있습니다. 셰프(MSBuild)는 이 카드만 읽고 모든 작업을 합니다.
  • obj/조리대 입니다. 썰다 만 양파, 해동 중인 고기, "어제 어디까지 썰었더라" 메모가 쌓이는 공간입니다. 손님에게 내보낼 수 없는, 중간 상태의 작업물이 모여 있습니다.
  • bin/서빙 접시 입니다. 플레이팅이 끝난 완성품만 올라갑니다. 손님(사용자·서버·다른 개발자)에게 그대로 내보낼 수 있는 상태입니다.
  • dotnet CLI 는 셰프의 행동 입니다. new 는 "레시피 카드 만들기", build 는 "요리하기", run 은 "요리하고 바로 맛보기", publish 는 "배달 포장".

핵심은 조리대와 접시가 같은 공간이면 안 된다 는 점입니다. 조리대를 접시로 쓰면 손님에게 나가면 안 되는 쓰레기가 함께 서빙됩니다. obj/bin/ 이 분리된 이유도 같습니다. MSBuild는 obj/ 를 자유롭게 어지럽히면서 bin/ 에는 딱 배포에 필요한 것만 올립니다.

2.2 빌드 파이프라인 전체 그림

dotnet build 의 내부 흐름

흐름을 한 줄로 요약하면, .csproj + *.cs → (Restore → Compile → CopyToOutput) → obj/ + bin/ 입니다. 세 단계 모두 obj/ 에 흔적을 남기지만, 최종적으로 우리가 실행하고 배포하는 파일만 bin/ 에 모입니다.

2.3 용어 정리

용어 설명
SDK <Project Sdk="Microsoft.NET.Sdk"> 에서 참조하는 빌드 도구 묶음. 콘솔·라이브러리·WPF·Web 등 프로젝트 종류별로 기본 컴파일·복사 규칙이 들어 있습니다.
TargetFramework 컴파일 결과가 돌아갈 .NET 버전. net10.0, net8.0, netstandard2.1 등.
MSBuild Microsoft의 빌드 엔진. .csproj XML 을 읽어 타깃(단계)을 순서대로 실행합니다.
Roslyn C# 컴파일러 (csc). MSBuild의 Compile 타깃이 내부에서 호출합니다.
PackageReference .csproj 안에서 선언하는 NuGet 의존성. 과거의 packages.config 를 대체했습니다.
project.assets.json Restore 단계가 obj/ 에 남기는 "완성된 의존성 그래프". 실제 패키지 경로·버전·호환성 정보가 기록됩니다.
GlobalUsings.g.cs <ImplicitUsings>enable</ImplicitUsings> 일 때 SDK 가 obj/ 에 자동 생성하는 global using 파일.

3. dotnet CLI 네 자매 — new / build / run / publish

3.1 네 명령어의 역할 분담

dotnet CLI 네 자매의 역할

3.2 실제로 어떤 파일이 생기는지 관찰

주방 비유를 실제 파일 시스템에서 재확인해 봅시다. 빈 폴더에서 dotnet new console -n HelloBuild 만 돌린 직후의 상태입니다.

HelloBuild/
├── HelloBuild.csproj                             ← 레시피
├── Program.cs                                    ← 소스
└── obj/                                          ← ⚠️ 아직 build 도 안 했는데 생김
    ├── HelloBuild.csproj.nuget.dgspec.json       ← restore 입력 스펙
    ├── HelloBuild.csproj.nuget.g.props           ← MSBuild 주입 props
    ├── HelloBuild.csproj.nuget.g.targets         ← MSBuild 주입 targets
    ├── project.assets.json                       ← 의존성 그래프 (지금은 거의 빔)
    └── project.nuget.cache                       ← restore 캐시

dotnet new 가 내부적으로 한 번 restore 를 돌리기 때문에 이미 obj/ 에 NuGet 관련 메타가 채워집니다. bin/ 은 아직 없습니다. 이어서 dotnet build 를 돌린 뒤의 상태입니다.

HelloBuild/
├── HelloBuild.csproj
├── Program.cs
├── obj/
│   ├── Debug/net10.0/
│   │   ├── .NETCoreApp,Version=v10.0.AssemblyAttributes.cs   ← 런타임 속성
│   │   ├── apphost                                           ← OS 실행 런처 템플릿
│   │   ├── HelloBuild.AssemblyInfo.cs                        ← 어셈블리 메타 (생성됨)
│   │   ├── HelloBuild.AssemblyInfoInputs.cache               ← incremental 캐시
│   │   ├── HelloBuild.assets.cache                           ← 자산 해시 캐시
│   │   ├── HelloBuild.csproj.CoreCompileInputs.cache         ← 컴파일 입력 해시
│   │   ├── HelloBuild.csproj.FileListAbsolute.txt            ← bin 에 복사된 파일 목록
│   │   ├── HelloBuild.dll                                    ← 중간 산출 dll (= bin 과 동일)
│   │   ├── HelloBuild.GeneratedMSBuildEditorConfig.editorconfig
│   │   ├── HelloBuild.genruntimeconfig.cache
│   │   ├── HelloBuild.GlobalUsings.g.cs                      ← ★ ImplicitUsings 의 결과
│   │   ├── HelloBuild.pdb                                    ← 디버그 심볼
│   │   ├── ref/HelloBuild.dll                                ← 참조 전용 어셈블리
│   │   └── refint/HelloBuild.dll                             ← 참조 API 인터페이스
│   ├── HelloBuild.csproj.nuget.*.json|props|targets
│   ├── project.assets.json
│   └── project.nuget.cache
└── bin/
    └── Debug/net10.0/
        ├── HelloBuild                     ← apphost 기반 실행 바이너리 (Unix 는 실행권한)
        ├── HelloBuild.deps.json           ← 런타임 의존성 목록
        ├── HelloBuild.dll                 ← IL 어셈블리
        ├── HelloBuild.pdb                 ← 디버그 심볼
        └── HelloBuild.runtimeconfig.json  ← 어떤 .NET 런타임을 쓸지

핵심을 짚어 보면:

  • HelloBuild.dllobj/Debug/net10.0/ 에도 있고 bin/Debug/net10.0/ 에도 있습니다. 같은 파일을 복사한 것이 아니라, Compile 타깃이 obj/ 에 먼저 만들고 CopyToOutput 타깃이 bin/ 으로 한 번 더 복사합니다. 이렇게 이중화해야 중간에 빌드가 실패해도 bin/ 의 이전 성공 버전은 깨지지 않습니다.
  • obj/Debug/net10.0/ref/, refint/ 는 외부에서 이 어셈블리를 참조만 할 때 쓰는 "공용 API 인터페이스만 뽑은 dll" 입니다. 구현을 바꿔도 API 가 그대로면 참조하는 쪽은 재빌드가 필요 없어 대규모 솔루션에서 빌드 시간을 줄입니다.
  • apphost 는 .NET 런타임을 찾아 .dll 을 실행해 주는 네이티브 런처입니다. 여러분이 ./HelloBuild 로 바로 실행하면 이 apphostHelloBuild.dll 을 로드합니다.

3.3 dotnet publish 가 만들어 주는 것

dotnet publish -c Release 를 돌리면 bin/Release/net10.0/ 밑에 한 번 더 하위 폴더 publish/ 가 생깁니다.

bin/Release/net10.0/
├── HelloBuild
├── HelloBuild.deps.json
├── HelloBuild.dll
├── HelloBuild.pdb
├── HelloBuild.runtimeconfig.json
├── Newtonsoft.Json.dll                  ← 참조한 NuGet dll이 복사됨
└── publish/                             ← ★ 실제 배포용
    ├── HelloBuild
    ├── HelloBuild.deps.json
    ├── HelloBuild.dll
    ├── HelloBuild.pdb
    ├── HelloBuild.runtimeconfig.json
    └── Newtonsoft.Json.dll

bin/publish/ 가 겹쳐 보여 헷갈리지만 역할이 다릅니다.

  • bin/Debug/ · bin/Release/ — 개발자 머신에서 실행·디버깅을 위한 출력. 로컬 NuGet 캐시·SDK 경로에 의존할 수 있습니다.
  • bin/Release/net10.0/publish/ — 다른 머신에 "그대로 들고 가서 실행" 할 수 있도록 필요한 파일만 완결적으로 모아 놓은 폴더. --self-contained true -r linux-x64 같은 옵션을 주면 .NET 런타임까지 통째로 같이 들어갑니다.

경험칙: 배포할 때는 절대 bin/Release/net10.0/ 자체를 zip 뜨지 말고 publish/ 폴더를 zip 하시기 바랍니다.


4. .csproj 한 장 분해

4.1 기본 콘솔 프로젝트

dotnet new console 로 만든 최소 .csproj 입니다.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

단 10줄이지만 이 XML 이 빌드 전체를 통제합니다. 각 줄을 분해합니다.

4.2 <Project Sdk="Microsoft.NET.Sdk"> — 빌드 규칙 세트 선택

Sdk 속성은 "이 프로젝트에 어떤 기본 빌드 규칙 묶음을 입힐지" 결정합니다. Microsoft.NET.Sdk 는 콘솔·라이브러리용 기본 세트입니다.

SDK 용도
Microsoft.NET.Sdk 콘솔·클래스 라이브러리 (기본)
Microsoft.NET.Sdk.Web ASP.NET Core 웹
Microsoft.NET.Sdk.Worker Worker Service
Microsoft.NET.Sdk.BlazorWebAssembly Blazor WASM

이 한 줄 덕분에 .csproj 에서 어떤 .cs 파일을 컴파일할지 일일이 나열할 필요가 없어집니다. 과거(.NET Framework) .csproj 가 수천 줄이던 이유는 모든 .cs 파일을 <Compile Include="..."/> 로 적어야 했기 때문이고, SDK 스타일은 "현재 폴더 이하 *.cs 전부" 를 기본 규칙으로 자동 포함하기 때문에 10줄로 끝납니다.

4.3 <PropertyGroup> — 프로젝트 속성

속성 역할 예시
<OutputType> 실행 파일 종류 Exe (실행), Library (dll만)
<TargetFramework> 대상 .NET 버전 net10.0, net8.0, netstandard2.1
<TargetFrameworks> 여러 버전 동시 타깃 net8.0;netstandard2.1
<ImplicitUsings> System 등 자동 using enable / disable
<Nullable> nullable 참조 타입 enable / warnings / disable
<LangVersion> C# 언어 버전 고정 13.0, latest
<RootNamespace> 기본 네임스페이스 MyCompany.MyProduct
<AssemblyName> 생성될 dll 이름 기본값은 프로젝트 폴더명

<ImplicitUsings>enable</ImplicitUsings> 이 실제로 하는 일을 눈으로 보려면 obj/Debug/net10.0/HelloBuild.GlobalUsings.g.cs 를 열어 보면 됩니다.

C#
// <auto-generated/>
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Linq;
global using System.Net.Http;
global using System.Threading;
global using System.Threading.Tasks;

이 파일은 여러분이 작성한 소스가 아니라 SDK 가 빌드 전에 obj/ 에 찍어 놓고 컴파일에 함께 넣는 가상의 소스 파일입니다. obj/ 가 "빌드 부산물" 을 넘어서 "생성된 소스 코드의 집" 역할도 한다는 사실이 여기서 드러납니다.

4.4 <ItemGroup> — NuGet 참조와 파일 포함

가장 자주 쓰는 Item 이 PackageReference 입니다.

<ItemGroup>
  <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
  <PackageReference Include="Serilog" Version="4.1.0" />
</ItemGroup>

dotnet add package Newtonsoft.Json --version 13.0.3 명령을 쓰면 자동으로 이 줄이 추가되며, 저장하는 순간 IDE 가 dotnet restore 를 돌려 obj/project.assets.json 을 갱신합니다.

그 외에 자주 나오는 Item 종류입니다.

Item 용도
<Compile Remove="..."/> 기본 포함에서 특정 .cs 제외
<Content Include="..." CopyToOutputDirectory="PreserveNewest"/> 설정 파일 등을 bin/ 에 복사
<EmbeddedResource Include="..."/> 리소스를 dll 안에 내장
<ProjectReference Include="../OtherProj/OtherProj.csproj"/> 다른 프로젝트 참조

4.5 SVG — .csproj 구조 한눈에 보기

.csproj 의 구성 요소

5. obj/bin/ — 왜 굳이 둘로 나뉘는가

5.1 두 폴더의 책임 분리

항목 obj/ bin/
영어 원뜻 object (중간 산출물) binary (최종 산출물)
목적 MSBuild 작업 공간 실행·배포 대상
파일 예 *.cache, GlobalUsings.g.cs, project.assets.json, .AssemblyInfo.cs, ref/, refint/, 중간 .dll 최종 .dll/.exe/.pdb, *.deps.json, *.runtimeconfig.json, 참조 NuGet .dll
지워도 되는가 언제든 — 다음 빌드에 다시 생성 언제든 — 다음 빌드에 다시 생성
git 에 올리나 .gitignore 필수 .gitignore 필수
직접 만지나 ❌ 절대 (곧 덮어쓰임) ❌ 디버깅용 확인만

둘을 나누는 근본적인 이유는 "실패해도 안전한 작업 공간이 필요하다" 는 설계 원칙입니다. 컴파일 중간에 에러가 나면 obj/ 는 반쯤 망가진 상태로 남을 수 있지만, bin/ 은 이전 성공 빌드의 상태를 그대로 유지 합니다. 덕분에 빌드가 실패한 순간에도 과거 산출물로 앱을 돌려 볼 수 있습니다.

5.2 incremental 빌드의 비밀은 obj/ 에 있다

변경이 하나도 없는 상태에서 dotnet build 를 연속으로 두 번 돌려 보면 두 번째가 훨씬 빠르다는 것을 느낄 수 있습니다. 실제로 측정해 보면 다음과 같은 결과가 나옵니다.

  • 첫 빌드: 경과 시간: 00:00:00.63
  • 두 번째 빌드(변경 없음): 경과 시간: 00:00:00.31

절반으로 줄어든 이유는 obj/Debug/net10.0/*.cache 파일들 덕분입니다.

  • HelloBuild.csproj.CoreCompileInputs.cache — 모든 .cs 입력의 해시
  • HelloBuild.assets.cache — NuGet 자산 해시
  • HelloBuild.AssemblyInfoInputs.cache — 어셈블리 메타 생성 입력 해시

MSBuild 는 빌드 타깃을 실행하기 전에 "입력 해시가 지난번과 같으면 이 타깃을 건너뛴다" 는 규칙을 갖고 있고, 이 해시가 저장되는 곳이 obj/ 입니다. 그래서 obj/ 를 지우면 이 캐시가 날아가면서 다음 빌드가 다시 풀 빌드가 됩니다. git pull 뒤 첫 빌드가 오래 걸리는 건 원격에서 바뀐 파일들이 입력 해시를 깨기 때문입니다.

5.3 bin/ 안의 네 가지 친구

bin/Debug/net10.0/ 에 들어 있는 파일들을 역할로 나눠 보면 이렇습니다.

  • 어셈블리 (HelloBuild.dll) — 여러분의 C# 코드가 IL 로 컴파일된 결과. 이 파일이 진짜 "프로그램" 입니다.
  • 네이티브 런처 (HelloBuild / HelloBuild.exe) — .NET 런타임을 찾아 위 dll 을 로드해 주는 OS 네이티브 실행 파일. SDK 가 제공하는 apphost 템플릿에 이름·경로만 심어 만든 것입니다.
  • 실행 설정 (HelloBuild.runtimeconfig.json) — 어떤 .NET 런타임 버전을 요구하는지, GC 모드는 뭔지 등이 들어 있습니다.
  • 의존성 명세 (HelloBuild.deps.json) — 실행 시점에 어떤 dll 을 로드해야 하는지, 어떤 NuGet 패키지의 몇 버전인지 전부 기록됩니다.

Release 빌드에서 NuGet 을 추가하면 이 네 가지 외에 참조 패키지 dll이 통째로 복사됩니다. 예를 들어 Newtonsoft.Json 을 추가한 뒤 publish 하면 bin/Release/net10.0/publish/Newtonsoft.Json.dll 이 함께 들어갑니다.


6. NuGet 참조의 여정 — .csproj 에서 bin/ 까지

6.1 3단 구조

NuGet 패키지가 "선언" 에서 "배포 dll" 로 바뀌는 과정은 세 단계입니다.

NuGet 참조가 bin/ 에 도달하기까지

6.2 obj/project.assets.json — 의존성 그래프의 실체

PackageReference 는 "버전 하나" 만 선언하지만, 실제로 그 패키지가 의존하는 다른 패키지까지 줄줄이 따라 들어와야 합니다. 이 "완성된 그래프" 가 obj/project.assets.json 에 저장됩니다.

{
  "version": 3,
  "targets": {
    "net10.0": {}
  },
  "libraries": {
    "Newtonsoft.Json/13.0.3": {
      "type": "package",
      "sha512": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==",
      ...
    }
  },
  "project": {
    "restore": {
      "projectUniqueName": ".../HelloBuild.csproj",
      "packagesPath": "/Users/pjhara/.nuget/packages/",
      "projectStyle": "PackageReference",
      ...
    }
  }
}

보아야 할 포인트입니다.

  • libraries."이름/버전".sha512패키지 내용 해시. 이 값이 유지돼야 "내 컴퓨터에서는 되는데" 문제가 사라집니다. 다른 사람이 같은 레포를 받아도 똑같은 바이트의 dll 을 쓰게 됩니다.
  • packagesPath — 실제 NuGet 파일은 프로젝트 안이 아니라 사용자 홈의 ~/.nuget/packages/ 에 딱 한 번 캐시됩니다. obj/project.assets.json 은 그 캐시를 가리키는 "포인터" 일 뿐입니다.
  • projectStyle: "PackageReference" — 과거 packages.config 스타일과 구분하는 값.

이 JSON 을 손으로 수정하면 안 됩니다. dotnet restore 가 매번 덮어씁니다. .csproj 를 수정하고 restore 를 돌리는 것이 정답입니다.

6.3 .csproj 에 쓴 한 줄이 실제 bin/ 에 반영되는지 확인

다음과 같이 실험해 보면 세 단계가 실시간으로 보입니다.

# (1) 추가
$ dotnet add package Newtonsoft.Json --version 13.0.3
info : ...PackageReference가 HelloBuild.csproj 파일에 추가되었습니다.
info : 자산 파일을 디스크에 쓰는 중입니다. 경로: .../obj/project.assets.json
log  : .../HelloBuild.csproj을(를) 1.04초 동안 복원했습니다.

# (2) .csproj 확인
$ cat HelloBuild.csproj
...
  <ItemGroup>
    <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
  </ItemGroup>
...

# (3) assets 확인
$ grep '"Newtonsoft.Json' obj/project.assets.json | head -2
      "Newtonsoft.Json/13.0.3": {
    "Newtonsoft.Json/13.0.3": {

# (4) publish 후 실제 dll 존재 확인
$ dotnet publish -c Release
$ ls bin/Release/net10.0/publish/
HelloBuild            HelloBuild.deps.json  HelloBuild.pdb                Newtonsoft.Json.dll
HelloBuild.dll        ...                   HelloBuild.runtimeconfig.json

.csproj 에 한 줄 → obj/project.assets.json 에 해시 기록 → bin/.../publish/ 에 dll 복사, 이 세 단계가 짝을 이룹니다.


7. IL 분석 — dotnet build 의 결과물이 정말로 IL 인지 확인

이번 주제의 중심은 빌드 "시스템" 이기 때문에 IL 심층 분석보다는 "build 버튼 한 번이 정말로 IL 을 낳는가" 의 연결 고리만 확실히 짚겠습니다.

7.1 원본 C#

dotnet new console 이 만든 Program.cs 한 줄 코드입니다.

C#
Console.WriteLine("Hello, World!");

이 한 줄을 dotnet build 로 빌드하면 bin/Debug/net10.0/HelloBuild.dll 이 생깁니다.

7.2 디컴파일한 IL

ilspycmd -il bin/Debug/net10.0/HelloBuild.dll 의 Main 메서드 부분만 옮기면 이렇습니다.

.method private hidebysig static
    void '<Main>$' (
        string[] args
    ) cil managed
{
    // Method begins at RVA 0x2050
    // Header size: 1
    // Code size: 12 (0xc)
    .maxstack 8
    .entrypoint

    IL_0000: ldstr "Hello, World!"
    IL_0005: call void [System.Console]System.Console::WriteLine(string)
    IL_000a: nop
    IL_000b: ret
} // end of method Program::'<Main>$'

7.3 읽어내야 할 네 가지 사실

  1. 한 줄 C# 이 IL 로는 4개 명령(ldstr · call · nop · ret) 입니다. dotnet build 는 이 번역을 obj/Debug/net10.0/HelloBuild.dll 에 먼저 쓰고, bin/Debug/net10.0/HelloBuild.dll 로 복사합니다.
  2. 메서드 이름이 <Main>$ 입니다. < > $ 는 C# 식별자에 못 쓰는 문자로, 컴파일러가 "내가 자동 생성했다" 는 표시로 씁니다. top-level statements(9번 주제) 가 Program 타입 안에 <Main>$ 메서드로 감싸지는 실체입니다.
  3. call void [System.Console]System.Console::WriteLine(string) 에서 [System.Console]어셈블리 이름 입니다. 즉 이 IL 은 System.Console.dll 에 정의된 WriteLine 을 호출한다고 명시합니다. bin/HelloBuild.deps.jsonSystem.Console 이 실행 시점에 로드되어야 한다고 기록되는 근거가 여기에 있습니다.
  4. .entrypoint 가 이 메서드에 붙어 있습니다. CLR 이 어셈블리를 로드할 때 "어디부터 실행하나" 를 이 플래그로 찾습니다. apphost 도 결국 이 진입점을 찾아 호출할 뿐입니다.

IL 레벨에서 보면 dotnet build 는 특별한 일을 하는 게 아니라 C# → IL 번역을 Roslyn 에 위임하고, 결과를 obj/ 에 쓰고, bin/ 에 복사하는 컨베이어 입니다. dotnet publish 는 여기에 "의존 dll 까지 함께 모아 준다" 는 단계를 하나 더 붙인 것뿐입니다.


8. Unity 실전 — Assets 의 .csproj 는 누가 만들고 누가 지우는가

8.1 Unity 의 .csproj 는 "IDE 연동용 힌트 파일"

Unity 프로젝트 폴더에는 Assembly-CSharp.csproj · Assembly-CSharp-Editor.csproj 같은 파일이 에디터가 켜진 뒤 자동으로 생깁니다. 하지만 이것들은 일반 .NET 프로젝트의 .csproj 와 의미가 다릅니다.

  • Unity 에디터가 Rider · Visual Studio 같은 외부 IDE 에게 "코드 자동완성·디버깅 할 때 이 파일 목록을 참고하세요" 라고 넘겨 주는 힌트 파일 입니다.
  • 실제로 게임을 빌드하는 컴파일러는 Unity 내부 빌드 파이프라인이며, .csproj 를 참고하지 않습니다. 내부 파이프라인은 .asmdef 만 읽습니다.
  • Unity 를 재시작하거나 에셋을 추가·삭제하면 이 .csproj 들은 통째로 덮어써집니다. 손으로 수정해도 살아남지 못합니다.

8.2 .asmdef 가 실질적인 .csproj 단위

Unity 는 기본적으로 Assets/ 아래 모든 스크립트를 하나의 큰 Assembly-CSharp.dll 로 컴파일합니다. .asmdef 파일을 특정 폴더에 놓으면 그 폴더 이하만 별도의 어셈블리로 분리 되고, 그 분리 단위 하나가 바로 .csproj 하나에 대응합니다.

일반 .NET Unity
.csproj 하나 = 어셈블리 하나 .asmdef 하나 = 어셈블리 하나
PackageReference 로 NuGet 참조 .asmdefreferences 배열로 다른 asmdef 참조
의존 그래프 = project.assets.json 의존 그래프 = Library/ScriptAssemblies/ 의 dll 간 참조
빌드 산출 = bin/, obj/ 빌드 산출 = Library/ScriptAssemblies/

그래서 Unity 에서 "컴파일이 너무 오래 걸려요" 라는 문제의 해법은 ".csproj 를 쪼개라" 가 아니라 ".asmdef 로 어셈블리를 쪼개라" 입니다. 일반 .NET 에서 .csproj 를 쪼개면 빌드가 빨라지는 원리(바뀐 어셈블리만 재컴파일)가 Unity 에서는 .asmdef 에 그대로 적용됩니다.

8.3 bin/ · obj/ 는 Unity 에서는 쓰이지 않는다

Unity 에서 Build 버튼을 눌러 iOS·Android·Windows 빌드를 만들어도 bin/ · obj/나타나지 않습니다. Unity 가 쓰는 출력 폴더는 다릅니다.

폴더 만드는 주체 내용
Library/ScriptAssemblies/ Unity 에디터 스크립트 컴파일 결과 .dll
Library/PackageCache/ Unity Package Manager UPM 패키지 캐시
Library/Bee/ Unity Bee 빌드 시스템 증분 빌드 메타
Temp/ Unity 에디터 임시 빌드 파일
Build/ 또는 사용자 지정 Build Settings > Build 최종 플레이어 빌드(.apk/.ipa/.exe)

만약 Unity 프로젝트에 bin/obj/ 가 보인다면 그건 보통 IDE(Rider/VS)나 외부 dotnet build 명령이 만든 찌꺼기 입니다. Unity 는 쓰지 않으므로 .gitignore 에 반드시 넣어야 합니다.

# Unity 공식 .gitignore 에 기본 포함
[Ll]ibrary/
[Tt]emp/
[Oo]bj/
[Bb]in/
[Bb]uilds/

8.4 Unity 빌드 파이프라인이 추가로 하는 일

일반 .NET 은 "C# → IL → (JIT) → 네이티브" 로 끝나지만, Unity 는 특히 iOS 빌드에서 한 단계 더 갑니다.

  1. Roslyn 으로 Assets/*.csLibrary/ScriptAssemblies/*.dll (IL) 생성
  2. 이 IL 을 IL2CPP 가 C++ 소스로 변환
  3. Xcode 가 그 C++ 를 ARM64 네이티브 바이너리로 컴파일해 .ipa 에 포함

이 과정에서 일반 .NET 의 bin/ 에 해당하는 "최종 배포 패키지" 는 Build/ 아래의 .apk·.ipa·.exe 입니다. 즉 Unity 관점에서 bin/ 의 자리에는 플랫폼 네이티브 패키지 가 들어갑니다. .csproj · bin/ · obj/ 는 Unity 개발자에게 "평소엔 무시하지만, Rider 에서 뭔가 이상할 때 열어 보는 파일" 정도로 이해하시면 됩니다.


9. 흔한 함정 체크리스트

  1. bin/·obj/ 를 실수로 git 에 커밋 — 수십 MB 의 dll·pdb 가 레포를 부풀립니다. .gitignore 최상단에 bin/, obj/ 를 반드시 넣습니다.
  2. "obj 만 지웠는데 빌드가 전부 다시 됨" — 정상입니다. incremental 캐시가 obj/ 에 있기 때문입니다. 이상 징후가 아니라 설계 그대로 입니다.
  3. dotnet restore 없이 빌드가 안 될 때 — 보통 obj/project.assets.json 이 없거나 깨진 상태. dotnet restore --force 로 해결.
  4. 같은 패키지가 두 버전으로 참조될 때.csproj 에 직접 선언한 버전이 이긴다는 규칙은 없습니다. NuGet 은 "사용 가능한 가장 높은 버전" 을 선택합니다. 의도적으로 고정하려면 <PackageReference Version="[13.0.3]"/> 처럼 대괄호로 "정확히 이 버전만" 을 지정합니다.
  5. dotnet publish 대신 bin/Release/ 를 그대로 zip 해서 배포 — 의존 dll 이 빠져 있거나 개발용 경로에 묶여 있을 수 있습니다. 배포는 반드시 publish/ 를 쓰시기 바랍니다.
  6. bin/ 만 지우고 "깨끗해졌다" 고 착각obj/ 안에 이전 빌드의 캐시가 남아 있으면 "왜 고쳤는데도 그대로지?" 하는 상황이 생깁니다. 진짜로 초기화하려면 dotnet clean + bin/ obj/ 수동 삭제.
  7. Unity 프로젝트에서 .csproj 를 수정 — Unity 가 덮어씁니다. 어셈블리 구성을 바꾸려면 .asmdef 를 수정해야 합니다.
  8. SDK 스타일이 아닌 옛 .csproj 를 복붙<Compile Include="*.cs"/> 를 손으로 넣으면 SDK 의 기본 포함과 충돌해 "같은 파일이 두 번 컴파일됨" 에러가 납니다. SDK 스타일에서는 파일 목록을 쓰지 않습니다.
  9. TargetFramework 를 다운그레이드했는데 동작이 그대로obj/ 를 지우지 않으면 이전 TFM 의 캐시가 남아 효과가 안 보입니다. bin/ obj/ 삭제 후 재빌드.
  10. CI 에서 매번 느린 빌드 — CI 러너는 obj/ 캐시가 없으므로 매번 풀 빌드입니다. GitHub Actions 의 actions/cache~/.nuget/packagesobj/ 를 캐싱하면 로컬 개발만큼 빨라집니다.

10. 정리 — "빌드가 불투명했던" 이유가 풀리는 지점

처음의 질문으로 돌아갑니다. dotnet new 한 번에 왜 폴더 두 개가 나오고, dotnet build 는 왜 obj/bin/ 을 굳이 따로 만드는가.

  • .csproj레시피 카드 입니다. SDK 지정 한 줄 + PropertyGroup + ItemGroup 이 빌드 전체를 지배합니다.
  • obj/조리대 입니다. 중간 .dll, 생성 소스(GlobalUsings.g.cs), incremental 캐시, NuGet 자산 그래프(project.assets.json) 가 모입니다. 빌드가 빨라지는 비밀은 모두 obj/ 에 있습니다.
  • bin/서빙 접시 입니다. 실행·디버깅·배포에 쓰이는 최종 산출물만 올라갑니다. 진짜 배포는 그 안의 publish/ 를 씁니다.
  • dotnet CLI 네 자매는 "레시피 카드 작성(new) → 주방 안 요리(build) → 시식(run) → 배달 포장(publish)" 으로 명확히 나뉩니다.
  • NuGet 은 .csproj 선언 → obj/project.assets.json 의 그래프 → bin/ 으로의 dll 복사 3단을 반드시 거칩니다.
  • Unity 는 .csproj 를 IDE 힌트로만 쓰고 실질 어셈블리 단위는 .asmdef 입니다. bin/·obj/ 대신 Library/ScriptAssemblies/Build/ 를 쓰므로, 혹시 bin/obj/ 가 보이면 외부 툴의 찌꺼기로 보고 무시하면 됩니다.

여기까지 이해하고 나면 dotnet build 라는 한 줄이 더는 블랙박스가 아닙니다. 다음 번 빌드 오류가 났을 때, 메시지를 보자마자 "이건 obj/ 문제야" / "이건 .csprojPackageReference 문제야" / "이건 bin/publish/ 로 확인해야 해" 라고 반사적으로 위치를 짚을 수 있게 되는 것이 이 주제를 공부하는 진짜 목표입니다.

반응형

+ Recent posts