ECS 관련 기초 글 :

ECS (Entity Component System)

DOTS 1.0 - 기본 (Component, System, Aspect, Job)

Entities - 컴포넌트 구조


왜 Hybrid가 필요한가

ECS는 성능이 좋다. 데이터가 청크에 연속으로 저장되어 캐시 친화적이고, Burst 컴파일 + Job 시스템으로 병렬 처리도 된다. 그런데 문제가 있다.

유니티의 모든 기능이 ECS로 제공되지는 않는다.

Animator, ParticleSystem, Light, VFX Graph, AudioSource 같은 것들은 모두 MonoBehaviour 기반이다. 이것들은 managed 객체이며 ECS 청크에 struct처럼 넣을 수가 없다.

예를 들어, ECS로 적 유닛 1000마리를 시뮬레이션한다고 치자. 이동이나 AI 의사결정은 Burst+Job으로 돌릴 수 있다. 그런데 각 적마다 Animator로 걷기/공격 애니메이션을 재생하려면? Animator는 MonoBehaviour이므로 ECS 세계 밖의 GameObject가 필요하다.

그러면 선택지는 두 가지다.

  1. 이런 기능을 안 쓴다 → 비현실적
  2. ECS와 GameObject 세계를 같이 쓴다 → Hybrid

많은 DOTS 프로젝트가 Hybrid로 동작한다. 시뮬레이션은 ECS로, 비주얼은 GameObject로. 이 조합을 많이 쓴다.


Hybrid 접근법 3가지

Entity에 managed 데이터를 붙이는 방법은 세 가지가 있다. 하나씩 보자.


1. struct IComponentData (순수 ECS)

public struct Health : IComponentData
{
    public float Value;
}

가장 기본적인 형태다. ECS 청크에 직접 저장되고, Burst 컴파일 가능, Job 스케줄 가능, 성능 최고.

그러나 blittable한 값 타입만 저장할 수 있다. 문자열, 배열, UnityEngine.Object 같은 managed 참조는 못 넣는다.

2. class IComponentData (관리형 컴포넌트)

public class PresentationGo : IComponentData
{
    public GameObject Prefab;
}

class로 선언하면 managed 참조를 저장할 수 있다. 청크가 아닌 별도의 managed 배열에 저장되고, 청크에는 인덱스만 들어간다.

Burst 불가, Job 불가, 메인 스레드 전용.

성능 이점은 전혀 없지만, Entity에 managed 데이터를 연결해야 할 때 쓸 수 있다.

3. Companion GameObject

실제 UnityEngine.Component(MonoBehaviour)를 Entity에 붙이는 방식이다.

Light, Animator, ParticleSystem 같은 컴포넌트는 어차피 GameObject 위에서만 돌아간다. 그러면 숨겨진 GameObject를 하나 만들어서 Entity와 연결하면 되지 않겠나? 이 숨겨진 GameObject를 Companion GameObject라고 부른다.


비교

struct IComponentData class IComponentData Companion GO
저장 위치 ECS 청크 managed 배열 힙 (숨겨진 GO)
Burst/Job O X X
managed 참조 X O O (그 자체가 managed)
Transform 동기화 수동 수동 자동
생명주기 관리 ECS가 처리 수동 (IDisposable) 자동 (CompanionLink)

핵심 차이 : Companion GO는 Transform 동기화와 생명주기 관리가 자동이라는 것이다.

Entity가 이동하면 Companion GO도 따라가고, Entity가 파괴되면 Companion GO도 같이 파괴된다.

class IComponentData로는 이걸 직접 다 구현해야 한다.


Companion GO의 동작 원리

그러면 Companion GO가 내부적으로 어떻게 돌아가는지 보자. 세 가지 요소로 구성된다.

1. 숨겨진 GameObject

HideFlags가 설정된 실제 GameObject다. Light나 ParticleSystem 같은 MonoBehaviour 컴포넌트가 여기에 붙는다. Hierarchy 창에서는 안 보인다.

2. CompanionLink

Entity와 Companion GO를 연결하는 managed 컴포넌트다.

ICloneableIDisposable을 구현하고 있어서, Entity 프리팹을 Instantiate하면 Companion GO도 복제되고, Entity를 Destroy하면 Companion GO도 파괴된다.

3. CompanionGameObjectUpdateTransformSystem

매 프레임 Entity의 LocalToWorld를 Companion GO의 Transform에 복사해주는 빌트인 시스템이다.

동기화 방향은 ECS → GO 단방향이다. Companion GO의 Transform을 직접 수정하면 안 된다. 다음 프레임에 어차피 덮어쓰여진다.


사용법 1 : SubScene 베이킹 + AddComponentObject

가장 간단한 방법부터 보자. SubScene 안에 Light나 ParticleSystem이 달린 GameObject를 넣으면, Entities Graphics가 자동으로 Companion GO를 만들어준다. Baker 코드가 필요 없다.

Animator처럼 Baker에서 직접 붙여야 하는 경우도 있다. 다음의 코드를 보자.

public sealed class AnimatorCompanionAuthoring : MonoBehaviour
{
    public Animator Animator;

    private sealed class Baker : Baker<AnimatorCompanionAuthoring>
    {
        public override void Bake(AnimatorCompanionAuthoring a)
        {
            var animator = a.Animator ?? a.GetComponentInChildren<Animator>(true);
            if (animator == null) return;

            var e = GetEntity(animator.gameObject,
                TransformUsageFlags.Dynamic);

            AddComponentObject(e, animator);  // Animator를 Entity에 부착
            AddComponent(e, new AnimParameter { MoveSpeed = 0f });
        }
    }
}

AddComponentObject를 사용하면 UnityEngine.Component를 Entity에 붙일 수 있다.

이후 시스템에서는 ManagedAPI.UnityEngineComponent<Animator>로 접근하면 된다.

그러면 실제로 ECS 데이터를 Animator에 넘겨주는 시스템을 보자.

예를 들어, Entity의 PhysicsVelocity를 가져와서 Animator의 MoveSpeed 파라미터에 넣어주는 식이다.

[UpdateInGroup(typeof(PresentationSystemGroup))]
partial struct AnimatorSyncSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        foreach (var (animator, velocity) in SystemAPI
            .Query<SystemAPI.ManagedAPI.UnityEngineComponent<Animator>,
                   RefRO<PhysicsVelocity>>())
        {
            float speed = math.length(velocity.ValueRO.Linear);
            animator.Value.SetFloat("MoveSpeed", speed);
        }
    }
}

ECS 쪽에서 Burst+Job으로 계산한 물리 결과가 PhysicsVelocity에 들어있고, 이 시스템이 그 값을 Animator에 넘겨주는 것이다.

시뮬레이션(ECS) → 비주얼(Animator) 단방향 흐름이 여기서도 그대로 적용된다.


사용법 2 : 런타임에 Companion GO 직접 생성

여기부터가 핵심이다. SubScene 베이킹이 아닌, 런타임에 동적으로 Companion GO를 생성하는 패턴이다. 적이나 총알처럼 게임 중에 생겨나는 Entity에 비주얼을 붙여야 할 때 유용하다.

이 패턴은 두 가지 컴포넌트로 구성된다.

// 1. "프리팹 설정" — 어떤 GO를 생성할지 (managed IComponentData)
public class PresentationGo : IComponentData
{
    public GameObject Prefab;
}

// 2. "인스턴스 링크" — 생성된 GO 참조 (ICleanupComponentData)
public class PresentationGoLink : ICleanupComponentData
{
    public GameObject Instance;
}

ICleanupComponentData를 쓸까?

Entity가 파괴되어도 Cleanup 컴포넌트는 살아남기 때문이다. 덕분에 Cleanup 시스템이 GO를 정리할 시간이 생긴다.

(Entity가 파괴되는 순간 GO 참조까지 날아가버리면 정리할 방법이 없겠지?)

Authoring은 다음과 같이 작성한다.

public class GoCompanionAuthoring : MonoBehaviour
{
    public GameObject Prefab;

    class GoCompanionAuthoringBaker : Baker<GoCompanionAuthoring>
    {
        public override void Bake(GoCompanionAuthoring authoring)
        {
            var entity = GetEntity(
                TransformUsageFlags.Dynamic | TransformUsageFlags.Renderable);
            AddComponentObject(entity,
                new PresentationGo { Prefab = authoring.Prefab });
        }
    }
}

그리고 Init 시스템에서 실제로 GO를 생성한다. 여기가 좀 길지만 중요한 부분이니 자세히 보자.

[UpdateInGroup(typeof(InitializationSystemGroup))]
partial struct PresentationGoInitSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        foreach (var entity in SystemAPI.QueryBuilder()
            .WithAll<LocalToWorld, PresentationGo>()
            .WithNone<PresentationGoLink>().Build()
            .ToEntityArray(Allocator.Temp))
        {
            var config = SystemAPI.ManagedAPI
                .GetComponent<PresentationGo>(entity);

            // GO 생성
            var go = Object.Instantiate(config.Prefab);

            // managed 컴포넌트들을 Entity에 부착
            state.EntityManager.AddComponentObject(entity, go.transform);

            var animator = go.GetComponent<Animator>();
            if (animator != null)
                state.EntityManager.AddComponentObject(entity, animator);

            // 생명주기 동기화 — GO가 먼저 파괴되면 Entity도 파괴
            go.AddComponent<EntityGameObjectLifeSyncer>()
                .AssignEntity(entity, state.World);

            // 링크 저장
            state.EntityManager.AddComponentData(entity,
                new PresentationGoLink { Instance = go });

            // 초기 위치 동기화
            var ltw = SystemAPI.GetComponent<LocalToWorld>(entity);
            go.transform.position = ltw.Position;
            go.transform.rotation = ltw.Rotation;
        }
    }
}

위 코드에서 핵심을 정리하면 이렇다.

PresentationGo는 있는데 PresentationGoLink는 아직 없는 Entity를 찾아서, GO를 생성하고, managed 컴포넌트들을 AddComponentObject로 부착한 뒤, PresentationGoLink에 인스턴스를 저장한다.

이미 PresentationGoLink가 있으면 WithNone 조건에 걸려서 스킵되므로 한 번만 실행된다.

다음은 Transform 동기화 시스템이다. 매 프레임 ECS 위치를 GO에 복사한다.

[UpdateInGroup(typeof(PresentationSystemGroup))]
partial struct PresentationGoTransformSyncSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        foreach (var (localToWorld, transform) in SystemAPI
            .Query<RefRO<LocalToWorld>,
                SystemAPI.ManagedAPI.UnityEngineComponent<Transform>>()
            .WithAll<PresentationGo>())
        {
            transform.Value.position = localToWorld.ValueRO.Position;
            transform.Value.rotation = localToWorld.ValueRO.Rotation;
        }
    }
}

마지막으로 Cleanup 시스템이다. Entity가 파괴되면 GO도 같이 정리해야 한다.

partial struct PresentationGoCleanupSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        var ecb = SystemAPI.GetSingleton
            <BeginInitializationEntityCommandBufferSystem.Singleton>()
            .CreateCommandBuffer(state.WorldUnmanaged);

        // PresentationGo는 없지만 PresentationGoLink는 남아있는 Entity
        // = Entity가 파괴된 직후
        foreach (var (link, entity) in SystemAPI
            .Query<PresentationGoLink>()
            .WithNone<PresentationGo>().WithEntityAccess())
        {
            if (link.Instance != null)
                Object.Destroy(link.Instance);
            ecb.RemoveComponent<PresentationGoLink>(entity);
        }
    }
}

위 코드의 핵심은 WithNone<PresentationGo>다.

Entity가 파괴되면 일반 컴포넌트인 PresentationGo는 같이 제거되지만, ICleanupComponentDataPresentationGoLink는 남아있다.

이 차이를 이용해서 "파괴된 직후"의 Entity를 잡아내는 것이다.

이 패턴의 구조를 정리하면 이렇다.

시스템 역할 시점
PresentationGoInitSystem GO 생성 + Entity에 연결 Entity 생성 직후 (1회)
PresentationGoTransformSyncSystem ECS → GO 위치 동기화 매 프레임
PresentationGoCleanupSystem Entity 파괴 시 GO 정리 Entity 파괴 직후

빌트인 CompanionLink가 자동으로 해주는 것을 수동으로 구현한 셈이다.

귀찮아 보이지만, 이 방식은 죽음 애니메이션 재생 후 지연 파괴 같은 커스텀 로직을 넣을 수 있다는 장점이 있다. 실제 프로젝트에서는 이쪽이 훨씬 유연하다.


주의사항

1. Burst 컴파일/Job 스케줄 불가

Companion GO 관련 코드는 managed 객체를 다루므로 Burst 컴파일이 안 된다. 메인 스레드에서만 실행해야 한다.

2. Sync Point 주의

AddComponentObject는 구조적 변경(structural change)을 일으킨다. 실행 중인 모든 Job이 완료될 때까지 기다리는 sync point가 발생한다.

핫 패스에서 매 프레임 호출하면 큰일난다. 스폰 시점에 한 번만 호출하는 것이 좋다.

3. Transform 동기화 방향

ECS → GO 단방향이다. Companion GO의 Transform을 직접 수정해봤자 다음 프레임에 덮어쓰여진다. 위치를 바꾸고 싶으면 Entity의 LocalTransform을 수정해야 한다.

4. 성능 이점은 없다

이걸 착각하면 안 된다. Companion GO 자체는 일반 GameObject와 동일한 성능이다.

성능 이점은 Entity 쪽 시뮬레이션에서 나오는 것이다. 수천 개의 Entity를 Burst+Job으로 시뮬레이션하고, 비주얼만 Companion GO로 표현하는 게 핵심이다.

5. UI는 지원 안 됨

Canvas, TextMeshPro 등 UI 컴포넌트는 SubScene에서 Companion 컴포넌트로 사용할 수 없다.


정리 : 언제 무엇을 쓸까

상황 방법
단순 데이터 (float, int, Entity 등) struct IComponentData
managed 참조가 필요하지만 GO는 필요 없음 class IComponentData
UnityEngine 컴포넌트가 필요 (Light, Animator, VFX 등) Companion GO
SubScene에 배치한 Light/ParticleSystem 빌트인 Companion (자동)
런타임 스폰 Entity에 비주얼 부착 커스텀 Companion GO 패턴

순수 ECS만으로 게임을 만드는 건 쉽지 않다. 시뮬레이션은 ECS, 비주얼은 Companion GO. 이 조합을 많이 쓴다.

'게임엔진 > ECS(Unity)' 카테고리의 다른 글

ECS에서 Behavior Tree 구현하기 (ft. xNode)  (0) 2026.02.08
BlobAsset의 개념  (0) 2026.02.07
ECB + sortKey  (0) 2025.11.30
Entities - 컴포넌트 구조  (0) 2023.02.25