decide(base): Slot CRUD(Add/Remove/Extract/Clear/Move/Swap)·요소 타입 제약 확정, Slot:List 신설

- Slot CRUD를 Add/Remove/Extract/Clear/Move/Swap 6종으로 확정, get/set 드롭
- "원시 연산 최소화" 원칙 뒤집고 Move/Swap 추가 — Extract+Add 기반 리오더가
  Parent 조작 두 번(detach+reattach)이라 무겁고, :List 없이 수동 구성한
  Slot엔 리오더 수단 자체가 없었음. Swap은 element 아닌 index 기준(element면
  위치 조회에 2n 들어 O(1) 약속이 깨짐)
- isMounted 이중 추적 분리(Slot 컨테이너 self._mounted vs 개별 element
  전역 weak-set), pre-implementation-audit.md 1-7/1-8 해소
- 요소 타입 제약 신설 — nil/None 둘 다 raw 요소로 금지, 핸들러 계층 값
  (Ref/PreRef/Observer/Effect/Modifier)은 self-ref 컨텍스트가 없어 의미
  불성립이라 즉시 error(Modifier 필드와 같은 판별 메커니즘 재사용).
  Slot<T>() 제네릭화
- Slot:List(data, updateFn, keyFn?) 신설 — 키 기반 동적 컬렉션 재조정,
  research/additional-primitives-plan.md에서 승격·통합. keyFn 생략 시
  index를 key로 사용(80% 케이스 커버, 캐스케이드 갱신 트레이드오프 명시)
- updateFn<UD>(item, index, userdata, prev)이 매 reconcile 사이클마다
  호출 — filter/toggle이 Visible 토글이 아니라 실제 파괴/재생성이 되도록
  재설계(200+ 항목에서 lazy하지 않은 문제 회피), prev 재사용이 저비용 경로
- :List가 Source를 더 이상 안 만들고 userdata로 그 권한을 updateFn에 위임 —
  result/userdata 반환값 커플링 제거, 정리 루프는 mounted가 아니라 직전
  keyIndex 전체를 순회해야 함(userdata만 살아남는 케이스 커버)
- userdata는 GC-native 값만 허용, 명시적 cleanup 필요한 값은 UB로 명문화 —
  item=nil 정리 훅 추가안은 부모 Destroy 경로에서 안 불려 절반만 동작하므로
  기각(retract가 Destroy 시 안 불리는 것과 같은 이유)
- question.md/ROADMAP.md/README.md 동기화, slot-plan.md 자체 정합성 재감사
  (stale 백로그 섹션 제거 등)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYfAz3BsaTrmMM8hnzn6mj
This commit is contained in:
qwreey 2026-08-09 19:21:22 +09:00
parent 911ab559ea
commit baa004ad42
Signed by: qwreey
GPG key ID: D28DB79297A214BD
8 changed files with 801 additions and 160 deletions

View file

@ -30,7 +30,7 @@
| `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차 라운드) |
| `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정 |
| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정 |
| `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?)`, `keyFn` 생략 시 index 기본값, `updateFn<UD>(item, index, userdata, prev)`가 매 사이클 호출되며 `prev` 재사용/`nil` 반환으로 filter 지원, `Source` 생성은 `updateFn``userdata`로 직접 관리, `userdata`는 GC-native 값만 허용·명시적 cleanup 필요한 값은 UB)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가 |
| `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정) |
| `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` 포인터로 압축 |
@ -58,7 +58,7 @@
| `documentation-plan.md` | 문서 사이트 구조(초심자/api/심화/`quadnomicon` 4축, 백엔드별 트랙 분리) + UI 네이밍 컨벤션·Store 부작용 패턴·권장 이벤트 핸들링 3개 세부 문서 뼈대 | 하 — 착수 시점 미정, 구조/스코프만 합의된 상태 |
| `documentation-content-map.md` | 위 4축에 실제로 뭘 채울지 `base/` 전체를 초심자/api/심화/skip으로 서베이한 콘텐츠 맵 — 초심자 core loop 목차 초안 포함 | 하 — 문서화 착수 시점의 목차/우선순위표로 쓸 것 |
| `framework-comparison-findings.md` | quad vs Fusion/Vide/react-lua 정직한 비교(실 소스 근거) — quad 강점, 진짜 불리한 점 중 고칠 만한 것 3개, 못 고치는 트레이드오프 정리 | 하 — 사용자 검토 후 반영 여부 결정 대기 |
| `additional-primitives-plan.md` | **[2026-08-07 범위 축소]** 확정/기각된 Effect·Blocker·Batch·Context는 `base/blocker-plan.md`·`base/effect-plan.md`·`archive/`로 분리됨 — 이제 **키 기반 동적 컬렉션 재조정**(Fusion `ForPairs`/Vide `indexes()`류에 대응하는 프리미티브가 quad엔 없음) 하나만 다룸 | 상 — 사용자 판단 대기, 착수 전 M0/M1 스코프에 영향 줄 수 있음 |
| `additional-primitives-plan.md` | **[2026-08-09 세 번째 세션, 전부 해소]** 마지막으로 남아있던 키 기반 동적 컬렉션 재조정도 `Slot:List(...)`로 확정되어 `base/slot-plan.md`로 승격 — 이 문서엔 새로 열린 설계 질문 없음, "빈 자리 아닌 것"/"문서화 백로그"/조사 소스 목록만 배경 자료로 유지 | 하 — 배경 리서치 기록용, 열린 결정 없음 |
| `pre-implementation-audit.md` | M0 착수 직전 크리티컬 감사(2026-08-06 신설) — `base/` 전체를 모호성/지연결정리스크/단순화후보 세 렌즈로 재검토, 11개 우선순위1(M0~M4 착수 전 확인 권장) + 11개 우선순위2 + 2개 단순화후보 | 상 — M0 착수 전 최소 우선순위1 항목 확인 권장 |
| `v1-compat-plan.md` | v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat` 패키지, v2→v1 단방향 브리지(`state:Observer()`+v1 프로퍼티 재대입), v2-in-v1/v1-in-v2 두 임베딩 방향의 기술 규칙까지 확정. quad2-try의 `quad-compat`은 빈 폴더로 실제 시도된 적 없었음을 확인 | 하 — Slot이 foreign Instance를 어떻게 다루는지만 Slot 코어 구현 시점까지 미결 |

View file

@ -6,7 +6,10 @@ architecture.md`의 "구현 착수: 소스 트리 구조 확정" 절 참고).
`.claude/initreq/raw-userinput.md` "slot을 구현하도록 하기로 했음" 절. Fusion의
`Children` SpecialKey와 Vide의 mount 무가드 비교는 `reference/comparison-fusion-vide.md`
참고 — 결론: **두 라이브러리 어디에도 이런 엄격한 단일 마운트 가드가 없음,
quad의 진짜 개선점.**
quad의 진짜 개선점.** **[2026-08-09 세 번째 세션]** CRUD 의미론
(`pre-implementation-audit.md` 1-7/1-8) 완전 확정, `research/
additional-primitives-plan.md`가 다루던 키 기반 동적 컬렉션 재조정도
`Slot:List(...)` 메소드로 이 문서에 승격·통합 완료 — 아래 참고.
## base/roblox 패키지 경계 (2026-08-04, 5차 라운드 확정)
@ -17,6 +20,12 @@ Handlers/Slot.luau`가 그 위에서 적용/해제만 담당 — 다른 모든
분리와 동일한 패턴(`base/architecture.md`의 소스 트리 참고). Slot 자체는
당연히 Instance들을 담게 될 것으로 취급.
**[2026-08-09 세 번째 세션 보강]** 이 경계가 담당하는 훅은 mount(`Add`)/
unmount(`Remove`) 둘이 아니라 **reposition(`Move`/`Swap`)까지 셋**
아래 "CRUD API 확정" 절 참고. reposition은 **Parent를 건드리지 않는다는
계약만 base가 강제**하고, quad-roblox가 이걸 `SetSiblingIndex`로 구현할지
(`LayoutOrder` 기반 정렬이라) 사실상 no-op으로 둘지는 구현 선택.
**추가로 필요해진 핸들러**: Slot과는 별개로, `k`가 number이고 `v`가 이미
만들어진 Instance인 경우(중첩 인스턴스를 자식으로 직접 넣는 경우, 예:
`Frame { Frame {} }`)를 위한 핸들러도 필요 — `quad-roblox/src/Handlers/
@ -25,9 +34,47 @@ InstanceChild.luau`. Slot은 "뮤터블 배열"을 다루고 이 핸들러는 "
## 개념
`add`/`remove`/`clear`/`get`/`set` 등 뮤터블 연산을 지원하는 메타 배열. 실제
바인드가 일어나면 child로 풀리고, 이 메타 배열에 CRUD를 하면 실제 children이
적절히 제어됨.
뮤터블 자식 배열. `Slot<T>()`(빈 인스턴스, 인자 없는 바닥 생성자 — 다른
독립 프리미티브의 `Type(args)` 관습과 동일하되, 무인자라 `T`를 추론할
수 없어 tbox 명시적 제네릭 적용 `Slot<<Instance>>()`로 지정)로 만들고,
`Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD로 조작하면 실제
바인드된 children이 그에 맞춰 갱신됨 — 정확한 시그니처는 아래 "CRUD API
확정" 절 참고(`get`/`set`은 드롭).
### 요소 타입 제약 (2026-08-09 세 번째 세션)
- **`nil`/`None` 둘 다 금지 — Slot의 raw 요소는 오직 실제 마운트 가능한
`T` 값만.** [정정, 같은 세션 후속] 처음엔 "배열 파트는 `nil` 대신
`None`" 원칙을 그대로 가져와 `None`을 Slot 요소로 허용했었는데,
`:List`의 필터링 요구사항을 구체화하며 재검토한 결과 불필요했음이
드러남 — `updateFn`이 "이번엔 렌더 안 함"을 표현하는 건 아래 `:List`
절에서 **`updateFn`의 반환값을 해석하는 `:List` 자신의 내부 로직**으로
처리되고, 그 경우 `rawAdd` 자체가 아예 호출되지 않음(즉 `None`이 실제로
Slot 배열에 들어갈 일이 없음) — 그래서 raw `Add`가 굳이 `None`
허용해야 할 이유가 없어짐. `element == nil`뿐 아니라 `element == None`
`Add`(및 내부 `raw*`)에서 즉시 `error` — "Slot 안엔 실제로 마운트
가능한 값만 들어간다"는 단일 규칙으로 단순화.
- **핸들러 계층 값(Ref/PreRef/Observer/Effect/Modifier) 금지, 즉시
`error`** — `Modifier` 필드가 이 값들을 담으면 즉시 `error`로 확정했던
것(`modifier-plan.md` 7번)과 같은 판별 메커니즘(`isRef`/`isPreRef`/
`isObserver`/`isEffect`/`isModifier` Brand predicate)을 그대로 재사용.
근거: `Dispatch/Leaf.luau`가 처리하는 "children 배열에 `Ref`/`Observer`/
`PreRef`가 직접 놓이는" 케이스는 **그 컴포넌트가 지금 만들고 있는
Instance 자기 자신을 가리키는 self-ref 캡처**(`Frame { PreRef():Callback(fn) }`가
그 Frame 자신을 잡는 것)라 `inst`가 "지금 생성 중인 바로 그 하나의
Instance"로 고정돼 있어야 의미가 성립하는데, **Slot은 특정 컴포넌트
호출 하나에 묶여있지 않고 이미 존재하는 부모에 나중에 독립적으로
붙는 동적 리스트라 이 전제 자체가 없음** — Slot 안의 Ref가 "무엇"을
가리켜야 하는지 정의가 안 됨. 대체 경로도 이미 있어 능력 손실 없음 —
특정 child에 ref가 필요하면 그 child를 만드는 컴포넌트 호출 자체에
Ref를 넘기면 됨(`slot:Add(Frame { Ref = myRef })`).
- **`T`의 실제 의미**: 위 배제 덕에 "이 Slot이 실제로 담을 수 있는 최종
마운트 가능한 값의 타입" 그 자체로 단순해짐 — quad-roblox엔 사실상
`T = Instance` 하나뿐(컴포넌트 호출 결과도 결국 Instance)이라
`D.InstSlot = Slot<<Instance>>`가 사실상 "그" Slot 타입. `Slot<T>()`
기본값(`T` 생략 시) 없이 항상 명시를 요구하는지, `quad-base`에선
`any`로 기본값을 두는지는 tbox 제네릭 적용 문법 확정 시 같이 정할 것
(이 문서 "자식으로 넘기는 클래스 스토어" 절의 기존 미결과 같은 갈래).
## 핵심 제약: 소유권 귀속과 단일 마운트
@ -43,6 +90,26 @@ Fusion의 `Children` SpecialKey는 이걸 "특정 SpecialKey 하나의 내부
같은 target에 두 번 `mount()`하면 조용히 두 개의 독립 루트가 생김 — 둘 다
반면교사.
### `isMounted` 이중 추적 분리 (1-8 해소, 2026-08-09 세 번째 세션)
"한 인스턴스가 다중 마운팅 절대 안 됨"이라는 위 원칙과 아래 "재마운트 시
즉시 throw"가 원래 하나의 `isMounted`로 뭉뚱그려 서술돼 있었는데, 실제로는
서로 다른 두 대상을 추적해야 함 — 명시적으로 분리:
- **Slot 컨테이너 자신**: `self._mounted: boolean`(Slot 인스턴스 필드
하나). **트리거 시점은 `Dispatch.process(inst,k,self)`가 이 Slot
객체에 대해 실제로 호출된 순간**(핸들러 매치 시점) — Instance
`Parent` 대입 완료를 기다리지 않음. 다른 모든 "마운트됨" 판정(PreRef
소진, Ref 콜백 fire 등)이 전부 dispatch-process 시점 기준이라 여기만
post-effect 기준으로 가면 일관성이 깨짐. 컴포넌트가 Slot을 prop으로
받아 저장만 하고 실제 트리에 안 놓는 경로는 `process`가 애초에 안
불려서 이 정의로도 오탐 없음.
- **개별 element**: Slot 안에 담기는 각 element(Instance/컴포넌트 결과 등)
마다 전역 weak-set 멤버십으로 추적 — 특정 Slot 인스턴스에 안 묶임
("한 인스턴스가 어디에도 중복 마운트 안 됨"이 라이브러리 전역 불변식이라서).
`Add`가 이 weak-set을 확인(이미 참이면 error)/설정, `Remove`/`Extract`
둘 다 여기서 제거(둘의 차이는 파괴 여부일 뿐, "마운트 해제"라는 점은 같음).
## 여럿 존재 가능, 부모가 실제 데이터 테이블만 다루면 됨
Slot은 하나의 instance 안에 여럿 존재할 수 있다. 전부 하나의 children으로
@ -117,6 +184,351 @@ Slot은 바인딩되는 순간 그 안의 요소를 전부 own해버리는 데
**이번 마일스톤에서는 오버엔지니어링으로 판단, 하지 않음** — 필요성이 명확해지면
그때 별도로 다시 논의.
> **범위 명확화(2026-08-09 세 번째 세션)**: 위 "폐기, 옮기지 않음"은
> **프레임워크가 store-bind 재실행으로 Slot 값 전체를 통째로 갈아치울
> 때**(retract)만의 얘기 — **사용자가 직접 `Slot:Extract(element)`
> 부르는 CRUD 경로는 이것과 다른 시나리오**다. Extract로 뺀 element는
> 파괴되지 않고 호출부가 소유권을 되찾으며, **임의의 다른 Slot으로
> 자유롭게 다시 `Add`할 수 있다**(아래 "CRUD API 확정" 절) — retract가
> "옮기지 않는다"고 확정한 건 프레임워크가 알아서 옮겨주는 자동 portal을
> 안 만든다는 뜻이지, 사용자가 명시적으로 두 번 호출(`Extract` 후
> `Add`)해서 옮기는 것 자체를 막는 게 아니다.
## CRUD API 확정 (2026-08-09 세 번째 세션, 1-7 해소)
`get`/`set`은 드롭 — 최종 표면(`Move`/`Swap`은 같은 세션 후속 논의에서
추가, 아래 "원시 최소화 원칙 정정" 참고):
| 연산 | 시그니처 | 복잡도 | 의미 |
|---|---|---|---|
| `Add` | `Slot:Add(element, index?)` | O(n) | 삽입(뒤 요소 밀림), `index` 생략 시 끝에 추가 |
| `Remove` | `Slot:Remove(element)` | O(n) | 제거 **+ 파괴**(retract/Destroy) |
| `Extract` | `Slot:Extract(element)` | O(n) | 제거, **파괴 안 함** — 호출부가 소유권 회수, 임의의 다른 Slot에 재삽입 가능 |
| `Clear` | `Slot:Clear()` | O(n) | 전체 `Remove`(전부 파괴) — 빈 Slot에 호출해도 no-op |
| `Move` | `Slot:Move(element, newIndex)` | **O(n)** | 제자리 재배치 — 옛/새 위치 사이 요소들이 밀림/당겨짐(배열 splice와 동일 의미), **Parent 안 건드림** |
| `Swap` | `Slot:Swap(indexA, indexB)` | **O(1)** | 두 **인덱스**의 요소를 맞교환, 나머지 안 건드림, **Parent 안 건드림** |
- **식별은 기본적으로 element 레퍼런스 기준**(`Add`/`Remove`/`Extract`/
`Move`) — element는 컴포넌트 호출/Instance 생성이 만들어낸 구체 값이라
항상 아이덴티티로 구분 가능. 인덱스 기준으로 하면 add/remove가 반복될
때 호출부가 들고 있던 인덱스가 곧바로 stale해짐.
- **`Swap`만 예외 — 인덱스 기준(`indexA`/`indexB`), element 레퍼런스
아님(2026-08-09 세 번째 세션 정정).** element 레퍼런스로 받으면 Slot이
element→index 역방향 맵을 안 갖고 있는 이상 두 element 각각의 현재
위치를 찾는 데 O(n)씩(총 2n) 들어서 **`Swap`이 약속하는 O(1)이 깨짐**
`Move`는 어차피 시프트 자체가 O(n)이라 element 조회 비용이 묻히지만,
`Swap`은 조회 비용이 곧 전체 비용이라 이 차이가 그대로 드러남. 호출부는
보통 "지금 몇 번째 항목과 몇 번째 항목을 바꿀지"를 이미 알고 있는
상황(예: 드래그 리오더 UI)이라 인덱스로 받는 게 자연스럽기도 함.
- **열거(iteration)/`Get` API는 지금 안 만듦**`Slot:List(...)`(아래)는
자기 자신의 key→element 맵을 따로 들고 있어 Slot의 내부 상태를 조회할
필요가 없음. 다른 실사용이 나오면 그때 추가(YAGNI).
- **에러 조건 — 전부 즉시 `error()`, no-op 없음**(기존 "재마운트 시 throw"와
같은 fail-fast 톤):
- `Add`: element가 이미 어딘가(같은 Slot이든 다른 Slot이든) 마운트돼
있으면 에러 — "라이브러리 차원에서 다중 마운팅 절대 금지" 원칙을
CRUD 경로에도 동일 적용. `element``nil`/`None`이거나 핸들러 계층
값(Ref/PreRef/Observer/Effect/Modifier)이면 에러 — 위 "요소 타입 제약" 절.
- `Remove`/`Extract`: 그 element가 지금 이 Slot의 멤버가 아니면 에러.
- `Move`: `element`가 이 Slot의 멤버가 아니면 에러.
- `Swap`: `indexA`/`indexB` 중 하나라도 범위 밖(1..현재 개수)이면 에러 —
`Swap(i, i)`(같은 인덱스)는 위치가 안 바뀌므로 에러 없이 no-op.
- **`Move`/`Swap`은 반환값 없음(void)** — 내부 재배치만 수행, 멤버십
weak-set을 안 건드림(요소가 Slot을 떠난 적이 없으므로) — 그래서 `Add`/
`Remove`/`Extract`보다 저렴함.
- **모든 공개 CRUD는 "가드 확인 + `raw*` 위임"의 얇은 wrapper**`Add`/
`Remove`/`Extract`/`Clear`/`Move`/`Swap` 전부 `self._listed`(`:List`가
설치돼 있으면 수동 CRUD 금지)만 확인하고 실제 로직은 `rawAdd`/
`rawRemove`/`rawExtract`/`rawClear`/`rawMove`/`rawSwap`에 있음 — 이
`raw*` 함수들이 `:List`의 reconcile이 가드 없이 직접 호출하는 바로
그 함수(아래 "`Slot:List`" 절의 "구현" 참고). 공개 메소드에 로직이
따로 있는 게 아니라 전부 이 한 세트를 공유.
- **재진입성**(Observer/store-bind 재실행 콜백 안에서 `Add`/`Clear`를
다시 호출) — 별도 가드 불필요. CRUD는 평범한 동기 테이블 뮤테이션 +
Dispatch 호출일 뿐이라 "일반적 무한루프는 방어 안 함, provider 버그로
간주"라는 기존 원칙이 그대로 적용됨.
- **`Slot()` 생성자**: 인자 없는 빈 생성자로 확정 — 초기 children을
가변인자로 받는 옵션도 검토했으나, "명시적으로 `Add`해야 들어간다"
쪽이 이 프로젝트의 "매직 없이 명시적" 기조와 더 맞음.
### 원시 최소화 원칙 정정 — `Move`/`Swap` 공개 API로 추가 (같은 세션 후속)
`:List`의 리오더 메커니즘을 구체화하던 중, 처음엔 `Extract`+`Add(index)`
조합으로 충분하다고 봐서 "원시 연산 최소화" 원칙에 따라 별도 `Move`/`Swap`을
안 만들기로 했었는데 — 실제로는 두 가지 공백이 드러나 **뒤집음**:
1. **`Extract`+`Add`는 리오더치고 너무 무겁다.** `Extract`의 계약이 "제거,
파괴 안 함, 소유권 회수"라 백엔드가 곧이곧대로 구현하면 실제 Parent
조작이 두 번(detach+reattach) 일어남 — Roblox에서 `AncestryChanged`
발화, 잠재적 깜빡임, 불필요한 재바인딩 비용까지 딸려올 수 있음.
순서만 바뀌는, 매 `:List` 재계산마다 흔히 일어나는 케이스치고 과함.
2. **`:List` 없이 수동으로 Slot을 구성하는 사용자에겐 리오더 수단이
아예 없었다** — `Extract`+`Add`도 결국 위 1번 비용을 그대로 지므로
대체제가 못 됨.
둘 다 원시 최소화보다 우선하는 실사용 공백이라 판단, `Move`(O(n), 배열
splice 의미)와 `Swap`(O(1), 순수 페어 교환)을 공개 CRUD에 추가 — 시간복잡도
차이를 문서화해서 사용자가 상황에 맞게 고를 근거를 줌. `:List`의 reconcile
자체는 키 기반 diff가 "이 키는 이제 절대 위치 i다"를 산출하지 "A랑 B를
맞바꿔라"를 산출하지 않으므로 내부적으로는 계속 `Move`(의 가드 없는 버전)만
사용 — `Swap`은 순수하게 수동 Slot 사용자를 위한 편의 API.
## `Slot:List(data, updateFn, keyFn?)` — 키 기반 동적 컬렉션 재조정 (2026-08-09 세 번째 세션, `research/additional-primitives-plan.md`에서 승격·통합)
Fusion `ForPairs`/`ForKeys`/`ForValues`, Vide `indexes()`/`values()`, React
`key` prop에 대응하는 프리미티브 — 데이터 배열을 정체성(key) 기준으로
diff해서 변경분만 생성/갱신/파괴한다. **독립 타입이 아니라 `Slot`
콜론 메소드**로 확정(아래 "왜 자유 함수/새 타입이 아닌가" 참고) — 자기
자신을 변경하고 자신을 반환, `Ref():Callback(fn)`류의 기존 체이닝 패턴과
동일:
```
Slot():List(data, updateFn, keyFn?) -> Slot -- self
```
**파라미터 순서 정정, `keyFn` 선택 인자화 (같은 세션 후속).** 원래
`(data, keyFn, updateFn)`이었는데, 실사용 대부분(사용자 추정 80%)이
"item 자체의 정체성 추적 없이 그냥 순번을 key로 써도 충분한" 단순 목록
(재정렬·중간 삽입/삭제로 인한 identity 보존이 필요 없는 경우)이라
`keyFn`을 매번 명시하게 하는 게 불필요한 보일러플레이트였음 — `updateFn`
필수 인자 자리(두 번째)로, `keyFn`을 선택 인자(세 번째, 생략 시 인덱스를
그대로 key로 사용하는 `function(item, index) return index end`)로 재배치.
**tradeoff는 명시적으로 문서화 필요**: 인덱스를 key로 쓰면 중간 삽입/삭제
시 그 뒤 모든 항목이 "다른 item인데 같은 key"로 오인돼 캐스케이드 갱신이
일어남(파괴/재생성은 없음, 단지 identity 보존이 없을 뿐) — 흔한 업계
관행(React `key` 생략 시 index 기본값, Vue `v-for` key 없이 쓰는 경우)과
같은 트레이드오프라 새로 설명할 개념은 아님, 재정렬/중간 삽입이 실제로
일어나는 목록엔 진짜 `keyFn`을 넘기라고 안내하는 정도로 충분.
**이름 정정 — `renderFn``updateFn` (같은 세션 후속).** 아래 서술하는
호출 계약이 "새 key가 나타났을 때 1회 렌더"에서 "매 사이클 재호출되어
갱신 여부를 스스로 판단"으로 바뀌면서, "render"보다 "update"가 실제
역할을 더 정확히 반영한다고 판단해 이름도 같이 바꿈.
- `data: {[K]:V} | State<{[K]:V}> | Source<{[K]:V}>` — plain이면 최초
1회 배치만 하고 이후 추적 안 함(다시는 안 바뀌므로), State/Source면
아래 메커니즘이 계속 동작. 기존 leaf 프로퍼티의 "리터럴 또는 State
둘 다 받는" 폴리모픽 컨벤션 재사용.
- `keyFn(item, index) -> key`(선택, 생략 시 `index`를 그대로 key로 사용) —
아이템 값과 인덱스 둘 다 받음.
- **`updateFn<UD = any>(item, index, userdata: UD?, prev: T?): (T | nil, UD?)`
— 매 reconcile 사이클마다 모든 key에 대해 호출됨.** `:List`는 더 이상
item을 위해 `Source`를 대신 만들어주지 않음(아래 "왜 `Source`
`:List`가 안 만드는가" 참고) — `item`/`index`는 매번 그 사이클의 raw
현재값 그대로 넘어감, 반응형으로 쓸지는 `updateFn`이 알아서 결정.
- **`userdata: UD?`** — 이 key에 대해 지난 호출에서 `updateFn` 자신이
반환해둔 두 번째 값을 그대로 돌려받음(첫 호출은 `nil`). 완전히
opaque — `:List`는 안을 전혀 안 들여다봄. `updateFn`이 원하는 걸
아무거나 담아도 됨(item의 `Source`, 여러 파생 State, 로컬 UI
상태 등).
- **`prev: T?`** — 이 key에 대해 지금 실제로 마운트돼 있는
element(없으면 `nil`, 첫 호출을 포함해 언제든 가능).
- **반환값 두 개는 서로 완전히 독립**`:List``result``userdata`
사이에 어떤 커플링도 안 둠(예: `result``nil`이라고 `userdata`
자동으로 지우지 않음), 그대로 기록만 함. **[정정, 같은 세션 후속]**
처음엔 "`result`가 `nil`이면 `userdata`도 같이 버림"이었으나, 이러면
"인스턴스는 파괴하되 다시 나타날 때 재사용하려고 캐시는 남겨두고
싶다" 같은 정당한 패턴 자체가 원천 봉쇄됨 — 그럴 이유가 없어 커플링을
없앰. 흔한 경우(둘 다 리셋)는 그냥 `return nil` 하나로 충분(Lua가
안 받은 반환 슬롯을 알아서 `nil`로 채움), 캐시를 남기고 싶으면
명시적으로 `return nil, ud`.
- `updateFn`은 매번 다음 중 하나를 반환:
- **`prev`를 그대로 반환** — "지금 마운트된 걸 계속 쓴다"는 뜻.
관용구: `if prev and (필터 통과) then ...update ud...; return prev,
ud end`. 실제 마운트/파괴가 없는 **저렴한 경로**.
- **새 값(또는 다른 값)을 반환** — 첫 렌더(이 key 최초 등장) 또는
의도적 교체. `prev`가 있었다면 그건 파괴되고 새 값이 그 자리를
대신함.
- **첫 번째 값으로 `nil`을 반환** — "지금 이 key는 렌더 안 함"(filter
탈락 등). `prev`가 있었다면 실제로 파괴됨(단순 `Visible = false`
아님 — 아래 참고). `None`을 반환해도 동일 취급(둘 다 허용, 편의상
`nil` 권장 — 반환값이 raw Slot 요소로 직접 들어가는 게 아니라
`:List`의 reconcile이 해석만 하므로 "요소 타입 제약"의 raw
`nil`/`None` 금지와 안 부딪힘).
- `userdata = userdata or {}`류 lazy-init 관용구가 `UD`가 완전히 자유
제네릭인 상태에서도 Luau 타입 시스템이 매끄럽게 좁혀주는지는 **실측
필요**(M0/M6 착수 시 확인 항목, 지금 단정 안 함).
### 왜 매 사이클 호출로 바뀌었는가 — filter/toggle 문제
사용자가 제기한 문제: item이 State 변경으로 "더 이상 렌더되면 안 되는"
상태가 될 수 있는데(예: 검색 필터에서 탈락), 기존 "1회만 호출" 모델엔
이걸 표현할 방법이 없었음. 실무에서 흔한 회피책은 실제로 제거하지 않고
`Visible = false`만 토글하는 것 — 하지만 이건 **lazy하지 않음**: 필터링된
항목도 여전히 완전히 살아있는 Instance라 애니메이션/이벤트 연결/재계산이
계속 돎. 리스트가 200개+가 되면 "보이는 건 20개인데 200개가 전부 계속
돌아가는" 문제가 실제 비용으로 드러남.
**해법**: `updateFn`을 매 사이클 호출하되, `prev`를 줘서 "바꿀 게 없으면
그대로 돌려주기만 하면 되는" 저렴한 경로를 만들고, filter 탈락은 `nil`
반환으로 **진짜 파괴**되게 함 — Visible 토글이 아니라 실제 Remove.
200개 중 20개만 통과하는 필터면 20개만 실제로 살아있고 나머지 180개는
정말로 존재하지 않음(애니메이션도 안 돎).
**"이전 상태를 다음 호출에 어떻게 넘기냐" 문제는 `userdata`가 그 채널** —
item이 plain table이라 매번 `Source`를 새로 안 만들고 재사용하려면 그
`Source`를 어딘가 저장해야 하는데, `:List`가 그걸 대신 안 만들어주는
대신(아래 참고) `userdata`라는 전용 채널로 `updateFn`이 직접 관리하게
함 — filter 탈락 후 재등장해도(Instance는 파괴됐다 새로 만들어져도)
`userdata`를 살려뒀다면 그대로 이어짐(위 "반환값 두 개는 서로 독립" 참고).
**sort는 이 재설계와 무관, 기존 메커니즘으로 이미 커버됨** — 호출부가
`data`의 순서를 바꾸면 `keyIndex[key] ~= i` 감지 → `Move`가 그대로
처리, 새 메커니즘 필요 없음(사용자가 filter와 같이 물었던 것 중 sort는
원래도 문제가 없었음).
### 왜 `Source``:List`가 안 만드는가 — item/index를 raw로 넘기는 이유
이전 초안은 `:List``itemState`/`indexState`(내부 `Source`)를 강제로
만들어 `updateFn`에 넘겨줬는데, 재검토 결과 이건 **`:List`가 굳이 강요할
필요 없는 결정**이었음 — 반응형 바인딩이 필요 없는 단순한 행(예: 매번
그냥 새로 계산해도 싼 텍스트 하나)까지 전부 `Source` 생성 비용을 억지로
지게 됨. `userdata`로 이 권한을 완전히 `updateFn` 쪽에 넘기면, 원하는
item만 자기 `Source`를 만들어 `userdata`에 담고, 나머지는 매번 raw
`item`에서 그냥 다시 계산해도 됨 — 어느 쪽이 나은지는 케이스 by 케이스라
`:List`가 미리 정할 이유가 없음.
**부수 효과 — 이전 "item 값은 무조건 재전파, index는 실제 변경시만"
비대칭 백로그가 사라짐.** `:List`가 더 이상 `Source`를 안 만드므로 그
문제 자체가 `:List` 소관이 아니게 됨 — item/index를 반응형으로 감쌀지,
매번 무조건 `:Set()`할지 조건부로 할지는 전부 `updateFn` 작성자의 선택.
### `userdata`의 생명주기 제약 — GC-native만 허용, 명시적 cleanup이 필요한
값은 UB (같은 세션 후속)
**검토했다가 기각한 대안**: `item``T?`(nilable)로 바꿔서, key가 최종
제거될 때 `updateFn(nil, index, userdata, prev)`를 한 번 더 불러 "정리할
기회"를 주는 안 — `if not item then <userdata 안의 구독 해제 > return
end` 관용구로 `userdata` 안에 담긴 리소스(예: `Observer:Subscribe()`
구독)를 정리할 수 있게 하자는 아이디어. **기각 — 사용자가 스스로 반례를
찾음**: 이 훅은 `data`에서 key가 빠져 `reconcile`이 다시 도는 정상
경로에서만 발화함 — 하지만 **Slot을 담고 있는 부모 Instance 자체가
`Destroy`되는 경로**(가장 흔한 소멸 경로)는 `reconcile`을 다시 안 돌기
때문에 이 훅이 전혀 안 불림. 절반만 동작하는 정리 메커니즘은 없는 것보다
나쁨 — 사용자가 "정리가 보장된다"고 오해하고 `Subscribe`류를 `userdata`
넣었다가 Destroy 경로에서 조용히 새는 게 실제로 훨씬 위험한 결과.
`retract`가 Destroy 시엔 절대 안 불린다는 기존 원칙(`base/
lifecycle-pattern.md` "quad는 라이프사이클 중간에 있지 않다")과 정확히
같은 이유로, `:List`에 새 반쪽짜리 예외를 만들 이유가 없음.
**대신 명시적 제약으로 문서화**: **`userdata`에는 반환된 element(또는
Slot 자신)보다 명시적으로 오래 살아야 하는 값을 담으면 안 됨 — GC만으로
자연히 정리되는 값만 담을 것(plain 값, `Source`/`State` 등), `:Subscribe()`
`Observer`/`Effect`류처럼 명시적 `:Unsubscribe()`가 필요한 값을 담는 건
UB.** `:List`가 어떤 teardown 경로도 보장 안 하므로, `userdata` 안의
무언가가 GC 하나만으로 안 죽는다면 그건 곧 leak. 이건 quad 전역
GC-native 원칙(`lifecycle-pattern.md`)을 `:List`라는 구체적 지점에 그대로
적용한 것뿐 — 새 원칙 아님.
### 구현
```lua
function Slot:List(data, updateFn, keyFn)
assert(not self._listed, "Slot already has :List installed")
self._listed = true
keyFn = keyFn or function(_, index) return index end
local mounted, userdata, keyIndex = {}, {}, {}
local function reconcile(items)
local newKeyIndex, seen = {}, {}
for i, item in ipairs(items) do
local key = keyFn(item, i)
newKeyIndex[key] = i
seen[key] = true
local prev = mounted[key]
local result, ud = updateFn(item, i, userdata[key], prev)
if result == None then result = nil end -- 편의: None도 nil과 동일 취급
if result ~= prev then
if prev ~= nil then rawRemove(self, prev) end -- 파괴
if result ~= nil then rawAdd(self, result, i) end -- 새로 배치
mounted[key] = result
elseif prev ~= nil and keyIndex[key] ~= i then
rawMove(self, prev, i) -- 그대로 쓰되 위치만 이동
end
userdata[key] = ud -- result와 무관, 그대로 기록
end
for key in pairs(keyIndex) do -- 직전 사이클에 존재했던 전체 key
if not seen[key] then
local prev = mounted[key]
if prev ~= nil then rawRemove(self, prev) end
mounted[key], userdata[key] = nil, nil
end
end
keyIndex = newKeyIndex
end
if isState(data) then
data:Observer(function() reconcile(data:Get()) end)
-- Observer는 등록 즉시 1회 실행 확정 -> 최초 population도 공짜
else
reconcile(data)
end
return self
end
```
- **`data:Observer(fn)`**: 새 구독 프리미티브 아님 — 2026-08-07 여섯 번째
세션에 이미 "등록 즉시 1회 실행" 확정된 그 메소드를 그대로 씀.
`reconcile`은 매번 **현재 전체 스냅샷을 받아 O(n) 단일 패스로 diff**
— 트리 전체를 비교하는 비싼 diff가 아니라 `seen` 셋 하나로 "새 key
목록에 없는 건 지운다"만 판정하는 React/Vue/Solid류의 표준 key 기반
방식, `data`가 참조를 유지한 채 뮤테이션+`Emit()`되는 경로도 지원해야
하는 이상 최소 한 번은 훑어야 하는 게 불가피함.
- **`updateFn`을 매번 부르는 게 비싼 게 아닌 이유** — 흔한 경로(`prev`
그대로 반환)는 함수 호출 하나뿐, 실제 Instance 생성/파괴가 있는 건
key가 새로 나타나거나/사라지거나/filter로 구조가 바뀌는 경우뿐.
200개 중 값만 갱신되는 사이클엔 200번의 값싼 함수 호출이 있을 뿐,
200번의 재구성이 있는 게 아님.
- **`mounted`/`userdata`를 정리하는 루프가 `mounted`가 아니라 이전
사이클의 `keyIndex`를 순회하는 이유** — `userdata`가 이제 `result ==
nil`이어도 살아남을 수 있어서(위 "반환값 두 개는 서로 독립"), 어떤
key가 `mounted[key] == nil`인 채로(필터 탈락 상태) `data`에서 완전히
사라지면 `pairs(mounted)`로는 그 key가 아예 안 잡혀서 `userdata`
못 치워지고 샘 — 직전 사이클에 실제로 존재했던 **전체** key 집합
(`keyIndex`, 매 사이클 모든 key에 대해 채워짐)을 순회해야 이 케이스를
놓치지 않음.
- **`mounted`/`userdata`/`keyIndex`**: 이 Slot 인스턴스의 평범한 로컬
필드(클로저 업밸류) — 별도 전역 weak table(`Relate` 등) 불필요, `self`
살아있는 동안만 존재하면 되고 Slot이 죽으면 클로저도 같이 GC됨.
- **`reconcile`이 직접 호출하는 건 `rawAdd`/`rawRemove`/`rawMove`뿐** —
`rawExtract`/`rawSwap`/`rawClear`도 (위 "모든 공개 CRUD는 가드+위임"
구조상) 당연히 존재하지만, `:List`의 reconcile 알고리즘 자체가 그
셋을 쓸 일이 없을 뿐(제거는 항상 파괴 확정이라 `Extract` 아닌 `Remove`
경로, 리오더는 항상 절대 위치 이동이라 `Swap` 아닌 `Move` 경로,
`Clear`는 reconcile 단위가 아니라 Slot 전체 단위 연산이라 무관).
- **리오더는 `Move`(의 가드 없는 버전)** — Parent를 안 건드리는 진짜
저비용 경로. 최소-이동 알고리즘(LIS 기반 등) 자체는 구현 시점 최적화로
미룸, 여기선 계약(파괴 없이 위치만 바뀜)만 확정.
### 왜 자유 함수/새 타입이 아닌가
처음엔 `List(data, updateFn, keyFn?) -> Slot` 같은 자유 함수(또는 `Slot`
구조적으로 만족하는 새 타입 `List`)로 검토했으나 둘 다 기각:
- **자유 함수 기각**: `Source(default)`/`Ref(default)`/`Store({defaults})`가
지켜온 "`Type(args)` 팩토리 이름 = 반환 타입"이라는 컨벤션이 깨짐 —
`List(...)``Slot`을 반환하면 이름과 실제 타입이 안 맞음.
- **새 서브타입(`List extends Slot`, Source⊇State 같은 구조적 서브타이핑)
기각**: Source가 State의 서브타입이어야 했던 이유는 Source가 State보다
진짜로 더 많은 공개 메소드(`:Set`/`:Emit`)를 갖기 때문 — 반면 이
프리미티브는 Slot이 이미 가진 것(`Add`/`Remove`/`Extract`/`Clear`/
`Move`/`Swap`) 위에 새 공개 메소드를 얹지 않음. 그냥 "자동으로 채워지고
관리되는 Slot"일 뿐이라 별도 타입일 이유가 없음.
- **결론: `Slot`의 콜론 메소드.** "원천에 종속된 파생 데이터는 자유 함수
생성자가 없고 메소드로만 얻어진다"(State/Observer)는 기존 분류 원칙과
같은 모양 — 다만 여기 원천은 Source가 아니라 이미 만들어진 Slot 자신.
Fusion의 `ForPairs`/`ForKeys`/`ForValues` 3분할도 이 재구성으로 통합
방향이 자연스러워짐(단일 `:List`가 이미 Slot 메소드 이름공간 안에
있으니 여러 진입점을 나열할 이유가 약해짐) — **통합 확정**.
- 이름 후보로 검토됐던 `Render`/`Draw`도 이 재구성으로 더 이상 "타입
이름"이 아니라 "메소드 이름" 문제가 됐지만, `List`가 여전히 가장
낫다고 판단(`Render`는 quad의 "렌더 주기 없음" 원칙과 메소드 이름으로
써도 충돌 소지가 남고, `Draw`는 즉시모드 GUI 뉘앙스) — **`List`로 확정**.
## 자식으로 넘기는 클래스 스토어
자식에게 내려주는 클래스 스토어는 부모 쪽에서 미리 만들어서 내려보내는 게
@ -131,13 +543,9 @@ Slot은 바인딩되는 순간 그 안의 요소를 전부 own해버리는 데
정도 — 설계 방향 자체는 더 이상 열려있지 않음.
- "클래스가 슬롯을 받는 방법"(Named Slot 없음)도 확정됨(위 "클래스가 슬롯을
받는 방법" 절 참고).
- **[해소됨, 2026-08-09 세 번째 세션]** `add`/`remove`/`clear` CRUD 의미론,
`isMounted` 이중 추적 분리, 키 기반 동적 컬렉션 재조정(`Slot:List`) —
위 "CRUD API 확정"/"`isMounted` 이중 추적 분리"/"`Slot:List`" 절 참고.
- **여러 Slot이 형제로 섞일 때 순서 보장**은 아직 열려있음(위 "여러 Slot이
섞일 때 순서 보장" 절 참고) — Roblox 단일 백엔드로는 급하지 않음, Slot
코어 로직 구현 시점에 재검토.
- **`add`/`remove`/`clear` CRUD 의미론 자체가 아직 정의 안 됨** — 위
"개념" 절이 이 세 연산을 뮤터블 메타 배열에 지원되는 것처럼 나열만
하고 정확한 동작(예: `add`가 위치를 지정하는지, `remove`가 값 동등성
기준인지 참조 기준인지, `clear`가 재마운트 가능한 자리를 남기는지)은
정의돼 있지 않음. `research/pre-implementation-audit.md`가 이미 지적한
갭이고, 2026-08-07 아홉 번째 세션에서 사용자가 직접 다루기로 보류함 —
Slot 코어 로직 구현 착수 전 반드시 확정 필요.

View file

@ -20,16 +20,12 @@
`archive/batch-rejected.md`, Context(+레이어드 Store) → `archive/
context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 남김.
- **키 기반 동적 컬렉션 재조정(유일하게 아직 완전히 열려있음, 최우선)**:
Fusion `ForPairs`/`ForKeys`/`ForValues`, Vide `indexes()`/`values()`,
React `key` prop에 대응하는 프리미티브가 quad엔 전혀 없음 확인 —
`pre-implementation-audit.md` 1-7번(Slot CRUD 미정의)과 같은 지점이라
같이 정의해야 함. **자유 함수**(plain data 또는 State 둘 다 받는
폴리모픽 시그니처, State 메소드 프레이밍은 Source 안 쓰는 컴포넌트가
못 쓴다는 반례로 철회됨), Slot에 파괴 없이 빼내는 `Extract` 연산 추가
필요 — 최종 이름만 미정(아래 "용어 정리" 절에 후보 추가). **사용자가
"작업 전에 모든 정의를 마치고 싶다"고 명시** — M0 이전 완전 확정 목표.
상세는 `research/additional-primitives-plan.md`(이제 이 주제 전용).
- **[해소됨, 2026-08-09 세 번째 세션]** 키 기반 동적 컬렉션 재조정 —
`Slot:List(data, updateFn, keyFn?) -> Slot` 콜론 메소드로 완전히 확정
(자유 함수/새 타입 둘 다 기각, "Slot이 이미 가진 것 위에 새 공개
메소드를 안 얹으니 별도 타입일 이유가 없다"는 게 근거). Slot의
`Extract`/`Add(index)` CRUD와 같이 확정됨, 상세는 `base/slot-plan.md`
"`Slot:List(...)`" 절.
- **[해소됨, 2026-08-07 여섯 번째 세션]** Effect/Observer 관계 — Effect는
자유 함수로 확정(`state` 인자를 받으면 내부적으로 `state:Observer(...)`
조합해 재실행+자동 cleanup 배선, React `useEffect`와 동형). `state:Observer(fn)`
@ -80,13 +76,9 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만**
질문이라 `is`보다 `can` 계열 접두를 유지하는 쪽이 낫다는 방향으로 사용자가
기욺 — 여전히 미확정, 다음에 `can`으로 시작하는 구체 대안(예: `canRun`)을
같이 검토할 것.
- **키 기반 동적 컬렉션 재조정 프리미티브 이름(3순위, 2026-08-06 추가)**:
`Keyed`는 타이핑이 어색하고 "Slot을 렌더한다"는 느낌과 안 맞는다는
사용자 피드백으로 탈락. 후보: `Render`(가장 직접적이지만 "quad엔 렌더
주기가 없다"는 기존 원칙과 이름이 충돌해 보일 수 있음), `Draw`(짧지만
즉시모드 GUI 뉘앙스), `List`(중립적이나 메커니즘을 안 알려줌) — 아직
미정, `research/additional-primitives-plan.md`의 "키 기반 동적 컬렉션
재조정" 절 참고.
- **[해소됨, 2026-08-09 세 번째 세션]** 키 기반 동적 컬렉션 재조정 이름 —
`List`로 확정(`Slot:List(...)` 메소드, `Render`/`Draw`는 기각). 상세는
`base/slot-plan.md` "`Slot:List(...)`" 절.
- **[해소됨, 2026-08-09 세션]** `Bound`**`canBound(handle): boolean`
탑레벨 함수로 확정**, `canExecute`와 같은 결(raw 필드를 직접 노출하는
대신 predicate 함수로 감쌈). `base/bind-system-plan.md` "이중 바인딩
@ -178,9 +170,10 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만**
`pre-implementation-audit.md` 3-1). **[해소됨]** UI shorthand의 기존
UICorner 매칭 기준도 `base/ui-shorthand-plan.md`에 이미 확정 반영돼
있던 것을 이번에 `pre-implementation-audit.md` 2-11에도 해소 표시로
동기화. **아직 실제로 열려있는 건 두 개뿐** — Slot CRUD 의미론
(`add`/`remove`/`clear`) 미정의(1-7), 우선순위 스캔 동률/매치실패
처리(1-3) — `pre-implementation-audit.md` 본문 참고.
동기화. **[해소됨, 2026-08-09 세 번째 세션]** Slot CRUD 의미론
(`add`/`remove`/`clear`) 미정의(1-7)/`isMounted` 이중 추적 혼용(1-8) —
`base/slot-plan.md` 참고. **아직 실제로 열려있는 건 하나** — 우선순위
스캔 동률/매치실패 처리(1-3) — `pre-implementation-audit.md` 본문 참고.
- **[해소됨, 2026-08-08 두 번째 세션]** `Frame { ref }`/`Frame { observer }`처럼
children 배열 숫자 슬롯에 직접 놓는 leaf 값을 매칭·바인드하는 Handler
(`(i:number, v=Ref/Observer/PreRef)`)의 패키지 배치 — 원래 제안대로

View file

@ -4,9 +4,12 @@
**2026-08-07 문서 정리에서 확정/기각된 항목을 분리**: Effect/Blocker →
`base/blocker-plan.md`/`base/effect-plan.md`, Batch(lexical) → `archive/
batch-rejected.md`, Context(+레이어드 Store 대안) → `archive/
context-rejected.md`. 이 문서에는 **아직 완전히 열려있는 것 하나만** 남음
— 키 기반 동적 컬렉션 재조정. 사용자가 "작업 전에 모든 정의를 마치고
싶다"고 명시 — M0 전 완전 확정이 목표.
context-rejected.md`. **[2026-08-09 세 번째 세션]** 마지막으로 남아있던
키 기반 동적 컬렉션 재조정도 `Slot:List(...)` 메소드로 완전히 확정되어
`base/slot-plan.md`로 승격됨(아래 절은 요약+포인터만 남기고 상세는 그쪽
참고) — **이 문서에 새로 열려있는 설계 질문은 더 이상 없음**, 아래 표/
"빈 자리 아닌 것"/"문서화 백로그"/"참고 소스" 절은 배경 리서치 기록으로만
유지.
## 배경
@ -29,7 +32,7 @@ Vide/v1/artworks 소스 근거 조사, Context 구현 난이도 판정) + 그
| 후보 | 판정 | 현재 위치 |
|---|---|---|
| 키 기반 동적 컬렉션 재조정 | **진짜 빈 자리, 최우선** — 아직 열려있음 | 이 문서(아래) |
| 키 기반 동적 컬렉션 재조정 | **채택, 확정** — `Slot:List(...)` 메소드로 통합 | `base/slot-plan.md`(2026-08-09 세 번째 세션) |
| Effect(leaf 죽음에 확정 정리 + `state` 있으면 재실행) | **채택, 확정** — Observer와의 관계도 해소 | `base/effect-plan.md` |
| Blocker(값 기반 emit 지연/합치기) | **채택** — Batch의 대안 | `base/blocker-plan.md` |
| Batch(함수/코루틴 스코프 lexical block) | **기각** | `archive/batch-rejected.md` |
@ -40,121 +43,21 @@ Vide/v1/artworks 소스 근거 조사, Context 구현 난이도 판정) + 그
| Error Boundary | 빈 자리 아님 | 아래 "빈 자리 아닌 것" 절 |
| Readonly wrapper | 빈 자리 아님 | 아래 "빈 자리 아닌 것" 절 |
## 키 기반 동적 컬렉션 재조정 — 최우선, 설계 진행 중
## 키 기반 동적 컬렉션 재조정 — 확정, `base/slot-plan.md`로 승격 (2026-08-09 세 번째 세션)
**무엇인가**: 데이터 배열(인벤토리, 리더보드, 채팅로그처럼 삽입/삭제/
재정렬되는 목록)을 UI로 렌더링할 때, 이전 렌더 결과와 새 데이터를
**정체성(key) 기준으로 diff**해서 변경분만 생성/갱신/파괴하는 프리미티브.
React `key` prop, Vue `v-for :key`, Solid `<For>`, Fusion `ForPairs`/
`ForKeys`/`ForValues`, Vide `indexes()`/`values()`가 이 층위. `base/
slot-plan.md`의 `Slot`은 CRUD 껍데기일 뿐 diff 엔진이 아니라서 이 프리미티브가
quad엔 없음(확인 완료, 근거는 문서 하단 소스 목록 참고).
`ForKeys`/`ForValues`, Vide `indexes()`/`values()`에 대응하는 프리미티브 —
데이터 배열을 정체성(key) 기준으로 diff해서 변경분만 생성/갱신/파괴한다.
**최종 확정 형태는 자유 함수도 새 타입도 아니라 `Slot`의 콜론 메소드**
(`Slot():List(data, updateFn, keyFn?) -> Slot`) — 상세 시그니처/구현
의사코드/왜 자유 함수·새 타입이 아닌지/`Move` 기반 리오더/`userdata` 기반
`Source` 관리 위임은 전부 `base/slot-plan.md`의 "`Slot:List(...)`" 절
참고, 여기서 반복 안 함.
### 왜 "매핑 함수" 직관이 안 통하는가
React류는 매 렌더마다 새 가상 트리를 통째로 새로 만들고 reconciler가
old/new를 diff한다 — 그래서 "그냥 다시 매핑"이 성립한다. quad는 컴포넌트가
**한 번만 실행**되므로 그 "매 렌더"가 아예 없다. 그래서 이건 매핑 함수가
아니라 **한 번 설치되면 스스로 diff-and-patch를 도는 Observer 변형**이다 —
전달한 `renderFn`은 새 key가 나타났을 때 딱 한 번만 불리고, 그 이후로는
값이 바뀌어도 절대 다시 안 불린다.
### 메커니즘 스케치
```
key가 새로 나타남 → renderFn(key, itemState) 호출, itemSource:Set(초기값), Slot에 삽입
key가 사라짐 → Slot에서 제거(파괴)
key가 유지, 값만 변경 → renderFn 재호출 없음, itemSource:Set(새값)만 → itemState 구독한 리프만 갱신
key가 유지, 순서만 변경 → renderFn 재호출 없음, Slot 위치만 조정(파괴/재생성 없음)
```
`itemState`는 이 프리미티브 내부 소유의 Source이고, `renderFn`엔 그 State
뷰만 노출한다(Source 자체를 주면 renderFn이 실수로 `:Set()`해서 diff
엔진과 경쟁할 수 있음).
### 폼 팩터 — 자유 함수로 정정 (State 메소드 프레이밍 철회)
`state:Keyed(...)`처럼 State의 메소드로 두려던 초안은 "Source를 안 쓰는
컴포넌트가 접근 못 함" 반례로 철회됨 — 상세 경위는
`archive/keyed-collection-state-method-rejected.md` 참고.
재검토 결과 — quad는 이미 **leaf 프로퍼티가 "리터럴 값 또는 State" 둘 다
받는 폴리모픽 컨벤션**을 갖고 있다(`BackgroundColor3 = someColor`도
`BackgroundColor3 = someState`도 같은 자리에서 됨, 정적이면 한 번만 세팅,
State면 구독). 이 프리미티브도 같은 컨벤션을 따르는 게 자연스럽다 —
**자유 함수**로 두고 `data` 인자가 plain array/table이든 `State<array>`/
`Source<array>`든 둘 다 받게 한다. Plain이면 diff 로직 자체가 발동 안 하고
(다시는 안 바뀌므로 최초 1회 배치만 하면 끝), State/Source면 위 메커니즘이
동작한다. 이름은 아직 미정이지만 시그니처 형태:
```
<이름>(data: {[K]: V} | State<{[K]: V}>, keyFn: (V, K) -> Key, renderFn: (Key, State<V>) -> Child) -> Slot
```
### Slot 확장 — `Extract`, 그리고 확정해야 할 소유권 모델
순서 변경(파괴 없이 위치만 옮기기)을 처리하려면 Slot에 **"파괴하지 않고
빼내기"** 연산이 필요하다. 기존 확정 사항(`slot-plan.md`):
> retract되는 slot은 옮겨지지 않고 그냥 폐기된다 ... React의 portal류로
> 나중에 옮길 수 있게 하는 것도 검토됐으나 이번 마일스톤에서는
> 오버엔지니어링으로 판단, 하지 않음.
이건 **"다른 Slot으로 옮기기"(portal)를 안 한다**는 결정이지 **"같은 Slot
안에서 위치만 바꾸기"**를 막는 결정이 아니다 — 순서 재조정은 후자만
필요하므로 이 결정과 충돌하지 않는다.
**사용자가 명확히 한 최종 소유권 모델(확정)**:
- **Slot 자체의 바인딩은 귀속·불가역** — 한 번 마운트되면 그 Slot 컨테이너
자체를 다른 곳에 다시 바인드할 수 없음(기존 "재마운트 시 throw"와 동일).
- **Slot 안에 있는 개별 요소의 입출력은 자유** — 넣고 빼고 다시 넣는 것에
제약 없음.
- **단, 요소의 `.Parent`를 Slot API를 거치지 않고 직접 만지는 건 UB.**
제안:
```
Slot:Extract(key) -> element -- 파괴 없이 빼냄
Slot:Add(element, index?) -- 기존 그대로, Extract로 뺀 것도 다시 넣을 수 있음
```
리오더는 `Extract` + `Add(index)` 조합으로 구현(별도 `Move`/`Swap` 원시
연산은 새로 안 만듦 — 원시 개수를 최소화). 덤으로 이게
`pre-implementation-audit.md` 1-8번("isMounted가 element 단위와 Slot
컨테이너 단위를 뭉뚱그려 서술됨")을 자연스럽게 푼다 — `Extract`가 있으면
"element의 mounted 여부는 가변, Slot 컨테이너 자체의 mounted 여부는
불변"으로 두 개념이 명확히 분리된다.
### 이름 후보 (확정 아님, 용어 정리 라운드 대상)
`Keyed`는 탈락 — 타이핑이 어색하고 "Slot을 렌더한다"는 느낌과도 안 맞는다는
사용자 피드백. 후보:
- `Render(data, keyFn, renderFn) -> Slot` — 가장 직접적("슬롯을 렌더한다").
단, quad는 "렌더 주기가 없다"를 아키텍처 원칙으로 강조해왔는데 이 이름만
"Render"를 쓰면 혼동 가능성 있음.
- `Draw(data, keyFn, renderFn) -> Slot` — 짧음, "Render"와의 충돌은 피하지만
즉시모드 GUI(Dear ImGui류) 뉘앙스를 가져올 수 있어 quad의 유지형(retained)
모델과 어긋나 보일 수 있음.
- `List(data, keyFn, renderFn) -> Slot` — 중립적, 결과가 "리스트"라는 것만
전달, 메커니즘을 과다 암시 안 함.
세 후보 다 트레이드오프가 있어 확정 안 함 — `.claude/question.md`의 용어
정리 라운드에 후보로 올려둠.
### 남은 열린 질문
- 최종 이름(위 후보 중 또는 새 후보).
- `Extract`/`Add(index)` 조합의 정확한 시그니처(에러 조건: 이미 다른 Slot에
있는 요소를 Add하면? 존재 안 하는 key를 Extract하면?).
- Fusion의 `ForPairs`/`ForKeys`/`ForValues` 3종 분리를 안 따르고
`renderFn(key, itemState)` 1종으로 통합하는 안이 여전히 유력(단순화
후보, `pre-implementation-audit.md`의 "단순화 후보" 렌즈와 같은 결) —
최종 확정 아님.
- **목표: M6(Slot) 착수 전, 가급적 M0 스파이크 이전에 이 전체를 완전히
정의**(사용자 명시적 요청) — 이 프리미티브가 Slot 자체의 CRUD 시맨틱
(`pre-implementation-audit.md` 1-7, 지금 미정)과 얽혀 있어서, Slot을
먼저 정하고 나중에 여기를 끼워맞추면 재작업이 날 가능성이 높음. Slot
CRUD 시맨틱을 정의할 때 이 프리미티브의 요구사항을 같이 고려할 것.
이 아래 있던 "왜 매핑 함수 직관이 안 통하는가"/"메커니즘 스케치"/
"이름 후보"/"남은 열린 질문" 절은 전부 그 문서로 흡수·확정되어 제거함 —
State 메소드로 두려던 초기 폼팩터가 기각된 경위만 여전히
`archive/keyed-collection-state-method-rejected.md`에 별도 보존.
## 빈 자리 아닌 것으로 확인된 것들

View file

@ -88,7 +88,14 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부
- 초심자: Modifier 기본 체이닝+merge 우선순위 규칙 실제 예시 / Slot 기본 개념(children 배열)+클래스가 슬롯 받는 방법(Named Slot 없음) / 마운트된 slot 재마운트 시 즉시 throw
- api: Setter가 리터럴/변환 함수 둘 다 받음(→심화: getter 없는 이유) / 필드가 State일 수 있는 4가지 조합 표(→심화: 반응성 유지/끊김 이유) / `mod:UICorner(8)` dot-access 생성자 관습 / Slot은 인스턴스당 여럿 가능 / 중첩 인스턴스 자식 처리 / retract 시 slot 내용 폐기(→심화: portal 없는 이유) / `:Apply(factory)` 기본 체이닝 관용구(→심화: 언제 `Apply` vs `Overridden`인지 성능 기준) / `:Peek<<T>>(key)` + `isState`(→심화: `Get`과 이름을 다르게 한 이유)
- 심화: 정적 merge vs 런타임 pluggable 기각 이유(CSS cascade) / immutable+clone 체이닝 이유(형제 오염 방지) / getter 미채택 이유 / `__index` 런타임 구현 통찰 / Modifier가 핸들러 계층을 모르는 이유 / base/roblox 패키지 경계(Dispatch/Slot vs Handlers/Slot) / Slot 단일 마운트 소유권이 v1/Fusion/Vide 대비 개선인 이유 / retract=폐기 확정 히스토리(portal 검토 후 기각) / **왜 `Apply`가 기본이고 `Overridden`는 최적화 특수 케이스인가**(계산 의존성 있는 조합 vs 독립적 재사용 가능 조각의 병합 — 2026-08-07 다섯 번째 세션, `modifier-plan.md` 9번) / 왜 `Apply`가 clone 대신 mutate하지 않는가(형제 오염 방지가 개별 clone 비용 절감보다 우선)
- 열린 질문(문서화 보류): 여러 Slot이 형제로 섞일 때 순서 보장 — 미확정
- 열린 질문(문서화 보류): 여러 Slot이 형제로 섞일 때 순서 보장 — 미확정.
**[2026-08-09 추가]** `Slot:List``prev`/`userdata` 재사용 최적화를
getting-started에서 "항상 파괴 후 재생성" 단순 버전만 가르치고 나중에
최적화 단계에서 별도로 알려줄지, 아니면 Slot이 학습 순서상 core loop
후반부라 어차피 Source/State를 다 아는 시점이니 처음부터 완전한 형태로
한 번에 가르칠지 — 사용자가 직접 제기, 미결. 제 의견은 후자(후반부
배치라 단계적으로 나눌 이득이 적어 보임)로 기울지만 확정 아님, 실제
콘텐츠 작성 시점에 결정.
- skip: 세션 날짜/확정 이력, 문서 승격/정정 안내
### store-semantics.md / tween-plan.md / ui-shorthand-plan.md

View file

@ -198,6 +198,10 @@ eager cleanup을 택했다"는 구체적 위험을 지적하며 "quad는 rbvm
### 1-7. Slot의 `add`/`remove`/`clear` CRUD 의미론이 정의돼 있지 않음
**[해소됨, 2026-08-09 세 번째 세션]** `base/slot-plan.md`의 "CRUD API
확정" 절에 `Add`/`Remove`/`Extract`/`Clear` 시그니처·에러 조건·재진입성까지
전부 확정 반영됨(`get`/`set`은 드롭). 아래는 당시 지적 원문, 참고용으로만 남김.
**위치**: `base/slot-plan.md` "개념" 절 — "`add`/`remove`/`clear`/`get`/
`set` 등 뮤터블 연산을 지원하는 메타 배열"이라고만 서술.
@ -214,6 +218,11 @@ eager cleanup을 택했다"는 구체적 위험을 지적하며 "quad는 rbvm
### 1-8. Slot "재마운트 시 throw"가 두 가지 다른 추적 대상을 혼용해서 서술됨
**[해소됨, 2026-08-09 세 번째 세션]** `base/slot-plan.md`의 "`isMounted`
이중 추적 분리" 절에 Slot 컨테이너(`self._mounted`, dispatch-process 시점
트리거)와 개별 element(전역 weak-set)를 명시적으로 분리 반영됨. 아래는
당시 지적 원문, 참고용으로만 남김.
**위치**: `base/slot-plan.md` "핵심 제약: 소유권 귀속과 단일 마운트" +
"마운트된 Slot의 재마운트는 즉시 throw" 절.

295
CLAUDE.md
View file

@ -44,8 +44,9 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
- `.claude/research/` — 아직 착수 전, 사용자와 상의 필요한 설계 논의.
`tween-plan.md`/`existing-instance-bind-plan.md`/`debug-tooling-plan.md`/
`documentation-plan.md`/`documentation-content-map.md`/
`framework-comparison-findings.md`/`additional-primitives-plan.md`(키 기반
동적 컬렉션 재조정만 남음)/`pre-implementation-audit.md`/`v1-compat-plan.md`
`framework-comparison-findings.md`/`additional-primitives-plan.md`(2026-08-09
세 번째 세션에 마지막 열린 항목까지 전부 해소, 이제 배경 자료용)/
`pre-implementation-audit.md`/`v1-compat-plan.md`
— 전부 후순위(급한 건 `tween-plan.md` 세부 옵션 정도). 최신 목록·우선순위는
`.claude/README.md`가 소스, 여기서 개수 반복 안 함(과거에 "두 개뿐"이라
적어놨다가 새 문서 추가될 때마다 안 갱신되는 패턴이 반복돼서 아예 안
@ -1858,3 +1859,293 @@ Modifier만 예외인 건 Modifier가 애초에 dispatch 경로 자체를 안
**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, 위 "다음 세션 예고"
Slot/키 기반 컬렉션 재조정도 그대로) — 이번 세션은 순수 문서 위생
작업이라 설계 우선순위엔 영향 없음.
## 2026-08-09 세 번째 세션 — Slot CRUD 완전 확정, 키 기반 동적 컬렉션
재조정이 `Slot:List(...)` 메소드로 통합·승격
위에서 예고된 "다음 세션 주제"(Slot과 키 기반 동적 컬렉션 재조정)를
실제로 다룬 세션. `pre-implementation-audit.md` 1-7/1-8과
`research/additional-primitives-plan.md`의 마지막 열린 항목이 전부
`base/slot-plan.md`에 흡수·확정됐음 — 상세는 그 문서 본문이 소스,
여기는 요지만:
- **Slot CRUD 최종 확정**: `Add(element, index?)`/`Remove(element)`(제거+파괴)/
`Extract(element)`(제거, 파괴 안 함)/`Clear()`(전체 `Remove`) — `get`/`set`은
드롭(YAGNI). 식별은 항상 element 레퍼런스 기준(인덱스 아님). 에러 조건
전부 즉시 `error()`(이미 다른 곳에 마운트된 element를 `Add`, 멤버 아닌
element를 `Remove`/`Extract`) — fail-fast 톤 유지. 재진입성은 별도 가드
불필요(기존 "무한루프 방어 안 함" 원칙 재사용). `Slot()`은 인자 없는
빈 생성자로 확정.
- **`isMounted` 이중 추적 분리(1-8 해소)**: Slot 컨테이너 자신은
`self._mounted`(트리거는 `Dispatch.process`가 이 Slot 객체에 실제로
호출된 시점 — 다른 모든 "마운트됨" 판정과 동일하게 dispatch-process
기준), 개별 element는 전역 weak-set(라이브러리 전역 다중 마운트 금지
불변식이라 특정 Slot에 안 묶임).
- **`Extract` 후 portal 범위 — 임의의 다른 Slot으로 자유 이동 확정.**
기존 "retract되는 slot은 폐기되지 옮겨지지 않는다"는 확정은 **프레임워크가
store-bind 재실행으로 값을 통째로 갈아치우는 시나리오**에만 해당하고,
사용자가 명시적으로 `Extract`→`Add` 두 번 호출해서 옮기는 것과는 다른
얘기라는 걸 명확히 구분(사용자 확인).
- **키 기반 동적 컬렉션 재조정 — `Slot:List(data, keyFn, renderFn) -> Slot`
확정, 자유 함수/새 타입 둘 다 기각.** 처음엔 `List(...) -> Slot` 자유
함수를 검토했으나, "타입 이름=반환 타입"이라는 `Source(default)`
팩토리 컨벤션이 깨진다는 문제를 사용자가 직접 지적 — Source⊇State식
구조적 서브타입도 검토했으나 List가 Slot 위에 새 공개 메소드를 안
얹으므로(그냥 "자동으로 채워지는 Slot") 별도 타입일 근거가 약해 기각.
최종적으로 "원천에 종속된 파생 데이터는 메소드로만 얻어진다"(State/
Observer와 같은 원칙, 여기 원천은 Slot 자신)로 수렴 — `Ref():Callback(fn)`
체이닝과 같은 패턴. Fusion `ForPairs`/`ForKeys`/`ForValues` 3분할도
단일 `:List`로 통합 확정.
- **구현 메커니즘은 전부 기존 프리미티브 재사용, 새 개념 없음** — 사용자가
"너무 마법같다"고 지적해 의사코드까지 구체화해서 검증: `data:Observer(fn)`
(2026-08-07 확정된 "등록 즉시 1회 실행"), `Source(item)`, 방금 확정한
Slot CRUD의 비공개(가드 안 거치는) 버전 세 개의 조합일 뿐. `itemSources`/
`elements`/`order`는 Slot 인스턴스의 평범한 클로저 업밸류(별도 전역
저장소 불필요). 리오더는 `Extract`+`Add(index)` 조합, 최소-이동
알고리즘 자체는 구현 시점 최적화로 미룸.
- **`renderFn(key, itemState)``itemState`는 내부 `Source`를 그냥
`State`로 다운캐스트해서 넘김 — 별도 `ReadOnlySource` 타입 안 만듦**
(사용자 확인: "그걸 위해 ReadOnlySource 같은 걸 만들 이유가 있냐 하면
아니다, 이미 그게 State다"). 타입 레벨 힌트만, 런타임 강제 없음(`Peek`/
Modifier UB와 같은 "규율 위반은 방어 안 함" 기조) — 나중에 진짜
런타임 강제가 필요해지면 `src:Compute(function(v) return v end)`(항등
함수 Compute)로 `:Set` 없는 State를 만드는 가벼운 대안이 있다는 것만
메모.
- **백로그, 착수 안 함(연구만) — reconcile의 무조건 `:Set()` 재전파.**
`data`가 테이블 뮤테이션+`:Emit()`으로 오는 경로도 지원해야 해서 이전
값과 동등성 비교를 할 방법이 없고, 그래서 값이 실제로 안 바뀐 item도
매 재계산마다 재전파됨 — 사용자 판단: "이 재계산 비용은 우리가 핸들해야
할 부분은 아닌 것 같다", `Blocker`류 값-동등성 기반 전파 억제도 검토했으나
"확정 안 하면 이전 값 자체가 없어서 비교가 안 된다"는 근본적 어려움이
있어 기술적으로 더 논의해볼 만한 주제로만 `research/
additional-primitives-plan.md`에 백로깅.
- **`research/additional-primitives-plan.md` 사실상 전부 해소** — 마지막
열린 항목(키 기반 컬렉션)까지 없어져서, 이 문서엔 이제 새로 열린 설계
질문이 없음(배경 자료로만 유지). `question.md`/`ROADMAP.md`(M6 체크박스)/
`README.md` 전부 동기화 완료.
**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — Slot/키 기반 컬렉션
재조정이 이번 세션에서 완결됐으므로 더 이상 "다음 세션 예고" 대상 아님.
남은 열린 것은 여전히 `question.md``DI`→`D`/`canExecute`→`isAlive`/
`Brand` 이름, `pre-implementation-audit.md` 1-3(우선순위 스캔 동률 처리),
"여러 Slot이 형제로 섞일 때 순서 보장"(Roblox 단일 백엔드론 급하지 않음)
정도.
**같은 세션 후속 — quad-roblox 구현 관점에서 재검토, `Move`/`Swap` 공개
CRUD로 추가(원시 최소화 원칙 뒤집음), `renderFn``indexState` 추가.**
사용자가 "Slot 값 변경을 quad-roblox가 실제로 어떻게 따라가나"를 구체적으로
캐물으며 세 가지가 드러남:
- **`renderFn(key, itemState) -> element`에 위치 정보가 빠져있었음** —
Roblox는 순서를 `LayoutOrder`로 표현하므로 `renderFn`이 그걸 반응형으로
바인딩하려면 위치도 State로 받아야 함. `itemState`와 독립된
`indexState: State<number>`를 추가(`renderFn(key, itemState,
indexState)`) — 값 변경/위치 변경은 서로 독립 신호라는 게 근거, Slot이
`LayoutOrder`를 대신 관리해주는 마법은 안 둠.
- **`Extract`+`Add(index)`로 리오더를 구현하면 백엔드에서 진짜 Parent
조작이 두 번(detach+reattach) 일어난다는 게 드러남** — Roblox
`AncestryChanged` 발화, 잠재적 깜빡임, 불필요한 재바인딩 비용까지
딸려올 수 있어 매 `:List` 재계산마다 흔한 케이스치고 과함.
**`Move`(O(n), 배열 splice 의미)/`Swap`(O(1), 순수 페어 교환)을 공개
CRUD로 추가** — 둘 다 Parent를 안 건드림. `:List` 없이 수동으로 Slot을
구성하는 사용자에게 애초에 리오더 수단이 아예 없었다는 것도 같이
드러난 공백 — "원시 연산 최소화" 원칙보다 이 두 실사용 공백이 우선한다고
판단해 뒤집음(같은 세션 내 정정이라 별도 archive 없이 `slot-plan.md`
본문에 "원시 최소화 원칙 정정" 절로 직접 반영).
- **base/roblox 패키지 경계에 mount/unmount 둘로는 부족, reposition
훅이 세 번째로 필요함** — `Dispatch/Slot.luau`/`Handlers/Slot.luau`가
이제 "Parent 조작(mount/unmount)"뿐 아니라 "Parent 안 건드리는 재배치
(reposition, `Move`/`Swap`)"까지 계약해야 함. quad-roblox가 이걸
`SetSiblingIndex`로 구현할지, `LayoutOrder` 기반 정렬이라 사실상 no-op
으로 둘지는 구현 선택으로 열어둠.
- **item 값 전파(무조건, 백로그)와 index 전파(실제 변경시만)가 비대칭인
이유도 명확해짐** — item 값은 외부 뮤테이션+`Emit()` 경로 때문에 "이전
값"을 비교할 방법이 없지만, `:List`가 전적으로 소유하는 `keyIndex`
"실제로 위치가 바뀌었는지"를 정확히 알 수 있어 index 쪽엔 같은 문제가
없음 — 그래서 index 전파는 처음부터 조건부로 구현.
전부 `base/slot-plan.md`(CRUD 표, "원시 최소화 원칙 정정" 신규 절, `:List`
구현 스케치·설명 갱신)/`ROADMAP.md`(M6)/`README.md` 반영 완료. `question.md`
새로 열린 항목 없음 — 이번 후속도 순수 확정/구현 세부 명확화.
**같은 세션 두 번째 후속 — `Swap`을 element 레퍼런스가 아니라 인덱스
기준으로 정정, "공개 CRUD는 가드+`raw*` 위임" 구조 명문화.** 사용자가
`Swap(elementA, elementB)`를 바로 잡음 — element 레퍼런스로 받으면 Slot이
element→index 역방향 맵을 안 갖고 있는 이상 두 element의 현재 위치를 각각
찾는 데 O(n)씩(총 2n) 들어서, `Swap`이 약속한 O(1)이 그 자리에서 깨짐.
`Move`는 시프트 자체가 O(n)이라 조회 비용이 묻히지만 `Swap`은 조회 비용이
곧 전체 비용이라 이 차이가 그대로 드러남 — `Slot:Swap(indexA, indexB)`
정정(호출부가 이미 "몇 번째와 몇 번째를 바꿀지"를 아는 상황, 예: 드래그
리오더 UI, 이라는 것도 자연스러움의 근거). 이어서 사용자가 "`Slot:Move`
구현은 결국 락(`_listed`) 확인만 하고 실제 로직은 `rawMove`류에 다 있는
구조 아니냐"고 확인 요청 — 맞다고 답하며 이걸 여섯 CRUD 전체에 적용되는
일반 구조로 명문화: `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` 전부
"`self._listed` 확인 + `raw*` 위임"뿐인 얇은 wrapper, 실제 로직은 전부
`raw*` 함수 세트 하나에 있고 `:List`의 reconcile도 그 세트를 가드 없이
직접 호출. 전부 `base/slot-plan.md`/`ROADMAP.md` 반영 완료.
**다음 세션이 할 일**: 여전히 안 바뀜(`ROADMAP.md` M0부터).
**같은 세션 세 번째 후속 — Slot 요소 타입 제약 신설: `nil` 금지/`None`
허용/핸들러 계층 값(Ref/PreRef/Observer/Effect/Modifier) 금지, `Slot<T>()`
제네릭화.** 사용자가 "Slot 안에 뭐가 들어갈 수 있는지 정해진 바 없다"고
지적하며 시작 — 처음엔 제가 "Ref/Observer/PreRef도 Slot 요소로 허용,
`D.InstSlot = Slot<<Instance>>`류 백엔드 별칭으로 좁히자"고 제안했으나,
사용자가 바로 반박: Slot이 동적으로 다뤄지는데 그 안에 Ref/Observer가
들어가면 quad-roblox가 그걸 처리할 방법이 없고(특수 대응을 새로 만들어야
해서 오버엔지니어링),애초에 왜 필요한지도 불명확하다는 지적 — 검증해보니
정확히 맞았음:
- `Dispatch/Leaf.luau`가 처리하는 "children 배열에 Ref/Observer/PreRef가
직접 놓이는" 케이스는 **그 컴포넌트가 지금 만들고 있는 Instance 자기
자신을 가리키는 self-ref 캡처**라(`Frame { PreRef():Callback(fn) }`가
그 Frame 자신을 잡음), `inst`가 "지금 생성 중인 바로 그 하나의 Instance"로
고정돼 있어야 의미가 성립함. Slot은 특정 컴포넌트 호출 하나에 안 묶이고
이미 존재하는 부모에 나중에 독립적으로 붙는 동적 리스트라 이 전제
자체가 없음 — Slot 안의 Ref가 "무엇"을 가리켜야 하는지 정의가 안 됨.
- 대체 경로가 이미 있어 능력 손실도 없음 — 특정 child에 ref가 필요하면
그 child를 만드는 컴포넌트 호출 자체에 Ref를 넘기면 됨
(`slot:Add(Frame { Ref = myRef })`).
- 사용자가 직접 대비시킨 반례도 정확함: `State<Slot>`(Slot 자체가 State의
값)은 retract 시 통째로 버려지고 다시 채워지는 굵은 단위 교체라 이미
확정된 모델(폐기, 재구성)과 맞지만, Slot **요소 하나하나**로
Ref/Observer가 들어가는 건 그런 굵은 단위 교체가 아니라 세밀한 CRUD
대상이라 성격이 다름.
- **결론**: `Modifier` 필드가 핸들러 계층 값을 담으면 즉시 `error`
확정했던 것과 같은 판별 메커니즘(`isRef`/`isPreRef`/`isObserver`/
`isEffect`/`isModifier` Brand predicate)을 Slot에도 재사용 — 새
메커니즘 없이 그대로 막음. 덕분에 `Slot<T>``T`도 "실제로 마운트
가능한 최종 값의 타입"으로 단순해짐 — quad-roblox엔 사실상 `T =
Instance` 하나뿐이라 `D.InstSlot = Slot<<Instance>>`가 사실상 "그"
Slot 타입. `nil`은 기존 배열 파트 `None` 원칙을 그대로 적용해 금지,
`None``:List``renderFn`이 "이 item은 이번엔 스킵"을 표현하는
용도로 허용 — `renderFn`의 반환 타입도 `T | None`으로 갱신.
- `Slot<T>()`가 무인자 생성자라 `T` 추론이 안 되므로 tbox 명시적 제네릭
적용(`Slot<<Instance>>()`)이 필요하다는 것도 같이 반영 — 정확한 문법은
"자식으로 넘기는 클래스 스토어" 절의 기존 tbox 참고 미결과 같은 갈래로
묶어 열어둠.
전부 `base/slot-plan.md`(신규 "요소 타입 제약" 절, CRUD 에러 조건,
`renderFn` 반환 타입) 반영 완료.
**다음 세션이 할 일**: 여전히 안 바뀜(`ROADMAP.md` M0부터).
**같은 세션 네 번째 후속 — `Slot:List``renderFn`을 "1회 호출"에서
"매 사이클 호출 + `before` 재사용"으로 재설계, filter/toggle 문제 해결.**
사용자가 두 가지를 연달아 제기: (1) `renderFn``None`을 반환해 "지연
렌더"를 표현하는 아이디어는 좋지만, State 변경으로 이미 렌더된 필드를
나중에 다시 지워야 하는 경우(filter)는 기존 "1회만 호출" 모델로 안 풀림.
(2) filter/sort를 Slot에서 어떻게 구현할지가 문제 — 흔한 회피책인
"`Visible`만 토글"은 필터링된 item도 여전히 완전히 살아있는 Instance로
남겨서(애니메이션/이벤트 연결 계속 돎) 200개+ 리스트에서 lazy하지 않다는
실질적 비용이 됨.
**해법 — 사용자가 직접 제시**: `renderFn(itemState, before: inst?): inst?`
모양으로 바꿔 **매 reconcile 사이클마다 호출**하되, 이전에 마운트된
element(`before`, 없으면 `nil`)를 받아서 `if before then return before
end`(바꿀 거 없으면 그대로 반환, 값 갱신은 이미 물려있는 반응형 바인딩이
자동으로 함)로 저비용 재사용 경로를 만듦 — filter 탈락 시엔 `nil` 반환으로
**진짜 파괴**(Visible 토글 아님). 편의상 `renderFn`이 raw `nil`
던지는 게(Lua에서 자연스러운 관용구) `None`보다 편하다는 것도 사용자가
지적 — 검토 결과 `renderFn`의 반환값은 raw Slot 요소로 직접 들어가는
게 아니라 `:List`의 reconcile이 해석만 하는 것이라, `nil`을 받아도 위
"요소 타입 제약"(raw Slot 요소는 `nil` 금지)과 전혀 안 부딪힘 — `nil`/
`None` 둘 다 "스킵" 신호로 동일하게 받아들이기로 정리.
**부수적으로 드러난 것 — "이전 상태를 다음 렌더에 어떻게 넘기냐" 문제는
이미 해소돼 있었음.** 사용자가 "item이 보통 plain table이라 매 렌더마다
Source/Store를 새로 안 만들려면 이전 상태를 어딘가 저장해야 하는데 그게
어렵다"고 우려했으나, 확인해보니 `itemSources[key]`/`indexSources[key]`가
`renderFn` 호출 여부와 무관하게 **처음부터 `:List` 자신이 계속 소유**하고
있어서(원래 설계 그대로) — `renderFn`이 매 사이클 불려도 이 부분은 전혀
안 바뀜, item이 filter 탈락 후 재등장해 Instance가 파괴됐다 새로 만들어져도
반응형 Source는 안 끊기고 그대로 이어짐. 이 부분은 재설계가 아니라 기존
설계가 이미 답이었다는 걸 확인한 것.
**sort는 이번 재설계와 무관** — 호출부가 `data` 순서를 바꾸면 기존
`keyIndex`/`Move` 메커니즘이 이미 처리, 새로 손댈 것 없음(사용자가 filter와
같이 물었던 것 중 이건 원래도 문제 없었음).
전부 `base/slot-plan.md`(요소 타입 제약 절 "None 허용" → "nil/None 둘 다
금지"로 정정, `:List``renderFn` 시그니처·구현 스케치·"왜 매 사이클
호출로 바뀌었는가" 신규 절)/`ROADMAP.md`(M6)/`README.md` 반영 완료.
**다음 세션이 할 일**: 여전히 안 바뀜(`ROADMAP.md` M0부터).
**같은 세션 다섯 번째 후속 — `renderFn``updateFn` 개명, `:List`
`Source` 생성을 그만두고 `userdata`로 그 권한을 통째로 넘김.** 사용자가
"`renderFn`이 아니라 `updateFn`이 맞고, `itemState``:List`가 강제로
만들지 말고 원문 item + `userdata: UD?` + `prev: T?`를 주는 게 낫다"고
제안 — 검토 후 채택, 근거:
- **`itemState`/`indexState`를 `:List`가 강제로 만드는 건 불필요한 강요였음**
— 반응형이 필요 없는 단순한 행까지 전부 `Source` 생성 비용을 지게
했음. `userdata`로 권한을 넘기면 필요한 item만 자기 `Source`를 만들어
`userdata`에 담고, 나머지는 매번 raw `item`에서 다시 계산해도 됨 —
`:List`가 미리 정할 이유가 없는 선택.
- **"이전 상태를 다음 호출에 넘기는" 문제, 원래 걱정했던 것과 달리
`userdata`라는 명시적 채널로 완전히 해소됨** — item이 plain table이라
매번 `Source`를 새로 안 만들려면 어딘가 저장해야 한다는 우려가 있었는데,
`userdata`가 정확히 그 저장소.
- **`prev`(구 `before`)와 `userdata`가 원래 비일관적이었음** — 사용자가
직접 지적: 하나(`prev`)는 `:List`가 자동 관리하는데 다른
하나(`userdata`)만 수동 반환을 요구했음. 해법은 **둘 사이 커플링을
완전히 제거** — `result``nil`이라고 `:List``userdata`를 자동으로
안 지움, 그대로 기록만 함. 흔한 경우(둘 다 리셋)는 `return nil` 하나로
Lua가 나머지 반환 슬롯을 알아서 `nil`로 채워주고, "파괴하되 캐시는
남기고 싶다"는 정당한 패턴은 `return nil, ud`로 명시적으로 표현
가능해짐 — 이전 설계(result nil이면 userdata 자동 삭제)로는 이 패턴이
원천 봉쇄돼 있었음.
- **제가 놓칠 뻔한 버그를 사용자와의 논의 과정에서 직접 잡음**: `userdata`
이제 `mounted`(실제 element)보다 오래 살 수 있게 되므로, 정리 루프가
`pairs(mounted)`만 순회하면 "필터 탈락 상태(mounted=nil)로 `userdata`
살아있던 key가 `data`에서 완전히 사라지는" 케이스를 못 잡고 새서
— 직전 사이클의 전체 key 집합(`keyIndex`, 매 사이클 모든 key에 대해
채워짐)을 순회하도록 정정.
- **부수 효과 — "item 값 무조건 재전파" 백로그가 사라짐**: `:List`
더 이상 `Source`를 안 만드므로 그 문제 자체가 `:List` 소관이 아니게
됨, `updateFn` 작성자의 선택으로 넘어감.
- `userdata = userdata or {}`류 lazy-init 관용구가 `UD`가 자유 제네릭인
채로 Luau 타입 시스템에서 잘 좁혀지는지는 실측 필요 항목으로 명시적으로
남김(사용자가 직접 이 불확실성을 짚음) — M0/M6 착수 시 확인.
전부 `base/slot-plan.md`(`:List` 절 전면 재작성 — `updateFn` 시그니처/구현/
"왜 `Source``:List`가 안 만드는가" 신규 절)/`ROADMAP.md`(M6)/
`README.md` 반영 완료.
**다음 세션이 할 일**: 여전히 안 바뀜(`ROADMAP.md` M0부터).
**같은 세션 여섯 번째 후속 — `keyFn` 선택 인자화(파라미터 순서 정정),
`userdata` cleanup 훅 검토 후 기각·GC-native 제약 명문화, 문서화 순서
질문은 백로그로 이관.** 사용자가 세 가지를 짧게 제기:
1. **`Slot:List(data, keyFn, updateFn)` → `Slot:List(data, updateFn,
keyFn?)`로 파라미터 순서 정정, `keyFn` 선택 인자화.** 실사용 대부분
(사용자 추정 80%)이 item identity 추적 없이 순번을 key로 써도 충분한
단순 목록이라 매번 `keyFn`을 명시하게 하는 게 불필요한 보일러플레이트 —
생략 시 `function(item, index) return index end` 기본값. tradeoff(중간
삽입/삭제 시 그 뒤 항목들이 "다른 item인데 같은 key"로 오인돼 캐스케이드
갱신 — identity 보존 없음, 파괴/재생성 자체는 없음)는 React `key` 생략
시 index 기본값 등 업계 흔한 관행과 같은 결이라 새로 설명할 개념 아님.
2. **`updateFn(item?, ...)`로 바꿔 최종 제거 시 "정리용 1회 추가 호출"을
주는 안 — 검토 후 기각, 사용자가 직접 반례를 찾음.** 이 훅은 `data`에서
key가 빠져 `reconcile`이 다시 도는 정상 경로에서만 발화하는데, **Slot을
담은 부모 Instance 자체가 `Destroy`되는(가장 흔한) 경로는
`reconcile`이 다시 안 돌아서 이 훅이 전혀 안 불림** — 절반만 동작하는
정리 메커니즘은 없는 것보다 위험(사용자가 "정리가 보장된다"고 오해하고
`Subscribe`류를 `userdata`에 넣었다가 Destroy 경로에서 조용히 샘).
`retract`가 Destroy 시 절대 안 불린다는 기존 원칙(`lifecycle-pattern.md`
"quad는 라이프사이클 중간에 있지 않다")과 정확히 같은 이유로 기각.
**대신 `userdata`엔 GC-native 값만 담고, `:Subscribe()`한 Observer류처럼
명시적 cleanup이 필요한 값을 담는 건 UB로 명문화** — quad 전역
GC-native 원칙을 `:List`라는 구체 지점에 그대로 적용한 것뿐, 새 원칙
아님.
3. **문서화 순서(getting-started에서 단순 버전만 가르치고 나중에
`prev`/`userdata` 최적화를 알려줄지, 아니면 Slot이 학습 순서상 후반부라
처음부터 완전한 형태로 가르칠지)는 결정 안 함** — `research/
documentation-content-map.md`의 modifier/slot 절에 백로그로 추가,
제 의견(후자 쪽으로 기욺)만 메모, 실제 콘텐츠 작성 시점 결정 사항이라
지금 확정 안 함.
전부 `base/slot-plan.md`(`:List` 시그니처/코드 재정렬, `keyFn` 기본값
설명, "`userdata`의 생명주기 제약" 신규 절)/`ROADMAP.md`(M6)/`README.md`/
`research/documentation-content-map.md` 반영 완료.
**다음 세션이 할 일**: 여전히 안 바뀜(`ROADMAP.md` M0부터).

View file

@ -170,14 +170,44 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
- [ ] "여러 Slot이 형제로 섞일 때 순서 보장" 열린 질문 확인(`slot-plan.md`) —
Roblox 단일 백엔드로는 급하지 않으면 스킵하고 진행 가능
- [ ] **Slot의 `add`/`remove`/`clear` CRUD 의미론 확정 — 착수 전 필수.**
`research/pre-implementation-audit.md` 우선순위1이 지적한 갭, 아직
미해결(2026-08-07 아홉 번째 세션에서 "사용자가 다음 세션에서 직접
다루기로 보류"로 확인, 2026-08-09 세션 말미에도 다음 세션 예고로
다시 지목됨). "재마운트 시 즉시 throw"가 개별 element 기준인지 Slot
컨테이너 전체 기준인지도 이 논의에서 같이 확정할 것.
- [ ] base `Dispatch/Slot.luau`(추상 재조정) + quad-roblox `Handlers/Slot.luau`
(실제 Parent 조작)
- [x] **Slot의 `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD 의미론
확정** (2026-08-09 세 번째 세션) — `get`/`set` 드롭, 에러 조건까지
전부 확정(`base/slot-plan.md` "CRUD API 확정"). "재마운트 시 즉시
throw"도 `isMounted` 이중 추적 분리로 개별 element/Slot 컨테이너
기준이 명확히 갈림(같은 문서 "`isMounted` 이중 추적 분리" 절).
`Move(element, newIndex)`(O(n))/`Swap(indexA, indexB)`(O(1), element
아닌 인덱스 — element면 위치 조회에 2n 들어 O(1) 약속이 깨짐)은
리오더 전용, Parent를 안 건드림. 공개 6개 메소드 전부 "가드 확인 +
`raw*` 위임" 얇은 wrapper. base/roblox 경계에 mount/unmount 외
reposition 훅 추가됨. **`Slot<T>()` 제네릭화, 요소 타입 제약 확정**
`nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔 실제 마운트 가능한
`T`만), 핸들러 계층 값(Ref/PreRef/Observer/Effect/Modifier)은
self-ref 컨텍스트가 없어 의미 불성립이라 즉시 error(`Modifier`
필드와 같은 판별 메커니즘 재사용) — `D.InstSlot = Slot<<Instance>>`
quad-roblox의 사실상 유일한 Slot 타입.
- [ ] `Slot:List(data, updateFn, keyFn?)` — 키 기반 동적 컬렉션 재조정,
`keyFn` 생략 시 index를 그대로 key로 사용(중간 삽입/삭제 시 identity
보존 안 됨, 캐스케이드 갱신 — 흔한 업계 관행과 같은 트레이드오프).
`updateFn<UD=any>(item, index, userdata: UD?, prev: T?): (T|nil, UD?)`
**매 reconcile 사이클마다 호출**(filter/toggle 지원 — 첫 반환값
`nil` 시 실제 파괴, `Visible` 토글 아님, 200+ 항목에서 lazy하지 않은
문제 회피), `prev` 그대로 반환하면 저비용 재사용 경로. `:List`
`Source`를 대신 안 만듦 — item/index를 반응형으로 감쌀지는
`updateFn``userdata`에 직접 관리(반환값 두 개는 서로 독립,
`result``nil`이어도 `userdata`는 명시적으로 반환 안 하는 한 안
지워짐). 정리 루프는 `mounted`가 아니라 직전 사이클 `keyIndex`
전체를 순회해야 함(`userdata`만 살아있는 채로 key가 완전히 사라지는
케이스 커버). `userdata = userdata or {}` lazy-init 패턴이 Luau
제네릭에서 잘 좁혀지는지 실측 필요. **`userdata`는 GC-native 값만
허용, `:Subscribe()`한 Observer류 명시적 cleanup 필요한 값은 UB** —
`item`을 nilable로 바꿔 최종 제거 시 정리 훅을 한 번 더 부르는 안은
기각(Slot 부모 자체가 Destroy되는 경로에선 이 훅이 전혀 안 불려서
절반만 동작, `retract`가 Destroy 시 안 불리는 것과 같은 이유).
(2026-08-09 세 번째 세션 확정,
`base/slot-plan.md` "`Slot:List(...)`" 절) 구현
- [ ] base `Dispatch/Slot.luau`(추상 재조정, mount/unmount/reposition 3훅) +
quad-roblox `Handlers/Slot.luau`(실제 Parent 조작 + reposition —
`SetSiblingIndex` 또는 `LayoutOrder` 기반이면 no-op, 구현 선택)
## M7 — Modifier