작성: 2026-08-10 · 계기: GameObject/Object 기반 설계 평가 + "유니티를 연상시키는 구조에서
벗어나되 최신의 발전된 구조를 취하고 싶다"는 요구.
개정(같은 날): "프리팹도 재설계가 많이 들어가야 한다"는 지적에 따라 프리팹을 실측하고
트랙 P를 신설했다(§1.4·§3 트랙 P). 실측 결과가 지적을 뒷받침한다 — 씬을 저장했다
다시 열면 프리팹 인스턴스 연결이 끊긴다(재연결 코드가 통째로 주석 처리되어 있다).
관련 문서: Phase4CouplingPlan.md(간선 절단 — 방향 동일, 상호 보강),
BuildPipelinePlan.md(트랙 구조·게이트 관례를 승계),
MultiCameraRenderPlan.md(뷰별 시간축 상태 — B단계의 소비자).
표면과 코어를 분리해서 판단한다. "유니티스러움"의 실체는 둘이다:
- 스크립팅 API 표면 (Awake/Update 훅, GetComponent, Instantiate) — 이것은 자산이다. 사용자가 바로 익히고, C# 스크립트·에디터·직렬화가 전부 이 모양에 묶여 있다. 유지한다.
- 내부 아키텍처 (상속 기반 Object 그래프 + shared_ptr 소유 + 4중 정체성) — 이것이 낡은 부분이다. 여기를 데이터 지향으로 갈아끼운다. Godot(서버/RID)도 Unreal(Mass)도 이 방식으로 차별화했다 — 표면은 익숙하게, 코어는 데이터 지향으로.
핵심 관찰: 이 엔진에는 이미 세 개의 씨앗이 있다. 이 계획은 새 개념을 발명하지 않고 있는 씨앗을 정본(canonical)으로 승격한다:
| 씨앗 | 현재 위치 | 현재 신분 | 승격 후 |
|---|---|---|---|
| 세대 핸들 (index+generation, UAF 구조적 차단) | ScriptObjectRegistry.h |
C# 경계 전용 옆 테이블 | 엔진 전체의 정체성 정본 (A) |
| 판정/디스패치 분리 (타입당 비트마스크) | LifecycleRegistry.h |
생명주기 훅 판정표 | 시스템 쿼리 등록의 원형 (C) |
페이즈별 평탄 리스트 (m_updateList 등) |
Scene.cpp:745-748 |
컴포넌트 포인터 리스트 | 명시적 시스템 스케줄 (C) |
단계는 A(정체성) → B(Transform SoA) → C(시스템 스케줄) → D(선택: 데이터 코어 교체 평가), 그리고 이와 **병행하는 트랙 P(프리팹 재설계)**다. 각 단계는 독립적으로 가치가 있고, 어디서 멈춰도 엔진은 그 시점의 개선을 유지한다.
트랙 P를 별도 축으로 세우는 이유: 프리팹은 지금 저장·로드를 건너면 연결이 끊기는 반쪽 상태이고(§1.4), 그 원인이 오브젝트 모델이 아니라 "프리팹이 에셋이 아니라 YAML 스냅샷"이라는 별도 문제이기 때문이다. A의 핸들 전환이 P의 한 항목(인스턴스 추적)을 거저 해결해 주지만 나머지는 스스로 풀어야 한다.
GameObject 하나가 갖는 식별자와 그 용도:
| 식별자 | 타입 | 용도 | 문제 |
|---|---|---|---|
m_instanceID |
HashedGuid | 직렬화·전역 조회 | 기본 생성자와 ID 지정 생성자의 GUID 등록이 비대칭 |
m_index |
uint32 (씬 벡터 슬롯) | 계층·순회·Transform 부모 | DDOL 이동 후에도 옛 씬 슬롯 번호 유지, 인덱스 0 = 루트 매직 |
m_name |
HashingString | Find 조회 | 유일성은 GenerateUniqueGameObjectName이 보장 시도 |
m_attachedSoketID / m_prefabFileGuid |
HashedGuid / FileGuid | 소켓·프리팹 연결 | 별도 조회 API (FindAttachedID) |
조회 API도 Find / FindIndex / FindInstanceID / FindAttachedID 4갈래.
| 패턴 | 규모 | 밀집 지점 |
|---|---|---|
shared_ptr<GameObject> |
99곳 / 24파일 | Scene 34, UIManager 26, ModelSceneBridge 10, HierarchyWindow 6 |
GameObject::Index / GameObjectIndex |
94곳 / 25파일 | Scene 29, GameObjectCommand 14, GameObject 17 |
GetGameObject( 호출 |
60곳 / 14파일 | Scene 15, ConsoleCommandSystem 14, HierarchyWindow 7 |
Dynamic_CPP(게임 프로젝트) 쪽 사용은 소수(C++ 레거시 스크립트 잔재) — 9-4 은퇴 트랙과 겹치므로 마이그레이션 대상에서 제외 가능.
- 계층 불변식 4필드(
m_parentIndex·m_childrenIndices·m_rootIndex·m_transform.parentID)를 최소 4개 호출처가 수동 유지:GameObject.cpp:237(AddChild),Object.cpp:81(DDOL),Prefab.cpp:169,Object.cpp:145(Instantiate). 그리고Object.cpp:146은 실제로 틀렸다 — 자식 클론의 Transform 부모를 자기 자신 인덱스로 설정(다른 모든 호출처는 부모 인덱스를 넘긴다). m_isEnabled가 public이라 PHASE 9-2의 전이 기반 OnEnable/OnDisable을 우회 가능.m_typeID초기화 관용구 3종 혼재(GENERATED_BODY / 수동 대입 / type_guid).Scene.cpp:104—const_cast<GameObject::Index&>로 인덱스 대입. 인덱스 안정성은 tombstone(null 슬롯)으로 처리 중 — slot map의 절반을 이미 손으로 구현한 상태다.Object.cpp가 말단 컴포넌트 10종을 include(기반 TU → 잎 의존).
코드는 작지만(Prefab 196 + PrefabUtility 150 + PrefabEditor 57행) 에셋은 206개다. 그런데 저장·로드를 건너면 프리팹 연결이 끊긴다:
| # | 결함 | 근거 | 여파 |
|---|---|---|---|
| P-a | 씬 로드 시 인스턴스 재연결이 통째로 주석 처리 | SceneManager.cpp:1109~1201 |
씬을 다시 열면 인스턴스가 더는 프리팹의 인스턴스가 아니다 |
| P-b | 오버라이드 기준(m_prefabOriginal)이 직렬화되지 않음 |
GameObject.h:177 — [[Property]] 없음 |
로드 후 3-way 병합의 기준선이 빈 노드 → 갱신이 인스턴스에 닿지 않는다 |
| P-c | 인스턴스 추적이 raw 포인터 목록 + Unregister 없음 | PrefabUtility.h:30, .cpp:41 |
파괴된 오브젝트가 목록에 남아 UpdateInstances가 UAF |
| P-d | 오버라이드 판정이 YAML 문자열 덤프 비교 | ReflectionYml.h:348 |
프로퍼티마다 Dump 생성. 포맷·부동소수 표기 차이로 오탐 |
| P-e | 컴포넌트 갱신이 all-or-nothing | PrefabUtility.cpp:71~81 |
컴포넌트 하나만 손대도 나머지 갱신이 전부 차단된다 |
| P-f | 갱신이 Destroy 후 재생성 | PrefabUtility.cpp:83~113 |
런타임 상태(참조·캐시)가 날아가 플레이 중 갱신이 사실상 불가 |
| P-g | Prefab 객체 누수 | PrefabUtility.cpp:143 new Prefab() |
소유자가 없다. 로드할 때마다 샌다 |
| P-h | 인스턴스화 경로에 std::cout 벤치마크 잔존 |
Prefab.cpp:55, 143, 162 |
오브젝트 하나당 2줄 + 프리팹당 1줄 |
| P-i | 중첩 프리팹·베리언트 개념 없음 | — | 프리팹 안의 프리팹이 그냥 펼쳐져 저장된다 |
구조적으로 보면 원인은 하나다. 프리팹이 "에셋"이 아니라 "그때그때 만든 YAML 스냅샷"이다. 수명 주인이 없어 새고(P-g), 인스턴스와의 연결이 메모리에만 있어 저장을 건너지 못하며 (P-a·P-b), 그 연결이 raw 포인터라 안전하지도 않다(P-c). 오버라이드는 "명시적으로 무엇을 덮었는가"가 아니라 "지금 값이 원본과 다른가"를 문자열로 물어서 판정한다(P-d).
GameObject의 값 멤버(m_transform). 내부: 소유자 포인터 + m_parentID(uint32,
씬 인덱스) + 행렬 3개(local/world/inverse) + dirty 플래그 + 분해된 월드 성분.
per-object 산재 배치라 계층 갱신이 포인터 추적 순회. RectTransform은 별도 컴포넌트로
공존(지난 세션에서 성능·메모리 이중 부담 확인).
[스크립팅 표면] C# Behaviour(Awake/Update…) · GameObject/Component 파사드 API
│ 표면 계약 불변 — 스크립트·에디터·직렬화가 보는 모양
[오브젝트 모델] GameObject = { EntityHandle + 컴포넌트 목록 + 이름/태그 } ← 얇아진다
│ 정체성·수명 질의는 전부 핸들 경유
[에셋] Prefab = 불변 템플릿(FileGuid 정본, DataSystem 소유)
인스턴스 = { 템플릿 GUID + 명시적 오버라이드 목록 + EntityHandle }
[데이터 코어] Scene슬롯테이블(slot map: 소유+세대) · TransformStore(SoA)
시스템 스케줄(페이즈 리스트의 승격) · LifecycleRegistry(유지)
[경계] ScriptObjectRegistry → 코어 핸들과 통합 (옆 테이블 소멸)
RenderScene 프록시 · DX12 일괄 기록 경로 → TransformStore 직결
규칙 셋:
- 정체성은 핸들 하나.
EntityHandle{index, generation}이 런타임 정본. GUID는 직렬화·에셋 참조 전용, 이름은 조회 편의로 격하.FindIndex류 4갈래 API는 핸들 resolve 하나로 수렴. - 소유는 씬 슬롯 테이블 하나. shared_ptr 그래프 소멸. raw 포인터는 "이번 프레임 안 지역 변수"로만 허용 — 보관은 핸들로만.
- 핫 데이터는 SoA, 로직은 시스템. Transform부터. 가상함수 개별 디스패치는 네이티브 컴포넌트에서 단계적으로 걷어내되, C# 훅 표면은 ScriptSystem이 보존.
- 프리팹은 에셋, 오버라이드는 명시 데이터. 템플릿은 DataSystem이 소유하는 불변 자산이고, 인스턴스는 "무엇을 덮었는지"를 값으로 들고 저장한다 — "지금 값이 원본과 다른가"를 런타임에 문자열로 되묻지 않는다. 이 규칙 하나가 §1.4의 P-a·P-b·P-d·P-e·P-g를 동시에 없앤다.
각 슬라이스는 독립 커밋. 판정은 항상 ① CreatorEngine.sln 전체 빌드 그린
(Debug|x64, VS 18 MSBuild, 전체 약 2분 — 코어 헤더를 건드리면 더 길다)
② Tools/regression/run-all.ps1 ③ 성능 관련 슬라이스는 측정 첨부(DX12 실측
관례 — 주장 말고 숫자).
선행 문서들이 언급하는
GameBuild.sln은 현재 워킹트리에 없다(솔루션은CreatorEngine.sln하나뿐이며 Player.exe도 여기서 나온다). 관례를 그대로 옮기지 않고 실측에 맞춘다.
전환 대상 코드가 틀린 채로 이관되지 않게 먼저 고친다.
- ✅ A0-1 (2026-08-10,
5da5ba91)Object.cpp:146자식 클론이 Transform 부모로 자기 인덱스를 넘기던 버그 + 인접dynamic_castnull 검사. - ✅ A0-2 (2026-08-10,
cbad4f29) 부모 인덱스 쓰기를GameObject::SetParentIndex한 점으로 모으고Transform::SetParentID를 private +friend GameObject로 닫았다. 쌍을 깨는 코드가 이제 컴파일되지 않는다. 실측 쓰기 지점은 평가 당시 추정한 4곳이 아니라 9개 파일 15곳이었고, 그중Scene.cpp의 삭제·재매핑 경로 둘은 부모를 끊으면서 Transform을 갱신하지 않아 죽은 인덱스가 남아 있었다(치환으로 해소).- 부수 발견:
SetParentID의 월드 보존 계산(newLocal)이 어디에도 대입되지 않는 죽은 코드였다 — 월드 보존은 일어난 적이 없고 호출처의 "월드 유지" 주석은 사실과 다르다. 계산만 걷고 동작은 유지했다(저장된 씬·프리팹이 현재 동작에 맞춰져 있다). 월드 보존으로 고치는 것은 별도 결정이다. - 계층 4필드 private화는 A1-2로 미룬다 — 읽기 지점이 94곳이고 A1에서 타입이 핸들로 바뀌며 어차피 전부 건드린다. A0의 목표(불변식 위반 차단)는 쓰기 수렴과 Transform 봉인만으로 달성된다.
- 부수 발견:
- ✅ A0-3 (2026-08-10,
ae43a359)m_isEnabled를 protected로. 리플렉션은 클래스 내부 정적 함수라 그대로 동작한다(m_instanceID가 이미 protected면서 등록되어 있다).- 이것은 스타일 정리가 아니라 버그 수정이었다:
InspectorWindow.cpp:175가 체크박스에&m_isEnabled를 직접 물려 인스펙터로 컴포넌트를 끄면 OnDisable이 호출되지 않았다. 매 프레임 브로드캐스트하던 예전 방식은 이런 직접 쓰기도 다음 프레임에 알아챘지만, 전이 기반(PHASE 9-2)에는 알아챌 방법이 없다. - 범위 산정 실패 기록: 최초 grep이 206개 프리팹 데이터에 밀려 코드 사용처 둘
(
BehaviorTreeComponent·DecalComponent)을 놓쳤고 빌드가 잡았다(§5 참고).
- 이것은 스타일 정리가 아니라 버그 수정이었다:
- ⏸ A0-4 보류 —
m_typeID관용구 통일(수동 대입 13곳). 근거 둘: ① 원래 우려였던 "조용한 실패"는 이미 방어된다 —Scene::RegisterComponent가 등록되지 않은 typeID를 만나면 이름과 함께 에러를 찍는다(Scene.cpp:714). ②GENERATED_BODY는 생성자를 통째로 정의하므로 생성자 본문이 필요한 타입 (Canvas·MeshRenderer·ImageComponent 등 대다수)에는 애초에 적용할 수 없다. 남는 것은 순수 스타일 통일인데 13곳 변경의 회귀 위험이 그 이득을 넘는다. - ✅ A0-5 (2026-08-10,
b031935f) Object 복사·복사대입 삭제 +IObject가상 소멸자. 파생의 이동 연산이 함께 막히지만GameObject를 이동하는 코드는 실측 0곳이었고 빌드도 그대로 통과했다.
A0 종료. 다섯 항목 중 넷을 적용하고 하나(A0-4)를 근거와 함께 보류했다. 결과적으로 A0은 "정리"가 아니라 버그 수정 세 건이었다 — Instantiate의 잘못된 부모, Scene 두 경로의 죽은 부모 ID, 인스펙터의 생명주기 우회. 셋 다 "불변식을 여러 곳이 손으로 유지한다"는 같은 구조에서 나왔고, 그 구조를 없애는 것이 A1의 목표다.
판정: 커밋마다 전체 빌드 그린, 마지막에 회귀 세트 7종 전부 통과(2026-08-10). 계층·프리팹을 건드린 변경이라 그중 둘이 실질 근거다 — "저작 배치 재현"(12개 프리팹 322 rect 일치)과 "행동 트리 동작"(프리팹에서 트리 3개가 서고 540틱, 씬 교체 후 0). "생명주기 순서"(92 사건 순서 동일)가 A0-3을 받친다.
실행 함정: 이 세트는
pwsh로 돌려야 한다. Windows PowerShell 5.1로 실행하면 UTF-8 no-BOM 한글 주석이 깨지며Run-Step의 스크립트블록 파싱이 무너져, 검사가 실행되지도 않은 채 실패로 끝난다. 실제로 이 함정에 걸려 한 번 오판했다.
설계. ScriptObjectHandle의 구조(uint32 index + uint32 generation, 0 = 무효)를
그대로 코어로 가져온다. 이름은 EntityHandle. Scene의 m_SceneObjects
(vector<shared_ptr<GameObject>> + tombstone)를 슬롯 테이블로 바꾼다:
struct Slot { std::unique_ptr<GameObject> object; uint32 generation; };
// tombstone(null 대입) → 슬롯 해제 + 세대 증가로 대체. 재사용 시 기존 핸들 자동 무효.
- 슬라이스 순서 (각각 독립 커밋):
- ⬜ A1-1:
EntityHandle타입 + Scene 슬롯 테이블 도입.GetGameObject(Index)는 당분간 유지(내부에서 핸들 resolve로 위임) — 60곳 호출처가 한 번에 안 깨지게. - ⬜ A1-2:
GameObject::Index보관처를 핸들로 교체 —m_parentIndex·m_childrenIndices·m_rootIndex·Transform::m_parentID. A0의 SetParent 단일점 덕에 갱신 지점은 이미 한 곳이다. - ⬜ A1-3: 조회 API 수렴 —
FindIndex/FindInstanceID/FindAttachedID를Resolve(EntityHandle)+ GUID 조회(직렬화용) 둘로. 매직 인덱스 0(루트)은Scene::RootHandle()명시 API로. - ⬜ A1-4:
ScriptObjectRegistry를 코어 핸들로 통합 — C#에 넘기는 핸들이 곧 엔진 핸들이 된다(배치 동일 조건 유지: uint32 두 개). 옆 테이블·역방향 map·mutex 소멸.
- ⬜ A1-1:
- 직렬화 호환이 핵심 제약: §4 참조. 파일 포맷은 바꾸지 않는다.
- 게이트:
shared_ptr<GameObject>·GameObject::Index잔존 수를Phase4CouplingPlan의 래칫 방식으로 주간 측정(99·94에서 단조 감소만 허용).
- ⬜ 소유를 Scene 슬롯의
unique_ptr하나로.enable_shared_from_this제거. - ⬜ 보관성 참조(UIManager 26곳, Canvas의
weak_ptr목록, ModelSceneBridge 10곳, 에디터 HierarchyWindow/SceneViewWindow)를 핸들로 교체. weak_ptr의lock()관용구는scene->Resolve(handle)로 1:1 치환된다 — 의미가 같다(죽었으면 null). - ⬜
Instantiate재설계: 시그니처를 GameObject로 좁히고 핸들 반환. 비-GameObject 경로의 누수(평가 §5) 자연 소멸. - 함정: DDOL 버킷(
SceneManagers->AddDontDestroyOnLoad)은 씬을 넘는 소유라 슬롯 테이블 바깥 — DDOL 전용 테이블을 두고 핸들에 씬 구분 비트를 넣을지, 이송 시 재등록할지 이 슬라이스에서 결정(권장: 재등록 — 핸들 무효화가 명시적이라 "옛 씬 슬롯 번호를 계속 들고 있는" 현재 버그 급의 모호함이 사라진다).
설계. position/rotation/scale/local·world 행렬/dirty/부모핸들을 씬 슬롯 인덱스와
평행한 배열들로. Transform은 데이터를 소유하지 않는 뷰 타입이 된다(핸들 +
스토어 참조). 기존 메서드 시그니처(SetPosition, GetWorldMatrix…)는 전부 유지 —
호출처와 리플렉션 표면 불변.
- ⬜ B-1: TransformStore 도입 + Transform을 뷰로 전환. inverse 행렬은 온디맨드 계산으로 강등(지난 세션 결론: 구버전 스크립트 API 편의였음 — 64바이트/객체 회수).
- ⬜ B-2: 계층 갱신을 평탄 순회로 — 부모-우선 정렬 인덱스 배열을 유지하고 dirty 전파를 배열 한 번 훑기로. 측정 필수: 갱신 시간 before/after (오브젝트 1k/10k 씬, 지난 세션에 쓴 측정 하네스 재사용).
- ⬜ B-3: RectTransform 통합 — UI도 같은 스토어를 쓰고 앵커/피벗 해석만 별도 시스템으로(지난 세션의 "Transform·RectTransform 공존 메모리 불리" 결론의 해소).
- ⬜ B-4: 소비자 직결 — RenderScene 프록시 수집과 DX12 일괄 기록 경로가 TransformStore 배열을 직접 읽게. 소비자 없는 출력 금지(라이브 배선 교훈): B-4까지가 한 묶음이고, B-1~3만 하고 멈추면 미완성으로 간주한다.
- ⬜ C-1: 페이즈 리스트를
SystemSchedule로 명명·구조화 — 실행 순서가 코드에 명시되는 목록(FixedUpdate→Update→LateUpdate→커밋). 지금의 암묵 순서를 문서화하는 수준의 얇은 슬라이스. - ⬜ C-2: 구조 변경 지연 커밋 — AddComponent/Destroy를 프레임 중 즉시 반영하지 않고
커맨드 버퍼에 쌓아 페이즈 경계에서 커밋. 현재
m_pendingAwake/Start가 절반을 이미 하고 있다 — 파괴·부착까지 확장. (이터레이션 중 리스트 변형 사고의 구조적 차단) - ⬜ C-3: 네이티브 컴포넌트의 가상 Update를 시스템 함수로 이관 — 컴포넌트당 독립 슬라이스(Animator부터: 인스턴스 수가 많고 데이터 지역성 이득이 가장 큼). C# 스크립트는 건드리지 않는다 — ScriptComponent 일괄 호출이 하나의 시스템.
- LifecycleRegistry는 그대로 — 판정표는 이미 올바른 모양이다.
§1.4의 아홉 결함은 "스냅샷을 에셋으로 승격"이라는 하나의 방향으로 정리된다. A 트랙과 대체로 독립이므로 병행할 수 있고, 인스턴스 추적(P2)만 핸들을 기다린다.
-
✅ P0 — 지혈 (독립, 즉시) — 2026-08-10 완료. 빌드 그린 + 왕복 검사 신설.
P-a를 수치로 못박았다. 새 검사가 처음 돌린 결과:
시점 씬 인스턴스 등록 캐시 시작 0 0 0 소환 후(BTProbe ×2) 2 2 1 저장 → 재로드 후 2 0 1 씬 인스턴스는 왕복을 건너지만(
m_prefabFileGuid가 직렬화된다) 등록은 0이 된다 — 이것이 P-a의 정확한 모양이다. 캐시가 1인 것은 P0-1의 증거이기도 하다: BTProbe를 두 번 소환했는데 Prefab 객체는 하나만 만들어 재사용했다(예전에는 둘 다 새로 만들고 둘 다 버렸다).- P0-1
LoadPrefabFullPath의new Prefab()누수 차단(P-g). DataSystem에는 프리팹 캐시가 없다 —FileType::Prefab열거만 있고 자산 관리 밖이라, 로드할 때마다 새로 만들어 던진다.PrefabUtility가unique_ptr로 소유하고 재사용하게 했다. DataSystem 편입은 P1 이후로 미룬다(에셋 수명·핫리로드 정책이 걸려 범위가 커진다).- 캐시 키는 FileGuid가 아니라 정규화 경로다. 계획 초안은 GUID를 키로 적었는데,
실측해 보니
LoadPrefabFullPath는make_file_guid(path)로,LoadPrefab은 DataSystem이 매긴 GUID로 서로 다르게 매긴다. 같은 파일이 호출 경로에 따라 다른 GUID를 갖는 셈이라 키로 쓸 수 없다. (이 불일치 자체는 P2에서 정리한다) - 저장이 캐시를 앞지르지 않게
SavePrefab에서 해당 항목을 버린다. 단 방금 저장한 바로 그 객체면 남긴다 — 지우면 호출자가 든 포인터가 죽는다. - 누수는 이론이 아니었다: 게임 스크립트가 적이 죽을 때마다
LoadPrefab("EnemyDeathEffect")를 부른다. 한 번의 실수가 아니라 게임플레이 루프에서 계속 새는 구조였고, 캐시가 파일 I/O 반복도 함께 없앤다. - 새는 입구가 하나 더 있다:
CreatePrefab(→Prefab::CreateFromGameObject)도new Prefab을 돌려주고 호출자 넷(DataSystem 3곳의 모델 임포트, 콘솔prefab.create)이 전부 저장만 하고 버린다. 로드 경로만 막으면 절반만 막는 셈이라 생성 경로도 함께 소유한다.
- 캐시 키는 FileGuid가 아니라 정규화 경로다. 계획 초안은 GUID를 키로 적었는데,
실측해 보니
- P0-2
Prefab.cpp의std::cout벤치마크 제거(P-h). 문서에 3곳으로 적었으나 실제로는 4곳이었다(두 번째Instantiate오버로드와 컴포넌트 로드 구간 포함). - P0-3 파괴된 오브젝트를 인스턴스 목록에서 빼는
UnregisterInstance추가(P-c).GameObject::Destroy가 부른다. 어느 프리팹 소속인지 알아도 맵 전체를 훑는다 —m_prefabFileGuid가 이미 지워졌거나 등록 시점과 달라진 경우에도 죽은 포인터를 남기지 않기 위해서다. 핸들이 오면(P2) 세대 검사가 대신하므로 이 경로는 사라진다. - P0-4 프리팹 왕복 회귀 검사 신설 —
prefab_roundtrip.txt+verify-prefab-roundtrip.ps1,run-all.ps1에 등록. 판정 5항목 중 마지막 ("재로드 후 등록 복원")은 P2까지 실패가 예상된 항목으로 분리해 보고한다: 세트를 빨갛게 만들지 않되(exit 0) 침묵하지도 않는다. 침묵하면 P2에서 고쳐야 할 것이 있다는 사실 자체가 잊힌다. P2 완료 후-Strict를 기본으로 올린다. 관측용prefab.status콘솔 명령을 함께 넣었다(bt.status와 같은 자리·같은 이유 — 연결이 끊겨도 화면은 멀쩡해서 밖에서 볼 창이 없으면 판정 자체가 불가능하다). 두 수를 따로 세는 것이 설계의 핵심이다: 씬 인스턴스(오브젝트가 든m_prefabFileGuid— 직렬화되므로 왕복을 건넌다)와 등록(PrefabUtility의 목록 — 메모리에만 있어 왕복에서 끊긴다). 둘이 벌어지는 폭이 곧 P-a의 크기다.
- P0-1
-
⬜ P1 — 오버라이드를 명시 데이터로 (독립, 포맷 변경)
- 인스턴스가
m_prefabOverrides(프로퍼티 경로 → 값)를 값으로 들고 직렬화한다. 자료구조 제약: 직렬화기는vector만 다루고map은 지원하지 않으며, 원소 타입도 등록되어 있어야 한다(ReflectionYml.h:118FindYamlVectorEntry). 따라서map<경로, 값>이 아니라vector<PrefabOverride>(경로·값을 담은 등록된 구조체)로 설계한다.m_prefabOriginal(비직렬 YAML 사본)과DeserializePrefab의 문자열 덤프 비교는 폐기(P-b·P-d). 갱신은 "템플릿 적용 후 오버라이드 재적용"이라는 단방향이 된다. - 컴포넌트 단위 all-or-nothing 폐기 — 오버라이드가 경로 단위라 자연히 해소(P-e).
- 마이그레이션: 기존 206개 프리팹과 씬은 오버라이드 목록이 없다. 로더가 목록 부재를 "오버라이드 없음"으로 읽으면 기존 데이터가 그대로 열린다. 최초 저장 시 새 필드가 붙는다. 일괄 변환 스크립트를 만들지 않는다 — 데이터를 건드리지 않는 쪽이 안전하다.
- 인스턴스가
-
⬜ P2 — 인스턴스 추적을 핸들로 (A1 의존)
m_instanceMap:FileGuid → vector<EntityHandle>. 세대 검사가 죽은 인스턴스를 자동으로 걸러 P-c가 구조적으로 사라진다(P0의 임시 방어는 이때 제거).- 씬 저장·로드 왕복 복원(P-a): 저장은
m_prefabFileGuid+ 오버라이드 목록, 로드는 그 GUID로 템플릿을 잡고 인스턴스를 재등록.SceneManager.cpp:1109~1201의 주석 코드를 되살리는 것이 아니라 P1의 데이터 위에서 새로 쓴다.
-
⬜ P3 — 갱신을 비파괴로 (P1 이후)
- Destroy 후 재생성(P-f) 대신 컴포넌트 목록의 차집합만 적용 — 남는 컴포넌트는 인스턴스를 유지한 채 프로퍼티만 패치. 플레이 중 프리팹 갱신이 가능해진다.
-
⬜ P4 (선택) — 중첩 프리팹·베리언트 (P-i)
- 범위가 크고 에디터 UX 결정이 얽힌다. P1의 오버라이드 모델이 자리잡은 뒤에만 착수 판단. 착수하지 않아도 P0~P3의 가치는 온전하다.
판정: 프리팹은 회귀가 데이터 손상으로 나타나므로 왕복 검증이 필수다 — 씬 저장 →
재로드 → 인스턴스 연결·오버라이드 보존 확인을 각 슬라이스의 통과 조건으로 둔다.
현재 Tools/regression은 UI 전용이고 프리팹 시나리오가 없으므로 P0에서 만든다.
새 하네스를 짜지 않는다 — run-all.ps1이 쓰는 Academy_4Q.exe --script <파일> 구동에
프리팹 왕복 스크립트를 하나 얹고, 판정도 같은 방식(로그 프로브 + 종료 코드)을 쓴다.
P-a가 지금도 살아 있으므로 이 검사는 P0 시점에 실패로 시작하는 것이 정상이다 —
P2에서 초록으로 바뀌는 것이 트랙 P의 완료 신호다.
B·C 완료 시점에만 연다. 자가 구현(슬롯 테이블 + SoA 스토어)과 EnTT를 벤치로 비교:
- 채택 조건: ① 갱신·조회 벤치에서 자가 구현 대비 유의미한 우위 ② 리플렉션·직렬화 통합 비용이 슬라이스 2개 이하 ③ MSVC 유니티 빌드에서 심볼 충돌 없음(기존 함정).
- Flecs는 계층(
ChildOf)과 프리팹(IsA)이 언어 차원 관계로 내장되어 있다. 원래는 "기존 프리팹과 개념이 충돌한다"는 이유로 제외했는데, 트랙 P가 프리팹을 재설계하므로 그 제외 근거가 약해진다. 다만 순서가 중요하다 — P1의 오버라이드 모델이 자리잡기 전에 Flecs를 채택하면 남의 프리팹 개념에 우리 데이터를 맞추게 된다. 판단 시점은 P1~P3 완료 후로 못박고, 그때 P의 결과물과IsA모델을 나란히 놓고 비교한다. - 어느 쪽이든 A~C의 구조는 그대로 유효 — D는 저장소 구현 교체일 뿐이다.
파일 포맷 불변이 목표. 씬 YAML에는 지금처럼 m_index/m_parentIndex(정수)와
GUID를 기록한다. 세대는 런타임 전용 — 저장하지 않는다.
- 로드: 파일의 인덱스로 슬롯을 순서대로 채우며 세대 1로 시작 → 핸들 재구성. 로드 경로는 지금도 인덱스를 신뢰하므로 동작 변화 없음.
- 프리팹:
m_prefabFileGuid·FileGuid 체계는 건드리지 않는다.Prefab.cpp:169의 인덱스 재배치는 A0-2의SetParentIndex단일점을 쓰게 되었으므로 핸들 전환 시 자동 승계된다.- 트랙 P는 이 원칙의 유일한 예외다. P1이 인스턴스에 오버라이드 목록이라는 새 필드를 추가한다 — 즉 프리팹·씬 포맷이 늘어난다. 다만 읽기 호환은 유지한다: 목록이 없는 기존 파일은 "오버라이드 없음"으로 읽히고, 새 필드는 최초 저장 때 붙는다. 기존 206개 에셋을 일괄 변환하지 않는다(§3 트랙 P 참고).
- 반대로 지금 프리팹이 저장하는
m_prefabOriginal은 애초에 직렬화되지 않으므로 (§1.4 P-b) 잃을 호환이 없다 — 폐기해도 파일이 달라지지 않는다.
- 리플렉션:
[[Property]]표면은 유지.EntityHandle을 Meta 타입으로 등록하되 직렬화 시 index만 내보내는 커스텀 시리얼라이저 하나 추가(MetaGenerator 수정 불필요 — LifecycleRegistry가 옆 표를 택한 것과 같은 이유). - C# 세이브/로드 경계: ScriptObjectHandle 배치(uint32×2)가 유지되므로 관리 측 코드 변경 없음(A1-4에서 검증 항목으로 포함).
- 유니티 빌드 전이 include: 헤더를 옮기면 다른 TU가 조용히 깨진다. 슬라이스마다 전체 빌드가 판정 기준인 이유.
- 워크트리 동시 커밋: 작업 중 사용자가 같은 트리에 커밋한다. 커밋 전 HEAD
재확인, 래칫 allowlist
--update금지. - Scene.h ↔ GameObject.h 순환: 핸들 도입으로 오히려 완화된다(핸들은 전방 선언
불필요한 POD) — 단,
GameObject.inl의SceneObjectAt우회는 핸들 resolve로 대체할 때까지 유지. - 소비자 없는 출력 = 미완성 패스: B단계 명시 규칙. 스토어만 만들고 렌더 경로가 안 읽으면 그 슬라이스는 완료가 아니다.
- 두 핸들 체계의 공존 기간: A1-1~A1-3 동안 Index와 EntityHandle이 공존한다. 공존은 허용하되 새 코드가 Index를 보관하는 것은 금지(리뷰 체크 항목) — 래칫 카운트가 감시한다.
- DDOL: 씬 슬롯 바깥의 소유가 유일하게 남는 지점. A2에서 재등록 방식으로 명시 처리(§3 A2 참고). 여기를 얼버무리면 현재의 "옛 인덱스 유령" 버그가 핸들 시대에도 형태만 바꿔 살아남는다.
- 프리팹 회귀는 데이터 손상으로 나타난다: 컴파일도 통과하고 실행도 되는데 저장했다 열면 연결이 끊겨 있는 식이다 — 실제로 지금이 그 상태다(§1.4 P-a). 트랙 P의 모든 슬라이스는 저장 → 재로드 → 연결·오버라이드 보존을 통과 조건으로 둔다. 눈으로 보이는 화면만 확인하면 이 종류의 회귀를 놓친다.
- 숨은 사용처는 grep 한 번으로 안 잡힌다: A0-3에서
m_isEnabled접근을 세어봤을 때 206개 프리팹 데이터가 결과를 채워 코드 두 곳 (BehaviorTreeComponent·DecalComponent)이 잘려 나갔고, 빌드가 잡았다. 캡슐화 슬라이스는 데이터 파일을 제외한 소스 전수 검색으로 범위를 잡는다.
- 스크립팅 API 표면 변경 — Awake/Update·GetComponent·Instantiate 시그니처는 유지한다. 쿼리 기반 스크립팅 API 같은 표면 차별화는 코어 전환이 끝난 뒤의 별도 결정이다.
- 풀 아키타입 ECS 전환 — 리플렉션·에디터·프리팹·C# 바인딩 전면 재작성을 요구한다. D 게이트의 EnTT(희소집합)까지가 이 계획의 상한이다.
- GameObjectType enum 재설계 — UI/Canvas가 타입 enum에 있는 문제는 별도 결정(지난 세션 논의)이며, 이 계획과 독립적으로 진행 가능하다.
- 기존 프리팹 에셋 일괄 변환 — 206개를 스크립트로 훑지 않는다. 새 필드는 읽기 호환으로 흡수하고 저장할 때 자연히 붙는다(§4). 변환 스크립트는 그 자체가 데이터 손상 경로다.
- 중첩 프리팹·베리언트 — P4로 적어 두었지만 착수를 전제하지 않는다. 에디터 UX 결정이 얽혀 있어 P1~P3와 성격이 다르다.
SetParentID를 월드 보존으로 고치는 것 — A0-2에서 그 계산이 죽어 있었음을 확인했지만(§3 A0), 되살리면 현재 동작에 맞춰 저장된 씬·프리팹이 전부 틀어진다. 고칠지는 트랙 P의 왕복 검증 기반이 선 뒤에 판단한다.