전체 코드 구조가 마음에 안 들어서 갈아엎던 중에, 이왕 수정하는 김에 ECS를 적용해보기로 했음.


여기서 ECS는 데이터(Entity)를 구성하는 요소(Component)를 잘게 나누고, 이를 별도의 System에서 처리함으로써 성능과 관리 효율을 높이는 방식임.


유니티에 ECS 기능(DOTS)이 있긴 한데, 자료가 너무 적고 배우기도 귀찮아서 그냥 이론만 가지고 내 방식대로 직접 구현했음.


먼저 데이터를 저장할 Entity와 모든 Component의 기본 인터페이스 IComponent를 만듦


using System; namespace ECS { public interface IComponent { } public abstract class ComponentBase : IComponent { public Entity Owner { get; internal set; } } }
using System; using System.Collections.Generic; using ECS.Events; namespace ECS { public class Entity { public int ID { get; private set; } private Dictionary<Type, IComponent> components = new(); public Entity(int id) { ID = id; } public void AddComponent(IComponent component) { // 만약 BaseComponent인지 확인해서 Owner를 지정 if (component is ComponentBase baseComp) { baseComp.Owner = this; } // Dictionary에 컴포넌트 등록 components[component.GetType()] = component; EventManager.Instance.Publish(new ComponentAddedEvent(this, component)); // 컴포넌트 추가 시 이벤트 발행 } public bool HasComponent<T>() where T : IComponent => components.ContainsKey(typeof(T)); public bool HasComponent<T>(out T component) where T : IComponent { if (components.TryGetValue(typeof(T), out IComponent comp)) { component = (T)comp; return true; } component = default; return false; } public T GetComponent<T>() where T : IComponent { if (components.TryGetValue(typeof(T), out IComponent comp)) { return (T)comp; } return default; } public void RemoveComponent<T>() where T : IComponent { if (components.ContainsKey(typeof(T))) { components.Remove(typeof(T)); } } } }


기존 기능들을 ECS 방식으로 바꾸면서, 일단 플레이어/몬스터 움직임 같은 기본 기능을 Component와 System으로 나눠봤음. 대충 아래처럼 됨.


using UnityEngine; namespace ECS.Components { public class MovementComponent : ComponentBase { public Rigidbody2D Rigidbody { get; private set; } public float Speed { get; private set; } public Vector2 Direction { get; private set; } public MovementComponent(GameObject gameObject, float speed) { Rigidbody = gameObject.GetComponent<Rigidbody2D>(); Speed = speed; } // 이동할 위치 세팅 public void SetDirection(Vector2 newDirection) => Direction = newDirection; } }
using UnityEngine; using ECS.Components; using ECS.Events; namespace ECS.Systems { public class MovementSystem : MonoBehaviour { public void OnEnable() { EventManager.Instance.Subscribe<CollisionEvent>(OnCollisionEvent); } public void OnDisable() { EventManager.Instance.Unsubscribe<CollisionEvent>(OnCollisionEvent); } public void FixedUpdate() { foreach (var movement in ECSWorld.Instance.GetComponentWith<MovementComponent>()) { AlignToPixel(movement); if (movement.Owner.HasComponent<PlayerTagComponent>()) { EventManager.Instance.Publish(new PlayerMovedEvent(movement.Rigidbody.position)); } } } /// <summary> /// 현재 위치와 이동 방향을 기반으로 픽셀 단위로 정렬된 다음 위치를 계산 /// </summary> private Vector2 CalculatePixelAlignedPosition(Vector2 currentPos, Vector2 direction, float speed) { currentPos = PixelAlign(currentPos); // 현재 위치를 픽셀 정렬 if (direction.sqrMagnitude > 0f) { // 다음 위치 = 현재 위치 + (이동 방향 × (Speed / 픽셀 단위)) Vector2 nextPos = currentPos + (direction * (speed / IntConstants._PixelsPerUnit)); return PixelAlign(nextPos); // 계산된 위치 픽셀 정렬 } else { // 방향이 0이면 움직임이 없으므로, 현재 위치를 픽셀 정렬한 채로 리턴 return currentPos; } } /// <summary> /// 주어진 위치를 픽셀 단위로 정렬 /// </summary> private Vector2 PixelAlign(Vector2 position) { float x = Mathf.Round(position.x * IntConstants._PixelsPerUnit) / IntConstants._PixelsPerUnit; float y = Mathf.Round(position.y * IntConstants._PixelsPerUnit) / IntConstants._PixelsPerUnit; return new Vector2(x, y); } private void AlignToPixel(MovementComponent movement) { if (movement == null) return; Vector2 targetPos = CalculatePixelAlignedPosition( movement.Rigidbody.position, movement.Direction, movement.Speed ); movement.Rigidbody.MovePosition(targetPos); } // 충돌 후, 위치가 픽셀 단위에서 어긋날 수 있으므로 픽셀 재정렬 private void OnCollisionEvent(CollisionEvent evt) { AlignToPixel(evt.Entity1.GetComponent<MovementComponent>()); AlignToPixel(evt.Entity2.GetComponent<MovementComponent>()); } } }


그런데 바로 문제가 생김.

기존대로 MonoBehaviour를 상속받아서 System을 만들면


24b0d121e09c28a8699fe8b115ef046c61f02c4a9b


이렇게 오브젝트에 붙이는 컴포넌트가 끝없이 늘어나서 오히려 관리가 지옥이 돼버림.

이걸 해결하려고 생각한 게, System이 MonoBehaviour를 상속받지 않고 ISystem이라는 인터페이스만 가지게 만드는 방식임. 대신 MonoBehaviour를 상속받은 SystemManager 하나가 모든 System의 Start, Update 등을 대신 처리하게 함.


근데 또 새로운 문제가 있음.


SystemManager에서 이 System 클래스들을 가져와서 처리하려면, 모든 ISystem 상속 클래스를 어딘가에 등록해야 함. 찾아보니 대표적인 방법이 세 가지 정도 있었음.


  1. 그냥 리스트에 일일이 "new()"로 때려박기 (클래스 직접 초기화)
  2. DI 컨테이너 쓰기
  3. ScriptableObject 쓰기


근데 각각 단점이 너무 분명했음.


1번 방식은 시스템 만들 때마다 매번 초기화 코드 넣어줘야 해서 귀찮음

2번 방식은 DI 컨테이너 라이브러리 깔고 또 사용법을 익혀야 해서 귀찮음

3번 방식은 관리 자체는 좀 편해도 결국 추가 관리 작업이 생겨서 귀찮음


결국 다 귀찮으니까 그냥 직접 로직을 만들어버리기로 함.


ChatGPT의 힘을 빌려서 "Assets/Script" 폴더 내에 있는 ISystem을 상속받는 클래스들을 자동으로 긁어와 초기화하고 리스트에 담는 로직(TypeLoaderUtility.LoadInstances<T>())을 짜줬음.


그리고 SystemManager를 만들면

namespace ECS.Systems { public interface ISystem { public void OnStart() { } public void OnUpdate() { } public void OnFixedUpdate() { } } }
using System.Collections.Generic; using UnityEngine; namespace ECS.Systems { public class SystemManager : MonoBehaviour { private List<ISystem> systems; private void Awake() { // "Assets/Script" 폴더 내에 ISystem를 상속받는 모든 클래스를 가져옴 systems = TypeLoaderUtility.LoadInstances<ISystem>(); } private void Start() { foreach (var sys in systems) { if (sys.IsOverriddenMethod(x => x.OnStart())) // 또는 nameof(ISystem.OnStart) { sys.OnStart(); } } } private void Update() { foreach (var sys in systems) { if (sys.IsOverriddenMethod(x => x.OnUpdate())) { sys.OnUpdate(); } } } private void FixedUpdate() { foreach (var sys in systems) { if (sys.IsOverriddenMethod(x => x.OnFixedUpdate())) { sys.OnFixedUpdate(); } } } } }


이제부터는 Assets/Script 안에서 ISystem만 상속받아주면 자동으로 인식돼서 추가 관리가 필요 없게 됐음.

ECS 구조 잡느라 고생했지만, 한번 만들어 놓으니까 코드가 엄청 깔끔해져서 만족 중임.