diff --git a/.claude/README.md b/.claude/README.md index f676980..4d81a04 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -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()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`, `keyFn` 생략 시 index 기본값, `updateFn(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 코어 구현 시점까지 미결 | diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index f0a969a..0ffa9a5 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -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()`(빈 인스턴스, 인자 없는 바닥 생성자 — 다른 +독립 프리미티브의 `Type(args)` 관습과 동일하되, 무인자라 `T`를 추론할 +수 없어 tbox 명시적 제네릭 적용 `Slot<>()`로 지정)로 만들고, +`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<>`가 사실상 "그" Slot 타입. `Slot()`가 + 기본값(`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(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 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 코어 로직 구현 착수 전 반드시 확정 필요. diff --git a/.claude/question.md b/.claude/question.md index 6c09e18..dba4757 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -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)`)의 패키지 배치 — 원래 제안대로 diff --git a/.claude/research/additional-primitives-plan.md b/.claude/research/additional-primitives-plan.md index 090e821..939586c 100644 --- a/.claude/research/additional-primitives-plan.md +++ b/.claude/research/additional-primitives-plan.md @@ -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 ``, 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`/ -`Source`든 둘 다 받게 한다. Plain이면 diff 로직 자체가 발동 안 하고 -(다시는 안 바뀌므로 최초 1회 배치만 하면 끝), State/Source면 위 메커니즘이 -동작한다. 이름은 아직 미정이지만 시그니처 형태: - -``` -<이름>(data: {[K]: V} | State<{[K]: V}>, keyFn: (V, K) -> Key, renderFn: (Key, State) -> 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`에 별도 보존. ## 빈 자리 아닌 것으로 확인된 것들 diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index 7d74850..aa6467c 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -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<>(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 diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index 476db27..bc5872b 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -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" 절. diff --git a/CLAUDE.md b/CLAUDE.md index 4b13402..daf9834 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`를 추가(`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()` +제네릭화.** 사용자가 "Slot 안에 뭐가 들어갈 수 있는지 정해진 바 없다"고 +지적하며 시작 — 처음엔 제가 "Ref/Observer/PreRef도 Slot 요소로 허용, +`D.InstSlot = Slot<>`류 백엔드 별칭으로 좁히자"고 제안했으나, +사용자가 바로 반박: 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 자체가 State의 + 값)은 retract 시 통째로 버려지고 다시 채워지는 굵은 단위 교체라 이미 + 확정된 모델(폐기, 재구성)과 맞지만, Slot **요소 하나하나**로 + Ref/Observer가 들어가는 건 그런 굵은 단위 교체가 아니라 세밀한 CRUD + 대상이라 성격이 다름. +- **결론**: `Modifier` 필드가 핸들러 계층 값을 담으면 즉시 `error`로 + 확정했던 것과 같은 판별 메커니즘(`isRef`/`isPreRef`/`isObserver`/ + `isEffect`/`isModifier` Brand predicate)을 Slot에도 재사용 — 새 + 메커니즘 없이 그대로 막음. 덕분에 `Slot`의 `T`도 "실제로 마운트 + 가능한 최종 값의 타입"으로 단순해짐 — quad-roblox엔 사실상 `T = + Instance` 하나뿐이라 `D.InstSlot = Slot<>`가 사실상 "그" + Slot 타입. `nil`은 기존 배열 파트 `None` 원칙을 그대로 적용해 금지, + `None`은 `:List`의 `renderFn`이 "이 item은 이번엔 스킵"을 표현하는 + 용도로 허용 — `renderFn`의 반환 타입도 `T | None`으로 갱신. +- `Slot()`가 무인자 생성자라 `T` 추론이 안 되므로 tbox 명시적 제네릭 + 적용(`Slot<>()`)이 필요하다는 것도 같이 반영 — 정확한 문법은 + "자식으로 넘기는 클래스 스토어" 절의 기존 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부터). diff --git a/ROADMAP.md b/ROADMAP.md index 0eb15a5..cc00409 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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()` 제네릭화, 요소 타입 제약 확정** + — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔 실제 마운트 가능한 + `T`만), 핸들러 계층 값(Ref/PreRef/Observer/Effect/Modifier)은 + self-ref 컨텍스트가 없어 의미 불성립이라 즉시 error(`Modifier` + 필드와 같은 판별 메커니즘 재사용) — `D.InstSlot = Slot<>`가 + quad-roblox의 사실상 유일한 Slot 타입. +- [ ] `Slot:List(data, updateFn, keyFn?)` — 키 기반 동적 컬렉션 재조정, + `keyFn` 생략 시 index를 그대로 key로 사용(중간 삽입/삭제 시 identity + 보존 안 됨, 캐스케이드 갱신 — 흔한 업계 관행과 같은 트레이드오프). + `updateFn(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