ECS with Scene and Node
Entity는 이름표일 뿐이고, 실제 데이터는 Component가 들고 있음. Node는 System에 필요한 Component 조합을 정확히 묶어주고, 실행 순서는 사람이 손으로 짠 목록이 아니라 의존성 Graph가 정함.
목차
- 01핵심 내용
- 02상세 내용 & 다이어그램
- 03개발 히스토리
- 04왜 Sparse Set도 Archetype도 아닌 Node인가
- 05이슈
- 06버그
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
Sequence 다이어그램 — World::Update() 한 틱
공통 베이스
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> 실제 통합 구조
03 / HISTORY
실제 diff에서 확인한 변화만 정리
개발 히스토리
GitHub 커밋 기준실제 diff에서 확인한 변화만 정리
커밋 기록을 불러오는 중…
- 1기Manager 기반 첫 버전2023.06 — 2023.09
DX11 엔진에서 떼어낸 직후 - 2023.06.21ECS독립 저장소로 첫 커밋엔진 안에서 쓰던 Scene · Entity · Component · Node · System Manager 구조를 그대로 들고 나옴.
- 2023.09.02ECSSystemManager에 위상정렬 도입System 실행 순서를 손으로 짠 목록 대신 의존 관계로 정하기 시작함 — 지금
Graph<Base>의 출발점. - 2기통짜 재작성 → 범용 Registry · Graph2025.12 — 2026.05
2년 넘는 공백 뒤 12.01 "Set the developping"으로 재개 - 2025.12.17ECSManager 5개 + Interface 6개 제거커밋
2b2b069하나에서SceneManager/EntityManager/ComponentManager/SystemManager/NodeManager와IObject/IComponent/INode/ISystem/Entity/Scene을 전부 삭제 —Object공통 베이스, 타입별 Registry 4개, 신규Graph.h로 교체. 지금 구조의 첫 뼈대. - 2026.03.24ECSContainer 4개 →
Container<T, Base>하나로 통합커밋1801487— 타입별로 따로 있던 Base/Component/Entity/NodeContainer를 제네릭 하나로 교체하고, Component/Node/ContainerRegistry 3개도Registry<Base>하나로 합침. SystemRegistry만 이때는 살아남음. - 2026.04.17ECS마지막 SystemRegistry 제거의존성 관리를
Graph<Base>로 흡수커밋5e41709—Graph.h를 337줄 고쳐 위상정렬 로직을 흡수하고, System 전용이던 의존성 관리를 범용 Graph로 넘김. 엔진의 RenderGraph도 이 Graph를 상속해서 Pass 순서를 정함. - 2026.05.26ECSGraph Node의 의존 정보가 반영되지 않던 문제 수정
- 2기코드 리뷰 → 수정2026.08
- 2026.08.08ECS리뷰로 찾은 버그 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 하나로 환원되지는 않음. |
| Archetype | Component 하나만 추가해도 새 archetype이 필요 — 기존 데이터를 복제해서 옮기고 원래 자리에서 지워야 함. 라이트+렌더 조합 하나 만들려고 해도 "Light Render Archetype"을 새로 만들어야 하는 식. |
| Node (이 프로젝트) | Entity의 데이터를 옮기거나 복제하지 않고도 System이 필요한 Component 조합을 선언. 라이트+렌더 = 같은 Entity에 두 Node를 등록하고 Transform을 공유. |
05 / ISSUES
이슈
- 원인:
- 개선 방향: 같은 역할을 하는 범용
- 해결(2026.08.08): 없는 헤더를 include하던
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건 수정 · 상태는 실시간 동기화
버그
2026.08 코드 리뷰에서 직접 찾아 등록한 이슈7건 중 6건 수정 · 상태는 실시간 동기화
실제 버그 7건 — 6건 수정 완료 클릭해서 펼치기
- Template 래퍼가
- 수정(2026.08.08):
AddNode(nodeType) 대신 AddComponent(nodeType)를
호출함. 짝인 RemoveNode<T>()는 맞게 작성돼 있어서 더 눈에 안 띄는
비대칭 버그. 이 경로로 등록한 Node는 HasNode<T>()가 항상 false를
반환함.- 수정(2026.08.08):
AddNode(nodeType)을 부르도록 고침.
-
- 수정(2026.08.08):
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가 되게 바꿈.
-
- 수정(2026.08.08):
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가 다시 진짜 커스터마이즈 지점이 됨.
-
- 수정(2026.08.08): 조회 미스면 공유 static 대신 빈
static GraphNode<T> null;은 같은 타입 T의 모든
조회 미스가 공유하는 함수-로컬 정적 변수. 호출자가 반환값에 SetDependency
등으로 쓰기를 하면, 그 타입에 대한 이후 모든 미스가 오염된 상태를 보게 됨.- 수정(2026.08.08): 조회 미스면 공유 static 대신 빈
RefPtr를 반환.
- 기본 멤버 초기화자가 없고 생성자는
- 수정(2026.08.08): 기본 멤버 초기화자를 추가.
= default. Graph::GetSorted()는
Add/Create가 한 번도 호출되지 않은 상태에서도
IsUpdated()를 읽어서, 첫 호출은 정의되지 않은 값을 읽는 셈.- 수정(2026.08.08): 기본 멤버 초기화자를 추가.
demofile/main.cpp, SystemList.h, ComponentList.h, NodeList.h
존재하지 않는 API를 호출하던 데모이슈 #8보다 한 겹 더 깊은 불일치
해결
-
- 해결(2026.08.08): 데모를 Entity · Component · Node · Registry · Graph만 쓰도록 다시 짜서 빌드 · 실행 확인 (#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에 지금 열려 있는 이슈 (실시간)
불러오는 중…