[PART1.C# 런타임과 .NET 플랫폼 기초(10/11)] 프로젝트 구조와 빌드 — .csproj, bin/, obj/
dotnet build 한 줄이 왜 두 개의 폴더를 만드는가 / .csproj XML 한 장에 빌드 전체가 담기는 이유 / Unity가 bin/obj 를 무시하고 Library/ 를 쓰는 이유
목차
- 문제 제기 — `dotnet new` 한 번에 왜 폴더 두 개가 튀어나오는가
- 개념 정의 — `.csproj` 는 레시피, `obj/` 는 조리대, `bin/` 은 접시
- `dotnet` CLI 네 자매 — new / build / run / publish
- `.csproj` 한 장 분해
- `obj/` 와 `bin/` — 왜 굳이 둘로 나뉘는가
- NuGet 참조의 여정 — `.csproj` 에서 `bin/` 까지
- IL 분석 — `dotnet build` 의 결과물이 정말로 IL 인지 확인
- Unity 실전 — Assets 의 `.csproj` 는 누가 만들고 누가 지우는가
- 흔한 함정 체크리스트
- 정리 — "빌드가 불투명했던" 이유가 풀리는 지점
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/는 서빙 접시 입니다. 플레이팅이 끝난 완성품만 올라갑니다. 손님(사용자·서버·다른 개발자)에게 그대로 내보낼 수 있는 상태입니다.dotnetCLI 는 셰프의 행동 입니다.new는 "레시피 카드 만들기",build는 "요리하기",run은 "요리하고 바로 맛보기",publish는 "배달 포장".
핵심은 조리대와 접시가 같은 공간이면 안 된다 는 점입니다. 조리대를 접시로 쓰면 손님에게 나가면 안 되는 쓰레기가 함께 서빙됩니다. obj/ 와 bin/ 이 분리된 이유도 같습니다. MSBuild는 obj/ 를 자유롭게 어지럽히면서 bin/ 에는 딱 배포에 필요한 것만 올립니다.
2.2 빌드 파이프라인 전체 그림

흐름을 한 줄로 요약하면, .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 네 명령어의 역할 분담

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.dll이obj/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로 바로 실행하면 이apphost가HelloBuild.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 를 열어 보면 됩니다.
// <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 구조 한눈에 보기

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" 로 바뀌는 과정은 세 단계입니다.

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 한 줄 코드입니다.
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 읽어내야 할 네 가지 사실
- 한 줄 C# 이 IL 로는 4개 명령(ldstr · call · nop · ret) 입니다.
dotnet build는 이 번역을obj/Debug/net10.0/HelloBuild.dll에 먼저 쓰고,bin/Debug/net10.0/HelloBuild.dll로 복사합니다. - 메서드 이름이
<Main>$입니다.<>$는 C# 식별자에 못 쓰는 문자로, 컴파일러가 "내가 자동 생성했다" 는 표시로 씁니다. top-level statements(9번 주제) 가Program타입 안에<Main>$메서드로 감싸지는 실체입니다. call void [System.Console]System.Console::WriteLine(string)에서[System.Console]은 어셈블리 이름 입니다. 즉 이 IL 은System.Console.dll에 정의된WriteLine을 호출한다고 명시합니다.bin/HelloBuild.deps.json에System.Console이 실행 시점에 로드되어야 한다고 기록되는 근거가 여기에 있습니다..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 참조 |
.asmdef 의 references 배열로 다른 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 빌드에서 한 단계 더 갑니다.
- Roslyn 으로
Assets/*.cs→Library/ScriptAssemblies/*.dll(IL) 생성 - 이 IL 을 IL2CPP 가 C++ 소스로 변환
- Xcode 가 그 C++ 를 ARM64 네이티브 바이너리로 컴파일해
.ipa에 포함
이 과정에서 일반 .NET 의 bin/ 에 해당하는 "최종 배포 패키지" 는 Build/ 아래의 .apk·.ipa·.exe 입니다. 즉 Unity 관점에서 bin/ 의 자리에는 플랫폼 네이티브 패키지 가 들어갑니다. .csproj · bin/ · obj/ 는 Unity 개발자에게 "평소엔 무시하지만, Rider 에서 뭔가 이상할 때 열어 보는 파일" 정도로 이해하시면 됩니다.
9. 흔한 함정 체크리스트
bin/·obj/를 실수로 git 에 커밋 — 수십 MB 의 dll·pdb 가 레포를 부풀립니다..gitignore최상단에bin/,obj/를 반드시 넣습니다.- "obj 만 지웠는데 빌드가 전부 다시 됨" — 정상입니다. incremental 캐시가
obj/에 있기 때문입니다. 이상 징후가 아니라 설계 그대로 입니다. dotnet restore없이 빌드가 안 될 때 — 보통obj/project.assets.json이 없거나 깨진 상태.dotnet restore --force로 해결.- 같은 패키지가 두 버전으로 참조될 때 —
.csproj에 직접 선언한 버전이 이긴다는 규칙은 없습니다. NuGet 은 "사용 가능한 가장 높은 버전" 을 선택합니다. 의도적으로 고정하려면<PackageReference Version="[13.0.3]"/>처럼 대괄호로 "정확히 이 버전만" 을 지정합니다. dotnet publish대신bin/Release/를 그대로 zip 해서 배포 — 의존 dll 이 빠져 있거나 개발용 경로에 묶여 있을 수 있습니다. 배포는 반드시publish/를 쓰시기 바랍니다.bin/만 지우고 "깨끗해졌다" 고 착각 —obj/안에 이전 빌드의 캐시가 남아 있으면 "왜 고쳤는데도 그대로지?" 하는 상황이 생깁니다. 진짜로 초기화하려면dotnet clean+bin/ obj/수동 삭제.- Unity 프로젝트에서
.csproj를 수정 — Unity 가 덮어씁니다. 어셈블리 구성을 바꾸려면.asmdef를 수정해야 합니다. - SDK 스타일이 아닌 옛
.csproj를 복붙 —<Compile Include="*.cs"/>를 손으로 넣으면 SDK 의 기본 포함과 충돌해 "같은 파일이 두 번 컴파일됨" 에러가 납니다. SDK 스타일에서는 파일 목록을 쓰지 않습니다. TargetFramework를 다운그레이드했는데 동작이 그대로 —obj/를 지우지 않으면 이전 TFM 의 캐시가 남아 효과가 안 보입니다.bin/ obj/삭제 후 재빌드.- CI 에서 매번 느린 빌드 — CI 러너는
obj/캐시가 없으므로 매번 풀 빌드입니다. GitHub Actions 의actions/cache로~/.nuget/packages와obj/를 캐싱하면 로컬 개발만큼 빨라집니다.
10. 정리 — "빌드가 불투명했던" 이유가 풀리는 지점
처음의 질문으로 돌아갑니다. dotnet new 한 번에 왜 폴더 두 개가 나오고, dotnet build 는 왜 obj/ 와 bin/ 을 굳이 따로 만드는가.
.csproj는 레시피 카드 입니다. SDK 지정 한 줄 + PropertyGroup + ItemGroup 이 빌드 전체를 지배합니다.obj/는 조리대 입니다. 중간.dll, 생성 소스(GlobalUsings.g.cs), incremental 캐시, NuGet 자산 그래프(project.assets.json) 가 모입니다. 빌드가 빨라지는 비밀은 모두obj/에 있습니다.bin/은 서빙 접시 입니다. 실행·디버깅·배포에 쓰이는 최종 산출물만 올라갑니다. 진짜 배포는 그 안의publish/를 씁니다.dotnetCLI 네 자매는 "레시피 카드 작성(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/ 문제야" / "이건 .csproj 의 PackageReference 문제야" / "이건 bin/publish/ 로 확인해야 해" 라고 반사적으로 위치를 짚을 수 있게 되는 것이 이 주제를 공부하는 진짜 목표입니다.