Merge remote-tracking branch 'origin/quad2'

This commit is contained in:
qwreey 2026-08-10 01:17:08 +09:00
commit 113b160b81
Signed by: qwreey
GPG key ID: D28DB79297A214BD
64 changed files with 14775 additions and 0 deletions

92
.claude/README.md Normal file
View file

@ -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<T>()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`, `keyFn` 생략 시 index 기본값, `updateFn<UD>(item, index, userdata, prev)`가 매 사이클 호출되며 `prev` 재사용/`nil` 반환으로 filter 지원, `Source` 생성은 `updateFn``userdata`로 직접 관리, `userdata`는 GC-native 값만 허용·명시적 cleanup 필요한 값은 UB)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[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<T>` vs `BooleanAttribute`류)만 미확정 |
| `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)``GetPropertyChangedSignal` 바인딩 전용 DI 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State<function>`은 기존 이벤트 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<T>` 래퍼(PropertyHandler가 소비)로 전환, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`로 이전. 3-상태 릴레이션 슬롯(`RobloxTween\|true\|nil`)으로 hasBeenSet 억제, `T'=T\|Tween<T>` 타입 치환, `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<T>` 래퍼 모델로 완전히 대체됨(`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` — 위 문서들로 나누기 전의 원본, 참고용 백업이니 그대로 둘 것

View file

@ -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`가 둘 다 존재, 전자는 집합 멤버십, 후자는 단순 항등)이
최종 소스.

View file

@ -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`에서 "콜스택/코루틴 스코프로 상태를
표현하려던 시도가 왜 항상 위험한가"의 구체 사례로 쓰기 좋음.

View file

@ -0,0 +1,66 @@
# [기각됨] `Context`(트리 하위 암묵 전파) + 대안이었던 "레이어드 Store"
**기각 일시**: 2026-08-06~07. **현재 유효한 설계**: 명시적 타입 강제
Store 전달(`props.Theme: Store<Theme>`처럼 컴포넌트가 필요한 걸 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<Theme>`)가 "몰래 안 줘서 죽는다"보다 나은 실패
모드 — Slot 기반 저작 모델(부모가 자손을 직접 구성)에서도 이런 요청은
자연스럽게 props로 흐른다.
## 결론
Context, 레이어드 Store 둘 다 프리미티브로 만들지 않음. "왜 Context가
없는가"(명시적 Store 전달이 이미 그 역할을 하고, 타입 강제가 Context의
실패 모드보다 안전하다는 논증)는 `quadnomicon` 에세이 후보로 등록
(`research/documentation-content-map.md` 참고).

View file

@ -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`에 둬야
할 이유도 애초에 없었음.

View file

@ -0,0 +1,26 @@
# [기각됨] 키 기반 동적 컬렉션 재조정 프리미티브를 `state:Keyed(...)` State 메소드로 두는 안
**기각 일시**: `research/additional-primitives-plan.md` 논의 도중(날짜 미상,
"이전 라운드"로만 기록). **현재 유효한 설계**: `research/
additional-primitives-plan.md` "폼 팩터" 절 — 이 프리미티브는 자유 함수로
두고, `data` 인자가 plain array/table이든 `State<array>`/`Source<array>`든
둘 다 받는 폴리모픽 컨벤션(quad의 leaf 프로퍼티가 이미 쓰는 "리터럴 또는
State 둘 다" 관례와 동일)을 따름. 이름 자체는 아직 미정 — 이 프리미티브의
최종 설계는 여전히 열려있는 질문이라 `question.md`/`additional-primitives-plan.md`
본문을 계속 참고할 것, 이 파일은 "왜 State 메소드가 아닌가"라는 기각
사유만 보존.
## 무엇을 검토했었나
"독립 프리미티브 vs 원천 종속 파생 데이터" 원칙(Source/Ref/Store/Modifier=
독립 프리미티브, State/Observer=원천에 종속된 파생 데이터)을 그대로 적용해,
이 재조정 프리미티브도 `state:Keyed(...)`처럼 **State의 메소드**로 두자는
제안.
## 기각 이유
Source를 안 쓰는 컴포넌트는 이 메소드 자체에 접근을 못 함 — 정적 데이터
(한 번만 렌더되고 다시는 안 바뀌는 리스트)를 키 기반으로 렌더링하고 싶을
뿐인데, 굳이 `Source(정적데이터)`로 감싸야 접근 가능하다면 불필요한 강제.
"독립 프리미티브 vs 파생 데이터" 원칙 자체가 틀린 게 아니라, 이 프리미티브가
그 분류 어디에도 깔끔히 안 맞는 케이스였다는 게 재검토 결과.

View file

@ -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하는 지금 방식이 버그 클래스를 균일하게
없애는 유일한 방법 — 확정 유지.

View file

@ -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` 참고).

View file

@ -0,0 +1,38 @@
# [기각됨] `OnChange.PropertyName` 프로퍼티별 정적 코드 생성
**기각 일시**: 2026-08-10. **현재 유효한 설계**: `base/onchange-plan.md`
`OnChange(name)` 제네릭 없는 단일 팩토리, 콜백 파라미터 타입은 호출부가
직접 명시.
## 무엇이었나
`Attribute``[Attribute<<T>> "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`만 유별나게 정적
타입 안전성을 추구할 근거가 약함.

View file

@ -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` 체이닝으로
충분히 달성된 것으로 최종 판단.

View file

@ -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 이벤트의 동기 발화)를 만나 타입
분리로 귀결된 사례"로 쓰기 좋음.

View file

@ -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<T> | State<T>` 유니온으로 받고, 내부에서
> 타입 체크만 하면 됨(Source면 인스턴스 변경 이벤트에 걸어 역방향 쓰기까지
> 처리, State면 읽기만) — `isHandlable`/`priority`/`process`/`retract` 4종
> 계약에 5번째 항목을 추가할 필요 없음.
## 역전된 이유
`store.key`의 타입 문제를 다시 들여다보다가 드러남: Store의 정적 타입을
`{key: State<number>}`류 평범한 레코드 타입으로 지으면(2026-08-04 3차
라운드에서 확정했던 방식) Luau 구조적 타이핑상 그 필드의 읽기/쓰기 타입이
같아야 하는데, 실제 쓰기(`store.key = value`, raw `number`)와 읽기
(`State<number>`)가 서로 다른 타입이라 애초부터 정합적이지 않았음 —
`StoreSource` 프록시 설계 시점엔 이 비대칭을 못 잡았던 것.
이걸 풀려고 대안(store를 `store.key`/`store.state.key`로 네임스페이스
분리하는 안, `RefSource<T>`라는 store 전용 타입을 새로 만드는 안)을
검토하다가, 더 근본적인 재구성으로 수렴: **Source 자체가 구조적으로
State를 만족**(Svelte `Writable<T> extends Readable<T>`와 같은 모양)
하게 만들면,애초에 "Source를 감추고 별도 프록시로 감쌀" 이유 자체가
없어짐 — Store가 내부에 갖고 있는 진짜 Source 객체를 그대로 돌려줘도
안전하고, 오히려 프록시 객체를 매번 만들거나 캐싱하는 계층 하나가 통째로
사라져서 더 쌈.
## 이전 것과 지금 것의 차이
| | `StoreSource`(역전됨) | Source가 State를 만족(현재) |
|---|---|---|
| `store.key`가 반환하는 것 | 별도 프록시 `StoreSource`(Source 인터페이스를 구현한 wrapper) | 진짜 `Source<T>` 객체 그대로 |
| 쓰기 문법 | `store.key = value`(`__newindex`) | `store.key:Set(value)` |
| 캐싱 | "매번 새로 만듦"(State와 같은 정책) | Store 생성 시 이미 만들어둔 Source를 그대로 반환 — 별도 캐싱 메커니즘 자체가 불필요 |
| 핸들러가 Source를 받는 방법 | `Source<T> \| State<T>` 명시적 유니온 | `State<T>` 하나만 받아도 서브타입 호환으로 자동 통과, 런타임에 구분하고 싶으면 `isSource`류 판별자 |
| 타입 정합성 | 레코드 필드 읽기/쓰기 타입 비대칭 문제가 잠재해 있었음(발견 안 된 채로 확정됐었음) | 필드 타입이 항상 `Source<T>`로 대칭 — 쓰기가 메소드 호출로 옮겨가며 문제 자체가 해소됨 |
## 왜 완전히 헛수고는 아니었나
`StoreSource`가 짚었던 문제의식(Store 내부 표현을 그대로 노출하면 안
될 수 있다, Source와 State는 다른 쓰기 권한을 가져야 한다)은 여전히
유효함 — "State/Source 경계 규칙: 파생이면 읽기전용, 원본이면 쓰기 가능"
원칙(`base/component-composition-plan.md` 2번)은 살아남았고, 결론만
"별도 프록시 타입을 만든다"에서 "Source 자체를 서브타입으로 승격한다"로
바뀐 것. 이 반전 자체가 "타입이 없던 v1 습관을 재검토 없이 typed 재작성에
그대로 가져오면 안 된다"는 더 큰 교훈의 구체적 사례이기도 함(사용자 회고,
`base/store-semantics.md` 참고) — `quadnomicon`에서 "설계가 왜 이렇게
반전됐는가" 사례로 쓰기 좋음.

View file

@ -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<boolean>` — 태그 이름 하나당
해시 파트 키 하나, 값은 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<Tag|nil>`가 store-bind를 거쳐
TagHandler로 위임)에 진입하는 첫 구체 사례가 되면서, "핸들러 타입이 안
바뀌니 retract 불필요"라는 구 모델의 전제 자체가 신 모델에서 깨졌고, 그
자리를 메우려다 `Dispatch.retractUnder`(체인 기반 retract 전파) 설계로
이어짐.

View file

@ -0,0 +1,68 @@
# [역전됨] Tween = 우선순위 최상위 store-bind Dispatch 핸들러(`[Tween(key,tweenData...)] = storeValue`) — 값-레벨 `Tween<T>` 래퍼로 대체됨
**역전 일시**: 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<T>` 래퍼) |
|---|---|---|
| 매치 방식 | `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<T> = {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 소재).

View file

@ -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해야 하는 구조적 번거로움)는 서로 다른 축이라,
하나가 해소됐다고 다른 하나도 자동으로 해소되는 게 아님.

View file

@ -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`).

View file

@ -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<<boolean>> "name"] = true` (리터럴 또는 store-bind 값) —
제네릭 파라미터로 타입을 명시하는 제네릭 생성자 스타일. 기본/범용 경로.
- `[BooleanAttribute "name"] = true` — 타입별로 이름이 다른 정적 생성자
패밀리(`StringAttribute`/`NumberAttribute`/`Color3Attribute`/
`InstanceAttribute` 등). 실사용 빈도가 높은 몇 개만 지름길로.
**근거**: 이미 확정된 DI 인스턴스 생성 패턴(`bind-system-plan.md` "인스턴스
생성 / 이벤트 네이밍 인체공학" 절)과 구조적으로 똑같은 문제라 같은 결론
재사용 — `new<ClassName>(className)` 제네릭 생성자 + 자주 쓰는 ~25개는
정적 필드로 미리 바인딩했던 것과 동일한 절충. **내부 구현은 완전히
동일**(같은 Handler를 타고, 같은 프리미티브) — 둘 사이 차이는 순전히
호출부가 타입을 어떻게 명시하느냐(제네릭 파라미터 vs 이름)뿐이라 어느
쪽을 쓰든 런타임 동작에 차이 없음.
**[실측 필요, M0/M10]** `[Attribute<<boolean>> "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<T>`/`State<T>`
값도 받음).
## 패키지 배치
UICorner 숏핸드/Tween/Tag와 같은 판단 재사용 — `quad-roblox` 코어에 직접
포함, 별도 opt-out 패키지로 안 쪼갬.
## 열린 질문 (`.claude/question.md`에도 취합)
- 타입 파라미터화 이름(`Attribute<T>` 제네릭 vs `BooleanAttribute`류 정적
패밀리 vs 절충) — 위 "문제" 절 참고, 다음 세션 사용자 판단 필요.

File diff suppressed because it is too large Load diff

View file

@ -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`와 나란히 인용).

View file

@ -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<T>`가 구조적으로 `State<T>`를 만족하므로(단방향 호환,
Svelte `Writable<T> extends Readable<T>`와 같은 모양), `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<T>` 하나만 받아도 Source 인스턴스가 서브타입 호환으로
자동 통과된다(`Source<T> | State<T>` 유니온 불필요, `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`는 확인 불필요로 재확인** — 진행이 중단된 상태라 이 논의와
무관.

166
.claude/base/effect-plan.md Normal file
View file

@ -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번의 관련 항목도 해소됨으로 갱신 완료.

View file

@ -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<number>`가 들어오면 이전 것에 걸어둔 Observer를 먼저 정리해야 함,
`State<Slot>` 교체가 대표 사례)처럼 **`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" 표기가 남은
문서가 있을 수 있으며, 그 확인/정리는 진행 중.

View file

@ -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<<T>>(key)`의 반환 타입이 `T | State<T> | 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<<Frame>>(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<Ref>`
"State/Source가 담고 있는 값"이 핸들러 계층 값인 경우는 이 체크로
못 잡음.** `isRef(v)` 등은 setter가 확정하는 바로 그 값(State 자체
또는 plain 값)만 보므로, 값이 State/Source면 그 껍데기가 `isState`
통과해 검사를 그냥 지나가고, 그 State가 나중에 `:Get()`됐을 때 실제로
내놓는 내용물(예: 그 State가 Ref/PreRef/Observer/Effect/Slot을 값으로
들고 있는 경우)까지는 검사하지 않음 — 검사 시점엔 아직 실체화 안 된
값이라 정적으로 알 수 없고, 값이 바뀔 때마다 매번 `:Get()`해서
검사하는 건 관측 시점을 앞당기는 부작용까지 생기는 오버엔지니어링.
**이 안쪽 케이스는 방어 로직 없는 순수 UB로 문서화만 하고 넘어감**
의도치 않게 자주 발생할 이유가 없는 조합이라 실사용 위험은 낮음.
- **7번 절(`State<Modifier>` 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<Modifier>` 같은 조합을 타입 정의 단계에서
거부)은 여전히 되면 좋은 보너스로 계속 시도해볼 수 있지만
(`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<T>`
이제 `process`/`retract`가 없는 순수 raw 데이터 값 — `None`과 같은
분류. State/Source에 `Tween<T>`가 담기는 것 자체는 여전히 문제없이
허용되지만(위 타입 대수 절 참고), 그 이유는 "재귀 dispatch 참가자라서"가
아니라 "그냥 raw 값이라서"로 바뀜.
- **`Store<T>``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<<T>>(key): T | State<T> | None | nil`** — Modifier 필드를 확정하지
않고 그대로 읽는 접근자. 이름을 `Get`이 아니라 `Peek`로 정한 이유: 이
프로젝트 전역에서 `State:Get()`은 "확정한다"(pull + recompute + 최종값
반환)는 의미로 이미 자리잡았는데, Modifier의 읽기는 정반대(들고 있는
그대로, State면 State 핸들 그대로) — 같은 동사를 반대 의미로 쓰면 안
되므로 다른 이름 필요. 반환 타입을 `T`로 확정해 돌려주지 않고
`T|State<T>|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' = T | Tween<T>` 치환만으로 해결 (2026-08-10 세션)
`research/tween-plan.md`가 값-레벨 `Tween<T>` 래퍼로 재설계되며, 프로퍼티류
Modifier 필드 setter가 트윈 값도 받을 수 있어야 하는지가 자연히 따라오는
질문이었음 — **답은 "이미 있는 `T | State<T>` 필드 타입 모양에 새 케이스를
추가할 필요가 없다"** — 위 4번 절이 확정한 필드 타입 모양(리터럴 `T` 또는
`State<T>`)에서 "이 필드의 `T`" 자체를 `T' = T | Tween<T>`로 치환하면
자동으로 `T | Tween<T> | State<T | Tween<T>>`가 나옴. 즉 `FrameModifier`
타입 생성 스크립트가 `Position` 필드를 만들 때 그냥 `T`를 `UDim2 |
Tween<UDim2>`로 바꿔서 기존 setter 시그니처 생성 로직에 그대로 넣으면 됨 —
Modifier의 제네릭 `__index`/`table.clone` 런타임(위 "런타임은 클래스별
코드 없이" 절)에도 `Tween` 인지 로직을 전혀 추가할 필요 없음(setter는
어차피 값을 그대로 baked 저장할 뿐, 그 값이 `Tween<T>`인지는 나중에
PropertyHandler가 판단).
`Tween<T>`가 Modifier 필드로 담기는 것도, `State<Tween<T>>`처럼 State/Source
값으로 담기는 것도 둘 다 아무 문제 없음 — 7번 절의 "핸들러 계층 값 →
error" 규칙에 안 걸림(`Tween<T>`는 `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` 용어 정리 라운드)에서 더 나은 대안 없어
현재 이름 그대로 최종 확정됨.

View file

@ -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는 프리미티브가 아니다" 절.

View file

@ -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<<T>>` 같은 타입 파라미터화는 안
함.** 콜백 파라미터 타입은 호출부가 인라인으로 직접 명시
(`function(v: UDim2) ... end`) — Luau가 그 타입이 실제 프로퍼티 타입과
일치하는지 검증해주지 않음. 이미 확정된 "이벤트 바인딩은 콜백 시그니처를
Luau가 검증 못 하는 대가를 받아들인다"는 결정(`bind-system-plan.md` "이벤트
바인딩 — `On.EventName` 도트액세스 안 씀" 절, "타입 안전성을 어느 정도
포기하는 대가")과 같은 급의 트레이드오프라 새로 정당화할 것 없음 — 오히려
`Attribute<<T>>`처럼 제네릭으로 정확히 맞추려는 시도는 이벤트 키보다 더
엄격한 걸 요구하는 셈이라 일관성이 깨짐.
- **기각안 — 프로퍼티별 정적 `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<function>` 지원 — 새 메커니즘 없음.** 이미 확정된 "이벤트도
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(값을 직접 받음)보다 이벤트에 더 가깝기 때문 — 카테고리가
헷갈리지 않도록 표로 명확히 구분해둠.

View file

@ -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 각주 수준) 배치할지 —
급하지 않음, 실제 문서 작성 단계에서 결정.

View file

@ -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`)와
같은 "타입 이름이 곧 생성자" 컨벤션 그대로.

697
.claude/base/slot-plan.md Normal file
View file

@ -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<T>()`(빈 인스턴스, 인자 없는 바닥 생성자 — 다른
독립 프리미티브의 `Type(args)` 관습과 동일하되, 무인자라 `T`를 추론할
수 없어 tbox 명시적 제네릭 적용 `Slot<<Instance>>()`로 지정)로 만들고,
`Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD로 조작하면 실제
바인드된 children이 그에 맞춰 갱신됨 — 정확한 시그니처는 아래 "CRUD API
확정" 절 참고(`get`/`set`은 드롭).
### 요소 타입 제약 (2026-08-09 세 번째 세션)
- **`nil`/`None` 둘 다 금지 — Slot의 raw 요소는 오직 실제 마운트 가능한
`T` 값만.** [정정, 같은 세션 후속] 처음엔 "배열 파트는 `nil` 대신
`None`" 원칙을 그대로 가져와 `None`을 Slot 요소로 허용했었는데,
`:List`의 필터링 요구사항을 구체화하며 재검토한 결과 불필요했음이
드러남 — `updateFn`이 "이번엔 렌더 안 함"을 표현하는 건 아래 `:List`
절에서 **`updateFn`의 반환값을 해석하는 `:List` 자신의 내부 로직**으로
처리되고, 그 경우 `rawAdd` 자체가 아예 호출되지 않음(즉 `None`이 실제로
Slot 배열에 들어갈 일이 없음) — 그래서 raw `Add`가 굳이 `None`
허용해야 할 이유가 없어짐. `element == nil`뿐 아니라 `element == None`
`Add`(및 내부 `raw*`)에서 즉시 `error` — "Slot 안엔 실제로 마운트
가능한 값만 들어간다"는 단일 규칙으로 단순화.
- **핸들러 계층 값(Ref/PreRef/Observer/Effect/Modifier) 금지, 즉시
`error`** — `Modifier` 필드가 이 값들을 담으면 즉시 `error`로 확정했던
것(`modifier-plan.md` 7번)과 같은 판별 메커니즘(`isRef`/`isPreRef`/
`isObserver`/`isEffect`/`isModifier` Brand predicate)을 그대로 재사용.
근거: `Dispatch/Leaf.luau`가 처리하는 "children 배열에 `Ref`/`Observer`/
`PreRef`가 직접 놓이는" 케이스는 **그 컴포넌트가 지금 만들고 있는
Instance 자기 자신을 가리키는 self-ref 캡처**(`Frame { PreRef():Callback(fn) }`가
그 Frame 자신을 잡는 것)라 `inst`가 "지금 생성 중인 바로 그 하나의
Instance"로 고정돼 있어야 의미가 성립하는데, **Slot은 특정 컴포넌트
호출 하나에 묶여있지 않고 이미 존재하는 부모에 나중에 독립적으로
붙는 동적 리스트라 이 전제 자체가 없음** — Slot 안의 Ref가 "무엇"을
가리켜야 하는지 정의가 안 됨. 대체 경로도 이미 있어 능력 손실 없음 —
특정 child에 ref가 필요하면 그 child를 만드는 컴포넌트 호출 자체에
Ref를 넘기면 됨(`slot:Add(Frame { Ref = myRef })`).
- **`T`의 실제 의미**: 위 배제 덕에 "이 Slot이 실제로 담을 수 있는 최종
마운트 가능한 값의 타입" 그 자체로 단순해짐 — quad-roblox엔 사실상
`T = Instance` 하나뿐(컴포넌트 호출 결과도 결국 Instance)이라
`D.InstSlot = Slot<<Instance>>`가 사실상 "그" Slot 타입. `Slot<T>()`
기본값(`T` 생략 시) 없이 항상 명시를 요구하는지, `quad-base`에선
`any`로 기본값을 두는지는 tbox 제네릭 적용 문법 확정 시 같이 정할 것
(이 문서 "자식으로 넘기는 클래스 스토어" 절의 기존 미결과 같은 갈래).
## 핵심 제약: 소유권 귀속과 단일 마운트
Slot에 들어간 요소는 **ownership이 귀속**되며 다른 곳에 마운트할 수 없게 된다.
`isMounted`를 관리해서, **한 인스턴스에 대한 다중 마운팅이 라이브러리 차원에서
절대 일어나지 않도록 강제**하는 게 v1 대비 핵심 디자인 변화. v1의 `mount()`
별다른 강제를 안 했지만(`reference/quad-v1-architecture.md`의 mount.lua 분석 참고 —
실제로는 부모/자식 부기까지 했지만 다중 마운트 방지는 없었음), v2는 mount
함수 자체가 이 강제를 담당.
Fusion의 `Children` SpecialKey는 이걸 "특정 SpecialKey 하나의 내부 부기"로만
구현했고(재사용 가능한 1급 프리미티브가 아님), Vide는 아예 이 개념이 없어서
같은 target에 두 번 `mount()`하면 조용히 두 개의 독립 루트가 생김 — 둘 다
반면교사.
### `isMounted` 이중 추적 분리 (1-8 해소, 2026-08-09 세 번째 세션)
"한 인스턴스가 다중 마운팅 절대 안 됨"이라는 위 원칙과 아래 "재마운트 시
즉시 throw"가 원래 하나의 `isMounted`로 뭉뚱그려 서술돼 있었는데, 실제로는
서로 다른 두 대상을 추적해야 함 — 명시적으로 분리:
- **Slot 컨테이너 자신**: `self._mounted: boolean`(Slot 인스턴스 필드
하나). **트리거 시점은 `Dispatch.process(inst,k,self)`가 이 Slot
객체에 대해 실제로 호출된 순간**(핸들러 매치 시점) — Instance
`Parent` 대입 완료를 기다리지 않음. 다른 모든 "마운트됨" 판정(PreRef
소진, Ref 콜백 fire 등)이 전부 dispatch-process 시점 기준이라 여기만
post-effect 기준으로 가면 일관성이 깨짐. 컴포넌트가 Slot을 prop으로
받아 저장만 하고 실제 트리에 안 놓는 경로는 `process`가 애초에 안
불려서 이 정의로도 오탐 없음. **[2026-08-09 일곱 번째 세션 보강]**
같은 자리에서 `self._mountedInst = inst`도 같이 저장 — `:List()`
마운트 이후에 호출되는 경우 이 값으로 즉시 활성화(아래 "`Slot:List(...)`"의
"구독 시점" 절 참고).
- **개별 element**: Slot 안에 담기는 각 element(Instance/컴포넌트 결과 등)
마다 전역 weak-set 멤버십으로 추적 — 특정 Slot 인스턴스에 안 묶임
("한 인스턴스가 어디에도 중복 마운트 안 됨"이 라이브러리 전역 불변식이라서).
`Add`가 이 weak-set을 확인(이미 참이면 error)/설정, `Remove`/`Extract`
둘 다 여기서 제거(둘의 차이는 파괴 여부일 뿐, "마운트 해제"라는 점은 같음).
## 여럿 존재 가능, 부모가 실제 데이터 테이블만 다루면 됨
Slot은 하나의 instance 안에 여럿 존재할 수 있다. 전부 하나의 children으로
들어가지만, 실제 렌더된 instance에서 `GetChildren()`을 직접 하지 않고도 부모가
생성한 "실제 slot 데이터 테이블"만 다루면 되게 해서 **추상화 수준을 낮은 직접
바인딩에서 한 단계 떼어냄**(간접화를 통한 추상화).
## 마운트된 Slot의 재마운트는 즉시 throw (확정)
**사용자 확인 완료**: 이미 사용된(마운트된) slot을 재마운트하려 하면 **즉시
`error()`로 중단** — warn+no-op 아님. 개발 중 바로 잡아낼 수 있게 강하게
실패하는 쪽 선택. 마운트되는 순간 slot의 실제 대상은 고정된다 — 따라서
**글로벌 스코프에서 slot을 쓰는 건 그다지 좋지 않을 수 있음**(재사용/재마운트가
막히므로).
## 클래스가 슬롯을 받는 방법
"네이밍된 슬롯"이 필요한가에 대한 사용자 자문: 그냥 슬롯 바인드 테이블을
값으로 넘기면 되는 것 아닌가 — 결국 array처럼 구현된 Store라고 생각하는 게
편하다는 방향. **기울어진 결론**: 별도 "Named Slot" 개념 없이, store나
파라미터로 넘기고 그게 그냥 ref처럼 바인드되는 모양.
**확정(2026-08-04, 로드맵 인수인계 라운드)**: 위 방향 그대로 확정 — 별도
"Named Slot" 개념 없음, 슬롯 바인드 테이블을 store나 파라미터로 그냥 넘기면
ref처럼 바인드됨 — **사용자 확정**("A. 맞음. 리프노드에선 그렇게 마운트됨").
단, 이 확정은 "리프 레벨에서 슬롯 하나가 마운트되는 방식"에 한정 — 여러
Slot이 형제로 섞이는 경우의 순서 보장 문제는 별도로 열려있음, 바로 아래
참고.
### 여러 Slot이 섞일 때 순서 보장 — 해소됨 (2026-08-09 여섯 번째 세션)
`Frame { Slot1, 일반자식, Slot2 }`처럼 Slot과 Slot 사이에 다른 요소가 끼거나
Slot이 여럿 형제로 존재할 때, 최종 자식 순서가 저작 순서(위쪽 Slot의 요소가
항상 아래쪽 Slot의 요소보다 앞)를 안정적으로 지키는지가 2026-08-04부터 열려
있었던 질문 — **메커니즘 확정으로 해소됨**: `Dispatch.setLength`/
`Dispatch.setOffsetSource` + 형제별 개수 누적합(`offset`)을 리액티브
프로퍼티(Roblox `LayoutOrder`)에 바인딩하는 방식 — 상세는 `base/
bind-system-plan.md`의 "Length/Offset — 여러 Slot이 형제로 섞일 때 순서
보장" 절 참고. **DOM류 물리 순서 백엔드에도 같은 base 메커니즘이 그대로
재사용됨**(offset이 바뀌어도 이미 마운트된 원소를 물리적으로 옮길 필요
없음 — `insertBefore`가 뒤 형제를 자연히 밀어주므로, backend Handler의
"offset 변경 시 할 일"만 no-op으로 달라짐) — `architecture.md`의 "다른
렌더 백엔드에서도 재사용 가능해야 한다"는 전제와도 부딪히지 않음.
## Slot과 Store 바인드의 관계 (`retract` 순서)
Slot이 store 바인드로 들어오는 경우, pluggable 처리기에 `retract`(구 cleanup,
`base/lifecycle-pattern.md` 참고) 핸들러가 필요함 — 한번 넘어간 slot 요소가
나중에 `retract`되면 삭제되는지, 아니면 "부모의 소유이니 부모가 처리"해야
하는지 검토 필요. **기울어진 결론(잠정안, 이후 정정됨)**: 부모가 정리 정도만
미리 수행하고 다시 `process`하면 되므로, 부모에게 위임(자식 slot 요소 자체가
스스로 정리를 실행하는 게 아니라).
> **정정(2026-08-04 검증 라운드)**: 위 "부모 위임" 잠정안은 이후 **폐기**
> 쪽으로 정정됨 — 아래 "확정" 절과 `.claude/question.md`("Slot의 `retract`
> 동작이 '부모 위임' 잠정안에서 '폐기(옮기지 않음)'로 확정") 참고. 이 문단은
> 검토 과정의 히스토리로만 남겨둠, 현재 유효한 동작 아님.
이건 `base/bind-system-plan.md`의 "Store 바인드는 재실행 래핑" 확정
모델과 맞물림 — slot이 store 값으로 오면, store 바인드 핸들러가 이전 slot
상태를 `retract`하고 새 slot 상태로 다시 `process`하는 사이클을 돈다는 뜻.
Slot 핸들러 자신이 감시 중인 값(배열/스토어)이 바뀔 때 child를 갱신하는
추적(구독)도 `base/bind-system-plan.md`가 말하는 "process 함수가 다른 값
변경을 추적해도 됨" 범위에 속하고, `retract` 시점엔 그 추적만 풀면 됨 —
Destroy 시점엔 `retract`가 호출되지 않는다는 원칙(`base/lifecycle-pattern.md`)도
동일하게 적용.
**확정(2026-08-04 검증 라운드): retract되는 slot은 옮겨지지 않고 그냥 폐기된다.**
Slot은 바인딩되는 순간 그 안의 요소를 전부 own해버리는 데이터형 — 새 slot
상태로 교체될 때 이전 slot의 내용을 다른 곳으로 옮기는 경로는 없음, 그냥
버림. React의 portal(`<></>`)류로 나중에 옮길 수 있게 하는 것도 검토됐으나
**이번 마일스톤에서는 오버엔지니어링으로 판단, 하지 않음** — 필요성이 명확해지면
그때 별도로 다시 논의.
> **범위 명확화(2026-08-09 세 번째 세션)**: 위 "폐기, 옮기지 않음"은
> **프레임워크가 store-bind 재실행으로 Slot 값 전체를 통째로 갈아치울
> 때**(retract)만의 얘기 — **사용자가 직접 `Slot:Extract(element)`
> 부르는 CRUD 경로는 이것과 다른 시나리오**다. Extract로 뺀 element는
> 파괴되지 않고 호출부가 소유권을 되찾으며, **임의의 다른 Slot으로
> 자유롭게 다시 `Add`할 수 있다**(아래 "CRUD API 확정" 절) — retract가
> "옮기지 않는다"고 확정한 건 프레임워크가 알아서 옮겨주는 자동 portal을
> 안 만든다는 뜻이지, 사용자가 명시적으로 두 번 호출(`Extract` 후
> `Add`)해서 옮기는 것 자체를 막는 게 아니다.
## CRUD API 확정 (2026-08-09 세 번째 세션, 1-7 해소)
**[정정, 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<UD = any>(item, index, userdata: UD?, prev: T?): (T | nil, UD?)`
— 매 reconcile 사이클마다 모든 key에 대해 호출됨.** `:List`는 더 이상
item을 위해 `Source`를 대신 만들어주지 않음(아래 "왜 `Source`
`:List`가 안 만드는가" 참고) — `item`/`index`는 매번 그 사이클의 raw
현재값 그대로 넘어감, 반응형으로 쓸지는 `updateFn`이 알아서 결정.
- **`userdata: UD?`** — 이 key에 대해 지난 호출에서 `updateFn` 자신이
반환해둔 두 번째 값을 그대로 돌려받음(첫 호출은 `nil`). 완전히
opaque — `:List`는 안을 전혀 안 들여다봄. `updateFn`이 원하는 걸
아무거나 담아도 됨(item의 `Source`, 여러 파생 State, 로컬 UI
상태 등).
- **`prev: T?`** — 이 key에 대해 지금 실제로 마운트돼 있는
element(없으면 `nil`, 첫 호출을 포함해 언제든 가능).
- **반환값 두 개는 서로 완전히 독립**`:List``result``userdata`
사이에 어떤 커플링도 안 둠(예: `result``nil`이라고 `userdata`
자동으로 지우지 않음), 그대로 기록만 함. **[정정, 같은 세션 후속]**
처음엔 "`result`가 `nil`이면 `userdata`도 같이 버림"이었으나, 이러면
"인스턴스는 파괴하되 다시 나타날 때 재사용하려고 캐시는 남겨두고
싶다" 같은 정당한 패턴 자체가 원천 봉쇄됨 — 그럴 이유가 없어 커플링을
없앰. 흔한 경우(둘 다 리셋)는 그냥 `return nil` 하나로 충분(Lua가
안 받은 반환 슬롯을 알아서 `nil`로 채움), 캐시를 남기고 싶으면
명시적으로 `return nil, ud`.
- `updateFn`은 매번 다음 중 하나를 반환:
- **`prev`를 그대로 반환** — "지금 마운트된 걸 계속 쓴다"는 뜻.
관용구: `if prev and (필터 통과) then ...update ud...; return prev,
ud end`. 실제 마운트/파괴가 없는 **저렴한 경로**.
- **새 값(또는 다른 값)을 반환** — 첫 렌더(이 key 최초 등장) 또는
의도적 교체. `prev`가 있었다면 그건 파괴되고 새 값이 그 자리를
대신함.
- **첫 번째 값으로 `nil`을 반환** — "지금 이 key는 렌더 안 함"(filter
탈락 등). `prev`가 있었다면 실제로 파괴됨(단순 `Visible = false`
아님 — 아래 참고). `None`을 반환해도 동일 취급(둘 다 허용, 편의상
`nil` 권장 — 반환값이 raw Slot 요소로 직접 들어가는 게 아니라
`:List`의 reconcile이 해석만 하므로 "요소 타입 제약"의 raw
`nil`/`None` 금지와 안 부딪힘).
- `userdata = userdata or {}`류 lazy-init 관용구가 `UD`가 완전히 자유
제네릭인 상태에서도 Luau 타입 시스템이 매끄럽게 좁혀주는지는 **실측
필요**(M0/M6 착수 시 확인 항목, 지금 단정 안 함).
### 왜 매 사이클 호출로 바뀌었는가 — filter/toggle 문제
사용자가 제기한 문제: item이 State 변경으로 "더 이상 렌더되면 안 되는"
상태가 될 수 있는데(예: 검색 필터에서 탈락), 기존 "1회만 호출" 모델엔
이걸 표현할 방법이 없었음. 실무에서 흔한 회피책은 실제로 제거하지 않고
`Visible = false`만 토글하는 것 — 하지만 이건 **lazy하지 않음**: 필터링된
항목도 여전히 완전히 살아있는 Instance라 애니메이션/이벤트 연결/재계산이
계속 돎. 리스트가 200개+가 되면 "보이는 건 20개인데 200개가 전부 계속
돌아가는" 문제가 실제 비용으로 드러남.
**해법**: `updateFn`을 매 사이클 호출하되, `prev`를 줘서 "바꿀 게 없으면
그대로 돌려주기만 하면 되는" 저렴한 경로를 만들고, filter 탈락은 `nil`
반환으로 **진짜 파괴**되게 함 — Visible 토글이 아니라 실제 Remove.
200개 중 20개만 통과하는 필터면 20개만 실제로 살아있고 나머지 180개는
정말로 존재하지 않음(애니메이션도 안 돎).
**"이전 상태를 다음 호출에 어떻게 넘기냐" 문제는 `userdata`가 그 채널** —
item이 plain table이라 매번 `Source`를 새로 안 만들고 재사용하려면 그
`Source`를 어딘가 저장해야 하는데, `:List`가 그걸 대신 안 만들어주는
대신(아래 참고) `userdata`라는 전용 채널로 `updateFn`이 직접 관리하게
함 — filter 탈락 후 재등장해도(Instance는 파괴됐다 새로 만들어져도)
`userdata`를 살려뒀다면 그대로 이어짐(위 "반환값 두 개는 서로 독립" 참고).
**sort는 이 재설계와 무관, 기존 메커니즘으로 이미 커버됨** — 호출부가
`data`의 순서를 바꾸면 `keyIndex[key] ~= i` 감지 → `Move`가 그대로
처리, 새 메커니즘 필요 없음(사용자가 filter와 같이 물었던 것 중 sort는
원래도 문제가 없었음).
### 왜 `Source``:List`가 안 만드는가 — item/index를 raw로 넘기는 이유
이전 초안은 `:List``itemState`/`indexState`(내부 `Source`)를 강제로
만들어 `updateFn`에 넘겨줬는데, 재검토 결과 이건 **`:List`가 굳이 강요할
필요 없는 결정**이었음 — 반응형 바인딩이 필요 없는 단순한 행(예: 매번
그냥 새로 계산해도 싼 텍스트 하나)까지 전부 `Source` 생성 비용을 억지로
지게 됨. `userdata`로 이 권한을 완전히 `updateFn` 쪽에 넘기면, 원하는
item만 자기 `Source`를 만들어 `userdata`에 담고, 나머지는 매번 raw
`item`에서 그냥 다시 계산해도 됨 — 어느 쪽이 나은지는 케이스 by 케이스라
`:List`가 미리 정할 이유가 없음.
**부수 효과 — 이전 "item 값은 무조건 재전파, index는 실제 변경시만"
비대칭 백로그가 사라짐.** `:List`가 더 이상 `Source`를 안 만드므로 그
문제 자체가 `:List` 소관이 아니게 됨 — item/index를 반응형으로 감쌀지,
매번 무조건 `:Set()`할지 조건부로 할지는 전부 `updateFn` 작성자의 선택.
### `userdata`의 생명주기 제약 — GC-native만 허용, 명시적 cleanup이 필요한
값은 UB (같은 세션 후속)
**검토했다가 기각한 대안**: `item``T?`(nilable)로 바꿔서, key가 최종
제거될 때 `updateFn(nil, index, userdata, prev)`를 한 번 더 불러 "정리할
기회"를 주는 안 — `if not item then <userdata 안의 구독 해제 > return
end` 관용구로 `userdata` 안에 담긴 리소스(예: `Observer:Subscribe()`
구독)를 정리할 수 있게 하자는 아이디어. **기각 — 사용자가 스스로 반례를
찾음**: 이 훅은 `data`에서 key가 빠져 `reconcile`이 다시 도는 정상
경로에서만 발화함 — 하지만 **Slot을 담고 있는 부모 Instance 자체가
`Destroy`되는 경로**(가장 흔한 소멸 경로)는 `reconcile`을 다시 안 돌기
때문에 이 훅이 전혀 안 불림. 절반만 동작하는 정리 메커니즘은 없는 것보다
나쁨 — 사용자가 "정리가 보장된다"고 오해하고 `Subscribe`류를 `userdata`
넣었다가 Destroy 경로에서 조용히 새는 게 실제로 훨씬 위험한 결과.
`retract`가 Destroy 시엔 절대 안 불린다는 기존 원칙(`base/
lifecycle-pattern.md` "quad는 라이프사이클 중간에 있지 않다")과 정확히
같은 이유로, `:List`에 새 반쪽짜리 예외를 만들 이유가 없음.
**대신 명시적 제약으로 문서화**: **`userdata`에는 반환된 element(또는
Slot 자신)보다 명시적으로 오래 살아야 하는 값을 담으면 안 됨 — GC만으로
자연히 정리되는 값만 담을 것(plain 값, `Source`/`State` 등), `:Subscribe()`
`Observer`/`Effect`류처럼 명시적 `:Unsubscribe()`가 필요한 값을 담는 건
UB.** `:List`가 어떤 teardown 경로도 보장 안 하므로, `userdata` 안의
무언가가 GC 하나만으로 안 죽는다면 그건 곧 leak. 이건 quad 전역
GC-native 원칙(`lifecycle-pattern.md`)을 `:List`라는 구체적 지점에 그대로
적용한 것뿐 — 새 원칙 아님.
### 구현
**구독 시점은 `:List()` 호출이 아니라 Slot 마운트 시점 — lazy `bindLifetime`
(2026-08-09 일곱 번째 세션, 아래 "구독 시점" 절 참고).** `:List()`는 설정만
저장하고 반환, 실제 `data:Observer(fn)` 구독과 최초 `reconcile`은 Slot
자신이 마운트되는 순간(`Dispatch/Slot.luau`의 `process(inst,k,self)`)에
`activateList`가 수행 — `Dispatch.setLength`가 이미 쓰고 있는 것과 같은
패턴(마운트 시점까지 미뤘다가 그 자리에서 `bindLifetime`).
```lua
function Slot:List(data, updateFn, keyFn)
assert(not self._listed, "Slot already has :List installed")
self._listed = true
self._listData = data
self._updateFn = updateFn
self._keyFn = keyFn or function(_, index) return index end
if self._mounted then
activateList(self, self._mountedInst) -- 이미 마운트돼 있으면 즉시 활성화
end
return self
end
-- Dispatch/Slot.luau의 process(inst,k,self)가 마운트 시점에 1회 호출
-- (self._mounted=true/self._mountedInst=inst를 세팅하는 바로 그 자리)
function activateList(self, inst)
local keyFn, updateFn = self._keyFn, self._updateFn
local mounted, userdata, keyIndex = {}, {}, {}
local function reconcile(items)
local newKeyIndex, seen = {}, {}
for i, item in ipairs(items) do
local key = keyFn(item, i)
newKeyIndex[key] = i
seen[key] = true
local prev = mounted[key]
local result, ud = updateFn(item, i, userdata[key], prev)
if result == None then result = nil end -- 편의: None도 nil과 동일 취급
if result ~= prev then
if prev ~= nil then rawRemove(self, prev) end -- 파괴
if result ~= nil then rawAdd(self, result, i) end -- 새로 배치
mounted[key] = result
elseif prev ~= nil and keyIndex[key] ~= i then
rawMove(self, prev, i) -- 그대로 쓰되 위치만 이동
end
userdata[key] = ud -- result와 무관, 그대로 기록
end
for key in pairs(keyIndex) do -- 직전 사이클에 존재했던 전체 key
if not seen[key] then
local prev = mounted[key]
if prev ~= nil then rawRemove(self, prev) end
mounted[key], userdata[key] = nil, nil
end
end
keyIndex = newKeyIndex
end
local data = self._listData
if isState(data) then
local observer = data:Observer(function() reconcile(data:Get()) end)
-- Observer 등록 자체의 "등록 즉시 1회 실행"은 canExecute/Subscribed
-- 게이팅과 무관하게 여기서 이미 무조건 일어남(아래 "구독 시점" 절) —
-- bindLifetime은 그 다음에 걸어 *이후* 재실행만 inst 생명주기에 귀속
bindLifetime(inst, observer)
else
reconcile(data)
end
end
```
- **`data:Observer(fn)`**: 새 구독 프리미티브 아님 — 2026-08-07 여섯 번째
세션에 이미 "등록 즉시 1회 실행" 확정된 그 메소드를 그대로 씀.
`reconcile`은 매번 **현재 전체 스냅샷을 받아 O(n) 단일 패스로 diff**
— 트리 전체를 비교하는 비싼 diff가 아니라 `seen` 셋 하나로 "새 key
목록에 없는 건 지운다"만 판정하는 React/Vue/Solid류의 표준 key 기반
방식, `data`가 참조를 유지한 채 뮤테이션+`Emit()`되는 경로도 지원해야
하는 이상 최소 한 번은 훑어야 하는 게 불가피함.
- **`updateFn`을 매번 부르는 게 비싼 게 아닌 이유** — 흔한 경로(`prev`
그대로 반환)는 함수 호출 하나뿐, 실제 Instance 생성/파괴가 있는 건
key가 새로 나타나거나/사라지거나/filter로 구조가 바뀌는 경우뿐.
200개 중 값만 갱신되는 사이클엔 200번의 값싼 함수 호출이 있을 뿐,
200번의 재구성이 있는 게 아님.
- **`mounted`/`userdata`를 정리하는 루프가 `mounted`가 아니라 이전
사이클의 `keyIndex`를 순회하는 이유** — `userdata`가 이제 `result ==
nil`이어도 살아남을 수 있어서(위 "반환값 두 개는 서로 독립"), 어떤
key가 `mounted[key] == nil`인 채로(필터 탈락 상태) `data`에서 완전히
사라지면 `pairs(mounted)`로는 그 key가 아예 안 잡혀서 `userdata`
못 치워지고 샘 — 직전 사이클에 실제로 존재했던 **전체** key 집합
(`keyIndex`, 매 사이클 모든 key에 대해 채워짐)을 순회해야 이 케이스를
놓치지 않음.
- **`mounted`/`userdata`/`keyIndex`**: `activateList`(마운트 시점 1회
실행)의 로컬 변수(클로저 업밸류) — 별도 전역 weak table(`Relate` 등)
불필요, `inst`/`self`가 살아있는 동안만 존재하면 되고 죽으면 클로저도
같이 GC됨(아래 "구독 시점" 절).
- **`reconcile`이 직접 호출하는 건 `rawAdd`/`rawRemove`/`rawMove`뿐** —
`rawExtract`/`rawSwap`/`rawClear`도 (위 "모든 공개 CRUD는 가드+위임"
구조상) 당연히 존재하지만, `:List`의 reconcile 알고리즘 자체가 그
셋을 쓸 일이 없을 뿐(제거는 항상 파괴 확정이라 `Extract` 아닌 `Remove`
경로, 리오더는 항상 절대 위치 이동이라 `Swap` 아닌 `Move` 경로,
`Clear`는 reconcile 단위가 아니라 Slot 전체 단위 연산이라 무관).
- **리오더는 `Move`(의 가드 없는 버전)** — Parent를 안 건드리는 진짜
저비용 경로. 최소-이동 알고리즘(LIS 기반 등) 자체는 구현 시점 최적화로
미룸, 여기선 계약(파괴 없이 위치만 바뀜)만 확정.
### 구독 시점 — `:List()` 호출이 아니라 Slot 마운트 시점, lazy `bindLifetime`
(2026-08-09 일곱 번째 세션)
**문제**: 원래 초안은 `data:Observer(fn)``:List()` 호출 그 자리에서 만들었음
— 근데 `:List()``Slot():List(data, updateFn)`처럼 Slot이 아직 어디에도
마운트되기 전에 불리는 게 흔한 사용법이라, 그 시점엔 `inst`를 몰라서
`bindLifetime`을 걸 수 없었음(사용자가 직접 지적) — 마운트 대상이 나중에
`Destroy`돼도 이 구독을 멈출 방법이 없는 gap이었음.
**해법 — `Dispatch.setLength`가 이미 쓰고 있는 패턴 그대로 재사용**: 새
메커니즘 발명 아님. `:List()``data`/`updateFn`/`keyFn`만 저장하고 반환,
실제 `data:Observer(fn)` 구독 + 최초 `reconcile`은 Slot 컨테이너 자신이
마운트되는 순간(`Dispatch/Slot.luau`의 `process(inst,k,self)` — 위
"`isMounted` 이중 추적 분리" 절이 이미 `self._mounted`를 세팅하는 바로 그
지점)에 `activateList(self, inst)`가 수행. `Dispatch.setLength(inst,i,
self.Length)`를 부르는 것과 같은 자리에서 같이 트리거되면 됨.
**`:List()`가 마운트 이후에 불리는 경우 — `self._mounted`면 즉시 활성화
(확정)**: 마운트는 1회성 이벤트라, `:List()`가 마운트보다 늦게 호출되면
그 이벤트를 기다리는 방식으론 영영 활성화가 안 됨 — `:List()`
`self._mounted`를 확인해서 이미 참이면 그 자리에서 바로
`activateList(self, self._mountedInst)`를 호출(마운트 시점에 `inst`
`self._mountedInst`로 같이 저장해둠). CRUD와의 상호배타 가드(`self._listed`)와
같은 자리에서 자연스럽게 처리됨 — 호출 순서에 대한 새 제약을 추가하지 않음.
**canExecute와 "등록 즉시 1회 실행"의 관계 — 초기 실행은 게이팅과 무관하게
무조건 일어남(사용자 확인)**: `data:Observer(fn)`가 등록되는 순간
(`bindLifetime` 호출 *이전*) `fn`이 이미 한 번 동기 실행됨(Observer 자체의
"등록 즉시 1회 실행" 계약) — 이 시점엔 아직 `bindLifetime``Subscribed`
세팅 전이라 `canExecute`를 물으면 거짓이겠지만, 애초에 최초 실행은
`canExecute`로 게이팅되는 대상이 아니라서 상관없음. `bindLifetime`은 그
직후에 걸려서 **이후의** 재실행(`data`가 다시 바뀔 때)만 게이팅 —
`Dispatch.setLength``bindLifetime(inst,observer)` 다음 줄에 있는
"등록 즉시 1회와 겹쳐도 무해"라는 주석과 정확히 같은 구조.
**Destroy 이후 — "재실행 막기"와 "관측 자체를 관두기"가 새 메커니즘 없이
한 번에 해결됨**: `inst`가 Destroy되면 `bindLifetime``gcconn`(Roblox가
Destroy 시 자동으로 끊는 Connection)이 죽어 `canExecute`가 거짓이 되고
future 재실행이 no-op됨(위 "`state:Observer(fn)`" 절 원칙 재사용) — 그리고
"이전 state를 계속 관측하는 것도 관둬야 한다"는 요구도, `gchold`
`Relate(inst)`(weak-keyed) 아래 있어서 `inst`가 죽으면 그 안에 강참조로
붙잡혀 있던 Observer/클로저(`mounted`/`userdata`/`keyIndex`를 포함해)가
전부 같이 GC 대상이 되는 것으로 공짜로 해결 — 명시적으로 구독을 끊는
새 코드가 필요 없음, `base/lifecycle-pattern.md`의 "정리는 기본적으로
GC에 위임" 원칙 그대로.
**부수 관찰(설계 아님, 메모만)**: `bindLifetime``Relate(inst)` 기반이라,
"이 `inst`에 지금 어떤 Slot/Observer가 붙어있는가"를 나중에 weak하게
역조회하는 것도 같은 저장소로 가능해 보임(quad-debug의 "무엇이 무엇에
연결됐는가" 그래프와 맞닿을 수 있음) — 지금 설계할 필요는 없음, 필요성이
확인되면 그때.
### 왜 자유 함수/새 타입이 아닌가
처음엔 `List(data, updateFn, keyFn?) -> Slot` 같은 자유 함수(또는 `Slot`
구조적으로 만족하는 새 타입 `List`)로 검토했으나 둘 다 기각:
- **자유 함수 기각**: `Source(default)`/`Ref(default)`/`Store({defaults})`가
지켜온 "`Type(args)` 팩토리 이름 = 반환 타입"이라는 컨벤션이 깨짐 —
`List(...)``Slot`을 반환하면 이름과 실제 타입이 안 맞음.
- **새 서브타입(`List extends Slot`, Source⊇State 같은 구조적 서브타이핑)
기각**: Source가 State의 서브타입이어야 했던 이유는 Source가 State보다
진짜로 더 많은 공개 메소드(`:Set`/`:Emit`)를 갖기 때문 — 반면 이
프리미티브는 Slot이 이미 가진 것(`Add`/`Remove`/`Extract`/`Clear`/
`Move`/`Swap`) 위에 새 공개 메소드를 얹지 않음. 그냥 "자동으로 채워지고
관리되는 Slot"일 뿐이라 별도 타입일 이유가 없음.
- **결론: `Slot`의 콜론 메소드.** "원천에 종속된 파생 데이터는 자유 함수
생성자가 없고 메소드로만 얻어진다"(State/Observer)는 기존 분류 원칙과
같은 모양 — 다만 여기 원천은 Source가 아니라 이미 만들어진 Slot 자신.
Fusion의 `ForPairs`/`ForKeys`/`ForValues` 3분할도 이 재구성으로 통합
방향이 자연스러워짐(단일 `:List`가 이미 Slot 메소드 이름공간 안에
있으니 여러 진입점을 나열할 이유가 약해짐) — **통합 확정**.
- 이름 후보로 검토됐던 `Render`/`Draw`도 이 재구성으로 더 이상 "타입
이름"이 아니라 "메소드 이름" 문제가 됐지만, `List`가 여전히 가장
낫다고 판단(`Render`는 quad의 "렌더 주기 없음" 원칙과 메소드 이름으로
써도 충돌 소지가 남고, `Draw`는 즉시모드 GUI 뉘앙스) — **`List`로 확정**.
## 자식으로 넘기는 클래스 스토어
자식에게 내려주는 클래스 스토어는 부모 쪽에서 미리 만들어서 내려보내는 게
편할 것 같다는 방향 — `store<<ChildClass.Props>>` 형태로 구성된 스토어를 만들면
됨(타입 표기는 러프한 스케치, 실제 문법은 tbox의 명시적 제네릭 적용 패턴
`f<<T>>(...)``.claude/initreq/tbox/CLAUDE.md:40-41` — 참고해서 확정할 것).
## 열린 질문 (`.claude/question.md`에도 취합)
- 재마운트 에러 처리(throw), retract 시 폐기(옮기지 않음) 둘 다 확정. 남은 건
실제 구현 단계에서 이 "폐기" 동작이 실사용에서 불편하지 않은지 재검증하는
정도 — 설계 방향 자체는 더 이상 열려있지 않음.
- "클래스가 슬롯을 받는 방법"(Named Slot 없음)도 확정됨(위 "클래스가 슬롯을
받는 방법" 절 참고).
- **[해소됨, 2026-08-09 세 번째 세션]** `add`/`remove`/`clear` CRUD 의미론,
`isMounted` 이중 추적 분리, 키 기반 동적 컬렉션 재조정(`Slot:List`) —
위 "CRUD API 확정"/"`isMounted` 이중 추적 분리"/"`Slot:List`" 절 참고.
- **[해소됨, 2026-08-09 여섯 번째 세션]** 여러 Slot이 형제로 섞일 때
순서 보장 — 위 "여러 Slot이 섞일 때 순서 보장" 절 참고, 메커니즘은
`base/bind-system-plan.md`의 "Length/Offset" 절이 최신 소스.
## Slot.Length — `:List`뿐 아니라 항상 노출됨 (2026-08-09 여섯 번째 세션)
Slot은 CRUD/`:List` 여부와 무관하게 `.Length: State<number>`를 항상
노출 — 지금 실제로 마운트된 요소 개수(사용자가 직접 CRUD로 넣든 `:List`
reconcile이 넣든 동일). 두 용도를 겸함: (1) 사용자가 "n개 검색됨" 같은
UI에 직접 관측, (2) `Dispatch.setLength(inst, i, slot.Length)`가 형제
순서 보장(위 "여러 Slot이 섞일 때 순서 보장" 참고)에 내부적으로 읽는 바로
그 값 — 별도 두 State가 아니라 하나. `:List`의 filter 탈락이 실제
`Remove`(Visible 토글 아님)로 확정돼 있어서 `Length`는 자동으로 "실제
마운트된 것"만 반영 — 수동 Visible 토글을 쓰면 `Length`가 그걸 못 잡는
게 맞고, 그건 사용자가 별도 State로 계산해야 하는 몫.
**동적 자식은 반드시 `Slot` 또는 `state<Frame>`류 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<Frame?>`를 조건부로 마운트하는
관용구를 더 명시적으로 표현) — `.Length`는 그냥 0/1이고 나머지(offset 소비,
LayoutOrder 바인딩)는 일반 Slot과 완전히 같은 프로토콜. 아직 상세 설계
안 함, `.claude/question.md`에 백로그로만 반영.

View file

@ -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<number>}`가 읽기/쓰기 비대칭이라 Luau
타이핑이 안 맞음, 위 "Source가 State를 만족함" 논의에서 도출)를 풀다가
사용자가 제안한 더 근본적인 재구성. `RefSource<T>`(store 슬롯을 가리키는
전용 타입)를 따로 만드는 중간안도 검토했으나, 최종적으로 **Source 자체가
State를 만족하도록 만들고, RefSource라는 별도 타입은 폐기**하는 쪽으로
수렴.
**확정 방향**:
- **`Source<T>`가 구조적으로 `State<T>`를 만족(단방향 호환)** — State
자리엔 Source를 넣을 수 있지만 역은 안 됨(Svelte의 `Writable<T> extends
Readable<T>`와 같은 모양). 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<U>`를 반환** — Source
자신을 변형하는 게 아니라, "Source의 State 뷰를 뽑아 그 위에 파이핑"하는
것과 동치. 구현은 metatable `__index` 델리게이션(Source의 메소드
테이블이 State의 메소드 테이블로 폴백)으로 충분 — `Modifier`의 제네릭
`__index` 트릭(`base/modifier-plan.md`)과 같은 패턴이라 로직 중복이
생기지 않음.
- **`RefSource<T>` 같은 별도 타입은 불필요, `Store({defaults})`
내부적으로 `{[key] = Source(default), ...}`나 다름없게 됨.**
`defaults`**선택**(안 줘도 됨, 순수 편의용) — `store.key`는 이미
만들어져 있는 키면 그 Source를 그대로 돌려주지만, **아직 안 만들어진
키면 그 자리에서 `Source(defaults의 해당 값 또는 nil)`을 만들어 저장한
뒤 돌려줌**([정정, 2026-08-07] eager 생성만으로 충분하다고 서술했던
이전 버전은 부정확 — Luau 타입은 런타임에 강제되지 않으므로
`Store<<SomeType>>()`처럼 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<any>`가 아니라 `Source<any>`
반환**하는 것으로 자연히 갱신됨(위 "타입 추론 문제" 절과 연동).
**검증 필요(확정 아님, M0 스파이크 대상)**: `Source<T>``:Compute`
시그니처가 자기 자신(`Source<T>`)과 `State<U>`를 동시에 참조하는 제네릭
메소드라, Luau 솔버가 재귀 타입 조합에서 막히지 않는지 실제로 검증
필요(사용자 우려: "솔버가 종종 죽는다"). 구분해서 볼 것:
- **자기 자신을 가리키는 self 타이핑**(`{ Compute: <U>(self: Source<T>, ...) -> State<U> }`
같은 패턴)은 Luau에서 극히 흔하고 대체로 안전 — 모든 메소드 테이블
클래스가 쓰는 패턴이라 이것 자체가 위험 신호는 아님.
- **진짜 위험한 건 두 제네릭 타입 별칭이 서로를 참조하는 상호 재귀**
(`Source<T>` 정의가 `State<T>`를 참조하고, `State<T>`도 거꾸로
`Source<T>`를 참조하는 경우) — 이게 Luau 솔버가 알려진 대로 취약한
패턴. **`State<T>``Source`를 전혀 참조하지 않도록 먼저 독립적으로
정의하고, `Source<T>``State<T>`를 참조하는 단방향 의존으로 두면**
이 위험한 패턴 자체를 피할 수 있어 보임 — 다만 이것도 추론이라 실제
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<T>`를 직접 반환하는
평범한 레코드 필드(`{key: Source<number>}`)로 타이핑되는데, 레코드
필드는 읽기/쓰기 타입이 같아야 Luau 구조적 타이핑이 깨끗하게 성립함.
`store.key = value`(raw `T` 대입)를 유지하면 읽기(`Source<T>`)/쓰기(`T`)
타입이 갈려 mismatch가 남음 — `store.key:Set(value)`로 통일하면 필드
타입이 항상 `Source<T>`로 대칭적이라 문제 자체가 안 생김(사용자 지적).
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>``T`는 Modifier가 될 수 없음.** Modifier는
정적 flatten으로 dispatch와 완전히 별개인 단계에서 처리되고
(`base/modifier-plan.md`) — Store/State/dispatch 경로엔 애초에
Modifier용 processor가 없음. **[정정, 2026-08-09 세션]** `State<Modifier>`
조합은 "UB, 가능하면 타입 차단"이 아니라 **명시적 `error`로 확정**
(`modifier-plan.md` 7번) — `isModifier` predicate를 `Source:Set()`/
Store 생성 시 eager `Source(default)`/State의 `:Compute` 결과 캐싱
지점에서 확인해 런타임에 직접 막음, 타입 차단은 되면 좋은 보너스일
뿐 유일한 방어선이 아님. **[2026-08-06 후속 세션 추가]** Source가
State를 구조적으로 만족하게 되면서 이 제약은 `Source<Modifier>`(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` 참고.

117
.claude/base/tag-plan.md Normal file
View file

@ -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`).

View file

@ -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번 참고, 강제 사항 아님,
구현 시점에 결정할 정도의 사소한 개선 후보.

View file

@ -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 결과가 그대로인지 확인해볼 것 — 순서가 소스 텍스트가
아니라 오직 "배열/해시 파트 분리"에만 의존한다는 걸 재확인하는 목적.
]]

View file

@ -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 "<None>"
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 대신 선형 탐색 재사용 등록 함수"가 실제로
의도대로 동작하는지의 핵심 확인.
]]

View file

@ -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 "<None>"
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에
추가할 것).
]]

View file

@ -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. 스택 오버플로 없이 전부 정상 종료되는가.
]]

View file

@ -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 온톨로지"
절)은 여기 반영 안 돼 있음, 이 파일은 오직 "전파 알고리즘 자체의
정확성"만 검증 대상.
]]

View file

@ -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 "<None>"
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> = T | typeof(None)`류) 실 Luau
에러 메시지를 보고 판단해볼 것 — 지금 파일은 `any` 캐스팅으로
일단 회피해뒀음, 이 부분은 M7/M8 실제 구현 시 정확한 타입을 찾아야 함.
]]

View file

@ -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는 정확한 타이밍을 보장 안 하므로 완벽한 증거는
아님, 참고 신호 정도로만 볼 것).
]]

View file

@ -0,0 +1,78 @@
--!strict
--[[
검증 대상: Source<T>가 구조적으로 State<T>를 만족하는(self 타이핑 +
State 참조가 섞인 제네릭 :Compute) 설계가 Luau 타입 솔버에서 안전하게
추론되는지 — 실제 실행이 아니라 타입 체크(luau-analyze) 대상.
배경: .claude/base/store-semantics.md "검증 필요(확정 아님, M0 스파이크
대상)" 절, ROADMAP.md M0 2번째 항목.
핵심 우려: State<T>가 거꾸로 Source를 참조하는 "상호 재귀"는 Luau
솔버가 취약한 패턴 — 그래서 아래 State<T>는 Source를 전혀 참조하지
않도록 독립적으로 먼저 정의하고, Source<T>만 State<T>를 단방향으로
참조하게 구성함. 타입은 사용자 선호대로 &(교차)가 아니라 손으로
펼쳐 씀(런타임 구현 델리게이션과는 별개 축이라 상관없음).
실행: `luau-analyze 08-type-source-satisfies-state.luau`
(로컬에 luau-analyze가 없으면 Luau 공식 릴리즈의 CLI 툴체인 설치 필요 —
https://github.com/luau-lang/luau/releases, 또는 lune 배포판에 포함된 것)
기대 결과: 에러 없이 통과하거나, 통과 안 하면 정확히 *어느 줄에서*
*무슨 에러*가 나는지가 다음 결정에 중요한 정보임 — 에러가 나면 그
메시지를 그대로 가져와서 알려줄 것.
]]
-- State<T>는 Source를 절대 참조하지 않음(단방향 의존을 위한 핵심 제약)
export type State<T> = {
Get: (self: State<T>) -> T,
With: (self: State<T>, ...State<any>) -> State<any>,
Compute: <U>(self: State<T>, fn: (T) -> U) -> State<U>,
}
-- Source<T>만 State<T>를 참조(단방향) — self 타이핑(Source<T> 자신을 가리킴)과
-- 바깥 타입 참조(State<U>)가 섞인 제네릭 메소드가 바로 검증 대상
export type Source<T> = {
Get: (self: Source<T>) -> T,
With: (self: Source<T>, ...State<any>) -> State<any>,
Compute: <U>(self: Source<T>, fn: (T) -> U) -> State<U>,
Set: (self: Source<T>, value: T) -> (),
Emit: (self: Source<T>) -> (),
}
-- 1. Source 값을 만드는 흉내 생성자(런타임 구현은 아직 없으므로 타입만 맞추는 더미)
local function fakeSource<T>(default: T): Source<T>
return (nil :: any) :: Source<T>
end
-- 2. State<T>를 요구하는 함수에 Source<T>를 그대로 넘길 수 있는가
-- (구조적 서브타이핑 — "Source가 State를 만족함" 절의 핵심 주장)
local function useAsState<T>(s: State<T>): T
return s:Get()
end
local mySource: Source<number> = fakeSource(0)
local viaSubtype: number = useAsState(mySource) -- 여기가 타입체크 되는지가 핵심
-- 3. Compute 체이닝이 제네릭을 타고 잘 흐르는가(Source -> State<string> -> State<boolean>)
local derived1: State<string> = mySource:Compute(function(n: number): string
return tostring(n)
end)
local derived2: State<boolean> = derived1:Compute(function(s: string): boolean
return #s > 0
end)
-- 4. store.key가 Source<T>를 직접 반환한다는 모델(레코드 필드 읽기/쓰기 대칭)
export type Store = {
-- 실제로는 defaults의 각 키를 Source<...>로 매핑하는 mapped type이 이상적이지만
-- Luau에 mapped type이 없으므로(2026-08 시점) 구체 예시 하나로만 검증
Health: Source<number>,
}
local function useStore(store: Store)
store.Health:Set(100) -- 쓰기
local hp: number = store.Health:Get() -- 읽기 — 같은 필드 타입(Source<number>)으로 대칭
return hp
end
print("이 파일은 luau-analyze로만 의미가 있음 (런타임 실행은 그냥 통과함)")
print(viaSubtype, derived2)

View file

@ -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에 반영할 것.
]]

View file

@ -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 로그를 그대로 복사해서 공유해주면 됨")

View file

@ -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를 씀,
여기선 그 판별 로직 자체가 아니라 "체크 지점 배치가 실제로 동작하는가"만
검증 대상.
]]

View file

@ -0,0 +1,93 @@
--!strict
--[[
검증 대상: `[Attribute<<boolean>> "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<T> — Attribute<<T>>(name)이 반환하는 "타입이 실린 키" 흉내
type SpecialKey<T> = { __attributeKeyBrand: T }
local function Attribute<T>(name: string): SpecialKey<T>
return (nil :: any) :: SpecialKey<T>
end
-- ===== 시도 1: 동질적(homogeneous) 인덱스 시그니처 — 항상 통과해야 함(비교군) =====
-- 이 방식은 "이 테이블의 모든 특수 키가 전부 boolean 값이어야 한다"는
-- 고정된 단일 인스턴스라, 애초에 여러 타입을 섞을 수 없음 — 진짜 검증
-- 대상이 아니라 대조군.
type HomogeneousParams = {
[SpecialKey<boolean>]: boolean,
}
local homo: HomogeneousParams = {
[Attribute("Enabled")] = true, -- 이건 당연히 통과해야 함
}
-- ===== 시도 2: 이질적(heterogeneous) — 한 테이블에 boolean/number Attribute를 섞음 =====
-- 이게 진짜 검증 대상: SpecialKey<T>의 T가 키마다 달라도 값이 그 T로
-- 각각 좁혀지는가? (TypeScript의 mapped/conditional type이 있어야 되는
-- 문제 — Luau에 해당 기능이 없으면 아래 셋 중 하나가 일어날 것으로 예상:
-- (a) 두 번째 대입에서 타입 에러, (b) 값 타입이 조용히 any/union으로
-- 뭉개짐, (c) 테이블 타입 자체를 선언하는 시점에 에러)
local mixedProps: { [SpecialKey<any>]: any } = {} -- 일단 any로 도피한 버전(항상 통과할 것)
mixedProps[Attribute("Enabled")] = true
mixedProps[Attribute("Count")] = 5
-- 진짜 물어볼 질문: 개별 대입 표현식 하나만 놓고 봤을 때, Luau가
-- `Attribute<T>(name)`의 제네릭 인스턴스화 결과로 옆의 값 리터럴 타입을
-- 체크/추론해주는지 — 함수 호출 결과 타입과 그 옆 대입값 사이의 관계는
-- "인덱스 시그니처"가 아니라 그냥 "함수 반환 타입에 맞는 변수 대입"
-- 문제로 좁혀서 아래처럼 직접 테스트:
local function setAttributeTyped<T>(key: SpecialKey<T>, 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<<T>>(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>]: T}`류
제네릭 인덱스 시그니처를 실제로 선언할 수 있는지부터 luau-lsp가
에러를 내는지 먼저 볼 것).
3. 최종적으로 "제네릭 DI 키를 테이블 리터럴 안에서 쓸 때 값 타입이
실제로 좁혀지는지"에 대한 결론이 나오면 attribute-plan.md의
"[실측 필요, M0/M10]" 캐비엇을 그 결과로 갱신할 것 — 안 되는 걸로
확인되면 "정적 체크는 `BooleanAttribute`류 정적 타입 패밀리 쪽만
신뢰 가능"이라는 문서의 fallback 결론이 확정됨.
]]

View file

@ -0,0 +1,135 @@
--!strict
--[[
검증 대상: 2026-08-09 열한 번째 세션(커밋 f198fd9)에서 뒤집힌 결정 —
`isRef`/`isPreRef`가 "서로 배타적인 형제 브랜드"에서 "Source가 State를
만족하는 것과 같은 포함 관계(PreRef가 Ref의 하위 개념)"로 재정정됨.
이전엔 `isRef(preRefInstance) == false`였는데, 지금은
`isRef(preRefInstance) == true`로 바뀜.
이 파일은 두 부분으로 나뉨:
A) 타입 체크 대상 — `PreRef<T>`가 구조적으로 `Ref<T>`를 만족하는지
(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<T> = {
Value: T,
Set: (self: Ref<T>, value: T) -> Ref<T>,
Callback: (self: Ref<T>, fn: (T) -> ()) -> Ref<T>,
Wait: (self: Ref<T>, thread: thread?) -> Ref<T>,
}
-- PreRef는 "Ref 런타임을 재사용하되 브랜드 태그만 다름"이라고 문서가
-- 명시함 — 타입도 필드 구성이 완전히 동일해야 자연스러움(브랜드 차이는
-- 런타임 전용이라 정적 타입엔 안 드러남, 아래서 별도 nominal 표시로만 구분)
export type PreRef<T> = {
Value: T,
Set: (self: PreRef<T>, value: T) -> PreRef<T>,
Callback: (self: PreRef<T>, fn: (T) -> ()) -> PreRef<T>,
Wait: (self: PreRef<T>, thread: thread?) -> PreRef<T>,
}
local function fakePreRef<T>(default: T): PreRef<T>
return (nil :: any) :: PreRef<T>
end
-- 시도: PreRef<T> 값을 Ref<T>가 필요한 자리에 그대로 넘길 수 있는가
local function useAsRef<T>(r: Ref<T>): T
return r.Value
end
local myPreRef: PreRef<number> = 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)`를
같이 확인해야 한다는 요구사항)가 실제로 필요한 조합인지.
]]

View file

@ -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<number>()`(default 생략)를 만들면 실제 런타임 값은 `nil`인데
`T=number`(non-nilable)라고 선언하면 타입과 실제 값이 어긋남 —
특히 `:Callback(fn)`이 등록 즉시 그 시점 값(nil)으로 1회 호출되므로
이 어긋남이 바로 드러남.
시도할 두 가지 설계:
A) 단일 시그니처 `Ref<T>(default: T?): Ref<T>` — default를 항상
optional로 열어둠. 이러면 `Ref<number>()`가 타입 에러 없이
통과해버려서(캐비엇을 막지 못함) 이게 바로 지금 실제로 벌어지고
있는 문제 상황.
B) 오버로드 흉내 — `default: T` 필수 시그니처와 `(): Ref<T?>`
무인자 시그니처 두 개를 함수 타입 교차(`&`)로 합쳐, "생략하면
자동으로 반환 타입이 T?로 바뀐다"를 강제할 수 있는지.
실행: `luau-analyze 14-type-nilable-default-overload.luau` (또는
luau-lsp)
]]
export type Ref<T> = {
Value: T,
Set: (self: Ref<T>, value: T) -> Ref<T>,
}
-- ===== A) 단일 시그니처 — default가 항상 optional(현재 캐비엇이 실제로 벌어지는 형태) =====
local function RefA<T>(default: T?): Ref<T>
return (nil :: any) :: Ref<T>
end
local refA1: Ref<number> = RefA(5) -- 정상 — 통과해야 함
local refA2: Ref<number> = RefA() -- <- 문제의 그 케이스: default 생략, T=number(non-nilable)인데
-- 통과해버리면(기대되는 나쁜 결과) 이게 바로 캐비엇이 막고 싶어하는 구멍 —
-- 런타임엔 .Value가 nil인데 타입은 number라고 거짓말하는 상태가 됨.
-- ===== B) 오버로드 흉내 — 함수 타입 교차로 "생략 시 T?" 강제 시도 =====
type RefCtorOverload = (<T>(default: T) -> Ref<T>) & (<T>() -> Ref<T?>)
local RefB: RefCtorOverload = (nil :: any) :: RefCtorOverload
local refB1: Ref<number> = RefB(5) -- 정상 — 첫 번째 오버로드(T=number)로 통과해야 함
local refB2 = RefB() -- 두 번째 오버로드로 잡혀야 함 — 추론된 타입이 Ref<unknown?> 류가 될 것으로 예상
-- 아래가 진짜 확인 대상: refB2를 non-nilable Ref<number>에 대입하면 막히는가?
local refB2_annotated: Ref<number> = RefB() -- <- 이것도 에러가 나야 "막혔다"고 할 수 있음
-- (T가 추론 컨텍스트에서 number로 잡히면서 동시에 "무인자 오버로드라 T?
-- 여야 한다"는 두 요구가 충돌하는지가 관건 — 충돌해서 에러가 나면 성공,
-- 조용히 number로 통과해버리면 오버로드로도 못 막는다는 뜻)
-- 대조군 — nilable로 명시하면 항상 통과해야 함(오버로드가 정상 케이스는 안 막는지 확인)
local refB3: Ref<number?> = RefB()
print("런타임 실행은 의미 없음 — luau-analyze/luau-lsp 진단만 확인할 것")
print(refA1, refA2, refB1, refB2, refB2_annotated, refB3)
--[[
확인 포인트:
1. A) `refA2 = RefA()` 줄이 에러 없이 통과하는가? (예상: 통과함 —
이게 바로 "타입으로 못 막는" 현재 상태를 보여주는 대조군)
2. B) `refB2_annotated: Ref<number> = RefB()` 줄이 에러가 나는가?
- 에러가 나면: 오버로드 방식으로 실제로 이 캐비엇을 타입 레벨에서
막을 수 있다는 뜻 — base 문서의 "타입으로 막을 수 있으면 막을 것"
을 실제 설계로 채택할 근거가 생김, `Source`/`Ref` 생성자를
이 오버로드 모양으로 다시 쓸 것.
- 에러가 안 나면(조용히 통과): Luau의 제네릭 함수 교차 타입
오버로드가 이 정도로 정교한 추론을 못 한다는 뜻 — 문서의
"안 되면 UB로 경고"가 fallback이 아니라 사실상 유일한 선택지로
확정됨.
3. `refB3`(nilable로 명시한 정상 케이스)는 항상 통과하는가 — 오버로드
자체가 정상 사용까지 막아버리는 부작용은 없는지 확인.
4. 이 결과가 나오면 `bind-system-plan.md`의 해당 캐비엇 절에 "실측
결과"로 반영할 것 — 지금은 "타입으로 막을 수 있으면 막고"라는
조건문으로만 적혀 있어서 결론이 필요함.
]]

118
.claude/luau-test/README.md Normal file
View file

@ -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<T>``State<T>`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `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<<T>> "name"] = value`처럼 제네릭 DI 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) |
| `13-type-ref-preref-subtype.luau` | (A, 타입) `PreRef<T>``Ref<T>`를 구조적으로 만족하는지, (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)`) 자체가
잘못 짜인 것이니 우선순위 높게 알려줄 것.

276
.claude/question.md Normal file
View file

@ -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<T>`, PropertyHandler가 소비)로 재설계해 해소.
범용 State/Source 언랩은 `Dispatch/StoreBind.luau` 하나뿐, Tween 여부
판단은 완전히 별개(`research/tween-plan.md` 전면 재작성, 우선순위1-1
해소).
- **[해소됨, 2026-08-09 세션]** `State<Modifier>`와 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<<boolean>> "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<T>` 래퍼로 구조 확정(2026-08-10), 오버라이드 기본값 Cancel, 옵션 값 모양·옵션 키 이름만 남음 | `research/tween-plan.md` |
| quad2-try(폐기된 이전 시도) 리서치 — OOP 상속/커스텀 파서/Slot 스텁/`Pipe` COW 전부 죽은 접근으로 확인, 반복 조사 금지 | `base/bind-system-plan.md` |
---
전체 순서/우선순위는 루트 `CLAUDE.md`가 최종 소스 — 위 표는 힌트일 뿐 그쪽이
바뀌면 이 문서도 갱신할 것.

View file

@ -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<T>(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<T>` 타입은
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/키 기반
컬렉션 미결 상태).

View file

@ -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의
차별점이라는 근거로 재사용 가능.

View file

@ -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&amp;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 마이그레이션 가이드에서 "이 기능은 애초에 없었다"고 명시해야 함.

View file

@ -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 `<For>`, 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/<repo>/...` 기준(읽기 전용 참고 레포).

View file

@ -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`로 분리해 뼈대만 기록함.

View file

@ -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<Class>` + 자주 쓰는 ~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<T>` 특수 키 후보(미확정 명시 필요)
- 심화: 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<<T>>(key)` + `isState`(→심화: `Get`과 이름을 다르게 한 이유)
- 심화: 정적 merge vs 런타임 pluggable 기각 이유(CSS cascade) / immutable+clone 체이닝 이유(형제 오염 방지) / getter 미채택 이유 / `__index` 런타임 구현 통찰 / Modifier가 핸들러 계층을 모르는 이유 / base/roblox 패키지 경계(Dispatch/Slot vs Handlers/Slot) / Slot 단일 마운트 소유권이 v1/Fusion/Vide 대비 개선인 이유 / retract=폐기 확정 히스토리(portal 검토 후 기각) / **왜 `Apply`가 기본이고 `Overridden`는 최적화 특수 케이스인가**(계산 의존성 있는 조합 vs 독립적 재사용 가능 조각의 병합 — 2026-08-07 다섯 번째 세션, `modifier-plan.md` 9번) / 왜 `Apply`가 clone 대신 mutate하지 않는가(형제 오염 방지가 개별 clone 비용 절감보다 우선)
- 열린 질문(문서화 보류): 여러 Slot이 형제로 섞일 때 순서 보장 — 미확정.
**[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>`의 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<T>` 제네릭 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번 리스트를 심화 섹션
에세이 백로그로 사용.

View file

@ -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(스캐폴딩) 즈음부터 조금씩 곁들일지(네이밍 컨벤션은 특히 초기부터
적용하는 게 나중에 소급 적용하는 것보다 쌀 수 있음 — 검토 필요).

View file

@ -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`에도 취합)
- 구체적 설계는 완전히 미정 — 실사용 패턴이 쌓이기 전까지는 착수하지 않음.
급하지 않음.

View file

@ -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 콘텐츠 소재로 재사용 가능.

View file

@ -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<T>`)로
바뀌어, "일반 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<number>}` 같은 **레코드 필드로서의 dot-access 타이핑**(읽기/쓰기
대칭성 논거의 핵심 전제)은 별개로 M3에 남아있다. 같은 리스크 카테고리인데
M1(스캐폴딩)·M2(디스패치 엔진) 투자가 먼저 이뤄진 뒤에야 검증되는 셈이라,
여기서 걸리면 이미 만든 스캐폴딩/디스패치 타입 시그니처를 다시 손봐야 할
수 있음.
**제안**: M0 항목에 "`store.key`가 실제로 `Source<T>` 레코드 필드로
안전하게 추론되는지"도 같이 넣을 것 — 어차피 같은 스파이크 파일에서 몇 줄
추가로 검증 가능.
### 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<Modifier>` 타입 차단이 Luau에서 실제로 가능한지 검증 계획이 없음
**[대부분 해소, 2026-08-09 세션]** "가능하면 타입 차단, 안 되면 UB로
후퇴"라는 원래 걱정 자체가 무의미해짐 — `State<Modifier>`를 이제
`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<T> satisfies
State<T>` 조합조차 M0 스파이크 대상(솔버가 죽을 수 있음)으로 잡아뒀는데,
이보다 어려운 문제(제네릭 타입 파라미터 배제 제약)인 `State<Modifier>`
차단은 어디에도 검증 대상으로 언급되지 않는다. 실제로 안 되면 "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<Frame>` 등)이라면, `Modifier.Overridden<T>(mod1: Modifier<T>,
mod2: Modifier<T>): Modifier<T>`처럼 같은 `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<Modifier>` 차단)엔 이 클래스별 타입 생성 작업이
전혀 없고, 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<Modifier>` 타입 차단(엔지니어링 비용 감수) vs Ref/Slot이
Modifier 필드에 들어가는 건 UB로 방치 — 같은 문서 안에서 정반대 원칙이
나란히 적용됨.** `base/modifier-plan.md` "Modifier는 핸들러 계층을
모름" 절은 "권장 사용법은 아니지만 막을 이유도 없음 — 방어 로직 없는
UB로 남겨둠"이라 명시적으로 방어를 포기했는데, 바로 옆 7번 절은
`State<Modifier>` 조합을 "UB로 확정, **가능하면 타입 시스템으로 아예 못
넣게 막을 것**"이라며 정반대로 엔지니어링 비용을 들여 방어하기로 했다.
두 결정 다 나름의 근거(후자는 "State에 담기면 재-flatten이 필요해져서
정적 merge 전제와 정면 충돌"이라 더 위험하다는 논리로 보임)가 있어
보이지만, 문서 어디에도 "왜 이 경우엔 원칙에서 예외로 처리하는가"를
명시적으로 인정/정당화하지 않고 그냥 나란히 적혀 있다. 위 2-2 항목
(Luau에서 실제 차단 가능한지)과 묶어서 같이 정리할 문제.
**[완전 해소, 2026-08-09 세션]** 양쪽 다 이제 같은 메커니즘 —
Ref/Slot(+Observer/Effect/Modifier 자기 자신)이 Modifier *필드*로
들어오는 것도, `State<Modifier>`처럼 Modifier가 State/Source *값*으로
담기는 것도 전부 `Brand` 기반 `isX` predicate로 런타임에 즉시
`error`(`base/modifier-plan.md` 4번/7번 절 정정, `base/
store-semantics.md` "따름정리" 절). 더 이상 "한쪽만 방어" 비대칭이
아님 — 남은 차이는 `State<Modifier>` 쪽에 "되면 좋은 보너스"로
타입 차단을 추가 시도해볼 여지가 있다는 것뿐(위 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는 반영 완료, 위
항목 참고).
- **나머지**: 해당 마일스톤 착수 시점에 이 문서를 다시 열어 관련 항목만
확인하면 됨 — 지금 전부 결정할 필요는 없음.

View file

@ -0,0 +1,289 @@
# Tween / 애니메이션 플러깅 (구조 재확정 — 값-레벨 `Tween<T>` 래퍼, 옵션 값 모양만 남음)
**상태**: research — **2026-08-10 세션에서 구조 전체가 재설계됨.** 기존
"`v`가 Store인 아무 `k`나 잡는 우선순위 최상위 Dispatch 핸들러" 모델은
`research/pre-implementation-audit.md` 1-1이 지적한 구조적 모호함("애니메이션
없는 일반 반응형 프로퍼티 바인딩도 결국 이름이 Tween인 파일을 거쳐가는가")을
명확히 답하지 못했음 — 대체된 새 모델(`Tween<T>`를 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<T>` 값-레벨 래퍼, 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<T>
```
`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<T>`를 받아야 하나(내부에 자체 Observer를
걸어 값이 바뀔 때마다 트윈을 재시작) 검토했으나 **불필요로 확정** — 이미
바깥 `:Compute`가 소스 State 변경마다 새 `Tween{Value=v,...}` 테이블을
통째로 재생성해 StoreBind 재귀 재-dispatch 경로를 타므로, `Tween`
내부에 또 다른 반응 경로를 만들 이유가 없음. "같은 일 하는 두 번째 경로를
만들지 않는다"는 이 프로젝트가 Effect의 deps/Ref의 대기 경로 등에서 이미
여러 번 적용한 원칙과 정확히 같은 결. **`Tween<T> = {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<T>`
였든 무관), 지금은 활성 엔진 Tween 없음.
- **실제 엔진 `TweenBase` 인스턴스** — 지금 애니메이션이 진행 중, 새 값을
처리하기 전에 먼저 정리해야 함.
**분기**:
1. **`prev == nil`(첫 세팅)** — `realv``Tween<T>`든 plain이든 무관하게
**애니메이션 없이 즉시 `Value`(또는 plain 값)로 세팅**, 슬롯엔 `true`
저장. 엔진 기본값(예: Frame 기본 `Position`)에서 목표값으로 날아오는
"첫 마운트 진입 애니메이션" 버그를 이걸로 방지.
2. **`prev == true`(세팅된 적 있음, 활성 트윈 없음)**:
- `realv`가 plain 값 → 즉시 세팅, 슬롯은 `true` 유지.
- `realv``Tween<T>` → 이제 정상적으로 애니메이션 시작(현재 인스턴스
프로퍼티 값에서 자연스럽게 출발), 슬롯에 새로 만든 엔진 Tween 객체
저장.
3. **`prev`가 엔진 Tween 객체(활성 트윈 있음)**:
- **먼저 override 정책(기본 Cancel, 아래 절)에 따라 이전 트윈을 정리 —
반드시 그 정리가 끝난 뒤에 새 값을 세팅한다.** 순서가 뒤바뀌면
이전 트윈의 다음 인터폴레이션 프레임이 방금 세팅한 값을 덮어쓸
위험이 있음(엔진 트윈은 비동기로 계속 프로퍼티를 갱신 중이므로).
- 정리 후: `realv`가 plain 값이면 (정리 결과로 프로퍼티에 남은 현재
값 위에) 즉시 덮어쓰기 + 슬롯 `true`. `realv``Tween<T>`면 (같은
현재 값에서) 새 트윈 시작 + 슬롯을 새 엔진 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<T>` — Modifier/State/Source에 새 타입 기계 불필요
지금 프로퍼티류 필드가 열려 있는 자리(Modifier setter, Ref, Store/Source
필드)는 전부 `T | State<T>` 모양 하나로 통일돼 있음. 여기서 "이 필드의
`T`" 자체를 `T' = T | Tween<T>`로 치환하면 자동으로 `T | Tween<T> |
State<T | Tween<T>>`가 나옴 — Modifier/State/Source/StoreBind 코드엔
`Tween` 인지 로직을 전혀 안 넣어도 됨(StoreBind는 원래도 페이로드 타입에
무관하게 `isState`만 보고 언랩하는 opaque한 구조였음). `Tween<T>`를 실제로
해석하는 코드는 여전히 PropertyHandler 하나에만 존재.
**핸들러 계층 UB 체크와도 안 부딪힘** — `Tween<T>``Ref`/`Observer`/
`Slot`류처럼 `process`/`retract`를 가진 dispatch 참가자가 아니라 `None`/
`Tag`처럼 순수 raw 데이터 값(별도 `TweenTag` Brand)이라, Modifier 필드/
`State<Modifier>`가 막는 "핸들러 계층 값" 규칙(`base/modifier-plan.md`)에
안 걸림 — 그 문서가 원래 Tween을 Slot/Tag/Attribute와 같은 "dispatch
참가자" 그룹으로 분류해뒀던 건 부정확했던 것으로 이번에 정정(아래
"패키지 경계" 절 참고).
## `useTween`(트윈 우회) — 해소됨, 새 옵션 필드 불필요
이전엔 `Tween{useTween=state<boolean>}`처럼 `Tween` 생성자 안에 별도
옵션 필드를 두는 방향으로 열려 있었으나, 값-레벨 래퍼 모델에선 **이미
있는 `state:Apply(factory)`/`:Compute`만으로 공짜로 풀림** — 새 필드
불필요:
```lua
-- reduceMotion: State<boolean>
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<boolean>})`처럼 조건과 옵션을 하나의 테이블로 합치는
안도 검토 가치 있음 — 확정 아님, M11에서 정리). 핵심은 **base
프리미티브가 아니라는 것** — `Tween<T>` 값 타입/`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 억제 동작과의 상충을
같이 풀어야 함.

View file

@ -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 규칙들)이 최선의 추정치.

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.claude/initreq

2698
CLAUDE.md Normal file

File diff suppressed because it is too large Load diff

65
HUMAN_TODO.md Normal file
View file

@ -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)

397
ROADMAP.md Normal file
View file

@ -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<U>(self:
Source<T>, ...) -> State<U>`류, self 타이핑 + State 참조 혼합)이
Luau 솔버에서 안전하게 추론되는지 확인(2026-08-06 세 번째 세션,
`base/store-semantics.md` "Source가 State를 만족함" 절 — `State<T>`
`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<number>)`/
`Dispatch.setOffsetSource(inst,i,offset:Source<number>|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<T>) -> 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<number>`
이때 확정(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<T>()` 제네릭화, 요소
타입 제약 확정** — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔
실제 마운트 가능한 `T`만), 핸들러 계층 값(Ref/PreRef/Observer/
Effect/Modifier)은 self-ref 컨텍스트가 없어 의미 불성립이라 즉시
error(`Modifier` 필드와 같은 판별 메커니즘 재사용) — `D.InstSlot =
Slot<<Instance>>`가 quad-roblox의 사실상 유일한 Slot 타입.
- [ ] `Slot:List(data, updateFn, keyFn?)` — 키 기반 동적 컬렉션 재조정,
`keyFn` 생략 시 index를 그대로 key로 사용(중간 삽입/삭제 시 identity
보존 안 됨, 캐스케이드 갱신 — 흔한 업계 관행과 같은 트레이드오프).
`updateFn<UD=any>(item, index, userdata: UD?, prev: T?): (T|nil, UD?)`
**매 reconcile 사이클마다 호출**(filter/toggle 지원 — 첫 반환값
`nil` 시 실제 파괴, `Visible` 토글 아님, 200+ 항목에서 lazy하지 않은
문제 회피), `prev` 그대로 반환하면 저비용 재사용 경로. `:List`
`Source`를 대신 안 만듦 — item/index를 반응형으로 감쌀지는
`updateFn``userdata`에 직접 관리(반환값 두 개는 서로 독립,
`result``nil`이어도 `userdata`는 명시적으로 반환 안 하는 한 안
지워짐). 정리 루프는 `mounted`가 아니라 직전 사이클 `keyIndex`
전체를 순회해야 함(`userdata`만 살아있는 채로 key가 완전히 사라지는
케이스 커버). `userdata = userdata or {}` lazy-init 패턴이 Luau
제네릭에서 잘 좁혀지는지 실측 필요. **`userdata`는 GC-native 값만
허용, `:Subscribe()`한 Observer류 명시적 cleanup 필요한 값은 UB** —
`item`을 nilable로 바꿔 최종 제거 시 정리 훅을 한 번 더 부르는 안은
기각(Slot 부모 자체가 Destroy되는 경로에선 이 훅이 전혀 안 불려서
절반만 동작, `retract`가 Destroy 시 안 불리는 것과 같은 이유).
(2026-08-09 세 번째 세션 확정,
`base/slot-plan.md` "`Slot:List(...)`" 절) 구현.
**`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<Modifier>` 조합에 `isModifier` 기반 명시적 error 적용
(`modifier-plan.md` 7번, 2026-08-09 세션 확정) — 타입 차단은
되면 좋은 보너스로 선택 검증(필수 아님)
- [ ] `:Apply(factory)` 팩토리 함수 체이닝(`modifier-plan.md` 8번, 예약 키
`Apply`가 제네릭 `__index` 필드 setter와 안 겹치는지 확인)
- [ ] `:Peek<<T>>(key): T|State<T>|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<T>` 치환 반영(타입 생성
스크립트가 `Position: UDim2` 자리를 `UDim2 | Tween<UDim2>`로 만들면
끝, 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<T>` 래퍼 모델로 전환 — 상세는 `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만이면 급하지 않음

2
SAFETY.md Normal file
View file

@ -0,0 +1,2 @@
- Git 레포지토에리 대해 접근을 주지 마세요: 모델이 어디로 튈 지 모르기에, github 에는 작업하지 않음. 그래서 모델의 작업 공간은 오직 git.qwreey.moe(예를 들어) 에 제한적 계정으로 계정을 생성해 주는것으로 사용.
- Code-docker 활용: 컨테이너 환경으로 한번 격리하여 사용할 수 있도록 유도. studio 환경은 메인 계정이 아닌 다른 계정을 사용하여야함