[Unity] DOTS 실전 개념 정리 - Tag, EnableableComponent, CompanionLink 이해하기
Unity DOTS 노트 시리즈
Unity DOTS를 공부하다 보면 구조 자체는 단순하게 느껴지지만, 막상 직접 사용해 보면 쉽게 이해되지 않는 개념들이 자주 등장합니다. 특히 Tag Component, EnableableComponent, CompanionLink의 동작 방식 등은 처음 접하는 입장에서 혼란을 주기 쉽습니다. 게다가 C#에서 갑자기 **EntityQuery **쿼리문 같은 문법이 등장한다는 점도 당황스럽습니다. **Enumerable.Where()**처럼 LINQ를 통해 간접적으로 쿼리 비슷한 경험을 해본 적은 있어도, ECS 구조에서 직접 쿼리를 날리는 방식은 낯설게 느껴질 수밖에 없습니다.
이번 글에서는 이러한 DOTS의 핵심 개념들을 실전 위주로 정리해 보겠습니다.
1. 태그(Tag Component)는 GameObject의 Tag와 어떻게 다를까요?
Unity를 처음 배울 때 자주 사용하는 API 중 하나가 GameObject.FindWithTag("Player") 같은 방식입니다. 여기서 말하는 "태그(tag)"는 GameObject의 Inspector에서 설정할 수 있는 문자열 기반의 속성입니다. 즉, Unity 엔진이 내부적으로 오브젝트를 구분하거나 찾을 수 있도록 도와주는 문자열 메타데이터입니다.
하지만 Unity DOTS에서 말하는 Tag Component는 완전히 다른 개념입니다.
public struct Selected : IComponentData { }
위와 같은 구조체는 데이터 없이 존재만 하는 컴포넌트입니다. DOTS에서는 컴포넌트를 붙이는 것이 곧 데이터를 부여하는 것인데, 이처럼 아무 멤버가 없는 구조체를 붙이면 그 Entity가 특정 상태에 있다는 것을 의미하게 됩니다. 이러한 컴포넌트를 흔히 "태그 컴포넌트(Tag Component)"라고 부릅니다.
즉, GameObject의 태그는 문자열입니다. 반면, DOTS의 태그 컴포넌트는 빈 구조체 데이터입니다.
실제로 사용법도 다릅니다.
- MonoBehaviour 방식:
GameObject.FindWithTag("Enemy");
- DOTS 방식:
foreach (RefRO<Enemy> in SystemAPI.Query<RefRO<Enemy>>()) { ... }
public struct Enemy : IComponentData { }
2. 태그(Tag Component)에 변수를 추가한다면?
public struct Selected : IComponentData
{
public bool isSelected;
}
이렇게 구성하면 이제 태그가 아니라, 데이터를 가진 일반 컴포넌트가 됩니다.
WithAll
Tag Component로서 작용할 때는 Runtime Inspector 상에서 체크박스가 나타납니다.
하지만 변수를 추가하게 되면,
이런 식으로 체크박스가 사라지게 됩니다.
3. 태그(Tag Component)에 변수를 추가했지만, 여전히 태그처럼 쓰고 싶다면?
방법 1. IEnableableComponent 사용
public struct Selected : IComponentData, IEnableableComponent
{
public bool isSelected;
}
기존 IComponentData 상속과 함께 IEnableableComponent 인터페이스까지 추가하면 됩니다.
방법 2. 태그와 데이터를 분리
public struct Selected : IComponentData
{
public float Value;
}
public struct SelectedActive : IComponentData { }
이 방식은 더 직관적이지만, struct를 2개 생성해야 한다는 단점이 있습니다.
4. EntityQuery는 정적 리스트가 아니다?
처음 DOTS를 접할 때 흔히 오해하는 것 중 하나는 EntityQuery가 "고정된 리스트"처럼 작동한다고 생각하는 것입니다. 하지만 EntityQuery는 일종의 조건 필터이며, 호출할 때마다 ECS 월드의 현재 상태를 기준으로 동적으로 평가됩니다.
즉, 쿼리를 한 번 만들었다고 해서 그 결과가 고정되어 있는 게 아닙니다. 매 프레임마다 현재 월드에서 조건에 맞는 엔티티들을 즉시 추출해주는 방식이기 때문에, 시스템 흐름을 짤 때 항상 이 점을 염두에 둬야 합니다.
// 1. 쿼리를 통해 Selected 컴포넌트를 가진 모든 엔티티를 배열로 복사
var selectedQuery = SystemAPI.QueryBuilder().WithAll<Selected>().Build();
var selectedEntities = selectedQuery.ToEntityArray(Allocator.Temp); // 예: 10개 존재
// 2. 그 사이에 Selected 컴포넌트를 가진 새로운 엔티티 1개가 추가됨
var ecb = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>().CreateCommandBuffer(state.WorldUnmanaged);
var newEntity = ecb.CreateEntity();
ecb.AddComponent<Selected>(newEntity);
// 3. 복사한 배열(10개)에 대해 11개의 작업을 시도할 경우 오류 발생 가능
for (int i = 0; i < selectedQuery.CalculateEntityCount(); i++) // 이제 쿼리는 11개로 인식됨
{
// ❗ selectedEntities.Length는 여전히 10
// i == 10에서 IndexOutOfRangeException 발생!
Entity e = selectedEntities[i]; // 여기서 에러
}
5. WithAll / WithNone / WithDisabled / WithPresent / WithAbsent 차이점 정리
DOTS에서 SystemAPI.Query
| 메서드 | 설명 |
| WithAll<T>() | T 컴포넌트를 가진 엔티티만 포함 |
| WithNone<T>() | T 컴포넌트를 가진 엔티티는 제외 |
| WithDisabled<T>() | Enableable Component 중 비활성화된 것만 포함 |
| WithPresent<T>() | 해당 컴포넌트가 있다면 조건 분기 처리가 가능함 (없어도 쿼리는 가능) |
| WithAbsent<T>() | 해당 컴포넌트가 아예 없는 엔티티만 추출 |