---
title: "Content Directories 는 등록할 때 실선까지만 올린다"
description: "Content Directories 는 디렉터리를 등록할 때 매니페스트를 읽고 Root 와 실선만 올립니다. 나머지는 Loadable.Load 가 매니페스트로 파일을 찾아 실선 순서대로 열고, 씬과 핸들로 각각 올린 같은 에셋은 하나의 객체로 공유됩니다."
author: "유니얼"
pubDate: 2026-10-19
category: "game-dev/unity/runtime"
tags: ["Unity", "메모리"]
canonical: https://blog.unialgames.com/posts/unity-content-directories-load/
---
<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": "등록 도우미", "path": "Assets/Scripts/ContentDirectoryManager.cs" },
    { "text": "로드 측정 로그", "path": "measurements/cd123_player_Normal.log" }
  ]
}
</script>
</x-repo>

콘텐츠를 Player 밖 폴더로 빼면, 게임은 그 폴더에 무엇이 있는지 모릅니다. 파일 이름은 해시라 경로로 찾을 수도 없고, 무엇을 언제 메모리에 올릴지도 정해 줘야 합니다.

Content Directories 는 이것을 두 단계로 나눴습니다. **등록할 때 매니페스트를 읽어 전체 지도를 만들고 Root 와 그 실선만 올립니다. 나머지는 `Loadable` 을 `Load()` 할 때 그 지도를 보고 필요한 파일만 엽니다.**

## 등록은 지도를 만들고 Root 와 실선만 올린다

`ContentLoadManager.RegisterContentDirectory(경로)` 는 폴더의 매니페스트를 읽습니다. 매니페스트에는 파일마다 "같이 열어야 하는 파일" 목록과 실제 파일 이름이 있어서, 이것 하나로 **어떤 콘텐츠 파일도 열지 않고** 전체 로딩 계획을 세울 수 있습니다.

그다음 매니페스트의 `RootAssets` 에 적힌 Root 와, Root 가 일반 필드로 가리키는 실선 아래만 올립니다. Root 는 타입으로 꺼냅니다.

```csharp
ContentDirectoryHandle handle = ContentLoadManager.RegisterContentDirectory(path);
var root = ContentLoadManager.GetRootAssets<ContentPackRootAsset>()[0];

Debug.Log(root.metadata.description);    // 실선이라 이미 올라와 있다
Debug.Log(root.bonusPrefab.Status);      // None. Loadable 은 아직이다
```

이 프로젝트에서 등록 직후 Root 는 1개, 메타데이터는 읽히고, Cube 의 `Status` 는 `None` 이었습니다. 에디터와 Player 가 같았습니다.

경로가 없으면 `FileNotFoundException` 을 던집니다. 에디터에서는 빌드 출력 폴더를, Player 에서는 `Application.streamingAssetsPath` 아래를 등록해야 해서 경로를 나눠 둡니다.

## Load 는 지도를 보고 실선 끝부터 연다

`Loadable<T>.Load()` 는 자기 안의 ID 로 매니페스트의 `Loadables` 표를 찾아 어느 파일의 어느 오브젝트인지 알아냅니다. 그 파일을 열기 전에, 그 파일이 같이 열어야 하는 파일을 끝까지 따라가 **안쪽부터** 엽니다. Cube 하나를 올릴 때 이 프로젝트의 매니페스트는 이렇게 이어져 있었습니다.

<x-wiring>
<script type="application/json">
{
  "label": "bonusPrefab.Load() 가 파일을 여는 순서",
  "width": 560,
  "height": 560,
  "nodes": [
    { "id": "code", "title": "bonusPrefab.Load()", "x": 170, "y": 10, "w": 220, "h": 46, "aside": true },
    { "id": "mf", "title": "매니페스트", "sub": "Loadables: Cube 는 파일 7", "x": 170, "y": 110, "w": 220 },
    { "id": "f2", "title": "파일 2 · 16", "sub": "폴백 셰이더 둘", "x": 170, "y": 215, "w": 220 },
    { "id": "f3", "title": "파일 3", "sub": "Lit.shader", "x": 170, "y": 300, "w": 220 },
    { "id": "f18", "title": "파일 18", "sub": "Lit.mat", "x": 170, "y": 385, "w": 220 },
    { "id": "f7", "title": "파일 7", "sub": "Cube.prefab", "x": 170, "y": 470, "w": 220 }
  ],
  "edges": [
    { "from": "code", "to": "mf", "label": "ID 로 조회" },
    { "from": "mf", "to": "f2", "label": "7 → 18 → 3 → 2, 16" },
    { "from": "f2", "to": "f3", "label": "다음 바깥" },
    { "from": "f3", "to": "f18", "label": "다음 바깥" },
    { "from": "f18", "to": "f7", "label": "마지막에 Cube" }
  ],
  "steps": [
    { "edge": 0, "why": "Cube 의 ID 로 매니페스트를 찾습니다. 파일 7 안의 오브젝트입니다." },
    { "edge": 1, "why": "파일 7 은 18 을, 18 은 3 을, 3 은 2 와 16 을 같이 열어야 합니다. 가장 안쪽인 2 와 16 부터 엽니다." },
    { "edge": 2, "why": "셰이더가 기대는 폴백이 열렸으니 Lit.shader 를 엽니다." },
    { "edge": 3, "why": "셰이더가 열렸으니 머티리얼을 엽니다." },
    { "edge": 4, "why": "마지막으로 Cube 파일을 열고 그 안의 GameObject 를 돌려줍니다. Status 는 Loaded 가 됩니다." }
  ],
  "note": "이미 열린 파일은 다시 읽지 않고 쓰는 쪽 수만 하나 늘립니다. Cube 의 기본 메시는 엔진 내장이라 콘텐츠에서 열지 않습니다."
}
</script>
</x-wiring>

에디터에서 `Load()` 직후 셰이더 수를 세면 3개가 늘었습니다. 파일 3, 16, 2 의 Lit, FallbackError, FallbackShader 입니다. Cube 가 쓰지 않는 씬 머티리얼은 하나도 안 올라왔습니다.

`Load()` 는 그 자리에서 끝나는 동기 호출이고, 같은 일을 프레임을 넘겨 하는 `LoadAsync()` 도 있습니다. 실패하면 `null` 을 돌려주고 `Status` 가 `Failed` 가 됩니다.

## 어느 길로 올려도 같은 에셋은 하나다

같은 에셋이 씬에도 쓰이고 `Loadable` 로도 잡혀 있으면 두 벌이 올라올까요. InDoor 씬이 쓰는 `Gold.mat` 을 Root 에 `Loadable<Material>` 로 하나 더 걸어 두고 두 순서로 올려 봤습니다.

| 순서 | 단계 | 콘텐츠 머티리얼 | 결과 |
| --- | --- | --- | --- |
| 씬 먼저 | 씬 로드 | 8 | 씬의 Gold 가 올라옴 |
| | Gold 핸들 `Load()` | 8 (늘지 않음) | 씬의 것과 같은 객체 |
| | 씬 언로드 | 3 | Gold 만 남음. 핸들이 잡고 있다 |
| 핸들 먼저 | Gold 핸들 `Load()` | 3 | |
| | 씬 로드 | 8 | 핸들의 것과 같은 객체 |
| | Gold `Release()` | 8 | 씬이 쓰고 있어 남음 |

어느 쪽을 먼저 올려도 **객체는 하나**이고, 씬과 핸들이 각자 그 객체를 쓰는 쪽으로 셉니다. 둘 다 놓아야 내려갑니다. 같은 씬을 Additive 로 여러 번 올렸을 때도 씬 오브젝트만 늘고 머티리얼은 늘지 않았습니다. 같은 빌드 안의 에셋은 파일이 하나라서, 열린 파일을 다시 쓰는 것입니다.

## 씬은 SceneManager 로 올리고 Scene 으로 내린다

씬은 `Loadable` 이 아니라 `LoadableSceneId` 로 가리키고, 올리는 것은 기존 `SceneManager` 입니다.

```csharp
// 같은 씬을 두 번 올리지 않게 먼저 확인한다
if (!SceneManager.GetSceneByLoadableSceneId(root.bonusLevel).isLoaded)
    SceneManager.LoadSceneAsync(root.bonusLevel, new LoadSceneParameters(LoadSceneMode.Additive));

// 내릴 때는 LoadableSceneId 가 아니라 Scene 으로 내린다
Scene scene = SceneManager.GetSceneByLoadableSceneId(root.bonusLevel);
SceneManager.UnloadSceneAsync(scene);
```

`Loadable` 과 달리 씬에는 `Status` 가 없고, `UnloadSceneAsync` 에는 `LoadableSceneId` 를 받는 오버로드가 없습니다. 같은 ID 로 씬을 여러 개 올릴 수 있어서 어느 것을 내릴지 `Scene` 으로 정하게 한 것으로 보입니다. 중복 로드를 막는 일도 쓰는 쪽 몫입니다. 위 코드의 `isLoaded` 는 비동기 로드가 끝나기 전에는 `false` 라서, 빠르게 두 번 부르면 둘 다 통과합니다.

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

등록 직후와 `Load()` 직후의 상태, 씬과 핸들의 객체 공유를 한 번에 찍습니다. `goldMaterial` 은 씬이 쓰는 머티리얼을 Root 에 `Loadable<Material>` 로 걸어 둔 필드입니다.

```csharp
IEnumerator Start()
{
    ContentDirectoryHandle handle = ContentLoadManager.RegisterContentDirectory(ContentPath());
    var root = ContentLoadManager.GetRootAssets<ContentPackRootAsset>()[0];
    Debug.Log($"등록 직후 Cube: {root.bonusPrefab.Status}, 머티리얼 {CountMaterials()}");

    root.bonusPrefab.Load();
    Debug.Log($"Load 직후 Cube: {root.bonusPrefab.Status}, 머티리얼 {CountMaterials()}");

    var op = SceneManager.LoadSceneAsync(root.bonusLevel, new LoadSceneParameters(LoadSceneMode.Additive));
    while (!op.isDone) yield return null;
    Scene scene = SceneManager.GetSceneByLoadableSceneId(root.bonusLevel);

    Material fromHandle = root.goldMaterial.Load();
    Material fromScene = FindMaterial(scene, fromHandle.name);
    Debug.Log($"같은 객체? {ReferenceEquals(fromHandle, fromScene)}");

    root.goldMaterial.Release();
    root.bonusPrefab.Release();
    yield return SceneManager.UnloadSceneAsync(scene);
    ContentLoadManager.UnregisterContentDirectory(handle);
}

static string ContentPath()
{
#if UNITY_EDITOR
    return "Builds/ContentDirectories/ContentPack";
#else
    return Application.streamingAssetsPath + "/ContentPack";
#endif
}

static Material FindMaterial(Scene scene, string name)
{
    foreach (GameObject go in scene.GetRootGameObjects())
        foreach (Renderer r in go.GetComponentsInChildren<Renderer>(true))
            foreach (Material m in r.sharedMaterials)
                if (m != null && m.name == name) return m;
    return null;
}

// 에디터 내장이나 URP 내부 머티리얼을 빼고 센다
static int CountMaterials()
{
    int n = 0;
    foreach (Material m in Resources.FindObjectsOfTypeAll<Material>())
        if (m.hideFlags == HideFlags.None && !m.name.StartsWith("Hidden/")) n++;
    return n;
}
```

`Start()` 는 첫 렌더링 전이라 URP 가 내부 머티리얼을 만들기 전입니다. 기준값을 정확히 보려면 첫 프레임이 지난 뒤에 찍습니다.

## 주의할 점

### 1. 등록 경로를 에디터와 Player 에서 나눈다

**안 지키면 생기는 문제.** 에디터용 빌드 출력 경로를 그대로 쓰면 Player 에는 그 폴더가 없어서 등록이 `FileNotFoundException` 으로 끝납니다.

**이렇게 쓴다.** 에디터는 빌드 출력 폴더, Player 는 `Application.streamingAssetsPath` 아래로 나누는 도우미 하나를 두고 등록은 그것으로만 합니다.

### 2. 같은 씬을 두 번 올리지 않게 직접 막는다

**안 지키면 생기는 문제.** 씬에는 `Status` 도 참조 수도 없습니다. 버튼을 빠르게 두 번 누르면 `isLoaded` 가 둘 다 `false` 라 씬이 두 개 올라옵니다.

**이렇게 쓴다.** `LoadSceneAsync` 가 돌려준 작업을 필드에 들고, 그것이 끝나기 전에는 다시 부르지 않습니다.

### 3. 등록을 풀기 전에 올린 것을 먼저 놓는다

**안 지키면 생기는 문제.** 매뉴얼에 따르면 열린 파일이 남아 있는 디렉터리를 `UnregisterContentDirectory` 하면 에러 로그가 찍힙니다.

**이렇게 쓴다.** `Loadable` 을 전부 `Release` 하고 콘텐츠 씬을 내린 뒤 등록을 풉니다. 여러 디렉터리면 등록의 반대 순서로 풉니다.

## 정리

Content Directories 는 **등록 때 지도를 만들고 Root 와 실선만 올린 뒤, 나머지는 `Load()` 가 그 지도로 필요한 파일만 엽니다.**

- 등록은 매니페스트 하나로 로딩 계획을 세우고 Root 와 일반 필드만 올립니다. `Loadable` 은 `None` 으로 기다립니다.
- `Load()` 는 같이 열어야 하는 파일을 안쪽부터 엽니다. Cube 하나에 셰이더 셋이 따라오고 씬 머티리얼은 안 올라옵니다.
- 씬과 핸들로 각각 올린 같은 에셋은 하나의 객체이고, 둘 다 놓아야 내려갑니다. 씬의 중복 로드는 쓰는 쪽이 막습니다.