diff --git a/.claude/README.md b/.claude/README.md new file mode 100644 index 0000000..ed33ee4 --- /dev/null +++ b/.claude/README.md @@ -0,0 +1,92 @@ +# .claude/ — quad-v2 계획/설계 문서 색인 + +이 레포 전체가 quad-v2(재작성) 프로젝트이므로, webmanager류 서브프로젝트 구분 없이 +`.claude/` 바로 아래에 전부 있음. **루트 `CLAUDE.md`가 현재 상태+TODO 색인의 +최종 소스** — 먼저 그걸 보고, 특정 결정의 자세한 근거/논의가 필요할 때만 아래 +개별 문서를 열어볼 것. + +## 폴더 기준 + +| 폴더 | 기준 | +|---|---| +| `base/` | 결정 완료 + 프로젝트 전체에 걸치는 컨텍스트 — plan/done 개념 없음, 계속 참조되는 배경지식. **항상 읽어야 하는** 배경지식만 여기 둠(다른 문서를 이해하는 데 전제되는 것) | +| `reference/` | **[2026-08-07 신설]** 결정 자체가 아니라 다른 문서가 근거로 인용하는 온디맨드 참고 자료(v1 스냅샷, 프레임워크 비교 리서치) — "완료" 개념 없는 건 `base/`와 같지만, 항상 읽을 필요는 없고 해당 문서가 인용될 때만 열어보면 됨. `quadnomicon` 소재 후보가 많음 | +| `research/` | 아직 착수 전, 사용자와 스코프/설계를 더 상의해야 함 | +| `qa-request/` | 구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음 — 지금은 구현 자체가 시작 전이라 비어있음 | +| `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]` — `[역전됨]`과 의미가 다르므로 혼동하지 말 것. **[2026-08-07 세 번째 확장]** 설계 반전/기각과 별개로, 에이전트가 문서 작성 중 스스로 낸 개념 혼동을 정정한 이력은 `[에이전트 실수]` 태그로 `agent-mistake.md` 하나에 모음(CLAUDE.md 세션 로그 중복 방지) | +| `feedback/` | 실사용 피드백을 정리한 긴 로그 — 지금은 비어있음(구현 시작 전) | +| `luau-test/` | **[2026-08-09 신설]** `base/` 확정 사항 중 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"(M0 스파이크 대상)을 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 사용자가 직접 돌려볼 독립 실행 스크립트 모음. 아직 결과 미확인 — `luau-test/README.md`가 색인 | +| `initreq/` | 프로젝트 착수 시 클론해둔 참고 레포(quad v1, fusion, vide, rbvm, tbox, code-docker) + PA님 실 코드(`artworks/`, 4차 라운드 교차검증 근거) + 원본 요청(`req.md`, `raw-userinput.md`) + `quad2-try`(이전에 시도했다 폐기한 v2 재작성 시도 — 리서치 완료, 결론은 `base/bind-system-plan.md`) — 읽기 전용 리서치 소스, 여기 내용을 옮기지 말고 항상 원본 그대로 유지 | + +`research/`의 문서가 설계 확정되면 `base/`로 승격(또는 구현 착수 시 +`qa-request/`행). 지금은 구현 라운드 전(설계 단계)이라 전부 `base/`/`research/`에만 +있음. + +## `base/` — 결정된 것, 프로젝트 전체 컨텍스트 + +| 문서 | 내용 | +|---|---| +| `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서) | +| `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식 | +| `store-semantics.md` | Store는 부작용 허용이 기본. State는 Store 위의 조합 가능한 캐시 레이어로 실제로 필요함(2026-08-04 정정) — 온톨로지 핵심 메커니즘은 2026-08-04 2차 라운드에서 확정, 최신 상세는 `base/bind-system-plan.md` | +| `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드) | +| `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정 | +| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`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 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설 | +| `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` 포인터로 압축 | +| `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게, State 마일스톤(M3)과 함께 개발. 메커니즘+이름 확정 | +| `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, state?)` — `state` 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료 | +| `ui-shorthand-plan.md` | **[2026-08-07 `research/`에서 승격]** `UICorner`/`UIPadding`/`UIScale` 인라인 편의 키 — 이름(v1 `Corner`/`PaddingAll`/`Scale`에서 Modifier 필드명과 안 겹치게 `UI` 프리픽스로 확정)·메커니즘(Handler)·패키지 배치(quad-roblox 코어)·store-bind 가능성까지 전부 확정. 이미지 라운드 트릭(`RoundSize`)은 드롭 — `archive/ui-shorthand-roundsize-dropped.md` 참고. `v=nil`이면 `process` 자신이 만든 자식 제거(`retract` 아님) | +| `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. 이제 `retract`가 의미 있음(타입이 바뀌면 전체 삭제, 같은 Tag끼리는 `process`가 diff). 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md` | +| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** `[Attribute "Name"]` — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐, `retract` 불필요. 타입 파라미터화 이름(`Attribute` vs `BooleanAttribute`류)만 미확정 | +| `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)` — `GetPropertyChangedSignal` 바인딩 전용 DI 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State`은 기존 이벤트 store-bind 메커니즘 재사용 | +| `relate-plan.md` | **[2026-08-08 신설]** `Relate` — `inst`를 weak 키로 하는 범용 릴레이션 프리미티브(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`, 비싱글톤 생성자). 구 `base.perInstanceState(inst)` placeholder를 대체·정식 승격, `lifecycle-pattern.md`의 `bindLifetime`/`canExecute`가 그 위에 얹힘 | + +## `reference/` — 온디맨드 참고 자료 (2026-08-07 신설) + +| 문서 | 내용 | +|---|---| +| `quad-v1-architecture.md` | v1(`initreq/quad`) 내부 동작 스냅샷 — "이 문제를 안 반복하려면"의 기준선. **[2026-08-07 `base/`→`reference/` 이동]** v2의 결정 자체가 아니라 다른 문서가 인용하는 온디맨드 자료라 항상 읽을 필요는 없음 | +| `comparison-fusion-vide.md` | Fusion/Vide 아키텍처 비교 리서치 — 설계 결정 근거 자료(전파 모델 등 일부 서술은 이후 라운드에서 뒤집혔으니 `bind-system-plan.md` 쪽을 최신으로 볼 것). **[2026-08-07 `base/`→`reference/` 이동]**, `quadnomicon` 소재 후보 | +| `comparison-charm.md` | **[2026-08-09 신설]** littensy/charm(Roblox Zustand류) 비교 — `batch()`/`atom()`/수동 dispose Effect 3가지는 quad가 이미 기각한 패턴이라 반면교사, `None` 센티널은 독립 재확인, charm-sync의 diff/patch는 quad 미착수 네트워크 복제 영역의 첫 참고자료, Blocker의 "previous 값 비교" 미결 문제엔 정황 증거(생성 시 필수 `equals`, computed의 previous-in-getter) 제공 | + +## `research/` — 아직 착수 전, 상의 필요 + +| 문서 | 내용 | 우선순위 | +|---|---|---| +| `tween-plan.md` | **[2026-08-10 세션 재설계]** 트윈을 값-레벨 `Tween` 래퍼(PropertyHandler가 소비)로 전환, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`로 이전. 3-상태 릴레이션 슬롯(`RobloxTween\|true\|nil`)으로 hasBeenSet 억제, `T'=T\|Tween` 타입 치환, `useTween`은 `:Apply`/`:Compute`로 해소. 기본 오버라이드는 Cancel, 트윈 옵션 값 모양(TweenInfo vs 편의 필드)만 남음 | 중 — 세부 옵션만 남음 | +| `existing-instance-bind-plan.md` | 이미 생성된 인스턴스 재바인드 — 착수 안 하되 "미지원" 확정도 안 함, 열린 가능성 유지 | 하 — v2 초기 스코프 제외 | +| `debug-tooling-plan.md` | 실물 Instance→코드 위치 역추적 Studio 플러그인(`quad-debug`) — 채널 실현 가능성(BindableEvent/Function 크로스 컨텍스트)까지 실측 검증 완료, 세부 API 이름·구현만 남음 | 하 — 사용자가 "quad 개발 완료 전엔 착수 못 함"으로 직접 후순위 지정, base 설계 시 훅 확장 지점만 고려 | +| `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-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 코어 구현 시점까지 미결 | + +## `archive/` — 완료됐거나 완전히 뒤집힌 것, 능동 참고 불필요 + +| 문서 | 내용 | +|---|---| +| `store-source-proxy-reversed.md` | [역전됨] 2026-08-04에 확정했던 `StoreSource` 프록시 설계(Store가 Source를 감춘 별도 프록시로 감쌈) — 2026-08-06 세 번째 세션에서 "Source가 State를 구조적으로 만족" 재구성으로 완전히 대체됨. 원문·역전 이유·신구 비교표 보존, `quadnomicon` 소재 후보 | +| `ref-phase-option-reversed.md` | [역전됨] `CreatedRef`의 `phase` 옵션 — 위치 기반 순서 + `PreRef` 신설로 대체됨 | +| `ui-shorthand-roundsize-dropped.md` | **[기각됨, 2026-08-07 신설]** v1 `RoundSize`(이미지 9-slice 라운드 트릭) — 네이티브 `UICorner`로 대체되어 포팅 불필요. 이 판단이 한 차례 "Corner/PaddingAll/Scale 숏핸드 전체가 불필요하다"로 과잉일반화됐다가 정정된 이력 포함 | +| `batch-rejected.md` | **[기각됨, 2026-08-07 신설]** lexical `Batch(fn)` — 코루틴 yield 위에서 구조적으로 위험해 기각, 값 기반 `Blocker`(`base/blocker-plan.md`)로 대체 | +| `context-rejected.md` | **[기각됨, 2026-08-07 신설]** `Context`(트리 하위 암묵 전파) + 대안이던 레이어드 Store 둘 다 기각 — 명시적 타입 강제 Store 전달로 충분하다는 판단 | +| `modifier-apply-mutable-rejected.md` | **[기각됨, 2026-08-08 신설]** `Modifier.Apply`/setter를 mutable로 바꾸는 방안(및 "Apply 경계에서만 clone" 절충안) — 둘 다 형제 서브트리 오염 방지가 clone 비용 절감보다 우선이라 기각 | +| `tag-hash-key-model-reversed.md` | [역전됨] 구 `Tag` 모델(해시 파트 boolean 키, 태그 개수만큼 키 갱신) — 2026-08-08 세 번째 세션에서 array-part 값 객체(`Tag(...)`, `:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`) 모델로 완전히 대체됨 | +| `agent-mistake.md` | **[에이전트 실수, 2026-08-07 신설]** 설계 반전이 아니라 에이전트가 문서 작성 중 개념을 혼동했다가 같은 세션 안에서 스스로 정정한 사례 모음(`canExecute`/`isHandlable` 혼동, `isSource` 불필요 오판) — CLAUDE.md 세션 로그의 중복 서술을 여기로 옮기고 포인터만 남김 | +| `quad2-try-research-findings-rejected.md` | **[기각됨, 2026-08-09 코퍼스 정리 신설]** quad2-try 이전 시도 리서치 전문(OOP 상속/커스텀 파서/Slot 스텁/`Pipe` copy-on-write 4가지 죽은 접근 + `:With` 이름 방증) — `base/bind-system-plan.md`에 남아있던 인라인 전체 서술을 이전, 결론 한 줄 포인터만 본문에 남김 | +| `observer-cleanup-contract-rejected.md` | **[기각됨, 2026-08-09 코퍼스 정리 신설]** `Observer` 자체에 React `useEffect`식 cleanup 반환 계약을 추가하는 안 — 클로저로 이미 충분해 기각, `Effect`가 opt-in 상위 계층으로 이 패턴을 제공 | +| `keyed-collection-state-method-rejected.md` | **[기각됨, 2026-08-09 코퍼스 정리 신설]** 키 기반 동적 컬렉션 재조정 프리미티브를 `state:Keyed(...)` State 메소드로 두려던 초안 — Source 미사용 컴포넌트가 접근 못 한다는 반례로 철회, 현재는 자유 함수로 확정 | +| `debug-channel-replicatedstorage-rejected.md` | **[기각됨, 2026-08-09 코퍼스 정리 신설]** quad-debug 채널을 `ReplicatedStorage`에 자동 생성하던 초안 — 게임 트리 오염 부작용으로 기각, quad 모듈 자신의 트리+`CollectionService` 태그로 대체 | +| `tween-special-bind-key-reversed.md` | **[역전됨, 2026-08-10 신설]** 구 Tween 모델(`[Tween(key,tweenData...)] = storeValue` 특수 bind key, 우선순위 최상위 Dispatch 핸들러) — 값-레벨 `Tween` 래퍼 모델로 완전히 대체됨(`research/tween-plan.md`) | +| `onchange-per-property-codegen-rejected.md` | **[기각됨, 2026-08-10 신설]** `OnChange.PropertyName` 프로퍼티별 정적 코드 생성 — Attribute의 정적 지름길과 달리 (클래스 수 × 프로퍼티 수) 규모로 폭발해 기각, `OnChange(name)` 단일 팩토리로 대체 | + +## 참고 + +- **저장소 소유자가 답해야 할 질문 전체 취합**: `.claude/question.md` +- **사람만 할 수 있는 일(로컬 조작/결정)**: 루트 `HUMAN_TODO.md` +- **원본 브레인스토밍(raw chain-of-thought)**: `.claude/initreq/raw-userinput.md`, + `.claude/initreq/req.md` — 위 문서들로 나누기 전의 원본, 참고용 백업이니 그대로 둘 것 diff --git a/.claude/archive/agent-mistake.md b/.claude/archive/agent-mistake.md new file mode 100644 index 0000000..67c39ef --- /dev/null +++ b/.claude/archive/agent-mistake.md @@ -0,0 +1,39 @@ +# [에이전트 실수] 에이전트 실수 기록 + +CLAUDE.md 세션 로그 안에 흩어져 있던 "에이전트가 같은 세션 안에서 스스로 +정정한 실수" 서술을 여기로 모음 — 최종 결론은 이미 각 `base/` 문서에 +정확히 반영돼 있어서 CLAUDE.md에 전체 문단을 남겨둘 필요는 없지만(중복), +같은 실수를 반복하지 않기 위한 기록 자체는 남겨둘 가치가 있음. 다른 archive +문서(`*-reversed.md`/`*-rejected.md`)와 달리 이건 "설계 결정의 반전"이 +아니라 "에이전트가 문서를 쓰다가 실제로 개념을 혼동했던 사례" 전용. + +## 1. `canExecute`와 `isHandlable`을 같은 개념으로 혼동 (2026-08-07 여덟 번째 세션) + +**실수**: `NoneHandler`(값을 `None`에서 `nil`로 바꿔 재디스패치하는 base +내장 핸들러)를 설계하며 그 매치 조건을 `canExecute`로 잘못 서술함. + +**정정**: 둘은 완전히 다른 계층 — `isHandlable(k,v)`는 KV 매치 +predicate(핸들러가 이 키/값을 담당하는지 판단, 핸들러 계약 4종 중 하나), +`canExecute(handle)`는 특정 바인딩 하나가 "지금 살아있어 실행돼도 +되는가"만 보는 별개의 라이프타임 게이트(`base/lifecycle-pattern.md`). +`NoneHandler`가 구현해야 하는 건 `isHandlable`이지 `canExecute`가 아님. + +**현재 유효한 설계**: `base/bind-system-plan.md`의 `None` 센티널 절과 +"매치 predicate는 `isHandlable`" 절이 최종 소스. + +## 2. `isSource`가 불필요하다고 잘못 판단 (다섯 번째 세션 → 여덟 번째 세션에서 정정) + +**실수**: 2026-08-07 다섯 번째 세션에서 `isState`/`isSource` predicate를 +설계하며 "State면 충분한 용도만 있으니 `isSource`는 따로 안 만들어도 +된다"고 서술. 이때 `base/component-composition-plan.md` 4번 절은 이미 +`isSource`가 존재한다고 가정하고 쓰여 있었는데, 그 모순을 그때는 못 +찾아냄. + +**정정**: `Source`는 `State`보다 실제로 더 많은 능력(`:Set`/`:Emit`)을 +가진 서브타입이라, "쓰기도 되는 원천인가"를 알아야 하는 코드는 +`isState`만으론 부족함 — `isSource`를 별도로 제공해야 함. `isState`는 +여전히 `{State, Source}` 둘 다 통과시킴(상위집합 판별 유지). + +**현재 유효한 설계**: `base/bind-system-plan.md`의 `Brand` 절 +(`isState`/`isSource`가 둘 다 존재, 전자는 집합 멤버십, 후자는 단순 항등)이 +최종 소스. diff --git a/.claude/archive/batch-rejected.md b/.claude/archive/batch-rejected.md new file mode 100644 index 0000000..d0ada8b --- /dev/null +++ b/.claude/archive/batch-rejected.md @@ -0,0 +1,63 @@ +# [기각됨] `Batch(fn)` — lexical block 기반 지연/합치기, `Blocker`로 대체됨 + +**기각 일시**: 2026-08-06~07. **현재 유효한 설계**: `base/ +blocker-plan.md` — 이 문서가 다루는 것과 같은 +문제("여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게")를 +값 기반으로 풀어 대체함. 이 파일은 더 이상 능동적으로 참고할 필요 없음 +(구현에 안 씀) — "왜 lexical Batch를 기각하고 값 기반 Blocker를 택했는가"가 +`quadnomicon`(프레임워크 설계자용 심화 콘텐츠) 소재로 가치 있어서 사유를 +통째로 보존해둔 것. + +**중요**: 이건 "값 기반 지연/합치기"라는 문제 자체를 기각한 게 아니라, +**그 문제를 lexical block(Solid `batch()`/MobX `runInAction()`류)으로 +풀려는 접근만** 기각한 것 — 실제 해법은 완전히 다른 별개 primitive인 +`Blocker`로 채택됨. + +## 무엇을 검토했었나 + +`Batch(fn)`을 "플래그 세우고 `fn` 실행, 끝나면 flush"로 구현하는 안 — +`fn` 안에서 여러 `Set()`을 몰아서 호출해도 소비자에게 전파는 `fn`이 끝난 +뒤 딱 한 번만 되게 하는, 함수/코루틴 스코프 lexical transaction 블록. + +### "즉시 pull"이 뭔지 (참고용 예시) + +store-bind 핸들러(예: `Frame { BackgroundColor3 = total }`)는 lazy가 +아니다 — 화면에 실제로 반영하는 "누군가"가 바로 이 핸들러 자신이라, +무효화 신호를 받자마자 스스로 `Get()`하고 바로 대입한다: + +```lua +local total = a:With(b):Compute(function(av, bv) return av + bv end) +Frame { BackgroundColor3 = total } + +a:Set(1) -- total 무효화 → 핸들러가 즉시 (av=1, bv=이전b)로 재계산+대입 +b:Set(2) -- total 무효화 → 핸들러가 즉시 (av=1, bv=2)로 다시 재계산+대입 +-- BackgroundColor3가 중간값을 한 번 거쳐가고, 두 번 대입됨 +``` + +## 기각 이유 — 코루틴 yield 위에서 구조적으로 위험 + +`Batch(fn)`을 "플래그 세우고 `fn` 실행, 끝나면 flush"로 구현하면 **`fn`이 +yield하는 순간 위험해진다**(사용자 지적, 정확함): + +1. 플래그가 전역이면, yield 중 스케줄러가 돌리는 무관한 코루틴의 `Set()`이 + 이 Batch에 잘못 휘말릴 수 있음. +2. 플래그를 코루틴 스코프로 만들어도(Fusion `Contextual`류 코루틴 키 weak + table), `fn` 안에서 새 코루틴을 스폰하는 API(Promise, `task.spawn`)를 + 부르면 그 새 코루틴은 배치 스코프를 상속 못 받아 일부 Set이 새어나감. +3. `fn`이 영원히 재개 안 되면(장시간 대기, 리크된 코루틴) flush가 영영 안 + 일어나 store-bind 핸들러들이 화면을 무기한 stale 상태로 방치 — 즉시 + pull보다 더 나쁜 실패 모드. + +이건 구현을 잘 짜서 피할 수 있는 버그가 아니라 **lexical block 모델 +자체가 협조적 스케줄링 환경과 구조적으로 안 맞는 것**으로 판단, 기각. + +## 왜 완전히 헛수고는 아니었나 + +"지연 구간을 표현하고 싶다"는 문제의식 자체는 정확했고, `Blocker`가 +정확히 그 문제를 콜스택/코루틴이 아니라 **값**(`Blocker` 객체의 +`On()`/`Off()`)으로 표현해 풀었다 — Batch가 무너뜨렸던 세 가지 실패 +모드(전역 플래그 오염, 새 코루틴 미상속, 영구 yield로 인한 무기한 방치)가 +Blocker에선 구조적으로 전부 해당 안 됨(`On()`/`Off()` 사이에 얼마나 많은 +yield/코루틴 전환이 끼어도, 심지어 완전히 다른 코루틴에서 `Off()`를 +불러도 문제없음). `quadnomicon`에서 "콜스택/코루틴 스코프로 상태를 +표현하려던 시도가 왜 항상 위험한가"의 구체 사례로 쓰기 좋음. diff --git a/.claude/archive/context-rejected.md b/.claude/archive/context-rejected.md new file mode 100644 index 0000000..cc66642 --- /dev/null +++ b/.claude/archive/context-rejected.md @@ -0,0 +1,66 @@ +# [기각됨] `Context`(트리 하위 암묵 전파) + 대안이었던 "레이어드 Store" + +**기각 일시**: 2026-08-06~07. **현재 유효한 설계**: 명시적 타입 강제 +Store 전달(`props.Theme: Store`처럼 컴포넌트가 필요한 걸 named +parameter로 명시적으로 요구) + 오버라이드가 필요한 지점에서 +`Store({...부모값, 변경필드=새값})`을 한 번 명시적으로 만들어 그 지점부터 +평소처럼 prop으로 넘기는 것 — 새 primitive 없이 이미 있는 Modifier의 +"merge, 나중 게 이김" 패턴 재사용. **base/ 포인터**: named parameter로 +경계를 넘기는 일반 패턴은 `base/component-composition-plan.md` "1. Named +parameter로 경계를 넘김" 절, merge 패턴 자체는 `base/modifier-plan.md` +2번 절 — 이 결정 자체가 새 primitive를 만들지 "않기로" 한 것이라 전용 +base/ 절이 따로 없고 기존 두 절의 재사용으로 충분함이 이 파일의 결론. +이 파일은 더 이상 능동적으로 참고할 필요 없음(구현에 안 씀) — "왜 Context가 +없는가"가 `quadnomicon`(프레임워크 설계자용 심화 콘텐츠) 소재로 가치 있어서 +사유를 통째로 보존해둔 것. + +## 무엇을 검토했었나 + +React `Context`/Vue `provide`-`inject`류, 트리 상위에서 값을 하나 심어두면 +중간 컴포넌트가 명시적으로 전달하지 않아도 하위 어디서든 그 값을 읽을 수 +있는 암묵적 전파 메커니즘. + +### 난이도 판정 요약 + +서브에이전트 조사 결과: 동기적 저작 트리에 한정한 얕은 버전(Fusion +`Contextual`류 코루틴 키 weak table push-pop)은 구현 난이도 **낮음**이지만, +quad가 정상 패턴으로 확정한 "Slot에 이벤트 핸들러/코루틴에서 비동기로 +자식이 추가되는 경우"엔 **에러 없이 조용히 기본값으로 폴백**하는 함정이 +있음. 완전 자동(비동기 추가까지 자동 전파)은 Roblox Luau에 +thread-local/async-context-propagation 훅이 없어 **사실상 불가**(플랫폼 +한계, Node `AsyncLocalStorage`/Python `contextvars`도 "자동"이 아니라 +"async 경계마다 명시적 캡처+재진입"으로 같은 문제를 품). + +## 기각 이유 + +얕은 버전조차: (1) `base/slot-plan.md`가 정상 패턴으로 명시한 Slot 비동기 +추가에서 가장 먼저, 가장 조용히 깨짐. (2) `research/debug-tooling-plan.md`의 +"모든 연결은 선언된 그래프여야 한다"는 quad-debug 철학과 충돌하는 안 보이는 +채널을 만듦. + +## 대안이었던 "레이어드 Store"도 철회 (사용자 반박 수용) + +Context 대신 권고했던 대안 — "레이어드 Store"(자식 Source 모음이 없는 +키는 부모로 `__index` 폴백)도 사용자 반박으로 철회됨: + +- quad는 이미 **타입으로 강제되는 명시적 Store 전달**을 갖고 있고, 이건 + Context의 "Provider 안 넣으면 조용히 기본값/에러"보다 **더 안전**하다 + (컴파일타임에 걸림 vs 런타임에 조용히 새는 값) — Context보다 나은 지점. +- "필드 일부만 오버라이드, 나머지는 부모 값 그대로"가 필요하면, 오버라이드 + 지점에서 `Store({...부모값, 변경필드=새값})`을 **한 번 명시적으로** + 만들어서 그 지점부터 평소처럼 prop으로 넘기면 끝 — Modifier가 이미 쓰는 + "merge, 나중 게 이김" 패턴 재사용 가능, 새 primitive 불필요. 레이어드 + Store(읽는 시점에 몇 단계를 거슬러 올라가는지 안 보이는 자동 폴백)는 + 정확히 Context와 같은 이유(디버깅 어려움 — "이 값이 왜 이거지?"를 + 추적하려면 부모 체인을 다 훑어야 함)로 얻는 것보다 잃는 게 크다. +- 서드파티 라이브러리가 뭔가 필요하면, 타입이 강제하는 명시적 요구 + (`props.Theme: Store`)가 "몰래 안 줘서 죽는다"보다 나은 실패 + 모드 — Slot 기반 저작 모델(부모가 자손을 직접 구성)에서도 이런 요청은 + 자연스럽게 props로 흐른다. + +## 결론 + +Context, 레이어드 Store 둘 다 프리미티브로 만들지 않음. "왜 Context가 +없는가"(명시적 Store 전달이 이미 그 역할을 하고, 타입 강제가 Context의 +실패 모드보다 안전하다는 논증)는 `quadnomicon` 에세이 후보로 등록 +(`research/documentation-content-map.md` 참고). diff --git a/.claude/archive/debug-channel-replicatedstorage-rejected.md b/.claude/archive/debug-channel-replicatedstorage-rejected.md new file mode 100644 index 0000000..dcbea5a --- /dev/null +++ b/.claude/archive/debug-channel-replicatedstorage-rejected.md @@ -0,0 +1,22 @@ +# [기각됨] quad-debug 채널을 `ReplicatedStorage`에 자동 생성하는 방식 + +**기각 일시**: 2026-08-06 세션. **현재 유효한 설계**: `research/ +debug-tooling-plan.md` "데이터 채널" 절 — Bindable을 quad 모듈 자신의 +Instance 트리 안(quad가 이미 설치돼 있는 위치 그대로)에 두고 +`CollectionService` 태그로 노출, 플러그인은 `GetTagged(tag)`로 찾음 +(`GetDescendants()` 전체 순회 불필요). 이 파일은 더 이상 능동적으로 참고할 +필요 없음(구현에 안 씀) — 사유를 짧게 보존해둔 것. + +## 무엇을 검토했었나 + +quad-debug-roblox가 초기화 시 `ReplicatedStorage` 밑에 잘 알려진 이름으로 +Bindable을 만들어 노출하는 방식. + +## 기각 이유 + +개발자가 의도하지 않은 Instance를 게임 트리에 주입하는, 부작용이 큰 행위라 +기각(사용자 정정). `ReplicatedStorage`는 개발자 자신의 게임 트리이지 +quad가 마음대로 채워도 되는 공간이 아님 — quad 모듈 자신의 Instance 트리 +안에 두면 이 문제 자체가 없고, `CollectionService` 태그를 쓰면 플러그인이 +quad가 어디 설치됐는지 몰라도 바로 찾을 수 있어 `ReplicatedStorage`에 둬야 +할 이유도 애초에 없었음. diff --git a/.claude/archive/keyed-collection-state-method-rejected.md b/.claude/archive/keyed-collection-state-method-rejected.md new file mode 100644 index 0000000..310f845 --- /dev/null +++ b/.claude/archive/keyed-collection-state-method-rejected.md @@ -0,0 +1,26 @@ +# [기각됨] 키 기반 동적 컬렉션 재조정 프리미티브를 `state:Keyed(...)` State 메소드로 두는 안 + +**기각 일시**: `research/additional-primitives-plan.md` 논의 도중(날짜 미상, +"이전 라운드"로만 기록). **현재 유효한 설계**: `research/ +additional-primitives-plan.md` "폼 팩터" 절 — 이 프리미티브는 자유 함수로 +두고, `data` 인자가 plain array/table이든 `State`/`Source`든 +둘 다 받는 폴리모픽 컨벤션(quad의 leaf 프로퍼티가 이미 쓰는 "리터럴 또는 +State 둘 다" 관례와 동일)을 따름. 이름 자체는 아직 미정 — 이 프리미티브의 +최종 설계는 여전히 열려있는 질문이라 `question.md`/`additional-primitives-plan.md` +본문을 계속 참고할 것, 이 파일은 "왜 State 메소드가 아닌가"라는 기각 +사유만 보존. + +## 무엇을 검토했었나 + +"독립 프리미티브 vs 원천 종속 파생 데이터" 원칙(Source/Ref/Store/Modifier= +독립 프리미티브, State/Observer=원천에 종속된 파생 데이터)을 그대로 적용해, +이 재조정 프리미티브도 `state:Keyed(...)`처럼 **State의 메소드**로 두자는 +제안. + +## 기각 이유 + +Source를 안 쓰는 컴포넌트는 이 메소드 자체에 접근을 못 함 — 정적 데이터 +(한 번만 렌더되고 다시는 안 바뀌는 리스트)를 키 기반으로 렌더링하고 싶을 +뿐인데, 굳이 `Source(정적데이터)`로 감싸야 접근 가능하다면 불필요한 강제. +"독립 프리미티브 vs 파생 데이터" 원칙 자체가 틀린 게 아니라, 이 프리미티브가 +그 분류 어디에도 깔끔히 안 맞는 케이스였다는 게 재검토 결과. diff --git a/.claude/archive/modifier-apply-mutable-rejected.md b/.claude/archive/modifier-apply-mutable-rejected.md new file mode 100644 index 0000000..65a86b0 --- /dev/null +++ b/.claude/archive/modifier-apply-mutable-rejected.md @@ -0,0 +1,46 @@ +# [기각됨] Modifier `Apply`/setter를 mutable로 바꾸는 방안 (전체·절충안 둘 다) + +**상태**: 후보였다가 채택 안 됨(확정한 적 없이 검토 후 기각) — `base/ +modifier-plan.md` 9-1번 절에서 이 판단의 결론(판단 기준 자체는 "동질적/ +이질적"이 아니라 "계산 의존성 유무")만 남기고 아래 전체 경위는 이 문서로 +옮김. `batch-rejected.md`/`context-rejected.md`와 같은 카테고리 — +`quadnomicon` 소재 후보. + +## 배경 + +2026-08-07 다섯 번째 세션 후속. `Apply` 체이닝이 호출마다 clone을 만들기 +때문에, 항목 수천 개짜리 리스트 UI처럼 무거운 Modifier를 대량으로 +재생성하는 상황에서 이 clone 비용이 누적되는 게 아닌지 사용자가 우려 — +대안으로 (a) `Apply`/setter를 아예 mutable로 바꾸는 방안, (b) `Overridden`를 +"여러 값을 합칠 특수 상황"이 아니라 "성능 최적화 수단"으로 승격하는 방안을 +검토했음(이 문서는 (a)와 그 절충안만 다룸 — (b)는 기각되지 않고 "계산 +의존성 유무" 판단 기준으로 정리되어 `modifier-plan.md` 본문에 그대로 남음). + +## (a) `Apply`를 mutable로 바꾸는 방안 — 기각 + +3번 절에서 immutable+clone을 확정한 이유가 정확히 "같은 modifier +레퍼런스를 공유하는 형제 서브트리가 mutate로 오염되는 것"을 막기 +위해서였음 — 이건 특정 세션 판단이 아니라 2026-08-04부터 계속 지켜온 +하드 제약. `Apply`/setter가 mutable이면 여러 컴포넌트가 참조하는 공유 +테마 상수 하나에 어느 한쪽이 체이닝만 해도 다른 쪽까지 같이 바뀌는 +클래스의 버그가 그대로 돌아옴 — clone 비용 절감이 이 안전성보다 +우선순위가 높다고 볼 근거가 없어 기각. (단, `table.clone`은 Luau native +shallow-copy라 Modifier 필드 수(한 자리~여남은 개) 기준 개별 clone 비용 +자체는 이미 3번 절에서 무시 가능하다고 판단됨 — 이번에 새로 문제 삼는 건 +"한 번 비용의 크기"가 아니라 "체인 길이 × 인스턴스 수로 누적되는 clone +*횟수*"라는 별개 축.) + +## (a-1) 절충안 — "`Apply` 진입 시 한 번만 clone하고 그 안에서는 mutable로" — 검토했으나 기각 + +clone 횟수를 체인 길이만큼이 아니라 `Apply` 호출당 1번으로 줄이자는 +아이디어(`Apply` 경계에서만 복사, 내부 setter들은 그 복사본을 그대로 +mutate). + +**기각 이유**: 이렇게 해도 버그 클래스 자체가 안 없어짐 — `Apply`를 +거치지 않고 setter를 직접 호출하는 흔한 경로(`mod:FontSize(...)`처럼 +체이닝 자체가 아니라 단발 호출)는 여전히 mutable이라, 공유 레퍼런스에 +대고 단발 setter 하나만 불러도(예: 서브트리 어딘가에서 폰트 두께만 살짝 +바꾸는 경우) 그대로 오염됨 — "`Apply` 안에서는 안전, 밖에서는 안 안전"처럼 +**어디서 터지느냐만 달라질 뿐 문제 자체는 그대로 남는 비일관적인 +절충**이라 실익이 없음. 전부 clone하는 지금 방식이 버그 클래스를 균일하게 +없애는 유일한 방법 — 확정 유지. diff --git a/.claude/archive/observer-cleanup-contract-rejected.md b/.claude/archive/observer-cleanup-contract-rejected.md new file mode 100644 index 0000000..7305665 --- /dev/null +++ b/.claude/archive/observer-cleanup-contract-rejected.md @@ -0,0 +1,41 @@ +# [기각됨] `Observer` 자체에 React `useEffect`식 cleanup 반환 계약 추가 + +**기각 일시**: 2026-08-07 여섯 번째 세션. **현재 유효한 설계**: `base/ +effect-plan.md` "Effect와 Observer의 관계" 절 — `Observer`의 기본 계약은 +재실행 신호만 주고 cleanup은 클로저로 직접 처리, 자동 cleanup 배선이 +필요하면 opt-in 상위 계층인 `Effect(fn, state?)`를 쓸 것. 이 파일은 더 +이상 능동적으로 참고할 필요 없음(구현에 안 씀) — "왜 Observer 자체에 +cleanup 계약을 안 넣었는가"가 `quadnomicon`(프레임워크 설계자용 심화 +콘텐츠) 소재로 가치 있어서 사유를 보존해둔 것. + +## 무엇을 검토했었나 + +React `useEffect`류 패턴 — `state:Observer(fn)`의 `fn`이 `nil | () -> ()`를 +반환하면, 다음 재실행 직전에 quad가 그 반환값을 자동으로 호출해주는 안. + +## 기각 이유 + +클로저 업밸류로 이미 쉽게 되고 잘 작동함: + +```lua +local lastConn +state:Observer(function() + if lastConn then lastConn:Disconnect() end + lastConn = ... +end) +``` + +**Observer 자체**가 이걸 대신 배선해줘야 할 이유가 약함 — 반환값을 잡아뒀다가 +다음 실행 전에 불러주는 기능을 Observer 코어에 넣으면, 그 계약을 안 쓰는 +대다수 사용처까지 복잡도가 늘어나는데 클로저로 이미 공짜로 되는 걸 다시 +API 표면으로 만드는 셈. + +## 왜 완전히 헛수고는 아니었나 — Effect 설계와 상충하지 않음 + +이 기각과 이후 확정된 `Effect(fn, state?)` 설계는 상충하지 않는다 — 그때 +기각한 건 "Observer 자체에 이 복잡도를 넣지 말자"였지 "이 패턴 자체가 +무용하다"가 아니었음. 자동 cleanup 배선이 필요한 사람만 opt-in으로 쓰는 +별도 계층(`Effect`)으로 분리해 얹었을 뿐, `Observer`의 기본 계약(재실행 +신호만, cleanup은 클로저로 직접)은 그대로 가볍게 유지됨 — `Effect`가 +내부적으로 `state:Observer(...)`를 조합해 이 패턴을 상위 계층에서 정확히 +구현한다(`base/effect-plan.md` 참고). diff --git a/.claude/archive/onchange-per-property-codegen-rejected.md b/.claude/archive/onchange-per-property-codegen-rejected.md new file mode 100644 index 0000000..4d55a66 --- /dev/null +++ b/.claude/archive/onchange-per-property-codegen-rejected.md @@ -0,0 +1,38 @@ +# [기각됨] `OnChange.PropertyName` 프로퍼티별 정적 코드 생성 + +**기각 일시**: 2026-08-10. **현재 유효한 설계**: `base/onchange-plan.md` — +`OnChange(name)` 제네릭 없는 단일 팩토리, 콜백 파라미터 타입은 호출부가 +직접 명시. + +## 무엇이었나 + +`Attribute`가 `[Attribute<> "name"]`(제네릭 경로)과 `[BooleanAttribute +"name"]`(자주 쓰는 타입만 정적 지름길)을 둘 다 채택했던 것(`base/ +attribute-plan.md`)과 같은 모양으로, `OnChange`도 `OnChange.Position`/ +`OnChange.Size`처럼 프로퍼티 이름별로 이미 타입이 박힌 정적 필드를 코드 +생성기로 전부 만들어두는 안이 검토됐음. + +## 기각 이유 + +Attribute의 정적 지름길과 겉보기엔 같은 절충처럼 보이지만 실제로는 규모가 +다른 문제: + +- Attribute의 타입 파라미터 `T`는 Roblox Attribute가 지원하는 좁고 고정된 + 프리미티브 집합(string/boolean/number/Color3/UDim/UDim2/Vector2/Vector3/ + CFrame/Instance 등, ~10종)에서만 옴 — 정적 지름길 후보가 유한하고 작음. +- `OnChange`가 감쌀 수 있는 프로퍼티는 **클래스마다 이름/타입 집합이 전부 + 다름** — `Frame.Position`, `TextLabel.Text`, `ScrollingFrame.CanvasSize` + 등 클래스 종류만큼 프로퍼티 집합이 갈라지므로, "자주 쓰는 것만 정적 + 지름길"이 성립하려면 사실상 (클래스 수 × 프로퍼티 수) 규모의 조합을 + 전부 커버해야 함 — 유한한 지름길 목록으로 수렴하지 않음. +- 지름길을 특정 클래스 몇 개(Frame 등)로만 좁혀도, 그 클래스의 `OnChange` + 네임스페이스가 실제로 그 클래스에서만 유효한 프로퍼티인지 타입 레벨에서 + 강제할 방법이 마땅치 않음 — 결국 반쯤 타입 안전한 것처럼 보이는 인터페이스만 + 남고 실제 검증은 여전히 없음. + +## 대안(채택됨) + +콜백 파라미터 타입을 호출부가 직접 명시하는 것으로 충분 — 이미 이벤트 +바인딩(`Frame { MouseButton1Click = fn }`)이 콜백 시그니처 검증을 포기하는 +것과 같은 급의 트레이드오프를 받아들이는 것뿐, `OnChange`만 유별나게 정적 +타입 안전성을 추구할 근거가 약함. diff --git a/.claude/archive/quad2-try-research-findings-rejected.md b/.claude/archive/quad2-try-research-findings-rejected.md new file mode 100644 index 0000000..405bd3c --- /dev/null +++ b/.claude/archive/quad2-try-research-findings-rejected.md @@ -0,0 +1,90 @@ +# [기각됨] quad2-try 리서치 — 죽은 접근 4가지 + Unix 파이프 영감의 최종 정리 + +**기각/해소 일시**: 2026-08-04(2차 라운드). **현재 유효한 설계**: +`base/bind-system-plan.md`의 "Store/State/Source 온톨로지" 절 — `state(state)`로 +기존 state의 결과를 받아 새 state를 만드는 조합 모델이 최종 결론, Slot은 +`base/slot-plan.md`의 from-scratch 설계, `:With` 이름은 이미 확정. 이 파일은 +더 이상 능동적으로 참고할 필요 없음(구현에 안 씀, "OOP 상속/커스텀 파서/Slot +스텁/Pipe copy-on-write는 확인된 죽은 접근이라 반복 조사 금지"라는 결론 +한 줄만 `CLAUDE.md`/`base/bind-system-plan.md`에 포인터로 남으면 충분) — +"이전 시도에서 뭘 배웠는가"가 `quadnomicon`(프레임워크 설계자용 심화 콘텐츠) +소재로 가치 있어서 조사 과정과 근거를 통째로 보존해둔 것. + +## 배경 — quad는 원래 Unix 파이프에서 영감을 받아 설계됨 + +quad는 원래 파이프라인/스트림 개념에서 영감을 받아 만들어짐. 이상적으로는 +store에서 한 값을 추적(track)하면 "State"가 나오고, 거기에 `compute`를 +적용하면 또 다른 "State"가 나오는 식 — Unix의 `(cat a; cat b) | while +read ...`처럼 State끼리 자유롭게 합성/파이핑 가능한 것이 최종 목표. +`:With`의 두 번째 인자도 다른 `:Compute`의 결과물(State)을 그대로 받을 수 +있어야 이상적이었음. + +이 목표를 실제로 어떻게 구현할지에 두 갈래 긴장이 있었음: (1) Compute +체인이 자기 자신을 mutable하게 바꾸는 방식(엔지니어링 비용 낮지만 +공유/합성이 깨짐) vs (2) 명시적 `State:fromState(state)`류 비-mutating +생성자(합성은 안전, 비용 미확정). `.claude/initreq/quad2-try/out/quad-core`에 +정확히 이 문제를 다뤘던 이전 재작성 시도가 있어서 그걸 조사해 답을 찾으려 +했음. + +## 조사 결과 — 확인된 죽은 접근, 절대 반복하지 말 것 + +- **OOP 상속(`Base:Extends`) 구조**가 `Source`/`State`/`Pipe`/`Store`/ + `Event`/`Action`+8개 서브타입 전체에 퍼져 있었음 — 모든 서브클래스 + 생성자마다 `self._super._constructor(self, ...)`를 수동으로 호출해야 + 하고(빼먹기 쉬움, 컴파일러가 검증 안 함), private/protected는 `_` 접두사 + 관례일 뿐 실제 캡슐화가 전혀 없었으며, `Base:IsInstance`가 수동 유지되는 + `_proto`/`_super` 연결 리스트를 순회하는 런타임 전용 타입 체크라 Luau + 정적 타입 시스템이 전혀 못 봄. 사용자가 우려한 그대로 확인됨 — 상속 + 기반 설계 금지. +- **`--&` 커스텀 파서 시도**는 완전히 죽은 코드였음 — 6개 파일에 156줄의 + 주석 기반 타입/가시성 어노테이션이 있었지만, 이걸 실제로 소비할 도구 + (`quad-gen`, `quad-lang`)는 둘 다 완전히 빈 디렉토리였음. 오타(`@clsas`를 + `@class`로 못 고침)가 안 잡힌 채 남아있었고, 같은 주석 마커 아래 전혀 다른 + Lua5.1-호환 트랜스파일러 지시어까지 섞여 있었음 — 파서가 한 번도 제대로 + 동작한 적 없다는 명백한 증거. +- **Slot은 이 시도에서도 사실상 빈 스텁**이었음 — `Insert`의 실제 구현부가 + 전부 주석 처리되어 있고, `Notify()`도 빈 함수. 심지어 구 v1(`quad-2`)의 + `DEV_CHANGELOG.txt`에도 "TODO: slot 기능 구현"이 마지막까지 미완료로 + 남아있었음 — 가져올 게 전혀 없음, `base/slot-plan.md`의 from-scratch + 설계를 그대로 진행하면 됨(재조사 불필요). +- 다른 서브패키지(`quad-roblox`/`quad-gtk`/`quad-lang`/`quad-gen`/ + `quad-compat`/`quad-debug`/`quad-docs`)는 전부 파일이 0개인 빈 디렉토리 + — `quad-core` 밖엔 참고할 게 없음. +- `Store:Pipe`/`Store:Value` 연동이 담긴 유일한 두 예제 콜사이트 + (`slot.luau:31-41`)조차 존재하지 않는 `Store:Value` 메서드를 호출하는 등 + 실제로 동작 검증된 적이 없는 죽은 스크래치 코드였음 — 이 프로토타입은 + 끝까지 실사용 검증을 통과한 적이 없음. +- **`Pipe`가 mutate-vs-`fromState` 긴장 관계에 제시했던 절충안** — + "체이닝된 `Compute`/`Add`/... 호출은 자신이 액션 리스트의 유일한 + '끝(tip)'일 때만 공유 배열에 그대로 append(뮤테이션), 이미 다른 코드가 + 그 지점 이후로 체인을 확장해버렸다면 배열을 복사한 뒤 새 `Pipe` 객체를 + 반환"하는 copy-on-write 방식 — 한때는 위 (1)/(2) 긴장을 풀어보려 한 + 유일한 시도로서 다시 설계해볼 후보였으나, 최종적으로 폐기됨 — + `state(state)` 조합 모델이 소유권/버전 가드 없이도 같은 문제를 더 + 간단히 풀어서 이 절충안 자체가 불필요해짐. 원본이 갖고 있던 진짜 결함 + (소유권/버전 관리 없이 경쟁 상황에 취약, 테스트/실사용 검증도 없었음)도 + 기록으로 남김. + +## 건질 만한 것 (인체공학/아이디어만, 코드는 아님) + +- **`store:Pipe(key):Compute(fn)` 같은 왼쪽에서 오른쪽으로 읽히는 파이프 + 문법 자체**는 목표로 유지할 가치가 있다고 판단됐음 — 실제로 이후 + `:With`+`:Compute` 체이닝으로 달성됨. +- **`Depend(...)` 액션** — 계산값에는 관여하지 않고 오직 "이 소스가 바뀌면 + 다시 계산하라"는 추가 의존성만 등록하는 값-투명(value-transparent) no-op + 액션. 작지만 깔끔한 아이디어로 기록됐으나, 이후 실제 설계에서 별도 + 프리미티브로 채택되지는 않음(`:With(...)` 가변인자로 같은 효과를 얻음). +- **흥미로운 발견**: 스크래치 파일(`out/asdf`)에 남아있던 더 이전 버전의 + 파이핑 스케치가 정확히 `Pipe(store.background):With(store.transparency, + globalStore.test):Compute(fn)` 모양이었음 — 실제 구현으로 넘어가며 + `:Depend()`+포지셔널 인자로 바뀌었지만, `:With(...)` 네이밍은 이후 + 라운드에서 다시 요청된 것과 정확히 일치 — 우연이 아니라 원래 지향점이었던 + 것으로 보이며, `:With` 이름 채택에 힘을 실어준 방증. + +## 결론 + +이 프로토타입은 사실상 죽은 시도가 맞음(확인됨) — `:With` 네이밍은 +quad-v2 설계에 그대로 살아남았지만, Pipe의 copy-on-write 절충안은 +2026-08-04 검증 라운드에서 폐기되고 `state(state)` 조합 모델로 대체됨. +Unix 파이프 영감이라는 원래 동기 자체는 `:With`+`:Compute` 체이닝으로 +충분히 달성된 것으로 최종 판단. diff --git a/.claude/archive/ref-phase-option-reversed.md b/.claude/archive/ref-phase-option-reversed.md new file mode 100644 index 0000000..69403fa --- /dev/null +++ b/.claude/archive/ref-phase-option-reversed.md @@ -0,0 +1,74 @@ +# [역전됨] `CreatedRef`의 `phase` 옵션 + "Ref는 특수 처리 없는 평범한 참가자" — 위치 기반 순서 + `PreRef` 신설로 대체됨 + +**역전 일시**: 2026-08-07 (세 번째 세션). **원 확정 일시**: 2026-08-04 +(Ref 도입 확정 절)~2026-08-06(Ref 일반화 절)에 걸쳐 누적 확정. +**현재 유효한 설계**: `base/bind-system-plan.md`의 "확정된 디스패치 +모델" 절 하단(배열/해시 두 패스 계약)과 "`phase` 옵션 폐기 → +위치로 표현, `PreRef` 신설" 절이 최종 소스. 이 파일은 더 이상 능동적으로 +참고할 필요 없음(구현에 안 씀) — 왜 "옵션 하나로 phase를 고르는 설계"에서 +"위치 기반 순서 + 별도 타입 분리"로 넘어갔는지가 `quadnomicon`(프레임워크 +설계자용 심화 콘텐츠) 소재로 가치 있어서 사유·원문을 통째로 보존해둔 것. + +## 역전된 사례 — 원래 무엇을 확정했었나 + +**1. Ref는 dispatch 레지스트리의 "평범한 참가자"였음** (2026-08-04 +원문, `bind-system-plan.md` "Ref — 도입 확정" 절): + +> **바인드 방법**: 특수 처리 없이, children을 배열 아이템으로 넣듯 +> `CreatedRef` 같은 값을 숫자 키 슬롯에 넣는 방식 — child와 동일한 +> 층위에서 `process(inst,k,v)` 디스패치를 그대로 타게 함. 즉 Ref도 +> pluggable 핸들러 레지스트리의 평범한 참가자. + +**2. "생성 직후"/"마운트 후" 두 타이밍은 옵션 값으로 골랐음** (같은 절): + +> **콜백 호출 시점 확정**: 생성 직후(construction 시점)와 트리 마운트 +> 후(Parent 세팅 완료) 둘 다 지원 — 옵션으로 선택(`CreatedRef(fn, +> {phase="created"|"mounted"})`류, 정확한 API 이름은 구현 단계에서 확정). + +당시엔 이 두 문장이 서로 모순되지 않는다고 봤음 — "평범한 참가자"이면서 +동시에 "옵션으로 두 시점 중 골라 fire"하는 게 가능하다고 전제했던 것. + +## 역전된 이유 + +실제 사용 시나리오를 짚다가 드러남: quad-roblox 이벤트는 `self(Instance)`를 +안 주기로 이미 확정돼 있어서(`base/bind-system-plan.md` "이벤트 핸들러는 +self를 받지 않는다"), 이벤트 안에서 인스턴스 자신을 참조하려면 Ref로 +받아둔 값을 읽는 수밖에 없음. 그런데 Roblox 이벤트 중 일부(`ChildAdded`/ +`DescendantAdded`/`Changed`류)는 유저 인터랙션을 기다리지 않고 **setup +도중 프로퍼티 대입/Parent 세팅 자체의 부작용으로 동기적으로 발화**할 수 +있음 — 이 시점에 self-ref가 아직 안 채워져 있으면 그대로 터짐. + +이 문제를 실제로 풀려고 보니 "phase 옵션 하나로 고르는 평범한 참가자" +모델이 두 가지를 보장하지 못한다는 게 드러남: +1. **"평범한 참가자"라는 전제 자체가 Modifier/Store를 거치면 깨짐.** + Ref가 Modifier 필드로 flatten되거나 Source/Store 값으로 나중에 + 도착하면, "이 인스턴스에 다른 무엇보다 먼저"라는 순서 보장을 구조적으로 + 줄 방법이 없음(Modifier flatten은 해시 파트로 합쳐지고, Store 값은 + process/retract 재귀 경로로 원래 스캔보다 나중에 도착하므로). +2. **"created" phase가 실제로 뭘 보장하는지가 원래 정의돼 있지 않았음.** + "생성 직후"가 "다른 모든 프로퍼티/이벤트보다 먼저"까지 보장하는 건지, + 아니면 "그냥 루프 어딘가에서, construction 이후"면 충분한 건지가 + 불명확했음 — 후자로 해석하면 옵션이 무의미해지고, 전자로 해석하면 + `process(inst,k,v)` 우선순위 스캔만으로는 줄 수 없는 순서 보장이라 + 드라이버 레벨 개입이 필요해짐. + +## 이전 것과 지금 것의 차이 + +| | phase 옵션(역전됨) | 위치 기반 + `PreRef`(현재) | +|---|---|---| +| "자식 마운트 전/후" 표현 | `{phase="created"\|"mounted"}` 옵션 값 | children 배열에서 다른 형제보다 앞/뒤에 놓는 것만으로 결정(두 패스 계약 위에서 공짜로 나옴) | +| "프로퍼티/이벤트보다 먼저" 표현 | 같은 옵션의 `"created"` 값 — 실제로 이 보장을 줄 메커니즘은 없었음 | 별도 nominal 타입 `PreRef` — Modifier/Store엔 타입으로 아예 못 들어가고, 배열 파트 스캔 전에 driver가 따로 pre-pass로 fire, 위치와도 무관하게 항상 최우선(호이스팅) | +| Ref/CreatedRef가 참가자로서 특수한지 | "특수 처리 없이, 평범한 참가자"라고 명시 | 일반 Ref/CreatedRef는 여전히 평범한 `(v=Ref)` 핸들러 매치 — 다만 그 매치가 성립하려면 base가 배열 파트/해시 파트 순서를 **명시적으로 계약화**해야 했음(우연한 Luau 테이블 동작에 기대지 않음), `PreRef`는 아예 별도 pre-pass 대상이라 진짜 특수 취급 | +| Store/Modifier 조합 가능 범위 | 논의 안 됨(암묵적으로 전부 가능하다고 전제) | 일반 Ref는 자유, `PreRef`는 타입으로 원천 차단 | + +## 왜 완전히 헛수고는 아니었나 + +"children 배열 슬롯에 넣으면 dispatch가 채워준다"는 `CreatedRef`의 +핵심 아이디어 자체는 그대로 살아남음 — 바뀐 건 "그 안에서 두 타이밍을 +옵션 하나로 고르게 하자"는 세부 설계뿐. 오히려 이 반전 덕분에 "왜 굳이 +`PreRef`라는 별도 타입이 필요한가"(=순서 보장이 안 되는 경로가 실제로 +있다는 것)와 "base 드라이버가 왜 배열/해시 순서를 명시적으로 계약화해야 +하는가"(=Lua 테이블의 우연한 동작에 기대면 다른 백엔드에서 깨질 수 +있다는 것) 두 가지가 훨씬 선명해짐 — `quadnomicon`에서 "옵션 하나로 +퉁치려던 설계가 실제 시나리오(Roblox 이벤트의 동기 발화)를 만나 타입 +분리로 귀결된 사례"로 쓰기 좋음. diff --git a/.claude/archive/store-source-proxy-reversed.md b/.claude/archive/store-source-proxy-reversed.md new file mode 100644 index 0000000..41ca8d4 --- /dev/null +++ b/.claude/archive/store-source-proxy-reversed.md @@ -0,0 +1,73 @@ +# [역전됨] `StoreSource` 프록시 설계 — Source가 State를 만족하는 서브타입으로 대체됨 + +**역전 일시**: 2026-08-06 (세 번째 세션). **원 확정 일시**: 2026-08-04 +(`component-composition-plan.md` 컴포넌트화 논의 3~4번 항목). +**현재 유효한 설계**: `base/store-semantics.md`의 "Source가 State를 +만족함 — 구조적 서브타입" 절이 최종 소스. 이 파일은 더 이상 능동적으로 +참고할 필요 없음(구현에 안 씀) — 왜 이 경로를 갔다가 되돌아왔는지가 +`quadnomicon`(프레임워크 설계자용 심화 콘텐츠, `research/documentation-plan.md` +0번 항목) 소재로 가치 있어서 사유·원문을 통째로 보존해둔 것. + +## 역전된 사례 — 원래 무엇을 확정했었나 (2026-08-04 원문) + +`store.key`로 값을 얻을 때, Store가 내부 Source 객체를 **직접 노출하지 +않고** 별도의 얇은 프록시 타입 `StoreSource`를 만들어 반환하는 설계였음: + +> **Source = 인터페이스이자 구현체**: 독립 생성자 `Source(initial)`가 기본 +> 구현체, `store:GetSource("key")`(가칭)류 접근자가 반환하는 값은 같은 +> 인터페이스를 구현하는 별도의 얇은 프록시(`StoreSource`) — 읽기는 +> `store.key`로, 쓰기는 `store.key = v`로 위임. **내부 Source 객체를 그대로 +> 노출하지 않음** — 그러면 "쓰기는 오직 Store의 `__newindex`뿐"이라는 기존 +> 확정과 새 쓰기 경로가 충돌하게 됨. +> +> **캐시 안 함**: State가 이미 "매번 새로 만듦, store에 캐시 안 됨"으로 +> 확정돼 있어 일관성 + 엔지니어링 비용 둘 다 이쪽이 쌈 — **사용자 확정** +> ("그냥 엔지니어링적으로 비용이 싼거 택해"). + +같은 논의에서 파생된 핸들러 계약 쪽 결정도 같이 뒤집힘 — Source를 핸들러가 +직접 받을 때는 별도 유니온 타입으로 처리하기로 했었음: + +> 핸들러가 값을 받을 때 `Source | State` 유니온으로 받고, 내부에서 +> 타입 체크만 하면 됨(Source면 인스턴스 변경 이벤트에 걸어 역방향 쓰기까지 +> 처리, State면 읽기만) — `isHandlable`/`priority`/`process`/`retract` 4종 +> 계약에 5번째 항목을 추가할 필요 없음. + +## 역전된 이유 + +`store.key`의 타입 문제를 다시 들여다보다가 드러남: Store의 정적 타입을 +`{key: State}`류 평범한 레코드 타입으로 지으면(2026-08-04 3차 +라운드에서 확정했던 방식) Luau 구조적 타이핑상 그 필드의 읽기/쓰기 타입이 +같아야 하는데, 실제 쓰기(`store.key = value`, raw `number`)와 읽기 +(`State`)가 서로 다른 타입이라 애초부터 정합적이지 않았음 — +`StoreSource` 프록시 설계 시점엔 이 비대칭을 못 잡았던 것. + +이걸 풀려고 대안(store를 `store.key`/`store.state.key`로 네임스페이스 +분리하는 안, `RefSource`라는 store 전용 타입을 새로 만드는 안)을 +검토하다가, 더 근본적인 재구성으로 수렴: **Source 자체가 구조적으로 +State를 만족**(Svelte `Writable extends Readable`와 같은 모양) +하게 만들면,애초에 "Source를 감추고 별도 프록시로 감쌀" 이유 자체가 +없어짐 — Store가 내부에 갖고 있는 진짜 Source 객체를 그대로 돌려줘도 +안전하고, 오히려 프록시 객체를 매번 만들거나 캐싱하는 계층 하나가 통째로 +사라져서 더 쌈. + +## 이전 것과 지금 것의 차이 + +| | `StoreSource`(역전됨) | Source가 State를 만족(현재) | +|---|---|---| +| `store.key`가 반환하는 것 | 별도 프록시 `StoreSource`(Source 인터페이스를 구현한 wrapper) | 진짜 `Source` 객체 그대로 | +| 쓰기 문법 | `store.key = value`(`__newindex`) | `store.key:Set(value)` | +| 캐싱 | "매번 새로 만듦"(State와 같은 정책) | Store 생성 시 이미 만들어둔 Source를 그대로 반환 — 별도 캐싱 메커니즘 자체가 불필요 | +| 핸들러가 Source를 받는 방법 | `Source \| State` 명시적 유니온 | `State` 하나만 받아도 서브타입 호환으로 자동 통과, 런타임에 구분하고 싶으면 `isSource`류 판별자 | +| 타입 정합성 | 레코드 필드 읽기/쓰기 타입 비대칭 문제가 잠재해 있었음(발견 안 된 채로 확정됐었음) | 필드 타입이 항상 `Source`로 대칭 — 쓰기가 메소드 호출로 옮겨가며 문제 자체가 해소됨 | + +## 왜 완전히 헛수고는 아니었나 + +`StoreSource`가 짚었던 문제의식(Store 내부 표현을 그대로 노출하면 안 +될 수 있다, Source와 State는 다른 쓰기 권한을 가져야 한다)은 여전히 +유효함 — "State/Source 경계 규칙: 파생이면 읽기전용, 원본이면 쓰기 가능" +원칙(`base/component-composition-plan.md` 2번)은 살아남았고, 결론만 +"별도 프록시 타입을 만든다"에서 "Source 자체를 서브타입으로 승격한다"로 +바뀐 것. 이 반전 자체가 "타입이 없던 v1 습관을 재검토 없이 typed 재작성에 +그대로 가져오면 안 된다"는 더 큰 교훈의 구체적 사례이기도 함(사용자 회고, +`base/store-semantics.md` 참고) — `quadnomicon`에서 "설계가 왜 이렇게 +반전됐는가" 사례로 쓰기 좋음. diff --git a/.claude/archive/tag-hash-key-model-reversed.md b/.claude/archive/tag-hash-key-model-reversed.md new file mode 100644 index 0000000..8807bc0 --- /dev/null +++ b/.claude/archive/tag-hash-key-model-reversed.md @@ -0,0 +1,45 @@ +# [역전됨] Tag = 해시 파트 boolean DI 키(`[Tag "Name"] = true`) — array-part 값 객체로 대체됨 + +**역전 일시**: 2026-08-08 (세 번째 세션). **원 확정 일시**: 2026-08-07 +여덟 번째 세션(`base/tag-plan.md` 최초 작성, "상태: base — 전부 확정"). +**현재 유효한 설계**: `base/tag-plan.md`(전면 재작성됨)가 최종 소스. 이 +파일은 능동적으로 참고할 필요 없음(구현에 안 씀) — 왜 "태그 하나당 키 +하나"에서 "여러 태그를 조합하는 값 객체"로 넘어갔는지가 `quadnomicon` +소재로 가치 있어서 사유·원문을 통째로 보존해둔 것. + +## 역전된 사례 — 원래 무엇을 확정했었나 + +**값 모양**: `[Tag "Name"] = boolean | State` — 태그 이름 하나당 +해시 파트 키 하나, 값은 store-bind 가능한 boolean. + +**메커니즘**: `isHandlable`이 `[Tag "Name"]` 모양의 키를 매칭하는 +`TagHandler` 하나로 충분. `process(inst,k,v)`가 `v`가 참이면 `AddTag`, +거짓/`nil`이면 `RemoveTag`. **`retract` 불필요**로 결론 — "값이 뭐든 +(`true`/`false`/`nil`) 항상 같은 `TagHandler`가 이 키를 계속 담당하니 +핸들러 *타입*이 안 바뀐다"는 게 근거였음. + +## 왜 역전됐나 + +사용자가 실사용 시나리오를 제시하며 기각: 상호배타적인 스타일 상태 +(`btn1`/`btn2`/`btn3`류, 실제로는 20개까지도 가능)를 표현하려면 이 모델은 +**태그 이름 개수만큼 boolean 키를 각각 만들어야** 함 — 상태 전환마다 +여러 키를 동시에 갱신해야 하고, 스타일 조합(여러 태그를 합쳐 쓰는 것)도 +자연스럽게 표현이 안 됨. "하나의 값을 통째로 바꿔서 태그 집합을 바꾼다"는 +요구를 이 모델은 구조적으로 못 담음. + +## 대체 모델과의 비교 + +| | 구 모델(해시 파트) | 신 모델(array-part 값 객체) | +|---|---|---| +| 값 모양 | `[Tag "이름"] = boolean` | `Tag(...)`/`Tag.Merged(...)` 값 객체, array 슬롯에 놓임 | +| 상태 전환 | 태그 개수만큼 키 갱신 | 값 하나를 store-bind로 교체 | +| 조합 | 안 됨(키가 독립적) | `:Added`/`:Removed`/`Merged`로 조립 | +| retract | 불필요(핸들러 타입 안 바뀜) | 필요(값이 `nil`이 되면 핸들러 자체가 안 바뀜, 전체 삭제) — `Dispatch` 체인 메커니즘(`bind-system-plan.md` "Dispatch 체인" 절)과 맞물려 재설계됨 | + +부수적으로, 이 역전이 `Dispatch.process`/`retract`의 "이전 매치 핸들러 +추적" 문제(`pre-implementation-audit.md` 1-2번)를 실제로 파고드는 계기가 +됐음 — Tag가 재귀 재-dispatch(`Source`가 store-bind를 거쳐 +TagHandler로 위임)에 진입하는 첫 구체 사례가 되면서, "핸들러 타입이 안 +바뀌니 retract 불필요"라는 구 모델의 전제 자체가 신 모델에서 깨졌고, 그 +자리를 메우려다 `Dispatch.retractUnder`(체인 기반 retract 전파) 설계로 +이어짐. diff --git a/.claude/archive/tween-special-bind-key-reversed.md b/.claude/archive/tween-special-bind-key-reversed.md new file mode 100644 index 0000000..82775ab --- /dev/null +++ b/.claude/archive/tween-special-bind-key-reversed.md @@ -0,0 +1,68 @@ +# [역전됨] Tween = 우선순위 최상위 store-bind Dispatch 핸들러(`[Tween(key,tweenData...)] = storeValue`) — 값-레벨 `Tween` 래퍼로 대체됨 + +**역전 일시**: 2026-08-10. **원 확정 일시**: 2026-08-04(로드맵 인수인계 +라운드 전후, "확정된 방향: 트윈을 Store/반응 그래프 밖에 둔다" 최초 작성). +**현재 유효한 설계**: `research/tween-plan.md`(전면 재작성됨)가 최종 소스. +이 파일은 능동적으로 참고할 필요 없음(구현에 안 씀) — 왜 "Tween이 곧 +범용 store-bind 핸들러"였던 모델에서 "Tween은 PropertyHandler가 소비하는 +값-레벨 래퍼"로 넘어갔는지가 `quadnomicon` 소재로 가치 있어서 사유·원문을 +통째로 보존해둔 것. + +## 역전된 사례 — 원래 무엇을 확정했었나 + +**메커니즘**: Tween을 `[Tween(key, tweenData...)] = storeValue` 형태의 +특수 bind key로 제공. `k`는 무엇이든 받고 `v`가 Store(반응형 값)인 경우를 +잡아내는, **우선순위가 매우 높은 Dispatch 핸들러**. 처음 실행될 때는 +그냥 바인드로 필드를 쓰지만, 이후에는 store 값을 핸들해서 바뀔 때마다 +트윈을 처리: + +``` +[Tween(key, tweenData...)] = storeValue +``` + +핸들러 내부에서: (1) 라이프타임(`Connected`) 확인, (2) 사용자가 넘긴 +함수들을 거쳐 실제 값(`realv`) 계산, (3) `Dispatch.retractUnder`로 자기 +밑을 정리한 뒤 `realv`를 들고 `Dispatch.process(inst, k, realv)`를 재귀 +호출 — "store 바인드는 pluggable 바인드를 재실행하는 래핑"이라는 원칙의 +구체 사례. + +**override 정책**: 기본값 Cancel, 나머지 세 옵션(오버라이드/삭제 후 +재시작/끝점 이동 후 재시작)은 `retract(inst, k, v)`가 이전 값을 받아 +처리 — 이 부분은 새 모델에서도 그대로 유지됨(PropertyHandler 내부 +로직으로 위치만 이동). + +## 왜 역전됐나 + +`research/pre-implementation-audit.md` 우선순위1-1이 지적한 구조적 +모호함이 출발점 — 이 문서 전체에서 "`v`가 store인 값을 구독해 `realv`로 +재귀 process하는" 범용 메커니즘의 유일한 구체 예시가 항상 "Tween"으로만 +등장했음. 그런데 Tween(실제 애니메이션, override/cancel 정책)은 명백히 +더 좁고 별개인 기능이라, `Frame { BackgroundColor3 = store.color }`처럼 +애니메이션 없이 그냥 반응형으로 값만 바뀌길 원하는 가장 흔한 케이스가 +(a) 결국 이름은 "Tween"인 파일을 거쳐가며 "애니메이션 없음"으로 처리되는 +건지, (b) 각 핸들러가 범용 `Dispatch/StoreBind.luau` 유틸을 독립적으로 +써야 하는 건지 문서가 정하지 않은 상태로 남아있었음. + +2026-08-10 세션에서 사용자가 직접 제기한 재설계 방향("Tween 프리미티브를 +`V`에 넣는 식, 최종 Property가 알아서 `V`가 `isTween`이면 트윈 넣도록")으로 +해소 — State/Source 언랩(범용 StoreBind)과 "이 값이 트윈 대상인가" 판단을 +완전히 분리해, 후자를 Dispatch 우선순위 경쟁이 아니라 PropertyHandler +내부의 평범한 값 분기로 옮김. + +## 대체 모델과의 비교 + +| | 구 모델(우선순위 최상위 Dispatch 핸들러) | 신 모델(값-레벨 `Tween` 래퍼) | +|---|---|---| +| 매치 방식 | `isHandlable(inst,k,v) = isState(v)` — Tween이 범용 StoreBind 역할까지 겸함 | 범용 `StoreBind`가 State/Source를 언랩, `Tween` 여부는 `realv`를 받은 PropertyHandler가 직접 판단 | +| "애니메이션 없는 일반 반응형 바인딩"의 정체 | 불명확(이름이 Tween인 파일을 거쳐가는지 문서가 안 정함) | 명확함 — 그냥 `Dispatch/StoreBind.luau`, Tween과 완전히 무관 | +| 핸들러 타입 전환 | Tween↔프로퍼티 핸들러 사이에서 실제로 바뀜 → `retract`가 이 케이스의 대표 예시였음 | 항상 PropertyHandler 하나만 매치 → 이 `retract` 케이스 자체가 사라짐, 전환은 3-상태 릴레이션 슬롯으로 내부 처리 | +| 트윈 대상 값 타입 | Store 전체(`T`뿐 아니라 임의 반응형 값) | `Tween = {Value: T, ease...}` — `Value`는 plain `T`만, 반응성은 바깥 `:Compute`가 전담 | +| 진입 애니메이션 억제 | 별도 논의 없음 | 3-상태 슬롯(`RobloxTween\|true\|nil`)의 `hasBeenSet` 분기로 자동 해결 | + +부수적으로, 이 역전은 Tag가 이미 겪었던 것과 같은 종류의 단순화 — +"핸들러 *타입*이 실제로 안 바뀌면 `retract`가 필요 없어진다"는 결론을 +Tween에도 적용한 셈. Tag 역전(`archive/tag-hash-key-model-reversed.md`)이 +"핸들러 타입이 안 바뀐다는 전제가 실사용에서 깨졌다"는 방향이었다면, +Tween 역전은 반대로 "핸들러 타입을 애초에 안 바뀌게 재설계해서 전제 +자체를 성립시켰다"는 방향 — 같은 `retract`/핸들러-전환 문제를 서로 +반대 방향에서 접근한 두 사례로 대비해볼 만함(quadnomicon 소재). diff --git a/.claude/archive/ui-shorthand-roundsize-dropped.md b/.claude/archive/ui-shorthand-roundsize-dropped.md new file mode 100644 index 0000000..cc87560 --- /dev/null +++ b/.claude/archive/ui-shorthand-roundsize-dropped.md @@ -0,0 +1,44 @@ +# [기각됨] `RoundSize`(이미지 9-slice 라운드 트릭) 포팅 — 네이티브 `UICorner`로 대체되어 불필요 + +**기각 일시**: 2026-08-06. **현재 유효한 설계**: `base/ui-shorthand-plan.md` — +이 문서는 v1의 `RoundSize`가 왜 포팅 대상에서 빠졌는지, 그리고 그 판단이 +한 차례 잘못 일반화됐다가 정정된 이력을 보존해둔 것. 능동적으로 참고할 +필요 없음(구현에 안 씀) — `RoundSize`류 "네이티브 Instance가 나중에 생겨 +워크어라운드가 필요 없어진 사례"는 `quadnomicon` 소재로 가치 있음. + +## 무엇이었나 + +v1 `class.lua`가 지원하던 특수 키 `RoundSize = 16`(`ImageLabel`/ +`ImageButton` 전용) — `UICorner`가 아니라 이미지 자체를 9-slice로 잘라 +둥글게 보이게 만드는 트릭(`round.SetRound()`). `UICorner`/`UIPadding`/ +`UIScale` 자동 생성 숏핸드(`Corner`/`PaddingAll`/`Scale`, 현재 +`base/ui-shorthand-plan.md`가 이어받은 기능)와 겉보기엔 "인라인 리터럴 값 +하나로 GUI를 꾸민다"는 카테고리가 비슷해 보이지만, **메커니즘 자체가 +완전히 다름**(하나는 별도 Instance 생성, 하나는 이미지 처리) — 이 문서가 +쓰인 이유가 바로 이 둘을 혼동하지 않기 위함. + +## 기각 이유 + +`RoundSize`는 **당시 Roblox에 `UICorner` 같은 네이티브 구현체가 없었기 +때문에** 존재하던 워크어라운드였음. 지금은 `UICorner`가 안정적인 네이티브 +Instance라 이미지 대상에도 그냥 실제 `UICorner`를 붙이면 되므로, 이미지를 +9-slice로 잘라 둥글게 "보이게" 만드는 트릭 자체를 그대로 포팅할 이유가 +없음 — **포팅 안 함으로 확정**. + +## 왜 archive에 남기나 — 한 차례 과잉일반화됐다가 정정된 이력 + +`RoundSize` 하나를 드롭하기로 한 판단이, 초안 작성 과정에서 실수로 +**"UICorner가 네이티브가 됐으니 Corner/PaddingAll/Scale 숏핸드 자체가 +불필요하다"는 훨씬 넓은 결론으로 잘못 일반화된 적이 있었음**("이전 정리 +('포팅 불필요')는 오해였고 정정함"). 사용자가 직접 반박해 정정됨: +`UICorner`가 네이티브 Instance가 됐다는 사실은 "이미지를 트릭으로 둥글게 +보이게 할 필요가 없어졌다"는 것만 의미할 뿐 — `UIScale`/`UIPadding`류가 +**여전히 부모에 Parent해야 하는 별도 Instance**라는 구조적 사실 자체는 +전혀 안 바뀌었으므로, `Corner`/`PaddingAll`/`Scale` 숏핸드(현재 +`UICorner`/`UIPadding`/`UIScale`)의 존재 이유는 그대로 유효. + +**교훈(재사용 가능)**: "네이티브 Instance가 생겼다"는 사실 하나로부터 +"관련 숏핸드 전체가 불필요해졌다"를 성급히 일반화하지 말 것 — 워크어라운드가 +드롭되는 이유(네이티브 대체재 등장)와 편의 숏핸드가 필요한 이유(별도 +Instance를 만들어 Parent해야 하는 구조적 번거로움)는 서로 다른 축이라, +하나가 해소됐다고 다른 하나도 자동으로 해소되는 게 아님. diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md new file mode 100644 index 0000000..f17e18a --- /dev/null +++ b/.claude/base/architecture.md @@ -0,0 +1,278 @@ +# quad-v2 전체 아키텍처 (현재 상태 요약) + +**상태**: base — 횡단 결정의 최종 상태 요약. 특정 기능 plan이 아니라 프로젝트 +전체에 걸친 결정이라 완료 개념 없음. 근거가 된 원본 브레인스토밍은 +`.claude/initreq/raw-userinput.md`(안 옮기고 그대로 둠 — 이 문서들로 나누기 전의 +raw chain-of-thought 백업 역할). 현재 v1 구조는 `reference/quad-v1-architecture.md`, +비교 리서치는 `reference/comparison-fusion-vide.md`, `base/lifecycle-pattern.md` 참고. + +## 한 줄 요약 + +quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblox UI 렌더러라는 +정체성은 유지하되, 내부를 확장 가능하게 재구현한다. 프로덕트 하나를 빨리 내는 게 +목표가 아니라 코드 퀄리티/지속 가능성이 목표 — 빠른 이터레이션보다 정확성이 +우선. + +## 확정된 결정 + +1. **DOMless 유지, 하지만 pluggable 하게.** 가상 DOM 없이 즉시 Roblox Instance를 + 만드는 기존 방식은 유지. 대신 key/value 바인드 디스패치, 렌더 백엔드를 + pluggable하게 만들어 확장성 확보(아래 4, 5번). +2. **Class는 이제 "특정 상태의 store를 받는 함수"** — v1의 `Class.Extend()`류 + OOP 스타일(메서드 체이닝, Getter/Setter) 대신 함수형이 기본. 체이닝은 store + 바인드처럼 정말 체인이 자연스러운 곳에만(`:` 문법) 남김. 타입 작성 난이도가 + OOP 스타일에서 너무 커진다는 게 이유. +3. **복사(clone) 구현 지양, 팩토리 함수로 대체.** v1의 metatable 체이닝(1-필드 + 테이블을 계속 쌓는 방식, `reference/quad-v1-architecture.md` 참고)은 폐기. + store 바인드에 대한 변경은 "전체 변경"으로 간주(UB 아님, 문서화된 의미론) — + 부분 복사/오버레이가 필요하면 팩토리 함수로 필요한 곳만 명시적으로 복사. +4. **PA님 스타일 DI 키 계속 지원**: `[Attribute "Name"]` 같은 특수 바인드 키, + store 컴퓨티드 바인드도 가능해야 함(`retract`, 구 cleanup, + `base/lifecycle-pattern.md` 참고). **[정정, 2026-08-08 세 번째 세션]** + `Tag`는 더 이상 `[Tag ""] = true` 해시 파트 DI 키가 아님 — array-part + 값 객체(`Tag(...)`)로 재설계됨, `base/tag-plan.md` 참고 + (`archive/tag-hash-key-model-reversed.md`에 구 모델 보존). +5. **id 기반 전역 조회 폐지, Tag 시스템으로 대체.** v1의 `Store.GetObject(id)`/ + `Frame "id" {}`류는 더 이상 없음 — "id 매핑이 비현실적"이라는 게 이유. + 네임스페이싱 문제는 있지만 별도 네임스페이스 개념을 추가하면 라이브러리 + 복잡도가 너무 올라간다고 판단 — 당장은 `CollectionService` 그대로 사용. **대신 + Ref가 도입됨** — 단 Ref의 용도는 "id로 조회"가 아니라 "외부에서 이미 + 관리되고 있는 instance를 quad로 점진적으로 마이그레이션/래핑하기 위해 + 직접 참조를 얻는 것"(`base/bind-system-plan.md`의 Ref 절 참고) — 둘을 + 혼동하지 말 것. + - **2026-08-04 6차: 네임스페이싱 충돌을 심각하게 안 보는 이유 확정.** + 충돌을 피해야 하는 단위는 보통 컴포넌트 단위로 나오고, 그 경우는 Ref로 + 직접 참조를 얻으면 되므로 태그 자체의 전역 네임스페이스가 굳이 필요 + 없음. 태그는 원래 주로 스타일링(스타일시트 셀렉터) 용도인데, 스타일시트는 + 적용 위치가 트리 상위에 존재해야 하고 사용자가 직접 그 위치에 심어야 + 하는 등 스크립팅으로 구성하기 어려워 quad 같은 UI 라이브러리에서는 잘 + 안 쓰는 접근 — 그래서 스타일시트 대신 modifier kit을 제공하는 것(아래 + 7번 항목의 modifier 우선순위 규칙 참고). +6. **함수지향 디폴트, `:` 체이닝은 예외적으로만.** 스토어 바인드처럼 체인이 정말 + 편한 경우만 `:` 사용, 나머지는 외부 함수가 인스턴스를 인자로 받는 모양. +7. **Style(Default) 시스템 폐기.** 대신 modifier(spread되는 값, `...`으로 + 풀리는 것)를 지향 — 함수형 modifier가 store 바인드를 받을 수도 있음. + (초기 근거였던 "Roblox 자체 스타일시트를 쓰는 게 낫다"는 6차 라운드에서 + 갱신됨 — 위 5번 항목의 6차 추가분 참고: 스타일시트는 적용 위치 제약과 + 스크립팅 난이도 때문에 오히려 안 쓰기로 하고 modifier kit으로 대체함.) + - **2026-08-04 세션: modifier 메커니즘 전체 확정, 상세는 + `base/modifier-plan.md`로 분리.**[정정: `research/`에서 `base/`로 + 승격됨] 요지만: 런타임 pluggable 핸들러가 + 아니라 디스패치 이전에 정적으로 flatten되는 값(핸들러 레지스트리 미참여, + CSS cascade 문제 회피). Merge 우선순위는 "배열 순서상 나중 modifier가 + 우선"과 "인라인 키는 modifier보다 무조건 우선"이라는 독립된 두 규칙(Lua + 테이블 리터럴이 배열/해시 파트 간 소스 순서를 보존 안 하므로 하나로 합칠 + 수 없음). 값은 immutable — 체이닝 메소드(`:FontSize(...)`류)는 항상 + `table.clone` 후 반환, 원본 mutate 금지(형제 서브트리 오염/재렌더 드리프트 + 방지, 비용은 무시 가능한 수준으로 확인됨). +8. **특수 이벤트는 특수 플러깅으로.** `PropertyChangedSignal`, `PropertyChangedEvent ""` + 같은 것들은 일반 이벤트 바인드가 아니라 pluggable 바인드 핸들러 중 하나로 + 구현(`base/bind-system-plan.md`). +9. **Tracker 미구현.** v1의 소스 변경 감지 자동 재렌더 기능(hot-reload watcher, + 실제로는 `.claude/initreq/quad/src/tracker.lua` — v1에서도 이미 `exports.lua`에 + 연결 안 된 죽은 코드였음, `reference/quad-v1-architecture.md` 참고)은 렌더 + 라이브러리 범위 밖으로 판단. 스토리북 구현체(https://ui-labs.luau.page/docs/getstarted)가 + 이미 존재하므로 대체. +10. **lang 모듈 미구현, 분리.** 로케일/문자열 처리는 리액터블 라이브러리와 + 별개 라이브러리로 존재해야 함(v1 `lang.lua`의 전역 스코프 버그 등은 + `reference/quad-v1-architecture.md` 참고 — 애초에 반면교사). +11. **커스텀 Signal 클래스 미구현.** 콜백 정도로 이벤트 바인드 뒤에 함수를 + 넣는 것만으로 충분하다고 판단. (이전 초안엔 "rbvm의 Signal이 재사용 + 가능해 보여 상충한다"는 메모가 있었으나 2026-08-04 검증 라운드에서 최종 + 확정으로 재확인 — 더 이상 열린 질문 아님.) +12. **멀티 타겟(pluggable 백엔드) — 특히 GTK 지원까지 염두.** Roblox 전용 렌더 + 기술(react.lua, Fusion)은 결국 외부 개발자 유인이 없어 발전이 더딜 거라는 + 문제의식. 결과적으로 `quad-base`/`quad-roblox`로 나뉨(5차 라운드에서 확정된 + 정확한 패키지 이름, 아래 "구현 착수" 절 참고) — base가 가상돔 없이도 + 프로바이더 패턴으로 백엔드를 받는 인터페이스만 정의하고, 실제 Roblox 구현은 + `quad-roblox`가 담당. +13. **모듈은 기본 싱글톤, `New()`는 나중에.** 한 Lua 스레드에서 Roblox/비-Roblox + 프로바이더를 동시에 쓸 일이 거의 없을 거라 판단 — 필요해지면 그때 `New()` + 추가. **메커니즘도 이미 정해짐(2026-08-08 두 번째 세션, 새 설계 아니라 + 기존 패턴의 자연스러운 연장)**: v1처럼 `require`를 감싸 `Init(QuadId?)`로 + 격리 인스턴스를 만드는 방식은 안 씀 — 대신 지금 있는 "팩토리가 + `BaseModule`을 뮤테이션" 패턴(14번) 그대로, `New()`가 생기면 매번 새 + `BaseModule` 테이블을 만들어 팩토리로 채우는 것뿐. Dispatch의 handler + 레지스트리를 포함해 지금 module-level state로 사는 모든 것(`_initializedBy` + 마커, Dispatch 레지스트리 등)이 자동으로 테이블별 스코핑됨 — 상세 근거는 + `base/bind-system-plan.md`의 "Dispatch는 프리미티브가 아니다" 절. +14. **pluggable 초기화는 팩토리 함수로.** rbvm처럼 네임스페이스 하나하나 수동 + init 하는 방식(`base/lifecycle-pattern.md` 5번 항목 참고)은 피하고, + `InitRoblox(Module)` 같은 팩토리 함수가 생성된 모듈을 뮤테이션하는 도구를 + 주는 방식. + +## 구현 착수: 소스 트리 구조 확정 (2026-08-04, 5차 라운드) + +**상태**: 소스 트리 레이아웃과 `quad-base`/`quad-roblox` 패키지 경계 확정 — +아래가 다음 세션에서 실제로 만들 구조. 지금은 문서 확정까지만, 실제 +폴더/`wally.toml`/`project.json` 스캐폴딩은 다음 세션. + +**패키징 방식(모노레포, RbxUtil 선례 채택)**: 최종적으로는 여러 개의 독립 +wally 패키지로 나누고 싶지만, 지금 Luau 툴링(특히 wally로 설치된 패키지의 +타입 정보 단절·`luau-lsp`의 심볼릭 링크 해석 문제 — 최근 `luau-lsp 1.63.0` +에서야 수정됨)이 아직 불안정해서 **당장은 모놀리식**으로 감. `Sleitnick/ +RbxUtil`이 정확히 이 패턴(루트 하나로 통합 개발/테스트, 서브폴더마다 자체 +`wally.toml`로 독립 퍼블리시)을 쓰는 선례라 그대로 채택. `.luaurc`의 +`aliases`는 **런타임 require에서 아직 엔진이 지원 안 함**(Roblox 스태프가 +지원 예정이라고만 밝힌 상태, 2026-01 기준) — 그래서 alias는 편집기 +자동완성/타입체크용으로만 곁들이고, 실제 크로스패키지 require는 상대경로로 +쓴다. 나중에 실제로 레포를 쪼갤 때는 Rojo `project.json`의 트리 매핑 규칙만 +유지하면 되고, require는 그 시점에 한 번 기계적으로 바꾸는 정도로 감수. + +**패키지 경계**: `quad-base`는 다른 렌더 백엔드(GTK 등, 항목 12 참고)에서도 +재사용 가능해야 한다는 전제 — Store/State/Source 온톨로지+전파뿐 아니라 +**pluggable 디스패치 엔진 자체도 "인터페이스"로 base가 소유**한다(엔진마다 +큰 구현을 중복하지 않기 위함 — rbvm이 relation을 하나로 통합하려 했던 것과 +같은 동기). `quad-roblox`는 그 인터페이스의 **실제 구현체**만 제공. + +``` +quad/ +├── .luaurc # @quad-base, @quad-roblox alias (편집기 경험용, 런타임 비의존) +├── default.project.json # 루트 통합 개발/테스트용 Rojo 프로젝트 +├── quad-base/ +│ ├── wally.toml +│ └── src/ +│ ├── Source.luau # 값의 근원, 단일 지점. Source가 State를 구조적으로 만족(`__index` 델리게이션) +│ ├── State.luau # 캐시만 하는 non-owning 핸들, state(state) 분기, `:With`/`:Compute`/`:Observer`(등록 즉시 1회 실행) 전부 여기 소속 +│ ├── Store.luau # source 집합체, dot-access로 Source 그대로 반환 +│ ├── Blocker.luau # 값 기반 emit 지연/합치기(`base/blocker-plan.md`), State/Source와 밀접 연관돼 같은 위치 +│ ├── Modifier.luau # flatten-before-dispatch, immutable 체이닝, 제네릭 `__index` 필드 setter 합성 + `:Apply`/`:Peek`/`Overridden`(`base/modifier-plan.md`) +│ ├── Tag.luau # 값 타입+immutable clone 체이닝(`Tag(...)`/`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), CollectionService 글루는 quad-roblox Handlers/Tag.luau(`base/tag-plan.md`, 2026-08-08 세 번째 세션) +│ ├── Tween.luau # 값 타입만(`Tween(opts)` 팩토리, `isTween`/`TweenTag`) — 엔진 무관, 독립 Dispatch 핸들러 아님. 실제 애니메이션 처리는 quad-roblox Handlers/Property.luau 내부 분기(`research/tween-plan.md`, 2026-08-10 세션 재설계) +│ ├── Effect.luau # `Effect(fn, state?)` — state 없으면 설치1회+leaf사망시 정리, 있으면 State.Observer를 조합해 재실행(`base/effect-plan.md`) +│ ├── Dispatch/ +│ │ ├── init.luau # process/retract 엔진, isHandlable 우선순위 스캔, `chains`(inst,k별 핸들러 체인)+`retractUnder`(`bind-system-plan.md` "Dispatch 체인" 절, 2026-08-08 세 번째 세션) +│ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process/retract) +│ │ ├── StoreBind.luau # store 값 재귀 재실행 로직(범용, 엔진 무관) +│ │ ├── Leaf.luau # (i:number, v=Ref/Observer/PreRef) children-array leaf 매칭 Handler, StoreBind와 같은 층위(범용/엔진무관, 2026-08-08 두 번째 세션 확정) +│ │ └── Slot.luau # add/remove/clear 재조정 로직(추상 자식 참조 기준) +│ ├── Relate.luau # inst를 weak 키로 하는 범용 릴레이션(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`), 비싱글톤 생성자(`base/relate-plan.md`) — 구 PerInstanceState/perInstanceState 대체 +│ ├── LifetimeHandle.luau # `bindLifetime(inst,value)`/`canExecute(inst,value)` 탑레벨 함수 "인터페이스"(타입/계약만), 내부는 Relate 사용(`base/lifecycle-pattern.md`) +│ ├── Ref.luau # 범용 값 박스(.Value 읽기 + :Set()/:Callback()/:Wait() 셋), `Ref(default)`를 children 배열 숫자 슬롯에 직접 놓으면 (v=Ref) 매치 핸들러가 바인드 — 별도 CreatedRef 래퍼 없음 +│ ├── PreRef.luau # Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 호이스팅되는 pre-pass 특수화(별도 파일, `bind-system-plan.md` "PreRef 신설" 절, 2026-08-07 여섯 번째 세션에서 분리) +│ └── init.luau +└── quad-roblox/ + ├── wally.toml + └── src/ + ├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) + ├── LifetimeHandle.luau # bindLifetime/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetStrong으로 gcconn/gchold 저장(`base/lifecycle-pattern.md`). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용) + ├── Handlers/ + │ ├── Property.luau # 일반 프로퍼티 세팅 + `isTween(realv)` 분기(3-상태 릴레이션 슬롯 `RobloxTween|true|nil`, hasBeenSet 억제, override 정책) — 구 `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러)는 폐기(`archive/tween-special-bind-key-reversed.md`) + │ ├── Event.luau # ReflectionService 기반 자동 판별 + │ ├── OnChange.luau # `OnChange(name)` DI 키 팩토리+Handler, `GetPropertyChangedSignal` 바인딩(`base/onchange-plan.md`, 2026-08-10 세션) + │ ├── Attribute.luau + │ ├── Tag.luau # CollectionService 글루만(process/retract) — 값 타입/API는 quad-base Tag.luau(`base/tag-plan.md`) + │ ├── Slot.luau # base Slot 재조정 로직의 실제 적용/해제(Instance Parent 조작) + │ └── InstanceChild.luau # k:number, v:Instance — 중첩 인스턴스 자식(예: Frame { Frame {} }) + ├── Animate.luau # `Animate(condOrOpts, opts?)` 편의 콤비네이터 — `:Apply`/`:Compute`/`Tween{...}` 조합, base 프리미티브 아님(`research/tween-plan.md`) + ├── DI/ + │ └── init.luau # 제네릭 생성자 + ~25개 정적 필드(UIInstances) + └── init.luau +``` + +**남은 것**: Slot 코어 로직의 정확한 API(`research`→`base` 승격된 +`slot-plan.md` 참고)와 각 파일의 정확한 함수/타입 이름은 구현 단계에서. +Tween/existing-instance-bind는 여전히 `research/`에 남아있고 이 구조 확정을 +막지 않음(`purity-and-effects-plan.md`는 이미 `base/`로 승격 완료). + +## 코드 스타일 — 네이밍 케이싱 (2026-08-08 두 번째 세션 신설) + +지금까지 각 문서가 예시 코드를 쓰며 암묵적으로 따라온 패턴을 사용자가 +명시적 규칙으로 정리해달라고 요청 — 실제로 지금까지 나온 모든 이름이 +예외 없이 따르는 규칙이라 새로 뭘 바꿀 필요는 없고, 그냥 문서화만: + +- **대문자 시작(PascalCase)** — 다음 세 가지, 공통점은 전부 **어떤 + 프리미티브 타입 자신의 공개 어휘**라는 것: + 1. 프리미티브 타입 생성자, `Type(args)` 스타일: `Source(default)`/ + `Ref(default)`/`Store({defaults})`/`Modifier()`/`Relate()`/ + `Effect(fn, state?)`/`PreRef(default)`. + 2. 그 인스턴스의 콜론 메서드: `state:Get()`/`:With(...)`/`:Compute(fn)`/ + `:Observer(fn)`/`:Apply(factory)`/`:Peek(key)`, `source:Set(v)`/`:Emit()`, + `ref:Set(v)`/`:Callback(fn)`/`:Wait(thread?)`, `observer:Subscribe()`/ + `:Unsubscribe()`, `relate:SetWeak(...)`/`:GetWeak(...)`/`:SetStrong(...)`/ + `:GetStrong(...)`, `mod:FontSize(...)`(필드 setter 체이닝). + 3. 프리미티브 타입 자신의 네임스페이스에 달린 정적 결합 함수 — + `Modifier.Overridden(mod1, mod2, ...)`가 유일한 현재 사례. 콜론 메서드는 + 아니지만(여러 Modifier를 동등한 인자로 받아야 해서 self 하나로 안 + 됨) `Modifier` 타입 고유의 공개 연산이라는 점에서 1/2과 같은 부류 — + `Modifier()` 생성자와 같은 이유로 대문자. +- **소문자 시작(camelCase)** — 특정 프리미티브 타입 하나에 안 묶이고 여러 + 타입을 넘나드는 범용 유틸(`isState`/`isSource`/`isRef`/`isPreRef`/ + `isModifier`/`isObserver`/... `Brand` 절), 생명주기 게이트(`canExecute`/ + `bindLifetime`, `base/lifecycle-pattern.md`), 그리고 **프리미티브가 + 아닌** 내부 엔진/레지스트리의 네임스페이스 멤버(`Dispatch.process`/ + `getHandler`/`addHandler`/`drive`, `Brand.set`/`get`) — 이 셋은 "타입 + 고유의 어휘"가 아니라 여러 타입에 걸쳐 쓰이거나(`isX`류) 프리미티브 + 자체가 아닌 것(Dispatch/Brand는 `Type(args)` 생성자가 없는 내부 엔진)의 + 구성원이라 PascalCase 대상이 아님. Handler 계약 필드(`isHandlable`/ + `priority`/`process`/`retract`)도 여기 속함 — 이건 애초에 "함수"라기보다 + 구현체가 채워 넣는 구조체 필드. +- **경계 판단 기준**: 새 이름을 지을 때 "이게 특정 프리미티브 타입 하나의 + 전용 소유물인가?"로 물으면 됨 — 그렇다면 대문자(생성자/메서드/그 + 타입의 정적 결합 함수), 아니면(범용 유틸이거나 프리미티브가 아닌 엔진 + 소속) 소문자. `Dispatch`/`Brand`가 프리미티브가 아닌 이유는 + `base/bind-system-plan.md`의 "Dispatch는 프리미티브가 아니다" 절/ + `base/store-semantics.md`의 "세 번째 카테고리 — Handler" 절 참고. + +## 테스트 전략: quad-base용 최소 mock (2026-08-04) + +**결정**: quad-base 테스트는 Vide 선례(`initreq/vide/test/mock.luau`, 약 +300줄)를 따라 최소한의 mock으로 감 — parent/children 트리 + 타입 검증 없는 +property bag + property별 변경 시그널 정도만 흉내내고, `IsA()`/클래스별 +프로퍼티 스키마/`WaitForChild`/`DataModel` 같은 건 안 만듦. 순수 `luau` CLI로 +Studio/엔진 없이 테스트(Vide가 실제로 이렇게 CI에 물려놓음) — Fusion처럼 +Studio 안에서만 도는 방식은 채택 안 함. 근거: quad-base 코어(Store/State/ +Source/Modifier/Slot, 디스패치 엔진)는 이미 `inst`를 `any`로 취급하고 +Instance 특정 동작을 전혀 참조하지 않도록 설계돼 있어(`bind-system-plan.md` +"inst가 항상 Roblox Instance일 필요는 없음" 절), mock이 실제 Roblox 충실도를 +가질 이유가 없음. + +**스코프는 "정적 디버깅"으로 한정** — **사용자 확정**: mock으로 확인하려는 +건 한 시점의 렌더 결과(정적 스냅샷)지, 시간에 따라 변하는 동적 동작(Tween +애니메이션, 타이밍 등)이 아님. 그래서 지금 단계 mock엔 시간 기반 핸들러를 +흉내낼 계획이 없음. + +**"quad-roblox로 작성한 컴포넌트가 mock에서도 그대로 돌아가야 한다"는 요구는 +없음** — **사용자 확정**("이건 꼭 지켜질 필요까지 있진 않아, 단순하게 가도 +됨"). mock은 quad-roblox의 실제 핸들러(ReflectionService 기반 이벤트 판별, +CollectionService 태그 등)를 흉내낼 필요가 없고, quad-base 자체 로직(디스패치 +엔진, Store/State/Source, Modifier, Slot)만 검증하면 충분 — quad-roblox +개발과는 무관해도 됨. + +**백로그**: 나중에 범용 렌더 결과 디버깅 도구로 키우고 싶어지면(정적 +스냅샷을 넘어 Tween mock 같은 동적 동작까지 포함) 그때 스코프를 넓히는 +걸로 — 지금은 quad-base 테스트 전용 최소 mock까지만(`CLAUDE.md` 백로그 +참고). + +## Store/State/Source 온톨로지 — 확정됨 (요약) + +Store는 source(실제 값이 존재하는 단일 지점) 집합체이고, `store.key`로 +접근하면 이미 만들어져 있는 Source 객체를 그대로 반환하거나(defaults로 +Store 생성 시 미리 만들어둔 경우), 아직 없으면 그 자리에서 만들어 저장한 +뒤 반환한다(별도 wrapper 없음 — **[2026-08-06 후속 세션 정정, 2026-08-07 +추가 정정]** 원래 "매번 새 State를 감싸 반환"이었으나, `Source`가 구조적으로 +`State`를 만족하도록 재구성되며 wrapper 계층 자체가 불필요해짐. 이후 +"Store 생성 시 전부 eager하게만 만들어진다"로 한 차례 더 정리됐다가, Luau +타입이 런타임에 강제되지 않아 defaults 없이 만든 키를 나중에 `:Set()`하면 +크래시난다는 점이 지적돼 lazy `__index`+저장 생성도 같이 필요함이 확인됨 — +상세는 `base/store-semantics.md` "Source가 State를 만족함" 절). 전파는 +push-invalidate(신호만)/ +pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬드 +의존성 중복 재계산 문제가 풀림. State는 쓰기 대상이 아니고, 값을 쓰는 +경로는 `source:Set(value)`(Source가 State보다 넓은 인터페이스를 가짐 — +`:Get()`/`:With`/`:Compute` 위에 `:Set`/`:Emit` 추가; [정정, 2026-08-07] +읽기는 `:Get()` 하나로 통일 — `.value` 표기는 Ref 전용으로 좁혀짐). 값 하나만 +다룰 땐 Store와 별개인 가벼운 `Source` 프리미티브를 독립적으로도 씀. +`store.key` dot-access를 타입 추론 1급 경로로 삼는 것도 3차 라운드에서 +정식 확정됨 — **더 이상 열린 질문 아님**, 남은 건 정확한 API 이름뿐. +상세는 `base/store-semantics.md`의 "Source가 State를 만족함" 절과 +`base/bind-system-plan.md`의 "Store/State/Source 온톨로지" 절 참고. + +## 아직 미정 (research/로 분리됨) + +Tween 플러깅, 이미 생성된 인스턴스에 대한 바인드 — `.claude/research/` 각 +문서 참고, 전체 색인은 `.claude/README.md`. 바인드 디스패치/Slot/모듈 +라이프사이클/Modifier/컴포넌트화(컴포넌트 경계 modifier/Ref 전달 포함)는 +위 "구현 착수" 섹션대로 확정되어 `.claude/base/`로 승격됨 +(`bind-system-plan.md`/`module-lifecycle-plan.md`/`slot-plan.md`/ +`modifier-plan.md`/`component-composition-plan.md`). diff --git a/.claude/base/attribute-plan.md b/.claude/base/attribute-plan.md new file mode 100644 index 0000000..f3fa874 --- /dev/null +++ b/.claude/base/attribute-plan.md @@ -0,0 +1,79 @@ +# Attribute 특수 키 — 타입 파라미터화, `SetAttribute(name, nil)` 네이티브 지우기 + +**상태**: base — 메커니즘/`None`/`retract` 동작뿐 아니라 타입 파라미터화도 +**둘 다 채택으로 확정**(2026-08-09 열한 번째 세션, 아래 참고). `[Attribute +"Name"]` DI 키의 존재 자체는 `architecture.md` +4번 항목에서 이미 확정. UICorner 숏핸드/Tween처럼 별도 전용 문서가 없던 걸 +2026-08-07 여덟 번째 세션에 메꿈("1 프리미티브 1 파일" 관례를 Tag/Attribute +에도 적용해야 한다는 사용자 지적) — `bind-system-plan.md`의 "Attribute +특수 키 — 타입 파라미터화" 절(2026-08-06 신설) 내용을 그대로 옮기고, 오늘 +논의한 `None`/`process`/`retract` 동작을 추가. + +## 문제 — 타입 있는 값이라 Luau가 좁혀줄 방법이 필요 + +Roblox Attribute는 Instance/Tag와 달리 실제로 **타입이 있는 값** +(string/boolean/number/Color3/UDim/UDim2/Vector2/Vector3/CFrame/Instance +참조 등 제한된 프리미티브 집합, 테이블 등 복합 타입은 지원 안 함)이라, 그냥 +`[Attribute "name"] = value`로 두면 `value`의 타입을 Luau가 좁혀줄 방법이 +없음. 커스텀/복합 데이터(테이블 등)는 애초에 Attribute가 지원을 안 하므로 +Ref(직접 참조 획득) 쪽으로 빠지는 게 맞고, Attribute는 프리미티브 전용으로 +남기면 된다는 게 사용자 판단 — Value 오브젝트가 역사적으로 Attribute의 +대안(테이블/참조를 담는 용도)으로 나온 배경이지만, 지금은 Roblox Attribute가 +Instance 참조 타입도 지원해서 `ObjectValue` 없이도 Ref 용도로 Attribute를 +그대로 쓸 수 있다는 점을 사용자가 짚음(`research/debug-tooling-plan.md`의 +"Value 오브젝트 기각, Attribute로 확정" 결정과 같은 방향 — Instance 타입 +지원까지 감안하면 그 결정의 근거가 한층 더 탄탄해짐). + +**확정(2026-08-09 열한 번째 세션) — 둘 다 채택**: +- `[Attribute<> "name"] = true` (리터럴 또는 store-bind 값) — + 제네릭 파라미터로 타입을 명시하는 제네릭 생성자 스타일. 기본/범용 경로. +- `[BooleanAttribute "name"] = true` — 타입별로 이름이 다른 정적 생성자 + 패밀리(`StringAttribute`/`NumberAttribute`/`Color3Attribute`/ + `InstanceAttribute` 등). 실사용 빈도가 높은 몇 개만 지름길로. + +**근거**: 이미 확정된 DI 인스턴스 생성 패턴(`bind-system-plan.md` "인스턴스 +생성 / 이벤트 네이밍 인체공학" 절)과 구조적으로 똑같은 문제라 같은 결론 +재사용 — `new(className)` 제네릭 생성자 + 자주 쓰는 ~25개는 +정적 필드로 미리 바인딩했던 것과 동일한 절충. **내부 구현은 완전히 +동일**(같은 Handler를 타고, 같은 프리미티브) — 둘 사이 차이는 순전히 +호출부가 타입을 어떻게 명시하느냐(제네릭 파라미터 vs 이름)뿐이라 어느 +쪽을 쓰든 런타임 동작에 차이 없음. + +**[실측 필요, M0/M10]** `[Attribute<> "name"] = value`처럼 DI +키 제네릭 파라미터로 `=` 뒤 `value`의 타입까지 실제로 좁혀지는지는 +미검증 — Luau 솔버가 이 조합을 못 풀면 `value`가 `any`로 남을 수 있음. +단, **타입 추론이 안 되더라도 런타임 동작에는 영향 없음**(순수 정적 +타입체크 실패일 뿐, `SetAttribute` 호출 자체는 항상 정상 작동) — 안 +되면 `BooleanAttribute` 같은 정적 타입 패밀리 쪽이 사실상 유일하게 +믿을 수 있는 정적 체크 경로가 됨. + +## 메커니즘, `None`, `retract` — 전부 확정 (2026-08-07 여덟 번째 세션) + +타입 파라미터화 이름과 무관하게 런타임 동작은 확정: + +- `process(inst, k, v)` — `inst:SetAttribute(name, v)`가 사실상 전부. + **Attribute는 `None`의 가장 깔끔한 사례** — Roblox API 자체가 + `SetAttribute(name, nil)`을 "그 Attribute 엔트리를 지운다"는 뜻으로 + 네이티브 지원하므로, `None → nil` 재디스패치(`base/bind-system-plan.md`의 + `None` 센티널 절)가 도착했을 때 handler가 **아무 특별 처리도 없이** + `inst:SetAttribute(name, nil)`을 그대로 호출하면 끝 — UICorner 숏핸드처럼 + "만들어둔 자식을 수동으로 찾아 지우는" 로직조차 필요 없음. +- **`retract` 불필요** — Tag와 같은 이유: 값이 뭐든(실제 값/`nil`) 항상 + 같은 `AttributeHandler`가 이 키를 계속 담당(핸들러 *타입*이 안 바뀜). + `retract`가 의미 있는 유일한 패턴("매치되는 핸들러 타입 자체가 바뀜", + `Tag(...)`↔`nil`이 실사례 — 2026-08-10 세션부터 Tween은 더 이상 이 + 패턴의 예시가 아님, `research/tween-plan.md`)에 해당 안 함 — + `bind-system-plan.md` "확정된 디스패치 모델" 절이 한때 Attribute도 + retract 필요 예시로 들었던 걸 여기서 바로잡음. +- store-bind 가능(일반 프로퍼티와 동일하게 취급, `Store`/`State` + 값도 받음). + +## 패키지 배치 + +UICorner 숏핸드/Tween/Tag와 같은 판단 재사용 — `quad-roblox` 코어에 직접 +포함, 별도 opt-out 패키지로 안 쪼갬. + +## 열린 질문 (`.claude/question.md`에도 취합) + +- 타입 파라미터화 이름(`Attribute` 제네릭 vs `BooleanAttribute`류 정적 + 패밀리 vs 절충) — 위 "문제" 절 참고, 다음 세션 사용자 판단 필요. diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md new file mode 100644 index 0000000..08dcaec --- /dev/null +++ b/.claude/base/bind-system-plan.md @@ -0,0 +1,2203 @@ +# Bind 시스템 — pluggable key/value 핸들러 (base로 승격됨) + +**상태**: base — 핵심 디스패치 모델(`process`/`retract`, 핸들러 4종 계약, +Signal 미채택, Ref 역할)과 소스 트리 상 패키지 경계(디스패치 엔진은 +`quad-base`가 인터페이스로 소유, `quad-roblox`는 실제 구현만)까지 전부 +2026-08-04 세션에서 확정되어 `research/`에서 승격됨(`base/architecture.md`의 +"구현 착수: 소스 트리 구조 확정" 절 참고). 남은 건 세부 시그니처(dependency +array API) 뿐 — 구현 단계에서 자연히 정리됨. 원본: +`.claude/initreq/raw-userinput.md` +"key와 value에 대한 바인드 연산은 pluggable 하도록 구성하기" / "스토어는 스토어를 +저장 가능한가" / "Ref는 고민중" 절. v1의 문제점은 `reference/quad-v1-architecture.md` +("ProcessQuadProperty" 하드코딩 디스패처), 참고 패턴은 `.claude/initreq/tbox` +(레지스트리)와 Fusion/Vide 비교는 `reference/comparison-fusion-vide.md` 참고. + +## 문제 + +v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는 +숫자 키(children/style) vs 문자열 키(prop/event) vs `__type` 태그 테이블 +(register/linker/style)을 하드코딩된 if/elseif 체인으로 구분한다. 새 특수 키 +(`[Attribute "X"]`, `[Tag ""]`, `PropertyChangedEvent ""` 등)를 추가하려면 이 +중앙 함수 자체를 고쳐야 한다 — 라이브러리로서 확장 불가능한 구조. + +## 핸들러 계약 (확정 — 아래 "확정된 디스패치 모델" 절과 통합해서 읽을 것) + +핸들러는 다음 4개를 제공하는 등록 가능한 객체: + +- `isHandlable(inst, key, value): boolean` — 이 핸들러가 이 inst/key/value + 조합을 처리할 수 있는지 판별하는 predicate. **부작용 없이, 빠르게** — + tbox의 type-check/constraint-check 분리 원칙(`.claude/initreq/tbox/ + CLAUDE.md`의 "타입 체크는 분기 선택에 쓰이므로 순수해야 함")을 그대로 + 적용: `isHandlable`은 오직 "이 핸들러가 맞는가" 판별에만 쓰이고, 실제 + 유효성 검사는 핸들러가 선택된 *이후* 별도 단계에서. **`inst`도 받음 + (2026-08-07 여덟 번째 세션 정정, 원래 `(key,value)`뿐이었음)** — + `process`/`retract`는 처음부터 항상 `inst`를 받았는데("모든 핸들러는 + 대상 Instance를 직접, 항상 받는다", 아래 "확정된 디스패치 모델" 절) + `isHandlable`만 예외였던 게 애초에 약간의 불일치. 지금 당장 `inst`에 + 따라 매치 여부가 갈리는 케이스는 없지만, 나중에 필요해지면(다른 + 백엔드에서 인스턴스 종류별로 매치가 달라져야 하는 경우 등) 핸들러 + 계약 자체를 깨는 breaking change가 되므로 지금 넣어두는 게 훨씬 쌈 — + 사용자 판단으로 확정. +- `priority: number` — 우선순위. 등록 순서(Fusion의 4단계 고정 stage, Vide의 + action() 우선순위)보다 일반화된 **열린 숫자 공간**으로. +- `process(inst, key, value)` — 실제 처리 수행(아래 "확정된 디스패치 모델" + 절 참고). v1/기존 논의에서 "bind"라 부르던 것과 동일한 역할. +- `retract(inst, key, value)` — 이전 처리를 무르는/멈추는 함수(아래 절, + `base/lifecycle-pattern.md` 참고). 모든 핸들러가 의미 있게 구현할 필요는 + 없음(예: 일반 프로퍼티 핸들러는 보통 no-op). + **`retract` 필드 자체는 생략 불가, no-op이라도 항상 정의할 것(2026-08-08 + 세션, 확정)** — `Dispatch.process`(아래 "확정된 디스패치 모델" 절)는 + 담당 핸들러 *타입*이 바뀔 때 이전 핸들러의 `retract`를 nil 체크 없이 + 무조건 호출함. 필드를 생략한 핸들러가 나중에(드물더라도, 예: `Tag(...)`↔ + `nil` 교체 — `base/tag-plan.md`) 실제로 담당이 바뀌는 순간 `attempt to + call a nil value`로 + 바로 크래시 — "의미 있게 구현할 필요 없음"은 "구현이 사소해도 됨"이라는 + 뜻이지 "필드를 안 둬도 안전하다"는 뜻이 아님. 새 핸들러를 짤 때 이 필드가 + 없으면 리뷰/린트에서 걸러내야 함(M2 착수 시 확인 목록에 추가). + +디스패치는 등록된 핸들러를 우선순위 순으로 스캔하며 `isHandlable`을 호출, +첫 매치가 처리(Fusion의 SpecialKey 우선순위 스캔과 유사하되 4단계 고정이 아니라 +열린 레지스트리). tbox의 `TUnion` 런타임 체커가 이미 이 "순서대로 스캔, 첫 매치 +반환, 실패 정보는 클로저로 지연 생성" 패턴을 구현해뒀음(`.claude/initreq/tbox/ +src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들지 말고 매치 +실패 시에만 클로저 호출. + +## 확정된 디스패치 모델: `process(inst, k, v)` / `retract(inst, k, v)` + +**사용자가 직접 준 구체적인 모델 — 이 문서의 이전 초안보다 우선함.** 아래가 +실제로 구현할 모양: + +- 모든 핸들러는 대상 **Instance를 직접, 항상** 받는다. quad는 "인스턴스를 생성하고 + 그 인스턴스를 처리하는" 라이브러리다 — 다른 라이브러리가 만든 값(예: Store)을 + 그 인스턴스에 적용하도록 돕는 역할에 가깝다. 그래서 핸들러가 "나중에 생길 + 대상"을 비동기로 기다릴 필요 자체가 없음(아래 Ref 절 참고 — Ref는 다른 이유로 + 존재). + - **보강(2026-08-04)**: `inst`가 항상 살아있는 엔진 객체(Roblox Instance)일 + 필요는 없음 — 특정 백엔드에서 실제 엔진 객체 생성/바인딩 비용이 비싸면 + (예: 웹 DOM) 중간 표현으로 평범한 테이블을 만들고 나중에 그 테이블을 + 렌더링하는 것도 가능. 이건 core(base)가 신경 쓸 일이 아니라 각 최종 + 엔드포인트 백엔드(`quad-roblox`/`quad-web` 등)가 알아서 결정할 문제 — + base 인터페이스는 "무언가를 inst로 받아 process/retract한다"는 계약만 + 지키면 됨, 그 inst의 실체가 뭔지는 백엔드 재량. +- `process(inst, k, v)` — 우선순위 순으로 등록된 핸들러를 스캔, + `isHandlable(inst,k,v)`를 만족하는 최상위 핸들러가 실제 처리를 담당. + **이 "스캔+실행" 오케스트레이터는 `Dispatch.process`로, 순수 스캔 + 부분은 `Dispatch.getHandler`로 이름이 공식화됨**(아래 `None` 센티널 + 절, 2026-08-07 여덟 번째 세션) — 이 절에서는 개념 설명이라 편의상 + 그냥 `process`로 계속 씀. +- 예시: `Dispatch/StoreBind.luau`(범용, 엔진 무관)는 **`k`는 무엇이든 받고 + `v`가 State/Source인 경우를 잡아내는, 우선순위가 매우 높은 핸들러** — + `v`가 반응형이면 그 값을 처리(구독)함. 이 핸들러 안에서: + 1. 지금 이 처리가 실행되어도 되는지 라이프타임(`Connected`)을 확인 — + 확인 안 하면 이미 Destroy된 대상에 대해 처리가 실행되는 문제가 생김. GC가 + 결국 정리하긴 하지만, GC 되기 전에도 store 값이 업데이트될 수 있으므로 + 그 시점엔 그냥 `Connected`를 보고 무시(no-op). + 2. 처리해도 되면, 사용자가 넘긴 함수들을 거쳐 실제 값(`realv`)을 계산. + 3. **재귀 호출 전에 먼저 `Dispatch.retractUnder(inst, k, self, realv)`를 + 불러 자기 밑에 위임돼 있던 걸 정리한 뒤, `realv`를 들고 + `Dispatch.process(inst, k, realv)`를 재귀 호출**(정확한 메커니즘은 + 아래 "Dispatch 체인" 절, 2026-08-08 세 번째 세션 — 오케스트레이터 + 이름 공식화는 아래 `None` 센티널 절 참고, 2026-08-07 여덟 번째 + 세션) — 이게 바로 "store 바인드는 pluggable 바인드를 재실행하는 + 래핑"이라는 이 문서 이전 초안의 결론과 일치. `realv`가 반응형이 + 아니라면 자연히 `StoreBind`의 `isHandlable`을 통과 못 하고 우선순위상 + 다음 핸들러(일반 프로퍼티 세터 등)로 흘러감 — 무한 재귀 걱정 없음. + **[정정, 2026-08-10 세션]** 이 예시는 원래 "Tween의 store-bind + 핸들러"였으나, Tween이 독립 Dispatch 핸들러가 아니라 PropertyHandler가 + 소비하는 값-레벨 래퍼(`Tween`)로 재설계되며(`research/ + tween-plan.md`, `archive/tween-special-bind-key-reversed.md`) 이 + 자리의 대표 예시에서 빠짐 — `NoneHandler`(아래 절)가 지금은 이 + 패턴의 남은 대표 예시. +- **`retract(inst, k, v)`** (이전 초안의 "cleanup", 이름 변경 근거는 + `base/lifecycle-pattern.md` 참고) — 이전 처리를 무르는/멈추는 함수. **오직 + "같은 key에 새 값이 들어와서 이전 처리를 갈아치우는" 시나리오에만 존재** — + 인스턴스/바인드 전체가 Destroy될 때는 `retract`가 호출되지 않음(`base/ + lifecycle-pattern.md`의 "quad는 라이프사이클 중간에 있지 않다" 원칙 참고). + - 일반 프로퍼티는 애초에 "unset" 개념이 없음(`nil`로 셋하는 것도 그냥 셋 + 동작) — 그래서 프로퍼티 핸들러는 보통 `retract`가 필요 없음. + - **`retract`가 실제로 의미 있는 유일한 패턴은 "같은 키에 대해 매치되는 + 핸들러 *타입 자체*가 사이클마다 바뀌는 경우"** (2026-08-07 여덟 번째 + 세션, 정정) — 예: `Tag(...)`↔`nil` 사이에서 핸들러 타입 자체가 + 바뀌므로 `retract`가 의미 있어짐(전체 삭제), 같은 Tag끼리 바뀌는 + diff는 `process`가 담당(`base/tag-plan.md`, 2026-08-08 세 번째 세션 + — array-part 값 객체 재설계 이후, 구 모델은 `archive/ + tag-hash-key-model-reversed.md`). **Attribute는 여기 해당 안 함** — + UICorner 숏핸드와 같은 패턴(값의 참/거짓/nil 여부와 무관하게 항상 + 같은 핸들러가 계속 담당, 추가/제거를 전부 `process` 자신이 처리)이라 + 핸들러 교체 자체가 안 일어남 — `base/attribute-plan.md`. **[정정, + 2026-08-10 세션] Tween도 더 이상 여기 해당하지 않음** — 원래는 이 + 패턴의 대표 예시("Tween 핸들러가 매치돼 애니메이션 실행 중이었는데 + 값이 더 이상 Tween 대상이 아니게 되어 일반 PropertyHandler로 매치가 + 넘어가는 경우")였으나, Tween이 독립 Dispatch 핸들러가 아니라 + PropertyHandler가 소비하는 값-레벨 래퍼(`Tween`)로 재설계되며 + 매치되는 핸들러가 항상 PropertyHandler 하나뿐이 되어 이 케이스 + 자체가 사라짐 — 트윈 취소/전환은 이제 PropertyHandler 내부의 + 3-상태 릴레이션 슬롯으로 처리(`research/tween-plan.md`, `archive/ + tween-special-bind-key-reversed.md`). + - store bind가 새 값으로 넘어갈 때 이전 핸들러의 `retract`를 호출해주면 + 됨 — **정확한 전파 메커니즘은 아래 "Dispatch 체인" 절 참고**(재귀 + 재-dispatch에서 여러 단계가 겹칠 때 어느 슬롯에 뭘 추적하는지가 + 2026-08-08 세 번째 세션에 구체화됨, 여기 한 줄 설명은 그 요약). +- **핸들러 내부 상태 저장**: `retract`가 "이전에 생성한 것"(예: 실행 중이던 + Tween 객체)에 접근하려면 상태를 어딘가에 저장해야 함 — **`inst`를 키로 하는 + weak-keyed 테이블에 `k`별로 저장**(예: 생성된 Tween을 담아뒀다가 나중에 + 멈추거나 끝냄). **[정정, 2026-08-08 세션] `base.perInstanceState(inst)`라는 + 이름/모양은 폐기 — `base/relate-plan.md`의 `Relate` 프리미티브로 구체화됨.** + 각 핸들러 모듈이 자기 톱레벨에 `local relate = Relate()`를 하나 두고 + `relate:SetStrong(inst, k, tween)`/`relate:GetStrong(inst, k)`로 저장/조회 — + "모든 핸들러가 WeakMap을 재발명하지 않고 공유 유틸을 쓴다"는 원래 취지는 + 그대로, `Relate`가 그 공유 유틸의 정식 인터페이스. `base/lifecycle-pattern.md`의 + `bindLifetime`/`canExecute`도 같은 `Relate`를 내부적으로 씀(용도가 다르니 + 별도 `Relate()` 인스턴스). **왜 GC-안전한가(2026-08-07 여섯 번째 세션, + 명시화)**: 구조가 "`inst`로 weak-keyed된 바깥 릴레이션 안에 `k`별 안쪽 + 릴레이션이 중첩된" 모양이라, `inst`가 죽어 weak table 엔트리가 통째로 + 사라지는 순간 그 안에 중첩된 `k`별 Tween 인스턴스 릴레이션도 같이 GC됨 — + 별도 cleanup 로직 불필요(PropertyHandler가 여기 담아두는 실제 엔진 Tween + 인스턴스도 자동으로 같이 죽는 것까지 포함). **[정정, 2026-08-10 세션]** + Tween은 더 이상 별도 "Tween 핸들러"가 아니라 PropertyHandler 내부 + 로직이므로, 이 슬롯이 실제로 담는 값은 `RobloxTween | true | nil` + 3-상태(첫 세팅 여부까지 같은 슬롯에 통합) — 상세는 `research/ + tween-plan.md` "3-상태 저장" 절 참고. +- **다른 값 변경을 추적하는 것도 process 함수의 정상 범위**: 예를 들어 Slot + 핸들러는 자기가 감시하는 값(배열/스토어)이 바뀌면 그에 따라 child를 + 갱신해야 함 — `retract` 시점엔 그 추적(구독)만 풀면 됨. +- **일반적인 무한루프 방어(사이클 감지 등)는 하지 않기로 확정(2026-08-04, + 로드맵 인수인계 라운드)**: 우선순위 스캔+재귀 `process` 구조 자체는 핸들러가 + 규율을 안 지키면(예: 값을 좁히지/변형하지 않고 같은 값을 그대로 다시 + `process`에 넘김) 무한루프에 빠질 수 있음 — 하지만 이건 base가 방어 로직을 + 둬야 할 문제가 아니라 오작동하는 handler/provider(`quad-roblox` 등) 쪽 + 버그로 간주 — **사용자 확정**("입력된 값이 다시 입력되면 무한루프 + 빠지겠지만, 그건 막기 힘들고 유저가 내기도 힘들어. 아예 quad-roblox나 + 프로바이더가 잘못 짠 코드일테니까"). `StoreBind`의 재귀 케이스(위 절)처럼 + 자연히 좁혀지는 경우가 일반적이고, 일반 사용자가 만들어낼 수 있는 상황이 + 아니라고 판단해 별도 가드 없이 진행. + +- **props 순회 순서는 base 디스패치 드라이버가 명시적으로 두 단계로 + 고정한다 — 배열 파트(숫자 키, children/Ref류) 먼저, 해시 파트(문자열 키, + 프로퍼티/이벤트/특수 DI 키) 나중(2026-08-07 세 번째 세션).** Luau + 테이블을 `pairs`/제네릭 `for`로 순회하면 실제로 배열 파트가 해시 파트보다 + 먼저 나옴(`for i, v in {a=1, 2, b=3} do print(i,v) end` → `1 2`, `a 1`, + `b 3` 순서 — 사용자가 직접 확인). 이 관찰된 동작에 그냥 얹혀가지 않고, + **base 드라이버가 명시적으로 두 패스로 나눠 돌기로 계약화**한다 — 숫자 + 키(children)를 먼저 index 순서대로 처리하고, 그 다음 나머지 키를 처리. + 이유: (1) 다른 백엔드(`quad-web` 등)가 병합된 props를 Lua 테이블이 아닌 + 다른 자료구조로 표현할 수도 있어서 "Lua 테이블의 우연한 내부 동작"에 + 기대면 이식성이 깨짐, (2) 어차피 숫자 키(children/Ref)와 문자열 + 키(프로퍼티/이벤트)를 다른 의미로 취급해야 하니 구분 비용이 이미 드는 + 참에 순서까지 명시적으로 고정하는 게 거의 공짜. **결과적으로 배열 + 슬롯에 놓인 어떤 값(Ref 포함)이든 모든 프로퍼티/이벤트 세팅보다 항상 + 먼저 처리된다는 게 base 자체의 보장**이 됨 — 아래 "Ref 일반화" 절 뒤에 + 이어지는 "PreRef" 절이 이 보장 위에서 성립. **M0 스파이크에서 실제 + Luau로 이 순회 동작 자체를 검증할 것**(지금까지 추론/관찰만으로 확정된 + 항목 — `research/pre-implementation-audit.md`가 짚은 "실제 Luau로 + 부딪혀본 적 없는 것" 범주와 같은 급이라 신중하게 다룸). + +### `None` 센티널 — StoreBind와 같은 재귀 재디스패치 패턴 재사용 (2026-08-07 여덟 번째 세션, 예시는 2026-08-10 세션에 StoreBind로 정정) + +`modifier-plan.md` "2-1"절의 "인라인 키로 modifier 필드를 명시적으로 +지우기" 문제 — raw 저장 계층(Modifier 필드/인라인 props/`Peek`)에서 쓰는 +`None` 센티널이 실제로 인스턴스에 반영될 때 base가 뭘 하는지가 이 문서의 +층위. 결론: **새 메커니즘이 아니라 위 "확정된 디스패치 모델"의 +`StoreBind` 핸들러(위 절)와 완전히 같은 모양의 핸들러 하나 추가.** + +``` +NoneHandler.priority = <매우 높음> +NoneHandler.isHandlable(inst, k, v) = (v == None) +NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 +``` + +- **매치 predicate는 `isHandlable`** — `canExecute`가 아님. 둘은 완전히 + 다른 개념이라 혼동하지 말 것: `isHandlable(k,v)`는 KV 매치 predicate(핸들러 + 계약 4종 중 하나, 이 절에서 다루는 것), `canExecute`는 인자로 받은 특정 + 바인딩/등록 하나가 "지금 살아있어서 실행돼도 되는가"만 보는 별개의 + 라이프타임 게이트(`base/lifecycle-pattern.md` "생명 바인드 유틸" 절) — + KV 매치와 무관. + **이 `NoneHandler`는 해시 파트(프로퍼티/이벤트) 전용 — 배열 파트에서 + `None`을 만나는 건 완전히 다른 규칙(2026-08-07 열 번째 세션, "PreRef" + 절 "호이스팅의 실제 구현" 참고).** 배열 파트의 `None`은 "빈 슬롯" + 표시일 뿐 처리할 핸들러 자체가 없으므로, `Dispatch.drive`의 두 패스 + 루프 자신이 `NoneHandler`/`Dispatch.process`를 거치지 않고 바로 + 건너뜀 — 같은 센티널 값이지만 배열 파트냐 해시 파트냐에 따라 처리 + 경로가 다르다는 점에 유의. + `NoneHandler.isHandlable`은 `v == None`(센티널 자체)을 잡는 것이지 + `v == nil`이 아님 — 진짜 `nil`은 애초에 테이블 순회로 나올 수 없다는 게 + 이 문제의 출발점이었으므로, 매치 대상은 항상 `None` 마커. + `Dispatch.process(inst, k, nil)`로 재귀 호출하는 순간 `None`은 더 이상 + 존재하지 않고 진짜 `nil`이 되므로, 다음 우선순위 스캔은 자연히 키 `k`를 + 원래 담당하던 핸들러(프로퍼티/이벤트/UI shorthand 등)로 흘러감 — + `StoreBind` 핸들러가 `realv`를 들고 재귀하면 자연히 다음 핸들러로 좁혀지는 + 것과 정확히 같은 원리, 무한루프 걱정도 동일하게 없음. +- **`Dispatch.process`/`Handler.process` 이름 겹침 — 소유자 네임스페이싱으로 + 해소, 새 이름 발명 안 함 (2026-08-07 여덟 번째 세션 후속).** 원래 + "확정된 디스패치 모델" 절은 "스캔+실행"과 "매치된 핸들러 자신의 처리 + 로직" 둘 다 그냥 `process`라고 불러서 이름이 겹쳤음 — 이제 두 계층을 + 명시적으로 분리: + - `Dispatch.getHandler(inst,k,v): Handler?` — 순수 스캔(`handler.isHandlable(inst,k,v)`+ + `priority`), 부작용 없음. + - `Dispatch.process(inst,k,v)` — 오케스트레이터: `getHandler` 호출 → + 매치된 핸들러를 `(inst,k)` 체인 꼬리에 push → 그 핸들러의 `.process` + 호출. **"이전 핸들러와 다르면 retract"라는 diff는 `Dispatch.process` + 자신의 일이 아님** — 재귀/래핑 핸들러(`StoreBind`/ + `NoneHandler`)가 재-dispatch 전에 스스로 `Dispatch.retractUnder(inst, + k, self, newV)`를 먼저 불러 자기 밑을 정리하는 책임을 짐(정확한 + 메커니즘·기각된 대안은 아래 "Dispatch 체인" 절 참고 — 전역 소유자 + 슬롯 하나로 diff하는 안은 래핑 핸들러에서 깨져서 기각됨). + - `Dispatch.addHandler(handler: Handler)` — 핸들러를 우선순위 레지스트리에 + 등록. `Dispatch.process`/`getHandler`와 마찬가지로 base엔 인터페이스만 + 있고, quad-roblox의 concrete Handler들(PropertyHandler/EventHandler/ + OnChangeHandler/UICornerHandler/TagHandler/AttributeHandler 등)은 + 팩토리가 `BaseModule`을 + 뮤테이션하는 시점에 이걸로 등록됨(아래 "base 유틸은 인터페이스" 절과 + 같은 패턴, 새 메커니즘 아님). + - Handler 자신의 필드는 계속 `process`/`retract`(이미 확정된 이름, + `question.md`에 "특별한 문제 없음"으로 못박혀 있어 재검토 대상 아님) — + 겹침은 실제 런타임 충돌이 아니라 프로즈 표기 문제였을 뿐이라, 항상 + 소유자를 명시(`Dispatch.process` vs `handler.process`)하는 것으로 해소. + - **base 드라이버 루프 자신의 이름은 `Dispatch.drive(inst, flattened)`로 + 확정** — 이미 위 "props 순회 순서" 절이 이걸 비공식적으로 "base + 디스패치 드라이버"라고 불러왔던 걸 그대로 동사화(`apply`는 "Dispatch를 + 뮤테이션해서 결과를 낸다"는 어감이라 기각 — 사용자 판단). `inst`와 + flatten된 props 테이블을 받아 배열 파트(children/Ref) 먼저, 해시 + 파트(프로퍼티/이벤트) 나중으로 두 패스 순회하며 각 `(k,v)`에 + `Dispatch.process`를 호출하는 게 이 함수의 본체. +- **`v=nil`이 구체적으로 뭘 뜻하는지는 핸들러마다 다름, `None` 자신은 + "리셋"이 아님** — 일반 프로퍼티는 "`nil`로 셋하는 것도 그냥 셋 동작"이라 + 사실상 그대로 두는 것과 다름없고, UICorner 같은 숏핸드 핸들러는 만들어둔 + 자식 Instance를 실제로 지우는 것까지 포함 — 구체 예시는 + `base/ui-shorthand-plan.md`/`base/tag-plan.md`/`base/attribute-plan.md`. + `None`은 **"이 조합 단계에서 나는 이 필드를 세팅 안 한다"**는 뜻이고, + 그걸 받은 실제 핸들러가 무엇을 할지는 각자 몫. 개별 프로퍼티/이벤트/UI + shorthand 핸들러의 `process` 시그니처는 안 바뀜 — 이들은 원래도 `v`가 + State 계산 결과로 `nil`이 되는 경우를 처리할 수 있어야 했으므로(일반 + 반응형 케이스), `None`은 그 기존 경로에 도달하는 방법 하나가 늘어난 것뿐. + **구현 디테일 캐비엇**: `None→nil`이 Roblox의 nil을 허용 안 하는 타입 + 프로퍼티(Color3/number 등)에 도달하면 `inst[k] = nil`은 런타임 에러 — + PropertyHandler 자신이 `v == nil`이면 셋을 건너뛰는 방어를 갖고 있어야 + 함(None 자체의 문제가 아니라 PropertyHandler 구현 디테일, M9/M10로 미룸). +- **retract와는 무관** — `retract`는 "같은 키를 다른 *핸들러 타입*이 + 넘겨받는" 시나리오 전용(아래 정정된 "확정된 디스패치 모델" 절)이지 + "`v`가 `nil`이 됨"과는 다른 문제. `None → nil` 재디스패치는 항상 + `Dispatch.process` 경로로만 흐름 — `NoneHandler` 자신도 `retract`가 + 딱히 할 일이 없음(재귀 호출 자체가 이미 process이므로). +- **[해소됨, 2026-08-08 세 번째 세션]** "이 키를 지금 누가 담당 중인가" + bookkeeping — `pre-implementation-audit.md` 우선순위1 "이전에 실제로 + 매치됐던 핸들러 추적" 항목이 여기서 다시 언급됐던 것. 아래 "Dispatch + 체인" 절의 `chains`/`Dispatch.retractUnder`로 구체화됨 — `NoneHandler`의 + 재귀 재호출도 이 메커니즘 위에서 동일하게 동작(`None`으로 유지되는 매 + 사이클마다 담당자가 자연히 정확하게 갱신됨, 별도 특수 처리 불필요). + +### Dispatch는 프리미티브가 아니다 — 탑레벨 싱글톤 확정 (2026-08-08 두 번째 세션) + +`Dispatch.process`/`getHandler`/`addHandler`/`drive`를 `Source`/`Ref`/`Store`/ +`Modifier`처럼 생성자가 있는 프리미티브(예: `Dispatch()`로 인스턴스를 여러 개 +만들 수 있는 것)로 바꿔야 하는지 검토 후 **기각, 지금 형태(모듈 require로 +바로 닿는 flat 탑레벨 함수) 유지로 확정**: + +- **재귀 재-dispatch가 요구하는 필연** — `NoneHandler`/`Dispatch/ + StoreBind.luau` 전부 자기 `process` 안에서 다시 `Dispatch.process(inst,k, + realv)`를 호출함(위 "확정된 디스패치 모델"/"`None` 센티널" 절). 이게 + 성립하려면 Dispatch가 `canExecute`/`bindLifetime`(`base/ + lifecycle-pattern.md`)과 똑같이 require 한 번으로 바로 닿는 안정된 + 전역이어야 함 — 인스턴스화 가능한 프리미티브로 만들면 모든 Handler + 등록/호출 경로에 Dispatch 핸들을 인자로 계속 실어날라야 하는 스레딩 + 비용이 생기는데, 지금 형태는 그 비용을 아예 안 짐. +- **순환참조로 보이는 건 착시 — 실제로는 단방향.** "Handler"라는 말이 두 + 가지를 가리켜서 헷갈릴 수 있음: (a) `Handler.luau`의 **타입 계약** + (`isHandlable`/`priority`/`process`/`retract` 시그니처만 있는 순수 leaf, + Dispatch를 몰라도 됨) vs (b) `StoreBind.luau`처럼 그 계약을 + **구현하는 concrete 값 모듈**(재귀호출 위해 Dispatch를 require함). 의존 + 방향은 항상 한쪽으로만 흐름 — `Handler.luau`(leaf) ← `Dispatch/init.luau` + (`addHandler(h: Handler)`가 `Handler` 타입만 참조) ← `StoreBind.luau` + (재귀호출 위해 Dispatch를 참조). `Handler.luau` 자신이 + Dispatch를 되받아 참조하는 일이 없으니 타입 레벨에서도 사이클이 안 생김. + 런타임에서도 마찬가지 — 어떤 handler의 `process`든 실제로 *호출*되는 + 시점은 컴포넌트가 렌더되는 시점이라, 그때는 이미 Dispatch 모듈 require가 + 완전히 끝나있어 부트스트랩 문제도 없음. +- **quad-base 자신의 기본 핸들러도 같은 레지스트리를 씀** — `NoneHandler`, + `Dispatch/StoreBind.luau`("범용, 엔진 무관")뿐 아니라, children 배열 + 숫자 슬롯에 `Ref`/`Observer`/`PreRef`를 직접 놓는 leaf 값을 매칭하는 + Handler도 여기 속함(`inst`를 `any`로 취급, 엔진 특정 API 불필요 — + `.claude/question.md`가 2026-08-08 세션에 "quad-base/quad-roblox 중 + 어디 사는지 미확인"으로 남겨뒀던 항목, 이 결론으로 해소: quad-base, + `Dispatch/Leaf.luau`, `Dispatch.addHandler`로 등록). quad-roblox의 + Property/Event 핸들러도 **같은** `Dispatch.addHandler` 레지스트리에 + 등록됨 — base 기본 핸들러와 backend 핸들러가 별도 경로로 안 갈리고 + 전부 하나의 우선순위 스캔을 공유. **[정정, 2026-08-10 세션]** Tween은 + 더 이상 별도로 등록되는 핸들러가 아님 — Property 핸들러 내부에서 + 소비되는 값-레벨 래퍼로 재설계됨(`research/tween-plan.md`). +- **모듈 재생성(`New()`)과의 관계 — 새 설계 불필요, 이미 있는 선례로 자연히 + 풀림.** v1처럼 `require`를 감싸 `Init(QuadId?)`로 격리 인스턴스를 만드는 + 방식은 안 씀(위 "확정된 것" 절 — id 기반 조회 자체가 Ref로 대체되며 + 기각됨). 대신 이미 확정된 "base 유틸은 인터페이스, 실제 구현은 팩토리가 + `BaseModule`을 뮤테이션해서 주입"(`RobloxFactory(BaseModule)`) 패턴을 + 그대로 따름 — Dispatch의 handler 레지스트리도 `BaseModule` 테이블에 + 딸린 state 중 하나일 뿐이라, `_initializedBy` 마커에 대해 이미 확정된 + 것과 완전히 같은 논리가 적용됨(위 "base 유틸은 인터페이스" 절, "`New()`가 + 생기면 각 인스턴스가 별도 테이블이 되므로 이 마커도 테이블별로 독립적으로 + 스코핑됨, 재설계 불필요"). `New()`가 실제로 생기면 그 시점에 BaseModule + 전체를 인스턴스별 테이블로 만드는 메커니즘에 Dispatch도 자연히 같이 + 딸려가고, 호출부는 `module.Dispatch.process(...)`처럼 그 인스턴스 + 테이블을 통해 접근하게 됨 — 지금 미리 프리미티브화해둘 이유가 없음. + +### Dispatch 체인 — 재귀 재-dispatch의 retract 전파, `Dispatch.retractUnder` (2026-08-08 세 번째 세션) + +**문제**: `NoneHandler`/`StoreBind`처럼 자기 `process` 안에서 +`Dispatch.process(inst,k,realv)`를 다시 부르는 래핑 핸들러가 있으면, 같은 +`(inst,k)`에 대해 "지금 누가 담당 중인가"를 슬롯 하나로 추적하는 순간 +깨짐 — 래핑 핸들러 A 자신의 생명주기(예: StoreBind의 Observer 구독)와, +A가 재귀로 위임한 핸들러 B의 생명주기가 **같은 슬롯을 두고 서로 +덮어씀**. 구체적으로: A의 재귀 진입 시점에 슬롯을 A→B로 갱신해두면, A가 +스스로 다시 값을 재계산해 재-dispatch할 때(예: store 값이 또 바뀜) 그 +슬롯엔 이미 B가 적혀있어 "A로 바뀌었다"고 오판해 A 자신을 엉뚱하게 +retract하거나, 반대로 A가 자길 스스로 retract하는 오작동이 남 — 처음 +검토했던 "Dispatch 전역 소유자맵 슬롯 하나" 안은 이 이유로 기각됨(당시 +대화에서 직접 반례로 확인). + +**해법 — Dispatch가 `(inst,k)`별 핸들러 체인(순서 있는 배열)을 소유**: + +```lua +-- Dispatch/init.luau +local chains = Relate() -- {[inst(weak)] = {[k] = {handler, handler, ...}(strong, 순서 있는 배열)}} + +function Dispatch.process(inst, k, v) + local h = Dispatch.getHandler(inst, k, v) + if h then + local list = chains:GetStrong(inst, k) or {} + table.insert(list, h) -- 항상 꼬리에 추가 + chains:SetStrong(inst, k, list) + h.process(inst, k, v) + end +end + +function Dispatch.retractUnder(inst, k, keep, v) + local list = chains:GetStrong(inst, k) + if not list then return end + local cutoff = 0 + if keep then + for i, h in list do if h == keep then cutoff = i break end end + end + for i = #list, cutoff + 1, -1 do + list[i].retract(inst, k, i == cutoff + 1 and v or nil) + list[i] = nil + end +end +``` + +- **`handler.process(inst,k,v)`를 `Dispatch.process`를 거치지 않고 직접 + 호출하는 것은 UB — 반드시 `Dispatch.process`를 통해서만 진입할 것.** + 이유: `chains` 배열에 push하는 bookkeeping이 `Dispatch.process` 내부에만 + 있어서, `handler.process`를 직접 부르면 그 핸들러가 실제로 활성화됐는데도 + 체인에 안 올라가 — 나중에 다른 값으로 바뀌어도 `retractUnder`가 이 + 핸들러의 존재를 몰라 `retract`가 영영 안 불리거나(리소스 누수), 반대로 + 체인 순서 자체가 실제 활성 상태와 어긋나는 정합성 붕괴로 이어짐. 재귀/ + 래핑 핸들러가 위임할 때도 항상 `Dispatch.process(inst,k,newV)`를 + 불러야지 매치된 핸들러의 `.process`를 스스로 찾아 직접 호출하면 안 됨. +- **재귀/래핑 핸들러는 재-dispatch 전에 반드시 `Dispatch.retractUnder(inst, + k, self, newV)`를 먼저 부른 뒤 `Dispatch.process(inst, k, newV)`를 + 부름** — "나 밑에 있던 걸 전부 정리하고 새로 위임". `keep`(자기 자신) + 바로 다음 항목만 실제 `newV`를 받고, 그보다 더 안쪽(다단 체인이 있을 + 경우)은 `nil`을 받음 — 더 안쪽 항목엔 "구체적으로 뭐로 대체됐는지" + 정보가 없고 "완전히 사라진다"는 것만 사실이라서. +- **개별 핸들러의 `retract`는 더 이상 자기 위임 대상을 수동으로 안 + 쫓아가도 됨** — `retractUnder`가 꼬리부터 `keep` 앞까지 한 번의 + 루프로 체인 전체를 순서대로 정리해주므로, A→B→C처럼 몇 단계든 각 + 핸들러는 **자기 자신의 자원만** 정리하면 자동으로 전파됨(질문 + 제기됐던 "다단 체인에서 안쪽까지 retract가 안 간다" 문제가 이걸로 + 해소 — `retractUnder`의 루프 자체가 체인 전체를 훑으므로 각 핸들러가 + 수동으로 cascade할 필요가 원천적으로 없음). +- **구멍 걱정 없음** — 이 배열은 항상 꼬리에서만 추가/삭제되는 스택 + 모양이라(`retractUnder`가 항상 꼬리부터 연속으로 지움), "촘촘하지 + 않은 정수 키는 순회 순서가 깨진다"는 문제(위 "PreRef" 절의 `None` + 소진 이슈)가 애초에 발생할 구조가 아님. +- **`retract`는 여전히 `(inst,k,v)` 3-인자** — 드롭하자는 제안이 대화 + 중 한 번 나왔으나 기각(전체 삭제 vs 부분 diff를 갈라야 하는 핸들러가 + 있어서, `base/tag-plan.md` 참고). 다만 `v`가 실제로 필요한지는 + 핸들러마다 다름 — Tag는 구조상 retract가 "더 이상 매치 안 될 때만" + 불리므로 `v`를 안 봐도 항상 전체 삭제가 맞음(무조건) — `v`는 "계약상 + 항상 주어지지만 안 쓰는 핸들러가 있어도 됨" 정도로 이해할 것. + **[정정, 2026-08-10 세션]** 원래 두 번째 예시로 들었던 Tween(자기 + `Relate` 저장분만 보고 `Cancel`하면 되니 `v`를 꼭 안 봐도 됨)은 더 + 이상 유효한 예시가 아님 — PropertyHandler가 항상 매치되는 유일한 + 핸들러가 되어 이 `retract` 경로 자체가 사실상 안 쓰임(`research/ + tween-plan.md`). +- **순환은 UB, 방어 로직 없음** — Handler 간 순환 참조(A가 B를 부르고 + B가 다시 A로 돌아오는 것)는 재귀 호출이 안 끝나 바로 스택오버플로가 + 나므로 애초에 일어날 수 없는 구조(각 핸들러는 최대 한 번씩만 그 + 키에서 호출됨을 전제) — 값에 별도 플래그를 심어 의도적으로 순환을 + 만드는 것도 이론상 가능하지만 use case가 없어 문서화 대상 밖, + 2026-08-04 세션에 이미 확정된 "일반적 무한루프 방어 안 함" 원칙과 + 같은 결로 UB 취급. +- **부수 효과 — 미래 재바인드/quad-debug에 유리**: 이 체인이 Dispatch에 + 중앙화돼 있으므로, `research/existing-instance-bind-plan.md`가 다룰 + 미래의 재바인드는 `Dispatch.retractUnder(inst, k, nil, newV); + Dispatch.process(inst, k, newV)` 두 줄로 "이 키의 체인을 통째로 갈아 + 끼우기"가 자연스럽게 됨(각 래핑 핸들러가 자기 전용 `Relate`에 위임 + 대상을 비공개로 숨겨두는 대안 설계는 이게 안 됨 — 대화 중 검토 후 + 기각). `research/debug-tooling-plan.md`의 "무엇이 무엇에 연결됐는가" + 그래프도 이 `chains` 구조를 그대로 읽으면 됨 — quad-debug 착수 시점에 + 새로 설계할 필요 없음. + +### Length/Offset — 여러 Slot이 형제로 섞일 때 순서 보장 (2026-08-09 여섯 번째 세션) + +**문제(`base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 열린 질문, +2026-08-04 신설)**: `Frame { Slot1, Element, Slot2 }`처럼 Slot과 정적 +자식이 형제로 섞일 때, Slot1의 동적 개수가 바뀌어도 "Slot1 전체는 항상 +Element보다 앞, Slot2보다 앞"이라는 저작 순서가 유지돼야 함. Slot2가 +자기 순서를 정하려고 "Slot1이 지금 몇 개인지"를 직접 세는 방식은 +Slot1이 바뀔 때마다 Slot2에 다시 알려줘야 하는 캐스케이드 의존을 +만들어서 막다른 길. + +**해법의 핵심 전환**: 절대 위치를 계산해서 전파하는 게 아니라, **각 +구조적 위치(자리 자체는 저작 시점에 고정)가 자기 앞의 형제들이 지금까지 +기여한 개수의 누적합만 알면 됨** — Roblox는 `LayoutOrder`/`ZIndex`가 +`Instance.Parent` 배열의 물리적 순서와 완전히 분리된 정수 프로퍼티라, +이 누적합을 그 프로퍼티에 반응형으로 바인딩하기만 하면 별도 배선이 +필요 없음(이미 있는 store-bind 재실행 패턴 재사용). + +**`Dispatch`의 두 API — 둘 다 Handler→Dispatch 등록(push) 방향**: + +```lua +Dispatch.setLength(inst, i, len: number | State) +Dispatch.setOffsetSource(inst, i, offset: Source | None) +``` + +- **`setLength`**: 이 위치(array part의 number 인덱스 `i`)가 지금 몇 개의 + 실제 마운트 가능한 leaf를 기여하는지 보고. 정적 단일 자식은 상수 + `1`(또는 `nil`/`None`이면 `0`), Slot은 자기 `.Length`(`State`, + 아래 참고), `state`처럼 store-bind로 오가는 단일 위치는 그 + store-bind 핸들러가 값이 바뀔 때마다 다시 호출. **호출 책임은 `Slot` + 자신의 `:List`/CRUD가 아니라 그 위치를 처음 매치한 Handler(`Dispatch/ + Slot.luau`)** — `Slot`은 `inst`/`i`를 모르는 독립 값(어디 마운트될지 + 자기가 결정 안 함)이라, `process(inst, i, slotValue)`가 매치되는 + 시점에 그 Handler가 `Dispatch.setLength(inst, i, slotValue.Length)`를 + 1회 호출(길이 자체가 바뀌는 매 순간은 이미 `slotValue.Length`가 + `State`라 알아서 전파됨, Handler가 매번 다시 부를 필요 없음). `state` + 교체 시엔 이 Handler가 새 값으로 다시 `setLength`를 호출. +- **`setOffsetSource`**: 이 위치가 자기 순서 계산에 쓸 `Source`를 + **스스로 만들어서** 등록 — Dispatch는 그냥 레지스트리에 넣어두기만 + 하고, `recompute`가 그 자리에 값을 `:Set()`함. Handler는 이 **같은** + Source 객체를 자기 원소(들)의 `LayoutOrder` 바인딩에 그대로 씀 + (`localIndex:With(offset):Compute(function(i,o) return i+o end)`을 + `LayoutOrder`에 store-bind로 걸어두면, offset이 바뀔 때 기존 store-bind + 재실행 메커니즘이 알아서 다시 씀 — 새 push/observer 시스템 불필요). + **실제 마운트를 하지 않는 위치는 `None`을 등록** — 순서 계산에 + 참여할 게 없다는 명시적 선언. 대상은 Ref/PreRef뿐 아니라 **그 배열 + 위치의 값 자체가 `None`인 모든 경우**(예: `props.Ref or None` 관용구로 + 캐우칭된 미전달 Ref, PreRef pre-pass가 소진시킨 슬롯 등) — `setLength`도 + 같은 위치엔 짝을 맞춰 `0`으로 등록해야 함(위 `setLength` 항목의 + "`nil`/`None`이면 `0`" 규칙과 항상 같이 감, 둘 중 하나만 반영되면 + 길이 합계와 실제 순서 계산이 어긋남). + +**둘 다 array part의 모든 number 인덱스에 대해 반드시 호출 — 생략은 UB +(2026-08-09 여섯 번째 세션 확정).** `retract` 필드 생략 불가와 같은 톤 — +이건 **Handler 구현체 작성자만 지키는 계약**이고 일반 컴포넌트 작성자는 +이 존재 자체를 몰라도 됨(사용성 저하 없음), API 문서화만 명확히 하면 됨. + +**저장 위치**: `lengthList`/`sourceList`(부모 `inst` 하나에 귀속, 그 +`inst`의 array part 크기 `N` — `bk.N`으로 같이 저장, `Dispatch.drive`가 +최초 배열 파트 순회 시점에 이미 알고 있는 값) — `Relate(parentInst)`에 +lazy 생성. + +**`sourceList`에도 `nil`이 아니라 `None`을 쓰는 이유는 기존 배열 파트 +원칙 재사용** — 모든 number 인덱스를 반드시 채워야 하는데(위 UB 규칙) +`nil`을 넣으면 (1) 그 자리가 "안 채워짐"과 구별이 안 되고 (2) 배열이 +구멍 나면서 순수 array 취급이 깨져 접근 비용이 올라감(해시 파트로 밀림) +— `None`은 실재하는 값이라 자리를 "채워짐"으로 유지시켜줌, PreRef +pre-pass 소진 슬롯에 이미 적용된 것과 같은 원칙(위 "PreRef" 절의 +"왜 `None`이 아니라 `nil`인가" 참고 — **단, 그 절에서 최종적으로 `nil`로 +되돌아간 건 Ref 콜백/대기자 배열 한정**이고 `sourceList`/PreRef +pre-pass처럼 순서가 실제로 중요하거나 "채워짐 여부"를 엄밀히 구별해야 +하는 배열은 여전히 `None`이 맞음, 헷갈리지 말 것). 다만 `recompute`가 +`1..N` 고정 범위를 도는 인덱스 `for`라 애초에 성긴 정수 키 순회 문제 +자체는 안 생김 — `None`이 필요한 이유는 순회 순서 보존이 아니라 "채워짐 +여부 구별과 접근 비용" 쪽. + +**recompute — 매번 전체 순회, `Get` 가드로 캐스케이드만 방지**: + +```lua +local function recompute(inst, bk) + local sum = 0 + for i = 1, bk.N do + local v = bk.lengthList[i] + sum += (isState(v) and v:Get() or v) + local offset = bk.sourceList[i] + -- offset은 실제 Source이거나 None(참여 안 함) — None은 truthy라 + -- `if offset then`만으로는 안 걸러짐, 명시적으로 배제해야 함 + if offset ~= None and offset:Get() ~= sum then -- 실제로 다를 때만 Set + offset:Set(sum) + end + end +end +``` + +전체 순회의 O(N) 비용은 무시 가능(`N`은 저작 시점에 고정된 배열 리터럴 +길이, 보통 작음) — 진짜 비싼 건 `Set`이 트리거하는 다운스트림 리액티브 +캐스케이드(그 위치에 이미 마운트된 원소들의 `LayoutOrder` 재적용)라, +`Get() ~= sum`일 때만 `Set`해서 안 바뀐 앞쪽 위치들은 캐스케이드가 안 +일어나게 막음. + +**`setLength` 구현 — leaf-lifetime 경로(`bindLifetime`/`unbindLifetime`), +`:Subscribe()` 아님(2026-08-09 여섯 번째 세션)**: + +```lua +function Dispatch.setLength(inst, i, len) + local bk = getBookkeeping(inst) -- Relate(inst) 기반, lazy 생성 + + local oldObserver = bk.observers[i] + if oldObserver then + unbindLifetime(inst, oldObserver) -- gchold 내부 구조 몰라도 됨 + bk.observers[i] = nil + end + + bk.lengthList[i] = len + + if isState(len) then + local observer = len:Observer(function() + recompute(inst, bk) + end) + bindLifetime(inst, observer) -- inst 생명주기에 귀속, Subscribe 아님 + bk.observers[i] = observer + end + + recompute(inst, bk) -- 등록 즉시 1회(Observer 자체의 "등록 즉시 1회 실행"과 겹쳐도 무해) +end +``` + +`:Subscribe()`/`:Unsubscribe()`(독립 경로)를 안 쓰는 이유: 이 Observer는 +본질적으로 `inst` 하나에 종속된 내부 배관이라, `inst`가 Destroy될 때 +같이 죽어야 함 — `:Subscribe()`는 명시적 `:Unsubscribe()`가 없으면 안 +끊기므로 안 맞음. `bindLifetime`/`unbindLifetime`이 이미 이 요구(GC-native, +`inst` 생명주기에 자동 귀속)를 충족. + +**동기 순서 — offset 갱신이 마운트보다 먼저 끝나야 함(안 그러면 Roblox의 +실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출될 위험)**: +Slot의 `rawAdd`는 `self.Length:Set(newCount)`(→ 다운스트림 offset/LayoutOrder +갱신이 동기적으로 여기서 끝남) 다음에 `element.Parent = target`(→ 이제 +트리에 보이는 시점엔 다운스트림이 이미 정합적) 순서로 호출. `Length:Set` +자체도 이전 카운트와 실제로 다를 때만 호출(no-op 캐스케이드 방지, 위 +`Get` 가드와 같은 원칙을 호출부에서도 적용). + +**`:List` reconcile에서 `Length` 갱신 시점**: 한 사이클(여러 항목이 +한꺼번에 추가/제거되는 경우 포함) 전체가 끝난 뒤 **한 번만** — 사이클 +도중 항목마다 갱신하면 캐스케이드가 그만큼 반복됨. + +**웹 백엔드(quad-web, 아직 없음) — 같은 `lengthList`/`sourceList`/ +`recompute`를 그대로 재사용, 다른 건 "offset 변경 시 무엇을 하는가"뿐**: +DOM의 `insertBefore`류는 물리적으로 삽입하면 뒤 형제가 자연히 밀려나므로, +`offset`이 바뀌었다고 이미 마운트된 원소를 실제로 옮길 필요가 없음 — +quad-web의 해당 Handler는 offset 변경 관측 시 아무것도 안 하는 no-op이고, +`offset` 숫자는 그 위치가 **다음에** 스스로 insert/remove할 때 어느 +물리 인덱스에서 해야 하는지를 위해서만 부기됨. base 레벨 로직은 완전히 +동일, backend Handler의 "무엇을 하는가"만 다름. + +**`Slot.Length`와 `Slot.Offset`은 별개(사용자 질문으로 명시화)**: +`Length`는 Slot이 스스로 노출하는 순수 출력값(지금 실제로 마운트된 +개수) — "n개 검색됨" 같은 UI에 그대로 써도 되고, 동시에 위 `setLength`가 +읽는 바로 그 값(하나의 State가 두 용도를 겸함). `:List`가 filter 탈락을 +실제 `Remove`로 처리하도록 이미 확정해둔 덕에(Visible 토글 아님) `Length`는 +자동으로 "실제 마운트된 것"만 반영 — 수동 Visible 토글을 쓰는 경우엔 +`Length`가 그걸 못 잡는 게 맞고, 그건 별도 State로 계산해야 하는 사용자 +몫. `Offset`은 Dispatch가 `setOffsetSource`로 등록받아 `recompute`가 +채워주는 입력값, 순서 계산 전용 — 서로 다른 두 `Source`. + +`base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 절이 이 메커니즘으로 +해소됨 — 상세는 그 문서 참고. + +**동적 자식 추가/제거의 유일한 정당 경로는 `Slot` 또는 `state`류 +store-bind — 그 외 방식은 UB로 확정(2026-08-10 세션).** `Length`/`Offset` +카운팅은 그 위치를 담당하는 Handler(`Dispatch/Slot.luau`, store-bind +프로퍼티 핸들러)가 `Dispatch.setLength`/`Dispatch.setOffsetSource`를 +호출해줘야만 정합적으로 유지됨 — 이 두 API를 부르지 않고 quad가 관리하는 +부모 Instance에 자식을 끼워 넣는 경로(예: 사용자 코드가 `newInst.Parent = +parentInst`를 직접 호출해 Slot이 마운트해둔 부모 밑에 자식을 몰래 +추가/제거하는 것)는 `lengthList`/`sourceList`가 그 변화를 전혀 모르게 +만들어 카운트·형제 순서 계산이 조용히 어긋남 — 별도 방어 로직 없는 UB. +`Slot`이든 `state`이든 둘 다 이미 이 두 API를 정확히 호출하는 +유일한 정당 경로로 확정돼 있음(위 `setLength`/`setOffsetSource` 절 +참고) — 새 경로를 만들 필요 없이 "동적 자식은 반드시 이 둘 중 하나를 +거쳐야 한다"는 규칙만 문서화하면 됨. + +## Store 바인드는 특수 경우인가, 아니면 pluggable 바인드를 재실행하는 래핑인가 + +사용자 원 메모: "스토어 바인드는 특수 경우로 둘지, 아니면 다른 pluggable 바인드를 +재실행하는 래핑으로 쓸지 생각해봐야함... 충분히 확장 가능하게 둘 수 있음." + +**확정**: 래핑 쪽. 위 "확정된 디스패치 모델" 절 참고 — store 바인드 핸들러도 +다른 핸들러와 동일한 `isHandlable`/`priority`/`process`/`retract` 계약을 +따르되, 자신의 `process`가 내부적으로 "실제 값이 바뀔 때마다 (원래 key, 새 +value)로 `Dispatch.process(inst,k,realv)`를 재귀 호출"하는 식으로 구현됨. +이러면 store 값 자체가 어떤 타입이든(원시값, 인스턴스, 심지어 다른 store) +상관없이 동일한 재귀적 디스패치로 처리 가능 — 아래 "store가 store를 저장 +가능한가"와 직결. + +**"값이 바뀔 때마다"의 실제 구독 메커니즘 = `state:Observer(fn)` 재사용으로 +확정(2026-08-08 세션).** 이전엔 이 절이 구독 메커니즘 자체를 추상적으로만 +서술했는데(새 프리미티브를 발명하는 것처럼 읽힐 수 있었음), 실제로는 아래 +"`state:Observer(fn)`" 절에서 이미 확정된 것을 그대로 재사용하면 됨 — 새 +구독 primitive를 store-bind 전용으로 따로 만들 이유가 없음: + +```lua +-- 예: 일반 프로퍼티 store-bind 핸들러의 process(inst, k, state) +local observer = state:Observer(function() + Dispatch.retractUnder(inst, k, StoreBind, state:Get()) -- 나 밑에 있던 거 정리 + Dispatch.process(inst, k, state:Get()) -- 새로 위임(체인에 push) +end) +bindLifetime(inst, observer) +relate:SetStrong(inst, k, observer) -- retract에서 unbindLifetime을 부르려면 들고 있어야 함 +``` + +**[정정, 2026-08-09 여섯 번째 세션] `:Subscribe()`/`:Unsubscribe()`가 +아니라 `bindLifetime`/`unbindLifetime`을 씀 — 원래 이 절이 "leaf가 +아니니 `:Subscribe()`가 유일한 선택"이라고 적어뒀던 게 틀림.** `:Subscribe()`/ +`:Unsubscribe()`는 **`inst`와 아예 무관한 전역/독립** Observer(모듈 +최상위에 두는 디버그 print용 등)를 위한 전역 GC 방지 테이블 전용 — +"leaf가 아니면 `:Subscribe()`"가 아니라 "**`inst`에 안 묶이면** +`:Subscribe()`, `inst`에 묶이면(leaf든 이런 핸들러 내부 배관이든) +`bindLifetime`"이 실제 기준. 이 Observer는 처음부터 `inst`(그리고 그 +자식 프로퍼티 `k`)에 묶여있는 존재라 `bindLifetime`이 맞음 — 위 "이중 +바인딩 금지" 절의 정정 참고(leaf 부착도 사실 `bindLifetime` 호출이라, +`:Subscribe()`와 상호 배타적인 건 leaf가 아니라 "전역이냐 inst냐"임). + +- **`retract`가 할 일은 `unbindLifetime(inst, observer)` 호출뿐 — 위임 + 대상까지 수동으로 안 쫓아가도 됨.** `Dispatch.retractUnder`가 자기 + 밑에 위임된 걸 알아서 정리해주므로(위 "Dispatch 체인" 절), 이 + 핸들러의 `retract`는 정확히 자기 자신의 자원(Observer)만 정리하면 + 끝 — 이게 위 "이벤트도 store-bind 가능" 절에서 이미 "엔지니어링 + 비용이 낮다"고 서술한 것과 같은 이유(새 디스패치 메커니즘 없이 기존 + 계약만 구현). +- **핸들러가 직접 `canExecute`/liveness를 재구현할 필요 없음** — Observer가 + 이미 자기 `Subscribed` 상태로 게이팅됨(아래 `base/lifecycle-pattern.md`의 + `canExecute(inst, value)` 절 참고, Observer/Effect는 그 함수 안에서 + 특별 취급됨). `bindLifetime`도 이 `.Subscribed` 필드를 그대로 + 세팅/해제하므로(위 "이중 바인딩 금지" 절 참고) 이 게이팅은 그대로 유효. +- Observer가 "등록 즉시 1회 실행"이므로 **최초 적용과 이후 재실행이 같은 + 코드 경로로 자동 통일**됨 — 프로퍼티 store-bind 핸들러가 "설치 시 1회 + 적용"을 별도로 안 짜도 되는 이유(위 Observer 절의 원래 근거 그대로). +- `relate`는 `base/relate-plan.md`의 `Relate` 인스턴스 — 이 핸들러 모듈 + 톱레벨에 `local relate = Relate()`로 하나 두고 재사용. + +Slot이 store 바인드로 넘어오는 경우, pluggable 처리기에 `retract` 핸들러가 +필요하다는 점(부모가 slot을 정리하고 다시 process하는 방식)도 이 래핑 방식과 +자연스럽게 맞음 — `base/slot-plan.md` 참고. + +## Store가 Store를 저장 가능한가 + +사용자 원 메모: "슬롯을 스토어처럼 생각 가능하다면 이건 가능하다고 봐야하는가? +아니면 아예 다른 값으로 둬야 하는가? table/number 같은 프리미티브 타입이나 +ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플러그를 만드는 걸로." + +**2026-08-04 6차 확정: 그런 경우는 없다고 본다.** 위에 적힌 "재실행 래핑으로 +기계적으로는 커버 가능하다"는 제안은 메커니즘상 틀리지 않지만, 실제 설계 +의도와 안 맞음 — Store는 Source에 준하는 존재로 모든 반응형 값의 "시작점" +역할만 함. 시작점은 다른 변화하는 무언가에 연결되는 것을 제공하고자 하지 +않음(= Store가 다른 Store/State를 값으로 담아 자동으로 따라가게 하는 용도로 +쓰지 않음). Store에서 값을 꺼내 State를 옵저빙하다가 콜백으로 다른 Store 값을 +바꾸는 식의 수동 연결은 있을 수 있지만, 잘 짜인 UI에서 실사용 사례를 거의 +보지 못했다는 게 사용자 판단 — 그래서 이 케이스를 위해 별도로 신경 쓰지 않음. + +## Ref — 도입 확정, 단 용도는 재정의됨 + +**중요한 정정**: Ref는 Tween이 대상을 얻기 위해 필요한 게 아님(트윈을 +실제로 처리하는 PropertyHandler도 `process(inst,k,v)`처럼 항상 대상 +Instance를 직접 받으므로 — 위 "확정된 디스패치 모델" 참고, `research/ +tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름: + +- v1의 `Frame "id" {}` + `Store.GetObject(id)` 식 id 매핑은 폐기 확정 + (`base/architecture.md` 5번 항목) — "비현실적"이라는 게 이유. +- 하지만 **"라이브러리가 자기 자신이 만들어낸 instance를 나중에 다루기 편하게"** + 하는 용도로 Ref는 여전히 필요. 구체 시나리오: 기존에 다른 라이브러리로 + 관리되던 instance를 당장 quad로 옮기지 않고, ref를 따서 그 안에 자식을 + `Parent`로 마운트한다든가, 점진적으로 마이그레이션한다든가, 래퍼를 만든다든가 + 하는 다양한 용도. +- Store는 이미 바깥에서 옵저빙 가능한 존재라 별도 취급 불필요 — Ref는 그와 + 달리 "원하는 객체 자체를 직접 얻어오는" 경로. **얻어진 뒤에 그 참조를 어디에 + 저장하고 어떻게 쓰는지는 라이브러리 책임 범위 밖**(사용자 자유). +- **바인드 방법**: children을 배열 아이템으로 넣듯 `Ref(default)`(또는 + `:Callback(fn)`을 미리 걸어둔 `Ref(default):Callback(fn)`) 인스턴스 + 자체를 숫자 키 슬롯에 그대로 넣는 방식 — `(v=Ref)` 매치 핸들러가 이걸 + 처리함. **별도 `CreatedRef` 래퍼 함수는 없음(2026-08-07 아홉 번째 + 세션, 사용자 확인) — `Ref`/`PreRef`가 이미 Compose식 `Type(default)` + 팩토리 생성자로 확정됐으므로("생성자 스타일 확정" 절), 그 결과를 그대로 + children 배열에 놓는 것 자체가 바인드 관용구.** 원래 "`CreatedRef` 같은 + 이름 미정의 래퍼"로 서술했던 것은 Ref가 아직 "instance 얻는 통로"로 + 좁게 정의됐던 시절(2026-08-04)의 잔재였고, 2026-08-06 Ref 일반화 이후 + 래퍼 자체가 불필요해졌는데 이름만 남아있던 것을 이번에 정리함. + **[정정, 2026-08-07 세 번째 세션]** 정확한 순서 보장(자식 마운트 + 전/후, 프로퍼티보다 먼저)은 위치와 `PreRef` 타입으로 갈렸음 — 아래 + "`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절이 최신, 원래 있던 + "옵션(`{phase=...}`)으로 두 타이밍을 고른다"/"특수 처리 없는 평범한 + 참가자" 서술은 `archive/ref-phase-option-reversed.md`로 옮김. +- **왜 값이 아니라 콜백인가**: quad는 React처럼 렌더 함수가 계속 + 재실행되지 않음(플레인 함수를 한 번 호출해 트리를 만들고 끝) — 그래서 + "채워졌는지 매 렌더마다 다시 확인"하는 모델 자체가 없고, `useEffect` + 의존성 배열 같은 것도 없음. 즉 값이 채워지는 시점을 외부에서 알아낼 + 방법이 콜백(또는 폴링, 채택 안 함 — `lifecycle-pattern.md`에서 폴링 + 방식은 이미 기각된 패턴) 말고 없음. 값 자체를 나중에 다루고 싶으면 + 콜백 안에서 원하는 곳(외부 변수, `self._button` 같은 필드, Store 등)에 + 직접 대입해 캡쳐하면 됨 — `component-composition-plan.md` 31행 예제 + 참고. 즉 "값으로도 얻어진다"는 요구는 별도 API가 아니라 콜백이 이미 + 충족함. + +### Ref 일반화 — 엔진 instance 전용이 아니라 범용 값 박스 (2026-08-06 후속 세션) + +**결정**: Ref는 "quad가 만든 instance를 얻는 통로"로 좁게 남지 않고, +**아무 사용자 값이나 담을 수 있는 범용 "채워지길 기다리는 값 박스"**로 +확장한다. 위 "코루틴 기반 대기 지원 여부는 미정"이었던 항목은 이걸로 +해소됨(더 이상 열린 질문 아님). + +- **object-ref/function-ref로 나누지 않음.** React의 `useRef`가 DOM + 노드든 임의의 사용자 값이든(함수 포함, `ref.current?.()`로 호출하는 + imperative-handle 패턴 포함) 같은 API로 다루는 것과 동일한 선례 — + 두 개념으로 쪼개면 사용자가 "이번엔 어느 쪽을 써야 하나" 매번 + 판단해야 해서 나쁨. 엔진 instance도 그냥 "사용자 값의 한 종류"일 뿐. +- **구체 유스케이스**: 자식 컴포넌트가 비싸고 온디맨드로만 필요한 계산 + (예: 클릭 위치 기준 컨텍스트 메뉴를 그리기 위한 clip bounds 계산)을 + 부모에 노출하고 싶을 때, 매 변경마다 push하는 대신 부모가 필요할 때만 + `ref.Value?.()`처럼 호출하는 함수를 Ref에 담아 넘기는 패턴 — React의 + imperative handle과 동일한 이유(비싼 연산이라 온디맨드가 맞음, 값이 + 최신인지 아닌지도 애매해짐). +- **API 모양**: `.Value`(읽기 전용 필드) + `:Set(value) -> Ref`(쓰기) + + `:Callback(fn) -> Ref`(콜백 등록, 복수 허용) + `:Wait(thread?) -> Ref` + (coroutine 컨텍스트에서 사용 — 렌더 함수 바디 안에서 `return` 위에 바로 + 못 씀, 그래서 콜백도 같이 필요) **세 메소드로 확정(2026-08-07 여섯 번째 + 세션)**. `:Set`/`:Callback`/`:Wait` 전부 **mutation 패턴이라 자기 자신 + (`Ref`)을 반환** — `store.key:Set(value)`류 "값을 바꾸는 연산엔 `:` + 체이닝 허용" 원칙(`base/store-semantics.md` 190행)의 자연스러운 재적용. + 이 self-반환 덕에 Luau의 `if`-표현식과 결합해 흔한 관용구를 한 줄로 + 쓸 수 있음(사용자 제시 예): + ```luau + local t = if ref.Value + then ref.Value + else ref:Wait().Value + ``` + - 콜백은 이미 채워져 있으면 등록 즉시 그 값으로 1회 호출됨 — nil/미설정 + 상태여도 그 상태 그대로 호출. React의 `useEffect`가 매번 `.current` + 존재 여부부터 체크하는 것과 같은 이유, Ref가 자식으로 전달되는 경우 + 채워지는 시점이 더 늦어질 수 있어서 "이미 채워졌는지" 확인이 항상 + 필요함. `:Wait()`의 대기자 리스트와 콜백 리스트는 같은 구조 재사용 + 가능(발화 후 해당 인덱스만 **`nil`로 소진** — 아래 구현 디테일 참고, + **[재정정, 2026-08-09 열한 번째 세션] `None`이 아니라 `nil`이 맞음**, + 바로 아래 캐비엇 참고). + - **`.Value`는 이 테이블의 평범한 hash 필드로 직접 저장하지 않고 + `__index` 메타메소드로 구현함(2026-08-09 열한 번째 세션 보강)** — + Ref 객체 자신이 곧 콜백/대기자 배열(숫자 키로 색인)이라, `.Value`를 + `self.Value = v`로 그냥 얹으면 그 값 자체가 이 테이블의 hash 파트에 + 같이 걸림. `T`가 함수나 스레드 타입일 수 있는데(Ref는 범용 값 박스, + 위 "object-ref/function-ref로 나누지 않음" 참고), `for i, v in self do` + 같은 일반화 순회가 배열 파트뿐 아니라 hash 파트도 함께 훑으므로 이 + 경우 `.Value`가 콜백/대기자 처리 루프에 잘못 걸려 `type(v)`로 + 오분류될 위험이 생김. `__index`로 실제 저장 위치를 배열과 분리해두면 + 이 충돌 자체가 안 생김. + - **`:Wait(thread?)`의 `thread` 인자(2026-08-07 여섯 번째 세션, 사용자 + 제안, 확정)**: 생략(`nil`)하면 `coroutine.running()`으로 호출 중인 + 코루틴 자신을 캡처해 대기자로 등록하고 그 자리에서 `coroutine.yield()`로 + **자기 자신을 정지**시킴(값이 채워지면 재개). 명시적으로 다른 thread를 + 넘기면 **그 thread를 대기자로 등록만 하고 정지 없이 즉시 `self`를 + 반환** — 코루틴 역학상 `coroutine.yield()`는 지금 실행 중인 코루틴만 + 정지시킬 수 있고, 어딘가 이미 정지해 있는 남의 thread를 여기서 대신 + 정지시킬 수는 없기 때문(그 thread는 이미 정지 상태). 이 표면의 + 용도: 사용자가 직접 관리하는 스케줄러가 이미 만들어 둔(어딘가 다른 + 지점에서 정지시킨) thread 하나를 Ref에 등록해두고, 등록한 코드 자신은 + 블록되지 않고 계속 진행하고 싶은 경우. 구현은 정말 단순함 — `thread`가 + `nil`이면 yield, 있으면 yield 안 함. + - **구현 디테일(2026-08-07 세 번째 세션 제안, 여섯 번째 세션에서 resume + payload 정정, 열한 번째 세션에서 소진 방식 최종 확정)**: 값이 새로 + `:Set()`될 때, 같은 배열 하나를 `for i, v in <배열> do ... end`로 한 번만 + 순회하면서 `type(v) == "thread"`면 `:Wait()`가 만든 대기자로 보고 + **`coroutine.resume(v, self)`** (즉 값이 아니라 **Ref 자기 자신**을 + resume 인자로 넘김 — 위 self-반환 관용구가 `:Wait()`의 yield + 경로에서도 그대로 성립하게 하기 위해, `coroutine.yield()`의 + 리턴값이 곧 `self`가 되도록 정정. 세 번째 세션 원안은 `value`를 + 넘기는 것으로 적혀 있었으나 이러면 `ref:Wait().Value`가 안 풀려서 + 정정) 후 **`[i] = nil`**로 소진(아래 "왜 `None`이 아니라 `nil`인가" + 참고), 아니면 일반 콜백 함수로 보고 그냥 `v(value)`(콜백은 여전히 + 원래 값을 직접 받음, 소진 안 함, 계속 유지)로 분기하면 됨 — 대기자/콜백을 + 서로 다른 배열로 나눌 필요 없이 값 타입 하나로 분기 가능 + (`type(v) == "thread"` → 대기자, `type(v) == "function"` → 콜백, + `nil` → 빈 슬롯이라 스킵). 새 콜백/대기자 등록은 `table.insert`가 + 아니라 **비어있는(=`nil`인) 첫 슬롯을 선형 탐색해 재사용**하는 + 등록 함수로 함(아래 참고) — 소진된 슬롯이 실제로 비므로 등록이 그 + 자리를 되찾아 쓸 수 있음. + - **왜 `None`이 아니라 `nil`인가(2026-08-09 열한 번째 세션, 최종 정정) + — 2026-08-07 열 번째 세션에 `None`으로 바꿨던 것은 이 배열에는 안 + 맞는 처방이었음, 되돌림.** `None`을 도입한 원래 근거(구멍 있는 + 정수 키가 해시 파트로 튀어 순회 순서가 깨짐, `table.insert`의 `#t`가 + 구멍 있는 테이블에서 미정의 동작)는 **순서가 실제로 중요한 배열** + (`PreRef` pre-pass, Length/Offset의 `sourceList` — `1..N` 고정 + 범위로 도는 `for` 루프라 구멍이 있으면 안 됨)에는 맞는 처방이지만, + Ref의 콜백/대기자 배열은 애초에 **순서가 중요하지 않다**(어느 게 + 먼저 fire되든 전부 fire되기만 하면 됨) — 일반화 `for i,v in tbl do`는 + 구멍이 있어도 순서가 뒤섞여도 **모든 엔트리를 빠짐없이 방문**하므로 + "순서 보장이 깨진다"는 문제 자체가 이 배열엔 없음. 오히려 `None`을 + 쓰면 소진된 슬롯이 영원히 non-nil로 채워진 채 남아 **매 `:Wait()` + 호출마다 배열이 끝없이 길어지는** 새 문제가 생김(등록이 항상 끝에만 + 추가되고 예전 슬롯을 재사용 못 함) — `nil`로 지우면 다음 등록이 그 + 빈 슬롯을 재사용할 수 있어 배열 크기가 동시 대기자 수만큼만 유지됨. + `table.insert`의 `#t` 문제도 **`table.insert`를 아예 안 쓰고** 빈 + 슬롯을 선형 탐색해 넣는 등록 함수로 우회하면 됨(`None`이 필요했던 + 이유 자체가 없어짐). 결론: **순서가 안 중요하고 슬롯 재사용이 + 필요한 배열(Ref 콜백/대기자)은 `nil` 소진, 순서가 중요한 배열 + (PreRef pre-pass 소진 슬롯, Length/Offset `sourceList`)은 계속 + `None`** — 두 패턴이 서로 다른 문제를 풀고 있었을 뿐, 하나로 통일할 + 이유가 없었음. + - **주의(문서화 대상, 방어 로직 없음)**: 이미 죽은(완료/에러난) thread를 + `:Wait(thread)`에 넘기면 나중에 `coroutine.resume`이 에러남 — 이건 + 다른 UB 케이스들과 같은 결로 라이브러리가 방어하지 않고 호출부 책임으로 + 둠. +- **제네릭 시그니처(2026-08-07 확정): `Ref(T) -> Ref` — 단일 타입 + 파라미터.** React `useRef(U): T|U`류 "초기값 타입과 최종 타입을 + 분리"하는 2파라미터 설계도 검토했으나(예: `Ref<>(null)` + → `HTMLDivElement|null`), Luau 솔버로는 명시된 타입 파라미터 하나와 + 인자에서 추론되는 다른 타입 파라미터가 만드는 합집합이 깔끔하게 + 풀리지 않고 미해소 제네릭 변수가 결과 타입에 남는 것으로 확인(사용자가 + 직접 Luau 플레이그라운드류로 확인) — `Source satisfies State`나 + `State` 차단 검증 항목(`research/pre-implementation-audit.md`) + 에서 이미 반복 확인된 "Luau 제네릭 솔버는 복잡한 조합에서 잘 안 풀린다"는 + 패턴과 같은 결. 단일 파라미터로 단순화하면 이 위험 자체가 없음 — 대신 + 초기값만으로 좁은 타입이 추론되는 문제(`Ref(nil)`이 `Ref`로 + 좁혀짐)는 `Ref<>(nil)`처럼 **명시적 제네릭 적용**(`f<>(...)` + 패턴, `.claude/initreq/tbox/CLAUDE.md:40-41` 선례)으로 타입을 넓혀 + 풀면 됨 — React `useRef(null)`도 명시적 타입 인자 없이는 + 같은 문제를 겪으므로 이미 널리 받아들여진 UX, quad가 새로 감수하는 + 트레이드오프 아님. +- **children 배열에 넣으면 dispatch가 자동으로 채워주는 것과의 관계**: + 이 절의 Ref가 그 범용 프리미티브 자체 — 위 "바인드 방법" 절대로 `Ref` + 인스턴스를 children 배열 숫자 슬롯에 그대로 놓으면 됨(quad가 만든 + instance에 한정된 경우). 정확한 타이밍 보장은 옵션 값이 아니라 위치 + 기반 + `PreRef` 타입으로 표현됨 — 아래 "`phase` 옵션 폐기 → 위치로 + 표현, `PreRef` 신설" 절이 최신. +- **해소됨 — 반복 재설정 가능(one-shot 아님), 사용자 확정.** React에서도 + 자식이 재생성되는 경우 같은 방식(ref가 다시 채워짐)을 씀 — 예: 마우스 + 호버/무브 시 `current` 확인 후 라벨 위치를 결정하는 라벨 컨테이너 + 하나를 두고 라벨 내용만 스왑해가며 Ref를 재사용하는 패턴. 이런 고급 + 패턴은 조심할 게 많지만 그건 라이브러리가 아니라 사용자가 신경 쓸 + 몫. **따라서 콜백은 "발화 후 소진"이 아니라 매 `:Set()`마다 다시 + 불림** — 소진되는 건 `:Wait()`가 만드는 개별 대기자(coroutine 재개는 + 본질적으로 1회성)뿐, 콜백 리스트 자체는 유지됨. +- **⚠️ Ref는 의도적으로 lazy가 아니고 `:Compute` 파생을 지원하지 않음 + — State와의 이 차이가 중요함.** (예전엔 Store가 Ref와 비슷한 것도 + 겸해서 지원한 적이 있었는데, State의 lazy 재계산 모델과 Ref의 즉시 + get/set 모델이 섞여서 좋지 않았음 — 그 경험에서 나온 의도적 분리.) + Ref는 그냥 "지금 뭐가 들어있나/누가 채워주길 기다리나"만 다루는 즉시 + 값 박스이고, 파생값이 필요하면 Store/State(`:With`+`:Compute`)를 쓸 것 + — 둘을 섞으려 하지 말 것. +- **[해소됨, 2026-08-08 다섯 번째 세션]** 위 정의 확장을 감안해도 `Ref` + 이름은 그대로 확정 — "지연 없는 확정된 값 박스"라는 정의가 leaf로 + 담기는 용도/leaf에 바인딩하는 용도 둘 다에 여전히 맞아 더 나은 대안이 + 없다는 결론, 용어 정리 대상에서 제외됨. + +### `phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설 (2026-08-07 세 번째 +세션 — 이 절이 당시 쓰던 `CreatedRef(fn, ...)` 래퍼 이름 자체도 이후 +아홉 번째 세션에서 폐기됨, 위 "바인드 방법" 절 참고) + +**children 배열에 놓는 Ref에 `{phase="created"|"mounted"}` 옵션으로 두 +타이밍을 고르게 하던 것 자체를 없앤다.** 위 "확정된 디스패치 모델" 절에 +새로 추가된 두 패스 보장(배열 파트는 index 순서대로, 그 다음 해시 파트) +덕분에, 같은 인스턴스 안에서 **일반 `Ref`를** 다른 children보다 앞/뒤 +어디에 놓느냐가 +이미 "그 형제가 마운트되기 전/후"를 그대로 결정함 — 각 자식은 자기 +서브트리까지 전부 동기적으로 마운트를 끝내야 다음 형제로 넘어가므로, +"마지막에 놓기"만으로 "모든 자식 마운트 후" 의미가 공짜로 나옴. 별도 +옵션 문법을 유지할 이유가 없어짐. **(아래 `PreRef`는 이 위치-의존 규칙의 +예외 — 위치 영향을 아예 안 받고 호이스팅됨, 해당 절 참고.)** + +**단, "프로퍼티/이벤트 세팅보다도 먼저"는 위치만으론 못 푼다.** 배열 +파트가 해시 파트보다 항상 먼저 처리된다는 보장은 **그 인스턴스의 최초 +props 테이블에 리터럴로 존재하는 항목에 한정**됨 — Modifier를 거쳐 +flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store를 거쳐 +나중에 도착하는 값은 애초에 이 최초 스캔 자체를 벗어난 시점(process/retract +재귀 경로)에 도착하므로 이 보장 밖. 그런데 "프로퍼티보다 먼저 채워져야 +한다"가 실제로 필요한 이유가 있음 — quad-roblox 이벤트는 `self(Instance)`를 +안 주기로 확정했으니(아래 절) self 접근은 Ref로 해야 하는데, Roblox +이벤트 중 일부(`ChildAdded`/`DescendantAdded`/`Changed`류)는 유저 +인터랙션을 기다리지 않고 **setup 도중 프로퍼티 대입/Parent 세팅 자체의 +부작용으로 동기적으로 발화**할 수 있음 — 이때 이벤트 핸들러가 아직 안 +채워진 self-ref를 읽으면 터짐. + +**해결**: 이 케이스만 별도 타입 `PreRef`로 분리. +- **구현은 `Ref` 그대로 재사용**(같은 `.Value`/`:Set()`/`:Callback()`/ + `:Wait()` API) — 브랜드 태그만 다른 nominal 타입. 런타임 코드 중복 없음. + **소스 파일은 분리(2026-08-07 여섯 번째 세션)**: `Ref`는 이제 그 자체로 + 충분히 완결된 프리미티브고 `PreRef`도 "children 배열 전용, 위치 무관 + 호이스팅"이라는 특이한 제약을 가진 별개 프리미티브라, 기존 프리미티브당 + 1파일 컨벤션(`modifier-plan.md`/`slot-plan.md`류, Blocker/Effect를 + 같은 이유로 분리한 2026-08-07 네 번째 세션과 같은 판단)을 따라 + `Ref.luau`/`PreRef.luau` 두 파일로 쪼갬 — 런타임 로직은 여전히 공유 + (`PreRef.luau`가 `Ref.luau`를 그대로 불러다 브랜드 태그만 얹음), 파일 + 분리는 순수 조직 문제라 위 재사용 결정과 상충 없음. `base/architecture.md` + 소스트리에 반영 완료. +- **오직 children 배열의 리터럴 아이템으로만 놓을 수 있다** — **Modifier + 필드 값으로도, Source/Store 값으로도 들어갈 수 없게 타입으로 차단.** + - Modifier 필드로 막는 이유: 거기 들어가면 flatten 후 해시 파트로 + 존재하게 돼 "배열 파트가 먼저"라는 보장 자체를 벗어남. 게다가 + Modifier는 여러 인스턴스에 재사용되는 값인데 PreRef는 정의상 "이 + 인스턴스 하나의 construction 훅"이라 애초에 공유할 이유가 없음 — + 허용해도 얻는 유스케이스가 없는 오버엔지니어링. + - Source/Store 값으로 막는 이유: Store 값은 항상 process/retract 재귀 + 경로로 도착하는데, 그 경로는 정의상 최초 배열 스캔보다 나중(또는 + 아예 스캔 밖)이라 "프로퍼티보다 먼저"를 구조적으로 만족시킬 방법이 + 없음 — `State`를 막기로 한 것(`modifier-plan.md` 7번, + 2026-08-09 세션부터 `isModifier` 기반 명시적 error)과 정확히 같은 + 원칙의 재적용. +- **`PreRef`는 배열 안 위치의 영향을 안 받는다 — 호이스팅.** 일반 + `Ref`와 달리, 같은 인스턴스의 배열 파트 안에서 다른 + children/`Ref`보다 뒤에 적었어도 그것들보다 먼저 fire됨(자바스크립트 + 함수 선언 호이스팅과 같은 느낌으로 문서화). 이유: PreRef의 존재 + 목적 자체가 "이 인스턴스에 뭐가 됐든 일어나기 전에" 채워지는 것인데, + 단순 위치 기반 순서만 따르면 그보다 앞선 형제(다른 child)가 먼저 + 마운트되면서 그 형제가 부모에 Parent될 때 부모의 `ChildAdded`류가 + 동기 발화할 수 있어 PreRef가 막으려는 문제가 그대로 재현됨. 그래서 + base 드라이버는 위 두 패스(배열→해시) 루프를 돌기 **전에** 별도의 + 작은 pre-pass로 배열 파트를 훑어 `PreRef` 항목만 먼저 전부 fire하고, + 그 다음 나머지(children/일반 Ref/프로퍼티/이벤트)를 평소처럼 두 + 패스로 처리하면 됨 — 이 pre-pass는 오직 `PreRef` 타입만 골라내므로 + 범위가 좁고, "확정된 디스패치 모델" 절의 두 패스 계약과 별개로 그 + 앞에 얹히는 것. + - **복수 `PreRef` 간 순서(2026-08-07 아홉 번째 세션, 사용자 확인) — + 새 규칙 불필요, 배열 index 순서 그대로.** 같은 인스턴스에 `PreRef`가 + 여럿 있으면, 이 pre-pass는 위 "props 순회 순서" 절이 이미 확정해둔 + "배열 파트는 index 순서대로" 계약을 그대로 재사용해 리터럴 순서대로 + fire하면 됨 — 서로 다른 우선순위/순서 개념을 별도로 만들 필요 없음 + (호이스팅은 "PreRef 전체 대 나머지"에만 적용되는 규칙이지, "PreRef끼리" + 에는 적용될 게 없음 — PreRef끼리는 그냥 평범한 배열 순회). + - **호이스팅의 실제 구현 = "물리적 재배치"가 아니라 "완전히 별도의 + 선행 스캔"(2026-08-07 아홉 번째 세션 후속, 사용자 질문에 답변).** + `Dispatch.drive(inst, flattened)`는 같은 `flattened` 배열을 **두 번 + 순회**한다 — (1) pre-pass: 배열 파트 전체를 index 순서대로 훑으며 + `isPreRef(v)`인 슬롯을 찾아 그 자리에서 fire하고 즉시 **`flattened[i] + = None`**으로 소진(`nil`이 아님, 2026-08-07 열 번째 세션 정정: `nil`로 + 지우면 그 순간 테이블이 "구멍 있는" 상태가 되어 이어지는 (2)의 순회 + 순서 보장 자체가 깨질 위험이 있음 — 정확히 이 pre-pass가 의존하는 + 바로 그 보장이라 치명적. **[주의, 2026-08-09 열한 번째 세션] Ref + 자신의 콜백/대기자 배열은 이 이유가 적용되지 않아 `nil` 소진으로 + 되돌아갔음(위 "Ref 일반화" 절 참고) — 여기 PreRef pre-pass는 순서 + 보장이 실제로 필요한 별개 케이스라 `None` 소진이 계속 맞음, 두 + 사례를 혼동하지 말 것**). (2) 그 다음에야 비로소 평소의 배열→해시 두 패스가 + **같은 테이블**을 다시 순회 — 이때 `None`으로 소진된 슬롯은 **정상 + `Dispatch.process`/`NoneHandler` 경로를 안 타고 두 패스 루프 자신이 + 직접 건너뜀**(`if v == None then continue end`, 배열 파트 전용 + 특수 취급). **해시 파트의 `None`(Modifier 필드 명시적 지우기)과는 + 의미가 다름** — 해시 파트 `None`은 "이 프로퍼티 핸들러야, 방금 + 지워진 걸 알고 알아서 처리해"라는 의미 있는 재디스패치(`NoneHandler` + 경유)지만, 배열 파트 `None`은 그냥 "여기 원래 아무것도 없었던 것과 + 같다"는 순수 빈 슬롯 표시라 처리할 핸들러 자체가 없음 — 재디스패치 + 없이 루프가 직접 스킵하는 게 맞음. "호이스팅"은 PreRef를 배열의 맨 + 앞으로 물리적으로 옮기는 게 아니라, **PreRef 전용 선행 루프가 + 통째로 먼저 끝난 뒤에야 나머지 처리가 시작된다는 뜻** — 그래서 + 소스에서 마지막 child로 적었어도 무조건 다른 모든 처리보다 먼저 + fire됨. **PreRef 슬롯을 소진시키는 게 단순 최적화가 아니라 정확성 + 요건인 이유**: 아래 "동적 경로 가드" Handler가 `(v=PreRef)`를 + 매치하면 무조건 `error`를 던지므로, pre-pass가 슬롯을 안 지우면 + 두 번째(정상) 패스가 이미 정당하게 처리된 그 PreRef를 + `Dispatch.process`로 다시 넘기게 되고, 그러면 이 가드 Handler가 + 엉뚱하게 매치되어 **정상적인 PreRef 사용에도 에러가 터짐** — 소진은 + 이 오탐을 막기 위해 반드시 필요. + - **명확화(2026-08-09 열한 번째 세션, 확인 질문에 답변) — + `NoneHandler.isHandlable(inst,k,v) = (v == None)`은 `k` 타입을 전혀 + 안 가리므로 숫자 키(`k=number`)에서도 이론상 매치될 수 있어 보이지만, + 실제로 문제 안 되는 이유는 위에서 이미 확정한 그대로다: 배열 파트의 + `None`은 **애초에 `Dispatch.process` 자체를 절대 안 탄다**(두 패스 + 루프가 `Dispatch.process` 호출 전에 자기 스스로 + `if v == None then continue end`로 걸러냄). `NoneHandler`는 + `Dispatch.process`를 거쳐야만 매치될 기회를 얻으므로, 배열 파트의 + `None`이 거기 아예 도달하지 않는 이상 `k=number` 조합으로 + `NoneHandler`가 실제로 매치되는 경우는 없음 — "재전파 없이 무시된다"가 + 정확한 설명. + - **M0 스파이크 검증 항목 갱신(2026-08-07 열 번째 세션)**: 위 "props + 순회 순서" 절은 `{a=1, 2, b=3}`류 **구멍 없는** 테이블에서 배열 + 파트가 해시 파트보다 먼저 나온다는 것만 실측 확인됨(2026-08-07 세 + 번째 세션). 같은 세션에서 사용자가 직접 `{[1]=1,[2222]=2222, + [211]=211,...}`류 **키가 듬성듬성한(sparse)** 테이블을 REPL로 + 실측해, 그런 테이블은 순회 순서가 index 오름차순이 전혀 아님(해시 + 버킷 순서)을 확인함 — 그래서 위 pre-pass는 (nil이 아니라) `None`으로 + 소진해 테이블을 "구멍 없이 촘촘한" 상태로 계속 유지하는 전략으로 + 이 위험을 원천 회피함(검증 불필요, 애초에 구멍을 안 만드므로). + **여전히 M0에서 검증해야 하는 건 다른 케이스**: `props.Modifier`/ + `props.Ref`를 caller가 안 넘겨 생기는 리터럴 `nil`-hole(`{nil, ref, + child}`, 위 "지금 할 일" 우선순위1 항목)은 caller가 직접 쓰는 raw + Lua 리터럴이라 프레임워크가 `None`으로 대신 못 채워줌 — 이번 REPL + 실측으로 그 케이스의 실제 위험도가 이전 서술("뒤 항목까지 무시될 수 + 있음", 국소적 피해로 서술돼 있었음)보다 훨씬 큼이 드러남: 구멍이 하나만 + 생겨도 **테이블 전체**가 해시 파트 취급으로 넘어가 그 인스턴스의 + 배열 파트 전체가 순서 보장을 잃을 수 있음. M0 스파이크에서 반드시 + 실측하고, 심각하면 "이런 nil-hole은 raw 리터럴로 하지 말고 항상 + `props.Modifier or Modifier()`처럼 non-nil을 보장하라"는 컨벤션 + 문서화까지 검토할 것. + - **pre-pass는 어디 사는가 — `Dispatch.drive(inst, flattened)` 자신, + 새 함수 불필요(2026-08-07 아홉 번째 세션, 사용자 제안 검토 후 확정).** + `Dispatch.drive`가 이미 `(inst, flattened)`를 받아 배열→해시 두 패스를 + 도는 함수로 확정돼 있으므로, 그 앞에 좁은 pre-pass 한 줄을 얹는 것만으로 + 충분 — `Handler.process`와 이름이 겹치는 새 `Dispatch.process(inst, + flatten, prerefs)`류 함수를 따로 만들 필요가 없음(그 이름은 이미 + 다른 뜻으로 쓰이는 `Dispatch.process(inst,k,v)` 오케스트레이터와 겹쳐서 + 안 좋음). **`flatten(nonFlatten) -> flatten` 함수 자체에 얹는 방안은 + 검토 후 기각** — flatten은 Modifier 값을 합치는 순수 변환(현재 `inst`를 + 안 받음, `research/existing-instance-bind-plan.md`가 다루는 "이미 + 마운트된 Instance 재바인드 시 Default→실값 flatten을 다시 해야 하는가" + 질문이 실제로 열려있어 flatten이 한 인스턴스 생애주기 동안 **여러 번 + 재호출될 가능성이 있음** — 여기에 PreRef fire를 얹으면 재바인드마다 + PreRef가 또 fire되어 "이 인스턴스 하나의 construction 훅"이라는 PreRef의 + 정의 자체가 깨짐. `Dispatch.drive`는 최초 마운트 시 정확히 한 번만 + 불리는 게 이미 전제라 이 위험이 없음. + - **동적 경로로 도착한 PreRef는 런타임에도 명시적으로 에러 + (2026-08-07 아홉 번째 세션, 사용자 제안 채택) — 아직 문서화 안 돼 + 있었음, 지금 확정.** 위 "Modifier 필드로 막는 이유"/"Source/Store + 값으로 막는 이유" 절은 **타입 차단**만 다뤘음 — Luau 타입은 런타임에 + 지워지므로(`:Peek`/`Overridden`/버그로 타입을 우회해 PreRef가 Modifier나 + Store 값으로 실제로 흘러들어오는 경우), 런타임에도 방어가 필요함. + 전용 `Handler`를 하나 등록: `{ isHandlable = function(inst,k,v) return + isPreRef(v) end, process = function(inst,k,v) error("PreRef는 children + 배열 리터럴에만 놓을 수 있음") end }` — `NoneHandler`와 같은 결의 + "한 값 종류만 전담하는 Handler" 패턴 재사용, 새 메커니즘 아님. 이 + Handler는 **`Dispatch.process`/`getHandler`의 정상 우선순위 스캔에 + 등록**되는 반면(pre-pass처럼 그 밖에서 도는 게 아님), 리터럴 배열의 + `PreRef`는 pre-pass가 fire와 동시에 해당 슬롯을 소진(`None` 처리, + `nil` 아님)해 정상 두 패스 스캔에 다시 노출되지 않게 하므로, 이 + Handler가 실제로 + 매치되는 경우는 오직 "타입이 막았어야 했는데 어떻게든 동적으로 + 새어들어온" 버그 케이스뿐 — 그래서 no-op이 아니라 즉시 `error`. +- **일반 `Ref`는 계속 Modifier/Store 어디든 자유롭게 + 들어감** — Store를 통해 나중에 도착하는 Ref는 그냥 도착한 그 순간 + 처리하면 됨, phase 개념 자체가 필요 없음("만난 순간 처리"로 충분). +- **quad v1의 `OnCreated` 특수 DI 키는 이식하지 않는다.** + `Ref():Callback(function(inst) end)`를 children 배열에 넣는 것만으로 + 완전히 대체됨(여러 개 등록도 자연히 지원, 별도 특수 키 불필요) — + v1 대비 빠진 기능처럼 보이지 않도록 이 대체 관계를 문서에 남겨둠. +- **`:Wait()`는 PreRef에도 그대로 유효해야 함.** PreRef 자신의 fire는 + 항상 동기적이지만, `:Wait()`를 호출하는 코드가 `task.spawn`이 아니라 + 순수 `coroutine`로 실행 중이었다면(Roblox `task` 스케줄러의 순서 보장이 + 없는 컨텍스트) 호출 시점에 아직 안 채워져 있어 실제로 yield-resume이 + 필요한 경우가 생김 — "항상 동기적이니 `:Wait()`는 즉시 리턴할 것"이라고 + 단정해 구현을 특수화하면 안 됨, 그냥 보통 `Ref`와 동일한 대기자 + 리스트/coroutine.yield 구현을 그대로 씀. **문서화 필요**: "채워졌는지 + 먼저 확인, 없으면 `:Wait()`" 방어적 패턴을 권장 관용구로 명시(콜백이 + "이미 채워져 있으면 즉시 1회 호출"하는 것과 대칭되는, 값이 없을 수도 + 있다는 걸 항상 코드가 스스로 확인해야 한다는 Ref 전체의 일관된 원칙). +- **프로퍼티/이벤트가 항상 children/Ref보다 나중에 세팅된다는 사실 자체는 + "고치지" 않는다** — 두 패스 순서를 뒤집거나 재배치하는 시도는 + 오버엔지니어링으로 판단해 안 함(이걸 원하면 애초에 PreRef를 쓰면 됨). + 이 결정과 이유는 나중에 `quadnomicon` 콘텐츠로 문서화 예정 + (`research/documentation-content-map.md` 후보로 메모). + +## 이벤트 핸들러는 self(Instance)를 받지 않는다 — 확정 (2026-08-06) + +**결정**: v1의 `function(self, ...)` 관습(`self`/`this`로 이벤트 대상 +Instance를 넘겨주는 것, `.claude/reference/quad-v1-architecture.md` 참고 — +실제로 `event.lua`의 `Bind`가 `func(self or this, ...)`로 넘겨줌)은 +**채택하지 않는다.** quad-roblox의 이벤트 핸들러는 엔진이 네이티브로 +주는 이벤트 인자만 받는다(React의 `onXxx`가 DOM 노드가 아니라 +SyntheticEvent만 주는 것과 같은 모양). + +**근거**: +1. **Ref가 이미 이 자리를 채움.** "생성 직후/마운트 후 ref 채우기"가 되는 + 순간 Instance 접근이 필요하면 클로저 캡처로 해결됨(위 Ref 절) — self는 + 그와 중복되는 두 번째 채널일 뿐이고, 두 채널이 있으면 "어느 쪽이 + authoritative냐"는 질문이 항상 따라붙음. +2. **thin wrapper를 제공하면 엔지니어링 구조 자체가 바뀜.** self로 얻는 + 값이 mutable한 재바인드 가능 wrapper라면, 그건 Modifier의 정적 + flatten(`base/modifier-plan.md`)과 항상 경쟁하는 두 번째 쓰기 경로가 + 생긴다는 뜻 — flatten된 뒤엔 wrapper 쪽에서 "이전 modifier가 뭐였는지" + 재구성할 방법이 없음. Modifier 핸들러가 KV 매치 기반이라는 걸 감안하면, + wrapper 값을 처리하려면 핸들러가 "이게 flatten된 정적 값이냐, 아니면 + 언제든 바뀔 수 있는 wrapper냐"를 매번 분기해야 함 — 오버엔지니어링이고 + hot path(매 `process` 호출)에 분기 비용이 붙음. 반대로 raw Instance를 + 그대로 주는 선택지도 있지만, 그러면 quad가 스스로 지양하는 "quad가 + 모르는 직접 mutate 경로"를 공식 API로 만들어주는 셈이라 (3)과 충돌. +3. **디버깅 관점에서 더 결정적.** quad-debug의 가치 제안이 "무엇이 + 무엇에 연결됐는가"를 선언된 반응형 그래프로 추적하는 것인데 + (`research/debug-tooling-plan.md`), self로 얻은 Instance를 이벤트 + 핸들러 안에서 직접 mutate하는 경로는 그 그래프 밖 — `base/ + purity-and-effects-plan.md`의 "재사용 가능한 컴포넌트는 store만 + 파라미터로 받아야 한다"는 이식성 원칙과도 같은 결. +4. **성능/GC**: self를 넘겨주려면 원본 콜백을 클로저로 한 번 더 감싸야 + 함(`event:Connect(function(...) func(self, ...) end)`) — Connect마다 + 불필요한 클로저 할당 비용이 들고, 최적화에도 GC 흐름에도 좋을 게 + 없음. self가 없으면 사용자가 준 함수를 그대로 `:Connect`에 넘기면 + 충분함. quad는 어차피 라이프사이클 끝까지 바인딩을 들고 있으므로 + (`base/lifecycle-pattern.md`, rbvm 선례 — GC-native), Destroy되면 + 해당 Connection도 자연히 같이 정리됨 — 별도 Disconnect 관리가 애초에 + 불필요. **[정정, 2026-08-06 후속 세션] 동적으로 Connect/Disconnect를 + 반복하고 싶은 케이스는 Ref로 수동 처리하는 대신 store-bind로 네이티브 + 지원하기로 확정** — 아래 "이벤트도 store-bind 가능 — `false`로 + disconnect" 절 참고. 엔지니어링 비용이 예상보다 훨씬 낮다는 게 나중에 + 확인됨(기존 store-bind 재실행 래핑을 그대로 재사용, 새 디스패치 + 메커니즘 불필요). + +**일반화**: 이 논거의 핵심은 Roblox에 국한되지 않는 원칙으로 정리됨 — +"엔진이 네이티브로 콜백에 뭘 주든, quad는 그걸 감싸지 않고 그대로 +호출해줘도 무방하다"는 것. 다만 이벤트 등록 자체가 quad-roblox에만 +있는 개념이라(다른 백엔드는 이벤트 모델이 다를 수 있음) 이건 base +문서가 아니라 quad-roblox 로컬 결정 — 다른 백엔드 구현체를 만들 때 +참고할 만한 템플릿 정도로만 취급. + +## 이벤트도 store-bind 가능 — `false`로 disconnect (2026-08-06 후속 세션) + +**결정**: 이벤트 핸들러 값으로 State를 넘기는 것(reactive하게 콜백을 +바꿔치기/해제하는 것)을 지원한다. quad-roblox 로컬 결정, base 변경 없음. + +**엔지니어링 비용이 낮은 이유**: 이미 확정된 "Store 바인드는 pluggable +바인드를 재실행하는 래핑"(위 절, 핸들러의 `process`가 값이 바뀔 때마다 +`Dispatch.process(inst,k,realv)`를 재귀 호출) + "재실행 래핑이 `retract`도 +같이 호출한다"(Slot이 이미 이 조합을 씀, 같은 절)는 두 메커니즘이 이미 있음. +이벤트 핸들러가 할 일은 딱 하나: `process`에서 `:Connect()`한 Connection을 +per-instance 저장소에 기억해두고, `retract`에서 그걸 `:Disconnect()`하는 +것 — 새 디스패치 메커니즘 발명 필요 없이 기존 4종 계약(`isHandlable`/ +`priority`/`process`/`retract`)만 제대로 구현하면 됨. + +**`false`로 disconnect, `nil` 아님.** `nil`은 Lua 테이블에서 "키가 아예 +없음"과 구별이 안 됨(`pairs`에서도 안 보임) — "명시적으로 꺼짐"이라는 +신호를 값으로 전달하기엔 부적합. 대신 `false`(Luau에서 실재하는 싱글톤 +타입)를 "연결 없음" 센티널로 씀: `process(inst,k,false)`가 들어오면 +`retract`가 하던 일(기존 Connection 해제)만 하고 새로 Connect 안 함. +이벤트인지 여부는 값이 아니라 키(리플렉션으로 판별)로 결정되므로, 다른 +boolean 프로퍼티 핸들러와 `(k, false)` 매칭이 겹칠 위험 없음. + +**quad가 미는 기본 패턴은 아님 — 부차적 옵션.** 저빈도 UI 이벤트(클릭류)를 +조건부로 켜고 끄고 싶은 흔한 케이스는 사실 이 메커니즘 없이도 됨 — 핸들러 +하나를 계속 연결해두고 안에서 분기하면 끝: + +```lua +MouseButton1Click = function() + if not store.enabled:Get() then return end + ... +end +``` + +이 "핸들러 하나 + 내부 분기" 패턴이 Connect/Disconnect 자체가 없어서 더 +싸고, Roblox/React 어디서든 이미 익숙한 관용구라 **기본 권장 패턴**. +store-bind 방식이 실제로 값어치 있는 지점은 고빈도 신호(Heartbeat/ +RenderStepped/마우스 무브처럼 안 쓸 때 Connection을 살려두는 것 자체가 +낭비인 경우)나, 단순 on/off가 아니라 로직 자체가 바뀌는 드문 케이스. +자주 재계산되는 State에 이벤트를 직접 물리면 매 재계산마다 Disconnect+ +Connect가 도는 숨은 churn 비용도 있음(Store Set은 dedup 안 함, +`store-semantics.md`) — 그래서 남용하지 말라는 캐비엇. + +**그래도 일관성 있게 지원은 해둠.** "저빈도엔 필요 없다"가 "그러니 예외로 +빼고 못 하게 막자"로 이어질 이유는 없음 — 프로퍼티/태그/어트리뷰트가 +전부 store-bind되는데 이벤트만 특별 취급해서 뺄 근거가 약하고, 구현 +비용도 낮으니(위 "엔지니어링 비용이 낮은 이유" 참고) 일관되게 지원해두는 +쪽을 택함. 그냥 "이런 것도 가능하다" 정도로 존재하고, quad가 이 패턴을 +적극 권장하진 않는다는 톤으로 문서화(`research/documentation-plan.md` +3번 "권장 이벤트 핸들링 패턴" 문서에 이 대조까지 반영 예정). + +## 여러 Store 값을 묶어 파생값 만들기 — `:With` + `:Compute`, 포지셔널 인자 지양 + +**사용자 확인 완료, 상세 방향 확정.** 후보로 검토했던 두 방식 모두 기각: + +- **암묵적 자동 추적(Vide식 ambient stack)** 기각 — "함수 실행 중과 끝 사이를 + 확인하고 부작용이 필요"한 방식이라 Lua에서 깔끔한 방법이 아니라고 판단. +- **명시적 디펜던시 배열 + 포지셔널 인자**(`Store.Combine({a,b}, function(av,bv) + ...)`)도 기각 — 두 가지 이유: (1) 팩토리 함수로 store-bind 처리기를 쉽게 못 + 만들어줌, (2) 여러 팩토리를 체이닝하면 인자 순서가 꼬일 수 있고, 타입 표기도 + 어려워짐. + +**채택 방향**: `:With(...)`로 필요한 의존성을 모으고, 그 뒤 `:Compute(function() +... end)`에서 **`with`한 값을 포지셔널 인자로 받지 않고 클로저로 직접 읽는다** +(정확히 어떤 방식으로 "직접 읽는지"는 2차 라운드에서 확정 — self/with 값 둘 다 +lazy State 핸들로 통일, 아래 "Store/State/Source 온톨로지" 절의 "`:With`/ +`:Compute`" 부분 참고). + +**`fn`을 커링 스타일로 짜는 것도 권장(2026-08-07 일곱 번째 세션)** — +`key:Compute(makeFormatter("ko-KR"))`처럼 팩토리가 실제 `fn`을 만들어 +반환하는 패턴, Observer/Effect의 동일 관용구(아래 "`fn`을 커링 스타일로 +짜는 것도 모듈화 관용구로 권장" 절, `base/effect-plan.md`)와 같은 결 — +`:Compute`가 원래부터 이 셋 중 제일 먼저 있던 자리라 뒤늦게 문서화된 +것뿐, 새 결정이라기보다 이미 있던 패턴을 명문화한 것. + +### `:Compute(fn)`의 선택적 두 번째 인자 — `previous` (무거운 파생 객체 재사용, 2026-08-06) + +**배경**: `:Compute`의 결과가 그 자체로 무겁고 재생성 비용이 큰 엔진 +객체일 수 있음(예: 큰 로케일 테이블을 Roblox `LocalizationTable` +Instance로 변환하는 경우 — `LocalizationTable`은 `Set`/`Get`/`List`로 +부분 갱신 가능한 userdata). 매번 새로 만들지 않고 이전 결과를 그대로 +재사용해 필드만 patch하고 싶을 때를 위해, `fn(value, previous)` 형태로 +**직전에 이 Compute 함수가 반환했던 값**을 두 번째 인자로 받을 수 있게 +한다. + +- **opt-in**: 안 쓰는 Compute 함수는 두 번째 인자를 그냥 무시하면 됨 — + 비용 0. 대부분의 Compute는 이걸 쓸 필요 없음. +- **`previous`는 "바로 직전 버전"이 보장되지 않음.** lazy pull 모델이라 + 중간에 여러 번 무효화됐어도 실제로 관측(`Get()`) 안 됐으면 재계산 + 자체가 안 일어남 — 그래서 `previous`는 몇 세대 전 값인지 알 수 없음. + **따라서 `previous`를 다루는 로직은 반드시 "현재 입력 전체 대 이전 + 결과 전체"의 full diff여야 하고, "정확히 한 단계 전"이라고 가정하는 + incremental delta 로직을 짜면 안 됨.** 이건 React 자체의 reconciler가 + 하는 것과 같은 모양(old tree/new tree 전체 비교 후 실제 host 객체에 + 패치 적용)이라 새로 발명하는 패턴은 아님. +- 최종 소비처가 patch된 값을 다시 한번 Set/Parent하게 되는 경우가 + 있어도(레퍼런스는 같은데 다시 대입) 대체로 치명적이지 않음(Roblox + 프로퍼티 재대입은 저렴/멱등인 경우가 대부분) — 문서화만 해두면 충분. + +**⚠️ 이 패턴을 쓸 때 반드시 같이 지켜야 하는 것 — "확정(관측)되기 전엔 +연산이 없다".** `previous`를 mutate하는 로직은 Compute 함수 **본문 +안**에 있으므로, 그 함수가 재실행되지 않으면(=아무도 다시 `Get()`하지 +않으면) mutation 코드 자체가 아예 실행되지 않는다 — 단순히 "가끔 +stale하다" 수준이 아니라 **영영 갱신이 안 일어날 수 있음**. 이 패턴으로 +만든 State는 반드시 다음 중 하나로 계속 능동적으로 관측되어야 함: +1. quad의 정상적인 선언적 prop 바인딩 경로(`[Property "X"] = someState` + 류)에 실제로 물려있어서, dispatch 엔진이 무효화 시 자동으로 + 재`Get()`하게 되어 있거나, +2. 아래 "Observer" 절의 `state:Observer(fn)` + 콜백 안에서 명시적 + `Get()` 호출 + 그 결과를 children 배열에 넣어 라이프사이클에 + 묶어두기. +"Ref로 한 번 얻어서 수동으로 Parent만 하고 끝"처럼 능동적 관측 경로가 +안 남아있으면, 이 최적화는 그냥 조용히 작동을 멈춘다. + +**[2026-08-09 세션] 오버엔지니어링 의심 재검토 — 기각, 현재 설계 +유지.** `research/pre-implementation-audit.md` 3-1이 "클로저 업밸류로 +이미 되는 걸 별도 API로 만든 것 아니냐"고 의심했던 것에 대한 사용자 +반박: 클로저 업밸류 대안은 실제로 다음처럼 즉시실행함수(IIFE)로 감싸 +업밸류를 준비해야 함 — + +```lua +local computeFn = (function() + local prev + return function(self) + -- prev를 읽고 새 값을 계산, prev 갱신 + prev = ... + return prev + end +end)() +someSource:Compute(computeFn) +``` + +이 준비 코드 자체가 이미 별도 `previous` 인자 하나보다 무겁고 번거로움 +— "재사용하고 싶으면 그냥 캐시된 값을 바로 넘겨주면 되는" 게 더 +단순하다는 게 사용자 논거. 반대로 `previous`가 없으면 `fn`은 매 호출마다 +새 인스턴스를 만들어야 해서(예: `LocalizationTable.new()`) lazy든 +아니든 재계산이 일어날 때마다 항상 비싼 재생성이 발생 — `previous`가 +막으려는 문제는 실재함. **`pre-implementation-audit.md` 3-1 해소 — +현재 `fn(self, previous)` 설계 그대로 유지, API 표면을 줄이지 않음.** + +**스코핑 명확화(이번 세션에 확인, 새 결정 아님) — `previous`는 `self` +(입력)가 아니라 "이 `:Compute` 호출 하나가 만들어낸 결과 State 노드" +자신에 귀속된다.** State가 `:With`/`:Compute` 호출마다 새 노드를 +만든다는 건 이미 확정된 온톨로지(아래 "왜 State 체인을 Modifier처럼 +플래튼하지 않는가" 절)라, `previous`도 그 새 노드의 내부 캐시 슬롯일 +뿐 `self`에 얹히는 게 아님 — 같은 `self`에서 여러 `:Compute`가 갈라지는 +팬아웃(`c1 = w:Compute(g1)`, `c2 = w:Compute(g2)`)이 있어도 `g1`/`g2` +각자의 `previous`는 각자의 결과 노드에 독립적으로 저장되므로 서로 안 +섞임 — 새로 결정할 것 없이 기존 "노드별 캐시" 원칙의 당연한 귀결. +(참고: `self.Cache`처럼 `self` — 즉 입력 — 에 캐시를 얹는 모양은 이 +스코핑과 안 맞아 채택하지 않음 — 팬아웃 시 여러 소비자가 같은 +`self.Cache` 슬롯을 공유해 덮어쓰는 충돌이 생기기 때문.) + +### `state:Observer(fn)` — 값을 안 실어주는 구독, children 배열에 직접 놓는 leaf 값 + +**결정(2026-08-06 후속 세션, 사용자 확정)**: 별도 `ObserverHolder` +래퍼 타입은 안 만듦 — `state:Observer(fn)`가 반환하는 값 자체가 이미 +"children 배열에 바로 놓을 수 있는 leaf 값"이라 감쌀 필요가 없음. +`Ref`와 완전히 같은 층위. **자유 함수 `Observer(state, fn)`가 +아니라 메소드 `state:Observer(fn)`로 확정** — `state`가 항상 필요한 +필수 인자라 `:` 리시버 자리에 자연스럽게 들어가고(다른 형태면 인자 +두 개짜리 자유 함수가 되어 읽는 순서가 어색해짐), `architecture.md`의 +"함수지향 디폴트, `:` 체이닝은 예외적으로만(체이닝이 정말 편한 경우만)" +원칙이 정확히 이 경우를 가리킴 — Store 값 변경 체이닝과 같은 예외 +카테고리. **더 근본적인 이유**: `base/store-semantics.md`의 "독립 존재 +가능한 프리미티브 vs 원천에 종속된 파생 데이터" 원칙 참고 — Observer는 +State처럼 원천 없이는 존재할 수 없는 파생 데이터라, 애초에 "타입 +이름을 부르는 자유 함수 생성자" 카테고리에 안 속함(Source/Ref/Store/ +Modifier와는 다른 부류). + +```lua +local observer = state:Observer(function() + state:Get() +end) + +Frame { + observer, +} +``` + +이러면 `observer`는 `Frame`이 살아있는 동안만 유지되고, `Frame`이 +retract/Destroy되면 자동으로 정리됨. + +- **`fn`은 등록 시점에 즉시 1회 실행된다(2026-08-07 여섯 번째 세션, + 사용자 확정 — 이전까지 미명시였던 항목).** 근거: (1) 이미 채워진 + State를 나중에 구독하면 그 값을 반영하는 연산이 아예 한 번도 안 + 일어나는 문제가 생겨 초기화 순서에 디버깅 부담이 생김. (2) 초회 + 실행을 하지 말아야 할 구체적 근거가 약함. (3) **이 결정 덕에 + Observer 하나로 "초기값 적용"과 "이후 변경 반영"을 같은 코드 경로로 + 통일할 수 있음** — 예: State→프로퍼티 store-bind 핸들러가 그냥 + `state:Observer(function() inst.SomeProp = state:Get() end)`를 걸어 + 두는 것만으로 최초 적용까지 공짜로 됨(별도의 "설치 시 1회 적용" 코드를 + 따로 안 짜도 됨). `state:Observer()`(인자 없는 "항상 관측" 유틸)도 + 이 규칙을 그대로 따름 — 호출 즉시 한 번 관측이 트리거됨. +- **값을 안 실어줌 — 반드시 `Get()`을 다시 해야 함.** 기존 "emit은 + 무효화 신호 하나로 좁혀짐 — 값을 안 실어보내므로 저렴함" 원칙(아래 + "Store/State/Source 온톨로지" 절)이 그대로 적용됨: `fn`은 "뭔가 + 바뀌었으니 다시 확인하라"는 신호만 받고 새 값 자체는 안 받음 — + 위 예시처럼 `fn` 본문에서 `state:Get()`을 명시적으로 다시 + 읽어야 함. 자동으로 안 해주는 이유: 재계산이 진짜 필요한지가 다른 + `:With`한 값에 따라 갈리는 경우가 있어서(위 "포지셔널 인자 지양" 절의 + `noprint` 예시처럼 계산 자체를 통째로 생략하고 싶을 수 있음) — `Get()` + 호출 여부를 작성자가 직접 결정하게 열어둔 것. +- **`fn`을 커링 스타일로 짜는 것도 모듈화 관용구로 권장(2026-08-07 여섯 + 번째 세션)** — `state:Observer(makeLogger("x"))`처럼 팩토리가 실제 + `fn`을 만들어 반환하는 패턴, `Modifier`의 `Boldify(10)` 커링(`modifier-plan.md` + 8번)과 같은 결. `base/effect-plan.md`의 Effect도 동일하게 권장. +- **base가 제공하는 것은 `isObserver`류 타입 판별자 하나** — children + 배열 dispatch가 숫자 슬롯 값을 훑을 때 "이게 Observer인가"를 판별해 + `Ref`와 같은 방식으로 라이프사이클에 묶어주는 것 말고는 base가 + 더 해줄 일이 없음. 새 dispatch 메커니즘이 아니라 기존 children-array + 참가자 패턴의 반복. +- **콜백 실행은 기존 `canExecute` predicate로 게이팅**(Slot 생존 확인과 + 동일한 재사용 — "canExecute 하나로 통일" 원칙, 새 메커니즘 발명 아님) + — 발화 시점과 처리 시점 사이에 owning leaf가 이미 죽었으면 no-op. +- **구현 노트(사용자 제안, 확정된 아키텍처는 아니고 구현 시 참고)**: + 살아있는 Observer 집합을 Observer 값 내부 필드로 안 두고, 외부에 + weak table(`{[observer] = true}`, `__mode = "k"`)로 인덱싱하는 방식을 + 선호 — 포인터 해싱 비용만 들고 값 자체엔 부작용 없음. rbvm의 + `getNamespaceOf`류가 비슷한 외부 weak-table 인덱싱을 씀 + (`base/lifecycle-pattern.md` 참고). +- **인자 없는 `state:Observer()` — "항상 관측" 유틸.** `fn`을 생략하면 + 내부적으로 no-op 콜백을 쓰는 것으로 취급해, 그냥 "이 State를 계속 + 능동적으로 관측 상태로 유지"하는 용도로만 씀. 위 "`previous` 인자" + 절의 캐비엇("능동적 관측 경로가 안 남아있으면 mutate 로직이 조용히 + 멈춘다")을 만족시키는 가장 단순한 도구 — 별도 콜백 로직 없이 그냥 + 이 State가 계속 재계산되게만 강제하고 싶을 때 씀. 문서화만 확실히 + 하면 별문제 없음(사용자 판단). + +### `state:Apply(factory)` — Modifier와 동일한 순수 체이닝 설탕으로 확정 (2026-08-07 일곱 번째 세션) + +**처음 제안됐던 "`:With`/`:Compute` 등록을 커링으로 자동화하는 조합기" +방향은 기각됨 — 사용자가 재확인한 실제 의도는 그보다 훨씬 단순함.** +`Modifier:Apply(factory)`도 매번 새 값을 만들어내는 체이닝 설탕일 뿐이듯, +State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양이라 — +`state:Apply(factory)`는 그냥 `factory(state)`를 메소드 체이닝 문법으로 +쓴 것뿐이고 그 이상의 계약은 없음(`Modifier:Apply`와 완전히 동일한 +정의: `function(self, factory) return factory(self) end`). + +- **동기**: 커링 팩토리 두 개 이상을 이미 있는 문법만으로 이으면 바깥에서 + 안으로 겹쳐 읽어야 하는 중첩 호출이 됨 — 실제 형태로 예를 들면, + ```lua + -- Apply 없이: 안쪽(가장 최근에 만든 것)부터 거꾸로 읽어야 함 + local capped = capAt(100)(withLocale(localeStore.locale)(rawScore)) + + -- state:Apply로: 왼쪽에서 오른쪽, 만든 순서 그대로 읽힘 + local capped = rawScore + :Apply(withLocale(localeStore.locale)) + :Apply(capAt(100)) + ``` + 팩토리가 세 개, 네 개로 늘어날수록 앞쪽 버전은 괄호 깊이와 읽는 방향이 + 코드 작성 순서와 반대로 꼬여 diff/리뷰에서 특히 안 좋음 — `:Apply` + 버전은 각 줄이 "그다음 뭘 했는지"를 순서대로 나열하므로 Modifier + 체이닝(`mod:FontSize(14):Apply(Boldify(10)):Apply(Italicify)`)과 읽는 + 방식이 완전히 통일됨. `:With`/`:Compute` 자체를 대신 호출해주는 + 자동화가 아니므로, 여전히 팩토리 본문 안에서 `:With`/`:Compute`를 + 직접 호출하는 건 팩토리 작성자 몫. +- **구현 비용 거의 0**: Modifier와 달리 State/Source는 제네릭 `__index`로 + 필드 setter를 즉석 합성하는 메커니즘이 없어서(고정된 메소드 표면만 + 존재), Modifier의 `Apply`처럼 "필드 이름으로 예약해야 하는" 충돌 + 자체가 없음 — 그냥 고정 메소드 하나 추가하는 것. +- **타입은 `factory: (State) -> U): U`로 완전히 열어둠** — Modifier의 + `Apply`는 `factory: (M) -> M`으로 같은 타입을 유지해야 체이닝이 + 이어지지만, State의 `:Apply`는 팩토리가 State가 아닌 값(예: 최종 + 요약된 plain 값)을 반환해 반응형 그래프를 벗어나는 탈출구로 쓰는 것도 + 막을 이유가 없음 — Modifier보다 오히려 더 자유로운 시그니처. +- **Source도 자동 포함**: Source가 State를 구조적으로 만족하는 기존 + 델리게이션(`__index`로 `:With`/`:Compute` 위임)에 `:Apply`도 그대로 + 얹히므로 별도 구현 불필요. +- **Effect/Observer/Compute의 `fn` 커링 권장(위 절들)과 같은 스레드지만 + 별개 기능** — 커링은 "`fn` 자체를 팩토리로 짜는 관용구" 권장이고, + `:Apply`는 그렇게 만든 팩토리를 체이닝 문법으로 적용하는 수단. 둘이 + 합쳐지면 `state:Apply(makeFormatter("ko-KR"))`처럼 자연스럽게 이어짐. + +**Observer/Effect의 `:Subscribe()`/`:Unsubscribe()`는 이 절과 무관한 +별개 주제** — 아래 새 절로 분리(이전에 이 헤더 아래 잘못 걸려 있던 +문서 버그 수정, 내용 자체는 이미 확정된 것 그대로). + +### Observer의 `:Subscribe()`/`:Unsubscribe()` — children 배열 밖 독립 구독 (2026-08-06 후속 세션) + +**문제**: children 배열에 넣는 자동 라이프사이클 바인딩은 Observer가 +"어딘가 leaf에 붙어있다"는 걸 전제함. 근데 흔한 실사용 패턴 하나가 이 +전제를 깨뜨림 — 개발자가 디버깅용으로 `RunService:IsStudio()` 가드 +안에서 Store에 직접 Observer를 걸어 `print`하는 패턴(원하면 BooleanValue +로 부분부분 켰다 껐다 하기도 함). 이건 다크패턴이 아니라 오히려 방어적인 +엔지니어링이고, 붙일 leaf 자체가 없는 "전역/독립" 사용이라 위 weak-table +기반 자동 추적이 적용 안 됨. **[용어 정정, 2026-08-09 여섯 번째 세션]** +여기서 "weak-table 기반 자동 추적"이라 부른 것이 나중에 정식으로 +`bindLifetime`(`base/lifecycle-pattern.md`)으로 명명됨 — 별도 메커니즘 +두 개가 아니라 같은 것의 명명 전/후 표현. + +**해결**: 명시적 `:Subscribe()`/`:Unsubscribe()`를 추가로 지원. 이건 새 +설계가 아니라 `bind-system-plan.md`의 PA님 코드 교차검증(라이프사이클 +절)에서 이미 예고해둔 확장 지점을 실제로 채우는 것 — "나중에 GC만으로 +정말 부족한 케이스가 생기면 명시적 dispose 경로를 추가로 얹는 게 가능한 +디자인"이라고 그때 이미 못박아뒀음. + +- **`local` 변수로 참조만 들고 있는 것으로는 부족한 이유**: 토글(BooleanValue로 + 로깅 껐다 켰다) 케이스에서, 참조를 끊어도 실제 GC는 결정론적으로 즉시 + 일어나지 않음 — "껐다"고 생각한 뒤에도 한동안 계속 발화할 수 있음. + `:Unsubscribe()`는 즉시/결정론적으로 끊는 경로라 이 문제가 없음. +- **liveness 체크는 필드 우선, weak table은 폴백**(사용자 제안): 외부 + weak table 조회보다 리터럴 필드 접근이 더 쌈(Luau가 문자열 키 접근을 + 미리 해시해둠) — + ```lua + if self.Subscribed then return true end + if self.Connection then return self.Connection.Connected end + ``` + 자동(리프 부착)/수동(구독) 두 라이프사이클 경로를 하나의 `canExecute`류 + predicate로 OR 묶는 자연스러운 형태. 실측은 구현 단계에서 확인. +- **내부 강참조 레지스트리**: `SubscribedObservers: {[observer]: true}`류를 + **weak 아닌 강참조**로 둠 — 여기서 weak면 "구독해서 살려둔다"는 목적 + 자체가 무의미해짐. 위 자동 케이스의 weak table과 역할이 명확히 갈림 + (weak table=자동/리프 전용, 강참조 레지스트리=수동 구독 전용). + **`:Unsubscribe()`는 이 레지스트리에서 반드시 `SubscribedObservers[observer] + = nil`까지 해야 함** — `Subscribed` 플래그만 내리고 강참조를 안 끊으면 + GC 대상이 안 되는 반쪽짜리 해제가 됨, 둘은 항상 같이 일어나는 한 세트. +- **`:Subscribe()`/`:Unsubscribe()` 둘 다 idempotent** — 이미 구독 중인데 + 또 Subscribe해도, 구독 안 했는데 Unsubscribe해도 에러 안 나고 그냥 + no-op. 토글 로직 짤 때 상태 추적 부담을 줄여줌. +- **[정정, 2026-08-09 여섯 번째 세션] "`:Unsubscribe()`는 자동(리프) + 케이스에도 동일하게 씀"은 틀림 — 리프/`bindLifetime` 경로의 조기 + 해제는 `unbindLifetime(inst, value)`가 담당, `:Unsubscribe()`는 + 전역 강참조 레지스트리 경로 전용으로 남음.** `inst`를 모르는 + `:Unsubscribe()`가 `bindLifetime`이 어느 `inst`에 등록했는지 찾아낼 + 방법이 없어서(레지스트리가 `inst`별로 나뉘어 있음) 하나로 통합할 수 + 없음 — 위 "이중 바인딩 금지" 절의 정정 참고. +- **`state:Observer(fn):Subscribe()`처럼 참조를 아무 데도 안 담아도 정상** + — 강참조 레지스트리 자체가 생존을 보장하는 유일한 근거라, 로컬 변수에 + 담아둘 필요가 없음. 예외 없이 그냥 계속 돎(그게 이 메커니즘의 핵심 + 포인트). +- **⚠️ 이건 quad 전역의 "정리는 기본적으로 GC에 위임" 원칙의 의도적 + 예외 — 문서에 명시적으로 경고할 것(2026-08-09 열한 번째 세션).** + `:Subscribe()`로 등록한 뒤 로컬 변수 참조를 전부 놓아도(스코프 이탈, + 변수 재할당 등) **GC되지 않고 영원히 계속 실행됨** — 강참조 + 레지스트리가 그 자체로 생존을 보장하기 때문. `bindLifetime`(leaf + 부착 포함) 경로는 `inst`가 죽으면 자동으로 정리되는 GC-native 그대로지만, + `:Subscribe()` 경로는 오직 명시적 `:Unsubscribe()` 호출로만 끊김 — 이 + 차이를 모르고 "quad는 다 GC-native니까 참조만 버리면 되겠지"라고 + 가정하면 조용한 누수(메모리뿐 아니라 계속 재실행되는 콜백까지)로 + 이어짐. 용도도 "완전히 top-level(어떤 Instance 생명주기에도 안 묶인) + 사이드 이펙트"로 좁게 문서화할 것 — 특정 `inst`에 묶인 경우는 + `:Subscribe()`가 아니라 leaf 부착(`bindLifetime`)이 정상 경로. +- **`:Subscribe()`/`:Unsubscribe()` 둘 다 `self`를 리턴(대칭)** — + `local obs = state:Observer(fn):Subscribe()`처럼 "구독 시작 + 나중에 + 끊을 핸들 확보"가 한 줄로 되고, `table.insert(subs, state:Observer(fn) + :Subscribe())`처럼 리스트에 담을 때도 줄바꿈 없이 됨. Observer가 + immutable 값이 아니라 원래 mutable한 구독 핸들이라 fluent 체이닝이 + 자연스러움 — Modifier의 clone-then-return 체이닝과는 다른 이유(같은 + 객체를 mutate하고 그대로 돌려주는 것)지만 표면 문법은 비슷하게 + 체이닝 가능. + +### 이중 바인딩 금지 — 진짜 독립된 경로는 `:Subscribe()`(전역)와 `bindLifetime`(inst-scoped) 둘뿐, `canBound(handle)`로 즉시 에러 (2026-08-07 일곱 번째 세션, 2026-08-09 세션에서 이름 확정, 같은 날 여섯 번째 세션에서 "leaf 부착=bindLifetime 호출"로 정정) + +**규칙**: 같은 Observer/Effect 핸들 하나는 라이프사이클 바인딩 경로를 +딱 하나만 가질 수 있음 — `:Subscribe()`로 전역 강참조 레지스트리에 +등록되거나(위 절), `bindLifetime(inst, value)`로 특정 `inst`에 종속되거나 +(아래 "`bindLifetime`도 같은 게이트를 공유" 절) — **이 둘 중 하나만**. + +**[정정, 2026-08-09 여섯 번째 세션] "leaf 부착"은 세 번째 독립 경로가 +아니라 `bindLifetime`을 호출하는 것 그 자체다.** `Frame { observer }`처럼 +children 배열에 Observer를 직접 놓으면, `Dispatch/Leaf.luau`가 이걸 +매치해 내부적으로 `bindLifetime(inst, observer)`를 호출 — "children +배열에 놓여 leaf에 자동 부착"과 "`bindLifetime`으로 특정 `inst`에 +종속"은 **같은 동작**이라 서로 배타적일 수 없음(둘 다 하는 게 아니라 +leaf 부착이 곧 `bindLifetime` 호출 방식 중 하나일 뿐). 그래서 실제 +상호 배타는 "전역 소유(`:Subscribe()`)" vs "특정 `inst` 소유 +(`bindLifetime`, 직접 호출이든 leaf 부착을 통한 호출이든)"라는 +**2-way**로 정정 — 위 "Observer의 `:Subscribe()`/`:Unsubscribe()`" 절이 +leaf 부착을 "weak table 기반 자동 추적"이라 불렀던 건 `bindLifetime`이 +정식 이름을 얻기 전(2026-08-06 후속 세션) 표현이라 지금은 같은 것을 +가리킴 — 별도 메커니즘 두 개가 있던 게 아니었음. + +**둘 이상 동시에 걸리는 건 UB로 확정** — 이미 한 경로로 바인딩된 핸들을 +다른 경로로 또 바인딩하는 건 금지(leaf로 이미 부착된 걸 `:Subscribe()` +하는 것, 또는 그 반대). 같은 값을 `bindLifetime`으로 두 번(leaf 부착 +한 번 + 직접 호출 한 번, 또는 leaf로 두 Instance에 부착) 등록하려는 +것도 걸림 — 이건 "leaf vs bindLifetime 충돌"이 아니라 "같은 단일 +메커니즘을 중복 호출"하는 것이라 자연히 같은 게이트가 잡아줌. + +**UB를 조용한 오동작이 아니라 즉시 에러로 만든다** — 판별 비용이 사실상 +0(불리언 필드 하나 확인)이라, 조용히 이상하게 동작하게 두는 것보다 +바로 에러를 던져 버그를 그 자리에서 잡는 게 엔지니어링상 훨씬 쌈. + +**이름 확정 — `canBound(handle): boolean`, `canExecute`와 같은 결의 +탑레벨 함수(2026-08-09 세션, 가칭 `Bound` 필드를 직접 노출하는 대신).** +`canExecute(inst, value)`가 "지금 살아있어서 실행돼도 되는가"를 묻는 +탑레벨 predicate인 것과 똑같이, "아직 어느 경로로도 안 묶였는가"도 +raw 필드(`self.Bound`)를 직접 보여주지 않고 같은 스타일의 탑레벨 +함수로 감싼다 — Observer/Effect 둘 다 쓰는 범용 predicate라 특정 +프리미티브 하나의 전용 소유물이 아니므로(`store-semantics.md`의 +네이밍 케이싱 기준: "이 이름이 특정 프리미티브 타입 하나의 전용 +소유물인가?"에 아니오라 소문자 탑레벨이 맞음, `architecture.md` +"코드 스타일 — 네이밍 케이싱" 절과 같은 기준): + +```lua +-- :Subscribe() 진입부, bindLifetime 진입부(leaf 부착도 내부적으로 이걸 거침) +-- — 둘 다 진입 전 동일하게 확인 +if not canBound(self) then + error("Observer/Effect가 이미 다른 경로로 바인딩됨 — :Subscribe()와 bindLifetime(leaf 부착 포함)은 동시에 쓸 수 없음") +end +-- 통과했으면 여기서 바인딩됨으로 표시(내부 구현 디테일 — 공개 표면은 canBound 하나뿐) +``` + +- `canBound(handle)`은 "이 핸들이 아직 어느 경로로도 안 묶였으면 + `true`, 이미 한 번 묶였으면 `false`"를 답하는 순수 predicate — 내부 + 구현은 여전히 불리언 플래그 하나(예전 가칭 `Bound`)로 충분하지만, + 공개 표면에서 그 raw 필드를 직접 보여주지 않고 함수로 감싼다는 점만 + 바뀜. 동작 자체(둘 중 한 경로만 허용, 위반 시 그 자리에서 에러)는 + 안 바뀜. **이 내부 플래그는 새 필드가 아니라 `canExecute`가 이미 보는 + `.Subscribed` 필드 그 자체(2026-08-09 여섯 번째 세션 명시)** — + `:Subscribe()`뿐 아니라 `bindLifetime`도(Observer/Effect 값에 한해) + 이 필드를 `true`로 세팅, `:Unsubscribe()`/`unbindLifetime` 둘 다 + `false`로 되돌림 — 그래야 `bindLifetime`으로 등록된 Observer도 + `canExecute`가 정상적으로 "살아있음"으로 인식함(필드를 둘로 나누면 + `bindLifetime`으로만 등록된 Observer가 `canExecute`에서 항상 + `false`로 오판됨). +- 이 predicate는 어느 경로가 먼저 왔는지와 무관하게 "이미 바인딩됨"만 + 답함 — 두 진입점이 똑같이 `canBound`를 확인하므로 순서와 무관하게 + 대칭적으로 막힘. +- **`:Unsubscribe()`는 `:Subscribe()` 경로의 해제만 담당, `bindLifetime` + (leaf 부착 포함) 경로는 `unbindLifetime(inst, value)`로 해제** — + 둘은 서로 다른 함수로 남음(호출자가 `bindLifetime`을 부른 쪽이 + `unbindLifetime`도 대칭적으로 부르는 책임을 짐 — `inst`를 모르는 + `:Unsubscribe()`가 대신 처리할 수 없는 정보라서). leaf 부착으로 + 세워진 바인딩의 실제 해제도(예: Instance 파괴 전 조기 해제하고 싶을 + 때) 결국 `unbindLifetime`이 담당 — 위 "`:Unsubscribe()`는 자동(리프) + 케이스에도 동일하게 씀" 절의 서술은 leaf 부착이 별도 메커니즘이라고 + 전제했던 것이라 **이 정정으로 대체**(`:Unsubscribe()`가 아니라 + `unbindLifetime`이 leaf 해제의 실제 통로). +- **Effect도 동일 규칙 적용(사용자 확인)** — Effect가 `state` 인자로 + 내부적으로 Observer를 조합하는 경우든, `state` 없는 경우든 같은 + `canBound` 게이트를 그대로 재사용(`base/effect-plan.md`) — Effect + 자신이 아니라 내부 Observer가 게이트를 갖고 있어서, Effect 구현이 + 이 정정을 몰라도 자동으로 커버됨. 이전에 그 문서에 적어뒀던 "leaf + 부착과 `:Subscribe()`를 동시에 쓰는 것도 안전"이라는 서술은 **이 + 규칙으로 대체(정정)** — 안전하게 지원하는 게 아니라 애초에 막아야 + 하는 조합이었음. +- **문서화 경고 대상(api/심화)**: "한 Effect/Observer 핸들을 children + 배열에 놓았다면(=`bindLifetime`으로 등록된 것) 그걸 다시 + `:Subscribe()`하거나 다른 Instance에 또 leaf로 놓지 말 것, 반대도 + 마찬가지 — 여러 경로를 동시에 쓰고 싶으면 각각 독립된 새 + `Effect(...)`/`state:Observer(...)` 호출로 따로 만들 것"을 명시할 것. + +### `bindLifetime`이 이 게이트의 두 번째(이자 leaf 부착이 실제로 쓰는) 진입점이다 (2026-08-09 여섯 번째 세션) + +`Dispatch.setLength`처럼 특정 `inst`에 종속된 내부 Observer를 등록할 때 +쓰는 `bindLifetime(inst, value)`(`base/lifecycle-pattern.md`)도 **같은 +`canBound` 게이트를 확인** — Observer/Effect 값을 `bindLifetime`할 때도 +진입 전 `canBound(value)`를 확인하고, 통과하면 바인딩됨으로 표시. +**children 배열 leaf 부착도 바로 이 `bindLifetime` 호출** — +`Dispatch/Leaf.luau`가 `(i:number, v=Observer/Effect)`를 매치하면 +그 자리에서 `bindLifetime(inst, v)`를 호출하는 것뿐, 별도 "leaf 전용" +바인딩 로직이 따로 있는 게 아님. 그래서 **실제 상호 배타는 `:Subscribe()` +(전역 강참조 레지스트리)와 `bindLifetime`(inst별 gchold, 직접 호출이든 +leaf 부착을 통한 간접 호출이든) 둘뿐** — 새 규칙을 따로 만들 이유가 +없음, 기존 게이트에 진입점 하나(`bindLifetime`, leaf 부착이 그 특수 +사례)만 추가. + +```lua +function bindLifetime(inst, value) + local isOE = isObserver(value) or isEffect(value) + if isOE and not canBound(value) then + error("Observer/Effect가 이미 다른 경로로 바인딩됨") + end + ... -- gchold 등록(base/lifecycle-pattern.md) + if isOE then value.Subscribed = true end -- canExecute가 보는 필드 그대로 재사용 +end + +function unbindLifetime(inst, value) + ... -- gchold 해제 + if isObserver(value) or isEffect(value) then value.Subscribed = false end +end +``` + +- **비-Observer/Effect 값(예: Tween 내부에 쓰는 평범한 클로저)은 이 게이트 + 자체가 안 적용됨** — `canBound`는 `.Subscribed`류 필드가 있는 Observer/ + Effect 전용 predicate라, 그 외 값은 `bindLifetime`이 그냥 통과시킴(leaf/ + `:Subscribe()` 경로 자체가 성립 안 하는 값들이라 충돌 대상이 없음). +- Observer/Effect가 `bindLifetime`으로 바인딩된 뒤엔 `canBound`가 + `false`를 반환하므로, 그 뒤에 같은 값을 leaf로 놓거나 `:Subscribe()`하면 + 기존 두 진입점의 기존 체크가 그대로 걸러줌 — 이 방향은 별도 코드 추가 + 없이 이미 성립. + +**quad의 Unix 파이프 영감(원래 동기)과 `Pipe`/`fromState` 후보 검토 경위는 +`archive/quad2-try-research-findings-rejected.md`로 이전됨** — 최종 결론만 +남기면: 목표(State끼리 자유롭게 합성/파이핑)는 아래 "Store/State/Source +온톨로지" 절의 `state(state)` 조합 모델로 달성됨, 별도 `Pipe`/`fromState` +콤비네이터 타입은 불필요로 폐기. + +## Store/State/Source 온톨로지 — 핵심 메커니즘 확정 (2026-08-04 2차 라운드) + +**상태**: 전파 모델/`:Compute` 인자 규칙/State 쓰기 금지/Slot 생존 확인/타입 +추론(dot-access) 전부 `AskUserQuestion`으로 확인 완료. 남은 건 정확한 함수/ +생성자 이름뿐(구현 단계). `base/store-semantics.md`의 "State 프리미티브는 +실제로 필요하다" 정정에서 이어짐. + +**핵심 온톨로지** (2026-08-06 후속 세션에서 Store/Source 부분 정정 — +아래 "State는 쓰기 대상이 아님" 절 이후 내용 및 `base/store-semantics.md`의 +"Source가 State를 만족함" 절 참고): +- **Source** — 실제 값이 존재하고 변경될 수 있는 단일 지점(v1의 "값의 근원"). + **구조적으로 State를 만족(단방향 호환)** — `:Get()`/`:With`/`:Compute` + 전부 지원 위에 `:Set(value)`/`:Emit()` 추가. +- **Store** — Source들의 이름 붙은 모음, 그 이상 아님. `store.a`처럼 키로 + 접근하면 **이미 만들어진 Source가 있으면 그대로 반환, 없으면 그 자리에서 + 만들어 저장한 뒤 반환**(더 이상 별도 State wrapper를 매번 만들거나 따로 + 캐싱하지 않음 — Source 자체가 이미 State를 만족하므로 wrapper 계층 + 자체가 불필요해짐. **[정정, 2026-08-07]** "Store 생성 시 전부 eager하게만 + 만들어진다"는 이전 서술은 부정확 — `defaults`가 선택이고 Luau 타입이 + 런타임에 강제 안 되므로, 생성 시점 eager 생성(각 `defaults` 키)과 + `store.key` 접근 시점 lazy 생성(아직 없는 키를 그 자리에서 만듦)이 둘 다 + 필요함, 상세는 `base/store-semantics.md` 참고). +- **State** — source(또는 다른 state)의 결과를 캐싱만 하는 존재, 자기 고유의 + 독립적 value 개념이 없음. `state(state)`로 기존 state의 결과를 받아 새 + state를 만들어 분기 가능 — 이게 사실상 Unix 파이프 영감의 "State끼리 + 합성 가능"이라는 원래 목표를 구현하는 방식. + +**전파 모델 확정: push-invalidate(신호만) / pull-recompute(`Get()` 시점에만) — +Fusion식 eager 노드·생성순 정렬은 안 만듦** + +- `Source`는 값이 바뀌면 구독 중인 State들에게 **"무효화됐다"는 신호만 + 쏜다** — 새 값 자체는 신호에 안 실림("state는 세터를 내보내기보다 + 업데이트 됐다는 신호만 쏜다" — 사용자 확정 문구). +- 신호를 받은 State는 자기 `invalid` 플래그만 세우고, 이미 `invalid`였다면 + 그 아래로 더 전파하지 않는다 — 다이아몬드 의존성에서 중복 워크를 막는 + 장치(Vide가 저자 스스로 `todo.md`에 미해결로 남긴 문제의 해결책). +- 실제 재계산은 `:Get()`이 호출되는 시점에만 일어남 — + "필요할 때 계산" 원칙(사용자 확정). Fusion의 `timeliness="eager"` 노드/ + 생성순 정렬 장치는 만들지 않음 — quad엔 그런 다단계 즉시 재계산이 필요한 + 소비자가 없다는 판단. 유일하게 "즉시 반응해야 하는" 소비자는 store-bind + pluggable 핸들러(위 "확정된 디스패치 모델" 절)인데, 이건 무효화 신호를 + 받는 즉시 자기가 알아서 `Get()`을 호출해 pull하는 방식으로 충분함 — + State 스스로 "지금 나를 보는 eager 소비자가 있나" 같은 부기가 전혀 + 필요 없음. +- `emit`은 이 무효화 신호 하나로 좁혀짐 — 값을 안 실어보내므로 저렴함 + ("emit 필요 여부" 열린 질문은 이걸로 해소). + +**전역 원칙으로 명문화: "관측해야 실체화된다" (2026-08-04 세션)** + +위 pull-recompute 규칙을 State 하나의 재계산 메커니즘으로만 읽지 말고, +프로젝트 전역에 적용되는 원칙으로 명시함: **어떤 파생값도 `:Get()`으로 +직접 읽히기(관측) 전까지는 계산되지 않는다.** 이 원칙은 State 자체뿐 아니라, +State를 필드 값으로 담고 있는 다른 구조(예: `base/modifier-plan.md`의 +Modifier)에도 그대로 적용됨 — Modifier의 getter가 State 필드를 읽으면 그 +순간이 바로 관측이고, 그 순간 계산이 확정됨. + +**주의 — 구조적 복사는 관측이 아님.** `table.clone`처럼 테이블 레퍼런스만 +복사하는 연산은 안에 담긴 State 핸들을 그대로 옮길 뿐 `:Get()`을 +호출하지 않으므로 관측이 아니고, 계산을 트리거하지 않음. Modifier 체이닝 +메소드가 `table.clone` 후 필드를 덮어쓰는 것(위 "Immutable 값 + clone 기반 +체이닝")과 이 원칙이 충돌하지 않는 이유가 바로 이것 — clone은 그저 참조 +복사라 State 필드는 클론 이후에도 여전히 살아있는 lazy 핸들로 남음. + +**왜 State 체인을 Modifier처럼 플래튼하지 않는가 (2026-08-06 후속 세션)** + +**문제 제기(사용자)**: State가 `a → b → c`처럼 계속 연결되는 구조면, 이전 +노드가 다음 노드에 대한 emit 연결/값 연결을 항상 들고 있어야 함(weak +table로 GC는 되지만 별도 데이터스트럭처 관리 부담). 대안으로, 각 State가 +자기 Compute 함수 목록을 통째로 누적해서 갖고(Modifier의 clone-then-return +체이닝처럼) 매번 클론+append하면 링크드 그래프 자체가 필요 없어지지 +않는가? + +**기각 이유 — State의 정의 자체가 "캐싱하는 존재"임.** 위 온톨로지에 +"State — source(또는 다른 state)의 결과를 **캐싱만 하는** 존재"라고 +확정돼 있고, `previous` 두 번째 인자 메커니즘(무거운 파생 엔진 객체 +재생성 비용 절감)도 이 캐싱 전제 위에서만 의미가 있음. 만약 Compute +체인을 매번 통째로 클론해 각 leaf가 독립된 함수 목록을 갖게 하면, 중간 +State를 여러 갈래가 공유하는 다이아몬드 형태(`b`에서 `c1 = b:Compute(g1)`, +`c2 = b:Compute(g2)`로 분기)에서 `b`까지의 계산이 캐시 공유 없이 소비자 +수만큼 중복 실행됨 — `previous` 메커니즘이 막으려던 문제를 반대로 다시 +만들어내는 셈이라 방향이 안 맞음. + +**"별도 데이터스트럭처 관리" 부담은 실제로는 작음.** "관측해야 +실체화된다" 원칙 때문에 살아있는 노드-대-노드 구독 엣지가 필요한 건 +실제로 관측되는(`Get()`되는) State뿐 — 중간에 만들어놓고 아무도 안 보는 +State는 구독 등록 자체가 안 일어남. 다이아몬드에서 중복 워크를 막는 +`invalid` 플래그 dedup 장치도 체인 전체가 링크드일 것을 요구하지 않고 +각 노드가 자기 구독자 목록만 가지면 되는 것이라, 이 결정과 무관하게 +그대로 유지됨. 구현은 Observer와 동일한 패턴(외부 weak table, +`{[child] = true}` 류)으로 충분 — 새 메커니즘 발명 아님. + +**결론**: 노드별 캐시 유지(현재 모델) 유지, 플래튼 기각. Modifier가 +플래튼+클론을 쓰는 건 애초에 캐싱이 필요 없는 정적 데이터라 성립하는 +것이고, State는 존재 이유 자체(캐싱)가 달라 같은 패턴을 적용할 수 없음. +`research/documentation-plan.md`의 심화 문서 후보로 남겨둠 — "왜 State는 +Modifier처럼 플래튼하지 않는가"는 설계 근거를 알고 싶은 사용자를 위한 +좋은 심화 콘텐츠 소재. + +### `:With`도 새 State 노드로 확정, 가변인자로 체인 남발 방지 (2026-08-07) + +**문제 제기(사용자)**: `:With(...)`가 문서상 가변인자 표기이긴 한데, 실제로 +호출마다(`:With(a):With(b):With(c)`처럼 체이닝할 때) 매번 새 State 노드를 +만드는 게 맞는지, 아니면 값 없이 의존성 목록만 clone-then-append로 누적하는 +가벼운 빌더로 만들어 노드 증식을 피해야 하는지가 불명확했음. + +**"빌더" 대안은 기각.** 세 가지 이유: + +1. **디버그 그래프가 꼬임.** `quad-debug`의 핵심 UX는 "무엇이 무엇에 + 연결됐는가" 그래프(`research/debug-tooling-plan.md`). With/Compute를 + 전부 실제 노드로 두면 코드상의 호출 체인이 그래프 엣지와 1:1로 그대로 + 대응됨. 빌더로 만들면 그래프 툴이 "이건 노드가 아니라 나중에 갈라지는 + 지점"이라는 가상의 분기 모양을 따로 합성해야 함 — 그럴 이유가 없음. +2. **다이아몬드 dedup을 못 타고 특수 케이스가 생김.** With가 진짜 노드면 + `w = key1:With(key2)`에서 갈라지는 `c1 = w:Compute(g1)`, `c2 = + w:Compute(g2)` 같은 흔한 fan-out이 이미 확정된 "invalid 플래그로 + 다이아몬드 중복 워크 방지" 장치(위 "전파 모델 확정" 절)를 그대로 + 재사용함. 빌더면 c1/c2가 key1/key2에 각자 직접 구독을 걸어야 해서 + 기존 dedup 경로를 매번 우회하는 특수 케이스가 생김. +3. **clone 기반 구현은 Compute 노드 위에서 실제로 깨짐(사용자 지적, + 검증 완료).** `c = a:Compute(f)` 뒤에 `w = c:With(b)`를 clone으로 + 구현하면, `table.clone`이 `c`의 캐시 슬롯(계산된 값 + `invalid` + 플래그)까지 그대로 복사해 `w`가 `c`와 별개의 독립 캐시를 갖는 사실상 + 다른 노드가 됨. `c`와 `w`가 각자 관측되면 `f`가 두 번 따로 + 실행/캐싱됨 — 바로 위 "왜 State 체인을 Modifier처럼 플래튼하지 + 않는가" 절에서 이미 기각한 것과 정확히 같은 실패 모드(공유돼야 할 + 계산이 소비자 수만큼 중복 실행). Modifier의 clone-then-append 패턴을 + State 쪽에 그대로 가져오면 안 되는 이유가 바로 이것. + +**결정**: `:With(...)`는 호출마다 self+주어진 인자들을 구독하는 **새 State +노드**를 만든다(레퍼런스 기반 구독, clone 아님) — 계산 함수는 없고 값은 +`self`를 그대로 통과(pass-through)시키되 구독 목록만 넓힌 얇은 노드. 이 +노드는 Observer와 같은 패턴(외부 weak table)으로 상위 노드의 구독자 목록에 +등록됨. + +**노드 증식 걱정은 가변인자로 해소.** 처음 문제 제기("With 하나마다 노드가 +하나씩 늘어나는 게 낭비 아니냐")는 노드 자체를 없애는 대신, `:With(...)`가 +여러 의존성을 한 번에 받을 수 있게 해서 해소함: + +- `key1:With(a, b, c):Compute(fn)` — 노드 1개(구독 3개)로 끝남. +- `key1:With(a):With(b):With(c):Compute(fn)` — 여전히 가능하지만 노드 + 3개가 만들어짐. 이건 나쁜 게 아니라 각 노드가 dedup/디버그 그래프에서 + 실제 역할(구독 fan-in 지점)을 하는 저렴한 노드(계산 없음, Modifier + clone과 같은 급의 비용)라 걱정할 비용이 아님. +- 그래도 **가변인자 스타일을 권장 관례로 삼음** — 그래프로 그릴 때도 + `:With(a, b, c)`가 `:With(a):With(b):With(c)`보다 단순(노드 1개에 들어오는 + 엣지 3개 vs 노드 3개가 순서대로 이어지는 모양)해서 디버그하기 쉬움 + (사용자 확인). + +**`:With`/`:Compute` — self 인자도 lazy 핸들로 통일** + +- 최초안(self 값은 포지셔널 raw 값, with한 값만 클로저로 읽음)에는 실제 + 단점이 있었음 — self가 raw 값이면 `fn` 호출 전에 항상 self를 먼저 + `Get()`해야 하므로, `fn` 내부 로직이 with한 다른 값을 보고 "이 경우엔 self + 계산 자체가 필요 없다"고 판단해도 이미 늦음(예: `:With(noprint)`이고 + `noprint:Get() == true`면 앞단 계산을 통째로 생략하고 싶은 경우). +- **해결(사용자 확정)**: self도 raw 값이 아니라 **State 핸들 그 자체**를 + `fn`의 포지셔널 인자로 넘긴다 — `fn(self: State)`, 내부에서 + `self:Get()`을 실제로 읽을 때만 계산이 트리거됨. with한 값과 동일한 + lazy 원칙을 self에도 그대로 적용 — 별도 `ComputeWithout` 변형은 + 불필요, `Compute` 하나로 일관. +- **[정정, 2026-08-07] `.value`는 State/Source에서 제외, `:Get()`만 지원.** + 이전엔 `Get()`을 감싼 읽기 전용 계산 속성(`base/lifecycle-pattern.md`의 + `Connected`와 동일한 "저장되는 필드가 아니라 계산된 속성" 패턴)으로 + `.value`/`:Get()` 둘 다 지원하고 `.value`를 관용적 표기로 앞세웠으나, + "관측해야 실체화된다"는 원칙이 가장 날카롭게 느껴져야 할 지점에서 + 프로퍼티 문법이 그 느낌을 무디게 한다는 재검토 끝에 함수 호출 + `:Get()` 하나로 좁힘 — `:Set()`과의 동사 짝도 자연스러움. `.value` + 표기 자체는 폐기하지 않고 **Ref 전용으로 좁힘**(Ref는 lazy가 아니라 + 값을 읽어도 계산이 트리거되지 않으므로 프로퍼티 문법이 정직함 — 이 + 절 위쪽 "Ref 일반화" 절의 `.Value`가 그대로 유일한 존재가 됨, 이름 + 충돌 자체가 사라져 별도 표기 정리 불필요). +- 예시 갱신: `store "key1":With(store "key2"):Compute(function(key1) return + key1:Get() + store.key2:Get() end)` — `key1`은 이제 raw 숫자가 아니라 + State. + +**State는 쓰기 대상이 아님 — 확정, Source는 독립 공개 프리미티브로 격상** + +- `state:Get()`은 항상 읽기 전용. State에는 쓰기 API가 아예 없음. "State에 + 직접 쓰기 API를 허용하면 다른 source에서 파생된 state에 직접 쓰기가 + 가능해져 버린다"는 이전 우려는 이걸로 근본적으로 해소(그런 API 자체가 + 없음). +- **[정정, 2026-08-06 후속 세션] 값을 쓰는 경로는 `store.key = value` + (`__newindex`)가 아니라 `store.key:Set(value)`로 전환됨** — 이유와 + 상세는 `base/store-semantics.md`의 "Store 값 설정 문법" 절 참고(요지: + Source가 State를 만족하는 구조로 바뀌며 레코드 타입 읽기/쓰기 대칭을 + 맞추려면 대입 문법을 포기해야 함 + `=`가 암시하는 "즉시 커밋"이 실제 + lazy 동작과 정서적으로 안 맞는다는 논거). 같은 문서의 "Source가 State를 + 만족함" 절에 Source/State 서브타입 구조 전체가 정리돼 있음. +- **`Source`는 Store의 내부 구현 디테일이 아니라 별도의 가벼운 공개 + 프리미티브로 노출** — Store는 다수의 source를 등록/관리하는 무거운 + 구조라, 값 하나만 반응형으로 다루고 싶을 때 Store를 통째로 만드는 건 + 비효율이라는 게 사용자 판단("store가 source 수십 개 만드는건 비효율이니 + 둘이 다른 구현이라 봐도 될듯"). `Source(initial)` 류의 독립 생성자 + (정확한 이름은 구현 단계에서 확정)가 Store와 나란히 존재. +- **생성자 스타일 확정(2026-08-06 후속 세션): Kotlin Compose식 "타입 + 이름 자체를 팩토리 함수로" — `Source(default)`, `Ref(default)`, + `Store({defaults})`.** Ref도 예외 없이 이 스타일을 따름 — Ref가 + `Ref()`로 안 만들어질 특별한 이유는 없었고(이전 절에서 API 모양만 + 다루고 생성자를 명시 안 해서 생긴 공백), `architecture.md`의 "복사 + 구현 지양, 팩토리 함수로 대체" 원칙과도 정확히 일치. `Store({defaults})`도 + 같은 스타일로 지원(`defaults`는 선택 — 안 주고 `Store()`만 호출해도 + 됨, 순수 편의용 초기값 템플릿). +- **[보강, 2026-08-09 열한 번째 세션] `Source(default)`/`Ref(default)`의 + `default` 인자가 "선택"이라는 서술은 정확히는 `T`가 `nil`을 포함할 때만 + 성립함 — 생략하면 실제로 `nil`이 그 자리를 채우기 때문.** `Source()` + (무인자)는 `Source(nil)`과 동치라고 이미 명시돼 있으나, 이게 타입 + 레벨에서 뭘 뜻하는지(`T`가 nilable이 아니면 타입과 실제 저장값이 + 어긋난다는 것)는 지금까지 명시적으로 안 적혀 있었음. `Ref`도 마찬가지 + 캐비엇이 있고 오히려 더 눈에 띄게 드러남 — `:Callback(fn)`은 등록 + 즉시 그 시점 값으로 무조건 1회 호출되므로(미설정 상태여도 그 상태 + 그대로 호출, 아래 `Ref` "바인드 방법" 절 참고), `default`를 생략한 + `Ref()`에 콜백을 걸면 그 콜백이 즉시 `nil`로 한 번 불림 — `T`가 + non-nilable이면 이 시점에 이미 타입 위반. 따라서 `default`를 생략해도 + 되는 건 오직 `T`가 nilable(`T?`)로 선언된 경우뿐이라는 걸 문서 차원에서 + 명시할 것(non-nilable `T`에 `default` 없이 생성하는 건 사용자 실수, + 타입으로 막을 수 있으면 막고 안 되면 UB로 문서 경고). + **[정정, 2026-08-07]** 아래 두 문장은 이후 라운드에서 정정된 옛 서술 — + 실제 메커니즘·mutate 취급은 `base/store-semantics.md` "Source가 State를 + 만족함" 절이 최종 소스: (a) "`__newindex`/`__index` 프록시로 감싸면 + 됨"은 이후 `store.key = value` 쓰기 문법 자체가 `:Set()`으로 옮겨가며 + `__newindex`는 더 이상 관여 안 함(읽기 쪽 `__index`는 "없으면 그 자리에서 + Source를 만들어 저장"하는 lazy 생성 용도로 여전히 필요, 위 store-semantics.md + 참고). (b) "defaults 테이블 원본을 직접 mutate하는 건 UB로 둠"도 최신 + 모델과 안 맞음 — `defaults`는 라이브 백킹 스토리지가 아니라 "아직 안 + 만들어진 Source를 만들 때 참고하는 초기값 템플릿"으로만 쓰이므로, 생성 + 후 원본을 바꿔도 문제없고 UB가 아님. + +**Slot 생존 확인 — 별도 메커니즘 아님, `canExecute` 재사용으로 확정** + +- `base/store-semantics.md`에 있던 "`isInit=false`면 허용, `isInit=true`+ + 생존확인 거짓이면 불허" 분기 초안은 폐기. state-invalidate 리스너 + 클로저도 `base/lifecycle-pattern.md`의 "생명 바인드 유틸"(canExecute + predicate)로 등록하면, 발화 시 `canExecute(inst, value)`(2026-08-08 세션 + 최종 시그니처) 하나만 확인하고 거짓이면 + 그냥 no-op — `isInit` 분기라는 별도 개념 자체가 불필요(사용자 확정: + "canExecute 하나로 통일"). + +**타입 추론 문제 — 확정(2026-08-04 3차 라운드)** + +- `store "key"`(문자열 커링)로 `state`를 오버로드 함수 타입으로 정확히 + 추론하려는 시도는 포기하고, **`store.key`(dot-access)를 1급 경로로 확정** + — Store 타입을 `{key: Source, other: Source}`류 평범한 + 레코드 타입으로 지으면 일반 구조적 필드 타이핑으로 자동 해결되고, 문자열 + 리터럴 narrowing 문제 자체가 안 생김([정정, 2026-08-06] 원래 `State` + 필드로 적혀있었으나 Source가 State를 만족하는 구조로 바뀌며 `Source`로 + 갱신 — `store.key = value` 쓰기 문법이 `:Set()`으로 옮겨가 이 필드가 + 더 이상 `__newindex`로 쓰이지 않으므로 읽기/쓰기 타입 대칭 문제도 같이 + 해소됨, `base/store-semantics.md` "Source가 State를 만족함" 절 참고). + `store "key"` 문자열 커링은 동적 키가 필요할 때 쓰는 미타입(`Source`) + 폴백으로 격하. +- 이 패턴은 Store에만 국한되지 않고 **인스턴스 생성까지 관통하는 프로젝트 + 전역 관습으로 확정**됨 — 단 이벤트는 이후 4차 라운드에서 이 관습의 + **유일한 예외**로 빠졌음(PA님 방식인 문자열 키+런타임 리플렉션으로 전환). + 아래 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절이 최신 확정 내용. + +**`Pipe`(quad2-try 후보)는 폐기 확정** — 별도 `Pipe` 타입에 소유권/버전 +가드를 넣어 재설계하는 대신, State 자체가 파이핑 결합체이고 +`state(state)`로 분기하는 위 모델로 완전히 대체됨. + +**PA님 코드와의 교차검증(2026-08-04 4차 라운드) — 둘 다 기존 확정 유지** + +`.claude/initreq/artworks/EventDrivenProgramming/`(Connection/Event/ +Observable/Observer)을 조사한 결과, 두 지점에서 기존 확정과 실제로 다른 +선택이 나와 재검토했으나 결론은 변경 없음. **이름 주의**: 아래에서 말하는 +`Observer`는 PA님 코드의 클래스 이름(pub-sub, 8개 `subscribeXxx` 헬퍼)이고, +위 "`state:Observer(fn)`" 절에서 확정한 quad의 `Observer`와는 이름만 +같을 뿐 무관한 별개 개념 — 이 절은 순수 역사적 교차검증 기록으로만 읽을 것. + +- **전파 모델**: PA님의 pub-sub은 push-invalidate가 아니라 **push-값** + (`Event:fire(...)`가 인자를 그대로 콜백에 전달, `Observable`의 `__newindex`가 + 새 값을 실어 즉시 `changed:fire(key, value)`, dirty-flag/`Get()` pull 단계 + 자체가 없음). 한때 "leaf(source 하나→sink 하나, 파생 없음)는 PA님처럼 + push-값으로 단순화하고 push-invalidate/pull-recompute는 실제 `:Compute` + 파생이 있을 때만 쓰자"는 이원화를 검토했으나 **기각** — invalidate+`Get()` + 방식도 leaf에서 딱히 더 복잡하지 않고(불리언 플래그 하나 + `Get()`/`emit` + 둘로 나뉘는 정도), 오히려 두 메커니즘을 병행하면 "leaf State가 나중에 + `:Compute`로 감싸일 때 두 메커니즘을 어떻게 연결하는가"라는 새 경계 문제가 + 생겨 이원화가 더 복잡함. **결정적으로, PA님 코드엔 애초에 `:Compute`/`:With` + 같은 파생·합성 개념 자체가 없음** — quad-v2가 lazy pull을 도입한 이유(여러 + 소비자가 하나의 파생 State를 공유할 때 오염 방지, 안 쓰이는 연산 스킵)를 + PA님 시스템은 처음부터 안 풀려던 문제라, 대등한 반례가 아니었음. **결론: + push-invalidate/pull-recompute로 통일 유지, 변경 없음.** 사용자 최종 확인 + 문구: "store 전파 처리는 우리 방식이 맞음. 이건 vide 에서 없었던것과 + 동일함, [PA님] 저기도 디자인 상 해결 못하는 문제가 된거거든. 비 필요 + 연산과 중복 연산을 지우는건 디자인 단계에서 구성할 일임. 우린 디자인 + 단계부터 해당 문제를 해결하고 싶었던거야." +- **라이프사이클**: PA님 코드는 GC-native가 아니라 **전부 수동 해제** + (`Connection.connected`는 계산 속성이 아니라 저장된 bool, `Observer`의 + 8개 `subscribeXxx` 헬퍼 전부 명시적 `:unsubscribe()` 필요, weak table은 + `Observable`의 subject↔observable 캐시 한 곳뿐). rbvm 기반으로 확정한 + "GC 위임, 명시적 dispose 없음" 원칙과 반대 선택이라 재확인 질문했으나, + **GC-native 유지로 확정** — 지금까지 이 정도 규모(명시적 dispose가 꼭 + 필요할 만큼 큰 자원)를 요구하는 실제 사례가 없었다는 게 사용자 판단. 다만 + **완전히 막다른 길은 아님**을 기록해둠: rbvm처럼 관계를 양쪽 다 weak-keyed로 + 두고 모든 걸 connection 람다에 담아 "연결이 살아있는 동안만 살아있게" 하는 + 방식이면, 나중에 GC만으로 정말 부족한 케이스가 생겨도 그 connection을 얻어 + `disconnect()`하는 명시적 dispose 경로를 추가로 얹는 게 가능한 디자인 — + 지금 마일스톤에서는 필요 없어서 안 함(사용자: "필요하다면 dispose 핸들러를 + 만들어주는 것도 가능한 디자인, 다만 지금까지 요구가 없었음"). (rbvm의 + GC-native 패턴이 실물에서 검증됐다는 근거는 `base/lifecycle-pattern.md` 상단 + 참고 메모 참고.) + +## quad2-try 리서치 결과 (완료) — 이전 시도에서 뭘 가져오고 뭘 버릴지 + +`.claude/initreq/quad2-try/out/quad-core`에 정확히 이 문제(Unix 파이프 영감의 +State/스트림)를 다뤘던 이전 시도가 있어 조사함 — **확인된 죽은 접근(OOP 상속 +`Base:Extends`/`--&` 커스텀 파서/Slot 빈 스텁/`Pipe` copy-on-write 절충안)은 +절대 반복 조사하지 말 것**, 상세 근거와 "건질 만한 것"(`:With` 이름의 +방증 등)은 `archive/quad2-try-research-findings-rejected.md` 참고 — 이 +조사의 최종 결론은 이미 아래 "Store/State/Source 온톨로지" 절의 `state(state)` +조합 모델로 대체되어 있고 Slot은 `base/slot-plan.md`의 from-scratch 설계를 +그대로 쓰면 됨(재조사 불필요). + +## 확정된 것 (더 이상 열린 질문 아님) + +- **핸들러 계약**: `isHandlable(inst,k,v)` + `priority` + `process`(구 + `bind`) + `retract`(구 `cleanup`) 4종 조합으로 확정 — tbox식 6-hook 세분화는 지금은 + 안 함. 실제 구현하며 부족한 지점이 보이면 그때 hook 추가(점진적 확장). +- **Signal 클래스**: 안 만듦, 콜백 + `Connected` 계산 속성만(`base/ + lifecycle-pattern.md`). +- **Ref**: 도입 확정(위 절 참고), 용도는 "id 기반 조회 대체"가 아니라 "외부 + 관리 instance를 점진적으로 다루기 위한 직접 참조 획득". + +## base 유틸은 인터페이스, 실제 구현은 백엔드 팩토리가 주입 (2026-08-04 보강) + +`base/lifecycle-pattern.md`가 말하는 "범용 유틸"(per-instance 상태 저장소, +생명 바인드 유틸)은 base가 직접 구현하는 게 아니라 **인터페이스만 정의** — +`inst`는 base 입장에선 `any`일 수 있음(다른 엔진일 수도 있으므로). 실제 +구현은 `RobloxFactory(BaseModule)` 같은 팩토리 함수가 `BaseModule`을 +뮤테이션해서 그 안에 실 구현체(`canExecute` 등)를 채워넣는 방식 — 사용자는 +`quad-base`/`quad-roblox`를 각각 import해서 `const quad = +RobloxFactory(QuadBase)` 세 줄 정도로 직접 조립하면 됨(별도 번들 `quad` +패키지로 재수출할 필요 없음, 필요하면 만들어도 됨). + +**확정(2026-08-04 3차 라운드)**: `RobloxFactory`를 같은 `BaseModule`에 여러 +번 호출했을 때 — **같은 팩토리로 재호출하면 무시(no-op)**, hot-reload처럼 +초기화 스크립트가 다시 도는 경우를 안전하게 만듦. **다른 팩토리 +(`AnotherFactory` 등, 가상의 예)로 재호출하면 에러** — 이건 `base/module-lifecycle-plan.md`의 "bind는 유일 슬롯" 원칙(이미 구현체가 있는데 또 +다른 구현체로 init하려 하면 오류)이 다루던 것과 정확히 같은 케이스, 이 +문서의 이전 "무시" 잠정안과 그 문서의 "오류" 잠정안이 서로 모순되는 게 +아니라 **같은 팩토리 재호출(무시) vs 다른 팩토리로 유일 슬롯 충돌(에러)이라는 +서로 다른 케이스를 각각 가리키고 있었음**. 구현은 모듈 테이블에 "누가 +초기화했는지" 마커(`_initializedBy = "roblox"`류, 정확한 이름은 구현 단계)만 +두면 됨. 모듈 스코핑(`New()`, `base/architecture.md` 13번)과의 관계도 실은 +열려있던 게 아니라 자연히 풀림 — `New()`가 생기면 각 인스턴스가 별도 +테이블이 되므로 이 마커도 테이블별로 독립적으로 스코핑됨, 재설계 불필요. + +## 인스턴스 생성 / 이벤트 네이밍 인체공학 — 확정(2026-08-04 3~4차 라운드, PA님 실 코드로 검증됨) + +`Quad "Frame"`처럼 문자열로 인스턴스 종류를 지정하는 방식은 타입 추론이 +어려움(위 온톨로지 절의 Luau 오버로드 문제와 같은 원인). 사용자가 실제 +참고 코드를 `.claude/initreq/artworks/DeclarativeProgramming/ +DeclarativeInstance.luau`(PA님 작성, UI 포함 전반적 설계 패턴을 시범 적용한 +데모 모듈)에 공유해줘서 직접 확인 — **"DI"는 Dependency Injection이 아니라 +"Declarative Instance"(선언형 인스턴스 생성)**. + +**인스턴스 생성 — PA님 코드 그대로 채택**: 처음 제안했던 "필드=1급 타입 +경로, 문자열=폴백"이라는 2트랙(`DI.Frame` vs `DI.New<> "Frame"`) 구상 +보다 실제로는 더 단순했음(`DeclarativeInstance.luau:104-160`) — +**제네릭 생성자 함수 하나(`new(className): from>`)가 알려진 타입과 모르는 타입을 전부 커버**하고, 그중 UI에서 자주 +쓰는 클래스 ~25개(`Frame`/`TextButton`/`UICorner` 등, `UIInstances` 타입 +테이블에 등록된 것들)만 모듈 로드 시점에 **즉시(eager)** `constructor.Frame = +new("Frame")`처럼 필드로 미리 채워둠 — `__index` 메타메소드 지연 생성이 +아니라 그냥 정적 테이블. quad-v2도 이 모양 그대로 채택: 하나의 제네릭 +생성자 + 자주 쓰는 것만 정적으로 미리 바인딩. + +**이벤트 바인딩 — `On.EventName` 도트액세스 안 씀, PA님 방식(평범한 문자열 +키 + 런타임 리플렉션)으로 전환**: `DeclarativeInstance.luau:13-91`의 +`assign(instance, key, value)`가 `ReflectionService:GetPropertiesOfClass`/ +`GetEventsOfClass`로 클래스별 프로퍼티/이벤트 타입을 캐싱해두고, 키가 +`RBXScriptSignal` 타입이면 자동으로 `instance[key]:Connect(value)`로 처리함 +— `Frame { MouseButton1Click = fn }`처럼 별도 네임스페이스 없이 그냥 문자열 +키로 씀. 이건 타입 안전성을 어느 정도 포기하는 대가지만(콜백 시그니처까지 +Luau가 검증 못 함 — `apply(instance: T, properties: U): T & U`가 스키마 +검증 없이 구조적으로만 merge), 이미 UB로 남긴 "테이블 리터럴 안 키별 값 +타입 자동 검증 불가"와 같은 급의 한계라 손해가 크지 않고, `On.` 접두어 없이 +문법이 더 간결해짐 — **사용자 확정**("PA 님 방식 괜찮은듯. 타이핑은 인라인이 +되긴 하겠지 정도면 괜찮다"). quad-v2 구현에서는 이 "키가 이벤트인가" +판별을 `isHandlable`로 감싼 pluggable 핸들러(`quad-roblox`가 `Reflection +Service` 기반으로 구현)로 두면 됨 — 별도 `On` 모듈/필드 접근 구조 자체가 +불필요해짐. + +**Store 쪽 dot-access는 그대로 유지**: `store.key`(1급 타입 경로)/ +`store "key"`(문자열 커링, 동적 키 폴백)는 이벤트와 달리 실질적으로 Luau가 +타입을 좁혀주는 이득이 있어서(Store 자체가 `{key: Source, ...}`류 +평범한 레코드 타입으로 지어짐[정정: 2026-08-06 후속 세션에서 필드 타입이 +`State`→`Source`로 갱신, "Source가 State를 만족함" 절 참고]) 그대로 +유지 — 이벤트만 예외였을 뿐, "정적으로 알려진 것=필드 접근" 원칙 자체가 +깨진 건 아님. + +**`GetPropertyChangedSignal`은 이 문자열 키 패턴이 안 통함 — 별도 `OnChange` +DI 키로 확정(2026-08-10 세션).** 이벤트는 `inst[key]`가 이미 Signal이라 +그대로 `Connect`하면 되지만, `GetPropertyChangedSignal(name)`은 프로퍼티 +이름을 인자로 받아야 하고 그 이름이 "값 세팅" 키 네임스페이스와 겹쳐서 +평범한 문자열 키로는 세팅과 리스닝을 구분할 수 없음 — 상세는 +`base/onchange-plan.md`. + +**PA님 코드와 대조해서 재확인한 것(변경 없음)**: +- **OOP 회피 결정은 오히려 보강됨** — PA님의 `ObjectOrientedProgramming/ + class.luau`도 `setmetatable(methods, {__index = parent})` 체이닝 상속이라 + quad-v2가 피하기로 한 quad2-try `Base:Extends`와 같은 모양이고, 제네릭을 + 파일마다 중첩해서 재선언해야 하는 보일러플레이트까지 동일하게 나타남. +- **Instance 태그는 CollectionService 직접 사용 그대로 유지** — PA님의 + `EventDrivenProgramming/Observer.luau`의 `subscribeTaggedInstance`도 얇은 + `CollectionService` 래퍼일 뿐. `DataOrientedProgramming/TagService.luau`는 + 이것과 무관하게 plain-table 엔티티(비-Instance 데이터)용 커스텀 태그 + 인덱스라 지금 quad-v2 스코프 밖 — Instance가 아닌 데이터에 태깅이 필요해질 + 미래 시나리오를 위한 참고 자료로만 기록. +- **Store/State 전파 모델, 라이프사이클 — 둘 다 재검토 후 기존 확정 유지** + (위 "Store/State/Source 온톨로지" 절의 "PA님 코드와의 교차검증" 참고). + +## Tag/Attribute 특수 키 — 전용 문서로 분리됨 (2026-08-07 여덟 번째 세션) + +`base/tag-plan.md`/`base/attribute-plan.md`로 이동 — 이 절이 다루던 타입 +파라미터화 문제(`[Attribute<> "name"]` vs `[BooleanAttribute +"name"]`)뿐 아니라 `None`/`process`/`retract` 동작까지 확정 반영됨. +UICorner 숏핸드/Tween처럼 "1 프리미티브 1 파일" 관례를 따라야 한다는 +지적으로 분리. + +## `Brand` — 런타임 nominal 타입 판별 통합 메커니즘, `isState`를 일반화 (2026-08-07 여덟 번째 세션) + +**배경**: `isState`(2026-08-07 다섯 번째 세션 확정, `:Peek<>(key): +T|State|nil`가 돌려주는 raw union을 사용자 코드가 분기하려면 판별 +수단이 필요했음)와 똑같은 필요가 quad의 다른 branded 타입에도 전부 +적용됨 — `Observer`/`Effect`/`Tag`/`Attribute`/`Tween`/`Blocker`/`Store`/ +`Source`/`Slot`/`None`까지, Handler 구현(`isHandlable`에서 "이 값이 +Store인가/Tag인가" 판별, 또는 PropertyHandler의 `process` 내부에서 +"이 값이 Tween인가" 판별 — 2026-08-10 세션부터 `isTween`은 `isHandlable`이 +아니라 값-레벨 분기에서만 쓰임, `research/tween-plan.md` 참고)과 사용자 +코드 양쪽에서 반복적으로 필요해질 수단이라 `isState` 하나만 만들고 +끝내지 않고 전체를 일관된 메커니즘으로 +통합(component-composition-plan.md 4번 절이 이미 "`isSource`류 판별자로 +(`isObserver`와 동일한 패턴)"라고 이 방향을 예견해뒀던 것과 맞아떨어짐). + +**구현: 공유 weak-key 레지스트리 하나 + 테이블 아이덴티티를 태그로 +사용(문자열 아님).** + +``` +local Brand = {} +local registry = setmetatable({}, {__mode = "k"}) + +function Brand.set(x, tag) registry[x] = tag end +function Brand.get(x) return registry[x] end -- nil이면 quad가 모르는 값 + +-- 각 브랜드는 고유 테이블(빈 테이블이어도 됨) — 문자열 리터럴 아님 +local ObserverTag, EffectTag, TagTag, AttributeTag, TweenTag, BlockerTag, + StateTag, SourceTag, StoreTag, SlotTag, RefTag, PreRefTag, ModifierTag = + {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {} + +-- 각 타입의 모든 생성 지점(Observer(...), Source(...), :With(...), Tag(...) 등)에서: +Brand.set(newHandle, ObserverTag) +``` + +**문자열 대신 테이블 아이덴티티를 태그로 쓰는 이유(사용자 제안)** — +Luau의 인터닝된 문자열 비교도 이미 사실상 O(1) 포인터 비교라 성능 차는 +무시할 만하지만, **오타 안전성**이 실질적 이득: 태그가 오타난 문자열 +리터럴("Oberver")이면 등록/조회 양쪽이 조용히 어긋나는데, 테이블 +레퍼런스는 잘못된 변수를 참조하면 즉시 드러나거나 최소한 진짜 다른 값이 +되어 헷갈릴 여지가 없음. + +**`isX`는 `Brand`를 직접 노출 안 하고 각자 얇은 wrapper로 감쌈** — +단순 항등인 경우(`isObserver(x) = Brand.get(x) == ObserverTag`)와, 상위 +관계(subtype)가 있어 **더 구체적인 브랜드 체크 위에 OR로 얹는** 경우 +(`isState`/`isRef`)로 갈림. **[정정, 2026-08-09 열한 번째 세션]** 후자를 +"집합 멤버십"(`t == A or t == B`, 플랫한 셋 체크)으로 구현하던 방식을 +"더 구체적인 predicate를 먼저 정의하고 그 위에 얹는" 합성 방식으로 +재정리 — 동작은 동일하지만, 어느 predicate가 다른 predicate를 내포하는지 +(포함 관계의 방향)가 코드 모양 자체에 드러나게 함: + +``` +local function isSource(x) + return Brand.get(x) == SourceTag +end +local function isState(x) + return isSource(x) or Brand.get(x) == StateTag -- Source가 State를 구조적으로 만족 +end + +local function isPreRef(x) + return Brand.get(x) == PreRefTag +end +local function isRef(x) + return isPreRef(x) or Brand.get(x) == RefTag -- PreRef가 Ref 런타임을 재사용 = Ref의 한 종류 +end +``` + +**정정 — `isSource`는 별도로 필요함, 다섯 번째 세션의 "불필요" 서술을 +뒤집음(2026-08-07 여덟 번째 세션).** 그때는 "State면 충분한 용도"만 +염두에 뒀지만, `Source`는 State보다 진짜로 더 많은 능력(`:Set`/`:Emit`)을 +가진 진짜 서브타입이라 "이 값이 (읽기 전용이 아니라) 쓰기도 되는 +원천인가"를 알아야 하는 코드는 `isState`만으론 부족함 — `isSource`를 +별도로 제공, `isState`는 여전히 `{State, Source}` 둘 다 통과시킴(상위 +개념이니까 당연히). `component-composition-plan.md` 4번 절이 이미 +`isSource`가 존재한다고 가정하고 있었던 것과도 이걸로 정합됨(그동안 두 +문서가 서로 모순돼 있었음). `base/modifier-plan.md`의 "별도 `isSource` +불필요" 서술도 같이 정정 대상. + +**갭 보강 — `isRef`/`isPreRef`/`isModifier`가 태그 목록에서 빠져있던 것 +추가(2026-08-07 열 번째 세션), 이후 `isRef`/`isPreRef` 관계 자체가 +재정정됨(2026-08-09 열한 번째 세션).** 처음엔 `isRef`/`isPreRef`를 +`isObserver`와 같은 단순 항등으로 두고 서로 배타적인 형제 브랜드로 +취급(`isRef(preRefInstance)`가 `false`)했으나, 이건 `isState`/`isSource` +쌍과 비일관적이었음 — `Source`가 State를 구조적으로 만족하듯, +**`PreRef`도 "Ref 런타임을 그대로 재사용하는" 관계라 같은 포함 +방향(상위=Ref, 하위=PreRef)으로 다뤄야 일관적**이라는 지적으로 뒤집힘. + +- **`isPreRef(x)`가 가장 구체적인 항등 체크**(`Brand.get(x) == + PreRefTag`), **`isRef(x)`는 그 위에 `Brand.get(x)==RefTag`를 OR로 + 얹은 상위 개념** — 즉 이제 **`isRef(preRefInstance)`는 `true`.** +- **`(v=Ref)` children 배열 leaf 매치 핸들러(`Dispatch/Leaf.luau`)는 + 이제 `isHandlable`을 `isRef(v) and not isPreRef(v)`로 명시적으로 + 좁혀야 함** — 예전처럼 `isRef` 자체가 배타적이라 저절로 걸러지는 게 + 아니라, "Ref이긴 한데 그 중 PreRef는 아니다"를 호출부가 명시적으로 + 말해야 하는 모양으로 바뀜(PreRef pre-pass가 이미 소진시켜 정상 경로에선 + 거의 안 걸리지만, 위 "PreRef" 절의 동적 경로 가드 Handler와 이 조합이 + 같이 "일반 Ref 경로를 절대 타면 안 됨"을 보장). `isModifier`도 같은 + 단순 항등(`Brand.get(x) == ModifierTag`, 상위 개념 없음). + +**같은 이유로 `isSlot`/`isEffect`도 명시(2026-08-09 세션)** — +`Brand.get(x) == SlotTag`/`Brand.get(x) == EffectTag`인 단순 항등 +predicate, 태그 자체는 원래부터 목록에 있었지만(`SlotTag`) `isX` +wrapper로 명시적으로 안 적혀 있던 것을 `base/modifier-plan.md`의 +"핸들러 계층 값이 필드로 들어오면 즉시 error" 절이 필요로 해서 이번에 +같이 적음. + +**`None`은 이 레지스트리에 안 들어감 — 싱글턴이라 항등 비교로 충분.** +`Observer`/`Store`처럼 인스턴스가 여러 개 생기는 타입과 달리 `None`은 +quad 전체에서 딱 하나만 존재하므로 weak table 조회보다 `x == None` +레퍼런스 비교가 더 싸고 정확함. 다만 `Brand.get(x)`가 "quad가 아는 모든 +값의 태그를 답해주는 범용 introspection 창구"(quad-debug 같은 도구가 +"이 값이 뭐냐"를 물어볼 단일 창구) 역할까지 겸하게 하려면 `None`도 +빠지면 안 되므로, `Brand.get`이 내부적으로 `x == None`을 먼저 확인하는 +특수 분기를 하나 두고 그 뒤에 일반 레지스트리 조회로 폴백 — `isNone`은 +바로 이 특수 분기의 실제 구현체가 됨(별도로 새로 만들 것 없음). + +**duck-typing(예: `type(x) == "table" and x.Compute ~= nil`)을 쓰지 않는 +이유**: `Peek`가 돌려주는 `T`는 Modifier 필드에 들어갈 수 있는 임의의 +값(테이블, Roblox userdata 등)이라 — 우연히 비슷한 모양의 필드/메소드를 +가진 `T`에 false positive가 나거나, 일부 Roblox userdata는 정의 안 된 키 +인덱싱 자체에서 에러를 던지므로 duck-typing이 `pcall`로 감싸야 하는 지저분한 +엔지니어링이 되거나 최악의 경우 그냥 엔진이 죽는 상황까지 생길 수 있음. +weak-key 레지스트리는 rbvm 네임스페이스 추적(`base/lifecycle-pattern.md`)과 +같은 이미 확정된 패턴 재사용이라 새 아이디어 아님 — weak 키라 등록된 값이 +GC되면 레지스트리 엔트리도 자동으로 사라짐(살려두는 목적의 강참조 +레지스트리인 Observer의 `:Subscribe` 레지스트리와는 반대 성격). + +**Luau 타입 narrowing은 자동으로 안 됨 — 명시적 `::` 캐스팅 필요(사용자 +확인, Luau가 원래 그렇게 동작함).** `isX(v)`가 참이어도 Luau 컴파일러가 +`v`의 정적 타입을 알아서 좁혀주진 않음(TypeScript의 `x is T`류 사용자 +정의 타입 가드를 Luau가 지원 안 함) — `if isState(v) then local s = v :: +State ... end`처럼 런타임 검증 뒤 명시적 캐스팅을 붙이는 게 실제 +패턴. 여전히 duck-typing/`pcall`보다 훨씬 안전하니 가치는 있음, 다만 +"자동 narrowing"을 기대하면 안 됨. + +**이름은 전부 가칭 — `Brand`/`ObserverTag`류 포함 용어 정리 대상, +`.claude/question.md`에 반영.** + +## 남은 열린 질문 (`.claude/question.md`에도 취합) + +이 문서의 핵심 설계 질문은 2026-08-04 세 라운드(전파 모델/`:Compute`/State +쓰기 금지/Slot 생존 확인 → dot-access 타입 추론/인스턴스·이벤트 네이밍/ +`RobloxFactory` 재호출 가드)를 거치며 전부 확정됨. 남은 건 순수 API 표면 +이름뿐: + +- **`state()`/`Source()`/`Get()`/`DI`(또는 다른 이름) 등 정확한 함수·생성자· + 모듈 이름** — 방향은 전부 확정, 이름만 구현 단계에서 남음(`On` 모듈은 + 이벤트 바인딩이 PA님 방식으로 바뀌며 아예 불필요해짐 — 위 "인스턴스 생성 / + 이벤트 네이밍" 절 참고). +- **매 `process()` 호출마다 우선순위 스캔 비용** — 실제 구현/벤치마크 단계에서 + 확인 필요(디자인 자체는 확정됐으므로 더 이상 사용자 확인 대상 아님, 구현 + 검증 대상). + +**해소된 것**: "Store가 Store를 담는 경우 이중 해제(double-dispose) 방지가 +필요한가"는 재검토 결과 질문 자체가 성립 안 함으로 결론 — 두 가지 독립적인 +이유로 이중 해소됨. (1) 애초에 그런 경우를 만들지 않기로 확정(위 "Store가 +Store를 저장 가능한가" 절, 2026-08-04 6차 — Store는 Source에 준하는 "시작점" +이라 다른 반응형 값을 담아 자동 연결되는 용도로 안 씀). (2) 설령 발생해도 +State/Source 그래프 구독이 전부 weak-keyed GC-native(명시적 `dispose()` 호출이 +아예 없음, `base/lifecycle-pattern.md`의 GC 위임 원칙 재사용)라 "같은 걸 두 번 +해제"할 행위 자체가 존재하지 않음(GC는 멱등). "`:Compute`가 with한 값을 어떻게 +읽는가"/"emit 필요 여부"도 전파 모델 확정으로 해소, `RobloxFactory` 중복 +호출/충돌 시나리오·인스턴스 생성/이벤트 네이밍도 위 절에서 전부 확정. diff --git a/.claude/base/blocker-plan.md b/.claude/base/blocker-plan.md new file mode 100644 index 0000000..bc98d88 --- /dev/null +++ b/.claude/base/blocker-plan.md @@ -0,0 +1,100 @@ +# Blocker — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게 + +**상태**: base — `research/additional-primitives-plan.md`(다른 프레임워크 +대비 갭 분석)에서 갈라져 나온 확정 프리미티브. lexical `Batch(fn)`으로 +풀려던 대안은 기각되어 `archive/batch-rejected.md`로 분리됨 — 이 문서는 +**확정된 Blocker만** 다룬다. `base/effect-plan.md`(같은 조사에서 나온 +다른 확정 프리미티브)와는 서로 무관 — Blocker는 State/Store 작업과 +밀접히 얽혀 있고 Effect는 완전히 독립된 요소라 원래도 별개 파일이었어야 +했음(2026-08-07 문서 정리에서 한 파일로 합쳤던 걸 다시 분리). + +**왜 필요한가**: `state1, state2 -> state3`처럼 여러 소스가 한 파생값에 +합류할 때, 둘을 한 번에 바꾸면 소비자에게 두 번 전파(재계산+재대입)되는 +문제. lexical `Batch(fn)`(Solid `batch()`/MobX `runInAction()`류)으로 +풀려던 접근은 코루틴 yield 위에서 구조적으로 위험해 기각됨 — 상세 근거는 +`archive/batch-rejected.md` 참고, 여기서 반복하지 않음. **Blocker는 그 +문제를 콜스택/코루틴이 아니라 사용자가 들고 있는 "값"으로 표현**해서 이 +위험을 구조적으로 우회한다. + +**store 개발(M3)과 밀접하게 연관됨** — `state:Block(blocker)`가 State +위에 얹히는 메소드이므로 `base/store-semantics.md`의 Store/State/Source +온톨로지, 특히 push-invalidate/pull-recompute 전파 모델(`base/ +bind-system-plan.md` "전파 모델 확정" 절)을 전제로 함. 별도 파일로 두되 +State와 같은 마일스톤(`ROADMAP.md` M3)에서 함께 구현할 것. + +## 메커니즘 (확정) + +``` +Blocker() -> blocker -- 생성자 +blocker:On() -> self -- IsBlocked = true로만 설정, 그 외 아무것도 안 함 +blocker:Off() -> self -- IsBlocked = false로 먼저 설정, 그 다음 등록된 + -- onunblock 핸들 전부 실행(순서 무관, idempotent) + +state:Block(blocker) -> state -- 새 gated state 반환. **호출되는 즉시**(나중에 + -- 처음 블록될 때가 아니라) onunblock 핸들을 + -- blocker의 weak 배열에 등록. +``` + +gated state의 동작: +- 원본 state가 emit(무효화)될 때, 이 gated state로 전파를 시도. +- `blocker.IsBlocked`이면: 전파 안 하고 `HasBlockedEmit = true`만 세팅. +- `blocker.IsBlocked`가 아니면: 평소처럼 그냥 전파(투명하게 통과). +- `blocker:Off()`가 실행하는 onunblock 핸들은: `HasBlockedEmit`을 확인해 + true면 그제서야 정확히 1회 전파(emit)하고 플래그를 리셋. 이미 false면 + 아무 것도 안 함(idempotent). + +**`:Get()`엔 영향 없음** — 블록은 emit **전파**만 지연시킨다. 블록 중이라도 +누군가 명시적으로 `:Get()`하면 그 순간의 실제 값을 정상적으로 계산해서 +준다 — `store-semantics.md`의 "`Get()`은 라이브 레퍼런스를 준다" 원칙과 일치. + +## 사용 예시 + +`state1`/`state2` 각각이 아니라 **결합된 결과(`state3`) 하나에만** `:Block`을 +건다: + +```lua +local blocker = Blocker() +local gated3 = state3:Block(blocker) -- 소비자는 gated3를 구독 + +blocker:On() +state1:Set(1) -- state3 무효화 → gated3로 전파 시도 → 블록됨 → HasBlockedEmit=true +state2:Set(2) -- state3 무효화 → gated3로 전파 시도 → 이미 true, 그대로 +blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한 번 emit +``` + +**일반 사용 가이드(확정, 문서화 필수)**: Block은 **파이프라인의 최종 연산 +지점**(실제로 무거운 계산이 일어나는 derived state, eager 소비자에 가장 +가까운 지점)에 거는 게 원칙 — 소스가 여러 개든, 하나가 한 주기에 여러 번 +바뀌든 상관없이 이 지점 하나만 지키면 됨. 소스 쪽에 각각 거는 게 아니다. + +## 이름 확정 + +- 클래스: `Blocker` — `Observer`/`Modifier`/`Ref`와 같은 명사-행위자 + 네이밍 관례와 일치. +- Blocker 자신의 토글: **`On()`/`Off()` -> self** (`Block()`/`Unblock()` + 아님) — `state:Block(blocker)`가 이미 "배선(wiring)" 동작의 동사로 + "Block"을 쓰고 있어서, Blocker 자신의 토글까지 같은 단어를 쓰면 + `blocker:Block()`(블로커를 켠다)과 `state:Block(blocker)`(state를 이 + 블로커에 배선한다)가 같은 단어로 다른 두 동작을 가리키게 됨. +- 필드: **`IsBlocked`**(Blocker 자신의 On/Off 상태), **`HasBlockedEmit`** + (gated state의 대기 플래그, `Is`/`Has` 접두어로 불리언임을 바로 알려줌). +- 메소드: `state:Block(blocker) -> state`. + +## 재진입(네스팅) — 의도적으로 미지원, 강한 문서화 필수 + +`IsBlocked`는 카운터가 아니라 단순 불리언이고, **의도적으로 그렇게 둔다.** +레퍼런스 카운팅으로 네스팅을 지원할 수도 있었지만, 그러면 "`On()` 여러 +번, `Off()` 실수로 적게" 같은 버그가 **영구 블록으로 조용히 새는** 더 +위험한 실패 모드를 만든다("poisoned mutex" 트래킹류 해키함도 만들지 +않기로 함). + +**대신 확정된 규칙**: 겹치는 배치가 필요하면 **각자 새 `Blocker` 인스턴스를 +만들 것** — 하나의 Blocker를 여러 컨텍스트에서 재사용/중첩하지 않는다. +`Off()`는 스태킹 없이 즉시 그 자리에서 꺼진다. **이 제약은 반드시 사용자 +문서(API 레퍼런스 수준)에 명시적으로 강조할 것** — 네스팅을 시도하면 +조용히 잘못된 시점에 조기 해제되는, 원인 추적이 어려운 버그로 이어짐. + +## 상태: 핵심 메커니즘+이름 확정. 남은 건 문서화뿐 + +`quadnomicon`에서 "Batch를 기각하고 왜 Blocker로 갔는가"를 비교 설명하는 +게 좋은 소재(`archive/batch-rejected.md`와 나란히 인용). diff --git a/.claude/base/component-composition-plan.md b/.claude/base/component-composition-plan.md new file mode 100644 index 0000000..3457d0e --- /dev/null +++ b/.claude/base/component-composition-plan.md @@ -0,0 +1,323 @@ +# 컴포넌트화 (Roblox 기본 오브젝트 이외의 사용자 정의 컴포넌트) + +**상태**: base — 2026-08-04 세션(6차 라운드 이후) 채팅 논의로 핵심 골격 + +modifier/Ref 컴포넌트 경계 통과 문제까지 전부 확정. 사용자가 "지금 quad에서 +가장 문제되는 부분"으로 직접 지목했던 주제였으나 이번 라운드에서 수렴 완료. +남은 건 API 이름뿐(아래 "남은 열린 질문" 참고). `base/bind-system-plan.md`의 +Store/State/Source 온톨로지가 먼저 확정된 뒤에야 이 논의가 열림 — 그 +문서가 선행 컨텍스트. + +## 문제 + +v1의 `Class.Extend()`(Init/Render/AfterRender/Getter/Setter/UpdateTriggers, +`reference/quad-v1-architecture.md` 참고)는 이미 OOP 상속 스타일이라 폐기 +방향이지만, 그게 제공하던 실제 편의 기능(컴포넌트가 자기 store를 자동으로 +가짐, props로 넘어온 State를 자동 흡수, `self:Default`/`self "key"`로 +기본값·바인딩)까지 같이 버려도 되는지가 미결이었음. `MyComp {...}` 형태로 +호출되는 사용자 정의 컴포넌트를 v2에서 어떤 모양으로 작성하게 할지가 핵심 +질문. + +## v1 실제 메커니즘 (조사 완료, `quad.qwreey.kr` 튜토리얼 + `initreq/quad/src/` 소스로 교차검증) + +- `myStore "key"` → register(현재 State에 해당) 반환. `:Default(v)`/ + `:With(fn)`/`:Add(v)`/`:Tween(opts)` 체이닝 가능(`store.lua:433-457`). +- `Class.Extend()`의 `:Init(props)`에서 `props:Default("Size", v)`로 기본값 + 설정, props 테이블 자체가 store 인스턴스로 변신(`class.lua:365-379`, + `storeNew(prop,nil)`). +- props로 넘어온 값이 State(`quad_register`)면 `initStoreRegisterBinding` + (`store.lua:394-431`)이 자동으로 감지해 컴포넌트 자신의 store 키에 재귀 + 연결 — **자동 흡수 매직**이 실제로 존재했음. +- `self(name)` linker가 **두 가지 역할**을 겸함: (1) `self "_button"`을 + 자식 자리에 넣으면 렌더링된 인스턴스를 `self._button`에 즉시 잡아둠(Ref + 역할) (2) `[Event.Prop "Text"] = self "Text"`로 인스턴스 프로퍼티 변경을 + 다시 컴포넌트 store로 역방향 전파(양방향 바인딩, `EmitPropertyChangedSignal` + 자동 연결과 동일) — quad.qwreey.kr 튜토리얼 `11_extend/` 문서 원문 확인. + +이 두 역할이 v2 온톨로지에서는 이미 갈라져 있음: (1)은 확정된 **Ref**가 +대체, (2)는 아래 "4. Source 직접 전달" 절이 대체(폐기된 `StoreSource` +프록시와는 다른 개념 — 혼동 방지용으로 명명을 맞춤). + +## 수렴된 결론 + +### 1. 컴포넌트 = 그냥 함수, "자기 store 자동 소유" 매직은 폐기 + +`MyComp = function(props) return Frame {...} end`, 호출 규약은 +`Frame{...}`와 동일(`MyComp{...}` → `MyComp(propsTable)`). v1의 Extend +자동-store-생성+자동-흡수 매직은 재현하지 않음 — 대신 React식으로 호출부가 +State/raw/Source/콜백 중 뭘 넘길지 명시적으로 고름. 이유: 자동 흡수는 +매 컴포넌트 호출마다 "이 prop이 State인가?" 타입 분기를 프레임워크가 +암묵적으로 수행해야 하는 매직이고, 명시적 전달이 더 단순·예측 가능(React가 +Vue/Svelte 대비 내세우는 강점과 동일 논리) — **사용자 확정**("마법 안쓴다 +그것도 동의함"). + +### 2. State/Source 경계 규칙: 파생이면 읽기전용, 원본이면 쓰기 가능 + +State는 `:With`/`:Compute`로 만들어진 파생값일 수 있어 쓰기가 정의 자체가 +안 됨. Source는 파생이 아니라 항상 원본 슬롯 하나를 직접 가리키므로 쓰기가 +의미 있음 — **사용자 확정**("맞음. 확실해"). 이 원칙 자체는 그대로 유지되고, +아래 3번의 구체적 메커니즘만 2026-08-06 후속 세션에서 더 단순하게 갱신됨. + +### 3. Store는 내부 Source를 그대로 반환 — Source가 State를 구조적으로 만족 + +**확정**: `Source`가 구조적으로 `State`를 만족하므로(단방향 호환, +Svelte `Writable extends Readable`와 같은 모양), `store.key`는 Store +생성 시 이미 만들어둔 진짜 Source 객체를 그대로 반환한다 — 별도 프록시 +타입도, 별도 캐싱 계층도 없음(Source 자체가 이미 State의 읽기 계약을 +전부 만족하고 거기에 `:Set(value)`/`:Emit()`이 추가로 있을 뿐이라 "원본이라 +쓰기 가능"이라는 위 2번 규칙과도 자연히 맞아떨어짐). 쓰기 문법도 같이 +바뀜: `store.key = v`가 아니라 `store.key:Set(v)`(레코드 타입 읽기/쓰기 +대칭 + lazy 동작에 `=`가 암시하는 "즉시 커밋"이 안 맞는다는 논거). 상세 +근거·타입 설계·Luau 솔버 검증 필요 항목은 `base/store-semantics.md`의 +"Source가 State를 만족함" 절이 최종 소스. + +**[이전에 확정했다가 폐기된 `StoreSource` 프록시 설계는 이 결론으로 +완전히 대체됨 — 원문·역전 이유·신구 비교표는 +`archive/store-source-proxy-reversed.md` 참고, 여기서는 반복하지 않음.]** + +### 4. Source 직접 전달 — 타입 유니온 불필요, 서브타입 호환으로 자동 통과 + +핸들러는 `State` 하나만 받아도 Source 인스턴스가 서브타입 호환으로 +자동 통과된다(`Source | State` 유니온 불필요, `isHandlable`/ +`priority`/`process`/`retract` 4종 계약에 5번째 항목 추가 불필요). 런타임에 +"이게 Source면 역방향 쓰기까지 걸고 싶다"처럼 구분하고 싶은 경우는 +`isSource`류 판별자로(`isObserver`와 동일한 패턴). + +- **실사용 범위가 좁다는 판단은 그대로 유지**: `isEnabled`처럼 여러 조건에 + 영향받는(=파생된) 값은 애초에 State지 Source가 아니므로 이 경로로 못 + 넘김. Source 직접 전달이 통하는 건 진짜 단순한 1:1 원본-토글 케이스뿐이고, + 일반적인 경우엔 React식 `value(State) + onChange(callback)` 패턴이 기본 + — **사용자 확정**("isenabled가 여러 조건에 영향 받으면 바로 문제가 + 생기는거지. 따라서 실제 사용은 제한적일듯. callback을 쓰는게 + 일반적이여 보이긴 해. 타입으로도 편하기도 하고 디버깅도 편함"). + +### 5. 리프(Roblox 프로퍼티) 바인딩 — Source 직접 바인딩도 정상 경로, +"좁은 예외"라는 표현이 오해를 유발해 정정함(2026-08-09 열한 번째 세션) + +**[정정] 이전 서술("Source는 리프 바인딩용 프리미티브가 아니라 좁은 +용도의 예외")은 부정확했음 — 사용자가 직접 반례를 제시: +`local a = Source(true); Frame { Visible = a }; a:Set(false)`처럼 +Source를 리프 프로퍼티에 곧바로 물리는 건 **막힐 이유가 전혀 없고 +흔한 정상 패턴**(단순 토글/가시성 같은 값은 오히려 이 모양이 자연스러움) +— 4번 절이 이미 확정해둔 "Source가 State를 구조적으로 만족해서 +핸들러가 서브타입 호환으로 자동 통과시킨다"가 정확히 이 케이스를 +커버함, 별도 제약이 있었던 적이 없음.** + +바로잡은 원칙: **"State가 일반 경로"라는 말은 Source를 못 쓴다는 뜻이 +아니라, 리프에 물리는 값이 "여러 소스에서 파생된 계산 결과"인 경우 +(`:With`/`:Compute`로 조합된 값)엔 그 결과가 State이지 Source가 아니기 +때문에 자연히 State가 더 자주 보인다는, **결과의 통계적 경향에 대한 +서술**일 뿐이다.** 원본 값 하나를 그대로(가공 없이) 리프에 물리는 +경우(`Visible`/`Enabled`류 단순 불리언 토글이 가장 흔한 예)엔 Source +직접 바인딩이 오히려 첫 번째로 권할 만한 관용구 — `isEnabled`처럼 +여러 조건에 영향받는(파생된) 값만 원천적으로 Source가 될 수 없는 +경우(그런 값은 애초에 `:Compute`로 만들어진 State일 수밖에 없어서), +그 경우에 한해 "State/콜백 패턴이 기본"이라는 4번 절 서술은 그대로 +유효. + +## 프레임워크 사례 조사 (2026-08-04, modifier/Ref 경계 통과 문제 관련) + +병렬 리서치로 4개 소스(Compose 공식 문서, Fusion/Vide 소스, quad v1 + +PA artworks)를 확인. **결론: 조사한 어떤 선례도 "컴포넌트 경계에서 modifier/Ref +전달" 문제를 완전히 풀어놓지 않음** — 심지어 quad가 이미 많이 참고한 Fusion도 +multi-root를 지원은 하지만 그 상태에서 외부 ref/props를 특정 root에 연결하는 +관례는 자체 문서에도 없음. + +### Compose의 Modifier는 애초에 flat property bag이 아님 — 순서 의존적 wrapper 체인 + +`modifier-plan.md`가 이미 확정한 "필드 단위 flatten, 나중 게 이김" 모델과 +Compose의 실제 메커니즘은 근본적으로 다른 종류임. Compose `Modifier`는 +`CombinedModifier`(2-노드 연결 리스트)로 순서대로 이어붙는 wrapper 체인 — +`Modifier.padding(16.dp).clickable(onClick)` vs +`Modifier.clickable(onClick).padding(16.dp)`가 실제로 다르게 동작함(패딩 +영역이 클릭 가능한지 여부가 순서에 따라 갈림, 공식 문서 예시). +`.then()`/`+`는 "같은 프로퍼티면 덮어쓰기"가 아니라 순수 **연결(concatenation)**. +→ **quad의 "필드명 기준 last-wins" 모델은 Compose를 그대로 벤치마킹한 게 +아니라 독자 설계임을 확인** — Compose와의 유사성은 "관례로 경계를 넘긴다"는 +아이디어 수준에서만 성립, merge 의미론까지 가져올 근거는 아님. + +공식 API 가이드라인(`compose-api-guidelines.md`, +`compose-component-api-guidelines.md`, `mrmans0n/compose-rules` 린트)이 +명시하는 규칙: +- `modifier` 파라미터는 이름 고정, 타입 `Modifier`, 기본값 `Modifier`, 첫 번째 + optional 파라미터여야 함. +- 받은 modifier는 컴포저블이 만드는 루트 레이아웃 노드에 **체인의 맨 앞**에 + 적용, 필요하면 뒤쪽에 이어붙이는 것만 허용(앞에 붙이는 것 금지). +- 같은 modifier 인스턴스를 여러 노드에 나눠 쓰지 말 것(단일 소비 전제). + +**Multi-root(루트가 여럿인 컴포저블)에 대한 공식 답은 없음** — 오히려 +가이드라인은 `CheckboxRow(rowModifier, checkboxModifier)`처럼 파트별 +modifier 파라미터를 두는 패턴을 명시적으로 **반례(DON'T)**로 제시하며 +"modifier는 컴포넌트 자체의 외부 동작을 위한 것이지 하위 파츠용이 아님, +대신 슬롯(자식 컴포저블 람다)으로 만들어라"라고 함. 즉 Compose는 이 문제를 +**풀지 않고 애초에 안 생기게 architecture로 피함**(multi-root 자체를 권장하지 +않고 slot 패턴으로 유도). + +### Fusion — modifier 개념 자체가 없음, multi-root는 있지만 ref 전달 관례 없음 + +- `merge.luau`(`src/Utility/merge.luau:13-33`)는 scope 메소드 테이블 병합용이지 + props 병합용이 아님 — quad Modifier에 대응하는 게 Fusion엔 없음. +- prop 전달 관례는 전부 **named table**(`props.Layout.Size`, + `props[Children]`) — 배열 아이템으로 뭔가를 넘기는 관례 자체가 없음. + Children도 예약된 `[Children]` 키로 감, 포지셔널 아님. +- `New()`/`Hydrate()`는 raw Instance 리턴(quad와 동일 지점). +- **Multi-root 컴포넌트는 실제로 지원**(`docs/tutorials/best-practices/ + instance-handling.md:17-61` — "Instance 배열 리턴, 여러 값 리턴 대신 배열로 + 감싸라"), 하지만 **외부에서 넘어온 ref/props를 그중 특정 root에 연결하는 + 예시나 관례는 문서에 없음** — quad가 지금 맞닥뜨린 것과 완전히 같은 질문이 + Fusion 자체 문서에서도 답이 안 나가 있음. + +### Vide — modifier도, 배열 기반 전달 관례도, multi-root 사례도 전무 + +`src/`/`docs/` 전체에 `modifier`/`merge`/`combine`/`spread` 매칭 0건. 모든 +컴포넌트 예제가 named+typed `props` 테이블을 필드별로 직접 옮겨씀. 인스턴스 +생성자는 raw Instance 리턴. multi-root 예제/개념 자체가 문서에 존재하지 않음. + +### quad v1 — 배열 아이템 구분은 항상 런타임 `__type` 태그로 함 + +`ProcessQuadProperty`(`class.lua:134-213`)는 배열 위치의 모든 아이템을 +`__type`으로 검사(`quad_linker`/`quad_register`/`quad_style` 중 하나면 그 +용도로, 아니면 무조건 자식으로 마운트) — v2의 "리프 레벨에서 타입으로 +Modifier/Ref/자식을 구분"이 이미 v1의 유일한 해법이었던 패턴 그대로임을 +확인. 단 v1도 multi-root 사례가 전혀 없어서, "컴포넌트가 여러 루트를 반환할 +때"는 v1도 답을 준 적이 없음. PA artworks에도 컴포넌트 추상화/multi-root +사례 없음(재사용 가능한 컴포넌트 함수 자체가 아직 코드로 존재하지 않음). + +### 종합 + +| | Modifier-equiv 있음? | 전달 관례 | multi-root 지원 | multi-root 시 ref/modifier 전달 관례 | +|---|---|---|---|---| +| Compose | O(순서의존 체인) | named 파라미터 강제(린트) | 사실상 비권장, slot으로 유도 | 없음(애초에 안 만듦) | +| Fusion | X | named table | O(배열 리턴) | **없음(미해결로 확인)** | +| Vide | X | named table | 사례 없음 | 해당 없음 | +| quad v1 | X(런타임 태그로 대체) | 태그 기반 배열 아이템 | 없음 | 없음 | + +시사점: (1) "배열 아이템을 타입으로 구분"은 quad v1 고유 패턴이자 quad-v2 +리프 레벨이 이미 계승한 것 — 그런데 이 문서 위쪽에서 지적했듯 컴포넌트 +함수 경계에서는 타입 스니핑을 자동으로 해줄 디스패처가 없어서 저작자가 직접 +루프를 돌려야 함(v1도 이 경계에서 실제로 쓰인 적이 없어 검증 안 된 채로 +남음). (2) named-key 전달(Fusion/Vide/Compose 공통)이 "함수 호출만으로 +경계를 넘는" 상황에서 유일하게 실제로 쓰이고 있는 관례. (3) multi-root + +외부 ref/modifier 전달은 **조사한 4개 선례 중 어느 것도 실제로 풀어놓지 +않음** — Compose는 회피, Fusion은 미해결로 방치, Vide/v1은 애초에 안 함. +즉 이 지점은 quad가 진짜 새로 설계해야 하는 부분이지, 어딘가에 있는 답을 +못 찾은 게 아님. + +## 최종 결론: 컴포넌트 경계 modifier/Ref 전달 (2026-08-04, 확정) + +### 1. Named parameter로 경계를 넘김 — 리프 레벨과는 다른 계약 + +컴포넌트 함수(`function(props) return Frame{...} end`)는 `Frame{...}`처럼 +배열 아이템 + 런타임 타입 스니핑으로 modifier/Ref를 받지 않음 — 함수 호출로 +경계를 넘는 순간부터는 자동으로 타입을 스니핑해줄 디스패처가 없기 때문(리프 +레벨의 `ProcessQuadProperty`류 디스패치는 `Frame{...}` 호출 내부에서만 +동작하고 컴포넌트 함수 몸통엔 적용되지 않음). 대신 caller는 named key(가칭 +`props.Modifier`/`props.Ref`)로 넘기고, 컴포넌트 저작자가 자기 코드 안에서 +명시적으로 원하는 내부 `Frame{...}` 호출의 배열 자리에 다시 꽂아넣음 +(`return Frame { props.Modifier, props.Ref, ... }`) — **사용자 확정** +("결과적으로 함수 구현에선 타입을 멀쩡히 지정하는게 더 중요하니 네임드가 +맞는듯"). + +**⚠️ 필수 관용구 — `props.Modifier or None`/`props.Ref or None`으로 +써야 함, 맨 리터럴로 꽂으면 안 됨(2026-08-07 열 번째 세션, `nil`-hole +버그 실측 후 확정).** caller가 `props.Modifier`/`props.Ref`를 안 넘기면 +`nil`인데, `{nil, props.Ref, child}`처럼 Lua 배열 리터럴에 `nil`이 그대로 +들어가면 그 순간 테이블의 배열 파트 전체가 순회 순서 보장을 잃을 위험이 +있음(`base/bind-system-plan.md`의 PreRef pre-pass 절이 다루는 것과 같은 +부류의 문제 — Luau REPL 실측으로 확인된 실제 버그, 국소적 피해가 아니라 +테이블 전체에 영향. **[주의]** 순서가 안 중요한 Ref 자신의 콜백/대기자 +배열은 반대로 `nil` 소진이 맞다는 정정이 따로 있음 — 여기서 다루는 건 +컴포넌트가 넘기는 **리터럴 children 배열**(순서가 중요한 배열)이라 +그 정정과 무관하고 `None` 관용구가 계속 맞음). +그래서 **컴포넌트 저작자는 항상 `or None`으로 감싸서 넘겨야 함**: +```luau +return Frame { props.Modifier or None, props.Ref or None, child } +``` +- **왜 `Modifier()`(빈 modifier 생성)가 아니라 `None`인가**: 별도 할당이 + 필요 없고, 기존 메커니즘을 그대로 재사용함 — `flatten` 단계는 애초에 + `isModifier(v)`가 거짓인 값은 그냥 건드리지 않고 통과시키므로 + (`None`은 Modifier가 아니라서 자동으로 이 경로), `props.Modifier or + None`이 최종적으로 배열 파트에 `None`인 채로 남으면 두 패스 루프 + 자신의 array-part `None`-스킵 규칙(위 "PreRef" 절)이 그대로 적용돼 + 아무 일도 안 일어남 — 새 특수 케이스 코드가 하나도 안 늘어남. +- 이 관용구는 컴포넌트 저작자가 **직접 챙겨야 하는 규율**(base가 강제로 + 검증해줄 방법은 없음, Lua는 이런 걸 린트로만 잡을 수 있음) — quad + 문서화(초심자 가이드/`props.Modifier`/`props.Ref` 절)에 필수 패턴으로 + 명시할 것, `research/documentation-content-map.md`에 반영 필요. + +Compose(named `modifier` 파라미터 강제, 린트로 감시)와 +Fusion/Vide(named prop 전달, `[Children]`류 예약 키)가 서로 다른 이유로 전부 +같은 결론에 도달한 유일한 실용적 패턴 — quad가 발명한 게 아니라 선례가 +수렴하는 지점(위 "프레임워크 사례 조사" 절 참고). + +### 2. "다중 루트로 반환" 자체를 컴포넌트 개념에서 제거 + +기존에 "컴포넌트가 여러 루트를 반환하면 모호해짐"이라던 프레이밍이 서로 +다른 두 가지를 하나로 섞은 것이었음이 드러나 재정리: + +- **정적으로 고정된 여러 형제 Instance를 한 함수 호출이 그대로 반환**(React + Fragment류) — **불필요로 폐기**. 근거 셋: (1) Luau가 tail position 밖에서 + 다중 리턴을 지원 안 함 — `return a, b`는 `Frame{ MyComp{...}, other }`처럼 + 배열 중간에 놓이는 순간 첫 값만 살아남으므로, 언어 차원에서 이 패턴이 + 애초에 자연스럽게 지원되지 않음(**사용자 확인**). (2) 필요하면 호출부에서 + 그냥 여러 컴포넌트를 나란히 쓰면 됨(`Frame{ IconA{...}, LabelB{...} }`) — + 한 컴포넌트 호출이 몰래 여러 형제를 뿜어낼 이유가 없음. (3) 프레임워크 + 조사에서도 진짜 수요가 있어 제대로 지원된 사례가 없음(Fusion은 "된다"고만 + 하고 ref 연결 관례는 미해결로 방치, Compose는 아예 안 만들도록 가이드) — + 어려워서 방치된 게 아니라 실제 수요가 없어서 아무도 안 만든 것 — + **사용자 확정**("그럴 필요가 있나 싶네... 애초에 다중 리턴이 될 이유가 + 없는듯"). +- **컴포넌트가 Slot을 반환**(개수가 가변적인 자식 묶음을 부모의 형제 레벨에 + 래퍼 없이 그대로 펼침 — 예: `ItemList{items=state}`가 `UIListLayout` 밑에서 + 래퍼 Frame 없이 `Header{}`/`Footer{}`와 같은 레벨로 항목들을 끼워넣는 경우) + — **이미 있는 별개 메커니즘**(`base/slot-plan.md`), 새 설계 불필요. Slot은 + 단일 Instance 정체성이 없으므로, 이런 컴포넌트는 애초에 `Modifier`/`Ref` + 파라미터를 선언하지 않으면 그만 — 타입 시그니처 자체가 "나는 단일 대상에게 + 적용할 modifier/Ref가 없다"를 표현. 별도 조율 메커니즘 불필요 — **사용자 + 확정**("불가능하진 않고 기술적으로도 충분히 되는 일... 엄청 집중해야할 + 일은 아니지 않을까"). **[재확인, 2026-08-09 열한 번째 세션]** 새 배선 + 없이 그대로 작동함을 재확인 — `Frame { Comp{} }`에서 `Comp`가 `Slot`을 + 반환하면, 그 반환값이 그냥 children 배열의 한 항목(값)이 되고 + `Dispatch/Slot.luau`의 기존 Slot 매치 핸들러가 평소처럼 처리(값이 + 컴포넌트 호출로 왔든 리터럴로 직접 놓였든 디스패치 입장에선 구분이 + 없음) — 이 경로 전용 특수 취급이 전혀 필요 없다는 뜻. + +이 정리로 원래의 "모호해지는 케이스"는 사라짐: 컴포넌트가 단일 root를 갖는 +한 named parameter로 명확히 전달되고, 단일 root가 없는 컴포넌트(Slot 반환)는 +애초에 그 파라미터를 안 받으므로 모호함이 생길 지점 자체가 없음. 반환값에 +"사후적으로" 뭔가를 꽂아넣는다는 그림 자체가 틀렸던 것 — forwarding은 항상 +컴포넌트가 반환하기 *전에*, 저작자 코드 안에서 일어나는 일이라 어느 root로 +가야 하는지는 저작자가 자기 코드에 뭐라고 쓰느냐로 완전히 결정됨(자동 전파가 +없기 때문에 성립하는 단순함). + +### 3. 여러 modifier를 하나로 합치는 공개 유틸 필요 — `Modifier.Overridden`(2026-08-07 다섯 번째 세션에서 `Merge`→`Override`로 개명, 동작 확정; 2026-08-08 세션에서 `Overridden`으로 이름 확정) + +caller가 named parameter 하나에 여러 modifier를 몰아넣고 싶을 때 +(`Frame{modifier1, modifier2}`의 컴포넌트판)를 위해, 기존 flatten 규칙(배열 +순서상 나중 것이 필드 단위로 이김, `modifier-plan.md` 2번)을 그대로 재사용하는 +결합 함수를 공개 API로 노출: `Modifier.Overridden(mod1, mod2, ...) -> Modifier`. +새 병합 규칙이 아니라 이미 확정된 flatten을 함수로 한 번 더 꺼내 쓸 수 있게 +하는 것뿐 — **사용자 요청**("modifier를 합칠 방법도 존재한다면 좋을것 +같아"). `MyComp { Modifier = Modifier.Overridden(theme, override) }` → 컴포넌트 +내부는 항상 이미 합쳐진 단일 값만 받으므로 컴포넌트 저작자가 배열 처리를 +신경 쓸 필요 없음. Ref는 필드 충돌 개념이 없어 이 문제 자체가 없음(여러 +Ref를 받으면 그냥 전부 실행하면 됨 — Ref 콜백 리스트는 애초에 여러 등록을 +누적하도록 설계돼 있음, `bind-system-plan.md`의 Ref 콜백/대기자 절) — 별도 +결합 유틸 불필요. **정확한 동작(baked 값 교체 경고, 순서 의존성, `Apply`와의 +역할 구분, `:Peek`/`isState`)은 `base/modifier-plan.md` 9번 절이 최종 +소스** — `Merge`로 전부 대체해 `Apply`만 강제하는 방안도 이번에 검토했으나, +이 3번 절에서 확정한 실사용 니즈(단일 named parameter 슬롯에 독립적으로 +만들어진 modifier 값들을 밀어넣는 경우)를 못 풀어서 기각됨. + +## 남은 열린 질문 (`.claude/question.md`에도 취합, 전부 후순위 — 이름만 남음) + +- **정확한 API 이름**: `Component`(플레인 함수 규약이라 별도 래퍼가 필요한지 + 자체도 불확실 — 아마 불필요), `Source`/`State` 독립 생성자·타입 이름, + 컴포넌트 경계용 `props.Modifier`/`props.Ref` 필드명은 전부 가칭 + (`Modifier.Overridden`은 2026-08-08 세션에서 이름 확정, 이 목록에서 + 빠짐). (`GetSource` 계열 접근자는 위 3번 정정으로 아예 + 불필요해짐 — `store.key`가 직접 Source를 반환하므로 별도 접근자 자체가 + 없음.) `base/bind-system-plan.md`의 "남은 열린 질문" 절(정확한 함수/ + 생성자 이름 미정)과 같은 급의 후순위 항목 — 구현 단계에서 다른 + 이름들과 함께 확정. +- **`quad2-try`는 확인 불필요로 재확인** — 진행이 중단된 상태라 이 논의와 + 무관. diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md new file mode 100644 index 0000000..28e3a98 --- /dev/null +++ b/.claude/base/effect-plan.md @@ -0,0 +1,166 @@ +# Effect — 설치 + 확정 정리, `state` 있으면 Observer를 감싸 재실행도 지원 + +**상태**: base — `research/additional-primitives-plan.md`(다른 프레임워크 +대비 갭 분석)에서 갈라져 나온 확정 프리미티브. `base/blocker-plan.md`(같은 +조사에서 나온 다른 확정 프리미티브)와는 서로 무관 — Store/State 작업이나 +Ref/PreRef와 파생 관계는 아니라 별도 파일로 둔다(2026-08-07 문서 정리에서 +한 파일로 합쳤던 걸 다시 분리). 단 `state` 인자를 받는 형태는 내부적으로 +Observer를 조합해서 만들어짐(아래 참고, 2026-08-07 여섯 번째 세션 확정) — +"Observer와 무관한 완전 독립 프리미티브"였던 이전 서술은 정정됨. + +**Effect와 Observer의 관계 확정(2026-08-07 여섯 번째 세션)**: 별개의 +독립 프리미티브이되, `state`를 받는 형태의 Effect는 내부적으로 Observer를 +**조합(compose)**해서 만들어짐 — Ref/PreRef처럼 브랜드 태그만 다른 재사용이 +아니라, Observer(재실행 신호) 위에 자동 cleanup 배선을 얹은 한 단계 위 +계층. **자유 함수인 이유는 여전히 유효**: `state` 없이도 성립하는 +mount/unmount 전용 유스케이스가 있고, 실제 leaf 생명주기 바인딩은 (Observer와 +마찬가지로) children 배열 위치에 거는 것이라 `state`가 그 바인딩을 소유하지 +않음 — Roblox엔 `task.spawn`으로 코루틴에 반복문/타이머를 돌리는 패턴이 +흔하고, Luau 테이블엔 `__gc` 같은 GC 시점 훅이 없어서 "이게 진짜 사라지는 +순간"을 아는 유일한 방법은 `Instance.Destroying`류 명시적 신호뿐 — 이런 +케이스(타이머 시작 → leaf가 죽을 때 반드시 정지)를 위한 별도 primitive로 +합의됨. + +``` +Effect(fn, state?) -> EffectHandle +``` + +**`state` 생략 시**: `fn()`을 즉시 1회 실행, 리턴값(`nil | () -> ()`)은 +이 Effect가 바인드된 leaf가 죽을 때 정확히 1회 호출. 재실행 없음 +(mount/unmount 전용, React `useEffect(fn, [])`와 동형). + +**`state` 지정 시(2026-08-07 여섯 번째 세션 확정)**: Effect는 내부적으로 +`state:Observer(...)`를 감싸는 걸로 구현 — `fn`은 포지셔널 인자로 `state`를 +받고(`fn(state)`, `:Compute`의 `fn(self)` 포지셔널-self 패턴 재사용, +모듈화 목적 — 클로저 캡처 없이 `fn`을 독립적으로 정의/재사용 가능), +Observer가 이제 등록 즉시 1회 실행되므로(아래 Observer 절 참고) 그 첫 +실행이 "설치"를 겸함. 이후 `state`가 무효화될 때마다 **직전 `fn` 호출이 +리턴한 cleanup을 먼저 호출한 뒤 `fn`을 재호출**, 그리고 Effect가 바인드된 +leaf가 죽을 때 **마지막 cleanup을 한 번 더 호출**. 결과적으로 React +`useEffect(fn, [dep])`와 동형(설치+재실행 사이/최종 cleanup 전부 같은 +반환 계약 하나로 처리). + +- **다수 의존성은 `:With(...)`로 먼저 하나의 State로 묶어서 넘길 것** — + React식 별도 deps 배열을 새로 만들지 않음, quad가 이미 가진 다중 의존성 + 결합 관용구(`base/bind-system-plan.md` "`:With` + `:Compute`" 절)를 + 그대로 재사용해 같은 일 하는 두 번째 경로를 안 만듦. +- **`fn`은 커링 스타일도 권장(2026-08-07 여섯 번째 세션, 사용자 제안)** — + `Effect(makeLogger("mount"), state)`처럼 팩토리 함수가 실제 `fn(state)`를 + 만들어 반환하는 패턴, `Modifier`의 `Boldify(10)` 커링 관용구(`modifier-plan.md` + 8번)와 같은 결. `state:Observer(fn)`도 동일하게 커링 스타일을 권장 대상으로 + 같이 문서화(아래 Observer 절 참고) — 모듈화가 필요하면 둘 다 이 패턴을 쓸 것. +- **재실행이 필요 없는 케이스와 혼동하지 말 것**: 값 변화와 무관하게 설치+최종 + 정리만 필요하면 `state` 없이 `Effect(fn)`을 씀 — `state`를 굳이 넘겨서 + 재실행을 유발할 필요 없음. + +children 배열에 leaf로 놓는 기존 Observer 바인딩 패턴을 그대로 재사용(그 +leaf가 살아있는 동안만 유효, leaf가 죽으면 최종 정리 콜백 호출). 비용은 +leaf당 실제 Destroying 바인딩 하나(공유 weak table로 되는 Observer보다 +비쌈) — 필요할 때만 쓰는 걸로 충분. + +**보강 — `EffectHandle`의 내부 Observer 바인딩 세부(2026-08-09 열한 번째 +세션, 재확인 후 명시화)**: + +- **`EffectHandle`은 내부 Observer를 필드로 강참조** — `handle._observer = + observer`(`state`가 주어진 경우만 존재). 이건 GC 방지가 목적이 아니라 + (그건 아래 `bindLifetime`/`gchold`가 담당) `:Unsubscribe()`/`bindLifetime` + cascade가 이 필드를 통해 내부 Observer에 접근하기 위한 것. +- **`bindLifetime(inst, handle)`은 `state`가 있는 경우 내부 Observer도 + 같은 `inst`로 `bindLifetime(inst, handle._observer)`를 cascade해야 + 함** — `Dispatch/Leaf.luau`가 children 배열의 `EffectHandle`을 매치해 + `bindLifetime(inst, handle)`을 부르는 시점(leaf 부착)과, `:Subscribe()`가 + `handle`을 전역 레지스트리에 등록하는 시점(아래) 둘 다 해당. 이유: + 내부 Observer 자신의 재실행 게이팅(`canExecute`)이 "`Subscribed` 필드 + + `inst`의 gcconn"을 함께 보는데, 후자는 그 Observer가 **직접** + `bindLifetime(inst, observer)`된 적이 있어야만 올바른 `inst`를 참조함 + — `EffectHandle`만 바인드하고 내부 Observer는 안 하면, 그 Observer의 + `canExecute`가 `inst` 생존을 못 보고 엉뚱하게(또는 전혀) 게이팅됨. + 같은 이유로 `unbindLifetime(inst, handle)`도 내부 Observer까지 같이 + 풀어야 대칭이 맞음. +- **`:Subscribe()`도 마찬가지로 `state`가 있으면 내부 Observer를 같은 + 전역 강참조 레지스트리에 같이 등록**(`handle` 자신 + `handle._observer` + 둘 다, 또는 `handle._observer`만으로 충분한지는 구현 세부 — 어느 쪽이든 + "`EffectHandle`은 등록됐는데 내부 Observer는 등록 안 됨" 상태가 생기면 + 안 됨). + +**Observer 자체에 cleanup 반환 계약을 추가하는 안은 여전히 기각** — React +`useEffect`식으로 `fn`의 반환값을 자동으로 배선해주는 안을 검토했으나, +클로저 업밸류로 이미 충분해 채택 안 함. 이 기각은 위 Effect 설계와 +상충하지 않음(그때 기각한 건 "Observer 자체에 이 복잡도를 넣지 말자"였지 +패턴 자체의 무용함이 아니었고, `Effect`가 opt-in 상위 계층으로 정확히 +이 패턴을 제공함) — 상세 경위는 `archive/observer-cleanup-contract-rejected.md` +참고. + +## `EffectHandle:Subscribe()`/`:Unsubscribe()` — leaf 없이 쓰는 독립 Effect (2026-08-07 일곱 번째 세션) + +**동기**: 지금까지 Effect의 유일한 생애주기 경로는 children 배열의 leaf +부착뿐이었음 — leaf 없이 `Effect(fn)`/`Effect(fn, state)`를 호출하면 +설치(1회 실행)는 되지만 반환된 `EffectHandle`엔 아무 인터페이스도 없어서 +cleanup을 트리거할 방법이 없는 막다른 길이었음. `state:Observer(fn)`가 +이미 `:Subscribe()`/`:Unsubscribe()`(위 bind-system-plan.md 절)로 "children +배열 밖, 모듈/스크립트 레벨에서 독립적으로 켜고 끄는" 경로를 갖고 있는데, +Effect도 모듈/스크립트 사이드 이펙트(백그라운드 시스템, non-UI 코드가 +quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 쓰일 수 있어서 +같은 결로 필요 — `Effect`도 leaf 없이 독립적으로 켜고 끌 수 있어야 함. + +**확정**: `EffectHandle`에도 `:Subscribe()`/`:Unsubscribe()` 추가, 둘 다 +`self` 반환(Observer와 동일한 fluent 대칭). + +- **`:Subscribe()`** — Observer가 쓰는 것과 같은 강참조 레지스트리에 + 자신(또는 `state` 있는 경우 내부 Observer)을 등록 — 새 메커니즘 아님, + 기존 레지스트리 재사용. 이후 로컬 변수로 참조를 안 들고 있어도 계속 + 살아있음(Observer와 동일 관용구). + - **⚠️ 용도는 완전히 top-level(모듈/스크립트 레벨, 어떤 Instance + 생명주기에도 안 묶인) 사이드 이펙트로 한정할 것 — 특정 `inst`에 + 묶인 경우엔 leaf 부착(`bindLifetime`)을 쓰지 `:Subscribe()`를 쓰지 + 않는 게 정상 경로.** `:Subscribe()`를 쓰기로 했다면(top-level이든 + 의도적으로 다른 경우든) **반드시 `:Unsubscribe()`로 짝을 맞춰야 + 함** — 강참조 레지스트리는 quad 전역의 "정리는 기본적으로 GC에 + 위임" 원칙의 **의도적 예외**라, 로컬 변수 참조를 다 놓아도(스코프를 + 벗어나도) **GC되지 않고 계속 실행됨**. 이건 quad의 다른 프리미티브 + 대부분이 GC-native인 것과 정반대라 혼동하기 쉬운 지점 — 사용자 + 문서에 명시적으로 경고할 것(`:Subscribe()`를 부르는 순간부터 그 + 핸들의 생애주기는 전적으로 수동 관리 대상이 됨). +- **`:Unsubscribe()`는 Observer의 것을 그냥 위임하지 않는다 — Effect + 계층에서 의미가 확장됨.** Observer의 `:Unsubscribe()`는 "미래 재실행만 + 끊는다"(Observer 자체엔 정리할 상태가 없음)로 충분하지만, Effect의 + 계약은 "생애주기가 끝나는 시점에 마지막 cleanup이 정확히 1회 호출된다" + 이고 leaf 사망은 그 "끝"의 신호 중 하나일 뿐이라, `:Unsubscribe()`도 + 동일하게 "지금 끝났다"는 신호로 취급해야 계약이 일관됨: + 1. `state`가 있으면 내부 Observer도 `:Unsubscribe()`해서 향후 재실행을 + 끊고, + 2. **직전(또는 유일한) cleanup을 정확히 1회 호출** — leaf가 죽을 때 + 하던 것과 정확히 같은 이벤트를 수동으로 앞당기는 것. + 3. **idempotent, 그리고 이후 leaf가 실제로 죽어도 cleanup이 중복 + 호출되면 안 됨** — 새 메커니즘 불필요, Observer가 이미 확정해둔 + "`Subscribed` 필드 우선 liveness 체크"가 자동(리프)/수동(Unsubscribe) + 두 경로를 하나의 게이트로 OR 묶어주므로 여기 그대로 얹힘. +- **`state` 없는 mount-only Effect엔 특별한 분기 불필요** — install은 이미 + `Effect(fn)` 호출 시점에 끝나 있으므로, `:Unsubscribe()`는 그냥 "지금 + leaf-사망 cleanup을 수동으로 트리거"하는 것과 완전히 동치. +- **leaf 부착과 `:Subscribe()`를 동시에 쓰는 건 UB — 정정(2026-08-07 + 일곱 번째 세션 후속)**: 처음엔 "같은 liveness 게이트를 공유하니 + 동시에 써도 안전"으로 적었으나, 애초에 한 핸들은 라이프사이클 바인딩 + 경로를 하나만 가져야 한다는 게 맞는 방향이라 판단이 뒤집힘 — 상세 + 규칙과 `canBound(handle)` 기반 즉시-에러 메커니즘(구 가칭 `Bound` + 플래그, 2026-08-09 세션에서 이름 확정)은 + `base/bind-system-plan.md`의 "이중 바인딩 금지" 절 참고. **[정정, + 2026-08-09 여섯 번째 세션] leaf 부착 후 조기 해제는 `:Unsubscribe()`가 + 아니라 `unbindLifetime(inst, value)`** — leaf 부착 자체가 내부적으로 + `bindLifetime(inst, value)` 호출이라, 그 해제도 짝인 `unbindLifetime` + 전용(`:Unsubscribe()`는 `inst`를 몰라 대신 처리 못 함) — 금지되는 건 + 여전히 `:Subscribe()`(전역 경로)와 `bindLifetime`(leaf 부착 포함, + inst-scoped 경로)을 **같이** 쓰는 것뿐. + +## 해결됨 — Effect/Observer 관계 (2026-08-07 여섯 번째 세션, 이전 미해결 절 대체) + +**과거 미해결이었던 두 질문 모두 확정**: +1. Effect는 자유 함수로 확정(`state:Effect(fn)` 메소드 아님) — 위 "Effect와 + Observer의 관계 확정" 절 참고. `state` 인자가 있어도 실제 leaf 생명주기 + 바인딩을 `state`가 소유하지 않아서 메소드로 만들 필연성이 없었음. +2. `state:Observer(fn)`는 등록 즉시 1회 실행되는 것으로 확정(`base/ + bind-system-plan.md`의 Observer 절 참고) — 이 덕에 Effect가 `state`를 + 받을 때 Observer를 그대로 조합해 재사용할 수 있게 됨(별도 "설치 시 + 1회 실행" 로직을 Effect가 따로 만들 필요 없음). + +`.claude/question.md` 0번의 관련 항목도 해소됨으로 갱신 완료. diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md new file mode 100644 index 0000000..90d82be --- /dev/null +++ b/.claude/base/lifecycle-pattern.md @@ -0,0 +1,308 @@ +# 라이프사이클 패턴 — rbvm의 `Connected` + GC 관용구 채택 + +**상태**: 결정됨(base) — quad-v2가 채택할 라이프사이클/정리(`retract`) 전략의 원본. +완료 개념 없음, 구현하면서 세부 조정 있을 수 있음. + +**참고(2026-08-06)**: rbvm은 사용자가 직접 실행해 실제로 잘 동작하는 것을 확인한 +코드베이스(프로덕션에서 검증됨) — 사람이 짠 코드라 무결성이 100% 보장되는 건 +아니지만(실제로 아래 5번 항목에서 `Connected` 체크 방향 반전 버그, `__mode` 오타 +버그가 발견됨), 다른 리서치 참고 레포보다 "실물로 돌아간다"는 근거가 확실한 +비교 대상. 단 이건 참고용 비교 대상일 뿐 quad-v2가 반드시 따라야 할 규범은 +아님 — 실제 채택 여부는 이 문서 각 절의 확정 내용(특히 아래 "확정" 두 절)을 +따를 것, rbvm 코드를 그대로 베끼라는 뜻이 아님. + +## 배경 + +`raw-userinput.md`(이하 사용자 원 메모): "레지스터의 실행은 내 생각엔 그냥 Destroy +되었는지를 라이프타임 홀더의 Connected 상태로 보는게 맞는듯", "여전히 connection +으로 해키한 방법 쓸듯. destroying으로 자료구조 계속 바꿔가는거 비용 큼. gc 네이티브 +에 맡겨야함. 대신 이젠 Connected 필드로 이 연결이 살아 있는지 보고 설정 가능함. +이게 rbvm 쪽에서 구현되어있음." + +rbvm(`.claude/initreq/rbvm/`)을 조사한 결과, 정확히 이 패턴이 존재함. 아래는 +그 조사 결과 요약(전체는 이 문서 작성에 쓰인 리서치 세션 참고, 소스는 +`.claude/initreq/rbvm/src/signal.luau`, `src/proxy/base.luau`, `src/namespace.luau`). + +## 채택할 패턴 + +### 1. `Connected`는 저장되는 bool이 아니라 계산된 속성 + +rbvm의 `Connection` 타입(`signal.luau:21-24`)은 `Connected`를 실제 필드로 두지 않고 +`__index` 메타메소드에서 계산함: + +```luau +function ConnectionMeta.__index(self: Connection, key: string): any + if key == "Connected" then + local data = Connection.GetPrivate(self) + return data.Signal ~= nil + end +end +``` + +연결 해제 시 `data.Signal = nil`만 하면 됨(`Connection.Dispose`) — 자료구조를 +바로 지우거나 재구성하지 않음. quad-v2도 이 모양을 그대로 채택: 라이프타임 +홀더는 "내가 아직 살아있게 하는 뒷받침 참조"가 nil인지만 확인하면 됨. + +### 2. Instance 파괴는 `Instance.Destroying` 훅 하나로만 관측 + +rbvm은 실제 Roblox Instance의 파괴를 감지하는 지점을 단 하나로 좁혀둠 — +`inst.Destroying:Connect(...)` (`proxy/base.luau:150-156`), `Destroyed` 같은 +플래그를 그 콜백에서만 true로 뒤집음. `AncestryChanged`나 폴링 방식은 안 씀. +quad-v2도 동일: 인스턴스 라이프사이클 훅 지점은 `Destroying` 하나로 통일. + +### 3. 정리(`retract`)는 기본적으로 GC에 위임, 예외적으로만 즉시(eager) + +rbvm 전역에 약한 테이블(weak table, `__mode = "k"/"v"/"kv"`)로 private 데이터를 +저장 — 홀더 객체를 아무도 안 들고 있으면 그 private 레코드도 자동으로 사라짐. +즉시 처리하는 예외는 딱 두 가지뿐이었음: (a) 가상 트리의 부모/자식 포인터처럼 +방치하면 죽은 참조를 계속 순회하게 되는 "작고 유계(bounded)"인 것, (b) 네임스페이스 +전체가 통째로 죽을 때의 순서 있는 dispose 훅. **quad-v2 원칙: 기본은 GC 위임, +즉시 정리는 "안 끊으면 죽은 참조를 순회하게 되는 작은 포인터"류에만 국한.** + +### 4. (참고 기록) rbvm의 Signal 자체는 재사용 가능한 범용 emitter였음 — 실제로는 채택 안 함 + +`signal.luau`의 `Signal`/`Connection` 클래스는 rbvm 프록시 시스템에 의존하지 +않는 범용 이벤트 emitter임 (`Connect`/`Once`/`Wait`/`Fire`/`Destroy`, +`IsInited`/`OnInit`/`OnUninit` 지연 활성화 훅 포함). 사용자 원 메모에는 +"시그널 자체 구현은 아닌듯... 콜백 정도로도 충분"이라는 언급이 있어 한때 +이 문서 초안 단계에서 상충하는 것처럼 보였으나, **이 질문은 2026-08-04 +검증 라운드에서 최종 확정으로 재확인됨 — 더 이상 열린 질문 아님** +(`base/architecture.md` 11번 항목도 동일하게 명시). 결론은 아래 "확정: Signal +클래스는 안 만든다" 절 참고 — 커스텀 `Signal`/`Connection` 클래스는 만들지 +않고, 콜백 + `Connected` 계산 속성만 채택한다. + +### 5. rbvm에서 그대로 가져오면 안 되는 것 (버그 발견됨) + +- `proxy/base.luau:72-78`의 `Proxy.DisposeNamespace`와 `signal.luau:401-408`의 + `SignalProxy.DisposeNamespace`가 `Connected` 체크 방향이 서로 뒤집혀 있음 + (하나는 "아직 연결되어 있으면 continue", 다른 하나는 정반대) — 후자가 맞는 + 방향(`not Connected`일 때만 skip, 살아있으면 Disconnect). quad-v2 구현 시 + 이 반전 버그를 복사하지 않도록 주의. +- `namespace.luau:5-6`의 `ItemNamespaceMap`은 `__mod = "k"`로 오타가 나 있어서 + 실제로는 weak table이 아님(`__mode`가 맞음) — 그대로 베끼면 메모리 누수. +- `InitNamespace`/`Registered`-가드/`NewLib` 3종 세트로 "라이브러리마다 하나하나 + 수동 init" 하는 방식은 정확히 사용자가 피하고 싶다고 한 패턴 — 순서 있는 + dispose-hook 리스트 자체는 재사용해도, 수동 init 관례는 그대로 베끼지 말 것 + (팩토리 함수로 대체 — `base/module-lifecycle-plan.md` 참고). + +## 확정: Signal 클래스는 안 만든다 + +**사용자 확인 완료** — 콜백 + `Connected` 계산 속성만으로 간다. rbvm의 범용 +`Signal`/`Connection` 클래스 전체는 가져오지 않음. rbvm에서 채용하는 건 오직 +"`Connected`가 계산된 속성" 이라는 패턴 자체뿐. + +## 확정: quad는 자신이 만든 Instance의 라이프사이클 "중간"에 있지 않다 + +이게 이 문서 전체의 핵심을 재정의하는 결정. rbvm의 proxy는 Instance와 소비자 +사이에 자신을 끼워넣는(중간 계층) 설계라 "내가 사라질 때 무언가를 정리해야 +하는가"라는 문제가 생기지만, **quad는 자신이 만든 Instance를 그 Instance의 +생명주기 끝까지 그대로 들고 있는 소유자다 — 중간에 끼는 계층이 없음.** + +결론: **Instance/바인드 전체가 Destroy될 때 실행해야 하는 정리(teardown) 로직은 +없다.** 오히려 있으면 안 됨 — Destroy 이후에 그 Instance에 프로퍼티를 셋하거나 +메서드를 호출하면(예: 이미 죽은 Tween에 `:Cancel()`) 그냥 에러남. 대상이 +Destroy되면 그 대상에 묶인 것들(Tween 등)도 자연히 죽은 상태가 됨 — GTK 등 +다른 백엔드도 마찬가지로 "run된 dispose를 관리"할 필요가 없는 구조로 봄. +**해야 할 일은 딱 하나: 생명주기가 끝난 뒤에 그 대상을 다시 건드리는 시도가 +일어나지 않게 막는 것**(=처리를 그냥 멈춤) 뿐 — 자료구조 자체의 해제는 +가능하면 GC에 맡김. + +이 원칙 때문에 "값 교체 시 이전 처리를 무르는 것"(아래 `retract`)과 "완전 +소멸 시 정리"는 **하나로 통일** — 후자는 애초에 안 만듦. `research/ +tween-plan.md`/`base/slot-plan.md`의 "cleanup" 표기는 대부분 `retract`로 +갱신됨(이름 변경 근거는 아래) — 잔여 표기 확인은 진행 중, 해당 문서들은 +각자 별도로 정리될 예정. + +## 함수 안에서 만든 옵저버도 GC 대상이 되어야 함 — 범용 "생명 바인드 유틸" 필요 + +사용자 원 메모: "함수 안에서 옵저버 만들어버린 거, 그것도 gc 대상 되어야 +할 텐데, 이건 아래쪽에 생성하는 실 객체에 유저가 바인드 할 수 있게 하는 약간의 +유틸이 있긴 해야 할듯. connect 트릭 그대로 들고 와서 쓰면 될 것 같고. 옵저버는 +canExecute 같은 람다 함수 하나 달게 해서 Connected 상태 보게 하여 실행 안 될 +수 있게 만들어도 될 듯." + +즉, 핸들러가 처리 도중 만든 구독/옵저버 클로저는 그 자체로는 아무것도 자동으로 +GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연결해 참조를 +붙잡아두던 "GC 방지 핫팩"(`reference/quad-v1-architecture.md` 참고)과 같은 문제. +**base가 범용 유틸로 제공할 것: 임의의 클로저/구독을 실제 Roblox 객체의 +생명주기에 바인드하는 도구** — 내부적으로 v1/rbvm이 쓰던 "connect 트릭"(어떤 +신호에든 연결해서 참조를 죽을 때까지만 붙잡아두는 것)을 그대로 재사용. 이 +도구로 바인드된 옵저버는 `canExecute` predicate로 게이팅되어, 살아있지 않으면 +실행 자체를 건너뛸 수 있음(죽은 대상에 대한 처리 시도 방지, 위 원칙과 직결). + +### `bindLifetime`/`canExecute`/`unbindLifetime` — 확정(2026-08-08 세션, +`unbindLifetime`은 2026-08-09 세션 추가) + +**탑레벨 평범한 함수로 확정, 네임스페이스에 안 숨김.** `Dispatch.process`/ +`Handler.xxx`는 "시스템 내부 배관"이라 네임스페이스가 맞지만, `bindLifetime`/ +`canExecute`/`unbindLifetime`는 `isState`/`isObserver`처럼 핸들러 작성자가 +직접 호출하는 **1급 프리미티브 연산**이라 `LifetimeHandle.bind(...)`류로 +감싸면 안 됨 — `LifetimeHandle.luau` 파일 안에 있어도 되지만 export는 +평평한 함수: + +```lua +bindLifetime(inst: any, value: any): () +unbindLifetime(inst: any, value: any): () +canExecute(inst: any, value: any): boolean +``` + +**`unbindLifetime` 추가 이유(2026-08-09 세션, `bind-system-plan.md`의 +"Length/Offset" 논의에서 파생)**: `Dispatch.setLength`(같은 위치에 새 +`State`가 들어오면 이전 것에 걸어둔 Observer를 먼저 정리해야 함, +`State` 교체가 대표 사례)처럼 **`inst` 전체 생명주기보다 먼저, +특정 값 하나만 콜백/구독을 끊어야 하는 경우**가 실제로 생김 — +`bindLifetime`만 있으면 그 호출부가 gchold의 내부 저장 구조(배열이든 +`value`를 키로 쓰는 테이블이든)를 직접 알아야만 특정 항목을 지울 수 +있어서 캡슐화가 깨짐. `unbindLifetime(inst, value)`을 짝으로 추가하면 +호출부는 내부 구조를 몰라도 됨 — 구현이 쉬운 이유도 여기 있음(아래 +스케치처럼 gchold를 `value`를 키로 쓰는 테이블로 두면 `gchold[value] = +nil` 한 줄). 안 걸려있던 값에 불러도 안전한 no-op(`:Unsubscribe()`류 +기존 관례와 동일). + +base는 이 두 함수의 **인터페이스만**(타입 시그니처) 갖고, quad-roblox가 +`BaseModule` 뮤테이션 시점에 실 구현을 채워넣는다는 원칙은 그대로(`canExecute` +관련 기존 절 참고) — 아래는 그 실 구현 스케치, `base/relate-plan.md`의 +`Relate` 프리미티브 위에 얹힘(2026-08-08 세션, gchold를 `perInstanceState` +직접 조작 대신 `Relate`로 구현): + +```lua +-- quad-roblox 실 구현 스케치 +local relate = Relate() -- 이 모듈 전용 인스턴스, 다른 핸들러와 key 충돌 없음 +local GCCONN = "__gcconn" +local GCHOLD = "__gchold" + +function bindLifetime(inst, value) + local isOE = isObserver(value) or isEffect(value) + -- leaf 부착도 내부적으로 이 함수를 호출하므로, :Subscribe()와 상호 + -- 배타적인 "이중 바인딩 금지"(base/bind-system-plan.md)를 여기서 확인 + if isOE and not canBound(value) then + error("Observer/Effect가 이미 다른 경로로 바인딩됨") + end + + local gcconn = relate:GetStrong(inst, GCCONN) + if not gcconn then + -- ClassName은 절대 안 바뀌는 프로퍼티라 이 신호는 절대 발화하지 않음 + -- (rbvm 패턴 그대로) — 콜백 클로저가 gchold를 업밸류로 캡쳐해 살려둠 + local gchold = {} -- value 자신을 키로 씀(배열 아님) — unbindLifetime을 O(1)로 + relate:SetStrong(inst, GCHOLD, gchold) + gcconn = inst:GetPropertyChangedSignal("ClassName"):Connect(function() + local _ = gchold -- 발화 안 함, 클로저 생존이 곧 gchold 생존 + end) + relate:SetStrong(inst, GCCONN, gcconn) + end + local gchold = relate:GetStrong(inst, GCHOLD) + gchold[value] = true -- 강참조 생성, inst 죽으면 gcconn 클로저와 함께 GC + if isOE then value.Subscribed = true end -- canExecute가 보는 필드 그대로 재사용 +end + +function unbindLifetime(inst, value) + local gchold = relate:GetStrong(inst, GCHOLD) + if gchold then + gchold[value] = nil -- inst는 안 건드림, 이 value 하나만 조기 해제 + end + if isObserver(value) or isEffect(value) then value.Subscribed = false end +end + +function canExecute(inst, value) + -- Observer/Effect는 자기 바인딩 경로(bindLifetime=leaf 부착 포함, + -- 또는 :Subscribe())의 생존 여부를 스스로 알고 있음 — inst가 살아있어도 + -- 이 값이 먼저 죽어 있을 수 있으므로(예: retract가 unbindLifetime만 + -- 하고 inst는 안 죽음) 반드시 먼저 확인. + if (isObserver(value) or isEffect(value)) and not value.Subscribed then + return false + end + local gcconn = relate:GetStrong(inst, GCCONN) + return gcconn ~= nil and gcconn.Connected +end +``` + +**`canExecute`의 시그니처는 `(inst, value) -> boolean`(2026-08-08 세션, +재정정 — 원래 있던 "`(handle) -> boolean`, zero-arg 아님" 결정을 대체함).** +이전 라운드(2026-08-07 여덟 번째 세션)는 "등록마다 클로저를 새로 만들지 +않기 위해 zero-arg 대신 `handle` 인자를 받는다"까지만 확정했는데, 실제로 +`handle`이 뭘 가리키는지(단일 Connection? Observer 자신?)가 미정으로 +남아있었음 — 이번에 `(inst, value)` 2-인자로 구체화됨. 이유: Observer 자신의 +바인딩 생존(`Subscribed`)과 `inst` 자체 생존(gcconn)은 **독립적인 두 조건**이라 +하나의 opaque `handle`로 뭉치면 "inst는 살아있지만 이 Observer는 이미 +`:Unsubscribe()`됨" 케이스를 못 구별함 — 위 구현처럼 `value`의 타입에 따라 +분기해서 먼저 확인하고, 그 다음 `inst` 공유 gcconn을 봄. "canExecute 하나로 +전역 통일" 원칙(Slot 생존/Observer 게이팅/store-bind retract 전부 재사용)은 +안 바뀜, 시그니처만 구체화된 것. + +**Instance당 gcconn/gchold는 하나로 공유**(꼭 그럴 필요는 없지만 보통 그게 +싸서) — `bindLifetime`을 여러 값에 대해 여러 번 불러도 같은 `inst`면 같은 +`gcconn`/`gchold`를 재사용(첫 호출에서만 생성, 이후는 `relate:GetStrong`으로 +바로 찾음). `Relate`의 lazy 생성 자체가 이 재사용 비용을 이미 다뤄줌 — +자세한 내부 구조는 `base/relate-plan.md`. + +**실측 필요(M0/M2)**: Observer→liveness 역참조를 `value.Subscribed` 필드 +직접 읽기로 확정했으나(위 구현), 실제 Luau 필드 접근 비용/weak table 조회 +비용 비교는 여전히 quad-roblox 구현 단계에서 실측 확인 대상. + +이건 `base/bind-system-plan.md`의 "핸들러 내부 상태 저장" 유틸(`Relate` +직접 사용)과 짝을 이루는 별도 유틸 — 하나는 "상태를 어디에 저장할지" +(`Relate:SetStrong`/`:SetWeak`), 다른 하나는 "언제까지 실행되어도 되는지" +(`bindLifetime` + `canExecute`)를 다룸. 후자는 내부적으로 전자가 제공하는 +같은 `Relate` 프리미티브 위에 얹혀 구현됨(위 절) — 별도 저장 메커니즘을 +새로 만든 게 아니라 `Relate` 하나를 두 용도로 재사용. 둘 다 base가 제공하는 +범용 유틸로 확정. + +**교차검증(2026-08-04 4차 라운드)**: 사용자가 공유해준 실제 참고 코드 +(`.claude/initreq/artworks/EventDrivenProgramming/`, PA님 작성)는 GC-native가 +아니라 전부 수동 `:unsubscribe()`/`:disconnect()`로 관리됨 — rbvm 기반 +GC-native 원칙과 반대 선택이라 재확인했으나 **GC-native 유지로 확정**(지금까지 +명시적 dispose가 꼭 필요할 만큼 큰 자원을 다루는 실제 사례가 없었음). **막다른 +길은 아님을 기록**: rbvm처럼 관계를 양쪽 다 weak-keyed로 두고 모든 걸 connection +람다에 담아두는 방식이면, 나중에 GC만으로 부족한 케이스가 실제로 생겨도 그 +connection을 얻어 `disconnect()`하는 명시적 dispose 경로를 추가로 얹는 게 +가능한 디자인 — 필요성이 드러나면 그때 얹을 하이브리드 여지로만 남겨둠. + +**재사용 사례(2026-08-04 2차 라운드)**: Store/State의 무효화(invalidate) +신호를 받는 리스너 클로저도 정확히 이 유틸로 등록됨 — `base/ +store-semantics.md`가 예전에 "state 옵저빙 결과로 slot을 조작할 때 생존 +여부를 어떻게 확인할지" 미해결로 남겨뒀던 문제가, 사실은 새 메커니즘이 +필요한 게 아니라 이 canExecute 게이트를 그대로 적용하면 되는 사례였음(별도 +`isInit` 분기 불필요). 상세는 `base/bind-system-plan.md`의 "Store/State/ +Source 온톨로지" 절 참고. + +## 2026-08-04 검증 라운드에서 보강된 내용 + +**`Connected` 체크는 rbvm 패턴을 그대로 베끼는 게 아니라 base가 인터페이스로만 +내보내는 것.** Roblox는 `RBXScriptConnection`에 이미 `Connected`가 존재하고 +Destroy 시 모든 커넥션을 즉시 끊어주지만, 다른 엔진에서도 라이프사이클을 +확인할 수 있어야 하므로 base는 "이 바인드가 아직 유효한가"를 묻는 람다/인터페이스만 +정의하고, quad-roblox가 그 구현을 Roblox의 실제 `Connected`로 채워넣는다(구현 +주입 방식은 아래 "base 유틸은 인터페이스, 구현은 백엔드 팩토리" 절 참고). 이게 +필요한 이유: rbvm처럼 GC 트릭으로 라이프사이클을 연결하면 GC가 즉발이 아니라서 +중간에 죽은 참조가 남아있을 수 있고, 그 시점에 store에 새 값이 들어오면 죽은 +대상에 처리를 시도하다 터질 수 있음 — 그래서 처리 직전에 유효성을 확인. + +**`Destroying` 훅은 생각보다 덜 중요할 수 있음.** rbvm의 GC-네이티브 무효화 +방식(자료구조를 직접 건드리지 않고 네이티브 GC에 후처리를 위임)이 성능상 +유리해서, `Destroying` 훅에 명시적으로 의존하는 경로는 실제로는 거의 필요 +없을 가능성이 큼 — 확정된 방향(Destroying 하나로 통일)은 유지하되, 실제 +구현에서 이 훅을 쓰는 지점이 예상보다 적을 수 있다는 점을 열어둘 것. + +**즉시(eager) 정리 예외 두 가지(작고 유계한 포인터, 네임스페이스 dispose)는 +quad에는 거의 해당 안 될 가능성이 큼.** rbvm은 이미 존재하는 real DOM 위에 +가상 계층을 얹는 구조라 "가상 계층이 필요 없어지면 지운다"는 문제가 있지만, +quad는 자신이 만든 instance를 항상 끝까지 들고 있어서 이런 종류의 즉시 정리 +자체가 필요 없을 가능성이 높음 — 실제 구현 단계에서 필요성이 확인되면 그때 +추가. + +**retract는 Destroy 시점에 필요 없는 이유가 엔진 레벨에서 한 번 더 보강됨.** +Roblox 엔진 자체가 Destroy 시 Tag/Attribute/실행 중인 Tween을 전부 알아서 +정리해준다 — 라이브러리가 따로 처리할 필요가 없음. Roblox 이외의 엔진에서 +이런 정리가 필요하다면 그건 그 엔진의 `quad-X` 서브패키지가 책임질 문제(base +관심사 아님). 사용자가 커스텀 Destroy-time 처리가 필요하면 `[Event +"Destroying"]`을 직접 바인드해서 처리하면 되는 구조라, 라이브러리가 강제로 +제공할 필요도 없음. + +## 이름: `cleanup` → `retract` + +"cleanup"이라는 이름은 부적절하다는 사용자 피드백(완전 소멸 정리로 오인되기 +쉬움) — 실제 의미는 "이전에 적용한 처리를 무른다/멈춘다"이므로 **`retract`** +로 통일. (`revert`, `rescind`도 검토했으나 `retract`가 "이전에 취한 조치를 +철회한다"는 의미로 가장 정확 — `process`/`retract` 쌍으로 자연스럽게 대구를 +이룸.) 대부분의 문서에서 이 이름으로 갱신됨 — 잔여 "cleanup" 표기가 남은 +문서가 있을 수 있으며, 그 확인/정리는 진행 중. diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md new file mode 100644 index 0000000..718b321 --- /dev/null +++ b/.claude/base/modifier-plan.md @@ -0,0 +1,586 @@ +# Modifier 설계 (정적 merge, immutable 체이닝) + +**상태**: base — 핵심 메커니즘(런타임 plug 아님/정적 merge, immutable +값+clone 기반 체이닝, 이중 setter)은 2026-08-04 세션 채팅 논의로 확정. +**Getter는 별도로 안 만들기로 확정(2026-08-06 후속 세션)** — 아래 "4. +Setter는 리터럴 값과 변환 함수 둘 다 받음" 절 참고. **팩토리 함수 체이닝 +(`:Apply`), 값 결합(`Overridden`, 구 `Merge`), 필드 읽기(`:Peek`)+판별 +(`isState`)은 2026-08-07 세션들에 걸쳐 확정 — 8/9번 절 참고, 한 줄 요약은 +`Apply`="변경을 수행", `Overridden`="이미 계산된 다른 mod를 합침".** Modifier가 +컴포넌트 경계를 어떻게 통과하는지(named parameter로 전달, multi-root +개념 폐기)는 별개 문제로 **[정정] `research/component-composition-plan.md`는 +2026-08-04 세션에 수렴 완료돼 `base/component-composition-plan.md`로 +승격됨 — 이 문서는 "Modifier 값 자체가 어떻게 동작하는가"만 다룸. + +## 문제 + +`base/architecture.md` 7번 항목("Style(Default) 시스템 폐기, modifier +지향")이 방향만 정하고, 실제 메커니즘은 미정이었음: 핸들러 레지스트리에 +넣을 것인가, 여러 modifier가 같은 키를 건드리면 어떻게 되는가, 트리를 +타고 내려가며 조금씩 변형되는 modifier(예: 문서 뷰어의 TextStyle 상속)를 +어떻게 안전하게 다룰 것인가. + +## 확정된 결론 + +### 1. 런타임 pluggable 핸들러 아님 — 정적 merge + +Modifier는 `isHandlable`/`priority`/`process`/`retract` 핸들러 레지스트리에 +안 들어감. 그냥 평범한 테이블(데이터)을 보유하는 값이고, 디스패치 들어가기 +전에 한 번 평탄화(flatten)돼서 최종 props 테이블에 합쳐짐. 이유: 런타임 +pluggable로 만들면 여러 modifier가 반응형으로 같은 키를 계속 다투는 CSS +cascade 문제가 그대로 오는데, 이건 이미 확정된 "Store 바인드 변경은 전체 +교체, 부분 오버레이 없음"(`base/architecture.md` 3번) 원칙과 충돌함. + +**flatten이 배열 항목 중 뭐가 Modifier인지 판별하는 수단 — `isModifier` +(`Brand` 기반, 2026-08-07 열 번째 세션 명시).** 다른 모든 nominal 타입 +판별과 같은 메커니즘(`bind-system-plan.md`의 `Brand` 절) 재사용 — flatten은 +배열을 훑으며 `isModifier(v)`가 참인 항목만 필드를 뽑아 merge하고, 나머지는 +전혀 안 건드리고 그대로 배열 파트에 남겨둠(그래서 `None`처럼 Modifier가 +아닌 값은 flatten을 그냥 통과함 — `component-composition-plan.md`의 +"필수 관용구" 절 참고). + +관련: 이미 마운트된 Instance에 재바인드할 때 Default→실값 flatten을 다시 +해야 하는지/clone이 필요한지는 별개 미정 문제로 +`research/existing-instance-bind-plan.md`의 "Default 값과 얽히는 문제" 절 +참고 — 여기서 다루는 건 컴포지션 타임의 modifier 값 자체 flatten이라 층위가 +다름. + +### 2. Merge 우선순위: 배열 순서와 인라인은 독립된 두 규칙 + +`Frame { modifier1, modifier2, Name = ... }` 평탄화 시: +(a) 배열에 나열된 modifier들끼리는 순서상 나중 것이 우선. +(b) 명시적 키(인라인)는 modifier가 뭘 하든 무조건 우선. +Lua 테이블 리터럴은 배열 파트/해시 파트 사이에 소스 텍스트 순서를 보존하지 +않으므로 "순서상 나중이 이긴다"는 단일 규칙만으로는 구현 불가 — 반드시 두 +규칙으로 쪼개야 함. + +### 2-1. 인라인 키로 modifier 필드를 명시적으로 "지우기" — `None` 센티널로 확정 (2026-08-07 여덟 번째 세션) + +**문제**: `{ TextColor3 = nil, mod }`처럼 인라인 키로 modifier가 주는 값을 +명시적으로 취소하고 싶어도, Lua 테이블 리터럴에서 `키 = nil`은 그 키 +자체가 아예 존재하지 않는 것과 구별이 안 됨(`pairs`에서도 안 보임) — 그래서 +위 2번 "인라인은 무조건 우선" 규칙이 실제로 작동할 근거(인라인 키가 +존재한다는 사실 자체)가 사라지고, `mod`가 주는 값이 그대로 새어나옴. + +**결론 — `None`은 raw 저장 계층에만 존재하는 실재값 센티널, merge/setter는 +전혀 안 바뀜.** 이벤트 store-bind의 "`nil` 대신 실재하는 센티널" +(`false`로 disconnect, `base/bind-system-plan.md` "이벤트도 store-bind +가능" 절)과 같은 발상이지만, 처리 위치가 다름 — merge 단계가 아니라 +**디스패치 단계**에서 풀린다: + +- **`{ TextColor3 = None, mod }`도, `mod:TextColor3(None)`도 둘 다 지원.** + Modifier setter/Overridden/인라인 props 테이블은 `None`을 그냥 평범한 raw + 값으로 저장·교체할 뿐 특별 취급이 전혀 없음 — 애초에 문제였던 건 "`nil`이 + 테이블에 존재하는 값으로 표현이 안 된다"는 것뿐이라, 표현 가능한 실재 + 센티널만 있으면 기존 merge 규칙("인라인 키 존재 시 무조건 우선", + `Overridden`의 "뒤 인자가 필드 단위로 이김")이 손댈 것 없이 그대로 작동함. + 구현 비용이 사실상 0이라 인라인 키/setter 둘 다 여는 데 주저할 이유가 + 없음(2026-08-07 여덟 번째 세션 확정) — setter로 받으면 "특정 필드만 지우는 + 재사용 가능한 modifier 조각"(9-1번의 스타일 프리셋 opt-out 시나리오)도 + 공짜로 됨. +- **`:Peek<>(key)`의 반환 타입이 `T | State | None | nil`로 확장됨** — + `Peek`은 raw 저장값을 그대로 읽으므로(9번 절 "현재 저장된 그대로 넘김" + 원칙) `None`을 다른 값처럼 있는 그대로 돌려줌. "필드가 아예 안 채워짐" + (`nil`)과 "명시적으로 지워짐"(`None`)은 raw 계층에서 계속 구별됨. +- **실제 "지우기" 동작은 디스패치 쪽 `NoneHandler`가 담당** — merge가 끝난 + 뒤 최종 flatten 결과에 `None`이 남아있으면, base 드라이버가 그 키를 어떻게 + 처리하는지는 새 개념이 아니라 이미 확정된 디스패치 모델 그대로다. + 상세는 `base/bind-system-plan.md`의 "`None` 센티널 — StoreBind와 + 같은 재귀 재디스패치 패턴 재사용" 절 참고 — 핵심만 요약하면 `NoneHandler`도 + `StoreBind` 핸들러와 완전히 같은 모양(`isHandlable`이 `v == None`을 + 잡고, `process`가 `v`를 진짜 `nil`로 바꿔 `process(inst, k, nil)`을 재귀 + 호출)이라 base 드라이버 자체엔 `None`을 아는 코드가 한 줄도 안 들어감 — + 개별 프로퍼티/이벤트/UI shorthand 핸들러도 자기 시그니처에 `None`이 안 + 나옴(원래도 있어야 했던 "`v`가 `nil`일 때" 처리를 재사용할 뿐). 구체 예시는 + `base/ui-shorthand-plan.md`의 UICorner 숏핸드 절(nil 받으면 만들어둔 자식 + 제거, 단 `retract`가 아니라 `process` 쪽 로직). + +### 3. Immutable 값 + clone 기반 체이닝 + +컴포지션 트리를 타고 내려가며 조금씩 변형되는 modifier(문서 뷰어에서 상위 +TextStyle을 상속해 타이틀만 1.2배 키우는 경우 — Jetpack Compose의 +`TextStyle.merge()`/`CompositionLocal`과 동일한 use case)는 특히 위험함 — +mutable하게 구현하면 같은 modifier 레퍼런스를 공유하는 형제 서브트리가 +오염되거나(한쪽이 mutate하면 다른 쪽도 영향받음), 재렌더 시 값이 누적 +드리프트하는 버그가 생김(`.claude/question.md` 초기 논의의 "원본 테이블 +덮어쓰기/루프 깨짐" 우려와 동일 클래스). + +**해결**: 모든 변환 메소드(`:FontSize(...)`류 체이닝)는 내부에서 +`table.clone(self)`로 새 테이블을 만든 뒤 필드만 덮어써 반환 — 원본은 +절대 mutate하지 않음. 별도의 제네릭 clone 콤비네이터 타입 +(`modifier<>(modifier):Set` 류 아이디어)은 기각 — 그런 타입을 만들면 +`base/architecture.md` 3번의 "복사 구현 지양, 필요한 곳만 팩토리 함수로 +명시적 복사" 원칙을 다시 재작업하는 셈이라, 각 변환 메소드 자체가 그 원칙을 +따라 알아서 최소한만 복사하면 충분. + +**성능**: Luau `table.clone`은 native shallow-copy라 modifier 크기(보통 +한 자리~여남은 개 필드) 기준 비용 무시 가능, 렌더/컴포지션 타임에만 +발생(프레임마다 도는 게 아님). State가 이미 `:With`/`:Compute`마다 새 +노드를 할당하는 것과 같은 급의 비용이라 일관되고, mutable+문서화 경고보다 +오염 버그를 원천 차단하는 쪽이 라이브러리 복잡도/사용자 편의 양쪽에서 +낫다고 판단 — **immutable 기본으로 확정**. + +**바닥 생성자 — `Modifier()`(필드 없는 빈 인스턴스, 2026-08-07 열 번째 +세션 명시).** 지금까지 문서 어디에도 modifier 체이닝이 시작되는 첫 +호출(`props.Modifier`처럼 이미 존재하는 modifier를 이어받지 않고 처음부터 +만드는 경우)이 명시된 적이 없었던 갭 — `Source(default)`/`Ref(default)`/ +`Store({defaults})`와 같은 "`Type(args)` 팩토리" 관습을 그대로 적용하면 +됨, Modifier는 초기 필드가 필수가 아니므로 `args`가 비어도 되는 +`Modifier()`. `mod:FontSize(20)`처럼 체이닝하는 모든 예시가 실은 이 +`Modifier()`가 만든 빈 인스턴스 위에서 시작함. `base/store-semantics.md` +"독립 존재 가능한 프리미티브" 절의 예시 목록도 이걸로 갱신. + +### 4. Setter는 리터럴 값과 변환 함수 둘 다 받음, 별도 Getter는 없음 + +`:FontSize(value)`(리터럴) / `:FontSize(function(current) return +current*1.2 end)`(변환 함수) 둘 다 지원 — 한 줄로 끝내고 싶을 때는 리터럴, +이전 값을 바탕으로 계산하고 싶을 때는 변환 함수 하나로 충분. + +**Getter는 만들지 않기로 확정(2026-08-06 후속 세션).** 애초에 getter가 +필요했던 유일한 이유가 "현재 값을 꺼내서 여러 줄에 걸쳐 계산한 뒤 리터럴로 +다시 넣는" 멀티라인 스타일이었는데, `:FontSize(function(old) ... end)` +변환 함수 하나가 그 케이스를 인라인으로 완전히 커버함 — 별도 `:Get(key)`/ +dot-access 겸용 여부를 고민할 이유 자체가 없어짐(모양을 정하는 대신 +개념을 없애는 걸로 해소). + +변환 함수는 State의 `:Compute`처럼 lazy State 핸들을 넘길 필요가 없음(*필드가 +순수 데이터인 일반적인 경우에 한해* — 필드가 State일 때의 예외는 아래 참고). +계산 비용 자체가 없는 순수 데이터라면 콜백엔 그냥 raw 현재 값을 즉시 넘기면 +충분(State의 self-lazy-핸들 문제와는 다른 카테고리). + +**`old`는 항상 "현재 저장된 그대로" 넘김 — 일관된 원칙.** 필드가 plain이면 +raw 값, State면 State 핸들 그 자체(아래 4-1 표의 "State + 함수" 행이 +`field:Compute(fn)`으로 위임하는 것과 동일 — `:With`/`:Compute`의 self가 +이미 raw 값이 아니라 State 핸들로 통일된 것과 같은 결). 별도 변환/정규화 +없이 그냥 지금 들고 있는 걸 그대로 준다는 원칙 하나로 이 절과 4-1절 표가 +전부 설명됨. + +**별도 `func(state) -> state` 인자 모양은 불필요(검토 후 기각).** "여러 +Compute를 합치고 싶다"는 동기였는데, 이미 두 가지로 다 커버됨: (1) 여러 +계산을 합치고 싶으면 변환 함수 본문 안에서 다른 함수를 그냥 호출하면 +됨(평범한 함수 합성, 새 계약 불필요), (2) 필드 자체를 State로 만들고 +싶으면 리터럴 자리에 State를 직접 넘기면 됨(위 4-1 표 "State" 행). 즉 +"함수가 State를 반환"하는 세 번째 모양이 커버할 새 유스케이스가 없음. + +**내부 구현**: `__real` 같은 별도 래퍼는 불필요해 보임 — 데이터를 테이블에 +직접 두고 메소드는 공유 메타테이블 `__index`로 붙이면, `table.clone`이 +메타테이블까지 그대로 복사해주는 Luau 동작 덕분에 클론해도 체이닝이 안 +끊김. flatten도 그 테이블 필드를 직접 읽으면 됨. + +**런타임은 클래스별 코드 없이 base에 딱 하나만 있으면 됨(2026-08-06 후속 +세션, 핵심 통찰).** `mod:FontSize(14)`는 `mod.FontSize(mod, 14)`로 풀리는 +문법 설탕이고, `mod.FontSize`는 `FontSize`가 리터럴 키로 안 박혀있으니 +`__index(self, key)`가 잡음 — 그러니 `__index`가 **어떤 key가 오든** 그 +key를 클로저에 캡쳐한 `function(self, arg) local clone = table.clone(self) +... end`류 함수를 즉석에서 만들어 리턴하기만 하면 끝. 즉 `:FontSize`/ +`:Round`/앞으로 생길 어떤 필드 이름이든 전부 이 **하나의 제네릭 `__index` +구현**이 처리 가능 — 필드별로 미리 등록된 메소드가 하나도 없어도 됨. +**중요한 결론**: 위 "FrameModifier 타입" 문제(클래스별로 flat 타입을 생성기로 +뽑아야 하는 것)는 순전히 **정적 타입 체크**를 위한 것이고, **런타임 +구현에는 아무 영향 없음** — quad-roblox의 클래스별 코드 생성이 늘어나도 +런타임 쪽 코드량은 절대 안 늘어남. 그리고 이 `__index` 메커니즘 자체는 +Roblox API에 전혀 의존 안 하는 순수 Lua 테이블 조작이라, "base는 인터페이스만, +구현은 백엔드 팩토리가 주입"(`base/bind-system-plan.md`) 원칙과 무관하게 +**Modifier의 체이닝 엔진 자체는 quad-base에 완결된 구현으로 그대로 +존재해도 됨** — 주입할 엔진별 구현이 애초에 없음. + +**Modifier 필드에 핸들러 계층 값(Ref/PreRef/Observer/Effect/Slot/ +Modifier)이 들어오면 즉시 error — UB 아님(2026-08-09 세션, 정정).** +이전 버전("권장 사용법은 아니지만 막을 이유도 없음 — 방어 로직 없는 +UB로 남겨둠")은 폐기. 재검토 근거(사용자): Modifier는 애초에 자식/Ref +같은 걸 다루는 목적이 아니고, 이런 값이 실제로 쓸모 있는 use case가 +없다고 확인된 이상 조용한 UB보다 그 자리에서 막는 쪽이 낫다 — 판별 +비용도 이미 있는 `Brand` 기반 predicate(`isRef`/`isPreRef`/ +`isObserver`/`isEffect`/`isSlot`/`isModifier`, `bind-system-plan.md`의 +`Brand` 절)를 그대로 재사용하면 되므로 거의 공짜. + +- **체크 지점 — 제네릭 `__index` setter가 최종 저장 직전에 검사.** 위 + 4번 절의 제네릭 setter(`clone[key] = value`, 또는 함수 인자면 + `clone[key] = fn(old)`, 4-1번 표의 State 분기 결과도 포함)가 실제로 + 필드에 쓰려는 값을 확정한 직후, 그 값이 `isRef(v) or isPreRef(v) or + isObserver(v) or isEffect(v) or isSlot(v) or isModifier(v)`를 + 만족하면 `error`. 리터럴로 직접 넣은 경우든(`mod:SomeField(someRef)`류 + 오용) 변환 함수가 반환한 경우든(`mod:X(function(old) return someRef + end)`) 동일하게 걸림 — "콜백이냐 직접 실행이냐"를 구분하지 않고 최종 + 저장값 하나만 보면 충분(사용자 제안). +- **State/Source는 여전히 허용** — 이 체크는 핸들러 계층 값만 잡음, + 4-1번 절의 "필드가 State일 수도 있음"과 안 부딪힘(`isState`가 참인 + 값은 이 체크를 그냥 통과함). +- **한계, 명시적 UB로 남김(2026-08-09 열한 번째 세션) — `State`류 + "State/Source가 담고 있는 값"이 핸들러 계층 값인 경우는 이 체크로 + 못 잡음.** `isRef(v)` 등은 setter가 확정하는 바로 그 값(State 자체 + 또는 plain 값)만 보므로, 값이 State/Source면 그 껍데기가 `isState`를 + 통과해 검사를 그냥 지나가고, 그 State가 나중에 `:Get()`됐을 때 실제로 + 내놓는 내용물(예: 그 State가 Ref/PreRef/Observer/Effect/Slot을 값으로 + 들고 있는 경우)까지는 검사하지 않음 — 검사 시점엔 아직 실체화 안 된 + 값이라 정적으로 알 수 없고, 값이 바뀔 때마다 매번 `:Get()`해서 + 검사하는 건 관측 시점을 앞당기는 부작용까지 생기는 오버엔지니어링. + **이 안쪽 케이스는 방어 로직 없는 순수 UB로 문서화만 하고 넘어감** — + 의도치 않게 자주 발생할 이유가 없는 조합이라 실사용 위험은 낮음. +- **7번 절(`State` UB)과의 비대칭이 이걸로 줄어듦** — + `pre-implementation-audit.md`가 지적했던 "같은 문서 안에서 한쪽은 + 방어(타입 차단 시도), 한쪽은 무방비 UB"라는 비일관성이, 이제 둘 다 + "적극적으로 막는다"는 같은 방향으로 정리됨(메커니즘은 여전히 다름 — + 하나는 타입 레벨 차단 시도+실패 시 UB 폴백, 하나는 런타임 `error` — + 이 차이 자체는 남지만 "막을 가치가 있는가"라는 판단은 통일됨). + +### 4-1. 필드가 State일 수도 있음 — Setter가 State/plain 여부로 분기 + +`architecture.md` 7번 항목이 "함수형 modifier가 store 바인드를 받을 수도 +있음"이라고 이미 언급한 대로, Modifier 필드는 plain 값뿐 아니라 State일 +수도 있음(예: 상위에서 내려온 테마 색상이 Store에 바인드된 반응형 값). +이 경우 위 4번의 setter가 그대로 통하려면, **현재 저장된 필드 값이 State냐 +plain이냐에 따라 setter 내부 동작이 갈려야 함** — 새 개념이 아니라 State에 +이미 있는 lazy/`:Compute` 체이닝을 그대로 재사용하는 것뿐: + +| 현재 필드 | 인자 | 동작 | +|---|---|---| +| plain | 리터럴 | clone 후 그 값으로 덮어씀 | +| plain | 함수 | clone 후 즉시 호출해 나온 값으로 덮어씀(현재 값이 raw로 넘어감) | +| **State** | **리터럴** | clone 후 **State를 통째로 리터럴로 덮어씀 — 의도적으로 반응성이 끊김**(Store의 "부분 오버레이 없음, 전체 교체" 원칙과 같은 결) | +| **State** | **함수** | clone 후 `field:Compute(fn)`으로 **새 파생 State**를 만들어 대입 — 반응성 유지, State의 기존 `:Compute` 메커니즘에 그대로 위임 | + +즉 함수형 셋터는 필드가 State일 때 반응성을 보존하고, 리터럴 셋터는 그 +순간 값을 확정시켜 반응성을 끊음 — 이 차이는 사용자가 인지하고 골라 쓰는 +것으로 문서화. + +### 4-2. Modifier는 소유권/유일성 제약이 없음 + +Modifier는 자식(child)을 담지 않음 — 마운트 정체성이 없는 순수 값. 그래서 +어떤 컴포넌트가 특정 modifier를 실제로 적용하든 안 하든, 또 같은 modifier를 +트리 여러 곳에 반복 적용하든 에러가 나지 않고 상관없음(Ref나 Slot 자식처럼 +"정확히 한 곳에만 마운트돼야 한다"는 소유권 제약이 이들에게는 있지만 +Modifier에는 없음). + +### 5. 타입 출처는 이미 확정된 dot-access 관습 재사용 + +"누가 modifier에 타입을 붙여주냐"는 새 문제가 아니라, Store/인스턴스 생성에 +이미 적용한 "정적으로 알려진 건 dot-access, 동적인 건 문자열 폴백" 프로젝트 +전역 관습(`base/bind-system-plan.md` "타입 추론 문제" 절)을 그대로 적용하면 +됨 — `mod:UICorner(8)`/`mod:FontSize(...)`처럼 DI 쪽 "제네릭 생성자 함수 +하나 + 자주 쓰는 것만 정적 필드로 미리 바인딩" 패턴 재사용. +(주의: 이벤트는 이 관습의 유일한 예외라 인용 대상에서 제외 — 이벤트 바인딩은 +PA님 방식인 문자열 키 + 런타임 리플렉션으로 감, `base/bind-system-plan.md` +"이벤트 바인딩 정정" 절 참고. Modifier는 이벤트가 아니라 Store/인스턴스 +생성과 같은 카테고리라 dot-access 관습이 그대로 적용됨.) + +`mod:UICorner(8)`가 실제로 어떻게 UICorner 자식을 만들어 붙이는지(v1의 +`Corner` 특수 프로퍼티 선례, 핸들러 배치 소견)는 +`base/ui-shorthand-plan.md` 참고 — 이 문서는 Modifier 값 자체의 +동작만 다루므로 분리. + +### 6. State/Pipe 쪽엔 영향 없음 — 이미 있던 결정의 재확인일 뿐 + +Modifier가 immutable해야 하는 이유(변환마다 clone)와 State가 이미 +"`:With`/`:Compute`마다 새 노드를 만든다"(`base/bind-system-plan.md` 2차 +라운드 확정)로 확정해둔 이유는 같은 클래스의 문제(공유 mutable 상태로 인한 +오염 방지)임을 이번 논의에서 재확인했을 뿐 — State/Source 온톨로지 자체엔 +변경 사항 없음. 파이프 분기(`:With(...):Compute(fn)`)는 이미 코드에 +명시적으로 쓰는 구조라 "암묵적 분기"가 애초에 존재하지 않음 — 새로 결정할 +것 없음. + +### 7. State/Source가 Modifier를 값으로 담는 것 — 명시적 error로 확정 (2026-08-04 신설, 2026-08-09 세션 정정) + +Modifier "필드"가 State일 수 있는 것(4-1번)과는 별개로, **State/Source +자체의 value가 Modifier인 경우**(예: `someState:With(fn)`이 Modifier를 +반환하거나 `someSource:Set(someModifier)`)는 지원 대상이 아님 — Modifier는 +"flatten해서 한 번 적용"이 전제인 정적 값인데, State/Source에 담기면 그 +값이 반응형으로 바뀔 수 있다는 뜻이 되어 매번 재-flatten이 필요해지고, +이는 "정적 merge" 확정(1번, "Modifier는 런타임 pluggable 핸들러가 아니라 +dispatch 밖에서만 처리되는 유일한 존재")과 정면으로 충돌함 — **사용자 +확정**("state 안에 modifier가 있으면 그건 끔찍히 힘들꺼야... 타입 상 받지 +못하게 만들어야 할 수도 있고"). + +**[정정, 2026-08-09 세션] "UB, 가능하면 타입 차단"에서 "명시적 +`error`로 확정"으로 전환** — 위 "핸들러 계층 값이 필드로 들어오면 +즉시 error" 절(Ref/PreRef/Observer/Effect/Slot/Modifier가 Modifier +*필드*로 들어오는 걸 막은 것)과 같은 방향으로 통일: `isModifier` +predicate(`Brand` 절)를 State/Source 쪽에도 적용해 **런타임에 직접 +막는다.** 타입 차단(`State` 같은 조합을 타입 정의 단계에서 +거부)은 여전히 되면 좋은 보너스로 계속 시도해볼 수 있지만 +(`research/pre-implementation-audit.md` 2-2 — Luau에서 실제로 가능한지 +미검증), **더 이상 유일한 방어선이 아님** — 타입이 뚫려도 런타임 +`error`가 항상 잡아준다. + +- **적용 지점**: "어떤 값이 Source/State의 현재 값으로 확정되는 모든 + 지점" — `Source:Set(value)` 호출 시, `Store({defaults})` 생성 시 + 각 `defaults` 키를 `Source(v)`로 만드는 시점, 그리고 State의 + `:Compute(fn)` 결과를 캐시로 저장하기 직전(`fn`이 반환한 값이 + `isModifier`면 캐싱 전에 `error`). 새 체크 지점을 여러 곳에 흩는 게 + 아니라, "값이 State/Source의 값으로 확정되는" 이미 존재하는 몇 안 + 되는 지점에 `isModifier` 검사 한 줄씩 얹는 것뿐. +- **Slot/Tag/Attribute 등 다른 핸들러 계층 값은 여전히 아무 + 문제 없이 State/Source에 담길 수 있음 — Modifier만의 예외임을 + 명확히.** (사용자 확인: "slot은 당연히 가능함, retract도 되는 애고 + 런타임 값이라") 이 값들은 전부 정상적으로 `process`/`retract` + 재귀 경로(store-bind 재실행 모델, "확정된 디스패치 모델" 절)를 타는 + 진짜 런타임 dispatch 참가자라, State/Source 값으로 담겨 바뀌어도 + 기존 재귀 재-dispatch 메커니즘이 그대로 처리해줌 — 새로 막을 이유가 + 없음. Modifier만 유독 문제인 건 Modifier가 애초에 dispatch 경로를 + 아예 안 타는 유일한 존재(1번 절)라서, State/Source에 담기는 순간 + "재귀 재-dispatch로 처리"할 대상 자체가 없어지기 때문 — 이 구분이 + 왜 Modifier만 막고 나머지는 다 허용하는지의 핵심 근거. **[정정, + 2026-08-10 세션] `Tween`은 이 그룹에서 빠짐** — Tween이 독립 Dispatch + 핸들러(`process`/`retract`를 가진 dispatch 참가자)에서 PropertyHandler가 + 소비하는 값-레벨 래퍼로 재설계되며(`research/tween-plan.md`), `Tween`는 + 이제 `process`/`retract`가 없는 순수 raw 데이터 값 — `None`과 같은 + 분류. State/Source에 `Tween`가 담기는 것 자체는 여전히 문제없이 + 허용되지만(위 타입 대수 절 참고), 그 이유는 "재귀 dispatch 참가자라서"가 + 아니라 "그냥 raw 값이라서"로 바뀜. +- **`Store`의 `T`는 Modifier가 될 수 없음(`base/store-semantics.md` + "따름정리" 절)도 이 결정을 그대로 물려받음** — Source가 State를 + 구조적으로 만족하므로 별도로 다시 논증할 필요 없이 동일하게 적용됨. + +### 8. `:Apply(factory)` — 팩토리 함수 체이닝 지원 (2026-08-07) + +**동기**: 재사용 가능한 스타일 프리셋을 만들고 싶을 때(예: `Boldify(mod) +-> mod`처럼 어떤 modifier든 받아 기본값보다 더 두껍게 만들어 돌려주는 +함수, 커링해서 `Boldify(10)(mod) -> mod`처럼도 씀) 이런 "modifier +팩토리"를 체이닝에 자연스럽게 끼워 넣을 방법이 없었음 — 팩토리를 직접 +호출하면 `Italicify(Boldify(10)(mod:FontSize(14)))`처럼 안에서 밖으로 +쌓여 읽는 순서가 실행 순서와 반대로 뒤집힘. + +**결정**: `mod:Apply(factory)`를 지원 — `factory`는 그냥 `Modifier -> +Modifier` 평범한 함수(커링된 클로저 포함, 새 타입 개념 아님). 동작은 +`function(self, factory) return factory(self) end`이 전부. 이걸로 +`mod:FontSize(14):Apply(Boldify(10)):Apply(Italicify)`처럼 필드 +setter 체이닝과 팩토리 적용을 같은 fluent 문법 하나로 섞어 쓸 수 +있음 — 읽는 순서 = 적용 순서. + +**왜 좋은 아이디어인가**: Jetpack Compose의 커스텀 `Modifier` 확장 함수 +패턴(`fun Modifier.myStyle(): Modifier = this.then(...)`)과 동일한 +효용(모듈화된 스타일 프리셋을 라이브러리로 나눠 배포/재사용, 체이닝으로 +조합)을 Luau엔 확장 함수 문법이 없으니 `:Apply` 콤비네이터로 흉내낸 것. +새 개념을 추가하는 게 아니라 "펑션도 그냥 값"이라는 Lua 특성과 이미 있는 +immutable clone 체이닝(3번)에 얹는 얇은 sugar라 구현/개념 비용이 거의 +없음 — 팩토리 자신이 내부에서 이미 `:FontSize(...)` 같은 필드 setter를 +호출해 clone된 새 Modifier를 반환하므로, `Apply` 자체는 clone할 필요조차 +없음(`factory(self)`가 이미 새 값을 만들어 줌). + +**구현 시 주의**: `Apply`는 제네릭 `__index`가 즉석에서 만들어주는 필드 +setter 클로저(4번)와 이름이 겹치면 안 됨 — `__index`가 고정 메소드 +테이블(현재는 `Apply` 하나)을 먼저 확인하고, 없을 때만 필드 setter를 +합성하도록 구현. 따라서 **`Apply`는 Modifier 필드 이름으로 예약됨**(실제 +스타일 프로퍼티 이름과 겹칠 일은 거의 없어 보이지만 문서화 필요). + +**권장 관용구, 문서화 필요(2026-08-07 다섯 번째 세션)**: 특정 modifier를 +계속 변형/보정하고 싶은 경우(스타일 프리셋, 커링된 팩토리 등)엔 항상 +`Apply`를 기본 선택지로 유도할 것 — 아래 9번의 `Overridden`는 "이미 따로 +만들어진 modifier 값 두 개 이상을 합쳐야 하는" 경우로만 좁혀서 문서화(용도 +구분 절 참고). + +**`Apply`는 `factory(self)` 그 이상도 이하도 아님 — 특별한 계약 없음, +문서화 필요.** `factory` 내부가 `Peek`으로 State를 기대했는데 없다고 +`error`를 던지거나, 특정 조건에서 그냥 죽어버리는 것도 `Apply` 입장에선 +아무 문제 아님 — `Apply`는 `factory`가 뭘 하든 관여하지 않는 순수 함수 +호출 sugar일 뿐이라, 유효성 검사/기본값 처리/에러 핸들링은 전부 `factory` +저작자 책임. 문서에는 "`:Apply(f)`는 `f(mod)`를 체이닝 문법으로 쓴 것뿐, +Apply 자체가 뭔가를 검증하거나 보장해준다고 오해하지 말 것"을 명시. + +### 9. Modifier 결합 — `Modifier.Overridden(mod1, mod2, ...)`, `:Peek`, `isState` (2026-08-07 다섯 번째 세션) + +**배경**: `base/component-composition-plan.md` 3번 절이 이미 "여러 +modifier를 하나로 합치는 공개 유틸이 필요하다"고 확정하며 `Modifier.Merge` +가칭을 남겨뒀었음(컴포넌트 경계는 `props.Modifier` named parameter 단일 +슬롯이라, 리프 레벨 `Frame{mod1, mod2}` 배열 flatten이 거기까진 안 닿아서 +생기는 진짜 필요 — `Apply`만으로는 안 풀림: `Apply`는 팩토리 함수를 받는 +콤비네이터라, 이미 따로따로 만들어진 modifier *값* 두 개를 하나로 합치려면 +호출부가 그 값 중 하나를 즉석에서 팩토리로 다시 쓰도록 강제하게 됨 — +`Apply`로 완전 대체/강제 통합하는 방안도 이번에 검토했으나 이 실사용 +니즈를 못 풀어서 기각). 이번 세션에서 실제 동작을 확정. + +**이름 변경**: `Merge` → **`Overridden`로 확정**(사용자 제안). "Merge"는 +중립적 합침을 암시하지만 실제 동작은 명시적으로 나중 인자가 이기는 +"덮어쓰기"라, 이름이 의미를 정직하게 반영해야 함 — `component-composition-plan.md`의 +참조도 이번에 같이 갱신함. + +**용도를 좁게 문서화할 것 — "진짜 합칠 필요가 있는 경우"로 한정 +(2026-08-07 다섯 번째 세션, 사용자 강조).** `Overridden`는 범용 조합 +도구가 아니라 `Frame{mod1, mod2}`의 컴포넌트 경계판, 그 이상도 이하도 +아님 — 이게 없으면 `props.Modifier` 같은 단일 슬롯에 여러 독립 modifier +값을 넣을 방법이 아예 없어지므로 프리미티브로 남겨두는 것뿐. **"특정 +modifier를 계속 바꿔나가고 싶다"는 요구는 `Overridden`가 아니라 위 8번 +`Apply`로 풀도록 유도** — 간결한 커링/일급 함수 전달이 기본 관용구가 +되도록, API 문서에서 `Overridden`를 "값 두 개 이상을 합쳐야 하는 특수 +상황"으로만 소개하고 스타일 변형/보정의 기본 진입점으로는 절대 먼저 +보여주지 않을 것. + +**동작 = 기존 flatten을 함수로 노출한 것, 새 규칙 없음.** +`Modifier.Overridden(mod1, mod2, ...)`는 뒤 인자가 필드 단위로 이긴다(2번 +절 "배열 순서" 규칙과 동일). 구현은 단순 필드별 덮어쓰기 — 특별한 +State/함수 분기가 필요 없음: setter가 이미 호출 시점에 함수를 즉시 +실행하고 State 필드는 즉시 `:Compute`로 파생시켜 저장하므로(4번/4-1번), +Modifier가 들고 있는 모든 필드는 항상 "그 시점에 이미 완전히 처리된 +(baked) 값"임 — `Overridden`는 그 baked 값을 필드별로 그대로 교체할 뿐. + +**경고, 반드시 문서화**: baked 값 교체는 그 필드에서 파생된 다른 필드에 +소급 반영되지 않는다. 예: `Boldify`가 `Font` 필드를 읽어(`Peek`, 아래 +참고) `FontWeight`를 계산해 넣어둔 modifier를, 나중에 `Font`를 바꾸는 +다른 modifier와 `Overridden`로 합치면 `Font`는 새 값으로 바뀌지만 +`FontWeight`는 예전 `Font` 기준으로 계산된 채 그대로 남는다 — 사용자 +실수 범주지만 조용히 틀린 결과가 나오는 케이스라 API 문서(경고 박스)로 +명시 필요. `A:Overridden(B)`와 `B:Overridden(A)`가 다른 결과를 낸다는 순서 +의존성도 같은 경고 박스에 같이 명시. + +**용도 구분 — `Apply` vs `Overridden`, 둘 다 유지, 서로 대체 안 함**: +한 줄로 요약하면 **`Apply`는 "특정 대상에 대해 변경을 수행한다", `Overridden`는 +"특정 대상에 이미 계산된(baked) 다른 mod를 합친다"** — 문서화 시 이 한 +문장을 그대로 핵심 구분 기준으로 앞세울 것(2026-08-07 다섯 번째 세션, +사용자 정리). 재사용 가능한 스타일 "변형"(팩토리, 파라미터화 가능)은 +`Apply`, 독립적으로 이미 만들어진 modifier "값" 두 개 이상을 한 슬롯에 +밀어넣어야 하는 경우(주로 컴포넌트 경계)는 `Overridden`. + +**9-1. 판단 기준을 "이질적/동질적"이 아니라 "계산 의존성 유무"로 명시할 것, +`Apply`를 mutable로 바꾸는 방안은 기각 (2026-08-07 다섯 번째 세션 후속)** + +**동기**: `Apply` 체이닝이 호출마다 clone을 만들기 때문에, 항목 수천 개짜리 +리스트 UI처럼 무거운 Modifier를 대량으로 재생성하는 상황에서 이 clone +비용이 누적되는 게 아닌지 사용자가 우려 — 대안으로 (a) `Apply`/setter를 +아예 mutable로 바꾸는 방안(과 그 절충안), (b) `Overridden`를 "여러 값을 +합칠 특수 상황"이 아니라 "성능 최적화 수단"으로 승격하는 방안을 검토. + +**(a) `Apply`/setter를 mutable로 바꾸는 방안(및 "`Apply` 경계에서만 clone" +절충안) — 둘 다 검토 후 기각.** 3번 절의 immutable+clone 하드 제약(형제 +서브트리 오염 방지)이 clone 비용 절감보다 우선순위가 높다는 결론, 절충안도 +"어디서 터지느냐만 달라질 뿐 문제 자체는 남는" 비일관적 타협이라 기각 — +전체 경위·반박 논리는 `archive/modifier-apply-mutable-rejected.md` 참고. + +**(b) 판단 기준 — "동질적 vs 이질적 프로퍼티"가 아니라 "필드 간 계산 +의존성 유무"로 명시.** 사용자가 처음엔 "동질적(폰트 굵기 보정처럼 연관된 +속성끼리)은 `Apply`, 이질적(배경/텍스트/위치처럼 무관한 속성끼리)은 +`Overridden`"로 구분을 제안했으나, 실제 기준은 주제의 이질성 자체가 아니라 +**한쪽이 다른 쪽의 이미 baked된 값을 읽어야 하는가(`Peek`으로 데이터가 +흘러가는가)**임 — 이질적으로 보여도 계산 의존성이 있으면 `Apply`가 +맞고(예: "배경색에 맞춰 텍스트 명도를 자동 보정" — 배경/텍스트라는 이질적 +주제인데도 의존성이 있어 `Peek`+`Apply`가 필요), 반대로 동질적으로 보여도 +서로 완전히 독립이면(예: 여러 개의 `FontSize` 프리셋 중 하나를 통째로 +갈아끼우는 경우) `Overridden`도 무방함. `Overridden`는 필드 단위 raw 교체일 +뿐 `Peek`으로 값을 읽어 다른 필드에 반영하는 데이터 흐름이 아예 없으므로 +(위 "동작" 절), 계산 의존성이 있는 조합엔 애초에 못 씀 — 이게 진짜 판별 +기준. 문서에는 "이질적/동질적"이라는 표면적 구분 대신 이 기준으로 적을 것. + +**실제 최적화 권장 패턴**: 계산 의존성이 없고 재사용 가능한 조각(예: +배경 스타일 하나, 텍스트 스타일 하나, 레이아웃 위치 하나 — 각각 서로 +다른 서브시스템/모듈에서 한 번만 만들어지는 값)은 **모듈 상수/한 번만 +생성한 값으로 만들어두고, 인스턴스마다 `Overridden`로 결합**하는 게 +`Apply` 체인으로 매번 처음부터 다시 파생시키는 것보다 저렴함 — 조각 자체를 +매번 재계산 안 해도 되고, `Overridden`는 필드별 단순 복사 한 번으로 끝나서 +여러 단계 clone이 누적되는 `Apply` 체인보다 쌈. **주의**: 이건 "`Overridden`가 +내부적으로 값을 캐싱해준다"는 뜻이 아님 — `Overridden` 자체엔 캐싱/메모이제이션 +같은 새 메커니즘이 전혀 없고(순수 필드 복사), "캐싱"은 그냥 사용자가 조각 +Modifier 값을 변수/모듈 상수로 만들어 재사용하는, 기존에도 항상 가능했던 +평범한 값 재사용일 뿐 — 라이브러리에 새 캐싱 레이어를 추가하는 게 아니라는 +점을 문서에서 분명히 할 것(라이브러리 차원의 자동 메모이제이션은 지금 +검토 대상 아님 — 실제로 필요하다고 확인되면 그때 별도로 논의). + +**문서 배치**: 초심자 문서엔 `Overridden`를 아예 안 보여주고(위 "용도를 좁게 +문서화" 절), 이 "언제 `Apply` vs `Overridden`, 성능 기준" 절 전체는 api/심화 +문서 전용 — `research/documentation-content-map.md`의 modifier-plan.md +분류에 반영 완료. + +**미검토로 남긴 것**: `Apply` 체인이 실측으로 병목이라고 확인되면 그때 +"unsafe/fast-path mutable 빌더" 같은 별도 opt-in을 검토할 수 있으나, 지금은 +근거 없는 선제 최적화라 설계하지 않음 — CLAUDE.md의 "드문 오용/가상 미래 +요구까지 방어/최적화하려고 구조를 복잡하게 만들지 않는다" 원칙과 동일. + +### 9-2. `Overridden`가 서로 다른(그러나 상하위 관계인) Modifier 타입을 섞는 경우 — +타입 시그니처 미확정, 실 Luau 테스트 필요 (2026-08-07 다섯 번째 세션 후속) + +**문제**: Modifier 타입은 위 4번 절 "FrameModifier 타입" 언급대로 Roblox +클래스별로 생성기가 뽑아내는 flat 타입인데, 그 밑의 Roblox 클래스 자체엔 +서브타입 관계가 있음(`Frame`이 `GuiObject`의 서브클래스) — 그럼 생성된 +`FrameModifier`도 `GuiObjectModifier`의 서브타입이어야 자연스럽고, 실제로 +`Modifier.Overridden(guiObjectMod, frameMod)`처럼 공통 상위 클래스 스타일 +프리셋과 하위 클래스 전용 보정을 섞어 합치는 패턴이 필요해 보임(사용자 +지적, 2026-08-07). + +**막히는 지점**: 필드 setter 메소드(`:FontSize` 류)는 각 타입마다 반환 +타입이 자기 자신(`self`, 즉 `FrameModifier`는 `FrameModifier`를, +`GuiObjectModifier`는 `GuiObjectModifier`를 리턴)이라, 같은 이름의 메소드 +필드끼리 리턴 타입이 갈려서 단순 구조적 서브타이핑만으로는 안 풀릴 가능성이 +있음. + +**후보안(미검증)**: 같은 이름의 메소드 필드는 리턴 타입이 다르니 그냥 +`any`로 뭉개고, 나머지(메소드가 아닌 순수 데이터 필드) 쪽만 `[string]: nil`류 +인덱스 시그니처 조건이 성립하면 통과시키는 식으로 서브타입 호환을 흉내낼 수 +있는지 — 이게 실제로 Luau 솔버에서 받아들여지는 타입 구성인지는 추론만으로 +결론 낼 수 없고 실제 코드로 테스트해봐야 함(M0가 이미 검증 대상으로 삼은 +"추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"과 같은 성격의 모호함). + +**당장의 fallback**: 위 후보안이 Luau에서 실제로 안 먹히는 걸로 확인되면, +`Modifier.Overridden`의 타입 시그니처를 일단 `Overridden(...: any): any`류로 +느슨하게 열어 정적 체크를 포기 — 이건 임시 처치로 명시하고, M7 실제 구현 +시점에 실 테스트 결과에 따라 다시 좁히는 걸 목표로 로드맵에 남김 +(`ROADMAP.md` M7). + +**`:Peek<>(key): T | State | None | nil`** — Modifier 필드를 확정하지 +않고 그대로 읽는 접근자. 이름을 `Get`이 아니라 `Peek`로 정한 이유: 이 +프로젝트 전역에서 `State:Get()`은 "확정한다"(pull + recompute + 최종값 +반환)는 의미로 이미 자리잡았는데, Modifier의 읽기는 정반대(들고 있는 +그대로, State면 State 핸들 그대로) — 같은 동사를 반대 의미로 쓰면 안 +되므로 다른 이름 필요. 반환 타입을 `T`로 확정해 돌려주지 않고 +`T|State|nil` raw 그대로 노출하는 이유: 4-1번 절의 함수형 setter가 +받는 `old` 인자와 정확히 같은 원칙("현재 저장된 그대로 넘김") 재사용 — +자동으로 `:Get()`해서 `T`로 확정해버리면 반응성이 조용히 끊기는데 +타입엔 그 사실이 안 드러나서 위험함. `.RealValue.Font` 같은 별도 +인덱싱 표면은 기각 — 이미 `__index`가 필드 setter 합성용으로 예약돼 +있는데(`Apply`가 첫 예약 사례) 또 다른 프록시 네임스페이스를 얹으면 +setter 표면과 read 표면이 헷갈리고, 타이핑 이득도 메소드 방식과 별 +차이 없음. + +`:Peek`는 팩토리 함수(`Apply`에 넘기는 콤비네이터) 안에서 쓰는 게 +전형적 — "이 modifier가 현재 어떤 상태인지 보고 그걸 바탕으로 값을 +계산"한다는 문맥이 명확해서 오해 소지가 적음. Peek 결과가 State일 때 +그걸 즉시 읽어 스냅샷으로 쓸지, State 핸들을 그대로 물고 가 `:Compute`로 +새 파생 State를 만들지는 유저 선택 — 전자는 이후 원본이 바뀌어도 반영 +안 되는 캐비엇이 있지만, 이건 quad가 대신 풀어줄 문제가 아니라 문서화 +(경고)로 충분(이미 있는 "`Get()` 결과 캐싱 금지" 캐비엇과 같은 클래스). + +**`isState(x): boolean` 필요 — `base/bind-system-plan.md`에 정의**. +`Peek`가 raw union을 돌려주므로 사용자 코드가 State/plain을 분기하려면 +판별 수단이 필요함(Source가 State를 구조적으로 만족하므로 `isState`가 +Source도 같이 잡아줌 — **[2026-08-07 여덟 번째 세션 정정] `isSource`도 +별도로 존재함**, `:Set`/`:Emit` 같은 Source 전용 능력이 있는지 알아야 +하는 코드를 위해 필요하다고 판단 정정됨). 상세 근거/구현 방식은 +`bind-system-plan.md`의 `Brand`/`isState` 절 참고 — 요지만: duck-typing +대신 weak-key 레지스트리 기반(quad의 다른 branded 타입 전부와 공유하는 +통합 메커니즘으로 일반화됨), 그리고 이 판별 로직 자체는 새로 만드는 게 +아니라 4-1번 setter 분기가 이미 내부적으로 해야 하는 걸 public 유틸로 +승격하는 것뿐. + +### 10. `Tween`와의 타입 합성 — `T' = T | Tween` 치환만으로 해결 (2026-08-10 세션) + +`research/tween-plan.md`가 값-레벨 `Tween` 래퍼로 재설계되며, 프로퍼티류 +Modifier 필드 setter가 트윈 값도 받을 수 있어야 하는지가 자연히 따라오는 +질문이었음 — **답은 "이미 있는 `T | State` 필드 타입 모양에 새 케이스를 +추가할 필요가 없다"** — 위 4번 절이 확정한 필드 타입 모양(리터럴 `T` 또는 +`State`)에서 "이 필드의 `T`" 자체를 `T' = T | Tween`로 치환하면 +자동으로 `T | Tween | State>`가 나옴. 즉 `FrameModifier`류 +타입 생성 스크립트가 `Position` 필드를 만들 때 그냥 `T`를 `UDim2 | +Tween`로 바꿔서 기존 setter 시그니처 생성 로직에 그대로 넣으면 됨 — +Modifier의 제네릭 `__index`/`table.clone` 런타임(위 "런타임은 클래스별 +코드 없이" 절)에도 `Tween` 인지 로직을 전혀 추가할 필요 없음(setter는 +어차피 값을 그대로 baked 저장할 뿐, 그 값이 `Tween`인지는 나중에 +PropertyHandler가 판단). + +`Tween`가 Modifier 필드로 담기는 것도, `State>`처럼 State/Source +값으로 담기는 것도 둘 다 아무 문제 없음 — 7번 절의 "핸들러 계층 값 → +error" 규칙에 안 걸림(`Tween`는 `process`/`retract`를 가진 dispatch +참가자가 아니라 `None`처럼 순수 raw 데이터 값, 위 7번 절 "Slot/Tag/Attribute +등" 목록에서 Tween을 뺀 정정 참고). + +## 열린 질문 (`.claude/question.md`에도 취합) + +- **[해소됨]** Getter 정확한 이름/모양 — 2026-08-06 후속 세션에서 getter + 자체를 안 만들기로 확정(위 "4. Setter는..." 절 참고), 더 이상 열린 + 질문 아님. +- Modifier가 컴포넌트 경계를 어떻게 통과하는지는 **[정정] 이미 + `base/component-composition-plan.md`에서 해소됨**(named parameter로 + 전달, "다중 루트" 개념 자체는 폐기) — 더 이상 열린 질문 아님, 이 문서가 + 다루는 "값 자체의 동작"과는 별개 문제였다는 점만 참고로 남김. +- **[해소됨]** `Overridden` 이름 — 2026-08-08 세션에서 확정(`Add`/`Remove` + →`Added`/`Removed`, `Merge`→`Merged`와 같은 분사형 네이밍 컨벤션에 + 맞춰 불규칙동사 `override`의 정확한 과거분사를 씀, `Overrided`는 오기). + `Peek`/`isState` — 동작은 위 9번 절에서 확정, 이름도 2026-08-08 다섯 + 번째 세션(`.claude/question.md` 용어 정리 라운드)에서 더 나은 대안 없어 + 현재 이름 그대로 최종 확정됨. diff --git a/.claude/base/module-lifecycle-plan.md b/.claude/base/module-lifecycle-plan.md new file mode 100644 index 0000000..ce940ae --- /dev/null +++ b/.claude/base/module-lifecycle-plan.md @@ -0,0 +1,132 @@ +# 모듈 라이프사이클 — Handler 패턴, bind/store는 누가 구현하는가 (base로 승격됨) + +**상태**: base — "누가 store를 구현하는가"까지 포함해 전부 확정되어 +`research/`에서 승격됨(`base/architecture.md`의 "구현 착수: 소스 트리 구조 +확정" 절 참고). 원본: +`.claude/initreq/raw-userinput.md` "넘버 바인드는 누가 처리?" / "모듈은 스코핑 +되는가" / "pluggable 하다면 해당 플러그를 초기화하는 건 누구 몫?" / "다시 돌아와서… +bind는 누가 어떻게 구현" / "스토어는 누가 구현해…" 절. 확정된 상위 결정은 +`base/architecture.md` 12~14번 항목(멀티 백엔드, 싱글톤 모듈, 팩토리 초기화) +참고 — 이 문서는 그 안의 세부 미해결 사항만 다룸. + +## 넘버 바인드(숫자 프로퍼티 등)는 누가 처리하는가 + +Slot과 맞물려서 잘 생각해서 구현해야 하는 부분. **기울어진 방향**: mount가 +처리하는 게 맞아 보이지만, 그러면 확장성이 있을지가 문제. 결론: **표준 구현체는 +인터페이스만 두고, 실제 구현은 `quad-roblox` 같은 백엔드 서브패키지가 해당 +인터페이스를 구현**. 런타임에 Handler로 Roblox를 주입받는 방향(반대로 +"Handler로 base를 받는" 게 아니라) — 이유: 여긴 가상돔이 없어서, base +쪽이 "누가 실제로 그려주는지" 모르는 채로 있다가 Roblox Handler를 주입받는 +모양이 더 자연스러워 보임. (이름 자체는 이후 "Handler"로 확정 — +`base/bind-system-plan.md`의 핸들러 계약 절 참고, 이 문서는 여전히 +초안 당시 표현인 "프로바이더"로 쓰여 있던 걸 정정.) + +## pluggable 플러그 초기화는 누구 몫인가 + +RBVM처럼 `init namespace` 하나하나 부르는 방식은 별로(`base/lifecycle-pattern.md` +5번 항목에서 실제로 rbvm이 이렇게 되어 있는 걸 확인함 — `InitNamespace`/ +`Registered`-가드/`NewLib` 3종 세트를 라이브러리마다 반복). 대신 **적절한 팩토리 +함수 제공**: `InitRoblox(Module)` 식으로, 생성된 모듈을 뮤테이션할 수 있는 도구를 +주고 사용자가 호출하도록. `base/architecture.md` 14번 항목과 동일한 결정 — +여기서는 "왜"만 보강. + +## Bind는 누가, 어떻게 구현하는가 + +인터페이스 상 `bind`를 두고 이것도 pluggable하게 할지 고민 — 단 **1개만 존재할 +수 있는 형태**로 구현하는 게 맞다고 기울어짐: 이미 bind 구현체가 있는데 또 +init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류. 즉 "pluggable +슬롯이지만 유일하게 채워질 수 있는 슬롯" — 위의 `base/bind-system-plan.md`가 +말하는 "여러 핸들러가 우선순위로 경쟁"하는 것과는 다른 층위: **핸들러 +레지스트리 자체(그 배후의 실제 bind 구현/백엔드)는 유일해야 하고, 그 안에 +등록되는 개별 핸들러들은 여럿+우선순위 경쟁이 맞는 모양.** + +의존성을 부작용 식으로 주입해서 `quad-roblox` 바인드를 허용케 하는 건 괜찮아 +보임(=`InitRoblox(Module)`가 하는 일이 바로 이 "유일 슬롯 채우기"). + +## Store는 누구 몫인가 — 상당 부분 확정됨 + +**사용자 확인 완료**: base가 `LifetimeHandle` 추상화(생명주기/`Connected` +계산 속성)를 소유하는 게 맞다고 확정. 추가로 명확해진 것 — **store 바인드가 +수행하는 "처리된 값을 다시 `Dispatch.process(inst,k,realv)`로 넘기는" 재실행 +로직 자체도 base가 한 번만 구현**해야 함(모든 백엔드/핸들러가 각자 +재구현하면 안 됨). 근거: "모든 곳에서 다시 구현하는 건 나쁘니까." → +`base/bind-system-plan.md`의 "확정된 디스패치 모델"/`Dispatch` 네이밍 절이 +바로 이 base 제공 로직. + +부수적으로 확인된 것: +- **Store 자체의 연산은 더 단순해져도 됨** — v1의 `:Add`/`:With`/`:Tween` 같은 + 이름 붙은 체이닝 연산(named modifier)은 명시적으로 안 만들기로 확정, 대신 + 일반 함수를 받는 형태로 통일(`base/store-semantics.md` 참고). "너무 verbose한 + 연산들은 오히려 일관성을 해친다"는 게 이유. (주의: 아래의 v2 `:With(...)`는 + 이름만 같을 뿐 여기서 안 만들기로 한 v1의 `:With`와는 다른 연산임 — v1은 + "함수/테이블에서 값을 가져오는" 가공 연산이었고, v2는 그냥 "여러 State를 + 의존성으로 모으는" 수집 연산.) +- **여러 store 값을 묶어 유연하게 처리하는 방법**(`useEffect`류 dependency + array)은 있으면 좋겠다는 요청이었고 — **API 시그니처도 확정됨**: + `:With(...)`로 의존성을 모으고 `:Compute(fn)`으로 파생 State를 만드는 + 형태, 상세는 `base/store-semantics.md`의 "여러 스토어 값을 묶어 처리하는 + 것" 절 참고. +- `can execute store bind` 후킹 자체는 `Connected` 계산 속성으로 대체된다는 + 잠정 제안이 그대로 유지되고, 여기에 더해 **완전 소멸(Destroy) 시점엔 아무 + 처리도 필요 없다**는 원칙까지 확정됨(`base/lifecycle-pattern.md`) — 즉 이 + 질문은 "필요한가?"에서 "확정된 Connected 체크 하나로 충분하다"로 정리됨. +- 여러 `isHandlable`이 되는 플러그를 매번 우선순위 순으로 스캔하는 비용은 + 여전히 실제 구현/벤치마크 단계에서 검증 필요 — 디자인 자체는 확정됐으므로 + 더 이상 사용자 자문 대상이 아니라 구현 검증 대상. + +## 모듈 스코핑 (참고, 확정은 `base/architecture.md` 13번) + +한 Lua 스레드에서 둘 이상의 모듈 분화체(Roblox+비Roblox 동시)를 쓸 일이 +거의 없을 거라 판단, 지금은 싱글톤으로 두고 필요해지면 `New()` 추가. + +## Quad는 스크립트인가 라이브러리인가 (확정, 참고용) + +이전엔 Instance를 보조하는 역할이라 "스크립트"로 분류했지만, 지금은 확실히 +"라이브러리" — 구조화되어 있고 데이터 타입이 존재함. 기능을 각자 따로 묶는 게 +아니라 하나의 시스템으로 돌 수 있게(pluggable 하게 두자는 논리의 근거이기도 +함). `base/architecture.md` 도입부와 동일 결정. + +## 열린 질문이었던 것 — 전부 해소됨 (2026-08-08 두 번째 세션 정리) + +**이 문서 상단 "상태" 줄이 이미 "확정되어 승격됨"이라고 말하고 있었는데도 +이 절 자체는 오래 stale로 방치돼 있었음** — 아래 4개 항목 중 2/3번은 그 뒤 +`base/bind-system-plan.md`의 Handler 계약 확정으로 이미 풀렸는데 여기 +반영이 안 됨. 원문은 남기고 각각에 해소 표시만 추가: + +- **Store 책임 분리(base vs provider)는 확정됨** — 위 절 참고. ~~남은 건 + 실제 구현 단계에서 base의 `LifetimeHandle`/재실행 유틸 API를 정확히 + 어떻게 노출할지 정도~~ **[해소됨]** 노출 방식도 확정 — `bindLifetime`/ + `canExecute`는 네임스페이스 없는 탑레벨 함수(`base/lifecycle-pattern.md`), + 케이싱까지 포함해 `base/architecture.md` "코드 스타일 — 네이밍 케이싱" + 절 참고. +- ~~넘버 바인드/프로바이더 인터페이스의 정확한 함수 시그니처(base가 요구하는 + provider 인터페이스 계약)는 아직 미정~~ **[해소됨]** — 그 "provider + 인터페이스"가 곧 Handler 계약: `isHandlable(inst,key,value)`/ + `priority`/`process(inst,key,value)`/`retract(inst,key,value)` 4종, + `retract`는 no-op이라도 필드 생략 불가까지 확정. `base/bind-system-plan.md` + "핸들러 계약" 절. +- ~~**네이밍 미정(2026-08-04 보강)**: "프로바이더"라고 불러온 개념을 정확히 + 뭐라고 부를지("provider" vs "processor" vs 그냥 "plug") 아직 안 정함~~ + **[해소됨]** — **`Handler`로 확정**, 위 항목이 가리키는 계약의 정식 이름. + `Dispatch`(그 계약을 스캔/실행하는 엔진, 프리미티브 아닌 탑레벨 싱글톤)와 + 구분해서 쓸 것 — `base/bind-system-plan.md` "Dispatch는 프리미티브가 + 아니다" 절. **왜 다른 후보들을 기각했는지(2026-08-08 세션, 재확인)**: + `Processor`는 계약 메소드 자체가 `process`라 이름 안에 같은 단어가 + 겹쳐 눈에 거슬림, `Provider`는 `canProvide`처럼 "뭔가를 공급한다"는 + 늬앙스인데 Handler는 실제로 값을 공급하는 게 아니라 처리/반응하는 + 쪽이라 의미가 안 맞고 React `Context.Provider`류 맥락(context) 패턴과도 + 헷갈릴 수 있음, `Plug`는 "동적으로 꽂힌다"는 어감은 맞지만 "값을 + 처리한다"는 의미가 빠져 있음 — `Handler`가 계약 4종 + (`isHandlable`/`priority`/`process`/`retract`) 전체를 가장 정확히 + 담는다는 결론. +- base 유틸(per-instance 상태 저장소, 생명 바인드 유틸)이 인터페이스만 두고 + 실제 구현은 백엔드 팩토리(`RobloxFactory(BaseModule)`류)가 뮤테이션으로 + 주입한다는 패턴이 확정됨 — 상세는 `base/bind-system-plan.md`의 "base + 유틸은 인터페이스, 실제 구현은 백엔드 팩토리가 주입" 절 참고. **중복 호출 + 가드/`New()`와의 관계는 2026-08-04 3차 라운드에서 확정**: 같은 팩토리로 + 재호출하면 무시(no-op), 다른 팩토리로 재호출하면 에러(유일 슬롯 충돌 — + 바로 위 "Bind는 누가, 어떻게 구현하는가" 절의 원칙과 일치) — `New()`가 + 생기면 인스턴스별 테이블이 분리되므로 이 가드도 자연히 인스턴스별로 + 스코핑됨, 별도 재설계 불필요. **이 결론이 Dispatch의 handler 레지스트리에도 + 그대로 적용된다는 게 2026-08-08 두 번째 세션에서 재확인/일반화됨** — + `base/bind-system-plan.md` "Dispatch는 프리미티브가 아니다" 절. diff --git a/.claude/base/onchange-plan.md b/.claude/base/onchange-plan.md new file mode 100644 index 0000000..e673da1 --- /dev/null +++ b/.claude/base/onchange-plan.md @@ -0,0 +1,60 @@ +# `OnChange` 특수 키 — `GetPropertyChangedSignal` 바인딩 + +**상태**: base — 2026-08-10 세션에서 확정. quad-roblox 전용(값 타입/API +레이어 없음, `Attribute`와 같은 패키지 배치). + +## 문제 + +이벤트 바인딩은 이미 평범한 문자열 키 + reflection(`GetEventsOfClass`)으로 +확정돼 있음(`bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" +절) — `inst[key]`가 이미 `RBXScriptSignal`이라 그냥 `Connect`하면 됨. +`GetPropertyChangedSignal(name)`은 이 패턴이 그대로 안 통함: 프로퍼티 이름을 +인자로 받아 **별도 메소드 호출**로 시그널을 얻어야 하고, 그 프로퍼티 이름은 +이미 "값 세팅" 키 네임스페이스(`Frame.Position = x`)와 겹침 — 값 타입만으론 +"세팅"과 "변경 리스닝"을 구분할 방법이 없어서 별도 마커가 필요함. + +## 확정 + +- **`OnChange(propertyName): OnChangeKey`** — 프로퍼티 이름을 감싸는 DI 키 + 팩토리, `Attribute(name)`/`Tag(...)`와 같은 패턴. 사용 예: + `Frame { [OnChange "Position"] = function(v: UDim2) ... end }`. +- **제네릭 타입 파라미터 없음 — `OnChange<>` 같은 타입 파라미터화는 안 + 함.** 콜백 파라미터 타입은 호출부가 인라인으로 직접 명시 + (`function(v: UDim2) ... end`) — Luau가 그 타입이 실제 프로퍼티 타입과 + 일치하는지 검증해주지 않음. 이미 확정된 "이벤트 바인딩은 콜백 시그니처를 + Luau가 검증 못 하는 대가를 받아들인다"는 결정(`bind-system-plan.md` "이벤트 + 바인딩 — `On.EventName` 도트액세스 안 씀" 절, "타입 안전성을 어느 정도 + 포기하는 대가")과 같은 급의 트레이드오프라 새로 정당화할 것 없음 — 오히려 + `Attribute<>`처럼 제네릭으로 정확히 맞추려는 시도는 이벤트 키보다 더 + 엄격한 걸 요구하는 셈이라 일관성이 깨짐. +- **기각안 — 프로퍼티별 정적 `OnChange.PropertyName` 전량 코드 생성**: + `archive/onchange-per-property-codegen-rejected.md` 참고. Attribute의 + "제네릭 + 자주 쓰는 것만 정적 지름길" 절충과 겉보기엔 비슷해 보이지만 + 규모가 다른 문제라 기각. +- **패키지 경계: 전부 quad-roblox** — `Handlers/OnChange.luau`에 `OnChange(name)` + 키 팩토리와 Handler를 같이 둠(`Attribute.luau`와 같은 배치, base 쪽 값 + 타입 파일 없음). `GetPropertyChangedSignal` 자체가 Roblox 엔진 API라 base에 + 둘 이유가 없음 — Tag처럼 백엔드 무관한 값/API 레이어가 따로 있는 경우와 + 다름. +- **`process(inst,k,v)`**: `inst:GetPropertyChangedSignal(name):Connect(function() + v(inst[name]) end)`. **`retract(inst,k,v)`**: 그 Connection을 + `:Disconnect()`. 일반 `Handlers/Event.luau`와 같은 결(Connection + 관리뿐, 새 메커니즘 없음). +- **`State` 지원 — 새 메커니즘 없음.** 이미 확정된 "이벤트도 + store-bind 가능 — `false`로 disconnect" 메커니즘(`bind-system-plan.md`)이 + `OnChange` 키에도 그대로 적용됨 — `OnChangeHandler`는 `process`/`retract`만 + 구현하면 되고, `v`가 State/Source면 범용 `Dispatch/StoreBind.luau`가 알아서 + 언랩+재귀 재-dispatch해서 `process`를 다시 호출해줌. `OnChange` 전용 분기 + 불필요. + +## 다른 특수 DI 키와의 대조 + +| | 소스 | 값 타입 | 패키지 경계 | +|---|---|---|---| +| 이벤트(`MouseButton1Click = fn`) | `inst[key]`가 이미 Signal | 콜백, 타입 미검증 | quad-roblox(`Handlers/Event.luau`) | +| `Attribute(name)` | `SetAttribute`/`GetAttribute` | 값(제네릭 또는 정적 타입 패밀리로 타입 파라미터화) | quad-roblox(`Handlers/Attribute.luau`) | +| `OnChange(name)` | `GetPropertyChangedSignal(name)` | 콜백, 타입 미검증(제네릭 없음) | quad-roblox(`Handlers/OnChange.luau`) | + +`OnChange`가 Attribute처럼 제네릭화되지 않은 이유는 "콜백을 받는다"는 +성질이 Attribute(값을 직접 받음)보다 이벤트에 더 가깝기 때문 — 카테고리가 +헷갈리지 않도록 표로 명확히 구분해둠. diff --git a/.claude/base/purity-and-effects-plan.md b/.claude/base/purity-and-effects-plan.md new file mode 100644 index 0000000..0653768 --- /dev/null +++ b/.claude/base/purity-and-effects-plan.md @@ -0,0 +1,53 @@ +# 컴포넌트 순수성이 아니라 "이식성" 문제 (재정의됨) + +**상태**: base — 확정됨(2026-08-04 세션에 `research/`에서 승격). 남은 건 +가이드 문서 내 배치 위치 정도로 기술적 결정 사항은 없음. 원본: +`.claude/initreq/raw-userinput.md` "순수함수에 대한 범위를 정할 필요가 있음" / +"진짜 부작용은 외부에 만들어버린다" 절. + +## 정정: "순수함수 여부"가 아니라 "이식성(portability)" 문제였다 + +**사용자 확인 완료 — 이전 초안의 프레이밍이 부정확했음.** quad는 vdom이 +없으므로 컴포넌트(Class 함수)는 **딱 한 번만 실행**된다. 모든 부작용은 그 +한 번의 실행에서 전부 등록됨 — store에 의해 렌더 함수 안 특정 부분이 다시 +트리거될 순 있지만, 함수 자체가 반복 실행되는 구조가 아님. 이 전제 위에서 +실제로 문제였던 것은 "순수함수냐 아니냐"가 아니라 **컴포넌트가 자신이 받은 +파라미터(store) 대신 전역(global) store를 직접 참조하는 경우의 이식성**이었음. + +### 구체적 문제 상황 + +컴포넌트가 특정 store를 받아서 렌더하도록 설계되어야 하는데, 그렇게 안 하고 +전역 store를 직접 참조해버리는 경우: +- 그 컴포넌트가 **한 게임 안에서 한 번만 쓰이는 존재**(예: 특정 페이지에 해당하는 + 컴포넌트)라면 전혀 문제 없음 — 오히려 그게 자연스러울 수 있음. +- 하지만 **여기저기서 재사용하려고 만들어둔 컴포넌트**가 전역을 건드린다면 + 이식성이 망가짐 — 다른 프로젝트/다른 컨텍스트에 갖다 쓸 수 없게 됨. +- **라이브러리 내부적으로만 쓰는 공유 값**(라이브러리가 의도적으로 내부에서 + 전역 상태를 만들어 쓰는 경우)은 문제 없을 수도 있음 — 이식성 문제는 "재사용을 + 의도한 컴포넌트가 자기가 받은 입력 밖의 것에 은밀히 의존하는가"에 국한됨. + +### 결론: 입력받은 store만 처리하는 함수가 좋은 컴포넌트 + +재사용/이식을 의도하는 컴포넌트는 파라미터로 받은 store만 처리하는 게 +좋다는 게 결론 — 다만 **이건 기술적으로 막을 문제가 아니라 UB로 두고 사용자에게 +경고해야 할 문서화 문제**. 라이브러리가 "전역 참조 금지"를 런타임/타입 +시스템으로 강제하려는 시도는 좋은 접근이 아니라고 명시적으로 판단함(과도한 +엔지니어링, 정당한 유스케이스까지 막을 위험). + +## 문서화 방향 + +- `base/store-semantics.md`("Store는 부작용을 허용하는 게 기본 디자인")와 + 같은 결의 문제 — Store 자체의 부작용 허용 여부와는 별개로, **컴포넌트가 + "자기 입력 밖의 상태"에 의존하면 이식성이 깨진다**는 원칙을 문서에 별도로 + 명시. +- 가이드 문서에 "재사용 가능한 컴포넌트를 만들 땐 store를 파라미터로만 + 받고 전역을 직접 참조하지 말 것 — 페이지/앱 최상위 컴포넌트처럼 애초에 + 재사용 의도가 없다면 상관없음"이라는 원칙과, 그 이유(이식성)를 예시와 함께 + 기술. +- 린트 규칙이나 런타임 경고 같은 기술적 강제는 하지 않음(확정) — 순수 문서 + 수준의 권장. + +## 열린 질문 + +- 문서에 이 원칙을 얼마나 두드러지게(가이드 최상단 vs 각주 수준) 배치할지 — + 급하지 않음, 실제 문서 작성 단계에서 결정. diff --git a/.claude/base/relate-plan.md b/.claude/base/relate-plan.md new file mode 100644 index 0000000..3f83073 --- /dev/null +++ b/.claude/base/relate-plan.md @@ -0,0 +1,97 @@ +# Relate — inst와 임의의 값을 weak하게 엮는 범용 릴레이션 프리미티브 + +**상태**: base — 2026-08-08 세션에서 신설, 확정. `base/bind-system-plan.md`의 +"핸들러 내부 상태 저장"과 `base/lifecycle-pattern.md`의 `bindLifetime`/ +`canExecute` 양쪽이 필요로 했던 "`inst`를 weak 키로 하는 저장소"가 지금까지 +`base.perInstanceState(inst)`라는 이름만 있고 인터페이스가 미정인 placeholder로 +남아있던 것 — 이번에 독립 프리미티브로 정식 승격, `perInstanceState`라는 +이름/모양은 폐기. + +## 왜 필요한가 + +Store-bind 핸들러(Tween 등)가 "이전에 만든 것"(실행 중인 Tween, gchold +Connection, gchold 배열 등)에 `retract`/`bindLifetime` 시점에 다시 접근하려면 +그 값들을 `inst`에 매달아 저장해야 함. `inst`가 죽으면 이 저장물도 자동으로 +같이 죽어야(GC-native, `base/lifecycle-pattern.md` 원칙) 하므로 바깥 키(`inst`)는 +weak여야 함 — 그런데 그 안에 담기는 값은 경우에 따라 **강하게 붙잡아야 +하는 것**(실행 중인 Tween 인스턴스, gcconn — 안 붙잡으면 존재 이유가 없어짐)과 +**약하게만 참조해도 되는 것**(캐시성 값)이 둘 다 있음 — 이 둘을 하나의 테이블 +`__mode`로는 표현 못 함(Luau/Lua 테이블의 weak 모드는 테이블 전체 단위). + +## 왜 자동으로 강하게 들지 않는가 — 엔진이 결정할 일 + +**Relate 자신은 `inst`도 `value`도 자동으로 홀드하지 않는다** — 어느 쪽을 +얼마나 강하게 들지는 호출부(주로 `quad-roblox`)가 명시적으로 결정해야 +함(2026-08-08 세션, 사용자 확정). 자동으로 결정해버리면 weak 키가 참조하는 +값이 그 키를 다시 강하게 참조하는 사이클(예: 프로퍼티 핸들러가 만든 클로저가 +`inst`를 업밸류로 캡쳐한 채로 그 클로저 자신이 `inst`에 매달린 strong 저장소에 +들어가는 경우)이 너무 쉽게 생김 — 엔진 객체의 실제 생명주기를 아는 쪽 +(`quad-roblox`)만이 "이 값은 strong으로 둬도 안전하다"를 판단할 수 있음. +그래서 `Relate`는 판단을 안 하고 **`SetWeak`/`SetStrong`으로 호출부가 매번 +명시**하게 만드는 얇은 표면만 제공. + +## API (확정) + +```lua +Relate() -> relate -- 생성자, 싱글톤 아님 + +relate:SetStrong(inst: any, key: any, value: any) -- value를 강하게 보관 +relate:GetStrong(inst: any, key: any): any? + +relate:SetWeak(inst: any, key: any, value: any) -- value를 약하게만 참조 +relate:GetWeak(inst: any, key: any): any? +``` + +- **`inst`(첫 인자)는 항상 weak** — 이 자유도는 아예 안 열어둠. 지금까지 + 나온 어떤 유스케이스도 "`inst` 쪽을 strong으로 두고 싶다"가 없었고, 열어두면 + "`Relate`가 실수로 엔진 객체를 영구히 붙잡는" 사고 가능성만 늘어남. + `Weak`/`Strong`은 오직 **`value`의 보관 방식**을 가리킴. +- **비싱글톤 — 생성 가능한 값(`Ref`/`Store`/`Modifier`와 같은 프리미티브 + 컨벤션)**. 각 핸들러 모듈이 자기 톱레벨에 `local relate = Relate()`를 + 하나씩 두고 재사용 — 서로 다른 `Relate` 인스턴스라 `key` 네이밍이 모듈 + 간에 겹칠 걱정이 없음(모듈 하나가 감당할 key 개수는 보통 한두 개뿐이라 + `Relate()`를 여러 개 만드는 비용은 무시할 만함). + +## 실제 구조 (확정, 2026-08-08 세션) + +``` +{ [inst(weak)]: { StrongMap: {[key]: value}?, WeakMap: {[key]: value(weak)}? }? } +``` + +- **바깥 테이블 하나**: `inst`로 weak-keyed(`__mode = "k"`), 값은 `{ StrongMap?, WeakMap? }` + 형태의 서브테이블. +- **`StrongMap`/`WeakMap`은 각각 lazy 생성** — `Relate()` 호출 시점엔 아무 + 것도 미리 안 만듦. `inst`당 서브테이블도, 그 안의 `StrongMap`/`WeakMap`도 + **`SetWeak`/`SetStrong`이 처음 불릴 때 인덱싱해보고 없으면 그때 생성**. + 이유(사용자 확정, 성능 근거): Luau가 정적 분석으로 포인터 해싱을 캐싱해서 + 같은 자리에서 여러 번 인덱싱하는 건 이미 꽤 싸지지만, **테이블 생성 + 자체(array+hash part 초기화)는 상대적으로 비쌈** — 안 쓸 `inst`/모드 + 조합에 대해 테이블을 미리 만들어두는 건 순수 낭비. +- **`WeakMap`의 메타테이블은 항상 같은 객체를 재사용**(`{__mode = "v"}`류 + 하나를 모듈 로드 시 한 번만 만들어두고, 모든 `WeakMap` 생성에 그 객체를 + 그대로 `setmetatable`) — 메타테이블 내용이 매번 똑같으니 매번 새로 만들 + + 이유가 없음. `StrongMap`은 메타테이블 자체가 필요 없어 그냥 `{}`. +- `GetWeak`/`GetStrong`은 각각 대응하는 서브맵이 아직 안 만들어졌으면(=한 + 번도 `Set`된 적 없음) 그냥 `nil` 반환 — 서브맵을 만들 필요 없음(읽기가 + 쓰기를 유발하면 안 됨). + +**M2 착수 시 실측 확인**: 위 lazy 생성 전략과 `WeakMap` 공유 메타테이블 +재사용이 실제 Luau에서 기대한 만큼 이득인지, `SetStrong`/`SetWeak`을 아주 +자주 왕복 호출하는 핫패스(예: 매 프레임 store-bind 재실행)에서 서브테이블 +존재 체크 자체가 새 비용이 되지는 않는지 — base 설계에는 영향 없는 순수 +구현 최적화 문제. + +## 대체하는 것 + +- `base/bind-system-plan.md` "핸들러 내부 상태 저장" 절의 `base.perInstanceState(inst)` + placeholder — Tween 등 핸들러가 `retract` 대상을 저장하는 용도, `SetStrong`으로. +- `base/lifecycle-pattern.md`의 `bindLifetime`/`canExecute` — gcconn/gchold를 + `Relate`의 `SetStrong`으로 저장(둘 다 존재 이유가 "안 죽는 것"이므로 + strong). + +## 이름 + +`Relate` — 사용자 확정("이름은 Relate 괜찮아요? ... 좋습니다", 2026-08-08 +세션). 다른 프리미티브(`Source`/`Ref`/`Store`/`Modifier`/`Effect`/`Blocker`)와 +같은 "타입 이름이 곧 생성자" 컨벤션 그대로. diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md new file mode 100644 index 0000000..c4c5711 --- /dev/null +++ b/.claude/base/slot-plan.md @@ -0,0 +1,697 @@ +# 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()`(빈 인스턴스, 인자 없는 바닥 생성자 — 다른 +독립 프리미티브의 `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 제네릭 적용 문법 확정 시 같이 정할 것 + (이 문서 "자식으로 넘기는 클래스 스토어" 절의 기존 미결과 같은 갈래). + +## 핵심 제약: 소유권 귀속과 단일 마운트 + +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 해소) + +**[정정, 2026-08-09 열한 번째 세션] 식별 기준을 element 레퍼런스에서 +인덱스 기준으로 전환.** 원래 "인덱스는 add/remove 반복 시 곧 stale +해진다"는 이유로 레퍼런스 기준을 택했으나, 실사용에서는 반대 문제가 더 +흔함(사용자 지적) — `slot:Add(Frame{...})`처럼 호출부가 리턴값을 변수에 +안 담고 바로 흘려보내는 경우가 많아서, 나중에 그 element를 다시 골라 +Remove/Extract/Move하려 해도 참조를 안 들고 있는 경우가 잦음. `Add`만 +새로 넣는 대상이라 자연히 element를 직접 받고, 나머지 CRUD는 전부 +**인덱스 기준**으로 재확정 — 레퍼런스만 갖고 있으면 `IndexOf`로 먼저 +인덱스를 구하면 됨(아래): + +| 연산 | 시그니처 | 복잡도 | 의미 | +|---|---|---|---| +| `Add` | `Slot:Add(element, index?): number` | O(n) | 삽입(뒤 요소 밀림), `index` 생략 시 끝에 추가 — **실제로 삽입된 인덱스를 반환** | +| `Remove` | `Slot:Remove(index)` | O(n) | 제거 **+ 파괴**(retract/Destroy) — `Extract(index):Destroy()`와 동치, 흔한 경로라 별도 이름으로 유지 | +| `Extract` | `Slot:Extract(index, newElement?)` | O(n) 또는 O(1) | `newElement` 생략 — 제거만(파괴 안 함), 뒤 요소가 당겨져 빈 자리를 메움(O(n)). `newElement` 지정 — 그 자리를 즉시 교체(뒤 요소 안 건드림, O(1)), 이전 element를 반환 | +| `ExtractAll` | `Slot:ExtractAll(): {T}` | O(n) | 전체 추출(파괴 안 함) — `Clear`의 비파괴 버전, 추출된 element 배열(순서 보존)을 반환 | +| `Clear` | `Slot:Clear()` | O(n) | 전체 `Remove`(전부 파괴) — 빈 Slot에 호출해도 no-op | +| `Move` | `Slot:Move(oldIndex, newIndex)` | **O(n)** | 제자리 재배치 — 옛/새 위치 사이 요소들이 밀림/당겨짐(배열 splice와 동일 의미), **Parent 안 건드림** | +| `Swap` | `Slot:Swap(indexA, indexB)` | **O(1)** | 두 인덱스의 요소를 맞교환, 나머지 안 건드림, **Parent 안 건드림** | +| `Get` | `Slot:Get(index): T?` | O(1) | 그 인덱스의 element 조회(범위 밖이면 `nil`) | +| `IndexOf` | `Slot:IndexOf(element): number?` | O(n) | element의 현재 인덱스 역조회(멤버 아니면 `nil`) — 레퍼런스만 있고 인덱스가 없을 때 다른 CRUD와 연결하는 다리 | + +- **`Add`가 삽입된 인덱스를 반환하는 이유(2026-08-10 세션 확정)** — + `index`를 생략(끝에 추가)하면 호출부가 실제 위치를 모르는데, 그걸 + 알아내는 유일한 방법이 `IndexOf(element)`(O(n))뿐이었음 — `Add`는 + 그 값을 삽입 과정에서 이미 계산하므로 반환은 공짜. `index`를 명시적으로 + 넘긴 호출에서는 반환값이 그냥 echo라 다소 중복이지만, "항상 최종 + 인덱스를 반환"으로 시그니처를 통일해 분기 없이 단순하게 둠. `Move`/ + `Swap`이 void인 것과 모순 아님 — 그 둘은 호출부가 이미 위치를 알고 + 부르는 연산이라 새로 알려줄 정보가 없어서 void인 것이고, `Add`는 + 반대로 새 정보(계산된 위치)가 생기는 경우라 "반환값은 실제로 새로 + 알게 되는 정보만"이라는 같은 원칙의 연장. +- **`Extract(index, newElement?)`가 존재하는 이유** — 인덱스 기준 모델에서 + "요소 하나를 다른 걸로 교체"하려면 `Extract(index)`(O(n) 시프트) 후 + `Add(newElement, index)`(O(n) 시프트 재발생)를 따로 불러야 해서 이중으로 + 무거움. `newElement`를 같이 넘기면 그 자리 값을 시프트 없이 바로 + 갈아끼우기만 하면 되므로 훨씬 쌈 — 별도 `Set`이라는 이름 대신 `Extract`의 + 확장으로 둔 이유는 반환값이 "이전 element"라는 의미가 `Extract`와 + 정확히 같아서(교체도 "그 자리 걸 빼내고 새 걸 넣는" 것의 원자적 버전일 + 뿐). `newElement`에도 `Add`와 같은 검증(이미 마운트/타입 제약)이 + 똑같이 적용됨. +- **`Get`/`IndexOf` 신설, 원래 "YAGNI"로 뺐던 것을 재추가.** 처음엔 + "`:List`가 자기 key→element 맵을 따로 들고 있어 Slot 내부 상태 조회가 + 불필요"하다고 판단해 드롭했으나, 위 인덱스 기준 전환과 맞물려 다시 + 필요해짐 — element 레퍼런스만 갖고 있는 호출부가 인덱스 기반 CRUD를 + 쓰려면 `IndexOf`가 유일한 다리. `Get`은 대칭성/일반적인 컬렉션 API + 완결성을 위해 같이 열어둠(필수까진 아니지만 비용이 거의 없어 열어둠). +- **`raw*` 내부 호출 규약은 공개 API와 다를 수 있음(구현 세부, M6에서 + 확정)** — `:List`의 reconcile은 이미 자기 `key→element` 맵을 들고 + 있어서 `rawRemove`/`rawMove` 등을 element 기준으로 계속 부를 수도 + 있음. 공개 CRUD가 인덱스를 받아 내부적으로 element를 찾아 `raw*`에 + 넘기는 얇은 변환 계층이 될지, `raw*` 자체를 인덱스 기준으로 통일할지는 + base 설계가 못박을 필요 없는 구현 디테일. +- **에러 조건 — 전부 즉시 `error()`, no-op 없음**(기존 "재마운트 시 throw"와 + 같은 fail-fast 톤): + - `Add`: element가 이미 어딘가(같은 Slot이든 다른 Slot이든) 마운트돼 + 있으면 에러 — "라이브러리 차원에서 다중 마운팅 절대 금지" 원칙을 + CRUD 경로에도 동일 적용. `element`가 `nil`/`None`이거나 핸들러 계층 + 값(Ref/PreRef/Observer/Effect/Modifier)이면 에러 — 위 "요소 타입 제약" 절. + `index`가 범위 밖(1..현재 개수+1, 즉 끝에 추가하는 위치까지 포함)이면 + 에러 — **clamp 안 함**(2026-08-10 세션 확정): index가 조용히 다른 + 자리로 보정되면 "의도한 위치가 아닌데 그대로 성공한" 조용한 버그가 + 생기고, 이미 다른 CRUD 전부가 fail-fast인 것과도 불일치함. + - `Remove`/`Extract`/`Move`: `index`(들)가 범위 밖(1..현재 개수)이면 + 에러. + - `Extract(index, newElement)`: `newElement`도 `Add`와 동일한 검증 + (이미 마운트/타입 제약) 적용. + - `Swap`: `indexA`/`indexB` 중 하나라도 범위 밖이면 에러 — 단 + `Swap(i, i)`(같은 인덱스)는 위치가 안 바뀌므로 에러 없이 no-op. +- **`Move`/`Swap`은 반환값 없음(void)** — 내부 재배치만 수행, 멤버십 + weak-set을 안 건드림(요소가 Slot을 떠난 적이 없으므로) — 그래서 `Add`/ + `Remove`/`Extract`보다 저렴함. +- **공개 CRUD 중 실제로 mutate하는 것(`Add`/`Remove`/`Extract`/ + `ExtractAll`/`Clear`/`Move`/`Swap`)은 "가드 확인 + `raw*` 위임"의 얇은 + wrapper** — `self._listed`(`:List`가 설치돼 있으면 수동 CRUD 금지)만 + 확인하고 실제 로직은 `rawAdd`/`rawRemove`/`rawExtract`/`rawClear`/ + `rawMove`/`rawSwap`에 있음 — 이 `raw*` 함수들이 `:List`의 reconcile이 + 가드 없이 직접 호출하는 바로 그 함수(아래 "`Slot:List`" 절의 "구현" + 참고). 공개 메소드에 로직이 따로 있는 게 아니라 전부 이 한 세트를 + 공유. **`Get`/`IndexOf`는 순수 읽기라 이 가드 대상 아님** — `: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`라는 구체적 지점에 그대로 +적용한 것뿐 — 새 원칙 아님. + +### 구현 + +**구독 시점은 `: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<>` 형태로 구성된 스토어를 만들면 +됨(타입 표기는 러프한 스케치, 실제 문법은 tbox의 명시적 제네릭 적용 패턴 +`f<>(...)` — `.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`를 항상 +노출 — 지금 실제로 마운트된 요소 개수(사용자가 직접 CRUD로 넣든 `:List` +reconcile이 넣든 동일). 두 용도를 겸함: (1) 사용자가 "n개 검색됨" 같은 +UI에 직접 관측, (2) `Dispatch.setLength(inst, i, slot.Length)`가 형제 +순서 보장(위 "여러 Slot이 섞일 때 순서 보장" 참고)에 내부적으로 읽는 바로 +그 값 — 별도 두 State가 아니라 하나. `:List`의 filter 탈락이 실제 +`Remove`(Visible 토글 아님)로 확정돼 있어서 `Length`는 자동으로 "실제 +마운트된 것"만 반영 — 수동 Visible 토글을 쓰면 `Length`가 그걸 못 잡는 +게 맞고, 그건 사용자가 별도 State로 계산해야 하는 몫. + +**동적 자식은 반드시 `Slot` 또는 `state`류 store-bind를 통해서만 +추가/제거 — 그 외 경로는 UB(2026-08-10 세션, `base/bind-system-plan.md`의 +"Length/Offset" 절 반영).** 둘 다 `Dispatch.setLength`/`setOffsetSource`를 +정확히 호출하는 유일한 정당 경로라, 이걸 우회해서(예: 외부 코드가 Slot이 +마운트해둔 부모 Instance에 직접 `.Parent = parentInst`로 자식을 끼워 +넣는 것) 자식을 추가/제거하면 `Length`/형제 순서 계산이 그 변화를 몰라 +조용히 어긋남 — 별도 방어 로직 없음, 문서 경고로만 남김. + +## 백로그 — `Slot():Single(state, updateFn?)` (2026-08-09 여섯 번째 세션, 미착수) + +`:List`의 key-map(`mounted`/`userdata`/`keyIndex`) 없이 "0개 아니면 1개"만 +다루는 더 가벼운 편의 메소드 제안(예: `state`를 조건부로 마운트하는 +관용구를 더 명시적으로 표현) — `.Length`는 그냥 0/1이고 나머지(offset 소비, +LayoutOrder 바인딩)는 일반 Slot과 완전히 같은 프로토콜. 아직 상세 설계 +안 함, `.claude/question.md`에 백로그로만 반영. diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md new file mode 100644 index 0000000..66d3407 --- /dev/null +++ b/.claude/base/store-semantics.md @@ -0,0 +1,323 @@ +# Store 의미론 — 부작용 허용, State는 Source 위의 조합 가능한 캐시 레이어 + +**상태**: base — 전부 확정. State/Source 온톨로지는 2026-08-04 검증 +라운드에서 새로 열려 같은 세션 2~4차 라운드에 걸쳐 확정까지 마침 — 최신 +상세는 `base/bind-system-plan.md` 참고. 원본: `.claude/initreq/raw-userinput.md` +"store는 부작용을 허용함" / "state는 어떻게 구현하는가" 절. + +## Store는 부작용을 허용하는 게 기본 디자인 + +부작용 없이(파라메터 패싱만으로) 쓰는 것도 물론 가능하지만, 라이브러리 차원에서 +막지 않는다. 부작용 유무는 **사용자가 직접 문서화**하는 관례로 둔다 — 라이브러리가 +순수성을 강제하지 않음. + +다만 한 가지는 명확히 구분: **렌더 리턴 위에서 무언가를 observe하는 것은 그냥 +부작용**이다 (`useEffect`와 유사한 것으로 문서화). 이건 "허용되는 부작용"이 아니라 +"당연히 부작용"이라는 뜻 — 문서화 시 이 경계를 분명히 할 것 (`base/ +purity-and-effects-plan.md`와 연결됨). + +**보강(2026-08-04 검증 라운드): 부작용은 심각도가 다른 두 갈래로 나뉜다.** + +1. **국소적 부작용** — 입력으로 받았거나 자신이 만들어 소유한 대상에 대한 + 부작용(예: 렌더 리턴 아래에서 옵저빙해서 자기 slot을 갱신). 이건 편의성이 + 커서 적극 환영하는 영역. +2. **경계를 넘는 부작용** — globalStore처럼 컴포넌트 바깥의 전역 상태를 + 다루는 경우. 게임 UI 특성상(스킬/주변 환경에 영향받는 UI 등) 완전히 + 막을 수는 없지만, 라이브러리로 재사용하려는 컴포넌트가 이런 부작용을 + 가지면 이식성이 떨어짐(`base/purity-and-effects-plan.md`와 연결). + +**해소됨(2026-08-04 2차 라운드)**: state를 옵저빙해서 나온 결과로 slot에 +`clear`/`add` 같은 연산을 할 때, 그 시점에 대상 slot이 이미 죽어있으면 +어떻게 되는가 — 별도 메커니즘을 새로 만들 필요 없이, `base/ +lifecycle-pattern.md`의 "생명 바인드 유틸"(canExecute predicate)을 state- +invalidate 리스너 클로저 등록에도 그대로 재사용하면 됨: 발화 시 +`canExecute(inst, value)`(2026-08-08 세션 최종 시그니처 — `base/ +lifecycle-pattern.md` 참고) 하나만 확인, 거짓이면 no-op. 한때 검토했던 `isInit=false`면 +허용/`isInit=true`+생존확인 거짓이면 불허 분기 초안은 폐기 — `canExecute` +하나로 통일(사용자 확정). 상세는 `base/bind-system-plan.md`의 +"Store/State/Source 온톨로지" 절 참고. + +## 정정(2026-08-04 검증 라운드): `State` 프리미티브는 실제로 필요하다 + +**후속(2026-08-04 2차 라운드)**: 아래 온톨로지의 전파 모델(push-invalidate/ +pull-recompute)·`:Compute` 인자 규칙·State 쓰기 금지·`Source` 독립 +프리미티브화·Slot 생존 확인까지 전부 확정됨 — 최신 상세는 `base/bind-system-plan.md`의 "Store/State/Source 온톨로지 — 핵심 메커니즘 확정" +절이 최종 소스, 이 절은 배경/온톨로지 명칭 정의로만 유지. + +**이전 버전의 이 절("State 프리미티브는 만들지 않는다")은 틀렸음 — 사용자가 +검증 라운드에서 직접 정정.** 정확한 모델: + +- **Store는 "source 집합체"이자 state를 만들어주는 존재.** 실제 값이 존재하고 + 변경될 수 있는 단일 지점은 source(v1의 "값의 근원"에 해당) — store는 이런 + source들의 모음. +- **State는 source(또는 다른 state)를 받아 캐싱만 하는 존재, 자기 고유의 + 독립적 value 개념이 없다.** 단일 값에 대한 state 생성은 store가 자동으로 + 해주지만, 그 결과를 다시 분기하고 싶으면(하나의 파생 스트림에서 여러 + 소비자가 각자 다른 추가 compute를 얹고 싶은 경우) `state(state)`처럼 기존 + state의 결과를 받아 새 state를 만드는 조합이 필요. +- **[정정, 2026-08-06 후속 세션] store에서 값을 얻는 연산(`store.key`)은 + Source를 직접 반환한다 — 더 이상 별도 State 인스턴스를 감싸서 반환하지 + 않음.** 상세는 아래 "Source가 State를 만족함" 절 참고. 이 항목의 원래 + 버전("항상 새 state 인스턴스를 반환")은 틀림 — Store가 별도 wrapper + 없이 자기 안에 만들어둔 Source를 그대로 돌려주는 쪽으로 재정리됨. +- 이건 quad2-try(폐기된 이전 시도)의 `Pipe` copy-on-write 절충안을 대체하는 + 방향으로 좁혀짐 — 별도 `Pipe` 타입을 만들어 소유권/버전 가드를 넣는 대신 + State 자체가 "파이핑 결합체"이고 `state(state)`로 분기하면 될 걸로 보임 + (`Pipe` 후보는 사실상 폐기 쪽으로 기움). 상세는 `base/bind-system-plan.md`의 + "Store/State/Source 온톨로지" 절 참고 — **이 절 이후 2~4차 라운드에 걸쳐 + 전부 확정됨, 더 이상 진행 중인 스레드 아님.** + +## 일반 원칙 — 독립 존재 가능한 프리미티브 vs 원천에 종속된 파생 데이터 (2026-08-06 후속 세션) + +위 "State는 자기 고유의 독립적 value 개념이 없다"는 관찰을 일반 원칙으로 +확장(사용자 관찰): quad의 개념들은 두 부류로 갈린다. + +- **독립 존재 가능한 프리미티브** — Source, Ref, Store, Modifier. 다른 + 무언가 없이 그 자체로 `Type(args)` 팩토리 함수로 만들어짐(`Source(default)`/ + `Ref(default)`/`Store({defaults})`/`Modifier()`, 위 "생성자 + 스타일 확정" 참고 — `Modifier()`는 빈 인스턴스, 실제 필드는 + `mod:UICorner(8)`류 체이닝으로 그 위에 얹음). +- **원천에 종속된 파생 데이터** — State, Observer. 자기 혼자 존재할 수 + 없고 항상 특정 원천(Source/다른 State)에 의존 — 그래서 이 둘은 자유 + 함수 생성자가 없고, 항상 원천에 대한 메소드 호출로만 얻어진다 + (`store.key`/`state:Compute(fn)`/`state:With(...)` → State, + `state:Observer(fn)` → Observer). "클래스 같은 독립 타입"이라기보다 + "State를 관측·핸들링하는 데이터"에 가까움. + +이게 `base/bind-system-plan.md`의 `state:Observer(fn)`가 메소드고 +`Observer(state, fn)`라는 자유 함수가 없는 더 근본적인 이유 — 단순히 +"읽기 편해서"가 아니라 Observer 자체가 State처럼 원천 없인 존재할 수 +없는 카테고리라서. 앞으로 새 개념을 추가할 때도 이 두 부류 중 어디에 +속하는지가 생성자 모양(자유 함수 팩토리 vs 원천에 대한 메소드)을 +결정하는 기준으로 쓸 수 있음. + +**세 번째 카테고리 — Handler는 둘 중 어디에도 안 낌(2026-08-08 두 번째 +세션, 명시화).** `Handler`(`isHandlable`/`priority`/`process`/`retract` +4종 계약, `base/bind-system-plan.md` "핸들러 계약" 절)는 위 분류가 다루는 +"quad 사용자가 직접 다루는 리액티브 값"이 아니라 **그 자체로는 구현체가 +없는 순수 타입 계약**이라 애초에 이 분류표의 대상이 아님 — Source/Ref처럼 +`Type(args)` 자유 함수로 인스턴스를 만들 수도 없고(계약을 만족하는 값은 +`PropertyHandler`/`TagHandler`/`Dispatch/StoreBind.luau`의 `NoneHandler`처럼 +**구현하는 쪽**이 리터럴 테이블로 직접 채워 넣는 것), State/Observer처럼 +어떤 원천에 종속된 파생물도 아님(애초에 "원천"이라는 개념 자체가 안 맞음). +Handler는 quad 사용자가 아니라 **백엔드/핸들러 구현자가 채우는 확장 +지점**이라는 완전히 다른 축의 개념이라, 여기 분류를 "왜 Handler가 +빠졌는지" 궁금해할 필요 없음 — 프리미티브 분류가 불완전한 게 아니라 +Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispatch`) 자체가 +왜 프리미티브가 아니라 탑레벨 싱글톤인지는 `base/bind-system-plan.md`의 +"Dispatch는 프리미티브가 아니다" 절 참고. + +과거 "미해결로 남은 것"으로 적었던 두 항목도 모두 해소됨: `:Compute`의 +캐싱/무효화 전략은 push-invalidate(신호만)/pull-recompute(`Get()` 시점)로 +확정(`base/bind-system-plan.md` "전파 모델" 절), `store "key"` 커링의 타입 +추론 문제는 `store.key`(dot-access)를 1급 경로로 확정하며 해소(같은 문서 +"타입 추론 문제" 절, 3차 라운드). + +## Source가 State를 만족함 — 구조적 서브타입, RefSource 개념 폐기 (2026-08-06 후속 세션) + +**배경**: `store.key`가 매번 새 State를 감싸 반환하던 이전 모델의 타입 +문제(레코드 타입 `{key: State}`가 읽기/쓰기 비대칭이라 Luau +타이핑이 안 맞음, 위 "Source가 State를 만족함" 논의에서 도출)를 풀다가 +사용자가 제안한 더 근본적인 재구성. `RefSource`(store 슬롯을 가리키는 +전용 타입)를 따로 만드는 중간안도 검토했으나, 최종적으로 **Source 자체가 +State를 만족하도록 만들고, RefSource라는 별도 타입은 폐기**하는 쪽으로 +수렴. + +**확정 방향**: +- **`Source`가 구조적으로 `State`를 만족(단방향 호환)** — State + 자리엔 Source를 넣을 수 있지만 역은 안 됨(Svelte의 `Writable extends + Readable`와 같은 모양). Source는 State가 주는 모든 것(`:Get()`, + `:With(...)`, `:Compute(fn)`) 위에 `:Set(value)`/`:Emit()`을 + 추가로 가짐([정정, 2026-08-07] `.value`는 State/Source에서 제외되고 + `Get()`으로 통일됨, `.value` 표기는 Ref 전용으로 좁혀짐 — `base/ + bind-system-plan.md` "`:With`/`:Compute` — self 인자도 lazy 핸들로 + 통일" 절 참고). +- **`:With`/`:Compute`는 Source에서도 항상 `State`를 반환** — Source + 자신을 변형하는 게 아니라, "Source의 State 뷰를 뽑아 그 위에 파이핑"하는 + 것과 동치. 구현은 metatable `__index` 델리게이션(Source의 메소드 + 테이블이 State의 메소드 테이블로 폴백)으로 충분 — `Modifier`의 제네릭 + `__index` 트릭(`base/modifier-plan.md`)과 같은 패턴이라 로직 중복이 + 생기지 않음. +- **`RefSource` 같은 별도 타입은 불필요, `Store({defaults})`가 + 내부적으로 `{[key] = Source(default), ...}`나 다름없게 됨.** + `defaults`는 **선택**(안 줘도 됨, 순수 편의용) — `store.key`는 이미 + 만들어져 있는 키면 그 Source를 그대로 돌려주지만, **아직 안 만들어진 + 키면 그 자리에서 `Source(defaults의 해당 값 또는 nil)`을 만들어 저장한 + 뒤 돌려줌**([정정, 2026-08-07] eager 생성만으로 충분하다고 서술했던 + 이전 버전은 부정확 — Luau 타입은 런타임에 강제되지 않으므로 + `Store<>()`처럼 defaults 없이 만든 뒤 `.Key:Set(v)`를 부르는 + 경우, `__index`가 "없으면 그 자리에서 만들어 저장"까지 해주지 않으면 + `.Key`가 `nil`이라 크래시남 — 그래서 Store 생성 시점의 eager 생성(각 + `defaults` 키마다 미리 만들어둠, 이건 여전히 필요)과 `store.key` 접근 + 시점의 lazy 생성(아직 없는 키를 그 자리에서 만들어 저장, 이후 재접근은 + 재생성 없이 그대로 반환)이 **둘 다** 필요함). `defaults` 테이블 자체는 + 라이브 백킹 스토리지로 쓰이지 않고 "아직 안 만들어진 Source를 만들 때 + 참고하는 초기값 템플릿"으로 반복 참조될 뿐이라, Store 생성 후 원본 + `defaults` 테이블을 밖에서 바꿔도 문제없음(UB 아님 — 이 항목도 + `bind-system-plan.md`에 남아있던 "defaults 테이블 직접 mutate는 UB"라는 + 옛 서술과 충돌해 2026-08-07에 같이 정정함, 아래 참고). 별도 `__values`류 + 그림자 실값 저장소도 불필요 — Source 객체 자체가 저장소 역할을 함. 이 + 모델은 이전에 검토했던 "State를 weak table로 캐싱" 절충안보다 더 싸다 + (래퍼 생성/캐싱 단계 자체가 사라짐). v1이 모든 값을 Store 하나에 + 몰아넣던 습관은 "당시 정적 타입이 없어 단순하게 쓰는 게 편해서"였다는 + 게 사용자의 회고적 재평가 — 지금은 타입이 핵심 제약이라 그 전제 자체가 + 더 이상 안 맞고, 이번 정리로 Store는 "이름 붙은 Source 모음, 그 이상 + 아님"으로 더 단순해짐. +- **구현 스케치(2026-08-07, 성능 근거): eager 생성은 `table.clone(defaults)` + 후 그 결과를 순회하며 각 슬롯을 `Source(v)`로 교체하는 모양이어야 함** + (`local sources = table.clone(defaults); for k, v in sources do + sources[k] = Source(v) end` 류) — 빈 테이블을 새로 만들어 키를 하나씩 + 넣는 것보다, `table.clone`으로 원본의 해시/배열 슬롯 구조를 그대로 + 재사용하는 쪽이 Luau VM 입장에서 더 쌈(직접 해시 슬롯을 처음부터 + 구성하는 것보다 기존 슬롯을 복제하는 게 저렴). `Source()`(인자 없이 + 호출)는 `Source(nil)`과 동치 — `defaults`에 값이 없는 키를 `store.key` + 접근 시점에 lazy 생성할 때 이 무인자 형태를 씀. +- **이 서브타입 관계는 `quad2-try`에서 기각한 컴포넌트/클래스 OOP 상속과는 + 다른 층위.** 그때 금지한 건 사용자가 짜는 컴포넌트 계층 구조(`Class:Extend()`류 + 매직)였고, 지금은 두 프리미티브 타입 사이의 구조적 서브타이핑(런타임 + 구현 델리게이션 포함)이라 그 금지와 충돌하지 않음. +- **동적 키 폴백(`store "key"`)은 이제 `State`가 아니라 `Source`를 + 반환**하는 것으로 자연히 갱신됨(위 "타입 추론 문제" 절과 연동). + +**검증 필요(확정 아님, M0 스파이크 대상)**: `Source`의 `:Compute` +시그니처가 자기 자신(`Source`)과 `State`를 동시에 참조하는 제네릭 +메소드라, Luau 솔버가 재귀 타입 조합에서 막히지 않는지 실제로 검증 +필요(사용자 우려: "솔버가 종종 죽는다"). 구분해서 볼 것: +- **자기 자신을 가리키는 self 타이핑**(`{ Compute: (self: Source, ...) -> State }` + 같은 패턴)은 Luau에서 극히 흔하고 대체로 안전 — 모든 메소드 테이블 + 클래스가 쓰는 패턴이라 이것 자체가 위험 신호는 아님. +- **진짜 위험한 건 두 제네릭 타입 별칭이 서로를 참조하는 상호 재귀** + (`Source` 정의가 `State`를 참조하고, `State`도 거꾸로 + `Source`를 참조하는 경우) — 이게 Luau 솔버가 알려진 대로 취약한 + 패턴. **`State`가 `Source`를 전혀 참조하지 않도록 먼저 독립적으로 + 정의하고, `Source`만 `State`를 참조하는 단방향 의존으로 두면** + 이 위험한 패턴 자체를 피할 수 있어 보임 — 다만 이것도 추론이라 실제 + Luau로 확인 전엔 확정 아님. +- 사용자는 `&`(교차 타입) 조합보다 **타입을 손으로 펼쳐 쓰는(flatten) + 쪽을 선호**(엔지니어링 비용을 감수하더라도 솔버 안정성 우선) — 이건 + 런타임 구현의 델리게이션(위 항목)과는 별개 축이라 서로 충돌 안 함: + 타입은 펼쳐 쓰고 구현은 공유하는 조합이 가능함. +- `ROADMAP.md` M0의 "Store/State propagation" 스파이크 항목에 이 구체적 + 케이스(Source가 State를 만족하는 제네릭 메소드 체이닝)를 포함해서 + 검증할 것. + +**이름 주의**: `Source`/`State`라는 이름 자체가 `CLAUDE.md` "지금 할 일" +2번의 용어 정리 대상(특히 `State`)과 겹침 — 구조(서브타입 관계, RefSource +폐기)는 지금 확정해도 정확한 이름은 용어 정리 라운드까지 가칭으로 남김. + +## Store 값 설정 문법 — `myStore.key = value` 폐기, `source:Set(value)`로 전환 (2026-08-06 후속 세션, 정정) + +**이전 버전("v1 인체공학 유지, `__newindex` 기반 `myStore.key = value` +그대로")은 폐기됨.** 아래 "Source가 State를 만족함" 절의 타입 설계와 +맞물려 재검토된 결과: + +1. **타입 대칭성**: `store.key`가 이제 `Source`를 직접 반환하는 + 평범한 레코드 필드(`{key: Source}`)로 타이핑되는데, 레코드 + 필드는 읽기/쓰기 타입이 같아야 Luau 구조적 타이핑이 깨끗하게 성립함. + `store.key = value`(raw `T` 대입)를 유지하면 읽기(`Source`)/쓰기(`T`) + 타입이 갈려 mismatch가 남음 — `store.key:Set(value)`로 통일하면 필드 + 타입이 항상 `Source`로 대칭적이라 문제 자체가 안 생김(사용자 지적). +2. **의미론적 정직성**: `=` 대입 문법은 관례상 "그 자리에서 즉시 확정되는 + 부작용 없는 값 쓰기"를 암시하는데, quad의 실제 동작은 **lazy** — + `Set`은 무효화 신호만 쏘고, 실제 재계산은 나중에 누군가 관측(`Get()`)할 + 때만 일어남("Emit으로 필요한 사람 있어? 하고 물어보고, 있어야 진짜 + 계산 시작"). 이건 `=`가 암시하는 "즉시 커밋"과 정서가 안 맞고, 메소드 + 호출(`:Set()`)이 "이건 프로세스를 트리거하는 연산"이라는 걸 더 정직하게 + 신호함(사용자 확정 논거). +3. `:Set()`은 이미 확정된 "값을 바꾸는 연산엔 `:` 체이닝 허용" 원칙(`base/ + architecture.md`)에도 자연스럽게 들어맞음 — 문법 자체가 새로 생기는 게 + 아니라 기존 원칙의 정상적인 적용. + +**남는 것**: `myStore "key"`(문자열 커링)는 이미 3차 라운드에서 동적 키 +전용 미타입 폴백으로 격하돼 있었으므로 이번 정정과 무관하게 그대로 유지. +`:` 체이닝 원칙도 `:Set()` 자체가 그 사례라 유지. + +`base/architecture.md`의 "복사 구현 지양, 팩토리 함수로 대체" 원칙과 함께 +읽을 것 — v1의 문제는 metatable 체이닝으로 매번 새 테이블을 할당하며 +"불변 빌더"를 흉내낸 것이었지, `:` 체이닝 문법 자체나 커링 문법 자체가 +아니었음. + +## Source 값을 직접 mutate한 뒤 전파 — `:Emit()` (2026-08-06 후속 세션, 호출부 정정) + +**결정**: Source가 들고 있는 값을 새 값으로 교체하지 않고 제자리에서 +mutate한 뒤, `:Emit()`으로 무효화 신호만 별도로 쏘는 것을 **Source +원천(store가 직접 들고 있는 값)에 한해 허용**한다. + +**[정정, 같은 세션 후반]** 원래 `Store:Emit(key)`(Store에 key를 넘겨 +호출)로 적혀있었으나, 아래 "Source가 State를 만족함" 절에서 `store.key` +자체가 Source를 직접 반환하는 것으로 바뀌면서 `Emit`도 Source의 평범한 +메소드로 이동 — `store.key:Emit()`(key 인자 불필요, 이미 손에 든 Source +핸들에 바로 호출). `Store:Emit(key)`라는 별도 경로는 유지할 이유가 +없어져 폐기(같은 걸 하는 두 번째 경로를 남기지 않는다는 이번 세션 전반의 +원칙과 일치 — `store.key = value` → `store.key:Set(value)` 정리와 같은 결). + +**존재 이유(우선순위순)**: +1. **clone이 아예 불가능한 값이 있음.** userdata나 외부 라이브러리 + 객체(엔진 Instance 등)는 `table.clone`으로 새 값을 만들 수 없음 — + 이런 값은 "새 값을 만들어 Set"이라는 대안 자체가 없으므로, in-place + mutation + `Emit`이 변경을 전파하는 유일한 수단. +2. Lua의 불변 업데이트가 verbose함(JS의 `{...t, x=1}` 같은 문법이 없어 + `table.clone` 후 필드 덮어쓰기 + 재대입 필요) — 이걸 줄여주는 부차적 + 이득도 있지만, 이게 주된 이유는 아님(1번이 진짜 이유). + +**왜 새 구멍이 아닌가**: `Get()`은 원래도 라이브 테이블 레퍼런스를 +돌려주므로, 그 레퍼런스를 mutate하는 것 자체는 `Emit` 유무와 무관하게 +Lua에서 항상 가능한 일. `Emit`이 없으면 그 mutation은 "조용히 반영 안 +되는"(dependent가 재계산 안 됨, UI가 stale한 채 멈춤) 상태로 남을 뿐이라 +오히려 `Emit` 없는 쪽이 더 나쁜 버그 클래스 — `Emit`은 이미 가능한 +mutation에 정식 신호를 붙여주는 것뿐. + +**남는 캐비엇(문서에 반드시 명시)**: `Get()`으로 이전에 그 테이블을 +읽어서 어딘가(로컬 변수, 다른 코드가 들고 있는 참조)에 캐시해둔 게 +있다면, mutation 순간 그것도 같이 바뀐다 — 새 테이블이 아니라 같은 +레퍼런스라서. **`Get()` 결과를 나중 비교(`==`)나 diff 캐시 용도로 들고 +있으면 안 됨 — 항상 다시 `Get()`할 것.** + +**하드 경계 — Source 원천에만 허용, 중간/파생 State에는 없음.** `:With`/ +`:Compute`로 만들어진 파생 State에는 `Emit`이라는 개념 자체가 없다 — +허용하면 "이 State의 현재 값이 뭘 근거로 계산됐는가"를 아무도 설명할 수 +없게 되어(quad-debug가 추적하려는 "무엇이 무엇을 계산했는가" 그래프가 +깨짐) 디버깅이 사실상 불가능해짐. State의 값은 항상 "선언된 Compute +함수를 실제로 실행한 결과"여야 한다는 불변식이 깨지면 안 됨. 무거운 +파생 객체를 재사용하고 싶은 경우(Compute의 결과 자체가 무거운 userdata인 +경우)를 위한 별도 메커니즘은 `base/bind-system-plan.md`의 "`:Compute(fn)`의 +선택적 두 번째 인자 — `previous`" 절 참고 — 이건 `Emit`과 다른 메커니즘. + +**따름정리 — `Store`의 `T`는 Modifier가 될 수 없음.** Modifier는 +정적 flatten으로 dispatch와 완전히 별개인 단계에서 처리되고 +(`base/modifier-plan.md`) — Store/State/dispatch 경로엔 애초에 +Modifier용 processor가 없음. **[정정, 2026-08-09 세션]** `State` +조합은 "UB, 가능하면 타입 차단"이 아니라 **명시적 `error`로 확정** +(`modifier-plan.md` 7번) — `isModifier` predicate를 `Source:Set()`/ +Store 생성 시 eager `Source(default)`/State의 `:Compute` 결과 캐싱 +지점에서 확인해 런타임에 직접 막음, 타입 차단은 되면 좋은 보너스일 +뿐 유일한 방어선이 아님. **[2026-08-06 후속 세션 추가]** Source가 +State를 구조적으로 만족하게 되면서 이 제약은 `Source`(Store를 +거치지 않는 독립 `Source(someModifier)`)에도 동일하게 적용됨을 명시 — +Source가 State 계약을 만족하는 이상 같은 이유(Modifier용 processor +부재)가 그대로 적용되고, 별도로 다시 논증할 필요 없음. 위 "하드 경계"와 +같은 이유로, `Emit`이 Modifier의 정적 flatten과 충돌할 걱정 자체가 +성립하지 않음(둘이 만날 지점이 없음). + +## 여러 스토어 값을 묶어 처리하는 것 (dependency array) — 확정 + +`useEffect`처럼 여러 store 값을 디펜던시로 묶어 파생값을 계산하고 싶다는 +요구가 있었음(v1의 `myStore "a,b"` 콤마-조인 문자열 방식은 폐기 대상 — +`reference/quad-v1-architecture.md`의 "문자열 DSL" 문제점 참고). **v1의 +`:Add`/`:With`/`:Tween`처럼 값을 직접 가공하는 이름 붙은(named) 체이닝 연산은 +만들지 않음** — 대신 일반 함수를 받아 처리. (주의: 아래의 v2 `:With(...)`는 +이름만 같을 뿐 v1의 `:With`와는 다른 연산임 — v1은 "함수/테이블에서 값을 +가져오는" 가공 연산이었고, v2는 그냥 "여러 State를 의존성으로 모으는" 수집 +연산.) 최종 형태는 `:With(...)`로 의존성을 모으고 +`:Compute(fn)`으로 파생 State를 만드는 것으로 확정 — `Store.Combine({a,b}, +fn)`류 포지셔널 인자 방식은 기각됨. 정확한 lazy 인자 규칙(self/with 값 둘 다 +State 핸들로 넘기고 `:Get()`을 실제로 읽을 때만 계산)은 `base/bind-system-plan.md`의 "Store/State/Source 온톨로지" 절 참고. + +**여러 소스를 한 번에 바꿔도 파생값 재계산/재대입이 한 번만 되게 하려면 +`Blocker` 참고.** 위 `:With`+`:Compute`만으로는 "state1, state2를 연달아 +Set하면 결합된 파생값이 두 번 재계산/재대입된다"는 문제(즉시 pull하는 +store-bind 소비자 기준)는 안 풀림 — 이건 별도 확정 프리미티브 +`base/blocker-plan.md`가 다룸(State 개발과 같은 +마일스톤, `ROADMAP.md` M3에서 함께 구현). lexical `Batch(fn)`으로 풀려던 +초기 시도는 코루틴 yield 위에서 구조적으로 위험해 기각됨 — +`archive/batch-rejected.md` 참고. diff --git a/.claude/base/tag-plan.md b/.claude/base/tag-plan.md new file mode 100644 index 0000000..5694f63 --- /dev/null +++ b/.claude/base/tag-plan.md @@ -0,0 +1,117 @@ +# Tag — array-part 값 객체, `CollectionService` 얇은 래퍼 + +**상태**: base — 2026-08-08 세 번째 세션에서 값 모양을 전면 재설계(구 +모델은 `archive/tag-hash-key-model-reversed.md`에 원문·역전 이유 보존). +새 결정만 반영, 열린 질문 없음. + +## 왜 재설계됐나 + +구 모델(`[Tag "Name"] = boolean`, 태그 하나당 해시 파트 키 하나)은 상호 +배타적인 스타일 상태(`btn1`/`btn2`/`btn3`류, 실사용에서 20개까지도 가능)를 +표현하려면 태그 이름 개수만큼 키를 각각 갱신해야 해서 끔찍함, 스타일 +조합(여러 태그를 합쳐 쓰는 것)도 구조적으로 안 됨 — 상세 경위는 +`archive/tag-hash-key-model-reversed.md`. + +## 값 모양 — `Modifier`와 같은 immutable clone 체이닝 + +``` +Tag(name1, name2, ...) -- 생성자, 가변인자. Tag() 빈 값도 유효 +tag:Added(name): Tag -- clone 후 이름 추가, 원본 안 건드림 +tag:Removed(name): Tag -- clone 후 이름 제거 +tag:Contains(name): boolean -- 멤버십 확인 +tag:Apply(factory): U -- factory(self) 체이닝 설탕(Modifier와 동일 패턴) +Tag.Merged(tag1, tag2, ...): Tag -- 여러 Tag의 합집합(무손실). Modifier의 + Overridden(필드 단위 덮어쓰기, 손실 있음)와 + 다른 연산이라 이름도 다름 — Overridden은 + "이미 계산된 걸 합침", Merged는 "집합을 + 합침" +``` + +`Added`/`Removed`가 `-ed` 어미인 이유는 **`Add`/`Remove`로 쓰면 뮤테이션 +API처럼 보이기 때문** — 실제로는 항상 `table.clone` 후 반환(Modifier +3번 절과 동일한 immutable 확정 이유: 형제 서브트리 오염 방지). `Tag(a,b)` +자체가 `Tag():Added(a):Added(b)`의 sugar라고 생각하면 됨 — 별도 런타임 +경로 아님. + +**children 배열 슬롯(array-part)에 직접 놓임** — `Frame { Tag("selected") }`. +정적으로 여러 개 놓아도(`Frame { Tag("a"), Tag("b") }`) 각자 독립적으로 +자기 태그만 추가하면 되므로 `Merged` 없이도 됨(`Merged`/`Added`/`Removed`는 +"하나의 Tag 값을 프로그래밍적으로 조립"하는 용도). + +**동적 토글은 `Source`/`State`로, `None` 불필요** — 상호배타 상태 전환은 +`store.activeTag:Compute(function(name) return name == "btn1" and +Tag("selected") or nil end)`처럼 그냥 `nil`을 리턴하면 됨. `None` 센티널은 +"정적 테이블 리터럴에서 `키 = nil`이 키 없음과 구별 안 되는" 문제의 +해법이지(`bind-system-plan.md` "`None` 센티널" 절), 이건 함수 리턴값이 +동적으로 흘러가는 경우라 그 문제 자체가 없음 — `nil`을 인자로 넘기는 건 +아무 문제 없음. (단, `Frame { cond and Tag("a") or nil, sibling }`처럼 +**정적 리터럴**에서 조건부로 Tag를 넣거나 빼고 싶은 경우엔 다른 array-part +값들과 마찬가지로 `cond and Tag("a") or None` 관용구가 여전히 유효 — +이건 nil-hole 문제라 Tag만의 특수 규칙이 아니라 `props.Modifier`/ +`props.Ref`와 같은 일반 array-part 관용구.) + +## 메커니즘 — `TagHandler`, retract가 이제 의미 있어짐 + +구 모델과 달리 **핸들러 타입이 사이클마다 바뀔 수 있음**(`Tag(...)` ↔ +`nil`, 값이 `Tag`가 아니게 되면 `TagHandler.isHandlable`이 더 이상 안 +맞음) — 그래서 `retract`가 실제로 필요해짐(`bind-system-plan.md` "확정된 +디스패치 모델" 절의 일반 원칙 그대로). + +```lua +local relate = Relate() -- TagHandler 전용, 이전에 반영한 Tag 값 저장 + +TagHandler.priority = <일반> +TagHandler.isHandlable(inst, k, v) = isTag(v) -- Brand 기반, array-part 전용 + +function TagHandler.process(inst, k, v) + local old = relate:GetStrong(inst, k) + -- diff: old에 있고 v에 없는 이름만 RemoveTag, v에 있고 old에 없는 이름만 AddTag + -- (모두 지웠다 다시 붙이지 않음 — 랙/스타일 깜빡임 방지가 이 diff의 존재 이유) + relate:SetStrong(inst, k, v) +end + +function TagHandler.retract(inst, k, v) + assert(v == nil, "TagHandler.retract는 v가 nil일 때만 불려야 함") + local old = relate:GetStrong(inst, k) + if old then for name in old:Names() do CollectionService:RemoveTag(inst, name) end end + relate:SetStrong(inst, k, nil) +end +``` + +- **`Tag(A) → Tag(B)`(같은 핸들러, 타입 안 바뀜)**: `retract`는 아예 안 + 불림 — `Dispatch`의 "핸들러가 안 바뀌면 retract 없이 process만 다시" + 원칙 그대로(`bind-system-plan.md` "Dispatch 체인" 절). **diff는 여기, + `process` 안에서만** 일어남 — 전체 삭제 후 재생성하면 스타일이 순간 + 전부 사라졌다 다시 붙어 랙/깜빡임을 유발하므로(사용자 지적), 반드시 + 이전 값과 diff. +- **`Tag(A) → nil`(핸들러가 TagHandler → 없음으로 바뀜)**: `retract`가 + 불림. **[명시화, 2026-08-09 열한 번째 세션] 전체 삭제는 정확히 + `v == nil`일 때만 맞는 동작 — "v를 안 봐도 된다"가 아니라 "v가 항상 + nil로 들어온다는 걸 알고 있으니 별도 분기가 필요 없다"가 정확한 + 표현.** Tag 값을 담는 키에서 TagHandler가 더 이상 매치 안 되는 유일한 + 경로가 값이 `nil`이 되는 것(`None → nil` 재디스패치 포함)이라 이 + 전제가 깨지지 않는 한 위 구현처럼 `v`를 실제로 분기 안 해도 항상 + 옳음 — 위 pseudocode에 `assert(v == nil, ...)`을 추가해 이 전제를 + 코드에도 드러냄. `Handler.retract`가 여전히 `(inst,k,v)` 3-인자를 + 받는 건 계약 일관성 때문이지(다른 핸들러는 `v`를 실제로 씀) Tag가 + 그걸 필수로 요구해서가 아님. +- **`retract`가 자기 위임 대상까지 수동으로 안 쫓아가도 됨** — + `Dispatch.retractUnder`가 체인 전체를 알아서 훑어주므로 TagHandler는 + 자기 자원(위 `relate` 저장분)만 정리하면 됨. 상세 메커니즘은 + `bind-system-plan.md` "Dispatch 체인" 절. + +## 패키지 배치 — base는 값+API, roblox는 process/retract 글루 + +**Tag의 "값 타입과 clone 체이닝 API"(`Tag(...)`/`:Added`/`:Removed`/ +`:Contains`/`:Apply`/`Merged`)는 quad-base 소속** — `Modifier`와 정확히 +같은 층위(엔진 무관, 순수 데이터+연산). `CollectionService` 실제 호출 +(`TagHandler.process`/`retract`)만 quad-roblox 소속 — 이미 확정된 "base는 +인터페이스/값, backend는 process·retract 글루" 패턴(`LifetimeHandle`, +`Dispatch.addHandler` 자체가 이 패턴)을 값 타입 수준까지 그대로 확장한 +것뿐, 새 아키텍처 개념 아님. + +## 열린 질문 + +없음 — 값 모양/메커니즘/retract/패키지 배치 전부 확정. 이름 자체 +(`Tag`/`Added`/`Removed`/`Merged`)는 다른 가칭들과 같이 용어 정리 대상 +(`.claude/question.md`). diff --git a/.claude/base/ui-shorthand-plan.md b/.claude/base/ui-shorthand-plan.md new file mode 100644 index 0000000..511b121 --- /dev/null +++ b/.claude/base/ui-shorthand-plan.md @@ -0,0 +1,139 @@ +# UI 편의 숏핸드 (UICorner/UIPadding/UIScale) — 인라인 적용 + +**상태**: base — 기능 필요 여부·이름·메커니즘·패키지 배치·store-bind 가능성까지 +전부 확정(2026-08-07 문서 정리에서 `research/`→`base/` 승격). 남은 건 구현 +단계의 세부 시그니처뿐. + +## 배경 + +사용자 기억: v1을 쓸 때 "UICorner/UIPadding/UIScale 같은 걸 직접 +`Instance.new`로 만들어 Parent하는 귀찮은 작업 없이, Frame 안에 인라인으로 +넣기만 해도 CSS 스타일처럼 적용됐다 — 코드가 줄고 읽기도 편해서 꽤 +괜찮았다"는 것. v1 소스(`.claude/initreq/quad`)와 PA님 코드 +(`.claude/initreq/artworks`)를 서브에이전트로 조사해 확인. + +## v1 실제 메커니즘 (조사 완료) + +`class.lua`의 `SetProperty`/`GetProperty`(38~109행)와 `ProcessQuadProperty` +(134~213행)에 하드코딩된 if/elseif 분기로 특수 문자열 키 5종을 지원했음 — +`Corner = 8` → 숫자 하나, 기존 `UICorner` 자식이 있으면 재사용, 없으면 +`Instance.new("UICorner", item)`으로 생성(`Name = "_quad_round"`), +`CornerRadius = UDim.new(0, value)` 설정. `PaddingAll`/`PaddingAllOffset`, +`Scale`도 동일 패턴(`UIPadding`/`UIScale`, `_quad_padding`/`_quad_scale`). +값 모양은 항상 **리터럴 하나**(숫자/UDim) — 테이블도 `__type` 태그도 아님. +v1엔 이 5종과 별개로 `RoundSize`(이미지 9-slice 라운드 트릭, UICorner와는 +전혀 다른 메커니즘)도 있었으나 **이건 드롭 확정** — 자세한 사유는 +`archive/ui-shorthand-roundsize-dropped.md` 참고, 이 문서에서는 반복하지 +않음. + +**`UIListLayout`/`UIGridLayout`/flex 전용 숏핸드는 v1에 없었음** — +`ProcessQuadProperty`의 범용 자식 나열 분기(배열 인덱스로 놓인 Instance/ +Class 결과를 자동 mount)로 `UIListLayout{...}`을 그냥 직접 나열했을 뿐, +`List = true` 같은 전용 축약 문법은 레포 전체(PA님 코드 포함)에서 찾지 +못했음. quad-v2도 이 부분은 이미 있는 children-array + 인스턴스 생성 +문법으로 그대로 커버됨 — 새로 설계할 것 없음. + +## 결론 — 이름은 UICorner/UIPadding/UIScale로 확정 (프리픽스 필요) + +**기능은 여전히 필요**: `UICorner`가 Roblox 네이티브 Instance가 됐어도 +"별도 Instance를 만들어 부모에 Parent해야 한다"는 구조적 번거로움 자체는 +없어지지 않으므로, 이 숏핸드의 존재 이유는 그대로 유효 — **사용자 +재확정**("UIScale 같은 건 여전히 별도의 Instance고 부모 Frame에 영향을 +주는 구조, 숏핸드는 여전히 필요하다"). + +**이름은 v1의 `Corner`/`PaddingAll`/`Scale`을 그대로 안 가져오고 실제 +Roblox Instance 이름과 맞춘 `UICorner`/`UIPadding`(+`UIPaddingOffset`)/ +`UIScale`로 확정** — v1식 짧은 이름을 그대로 쓰면 Modifier 체이닝 +메소드(`mod:Corner(8)`)가 "진짜 UICorner를 만드는 숏핸드"인지 그냥 우연히 +비슷한 이름의 부가 Modifier 필드인지 구분이 안 됨(사용자 지적). 접두어 +`UI`를 붙이면 실제 대응하는 Roblox Instance 클래스 이름과 1:1로 읽혀서 +이 모호함 자체가 사라짐 — `Frame { UICorner = 8 }`, `mod:UICorner(8)`. + +## 메커니즘 — 새 아키텍처 개념 불필요 + +이미 있는 pluggable Handler로 그대로 커버됨. `UICorner`/`UIPadding`/ +`UIScale` 같은 특수 키를 인식하는 Handler(`isHandlable`이 그 키를 매칭)가 +"이름 붙은 자식을 찾거나 만들고 프로퍼티 세팅"을 `process(inst, k, v)`에 +구현 — v1의 하드코딩 if/elseif 대신 정식 핸들러 계약(`isHandlable`/ +`priority`/`process`/`retract`)을 따르는 것만 다름. `modifier-plan.md`가 +이미 예시로 든 `mod:UICorner(8)`은 이 특수 키를 flatten해서 props에 +꽂아넣는 사탕 문법일 뿐, 실제 처리는 이 Handler가 함 — Modifier를 안 거치고 +`Frame { UICorner = 8 }`처럼 순수 인라인 키로 직접 써도(v1처럼) 동일하게 +작동함, `architecture.md`의 `[Attribute "Name"]`류 특수 키와 같은 층위. +자동 생성된 자식은 기존 관례대로 `_`/`QUAD_` 접두어 네이밍 +(`research/debug-tooling-plan.md` 9번, v1의 `_quad_round`류 그대로 재사용). + +**[보강, 2026-08-09 열한 번째 세션] `mod:UICorner(8)`류 체이닝이 실제로 +타입체크되려면, 생성되는 `FrameModifier`류 정적 타입의 메소드 목록에 +`UICorner`/`UIPadding`/`UIScale`이 (진짜 프로퍼티들과 나란히) 포함돼 +있어야 함 — 순수 런타임 관점(제네릭 `__index`가 처리)에선 문제없지만, +타입 레벨에선 별도로 챙겨야 하는 항목.** quad-roblox의 각종 타입(DI +인스턴스 타입, Modifier 타입 등)이 Roblox API 덤프를 읽어 Luau 타입 +파일을 구워내는 스크립트로 생성될 예정이라(구현 단계 결정 사항) — 이 +스크립트가 실제 Roblox 프로퍼티뿐 아니라 이 3개 숏핸드 키도 각 +Modifier 타입의 메소드 목록에 끼워 넣도록 챙기면 됨, 새로 설계할 +게 없는 구현 체크리스트 항목. + +**기존 자식과의 매칭 기준(2026-08-06 감사에서 지적된 항목, 여기서 같이 +확정)**: 재사용 대상은 quad가 이전에 만든 고정 이름(`_quad_corner`류) +자식으로 한정 — 타입만 보고(`UICorner`이기만 하면 아무거나) 재사용하지 +않음. 사용자가 직접 만든 `UICorner`를 quad가 멋대로 건드리는 부작용을 +피하기 위함. + +### `v`가 `nil`인 경우 — `process`가 직접 자식 제거, `retract`는 관여 안 함 (2026-08-07 여덟 번째 세션) + +`modifier-plan.md`의 `None` 센티널(`base/bind-system-plan.md`의 +`NoneHandler` 재귀 재디스패치 절 참고)이 최종적으로 이 Handler의 +`process(inst, k, nil)`을 호출하는 구체 사례 — 이 Handler에서 "`v`가 +`nil`"은 만들어둔 `_quad_corner`류 자식이 있으면 그냥 지우는 것으로 확정. +일반 프로퍼티 핸들러와 달리 이 숏핸드는 실제 Instance를 만들어 붙이는 +쪽이라 "`nil` = 셋 안 함"이 곧 "만들어둔 게 있으면 치운다"는 뜻이 됨. + +- **이건 `retract`가 아니라 `process` 자신의 로직** — `retract`는 "이 + 키를 다른 핸들러가 넘겨받는" 시나리오 전용(`bind-system-plan.md` "확정된 + 디스패치 모델" 절)이지, 같은 핸들러가 값이 바뀌어서 자기 산출물을 + 정리하는 것과는 다른 문제. 값이 나중에 다시 숫자로(`2`→`nil`→`3`처럼) + 바뀌면 `process`가 다시 자식을 만들면 그만이라 `retract` 쪽에 별도로 + 구현할 게 없음. +- **캐비엇**: 이 왔다갔다가 잦으면(예: 반응형 State가 `nil`과 숫자 사이를 + 자주 토글) 매번 Instance 생성/제거 비용이 그대로 듦 — Tween처럼 무거운 + API는 아니지만 공짜도 아니므로, 잦은 토글이 예상되는 값을 이 숏핸드에 + 직접 물리는 건 문서화 시점에 캐비엇으로 명시할 것(지금은 메모만). + +## store-bind — 이 숏핸드도 지원, Tween만큼 무겁게 안 가도 됨 + +v1에서도 `Corner`/`PaddingAll`/`Scale`은 store 값으로 바인드 가능했음 +(`myStore "key"` 체이닝으로 다른 프로퍼티와 동일하게 취급됨) — quad-v2도 +이 능력을 유지한다. 트윈처럼 애니메이션까지 지원할 필요는 없음(API 표면만 +복잡해짐) — 그냥 값이 바뀌면 `CornerRadius`/`Padding`/`Scale` 프로퍼티를 +다시 세팅하는 정도로 충분. 구현 비용도 낮음: 각 Handler가 `process`에서 +"이전에 자기가 찾거나 만든 자식 Instance"를 얻어야 하는데, 이건 이미 +base가 범용 유틸로 제공하기로 확정한 per-instance weak-keyed 저장소 +(`Relate:SetStrong(inst,k,...)`, `base/relate-plan.md`/`base/bind-system-plan.md` +"핸들러 내부 상태 저장" 절)를 그대로 재사용하면 됨 — Tween 핸들러가 실행 중인 Tween +객체를 기억해두는 것과 정확히 같은 패턴. 새 메커니즘 발명 불필요, 이미 +있는 "store 바인드는 pluggable 바인드를 재실행하는 래핑" 원칙 +(`base/bind-system-plan.md` "확정된 디스패치 모델" 절)이 그대로 적용됨. + +## 패키지 배치 — `quad-roblox` 코어에 직접 포함, 확정 + +"트윈도 인스턴스 생성/제어를 직접 구현 가능한 걸 하나로 묶어 쉽게 쓰게 +합친 것 — 너무 잘게 쪼개 오버엔지니어링하기보다 확실히 하나로 코어에 +넣어도 충분하다, opt-out할 이유가 별로 없다"는 게 사용자 판단 — **작고 +항상 켜져 있어도 비용이 무시할 만한 편의 기능은 별도 opt-out 패키지로 +쪼개지 말고 `quad-roblox` 코어에 직접 포함한다**는 원칙으로 확정(이미 +계획된 Tween 핸들러가 같은 모양이라는 게 근거). 이 원칙은 일반화해서 +재사용 가능 — 앞으로 비슷한 "작은 인스턴스 편의 기능"이 제안되면 +`quad-roblox-util` 같은 걸 새로 만들지 않고 이 선례를 따르면 됨. + +**중요도**: 낮음("이건 나중에도 쉽게 구현됨" — 사용자) — 지금 M0 우선순위를 +바꿀 이유는 없음, M10(Handlers/Attribute 등) 전후로 다른 세부 Handler와 +함께 구현하면 충분. + +## 남은 열린 질문 (단순화 후보, 사소함) + +- UICorner/UIPadding/UIScale 3개 거의 동일한 형태의 Handler를 각각 만들지, + `{key -> {ChildClassName, ChildDefaultName, Property, wrap=fn}}` 룩업 + 테이블로 구동되는 단일 `Handlers/InstanceShorthand.luau`로 통합할지 — + `research/pre-implementation-audit.md` 3-2번 참고, 강제 사항 아님, + 구현 시점에 결정할 정도의 사소한 개선 후보. diff --git a/.claude/luau-test/01-two-pass-array-hash-order.luau b/.claude/luau-test/01-two-pass-array-hash-order.luau new file mode 100644 index 0000000..961c42e --- /dev/null +++ b/.claude/luau-test/01-two-pass-array-hash-order.luau @@ -0,0 +1,60 @@ +--[[ + 검증 대상: base 디스패치 드라이버가 명시적으로 강제하는 + "배열 파트(children/Ref) 먼저, 해시 파트(프로퍼티/이벤트) 나중" 두 패스 순회 계약. + + 배경: .claude/base/bind-system-plan.md "props 순회 순서" 절, ROADMAP.md M0 4번째 항목. + 사용자가 이미 Luau REPL로 `for i,v in {a=1, 2, b=3} do ... end`가 + `1, 2` 다음 `a, 1` `b, 3` 순서로 나오는 걸 확인했었지만(우연한 관찰), + base는 이 우연한 동작에 기대지 않고 배열 파트(1..#t)를 먼저, 그 다음 + 별도로 해시 파트만 골라내는 두 패스를 "명시적으로" 강제하기로 확정함 + — 이 스크립트는 그 강제 버전이 실제로 계약대로 동작하는지 확인. + + 실행: `luau 01-two-pass-array-hash-order.luau` (Roblox 필요 없음, 순수 CLI) + 기대 결과: "array pass"가 항상 "hash pass"보다 먼저 전부 출력되고, + array pass 안에서는 index 순서(1,2,3...)가 정확히 지켜져야 함. +]] + +local function isArrayKey(k) + return type(k) == "number" and k == math.floor(k) and k >= 1 +end + +-- Dispatch.drive(inst, flattened)의 최소 스파이크 버전 +local function drive(inst, flattened) + -- pass 1: 배열 파트, index 순서 보장 + local n = #flattened + for i = 1, n do + local v = flattened[i] + print(string.format("[array pass] inst=%s i=%d v=%s", tostring(inst), i, tostring(v))) + end + + -- pass 2: 해시 파트, 배열 인덱스(1..#t)는 건너뜀 + -- 주의: pairs()/제네릭 for는 배열 파트도 다시 순회하므로 반드시 걸러내야 함 + for k, v in flattened do + if not (isArrayKey(k) and k <= n) then + print(string.format("[hash pass] inst=%s k=%s v=%s", tostring(inst), tostring(k), tostring(v))) + end + end +end + +local children = { "Ref1", "Child2", "Child3" } +local props = { + children[1], + children[2], + children[3], + Name = "TestFrame", + BackgroundTransparency = 0, + Event_Activated = "handler", +} + +print("=== two-pass order 검증 ===") +drive("FakeInstance", props) + +--[[ + 추가로 확인할 것 (실행 후 눈으로 확인): + 1. array pass 3개가 hash pass보다 먼저, 그리고 i=1,2,3 순서로 나오는가? + 2. hash pass에 array 항목(children)이 중복으로 안 섞여 나오는가? + 3. 테이블 리터럴에서 해시 키를 적는 소스 텍스트 순서를 바꿔도(Name/ + BackgroundTransparency/Event_Activated 순서를 바꿔서 재실행) + array pass 결과가 그대로인지 확인해볼 것 — 순서가 소스 텍스트가 + 아니라 오직 "배열/해시 파트 분리"에만 의존한다는 걸 재확인하는 목적. +]] diff --git a/.claude/luau-test/02-none-sentinel-vs-nil-holes.luau b/.claude/luau-test/02-none-sentinel-vs-nil-holes.luau new file mode 100644 index 0000000..a341f98 --- /dev/null +++ b/.claude/luau-test/02-none-sentinel-vs-nil-holes.luau @@ -0,0 +1,172 @@ +--[[ + 검증 대상: 배열 슬롯을 "소진"시킬 때 nil로 지울지 None 센티널로 지울지는 + 그 배열의 성격(순서가 중요한가, 슬롯 재사용이 필요한가)에 따라 갈린다는, + 2026-08-09 열한 번째 세션에 재정정된 최종 결론. + + **[중요, 2026-08-09 커밋 f198fd9 반영]** 이 파일의 이전 버전은 + "Ref 콜백/대기자 배열도 None으로 소진해야 한다"고 잘못 적어뒀었음 — + 사용자가 직접 찾아낸 버그: None으로 소진하면 그 슬롯이 영원히 + non-nil로 남아있어서, `:Wait()`/`:Callback()`가 반복 호출될 때마다 + 배열이 끝없이 길어지는(예전 소진 슬롯을 재사용 못 하는) 진짜 버그였음. + .claude/base/bind-system-plan.md "왜 None이 아니라 nil인가" 절(2026-08-09 + 열한 번째 세션, 최종 정정)이 최신 소스 — 결론은 두 패턴이 서로 다른 + 문제를 풀고 있었다는 것: + + - **순서가 중요한 배열(PreRef pre-pass 소진 슬롯, Length/Offset의 + sourceList)**: 계속 `None` — 구멍이 생기면 해시 파트로 밀려 + index 순회 순서가 깨지므로, "채워짐"을 유지해야 함. + - **순서가 안 중요하고 슬롯 재사용이 필요한 배열(Ref 콜백/대기자 + 리스트)**: `nil` + "빈 슬롯을 선형 탐색해 재사용"하는 등록 함수 + (`table.insert`는 안 씀 — 구멍 있는 테이블에서 `#t`가 미정의 + 동작이라서). 순서 자체는 문제 안 됨 — 일반화 `for`는 구멍이 있어도 + 모든 non-nil 엔트리를 빠짐없이 방문하기 때문. + + 이 스크립트는 두 패턴을 나란히 재현해서 각각이 실제로 옳은 선택인지, + 그리고 "None을 잘못 썼을 때 실제로 배열이 끝없이 자라는" 버그 자체도 + 수치로 보여줌. + + 실행: `luau 02-none-sentinel-vs-nil-holes.luau` +]] + +local None = setmetatable({}, { __tostring = function() + return "" +end }) + +-- =============================================================== +-- Part A. 순서가 중요한 배열 — None이 맞는 경우 (PreRef pre-pass, sourceList) +-- =============================================================== + +print("=== A. 순서가 중요한 배열: None으로 소진해야 순서/#t가 안 깨짐 ===") + +local N = 50 + +local function buildList(n) + local t = {} + for i = 1, n do + t[i] = "item" .. i + end + return t +end + +print("-- A-1) BAD: nil로 지우면 순서/#t가 불안정해짐 --") +local bad = buildList(N) +for i = 2, N, 2 do + bad[i] = nil +end +print("bad #t =", #bad, "(Lua 명세상 정의되지 않은 동작 — 실제 값 확인용)") +local badOrder = {} +for i, v in bad do + table.insert(badOrder, tostring(i) .. "=" .. tostring(v)) +end +print("bad 순회 순서(구멍이 생겨 흐트러질 수 있음):", table.concat(badOrder, ", ")) + +print() +print("-- A-2) GOOD: None으로 지우면 #t/순서가 항상 보존됨(PreRef pre-pass에 필요한 성질) --") +local good = buildList(N) +for i = 2, N, 2 do + good[i] = None +end +print("good #t =", #good, "(항상 N — 구멍이 없으니까)") +local goodOrder = {} +for i = 1, #good do + local v = good[i] + goodOrder[#goodOrder + 1] = tostring(i) .. "=" .. (v == None and "None" or tostring(v)) +end +print("good 순회 순서(1..#t로 직접, 항상 안정적):", table.concat(goodOrder, ", ")) + +-- =============================================================== +-- Part B. 순서가 안 중요하고 슬롯 재사용이 필요한 배열 — nil이 맞는 경우 +-- (Ref 콜백/대기자 리스트가 실제로 이 카테고리, 2026-08-09 최종 정정) +-- =============================================================== + +print() +print("=== B. Ref 콜백/대기자 리스트: None을 쓰면 무한 성장 버그, nil+재사용이 맞음 ===") + +-- 등록: table.insert 대신 "빈(nil) 슬롯을 선형 탐색해 재사용" +local function registerNil(list, value) + for i = 1, #list + 1 do + if list[i] == nil then + list[i] = value + return i + end + end +end + +-- 소진: 그 인덱스를 nil로 되돌림(재사용 가능하게) +local function consumeNil(list, i) + list[i] = nil +end + +-- 대조군: 예전에 잘못 썼던 None 기반 버전(table.insert로만 추가, 소진은 None) +local function registerNoneBad(list, value) + table.insert(list, value) + return #list +end +local function consumeNoneBad(list, i) + list[i] = None +end + +print("-- B-1) nil + 슬롯 재사용: 동시 대기자 수만큼만 배열 크기가 유지되는가 --") +do + local waiters = {} + local maxSizeSeen = 0 + -- "등록 -> 곧바로 소진"을 여러 번 반복(:Wait() 호출 후 fire되는 흔한 패턴 흉내) + for cycle = 1, 1000 do + local idx = registerNil(waiters, "waiter" .. cycle) + maxSizeSeen = math.max(maxSizeSeen, #waiters) + consumeNil(waiters, idx) + end + print("1000번 등록/소진 반복 후 배열 길이 =", #waiters, "(0이어야 함 — 전부 소진됨)") + print("과정 중 관측된 최대 배열 크기 =", maxSizeSeen, "(작게 유지돼야 함, 이상적으론 1)") +end + +print() +print("-- B-2) None + table.insert(예전 버그): 같은 패턴을 반복하면 배열이 끝없이 자람 --") +do + local waiters = {} + for cycle = 1, 1000 do + local idx = registerNoneBad(waiters, "waiter" .. cycle) + consumeNoneBad(waiters, idx) + end + print("1000번 등록/소진 반복 후 배열 길이 =", #waiters, "(1000이어야 함 — 이게 바로 그 버그)") + local noneCount = 0 + for _, v in waiters do + if v == None then + noneCount += 1 + end + end + print("그 중 None으로 채워진(죽은) 슬롯 개수 =", noneCount, "(전부 죽은 슬롯인데 자리만 차지)") +end + +print() +print("-- B-3) nil 소진이 순서를 안 깨는가(대기자는 순서 안 중요하지만, 그래도 확인) --") +do + local waiters = {} + registerNil(waiters, "keep-me-1") + local idx2 = registerNil(waiters, "temp-2") + registerNil(waiters, "keep-me-3") + consumeNil(waiters, idx2) -- 중간 슬롯 소진 -> 구멍 생김 + local visited = {} + for i, v in waiters do + table.insert(visited, tostring(i) .. "=" .. tostring(v)) + end + print("구멍 있는 상태에서 순회(전부 방문되기만 하면 충분, 순서 무관):", table.concat(visited, ", ")) + -- 이제 새 등록이 빈 슬롯(구멍)을 재사용하는지 확인 + local reusedIdx = registerNil(waiters, "reused") + print("새 등록이 빈 슬롯(index=" .. idx2 .. ")을 재사용했는가?", reusedIdx == idx2) +end + +--[[ + 확인 포인트: + 1. Part A — good(None) 쪽은 #t/순서가 항상 N으로 안정적인가(PreRef + pre-pass가 요구하는 성질 재확인). + 2. Part B-1 — nil+재사용 방식은 반복해도 배열이 안 커지는가(0 또는 + 작은 값 유지)? + 3. Part B-2 — None+table.insert 방식은 실제로 1000까지 자라는가 — + 이게 바로 사용자가 찾아낸 "무한 성장" 버그의 정량적 재현. 이 결과가 + 기대와 다르면(예: 실제로는 안 자란다면) bind-system-plan.md의 정정 + 근거 자체를 재검토해야 하니 반드시 알려줄 것. + 4. Part B-3 — 새 등록이 소진된 빈 슬롯(index=idx2)을 실제로 재사용하는가 + — 이게 "table.insert 대신 선형 탐색 재사용 등록 함수"가 실제로 + 의도대로 동작하는지의 핵심 확인. +]] diff --git a/.claude/luau-test/03-recursive-store-bind-dispatch.luau b/.claude/luau-test/03-recursive-store-bind-dispatch.luau new file mode 100644 index 0000000..62b1115 --- /dev/null +++ b/.claude/luau-test/03-recursive-store-bind-dispatch.luau @@ -0,0 +1,168 @@ +--[[ + 검증 대상: process(inst,k,v)/retract(inst,k,v) 기반 재귀 재-dispatch + 모델(.claude/base/bind-system-plan.md "확정된 디스패치 모델" 절)이 실제 + Luau 함수 재귀로 자연스럽게 짜이는지, 우선순위 스캔(isHandlable)이 + 기대대로 동작하는지에 대한 최소 스파이크. + + 배경: ROADMAP.md M0 3번째 항목 "process/retract 재귀 재-process + 디스패치를 실제로 짜보기(store-bind 핸들러 하나 + isHandlable + 우선순위 스캔 포함)". + + 여기서는 다단 체인(retractUnder)까지는 다루지 않음 — 그건 + 04-dispatch-chain-retractUnder.luau가 별도로 다룸(단일 owner 슬롯 + 추적이 왜 깨지는지까지 포함). 이 파일은 "재귀 자체가 도는가", "우선순위 + 스캔이 맞는 핸들러를 고르는가", "None -> nil 재디스패치가 다음 + 핸들러로 자연히 좁혀지는가"까지만 검증. + + 실행: `luau 03-recursive-store-bind-dispatch.luau` + + 참고(2026-08-09 세션 갱신 반영): 아래 makeStore의 `subscribe(fn)`은 + 이 스파이크 전용으로 단순화한 것 — 실제 base 설계는 StoreBind가 + `state:Observer(fn)` + `bindLifetime(inst, observer)`/ + `unbindLifetime(inst, observer)`(`.claude/base/lifecycle-pattern.md`) + 조합으로 구독/해제한다. 여기서 검증하려는 건 그 구독 배관이 아니라 + "우선순위 스캔+재귀 process/retract 자체가 Luau에서 잘 도는가"라서 + 영향 없음 — 실제 Handler 구현 짤 때는 subscribe 대신 저 조합을 쓸 것. +]] + +local None = setmetatable({}, { __tostring = function() + return "" +end }) + +-- 아주 단순화된 "Store" 시늉 — 실제로는 Source/State가 되겠지만 여기선 +-- 그냥 값+구독자 리스트를 가진 테이블 +local StoreTag = {} +local function isStoreLike(v) + return type(v) == "table" and v[StoreTag] == true +end +local function makeStore(initial) + local self = { [StoreTag] = true, value = initial, subscribers = {} } + function self.get(_self) + return self.value + end + function self.set(_self, v) + self.value = v + for _, fn in self.subscribers do + fn(v) + end + end + function self.subscribe(_self, fn) + table.insert(self.subscribers, fn) + end + return self +end + +-- Dispatch 최소 스파이크 +local Dispatch = {} +local handlers = {} + +function Dispatch.addHandler(handler) + table.insert(handlers, handler) + table.sort(handlers, function(a, b) + return a.priority > b.priority + end) +end + +function Dispatch.getHandler(inst, k, v) + for _, h in handlers do + if h.isHandlable(inst, k, v) then + return h + end + end + return nil +end + +function Dispatch.process(inst, k, v) + local h = Dispatch.getHandler(inst, k, v) + if h then + print(string.format(" [Dispatch.process] inst=%s k=%s -> handler=%s", tostring(inst), tostring(k), h.name)) + h.process(inst, k, v) + else + print(string.format(" [Dispatch.process] inst=%s k=%s -> 매치되는 핸들러 없음!", tostring(inst), tostring(k))) + end +end + +-- 핸들러 1: NoneHandler (해시 파트 전용, 매우 높은 우선순위) +Dispatch.addHandler({ + name = "NoneHandler", + priority = 1000, + isHandlable = function(inst, k, v) + return v == None + end, + process = function(inst, k, v) + Dispatch.process(inst, k, nil) -- 재귀 재호출 + end, + retract = function() end, +}) + +-- 핸들러 2: StoreBind (store-like 값을 잡아 재귀 재-dispatch) +Dispatch.addHandler({ + name = "StoreBind", + priority = 900, + isHandlable = function(inst, k, v) + return isStoreLike(v) + end, + process = function(inst, k, store) + local function reprocess(realv) + print( + string.format( + " [StoreBind] %s.%s 값 변경 감지 -> 재귀 process(realv=%s)", + tostring(inst), + tostring(k), + tostring(realv) + ) + ) + Dispatch.process(inst, k, realv) + end + store:subscribe(reprocess) + reprocess(store:get()) -- 최초 1회 적용 (state:Observer의 "등록 즉시 1회 실행"을 흉내) + end, + retract = function(inst, k, v) + print(string.format(" [StoreBind.retract] %s.%s 구독 해제(흉내)", tostring(inst), tostring(k))) + end, +}) + +-- 핸들러 3: PropertyHandler (catch-all, 가장 낮은 우선순위) +Dispatch.addHandler({ + name = "PropertyHandler", + priority = 0, + isHandlable = function() + return true + end, + process = function(inst, k, v) + print(string.format(" [PropertyHandler] 실제 세팅: %s.%s = %s", tostring(inst), tostring(k), tostring(v))) + end, + retract = function() end, +}) + +print("=== 1. Store 값을 프로퍼티에 바인드 ===") +local colorStore = makeStore("red") +Dispatch.process("Frame1", "BackgroundColor", colorStore) + +print() +print("=== 2. Store 값 변경 -> 재귀 재-dispatch로 실제 값이 다시 세팅되는가 ===") +colorStore:set("blue") + +print() +print("=== 3. None 센티널 -> nil로 재귀 재-dispatch되어 PropertyHandler로 흘러가는가 ===") +Dispatch.process("Frame1", "Rotation", None) + +print() +print("=== 4. 무한 재귀 없이 종료되는가 ===") +print("위 1~3에서 스택 오버플로/무한 루프 없이 정상 종료됐다면 통과") + +--[[ + 확인 포인트: + 1. 콘솔에 handler=StoreBind가 먼저 찍히고, 그 다음 재귀로 + handler=PropertyHandler가 찍히는가? + 2. colorStore:set("blue") 이후 PropertyHandler가 다시(blue로) 불리는가? + 3. None 케이스가 PropertyHandler까지 자연스럽게 흘러가는가(중간에 + NoneHandler가 한 번만 관여하고 끝나는가)? + 4. table.sort 기반 우선순위 스캔이 매번 안정적으로 같은 순서를 내는가 + (Luau table.sort는 unstable sort일 수 있음 — 동일 priority 핸들러가 + 여러 개면 순서가 실행마다 바뀔 수 있다는 점 주의. 실제 구현에서는 + priority를 세밀하게 나누거나 등록 순서를 tie-breaker로 쓰는 걸 + 검토할 가치가 있어 보임 — 지금 base 문서엔 이 tie-break 규칙이 + 명시돼 있지 않음, 실제로 문제가 되면 base/bind-system-plan.md에 + 추가할 것). +]] diff --git a/.claude/luau-test/04-dispatch-chain-retractUnder.luau b/.claude/luau-test/04-dispatch-chain-retractUnder.luau new file mode 100644 index 0000000..9bf5128 --- /dev/null +++ b/.claude/luau-test/04-dispatch-chain-retractUnder.luau @@ -0,0 +1,201 @@ +--[[ + 검증 대상: Dispatch가 (inst,k)별 핸들러 체인을 배열로 소유하고, + retractUnder(inst,k,keep,v)가 꼬리부터 keep 앞까지 정리하는 설계 + (.claude/base/bind-system-plan.md "Dispatch 체인" 절)가 다단 체인 + (A->B->C)에서 실제로 정확한지 검증. + + 배경: 2026-08-08 세 번째 세션 — "전역 소유자 슬롯 하나"로 추적하는 + 1차 설계가 재귀/래핑 핸들러(A가 B로 위임하는데 A 자신도 나중에 + 재계산되는 경우)에서 깨지는 걸 반례로 확인하고 체인 방식으로 교체함. + CLAUDE.md는 "M2/M4 스파이크 검증 목록에 chains/retractUnder가 다단 + 체인에서 실제로 정확히 동작하는지가 새로 추가됨(추론만으로 확정된 것)" + 이라고 명시 — 아직 실제 Luau로 돌려본 적 없음. 이 파일이 그 검증. + + 시나리오: StoreA(바깥 store) -> StoreBind가 잡아서 그 값을 다시 + Dispatch.process로 재귀 -> 그 값이 또 다른 Store(StoreB, "이중 store" + 케이스를 흉내)일 때 두 번째 StoreBind가 또 잡아서 재귀 -> 최종적으로 + PropertyHandler가 실제 세팅. 즉 A(StoreBind)->B(StoreBind again)->C(Property) + 3단 체인. ("Store가 Store를 담지 않는다"가 설계상 확정이라 이 자체는 + UB에 가까운 입력이지만, 체인 메커니즘이 다단에서 실제로 버티는지는 + 그것과 별개로 확인해둘 가치가 있어 일부러 스트레스 테스트로 씀.) + + 실행: `luau 04-dispatch-chain-retractUnder.luau` + + 참고(2026-08-09 세션 갱신 반영): 03번과 동일하게 아래 `subscribe(fn)`은 + 이 스파이크 전용 단순화 — 실제로는 `state:Observer(fn)` + + `bindLifetime`/`unbindLifetime` 조합(`.claude/base/lifecycle-pattern.md`)을 + 쓴다. `retract`가 할 일이 "구독 해제"라는 본질은 같아서 체인/ + retractUnder 로직 검증엔 영향 없음. +]] + +local StoreTag = {} +local function isStoreLike(v) + return type(v) == "table" and v[StoreTag] == true +end +local function makeStore(initial) + local self = { [StoreTag] = true, value = initial, subscribers = {} } + function self.get(_self) + return self.value + end + function self.set(_self, v) + self.value = v + for _, fn in self.subscribers do + fn(v) + end + end + function self.subscribe(_self, fn) + table.insert(self.subscribers, fn) + end + return self +end + +-- Relate 대용 (weak-key까지는 이 스파이크에서 안 다룸, 순수 로직 검증이 목적 — +-- weak-key/GC 쪽은 07-relate-weak-table-gc.luau가 따로 다룸) +local chains = {} -- [inst] = { [k] = { handler, handler, ... } } +local function chainFor(inst, k) + chains[inst] = chains[inst] or {} + chains[inst][k] = chains[inst][k] or {} + return chains[inst][k] +end + +local Dispatch = {} +local handlers = {} + +function Dispatch.addHandler(h) + table.insert(handlers, h) + table.sort(handlers, function(a, b) + return a.priority > b.priority + end) +end + +function Dispatch.getHandler(inst, k, v) + for _, h in handlers do + if h.isHandlable(inst, k, v) then + return h + end + end + return nil +end + +function Dispatch.process(inst, k, v) + local h = Dispatch.getHandler(inst, k, v) + if not h then + print(string.format(" (매치 없음: %s.%s = %s)", tostring(inst), tostring(k), tostring(v))) + return + end + local list = chainFor(inst, k) + table.insert(list, h) + print(string.format(" [chain push] %s.%s <- %s (체인 길이=%d)", tostring(inst), tostring(k), h.name, #list)) + h.process(inst, k, v) +end + +-- .claude/base/bind-system-plan.md의 pseudo code 그대로 옮김 +function Dispatch.retractUnder(inst, k, keep, v) + local list = chainFor(inst, k) + local cutoff = 0 + if keep then + for i, h in list do + if h == keep then + cutoff = i + break + end + end + end + for i = #list, cutoff + 1, -1 do + local retractedHandler = list[i] + local passedValue = (i == cutoff + 1) and v or nil + print( + string.format( + " [retractUnder] %s.%s: %s.retract(v=%s) 호출, 체인에서 제거", + tostring(inst), + tostring(k), + retractedHandler.name, + tostring(passedValue) + ) + ) + retractedHandler.retract(inst, k, passedValue) + list[i] = nil + end +end + +-- 핸들러: StoreBind (self 식별을 위해 핸들러 테이블 자기 자신을 process 안에서 캡처) +local function makeStoreBindHandler(name, priority) + local self + self = { + name = name, + priority = priority, + isHandlable = function(inst, k, v) + return isStoreLike(v) + end, + process = function(inst, k, store) + local function reprocess(realv) + print( + string.format( + " [%s] %s.%s 재계산 -> retractUnder(keep=self) 먼저, 그 다음 재귀 process", + name, + tostring(inst), + tostring(k) + ) + ) + Dispatch.retractUnder(inst, k, self, realv) + Dispatch.process(inst, k, realv) + end + store:subscribe(reprocess) + reprocess(store:get()) + end, + retract = function(inst, k, v) + print(string.format(" [%s.retract] 나 자신(구독) 정리", name)) + end, + } + return self +end + +Dispatch.addHandler(makeStoreBindHandler("StoreBindA", 900)) +Dispatch.addHandler({ + name = "PropertyHandler", + priority = 0, + isHandlable = function() + return true + end, + process = function(inst, k, v) + print(string.format(" [PropertyHandler] 실제 세팅: %s.%s = %s", tostring(inst), tostring(k), tostring(v))) + end, + retract = function(inst, k, v) + print(string.format(" [PropertyHandler.retract] 이전 값 무름")) + end, +}) + +print('=== 1단계: StoreA(값="hello") 바인드 ===') +local storeA = makeStore("hello") +Dispatch.process("Frame1", "Text", storeA) +print(" 현재 체인 길이:", #chainFor("Frame1", "Text")) + +print() +print("=== 2단계: StoreA 값을 다른 일반 값으로 바꿈(체인이 A 밑을 정확히 정리하는가) ===") +storeA:set("world") +print(" 현재 체인 길이:", #chainFor("Frame1", "Text"), "(A->Property 2개여야 정상)") + +print() +print("=== 3단계: StoreA 값을 store로 다시 바꿔서(다단 체인 유도) 스트레스 테스트 ===") +local storeB = makeStore("nested") +storeA:set(storeB) +print(" 현재 체인 길이:", #chainFor("Frame1", "Text")) + +print() +print("=== 4단계: 안쪽 StoreB 값을 바꿔서, retractUnder(keep=StoreBindA 자신)가") +print(" 바깥 A는 안 건드리고 그 밑(B 이후)만 정리하는지 확인 ===") +storeB:set("nested-changed") + +--[[ + 확인 포인트 (이게 이 파일의 핵심 목적): + 1. 2단계에서 storeA:set("world") 이후 체인 길이가 정확히 2(A, Property)로 + 돌아오는가 — retractUnder가 이전 PropertyHandler를 정리하고 새로 + push했는가, 아니면 계속 누적돼서 체인이 무한정 길어지는가? + (누적되면 버그 — 체인이 GC 안 되는 메모리 누수이자 논리 오류) + 2. 3단계~4단계에서 A(StoreBindA) 자신은 살아남고, 그 밑(구 PropertyHandler + 또는 구 중첩 핸들러)만 정리되는가 — "A가 자길 엉뚱하게 retract하는" + 버그(CLAUDE.md가 기각한 1차 설계의 실패 모드)가 재현되지 않는가? + 3. 체인 길이가 각 단계마다 예상한 값과 정확히 일치하는가(주석에 적어둔 + 기대값과 실제 print 결과를 비교). + 4. 스택 오버플로 없이 전부 정상 종료되는가. +]] diff --git a/.claude/luau-test/05-store-state-diamond-propagation.luau b/.claude/luau-test/05-store-state-diamond-propagation.luau new file mode 100644 index 0000000..b0e0870 --- /dev/null +++ b/.claude/luau-test/05-store-state-diamond-propagation.luau @@ -0,0 +1,157 @@ +--[[ + 검증 대상: Store/State의 push-invalidate(신호만) / pull-recompute(Get() + 시점 재계산) 전파 모델이 다이아몬드 의존성에서 정확히 동작하는지. + + 배경: ROADMAP.md M0 1번째 항목 "Store/State push-invalidate -> + pull-recompute propagation을 실제로 짜보기(다이아몬드 의존성 케이스 + 포함 — 이미 invalid면 전파 중단되는지)". + + 다이아몬드 구조: + source + / \ + stateA stateB + \ / + stateC (:With(stateA, stateB):Compute(...)) + + 검증할 것 두 가지: + 1. source가 바뀌면 invalidate 신호가 stateA/stateB를 거쳐 stateC까지 + 전파되는데, "이미 dirty로 표시된 노드는 더 이상 아래로 전파하지 + 않는다"는 방어가 있어야 다이아몬드에서 stateC가 두 경로로 두 번 + invalidate 신호를 받아도 문제없이 처리됨(도달 자체는 두 번 일어나되, + 두 번째는 즉시 조기 종료돼야 함). + 2. stateC:Get()을 실제로 호출했을 때, compute 함수가 정확히 1번만 + 실행되는가(다이아몬드 때문에 stateA 경로/stateB 경로 각각 한 번씩 + 총 2번 이상 실행되면 버그). + + 실행: `luau 05-store-state-diamond-propagation.luau` +]] + +local function makeSource(initial) + local self = { value = initial, listeners = {} } + function self:Get() + return self.value + end + function self:Set(v) + self.value = v + self:Invalidate() + end + function self:Invalidate() + -- source 자신은 dirty 개념이 없음(항상 최신) — 그냥 리스너에게 신호만 쏨 + for _, fn in self.listeners do + fn() + end + end + function self:OnInvalidate(fn) + table.insert(self.listeners, fn) + end + return self +end + +local invalidateCallCount = { stateA = 0, stateB = 0, stateC = 0 } +local computeCallCount = { stateA = 0, stateB = 0, stateC = 0 } + +local function makeState(name, deps, computeFn) + local self = { + name = name, + dirty = true, -- 처음엔 아직 계산 안 됐으니 dirty + cached = nil, + listeners = {}, + } + function self:Invalidate() + invalidateCallCount[name] += 1 + if self.dirty then + -- 핵심: 이미 dirty면 더 아래로 전파하지 않음(다이아몬드 방어) + print(string.format(" [%s] 이미 dirty -> 전파 중단", name)) + return + end + print(string.format(" [%s] dirty로 표시, 아래로 전파", name)) + self.dirty = true + for _, fn in self.listeners do + fn() + end + end + function self:OnInvalidate(fn) + table.insert(self.listeners, fn) + end + function self:Get() + if self.dirty then + computeCallCount[name] += 1 + print(string.format(" [%s] pull-recompute 실행 (총 %d번째)", name, computeCallCount[name])) + local args = {} + for i, d in deps do + args[i] = d:Get() + end + self.cached = computeFn(table.unpack(args)) + self.dirty = false + else + print(string.format(" [%s] 캐시된 값 그대로 반환(재계산 없음)", name)) + end + return self.cached + end + for _, d in deps do + d:OnInvalidate(function() + self:Invalidate() + end) + end + return self +end + +local source = makeSource(1) +local stateA = makeState("stateA", { source }, function(v) + return v + 10 +end) +local stateB = makeState("stateB", { source }, function(v) + return v + 100 +end) +local stateC = makeState("stateC", { stateA, stateB }, function(a, b) + return a + b +end) + +print("=== 1. 최초 Get() — 전부 계산돼야 함 ===") +print("stateC:Get() =", stateC:Get()) +print("compute 호출 횟수:", computeCallCount.stateA, computeCallCount.stateB, computeCallCount.stateC) +assert( + computeCallCount.stateA == 1 and computeCallCount.stateB == 1 and computeCallCount.stateC == 1, + "최초 계산 횟수가 예상과 다름" +) + +print() +print("=== 2. 재차 Get() — 캐시만 반환, 재계산 없어야 함 ===") +print("stateC:Get() =", stateC:Get()) +assert(computeCallCount.stateC == 1, "invalidate 안 했는데 재계산이 일어남 (버그)") + +print() +print("=== 3. source:Set() -> 다이아몬드 invalidate 전파 ===") +source:Set(2) +print( + "invalidate 호출 횟수(stateC):", + invalidateCallCount.stateC, + "(stateA 경로 1번 + stateB 경로 1번 = 2번 호출은 정상, 단 2번째는 즉시 'already dirty'로 중단돼야 함)" +) + +print() +print("=== 4. invalidate 이후 Get() — 정확히 1번만 재계산되는가 ===") +print("stateC:Get() =", stateC:Get()) +print( + "compute 호출 횟수(stateC):", + computeCallCount.stateC, + "(2여야 함 — 1차 계산 + 이번 재계산, 3 이상이면 다이아몬드 중복 재계산 버그)" +) +assert(computeCallCount.stateC == 2, "다이아몬드 의존성 때문에 stateC가 여러 번 재계산됨 (버그)") + +print() +print("모든 assert 통과 — 다이아몬드 전파/재계산 모델이 예상대로 동작함") + +--[[ + 확인 포인트: + 1. 위 assert들이 전부 통과하는가(하나라도 실패하면 error로 죽고 스택 + 트레이스가 찍힘 — 그대로 알려줄 것). + 2. invalidateCallCount.stateC가 정확히 2(stateA 경로, stateB 경로 각각 + 1번씩 도달)이지만, 그 중 두 번째 호출은 "이미 dirty" 로그로 조기 + 종료되는지 눈으로 확인. + 3. 이 스파이크는 실제 :With/:Compute API 모양이 아니라 최소 골격만 + 흉내낸 것 — 실제 구현 시 self/deps를 State 핸들로 lazy하게 넘기는 + 부분(.claude/base/bind-system-plan.md "Store/State/Source 온톨로지" + 절)은 여기 반영 안 돼 있음, 이 파일은 오직 "전파 알고리즘 자체의 + 정확성"만 검증 대상. +]] diff --git a/.claude/luau-test/06-component-boundary-nil-hole-props.luau b/.claude/luau-test/06-component-boundary-nil-hole-props.luau new file mode 100644 index 0000000..fc6b2f5 --- /dev/null +++ b/.claude/luau-test/06-component-boundary-nil-hole-props.luau @@ -0,0 +1,87 @@ +--!strict +--[[ + 검증 대상: 컴포넌트 경계에서 props.Modifier/props.Ref를 "or None"으로 + 감싸 넘기는 필수 관용구가 실제로 nil-hole 문제를 막아주는지, 그리고 + `export type Params = {...}`로 타입 체크되는 컴포넌트 하나가 실제 + Luau에서 문제없이 짜이는지. + + 배경: ROADMAP.md M0 5번째 항목, .claude/base/component-composition-plan.md + "필수 관용구" 절, .claude/research/pre-implementation-audit.md 1-5. + + 두 가지 방법으로 확인 필요함: + 1. 런타임 동작(nil-hole 재현) 확인: `luau 06-component-boundary-nil-hole-props.luau` + 2. 타입 체크(Params 타입, Modifier/Ref 타입 흉내) 확인: + `luau-analyze 06-component-boundary-nil-hole-props.luau` + (luau-analyze가 로컬에 없으면 Luau 공식 릴리즈 CLI 툴체인 필요 — + https://github.com/luau-lang/luau/releases, 또는 lune/rojo 배포판) +]] + +local None = setmetatable({}, { __tostring = function() + return "" +end }) :: any + +-- Modifier/Ref를 아주 얇게 흉내낸 타입(실제 구현 API 모양과 다를 수 있음, +-- 여기선 오직 "props.Modifier or None" 패턴의 타입/런타임 동작만 검증) +type FakeModifier = { isModifier: true } +type FakeRef = { isRef: true } + +export type Params = { + Modifier: FakeModifier?, + Ref: FakeRef?, + Text: string, +} + +local function MyComponent(props: Params) + -- 핵심 관용구 — 이게 없으면 아래 "BAD" 케이스처럼 nil-hole이 생김 + local children = { + props.Modifier or None, + props.Ref or None, + props.Text, + "fixed-child-1", + "fixed-child-2", + } + return children +end + +print("=== BAD: or None 없이 raw로 꽂았을 때 ===") +local badProps: Params = { Text = "hello" } -- Modifier/Ref 둘 다 안 넘김 +local badChildren = { badProps.Modifier, badProps.Ref, badProps.Text, "fixed-child-1", "fixed-child-2" } +print("bad #t =", #badChildren, "(정의된 대로면 5, 하지만 앞쪽 nil-hole 때문에 불안정할 수 있음)") +for i, v in badChildren do + print(" bad[" .. tostring(i) .. "] =", tostring(v)) +end + +print() +print("=== GOOD: or None 관용구 사용 ===") +local goodChildren = MyComponent(badProps) +print("good #t =", #goodChildren, "(항상 5여야 함)") +for i = 1, #goodChildren do + print(" good[" .. tostring(i) .. "] =", tostring(goodChildren[i])) +end + +print() +print("=== 대조군: Modifier/Ref 둘 다 넘겼을 때도 동일하게 동작하는가 ===") +local fullProps: Params = { + Modifier = { isModifier = true }, + Ref = { isRef = true }, + Text = "hello", +} +local fullChildren = MyComponent(fullProps) +print("full #t =", #fullChildren, "(항상 5)") + +--[[ + 확인 포인트 (런타임 실행): + 1. bad #t가 5가 아니거나(예: 3), 순회 시 앞쪽 두 슬롯이 이상하게 뒤로 + 밀리거나 사라지는가 — 이게 실제 nil-hole 버그의 재현. + 2. good/full 양쪽 모두 #t가 정확히 5이고, 순서(Modifier자리, Ref자리, + Text, fixed-child-1, fixed-child-2)가 항상 지켜지는가. + + 확인 포인트 (luau-analyze 타입 체크): + 1. `export type Params`가 옵셔널 Modifier?/Ref? 필드로 문제없이 + 타입체크되는가. + 2. `props.Modifier or None`에서 None을 `any`로 캐스팅해뒀는데, 이걸 + 실제 Modifier/None 유니온 타입으로 더 정확히 표현하려면 어떤 타입 + 선언이 필요한지(예: `type Slot = T | typeof(None)`류) 실 Luau + 에러 메시지를 보고 판단해볼 것 — 지금 파일은 `any` 캐스팅으로 + 일단 회피해뒀음, 이 부분은 M7/M8 실제 구현 시 정확한 타입을 찾아야 함. +]] diff --git a/.claude/luau-test/07-relate-weak-table-gc.luau b/.claude/luau-test/07-relate-weak-table-gc.luau new file mode 100644 index 0000000..3e7e39e --- /dev/null +++ b/.claude/luau-test/07-relate-weak-table-gc.luau @@ -0,0 +1,135 @@ +--[[ + 검증 대상: .claude/base/relate-plan.md가 확정한 Relate의 실제 구조 + ({ [inst(weak)]: { StrongMap?, WeakMap? } })가 Luau의 진짜 weak-table + GC 동작과 맞아떨어지는지 — lazy 서브테이블 생성, WeakMap 공유 + 메타테이블, 그리고 무엇보다 "inst가 죽으면 중첩된 것까지 전부 + 같이 GC되는가"라는 .claude/base/bind-system-plan.md "왜 GC-안전한가" + 절의 핵심 주장 자체. + + 배경: .claude/base/relate-plan.md "M2 착수 시 실측 확인" 캐비엇. + + 중요한 제약: Roblox의 실제 게임 스크립트 환경에는 collectgarbage()가 + 노출되지 않음(강제 GC 트리거 불가) — 그래서 이 GC 타이밍 검증은 + Roblox Studio가 아니라 반드시 순수 luau CLI에서 해야 함(standalone + Luau 인터프리터는 collectgarbage를 허용). Roblox 쪽은 VM/GC 구현 + 자체가 같은 Luau이므로 여기서 확인된 동작이 그대로 적용된다고 가정할 + 수 있지만, "그대로 적용된다"는 가정 자체는 이 스크립트로 검증 불가능한 + 항목으로 남음(참고만 할 것). + + 실행: `luau 07-relate-weak-table-gc.luau` +]] + +local sharedWeakValueMeta = { __mode = "v" } + +local function Relate() + local outer = setmetatable({}, { __mode = "k" }) -- inst는 항상 weak + local relate = {} + + local function subtable(inst) + local t = outer[inst] + if not t then + t = {} + outer[inst] = t + end + return t + end + + function relate.SetStrong(_, inst, key, value) + local t = subtable(inst) + t.StrongMap = t.StrongMap or {} + t.StrongMap[key] = value + end + function relate.GetStrong(_, inst, key) + local t = outer[inst] + if not t or not t.StrongMap then + return nil + end + return t.StrongMap[key] + end + function relate.SetWeak(_, inst, key, value) + local t = subtable(inst) + if not t.WeakMap then + t.WeakMap = setmetatable({}, sharedWeakValueMeta) + end + t.WeakMap[key] = value + end + function relate.GetWeak(_, inst, key) + local t = outer[inst] + if not t or not t.WeakMap then + return nil + end + return t.WeakMap[key] + end + + -- 디버깅 전용 — 실제 Relate API엔 없음, 이 스파이크에서 관찰용으로만 + function relate._debugHasSubtable(_, inst) + return outer[inst] ~= nil + end + + return relate +end + +print("=== 1. lazy 생성 확인 ===") +local relate1 = Relate() +local instA = {} -- 실제로는 Roblox Instance지만, 순수 luau CLI엔 없으므로 plain table로 대체 +print("Set 호출 전 subtable 존재?", relate1:_debugHasSubtable(instA), "(false여야 함)") +relate1:SetStrong(instA, "k1", "v1") +print("SetStrong 호출 후 subtable 존재?", relate1:_debugHasSubtable(instA), "(true여야 함)") +print("GetStrong(instA, k1) =", relate1:GetStrong(instA, "k1")) +print("GetWeak(instA, 아무거나) — WeakMap 아직 안 만들어졌어도 nil로 안전하게 반환?", relate1:GetWeak(instA, "nope")) + +print() +print("=== 2. inst가 스코프를 벗어나면 그 안의 StrongMap도 같이 사라지는가(간접 확인) ===") +local relate2 = Relate() +do + local instB = {} + relate2:SetStrong(instB, "tween", "FAKE_TWEEN_INSTANCE") + print("instB 살아있을 때 GetStrong =", relate2:GetStrong(instB, "tween")) + -- instB에 대한 유일한 강참조는 이 do-블록의 로컬 변수뿐 — 블록을 벗어나면 사라짐 +end +collectgarbage() -- 표준 luau CLI에서 지원(Roblox에선 사용 불가 — 위 주석 참고) +collectgarbage() +print("(instB 참조를 잃었으므로 같은 값으로 재조회는 애초에 불가능 — 아래 3번이 실질 확인)") + +print() +print("=== 3. weak key가 실제로 GC되는지 카운팅으로 확인 ===") +local relate3 = Relate() +local keepAlive = {} -- 이 배열에 담긴 것만 살아남음 +for i = 1, 100 do + local inst = {} + relate3:SetStrong(inst, "data", "payload" .. i) + if i <= 10 then + keepAlive[i] = inst -- 앞 10개만 강하게 붙잡아둠 + end + -- 나머지 90개는 루프 변수 스코프를 벗어나는 즉시 참조를 잃음 +end +collectgarbage() +collectgarbage() + +local aliveCount = 0 +for i = 1, 10 do + if relate3:GetStrong(keepAlive[i], "data") ~= nil then + aliveCount += 1 + end +end +print("강하게 붙잡아둔 10개 중 살아있는 것:", aliveCount, "(10이어야 함)") + +print() +print('=== 참고: collectgarbage("count") 메모리 변화(대략적 신호일 뿐) ===') +print(collectgarbage("count"), "KB") + +--[[ + 확인 포인트: + 1. 1번 섹션 — SetStrong 호출 전엔 subtable이 안 만들어져 있다가, 호출 + 순간에만 생기는가(lazy 생성 실측). + 2. 3번 섹션 — collectgarbage()가 실제로 동작하고(에러 안 나고), + 강하게 붙잡아둔 10개는 살아있는가(당연히 그래야 함 — sanity check). + 3. **가장 중요한 미해결 관찰**: 이 스크립트는 "죽은 90개가 실제로 + GC됐는지"를 직접 카운트하지 못함(Luau가 weak table 내부 엔트리 + 개수를 세는 표준 API를 안 줌) — `collectgarbage("count")`로 전체 + 메모리 사용량 변화를 보는 정도가 간접 확인의 최선. 필요하면 위 + 3번 섹션의 루프를 더 크게(예: 100 -> 1,000,000) 돌리면서 루프 + 전후 collectgarbage("count") 차이를 비교해보면 신호가 더 뚜렷해질 + 수 있음(주의: GC는 정확한 타이밍을 보장 안 하므로 완벽한 증거는 + 아님, 참고 신호 정도로만 볼 것). +]] diff --git a/.claude/luau-test/08-type-source-satisfies-state.luau b/.claude/luau-test/08-type-source-satisfies-state.luau new file mode 100644 index 0000000..290e8fc --- /dev/null +++ b/.claude/luau-test/08-type-source-satisfies-state.luau @@ -0,0 +1,78 @@ +--!strict +--[[ + 검증 대상: Source가 구조적으로 State를 만족하는(self 타이핑 + + State 참조가 섞인 제네릭 :Compute) 설계가 Luau 타입 솔버에서 안전하게 + 추론되는지 — 실제 실행이 아니라 타입 체크(luau-analyze) 대상. + + 배경: .claude/base/store-semantics.md "검증 필요(확정 아님, M0 스파이크 + 대상)" 절, ROADMAP.md M0 2번째 항목. + + 핵심 우려: State가 거꾸로 Source를 참조하는 "상호 재귀"는 Luau + 솔버가 취약한 패턴 — 그래서 아래 State는 Source를 전혀 참조하지 + 않도록 독립적으로 먼저 정의하고, Source만 State를 단방향으로 + 참조하게 구성함. 타입은 사용자 선호대로 &(교차)가 아니라 손으로 + 펼쳐 씀(런타임 구현 델리게이션과는 별개 축이라 상관없음). + + 실행: `luau-analyze 08-type-source-satisfies-state.luau` + (로컬에 luau-analyze가 없으면 Luau 공식 릴리즈의 CLI 툴체인 설치 필요 — + https://github.com/luau-lang/luau/releases, 또는 lune 배포판에 포함된 것) + + 기대 결과: 에러 없이 통과하거나, 통과 안 하면 정확히 *어느 줄에서* + *무슨 에러*가 나는지가 다음 결정에 중요한 정보임 — 에러가 나면 그 + 메시지를 그대로 가져와서 알려줄 것. +]] + +-- State는 Source를 절대 참조하지 않음(단방향 의존을 위한 핵심 제약) +export type State = { + Get: (self: State) -> T, + With: (self: State, ...State) -> State, + Compute: (self: State, fn: (T) -> U) -> State, +} + +-- Source만 State를 참조(단방향) — self 타이핑(Source 자신을 가리킴)과 +-- 바깥 타입 참조(State)가 섞인 제네릭 메소드가 바로 검증 대상 +export type Source = { + Get: (self: Source) -> T, + With: (self: Source, ...State) -> State, + Compute: (self: Source, fn: (T) -> U) -> State, + Set: (self: Source, value: T) -> (), + Emit: (self: Source) -> (), +} + +-- 1. Source 값을 만드는 흉내 생성자(런타임 구현은 아직 없으므로 타입만 맞추는 더미) +local function fakeSource(default: T): Source + return (nil :: any) :: Source +end + +-- 2. State를 요구하는 함수에 Source를 그대로 넘길 수 있는가 +-- (구조적 서브타이핑 — "Source가 State를 만족함" 절의 핵심 주장) +local function useAsState(s: State): T + return s:Get() +end + +local mySource: Source = fakeSource(0) +local viaSubtype: number = useAsState(mySource) -- 여기가 타입체크 되는지가 핵심 + +-- 3. Compute 체이닝이 제네릭을 타고 잘 흐르는가(Source -> State -> State) +local derived1: State = mySource:Compute(function(n: number): string + return tostring(n) +end) +local derived2: State = derived1:Compute(function(s: string): boolean + return #s > 0 +end) + +-- 4. store.key가 Source를 직접 반환한다는 모델(레코드 필드 읽기/쓰기 대칭) +export type Store = { + -- 실제로는 defaults의 각 키를 Source<...>로 매핑하는 mapped type이 이상적이지만 + -- Luau에 mapped type이 없으므로(2026-08 시점) 구체 예시 하나로만 검증 + Health: Source, +} + +local function useStore(store: Store) + store.Health:Set(100) -- 쓰기 + local hp: number = store.Health:Get() -- 읽기 — 같은 필드 타입(Source)으로 대칭 + return hp +end + +print("이 파일은 luau-analyze로만 의미가 있음 (런타임 실행은 그냥 통과함)") +print(viaSubtype, derived2) diff --git a/.claude/luau-test/09-type-modifier-overridden-subtype.luau b/.claude/luau-test/09-type-modifier-overridden-subtype.luau new file mode 100644 index 0000000..69be83c --- /dev/null +++ b/.claude/luau-test/09-type-modifier-overridden-subtype.luau @@ -0,0 +1,73 @@ +--!strict +--[[ + 검증 대상: Modifier.Overridden(mod1, mod2, ...)가 서브타입 관계인 + 서로 다른 Modifier 타입(FrameModifier <: GuiObjectModifier)을 섞을 때 + 타입이 통과하는지 — 필드 setter가 전부 self를 반환하는 fluent 타입이라 + 구조적 서브타이핑이 실제로 성립하는지가 관건. + + 배경: .claude/base/modifier-plan.md 9-2번 절, ROADMAP.md M7. + "막히는 지점"으로 문서가 지목한 것: `:BackgroundColor3` 같은 메소드가 + FrameModifier에서는 FrameModifier를, GuiObjectModifier에서는 + GuiObjectModifier를 리턴하므로 같은 이름 필드의 리턴 타입이 갈려서 + 단순 구조적 서브타이핑이 깨질 수 있음. + + 이 파일은 두 버전을 나란히 둠: + A) "정직한" 버전 — 메소드 리턴 타입이 각자 자기 자신 + B) fallback 버전 — 문제가 생기면 쓸 `Overridden(...: any): any` 완화형 + luau-analyze를 돌려서 A가 실제로 어디서 막히는지(또는 안 막히는지) + 확인하는 게 목적. + + 실행: `luau-analyze 09-type-modifier-overridden-subtype.luau` +]] + +-- ===== A) 정직한 버전 ===== + +export type GuiObjectModifier = { + -- Color3 대신 number로 단순화(luau-analyze 단독 실행 환경엔 Roblox 타입이 없을 수 있어서) + BackgroundColor3: (self: GuiObjectModifier, v: number) -> GuiObjectModifier, + Apply: (self: GuiObjectModifier, f: (GuiObjectModifier) -> GuiObjectModifier) -> GuiObjectModifier, +} + +export type FrameModifier = { + BackgroundColor3: (self: FrameModifier, v: number) -> FrameModifier, + Apply: (self: FrameModifier, f: (FrameModifier) -> FrameModifier) -> FrameModifier, + ClipsDescendants: (self: FrameModifier, v: boolean) -> FrameModifier, -- Frame 전용 필드 +} + +local function fakeFrameModifier(): FrameModifier + return (nil :: any) :: FrameModifier +end + +-- 시도 1: FrameModifier 값을 GuiObjectModifier 변수에 그대로 대입 — 통과하는가? +local frameMod: FrameModifier = fakeFrameModifier() +local asGuiObjectMod: GuiObjectModifier = frameMod -- <- 여기가 luau-analyze 에러 나는지 확인 포인트 1 + +-- 시도 2: Overridden을 GuiObjectModifier 시그니처로 선언하고 FrameModifier를 인자로 넘김 +local function OverriddenHonest(...: GuiObjectModifier): GuiObjectModifier + return (nil :: any) :: GuiObjectModifier +end +local result1 = OverriddenHonest(frameMod) -- <- 확인 포인트 2 + +-- ===== B) fallback(any) 버전 ===== + +local function OverriddenLoose(...: any): any + return (nil :: any) +end +local result2 = OverriddenLoose(frameMod, asGuiObjectMod) -- 이건 항상 통과해야 함(any이므로) + +print("런타임 실행 자체는 의미 없음 — luau-analyze 출력을 확인할 것") +print(result1, result2) + +--[[ + 확인 포인트: + 1. "시도 1"(asGuiObjectMod 대입)에서 luau-analyze가 에러를 내는가? + 낸다면 정확한 에러 메시지(타입 불일치 상세)를 기록해둘 것 — + BackgroundColor3 필드의 리턴 타입 불일치 때문인지, 아니면 다른 + 이유인지가 다음 설계 결정에 중요함. + 2. "시도 2"(함수 인자로 넘기기)도 같은 결과가 나오는가, 아니면 대입과 + 함수 인자 전달이 Luau에서 다르게 취급되는가(공변성 처리 차이 가능성). + 3. A가 전부 막히면 -> .claude/base/modifier-plan.md 9-2번의 fallback대로 + `Overridden(...: any): any`로 확정하고 이 항목을 M7에서 다시 열 것. + A가 통과하면 -> 서브타입 체이닝을 정식으로 타입에 반영할 수 있다는 + 뜻이니 그 결과를 modifier-plan.md에 반영할 것. +]] diff --git a/.claude/luau-test/10-roblox-studio-checks.server.luau b/.claude/luau-test/10-roblox-studio-checks.server.luau new file mode 100644 index 0000000..93a5d5c --- /dev/null +++ b/.claude/luau-test/10-roblox-studio-checks.server.luau @@ -0,0 +1,206 @@ +--[[ + 검증 대상 (Roblox Studio 전용 — 순수 luau CLI로는 안 됨, 실제 Instance/ + Connection/CollectionService/Attribute가 필요함): + + A) bindLifetime/unbindLifetime/canExecute의 gcconn 트릭 — Observer/ + Effect 값의 이중 바인딩을 canBound로 막는지까지 포함해서 검증 + (2026-08-09 세션에 unbindLifetime 추가 + canBound 이름 확정 + + gchold를 배열이 아니라 value를 키로 쓰는 테이블로 바꾼 것까지 반영 + — 이전 버전의 이 스크립트는 array 기반 gchold였음, 이번에 정정). + B) Attribute가 Instance 참조 타입을 실제로 지원하는가(ObjectValue + 없이 Ref 용도로 쓸 수 있다는 CLAUDE.md 서술의 실측). + C) CollectionService 태그 + GetTagged 왕복이 quad-debug가 기대하는 + 대로 동작하는가(태그 추가/제거, GetTagged로 조회). + + 배경: .claude/base/lifecycle-pattern.md "bindLifetime/canExecute/ + unbindLifetime — 확정" 절 + "실측 필요(M0/M2)" 캐비엇, + .claude/base/bind-system-plan.md "이중 바인딩 금지" 절(canBound), + CLAUDE.md 2026-08-06 세션의 Attribute Instance 참조 지원 언급, + .claude/research/debug-tooling-plan.md의 CollectionService 노출 방식. + + 실행 방법: + 1. Roblox Studio에서 아무 place나 열고(빈 baseplate로 충분), + ServerScriptService에 이 파일 내용을 그대로 붙여넣은 Script를 + 하나 만든다. + 2. Play(F5) 또는 Run(F8) — Output 창에서 결과를 확인. + 3. 확인 끝나면 이 Script는 지워도 됨(Studio 안에 실제로 만든 Script + 얘기 — 이 원본 파일 자체는 `.claude/luau-test/`에 참고용으로 + 남겨둠). + + 주의: HUMAN_TODO.md 1번(Studio 별도 계정) 확인 후 실행할 것 — + SAFETY.md 준수. +]] + +print("========================================") +print("A) bindLifetime/unbindLifetime/canExecute/canBound gcconn 트릭") +print("========================================") + +do + local relate = {} -- 이 스파이크 전용 아주 단순한 strong map (inst -> {gcconn, gchold}) + + -- Observer/Effect를 흉내낸 최소 값 — .Subscribed 필드가 canExecute/canBound가 + -- 공유하는 그 필드(base/bind-system-plan.md "이중 바인딩 금지" 절 참고) + local function fakeObserver() + return { isObserverSpike = true, Subscribed = false } + end + local function isObserverLike(v) + return type(v) == "table" and v.isObserverSpike == true + end + + -- canBound(handle) — "아직 어느 경로로도 안 묶였으면 true" + local function canBound(value) + return not (isObserverLike(value) and value.Subscribed) + end + + local function bindLifetime(inst: Instance, value: any) + local isOE = isObserverLike(value) + if isOE and not canBound(value) then + error("Observer/Effect가 이미 다른 경로로 바인딩됨") + end + + local entry = relate[inst] + if not entry then + local gchold = {} -- value 자신을 키로 씀(배열 아님) — unbindLifetime을 O(1)로 + local gcconn = inst:GetPropertyChangedSignal("ClassName"):Connect(function() + -- 이 콜백은 정상적으로는 절대 발화하면 안 됨 — 발화하면 그 자체가 + -- "ClassName이 신호를 절대 안 쏜다"는 가정이 틀렸다는 증거이므로 경고. + warn("[예상 밖] ClassName Changed가 실제로 발화함! gcconn 트릭의 전제가 깨짐:", inst:GetFullName()) + local _ = gchold + end) + entry = { gcconn = gcconn, gchold = gchold } + relate[inst] = entry + end + entry.gchold[value] = true -- 강참조 생성, inst 죽으면 gcconn 클로저와 함께 GC + if isOE then + value.Subscribed = true -- canExecute/canBound가 보는 필드 그대로 재사용 + end + end + + local function unbindLifetime(inst: Instance, value: any) + local entry = relate[inst] + if entry then + entry.gchold[value] = nil -- inst는 안 건드림, 이 value 하나만 조기 해제(O(1)) + end + if isObserverLike(value) then + value.Subscribed = false + end + end + + local function canExecute(inst: Instance, value: any): boolean + if isObserverLike(value) and not value.Subscribed then + return false + end + local entry = relate[inst] + return entry ~= nil and entry.gcconn.Connected + end + + local target = Instance.new("Folder") + target.Name = "QuadLifetimeSpikeTarget" + target.Parent = workspace + + local obs1 = fakeObserver() + bindLifetime(target, obs1) + print("bindLifetime 직후 canExecute(target, obs1) =", canExecute(target, obs1), "(true여야 함)") + + print() + print("-- A-1) canBound 이중 바인딩 게이트: 같은 obs1을 또 bindLifetime하면 error가 나야 함 --") + local ok, err = pcall(function() + bindLifetime(target, obs1) + end) + print("두 번째 bindLifetime(obs1) 성공?", ok, "(false여야 함)", not ok and tostring(err) or "") + + print() + print("-- A-2) unbindLifetime: 특정 값 하나만 조기 해제, inst 전체엔 영향 없어야 함 --") + local obs2 = fakeObserver() + bindLifetime(target, obs2) + print("obs2 bindLifetime 직후 canExecute =", canExecute(target, obs2), "(true)") + unbindLifetime(target, obs2) + print("obs2 unbindLifetime 이후 canExecute =", canExecute(target, obs2), "(false여야 함, .Subscribed가 다시 false)") + print("obs1(같은 inst, 안 건드림)은 여전히 canExecute =", canExecute(target, obs1), "(true여야 함 — obs2 해제가 obs1에 영향 없어야 함)") + print("unbindLifetime 이후 같은 obs2를 다시 bindLifetime 가능한가(canBound가 재바인딩 허용하는지)?") + local ok2 = pcall(function() + bindLifetime(target, obs2) + end) + print("재-bindLifetime(obs2) 성공?", ok2, "(true여야 함 — unbindLifetime이 canBound를 다시 통과시켜야 함)") + + print() + print("-- A-3) Destroy 시 canExecute가 false로 바뀌는가(gcconn.Connected 확인) --") + target:Destroy() + print( + "Destroy 후 canExecute(target, obs1) =", + canExecute(target, obs1), + "(false여야 함 — Connection.Connected가 Destroy로 즉시 끊기는지 확인)" + ) + + -- 5초 정도 대기하며 위 warn이 늦게라도 튀어나오는지 관찰(비동기 우려 대비) + task.delay(5, function() + print("[A] 5초 대기 종료 — 그 사이 warn이 안 떴다면 gcconn 트릭 전제가 안전함") + end) +end + +print() +print("========================================") +print("B) Attribute의 Instance 참조 타입 지원 여부") +print("========================================") + +do + local target = Instance.new("Folder") + target.Name = "QuadAttributeRefSpikeTarget" + target.Parent = workspace + + local holder = Instance.new("Folder") + holder.Name = "QuadAttributeRefSpikeHolder" + holder.Parent = workspace + + local ok, err = pcall(function() + holder:SetAttribute("RefToTarget", target) + end) + print("SetAttribute(Instance) 성공?", ok, err and tostring(err) or "") + + if ok then + local readBack = holder:GetAttribute("RefToTarget") + print("GetAttribute 결과가 원본과 같은 Instance인가?", readBack == target) + end + + -- 대상이 Destroy되면 Attribute는 어떻게 되는가(참고 확인 — nil로 풀리는지, + -- 아니면 죽은 참조를 계속 들고 있는지는 Ref 설계에 영향을 줄 수 있음) + target:Destroy() + task.wait() + local afterDestroy = holder:GetAttribute("RefToTarget") + print("target Destroy 후 GetAttribute =", afterDestroy, "(nil로 풀리는지, 죽은 참조 그대로인지 확인)") + + holder:Destroy() +end + +print() +print("========================================") +print("C) CollectionService 태그 + GetTagged 왕복") +print("========================================") + +do + local CollectionService = game:GetService("CollectionService") + local TAG = "QuadDebugSpikeTag" + + local a = Instance.new("Folder") + a.Name = "TaggedA" + a.Parent = workspace + local b = Instance.new("Folder") + b.Name = "TaggedB" + b.Parent = workspace + + CollectionService:AddTag(a, TAG) + CollectionService:AddTag(b, TAG) + + local tagged = CollectionService:GetTagged(TAG) + print("GetTagged 결과 개수 =", #tagged, "(2여야 함)") + + CollectionService:RemoveTag(a, TAG) + local taggedAfterRemove = CollectionService:GetTagged(TAG) + print("RemoveTag 이후 GetTagged 개수 =", #taggedAfterRemove, "(1이어야 함)") + + a:Destroy() + b:Destroy() +end + +print() +print("모든 섹션 실행 완료 — Output 로그를 그대로 복사해서 공유해주면 됨") diff --git a/.claude/luau-test/11-modifier-illegal-value-error.luau b/.claude/luau-test/11-modifier-illegal-value-error.luau new file mode 100644 index 0000000..29a8d31 --- /dev/null +++ b/.claude/luau-test/11-modifier-illegal-value-error.luau @@ -0,0 +1,302 @@ +--[[ + 검증 대상: 2026-08-09 세션에 "UB, 방어 없음"에서 "즉시 error"로 전환된 + 두 규칙이 실제 Luau에서 자연스럽게 짜이는지 (신규 파일 — 이 폴더의 + 1차 작성 이후 새로 확정된 내용이라 이걸 검증하는 스크립트가 + 없었음): + + A) Modifier 필드에 핸들러 계층 값(Ref/PreRef/Observer/Effect/Slot/ + Modifier)이 들어오면 제네릭 __index 셋터가 최종 저장 직전에 즉시 + error. State/Source 값은 여전히 허용. + B) State/Source 자체의 "확정되는 값"(Source:Set, Store({defaults}) + 생성 시 각 default, State:Compute(fn)의 캐싱 직전)이 Modifier이면 + 즉시 error. Slot/Tag/Attribute/Tween 같은 다른 핸들러 계층 값은 + 여전히 허용(Modifier만의 예외). + + 배경: .claude/base/modifier-plan.md "Modifier 필드에 핸들러 계층 값이 + 들어오면 즉시 error" 절 + "7. State/Source가 Modifier를 값으로 담는 + 것 — 명시적 error로 확정" 절(둘 다 2026-08-09 세션 정정, 이전엔 + "UB, 가능하면 타입으로 막을 것"이었음). + + 실행: `luau 11-modifier-illegal-value-error.luau` +]] + +-- ===== Brand 흉내 — 실제로는 base/bind-system-plan.md의 Brand 절이 다루는 +-- weak-key 레지스트리 기반이지만, 이 스파이크에선 태그 필드로 단순화 ===== + +local function tag(name) + return function(t) + return setmetatable(t or {}, { __index = { __brand = name } }) + end +end + +local function brandOf(v) + if type(v) ~= "table" then + return nil + end + local mt = getmetatable(v) + return mt and mt.__index and mt.__index.__brand +end + +local makeRef = tag("Ref") +local makePreRef = tag("PreRef") +local makeObserver = tag("Observer") +local makeEffect = tag("Effect") +local makeSlot = tag("Slot") + +local function isRef(v) + return brandOf(v) == "Ref" +end +local function isPreRef(v) + return brandOf(v) == "PreRef" +end +local function isObserver(v) + return brandOf(v) == "Observer" +end +local function isEffect(v) + return brandOf(v) == "Effect" +end +local function isSlot(v) + return brandOf(v) == "Slot" +end + +-- ===== Modifier — 제네릭 __index 셋터 + "핸들러 계층 값 즉시 error" 체크 ===== + +local ModifierBrand = {} +local function isModifier(v) + return type(v) == "table" and v[ModifierBrand] == true +end +local function isState(v) + -- Source가 State를 구조적으로 만족(store-semantics.md) — 여기선 둘 다 + -- ".__isStateLike" 태그로 단순화해서 흉내 + return type(v) == "table" and v.__isStateLike == true +end + +local function illegalModifierFieldValue(v) + return isRef(v) or isPreRef(v) or isObserver(v) or isEffect(v) or isSlot(v) or isModifier(v) +end + +local function Modifier(initial) + local self = initial and table.clone(initial) or {} + self[ModifierBrand] = true + return setmetatable(self, { + __index = function(t, key) + -- 제네릭 setter 합성(modifier-plan.md 4번 절의 __index 트릭) + return function(selfArg, arg) + local clone = table.clone(selfArg) + local value + if type(arg) == "function" and not isState(selfArg[key]) then + -- plain 필드 + 함수 인자: 즉시 호출해 값 확정 (State 분기는 이 스파이크에서 생략) + value = arg(selfArg[key]) + else + value = arg + end + + -- 핵심 체크 지점: 최종 저장 직전 + if illegalModifierFieldValue(value) then + error( + string.format( + "Modifier 필드 '%s'에 핸들러 계층 값(%s)을 저장할 수 없음", + tostring(key), + tostring(brandOf(value) or (isModifier(value) and "Modifier") or "?") + ) + ) + end + + clone[key] = value + return clone + end + end, + }) +end + +print("=== A. Modifier 필드에 핸들러 계층 값 -> 즉시 error ===") + +local mod = Modifier() + +local casesA = { + { name = "plain 리터럴(허용)", fn = function() + return mod:FontSize(20) + end, expectError = false }, + { name = "State 유사 값(허용)", fn = function() + return mod:TextColor(setmetatable({ __isStateLike = true }, {})) + end, expectError = false }, + { name = "Ref(금지)", fn = function() + return mod:SomeField(makeRef()) + end, expectError = true }, + { name = "PreRef(금지)", fn = function() + return mod:SomeField(makePreRef()) + end, expectError = true }, + { name = "Observer(금지)", fn = function() + return mod:SomeField(makeObserver()) + end, expectError = true }, + { name = "Effect(금지)", fn = function() + return mod:SomeField(makeEffect()) + end, expectError = true }, + { name = "Slot(금지)", fn = function() + return mod:SomeField(makeSlot()) + end, expectError = true }, + { name = "다른 Modifier(금지)", fn = function() + return mod:SomeField(Modifier()) + end, expectError = true }, + { + name = "변환 함수가 Ref를 반환(금지 — 콜백이어도 최종값만 봄)", + fn = function() + return mod:SomeField(function(old) + return makeRef() + end) + end, + expectError = true, + }, +} + +for _, case in casesA do + local ok, err = pcall(case.fn) + local pass = (ok == not case.expectError) + print( + string.format( + " [%s] %s: ok=%s expectError=%s %s", + pass and "PASS" or "FAIL", + case.name, + tostring(ok), + tostring(case.expectError), + (not ok) and ("(error: " .. tostring(err) .. ")") or "" + ) + ) +end + +-- ===== B. State/Source가 확정하는 값이 Modifier면 즉시 error ===== + +print() +print("=== B. Source:Set / Store 생성 / State:Compute 캐싱 -> Modifier면 즉시 error ===") + +local function checkNotModifier(value, where) + if isModifier(value) then + error(where .. ": Modifier를 State/Source 값으로 저장할 수 없음") + end +end + +local function Source(default) + checkNotModifier(default, "Source(default)") + local self = { __isStateLike = true, value = default } + function self:Get() + return self.value + end + function self:Set(v) + checkNotModifier(v, "Source:Set") + self.value = v + end + function self:Compute(fn) + local derived = { __isStateLike = true, dirty = true } + function derived:Get() + if self.dirty then + local result = fn(self.value) + checkNotModifier(result, "State:Compute 캐싱") + derived.cached = result + derived.dirty = false + end + return derived.cached + end + return derived + end + return self +end + +local function Store(defaults) + local sources = {} + for k, v in defaults or {} do + sources[k] = Source(v) -- 여기서도 checkNotModifier가 자연히 걸림 + end + return sources +end + +local casesB = { + { + name = "Source(plain 초기값) — 허용", + fn = function() + return Source(1) + end, + expectError = false, + }, + { + name = "Source(Modifier 초기값) — 금지", + fn = function() + return Source(Modifier()) + end, + expectError = true, + }, + { + name = "source:Set(plain) — 허용", + fn = function() + local s = Source(1) + s:Set(2) + end, + expectError = false, + }, + { + name = "source:Set(Modifier) — 금지", + fn = function() + local s = Source(1) + s:Set(Modifier()) + end, + expectError = true, + }, + { + name = "Store({defaults}) 중 하나가 Modifier — 금지", + fn = function() + return Store({ Health = 100, Style = Modifier() }) + end, + expectError = true, + }, + { + name = "state:Compute(fn)이 Modifier를 반환 — Get() 호출 시점에 금지", + fn = function() + local s = Source(1) + local derived = s:Compute(function(v) + return Modifier() + end) + derived:Get() -- 캐싱 시점에 걸려야 함 + end, + expectError = true, + }, + { + name = "state:Compute(fn)이 Slot을 반환 — 허용(Modifier만의 예외)", + fn = function() + local s = Source(1) + local derived = s:Compute(function(v) + return makeSlot() + end) + derived:Get() + end, + expectError = false, + }, +} + +for _, case in casesB do + local ok, err = pcall(case.fn) + local pass = (ok == not case.expectError) + print( + string.format( + " [%s] %s: ok=%s expectError=%s %s", + pass and "PASS" or "FAIL", + case.name, + tostring(ok), + tostring(case.expectError), + (not ok) and ("(error: " .. tostring(err) .. ")") or "" + ) + ) +end + +--[[ + 확인 포인트: + 1. 모든 케이스가 "PASS"로 찍히는가 — FAIL이 있으면 어느 케이스인지, + 기대와 실제가 어떻게 달랐는지 알려줄 것. + 2. A의 마지막 케이스("변환 함수가 Ref를 반환")처럼 "콜백이 반환한 값"도 + 리터럴과 동일하게 잡히는지 — modifier-plan.md가 명시한 "콜백이냐 + 직접 실행이냐를 구분하지 않고 최종 저장값 하나만 본다"는 원칙의 핵심. + 3. B에서 Slot 같은 "Modifier가 아닌 다른 핸들러 계층 값"은 State/Source에 + 여전히 자유롭게 들어갈 수 있는가(Modifier만의 예외라는 걸 재확인). + 4. 이 스파이크는 Brand/isState를 태그 필드로 단순화한 것 — 실제 구현은 + base/bind-system-plan.md의 weak-key 레지스트리 기반 Brand를 씀, + 여기선 그 판별 로직 자체가 아니라 "체크 지점 배치가 실제로 동작하는가"만 + 검증 대상. +]] diff --git a/.claude/luau-test/12-type-attribute-generic-key-narrowing.luau b/.claude/luau-test/12-type-attribute-generic-key-narrowing.luau new file mode 100644 index 0000000..75a7074 --- /dev/null +++ b/.claude/luau-test/12-type-attribute-generic-key-narrowing.luau @@ -0,0 +1,93 @@ +--!strict +--[[ + 검증 대상: `[Attribute<> "name"] = value`처럼 제네릭 파라미터로 + 타입을 명시하는 특수 DI 키를 테이블 리터럴에 쓸 때, `=` 뒤 `value`의 + 타입이 실제로 그 제네릭 파라미터로 좁혀지는지 — Luau 타입 솔버가 + "이 계산된 키의 제네릭 인스턴스에 따라 옆 값의 타입이 달라진다"는 + 이질적인(heterogeneous) 매핑을 실제로 풀 수 있는지가 핵심. + + 배경: .claude/base/attribute-plan.md "[실측 필요, M0/M10]" 절 + (2026-08-09 열한 번째 세션에 새로 명시된 항목 — base 문서 자신이 + "미검증"이라고 못박아둔 몇 안 되는 곳). 문서 원문: "Luau 솔버가 이 + 조합을 못 풀면 value가 any로 남을 수 있음 — 단, 타입 추론이 안 + 되더라도 런타임 동작에는 영향 없음". 이 스크립트는 그 예상을 실제 + Luau로 확인하는 것. + + 실행: `luau-analyze 12-type-attribute-generic-key-narrowing.luau` + (또는 luau-lsp로 이 파일을 열어 인라인 진단을 확인 — 사용자가 직접 + luau-lsp로 확인할 예정) + + 참고: 이건 Roblox 실제 SetAttribute API 타입이 아니라, "제네릭 DI 키 + + 테이블 리터럴 값 타입 연동"이라는 메커니즘 자체만 최소로 흉내낸 + 것 — Roblox 전역 타입이 필요 없어서 luau-lsp의 sourcemap 없이도 + 그대로 확인 가능함. +]] + +-- SpecialKey — Attribute<>(name)이 반환하는 "타입이 실린 키" 흉내 +type SpecialKey = { __attributeKeyBrand: T } + +local function Attribute(name: string): SpecialKey + return (nil :: any) :: SpecialKey +end + +-- ===== 시도 1: 동질적(homogeneous) 인덱스 시그니처 — 항상 통과해야 함(비교군) ===== +-- 이 방식은 "이 테이블의 모든 특수 키가 전부 boolean 값이어야 한다"는 +-- 고정된 단일 인스턴스라, 애초에 여러 타입을 섞을 수 없음 — 진짜 검증 +-- 대상이 아니라 대조군. +type HomogeneousParams = { + [SpecialKey]: boolean, +} + +local homo: HomogeneousParams = { + [Attribute("Enabled")] = true, -- 이건 당연히 통과해야 함 +} + +-- ===== 시도 2: 이질적(heterogeneous) — 한 테이블에 boolean/number Attribute를 섞음 ===== +-- 이게 진짜 검증 대상: SpecialKey의 T가 키마다 달라도 값이 그 T로 +-- 각각 좁혀지는가? (TypeScript의 mapped/conditional type이 있어야 되는 +-- 문제 — Luau에 해당 기능이 없으면 아래 셋 중 하나가 일어날 것으로 예상: +-- (a) 두 번째 대입에서 타입 에러, (b) 값 타입이 조용히 any/union으로 +-- 뭉개짐, (c) 테이블 타입 자체를 선언하는 시점에 에러) + +local mixedProps: { [SpecialKey]: any } = {} -- 일단 any로 도피한 버전(항상 통과할 것) +mixedProps[Attribute("Enabled")] = true +mixedProps[Attribute("Count")] = 5 + +-- 진짜 물어볼 질문: 개별 대입 표현식 하나만 놓고 봤을 때, Luau가 +-- `Attribute(name)`의 제네릭 인스턴스화 결과로 옆의 값 리터럴 타입을 +-- 체크/추론해주는지 — 함수 호출 결과 타입과 그 옆 대입값 사이의 관계는 +-- "인덱스 시그니처"가 아니라 그냥 "함수 반환 타입에 맞는 변수 대입" +-- 문제로 좁혀서 아래처럼 직접 테스트: + +local function setAttributeTyped(key: SpecialKey, value: T) + -- 실제로는 여기서 SetAttribute(inst, name, value)를 호출하겠지만, + -- 이 스파이크는 타입 추론 자체만 봄 +end + +setAttributeTyped(Attribute("Enabled"), true) -- T=boolean으로 추론돼 통과해야 함 +setAttributeTyped(Attribute("Count"), 5) -- T=number로 추론돼 통과해야 함 +setAttributeTyped(Attribute("Enabled"), 5) -- <- 여기가 핵심: T=boolean인데 5(number)를 넘김. +-- 이게 타입 에러로 잡히면(기대하는 결과) "제네릭 키 함수 호출 패턴"은 +-- 최소한 함수 인자 형태로는 잘 작동한다는 뜻 — 그럼 테이블 리터럴 +-- `{[Attribute<>(name)] = value}` 안에서도 Luau가 "이건 사실 +-- 위 setAttributeTyped 호출과 같은 형태"로 취급해주는지가 다음 질문. + +print("런타임 실행은 의미 없음 — luau-analyze/luau-lsp 진단만 확인할 것") +print(homo, mixedProps) + +--[[ + 확인 포인트 (luau-analyze / luau-lsp): + 1. `setAttributeTyped(Attribute("Enabled"), 5)` 줄에서 실제로 타입 + 에러가 나는가? — 나면 "함수 인자 형태의 제네릭 키+값 연동"은 + Luau가 지원한다는 뜻. + 2. 위가 통과한다면, 그 다음으로 `mixedProps[Attribute("Enabled")] = + 5`처럼 **인덱스 대입 문법**으로도 같은 체크가 되는지 직접 추가해 + 실험해볼 것(이 파일엔 일부러 안 넣어둠 — `{[SpecialKey]: T}`류 + 제네릭 인덱스 시그니처를 실제로 선언할 수 있는지부터 luau-lsp가 + 에러를 내는지 먼저 볼 것). + 3. 최종적으로 "제네릭 DI 키를 테이블 리터럴 안에서 쓸 때 값 타입이 + 실제로 좁혀지는지"에 대한 결론이 나오면 attribute-plan.md의 + "[실측 필요, M0/M10]" 캐비엇을 그 결과로 갱신할 것 — 안 되는 걸로 + 확인되면 "정적 체크는 `BooleanAttribute`류 정적 타입 패밀리 쪽만 + 신뢰 가능"이라는 문서의 fallback 결론이 확정됨. +]] diff --git a/.claude/luau-test/13-type-ref-preref-subtype.luau b/.claude/luau-test/13-type-ref-preref-subtype.luau new file mode 100644 index 0000000..495aa4b --- /dev/null +++ b/.claude/luau-test/13-type-ref-preref-subtype.luau @@ -0,0 +1,135 @@ +--!strict +--[[ + 검증 대상: 2026-08-09 열한 번째 세션(커밋 f198fd9)에서 뒤집힌 결정 — + `isRef`/`isPreRef`가 "서로 배타적인 형제 브랜드"에서 "Source가 State를 + 만족하는 것과 같은 포함 관계(PreRef가 Ref의 하위 개념)"로 재정정됨. + 이전엔 `isRef(preRefInstance) == false`였는데, 지금은 + `isRef(preRefInstance) == true`로 바뀜. + + 이 파일은 두 부분으로 나뉨: + A) 타입 체크 대상 — `PreRef`가 구조적으로 `Ref`를 만족하는지 + (08번 파일이 Source/State에 대해 검증한 것과 정확히 같은 질문을 + Ref/PreRef에 대해 재검증). + B) 런타임 대상 — `isRef`/`isPreRef` predicate 합성이 문서에 적힌 대로 + 동작하는지, 그리고 `Dispatch/Leaf.luau`의 `(v=Ref)` 매치 핸들러가 + 이제 `isHandlable = isRef(v) and not isPreRef(v)`로 **명시적으로 + 좁혀야만** PreRef를 잘못 삼키지 않는다는 것. + + 배경: .claude/base/bind-system-plan.md의 `Brand` 절 + ("isRef(x)는 그 위에 Brand.get(x)==RefTag를 OR로 얹은 상위 개념")와 + "`(v=Ref)` children 배열 leaf 매치 핸들러... isRef(v) and not + isPreRef(v)로 명시적으로 좁혀야 함" 부분. + + 실행: + A) `luau-analyze 13-type-ref-preref-subtype.luau` (또는 luau-lsp) + B) `luau 13-type-ref-preref-subtype.luau` (런타임 부분은 그냥 통과함, + 타입 에러가 있어도 런타임 실행 자체는 대부분 luau CLI가 그냥 + 진행시켜줌 — 확실히 하려면 A/B를 따로 luau-analyze/luau로 각각 + 돌려볼 것) +]] + +-- ===== A) 타입 체크 대상 ===== + +export type Ref = { + Value: T, + Set: (self: Ref, value: T) -> Ref, + Callback: (self: Ref, fn: (T) -> ()) -> Ref, + Wait: (self: Ref, thread: thread?) -> Ref, +} + +-- PreRef는 "Ref 런타임을 재사용하되 브랜드 태그만 다름"이라고 문서가 +-- 명시함 — 타입도 필드 구성이 완전히 동일해야 자연스러움(브랜드 차이는 +-- 런타임 전용이라 정적 타입엔 안 드러남, 아래서 별도 nominal 표시로만 구분) +export type PreRef = { + Value: T, + Set: (self: PreRef, value: T) -> PreRef, + Callback: (self: PreRef, fn: (T) -> ()) -> PreRef, + Wait: (self: PreRef, thread: thread?) -> PreRef, +} + +local function fakePreRef(default: T): PreRef + return (nil :: any) :: PreRef +end + +-- 시도: PreRef 값을 Ref가 필요한 자리에 그대로 넘길 수 있는가 +local function useAsRef(r: Ref): T + return r.Value +end + +local myPreRef: PreRef = fakePreRef(0) +local viaSubtype: number = useAsRef(myPreRef) -- <- 여기가 luau-analyze 확인 포인트 + +print("A) 타입 체크는 luau-analyze/luau-lsp로 확인 — 런타임은 그냥 통과") +print(viaSubtype) + +-- ===== B) 런타임 대상 — Brand/isRef/isPreRef predicate 합성 ===== + +local Brand = {} +local registry = setmetatable({}, { __mode = "k" }) +function Brand.set(x, tag) + registry[x] = tag +end +function Brand.get(x) + return registry[x] +end + +local RefTag, PreRefTag = {}, {} + +local function isPreRef(x) + return Brand.get(x) == PreRefTag +end +local function isRef(x) + -- 재정정된 합성 — PreRef가 Ref의 하위 개념(OR로 얹음) + return isPreRef(x) or Brand.get(x) == RefTag +end + +local function makeRef() + local self = {} + Brand.set(self, RefTag) + return self +end +local function makePreRef() + local self = {} + Brand.set(self, PreRefTag) + return self +end + +local ref1 = makeRef() +local preref1 = makePreRef() + +print() +print("=== B-1. isRef/isPreRef 기본 동작 ===") +print("isRef(ref1) =", isRef(ref1), "(true여야 함)") +print("isPreRef(ref1) =", isPreRef(ref1), "(false여야 함 — Ref는 PreRef가 아님)") +print("isRef(preref1) =", isRef(preref1), "(true여야 함 — 2026-08-09 재정정의 핵심)") +print("isPreRef(preref1) =", isPreRef(preref1), "(true여야 함)") + +-- Dispatch/Leaf.luau의 (v=Ref) 매치 핸들러 흉내 — PreRef를 잘못 삼키면 안 됨 +local function leafRefHandlerIsHandlable(v) + return isRef(v) and not isPreRef(v) +end + +print() +print("=== B-2. Leaf의 (v=Ref) 핸들러가 PreRef를 잘못 삼키지 않는가 ===") +print("leafRefHandlerIsHandlable(ref1) =", leafRefHandlerIsHandlable(ref1), "(true — 일반 Ref는 처리해야 함)") +print( + "leafRefHandlerIsHandlable(preref1) =", + leafRefHandlerIsHandlable(preref1), + "(false여야 함 — PreRef는 pre-pass가 이미 처리했어야 하고, 이 핸들러가 또 삼키면 안 됨)" +) + +assert(leafRefHandlerIsHandlable(ref1) == true, "일반 Ref가 Leaf 핸들러에서 거부됨 (버그)") +assert(leafRefHandlerIsHandlable(preref1) == false, "PreRef가 Leaf 핸들러에 잘못 잡힘 (버그 — 2026-08-09 재정정이 요구하는 명시적 좁히기 실패)") +print() +print("assert 전부 통과 — isRef(v) and not isPreRef(v) 조합이 기대대로 동작함") + +--[[ + 확인 포인트: + A) luau-analyze/luau-lsp에서 `viaSubtype` 줄이 에러 없이 통과하는가 — + 08번 파일이 Source/State에 대해 확인했던 것과 같은 결론(구조적 + 서브타이핑 성립)이 Ref/PreRef에도 그대로 적용되는지. + B) 런타임 assert가 전부 통과하는가 — 특히 `isRef(preref1) == true` + (뒤집힌 결정 자체)와 `leafRefHandlerIsHandlable(preref1) == false` + (그 뒤집힘 때문에 Leaf 핸들러가 이제 반드시 `not isPreRef(v)`를 + 같이 확인해야 한다는 요구사항)가 실제로 필요한 조합인지. +]] diff --git a/.claude/luau-test/14-type-nilable-default-overload.luau b/.claude/luau-test/14-type-nilable-default-overload.luau new file mode 100644 index 0000000..3d365a9 --- /dev/null +++ b/.claude/luau-test/14-type-nilable-default-overload.luau @@ -0,0 +1,84 @@ +--!strict +--[[ + 검증 대상: `Source(default)`/`Ref(default)`의 `default` 인자를 생략할 수 + 있는 건 오직 `T`가 nilable(`T?`)일 때뿐이라는 캐비엇(2026-08-09 + 열한 번째 세션, 커밋 f198fd9 신규) — "타입으로 막을 수 있으면 막고 + 안 되면 UB로 문서 경고"라고 base 문서가 적어둔 부분을 실제로 타입 + 오버로드로 막을 수 있는지 검증. + + 배경: .claude/base/bind-system-plan.md "[보강, 2026-08-09 열한 번째 + 세션] Source(default)/Ref(default)의 default 인자가 '선택'이라는 + 서술은 정확히는 T가 nil을 포함할 때만 성립함" 절. 문제 상황: + `Ref()`(default 생략)를 만들면 실제 런타임 값은 `nil`인데 + `T=number`(non-nilable)라고 선언하면 타입과 실제 값이 어긋남 — + 특히 `:Callback(fn)`이 등록 즉시 그 시점 값(nil)으로 1회 호출되므로 + 이 어긋남이 바로 드러남. + + 시도할 두 가지 설계: + A) 단일 시그니처 `Ref(default: T?): Ref` — default를 항상 + optional로 열어둠. 이러면 `Ref()`가 타입 에러 없이 + 통과해버려서(캐비엇을 막지 못함) 이게 바로 지금 실제로 벌어지고 + 있는 문제 상황. + B) 오버로드 흉내 — `default: T` 필수 시그니처와 `(): Ref` + 무인자 시그니처 두 개를 함수 타입 교차(`&`)로 합쳐, "생략하면 + 자동으로 반환 타입이 T?로 바뀐다"를 강제할 수 있는지. + + 실행: `luau-analyze 14-type-nilable-default-overload.luau` (또는 + luau-lsp) +]] + +export type Ref = { + Value: T, + Set: (self: Ref, value: T) -> Ref, +} + +-- ===== A) 단일 시그니처 — default가 항상 optional(현재 캐비엇이 실제로 벌어지는 형태) ===== + +local function RefA(default: T?): Ref + return (nil :: any) :: Ref +end + +local refA1: Ref = RefA(5) -- 정상 — 통과해야 함 +local refA2: Ref = RefA() -- <- 문제의 그 케이스: default 생략, T=number(non-nilable)인데 +-- 통과해버리면(기대되는 나쁜 결과) 이게 바로 캐비엇이 막고 싶어하는 구멍 — +-- 런타임엔 .Value가 nil인데 타입은 number라고 거짓말하는 상태가 됨. + +-- ===== B) 오버로드 흉내 — 함수 타입 교차로 "생략 시 T?" 강제 시도 ===== + +type RefCtorOverload = ((default: T) -> Ref) & (() -> Ref) + +local RefB: RefCtorOverload = (nil :: any) :: RefCtorOverload + +local refB1: Ref = RefB(5) -- 정상 — 첫 번째 오버로드(T=number)로 통과해야 함 +local refB2 = RefB() -- 두 번째 오버로드로 잡혀야 함 — 추론된 타입이 Ref 류가 될 것으로 예상 +-- 아래가 진짜 확인 대상: refB2를 non-nilable Ref에 대입하면 막히는가? +local refB2_annotated: Ref = RefB() -- <- 이것도 에러가 나야 "막혔다"고 할 수 있음 +-- (T가 추론 컨텍스트에서 number로 잡히면서 동시에 "무인자 오버로드라 T? +-- 여야 한다"는 두 요구가 충돌하는지가 관건 — 충돌해서 에러가 나면 성공, +-- 조용히 number로 통과해버리면 오버로드로도 못 막는다는 뜻) + +-- 대조군 — nilable로 명시하면 항상 통과해야 함(오버로드가 정상 케이스는 안 막는지 확인) +local refB3: Ref = RefB() + +print("런타임 실행은 의미 없음 — luau-analyze/luau-lsp 진단만 확인할 것") +print(refA1, refA2, refB1, refB2, refB2_annotated, refB3) + +--[[ + 확인 포인트: + 1. A) `refA2 = RefA()` 줄이 에러 없이 통과하는가? (예상: 통과함 — + 이게 바로 "타입으로 못 막는" 현재 상태를 보여주는 대조군) + 2. B) `refB2_annotated: Ref = RefB()` 줄이 에러가 나는가? + - 에러가 나면: 오버로드 방식으로 실제로 이 캐비엇을 타입 레벨에서 + 막을 수 있다는 뜻 — base 문서의 "타입으로 막을 수 있으면 막을 것" + 을 실제 설계로 채택할 근거가 생김, `Source`/`Ref` 생성자를 + 이 오버로드 모양으로 다시 쓸 것. + - 에러가 안 나면(조용히 통과): Luau의 제네릭 함수 교차 타입 + 오버로드가 이 정도로 정교한 추론을 못 한다는 뜻 — 문서의 + "안 되면 UB로 경고"가 fallback이 아니라 사실상 유일한 선택지로 + 확정됨. + 3. `refB3`(nilable로 명시한 정상 케이스)는 항상 통과하는가 — 오버로드 + 자체가 정상 사용까지 막아버리는 부작용은 없는지 확인. + 4. 이 결과가 나오면 `bind-system-plan.md`의 해당 캐비엇 절에 "실측 + 결과"로 반영할 것 — 지금은 "타입으로 막을 수 있으면 막고"라는 + 조건문으로만 적혀 있어서 결론이 필요함. +]] diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md new file mode 100644 index 0000000..8826776 --- /dev/null +++ b/.claude/luau-test/README.md @@ -0,0 +1,118 @@ +# .claude/luau-test — M0 착수 전 실 Luau 기술검증 스파이크 모음 + +**[2026-08-09 이동]** 처음엔 레포 루트 `luau-ignoreme/`(git 자동 제외 +폴더)에 만들었으나, 사용자가 직접 확인해볼 만한 검증 코드라 커밋해서 +레포에 남기기로 함 — `.claude/luau-test/`로 옮기고 일반 추적 대상으로 +전환(더 이상 `*-ignoreme*` gitignore 패턴에 안 걸림). 위치만 바뀌었을 뿐 +내용/역할은 그대로 — 아직 M0가 공식 시작 전인 상태에서 미리 돌려보는 +사전 검증 스파이크 모음. + +## 왜 이게 필요한가 + +`.claude/base/`와 `ROADMAP.md` M0가 "추론만으로 확정하고 실제 Luau 코드로 +부딪혀본 적 없는 것"으로 명시적으로 지목한 항목들, 그리고 이후 세션들에서 +"M0/M2 스파이크 검증 목록에 추가됨"으로 흩어져 있던 항목들을 모아 각각 +독립 실행 가능한 스크립트로 만들었음. **내가(에이전트) 직접 실행은 못 +했음** — 이 환경엔 `luau`/`luau-analyze` 바이너리가 없어서, 전부 사용자가 +직접 돌려보고 결과를 알려줘야 함. + +각 파일 맨 위 주석에 다음이 전부 적혀있음: 뭘 검증하는지, 어느 base 문서/ +ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해야 하는지. + +## 실행 환경 세 갈래 + +| 환경 | 필요한 것 | 해당 파일 | +|---|---|---| +| **순수 Luau CLI** (`luau`) | [luau-lang/luau 릴리즈](https://github.com/luau-lang/luau/releases)의 `luau` 인터프리터, 또는 `lune` | 01, 02, 03, 04, 05, 06(런타임 부분), 07, 11, 13(런타임 부분) | +| **Luau 타입체커** (`luau-analyze` 또는 `luau-lsp`) | 같은 릴리즈에 포함된 `luau-analyze`, 또는 `luau-lsp analyze`/에디터 인라인 진단 | 06(타입 부분), 08, 09, 12, 13(타입 부분), 14 | +| **Roblox Studio** | 별도 계정으로 로그인(`HUMAN_TODO.md` 1번, `SAFETY.md` 준수) | 10 | + +**12/13/14는 특히 `luau-lsp`로 확인해달라고 요청받은 것들** — `luau-analyze`도 +같은 타입 솔버를 쓰므로 원리적으로는 같은 결과가 나와야 하지만, `luau-lsp`가 +에디터에서 인라인으로 에러 위치/메시지를 보여줘서 "정확히 어느 표현식이 +막히는지"를 확인하기 더 편함. sourcemap/Roblox 전역 타입 없이도 그대로 +확인 가능하게 만들어뒀음(전부 순수 Luau 타입 문법만 씀). + +로컬에 `luau`/`luau-analyze`가 없으면 위 GitHub 릴리즈에서 플랫폼에 맞는 +바이너리를 받으면 됨. Roblox Studio 파일은 스크립트 내용을 그대로 +`ServerScriptService`에 붙여넣은 `Script`로 만들어 Play(F5)하면 됨. + +## 파일 목록 — 뭘 검증하는지 요약 + +| 파일 | 검증 대상 | 근거 문서 | +|---|---|---| +| `01-two-pass-array-hash-order.luau` | 배열 파트(children/Ref) 먼저, 해시 파트(프로퍼티/이벤트) 나중이라는 두 패스 순회 계약 | `bind-system-plan.md` "props 순회 순서", ROADMAP M0-4 | +| `02-none-sentinel-vs-nil-holes.luau` | **[2026-08-09 커밋 f198fd9 반영해 전면 재작성]** 순서가 중요한 배열(PreRef pre-pass, sourceList)은 `None` 소진이 맞고, 순서가 안 중요하고 재사용이 필요한 배열(Ref 콜백/대기자)은 `nil`+슬롯 재사용이 맞다는 최종 구분 + `None`을 잘못 쓰면 배열이 무한정 자라는 버그의 정량적 재현 | `bind-system-plan.md` "왜 None이 아니라 nil인가"(2026-08-09 열한 번째 세션 최종 정정), ROADMAP M0-4 | +| `03-recursive-store-bind-dispatch.luau` | `process`/`retract` 재귀 재-dispatch 기본 모델, 우선순위 스캔 | `bind-system-plan.md` "확정된 디스패치 모델", ROADMAP M0-3 | +| `04-dispatch-chain-retractUnder.luau` | `Dispatch` 체인 + `retractUnder`가 다단(A→B→C) 재-dispatch에서 정확한지 | `bind-system-plan.md` "Dispatch 체인", 2026-08-08 세 번째 세션 | +| `05-store-state-diamond-propagation.luau` | push-invalidate/pull-recompute가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지 | ROADMAP M0-1 | +| `06-component-boundary-nil-hole-props.luau` | `props.Modifier or None` 관용구가 컴포넌트 경계 nil-hole을 막는지 + `Params` 타입 체크 | `component-composition-plan.md` "필수 관용구", ROADMAP M0-5 | +| `07-relate-weak-table-gc.luau` | `Relate`의 lazy 서브테이블 생성 + weak-key GC가 실제로 동작하는지 | `relate-plan.md` "M2 착수 시 실측 확인" | +| `08-type-source-satisfies-state.luau` (타입체크 전용) | `Source`가 `State`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `store-semantics.md` "검증 필요", ROADMAP M0-2 | +| `09-type-modifier-overridden-subtype.luau` (타입체크 전용) | `FrameModifier <: GuiObjectModifier`처럼 서브타입 관계인 Modifier를 `Overridden`으로 섞을 때 타입이 통과하는지 | `modifier-plan.md` 9-2번, ROADMAP M7 | +| `10-roblox-studio-checks.server.luau` (Studio 전용) | (A) `bindLifetime`/`unbindLifetime`/`canExecute`/`canBound`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복 | `lifecycle-pattern.md`, `bind-system-plan.md` "이중 바인딩 금지", CLAUDE.md 2026-08-06 세션, `debug-tooling-plan.md` | +| `11-modifier-illegal-value-error.luau` | Modifier 필드에 Ref/PreRef/Observer/Effect/Slot/Modifier가 들어오면 즉시 error, State/Source가 확정하는 값이 Modifier면 즉시 error(2026-08-09 세션에 "UB"에서 전환된 규칙) | `modifier-plan.md` "핸들러 계층 값 즉시 error" 절 + 7번 절 | +| `12-type-attribute-generic-key-narrowing.luau` (타입체크 전용) | `[Attribute<> "name"] = value`처럼 제네릭 DI 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) | +| `13-type-ref-preref-subtype.luau` | (A, 타입) `PreRef`가 `Ref`를 구조적으로 만족하는지, (B, 런타임) `isRef`/`isPreRef` 합성이 재정정대로 동작하는지(`isRef(preRefInstance)`가 이제 `true`) + Leaf 핸들러가 `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 하는 이유 | `bind-system-plan.md`의 `Brand` 절(2026-08-09 열한 번째 세션 재정정) | +| `14-type-nilable-default-overload.luau` (타입체크 전용) | `Source(default)`/`Ref(default)`의 `default` 생략이 `T`가 nilable일 때만 안전하다는 캐비엇을, 함수 오버로드(교차 타입)로 실제로 타입 레벨에서 막을 수 있는지 | `bind-system-plan.md` "[보강, 2026-08-09 열한 번째 세션]" 절 | + +## 갱신 이력 + +**1차 (2026-08-09 저녁, `8169b90`~`5836c2d` 반영)**: 01/02/05/06/07/08/09는 +검증 대상 API가 그대로였고, `03`/`04`에 참고 노트 추가, `10` Part A 갱신 +(canBound/unbindLifetime 반영), `11` 신규 추가. + +**2차 (2026-08-09 커밋 `f198fd9`, "중간검토(질문 모드)에서 발견된 설계 +결함 다수 수정" 반영)** — 사용자가 직접 `.claude/base/` 전체를 훑으며 +찾은 정정들 중 이 폴더(당시 `luau-ignoreme/`)에 영향 있는 것만: + +- `02`: **전면 재작성.** 이전 버전은 "Ref 콜백/대기자 배열도 None으로 + 소진해야 한다"고 잘못 적어뒀는데, 이게 실제로는 무한 성장 버그였음이 + 드러나 `nil`로 되돌아감(순서가 안 중요하고 슬롯 재사용이 필요한 + 배열은 `nil`, 순서가 중요한 배열(PreRef pre-pass/sourceList)은 + 계속 `None` — 두 카테고리로 나눠 각각 재현). +- `12`/`13`/`14`: **신규 추가.** 사용자 요청으로 "타입 관련 실측 필요 + 항목, 특히 luau-lsp로 확인해야 하는 것"을 새로 찾아 만듦 — Attribute + 제네릭 DI 키의 값 타입 narrowing(12), Ref/PreRef 구조적 서브타입 + + `isRef`/`isPreRef` 재정정(13), Source/Ref의 nilable-default 캐비엇을 + 오버로드로 막을 수 있는지(14). 셋 다 base 문서가 "미검증"/"실측 필요" + 로 스스로 표시해둔 지점이거나(12, 14) 이번 f198fd9에서 뒤집힌 결정 + (13)이라 기존 파일 중 커버하는 게 없었음. +- `01`/`03`~`11`(위 02 제외)은 f198fd9의 다른 변경(Slot CRUD 인덱스 + 기준 전환, Source 리프 직접 바인딩 정상 경로 재확인, Dispatch 직접 + 호출 UB 명시, Tag retract 전제 명시, Attribute 타입 파라미터화 확정 + 등)과 대조해본 결과 검증 대상 API에 영향 없어 안 건드림. + +**3차 (2026-08-09, 폴더 이동)**: `luau-ignoreme/` → `.claude/luau-test/`로 +이동, git 추적 대상으로 전환. 내용 변경 없음 — 경로 참조하는 문구만 +동기화. + +## 결과 확인 후 할 일 + +각 파일 결과를 알려주면, 실제로 걸리는 부분이 있는지 보고 필요하면 +`.claude/base/` 문서를 그 자리에서 고침(ROADMAP.md M0 통과 기준 그대로: +"안 되면 여기서 관련 base/ 문서부터 고치고 재시도"). 특히: + +- `08`/`09`가 luau-analyze에서 에러를 내면 어떤 정확한 에러 메시지인지가 + 다음 타입 설계 방향(펼쳐 쓰기 vs `any` fallback)을 결정하는 데 중요함. +- `07`이 예상대로 GC가 안 되는 것처럼 보이면(90개 안 죽는 것 같으면), + `collectgarbage("count")` 수치 변화를 같이 알려줄 것 — 정확한 판정이 + 어려운 항목이라 참고 신호로만 쓸 것. +- `10`의 A 섹션에서 만약 `warn`이 실제로 뜨면(ClassName Changed가 + 발화함), gcconn 트릭 전체를 재검토해야 하는 심각한 발견이니 바로 알려줄 것. + A-2(재-bindLifetime 허용 여부)가 실패하면 `canBound`/`unbindLifetime` + 설계 자체를 재검토해야 함. +- `11`은 전부 PASS가 기대값 — FAIL이 하나라도 있으면 어느 케이스인지 + 그대로 알려줄 것(특히 "변환 함수가 반환한 값" 케이스는 놓치기 쉬운 + 경로라 실제 구현에서도 잘 짜였는지 중요한 신호). +- `02`의 Part B-2("None + table.insert" 대조군)가 실제로 배열 길이 1000까지 + 자라는 게 확인되면 사용자가 찾은 버그가 정량적으로 재현된 것 — 반대로 + 안 자란다면 정정 근거 자체를 재검토해야 하니 꼭 알려줄 것. +- `12`/`14`는 **어느 쪽으로 나와도 유용한 정보** — 통과하면 그 타입 + 패턴을 실제 설계로 채택, 실패하면 `any`/정적 타입 패밀리로 fallback한다는 + 각 파일의 결론 그대로 base 문서에 반영하면 됨. 정확한 luau-lsp 에러 + 메시지(어느 줄, 어떤 문구)를 그대로 붙여서 알려주면 다음 문서 갱신이 + 빠름. +- `13`은 A(타입)/B(런타임) 둘 다 확인해줄 것 — B의 assert가 실패하면 + `Dispatch/Leaf.luau` 설계(`isRef(v) and not isPreRef(v)`) 자체가 + 잘못 짜인 것이니 우선순위 높게 알려줄 것. diff --git a/.claude/question.md b/.claude/question.md new file mode 100644 index 0000000..14ce370 --- /dev/null +++ b/.claude/question.md @@ -0,0 +1,276 @@ +# 확인/결정 필요 목록 + +**2026-08-04 세션 말미에 전체 재정리함.** 예전엔 라운드(1차~6차)별로 문서가 +계속 쌓이면서 순서가 시간순도 우선순위순도 아니게 됐고, 이미 해소된 라운드 +기록이 새로 열린 질문보다 위에 있는 등 혼동을 유발했음(문서 감사에서 발견). +그 상세 히스토리는 지우지 않았음 — git log로 이 파일의 이전 버전을 보거나, +각 `base/`/`research/` 문서 안의 라운드 표시("2026-08-04 3차 라운드" 등)를 +따라가면 그대로 남아있음. 이 문서는 이제 **"지금 열려있는 것" 우선으로만** +구성. + +## 지금 열려있는 것 (우선순위순) + +### 0. 추가 프리미티브 필요성 — 사용자 요청, 대부분 수렴(2026-08-06~07) + +사용자 질문: "다른 독립 프리미티브나 종속 파생 데이터는 뭐가 더 필요할 것 +같나요. 이것만으로 이 프로젝트는 충분하다 생각해요?" — 여러 서브에이전트 +조사 + 사용자와 라이브 논의로 계속 수렴 중. **2026-08-07 문서 정리에서 +확정/기각된 항목은 `research/additional-primitives-plan.md`에서 +분리됨**: Blocker → `base/blocker-plan.md`, Effect → `base/effect-plan.md`, Batch → +`archive/batch-rejected.md`, Context(+레이어드 Store) → `archive/ +context-rejected.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)`도 + 등록 즉시 1회 실행되는 것으로 확정. 상세는 `base/effect-plan.md`의 + "해결됨" 절과 `base/bind-system-plan.md`의 Observer 절. +- Untrack/Suspense/Error Boundary/Readonly는 조사 결과 새 프리미티브 없이 + 기존 설계·Lua 자체 기능으로 이미 충분한 것으로 판단(`research/ + additional-primitives-plan.md` "빈 자리 아닌 것" 절). +- **[백로그, 2026-08-09 여섯 번째 세션 추가, 미착수]** `Slot():Single(state, + updateFn?)` — `:List`의 key-map 없이 "0개 아니면 1개"만 다루는 가벼운 + 편의 메소드. `base/slot-plan.md` "백로그 — `Slot():Single(...)`" 절. + +### 1. 용어 정리 (사용자 요청, 진행 중) + +사용자 원 메모: "quad는 register라던가 좀 부정확하거나 느낌이 바로 와닿지 +않던 용어들이 많음 — 전체적 용어를 보고 생각해볼래? 제안을 줘, 나도 같이 +볼게." 1차 제안 완료, 아래는 우선순위순 요약 — 최종 판단은 사용자와 계속 +논의 필요: + +- **`State`(1순위, 위험도 높음)**: 지금 정의는 "읽기 전용, 파생/캐시 뷰"인데 + React/Vue 등 업계 전반에서 "state"는 거의 항상 "쓸 수 있는 로컬 슬롯"을 + 뜻함 — 처음 보는 사람이 정반대로 오해할 위험이 큼. `Computed`/`Derived` + (Vue `computed()`, Svelte 5 `$derived`가 정확히 같은 의미로 씀)가 실제 + 의미에 더 맞아 보임. 단, v1의 "register"를 이미 한 번 "State"로 리네임한 + 지 얼마 안 됐다는 점 고려 필요. +- **`DI`(Declarative Instance, 1순위)**: "Dependency Injection"의 업계 + 표준 축약어와 완전히 겹침 — 4차 라운드에서 이미 한 번 실제로 오해가 + 있었던 전례(`base/bind-system-plan.md`의 "인스턴스 생성" 절 참고). + **파급 효과(2026-08-06 추가)**: `DI`가 리네임되면 `DI.FrameModifier`류 + Modifier 클래스별 타입 프리픽스도 같이 바뀌어야 함 — `DI` 리네임 논의 + 때 이 연쇄까지 같이 고려할 것. **(2026-08-08 추가)** 사용자가 `D`(Declarative + 만 남김)로 축약하는 안을 제안 — 근거: (1) "Instance" 전용 개념이 아니라 + quad-* 전반의 declare 요소로 확장해도 되는 이름, (2) 엔진 종속 없이 다른 + 백엔드에서도 재사용 가능, (3) 어차피 `D.FrameModifier`류 타입 프리픽스가 + 길면 못 쓰므로 짧아야 한다는 실용적 제약. 아직 최종 확정 아님 — 다음 + 세션에서 마저 논의(한 글자 식별자의 검색성/자기설명력 트레이드오프를 + 문서에서 어떻게 보완할지도 같이). +- **[해소됨, 2026-08-08 세션]** `PerInstanceState` — 이름 문제 자체가 없어짐. + `State`와 이름이 겹쳐 혼동 유발하던 그 유틸은 `Relate`로 대체·정식 + 승격됨(`base/relate-plan.md`) — 이름도 이미 사용자 확정("Relate 괜찮아요"), + `State`와 안 겹침. +- **`Slot`(2순위)**: Vue의 "slot"(콘텐츠 주입 지점)과 이름은 같지만 의미가 + 다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이 + 헷갈릴 수 있음. +- **`canExecute`(3순위, 사소함)**: 실제로 "이 핸들이 아직 살아있나" 확인인데 + 이름이 범용 권한 체크처럼 들림 — `isAlive` 쪽이 더 직접적이라는 제안이 + 있었으나, **(2026-08-08 재검토)** `isAlive`는 top-level `isX` 계열 + (`isState`/`isRef`/`isPreRef`/`isModifier`/`isObserver`류 — 전부 타입 + 판별자)과 접두어가 겹쳐 "이것도 타입 체크인가" 오해를 유발할 수 있다는 + 점이 지적됨. `canExecute`는 타입이 아니라 liveness(생존 여부)를 묻는 + 질문이라 `is`보다 `can` 계열 접두를 유지하는 쪽이 낫다는 방향으로 사용자가 + 기욺 — 여전히 미확정, 다음에 `can`으로 시작하는 구체 대안(예: `canRun`)을 + 같이 검토할 것. +- **[해소됨, 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` "이중 바인딩 + 금지" 절 참고. +- **`Brand`(3순위, 사소함, 2026-08-07 여덟 번째 세션 추가)**: 런타임 + nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`, `isState`를 + 10종 branded 타입 전부로 일반화) — `bind-system-plan.md`의 `Brand` + 절에서 동작/구현 방식은 확정, "OOP 인스턴스의 클래스명을 얻는 느낌"을 + 전달할 더 나은 이름이 있는지가 열린 질문(사용자가 직접 제기) — `Tag`는 + 이미 quad-roblox의 `CollectionService` 래퍼로 쓰여서 이름 충돌, 후보로 + "type namespace"류를 사용자가 검토했으나 미확정. **(2026-08-08 재확인)** + 사용자가 다시 짚었지만 여전히 미정. +- **[해소됨, 2026-08-08 세션]** `Ref`/`PreRef`/`Peek`/`isState`(구 + `Override`는 이미 `Overridden`으로 별도 확정) — 전부 현재 이름 그대로 + 유지로 확정. `Ref`는 "지연 없는 확정된 값 박스"라는 정의를 재확인(leaf + 노드를 담는 용도로도, leaf 노드에 바인딩하는 용도로도 쓰임 — 넓어진 + 정의에도 여전히 맞음), `PreRef`는 더 나은 대안이 안 보여 그대로, + `Peek`/`isState`는 이미 잘 맞는다고 재확인. +- **[해소됨, 2026-08-08 세션]** `None`/`NoneHandler` — `Undefined`/`Null`/ + `Nothing`도 검토했으나 기각(`Null`은 보통 "포인터가 비어있음"을 뜻해 + "값이 없음"이라는 의도와 안 맞는다는 게 이유), `None`/`NoneHandler` + 그대로 확정. +- **[해소됨, 2026-08-08 세션]** "프로바이더" → **`Handler`로 확정** — + `base/module-lifecycle-plan.md`가 이미 [해소됨]으로 표시해뒀던 걸 + 이 목록에 반영 안 하고 있던 stale 항목. `Processor`는 계약 메소드 이름 + 자체가 `process`라 "그 안에 또 process가 있어" 눈에 걸리고, `Provider`는 + `canProvide`처럼 "뭔가를 공급한다"는 늬앙스라 실제로는 값을 처리/반응하는 + Handler의 동작과 안 맞으며 React `Context.Provider`류 맥락 패턴과도 헷갈릴 + 수 있고, `Plug`는 "꽂힌다"는 늬앙스는 맞지만 "값을 처리한다"는 의미가 + 없어 기각 — `Handler`가 계약(`isHandlable`/`process`/`retract`) 전체를 + 가장 정확히 담는다는 사용자 재확인. 근거를 `base/module-lifecycle-plan.md` + "프로바이더" 절에 보강 완료. +- **이미 지나간 사례로 참고**: `register`(v1) → `State`(v2) 리네임은 + "모호함"은 풀었지만 "다른 뜻으로 이미 쓰이는 단어"라는 새 문제를 만든 + 셈 — 이번 정리에서 같은 패턴을 조심할 것. +- `Store`/`Source`/`Modifier`/`Ref`/`PreRef`/`Peek`/`isState`/`Handler`/ + `None`/`NoneHandler`/`process`/`retract`/`isHandlable`은 업계 선례와 + 잘 맞거나 이미 신중하게 결정된 이름들이라 특별한 문제 없음. +- **`Tag`/`Added`/`Removed`/`Merged`(3순위, 사소함, 2026-08-08 세 번째 + 세션 array-part 값 객체 재설계 때 확정된 API 표면)**: `base/tag-plan.md`가 + "열린 질문 없음, 값 모양/메커니즘/retract/패키지 배치 전부 확정, 이름 + 자체만 용어 정리 대상"이라고 명시해뒀으나 이 목록에 반영이 안 돼 있던 + 누락 — 이번에 추가. `Tag`는 Roblox `CollectionService`가 쓰는 용어와 + 1:1 대응이라 그 자체로는 무난해 보이지만, 위 `Brand` 항목(97-99행)에서 + "`Tag`가 이미 이 뜻으로 쓰이고 있어서 충돌"이라는 이유로 `Brand`의 + 대안 이름 후보에서 제외됐다는 점은 참고할 것 — 두 이름이 같은 코퍼스 + 안에서 공존 가능한지도 같이 검토 대상. + +### 2. 구현 착수 직전 감사 결과 (2026-08-06 신설, M0 착수 전 확인 권장) + +`research/pre-implementation-audit.md` — `base/` 전체를 M0 착수 직전 +시점에서 모호성/지연결정리스크/단순화후보 세 렌즈로 재감사한 결과. 총 +11개 우선순위1(구현 중 바로 부딪힐 가능성 높음) + 11개 우선순위2(지금 +정해두면 싼 지연리스크) + 2개 단순화후보. 전체는 그 문서 참고, 특히 +사용자 판단이 필요한 것 위주로 요약: + +- **[해소됨, 2026-08-10 세션]** Tween.luau가 "범용 store-bind 캐치올 + 핸들러"의 유일한 예시로 서술됨 — Tween을 독립 Dispatch 핸들러에서 + 값-레벨 래퍼(`Tween`, PropertyHandler가 소비)로 재설계해 해소. + 범용 State/Source 언랩은 `Dispatch/StoreBind.luau` 하나뿐, Tween 여부 + 판단은 완전히 별개(`research/tween-plan.md` 전면 재작성, 우선순위1-1 + 해소). +- **[해소됨, 2026-08-09 세션]** `State`와 Ref/Slot이 Modifier + 필드에 들어가는 것 — 이제 둘 다 `isX` predicate 기반 명시적 `error`로 + 통일(`base/modifier-plan.md` 4번/7번 절, `base/store-semantics.md` + "따름정리" 절). Luau 타입 차단은 "되면 좋은 보너스"로 격하되어 더 + 이상 필수 검증 항목 아님 — 문서모순 절 + 우선순위2-2도 갱신 완료. +- ~~`props.Modifier`/`props.Ref` forwarding 관례가 Lua 배열 리터럴 + nil-hole 함정에 그대로 노출됨~~ — **반영 완료(2026-08-07 열 번째 + 세션)**. `props.Modifier or None`/`props.Ref or None` 관용구를 필수로 + 확정(`base/component-composition-plan.md` "필수 관용구" 절) — M0에선 + 이 관용구 자체가 타입/런타임 양쪽에서 문제없이 동작하는지만 검증. +- **~~`canExecute`/`Connected`의 실제 구현 방식이 미확정~~ — 반영 완료 + (2026-08-08 세션)** — 우선순위1-6 해소. `bindLifetime(inst,value)`/ + `canExecute(inst,value)` 탑레벨 함수로 확정(네임스페이스 안 씀, + `LifetimeHandle.luau`는 이 둘의 인터페이스만 갖고 quad-roblox가 구현 + 주입), 시그니처는 `(handle)`이 아니라 `(inst, value)` 2-인자로 재정정 + (Observer 자신의 `Subscribed` 상태를 먼저 보고, 그 다음 `inst`의 공유 + gcconn을 봄 — 두 조건이 독립적이라 하나로 못 뭉침). gchold 저장소는 + 새 프리미티브 `Relate`(`base/relate-plan.md`) 위에 구현 — `base/ + lifecycle-pattern.md`의 "`bindLifetime`/`canExecute` — 확정" 절 참고. +- **~~`LifetimeHandle` 인터페이스가 M8에 배치돼 있지만 M4/M6이 이미 그걸 + 필요로 함(로드맵 순서 역전)~~ — 반영 완료(2026-08-07 세 번째 세션)**: + `LifetimeHandle`/`Relate` 인터페이스(타입만)를 `ROADMAP.md` + M2로 옮기고, quad-roblox 실 구현만 M8에 남김 — 우선순위1-9 해소. +- **[해소됨]** retract 시 "이전 핸들러" 추적 책임 소재 — Dispatch 체인 + (`chains`)+`Dispatch.retractUnder`로 2026-08-08 세 번째 세션에 이미 + 해소(`pre-implementation-audit.md` 1-2, `bind-system-plan.md` "Dispatch + 체인" 절). **[해소됨, 2026-08-09 세션]** `:Compute`의 `previous` 인자 + 오버엔지니어링 의심도 기각(`bind-system-plan.md` "previous" 절, + `pre-implementation-audit.md` 3-1). **[해소됨]** UI shorthand의 기존 + UICorner 매칭 기준도 `base/ui-shorthand-plan.md`에 이미 확정 반영돼 + 있던 것을 이번에 `pre-implementation-audit.md` 2-11에도 해소 표시로 + 동기화. **[해소됨, 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)`)의 패키지 배치 — 원래 제안대로 + `quad-base`, `Dispatch/Leaf.luau`(이미 있던 `Dispatch/StoreBind.luau`와 + 같은 층위)로 확정. Dispatch 자체가 프리미티브가 아니라 탑레벨 싱글톤이고 + base 기본 핸들러와 quad-roblox 백엔드 핸들러가 같은 `Dispatch.addHandler` + 레지스트리를 공유한다는 결론과 함께 나온 것 — `base/bind-system-plan.md` + "Dispatch는 프리미티브가 아니다" 절, `base/architecture.md` 소스트리 참고. + +### 3. 낮은 우선순위 + +- `research/existing-instance-bind-plan.md` — 스코프 논의만 필요, 구현 + 착수를 막지 않음. +- **v1 `objectListClass.__newIndex` 오타 기능의 재현 테스트 필요** — + `reference/quad-v1-architecture.md`에 남겨진 v1 내부 동작 확인 사항, 마이그레이션 + 가이드 작성 시점에 필요. 지금은 그냥 백로그로만 기록. +- **[해소됨, 2026-08-09 여섯 번째 세션]** 여러 Slot이 형제로 섞일 때 + 순서 보장 — `Dispatch.setLength`/`Dispatch.setOffsetSource` + 형제별 + 개수 누적합을 `LayoutOrder`에 리액티브 바인딩하는 메커니즘으로 확정, + DOM류 물리 순서 백엔드에도 같은 base 로직이 재사용됨(backend Handler의 + "offset 변경 시 할 일"만 no-op으로 갈림). 상세는 `base/ + bind-system-plan.md` "Length/Offset" 절, `base/slot-plan.md` "여러 + Slot이 섞일 때 순서 보장" 절. **같은 구현 시점에 같이 확인할 것 + (2026-08-06 추가, 아직 안 풀림)**: Slot이 quad 밖(v1 compat 등)에서 + 만들어진 임의 Instance를 동적 배열 원소로 받을 수 있는지, retract 시 + foreign Instance를 어떻게 다루는지 — `research/v1-compat-plan.md` 7-3 + 참고. +- **`quad-debug` 세부 API 이름** — `research/debug-tooling-plan.md` 참고. + 채널 실현 가능성(BindableEvent/Function이 플러그인↔Play 중 게임 경계를 + 넘는지)까지 사용자가 Studio에서 직접 실측 검증 완료 — 기술적 불확실성은 + 다 해소됨, 남은 건 세부 API 이름뿐("이벤트 함수가 self로 instance를 + 읽는 게 quad 관습"이라는 언급은 2026-08-06 후속 세션에서 해소 — + 채택 안 함으로 확정, `base/bind-system-plan.md` "이벤트 핸들러는 + self(Instance)를 받지 않는다" 절 참고). 사용자가 "quad 개발 완료 전엔 + 착수 못 함"으로 직접 후순위 지정한 건 여전함 — base 설계(M2 Dispatch/ + M3 Source/M5 DI 생성자) 시점에 훅 확장 지점만 고려해두면 됨. +- **문서화 전략(UI 네이밍 컨벤션, Store 부작용을 게임 시스템에서 쓰는 + 패턴)** — `research/documentation-plan.md`(뼈대만). 정식 백로그 항목으로 + 올릴지, 착수 시점을 언제로 볼지 사용자 판단 필요. +- **[해소됨, 2026-08-09 열한 번째 세션]** Attribute 특수 키 타입 + 파라미터화 — `[Attribute<> "name"]` 제네릭 스타일과 + `[BooleanAttribute "name"]` 타입별 정적 생성자 패밀리 **둘 다 채택으로 + 확정**(내부 구현 동일, 호출부 표기만 다름). `base/attribute-plan.md` + 참고 — 제네릭 파라미터가 `=` 뒤 값 타입까지 좁혀주는지는 M0/M10에서 + 실측 필요(안 돼도 런타임엔 영향 없음). +- **v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat`** — + `research/v1-compat-plan.md`(신규, 2026-08-06, 두 차례 후속 논의로 수렴). + 방향 확정: v1을 그대로 병행 실행 + 경계에서만 `state:Observer()`(lazy + 포기)로 값을 리졸브해 v1 프로퍼티에 재대입하는 브리지, v2→v1 단방향만 + (양방향 불필요로 확정), 패키지명 `quad-roblox-v1-compat`으로 확정(소스 + 트리에 세 번째 패키지로 추가될 예정). v2-in-v1/v1-in-v2 두 임베딩 방향 + 모두 기술적 근거와 안전 규칙까지 정리됐으나(문서 7번), **Slot이 foreign + Instance를 어떻게 다루는지만 Slot 코어 구현 시점까지 결정 불가로 남음** + (위 "여러 Slot이 형제로 섞일 때 순서 보장" 항목과 같은 시점에 확인). + 그 외 §8의 세부 항목(v1 자기 루트의 `Destroying` 자기청소 여부, + `registerClass` 체이닝 기능 브릿징 필요성)은 문서 자체가 "지금 결정 + 불필요"로 표시해둠 — 위 Slot 항목과 별도로, 실제 compat 레이어 구현 + 시점에 `research/v1-compat-plan.md` §8을 다시 열어 확인. +- **`framework-comparison-findings.md`의 두 남은 개선 후보 반영 여부** — + `research/framework-comparison-findings.md` "다음 단계" 절. use-after-destroy + 검증 안전망 부재, `:With`의 정적 의존성(동적 With 미지원) 두 가지를 실제 + 설계에 반영할지, 반영한다면 M0 스파이크 때 같이 검증할지 나중 최적화 + 패스로 미룰지 — 아직 사용자 판단 전. + +## 참고: 지금까지 확정된 것 (요약) + +전부 `base/`에 문서화되어 더 이상 열려있지 않음 — 상세 근거/논의 과정이 +필요하면 아래 문서를 열어볼 것(라운드별 세부 히스토리는 각 문서 안에 +"2026-08-04 O차 라운드" 식으로 표시돼 있음): + +| 주제 | 문서 | +|---|---| +| 전체 아키텍처 결정(디스패치 모델, DOMless, 태그/Ref, Signal 미채택 등) | `base/architecture.md` | +| Store/State/Source 온톨로지, 인스턴스 생성/이벤트 인체공학, Ref, 남은 API 이름 | `base/bind-system-plan.md` | +| Store 부작용 허용, `:With`+`:Compute`, dot-access 문법 | `base/store-semantics.md` | +| 프로바이더 패턴, bind/store 구현 책임 분리 | `base/module-lifecycle-plan.md` | +| Slot 재조정, 재마운트 시 throw, retract=폐기 | `base/slot-plan.md` | +| `Connected`+GC 라이프사이클 패턴 | `base/lifecycle-pattern.md` | +| Modifier(정적 merge, immutable 체이닝, State 필드 지원, `Apply`/`Overridden`/`Peek`/`isState`) | `base/modifier-plan.md` | +| 컴포넌트화(플레인 함수, State/Source 경계, 컴포넌트 경계 modifier/Ref는 named parameter로 전달, multi-root 개념 폐기, `Modifier.Overridden`) | `base/component-composition-plan.md` | +| 컴포넌트 이식성(전역 store 참조 시 재사용성 문제) | `base/purity-and-effects-plan.md` | +| Blocker(값 기반 emit 지연/합치기) | `base/blocker-plan.md` | +| Effect(설치+확정 정리, `state` 있으면 Observer 조합해 재실행도 지원 — 확정) | `base/effect-plan.md` | +| `Relate`(inst-weak 릴레이션 프리미티브, `SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`), `bindLifetime`/`canExecute`(inst,value) 탑레벨 함수 | `base/relate-plan.md`, `base/lifecycle-pattern.md` | +| `retract` 필드 생략 불가(no-op 허용, 누락 시 핸들러 교체 순간 크래시), store-bind 재실행은 `state:Observer(fn):Subscribe()` 재사용 | `base/bind-system-plan.md` | +| UICorner/UIPadding/UIScale 인라인 편의 키 — 이름·메커니즘·store-bind 가능성까지 확정 | `base/ui-shorthand-plan.md` | +| Batch(lexical) 기각, Context(+레이어드 Store) 기각 | `archive/batch-rejected.md`, `archive/context-rejected.md` | +| Fusion/Vide 비교 리서치(주의: 일부 서술은 이후 라운드에서 뒤집힘, 문서 내 정정 표시 참고) | `reference/comparison-fusion-vide.md` | +| v1 내부 동작 스냅샷 | `reference/quad-v1-architecture.md` | +| 트윈 — 값-레벨 `Tween` 래퍼로 구조 확정(2026-08-10), 오버라이드 기본값 Cancel, 옵션 값 모양·옵션 키 이름만 남음 | `research/tween-plan.md` | +| quad2-try(폐기된 이전 시도) 리서치 — OOP 상속/커스텀 파서/Slot 스텁/`Pipe` COW 전부 죽은 접근으로 확인, 반복 조사 금지 | `base/bind-system-plan.md` | + +--- +전체 순서/우선순위는 루트 `CLAUDE.md`가 최종 소스 — 위 표는 힌트일 뿐 그쪽이 +바뀌면 이 문서도 갱신할 것. diff --git a/.claude/reference/comparison-charm.md b/.claude/reference/comparison-charm.md new file mode 100644 index 0000000..1a690d8 --- /dev/null +++ b/.claude/reference/comparison-charm.md @@ -0,0 +1,135 @@ +# charm(littensy/charm) 비교 — quad-v2 설계 근거 + +**상태**: reference — 온디맨드 참고 자료, "완료" 개념 없음. quad에 관한 결정 +자체가 아니라 charm 리서치 스냅샷(2026-08-09, `.claude/initreq/charm`에 +새로 클론)이라 항상 읽어야 하는 base 컨텍스트는 아님 — Fusion/Vide 비교와 +같은 성격, `quadnomicon` 소재 후보이기도 함. quad-v2의 Blocker/Effect/ +Slot:List/(미래) 네트워크 동기화 설계에 근거로 인용될 때만 열어볼 것, +실제 확정 사항은 인용하는 쪽 `base/` 문서가 소스. + +**charm이 뭔지**: Roblox용 Zustand류 상태관리 라이브러리 — +`atom`/`computed`/`subscribe`/`effect`/`batch` 핵심(`packages/charm/src/ +init.luau`, ~1000줄) + `charm-sync`(클라/서버 상태 복제 diff 레이어) + +`react-charm`/`vide-charm`(얇은 어댑터). 코어는 실제로 절반쯤이 alien-signals +포크(`system.luau`, dirty/pending 비트플래그 전파 엔진, 237줄 — 가장 큰 +테스트 파일이 이걸 검증하는 `topology.test.luau` 484줄)라 순수 서핏보다 +알고리즘 실체가 있지만, quad는 노드/의존성 재사용 모델 자체를 안 쓰기로 +이미 갈라섰으므로 이 부분은 이식 대상이 아님. + +## 반면교사 — quad가 이미 기각/확정한 것과 충돌하는 부분 + +- **`batch(fn, ...)`가 quad가 이미 기각한 `Batch` 렉시컬 블록과 구조적으로 + 동일.** `init.luau:768-778`이 `startBatch`/`endBatch`(`init.luau:285-296`)로 + 콜백을 감싸 effect flush를 지연시키는 모듈 전역 `batchDepth` 카운터 + 방식(`init.luau:66`) — `archive/batch-rejected.md`가 "코루틴 yield에 + 안전하지 않다"는 이유로 기각한 것과 정확히 같은 모양. **charm 자신도 이 + 위험을 인정하는 증거를 갖고 있음**: `wrapUserSpace()`(`init.luau:100-129`)가 + signal/effect/batch 콜백을 `coroutine.create`/`resume`으로 감싸서 콜백 도중 + yield를 시도하면 에러내는 가드(`flags.strict`, Studio 기본 on, + `init.luau:71-81`)를 따로 둠 — 위험을 런타임 가드로 땜질한 것이지 없앤 게 + 아님. quad는 원시 자체를 제거하는 쪽을 택했으니(`Blocker`가 그 자리를 + 대신함, `base/blocker-plan.md:25-44`) 이 모양을 참고할 이유 없음. +- **`atom()`의 getter/setter 겸용 콜러블이 quad가 `Store`에서 이미 기각한 + 대입 문법과 같은 트레이드오프.** `atom(initialValue, equals?)` + (`init.luau:519-527`)가 인자 개수로 read/write를 분기하는 방식 — + `store.key = value`를 버리고 `store.key:Set(value)`로 간 이유 + (`base/store-semantics.md:208-233`, 읽기/쓰기 타입 비대칭)와 같은 문제. + charm 스스로도 README(185-196행)에서 `atom()`을 `signal()`(진짜 get/set + 쌍) 위에 얹은 편의 sugar로 취급 — charm 안에서도 "진짜 1급 형태는 아니다"로 + 다뤄지는 걸 참고. +- **Effect가 전혀 GC-native가 아님 — 전부 수동 dispose 필요.** `effect`/ + `effectScope`/`listen`/`subscribe` 전부 호출자가 직접 불러야 하는 + `Cleanup` 함수를 반환(`init.luau:607-641`, `652-676`, `800-835`) — Roblox + Instance 라이프타임에 자동으로 묶이는 경로가 코어에 아예 없음. `base/ + lifecycle-pattern.md`의 GC-native 원칙과 정반대 축. 오히려 `gc.test. + luau:19-33`의 코멘트가 "스코프 밖에서 `computed()`를 그냥 부르면 의존성에 + 대한 영구 강참조가 생겨서 `effectScope`로 감싸 명시적으로 풀어줘야 + 한다"는 걸 테스트 자체가 우회 헬퍼(`unlink()`, 29-33행)로 증명함 — + 이건 quad의 GC-native 가정을 **뒷받침하는** 증거가 아니라, "레퍼런스/ + 클로저 기반 반응 그래프가 자동으로 안 치워질 수 있다"는 **반례**로 + 인용할 것(rbvm이 "실물 검증된 근거"로 인용되는 것과 반대 방향 — 나중에 + quad의 GC-native 가정을 스트레스테스트할 때 이 케이스를 참고). +- **`computed()`의 값-동등성 억제가 기본값이자 암묵적, opt-in이 아님.** + `updateComputed`가 `oldValue ~= newValue`(`init.luau:302-321`, 특히 + 317행)를 리턴하고 signal setter도 `equals`가 없으면 `node.pendingValue ~= + value`로 기본 비교(`init.luau:489`) — charm의 모든 atom/computed가 기본으로 + 값 비교 억제를 함. quad가 나중에 Blocker에 인접한 "값 안 바뀌면 자동 + 스킵" 기본값을 도입하고 싶어질 때, charm처럼 **모든 노드에 암묵적으로** + 거는 방식은 `Blocker`가 이미 명시한 "특정 게이트 지점에서만 opt-in" + 원칙(`base/blocker-plan.md:65-68`)과 "Source는 스스로를 자동 변형하지 + 않는다"는 `store-semantics.md` 기조에 둘 다 어긋남 — 반면교사로 남겨둘 것. + +## 참고할만한 부분 + +- **charm의 `None` 센티널이 quad 자신의 것을 독립적으로 재확인해줌.** + `patch.luau:10,19-30`이 diff 페이로드에서 "안 바뀜"과 "명시적으로 + 지움"을 `nil`로는 구분 못 해서 `None = {__none="__none"}`을 따로 + 둔 이유 — quad의 배열/해시 파트 `None` 센티널 정당화(`base/ + bind-system-plan.md:180-266`)와 동기 없이 같은 결론에 수렴한 사례. + 새 아이디어는 아니고 인용 근거로만 가치 있음. +- **quad가 미결로 남긴 "previous 값 비교" 문제에 대한 두 가지 답.** + (1) `signal(initialValue, equals?)`(`init.luau:432`, `Equals` 타입은 + 23행)는 생성 시점에 `initialValue`를 항상 요구해서 "비교할 이전 값이 + 아직 없다"는 애매한 첫 상태 자체를 구조적으로 없앰 — + `research/additional-primitives-plan.md`가 남겨둔 "비교할 이전 값이 + 확정 안 된 문제"에 대한 한 가지 해법 형태. (2) `computed(getter)`가 + getter에 **이전 계산 결과**를 인자로 넘겨줌(`init.luau:538`, + `(previousValue: T?) -> T`, README 276-287행, `computed.test. + luau:84-104`가 홀수 업데이트를 스킵하는 걸로 실제 검증) — quad의 + `store-semantics.md:280-284`가 이미 띄워둔 "`:Compute(fn)`에 선택적 + 두 번째 `previous` 인자" 안과 거의 동일한 모양. 새로 수입할 아이디어가 + 아니라 **이미 검토 중인 안이 실제로 동작한다는 정황 증거**로 인용 + 가치 있음. +- **charm-sync의 diff/patch 메커니즘 — quad가 아직 전혀 안 다뤄본 영역이라 + 가장 새로운 참고자료.** `patch.luau:59-89`(`diff`)가 재귀적 구조적 + diff로 중첩 patch 테이블을 만들고, `apply`/`applyMutable` + (`patch.luau:91-131`)이 immutable 재구축(레벨마다 `table.clone`, 순수 + signal용)과 in-place mutate+`:Emit()`류 변형(반응형 프록시용) 둘 다 + 제공 — quad가 이미 다른 이유로 갖고 있는 clone-vs-mutate+`Emit` 분리 + (`base/store-semantics.md:240-284`)와 우연히 같은 모양. `patch. + luau:32-57`(`stringifySparseArray`)는 실전에서 놓치기 쉬운 페이로드 + 함정을 문서화함 — RemoteEvent/JSON 직렬화가 성긴 배열의 trailing hole을 + 조용히 드롭해서, 보낼 땐 문자열 키로 재인코딩하고 받을 땐 숫자 키로 + 복원해야 함(`patch.luau:101-107`). `server.luau`는 클라이언트별 관심사 + 필터링을 하나의 전역 diff 위에 구현(`clients` 테이블의 + `PENDING_INITIAL_STATE`/`LISTENING_FOR_CHANGES` 상태, 27-32행, + `selectFromGlobalPatch` 209-250행) + 모든 중간 변경을 보존하는 opt-in + 모드(`config.preserveHistory`, `diffGlobalUpdateBuffer`, 124-133행) vs + 기본값인 flush당 diff 하나로 합치는 모드(`diffGlobalState`, + 192-207행) — `Blocker`가 일반화하는 coalescing 트레이드오프의 손으로 짠 + sync 전용 구현체. 지금 스코프 밖이지만 나중에 quad가 네트워크 복제 + 설계를 시작하면 첫 참고 지점으로 쓸 것. +- **`observe()`의 엣지케이스 테스트 스위트가 `Slot:List` 테스트 체크리스트로 + 재사용할 만함.** `observe.test.luau`가 마운트 콜백 도중의 재귀적 + add/remove(92-113행), 자기 마운트 도중 자기 자신 제거(115-132행), add/remove + 도중 dispose(134-168행), 재귀적 업데이트 중 에러가 reconciler를 안 멈추게 + 하는지(170-196행)를 검증 — `observe()` 자신의 메커니즘(키별 + `effectScope`, `init.luau:851-898`)은 quad가 채택한 방식이 아니지만, + 테스트 항목 목록 자체는 `base/slot-plan.md`의 키 기반 재조정을 실제 + 구현할 때 대조 체크리스트로 쓸 가치가 있음. + +## 종합 + +코어(atom/computed/effect/subscribe/batch, `init.luau`의 절반쯤)는 평범한 +시그널 라이브러리라 quad가 이미 확정한 것을 대체로 재진술할 뿐이고, 세 +군데(`batch()`, `atom()`, 수동 dispose Effect)는 오히려 quad가 이미 능동 +기각한 패턴을 그대로 구현하고 있음 — 사용자가 애초에 예상한 "짧은 +라이브러리라 새로운 게 없을 것"이 이 레이어에는 대체로 맞음. 진짜 참고 +가치는 코어 밖에 있음: charm-sync의 diff/patch(현재 quad 스코프 밖이지만 +새 영역), 그리고 quad가 미결로 열어둔 Blocker의 "previous 값 비교" 문제에 +대한 두 가지 실동작 사례(`signal`의 필수 initialValue, `computed`의 +previous-in-getter). 지금 당장 base 문서를 고칠 만한 발견은 없음 — 순수 +참고자료로 등록. + +**인용 위치**: `packages/charm/src/init.luau:66,71-93,100-129,285-296, +302-321,432,489,519-527,538,607-641,652-676,768-778,800-835,851-898` · +`packages/charm/src/system.luau`(전체, alien-signals 포크) · +`packages/charm/test/gc.test.luau:9-33` · `packages/charm/test/ +computed.test.luau:84-104` · `packages/charm/test/observe.test.luau:92-196` · +`packages/charm-sync/src/patch.luau:10,19-30,32-57,59-89,91-131` · +`packages/charm-sync/src/server.luau:27-32,124-133,192-207,209-250` · +`README.md:185-196,262-287` · `base/store-semantics.md:208-233,240-284` · +`base/blocker-plan.md:25-44,65-68` · `base/lifecycle-pattern.md`(GC-native +원칙) · `archive/batch-rejected.md` · `base/bind-system-plan.md:180-266` +(None 센티널) · `research/additional-primitives-plan.md`(Blocker/키 기반 +컬렉션 미결 상태). diff --git a/.claude/reference/comparison-fusion-vide.md b/.claude/reference/comparison-fusion-vide.md new file mode 100644 index 0000000..63248ee --- /dev/null +++ b/.claude/reference/comparison-fusion-vide.md @@ -0,0 +1,72 @@ +# Fusion / Vide 아키텍처 비교 — quad-v2 설계 근거 + +**상태**: reference — 온디맨드 참고 자료, "완료" 개념 없음. **[2026-08-07 +문서 정리에서 `base/`→`reference/`로 이동]** quad에 관한 결정 자체가 아니라 +Fusion/Vide 리서치 스냅샷이라 항상 읽어야 하는 base 컨텍스트는 아님 — +`quadnomicon`(프레임워크 설계자용 심화 콘텐츠) 소재 후보이기도 함. quad-v2의 +Store/Slot/Tween/bind-dispatch 설계 결정에 근거로 인용될 때만 열어볼 것, +실제 확정 사항은 인용하는 쪽 `base/` 문서가 소스. + +## Fusion (`.claude/initreq/fusion/`) + +- **반응 그래프**: push(무효화) + pull(재계산) 하이브리드. `Value:set()`이 `change()`를 + 통해 `dependentSet`을 BFS로 훑으며 `invalid`로 마킹하지만, 실제 재계산은 + `timeliness="eager"`인 노드(Observer/Tween/Spring)만 즉시 동기 실행 — `Computed`/ + `Value`는 `use()`될 때만 lazy하게 재계산. 글리치 방지를 위해 eager 집합을 + `createdAt` 순으로 정렬 후 처리. +- **Scope 정리 모델**: `Scope`는 사실 그냥 배열 + 메타테이블로 생성자들을 주입한 + 것. 생성자마다 자기 destroy 클로저를 배열에 `insert`. `doCleanup`은 다형적 + (Instance→Destroy, Connection→Disconnect, 함수→호출, 테이블→역순 순회) 티어다운. + **완전히 eager/수동 — GC에 의존하지 않음.** +- **프로퍼티/자식/이벤트 디스패치**: `SpecialKey`라는 열린 "모양"(shape)은 + 누구나 만들 수 있지만, 우선순위 축이 `self/descendants/ancestor/observer` 4단계로 + 하드코딩되어 있어 5번째 우선순위 도입이 불가능 — quad가 원하는 완전 개방형 + priority 레지스트리보다 약함. +- **Tween/Spring이 State그래프 안의 1급 노드** — 매 프레임 틱하는 외부 + Stopwatch/ExternalTime 소스에 의존, 애니메이션-입력 간 별도 lifetime 체크 + 기계장치 필요. **quad가 트윈을 Store 밖(특수 bind key)으로 빼려는 이유의 + 반면교사**: Fusion처럼 그래프 안에 넣으면 Computed의 입력으로 자유롭게 합성 + 가능해지지만, 그 대가로 프레임 클럭 통합 + eager 노드 + 교차 lifetime 체크 + 3중 복잡도를 떠안음. + +## Vide (`.claude/initreq/vide/`) + +- **반응 그래프**: SolidJS류 순수 push. `source()`를 쓰면 즉시, 동기적으로, + 깊이우선으로 모든 의존 노드를 재평가(lazy/pull 경로 없음). **저자들 스스로 + `todo.md`에 "복잡한 다이아몬드 그래프에서 중복 재평가 방지" 를 미해결로 남겨둠** + — quad Store가 이 naive BFS 방식을 그대로 베끼면 안 되는 이유. +- **정리 모델**: 의존성 엣지(`parents`)와 구조적 소유(`owner`/`owned`)를 같은 + `Node`에서 두 개의 별도 관계로 분리 — CHANGELOG 0.2.0에서 "destroy가 더 이상 + reactive dependent까지 타고 내려가지 않고 owned만" 으로 명시적으로 고침(초기 + 설계 실수를 나중에 수정한 이력). 0.4.0에서 "활성 스코프는 destroy 불가" 하드 + 가드 추가. **역시 완전 eager/수동 — GC 의존 없음**(오히려 `root.luau`가 GC로부터 + 루트를 보호하는 `refs` 테이블까지 둠). +- **디스패치**: 대부분 Luau 키 타입으로 닫힌 하드코딩. 유일한 열린 확장점은 + `action(callback, priority)` — 등록 없이 private 메타테이블 태그로 인식되는 + 값을 던지면 우선순위 순으로 실행. 단 key/value 쌍이 아니라 콜백+우선순위만 + 전달 — quad가 원하는 "key와 value를 함께 받는 핸들러"보다 좁음. +- **`mount()`에 단일-마운트 가드가 전혀 없음** — 같은 target에 두 번 mount하면 + 독립된 두 루트가 생겨 자식이 중복됨. **quad의 Slot "엄격한 단일 마운트 + 소유권"이 두 라이브러리 어디에도 없는 진짜 개선점**이라는 근거. + +## 종합 비교표 + +| 축 | Fusion | Vide | quad-v2 시사점 | +|---|---|---|---| +| 전파 모델 | push+pull 하이브리드, eager 집합만 즉시 재계산, 생성순 정렬로 글리치 방지 | 순수 push, 즉시 동기 재평가, 다이아몬드 중복 재평가 미해결(저자 인정) | ⚠️ **[정정] 아래 서술은 리서치 당시(2026-08-03 이전) 검토 방향이며 이후 뒤집힘 — 최종 확정은 `base/bind-system-plan.md`의 "전파 모델 확정" 절 참고**(push-invalidate는 신호만 쏘고 값은 안 실음, 재계산은 `Get()` 시점 pull-recompute로만, Fusion식 eager 노드·생성순 정렬은 아예 채택 안 함 — quad엔 그런 다단계 즉시 재계산이 필요한 소비자가 없다는 판단). 당시 스냅샷 원문: "Store는 값 자체에 항상 eager 발화, retract(구 cleanup)가 key/value를 먼저 확인" 요구사항은 Vide의 push 모델 + eval-전-retract 패턴에 더 가까움. 단 Vide의 naive BFS 대신 Fusion의 생성순 정렬 글리치 방지 규율은 채택할 것. | +| 정리/스코프 | 배열+메타테이블, dependency-agnostic, bind 시점에만 lifetime soft-check | dependency edge와 구조적 owner를 분리한 2중 관계, destroy는 owned만 cascade, 활성 스코프 destroy 하드 가드 | 둘 다 GC 비의존 eager 수동 정리 — rbvm의 Connected+GC 관용구와 정반대 축. quad의 Slot은 Vide처럼 "마운트 소유권"과 "반응 의존성"을 별개 관계로 분리하는 게 안전해 보임(`base/lifecycle-pattern.md`의 rbvm 패턴과는 다른 층위 — rbvm은 인스턴스 파괴 감지, 이건 Slot 내부 소유권 모델). | +| 키/값 디스패치 개방성 | SpecialKey 모양은 열려있으나 우선순위 4단계 하드고정 | action()은 등록 없는 태그 인식 방식이지만 key/value 버림, 콜백+우선순위만 | quad는 Fusion의 "디스패처가 key+value+target을 다 받는" 풍부함과 Vide의 "등록 없이 태그로 인식" 인체공학을 합치되, 우선순위 축은 열린 숫자 공간으로 일반화해야 함(`base/bind-system-plan.md`). | + +## 추가로 기록해둘 것 + +- Vide의 암묵적(ambient stack) 의존성 추적 vs Fusion의 명시적 `use()` 축은 + push/pull 축과 독립적인 별개 결정. **[정정] 리서치 당시(2026-08-03 이전)엔 + "quad는 아직 미정"이었으나, `base/bind-system-plan.md`의 "여러 Store 값을 + 묶어 파생값 만들기 — `:With` + `:Compute`" 절에서 이미 명시적 모델로 + 확정됨** — Vide식 암묵적 ambient stack 추적은 "함수 실행 중과 끝 사이를 + 확인하고 부작용이 필요"한 방식이라 Lua에서 깔끔하지 않다는 이유로 기각, + `:With(...)` + `:Compute(fn)`(클로저로 직접 읽는 명시적 방식)를 채택. + Fusion의 명시적 `use()`는 `checkLifetime` 같은 bind-time 체크를 가능하게 + 하는 부수 효과가 있음. +- 두 라이브러리 다 mount 시 단일 소유권 가드가 없다는 것 자체가 quad Slot의 + 차별점이라는 근거로 재사용 가능. diff --git a/.claude/reference/quad-v1-architecture.md b/.claude/reference/quad-v1-architecture.md new file mode 100644 index 0000000..249cbdf --- /dev/null +++ b/.claude/reference/quad-v1-architecture.md @@ -0,0 +1,89 @@ +# quad v1 내부 구조 (재작성 이전 기준선) + +**상태**: reference — 온디맨드 참고 자료, "완료" 개념 없음. **[2026-08-07 +문서 정리에서 `base/`→`reference/`로 이동]** v1 자체에 대한 스냅샷일 뿐 v2의 +결정 사항이 아니라서 항상 읽어야 하는 base 컨텍스트는 아님 — 다른 문서가 +"v1은 이랬는데"를 인용할 때만 열어볼 것. v2 설계 시 "이 문제를 안 반복하려면"의 +기준선으로 근거 인용되는 용도는 그대로 유지(각 인용 지점은 여전히 +`base/`에 있음, 이 문서는 그 인용의 원본 소스). 아래는 리서치 에이전트가 +file:line까지 확인한 내용의 요약 — 정확한 인용이 필요하면 +`.claude/initreq/quad/src/*.lua` 원본을 볼 것. + +## 공개 API 개요 + +```lua +local Quad = require(path).Init(QuadId?) -- id 생략 시 격리된 인스턴스 +local Class, Store, Mount, Event, Style, Signal, Lang, Tween = Quad.Class, ... + +local Frame = Class "Frame" +Frame { + Name = "Wow!"; + Frame { Name = "Child" }; -- [1] = child + [Event "Activated"] = function(self,...) end; -- 이벤트 바인드 키 + BackgroundColor3 = myStore "color"; -- store 바인드 + myStyle; -- style 오브젝트도 숫자 키로 +} +Mount(ScreenGui, Frame {...}) +``` + +`Class.Extend()`로 재사용 컴포넌트(`Init/Render/AfterRender/Getter/Setter/ +UpdateTriggers/Unload`) 정의 가능. `Store.GetObject(id)`류 id 기반 전역 조회는 +v2에서 대체될 예정 — Ref 도입과 네임스페이싱 판단까지 포함해 최신 상세는 +`base/architecture.md` 5번 항목 참고. + +## 핵심 내부 동작 요약 + +- **`class.lua`의 `ProcessQuadProperty`**(하드코딩된 if/elseif 디스패처)가 사실상 + 전체 "키 핸들러"임 — 숫자 키(children/style), `quad_register`/`quad_linker`/ + `quad_style` 같은 `__type` 문자열 태그가 붙은 테이블, 그리고 `"Event::"` 접두 + 문자열 세 가지를 런타임 `typeof`/`type` sniffing으로 구분. 새 특수 키를 + 추가하려면 이 중앙 함수 자체를 고쳐야 함 — **v2가 pluggable bind 시스템을 + 원하는 직접적인 이유**. +- **`store.lua`의 register 체이닝이 바로 사용자가 "별로였다"고 한 metatable + 체이닝**: `:With`/`:Add`/`:Tween`/`:Default` 각각이 이전 register를 `__index`로 + 가리키는 새 1-필드 테이블을 만드는 방식 — 매 호출마다 테이블+메타테이블 할당, + 같은 메서드 두 번 호출하면 마지막 것만 남음(합성 안 됨), `Register`/`Observe`는 + 반대로 루트 스토어를 직접 mutate — 일관성 없는 순수/불순 혼합. +- **정리(cleanup)에 대한 통일된 모델이 없음** — 여러 곳에서 각자 + `PropertyChangedSignal("ClassName")`에 연결해 참조를 붙잡아두는 "GC 방지 핫팩"이 + 중복 등장(`class.lua`에 2곳, `lang.lua`에 1곳). 대칭되는 해제(dispose) 경로가 + 없어서 weak table GC에만 의존. `Uninit(id)`도 실제 파괴 없이 참조만 끊는 스텁. +- **`mount.lua`는 실제로 부모/자식 부기(bookkeeping) + 라이프사이클 파괴까지 + 담당하는 무거운 모듈**(`rawget/rawset`로 Extend 내부 필드를 직접 건드림) — + 사용자 원 메모의 "이전 quad는 mount가 별다른 행동 안 함"은 더 오래된 스냅샷 + 기준일 가능성. +- **`event.lua`는 이벤트 연결 후 해제(disconnect) 추적이 전혀 없음** — fire-and-forget. + `signal.lua`는 완전 커스텀 Signal 구현체(Roblox BindableEvent 미사용)이지만 + class.lua/mount.lua의 정리 경로에 연결되어 있지 않음. +- **`style.lua`는 이름 매칭(문자열 패턴) 기반, 선언 순서 의존적** — 실행 순서가 + 꼬이면 스타일이 안 먹는 문서화된 함정. +- **`tracker.lua`는 실제로 `exports.lua`에서 require조차 안 되는 죽은 코드** — + Rojo 트리(`DescendantAdded`/`.Changed`) 변경을 감지해 debounce 후 "updated"를 + 쏘는 핫리로드 감시자였지만 현재 공개 API에 연결 안 됨. v2는 아예 구현 안 하기로 + 이미 결정됨(스토리북 라이브러리가 대체, `base/architecture.md` 참고). +- **`lang.lua`의 로케일 상태(`CurrentLocale`/`langList`)가 module-local 전역이라 + `Quad.Init(id)`의 id 스코프를 무시함** — Store/Style은 id별로 스코프되는데 Lang만 + 전역 공유, 일관성 없는 스코핑. v2는 lang 모듈 자체를 분리해서 안 만들기로 결정됨. +- **문자열 DSL(`"a,b"`, `"a&b,c"` 같은 콤마/앰퍼샌드 파싱)로 구현된 구조적 기능**들이 + 주석 처리된 죽은 코드(`__newIndex` 대문자 오타로 절대 안 불리는 메타메소드 등)와 + 섞여 있어 신뢰도가 낮음. + +## v2가 명시적으로 피하려는 것 (이 문서에서 근거로 인용) + +1. Metatable 체이닝으로 "불변 빌더" 흉내내기 → 대신 팩토리 함수로 필요한 곳만 복사 + (`raw-userinput.md` "복사 구현은 지양" 항목, `.claude/initreq/raw-userinput.md:83-86`). +2. 하드코딩된 중앙 디스패처 → pluggable `isHandlable(key,value)` + 우선순위 핸들러 + 레지스트리 (`base/bind-system-plan.md`). +3. 흩어진 "GC 안 되게 참조 붙잡기" 핫팩 → rbvm 스타일 `Connected` 계산 속성 + + 명시적 라이프타임 홀더 (`base/lifecycle-pattern.md`). +4. mount가 여러 책임(부모 부기+파괴+child 레지스트리)을 한 모듈에 다 지는 구조 → + Slot이 child CRUD를 전담, mount는 단일-마운트 강제만 전담 + (`base/slot-plan.md`). +5. tracker.lua, lang.lua 내장 → 둘 다 라이브러리 범위 밖으로 분리(스토리북/ + 외부 로케일 라이브러리에 위임). + +## 열려 있는 확인 사항 + +- `objectListClass.__newIndex`(오타, 항상 미발동)로 문서화된 "GetObjects() 리스트에 + 일괄 프로퍼티 설정" 기능이 실제로 동작하는지 v1에서 재현 테스트 필요 — 동작 안 + 했다면 v2 마이그레이션 가이드에서 "이 기능은 애초에 없었다"고 명시해야 함. diff --git a/.claude/research/additional-primitives-plan.md b/.claude/research/additional-primitives-plan.md new file mode 100644 index 0000000..939586c --- /dev/null +++ b/.claude/research/additional-primitives-plan.md @@ -0,0 +1,125 @@ +# 추가 프리미티브 필요성 조사 — 다른 프레임워크 대비 갭 분석 + +**상태**: research — 사용자와 라이브 논의로 대부분 수렴(2026-08-06~07), +**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`. **[2026-08-09 세 번째 세션]** 마지막으로 남아있던 +키 기반 동적 컬렉션 재조정도 `Slot:List(...)` 메소드로 완전히 확정되어 +`base/slot-plan.md`로 승격됨(아래 절은 요약+포인터만 남기고 상세는 그쪽 +참고) — **이 문서에 새로 열려있는 설계 질문은 더 이상 없음**, 아래 표/ +"빈 자리 아닌 것"/"문서화 백로그"/"참고 소스" 절은 배경 리서치 기록으로만 +유지. + +## 배경 + +사용자 질문: "다른 독립 프리미티브나 종속 파생 데이터는 뭐가 더 필요할 것 +같나요. 이것만으로 이 프로젝트는 충분하다 생각해요?" — 지금까지 확정된 +독립 프리미티브(`Source`/`Store`/`Ref`/`Modifier`/`Slot`/`DI`)+파생 데이터 +(`State`/`Observer`)만으로 충분한지, 웹 프레임워크나 실제 Roblox 개발 관점에서 솔직하게 +재검토해달라는 요청. + +## 조사 방법 + +서브에이전트 여러 개를 병렬/순차로 띄워 조사(웹 프레임워크 서베이, Fusion/ +Vide/v1/artworks 소스 근거 조사, Context 구현 난이도 판정) + 그 결과를 +사용자와 라이브로 검증/반박/재조정. `research/framework-comparison-findings.md` +(quad vs Fusion/Vide/react-lua 강점/약점 비교)와는 다른 질문 — 그 문서는 +"같은 개념을 quad가 얼마나 잘 구현했는가", 이 문서는 "개념 자체가 통째로 +없는 게 있는가/필요한가". + +## 결론 요약 + +| 후보 | 판정 | 현재 위치 | +|---|---|---| +| 키 기반 동적 컬렉션 재조정 | **채택, 확정** — `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` | +| Context(트리 하위 암묵 전파) + 레이어드 Store | **기각** | `archive/context-rejected.md` | +| Observer에 cleanup 반환 계약 추가 | **기각** — 클로저 업밸류로 이미 충분 | `base/effect-plan.md`(근거만 인용) | +| Untrack/Peek | 빈 자리 아님 | 아래 "빈 자리 아닌 것" 절 | +| Suspense/비동기 경계 | 빈 자리 아님(문서화 문제로 재분류) | 아래 "빈 자리 아닌 것" 절 | +| Error Boundary | 빈 자리 아님 | 아래 "빈 자리 아닌 것" 절 | +| Readonly wrapper | 빈 자리 아님 | 아래 "빈 자리 아닌 것" 절 | + +## 키 기반 동적 컬렉션 재조정 — 확정, `base/slot-plan.md`로 승격 (2026-08-09 세 번째 세션) + +React `key` prop, Vue `v-for :key`, Solid ``, Fusion `ForPairs`/ +`ForKeys`/`ForValues`, Vide `indexes()`/`values()`에 대응하는 프리미티브 — +데이터 배열을 정체성(key) 기준으로 diff해서 변경분만 생성/갱신/파괴한다. +**최종 확정 형태는 자유 함수도 새 타입도 아니라 `Slot`의 콜론 메소드** +(`Slot():List(data, updateFn, keyFn?) -> Slot`) — 상세 시그니처/구현 +의사코드/왜 자유 함수·새 타입이 아닌지/`Move` 기반 리오더/`userdata` 기반 +`Source` 관리 위임은 전부 `base/slot-plan.md`의 "`Slot:List(...)`" 절 +참고, 여기서 반복 안 함. + +이 아래 있던 "왜 매핑 함수 직관이 안 통하는가"/"메커니즘 스케치"/ +"이름 후보"/"남은 열린 질문" 절은 전부 그 문서로 흡수·확정되어 제거함 — +State 메소드로 두려던 초기 폼팩터가 기각된 경위만 여전히 +`archive/keyed-collection-state-method-rejected.md`에 별도 보존. + +## 빈 자리 아닌 것으로 확인된 것들 + +- **Untrack/Peek**(Solid `untrack()`, Vue `toRaw`): quad는 Vide식 암묵 + 추적을 기각하고 `:With(...)` 명시적 의존성 선언을 택함 — "읽었지만 + 추적 안 하고 싶다"는 필요 자체가 안 생김(`:With`에 안 넣으면 그게 곧 + untracked read). Vide `untrack()`은 암묵 추적 전용 문제라 quad엔 애초에 + 적용 안 됨. +- **Suspense/비동기 경계**: `Ref:Wait()`(coroutine 대기) + 처음엔 nil인 + Source로 부분 커버되지만, **quad 컴포넌트가 한 번만 실행된다**는 전제와 + 부딪히는 함정이 있음 — 렌더 함수 최상단의 `if loading then return + Spinner end`류는 마운트 시점 단 한 번만 평가되고 데이터 도착 후 + 재평가 안 됨. Slot + Observer 조합으로 실제 구현은 가능하나 1급 패턴이 + 아니라서, 새 코어 프리미티브보다는 **"render-once 함정" 문서화 + 우선순위 문제**로 재분류(`research/documentation-plan.md`의 권장 패턴 + 문서 부류에 속함, React 습관 개발자가 특히 잘 빠질 실수). +- **Error Boundary**: quad 컴포넌트는 평범한 Lua 함수 호출이라, 리스트 + 개별 아이템 생성 주변에 `pcall(MyComp, props)`를 감싸는 것만으로 React + Error Boundary와 같은 격리 효과를 프레임워크 지원 없이 얻음. +- **Readonly wrapper**: `component-composition-plan.md`가 이미 "Source + 직접 전달은 좁은 케이스에 한정, 일반적으론 State + callback이 기본"으로 + 못박아둬서 캡슐화 깨짐 문제 자체가 대부분 상황에서 안 생김. +- **Fusion `Observer`/`Attribute`**: quad `state:Observer(fn)` + + `bind-system-plan.md`의 Attribute 논의로 이미 커버 중, 신규 아님. +- **디바운스/스로틀**: Fusion/Vide/v1 어디에도 공개 프리미티브로 없음 — + 세 레포 모두 없다는 것 자체가 "quad도 굳이 안 만들어도 된다"는 정황. + +## 문서화 백로그 (2026-08-06~07, `documentation-content-map.md`에도 반영) + +- **quadnomicon 에세이**: + - "왜 lexical Batch를 기각하고 대신 값 기반 Blocker를 택했는가" — + `archive/batch-rejected.md`와 `base/blocker-plan.md`의 + Blocker 절을 나란히 비교. + - "왜 Context가 없는가" — `archive/context-rejected.md` 참고. + - "왜 push-invalidate/pull-recompute 설계가 laziness와 재계산 방지를 + 최우선 목표로 뒀는가" — Blocker 같은 파생 프리미티브가 이 목표 위에서 + 자연스럽게 나온 이유까지 포함해 기존 심화 콘텐츠 후보 3번(`왜 + push-invalidate/pull-recompute인가`)을 더 깊게 확장. +- **심화 문서**: + - "State 파생 체인 동작 원리" — emit이 아래로 전파되고, `Get()` 요청이 + 위로 거슬러 올라가 재계산된 뒤 다시 아래로 내려오는 흐름을 명확히 + 설명(Blocker/Effect 둘 다 이 흐름 위에서 동작하므로 선행 이해로 필요). + - "`:Compute` 함수 안에서 `if` 등으로 일부 의존값만 조건부로 사용하는 + 유연한 구조" — 명시적 의존성 선언(`:With`) 위에서도 실제 계산은 + 조건부로 일부만 쓸 수 있다는 팁. + - "Blocker 사용 가이드" — 파이프라인 최종 연산 지점에 배치, **네스팅 + 금지를 강하게 명시**(`base/blocker-plan.md`의 "재진입" 절 + 참고, 문서화 시 최우선 강조 항목). + - "여러 Source를 한꺼번에 바꿀 때 Blocker 없이도 중복 재계산을 피하는 + 파이프라인/업데이트 순서 팁"(Blocker를 안 쓰는 단순 케이스용 보조 팁, + 기존 "심화 최적화 팁" 항목을 Blocker 존재를 전제로 재조정). + +## 참고: 조사에 사용한 소스 근거 + +- Fusion: `State/ForPairs.luau`, `State/ForKeys.luau`, + `Utility/Contextual.luau`, `Graph/Observer.luau`, `Instances/Attribute.luau`, + `Memory/doCleanup.luau` +- Vide: `indexes.luau`, `values.luau`, `context.luau`, `batch.luau`, + `action.luau`, `untrack.luau`, `cleanup.luau` +- quad v1: `store.lua`, `tracker.lua`, `class.lua`(diff/reconcile/keyed + 계열 헬퍼 없음, grep 확인) +- artworks: `EventDrivenProgramming/Observable.luau`, `Utility/Array.luau`, + `GlobalDataStorage/request.luau`, `DeclarativeProgramming/DeclarativeInstance.luau` + +경로는 모두 `.claude/initreq//...` 기준(읽기 전용 참고 레포). diff --git a/.claude/research/debug-tooling-plan.md b/.claude/research/debug-tooling-plan.md new file mode 100644 index 0000000..0cb3776 --- /dev/null +++ b/.claude/research/debug-tooling-plan.md @@ -0,0 +1,528 @@ +# quad-debug — 런타임 디버깅/역추적 도구 계획 + +**상태**: research — 착수 전, 사용자와 계속 논의 필요. 사용자가 "quad 개발이 +어느 정도 끝날 때까지는 실제 구현에 못 들어갈 것"이라고 스스로 판단한 후순위 +항목이지만, **base 설계(디스패치 엔진/Source/DI 생성자) 시점에 훅 확장 +지점만 미리 고려해두면 나중에 훨씬 싸게 먹힌다**는 문제의식으로 지금 미리 +정리해둠. `ROADMAP.md` 백로그 항목("범용 렌더 디버깅 도구로서의 quad-mock")과 +목적이 다름 — 아래 "quad-mock 백로그와의 관계" 절 참고. + +**2026-08-06 세션 결론(핸드오버 요약)**: 설계를 막던 유일한 기술적 불확실성 +(플러그인이 Play 중인 게임과 실시간으로 통신 가능한가)이 사용자의 Studio +실측으로 **해소됨** — `BindableEvent`/`BindableFunction` 둘 다 Plugin↔Play +클라이언트 경계를 넘는다(아래 "데이터 채널" 절). 이후 그 위에서 채널 위치 +(quad 모듈 내부+CollectionService 태그), 페이로드 제약(순수 직렬화 값만), +UUID 기반 on-demand compute, Element Inspector, flash 범위 축소까지 +설계가 한 라운드 더 수렴함(아래 "핵심 설계 방향" 7/8번, React DevTools +절 4번). **남은 건 세부 API 이름과 구현 착수뿐** — 남은 열린 질문은 전부 +후순위/백로그 표시된 것들, 다음 세션에서 뭔가 막혀있지 않음. + +## 배경 — 팀원 피드백 원문 요지 + +Roblox 안의 대부분 렌더러는 "뭐가 어디서 어떻게 렌더링됐는지" 알기 힘들다. +react-lua는 방법이 있긴 하지만 쓰기 어렵고, Studio 안에서 플러그인처럼 바로 +볼 수 있으면 좋겠다는 요청. 구체적으로 원하는 것: + +- **실물 Frame → 생성한 코드 위치 역추적** (제일 핵심 요청) +- Explorer에서 선택한 인스턴스로부터 코드 위치를 보여주는 플러그인, 또는 + 플러그인 자체 트리뷰 +- 어떤 프로퍼티가 어떤 파이프(Store/State 체인)에 연결됐는지, 파이프라인이 + 어떻게 생겼는지 UI로 확인 +- 웹 devtools처럼 변경된 부분을 반짝이게(flash) 보여주기 +- 생성된 Source/Store 목록을 스크립트별로 보여주기 + +스토리북(`ui-labs`, `architecture.md` 9번 항목으로 이미 대체 확정된 것)과는 +다른 문제라는 점을 사용자가 명시적으로 구분함 — 스토리북은 컴포넌트 단위 +격리 테스트, 이건 **인게임 전체를 실행한 상태에서** 발생하는 실제 버그를 +찾는 용도. 실사용 인게임 버그는 이 방식이 훨씬 찾기 쉽다는 게 사용자 판단. + +## 스코프 확정 (사용자 확인) + +**1차 설계는 클라이언트 UI 한정.** quad는 거의 항상 LocalScript/클라이언트 +UI를 다루므로, 1차 통신 채널은 "플러그인 ↔ 같은 머신의 로컬 클라이언트" +BindableEvent 채널로 한정. 서버에서 생성되는 인스턴스(팀 테스트/멀티플레이 +시나리오, RemoteEvent 필요)까지 다루는 건 실제 필요성이 확인되면 그때 확장 — +지금 설계를 막지 않음. + +## 리서치 결과 요약 + +서브에이전트로 `.claude/initreq/` 전체(quad v1, fusion, vide, rbvm, tbox, +quad2-try, artworks)를 조사, 일반 지식으로 Roblox 엔진 제약도 확인: + +1. **참고할 기존 구현체가 없음** — react-lua/roact devtools 소스 자체가 이 + 레포에 없음(react-lua가 애초에 이 프로젝트의 참고 레포 목록에 없음). + `quad2-try/out/quad-debug/`라는 빈 디렉토리가 이미 예약되어 있었으나 + 파일 0개 — 이전 시도도 손댄 적 없는 영역. v1에도 재사용할 인프라 없음 + (`customWarn.lua` 정도, `debug.traceback` 출력만 하는 3줄). +2. **에러 발생 시점 스냅샷 방식은 있지만 상시 레지스트리는 없음** — Vide + (`src/graph.luau`)와 Fusion(`src/Logging/parseError.luau`)은 둘 다 + `xpcall`+`debug.traceback`/`debug.info`로 **에러 나는 순간에만** 스택을 + 찍음. Instance→생성 위치를 항상 기록해두는 상시 레지스트리를 유지하는 + 선례는 없음 — quad-debug가 여기까지 해낸다면 차별점. +3. **Roblox Luau의 `debug` 라이브러리는 제한적** — 표준 Lua/LuaJIT에 있는 + `debug.sethook`(라인/콜 단위 훅), `debug.getlocal`/`setlocal`/ + `getupvalue`/`setupvalue`가 Roblox엔 없음(보안/성능 이유로 제거). + `debug.info`/`debug.traceback`/`debug.profilebegin`류만 노출. **즉 엔진이 + 공짜로 주는 동적 트레이싱 방법은 없고, quad 코드 안에 직접 계측을 심는 + 것 외엔 방법이 없음** — 사용자가 우려한 그대로 확인됨. +4. **"no-op 기본값 → 나중에 실제 구현으로 교체" 패턴의 실사용 선례 발견** — + Fusion `src/External.luau`가 정확히 이 모양: 모듈 상단 upvalue + `currentProvider: ExternalProvider? = nil`을 두고 + `External.setExternalProvider(newProvider)`(31행)로 통째로 교체, 소비 + 함수(`logWarn` 등)는 매 호출 시 `if currentProvider then ... end`로만 + 분기(101행). `RobloxExternal.luau:51`의 `RobloxExternal.logWarn = warn`도 + 같은 기법의 정적 버전. **quad가 이미 쓰기로 확정한 패턴(base는 인터페이스, + 구현은 팩토리가 나중에 주입 — `RobloxFactory` 등)과 정확히 같은 모양** — + 새로 발명할 필요 없이 기존 설계 원칙을 트레이싱에도 그대로 적용하면 됨. +5. **프로덕션에서 완전 제거하고 싶다면**(당장 필요한 결정 아님) darklua의 + 전역 값 주입(`inject_global_value`) + dead-branch 제거, 또는 Rojo + `project.json`을 릴리즈용으로 따로 둬 디버그 전용 파일 자체를 트리에서 + 빼는 방법이 있음 — 일반 지식 수준으로만 확인, 실제 필요해지면 재조사. +6. **플러그인과 실행 중인 게임은 별도 Luau VM(별도 보안 컨텍스트)** — + `_G`/`shared`가 공유되지 않음. `require()`는 공식 문서(creator-docs + `scripting/module.md`)로 "클라이언트-서버 경계를 넘어 같은 ModuleScript를 + require하면 각 쪽이 **독립적인 참조**를 받는다"가 확인됨 — 플러그인 + 경계에도 같은 메커니즘(별도 require 캐시)이 적용될 가능성이 높으나 공식 + 문서가 플러그인 케이스를 명시하진 않음. 따라서 플러그인이 게임 내부의 + Store/Source 레지스트리에 직접 접근하는 경로는 없다고 봐야 함. + - **정정 이력(2026-08-06)**: 이전 초안이 `BindableEvent` 브릿지를 + "차별점"으로 단정했던 건 근거 부족이었음(공식 문서는 플러그인 언급 + 없음, DevForum엔 실패 사례도 있었음) — 이후 사용자가 Studio에서 직접 + 실측해 **BindableEvent Fire/Connect가 Plugin↔Play 중 클라이언트 + 경계를 실제로 넘는다는 걸 확인**(아래 "데이터 채널" 절 5번 참고). + 핵심 원인 추정("react-lua devtools가 쓰기 어렵다")은 여전히 "게임 + 쪽에 브릿지를 미리 심어야 하는 설정 부담" 쪽으로 유지 가능해짐 — + 채널 자체는 되는 게 확인됐으므로. + +## 핵심 설계 방향 + +### 1. "존재하는 State 목록"이 아니라 "무엇이 무엇에 연결됐는가" — 사용자 확정 + +`bind-system-plan.md`에 이미 있듯 State는 `store.key`로 접근할 때마다 +매번 새로 만들어지는 ephemeral 캐시 핸들이라 "지금 존재하는 State 목록"이라는 +개념 자체가 성립하지 않음. **사용자가 이 논의 중 직접 정정**: 값 목록을 +보여주는 대신, Frame을 선택했을 때 "어디에 어떻게 훅이 연결돼 있는지", "이 +Compute 함수가 어디서 생성됐는지"를 보여주는 **연결 그래프** 중심으로 UX를 +잡는 게 맞음. 이건 quad 온톨로지와도 자연히 맞아떨어짐 — 열거해야 할 진짜 +실체는 State가 아니라 **Source**(Store가 소유하는 유일한 진짜 값 지점)와 +**디스패치 이력**(무엇이 언제 어떤 값으로 `process`됐는가) 둘뿐. 파이프라인 +그래프는 이 디스패치 이력을 재구성해서 보여주는 것. + +### 2. 계측 지점 3곳 — no-op 훅 upvalue, Fusion `External.luau` 패턴 재사용 + +사용자가 요청한 "빈 함수 만들어두고 나중에 트레이스 뽑는 동적 계측"을 +그대로 적용. `if DEBUG then` 분기를 코드 전체에 뿌리지 않고, 아래 세 지점에 +**모듈 upvalue 형태의 no-op 기본 훅**만 심어두면 됨(위 리서치 4번 패턴): + +- **`Dispatch/init.luau`의 `process`/`retract` 스캔 루프** — 어차피 매 + 호출마다 우선순위 스캔이 도는 지점이라, 여기에 훅 호출 1개(no-op이면 + 사실상 함수 호출 오버헤드뿐, 무시 가능 수준) 추가. `(inst, k, v, handler, + timestamp)`를 훅에 넘기면 "무엇이 무엇을 바꿨는가" 이력의 원천이 됨. +- **`Source.luau` 생성자** — Source 인스턴스를 weak-keyed 전역 레지스트리에 + 등록하는 훅(기본 no-op). 켜졌을 때만 이 레지스트리가 존재하므로 GC-native + 원칙(`lifecycle-pattern.md`)과 충돌 없음 — 꺼져 있으면 레지스트리 자체가 + 안 만들어짐. +- **quad-roblox `DI/init.luau`의 제네릭 생성자(`new(className)`)** — 인스턴스 + 생성 순간 `debug.info(2, "sl")`로 caller의 script+line을 얻어 기록하는 + 훅(기본 no-op). Instance 생성은 프로퍼티 변경보다 훨씬 드물게 일어나므로 + (렌더 타임 1회), 여기서만 비교적 비싼 `debug.info` 호출을 해도 부담 적음. + +이 세 곳 모두 "인터페이스는 base가 정의, 실 구현은 quad-debug가 나중에 +주입"하는 기존 확정 원칙(`bind-system-plan.md` "base 유틸은 인터페이스, +실제 구현은 백엔드 팩토리가 주입")과 완전히 같은 모양 — quad-debug를 위해 +새 아키텍처 패턴을 만드는 게 아니라 기존 패턴을 재사용하는 것뿐. + +### 3. 표준 디버그 인터페이스 — 핸들러가 선택적으로 구현하는 5번째 훅 (사용자 제안) + +사용자 제안: "트윈이 뭐 땜에 일어나냐, quad 땜인지 아님 Ref로 밖에 나가진 +instance로 직접 트윈되어버리는지" 같은 걸 구분하고 싶음. 이건 디스패치 +이력만으론 부족함 — quad가 만든 Tween과 사용자 코드가 `Ref`로 얻은 raw +Instance에 직접 `TweenService:Create()`를 건 것을 구분하려면 **핸들러 자신만 +아는 맥락**이 필요. + +**제안**: `isHandlable`/`priority`/`process`/`retract` 4종 계약에 선택적 +5번째 훅을 추가 — `describe(inst, k, v): DebugInfo?`(가칭, 기본 미구현 += no-op과 동일 효과). quad-debug가 트레이스 이벤트를 기록할 때 해당 키를 +처리한 핸들러에게 `describe`가 있으면 호출해서 사람이 읽을 수 있는 부가 +정보(예: Tween 핸들러라면 "store-bind 유발" vs "직접 세팅"인지, 어떤 store +key에서 왔는지)를 이벤트에 덧붙임. `bind-system-plan.md`가 이미 "4종 계약은 +지금 확정이지만 실제 구현하며 부족한 지점이 보이면 그때 hook 추가(점진적 +확장)"라고 열어둔 것과 정확히 맞아떨어지는 케이스 — 새 원칙이 아니라 이미 +예견된 확장. + +### 4. 외부 변경 감지 — 보조 신호일 뿐, 핵심 채널로 쓸 수 없음(사용자 정정) + +위 3번의 한계: quad가 전혀 모르는 코드 경로(Ref로 얻은 raw Instance에 대한 +직접 조작)는 애초에 `process()`를 거치지 않으므로 quad-debug의 계측 +지점으로는 절대 안 잡힘. 처음 검토했던 방법: quad가 관리하는 인스턴스에 +대해 `inst:GetPropertyChangedSignal(prop)`(Roblox 엔진 자체가 모든 +인스턴스에 제공하는 범용 시그널)을 구독해두고, 변경 시점을 직전 quad +디스패치 이력과 타임스탬프로 대조해 "일치하는 트레이스가 없으면 외부 +변경"으로 표시하는 아이디어. + +**사용자 정정(2026-08-06)**: 이건 quad-debug의 핵심 가치와 맞지 않음 — +"이 프로퍼티가 바뀌었다"는 사실 자체는 `PropertyChangedSignal`로 누구나 +알 수 있고, quad-debug가 진짜 필요한 이유는 **그 변경이 quad의 어떤 +Store/파이프라인/handler에서 왔는지, 코드 몇 번째 줄에서 유발됐는지**를 +보여주는 것 — 순수 관찰(passive observation)만으로는 "무엇이 바뀌었다"만 +알 수 있을 뿐 "왜/어디서"는 증발함. **결론: `PropertyChangedSignal` 기반 +교차검증은 (실제로 크로스 컨텍스트에서 작동한다는 전제하에도) 기껏해야 +"quad가 설명 못 하는 변경이 있었다"는 보조 신호 정도이지, 핵심 트레이스 +채널의 대체재가 될 수 없음.** 핵심 채널(어디서/왜)은 여전히 능동적 계측 +(위 2번, 3번의 훅)에서 나와야 함 — 그 계측 데이터를 플러그인까지 실제로 +전달할 수 있는지는 실측으로 확인됨(아래 "데이터 채널" 절), 이 항목 +자체는 채택하더라도 어디까지나 보조 기능으로만 남음(백로그, 아래 "열린 +질문" 참고). + +### 5. 데이터 채널 — Attribute(스냅샷) + BindableEvent(스트림), **BindableEvent 크로스 컨텍스트 확인 완료** + +**상태(2026-08-06): 사용자가 Studio에서 실측 검증 완료 — BindableEvent가 +Plugin↔Play 중인 클라이언트(LocalScript) 경계를 실제로 넘는다.** +`plugin-ignoreme.luau`(Explorer에서 선택한 BindableEvent에 Connect)와 +`game-ignoreme.luau`(2초마다 반복 Fire)로 테스트한 결과, Fire와 플러그인 +수신이 거의 동일한 타임스탬프로 반복 확인됨(`14:10:42.243` Fire / +`14:10:42.243` Plugin 수신 등, 여러 사이클에 걸쳐 안정적). + +**사용자가 정리한 이유**: Play 모드에 들어가도 플러그인이 다루는 `game`은 +edit 모드와 **별도로 복제된 DataModel이 아니라 같은 DataModel**이고(Play +진입 시 "복사된 컨텍스트"라 부른 건 script identity/보안 컨텍스트가 +다르다는 뜻이지 Instance 자체가 별도 메모리에 복제된다는 뜻이 아니었음), +Roblox Luau 샌드박스가 분리하는 건 **스레드/스크립트 컨텍스트**뿐이고 +그 밑의 C++ 구현 userdata(Instance 자체)는 어느 컨텍스트에서 접근하든 +같은 참조를 가리킴 — 그래서 Instance 기반 메커니즘(시그널 Connect/Fire +포함)이 자연스럽게 경계를 넘어 작동함. `require()`가 컨텍스트별로 독립 +모듈 인스턴스를 주는 것(이전 확인 사항)과는 완전히 다른 층위 — 그건 +Lua 모듈 캐시가 컨텍스트별로 분리된다는 것이지 Instance 자체가 +분리된다는 뜻이 아니었음, 이번 실측으로 그 구분이 명확해짐. + +**`BindableFunction`(요청-응답)도 확인 완료**: `Invoke`→`OnInvoke` 실행→ +리턴값 수신까지 왕복이 여러 사이클에 걸쳐 안정적으로 동작(`ok=true, +"plugin-received"`). 이걸로 위 "React DevTools에서 가져올 아이디어" 3번의 +"기본은 얇은 스트림(BindableEvent), 상세는 on-demand 요청-응답 +(BindableFunction)" 구조가 양쪽 다 실측 검증됨. + +**설계 제약으로 반영할 관측(사용자 지적)**: 같은 스크립트 컨텍스트 안에서 +`BindableEvent`/`Function`은 원래 인자를 직렬화 없이 레퍼런스 그대로 +넘기는 게 문서화된 특징(RemoteEvent와 달리 함수/메타테이블도 그대로 +통과 가능)인데, **플러그인 경계를 넘을 때는 RemoteEvent와 비슷하게 +내부적으로 마샬링(직렬화/역직렬화)되는 것으로 보임** — 실측으로 함수 +자체를 못 넘겨본 건 아니지만(테스트는 단순 값만 사용), Instance/Plugin +간 별도 Luau VM 경계라는 점을 고려하면 합리적인 추정. **결론**: +trace 이벤트 페이로드는 처음부터 함수/클로저 없이 **순수 직렬화 가능한 +값(숫자/문자열/불리언/plain 테이블/Instance 참조)만** 담는다는 원칙으로 +설계 — 애초에 "State를 그대로 넘기고 플러그인이 나중에 `.Get()`한다"류의 +설계는 안 되고(State는 클로저를 담은 객체라 직렬화 불가능할 가능성이 +높음), 넘길 값은 항상 quad-debug가 미리 원시 값으로 변환해서 보내야 함. + +**남은 미확인 범위**: 이번 테스트는 (a) 같은 로컬 머신의 Play/Play Solo +클라이언트 컨텍스트, (b) 원시 값 인자만 확인함. 서버 컨텍스트나 복잡한 +중첩 테이블/Instance 배열 전달까지는 실제 구현 단계에서 재확인 권장 — +단, 1차 스코프(클라이언트 한정, 원시 값 위주 trace 이벤트)에서는 이번 +검증만으로 채널 자체의 실현 가능성은 확정됐다고 봐도 됨. + +- **Attribute**: 인스턴스 생성 시점 1회성 정보(생성 위치, "quad가 관리하는 + 인스턴스인가" 마커)에 적합 — DataModel 자체의 일부라 플러그인과 게임이 + 별도 Luau VM이어도 문제없이 공유됨(Selection 서비스로 바로 읽힘). 단점: + 문자열 크기 제약, 그리고 배포된 게임에 실수로 남으면 유저가 F9 콘솔이나 + Explorer로 내부 코드 경로를 볼 수 있는 정보 노출 위험 — `RunService:IsStudio()` + 가드가 필수(quad-debug require 자체가 옵트인이라는 1차 방어선 + IsStudio가 + 2차 방어선, 이중 게이팅). +- **Value 오브젝트(StringValue/ObjectValue 등)는 기각 — 사용자 확정**: + Attribute의 대안으로 자식 Instance로 값을 담는 Value 오브젝트도 검토했으나, + `:GetChildren()`을 호출하면 그대로 드러나 트리를 오염시킴(quad가 실제로 + 마운트한 자식과 섞여버려 `base/slot-plan.md`의 자식 재조정 로직이나 + 사용자 코드의 children 순회를 방해할 위험) — Attribute는 자식이 아니라 + 메타데이터라 이 문제 자체가 없음. **스냅샷성 데이터는 Attribute로 확정, + Value 오브젝트는 후보에서 제외.** +- **BindableEvent+BindableFunction(크로스 컨텍스트 확인 완료, 위치는 + 재검토)**: "지금 이 순간 일어난 일" 스트림은 BindableEvent, 특정 + Instance 선택 시 상세 정보 요청-응답은 BindableFunction — 둘 다 실측 + 확인됨(위 참고). + - **`ReplicatedStorage` 자동 생성 방식은 기각** — 개발자가 의도하지 + 않은 Instance를 게임 트리에 주입하는 부작용 때문(상세 경위는 + `archive/debug-channel-replicatedstorage-rejected.md`). 대신 Bindable을 + **quad 모듈 자신의 Instance 트리 안**(quad가 이미 설치돼 있는 위치 + 그대로, 새 위치를 따로 안 만듦)에 두고, `CollectionService` 태그로 + 노출 — 플러그인은 quad가 어디 설치됐는지 몰라도 + `CollectionService:GetTagged(tag)`로 바로 찾음(`GetDescendants()`로 + 전체 트리를 훑어 필터링할 필요 없음). 태그를 모듈 자신에 달지 + Bindable 각각에 달지는 취향 차이 — **사용자 확정**("큰 차이는 없는 + 엔지니어링 선택"). +- **공통 원칙(사용자 확정)**: 어떤 채널이든 **debug를 안 켰을 땐 CPU/메모리 + 영향이 사실상 없어야 함**(패시브하게 가볍게만 들고 있거나, 아예 아무것도 + 안 함) — 무거운 트레이스 함수 실행은 debug를 켠 다음부터만. 이건 위 + "계측 지점 3곳"의 no-op 훅 upvalue 패턴과 정확히 같은 원칙의 재확인. + +### 6. "관측해야 실체화된다" 원칙 — quad-debug 자신이 위반하면 안 됨 + +`bind-system-plan.md`의 전역 원칙: 어떤 파생값도 `:Get()`으로 직접 +읽히기 전까지 계산되지 않음. quad-debug UI가 트리뷰를 그리면서 모든 State를 +자동으로 펼쳐 값을 미리 읽어버리면, 원래 필요 없었을 계산을 디버그 도구가 +유발하는 부작용이 생김 — **디버그 도구 자체도 lazy해야 함**: 사용자가 UI에서 +노드를 명시적으로 펼칠 때만 그 시점의 값을 읽고, 자동 폴링/자동 전개는 +지양. 이건 UI 설계 시 지켜야 할 제약으로 문서화만 해두고 지금 확정할 필요는 +없음. + +### 7. UUID 기반 on-demand compute — "관측"을 플러그인 클릭으로 명시화 + +debug 모드가 켜지면 quad의 내부 객체(Source/State/handler 등)에 uuid를 +부여해 trace 이벤트와 함께 플러그인에 넘김(위 "데이터 채널" 절의 페이로드 +제약 — uuid 자체는 순수 문자열이라 문제없음). 플러그인 UI에서 특정 +State 노드를 클릭하면 그 uuid로 `BindableFunction`을 통해 "지금 이 값을 +계산해서 보여줘" 요청을 보내고, quad-debug-roblox가 해당 uuid에 +대응하는 실제 State를 찾아 `.Get()`을 호출해 원시 값으로 변환해 돌려줌 — +**사용자 제안**, 위 "6. 관측해야 실체화된다" 원칙과 정확히 맞아떨어짐 +(플러그인 클릭이 곧 명시적 관측 행위). + +**안전 문서화 경고 필요(사용자 지적)**: 이 compute 호출은 여전히 부작용을 +일으킬 수 있음 — `purity-and-effects-plan.md`가 이미 Store는 부작용 +허용이 기본이라고 확정해뒀고, Compute 함수는 원래 "State가 최신 상태를 +요구받는 시점"에만 실행되는 게 전제인데, 플러그인이 임의의 시점(사용자가 +UI를 클릭한 순간)에 강제로 그 계산을 트리거하는 건 이 전제를 벗어남 — +당장 문제를 일으키진 않더라도, quad-debug 문서에는 "State를 눌러보는 +행위 자체가 그 계산과 딸린 부작용을 실행시킨다"는 걸 명확히 경고해야 함. + +**비직렬화 값의 표시**: 함수/클로저처럼 순수 직렬화 불가능한 값은 +`print`류 다른 디버깅 도구들이 흔히 하듯 `"function"` 같은 플레이스홀더 +라벨로 표시(루아 사이드에서 포인터 주소를 얻는 표준적 방법은 없어 보임, +필요하면 재조사). 다만 사용자가 직접 만든 복잡한 값(예: 커스텀 Tween류 +객체)이 그냥 raw 테이블로만 보이면 알아보기 힘드므로, **개발자가 자기 +타입에 대해 "디버거에 어떻게 보여줄지"를 지정할 수 있는 선택적 직렬화 +인터페이스**를 제공하는 것도 검토할 만함(사용자 제안) — 단, 사용자가 +강조한 대로 **오버엔지니어링 경계 주의 — 디버깅 도구는 한정된 규모에서 +도움이 되는 게 목적이지 모든 걸 다 예쁘게 보여주는 게 목적이 아님**, +구현 비용/이점/타당성을 따져서 결정. + +### 8. Element Inspector — 마우스로 UI 요소 피킹 (사용자의 실제 pain point) + +사용자가 직접 겪은 문제: Roblox가 최근 Play 중 라이브 UI 편집 도구를 +꺼버려서, 실제 화면에 보이는 UI 요소의 위치를 찾으려면 Explorer를 계속 +펼치고 접으며 찾거나 검색해야 하는데, quad로 만든 요소는 보통 이름을 +잘 안 지정해서 특히 힘듦. **웹 devtools의 "inspect element"처럼 화면을 +클릭해서 바로 그 자리의 (quad가 관리하는) UI 요소를 선택하는 도구가 +필요** — 최상위에 클릭을 가로채는 투명 레이어를 하나 띄우고 마우스 +위치를 추적, 그 좌표에 있는 요소를 히트테스트해서 quad 요소로 필터링해 +사용자에게 보여주는 방식. Explorer 기반 트리뷰(위 "핵심 설계 방향" 1번)를 +보완하는 별도 진입점 — "무엇을 선택할지도 모르는 상태에서 화면만 보고 +찾아 들어가야 하는" 초기 탐색 단계의 마찰을 없애는 게 목적. 사용자가 +이번 논의에서 원래 요청("Explorer에서 코드 위치를 알려주는 플러그인") +보다 실제로는 더 크게 느낀 pain point로 언급. + +### 9. Explorer ↔ 플러그인 트리 동기화, UI 아키텍처 확인 (2026-08-06) + +**질문**: 플러그인 자체 트리에는 없는 내부 구현 디테일(예: 특수 핸들러가 +자동 생성해 붙인 자식 Instance)을, 사용자가 Roblox 기본 Explorer에서 +직접 선택하면 어떻게 처리할까? + +**사용자 확정 — 두 경우로 분기**: +- 플러그인 트리에 대응 노드가 **없는** 내부 전용 자동 생성물(quad가 + bind/track하지 않고 그냥 만들어 붙여만 둔 것)이면, 플러그인 트리가 + 실제로 알고 있는 가장 가까운 **부모**를 대신 선택/하이라이트. +- 플러그인 트리에 대응 노드가 **있는** 경우(예: 사용자가 어떤 Instance를 + 직접 컴포넌트화해서 quad로 bind한 경우 — 이건 UB가 아니라 충분히 유효한 + 사용법, `base/component-composition-plan.md`의 "컴포넌트 = 플레인 함수" + 원칙과도 맞음) 트리에 있는 그 실제 노드를 정확히 선택. + +**네이밍 컨벤션(사용자 제안)**: 내부 자동 생성 helper Instance는 `_`나 +`QUAD_` 같은 접두어를 붙여서 Explorer에서 직접 봤을 때 헷갈리지 않게 함 — +이름 바꾸는 건 비용이 크지 않음. v1이 이미 `_quad_round`/`_quad_padding`/ +`_quad_scale` 네이밍(`base/ui-shorthand-plan.md` 참고)으로 정확히 +이 관습을 썼던 전례 — quad-v2에서 내부 자동 생성물이 생기면 그대로 +재사용. `research/documentation-plan.md`의 "UI 네이밍 컨벤션 문서" +백로그에도 이 구체적 규칙을 추가해둠. + +**플러그인 UI 아키텍처 확인(사용자 질문에 대한 답 — 맞음)**: 세 개의 +구분된 상호작용면으로 구성됨 — +1. **자기 트리 뷰** — React DevTools 컴포넌트 트리처럼, 플러그인 자체 + `DockWidgetPluginGui` 안에 quad가 관리하는 계층을 보여줌(위 "핵심 + 설계 방향" 1번, "무엇이 무엇에 연결됐는가" 그래프). +2. **리프 클릭 → 상세/상태 패널** — 노드(State 등)를 누르면 그 상세를 + on-demand로 보여줌(위 7번, UUID 기반 compute-on-click). +3. **실제 Instance 선택과의 연동** — Roblox 기본 Explorer에서 직접 + 선택하거나(`Selection` 서비스로 감지, 위 이 절의 동기화 규칙), 또는 + Element Inspector(위 8번)로 화면을 클릭해서 선택하면, 그 실제 + Instance에 대응하는 노드가 1번의 자기 트리 뷰에서 하이라이트/선택됨. + +Explorer(Studio 기본 창)와 플러그인의 트리 뷰(`DockWidgetPluginGui`)는 +**서로 다른 별도 창** — 하나로 합쳐진 UI가 아니라 나란히 떠 있는 도킹 +위젯 두 개고, 3번이 그 둘을 이어주는 동기화 레이어. + +## React DevTools에서 가져올 아이디어 (2026-08-06 조사) + +서브에이전트로 React DevTools 오픈소스(`facebook/react` 내 +`react-devtools-shared` 등)를 조사. 그대로 베낄 순 없지만(브라우저 +익스텐션 ↔ 웹페이지 구조는 Roblox와 다름) 4가지 발상 중 2개는 상당히 +바로 적용 가능: + +### 1. 전역 훅 주입(`__REACT_DEVTOOLS_GLOBAL_HOOK__`) — 참고는 되지만 그대로는 못 씀 + +익스텐션이 React 로드 *전에* `window.__REACT_DEVTOOLS_GLOBAL_HOOK__`을 +먼저 심어두고, React 렌더러가 부팅하며 그걸 찾아 `hook.inject(...)`로 +스스로 등록하는 "로드 순서 무관 레지스트리" 패턴 — Fusion `External.luau`의 +"이미 로드된 모듈의 업밸류를 나중에 스왑"과는 다른 축(React 쪽은 "누가 +먼저 로드되든 상관없게", Fusion 쪽은 "함수 포인터 교체"). quad-debug에도 +개념은 유효하나, Roblox는 플러그인/게임이 애초에 별도 프로세스(VM)라 +"전역"이 그 경계를 못 넘는다는 근본 제약이 있어 그대로 못 씀 — 이미 알고 +있는 문제(위 "데이터 채널" 절)와 동일선상. + +### 2. 소스 위치 캡처는 런타임 스택 트레이스가 아니라 **컴파일타임 주입** — 유력한 대안 후보 + +확인 결과 React DevTools의 "이 컴포넌트가 어디서 정의됐나"는 런타임 +스택 트레이스에 전혀 의존하지 않음 — `@babel/plugin-transform-react-jsx-source`가 +**빌드 타임에** 모든 JSX 생성 호출에 `__source: {fileName, lineNumber, +columnNumber}`를 리터럴로 박아 넣는 컴파일타임 트랜스폼. 런타임엔 이미 +값으로 존재. + +**quad-debug 적용 후보**: 위 "계측 지점 3곳"에서 제안한 +`debug.info(2, "sl")` 런타임 캡처(DI 제네릭 생성자, 호출 시점 caller +위치)의 대안/보완으로, **darklua** 같은 빌드타임 Luau 변환기로 quad +생성자 호출부를 순회하며 파일/라인 리터럴을 인자로 미리 주입하는 방식을 +검토할 만함. `debug.info`가 "호출자(caller)의 정확한 라인"을 항상 +안정적으로 못 주는 경우(꼬리 호출 최적화, 인라인화 등)에 특히 유용 — +런타임 계측보다 신뢰도가 높을 가능성. 단, **darklua를 빌드 파이프라인에 +편입해야 한다는 전제가 새로 생기므로**(지금 프로젝트는 아직 별도 +빌드/번들 단계가 없음, 순수 Rojo 싱크) 실제 채택은 quad-debug 착수 +시점에 비용 대비 검토. + +### 3. 얇은 operation diff + on-demand 상세조회 — 데이터 채널 설계에 바로 적용 가능 + +content script(페이지) ↔ devtools panel은 별도 프로세스라 매 커밋마다 +전체 트리를 보내지 않음 — **압축된 "operation" 배열**(add/remove/reorder +같은 짧은 코드 시퀀스)만 기본으로 보내고, props/state 같은 무거운 데이터는 +사용자가 실제로 그 노드를 선택했을 때만 별도 요청-응답(`inspectElement`)으로 +가져옴. "기본은 얇은 델타, 상세는 온디맨드"라는 원칙. + +**quad-debug 적용**: BindableEvent 크로스 컨텍스트 검증 결과와 무관하게 +(되든 안 되든, 채널이 무엇이든) 이 원칙 자체는 그대로 채택할 만함 — 매 +`process`/`retract` 호출마다 전체 상태를 흘려보내지 않고 "무슨 일이 +있었다"는 최소 메타데이터(대상 id, key, handler id, timestamp)만 기본 +스트림으로 보내고, 플러그인이 실제로 그 Instance를 선택했을 때만 상세 +정보(생성 스택, props 스냅샷)를 별도로 가져오는 구조. 이러면 채널 +대역폭/오버헤드 문제(위 "공통 원칙 — debug 꺼졌을 때 영향 없어야 함"과 +직결)가 크게 완화됨. + +### 4. flash-on-update 오버레이 — 전체 상시 적용은 기각, 범위를 좁혀 채택 + +원안: `getBoundingClientRect()`류(Roblox면 `AbsolutePosition`/ +`AbsoluteSize`, 3D면 바운딩 박스)를 읽어 오버레이 박스를 그리고 매 +커밋마다 갱신 — Studio 플러그인 오버레이(`Highlight`/`SelectionBox`/ +`BoxHandleAdornment`)로 이식 가능한 아이디어 자체는 유효. + +**사용자 정정(2026-08-06)**: 이걸 quad가 관리하는 **모든** Instance의 +**모든** 프로퍼티 변경에 상시 적용하면 안 됨 — "정말 많은 것들이 다 +반짝일 것"(노이즈)이고 추적 비용도 큼. 범위를 좁혀서 채택: + +- **Instance 마운트/언마운트(생성/파괴)는 상시 flash 가능** — 사용자에게 + "뭔가 새로 생겼다/사라졌다"는 notice로 유용하고 빈도도 낮아 비용 문제 + 없음. +- **개별 프로퍼티 변경 flash는 플러그인에서 현재 열어본(inspect 중인) + Instance 한정** — 위 "핵심 설계 방향" 7/8번의 on-demand 상세조회 + 패널을 연 상태에서만 그 Instance의 값 변경을 반짝이게 표시, 나머지는 + 안 함. "얇은 스트림 + on-demand 상세"라는 이미 확정된 프로토콜 모양 + (아래 3번)과도 자연히 맞아떨어짐. +- **백로그(낮은 우선순위, 사용자 확정)**: 선택된 Instance에서 quad가 + 건드리지 않은 프로퍼티 중 기본값이 아닌 것까지 같이 알려주는 기능 — + "쉽다면 있으면 좋겠지만 엄청 중요하진 않다"는 평가, 초기 설계 시 + 가능성 정도만 열어두고 실제 채택은 나중에. + +## quad-mock 백로그와의 관계 + +`architecture.md`의 기존 백로그("범용 렌더 디버깅 도구로서의 quad-mock, +Tween mock 등 동적 동작 포함")와 목적이 다름: + +| | quad-mock 확장판 (기존 백로그) | quad-debug (이 문서) | +|---|---|---| +| 실행 환경 | Studio 불필요, 순수 `luau` CLI, CI | Studio Play 세션, 실제 엔진 | +| 시점 | 오프라인 스냅샷/리플레이 | 실시간 라이브 관찰 | +| 목적 | 렌더 결과 회귀 검증 | 실사용 중 버그 위치 역추적 | + +**공유 가능한 기반**: 둘 다 "quad 내부 이벤트(process/retract 호출, Source +변경)를 관찰 가능한 스트림으로 노출하는 계측 레이어"가 필요하다는 점은 +같음 — 위 "계측 지점 3곳"에서 정의하는 trace 이벤트 스키마를 하나로 +설계해두면, quad-mock(오프라인 검증)과 quad-debug(실시간 스트리밍)가 같은 +이벤트 포맷을 재사용할 수 있음. 지금 당장 통합할 필요는 없고, quad-mock을 +실제로 확장하게 될 때 이 문서를 먼저 참고하라는 정도로만 기록. + +## 패키지 구조 제안 (가칭, 확정 아님) + +기존 `quad-base`/`quad-roblox` 경계 원칙을 그대로 따름 — base는 인터페이스만, +실 구현은 백엔드/애드온이 주입: + +- **`quad-debug`** — 엔진 무관 core. trace 이벤트 스키마 정의, 위 5개 훅 + 지점의 no-op 기본 구현, 이벤트 버퍼/필터링 같은 순수 로직. `quad-base` + 자체에 넣지 않고 별도 패키지로 두는 이유: `quad-base`는 프로덕션 코드가 + 항상 의존하는 코어라 디버그 전용 코드를 섞고 싶지 않음(위 리서치 5번, + 나중에 완전 제거하고 싶을 때도 별도 패키지면 그냥 require 자체를 안 하면 + 끝). +- **`quad-debug-roblox`** — 게임(클라이언트) 쪽에서 require하는 provider. + quad-roblox의 Dispatch/DI에 실제 훅을 꽂고, BindableEvent/Function을 + **quad 모듈 자신의 Instance 트리 안에** 만들어 CollectionService + 태그로 노출(위 "데이터 채널" 절 — `ReplicatedStorage` 등 게임 트리에 + 별도 주입 안 함), `IsStudio` 가드 포함. +- **`quad-debug-roblox-plugin`** — Studio 플러그인. `DockWidgetPluginGui` UI, + `Selection` 서비스로 Explorer 선택 감지, 브릿지 BindableEvent 구독, + 연결 그래프/트리뷰/flash 렌더링. + +## 지금 로드맵에 반영할 것 (최소한만) + +사용자도 동의한 대로 지금 구현할 단계는 아님 — 다만 아래는 관련 마일스톤 +설계 시 "고려는 해두되 지금 확정/구현하지는 않는" 참고용 메모로 남김 +(`ROADMAP.md`의 M2/M3/M5 근처에 훅 확장 지점 존재 가능성만 인지해두는 정도): + +- M2(디스패치 엔진) 구현 시 `process`/`retract` 스캔 루프에 나중에 훅 + 하나를 끼워 넣기 쉬운 모양으로 짜여 있는지 정도만 유의(지금 훅 자체를 + 만들 필요는 없음). +- M3(Source) 구현 시 마찬가지로 나중에 weak-registry 등록 훅을 끼우기 + 쉬운 생성자 모양인지만 유의. +- M5(quad-roblox DI 제네릭 생성자) 구현 시 caller 정보를 나중에 끼워넣기 + 쉬운 단일 진입점(생성자 함수 하나)인지만 유의 — 이건 이미 + `bind-system-plan.md`가 "제네릭 생성자 함수 하나로 통일"이라 확정해둔 + 것과 자연히 맞아떨어짐, 별도 조치 불필요할 가능성이 큼. + +**중요**: 위는 "이런 게 나중에 필요할 수 있으니 지금 설계를 크게 바꾸라"는 +게 아니라, 이미 확정된 설계(단일 디스패치 진입점, 단일 생성자 진입점)가 +우연히도 계측 친화적이라는 걸 확인해두는 것에 가까움 — M0~M11 순서/범위 +자체를 바꿀 이유는 없음. + +## 열린 질문 (`.claude/question.md`에도 취합) + +기술적 실현 가능성은 전부 해소됨(위 핸드오버 요약) — 아래는 우선순위별로만 +분류, 다음 세션 진행을 막는 항목 없음. + +**해소됨 (2026-08-06 후속 세션)** + +- "이벤트 함수들이 실제 instance를 읽을 수 있게 self를 건네받는 게 quad의 + 관습"이라는 언급 — v1 `event.lua`의 `func(self or this, ...)` 관습이 + 실존함은 확인됐으나(v1 튜토리얼에도 문서화), **quad 재설계에서는 + 채택하지 않기로 확정**. Ref가 이미 인스턴스 접근 용도를 커버하고, + thin wrapper를 준다면 Modifier 정적 flatten과 경쟁하는 두 번째 쓰기 + 경로가 생겨 오버엔지니어링/성능/디버깅 추적성 문제가 생긴다는 게 + 이유 — 클로저 래핑 비용도 근거로 추가됨. 상세 근거와 결정문은 + `base/bind-system-plan.md`의 "이벤트 핸들러는 self(Instance)를 받지 + 않는다" 절 참고. quad-debug 입장에서는 self/thin wrapper 경로가 + 아예 없어지므로 오히려 계측 대상이 단순해짐(추적 안 되는 경로 자체가 + 존재하지 않게 됨). + +**세부 API 이름 (후순위, 구현 착수 시점에 자연히 정리)** + +- `describe`(가칭) 5번째 핸들러 훅의 정확한 시그니처/이름. +- Attribute 이름 네임스페이싱(`__quadSource`류)과 노출 정보 범위(스크립트 + 전체 경로를 노출해도 되는지, 파일명만 남길지 등 보안/정보노출 고려). + +**백로그(채택 여부만 남음, 핵심 설계와 무관)** + +- 외부 변경 감지(핵심 설계 방향 4번)를 보조 신호로라도 실제로 켤지 — 타이밍 + 매칭 정확도는 프로토타입 단계에서 검증 필요. +- 서버 확장(RemoteEvent) 여부 — 필요성이 실제로 드러나면 그때. +- quad-mock과 trace 이벤트 스키마를 실제로 공유할지, 아니면 별도로 갈지 — + quad-mock 확장 착수 시점에 재검토. +- 인스턴스 변경을 추적하는 전용 새 Source 메커니즘(다크패턴 방지용으로 + 사용자가 제안했다가 스스로 낮게 평가) — quad가 이미 생성 객체에 + `GetPropertyChangedSignal` 역바인딩 옵션을 제공해서 3줄로 되는 것과 큰 + 차별점이 없어 보임, 타당성 조사만 백로그로. + +**범위 밖 — 별도 문서로 분리됨** + +- 이번 논의에서 파생된 문서화 숙제(UI 네이밍 컨벤션, Store 부작용을 게임 + 시스템에서 깔끔하게 쓰는 패턴)는 quad-debug 범위가 아니라 + `research/documentation-plan.md`로 분리해 뼈대만 기록함. diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md new file mode 100644 index 0000000..e75ee54 --- /dev/null +++ b/.claude/research/documentation-content-map.md @@ -0,0 +1,261 @@ +# 문서 콘텐츠 분류 맵 (초심자/api/심화/skip) + +**상태**: research — `documentation-plan.md` 0번 항목(3축 구조: 초심자/api/심화 ++ 백엔드별 트랙 분리)이 확정된 뒤, 실제로 각 축에 뭘 채울지 `.claude/base/*.md` +전체 + 관련 `research/*.md`(tween-plan, ui-shorthand-plan)를 2026-08-06 세션에 +6개 에이전트로 병렬 서베이해 분류함. **아직 문서를 쓰라는 뜻 아님** — 착수 +시점은 여전히 구현 우선(`CLAUDE.md` "지금 할 일" 1번). 나중에 실제 문서화를 +시작할 때 이 맵을 목차/우선순위표로 쓰면 됨. + +**api↔심화 연결 원칙(사용자 확정)**: api 문서는 항목마다 설명을 간략하게 +유지하고, 근거·내부 동작까지 파고드는 내용은 심화 섹션으로 링크("더 알아보기 +→ 심화")하는 방식으로 연결. 아래 [api] 항목 중 "→심화"가 붙은 것들이 이 +패턴 대상. + +**분류 기준**: [초심자] core loop에 필수(백엔드 구체적, quad-roblox 기준) / +[api] 레퍼런스, 빠른 룩업용 짧은 설명 / [심화] 왜 이렇게 설계했는지, 최적화· +대규모 코드베이스 관리 관심자용 / [skip] 내부 설계 과정 기록, 최종 사용자 +문서엔 안 들어감(세션 날짜, 정정 이력, 조사 원자료 등). + +--- + +## 1. 초심자(getting-started) core loop — 취합된 목차 초안 + +전체 서베이에서 나온 [초심자] 항목을 실제 학습 순서로 재배열한 것. 이대로 +목차를 잡으면 좋아 보임(그대로 확정은 아니고 초안): + +1. **초기화** — `RobloxFactory(QuadBase)`로 base+backend 조립 (`module-lifecycle-plan.md`, `bind-system-plan.md`) +2. **Instance 만들기** — DOMless 즉시 생성 모델, 제네릭 `new` + 자주 쓰는 ~25개 클래스 정적 필드(`Frame`, `TextButton` 등) (`architecture.md`, `bind-system-plan.md`) +3. **속성 채우기** — `[Attribute "Name"]`, `[Tag ""] = true` 특수 바인드 키 (`architecture.md`) +4. **반응형 기초** — `Source`/`Store` 생성, `store.key`(dot-access)로 Source 읽기(Source는 State를 만족), `store.key:Set(value)`로 쓰기, State는 항상 읽기 전용 (`bind-system-plan.md`, `store-semantics.md`; 2026-08-06 후속 세션에서 dot-access가 Source를 직접 반환하고 쓰기가 `:Set()`으로 바뀜) +5. **스타일링** — Modifier 기본 체이닝(`:FontSize(14)`), 배열/인라인 merge 우선순위 규칙 (`modifier-plan.md`) +6. **자식 전달** — Slot 기본 개념(children 배열, add/remove/clear), 마운트된 slot 재마운트 시 throw (`slot-plan.md`) +7. **컴포넌트 작성** — 컴포넌트 = 순수 함수, 리프 프로퍼티엔 State만 바인딩, 전역 store 직접 참조 금지(이식성) (`component-composition-plan.md`, `purity-and-effects-plan.md`) +8. **컴포넌트 경계 넘기기** — `props.Modifier`/`props.Ref` named parameter 패턴 (`component-composition-plan.md`) +9. **이벤트** — self(Instance) 안 받음, 문자열 키(`Frame { MouseButton1Click = fn }`) (`bind-system-plan.md`) +10. **생명주기** — GC 위임(수동 정리 불필요), Destroy 이후 대상 재사용 금지 (`lifecycle-pattern.md`) +11. **Ref 기초** — 외부 관리 Instance 참조/마이그레이션용, `Ref(default):Callback(fn)`을 children 배열 숫자 슬롯에 직접 놓기 + 배열 위치로 자식 전/후 표현, "프로퍼티보다도 먼저" 필요할 때만 `PreRef`(2026-08-07 세 번째 세션, `phase` 옵션 폐기) (`architecture.md`, `bind-system-plan.md`) +12. **파생값 최소 예시** — `:With(...)` + `:Compute(fn)` 기본형 (`bind-system-plan.md`, `store-semantics.md`) +13. **Tween 기초** — `[Tween(key, ...)] = storeValue`, 취소 시 현재 보간값에서 자연스럽게 이어짐 (`research/tween-plan.md`) +14. **UI 숏핸드(quad-roblox 한정)** — `UICorner`/`UIPadding`/`UIPaddingOffset`/`UIScale` 인라인 키 (`base/ui-shorthand-plan.md`) + +--- + +## 2. 파일별 상세 분류 + +### architecture.md +- 초심자: DOMless 즉시 Instance 생성 모델 / 특수 바인드 키 / Ref 기본 개념 / modifier 기본 사용법(스타일링) / Store·State·Source 온톨로지 핵심 동작 / quad-base·quad-roblox 패키지 구조 존재 사실 +- api: Class 함수형+`:` 체이닝 예외 규칙 / store 바인드=전체 교체 의미론 / Tag/retract(CollectionService 기반) / modifier 병합 우선순위 규칙(→심화: CSS cascade 회피 근거) / PropertyChangedSignal이 pluggable 핸들러로 구현 / Source·State·Store 타입 정의 +- 심화: Class가 OOP 아닌 함수형인 이유 / metatable 체이닝 폐기 이유(v1 clone 문제) / id 기반 전역 조회 폐지 이유 / Style(Default) 시스템 폐기→modifier 대체 근거 / 멀티 백엔드(GTK 등) 지향 이유 / push-invalidate·pull-recompute 전파 모델 상세, 다이아몬드 의존성 해결 근거 +- skip: Tracker 미구현, lang 모듈 분리, Signal 클래스 미구현 판단 과정, 소스 트리·모노레포 구조, 테스트 전략(mock 설계) + +### comparison-fusion-vide.md — 대부분 skip(내부 리서치 스냅샷) +- **quadnomicon**으로 재작성 가치 있는 것 두 개(2026-08-06 재분류 — 원래 + 심화 후보였다가, 독자층이 "quad 사용자"가 아니라 "프레임워크 설계 자체에 + 관심 있는 엔지니어"라 quadnomicon으로 이동): **Slot 단일 마운트 소유권**이 + Fusion/Vide 둘 다에 없는 quad만의 차별점(Fusion/Vide 경험자 대상 "왜 이중 + mount를 막는가" 비교 소재) / **`:With`+`:Compute` 명시적 파생값**이 Vide의 + 암묵적 ambient stack 대신 채택된 이유(Vide 경험자 대상 비교 설명, 원문 + 재작성 필요) — 단, "왜 Slot은 단일 마운트를 강제하는가" 자체(다른 프레임워크 + 비교 없이 quad 논리만으로 설명 가능한 부분)는 여전히 심화에 남음(아래 4번 + 9번 항목). +- 나머지(Fusion 반응 그래프 BFS 분석, Scope 정리 모델, Vide 디스패치 분석, 비교표 전체)는 전부 내부 설계 근거 수집용, skip — `.claude/` 내부 설계사로만 남고 publish 대상 아님 + +### quad-v1-architecture.md — 전체 skip +v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 회고. 단, **v1에서 +넘어오는 기존 사용자용 마이그레이션 가이드**가 나중에 별도 문서로 계획된다면 +그때만 재사용 가치 있음 — 지금 3축 어디에도 해당 없음. + +### bind-system-plan.md (943줄, 최대 문서) +- 초심자: Source/Store/State 기본 정의+생성자, State 읽기 전용 규칙 / dot-access가 값 읽기 1급 경로 / `:With`+`:Compute` 최소 사용법 / Ref 기본 개념(children 배열에 직접 놓기, 별도 `CreatedRef` 없음) / 이벤트 self 미채택 기본 규칙+문자열 키 / 인스턴스 생성(제네릭+정적 필드) / 라이브러리 초기화 3줄(`RobloxFactory(QuadBase)`) +- api: `state:Observer(fn)` 사용법(→심화: weak-table 내부 인덱싱) / `:Subscribe()`/`:Unsubscribe()` 시그니처(→심화: 강참조 레지스트리 구조) / Ref 일반화 표면 API(→심화: "왜 값이 아니라 콜백인가") / 이벤트 store-bind 존재+권장 안 함 가이드(→심화: 엔지니어링 비용 근거) / 핸들러 4종 계약(`isHandlable`/`priority`/`process`/`retract`) / `Attribute` 특수 키 후보(미확정 명시 필요) +- 심화: push-invalidate/pull-recompute 전파 모델+"관측해야 실체화된다" 원칙+`previous` 캐비엇 / **왜 State를 Modifier처럼 플래튼하지 않는가**(이미 문서화 완료, 아래 3번 참고) / Store가 Store를 못 담는 이유 / 이벤트 self 미채택 4가지 근거 / store-bind 재귀 래핑 내부 메커니즘, retract가 Destroy 시 호출 안 되는 이유 / 같은 팩토리 재호출 no-op·다른 팩토리 충돌 에러 내부 안전장치 +- skip: quad2-try 리서치 결과 섹션 전체(OOP 상속/커스텀 파서/Slot 스텁/`Pipe` 폐기 이력) / PA님 코드 교차검증 절(역사적 검증 기록) / "남은 열린 질문"/"확정된 것" 메타 요약 + +### component-composition-plan.md / module-lifecycle-plan.md +- 초심자: 컴포넌트=순수 함수 / 리프 프로퍼티 바인딩(**[정정, 2026-08-09 열한 번째 세션] "State만"이 아님 — 단순 원본 토글(`Frame{Visible=source}`)은 Source 직접 바인딩이 정상 경로, 여러 값에서 파생된 계산 결과일 때만 자연히 State가 됨, `component-composition-plan.md` 5번 절 참고**) / `props.Modifier`/`props.Ref` named parameter 경계 전달(**`props.Modifier or None`/`props.Ref or None` 필수 관용구 — 안 쓰면 nil-hole 버그, 2026-08-07 열 번째 세션 확정**) / `InitRoblox(Module)` 팩토리 초기화 +- api: State(파생, 읽기전용) vs Source(원본, 쓰기가능) 경계 요약(→심화) / Slot 반환 컴포넌트는 Modifier/Ref 파라미터 미선언 / `Modifier.Overridden(mod1, mod2, ...)` 유틸(구 `Merge`, `props.Modifier` 단일 슬롯용 특수 상황으로 한정 소개 — 아래 modifier-plan.md 절 참고) / Bind는 유일 슬롯(재호출 no-op, 충돌 에러, →심화) / `:With`/`:Compute`로 파생 State 생성 시그니처 / 모듈 싱글톤 스코프 +- 심화: v1 `Extend` 자동 store 소유 폐지 이유(React 벤치마킹) / Source가 State를 구조적으로 만족하는 서브타입 설계(2026-08-06 후속 세션 — `StoreSource` 프록시 중간안은 폐기되고 이걸로 대체됨, `store-semantics.md` 참고) / named-parameter 경계 방식 채택 이유(Compose/Fusion/Vide/v1 선례 수렴) / 다중 루트 반환 개념 제거 근거 / 팩토리 초기화 패턴 채택 이유(RBVM `InitNamespace` 반례) / Store 책임 분리(base가 `LifetimeHandle` 소유) / v1 named 체이닝 연산 폐기 +- skip: Compose/Fusion/Vide/v1 프레임워크 비교 원자료 / provider/processor 네이밍 미정 등 열린 질문 메모 + +### lifecycle-pattern.md / purity-and-effects-plan.md +- 초심자: 수동 정리 불필요(GC 위임) / Destroy 이후 재사용 금지 / 컴포넌트는 파라미터로 받은 store만 사용(전역 store 직접 참조 금지) +- api: `Connected`/canExecute 인터페이스(→심화) / 생명 바인드 유틸 시그니처(→심화) / 이식성 규칙이 린트 강제가 아니라 컨벤션이라는 사실 +- 심화: `Connected`가 계산된 속성인 이유(rbvm 근거) / `Instance.Destroying` 훅 단일화 이유 / weak-table GC-native 원칙+eager 정리 예외 / Signal 클래스 미채택 이유 / "quad는 생명주기 중간 계층이 아니다" 소유권 모델 / `retract` 네이밍 배경 / "순수함수 아니라 이식성 문제"로 재정의된 배경(vdom 없음 전제) +- skip: rbvm 조사 세션 메타, EventDrivenProgramming 교차검증 일화(결론만 심화에 남음) + +### modifier-plan.md / slot-plan.md +- 초심자: 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이 형제로 섞일 때 순서 보장 — 미확정. + **[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 +- 초심자: Store 생성+`myStore.key:Set(value)` 문법 / `store.key`로 State 얻기 개념 / Tween 기본 바인드 키+취소 기본 동작 / UI 숏핸드 인라인 키 기본 예시(`Frame { UIPaddingOffset = 50 }`) +- api: `:With`+`:Compute` 시그니처(→심화) / `source:Emit()` 존재+"Get() 결과 캐시 금지" 캐비엇(버그 유발 포인트라 api에도 명시 가치 있음, →심화; 2026-08-06 후속 세션에서 `Store:Emit(key)`→`source:Emit()`로 호출부 변경, `store-semantics.md` 참고) / Tween 핸들러가 Instance 직접 받음(Ref 불필요) / retract는 Destroy 시 호출 안 됨(→심화) / UI 숏핸드 키 목록 레퍼런스 표 / Modifier와 순수 인라인 키 동등성 +- 심화: Source·Store·State·Observer 온톨로지(독립 프리미티브 vs 파생 데이터 원칙, 생성자 모양 근거) / `Emit`이 Source 전용인 이유(디버깅 그래프 무결성) / `Store`의 T가 Modifier 불가인 이유 / Tween을 반응 그래프 밖 특수 bind key로 둔 이유(Fusion 반면교사) / RoundSize 포팅 불필요 vs UICorner/UIPadding/UIScale 필요 이유 / "작고 opt-in 아닌 편의 기능은 코어 포함" 원칙 +- 열린 질문(문서화 보류): tween-plan.md의 오버라이드/삭제후재시작/끝점이동 옵션 키 이름 미정 / ui-shorthand의 RoundSize 완전 드롭 여부 +- skip: 세션 정정 이력, v1 소스 조사 경위 + +--- + +## 3. 이미 작성 완료된 심화 콘텐츠 + +- **왜 State 체인을 Modifier처럼 플래튼하지 않는가** — `base/bind-system-plan.md` 해당 절에 결정문 있음(2026-08-06 세션에서 이 대화 중 확정). + +## 4. 심화 전용 신설 콘텐츠 후보 (반복 테마 정리, 에세이 단위) + +위 표에서 반복 등장하는 "왜" 주제들을 에세이 단위로 묶으면: + +1. 왜 함수형 컴포넌트인가(OOP 상속 대신) — `architecture.md`, `component-composition-plan.md` +2. 왜 Modifier는 런타임 pluggable이 아니라 정적 flatten인가 — `modifier-plan.md` +3. 왜 push-invalidate/pull-recompute인가(Fusion eager 노드 미채택) — `bind-system-plan.md` +4. 왜 State는 플래튼하지 않는가 — 작성 완료(위 3번) +5. 왜 GC-native 생명주기인가(Signal 클래스 없음) — `lifecycle-pattern.md` +6. 왜 이벤트 핸들러는 self를 안 받는가 — `bind-system-plan.md`, `research/documentation-plan.md` 3번과 통합 가능 +7. 왜 컴포넌트 경계는 named parameter인가(Compose/Fusion/Vide/v1 수렴) — `component-composition-plan.md` +8. 왜 "다중 루트 반환" 개념을 없앴는가 — `component-composition-plan.md` +9. 왜 Slot은 단일 마운트 소유권을 강제하는가(v1/Fusion/Vide 대비) — `slot-plan.md`, `comparison-fusion-vide.md` +10. 왜 Tween은 반응 그래프 밖에 있는가 — `research/tween-plan.md` +11. 왜 `:Emit()`은 Source 전용이고 파생 State엔 없는가(호출부는 `source:Emit()`, 2026-08-06 후속 세션에서 `Store:Emit(key)`→이 형태로 정리) — `store-semantics.md` +12. 독립 프리미티브 vs 파생 데이터 — 생성자 모양을 결정하는 원칙 — `store-semantics.md` +14. 왜 컴포넌트는 전역 store를 직접 참조하면 안 되는가(이식성) — `purity-and-effects-plan.md` +15. 왜 Source가 State를 구조적으로 만족하는가(Svelte Writable/Readable과 같은 서브타입 모양, `RefSource`/`StoreSource` 중간안이 왜 기각됐는가) — `store-semantics.md`, `component-composition-plan.md` +16. **State 파생 체인 동작 원리** — emit이 아래로 전파되고, `Get()` 요청이 + 위로 거슬러 올라가 재계산된 뒤 다시 아래로 내려오는 흐름을 명확히 + 설명(Blocker/Effect 둘 다 이 흐름 위에서 동작하므로 선행 이해로 필요) + — `research/additional-primitives-plan.md` "문서화 백로그" 절 + (2026-08-06~07 신설) +17. **`:Compute` 함수 안에서 `if` 등으로 일부 의존값만 조건부로 사용하는 + 유연한 구조** — 명시적 의존성 선언(`:With`) 위에서도 실제 계산은 + 조건부로 일부만 쓸 수 있다는 팁 — `research/additional-primitives-plan.md` + "문서화 백로그" 절 +18. **Blocker 사용 가이드** — 파이프라인 최종 연산 지점(무거운 계산이 + 실제 일어나는 derived state)에 배치하는 게 원칙이라는 것, **네스팅 + 금지를 최우선으로 강조**(겹치는 배치는 각자 새 `Blocker`를 만들 것 — + 안 지키면 조용히 잘못된 시점에 조기 해제되는 원인 추적 어려운 버그로 + 이어짐) — `base/blocker-plan.md` +19. 여러 Source를 한꺼번에 바꿀 때 Blocker 없이도 중복 재계산/재대입을 + 피하는 파이프라인/업데이트 순서 팁(Blocker를 안 쓰는 단순 케이스용 + 보조 팁) — `research/additional-primitives-plan.md` "문서화 백로그" 절 + +(13번이었던 "Fusion/Vide 경험자용 비교 섹션"은 2026-08-06 재분류로 아래 6번 +`quadnomicon`으로 이동) + +## 6. `quadnomicon` — 4번째 축, 프레임워크 설계자용 (2026-08-06 신설) + +**독자층이 다름**: 심화(1~5번)는 "quad를 깊게 이해해 최적화하거나 왜 +이런지 이해하고 싶은 quad 사용자"용. `quadnomicon`은 "비슷한 반응형 UI +프레임워크를 직접 설계/포크하려는 엔지니어"용 — quad를 그냥 쓰기만 +한다면 평생 안 읽어도 무방한 콘텐츠. Rustonomicon 패러디로 이름 확정 +(사용자 선택). + +**현재 후보(둘 다 `comparison-fusion-vide.md`에서 재작성 필요, 원문 +그대로 쓰면 안 됨 — 지금은 우리 내부 리서치 원자료 톤)**: +1. Slot 단일 마운트 소유권이 Fusion/Vide 둘 다에 없는 quad만의 차별점 — + "왜 이중 mount를 막는가"를 Fusion/Vide 내부 동작과 나란히 비교 +2. `:With`+`:Compute` 명시적 파생값이 Vide의 암묵적 ambient stack 대신 + 채택된 이유 — Vide 경험자 대상 비교 + +**2026-08-06~07 후속 세션에서 추가된 후보(전부 `research/ +additional-primitives-plan.md`의 "문서화 백로그" 절이 원자료)**: +3. **왜 lexical Batch를 기각하고 대신 값 기반 Blocker를 택했는가** — + Solid `batch()`/MobX `runInAction()`류 lexical transaction이 Roblox의 + 협조적 스케줄링(코루틴 yield) 환경에서 왜 근본적으로 위험한지(전역/ + 코루틴 스코프 플래그가 새 코루틴 스폰·영구 yield에 어떻게 깨지는지 + 구체 시나리오) **+** 그 대안으로 `Blocker`(콜스택/코루틴이 아니라 + 값으로 지연 구간을 표현, 네스팅 의도적 미지원)가 어떻게 같은 문제를 + 구조적으로 우회하는지 나란히 비교 — Fusion/Vide 비교는 아니고 "설계 + 원리"형 에세이라 Rustonomicon 패러디 취지(비슷한 프레임워크 설계자용)와 + 잘 맞음. (2026-08-06 세션엔 "왜 Batch가 없는가"로만 다뤘다가, Blocker + 채택 후 2026-08-07 세션에서 비교 에세이로 재구성됨 — Batch(lexical) + 기각과 Blocker 채택은 별개 결정이니 혼동하지 말 것.) +4. 왜 Context가 없는가 — 얕은 버전(코루틴 키 weak table push-pop)조차 + quad가 정상 패턴으로 확정한 Slot 비동기 추가에서 조용히 깨지는 이유, + 완전 자동 버전이 Roblox Luau의 플랫폼 한계(thread-local 없음)로 불가한 + 이유, 명시적 타입 강제 Store 전달이 Context보다 안전한 이유(레이어드 + Store 대안도 왜 함께 기각됐는지 포함) +5. 왜 push-invalidate/pull-recompute 설계가 laziness와 재계산 방지를 + 최우선 목표로 뒀는가 — 위 `심화` 3번(`왜 push-invalidate/pull-recompute + 인가`)을 더 깊게 확장, `Blocker` 같은 파생 프리미티브가 이 목표 위에서 + 왜 자연스럽게 나왔는지까지 포함하는 설계 철학 에세이 +6. **왜 배열/해시 두 패스 순서를 안 뒤집는가, `PreRef`는 왜 그 예외로 + 따로 필요한가** (2026-08-07 세 번째 세션 원자료, `bind-system-plan.md` + "`phase` 옵션 폐기" 절 마지막 항목이 이 자리를 지목해뒀던 것 — 지금까지 + 여기 안 옮겨져 있었음) — "프로퍼티/이벤트가 항상 children/Ref보다 + 나중"이라는 순서를 고치는 대신 `PreRef`라는 별도 타입으로 예외를 + 빼낸 선택 자체가 에세이 소재. **여기 곁들일 후보 프레이밍(사용자 제시, + 2026-08-07, 정확한 정의는 미확정 — 아래 5번 목록 참고)**: `Ref`는 + `(v=Ref)` 매치 핸들러로 처리돼 다른 핸들러들과 + 같은 우선순위 스캔에 참여한다는 의미에서 "hook"(순서 등록 가능, 다른 + 값으로 교체되면 `retract`로 취소됨)에 가깝고, `PreRef`는 그 스캔 밖의 + 고정 pre-pass라는 의미에서 "pre-hook"(항상 최우선 고정, 순서/취소 + 개념 자체가 다름)에 가깝다는 구분 — quadnomicon 에세이로 쓸 때 이 + "hook"/"pre-hook" 용어 자체를 채택할지부터 먼저 확인 필요(복수 `PreRef` + 간 순서는 2026-08-07 아홉 번째 세션에서 해소됨 — 배열 index 순서 + 그대로, 별도 규칙 없음, `bind-system-plan.md` "PreRef" 절 참고. 취소 + 가능 여부는 여전히 미정 — PreRef는 fire와 동시에 소진되는 1회성 + pre-pass 참가자라 "취소"라는 개념 자체가 성립하는지부터 다시 볼 것). + +**publish 안 하는 것과의 경계**: 세션별 정정 이력, 조사 원자료(Fusion +반응 그래프 BFS 분석, quad2-try 죽은 코드 조사 등)는 quadnomicon에도 +안 들어감 — 그건 새 티어가 필요한 게 아니라 애초에 `.claude/` 내부 +설계사로만 남고 절대 publish 안 하는 것(RFC 논의 저장소 같은 성격, +위 각 파일 섹션의 skip 참고). quadnomicon은 잘 다듬은 소수의 큐레이션된 +에세이 공간이지, 내부 연구 기록을 그대로 옮기는 곳이 아님. + +**배경지식 자체가 깊은 주제(예: GC) 처리 방침**: 새 티어를 만들지 않음. +"quad가 GC를 어떻게 활용하는가"(quad 고유)는 심화에 그대로 두되, "GC란 +무엇인가" 자체를 가르치는 자체 튜토리얼은 안 쓰고 외부 좋은 자료로 +링크 처리 — 안 그러면 문서 프로젝트가 일반 프로그래밍 교육 쪽으로 +스코프 크리프될 위험이 있음(사용자 판단). + +## 5. 문서화 아직 보류(미확정 설계라 쓰면 안 됨) + +**[정정, 2026-08-09 열한 번째 세션] 아래 목록 중 상당수가 이미 해소돼 +있었음 — 이 절이 오래 안 갱신되며 stale해진 것, 실제 열린 것만 남기고 +해소된 건 표시만 남김(중복 조사 방지 목적, 지웠다가 나중에 또 조사하게 +되는 걸 막기 위해 흔적만 유지).** + +- **[해소됨]** Slot 형제 순서 보장 — `Dispatch.setLength`/ + `setOffsetSource`(Length/Offset)로 2026-08-09 여섯 번째 세션에 확정, + `bind-system-plan.md` "Length/Offset" 절 참고. +- Tween 오버라이드/삭제후재시작/끝점이동 세부 옵션 키 이름, 트윈 옵션 값 + 모양(TweenInfo vs 편의 필드) (`research/tween-plan.md`) — 아직 열림. +- **[해소됨]** `Attribute` 제네릭 vs 타입별 정적 생성자 — 2026-08-09 + 열한 번째 세션에 "둘 다 채택"으로 확정, `base/attribute-plan.md` 참고. +- provider/processor 네이밍 — **[해소됨]** `Handler`로 이미 오래전 확정 + (`base/module-lifecycle-plan.md`), 이 줄이 그 갱신을 놓치고 있었음. +- **[해소됨]** 키 기반 동적 컬렉션 재조정 최종 이름/시그니처, `Slot:Extract` + 세부 시맨틱 — `Slot:List(data, updateFn, keyFn?)`로 2026-08-09 세 번째 + 세션에 전부 확정·통합(`base/slot-plan.md`), `Extract`도 CRUD 표에서 + 완전히 확정(2026-08-09 열한 번째 세션엔 `Extract(index, newElement?)`로 + 더 확장). `research/additional-primitives-plan.md`는 더 이상 열린 + 항목 없음, 배경 자료로만 유지. +- **"hook"/"pre-hook" 용어 채택 여부 + `PreRef`의 취소 가능성** (2026-08-07, + 위 심화 후보 6번 참고) — `bind-system-plan.md`는 `PreRef`가 위치 무관 + 호이스팅이라는 것과 일반 `Ref`가 우선순위 스캔에 참여한다는 것까지는 + 확정해뒀고(복수 `PreRef` 간 순서=배열 index 순서, 동적 경로로 도착한 + PreRef는 전용 Handler가 즉시 error — 둘 다 아홉 번째 세션에서 추가 + 확정), "hook 대 pre-hook"이라는 용어 자체를 문서화 시 채택할지와 + `PreRef`의 취소 가능성(애초에 fire와 동시에 소진되는 1회성이라 + "취소"가 의미 있는 개념인지부터)만 아직 미정. + +이 항목들은 `.claude/question.md`에도 이미 열린 질문으로 잡혀있음 — 여기선 +"확정 전엔 문서화 대상 아님"이라는 표시만 겸함. + +## 다음 단계 + +이 맵 자체를 지금 실행할 필요는 없음(구현 착수가 여전히 최우선, +`documentation-plan.md` "다음 단계" 참고). 나중에 실제로 문서 사이트 +작업을 시작할 때: (1) 위 1번 목차 초안으로 초심자 트랙 스캐폴딩, (2) 파일별 +[api] 항목으로 레퍼런스 페이지 스캐폴딩, (3) 4번 리스트를 심화 섹션 +에세이 백로그로 사용. diff --git a/.claude/research/documentation-plan.md b/.claude/research/documentation-plan.md new file mode 100644 index 0000000..c8c7b07 --- /dev/null +++ b/.claude/research/documentation-plan.md @@ -0,0 +1,182 @@ +# 문서화 전략 계획 (뼈대만) + +**상태**: research — 매우 초기. `research/debug-tooling-plan.md` 논의 +(2026-08-06) 도중 파생된 두 아이디어를 별도로 트래킹할 가치가 있다고 +판단해 뼈대만 기록해둠. 세부 설계는 없음, 착수 시점도 미정 — 사용자가 +"간단히 plan 작성은 하는 게 맞을 듯, 아주 뼈대만 있는 것이라 할지라도 +적어줄 필요는 있겠다"고 판단해서 만든 문서. 같은 날 후속 세션에서 +이벤트 핸들링 self 관습 논의가 확정되며 3번 항목이 추가됨. 별도 세션 +(날짜 미상, 다음 세션이 채워 넣을 것)에서 전체 문서 사이트 구조 논의가 +추가돼 0번 항목으로 들어감. + +## 0. 전체 문서 사이트 구조 — 3축 + 백엔드별 트랙 분리 (확정, 착수는 여전히 후순위) + +**축 구조 확정**: 초심자(getting started, 전반적 패턴을 실행 가능한 +예제로) / api(레퍼런스, 노출 표면 전부를 빠르게 훑어보기 용) / 심화(왜 +quad가 그렇게 설계됐는지 기술적 근거, 최적화·대규모 코드베이스 관리 +관심자용) 3축으로 관리. Diátaxis 프레임워크(tutorial/reference/ +explanation)와 사실상 동형 — 검증된 패턴이라 그대로 채택. 원래 4분면인 +Diátaxis의 "how-to guides"(태스크 지향 레시피)에 해당하는 콘텐츠는 +별도 축을 새로 만들지 않고 초심자 트랙 안에 녹이기로 함(아래 "초심자 +스코프" 참고, 2번 항목의 "Store 부작용 패턴" 문서가 대표 사례). + +**초심자 문서 스코프 — "core loop 완주에 필요한 최소 집합만"**: +프리미티브(Source/State/Modifier 등)와 그 주요 파생 데이터 입력값 +정도는 처음부터 가르치기 쉬움(quad엔 가상 DOM이 없어서 개념 자체가 +단순함). 반면 `Compute`의 `previous` 인자, Observer, Store `:Emit`, +이벤트 store-bind, Slot 형제 순서 같은 건 "필요해지면 찾아보는" 것으로 +api/심화에 위임 — 처음부터 노출하면 core loop이 흐려짐. 로블록스 +스크립팅 유저 간 실력 편차가 큰 문제는, 초심자 트랙 자체를 짧고 선형으로 +유지하면서 각 개념 옆에 api/심화로 새는 링크를 촘촘히 박아두는 방식으로 +대응 — "쉬운 버전/어려운 버전"을 따로 쓰지 않고 한 문서 안에 탈출구를 +많이 두는 쪽. + +**초심자 문서에 quad-base/quad-roblox 분리를 노출하지 않기로 확정**: +지금은 백엔드가 quad-roblox 하나뿐이라, 초심자에게 "이건 base 프리미티브, +이건 roblox 전용"이라고 구분해서 가르치면 실행도 안 해본 추상 개념부터 +마주치게 됨 — 대신 `Source`/`State`/`Modifier` 등을 전부 구체적인 +Roblox 코드(Frame 만들기 등) 안에서 자연스럽게 등장시키고, 엔진 무관하다는 +사실은 각주 정도로만 언급. 이 구분이 실제로 값어치를 하는 곳은 (a) api +문서 — 패키지별로 quad-base 페이지/quad-roblox 페이지를 나눠 "이 함수가 +어디서 왔는지" 룩업 가능하게, (b) 심화 문서 — base/roblox를 왜 쪼갰는지 +설계 근거 자체가 콘텐츠. + +**백엔드별 튜토리얼은 필요해질 때 추가 — 지금 설계 불필요**: 초심자 +문서는 메이저 버전업이나 개명 수준이 아니면 거의 안 바뀌는 성격이라, +나중에 quad-web/love2d 등 실제로 다른 백엔드가 분리되어 나올 때 그 +사용자들을 위한 튜토리얼을 그때 가서 추가하면 됨 — 이벤트 핸들링 모델도 +다르고 Tween처럼 아예 없는 백엔드도 있을 수 있어 지금 추상 개념을 +먼저 가르치려 하면 없는 공통분모를 억지로 만드는 셈이라 오히려 나쁨. +즉 초심자 트랙은 **백엔드별로 완전히 분리된 트랙**(지금은 quad-roblox +하나)으로 구조화하고, "quad 코어가 뭔지"는 심화에서만 다룸. + +**착수 시점**: 위 1~3번 항목과 동일하게 지금 당장 설계/작성할 필요는 +없음(`CLAUDE.md` "지금 할 일" 1번 — 구현 착수가 최우선). 이 0번 항목은 +"만약 시작한다면 이런 구조로"에 대한 합의이지, 착수 신호는 아님. + +**콘텐츠 분류 완료(2026-08-06)**: 위 3축을 실제로 뭘로 채울지 +`.claude/base/*.md` 전체 + 관련 `research/*.md`(tween-plan, ui-shorthand-plan)를 +초심자/api/심화/skip으로 서베이한 결과는 `research/documentation-content-map.md` +참고 — 초심자 core loop 목차 초안, 파일별 상세 분류, 심화 전용 신설 콘텐츠 +후보(14개 에세이 소재), 아직 미확정이라 문서화 보류해야 할 항목까지 정리됨. +api 항목은 간략 설명 + 심화로 "더 알아보기" 링크를 다는 패턴(사용자 확정)도 +그 문서에 반영. + +**4번째 축 신설 — `quadnomicon`(2026-08-06, 같은 세션 후속)**: 콘텐츠 분류 +중 "심화"로 분류하기엔 성격이 다른 두 갈래가 섞여 있는 게 드러남 — +(a) Fusion/Vide 내부 구현 비교, 다른 프레임워크 선례 조사처럼 "quad를 깊게 +이해해 최적화하려는 사용자"가 아니라 "비슷한 프레임워크를 직접 설계/포크 +하려는 엔지니어"를 위한 콘텐츠, (b) 세션별 정정 이력/조사 원자료처럼 애초에 +공개 문서화 대상이 아닌 내부 설계사. **(b)는 새 티어도 필요 없이 그냥 +`.claude/` 내부 설계 기록으로 남고 절대 publish 안 함**(Rust로 치면 +rust-lang/rfcs 저장소처럼 raw 상태로만 존재). **(a) 중 잘 다듬은 소수만** +Rustonomicon 패러디 이름 `quadnomicon`으로 새 4번째 축에 publish — +독자층은 quad 사용자가 아니라 프레임워크 설계 자체에 관심 있는 엔지니어. +이름은 사용자가 여러 후보(quadnomicon/심해/해구/흑마법서) 중 직접 확정. + +**GC처럼 quad 고유 내용은 아니지만 배경지식 자체가 깊은 주제 처리 방침**: +새 티어를 안 만들고, "quad가 GC를 어떻게 활용하는가"(quad 고유)는 심화에 +그대로 남기되 "GC란 무엇인가" 자체는 자체 튜토리얼을 쓰지 않고 외부 좋은 +자료로 링크 처리하는 쪽으로 방침 확정 — 안 그러면 문서 프로젝트가 일반 +프로그래밍 교육 쪽으로 스코프 크리프될 위험이 있다는 판단. + +## 1. UI 요소 네이밍 컨벤션 문서 + +**배경**: quad-debug 논의 중 사용자가 직접 겪은 pain point로 드러남 — +Roblox가 Play 중 라이브 UI 편집 도구를 꺼버려서 실제 화면의 UI 요소 +위치를 찾으려면 Explorer를 계속 펼치고 접어야 하는데, quad로 만든 요소는 +보통 이름을 잘 안 지정해서 특히 힘들다는 지적(`debug-tooling-plan.md` +"핵심 설계 방향" 8번, Element Inspector로 부분 완화 예정이지만 근본적으로는 +좋은 네이밍 습관이 있어야 함). + +**뼈대(아직 설계 아님, 물음표만)**: +- 어떤 단위에 이름을 붙이게 유도할까 — 모든 Frame? 컴포넌트 루트만? +- 자동 이름 부여 옵션이 있으면 좋을까(예: 컴포넌트 함수 이름을 기본 + `Name`으로 쓰는 관례 — 있으면 편하지만 매직에 가까워 "마법 안 쓴다" 원칙과 + 긴장 있을 수 있음, 검토 필요).[정정: 인용 위치 정정 — + `architecture.md` 2번(함수형 vs OOP 논의)이 아니라 + `base/component-composition-plan.md` §1("컴포넌트 = 그냥 함수, '자기 + store 자동 소유' 매직은 폐기")의 **사용자 확정** "마법 안쓴다 그것도 + 동의함" 발언이 근거임] +- 어디에 문서화할까 — 퀵스타트 튜토리얼? 별도 스타일 가이드? 린트 규칙으로 + 강제할까(과한 선택지, 참고만)? + +**구체적으로 결정된 하위 규칙 하나(2026-08-06)**: quad가 내부적으로 +자동 생성하는 helper Instance(예: `base/ui-shorthand-plan.md`의 +UICorner/UIPadding/UIScale 숏핸드가 만드는 자식)는 `_`나 `QUAD_` 같은 +접두어를 붙여 Explorer에서 직접 볼 때 사용자가 만든 것과 헷갈리지 않게 +함 — v1의 `_quad_round`류 네이밍 재사용(`research/debug-tooling-plan.md` +"핵심 설계 방향" 9번과도 연결, 플러그인이 Explorer 선택을 자기 트리와 +동기화할 때도 이 구분이 필요함). 이건 "사용자가 자기 컴포넌트에 이름을 +잘 붙이게 유도"하는 위 물음표들과는 별개로 이미 결정된 사항. + +## 2. Store 부작용을 게임 시스템에서 깔끔하게 쓰는 패턴 문서 + +**배경**: quad의 Store는 부작용 허용이 기본 설계(`base/architecture.md`, +`base/store-semantics.md`에서 확정)인데, 실제 게임 개발(스킬, 쿨타임, +재화 같은 도메인 상태를 담당하는 store, 부분별 모듈화 등)에서 이 자유도를 +깔끔한 패턴으로 쓰는 법이 아직 문서화 안 됨 — 사용자 원 발언: "원래 +의도했던 편한 부작용 허용을 좀더 더럽지 않은 패턴으로 수행하는 방법도 +적절한 문서화 계획이 있어야겠습니다". + +**뼈대(아직 설계 아님, 물음표만)**: +- 예제 도메인으로 스킬/쿨타임/재화 같은 흔한 게임 시스템을 다룰 것으로 + 보임 — 실제 예제 코드까지 만들지, 원칙만 서술할지 미정. +- `base/purity-and-effects-plan.md`(컴포넌트 "이식성" 경고)와는 성격이 + 다름 — 그쪽은 "이러면 재사용성이 깨진다"는 경고 문서고, 이건 "그래도 + 부작용을 쓸 거면 이렇게 하면 덜 지저분하다"는 처방 문서. 둘을 같은 + 문서에 합칠지 분리할지는 미정. +- 안티패턴 vs 권장 패턴 대조 형식이 적당할지, 다른 형식이 나을지도 미정. + +## 3. 권장 이벤트 핸들링 패턴 문서 (2026-08-06 추가) + +**배경**: 이벤트 핸들러가 self(Instance)를 받지 않기로 확정된 결정 +(`base/bind-system-plan.md` "이벤트 핸들러는 self(Instance)를 받지 +않는다" 절)의 근거가 꽤 비직관적임 — v1을 써본 사용자는 self가 없어진 +걸 보고 "왜 없어졌지, 불편한데"라고 느낄 수 있어서 근거까지 같이 +문서화할 가치가 있음. + +**뼈대(아직 설계 아님, 물음표만)**: +- **"왜 thin wrapper를 안 주는가"**를 설명하는 절 — 메모리 낭비(클로저 + 래핑 비용)와 오버엔지니어링(Modifier 정적 flatten과 경쟁하는 두 번째 + 쓰기 경로, KV 핸들러 분기 비용)이 핵심 논거. `bind-system-plan.md`의 + 결정문을 그대로 요약하면 될 듯. +- **권장 이벤트 핸들링 패턴** 자체 — Instance가 필요하면 Ref로 캡쳐해서 + 쓰는 예제, Store/Source를 만들고 State로 파이핑되는 초기점을 바꿔가며 + 반응형을 구현하는 게 더 일관된 흐름이라는 사용자 원 논지를 코드 + 예제로 보여주는 게 좋아 보임. +- 일반화된 원칙("엔진이 네이티브로 콜백에 뭘 주든 그대로 호출해줘도 + 무방하다")을 quad-roblox 한정 설명으로 둘지, 다른 백엔드 구현자를 + 위한 일반 가이드로도 남길지는 미정. +- **조건부 이벤트 처리 — 기본 패턴 vs store-bind, 대조해서 보여줄 것** + (2026-08-06 추가). "핸들러 하나 계속 연결 + 내부에서 `store.enabled:Get()` + 분기"가 기본 권장 패턴이고, 이벤트 store-bind(`false`로 disconnect, + `bind-system-plan.md` "이벤트도 store-bind 가능" 절)는 고빈도 신호나 + 로직 자체가 바뀌는 드문 케이스를 위한 부차적 옵션이라는 점을 코드 + 대조로 보여줄 것 — quad가 미는 디자인은 전자, 후자는 "이럴 수도 있다" + 정도로만 소개. +- 어느 문서에 넣을지 — 퀵스타트? 위 1번 네이밍 컨벤션 문서와 마찬가지로 + 아직 미정. + +## 4. 심화 문서 소재 후보 (누적, 2026-08-06 추가) + +설계 논의 중 "이건 왜 이렇게 안 했는가/이렇게 했는가" 식으로 나온 세세한 +근거들 중, 나중에 0번 항목의 "심화" 축에 넣을 만한 것들을 누적 기록. +지금 당장 문서를 쓰라는 뜻 아님 — 원 결정문은 각 `base/` 문서에 이미 +있으므로 여기선 소재 제목과 위치만 남김: + +- **왜 State 체인을 Modifier처럼 플래튼하지 않는가** — 결정문은 + `base/bind-system-plan.md` "왜 State 체인을 Modifier처럼 플래튼하지 + 않는가" 절. State의 정의가 "캐싱하는 존재"라 다이아몬드 파생에서 + 캐시 공유를 잃으면 `previous` 메커니즘이 막으려던 중복 계산 문제가 + 재발한다는 게 핵심 논거. + +## 다음 단계 + +셋 다 지금 당장 설계할 필요는 없음(`CLAUDE.md` "지금 할 일" 1번 — +구현 착수가 최우선). 사용자 판단이 필요한 것: +- 이걸 `.claude/question.md`/`CLAUDE.md`에 정식 백로그 항목으로 올릴지, + 아니면 이 파일 하나로 충분한지. +- 착수 시점 — quad-debug처럼 "개발 상당 부분 끝난 뒤"로 볼지, 아니면 + M1(스캐폴딩) 즈음부터 조금씩 곁들일지(네이밍 컨벤션은 특히 초기부터 + 적용하는 게 나중에 소급 적용하는 것보다 쌀 수 있음 — 검토 필요). diff --git a/.claude/research/existing-instance-bind-plan.md b/.claude/research/existing-instance-bind-plan.md new file mode 100644 index 0000000..c824584 --- /dev/null +++ b/.claude/research/existing-instance-bind-plan.md @@ -0,0 +1,59 @@ +# 이미 생성된 인스턴스에 대한 바인드 (후순위, UB 또는 마일스톤) + +**상태**: research — 명시적으로 후순위/UB 후보. 원본: +`.claude/initreq/raw-userinput.md` "이미 생성된 객체에 대한 바인드?" 절. + +## 문제 + +이미 생성된 Roblox Instance에 새로운 `{k=v}` 프롭 테이블을 나중에 바인드하는 +걸 허용할지. 허용하려면 이전 바인드를 끊는 처리가 필요한데, retract가 +구현되어 있어도 바로 지원하는 건 엔지니어링 비용이 높음. + +## 기울어진 방향 + +**UB로 두거나, 마일스톤(추후 구현)으로 미룬다.** retract가 이미 있고 store +바인드도 우선순위 높은 플러그라면 이론적으로는 가능해 보이지만(핸들러 +레지스트리가 이미 "우선순위 스캔 후 bind" 구조라 재바인드도 같은 경로를 타면 +됨), 초기 구현에서 **우선순위를 낮게** 잡아야 함 — 문제 유무가 많을 수 있어서. + +## Default 값과 얽히는 문제 + +Default를 넣으면 더 어려워짐 — Default로 쓰다가 실제 쓰인 값으로 되돌아가는 +케이스를 생각해야 함. Modifier 설계와 맞물려 있는 문제로, 결과적으로 매번 +테이블을 flattening 해야 할 수도 있음 — 그런데 그걸 위해 클론까지 해야 하나? +사용자 스스로도 "약간 애매" 하다고 남김. + +**후보 아이디어(미확정)**: ref로만 다시 바인드 가능한 걸 얻게 하고, ref가 되면 +복사(clone) 모드를 켜야 하나 — 근데 그러면 너무 복잡해질 것 같다는 우려까지만 +기록. 결론 없음. + +관련: Modifier flatten 자체의 clone 정책(변환마다 `table.clone`, immutable +확정)은 이미 `base/modifier-plan.md`의 "3. Immutable 값 + clone 기반 체이닝" +절에서 다뤄짐 — 단 그건 "Modifier 값 자체의 clone"이고, 여기서 미정인 건 +"이미 마운트된 Instance에 대한 재바인드 시점의 Default→실값 flatten"이라 층위가 +다름, 새로 설계하지 않고 참고 링크만. + +## 사용자 확인 결과: 진짜로 모르겠음 — 열린 가능성으로 유지 + +**사용자 확인 완료, 그러나 결론은 "미정 유지".** 실제로 이 기능을 원한다고 +말한 사용자를 본 적은 없지만, 막상 만들어진다면 유용하게 쓸 수 있을 것 같다는 +느낌은 있음. 근거: +- `retract`(구 cleanup)이 이미 존재한다면, store 바인드도 이미 `retract`되는 + 경로가 있는 셈 — 재바인드를 지원하기 위한 인프라가 어느 정도 이미 깔림. +- Modifier를 잘 설계하면 나중에 오버라이드가 자연스럽게 가능해질 수도 있음 — + 미래에 어떤 방법을 생각해낼 여지가 있다는 것. +- **역사적 맥락**: quad는 원래 "script 스니펫"이라고 부를 정도로, react.lua + 같은 당대 대안 대비 압도적으로 쉽고 단순해서 누구나 빠르게 이해해 쓸 수 + 있는 걸 의도적으로 지향한 도구였음. 라이브러리가 지금처럼 몸집이 커지는 + 후속 단계에선 이런 기능성을 충분히 고려할 만함. + +**결론**: v2 초기 스코프에서 제외하되, "미지원"으로 확정 명문화하지는 않음 — +진짜 열린 가능성으로 남겨두고, 실사용 중 필요성이 드러나면 그때 설계. +`base/architecture.md`의 "복사 구현 지양, store 바인드 변경은 전체 변경" +원칙과 긴장 관계에 있다는 점은 여전히 유효 — 나중에 설계할 때 이 원칙과 +어떻게 공존할지부터 다시 볼 것. + +## 열린 질문 (`.claude/question.md`에도 취합) + +- 구체적 설계는 완전히 미정 — 실사용 패턴이 쌓이기 전까지는 착수하지 않음. + 급하지 않음. diff --git a/.claude/research/framework-comparison-findings.md b/.claude/research/framework-comparison-findings.md new file mode 100644 index 0000000..50a9441 --- /dev/null +++ b/.claude/research/framework-comparison-findings.md @@ -0,0 +1,110 @@ +# quad vs Fusion/Vide/react-lua — 정직한 비교 (2026-08-06) + +**상태**: research — 3개 에이전트가 각각 Fusion(`.claude/initreq/fusion` 실 +소스), Vide(`.claude/initreq/vide` 실 소스), react-lua(로컬 클론 없어 +웹 리서치)를 직접 읽고 quad의 확정 설계와 대조. 목적은 마케팅이 아니라 +정직한 자가점검 — "quad가 진짜 나은 부분"(나중에 초심자/quadnomicon 문서의 +"왜 quad인가" 소재)과 "quad가 진짜 불리한 부분, 그중 고칠 수 있는 것"을 +사용자가 직접 검토하기 위함. **quad는 구현 0줄** 상태라 모든 비교가 +"검증된 프로덕션 코드 vs 종이 설계"라는 근본적 비대칭을 안고 있음 — 아래 +모든 강점/약점은 이 전제하에 읽을 것. + +## 1. quad가 실제로 나은 점 (소스 근거 있음, 향후 "왜 quad인가" 문서 소재) + +- **Slot 단일 마운트 가드 — Fusion·Vide 둘 다 없음, 실재하는 버그 클래스를 막음.** + Fusion `Children.luau`엔 `-- TODO: check for ancestry conflicts here` + 주석이 그대로 남아있고 이미 마운트된 인스턴스를 조건 없이 재부모화함(조용한 + 이중 마운트). Vide `mount.luau`도 중복 마운트 체크가 전혀 없음. quad의 + "이미 마운트된 Slot 재마운트 시 즉시 throw"는 둘 다에 없는 실질적 안전장치. +- **열린 우선순위 축 — Fusion의 하드코딩된 4단계보다 확장성 좋음.** Fusion + `applyInstanceProps.luau`는 `{self, descendants, ancestor, observer}` + 정확히 4개 버킷만 갖고 5번째를 쓰면 에러남. quad의 열린 숫자 우선순위 + 레지스트리는 커스텀 bind key를 라이브러리 수정 없이 임의 우선순위에 + 끼워넣을 수 있음. +- **명시적 의존성이 여러 버그 클래스를 원천 차단.** Vide는 전역 `scopes` + 스택 기반 암묵 추적이라 리액티브 스코프 안 yield가 그래프를 깨는 걸 막기 + 위해 별도 `ycall` 장치까지 둠(`graph.luau`). quad의 명시적 `:With` + 의존성 전달은 이 버그 클래스 자체가 발생하지 않음. +- **다이아몬드 의존성 재계산 dedup — Vide가 스스로 미해결로 남긴 문제를 + 더 구조적으로 해결.** Vide `todo.md`가 diamond 그래프 중복 재평가 방지를 + 미해결로 인정했고, 실제로 `test/tests.luau`의 "recursive queue flush + diamond" 테스트가 이상적 2회 대신 3회 실행됨을 재현함. quad의 `invalid` + 플래그 dedup은 이걸 원시 레벨에서 막도록 설계됨. +- **fine-grained라 vdom 특유의 버그 클래스가 통째로 없음(vs react-lua).** + react-lua는 리스트 diffing을 위해 key 관리가 필요하고(불안정하면 자식 + 상태 유실), hooks 호출 순서 규칙이 있으며(위반 시 "Rendered fewer/more + hooks" 에러), 고빈도 갱신엔 리렌더를 우회하는 별도 API(`Bindings`)를 + 공식적으로 추가해야 했음(react-lua 스스로 "vdom 재조정만으론 부족하다"고 + 인정한 셈). quad는 모든 값이 동일한 push-invalidate/pull-recompute + 모델이라 이 세 문제 자체가 없음. +- **Tween을 그래프 밖에 둬서 구조적 복잡도를 회피.** Fusion `Animation/ + Tween.luau`는 `Stopwatch`+`ExternalTime` 그래프 노드와 + `checkLifetime.bOutlivesA` 교차 lifetime 검증까지 필요한 3중 장치. + quad엔 이 장치 자체가 없음(단, 반대급부는 아래 3번 참고). + +## 2. quad가 불리한 점 중 — 고칠 만한 것(fixable, 검토 가치 있음) + +- **use-after-destroy 검증 안전망 부재.** Fusion `Memory/checkLifetime.luau`는 + "짧게 사는 스코프가 오래 사는 대상에 바인딩됐다" 같은 실수를 사람이 읽을 + 수 있는 에러 메시지로 즉시 잡아줌. quad `base/lifecycle-pattern.md`엔 + 이런 사전 검증 개념이 없음. GC-native 프로덕션 동작 자체를 바꿀 필요는 + 없고, **개발/Studio 모드 한정 옵트인 검증 레이어**(quad-debug류와 결합 + 가능)로 추가하는 정도는 GC-native 철학과 안 부딪히고 고려해볼 만함. +- **`:With(...)` 정적 의존성 목록 — Fusion의 동적 재평가보다 약함.** Fusion + `evaluate.luau`는 매 평가마다 실제 `use()`된 의존성만 다시 구독해 특정 + 라운드엔 조건부로 일부 의존성을 아예 구독 안 할 수 있음. quad는 `:With` + 에 나열한 목록이 Compute 시점에 고정돼, lazy handle로 재계산 트리거는 + 피해도 무효화 신호 자체는 계속 도착해 불필요한 재-Get이 누적될 수 있음. + **동적 With 등록/해제 API** 정도로 완화 가능해 보임 — 우연한 갭에 가까움. +- ~~Store dot-access가 매 접근마다 새 State를 할당~~ — **[해소됨, + 2026-08-06 세 번째 세션]** 이 항목이 직접 트리거가 되어 Source/State + 관계 자체를 재구성(`store-semantics.md` "Source가 State를 만족함" 절) — + Store가 이제 생성 시 만들어둔 Source를 그대로 반환해 wrapper 할당 자체가 + 없어짐, 구현 단계 최적화가 아니라 설계로 완전히 없앰(캐싱/풀링보다도 쌈). + +## 3. quad가 불리한 점 중 — 못 고치는 것(의도된 트레이드오프, "고친다" 개념 자체가 안 맞음) + +- **암묵적 추적의 인체공학적 우위(vs Vide)** — `derive()` 안에서 그냥 + 호출하면 의존성이 잡히는 Vide 대비, quad는 전부 `:With`에 나열해야 해 + 보일러플레이트가 늘어남. quad가 "Lua에서 암묵 추적은 부작용 관찰이 + 필요해 지저분하다"는 이유로 의도적으로 거부한 결과라, 명시성을 유지하는 + 한 고칠 개념 자체가 아님(경감책은 있을 수 있음 — 아래 4번 참고). +- **Tween이 그래프 밖이라 다른 Compute의 입력으로 자유롭게 합성 불가(vs + Fusion/Vide)** — Fusion Tween/Spring, Vide `spring()`은 그래프 노드라 + 다른 파생값의 입력으로 얽어 쓸 수 있음. quad는 Fusion을 반면교사 삼아 + 의도적으로 이 경로를 포기한 것이라 원 설계 취지와 충돌. 필요해지면 + 옵트인 브릿지 추가가 현실적 타협(지금 급한 건 아님). +- **GC-native 라이프사이클 자체가 안고 있는 리스크** — Vide는 GC와 + `Instance.Destroying` 순서가 비결정적이라는 알려진 함정 때문에 의도적으로 + eager·수동 cleanup을 택함. quad의 "수동 dispose 불필요"는 GC 의존을 + 없애려면 결국 Vide식 수동 owner 트리로 돌아가야 해서 철학과 충돌 — + 다만 `base/lifecycle-pattern.md`의 rbvm 실물 검증 근거로 리스크는 이미 + 어느 정도 완화돼 있음(기존 base 문서 참고). +- **DOMless+컴포넌트 1회 실행 때문에 "지금 트리가 어떻게 생겼는가"를 + 한눈에 재구성하기 어려움(vs react-lua)** — react-lua는 렌더마다 전체 + 서브트리를 선언적으로 다시 기술해 현재 상태가 코드 한 곳에 드러남. quad는 + 변화가 개별 leaf bind에 흩어져 처리돼 복잡한 조건부 트리 추론이 어려움. + 근본 선택에서 필연적으로 따라오는 트레이드오프라 설계 변경으론 해소 안 + 되고, quad-debug 같은 관측 도구로만 보완 가능(이미 백로그에 있음 — + `research/debug-tooling-plan.md`). + +## 4. 성숙도 격차 — 설계 결함 아니지만 지금 시점 비교에선 정직하게 명시해야 함 + +Fusion(~5000줄+테스트+수년 실사용), Vide(2800줄+테스트+0.1.0→0.4.1 하드닝 +이력), react-lua(Roblox 사내 실사용+전용 벤치마크 레포)는 전부 실전에서 +발견되고 고쳐진 문제들의 산물. quad는 구현이 0줄이라 이 비교의 강점 항목도 +전부 M0 스파이크 이후 실제 Luau로 검증돼야 신뢰할 수 있고, 구현이 진행되면 +유사한 이유로 비슷한 안전장치를 뒤늦게 추가하게 될 가능성이 있음(1번의 +use-after-destroy 검증처럼). "hooks 없는 quad의 `:With`/`:Compute`가 +React 커스텀 훅만큼의 합성성을 실사용 규모에서 주는가"도 지금은 데이터 +없음 — 고칠 문제인지조차 판단 이를 정도로 이름. + +## 다음 단계 + +이 문서 자체는 지금 당장 뭘 바꾸라는 결정문이 아님 — 사용자가 직접 검토 +후 판단할 항목: +- 2번의 남은 두 가지(use-after-destroy 검증, 동적 With)를 실제로 설계에 + 반영할지, 반영한다면 언제(M0 스파이크 때 같이 검증할지, 나중 최적화 + 패스로 미룰지). 세 번째(Store dot-access 할당)는 위에서 이미 해소됨. +- 1번 강점 목록은 `research/documentation-content-map.md`의 "왜 quad를 + 쓰는가" 초심자/quadnomicon 콘텐츠 소재로 재사용 가능. diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md new file mode 100644 index 0000000..f23b8e0 --- /dev/null +++ b/.claude/research/pre-implementation-audit.md @@ -0,0 +1,660 @@ +# 구현 착수 직전 감사 — 모호성 / 지연결정 리스크 / 단순화 후보 + +**상태**: research — 사용자 상의 필요. 2026-08-06 세션에서 신설. `.claude/base/` +전체가 "확정"으로 표시돼 있지만, 실제 `ROADMAP.md` M0 착수를 앞두고 구현자 +시점에서 다시 크리티컬하게 훑어본 결과. 방법론: `base/` + 근접 +`research/`(tween-plan, ui-shorthand-plan, existing-instance-bind-plan)를 +4개 클러스터로 나눠 서브에이전트로 병렬 정독시키고, 각각 세 가지 렌즈로 +리뷰하게 했음 — + +1. **모호성**: 실 구현 중 "이 경우엔 정확히 어떻게 동작하지?"라고 멈춰서 + 다시 물어봐야 할 만한, 구체적 동작이 명시 안 된 지점. +2. **지연 결정 리스크**: 지금 "나중에 정해도 된다"고 취급되지만 사실 코어 + 타입 구조/패키지 경계/데이터 모델에 깊이 얽혀 있어 나중에 바꾸면 연쇄 + 파급이 클 것 같은 항목. +3. **오버엔지니어링/단순화 후보**: 확정됐다고 적혀 있지만 목적에 비해 과한 + 추상화로 보이거나 더 간단한 대안이 있어 보이는 지점. (단, "이미 여러 + 라운드에 걸쳐 검증됨"이라고 문서가 스스로 못박은 결정 자체를 재론하는 + 건 배제 — `CLAUDE.md`의 반복 조사 금지 원칙과 같은 이유.) + +이미 `.claude/question.md`에 취합된 항목(용어 재검토, M0 스파이크 항목 +자체, Slot 형제 순서 보장 등)은 여기서 제외했다 — 아래는 **전부 새로 발견된 +것**. 부수적으로 이 감사 과정에서 `architecture.md`의 stale한 부분 두 곳 +(온톨로지 요약 절, 소스트리 `Store.luau`/`Ref.luau` 주석)을 발견해 같은 +세션에서 바로 고쳤다 — 판단이 필요 없는 순수 문서 동기화라 여기 남기지 +않고 해당 문서에서 직접 정정함. + +## 어떻게 쓸 것 + +우선순위 1은 M0~M4 구현 도중 실제로 부딪힐 가능성이 높은 것 — **가능하면 +M0 착수 전에 확인**. 우선순위 2는 지금 결정해두면 싼데 안 해두면 나중에 +비쌀 것들 — 해당 마일스톤 착수 직전에만 확인해도 됨. 우선순위 3은 사용자 +판단에 달린 단순화 제안. 문서 모순 절은 이미 고친 것과 아직 안 고친 것을 +구분해뒀다. + +--- + +## 우선순위 1 — M0~M4 착수 전 확인 권장 + +### 1-1. Tween이 "범용 store-bind 캐치올 핸들러"의 유일한 예시로 쓰여, 일반 반응형 프로퍼티 바인딩과 혼동될 위험 — [해소됨, 2026-08-10 세션] + +**해소**: 아래 "제안"과 같은 방향(별개 핸들러로 분리)이 아니라, 그보다 +더 근본적인 재설계로 해소됨 — Tween 자체가 더 이상 독립 Dispatch +핸들러가 아니라 PropertyHandler가 소비하는 값-레벨 래퍼(`Tween`)로 +바뀌어, "일반 store-bind와 Tween-store-bind가 같은 핸들러인지"라는 +질문 자체가 성립하지 않게 됨 — 범용 State/Source 언랩은 `Dispatch/ +StoreBind.luau` 하나뿐이고 Tween은 그 아래에서 나온 `realv`를 +PropertyHandler가 직접 판별. 상세는 `research/tween-plan.md`(전면 +재작성), 구 모델은 `archive/tween-special-bind-key-reversed.md`. 아래는 +원래 발견 당시 기록. + +**위치**: `base/bind-system-plan.md` "확정된 디스패치 모델" 절 67-79행 — +"Tween의 store-bind 핸들러는 **`k`는 무엇이든 받고 `v`가 Store인 경우를 +잡아내는, 우선순위가 매우 높은 핸들러**"; `architecture.md` 소스트리엔 이 +역할을 하는 quad-roblox 파일이 `Handlers/Tween.luau` 하나뿐(별도 범용 +StoreBind 핸들러 파일 없음); `ROADMAP.md` M11도 Tween을 "높은 우선순위 +store-bind 핸들러"로 서술. + +**문제**: 이 문서 전체에서 "v가 store인 값을 구독해 realv로 재귀 process +하는" 범용 메커니즘의 **유일한 구체 예시가 항상 "Tween"으로만 등장**한다. +그런데 Tween(실제 애니메이션, override/cancel 정책)은 `research/ +tween-plan.md`라는 별개 리서치 문서와 별도 로드맵 마일스톤(M11)을 가진, +명백히 더 좁고 아직 미확정인 기능이다. `Frame { BackgroundColor3 = +store.color }`처럼 애니메이션 없이 그냥 반응형으로 값만 바뀌길 원하는 +가장 흔한 케이스가 (a) 결국 이름은 "Tween"인 파일을 거쳐가며 "애니메이션 +없음"으로 처리되는 건지, (b) Property/Tag/Attribute 등 각 핸들러가 각자 +`Dispatch/StoreBind.luau`(quad-base, 범용) 유틸을 직접 써서 독립적으로 +구현해야 하는 건지 문서가 정하지 않았다. `ROADMAP.md` M4("첫 end-to-end +반응형 업데이트")는 Tween 없이(M11보다 훨씬 전에) `Dispatch/StoreBind.luau` +만으로 "store 값 바꾸면 process가 다시 호출된다"를 검증하게 돼 있어 (a)는 +아닌 것 같지만, 그럼 M11에서 Tween.luau가 실제로 추가될 때 그게 기존 +경로와 **레이어링(우선순위로 얹힘)되는지 대체되는지**가 불명확하다. + +**제안**: "일반 store-bind(애니메이션 없음)"와 "Tween 전용 store-bind +(애니메이션 있음)"가 같은 핸들러인지 별개 핸들러인지부터 확정. 별개라면 +소스트리에 `Handlers/StoreBind.luau`(또는 유사) 항목을 명시적으로 추가하고, +`Tween.luau`는 그 위에 얹히는 "값에 tween 설정이 붙어있으면 가로채는" 더 +높은 우선순위의 특수 케이스로 재정리하는 게 자연스러워 보임. + +### 1-2. retract 시 "이전에 실제로 매치됐던 핸들러"를 누가 추적하는지 불명 — [해소됨, 2026-08-08 세 번째 세션] + +**해소**: `Dispatch`가 `(inst,k)`별 핸들러 체인(순서 있는 배열, `chains`)을 +직접 소유하고, `Dispatch.retractUnder(inst,k,keep,v)`가 꼬리부터 `keep` +앞까지 정리해주는 걸로 확정 — 아래 원래 제안(`Dispatch/StoreBind.luau`가 +"마지막 선택된 핸들러"를 직접 들고 있는 방식)은 재귀/래핑 핸들러가 +여러 단계(A→B→C)로 겹칠 때 자기 자신의 상태와 위임한 핸들러의 상태가 +슬롯 하나를 두고 충돌하는 문제가 있어 기각되고, 대신 Dispatch 자신이 +전체 체인을 배열로 들고 있는 쪽으로 정리됨. 상세는 `base/ +bind-system-plan.md` "Dispatch 체인" 절, `ROADMAP.md` M2/M4. 아래는 +원래 발견 당시 기록. + +**위치**: `base/bind-system-plan.md` "확정된 디스패치 모델" 절 90-91행 — +"store bind가 새 값으로 넘어갈 때 이전 핸들러의 `retract(inst, k, v)`를 +한 번 호출해주면 됨." + +**문제**: store-bind 재실행 모델에서 `realv`의 실질 타입은 매 갱신마다 +바뀔 수 있다(예: 처음엔 숫자값이라 Property 핸들러가 매치, 다음번엔 다른 +타입값이라 다른 핸들러가 매치). 이 경우 "이전 핸들러"가 정확히 어느 +핸들러였는지는 `(inst, k)`별로 어딘가 기록돼 있어야 정확한 `retract` +대상을 찾을 수 있다. "핸들러 내부 상태 저장" 절은 "각 핸들러가 자기가 +만든 것"(예: 실행 중인 Tween 객체)을 저장하는 패턴만 다루지, "이 키를 +마지막으로 어느 핸들러가 담당했는가"라는 상위 레벨 라우팅 상태를 누가 +(base 엔진 vs `Dispatch/StoreBind.luau` 래퍼) 관리하는지는 명시가 없다. + +**제안**: `Dispatch/StoreBind.luau`가 "마지막으로 선택된 핸들러" 자체를 +`(inst, k)`별 상태로 들고 있다가, 새 `realv` 처리 전에 그 핸들러의 +`retract`를 호출하는 식으로 지금 결정해두는 게 좋아 보임 — M2/M4에서 바로 +부딪힐 지점. + +### 1-3. 우선순위 스캔의 동률 처리, 매치 실패 시 동작이 정의 안 됨 + +**위치**: `base/bind-system-plan.md` "핸들러 계약" 절 — "디스패치는 등록된 +핸들러를 우선순위 순으로 스캔하며 `isHandlable`을 호출, 첫 매치가 처리." + +**문제**: (a) 두 핸들러가 같은 `priority` 값을 가질 때 어느 쪽이 우선인지 +(등록 순서? 정의 안 됨) 규칙이 없음. (b) 어떤 핸들러도 `isHandlable(k, v)`를 +만족하지 않는 `(k, v)` 쌍이 들어왔을 때 — 조용히 무시? 에러? — 도 정의가 +없음. 후자는 특히 사용자가 오타 키를 쓰거나 지원 안 되는 조합을 넣었을 때 +디버깅 경험에 직결. + +**제안**: 최소한 "매치 실패는 에러(silent 무시 금지)"만이라도 지금 +결정해두면 구현 중 인터럽트를 막을 수 있음. 동률은 "등록 순서가 tiebreak" +정도로 명시만 해둬도 충분. M2(Dispatch 엔진) 착수 직전 확인. + +### 1-4. provider(팩토리) 미주입 상태에서 dispatch가 호출되면 어떻게 되는지 세 번째 케이스가 빠짐 + +**위치**: `base/module-lifecycle-plan.md` "Bind는 누가, 어떻게 구현하는가" +절 — 재호출 가드는 "같은 팩토리=무시, 다른 팩토리=에러" 두 케이스로 +확정됐지만, 이건 전부 "팩토리가 이미 한 번 실행된 이후" 얘기다. + +**문제**: 원문이 처음 언급했던 세 번째 케이스 — **아직 아무 팩토리도 실행 +안 된 상태에서 dispatch(`process`/`retract`)가 호출되는 경우**(예: +`InitRoblox` 호출 전에 컴포넌트를 마운트 시도)는 이후 어느 문서에서도 다시 +다뤄지지 않았다. 이때 정확히 뭐가 일어나는지(명시적 에러 메시지 vs +nil-index 크래시 vs 조용한 no-op)가 안 정해져 있음. + +**제안**: base dispatch 엔진이 "아직 provider 미주입" 상태를 감지해 명확한 +에러를 던지도록 지금 결정해두면, 구현 중 흔한 초기화 순서 실수를 훨씬 덜 +헷갈리게 만들 수 있음. 1-2번과 같은 타이밍(M2)에 같이 확정. + +### 1-5. `props.Modifier`/`props.Ref` forwarding 관례가 Lua 배열 리터럴의 nil-hole 함정에 그대로 노출됨 + +**[2026-08-07 열 번째 세션 갱신 — 반영 완료.]** 아래 제안 (a)/(b) 대신 +더 단순한 (c)류 해법으로 확정: `props.Modifier or None`/`props.Ref or +None` 관용구를 필수로 강제 — `None`이 항상 non-nil이라 리터럴 구멍 자체가 +안 생기고, 이미 있는 array-part `None`-스킵 메커니즘(PreRef 논의 중 +같은 세션에서 확정)을 그대로 재사용해 새 코드가 안 늘어남. +`base/component-composition-plan.md` "필수 관용구" 절, `ROADMAP.md` M0에 +반영 완료 — 더 이상 열린 항목 아님, 아래는 원래 발견 당시 기록. + +**위치(당시)**: `base/component-composition-plan.md` "최종 결론" 1번 — +`return Frame { props.Modifier, props.Ref, ... }` 패턴. + +**문제**: caller가 `props.Modifier`나 `props.Ref`를 안 넘기면 그 값은 +`nil`이다. Lua 테이블 생성자에서 `{nil, refValue, child}`는 `t[1]`이 +사실상 키 없는 상태가 되지만 `t[2]`, `t[3]`은 정상적으로 채워진다. 이때 +디스패치 루프가 v1처럼 `ipairs`로 배열을 순회한다면 **`t[1]`이 nil이라는 +이유만으로 `t[2]`(Ref)와 `t[3]`(자식)까지 통째로 무시**될 수 있다 — Ref +콜백이 조용히 안 불리고 자식도 안 그려지는, 원인 추적이 매우 어려운 버그 +클래스. 이 문서가 정식으로 권장하는 forwarding 패턴 자체가 이 함정을 +유발하는 전형적 모양인데 nil 처리 규칙이 전혀 언급되지 않는다. + +**제안**: (a) 디스패치 루프를 `ipairs` 대신 `#t` 기반 명시적 인덱스 루프나 +`table.pack`/센티널로 nil-safe하게 만들거나, (b) forwarding 관례 자체를 +`Frame { Modifier = props.Modifier, Ref = props.Ref, [1] = child }`처럼 +명시적 키로 넘기게 하거나, (c) 최소한 "props.Modifier/Ref가 nil일 수 +있으니 배열 위치에 직접 넣지 말라"는 경고를 문서에 남길 것. **M0 +스파이크가 이 패턴을 이미 검증 대상으로 잡고 있으니(`props.Modifier`/ +`props.Ref` named-parameter 컴포넌트 작성), 그 스파이크 코드에 caller가 +Modifier/Ref를 아예 안 넘기는 케이스를 반드시 포함시킬 것.** + +### 1-6. `canExecute`/`Connected`의 실제 구현 방식이 미확정인 채로 코어 전역에 이미 재사용 확정됨 + +**[해소됨, 2026-08-08 세션]** `bindLifetime(inst,value)`/`canExecute(inst,value)` +탑레벨 함수로 확정, `Relate` 프리미티브(`base/relate-plan.md`) 위에 gcconn/ +gchold를 얹는 구체 구현까지 나옴 — `base/lifecycle-pattern.md`의 +"`bindLifetime`/`canExecute` — 확정" 절이 최신. 아래는 이 결정이 나오기 +전까지의 문제 서술(정확했던 문제 인식이라 그대로 둠, 남은 실측 항목은 +`lifecycle-pattern.md` 쪽 "M0/M2 실측 필요" 캐비엇으로 이동). + +**위치**: `base/lifecycle-pattern.md` "2026-08-04 검증 라운드에서 보강된 +내용" 절, 특히 "`Destroying` 훅은 생각보다 덜 중요할 수 있음" 부분. + +**문제**: base는 "이 바인드가 아직 유효한가"를 묻는 인터페이스만 정의하고 +quad-roblox가 구현을 채워넣는다고 되어 있는데, 후보 구현 방식들이 서로 +다른 타이밍/정확도 보장을 갖는다 — `Instance.Parent == nil` 체크(단순하지만 +"일시적으로 부모 없이 옮기는 중"일 때 false positive 위험), 저장해둔 +`RBXScriptConnection.Connected`(정확하지만 "무엇에 Connect한 Connection을 +기준 삼을지" 별도 결정 필요), `Destroying:Connect`로 세운 플래그(문서가 +스스로 "덜 중요할 수 있다"고 약화시킴). 그런데 이 predicate는 이미 Slot +생존 확인·Observer 게이팅·store-bind retract 등 코어 전역에 "canExecute +하나로 통일" 원칙으로 재사용 확정돼 있다 — 즉 여러 하위 시스템이 의존하는 +핵심 predicate의 실제 정확도 보장이 아직 안 정해진 채로 그 위에 여러 기능이 +이미 "확정"되어 쌓인 상태. + +부가적으로, `framework-comparison-findings.md`는 "Vide는 GC와 +`Instance.Destroying` 발화 순서가 비결정적이라는 알려진 함정 때문에 의도적 +eager cleanup을 택했다"는 구체적 위험을 지적하며 "quad는 rbvm 실물 검증 +근거로 이 리스크가 완화됐다"고 적었지만, `lifecycle-pattern.md` 본문 +어디에도 **"Destroying 발화 순서 비결정성"이라는 그 구체적 함정에 대한 +분석이 없다** — rbvm 검증은 "GC-native 정리가 프로덕션에서 잘 돌아간다"는 +것만 보여줄 뿐, "발화 순서가 신뢰 가능한가"라는 별개 질문엔 답하지 않는다. +리스크가 "완화됐다"는 문장이 실제로는 근거 문서 안에서 뒷받침되지 않음. + +**제안**: M0 스파이크(또는 M0 직후, M2/M3 착수 전)에서 실제로 어떤 구현이 +오탐 없이 동작하는지(특히 Reparent-but-not-Destroy 케이스, 여러 자식이 +동시에 Destroy될 때 부모/자식 `Destroying` 발화 순서) 먼저 실측하고, 그 +결과로 `lifecycle-pattern.md`의 애매한 서술을 확정 문장으로 교체할 것. + +### 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` 등 뮤터블 연산을 지원하는 메타 배열"이라고만 서술. + +**문제**: 실제 시그니처/의미론이 전혀 없다. 예: `remove`는 인덱스를 +받는지 값(참조)을 받는지, 존재하지 않는 값을 remove하면 no-op인지 +에러인지, `set(i, v)`가 기존 위치의 element를 retract하고 교체하는지, +`clear()` 중간에 개별 element의 retract가 실패(에러)하면 나머지는 계속 +처리되는지 등. Observer 콜백이나 store-bind 재실행 안에서 `add`/`clear`가 +재진입적으로 호출될 가능성도 있는데, 그 경우의 동작도 무정의. + +**제안**: M6(Slot) 착수 시점에 CRUD 각 연산의 인자/반환값/에러 조건을 +최소한 표로 확정해둘 것 — 이미 알려진 "여러 Slot 순서 보장" 논의와 같은 +타이밍에 같이 정리하면 됨. + +### 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" 절. + +**문제**: `isMounted`라는 용어가 두 가지 다른 대상에 쓰이는 것처럼 읽힌다 +— (a) "한 인스턴스에 대한 다중 마운팅이 절대 일어나지 않도록 강제"는 +**Slot에 담기는 개별 child element**가 두 곳에 동시 마운트되는 걸 막는 +얘기, (b) "이미 사용된 slot을 재마운트하려 하면 즉시 error()"는 **Slot +컨테이너 자체**가 두 번째 바인드 지점에 쓰이는 걸 막는 얘기다. 둘은 서로 +다른 추적 대상(개별 element vs Slot 객체)인데 문서는 하나의 "isMounted +관리"로 뭉뚱그린다. (b)의 트리거 시점도 미정 — `process(inst,k,slotValue)` +가 실제로 호출된 시점(핸들러 매칭)인지, Instance `Parent` 대입까지 끝난 +시점인지에 따라 "컴포넌트가 Slot을 prop으로 받아 저장만 하고 실제로는 +렌더하지 않는 경로"에서 오탐 throw가 날 수도, 반대로 진짜 이중 마운트를 +놓칠 수도 있음. + +**제안**: 두 추적을 명시적으로 분리(예: `Slot._mounted: boolean` vs +element별 weak-set) — throw 조건을 "Slot 핸들러의 `process`가 같은 Slot +객체에 대해 두 번째로 불렸을 때"로 명문화. M6 착수 시. + +### 1-9. `LifetimeHandle` 인터페이스가 M8에 배치돼 있지만 M4/M6이 이미 그걸 필요로 함(로드맵 순서 역전) + +**[2026-08-07 세 번째 세션 갱신 — 반영 완료.]** 아래 제안대로 +`LifetimeHandle.luau`/`PerInstanceState.luau` 인터페이스가 `ROADMAP.md` +M2로 이동됐고, M8은 quad-roblox 실제 구현만 담당하도록 분리됨 — 더 이상 +열린 항목 아님, 아래는 원래 발견 당시 기록. + +**위치(당시)**: `ROADMAP.md` M8 "Ref" — `"LifetimeHandle 인터페이스 + quad-roblox +실제 구현(Instance 생존 확인)"`. + +**문제**: `base/lifecycle-pattern.md`("생명 바인드 유틸"을 State-invalidate +리스너 클로저 등록에도 재사용)와 `base/slot-plan.md`(Slot의 `retract`가 +같은 canExecute 패턴을 그대로 씀)는 둘 다 이 유틸을 State/Store 구독 +(M3/M4 영역)과 Slot(M6)에서 이미 쓴다고 명시하는데, `LifetimeHandle` +인터페이스 자체는 M8에서야 정의된다. 즉 M4/M6이 개념적으로 필요로 하는 +base 인터페이스가 그보다 늦은 M8에서 만들어지는 순서 역전. + +**제안**: `LifetimeHandle.luau`(quad-base, 인터페이스만)를 M2(Dispatch +엔진) 또는 M3(Store/State)로 옮기고, M8은 "quad-roblox 실제 구현(Instance +`Connected` 기반)"만 담당하도록 분리. M1 mock에도 이 인터페이스의 트리비얼 +스텁(항상 true)을 붙여두면 M4/M6 테스트가 자연스러워짐. + +### 1-10. `store.key`의 레코드 필드 타이핑 검증이 M0가 아니라 M3로 밀려 있음 + +**위치**: `ROADMAP.md` M0 vs M3 `"store.key dot-access 타입 추론 확인"`. + +**문제**: M0의 정의 자체가 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 +없는 것"을 검증하는 단계다. `base/store-semantics.md`가 요청한 M0 항목( +"Source가 State를 만족하는 제네릭 메소드 체이닝"의 솔버 안정성)은 이미 +반영됐지만, 이건 `:Compute` 같은 제네릭 메소드 체이닝만 다루고 `{key: +Source}` 같은 **레코드 필드로서의 dot-access 타이핑**(읽기/쓰기 +대칭성 논거의 핵심 전제)은 별개로 M3에 남아있다. 같은 리스크 카테고리인데 +M1(스캐폴딩)·M2(디스패치 엔진) 투자가 먼저 이뤄진 뒤에야 검증되는 셈이라, +여기서 걸리면 이미 만든 스캐폴딩/디스패치 타입 시그니처를 다시 손봐야 할 +수 있음. + +**제안**: M0 항목에 "`store.key`가 실제로 `Source` 레코드 필드로 +안전하게 추론되는지"도 같이 넣을 것 — 어차피 같은 스파이크 파일에서 몇 줄 +추가로 검증 가능. + +### 1-11. Modifier의 "제네릭 `__index` + `table.clone` 메타테이블 보존" 트릭이 검증 안 된 채 M7 전체 설계의 전제가 됨 + +**위치**: `base/modifier-plan.md` "런타임은 클래스별 코드 없이 base에 딱 +하나만 있으면 됨" 절, `ROADMAP.md` M7. + +**문제**: M7의 핵심 주장("base에 제네릭 `__index` 하나면 충분, FrameModifier +류는 순전히 정적 타입 체크용")은 `mod:FontSize(14)` → `__index(self, +"FontSize")`가 즉석 클로저를 리턴하고, `table.clone`이 메타테이블을 그대로 +복사해줘서 체이닝이 안 끊긴다는 두 가지 Luau 동작에 전적으로 의존한다. +문서 자체가 "핵심 통찰"이라 부르지만 실제 Luau 코드로 확인된 적은 없다. +이게 틀리면 M7에서 "클래스별 런타임 코드 불필요"라는 설계가 무너지고 +필드별 정적 등록 방식으로 되돌아가야 하는데, M7은 M0~M6 다 끝난 뒤라 +되돌릴 때 비용이 큼. + +**제안**: 검증 비용이 낮음(Modifier 없이도 순수 메타테이블 실험 몇 줄로 +가능) — M0 스파이크 후보에 추가하거나, 최소한 "M7 착수 시점에 제일 먼저 +확인"이라고 `ROADMAP.md`에 명시. + +--- + +## 우선순위 2 — 지금 결정해두면 싼 것 (지연 결정 리스크) + +### 2-1. Source가 State를 만족하는 제네릭 검증이 실패했을 때의 대안(Plan B)이 전혀 없음 + +**위치**: `base/store-semantics.md` "Source가 State를 만족함" 절 — +"검증 필요(확정 아님, M0 스파이크 대상)... 다만 이것도 추론이라 실제 +Luau로 확인 전엔 확정 아님." + +**문제**: 검증 필요성 자체는 이미 M0 항목이라 새 지적 아니지만, **검증이 +실패했을 때 뭘 하는지가 문서 어디에도 없다.** 이 타입 구조(Source⊂State +서브타입)는 `store.key`의 반환 타입, `:Set()` 문법, `:Emit()`의 위치, +dot-access 타입추론, `RefSource` 폐기 결정까지 전부 이 위에 얹혀 있어서, +Luau 솔버가 막히면 되돌릴 범위가 `store-semantics.md`의 절반 이상에 걸침. + +**제안**: M0 스파이크 계획에 "실패 시 폴백은 RefSource 부활 vs 다른 대안" +한 줄이라도 미리 박아두면, 실패했을 때 다시 사용자 자문을 구하느라 멈추는 +걸 막을 수 있음. + +### 2-2. `State` 타입 차단이 Luau에서 실제로 가능한지 검증 계획이 없음 + +**[대부분 해소, 2026-08-09 세션]** "가능하면 타입 차단, 안 되면 UB로 +후퇴"라는 원래 걱정 자체가 무의미해짐 — `State`를 이제 +`isModifier` predicate 기반 명시적 `error`로 막기로 확정 +(`base/modifier-plan.md` 7번 정정, `base/store-semantics.md` "따름정리" +절)했으므로, 타입 차단은 성공하든 실패하든 런타임 에러라는 안전망이 +항상 있음 — 아래 "제안"이 우려했던 "조용히 UB로 후퇴" 시나리오 자체가 +발생하지 않음. 타입 차단이 Luau에서 실제로 가능한지는 여전히 미검증이지만, +이제 "되면 좋은 보너스"로 우선순위가 낮아짐 — M0/M7 필수 검증 항목에서 +제외해도 됨. 아래는 원래 발견 당시 기록. + +**위치**: `base/modifier-plan.md` 7번. + +**문제**: "가능하면 타입 시스템으로 아예 못 넣게 막을 것"이라 확정했지만, +Luau 제네릭은 "T가 특정 타입이면 거부"하는 부정 제약을 기본 지원하지 +않는다. `store-semantics.md`는 이보다 단순한 `Source satisfies +State` 조합조차 M0 스파이크 대상(솔버가 죽을 수 있음)으로 잡아뒀는데, +이보다 어려운 문제(제네릭 타입 파라미터 배제 제약)인 `State` +차단은 어디에도 검증 대상으로 언급되지 않는다. 실제로 안 되면 "UB, +가능하면 타입 차단"이 조용히 "그냥 UB, 런타임 가드 없음"으로 후퇴하는데 +그 fallback도 안 적혀있음. + +**제안**: `ROADMAP.md` M0(또는 M7 착수 시점)에 이 케이스를 포함하거나, +최소한 `modifier-plan.md`에 "타입 차단이 Luau에서 불가능하면 순수 UB로 +폴백"이라는 명시적 fallback 문장을 추가할 것. + +### 2-3. Component 래퍼 필요 여부가 "이름만 남음"으로 후순위 처리됐지만 구조적 결정일 가능성 + +**위치**: `base/component-composition-plan.md` "남은 열린 질문" — +"`Component`(플레인 함수 규약이라 별도 래퍼가 필요한지 자체도 불확실 — +아마 불필요)". + +**문제**: 다른 순수 네이밍 항목들과 동급의 후순위로 묶여 있지만, 이건 이름 +문제가 아니라 **구조 문제**일 수 있다 — 래퍼가 있고 없고에 따라 (a) 정적 +타입 체크 지점(`props.Modifier`/`props.Ref` 필수 필드 검증을 어디서 +강제할지), (b) `quad-debug`가 요구하는 컴파일타임 소스 위치 주입(darklua) +훅 지점이 "함수 정의부"가 되는지 "매 호출부"가 되는지가 갈린다. 나중에 +"역시 얇은 래퍼가 필요하다"로 뒤집히면, 이미 "그냥 함수" 규약으로 짜인 +기존 컴포넌트 전체를 마이그레이션해야 하는 연쇄가 발생. + +**제안**: `CLAUDE.md`가 이미 M2/M3/M5에서 quad-debug 훅 확장 지점을 +고려하라고 명시해뒀으니, 그 시점에 이 질문도 같이 열어 "래퍼 없음"이 +구조적으로도 최종 확정인지 한 번 더 확인할 것. M1 스캐폴딩 전에. + +### 2-4. existing-instance-bind가 Slot의 "엄격한 단일 마운트 소유권" 불변식과 근본적으로 긴장 + +**위치**: `research/existing-instance-bind-plan.md` 전체 vs `base/ +slot-plan.md` "핵심 제약: 소유권 귀속과 단일 마운트". + +**문제**: 문서가 스스로 언급한 긴장(Modifier flatten의 clone 비용)과는 +별개로 더 근본적인 긴장이 있다. `retract`는 "이전에 자신이 process한 것을 +무른다"는 전제(quad가 자신이 만든 Instance를 생명주기 끝까지 들고 있는 +소유자)로 설계됐는데, existing-instance-bind는 정의상 **quad가 한 번도 +process한 적 없는 인스턴스**에 처음 바인드하는 시나리오다. 특히 Slot을 이 +인스턴스의 children 제어에 쓰려는 경우 — 기존에 손으로 만들어둔 자식들이 +Slot의 "own"한 대상인지 아닌지가 완전히 미정. 문서는 "핸들러 레지스트리가 +이미 우선순위 스캔 후 bind 구조라 재바인드도 같은 경로를 타면 됨"이라고 +낙관하지만, 이건 "새 값을 process하는 법"만 있으면 된다는 얘기고 "이 +인스턴스에 대해 quad가 이전에 뭘 소유했었는지 모르는 상태에서 안전하게 +재바인드하는 법"은 다른 문제다. 나중에 "기존 children을 흡수(adopt)하는 +API"를 얹어야 할 때 Slot의 "own한 것만 CRUD 대상" 불변식 자체를 건드려야 +할 수 있음. + +**제안**: 착수 안 해도 되지만, "이 기능이 실제로 필요해지면 Slot의 +소유권 모델에 '흡수(adopt)' 개념을 추가해야 할 수도 있다"는 캐비엇을 +`existing-instance-bind-plan.md`에 한 줄 추가해둘 것 — 기존 "Modifier +flatten과 긴장" 캐비엇 옆에 병기. + +### 2-5. `Modifier.Overridden`(구 `Merge`) 시 서로 다른 클래스의 Modifier가 섞이는 게 허용되는지 — **런타임은 해소, 타입 레벨은 여전히 미정** + +**위치**: `base/modifier-plan.md` "2. Merge 우선순위" + 9번(`Overridden`) + +`base/component-composition-plan.md` 3번. + +**[2026-08-07 다섯 번째 세션 갱신] 런타임 동작은 이제 명확함**: `modifier-plan.md` +9번에서 `Overridden`가 "필드별 raw 덮어쓰기"로 확정됐고, "Modifier는 핸들러 +계층을 모름 — 순수 데이터 merge 레이어"(1번 절) 원칙도 이미 있었으므로, +**런타임 레벨에서는 대상 클래스가 다른 Modifier끼리 `Overridden`해도 막을 +이유가 없음**(필드명만 보고 그대로 덮어쓸 뿐 — Luau 타입은 런타임에 +강제되지 않는다는 점은 `store-semantics.md`에도 이미 명시된 전제). + +**여전히 미정인 것 — 타입 레벨**: Modifier가 target 클래스별 제네릭 +타입(`Modifier` 등)이라면, `Modifier.Overridden(mod1: Modifier, +mod2: Modifier): Modifier`처럼 같은 `T`만 받도록 타입으로 강제할지, +아니면 공통 base 타입(여러 GuiObject 클래스에 걸친 공통 필드)과 클래스별 +확장 사이의 계층 구조를 별도로 두고 `Overridden`가 그 계층을 넘나들 수 +있게 할지는 아직 결정된 바 없음 — "공통 테마 Modifier + 클래스별 override +Modifier를 합친다"는 시나리오가 `Overridden`의 가장 그럴듯한 실사용 +예시라 이 타입 설계가 실제로 막히면 바로 걸릴 문제. + +**제안**: Modifier의 클래스별 typed 생성자 계층(2-8번과 같은 지점) 설계 +시 `Overridden`의 제네릭 시그니처도 같이 확정할 것. M7 착수 시. + +### 2-6. Modifier 필드에 State/Source를 인자로 넘기는 케이스가 세터 표에서 빠짐 + +**위치**: `base/modifier-plan.md` "4-1. 필드가 State일 수도 있음" 표. + +**문제**: 표는 {필드: plain/State} × {인자: 리터럴/함수} 4칸만 다루는데, +"인자 자체가 State/Source"인 경우(예: `mod:FontSize(theme.fontSize)`처럼 +이미 반응형인 값을 modifier 필드에 바인딩)가 없다. 상위에서 내려온 테마 +색상을 Modifier 필드에 물리는 매우 흔한 패턴일 가능성이 높음. "리터럴"의 +정의에 State 핸들도 포함되는지(포함된다면 필드가 State로 교체돼 반응형이 +되는지) 불명. + +**제안**: 표에 "인자=State" 행 2개(필드 plain/State 각각)를 추가해 명시할 +것 — 아마 "clone 후 필드를 그 State로 교체(반응형 획득/전환)"가 자연스러운 +답이지만, 함수 인자 케이스(`field:Compute(fn)`)와 어떻게 다른지 분명히 할 +것. + +### 2-7. 여러 Ref를 하나의 named parameter로 넘길 때 nested-array flatten 여부 불명 + +**위치**: `base/component-composition-plan.md` 3번(`Modifier.Overridden`, +구 `Merge`) vs "Ref는... 별도 결합 유틸 불필요" 문장. + +**문제**: Modifier는 여러 개를 합치려면 `Modifier.Overridden`가 명시적으로 +필요한데, 바로 다음 문장은 Ref는 "여러 Ref를 받으면 그냥 전부 실행하면 +됨 — 별도 결합 유틸 불필요"라고 한다. `props.Ref = {ref1, ref2}`처럼 +배열을 넘기면 리프 디스패처가 그 중첩 배열을 재귀적으로 펼쳐서 각 Ref를 +인식한다는 뜻인지, 아니면 다중 Ref를 한 named parameter에 담아 넘기는 +구체적 방법 자체가 여전히 안 정해진 것인지 불명확. 리프 레벨 디스패처가 +배열 위치의 항목을 태그로 판별한다면, 항목 자체가 "배열"일 때 태그가 +없어 인식 실패할 가능성이 있음. + +**제안**: 리프 디스패처가 중첩 배열을 flatten하는지 명시적으로 확정하고, +다중 Ref를 넘기는 구체적 문법을 한 줄로 못박을 것. M8/M9 착수 시. + +### 2-8. Modifier 클래스별 typed 생성자(`FrameModifier` 등)가 M5/M7 로드맵 어디에도 없음 + +**위치**: `base/modifier-plan.md` "5. 타입 출처는 이미 확정된 dot-access +관습 재사용" 절, `.claude/question.md` 1번. + +**문제**: Modifier의 런타임 체이닝 엔진은 quad-base 소유가 맞지만, 클래스별 +정적 타입 안전성(`mod:UICorner(8)`가 `FrameModifier` 타입으로 추론되는 +것)은 "DI 쪽 '제네릭 생성자 함수 하나 + 자주 쓰는 것만 정적 필드' 패턴 +재사용"이라 문서 스스로 밝히듯 quad-roblox의 DI 타입 생성 계층(M5)에 강하게 +결합돼 있다. 그런데 `ROADMAP.md` M7 체크리스트(flatten-before-dispatch, +`Modifier.Overridden`, `State` 차단)엔 이 클래스별 타입 생성 작업이 +전혀 없고, M5 DI 체크리스트에도 Modifier 언급이 없음. + +**제안**: M5 또는 M7 체크리스트에 "quad-roblox 클래스별 typed Modifier +생성자(FrameModifier 등)" 항목을 명시적으로 추가해 누락을 막을 것. + +### 2-9. 컴포넌트가 `props.Modifier`를 받아놓고 forward 안 하면 조용히 드롭됨 — 원칙 명문화 필요 + +**위치**: `base/component-composition-plan.md` "최종 결론" 1번, 3번. + +**문제**: 저작자가 `props.Modifier`를 실수로(또는 의도적으로) 내부 +`Frame{...}` 호출에 안 꽂아 넣으면 caller가 넘긴 modifier/ref는 조용히 +사라진다. 타입 시그니처에 `props.Modifier: Modifier`가 선언돼 있어도 +**실제로 그걸 쓰는지는 런타임/타입 어느 쪽도 강제 안 함** — "받았는데 +안 쓰는" 실패 모드가 별도로 존재. + +**제안**: 최소한 "무시되면 조용히 드롭된다(UB, 방어 로직 없음)"는 원칙을 +명시적으로 못박을 것. 방어할 가치가 있다고 판단되면 컴파일타임 린트 +(darklua) 후보로 `quad-debug`/문서화 백로그에 메모. + +### 2-10. Tween 자연완료(Completed) 시 per-instance 북키핑 정리 여부가 명세 안 됨 + +**위치**: `research/tween-plan.md` "`retract`(구 cleanup)로 확정된 +오버라이드 시맨틱" 절. + +**문제**: "새 값이 들어와 갈아치울 때"의 `retract` 동작(4가지 옵션, 기본값 +Cancel)은 상세히 정의했지만, **Tween이 사용자 개입 없이 스스로 끝까지 +재생되어 자연 완료된 경우** per-instance weak-keyed 저장소에 남아있는 +"이전 Tween 객체" 참조를 어떻게 다루는지는 언급이 없다. 남은 세 오버라이드 +옵션(override-without-delete/delete-then-restart/move-to-end) 각각이 +"이전 Tween이 아직 재생 중인가, 이미 끝났는가"에 따라 동작이 갈릴 수 +있는데(예: "끝점으로 옮기고 새 트윈 시작"은 이미 완료된 Tween엔 의미가 +이상해짐), 이 구분 로직 자체가 설계에 없음. + +**제안**: Tween 핸들러가 생성한 Tween의 `Completed` 이벤트를 구독해 +per-instance 저장소를 정리(또는 상태 플래그 갱신)하는지 여부를 명시. +M11 착수 시. + +### 2-11. UI shorthand — 기존 UICorner 자식과의 매칭 기준(이름 vs 타입)이 불명, 사용자 실수 유발 위험 높음 + +**[해소됨 — 이 감사와 같은 세션(2026-08-06)에 이미 확정, 여기 표시만 +누락돼 있었음, 2026-08-09 세션에 발견해 동기화.]** `base/ui-shorthand-plan.md` +"기존 자식과의 매칭 기준" 절이 정확히 아래 "제안"대로 확정해둠 — +재사용 대상은 quad가 이전에 만든 고정 이름(`_quad_corner`류) 자식으로 +한정, 타입만 보고(`UICorner`이기만 하면 아무거나) 재사용하지 않음. +아래는 원래 발견 당시 기록. + +**위치**: `base/ui-shorthand-plan.md` "v1 실제 메커니즘" 절. + +**문제**: v1 메커니즘은 "기존 `UICorner` 자식이 있으면 재사용, 없으면 +`Instance.new("UICorner", item)`(`Name = "_quad_round"`)"라고 서술되는데, +**이름으로 매칭**(quad가 이전에 만든 `_quad_round`만 재사용)인지 **타입으로 +매칭**(자식 중 아무 `UICorner`나 있으면 재사용)인지 불명확. quad-v2 문서가 +이 구분을 명시하지 않은 채 그대로 포팅 대상으로 재확정했다. 후자면 +사용자가 직접 넣은(quad가 모르는) `UICorner`를 quad가 멋대로 바꿔버리는 +부작용 경로가 생기고, 전자인데 사용자가 별도 이름으로 `UICorner`를 하나 +더 넣으면 같은 GuiObject에 UICorner가 2개 존재하는 상태(Roblox에서 어느 +쪽이 실제로 적용되는지 불명확)가 됨. 다른 항목들과 달리 이건 UB로 방치하기 +엔 사용자가 실수하기 매우 쉬운 흔한 시나리오(디자이너가 UICorner를 수동 +으로 넣어본 적 있는 프로젝트). + +**제안**: "재사용 대상은 quad가 이전에 만든 고정 이름(`_quad_round`류) +자식으로 한정하고, 사용자가 별도로 만든 UICorner와는 아예 상호작용하지 +않는다"는 규칙을 명시적으로 확정할 것. M10 전후 착수 시. + +--- + +## 우선순위 3 — 단순화 후보 (사용자 판단 필요) + +### 3-1. `:Compute(fn)`의 `previous` 인자 — 클로저 업밸류로 이미 되는 걸 별도 API로 만든 것일 수 있음 + +**[해소됨, 2026-08-09 세션]** 오버엔지니어링 아님으로 확정, 현재 +`fn(self, previous)` 설계 그대로 유지 — 클로저 업밸류 대안은 IIFE로 +감싸야 하는 준비 비용이 오히려 더 크다는 게 사용자 반박 논거. +`previous`는 `self`(입력)가 아니라 이 `:Compute` 호출 하나가 만든 +결과 State 노드 자신에 귀속되므로 팬아웃 시에도 충돌 없음 — 상세는 +`base/bind-system-plan.md`의 "previous" 절 참고. 아래는 원래 발견 +당시 기록. + +**위치**: `base/bind-system-plan.md` "`:Compute(fn)`의 선택적 두 번째 +인자 — `previous`" 절. + +**문제**: quad는 "렌더 함수가 계속 재실행되지 않고, `Compute`에 전달한 +함수 자체가 한 번 등록되어 재계산마다 그 동일 클로저가 재호출된다"는 +모델(문서 자체가 명시)다. 그렇다면 사용자가 `fn` 바깥에 `local prev` +업밸류를 두고 `fn` 안에서 그걸 읽고 갱신하면, 별도 `previous` 파라미터 +없이도 정확히 같은 "직전 반환값 재사용" 효과를 순수 Lua 문법만으로 얻을 +수 있어 보인다. 그런데 이 문서는 `previous`를 **별도 API 표면**(두 번째 +인자)으로 만들었고, "능동적으로 계속 관측되지 않으면 조용히 영구 +정지한다"는 상당히 위험한 캐비엇까지 별도로 문서화해야 할 만큼 무거운 +기능이다. 왜 클로저 업밸류로 충분하지 않은지 근거가 안 보임. + +**제안**: `previous` 인자를 유지할 근거(예: 업밸류 방식보다 타입 추론이 +쉬워진다든가)가 있다면 한 줄 추가하고, 없다면 "그냥 클로저 업밸류를 +쓰라"는 문서화 패턴으로 대체해 API 표면 자체를 줄이는 걸 검토. + +### 3-2. UICorner/UIPadding/UIScale 개별 Handler 대신 데이터 테이블 구동 단일 제네릭 Handler + +**위치**: `base/ui-shorthand-plan.md` "메커니즘 — 새 아키텍처 개념 +불필요" 절. + +**문제**: 문서는 "UICorner/UIPadding/UIScale 같은 특수 키를 인식하는 +Handler"라고만 서술해, 사실상 3개의 거의 동일한 형태(리터럴 값 하나 → +고정 이름 자식 찾기/생성 → 프로퍼티 세팅)의 Handler를 각각 만드는 +그림이다. 문서 자체가 "앞으로 비슷한 제안이 오면 이 선례를 따르라"고 +일반화하고 있어, 향후 비슷한 shorthand가 추가될 때마다 Handler 파일이 +선형으로 늘어나는 구조. + +**제안**: `{key -> {ChildClassName, ChildDefaultName, Property, wrap=fn}}` +형태의 룩업 테이블 하나로 구동되는 단일 `Handlers/InstanceShorthand.luau`로 +통합하는 안을 검토. 새 shorthand 키 추가가 "테이블에 항목 하나 추가"로 +끝나 M10 이후 유지보수 비용이 줄어듦. 강제 사항 아님, 구현 시점에 결정할 +정도의 사소한 개선 후보. + +--- + +## 문서 모순 — 발견 현황 + +### 이미 고침 (이번 세션) + +- `architecture.md`의 "Store/State/Source 온톨로지 확정 요약" 절이 + `store-semantics.md`의 최신 재구성(Source가 State를 만족, `store.key`가 + Source를 직접 반환, `store.key:Set()`)을 못 따라가고 있던 것 — 이 감사 + 세션 도중 발견해 직접 정정(커밋 `4b839b0`에서 별도로 이미 반영됨을 뒤늦게 + 확인 — 같은 문제를 두 세션이 독립적으로 발견한 셈). +- `architecture.md` 소스트리의 `Store.luau`/`Ref.luau` 주석이 각각 옛 + `__newindex` 모델, 옛 "Ref=CreatedRef 자체" 정의를 그대로 담고 있던 것 — + 이번 세션에서 직접 정정. + +### 아직 안 고침 (판단 필요해서 여기 남김) + +- **`State` 타입 차단(엔지니어링 비용 감수) vs Ref/Slot이 + Modifier 필드에 들어가는 건 UB로 방치 — 같은 문서 안에서 정반대 원칙이 + 나란히 적용됨.** `base/modifier-plan.md` "Modifier는 핸들러 계층을 + 모름" 절은 "권장 사용법은 아니지만 막을 이유도 없음 — 방어 로직 없는 + UB로 남겨둠"이라 명시적으로 방어를 포기했는데, 바로 옆 7번 절은 + `State` 조합을 "UB로 확정, **가능하면 타입 시스템으로 아예 못 + 넣게 막을 것**"이라며 정반대로 엔지니어링 비용을 들여 방어하기로 했다. + 두 결정 다 나름의 근거(후자는 "State에 담기면 재-flatten이 필요해져서 + 정적 merge 전제와 정면 충돌"이라 더 위험하다는 논리로 보임)가 있어 + 보이지만, 문서 어디에도 "왜 이 경우엔 원칙에서 예외로 처리하는가"를 + 명시적으로 인정/정당화하지 않고 그냥 나란히 적혀 있다. 위 2-2 항목 + (Luau에서 실제 차단 가능한지)과 묶어서 같이 정리할 문제. + + **[완전 해소, 2026-08-09 세션]** 양쪽 다 이제 같은 메커니즘 — + Ref/Slot(+Observer/Effect/Modifier 자기 자신)이 Modifier *필드*로 + 들어오는 것도, `State`처럼 Modifier가 State/Source *값*으로 + 담기는 것도 전부 `Brand` 기반 `isX` predicate로 런타임에 즉시 + `error`(`base/modifier-plan.md` 4번/7번 절 정정, `base/ + store-semantics.md` "따름정리" 절). 더 이상 "한쪽만 방어" 비대칭이 + 아님 — 남은 차이는 `State` 쪽에 "되면 좋은 보너스"로 + 타입 차단을 추가 시도해볼 여지가 있다는 것뿐(위 2-2번, 미검증이지만 + 더 이상 필수 방어선이 아니라 우선순위 낮음). +- **Destroying 훅 신뢰도에 대한 서술이 `lifecycle-pattern.md` 내부에서도, + `framework-comparison-findings.md`와의 사이에서도 어긋남** — 위 1-6 + 항목에 상세, 여기서는 "아직 아무도 하나의 확정 문장으로 정리 안 함"이라는 + 사실만 문서모순 항목으로 남겨둠. + +--- + +## 참고 — 감사했지만 문제없다고 확인된 것 + +- `component-composition-plan.md`가 예전 `StoreSource`/`RefSource` + 개념을 참조하는 채로 남아있진 않은지 확인 — 이미 "Source가 State를 + 만족함" 최신 모델로 정정돼 있어 문제없음. +- `ROADMAP.md`에 `store.key = value`(구 `__newindex`) 모델을 암시하는 + 잔여 표현은 없음 — M3/M4 서술 모두 문법을 명시하지 않아 최신 `:Set()` + 모델과 직접 충돌하는 곳은 없음. +- M9(컴포넌트 합성)이 M7(Modifier)·M8(Ref) 뒤에 오는 순서 — M9는 "M0 + 스파이크(named-parameter 전달)를 정식 Modifier/Ref로 검증"하는 단계라고 + 명시돼 있어 뒤늦은 검증이 아니라 의도된 정식화. +- `Relate`(구 `PerInstanceState`) 실제 구현 시점(M8) — 이걸 필요로 하는 + 핸들러(Tag/Attribute/Tween)가 전부 M10/M11이라 순서상 문제없음. +- Slot의 store-bind 의존(M6→M4) 순서. + +--- + +## 다음 액션 제안 + +- **M0 착수 전**: 1-5(props.Modifier/Ref nil-hole)는 `ROADMAP.md` M0에 + 반영 완료. 1-10(`store.key` 레코드 필드 타이핑)을 M0로 앞당기는 것 검토, + 1-11(Modifier `__index` 트릭)도 비용이 낮으니 M0 후보로 포함 검토는 계속 + 열려있음. +- **M2(Dispatch) 착수 전**: 1-3, 1-4를 한 번에 확정(전부 base dispatch + 엔진의 에러/상태관리 규칙이라 같이 결정하는 게 효율적). 1-2는 2026-08-08 + 세 번째 세션에 Dispatch 체인+`retractUnder`로 이미 해소됨(위 1-2번 항목 + 참고) — 남은 건 M2 스파이크에서 다단 체인 케이스가 실제로 맞게 + 동작하는지 실측하는 것뿐. +- **M2/M3 착수 전**: 1-6(canExecute 실제 구현) 실측(1-9는 반영 완료, 위 + 항목 참고). +- **나머지**: 해당 마일스톤 착수 시점에 이 문서를 다시 열어 관련 항목만 + 확인하면 됨 — 지금 전부 결정할 필요는 없음. diff --git a/.claude/research/tween-plan.md b/.claude/research/tween-plan.md new file mode 100644 index 0000000..d8ea3ec --- /dev/null +++ b/.claude/research/tween-plan.md @@ -0,0 +1,289 @@ +# Tween / 애니메이션 플러깅 (구조 재확정 — 값-레벨 `Tween` 래퍼, 옵션 값 모양만 남음) + +**상태**: research — **2026-08-10 세션에서 구조 전체가 재설계됨.** 기존 +"`v`가 Store인 아무 `k`나 잡는 우선순위 최상위 Dispatch 핸들러" 모델은 +`research/pre-implementation-audit.md` 1-1이 지적한 구조적 모호함("애니메이션 +없는 일반 반응형 프로퍼티 바인딩도 결국 이름이 Tween인 파일을 거쳐가는가")을 +명확히 답하지 못했음 — 대체된 새 모델(`Tween`를 PropertyHandler가 +소비하는 값-레벨 래퍼로 두는 것)이 이 모호함을 구조적으로 해소함, 아래 +"새 모델" 절부터가 최종 소스. **구 모델(특수 bind key `[Tween(key, +tweenData...)] = storeValue`)은 `archive/tween-special-bind-key-reversed.md`로 +이전됨** — 원문/역전 사유는 거기 보존, 이 문서는 새 모델만 서술. + +남은 건 옵션 값 모양(`TweenInfo` 그대로 vs 편의 필드)과 override 정책 +옵션 키의 정확한 이름/시그니처뿐 — M11 착수 시 확정. 원본: +`.claude/initreq/raw-userinput.md` "트윈은 어떻게 할 것이냐" / "스토어 값은 +항상 먼저 캐치한다" / "네임스페이스드 객체" 절. Fusion의 Tween/Spring이 +반응 그래프 안에 있는 설계는 명시적 반면교사 — `reference/ +comparison-fusion-vide.md`의 "Fusion" 절 마지막 불릿("Tween/Spring이 +State그래프 안의 1급 노드") 참고. + +## 확정된 방향: 트윈을 Store/반응 그래프 밖에 둔다 (변경 없음) + +렌더 라이브러리가 트윈을 직접 구현하는 건 처음부터 디자인이 잘못된 접근 — +렌더링 엔진(Roblox `TweenService`)의 애니메이션 관리를 전혀 안 따르게 되기 +때문. quad는 트윈을 반응 그래프에 1급 노드로 편입시키지 않고, 트윈 대상 +값을 감싸는 얇은 값-레벨 래퍼(아래)로만 취급한다. + +**왜 이게 중요한가(Fusion 리서치에서 확인된 근거)**: Fusion은 Tween/Spring을 +`GraphObject`(1급 반응 노드, `timeliness="eager"`)로 만들어서 Computed의 입력으로 +자유롭게 합성 가능하게 했지만, 그 대가로 (1) 매 프레임 틱하는 외부 클럭 +소스(Stopwatch/ExternalTime)를 반응 그래프에 통합해야 했고, (2) eager 재계산 +노드가 무효화/전파 로직과 경쟁하게 됐고, (3) 트윈-입력 간 별도의 교차 lifetime +체크 기계장치가 필요해졌다. quad가 트윈을 값-레벨 래퍼로 빼면 이 세 가지 +복잡도를 전부 피할 수 있다 — 대신 트윈된 값이 Computed류의 추가 입력으로 +자유롭게 합성되진 못한다는 걸 감수(Fusion 대비 유일한 손해, 아래 새 모델에서도 +그대로 유지됨 — `Tween.Value`는 plain `T`만 받고 자체 반응 경로를 안 둠). + +## 새 모델: `Tween` 값-레벨 래퍼, PropertyHandler가 소비 (2026-08-10 세션, 핵심 재설계) + +**동기**: 기존 모델("`k`는 무엇이든, `v`가 Store인 경우를 잡는 우선순위 +매우 높은 핸들러")은 State/Source 언랩(범용 StoreBind)과 Tween(실제 +애니메이션, 좁고 별개인 기능)을 같은 Dispatch 핸들러 하나로 뭉쳐서, +"`Frame { BackgroundColor3 = store.color }`처럼 애니메이션 없이 그냥 +반응형으로만 바뀌길 원하는 흔한 케이스가 결국 이름은 Tween인 파일을 +거쳐가는지"가 문서로 답이 안 됐음. + +**해소**: State/Source 언랩(`Dispatch/StoreBind.luau`, 범용·엔진무관 — +`k`/`v`의 실제 타입과 무관하게 그냥 `isState(v)`만 보고 `realv`까지 +재귀적으로 풀어냄)은 완전히 별개로 유지하고, **"이 값이 트윈 대상인가"는 +최종 소비자(PropertyHandler)가 `realv`를 다 풀어낸 뒤 직접 판단**한다 — +별도 Dispatch 핸들러/우선순위 경쟁이 아니라, PropertyHandler 함수 내부의 +평범한 분기. + +```lua +Tween(opts: {Value: T, ease..., onOverride?...}) -> Tween +``` + +`Store({...})`와 같은 "`Type(args)`가 테이블 인자를 받는 팩토리" 컨벤션 — +Lua 문법상 `Tween{Value=target, ease=...}`처럼 괄호를 생략해 호출. + +**PropertyHandler.process(inst,k,realv)의 새 로직** — `realv`는 이미 +StoreBind가 State/Source 레이어를 전부 풀어낸 뒤의 값: + +1. `isTween(realv)`가 거짓이면 — 기존과 동일하게(아래 "3-상태 저장" 참고, + `hasBeenSet` 여부만 갱신하고) 즉시 세팅. +2. `isTween(realv)`가 참이면 — 아래 "3-상태 저장" 절의 분기를 따름. + +### `Tween.Value`는 plain `T`만 받음 — 내부에 별도 반응 경로를 안 둠 + +처음엔 `Tween.Value`도 `T | State`를 받아야 하나(내부에 자체 Observer를 +걸어 값이 바뀔 때마다 트윈을 재시작) 검토했으나 **불필요로 확정** — 이미 +바깥 `:Compute`가 소스 State 변경마다 새 `Tween{Value=v,...}` 테이블을 +통째로 재생성해 StoreBind 재귀 재-dispatch 경로를 타므로, `Tween` 값 +내부에 또 다른 반응 경로를 만들 이유가 없음. "같은 일 하는 두 번째 경로를 +만들지 않는다"는 이 프로젝트가 Effect의 deps/Ref의 대기 경로 등에서 이미 +여러 번 적용한 원칙과 정확히 같은 결. **`Tween = {Value: T, ease..., +onOverride?...}`로 확정** — `Value` 필드는 항상 plain `T`. + +### 3-상태 저장 — `RobloxTween | true | nil` (릴레이션 슬롯 하나로 `hasBeenSet` 통합) + +처음엔 "첫 세팅 여부(`hasBeenSet: boolean`)"와 "실행 중인 엔진 Tween +객체"를 별도 필드로 저장하려 했으나, **하나의 릴레이션 슬롯으로 통합** — +`relate:GetStrong(inst,k)`가 돌려주는 값의 3가지 상태: + +- **`nil`** — 이 `(inst,k)`가 이번 `inst`에서 한 번도 process된 적 없음 + (첫 세팅). +- **`true`** — 최소 한 번 세팅된 적 있음(직전 값이 plain이었든 `Tween` + 였든 무관), 지금은 활성 엔진 Tween 없음. +- **실제 엔진 `TweenBase` 인스턴스** — 지금 애니메이션이 진행 중, 새 값을 + 처리하기 전에 먼저 정리해야 함. + +**분기**: + +1. **`prev == nil`(첫 세팅)** — `realv`가 `Tween`든 plain이든 무관하게 + **애니메이션 없이 즉시 `Value`(또는 plain 값)로 세팅**, 슬롯엔 `true` + 저장. 엔진 기본값(예: Frame 기본 `Position`)에서 목표값으로 날아오는 + "첫 마운트 진입 애니메이션" 버그를 이걸로 방지. +2. **`prev == true`(세팅된 적 있음, 활성 트윈 없음)**: + - `realv`가 plain 값 → 즉시 세팅, 슬롯은 `true` 유지. + - `realv`가 `Tween` → 이제 정상적으로 애니메이션 시작(현재 인스턴스 + 프로퍼티 값에서 자연스럽게 출발), 슬롯에 새로 만든 엔진 Tween 객체 + 저장. +3. **`prev`가 엔진 Tween 객체(활성 트윈 있음)**: + - **먼저 override 정책(기본 Cancel, 아래 절)에 따라 이전 트윈을 정리 — + 반드시 그 정리가 끝난 뒤에 새 값을 세팅한다.** 순서가 뒤바뀌면 + 이전 트윈의 다음 인터폴레이션 프레임이 방금 세팅한 값을 덮어쓸 + 위험이 있음(엔진 트윈은 비동기로 계속 프로퍼티를 갱신 중이므로). + - 정리 후: `realv`가 plain 값이면 (정리 결과로 프로퍼티에 남은 현재 + 값 위에) 즉시 덮어쓰기 + 슬롯 `true`. `realv`가 `Tween`면 (같은 + 현재 값에서) 새 트윈 시작 + 슬롯을 새 엔진 Tween 객체로 갱신. + - plain 값이 들어와 진행 중인 트윈을 끝내는 경우, 기존 override + 정책의 4가지 옵션(Cancel/Override/Delete-restart/Move-to-end-restart, + 아래 절)은 원래 Tween→Tween 전환을 염두에 둔 것이라 Tween→plain + 전환에는 사실상 전부 "멈추고 그 자리에서 즉시 덮어쓴다"로 수렴하는 + 것으로 보임 — 별도 5번째 옵션이 필요해 보이진 않으나 **확정은 아님, + M11 착수 시 재확인**. + +**GC-안전성은 기존과 동일** — `Relate`가 `inst`로 weak-keyed되어 있어 +`inst`가 죽으면 이 슬롯(엔진 Tween 객체 포함)도 별도 정리 로직 없이 같이 +GC됨. `retract`는 이 케이스에서 거의 안 불림 — 아래 절 참고. + +### 왜 `retract`가 더 이상 필요 없는가 — Dispatch 체인 관점의 결과적 단순화 + +기존 모델에선 "Tween 핸들러가 매치되어 애니메이션이 실행 중이었는데, +다음 값이 더 이상 Tween 대상이 아니게 되어 일반 PropertyHandler로 +핸들러 *타입*이 바뀌는" 경우가 `base/bind-system-plan.md`가 서술하는 +"`retract`가 실제로 의미를 갖는 유일한 패턴"의 대표 예시였음. 새 모델에선 +**매치되는 Dispatch 핸들러가 항상 PropertyHandler 하나뿐**(Tween 여부는 +값 내부 분기일 뿐 핸들러 매치 자체엔 영향 없음) — 이 시나리오 자체가 +Dispatch 레벨에서 사라짐. 트윈 취소/전환은 위 3-상태 저장 로직으로 +PropertyHandler 내부에서 처리 — Tag가 이미 하고 있는 "diff는 `process` +자신이 담당" 패턴과 같은 모양이라 새 개념 아님. (PropertyHandler의 +`retract` 필드 자체는 여전히 정의해둬야 함 — "필드 생략 불가" 규칙은 +예외 없는 일반 규칙 — 다만 실제로 호출될 일이 이 경로에선 사실상 없음.) + +### 타입 대수: `T' = T | Tween` — Modifier/State/Source에 새 타입 기계 불필요 + +지금 프로퍼티류 필드가 열려 있는 자리(Modifier setter, Ref, Store/Source +필드)는 전부 `T | State` 모양 하나로 통일돼 있음. 여기서 "이 필드의 +`T`" 자체를 `T' = T | Tween`로 치환하면 자동으로 `T | Tween | +State>`가 나옴 — Modifier/State/Source/StoreBind 코드엔 +`Tween` 인지 로직을 전혀 안 넣어도 됨(StoreBind는 원래도 페이로드 타입에 +무관하게 `isState`만 보고 언랩하는 opaque한 구조였음). `Tween`를 실제로 +해석하는 코드는 여전히 PropertyHandler 하나에만 존재. + +**핸들러 계층 UB 체크와도 안 부딪힘** — `Tween`는 `Ref`/`Observer`/ +`Slot`류처럼 `process`/`retract`를 가진 dispatch 참가자가 아니라 `None`/ +`Tag`처럼 순수 raw 데이터 값(별도 `TweenTag` Brand)이라, Modifier 필드/ +`State`가 막는 "핸들러 계층 값" 규칙(`base/modifier-plan.md`)에 +안 걸림 — 그 문서가 원래 Tween을 Slot/Tag/Attribute와 같은 "dispatch +참가자" 그룹으로 분류해뒀던 건 부정확했던 것으로 이번에 정정(아래 +"패키지 경계" 절 참고). + +## `useTween`(트윈 우회) — 해소됨, 새 옵션 필드 불필요 + +이전엔 `Tween{useTween=state}`처럼 `Tween` 생성자 안에 별도 +옵션 필드를 두는 방향으로 열려 있었으나, 값-레벨 래퍼 모델에선 **이미 +있는 `state:Apply(factory)`/`:Compute`만으로 공짜로 풀림** — 새 필드 +불필요: + +```lua +-- reduceMotion: State +Position = mySource:Apply(Animate(reduceMotion, {ease = ...})) +``` + +`Animate(reduceMotion, opts)`는 커링 팩토리로, 개념상 다음과 같은 모양: + +```lua +return function(state) + return state:Compute(function(v) + if reduceMotion:Get() then + return v + else + return Tween{Value = v, ease = opts.ease} + end + end) +end +``` + +`reduceMotion`이 바뀌면 `:Compute`가 재계산되어 StoreBind가 자연히 새 +`realv`(plain 또는 Tween-wrapped)로 재-dispatch — PropertyHandler는 평소처럼 +그 값만 보고 처리하면 됨, 우회 로직을 따로 알 필요 없음. **`Animate`는 +base 프리미티브가 아니라 quad-roblox가 제공하는 자유 함수 조합기**(아래 +"패키지 경계" 절) — `Modifier:Apply(Boldify(10))` 커링 패턴과 완전히 +같은 모양이라 base에 새로 추가할 게 없음. + +## 초기 진입 애니메이션(`initValue`) — 여전히 별개 문제, 위 hasBeenSet과 상충 방향 주의 + +`initValue`는 여전히 미확정(2026-08-09 세션 결론 유지: "필요성 낮은 +쪽으로 기움", 완전 폐기는 아님). 다만 이번 세션에서 **"3-상태 저장"의 +1번 분기(`hasBeenSet`)가 "첫 세팅은 무조건 애니메이션 없이 스냅"을 +기본 동작으로 확정**했으므로, 나중에 `initValue`(다이얼로그가 아래에서 +위로 슬라이드-인하는 것처럼 첫 마운트에도 애니메이션을 원하는 경우)가 +실제로 필요해지면 **이 억제 동작을 어떻게 명시적으로 우회할지**(예: +릴레이션 슬롯에 `nil` 대신 다른 초기 상태를 미리 심어두는 옵션)까지 +같이 설계해야 함 — 지금은 새 결정 없이 이 긴장 관계만 기록해둠. + +## `Animate` 콤비네이터 — quad-roblox 유틸(base 아님) + +`Animate(condOrOpts, opts?)`류 팩토리를 quad-roblox가 제공, `:Apply`로 +체이닝해서 쓰는 용도. 상세 시그니처는 미확정(예: `Animate({ease=..., +useAnimate=state})`처럼 조건과 옵션을 하나의 테이블로 합치는 +안도 검토 가치 있음 — 확정 아님, M11에서 정리). 핵심은 **base +프리미티브가 아니라는 것** — `Tween` 값 타입/`isTween`만 +base(`quad-base/Tween.luau`)에 있고, `Animate`는 이미 있는 `:Apply`/ +`:Compute`/`Tween{...}`를 조합한 quad-roblox 레벨 편의 함수라 나중에 +이름/모양을 자유롭게 바꿔도 base 계약에 영향이 없음 — 저비용 +고효율(사용자 표현) 엔지니어링으로 판단. + +## 패키지 경계 — `Tag`가 이미 밟은 것과 같은 분리 (2026-08-10 세션 확정) + +- **quad-base**: `Tween.luau` — 값 타입(`Tween(opts)` 팩토리, `isTween` + predicate/`TweenTag` Brand)만. 엔진 무관. +- **quad-roblox**: `Handlers/Property.luau`(기존 프로퍼티 세팅 로직에 + `isTween` 분기 + 3-상태 릴레이션 저장 + override 정책 추가) + + `Animate.luau`(편의 콤비네이터, 신규). +- **기존 `Handlers/Tween.luau`(독립 Dispatch 핸들러 파일) 자체는 더 + 이상 필요 없음** — `base/architecture.md` 소스트리 갱신 완료. + +## `retract`(구 cleanup)로 확정된 오버라이드 시맨틱 — Tween↔Tween 전환에서는 그대로 유지 + +**이 절의 4가지 옵션은 안 바뀜 — 다만 "Dispatch의 `retract` 호출"이 아니라 +"PropertyHandler 내부 로직이 참고하는 정책"으로 위치만 이동했다는 점에 +유의.** 이전 트윈을 취소하고 새 트윈을 만드는 게 맞지만, "취소" 시점의 +동작이 여러 갈래로 갈릴 수 있음: + +1. 키 밸류를 받으면, 그로 인해 생성된 트윈을 얻어서 **멈춰버리기**. +2. 트윈 뒤에 **삭제하지 않고 오버라이드**(새 트윈이 이전 트윈의 현재 값에서 + 시작, 이전 트윈 자체는 그대로 재사용/대체). +3. **삭제** 후 새로 시작. +4. 트윈을 **끝 지점으로 옮기고** 새로운 트윈을 시작. + +**확정된 기본값**: **멈춤(Cancel)** — 새 트윈은 현재 보간된 값에서 자연스럽게 +시작. 근거: Roblox `TweenService`의 `:Cancel()`은 프로퍼티를 되돌리지 않고 +그 자리에서 멈추기만 하므로, 새 트윈이 시작될 때 이미 인스턴스 프로퍼티에 +남아있는 현재 값에서 자연스럽게 이어짐 — 대부분의 UI 애니메이션이 기대하는 +동작과 일치. + +이 기본값 외 나머지 세 동작(오버라이드/삭제 후 재시작/끝점 이동 후 재시작)은 +라이브러리가 강제하지 않고, `Tween{Value=..., ease=..., onOverride=...}`처럼 +`Tween` 생성 시 넘긴 옵션으로 사용자가 고를 수 있게 열어둠 — PropertyHandler가 +위 3-상태 저장의 3번 분기에서 이 옵션을 참고해 구현. + +## 트윈 옵션 값 모양 — TweenInfo 그대로 vs 편의 필드 (여전히 열린 논의) + +**아직 논의 시작 단계 — 나중에 더 다룰 주제로만 남겨둠.** Roblox의 +`TweenInfo.new(time, easingStyle, easingDirection, repeatCount, reverses, +delayTime)`는 순수 포지셔널 생성자인데, Luau엔 named call 문법이 없어서 +직접 쓰면 `TweenInfo.new(0.3, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)` +처럼 각 인자가 뭘 뜻하는지 호출부만 보고 알기 어렵다. 후보: + +1. **`TweenInfo`를 그대로 받는다** — 사용자가 이미 만들어둔 `TweenInfo`를 + 재사용하고 싶은 경우엔 상관없지만, 대부분의 흔한 케이스(길이/이징만 + 바꾸고 싶음)에서 매번 포지셔널 생성자를 마주해야 함. +2. **편의 필드로 개별 인자를 받고 기본값을 제공** — 예: + `Tween{Value=..., Time=0.3, Style=Enum.EasingStyle.Quad, Reverses=false, ...}`처럼 + 이름 붙은 키로 받고 흔한 기본값(예: `Time=0.2`, `Style=Quad`, + `Direction=Out`)을 채워줌. 명시적으로 `TweenInfo`가 이미 있어서 + 재사용하고 싶다는 케이스에도 열어두면(예: `Info = someTweenInfo` + 필드로), 둘 다 지원 가능. + +**현재 소견(확정 아님)**: 2번(편의 필드 + 기본값)이 흔한 사용 경험상 더 +낫다는 쪽으로 기움 — 이번 세션의 모든 예시(`Tween{Value=..., ease=...}`)도 +자연스럽게 이 방향을 가정하고 있음. 다만 구체적인 필드 이름/기본값/ +`TweenInfo` 재사용 경로의 정확한 문법은 아직 확정 아님 — 나중 논의 대상으로 +남김. + +## 네임스페이스드 객체 (더 이상 유효한 관심사 아님) + +기존 모델(핸들러가 대상을 이름으로 찾아야 하는 가능성)을 염두에 두고 +열어뒀던 절 — 새 모델에서는 PropertyHandler가 `inst`를 항상 직접 +받으므로(다른 모든 핸들러와 동일) 이 문제 자체가 성립하지 않음. 절 자체는 +과거 기록으로만 남김, 실행할 내용 없음. + +## 열린 질문 (`.claude/question.md`에도 취합) + +- 기본값(Cancel)은 확정됨. 남은 건 나머지 세 동작(오버라이드/삭제 후 재시작/ + 끝점 이동 후 재시작)을 선택하는 옵션 키의 정확한 이름/시그니처, 그리고 + Tween→plain 전환에 5번째 옵션이 필요한지 — 구현 단계에서 확정. +- 트윈 옵션 값 모양(위 절) — `TweenInfo` 그대로 받을지 편의 필드+기본값으로 + 받을지, 소견은 후자 쪽이지만 확정 아님. +- `Animate` 콤비네이터의 정확한 시그니처(조건/옵션 분리 vs 통합) — M11에서 + 정리. +- 자연 완료(Completed) 시 per-instance 북키핑 정리 여부(3-상태 슬롯을 + `true`로 되돌리는 시점) — `research/pre-implementation-audit.md` 2-10번 + 참고, M11 착수 시 확정. +- `initValue`(진입 애니메이션) — 위 절 참고, 필요성 자체가 낮은 쪽으로 + 기움, 완전 폐기는 아님. 필요해지면 hasBeenSet 억제 동작과의 상충을 + 같이 풀어야 함. diff --git a/.claude/research/v1-compat-plan.md b/.claude/research/v1-compat-plan.md new file mode 100644 index 0000000..02b8cb0 --- /dev/null +++ b/.claude/research/v1-compat-plan.md @@ -0,0 +1,269 @@ +# v1 하위호환(compat) 레이어 타당성 검토 + +**상태**: research — 신규 조사(2026-08-06 세션, 사용자 질문으로 착수). 설계 +확정 아님, "얇은 래퍼가 가능한가"에 대한 타당성 평가만 담음. + +**배경**: 사용자가 "quad v1에 대한 하위호환 레이어를 v2가 얇은 래퍼로 제공할 +수 있을지" 질문. 폐기된 재작성 시도 `quad2-try`에 `quad-compat`이라는 +서브패키지가 있어서 "이미 한 번 시도했다 실패한 것"으로 짐작했으나, 조사 +결과 아래처럼 사실이 아니었음 — 완전히 새로 검토할 만한 주제. + +## 1. 선행 조사: quad2-try의 `quad-compat`은 실제로 시도된 적 없음 + +`base/bind-system-plan.md:715`에서 quad2-try의 서브패키지 9개(`quad-docs`, +`quad-debug`, `quad-compat`, `quad-2`, `quad-roblox`, `quad-lang`, `quad-gtk`, +`quad-core` 등)를 나열하며 "`quad-core` 밖엔 참고할 게 없다"고 기록돼있는데, +직접 확인한 결과 `out/quad-compat/`은 **파일이 0개인 완전히 빈 디렉토리**. +compat.lua나 어댑터 코드는 전혀 없고, README/커밋 메시지에도 "왜 포기했는지" +단서가 없음 — 애초에 착수된 적이 없다는 뜻. + +→ `question.md:110`이 "OOP 상속/커스텀 파서/Slot 스텁/`Pipe` COW는 확인된 +죽은 접근"이라고 명시한 목록에 compat은 **포함돼 있지 않음**(정확한 서술). +즉 CLAUDE.md의 "반복 조사 금지"는 compat에는 적용되지 않는다 — 이 문서를 +쓰는 게 규칙 위반이 아님. + +## 2. v1 공개 API 표면 — 두 계층으로 나뉨 + +v1(`.claude/initreq/quad/src`) 조사 결과, API는 성격이 다른 두 계층으로 +나뉜다: + +**(a) 표면 문법** — 개별 함수/헬퍼로 비교적 독립적: +- 이벤트 핸들러가 첫 인자로 `self`(or `this`)를 받는 관습(`event.lua:81-83`) +- 프로퍼티 테이블의 특수 키(`RoundSize`/`Corner`/`PaddingAll`/`Scale`, + `class.lua:134-213`) +- `target()`(정확히는 컴포넌트 내부 `self("이름")` 호출)을 통한 named + child 등록 + 시그널 중계(Linker, `class.lua:112-131,352-358,511-521`) — + **정정(2026-08-06)**: 최초 조사 때 "양방향 바인딩"으로 잘못 서술했음. + 실제로는 데이터 동기화가 아니라, Linker 값을 숫자 키(자식 위치)에 놓으면 + 생성된 자식을 `target[name]`에 한 번 등록(`Link`의 `indexType=="number"` + 분기, `rawset`)하고, 문자열 키(이벤트 값)에 놓으면 자식 이벤트 발생마다 + `target:GetPropertyChangedSignal(name)`을 대신 Fire하는 시그널 중계일 + 뿐 — "이름 있는 자식 참조 등록"에 더 가까움. +- `store.GetObjects("a,b&c")` 쿼리 문법의 오브젝트 태그 저장소(`store.lua:103-190`) + +**(b) 핵심 런타임** — v1 컴포넌트 모델 그 자체: +- `Class.Extend()`가 반환하는 단일 메타테이블이 상속 체인을 대신 (`class.lua:361`) +- 인스턴스화 시 생성자 인자를 자동으로 store로 감싸고(`class.lua:367`), + 이후 `comp.Text = "hi"`처럼 프로퍼티를 재대입하면 `__newindex`가 자동으로 + 내부 store에 위임 + `UpdateTriggers`에 걸리면 자동 재렌더까지 발생 + (`class.lua:524-566`) — CLAUDE.md에 이미 "이 자동 위임/재렌더 매직은 + v2에서 폐기하기로 확정"이라 기록된 바로 그 메커니즘. + +## 3. 계층별 실현 가능성 + +### 3-1. (a)는 얇게 재현 가능 — opt-in 서브패키지로 격리하면 근거 문제도 해소됨 + +- **이벤트 self 관습**: 클로저 한 겹으로 재현 가능. `base/bind-system-plan.md` + "이벤트 핸들러는 self를 받지 않는다" 절이 든 반대 근거 4가지(Ref 중복 + 채널, Modifier 정적 flatten과 경쟁, quad-debug 추적 밖 mutate 경로, 클로저 + 비용)는 **코어에 넣을 때** 문제가 되는 것들 — 별도 opt-in 패키지 + (`quad-compat` 부활)로 격리하면 비용은 compat 사용자만 부담하고 코어 KV + 핸들러 분기도 안 생김. 단, "quad-debug 추적 밖 mutate 경로가 생긴다"는 + 근거(4번)는 격리해도 남는 문제라 별도 검토 필요. +- **RoundSize 등 특수 키**: `Corner`/`PaddingAll`/`Scale`은 이미 + `base/ui-shorthand-plan.md`에서 네이티브 포팅 확정됨 — 별도 compat + 작업 불필요. `RoundSize`(이미지 9-slice 라운드 트릭)만 UICorner 없던 + 시절 워크어라운드라 재현 자체가 불필요하다고 이미 결론남. +- **`target()`/Linker**: 위 정정대로 "이름 있는 자식 등록 + 시그널 중계"일 + 뿐이라, v2 쪽에서 굳이 흉내낼 이유가 약함 — v2엔 이미 Ref가 있고(컴포넌트 + 경계로 참조를 넘기는 표준 경로), 시그널 중계는 아래 4번 브리지 메커니즘이 + 흡수함. +- **오브젝트 태그 조회**: v2엔 대응 개념이 아예 없음 — `CollectionService` + 태그로 유사 구현은 가능하나 새 서브시스템에 가까워 "얇다"고 하기 애매. + +### 3-2. (b)는 얇게 안 됨 — 컴포넌트 정체성 모델 자체가 충돌 + +`Class.Extend()` 자동-store 위임 + 자동 재렌더는 v1 컴포넌트 작성 경험의 +본질인데, v2는 정확히 이 매직("자기 store 자동 소유")을 이미 폐기하기로 +확정한 상태(`base/component-composition-plan.md` §1, 사용자 확정 발언 +"마법 안쓴다 그것도 동의함"). 이유는 이름 문제가 아니라 **컴포넌트 +정체성을 다르게 정의**하기 때문: + +- v1: 컴포넌트는 렌더 후에도 "살아있는 오브젝트"로 남아 `.Text = ...` + 재대입을 전제 — mutate 기반. +- v2: 반응형 소스(Store/State)를 갈아끼우는 방식, 컴포넌트는 "특정 상태의 + store를 받는 함수"(`architecture.md`) — 만들어진 후의 컴포넌트 인스턴스를 + 밖에서 mutate하는 접점 자체가 없음. + +이 격차를 메우려면 compat 레이어가 컴포넌트마다 "가짜 OOP 인스턴스"를 +만들어 내부적으로 v2 Store/State를 대신 조작해주는 shim을 새로 설계해야 +함 — 몇 줄짜리 어댑터가 아니라 사실상 v1 런타임을 v2 위에 재구현하는 것. +참고 사례로 Vue 2→3의 `@vue/compat`이 있으나, 그것도 별도 빌드 모드 + +다수의 호환 플래그 + 성능 오버헤드 경고가 딸린 규모라 "얇다"고 부르기 +어려움. + +## 4. 사용자 제안 — v1/v2 병행 사용 + 경계 리졸브 브리지 (2026-08-06 후속, 유력 방향) + +사용자가 3-2의 "얇게 안 됨" 결론에 대한 대안으로 제시한 방향: v1 런타임을 +v2 위에 재현하려 하지 말고, **v1을 그대로, 수정 없이 계속 돌리면서** v2와 +병행 사용하고, 두 시스템의 경계(v2가 만든 반응형 값을 v1 쪽에 넘겨야 하는 +지점)에서만 작은 브리지를 둔다는 아이디어. 검토 결과 **이쪽이 3-1/3-2보다 +분명히 나은 방향** — 아래 근거. + +### 왜 이게 작동하는가 + +1. **구조적 합성은 이미 공짜** — `architecture.md:11,18`의 DOMless 원칙상 + v1/v2 둘 다 렌더 결과가 그냥 평범한 Roblox Instance라, v1이 만든 + Instance를 v2 트리 안에 자식으로 두거나 그 반대나 특별한 어댑터 없이 + Roblox 부모-자식 관계만으로 합성됨. 3-2가 문제 삼은 "컴포넌트 정체성 + 충돌"은 **v1 컴포넌트 자체를 v2로 재구성하려 할 때만** 발생하는 문제고, + "v1 컴포넌트를 그대로 두고 옆에 놓기"에는 애초에 적용되지 않음. +2. **v2→v1 값 전달(사용자가 든 예시)도 이미 있는 재료로 충분히 얇음**: + - v2 쪽: `state:Observer()`를 인자 없이 호출하면 "이 State를 계속 + 능동 관측 상태로 유지"하는 유틸로 동작(`base/bind-system-plan.md:441`) + — 이걸로 lazy를 포기하고 항상 최신값이 계산되게 강제하는 부분이 이미 + 설계돼 있음. 사용자가 말한 "포기하고 전부 관측된 값으로" 정확히 이 API. + - v1 쪽: 만들어진 v1 인스턴스에 `instance.Text = value`처럼 그냥 + 재대입하면 v1의 진짜 공개 API(`class.lua:543-566`의 `__newindex`)를 + 타고 v1 자신의 업데이트 파이프라인(`UpdateTriggers`, 재렌더)이 정상 + 작동함 — v1 내부를 뜯어 흉내낼 필요 없이 v1이 원래 하던 일을 밖에서 + 호출만 하는 것. + - 합치면: `state:Observer(function() v1Instance.Text = state:Get() end)` + 한 줄 수준의 브리지로 "v2 State가 바뀔 때마다 v1 인스턴스 프로퍼티에 + 써주기"가 됨 — 3-2에서 우려한 "v1 런타임 재구현"이 전혀 필요 없음. +3. **정반대 방향(v1→v2)도 필요하다면 대칭적으로 얇음(미검증, 방향성만)**: + v1은 `GetPropertyChangedSignal`/`EmitPropertyChangedSignal` + (`class.lua:407-437`)을 이미 공개 API로 노출하므로, 그 시그널을 구독해서 + 매번 v2 `Source:Set()`(또는 clone 불가 값이면 `:Emit()`)을 호출해주는 + 것도 같은 패턴 — 다만 사용자가 예시로 든 건 v2→v1 한 방향뿐이라, 실제로 + 양방향이 필요한지는 아래 열린 질문으로 남김. +4. **경계 코드의 라이프사이클 정리도 새로 설계할 필요 없음** — 브리지용 + Observer 구독을 v1 인스턴스(진짜 Roblox Instance)의 `Destroying`에 + 묶으면 됨, 이미 채택된 rbvm `Connected`+GC 관용구(`base/ + lifecycle-pattern.md`)를 그대로 재사용. + +### 3-1(문법 설탕 compat)과의 관계 + +이 방향은 3-1의 "이벤트 self 관습, 프로퍼티 특수 키" 같은 **v1 쪽 표현을 +v2 문법으로 흉내내는 작업 자체를 없앰** — v1 코드는 그냥 v1 문법 그대로 +남아있고, v2는 v1을 흉내낼 필요가 없음. 즉 "compat 레이어가 v1처럼 보이게 +만드는" 문제가 "v1이 원래 하던 일을 그대로 하게 두고 데이터만 새 파이프로 +갈아끼우는" 훨씬 좁은 문제로 축소됨. + +## 5. 결론 / 권장 + +- **1순위(신규 권장)**: 4번의 "병행 사용 + 경계 리졸브 브리지" — v1을 + 그대로 두고 v2와 나란히 돌리되, 반응형 값이 경계를 넘는 지점만 각 쪽의 + 기존 공개 API(v2 `state:Observer()`, v1 프로퍼티 재대입/시그널)로 잇는 + 얇은 글루 코드. 3-2가 지적한 "컴포넌트 정체성 모델 충돌"을 재구현이 + 아니라 회피로 해결 — 사실상 strangler-fig식 점진 마이그레이션 패턴. +- **2순위(보조)**: 3-1의 문법 설탕 어댑터(이벤트 self 등) — 위 1순위로 + 충분하다면 불필요할 수 있음, "v1 문법 자체를 v2 컴포넌트 함수 안에서 + 쓰고 싶다"는 별도 니즈가 있을 때만 검토. +- **"v1 코드를 완전히 무수정으로 v2 런타임 위에서 돌리는 것"(3-2, OOP + mutate 재구현)은 여전히 비권장** — 1순위 방향이 그 문제 자체를 안 만들기 + 때문에 불필요. + +## 6. 확정된 것 (2026-08-06 후속 라운드) + +- **방향: v2→v1 단방향만.** 4번 3항목의 v1→v2(시그널 구독 → `Source:Set()`) + 방향은 사용자가 "필요성 모르겠다"고 확정 — 설계 범위에서 제외. 굳이 + 대칭성 때문에 만들 필요 없음. +- **패키지명: `quad-roblox-v1-compat`.** `quad-compat`처럼 엔진 무관을 + 가장하는 이름 대신, v1 자체가 애초에 Roblox 전용이라(quad가 엔진 무관화에 + 실패한 전례가 있다는 사용자 확인) 이 브리지도 처음부터 `quad-roblox` + 계열의 Roblox 전용 패키지로 이름 붙임 — `quad-base`/`quad-roblox` 확정 + 트리에 세 번째로 추가되는 패키지. +- **번역 경계 원칙 확정**: v1의 원시 타입(Linker, v1 store의 + `registerClass` 객체, `Class.Extend().New()`가 만드는 `this` OOP + 인스턴스)이 v2 코드 쪽으로 그대로 흘러들어가지 않고, v2의 원시 타입 + (Source/State/Store/Modifier/Ref)도 v1 코드 쪽으로 흘러들어가지 않는다 + — `quad-roblox-v1-compat`의 공개 표면은 오직 (a) 리졸브된 평범한 값과 + (b) Roblox Instance만 주고받는다. 두 런타임의 내부 핸들 타입이 서로의 + 영역을 침범하지 않는 게 핵심 — 아래 7번의 구체적 규칙들이 전부 이 원칙의 + 적용. + +## 7. 기술 계획 — 두 임베딩 방향 + Slot 조사 결과 (2026-08-06 후속) + +v1/v2를 병행 사용할 때 실제로 쓰이는 모양은 두 가지다: (A) 신규로 짜는 +v2 트리 안에 과거 v1 컴포넌트를 리프로 박아넣는 것, (B) 기존 v1 앱 안의 +요소를 하나씩 v2로 교체하는 것. 둘 다 지원 가능한지 v1 `mount.lua`/ +`class.lua`와 v2 `base/slot-plan.md`를 대조 조사했다. + +### 7-1. (A) v2 트리 안에 v1 컴포넌트를 리프로 박기 + +제안: `quad-roblox-v1-compat`에 `EmbedV1(v1ClassOrFactory, propsBuilder)`류 +어댑터 — v1 컴포넌트를 생성하고 루트 Instance를 v2 Slot/`InstanceChild`가 +받을 수 있는 leaf 값으로 반환. 내부에 흘려줄 v2 State는 4번에서 확정한 +`state:Observer()` 브리지로 v1 인스턴스 프로퍼티에 재대입. + +- **근거**: v1의 `mount()`(`mount.lua:49-87`)는 부모-자식 관계에 소유권 + 검사가 전혀 없음(누가 만든 Instance든 그냥 Parent 세팅 + `__child` + 등록) — v2가 v1이 만든 루트 Instance를 자기 Slot에 끼우는 것 자체는 + 막힘 없음. +- **위험 + 제안 규칙**: v1의 `mountClass:Unmount()`(`mount.lua:21-46`)는 + `this`가 Instance면 무조건 `this:Destroy()`를 직접 호출함. 반대로 v2 + Slot의 retract(교체) "폐기" 시맨틱이 quad가 안 만든(v1이 만든) foreign + Instance에 대해 뭘 하는지는 `slot-plan.md`에 명시가 없음(7-2 참고). + **→ v2 Slot이 `EmbedV1` 결과물을 폐기할 때 절대 직접 `:Destroy()`를 + 부르지 말고, 반드시 `EmbedV1`이 반환한 핸들의 v1 쪽 정식 `Unmount()`를 + 거치게 한다** — 이게 6번 "번역 경계 원칙"의 구체적 적용 하나. + +### 7-2. (B) v1 트리 안 요소를 하나씩 v2로 교체 + +제안: `quad-roblox-v1-compat`에 `EmbedV2(v2Component, props)`류 반대쪽 +어댑터 — v2 컴포넌트를 렌더한 루트 Instance를 v1 prop 테이블의 숫자 키 +자식으로 그냥 꽂을 수 있는 값으로 반환. + +- **위험 1 — 재렌더 시 파괴**: v1의 `Update()`(`class.lua:452-491`)는 + 루트 Instance를 파괴 후 재생성하되, `__child`에 정식 등록된(=`mount()`/ + `mountfunc` 경로를 거친) 자식만 새 루트로 재부모 지정하고, 그 외(직접 + `.Parent=` 대입 등)는 옛 루트와 함께 파괴됨. **→ `EmbedV2` 결과물은 + 반드시 v1의 정식 children 경로(prop 테이블의 숫자 키)로만 붙여야 함, + `.Parent=` 직접 대입 금지.** +- **위험 2 — Clone 함정**: `ProcessQuadProperty`(`class.lua:209-212`)는 + 같은 prop 테이블이 여러 인스턴스 생성 호출에 걸쳐 재사용되면(첫 번째 + 인자, `iprop==1`이 아닌 경우) 그 안의 자식 Instance를 통째로 `Clone()`함 + — v2 루트가 Clone되면 원본과 반응형 그래프 연결이 끊긴 죽은 복제본이 + 생김. **→ `EmbedV2` 결과물은 절대 공유/캐시된 prop 테이블(`Import`의 + defaultProperties, 재사용 style 테이블 등)에 넣지 말고, 매번 새로 만드는 + 최초(iprop==1) prop 테이블에만 넣도록 문서화** — 가능하면 구현 시점에 + Clone 감지 가드(예: 복제 발생 시 error) 추가 검토. +- **거저 얻는 이득 — 파괴 방향은 이미 맞물림**: v1은 자기가 파괴될 때 + children을 순회하며 개별 Destroy하지 않고 Roblox 엔진의 cascading + destroy에 의존함(`class.lua:494-508`에 순회 로직 없음, 확인 완료). v2의 + 라이프사이클은 이미 `Destroying` 훅 기반 GC-native 패턴 + (`base/lifecycle-pattern.md`)이라 "누가 파괴를 트리거했든 Destroying만 + 감지하면 됨" — v1이 자기 루트를 Destroy()해서 안에 박힌 v2 서브트리가 + cascading으로 같이 파괴돼도 v2 쪽 정리가 별도 브리지 코드 없이 자동으로 + 맞물림. + +### 7-3. Slot — 조사했지만 완전히 못 푼 부분 (사용자가 예상한 대로) + +- `base/slot-plan.md`엔 "엄격한 단일 마운트 소유권"(`isMounted` 관리, + 재마운트 시 즉시 `error()`)은 확정돼 있지만, **Slot이 이미 만들어진 + 임의 Instance를 동적 배열 원소로 받을 수 있는지, 아니면 그건 별도 + `InstanceChild`(정적 단일 삽입) 핸들러 전용인지가 문서에 명시 안 됨.** + `EmbedV1`의 반환값을 v2 쪽에서 Slot(동적 배열)에 넣을 수 있는지 + `InstanceChild`(정적 단일)로만 넣을 수 있는지는 실제 Dispatch/Slot + 구현 시점에 가서야 확인 가능. +- Slot의 retract "폐기"가 quad가 안 만든 Instance에 대해 정확히 뭘 하는지 + (그냥 `:Destroy()`인지, 다른 처리인지)도 문서 밖 — 7-1에서 제안한 + "직접 Destroy 금지, Unmount 경유" 규칙을 Dispatch 엔진의 어느 지점에 + 훅으로 강제할지도 Slot 실제 구현 시점 확인 필요. +- **결론: 지금 결정 불가.** M0 이후 Slot 코어 로직 구현 라운드 + (`question.md`의 "여러 Slot이 형제로 섞일 때 순서 보장" 항목과 같은 + 시점)에서 이 두 가지를 실제 구현과 함께 재확인해야 함. + +## 8. 남은 확인 사항 (추가 리서치 후보, 지금 결정 불필요) + +- v1이 자기 루트 Instance의 `Destroying`(또는 유사 신호)을 듣고 Lua측 + 부기(`store.AddObject` 태그 레지스트리 등)를 스스로 청소하는 경로가 + 있는지 미확인 — 7-1의 "v2가 v1 임베딩을 Destroy 대신 Unmount 경유해서 + 정리하라"는 규칙이 얼마나 엄격히 지켜져야 하는지가 여기 달림(v1이 + Destroying만 들어도 알아서 청소한다면 직접 Destroy해도 무방해질 수 + 있음). +- v1 store의 `registerClass` 체이닝 기능(`:Tween`/`:Default` 등)까지 + 브리지가 흡수해야 하는 케이스가 있는지 — 단순 프로퍼티 재대입만으로 + 충분한 범위인지 실사용 예시로 확인 필요. +- (2순위 문법 설탕 어댑터를 실제 채택할 경우) 이벤트 self 관습을 compat에서 + 되살릴 때, `base/bind-system-plan.md`가 명시한 반대 근거 4번(quad-debug + 추적 밖 mutate 경로)을 어떻게 처리할지 — quad-debug는 어차피 후순위라 + 지금 결정 불필요할 수도 있음. + +## 착수 시점 + +지금 당장 설계/착수 불필요 — `CLAUDE.md` "지금 할 일" 1번(구현 착수, +ROADMAP M0)이 최우선. 7-3의 Slot 관련 두 항목은 M0 이후 Slot 구현 +라운드에서 실제 코드와 함께 재확인해야 풀림 — 그 전까진 이 문서의 제안 +(7-1/7-2 규칙들)이 최선의 추정치. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22e392c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.claude/initreq diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6a87033 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,2698 @@ +# CLAUDE.md + +## 언어/모델 관례 (기존 메모, 유지) + +사용자는 한국 유저임을 유의해. 사용자가 보게 될 것은 한국어로 띄워주는 게 +좋아. 너가 보는 것들(코드 주석 등)은 원하는 언어여도 되는 것(가장 성능이 좋을 +영어를 쓰든 그래도 됨, 예를 들어 이 CLAUDE.md도 영어여도 무방하지만 지금은 +한국어로 유지). 사용자가 검토해야 하는 것(plan, backlog 등)은 한국어로 써. +코드 안 주석은 공식성을 유지하기 위해 굳이 한국어일 필요 없음, 영어 가능. + +또 토큰 맥싱에 유의해 — 아주 작은 테스크라 충분히 작은 모델이 쓸 수 있으면 +haiku, 일반 작업은 sonnet. 특히 소스코드를 많이 읽어야 하는 리서치는 메인 +컨텍스트를 부패시키니 Agent로 위임할 것(아래 "작업 방식" 참고). + +## 이 프로젝트가 뭔지 + +Roblox 엔진에서 동작하는 DOMless UI 렌더러 **quad**를 처음부터 다시 짜는 +프로젝트. 목표는 개별 프로덕트가 아니라 **라이브러리**로서의 코드 퀄리티와 +지속 가능성 — 빠른 이터레이션보다 정확성/설계 정합성이 우선. 작업 기간은 +길게 잡음. + +**지금은 설계/계획 단계이고 구현은 아직 시작 전** — 저장소 루트에 실제 소스 +코드(`src/` 등)가 없음. 핵심 아키텍처(Store 책임 분리, `process`/`retract` +디스패치 모델, Store/State/Source 온톨로지, 소스 트리 구조, Modifier 메커니즘, +컴포넌트=플레인 함수, 컴포넌트 경계 modifier/Ref 전달)는 전부 `.claude/base/`에 +문서로 확정돼 있음 — 먼저 `.claude/base/architecture.md`를 읽을 것. 사용자가 +직접 "지금 quad에서 가장 문제되는 부분"으로 지목했던 컴포넌트화(특히 +modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션에서 수렴 +완료(`base/component-composition-plan.md`) — 남은 핵심 설계 질문은 없고, +용어 정리(진행 중)와 실제 스캐폴딩만 남음, 아래 "지금 할 일" 참고. + +이전에 시도했다 폐기한 v2 재작성 시도(`.claude/initreq/quad2-try`)도 리서치 +완료 — OOP 상속/커스텀 파서/Slot 스텁/`Pipe` copy-on-write 절충안은 확인된 +죽은 접근이라 반복 조사 금지(`base/bind-system-plan.md` 참고). + +## 계획 문서 구조 + +`.claude/README.md`가 색인. 요약: +- `.claude/base/` — 확정된 아키텍처/컨텍스트, plan/done 개념 없음. 먼저 + `.claude/base/architecture.md`를 읽을 것. +- `.claude/reference/` — **[2026-08-07 신설]** base처럼 확정된 건 아니지만 + base 문서가 근거로 인용하는 온디맨드 참고 자료(v1 내부 동작 스냅샷, + Fusion/Vide 비교 리서치) — 항상 읽을 필요는 없고 인용될 때만 열어볼 것. +- `.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`(2026-08-09 + 세 번째 세션에 마지막 열린 항목까지 전부 해소, 이제 배경 자료용)/ + `pre-implementation-audit.md`/`v1-compat-plan.md` + — 전부 후순위(급한 건 `tween-plan.md` 세부 옵션 정도). 최신 목록·우선순위는 + `.claude/README.md`가 소스, 여기서 개수 반복 안 함(과거에 "두 개뿐"이라 + 적어놨다가 새 문서 추가될 때마다 안 갱신되는 패턴이 반복돼서 아예 안 + 세기로 함). +- `.claude/qa-request/`, `.claude/feedback/` — 구현 시작되면 쓰기 시작함, + 지금은 비어있음. `.claude/archive/`는 원래 같은 취급이었으나 + 2026-08-06 세 번째 세션부터 **완전히 뒤집힌 설계 결정을 원문+역전 + 이유+diff와 함께 보존하는 용도로도 사용 시작**(구현 완료 대상만이 + 아님) — `archive/store-source-proxy-reversed.md`가 첫 사례, 나중 + `quadnomicon` 콘텐츠 소재로 재사용 예정. +- `.claude/initreq/` — 클론해둔 참고 레포(quad v1, Fusion, Vide, rbvm, tbox, + code-docker) + PA님 실 코드(`artworks/`) + 원본 요청. **읽기 전용, + `.gitignore`로 커밋 제외됨** — 내용을 다른 곳으로 옮기지 말고 항상 원본 + 그대로 둘 것. 리서치가 더 필요하면 이 폴더를 다시 파고들 것. +- `.claude/question.md` — 사용자가 답해야 할 질문 전체 취합(우선순위순). +- 루트 `ROADMAP.md` — 설계 단계 종료 후 실제 구현 순서(M0, M1, ... 마일스톤 + + todo 체크박스). "무엇을 확정했는가"는 `.claude/base/`가 소스, "어떤 순서로 + 만드는가"는 이 문서가 소스 — 헷갈리지 말 것. +- 루트 `HUMAN_TODO.md` — 사람만 할 수 있는 일(로컬 GUI 조작, 스케줄/루프 + 설정 등). + +## 작업 방식 + +- **소스코드를 많이 읽어야 하는 리서치는 Agent(Explore)로 위임** — 메인 + 컨텍스트 보호. 이미 완료된 v1/rbvm/tbox/Fusion/Vide 리서치 결과는 + `.claude/base/`에 정리되어 있으니 중복 조사하지 말고 먼저 그걸 볼 것. +- **병렬화 가능한 작업은 Agent 여러 개를 한 메시지에 동시 호출.** 서로 독립적인 + 파일/주제를 다루는 리서치나 구현 조사, 또는 서로 다른 문서 파일을 고치는 + 문서 정리 작업이 여기 해당(단, 같은 파일을 동시에 고치는 에이전트를 병렬로 + 띄우지 말 것 — 충돌함). +- **크리티컬한 설계 결정은 구현으로 밀어붙이지 말고 plan을 research/에 남긴 채 + 연기.** 사용자는 Lua/Roblox 엔진을 깊이 아는 사람 — 근거와 선택지를 문서에 + 정리해두면 사용자가 깨어있을 때 훑어보고 답해줄 것. `.claude/question.md`에 + 반드시 반영. +- **작업이 끝나면(또는 방향이 바뀌면) 항상 자기 문서화** — 완료된 걸 다시 + 조사하게 되는 재작업을 막기 위함. `.claude/base/`로 승격, `.claude/qa-request/`로 + 이동, 또는 문서 자체를 갱신. code-docker/webmanager의 `.claude/` 관리 방식이 + 좋은 예시(`.claude/initreq/code-docker/webmanager/.claude/README.md` 참고). +- **문서가 쌓이면서 모순/중복/stale 마커가 생기기 쉬움 — 주기적으로 감사할 + 것.** 2026-08-04 세션에 실제로 전체 `.claude/` 코퍼스에서 이런 문제가 + 다수 발견되어 정리함(아래 "최근 세션 요약" 참고) — 여러 라운드에 걸쳐 + 같은 문서를 계속 고치다 보면 "정정됨" 표시가 원래 문장에 안 반영되고 + 방치되는 패턴이 반복되니, 큰 방향 전환이 있을 때마다 관련 문서 전체를 + 훑어 확인할 것. +- **Roblox Studio MCP 연결 시 주의**: Studio는 잘 죽는 편 — 죽었을 때 살리려고 + 위험한 명령을 반복 시도하지 말 것. 그런 상황이면 MCP 없이 할 수 있는 작업만 + 하거나 대기. 연결 방법은 `HUMAN_TODO.md` 1번 항목 참고(사용자가 Studio에서 + 베타 기능을 켜줘야 함). +- **`SAFETY.md` 반드시 지킬 것** — (1) GitHub 등 외부 git 호스팅에 이 레포를 + push하지 말 것, 모델의 git 작업 공간은 사용자가 별도로 마련해줄 제한 계정 + (예: git.qwreey.moe) 전용으로 국한됨 — 그 계정/원격이 설정되기 전까지는 + **로컬 git 커밋까지만** 하고 원격 추가/푸시는 하지 말 것. (2) Roblox Studio는 + 메인 계정이 아닌 별도 계정으로만 사용 — 로그인 계정 전환을 사용자가 안 해줬다면 + Studio 관련 작업(MCP 연결 등)을 진행하지 말고 대기. + +## 지금 할 일 (우선순위순) + +1. **구현 시작 — 루트 `ROADMAP.md`의 M0부터.** 설계 단계는 2026-08-04 + 로드맵 인수인계 라운드로 종료, 다음 세션은 바로 `ROADMAP.md` M0(스켈레톤+ + 기술검증 스파이크)부터 시작. M0는 "진짜" 마일스톤이 아니라, 지금까지 + 추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 세 가지(Store/State + propagation, 재귀 process/retract 디스패치, 컴포넌트 경계 named-parameter + 전달)를 던지는 코드로 검증하는 단계 — 여기서 걸리면 `base/` 문서를 그 + 자리에서 고치는 게 정상. M0 통과 후 M1(실제 스캐폴딩: `quad-base/`, + `quad-roblox/` 폴더 + `wally.toml`/`default.project.json`/`.luaurc` + + quad-base용 최소 mock 테스트 하네스)으로 진행 — 소스 트리 자체는 이미 + 확정됨(`base/architecture.md` "구현 착수" 절). 이 단계부터 + `qa-request/`/`archive/` 폴더가 실제로 쓰이기 시작함. **세부 순서/todo는 + `ROADMAP.md`가 소스** — 여기서 반복 안 함. **M0 착수 직전에 `research/ + pre-implementation-audit.md`(2026-08-06 신설)의 우선순위1 항목부터 먼저 + 확인할 것** — 특히 M0 스파이크 코드 자체에 반영해야 할 항목(props.Modifier/ + Ref를 안 넘기는 케이스 포함, `store.key` 레코드 필드 타이핑도 M0로 + 앞당기기 검토)이 있음, 아래 최신 세션 요약 참고. **M0 실제 착수 전, + `.claude/luau-test/`(2026-08-09 신설)의 사전 검증 스파이크 결과부터 + 확인할 것** — M0가 공식 짜야 할 스파이크와 겹치는 항목들을 미리 + 독립 스크립트로 만들어 사용자가 `luau`/`luau-analyze`/`luau-lsp`/ + Roblox Studio로 직접 돌려보기로 한 상태, 아직 결과 미확인. 걸리는 + 게 있으면 `base/` 문서부터 고치고, 없으면 그대로 M0 실제 코드 작성에 + 재사용하면 됨(README 참고). +2. **용어 정리 — 사용자가 별도로 요청, 진행 중.** "register"(v1) 같이 + 부정확한 이름들을 전체적으로 재검토하자는 요청 — 1차 제안 완료(우선순위 + 순: `State`가 React/Vue식 "쓸 수 있는 로컬 상태"라는 통상 의미와 반대라 + 가장 위험, `DI`가 Dependency Injection 축약어와 충돌, `PerInstanceState`가 + 핵심 프리미티브 `State`와 이름 충돌 — 세부는 `.claude/question.md` 참고), + 사용자와 같이 계속 논의 필요. 컴포넌트 경계용 `props.Modifier`/`props.Ref`/ + `Modifier.Merge` 같은 새 가칭들도 이 정리에 합류 대상. +3. `research/existing-instance-bind-plan.md`는 급하지 않음 — 스코프 논의만 + 필요, 구현 착수를 막지 않음. +4. **[백로그] 범용 렌더 디버깅 도구로서의 quad-mock.** 1번의 quad-base 테스트용 + mock과는 별개 — 정적 스냅샷을 넘어 Tween mock 같은 동적 동작까지 지원하는 + 더 큰 스코프의 디버깅 도구(`architecture.md` "테스트 전략" 절 백로그 참고). + 효용성 봐가며 나중에 검토, 지금 당장 설계할 필요 없음. **[백로그, 별개]** + 런타임 디버깅 플러그인 `quad-debug`(실물 Instance→코드 위치 역추적, + `research/debug-tooling-plan.md`)도 2026-08-06 세션에서 설계 수렴 — + 채널 실현 가능성(Studio 플러그인↔Play 중 게임 간 BindableEvent/Function + 통신)까지 실측 검증 완료, 세부 API 이름만 남음. 착수 시점은 여전히 + "quad 개발 상당 부분 끝난 뒤"로 사용자가 못박음. 같은 날 파생된 문서화 + 전략(`research/documentation-plan.md` — UI 네이밍 컨벤션, Store 부작용 + 게임 시스템 활용 패턴, 권장 이벤트 핸들링 패턴 3종)도 후순위 백로그로 + 같이 남김. **[2026-08-06 세 번째 세션에서 크게 확장됨]** 문서 사이트 + 전체 구조(초심자/api/심화/`quadnomicon` 4축)와 실제 콘텐츠 분류맵 + (`research/documentation-content-map.md`), quad vs Fusion/Vide/react-lua + 정직 비교(`research/framework-comparison-findings.md`)까지 늘어남 — + 착수 우선순위 자체는 안 바뀜(여전히 후순위), 아래 최신 세션 요약 참고. +5. 자율 작업 루프/스케줄 설정 여부는 사용자 결정 대기 중 + (`HUMAN_TODO.md` 2번 항목). + +## 최근 세션 요약 (2026-08-04, 6차 라운드 이후) + +**6차 라운드**: 남아있던 "급하지 않음" 질문 두 개 해소 — 태그 네임스페이싱 +충돌은 컴포넌트 단위로는 Ref가 대신 해결해줘서 심각하게 안 봄(`architecture.md` +5번), Store가 Store를 담는 경우는 없음으로 확정(Store는 Source에 준하는 +"시작점"이라 다른 반응형 값에 자동 연결되지 않음, `bind-system-plan.md`). + +**그 이후 채팅에서 세 가지 큰 스레드가 새로 열림/정리됨**: +- **Modifier 메커니즘 전체 확정** — 런타임 pluggable 핸들러가 아니라 정적 + merge, immutable+`table.clone` 기반 체이닝, 필드가 State일 수도 있는 + 경우의 setter/getter 동작까지 전부 확정(`base/modifier-plan.md`, 새로 + base 승격). 이 논의에서 "관측해야 실체화된다"는 프로젝트 전역 원칙도 + 명문화(`bind-system-plan.md`). +- **컴포넌트화 논의, 같은 날 후속 세션에서 완결** — v1의 `Class.Extend()` + 자동-store 매직은 폐기하고 React식으로 값을 명시적으로 전달하는 방향으로 + 수렴, `StoreSource`(Source를 인터페이스+구현체로 보고 Store 키에서 얇은 + 프록시로 얻는 것) 아이디어 확정. 마지막 미결이던 "modifier/Ref의 컴포넌트 + 경계 통과"도 후속 세션에서 풀림: Compose/Fusion/Vide/v1 4개 선례를 + 서브에이전트로 병렬 조사한 결과 전부 named parameter로 경계를 넘기는 + 패턴에 수렴한다는 게 확인됐고, "컴포넌트가 여러 루트를 반환한다"는 + 프레이밍 자체가 (a) Luau가 tail position 밖 다중 리턴을 지원 안 해서 + 불필요한 개념과 (b) 이미 있는 Slot 메커니즘을 섞은 것이었음이 드러나 + 정리됨 — 결론: 경계는 named parameter(`props.Modifier`/`props.Ref` + 가칭), "다중 루트"라는 별도 개념은 폐기, 여러 modifier를 하나로 합치는 + `Modifier.Merge`(가칭) 유틸 추가. `research/component-composition-plan.md` + → `base/component-composition-plan.md`로 승격 완료. +- **문서 전체 감사 및 정리** — `.claude/` 코퍼스 전체(약 15개 문서)를 + 서브에이전트로 감사해 여러 라운드에 걸쳐 쌓인 모순/중복/stale 마커를 + 대거 발견하고 수정(예: 이벤트 dot-access 확정 여부가 문서 내에서 서로 + 모순, 이미 해소된 질문이 "미해결"로 방치, 존재하지 않는 문서/섹션을 + 가리키는 끊긴 참조 다수, `TagService`/`CollectionService` 혼용 등). + `research/purity-and-effects-plan.md`도 내용이 이미 확정 상태라 `base/`로 + 승격. **이 CLAUDE.md 자체도 이번에 오래된 라운드별 인수인계 메모 3개를 + 이 요약 하나로 통합하며 정리함** — 라운드별 상세 히스토리가 필요하면 + git log와 각 `base/`/`research/` 문서 안의 라운드 표시(예: "2026-08-04 + 3차 라운드")를 참고할 것, 여기서 전부 반복하지 않음. + +**같은 날 로드맵 인수인계 라운드 — 설계 단계 마무리, 구현 준비 완료**: +- **quad-base 테스트 mock 방향 확정**: Vide 선례(`test/mock.luau`, ~300줄, + 순수 `luau` CLI, Studio 불필요) 그대로 채택, 스코프는 정적 디버깅 한정(Tween + 같은 동적 동작 제외), quad-roblox로 작성한 컴포넌트가 mock에서도 그대로 + 돌아가야 한다는 요구 없음(단순하게 감) — `architecture.md` "테스트 전략" + 절. 범용 렌더 디버깅 도구(Tween mock 포함)는 별개로 백로그. +- **구현 전 리스크 감사**: `.claude/base/` 전체 + 남은 `research/`를 + 서브에이전트로 감사해 "실제 Luau 접촉 없이 추론만으로 확정된 것" 3개 + (Store/State 반응형 코어, 디스패치 엔진, 컴포넌트 경계 modifier/Ref)를 + 식별 — 이것들은 M0 스파이크로 검증하기로 함(아래). 감사 중 `slot-plan.md`가 + 스스로 "정식 확정 안 됨"이라 표시해뒀던 "클래스가 슬롯을 받는 방법"(Named + Slot 없음)도 이번에 정식 확정, 대신 "여러 Slot이 형제로 섞일 때 순서 보장" + 이라는 새 하위 질문이 열림(다중 백엔드 관점, Roblox만이면 급하지 않음). + `State` 조합은 UB로 확정해 타입으로 막기로 함(`modifier-plan.md` + 7번), 디스패치 엔진의 일반적 무한루프는 방어 로직 없이 provider 버그로 + 간주하기로 확정(`bind-system-plan.md`). +- **루트 `ROADMAP.md` 신설** — M0(스켈레톤+기술검증 스파이크, "진짜" + 마일스톤 아님)부터 M11(Tween)까지 + 병행 가능 항목 + 백로그로 구성된 실행 + 계획, todo 체크박스 포함. 오늘은 문서 준비만 — **다음 세션이 M0부터 실제 + 시작**. + +용어 정리 제안 진행 중인 점은 위 "지금 할 일" 2번 참고. + +## 2026-08-06 세션 — quad-debug(런타임 디버깅 플러그인) 설계, 실측 검증까지 완료 + +팀원이 "실물 Frame에서 그걸 만든 코드 위치를 역추적하는 Studio 플러그인이 +있으면 좋겠다"는 피드백을 줬고, 사용자가 이걸 `quad-debug`/ +`quad-debug-roblox-plugin`으로 후순위 설계해두자고 판단해 시작된 세션. +착수는 여전히 "quad 개발이 상당 부분 끝난 뒤"로 못박혀 있음(구현 우선순위는 +안 바뀜) — 대신 base 설계(디스패치/Source/DI 생성자) 시점에 훅 확장 +지점만 고려해두면 나중이 훨씬 싸진다는 문제의식으로 지금 미리 설계만 +해둠. 전체 내용은 `research/debug-tooling-plan.md`, 요지만 요약: + +- **설계를 막던 유일한 기술적 불확실성이 실측으로 해소됨**: Roblox Studio + 플러그인과 Play 중인 게임(LocalScript)이 별도 Luau VM/스크립트 + 컨텍스트라 `BindableEvent`/`BindableFunction`이 그 경계를 실제로 + 넘는지가 문서만으로는 안 갈렸음(공식 문서는 언급 없음, DevForum엔 + 실패 사례도 있었음) — 사용자가 테스트용 플러그인/스크립트 + (`plugin-ignoreme.luau`/`game-ignoreme.luau`, 레포 루트, `*-ignoreme*` + 패턴이라 자동 gitignore)를 직접 Studio에서 돌려 **Fire/Connect, + Invoke/OnInvoke 왕복 둘 다 안정적으로 작동함을 확인**. 원리는 "Play + 진입 시 DataModel이 복제되는 게 아니라 script identity/보안 컨텍스트만 + 분리되고, Instance 자체(C++ userdata)는 어느 컨텍스트에서든 같은 + 참조를 가리킨다"는 것 — 사용자가 실측 도중 직접 정리한 설명. +- **채널은 확인됐지만 처음 구상(quad-debug-roblox가 `ReplicatedStorage`에 + Bindable을 자동 생성)은 기각** — 개발자가 의도 안 한 Instance를 게임 + 트리에 주입하는 부작용이 크다는 사용자 지적. 대신 quad 모듈 자신의 + Instance 트리 안에 두고 `CollectionService` 태그로 노출, 플러그인은 + `GetTagged`로 찾음(`GetDescendants` 전체 순회 불필요). +- Roblox Luau의 `debug` 라이브러리엔 `sethook`류가 없어(확인됨) 엔진이 + 공짜로 주는 동적 트레이싱 방법이 없음 — 대신 Fusion `src/External.luau`에 + 이미 있던 "no-op 업밸류를 나중에 실제 구현으로 통째로 교체" 패턴을 + 재사용하기로 함(quad가 이미 쓰는 "base는 인터페이스, 구현은 팩토리가 + 주입" 원칙과 같은 모양이라 새로 발명할 필요 없음). +- React DevTools 아키텍처도 서브에이전트로 조사 — 그대로 못 베끼는 것도 + 있지만(전역 훅 주입은 프로세스 경계 문제로 안 됨), **컴파일타임 소스 + 위치 주입**(Babel처럼 darklua로 흉내낼 후보)과 **얇은 스트림+on-demand + 상세조회** 원칙은 그대로 채택. +- UX 방향은 사용자가 여러 번 직접 정정: "존재하는 State 목록"이 아니라 + "무엇이 무엇에 연결됐는가" 그래프 중심, flash-on-update는 전체 상시 + 적용이 아니라 마운트/언마운트만 상시+개별 프로퍼티 변경은 현재 열어본 + Instance 한정, PropertyChangedSignal 기반 "외부 변경 감지"는 핵심 + 채널이 아니라 보조 신호일 뿐(어디서/왜 바뀌었는지가 quad-debug의 진짜 + 가치라 순수 관찰만으론 부족). **Element Inspector**(화면 클릭으로 UI + 요소 피킹)가 사용자가 실제로 가장 크게 느낀 pain point로 새로 부상 — + Roblox가 Play 중 라이브 UI 편집 도구를 꺼버려서 Explorer만으로 요소 + 찾기가 힘들다는 실사용 불만. +- 부수적으로 파생된 두 가지(quad-debug 범위 밖) 문서화 아이디어 — + UI 네이밍 컨벤션 문서, 스킬/쿨타임/재화 같은 게임 시스템에서 Store의 + 부작용 허용을 깔끔한 패턴으로 쓰는 법 문서 — 를 `research/ + documentation-plan.md`에 뼈대만 분리해서 남김(위 "지금 할 일" 4번). +- **이벤트 self 관습 확인 필요했던 항목 — 같은 날 후속 세션에서 해소됨.** + 아래 "2026-08-06 후속 세션" 절 참고. + +**같은 세션 후반, 별개 주제 두 개 추가**(quad-debug와 무관, 사용자가 +"적어는 뒀는데 안 줬는건가" 하며 새로 떠올린 것들): +- **Attribute 특수 키 타입 파라미터화** — `[Attribute<> "name"]` + 제네릭 스타일 vs `[BooleanAttribute "name"]` 타입별 정적 생성자 패밀리. + 기존 문서 어디에도 없던 신규 논의로 확인(`bind-system-plan.md` + "Attribute 특수 키" 절에 새로 추가) — 소견은 DI 인스턴스 생성 때 이미 + 쓴 "제네릭 하나 + 자주 쓰는 타입만 정적 지름길" 패턴 재사용, 확정은 아님. + Roblox Attribute가 이제 Instance 참조 타입도 지원해서 `ObjectValue` + 없이 Ref 용도로도 쓸 수 있다는 점도 확인 — quad-debug 논의의 "Value + 오브젝트 기각, Attribute 우선" 결정을 보강함. +- **UICorner/UIPadding/UIScale 인라인 편의 키** — 사용자가 v1에서 "Frame + 안에 인라인으로 넣기만 해도 CSS처럼 적용됐다"고 기억한 기능, 서브에이전트로 + v1 소스(`class.lua`) 조사해 실체 확인: `Corner`/`PaddingAll(Offset)`/ + `Scale` 3종(+ 별개 메커니즘인 `RoundSize`)이 실제로 있었음(리터럴 값 하나 + → 이름 붙은 UICorner/UIPadding/UIScale 자식을 찾거나 생성). `UIListLayout`/ + Grid/Flex 전용 숏핸드는 없었음 — 그건 이미 quad-v2에 있는 범용 + children-array 메커니즘으로 충분히 커버되므로 새로 설계할 것 없음. + **한 차례 "지금은 UICorner가 네이티브라 포팅 불필요"로 잘못 정리했다가 + 사용자가 재정정**: `RoundSize`(이미지 9-slice 라운드 트릭)만 UICorner + 없던 시절의 워크어라운드라 포팅 불필요고, `Corner`/`PaddingAll`/`Scale` + 자체는 "UIScale 등이 여전히 별도 Instance라 부모에 붙여야 하는 구조는 + 안 바뀌었다"는 이유로 **여전히 필요한 기능으로 재확정**. `research/ + ui-shorthand-plan.md`에 최종 정리 — 메커니즘은 기존 pluggable Handler로 + 그대로 커버(새 아키텍처 개념 불필요), 패키지 배치는 `quad-roblox` 코어에 + 직접 포함으로 확정(별도 `quad-roblox-util` 불필요 — "트윈도 하나로 묶어 + 코어에 넣은 선례처럼, 작고 opt-in 아닌 건 분리 안 한다"는 사용자 판단). +- **quad-debug 플러그인 UI 구조 확정** — Explorer에서 quad 내부 자동 + 생성물(예: 위 UICorner 숏핸드가 만든 것)을 직접 선택했을 때 플러그인 + 트리에 대응 노드가 없으면 부모로 대신 선택, 있으면(사용자가 직접 + bind한 경우 등, UB 아님) 정확히 그 노드 선택. 내부 자동 생성물은 + `_`/`QUAD_` 접두어로 네이밍(v1 `_quad_round`류 재사용, + `documentation-plan.md` 네이밍 컨벤션과 연결). 플러그인 UI는 세 + 상호작용면(자기 트리 뷰/리프 클릭→상세 패널/실제 Explorer 선택과 연동, + Explorer와 플러그인 트리는 별도 도킹 위젯)으로 구성된다는 것도 사용자 + 질문에 확인 응답 — `debug-tooling-plan.md` "핵심 설계 방향" 9번. + +## 2026-08-06 후속 세션 — 이벤트 self 관습 결정, rbvm GC 참고, 문서 코퍼스 정리 + +같은 날 이어진 세션에서 세 가지를 처리함. **다음 세션이 새로 알아야 할 것은 +없음** — 아래 전부 `base/`/`research/`/`question.md`에 실제로 반영 완료. + +**1. 이벤트 핸들러 self(Instance) 관습 — 채택하지 않기로 확정.** 위 절에서 +"확인 필요"로 남겨뒀던 것의 결론: v1의 `func(self or this, ...)` 관습은 +실존함을 확인했지만(`.claude/initreq/quad/src/event.lua` 82행, 튜토리얼 +문서화까지 있음), quad 재설계에서는 채택하지 않음. 근거 네 가지 — +(1) Ref가 이미 "생성 직후/마운트 후 Instance 접근"을 콜백으로 커버해서 +중복 채널이 됨, (2) self로 재바인드 가능한 thin wrapper를 준다면 Modifier의 +정적 flatten과 경쟁하는 두 번째 쓰기 경로가 생겨 KV 핸들러가 매번 +"flatten된 값이냐 wrapper냐"를 분기해야 하는 오버엔지니어링, (3) +quad-debug가 추적하는 반응형 그래프 밖의 mutate 경로가 공식 API로 +생기는 셈이라 `purity-and-effects-plan.md`의 이식성 원칙과 충돌, (4) +self를 넘기려면 원본 콜백을 클로저로 한 번 더 감싸야 해서 불필요한 할당 +비용 — quad는 어차피 라이프사이클 끝까지 바인딩을 들고 있어 Destroy 시 +Connection도 자연히 정리되므로(`lifecycle-pattern.md`, GC-native) 감쌀 +이유가 없음. 상세 결정문은 `base/bind-system-plan.md`의 "이벤트 핸들러는 +self(Instance)를 받지 않는다" 절. `research/debug-tooling-plan.md`/ +`.claude/question.md`의 관련 항목은 "해소됨"으로 갱신 완료, 이 결정을 +설명하는 문서화 숙제("왜 thin wrapper를 안 주는가", "권장 이벤트 핸들링 +패턴")는 `research/documentation-plan.md` 3번으로 신설(다른 두 항목과 +동일하게 아직 백로그 뼈대만). + +**2. rbvm GC 패턴 — "실물 검증됨" 근거 보강.** 사용자가 "GC 처리를 봐야 +한다면 rbvm을 확인하라, 실제 프로덕션에서 잘 돌아가는 걸 직접 확인한 +모듈"이라고 언급 — 실제로 rbvm의 GC 패턴(weak table 4종, `Instance. +Destroying` 기반 gcHold 클로저, 네임스페이스 Dispose 훅 등)은 이미 +`base/lifecycle-pattern.md`에 파일:라인까지 인용하며 상세 반영돼 있었지만 +"사용자가 직접 실행해서 확인했다"는 신뢰도 근거는 빠져있어서 그 문단을 +추가함(사람이 짠 코드라 100% 무결 보장은 아님 — 이미 발견된 버그 2건도 +근거로 같이 인용, 규범이 아니라 참고용 비교 대상이라는 톤 유지). + +**3. `.claude/` 코퍼스 전체 정리 패스.** 이전 세션들에서 쌓인 stale +참조/모순을 서브에이전트로 전수 감사 후 수정 — `modifier-plan.md`/ +`architecture.md`의 `research/component-composition-plan.md` 참조를 +승격된 `base/` 경로로 갱신, `comparison-fusion-vide.md`의 낡은 "Vide식 +암묵적 추적 vs Fusion식 명시적 축, quad는 미정" 서술을 실제 확정 사실 +(`bind-system-plan.md`의 `:With`+`:Compute` 명시적 모델 채택)로 정정, +`tween-plan.md`의 끊긴 절 참조 수정, `documentation-plan.md`의 인용 +오류 정정. `module-lifecycle-plan.md`가 스스로 "question.md에도 취합" +표시해뒀지만 누락돼 있던 "프로바이더" 이름(provider/processor/plug) +미정 항목도 `question.md`에 추가함. 여러 문서에 흩어져 있던 진짜 열린 +설계 질문들(Slot 형제 순서 보장, Attribute 타입 파라미터화, UI shorthand +이름 등)은 전부 `.claude/question.md`에 이미 반영되어 있음을 재확인만 +하고 임의로 결정하지 않음 — **이 파일이 여전히 "지금 열려있는 것"의 +단일 소스.** + +**4. Store `:Emit`, `:Compute`의 `previous` 인자, `state:Observer(fn)`, +Ref 일반화 — 네 가지 다 확정, 실제 base 문서에 반영 완료.** 같은 세션에서 +더 이어진 Store/Ref 설계 논의, 전부 `base/store-semantics.md`와 +`base/bind-system-plan.md`에 반영됨: +- **`Store:Emit(key)`** — Source 원천에 한해서만 허용(중간/파생 State엔 + 없음). 존재 이유는 clone 불가능한 userdata/엔진 객체가 우선(편의성은 + 부차적). `Get()`이 라이브 레퍼런스를 주므로 캐시해서 비교/diff하면 + 안 된다는 캐비엇 명시. Modifier는 정적 flatten이라 Store/State 경로에 + 아예 안 걸치므로 Emit과 충돌할 지점 자체가 없음(따름정리: + `Store`의 `T`는 Modifier가 될 수 없음) — `store-semantics.md`. +- **`:Compute(fn)`의 선택적 두 번째 인자 `previous`** — Compute 결과 + 자체가 무거운 userdata인 경우(예: 큰 locale 테이블 → Roblox + `LocalizationTable` 변환) 재생성 대신 이전 결과를 재사용/patch하는 + 용도, opt-in. `previous`는 "정확히 한 단계 전"이 보장 안 되므로 반드시 + full diff로 다뤄야 함(React reconciler와 같은 모양). **핵심 캐비엇**: + 이 패턴은 결과 State가 계속 능동적으로 관측(정상 prop 바인딩 또는 + `state:Observer(fn)`+명시적 `Get()`)되지 않으면 mutate 로직 자체가 + 다시 실행 안 되어 조용히 영구 정지함 — `bind-system-plan.md`. +- **`state:Observer(fn)`** — 무효화 신호만 주고 값은 안 줌, `fn` 안에서 + 명시적으로 `Get()` 해야 실제 값을 얻음(기존 "emit은 저렴한 무효화 + 신호" 원칙 재사용). 반환값 자체가 `CreatedRef`처럼 children 배열에 + 바로 놓는 leaf 값(별도 `ObserverHolder` 래퍼 불필요, 사용자가 직접 + 단순화) — 그 leaf가 살아있는 동안만 구독 유지, `canExecute`로 게이팅. + `fn` 생략 시 "이 State를 그냥 계속 능동 관측 상태로 유지"하는 유틸로 + 씀(위 `previous` 캐비엇의 해결 도구). 구현은 값 내부가 아니라 외부 + weak table로 살아있는 Observer를 추적하는 방식 권장(rbvm + `getNamespaceOf`류 선례) — `bind-system-plan.md`. +- **Ref 일반화** — "quad가 만든 instance 전용"에서 "아무 사용자 값이나 + 담는 범용 값 박스"로 확장(object-ref/function-ref 안 나눔, React + `useRef`가 선례). `.Value` + `:Wait()`(coroutine 컨텍스트용) + 콜백 + 등록(복수 허용, 이미 채워져 있으면 즉시 1회 호출) — 이걸로 "코루틴 + 기반 대기 지원 미정"이던 항목 해소. `CreatedRef`는 이 위에 얹힌 특수 + 편의 패턴으로 재정리, 상충 없음. **one-shot 여부도 해소됨 — 반복 + 재설정 가능으로 확정**(React의 자식 재생성 시 ref 재사용 패턴이 선례, + 라벨 컨테이너 재사용 예시로 확인). 콜백은 발화 후에도 안 소진되고 + 매 `:Set()`마다 다시 불림 — 소진되는 건 `:Wait()`의 개별 대기자뿐. + **Ref는 의도적으로 lazy가 아니고 `:Compute` 파생도 지원 안 함** — + State와의 이 차이가 중요(예전에 Store가 Ref 역할도 겸했다가 lazy + 모델과 섞여서 안 좋았던 경험에서 나온 의도적 분리). Ref 정의 자체가 + 넓어졌으니 용어 정리 때 이름도 같이 재검토 대상. `question.md`의 + 관련 항목은 해소됨으로 갱신. + +**5. Observer 이름 확정, Ref/Source/Store 생성자 스타일, "독립 프리미티브 vs +파생 데이터" 원칙, Modifier 세부 마무리 — 전부 확정, base 문서 반영 완료.** +- `Observer`로 확정(`ObserverHandle` 아님) — `:Connect()`→`Connection`과 + 같은 기존 명명 관례. PA님 코드의 동명 클래스와는 무관, 각주로 구분. +- **생성자 스타일**: `Source(default)`/`Ref(default)`/`Store({defaults})` + — Kotlin Compose식 "타입 이름 자체가 팩토리 함수". Ref만 예외였던 이유 + 없었음(단순 명세 공백). +- **일반 원칙 신설**: 독립 존재 가능한 프리미티브(Source/Ref/Store/ + Modifier, `Type(args)` 자유 함수 생성자) vs 원천에 종속된 파생 데이터 + (State/Observer, 원천에 대한 메소드로만 얻어짐) — `state:Observer(fn)`가 + 메소드고 자유 함수가 없는 더 근본적인 이유로 연결(`store-semantics.md`). +- **Modifier 마무리**: (a) Getter를 아예 안 만들기로 확정 — + `:FontSize(function(old)->new)`가 유일했던 use case를 인라인으로 커버. + (b) `old`는 항상 "현재 저장된 그대로"(plain이면 raw, State면 State + 핸들) 넘김 — `:Compute`의 self와 같은 결. (c) `func(state)->state`라는 + 세 번째 인자 모양은 불필요(함수 합성 + State 직접 대입으로 이미 커버). + (d) Modifier는 핸들러 계층(Ref/Slot 등)을 몰라도 됨 — 순수 데이터 + merge 레이어라 UB로 흘려보내도 문제없음. (e) **런타임 구현은 base에 + 제네릭 `__index` 하나면 충분** — `mod:FontSize(...)`가 `__index(self, + "FontSize")`로 잡히므로 클래스별 런타임 코드 불필요, FrameModifier류 + 타입 생성기는 순전히 정적 타입 체크만을 위한 것. (f) 이벤트도 + store-bind 가능하도록 확정 — 기존 재실행 래핑 재사용, `false`를 + disconnect 센티널로 씀(`nil`은 테이블에서 사라져서 부적합) — + `bind-system-plan.md`. Modifier가 이벤트 키를 담아도 되는지는 (d)로 + 자동 해소(Modifier가 애초에 키 종류를 구분 안 하므로). + +**6. 이벤트 store-bind는 부차적 옵션으로 재조정, Observer의 `:Subscribe`/ +`:Unsubscribe` 추가 — 둘 다 확정, 반영 완료.** +- 이벤트 store-bind(5번 (f))를 다시 검토 — "구현이 쉽다"가 "구현할 + 가치가 있다"를 보장 안 함을 재확인. 저빈도 UI 이벤트의 조건부 처리는 + "핸들러 하나 계속 연결 + 내부에서 `store.enabled:Get()` 분기"가 이미 + Connect/Disconnect 없이 더 싸고 표준적이라 **이걸 기본 권장 패턴으로 + 확정**. store-bind는 고빈도 신호(Heartbeat 등)나 로직 자체가 바뀌는 + 드문 케이스를 위한 부차적 옵션으로 격하(메커니즘 자체는 유지 — 일관성 + 위해 예외로 뺄 근거는 약함). 자주 재계산되는 State에 물리면 Connect/ + Disconnect churn이 숨은 비용이 된다는 캐비엇도 추가. +- **Observer의 `:Subscribe()`/`:Unsubscribe()`** — children 배열에 안 + 붙는 "전역/독립" Observer(디버깅용으로 Store에 직접 걸어 print하는 + 흔한 패턴, `RunService:IsStudio()` 가드 + BooleanValue 토글)를 위한 + 명시적 라이프사이클 경로. 이건 새 설계가 아니라 PA님 코드 교차검증 + 때 이미 예고해둔 확장 지점("GC만으로 부족하면 명시적 dispose 경로 + 추가 가능")을 실제로 채운 것. liveness 체크는 `self.Subscribed` 필드 + 우선, `self.Connection.Connected` 폴백(필드 접근이 weak table 조회보다 + 쌈). 내부 레지스트리는 자동 케이스의 weak table과 별개로 강참조 + (weak면 "살려둔다"는 목적이 무의미해짐). 둘 다 idempotent, `:Unsubscribe()`는 + 자동 케이스 조기 해제에도 재사용. + +## 2026-08-06 세 번째 세션 — 문서 사이트 구조, 프레임워크 정직 비교, Source가 State를 만족하는 서브타입 재구성 + +같은 날 이어진 세 번째 세션. 셋으로 갈리는 주제라 순서대로 요약 — **다음 +세션이 새로 알아야 할 건 4번(Source/State 재구성)뿐**, 1~3번은 배경/참고용. + +**1. 문서 사이트 구조 확정 — 초심자/api/심화 3축 + `quadnomicon` 4번째 축.** +`research/documentation-plan.md` 0번 항목에 전부 반영. 초심자는 "core loop +완주에 필요한 최소 집합만, 백엔드 구체적(quad-roblox), quad-base/roblox +분리 노출 안 함, 다른 백엔드 생기면 그때 별도 트랙 추가"로 스코프 확정. +api는 간략 설명 + 심화로 "더 알아보기" 링크 패턴. `quadnomicon`(Rustonomicon +패러디, 사용자 확정 이름)은 quad 사용자가 아니라 "비슷한 프레임워크를 +설계/포크하려는 엔지니어"용 4번째 축 — Fusion/Vide 내부 비교 같은 콘텐츠가 +여기 해당, 세션 정정 이력 같은 순수 내부 리서치 원자료는 이 축에도 안 +들어가고 그냥 `.claude/` 내부에만 영구히 남음(RFC 저장소 성격). GC처럼 +quad 밖 배경지식이 깊은 주제는 새 티어 없이 "quad 활용법만 심화에, 일반 +개념은 외부 링크"로 처리. 실제 콘텐츠 분류(초심자 core loop 목차 초안, +파일별 분류, 심화 에세이 후보 15개)는 `research/documentation-content-map.md`. + +**2. quad vs Fusion/Vide/react-lua 정직 비교 — 3개 에이전트가 실제 +소스/웹 리서치로 검증.** `research/framework-comparison-findings.md`. +요지: quad의 Slot 단일 마운트 가드·열린 우선순위 축·명시적 의존성·다이아몬드 +dedup은 실 소스 근거로 확인된 진짜 강점(Fusion `Children.luau`의 TODO +주석, Vide `mount.luau`의 중복 체크 부재, Vide 자신이 `todo.md`에 미해결로 +남긴 diamond 문제 등). 반대로 use-after-destroy 검증 안전망 부재·`:With` +정적 의존성·Store dot-access 할당 비용 3가지는 고칠 만한 약점으로 식별(3번은 +이후 4번 논의로 이미 해소됨). GC-native 리스크·암묵 추적 대비 보일러플레이트· +Tween 비합성성·"지금 트리 상태" 파악 어려움은 의도된 트레이드오프로 "고친다" +개념 자체가 안 맞음. 성숙도 격차(quad 구현 0줄)는 정직하게 명시. + +**3. 위 1·2번에서 파생된 실행 항목**: 아직 결정 아님, `research/ +documentation-plan.md`/`framework-comparison-findings.md`의 "다음 단계"에 +남겨둔 사용자 판단 대기 항목들(문서화 착수 시점, 프레임워크 비교에서 나온 +개선안 반영 여부/시점) 그대로 참고. + +**4. Source가 State를 구조적으로 만족 — Store/State/Source 핵심 메커니즘 +재구성, base 문서 전부 반영 완료.** `store.key`의 타입 문제(레코드 타입 +`{key: State}`가 읽기/쓰기 비대칭이라 Luau 타이핑이 안 맞음)를 +풀다가 나온 더 근본적인 재구성: +- **`Source`가 구조적으로 `State`를 만족**(단방향 호환, Svelte + `Writable extends Readable`와 같은 모양) — `.value`/`:Get()`/ + `:With`/`:Compute` 전부 지원 위에 `:Set(value)`/`:Emit()` 추가. `:With`/ + `:Compute`는 Source에서도 항상 `State` 반환(구현은 metatable `__index` + 델리게이션, `Modifier`의 제네릭 `__index` 트릭과 같은 패턴이라 로직 + 중복 없음). 이 서브타입 관계는 `quad2-try`에서 기각한 컴포넌트/클래스 + OOP 상속과 다른 층위(프리미티브 타입 간 구조적 서브타이핑일 뿐, 사용자가 + 짜는 클래스 계층 구조가 아님)라 그 금지와 안 부딪힘. +- **`RefSource`(store 슬롯 전용 타입 중간안)와 그 전신인 `StoreSource` + 프록시(2026-08-04 세션에서 confirmed였던 것)는 전부 폐기.** Store는 + 이제 "이름 붙은 Source 모음, 그 이상 아님" — `store.key`는 Store 생성 + 시 이미 만들어둔 진짜 Source 객체를 그대로 반환(별도 wrapper 생성/캐싱 + 단계 자체가 사라짐, 이전에 검토한 "State를 weak table로 캐싱"보다도 + 쌈). v1이 타입 없던 시절 습관으로 모든 값을 Store에 몰아넣은 건 "당시엔 + 편해서"였지 지금 그대로 가져올 이유가 아니라는 게 사용자의 회고적 + 재평가 — 그 재검토가 이번 단순화로 이어짐. +- **`store.key = value`(`__newindex`) 폐기, `store.key:Set(value)`로 + 전환** — 이유 둘: (a) 레코드 타입 `{key: Source}`가 읽기/쓰기 + 둘 다 같은 타입이어야 Luau 타이핑이 깨끗한데 대입 문법을 유지하면 + 비대칭이 남음, (b) `=`는 관례상 "즉시 커밋되는 부작용 없는 쓰기"를 + 암시하는데 quad는 실제로 lazy(무효화 신호만 쏘고 재계산은 관측 시점에)라 + 대입 문법이 실제 동작과 정서적으로 안 맞음(사용자 논거). `Store:Emit(key)`도 + 같은 이유로 `source:Emit()`(key 인자 불필요)로 이동 — 같은 일 하는 + 두 번째 경로를 안 남긴다는 원칙과 일치. +- **검증 필요, M0 스파이크에 항목 추가됨(`ROADMAP.md`)**: Source의 + `:Compute` 시그니처가 자기 자신과 `State`를 동시 참조하는 제네릭 + 메소드라 Luau 솔버가 재귀 타입 조합에서 안 막히는지 확인 필요. 자기 + 참조 self 타이핑 자체는 흔하고 안전하나, `State`가 거꾸로 `Source`를 + 참조하는 **상호 재귀**는 Luau의 알려진 취약 패턴이라 피해야 함 — + `State`를 `Source` 참조 없이 독립적으로 먼저 정의하고 `Source`만 + 단방향으로 `State`를 참조하게 두면 이 위험을 피할 수 있어 보이나 + 확정 아님. 타입은 `&`(교차) 조합 대신 손으로 펼쳐 쓰는 쪽으로(사용자 + 선호, 솔버 안정성 우선) — 이건 런타임 구현 델리게이션과 다른 축이라 + 서로 안 부딪힘(타입은 펼치고 구현은 공유 가능). +- **반영된 파일**: `base/store-semantics.md`(신규 "Source가 State를 + 만족함" 절이 최종 소스), `base/bind-system-plan.md`(온톨로지·타입 추론 + 절 정정), `base/component-composition-plan.md`(`StoreSource`/타입 + 유니온 절 재작성), `ROADMAP.md`(M0 항목 추가), `research/ + documentation-content-map.md`/`.claude/README.md`(참조 갱신). 이름 + 자체(`Source`/`State`)는 여느 때처럼 "지금 할 일" 2번 용어 정리 + 라운드까지 가칭. + +## 2026-08-06 네 번째 세션 — M0 착수 직전 크리티컬 감사, `research/pre-implementation-audit.md` 신설 + +사용자 요청: "실 개발 시 모호하여 인터럽트될 수 있는 부분, 나중에 결정되면 +치명적일 것 같은 것, 지금 구조가 오버엔지니어링일 수 있어 보이며 더 나은 +대안이 있는 것"을 찾아 정리해달라는 요청. `.claude/base/` 전체(architecture/ +bind-system/store-semantics/module-lifecycle/component-composition/ +modifier/purity-and-effects/slot/lifecycle-pattern/quad-v1-architecture)와 +근접 `research/`(existing-instance-bind/tween/ui-shorthand) + `ROADMAP.md`를 +4개 클러스터로 나눠 서브에이전트 4개를 병렬로 돌려 "모호성/지연결정리스크/ +단순화후보" 세 렌즈로 재감사, 결과를 `research/pre-implementation-audit.md` +로 종합. `.claude/question.md`엔 이미 취합된 것(용어 재검토, M0 스파이크 +항목 자체 등)과 겹치지 않는 새 발견만 반영. + +**작업 도중 발견한 부수 이슈**: 워크트리 생성 시점과 main 체크아웃의 +미커밋 변경사항(세 번째 세션 결과물)이 어긋나 있었음 — 워크트리는 커밋 +시점 기준으로 fork되므로 아직 커밋 안 된 변경은 안 딸려옴. 사용자가 중간에 +main에 커밋을 완료해줘서(`4b839b0`) 워크트리를 새로 만들어 재동기화함 — +**앞으로 워크트리에서 최신 설계를 감사/참조해야 하는 작업을 시작하기 전엔, +main에 미커밋 변경이 있는지(`git status`) 먼저 확인하고 필요하면 커밋을 +요청하거나 파일을 직접 동기화할 것.** + +**핵심 발견 요약** (전체 25개 항목은 `pre-implementation-audit.md` 참고, +우선순위1만 발췌): + +- **Tween.luau가 문서 전체에서 "범용 store-bind 캐치올 핸들러"의 유일한 + 구체 예시로 서술됨** — 애니메이션 없는 일반 반응형 프로퍼티 바인딩이 + 실제로 Tween 파일을 거쳐가는지, 별도 범용 핸들러가 필요한지 확정 안 됨. + 가장 구조적인 발견 — 직접 `bind-system-plan.md` 67-79행을 재확인해 + agent 발견을 검증함. +- `props.Modifier`/`props.Ref` forwarding 관례가 Lua 배열 리터럴의 + nil-hole 함정(caller가 안 넘기면 `{nil, ref, child}`에서 뒤 항목까지 + 무시될 수 있음)에 그대로 노출 — M0 스파이크 코드에 이 케이스를 반드시 + 포함시켜야 함. +- `canExecute`/`Connected`의 실제 구현 방식이 미확정인 채 코어 전역 + (Slot/Observer/store-bind retract)에 이미 재사용 확정돼 있음. +- `LifetimeHandle` 인터페이스가 M8에 배치돼 있지만 M4/M6이 이미 그 인터 + 페이스를 전제로 서술돼 있음 — 로드맵 순서 역전, `ROADMAP.md` 조정 필요. +- retract 시 "이전에 실제로 매치됐던 핸들러" 추적 책임, 우선순위 스캔 + 동률/매치실패 처리, provider 미주입 상태 dispatch 호출 시 동작 — + 전부 M2(Dispatch 엔진) 착수 전 한 번에 결정하면 효율적인 것들. +- Slot의 `add`/`remove`/`clear` CRUD 의미론 자체가 정의 안 돼 있음, + "재마운트 시 throw"도 추적 대상(개별 element vs Slot 컨테이너)이 + 뭉뚱그려 서술됨 — 둘 다 M6 착수 전 확정 필요. + +**단순화 후보로 지적된 것 중 사용자 판단 필요**: `:Compute(fn)`의 +`previous` 두 번째 인자 — quad의 "함수 자체가 재호출되는" 모델상 클로저 +업밸류로 이미 되는 걸 별도 API 표면으로 만든 것일 수 있음(근거 불명). + +**문서모순으로 남겨둔 것**: `State`는 "UB, 가능하면 타입으로 +차단"인데 Ref/Slot이 Modifier 필드에 들어가는 건 "UB, 방어 로직 없음" — +같은 문서(`modifier-plan.md`) 안에서 정반대 원칙이 근거 설명 없이 나란히 +적용됨. 판단이 필요해 고치지 않고 감사 문서에만 남김. + +**부수적으로 직접 고친 stale 문서(판단 불필요한 순수 동기화)**: `base/ +architecture.md` 소스트리 주석 두 곳 — `Store.luau`가 여전히 옛 `__newindex` +모델을 언급, `Ref.luau`가 여전히 "CreatedRef 메커니즘 자체"로만 서술(Ref +일반화 결정 반영 안 됨). 온톨로지 요약 절 stale은 같은 세션 도중 커밋 +`4b839b0`에서 이미 독립적으로 고쳐져 있었음을 확인 — 재작업 없이 스킵. + +**다음 세션이 할 일**: M0 착수 전에 `pre-implementation-audit.md` 우선순위1 +항목(특히 위 6개)부터 확인 — "지금 할 일" 1번 참고. `.claude/question.md` +2번에 사용자 판단이 필요한 항목 요약이 반영돼 있음. + +## 2026-08-07 세션 — `:With`도 새 State 노드로 확정 + +사용자 질문에서 시작: `:With(...)`가 문서상 가변인자 표기이긴 한데, 체이닝 +(`:With(a):With(b):With(c)`)할 때마다 실제로 새 State 노드를 만드는 게 +맞는지, 아니면 값 없이 의존성 목록만 clone-then-append로 누적하는 가벼운 +빌더로 만들어 "노드가 With 호출마다 하나씩 증가하는" 낭비를 피해야 하는지가 +불명확했음. 처음엔 "빌더" 대안(진짜 State가 아닌 clone 기반 누적 객체)을 +검토했으나, 사용자가 두 가지 반례를 직접 제시하며 기각함: + +1. **디버그 그래프가 꼬임** — `quad-debug`의 핵심 UX가 "무엇이 무엇에 + 연결됐는가" 그래프인데, With/Compute가 전부 실제 노드면 코드 호출 + 체인이 그래프 엣지와 1:1 대응되지만, 빌더로 만들면 그래프 툴이 가상의 + 분기 지점을 따로 합성해야 함. +2. **clone 기반 구현이 Compute 노드 위에서 실제로 깨짐** — `c = + a:Compute(f)` 뒤에 `w = c:With(b)`를 clone으로 구현하면 `c`의 캐시 + 슬롯까지 그대로 복사되어 `w`가 `c`와 별개의 독립 캐시를 갖게 되고, + `c`/`w`가 각자 관측되면 `f`가 두 번 따로 실행됨 — `bind-system-plan.md`가 + 이미 기각해둔 "State 체인 플래튼"과 정확히 같은 실패 모드. + +**결정**: `:With`는 호출마다 self+인자들을 레퍼런스로 구독하는 새 State +노드를 만든다(clone 아님, 계산 없는 pass-through 노드). 원래 문제 제기 +(노드 남발)는 노드를 없애는 대신 `:With(...)`를 진짜 가변인자로 만들어 +해소 — `:With(a, b, c)` 한 번으로 노드 1개(구독 3개)를 만들 수 있고, +디버그 그래프도 이쪽이 더 단순해 권장 관례로 삼음. 체이닝 스타일도 여전히 +가능하나 그건 저렴한 노드가 늘어나는 것뿐이라 문제 삼을 비용이 아님. +`base/bind-system-plan.md`의 "왜 State 체인을 Modifier처럼 플래튼하지 +않는가" 절 바로 뒤에 새 소절로 반영 완료. 다른 문서(`question.md`/ +`ROADMAP.md`/`modifier-plan.md`)엔 이 결정과 모순되거나 갱신이 필요한 +서술 없음을 확인함(감사 완료) — `modifier-plan.md`가 이미 "State가 +`:With`/`:Compute`마다 새 노드를 할당"이라고 서술해뒀던 것과도 정합적. + +다음 세션이 할 일은 안 바뀜(위 2026-08-06 네 번째 세션 절 참고) — 이 +결정은 M0 스파이크(Store/State propagation 검증)가 실제로 짜볼 때 +참고할 구체 스펙이 하나 더 생긴 것뿐. + +## 2026-08-07 두 번째 세션 — Modifier `:Apply(factory)` 팩토리 체이닝 추가 + +사용자 제안: `Boldify(mod) -> mod`처럼 어떤 modifier든 받아 적절히 변형해 +돌려주는 재사용 가능한 "팩토리 함수"(커링 지원, `Boldify(10)(mod) -> mod`)를 +`mod:Apply(Boldify(10)):Apply(Italicify)`처럼 기존 필드 setter 체이닝과 +같은 fluent 문법으로 끼워 넣을 수 있게 하자는 것 — Jetpack Compose의 커스텀 +`Modifier` 확장 함수 패턴과 같은 효용(모듈화된 스타일 프리셋 재사용)을 +Luau엔 확장 함수 문법이 없으니 콤비네이터로 흉내낸 아이디어. 채택 확정, +`base/modifier-plan.md` 8번 절에 반영 — `:Apply`는 `function(self, factory) +return factory(self) end`이 전부인 얇은 sugar(팩토리 자신이 이미 clone된 +새 Modifier를 반환하므로 Apply 자체는 clone 불필요), 기존 3번(immutable +clone 체이닝)/4번(제네릭 `__index`) 결정 위에 그대로 얹힘. 구현 시 주의점 +하나만 새로 생김: `Apply`는 제네릭 `__index`가 필드 setter를 즉석 합성하기 +전에 먼저 확인해야 하는 고정 메소드 이름이라, **Modifier 필드 이름으로는 +예약됨**(실 스타일 프로퍼티와 겹칠 일은 거의 없어 보이나 문서화 필요). +`ROADMAP.md` M7에 체크박스 추가 완료. 다음 세션이 새로 알아야 할 건 없음 — +M7 착수 시 `modifier-plan.md` 8번 참고하면 됨. + +## 2026-08-07 세 번째 세션 — Ref의 KV 핸들러 처리 vs phase 타이밍, `PreRef` 신설 + +**출발점**: Ref가 Modifier처럼 밖에서 처리되는 게 아니라 KV 핸들러 +(`process(inst,k,v)`)로 처리된다면, "생성 직후"/"자식 마운트 후" 두 +콜백 타이밍(특히 self(Instance)를 안 주는 이벤트가 Ref로 self를 얻는 +경우)을 단순 for-loop 디스패치만으로 어떻게 표현하는지가 출발 질문 — +길게 이어진 단일 스레드라 아래 요약만 읽으면 됨, 상세 근거는 각 base +문서에 이미 반영됨. + +**핵심 결론(전부 `base/bind-system-plan.md`에 반영 완료)**: +- **base 디스패치 드라이버는 props 순회를 "배열 파트(children/Ref) 먼저, + 해시 파트(프로퍼티/이벤트) 나중"으로 명시적으로 두 패스 계약화**한다 + — Luau 테이블이 실제로 이렇게 순회되는 걸 사용자가 직접 확인했지만, + 그 우연한 동작에 기대지 않고 base가 스스로 이 순서를 보장(다른 + 백엔드가 다른 자료구조를 쓸 수 있어서). M0 스파이크 검증 항목에 추가. +- **`CreatedRef`의 `{phase="created"|"mounted"}` 옵션은 폐기.** 두 패스 + 계약 덕에 "자식 마운트 전/후"는 그냥 배열 안에서 Ref를 다른 children + 보다 앞/뒤에 놓는 것만으로 공짜로 표현됨 — 옵션 문법 자체가 불필요. +- **`PreRef` 신설** — "프로퍼티/이벤트 세팅보다도 먼저"(Roblox의 + `ChildAdded`/`DescendantAdded`/`Changed`류가 setup 도중 동기 발화할 + 수 있어서 self-ref가 이벤트보다 먼저 채워져야 하는 케이스)만 담당하는 + 별도 nominal 타입. `Ref`를 그대로 재사용(런타임 중복 없음)하되 + Modifier 필드 값·Source/Store 값으로는 타입으로 아예 못 들어가게 + 막고, children 배열 안에서도 위치 무관하게 항상 최우선(호이스팅) — + base 드라이버가 두 패스 루프 앞에 `PreRef`만 골라 fire하는 좁은 + pre-pass를 하나 더 둠. +- **일반 `Ref`는 Modifier/Store 어디든 계속 자유롭게 들어감** — Store를 + 통해 나중에 도착하는 Ref는 그냥 도착한 순간 처리, 별도 phase 개념 불필요. +- **`:Wait()`는 PreRef에도 그대로 유효** — fire 자체는 동기적이지만 + 호출부가 `task.spawn`이 아니라 순수 `coroutine`일 수 있어 실제 + yield-resume이 필요한 경우가 있음. "채워졌는지 먼저 확인, 없으면 + `:Wait()`" 방어 관용구를 문서화 대상으로 명시. +- **콜백/대기자 실행 구현 디테일 추가**: 같은 배열 하나를 한 번의 + 일반화 `for`로 순회하며 `type(v)=="thread"`면 `coroutine.resume`+ + 슬롯 nil 처리(1회성), 함수면 그냥 호출(유지) — 새 등록은 `table.insert` + 로 끝, 성긴 배열이어도 압축 불필요. **[정정, 2026-08-07 열 번째 세션]** + "슬롯 nil 처리"는 틀림 — 사용자가 Luau REPL로 반례 제시, 실제로는 + `None`으로 소진해야 함(`#t`/`table.insert` 안전성 문제). `base/ + bind-system-plan.md` "왜 `nil`이 아니라 `None`인가" 절이 최신. +- v1의 `OnCreated` 특수 DI 키는 이식 안 함 — `Ref():Callback(fn)`으로 + 완전 대체. + +**역전된 이전 서술은 archive로 이동**: `CreatedRef`의 `phase` 옵션과 +"Ref는 특수 처리 없는 평범한 참가자"라는 원래 서술은 +`archive/ref-phase-option-reversed.md`로 옮기고 원 위치엔 짧은 포인터만 +남김(컨텍스트 비대화 방지 목적, `archive/store-source-proxy-reversed.md`와 +같은 패턴). `architecture.md` 소스트리 주석/`question.md`(PreRef를 +용어 재검토 대상에 추가)/`research/documentation-content-map.md`(stale +`{phase=...}` 예시 갱신)도 같이 동기화함. + +**아직 미해결, 다음 세션 주제로 예고됨**: `{ Override = nil, mod }`처럼 +인라인 키로 modifier가 주는 값을 명시적으로 "지우고" 싶어도 Lua +테이블 리터럴의 `키 = nil`은 키가 아예 없는 것과 구별이 안 돼서 안 +풀리는 문제 — `false`를 이벤트 disconnect 센티널로 쓴 선례처럼 `None` +(가칭) 프리미티브를 도입하는 방향만 `base/modifier-plan.md` "2-1"절에 +짧게 메모해두고 상세 설계는 다음 세션으로 미룸. + +## 2026-08-07 네 번째 세션 — `.claude/` 코퍼스 전반 정리(폴더 재편, 승격, 기각 분리) + +사용자가 코퍼스 전체를 훑고 "실제 코딩에 필요한가"를 기준으로 남길 것과 +분리할 것을 판단해 달라고 요청 — 여러 문서에 쌓인 역전 이력/quad +자체와 무관한 배경자료/이미 기각된 후보가 뒤섞여 있어 컨텍스트 크기와 +가독성 둘 다 해치고 있다는 문제의식. 아래 6가지를 처리, 전부 반영 완료: + +1. **`reference/` 폴더 신설** — `quad-v1-architecture.md`, + `comparison-fusion-vide.md`를 `base/`에서 이동. 항상 읽어야 하는 + 결정사항(`base/`)과, 다른 문서가 근거로 인용할 때만 열어보면 되는 + 온디맨드 스냅샷/비교자료(`reference/`)를 분리 — 전자는 "결정 완료", + 후자는 "결정이 아니라 결정의 근거"라는 차이. 전체 문서의 상호참조 + 경로도 전부 갱신함. +2. **`component-composition-plan.md`의 누적 역전 이력 트리밍** — + `StoreSource` 프록시 폐기 이력이 "원래 이랬다 → 이렇게 뒤집혔다"를 + 본문에서 장황하게 반복 서술하고 있었는데, 이미 `archive/ + store-source-proxy-reversed.md`에 원문·이유·비교표가 전부 보존돼 + 있으므로 본문은 최종 확정만 남기고 포인터로 압축. +3. **`ui-shorthand-plan.md`를 `research/`→`base/`로 승격, 재작성** — + (a) 이미지 라운드 트릭 `RoundSize`는 완전히 드롭, 근거는 + `archive/ui-shorthand-roundsize-dropped.md`로 분리(이 판단이 한 차례 + "Corner/PaddingAll/Scale 전체가 불필요하다"로 잘못 일반화됐다가 + 정정된 이력도 같이 보존). (b) 이름을 v1 그대로(`Corner`/`PaddingAll`/ + `Scale`)가 아니라 실제 Roblox Instance 이름과 맞춘 `UICorner`/ + `UIPadding`/`UIScale`로 확정 — v1식 짧은 이름은 Modifier 체이닝 + 메소드와 겹쳐 "진짜 UICorner 숏핸드인지 그냥 비슷한 이름의 부가 + Modifier인지" 구분이 안 된다는 사용자 지적 반영. (c) store-bind + 가능성 명시 — v1에서도 가능했던 기능이고, Tween처럼 무거운 API + 표면 없이 기존 per-instance weak-table 유틸(`base.perInstanceState`) + 재사용만으로 충분하다는 점을 추가. +4. **`additional-primitives-plan.md`를 4갈래로 분리**: 확정된 `Blocker`/ + `Effect`는 각각 새 `base/blocker-plan.md`/`base/effect-plan.md`로 + 승격(Blocker는 State와 같은 마일스톤에서 개발하기로 해서 + `store-semantics.md`에 교차 참조 추가, `ROADMAP.md` M3에도 체크박스 + 반영). 기각된 `Batch`(lexical block)와 `Context`(+대안이던 레이어드 + Store)는 각각 `archive/batch-rejected.md`/`archive/context-rejected.md`로 + 분리. `research/additional-primitives-plan.md`엔 아직 실제로 열려있는 + 것(키 기반 동적 컬렉션 재조정) 하나만 남김. **[같은 날 바로 정정]** + 처음엔 Blocker/Effect를 `base/additional-primitives.md` 한 파일로 + 합쳐 승격했으나, 사용자가 "State 볼 때 Effect까지 볼 필요는 없다, + 기존 프리미티브당 1파일 컨벤션(`modifier-plan.md`/`slot-plan.md`류)에 + 맞지 않는다"고 지적해 바로 두 파일로 재분리함 — Blocker는 + Store/State와 밀접해 교차 참조가 필요하지만 Effect는 완전히 독립된 + 요소라 애초에 같은 파일일 이유가 없었음. +5. **archive 제목 컨벤션을 둘로 분화** — 기존 `[역전됨]`(한 번 확정했다가 + 뒤집힌 것, `store-source-proxy-reversed.md`/`ref-phase-option-reversed.md`)과 + 새로 생긴 `[기각됨]`(확정한 적 없이 후보였다가 채택 안 된 것, + `batch-rejected.md`/`context-rejected.md`/`ui-shorthand-roundsize-dropped.md`)을 + 구분 — `README.md`의 `archive/` 폴더 기준 설명에 두 컨벤션 차이를 + 명시. +6. **`tween-plan.md` 보강** — `retract`가 Destroy 시엔 호출 안 된다는 + 사실을 상단 상태 요약에서도 짚도록 가시성 강화, `canExecute`(Destroy + 시 처리)와 `retract`(값 교체 시 처리)가 서로 다른 문제를 다룬다는 + 점을 quadnomicon급 문서화 숙제로 메모(지금은 상세 설명 안 하고 + 메모만). 트윈 옵션 값 모양(raw `TweenInfo` vs 이름 붙은 편의 + 필드+기본값) 논의를 새로 열어둠 — Luau가 named call을 지원 안 해서 + `TweenInfo.new(...)` 포지셔널 생성자가 읽기 어렵다는 문제의식, + 소견은 편의 필드 쪽이지만 확정 아님, 나중 논의 대상으로만 남김. + +## 2026-08-07 다섯 번째 세션 — Modifier 결합(`Override`)/읽기 접근자(`Peek`)/`isState` 확정, FuncSource 기각 사유 문서화 + +**출발점**: `:Apply`(4번째 세션 신설)처럼 Modifier에 더 있으면 좋을 게 +있는지 사용자가 제기 — `Merge`류 결합 유틸의 우선순위 문제, 그리고 +Modifier 자신이 자기 필드 값을 못 읽는 게 애매하다는 지적(예: +`Boldify`가 폰트별 굵기 보정을 하려면 현재 `Font` 필드를 읽어야 함). +같은 스레드에서 "Source가 항상 정해진 값만 담아야 하는 이유가 확정된 +건지, FuncSource(람다로 계산+self-emit하는 Source) 같은 건 왜 없는지"도 +같이 물어옴. + +**핵심 결론(전부 base 문서에 반영 완료)**: +- **`Modifier.Override(mod1, mod2, ...)`** — `component-composition-plan.md` + 3번 절에 2026-08-04부터 가칭 `Merge`로 이미 확정돼 있던 결합 유틸의 + 실제 동작을 확정하고 이름을 `Override`로 개명(중립적 "합침"이 아니라 + 명시적 "덮어쓰기"라 이름이 의미를 정직하게 반영해야 함). 뒤 인자가 + 필드 단위로 이김(기존 배열 flatten 규칙 재사용), 구현은 단순 필드별 + raw 교체 — setter가 이미 호출 시점에 함수/State를 즉시 처리해 저장하므로 + Modifier 필드는 항상 baked 값이라 특별한 분기 불필요. "baked 값 교체는 + 거기서 파생된 다른 필드에 소급 반영 안 됨"(Boldify가 FontWeight를 계산해 + 둔 뒤 Font가 Override로 바뀌어도 FontWeight는 예전 값 그대로)과 순서 + 의존성(`A:Override(B)` ≠ `B:Override(A)`) 둘 다 문서 경고 대상으로 확정. + **`Apply`로 전부 대체해 `Override`를 없애는 방안도 검토했으나 기각** — + 컴포넌트 경계(`props.Modifier`는 단일 named parameter라 배열 flatten이 + 안 닿음)라는 이미 확정된 실사용 니즈를 `Apply`만으로는 못 풀어서. + `base/modifier-plan.md` 9번 절. +- **`:Peek<>(key): T|State|nil`** — Modifier 필드를 확정(pull+recompute) + 하지 않고 raw 그대로 읽는 접근자. `Get`이 아니라 `Peek`인 이유는 이 + 프로젝트에서 `State:Get()`이 이미 "확정한다"는 의미로 굳어져 있어서 — + Modifier의 읽기는 정반대(State면 State 핸들 그대로) 동작이라 같은 + 동사를 못 씀. 반환 타입을 `T`로 자동 확정하지 않고 union 그대로 + 노출하는 이유는 4-1번 절 함수형 setter의 `old` 인자와 같은 원칙("현재 + 저장된 그대로 넘김") 재사용 — 자동 확정하면 타입에 안 드러나는 채로 + 반응성이 조용히 끊김. `.RealValue` 같은 별도 인덱싱 표면은 기각(이미 + `__index`가 setter 합성용으로 예약돼 있어 표면이 겹침). +- **`isState(x): boolean`** — `Peek`의 raw union을 분기하려면 필요. + Source가 State를 구조적으로 만족하므로 이거 하나로 Source도 같이 + 잡힘(`isSource` 불필요). duck-typing 대신 weak-key 레지스트리로 구현 + (rbvm 네임스페이스 추적과 같은 패턴 재사용) — `Peek`가 돌려주는 `T`가 + 임의의 테이블/userdata일 수 있어 duck-typing은 false positive나 일부 + Roblox userdata의 인덱싱 에러(pcall 필요)로 이어질 위험이 있음. 이 + 판별 로직 자체는 새 개념이 아니라 4-1번 setter가 이미 내부적으로 + 해야 했던 "필드가 State냐 plain이냐" 판별을 public 유틸로 승격한 것. + `base/bind-system-plan.md`의 `isState` 절. +- **FuncSource(값이 람다로 계산되고 self-emit하는 Source) 기각** — 사용자가 + 스스로 기각 논리를 제시했고("이미 Compute가 커버함"), 검증 결과 이미 + 확정된 두 원칙에서 그대로 연역됨: (1) Source는 "시작점"이라 다른 + 반응형 값에 자동 연결 안 됨(2026-08-04 6차 라운드, "Store가 Store를 + 담지 않는다" 확정 때 나온 원칙) — FuncSource는 다른 반응형 값에 종속된 + 계산이면서 겉으로는 origin인 척하는 것이라 이 원칙과 직접 충돌. + (2) `:With`가 clone 빌더가 아니라 진짜 노드여야 하는 이유(2026-08-07 + 세 번째 세션)가 "의존성이 구조적으로 안 보이면 디버그 그래프가 + 깨진다"였는데, FuncSource의 람다가 클로저로 캡쳐한 의존성은 정확히 + 그 문제를 재현함. 실제로 커버 안 되는 유스케이스도 없음 — "다른 + 반응형 값에서 계산"은 `Compute`, "clone 불가능한 값을 밖에서 바꾸고 + 알림"은 원천 Source+`Emit`으로 이미 전부 커버됨. 새 결정이 아니라 + 기존 확정 사항의 논리적 귀결이라 별도 base 절 신설 없이 여기 세션 + 요약으로만 기록(quadnomicon 소재로 재사용 가능하도록). + +**같은 세션 바로 후속 — 문서화 톤 보강(사용자 강조)**: `Override`는 범용 +조합 도구가 아니라 `Frame{mod1, mod2}`의 컴포넌트 경계판(단일 named +parameter 슬롯에 독립적으로 만들어진 값 두 개 이상을 넣어야 하는 특수 +상황)으로 좁게 문서화할 것 — "특정 modifier를 계속 바꿔나간다"는 요구는 +항상 `Apply` + 커링/일급 함수 전달을 기본 관용구로 유도. `Apply` 자체도 +`factory(self)` 호출 sugar 그 이상이 아니라는 걸 명시 — `factory`가 +`Peek`한 값이 기대와 다르면 `error`를 던지든 뭘 하든 전부 `factory` +저작자 책임, `Apply`가 검증/보장을 대신 해준다고 오해하면 안 됨. 둘 다 +`base/modifier-plan.md` 8/9번 절에 반영 완료. + +**같은 세션 두 번째 후속 — `Apply` vs `Override` 성능 기준 확정.** +"무거운 Modifier를 대량 생성할 때 `Apply`의 clone 비용이 누적되지 +않냐"는 우려에서 두 방안 검토 후 결론: **`Apply`를 mutable로 바꾸는 +방안은 기각**(3번 절 immutable 확정 이유 — 형제 서브트리 오염 방지 — +가 clone 비용 절감보다 우선순위 높음, 재확인). 대신 **판단 기준을 +"이질적/동질적 프로퍼티"가 아니라 "필드 간 계산 의존성 유무"로 +명확화** — 한쪽이 `Peek`으로 다른 쪽의 baked 값을 읽어 반영해야 하면 +이질적으로 보여도 `Apply`, 서로 완전히 독립이면 동질적으로 보여도 +`Override` 가능. 계산 의존성 없는 재사용 조각(배경/텍스트/레이아웃처럼 +서로 다른 서브시스템이 한 번만 만드는 값)은 모듈 상수로 만들어두고 +인스턴스마다 `Override`로 결합하는 게 실제 최적화 패턴 — 단 이건 +"`Override`가 내부적으로 캐싱해준다"가 아니라 사용자가 값을 재사용하는 +평범한 패턴일 뿐, 라이브러리에 새 캐싱 레이어가 생기는 게 아님을 +문서에 명시하기로 함. `base/modifier-plan.md` 9-1번 절. + +**같은 세션 세 번째 후속 — "`Apply` 경계에서만 clone, 안쪽은 mutable" +절충안도 검토 후 기각.** clone 횟수를 체인 길이가 아니라 `Apply` 호출당 +1번으로 줄이는 절충을 사용자가 직접 제시했으나, `Apply`를 거치지 않고 +setter를 단발로 직접 호출하는 흔한 경로는 여전히 mutable이라 공유 +레퍼런스가 그대로 오염될 수 있음(서브트리에서 폰트 두께만 바꿔도 터짐) +— "어디서 터지느냐만 달라지는" 비일관적 절충이라 실익 없다고 판단해 +기각. 전부 clone하는 현재 방식 유지 확정. `base/modifier-plan.md` +9-1번 (a-1) 절. + +**같은 세션 네 번째 후속(당시 CLAUDE.md에 미기록 — 2026-08-07 여섯 번째 +세션에서 뒤늦게 발견/보강) — `Override`가 서브타입 관계인 Modifier끼리 +섞일 때의 타입 시그니처는 미검증으로 열어둠.** `FrameModifier`가 +`GuiObjectModifier`의 서브타입이어야 자연스러운데, 필드 setter 메소드의 +리턴 타입이 각자 자기 자신이라(`self`) 단순 구조적 서브타이핑만으로 +`Modifier.Override(guiObjectMod, frameMod)`류가 통과하는지 추론만으로는 +결론 못 냄 — 후보안(메소드 필드는 `any`로 뭉개고 데이터 필드만 구조적 +체크)을 실 Luau로 검증 필요, 안 되면 `Override(...: any): any`로 +느슨하게 열고 이 항목으로 되돌아오는 걸 fallback으로 남김. +`base/modifier-plan.md` 9-2번, `ROADMAP.md` M7에 체크박스 반영 완료. + +**다음 세션이 할 일**: 안 바뀜(위 2026-08-06 네 번째 세션 절 참고, +`ROADMAP.md` M0부터) — 이번 세션도 순수 설계 확정이라 M0 착수 우선순위 +자체는 그대로. + +**미해결로 남긴 것 — 임의로 결론내지 않음**: Effect가 `state:Effect()` +형태로 Observer를 확장하는 변형인지, 완전히 독립된 free function인지가 +불명확함(사용자가 "확인 필요, 아니라면 논의해야 할 상태로 남겨두라"고 +명시). 관련 하위 질문으로 `state:Observer(fn)`가 생성 시 `fn`을 즉시 +1회 실행하는지도 문서 어디에도 명시돼 있지 않음이 이번에 드러남(Effect는 +"즉시 1회 실행"이 스펙에 명시돼 있어 이 부분만 보면 둘이 겹쳐 보임). +`base/effect-plan.md`의 "미해결" 절과 `.claude/question.md` +0번에 반영 — 구현 착수(M3~M4 전후) 전에 반드시 재확인할 것. + +**다음 세션이 할 일**: 안 바뀜(위 2026-08-06 네 번째 세션 절 "다음 세션이 +할 일" 참고, `ROADMAP.md` M0부터). 이번 세션은 순수 문서 정리라 설계 +결정 자체는 늘지 않았음 — 단, M3 체크리스트에 `Blocker.luau` 항목이 +하나 추가된 것과, 위 Effect/Observer 미해결 항목은 M3~M4 착수 전에 +확인해야 함. + +## 2026-08-07 여섯 번째 세션 — Ref/PreRef 메소드 API 확정, 파일 분리, Tween GC 저장 구조 확인, Effect/Observer 관계 해소 + +사용자가 메모 형태로 두 가지를 던짐: (1) Tween 인스턴스를 per-instance +저장소에 담는 구조가 실제로 GC-안전한지, (2) Ref가 이제 충분히 완결된 +프리미티브이니 PreRef와 파일을 분리하고, `:Set`/`:Callback`/`:Wait` +세 메소드로 API를 굳히자는 제안(전부 mutation 패턴이라 자기 자신을 +반환). 둘 다 검증 후 반영 완료: + +- **Tween per-instance 저장소는 이미 확정된 구조 그대로 GC-안전함** — + `inst`로 weak-keyed된 바깥 릴레이션 안에 `k`별 안쪽 릴레이션이 중첩된 + 모양이라(`base.perInstanceState(inst)`), `inst`가 죽으면 중첩된 Tween + 인스턴스 릴레이션도 별도 정리 없이 같이 GC됨 — 새 결정 아니라 기존 + 설계(`bind-system-plan.md` "핸들러 내부 상태 저장" 절)의 확인, "왜 + GC-안전한가" 설명만 명시적으로 추가. +- **Ref API가 `.Value`(읽기 전용) + `:Set(value)`/`:Callback(fn)`/ + `:Wait(thread?)`(전부 self 반환)로 확정.** self-반환 덕에 + `if ref.Value then ref.Value else ref:Wait().Value` 관용구가 성립 — + 이걸 성립시키려고 `:Set()`이 `coroutine.resume`할 때 넘기는 인자를 + 기존 문서(세 번째 세션 원안)의 `value`에서 **`self`**로 정정함(안 + 그러면 `:Wait()`의 yield 리턴값에 `.Value`를 체이닝할 방법이 없었음). + `:Wait(thread?)`의 `thread` 인자는 생략 시 `coroutine.running()`을 + 캡처해 진짜로 yield하고, 명시적으로 넘기면 그 thread를 등록만 하고 + yield 없이 즉시 `self` 반환(코루틴 역학상 남의 thread를 여기서 대신 + 정지시킬 수 없어서) — 사용자가 직접 관리하는 스케줄러가 이미 어딘가서 + 정지시켜 둔 thread를 등록만 해두고 호출부는 안 블록되고 싶은 유스케이스. + 콜백은 여전히 raw 값을 받음(Ref 자신이 아니라). +- **파일 분리**: `Ref`는 그 자체로 완결된 프리미티브, `PreRef`도 "children + 배열 전용, 위치 무관 호이스팅"이라는 특이한 제약을 가진 별개 + 프리미티브라 기존 1프리미티브-1파일 컨벤션(Blocker/Effect 분리와 + 같은 이유)을 따라 `Ref.luau`/`PreRef.luau`로 쪼갬 — 런타임은 여전히 + 공유(`PreRef`가 `Ref`를 재사용, 브랜드 태그만 다름), `base/architecture.md` + 소스트리에 반영 완료. +- 전부 `base/bind-system-plan.md`(Ref/PreRef 절)와 `research/tween-plan.md`에 + 반영 완료. `.claude/question.md`엔 이미 반영돼 있던 "Ref 이름 자체는 + 용어 정리 대상" 항목과 모순 없음(이번 세션은 메소드 이름만 확정, Ref라는 + 타입 이름 자체는 여전히 가칭). + +**같은 세션 후반 — `.claude/question.md` 0번의 마지막 미해결 항목(Effect가 +`state:Effect()`인지 자유 함수인지) 해소.** 사용자가 직접 "정해볼까" 하고 +제기해 라이브로 논의, 다음으로 확정(전부 `base/effect-plan.md`/ +`base/bind-system-plan.md`에 반영): + +- **`state:Observer(fn)`는 등록 즉시 1회 실행되는 것으로 확정** — 근거: + (1) 이미 채워진 State를 나중에 구독하면 반영 연산이 아예 한 번도 안 + 일어나는 초기화-순서 디버깅 문제, (2) 초회 실행을 안 해야 할 구체적 + 근거가 약함, (3) 이러면 Observer 하나로 "초기값 적용"과 "이후 변경 + 반영"이 같은 코드 경로로 통일됨(store-bind 프로퍼티 핸들러가 최초 + 적용용 코드를 별도로 안 짜도 됨). +- **`Effect(fn, state?) -> EffectHandle`로 확정** — `state` 생략 시 기존 + 스펙 그대로(설치 1회 + leaf 죽을 때 확정 정리, 재실행 없음). `state` + 지정 시 **내부적으로 `state:Observer(...)`를 조합** — Observer가 이제 + 즉시 1회 실행되므로 그 첫 실행이 설치를 겸하고, 이후 무효화마다 + 직전 cleanup 호출 후 `fn` 재호출, leaf 사망 시 마지막 cleanup 1회 — + React `useEffect(fn, [dep])`와 동형. 다수 의존성은 `:With(...)`로 먼저 + 하나의 State로 묶어서 넘기는 쪽으로 확정(React식 별도 deps 배열 + 안 만듦 — 같은 일 하는 두 번째 경로 방지 원칙). Effect는 여전히 + 자유 함수(메소드 아님) — `state` 없이도 성립하는 유스케이스가 있고, + 있어도 leaf 생명주기 바인딩을 `state`가 소유하지 않아서. +- **예전에 기각했던 "Observer에 cleanup 반환 계약 추가"와 안 부딪힘** — + 그때 기각한 건 "Observer 자체에 이 복잡도를 넣지 말자"였지 패턴 자체가 + 무용하다는 게 아니었음. Effect가 opt-in 상위 계층으로 이 패턴을 제공하는 + 지금 구조가 그 기각과 정확히 양립함. +- **`fn`을 커링 스타일(팩토리가 실제 fn을 만들어 반환)로 짜는 것도 Effect/ + Observer 둘 다 모듈화 관용구로 권장** — `Modifier`의 `Boldify(10)` 커링과 + 같은 결. +- **백로그로만 기록, 결정 안 함**: `state:Apply(...)`처럼 여러 개를 커링으로 + 받아 `:With`/`:Compute` 등록을 자동화하는 조합기 아이디어(사용자 제안, + `Modifier:Apply`의 State판 대응물) — `base/bind-system-plan.md`에 백로그 + 절로만 남김, 시그니처/필요성 미검증. **(2026-08-07 일곱 번째 세션에서 + 이 방향 자체가 기각되고 훨씬 단순한 형태로 확정됨 — 아래 참고.)** +- 이걸로 `question.md` 0번(추가 프리미티브 논의)의 열린 항목은 "키 기반 + 동적 컬렉션 재조정" 하나만 남음. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 이미 +설계된 것의 세부 마무리라 M0 착수 우선순위 자체는 그대로. + +## 2026-08-07 일곱 번째 세션 — `:Compute` 커링, `state:Apply` 확정(백로그안 기각), Effect `:Subscribe`/`:Unsubscribe` 신설, 이중 바인딩 금지 + +짧은 대화형 세션, 네 가지를 순서대로 처리 — 전부 `base/bind-system-plan.md`/ +`base/effect-plan.md`/`ROADMAP.md`/`question.md`에 반영 완료: + +1. **`:Compute(fn)`에도 커링 권장 노트 추가.** 여섯 번째 세션에서 Observer/ + Effect의 `fn`에만 문서화됐던 "팩토리가 실제 `fn`을 만들어 반환하는 + 커링 스타일 권장"이 `:Compute`엔 빠져 있었음 — 같은 결이라 자연스럽게 + 확장, `bind-system-plan.md` "`:With`+`:Compute`" 절에 추가. +2. **`state:Apply(factory)` 확정 — 원래 백로그였던 "`:With`/`:Compute` + 등록을 커링으로 자동화하는 조합기" 방향은 기각.** 사용자가 재확인한 + 실제 의도는 훨씬 단순함: `Modifier:Apply`와 똑같이 `factory(self)`를 + 체이닝 문법으로 부르는 순수 설탕(`function(self, factory) return + factory(self) end`) — `fnb(c,d)(fn(a,b)(state))`처럼 팩토리를 안에서 + 밖으로 겹쳐 읽어야 하는 중첩을 `state:With(a,b):Compute(fn(a,b)) + :Apply(fnb(c,d))`로 펴는 게 유일한 목적. 구현 비용 거의 0(State는 + Modifier와 달리 제네릭 `__index` 필드 setter 합성이 없어 이름 예약 + 충돌도 없음), 타입은 `factory: (State) -> U): U`로 Modifier보다 + 더 열어둠(팩토리가 State 밖 plain 값을 반환해 반응형 그래프를 벗어나는 + 것도 허용). Source는 기존 `:With`/`:Compute` 델리게이션에 얹혀 자동 + 포함. `bind-system-plan.md` "`state:Apply(factory)`" 절, 구체 전/후 + 코드 예시까지 반영. 부수적으로 같은 헤더 아래 잘못 걸려 있던 Observer + `:Subscribe`/`:Unsubscribe` 내용(무관한 주제)을 별도 절로 분리하는 + 문서 버그도 수정. +3. **`EffectHandle:Subscribe()`/`:Unsubscribe()` 신설.** 지금까지 Effect의 + 유일한 생애주기 경로는 children 배열 leaf 부착뿐이라, leaf 없이 쓰는 + 모듈/스크립트 레벨 사이드 이펙트(백그라운드 시스템 등)엔 반환된 + `EffectHandle`이 막다른 길이었음 — Observer가 이미 가진 `:Subscribe`/ + `:Unsubscribe`와 같은 결로 확정. **핵심 주의점**: Effect의 + `:Unsubscribe()`는 Observer의 것을 그냥 위임하면 안 됨 — Observer의 + 계약은 "미래 재실행만 끊는다"로 충분하지만, Effect의 계약은 "생애주기가 + 끝나는 시점에 마지막 cleanup이 정확히 1회 호출된다"이고 leaf 사망은 + 그 "끝"의 신호 중 하나일 뿐이라, `:Unsubscribe()`도 동일하게 "지금 + 끝났다"는 신호로 취급해 마지막 cleanup을 트리거해야 계약이 일관됨(leaf + 가 살아있어도 마찬가지). idempotent 보장은 기존 `Subscribed` 필드 + liveness 체크 재사용으로 공짜. `base/effect-plan.md` 신규 절. +4. **Observer/Effect 이중 바인딩 금지 — `Bound`(가칭) 플래그로 즉시 + `error`.** 처음엔 "leaf 부착과 `:Subscribe()`를 동시에 써도 같은 + liveness 게이트를 공유하니 안전"이라고 적었으나, 사용자가 애초에 한 + 핸들은 라이프사이클 바인딩 경로를 하나만 가져야 한다고 정정 — 동시 + 바인딩은 UB로 확정하되, 판별 비용이 사실상 0(불리언 필드 하나)이라 + 조용한 오동작 대신 그 자리에서 `error`를 던지는 쪽으로 결정 + (엔지니어링 비용 대비 디버깅 이득이 명확). 두 진입점(`:Subscribe()` + 호출부, children 배열 leaf 부착부)이 똑같이 확인/설정하는 대칭적 게이트 + — 순서 무관. `bind-system-plan.md` "이중 바인딩 금지" 절 신설, + `effect-plan.md`의 3번 항목 서술은 이 규칙으로 대체(정정 표시 남김). + +**부수 정리**: `ROADMAP.md` M3에 `state:Apply`/Effect `:Subscribe`· +`:Unsubscribe`/이중 바인딩 금지 체크박스 추가. `question.md`에 `Bound` +이름을 용어 정리 대상(3순위)으로 추가. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 +설계 확정이라 M0 착수 우선순위 자체는 그대로. + +## 2026-08-07 여덟 번째 세션 — `None` 센티널 확정(인라인 필드 지우기), `NoneHandler`가 Tween store-bind와 같은 재귀 재디스패치임을 확인 + +세 번째 세션에서 "미확정"으로 메모만 남겨뒀던 `None` 센티널 +(`{ Override = nil, mod }`처럼 인라인 키로 modifier 값을 명시적으로 +지우고 싶어도 Lua 테이블의 `키 = nil`이 "키 없음"과 구별 안 되는 문제)을 +사용자가 "이거 결정할 게 진짜 있냐"고 다시 제기해 라이브로 짧게 논의, +확정까지 감. 전부 `base/modifier-plan.md`(2-1번)/`base/bind-system-plan.md` +(신규 절)/`base/ui-shorthand-plan.md`(신규 절)에 반영 완료: + +- **merge/setter 쪽은 아무것도 안 바뀜** — `None`은 raw 저장 계층의 그냥 + 평범한 실재값이라, 기존 merge 규칙("인라인 키 존재 시 무조건 우선", + `Override`의 "뒤 인자가 필드 단위로 이김")이 손댈 것 없이 그대로 작동함. + 처음엔 "merge 시점에 키를 지운다"는 새 분기가 필요하다고 잘못 생각했다가, + "값을 표현만 할 수 있으면 기존 규칙이 이미 다 해줌"이라는 걸로 정정. + 인라인 props 테이블 키(`{ TextColor3 = None, mod }`)와 Modifier setter + 인자(`mod:TextColor3(None)`) 둘 다 지원 — 메커니즘이 완전히 같아 구현 + 비용 거의 0("어차피 무료로 얻어지는거 아님?" — 사용자), 후자 덕에 + "특정 필드만 지우는 재사용 가능한 modifier 조각" 패턴도 공짜로 됨. + `:Peek()` 반환 타입도 `T | State | None | nil`로 확장(raw 계층에서 + `None`을 있는 그대로 돌려줌 — Peek은 확정 안 하고 그대로 넘긴다는 기존 + 9번 절 원칙 그대로). +- **실제 "지우기"는 디스패치 단계에서, 새 메커니즘 없이 풀림 — 핵심 + 발견.** 처음엔 "우선순위 최상단에서 값을 그냥 nop 처리"로 생각했다가, + 사용자가 "그럼 process에 특수 로직이 들어간다"고 지적하며 더 나은 안을 + 직접 제시: `NoneHandler`라는 평범한 pluggable 핸들러 하나를 추가 — + `isHandlable`이 `v == None`을 잡고, `process`가 `v`를 진짜 `nil`로 바꿔 + **`process(inst, k, nil)`을 재귀 호출**. 이게 바로 이미 확정돼 있던 Tween + store-bind 핸들러(`bind-system-plan.md` "확정된 디스패치 모델" 절, + `v`가 Store면 `realv`를 계산해 `process(inst,k,realv)`로 재귀)와 + **완전히 같은 패턴**이라는 걸 확인 — 새 아키텍처 개념이 하나도 안 늘어남. + base 드라이버 자체(`process(inst,k,v) -> getHandler(inst,k,v).process(...)`)는 + `None`을 전혀 모르는 순수 제네릭 그대로 유지, 개별 프로퍼티/이벤트/UI + shorthand 핸들러 시그니처도 `None`이 안 나옴(원래 있어야 했던 "`v`가 + `nil`인 경우" 처리를 재사용할 뿐). +- **`None`의 의미는 "리셋"이 아니라 "이 조합 단계에서 이 필드를 세팅 + 안 함"** — 실제로 `v=nil`을 받은 핸들러가 뭘 할지는 핸들러마다 다름(일반 + 프로퍼티는 사실상 그대로 두는 것과 다름없고, UICorner 숏핸드처럼 실제 + Instance를 만들어 붙이는 핸들러는 그 자식을 지움). 구체 사례로 + `ui-shorthand-plan.md`에 UICorner 절 신설 — `process(inst,k,nil)`이 + 만들어둔 `_quad_corner`류 자식을 직접 지움(이건 `retract`가 아니라 + `process` 자신의 로직 — `retract`는 "다른 핸들러가 키를 넘겨받는" 별개 + 시나리오 전용, 이미 확정돼 있던 원칙 재확인), 값이 자주 `nil`↔숫자로 + 토글되면 생성/제거 비용이 매번 든다는 캐비엇도 명시. +- **M2(디스패치 엔진) 착수 시 확인할 것 하나 새로 생김** — "이 키를 지금 + 누가 담당 중인가" bookkeeping이 바깥 순회 루프가 아니라 `process` 호출 + 자체 내부에서 갱신돼야 함. 안 그러면 값이 계속 `None`으로 유지되는 매 + 사이클마다 "1차 매치는 `NoneHandler`, 재귀 호출 뒤 실제 담당은 다른 + 핸들러"로 바깥 루프가 오판해 불필요한 `retract`가 반복 호출될 위험 — + `ROADMAP.md` M2에 반영, `pre-implementation-audit.md`의 "이전 매치 + 핸들러 추적" 항목과 같은 부류라 새 우선순위 등급 없이 거기 흡수. + +**부수 정리**: `question.md`에서 "미확정"이던 `None` 항목을 해소로 +제거하고, 이름 자체(`None`/`NoneHandler`)만 다른 가칭들과 같이 용어 +정리 대상(3순위)으로 새로 추가. `ROADMAP.md` M7 체크박스를 "확정 완료"로 +갱신. + +**같은 세션 후속 — `Dispatch` 함수 네이밍 정리, `canExecute` 시그니처 정정, +Tag/Attribute 전용 문서 신설.** None 논의를 파고들다 디스패치 엔진 자체의 +용어가 여러 군데서 흔들리고 있다는 게 드러나 바로 이어서 정리함. 전부 +`base/bind-system-plan.md`/`base/lifecycle-pattern.md`/`base/tag-plan.md` +(신규)/`base/attribute-plan.md`(신규)/`ROADMAP.md`에 반영 완료: + +- **제 실수 정정 — `canExecute`와 `isHandlable`은 다른 개념** (전체 경위는 + `archive/agent-mistake.md` 1번으로 옮김) — 결론만: `NoneHandler`가 + 구현해야 하는 건 `isHandlable`이지 `canExecute`가 아님. +- **`Dispatch.getHandler`/`Dispatch.process`/`Dispatch.addHandler`/ + `Dispatch.drive`로 이름 공식화.** 원래 "확정된 디스패치 모델" 절은 + "스캔+실행"과 "매치된 핸들러 자신의 처리"를 둘 다 그냥 `process`라고 + 불러 이름이 겹쳤던 게 혼동의 원인이었음 — `Dispatch.getHandler(inst,k,v): + Handler?`(순수 스캔)와 `Dispatch.process(inst,k,v)`(오케스트레이터: + getHandler → 이전 담당자 다르면 그 `retract` → 새 핸들러의 `.process`)로 + 분리, Handler 자신의 필드는 계속 `process`/`retract`(이미 확정된 이름, + 재검토 대상 아님) — 겹침은 소유자 표기(`Dispatch.process` vs + `handler.process`)로 해소, 새 이름 발명 안 함. **`Dispatch.addHandler(handler)`** + 도 신설 — concrete Handler를 우선순위 레지스트리에 등록하는 것도 + 결국 quad-roblox가 `BaseModule`을 뮤테이션하는 시점에 해줘야 하는 + 일이라(기존 "base 유틸은 인터페이스, 백엔드가 주입" 패턴과 같은 모양). + **배열→해시 두 패스 순회 드라이버 자신은 `Dispatch.drive(inst, + flattened)`로 확정** — 문서가 이미 이걸 비공식적으로 "base 디스패치 + 드라이버"라 불러왔던 걸 그대로 동사화(`apply`는 기각 — "Dispatch를 + 뮤테이션해서 결과를 낸다"는 어감이라 안 맞는다는 사용자 판단). +- **`canExecute` 시그니처 정정: `(handle) -> boolean`, zero-arg 아님.** + `lifecycle-pattern.md`가 원래 `canExecute: () -> boolean`(바인딩마다 + 클로즈오버된 람다)으로 적어뒀던 걸 정정 — 그러면 등록마다 클로저를 + 새로 만들어야 해서 "base는 인터페이스만, quad-roblox가 `BaseModule` + 뮤테이션으로 실 구현 주입"이라는 이미 확정된 패턴과 안 맞음. 공유 + 함수 하나가 되려면 "어떤 등록을 볼지" 가리키는 인자가 필요 — + `canExecute(handle: LifetimeHandle): boolean`으로 확정. **quad-roblox + 구현 스케치(참고용, base 결정 아님)**: rbvm 패턴 재사용 — Instance당 + weak-keyed per-instance 저장소에 "gchold" 배열을 두고, 절대 발화 안 + 하는 신호에 연결한 Connection의 콜백 클로저 안에 살려두고 싶은 + Observer를 업밸류로 캡쳐(콜백은 안 불려도 클로저의 업밸류는 안 죽음, + `inst`가 GC되면 gchold 배열째로 같이 죽음). `canExecute(handle)`은 이 + Connection(류)의 `.Connected`를 확인. **미확인 세부사항으로 남긴 것**: + Observer→Connection 역참조를 별도 weak 릴레이션으로 둘지 그냥 Observer + 테이블 안 평범한 필드로 넣을지(정적 해싱 필드 접근이 더 쌀 수 있음) — + quad-roblox 구현 단계에서 실측 필요. +- **`Tag`/`Attribute`도 UICorner/Tween처럼 전용 문서가 있어야 한다는 + 지적 — 맞아서 `base/tag-plan.md`/`base/attribute-plan.md` 신설.** + 둘 다 이미 `architecture.md`/`ROADMAP.md` M10에 파일로는 계획돼 + 있었지만 "1 프리미티브 1 파일" 관례(Blocker/Effect/Ref/PreRef 분리 + 선례)를 따르는 전용 설계 문서가 없었음 — 흩어져 있던 내용(Attribute의 + 타입 파라미터화 논의 등)을 모으고, 오늘 확정된 `None`/`process`/ + `retract` 동작을 반영. **핵심 발견**: Tag/Attribute 둘 다 UICorner + 숏핸드와 같은 패턴(값이 뭐든 항상 같은 핸들러가 계속 담당, 추가/제거를 + `process` 자신이 처리)이라 **retract가 필요 없음** — "확정된 디스패치 + 모델" 절이 원래 Tag/Attribute를 retract 필요 예시로 들었던 게 잘못이었음, + 바로잡고 "retract가 의미 있는 유일한 패턴은 매치되는 핸들러 *타입* + 자체가 사이클마다 바뀌는 경우(Tween↔일반 프로퍼티가 실사례)"로 좁힘. + Attribute는 특히 깔끔한 사례 — Roblox `SetAttribute(name, nil)` 자체가 + 네이티브하게 "지움"이라 `None→nil` 재디스패치가 특별 처리 없이 그대로 + 맞아떨어짐. +- `.claude/README.md`에 두 신규 문서 반영, `ROADMAP.md` M2/M10 체크박스 + 갱신(`Dispatch` 4개 함수, `canExecute` 시그니처, Tag/Attribute 문서 + 참조). + +**같은 세션 세 번째 후속 — `canExecute` 옵션 하나 더 검토 후 확정 유지, +`Brand` 통합 판별 메커니즘 신설(`isState`를 10종으로 일반화), `isHandlable`도 +`inst`를 받도록 정정.** 전부 `base/bind-system-plan.md`(`Brand` 절, 핸들러 +계약 절)/`base/modifier-plan.md`/`ROADMAP.md`/`question.md`에 반영 완료: + +- **`canExecute`를 "각 핸들 타입이 직접 구현"(`Observer.canExecute`)할지 + "공유 함수"(`canExecute(any)->boolean`)로 할지 재확인 — 공유 함수 유지, + 솔직한 이유까지 명시.** `Observer` 자체는 quad-base 레벨(엔진 무관) + 타입인데 liveness 체크(Connection 기반)는 본질적으로 엔진 종속적이라, + `Observer.canExecute`가 직접 구현하면 base/roblox 분리 원칙이 깨지거나 + 결국 내부적으로 공유 함수를 다시 호출하는 얇은 래퍼가 될 뿐 — 어느 + 쪽이든 공유 함수 쪽이 낫다는 결론 재확인(추가 논의 없이 유지). +- **`Brand` 신설 — `isState`(다섯 번째 세션)를 quad의 다른 branded 타입 + 전부(`Observer`/`Effect`/`Tag`/`Attribute`/`Tween`/`Blocker`/`Store`/ + `Source`/`Slot`)로 일반화.** 공유 weak-key 레지스트리 하나(`Brand.set`/ + `Brand.get`) + **문자열이 아니라 테이블 아이덴티티를 태그로 사용** + (사용자 제안 — Luau 인터닝 문자열도 이미 O(1) 포인터 비교라 성능 차는 + 없지만, 오타 안전성이 실질 이득: 잘못된 변수 참조는 즉시 드러나지만 + 오타난 문자열 리터럴은 조용히 어긋남). `isX`는 `Brand`를 감싼 얇은 + wrapper — 단순 항등(`isObserver`)과 집합 멤버십이 필요한 경우(`isState` + = `{State,Source}`)로 갈림. **`None`만 예외 — 싱글턴이라 레지스트리 + 없이 `x == None` 항등 비교가 더 싸고 정확**, 대신 `Brand.get`이 범용 + introspection 창구(quad-debug 용도) 역할까지 겸하도록 `None`을 특수 + 분기로 앞단에서 걸러줌 — `isNone`이 그 분기의 실제 구현체. +- **정정 — `isSource`는 별도로 필요함, 다섯 번째 세션의 "불필요" 서술을 + 뒤집음** (전체 경위는 `archive/agent-mistake.md` 2번으로 옮김) — 결론만: + `isSource`를 별도 제공, `isState`는 여전히 `{State,Source}` 둘 다 통과. +- **Luau 타입 narrowing은 자동으로 안 됨 — 사용자가 직접 확인, 명시적 + `::` 캐스팅 필요.** `isX(v)`가 참이어도 Luau가 TypeScript의 `x is T` + 같은 사용자 정의 타입 가드를 지원 안 해서 `v`의 정적 타입을 자동으로 + 안 좁혀줌 — `if isState(v) then local s = v :: State ... end`처럼 + 런타임 검증 뒤 명시적 캐스팅이 실제 패턴. 여전히 duck-typing보다 훨씬 + 안전하니 가치는 있지만 자동 narrowing을 기대하면 안 됨. +- **`isHandlable`도 `inst`를 받도록 확정 — `(inst,key,value): boolean`, + 원래 `(key,value)`였던 걸 정정.** `process`/`retract`는 처음부터 + `inst`를 항상 받았는데(핸들러 계약 원 원칙) `isHandlable`만 예외였던 + 게 애초에 약간의 불일치 — 지금 당장 `inst`로 매치가 갈리는 케이스는 + 없지만, 나중에 필요해지면 핸들러 계약 자체를 깨는 breaking change가 + 되므로 지금 넣어두는 게 훨씬 쌈. `Dispatch.getHandler`가 스캔 중 + `handler.isHandlable(inst,k,v)`로 호출하도록 갱신. +- `ROADMAP.md` M2에 `Brand.luau` 체크박스 신설, `Handler.luau`/M7의 + `isState` 항목 갱신. `question.md`에 `Brand` 이름(용어 정리 대상, + "OOP 클래스명을 얻는 느낌"에 맞는 더 나은 이름 필요 — `Tag`는 이미 + quad-roblox에서 다른 뜻으로 쓰여서 충돌) 반영. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 +설계 확정이라 M0 착수 우선순위 자체는 그대로. + +## 2026-08-07 아홉 번째 세션 — 코퍼스 전체 정합성 감사·수정, `agent-mistake.md` +신설, `CreatedRef` 이름 완전 폐기 + +여러 세션에 걸쳐 쌓인 stale 참조/자기모순을 서브에이전트 5개 병렬 감사로 +찾아내 전부 수정하고(커밋 `98bd46a`), 이어서 사용자가 직접 발견한 남은 +문제(`CreatedRef` 이름 자체가 더 이상 존재할 이유가 없다는 지적)까지 처리한 +세션. 세 부분으로 나눠 요약: + +**1. 코퍼스 정합성 감사·수정 (커밋 `98bd46a`)**: `CreatedRef`의 `phase` +옵션 폐기가 `bind-system-plan.md` 안에서 세 곳 중 두 곳에 방치돼 있던 것, +`question.md`의 `Ref` 재검토 대상 여부 자기모순, UICorner 숏핸드 개명이 +5개 문서에 전파 안 된 것, `canExecute(handle)` 시그니처 정정이 막 확정된 +직후라 두 곳에 전파 안 된 것, `architecture.md`/`ROADMAP.md`/`CLAUDE.md`의 +stale 문구·누락 참조 등 12개 항목을 수정. `store-semantics.md` 제목도 +"State는 **Store** 위의 캐시 레이어"에서 "State는 **Source** 위의 캐시 +레이어"로 정정(사용자 확인: Source 단독 존재 가능 + Store는 Source들의 +집합이라는 온톨로지가 맞음). `slot-plan.md`의 CRUD 의미론 갭 하나만 +사용자가 다음 세션에서 직접 다루기로 보류. + +**2. `archive/agent-mistake.md` 신설** — 설계 반전(`*-reversed.md`)/기각 +후보(`*-rejected.md`)와 구분되는 세 번째 archive 카테고리: 에이전트가 +문서 작성 중 스스로 낸 개념 혼동을 같은 세션 안에서 정정한 사례 전용 +(`canExecute`/`isHandlable` 혼동, `isSource` 불필요 오판 2건). CLAUDE.md +세션 로그에 전체 경위가 장황하게 남아있던 것 중 최종 결론이 이미 `base/` +문서에 반영돼 중복이던 걸 옮기고 포인터만 남김 — 앞으로도 비슷한 사례가 +생기면 여기로 옮길 것(사용자 확인). + +**3. `CreatedRef` 이름 완전 폐기 — 사용자가 직접 발견.** "Ref가 이미 다 +정해진 것 같은데 `CreatedRef`는 이제 없는 말 아니냐"는 지적: `Source(default)`/ +`Ref(default)`/`Store({defaults})`가 이미 Kotlin Compose식 "타입 이름 +자체가 팩토리 함수" 생성자 스타일로 확정돼 있었는데(2026-08-06 네 번째 +세션), `CreatedRef(fn)`라는 별도 래퍼 이름만 그 확정 이전(2026-08-04, +Ref가 아직 "instance 얻는 통로"로 좁게 정의됐던 시절)의 잔재로 계속 +남아있었던 것 — 실제로는 `Ref(default)`(또는 `PreRef(default)`) +인스턴스 자체를 children 배열 숫자 슬롯에 그대로 놓으면 `(v=Ref)` 매치 +핸들러가 처리하므로, 별도 래퍼 함수가 있을 이유 자체가 없었음. `base/ +bind-system-plan.md`(바인드 방법 절 재작성, "CreatedRef와의 관계" 절 +삭제, "phase 옵션 폐기" 절/열린질문 절 정리) · `ROADMAP.md`(M0/M8 체크 +박스) · `question.md`(용어 재검토 목록에서 제거, 해소로 표시) · +`architecture.md`(소스트리 주석) · `research/documentation-content-map.md` +전부 동기화 완료. `archive/ref-phase-option-reversed.md`(phase 옵션 +자체의 역전 이력)와 CLAUDE.md 이전 세션 로그의 `CreatedRef` 언급은 +당시 기록으로서 정확하므로 그대로 둠 — 역사적 서술과 현재 유효한 설계를 +헷갈리지 않도록 "phase 옵션 폐기" 절 제목에 "이 절이 당시 쓰던 이름 +자체도 이후 폐기됨" 포인터만 추가. + +**부수 작업 — `PreRef`/`Modifier`의 "pre-hook" 태깅 요청 처리.** 같은 +세션 앞부분에서 사용자가 "PreRef와 Modifier는 문서화 시 pre-hook 태그가 +필요해 보인다, hook과 pre-hook의 차이(취소 가능/순서 등록 가능)도 적어 +두면 좋겠다"고 제안 — 이건 런타임 설계가 아니라 문서 사이트 콘텐츠 +분류 아이디어라 base/에 "확정"으로 못박지 않고 `research/ +documentation-content-map.md`(심화 콘텐츠 후보 6번 + "문서화 아직 보류" +목록)에 사용자 원문 프레이밍 그대로 미확정 표시로 남겨둠 — `PreRef`가 +"인스턴스에 뭐가 일어나기 전에 채워진다"는 사실 자체는 이미 확정 +서술돼 있었지만(재확인 후 "메모에서 지워도 됨"으로 답변), "hook"/ +"pre-hook" 용어 채택 여부·`PreRef`의 취소 가능성·복수 `PreRef` 간 순서는 +다음에 사용자가 직접 정해야 base/로 승격 가능. **[정정, 2026-08-07 열 +번째 세션]** 같은 대화에서 "Ref 콜백/대기자 배열은 압축 없이 `self[i] += nil`로만 지워도 된다"는 설계가 이미 정확히 반영돼 있다고 여기 적었던 +건 틀림 — 실제로는 `nil`이 아니라 `None`으로 지워야 함(아래 열 번째 +세션 절 참고), 이때는 아직 발견 전이었음. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터). `slot-plan.md` CRUD +의미론과 "hook/pre-hook" 프레이밍 확정만 사용자가 직접 다룰 후보로 남음. + +**같은 세션 후속 — `PreRef` pre-pass 구현 위치·복수 `PreRef` 순서·동적 +경로 가드 확정.** 사용자가 구체적인 구현 방안 세 개를 직접 제시: + +1. **복수 `PreRef` 간 순서는 배열 index 순서 그대로** — 별도 규칙 발명 + 불필요, 위 "props 순회 순서" 절이 이미 확정한 "배열 파트는 index + 순서대로" 계약을 그냥 재사용하면 됨. 호이스팅은 "PreRef 대 나머지" + 에만 적용되는 규칙이지 "PreRef끼리"엔 적용될 게 없음. +2. **pre-pass가 사는 곳 — 새 `Dispatch.*` 함수 대신 이미 확정된 + `Dispatch.drive(inst, flattened)` 자신.** 사용자가 두 대안을 직접 + 제시(`Dispatch.process(inst, flatten, prerefs)`류 신설 함수 vs + `flatten(inst, nonFlatten)` 함수 자체에 얹기) — 검토 결과 둘 다 + 불필요/위험함이 드러남. 전자는 이미 `Handler.process`/`Dispatch.process` + 이름이 다른 뜻으로 확정돼 있어 겹침. 후자(flatten에 얹기)는 사용자가 + "가장 간단해 보인다"고 제안했지만, `research/existing-instance-bind-plan.md`가 + 다루는 "이미 마운트된 Instance 재바인드 시 flatten을 다시 해야 + 하는가"라는 열린 질문이 실제로 flatten이 한 인스턴스 생애주기 동안 + 여러 번 재호출될 가능성을 열어두고 있어서, 거기 PreRef fire를 얹으면 + 재바인드마다 PreRef가 또 fire되어 "이 인스턴스 하나의 construction + 훅"이라는 정의 자체가 깨짐 — 기각. `Dispatch.drive`는 최초 마운트 + 시 한 번만 불리는 게 이미 전제라 이 위험이 없어서 그대로 거기 좁은 + pre-pass 한 줄만 얹으면 충분. +3. **동적 경로로 도착한 `PreRef`는 런타임에도 명시적으로 error — + 지금까지 타입 차단만 문서화돼 있던 빈틈을 채움.** 사용자 제안 + 그대로 채택: `{isHandlable = v is PreRef, process = error(...)}` + 전용 Handler를 정상 우선순위 레지스트리에 등록(`NoneHandler`와 같은 + "한 값 종류 전담" 패턴, 새 메커니즘 아님). 리터럴 배열의 `PreRef`는 + pre-pass가 fire와 동시에 슬롯을 소진시켜 정상 두 패스에 다시 + 노출되지 않으므로, 이 Handler가 실제로 매치되는 경우는 타입 차단을 + 어떻게든 우회한 버그 케이스뿐 — no-op이 아니라 즉시 `error`가 맞음. + +전부 `base/bind-system-plan.md` "PreRef" 절에 반영, `ROADMAP.md` M8 +체크박스 갱신, `research/documentation-content-map.md`의 "복수 PreRef +순서" 미정 표시 제거(해소됨, "취소 가능성"만 계속 미정으로 남김). + +**같은 세션 두 번째 후속 — "호이스팅이 물리적 재배치가 아니라 별도 +선행 스캔"이라는 것과 소진 방식을 명시화(뒤이은 세 번째 후속에서 +`nil`→`None`으로 다시 정정됨, 아래 참고).** 사용자가 "drive에서도 +결국 PreRef를 목록에서 뽑아내야 하는데, 호이스팅 안 되면 PreRef +의미가 사라지는 거 아니냐"고 재질문 — 이전 답변이 `Dispatch.drive`가 +pre-pass를 갖는다고만 하고 정확한 알고리즘을 안 써서 나온 질문. +`Dispatch.drive`가 같은 `flattened` 테이블을 **두 번** 순회한다는 +것으로 답변: (1) pre-pass가 배열 전체를 index 순으로 훑어 `PreRef`를 +fire하며 그 자리에서 슬롯을 소진, (2) 그 다음 평소 두 패스가 같은 +테이블을 다시 순회하되 소진된 슬롯은 자연히 건너뜀. "호이스팅"은 +PreRef를 배열 앞으로 물리적으로 옮기는 게 아니라 "PreRef 전용 선행 +루프가 통째로 먼저 끝난 뒤에야 나머지가 시작된다"는 뜻이라 소스 위치와 +무관하게 항상 먼저 fire됨. **소진이 최적화가 아니라 정확성 요건인 이유도 +명시**: 안 지우면 두 번째 패스가 이미 처리된 PreRef를 `Dispatch.process`로 +다시 넘겨서, 바로 위에서 신설한 "동적 경로 가드" Handler(`(v=PreRef)`→ +`error`)가 정상 사용에도 오탐 에러를 던지게 됨. + +## 2026-08-07 열 번째 세션 — 소진 슬롯을 `nil`이 아니라 `None`으로, +사용자가 Luau REPL 반례로 직접 발견 + +같은 날 이어진 세션. 사용자가 Luau REPL에서 직접 +`for i,v in {[1]=1,[2222]=2222,[211]=211,[131]=131,[3]=3,[6]=6, +[122]=122,[11]=11,[312]=312,[821]=821,[991]=991} do print(i,v) end`을 +돌려 순회 순서가 `1, 6, 122, 11, 991, 2222, 131, 312, 3, 821, 211`로 +나온다는 걸 보여줌 — index 오름차순이 전혀 아님. 이건 위 아홉 번째 +세션에서 "PreRef pre-pass가 fire된 슬롯을 `nil`로 지우면 된다"고 적은 +것과 여섯 번째 세션에서 "Ref 콜백/대기자 배열도 `[i]=nil`로 소진하면 +된다"고 적었던 것 둘 다를 뒤집는 반례 — 키가 촘촘한 저범위 정수에서 +벗어나면(구멍이든 원래 듬성듬성이든) Luau/Lua 테이블이 해시 파트 +취급으로 넘어가 순회가 해시 버킷 순서가 됨. + +**해결 — 소진에 `nil` 대신 `None` 센티널 사용, 전 코퍼스에 전파.** +`None`은 `nil`이 아닌 실재하는 값이라 그 슬롯을 "차 있다"로 유지시켜서 +테이블이 "구멍 없는 시퀀스"라는 불변식이 안 깨짐 — 두 가지를 동시에 +해결: (1) 순서가 실제로 중요한 배열(PreRef pre-pass)의 순서 보장 유지, +(2) `table.insert`가 내부적으로 쓰는 `#t`가 Lua 명세상 구멍 있는 +테이블에서 정의되지 않은 동작이라는 문제(Ref 콜백/대기자 배열이 새 +등록 때 `table.insert`를 씀 — 순서 자체는 원래도 안 중요했지만 이 +`#t` 안전성 문제는 진짜 버그였음). **배열 파트의 `None`은 해시 파트의 +`None`(Modifier 필드 명시적 지우기, `NoneHandler` 경유)과 의미가 +다름** — 배열 파트 `None`은 처리할 핸들러가 없는 순수 빈 슬롯 표시라 +`Dispatch.process`/`NoneHandler`를 안 거치고 두 패스 루프 자신이 직접 +`if v == None then continue end`로 스킵. + +`base/bind-system-plan.md`의 "왜 `nil`이 아니라 `None`인가"(Ref +콜백/대기자 절)와 PreRef pre-pass 절에 반영, `ROADMAP.md` M0/M8 +체크박스 갱신, 위 아홉/여섯 번째 세션 문단에 정정 표시 추가(원문은 +유지, 틀렸던 부분만 짧게 정정 포인터). + +**부수 발견 — `props.Modifier`/`props.Ref` nil-hole 위험도가 이전 +서술보다 큼.** `pre-implementation-audit.md` 1-5가 이미 이 위험을 +"뒤 항목까지 무시될 수 있음"으로 국소적 피해처럼 서술해뒀는데, 이번 +REPL 실측으로 실제로는 구멍이 하나만 생겨도 **그 테이블 전체**가 순서 +보장을 잃을 수 있다는 게 드러남 — M0 스파이크에서 반드시 실측하고, +심각하면 "raw 리터럴 대신 `props.Modifier or Modifier()`로 non-nil +보장" 컨벤션 문서화까지 검토하기로 `ROADMAP.md` M0에 메모 추가. 이 +케이스는 caller가 직접 쓰는 raw Lua 리터럴이라 `None`으로 프레임워크가 +대신 채워줄 수 없어서 별도 해법이 필요함 — `None` 소진 전략과 혼동하지 +말 것. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터). M0 착수 시 위 +nil-hole 위험도 실측이 우선순위 높아짐. + +**같은 세션 세 번째 후속 — `props.Modifier`/`props.Ref` nil-hole +해법을 실제로 확정, 세션 clear 전 문서 완결성 점검하며 발견한 갭 +3개도 같이 보강.** 사용자가 "컴포넌트에서는 꼭 `or None`이나 +`Modifier()` 같은 걸로 nil 못하게 강제하는 걸 문서화하자"고 요청, 그 +자리에서 결정하고 clear 전 세션 전체를 다시 훑어 새로 알게 됐지만 +아직 문서에 없던 것들을 마저 채움: + +1. **`props.Modifier or None`/`props.Ref or None`을 필수 관용구로 + 확정** — `Modifier()`(빈 modifier 새로 생성)가 아니라 `None`을 쓰는 + 이유는 이미 있는 array-part `None`-스킵 메커니즘(PreRef 논의에서 + 확정)을 그대로 재사용해 새 코드/할당이 하나도 안 늘어나기 때문 — + `flatten`이 `isModifier(None) == false`라 그냥 통과시키고, 이어지는 + 두 패스 루프가 `None`을 만나면 스킵. `base/component-composition-plan.md` + "필수 관용구" 절 신설, `ROADMAP.md` M0/`pre-implementation-audit.md` + 1-5/`question.md`에 반영(1-5는 해소로 표시). +2. **`Modifier()` 바닥 생성자가 문서 어디에도 없었던 갭 발견·보강** — + `Source(default)`/`Ref(default)`/`Store({defaults})`와 나란히 있어야 + 할 "`Type(args)` 팩토리" 4번째 예시가 원래 없었음(이전 아홉 번째 + 세션에 `Modifier.Rounded(8)` stale 참조를 고치면서 실수로 체이닝 + 예시인 `mod:UICorner(8)`로 잘못 채워 넣었던 것도 같이 바로잡음). + `modifier-plan.md` 3번 절에 명시, `store-semantics.md` 예시 목록 + 정정, `ROADMAP.md` M7 체크박스 추가. +3. **`Brand` 태그 목록에 `RefTag`/`PreRefTag`/`ModifierTag`가 빠져있던 + 갭 발견·보강** — 이번 세션 내내 `isPreRef(v)`/`isModifier(v)`를 + 이미 존재하는 predicate처럼 써왔는데 정작 여덟 번째 세션의 `Brand` + 태그 목록엔 없었음. 추가하면서 **`isRef`/`isPreRef`가 `isState`와 + 달리 집합 멤버십이 아니라 단순 항등이라는 것도 명시** — + `isRef(preRefInstance)`가 참이면 일반 `(v=Ref)` 핸들러가 `PreRef`도 + 집어삼켜 PreRef 전용 pre-pass/가드 Handler 설계 전체가 무너지므로 + 반드시 배타적이어야 함. `bind-system-plan.md`의 `Brand` 절, + `ROADMAP.md` M2 체크박스에 반영. +4. **배열 파트 `None`과 해시 파트 `None`(`NoneHandler`)이 같은 센티널인데 + 처리 경로가 다르다는 걸 `None` 센티널 절 자체에 명시적으로 + 교차 참조 추가** — 이전엔 PreRef 절에만 있고 `None` 센티널 원래 + 정의 절엔 이 예외가 안 적혀 있어서, 그 절만 읽으면 모든 `None`이 + `NoneHandler`를 탄다고 오해할 수 있었음. + +전부 커밋 `98bd46a` 이후 아직 커밋 안 된 이번 대화 전체 변경사항에 +포함 — 다음 세션이 새로 알아야 할 건 없음, `ROADMAP.md` M0부터 그대로 +시작. + +## 2026-08-08 세션 — `Relate` 신규 프리미티브, `bindLifetime`/`canExecute` +탑레벨 함수로 확정, store-bind 재실행=Observer 재사용 명문화, `retract` +필드 생략 불가 확정 + +사용자가 store-bind/라이프사이클 관련 문서 갭 두 개를 질문하며 시작된 세션 +— 답을 찾는 과정에서 지금까지 이름만 있던 placeholder(`base.perInstanceState`)가 +실제로는 제대로 설계된 적 없는 프리미티브였다는 게 드러나 그 자리에서 +설계까지 확정까지 감. 네 가지로 정리: + +**1. store-bind의 "값이 바뀔 때마다 재귀 process" 구독 메커니즘 = +`state:Observer(fn):Subscribe()` 재사용으로 확정.** 기존 "확정된 디스패치 +모델"/"재실행 래핑" 절이 구독을 추상적으로만 서술해서 마치 새 구독 +프리미티브가 필요한 것처럼 읽혔는데, 실제로는 이미 확정된 Observer(등록 +즉시 1회 실행이라 "최초 적용"과 "이후 갱신"이 공짜로 통일됨, 자기 `Subscribed` +liveness도 이미 있음)를 그대로 쓰면 됨 — `retract`는 `observer:Unsubscribe()` +호출 하나로 끝. 새 구독 메커니즘 발명 없음. `base/bind-system-plan.md` +"Store 바인드는 특수 경우인가" 절 반영. + +**2. `retract` 필드는 no-op이라도 항상 정의해야 함 — 생략 불가로 확정.** +"모든 핸들러가 의미 있게 구현할 필요는 없음(보통 no-op)"이라는 기존 서술이 +"필드 자체를 생략해도 된다"로 오독될 수 있는 갭이었음 — `Dispatch.process`는 +담당 핸들러 타입이 바뀔 때 이전 핸들러의 `retract`를 nil 체크 없이 무조건 +호출하므로, 필드를 생략한 핸들러가 실제로 교체되는 드문 순간(Tween↔프로퍼티 +등)에 `attempt to call a nil value`로 크래시함. `base/bind-system-plan.md` +"핸들러 계약" 절에 명시, M2 체크리스트에 린트 대상으로 추가. + +**3. `Relate` 신규 프리미티브 — `bindLifetime`/`canExecute`(가 의존하는 +per-inst weak 저장소)를 제대로 설계.** 사용자 질문 경위: `Frame { observer }`처럼 +children 배열에 직접 놓는 leaf 케이스와, property store-bind 핸들러가 +**내부에서** 만드는 Observer(배열에 안 들어가므로 그 leaf 부착 경로를 안 탐)를 +처음에 잘못 섞어서 답했다가 사용자가 "state 바인딩은 결국 k,inner v를 +호출하니 i=number,v=observer로 다시 실행 안 된다"고 정정 — 후자는 +`bindLifetime(inst, observer)` 같은 별도 배관이 필요하다는 걸로 이어짐. +이게 `base/lifecycle-pattern.md`가 이미 원 사용자 메모(2026-08-04)로 +갖고 있던 "함수 안에서 만든 옵저버도 GC 대상 되어야 함" 절과 정확히 +같은 문제였음이 드러남 — 그 절이 "범용 유틸이 있어야 한다"까지만 말하고 +실제 인터페이스/이름이 없던 것. + +- **탑레벨 평범한 함수로 확정, 네임스페이스 뒤에 안 숨김** — `bindLifetime(inst,value)`/ + `canExecute(inst,value)`. `Dispatch.process`류는 "시스템 배관"이라 + 네임스페이스가 맞지만 이 둘은 `isState`/`isObserver`처럼 핸들러 작성자가 + 직접 부르는 1급 프리미티브 연산이라 `LifetimeHandle.bind(...)`식으로 + 감싸면 안 된다는 사용자 지적(정확함, 처음 제 제안이 틀렸었음). +- **`canExecute` 시그니처를 `(handle)` 단일 인자에서 `(inst, value)` + 2-인자로 재정정** — Observer 자신의 바인딩 생존(`Subscribed`)과 `inst` + 자체 생존(gcconn)이 독립된 두 조건이라 opaque `handle` 하나로 못 뭉침. + 구현은 `value`가 Observer/Effect면 자기 `Subscribed`부터 확인, 그 다음 + `inst`의 공유 gcconn `.Connected`를 봄. +- **`Relate` — `inst`를 weak 키로 하는 범용 릴레이션, 신규 프리미티브로 + 독립 승격**(`base/relate-plan.md`, 1프리미티브-1파일 컨벤션). `Relate()` + 비싱글톤 생성자 + `:SetWeak`/`:GetWeak`/`:SetStrong`/`:GetStrong`. 핵심 + 결정 세 개, 전부 사용자가 직접 제시: + 1. **자동으로 아무것도 홀드 안 함** — `inst`도 `value`도 Relate 자신은 + 안 붙잡음, weak/strong 여부는 호출부(엔진을 아는 quad-roblox)가 + 매번 명시. 자동으로 정하면 weak 키가 참조하는 값이 그 키로 되돌아 + 강참조하는 사이클이 너무 쉽게 생김. + 2. **`inst`(키) 축은 항상 weak로 고정, 자유도를 안 열어둠** — 강한 키가 + 필요한 유스케이스가 지금까지 하나도 없어서, 그 자유도 자체가 사고 + 가능성만 늘림. `Weak`/`Strong`은 오직 `value` 보관 방식. + 3. **실 구조는 `{ [inst(weak)]: { StrongMap: {[k]:v}?, WeakMap: {[k]:v(weak)}? }? }`, + 둘 다 lazy 생성**(첫 `Set` 호출 시에만 만듦) — Luau가 정적 분석으로 + 포인터 해싱을 캐싱해 반복 인덱싱은 이미 싸지지만 테이블 생성(array+hash + part 초기화) 자체는 비교적 비싸다는 게 이유. `WeakMap`의 메타테이블은 + 매번 새로 안 만들고 공유 객체 하나를 재사용. + - **비싱글톤인 이유**: 각 핸들러 모듈이 자기 톱레벨에 `local relate = + Relate()`를 하나씩 두면 key 네이밍이 모듈 간에 겹칠 걱정이 원천적으로 + 없음(`Ref`/`Store`류와 같은 "생성 가능한 값" 컨벤션). +- **`base.perInstanceState(inst)` 이름/placeholder는 완전히 폐기** — + `Relate`가 그 자리를 정식으로 대체. `bind-system-plan.md`(핸들러 내부 + 상태 저장 절)/`ui-shorthand-plan.md`/`architecture.md`(소스트리, + `Relate.luau`는 quad-base 전체가 순수 Lua라 quad-roblox 재구현 없음)/ + `question.md`(용어 정리 목록에서 `PerInstanceState` 항목 삭제, 이름 + 갈등 자체가 해소됨)/`ROADMAP.md`(M2/M8/병행가능 세 곳) 전부 동기화. + +**4. 아직 안 풀린 것 — `(i:number, v=Ref/Observer/PreRef)` children-array +leaf Handler가 quad-base/quad-roblox 중 어디 사는지.** 3번을 풀다가 +갈라져 나온 별개 질문(`Frame { ref }` 자체를 매칭하는 Handler, store-bind +내부 Observer와는 무관) — 제 제안(엔진 특정 API가 필요 없으니 quad-base, +`Dispatch/StoreBind.luau`와 같은 층위)은 사용자 확인을 못 받은 채 대화가 +3번으로 넘어감. `question.md` 2번에 미확인으로 남김, base에는 반영 안 함 +— 다음에 확인 필요. **[해소됨, 같은 날 두 번째 세션]** 아래 절 참고 — +제 원래 제안 그대로 quad-base로 확정. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터). M0/M2 스파이크 코드가 +검증해야 할 것 목록에 `Relate`의 lazy 서브테이블 생성/공유 메타테이블 +전략, `bindLifetime`/`canExecute`의 실제 gcconn 트릭이 새로 추가됨 — +`base/lifecycle-pattern.md`/`base/relate-plan.md`의 "실측 필요" 캐비엇 +참고. + +## 2026-08-08 두 번째 세션 — Dispatch는 프리미티브가 아니라 탑레벨 싱글톤 확정, +네이밍 케이싱 컨벤션 신설, Handler를 세 번째 카테고리로 명문화 + +같은 날 이어진 세션. 사용자가 위 4번 미결 항목("Ref/Observer/PreRef leaf +Handler가 어디 사는지")을 다시 짚으며 시작 — "Handler도 실제 런타임 값이 +생기는 요소인데 왜 프리미티브로 안 다루나", "Dispatch는 어떻게 되는 거냐, +State 핸들러 안에서 `getHandler`를 부르려면 Dispatch가 이미 존재해야 +하는데" 하는 질문으로 확장돼 Dispatch 자체의 정체성(싱글톤 top-level +함수 모음 vs 인스턴스화 가능한 프리미티브) 논의로 이어짐. 네 가지로 정리, +전부 `base/bind-system-plan.md`/`base/store-semantics.md`/ +`base/architecture.md`/`question.md`/`ROADMAP.md`에 반영 완료: + +**1. Dispatch는 프리미티브가 아니라 탑레벨 싱글톤 — 확정, 지금 형태 유지.** +`Dispatch.process`/`getHandler`/`addHandler`/`drive`를 `Source`/`Ref`처럼 +생성자 있는 프리미티브로 바꿀지 검토했으나 기각. 근거: (a) Tween/ +`NoneHandler`/`StoreBind`가 자기 `process` 안에서 다시 `Dispatch.process`를 +재귀 호출해야 해서, `canExecute`/`bindLifetime`처럼 require 한 번으로 바로 +닿는 안정된 전역이어야 함 — 프리미티브화하면 모든 Handler 호출 경로에 +Dispatch 핸들을 실어날라야 하는 스레딩 비용이 생기는데 지금은 그 비용이 +없음. (b) 사용자가 우려한 "Handler가 Dispatch 원하고 Dispatch가 Handler +원해서 순환참조" 문제는 착시로 확인됨 — "Handler"가 (i) `Handler.luau`의 +순수 타입 계약(leaf, Dispatch를 몰라도 됨)과 (ii) 그 계약을 구현하는 +concrete 값 모듈(`StoreBind.luau`류, 재귀호출 위해 Dispatch를 참조) +두 가지를 가리켜서 헷갈렸던 것 — 의존 방향은 `Handler.luau` ← +`Dispatch/init.luau` ← `StoreBind.luau`로 항상 한쪽으로만 흐름, 사이클 +없음. (c) 모듈 재생성(`New()`)과의 관계도 새 설계가 필요 없음 — 이미 +확정된 "팩토리가 `BaseModule`을 뮤테이션" 패턴을 그대로 따르면 +`_initializedBy` 마커에 대해 이미 나왔던 결론("`New()`가 생기면 각 +인스턴스가 별도 테이블이 되므로 자연히 스코핑됨")이 Dispatch의 handler +레지스트리에도 그대로 적용됨. v1처럼 `require`를 감싸는 `Init(QuadId?)` +방식은 채택 안 함(id 기반 조회 자체가 Ref로 대체되며 이미 기각된 패턴). +`base/bind-system-plan.md`의 "Dispatch는 프리미티브가 아니다" 절, +`base/architecture.md` 13번 항목에 반영. + +**2. quad-base 기본 핸들러도 전부 같은 `Dispatch.addHandler` 레지스트리를 +공유 — Ref/Observer/PreRef leaf Handler 위치 확정.** `NoneHandler`/ +`Dispatch/StoreBind.luau`뿐 아니라, children 배열 숫자 슬롯에 `Ref`/ +`Observer`/`PreRef`를 직접 놓는 leaf 값을 매칭하는 Handler도 같은 부류 — +`inst`를 `any`로 취급하고 엔진 특정 API가 필요 없으니 quad-base, +`Dispatch/Leaf.luau`로 확정(위 4번 미결 항목 해소). quad-roblox의 +Property/Event/Tween 핸들러도 **같은** 레지스트리에 등록되므로, base +기본 핸들러와 backend 핸들러가 별도 경로로 안 갈리고 하나의 우선순위 +스캔을 공유한다는 것도 명시적으로 확인됨. `architecture.md` 소스트리에 +`Dispatch/Leaf.luau` 반영, `question.md`/`ROADMAP.md` M2 동기화. + +**3. Handler는 "독립 프리미티브 vs 파생 데이터" 분류의 세 번째, 별개 +카테고리 — 명문화.** 2026-08-06 후속 세션이 확정한 분류(Source/Ref/Store/ +Modifier=독립 프리미티브, State/Observer=파생 데이터)에 Handler가 왜 +안 끼는지 사용자가 재확인 요청 — 이유: Handler는 그 자체로 구현체가 +없는 **순수 타입 계약**이라 quad 사용자가 다루는 리액티브 값이 아님, +계약을 만족하는 값(`PropertyHandler`류)은 항상 **구현하는 쪽**(base +자신의 기본 핸들러 또는 quad-roblox 백엔드)이 채워 넣는 것이지 `Type(args)` +자유 함수로 사용자가 만드는 게 아니고, State/Observer처럼 어떤 원천에 +종속된 파생물도 아님. `base/store-semantics.md`의 "일반 원칙" 절 뒤에 +"세 번째 카테고리 — Handler" 절로 반영. + +**4. 네이밍 케이싱 컨벤션 신설 — 지금까지 나온 모든 이름이 이미 따르고 +있던 규칙을 문서화만 함, 리네임 없음.** 사용자 관찰: "탑레벨 함수는 +변수처럼 소문자 시작, 프리미티브 타입의 메서드는 대문자 시작(파스칼 +케이싱)이 맞아 보인다"는 규칙 제안 — 검증 결과 기존 이름 전체(생성자 +`Source`/`Ref`/`Store`/`Modifier`/`Relate`/`Effect`, 콜론 메서드 +`:Get`/`:With`/`:Set`/`:Apply`/`:Subscribe`류는 전부 대문자, `canExecute`/ +`bindLifetime`/`isState`류/`Dispatch.process`류/`Brand.set`류는 전부 +소문자)가 이미 예외 없이 이 규칙을 따르고 있었음이 확인됨. 유일하게 +애매해 보였던 `Modifier.Override(mod1, mod2, ...)`(콜론 아니고 dot-access +인데 대문자)도 규칙 위반이 아니라 세 번째 하위 규칙으로 설명됨 — 콜론 +메서드는 아니지만 **`Modifier` 타입 자신의 네임스페이스에 달린 정적 +결합 함수**라 "그 프리미티브 타입 고유의 공개 어휘"라는 점에서 생성자/ +메서드와 같은 부류. 반대로 `Dispatch.process`/`Brand.set`이 소문자인 +이유는 `Dispatch`/`Brand`가 애초에 `Type(args)` 생성자가 없는 프리미티브가 +**아닌** 내부 엔진/레지스트리라서. 최종 판단 기준: "이 이름이 특정 +프리미티브 타입 하나의 전용 소유물인가?" — 그렇다면 대문자, 아니면(여러 +타입에 걸친 범용 유틸이거나 비-프리미티브 엔진 소속) 소문자. `base/ +architecture.md`의 "코드 스타일 — 네이밍 케이싱" 절 신설. + +**같은 세션 후속 — `module-lifecycle-plan.md`의 "열린 질문" 절이 stale로 +방치돼 있던 것을 사용자가 직접 발견.** 문서 상단 "상태" 줄은 이미 +"확정되어 승격됨"이라고 말하는데 그 아래 "열린 질문" 절은 2026-08-04 +당시 그대로 남아있었음 — 그중 "프로바이더 인터페이스 시그니처 미정"/ +"네이밍 미정(provider/processor/plug)" 두 항목이 사실 그 뒤 `Handler` +계약 확정으로 이미 풀려 있었는데 이 문서에 반영이 안 됐던 것. 원문은 +남기고 각 항목에 해소 표시+포인터 추가, 절 제목도 "열린 질문이었던 것 — +전부 해소됨"으로 정정. 새 결정 아니라 순수 동기화. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 +설계/문서 정리라 M0 착수 우선순위 자체는 그대로. 위 2026-08-08 첫 세션이 +남긴 "M0/M2 스파이크 검증 목록"에 새로 추가되는 항목 없음. + +## 2026-08-08 세 번째 세션 — Tag를 array-part 값 객체로 재설계, Dispatch +체인+`retractUnder`로 재귀 재-dispatch의 retract 전파 문제 해결 + +같은 날 이어진 세션. 사용자가 "Tag를 해시 파트 boolean 키 대신 array-part +값 객체로 바꾸는 게 낫지 않냐"는 질문으로 시작 — 상호배타 스타일 상태 +(`btn1`/`btn2`/`btn3`류, 20개까지도 가능)를 표현하려면 구 모델은 태그 +개수만큼 키를 갱신해야 해서 끔찍하다는 실사용 근거. 이 논의가 "retract가 +새 값의 타입에 따라 이전 핸들러를 정확히 찾아 부를 수 있는가"라는 훨씬 +근본적인 구멍(`pre-implementation-audit.md` 1-2번이 이미 지적해뒀던 것)을 +직접 건드리게 됐고, 몇 차례 시행착오 끝에 사용자가 제시한 "체인+ +`retractUnder`" 설계로 수렴. 세 갈래로 정리: + +**1. Tag 재설계 — array-part 값 객체, `Modifier`와 같은 immutable clone +체이닝.** `Tag(name1, name2, ...)`(가변인자 생성자, 빈 `Tag()`도 유효), +`:Added`/`:Removed`(뮤테이션처럼 안 보이게 `-ed` 어미 — 실제로는 항상 +clone 후 반환), `:Contains(name):boolean`, `:Apply(factory)`(Modifier와 +동일한 순수 체이닝 설탕), `Tag.Merged(tag1,tag2,...)`(집합 합집합, 무손실 +— Modifier의 `Override`는 필드 단위 덮어쓰기라 손실 있음, 그래서 이름도 +다름). `None` 센티널은 불필요로 확인 — 동적 토글은 `Source`/`State`가 +계산 결과로 `nil`을 리턴하면 되는 함수 인자 전달이라 테이블 리터럴의 +nil-hole 문제 자체가 없음(정적 리터럴에서 조건부로 Tag를 넣고 뺄 때는 +다른 array-part 값과 마찬가지로 기존 `None` 관용구가 그대로 유효, Tag +전용 규칙 아님). 구 모델(해시 파트 boolean, "핸들러 타입이 안 바뀌니 +retract 불필요"가 결론이었음)은 `archive/tag-hash-key-model-reversed.md`로 +역전 보존, `base/tag-plan.md` 전면 재작성. 값 타입+API(`Tag.luau`)는 +quad-base, `CollectionService` 글루(`Handlers/Tag.luau`)만 quad-roblox — +이미 확정된 "base는 인터페이스/값, backend는 process·retract 글루" +패턴(`LifetimeHandle`)을 값 타입 수준까지 그대로 확장한 것으로 확인, +새 아키텍처 개념 아님. + +**2. Tag 재설계가 "retract가 실제로 필요해지는" 첫 array-part store-bind +사례가 되며, 기존 "이전 핸들러 추적" 설계 공백이 정면으로 드러남.** +`pre-implementation-audit.md` 1-2번이 이미 "store-bind 재실행 모델에서 +realv 타입이 매 갱신마다 바뀔 수 있는데 '이전 핸들러'를 누가 추적하는지 +불명"이라고 짚어뒀던 것 — Tag가 `Tag(...)`↔`nil` 사이를 오가며 실제로 +핸들러 타입이 바뀌는 구체 사례가 되어 더 이상 미룰 수 없어짐. 시행착오 +과정: +- **1차 제안(제가 냄, 기각됨)**: Dispatch가 `(inst,k)`별로 "지금 누가 + 담당 중인가"를 슬롯 하나로 추적. **재귀/래핑 핸들러(StoreBind 등) + 에서 깨짐** — 사용자가 직접 "A→B 구조에서 A가 바뀌면 B의 retract가 + 실행되고, 재귀로 B로 다시 내려오면 retract가 없는 거 아니냐"고 반례를 + 제시 — A 자신의 생명주기(예: Observer 구독)와 A가 재귀로 위임한 B의 + 생명주기가 슬롯 하나를 두고 서로 덮어써서, A가 스스로 재-dispatch할 + 때 자길 엉뚱하게 retract하거나 반대로 안 해야 할 때 안 하는 오작동이 + 생김이 실제 트레이스로 확인됨. +- **2차 제안(제가 냄, 부분 기각)**: 각 래핑 핸들러가 자기 전용 `Relate`에 + 위임 대상을 비공개로 저장(A→B→C면 A.retract가 수동으로 B.retract를 + 부르고 B.retract가 수동으로 C.retract를 부르는 linked 구조). 동작은 + 하지만 사용자가 두 가지 지적: (a) 나중에 재바인드(`existing-instance- + bind-plan.md`) 지원을 생각하면 위임 정보가 핸들러별로 비공개 분산돼 + 있어 외부에서 못 들여다봄, (b) 각 핸들러 작성자가 "내 retract에서 + 위임 대상도 cascade해야 한다"는 걸 매번 기억해야 하는 규율 의존적 + 설계. +- **최종 채택(사용자 제안) — Dispatch가 `(inst,k)`별 핸들러 체인(순서 + 있는 배열)을 직접 소유, `Dispatch.retractUnder(inst,k,keep,v)`가 + 꼬리부터 `keep` 앞까지 훑으며 정리.** `Dispatch.process`가 매치될 + 때마다 체인에 push, 재귀/래핑 핸들러는 재-dispatch 전에 + `retractUnder(inst,k,self,newV)`를 먼저 불러 자기 밑을 정리 — 이 + 한 번의 루프가 다단 체인(A→B→C) 전체를 순서대로 정리해주므로 개별 + 핸들러의 `retract`는 더 이상 자기 위임 대상을 수동으로 안 쫓아가도 + 됨(2차 제안의 (b) 해소), 체인이 Dispatch에 중앙화돼 있어 미래 + 재바인드도 `retractUnder(inst,k,nil,newV);process(inst,k,newV)` + 두 줄로 자연스럽게 됨((a) 해소) — quad-debug의 "무엇이 무엇에 + 연결됐는가" 그래프도 이 구조를 그대로 읽으면 됨. 배열이 항상 꼬리에서만 + 추가/삭제되는 스택 모양이라 `None` 소진 이슈(구멍 있는 정수 키 순회 + 문제)도 애초에 안 생김. **`retract`는 여전히 `(inst,k,v)` 3-인자 + 유지** — 한 차례 제가 "v 제거"를 제안했다가 틀렸음(사용자가 Tag의 + 전체삭제 vs diff 분기를 근거로 정정) — 다만 최종 설계에서 diff는 + `process`(같은 핸들러 유지 시)의 몫이고 `retract`는 항상 "더 이상 + 매치 안 될 때만" 불리므로 Tag 한정으로는 `v`를 안 봐도 항상 전체 + 삭제가 맞다는 것도 확인. 순환은 기존 "일반적 무한루프 방어 안 함" + 원칙(2026-08-04) 그대로 UB. + +**3. 전부 `base/bind-system-plan.md`(신규 "Dispatch 체인" 절 + "확정된 +디스패치 모델"/"None 센티널"/"Store 바인드는 특수 경우인가" 절 갱신)/ +`base/tag-plan.md`(전면 재작성)/`archive/tag-hash-key-model-reversed.md` +(신규)/`base/architecture.md`(소스트리 `Tag.luau` 추가, 4번 항목 정정)/ +`ROADMAP.md`(M2/M4/M10)/`research/pre-implementation-audit.md`(1-2번 +해소 표시)에 반영 완료.** + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터). M2/M4 스파이크 +검증 목록에 `chains`/`retractUnder`가 다단 체인에서 실제로 정확히 +동작하는지가 새로 추가됨(추론만으로 확정된 것, `pre-implementation-audit.md` +류 "실제 Luau로 부딪혀본 적 없는 것" 범주). `pre-implementation-audit.md` +1-1번(Tween이 유일한 store-bind 예시라 "일반 store-bind와 Tween이 같은 +핸들러인지"가 불명확한 문제)은 Tag가 두 번째 구체 사례가 되면서 정황상 +"별개 핸들러, 둘 다 `Dispatch/StoreBind.luau` 재사용"쪽에 힘이 실리지만 +**아직 명시적으로 확정된 건 아님** — M2/M4 착수 전 마저 확인할 것. + +## 2026-08-08 네 번째 세션 — `Modifier.Override` → `Overridden`으로 이름 확정 + +사용자가 IDE에서 `tag-plan.md`를 보다가 "Tag가 `Added`/`Removed`처럼 +`-ed` 어미를 의도적으로 쓰는데, Modifier의 `Override`도 그냥 +`Overrided`로 하면 어떤가"라고 질문 — `-ed`/분사 어미가 "즉시 커밋되는 +뮤테이션이 아니라 이미 계산되어 반환되는 새 값"을 신호한다는 기존 관례 +(`Add`/`Remove`가 `-ed` 없이 쓰이면 뮤테이션처럼 오독될 위험이 있어 +`Added`/`Removed`로 확정했던 것과 같은 문제가 `Override`에도 그대로 +있음)에 정확히 들어맞는 좋은 관찰이었음. 다만 `Overrided`는 오기 — +`override`는 불규칙동사라 과거분사가 `overrided`가 아니라 `overridden`. +`Add`/`Remove`/`Merge`가 전부 규칙동사라 우연히 단순 `-ed` 접미만으로 +맞았던 것뿐, `Override`엔 그 규칙이 그대로 안 통함. 사용자가 이 정정에 +동의하고 확정 요청 — `Modifier.Overridden(mod1, mod2, ...)`으로 이름 +자체를 확정(더 이상 가칭 아님, 용어 정리 라운드 대상에서도 제외). + +`base/modifier-plan.md`/`base/component-composition-plan.md`/ +`base/bind-system-plan.md`/`base/tag-plan.md`(비교 문구)/ +`base/architecture.md`/`ROADMAP.md`/`research/pre-implementation-audit.md`/ +`research/documentation-content-map.md`/`.claude/README.md`/ +`.claude/question.md` 전부에서 `Override` → `Overridden`으로 기계적 +치환 + 각 문서의 "가칭"/"이름만 잠정" 표시를 "이름 확정"으로 갱신 +(`question.md`의 3순위 용어 재검토 목록에선 완전히 제거, `Peek`/ +`isState`만 그 목록에 남음). CLAUDE.md 세션 히스토리(과거 `Override` +서술)와 `archive/`는 당시 기록이라 그대로 둠 — 역사적 서술과 현재 +유효한 이름을 헷갈리지 않도록 여기 새 절로만 반영. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 순수 네이밍 +확정이라 M0 착수 우선순위나 설계 자체엔 영향 없음. + +## 2026-08-08 다섯 번째 세션 — 용어 정리 라운드 정리: `Handler`/`None`· +`NoneHandler`/`Ref`/`PreRef`/`Peek`/`isState` 이름 확정, `DI`→`D`/ +`canExecute`→`isAlive`는 계속 미정으로 재확인 + +사용자가 `.claude/question.md`의 3순위(사소함) 용어 재검토 목록을 훑으며 +한 번에 여러 개를 정리 — 전부 `.claude/question.md` "1. 용어 정리" 절과 +`base/module-lifecycle-plan.md`에 반영 완료: + +- **`Ref`/`PreRef`/`Peek`/`isState` — 전부 현재 이름 그대로 확정(더 나은 + 대안 없음).** `Ref`는 "지연 없는 확정된 값 박스"라는 정의를 재확인 — + leaf 노드를 담는 용도로도, leaf 노드에 바인딩하는 용도로도 쓰인다는 게 + 넓어진 정의에도 여전히 맞다는 근거. +- **`None`/`NoneHandler` — 확정.** `Undefined`/`Null`/`Nothing`도 검토했으나 + `Null`은 보통 "포인터가 비어있음"(0)을 뜻해 "값이 없음"이라는 의도와 + 미묘하게 안 맞는다는 이유로 기각, `None`이 나음. +- **"프로바이더" → `Handler`로 확정, 기각 이유 보강.** 이미 + `module-lifecycle-plan.md`에 [해소됨]으로 반영은 돼 있었으나 + `question.md` 목록에 stale로 남아있던 걸 정리. `Processor`는 계약 + 메소드 자체가 `process`라 이름이 겹쳐 거슬림, `Provider`는 + `canProvide`처럼 "공급한다"는 늬앙스인데 실제로는 처리/반응하는 + 쪽이라 안 맞고 React `Context.Provider`류와도 헷갈릴 수 있음, `Plug`는 + "꽂힌다"는 어감은 맞지만 "처리한다"는 의미가 빠져있음 — `Handler`가 + 계약(`isHandlable`/`priority`/`process`/`retract`) 전체를 가장 정확히 + 담는다는 결론. +- **`DI` → `D`는 아직 미확정.** 사용자가 "Declarative만 남기고 D로 + 줄이자"는 안을 제안 — Instance 전용이 아니라 quad-* 전반의 declare + 요소로 확장 가능하고, 엔진 종속 없이 재사용 가능하며, `D.FrameModifier` + 류 타입 프리픽스가 짧아야 한다는 실용적 이유까지 근거로 나쁘지 않은 + 제안이나, 한 글자 식별자의 검색성/자기설명력 트레이드오프를 문서에서 + 어떻게 보완할지가 남아 다음에 마저 결정하기로 함. +- **`canExecute` → `isAlive`도 계속 미정, 방향만 정리.** `isAlive`가 의미는 + 더 정확하지만 top-level `isX` 타입 판별자 계열(`isState`/`isRef`/ + `isPreRef`/`isModifier`/`isObserver`)과 접두어가 겹쳐 "이것도 타입 + 체크인가" 오해를 유발할 위험이 지적됨 — `canExecute`는 타입이 아니라 + liveness를 묻는 질문이라 `is`보다 `can` 계열 접두를 유지하는 쪽으로 + 사용자가 기욺, 구체 대안(`canRun` 등)은 다음에. +- `Brand`는 이번에도 다시 짚었지만 여전히 미정으로 재확인만 함. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터). `DI`/`D`와 +`canExecute`/`isAlive` 두 개만 용어 정리 라운드에 계속 남음 — +`question.md` 1순위/3순위 목록 참고. + +## 2026-08-09 세션 — `canBound` 이름 확정, `:Compute`의 `previous` 방어/스코핑 +명확화, Modifier 핸들러 계층 값 UB→error 전환, Tween `initValue`/`useTween` +논의 신설 + +사용자가 `.claude/question.md`를 훑다 나온 여러 짧은 질문/제안을 한 번에 +처리. 전부 `base/`/`research/`에 반영 완료: + +1. **`Bound` → `canBound(handle): boolean` 탑레벨 함수로 확정** — 사용자 + 제안("canExecute 같은 게 있으니 canBound로 넣어도 되지 않나"), raw + 불리언 필드를 직접 노출하는 대신 `canExecute`와 같은 결의 predicate + 함수로 감쌈. 동작 자체(leaf 부착과 `:Subscribe()`는 상호 배타, 위반 + 시 즉시 에러)는 안 바뀜 — `base/bind-system-plan.md` "이중 바인딩 + 금지" 절, `base/effect-plan.md`, `.claude/question.md` 반영. +2. **`:Compute(fn)`의 `previous` 인자 — 오버엔지니어링 의심 기각, 현재 + 설계 유지.** `pre-implementation-audit.md` 3-1이 "클로저 업밸류로 + 이미 되는 걸 별도 API로 만든 것 아니냐"고 의심했던 데 대해 사용자가 + 직접 반박 — 클로저 업밸류 대안은 IIFE로 감싸는 준비 비용이 오히려 + `previous`라는 인자 하나보다 무겁고 번거로움. **부수적으로 스코핑도 + 명확화**: 처음엔 `self.Cache`처럼 `previous`를 `self`(입력) 쪽에 + 얹는 모양이 제안됐으나, `self`는 `:Compute`의 입력(receiver)이라 + 같은 `self`에서 여러 `:Compute`가 갈라지는 팬아웃(`w:Compute(g1)`, + `w:Compute(g2)`)이 있으면 `self.Cache` 슬롯이 충돌한다는 문제를 + 검토 중 발견 — `previous`는 그 대신 "이 `:Compute` 호출 하나가 만든 + 결과 State 노드" 자신에 귀속되는 것으로 정리(State가 호출마다 새 + 노드를 만든다는 기존 온톨로지의 당연한 귀결이라 새 결정은 아님). + `base/bind-system-plan.md`의 "previous" 절, `pre-implementation-audit.md` + 3-1 반영. +3. **Modifier 필드에 핸들러 계층 값(Ref/PreRef/Observer/Effect/Slot/ + Modifier)이 들어오면 UB 대신 즉시 `error`로 확정.** 기존 + "권장 사용법은 아니지만 막을 이유도 없음 — 방어 로직 없는 UB"였던 + 것을, 이런 값의 실사용 case가 없다는 게 확인된 이상 조용한 UB보다 + 그 자리에서 막는 쪽이 낫다는 사용자 판단으로 전환 — 이미 있는 + `Brand` 기반 predicate(`isRef`/`isPreRef`/`isObserver`/`isEffect`/ + `isSlot`/`isModifier`)를 제네릭 `__index` setter가 최종 저장 직전에 + 확인하기만 하면 되므로 구현 비용 거의 0. `isSlot`/`isEffect` + predicate가 `Brand` 절에 명시적으로 없던 갭도 같이 보강. + `pre-implementation-audit.md`가 지적했던 "`State`는 방어, + Ref/Slot은 무방비"라는 비일관성이 이걸로 절반 해소(메커니즘 차이는 + 남지만 "막을 가치가 있다"는 판단은 통일) — `base/modifier-plan.md` + "핸들러 계층을 모름" 절, `base/bind-system-plan.md`의 `Brand` 절, + `pre-implementation-audit.md` 문서모순 절 반영. +4. **UI shorthand(UICorner/UIPadding/UIScale)가 Modifier 체이닝에서도 + 되는지 — 이미 확정돼 있던 것 재확인, 새 결정 없음.** `mod:UICorner(8)`은 + 그냥 제네릭 `__index` setter가 `UICorner` 필드를 채우는 것뿐이고, + 그 필드가 Modifier flatten을 거쳐 최종 props 테이블에 얹히든 + `Frame { UICorner = 8 }`처럼 순수 인라인으로 들어가든 UICorner + Handler 입장에선 구분이 없음 — `base/ui-shorthand-plan.md`에 이미 + 명시돼 있던 내용이라 문서 변경 없음. +5. **Tween `initValue`/`useTween` — 새 열린 논의 신설, 확정 아님.** + 사용자가 두 실사용 시나리오(다이얼로그 진입 애니메이션, 트윈 우회)를 + 제기 — `initValue`(첫 마운트 시 시작값을 세팅 후 목표값으로 트윈)는 + 재검토 끝에 필요성이 낮은 쪽으로 기움(재process 시 "최초 1회" 판별 + 문제가 있어 보임), `useTween = state`(트윈을 끄고 즉시 + 스냅)은 필요성은 확인됐으나 정확한 모양/문서화 방식이 전혀 안 + 정해짐 — `research/tween-plan.md`에 신규 절로 반영, M11 착수 전 + 나중 세션에서 마저 정리하기로 함. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 +설계 확정/보강이라 M0 착수 우선순위 자체는 그대로. + +**같은 세션 후속 — `State`도 UB 대신 명시적 `error`로 확정, +"핸들러 계층 값 → error" 원칙을 State/Source 쪽까지 완전히 통일.** +사용자 질문: "Modifier 필드"뿐 아니라 "State/Source 자체의 값이 +Modifier인 경우"(`State`, `modifier-plan.md` 7번)도 같은 +방식으로 막아도 되는지 — 확정. `isModifier` predicate를 +`Source:Set()`/Store 생성 시 eager `Source(default)`/State의 +`:Compute` 결과 캐싱 지점에서 확인해 런타임 `error`, 타입 차단(Luau +가능 여부 미검증)은 필수 방어선이 아니라 되면 좋은 보너스로 격하. +**Slot은 대조적으로 계속 허용** — 사용자 확인("slot은 당연히 가능함, +retract도 되는 애고 런타임 값이라"): Slot/Tag/Attribute/Tween 등은 +정상적으로 process/retract 재귀 경로를 타는 진짜 dispatch 참가자라 +State/Source 값으로 담겨도 기존 재귀 재-dispatch가 그대로 처리해줌 — +Modifier만 예외인 건 Modifier가 애초에 dispatch 경로 자체를 안 타는 +유일한 존재라서. `base/modifier-plan.md` 7번, `base/store-semantics.md` +"따름정리" 절, `research/pre-implementation-audit.md` 2-2/문서모순 절 +(완전 해소로 갱신), `.claude/question.md`, `ROADMAP.md` M7 반영 완료 — +이걸로 `pre-implementation-audit.md`가 지적했던 "State는 +방어, Ref/Slot은 무방비"라는 비일관성이 완전히 해소됨. + +**핸드오버 준비 완료** — 이번 대화(2026-08-08~09에 걸친 세션)에서 나온 +결정은 전부 `base/`/`research/`/`question.md`/`ROADMAP.md`에 반영, +문서 간 참조도 동기화 완료. **다음 세션 예고(사용자 지정)**: Slot과 +"State에서 Slot을 뽑아내는" 키 기반 동적 컬렉션 재조정(가칭 `Keyed`는 +탈락, 최종 이름 미정) — `.claude/question.md` 0번 "키 기반 동적 +컬렉션 재조정"이 이미 최우선 항목으로 잡혀있으니 그걸 이어서 보면 됨. + +## 2026-08-09 두 번째 세션 — `.claude/` 코퍼스 전체 stale 마커 감사·수정, +무효화된 인라인 서사 archive 이전 + +새 설계 결정 없음, 순수 문서 정리 세션. 서브에이전트 4개를 병렬로 띄워 +`.claude/` 전체(30여 개 문서 + `ROADMAP.md`/`HUMAN_TODO.md`/`SAFETY.md`/ +`archive/`)를 클러스터별로 감사, "이미 해소됐는데 미해결로 표시된 것"과 +"문서 간 모순"을 찾아 전부 직접 수정(커밋 전 상태 기준). 이어서 사용자 +요청으로 두 번째 라운드 — 뒤집혔거나 무효화된 설계가 정정 표시만 붙은 채 +본문에 전체 서술로 남아있는 곳을 찾아 기존 `archive/*-reversed.md`/ +`*-rejected.md` 컨벤션대로 이전(본문엔 결론+포인터만 남김), 컨텍스트 +낭비 방지 목적. 이것도 서브에이전트 3개 병렬 감사로 후보를 찾은 뒤 직접 +판단해 적용. + +**1차 라운드 — stale 마커/모순 수정 (7개 파일)**: +- `bind-system-plan.md`: `Ref` 이름이 "용어 정리 재검토 대상"으로 남아있던 + 것 — 2026-08-08 다섯 번째 세션에서 이미 확정됐는데 반영 안 됨 → 해소 + 표시로 정정. `component-composition-plan.md` §4-2 인용 오류(그 절은 + 실제로 다른 내용을 다룸 — Ref 필드 충돌 없음의 근거를 잘못 인용)와 + 폐기된 `StoreSource` 프록시와 혼동될 수 있는 "Source 양방향 프록시" + 표현도 정정. +- `documentation-content-map.md`: 폐기된 `myStore.key = value` 대입 + 문법이 예시로 남아있던 것(같은 파일 바로 다음 줄은 `:Set()`으로 옳게 + 써서 자기모순) → 정정. +- `ROADMAP.md`: 세션 인용 오류 2건(`git blame`으로 실제 커밋 시점 확인해 + 정정 — M0의 Source/State 서브타입 항목은 "세 번째 세션", M2의 + `LifetimeHandle` 순서 역전 항목은 "네 번째 세션"이 맞음), `Bound`/ + `None` "가칭" 표기가 이미 이름 확정됐는데 안 지워진 것 2건 정정, M6에 + Slot CRUD 의미론 확정 체크박스 누락돼 있던 것 추가(`pre-implementation-audit.md` + 우선순위1이 이미 지적했던 갭). +- `question.md`: `Tag`/`Added`/`Removed`/`Merged`가 `tag-plan.md`에서 + "여기서 추적 중"이라 주장했지만 실제로 빠져있던 것 추가. +- `archive/context-rejected.md`: 다른 archive 문서와 달리 base/ 포인터가 + 없던 것 보강. +- `additional-primitives-plan.md`: State/Observer를 "독립 프리미티브"로 + 잘못 묶은 표현 정정(확정된 분류는 Source/Store/Ref/Modifier/Slot/DI= + 독립 프리미티브, State/Observer=파생 데이터, 2026-08-08 두 번째 세션 + "Handler는 세 번째 카테고리" 절 참고). + +**2차 라운드 — 무효화된 인라인 서사를 archive로 이전 (신규 archive 4개)**: +- `archive/quad2-try-research-findings-rejected.md` — `bind-system-plan.md`에 + 60줄 넘게 남아있던 quad2-try(폐기된 이전 재작성 시도) 리서치 전문(OOP + 상속/커스텀 파서/Slot 빈 스텁/`Pipe` copy-on-write 4가지 확인된 죽은 + 접근 + Unix 파이프 영감이라는 원래 동기 서사)을 통째로 이전 — "반복 + 조사 금지" 결론과 `state(state)` 조합 모델 포인터만 본문에 남김. +- `archive/observer-cleanup-contract-rejected.md` — `effect-plan.md`의 + "Observer 자체에 React `useEffect`식 cleanup 반환 계약을 추가하는 안" + 기각 서술(코드 예시 포함) 이전. +- `archive/keyed-collection-state-method-rejected.md` — `additional-primitives-plan.md`의 + "키 기반 동적 컬렉션 재조정을 `state:Keyed(...)` State 메소드로 두려던" + 초안 기각 서술 이전(이 프리미티브 자체는 여전히 열린 질문 — 폼 팩터 + 결정 부분만 이전됨). +- `archive/debug-channel-replicatedstorage-rejected.md` — `debug-tooling-plan.md`의 + "`ReplicatedStorage` 자동 생성" 초안 기각 서술 이전. + +각 archive 파일은 기존 컨벤션(`[기각됨]` 제목, "현재 유효한 설계" 포인터, +`quadnomicon` 소재 메모)을 그대로 따름, `README.md`의 archive 인덱스도 +4개 항목 추가로 동기화 완료. + +**의도적으로 손 안 댄 것들**: `bind-system-plan.md`의 PreRef pre-pass +위치 관련 기각 서술, `lifecycle-pattern.md`의 `canExecute` 시그니처 +재정정 단락, `modifier-plan.md` 9-1(b)의 "동질적/이질적" 초안 — 전부 +현재 설계를 정당화하는 근거로 너무 밀착돼 있어서, 분리하면 "왜 이렇게 +안 했는지"가 같이 잘려나가 다음 에이전트가 같은 대안을 또 검토할 +위험이 있다고 판단해 그대로 둠. `documentation-content-map.md`가 최근 +추가된 5개 base 문서(`relate`/`blocker`/`effect`/`tag`/`attribute`-plan.md)의 +초심자/api/심화 분류를 아직 안 갖고 있는 것도 실제 설계 판단(콘텐츠 +분류)이 필요해 손 안 댐 — 문서 자신도 이미 "지금 당장 안 급함"이라고 +인정하고 있음. + +**다음 세션이 할 일**: 안 바뀜(`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부터). + +## 2026-08-09 여섯 번째 세션 — 여러 Slot이 형제로 섞일 때 순서 보장 완전 +해소(Length/Offset), `unbindLifetime` 신설 + +**출발점**: 사용자가 미래의 `quad-web`을 가정하며 `{ Slot, Element, Slot }`처럼 +Slot이 여럿 형제로 섞일 때 최종 순서를 어떻게 보장하는지 물음 — +2026-08-04부터 "Roblox 단일 백엔드로는 급하지 않음"으로 후순위 열려있던 +질문(`slot-plan.md` "여러 Slot이 섞일 때 순서 보장" 절)을 실제로 라이브 +설계해서 완전히 풀어낸 긴 단일 스레드. 시행착오를 거쳐 최종 수렴한 결론만 +정리(중간 대안들 — "구간 예약"/`:With`+`:Compute` 체인 — 은 채택 안 됨, +사용자가 제시한 "정확한 누적합 + 플랫 재계산 루프"가 최종안): + +- **핵심 전환**: "각 원소가 절대 위치를 계산해서 전파"가 아니라 "각 + 구조적 위치가 자기 앞 형제들의 개수 누적합(`offset`)만 알면 됨" — + Roblox `LayoutOrder`가 이미 `Instance.Parent` 물리 순서와 분리된 + 정수 프로퍼티라는 사실이 이 전환을 공짜로 성립시킴. +- **`Dispatch.setLength(inst,i,len:number|State)`/ + `Dispatch.setOffsetSource(inst,i,offset:Source|None)`** — + 둘 다 Handler→Dispatch 등록(push) 방향, array part의 **모든** number + 인덱스에 대해 반드시 호출(생략 UB — Handler 구현체 작성자만의 계약, + 일반 사용자 영향 없음). `recompute`는 매번 `1..N` 전체를 도는 단순 + 루프(N은 저작 시점에 고정된 배열 리터럴 길이라 무시 가능)로, 각 + `offset:Set()` 호출 앞에서만 `Get() ~= sum` 가드를 걸어 실제로 안 + 바뀐 위치의 캐스케이드(다운스트림 `LayoutOrder` 재적용)를 막음 — + 전체 순회 비용과 `Set` 캐스케이드 비용을 분리해서 후자만 최적화. +- **각 원소의 `LayoutOrder`는 `localIndex+offset`의 State를 기존 + store-bind 프로퍼티 바인딩에 그냥 얹는 것** — 이게 이 설계의 가장 + 큰 단순화 지점: "offset 변경 시 이미 마운트된 원소를 다시 써야 한다"는 + 요구가 새 push/observer 메커니즘 없이 **이미 있는** store-bind + 재실행 모델(`state:Observer(fn):Subscribe()`) 재사용만으로 공짜로 + 풀림. +- **`setLength`의 내부 Observer는 leaf-lifetime 경로(`bindLifetime`)를 + 씀, `:Subscribe()` 아님** — 이 Observer는 특정 leaf가 아니라 `inst` + 자신에 종속된 내부 배관이라, `inst` Destroy 시 자동으로 안 죽는 + `:Subscribe()` 경로는 안 맞음. `State` 교체처럼 `inst` 전체가 + 죽기 전에 특정 위치 하나만 조기 재등록해야 하는 경우를 위해 + **`unbindLifetime(inst,value)`을 `bindLifetime`/`canExecute`의 + 세 번째 짝으로 신설** — `Dispatch.setLength`가 gchold 내부 저장 + 구조(배열/키드 테이블)를 몰라도 이전 등록을 블랙박스로 해제할 수 + 있게 캡슐화. quad-roblox 구현 스케치도 gchold를 배열 대신 `value`를 + 키로 쓰는 테이블로 바꿔 `unbindLifetime`을 O(1)로(`gchold[value] = + nil`) — base 결정은 아니고 참고용 스케치. +- **동기 순서 요구사항**: Slot의 `rawAdd`는 `Length:Set(newCount)` + (다운스트림 offset/LayoutOrder 캐스케이드가 여기서 동기적으로 끝남) + 다음에 `element.Parent = target`을 호출 — Source:Set()이 옵저버 + 체인을 동기적으로 끝까지 도는 기존 모델 덕에 별도 배리어 없이 순서만 + 지키면 자동 성립. 안 지키면 Roblox의 실시간 `UIListLayout` reflow가 + 한 프레임 잘못된 순서를 노출할 위험. +- **`Slot.Length: State`가 CRUD/`:List` 여부와 무관하게 항상 + 노출되는 프리미티브 필드로 확정** — 사용자가 직접 "n개 검색됨" UI에도 + 쓸 수 있다고 지적, `setLength`가 내부적으로 읽는 값과 완전히 동일(두 + 용도를 겸함, 별도 State 아님). `:List`의 filter=진짜 Remove 확정 + 덕에 "Visible 토글은 안 잡힘"이 자연히 성립(새 캐비엇 아님). +- **웹 백엔드(quad-web) 일반화 — base 로직 100% 재사용, backend + Handler의 "offset 변경 시 할 일"만 달라짐**: DOM `insertBefore`는 + 물리적 삽입 시 뒤 형제를 자동으로 밀어주므로, offset이 바뀌어도 + 이미 마운트된 노드를 실제로 옮길 필요가 없음 — quad-web Handler는 + offset 변경 관측 시 no-op, 숫자는 그 위치가 **다음** insert/remove + 때 쓸 물리 인덱스로만 부기됨. 처음 검토했던 "구간 예약"(고정 gap)이나 + "앵커 기반 상대 삽입" 안보다 이 방식이 dense global rank라 두 종류 + 백엔드(순서-분리 프로퍼티형/물리-순서형) 모두에 더 직접적으로 맞음. +- **백로그로만 남김**: `Slot():Single(state, updateFn?)` — `:List`의 + key-map 없이 "0 또는 1"만 다루는 가벼운 편의 메소드, 상세 설계 미착수. + +**같은 세션 후속 — `bindLifetime`/`unbindLifetime`이 실제로 뭘 하는지, +`canBound`(이중 바인딩 금지)와의 관계를 여러 차례 시행착오 끝에 정확히 +확정.** `Dispatch.setLength`가 이전 Observer 등록을 정리할 때 뭘 불러야 +하는지를 두고 제가 세 번 틀렸다가 사용자가 매번 정정 — 경위와 최종 +결론을 구분해서 기록: + +1. **1차 시도(틀림)**: `unbindLifetime`이 `canExecute`를 즉시 `false`로 + 만들어준다고 서술 — 틀림. `gchold`(순수 GC 방지용 강참조 테이블)는 + `canExecute`가 보는 값(Observer/Effect의 `.Subscribed`, 또는 `inst`의 + 공유 `gcconn.Connected`) 어디에도 안 들어감, 완전히 무관한 테이블. +2. **2차 시도(틀림)**: 그래서 "`unbindLifetime`은 필요 없고 `:Unsubscribe()` + 만 쓰면 된다"로 후퇴 — 이것도 틀림. 사용자 정정: `:Subscribe()`/ + `:Unsubscribe()`는 **`inst`와 아예 무관한 전역/독립** Observer(모듈 + 최상위 디버그 print 등, leaf도 없고 특정 Instance에도 안 묶인 경우)를 + GC로부터 지키기 위한 **전역** 강참조 테이블(`SubscribedObservers[observer] + = true/nil`)일 뿐 — `Dispatch.setLength`의 Observer처럼 처음부터 + `inst` 하나에 종속된 내부 배관에는 원래부터 안 맞는 도구. "`inst` + 연관은 전부 `bindLifetime`/`unbindLifetime`으로"가 맞는 원칙. +3. **최종 확정**: 진짜 독립된 라이프사이클 경로는 **`:Subscribe()`(전역) + 와 `bindLifetime`(inst-scoped) 둘뿐** — "children 배열 leaf 부착"은 + 세 번째 경로가 아니라 **`bindLifetime` 호출 그 자체**(`Dispatch/ + Leaf.luau`가 Observer/Effect leaf를 매치하면 그 자리에서 + `bindLifetime(inst, v)`를 호출), 이걸 제가 처음에 "leaf 부착/ + `:Subscribe()`/`bindLifetime` 셋 다 상호 배타"로 잘못 일반화했다가 + 사용자가 "leaf 부착 자체가 bindLifetime을 호출하는 거라 동일 동작, + 상호배타는 아니다"로 정정. `canBound`의 내부 플래그도 새 필드가 + 아니라 **`canExecute`가 이미 보는 `.Subscribed` 그 자체** — + `bindLifetime`/`unbindLifetime`도(Observer/Effect 값에 한해) 이 + 필드를 세팅/해제해야 `bindLifetime`으로 등록된 Observer가 + `canExecute`에서 정상적으로 "살아있음"으로 인식됨. Effect는 내부적으로 + Observer를 조합하므로 이 확장을 몰라도 자동으로 커버(사용자 확인). +4. **부수 정리**: 이미 확정돼 있던 StoreBind의 자기 재실행 Observer + 예제(`observer:Subscribe()`)도 같은 이유로 틀렸던 것이었음 확인 — + `bindLifetime`/`unbindLifetime`으로 교체. "`:Unsubscribe()`는 자동 + (리프) 케이스에도 동일하게 씀"이라던 기존 서술도 같은 이유로 정정 + (리프/`bindLifetime` 경로의 조기 해제는 `unbindLifetime` 전용, + `:Unsubscribe()`는 `inst`를 몰라 대신 처리 못 함). + +전부 `base/bind-system-plan.md`(신규 "Length/Offset" 절, "이중 바인딩 +금지" 절 정정 — 2-way로 재확정, StoreBind 예제 교체)/`base/slot-plan.md` +(열린 질문 해소, `Slot.Length` 절, `:Single` 백로그 절)/`base/ +lifecycle-pattern.md`(`unbindLifetime` 추가 + `canBound`/`.Subscribed` +연동 반영)/`ROADMAP.md`(M2/M3/M6)/`.claude/question.md` 반영 완료. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 +설계 확정이라 M0 착수 우선순위 자체는 그대로. + +## 2026-08-09 일곱 번째 세션 — `Slot:List`의 `data:Observer(fn)` 구독도 +마운트 시점 lazy `bindLifetime`으로 확정 (Destroy 후 재실행 gap 해소) + +사용자가 "Slot이 마운트된 대상이 Destroy로 죽으면 `updateFn` 재실행이 +`canExecute`로 막히고 있는 게 맞냐"고 질문하며 시작 — 확인 결과 **두 메커니즘이 +다른 상태였음**: `Dispatch.setLength`(Length/Offset, 여섯 번째 세션 확정)는 +이미 정확히 그렇게 돼 있었지만(Slot 마운트 시점에 `bindLifetime(inst,observer)`), +`Slot:List`의 `data:Observer(fn)` 구독은 `:List()` 호출 그 자리에서 즉시 +만들어져(`inst`를 모르는 시점) `bindLifetime`이 전혀 안 걸려있던 실제 gap — +사용자가 정확히 캐치함. 사용자가 이어서 "실제로 Instance에 바인드되려 시도될 +때(=마운트 시점)로 구독 자체를 lazy하게 미루면 되지 않냐"고 제안, 검증 후 +확정. `base/slot-plan.md`(`:List`의 "구현"/"구독 시점" 절 재작성 + +"base/roblox 패키지 경계" 절 보강)/`ROADMAP.md`(M6)에 반영 완료: + +- **`Dispatch.setLength`가 이미 쓰던 패턴을 그대로 재사용, 새 메커니즘 + 없음.** `:List(data,updateFn,keyFn)`는 이제 설정만 저장하고 반환 — + 실제 `data:Observer(fn)` 구독과 최초 `reconcile`은 Slot 컨테이너 자신이 + 마운트되는 순간(`Dispatch/Slot.luau`의 `process(inst,k,self)`, `self._mounted`를 + 세팅하는 바로 그 자리)에 `activateList(self,inst)`가 수행. +- **`:List()`가 마운트 이후에 불리는 경우 — `self._mounted`면 즉시 활성화로 + 확정(사용자 확인, 세 가지 대안 중 1번).** 마운트는 1회성 이벤트라 순서가 + 뒤바뀌면 그 이벤트를 못 기다리므로, `:List()`가 `self._mounted`를 직접 + 확인해서 이미 참이면 그 자리에서 즉시 `activateList` — 호출 순서 제약을 + 새로 추가하지 않음. +- **canExecute와 "등록 즉시 1회 실행"의 관계를 사용자가 직접 짚어 확정**: + `data:Observer(fn)` 등록 시점(=`bindLifetime` 호출 *이전*)의 최초 1회 + 실행은 `canExecute`/`Subscribed` 게이팅과 무관하게 무조건 일어남 — 이 + 시점엔 아직 `Subscribed`가 안 세팅돼 `canExecute`를 물으면 거짓이겠지만, + 애초에 최초 실행은 게이팅 대상이 아니라서 상관없음(`Dispatch.setLength`가 + 이미 "등록 즉시 1회와 겹쳐도 무해"로 같은 구조를 갖고 있었음). `bindLifetime`은 + 등록 직후에 걸려 **이후** 재실행만 게이팅. +- **Destroy 이후 "재실행 막기"+"관측 자체를 관두기"가 새 코드 없이 한 번에 + 해결됨** — `inst` Destroy 시 `gcconn`이 죽어 `canExecute`가 거짓이 되고 + 향후 재실행이 no-op되는 동시에, `gchold`가 `Relate(inst)`(weak-keyed) + 아래 있어서 `inst`가 죽으면 그 안에 강참조로 잡혀있던 Observer/클로저 + (`mounted`/`userdata`/`keyIndex` 포함)가 전부 GC 대상이 됨 — 명시적 + 구독 해제 코드가 안 필요함, `lifecycle-pattern.md`의 "정리는 기본적으로 + GC에 위임" 원칙 그대로. +- **부수 관찰(메모만, 설계 아님)**: 사용자가 "`Relate`로 마운트된 대상을 + weak하게 구할 수도 있겠다"고 언급 — `bindLifetime`이 `Relate(inst)` 기반이라 + 나중에 "이 `inst`에 지금 뭐가 붙어있는가" 역조회가 같은 저장소로 가능해 + 보임, quad-debug 그래프 UX와 맞닿을 수 있음. 지금 설계 안 함, 필요성 + 확인되면 그때. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 +설계 확정이라 M0 착수 우선순위 자체는 그대로. + +## 2026-08-09 여덟 번째 세션 — `.claude/base/` 전체 중간검토(질문 모드), +실제 설계 결함 다수 발견·수정 + +사용자가 "이 프로젝트의 계획을 중간검토합니다. 각 요소들에 대해서 함수나 +클래스 등의 동작을 제가 확인 가능하게 리스팅해요... 질문 모드를 쓰면 +좋겠습니다"라고 요청 — 2026-08-04 6차 라운드 때 예고해뒀던 "다음 세션 +검증 패스"를 실제로 실행한 세션. 서브에이전트 6개를 병렬로 띄워 +`.claude/base/` 전체(15개 파일, 5296줄)를 클러스터별로 정독시켜 확정된 +API/동작을 file:line 인용과 함께 그라운딩된 리스팅으로 뽑아낸 뒤, 6개 +배치(Store/State/Source+Dispatch, Ref/PreRef+Brand+Length-Offset+생명주기, +Modifier, Slot, Tag/Attribute/UI shorthand+Blocker/Effect, 컴포넌트 +경계+아키텍처)로 나눠 각 배치를 텍스트로 보여주고 바로 `AskUserQuestion` +(문제없음/문제있음)으로 확인받는 방식으로 진행 — 문제 제기된 건 그 +자리에서 바로 문서에 반영(끝까지 미루지 않음). 총 24개 확인 질문 중 +약 1/3에서 실제 설계 결함이 나옴 — 전부 사용자가 구체적인 반례/Luau +시맨틱스를 근거로 지적한 것이라 전부 그대로 수용, 방어하지 않고 수정. + +**발견·수정된 것 (파일별)**: + +- **`base/bind-system-plan.md`** (가장 많이 고침): + - `Source(default)`/`Ref(default)`의 `default` 생략이 "선택"이라는 + 서술에 "`T`가 nilable일 때만 안전하다"는 캐비엇 누락 — 추가. + `Ref`는 `:Callback`이 등록 즉시 발화해서 이 문제가 더 잘 드러남. + - Dispatch 체인 절에 "`handler.process`를 `Dispatch.process` 없이 + 직접 호출하면 UB(체인 bookkeeping이 깨져 `retract`가 영영 안 + 불리거나 정합성이 무너짐)"라는 불변식이 안 적혀 있었음 — 추가. + - **Ref 콜백/대기자 배열의 소진 슬롯을 `None`에서 `nil`로 되돌림** — + 2026-08-07 열 번째 세션에 "구멍 있는 정수 키는 순회 순서가 깨진다"는 + 이유로 `None`으로 바꿨던 게 이 배열엔 안 맞는 처방이었음(사용자 + 지적): 이 배열은 순서가 안 중요해서 일반화 `for`가 구멍이 있어도 + 전부 방문하고, 오히려 `None`을 쓰면 슬롯이 영원히 안 비어서 + `:Wait()`마다 배열이 끝없이 길어지는 새 문제가 생김 — `nil`로 + 지우고 빈 슬롯을 재사용하는 등록 함수로 바꿈. PreRef pre-pass/ + Length-Offset의 `sourceList`는 순서가 실제로 중요해서 계속 `None`이 + 맞음 — 두 사례를 헷갈리지 않게 교차 참조로 명확히 구분. + - `.Value`가 평범한 hash 필드가 아니라 `__index`로 구현돼야 하는 + 이유(콜백 배열과 같은 테이블에 있으면 `T`가 함수/스레드일 때 콜백 + 처리 루프에 오분류될 위험) 추가. + - **`isRef`/`isPreRef`를 `isState`/`isSource`와 같은 상위-하위 합성 + 패턴으로 재정정** — 원래 "서로 배타적인 형제 브랜드"였는데, `Source`가 + `State`를 만족하듯 `PreRef`도 `Ref` 런타임을 재사용하는 관계라 + 같은 방향(하위=PreRef가 상위=Ref에 포함)으로 다뤄야 일관적이라는 + 지적 — `isPreRef`가 가장 구체적인 항등, `isRef`는 그 위에 얹힌 + 상위 개념. `(v=Ref)` children leaf 매치 핸들러는 이제 + `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 함. + - `NoneHandler`가 `k` 타입을 안 가리는데 왜 배열 파트 `None`(숫자 + 키)에 실제로 안 걸리는지 명확화(배열 파트 `None`은 애초에 + `Dispatch.process`를 안 타서 `NoneHandler`가 볼 기회 자체가 없음). + - `setLength`/`setOffsetSource`의 `None` 페어링 대상을 "Ref/PreRef + 등" 예시 목록에서 "그 배열 위치의 값 자체가 `None`인 모든 경우"로 + 명시적으로 확장, 둘이 항상 짝을 맞춰야 한다는 점도 재강조. + - `:Subscribe()`가 quad 전역 GC-native 원칙의 의도적 예외(참조를 + 다 놓아도 GC 안 되고 계속 실행됨)라는 경고가 없었음 — 추가, 용도도 + "완전히 top-level" 케이스로 좁혀 문서화. +- **`base/modifier-plan.md`**: 핸들러 계층 값 → error 체크가 `State`류 + "State/Source가 감싼 내부 값"까지는 못 잡는다는 한계 — 명시적 UB로 + 문서화(오버엔지니어링 방지, 실사용 위험 낮음). +- **`base/slot-plan.md`** (가장 큰 변경): **CRUD 식별 기준을 element + 레퍼런스에서 인덱스 기준으로 전환** — `Remove(index)`/ + `Extract(index, newElement?)`/`Move(oldIndex, newIndex)`. 원래 + "인덱스는 stale해진다"는 이유로 레퍼런스 기준을 택했는데, 실제로는 + 반대(호출부가 `Add` 리턴값을 안 담고 흘려버리는 경우가 흔함)가 더 + 큰 문제였음. **`ExtractAll()`/`Get(index)`/`IndexOf(element)` 신설** + (`Get`은 "YAGNI"로 드롭했던 걸 재추가). **`Extract(index, newElement?)` + 신설** — 교체가 필요하면 기존엔 Extract+Add 이중 O(n) 시프트가 + 필요했는데, 이제 O(1) 제자리 교체 가능(이전 element 반환). +- **`base/tag-plan.md`**: `TagHandler.retract`의 전체 삭제 동작이 + 정확히 `v == nil`일 때만 맞다는 전제를 `assert`로 명시(기존엔 "v를 + 안 봐도 됨"이라고만 서술돼 있어 조건이 암묵적이었음). +- **`base/attribute-plan.md`**, **`.claude/question.md`**: 타입 + 파라미터화(`Attribute<>` 제네릭 vs `BooleanAttribute`류 정적 + 패밀리) — "미확정"에서 **"둘 다 채택"으로 확정**(내부 구현 동일, + 호출부 표기만 다름). `=` 뒤 값 타입까지 narrowing되는지는 M0/M10 + 실측 필요(안 돼도 런타임 무관)로 명시. +- **`base/ui-shorthand-plan.md`**: `UICorner`/`UIPadding`/`UIScale`이 + 타입 생성 스크립트가 만드는 `FrameModifier`류 타입의 메소드 목록에도 + 포함돼야 한다는 체크리스트 항목 추가(런타임과 무관한 순수 타입 + 생성 디테일). +- **`base/effect-plan.md`**: `EffectHandle`이 내부 Observer를 필드로 + 강참조한다는 것, `bindLifetime`/`:Subscribe()` 둘 다 `state`가 있으면 + 내부 Observer까지 cascade해야 한다는 것(안 그러면 내부 Observer의 + `canExecute` 게이팅이 올바른 `inst`를 못 봄) — 재확인 후 명시화. +- **`base/component-composition-plan.md`** (Length/Offset 다음으로 많이 + 고침): + - **"리프 바인딩엔 Source가 좁은 예외"라는 서술이 틀림 — 정정.** + `local a = Source(true); Frame { Visible = a }; a:Set(false)`처럼 + Source를 리프에 직접 물리는 건 이미 확정된 "Source가 State를 + 구조적으로 만족" 원칙이 그대로 커버하는 정상 경로였음 — "State가 + 일반적"이라는 서술은 Source를 못 쓴다는 뜻이 아니라 "여러 값에서 + 파생된 계산 결과는 State일 수밖에 없다"는 통계적 경향 서술일 + 뿐이라고 재정정. + - `props.Modifier or None` 관용구의 `None` 근거 포인터가 Ref 콜백 + 배열 정정으로 깨질 뻔한 걸 교차 참조로 바로잡음(그 배열은 순서가 + 중요한 별개 케이스라 `None`이 계속 맞음). + - `Frame { Comp{} }`에서 `Comp`가 `Slot`을 반환하는 다중 루트 우회 + 경로가 새 배선 없이 그대로 작동함을 재확인(값이 컴포넌트 호출로 + 왔든 리터럴이든 디스패치 입장에선 구분 없음). +- **`ROADMAP.md`**: 위 `Ref` `None`→`nil`/`isRef`·`isPreRef` 변경사항 + 체크박스 동기화. + +**변경 없이 확인만 된 것**: `:With`/`:Compute` 체이닝, `None` 센티널 +기본 메커니즘, Length/Offset 전체, 이중 바인딩 금지/`Relate`/생명주기, +Modifier setter/Apply/Overridden 판단 기준, `Peek`/`isState`/`None` +setter 인자, Slot 요소 타입 제약/Extract portal/`Length`, `Slot:List` +시그니처(단, 캐스케이드 성능 이슈는 `keyFn` 명시 유도로 이미 문서화돼 +있어 추가 조치 불필요), List 구독 lazy 시점, Tag 값 모양/패키지 배치, +Blocker 전체, 소스트리/네이밍 컨벤션/Handler 3분류/테스트 전략/이식성 +원칙. + +**부수 기록**: `.claude/memory`(세션 간 영속 기억)의 협업 스타일 메모에 +이번 리뷰 진행 방식(에이전트 병렬 추출 → 배치별 텍스트+AskUserQuestion +즉시 확인 → 그 자리에서 바로 문서 반영)을 다음에 재사용할 패턴으로 +기록 완료. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션은 설계 +확정이 아니라 기존 확정 사항의 결함 수정이었지만, 결과적으로 M0 착수 +전 상태가 더 탄탄해졌을 뿐 우선순위 자체는 그대로. 이 중간검토가 +마지막 배치(6단계)까지 끝났는지, 사용자가 이어서 더 볼 부분이 있는지는 +다음 세션 시작 시 확인. + +## 2026-08-09 열두 번째 세션 — `.claude/luau-test/` 신설: M0 사전 검증 +스파이크 작성, 결과는 아직 미확인 + +M0가 공식적으로 짜야 할 스파이크(위 "지금 할 일" 1번, `ROADMAP.md` M0 +체크박스)와 지금까지 세션 로그 곳곳에 흩어져 있던 "실제 Luau로 부딪혀본 +적 없는 것"/"M0/M2 스파이크 검증 목록에 추가됨" 표시들을 한 곳에 모아, +사용자가 직접 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 돌려볼 +수 있는 독립 실행 스크립트 14개 + `README.md` 색인으로 만듦. 세 라운드에 +걸쳐 진행됨: + +1. **1차 작성** — 레포 루트 `luau-ignoreme/`(당시엔 git 자동 제외 폴더로 + 시작)에 M0 체크리스트 5개 항목(Store/State 다이아몬드 전파, Source가 + State를 구조적으로 만족하는 제네릭 타입, process/retract 재귀 디스패치, + 배열/해시 두 패스 순회, `props.Modifier or None` nil-hole 관용구) + + `Dispatch` 체인/`retractUnder` 다단 검증, `Relate`의 weak-table GC + 실측, `Modifier.Overridden` 서브타입 타입체크, Roblox 전용 + `bindLifetime`/`canExecute`/Attribute Instance 참조/`CollectionService` + 태그 확인까지 10개 파일 작성(01~10). +2. **2차 — 커밋 `f198fd9`("중간검토에서 발견된 설계 결함 다수 수정") 반영.** + 그 사이 사용자가 직접 `.claude/base/` 전체를 훑으며 여러 결함을 + 정정(위 절 참고) — 그 중 `02`(Ref 콜백/대기자 배열의 소진 센티널이 + `None`→`nil`로 되돌아간 것, 실제로 `None`을 쓰면 배열이 무한정 + 자라는 버그였음이 드러남)이 luau-test 내용과 정면으로 어긋나 전면 + 재작성(순서가 중요한 배열은 계속 `None`, 순서 무관+슬롯 재사용 + 필요한 배열은 `nil`이라는 최종 구분 + 무한 성장 버그의 정량적 + 재현까지 포함). `Modifier` UB→error 전환(11 신규)도 이 라운드에 + 같이 반영. 나머지 파일은 대조 결과 영향 없음을 서브에이전트+직접 + 문서 대조로 확인. +3. **3차 — 사용자 요청으로 "타입 관련 실측 필요, 특히 `luau-lsp`로 + 확인해야 할 것" 3개 추가(12~14).** base 문서 자신이 "실측 필요"라고 + 명시적으로 못박아둔 지점(`attribute-plan.md`의 `[Attribute<> + "name"] = value` 제네릭 DI 키가 실제로 값 타입을 좁혀주는지, 12번)과 + f198fd9에서 뒤집힌 결정(`isRef`/`isPreRef`가 이제 `Source`/`State`와 + 같은 포함 관계 — `PreRef`가 `Ref`의 하위 개념이 됨, `PreRef`가 + `Ref`를 구조적으로 만족하는지 타입체크까지 포함, 13번), 그리고 + 같은 세션에 새로 명시된 캐비엇(`Source(default)`/`Ref(default)`의 + `default` 생략은 `T`가 nilable일 때만 안전하다는 것을 함수 오버로드로 + 타입 레벨에서 실제로 막을 수 있는지, 14번)을 찾아 작성. +4. **폴더 이동 — `luau-ignoreme/` → `.claude/luau-test/`.** 사용자가 + "커밋해서 레포에 남기자"고 판단 — `*-ignoreme*` gitignore 패턴을 + 벗어나 일반 추적 대상으로 전환, `.claude/README.md`에 새 폴더 행 + 추가. 내용/역할은 안 바뀜, 경로 참조 문구만 동기화. + +**아직 아무것도 실행 안 됨 — 에이전트도 로컬에 `luau`/`luau-analyze`가 +없어서 직접 못 돌려봤고, 사용자가 다음에 `luau`/`luau-analyze`/ +`luau-lsp`/Roblox Studio로 직접 돌려보고 결과를 알려주기로 함.** 결과에 +따라 할 일: +- 전부 통과 → M0 실제 착수 시 이 스크립트들의 로직을 그대로 재사용하며 + 진행. +- 하나라도 걸림(특히 12/14의 타입 narrowing 실패, 07의 GC 신호 이상, + 10의 `warn` 발생, 13의 런타임 assert 실패) → 해당 `base/` 문서를 + 그 자리에서 정정. +- `.claude/luau-test/README.md`의 "결과 확인 후 할 일" 절에 파일별로 + 뭘 우선 확인해야 하는지 이미 적어둠 — 다음 세션은 그 응답을 + 대조하는 것부터 시작하면 됨. + +**다음 세션이 할 일**: 사용자가 luau-test 실행 결과를 갖고 오면 그것부터 +반영. 아직 없으면 `ROADMAP.md` M0 착수 우선순위는 그대로(위 "지금 할 일" +1번 참고) — 단, 이 폴더 결과를 먼저 확인하고 진행하는 게 순서. + +## 2026-08-10 세션 — `Slot:Add`가 삽입 인덱스를 반환하도록 확정, 범위 밖 +`index`는 clamp 대신 error + +짧은 세션. 사용자가 "`Slot:Add`/`Remove`가 어차피 void인데 삽입된 인덱스를 +반환해줘도 되지 않냐"고 제기 — 검토 후 채택, `base/slot-plan.md`의 CRUD +표/에러 조건 절에 반영 완료: + +- **`Slot:Add(element, index?): number`로 확정** — `index` 생략(끝에 추가) + 시 호출부가 실제 위치를 모르는 문제를 `Add`가 이미 계산해서 아는 값을 + 그냥 반환하는 것으로 공짜 해결(기존엔 `IndexOf`로 O(n) 역조회해야 했음). + `Move`/`Swap`이 void인 것과 안 부딪힘 — 그 둘은 호출부가 이미 위치를 + 알고 부르는 연산이라 새 정보가 없어 void인 거고, `Add`는 반대로 새 + 정보(계산된 위치)가 생기는 경우라 "반환값은 실제로 새로 알게 되는 + 정보만"이라는 같은 원칙의 연장. +- **`Add`의 `index`가 범위 밖(1..현재 개수+1)이면 즉시 `error()`, clamp + 안 함 — 사용자가 직접 근거 제시.** clamp는 "의도한 위치가 아닌데 조용히 + 성공하는" 찾기 힘든 버그 유형을 새로 만들 뿐이고, 이미 `Remove`/ + `Extract`/`Move`/`Swap` 전부가 범위 밖에서 즉시 에러인 fail-fast 톤과도 + 맞아야 함. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, luau-test 결과 확인이 +여전히 먼저) — 이번 세션은 이미 확정된 CRUD 표의 작은 갭 하나만 메운 +것이라 우선순위엔 영향 없음. + +## 2026-08-10 세션 — 동적 자식 추가/제거는 `Slot`/`state`만 정당, +그 외는 UB로 명문화(문서 갭 보강) + +사용자 질문에서 시작: Slot이 마운트한 객체 수를 `Length`/`Offset` +누적합으로 세는 방식(2026-08-09 여섯 번째 세션 확정)이 되면서, 이 카운팅을 +안 거치고 quad가 관리하는 부모 Instance에 외부에서 직접 `.Parent = inst`로 +자식을 끼워 넣는 게 UB로 문서화돼 있는지 확인 요청 — 검토 결과 **문서 +어디에도 명시돼 있지 않은 진짜 갭**이었음(기존 UB 목록엔 Handler 순환/ +이중 바인딩/`Dispatch.process` 우회 직접 호출/`setLength`·`setOffsetSource` +생략 등은 있었지만 이 케이스는 빠져있었음, 인접했던 "수동 Visible 토글은 +Length가 못 잡는 게 맞다"는 캐비엇은 이미 마운트된 element를 나중에 +숨기는 별개 시나리오라 이것과 다름). + +**확정**: 동적 자식 추가/제거의 유일한 정당 경로는 `Slot` 또는 +`state`류 store-bind 뿐 — 둘 다 그 위치의 Handler가 +`Dispatch.setLength`/`Dispatch.setOffsetSource`를 정확히 호출하는 것으로 +이미 보장돼 있음. 이 두 경로를 거치지 않고 quad가 마운트해둔 부모 +Instance에 직접 `.Parent =` 대입으로 자식을 넣거나 빼면 `lengthList`/ +`sourceList`가 그 변화를 전혀 몰라 `Length` 카운트와 형제 순서(offset) +계산이 조용히 어긋남 — 새 방어 로직 없이 UB로 문서화만 함(다른 UB +케이스들과 같은 톤). `base/bind-system-plan.md`("Length/Offset" 절 +말미)/`base/slot-plan.md`("Slot.Length" 절 말미)에 반영 완료. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, luau-test 결과 확인 +우선) — 이번 세션은 순수 문서 갭 보강이라 우선순위엔 영향 없음. + +## 2026-08-10 두 번째 세션 — Tween 구조 전면 재설계: 독립 Dispatch 핸들러 → +값-레벨 `Tween` 래퍼, `pre-implementation-audit.md` 1-1 완전 해소 + +사용자가 "트윈도 타입 문제가 있다 — 키 타입을 어떻게 하냐, Property +setter가 더 분발해서 `V`가 `isTween`이면 트윈 넣는 게 낫지 않냐"고 +제기하며 시작된 긴 단일 스레드. 기존 확정 모델(`[Tween(key, +tweenData...)] = storeValue`, `v`가 Store인 아무 `k`나 잡는 우선순위 +최상위 Dispatch 핸들러, 2026-08-04부터 확정)이 실은 +`research/pre-implementation-audit.md` 우선순위1-1이 이미 지적해뒀던 +구조적 모호함("애니메이션 없는 일반 반응형 프로퍼티 바인딩도 결국 +이름이 Tween인 파일을 거쳐가는가")을 안고 있었다는 걸 사용자 제안이 +정확히 겨냥한 것으로 드러나, 세션 내내 살을 붙여 완전히 재설계까지 +감. 구 모델은 `archive/tween-special-bind-key-reversed.md`로 이전, +`research/tween-plan.md`는 전면 재작성됨 — 상세 근거는 그 두 문서가 +최종 소스, 여기는 결정 흐름만 요약. + +**핵심 재설계**: State/Source 언랩(범용 `Dispatch/StoreBind.luau`, `k`/`v` +타입 무관)과 "이 값이 트윈 대상인가" 판단을 완전히 분리 — 후자는 별도 +Dispatch 핸들러/우선순위 경쟁이 아니라, **PropertyHandler가 `realv`를 +다 풀어낸 뒤 직접 하는 값-레벨 분기**(`isTween(realv)`)로 옮김. `Tween(opts: +{Value: T, ease...}) -> Tween`는 `Store({...})`와 같은 `Type(args)` +테이블 팩토리. 이 전환 하나로 우선순위1-1이 구조적으로 성립 불가능해짐 +— 범용 반응형 바인딩과 Tween이 애초에 같은 핸들러를 놓고 경쟁할 지점 +자체가 없어짐. + +**세션 중 순서대로 다듬어진 세부 결정들**(전부 최종적으로 `research/ +tween-plan.md`에 반영): + +1. **`Tween.Value`는 plain `T`만, 자체 반응 경로 없음** — 처음엔 `Value`도 + `T|State`를 받아 내부에 별도 Observer를 걸어야 하나 검토했으나, + 바깥 `:Compute`가 소스 변경마다 `Tween{Value=v,...}`를 통째로 재생성해 + StoreBind 재귀를 타므로 불필요함을 확인 — "같은 일 하는 두 번째 경로를 + 안 만든다" 원칙 재적용, `Tween = {Value: T, ease...}`로 확정. +2. **3-상태 릴레이션 슬롯으로 `hasBeenSet`과 활성 엔진 트윈 저장을 통합** — + `relate:GetStrong(inst,k)`가 `RobloxTween | true | nil` 중 하나: + `nil`=이 키 첫 세팅(애니메이션 없이 즉시 스냅, 기본값→목표값으로 + 날아오는 진입 애니메이션 버그 방지), `true`=세팅된 적 있음/활성 트윈 + 없음(정상 애니메이션 시작 가능), 엔진 객체=활성 트윈 있음(override + 정책대로 정리 먼저). 사용자가 직접 "hasBeenSet은 어차피 트윈에만 + 쓰이니 트윈 저장 슬롯 하나로 합치자"고 제안해 확정. +3. **활성 트윈이 있는데 plain 값이 들어오는 경우의 순서 규칙 신설** — + 먼저 override 정책대로 이전 트윈을 정리(멈추거나 끝냄)하고, **그 + 정리가 끝난 뒤에만** 새 값을 세팅. 순서가 뒤바뀌면 이전 트윈의 다음 + 인터폴레이션 프레임이 방금 세팅한 값을 덮어쓸 위험이 있어서 — 사용자가 + 직접 짚은 시퀀싱 버그. +4. **타입 대수: `T' = T | Tween` 치환만으로 해결, 새 타입 기계 불필요** — + 지금 프로퍼티류 필드가 전부 `T | State` 모양으로 통일돼 있는데, + 여기서 "이 필드의 `T`" 자체를 `T' = T | Tween`로 치환하면 자동으로 + `T | Tween | State>`가 나옴 — Modifier/State/Source/ + StoreBind 코드엔 `Tween` 인지 로직이 전혀 안 들어감(StoreBind는 원래도 + 페이로드 타입에 무관하게 `isState`만 봄), 타입 생성 스크립트가 필드 + 타입 문자열만 바꾸면 끝. 사용자가 직접 대수적으로 도출. +5. **`useTween` 우회 — 새 옵션 필드 없이 해소.** 이전엔 + `Tween{useTween=state}`처럼 별도 필드가 필요하다고 열어뒀으나, + 2026-08-07 일곱 번째 세션에 확정된 `state:Apply(factory)` sugar 위에 + `someState:Apply(Animate(reduceMotion, opts))`처럼 조건부로 `Tween{...}`를 + 씌우거나 안 씌우는 `:Compute` 팩토리 하나로 공짜로 풀림 — 새 base + 메커니즘 불필요. +6. **`Animate` 콤비네이터는 quad-roblox 유틸, base 프리미티브 아님** — + `Tween` 값 타입/`isTween`만 base(`quad-base/Tween.luau`)에 있고, + `Animate`는 이미 있는 `:Apply`/`:Compute`/`Tween{...}`를 조합한 편의 + 함수라 나중에 이름/모양을 자유롭게 바꿔도 base 계약에 영향 없음 — + 사용자 표현으로 "저비용 고효율 엔지니어링". +7. **패키지 경계는 Tag가 이미 밟은 분리를 그대로 재사용** — quad-base: + `Tween.luau`(값 타입만). quad-roblox: `Handlers/Property.luau`(isTween + 분기+3-상태 저장+override 정책 흡수, 기존 독립 `Handlers/Tween.luau` + 폐기) + `Animate.luau`(신규). +8. **부수 발견 — `retract`가 Tween 경로에서 사실상 필요 없어짐.** 기존 + 모델에서 "Tween↔프로퍼티 핸들러 타입 교체"가 `retract`가 실제로 + 의미를 갖는 유일한 대표 예시였는데, 새 모델에선 매치되는 Dispatch + 핸들러가 항상 PropertyHandler 하나뿐이라 이 케이스 자체가 사라짐 — + 트윈 취소/전환은 PropertyHandler 내부의 3-상태 슬롯 로직으로 대체(Tag가 + 이미 하는 "diff는 process 자신이 담당" 패턴과 같은 모양). `retract` + 필드 자체는 "생략 불가" 일반 규칙이라 여전히 정의는 해두되, 실제 + 호출은 거의 없어짐. Tag(핸들러 타입이 실제로 바뀌게 재설계되어 + `retract`가 필요해진 사례)와 Tween(핸들러 타입이 안 바뀌게 재설계되어 + `retract` 필요성이 사라진 사례)을 서로 반대 방향 사례로 archive 문서에 + 대비해둠 — quadnomicon 소재. +9. **`Tween`의 핸들러 계층 분류 정정** — `base/modifier-plan.md`가 + 원래 Tween을 Slot/Tag/Attribute와 같은 "dispatch 참가자"(State/Source에 + 담겨도 재귀 재-dispatch가 그대로 처리해주는 부류)로 묶어뒀는데, 이제 + `Tween`는 `process`/`retract`가 없는 순수 raw 데이터 값이라 `None`과 + 같은 분류로 정정 — Modifier 필드/`State`가 막는 "핸들러 + 계층 값 → error" 규칙에 안 걸린다는 결론은 안 바뀜(그냥 raw 값이라서로 + 근거가 바뀜). +10. **`initValue`(진입 애니메이션)와 hasBeenSet의 긴장 관계를 기록만 + 해둠** — hasBeenSet이 "첫 세팅은 무조건 스냅"을 기본 동작으로 + 확정했으므로, 나중에 `initValue`(다이얼로그 슬라이드-인 등)가 실제로 + 필요해지면 이 억제 동작을 명시적으로 우회하는 방법까지 같이 설계해야 + 함 — 새 결정 없이 상충 관계만 `research/tween-plan.md`에 남김. + +**여전히 열려있는 것**(M11 착수 시 확정): override 정책 4가지 중 기본값 +Cancel 외 세 옵션의 정확한 키 이름/시그니처, Tween→plain 전환에 5번째 +옵션이 필요한지, 트윈 옵션 값 모양(`TweenInfo` 그대로 vs 편의 필드 — 소견은 +후자), `Animate`의 정확한 시그니처(조건/옵션 분리 vs 통합). + +**반영된 파일**: `research/tween-plan.md`(전면 재작성, 최종 소스), +`archive/tween-special-bind-key-reversed.md`(신규, 구 모델 원문+역전 +사유), `base/bind-system-plan.md`(9곳 — "확정된 디스패치 모델"의 대표 +예시를 Tween에서 StoreBind로, `retract` 필요 패턴 예시를 Tag로 교체, +"Dispatch는 프리미티브가 아니다"/"Dispatch 체인" 절의 핸들러 목록에서 +Tween 제거, `None` 센티널 절 예시 갱신, Ref/Brand 절 문구 정정), +`base/architecture.md`(소스트리 — `quad-base/Tween.luau` 신설, +`quad-roblox/Handlers/Tween.luau` 삭제하고 `Handlers/Property.luau` +설명에 흡수, `Animate.luau` 신설), `base/modifier-plan.md`(핸들러 계층 +분류에서 Tween 제외 + 신규 "10. `Tween`와의 타입 합성" 절), +`research/pre-implementation-audit.md`(우선순위1-1 해소 표시), +`ROADMAP.md`(M11 전면 재작성, M2/M7 체크박스 갱신), `.claude/question.md`/ +`.claude/README.md`(참조 동기화). + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, luau-test 결과 확인 +우선) — 이번 세션도 순수 설계 확정/문서 정리라 M0 착수 우선순위 자체는 +그대로. M11 착수 시점이 오면 위 "여전히 열려있는 것" 목록부터 확인. + +## 2026-08-10 세 번째 세션 — `OnChange` 특수 키 신설: `GetPropertyChangedSignal` +바인딩, 제네릭 없이 확정 + +사용자가 `GetPropertyChangedSignal`을 어떻게 다뤄야 할지 물으며 시작 — +이벤트는 이미 평범한 문자열 키(`inst[key]`가 곧 Signal)로 확정돼 있는데, +`GetPropertyChangedSignal(name)`은 프로퍼티 이름을 인자로 받아야 하고 그 +이름이 "값 세팅" 키 네임스페이스와 겹쳐서 같은 패턴을 못 씀 — 사용자가 +`[OnChange "PropertyName"] = function(v) ... end` 형태(타입은 콜백에 직접 +명시)와 "`OnChange.PropertyName`을 전부 코드 생성"하는 대안 두 가지를 +제시하며 의견을 물음. + +**확정**: `OnChange(name)` DI 키 팩토리, **제네릭 타입 파라미터 없음** — +`Attribute<>`와 달리 콜백 파라미터 타입은 호출부가 직접 명시. 이미 +확정된 "이벤트 바인딩은 콜백 시그니처를 Luau가 검증 못 하는 대가를 +받아들인다"는 결정과 같은 급의 트레이드오프라 새로 정당화할 것 없다는 게 +근거 — 오히려 `Attribute`처럼 제네릭으로 정확히 맞추려 들면 이벤트 키보다 +더 엄격한 걸 요구하는 셈이라 일관성이 깨짐. 프로퍼티별 정적 코드 생성 안은 +기각(`archive/onchange-per-property-codegen-rejected.md`) — Attribute의 +정적 지름길은 타입 파라미터가 좁고 고정된 프리미티브 집합(~10종)에서만 +와서 지름길 후보가 유한한데, 프로퍼티는 클래스마다 이름/타입 집합이 전부 +달라 (클래스 수 × 프로퍼티 수) 규모로 폭발함 — 겉보기엔 비슷한 절충 +같지만 실제로는 규모가 다른 문제. + +패키지 경계는 **전부 quad-roblox**(`Handlers/OnChange.luau`, `Attribute`와 +같은 배치 — `GetPropertyChangedSignal` 자체가 Roblox 엔진 API라 base에 둘 +값 타입/API 레이어가 없음). `process`는 `GetPropertyChangedSignal(name):Connect`, +`retract`는 `:Disconnect` — 일반 `Handlers/Event.luau`와 같은 결. **`State` +지원도 새 메커니즘 없이 해소** — 이미 확정된 "이벤트도 store-bind 가능 +(`false`로 disconnect)" 메커니즘이 그대로 적용됨, `OnChangeHandler`는 +`process`/`retract`만 구현하면 범용 `Dispatch/StoreBind.luau`가 State/Source +언랩+재귀 재-dispatch를 알아서 해줌. + +`base/onchange-plan.md`(신규)/`base/bind-system-plan.md`(이벤트 네이밍 절 +교차 참조)/`base/architecture.md`(소스트리 `Handlers/OnChange.luau`)/ +`ROADMAP.md`(M10 제목·체크박스)/`.claude/README.md`(base/archive 인덱스) +전부 반영 완료. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, luau-test 결과 확인 +우선) — 이번 세션도 순수 설계 확정이라 M0 착수 우선순위 자체는 그대로. diff --git a/HUMAN_TODO.md b/HUMAN_TODO.md new file mode 100644 index 0000000..aa4e051 --- /dev/null +++ b/HUMAN_TODO.md @@ -0,0 +1,65 @@ +# HUMAN_TODO — 사용자(사람)만 할 수 있는 일 + +에이전트가 못 하거나(로컬 GUI 조작, 외부 계정/기기 필요) 사용자의 결정이 필요해서 +멈춰둔 것만 여기 모음. 설계 질문(디폴트 이미 잡아두고 진행 중, 급하지 않음)은 +`.claude/question.md`에 따로 있음 — 그건 안 봐도 진행에 지장 없고, 이 문서는 실제로 +사람이 뭔가 눌러/타이핑해야 풀리는 것만 담음. + +## 1. Roblox Studio에 MCP로 연결 (테스트 자동화용) + +Roblox가 2026-02부터 Studio에 **MCP 서버를 내장**했음 — 예전처럼 Rust로 직접 +`studio-rust-mcp-server`를 빌드할 필요 없이 Studio 자체 베타 기능으로 켜면 됨. + +**설정 방법** (사용자가 로컬에서 직접): +1. Roblox Studio → File → Studio Settings → Beta Features → **MCP Server** 활성화 +2. 기본적으로 `localhost:3004`에서 리슨 시작함 +3. Claude Code의 MCP 클라이언트 설정(`.mcp.json` 등)에 이 로컬 서버를 추가 — + 이 설정 파일 자체는 내가 대신 만들어줄 수 있으니, Studio에서 베타 기능만 켜고 + "여기 프로젝트에 연결해줘"라고 말해주면 이어서 진행함. +4. 노출되는 툴: `create_object`, `set_property`, `set_script_source`, + `execute_luau` 등 — Undo 히스토리를 존중해서 Ctrl+Z로 되돌릴 수 있음(안전망 있음). + +**주의(사용자가 이미 말한 것)**: Roblox Studio는 잘 죽는 편 — 죽었을 때 살리려고 +위험한 명령을 반복 시도하지 않을 것이고, 그런 날엔 MCP 없이 할 수 있는 작업만 +하거나 대기함. 이 안전 원칙은 `CLAUDE.md`에도 적어둠. + +**해야 할 일**: 테스트용 place 파일(빈 place 하나, 또는 `quad/test.project.json` +기반 rojo 싱크 대상)을 열어서 베타 기능만 켜주면 됨. 이후 MCP 서버 설정 파일 +작성/연결 확인은 내가 진행 가능. + +**`SAFETY.md` 제약**: Studio는 메인 계정이 아닌 별도 계정으로만 사용하기로 +되어 있음 — 계정 전환 여부를 알려주기 전까지는 MCP 연결을 진행하지 않고 대기함. + +## 0. (SAFETY.md) Git 원격 저장소 계정 마련 + +`SAFETY.md`에 따라 이 레포는 GitHub 등 외부 호스팅에 올리지 않기로 되어 있음 — +모델(나)의 git 작업 공간은 사용자가 마련해줄 제한 계정 전용이어야 함(예: +git.qwreey.moe에 제한된 계정 생성). 로컬 git 저장소는 이미 초기화 + 초기 +커밋까지 해뒀음(원격 없음) — 원격을 추가하고 싶으면 그 계정 정보를 알려줄 것, +그 전까지는 로컬 커밋만 계속 쌓아둠. + +## 2. 자율 작업 루프/스케줄 설정 + +사용자가 잠들어 있는 동안에도 계획된 TODO를 이어서 진행하길 원한다는 요청이 있었음 +(`req.md` 참고). 이건 세션을 넘어 지속되는 자동 실행이라 다음 중 하나를 사용자가 +직접 트리거해야 함(에이전트가 임의로 크론/무인 실행을 켜는 건 파급力이 커서 먼저 +확인받는 게 맞다고 판단해 보류함): + +- `/loop` — 지금 세션 안에서 일정 주기로 스스로 다음 작업을 이어가게 함(사용자 + 대화 종료 전까지). 간단한 자율 반복엔 이걸로 충분. +- `/schedule` — 진짜 cron 스케줄로 별도 클라우드 에이전트를 반복 실행(예: 매일 + 새벽에 큐에 있는 다음 plan 문서 하나씩 처리). 무인 상태로 더 오래/여러 날에 + 걸쳐 진행하고 싶다면 이쪽. + +원하는 주기/범위를 알려주면 그에 맞춰 설정해줄 수 있음. 어떤 걸 골라도, 진행한 +내용은 항상 `.claude/`에 자기 문서화(완료 표시, 다음 TODO 갱신)해서 다음 세션이나 +사람이 바로 이어받을 수 있게 할 것. + +## 3. `.claude/question.md` 검토 (급하지 않음) + +디자인 결정 중 Lua/Roblox 엔진에 대한 깊은 경험이 필요한 것들은 합리적 기본값으로 +진행하면서 `.claude/question.md`에 모아두는 중. 깨어있을 때 훑어보고 기본값이 +마음에 안 드는 것만 답해주면 됨 — 막고 있는 항목은 없음. + +--- +Sources (MCP 리서치): [Roblox/studio-rust-mcp-server](https://github.com/Roblox/studio-rust-mcp-server), [How to Connect Claude Code to Roblox Studio — Clauder Navi](https://www.clauder-navi.com/en/claude-roblox-studio) diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..850619f --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,397 @@ +# ROADMAP.md + +quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기 안 옮겨적음 — +`.claude/base/`가 소스, 여긴 **순서와 진행 상황**만. 마일스톤 시작할 때 +체크박스를 세분화해서 늘려도 되고, 끝나면 체크만 하면 됨 — 살아있는 문서. + +**2026-08-04 세션에 준비만 해둔 상태로 신설, 이후 여러 세션에 걸쳐 설계가 +확정될 때마다 각 마일스톤 체크박스가 계속 갱신돼왔음 — 그래도 아직 M0 +자체는 시작 안 함.** 다음 세션은 바로 M0부터. + +## M0 — 스켈레톤 + 기술검증 (스파이크, "진짜" 마일스톤 아님) + +최종 소스 트리를 그대로 만들기 전에, 지금까지 **추론만으로 확정하고 실제 +Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검증하는 단계 — +`.claude/base/` 감사에서 나온 결론(2026-08-04). 여기서 뭔가 어긋나면 +`architecture.md`/`bind-system-plan.md` 등을 이 시점에 고치는 게 정상 — +실패가 아니라 이 단계의 목적. + +- [ ] Store/State push-invalidate → pull-recompute propagation을 실제로 + 짜보기(다이아몬드 의존성 케이스 포함 — 이미 invalid면 전파 중단되는지) +- [ ] Source가 State를 구조적으로 만족하는 제네릭 타입(`:Compute(self: + Source, ...) -> State`류, self 타이핑 + State 참조 혼합)이 + Luau 솔버에서 안전하게 추론되는지 확인(2026-08-06 세 번째 세션, + `base/store-semantics.md` "Source가 State를 만족함" 절 — `State`가 + `Source`를 참조하지 않는 단방향 의존으로 두면 위험한 상호 재귀는 + 피할 수 있어 보이나 실제 검증 전엔 확정 아님) +- [ ] `process`/`retract` 재귀 재-process 디스패치를 실제로 짜보기(store-bind + 핸들러 하나 + `isHandlable` 우선순위 스캔 포함) +- [ ] props 순회의 "배열 파트 먼저, 해시 파트 나중" 두 패스 계약이 실제 + Luau 테이블에서 관찰한 대로 동작하는지 확인, `PreRef` pre-pass + + 일반 `Ref`의 위치 기반 순서까지 최소 스파이크로 검증 + (2026-08-07 세 번째 세션, `base/bind-system-plan.md` "`phase` 옵션 + 폐기 → 위치로 표현, `PreRef` 신설" 절) — **PreRef pre-pass의 소진은 + `nil`이 아니라 `None`으로(2026-08-07 열 번째 세션 정정, 사용자가 + Luau REPL로 반례 제시 — 키가 듬성듬성해지면 순회가 index 순서를 + 전혀 안 지킴), 이 경로는 nil-hole 위험이 아예 없도록 설계됐으므로 + "구멍 있는 테이블 순회" 자체를 검증할 필요는 없어짐(같은 절 "왜 + `nil`이 아니라 `None`인가" 참고)** +- [ ] `props.Modifier`/`props.Ref` named-parameter로 받는 컴포넌트 하나 작성, + `export type Params = {...}`로 타입 체크되는지 확인 + (`component-composition-plan.md` 최종 결론 1번) — **`props.Modifier or + None`/`props.Ref or None` 관용구(2026-08-07 열 번째 세션 확정, + `component-composition-plan.md` "필수 관용구" 절)로 nil-hole을 막는 + 케이스를 반드시 포함할 것 — caller가 Modifier/Ref를 안 넘겨도 + `or None`이 항상 non-nil을 보장하므로 `{nil, ref, child}`류 리터럴 + 구멍 자체가 안 생김(`research/pre-implementation-audit.md` 1-5). + M0에서 검증할 것은 "어떻게 막을지"가 아니라 이 관용구가 실제로 + 타입 체크/런타임 양쪽에서 문제없이 동작하는지** +- [ ] 위 과정에서 소스 트리/메커니즘 문서에 고칠 부분이 생기면 그 자리에서 + `.claude/base/` 갱신 + +**통과 기준**: 세 개 다 Luau에서 자연스럽게 짜이는 게 확인되면 M1 진행. +안 되면 여기서 관련 `base/` 문서부터 고치고 재시도. + +## M1 — 실제 스캐폴딩 + +- [ ] `quad-base/`, `quad-roblox/` 폴더 + 각 `wally.toml` +- [ ] 루트 `default.project.json`, `.luaurc`(`architecture.md` "구현 착수: + 소스 트리 구조 확정" 절 그대로) +- [ ] quad-base용 최소 mock 테스트 하네스(Vide `test/mock.luau` 선례, 순수 + `luau` CLI, `architecture.md` "테스트 전략" 절 참고) +- [ ] 이 시점부터 `.claude/qa-request/`/`.claude/archive/` 폴더 실사용 시작 + +## M2 — 디스패치 엔진 + +- [ ] `Dispatch/init.luau` — `Dispatch.getHandler(inst,k,v): Handler?`(순수 + 스캔, `isHandlable`+`priority`) / `Dispatch.process(inst,k,v)`(오케 + 스트레이터: getHandler → 이전 담당자와 다르면 그 `retract` → 새 + 핸들러의 `.process`) / `Dispatch.addHandler(handler)`(레지스트리 + 등록, quad-roblox가 팩토리 뮤테이션 시점에 호출) / `Dispatch.drive(inst, + flattened)`(배열→해시 두 패스 순회하며 각 `(k,v)`에 `process` 호출 — + `bind-system-plan.md`의 `None` 센티널 절, 2026-08-07 여덟 번째 세션에 + 네이밍 확정). "이 키를 지금 누가 담당 중인가" bookkeeping은 + `Dispatch.drive`가 아니라 `Dispatch.process` 호출 자체 내부에서 + 갱신할 것(재귀 재-process 시에도 자연히 갱신되게 — 안 그러면 재귀 + 재디스패치를 쓰는 케이스(`StoreBind`, `NoneHandler`)에서 매 + 사이클 불필요한 `retract`가 반복 호출될 위험) +- [ ] `Handler.luau`(핸들러 계약 타입: `isHandlable(inst,k,v)`/`priority`/ + `process`/`retract` — `isHandlable`도 `inst`를 받도록 확정, 2026-08-07 + 여덟 번째 세션 정정) +- [ ] `Brand.luau`(공유 weak-key 레지스트리, `Brand.set(x,tag)`/ + `Brand.get(x)` — `isState`뿐 아니라 `isObserver`/`isEffect`/`isTag`/ + `isAttribute`/`isTween`/`isBlocker`/`isSource`/`isStore`/`isSlot`/ + `isRef`/`isPreRef`/`isModifier`(2026-08-07 열 번째 세션 추가 — 원래 + 태그 목록에서 빠져있었음. **[정정, 2026-08-09 열한 번째 세션]** + `isRef`/`isPreRef`는 `isState`처럼 상위-하위 관계로 재정정됨 — + `isPreRef`가 가장 구체적인 항등, `isRef`는 그 위에 얹혀 + `isPreRef`도 `true`로 통과시킴(PreRef가 Ref 런타임을 재사용하는 + 것과 정합). `(v=Ref)` children leaf 매치 핸들러는 이제 + `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 함. `isModifier`는 + 여전히 단순 항등, 상위 개념 없음) 전부의 기반. `isNone`만 예외로 + 레지스트리 없이 `x == None` 항등 비교 — `bind-system-plan.md`의 + `Brand` 절, 2026-08-07 여덟 번째 세션 신설) +- [ ] `Relate.luau`(전체가 quad-base, 순수 Lua — `base/relate-plan.md`) — + `Relate()` 비싱글톤 생성자, `:SetWeak`/`:GetWeak`/`:SetStrong`/`:GetStrong`. + `inst`(첫 인자)는 항상 weak, `StrongMap`/`WeakMap` 서브테이블은 lazy + 생성(첫 `Set` 호출 시에만), `WeakMap`은 공유 메타테이블(`{__mode="v"}`) + 재사용 — 구 `base.perInstanceState(inst)`/`PerInstanceState.luau`를 + 대체(2026-08-08 세션 신설). +- [ ] `LifetimeHandle.luau` **인터페이스만**(`bindLifetime(inst,value)`/ + `unbindLifetime(inst,value)`/`canExecute(inst,value)` 탑레벨 함수 + 타입 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래 M8에만 + 있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의 `canExecute`)이 + 이미 이 인터페이스를 전제로 서술돼 있어 로드맵 순서가 역전돼 + 있었음(`pre-implementation-audit.md` 우선순위1-9, `question.md` + 2번 — 2026-08-07 네 번째 세션에 반영). + **`canExecute`는 `(inst, value) -> boolean`으로 재확정(2026-08-08 + 세션, `(handle)` 단일 인자 서술을 대체)** — Observer/Effect는 자기 + `Subscribed` 상태를 먼저 확인, 그 다음 `inst`의 공유 gcconn(`Relate`로 + 저장)의 `.Connected`를 봄. **`unbindLifetime(inst,value)` 추가 + (2026-08-09 여섯 번째 세션)** — `inst` 전체 죽기 전에 특정 값 하나만 + 조기 해제(`Dispatch.setLength`가 State 재등록 시 이전 Observer를 + 정리하는 데 씀), gchold 내부 구조를 호출부가 몰라도 되게 캡슐화. + `bindLifetime`/`unbindLifetime`/`canExecute` 셋 다 네임스페이스 + 없이 탑레벨 함수로 export(`Dispatch.xxx`류 시스템 네임싱과 구분, + `isState`/`isObserver`와 같은 1급 프리미티브 취급) — `base/ + lifecycle-pattern.md`의 "`bindLifetime`/`canExecute`/`unbindLifetime` + — 확정" 절 참고. **Observer/Effect 값에는 `bindLifetime`/ + `unbindLifetime`도 M3의 `canBound` 게이트를 확인/세팅** — children + 배열 leaf 부착이 실제로는 `bindLifetime` 호출이라서(M3 체크박스 + 참고, 구현 순서상 M2가 M3의 `canBound`를 참조하게 됨에 유의) +- [ ] `Dispatch.setLength(inst,i,len:number|State)`/ + `Dispatch.setOffsetSource(inst,i,offset:Source|None)` — + array part 형제 순서 보장(Length/Offset 누적합→`LayoutOrder` 리액티브 + 바인딩), array part 모든 number 인덱스에 대해 둘 다 호출 필수(생략 + UB, Handler 구현체 작성자만의 계약) — `recompute`는 leaf-lifetime + 경로(`bindLifetime`/`unbindLifetime`)로 등록, `:Subscribe()` 아님 + (2026-08-09 여섯 번째 세션, `base/bind-system-plan.md` "Length/Offset" + 절 — `base/slot-plan.md` "여러 Slot이 섞일 때 순서 보장" 해소) +- [ ] 핸들러 계약 검증: `retract` 필드가 없는 핸들러를 등록하면 리뷰/린트에서 + 걸러내기(no-op이라도 필드 자체는 항상 정의 — `Dispatch.process`가 핸들러 + 교체 시 nil 체크 없이 호출, `base/bind-system-plan.md` "핸들러 계약" + 절, 2026-08-08 세션) +- [ ] `Dispatch/Leaf.luau` — `(i:number, v=Ref/Observer/PreRef)` children-array + leaf 매칭 Handler, `StoreBind.luau`와 같은 층위(범용/엔진무관) — + quad-base 소속으로 확정(2026-08-08 두 번째 세션, `base/ + bind-system-plan.md` "Dispatch는 프리미티브가 아니다" 절) +- [ ] `chains`(Relate 기반, `{[inst(weak)]={[k]={handler,handler,...} + (strong 순서 배열)}}`) + `Dispatch.retractUnder(inst,k,keep,v)` — + 재귀 재-dispatch(StoreBind/NoneHandler)의 retract를 다단 + 체인까지 정확히 전파(2026-08-08 세 번째 세션, `base/ + bind-system-plan.md` "Dispatch 체인" 절 — `pre-implementation-audit.md` + 1-2번 "이전 핸들러 추적" 항목 해소). `Dispatch.process`가 매치될 + 때마다 체인에 push하는 것도 이 항목에 포함 +- [ ] mock 대상 테스트 + +## M3 — Store/State/Source + +- [ ] `Source.luau`/`State.luau`/`Store.luau` +- [ ] `store.key` dot-access 타입 추론 확인 +- [ ] `Blocker.luau`(`base/blocker-plan.md` 참고 — 여러 Source를 + 한꺼번에 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive, + State와 밀접히 연관돼 있어 같은 마일스톤에서 개발) +- [ ] `state:Apply(factory)`(`base/bind-system-plan.md` "`state:Apply(factory)`" + 절, 2026-08-07 일곱 번째 세션) — `factory(self)`를 체이닝 문법으로 + 부르는 순수 설탕, `factory: (State) -> U): U`로 열린 타입. Source도 + 기존 `:With`/`:Compute` 델리게이션에 얹혀 자동 포함 +- [ ] `state:Observer(fn)` — children 배열 leaf 참가자, **등록 즉시 1회 + 실행 확정**(`base/bind-system-plan.md`의 Observer 절), `isObserver` + 판별자, canExecute 게이팅, `:Subscribe()`/`:Unsubscribe()` +- [ ] `Effect(fn, state?)`(`base/effect-plan.md`) — `state` 생략 시 설치 + 1회+leaf 사망 시 확정 정리, `state` 지정 시 내부적으로 + `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React + `useEffect` 동형). Observer 구현 이후에 착수(의존 관계). + `EffectHandle:Subscribe()`/`:Unsubscribe()`도 추가(leaf 없이 쓰는 + 모듈/스크립트 레벨 Effect) — `:Unsubscribe()`는 Observer와 달리 + 마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션) +- [ ] Observer/Effect 이중 바인딩 금지 — `canBound(handle)` predicate로 + `:Subscribe()`(전역)와 `bindLifetime`(inst-scoped, leaf 부착도 + 내부적으로 이걸 호출)이 동시에 걸리면 즉시 `error`(`base/ + bind-system-plan.md` "이중 바인딩 금지" 절, 2026-08-07 일곱 번째 + 세션 신설, 이름은 2026-08-09 세션에 `canBound`로 확정, 같은 날 + 여섯 번째 세션에서 "leaf 부착=bindLifetime 호출"로 정정 — 진짜 + 독립 경로는 둘뿐). `canBound`의 내부 플래그는 `canExecute`가 보는 + `.Subscribed`와 같은 필드 — `bindLifetime`/`unbindLifetime`도 + (Observer/Effect 값에 한해) 이 필드를 세팅/해제 +- [ ] mock 대상 테스트 + +## M4 — 첫 end-to-end 반응형 업데이트 + +- [ ] `Dispatch/StoreBind.luau`(재귀 재실행 로직, 엔진 무관 — 재-dispatch + 전 `Dispatch.retractUnder(inst,k,self,realv)` 호출 필수, `base/ + bind-system-plan.md` "Dispatch 체인" 절) +- [ ] mock 대상으로 "store 값 바꾸면 `process`가 다시 호출된다" + + "이전 값이 다른 타입이면 이전 핸들러의 `retract`가 정확히 불린다" + 확인 + +## M5 — quad-roblox 최소 프로바이더 + +- [ ] `RobloxFactory.luau`(BaseModule 뮤테이션, 재호출 가드) +- [ ] `DI/init.luau`(제네릭 생성자 + ~25개 정적 필드) +- [ ] `Handlers/Property.luau`, `Handlers/InstanceChild.luau` +- [ ] 실제 Roblox에서 첫 `Frame{...}` 렌더 확인 — **Studio 작업이라 + `HUMAN_TODO.md` 1번(계정 분리) 먼저 되어야 진행 가능, `SAFETY.md` 준수** + +## M6 — Slot + +- [x] **"여러 Slot이 형제로 섞일 때 순서 보장" 해소**(2026-08-09 여섯 번째 + 세션) — `Dispatch.setLength`/`setOffsetSource` 메커니즘, `base/ + bind-system-plan.md` "Length/Offset" 절. `Slot.Length: State`도 + 이때 확정(CRUD/`:List` 여부 무관 항상 노출, 순서 계산과 "n개 검색됨" + UI 둘 다 겸함) — 구현 시 이 두 API를 `:List`/CRUD의 `raw*`가 호출. +- [x] **Slot의 `Add`/`Remove`/`Extract`/`ExtractAll`/`Clear`/`Move`/`Swap`/ + `Get`/`IndexOf` CRUD 의미론 확정** (2026-08-09 세 번째 세션, 2026-08-09 + 열한 번째 세션에 식별 기준 재정정) — 에러 조건까지 전부 확정 + (`base/slot-plan.md` "CRUD API 확정"). "재마운트 시 즉시 throw"도 + `isMounted` 이중 추적 분리로 개별 element/Slot 컨테이너 기준이 + 명확히 갈림(같은 문서 "`isMounted` 이중 추적 분리" 절). + **[정정, 2026-08-09 열한 번째 세션] 식별 기준을 element 레퍼런스에서 + 인덱스 기준으로 전환** — `Remove(index)`/`Extract(index, newElement?)` + (O(n) 또는 O(1))/`Move(oldIndex, newIndex)`(O(n))/`Swap(indexA, + indexB)`(O(1)) 전부 인덱스, `Add(element, index?)`만 element를 직접 + 받음(새로 넣는 대상이라 참조가 당연히 있음). 호출부가 `Add` 리턴값을 + 안 담고 흘려버리는 경우가 흔해 레퍼런스 기준이 오히려 실사용과 안 + 맞았음 — 레퍼런스만 있으면 `IndexOf(element): number?`로 인덱스를 + 구하면 됨. `ExtractAll(): {T}`(Clear의 비파괴 버전), `Get(index): T?` + 신설(`get`/`set` 드롭했던 걸 재추가). `Extract(index, newElement?)` — + `newElement` 지정 시 O(1) 제자리 교체(이전 element 반환), 기존엔 + 교체하려면 Extract+Add 이중 O(n) 시프트가 필요했던 문제 해결. 공개 + mutate 메소드 전부 "가드 확인 + `raw*` 위임" 얇은 wrapper(`Get`/ + `IndexOf`는 순수 읽기라 가드 대상 아님). 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(...)`" 절) 구현. + **`data:Observer(fn)` 구독은 `:List()` 호출 시점이 아니라 Slot + 마운트 시점까지 lazy — `Dispatch.setLength`와 같은 패턴으로 + `bindLifetime(inst,observer)`(마운트 이후 `:List()`가 불리면 + `self._mounted` 확인 후 즉시 활성화)** (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 + +- [ ] `Modifier()`(빈 인스턴스 바닥 생성자, 2026-08-07 열 번째 세션 + 명시 — `Source(default)`/`Ref(default)`/`Store({defaults})`와 같은 + `Type(args)` 팩토리 관습, `modifier-plan.md` 3번) +- [ ] flatten-before-dispatch(`isModifier(v)`로 배열 항목 중 Modifier만 + 판별해 필드 merge, 나머지는 안 건드리고 통과 — 2026-08-07 열 번째 + 세션 명시, `modifier-plan.md` 1번), immutable `table.clone` 체이닝 +- [ ] `Modifier.Overridden(mod1, mod2, ...)`(이름 확정, 구 `Merge`→`Override`, + 2026-08-08 세션) — 필드별 raw 덮어쓰기, 특별한 State/함수 분기 + 불필요(`modifier-plan.md` 9번) +- [ ] `Overridden`가 서브타입 관계인 서로 다른 Modifier 타입(예: `FrameModifier`/ + `GuiObjectModifier`)을 섞을 때의 타입 시그니처 실 Luau 테스트 + (`modifier-plan.md` 9-2번, 미검증 — 안 되면 일단 `Overridden(...: any): + any`로 느슨하게 열어두고 이 항목으로 되돌아올 것) +- [ ] `State` 조합에 `isModifier` 기반 명시적 error 적용 + (`modifier-plan.md` 7번, 2026-08-09 세션 확정) — 타입 차단은 + 되면 좋은 보너스로 선택 검증(필수 아님) +- [ ] `:Apply(factory)` 팩토리 함수 체이닝(`modifier-plan.md` 8번, 예약 키 + `Apply`가 제네릭 `__index` 필드 setter와 안 겹치는지 확인) +- [ ] `:Peek<>(key): T|State|nil` 필드 읽기 접근자 + + `isState(x)`/`isSource(x): boolean`(`Brand` 공유 레지스트리 기반 — + `modifier-plan.md` 9번, `bind-system-plan.md`의 `Brand` 절, M2의 + `Brand.luau`에 이미 구현돼 있어야 함) +- [ ] 인라인 키/setter로 modifier 필드를 명시적으로 지우는 `None` 센티널 + (이름 확정, `modifier-plan.md` 2-1번, `Peek` 반환 타입에 `None` 추가) + + 이를 `nil`로 재디스패치하는 base 내장 `NoneHandler` + (`bind-system-plan.md`의 `None` 센티널 절, M2 dispatch 엔진의 + "이전 매치 핸들러 추적" 항목과 함께 구현 — `StoreBind` 핸들러와 + 동일한 재귀 재디스패치 패턴이라 새 메커니즘 아님) — 확정 완료 +- [ ] 프로퍼티류 필드 타입에 `T' = T | Tween` 치환 반영(타입 생성 + 스크립트가 `Position: UDim2` 자리를 `UDim2 | Tween`로 만들면 + 끝, Modifier 런타임/`__index` 자체엔 변경 없음 — `modifier-plan.md` + 10번, 2026-08-10 세션, `research/tween-plan.md`) + +## M8 — Ref + +- [ ] `Ref.luau`(`.Value` 읽기 전용 필드 + `:Set(value)`/`:Callback(fn)`/ + `:Wait(thread?)`, 전부 self 반환) + `PreRef.luau`(별도 파일, Ref + 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, + 위치 무관 호이스팅 pre-pass — `base/bind-system-plan.md` "`phase` + 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절 + "API 모양" 절) +- [ ] `(v=Ref)` 매치 핸들러 — children 배열의 숫자 슬롯에 놓인 + `Ref(default)` 인스턴스를 인식해 바인드(별도 `CreatedRef` 래퍼 + 없음 — 이름 자체가 폐기됨, 아래 참고) +- [ ] `PreRef` pre-pass — 새 `Dispatch.*` 함수 없이 `Dispatch.drive(inst, + flattened)` 자신이 두 패스(배열→해시) 루프 전에 배열 파트를 훑어 + `PreRef` 항목만 fire(Dispatch.process/getHandler 우회하는 raw 루프, + `flatten` 함수에는 얹지 않음 — 재바인드 시 flatten 재호출 가능성과 + 충돌하므로 기각). 복수 `PreRef`는 배열 index 순서 그대로(별도 규칙 + 없음). fire된 슬롯은 그 자리에서 소진(`None` 처리, `nil` 아님 — + 2026-08-07 열 번째 세션 정정)해 이어지는 정상 두 패스에 다시 노출 + 안 되게 함 — `base/bind-system-plan.md` "PreRef" 절 +- [ ] `PreRef` 동적 경로 가드 Handler — `{isHandlable = v is PreRef, + process = error(...)}` 형태로 정상 우선순위 레지스트리에 등록, + `NoneHandler`와 같은 "한 값 종류 전담" 패턴. 리터럴 배열 경로는 + pre-pass가 이미 소진시키므로 이 Handler가 매치되면 곧 타입 차단을 + 우회한 버그라는 뜻 — 같은 절 참고 +- [ ] Ref 콜백/대기자 실행 루프(`type(v)=="thread"`면 + `coroutine.resume(v, self)`+`nil`로 소진(2026-08-09 열한 번째 + 세션 최종 정정 — 순서 안 중요 + 슬롯 재사용 위해 `None`이 아닌 + `nil`, `table.insert` 대신 빈 슬롯 선형 탐색 등록), 함수면 + `v(value)` 호출+유지 — 같은 배열 하나로 통합). `:Wait(thread?)`는 + `thread`가 `nil`이면 + `coroutine.running()` 캡처+yield, 있으면 등록만 하고 즉시 `self` + 반환(남의 thread를 여기서 대신 정지시킬 수 없어서) +- [ ] `LifetimeHandle` quad-roblox 실제 구현 — `bindLifetime`/`canExecute` + 본체(`GetPropertyChangedSignal("ClassName")` 연결 트릭으로 gcconn 확보, + `Relate:SetStrong`으로 gcconn/gchold 저장 — 인터페이스 자체는 M2로 + 이동됨, `Relate` 자체는 quad-base라 quad-roblox 쪽 재구현 없음) + +## M9 — 컴포넌트 합성 레이어 + +- [ ] 플레인 함수 컴포넌트 관례 문서화/예제 +- [ ] `props.Modifier`/`props.Ref` 전달 관례를 정식 컴포넌트로 검증(M0 + 스파이크를 정식화) + +## M10 — Event / OnChange / Attribute / Tag + +- [ ] `Handlers/Event.luau`(`ReflectionService` 기반 자동 판별) +- [ ] `Handlers/OnChange.luau`(`OnChange(name)` DI 키 팩토리+Handler, + `GetPropertyChangedSignal` 바인딩 — 제네릭 없이 콜백 타입은 인라인 + 명시, `base/onchange-plan.md`, 2026-08-10 세션 확정) +- [ ] `Handlers/Attribute.luau`(`base/attribute-plan.md` — 메커니즘/`None`/ + `retract` 불필요 확정, 타입 파라미터화 이름만 착수 전 확인) +- [ ] `Tag.luau`(quad-base — 값 타입+immutable clone 체이닝: `Tag(...)`/ + `:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`, `base/tag-plan.md` + — 2026-08-08 세 번째 세션 array-part 값 객체로 재설계, 구 해시 파트 + 모델은 `archive/tag-hash-key-model-reversed.md`) +- [ ] `Handlers/Tag.luau`(quad-roblox — `CollectionService` process/retract + 글루만, `isHandlable`은 `isTag(v)`. `retract`는 이제 의미 있음(값이 + Tag가 아니게 되면 전체 삭제), 같은 Tag끼리 바뀌는 diff는 `process`가 + 자기 `Relate` 저장분과 비교해서 처리 — 전체 삭제 후 재생성 금지(랙 + 유발), `base/tag-plan.md` 참고) + +## M11 — Tween + +**[2026-08-10 세션, 구조 재설계]** 독립 Dispatch 핸들러 모델에서 값-레벨 +`Tween` 래퍼 모델로 전환 — 상세는 `research/tween-plan.md`(전면 +재작성), 구 모델은 `archive/tween-special-bind-key-reversed.md`. + +- [ ] `quad-base/Tween.luau`(값 타입만 — `Tween(opts)` 팩토리, `isTween`/ + `TweenTag` Brand, `Value: T` plain만 받고 State 재귀 없음) +- [ ] `Handlers/Property.luau`에 `isTween(realv)` 분기 추가(기존 + `Handlers/Tween.luau` 독립 핸들러는 폐기) + 3-상태 릴레이션 슬롯 + (`RobloxTween | true | nil` — `nil`=첫 세팅, `true`=세팅됨/트윈 + 없음, 엔진 객체=활성 트윈) + 첫 세팅은 무조건 애니메이션 없이 + 스냅(hasBeenSet 억제) + 활성 트윈 정리는 override 정책 완료 후에만 + 새 값 세팅(순서 뒤바뀌면 트윈 다음 프레임이 방금 세팅한 값을 덮어씀) +- [ ] override 정책 4가지(기본 Cancel/Override/Delete-restart/ + Move-to-end-restart) 중 기본값 외 옵션 키 이름/시그니처 확정, + Tween→plain 전환에 5번째 옵션이 필요한지 확인 +- [ ] `research/tween-plan.md` "트윈 옵션 값 모양" 확정(TweenInfo 그대로 + vs 편의 필드+기본값 — 소견은 후자) +- [ ] `quad-roblox/Animate.luau`(편의 콤비네이터 — `:Apply`로 체이닝, + `useTween` 우회는 이걸로 자연히 커버되어 별도 옵션 필드 불필요, + 정확한 시그니처는 M11에서 확정) +- [ ] `initValue`(진입 애니메이션) 필요성 재검토 — 필요해지면 hasBeenSet + 억제 동작과의 상충부터 풀 것(`research/tween-plan.md` 참고) + +## 특정 마일스톤에 안 묶이고 병행 가능 + +- [ ] 용어 정리 스윕 — `State`/`DI`/`Slot` 등(`PerInstanceState`는 `Relate`로 + 대체·해소됨) — `.claude/question.md` 1번, 최종 이름 확정되는 대로 + 아무 시점에나 +- [ ] 각 마일스톤 완료 시 `.claude/qa-request/`/`.claude/archive/`에 기록, + 필요하면 `CLAUDE.md` "최근 세션 요약"도 갱신 + +## 백로그 (스코프 밖 — 필요성이 실제로 드러나면 그때 설계) + +- [ ] `research/existing-instance-bind-plan.md` — Modifier 정적 flatten과 + 긴장 관계 있음, 재검토 시 그 문서부터 다시 볼 것 +- [ ] 범용 렌더 디버깅 도구로서의 quad-mock(Tween mock 등 동적 동작 포함, + M1의 quad-base 테스트용 mock과는 별개) +- [ ] `quad-debug`/`quad-debug-roblox-plugin` — 실물 Instance→코드 위치 + 역추적 Studio 플러그인(`research/debug-tooling-plan.md`). 위 + quad-mock과 목적이 다름(오프라인 검증 vs 실시간 라이브 관찰) — + 단 trace 이벤트 스키마를 공유할 여지는 있음, 그 문서 참고. M2/M3/M5 + 구현 시 훅 확장 지점만 고려해두면 이 항목 자체는 지금 착수 불필요. +- [ ] v1 마이그레이션 가이드 + `objectListClass.__newIndex` 오타 기능 재현 테스트 +- [ ] Slot 형제 순서 보장(다중 백엔드 관점) — Roblox만이면 급하지 않음 diff --git a/SAFETY.md b/SAFETY.md new file mode 100644 index 0000000..cb2a7a5 --- /dev/null +++ b/SAFETY.md @@ -0,0 +1,2 @@ +- Git 레포지토에리 대해 접근을 주지 마세요: 모델이 어디로 튈 지 모르기에, github 에는 작업하지 않음. 그래서 모델의 작업 공간은 오직 git.qwreey.moe(예를 들어) 에 제한적 계정으로 계정을 생성해 주는것으로 사용. +- Code-docker 활용: 컨테이너 환경으로 한번 격리하여 사용할 수 있도록 유도. studio 환경은 메인 계정이 아닌 다른 계정을 사용하여야함