Zenject 의 풀 반납은 쉬는 객체 수만큼 비싸다
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 로 만들 때와 같은 길이라, 생성자 인자 조회, 객체 생성, 필드 주입, 주입용 람다가 매번 붙습니다.
| 한 회 | 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 를 남겼습니다. 프레임마다 여럿 만드는 것은 풀로 돌리는 이유입니다.
풀은 쉬는 객체 스택과 빌려준 수 하나다
// 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", ...) 입니다. 스택에는 찾아보기 표가 없어서, 정상 반납은 “없음” 을 확인하려고 늘 끝까지 갑니다.
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")] 가 붙습니다.
// 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), 이중 반납은 코드로 막아야 합니다.