---
title: "Content Directories 는 Root 에서 참조를 따라 빌드한다"
description: "Content Directories 빌드는 Root Asset 에서 GUID 참조를 따라가며 닿는 에셋을 모읍니다. 빌드는 Loadable 같은 점선 참조까지 다 따라가지만 런타임 로드는 점선에서 멈추므로, 수명이 갈리는 자리에 점선을 둡니다."
author: "유니얼"
pubDate: 2026-10-19
category: "game-dev/unity/runtime"
tags: ["Unity", "메모리"]
canonical: https://blog.unialgames.com/posts/unity-content-directories-graph/
---
<x-repo>
<script type="application/json">
{
  "repo": "SeoBYP/ContentDirectoriesStudy",
  "text": "Unity 6.6 Content Directories 를 공식 문서대로 만들고, 빌드 결과와 런타임 동작을 에디터와 Player 에서 잰 기록입니다.",
  "chips": ["Unity 6000.6.3f1", "URP 17.6.0", "Windows Player"],
  "links": [
    { "text": "Root Asset", "path": "Assets/ContentPacks" },
    { "text": "빌드 메뉴 코드", "path": "Assets/Editor/BuildContentPackRootAsset.cs" }
  ]
}
</script>
</x-repo>

AssetBundle 은 어느 에셋을 어느 번들에 넣을지 사람이 정합니다. 빠뜨리면 그 에셋은 참조하는 번들마다 몰래 복사되고, 무엇이 어디로 들어갔는지 알려면 리포트를 뒤져야 합니다.

Content Directories 에는 배정하는 단계가 없습니다. **Root 하나에서 참조를 따라가며 닿는 것을 전부 담습니다.** 그리고 참조를 두 종류로 나눠, 빌드는 둘 다 따라가고 런타임 로드는 한쪽에서 멈추게 했습니다.

## 빌드는 Root 에서 GUID 를 따라간다

Player 빌드가 Scene List 에서 출발하듯, 콘텐츠 빌드는 **Root Asset** 에서 출발합니다. Root 의 필드가 가리키는 에셋, 그 에셋이 가리키는 에셋을 차례로 따라가며 모읍니다.

따라가는 길은 에셋 파일에 저장된 참조 값입니다. Root Asset 을 텍스트로 열면 이렇게 들어 있습니다.

```yaml
bonusPrefab:
  m_LoadableObjectId: {fileID: 645800571622001937, loadable: 1, guid: 462de8f7fb5dac54eb97111ce3d2eda1, type: 3}
bonusLevel:
  m_SceneGUID: 988cbc230d1f5174ab9aa0a8beda1dfe
```

- `guid` 는 어느 파일인지입니다. `Cube.prefab.meta` 에 적힌 값입니다
- `fileID` 는 그 파일 안의 어느 오브젝트인지입니다. 씬은 파일 전체가 단위라 GUID 만 씁니다
- `loadable: 1` 은 "빌드에는 넣되 로드는 끊는다" 는 표시입니다

같은 에셋은 GUID 가 같으니 **한 번만 담깁니다.** 이 프로젝트에서 Cube 의 `Lit.mat` 과 씬 머티리얼 여섯 개가 모두 같은 셰이더를 쓰는데, 빌드 결과에는 `Lit.shader` 파일이 하나뿐이고 일곱 머티리얼이 전부 그 파일을 가리킵니다. 같은 빌드 안의 중복 제거는 따로 설정하는 기능이 아니라 이 수집 방식에서 그냥 나옵니다.

반대로 **GUID 가 없는 참조는 그래프에 안 잡힙니다.** `Shader.Find("…")` 나 `Resources.Load("…")` 처럼 문자열로 찾는 것은 빌드가 따라갈 수 없어서, 에디터에서는 되는데 콘텐츠에는 빠질 수 있습니다.

## 빌드는 점선까지 가고 로드는 점선에서 멈춘다

참조는 세 가지 모양이 있고, 빌드와 런타임에서 다르게 다뤄집니다.

| 참조 | 빌드 | 런타임 |
| --- | --- | --- |
| 일반 필드 (실선) | 담는다 | 가진 쪽과 같이 올라온다 |
| `Loadable<T>` (점선) | 담는다 | `Load()` 해야 올라온다 |
| `LoadableSceneId` (점선) | 담는다 | `SceneManager.LoadSceneAsync` 로 올린다 |

이 프로젝트의 실제 그래프에서 두 경우를 바꿔 보면, 같은 그림 위에서 지나가는 길이 다릅니다.

<x-tabs>
<x-wiring data-tab="빌드할 때">
<script type="application/json">
{
  "label": "빌드가 따라가는 길",
  "width": 620,
  "height": 420,
  "nodes": [
    { "id": "root", "title": "Root", "sub": "ContentPackRootAsset", "x": 235, "y": 10 },
    { "id": "meta", "title": "Metadata", "sub": "일반 필드", "x": 20, "y": 125 },
    { "id": "cube", "title": "Cube.prefab", "sub": "Loadable", "x": 235, "y": 125 },
    { "id": "scene", "title": "InDoor.unity", "sub": "LoadableSceneId", "x": 450, "y": 125 },
    { "id": "mat", "title": "Lit.mat", "x": 235, "y": 240 },
    { "id": "smat", "title": "씬 머티리얼 6개", "x": 450, "y": 240 },
    { "id": "shader", "title": "Lit.shader", "sub": "파일 하나를 같이 쓴다", "x": 340, "y": 355, "w": 170 }
  ],
  "edges": [
    { "from": "root", "to": "meta" },
    { "from": "root", "to": "cube", "auto": true },
    { "from": "root", "to": "scene", "auto": true },
    { "from": "cube", "to": "mat" },
    { "from": "scene", "to": "smat" },
    { "from": "mat", "to": "shader" },
    { "from": "smat", "to": "shader" }
  ],
  "steps": [
    { "node": "root", "why": "Root 에서 출발합니다." },
    { "edge": 0, "why": "일반 필드를 따라 메타데이터를 담습니다." },
    { "edge": 1, "why": "Loadable 도 따라갑니다. 빌드에는 들어가야 나중에 올릴 수 있습니다." },
    { "edge": 2, "why": "씬 참조도 따라갑니다." },
    { "edge": 3, "why": "Cube 가 쓰는 머티리얼을 담습니다." },
    { "edge": 4, "why": "씬이 쓰는 머티리얼 여섯 개를 담습니다." },
    { "edge": 5, "why": "머티리얼이 쓰는 셰이더를 담습니다." },
    { "edge": 6, "why": "이미 담은 셰이더라 다시 담지 않습니다. 파일은 하나뿐입니다." }
  ],
  "note": "점선(청록)은 Loadable 과 LoadableSceneId 입니다. 빌드는 실선과 점선을 가리지 않습니다."
}
</script>
</x-wiring>
<x-wiring data-tab="등록할 때">
<script type="application/json">
{
  "label": "등록하면 올라오는 범위",
  "width": 620,
  "height": 420,
  "nodes": [
    { "id": "root", "title": "Root", "sub": "ContentPackRootAsset", "x": 235, "y": 10 },
    { "id": "meta", "title": "Metadata", "sub": "일반 필드", "x": 20, "y": 125 },
    { "id": "cube", "title": "Cube.prefab", "sub": "Loadable", "x": 235, "y": 125 },
    { "id": "scene", "title": "InDoor.unity", "sub": "LoadableSceneId", "x": 450, "y": 125 },
    { "id": "mat", "title": "Lit.mat", "x": 235, "y": 240 },
    { "id": "smat", "title": "씬 머티리얼 6개", "x": 450, "y": 240 },
    { "id": "shader", "title": "Lit.shader", "sub": "파일 하나를 같이 쓴다", "x": 340, "y": 355, "w": 170 }
  ],
  "edges": [
    { "from": "root", "to": "meta" },
    { "from": "root", "to": "cube", "auto": true },
    { "from": "root", "to": "scene", "auto": true },
    { "from": "cube", "to": "mat" },
    { "from": "scene", "to": "smat" },
    { "from": "mat", "to": "shader" },
    { "from": "smat", "to": "shader" }
  ],
  "steps": [
    { "node": "root", "why": "등록하면 Root 가 올라옵니다." },
    { "edge": 0, "why": "실선인 메타데이터가 같이 올라옵니다. 여기까지 6.93 KB 입니다." },
    { "node": "cube", "why": "점선 너머의 Cube 는 Status 가 None 입니다. Load() 를 부를 때까지 안 올라옵니다." },
    { "node": "scene", "why": "씬과 그 아래 머티리얼, 셰이더도 그대로 디스크에 있습니다. 전체 2.85 MB 중 나머지입니다." }
  ],
  "note": "런타임은 점선에서 멈춥니다. 점선을 넘을 때 그 아래 실선이 한꺼번에 올라옵니다."
}
</script>
</x-wiring>
</x-tabs>

Build Analysis 창에 같은 숫자가 나옵니다. Root 에서 바로 닿는 것(Direct)은 에셋 2개 6.93 KB 이고, 끝까지 닿는 것(Total)은 에셋 17개 2.85 MB 입니다. 점선 두 개가 등록 때 올라오는 양을 약 420분의 1 로 끊었습니다.

## 점선은 수명이 갈리는 자리에 둔다

점선을 어디에 두느냐가 곧 "무엇을 같이 올리고 같이 내릴지" 를 정하는 일입니다. 원칙은 하나입니다. **같이 살고 같이 죽는 것은 실선, 수명이 다른 경계에는 점선.**

- 항상 필요하고 가벼운 것(설정, 메타데이터)은 Root 의 일반 필드로 둡니다
- 열 때만 쓰는 UI, 특정 스테이지의 적, 보너스 레벨은 `Loadable<T>` 나 `LoadableSceneId` 로 끊습니다
- `Loadable<T>` 는 Root 에만 두는 것이 아닙니다. 로비 씬의 컨트롤러가 인벤토리 UI 프리팹을 `Loadable` 필드로 들고 있다가 열 때만 올리는 식으로 MonoBehaviour 필드에도 둡니다

점선 아래는 실선으로 한꺼번에 따라옵니다. Cube 를 `Load()` 하면 그 아래 머티리얼과 셰이더는 따로 부르지 않아도 같이 올라옵니다. 그래서 점선 하나가 끊는 범위는 그 아래 실선 전체입니다.

한 가지 예외가 있습니다. 서로를 참조하는 순환이 생기면 어느 것을 먼저 올릴지 정할 수 없어서, 매뉴얼에 따르면 **고리 전체를 한 덩어리로 묶고 64 MB 를 넘으면 빌드가 실패**합니다.

## 직접 확인하는 테스트 코드

Root 클래스 하나와 빌드 메뉴 하나면 그래프를 직접 볼 수 있습니다. 빌드한 뒤 Window > Analysis > Build Analysis 에서 Root 의 Direct 와 Total 을 비교하고, `Library/BuildHistory/<빌드ID>/ContentLayout.json` 에서 파일마다 같이 여는 파일(`SerializedFileDependencies`)과 점선(`LoadableDependencies`)을 확인합니다.

```csharp
[CreateAssetMenu(menuName = "Content/Content Pack Root Asset")]
public class ContentPackRootAsset : ScriptableObject
{
    public ContentPackMetadata metadata;     // 실선: Direct 에 들어간다
    public Loadable<GameObject> bonusPrefab; // 점선: Total 에만 들어간다
    public LoadableSceneId bonusLevel;       // 점선: Total 에만 들어간다
}
```

```csharp
public static class BuildContentPack
{
    [MenuItem("Build/Content Directories/Content Pack")]
    public static void Build()
    {
        var report = BuildPipeline.BuildContentDirectory(new BuildContentDirectoryParameters
        {
            outputPath = "Builds/ContentDirectories/ContentPack",
            rootAssetPaths = new[] { "Assets/ContentPacks/ContentPackRootAsset.asset" },
            options = BuildContentOptions.None,
        });
        Debug.Log(report.summary.result);
    }
}
```

이 프로젝트에서는 Direct 6.93 KB, Total 2.85 MB 가 나왔습니다. `ContentLayout.json` 에서 Root 파일의 같이 여는 파일은 스크립트 정보와 메타데이터 둘뿐이고, Cube 와 씬은 점선 칸에만 적혀 있습니다.

## 주의할 점

### 1. .meta 를 잃지 않는다

**안 지키면 생기는 문제.** 참조는 `.meta` 에 적힌 GUID 로 이어집니다. 에셋을 옮기면서 `.meta` 를 빠뜨리거나 버전 관리에서 빼면 새 GUID 가 붙어 Root 에서 그 에셋으로 가는 길이 끊깁니다.

**이렇게 쓴다.** 에셋은 에디터 안에서 옮기고, `.meta` 를 에셋과 같이 커밋합니다.

### 2. 문자열로 찾는 에셋은 Root 에서 닿게 만든다

**안 지키면 생기는 문제.** `Shader.Find` 나 `Resources.Load` 로 찾는 에셋은 그래프에 안 보여서 콘텐츠에 안 들어갑니다. 에디터에서는 프로젝트에 있으니 돌아가고, 빌드한 콘텐츠에서만 비어 나옵니다.

**이렇게 쓴다.** 필요한 에셋은 Root 나 콘텐츠 에셋의 필드로 참조해서 GUID 로 이어 둡니다.

### 3. 서로 참조하는 큰 덩어리를 만들지 않는다

**안 지키면 생기는 문제.** 순환 참조는 고리 전체가 한 번에 올라오고, 64 MB 를 넘으면 빌드가 실패합니다.

**이렇게 쓴다.** 되돌아가는 참조가 필요하면 한쪽을 `Loadable<T>` 로 바꿔 고리를 끊습니다.

## 정리

Content Directories 빌드는 **Root 에서 GUID 참조를 따라가며 닿는 것을 한 번씩 담습니다.**

- 같은 에셋은 GUID 가 같아서 같은 빌드 안에서는 한 번만 들어갑니다. 문자열로 찾는 에셋은 그래프에 안 보입니다.
- 빌드는 실선과 점선을 다 따라가고, 런타임은 점선에서 멈춥니다. 이 프로젝트에서 등록 때 올라오는 양은 6.93 KB, 전체는 2.85 MB 입니다.
- 같이 살고 같이 죽는 것은 실선, 수명이 다른 경계에는 `Loadable<T>` 나 `LoadableSceneId` 를 둡니다.