design(dispatch): 사각지대 손 트레이싱 라운드 + Dispatch 인덱스 기반 전면 재설계
네 번째 세션: State<State<T>> 발견 방식을 서브에이전트로 코퍼스 전체에 반복해 Tag 참조 카운트(객체 identity 키잉)/Attribute 그룹 자기충돌 버그 발견·수정. Attribute 소유권 충돌 방지를 위해 처음엔 Dispatch.processAs/ retractSelfAndUnder 체크포인트 핸들러 패턴을 신설. 다섯 번째 세션: 체크포인트 패턴이 임시방편이라는 지적에서 출발해, chains의 핸들러 객체 identity 기반 추적 자체가 State<State<T>>를 UB로 만든 근본 원인이었음을 재확인 — 재귀 깊이 인덱스로 재설계(같은 키 재귀는 index+1, 다른 키 위임은 항상 1부터), Handler 계약을 process/retract 2-메소드에서 process가 자기 retract 클로저를 반환하는 1-메소드로 축소. 이걸로 State<State<T>>가 UB에서 정상 지원 대상으로 바뀌고, 전날 만든 체크포인트 패턴 전체가 불필요해져 archive로 이전. 여러 핸들러(StoreBind/Ref/Tag/Slot/ Attribute)의 private Relate 상태 저장소도 대거 정리됨. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0132hkscPQWa34iyHrHdoRiF
This commit is contained in:
parent
35c66d4cd0
commit
c33ae041b0
12 changed files with 933 additions and 529 deletions
|
|
@ -31,17 +31,17 @@
|
|||
| `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서). **[2026-08-12 세션 신설, 같은 날 후속 세션에서 강화]** "코드 스타일 — Luau 문법 관례" 절 신설 — `if-then-else`가 공식 Luau 문법임을 명문화(환각/오타로 오인해 `and`/`or`로 되돌리는 회귀 방지), `A and B or C` 삼항 관용구는 항상-truthy 예외도 없이 전면 금지로 강화(`bind-system-plan.md`의 `retractUnder` falsy-값 버그가 실사례). `const` 바인딩은 공식 문법이나 툴링 미성숙으로 지금은 채택 보류 |
|
||||
| `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식 |
|
||||
| `store-semantics.md` | Store는 부작용 허용이 기본. State는 Store 위의 조합 가능한 캐시 레이어로 실제로 필요함(2026-08-04 정정) — 온톨로지 핵심 메커니즘은 2026-08-04 2차 라운드에서 확정, 최신 상세는 `base/bind-system-plan.md` |
|
||||
| `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`). **[2026-08-12 세션 후속]** `retractUnder`의 `A and B or C` 삼항 관용구 버그(`v`가 `false`일 때 `nil`로 새던 것)를 `if-then-else`로 수정한 게 계기가 되어 `and`/`or` 삼항 전면 금지 규칙으로 발전(`architecture.md` "코드 스타일" 절). **[2026-08-12 열일곱 번째 세션]** 우선순위 동률/매치 실패 처리(`HANDLER_PRIORITY_*` 상수+디버그 동률 감지, 매치실패는 즉시 error) 확정, `store.key` 레코드 필드 타이핑이 Luau `type function`으로 가능함을 스케치로 확인(`pre-implementation-audit.md` 1-3/1-4/1-10 해소). **[2026-08-12 스무 번째 세션]** Ref 사용 관례 명문화 — React `useRef`급 스코프 감각(만든 컴포넌트 자신이 쓰거나 자식에게 넘기는 용도, 경계 밖 반출·전역 장기 보관은 비권장). **[2026-08-12 스물한 번째 세션]** `:With`가 `Tag`/`Modifier`의 `:` clone 체이닝과 겉보기엔 같은 문법이지만 실제로는 정반대(clone 아니라 매번 새 State 노드)라는 혼동 경고 추가, `Compute`가 `-ed`(`Computed`)가 아닌 이유 절 신설(quad 자기 관례상 `Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값"을 선점해 lazy한 State에 재사용하면 충돌). **[2026-08-13 세션, 두 번째]** `State<State<T>>`(store가 emit하는 값 자체가 또 State/Source)가 같은 `(inst,k)`에 같은 핸들러를 중복 push시켜 `retractUnder`의 첫-매치 cutoff가 안쪽 자신을 잘못 retract하는 실제 체인 파손 버그로 확인됨(손 트레이싱, `luau-test/04`가 no-op `retract` 스텁 때문에 이 증상을 못 잡던 사각지대였음도 같이 발견) — `Dispatch.process`에 중복 핸들러 즉시 error 가드 추가, "동일한 재귀적 디스패치로 처리 가능"이라던 낙관적 서술과 "Store가 Store를 저장 가능한가" 절도 정정 |
|
||||
| `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`). **[2026-08-12 세션 후속]** `retractUnder`의 `A and B or C` 삼항 관용구 버그(`v`가 `false`일 때 `nil`로 새던 것)를 `if-then-else`로 수정한 게 계기가 되어 `and`/`or` 삼항 전면 금지 규칙으로 발전(`architecture.md` "코드 스타일" 절). **[2026-08-12 열일곱 번째 세션]** 우선순위 동률/매치 실패 처리(`HANDLER_PRIORITY_*` 상수+디버그 동률 감지, 매치실패는 즉시 error) 확정, `store.key` 레코드 필드 타이핑이 Luau `type function`으로 가능함을 스케치로 확인(`pre-implementation-audit.md` 1-3/1-4/1-10 해소). **[2026-08-12 스무 번째 세션]** Ref 사용 관례 명문화 — React `useRef`급 스코프 감각(만든 컴포넌트 자신이 쓰거나 자식에게 넘기는 용도, 경계 밖 반출·전역 장기 보관은 비권장). **[2026-08-12 스물한 번째 세션]** `:With`가 `Tag`/`Modifier`의 `:` clone 체이닝과 겉보기엔 같은 문법이지만 실제로는 정반대(clone 아니라 매번 새 State 노드)라는 혼동 경고 추가, `Compute`가 `-ed`(`Computed`)가 아닌 이유 절 신설(quad 자기 관례상 `Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값"을 선점해 lazy한 State에 재사용하면 충돌). **[2026-08-13 세션, 두 번째]** `State<State<T>>`(store가 emit하는 값 자체가 또 State/Source)가 같은 `(inst,k)`에 같은 핸들러를 중복 push시켜 `retractUnder`의 첫-매치 cutoff가 안쪽 자신을 잘못 retract하는 실제 체인 파손 버그로 확인됨(손 트레이싱, `luau-test/04`가 no-op `retract` 스텁 때문에 이 증상을 못 잡던 사각지대였음도 같이 발견) — `Dispatch.process`에 중복 핸들러 즉시 error 가드 추가, "동일한 재귀적 디스패치로 처리 가능"이라던 낙관적 서술과 "Store가 Store를 저장 가능한가" 절도 정정. **[2026-08-13 세션, 네 번째]** 사각지대 손 트레이싱 라운드에서 `isHandlable` 필드를 선택적으로 허용(생략하면 스캔에 안 걸림)하고, 그런 "체크포인트" 핸들러를 명시적으로 체인에 꽂는 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder`(target 자신 포함 철거) 신설 — `attribute-plan.md`의 그룹/직접쓰기 이름 소유권 충돌을 별도 레지스트리 없이 기존 재진입 가드로 흡수하는 데 씀. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 위 processAs/retractSelfAndUnder 대체]** `chains`를 핸들러 객체 identity가 아니라 **재귀 깊이 인덱스**로 추적하도록 재설계 — `Dispatch.process(inst,k,v,index)`가 핸들러 호출 *전에* 그 인덱스 점유 여부를 체크(핸들러 부작용 낭비 없음), `process`는 이제 `retract` 필드 대신 자기 retract 클로저(`(hintValue)->()`)를 반환. 같은 키 재귀는 `index+1`, 다른 키 위임은 항상 `1`부터 — 이걸로 `State<State<T>>`가 UB에서 정상 지원 대상으로 재정정됨(각 재귀 단계가 다른 슬롯을 쓰니 identity 충돌 자체가 없어짐), `retractUnder`/`retractSelfAndUnder`도 `Dispatch.retractFrom(inst,k,index,v)` 하나로 통합(자기 포함/미만은 호출자가 넘기는 인덱스로 표현)되며 체크포인트 패턴 자체가 불필요해짐(`archive/checkpoint-handler-pattern-reversed.md`). 계기: `AttributeGroupHandler` 소유권 버그를 체크포인트로 고치다, 그 근본 원인(identity 기반 추적)을 되짚은 사용자 지적 |
|
||||
| `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정 |
|
||||
| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot<T>()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn<UD>(item, index: number, offset: Source<number>, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot<Instance>`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/bind-system-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State<T>`/`Source<T>`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정. **[2026-08-12 열두 번째 세션]** 소유권 판정을 위치별 relate 비교에서, Slot 자신이 지금 어느 `inst`에 바인딩됐는지 직접 추적하는 `slotOwner`(slot→inst)로 전환(같은 Slot이 동시에 다른 위치에 마운트되는 경우까지 잡기 위함) — `owner==inst`면 emit 전파로 무시, 다른 inst면 즉시 error. **[2026-08-12 열세 번째 세션]** `slotOwner`/`kSlotMap`이 서로를 강하게 참조하는 두-`Relate` 상호 GC 순환 발견·수정 — 둘 다 `SetWeak`로 낮추고 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일(`attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가, `destroySlotTree`에 짝인 `unbindLifetime` 추가). **[2026-08-12 열네 번째 세션]** 위 순환이 Luau에 ephemeron이 없어 실제로 GC 안 되는 게 공식 문서(luau.org/compatibility)로 확인됨 — "혹시 몰라서"가 아니라 확정된 필수 조치로 격상(`relate-plan.md`에 일반 규칙으로도 승격). **[2026-08-12 열다섯 번째 세션]** `Slot:Splice(index, removeCount, ...newElements)` CRUD 신설(구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, `newElements`는 의도적으로 vararg 유지 — `Tag:Added`의 `string|{string}` 전환과는 다른 이유). **[2026-08-12 열여섯 번째 세션]** `slotOwner`를 top-level/nested 이중 마운트 gap까지 잡는 `elementOwner`로 일반화, `bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 transitively 생존) |
|
||||
| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot<T>()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn<UD>(item, index: number, offset: Source<number>, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot<Instance>`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/bind-system-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State<T>`/`Source<T>`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정. **[2026-08-12 열두 번째 세션]** 소유권 판정을 위치별 relate 비교에서, Slot 자신이 지금 어느 `inst`에 바인딩됐는지 직접 추적하는 `slotOwner`(slot→inst)로 전환(같은 Slot이 동시에 다른 위치에 마운트되는 경우까지 잡기 위함) — `owner==inst`면 emit 전파로 무시, 다른 inst면 즉시 error. **[2026-08-12 열세 번째 세션]** `slotOwner`/`kSlotMap`이 서로를 강하게 참조하는 두-`Relate` 상호 GC 순환 발견·수정 — 둘 다 `SetWeak`로 낮추고 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일(`attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가, `destroySlotTree`에 짝인 `unbindLifetime` 추가). **[2026-08-12 열네 번째 세션]** 위 순환이 Luau에 ephemeron이 없어 실제로 GC 안 되는 게 공식 문서(luau.org/compatibility)로 확인됨 — "혹시 몰라서"가 아니라 확정된 필수 조치로 격상(`relate-plan.md`에 일반 규칙으로도 승격). **[2026-08-12 열다섯 번째 세션]** `Slot:Splice(index, removeCount, ...newElements)` CRUD 신설(구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, `newElements`는 의도적으로 vararg 유지 — `Tag:Added`의 `string|{string}` 전환과는 다른 이유). **[2026-08-12 열여섯 번째 세션]** `slotOwner`를 top-level/nested 이중 마운트 gap까지 잡는 `elementOwner`로 일반화, `bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 transitively 생존). **[2026-08-13 세션]** `releaseOwner`가 소유권 불일치를 조용히 무시하던 걸 즉시 error로 강화, `bindLifetime`을 `attachSlot`의 조건 분기에서 `SlotHandler.process`(Handler 층위)로 이동해 `unbindLifetime`이 이미 `SlotHandler.retract`에 있는 것과 대칭을 맞춤. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `Dispatch`가 핸들러 identity 대신 인덱스로 재추적되며 `SlotHandler.process`가 `retract` 별도 필드 없이 자기 retract 클로저를 반환하는 계약으로 전환 — `kSlotMap`이 완전히 불필요해짐(어느 `process` 호출이 반환한 클로저든 `slotValue`/`inst`를 동일하게 캡처해 대칭적으로 동작하므로), `base/bind-system-plan.md` "Dispatch 체인" 절 참고 |
|
||||
| `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정). **[2026-08-12 열일곱 번째 세션]** `table.clone`이 메타테이블을 참조로 공유한다는 핵심 전제(M7 "클래스별 코드 없이 제네릭 `__index` 하나로 충분" 설계의 근거)가 실제 Luau 동작으로 확인됨(`pre-implementation-audit.md` 1-11 해소). Property에 Attribute식 이름 소유권 레지스트리를 적용하는 안은 검토 후 기각(엔진이 정한 유한 프로퍼티 이름 집합은 전용 키를 못 만들어 소유권 판정 자체가 성립 안 함 — Property가 override 우선순위를 쓰는 이유) |
|
||||
| `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 |
|
||||
| `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, Source가 State를 구조적으로 만족 — modifier/Ref 컴포넌트 경계 통과까지 전부 확정, 남은 건 API 이름뿐. **[2026-08-07 정리]** 폐기된 `StoreSource` 프록시 설계로의 역전 이력은 본문에서 빼고 `archive/store-source-proxy-reversed.md` 포인터로 압축 |
|
||||
| `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게, State 마일스톤(M3)과 함께 개발. 메커니즘+이름 확정 |
|
||||
| `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, state?)` — `state` 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료 |
|
||||
| `ui-shorthand-plan.md` | **[2026-08-07 `research/`에서 승격]** `UICorner`/`UIPadding`/`UIScale` 인라인 편의 키 — 이름(v1 `Corner`/`PaddingAll`/`Scale`에서 Modifier 필드명과 안 겹치게 `UI` 프리픽스로 확정)·메커니즘(Handler)·패키지 배치(quad-roblox 코어)·store-bind 가능성까지 전부 확정. 이미지 라운드 트릭(`RoundSize`)은 드롭 — `archive/ui-shorthand-roundsize-dropped.md` 참고. `v=nil`이면 `process` 자신이 만든 자식 제거(`retract` 아님) |
|
||||
| `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계, 2026-08-12 열한 번째 세션 메커니즘 정정]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. `retract`가 이전 Tag가 걸었던 이름을 이름별 참조 카운트 맵에서 빼고(다른 위치가 겹쳐 쓰면 실제 `RemoveTag`는 skip), `process`가 새 Tag의 이름을 등록 — 여러 위치가 같은 이름을 겹쳐 가져도(웹 `className`류 합집합) 안전. 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md`, 구 `assert(v==nil)` 메커니즘은 `archive/retract-always-fires-reversed.md`. **[2026-08-12 열다섯 번째 세션]** `Added`/`Removed`가 vararg가 아니라 `string | {string}`으로 정정 — `table.unpack`이 인자 목록 tail 위치에서만 완전히 펼쳐지는 Lua 문법 제약 때문에 여러 개의 독립된 동적 이름 테이블을 한 vararg 호출로 못 합치는 경우가 생김이 발견됨, `Tag(...)` 생성자 자체는 정적 리터럴 호출이라 vararg 유지 |
|
||||
| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey<T> "Name"]`(구 `Attribute<T>`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거). **[2026-08-12 열 번째 세션]** 그룹/직접 쓰기가 같은 이름을 동시에 관리하는 충돌을 막기 위해 그룹은 공개 캐시 대신 `rawNew(name)` 전용 키+소유권 `Relate`로 전환. **[열한 번째 세션]** `retract`가 store 재발행마다 항상 불린다는 정정에 맞춰 `AttributeKeyHandler.retract`를 손봄(이 시점엔 `v==nil` 가드 버전 — 아래 열여섯 번째 세션에서 최종 재정정됨), 그룹의 "남아있는 이름" 위임도 매번 `retractUnder`를 먼저 부르도록 정정(체인 누수 방지). **[2026-08-12 열여섯 번째 세션, 최종 재정정]** `retract`는 완전 no-op으로 굳어짐(`SetAttribute`는 오직 `process(inst,k,nil)`에서만) — Attribute는 명시적 `None`/`nil`로만 지워지고, 그룹 diff나 컴포넌트 언마운트로 이름이 조용히 사라져도 값은 자동으로 안 지워짐(`Ref`의 "Destroy 무관, 정리는 명시적으로" 철학과 통일), 단 사라진 이름의 *구독*은 끊어 자원 누수는 막음 — 위 "v==nil 가드" 버전은 이걸로 폐기 |
|
||||
| `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계, 2026-08-12 열한 번째 세션 메커니즘 정정]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. `retract`가 이전 Tag가 걸었던 이름을 이름별 참조 카운트 맵에서 빼고(다른 위치가 겹쳐 쓰면 실제 `RemoveTag`는 skip), `process`가 새 Tag의 이름을 등록 — 여러 위치가 같은 이름을 겹쳐 가져도(웹 `className`류 합집합) 안전. 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md`, 구 `assert(v==nil)` 메커니즘은 `archive/retract-always-fires-reversed.md`. **[2026-08-12 열다섯 번째 세션]** `Added`/`Removed`가 vararg가 아니라 `string | {string}`으로 정정 — `table.unpack`이 인자 목록 tail 위치에서만 완전히 펼쳐지는 Lua 문법 제약 때문에 여러 개의 독립된 동적 이름 테이블을 한 vararg 호출로 못 합치는 경우가 생김이 발견됨, `Tag(...)` 생성자 자체는 정적 리터럴 호출이라 vararg 유지. **[2026-08-13 세션]** 참조 카운트 `holders`가 Tag 객체 identity로 키잉돼 있어서 같은 Tag 객체를 여러 위치에서 재사용하면(immutable이라 흔한 관례) 한 위치만 retract돼도 다른 위치가 쓰는 태그가 지워지는 실제 버그 발견·수정 — holders를 위치(`k`) 기준으로 재키잉, `oldv==newv`면 retract 스킵하는 최적화도 추가. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `TagHandler.process`가 자기 retract 클로저를 반환하는 계약으로 전환되며 `kTagMap`(위치별 마지막 Tag)이 완전히 불필요해짐(클로저가 `v`를 직접 캡처) — `tagNameMap`(이름별 위치 집합)만 남음, `base/bind-system-plan.md` "Dispatch 체인" 절 참고 |
|
||||
| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey<T> "Name"]`(구 `Attribute<T>`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거). **[2026-08-12 열 번째 세션]** 그룹/직접 쓰기가 같은 이름을 동시에 관리하는 충돌을 막기 위해 그룹은 공개 캐시 대신 `rawNew(name)` 전용 키+소유권 `Relate`로 전환. **[열한 번째 세션]** `retract`가 store 재발행마다 항상 불린다는 정정에 맞춰 `AttributeKeyHandler.retract`를 손봄(이 시점엔 `v==nil` 가드 버전 — 아래 열여섯 번째 세션에서 최종 재정정됨), 그룹의 "남아있는 이름" 위임도 매번 `retractUnder`를 먼저 부르도록 정정(체인 누수 방지). **[2026-08-12 열여섯 번째 세션, 최종 재정정]** `retract`는 완전 no-op으로 굳어짐(`SetAttribute`는 오직 `process(inst,k,nil)`에서만) — Attribute는 명시적 `None`/`nil`로만 지워지고, 그룹 diff나 컴포넌트 언마운트로 이름이 조용히 사라져도 값은 자동으로 안 지워짐(`Ref`의 "Destroy 무관, 정리는 명시적으로" 철학과 통일), 단 사라진 이름의 *구독*은 끊어 자원 누수는 막음 — 위 "v==nil 가드" 버전은 이걸로 폐기. **[2026-08-13 세션, 전면 재정정]** `rawNew`+`owners` 수동 레지스트리 방식이 "그룹이 이름을 놓았다 다시 포함하면 자기 자신과 충돌"하는 실제 버그로 확인됨 — `AttributeGroupKeyHandler`라는 `isHandlable` 없는 순수 체크포인트 핸들러를 `Dispatch.processAs`로 명시 push하고 `Dispatch.retractSelfAndUnder`로 통째 철거하는 방식으로 전면 재설계, 소유권 충돌 감지도 별도 레지스트리 없이 기존 재진입 가드가 대신 잡아줌(`bind-system-plan.md` 참고). `AttributeKeyHandler`는 다시 완전 무상태로 단순화됨. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 체크포인트조차 불필요해짐]** `Dispatch`가 인덱스 기반으로 재설계되며 `AttributeGroupKeyHandler`/`processAs`/`retractSelfAndUnder`를 전부 걷어냄 — 그룹이 그냥 공개 `AttributeKey(name)`으로 항상 인덱스 1부터 `Dispatch.process`/`retractFrom`을 직접 부르면 끝(점유 체크 자체가 소유권 충돌 감지), `groupState` Relate도 필요 없어짐(반환 클로저가 이름 집합을 직접 캡처) — 중간 버전은 `archive/checkpoint-handler-pattern-reversed.md` |
|
||||
| `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)` — `GetPropertyChangedSignal` 바인딩 전용 DI 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State<function>`은 기존 이벤트 store-bind 메커니즘 재사용. **[2026-08-11 아홉 번째 세션 후속]** `AttributeKey`와 동일한 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 |
|
||||
| `relate-plan.md` | **[2026-08-08 신설]** `Relate` — `inst`를 weak 키로 하는 범용 릴레이션 프리미티브(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`, 비싱글톤 생성자). 구 `base.perInstanceState(inst)` placeholder를 대체·정식 승격, `lifecycle-pattern.md`의 `bindLifetime`/`canExecute`가 그 위에 얹힘. **[2026-08-12 열세/열네 번째 세션]** 서로 다른 두 `Relate`가 서로의 키를 상대방 값으로 강하게 붙잡는 상호 순환 패턴 경고 신설 — Luau에 ephemeron 테이블이 없어(공식 확인, luau.org/compatibility) 이런 순환은 실제로 GC가 안 됨, `Slot`의 `kSlotMap`/`slotOwner`가 실제 사례이자 수정 사례 |
|
||||
| `tween-plan.md` | **[2026-08-12 세션, `research/`에서 승격]** 값-레벨 `Tween<T>` 래퍼(PropertyHandler가 소비, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`). 3-상태 릴레이션 슬롯(`{Tween,Value}\|true\|nil`), `T'=T\|Tween<T>` 타입 치환. 옵션 값 모양은 `Info: TweenInfo?` 우선+편의 필드 폴백, override는 `Tween.Cancel`(기본)/`Tween.Finish` 2값. `Animate(info)`는 `Tween` opts를 `T\|State<T>`로 받아 `:Apply`로 꽂는 sugar. 자연완료 시 per-instance 북키핑은 정리 안 해도 됨으로 확정(목표값 도달 상태라 부작용 없음, Completed 이벤트 구독 장치는 오버엔지니어링으로 판단). `initValue`는 사용자가 직접 처리(에이전트 범위 제외) |
|
||||
|
|
@ -87,6 +87,7 @@
|
|||
| `tween-special-bind-key-reversed.md` | **[역전됨, 2026-08-10 신설]** 구 Tween 모델(`[Tween(key,tweenData...)] = storeValue` 특수 bind key, 우선순위 최상위 Dispatch 핸들러) — 값-레벨 `Tween<T>` 래퍼 모델로 완전히 대체됨(`base/tween-plan.md`) |
|
||||
| `onchange-per-property-codegen-rejected.md` | **[기각됨, 2026-08-10 신설]** `OnChange.PropertyName` 프로퍼티별 정적 코드 생성 — Attribute의 정적 지름길과 달리 (클래스 수 × 프로퍼티 수) 규모로 폭발해 기각, `OnChange(name)` 단일 팩토리로 대체 |
|
||||
| `retract-always-fires-reversed.md` | **[역전됨, 2026-08-12 열한 번째 세션 신설]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff" — 실제로는 `retract`가 store 재발행마다 항상 불림(핸들러 타입 무관). `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류 위에서 설계돼 있었음이 드러나 한 세션에 전부 정정 |
|
||||
| `checkpoint-handler-pattern-reversed.md` | **[역전됨, 2026-08-13 다섯 번째 세션 신설]** `AttributeGroupHandler`의 이름 소유권 충돌을 고치려고 만든 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder` 체크포인트 핸들러 패턴(같은 날 네 번째 세션 신설) — `chains`를 핸들러 identity가 아니라 재귀 깊이 인덱스로 추적하는 더 근본적인 재설계로 대체되며 같은 날 바로 불필요해짐. `State<State<T>>`도 이 재설계로 UB에서 정상 지원 대상으로 바뀜 |
|
||||
|
||||
## 참고
|
||||
|
||||
|
|
|
|||
95
.claude/archive/checkpoint-handler-pattern-reversed.md
Normal file
95
.claude/archive/checkpoint-handler-pattern-reversed.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# [역전됨] `Dispatch.processAs`/`Dispatch.retractSelfAndUnder` 체크포인트 핸들러 패턴
|
||||
|
||||
**상태**: 역전됨 — 2026-08-13 네 번째 세션에 신설, 같은 날 다섯 번째
|
||||
세션에 `chains`를 핸들러 객체 identity가 아니라 **인덱스**로 추적하는
|
||||
더 근본적인 재설계로 대체되며 통째로 불필요해짐. 원문(신설 당시
|
||||
`base/bind-system-plan.md`에 있던 그대로) + 역전 이유를 여기 보존.
|
||||
|
||||
## 신설 배경 (네 번째 세션)
|
||||
|
||||
`base/attribute-plan.md`의 그룹(`Attribute(...)`)이 여러 이름을 기존
|
||||
단일 키 `AttributeKey` 경로에 재귀 위임하면서, 예전엔 `rawNew(name)`로
|
||||
그룹 전용 키 객체를 만들고 `owners`라는 별도 `Relate` 레지스트리로
|
||||
"누가 이 이름을 관리 중인가"를 수동 추적했음 — 이 방식이 "그룹이 이름을
|
||||
놓았다 나중에 같은 그룹이 다시 그 이름을 포함하면 자기 자신과 충돌"하는
|
||||
실제 버그로 이어져(소유권 반납이 `process`의 `v==nil` 분기에만 있어서,
|
||||
그룹이 이름을 통째로 놓는 경로는 그 분기를 안 타서 옛 키가 영원히 남음),
|
||||
전면 재설계됨.
|
||||
|
||||
## 원문 (신설 당시 pseudocode 그대로)
|
||||
|
||||
```lua
|
||||
function Dispatch.processAs(inst, k, v, handler)
|
||||
-- getHandler 스캔을 건너뛰고 handler를 직접 지정 — isHandlable이
|
||||
-- 없는(스캔 자체에 안 걸리는) 체크포인트 핸들러 전용 진입점.
|
||||
-- push 직전 중복 검사는 Dispatch.process와 완전히 동일(같은 재진입 가드).
|
||||
local list = chains:GetStrong(inst, k) or {}
|
||||
for _, existing in list do
|
||||
if existing == handler then
|
||||
error("Dispatch: handler already active for this (inst,k) — re-entrant processAs")
|
||||
end
|
||||
end
|
||||
table.insert(list, handler)
|
||||
chains:SetStrong(inst, k, list)
|
||||
handler.process(inst, k, v)
|
||||
end
|
||||
|
||||
function Dispatch.retractSelfAndUnder(inst, k, target, v)
|
||||
-- retractUnder와 동일하되 target "이하"(자기 자신 포함)까지 지움 —
|
||||
-- cutoff를 target의 인덱스가 아니라 그 한 칸 앞으로 잡는 것만 다름.
|
||||
local list = chains:GetStrong(inst, k)
|
||||
if not list then return end
|
||||
local cutoff = 0
|
||||
if target then
|
||||
for i, h in list do if h == target then cutoff = i - 1 break end end
|
||||
end
|
||||
for i = #list, cutoff + 1, -1 do
|
||||
list[i].retract(inst, k, if i == cutoff + 1 then v else nil)
|
||||
list[i] = nil
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**쓰는 쪽 패턴**: 위임하는 핸들러(`AttributeGroupHandler`)가 자기 전용
|
||||
체크포인트 싱글톤(`AttributeGroupKeyHandler`, `isHandlable` 없음, `process`는
|
||||
가공 없이 `Dispatch.process(inst,k,v)`로 그대로 전달해 실제 매치는 정상
|
||||
스캔에 맡김, `retract`는 no-op — 자기 자원이 없는 순수 마커)을
|
||||
`Dispatch.processAs(inst,k,v,checkpoint)`로 체인 맨 위에 꽂고, 나중에 그
|
||||
이름을 완전히 정리할 때 `Dispatch.retractSelfAndUnder(inst,k,checkpoint,v)`
|
||||
한 번으로 체크포인트 자신을 포함해 그 아래 전부를 정리.
|
||||
|
||||
당시 장점으로 꼽았던 것: 소유권 충돌 감지가 기존 "같은 (inst,k)에 같은
|
||||
핸들러 재사용 시 error" 가드(`State<State<T>>` 가드와 동일 메커니즘)에
|
||||
공짜로 얹힘 — 별도 `owners` 레지스트리 불필요.
|
||||
|
||||
## 역전 이유 (다섯 번째 세션, 같은 날)
|
||||
|
||||
사용자가 근본 문제를 다시 짚음: `chains`가 핸들러 **객체 identity**로
|
||||
포지션을 추적하는 것 자체가 `State<State<T>>`가 UB여야 했던 원인이었고
|
||||
(같은 싱글톤이 재귀로 스스로와 매치되면 identity로 구분 불가), 체크포인트
|
||||
패턴은 그 문제를 Attribute 한정으로 우회한 것일 뿐 근본 해법이 아니었음.
|
||||
|
||||
대신 `chains`를 명시적 **재귀 깊이 인덱스**로 추적하도록 바꾸면(같은 키
|
||||
재귀는 `index+1`, 다른 키로 위임은 `index=1`부터) 두 가지가 동시에
|
||||
풀림:
|
||||
|
||||
1. `State<State<T>>`가 UB에서 **정상 지원 대상**으로 바뀜 — 각 재귀
|
||||
단계가 서로 다른 인덱스를 쓰므로 객체 identity 충돌 자체가 발생하지
|
||||
않음(순환 참조만 여전히 UB로 남음, 기존 "순환은 UB" 원칙과 같은 급).
|
||||
2. Attribute 그룹은 체크포인트 마커 없이 `Dispatch.process(inst,
|
||||
AttributeKey(name), source, 1)`을 직접 부르면 됨 — "인덱스 1이 이미
|
||||
점유돼 있는가"라는 occupancy 체크가 소유권 충돌 감지를 그대로
|
||||
대신함, 별도 마커 객체·`processAs`·`retractSelfAndUnder` 전부 불필요.
|
||||
|
||||
`retractSelfAndUnder`가 `retractUnder`와 딱 하나(cutoff를 target
|
||||
포함이냐 미만이냐)만 다른 거의 중복 함수였다는 것도 별도로 지적됨 —
|
||||
새 모델의 `Dispatch.retractFrom(inst,k,index,v)` 하나가 "자기 포함
|
||||
철거"(`index` 그대로 넘김)와 "자기 아래만 철거"(`index+1` 넘김) 둘 다
|
||||
호출자의 인덱스 선택만으로 표현하므로 두 함수로 쪼갤 이유 자체가
|
||||
없어짐.
|
||||
|
||||
**교훈**: 기존 메커니즘 위에 새 진입점을 얹어 특정 사례(Attribute)를
|
||||
푸는 것보다, 그 문제를 만든 더 근본적인 표현(객체 identity 기반 추적)을
|
||||
먼저 의심하는 게 나을 때가 있음 — 오늘 하루 안에서 신설과 역전이 바로
|
||||
이어진 사례. 최신 설계는 `base/bind-system-plan.md`의 "Dispatch 체인"
|
||||
절 참고.
|
||||
|
|
@ -146,8 +146,8 @@ quad/
|
|||
│ ├── Tween.luau # 값 타입만(`Tween(opts)` 팩토리, `isTween`/`TweenTag`) — 엔진 무관, 독립 Dispatch 핸들러 아님. 실제 애니메이션 처리는 quad-roblox Handlers/Property.luau 내부 분기(`base/tween-plan.md`, 2026-08-10 세션 재설계)
|
||||
│ ├── Effect.luau # `Effect(fn, state?)` — state 없으면 설치1회+leaf사망시 정리, 있으면 State.Observer를 조합해 재실행(`base/effect-plan.md`)
|
||||
│ ├── Dispatch/
|
||||
│ │ ├── init.luau # process/retract 엔진, isHandlable 우선순위 스캔, `chains`(inst,k별 핸들러 체인)+`retractUnder`(`bind-system-plan.md` "Dispatch 체인" 절, 2026-08-08 세 번째 세션)
|
||||
│ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process/retract)
|
||||
│ │ ├── init.luau # process 엔진(반환값=retract 클로저), isHandlable 우선순위 스캔, `chains`(inst,k별 인덱스 배열)+`retractFrom`(`bind-system-plan.md` "Dispatch 체인" 절, 2026-08-08 세 번째 세션 신설, 2026-08-13 다섯 번째 세션 인덱스 기반 전면 재설계)
|
||||
│ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process — process가 자기 retract 클로저를 반환)
|
||||
│ │ ├── StoreBind.luau # store 값 재귀 재실행 로직(범용, 엔진 무관)
|
||||
│ │ ├── Leaf.luau # (i:number, v=Ref/Observer/PreRef) children-array leaf 매칭 Handler, StoreBind와 같은 층위(범용/엔진무관, 2026-08-08 두 번째 세션 확정)
|
||||
│ │ └── Slot.luau # add/remove/clear 재조정 로직(추상 자식 참조 기준)
|
||||
|
|
@ -166,8 +166,8 @@ quad/
|
|||
│ ├── Event.luau # ReflectionService 기반 자동 판별
|
||||
│ ├── OnChange.luau # `OnChange(name)` DI 키 팩토리+Handler, `GetPropertyChangedSignal` 바인딩 + 이름별 weak 캐시(`AttributeKey`와 동일 기법, `base/onchange-plan.md`, 2026-08-10 세션)
|
||||
│ ├── AttributeKey.luau # 단일 키(`AttributeKey<<T>>(name)`/`BooleanAttribute`류) DI 키 팩토리+Handler, `SetAttribute`/`None` 지우기 + 이름별 weak 캐시(동등성 보장, `base/attribute-plan.md` "동등성" 절)
|
||||
│ ├── Attribute.luau # 그룹(`Attribute(store1, store2, ...)`) process/retract — 이름 집합 diff만 자체 로직, 실제 `SetAttribute`/구독은 메모이즈된 `AttributeKey(name)`로 `Dispatch.process`/`retractUnder`에 재귀 위임(단일 키 경로 재사용, 중복 구현 없음) — 값 타입/API는 quad-base Attribute.luau(`base/attribute-plan.md`)
|
||||
│ ├── Tag.luau # CollectionService 글루만(process/retract) — 값 타입/API는 quad-base Tag.luau(`base/tag-plan.md`)
|
||||
│ ├── Attribute.luau # 그룹(`Attribute(store1, store2, ...)`) process — 이름 집합 diff만 자체 로직(반환 클로저에 캡처, 별도 Relate 불필요), 실제 `SetAttribute`/구독은 공개 `AttributeKey(name)`으로 항상 인덱스 1부터 `Dispatch.process`/`retractFrom`에 재귀 위임(단일 키 경로 재사용, 중복 구현 없음) — 값 타입/API는 quad-base Attribute.luau(`base/attribute-plan.md`)
|
||||
│ ├── Tag.luau # CollectionService 글루만(process, 반환 클로저가 정리 담당) — 값 타입/API는 quad-base Tag.luau(`base/tag-plan.md`)
|
||||
│ ├── Slot.luau # base Slot 재조정 로직의 실제 적용/해제(Instance Parent 조작)
|
||||
│ └── InstanceChild.luau # k:number, v:Instance — 중첩 인스턴스 자식(예: Frame { Frame {} })
|
||||
├── Animate.luau # `Animate(info)` 편의 콤비네이터 — `factory(self)->State`, `:Apply`로 붙임(내부는 `:Compute`/`Tween{...}` 조합), base 프리미티브 아님(`base/tween-plan.md`)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,15 @@
|
|||
**상태**: base — 단일 키 메커니즘/`None`/`retract` 동작과 타입 파라미터화는
|
||||
전부 확정(2026-08-09 열한 번째 세션, **2026-08-12 세션 후속에서
|
||||
`retract` 완전 no-op화 + 그룹 청소 정책 전면 재정정 — 아래 "메커니즘"/
|
||||
"그룹 `Attribute(...)`" 절이 최신**). **[2026-08-11 아홉 번째 세션 추가]**
|
||||
"그룹 `Attribute(...)`" 절이 최신**). **[2026-08-13 세션, 하루 안에서
|
||||
두 차례 재설계]** 그룹/직접 쓰기 이름 충돌 방지 방식이 `rawNew`+`owners`
|
||||
수동 레지스트리 → `AttributeGroupKeyHandler` 체크포인트(`Dispatch.
|
||||
processAs`/`retractSelfAndUnder`) → **최종적으로 `Dispatch` 자체의
|
||||
인덱스 기반 재설계(`base/bind-system-plan.md` "Dispatch 체인" 절)에
|
||||
올라타 체크포인트도 필요 없어짐**(공개 `AttributeKey(name)`으로 항상
|
||||
인덱스 1에 직접 위임, 점유 체크가 소유권 충돌 감지를 대신함) — 아래
|
||||
"이름 소유권"/"메커니즘" 절이 최신, 중간 버전은 `archive/
|
||||
checkpoint-handler-pattern-reversed.md`에 보존. **[2026-08-11 아홉 번째 세션 추가]**
|
||||
Store 여러 개를 한 번에 attribute로 묶어 바인드하는 그룹 `Attribute(...)`
|
||||
프리미티브 신설, 이름 충돌 방지를 위해 기존 단일 키 생성자를
|
||||
`Attribute<<T>>` → `AttributeKey<<T>>`로 리네임(잠정 확정 — 최종 이름은
|
||||
|
|
@ -105,8 +113,8 @@ end
|
|||
|
||||
타입 파라미터화 이름과 무관하게 런타임 동작은 확정:
|
||||
|
||||
- `process(inst, k, v)` — `inst:SetAttribute(name, v)`가 사실상 전부,
|
||||
**`v`가 뭐든(실제 값이든 `nil`이든) 무조건 그대로 호출** — 일반
|
||||
- `process(inst, k, v, index)` — `inst:SetAttribute(name, v)`가 사실상
|
||||
전부, **`v`가 뭐든(실제 값이든 `nil`이든) 무조건 그대로 호출** — 일반
|
||||
프로퍼티 핸들러와 완전히 동일한 무조건 set. **Attribute는 `None`의
|
||||
가장 깔끔한 사례** — Roblox API 자체가 `SetAttribute(name, nil)`을
|
||||
"그 Attribute 엔트리를 지운다"는 뜻으로 네이티브 지원하므로, `None →
|
||||
|
|
@ -114,28 +122,28 @@ end
|
|||
도착했을 때 handler가 **아무 특별 처리도 없이** `inst:SetAttribute(name,
|
||||
nil)`을 그대로 호출하면 끝 — UICorner 숏핸드처럼 "만들어둔 자식을
|
||||
수동으로 찾아 지우는" 로직조차 필요 없음.
|
||||
- **`retract`는 완전 no-op — [재정정, 2026-08-12 세션 후속] "매번
|
||||
- **반환하는 클로저는 완전 no-op — [재정정, 2026-08-12 세션 후속] "매번
|
||||
불리지만 대부분 no-op"이라던 직전 서술도 틀렸음, "대부분"이 아니라
|
||||
"항상"** — 일반 프로퍼티 핸들러(`retract`가 완전 무조건 no-op,
|
||||
"항상"** — 일반 프로퍼티 핸들러(반환 클로저가 완전 무조건 no-op,
|
||||
`bind-system-plan.md` "일반 프로퍼티는 애초에 'unset' 개념이 없음")와
|
||||
완전히 같은 성격으로 재정정. **`AttributeKeyHandler.retract`는
|
||||
`SetAttribute`를 절대 호출하지 않음** — attribute를 지우는 유일한
|
||||
경로는 `process(inst,k,nil)`(`None`이든, State가 스스로 `nil`로
|
||||
바뀌든) 뿐. 이전 버전("이름이 사라질 때(`v==nil`)만 retract가
|
||||
완전히 같은 성격으로 재정정. **`AttributeKeyHandler`가 반환하는
|
||||
클로저는 `SetAttribute`를 절대 호출하지 않음** — attribute를 지우는
|
||||
유일한 경로는 `process(inst,k,nil,index)`(`None`이든, State가 스스로
|
||||
`nil`로 바뀌든) 뿐. 이전 버전("이름이 사라질 때(`v==nil`)만 retract가
|
||||
`SetAttribute(name,nil)`을 호출")은 두 가지 문제가 있었음 — (1)
|
||||
`retract` 안에 관측 가능한 부작용이 생겨 `bind-system-plan.md`의
|
||||
"retract는 구조적 팝만, process 트리거 금지" 일반 규칙과 어긋나는
|
||||
성격의 코드가 됨, (2) 그룹이 survivor 이름에 `retractUnder(...,source)`를
|
||||
부를 때 그 시점에 `SetAttribute`가 잘못 끼어들 수 있는 경로가 생겨
|
||||
`a→nil→b` 깜빡임 위험(사용자 지적) — `retract`가 완전 no-op이면 이
|
||||
경로 자체가 물리적으로 없어짐.
|
||||
이 클로저 안에 관측 가능한 부작용이 생겨 `bind-system-plan.md`의
|
||||
"이 클로저는 구조적 팝만, process 트리거 금지" 일반 규칙과 어긋나는
|
||||
성격의 코드가 됨, (2) 그룹이 survivor 이름에 재위임할 때 그 시점에
|
||||
`SetAttribute`가 잘못 끼어들 수 있는 경로가 생겨 `a→nil→b` 깜빡임
|
||||
위험(사용자 지적) — 클로저가 완전 no-op이면 이 경로 자체가 물리적으로
|
||||
없어짐.
|
||||
- store-bind 가능(일반 프로퍼티와 동일하게 취급, `Store<T>`/`State<T>`
|
||||
값도 받음).
|
||||
|
||||
### 이름 소유권 — 그룹/직접 쓰기 충돌 방지, `rawNew`와 per-name 전용 키 (2026-08-12 열 번째 세션)
|
||||
### 이름 소유권 — 그룹/직접 쓰기 충돌 방지 (2026-08-12 열 번째 세션, 2026-08-13 다섯 번째 세션 전면 재정정)
|
||||
|
||||
**문제**: `AttributeKey(name)`이 이름별 weak 캐시로 항상 같은 객체를
|
||||
리턴하고, 그룹 `Attribute(...)`가 그 경로를 그대로 재사용(위 "메커니즘"
|
||||
리턴하고, 그룹 `Attribute(...)`가 그 경로를 그대로 재사용(아래 "메커니즘"
|
||||
절)하다 보니, **서로 다른 원래 위치(해시파트 직접 쓰기 `[AttributeKey
|
||||
"name"]=value` vs 배열파트 `Attribute(store)`, 또는 서로 다른 두
|
||||
`Attribute(...)` 그룹)가 같은 이름을 동시에 관리하려 하면 정확히 같은
|
||||
|
|
@ -144,55 +152,48 @@ end
|
|||
(같은 해시 키는 한 Modifier 안에 하나뿐), 그룹의 이름 집합은 런타임에
|
||||
동적이라 이 해소망 밖에 있음.
|
||||
|
||||
**해법 — 그룹은 공개 `AttributeKey(name)` 캐시를 안 쓰고, 이름당 자기
|
||||
전용 키 객체를 만들어 씀.** `AttributeKey`의 내부 구현을 캐시 조회
|
||||
(`rawNew`가 없으면 만들어서 캐시)와 순수 객체 생성(`rawNew(name)`,
|
||||
브랜드 태그/`Name` 필드는 있지만 캐시를 거치지 않는 raw 생성자)로 분리 —
|
||||
공개 `AttributeKey(name)`은 지금처럼 캐시를 거치고, **그룹 Handler(roblox
|
||||
글루)만 `rawNew`를 직접 써서 이름마다 자기만의 키 객체를 만듦.**
|
||||
**[역사, 2026-08-13 세션 안에서 두 번 뒤집힘]** 첫 버전(`rawNew`로 그룹
|
||||
전용 키를 만들고 `owners` Relate로 이름별 소유권을 수동 추적)은 "그룹이
|
||||
이름을 놓았다 나중에 같은 그룹이 그 이름을 다시 포함하면 자기 자신과
|
||||
충돌"하는 실제 버그가 있었음(소유권 반납이 `process`의 `v==nil` 분기에만
|
||||
있어서, 그룹이 이름을 통째로 놓는 경로는 그 분기를 안 타서 옛 소유권
|
||||
기록이 안 지워짐). 두 번째 버전(`AttributeGroupKeyHandler`라는 스캔
|
||||
불가 체크포인트 핸들러를 `Dispatch.processAs`로 명시 push, 소유권 충돌을
|
||||
Dispatch의 재진입 가드에 얹어 감지)은 이 버그를 고치긴 했으나, 같은 날
|
||||
다섯 번째 세션에 `Dispatch` 자체가 `chains`를 핸들러 identity가 아니라
|
||||
**인덱스**로 추적하도록 재설계되며(`base/bind-system-plan.md` "Dispatch
|
||||
체인" 절) 체크포인트가 하던 일 자체가 통째로 불필요해짐 — 원문·역전
|
||||
이유는 `archive/checkpoint-handler-pattern-reversed.md`.
|
||||
|
||||
**최종(세 번째 버전) — 체크포인트도 `owners`도 없이, 항상 인덱스 1부터
|
||||
직접 위임.** 그룹이 이름마다 공개 `AttributeKey(name)`으로 그냥
|
||||
`Dispatch.process(inst, key, source, 1)`를 부르면 끝 — **"인덱스 1이 이미
|
||||
점유돼 있는가"라는 `Dispatch.process` 자신의 점유 체크가 소유권 충돌
|
||||
감지를 그대로 대신함**: 다른 그룹이나 직접 쓰기가 이미 그 이름을
|
||||
점유했다면, 이 호출은(체크포인트를 거칠 필요도 없이) 곧바로 "이미
|
||||
점유됨" error를 냄. `AttributeKeyHandler`도 다시 완전 무상태로 되돌아감:
|
||||
|
||||
```lua
|
||||
-- AttributeKeyHandler(quad-roblox) 전용, (inst,name)별 현재 이 이름을 쓰는 키 객체
|
||||
local owners = Relate() -- {[inst(weak)] = {[name]: keyObject}}
|
||||
|
||||
function AttributeKeyHandler.process(inst, k, v)
|
||||
local name = k.Name
|
||||
local map = owners:GetStrong(inst) or {}
|
||||
local current = map[name]
|
||||
if current ~= nil and current ~= k then
|
||||
error(("attribute \"%s\"는 이미 다른 AttributeKey가 관리 중"):format(name))
|
||||
end
|
||||
inst:SetAttribute(name, v) -- v가 nil이든 아니든 무조건 — 일반 프로퍼티와 완전히 동일
|
||||
map[name] = if v == nil then nil else k -- nil로 귀결되면 소유권도 같이 반납
|
||||
owners:SetStrong(inst, map)
|
||||
end
|
||||
|
||||
function AttributeKeyHandler.retract(inst, k, v)
|
||||
-- 완전 no-op. 일반 프로퍼티와 동일 — "unset" 개념 자체가 없음.
|
||||
-- retract 안에서 process를 부르는 건 Dispatch.retractUnder의 체인
|
||||
-- 추적을 꼬는 UB(base/bind-system-plan.md 일반 규칙)라 여기서도
|
||||
-- SetAttribute를 직접이든 간접이든 절대 안 부름 — 지우는 건 오직
|
||||
-- process(inst,k,nil).
|
||||
-- AttributeKeyHandler(quad-roblox) — 완전 무상태, 소유권 추적 없음
|
||||
function AttributeKeyHandler.process(inst, k, v, index)
|
||||
inst:SetAttribute(k.Name, v) -- v가 nil이든 아니든 무조건 — 일반 프로퍼티와 완전히 동일
|
||||
return function() end -- 지울 게 없음 — SetAttribute는 오직 process(inst,k,nil)로만
|
||||
end
|
||||
```
|
||||
|
||||
- **직접 리터럴 쓰기**(`[AttributeKey<<T>> "name"] = value`)는 공개
|
||||
`AttributeKey(name)`을 그대로 씀 — 한 Modifier 안에 같은 해시 키가
|
||||
중복될 수 없어 이 경로의 claimant는 항상 유일, 별도 캐싱 불필요.
|
||||
- **그룹**은 자기가 이미 갖고 있던 "(inst, 자기 배열 위치)별 마지막으로
|
||||
쓴 attribute 상태" 릴레이션(위 "메커니즘" 절)의 저장 형태를 **이름
|
||||
문자열 집합 → `{[name]: 그 이름 전용 키 객체}` 맵으로 확장**만 하면 됨 —
|
||||
새 릴레이션 불필요, 이미 있던 걸 재사용. 이름을 처음 보면 `rawNew(name)`로
|
||||
만들어 이 맵에 캐싱하고 그 키로 위임, 이미 맵에 있으면(이전 사이클에
|
||||
이미 관리 중이던, 즉 "남아있는" 이름) **그 캐싱된 같은 객체를 그대로
|
||||
재사용**해서 위임. 이 diff/재위임 로직의 정확한 코드는 아래 "그룹
|
||||
`Attribute(...)`" 절의 `AttributeGroupHandler.process`/`.retract` 참고
|
||||
— `AttributeKeyHandler` 자신은 diff를 전혀 모름(위 "이름이 살아있는
|
||||
동안 항상 같은 키 재사용" 전제만 지켜지면 그만).
|
||||
- **패키지 경계**: `AttributeKey` 자체가 이미 quad-roblox 소속(Tag와
|
||||
달리 base/roblox로 안 쪼갬, 아래 "패키지 배치" 절)이고 그룹의 실제
|
||||
위임 로직도 이미 roblox 쪽 글루라 `rawNew` 호출이 새 역의존을 안 만듦 —
|
||||
base쪽 `Attribute(...)` 값 객체 자신은 이 메커니즘을 전혀 모름.
|
||||
`AttributeKey(name)`을 그대로 씀, 정상 스캔으로 바로
|
||||
`AttributeKeyHandler`에 도달(인덱스 1) — 한 Modifier 안에 같은 해시
|
||||
키가 중복될 수 없어 이 경로 자체의 claimant는 항상 유일. 그룹이
|
||||
이미 그 이름의 인덱스 1을 점유 중이면, 이 직접 쓰기의
|
||||
`Dispatch.process(inst,key,value,1)`가 그 자리에서 곧바로 점유 error —
|
||||
그룹↔직접 쓰기 충돌도 같은 점유 체크 하나로 잡힘.
|
||||
- **그룹**은 아래 "메커니즘" 절에서 이름마다 `Dispatch.retractFrom`+
|
||||
`Dispatch.process` 페어로 위임 — 전용 키 객체도, 소유권 레지스트리도
|
||||
필요 없음(항상 공개 `AttributeKey(name)`, 항상 인덱스 1).
|
||||
- **패키지 경계**: `AttributeKey`는 이미 quad-roblox 소속(Tag와 달리
|
||||
base/roblox로 안 쪼갬, 아래 "패키지 배치" 절) — base쪽
|
||||
`Attribute(...)` 값 객체 자신은 이 메커니즘을 전혀 모름.
|
||||
|
||||
## 그룹 `Attribute(...)` — 여러 Store를 한 번에 attribute로 (2026-08-11 아홉 번째 세션 신설)
|
||||
|
||||
|
|
@ -228,67 +229,57 @@ Frame { Attribute(styleStore), Attribute(stateStore) } -- 여러 개 나란히
|
|||
슬롯을 그대로 가져와 자기 자신의 key→Source 맵에 넣는 것 — 아래 "레이어드
|
||||
Store 기각과 안 부딪히나" 참고.
|
||||
|
||||
### 메커니즘 — per-name 전용 키로 기존 단일 키 경로에 재귀 위임
|
||||
### 메커니즘 — 항상 인덱스 1부터 기존 단일 키 경로에 직접 위임
|
||||
|
||||
**[2026-08-11 아홉 번째 세션 후속, 개정]** 최초안은 "자기 완결형 Handler,
|
||||
Dispatch 재진입 없이 직접 `SetAttribute`+수동 per-field StoreBind 구독"
|
||||
이었으나, 위 "동등성" 절의 이름별 weak 캐시가 확정되며 그 회피 이유
|
||||
자체가 없어짐 — 그래서 그룹 Handler는 **자기만의 SetAttribute/구독 로직을
|
||||
새로 만들지 않고, 각 필드를 기존 단일 키 `AttributeKey` 경로에 그대로
|
||||
재귀 위임** — `None`/`retract`/store-bind 전부 이미 확정된 단일 키
|
||||
메커니즘을 100% 재사용, 중복 구현 없음. **[정정, 2026-08-12 열 번째
|
||||
세션] 위임에 쓰는 키가 공개 `AttributeKey(name)`이 아니라 `rawNew(name)`로
|
||||
매번 그룹 전용으로 만드는 키로 바뀜** — 이유·정확한 소유권 판정 방식은
|
||||
위 "이름 소유권" 절 참고, 이 절은 그 위에서 diff 로직이 어떻게 도는지만
|
||||
설명:
|
||||
재귀 위임** — `None`/store-bind 전부 이미 확정된 단일 키 메커니즘을
|
||||
100% 재사용, 중복 구현 없음. **[전면 재정정, 2026-08-13 다섯 번째 세션]
|
||||
`rawNew(name)` 그룹 전용 키 → `AttributeGroupKeyHandler` 체크포인트로
|
||||
두 번 거쳐온 위임 메커니즘이, `Dispatch`의 인덱스 기반 재설계로 다시
|
||||
한번 단순화됨 — 이제 그냥 공개 `AttributeKey(name)`으로 인덱스 1에
|
||||
직접 위임**(경위는 위 "이름 소유권" 절, 원문은 `archive/
|
||||
checkpoint-handler-pattern-reversed.md`):
|
||||
|
||||
**그룹의 `process`/`retract` — [전면 재정정, 2026-08-12 세션 후속]**
|
||||
아래는 `(inst, index)`(array-part 위치, `Tag`의 `relate:GetStrong(inst,k)`와
|
||||
동일 키잉 — `k`는 배열 인덱스)로 찾은 릴레이션에 저장된 **"이름 →
|
||||
그 이름 전용 키 객체 맵"**(이름 존재 여부뿐 아니라 그때 쓴 키 객체
|
||||
자체까지 같이 들고 있어야 위 "이름 소유권" 절의 동일 객체 재사용이
|
||||
성립)을 씀:
|
||||
**그룹의 `process`** — `(inst, index)`(array-part 위치, `Tag`의
|
||||
`relate:GetStrong(inst,k)`와 동일 키잉 — `k`는 배열 인덱스)에서 호출되고,
|
||||
반환하는 클로저가 "지금 관리 중인 이름 집합"을 직접 캡처 — **별도 `Relate`
|
||||
불필요**(2026-08-13 다섯 번째 세션, 클로저가 매 호출마다 자기 자신의
|
||||
이름 집합을 새로 만들어 캡처하므로 사이클을 가로질러 저장해둘 이유가
|
||||
없어짐):
|
||||
|
||||
```lua
|
||||
local groupState = Relate() -- {[inst(weak)] = {[index]: {[name]: keyObject}}}
|
||||
|
||||
function AttributeGroupHandler.process(inst, index, v)
|
||||
if v == nil then return end
|
||||
local map = groupState:GetStrong(inst, index) or {}
|
||||
for name, source in pairs(v:NameMap()) do
|
||||
local key = map[name] or rawNew(name) -- 남아있던 이름은 캐싱된 같은 객체 재사용
|
||||
map[name] = key
|
||||
Dispatch.retractUnder(inst, key, nil, source) -- chain-append-leak 방지, 매번(신규는 빈 체인이라 no-op)
|
||||
Dispatch.process(inst, key, source)
|
||||
local names = {}
|
||||
for name, source in pairs(v:NameMap()) do -- isHandlable이 이미 isAttribute(v)를 보장
|
||||
local key = AttributeKey(name) -- 공개 캐시 그대로 — 그룹 전용 키 불필요
|
||||
Dispatch.retractFrom(inst, key, 1, source) -- 그 이름 자리에 이미 뭔가 있으면 통째로 철거(신규는 no-op)
|
||||
Dispatch.process(inst, key, source, 1) -- 항상 인덱스 1부터 새로 위임 — StoreBind/AttributeKeyHandler는 정상 스캔으로 쌓임
|
||||
names[name] = true
|
||||
end
|
||||
groupState:SetStrong(inst, index, map)
|
||||
end
|
||||
|
||||
function AttributeGroupHandler.retract(inst, index, v)
|
||||
local map = groupState:GetStrong(inst, index)
|
||||
if not map then return end
|
||||
local newNames = if isAttribute(v) then v:NameMap() else {}
|
||||
for name, key in pairs(map) do
|
||||
if newNames[name] == nil then -- 새 v에 이제 없는 이름만
|
||||
Dispatch.retractUnder(inst, key, nil, nil) -- 구독만 끊음 — SetAttribute는 안 일어남(아래 원칙)
|
||||
map[name] = nil
|
||||
return function(hintValue)
|
||||
-- hintValue: 다음에 이 자리를 대체할 값(다른 Attribute, nil 등) — Tag/Ref와 같은 힌트 패턴
|
||||
local newNames = if isAttribute(hintValue) then hintValue:NameMap() else {}
|
||||
for name in pairs(names) do
|
||||
if newNames[name] == nil then -- 더 이상 이 그룹이 안 쓰는 이름만
|
||||
Dispatch.retractFrom(inst, AttributeKey(name), 1, nil) -- SetAttribute는 안 일어남(아래 원칙)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
- **`process`는 매번 살아있는 이름 전부를 `retractUnder`+`process`
|
||||
페어로 재위임** — 신규/생존 구분 없이 균일 처리. `Dispatch.process`가
|
||||
매번 체인 꼬리에 새 항목을 쌓기만 하지 스스로 옛 항목을 안 지우므로
|
||||
(팝은 `retractUnder`의 일), `retractUnder` 없이 `Dispatch.process`만
|
||||
반복 호출하면 같은 키 자리에 옛 `AttributeKeyHandler` 항목이 계속
|
||||
쌓이는 누수가 생김 — 신규 이름은 아직 체인이 없어 `retractUnder`가
|
||||
그냥 no-op이라 이 페어링을 신규/생존 가리지 않고 통일해도 비용 없음.
|
||||
**값 비교(`:Get()`으로 old/new 비교)는 안 함** — State 계약("값은 항상
|
||||
선언된 Compute 재실행 결과, 캐시 비교 금지", `store-semantics.md`
|
||||
"하드 경계" 절)과 어긋나고, `source`가 `State`/`Source`면
|
||||
`Dispatch/StoreBind`가 알아서 언랩+구독까지 다 해줌(그룹 Handler가
|
||||
따로 구독 관리 안 함)이라 굳이 비교할 이유가 없음.
|
||||
- **`process`는 매번 살아있는 이름 전부를 `retractFrom`+`process`
|
||||
페어로 재위임** — 신규/생존 구분 없이 균일 처리(신규 이름은 아직 체인이
|
||||
없어 `retractFrom`이 그냥 no-op이라 이 페어링을 통일해도
|
||||
비용 없음). **값 비교(`:Get()`으로 old/new 비교)는 안 함** — State
|
||||
계약("값은 항상 선언된 Compute 재실행 결과, 캐시 비교 금지",
|
||||
`store-semantics.md` "하드 경계" 절)과 어긋나고, `source`가
|
||||
`State`/`Source`면 `Dispatch/StoreBind`가 알아서 언랩+구독까지 다
|
||||
해줌(그룹 Handler가 따로 구독 관리 안 함)이라 굳이 비교할 이유가 없음.
|
||||
- **[확정, 2026-08-12 세션 후속, 사용자 결정] `retract`는 `SetAttribute`를
|
||||
절대 안 부름 — Attribute는 오직 명시적 `None`/`nil`로만 지워진다.**
|
||||
그룹에서 이름이 조용히 빠지든(diff로 사라짐), 그룹 바인딩 자체가
|
||||
|
|
@ -314,23 +305,19 @@ end
|
|||
`StoreBind` 구독이 인스턴스가 살아있는 동안 영원히 남아 원본
|
||||
`Source`가 바뀔 때마다 계속 `SetAttribute`를 쏘는 실제 리소스 누수가
|
||||
됨(이건 "마지막 값이 남는다"는 것과 다른 문제 — 안 죽는 구독 자체가
|
||||
문제). 그래서 `retract`는 사라진 이름에 한해 `Dispatch.retractUnder(inst,
|
||||
key, nil, nil)`만 부름 — **`Dispatch.process`는 절대 안 부르므로**
|
||||
(retract 안에서 process 호출은 `retractUnder`의 체인 추적을 꼬는 UB,
|
||||
`bind-system-plan.md` 일반 규칙) `AttributeKeyHandler.retract`(완전
|
||||
no-op)만 타고 끝나 `SetAttribute`는 여기서도 절대 안 일어남 — 위
|
||||
"명시적 None으로만 지운다" 원칙과 안 부딪힘.
|
||||
문제). 그래서 반환하는 클로저는 사라진 이름에 한해
|
||||
`Dispatch.retractFrom(inst, AttributeKey(name), 1, nil)`만 부름 —
|
||||
**`Dispatch.process`는 절대 안 부르므로**(이 클로저 안에서 새 등록을
|
||||
트리거하는 건 체인 추적을 꼬는 UB, `bind-system-plan.md` 일반 규칙)
|
||||
그 이름 아래가 전부 자기 자신의 클로저만 타고 끝나 `SetAttribute`는
|
||||
여기서도 절대 안 일어남 — 위 "명시적 None으로만 지운다" 원칙과 안
|
||||
부딪힘.
|
||||
- **필드 하나만 바뀌는 흔한 경우**(`storeA.foo:Set(v)`, 그룹 자체는
|
||||
안 바뀜)는 위 그룹 재처리를 아예 거치지 않음 — 마운트 시 이미 걸린
|
||||
단일 키 `AttributeKeyHandler`의 store-bind 구독이 바로
|
||||
`SetAttribute("foo", v)`를 호출(그룹 재진입 없이 그 경로 스스로).
|
||||
**`AttributeChanged`/`GetAttributeChangedSignal` 남발 걱정 없음** —
|
||||
키 집합이 안 바뀌는 한 diff 로직 자체가 안 돎.
|
||||
- **캐시(맵)는 그룹 값 교체를 넘어 계속 유지돼야 함** — 매 교체마다
|
||||
남아있는 이름의 키까지 새로 만들면, "이름 소유권" 절의 `owners`
|
||||
레지스트리엔 옛 키가 남아있어 새로 만든 키와 비교 시 오탐 충돌이
|
||||
남(자기 자신과 충돌하는 꼴). 이 릴레이션이 `(inst, index)`로 영속되는
|
||||
건 이미 확정돼 있던 설계.
|
||||
|
||||
**그룹 Handler에 남는 자기 로직은 사실상 "이름 집합 diff"뿐** — 실제
|
||||
`SetAttribute` 호출/`None` 처리/store-bind 구독은 전부 기존 단일 키
|
||||
|
|
|
|||
|
|
@ -22,7 +22,12 @@ v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는
|
|||
|
||||
## 핸들러 계약 (확정 — 아래 "확정된 디스패치 모델" 절과 통합해서 읽을 것)
|
||||
|
||||
핸들러는 다음 4개를 제공하는 등록 가능한 객체:
|
||||
**[전면 재정정, 2026-08-13 다섯 번째 세션] `process`/`retract` 2-메소드
|
||||
계약에서 `process`가 자기 retract 클로저를 반환하는 1-메소드 계약으로
|
||||
전환.** 계기와 근거는 아래 "Dispatch 체인" 절 참고 — 이 절은 바뀐 최종
|
||||
계약만 서술.
|
||||
|
||||
핸들러는 다음 3개를 제공하는 등록 가능한 객체:
|
||||
|
||||
- `isHandlable(inst, key, value): boolean` — 이 핸들러가 이 inst/key/value
|
||||
조합을 처리할 수 있는지 판별하는 predicate. **부작용 없이, 빠르게** —
|
||||
|
|
@ -37,24 +42,32 @@ v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는
|
|||
따라 매치 여부가 갈리는 케이스는 없지만, 나중에 필요해지면(다른
|
||||
백엔드에서 인스턴스 종류별로 매치가 달라져야 하는 경우 등) 핸들러
|
||||
계약 자체를 깨는 breaking change가 되므로 지금 넣어두는 게 훨씬 쌈 —
|
||||
사용자 판단으로 확정.
|
||||
사용자 판단으로 확정. **[2026-08-13 세션] 생략 불가, 항상 정의할
|
||||
것** — 같은 날 네 번째 세션에서 한때 "생략하면 스캔 불가시 체크포인트
|
||||
핸들러"로 확장했으나, 다섯 번째 세션(아래 "Dispatch 체인" 절)의 인덱스
|
||||
기반 재설계로 그 용도(`AttributeGroupKeyHandler`류 마커) 자체가
|
||||
없어져 이 확장도 같은 세션 안에서 신설·철회가 끝나 archive 이전 없이
|
||||
이 한 줄로만 기록.
|
||||
- `priority: number` — 우선순위. 등록 순서(Fusion의 4단계 고정 stage, Vide의
|
||||
action() 우선순위)보다 일반화된 **열린 숫자 공간**으로.
|
||||
- `process(inst, key, value)` — 실제 처리 수행(아래 "확정된 디스패치 모델"
|
||||
절 참고). v1/기존 논의에서 "bind"라 부르던 것과 동일한 역할.
|
||||
- `retract(inst, key, value)` — 이전 처리를 무르는/멈추는 함수(아래 절,
|
||||
`base/lifecycle-pattern.md` 참고). 모든 핸들러가 의미 있게 구현할 필요는
|
||||
없음(예: 일반 프로퍼티 핸들러는 보통 no-op).
|
||||
**`retract` 필드 자체는 생략 불가, no-op이라도 항상 정의할 것(2026-08-08
|
||||
세션, 확정)** — `Dispatch.process`(아래 "확정된 디스패치 모델" 절)는
|
||||
store 재발행마다(핸들러 *타입*이 안 바뀌어도) 이전 핸들러의 `retract`를
|
||||
nil 체크 없이 무조건 호출함(`[전면 정정, 2026-08-12 열한 번째 세션]`,
|
||||
아래 "retract-always-fires" 정정 절 참고 — 담당 타입이 바뀔 때만
|
||||
불린다는 건 예전의 틀린 가정이었음). 필드를 생략한 핸들러는 이 반복
|
||||
호출에서 바로 `attempt to call a nil value`로 크래시 — "의미 있게
|
||||
구현할 필요 없음"은 "구현이 사소해도 됨"이라는 뜻이지 "필드를 안 둬도
|
||||
안전하다"는 뜻이 아님. 새 핸들러를 짤 때 이 필드가
|
||||
없으면 리뷰/린트에서 걸러내야 함(M2 착수 시 확인 목록에 추가).
|
||||
- `process(inst, key, value, index): (hintValue) -> ()` — 실제 처리
|
||||
수행(아래 "확정된 디스패치 모델"/"Dispatch 체인" 절 참고) 하고,
|
||||
**자기 자신이 방금 벌인 일을 무르는 1-인자 클로저를 반환**.
|
||||
v1/기존 논의에서 "bind"라 부르던 것과 동일한 역할 + 예전의 `retract`
|
||||
필드가 여기로 합쳐짐(**[전면 재정정, 2026-08-13 다섯 번째 세션]**,
|
||||
계기·근거는 아래 "Dispatch 체인" 절). **반환값 생략 불가 — 정리할
|
||||
게 없는 핸들러도 항상 `function() end`(no-op) 형태로 반환할 것** —
|
||||
`Dispatch.process`(아래 절)가 이 반환값을 `chains`에 저장해뒀다가
|
||||
나중에 정확히 이 클로저 하나만 호출해서 정리하므로, 반환을
|
||||
생략하면(`nil`을 돌려주면) 나중에 `attempt to call a nil value`로
|
||||
크래시(예전 "retract 필드 생략 불가" 규칙과 같은 이유, 자리만 옮겨옴).
|
||||
**핸들러가 직접 자기 자신의 하위 위임(재귀 `Dispatch.process`로 만든
|
||||
것들)까지 클로저 안에서 다시 정리할 필요는 없음** — `Dispatch.
|
||||
retractFrom`의 순회 구조 자체가 항상 깊은 인덱스부터 먼저 정리하고
|
||||
나서 얕은 인덱스로 올라오므로, 이 클로저가 불릴 시점엔 자기보다
|
||||
아래(자기가 만들어낸 하위 위임)는 이미 전부 정리된 뒤임(아래
|
||||
"Dispatch 체인" 절 참고) — 클로저는 **오직 자기 자신의 직접
|
||||
자원**(Observer 구독 등)만 정리하면 됨.
|
||||
|
||||
디스패치는 등록된 핸들러를 우선순위 순으로 스캔하며 `isHandlable`을 호출,
|
||||
첫 매치가 처리(Fusion의 SpecialKey 우선순위 스캔과 유사하되 4단계 고정이 아니라
|
||||
|
|
@ -98,10 +111,16 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
|
|||
`quad-debug`(후순위, `research/debug-tooling-plan.md`)와는 다른 층위의,
|
||||
라이브러리 자체에 내장된 개발자 편의 기능.
|
||||
|
||||
## 확정된 디스패치 모델: `process(inst, k, v)` / `retract(inst, k, v)`
|
||||
## 확정된 디스패치 모델: `process(inst, k, v, index) -> retractor`
|
||||
|
||||
**사용자가 직접 준 구체적인 모델 — 이 문서의 이전 초안보다 우선함.** 아래가
|
||||
실제로 구현할 모양:
|
||||
실제로 구현할 모양. **[전면 재정정, 2026-08-13 다섯 번째 세션]** 이 절은
|
||||
원래 `process(inst,k,v)`/`retract(inst,k,v)` 별개 2-메소드로 서술돼
|
||||
있었으나, `chains`를 핸들러 **객체 identity**가 아니라 **인덱스**로
|
||||
추적하는 재설계(아래 "Dispatch 체인" 절)와 함께 `process`가 자기
|
||||
retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절의 예시/규칙은
|
||||
전부 새 모델로 갱신됨, 옛 2-메소드 버전은 `archive/`로 옮기지 않고 이
|
||||
정정 표시로만 남김(오늘 하루 안에서 신설→재정정이 끝났기 때문).
|
||||
|
||||
- 모든 핸들러는 대상 **Instance를 직접, 항상** 받는다. quad는 "인스턴스를 생성하고
|
||||
그 인스턴스를 처리하는" 라이브러리다 — 다른 라이브러리가 만든 값(예: Store)을
|
||||
|
|
@ -115,12 +134,15 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
|
|||
엔드포인트 백엔드(`quad-roblox`/`quad-web` 등)가 알아서 결정할 문제 —
|
||||
base 인터페이스는 "무언가를 inst로 받아 process/retract한다"는 계약만
|
||||
지키면 됨, 그 inst의 실체가 뭔지는 백엔드 재량.
|
||||
- `process(inst, k, v)` — 우선순위 순으로 등록된 핸들러를 스캔,
|
||||
`isHandlable(inst,k,v)`를 만족하는 최상위 핸들러가 실제 처리를 담당.
|
||||
**이 "스캔+실행" 오케스트레이터는 `Dispatch.process`로, 순수 스캔
|
||||
부분은 `Dispatch.getHandler`로 이름이 공식화됨**(아래 `None` 센티널
|
||||
절, 2026-08-07 여덟 번째 세션) — 이 절에서는 개념 설명이라 편의상
|
||||
그냥 `process`로 계속 씀.
|
||||
- `process(inst, k, v, index)` — 우선순위 순으로 등록된 핸들러를 스캔,
|
||||
`isHandlable(inst,k,v)`를 만족하는 최상위 핸들러가 실제 처리를 담당하고
|
||||
자기 retract 클로저를 반환. **이 "스캔+실행" 오케스트레이터는
|
||||
`Dispatch.process`로, 순수 스캔 부분은 `Dispatch.getHandler`로 이름이
|
||||
공식화됨**(아래 `None` 센티널 절, 2026-08-07 여덟 번째 세션) — 이
|
||||
절에서는 개념 설명이라 편의상 그냥 `process`로 계속 씀. **`index`가
|
||||
뭔지·왜 필요한지는 아래 "Dispatch 체인" 절 참고** — 요약하면 같은
|
||||
`(inst,k)` 안에서 "지금 몇 번째로 겹쳐 위임됐는지"를 나타내는 정수로,
|
||||
핸들러 객체 identity 대신 이 숫자로 체인 위치를 추적함.
|
||||
- 예시: `Dispatch/StoreBind.luau`(범용, 엔진 무관)는 **`k`는 무엇이든 받고
|
||||
`v`가 State/Source인 경우를 잡아내는, 우선순위가 매우 높은 핸들러** —
|
||||
`v`가 반응형이면 그 값을 처리(구독)함. 이 핸들러 안에서:
|
||||
|
|
@ -129,113 +151,111 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
|
|||
결국 정리하긴 하지만, GC 되기 전에도 store 값이 업데이트될 수 있으므로
|
||||
그 시점엔 그냥 `Connected`를 보고 무시(no-op).
|
||||
2. 처리해도 되면, 사용자가 넘긴 함수들을 거쳐 실제 값(`realv`)을 계산.
|
||||
3. **재귀 호출 전에 먼저 `Dispatch.retractUnder(inst, k, self, realv)`를
|
||||
3. **재귀 호출 전에 먼저 `Dispatch.retractFrom(inst, k, index + 1, realv)`를
|
||||
불러 자기 밑에 위임돼 있던 걸 정리한 뒤, `realv`를 들고
|
||||
`Dispatch.process(inst, k, realv)`를 재귀 호출**(정확한 메커니즘은
|
||||
아래 "Dispatch 체인" 절, 2026-08-08 세 번째 세션 — 오케스트레이터
|
||||
`Dispatch.process(inst, k, realv, index + 1)`를 재귀 호출**(정확한
|
||||
메커니즘은 아래 "Dispatch 체인" 절, 2026-08-08 세 번째 세션에 처음
|
||||
확정, 2026-08-13 다섯 번째 세션에 인덱스 기반으로 재정정 — 오케스트레이터
|
||||
이름 공식화는 아래 `None` 센티널 절 참고, 2026-08-07 여덟 번째
|
||||
세션) — 이게 바로 "store 바인드는 pluggable 바인드를 재실행하는
|
||||
래핑"이라는 이 문서 이전 초안의 결론과 일치. `realv`가 반응형이
|
||||
아니라면 자연히 `StoreBind`의 `isHandlable`을 통과 못 하고 우선순위상
|
||||
다음 핸들러(일반 프로퍼티 세터 등)로 흘러감 — 무한 재귀 걱정 없음.
|
||||
`realv`가 또 State/Source(`State<State<T>>`)여도 이제 **자연스럽게
|
||||
처리됨** — 안쪽 재귀는 `index+1`이라는 별개 슬롯을 쓰므로 바깥
|
||||
StoreBind의 슬롯(`index`)과 절대 안 겹침(아래 "Dispatch 체인" 절의
|
||||
`State<State<T>>` 재정정 참고, 예전엔 이게 UB였음).
|
||||
**[정정, 2026-08-10 세션]** 이 예시는 원래 "Tween의 store-bind
|
||||
핸들러"였으나, Tween이 독립 Dispatch 핸들러가 아니라 PropertyHandler가
|
||||
소비하는 값-레벨 래퍼(`Tween<T>`)로 재설계되며(`research/
|
||||
tween-plan.md`, `archive/tween-special-bind-key-reversed.md`) 이
|
||||
자리의 대표 예시에서 빠짐 — `NoneHandler`(아래 절)가 지금은 이
|
||||
패턴의 남은 대표 예시.
|
||||
- **`retract(inst, k, v)`** (이전 초안의 "cleanup", 이름 변경 근거는
|
||||
`base/lifecycle-pattern.md` 참고) — 이전 처리를 무르는/멈추는 함수. **오직
|
||||
"같은 key에 새 값이 들어와서 이전 처리를 갈아치우는" 시나리오에만 존재** —
|
||||
인스턴스/바인드 전체가 Destroy될 때는 `retract`가 호출되지 않음(`base/
|
||||
lifecycle-pattern.md`의 "quad는 라이프사이클 중간에 있지 않다" 원칙 참고).
|
||||
- **`process`가 반환하는 retractor(`(hintValue) -> ()`)** (이전 초안의
|
||||
"cleanup"/별도 `retract` 필드, 이름 변경 근거는 `base/lifecycle-pattern.md`
|
||||
참고, 별도 필드에서 반환값으로 합쳐진 경위는 위 "핸들러 계약" 절 —
|
||||
이전 처리를 무르는/멈추는 함수. **오직 "같은 key에 새 값이 들어와서
|
||||
이전 처리를 갈아치우는" 시나리오에만 존재** — 인스턴스/바인드 전체가
|
||||
Destroy될 때는 이 클로저가 호출되지 않음(`base/lifecycle-pattern.md`의
|
||||
"quad는 라이프사이클 중간에 있지 않다" 원칙 참고).
|
||||
- 일반 프로퍼티는 애초에 "unset" 개념이 없음(`nil`로 셋하는 것도 그냥 셋
|
||||
동작) — 그래서 프로퍼티 핸들러는 보통 `retract`가 필요 없음.
|
||||
- **[전면 정정, 2026-08-12 열한 번째 세션] `retract`는 "핸들러 타입이
|
||||
바뀔 때만" 불리는 게 아니라, **store 바인드가 재발행될 때마다(값이
|
||||
뭐로 바뀌든) 항상 불림** — 위 "확정된 디스패치 모델" 절이 처음부터
|
||||
동작) — 그래서 프로퍼티 핸들러는 보통 no-op 클로저(`function() end`)만
|
||||
반환하면 됨.
|
||||
- **[정정, 2026-08-12 열한 번째 세션, 2026-08-13 다섯 번째 세션에 계약
|
||||
자체가 클로저로 바뀌며 서술 갱신] 이 클로저는 "핸들러 타입이 바뀔
|
||||
때만" 불리는 게 아니라, store 바인드가 재발행될 때마다(값이 뭐로
|
||||
바뀌든) 항상 불림** — 위 "확정된 디스패치 모델" 절이 처음부터
|
||||
말해온 그대로: `StoreBind`는 재-dispatch 전에 **무조건**
|
||||
`Dispatch.retractUnder(inst,k,self,realv)`를 부르고, `retractUnder`는
|
||||
`keep` 바로 다음 항목에게 그 `realv`를(그 다음 항목들에겐 `nil`을)
|
||||
넘기며 체인을 통째로 걷어낸 뒤에야 `Dispatch.process`가 다시 매치를
|
||||
시도해 새 체인을 쌓음. 즉 **"핸들러가 안 바뀌면 retract 없이 process가
|
||||
diff"라는 이전 서술은 틀렸음** — `Tag`의 옛
|
||||
`assert(v == nil, "TagHandler.retract는 v가 nil일 때만 불려야 함")`을
|
||||
액면 그대로 믿고 거꾸로 일반 규칙을 추론한 게 오류의 출처(2026-08-07
|
||||
여덟 번째 세션 정정 당시엔 안 걸렸던 부분, `bind-system-plan.md` 자기
|
||||
"확정된 디스패치 모델" 절과 실제로 모순돼 있었음). 이 오류는
|
||||
`base/tag-plan.md`(원 출처)와, 이번 대화에서 그걸 그대로 이어받은
|
||||
`Ref`/`Slot`/`Attribute` 세 곳 전부에 퍼져 있었음 — 전부 정정
|
||||
완료(각 문서의 해당 절 참고). **[아카이브, `archive/
|
||||
retract-always-fires-reversed.md`]**.
|
||||
`Dispatch.retractFrom(inst,k,index+1,realv)`를 불러 자기 밑에 쌓인
|
||||
클로저들을 꼬리부터 전부 부른 뒤에야 `Dispatch.process`가 다시
|
||||
매치를 시도해 새 체인을 쌓음. 즉 **"핸들러가 안 바뀌면 retract
|
||||
없이 process가 diff"라는 이전 서술은 틀렸음** — 그 오류의 상세
|
||||
경위(`Tag`의 옛 `assert(v==nil)`을 액면 그대로 믿고 거꾸로 일반
|
||||
규칙을 추론한 것)는 `archive/retract-always-fires-reversed.md` 참고,
|
||||
지금은 결론만 인용.
|
||||
- **정정된 원칙 — 대부분의 핸들러는 이 반복 호출에서 실제로 할 일이
|
||||
없어(일반 프로퍼티처럼 값을 그냥 덮어쓰면 끝이라 "unset" 개념 자체가
|
||||
없음) `retract`가 사실상 no-op일 뿐, "타입이 안 바뀌면 retract가 아예
|
||||
없음) 반환하는 클로저가 사실상 no-op일 뿐, "타입이 안 바뀌면 아예
|
||||
안 불린다"는 뜻이 아님.** `Tag`/`Ref`/`Slot`/`Attribute`처럼 **여러
|
||||
위치가 하나의 실제 리소스(엔진 attribute/tag/mounted 서브트리 등)를
|
||||
공유하거나, 값 자체가 정리가 필요한 상태를 들고 있는** 핸들러는,
|
||||
`retract`가 매번 불려도 **"이전 값이 지금 들어오는 새 값(`v`)과
|
||||
사실상 같은지/그 새 값이 여전히 이 자원을 필요로 하는지"를 `v`를
|
||||
힌트 삼아 판단해 실제 엔진 호출만 skip**하는 방식으로 대응해야 함 —
|
||||
`Tag`의 `Contains` 힌트, `Ref`/`Slot`의 identity 비교가 그 예. **`v`를
|
||||
반드시 `nil`로 가정하면 절대 안 됨**(대체하는 새 값 그 자체일 수
|
||||
있음) — 새 핸들러를 짤 때 `retract` 안에서 `v`의 타입을 방어적으로
|
||||
확인할 것(2026-08-12 열한 번째 세션, 실제로 `Tag(...)`/`Ref`/`Slot`
|
||||
설계 전부에서 이 확인이 빠져 있었음이 드러남).
|
||||
이 클로저가 매번 불려도 **"이전 값이 지금 들어오는 새 값(`hintValue`)과
|
||||
사실상 같은지/그 새 값이 여전히 이 자원을 필요로 하는지"를 힌트
|
||||
삼아 판단해 실제 엔진 호출만 skip**하는 방식으로 대응해야 함 —
|
||||
`Tag`의 `Contains` 힌트, `Ref`/`Slot`의 identity 비교가 그 예.
|
||||
**`hintValue`를 반드시 `nil`로 가정하면 절대 안 됨**(대체하는 새 값
|
||||
그 자체일 수 있음) — 새 핸들러를 짤 때 이 클로저 안에서 `hintValue`의
|
||||
타입을 방어적으로 확인할 것.
|
||||
- **자연스러운 분업**: 여러 위치가 자원을 공유하는 핸들러는 대개
|
||||
"`retract`가 이전 기여를 걷어내고(실제 해제는 `v` 힌트로 skip
|
||||
"반환한 클로저가 이전 기여를 걷어내고(실제 해제는 힌트로 skip
|
||||
가능), `process`가 새 기여를 등록한다"는 모양으로 깔끔히 갈림 —
|
||||
`process` 쪽에 별도 old-vs-new diff가 필요 없어짐(그 diff를 `retract`가
|
||||
`process` 쪽에 별도 old-vs-new diff가 필요 없어짐(그 diff를 클로저가
|
||||
이미 통째로, 매번 정확하게 해주므로). `Tag(...)`↔`nil`, `Attribute`의
|
||||
그룹이 이름을 놓는 경우도 이 분업의 자연스러운 특수 케이스일 뿐, 별도
|
||||
패턴이 아님 — 상세 구현은 `base/tag-plan.md`/`base/attribute-plan.md`
|
||||
"이름 소유권" 절, `Ref`는 아래 "`Ref`의 retract" 절, `Slot`은
|
||||
`slot-plan.md` "Slot과 Store 바인드의 관계" 절 참고.
|
||||
- **[일반 규칙, 2026-08-12 세션 후속] `retract`의 `v`는 타입을 보장 안
|
||||
함 — 내용(메소드/필드)을 보려면 반드시 `isX(v)` 가드부터.** `retract`가
|
||||
`old ~= v`/`v == nil`처럼 identity/nil만 비교하면 가드가 필요 없지만
|
||||
(`Ref`/`Slot`/`AttributeKey`가 이 경우), `v`의 내용을 실제로 들여다봐야
|
||||
하면(`TagHandler.retract`의 `newv:Contains(name)`처럼) 그 전에 반드시
|
||||
`isTag(newv)` 같은 타입 가드를 거칠 것 — 안 그러면 그 자리 핸들러
|
||||
*타입*이 바뀌는 드문 경우에 엉뚱한 값의 메소드를 호출해 크래시함.
|
||||
(`AttributeGroupHandler.retract`가 이 가드를 처음엔 빠뜨렸던 실수,
|
||||
`base/attribute-plan.md` 참고.)
|
||||
- **[일반 규칙, 2026-08-12 세션 후속] `retract` 안에서 `Dispatch.process`를
|
||||
부르는 것은 UB — `Dispatch.retractUnder`가 체인을 걷는 도중의 트래킹이
|
||||
꼬임.** `retract`는 오직 청소(구조적 팝, 내부 자원 해제)만 전담하고
|
||||
새 등록을 트리거하면 안 됨 — 새 등록은 항상 바깥의 StoreBind/그룹
|
||||
로직이 `retract` 호출이 다 끝난 *뒤에* 별도로 `process`를 부르는
|
||||
순서로만 일어나야 함. `Dispatch.retractUnder`를 (다른 키에 대해)
|
||||
`retract` 안에서 부르는 건 문제없음 — 금지되는 건 오직 `process`.
|
||||
- **[일반 규칙] 클로저의 `hintValue`는 타입을 보장 안 함 — 내용(메소드/
|
||||
필드)을 보려면 반드시 `isX(hintValue)` 가드부터.** identity/nil만
|
||||
비교하면 가드가 필요 없지만(`Ref`/`Slot`/`AttributeKey`가 이 경우),
|
||||
내용을 실제로 들여다봐야 하면(`TagHandler`의 `newv:Contains(name)`처럼)
|
||||
그 전에 반드시 `isTag(newv)` 같은 타입 가드를 거칠 것 — 안 그러면
|
||||
그 자리 핸들러 *타입*이 바뀌는 드문 경우에 엉뚱한 값의 메소드를
|
||||
호출해 크래시함.
|
||||
- **[일반 규칙] 이 클로저 안에서 `Dispatch.process`를 부르는 것은
|
||||
UB — `Dispatch.retractFrom`이 체인을 걷는 도중의 트래킹이 꼬임.**
|
||||
이 클로저는 오직 청소(구조적 팝, 내부 자원 해제)만 전담하고 새
|
||||
등록을 트리거하면 안 됨 — 새 등록은 항상 바깥의 StoreBind/그룹
|
||||
로직이 클로저 호출이 다 끝난 *뒤에* 별도로 `process`를 부르는
|
||||
순서로만 일어나야 함. `Dispatch.retractFrom`을 (다른 키에 대해)
|
||||
이 클로저 안에서 부르는 건 문제없음 — 금지되는 건 오직 `process`.
|
||||
- **자기 자신의 하위 위임까지 클로저 안에서 수동으로 다시 정리할
|
||||
필요 없음** — 위 "핸들러 계약" 절 참고, `Dispatch.retractFrom`의
|
||||
순회 구조 자체가 항상 깊은 인덱스부터 정리하고 나서 얕은 인덱스로
|
||||
올라오므로 자동으로 해결됨(재귀/래핑 핸들러가 다단으로 겹쳐도
|
||||
각 클로저는 자기 자신의 자원만 책임지면 전체 cascade가 저절로 됨 —
|
||||
2026-08-08 세 번째 세션에 확정된 "다단 체인 자동 전파" 성질이 인덱스
|
||||
모델에서도 그대로 유지, 오히려 더 단순해짐).
|
||||
- Tween은 이 패턴과 무관 — 독립 Dispatch 핸들러가 아니라 PropertyHandler가
|
||||
소비하는 값-레벨 래퍼(`Tween<T>`)라 매치되는 핸들러가 항상
|
||||
PropertyHandler 하나뿐(2026-08-10 세션 재설계) — 트윈 취소/전환은
|
||||
PropertyHandler 내부의 3-상태 릴레이션 슬롯으로 처리(`base/tween-plan.md`,
|
||||
`archive/tween-special-bind-key-reversed.md`).
|
||||
- store bind가 새 값으로 넘어갈 때 이전 핸들러의 `retract`를 호출해주면
|
||||
됨 — **정확한 전파 메커니즘은 아래 "Dispatch 체인" 절 참고**(재귀
|
||||
재-dispatch에서 여러 단계가 겹칠 때 어느 슬롯에 뭘 추적하는지가
|
||||
2026-08-08 세 번째 세션에 구체화됨, 여기 한 줄 설명은 그 요약).
|
||||
- **핸들러 내부 상태 저장**: `retract`가 "이전에 생성한 것"(예: 실행 중이던
|
||||
Tween 객체)에 접근하려면 상태를 어딘가에 저장해야 함 — **`inst`를 키로 하는
|
||||
weak-keyed 테이블에 `k`별로 저장**(예: 생성된 Tween을 담아뒀다가 나중에
|
||||
멈추거나 끝냄). **[정정, 2026-08-08 세션] `base.perInstanceState(inst)`라는
|
||||
이름/모양은 폐기 — `base/relate-plan.md`의 `Relate` 프리미티브로 구체화됨.**
|
||||
각 핸들러 모듈이 자기 톱레벨에 `local relate = Relate()`를 하나 두고
|
||||
`relate:SetStrong(inst, k, tween)`/`relate:GetStrong(inst, k)`로 저장/조회 —
|
||||
"모든 핸들러가 WeakMap을 재발명하지 않고 공유 유틸을 쓴다"는 원래 취지는
|
||||
그대로, `Relate`가 그 공유 유틸의 정식 인터페이스. `base/lifecycle-pattern.md`의
|
||||
`bindLifetime`/`canExecute`도 같은 `Relate`를 내부적으로 씀(용도가 다르니
|
||||
별도 `Relate()` 인스턴스). **왜 GC-안전한가(2026-08-07 여섯 번째 세션,
|
||||
명시화)**: 구조가 "`inst`로 weak-keyed된 바깥 릴레이션 안에 `k`별 안쪽
|
||||
릴레이션이 중첩된" 모양이라, `inst`가 죽어 weak table 엔트리가 통째로
|
||||
사라지는 순간 그 안에 중첩된 `k`별 Tween 인스턴스 릴레이션도 같이 GC됨 —
|
||||
별도 cleanup 로직 불필요(PropertyHandler가 여기 담아두는 실제 엔진 Tween
|
||||
인스턴스도 자동으로 같이 죽는 것까지 포함). **[정정, 2026-08-10 세션]**
|
||||
Tween은 더 이상 별도 "Tween 핸들러"가 아니라 PropertyHandler 내부
|
||||
로직이므로, 이 슬롯이 실제로 담는 값은 `RobloxTween | true | nil`
|
||||
3-상태(첫 세팅 여부까지 같은 슬롯에 통합) — 상세는 `research/
|
||||
tween-plan.md` "3-상태 저장" 절 참고.
|
||||
- **핸들러 내부 상태 저장 — 클로저로 충분한 것과 `Relate`가 필요한 것을
|
||||
구분할 것.** "이 `process` 호출이 만든 걸 나중에 정리하는" 단발성
|
||||
handoff는 이제 클로저의 업밸류 캡처만으로 충분(예: Observer 객체를
|
||||
로컬 변수로 만들고 그대로 반환 클로저가 캡처) — 예전처럼 `Relate`에
|
||||
저장했다가 나중에 다시 조회할 필요가 없어짐(**[2026-08-13 다섯 번째
|
||||
세션, 이 문단 재작성]**). `Relate`가 여전히 필요한 경우는 **여러 번의
|
||||
독립적인 `process`/클로저 호출을 가로질러 누적되는 상태**뿐 —
|
||||
`Tag`의 `tagNameMap`(여러 위치가 같은 이름을 공유), `Attribute`의
|
||||
이름 소유권처럼 "이 `(inst,k)` 하나의 클로저 수명을 넘어서는" 정보만
|
||||
`local relate = Relate()`(모듈 톱레벨, `relate:SetStrong(inst,k,v)`/
|
||||
`:GetStrong(inst,k)`)로 저장. `base/lifecycle-pattern.md`의
|
||||
`bindLifetime`/`canExecute`도 같은 `Relate`를 내부적으로 씀(용도가
|
||||
다르니 별도 `Relate()` 인스턴스) — 이건 "언제까지 실행돼도 되는지"를
|
||||
묻는 것이라 애초에 클로저 수명과 무관한, 계속 남는 질문이라 그대로
|
||||
`Relate` 기반.
|
||||
- **다른 값 변경을 추적하는 것도 process 함수의 정상 범위**: 예를 들어 Slot
|
||||
핸들러는 자기가 감시하는 값(배열/스토어)이 바뀌면 그에 따라 child를
|
||||
갱신해야 함 — `retract` 시점엔 그 추적(구독)만 풀면 됨.
|
||||
|
|
@ -278,10 +298,13 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
|
|||
층위. 결론: **새 메커니즘이 아니라 위 "확정된 디스패치 모델"의
|
||||
`StoreBind` 핸들러(위 절)와 완전히 같은 모양의 핸들러 하나 추가.**
|
||||
|
||||
```
|
||||
```lua
|
||||
NoneHandler.priority = <매우 높음>
|
||||
NoneHandler.isHandlable(inst, k, v) = (v == None)
|
||||
NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출
|
||||
function NoneHandler.process(inst, k, v, index)
|
||||
Dispatch.process(inst, k, nil, index + 1) -- 재귀 재호출, 별개 인덱스
|
||||
return function() end -- 자기 자신은 아무 상태도 없어 no-op
|
||||
end
|
||||
```
|
||||
|
||||
- **매치 predicate는 `isHandlable`** — `canExecute`가 아님. 둘은 완전히
|
||||
|
|
@ -312,14 +335,16 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출
|
|||
명시적으로 분리:
|
||||
- `Dispatch.getHandler(inst,k,v): Handler?` — 순수 스캔(`handler.isHandlable(inst,k,v)`+
|
||||
`priority`), 부작용 없음.
|
||||
- `Dispatch.process(inst,k,v)` — 오케스트레이터: `getHandler` 호출 →
|
||||
매치된 핸들러를 `(inst,k)` 체인 꼬리에 push → 그 핸들러의 `.process`
|
||||
호출. **"이전 핸들러와 다르면 retract"라는 diff는 `Dispatch.process`
|
||||
자신의 일이 아님** — 재귀/래핑 핸들러(`StoreBind`/
|
||||
`NoneHandler`)가 재-dispatch 전에 스스로 `Dispatch.retractUnder(inst,
|
||||
k, self, newV)`를 먼저 불러 자기 밑을 정리하는 책임을 짐(정확한
|
||||
메커니즘·기각된 대안은 아래 "Dispatch 체인" 절 참고 — 전역 소유자
|
||||
슬롯 하나로 diff하는 안은 래핑 핸들러에서 깨져서 기각됨).
|
||||
- `Dispatch.process(inst,k,v,index)` — 오케스트레이터: 그 인덱스가 이미
|
||||
점유돼 있으면 즉시 error(핸들러 호출 전에 걸러짐) → 아니면
|
||||
`getHandler` 호출 → 매치된 핸들러의 `.process`를 불러 그 반환값
|
||||
(retractor 클로저)을 `(inst,k)` 체인의 그 인덱스에 저장. **"이전
|
||||
핸들러와 다르면 retract"라는 diff는 `Dispatch.process` 자신의 일이
|
||||
아님** — 재귀/래핑 핸들러(`StoreBind`/`NoneHandler`)가 재-dispatch
|
||||
전에 스스로 `Dispatch.retractFrom(inst, k, index + 1, newV)`를 먼저
|
||||
불러 자기 밑을 정리하는 책임을 짐(정확한 메커니즘·기각된 대안은
|
||||
아래 "Dispatch 체인" 절 참고 — 전역 소유자 슬롯 하나로 diff하는
|
||||
안은 래핑 핸들러에서 깨져서 기각됨).
|
||||
- `Dispatch.addHandler(handler: Handler)` — 핸들러를 우선순위 레지스트리에
|
||||
등록. `Dispatch.process`/`getHandler`와 마찬가지로 base엔 인터페이스만
|
||||
있고, quad-roblox의 concrete Handler들(PropertyHandler/EventHandler/
|
||||
|
|
@ -352,18 +377,19 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출
|
|||
프로퍼티(Color3/number 등)에 도달하면 `inst[k] = nil`은 런타임 에러 —
|
||||
PropertyHandler 자신이 `v == nil`이면 셋을 건너뛰는 방어를 갖고 있어야
|
||||
함(None 자체의 문제가 아니라 PropertyHandler 구현 디테일, M9/M10로 미룸).
|
||||
- **`retract`는 여기서 할 일이 없음** — **[정정, 2026-08-12 열한 번째
|
||||
세션]** `retract`는 store 재발행마다 항상 불리지만(위 "확정된 디스패치
|
||||
모델"/일반 retract 계약 절 정정분 참고), `NoneHandler`는 `v==None`을
|
||||
매치했을 때 재귀 호출로 곧바로 `Dispatch.process(inst,k,nil)`을
|
||||
- **반환하는 retractor는 여기서 할 일이 없음** — `NoneHandler`는 `v==None`을
|
||||
매치했을 때 재귀 호출로 곧바로 `Dispatch.process(inst,k,nil,index+1)`을
|
||||
부르는 게 전부고 자기 자신이 들고 있는 별도 상태가 없어서(`Relate` 등
|
||||
전혀 안 씀) `retract`가 불려도 정리할 게 없음 — 일반 프로퍼티 핸들러가
|
||||
`retract`를 no-op으로 두는 것과 같은 이유.
|
||||
- **[해소됨, 2026-08-08 세 번째 세션]** "이 키를 지금 누가 담당 중인가"
|
||||
bookkeeping — `pre-implementation-audit.md` 우선순위1 "이전에 실제로
|
||||
매치됐던 핸들러 추적" 항목이 여기서 다시 언급됐던 것. 아래 "Dispatch
|
||||
체인" 절의 `chains`/`Dispatch.retractUnder`로 구체화됨 — `NoneHandler`의
|
||||
재귀 재호출도 이 메커니즘 위에서 동일하게 동작(`None`으로 유지되는 매
|
||||
전혀 안 씀) `function() end`(no-op)만 반환하면 됨 — 일반 프로퍼티
|
||||
핸들러가 no-op 클로저를 반환하는 것과 같은 이유. 자기 아래(index+1)에
|
||||
쌓인 것의 정리는 `Dispatch.retractFrom`의 순회 구조가 대신해줌(위
|
||||
"핸들러 계약" 절 참고), `NoneHandler` 자신이 손댈 필요 없음.
|
||||
- **[해소됨, 2026-08-08 세 번째 세션, 2026-08-13 다섯 번째 세션에
|
||||
인덱스 기반으로 재정정]** "이 키를 지금 누가 담당 중인가" bookkeeping —
|
||||
`pre-implementation-audit.md` 우선순위1 "이전에 실제로 매치됐던 핸들러
|
||||
추적" 항목이 여기서 다시 언급됐던 것. 아래 "Dispatch 체인" 절의
|
||||
`chains`/`Dispatch.retractFrom`로 구체화됨 — `NoneHandler`의 재귀
|
||||
재호출도 이 메커니즘 위에서 동일하게 동작(`None`으로 유지되는 매
|
||||
사이클마다 담당자가 자연히 정확하게 갱신됨, 별도 특수 처리 불필요).
|
||||
|
||||
### Dispatch는 프리미티브가 아니다 — 탑레벨 싱글톤 확정 (2026-08-08 두 번째 세션)
|
||||
|
|
@ -383,8 +409,8 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출
|
|||
비용이 생기는데, 지금 형태는 그 비용을 아예 안 짐.
|
||||
- **순환참조로 보이는 건 착시 — 실제로는 단방향.** "Handler"라는 말이 두
|
||||
가지를 가리켜서 헷갈릴 수 있음: (a) `Handler.luau`의 **타입 계약**
|
||||
(`isHandlable`/`priority`/`process`/`retract` 시그니처만 있는 순수 leaf,
|
||||
Dispatch를 몰라도 됨) vs (b) `StoreBind.luau`처럼 그 계약을
|
||||
(`isHandlable`/`priority`/`process`(반환값 포함) 시그니처만 있는 순수
|
||||
leaf, Dispatch를 몰라도 됨) vs (b) `StoreBind.luau`처럼 그 계약을
|
||||
**구현하는 concrete 값 모듈**(재귀호출 위해 Dispatch를 require함). 의존
|
||||
방향은 항상 한쪽으로만 흐름 — `Handler.luau`(leaf) ← `Dispatch/init.luau`
|
||||
(`addHandler(h: Handler)`가 `Handler` 타입만 참조) ← `StoreBind.luau`
|
||||
|
|
@ -419,151 +445,142 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출
|
|||
딸려가고, 호출부는 `module.Dispatch.process(...)`처럼 그 인스턴스
|
||||
테이블을 통해 접근하게 됨 — 지금 미리 프리미티브화해둘 이유가 없음.
|
||||
|
||||
### Dispatch 체인 — 재귀 재-dispatch의 retract 전파, `Dispatch.retractUnder` (2026-08-08 세 번째 세션)
|
||||
### Dispatch 체인 — 인덱스 기반 재추적, `Dispatch.retractFrom` (2026-08-08 세 번째 세션 신설, 2026-08-13 다섯 번째 세션 전면 재설계)
|
||||
|
||||
**문제**: `NoneHandler`/`StoreBind`처럼 자기 `process` 안에서
|
||||
`Dispatch.process(inst,k,realv)`를 다시 부르는 래핑 핸들러가 있으면, 같은
|
||||
`(inst,k)`에 대해 "지금 누가 담당 중인가"를 슬롯 하나로 추적하는 순간
|
||||
깨짐 — 래핑 핸들러 A 자신의 생명주기(예: StoreBind의 Observer 구독)와,
|
||||
A가 재귀로 위임한 핸들러 B의 생명주기가 **같은 슬롯을 두고 서로
|
||||
덮어씀**. 구체적으로: A의 재귀 진입 시점에 슬롯을 A→B로 갱신해두면, A가
|
||||
스스로 다시 값을 재계산해 재-dispatch할 때(예: store 값이 또 바뀜) 그
|
||||
슬롯엔 이미 B가 적혀있어 "A로 바뀌었다"고 오판해 A 자신을 엉뚱하게
|
||||
retract하거나, 반대로 A가 자길 스스로 retract하는 오작동이 남 — 처음
|
||||
검토했던 "Dispatch 전역 소유자맵 슬롯 하나" 안은 이 이유로 기각됨(당시
|
||||
대화에서 직접 반례로 확인).
|
||||
**[전면 재설계, 2026-08-13 다섯 번째 세션]** 이 절은 원래 `chains`를
|
||||
핸들러 **객체 identity**로 추적했으나(옛 버전은 `archive/
|
||||
checkpoint-handler-pattern-reversed.md`에 그 위에 얹혔던 체크포인트
|
||||
패턴과 함께 보존), 그 표현 자체가 `State<State<T>>`를 UB로 만든
|
||||
근본 원인이었음이 드러나 **재귀 깊이를 나타내는 정수 인덱스**로
|
||||
추적하도록 다시 설계됨. 계기: `AttributeGroupHandler`의 소유권 충돌
|
||||
버그를 고치려고 체크포인트 핸들러 패턴을 얹었다가, 사용자가 "그 문제도
|
||||
결국 identity 기반 추적이 원인 아니냐"고 되짚으면서 인덱스 기반으로
|
||||
가면 체크포인트 없이도 같은 문제가 풀리고 `State<State<T>>` UB 자체도
|
||||
없앨 수 있다는 게 같은 세션 안에서 확인됨.
|
||||
|
||||
**해법 — Dispatch가 `(inst,k)`별 핸들러 체인(순서 있는 배열)을 소유**:
|
||||
**문제(원래 동기, 여전히 유효)**: `NoneHandler`/`StoreBind`처럼 자기
|
||||
`process` 안에서 `Dispatch.process(inst,k,realv)`를 다시 부르는 래핑
|
||||
핸들러가 있으면, 같은 `(inst,k)`에 대해 "지금 누가 담당 중인가"를 슬롯
|
||||
하나로 추적하는 순간 깨짐 — 래핑 핸들러 A 자신의 생명주기(예: StoreBind의
|
||||
Observer 구독)와, A가 재귀로 위임한 핸들러 B의 생명주기가 **같은 슬롯을
|
||||
두고 서로 덮어씀**. 처음 검토했던 "Dispatch 전역 소유자맵 슬롯 하나" 안은
|
||||
이 이유로 기각됨.
|
||||
|
||||
**해법 — Dispatch가 `(inst,k)`별로 인덱스 배열을 소유, 각 슬롯엔 그
|
||||
`process` 호출이 반환한 retractor 클로저만 저장**:
|
||||
|
||||
```lua
|
||||
-- Dispatch/init.luau
|
||||
local chains = Relate() -- {[inst(weak)] = {[k] = {handler, handler, ...}(strong, 순서 있는 배열)}}
|
||||
local chains = Relate() -- {[inst(weak)] = {[k] = {[index] = retractor}(strong)}}
|
||||
|
||||
function Dispatch.process(inst, k, v)
|
||||
local h = Dispatch.getHandler(inst, k, v)
|
||||
if h then
|
||||
local list = chains:GetStrong(inst, k) or {}
|
||||
for _, existing in list do
|
||||
if existing == h then
|
||||
error("Dispatch: handler already active for this (inst,k) — re-entrant dispatch (e.g. State<State<T>>) is not supported")
|
||||
end
|
||||
end
|
||||
table.insert(list, h) -- 항상 꼬리에 추가
|
||||
chains:SetStrong(inst, k, list)
|
||||
h.process(inst, k, v)
|
||||
function Dispatch.process(inst, k, v, index)
|
||||
local list = chains:GetStrong(inst, k) or {}
|
||||
if list[index] ~= nil then
|
||||
error("Dispatch: (inst,k)의 이 인덱스는 이미 점유돼 있음 — 먼저 retract할 것")
|
||||
end
|
||||
local h = Dispatch.getHandler(inst, k, v) -- 매치 실패는 기존 규칙대로 즉시 error
|
||||
list[index] = h.process(inst, k, v, index) -- 반환값 = retractor 클로저, 생략 불가
|
||||
chains:SetStrong(inst, k, list)
|
||||
end
|
||||
|
||||
function Dispatch.retractUnder(inst, k, keep, v)
|
||||
function Dispatch.retractFrom(inst, k, index, v)
|
||||
-- index부터(포함) 끝까지, 꼬리(가장 깊은 인덱스)부터 역순으로 정리.
|
||||
local list = chains:GetStrong(inst, k)
|
||||
if not list then return end
|
||||
local cutoff = 0
|
||||
if keep then
|
||||
for i, h in list do if h == keep then cutoff = i break end end
|
||||
end
|
||||
for i = #list, cutoff + 1, -1 do
|
||||
list[i].retract(inst, k, if i == cutoff + 1 then v else nil)
|
||||
for i = #list, index, -1 do
|
||||
local retractor = list[i]
|
||||
if retractor then
|
||||
retractor(if i == index then v else nil)
|
||||
end
|
||||
list[i] = nil
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**[2026-08-12 세션에서 정정, 같은 세션 후속으로 예외 조항까지 폐기]**
|
||||
`list[i].retract(...)`의 세 번째 인자가 원래 `i == cutoff + 1 and v or
|
||||
nil`(and/or 삼항 관용구)이었으나, `v`가 `false`일 때(정당한 boolean
|
||||
프로퍼티 값) `and`의 결과가 falsy가 되어 `i == cutoff + 1`이 참이어도
|
||||
`or nil`로 새는 조용한 버그였음 — Luau의 `if-then-else` 표현식(2021년
|
||||
도입)으로 교체. **일반 규칙(강화): `cond and x or y` 삼항 관용구는
|
||||
전면 금지, `if cond then x else y`만 쓸 것** — 처음엔 "가운데 값이
|
||||
테이블/항상-truthy일 때만 예외적으로 and/or 허용"이었으나, 안전 여부와
|
||||
무관하게 `if-then-else`가 항상 우월하다는 게 재확인돼 예외 자체를
|
||||
없앰: `and`/`or`는 진짜 short-circuit이라 각 단계마다 truthiness를
|
||||
테스트하는 명령이 들어가는데(최대 2회 분기), `if-then-else`는 `cond`
|
||||
하나만 테스트하고 단일 분기로 끝남 — 안전한 경우에도 `if-then-else`
|
||||
쪽이 바이트코드상 더 적은 분기. `base/architecture.md`의 "코드 스타일
|
||||
— Luau 문법 관례" 절도 같이 갱신.
|
||||
|
||||
- **`handler.process(inst,k,v)`를 `Dispatch.process`를 거치지 않고 직접
|
||||
호출하는 것은 UB — 반드시 `Dispatch.process`를 통해서만 진입할 것.**
|
||||
이유: `chains` 배열에 push하는 bookkeeping이 `Dispatch.process` 내부에만
|
||||
있어서, `handler.process`를 직접 부르면 그 핸들러가 실제로 활성화됐는데도
|
||||
체인에 안 올라가 — 나중에 다른 값으로 바뀌어도 `retractUnder`가 이
|
||||
핸들러의 존재를 몰라 `retract`가 영영 안 불리거나(리소스 누수), 반대로
|
||||
체인 순서 자체가 실제 활성 상태와 어긋나는 정합성 붕괴로 이어짐. 재귀/
|
||||
래핑 핸들러가 위임할 때도 항상 `Dispatch.process(inst,k,newV)`를
|
||||
불러야지 매치된 핸들러의 `.process`를 스스로 찾아 직접 호출하면 안 됨.
|
||||
- **재귀/래핑 핸들러는 재-dispatch 전에 반드시 `Dispatch.retractUnder(inst,
|
||||
k, self, newV)`를 먼저 부른 뒤 `Dispatch.process(inst, k, newV)`를
|
||||
부름** — "나 밑에 있던 걸 전부 정리하고 새로 위임". `keep`(자기 자신)
|
||||
바로 다음 항목만 실제 `newV`를 받고, 그보다 더 안쪽(다단 체인이 있을
|
||||
경우)은 `nil`을 받음 — 더 안쪽 항목엔 "구체적으로 뭐로 대체됐는지"
|
||||
정보가 없고 "완전히 사라진다"는 것만 사실이라서.
|
||||
- **개별 핸들러의 `retract`는 더 이상 자기 위임 대상을 수동으로 안
|
||||
쫓아가도 됨** — `retractUnder`가 꼬리부터 `keep` 앞까지 한 번의
|
||||
루프로 체인 전체를 순서대로 정리해주므로, A→B→C처럼 몇 단계든 각
|
||||
핸들러는 **자기 자신의 자원만** 정리하면 자동으로 전파됨(질문
|
||||
제기됐던 "다단 체인에서 안쪽까지 retract가 안 간다" 문제가 이걸로
|
||||
해소 — `retractUnder`의 루프 자체가 체인 전체를 훑으므로 각 핸들러가
|
||||
수동으로 cascade할 필요가 원천적으로 없음).
|
||||
- **구멍 걱정 없음** — 이 배열은 항상 꼬리에서만 추가/삭제되는 스택
|
||||
모양이라(`retractUnder`가 항상 꼬리부터 연속으로 지움), "촘촘하지
|
||||
않은 정수 키는 순회 순서가 깨진다"는 문제(위 "PreRef" 절의 `None`
|
||||
소진 이슈)가 애초에 발생할 구조가 아님.
|
||||
- **`retract`는 여전히 `(inst,k,v)` 3-인자** — 드롭하자는 제안이 대화
|
||||
중 한 번 나왔으나 기각(전체 삭제 vs 부분 diff를 갈라야 하는 핸들러가
|
||||
있어서, `base/tag-plan.md` 참고). 다만 `v`가 실제로 필요한지는
|
||||
핸들러마다 다름 — **[정정, 2026-08-12 열한 번째 세션]** Tag는 오히려
|
||||
반대로 `v`를 반드시 봐야 하는 대표 사례다: `retract`는 store
|
||||
재발행마다(핸들러 타입이 안 바뀌어도) 항상 불리므로, `TagHandler.retract`는
|
||||
`v`가 여전히 그 이름을 `Contains`하는지 힌트로 확인해 실제 엔진
|
||||
`RemoveTag` 호출만 skip한다 — "더 이상 매치 안 될 때만 불리므로 무조건
|
||||
전체 삭제가 맞다"는 원 서술은 이 정정 전 잘못된 가정이었음, 상세는
|
||||
`base/tag-plan.md` "메커니즘" 절 참고. `v`는 "계약상 항상 주어지지만
|
||||
안 쓰는 핸들러가 있어도 됨" 정도로 이해할 것.
|
||||
**[정정, 2026-08-10 세션]** 원래 두 번째 예시로 들었던 Tween(자기
|
||||
`Relate` 저장분만 보고 `Cancel`하면 되니 `v`를 꼭 안 봐도 됨)은 더
|
||||
이상 유효한 예시가 아님 — PropertyHandler가 항상 매치되는 유일한
|
||||
핸들러가 되어 이 `retract` 경로 자체가 사실상 안 쓰임(`base/
|
||||
tween-plan.md`).
|
||||
- **점유 체크는 핸들러를 부르기 전에, Dispatch가 직접 함** — 매치된
|
||||
핸들러의 `.process`가 실제 부작용(`SetAttribute`, Observer 구독 등)을
|
||||
일으키기 *전에* 걸러야, "일단 실행해보고 나중에 되돌리는" 낭비/깜빡임이
|
||||
없음. 충돌이 나면 핸들러 코드는 한 줄도 안 돔. **에러 메시지가
|
||||
도메인 특화(예: "attribute 이름 foo 충돌")로 상세하지 않은 건 의도된
|
||||
트레이드오프** — 에러가 나는 순간 이미 처리는 패닉 상태이고 그 이후의
|
||||
정합성은 애초에 관리 대상이 아니므로(2026-08-04 "일반적 무한루프 방어
|
||||
안 함"과 같은 결), 상세한 설명을 위해 먼저 실행해보는 비용을 들일
|
||||
이유가 없음. 필요하면 호출부(`AttributeGroupHandler` 등)가 이 에러를
|
||||
잡아 도메인 언어로 다시 던지는 건 자유(강제 안 함).
|
||||
- **인덱스의 의미 — 재귀 깊이, 서로 다른 키는 항상 1부터**: 같은 키에서
|
||||
값이 한 겹 더 반응형으로 감싸져 재귀하면(`StoreBind`가 `realv`를 들고
|
||||
다시 `Dispatch.process`를 부르는 경우) `index+1`을 넘김. **다른
|
||||
키로 위임할 때는 그 키의 재귀 깊이와 무관하게 항상 `1`부터 시작** —
|
||||
`chains[inst][key2]`는 `chains[inst][key1]`과 완전히 별개의 배열이라
|
||||
연속성이 필요 없음(예: `Attribute` 그룹이 `(inst,index)`에서
|
||||
`(inst,AttributeKey(name))`으로 위임할 때). **시작 인덱스는 0이 아니라
|
||||
1** — Luau `ipairs`/`#`(배열 part 순회)는 1부터 연속된 정수 키를
|
||||
전제하므로(quad 자신이 "props 순회 순서" 절에서 이 관례에 의존), 0을
|
||||
쓰면 그 항목이 `ipairs` 순회에서 조용히 빠지고 `quad-debug`가 나중에
|
||||
`chains`를 그대로 순회해서 보여주려는 계획과도 부딪힘.
|
||||
- **`handler.process(inst,k,v,index)`를 `Dispatch.process`를 거치지 않고
|
||||
직접 호출하는 것은 UB — 반드시 `Dispatch.process`를 통해서만 진입할
|
||||
것.** 이유: 점유 체크·`chains` 저장 bookkeeping이 `Dispatch.process`
|
||||
내부에만 있어서, `handler.process`를 직접 부르면 그 핸들러가 실제로
|
||||
활성화됐는데도 체인에 안 올라가 — 나중에 `retractFrom`이 이 핸들러의
|
||||
존재를 몰라 정리가 영영 안 되거나(리소스 누수), 반대로 같은 인덱스를
|
||||
다른 핸들러가 또 점유 시도해 정합성이 깨짐.
|
||||
- **재귀/래핑 핸들러는 재-dispatch 전에 반드시 `Dispatch.retractFrom(inst,
|
||||
k, index + 1, newV)`를 먼저 부른 뒤 `Dispatch.process(inst, k, newV,
|
||||
index + 1)`를 부름** — "내 바로 아래부터 전부 정리하고 새로 위임".
|
||||
자기 자신(`index`)은 그대로 살아있으므로 자기 자신의 retractor는 이
|
||||
시점에 안 불림 — 자기가 완전히 사라질 때(더 바깥의 `retractFrom`이
|
||||
자기 인덱스까지 포함해서 부를 때)만 불림.
|
||||
- **개별 핸들러의 retractor는 더 이상 자기 위임 대상을 수동으로 안
|
||||
쫓아가도 됨** — `retractFrom`이 꼬리(가장 깊은 인덱스)부터 목표
|
||||
인덱스까지 한 번의 루프로 순서대로 정리해주므로, A→B→C처럼 몇 단계든
|
||||
각 핸들러는 **자기 자신의 자원만** 정리하면 자동으로 전파됨. 자기
|
||||
자신을 포함해서 지우고 싶으면(`Attribute` 그룹처럼 위임한 것 전체를
|
||||
통째로 걷어내고 싶은 경우) 호출자가 자기 자신의 인덱스를 그대로
|
||||
넘기면 되고, 자기 아래만 지우고 싶으면(`StoreBind`가 자기 구독은
|
||||
유지한 채 하위만 갈아치우는 경우) `index+1`을 넘기면 됨 — **"미만"과
|
||||
"이하"를 별도 함수로 안 쪼개고 호출자가 넘기는 인덱스 하나로 통일**
|
||||
(옛 `retractUnder`/`retractSelfAndUnder` 두 함수가 이걸로 하나가 됨,
|
||||
`archive/checkpoint-handler-pattern-reversed.md` 참고).
|
||||
- **`process`가 반환하는 retractor는 여전히 `hintValue` 1-인자** — 드롭하자는
|
||||
제안이 대화 중 한 번 나왔으나 기각(전체 삭제 vs 부분 diff를 갈라야
|
||||
하는 핸들러가 있어서, `base/tag-plan.md` 참고). `Tag`는 오히려
|
||||
이 힌트를 반드시 봐야 하는 대표 사례다: 이 클로저는 store 재발행마다
|
||||
(핸들러 타입이 안 바뀌어도) 항상 불리므로, `TagHandler`가 반환한
|
||||
클로저는 `hintValue`가 여전히 그 이름을 `Contains`하는지 확인해 실제
|
||||
엔진 `RemoveTag` 호출만 skip한다 — 상세는 `base/tag-plan.md` "메커니즘"
|
||||
절 참고. `hintValue`는 "계약상 항상 주어지지만 안 쓰는 핸들러가
|
||||
있어도 됨" 정도로 이해할 것.
|
||||
- **순환은 UB, 방어 로직 없음** — Handler 간 순환 참조(A가 B를 부르고
|
||||
B가 다시 A로 돌아오는 것)는 재귀 호출이 안 끝나 바로 스택오버플로가
|
||||
나므로 애초에 일어날 수 없는 구조(각 핸들러는 최대 한 번씩만 그
|
||||
키에서 호출됨을 전제) — 값에 별도 플래그를 심어 의도적으로 순환을
|
||||
만드는 것도 이론상 가능하지만 use case가 없어 문서화 대상 밖,
|
||||
B가 다시 A로 돌아오는 것, 또는 값 자체가 결국 자기 자신을 가리켜
|
||||
무한히 깊어지는 인덱스)는 재귀 호출이 안 끝나 바로 스택오버플로가
|
||||
나므로 애초에 일어날 수 없는 구조 — 값에 별도 플래그를 심어 의도적으로
|
||||
순환을 만드는 것도 이론상 가능하지만 use case가 없어 문서화 대상 밖,
|
||||
2026-08-04 세션에 이미 확정된 "일반적 무한루프 방어 안 함" 원칙과
|
||||
같은 결로 UB 취급.
|
||||
- **[2026-08-13 세션 발견·수정] "각 핸들러는 최대 한 번씩만 그 키에서
|
||||
호출됨" 전제는 순환뿐 아니라 `State<State<T>>`(store가 emit하는 값
|
||||
자체가 또 State/Source인 경우)에서도 깨짐 — 단 순환과 달리
|
||||
스택오버플로로 걸러지지 않고 **조용히 체인을 파손시키는 실제 버그로
|
||||
재현됨**. `store.key = a`(State), `a:Get() = b`(State)일 때: (1)
|
||||
`Dispatch.process(inst,k,a)`가 StoreBind를 매치해 `list={SB}` 생성,
|
||||
`Observer` 즉시 1회 실행이 `Dispatch.process(inst,k,b)`로 재귀; (2) `b`도
|
||||
State라 StoreBind가 **같은 핸들러 객체로 또 매치**돼 `list={SB,SB}`가
|
||||
되고, 안쪽 Observer가 `relate:SetStrong(inst,k,·)`로 바깥 Observer의
|
||||
참조를 덮어써 바깥 것이 `bindLifetime`엔 살아있는데 `relate`에서 못
|
||||
찾는 유령 구독으로 새고; (3) 안쪽 Observer의 즉시 실행이 부르는
|
||||
`retractUnder(inst,k,SB,·)`는 위 457행 루프가 **첫 번째**로 매치되는
|
||||
`SB`(바깥 것의 인덱스)를 `cutoff`로 잡아버려, 그 다음 인덱스(안쪽
|
||||
자기 자신)를 대신 retract — **안쪽 State의 구독이 등록 직후 자기
|
||||
자신에 의해 끊겨 이후 `b:Set(...)`이 조용히 무시됨.** Attribute의
|
||||
`rawNew(name)` 위임은 별개의 `(inst, key)` 체인으로 옮겨가는 것뿐이라
|
||||
이 문제를 원천적으로 피하지 못함(위임된 값 자체가 `State<State<T>>`이면
|
||||
`AttributeKey` 체인 안에서 동일하게 재현). **고정**: 위 `Dispatch.process`
|
||||
pseudocode에 "같은 `(inst,k)`에 이미 같은 핸들러 객체가 push돼 있으면
|
||||
즉시 error" 가드를 추가 — 순환과 같은 전제(핸들러당 최대 1회)를 지키는
|
||||
가장 저비용 지점이 여기(push 직전)라서. `State<State<T>>`가 정당한 값
|
||||
모양인지 자체는 별도 논의(아래 "Store가 Store를 저장 가능한가" 참고) —
|
||||
이 가드는 그 논의와 무관하게 체인 파손을 조용히 넘기지 않게만 함.
|
||||
- **`State<State<T>>`는 더 이상 UB가 아님 — 정상 지원 대상으로 재정정
|
||||
(2026-08-13 다섯 번째 세션).** 원래(같은 날 두 번째 세션) `store.key
|
||||
= a`(State), `a:Get() = b`(State)일 때 같은 `StoreBind` 싱글톤이 같은
|
||||
`(inst,k)`에 identity로 두 번 매치돼 `retractUnder`의 cutoff 계산이
|
||||
안쪽 자신을 잘못 retract하는 실제 버그(체인 파손, 구독이 등록 직후
|
||||
스스로 끊김)로 재현돼 "같은 (inst,k)에 같은 핸들러 객체가 이미 있으면
|
||||
즉시 error" 가드로 막았었음(당시 고정, `archive/
|
||||
checkpoint-handler-pattern-reversed.md`가 인용하는 옛 코드 참고). 이
|
||||
버그의 근본 원인은 "핸들러당 최대 한 번씩만 그 키에서 호출됨"을
|
||||
객체 identity로 강제하려 한 것 — 인덱스 기반으로 바꾸면 `a`를 처리하는
|
||||
StoreBind는 인덱스 N, `a:Get()`(=`b`)을 처리하는(같은 싱글톤이지만)
|
||||
StoreBind는 인덱스 N+1을 써서 **애초에 슬롯이 안 겹침** — 같은 핸들러
|
||||
객체가 같은 키에서 여러 번 매치되는 것 자체가 문제가 아니게 됨.
|
||||
임의 깊이의 `State<State<State<...>>>`도 그냥 인덱스가 계속 늘어날
|
||||
뿐 정상 동작 — 유일하게 남는 UB는 위 "순환" 항목(값이 결국 순환 참조를
|
||||
이뤄 무한히 깊어지는 경우)뿐.
|
||||
- **부수 효과 — 미래 재바인드/quad-debug에 유리**: 이 체인이 Dispatch에
|
||||
중앙화돼 있으므로, `research/existing-instance-bind-plan.md`가 다룰
|
||||
미래의 재바인드는 `Dispatch.retractUnder(inst, k, nil, newV);
|
||||
Dispatch.process(inst, k, newV)` 두 줄로 "이 키의 체인을 통째로 갈아
|
||||
끼우기"가 자연스럽게 됨(각 래핑 핸들러가 자기 전용 `Relate`에 위임
|
||||
대상을 비공개로 숨겨두는 대안 설계는 이게 안 됨 — 대화 중 검토 후
|
||||
기각). `research/debug-tooling-plan.md`의 "무엇이 무엇에 연결됐는가"
|
||||
그래프도 이 `chains` 구조를 그대로 읽으면 됨 — quad-debug 착수 시점에
|
||||
새로 설계할 필요 없음.
|
||||
미래의 재바인드는 `Dispatch.retractFrom(inst, k, 1, newV);
|
||||
Dispatch.process(inst, k, newV, 1)` 두 줄로 "이 키의 체인을 통째로 갈아
|
||||
끼우기"가 자연스럽게 됨. `research/debug-tooling-plan.md`의 "무엇이
|
||||
무엇에 연결됐는가" 그래프도 이 `chains` 구조를 그대로 읽으면 됨 —
|
||||
quad-debug 착수 시점에 새로 설계할 필요 없음.
|
||||
|
||||
### Length/Offset — 여러 Slot이 형제로 섞일 때 순서 보장 (2026-08-09 여섯 번째 세션)
|
||||
|
||||
|
|
@ -833,16 +850,19 @@ parentInst`를 직접 호출해 Slot이 마운트해둔 부모 밑에 자식을
|
|||
재실행하는 래핑으로 쓸지 생각해봐야함... 충분히 확장 가능하게 둘 수 있음."
|
||||
|
||||
**확정**: 래핑 쪽. 위 "확정된 디스패치 모델" 절 참고 — store 바인드 핸들러도
|
||||
다른 핸들러와 동일한 `isHandlable`/`priority`/`process`/`retract` 계약을
|
||||
다른 핸들러와 동일한 `isHandlable`/`priority`/`process`(반환값 포함) 계약을
|
||||
따르되, 자신의 `process`가 내부적으로 "실제 값이 바뀔 때마다 (원래 key, 새
|
||||
value)로 `Dispatch.process(inst,k,realv)`를 재귀 호출"하는 식으로 구현됨.
|
||||
이러면 store 값 자체가 대부분의 타입(원시값, 인스턴스 등)에 대해 동일한
|
||||
재귀적 디스패치로 처리 가능 — 아래 "store가 store를 저장 가능한가"와
|
||||
직결. **[2026-08-13 세션 정정]** 단 "심지어 다른 store"는 낙관적으로
|
||||
틀린 서술이었음 — 실제로 값이 또 State/Source면(`State<State<T>>`) 같은
|
||||
핸들러가 같은 `(inst,k)`에 두 번 push되어 체인이 파손됨(위 "확정된
|
||||
디스패치 모델" 절의 2026-08-13 발견 항목 참고), 이제 `Dispatch.process`의
|
||||
중복 핸들러 가드가 이 경우 error로 막음.
|
||||
value)로 `Dispatch.process(inst,k,realv,index+1)`를 재귀 호출"하는 식으로
|
||||
구현됨. 이러면 store 값 자체가 대부분의 타입(원시값, 인스턴스 등)에 대해
|
||||
동일한 재귀적 디스패치로 처리 가능 — 아래 "store가 store를 저장 가능한가"와
|
||||
직결. **[2026-08-13 세션, 두 차례 정정]** 이 "동일한 재귀적 디스패치로
|
||||
처리 가능"은 처음엔 값이 또 State/Source면(`State<State<T>>`) 같은
|
||||
핸들러가 같은 `(inst,k)`에 identity로 두 번 push돼 체인이 파손되는 실제
|
||||
버그로 낙관적으로 틀린 서술임이 드러났었으나(같은 날 두 번째 세션), 같은
|
||||
날 다섯 번째 세션에 `chains`를 핸들러 identity가 아니라 재귀 깊이
|
||||
인덱스로 추적하도록 재설계되며 **다시 맞는 서술로 돌아옴** — `realv`가
|
||||
또 State면 `index+1`이라는 별개 슬롯을 쓰므로 identity 충돌 자체가 없어짐
|
||||
(위 "확정된 디스패치 모델"/"Dispatch 체인" 절 참고).
|
||||
|
||||
**"값이 바뀔 때마다"의 실제 구독 메커니즘 = `state:Observer(fn)` 재사용으로
|
||||
확정(2026-08-08 세션).** 이전엔 이 절이 구독 메커니즘 자체를 추상적으로만
|
||||
|
|
@ -851,13 +871,21 @@ value)로 `Dispatch.process(inst,k,realv)`를 재귀 호출"하는 식으로 구
|
|||
구독 primitive를 store-bind 전용으로 따로 만들 이유가 없음:
|
||||
|
||||
```lua
|
||||
-- 예: 일반 프로퍼티 store-bind 핸들러의 process(inst, k, state)
|
||||
local observer = state:Observer(function()
|
||||
Dispatch.retractUnder(inst, k, StoreBind, state:Get()) -- 나 밑에 있던 거 정리
|
||||
Dispatch.process(inst, k, state:Get()) -- 새로 위임(체인에 push)
|
||||
end)
|
||||
bindLifetime(inst, observer)
|
||||
relate:SetStrong(inst, k, observer) -- retract에서 unbindLifetime을 부르려면 들고 있어야 함
|
||||
-- 예: 일반 프로퍼티 store-bind 핸들러의 process(inst, k, state, index)
|
||||
function StoreBind.process(inst, k, state, index)
|
||||
local observer = state:Observer(function()
|
||||
local realv = state:Get()
|
||||
Dispatch.retractFrom(inst, k, index + 1, realv) -- 내 바로 아래부터 정리
|
||||
Dispatch.process(inst, k, realv, index + 1) -- 새로 위임(체인에 push)
|
||||
end)
|
||||
bindLifetime(inst, observer)
|
||||
return function()
|
||||
-- 자기 자신의 자원(Observer 구독)만 정리 — observer는 위 클로저가
|
||||
-- upvalue로 이미 캡처하고 있어 별도 Relate 저장/조회가 필요 없음
|
||||
-- (2026-08-13 다섯 번째 세션, 계약이 클로저 반환으로 바뀌며 단순화됨).
|
||||
unbindLifetime(inst, observer)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**[정정, 2026-08-09 여섯 번째 세션] `:Subscribe()`/`:Unsubscribe()`가
|
||||
|
|
@ -872,13 +900,17 @@ relate:SetStrong(inst, k, observer) -- retract에서 unbindLifetime을 부르
|
|||
바인딩 금지" 절의 정정 참고(leaf 부착도 사실 `bindLifetime` 호출이라,
|
||||
`:Subscribe()`와 상호 배타적인 건 leaf가 아니라 "전역이냐 inst냐"임).
|
||||
|
||||
- **`retract`가 할 일은 `unbindLifetime(inst, observer)` 호출뿐 — 위임
|
||||
대상까지 수동으로 안 쫓아가도 됨.** `Dispatch.retractUnder`가 자기
|
||||
- **반환하는 클로저가 할 일은 `unbindLifetime(inst, observer)` 호출뿐 —
|
||||
위임 대상까지 수동으로 안 쫓아가도 됨.** `Dispatch.retractFrom`이 자기
|
||||
밑에 위임된 걸 알아서 정리해주므로(위 "Dispatch 체인" 절), 이
|
||||
핸들러의 `retract`는 정확히 자기 자신의 자원(Observer)만 정리하면
|
||||
끝 — 이게 위 "이벤트도 store-bind 가능" 절에서 이미 "엔지니어링
|
||||
비용이 낮다"고 서술한 것과 같은 이유(새 디스패치 메커니즘 없이 기존
|
||||
계약만 구현).
|
||||
클로저는 정확히 자기 자신의 자원(Observer)만 정리하면 끝 — 이게 위
|
||||
"이벤트도 store-bind 가능" 절에서 이미 "엔지니어링 비용이 낮다"고
|
||||
서술한 것과 같은 이유(새 디스패치 메커니즘 없이 기존 계약만 구현).
|
||||
**[2026-08-13 다섯 번째 세션] 별도 `Relate`가 더 이상 필요 없음** —
|
||||
`observer`는 `process` 안의 로컬 변수를 반환 클로저가 upvalue로 그대로
|
||||
캡처하므로, 예전처럼 `relate:SetStrong(inst,k,observer)`로 저장해뒀다가
|
||||
나중에 `relate:GetStrong(inst,k)`로 다시 찾아올 필요가 없어짐(위
|
||||
"핸들러 계약"/"핸들러 내부 상태 저장" 절 참고).
|
||||
- **핸들러가 직접 `canExecute`/liveness를 재구현할 필요 없음** — Observer가
|
||||
이미 자기 `Subscribed` 상태로 게이팅됨(아래 `base/lifecycle-pattern.md`의
|
||||
`canExecute(inst, value)` 절 참고, Observer/Effect는 그 함수 안에서
|
||||
|
|
@ -887,8 +919,6 @@ relate:SetStrong(inst, k, observer) -- retract에서 unbindLifetime을 부르
|
|||
- Observer가 "등록 즉시 1회 실행"이므로 **최초 적용과 이후 재실행이 같은
|
||||
코드 경로로 자동 통일**됨 — 프로퍼티 store-bind 핸들러가 "설치 시 1회
|
||||
적용"을 별도로 안 짜도 되는 이유(위 Observer 절의 원래 근거 그대로).
|
||||
- `relate`는 `base/relate-plan.md`의 `Relate` 인스턴스 — 이 핸들러 모듈
|
||||
톱레벨에 `local relate = Relate()`로 하나 두고 재사용.
|
||||
|
||||
Slot이 store 바인드로 넘어오는 경우, pluggable 처리기에 `retract` 핸들러가
|
||||
필요하다는 점(부모가 slot을 정리하고 다시 process하는 방식)도 이 래핑 방식과
|
||||
|
|
@ -909,13 +939,17 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플
|
|||
바꾸는 식의 수동 연결은 있을 수 있지만, 잘 짜인 UI에서 실사용 사례를 거의
|
||||
보지 못했다는 게 사용자 판단 — 그래서 이 케이스를 위해 별도로 신경 쓰지 않음.
|
||||
|
||||
**[2026-08-13 세션, 스코프 명확화]** 이 절은 "Store *필드*가 Store/State를
|
||||
담는가"(예: `store.a = otherStore`) 얘기이고, "State가 *emit하는 값*이
|
||||
State/Source인가"(`State<State<T>>`, 예: `store.key`에 대입된 값 자체가
|
||||
State)는 다른 축 — 후자는 위 "확정된 디스패치 모델" 절에서 실제 체인
|
||||
파손 버그로 확인됨. 이 절의 "별도로 신경 쓰지 않음"은 전자에만 해당하고,
|
||||
후자는 이제 `Dispatch.process`가 명시적으로 error하도록 막혀 있어 "신경
|
||||
안 씀 = 조용히 UB"가 아니라 "신경 안 씀 = 즉시 실패"로 취급됨.
|
||||
**[2026-08-13 세션, 스코프 명확화, 같은 날 다섯 번째 세션에 결론 갱신]**
|
||||
이 절은 "Store *필드*가 Store/State를 담는가"(예: `store.a = otherStore`)
|
||||
얘기이고, "State가 *emit하는 값*이 State/Source인가"(`State<State<T>>`,
|
||||
예: `store.key`에 대입된 값 자체가 State)는 다른 축. 이 절의 "별도로
|
||||
신경 쓰지 않음"(Store 필드 얘기)은 그대로 유지 — 후자(`State<State<T>>`)는
|
||||
한때 실제 체인 파손 버그로 확인돼 `Dispatch.process`가 명시적으로 error
|
||||
하도록 막았었으나, 같은 날 다섯 번째 세션에 `chains`의 인덱스 기반
|
||||
재설계로 그 버그의 근본 원인이 없어져 **지금은 정상 지원 대상**(위
|
||||
"Dispatch 체인" 절의 `State<State<T>>` 재정정 참고) — "신경 안 씀"의
|
||||
의미가 "조용히 UB"도 "즉시 실패"도 아니라 "그냥 정상적으로 동작함"으로
|
||||
다시 한번 바뀜.
|
||||
|
||||
## Ref — 도입 확정, 단 용도는 재정의됨
|
||||
|
||||
|
|
@ -1126,45 +1160,52 @@ tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름:
|
|||
`refB`로 넘어갔다는 걸 모르는 코드가 `refA.Value`를 계속 유효하다고 믿는
|
||||
조용한 버그가 남음 — `PreRef` 재사용 버그(위 절)와 같은 클래스의 문제.
|
||||
|
||||
**메커니즘 — `retract`가 매번 불린다는 전제 위에서 언바인딩 전담
|
||||
(2026-08-12 열한 번째 세션 정정).** `Dispatch.retractUnder`는 store 값이
|
||||
바뀔 때마다(핸들러 타입이 그대로여도) 무조건 불림 — 위 "확정된 디스패치
|
||||
모델"/일반 retract 계약 절 참고. 그래서 `refA→refB` 전환도 `retract(inst,k,
|
||||
refB)`가 먼저 불려 `refA`를 언바인딩하고, 그 다음 `process(inst,k,refB)`가
|
||||
`refB`를 바인딩하는 두 단계로 자연히 갈림 — `process`가 old-vs-new diff를
|
||||
따로 계산할 필요가 없어짐(그 일을 `retract`가 매번 정확히 대신 해줌):
|
||||
**메커니즘 — retractor가 매번 불린다는 전제 위에서 언바인딩 전담
|
||||
(2026-08-12 열한 번째 세션 정정, 2026-08-13 다섯 번째 세션에 클로저
|
||||
반환 계약으로 서술 갱신).** `Dispatch.retractFrom`은 store 값이 바뀔
|
||||
때마다(핸들러 타입이 그대로여도) 무조건 불림 — 위 "확정된 디스패치
|
||||
모델"/일반 retract 계약 절 참고. 그래서 `refA→refB` 전환도 이전
|
||||
`process`가 반환한 클로저가 `hintValue=refB`로 먼저 불려 `refA`를
|
||||
언바인딩하고, 그 다음 `process(inst,k,refB,index)`가 `refB`를 바인딩하는
|
||||
두 단계로 자연히 갈림 — `process`가 old-vs-new diff를 따로 계산할
|
||||
필요가 없어짐(그 일을 클로저가 매번 정확히 대신 해줌). **`process` 쪽엔
|
||||
여전히 `Relate`가 필요** — "spurious하게 같은 Ref가 재발행되면 재통지
|
||||
skip"이라는 dedup은 `process`가 "이전에 뭐가 있었는지"를 알아야 하는데,
|
||||
그건 인자로 안 들어오고(클로저의 `hintValue`는 다음 값이지 이전 값이
|
||||
아님) 오직 여러 호출을 가로지르는 저장소로만 알 수 있음(위 "핸들러
|
||||
내부 상태 저장" 절이 이런 경우엔 `Relate`가 여전히 맞다고 한 그 사례):
|
||||
|
||||
```lua
|
||||
local relate = Relate() -- Ref-leaf handler 전용, (inst,k)별 마지막으로 바인딩한 Ref 기억
|
||||
local relate = Relate() -- Ref-leaf handler 전용, (inst,k)별 마지막으로 바인딩한 Ref 기억 —
|
||||
-- process의 spurious 재바인딩 dedup 전용(클로저 캡처로는 대체 불가)
|
||||
|
||||
RefLeafHandler.isHandlable(inst, k, v) = isRef(v) and not isPreRef(v)
|
||||
|
||||
function RefLeafHandler.process(inst, k, v)
|
||||
function RefLeafHandler.process(inst, k, v, index)
|
||||
local old = relate:GetStrong(inst, k)
|
||||
if old == v then return end -- 이미 같은 Ref가 이 자리를 차지 중(retract가
|
||||
-- 방금 손 안 댄 경우) — 콜백 재통지 없이 no-op
|
||||
v:Set(inst)
|
||||
relate:SetStrong(inst, k, v)
|
||||
end
|
||||
|
||||
function RefLeafHandler.retract(inst, k, v)
|
||||
local old = relate:GetStrong(inst, k)
|
||||
if old and old ~= v then -- v는 nil일 수도, 대체하는 새 Ref 자체일 수도 있음 —
|
||||
-- 어느 쪽이든 old와 다르면 old는 확실히 이 자리를 잃음
|
||||
old:Set(nil) -- 매 :Set()마다 콜백 재통지되는 기존 Ref 규칙(위 "해소됨 —
|
||||
-- 반복 재설정 가능" 항목)을 그대로 재사용, 새 알림 경로 아님
|
||||
relate:SetStrong(inst, k, nil)
|
||||
if old ~= v then -- 이미 같은 Ref가 이 자리를 차지 중이면 재통지 skip
|
||||
v:Set(inst)
|
||||
end
|
||||
relate:SetStrong(inst, k, v)
|
||||
return function(hintValue)
|
||||
-- hintValue는 nil일 수도, 대체하는 새 Ref 자체일 수도 있음 — v는
|
||||
-- 이 process 호출이 만든 클로저가 직접 캡처(Relate 재조회 불필요)
|
||||
if hintValue ~= v then
|
||||
v:Set(nil) -- 매 :Set()마다 콜백 재통지되는 기존 Ref 규칙(위 "해소됨 —
|
||||
-- 반복 재설정 가능" 항목)을 그대로 재사용, 새 알림 경로 아님
|
||||
end
|
||||
if relate:GetStrong(inst, k) == v then relate:SetStrong(inst, k, nil) end
|
||||
end
|
||||
-- old == v(같은 Ref 재발행) → 아무 것도 안 함, 곧 process도 no-op으로 스킵
|
||||
end
|
||||
```
|
||||
|
||||
- **`retract`가 언바인딩 전담, `process`는 바인딩 전담** — 겹치는 diff
|
||||
로직이 없음. `old == v`(같은 Ref 객체가 스스로 재발행된 spurious한
|
||||
경우)만 둘 다 스킵해 콜백이 `nil`→`inst`로 헛되이 두 번 안 불리게 함.
|
||||
- **retractor가 언바인딩 전담, `process`는 바인딩 전담** — 겹치는 diff
|
||||
로직이 없음. `hintValue == v`(같은 Ref 객체가 스스로 재발행된
|
||||
spurious한 경우)만 둘 다 스킵해 콜백이 `nil`→`inst`로 헛되이 두 번 안
|
||||
불리게 함.
|
||||
- **children 배열 리터럴 `Ref`도 같은 코드 경로를 그대로 씀** — 그 경우
|
||||
`retract`가 (StoreBind 경로가 아니라 이 리터럴 구성 자체가 처음이므로)
|
||||
아예 안 불리고 `relate:GetStrong(inst,k)`도 `nil`이라 `process`가 바로
|
||||
이전 클로저가 (StoreBind 경로가 아니라 이 리터럴 구성 자체가 처음이므로)
|
||||
아예 없고 `relate:GetStrong(inst,k)`도 `nil`이라 `process`가 바로
|
||||
`v:Set(inst)`로 끝남. "1회성 리터럴 구성"과 "반복 재바인드"가 하나의
|
||||
구현으로 자연히 커버됨, 케이스 분기 불필요.
|
||||
- **타입: 비-nilable `T`도 정당한 용도(사용자 확인, 2026-08-12 여덟 번째
|
||||
|
|
@ -1364,7 +1405,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
|
|||
(2026-08-12 여섯 번째 세션, 사용자 제안 채택).** `Ref`가 "다른 값으로
|
||||
교체되면 `retract`로 취소됨"이라는 의미의 취소를 가질 수 있는 건 정상
|
||||
우선순위 스캔의 `(inst,k)` 디스패치 체인에 실제로 참여해서임 —
|
||||
`Dispatch.retractUnder`가 그 체인을 대상으로 동작함. `PreRef`는 애초에
|
||||
`Dispatch.retractFrom`이 그 체인을 대상으로 동작함. `PreRef`는 애초에
|
||||
그 체인에 올라간 적이 없음(pre-pass에서 fire와 동시에 `None`으로
|
||||
소진되고 정상 두 패스는 건드리지 않음, 위 "호이스팅의 실제 구현" 절) —
|
||||
그래서 "취소 가능 여부" 자체가 성립할 토대가 없었던 게 구조적으로
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ Setter는 리터럴 값과 변환 함수 둘 다 받음" 절 참고. **팩토리
|
|||
|
||||
### 1. 런타임 pluggable 핸들러 아님 — 정적 merge
|
||||
|
||||
Modifier는 `isHandlable`/`priority`/`process`/`retract` 핸들러 레지스트리에
|
||||
Modifier는 `isHandlable`/`priority`/`process` 핸들러 레지스트리에
|
||||
안 들어감. 그냥 평범한 테이블(데이터)을 보유하는 값이고, 디스패치 들어가기
|
||||
전에 한 번 평탄화(flatten)돼서 최종 props 테이블에 합쳐짐. 이유: 런타임
|
||||
pluggable로 만들면 여러 modifier가 반응형으로 같은 키를 계속 다투는 CSS
|
||||
|
|
|
|||
|
|
@ -211,15 +211,16 @@ Slot이 store 바인드로 들어오는 경우, pluggable 처리기에 `retract`
|
|||
> 동작이 '부모 위임' 잠정안에서 '폐기(옮기지 않음)'로 확정") 참고. 이 문단은
|
||||
> 검토 과정의 히스토리로만 남겨둠, 현재 유효한 동작 아님.
|
||||
|
||||
**[전면 정정, 2026-08-12 열한 번째 세션] 위 "핸들러 타입이 안 바뀌면
|
||||
retract 없이 process가 diff 담당"이라는 전제 자체가 틀렸음** — 실제로는
|
||||
`Dispatch.retractUnder`가 store 재발행마다(핸들러가 그대로여도) **항상**
|
||||
먼저 불림(`base/bind-system-plan.md` "확정된 디스패치 모델"/일반 retract
|
||||
계약 절, 2026-08-12 열한 번째 세션 전면 정정 참고). 그래서 `State<Slot>`이
|
||||
`slotA→slotB`로 바뀔 때도 **`retract(inst,k,slotB)`가 먼저 불려 `slotA`를
|
||||
폐기하고, 그 다음 `process(inst,k,slotB)`가 `slotB`를 마운트**하는 두
|
||||
단계로 자연히 갈림 — `retract`가 "이전 것 정리", `process`가 "새 것
|
||||
마운트" 전담:
|
||||
**[전면 정정, 2026-08-12 열한 번째 세션, 2026-08-13 다섯 번째 세션에
|
||||
클로저 반환 계약으로 서술 갱신] 위 "핸들러 타입이 안 바뀌면 retract 없이
|
||||
process가 diff 담당"이라는 전제 자체가 틀렸음** — 실제로는
|
||||
`Dispatch.retractFrom`이 store 재발행마다(핸들러가 그대로여도) **항상**
|
||||
이전 `process`가 반환한 클로저를 먼저 부름(`base/bind-system-plan.md`
|
||||
"확정된 디스패치 모델"/일반 retract 계약 절 참고). 그래서 `State<Slot>`이
|
||||
`slotA→slotB`로 바뀔 때도 **`slotA`를 처리했던 클로저가 `hintValue=slotB`로
|
||||
먼저 불려 `slotA`를 폐기하고, 그 다음 `process(inst,k,slotB,index)`가
|
||||
`slotB`를 마운트**하는 두 단계로 자연히 갈림 — 클로저가 "이전 것 정리",
|
||||
`process`가 "새 것 마운트" 전담:
|
||||
|
||||
**[정정, 2026-08-12 열두 번째 세션] "같은 값인가"를 위치별 relate로
|
||||
간접 비교하는 대신, Slot 자신이 지금 어느 `inst`에 바인딩됐는지를 직접
|
||||
|
|
@ -260,7 +261,6 @@ tables" 항목).** 즉 이 회피는 "혹시 몰라서"가 아니라 **Luau에
|
|||
nested CRUD 경로가 **같은** 레지스트리를 쓰도록 통합:
|
||||
|
||||
```lua
|
||||
local kSlotMap = Relate() -- SlotHandler 전용, (inst,k)별 마지막으로 마운트한 Slot — weak, 조회 전용
|
||||
local elementOwner = Relate() -- element(Slot이든 plain 마운트 가능 값이든) 전체 공용
|
||||
-- {[element] = ownerKey} -- ownerKey: inst | Slot, 전부 weak
|
||||
local OWNER = "__owner" -- sentinel key(Relate는 항상 3-인자 SetWeak/2-인자 GetWeak 이후
|
||||
|
|
@ -280,28 +280,43 @@ local function claimOwner(element, ownerKey)
|
|||
end
|
||||
|
||||
local function releaseOwner(element, ownerKey)
|
||||
if elementOwner:GetWeak(element, OWNER) == ownerKey then
|
||||
elementOwner:SetWeak(element, OWNER, nil)
|
||||
-- [정정, 2026-08-13 세션] 불일치를 조용히 무시하지 않음 — claimOwner가 항상
|
||||
-- 먼저 성공해야만 이 element를 이 ownerKey가 들고 있을 수 있으므로, 여기서
|
||||
-- 불일치가 관측되는 것 자체가 호출측(rawRemove/rawExtract/SlotHandler.process가 반환한 클로저)의
|
||||
-- 소유권 bookkeeping이 어딘가 깨졌다는 뜻 — Dispatch의 "매치 실패는 조용한
|
||||
-- 무시 없이 즉시 error" 원칙과 같은 결로 즉시 error.
|
||||
local current = elementOwner:GetWeak(element, OWNER)
|
||||
if current ~= ownerKey then
|
||||
error("releaseOwner: 이 element는 이 ownerKey가 소유하고 있지 않음 — 호출측 소유권 추적이 깨졌음")
|
||||
end
|
||||
elementOwner:SetWeak(element, OWNER, nil)
|
||||
end
|
||||
|
||||
function SlotHandler.process(inst, k, slotValue)
|
||||
if not claimOwner(slotValue, inst) then
|
||||
return -- 이미 이 inst에 바인딩된 채 — 단순 emit 전파, no-op
|
||||
function SlotHandler.process(inst, k, slotValue, index)
|
||||
if claimOwner(slotValue, inst) then
|
||||
-- [정정, 2026-08-13 세션] bindLifetime을 attachSlot 밖, 여기(top-level Handler)로
|
||||
-- 이동 — 반환하는 클로저 쪽 unbindLifetime과 같은 층위(Handler)로 대칭. 중첩
|
||||
-- Slot은 여전히 anchor 불필요(자신을 담는 outer의 `_elements`(plain strong
|
||||
-- array)로 이미 transitively 살아있음 — elementOwner는 전부 weak라 별도 anchor
|
||||
-- 아님) — 그 구분은 이제 "attachSlot을 top-level에서 부르는 이 자리에서만
|
||||
-- bindLifetime한다"는 호출부 자체의 위치로 표현됨, attachSlot 내부 분기가 아님.
|
||||
bindLifetime(inst, slotValue)
|
||||
attachSlot(slotValue, inst, inst, k)
|
||||
end
|
||||
attachSlot(slotValue, inst, inst, k) -- top-level이라 내부에서 bindLifetime(inst, slotValue) 호출(위 "재귀 메커니즘" 절)
|
||||
kSlotMap:SetWeak(inst, k, slotValue)
|
||||
end
|
||||
|
||||
function SlotHandler.retract(inst, k, v)
|
||||
local old = kSlotMap:GetWeak(inst, k)
|
||||
if old and old ~= v then -- v는 nil일 수도, 대체하는 새 Slot 자체일 수도 있음
|
||||
destroySlotTree(old) -- 재귀 파괴(자식 정리), 폐기·옮기지 않음 — slot 자신의 GC 앵커는 안 건드림(top-level 전용)
|
||||
unbindLifetime(inst, old) -- top-level 자신의 GC 앵커 해제 — attachSlot의 top-level bindLifetime과 짝
|
||||
releaseOwner(old, inst)
|
||||
kSlotMap:SetWeak(inst, k, nil)
|
||||
-- claimOwner가 false여도(이미 같은 Slot이 이 자리를 차지 중인 spurious 재발행)
|
||||
-- 아래 클로저는 항상 똑같이 올바르게 동작함 — attachSlot을 두 번 안 부르는
|
||||
-- 것만 위에서 갈렸을 뿐, "이 자리가 결국 다른 값으로 바뀔 때 할 일"은 어느
|
||||
-- 쪽 process 호출이 반환하든 slotValue/inst가 같아서 동일함(2026-08-13 다섯
|
||||
-- 번째 세션 — 이래서 별도 `kSlotMap`이 더 이상 필요 없음: 나중에 이 인덱스가
|
||||
-- 통째로 철거될 때 실제로 불리는 건 항상 *가장 최근* process 호출이 반환한
|
||||
-- 클로저인데, 그게 어느 호출에서 왔든 캡처된 slotValue/inst는 늘 참값이라
|
||||
-- "누가 진짜로 attach했는지"를 별도로 기억해둘 필요 자체가 없음).
|
||||
return function(hintValue)
|
||||
if slotValue == hintValue then return end -- 같은 Slot 재발행 → no-op
|
||||
destroySlotTree(slotValue) -- 재귀 파괴(자식 정리), 폐기·옮기지 않음 — slot 자신의 GC 앵커는 안 건드림(top-level 전용)
|
||||
unbindLifetime(inst, slotValue) -- top-level 자신의 GC 앵커 해제 — attachSlot의 top-level bindLifetime과 짝
|
||||
releaseOwner(slotValue, inst)
|
||||
end
|
||||
-- old == v(같은 Slot 재발행) → 아무 것도 안 함, 곧 process도 owner==inst로 no-op
|
||||
end
|
||||
```
|
||||
|
||||
|
|
@ -324,8 +339,8 @@ Dispatch 마운트(`SlotHandler.process`)만 `slotOwner`를 봤고, `Add`가
|
|||
|
||||
**해법**: 위에서 승격한 `elementOwner`/`claimOwner`/`releaseOwner`를
|
||||
Slot뿐 아니라 **모든 마운트 가능 element(plain Instance 포함)**의
|
||||
소유권 판정에 공용으로 씀 — top-level(`SlotHandler.process`/`.retract`)과
|
||||
nested(`rawAdd`/`rawRemove`/`rawExtract`)가 정확히 같은 함수, 같은
|
||||
소유권 판정에 공용으로 씀 — top-level(`SlotHandler.process`와 그 반환
|
||||
클로저)과 nested(`rawAdd`/`rawRemove`/`rawExtract`)가 정확히 같은 함수, 같은
|
||||
`Relate`를 호출하므로 어느 경로로 먼저 클레임하든 다른 경로가 반드시
|
||||
봄:
|
||||
|
||||
|
|
@ -1219,21 +1234,15 @@ weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위
|
|||
```lua
|
||||
-- quad-base, Slot.luau — 재귀적 "attach" 하나로 최상위/중첩 마운트 통합
|
||||
local function attachSlot(slot, physicalTarget, ownerKey, position)
|
||||
-- [정정, 2026-08-13 세션] bindLifetime 호출은 여기 없음 — top-level 전용
|
||||
-- 앵커링은 SlotHandler.process(아래)로 이동. attachSlot 자신은 이제
|
||||
-- top-level/nested 어느 깊이에서 불려도 완전히 동일하게 동작하는 순수 구조적
|
||||
-- mount 로직만 담당(레이어 구분은 오직 이 함수를 부르는 쪽의 책임) — retract
|
||||
-- 쪽(destroySlotTree)이 이미 이 원칙대로였는데(자기 자신의 unbindLifetime은
|
||||
-- 안 하고 Handler.retract에서만 짝을 맞춤) process 쪽만 attachSlot 내부에
|
||||
-- ownerKey==physicalTarget 분기로 anchor 로직이 새어들어와 있던 비대칭이었음.
|
||||
slot._mounted = true
|
||||
slot._mountedInst = physicalTarget
|
||||
if ownerKey == physicalTarget then
|
||||
-- [2026-08-12 열여섯 번째 세션, 스코프 정정] bindLifetime은 최상위(물리
|
||||
-- inst에 직접 연동하는 말단)에서만 — 중첩 Slot은 자신을 담는 outer의
|
||||
-- `_elements`(plain strong array)로 이미 transitively 살아있어서
|
||||
-- (elementOwner는 전부 weak라 별도 anchor 아님, 위 "요소 소유권 —
|
||||
-- `elementOwner`" 절 참고) 여기서 또 anchor할 이유가 없음 — State의 노드 연결처럼
|
||||
-- 중간 노드는 구조로만 연결되고, 말단만 실제 엔진 생명주기에 건다는
|
||||
-- 원칙과 같은 결. 매 레벨 anchor하면 매 레벨 파괴 시 짝을 맞춰
|
||||
-- unbindLifetime해야 하는 부담만 늘어남(destroySlotTree 참고).
|
||||
bindLifetime(physicalTarget, slot) -- [2026-08-12 열세 번째 세션 추가] 최상위
|
||||
-- Slot의 GC 앵커 — 이게 빠져있으면 아무도
|
||||
-- 강하게 안 붙잡아 조기 GC될 수 있음
|
||||
end
|
||||
|
||||
Dispatch.setLength(ownerKey, position, slot.Length) -- slot.Length는 State<number>, 기존 로직 그대로
|
||||
local offsetSource = Source(0)
|
||||
|
|
@ -1303,8 +1312,8 @@ local function destroySlotTree(slot)
|
|||
end
|
||||
-- [2026-08-12 열여섯 번째 세션, 스코프 정정] slot 자신의 unbindLifetime은
|
||||
-- 여기서 안 부름 — attachSlot이 최상위에서만 bindLifetime하므로 짝도
|
||||
-- 최상위 파괴 지점(SlotHandler.retract, 아래)에서만 한 번. destroySlotTree는
|
||||
-- 재귀 전체에서 항상 이 위치까지만(자식 Observer 정리) 담당.
|
||||
-- 최상위 파괴 지점(SlotHandler.process가 반환하는 클로저, 위)에서만 한 번.
|
||||
-- destroySlotTree는 재귀 전체에서 항상 이 위치까지만(자식 Observer 정리) 담당.
|
||||
end
|
||||
|
||||
-- [명확화] 아래 시그니처는 index 기준 예시 — 위 "raw* 내부 호출 규약" 절이
|
||||
|
|
|
|||
|
|
@ -92,8 +92,9 @@ pull-recompute)·`:Compute` 인자 규칙·State 쓰기 금지·`Source` 독립
|
|||
결정하는 기준으로 쓸 수 있음.
|
||||
|
||||
**세 번째 카테고리 — Handler는 둘 중 어디에도 안 낌(2026-08-08 두 번째
|
||||
세션, 명시화).** `Handler`(`isHandlable`/`priority`/`process`/`retract`
|
||||
4종 계약, `base/bind-system-plan.md` "핸들러 계약" 절)는 위 분류가 다루는
|
||||
세션, 명시화).** `Handler`(`isHandlable`/`priority`/`process` 3종 계약 —
|
||||
`process`가 자기 retract 클로저를 반환, 2026-08-13 다섯 번째 세션 정정,
|
||||
`base/bind-system-plan.md` "핸들러 계약" 절)는 위 분류가 다루는
|
||||
"quad 사용자가 직접 다루는 리액티브 값"이 아니라 **그 자체로는 구현체가
|
||||
없는 순수 타입 계약**이라 애초에 이 분류표의 대상이 아님 — Source/Ref처럼
|
||||
`Type(args)` 자유 함수로 인스턴스를 만들 수도 없고(계약을 만족하는 값은
|
||||
|
|
|
|||
|
|
@ -109,83 +109,107 @@ nil`/`or None`(and/or 삼항)으로 적었으나, `Tag(...)`가 항상-truthy라
|
|||
clone을 반환) 내부에 State 같은 걸 담지도 않는 **항상 확정 상태인 말단
|
||||
값**(Tween과 같은 결) — 그래서 `State<Tag>`가 진짜로 다른 내용을 내놓을
|
||||
때마다 **항상 물리적으로 다른 `Tag` 객체**가 나옴. 이 사실 덕분에, 이름별로
|
||||
"어떤 `Tag` 객체들이 지금 이 이름을 걸고 있는가"를 집합으로 추적하면
|
||||
`retract`(이전 객체가 이 이름을 놓음)/`process`(새 객체가 이 이름을 걺)가
|
||||
**"지금 이 이름을 걸고 있는 위치(`k`)가 몇 개인가"를 집합으로 추적**하면
|
||||
`retract`(이전 위치가 이 이름을 놓음)/`process`(그 위치가 새 이름을 걺)가
|
||||
겹치는 이름/겹치는 위치 양쪽 다 자동으로 올바르게 처리됨:
|
||||
|
||||
**[정정, 2026-08-13 네 번째 세션] holders는 `Tag` 객체가 아니라 위치(`k`)로
|
||||
키잉함.** 최초안은 `holders[Tag객체] = true`(객체 identity 기준)이었으나,
|
||||
`Tag`는 immutable이라 **재사용이 자연스러운 관례**(예: `local SELECTED =
|
||||
Tag("selected")`를 모듈 상수로 만들어 여러 위치에서 재사용)인데, 객체
|
||||
identity로 홀더를 추적하면 같은 객체를 두 위치(`k1`, `k2`)에 걸었을 때
|
||||
`tagNameMap`엔 **단일 엔트리**만 생겨 두 위치가 구분이 안 됨 — `k1`만
|
||||
`retract`돼도 그 하나뿐인 엔트리가 지워져 `holders`가 비고, `k2`가 여전히
|
||||
그 이름을 쓰고 있는데도 `RemoveTag`가 불려버리는 실제 참조 카운트 버그로
|
||||
손 트레이싱에서 재현됨(하단 "여러 위치가 같은 이름을 겹쳐 가지는 경우"
|
||||
절이 암묵적으로 "서로 다른 객체"만 가정하고 있었던 게 원인). "여러 위치가
|
||||
같은 이름을 겹칠 수 있다"는 이 절의 원래 취지 자체가 **위치 기준
|
||||
집합**이어야 성립하므로, 홀더를 `k`로 바꾸는 게 원 의도와도 더 맞음:
|
||||
|
||||
**[정정, 2026-08-13 다섯 번째 세션] `process`가 자기 retract 클로저를
|
||||
반환하는 계약으로 전환되며 `kTagMap`(위치별 마지막 Tag)이 완전히
|
||||
불필요해짐** — `retract`가 필요로 했던 "이 위치에 걸려 있던 Tag가
|
||||
뭐였는가"는 이제 그 `process` 호출이 반환하는 클로저가 `v`를 upvalue로
|
||||
직접 캡처하므로, 별도 저장소에서 다시 조회할 이유가 없음(위
|
||||
`base/bind-system-plan.md` "핸들러 내부 상태 저장" 절 — 단발성 handoff는
|
||||
클로저로 충분). **`tagNameMap`(이름별 현재 걸고 있는 위치 집합)은 여전히
|
||||
필요** — 이건 서로 다른 여러 위치를 가로지르는, `process`/클로저 하나의
|
||||
호출 수명을 넘어서는 누적 상태라 `Relate`가 맞는 경우:
|
||||
|
||||
```lua
|
||||
local kTagMap = Relate() -- {[inst(weak)] = {[k]: Tag}} — 위치별 마지막으로 반영한 Tag
|
||||
local tagNameMap = Relate() -- {[inst(weak)] = {[tagName]: {[Tag]: true}}} — 이름별 현재 걸고 있는 Tag들
|
||||
local tagNameMap = Relate() -- {[inst(weak)] = {[tagName]: {[k]: true}}} — 이름별 현재 걸고 있는 위치들
|
||||
|
||||
TagHandler.priority = <일반>
|
||||
TagHandler.isHandlable(inst, k, v) = isTag(v) -- Brand 기반, array-part 전용
|
||||
|
||||
function TagHandler.retract(inst, k, newv)
|
||||
local oldv = kTagMap:GetStrong(inst, k)
|
||||
if not oldv then return end
|
||||
local newvIsTag = isTag(newv) -- newv는 nil일 수도, 대체하는 새 Tag 자체일 수도 있음
|
||||
for name in oldv:Names() do
|
||||
local holders = tagNameMap:GetStrong(inst, name) -- 이미 등록됐으므로 항상 있음
|
||||
holders[oldv] = nil
|
||||
if next(holders) == nil and not (newvIsTag and newv:Contains(name)) then
|
||||
inst:RemoveTag(name) -- 곧 process가 재확정할 이름이면 실제 호출은 skip(깜빡임 방지)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function TagHandler.process(inst, k, v)
|
||||
function TagHandler.process(inst, k, v, index)
|
||||
for name in v:Names() do
|
||||
local holders = tagNameMap:GetStrong(inst, name)
|
||||
if not holders then
|
||||
holders = {} -- strong map — Tag가 살아있는 동안 소유 목록도 살아있어야 함
|
||||
holders = {} -- strong map — 이 이름을 거는 위치가 하나라도 있는 동안 소유 목록도 살아있어야 함
|
||||
tagNameMap:SetStrong(inst, name, holders)
|
||||
end
|
||||
if next(holders) == nil then
|
||||
inst:AddTag(name)
|
||||
end
|
||||
holders[v] = true
|
||||
holders[k] = true -- 이 "위치"가 이 이름을 걺(Tag 객체가 다른 위치와 같아도 무관)
|
||||
end
|
||||
return function(hintValue)
|
||||
if v == hintValue then return end -- Tag는 immutable이라 객체가 안 바뀌면
|
||||
-- 이름 집합도 절대 안 바뀜 — holders 순회 자체가 불필요한 순수 최적화
|
||||
local hintIsTag = isTag(hintValue) -- hintValue는 nil일 수도, 대체하는 새 Tag 자체일 수도 있음
|
||||
for name in v:Names() do
|
||||
local holders = tagNameMap:GetStrong(inst, name) -- 이미 등록됐으므로 항상 있음
|
||||
holders[k] = nil -- 이 "위치"가 이 이름을 놓음(같은 Tag 객체를 다른 위치도 쓰고 있어도 무관)
|
||||
if next(holders) == nil and not (hintIsTag and hintValue:Contains(name)) then
|
||||
inst:RemoveTag(name) -- 곧 새 process가 재확정할 이름이면 실제 호출은 skip(깜빡임 방지)
|
||||
end
|
||||
end
|
||||
end
|
||||
kTagMap:SetStrong(inst, k, v)
|
||||
end
|
||||
```
|
||||
|
||||
- **`AddTag`는 온전히 `process`, `RemoveTag`는 온전히 `retract`** — 서로
|
||||
겹치는 diff 계산이 없음. `retract`가 이전 `Tag`(`oldv`)가 걸었던 이름
|
||||
전부를 소유 목록에서 빼되(항상 실행), 그 결과 목록이 비었을 때 **실제
|
||||
`RemoveTag` 호출만** "새로 들어올 `newv`가 그 이름을 여전히 Contains하는가"로
|
||||
힌트를 줘서 skip — 소유 목록 자체는 항상 최신 객체로 갱신되므로(정확히
|
||||
`oldv`를 빼고 `v`를 넣는 두 단계), 이름이 살아남는 경우에도 stale
|
||||
레퍼런스가 안 남음. `process`는 `v`가 새로 거는 이름 전부를 무조건
|
||||
등록(소유 목록이 비어있던 경우에만 실제 `AddTag`) — 자기 나름의 old-vs-new
|
||||
diff가 전혀 필요 없음(그 일을 `retract`가 매번 정확히 해줌).
|
||||
- **`Tag(A)→Tag(B)`(같은 위치, 내용만 바뀜)**: `retract(inst,k,B)`가 먼저
|
||||
불려 `A`가 걸었던 이름 중 `B`에 없는 것만 실제로 `RemoveTag`, 남은 건
|
||||
힌트로 skip — 그 다음 `process(inst,k,B)`가 `B`의 이름 전부를 등록(이미
|
||||
걸려있던 이름은 `AddTag`가 no-op으로 재확인만 됨, 소유 목록엔 `B`가 새로
|
||||
등록). 결과적으로 실제 `RemoveTag`/`AddTag` 호출은 진짜 변경된 이름에만
|
||||
- **`AddTag`는 온전히 `process`, `RemoveTag`는 온전히 반환하는 클로저**
|
||||
— 서로 겹치는 diff 계산이 없음. 클로저가 이전 `Tag`(`v`, 자기 자신이
|
||||
캡처)가 걸었던 이름 전부를 소유 목록에서 빼되(항상 실행), 그 결과
|
||||
목록이 비었을 때 **실제 `RemoveTag` 호출만** "새로 들어올 `hintValue`가
|
||||
그 이름을 여전히 Contains하는가"로 힌트를 줘서 skip — 소유 목록 자체는
|
||||
항상 최신 객체로 갱신되므로(정확히 `v`를 빼고 새 `process`가 새 값을
|
||||
넣는 두 단계), 이름이 살아남는 경우에도 stale 레퍼런스가 안 남음.
|
||||
`process`는 `v`가 새로 거는 이름 전부를 무조건 등록(소유 목록이
|
||||
비어있던 경우에만 실제 `AddTag`) — 자기 나름의 old-vs-new diff가 전혀
|
||||
필요 없음(그 일을 클로저가 매번 정확히 해줌).
|
||||
- **`Tag(A)→Tag(B)`(같은 위치, 내용만 바뀜)**: `A`를 처리했던 `process`가
|
||||
반환한 클로저가 `hintValue=B`로 먼저 불려 `A`가 걸었던 이름 중 `B`에
|
||||
없는 것만 실제로 `RemoveTag`, 남은 건 힌트로 skip — 그 다음
|
||||
`process(inst,k,B,index)`가 `B`의 이름 전부를 등록(이미 걸려있던
|
||||
이름은 `AddTag`가 no-op으로 재확인만 됨, 소유 목록엔 `B`가 새로 등록).
|
||||
결과적으로 실제 `RemoveTag`/`AddTag` 호출은 진짜 변경된 이름에만
|
||||
일어남 — 스타일 깜빡임 방지라는 원래 목적은 그대로 달성.
|
||||
- **`Tag(A)→nil`**: `retract(inst,k,nil)`만 불림(값이 `Tag`가 아니게 돼
|
||||
`process`는 매치 자체가 안 됨) — `newvIsTag=false`라 힌트가 항상
|
||||
거짓이 되어 `A`가 걸었던 이름 전부가 무조건 실제로 `RemoveTag`됨(다른
|
||||
위치가 그 이름을 계속 쓰고 있지 않다면).
|
||||
- **`Tag(A)→nil`**: `A`의 클로저가 `hintValue=nil`로만 불림(값이 `Tag`가
|
||||
아니게 돼 `process`는 매치 자체가 안 됨) — `hintIsTag=false`라 힌트가
|
||||
항상 거짓이 되어 `A`가 걸었던 이름 전부가 무조건 실제로 `RemoveTag`됨
|
||||
(다른 위치가 그 이름을 계속 쓰고 있지 않다면).
|
||||
- **여러 위치가 같은 이름을 겹쳐 가지는 경우**(`Frame { Tag("a"), Tag("a","b") }`):
|
||||
두 위치가 서로 다른 `k`로 각자 독립적으로 `process`/`retract`를 타지만,
|
||||
`tagNameMap["a"]`는 **양쪽 위치의 `Tag` 객체를 모두 담는 하나의 공유
|
||||
집합** — 한쪽이 "a"를 잃어도 다른 쪽 객체가 집합에 남아있으면 실제
|
||||
두 위치가 서로 다른 `k`로 각자 독립적으로 `process`/자기 클로저를
|
||||
타지만, `tagNameMap["a"]`는 **양쪽 위치(`k`)를 모두 담는 하나의 공유
|
||||
집합** — 한쪽이 "a"를 잃어도 다른 쪽 위치가 집합에 남아있으면 실제
|
||||
`RemoveTag`가 안 불림. 웹 `className`처럼 손실 없는 합집합이 정확히
|
||||
나옴.
|
||||
- **`retract`가 자기 위임 대상까지 수동으로 안 쫓아가도 됨** —
|
||||
`Dispatch.retractUnder`가 체인 전체를 알아서 훑어주므로 TagHandler는
|
||||
자기 자원(위 두 릴레이션)만 정리하면 됨. 상세 메커니즘은
|
||||
나옴. **위치 기준이므로 두 위치가 물리적으로 같은 `Tag` 객체를
|
||||
재사용해도(흔한 관례) 정확히 같은 방식으로 안전 — 이게 바로 위 "정정"
|
||||
절에서 객체 identity 기준을 버린 이유.**
|
||||
- **클로저가 자기 위임 대상까지 수동으로 안 쫓아가도 됨** —
|
||||
`Dispatch.retractFrom`이 체인 전체를 알아서 훑어주므로 TagHandler는
|
||||
자기 자원(위 `tagNameMap` 하나)만 정리하면 됨. 상세 메커니즘은
|
||||
`bind-system-plan.md` "Dispatch 체인" 절.
|
||||
|
||||
## 패키지 배치 — base는 값+API, roblox는 process/retract 글루
|
||||
## 패키지 배치 — base는 값+API, roblox는 process 글루
|
||||
|
||||
**Tag의 "값 타입과 clone 체이닝 API"(`Tag(...)`/`:Added`/`:Removed`/
|
||||
`:Contains`/`:Apply`/`Merged`)는 quad-base 소속** — `Modifier`와 정확히
|
||||
같은 층위(엔진 무관, 순수 데이터+연산). `CollectionService` 실제 호출
|
||||
(`TagHandler.process`/`retract`)만 quad-roblox 소속 — 이미 확정된 "base는
|
||||
인터페이스/값, backend는 process·retract 글루" 패턴(`LifetimeHandle`,
|
||||
(`TagHandler.process` 및 그 반환 클로저)만 quad-roblox 소속 — 이미
|
||||
확정된 "base는 인터페이스/값, backend는 process 글루" 패턴(`LifetimeHandle`,
|
||||
`Dispatch.addHandler` 자체가 이 패턴)을 값 타입 수준까지 그대로 확장한
|
||||
것뿐, 새 아키텍처 개념 아님.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
# 2026-08-13 네 번째 세션 — 사각지대 손 트레이싱 라운드, `Dispatch.processAs`/`retractSelfAndUnder` 체크포인트 핸들러 신설
|
||||
|
||||
## 배경
|
||||
|
||||
직전 세션(02)에서 `State<State<T>>`가 UB인데 "가능하다"로 문서에 낙관적으로
|
||||
적혀 있던 문제를 발견·수정한 것을 계기로, 사용자가 같은 방식(합성
|
||||
시나리오를 pseudocode에 손으로 대입)으로 다른 사각지대가 더 있는지
|
||||
찾아달라고 요청. 서브에이전트 4개(Tag/Attribute/Slot/Ref+교차훑기)를
|
||||
병렬로 띄워 `.claude/base/` 전체를 훑음.
|
||||
|
||||
## 1부 — 서브에이전트 발견 (실제 버그 3건)
|
||||
|
||||
1. **Tag 참조 카운트 붕괴**: `tagNameMap`의 holders가 `Tag` 객체 identity로
|
||||
키잉돼 있어서, 같은 Tag 객체(immutable이라 재사용이 흔한 관례,
|
||||
`local SELECTED = Tag("selected")`류)를 두 위치에 걸면 한 위치만
|
||||
retract돼도 다른 위치가 쓰는 태그가 지워지는 버그.
|
||||
2. **Attribute 그룹 자기충돌**: 그룹이 이름을 놓았다(retract, 로컬 캐시
|
||||
초기화) 나중에 같은 그룹이 같은 이름을 다시 포함하면(`rawNew`로 새
|
||||
키 생성), 전역 `owners` 레지스트리엔 옛 키가 안 지워진 채 남아있어
|
||||
"이미 다른 AttributeKey가 관리 중" 에러 — 그룹이 자기 자신과 충돌.
|
||||
3. **Slot `rawAdd`의 이중 State 언랩 실패**: `Slot:Add(State<State<T>>)`류
|
||||
이중 래핑에서 `reconcile`이 부르는 `rawAdd`가 `isState` 재검사를 안
|
||||
해서 State 객체가 그대로 `_elements`에 박힘 — **[정정, 2부에서 사용자
|
||||
확인] 실제로는 버그가 아니라 이미 확정된 `State<State<T>>` UB 범위의
|
||||
당연한 사례**, 최초 보고가 과다 보고였음.
|
||||
|
||||
문제 없음으로 확인된 것: `State<State<Ref>>`/`State<State<Tag>>`(StoreBind
|
||||
재진입 가드가 먼저 걸림), `PreRef` 재사용 가드, Slot의 owner-키 Relate ↔
|
||||
`chains` 네임스페이스 분리, `elementOwner`의 다단 Slot-in-Slot GC 안전성.
|
||||
|
||||
## 2부 — 사용자의 직접 기술 리뷰, 세 번째 설계로 수렴
|
||||
|
||||
사용자가 서브에이전트 보고에 대해 직접 pseudocode 레벨로 반박/재설계를
|
||||
제시(이 세션의 핵심):
|
||||
|
||||
- **Tag**: 위치(`k`) 기준 재키잉에 동의. 추가로 "Tag는 immutable이니
|
||||
`oldv==newv`면 retract 스킵 가능" 최적화 제안 — 채택.
|
||||
- **Attribute**: `AttributeGroupHandler.process`가 `retractUnder`에 `self`를
|
||||
안 넣는 이유를 질문하다, `retractUnder`가 `process` 안에 있는 것 자체가
|
||||
구조적으로 틀렸다고 재지적(`process`가 다시 안 불리는 상황이면
|
||||
`retractUnder`도 안 일어남) — 결론적으로 `owners`/`rawNew` 레지스트리를
|
||||
통째로 버리고, `isHandlable`이 없는(스캔에 안 걸리는) 순수 체크포인트
|
||||
핸들러 `AttributeGroupKeyHandler`를 새로 만들어 `Dispatch.processAs`로
|
||||
명시 push하고, 나중에 `Dispatch.retractSelfAndUnder`(신설 — 기존
|
||||
`retractUnder`가 `keep` **미만**만 지우는 것과 달리 `target` **이하**까지
|
||||
지움)로 체크포인트+그 아래 전부를 한 번에 철거하는 설계 제안. 손
|
||||
트레이싱으로 검증: 이 설계는 소유권 충돌 감지를 별도 레지스트리 없이
|
||||
기존 "같은 (inst,k)에 같은 핸들러 재사용 시 error" 가드(`State<State<T>>`
|
||||
가드와 동일 메커니즘)로 공짜로 얻음 — 채택, `AttributeKeyHandler`는
|
||||
완전 무상태로 단순화됨.
|
||||
- **Slot**: `releaseOwner`의 소유권 불일치를 조용히 무시하던 걸 즉시
|
||||
error로 바꿔야 한다는 지적(그 상황 자체가 이미 버그라는 판단) — 채택.
|
||||
`bindLifetime`이 `attachSlot`의 조건 분기에 묻혀있는 게 `unbindLifetime`이
|
||||
Handler 층위(`SlotHandler.retract`)에 있는 것과 비대칭이라는 지적 —
|
||||
`bindLifetime`을 `SlotHandler.process`로 이동, `attachSlot`은 순수
|
||||
구조적 mount 로직으로 단순화. 채택.
|
||||
- **Brand**: `isXX` 판별 함수들이 `nil`을 안전하게 처리하는지 서브에이전트로
|
||||
확인 요청 — 결과 **안전함**(weak registry lookup 방식이라 `nil` 키
|
||||
조회도 항상 안전하게 `false`로 귀결, 에러 나는 경로 없음). 문서 수정
|
||||
불필요, M0 스파이크 목록에 `isXX(nil)` 명시적 실측 케이스 추가는 선택
|
||||
사항으로 남김(안 함).
|
||||
|
||||
## 반영 완료
|
||||
|
||||
- `base/bind-system-plan.md`: 핸들러 계약에 `isHandlable` 선택적 필드로
|
||||
확장(생략 시 스캔 불가), "Dispatch 체인" 절에 `Dispatch.processAs`/
|
||||
`Dispatch.retractSelfAndUnder` 신설 + 체크포인트 패턴 설명, `handler.process`
|
||||
직접 호출 UB 경고에 `processAs`도 정식 진입점으로 포함.
|
||||
- `base/tag-plan.md`: holders를 Tag 객체 identity에서 위치(`k`) 기준으로
|
||||
재키잉, `TagHandler.retract`에 `oldv==newv` 조기 반환 추가.
|
||||
- `base/attribute-plan.md`: "이름 소유권"/"메커니즘" 두 절 전면 재작성 —
|
||||
`rawNew`/`owners` 제거, `AttributeGroupKeyHandler` 체크포인트+
|
||||
`processAs`/`retractSelfAndUnder` 페어로 교체, `AttributeKeyHandler`
|
||||
무상태화, `groupState`를 "이름→키 객체 맵"에서 "이름 집합"으로 단순화.
|
||||
- `base/slot-plan.md`: `releaseOwner` 불일치 시 error로 강화, `bindLifetime`
|
||||
호출을 `attachSlot`에서 `SlotHandler.process`로 이동.
|
||||
- `.claude/README.md`: 4개 파일 행에 이 세션 요약 append.
|
||||
|
||||
## 서브에이전트 조사(문서 수정 없음, 결과만)
|
||||
|
||||
- Relate `GetStrong`/`SetStrong` 키 누락 + const-키 비효율 패턴 코퍼스
|
||||
전수조사 — `attribute-plan.md`의 `owners:GetStrong(inst)` 1건(이미 이
|
||||
세션에서 재설계로 해소됨) 외 추가 발견 없음, 나머지 전부 정상 사용.
|
||||
- Brand/`isXX` nil 처리 전수조사 — 안전함 확인(위 2부 참고).
|
||||
|
||||
## 남는 것
|
||||
|
||||
- `Slot:ExtractAll()`/`:Splice(...)`의 반환값을 `Slot(...)` 생성자에 바로
|
||||
넣을 수 있다는 조합 예시를 문서에 추가하면 좋겠다는 사용자 코멘트 —
|
||||
버그 아님, 착수 안 함(원하면 다음 세션에).
|
||||
- `luau-test/` 스파이크(20개, 이 세션에서 다룬 새 메커니즘은 아직 스파이크
|
||||
파일 없음 — `Dispatch.processAs`/`retractSelfAndUnder`/`AttributeGroupKeyHandler`
|
||||
재설계를 검증할 스파이크 추가 여지가 있음, 필요시 다음 세션) — 여전히
|
||||
사용자가 `luau`로 안 돌려봄, M0 착수 전 최우선 게이트 그대로.
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
# 2026-08-13 다섯 번째 세션 — `Dispatch` 인덱스 기반 전면 재설계, `State<State<T>>` UB 해제
|
||||
|
||||
## 배경
|
||||
|
||||
직전 세션(04)에서 Attribute 그룹의 이름 소유권 충돌 버그를 고치려고
|
||||
`Dispatch.processAs`/`Dispatch.retractSelfAndUnder`("체크포인트 핸들러")
|
||||
패턴을 신설했음. 사용자가 이 설계에 대해 솔직한 평가를 요청 — Claude가
|
||||
`retractSelfAndUnder`가 사실 `retractUnder(keep=nil)`과 동치라 불필요할
|
||||
수 있다는 것과, "공짜 충돌 감지"가 실은 도메인 특화가 아닌 저수준 에러
|
||||
메시지라는 비용을 감춘다는 두 가지를 솔직히 지적.
|
||||
|
||||
## 1부 — 체크포인트 패턴 자체에 대한 사용자의 근본적 문제 제기
|
||||
|
||||
사용자가 "최상단에서 뭔가 지우는 일은 하기 싫다 — 그걸 제공한다는 것부터
|
||||
'어떤 핸들러가 최상단에 프로세싱을 넣어야 했다'는 가정이 생기고, 가정이
|
||||
많아지는 건 안 좋다"고 지적. 대신 "명시적으로 자신이 만들어낸 처리"를
|
||||
기입하는 편이 버그를 내기 어렵고 엔지니어링 비용도 거의 공짜라며, 각
|
||||
`process`/`processAs` 호출이 자신이 push했던 체인 인덱스를 제공하고, 각
|
||||
`process` 함수 자신이 retract 클로저를 반환하는 커링 방식을 제안. 재귀는
|
||||
`index+1`, 새 키로 위임하면 `1`부터 — `retractUnder`/`retractSelfAndUnder`도
|
||||
인덱스를 받게 하면 `State<State<T>>` 같은 복합 처리도 깨끗이 지원되면서
|
||||
전체 구조가 훨씬 단순해질 거라는 통찰.
|
||||
|
||||
Claude가 이 통찰을 검증: 기존 `chains`가 핸들러 **객체 identity**로
|
||||
포지션을 추적하던 것 자체가 `State<State<T>>`를 UB로 만들었던 근본
|
||||
원인(같은 싱글톤이 재귀로 자신과 매치되면 identity로 구분 불가)이었고,
|
||||
인덱스 기반으로 바꾸면 각 재귀 단계가 서로 다른 슬롯을 쓰므로 그 모호성
|
||||
자체가 사라짐 — 순환 참조만 여전히 UB로 남음(기존 "순환은 UB" 원칙과
|
||||
같은 급).
|
||||
|
||||
## 2부 — 세부 메커니즘 확정
|
||||
|
||||
사용자와 왕복하며 세 가지를 확정:
|
||||
|
||||
1. **`Dispatch.process`가 핸들러 호출 *전에* 점유 체크, 핸들러 부르고
|
||||
나서가 아님** — 부작용(SetAttribute, Observer 구독 등)이 실제로
|
||||
일어나기 전에 걸러야 낭비/깜빡임이 없음. 에러 메시지가 도메인 특화로
|
||||
상세하지 않은 것에 대해 사용자가 "이미 에러가 나면 패닉 상태고 그
|
||||
이후 정합성은 관리 대상이 아니다"라고 스스로 정리 — 상세 설명을 위해
|
||||
먼저 실행해보는 비용을 들일 이유가 없다는 데 동의.
|
||||
2. **시작 인덱스는 0이 아니라 1** — Luau `ipairs`/`#`은 1부터 연속된
|
||||
정수 키를 전제하므로(quad가 "props 순회 순서" 절에서 이미 이 관례에
|
||||
의존), 0을 쓰면 `ipairs` 순회에서 그 항목이 조용히 빠지고
|
||||
`quad-debug`가 `chains`를 그대로 순회해 보여주려는 계획과도 부딪힘.
|
||||
사용자 확인.
|
||||
3. **`retractUnder`/`retractSelfAndUnder`를 `Dispatch.retractFrom(inst,k,
|
||||
index,v)` 하나로 통합** — "자기 포함이냐 미만이냐"는 순전히 호출자가
|
||||
어느 인덱스를 넘기느냐의 문제가 됨(`myIndex` vs `myIndex+1`). 이걸로
|
||||
전날 세션에서 지적됐던 "retractSelfAndUnder가 사실 불필요할 수 있다"는
|
||||
비판이 자연스럽게 해소됨.
|
||||
4. 사용자가 추가로 "이러면 store 바인드가 이전 것을 retract할 필요도
|
||||
없다"고 지적 — `retractFrom`의 순회 구조(항상 깊은 인덱스부터 정리)가
|
||||
각 핸들러의 하위 위임 cascade를 자동으로 대신해준다는 걸 재확인. 이
|
||||
원칙을 손으로 검증하는 과정에서 SlotHandler의 "claimOwner가 false를
|
||||
반환하는(spurious 재발행) 분기"가 순진하게 no-op 클로저를 반환하면
|
||||
실제 자원의 cleanup 책임이 유실되는 사각지대를 발견 — 모든 process
|
||||
호출이 항상 동일한(slotValue/inst를 캡처하는) 대칭적 클로저를
|
||||
반환하도록 고쳐 해결(Ref는 이 문제가 없음 — 클로저의 동작이 `v`
|
||||
identity에만 의존해 어느 호출의 클로저든 동치이기 때문, Slot은
|
||||
`attachSlot`이라는 진짜 1회성 부작용이 있어서 달랐던 것).
|
||||
|
||||
## 반영 완료
|
||||
|
||||
- `base/bind-system-plan.md`: 핸들러 계약(3필드로 축소, `process`가
|
||||
retractor 반환), "확정된 디스패치 모델"/"None 센티널"/"Dispatch 체인"
|
||||
섹션 전면 재작성, `Dispatch.processAs`/`retractSelfAndUnder` 섹션
|
||||
삭제(→ archive), Store 바인드/Ref retract 예시 pseudocode 전부 새
|
||||
계약으로 갱신 — 여러 곳에서 private `Relate`가 클로저 캡처로 대체되며
|
||||
불필요해짐(StoreBind의 observer 저장, Ref의 언바인딩용 relate 일부).
|
||||
- `archive/checkpoint-handler-pattern-reversed.md` 신설 — 전날 만든
|
||||
체크포인트 패턴 원문+역전 이유 보존.
|
||||
- `base/attribute-plan.md`: `AttributeGroupKeyHandler`/`processAs`/
|
||||
`retractSelfAndUnder` 전부 제거, 그룹이 공개 `AttributeKey(name)`으로
|
||||
항상 인덱스 1에 직접 위임하도록 재작성. `groupState` Relate도 제거
|
||||
(반환 클로저가 이름 집합을 직접 캡처).
|
||||
- `base/tag-plan.md`: `TagHandler.process`가 retractor 반환, `kTagMap`
|
||||
제거(클로저가 `v` 직접 캡처), `tagNameMap`만 유지(여러 위치를
|
||||
가로지르는 진짜 누적 상태).
|
||||
- `base/slot-plan.md`: `SlotHandler.process`가 retractor 반환, `kSlotMap`
|
||||
제거 — 위 2부 4번 항목의 대칭성 수정 포함.
|
||||
- `architecture.md`/`store-semantics.md`/`modifier-plan.md`: 4필드
|
||||
Handler 계약 언급을 3필드로 정정.
|
||||
- 코퍼스 전체 grep sweep으로 `retractUnder`/`processAs`/
|
||||
`retractSelfAndUnder`/`.retract(inst` 잔존 참조 확인 — 의도된 역사
|
||||
서술 외 전부 반영 완료.
|
||||
|
||||
## 남는 것
|
||||
|
||||
- 오늘 신설한 인덱스 기반 모델(`Dispatch.process`의 점유 체크,
|
||||
`retractFrom`)을 검증할 `luau-test/` 스파이크가 아직 없음 — 기존
|
||||
`04`(다단 체인 스트레스 테스트)가 가장 가까운 후보라 다음 세션에
|
||||
그 파일을 새 모델에 맞춰 재작성하는 게 자연스러움.
|
||||
- `.claude/luau-test/`는 여전히 사용자가 `luau`로 안 돌려봄 — M0 착수
|
||||
전 최우선 게이트 그대로 유지, 이번 세션의 재설계도 손 트레이싱으로만
|
||||
검증됨.
|
||||
59
CLAUDE.md
59
CLAUDE.md
|
|
@ -125,7 +125,14 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
|
|||
나오면 그것부터 반영할 것(`luau-test/README.md`가 파일별로 뭘 우선
|
||||
확인해야 하는지 이미 적어둠). 걸리는 게 있으면 `base/` 문서부터 고치고,
|
||||
없으면 그대로 M0 실제 코드 작성에 재사용. 설계 자체는 더 이상 막힌
|
||||
게 없음 — `.claude/question.md` 2번이 최신 상태.
|
||||
게 없음 — `.claude/question.md` 2번이 최신 상태. **[2026-08-13 다섯
|
||||
번째 세션 추가 주의]** `Dispatch`가 인덱스 기반으로 전면 재설계됨
|
||||
(`base/bind-system-plan.md` "Dispatch 체인" 절, `chains`/`retractUnder`
|
||||
대신 `retractFrom`+`process`가 반환하는 클로저) — 기존 `luau-test/04`
|
||||
(다단 체인 스트레스 테스트)는 옛 모델(핸들러 identity 기반 `chains`)
|
||||
전제로 쓰여 있어서, 돌려보기 전에 새 모델에 맞춰 먼저 다시 써야 함
|
||||
(`session/2026-08-13-05-dispatch-index-based-redesign.md` "남는 것"
|
||||
참고) — 아직 안 함, 다음 세션 우선 항목.
|
||||
2. **용어 정리 — 1차 제안 이후 대부분 확정, 소수만 남음.** 최신 소스는
|
||||
`.claude/question.md` 1번(개수 반복 안 함, 항목 추가/해소될 때마다 여기가
|
||||
stale해지는 패턴이 반복됐어서). **[2026-08-13 정정]** `State`는
|
||||
|
|
@ -792,3 +799,53 @@ v2 논의 대상 아님으로 확정** (`session/2026-08-13-03-v1-newindex-typo-
|
|||
(`GetObjects()`류) 개념 자체가 없어져 재현 여부와 무관하게 v2 마이그레이션
|
||||
가이드에서 다룰 대상이 아님(있었다 해도 v1 전용 기능). `question.md`/
|
||||
`reference/quad-v1-architecture.md` 둘 다 해소로 반영.
|
||||
|
||||
**2026-08-13 네 번째 세션 — 사각지대 손 트레이싱 라운드, `Dispatch.processAs`/
|
||||
`retractSelfAndUnder` 체크포인트 핸들러 신설**
|
||||
(`session/2026-08-13-04-blind-spot-audit-checkpoint-handlers.md`)
|
||||
직전 세션의 `State<State<T>>` 발견 방식(합성 시나리오를 pseudocode에 손
|
||||
대입)을 서브에이전트 4개로 코퍼스 전체에 반복 — 실제 버그 3건 발견:
|
||||
Tag 참조 카운트가 객체 identity 기준이라 같은 Tag 객체 재사용 시 깨짐,
|
||||
Attribute 그룹이 이름을 놓았다 다시 포함하면 자기 자신과 소유권 충돌,
|
||||
(Slot의 이중 State 언랩은 사용자 확인 결과 버그가 아니라 기존
|
||||
`State<State<T>>` UB 범위였음, 과다 보고 정정). 사용자가 Attribute
|
||||
설계를 직접 재검토하며 `owners`/`rawNew` 수동 레지스트리를 통째로
|
||||
버리고, `isHandlable` 없는(스캔 불가) 순수 체크포인트 핸들러를
|
||||
`Dispatch.processAs`로 명시 push + `Dispatch.retractSelfAndUnder`(target
|
||||
자신 포함 철거, 신설)로 통째 정리하는 설계로 전환 — 소유권 충돌 감지가
|
||||
기존 재진입 가드로 공짜로 해결됨. Slot의 `releaseOwner` 불일치 무시를
|
||||
error로 강화, `bindLifetime` 위치를 Handler 층위로 이동해 `unbindLifetime`과
|
||||
대칭 맞춤. `Brand`/`isXX`의 nil 처리는 서브에이전트 확인 결과 안전.
|
||||
`base/bind-system-plan.md`/`tag-plan.md`/`attribute-plan.md`/`slot-plan.md`
|
||||
전부 반영 완료. **[정정, 같은 날 다섯 번째 세션]** 이 세션에서 신설한
|
||||
`Dispatch.processAs`/`retractSelfAndUnder` 체크포인트 패턴은 바로 다음
|
||||
세션에 더 근본적인 인덱스 기반 재설계로 대체되며 전부 걷어내짐 — 아래
|
||||
다섯 번째 세션 항목 참고, 원문은 `archive/checkpoint-handler-pattern-reversed.md`.
|
||||
|
||||
**2026-08-13 다섯 번째 세션 — `Dispatch` 인덱스 기반 전면 재설계,
|
||||
`State<State<T>>` UB 해제** (`session/2026-08-13-05-dispatch-index-based-redesign.md`)
|
||||
사용자가 체크포인트 패턴에 "왜 최상단에서 뭔가 지우는 일을 만들었냐,
|
||||
가정이 늘어나는 건 안 좋다"고 문제 제기하며 시작 — `chains`가 핸들러
|
||||
**객체 identity**로 위치를 추적하는 것 자체가 `State<State<T>>`를 UB로
|
||||
만든 근본 원인이라는 데까지 논의가 이어짐. 최종 설계: `chains`를
|
||||
**재귀 깊이 인덱스**로 추적(같은 키 재귀는 `index+1`, 다른 키 위임은
|
||||
항상 `1`부터, 0이 아니라 1인 이유는 Luau `ipairs`/`#` 관례), `Handler`
|
||||
계약이 `process`/`retract` 2-메소드에서 `process`가 자기 retract
|
||||
클로저(`(hintValue)->()`)를 반환하는 1-메소드로 축소, `Dispatch.process`가
|
||||
핸들러를 부르기 전에 그 인덱스 점유 여부를 먼저 체크(핸들러 부작용 낭비
|
||||
없음, 도메인 특화 에러 메시지가 없는 건 의도된 트레이드오프 — 에러=패닉
|
||||
상태라 상세 설명 비용을 들일 이유가 없다는 데 사용자 동의),
|
||||
`retractUnder`/`retractSelfAndUnder`도 `Dispatch.retractFrom(inst,k,index,v)`
|
||||
하나로 통합(자기 포함/미만 여부는 호출자가 넘기는 인덱스 자체로 표현).
|
||||
이 재설계로 `State<State<T>>`가 UB에서 정상 지원 대상으로 재정정되고,
|
||||
전날 만든 체크포인트 패턴 전체(`AttributeGroupKeyHandler`/`processAs`/
|
||||
`retractSelfAndUnder`)가 통째로 불필요해짐 — Attribute 그룹은 이제
|
||||
공개 `AttributeKey(name)`으로 항상 인덱스 1에 직접 위임, 점유 체크가
|
||||
소유권 충돌 감지를 대신함. 부수 효과로 여러 핸들러(StoreBind/Ref/Tag/
|
||||
Slot/Attribute)의 private `Relate` 상태 저장소가 대거 줄어듦(process가
|
||||
반환하는 클로저가 upvalue로 직접 캡처하므로 process→retract 사이 단발성
|
||||
handoff용 저장이 불필요해짐 — `Relate`는 여러 위치/사이클을 가로지르는
|
||||
누적 상태에만 남음). `bind-system-plan.md`/`tag-plan.md`/
|
||||
`attribute-plan.md`/`slot-plan.md`/`architecture.md`/`store-semantics.md`/
|
||||
`modifier-plan.md` 전부 반영, `archive/checkpoint-handler-pattern-reversed.md`
|
||||
신설.
|
||||
|
|
|
|||
Loading…
Reference in a new issue