A4Room
← Devlog 목록

[Unity] DOTS 개념 정리: Struct 구조 변경, ECB 사용법 등

Unity DOTS 노트 시리즈

티스토리 15 이미지 1

개발하다 보니 이런 에러가 떴습니다.

InvalidOperationException: System.InvalidOperationException: Structural changes are not allowed
while iterating over entities. Please use EntityCommandBuffer instead.

이런 에러는 왜 뜨는 것일까요..?

정답은 Struct 구조를 멀티스레드 환경에서 변경하려고 했기 때문입니다.

Unity DOTS를 사용하다 보면 자주 마주치는 개념들 —** Struct** 구조 변경, EntityCommandBuffer(ECB), WithEntityAccess, 그리고 BurstCompile 관련 디버깅 팁까지 헷갈리는 포인트들을 정리해봤습니다.

🔧 1. 구조 변경(Structural Changes)이란?

구조 변경은 DOTS에서 Entity의 구성 자체가 바뀌는 작업을 말합니다. 예를 들어:

//생성
Entity newEntity = ecb.CreateEntity();

//추가
ecb.AddComponent(newEntity, new MyComponent { Value = 1f });

//삭제
ecb.DestroyEntity(entity);

모두 구조 변경입니다. 이 작업들은 멀티스레드 환경에서 위험할 수 있기 때문에 반드시 안전한 방식으로 처리해야 합니다.

🧨 2. Entity 구조 변경이 왜 위험할까?

DOTS에서 Entity의 구조를 변경하는 작업(예: 삭제, 컴포넌트 추가 등)은 멀티스레드 환경에서 매우 조심해야합니다.

DOTS의 foreach는 내부적으로 Job System을 통해 병렬 처리(멀티스레드) 됩니다. 이 때문에 다음과 같은 문제가 발생할 수 있습니다:

❗ 예시 상황

티스토리 15 이미지 2

전사 유닛이 죽는 순간, 아군 힐러 유닛이 전사에게 버프를 걸었다고 가정해봅시다.

이것이 바로 Race Condition(경쟁 조건) 입니다.

🧠 Race Condition이란?

Race Condition이란, 둘 이상의 쓰레드가 동시에 공유 자원에 접근할 때, 실행 순서에 따라 결과가 달라지거나 오류가 발생할 수 있는 상황을 말합니다.

✔️ 해결 방법: EntityCommandBuffer 사용

Race Condition 문제를 방지하기 위해 DOTS에서는 구조 변경을 바로 하지 않고, **ECB(EntityCommandBuffer)에 '예약'**합니다. 그리고 메인 스레드에서 안전하게 한번에 적용하도록 합니다.

EntityCommandBuffer ecb = SystemAPI
    .GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>()
    .CreateCommandBuffer(state.WorldUnmanaged);

//var ecb = new EntityCommandBuffer(Allocator.Temp);

foreach (var (hp, entity) in SystemAPI.Query<RefRO<HP>>().WithEntityAccess())
{
    if (hp.ValueRO.Value <= 0)
    {
        ecb.DestroyEntity(entity); // 안전한 구조 변경
    }
}

//ecb.Playback(state.EntityManager);

여기서 중요한 점:

하지만 EndSimulationEntityCommandBufferSystem을 사용했다면, 매 프레임 자동으로 Playback되기 때문에 따로 호출할 필요 없습니다.

코드에서 만약 EndSimulationEntityCommandBufferSystem를 사용하지 않는다면(주석 처리한 부분),

Playback을 호출하여 매 프레임이 끝날 때마다 EntityCommandBuffer에 저장된 모든 명령을 실행해야 합니다.

🧬 4. WithEntityAccess

쿼리에 .WithEntityAccess()를 붙이면 foreach에서 마지막 인자로 entity를 받을 수 있습니다:

foreach (var (hp, entity) in SystemAPI.Query<RefRO<HP>>().WithEntityAccess())
{
    ...
}

이 entity를 사용해 삭제, 버프, 컴포넌트 추가 등을 할 수 있습니다.

🔍 5. Bake 단계에서는 왜 구조 변경이 가능할까?

Bake 시점에서는 AddComponent() 등을 마음껏 해도 됩니다.

public override void Bake(MyAuthoring authoring)
{
    var entity = GetEntity(TransformUsageFlags.Dynamic);
    AddComponent(entity, new MyComponent { ... }); // 문제 없음!
}

왜냐하면:

⚠️ 6. Burst 오류 디버깅 팁

Burst -> Enable Compilation 체크를 끄면, Burst 컴파일러가 꺼지면서 에러 메시지가 직관적으로 보입니다.

왜 그런가요? Burst는 C# 코드를 LLVM IR → 네이티브 코드로 바꾸는 과정에서 난독화가 발생하기 때문입니다. 컴파일러 최적화가 꺼지면 오류 위치도 더 정확하게 보입니다.

(C#에서 한 줄로 작성된 코드는 컴파일러 내부에서 여러 중간 단계(IL, IR, native) 로 분해되기 때문에, Enable Compilation을 끄더라도 오류의 줄이 정확하지 않을 수 있습니다.)

📚 부록: NativeArray, NativeList, DynamicBuffer 비교 정리

DOTS에서는 자료구조도 퍼포먼스동시성 제어에 맞게 설계되어 있습니다. 다음은 대표적인 3가지 Native 구조 비교입니다.

자료구조 크기 변경 사용 위치 특징
NativeArray<T> ❌ 고정 함수 내부 / Job 고정된 크기의 빠른 배열. 가장 효율적
NativeList<T> ✅ 동적 함수 내부 / Job 가변 크기. 내부적으로 자동 재할당
DynamicBuffer<T> ✅ 동적 Entity에 붙는 컴포넌트 엔티티별 여러 개의 값 저장용 (ex. 블록 리스트 등)

NativeArray와 NativeList는 OnUpdate 등에서 한두 프레임 단위로 배열 관리를 할 때 주로 사용합니다.

일반 C# 배열과 비슷해 보여도, Unity의 Allocator 시스템을 통해 Native 메모리에서 직접 관리됩니다. (GC 영향을 받지 않겠죠!)

DynamicBuffer는 특정 Entity에 종속된 컴포넌트이며, 동적 배열처럼 작동하는 멤버 변수의 기능을 수행합니다.

예를 들어, MonoBehaviour Script의 List objectList; 와 비슷한 맥락으로 사용이 가능합니다.