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
45 KiB
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 배열에 들어갈 일이 없음) — 그래서 rawAdd가 굳이None을 허용해야 할 이유가 없어짐.element == nil뿐 아니라element == None도Add(및 내부raw*)에서 즉시error— "Slot 안엔 실제로 마운트 가능한 값만 들어간다"는 단일 규칙으로 단순화.- 핸들러 계층 값(Ref/PreRef/Observer/Effect/Modifier) 금지, 즉시
error—Modifier필드가 이 값들을 담으면 즉시error로 확정했던 것(modifier-plan.md7번)과 같은 판별 메커니즘(isRef/isPreRef/isObserver/isEffect/isModifierBrand 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 객체에 대해 실제로 호출된 순간(핸들러 매치 시점) — InstanceParent대입 완료를 기다리지 않음. 다른 모든 "마운트됨" 판정(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)/
GetAPI는 지금 안 만듦 —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을
안 만들기로 했었는데 — 실제로는 두 가지 공백이 드러나 뒤집음:
Extract+Add는 리오더치고 너무 무겁다.Extract의 계약이 "제거, 파괴 안 함, 소유권 회수"라 백엔드가 곧이곧대로 구현하면 실제 Parent 조작이 두 번(detach+reattach) 일어남 — Roblox에서AncestryChanged발화, 잠재적 깜빡임, 불필요한 재바인딩 비용까지 딸려올 수 있음. 순서만 바뀌는, 매:List재계산마다 흔히 일어나는 케이스치고 과함.: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이 해석만 하므로 "요소 타입 제약"의 rawnil/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).
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/ForValues3분할도 이 재구성으로 통합 방향이 자연스러워짐(단일: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/clearCRUD 의미론,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에 백로그로만 반영.