---
title: "Content Directories 는 번들을 에셋 단위로 쪼갠다"
description: "AssetBundle 은 번들 하나가 배포, 로드, 해제 단위라 에셋 하나를 쓰려 해도 번들을 통째로 엽니다. Unity 6.6 의 Content Directories 는 이 일을 Root, 디렉터리, Loadable 셋으로 나눠 에셋 하나씩 올리고 내립니다."
author: "유니얼"
pubDate: 2026-10-19
category: "game-dev/unity/runtime"
tags: ["Unity", "메모리"]
canonical: https://blog.unialgames.com/posts/unity-content-directories-why/
---
<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": "README (학습 보고서)", "path": "README.md" },
    { "text": "Root Asset 클래스", "path": "Assets/Scripts/ContentPackRootAsset.cs" }
  ]
}
</script>
</x-repo>

콘텐츠를 AssetBundle 로 나누면 번들을 어떻게 묶을지 고민이 끝나지 않습니다. 텍스처 하나를 쓰려고 번들 하나와 그 번들이 기대는 번들까지 열고, 공용 셰이더는 번들마다 복사되고, 그걸 막으려고 중복 방지용 번들을 따로 만듭니다.

Unity 6.6 의 **Content Directories** 는 **번들이라는 묶음을 없애고, 에셋 하나를 파일 하나로 두어 따로 찾고 따로 올리고 따로 내립니다.** 번들 하나가 떠맡던 일을 Root, 디렉터리, `Loadable` 셋으로 나눈 것입니다.

## 번들 하나가 세 가지 일을 떠맡고 있었다

Unity 는 Content Directories 를 소개하는 글에서 AssetBundle 의 문제를 이렇게 짚었습니다. 번들 하나가 **배포, 저장, 로드의 나눌 수 없는 단위**라는 것입니다. 의존성도 번들 단위로 따라가서, 함께 받고 함께 올리고 함께 내려야 하는 큰 덩어리가 됩니다. 그래서 번들을 어떻게 나누느냐가 로딩 성능과 다운로드 크기를 좌우했고, 이건 AssetBundle 을 직접 쓰든 Addressables 를 쓰든 같았습니다.

매뉴얼도 같은 이야기를 합니다. 에셋 하나를 로드해도 그 번들과 의존 번들 전체를 재귀로 열어야 하고, 그 에셋이 쓰지 않는 것까지 같이 올라옵니다. 공용 에셋은 번들마다 복사될 수 있고, 그걸 막으려고 **중복을 피하는 것 말고는 하는 일이 없는 번들**이 생깁니다.

같은 Cube 프리팹 하나를 꺼낼 때 무엇이 열리는지 두 방식으로 보면 차이가 보입니다.

<x-tabs>
<x-wiring data-tab="AssetBundle">
<script type="application/json">
{
  "label": "Cube 하나를 꺼낼 때 열리는 것",
  "width": 560,
  "height": 430,
  "nodes": [
    { "id": "code", "title": "LoadAsset(\"Cube\")", "x": 170, "y": 10, "w": 220, "h": 46, "aside": true },
    { "id": "cube", "title": "cube 번들", "sub": "Cube 와 같이 묶인 것 전부", "x": 170, "y": 120, "w": 220 },
    { "id": "mat", "title": "mat 번들", "sub": "Lit.mat, Lit.shader 와 나머지", "x": 170, "y": 240, "w": 220 },
    { "id": "mem", "title": "메모리", "sub": "두 번들이 통째로 열려 있음", "x": 170, "y": 360, "w": 220 }
  ],
  "edges": [
    { "from": "code", "to": "cube", "label": "번들을 먼저 연다" },
    { "from": "cube", "to": "mat", "label": "의존 번들도 직접 연다" },
    { "from": "mat", "to": "mem", "label": "번들 단위로 남는다" }
  ],
  "steps": [
    { "edge": 0, "why": "에셋 하나를 꺼내려면 그 에셋이 든 번들을 먼저 엽니다." },
    { "edge": 1, "why": "머티리얼이 다른 번들에 있으면 그 번들도 열어야 합니다. 안 열면 머티리얼이 비어 나옵니다." },
    { "edge": 2, "why": "Cube 만 썼어도 두 번들은 번들째로 열려 있습니다. 내릴 때도 번들 단위입니다." }
  ],
  "note": "의존 관계는 번들 → 번들입니다. 에셋 하나가 필요해도 번들 하나가 움직입니다."
}
</script>
</x-wiring>
<x-wiring data-tab="Content Directories">
<script type="application/json">
{
  "label": "Cube 하나를 꺼낼 때 열리는 것",
  "width": 560,
  "height": 430,
  "nodes": [
    { "id": "code", "title": "bonusPrefab.Load()", "x": 170, "y": 10, "w": 220, "h": 46, "aside": true },
    { "id": "cube", "title": "Cube.prefab 파일", "sub": "Cube 하나", "x": 170, "y": 120, "w": 220 },
    { "id": "mat", "title": "Lit.mat 파일", "sub": "머티리얼 하나", "x": 170, "y": 240, "w": 220 },
    { "id": "shader", "title": "Lit.shader 파일", "sub": "셰이더 하나", "x": 170, "y": 360, "w": 220 }
  ],
  "edges": [
    { "from": "code", "to": "cube", "label": "Cube 의 파일을 찾는다" },
    { "from": "cube", "to": "mat", "label": "쓰는 파일만 따라 연다" },
    { "from": "mat", "to": "shader", "label": "쓰는 파일만 따라 연다" }
  ],
  "steps": [
    { "edge": 0, "why": "매니페스트에서 Cube 가 든 파일을 찾습니다. 파일 하나에 에셋 하나입니다." },
    { "edge": 1, "why": "Cube 가 쓰는 머티리얼의 파일을 엽니다. 엔진이 매니페스트를 보고 알아서 엽니다." },
    { "edge": 2, "why": "머티리얼이 쓰는 셰이더까지 엽니다. Cube 가 쓰지 않는 것은 하나도 안 열립니다." }
  ],
  "note": "의존 관계는 파일 → 파일입니다. 놓을 때도 이 파일들만 내려갑니다."
}
</script>
</x-wiring>
</x-tabs>

포장만 다르고 파일 안의 직렬화 포맷은 같습니다. AssetBundle 안에도 직렬화 파일과 `.resS` 가 들어 있고, Content Directories 는 그것을 번들로 묶지 않고 **에셋마다 낱개 파일**로 둡니다.

## 셋으로 나눴다: Root, 디렉터리, Loadable

번들 하나가 하던 "무엇이 들어 있나를 정리하는 일, 파일로 나가는 일, 올리고 내리는 일" 을 셋이 나눠 맡습니다.

| 나눈 것 | 맡는 일 | 따로 할 수 있는 것 |
| --- | --- | --- |
| Root | 목차. 콘텐츠로 들어가는 입구 | 타입으로 Root 를 골라 꺼내기 |
| 디렉터리 | 빌드, 배포, 등록 | 따로 빌드하고 Player 에 넣을지 정하기 |
| `Loadable<T>` | 메모리 | 에셋 하나를 따로 `Load` 하고 `Release` 하기 |

**Root** 는 직접 만든 `ScriptableObject` 입니다. Player 빌드가 Scene List 에서 출발해 닿는 것을 담듯, 콘텐츠 빌드는 Root 에서 출발해 닿는 것을 전부 담습니다. 런타임에는 `ContentLoadManager.GetRootAssets<T>()` 로 꺼내는 입구가 됩니다. 파일 이름이 해시라 경로로는 찾을 수 없기 때문입니다.

```csharp
public class ContentPackRootAsset : ScriptableObject
{
    public ContentPackMetadata metadata;     // 일반 참조: Root 와 같이 올라온다
    public Loadable<GameObject> bonusPrefab; // Load() 를 불러야 올라온다
    public LoadableSceneId bonusLevel;       // SceneManager 로 올린다
}
```

**디렉터리** 는 빌드 한 번의 결과 폴더입니다. 이 폴더를 `RegisterContentDirectory` 로 등록해야 엔진이 안에 무엇이 있는지 압니다. 같은 빌드 안에서는 같은 에셋이 한 번만 들어갑니다.

**`Loadable<T>`** 는 에셋 하나의 수명 손잡이입니다. 빌드에는 들어가지만 `Load()` 를 부를 때까지 메모리에 안 올라오고, `Release()` 하면 그 에셋만 내려갑니다. 문자열 경로 대신 타입이 붙은 참조라서 이름을 바꾸거나 옮겨도 끊기지 않습니다.

이 프로젝트의 Root 하나로 빌드하면 Root 에서 닿는 전체는 2.85 MB 인데, 등록할 때 바로 올라오는 것은 Root 와 일반 참조인 메타데이터, 스크립트 정보까지 6.93 KB 입니다. 나머지는 `Loadable` 과 `LoadableSceneId` 를 넘어갈 때만 올라옵니다.

## 기존 Addressables 프로젝트에서 숫자로 확인됐다

Unity 는 같은 글에서 Addressables 를 쓰던 Slime Rancher 2 를 **코드를 바꾸지 않고** Content Directories 로 돌린 결과를 냈습니다. Unity 6.6 베타, MacBook Pro M5 Max 기준입니다.

| 항목 | AssetBundle | Content Directories |
| --- | --- | --- |
| 증분 빌드 | 32분 16초 | 3분 4초 |
| 클린 빌드 | 58분 6초 | 37분 13초 |
| Player 크기 | 4 GB | 2.88 GB |
| 부팅에서 게임플레이까지 | 45초 | 30초 |

다만 매뉴얼은 "클린 빌드는 AssetBundle 보다 느릴 수 있다" 고 적고 있어서, 클린 빌드 숫자는 프로젝트 구성에 따라 달라질 수 있습니다.

대가도 있습니다. 6.6 은 Player 와 함께 넣는 로컬 콘텐츠만 다룹니다. 원격으로 받아 오는 배포는 아직 없고, Unity 는 이것을 Unity 7 세대에 에셋 단위 다운로드로 넓히겠다고 예고했습니다. 또 `Loadable` 의 `Load` 와 `Release` 짝을 맞추는 일은 쓰는 쪽 몫입니다.

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

Root Asset 하나를 빌드하고 등록해서 꺼내 보는 최소 코드입니다. 위 저장소의 `BuildContentPackRootAsset.cs` 와 `ContentDirectoryManager.cs` 를 줄인 것입니다. 에디터에서는 빌드 출력 폴더를, Player 에서는 `StreamingAssets` 를 등록합니다.

```csharp
// 에디터 스크립트: Root 에서 닿는 것을 전부 콘텐츠 디렉터리로 빌드한다
[MenuItem("Build/Content Directories/Content Pack")]
public static void Build()
{
    var parameters = new BuildContentDirectoryParameters
    {
        outputPath = "Builds/ContentDirectories/ContentPack",
        rootAssetPaths = new[] { "Assets/ContentPacks/ContentPackRootAsset.asset" },
        options = BuildContentOptions.None,
    };
    BuildPipeline.BuildContentDirectory(parameters);
}
```

```csharp
// 런타임: 등록하고, Root 를 꺼내고, Cube 하나만 올렸다 내린다
public class ContentBootstrap : MonoBehaviour
{
    ContentDirectoryHandle m_Handle;
    ContentPackRootAsset m_Root;

    void Start()
    {
#if UNITY_EDITOR
        string path = "Builds/ContentDirectories/ContentPack";
#else
        string path = Application.streamingAssetsPath + "/ContentPack";
#endif
        m_Handle = ContentLoadManager.RegisterContentDirectory(path);

        m_Root = ContentLoadManager.GetRootAssets<ContentPackRootAsset>()[0];
        Debug.Log($"메타데이터: {m_Root.metadata.description}");   // 등록만으로 올라와 있다
        Debug.Log($"Cube 상태: {m_Root.bonusPrefab.Status}");      // None. 아직 안 올라왔다

        GameObject cube = m_Root.bonusPrefab.Load();
        Debug.Log($"Cube 상태: {m_Root.bonusPrefab.Status}");      // Loaded
    }

    void OnDestroy()
    {
        m_Root.bonusPrefab.Release();
        ContentLoadManager.UnregisterContentDirectory(m_Handle);
    }
}
```

등록 직후 메타데이터는 읽히고 Cube 는 `None` 으로 나오면, Root 와 일반 참조만 올라오고 `Loadable` 은 기다린다는 것이 확인됩니다.

## 주의할 점

### 1. 메모리를 나누려면 Root 가 아니라 Loadable 로 끊는다

**안 지키면 생기는 문제.** Root 를 여러 개로 나누면 정리는 되지만 메모리는 그대로입니다. 디렉터리를 등록하면 그 안의 Root 전부와 그 Root 들이 일반 참조로 쥔 것이 한꺼번에 올라옵니다.

**이렇게 쓴다.** 나중에 올려도 되는 것은 Root 의 필드를 `Loadable<T>` 나 `LoadableSceneId` 로 바꿉니다. Root 는 목록이 너무 길어져 찾기 어려울 때 나눕니다.

### 2. 코드는 콘텐츠에 들어가지 않는다

**안 지키면 생기는 문제.** 콘텐츠에 새 컴포넌트나 새 Root 클래스를 넣고 콘텐츠만 다시 빌드하면, Player 에 그 클래스가 없어서 객체를 만들지 못합니다. 콘텐츠 파일에는 클래스 이름표만 들어가고 코드 본체는 늘 Player 의 DLL 에 있습니다.

**이렇게 쓴다.** 새 스크립트가 생긴 변경은 Player 도 다시 빌드합니다. 콘텐츠만 갈아 끼우는 변경은 기존 클래스와 필드 안에서만 합니다.

## 정리

Content Directories 는 **번들이 한꺼번에 하던 일을 Root, 디렉터리, `Loadable` 로 나눠 에셋 하나를 단위로 다룹니다.**

- 에셋 하나가 파일 하나라 의존 관계가 파일 → 파일이고, 쓰는 것만 열리고 놓은 것만 내려갑니다.
- Root 는 입구, 디렉터리는 빌드와 배포 단위, `Loadable` 은 메모리 단위입니다. 등록하면 Root 와 일반 참조만 올라옵니다.
- 대신 6.6 은 로컬 콘텐츠 전용이고, `Load` 와 `Release` 의 짝은 쓰는 쪽이 맞춥니다.