[Unity] DOTS 개념 정리: Struct 구조 변경, ECB 사용법 등
Unity DOTS 노트 시리즈
개발하다 보니 이런 에러가 떴습니다.
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을 통해 병렬 처리(멀티스레드) 됩니다. 이 때문에 다음과 같은 문제가 발생할 수 있습니다:
❗ 예시 상황
전사 유닛이 죽는 순간, 아군 힐러 유닛이 전사에게 버프를 걸었다고 가정해봅시다.
-
Thread 1에서 해당 Entity의 HP가 0이 되어 삭제됩니다.
-
동시에 Thread 2에서는 아직 살아 있다고 판단해 그 Entity에 버프 컴포넌트를 추가하려고 시도합니다.
-
하지만 Entity는 이미 삭제되었기 때문에, 존재하지 않는 Entity에 접근하게 되며 다음과 같은 에러가 생길 수 있습니다.
-
NullReferenceException
-
InvalidOperationException: The entity does not exist
-
또는 런타임 크래시
이것이 바로 Race Condition(경쟁 조건) 입니다.
🧠 Race Condition이란?
Race Condition이란, 둘 이상의 쓰레드가 동시에 공유 자원에 접근할 때, 실행 순서에 따라 결과가 달라지거나 오류가 발생할 수 있는 상황을 말합니다.
- 공유 자원: 여기서는 Entity 데이터
- 위험성: 실행 타이밍에 따라 버그가 생기기도 하고, 안 생기기도 함 → 디버깅이 매우 어려움
✔️ 해결 방법: 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);
여기서 중요한 점:
- ecb는 변경 요청을 Queue에 쌓고,
- **Playback()**을 통해 Main Thread에서 한번에 처리합니다.
하지만 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. 블록 리스트 등) |