Merge remote-tracking branch 'origin/quad2'
This commit is contained in:
commit
113b160b81
64 changed files with 14775 additions and 0 deletions
92
.claude/README.md
Normal file
92
.claude/README.md
Normal 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` — 위 문서들로 나누기 전의 원본, 참고용 백업이니 그대로 둘 것
|
||||
39
.claude/archive/agent-mistake.md
Normal file
39
.claude/archive/agent-mistake.md
Normal 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`가 둘 다 존재, 전자는 집합 멤버십, 후자는 단순 항등)이
|
||||
최종 소스.
|
||||
63
.claude/archive/batch-rejected.md
Normal file
63
.claude/archive/batch-rejected.md
Normal 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`에서 "콜스택/코루틴 스코프로 상태를
|
||||
표현하려던 시도가 왜 항상 위험한가"의 구체 사례로 쓰기 좋음.
|
||||
66
.claude/archive/context-rejected.md
Normal file
66
.claude/archive/context-rejected.md
Normal 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` 참고).
|
||||
22
.claude/archive/debug-channel-replicatedstorage-rejected.md
Normal file
22
.claude/archive/debug-channel-replicatedstorage-rejected.md
Normal 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`에 둬야
|
||||
할 이유도 애초에 없었음.
|
||||
26
.claude/archive/keyed-collection-state-method-rejected.md
Normal file
26
.claude/archive/keyed-collection-state-method-rejected.md
Normal 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 파생 데이터" 원칙 자체가 틀린 게 아니라, 이 프리미티브가
|
||||
그 분류 어디에도 깔끔히 안 맞는 케이스였다는 게 재검토 결과.
|
||||
46
.claude/archive/modifier-apply-mutable-rejected.md
Normal file
46
.claude/archive/modifier-apply-mutable-rejected.md
Normal 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하는 지금 방식이 버그 클래스를 균일하게
|
||||
없애는 유일한 방법 — 확정 유지.
|
||||
41
.claude/archive/observer-cleanup-contract-rejected.md
Normal file
41
.claude/archive/observer-cleanup-contract-rejected.md
Normal 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` 참고).
|
||||
38
.claude/archive/onchange-per-property-codegen-rejected.md
Normal file
38
.claude/archive/onchange-per-property-codegen-rejected.md
Normal 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`만 유별나게 정적
|
||||
타입 안전성을 추구할 근거가 약함.
|
||||
90
.claude/archive/quad2-try-research-findings-rejected.md
Normal file
90
.claude/archive/quad2-try-research-findings-rejected.md
Normal 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` 체이닝으로
|
||||
충분히 달성된 것으로 최종 판단.
|
||||
74
.claude/archive/ref-phase-option-reversed.md
Normal file
74
.claude/archive/ref-phase-option-reversed.md
Normal 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 이벤트의 동기 발화)를 만나 타입
|
||||
분리로 귀결된 사례"로 쓰기 좋음.
|
||||
73
.claude/archive/store-source-proxy-reversed.md
Normal file
73
.claude/archive/store-source-proxy-reversed.md
Normal 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`에서 "설계가 왜 이렇게
|
||||
반전됐는가" 사례로 쓰기 좋음.
|
||||
45
.claude/archive/tag-hash-key-model-reversed.md
Normal file
45
.claude/archive/tag-hash-key-model-reversed.md
Normal 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 전파) 설계로
|
||||
이어짐.
|
||||
68
.claude/archive/tween-special-bind-key-reversed.md
Normal file
68
.claude/archive/tween-special-bind-key-reversed.md
Normal 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 소재).
|
||||
44
.claude/archive/ui-shorthand-roundsize-dropped.md
Normal file
44
.claude/archive/ui-shorthand-roundsize-dropped.md
Normal 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해야 하는 구조적 번거로움)는 서로 다른 축이라,
|
||||
하나가 해소됐다고 다른 하나도 자동으로 해소되는 게 아님.
|
||||
278
.claude/base/architecture.md
Normal file
278
.claude/base/architecture.md
Normal 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`).
|
||||
79
.claude/base/attribute-plan.md
Normal file
79
.claude/base/attribute-plan.md
Normal 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 절충) — 위 "문제" 절 참고, 다음 세션 사용자 판단 필요.
|
||||
2203
.claude/base/bind-system-plan.md
Normal file
2203
.claude/base/bind-system-plan.md
Normal file
File diff suppressed because it is too large
Load diff
100
.claude/base/blocker-plan.md
Normal file
100
.claude/base/blocker-plan.md
Normal 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`와 나란히 인용).
|
||||
323
.claude/base/component-composition-plan.md
Normal file
323
.claude/base/component-composition-plan.md
Normal 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
166
.claude/base/effect-plan.md
Normal 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번의 관련 항목도 해소됨으로 갱신 완료.
|
||||
308
.claude/base/lifecycle-pattern.md
Normal file
308
.claude/base/lifecycle-pattern.md
Normal 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" 표기가 남은
|
||||
문서가 있을 수 있으며, 그 확인/정리는 진행 중.
|
||||
586
.claude/base/modifier-plan.md
Normal file
586
.claude/base/modifier-plan.md
Normal 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` 용어 정리 라운드)에서 더 나은 대안 없어
|
||||
현재 이름 그대로 최종 확정됨.
|
||||
132
.claude/base/module-lifecycle-plan.md
Normal file
132
.claude/base/module-lifecycle-plan.md
Normal 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는 프리미티브가 아니다" 절.
|
||||
60
.claude/base/onchange-plan.md
Normal file
60
.claude/base/onchange-plan.md
Normal 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(값을 직접 받음)보다 이벤트에 더 가깝기 때문 — 카테고리가
|
||||
헷갈리지 않도록 표로 명확히 구분해둠.
|
||||
53
.claude/base/purity-and-effects-plan.md
Normal file
53
.claude/base/purity-and-effects-plan.md
Normal 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 각주 수준) 배치할지 —
|
||||
급하지 않음, 실제 문서 작성 단계에서 결정.
|
||||
97
.claude/base/relate-plan.md
Normal file
97
.claude/base/relate-plan.md
Normal 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
697
.claude/base/slot-plan.md
Normal 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`에 백로그로만 반영.
|
||||
323
.claude/base/store-semantics.md
Normal file
323
.claude/base/store-semantics.md
Normal 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
117
.claude/base/tag-plan.md
Normal 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`).
|
||||
139
.claude/base/ui-shorthand-plan.md
Normal file
139
.claude/base/ui-shorthand-plan.md
Normal 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번 참고, 강제 사항 아님,
|
||||
구현 시점에 결정할 정도의 사소한 개선 후보.
|
||||
60
.claude/luau-test/01-two-pass-array-hash-order.luau
Normal file
60
.claude/luau-test/01-two-pass-array-hash-order.luau
Normal 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 결과가 그대로인지 확인해볼 것 — 순서가 소스 텍스트가
|
||||
아니라 오직 "배열/해시 파트 분리"에만 의존한다는 걸 재확인하는 목적.
|
||||
]]
|
||||
172
.claude/luau-test/02-none-sentinel-vs-nil-holes.luau
Normal file
172
.claude/luau-test/02-none-sentinel-vs-nil-holes.luau
Normal 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 대신 선형 탐색 재사용 등록 함수"가 실제로
|
||||
의도대로 동작하는지의 핵심 확인.
|
||||
]]
|
||||
168
.claude/luau-test/03-recursive-store-bind-dispatch.luau
Normal file
168
.claude/luau-test/03-recursive-store-bind-dispatch.luau
Normal 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에
|
||||
추가할 것).
|
||||
]]
|
||||
201
.claude/luau-test/04-dispatch-chain-retractUnder.luau
Normal file
201
.claude/luau-test/04-dispatch-chain-retractUnder.luau
Normal 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. 스택 오버플로 없이 전부 정상 종료되는가.
|
||||
]]
|
||||
157
.claude/luau-test/05-store-state-diamond-propagation.luau
Normal file
157
.claude/luau-test/05-store-state-diamond-propagation.luau
Normal 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 온톨로지"
|
||||
절)은 여기 반영 안 돼 있음, 이 파일은 오직 "전파 알고리즘 자체의
|
||||
정확성"만 검증 대상.
|
||||
]]
|
||||
87
.claude/luau-test/06-component-boundary-nil-hole-props.luau
Normal file
87
.claude/luau-test/06-component-boundary-nil-hole-props.luau
Normal 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 실제 구현 시 정확한 타입을 찾아야 함.
|
||||
]]
|
||||
135
.claude/luau-test/07-relate-weak-table-gc.luau
Normal file
135
.claude/luau-test/07-relate-weak-table-gc.luau
Normal 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는 정확한 타이밍을 보장 안 하므로 완벽한 증거는
|
||||
아님, 참고 신호 정도로만 볼 것).
|
||||
]]
|
||||
78
.claude/luau-test/08-type-source-satisfies-state.luau
Normal file
78
.claude/luau-test/08-type-source-satisfies-state.luau
Normal 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)
|
||||
73
.claude/luau-test/09-type-modifier-overridden-subtype.luau
Normal file
73
.claude/luau-test/09-type-modifier-overridden-subtype.luau
Normal 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에 반영할 것.
|
||||
]]
|
||||
206
.claude/luau-test/10-roblox-studio-checks.server.luau
Normal file
206
.claude/luau-test/10-roblox-studio-checks.server.luau
Normal 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 로그를 그대로 복사해서 공유해주면 됨")
|
||||
302
.claude/luau-test/11-modifier-illegal-value-error.luau
Normal file
302
.claude/luau-test/11-modifier-illegal-value-error.luau
Normal 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를 씀,
|
||||
여기선 그 판별 로직 자체가 아니라 "체크 지점 배치가 실제로 동작하는가"만
|
||||
검증 대상.
|
||||
]]
|
||||
|
|
@ -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 결론이 확정됨.
|
||||
]]
|
||||
135
.claude/luau-test/13-type-ref-preref-subtype.luau
Normal file
135
.claude/luau-test/13-type-ref-preref-subtype.luau
Normal 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)`를
|
||||
같이 확인해야 한다는 요구사항)가 실제로 필요한 조합인지.
|
||||
]]
|
||||
84
.claude/luau-test/14-type-nilable-default-overload.luau
Normal file
84
.claude/luau-test/14-type-nilable-default-overload.luau
Normal 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
118
.claude/luau-test/README.md
Normal 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
276
.claude/question.md
Normal 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`가 최종 소스 — 위 표는 힌트일 뿐 그쪽이
|
||||
바뀌면 이 문서도 갱신할 것.
|
||||
135
.claude/reference/comparison-charm.md
Normal file
135
.claude/reference/comparison-charm.md
Normal 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/키 기반
|
||||
컬렉션 미결 상태).
|
||||
72
.claude/reference/comparison-fusion-vide.md
Normal file
72
.claude/reference/comparison-fusion-vide.md
Normal 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의
|
||||
차별점이라는 근거로 재사용 가능.
|
||||
89
.claude/reference/quad-v1-architecture.md
Normal file
89
.claude/reference/quad-v1-architecture.md
Normal 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&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 마이그레이션 가이드에서 "이 기능은 애초에 없었다"고 명시해야 함.
|
||||
125
.claude/research/additional-primitives-plan.md
Normal file
125
.claude/research/additional-primitives-plan.md
Normal 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>/...` 기준(읽기 전용 참고 레포).
|
||||
528
.claude/research/debug-tooling-plan.md
Normal file
528
.claude/research/debug-tooling-plan.md
Normal 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`로 분리해 뼈대만 기록함.
|
||||
261
.claude/research/documentation-content-map.md
Normal file
261
.claude/research/documentation-content-map.md
Normal 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번 리스트를 심화 섹션
|
||||
에세이 백로그로 사용.
|
||||
182
.claude/research/documentation-plan.md
Normal file
182
.claude/research/documentation-plan.md
Normal 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(스캐폴딩) 즈음부터 조금씩 곁들일지(네이밍 컨벤션은 특히 초기부터
|
||||
적용하는 게 나중에 소급 적용하는 것보다 쌀 수 있음 — 검토 필요).
|
||||
59
.claude/research/existing-instance-bind-plan.md
Normal file
59
.claude/research/existing-instance-bind-plan.md
Normal 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`에도 취합)
|
||||
|
||||
- 구체적 설계는 완전히 미정 — 실사용 패턴이 쌓이기 전까지는 착수하지 않음.
|
||||
급하지 않음.
|
||||
110
.claude/research/framework-comparison-findings.md
Normal file
110
.claude/research/framework-comparison-findings.md
Normal 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 콘텐츠 소재로 재사용 가능.
|
||||
660
.claude/research/pre-implementation-audit.md
Normal file
660
.claude/research/pre-implementation-audit.md
Normal 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는 반영 완료, 위
|
||||
항목 참고).
|
||||
- **나머지**: 해당 마일스톤 착수 시점에 이 문서를 다시 열어 관련 항목만
|
||||
확인하면 됨 — 지금 전부 결정할 필요는 없음.
|
||||
289
.claude/research/tween-plan.md
Normal file
289
.claude/research/tween-plan.md
Normal 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 억제 동작과의 상충을
|
||||
같이 풀어야 함.
|
||||
269
.claude/research/v1-compat-plan.md
Normal file
269
.claude/research/v1-compat-plan.md
Normal 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
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.claude/initreq
|
||||
65
HUMAN_TODO.md
Normal file
65
HUMAN_TODO.md
Normal 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
397
ROADMAP.md
Normal 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
2
SAFETY.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
- Git 레포지토에리 대해 접근을 주지 마세요: 모델이 어디로 튈 지 모르기에, github 에는 작업하지 않음. 그래서 모델의 작업 공간은 오직 git.qwreey.moe(예를 들어) 에 제한적 계정으로 계정을 생성해 주는것으로 사용.
|
||||
- Code-docker 활용: 컨테이너 환경으로 한번 격리하여 사용할 수 있도록 유도. studio 환경은 메인 계정이 아닌 다른 계정을 사용하여야함
|
||||
Loading…
Reference in a new issue