---
title: "Zenject 의 풀 반납은 쉬는 객체 수만큼 비싸다"
description: "Zenject 의 MemoryPool.Despawn 은 이중 반납을 막으려고 쉬는 객체 스택을 끝까지 훑어, 풀이 클수록 반납이 느려집니다. 이 검사는 기본 설정에서 출시 빌드에도 남고, ZEN_STRIP_ASSERTS_IN_BUILDS 로 뺄 수 있습니다."
author: "유니얼"
pubDate: 2026-09-27
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "DI", "GC"]
canonical: https://blog.unialgames.com/posts/unity-zenject-pool/
---
```csharp
public class Spark { public class Pool : MemoryPool<Spark> { } }

Container.BindMemoryPool<Spark, Spark.Pool>().WithInitialSize(512);

var s = pool.Spawn();
pool.Despawn(s);        // 쉬는 객체가 512개면 한 번에 2 µs 남짓
```

컨테이너를 지나는 `Create()` 가 무거워서 풀로 바꿨는데, 풀을 넉넉히 잡을수록 반납이 느려집니다.

Zenject 의 **`Despawn` 은 이중 반납을 막으려고 쉬는 객체 스택 전체를 `Contains` 로 훑습니다.** 반납 비용이 쉬는 객체 수에 비례하고, 이 검사는 정의 기호를 넣지 않으면 출시 빌드에도 남습니다.

## 팩토리의 Create 는 매번 컨테이너를 지난다

`PlaceholderFactory<T>` 는 빈 클래스 하나로 선언하고, `Create()` 는 바인딩이 넣어 준 Provider 에게 인자 `List` 를 넘기는 것이 전부입니다. 그 아래는 컨테이너가 Transient 로 만들 때와 같은 길이라, 생성자 인자 조회, 객체 생성, 필드 주입, 주입용 람다가 매번 붙습니다.

<x-compare>
<script type="application/json">
{
  "label": "총알을 만드는 팩토리",
  "tabs": [
    {
      "label": "손으로 쓴 팩토리",
      "code": "public class BulletFactory\n{\n    readonly Ammo _ammo;\n    public BulletFactory(Ammo ammo) { _ammo = ammo; }\n\n    public Bullet Create(int damage)\n        => new Bullet(_ammo, damage);\n}\n\n// 조립하는 쪽\nvar factory = new BulletFactory(ammo);",
      "stats": [["만들 것", "필드, 생성자, Create 본문"], ["Bullet 생성자가 바뀌면", "팩토리도 고친다"], ["객체 하나 얻는 비용", "158 ns (new 한 번, Mono 플레이어)"]],
      "tone": "bad"
    },
    {
      "label": "PlaceholderFactory",
      "code": "public class BulletFactory\n    : PlaceholderFactory<int, Bullet> { }\n\n// 설치자\nContainer.BindFactory<int, Bullet, BulletFactory>();\n\n// 쓰는 쪽\n[Inject] BulletFactory _factory;\nvar b = _factory.Create(42);",
      "stats": [["만들 것", "빈 클래스 하나"], ["Bullet 생성자가 바뀌면", "그대로 둔다"], ["객체 하나 얻는 비용", "3,869 ns (컨테이너를 지난다, Mono 플레이어)"]],
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

| 한 회 | Mono | IL2CPP |
| --- | --- | --- |
| `new Bullet(ammo)` | 158 ns, 24 B | 115 ns, 24 B |
| `BulletFactory.Create()` | 3,869 ns, 248 B | 4,758 ns, 248 B |
| `Spark.Pool` 의 `Spawn` + `Despawn` (쉬는 객체 1개) | 61 ns | 102 ns |

`Create()` 는 `new` 의 25~40배였고 인자 없이도 한 번에 248 B 를 남겼습니다. 프레임마다 여럿 만드는 것은 풀로 돌리는 이유입니다.

## 풀은 쉬는 객체 스택과 빌려준 수 하나다

```csharp
// Factories/Pooling/MemoryPoolBase.cs
Stack<TContract> _inactiveItems;   // 쉬고 있는 객체
int _activeCount;                  // 빌려준 개수. 빌려준 객체의 목록은 없다
```

`Spawn` 은 스택에서 하나를 꺼내고 개수를 올립니다. 스택이 비었을 때만 늘리는데, 기본은 하나씩(`ExpandByOneAtATime`), `ExpandByDoubling` 이면 전체 개수만큼, `WithFixedSize(n)` 이면 예외입니다. 새로 만드는 순간은 팩토리와 같은 컨테이너 경로를 탑니다. 풀은 저절로 줄지 않고, `WithMaxSize` 를 넘을 때만 줄입니다.

## 반납은 쉬는 스택을 끝까지 훑는다

`Despawn` 의 첫 줄이 `Assert.That(!_inactiveItems.Contains(item), "Tried to return an item to pool {0} twice", ...)` 입니다. 스택에는 찾아보기 표가 없어서, 정상 반납은 "없음" 을 확인하려고 늘 끝까지 갑니다.

<x-flow>
<script type="application/json">
{
  "label": "Despawn 한 번이 쉬는 스택을 훑는 모습",
  "lines": [
    "Assert.That(!_inactiveItems.Contains(item), \"...twice\", GetType());",
    "    // Contains: 맨 위부터 하나씩 Equals",
    "_activeCount--;",
    "_inactiveItems.Push(item);",
    "OnDespawned(item);",
    "if (_inactiveItems.Count > _settings.MaxSize) Resize(_settings.MaxSize);"
  ],
  "cases": [
    {
      "label": "E 를 반납 (쉬는 객체 4개)",
      "state": "쉬는 스택 = [D, C, B, A] · 빌려준 것 = E",
      "path": [
        { "line": 0, "why": "E 가 스택에 있는지부터 봅니다." },
        { "line": 1, "state": "D 와 비교: 다름", "why": "1번째 비교" },
        { "line": 1, "state": "C 와 비교: 다름", "why": "2번째 비교" },
        { "line": 1, "state": "B 와 비교: 다름", "why": "3번째 비교" },
        { "line": 1, "state": "A 와 비교: 다름", "why": "4번째 비교. 끝까지 가야 없다는 것을 압니다.", "verdict": "없음" },
        { "line": 2 },
        { "line": 3, "state": "쉬는 스택 = [E, D, C, B, A]" },
        { "line": 4 },
        { "line": 5, "verdict": "false", "out": "비교 4번. 쉬는 객체가 많을수록 이 줄이 길어진다" }
      ]
    },
    {
      "label": "C 를 또 반납",
      "state": "쉬는 스택 = [D, C, B, A] · C 는 이미 돌아와 있다",
      "path": [
        { "line": 0 },
        { "line": 1, "state": "D 와 비교: 다름", "why": "1번째 비교" },
        { "line": 1, "state": "C 와 비교: 같음", "why": "2번째 비교에서 찾았습니다.", "verdict": "있음", "out": "ZenjectException: Assert hit! Tried to return an item to pool ... twice" }
      ],
      "note": "잡아 주는 것은 좋지만, 정상 반납은 늘 끝까지 훑어야 한다는 뜻이기도 합니다."
    }
  ],
  "note": "정상적인 반납일수록 스택 끝까지 가야 합니다. 비용은 쉬고 있는 객체 수에 비례합니다."
}
</script>
</x-flow>

`Despawn` 한 번의 몫만 떼어 쟀습니다(워밍업 2만 회 뒤 20만 회, 3회 중앙값).

| 쉬는 객체 수 | 에디터 플레이 모드 | Mono 플레이어 | IL2CPP 플레이어 |
| --- | --- | --- | --- |
| 16 | 134 ns | 120 ns | 162 ns |
| 64 | 332 ns | 321 ns | 266 ns |
| 256 | 1,219 ns | 1,068 ns | 650 ns |
| 512 | 2,418 ns | 2,072 ns | 1,166 ns |

256 에서 512 로 두 배가 되자 1.8~2.0배가 됐습니다. `Spawn` 은 개수와 상관없는데, 반납이 풀 크기에 묶입니다.

## 정의 기호 하나가 빌드에서 검사를 뺀다

`Assert.That` 은 `ZEN_STRIP_ASSERTS_IN_BUILDS` 가 정의돼 있을 때만 `[Conditional("UNITY_EDITOR")]` 가 붙습니다.

```csharp
// Internal/Assert.cs
#if ZEN_STRIP_ASSERTS_IN_BUILDS
        [Conditional("UNITY_EDITOR")]
#endif
        public static void That(bool condition, string message, object p1)
```

기호가 없으면 플레이어 빌드에서도 `Despawn` 마다 `Contains` 가 돕니다. 기호를 넣으면 빌드에서 호출문과 인자 계산이 통째로 빠집니다. 에디터에서는 기호와 상관없이 남습니다.

| 쉬는 객체 수 | Mono 기본 | Mono + 기호 | IL2CPP 기본 | IL2CPP + 기호 |
| --- | --- | --- | --- | --- |
| 16 | 120 ns | 35.0 ns | 162 ns | 16.5 ns |
| 512 | 2,072 ns | 34.7 ns | 1,166 ns | 16.4 ns |

기호를 넣은 빌드는 쉬는 객체 수와 상관없이 평평했고, 512개에서 반납 한 번이 Mono 60분의 1, IL2CPP 70분의 1 로 줄었습니다. 대신 빌드에서는 이중 반납을 아무도 막지 않습니다.

## 주의할 점

### 1. 풀을 크게 잡고 기본 설정으로 출시하지 않는다

**안 지키면 생기는 문제.** 이중 반납 검사는 쉬는 객체를 끝까지 훑고 기본 설정에서 플레이어 빌드에도 남습니다. 쉬는 객체 512개에서 반납 한 번이 Mono 플레이어 2.1 µs 였고, 탄막처럼 한 프레임에 수백 개를 돌려받는 곳에서는 반납만으로 프레임이 무거워집니다.

**이렇게 쓴다.** `Window > Zenject Pool Monitor` 로 실제 최대치를 보고 `WithInitialSize` 와 `WithMaxSize` 를 그 근처로 잡습니다. 출시 빌드에는 Scripting Define Symbols 에 `ZEN_STRIP_ASSERTS_IN_BUILDS` 를 넣고, 효과는 에디터가 아니라 플레이어 빌드에서 확인합니다.

### 2. 검사를 뺀 빌드에서는 반납 규칙을 코드로 지킨다

**안 지키면 생기는 문제.** 기호를 넣으면 이중 반납이 빌드에서 조용히 통과합니다. 같은 객체가 스택에 두 번 들어가고, 이후 두 번의 `Spawn` 이 같은 객체를 두 곳에 내줍니다. 총알 하나가 두 자리에서 동시에 움직이는 식의 버그가 빌드에서만 납니다.

**이렇게 쓴다.** 반납하는 길을 하나로 모읍니다. `IPoolable<IMemoryPool>` 을 구현해 `OnSpawned` 에서 풀을 기억하고, `OnDespawned` 에서 `_pool = null` 로 비운 뒤 `Dispose()` 가 비어 있으면 아무것도 하지 않게 하면 두 번째 반납이 풀까지 가지 않습니다. 에디터에서는 검사가 남으니 테스트는 에디터에서 돌립니다.

### 3. MemoryPool 에 IPoolable 을 기대하지 않는다

**안 지키면 생기는 문제.** 기본 `MemoryPool<T>` 는 객체가 `IPoolable` 을 구현해도 `OnSpawned` 와 `OnDespawned` 를 부르지 않습니다. 부르는 것은 `PoolableMemoryPool<T>` 계열뿐입니다. 컴파일도 되고 예외도 없어서, 초기화하지 않은 상태가 재사용된 객체에 남습니다.

**이렇게 쓴다.** `IPoolable` 을 구현했으면 `PoolableMemoryPool<T>`, 게임오브젝트면 `MonoPoolableMemoryPool<T>` 를 쓰거나 팩토리에 `FromPoolableMemoryPool` 을 붙입니다. 기본 풀을 쓸 때는 초기화를 풀 클래스의 `Reinitialize` 오버라이드에 둡니다.

## 정리

Zenject 의 **풀 반납은 이중 반납 검사 때문에 쉬는 객체 수에 비례해 비싸집니다.**

- `Create()` 는 매번 컨테이너를 지나(Mono 플레이어 3,869 ns, 248 B) 자주 만드는 것은 풀로 돌립니다.
- 풀은 스택 하나와 빌려준 수 하나이고, `Despawn` 은 그 스택을 `Contains` 로 끝까지 훑습니다(쉬는 객체 512개에서 Mono 2.1 µs).
- `ZEN_STRIP_ASSERTS_IN_BUILDS` 를 넣으면 빌드에서 반납이 크기와 무관해지지만(Mono 35 ns), 이중 반납은 코드로 막아야 합니다.