๐ English Abstract
This article provides an in-depth troubleshooting guide for handling NativeArray memory leaks and C# Job System synchronization bottlenecks in Unity DOTS (Data-Oriented Technology Stack) ECS environments. It explores allocator lifecycles, memory safety checks, Structural Changes, and EntityCommandBuffer optimization strategies when dealing with large-scale entity calculations. Developers will gain actionable insights on resolving main-thread stalls and optimizing memory performance for enterprise-level Unity projects.
์ ๋ํฐ์ DOTS(Data-Oriented Technology Stack)์ ECS(Entity Component System)๋ ์๋ง ๊ฐ ์ด์์ ๋๊ท๋ชจ ์ํฐํฐ(Entity)๋ฅผ ๊ณ ์ฑ๋ฅ์ผ๋ก ์ฒ๋ฆฌํ๊ธฐ ์ํ ๊ฐ๋ ฅํ ํ๋ ์์ํฌ์ ๋๋ค. ์บ์ ์ง์ญ์ฑ(Cache Locality)์ ๊ทน๋ํํ๊ณ Burst ์ปดํ์ผ๋ฌ(Burst Compiler)์ C# Job System์ ํ์ฉํ์ฌ ๊ฐ๋น์ง ์ปฌ๋ ์ (Garbage Collection) ์๋ ๊ทนํ์ ์ฐ์ฐ ์๋๋ฅผ ์ ๊ณตํฉ๋๋ค.
ํ์ง๋ง ์ค์ ์ค๋ฌด ํ๋ก์ ํธ์์ ๋๊ท๋ชจ ์ฐ์ฐ์ ๊ตฌํํ๋ค ๋ณด๋ฉด ์๊ธฐ์น ๋ชปํ ๋ ๊ฐ์ง ๊ฑฐ๋ํ ์ฅ๋ฒฝ์ ์ง๋ฉดํ๊ฒ ๋ฉ๋๋ค. ๋ฐ๋ก NativeArray ๋ฉ๋ชจ๋ฆฌ ๋์(Memory Leak)์ Job System ๋๊ธฐํ ๋ณ๋ชฉ(Synchronization Bottleneck / Sync Point) ํ์์ ๋๋ค. ๊ด๋ฆฌ๋๋ ๋ฉ๋ชจ๋ฆฌ(Managed Memory)์ ์ต์ํ C# ๊ฐ๋ฐ์๊ฐ ๋น๊ด๋ฆฌ ๋ฉ๋ชจ๋ฆฌ(Unmanaged Memory)๋ฅผ ์ ์ดํ ๋ ๋ฐ์ํ๋ ๋ฌธ์ ์ ๋ค์ ํ์ ํ๊ณ , ์ด๋ฅผ ์์คํ ์ ์ผ๋ก ํด๊ฒฐํ ์ ์๋ ๊น์ด ์๋ ํธ๋ฌ๋ธ์ํ ๊ฐ์ด๋๋ฅผ ๊ณต์ ํฉ๋๋ค.
1. NativeArray ๋ฉ๋ชจ๋ฆฌ ๋ชจ๋ธ ๋ฐ ๋์(Memory Leak) ์์ธ ๋ถ์
DOTS ECS ํ๊ฒฝ์์๋ C#์ GC ๊ด๋ฆฌ๋ฅผ ๋ฐ๋ ๋ฐฐ์ด ๋์ , C++ ํ(Heap) ์์ญ์ ์ง์ ๋ฉ๋ชจ๋ฆฌ๋ฅผ ํ ๋นํ๋ ๋ค์ดํฐ๋ธ ์ปจํ
์ด๋(Native Container)์ธ NativeArray<T>, NativeList<T>, NativeHashMap<K,V> ๋ฑ์ ์ฌ์ฉํฉ๋๋ค. ์ด ์ปจํ
์ด๋๋ค์ ๊ฐ๋น์ง ์ปฌ๋ ํฐ์ ์ถ์ ์ ๋ฐ์ง ์๊ธฐ ๋๋ฌธ์ ๊ฐ๋ฐ์๊ฐ ์ง์ ์๋ช
์ฃผ๊ธฐ(Lifecycle)๋ฅผ ๊ด๋ฆฌํด์ผ ํฉ๋๋ค.
1.1 ๋ฉ๋ชจ๋ฆฌ ํ ๋น์(Allocator)์ ์ดํด์ ์ค์ฉ ์ผ์ด์ค
NativeArray๋ฅผ ํ ๋นํ ๋๋ ์ฐ์ฐ์ ๋ชฉ์ ๊ณผ ์๋ช ์ ๋ง๋ ํ ๋น์(Allocator) ์ ํ์ ์ ํํด์ผ ํฉ๋๋ค. ์ ํ์ด ์๋ชป๋ ๊ฒฝ์ฐ ๋ฉ๋ชจ๋ฆฌ ๋์๋ ํฌ๋์(Crash)๊ฐ ๋ฐ์ํฉ๋๋ค.
- Allocator.Temp: ๊ฐ์ฅ ๋น ๋ฅธ ํ ๋น ์๋๋ฅผ ์๋ํ๋ฉฐ, ๋จ์ผ ํ๋ ์(Single Frame) ๋ด์์ ํ ๋น ๋ฐ ํด์ (Dispose)๊ฐ ์๋ฃ๋์ด์ผ ํฉ๋๋ค. 1ํ๋ ์์ ์ด๊ณผํ์ฌ ์ ์ง๋๊ฑฐ๋ ๋ฉํฐ์ค๋ ๋ Job ๋ด๋ถ๋ก ์ง์ ์ ๋ฌ๋๋ฉด ์๋ฌ๊ฐ ๋ฐ์ํฉ๋๋ค.
- Allocator.TempJob: ๋ฉํฐ์ค๋ ๋ Job์ ์ ๋ฌํ ๋ ์ฌ์ฉ๋๋ฉฐ, ์ต๋ 4ํ๋ ์ ์ด๋ด์ ํด์ ๋์ด์ผ ํฉ๋๋ค. ํด์ ํ์ง ์์ ๊ฒฝ์ฐ ์ ๋ํฐ ์๋ํฐ์์ ๊ฒฝ๊ณ ๋ฉ์ธ์ง(Leak Warning)๋ฅผ ์ถ๋ ฅํฉ๋๋ค.
- Allocator.Persistent: ์๋ช
์ด ๊ธธ๊ฑฐ๋ ์ ํ๋ฆฌ์ผ์ด์
์ ์ฒด์ ๊ฑธ์ณ ์ ์ง๋์ด์ผ ํ๋ ๋ฐ์ดํฐ์ ์ฌ์ฉ๋ฉ๋๋ค. C++์
malloc๊ณผ ๋์ผํ๋ฏ๋ก ๋ช ์์ ์ผ๋กDispose()๋ฅผ ํธ์ถํ์ง ์์ผ๋ฉด ์ฑ์ด ์ข ๋ฃ๋ ๋๊น์ง ๋ฉ๋ชจ๋ฆฌ ๋์๊ฐ ๋ฐ์ํฉ๋๋ค.
1.2 ์ค๋ฌด์์ ์์ฃผ ๋ฐ์ํ๋ ๋ฉ๋ชจ๋ฆฌ ๋์ ํจํด (Edge Cases)
๋๊ท๋ชจ ECS ์์คํ ์์ ๊ฐ์ฅ ๋ง์ด ๋ฐ์ํ๋ ๋ฉ๋ชจ๋ฆฌ ๋์ ํจํด์ "Job์ด ์๋ฃ๋๊ธฐ ์ ์ ์ค๋ ๋๊ฐ ์์ธ(Exception)๋ฅผ ์ผ์ผ์ผ Dispose()์ ๋๋ฌํ์ง ๋ชปํ๋ ์ผ์ด์ค"์ "๋์ ์ผ๋ก ์์ฑ๋ NativeArray๋ฅผ ์์ ํ๋ JobHandle ์ฒด์ธ์ ์ ์ค"์ ๋๋ค.
์๋๋ ์๋ชป ์์ฑ๋ ๋ฉ๋ชจ๋ฆฌ ํ ๋น ์ฝ๋์ ์ ํ์ ์ธ ์์์ ๋๋ค:
// โ ๋ฉ๋ชจ๋ฆฌ ๋์๊ฐ ๋ฐ์ํ๋ ์๋ชป๋ ์ฝ๋ ์์
public partial struct MovementSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
// ๋งค ํ๋ ์ Persistent ํ ๋น
NativeArray<float3> positions = new NativeArray<float3>(100000, Allocator.Persistent);
var job = new ProcessPositionJob { Positions = positions };
JobHandle jobHandle = job.Schedule(state.Dependency);
// state.Dependency๋ฅผ ์
๋ฐ์ดํธํ์ง ์๊ณ Dispose๋ ์๋ตํจ!
// ๋ฉ๋ชจ๋ฆฌ๊ฐ ๋งค ํ๋ ์ 100,000 * 12 Bytes ์ฉ ๋์๋จ
}
}
1.3 ์ฌ๋ฐ๋ฅธ Dispose ์ฒ๋ฆฌ ๊ธฐ๋ฒ: [DeallocateOnJobCompletion]๊ณผ DisposeJob
๋ฉ๋ชจ๋ฆฌ ๋์๋ฅผ ๋ฐฉ์งํ๊ธฐ ์ํด์๋ ์์ ์๋ฃ ํ ์๋์ผ๋ก ๋ฉ๋ชจ๋ฆฌ๋ฅผ ํด์ ํ๋ ์์ฑ(Attribute)์ ์ฌ์ฉํ๊ฑฐ๋, ์์ ํ ์์กด์ฑ ๊ด๋ฆฌ ๊ธฐ๋ฒ์ ๋์ ํด์ผ ํฉ๋๋ค.
// โญ ๋ฉ๋ชจ๋ฆฌ ๋์๋ฅผ ์์ ํ ๋ฐฉ์งํ๋ ์ฌ๋ฐ๋ฅธ ์ฝ๋
[BurstCompile]
public struct ProcessPositionJob : IJobParallelFor
{
// Job ์๋ฃ ์ ์๋์ผ๋ก NativeArray๋ฅผ ํด์ ํจ
[DeallocateOnJobCompletion]
[ReadOnly] public NativeArray<float3> Positions;
public void Execute(int index)
{
// ์ฐ์ฐ ๋ก์ง ์ํ
}
}
public partial struct MovementSystem : ISystem
{
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
NativeArray<float3> positions = new NativeArray<float3>(100000, Allocator.TempJob);
var job = new ProcessPositionJob { Positions = positions };
// JobHandle ์ฒด์ธ ๋ช
์์ ์ฐ๊ฒฐ
state.Dependency = job.Schedule(positions.Length, 64, state.Dependency);
// ๋๋ ๋ช
์์ ์ผ๋ก DisposeJob ์ฌ์ฉ ๊ฐ๋ฅ:
// state.Dependency = positions.Dispose(state.Dependency);
}
}
2. Job System ๋๊ธฐํ ๋ณ๋ชฉ(Sync Point)๊ณผ ์ฑ๋ฅ ์ ํ ์์
DOTS ECS์ ๋ ๋ฒ์งธ ์ฃผ์ ๋๊ด์ **๋๊ธฐํ ๋ณ๋ชฉ(Synchronization Bottleneck)**์ ๋๋ค. ์๋ฌด๋ฆฌ ๋ง์ CPU ์ฝ์ด๋ฅผ ํ์ฉํ๋๋ผ๋, ๋ฉ์ธ ์ค๋ ๋(Main Thread)์ ์์ปค ์ค๋ ๋(Worker Thread)๊ฐ ์๋ก๋ฅผ ๊ธฐ๋ค๋ฆฌ๋ **์คํจ(Stall)** ํ์์ด ๋ฐ์ํ๋ฉด ๋ณ๋ ฌ ์ฒ๋ฆฌ์ ์ด์ ์ด ์์๋ฉ๋๋ค.
2.1 ์กฐ๊ธฐ JobHandle.Complete() ํธ์ถ๋ก ์ธํ ๋ฉ์ธ ์ค๋ ๋ ๋๊ธฐ
๊ฐ์ฅ ํํ ๋ณ๋ชฉ์ ์์ธ์ JobHandle.Complete()๋ฅผ ํ์ ์ด์์ผ๋ก ๋นจ๋ฆฌ ํธ์ถํ๋ ๊ฒ์
๋๋ค. ์ด ๋ฉ์๋๋ ๋ฐฑ๊ทธ๋ผ์ด๋ ์ค๋ ๋์์ ์๋ ์ค์ธ ์ฐ์ฐ์ด ๋๋ ๋๊น์ง ๋ฉ์ธ ์ค๋ ๋๋ฅผ ๊ฐ์ ๋ก ๋ธ๋กํน(Blocking)ํฉ๋๋ค.
ํด๊ฒฐ ์ ๋ต: Complete() ํธ์ถ์ ์ต๋ํ ๋ฆ์ถ๊ฑฐ๋, ๋ค์ ํ๋ ์๊น์ง ์ฐ๋๋๋๋ก ํ๋ ์ ๊ฐ ๋๋ ์ด๋ฅผ ํ์ฉํ๋ ์์กด์ฑ ๊ตฌ์กฐ(Dependency Graph)๋ก ์ค๊ณํด์ผ ํฉ๋๋ค.
2.2 ๊ตฌ์กฐ์ ๋ณ๊ฒฝ(Structural Change)์ด ์ผ๊ธฐํ๋ ๊ธ๋ก๋ฒ ๋๊ธฐํ ์ง์
ECS ํ๋ ์์ํฌ์์ ๊ตฌ์กฐ์ ๋ณ๊ฒฝ(Structural Change)์ด๋ ์ฒญํฌ(Chunk)์ ๋ฉ๋ชจ๋ฆฌ ๋ ์ด์์์ ๋ณํํ๋ ๋ชจ๋ ์์ ์ ์๋ฏธํฉ๋๋ค:
- ์ํฐํฐ ์์ฑ(CreateEntity) ๋๋ ํ๊ดด(DestroyEntity)
- ์ปดํฌ๋ํธ ์ถ๊ฐ(AddComponent) ๋๋ ์ ๊ฑฐ(RemoveComponent)
- Shared Component ๊ฐ์ ๋ณ๊ฒฝ
๊ตฌ์กฐ์ ๋ณ๊ฒฝ์ด ์ผ์ด๋๋ ์๊ฐ, ์ ๋ํฐ๋ ๋ด๋ถ ์ฒญํฌ ๋ฉ๋ชจ๋ฆฌ๋ฅผ ์ฌ๋ฐฐ์น(Reallocation)ํด์ผ ํ๋ฏ๋ก ์คํ ์ค์ธ ๋ชจ๋ Job์ด ๋๋ ๋๊น์ง ๋ชจ๋ ์ค๋ ๋๋ฅผ ์ ์ง์ํต๋๋ค. ์ด๋ฅผ **๋๊ธฐํ ์ง์ (Sync Point)**์ด๋ผ ๋ถ๋ฅด๋ฉฐ, ๋ ๋๋ง ํ๋ ์์ด ์ฌ๊ฐํ๊ฒ ํ๋ ์์ธ์ด ๋ฉ๋๋ค.
2.3 EntityCommandBuffer(ECB)๋ฅผ ํ์ฉํ ๊ตฌ์กฐ์ ๋ณ๊ฒฝ ์ง์ฐ ์ฒ๋ฆฌ
๋๊ธฐํ ์ง์ ํญํ์ ํผํ๋ ํ์ค ํด๋ฒ์ EntityCommandBuffer(ECB)๋ฅผ ์ฌ์ฉํ๋ ๊ฒ์
๋๋ค. Job ๋ด๋ถ์์๋ ๊ตฌ์กฐ์ ๋ณ๊ฒฝ ๋ช
๋ น์ ๋ฒํผ์ ๊ธฐ๋ก(Record)๋ง ํด๋๊ณ , ํ๋ ์์ ํน์ ์์ (์: EndSimulationEntityCommandBufferSystem)์ ์ผ๊ด(Batch) ์ฒ๋ฆฌํ๋ ๊ตฌ์กฐ์
๋๋ค.
// โญ ParallelWriter์ ECB๋ฅผ ์ด์ฉํ ๋ณ๋ชฉ ์๋ ๊ตฌ์กฐ์ ๋ณ๊ฒฝ ์์
[BurstCompile]
public struct DestroyExpiredEntitiesJob : IJobEntity
{
public EntityCommandBuffer.ParallelWriter Sticker;
// EntityAndIndexInQuery๋ฅผ ์ด์ฉํ์ฌ ์์ ํ ๋ฉํฐ์ค๋ ๋ ๋ณ๋ ฌ ๊ธฐ๋ก
public void Execute(Entity entity, [EntityIndexInQuery] int sortIndex, in Lifetime lifetime)
{
if (lifetime.Value <= 0)
{
// ์ง์ ํ๊ดดํ์ง ์๊ณ ๋ฒํผ์ ์์ฝ
Sticker.DestroyEntity(sortIndex, entity);
}
}
}
public partial struct LifetimeSystem : ISystem
{
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var ecbSingleton = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>();
var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);
var job = new DestroyExpiredEntitiesJob
{
Sticker = ecb.AsParallelWriter()
};
state.Dependency = job.ScheduleParallel(state.Dependency);
}
}
3. ์ค๋ฌด ํธ๋ฌ๋ธ์ํ ์ฒดํฌ๋ฆฌ์คํธ ๋ฐ ํ๋กํ์ผ๋ง ๊ธฐ๋ฒ
๋ฉ๋ชจ๋ฆฌ ๋์์ ๋ณ๋ชฉ ํ์์ ์ ํํ๊ฒ ์ง๋จํ๊ธฐ ์ํด์๋ ์ ๋ํฐ์์ ์ ๊ณตํ๋ ์ ๊ตํ ํ๋กํ์ผ๋ง ๋๊ตฌ๋ฅผ ์ฐ๋ํ์ฌ ๊ด์ฐฐํด์ผ ํฉ๋๋ค.
- Native Memory Leak Detector ํ์ฑํ: ํ๋ก์ ํธ ๋น๋ ์ต์ ๋๋ ์๋ํฐ ์ค์ ์์ Jobs -> Burst -> Safety Checks๋ฅผ ํ์ฑํํ์ฌ leak ๊ฒฝ๊ณ ๋ฉ์์ง์ ์คํ ํธ๋ ์ด์ค(Stack Trace)๋ฅผ ํ๋ณดํฉ๋๋ค.
- Unity Profiler์ Timeline View ํ์ฉ: Profiler ํ๋จ์ Timeline ๋ทฐ๋ฅผ ํตํด ๋ฉ์ธ ์ค๋ ๋์
WaitForJob๋๋DOTS Sync Point๋ก ์ธํ ๊ฑฐ๋ํ ๊ฐญ(Gap)์ด ์กด์ฌํ๋์ง ๋ชจ๋ํฐ๋งํฉ๋๋ค. - Memory Profiler ํจํค์ง ์ฌ์ฉ: ์ค๋
์ท(Snapshot)์ ๋ ๋ฒ ์ดฌ์ํ์ฌ ๋น๊ต(Diff)ํจ์ผ๋ก์จ ์ด๋ค
NativeArray๋NativeUnsafePtr๊ฐ ์ ๋ฆฌ๋์ง ์๊ณ ๋์ ๋๋์ง ํ์ธํฉ๋๋ค.
4. ๊ฒฐ๋ก (Conclusion)
Unity DOTS ECS ํ๊ฒฝ์์ ์๋ง ๊ฐ ์ด์์ ์ํฐํฐ๋ฅผ ๋ถ๋๋ฝ๊ฒ ์ฐ์ฐํ๊ธฐ ์ํด์๋ C# ์ฐ์ฐ ํจ์จํ๋ฟ๋ง ์๋๋ผ ํ๋์จ์ด ๋ ์ด์ด์ ๊ทผ์ ํ ๋น๊ด๋ฆฌ ๋ฉ๋ชจ๋ฆฌ ๋ฐ ์ค๋ ๋ ๊ด๋ฆฌ๊ฐ ํ์์ ์ ๋๋ค.
NativeArray์ ์๋ช
์ Allocator ๋ฐ [DeallocateOnJobCompletion]์ ์ด์ฉํด ๋ช
ํํ ์ ์ดํ๊ณ , ๊ตฌ์กฐ์ ๋ณ๊ฒฝ์ EntityCommandBufferSystem์ผ๋ก ์ง์ฐ์์ผ ๋๊ธฐํ ๋ณ๋ชฉ(Sync Point)์ ์ง์๋๊ฐ๋ ๊ฒ์ด ๋๊ท๋ชจ ์ํคํ
์ฒ ๊ตฌ์ถ์ ํต์ฌ์
๋๋ค. ์์คํ
์ค๊ณ ๋จ๊ณ๋ถํฐ ์ ์ ์ ์ธ ํ๋กํ์ผ๋ง๊ณผ ์๋ฒฝํ ์์กด์ฑ ๊ด๋ฆฌ ํจํด์ ๋์
ํ์ฌ ์ต๊ณ ์์ค์ ํ๋ ์ ์ ์ง๋ ฅ์ ํ๋ณดํ์๊ธธ ๋ฐ๋๋๋ค.