---
title: "Content Directories 의 참조 카운트는 핸들만 센다"
description: "Content Directories 는 Loadable 핸들과 씬만 세어 마지막 쪽이 놓은 1프레임 뒤 에셋을 내립니다. Instantiate 한 인스턴스는 세지 않아서, 쓰는 중에 핸들을 놓으면 인스턴스의 머티리얼이 경고 없이 사라집니다."
author: "유니얼"
pubDate: 2026-10-19
category: "game-dev/unity/runtime"
tags: ["Unity", "메모리"]
canonical: https://blog.unialgames.com/posts/unity-content-directories-refcount/
---
<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/ContentBootstrap.cs" },
    { "text": "GPU Resident Drawer 비교", "path": "measurements/cd56.log" }
  ],
  "note": "화면 결과는 GPU Resident Drawer 가 켜진 URP 기본 설정에서 잰 것입니다."
}
</script>
</x-repo>

`Loadable` 로 프리팹을 올려 인스턴스를 만들고, 다 썼다 싶어 `Release()` 했는데 화면에서 오브젝트가 사라집니다. 에러도 경고도 없습니다. 반대로 인스턴스를 전부 지웠는데도 머티리얼이 메모리에 그대로 남아 있기도 합니다.

Content Directories 는 **`Loadable` 핸들과 씬만 세고, 마지막 쪽이 놓은 1프레임 뒤에 에셋을 내립니다.** `Instantiate` 로 만든 인스턴스는 이 셈에 들어가지 않습니다.

## 핸들 하나는 몇 번 Load 해도 하나로 센다

같은 `Loadable` 인스턴스에서 `Load()` 를 두 번 부르고, `Release()` 를 한 번씩 불렀습니다. 콘텐츠 머티리얼 수는 1프레임 뒤에 셌습니다.

| 단계 | 콘텐츠 머티리얼 | Status |
| --- | --- | --- |
| 같은 핸들로 `Load()` 두 번 | 10 | `Loaded`, 두 결과가 같은 객체 |
| `Release()` 한 번 | 9 | `None` |
| `Release()` 한 번 더 | 9 | `None`, 에러 없음 |

`Load()` 를 몇 번 불렀든 **`Release()` 한 번에 내려갑니다.** 핸들 하나가 셈 하나입니다. 두 번째 `Release()` 는 아무 일도 하지 않습니다.

같은 에셋을 가리키는 `Loadable` 을 두 개 만들면 각자 하나씩 셉니다.

| 단계 | 콘텐츠 머티리얼 | 원본 / 두 번째 |
| --- | --- | --- |
| 두 핸들 `Load()` | 10 | `Loaded` / `Loaded`, 같은 객체 |
| 원본만 `Release()` | 10 | `None` / `Loaded` |
| 두 번째도 `Release()` | 9 | `None` / `None` |

두 핸들이 돌려준 것은 같은 객체이고, 마지막 핸들을 놓을 때 내려갑니다. 그리고 내려가는 시점은 **`Release()` 를 부른 프레임이 아니라 그다음 프레임**이었습니다. `Release()` 직후에 세면 아직 10 입니다. 에디터와 Player 가 같았습니다.

C++ 의 `shared_ptr` 처럼 손잡이 하나가 강한 참조 하나라는 점은 같지만, 복사하거나 사라질 때 저절로 늘고 주는 방식이 아닙니다. 부르는 쪽이 `Load()` 와 `Release()` 로 직접 올리고 내립니다.

## 같이 쓰는 에셋은 마지막 사용자가 놓을 때 내려간다

Cube 의 머티리얼과 InDoor 씬의 머티리얼 여섯 개는 같은 `Lit.shader` 를 씁니다. Cube 핸들과 씬을 둘 다 올린 뒤 내리는 순서를 바꿔 봤습니다.

<x-wiring>
<script type="application/json">
{
  "label": "Cube 핸들과 씬이 셰이더 하나를 같이 쓸 때",
  "width": 560,
  "height": 340,
  "nodes": [
    { "id": "h", "title": "Cube 핸들", "sub": "bonusPrefab", "x": 20, "y": 10, "w": 200 },
    { "id": "s", "title": "InDoor 씬", "sub": "LoadableSceneId", "x": 340, "y": 10, "w": 200 },
    { "id": "m1", "title": "Lit.mat", "x": 20, "y": 135, "w": 200 },
    { "id": "m2", "title": "씬 머티리얼 6개", "x": 340, "y": 135, "w": 200 },
    { "id": "sh", "title": "Lit.shader", "sub": "쓰는 쪽 수를 센다", "x": 180, "y": 265, "w": 200 }
  ],
  "edges": [
    { "from": "h", "to": "m1" },
    { "from": "m1", "to": "sh" },
    { "from": "s", "to": "m2" },
    { "from": "m2", "to": "sh" }
  ],
  "steps": [
    { "edge": 0, "why": "Cube 를 Load 합니다. 머티리얼이 따라 올라옵니다." },
    { "edge": 1, "why": "셰이더가 올라옵니다. 셰이더를 쓰는 쪽은 1 입니다." },
    { "edge": 2, "why": "씬을 올립니다. 씬 머티리얼 여섯 개가 올라옵니다." },
    { "edge": 3, "why": "셰이더는 이미 있으니 새로 안 올립니다. 쓰는 쪽만 2 가 됩니다." },
    { "node": "s", "why": "씬을 먼저 내립니다. 씬 머티리얼은 내려가지만 셰이더는 남습니다. Cube 가 아직 쓰고 있습니다." },
    { "node": "h", "why": "Cube 를 Release 합니다. 쓰는 쪽이 0 이 되고, 1프레임 뒤 머티리얼과 셰이더 셋이 같이 내려갑니다." }
  ],
  "note": "따라 올라온 머티리얼과 셰이더는 따로 Release 하지 않습니다. 직접 Load 한 핸들만 놓으면 엔진이 이어서 내립니다."
}
</script>
</x-wiring>

| 내리는 순서 | 첫 번째를 내린 뒤 셰이더 | 두 번째를 내린 뒤 셰이더 |
| --- | --- | --- |
| Cube `Release()` → 씬 언로드 | 남음 | 3개 내려감 |
| 씬 언로드 → Cube `Release()` | 남음 (다섯 번 모두) | 3개 내려감 |

어느 순서든 **마지막으로 쓰던 쪽이 놓을 때** 내려갑니다. 그리고 `Resources.UnloadUnusedAssets()` 를 부르지 않아도 내려갑니다. 놓은 에셋만 처리하고 전체 오브젝트를 훑지 않습니다.

거꾸로 핸들이 잡고 있는 에셋은 `UnloadUnusedAssets()` 를 불러도 안 내려갑니다. Cube 핸들만 잡고 C# 변수도 인스턴스도 없는 상태에서 불렀을 때 `Unloading 0 unused Assets` 가 찍히고 Cube 는 `Loaded` 로 남았습니다.

| 셈을 움직이는 것 | 셈을 안 움직이는 것 |
| --- | --- |
| `Loadable.Load()` / `Release()` | `Instantiate` / `Destroy` |
| 씬 로드 / 언로드 | `renderer.sharedMaterial = …` 같은 대입 |
| 디렉터리 등록 / 해제 (Root) | C# 변수로 들고 있는 것 |

## Instantiate 한 인스턴스는 세지 않는다

표의 오른쪽 칸이 사고가 나는 자리입니다. Cube 를 `Load()` 해서 `Instantiate` 한 뒤, 인스턴스는 그대로 두고 핸들만 `Release()` 했습니다.

| 시점 | 원본 프리팹 | 씬의 인스턴스 | 인스턴스의 머티리얼 |
| --- | --- | --- | --- |
| `Release()` 한 프레임 | 정상 | 정상 | `Lit` |
| 1프레임 뒤 | 파괴됨 (`== null` 이 참) | 살아 있음 | `null` |

인스턴스는 씬에 남아 있는데, 그 인스턴스가 쓰던 머티리얼은 셈이 0 이 되어 내려갔습니다. **경고는 없습니다.** 화면에서는 어떻게 보이는지 Player 에서 큐브 가운데 픽셀을 찍었습니다.

<x-compare>
<script type="application/json">
{
  "label": "핸들만 놓은 뒤 인스턴스 화면 (GPU Resident Drawer)",
  "tabs": [
    { "label": "켬 (기본)", "text": "큐브가 그려지지 않습니다. 그 자리에 배경이 보입니다.", "stats": [["Release 전 가운데 픽셀", "rgb(144,137,134)"], ["Release 후 가운데 픽셀", "rgb(204,216,216) 배경"], ["sharedMaterial", "null"]], "tone": "bad" },
    { "label": "끔", "text": "머티리얼이 없을 때의 마젠타로 그려집니다.", "stats": [["Release 전 가운데 픽셀", "rgb(142,135,132)"], ["Release 후 가운데 픽셀", "rgb(209,1,209) 마젠타"], ["sharedMaterial", "null"]], "tone": "bad" }
  ]
}
</script>
</x-compare>

데이터 상태는 같고 그리는 방식만 다릅니다. URP 의 GPU Resident Drawer 가 켜져 있으면 머티리얼이 빈 렌더러를 그리기에서 빼는 것으로 보이는데, 내부 이유는 확인하지 못했습니다. 어느 쪽이든 핸들을 놓은 쪽 코드에서는 아무 신호가 없습니다.

해제된 원본으로 다시 `Instantiate` 하면 에러가 나는데, 에디터와 Player 의 메시지가 다릅니다. 에디터는 `MissingReferenceException` 으로 "이미 파괴됐다" 고 알려 주지만, Player 는 `ArgumentNullException: Value cannot be null. Parameter name: data` 라서 원인을 떠올리기 어렵습니다.

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

E1 은 인스턴스를 두고 핸들만 놓고, E2 는 같은 에셋에 핸들 둘을 만들어 하나씩 놓습니다. 결과는 1프레임마다 찍어 내려가는 프레임을 확인합니다.

```csharp
ContentPackRootAsset m_Root;
GameObject m_Instance;
Loadable<GameObject> m_Second;   // 두 번째 핸들은 필드로 들고 있어야 한다

// E1: 인스턴스는 둔 채 핸들만 놓는다
IEnumerator ReleaseWhileInUse()
{
    m_Instance = Instantiate(m_Root.bonusPrefab.Load());
    yield return null;

    m_Root.bonusPrefab.Release();
    for (int i = 0; i <= 2; i++)
    {
        var r = m_Instance.GetComponent<Renderer>();
        Debug.Log($"+{i}프레임 인스턴스 살아있음:{(bool)m_Instance} 머티리얼 null:{r.sharedMaterial == null}");
        yield return null;
    }
}

// E2: 같은 에셋, 다른 핸들 둘
IEnumerator TwoHandles()
{
    m_Second = new Loadable<GameObject>(m_Root.bonusPrefab.LoadableObjectId);
    GameObject a = m_Root.bonusPrefab.Load();
    GameObject b = m_Second.Load();
    Debug.Log($"같은 객체? {ReferenceEquals(a, b)}");

    m_Root.bonusPrefab.Release();
    yield return null;
    Debug.Log($"원본만 놓은 뒤: {m_Root.bonusPrefab.Status} / {m_Second.Status}, 살아있음:{a != null}");

    m_Second.Release();
    yield return null;
    Debug.Log($"둘 다 놓은 뒤 살아있음:{a != null}");
}
```

E1 에서 1프레임 뒤 "머티리얼 null:True" 가 나오고, E2 에서 원본만 놓은 뒤에는 살아 있다가 둘 다 놓은 뒤 `false` 가 되면 같은 규칙입니다.

## 주의할 점

### 1. 쓰는 곳마다 자기 Loadable 을 가진다

**안 지키면 생기는 문제.** `Loadable` 하나를 여러 시스템이 같이 쓰면, 몇 번 `Load()` 했든 셈은 하나입니다. 한 곳이 `Release()` 하면 다른 곳이 쓰던 에셋까지 내려갑니다.

**이렇게 쓴다.** 프리팹을 들고 있는 풀이나 스포너는 `new Loadable<T>(원본.LoadableObjectId)` 로 자기 핸들을 만들어 직접 올리고 내립니다.

### 2. 인스턴스를 다 지운 뒤에 Release 한다

**안 지키면 생기는 문제.** 인스턴스는 셈에 안 들어가서, 인스턴스가 남아 있어도 핸들을 놓으면 머티리얼이 내려갑니다. 화면에서 사라지거나 마젠타가 되고 경고는 없습니다.

**이렇게 쓴다.** 핸들의 수명을 그 프리팹으로 만든 인스턴스보다 길게 잡습니다. 인스턴스를 만든 컴포넌트의 `OnDestroy` 에서 인스턴스를 정리한 뒤 `Release()` 합니다.

### 3. 놓은 직후에는 아직 안 내려갔다

**안 지키면 생기는 문제.** `Release()` 나 씬 언로드 완료 이벤트 직후에 메모리를 재면 아직 그대로라 해제가 안 된 것처럼 보입니다.

**이렇게 쓴다.** 해제를 확인하거나 이어지는 작업을 할 때는 최소 1프레임을 기다립니다. 해제 완료를 알려 주는 이벤트나 셈을 읽는 API 는 찾지 못했습니다.

## 정리

Content Directories 는 **`Loadable` 핸들과 씬만 세어, 마지막 쪽이 놓은 1프레임 뒤에 에셋을 내립니다.**

- 핸들 하나는 몇 번 `Load()` 해도 셈 하나이고, 같은 에셋에 핸들이 여럿이면 각자 하나씩 셉니다. 같이 쓰는 에셋은 마지막 사용자가 놓을 때 내려갑니다.
- `UnloadUnusedAssets()` 없이 놓는 순간 그 에셋만 처리하고, 핸들이 잡은 에셋은 `UnloadUnusedAssets()` 도 안 내립니다.
- `Instantiate` 한 인스턴스는 세지 않아서, 쓰는 중에 핸들을 놓으면 머티리얼이 경고 없이 빠집니다. 인스턴스를 다 지운 뒤 놓습니다.