quad/.claude/base/slot-plan.md
qwreey 5836c2d12a
decide(base): Slot:List의 data:Observer 구독을 마운트 시점 lazy bindLifetime으로 통일
Dispatch.setLength는 이미 마운트 시점에 bindLifetime을 걸었지만, Slot:List의
data:Observer(fn) 구독은 :List() 호출 시점(inst를 모르는 시점)에 즉시
생성되어 Destroy 후에도 재실행/관측을 멈출 방법이 없었음. :List()는 이제
설정만 저장하고, 실제 구독+최초 reconcile은 Slot 마운트 시점에 activateList로
수행 — 마운트 이후 :List() 호출 시 self._mounted로 즉시 활성화.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYfAz3BsaTrmMM8hnzn6mj
2026-08-09 21:42:24 +09:00

655 lines
45 KiB
Markdown

# Slot — 뮤터블 자식 배열, 엄격한 단일 마운트 소유권 (base로 승격됨)
**상태**: base — 설계 방향(소유권 귀속, 재마운트 시 throw, retract=폐기)과
소스 트리 상 패키지 경계까지 확정되어 `research/`에서 승격됨(`base/
architecture.md`의 "구현 착수: 소스 트리 구조 확정" 절 참고). 원본:
`.claude/initreq/raw-userinput.md` "slot을 구현하도록 하기로 했음" 절. Fusion의
`Children` SpecialKey와 Vide의 mount 무가드 비교는 `reference/comparison-fusion-vide.md`
참고 — 결론: **두 라이브러리 어디에도 이런 엄격한 단일 마운트 가드가 없음,
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차 라운드 확정)
Slot의 add/remove/clear 재조정 로직(추상 자식 참조 기준 — "이 자리에 뭐가
있어야 하는가"를 결정하는 순수 로직)은 `quad-base/src/Dispatch/Slot.luau`
소유. 실제 트리 조작(Instance `Parent` 설정/`Destroy`)은 `quad-roblox/src/
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으로 둘지는 구현 선택.
**[2026-08-09 일곱 번째 세션 보강]** `Dispatch/Slot.luau`의 mount 훅
(`process(inst,k,self)`)은 `Dispatch.setLength(inst,i,self.Length)` 호출과
같은 자리에서 `self._listed``activateList(self,inst)`도 트리거해야 함 —
`:List``data:Observer(fn)` 구독을 Slot 마운트 시점까지 lazy하게 미루는
것도 이 mount 훅의 책임(아래 "`Slot:List(...)`"의 "구독 시점" 절 참고).
**추가로 필요해진 핸들러**: Slot과는 별개로, `k`가 number이고 `v`가 이미
만들어진 Instance인 경우(중첩 인스턴스를 자식으로 직접 넣는 경우, 예:
`Frame { Frame {} }`)를 위한 핸들러도 필요 — `quad-roblox/src/Handlers/
InstanceChild.luau`. Slot은 "뮤터블 배열"을 다루고 이 핸들러는 "정적으로
하나 박아넣는" 더 단순한 경우라 별개로 둠.
## 개념
뮤터블 자식 배열. `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 제네릭 적용 문법 확정 시 같이 정할 것
(이 문서 "자식으로 넘기는 클래스 스토어" 절의 기존 미결과 같은 갈래).
## 핵심 제약: 소유권 귀속과 단일 마운트
Slot에 들어간 요소는 **ownership이 귀속**되며 다른 곳에 마운트할 수 없게 된다.
`isMounted`를 관리해서, **한 인스턴스에 대한 다중 마운팅이 라이브러리 차원에서
절대 일어나지 않도록 강제**하는 게 v1 대비 핵심 디자인 변화. v1의 `mount()`
별다른 강제를 안 했지만(`reference/quad-v1-architecture.md`의 mount.lua 분석 참고 —
실제로는 부모/자식 부기까지 했지만 다중 마운트 방지는 없었음), v2는 mount
함수 자체가 이 강제를 담당.
Fusion의 `Children` SpecialKey는 이걸 "특정 SpecialKey 하나의 내부 부기"로만
구현했고(재사용 가능한 1급 프리미티브가 아님), Vide는 아예 이 개념이 없어서
같은 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`가 애초에 안
불려서 이 정의로도 오탐 없음. **[2026-08-09 일곱 번째 세션 보강]**
같은 자리에서 `self._mountedInst = inst`도 같이 저장 — `:List()`
마운트 이후에 호출되는 경우 이 값으로 즉시 활성화(아래 "`Slot:List(...)`"의
"구독 시점" 절 참고).
- **개별 element**: Slot 안에 담기는 각 element(Instance/컴포넌트 결과 등)
마다 전역 weak-set 멤버십으로 추적 — 특정 Slot 인스턴스에 안 묶임
("한 인스턴스가 어디에도 중복 마운트 안 됨"이 라이브러리 전역 불변식이라서).
`Add`가 이 weak-set을 확인(이미 참이면 error)/설정, `Remove`/`Extract`
둘 다 여기서 제거(둘의 차이는 파괴 여부일 뿐, "마운트 해제"라는 점은 같음).
## 여럿 존재 가능, 부모가 실제 데이터 테이블만 다루면 됨
Slot은 하나의 instance 안에 여럿 존재할 수 있다. 전부 하나의 children으로
들어가지만, 실제 렌더된 instance에서 `GetChildren()`을 직접 하지 않고도 부모가
생성한 "실제 slot 데이터 테이블"만 다루면 되게 해서 **추상화 수준을 낮은 직접
바인딩에서 한 단계 떼어냄**(간접화를 통한 추상화).
## 마운트된 Slot의 재마운트는 즉시 throw (확정)
**사용자 확인 완료**: 이미 사용된(마운트된) slot을 재마운트하려 하면 **즉시
`error()`로 중단** — warn+no-op 아님. 개발 중 바로 잡아낼 수 있게 강하게
실패하는 쪽 선택. 마운트되는 순간 slot의 실제 대상은 고정된다 — 따라서
**글로벌 스코프에서 slot을 쓰는 건 그다지 좋지 않을 수 있음**(재사용/재마운트가
막히므로).
## 클래스가 슬롯을 받는 방법
"네이밍된 슬롯"이 필요한가에 대한 사용자 자문: 그냥 슬롯 바인드 테이블을
값으로 넘기면 되는 것 아닌가 — 결국 array처럼 구현된 Store라고 생각하는 게
편하다는 방향. **기울어진 결론**: 별도 "Named Slot" 개념 없이, store나
파라미터로 넘기고 그게 그냥 ref처럼 바인드되는 모양.
**확정(2026-08-04, 로드맵 인수인계 라운드)**: 위 방향 그대로 확정 — 별도
"Named Slot" 개념 없음, 슬롯 바인드 테이블을 store나 파라미터로 그냥 넘기면
ref처럼 바인드됨 — **사용자 확정**("A. 맞음. 리프노드에선 그렇게 마운트됨").
단, 이 확정은 "리프 레벨에서 슬롯 하나가 마운트되는 방식"에 한정 — 여러
Slot이 형제로 섞이는 경우의 순서 보장 문제는 별도로 열려있음, 바로 아래
참고.
### 여러 Slot이 섞일 때 순서 보장 — 해소됨 (2026-08-09 여섯 번째 세션)
`Frame { Slot1, 일반자식, Slot2 }`처럼 Slot과 Slot 사이에 다른 요소가 끼거나
Slot이 여럿 형제로 존재할 때, 최종 자식 순서가 저작 순서(위쪽 Slot의 요소가
항상 아래쪽 Slot의 요소보다 앞)를 안정적으로 지키는지가 2026-08-04부터 열려
있었던 질문 — **메커니즘 확정으로 해소됨**: `Dispatch.setLength`/
`Dispatch.setOffsetSource` + 형제별 개수 누적합(`offset`)을 리액티브
프로퍼티(Roblox `LayoutOrder`)에 바인딩하는 방식 — 상세는 `base/
bind-system-plan.md`의 "Length/Offset — 여러 Slot이 형제로 섞일 때 순서
보장" 절 참고. **DOM류 물리 순서 백엔드에도 같은 base 메커니즘이 그대로
재사용됨**(offset이 바뀌어도 이미 마운트된 원소를 물리적으로 옮길 필요
없음 — `insertBefore`가 뒤 형제를 자연히 밀어주므로, backend Handler의
"offset 변경 시 할 일"만 no-op으로 달라짐) — `architecture.md`의 "다른
렌더 백엔드에서도 재사용 가능해야 한다"는 전제와도 부딪히지 않음.
## Slot과 Store 바인드의 관계 (`retract` 순서)
Slot이 store 바인드로 들어오는 경우, pluggable 처리기에 `retract`(구 cleanup,
`base/lifecycle-pattern.md` 참고) 핸들러가 필요함 — 한번 넘어간 slot 요소가
나중에 `retract`되면 삭제되는지, 아니면 "부모의 소유이니 부모가 처리"해야
하는지 검토 필요. **기울어진 결론(잠정안, 이후 정정됨)**: 부모가 정리 정도만
미리 수행하고 다시 `process`하면 되므로, 부모에게 위임(자식 slot 요소 자체가
스스로 정리를 실행하는 게 아니라).
> **정정(2026-08-04 검증 라운드)**: 위 "부모 위임" 잠정안은 이후 **폐기**
> 쪽으로 정정됨 — 아래 "확정" 절과 `.claude/question.md`("Slot의 `retract`
> 동작이 '부모 위임' 잠정안에서 '폐기(옮기지 않음)'로 확정") 참고. 이 문단은
> 검토 과정의 히스토리로만 남겨둠, 현재 유효한 동작 아님.
이건 `base/bind-system-plan.md`의 "Store 바인드는 재실행 래핑" 확정
모델과 맞물림 — slot이 store 값으로 오면, store 바인드 핸들러가 이전 slot
상태를 `retract`하고 새 slot 상태로 다시 `process`하는 사이클을 돈다는 뜻.
Slot 핸들러 자신이 감시 중인 값(배열/스토어)이 바뀔 때 child를 갱신하는
추적(구독)도 `base/bind-system-plan.md`가 말하는 "process 함수가 다른 값
변경을 추적해도 됨" 범위에 속하고, `retract` 시점엔 그 추적만 풀면 됨 —
Destroy 시점엔 `retract`가 호출되지 않는다는 원칙(`base/lifecycle-pattern.md`)도
동일하게 적용.
**확정(2026-08-04 검증 라운드): retract되는 slot은 옮겨지지 않고 그냥 폐기된다.**
Slot은 바인딩되는 순간 그 안의 요소를 전부 own해버리는 데이터형 — 새 slot
상태로 교체될 때 이전 slot의 내용을 다른 곳으로 옮기는 경로는 없음, 그냥
버림. React의 portal(`<></>`)류로 나중에 옮길 수 있게 하는 것도 검토됐으나
**이번 마일스톤에서는 오버엔지니어링으로 판단, 하지 않음** — 필요성이 명확해지면
그때 별도로 다시 논의.
> **범위 명확화(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`라는 구체적 지점에 그대로
적용한 것뿐 — 새 원칙 아님.
### 구현
**구독 시점은 `:List()` 호출이 아니라 Slot 마운트 시점 — lazy `bindLifetime`
(2026-08-09 일곱 번째 세션, 아래 "구독 시점" 절 참고).** `:List()`는 설정만
저장하고 반환, 실제 `data:Observer(fn)` 구독과 최초 `reconcile`은 Slot
자신이 마운트되는 순간(`Dispatch/Slot.luau`의 `process(inst,k,self)`)에
`activateList`가 수행 — `Dispatch.setLength`가 이미 쓰고 있는 것과 같은
패턴(마운트 시점까지 미뤘다가 그 자리에서 `bindLifetime`).
```lua
function Slot:List(data, updateFn, keyFn)
assert(not self._listed, "Slot already has :List installed")
self._listed = true
self._listData = data
self._updateFn = updateFn
self._keyFn = keyFn or function(_, index) return index end
if self._mounted then
activateList(self, self._mountedInst) -- 이미 마운트돼 있으면 즉시 활성화
end
return self
end
-- Dispatch/Slot.luau의 process(inst,k,self)가 마운트 시점에 1회 호출
-- (self._mounted=true/self._mountedInst=inst를 세팅하는 바로 그 자리)
function activateList(self, inst)
local keyFn, updateFn = self._keyFn, self._updateFn
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
local data = self._listData
if isState(data) then
local observer = data:Observer(function() reconcile(data:Get()) end)
-- Observer 등록 자체의 "등록 즉시 1회 실행"은 canExecute/Subscribed
-- 게이팅과 무관하게 여기서 이미 무조건 일어남(아래 "구독 시점" 절) —
-- bindLifetime은 그 다음에 걸어 *이후* 재실행만 inst 생명주기에 귀속
bindLifetime(inst, observer)
else
reconcile(data)
end
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`**: `activateList`(마운트 시점 1회
실행)의 로컬 변수(클로저 업밸류) — 별도 전역 weak table(`Relate` 등)
불필요, `inst`/`self`가 살아있는 동안만 존재하면 되고 죽으면 클로저도
같이 GC됨(아래 "구독 시점" 절).
- **`reconcile`이 직접 호출하는 건 `rawAdd`/`rawRemove`/`rawMove`뿐** —
`rawExtract`/`rawSwap`/`rawClear`도 (위 "모든 공개 CRUD는 가드+위임"
구조상) 당연히 존재하지만, `:List`의 reconcile 알고리즘 자체가 그
셋을 쓸 일이 없을 뿐(제거는 항상 파괴 확정이라 `Extract` 아닌 `Remove`
경로, 리오더는 항상 절대 위치 이동이라 `Swap` 아닌 `Move` 경로,
`Clear`는 reconcile 단위가 아니라 Slot 전체 단위 연산이라 무관).
- **리오더는 `Move`(의 가드 없는 버전)** — Parent를 안 건드리는 진짜
저비용 경로. 최소-이동 알고리즘(LIS 기반 등) 자체는 구현 시점 최적화로
미룸, 여기선 계약(파괴 없이 위치만 바뀜)만 확정.
### 구독 시점 — `:List()` 호출이 아니라 Slot 마운트 시점, lazy `bindLifetime`
(2026-08-09 일곱 번째 세션)
**문제**: 원래 초안은 `data:Observer(fn)``:List()` 호출 그 자리에서 만들었음
— 근데 `:List()``Slot():List(data, updateFn)`처럼 Slot이 아직 어디에도
마운트되기 전에 불리는 게 흔한 사용법이라, 그 시점엔 `inst`를 몰라서
`bindLifetime`을 걸 수 없었음(사용자가 직접 지적) — 마운트 대상이 나중에
`Destroy`돼도 이 구독을 멈출 방법이 없는 gap이었음.
**해법 — `Dispatch.setLength`가 이미 쓰고 있는 패턴 그대로 재사용**: 새
메커니즘 발명 아님. `:List()``data`/`updateFn`/`keyFn`만 저장하고 반환,
실제 `data:Observer(fn)` 구독 + 최초 `reconcile`은 Slot 컨테이너 자신이
마운트되는 순간(`Dispatch/Slot.luau`의 `process(inst,k,self)` — 위
"`isMounted` 이중 추적 분리" 절이 이미 `self._mounted`를 세팅하는 바로 그
지점)에 `activateList(self, inst)`가 수행. `Dispatch.setLength(inst,i,
self.Length)`를 부르는 것과 같은 자리에서 같이 트리거되면 됨.
**`:List()`가 마운트 이후에 불리는 경우 — `self._mounted`면 즉시 활성화
(확정)**: 마운트는 1회성 이벤트라, `:List()`가 마운트보다 늦게 호출되면
그 이벤트를 기다리는 방식으론 영영 활성화가 안 됨 — `:List()`
`self._mounted`를 확인해서 이미 참이면 그 자리에서 바로
`activateList(self, self._mountedInst)`를 호출(마운트 시점에 `inst`
`self._mountedInst`로 같이 저장해둠). CRUD와의 상호배타 가드(`self._listed`)와
같은 자리에서 자연스럽게 처리됨 — 호출 순서에 대한 새 제약을 추가하지 않음.
**canExecute와 "등록 즉시 1회 실행"의 관계 — 초기 실행은 게이팅과 무관하게
무조건 일어남(사용자 확인)**: `data:Observer(fn)`가 등록되는 순간
(`bindLifetime` 호출 *이전*) `fn`이 이미 한 번 동기 실행됨(Observer 자체의
"등록 즉시 1회 실행" 계약) — 이 시점엔 아직 `bindLifetime``Subscribed`
세팅 전이라 `canExecute`를 물으면 거짓이겠지만, 애초에 최초 실행은
`canExecute`로 게이팅되는 대상이 아니라서 상관없음. `bindLifetime`은 그
직후에 걸려서 **이후의** 재실행(`data`가 다시 바뀔 때)만 게이팅 —
`Dispatch.setLength``bindLifetime(inst,observer)` 다음 줄에 있는
"등록 즉시 1회와 겹쳐도 무해"라는 주석과 정확히 같은 구조.
**Destroy 이후 — "재실행 막기"와 "관측 자체를 관두기"가 새 메커니즘 없이
한 번에 해결됨**: `inst`가 Destroy되면 `bindLifetime``gcconn`(Roblox가
Destroy 시 자동으로 끊는 Connection)이 죽어 `canExecute`가 거짓이 되고
future 재실행이 no-op됨(위 "`state:Observer(fn)`" 절 원칙 재사용) — 그리고
"이전 state를 계속 관측하는 것도 관둬야 한다"는 요구도, `gchold`
`Relate(inst)`(weak-keyed) 아래 있어서 `inst`가 죽으면 그 안에 강참조로
붙잡혀 있던 Observer/클로저(`mounted`/`userdata`/`keyIndex`를 포함해)가
전부 같이 GC 대상이 되는 것으로 공짜로 해결 — 명시적으로 구독을 끊는
새 코드가 필요 없음, `base/lifecycle-pattern.md`의 "정리는 기본적으로
GC에 위임" 원칙 그대로.
**부수 관찰(설계 아님, 메모만)**: `bindLifetime``Relate(inst)` 기반이라,
"이 `inst`에 지금 어떤 Slot/Observer가 붙어있는가"를 나중에 weak하게
역조회하는 것도 같은 저장소로 가능해 보임(quad-debug의 "무엇이 무엇에
연결됐는가" 그래프와 맞닿을 수 있음) — 지금 설계할 필요는 없음, 필요성이
확인되면 그때.
### 왜 자유 함수/새 타입이 아닌가
처음엔 `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`로 확정**.
## 자식으로 넘기는 클래스 스토어
자식에게 내려주는 클래스 스토어는 부모 쪽에서 미리 만들어서 내려보내는 게
편할 것 같다는 방향 — `store<<ChildClass.Props>>` 형태로 구성된 스토어를 만들면
됨(타입 표기는 러프한 스케치, 실제 문법은 tbox의 명시적 제네릭 적용 패턴
`f<<T>>(...)``.claude/initreq/tbox/CLAUDE.md:40-41` — 참고해서 확정할 것).
## 열린 질문 (`.claude/question.md`에도 취합)
- 재마운트 에러 처리(throw), retract 시 폐기(옮기지 않음) 둘 다 확정. 남은 건
실제 구현 단계에서 이 "폐기" 동작이 실사용에서 불편하지 않은지 재검증하는
정도 — 설계 방향 자체는 더 이상 열려있지 않음.
- "클래스가 슬롯을 받는 방법"(Named Slot 없음)도 확정됨(위 "클래스가 슬롯을
받는 방법" 절 참고).
- **[해소됨, 2026-08-09 세 번째 세션]** `add`/`remove`/`clear` CRUD 의미론,
`isMounted` 이중 추적 분리, 키 기반 동적 컬렉션 재조정(`Slot:List`) —
위 "CRUD API 확정"/"`isMounted` 이중 추적 분리"/"`Slot:List`" 절 참고.
- **[해소됨, 2026-08-09 여섯 번째 세션]** 여러 Slot이 형제로 섞일 때
순서 보장 — 위 "여러 Slot이 섞일 때 순서 보장" 절 참고, 메커니즘은
`base/bind-system-plan.md`의 "Length/Offset" 절이 최신 소스.
## Slot.Length — `:List`뿐 아니라 항상 노출됨 (2026-08-09 여섯 번째 세션)
Slot은 CRUD/`:List` 여부와 무관하게 `.Length: State<number>`를 항상
노출 — 지금 실제로 마운트된 요소 개수(사용자가 직접 CRUD로 넣든 `:List`
reconcile이 넣든 동일). 두 용도를 겸함: (1) 사용자가 "n개 검색됨" 같은
UI에 직접 관측, (2) `Dispatch.setLength(inst, i, slot.Length)`가 형제
순서 보장(위 "여러 Slot이 섞일 때 순서 보장" 참고)에 내부적으로 읽는 바로
그 값 — 별도 두 State가 아니라 하나. `:List`의 filter 탈락이 실제
`Remove`(Visible 토글 아님)로 확정돼 있어서 `Length`는 자동으로 "실제
마운트된 것"만 반영 — 수동 Visible 토글을 쓰면 `Length`가 그걸 못 잡는
게 맞고, 그건 사용자가 별도 State로 계산해야 하는 몫.
## 백로그 — `Slot():Single(state, updateFn?)` (2026-08-09 여섯 번째 세션, 미착수)
`:List`의 key-map(`mounted`/`userdata`/`keyIndex`) 없이 "0개 아니면 1개"만
다루는 더 가벼운 편의 메소드 제안(예: `state<Frame?>`를 조건부로 마운트하는
관용구를 더 명시적으로 표현) — `.Length`는 그냥 0/1이고 나머지(offset 소비,
LayoutOrder 바인딩)는 일반 Slot과 완전히 같은 프로토콜. 아직 상세 설계
안 함, `.claude/question.md`에 백로그로만 반영.