WONMOCK LEE / GRAPHICS ENGINEER
프로젝트 02 · 독립 저장소

ECS with Scene and Node

Entity는 이름표일 뿐이고, 실제 데이터는 Component가 들고 있음. Node는 System에 필요한 Component 조합을 정확히 묶어주고, 실행 순서는 사람이 손으로 짠 목록이 아니라 의존성 Graph가 정함.

01 / INTRO

핵심 내용

왜 Node: Sparse Set은 Entity ID로 Component 하나만 접근 가능해서 여러 System이 걸치는 조합을 못 씀, Archetype은 조합이 하나 늘 때마다 새 Archetype과 데이터 복제가 필요함 — 데이터를 옮기거나 복제하지 않고 System별로 필요한 Component 조합만 선언하는 절충안으로 Node를 도입.
자동 검증 없는 Node 멤버십
Node는 ObjectPtr<T> 필드로 구성된 평범한 struct — 생성 시점에 수동으로 연결함. 프레임워크가 Entity의 Component를 스캔해서 확인해주는 과정은 없고, System이 사용 시점에 방어적으로 null 체크를 하는 게 실질적인 안전장치.
"필터링"의 실체Node 타입별 HashMap 하나
각 Node 타입은 자기만의 UUID → ObjectPtr<T> 저장소를 갖고, System은 그 Container를 그대로 받음. 프레임마다 훑으면서 필터링하는 과정은 없음.
의존성 Graph교과서적인 Kahn's Algorithm
DependOn() 엣지로 진입 차수(in-degree) 맵을 만들고, 진입 차수 0인 System부터 Queue에 넣어 위상정렬. 순환이 있으면 조용히 실패 — 예외를 던지지 않고 로그만 남긴 뒤 빈 배열을 반환.

특징

Sparse Set · Archetype 트레이드오프를 둘 다 피하는 Node
데이터를 옮기거나 복제하지 않고도 System이 필요한 Component 조합을 선언할 수 있어서, 라이트+렌더처럼 같은 Entity가 여러 System에 걸치는 경우를 자연스럽게 처리.
GC · Reflection 통합 완료
PROPERTY()로 등록된 멤버는 자동으로 GC 추적 대상이 되어서, 수동 참조 카운팅 없이 도달 불가능한 Entity/Component가 정리됨.
하드코딩 없는 실행 순서
DependOn() 선언만 해두면 위상정렬이 알아서 순서를 정해서, System을 추가/제거할 때 다른 System의 실행 순서 코드를 손댈 필요 없음.

주요 내용

실제 API와 완전히 다르던 데모수정됨 · #8
체크인된 코드에 없는 NodeRegistry/SystemRegistry/ DependOn 등을 데모가 그대로 참조해서, 처음 받은 사람은 데모조차 컴파일할 수 없었음 → 2026.08 데모를 실제 API만 쓰도록 다시 짜서 빌드 · 실행까지 확인함.
컴파일 타임에 못 잡는 GC 등록 누락
PROPERTY()를 깜빡한 멤버는 조용히 GC 추적에서 빠져서, 다음 Collect() 때 예고 없이 해제됨 — 런타임에야 드러나는 종류의 실수.
Instance화 전까지 숨어 있던 버그수정됨 · #12 #13
Entity::AddNode<T>()가 실제로는 Component 쪽에 등록되는 비대칭 버그, move 대입이 아무것도 move하지 않는 버그 등 — Template이라 실제로 그 경로를 쓰기 전까지는 조용히 묻혀 있었음 → 2026.08 리뷰에서 찾아 바로 수정함.
02 / MODULES

상세 내용 & 다이어그램

Class 다이어그램 — Entity / Component / Node / System / Graph
ECS Class 다이어그램
Sequence 다이어그램 — World::Update() 한 틱
World Update Sequence 다이어그램
공통 베이스 ObjectEntity/Component/Node/System 전부 상속, 저장은 Registry<Base> 하나
초기 설계 노트에는 SceneManager/EntityManager/ComponentManager/SystemManager/NodeManager, 다섯 개의 전용 Manager가 따로 있었음. 지금 코드는 이걸 범용 Registry<Base> 하나로 합침 — 타입 해시를 키로 Container<T, Base>를 담는 HashMap 하나가 생성(Create<T>)·조회·삭제를 타입 구분 없이 전부 처리. 위 "주요 내용"의 데모-API 불일치는 정확히 이 리팩토링 전/후 API 차이 때문이었음(2026.08 해소).
GC 도달 가능성 판정범용 Property 순회
Class별로 따로 짠 추적 함수가 아니라, TypeInfo의 Property 목록을 그대로 따라감. PROPERTY()로 등록하지 않은 ObjectPtr/RefPtr 멤버는 이 순회에 보이지 않음 — 아래 GC 함정이 예외적인 버그가 아니라 이 설계의 직접적인 결과인 이유.
GC는 PROPERTY()로 선언된 멤버만 추적함. Registry 멤버에 이걸 빠뜨리면 Collector가 도달 불가능하다고 판단해서, 다음 Memory::Collect() 때 조용히 해제해버림.
Class 다이어그램 — Registry<Base>/Container<T, Base> 실제 통합 구조
Registry Base Container 클래스 다이어그램
03 / HISTORY

개발 히스토리

커밋 기록을 불러오는 중…

  1. 1기Manager 기반 첫 버전2023.06 — 2023.09
    DX11 엔진에서 떼어낸 직후
  2. 2023.06.21
    ECS독립 저장소로 첫 커밋
    엔진 안에서 쓰던 Scene · Entity · Component · Node · System Manager 구조를 그대로 들고 나옴.
  3. 2023.09.02
    ECSSystemManager에 위상정렬 도입
    System 실행 순서를 손으로 짠 목록 대신 의존 관계로 정하기 시작함 — 지금 Graph<Base>의 출발점.
  4. 2기통짜 재작성 → 범용 Registry · Graph2025.12 — 2026.05
    2년 넘는 공백 뒤 12.01 "Set the developping"으로 재개
  5. 2025.12.17
    ECSManager 5개 + Interface 6개 제거
    커밋 2b2b069 하나에서 SceneManager/EntityManager/ComponentManager/SystemManager/NodeManager와 IObject/IComponent/INode/ISystem/Entity/Scene을 전부 삭제 — Object 공통 베이스, 타입별 Registry 4개, 신규 Graph.h로 교체. 지금 구조의 첫 뼈대.
  6. 2026.03.24
    ECSContainer 4개 → Container<T, Base> 하나로 통합
    커밋 1801487 — 타입별로 따로 있던 Base/Component/Entity/NodeContainer를 제네릭 하나로 교체하고, Component/Node/ContainerRegistry 3개도 Registry<Base> 하나로 합침. SystemRegistry만 이때는 살아남음.
  7. 2026.04.17
    ECS마지막 SystemRegistry 제거의존성 관리를 Graph<Base>로 흡수
    커밋 5e41709 — Graph.h를 337줄 고쳐 위상정렬 로직을 흡수하고, System 전용이던 의존성 관리를 범용 Graph로 넘김. 엔진의 RenderGraph도 이 Graph를 상속해서 Pass 순서를 정함.
  8. 2026.05.26
    ECSGraph Node의 의존 정보가 반영되지 않던 문제 수정
  9. 2기코드 리뷰 → 수정2026.08
  10. 2026.08.08
    ECS리뷰로 찾은 버그 5건 + 빌드 안 되던 데모 수정
    08.02 등록한 #12~#16과, 세 번의 리팩토링을 못 따라가 컴파일조차 안 되던 데모(#8)를 한 번에 정리 — 없는 헤더를 include하던 ECS.h를 지우고, 데모를 실제 API(Registry · Graph · 순환 감지 · GC 연동)만 쓰도록 다시 짬 (PR #17).
04 / DESIGN

Sparse Set도 Archetype도 아닌 Node

방식문제점
Sparse Set빠르지만 Entity ID로 접근 가능한 Component가 하나뿐 — 모든 System이 Component 하나로 환원되지는 않음.
ArchetypeComponent 하나만 추가해도 새 archetype이 필요 — 기존 데이터를 복제해서 옮기고 원래 자리에서 지워야 함. 라이트+렌더 조합 하나 만들려고 해도 "Light Render Archetype"을 새로 만들어야 하는 식.
Node (이 프로젝트) Entity의 데이터를 옮기거나 복제하지 않고도 System이 필요한 Component 조합을 선언. 라이트+렌더 = 같은 Entity에 두 Node를 등록하고 Transform을 공유.

출처: 개인 리서치 노트, Entity Component System

05 / ISSUES

이슈

- 원인: ECS.h가 저장소에 실제로 존재하지 않는 헤더 두 개 (Container/DataRegistry.h, Container/SystemRegistry.h) 를 include — 데모와 README가 체크아웃된 헤더에는 없는 API(TypedSystem<T>, 전용 SystemRegistry)를 설명하고 있는 상태.
- 개선 방향: 같은 역할을 하는 범용 Registry<Base>가 이미 존재하고 컴파일도 됨 — 빠진 전용 헤더를 복원하거나, 데모/README를 실제로 있는 범용 API 기준으로 갱신하면 됨.
- 해결(2026.08.08): 없는 헤더를 include하던 ECS.h를 지우고, 데모를 실제로 있는 범용 API 기준으로 다시 짬 — 빌드 · 실행까지 확인 (PR #17).
06 / BUGS

버그

실제 버그 7건 — 6건 수정 완료 클릭해서 펼치기
#12 · include/Object/Entity.h:72 Entity::AddNode<T>()의 잘못된 등록 위치Node가 아닌 Component 쪽 등록 해결
- Template 래퍼가 AddNode(nodeType) 대신 AddComponent(nodeType)를 호출함. 짝인 RemoveNode<T>()는 맞게 작성돼 있어서 더 눈에 안 띄는 비대칭 버그. 이 경로로 등록한 Node는 HasNode<T>()가 항상 false를 반환함.
- 수정(2026.08.08): AddNode(nodeType)을 부르도록 고침.
#13 · src/Object/Entity.cpp:47-59 아무것도 move하지 않던 Entity move 대입 연산자 해결
- Object::operator=(other)가 lvalue를 받아 베이스 Class는 복사되고, m_name = std::move(m_name)은 other.m_name이 아니라 자기 자신을 self-move — 사실상 no-op. "move" 대입인데 이름은 그대로, UUID만 원본과 같은 값이 됨.
- 수정(2026.08.08): Object::operator=(std::move(other))와 m_name = std::move(other.m_name)으로 실제 move가 되게 바꿈.
#14 · include/Graph/Graph.h:105,136,193 Create()/Add()에서 무시되던 Graph의 BaseNode Template 인자 해결
- Graph<Base, BaseNode = GraphNode<Base>>는 커스텀 노드 타입을 받을 수 있게 설계됐고 m_nodeData도 HashMap<UUID, BaseNode>인데, Create()와 Add()는 둘 다 자기 BaseNode 대신 GraphNode<Base> node;를 하드코딩함. 기본값(BaseNode = GraphNode<Base>)으로 쓰는 한은 문제없지만, 실제로 커스텀 BaseNode를 넘기면 이 대입 자체가 컴파일되지 않음.
- 수정(2026.08.08): Memory::MakeRef<BaseNode>()로 노드를 만들어 BaseNode가 다시 진짜 커스터마이즈 지점이 됨.
#15 · include/Graph/Graph.h:174-184 Graph::GetNode<T>()의 미스 처리공유 static Singleton 반환 해결
- static GraphNode<T> null;은 같은 타입 T의 모든 조회 미스가 공유하는 함수-로컬 정적 변수. 호출자가 반환값에 SetDependency 등으로 쓰기를 하면, 그 타입에 대한 이후 모든 미스가 오염된 상태를 보게 됨.
- 수정(2026.08.08): 조회 미스면 공유 static 대신 빈 RefPtr를 반환.
#16 · include/Graph/Graph.h:18-28 GraphObserver::m_updated 미초기화 해결
- 기본 멤버 초기화자가 없고 생성자는 = default. Graph::GetSorted()는 Add/Create가 한 번도 호출되지 않은 상태에서도 IsUpdated()를 읽어서, 첫 호출은 정의되지 않은 값을 읽는 셈.
- 수정(2026.08.08): 기본 멤버 초기화자를 추가.
demofile/main.cpp, SystemList.h, ComponentList.h, NodeList.h 존재하지 않는 API를 호출하던 데모이슈 #8보다 한 겹 더 깊은 불일치 해결
- ECS::NodeRegistry, ECS::ComponentRegistry, ECS::SystemRegistry, ECS::TypedSystem<Node>, System::DependOn/RemoveOn을 쓰지만 include/ 아래 어디에도 없음. 체크인된 코드는 범용 Registry<Base>와, 의존성 Graph API가 없는 추상 System<Base>뿐. ECS::Component/ ECS::Node도 기본 생성자만 선언하는데 데모는 ECS::Component(uuid)처럼 없는 생성자를 호출해서, 헤더를 복원해도 이 부분은 여전히 컴파일되지 않음.
- 해결(2026.08.08): 데모를 Entity · Component · Node · Registry · Graph만 쓰도록 다시 짜서 빌드 · 실행 확인 (#8).
Container/Storage.h:12-111 미사용 죽은 코드 ObjectStorage<T>DataStorage<T>와 거의 동일 죽은 코드
- 유일한 차이는 ID 부여 방식뿐인 복붙 Class. ECS/데모 어디에서도 참조되지 않음.

GitHub에 지금 열려 있는 이슈 (실시간)

불러오는 중…